Compare commits

..

18 Commits

Author SHA1 Message Date
31090fec81 chore(gui): 版本号 1.9.0 → 1.9.1
v1.9.1 收 v1.9.0 之后发现并修复的四个 UI/统计缺陷:

- per-key 编辑器画布画进后台标签页的全局画布,弹窗空白且污染全局链
- per-key 保存成功却不关弹窗,toast 文案还把已保存说成会失败
- 首页日/周/月视图请求记录表恒为空,滚动加载新旧行错位
- per-key 弹窗复用共享 #modal-wrap id、编辑即落盘(取消撤不回)、
  无独立链时画布填满全部 226 个模型

v1.9.0 的 tag 与附件保持不动,作为发布时点的凭据。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 11:22:17 +08:00
f9cd0770a9 fix(ui): per-key 弹窗复用共享弹窗ID、编辑即落盘、空画布显示226个模型
截图看渲染 + 交互实测后发现的四个问题,都出在我自己新写的代码里。

一、弹窗用了全站共享的 #modal-wrap。
这个 id 是 App 内所有弹窗共用的,且允许多个同时存在("添加档位"选择器就
开在本弹窗之上)。既有代码靠 .closest() 或 closeTopModal() 从按钮往上找,
是有效的——但本弹窗还要从画布侧被查(sortCanvasEl / keyAutoClose /
keyAutoInherit),那里没有按钮可走。实测点"添加档位"后 DOM 里同时有
modal-wrap=1 和 key-auto-modal=1,getElementById 会命中文档里的第一个,
也就是用户没在看的那一个:取消关掉错误的框,画布也可能解析到选择器上。
改为独立 id #key-auto-modal。

二、编辑即落盘,保存/取消形同虚设。
scrAddFromForm / sortScopeSave / scrDelSlot 等 6 处编辑后立刻调
persistAuto()。全局页这样是有意的(改动立即生效),但它和自己页面上那句
"点击保存排序后生效"的提示自相矛盾;在 per-key 弹窗里更严重——加一个槽位
就已经写进那个用户的链了,取消也无法撤销。收敛到 afterChainEdit():
有 scope 时只提示、不落盘,保存是唯一写入口。

三、无独立链时画布显示全部 226 个模型。
注释写的是"显示空画布",实现却是 buildLanes(idx, [], 'chat')——空 rules
会走 discovery 兜底,把每个源的模型全填进去。用户打开弹窗看到 226 个色块,
会以为这些就是这个密钥的链。改为空 rules 直接给 []。

四、"使用全局链"提示不随画布变化。
加完槽位后仍显示"该密钥使用全局 AUTO 链",与旁边的色块直接矛盾。改为由
afterChainEdit 触发、按画布是否有内容决定去留。

另:批量替换时正则的缩进假设错了一次,把 afterChainEdit 自己改成了自调用
(无限递归),node --check 抓到后修掉。这类批量替换必须先过语法检查。

判据两条 + 变异:弹窗回到共享 id → 判红;去掉 scope 分支 → 判红。
CDP 实测:弹窗 id 唯一、添加后画布 0→1 块、添加后服务端 inherits=true
(未落盘)、取消后仍 inherits=true、选择器与弹窗并存互不干扰。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 11:20:29 +08:00
6311913812 fix(stats): 日/周/月视图请求记录表空白,滚动加载顺序错乱
首页记录表由 paintRecords(st.records) 渲染,而 /api/stats 的 period 分支
手写了一份响应 map,压根没有 records 字段 —— 只有 all 分支走 Snapshot()
才带得上。实测切到日/周/月,记录表恒为空。

两处数据源也不一致:

| 端点 | 数据源 | 排序 |
|---|---|---|
| /api/stats(all)| 内存ring(几百条)| oldest-first |
| /api/stats/records(翻页)| 审计文件(全部)| newest-first |
| /api/stats(period)| 无 | 无 |

前端靠 paintRecords 里一句 .reverse() 把 all 的 oldest-first 转成 newest-first,
翻页端点则本来就是 newest-first。首屏和翻页方向相反,滚动时新旧行会错位
拼接;且 all 分支不带 next_cursor,表永远停在第一屏。

修法:两个分支都改用 AuditPage(与翻页端点同一份代码路径),记录方向统一
由它决定,前端去掉 .reverse()。附带好处:记录深度不再受 maxRecs 限制,
"全部"视图真的能翻到底。

判据三条,变异验证:period 分支返回 nil records → 前两条判红;all 分支不
覆盖 records → "no next_cursor"判红。变异过程中我自己也犯了两次错:第一版
判据硬编码 newest-first,而AuditPage 在单文件(整块读完)与多文件(分块反向
读)下方向不同,改为断言两处方向一致;第三版想测 admin 过滤,但
newTestGateway 的 sk-test 不是 admin,改为测真正的越权边界(非admin 不能
用 ?key= 看别人的记录)。

CDP 实测:四个视图各 100 行、时间从新到旧,滚动加载 100→200 行且新末行
时间更旧(顺序衔接正确)。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 10:32:51 +08:00
aa003e84d3 fix(ui): per-key 编辑器画布画错容器、保存成功却不关弹窗
CDP 实测发现两个真 bug,都是复用画布时引入的。

一、画布画进了后台标签页。sortCanvasEl() 原本按文档顺序找,先命中
#scr-canvas(全局优先级页的画布)。而弹窗是挂在设置页之上的,两个画布
同时存在于 DOM —— 于是 per-key 的 36 条泳道被画进隐藏的全局画布,弹窗
里空空如也,回���全局页还会发现自己的链被覆盖了。

改为按 sortState.scope 解析:有 scope 就用 #key-auto-canvas,否则用
#scr-canvas。

二、保存成功却弹窗不关。!j.slots 分支里我写了 return,跳过了
keyAutoClose。写确实成功了(服务端已存),但用户看到弹窗还开着、按钮
还是禁用状态,会反复点保存。而且那条 toast 文案写成"保存后 AUTO 请求
会失败",读起来像保存失败了 —— 一并改成"已保存,但槽位无法解析…"。

判据两条,变异验证:恢复按文档顺序查找 → 第一条判红;!slots 分支加
return → 第二条判红。第二条最初只查 keyAutoClose() 是否存在,变异加了
return 仍然通过,属于判据漏放,已改为扫描 try 块内 close 调用之前的
所有 return 语句。

CDP 复测(先开全局页制造干扰,再开 per-key 弹窗):
- 弹窗画布 36 泳道,全局画布保持 4 泳道未被污染
- 保存 gpt-5.2@xinjianya 后弹窗关闭、服务端存储正确、scope 清空
- 重开弹窗读回 [["gpt-5.2@xinjianya"]]

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 10:32:51 +08:00
28149bdd1a merge: release/v1.7.x → main(per-key AUTO 链 + GUI 插件管理 + UI 复用)
- 22decf2 feat(keys): 每个密钥可配独立 AUTO 链,管理员代配
- c66e1b5 test(keys): per-key AUTO 链的并发与压力验证
- 9dc2b64 chore(gui): 版本号 1.8.0 → 1.9.0
- 13e2397 fix(ui): per-key 链复用全局 AUTO 画布;统计周期显示实际窗口
- ed9baae feat(gui): 插件面板补齐安装/编辑/删除,on_disk 报告页面

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 08:50:55 +08:00
0cd1cd51f6 merge: release/v1.7.x → main(per-key AUTO 链 + 部署 v1.9.0)
- 22decf2 feat(keys): 每个密钥可配独立 AUTO 链,管理员代配
- c66e1b5 test(keys): per-key AUTO 链的并发与压力验证
- 9dc2b64 chore(gui): 版本号 1.8.0 → 1.9.0

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 08:15:00 +08:00
4b07be67b4 Merge commit 'ba01da9' into HEAD 2026-10-03 00:16:21 +08:00
913974bb6c Merge commit 'c8edea2' into HEAD 2026-10-02 20:16:18 +08:00
ffb2b8f2f2 Merge release/v1.7.x into main for v1.8.0
main had fallen a full feature generation behind: 13739 lines and 29 files
that main did not contain at all (the whole Lua plugin mechanism, the billing
package, stats_period, plugins_api, docs/plugins.md). v1.7.x contains
everything main has and nothing main lacks, so this is a one-way merge rather
than two divergent lines.

Eight conflicts, each read before resolving — the script that does it lives at
.probe/resolve_merge_conflicts.py and aborts rather than guess:

* cmd/gui/package.json, cmd/gui/package-lock.json — main already carries 1.8.0
  (4b37e1a); the release line carries 1.7.6. Kept 1.8.0. Taking 1.7.6 would
  ship tag v1.8.0 next to a manifest claiming 1.7.6.
* packaging/config.example.yaml (2 hunks) — release side is a superset both
  times: it documents the `auto:` field, and it replaces the older one-line
  runtime_file note with a paragraph that also states where templates and
  deleted-markers live and warns that the AUTO chain, gateway keys and sources
  are in config.yaml. HEAD's wording is strictly less.
* internal/config/config.go, internal/core/core.go, internal/gateway/api.go,
  internal/gateway/apiv1.go, internal/gateway/stats.go (6 hunks) — HEAD is
  zero-length in every one and the release side is the new code: BillingDSL
  types, applyBillingDSL, the pointer-semantics source upsert, the
  optional_fields doc string, the AUTO chain-walk field. The resolver asserts
  the empty-HEAD property instead of assuming it.

Verified before committing: no residual conflict markers, both manifests read
1.8.0, `go build -tags luajit ./...` clean, `go test -tags luajit ./...` all nine
packages pass.
2026-10-02 19:38:42 +08:00
1fe379f630 fix(gui): 缓存解封结果,否则每个请求都要 spawn 一个进程
The auth rule calls readAdminKey() on every outbound request so injection
never depends on ordering. Once the sealed-config path shells out to the
core, that turns each request into a process spawn: 200 simulated requests
took 1012ms and launched 200 cores.

Cache the unsealed key against config.yaml's mtime. Editing the config still
invalidates it, which is what the auth rule actually needs -- the port
rewrite, the first write, and a user edit all change mtime. Measured: 200
requests now cost 10ms and one spawn.

Only a successful unseal is cached. Caching a failure would pin an empty key
until the config next changes, turning a momentary spawn error into a locked
out user.
2026-10-01 19:23:20 +08:00
b03b12148f fix(gui): 桌面版被自己的密钥封存挡住登录
The desktop build authenticates the embedded core by reading the admin key
out of config.yaml with a regex and injecting it as a gw_key cookie. The core
seals credentials at rest (enc:v1:...), so from the second start onward that
regex yields ciphertext, the cookie is worthless, and the app asks the user for
a key they never set. The key is generated and hidden by the app itself.

Reproduced end to end: first start writes a plaintext profile, the core seals
it, every later start reads back "enc:v1:..." and falls through to the login
prompt.

- when the stored value is sealed, ask the core to unseal it via
  -show-secrets, which only reads, prints and exits. Reimplementing the core's
  AEAD in JS would be a second source of truth for its key format.
- cwd must be the profile dir. The core locates master.key relative to the
  config's runtime_file, so a call made from anywhere else has it generate a
  second master key in the CWD and then fail to decrypt ("master key changed?").
  Electron's CWD is not the profile dir, so without this the desktop build
  cannot read its own key even after unsealing is wired up.
- the loose regex is kept as a fallback so a future change to the -show-secrets
  output degrades to a login prompt rather than to a wrong credential.

Verified: plaintext start -> core seals -> restart recovers the same key, with
the core running the whole time. Dropping cwd:PROFILE_DIR makes the unseal fail
and leaves a stray master.key in the CWD, so the cwd argument is load-bearing
rather than tidiness.
2026-10-01 19:19:40 +08:00
a1d4365515 fix(sources): implement PUT and stop partial edits from clobbering api_key
Two defects on the admin source write path, both found while adding a model
to a live source by hand.

PUT /api/sources/{name} was advertised in the API index but never
implemented — handleSourcesAPI only switched on GET/POST/DELETE, so the
documented update verb answered 405 while the POST upsert behind it worked.

POST is an upsert that replaces the whole source, so a partial edit that did
not carry api_key persisted an empty or placeholder credential. The source
kept its name, base_url and models, the write returned 200, and the source
then answered 401 on the next request — long after the writing script exited
0. The WebUI had been routing around this by loading the real key through
?reveal=credentials; any script or partial update went straight into it.

- implement PUT, taking the name from the path and rejecting a body name
  that disagrees rather than silently resolving to one of them
- inherit the stored credential when api_key is omitted or sent as the
  literal "__KEEP__"; an explicit new key still rotates
- an empty api_key on a source that does not exist yet stays empty, since
  credential-less local upstreams are legitimate
- add model_ids, an additive shorthand, so "add these models" never has to
  read and echo the existing list back
- align the API index with the implementation

The model_ids merge had a first cut that dropped the existing list when the
request carried no models field; TestSourceModelIDsIsAdditive caught it.

Verified by mutation: removing PUT turns three tests red, flattening
resolveAPIKey into a pass-through turns TestSourceUpsertKeepsAPIKey red
on both subtests, and making model_ids replace instead of merge turns
TestSourceModelIDsIsAdditive red.
2026-10-01 18:15:33 +08:00
3548745f98 fix(startup): 密钥警告改读真实生效的 key 集合
启动时那条「gateway_keys is EMPTY — without a key every request is rejected」
读的是 legacy 的 cfg.GatewayKeys 段,而鉴权实际用 cfg.Keys(core.ListKeys)。
seedKeys 首次启动把 gateway_keys 搬进 keys[] 之后,YAML 里那个列表就不再
被鉴权使用。于是在它被清空(例如轮换掉 starter key 之后)而 keys[] 仍有
7 把可用 key(含 admin)时,进程每次启动都谎报「所有请求都会被拒绝」。

实测:生产日志出现该警告,而同一个 key 请求 /v1/models 返回 200。

- main.go 改为检查 c.ListKeys(),文案改成不绑定字段名。
- 顺带删掉 gateway.New 的 gatewayKeys 参数:函数体从未使用它,
  只读 ListKeys(),留着会继续诱导人以为鉴权来自那个列表。

判据:e2e/TestStartupWarningReflectsRealKeysNotLegacyList —— 构造
「gateway_keys 空 + keys[] 有 key」的真实形态,先断言该 key 确实能鉴权,
再断言日志里不再出现那句谎报。变异验证:回退成 GatewayKeys() 即变红。
2026-09-28 23:43:20 +08:00
3fe0df5bf2 fix(packaging): 发行包不再内置可用的 admin key
打包时把本地 config.yaml(gitignored,含运维真实密钥)原样复制成
config.example.yaml,而 postinst 在首次安装且 /etc 无配置时又把它
cp 成生产配置 ⇒ 每次安装都得到一个同值的、公开已知的 admin key。
实测该 key(sk-gw-local-0001)在生产上真实有效(/v1/models 返回 200,
而网关监听 0.0.0.0)。

三处修正:
- 新增 packaging/config.example.yaml(受 git 跟踪的净化模板),
  gateway_keys 留空、sources 留空,并写明不要填死值。
- core-dist.sh / nfpm.yaml 改为打包该模板,不再碰本地 config.yaml。
- postinst.sh 不再投递示例配置:留空文件会让网关启动但拒绝所有请求
  (无门可入)。改为让二进制首启时自行生成随机 admin key 并打印 ——
  每次安装都不同,且开箱可用。示例文件仅作为 /usr/share 下的参考保留。

实测首启:生成 sk-gw-838d66a1... 并打印,与旧的共享固定值不同。
2026-09-28 23:28:20 +08:00
8ca59b4bb2 fix(core): 修复启动重复播种 admin key + 配置封存非幂等
根因是 unseal 时序:NewFromConfig 把解密放在最后,而之前几步已经在读凭据。

1. seedKeys 重复播种(生产已累积 4 个同名 admin key)
   seedKeys 用 cfg.Keys[i].Key 与明文 gateway_keys 比对去重,但此时内存里的
   key 还是密文 enc:v1:…,比对永不命中 ⇒ 每次重启追加一个同值 admin key。
   实测:core.New(path) 连续重启,seeded key 数 2→3→4 递增。
   (旧测试用 NewFromConfig 构造全新内存对象,没有「盘上已有密文」这个前提,
    复现不出 —— 必须走 core.New 这条读盘的生产路径。)

2. 启动恒重写 config.yaml
   migratePlaintextSecrets 按内存状态判断,而 Save() 末尾会把内存恢复为明文,
   于是每次调用都判定「还有明文」并重写;注释却自称幂等。
   改为 UnsealSecrets 在解密前记录「盘上是否明文」,SealIfNeeded 据此决定
   是否写回 ⇒ 已封存的配置启动不再落盘。

原测试 TestMigratePlaintextSecretsIsIdempotent 用 ModTime 比较,两次写落在同一
时间戳刻度内就看不出来,所以表现为 ~1/6 概率的 flake 而非稳定失败。已改为比较
文件内容并走真实启动路径(UnsealSecrets + SealIfNeeded),并顺带消除该 flake。

附带更正:先前判断「rebuildRegistry 也会拿到密文 API key」不成立 ——
mergedSources → resolveSourceKey 对每个 source 独立解密(belt-and-braces),
provider 始终拿到明文。unseal 前置仍予保留,以消除对该兜底路径的隐性依赖、
并让 seedKeys 在明文下比较。

判据:
- TestRestartDoesNotDuplicateSeededKeys(敏感:回退顺序必红)
- TestSealingIsIdempotentAcrossStarts(12/12 稳定,原先 1/6 flake)
- TestProvidersGetPlaintextCredentials(钉 provider 必须拿到明文这一不变量)
2026-09-28 22:53:33 +08:00
980408d045 fix(tokens): 流式统计改用上游真实 usage,图片不再记 token
两处 token 单位错误,均影响 per-model 配额计费:

1. 流式路径的 prompt/completion 只是「字节÷3」估算。
   pumpStream 明明收到了上游最后一帧的真实 usage,却只发给客户端、
   从不回写审计记录,于是配额按估算值扣。生产实测同一请求:
   上游 prompt=37/completion=179 → 记账 27/262,prompt 低估 1.4x、
   completion 高估 1.5x(双向失真)。同模型流式 completion 中位数
   是非流式的 4-27 倍。非流式路径本就用真实值,两路不一致。
   修法:lastUsage 非零时写回 rec.Prompt/rec.Compl,估算降为兜底
   (上游不报 usage 时仍保留原估算行为)。

2. 图片请求把「图片张数」记成 completion_tokens。
   rec.Compl = int64(len(resp.ImageData)),len 是切片长度即张数
   (生产 38 条 image 记录全是 1),且被计入 token 总量。
   图片生成无 token 概念 ⇒ 新增 Req.ImageCount 独立字段,
   Prompt/Compl 归 0;UI 记录表 image 行改显示张数(新增 i18n thImgs)。

顺带补 TestUILocaleKeyParity:此前无人校验 zh/en 键集合一致,
单边加键不会报错,只会显示原始键名。

新增 token_units_test.go(定值上游 6 项),做过变异验证:
回退修复实测复现 stream=16/173 vs chat=44/100、image completion=3。
2026-09-28 22:20:14 +08:00
4b37e1a0db chore(version): 1.7.1 -> 1.8.0
main 的版本号此前停在 1.4.2(v1.6.0 回流时按纪律未带入 bump),
落后于已发布的 v1.7.1。按 SemVer,main 指向下一个未发布的中版本。
2026-09-27 19:08:37 +08:00
ebe60028f5 refactor(quota): 配额改为按模型,删除整钥总配额
用户明确要求:配额应当是密钥对应的**每个模型的单独配额**,而非整体配额。

## 语义变更

删除 GWKey.TokenQuota / ReqQuota / Period / Hours(整钥总额)。
ModelScope 新增 ReqQuota —— 请求数配额下沉到每条模型范围。

现在:每条 models[] 各自带 token 配额 + 请求数配额 + 重置周期,
彼此独立。一个模型用满只影响该模型。

★ 为什么不保留整钥总额:它会让「把 A 模型的额度挪给 B」变成一次全局
重分配;按模型独立计费则每个模型各自可控,运维能直接看出哪个模型在吃预算。

## 连带改动

- checkQuota 合并 key 级与 scope 级判定;checkKeyQuotaRetry 整体删除
  (顺带修掉上轮遗留的双重判定:入口不再先判空再重算)
- core:CreateKeyWithQuota / UpdateKeyWithQuota / ApplyQuota 全部删除,
  改由 ValidateScopeQuotas 校验每条 scope 的配额
- admin key:scope 上的配额不强制(admin 的 scope 仍限制模型范围,
  但不强制配额)—— 否则管理员会把自己锁在门外
- /api/v1/keys 不再回显 key 级配额字段(scope 里已含)
- WebUI:删除整钥配额徽标 / 「配额」按钮 / 创建表单的配额组 /
  putScope 的整钥回传;模型砖块与范围编辑器新增「请求数配额」输入,
  徽标显示 `1.0K 77×·1h`(未设配额显示 ∞)

## 判据

- TestOneModelsQuotaDoesNotBlockAnother 是本次核心保证。
  ★ 它第一版是**假判据**:m2 从不消耗,key-wide 计数器与 m1 自己的计数器
  读数恰好相同,退回 key-wide 仍通过。变异测试抓到后改为「先用 m2 花掉
  远超 m1 配额的量,再验证 m1 仍可用」—— 这样两种设计才可区分。
- TestUncappedModelNeverBlocked / TestAdminKeyScopesAreNotEnforced 新增
- UI 契约判据重写:整钥配额界面必须彻底消失(13 个符号)、
  scope 编辑器必须往返 req_quota、putScope 只发 scope 列表
- 错误消息点名具体模型(TestKeyAPIRejectionNamesTheModel)
- 3/3 变异全被抓

实测(真实进程 + 浏览器):m2 配额 500000 连打 25 次全成功,
m1 配额 1000 立即 429「token quota exceeded for "m1" (4315/1000)」,
此后 m2/m3 仍 200。UI:整钥配额元素全为 0,砖块各显配额,
编辑器预填/保存正确,零 JS 异常。

(cherry picked from commit c51066f0b6)
2026-09-27 19:07:28 +08:00
30 changed files with 81 additions and 5002 deletions

View File

@ -1,32 +0,0 @@
# archive/pre-v1.9-quota-refactor
**这是一个归档分支,不要从它继续开发。**
它冻结的是 v1.9 之前的一段插件重构历史,保存于 2026-10-05。
## 为什么保留
分支上有 16 个提交没有以等价形式进入主线。逐个 patch-id 核对后,其中 **3 个
是远端完全没有的**:
| commit | 内容 | 为什么没进主线 |
|---|---|---|
| `a6c6e6e` | 可见性、state 分区、注册式 HTTP 端点 | 后继版本 `35b9422` 是它的 rebase 等价版,但这个 hash 上还挂着另一些只存在于此分支的改动 |
| `6bc3ff8` | ccquota 显示每月额度 | ccquota 后来被通用 `quota` 插件取代,这段额度 UI 没进主线 |
| `b5f6cac` | ccquota 页面首次打开是空的 | 同上,是 ccquota 生命周期内的修复 |
其余 13 个已在主线以等价 patch 存在(`35b9422` / `cab3361` / `ba10f03` 等),
它们保留在这里是为了让这段历史可追溯,不是为了再次使用。
## 现在的功能落点
| 当时在这里 | 现在在哪 |
|---|---|
| ccquota 多账户额度插件 | `internal/lua/plugins/quota.lua`(通用套餐余量插件) |
| 可见性 / state 分区 / 按密钥授权 | `internal/lua/plugin_access.go`(`AccessRule`) |
| 注册式 HTTP 端点 | `internal/lua/plugin_http_handler.go` + `AccessRule` |
## 当前发布线
- `main` — 下一个未发布版本
- `release/v1.9.x` — v1.9.x 发布线(最新 tag `v1.9.5`)

View File

@ -1,12 +1,12 @@
{
"name": "modelrouter-gui",
"version": "1.9.0",
"version": "1.9.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "modelrouter-gui",
"version": "1.9.0",
"version": "1.9.1",
"license": "Proprietary",
"devDependencies": {
"electron": "^33.0.0",

View File

@ -1,6 +1,6 @@
{
"name": "modelrouter-gui",
"version": "1.9.0",
"version": "1.9.1",
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
"author": "ModelRouter",
"main": "main.js",

View File

@ -39,15 +39,6 @@ type Config struct {
// so peak traffic was billed at off-peak rates — no error, the bill just
// quietly halved. A schema-checked config file catches both at load.
BillingDSL *BillingDSL `yaml:"billing,omitempty"`
// PluginVisibility overrides the audience a plugin declared for itself,
// keyed by plugin name: "admin" hides a plugin (and its state) from user
// keys, "all" widens one that shipped admin-only, "user,admin" names roles
// explicitly. An absent name leaves the plugin's own declaration in force.
//
// This exists because a plugin author cannot know the operator's policy, and
// the operator should not have to edit a bundled .lua to change one — the
// next upgrade overwrites it.
PluginVisibility map[string]string `yaml:"plugin_visibility,omitempty" json:"plugin_visibility,omitempty"`
// box seals credentials (sources' api_key/headers, keys' key) at rest.
// In-memory values are always plaintext; only the bytes on disk are sealed.
// Wired by AttachSecretBox — Load leaves it nil so `-check` and tests stay

View File

@ -11,7 +11,6 @@ import (
"os"
"path/filepath"
"sort"
"net/url"
"strings"
"sync"
"sync/atomic"
@ -69,29 +68,6 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
// to boot the gateway because a third-party .lua has a typo would be the
// wrong trade.
c.plugins = lua.NewPlugins(c.vm, cfg.PluginDir)
// Plugin outbound HTTP is scoped to the hosts of configured sources. Set
// before LoadDir so a plugin that requests on its first hook already sees
// the real allowlist rather than the deny-all placeholder.
c.plugins.SetOutboundHosts(sourceHosts(cfg))
// Let the plugin layer validate access specs against the keys that exist.
// A stored allowlist naming a key that does not exist refuses every caller,
// which looks exactly like a broken endpoint.
lua.SetKeyNameLookup(func() []string {
// ListKeys is the structured view (bare `gateway_keys:` entries are
// expanded into records with names); cfg.GatewayKeys is still []string.
keys := c.ListKeys()
out := make([]string, 0, len(keys))
for _, k := range keys {
if k.Name != "" {
out = append(out, k.Name)
}
}
return out
})
// Operator overrides win over the plugin's own declared audience.
for name, aud := range cfg.PluginVisibility {
c.plugins.SetPluginVisibility(name, aud)
}
if err := c.plugins.SeedBundled(); err != nil {
log.Printf("[core] seed bundled plugins: %v", err)
}
@ -639,35 +615,9 @@ func (c *Core) rebuildRegistry() error {
c.buildAutoChain()
c.buildAutoImageChain()
c.rebuildKeyAutoChains()
// A source edit can add or remove an upstream host, which changes what a
// plugin is allowed to reach. Keeping this beside the chain rebuilds means
// one call site owns "config changed, refresh derived state".
c.plugins.SetOutboundHosts(sourceHosts(c.cfg))
return nil
}
// sourceHosts extracts the host of every configured source's base_url — the
// allowlist for plugin outbound HTTP. Unparseable URLs are skipped rather than
// failing startup: a malformed base_url is already rejected when the provider is
// built, and it must not take plugins down with it.
func sourceHosts(cfg *config.Config) []string {
if cfg == nil {
return nil
}
out := make([]string, 0, len(cfg.Sources))
for _, s := range cfg.Sources {
if s.BaseURL == "" {
continue
}
u, err := url.Parse(s.BaseURL)
if err != nil || u.Hostname() == "" {
continue
}
out = append(out, u.Hostname())
}
return out
}
// buildAutoChain rebuilds the AUTO chain snapshot from config.yaml rules
// against the current providers. Slot model ids are normalized to the exact
// configured spelling (case-insensitive match), otherwise ModelFor would fall

View File

@ -464,13 +464,6 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
"by_status": out.Status,
"buckets": out.Bucket,
"truncated": out.Truncated,
// "Active requests" is a live gauge (requests in flight right now),
// not a period aggregate. Omitting it from the period response made
// the KPI read `st.active || 0` → 0 on every day/week/month view while
// the all-time view showed the truth, which read as "the gateway is
// idle" precisely when it was busiest. A calendar window cannot
// filter it, so it travels with every response unfiltered.
"active": g.stats.Active(),
"records": page.Records,
// The paging cursor has to travel with the first page, otherwise
// the scroll handler has nothing to page from and the table is

View File

@ -256,17 +256,7 @@ func (g *Gateway) checkQuota(ctx context.Context, model string) *quotaRejection
}
return nil
}
// A scope that simply does not list the model is a different problem from
// a quota being exhausted, and the message has to say which: telling an
// operator "not allowed for this key" when they configured a quota sends
// them to the wrong setting. AUTO is checked here before the chain is even
// consulted, so this is the message that surfaces for AUTO.
if isAuto(model) {
return &quotaRejection{msg: fmt.Sprintf(
"model %q is not in this key's model scope, so it cannot use AUTO; "+
"add %q to the key's models or remove the scope restriction", model, model)}
}
return &quotaRejection{msg: fmt.Sprintf("model %q is not allowed for this key", model)}
return &quotaRejection{msg: fmt.Sprintf("model %q is not allowed for this key", model)}
}
// quotaWindowSuffix describes a quota's reset window for an error message, so
@ -462,11 +452,6 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
return
}
}
// Note: a key whose scope excludes AUTO is rejected earlier, by
// checkQuota(ctx, "AUTO") above, which produces the same message. That
// ordering matters — checkQuota runs before the chain is consulted, so it
// is the check an AUTO request actually hits. Duplicating it here would be
// dead code.
cands, effective := g.resolveCands(r.Context(), &req)
if len(cands) == 0 {
writeError(w, http.StatusNotFound, "model_not_found", fmt.Sprintf("model %q is not configured", model))

View File

@ -1,150 +0,0 @@
package gateway
// The /api/plugins/{name}/call/{fn} endpoint splits its path with string
// surgery, and the split point is easy to get wrong in a way that still
// "works": keeping the "call/" segment in the function name produced
// `does not export "call/collect_account"` — the plugin was correct, the router
// was not, and every call 502'd while the exports list looked perfectly fine.
//
// These tests drive the real router, so a refactor that reintroduces the
// off-by-one is caught here rather than in production.
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"llmsproxy/internal/config"
"llmsproxy/internal/core"
)
// callPlugin exposes exactly one export plus a function that is NOT exported.
// If the exports gate is not honoured, secretFn is reachable and with it the
// plugin's private state — for a plugin holding an API key that means handing
// the key to anyone who can POST to the gateway.
const callPlugin = `
local P = {}
P.name = "calltest"
P.version = "1.0.0"
P.exports = { "add" }
P.secret = "do-not-leak"
function P.add(a)
return { result = (tonumber(a and a.x) or 0) + 1 }
end
function P.secretFn()
return { leaked = P.secret }
end
return P
`
func gatewayWithCallPlugin(t *testing.T) *Gateway {
t.Helper()
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.yaml")
body := "listen: :0\n" +
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
"plugin_dir: " + filepath.Join(dir, "plugins") + "\n" +
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
"gateway_keys:\n - sk-test\n"
if err := os.WriteFile(cfgPath, []byte(body), 0600); err != nil {
t.Fatal(err)
}
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatal(err)
}
c, err := core.NewFromConfig(cfg)
if err != nil {
t.Fatalf("core: %v", err)
}
t.Cleanup(c.Close)
if err := c.Plugins().LoadSource("calltest", callPlugin); err != nil {
t.Fatalf("load calltest: %v", err)
}
g, err := New(c)
if err != nil {
t.Fatalf("gateway: %v", err)
}
return g
}
func TestPluginCallSplitsFunctionNameCorrectly(t *testing.T) {
g := gatewayWithCallPlugin(t)
rr := doReq(t, g, http.MethodPost,
"/api/plugins/calltest/call/add", `{"x":41}`)
if rr.Code != http.StatusOK {
t.Fatalf("call failed: HTTP %d %s", rr.Code, rr.Body.String())
}
var out struct {
Result struct {
Result int `json:"result"`
} `json:"result"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v (body=%s)", err, rr.Body.String())
}
// 41 + 1 == 42. Reaching 42 proves BOTH halves of the contract: the "/call/"
// segment did not leak into the function name, and the payload arrived.
if out.Result.Result != 42 {
t.Fatalf("got %d, want 42 — either the /call/ segment leaked into the "+
"function name or the argument table never reached Lua", out.Result.Result)
}
}
func TestPluginCallRefusesUndeclaredFunction(t *testing.T) {
g := gatewayWithCallPlugin(t)
rr := doReq(t, g, http.MethodPost,
"/api/plugins/calltest/call/secretFn", `{}`)
if rr.Code == http.StatusOK {
t.Fatalf("an unexported function must not be callable, got 200: %s",
rr.Body.String())
}
if strings.Contains(rr.Body.String(), "do-not-leak") {
t.Fatal("the refusal leaked the plugin's private state")
}
}
// A plugin that declares no exports at all has no callable surface. Reading the
// missing list as "allow everything" would turn every plugin into a remote
// evaluator the moment this endpoint ships.
func TestPluginCallWithoutExportsHasNoCallableSurface(t *testing.T) {
g := gatewayWithCallPlugin(t)
const bare = `
local P = {}
P.name = "bare"
P.version = "1.0.0"
function P.anything()
return { ok = true }
end
return P
`
if err := g.core.Plugins().LoadSource("bare", bare); err != nil {
t.Fatalf("load bare: %v", err)
}
rr := doReq(t, g, http.MethodPost,
"/api/plugins/bare/call/anything", `{}`)
if rr.Code == http.StatusOK {
t.Fatalf("a plugin with no plugin.exports must expose nothing, got 200: %s",
rr.Body.String())
}
}
func TestPluginCallIsAdminOnly(t *testing.T) {
g := gatewayWithCallPlugin(t)
// A real user-role key, not a bare one from config: sk-test/sk-user in the
// config both default to admin, so a test using them would pass the gate for
// the wrong reason and prove nothing.
rec, err := g.core.CreateKey("viewer", "user", nil, "")
if err != nil {
t.Fatal(err)
}
rr := doReqAs(t, g, rec.Key, http.MethodPost,
"/api/plugins/calltest/call/add", `{"x":1}`)
if rr.Code != http.StatusForbidden {
t.Fatalf("a non-admin key reached the call endpoint: HTTP %d %s",
rr.Code, rr.Body.String())
}
}

View File

@ -1,207 +0,0 @@
package gateway
// Mount for plugin-registered HTTP endpoints.
//
// The gateway owns the listener, the authentication, the role check and the
// response framing; the plugin owns the logic. That split is the point: a plugin
// cannot open a socket, cannot bypass auth, and cannot choose a path outside
// /api/plugins/http/.
//
// Why this exists rather than "plugins can call each other": some things are
// worth querying from OUTSIDE the browser — a CI job asking whether the upstream
// balance is above zero, a monitoring probe, another tool on the box. billing
// never wanted this (it only receives hooks); a quota plugin does. Hard-coding a
// route per plugin would mean a gateway change for every such plugin, and an
// arbitrary plugin's code in the main mux is not something the routing table
// should ever grow.
import (
"net/http"
"strconv"
"strings"
"llmsproxy/internal/lua"
)
// pluginHTTPPrefix is the mount point. It is deliberately narrow: a plugin's
// routes live under one prefix, which makes them greppable, makes them easy to
// revoke wholesale, and means a plugin cannot shadow /api/keys or /api/stats.
const pluginHTTPPrefix = "/api/plugins/http/"
// handlePluginHTTP dispatches a request to whichever plugin registered the
// longest matching path.
//
// Longest-match rather than first-match: a plugin may register /health and
// another /health/deep, and both must work. With registration order deciding it,
// the outcome would depend on load order and change when a plugin is added.
func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) {
rest := strings.TrimPrefix(r.URL.Path, pluginHTTPPrefix)
ps := g.core.Plugins()
if ps == nil {
writeError(w, http.StatusNotFound, "not_found", "no plugins loaded")
return
}
matches := ps.HTTPHandlers()
var chosen *lua.HTTPHandler
var params map[string]string
for i := range matches {
h := matches[i]
if p, ok := matchHandlerPath(h.Path, rest); ok {
if chosen == nil || segmentCount(h.Path) > segmentCount(chosen.Path) {
chosen, params = &h, p
}
}
}
if chosen == nil {
writeError(w, http.StatusNotFound, "not_found",
"no plugin serves this path")
return
}
if !strings.EqualFold(r.Method, chosen.Method) {
w.Header().Set("Allow", chosen.Method)
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed",
"this endpoint accepts "+chosen.Method+" only")
return
}
role := reqRole(r.Context())
if !chosen.Allows(role, reqKeyName(r.Context())) {
writeError(w, http.StatusForbidden, "forbidden",
handlerAccessHint(*chosen))
return
}
body, err := readBody(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
req := &lua.HandlerRequest{
Method: r.Method,
Path: rest,
Query: flattenQuery(r.URL.Query()),
Params: params,
Headers: flattenHeader(r.Header),
Body: body,
Role: role,
Key: reqKey(r.Context()),
}
resp, err := ps.InvokeHandler(handlerOwner(chosen), chosen.Handler, req)
if err != nil {
// A plugin error is a 502, not a 500: the gateway is fine, the plugin is
// not, and the operator needs to tell those apart in their logs.
writeError(w, http.StatusBadGateway, "plugin_error", err.Error())
return
}
lua.WriteHandlerResponse(w, resp)
}
// handlerAccessHint explains, in the refusal, exactly which declaration would
// open this endpoint. A 403 with no reason is the kind of thing an operator
// opens the source to work out, and then the fix is a guess.
func handlerAccessHint(h lua.HTTPHandler) string {
switch {
case len(h.Keys) > 0:
return "this endpoint allows only the named keys (" +
strings.Join(h.Keys, ", ") + "); authenticate with one of them"
case len(h.Roles) > 0:
return "this endpoint requires role " + strings.Join(h.Roles, " or ") +
" (widen it in the plugin's http declaration, or set public=true)"
default:
return "this endpoint declares no roles, so it is admin-only; " +
"set roles=… or keys=… in the plugin's http declaration"
}
}
// handlerOwner returns the plugin name that registered h. The gateway stores the
// owner alongside the handler precisely so this does not have to be guessed from
// the path prefix.
func handlerOwner(h *lua.HTTPHandler) string { return h.Plugin }
// segmentCount is how deep a route is, used for longest-match. A path with more
// segments wins, so /health/deep beats /health when both are registered.
func segmentCount(p string) int { return strings.Count(p, "/") }
// matchHandlerPath matches a route pattern against a concrete path, capturing
// ":name" segments.
//
// Returns ok=false on a segment-count mismatch or a missing value for a
// param — a route must not match with an empty capture, or two different URLs
// would reach the same handler indistinguishably.
func matchHandlerPath(pattern, path string) (map[string]string, bool) {
pp := strings.Split(strings.Trim(pattern, "/"), "/")
sp := strings.Split(strings.Trim(path, "/"), "/")
if len(pp) != len(sp) {
return nil, false
}
var params map[string]string
for i := range pp {
if strings.HasPrefix(pp[i], ":") {
name := pp[i][1:]
if sp[i] == "" {
return nil, false
}
if params == nil {
params = map[string]string{}
}
params[name] = sp[i]
continue
}
if pp[i] != sp[i] {
return nil, false
}
}
return params, true
}
func flattenQuery(q map[string][]string) map[string]string {
out := make(map[string]string, len(q))
for k, v := range q {
if len(v) > 0 {
out[k] = v[0]
}
}
return out
}
func flattenHeader(h http.Header) map[string]string {
out := make(map[string]string, len(h))
for k := range h {
out[strings.ToLower(k)] = h.Get(k)
}
return out
}
func readBody(r *http.Request) (string, error) {
if r.Body == nil {
return "", nil
}
defer r.Body.Close()
// A cap, because the handler runs inside the gateway process: an unbounded
// body from an authenticated caller is a memory exhaustion vector.
const maxBody = 4 << 20
buf := make([]byte, 0, 4096)
tmp := make([]byte, 32*1024)
for {
n, err := r.Body.Read(tmp)
if n > 0 {
if len(buf)+n > maxBody {
return "", &bodyTooLarge{}
}
buf = append(buf, tmp[:n]...)
}
if err != nil {
break
}
}
return string(buf), nil
}
type bodyTooLarge struct{}
func (bodyTooLarge) Error() string { return "request body exceeds 4 MiB" }
// limitBody is kept for the numeric form some callers want in a log line.
var _ = strconv.Itoa

View File

@ -93,30 +93,6 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) {
return
}
// /api/plugins/{name}/http/{path}/access — the operator's view of, and edit
// surface for, which keys an endpoint answers.
if i := strings.Index(path, "/http/"); i >= 0 {
name := path[:i]
rest := path[i+len("/http/"):]
if strings.HasSuffix(rest, "/access") {
epPath := strings.TrimSuffix(rest, "/access")
g.handleEndpointAccess(w, r, name, epPath)
return
}
}
// /api/plugins/{name}/call/{fn}
if i := strings.Index(path, "/call/"); i >= 0 {
name := path[:i]
fn := path[i+len("/call/"):]
if name == "" || fn == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "call needs a plugin and a function name")
return
}
g.handlePluginCall(w, r, name, fn)
return
}
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
@ -130,11 +106,6 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) {
"on_disk": ps.OnDisk(),
"hook_errors": ps.HookErrors(),
"plugin_dir": g.pluginDir(),
// Visibility overrides and the endpoint access table, so the
// plugin page renders what is IN FORCE rather than making the
// operator read .lua files or config.yaml to find out.
"visibility": ps.VisibilityOverrides(),
"http_access": ps.EndpointAccess(),
})
case http.MethodPost:
var body struct {
@ -240,33 +211,15 @@ func (g *Gateway) handlePluginState(w http.ResponseWriter, r *http.Request, name
ps := g.core.Plugins()
switch r.Method {
case http.MethodGet:
// The plugin's audience gates its DATA as well as its page. Hiding the UI
// while leaving GET /state open would put the numbers one curl away, which
// is the leak the audience field exists to close — and billing's state is
// a global ledger, not the caller's own usage.
//
// A plugin that declares no audience is unchanged (every role may read
// it), so a single-purpose widget keeps working without edits.
role := reqRole(r.Context())
if !ps.StateVisibleTo(name, role) {
writeError(w, http.StatusForbidden, "forbidden",
"plugin "+name+" is not visible to role "+role)
return
}
// Any role may read: plugin state is reporting data (cost, counts),
// and the caller has already been authenticated. Admin-only would stop
// a user key's own billing widget from rendering.
// prices rides along: the billing rules editor must show the table
// that is actually in effect, not a reconstruction from config. It is
// absent for plugins with no prices (every plugin but billing).
state := ps.State(name)
// A plugin may SPLIT its state: global buckets stay deployment-wide while
// per-caller buckets are narrowed to the authenticated key. Role
// visibility alone cannot express this — the same role is allowed to read
// the plugin, just not another caller's row.
if scope := ps.StateScopeFor(name); scope != nil && role != "admin" {
state = splitStateForKey(state, scope, reqKey(r.Context()))
}
payload := map[string]interface{}{
"plugin": name,
"state": state,
"state": ps.State(name),
}
if pr := ps.Prices(name); pr != nil {
payload["prices"] = pr
@ -292,45 +245,6 @@ func (g *Gateway) handlePluginState(w http.ResponseWriter, r *http.Request, name
}
}
// Call invokes a named function on a plugin's Lua state and returns its result.
//
// This exists because plugin pages otherwise have NO way to reach their own
// plugin code: pluginAPI offers state read/write and a couple of callbacks, and
// that is enough for billing (whose numbers arrive on hooks during request
// forwarding) but useless for a plugin that must do work on demand. A quota
// page has to ask the vendor when the operator clicks Refresh, and no hook
// fires at that moment.
//
// It is deliberately narrow:
// - The function must be one the plugin explicitly lists in plugin.exports.
// Anything else is refused, so this endpoint cannot become a generic "run
// arbitrary Lua with my credentials" oracle over a plugin's private state.
// - The caller must be admin, and the result is JSON — no Lua value is ever
// handed back to Go as a live closure.
// - Errors come back as (nil, message) from the plugin, matching every other
// plugin entry point, and surface as a 502 rather than raising.
func (g *Gateway) handlePluginCall(w http.ResponseWriter, r *http.Request, name, fn string) {
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
return
}
var args map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
result, err := g.core.Plugins().Call(name, fn, args)
if err != nil {
writeError(w, http.StatusBadGateway, "plugin_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"result": result})
}
func (g *Gateway) pluginDir() string {
if c := g.core.Config(); c != nil {
return c.PluginDir
@ -368,178 +282,3 @@ func (g *Gateway) removePlugin(name string) error {
_ = os.Remove(filepath.Join(g.pluginDir(), name+".lua"))
return g.core.Plugins().Unload(name)
}
// handleEndpointAccess serves the operator's view and edit surface for one
// plugin endpoint's reachable keys.
//
// GET …/http/{path}/access the declared rules plus any override
// PUT …/http/{path}/access body {"access": "admin,@homeagent"}
// DELETE …/http/{path}/access clear the override
//
// The spec string is a comma list: a bare word is a role, "@name" is a gateway
// key's configured name, and the literal "public" opens it to any
// authenticated key. That is the same grammar the visibility override uses, and
// keeping one grammar means an operator learns it once.
//
// Key names are validated against the configured keys BEFORE being stored. An
// allowlist naming a key that does not exist is a silent no-op — every caller
// is refused — and the operator would have no way to tell that apart from the
// endpoint simply working.
func (g *Gateway) handleEndpointAccess(w http.ResponseWriter, r *http.Request, name, epPath string) {
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
ps := g.core.Plugins()
// epPath "*" is the WHOLE-PLUGIN visibility override, not an endpoint. It
// shares this route (and therefore this store) on purpose: an operator
// setting "who can see this plugin" and "who can call this endpoint" is
// asking the same question at two granularities, and two stores would let
// them disagree. SetEndpointAccess cannot serve it — it validates that the
// endpoint exists, which "*" by definition does not.
if epPath == pluginWholeOverride {
g.handlePluginVisibility(w, r, name)
return
}
switch r.Method {
case http.MethodGet:
for _, e := range ps.EndpointAccess() {
if e.Plugin == name && e.Path == epPath {
writeJSON(w, http.StatusOK, map[string]interface{}{"endpoint": e})
return
}
}
writeError(w, http.StatusNotFound, "not_found", "no such endpoint")
case http.MethodPut, http.MethodDelete:
spec := ""
if r.Method == http.MethodPut {
var body struct {
Access string `json:"access"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
spec = body.Access
}
if err := ps.ValidateAccessSpec(spec); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if err := ps.SetEndpointAccess(name, epPath, spec); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "access": spec})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
}
}
// splitStateForKey rewrites a plugin state so that a non-admin caller sees only
// the buckets that belong to them.
//
// The plugin declares which buckets are global and which are per-key. A per-key
// bucket is keyed by the MASKED key ("***a1b2c3" — the same value the audit log
// uses), so the caller's own row is found by masking the presented secret and
// matching the last six characters.
//
// Keys that do not match are DROPPED, not zeroed: a zeroed row would render as
// "you spent nothing" and, worse, would leak that a row exists for someone else.
func splitStateForKey(state interface{}, scope *lua.StateScope, key string) interface{} {
m, ok := state.(map[string]interface{})
if !ok || scope == nil || key == "" {
return state
}
masked := maskKey(key)
out := make(map[string]interface{}, len(m))
for k, v := range m {
if !scope.IsGlobal(k) {
// Per-key bucket: keep only the caller's rows, at every nesting level
// the scope mentions (by_day_key is {day: {masked: bucket}}).
out[k] = filterKeyBuckets(v, masked, scope.DepthOf(k))
continue
}
out[k] = v
}
return out
}
// filterKeyBuckets narrows a keyed structure to one masked key.
//
// depth 1 is {masked: bucket}; depth 2 is {day: {masked: bucket}}. The level is
// declared rather than sniffed, because both shapes are dicts and sniffing would
// confuse an empty bucket with a nested one.
func filterKeyBuckets(v interface{}, masked string, depth int) interface{} {
switch depth {
case 0:
return v // not a per-key bucket; leave alone
case 1:
m, ok := v.(map[string]interface{})
if !ok {
return v
}
if row, ok := m[masked]; ok {
return map[string]interface{}{masked: row}
}
return map[string]interface{}{}
default:
m, ok := v.(map[string]interface{})
if !ok {
return v
}
out := make(map[string]interface{}, len(m))
for day, inner := range m {
im, ok := inner.(map[string]interface{})
if !ok {
continue
}
if row, ok := im[masked]; ok {
out[day] = map[string]interface{}{masked: row}
}
}
return out
}
}
// pluginWholeOverride is the epPath that addresses a plugin's overall visibility
// rather than one of its endpoints.
const pluginWholeOverride = "*"
// handlePluginVisibility is the whole-plugin half of the access API. The
// audience string uses the same vocabulary as an endpoint spec (roles, @key
// names, public) so the UI can drive both from one input.
func (g *Gateway) handlePluginVisibility(w http.ResponseWriter, r *http.Request, name string) {
ps := g.core.Plugins()
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]interface{}{
"plugin": name,
"visibility": ps.VisibilityOverrides()[name],
})
case http.MethodPut, http.MethodDelete:
spec := ""
if r.Method == http.MethodPut {
var body struct {
Access string `json:"access"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
spec = body.Access
}
if err := ps.ValidateAccessSpec(spec); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
ps.SetPluginVisibility(name, spec)
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "visibility": spec})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method+" is not allowed here")
}
}

View File

@ -210,54 +210,3 @@ func TestPluginStatePUTIsAdminOnly(t *testing.T) {
t.Errorf("admin PUT state = %d: %s", adm.Code, adm.Body.String())
}
}
// TestPluginsListCarriesAccessTables: the plugin page renders visibility
// overrides and the endpoint access table from /api/plugins. Without them in the
// response the operator has to open a .lua or config.yaml to learn who can see
// what, which is exactly what the UI is meant to avoid.
func TestPluginsListCarriesAccessTables(t *testing.T) {
g := gatewayWithCallPlugin(t)
rr := doReq(t, g, http.MethodGet, "/api/plugins", "")
if rr.Code != http.StatusOK {
t.Fatalf("GET /api/plugins = %d", rr.Code)
}
var out struct {
Visibility map[string]string `json:"visibility"`
HTTPAccess []map[string]interface{} `json:"http_access"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v (body=%s)", err, rr.Body.String())
}
// The calltest plugin registers no endpoints, so the table is present but
// empty — the KEY is that the field exists at all. A missing field decodes to
// nil and the UI silently renders nothing, which looks identical to "this
// plugin has no endpoints".
if !strings.Contains(rr.Body.String(), "http_access") {
t.Error("/api/plugins response lacks the http_access field")
}
if !strings.Contains(rr.Body.String(), "visibility") {
t.Error("/api/plugins response lacks the visibility field")
}
}
// TestPluginWholeVisibilityViaAccessAPI covers the "*" path: the plugin page
// edits a plugin's overall audience through the same route it edits endpoints
// with. Before this existed the UI had a visibility selector whose save 404'd,
// because SetEndpointAccess validates that the endpoint exists and "*" does not.
func TestPluginWholeVisibilityViaAccessAPI(t *testing.T) {
g := gatewayWithCallPlugin(t)
rr := doReq(t, g, http.MethodPut, "/api/plugins/calltest/http/*/access",
`{"access":"admin, user"}`)
if rr.Code != http.StatusOK {
t.Fatalf("PUT */access = %d: %s", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), `"ok":true`) {
t.Errorf("unexpected body: %s", rr.Body.String())
}
// A user role is a legitimate audience; a nonsense one is not.
bad := doReq(t, g, http.MethodPut, "/api/plugins/calltest/http/*/access",
`{"access":"../../etc"}`)
if bad.Code != http.StatusBadRequest {
t.Errorf("traversal-shaped audience = %d, want 400: %s", bad.Code, bad.Body.String())
}
}

View File

@ -235,10 +235,6 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
// treats anything after the name as a plugin sub-resource and would
// 404 on "rules" instead of reaching the billing editor.
g.handleBillingRules(w, r)
case strings.HasPrefix(r.URL.Path, pluginHTTPPrefix):
// Before the generic plugin router: these paths belong to a PLUGIN, not to
// the plugin manager, and "http" is a valid sub-resource name otherwise.
g.handlePluginHTTP(w, r)
case r.URL.Path == "/api/plugins" || strings.HasPrefix(r.URL.Path, "/api/plugins/"):
g.handlePluginsAPI(w, r)
case r.URL.Path == "/api/chat":
@ -309,43 +305,20 @@ func (g *Gateway) auth(next http.Handler) http.Handler {
http.Redirect(w, r, "/login?continue="+url.QueryEscape(r.URL.Path), http.StatusFound)
return
}
next.ServeHTTP(w, r.WithContext(withAuthNamed(r.Context(), key, rec.Role, rec.Name)))
next.ServeHTTP(w, r.WithContext(withAuth(r.Context(), key, rec.Role)))
})
}
// authCtx carries the authenticated gateway key, its role, and the key's
// configured NAME.
//
// The name is here because plugin HTTP endpoints authorise by key name rather
// than by secret, and looking the name up from the key at the point of use would
// mean reading config on every request. It is "" for a key configured without
// one, which such an endpoint can never match.
// authCtx carries the authenticated gateway key and its role.
type authCtx struct {
key string
role string
name string
}
type authCtxKeyT struct{}
func withAuth(ctx context.Context, key, role string) context.Context {
return withAuthNamed(ctx, key, role, "")
}
// withAuthNamed is withAuth plus the key's configured name.
func withAuthNamed(ctx context.Context, key, role, name string) context.Context {
return context.WithValue(ctx, authCtxKeyT{}, authCtx{key: key, role: role, name: name})
}
// reqKeyName returns the authenticated key's configured name, or "" if it has
// none. Plugin endpoint authorisation is written against this, not against the
// secret: a secret is rotated without notice, so a rule keyed on it would
// silently stop matching.
func reqKeyName(ctx context.Context) string {
if a, ok := ctx.Value(authCtxKeyT{}).(authCtx); ok {
return a.name
}
return ""
return context.WithValue(ctx, authCtxKeyT{}, authCtx{key: key, role: role})
}
// reqKey returns the authenticated gateway key id (masked suffix for display).

View File

@ -1211,16 +1211,3 @@ func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
"replay_partial": s.replayPartial,
}
}
// Active reports requests in flight right now.
//
// It is a live gauge, not an aggregate: there is no calendar period that scopes
// it, which is why the period-scoped /api/stats response has to carry it
// explicitly. Before it did, the day/week/month views fed `st.active || 0` a
// missing key and showed a hard 0 — the same gateway that was serving hundreds
// of requests a second looked idle in three of the four views.
func (s *Stats) Active() int64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.active
}

View File

@ -401,34 +401,3 @@ func TestPeriodWeekTotalCanExceedMonthTotal(t *testing.T) {
t.Fatal("today must be contained in both the week and the month totals")
}
}
// TestPeriodStatsCarryActiveGauge pins the live "active requests" gauge onto
// every period response.
//
// It is a live gauge, not an aggregate, so no calendar window can scope it — but
// the period response is what the day/week/month views render from, and it used
// to omit the field entirely. The UI then evaluated `st.active || 0` and printed
// a hard 0 in three of four views, while the all-time view (whose response did
// carry the field) showed the truth. Reading "0 active" off a busy gateway is
// worse than reading nothing: it is a confident wrong answer.
func TestPeriodStatsCarryActiveGauge(t *testing.T) {
for _, period := range []string{"day", "week", "month", "all"} {
g := newTestGateway(t)
rr := doReq(t, g, http.MethodGet, "/api/stats?period="+period, "")
if rr.Code != http.StatusOK {
t.Fatalf("period=%s: %d", period, rr.Code)
}
var out map[string]interface{}
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
t.Fatalf("period=%s decode: %v", period, err)
}
v, ok := out["active"]
if !ok {
t.Errorf("period=%s response has no active field; the UI renders 0", period)
continue
}
if _, ok := v.(float64); !ok {
t.Errorf("period=%s active is %T, want a number", period, v)
}
}
}

View File

@ -517,20 +517,7 @@
#toast{left:12px;right:12px;bottom:12px;text-align:center}
th,td{padding:8px 10px}
}
/* Shown when a key's model scope blocks AUTO: the chain editor can look
fine while every request 403s, so the conflict must be visible here. */
.warn-box {
margin: 0 0 10px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid rgba(224, 108, 51, 0.45);
background: rgba(224, 108, 51, 0.1);
color: #b45309;
font-size: 12.5px;
line-height: 1.5;
}
</style>
</style>
</head>
<body>
<div id="bgfx" aria-hidden="true">
@ -815,21 +802,8 @@
plTitle: "插件",
plState: "状态",
plStages: "阶段",
plVisibility: "可见性",
plEndpoints: "端点",
plAccessTitle: "访问控制",
plAccessHint: "填写允许访问的角色或密钥(@名称)。留空=仅 admin。填 public=任何人。指定 @密钥 时只有该密钥能访问,不受角色规则影响。",
plWholePlugin: "以下端点",
plKeyHint: "可用密钥名",
plAccessSave: "保存访问控制", plActive: "启用中",
tVisDefault: "跟随插件",
tVisAdmin: "仅 admin",
tVisRoles: "admin + user",
tVisAll: "所有人",
plOverridden: "已覆盖插件声明",
plEpsN: "%d 个端点", plDisabled: "已禁用",
plActive: "启用中",
plDisabled: "已禁用",
plBroken: "加载失败",
plBuiltin: "内置",
plHooks: "个阶段",
@ -852,16 +826,12 @@
kAutoChainOwn: "独立链",
kAutoChainGlobal: "跟随全局",
kAutoChainTitle: "独立 AUTO 链",
kAutoChainHint: "默认复制全局链供你增删;清空并保存即恢复跟随全局",
kAutoChainHint: "为空则跟随全局 AUTO 链",
kAutoChainInherit: "使用全局链",
kAutoChainAdd: "+ 添加槽位",
kAutoChainSave: "保存",
kAutoChainTier: "层级",
kAutoChainEmpty: "该密钥使用全局 AUTO 链",
kAutoChainSeeded: "已复制全局 AUTO 链作为编辑起点,保存后该密钥将使用这条独立链",
kAutoChainNewOwn: "该密钥当前跟随全局链,保存后改用这里配置的独立链",
kAutoChainWillInherit: "保存后该密钥将恢复跟随全局 AUTO 链",
kAutoChainScopeWarn: "警告:该密钥的模型范围不包含 AUTO,配了独立 AUTO 链也用不了。当前范围:",
kAutoChainBad: "已保存,但槽位无法解析(模型与源的组合不存在),AUTO 请求会失败",
kName: "名称",
kRole: "角色",
@ -1103,21 +1073,8 @@
plTitle: "Plugins",
plState: "State",
plStages: "Stages",
plVisibility: "Visibility",
plEndpoints: "Endpoints",
plAccessTitle: "Access control",
plAccessHint: "Roles or keys (@name) allowed to reach each endpoint. Empty = admin only. public = anyone. Listing @keys replaces the role rules entirely.",
plWholePlugin: "endpoints below",
plKeyHint: "Key names",
plAccessSave: "Save access", plActive: "Active",
tVisDefault: "follow plugin",
tVisAdmin: "admin only",
tVisRoles: "admin + user",
tVisAll: "everyone",
plOverridden: "overrides plugin",
plEpsN: "%d endpoints", plDisabled: "Disabled",
plActive: "Active",
plDisabled: "Disabled",
plBroken: "Failed to load",
plBuiltin: "Built-in",
plHooks: "stages",
@ -1140,16 +1097,12 @@
kAutoChainOwn: "own",
kAutoChainGlobal: "global",
kAutoChainTitle: "Per-key AUTO chain",
kAutoChainHint: "Starts from a copy of the global chain; clear it and save to follow the global chain again",
kAutoChainHint: "Leave empty to inherit the global AUTO chain",
kAutoChainInherit: "Use global chain",
kAutoChainAdd: "+ Add slot",
kAutoChainSave: "Save",
kAutoChainTier: "Tier",
kAutoChainEmpty: "This key uses the global AUTO chain",
kAutoChainSeeded: "Copied the global AUTO chain as a starting point — saving gives this key its own chain",
kAutoChainNewOwn: "This key currently follows the global chain; saving switches it to the chain configured here",
kAutoChainWillInherit: "Saving makes this key follow the global AUTO chain again",
kAutoChainScopeWarn: "Warning: this key\u2019s model scope does not include AUTO, so a per-key AUTO chain will still be rejected. Current scope: ",
kAutoChainBad: "Saved, but the slot does not resolve (no source serves that model); AUTO requests will fail",
kName: "Name",
kRole: "Role",
@ -2020,12 +1973,7 @@
const e = $(sel);
if (e) e.textContent = txt;
};
// No `|| 0` fallback: that fallback is what turned a MISSING field
// into a confident "0 active" on three of four views. If the server
// ever stops sending it again, an em dash says "unknown" and the
// regression is visible, instead of the gauge quietly claiming the
// gateway is idle while it serves traffic.
kset('[data-kpi="active"]', st.active === undefined ? "—" : fmtN(st.active));
kset('[data-kpi="active"]', st.active || 0);
kset('[data-kpi="reqs"]', fmtN(tot.reqs));
const rsub = $('[data-kpi="reqs-sub"]');
if (rsub)
@ -2068,41 +2016,16 @@ const pnote = $("#period-note");
// like a bug unless the dates are on screen — so print them.
const prange = $("#period-range");
if (prange && st.period && st.period !== "all" && st.from) {
// Bucket labels are hourly for a day ("2026-10-03T09") and daily
// for a week or month ("2026-10-01"). Appending "T00:00:00Z" to
// the hourly form builds "2026-10-03T09T00:00:00Z", which Date
// rejects — toISOString() then throws and the whole assignment is
// lost, leaving the range line silently empty on the day view (the
// one people look at most).
const f = (d) => d.toISOString().slice(0, 10);
const label = st.buckets && st.buckets.length
? st.buckets[st.buckets.length - 1].bucket
: "";
let endMs = 0;
if (/^\d{4}-\d{2}-\d{2}$/.test(label)) {
endMs = new Date(label + "T00:00:00Z").getTime();
} else if (/^\d{4}-\d{2}-\d{2}T\d{2}$/.test(label)) {
// An hourly bucket covers its hour, so the day it belongs to is
// what the range should end on.
endMs = new Date(label + ":00:00Z").getTime();
}
const from = new Date(st.from);
const end = endMs ? new Date(endMs) : from;
// The bucket count is not a day count: the day view buckets by
// HOUR, so "(5d)" for five hours of today was simply wrong. Days
// covered comes from the window itself, which is what the reader
// is trying to establish.
let days = 1;
if (endMs) {
days = Math.max(
1,
Math.round((endMs - from.getTime()) / 86400000) + 1,
);
}
const tail = " (" + days + "d)";
prange.textContent = endMs
? t("periodRange") + " " + f(from) + " → " + f(end) + tail
: t("periodRange") + " " + f(from) + tail;
const d0 = new Date(st.from);
const d1 = st.buckets && st.buckets.length
? new Date(st.buckets[st.buckets.length - 1].bucket + "T00:00:00Z")
: d0;
const f = (d) =>
d.toISOString().slice(0, 10);
prange.textContent =
t("periodRange") +
" " + f(d0) + " → " + f(d1) +
(st.buckets ? " (" + st.buckets.length + "d)" : "");
} else if (prange) {
prange.textContent = "";
}
@ -3679,20 +3602,13 @@ recsState.keyNames = keyNames || {};
sortState.origin = JSON.stringify(sortState.lanes);
const kindToggle =
`<div class="kind-toggle"><button class="${sortState.kind === "chat" ? "" : "ghost"} small" onclick="switchSortKind('chat')">${t("kChat") || "聊天"}</button><button class="${sortState.kind === "image" ? "" : "ghost"} small" onclick="switchSortKind('image')">${t("kImage") || "生图"}</button></div>`;
// renderSortEditor replaces its mount wholesale, so the mount must be
// the canvas slot ALONE. Passing the .card (which holds the h2 with the
// save/reset/add buttons) used to wipe the toolbar on every open — the
// lane canvas survived, the buttons did not. The per-key modal never hit
// this because its mount only ever contained the canvas.
$("#tab-sort").innerHTML = `
<div class="card">
<h2><span>${t("sortTitle")}</span><span class="grow"></span>${kindToggle}
<div class="card"><h2><span>${t("sortTitle")}</span><span class="grow"></span>${kindToggle}
<button class="ghost small" onclick="scrAddModal()">+ ${t("sortAdd")}</button>
<button class="ghost small" onclick="sortReset()">${t("sortReset")}</button>
<button class="small" onclick="saveSort()">${t("sortSave")}</button></h2>
<div id="sort-mount"></div>
<button class="small" onclick="saveSort()">${t("sortSave")}</button></h2>
</div>`;
renderSortEditor($("#sort-mount"), {
renderSortEditor($("#tab-sort").querySelector(".card"), {
canvasId: "scr-canvas",
});
}
@ -4504,8 +4420,6 @@ function afterChainEdit() {
<td><b>${esc(p.name)}</b>${builtin}<div class="muted">${esc(p.description || p.version || "")}</div>${err}</td>
<td>${state}</td>
<td>${hooks}</td>
<td>${plVisCell(j, p)}</td>
<td>${plEpsCell(j, p)}</td>
<td style="white-space:nowrap">
<button class="small" onclick="editPlugin('${escAttr(p.name)}')">${t("plEdit")}</button>
${toggle}
@ -4527,9 +4441,8 @@ function afterChainEdit() {
$("#tab-plugins").innerHTML = `
<div class="card"><h2>${t("plTitle")} (${(j.on_disk || []).length})</h2>
<div class="tbl-wrap"><table><tr>
<th>${t("tName")}</th><th>${t("plState")}</th><th>${t("plStages")}</th><th>${t("plVisibility")}</th><th>${t("plEndpoints")}</th><th></th>
</tr>${rows || `<tr><td colspan="6" class="empty">${t("plEmpty")}</td></tr>`}</table></div>
<div id="pl-access"></div>
<th>${t("tName")}</th><th>${t("plState")}</th><th>${t("plStages")}</th><th></th>
</tr>${rows || `<tr><td colspan="4" class="empty">${t("plEmpty")}</td></tr>`}</table></div>
${errList ? `<p class="muted" style="color:var(--danger)">${t("plHookErr")}</p><ul style="font-size:12px">${errList}</ul>` : ""}
<p class="muted">${t("plDir")} <code>${esc(j.plugin_dir)}</code></p>
</div>
@ -4542,161 +4455,6 @@ function afterChainEdit() {
<p><button onclick="installPlugin()">${t("plInstallBtn")}</button></p>
</div>`;
bindDropzone("pl-dz", "pl-file", "#pl-name", "#pl-code");
renderPluginAccess(j);
}
const PL_VIS = [
["", "tVisDefault"],
["admin", "tVisAdmin"],
["user,admin", "tVisRoles"],
["all", "tVisAll"],
];
// plVisCell shows the plugin's own declaration next to any operator
// override, because "admin-only" means something different depending on
// whether the plugin chose it or the deployment imposed it. Editing goes
// through the same access API as endpoints so there is one place where
// visibility is stored.
function plVisCell(j, p) {
const decl = (p.ui && p.ui.visibility) || "";
const ov = (j.visibility || {})[p.name] || "";
const cur = ov || decl;
// Compare as SETS. The server stores whatever order the operator typed
// and we round-trip it verbatim, so "admin, user" must still light up a
// choice — otherwise the selector silently shows "follow plugin", and
// saving from there writes an empty override that WIDENS access back to
// the plugin's own declaration.
const norm = (v) =>
(v || "")
.split(",")
.map((x) => x.trim())
.filter(Boolean)
.sort()
.join(",");
const curN = norm(cur);
// An audience with no matching choice (one naming a @key, say) is offered
// as itself so the value in force is never hidden from the operator.
const opts = PL_VIS.some((c) => norm(c[0]) === curN)
? PL_VIS
: PL_VIS.concat([[cur, "plCustomVis"]]);
return `<select data-plvis="${escAttr(p.name)}" style="font-size:11.5px;max-width:180px">
${opts
.map(
(c) =>
`<option value="${c[0]}"${norm(c[0]) === curN ? " selected" : ""}>${
c[1] === "plCustomVis" ? "自定义: " + cur : t(c[1])
}</option>`,
)
.join("")}
</select>${
ov && decl && ov !== decl
? `<div class="muted" style="font-size:10.5px" title="${esc(decl)}">${t("plOverridden")}</div>`
: ""
}`;
}
function plEpsCell(j, p) {
const n = (j.http_access || []).filter((e) => e.plugin === p.name).length;
return n
? `<a href="#" onclick="document.getElementById('pl-access').scrollIntoView({behavior:'smooth'});return false">${t("plEpsN").replace("%d", n)}</a>`
: `<span class="muted">—</span>`;
}
// ---- visibility & endpoint access ----
//
// The plugin page is where an operator edits who can see what. The
// alternatives are editing a bundled .lua — overwritten on the next
// upgrade — or hand-editing config.yaml, with no feedback about what is
// actually in force. Each row shows the rule next to whether it was
// DECLARED by the plugin or OVERRIDDEN here, because "admin-only" means
// something different depending on which.
function renderPluginAccess(j) {
const host = $("#pl-access");
if (!host) return;
const eps = j.http_access || [];
if (!eps.length) { host.innerHTML = ""; return; }
const keys = (window.__gwKeyRows || []).map((k) => k.name).filter(Boolean);
const byPlugin = {};
eps.forEach((e) => { (byPlugin[e.plugin] = byPlugin[e.plugin] || []).push(e); });
let h = `<div class="card" style="margin-top:12px"><h2>${t("plAccessTitle")}</h2>
<p class="muted" style="font-size:12px">${t("plAccessHint")}</p>`;
Object.keys(byPlugin).forEach((pn) => {
h += `<div style="margin:0 0 14px">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 6px">
<b style="font-size:13px">${esc(pn)}</b>
<span class="muted" style="font-size:11.5px">${t("plWholePlugin")}</span>
</div>`;
(byPlugin[pn] || []).forEach((e) => {
const ovr = (e.override_keys && e.override_keys.length) ||
(e.override_roles && e.override_roles.length);
const cur = ovr
? [...(e.override_keys || []).map((k) => "@" + k), ...(e.override_roles || [])].join(",")
: [...(e.keys || []).map((k) => "@" + k), ...(e.roles || []), e.public ? "public" : ""]
.filter(Boolean).join(",") || "admin";
h += `<div style="margin:6px 0 0;padding-left:10px;border-left:2px solid var(--bd)">
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
<code style="font-size:11.5px">${esc(e.method)} ${esc(e.full_path)}</code>
<span class="muted" style="font-size:11px">&rarr; ${esc(e.handler)}()</span>
${e.description ? `<span class="muted" style="font-size:11px">${esc(e.description)}</span>` : ""}
<span class="grow"></span>
<input data-ep="${esc(pn)}|${esc(e.path)}" value="${esc(cur)}"
style="max-width:340px;font-size:11.5px"
placeholder="admin, user, @homeagent, public">
</div>
${keys.length ? `<div style="font-size:11px;opacity:.7;margin-top:3px">${t("plKeyHint")}: ${esc(keys.join(", "))}</div>` : ""}
</div>`;
});
h += `</div>`;
});
h += `<p><button id="pl-access-save" onclick="savePluginAccess(this)">${t("plAccessSave")}</button></p></div>`;
host.innerHTML = h;
}
async function savePluginAccess(btn) {
btn.disabled = true;
try {
// Whole-plugin visibility first. Sent through the SAME access API as
// endpoints (path "*") so visibility has exactly one storage path.
const viss = Array.prototype.slice.call(
document.querySelectorAll("[data-plvis]"));
for (const sel of viss) {
const pn = sel.getAttribute("data-plvis");
const url =
"/api/plugins/" + encodeURIComponent(pn) + "/http/*/access";
const v = sel.value;
if (v) {
await api(url, { method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ access: v }) });
} else {
await api(url, { method: "DELETE" });
}
}
const inputs = Array.prototype.slice.call(
document.querySelectorAll("#pl-access [data-ep]"));
for (const inp of inputs) {
const raw = inp.getAttribute("data-ep");
const sep = raw.indexOf("|");
const pn = raw.slice(0, sep), ep = raw.slice(sep + 1);
const url = "/api/plugins/" + encodeURIComponent(pn) + "/http/" +
encodeURIComponent(ep) + "/access";
const v = inp.value.trim();
if (v) {
await api(url, { method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ access: v }) });
} else {
await api(url, { method: "DELETE" });
}
}
toast(t("toastSaved"));
renderPlugins();
} catch (e) {
toast(tFmt("toastSaveFail", e.message));
} finally {
btn.disabled = false;
}
}
async function togglePlugin(name, disabled) {
@ -5022,11 +4780,8 @@ function afterChainEdit() {
async function loadKeys() {
const el = $("#k-list");
if (!el) return;
const j = await api("/api/keys");
const j = await api("/api/keys");
const ks = j.keys || [];
// Cached so the per-key AUTO editor can read a key's model scope
// without a second request (keyAutoScopeWarning).
window._keyRows = ks;
el.innerHTML = ks.length
? ks.map((k) => keyCanvasHtml(k)).join("")
: `<div class="muted">${t("kEmpty")}</div>`;
@ -5482,7 +5237,6 @@ const j = await api("/api/keys");
wrap.innerHTML = `<div class="card" style="width:900px;max-width:100%">
<h2>${esc(t("kAutoChainTitle"))} \u00b7 ${esc(name || key)}</h2>
<div class="muted" style="margin-bottom:10px">${esc(t("kAutoChainHint"))}</div>
<div id="ka-scope-warn"></div>
<div id="ka-canvas-host"></div>
<p><button class="ghost small" onclick="scrAddModal()">+ ${esc(t("sortAdd"))}</button>
<button class="ghost small" onclick="keyAutoInherit()">${esc(t("kAutoChainInherit"))}</button>
@ -5493,67 +5247,48 @@ const j = await api("/api/keys");
// Park the key on the modal so a click that bubbles up cannot hit the
// global editor behind it.
wrap.addEventListener("click", (ev) => ev.stopPropagation());
try {
const j = await api("/api/v1/sources");
const idx = buildSortIndex(j.sources || []);
const cur = await api("/api/keys/" + encodeURIComponent(key) + "/auto");
keyAutoState.key = key;
keyAutoState.inherits = !!cur.inherits;
// A key with no chain of its own starts from a COPY OF THE GLOBAL
// CHAIN, not from an empty canvas. The realistic task is "give this
// user the global chain minus the two sources they must not hit",
// and starting blank forces the admin to retype every tier from a
// list of 226 models. This is NOT the discovery fallback (which
// dumps every model of every source): it is the same 10 rules the
// global chain actually runs.
let seededFromGlobal = false;
let rules = cur.auto || [];
if (!rules.length) {
try {
rules = ((await api("/api/auto")).rules) || [];
seededFromGlobal = rules.length > 0;
} catch (e) {
rules = [];
}
}
sortState.scope = key;
sortState.kind = "chat";
sortState.lanes = buildLanes(idx, rules, "chat");
sortState.origin = JSON.stringify(sortState.lanes);
await loadModelPairs();
renderSortEditor($("#ka-canvas-host"), { canvasId: "key-auto-canvas" });
keyAutoState.seeded = seededFromGlobal;
keyAutoInheritNotice();
keyAutoScopeWarning(key);
} catch (e) {
toast(String(e));
}
try {
const j = await api("/api/v1/sources");
const idx = buildSortIndex(j.sources || []);
const cur = await api("/api/keys/" + encodeURIComponent(key) + "/auto");
keyAutoState.key = key;
keyAutoState.inherits = !!cur.inherits;
// A key with no own chain shows an EMPTY canvas, never the discovery
// fallback buildLanes uses for an unconfigured global chain: that
// fallback fills every source's models (226 blocks on a real
// deployment), and showing them inside "this key's chain" dialog
// says they already belong to this user. They do not.
sortState.scope = key;
sortState.kind = "chat";
sortState.lanes =
cur.auto && cur.auto.length ? buildLanes(idx, cur.auto, "chat") : [];
sortState.origin = JSON.stringify(sortState.lanes);
await loadModelPairs();
renderSortEditor($("#ka-canvas-host"), { canvasId: "key-auto-canvas" });
// The notice is the only thing telling the user what an empty
// canvas means here. Without it the dialog is just a blank box and
// "Use global chain" looks like a button that already did
// something. It disappears as soon as the chain has slots of its
// own, which keyAutoSave's reload then confirms.
if (keyAutoState.inherits) keyAutoInheritNotice();
} catch (e) {
toast(String(e));
}
}
const keyAutoState = { key: null, inherits: true, seeded: false };
// The notice states what the canvas content MEANS relative to the server, which
// is the one thing the dialog cannot show visually: a full canvas can be
// either "this key's own chain (already saved)" or "a copy of the global
// chain that only becomes this key's chain on Save". Those two look
// identical and behave very differently if the admin hits Cancel.
function keyAutoInheritNotice() {
const keyAutoState = { key: null, inherits: true };
// The notice describes what an EMPTY canvas means, so it must track the
// canvas rather than the state the dialog opened with: adding the first
// slot makes "this key uses the global chain" false, and leaving it up
// reads as a contradiction right next to the slot the user just added.
function keyAutoInheritNotice(showing) {
const host = $("#ka-canvas-host");
if (!host) return;
let n = host.querySelector(".ka-inherit-note");
const hasSlots = (sortState.lanes || []).some(
(l) => (l.models || []).length,
);
let msg = "";
if (!hasSlots) {
// Empty canvas: saving restores inheritance.
msg = keyAutoState.inherits
? t("kAutoChainEmpty")
: t("kAutoChainWillInherit");
} else if (keyAutoState.inherits && keyAutoState.seeded) {
msg = t("kAutoChainSeeded");
} else if (keyAutoState.inherits) {
msg = t("kAutoChainNewOwn");
}
if (!msg) {
const want = showing === undefined ? !hasSlots : showing && !hasSlots;
if (!want) {
if (n) n.remove();
return;
}
@ -5563,13 +5298,12 @@ try {
n.style.margin = "0 0 10px";
host.appendChild(n);
}
n.textContent = msg;
n.textContent = t("kAutoChainEmpty");
}
// "Use the global chain" empties the lanes; saving then PUTs an empty
// list, which the server treats as clearing the override.
function keyAutoInherit() {
keyAutoState.inherits = true;
keyAutoState.seeded = false;
sortState.lanes = [];
sortState.origin = "[]";
const host = $("#ka-canvas-host");
@ -5580,38 +5314,12 @@ try {
paintSort();
keyAutoInheritNotice();
}
// keyAutoScopeWarning tells the admin when the key's model scope blocks
// AUTO outright. Configuring a per-key AUTO chain for such a key looks
// like it works, yet every request 403s — the scope filter runs before
// the chain is consulted. We deliberately do NOT widen the scope
// automatically: that would silently grant access nobody asked for.
function keyAutoScopeWarning(key) {
const host = $("#ka-scope-warn");
if (!host) return;
const rec = (window._keyRows || []).find((k) => k.key === key);
const models = (rec && rec.models) || [];
if (!models.length) {
host.innerHTML = "";
return;
}
const hasAuto = models.some(
(m) => (m.model || "").toUpperCase() === "AUTO",
);
host.innerHTML = hasAuto
? ""
: '<div class="warn-box">' +
esc(t("kAutoChainScopeWarn")) +
" " +
esc(models.map((m) => m.model).join(", ")) +
"</div>";
}
function keyAutoClose() {
const m = document.getElementById("key-auto-modal");
if (m) m.remove();
// Leaving scope set would make the global editor save to this key.
sortState.scope = null;
keyAutoState.key = null;
keyAutoState.seeded = false;
}
async function keyAutoSave(btn) {
const key = keyAutoState.key;
@ -5759,28 +5467,6 @@ toast(t("kAutoChainGlobal"));
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error?.message || r.status);
return true;
},
// call invokes one of the plugin's OWN exported functions. Billing never
// needs this — its numbers arrive on hooks while the gateway forwards a
// request — but a plugin that must do work on demand (asking a vendor
// for a balance when the operator clicks Refresh) has no other way to
// reach its own code: no hook fires on a click.
//
// Admin-only server side, and the plugin must have listed the function in
// plugin.exports, so this cannot be used to reach a plugin's internals.
async call(name, fn, args) {
const r = await fetch(
"/api/plugins/" + encodeURIComponent(name) + "/call/" + encodeURIComponent(fn),
{
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(args || {}),
},
);
const body = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(body.error?.message || r.status);
return body.result;
},
onTabShown(fn) {
PLUGIN_TAB_CBS.__last = PLUGIN_TAB_CBS.__last || [];
PLUGIN_TAB_CBS.__last.push(fn);

View File

@ -1,7 +1,6 @@
package gateway
import (
"os"
"strings"
"testing"
)
@ -267,172 +266,3 @@ func TestUIKeyAutoEditsDoNotAutoPersist(t *testing.T) {
}
}
}
// The stats range line formats its end date from the LAST bucket label.
// Bucket labels are hourly for the day view ("2026-10-03T09") and daily for
// week/month ("2026-10-01"), so blindly appending "T00:00:00Z" to the hourly
// form builds "2026-10-03T09T00:00:00Z" — Date rejects it, toISOString()
// throws, and the assignment is lost. The symptom is an empty range line on
// exactly the view people look at most, with no error anywhere.
func TestUIStatsPeriodRangeHandlesHourlyBuckets(t *testing.T) {
src := uiSource(t)
el := readFuncBody(t, src, "paintStats")
// It must branch on the label shape instead of concatenating blindly.
if !strings.Contains(el, `T\d{2}$`) && !strings.Contains(el, `\d{2}-\d{2}T\d{2}`) {
t.Fatal("paintStats must recognise the hourly bucket label shape " +
"(YYYY-MM-DDTHH); concatenating a time onto it yields an invalid Date")
}
// And it must not contain the naive form that broke it.
if strings.Contains(el, `bucket + "T00:00:00Z"`) {
t.Fatal("paintStats still appends T00:00:00Z to the bucket label — " +
"this throws on the day view and leaves the range line empty")
}
}
// A key with no chain of its own must open on a COPY OF THE GLOBAL CHAIN, not
// on an empty canvas. The realistic admin task is "this user gets the global chain
// minus the sources they must not hit"; starting blank forces retyping every
// tier against a 226-model picker. This is deliberately NOT the discovery
// fallback (which dumps every model of every source) — it is the same rules
// the global chain actually runs.
func TestUIKeyAutoSeedsFromGlobalChain(t *testing.T) {
src := uiSource(t)
body := readFuncBody(t, src, "openKeyAutoModal")
if !strings.Contains(body, `api("/api/auto")`) {
t.Fatal("openKeyAutoModal must read the global chain to seed the editor")
}
if !strings.Contains(body, "seededFromGlobal") {
t.Fatal("the editor must remember whether its content came from the " +
"global chain, so the notice can say so")
}
// And it must not fall back to the discovery path that fills 226 models.
if strings.Contains(body, `buildLanes(idx, [], `) {
t.Fatal("seeding from an empty rule list triggers buildLanes discovery")
}
}
// The notice must distinguish "already this key's chain" from "a copy that
// only becomes its chain on Save" — those two look identical on the canvas and
// behave very differently if the admin hits Cancel.
func TestUIKeyAutoNoticeDistinguishesSavedFromCopied(t *testing.T) {
src := uiSource(t)
body := readFuncBody(t, src, "keyAutoInheritNotice")
for _, k := range []string{"kAutoChainSeeded", "kAutoChainNewOwn", "kAutoChainWillInherit"} {
if !strings.Contains(body, k) {
t.Fatalf("the notice never uses %s — a copied chain and a saved one "+
"would look the same", k)
}
}
// Both languages, or a non-Chinese admin sees raw keys.
for _, k := range []string{"kAutoChainSeeded", "kAutoChainNewOwn", "kAutoChainWillInherit"} {
if n := strings.Count(src, k+`: "`); n < 2 {
t.Fatalf("i18n key %s defined %d time(s), want both zh and en", k, n)
}
}
}
// A key whose model scope excludes AUTO cannot use AUTO at all — the scope
// filter runs before the chain — yet the editor happily lets an admin
// configure a per-key chain. That combination is easy to create by accident
// and produces a 403 with no visible cause, so the dialog must say so.
//
// We deliberately do NOT widen the scope automatically: silently granting a
// model the operator did not ask for is worse than a loud warning.
func TestUIKeyAutoWarnsOnScopeConflict(t *testing.T) {
src := uiSource(t)
if !strings.Contains(src, "function keyAutoScopeWarning(") {
t.Fatal("no scope-conflict check in the per-key editor")
}
body := readFuncBody(t, src, "keyAutoScopeWarning")
if !strings.Contains(body, "AUTO") {
t.Fatal("keyAutoScopeWarning must test whether the scope lists AUTO")
}
// It must not write anything back to the key: reporting only.
for _, forbidden := range []string{"PUT", "POST", "fetch(", "api("} {
if strings.Contains(body, forbidden) {
t.Fatalf("keyAutoScopeWarning calls %s — it must only report the "+
"conflict, never widen the scope itself", forbidden)
}
}
// Empty scope means unrestricted, so no warning is correct there.
if !strings.Contains(body, "if (!models.length)") {
t.Fatal("an unrestricted key (no models) must not be warned")
}
// Both languages.
if n := strings.Count(src, "kAutoChainScopeWarn"); n < 2 {
t.Fatalf("i18n key kAutoChainScopeWarn appears %d time(s), want zh+en", n)
}
}
// The scope conflict for AUTO must be reported by checkQuota, because that is
// the check an AUTO request actually reaches first (it runs before the chain is
// consulted). Putting the message on the later path made it dead code — the
// request was rejected one check earlier with the generic "not allowed for this
// key" wording, which sends an operator looking for a quota that isn't the
// problem.
func TestAutoScopeConflictMessageComesFromCheckQuota(t *testing.T) {
b, err := os.ReadFile("chat.go")
if err != nil {
t.Fatalf("read chat.go: %v", err)
}
src := string(b)
i := strings.Index(src, "func (g *Gateway) checkQuota(")
if i < 0 {
t.Fatal("checkQuota not found")
}
// Body = up to the next top-level func.
rest := src[i:]
if j := strings.Index(rest[1:], "\nfunc "); j > 0 {
rest = rest[:j+1]
}
if !strings.Contains(rest, "isAuto(model)") {
t.Fatal("checkQuota must special-case AUTO: a scope that omits AUTO is " +
"rejected there before anything else looks at the chain")
}
if !strings.Contains(rest, "not in this key's model scope") {
t.Fatal("checkQuota's AUTO branch must name the scope as the cause")
}
}
// renderSortEditor replaces its mount's innerHTML wholesale. The global
// priority page used to hand it the .card that also holds the h2 toolbar, so
// every open deleted the save/reset/add buttons: the lanes still rendered and
// the drag worked, but there was no way to persist a change. The per-key modal
// never showed this because its mount held only the canvas.
//
// The regression is invisible in the DOM when you only count lane elements, so
// this asserts on the structure: the toolbar lives in a sibling of the mount,
// not inside it.
func TestGlobalSortToolbarSurvivesCanvasRender(t *testing.T) {
src := uiSource(t)
// The global editor must mount into a dedicated host, not the .card.
i := strings.Index(src, `$("#tab-sort").innerHTML = `)
if i < 0 {
t.Fatal("global sort markup not found")
}
chunk := src[i:min(len(src), i+1400)]
if !strings.Contains(chunk, `id="sort-mount"`) {
t.Fatal("global sort page must provide a dedicated #sort-mount for the canvas")
}
// The mount handed to renderSortEditor must be that host.
if !strings.Contains(src, `renderSortEditor($("#sort-mount")`) {
t.Fatal("renderSortEditor must be given #sort-mount; passing the .card " +
"wipes the h2 toolbar that holds the save button")
}
// The toolbar (save in particular) must be OUTSIDE #sort-mount.
close := strings.Index(chunk, "</div>`;")
if close < 0 {
t.Fatal("malformed #tab-sort template")
}
head := chunk[:close]
mountAt := strings.Index(head, `id="sort-mount"`)
saveAt := strings.Index(head, `onclick="saveSort()"`)
if saveAt < 0 {
t.Fatal("the global sort page has no save button at all")
}
if mountAt >= 0 && saveAt > mountAt {
t.Fatal("the save button sits inside the canvas mount and will be " +
"erased the moment the canvas renders")
}
}

View File

@ -388,26 +388,3 @@ func TestPluginSidebarEntryCarriesATitle(t *testing.T) {
t.Errorf("plugin sidebar button renders the icon only; a label is required. "+
"got: %.200s", stmt)
}
// TestPluginPageHasAccessEditor: the plugin page must let an operator configure
// endpoint access. The APIs existed with zero UI entry point, which meant the
// only way to change who could reach a plugin endpoint was to hand-edit
// config.yaml — the thing the UI is meant to replace.
func TestPluginPageHasAccessEditor(t *testing.T) {
src := uiSource(t)
for _, needle := range []string{
`renderPluginAccess`, // called after rendering the plugin list
`savePluginAccess`, // wired to a button
`/access`, // hits the per-endpoint access API
`id="pl-access"`, // container exists
`http_access`, // reads the table from /api/plugins
`data-ep=`, // one input per endpoint
`data-plvis=`, // one visibility selector per plugin
`/http/*/access`, // whole-plugin visibility shares the access API
`plVisCell`, // renders the declared-vs-override distinction
} {
if !strings.Contains(src, needle) {
t.Errorf("plugin page lacks %q", needle)
}
}
}

View File

@ -2,25 +2,12 @@ package lua
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
// pluginLoaded reports whether a plugin with this name is present, ignoring any
// LoadError: a plugin that is present but broken still counts as "loaded here",
// and the caller decides whether the error matters.
func pluginLoaded(ps *Plugins, name string) bool {
for _, p := range ps.List() {
if fmt.Sprint(p["name"]) == name {
return true
}
}
return false
}
// The billing plugin ships with the gateway, so its arithmetic is a contract:
// a wrong price silently produces wrong money. These tests drive it through the
// real hook path and check the NUMBERS, not merely that it loads.
@ -460,16 +447,8 @@ return plugin`)
}
// Order must be honoured so the sidebar is predictable.
//
// The page COUNT is not asserted: it used to be 4, and it grew every time a
// bundled plugin added a page. A hard count here reads as "the merge is
// broken" whenever the product gains a feature, which trains the reader to
// bump a number instead of reading the failure. What matters is that all
// three pages this test contributed merged, and that they came out sorted.
for _, want := range []string{"third-a", "third-b", "other-page"} {
if !got[want] {
t.Errorf("page %s did not merge: %v", want, got)
}
if len(ui.Pages) != 4 {
t.Fatalf("expected all 4 pages to merge, got %d: %v", len(ui.Pages), got)
}
for i := 1; i < len(ui.Pages); i++ {
if ui.Pages[i].Order < ui.Pages[i-1].Order {
@ -483,15 +462,8 @@ return plugin`)
// configuration, since seeding only happens on a fresh plugin dir.
func TestBillingPluginLoadedByDefault(t *testing.T) {
ps, pdir := billingVM(t)
// billing must be among the bundled plugins, not the only one. The count
// grows with every shipped plugin and asserting it makes this test a
// changelog detector.
if !pluginLoaded(ps, "billing") {
var names []string
for _, p := range ps.List() {
names = append(names, fmt.Sprint(p["name"]))
}
t.Fatalf("billing is not loaded; bundled plugins are %v", names)
if ps.Count() != 1 {
t.Fatalf("expected 1 bundled plugin, got %d", ps.Count())
}
if _, err := os.Stat(filepath.Join(pdir, "billing.lua")); err != nil {
t.Errorf("billing.lua was not written to the plugin dir: %v", err)

View File

@ -1,115 +0,0 @@
package lua
import (
"encoding/json"
"os"
"sort"
)
// Access overrides are OPERATOR policy, not plugin data. They live in their own
// file rather than in each plugin's state because:
//
// - they describe the deployment, not the plugin, so a plugin upgrade that
// replaces its .state.json must not silently widen access again;
// - they must survive a restart. An in-memory map made "restrict billing to
// admin" look like it worked right up to the next deploy, at which point the
// restriction evaporated with nothing in the logs to say why;
// - the plugin_dir is the one place an operator already looks when asking
// "what does this plugin actually get to see", so the answer lives beside it.
//
// The file is a flat map of "name" (whole plugin) or "name#path" (one endpoint)
// to an access spec, which is the same shape the in-memory store uses, so load
// and save cannot disagree about what a key means.
const accessFileName = ".access.json"
func (ps *Plugins) accessFile() string {
if ps.dir == "" {
return ""
}
return ps.dir + string(os.PathSeparator) + accessFileName
}
// LoadAccessOverrides reads the persisted overrides into memory. A missing file
// is not an error: no operator has restricted anything yet, which is the
// default state of a fresh install.
//
// A corrupt file is also not fatal. Losing a restriction is bad, but refusing to
// start the gateway is worse, and an empty map plus a logged error leaves the
// operator with something to fix.
func (ps *Plugins) LoadAccessOverrides() error {
path := ps.accessFile()
if path == "" {
return nil
}
b, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
m := map[string]string{}
if err := json.Unmarshal(b, &m); err != nil {
return err
}
ps.mu.Lock()
defer ps.mu.Unlock()
if ps.visibility == nil {
ps.visibility = visibilityOverrides{}
}
for k, v := range m {
ps.visibility[k] = v
}
return nil
}
// saveAccessOverridesLocked flushes the whole override map. It is called on
// every change, and unlike plugin state the map is tiny and changes only when an
// operator clicks Save — so a synchronous write costs nothing that matters, and
// it means the change is durable the moment the API returns 200.
func (ps *Plugins) saveAccessOverridesLocked() {
path := ps.accessFile()
if path == "" {
return
}
out := make(map[string]string, len(ps.visibility))
for k, v := range ps.visibility {
if v != "" {
out[k] = v
}
}
if len(out) == 0 {
// Leave no empty file behind: its absence is the honest encoding of
// "nothing is overridden".
_ = os.Remove(path)
return
}
writeJSONAtomic(path, out)
}
// AccessOverrides returns a copy of every override in force, for the UI.
func (ps *Plugins) accessOverridesSnapshot() map[string]string {
ps.mu.RLock()
defer ps.mu.RUnlock()
if len(ps.visibility) == 0 {
return nil
}
out := make(map[string]string, len(ps.visibility))
for k, v := range ps.visibility {
out[k] = v
}
return out
}
// sortedOverrideKeys exists only so a debug dump is stable; map iteration order
// would otherwise make two identical states look different.
func (ps *Plugins) sortedOverrideKeys() []string {
m := ps.accessOverridesSnapshot()
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

View File

@ -1,110 +0,0 @@
package lua
import (
"os"
"path/filepath"
"testing"
)
// TestAccessOverrideSurvivesReload is the test that matters for an access
// restriction. An in-memory map makes "billing is admin-only" look like it
// worked right up to the next restart, and then the restriction is simply gone
// with nothing in the logs to explain it. Both the plugin-level audience and
// the per-endpoint spec must come back.
func TestAccessOverrideSurvivesReload(t *testing.T) {
dir := t.TempDir()
src := `local P = {}
P.name = "api"
P.version = "1.0.0"
P.http = {
{ path = "health", handler = "health", roles = { "admin" } },
}
function P.health(req) return { json = { ok = true } } end
return P
`
first := NewPlugins(NewVM(dir), dir)
if err := first.LoadSource("api", src); err != nil {
t.Fatal(err)
}
first.SetPluginVisibility("api", "admin, user")
if err := first.SetEndpointAccess("api", "health", "@homeagent"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, accessFileName)); err != nil {
t.Fatalf("override was not written: %v", err)
}
// A fresh Plugins over the same dir is what a restart looks like.
second := NewPlugins(NewVM(dir), dir)
// The plugin source is on disk too, so this second instance sees both the
// plugin and the overrides — the same pair a restart sees. Loading only the
// overrides would leave no endpoints to walk, and the assertion below would
// pass vacuously.
if err := second.LoadSource("api", src); err != nil {
t.Fatal(err)
}
if err := second.LoadAccessOverrides(); err != nil {
t.Fatalf("reload overrides: %v", err)
}
if got := second.VisibilityOverride("api"); got != "admin, user" {
t.Errorf("plugin visibility lost across reload: %q", got)
}
found := false
for _, e := range second.EndpointAccess() {
if e.Path == "health" && len(e.OverrideKeys) == 1 && e.OverrideKeys[0] == "homeagent" {
found = true
}
}
if !found {
t.Error("endpoint override lost across reload")
}
}
// TestAccessOverrideClearedRemovesFile keeps the "nothing overridden" state
// distinguishable from "overridden with something".
func TestAccessOverrideClearedRemovesFile(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
ps.SetPluginVisibility("api", "admin")
path := filepath.Join(dir, accessFileName)
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected an access file: %v", err)
}
ps.SetPluginVisibility("api", "")
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("clearing the last override should remove the file, got %v", err)
}
}
// TestVisibilityOverridesExcludeEndpointKeys keeps endpoint policy out of the
// plugin-audience listing. Both live in one map, and reporting "probe#hello"
// under visibility is how the plugin page ends up offering to save an audience
// of that name — which nothing reads as an audience.
func TestVisibilityOverridesExcludeEndpointKeys(t *testing.T) {
dir := t.TempDir()
src := `local P = {}
P.name = "api"
P.version = "1.0.0"
P.http = { { path = "health", handler = "health", roles = { "admin" } } }
function P.health(req) return { json = { ok = true } } end
return P
`
ps := NewPlugins(NewVM(dir), dir)
if err := ps.LoadSource("api", src); err != nil {
t.Fatal(err)
}
ps.SetPluginVisibility("api", "admin, user")
if err := ps.SetEndpointAccess("api", "health", "@homeagent"); err != nil {
t.Fatal(err)
}
got := ps.VisibilityOverrides()
if got["api"] != "admin, user" {
t.Errorf("plugin audience missing: %+v", got)
}
if _, leaked := got["api#health"]; leaked {
t.Errorf("endpoint override leaked into the audience map: %+v", got)
}
if len(got) != 1 {
t.Errorf("expected exactly the plugin audience, got %+v", got)
}
}

View File

@ -1,248 +0,0 @@
package lua
// Outbound HTTP for plugins.
//
// The billing plugin never needed this: everything it reports arrives on a hook
// payload that the gateway produced while forwarding a request. A quota plugin
// is the first thing that must REACH OUT on its own — Command Code exposes
// /alpha/billing/credits and friends, and there is no way to learn a plan's
// remaining balance without asking.
//
// That capability is also the sharpest edge in the plugin model. A plugin is a
// single .lua file the WebUI installs from a textarea: untrusted, browser-facing
// code that runs in the gateway process. Handing it a general-purpose HTTP
// client would make every install a standing SSRF primitive against whatever
// the gateway can reach — the cloud metadata endpoint, the cluster control
// plane, whatever else sits on 127.0.0.1 in this deployment. Reading the
// environment for credentials is a second door out.
//
// So the bridge is deliberately narrow:
//
// - Destination must match a host that a CONFIGURED SOURCE already talks to.
// Those are operator-declared, not plugin-declared: a plugin cannot widen
// its own reach by inventing a URL, and an existing source's host is
// exactly the set of upstreams this deployment already trusts with API keys.
// - http:// on a non-loopback host is refused. Credentials belong in HTTPS.
// - Loopback needs no explicit opt-in: it is the only way a plugin can read
// the gateway's own API (the billing rules UI does exactly that), and it
// cannot leave the machine.
// - Every request is bounded by a timeout and the response is size-capped, so
// a plugin cannot wedge a worker goroutine or exhaust memory.
// - Credentials are NEVER read from the environment here. A plugin that needs
// a key must be given it (state, or the caller's own config), which keeps
// the grant visible to the operator instead of silently ambient.
//
// Redirects are NOT followed: the allowlist is checked per hop, and a redirect
// to an unlisted host would otherwise walk straight past it. A 3xx is returned
// as-is so the plugin can see it.
import (
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
golua "github.com/aarzilli/golua/lua"
)
// httpDefaultTimeout bounds a whole request; httpMaxResponseBytes caps the body
// a plugin can pull into the gateway's heap.
const (
httpDefaultTimeout = 15 * time.Second
httpMaxTimeout = 60 * time.Second
httpMaxResponse = 4 << 20 // 4 MiB
)
// httpClient is shared by every plugin request. It deliberately has no
// ProxyFromEnvironment: an operator who proxies upstreams configures the proxy
// per source, and a plugin request should not silently inherit one.
var httpClient = &http.Client{
Timeout: httpDefaultTimeout,
Transport: &http.Transport{
Proxy: nil,
DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 16,
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: time.Second,
},
// See above: a redirect is a hop the allowlist has not seen.
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
// httpGlobal is the Lua name of the outbound bridge.
const httpGlobal = "__llmsproxy_http"
// httpAllowlist is the set of hosts plugins may contact: the host of every
// configured source's base_url.
type httpAllowlist struct {
hosts map[string]bool
}
func newHTTPAllowlist(hosts []string) *httpAllowlist {
m := make(map[string]bool, len(hosts))
for _, h := range hosts {
if h != "" {
m[strings.ToLower(h)] = true
}
}
return &httpAllowlist{hosts: m}
}
// allows reports whether host:port may be contacted. Ports 80/443 are implied
// by the bare host so a source configured as https://api.example.com/v1 does not
// have to spell the port.
func (a *httpAllowlist) allows(host string, port string) bool {
host = strings.ToLower(host)
if host == "" {
return false
}
if a.hosts[host] {
return true
}
switch port {
case "", "80", "443":
return a.hosts[host]
}
return a.hosts[net.JoinHostPort(host, port)]
}
// checkURL validates a plugin-supplied URL against the allowlist. It returns
// the parsed URL plus a reason when it is refused.
func (a *httpAllowlist) checkURL(raw string) (*url.URL, error) {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return nil, fmt.Errorf("bad url: %v", err)
}
switch strings.ToLower(u.Scheme) {
case "https":
case "http":
// Plain HTTP is only tolerated on loopback, where it stays on this
// machine and is how the gateway's own plain-HTTP dev port is reached.
if !isLoopbackHost(u.Hostname()) {
return nil, fmt.Errorf("refusing plain http to non-loopback host %q: use https", u.Hostname())
}
default:
return nil, fmt.Errorf("unsupported scheme %q", u.Scheme)
}
if !a.allows(u.Hostname(), u.Port()) && !isLoopbackHost(u.Hostname()) {
// Loopback needs no entry: it cannot leave the machine, and the billing
// rules UI reads the gateway's own API over plain HTTP in dev setups.
return nil, fmt.Errorf("host %q is not an allowed outbound destination "+
"(allowed: the hosts of configured sources, plus loopback)", u.Hostname())
}
return u, nil
}
func isLoopbackHost(h string) bool {
if h == "localhost" {
return true
}
ip := net.ParseIP(strings.Trim(h, "[]"))
return ip != nil && ip.IsLoopback()
}
// httpRequestSpec is the decoded argument table Lua passes to __llmsproxy_http.
type httpRequestSpec struct {
URL string
Method string
Headers map[string]string
Body string
Timeout time.Duration
}
// luaHTTPFn builds the golua closure. It takes one Lua table argument and
// returns (responseTable, nil) or (nil, errorMessage) — never raises, so a
// plugin mistake cannot take the gateway with it.
func luaHTTPFn(L *golua.State, allow *httpAllowlist) golua.LuaGoFunction {
return func(L *golua.State) int {
spec, err := readHTTPRequestSpec(L)
if err != nil {
L.PushNil()
L.PushString(err.Error())
return 2
}
u, err := allow.checkURL(spec.URL)
if err != nil {
L.PushNil()
L.PushString(err.Error())
return 2
}
resp, err := performHTTP(u, spec)
if err != nil {
L.PushNil()
L.PushString(err.Error())
return 2
}
pushHTTPResponse(L, resp)
L.PushNil()
return 2
}
}
func performHTTP(u *url.URL, spec httpRequestSpec) (*httpResult, error) {
method := strings.ToUpper(spec.Method)
if method == "" {
method = http.MethodGet
}
timeout := spec.Timeout
if timeout <= 0 || timeout > httpMaxTimeout {
timeout = httpDefaultTimeout
}
var body io.Reader
if spec.Body != "" {
body = strings.NewReader(spec.Body)
}
req, err := http.NewRequest(method, u.String(), body)
if err != nil {
return nil, fmt.Errorf("build request: %v", err)
}
for k, v := range spec.Headers {
req.Header.Set(k, v)
}
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "ModelRouter-plugin")
}
client := *httpClient
client.Timeout = timeout
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %v", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, httpMaxResponse+1))
if err != nil {
return nil, fmt.Errorf("read body: %v", err)
}
if len(data) > httpMaxResponse {
return nil, fmt.Errorf("response exceeds %d byte cap", httpMaxResponse)
}
return &httpResult{
Status: resp.StatusCode,
Headers: flattenHeaders(resp.Header),
Body: string(data),
}, nil
}
type httpResult struct {
Status int
Headers map[string]string
Body string
}
func flattenHeaders(h http.Header) map[string]string {
out := make(map[string]string, len(h))
for k := range h {
out[strings.ToLower(k)] = h.Get(k)
}
return out
}

View File

@ -1,122 +0,0 @@
package lua
import (
"fmt"
"strings"
"time"
golua "github.com/aarzilli/golua/lua"
)
// httpArgIdx is the stack slot holding the caller's table. Verified against
// golua: a PushGoClosure function is invoked with its arguments already on top
// and nothing of itself below them.
const httpArgIdx = 1
// readHTTPRequestSpec decodes the Lua argument table:
//
// __llmsproxy_http({
// url = "https://host/path",
// method = "GET", -- optional, default GET
// headers = { ["X-A"] = "1" }, -- optional
// body = "...", -- optional
// timeout_ms = 8000, -- optional, clamped to 60s
// })
//
// The table is at index 1: measured, not assumed. A Go closure registered with
// PushGoClosure is NOT on the stack when it runs — GetTop() is 1 and slot 1 is
// the caller's table (type LUA_TTABLE). An earlier version assumed the closure
// occupied slot 1 and read from slot 2, which then reported "expected a table
// argument" for a perfectly good call.
//
// luaTableToGoValue already turns the table into a map/slice, so the whole
// argument is decoded in one pass instead of walking the table field by field.
func readHTTPRequestSpec(L *golua.State) (httpRequestSpec, error) {
var spec httpRequestSpec
if L.GetTop() < httpArgIdx {
return spec, fmt.Errorf("__llmsproxy_http: expected a table argument")
}
if L.Type(httpArgIdx) != golua.LUA_TTABLE {
return spec, fmt.Errorf("__llmsproxy_http: expected a table, got %s",
normalizeString(L, httpArgIdx))
}
arg, ok := luaTableToGoValue(L, httpArgIdx).(map[string]interface{})
if !ok {
return spec, fmt.Errorf("__llmsproxy_http: argument must be a keyed table")
}
for k, v := range arg {
switch strings.ToLower(k) {
case "url", "uri", "endpoint":
spec.URL = asString(v)
case "method", "verb":
spec.Method = asString(v)
case "body", "data", "payload":
spec.Body = asString(v)
case "headers", "header":
if m, ok := v.(map[string]interface{}); ok {
spec.Headers = make(map[string]string, len(m))
for hk, hv := range m {
spec.Headers[hk] = asString(hv)
}
}
case "timeout_ms", "timeoutms":
if n, ok := asFloat(v); ok {
spec.Timeout = time.Duration(n) * time.Millisecond
}
}
}
if spec.URL == "" {
return spec, fmt.Errorf("__llmsproxy_http: missing url")
}
return spec, nil
}
// pushHTTPResponse converts the result into two Lua values: a table
// {status, headers, body} and nil. The caller pushes the error slot itself.
func pushHTTPResponse(L *golua.State, r *httpResult) {
L.NewTable()
L.PushString("status")
L.PushNumber(float64(r.Status))
L.SetTable(-3)
L.PushString("headers")
pushStringMap(L, r.Headers)
L.SetTable(-3)
L.PushString("body")
L.PushString(r.Body)
L.SetTable(-3)
}
func pushStringMap(L *golua.State, m map[string]string) {
L.NewTable()
for k, v := range m {
L.PushString(v)
L.SetField(-2, k)
}
}
func asString(v interface{}) string {
switch t := v.(type) {
case string:
return t
case nil:
return ""
default:
return fmt.Sprintf("%v", t)
}
}
func asFloat(v interface{}) (float64, bool) {
switch t := v.(type) {
case float64:
return t, true
case int:
return float64(t), true
case int64:
return float64(t), true
default:
return 0, false
}
}

View File

@ -1,530 +0,0 @@
package lua
// Plugin-registered HTTP endpoints.
//
// The gateway mounts what a plugin declares in plugin.http and calls back into
// the plugin's Lua state to produce each response. This is the piece billing
// never needed: it only receives hooks, so it has nothing to expose. A plugin
// that computes something an operator wants to query from outside the browser —
// a health probe, a CI check, another tool — needs a route, and hard-coding one
// per plugin would mean a gateway change for every such plugin.
//
// The request and response are both plain tables crossing the Go/Lua boundary:
//
// request { method, path, query{}, params{}, headers{}, body, role, key }
// response { status, headers{}, body } or { status, json = <any table> }
//
// A handler returns (response, nil) or (nil, errorMessage). Errors never raise:
// an escaping Lua error inside a hook has taken the whole gateway down before
// (see hookGuardSrc), so the same discipline applies here.
//
// Two properties are enforced on the Go side rather than trusted to the plugin:
//
// - Method and status are checked against an allowlist before the response is
// written. A plugin returning status 0 or a redirect with an injected
// Location must not be able to steer the gateway's own routing.
// - Path traversal cannot escape the mount: params are substituted into a
// fixed segment count, and the handler's own Path is rejected at
// registration if it contains "..".
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
// allowedMethods is deliberately short. An arbitrary verb would need a case in
// the router for no benefit, and a plugin that wants every verb can register the
// five that exist.
var allowedMethods = map[string]bool{
http.MethodGet: true, http.MethodPost: true, http.MethodPut: true,
http.MethodDelete: true, http.MethodPatch: true,
}
// normalizeHandlerMethod validates and defaults a declared method.
func normalizeHandlerMethod(m string) (string, error) {
m = strings.ToUpper(strings.TrimSpace(m))
if m == "" {
return http.MethodGet, nil
}
if !allowedMethods[m] {
return "", fmt.Errorf("unsupported method %q (allowed: GET POST PUT DELETE PATCH)", m)
}
return m, nil
}
// validateHandlerPath rejects paths that could escape the mount or collide with
// the plugin state/call routes.
func validateHandlerPath(p string) error {
p = strings.TrimSpace(p)
if p == "" {
return fmt.Errorf("empty path")
}
if strings.Contains(p, "..") {
return fmt.Errorf("path %q contains .. ", p)
}
if strings.Contains(p, "//") {
return fmt.Errorf("path %q contains an empty segment", p)
}
for _, seg := range strings.Split(strings.Trim(p, "/"), "/") {
if seg == "" {
continue
}
if strings.HasPrefix(seg, ":") {
if len(seg) == 1 {
return fmt.Errorf("path %q has a nameless param segment", p)
}
continue
}
// A literal segment must not look like a wildcard, or the route table
// gets two patterns that match the same URL.
if strings.ContainsAny(seg, "*?") {
return fmt.Errorf("path %q uses a wildcard; use :name for a param", p)
}
}
return nil
}
// handlerRequest is what a Lua handler sees.
type handlerRequest struct {
Method string
Path string
Query map[string]string
Params map[string]string
Headers map[string]string
Body string
Role string
Key string
}
// handlerResponse is what a Lua handler returns.
type handlerResponse struct {
Status int
Headers map[string]string
Body string
// JSON is set when the handler returned {json = <table>}; it is encoded by
// the kernel so a plugin does not hand-roll escaping.
JSON interface{}
}
// writeHTTPHandlerResponse renders a handler's reply onto the gateway's writer.
//
// The status is validated even though it came from a plugin: a handler
// returning 0 or 999 would make net/http panic, and one returning 3xx with a
// Location the plugin chose could be used to bounce a caller at a URL of the
// plugin's picking. Anything outside 100..599 that is not a redirect becomes 500
// with an error body rather than a panic.
func writeHTTPHandlerResponse(w http.ResponseWriter, r *handlerResponse) {
status := r.Status
if status == 0 {
status = http.StatusOK
}
if status < 100 || status > 599 {
status = http.StatusInternalServerError
}
for k, v := range r.Headers {
// A plugin cannot set Content-Length (the body is written by the kernel)
// or hop-by-hop headers (Connection, Transfer-Encoding) which would
// corrupt the gateway's own response framing.
switch http.CanonicalHeaderKey(k) {
case "Content-Length", "Connection", "Transfer-Encoding", "Upgrade":
continue
}
w.Header().Set(k, v)
}
if r.JSON != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
// Encoded here rather than by the gateway layer: this file is in the lua
// package and importing the gateway would be a cycle.
if enc, err := json.Marshal(r.JSON); err == nil {
_, _ = w.Write(enc)
} else {
_, _ = w.Write([]byte(`{"error":"handler json could not be encoded"}`))
}
return
}
if w.Header().Get("Content-Type") == "" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
}
w.WriteHeader(status)
if r.Body != "" {
_, _ = w.Write([]byte(r.Body))
}
}
// parseHandlerResponse reads a Lua handler's return value.
//
// It accepts three shapes, because a plugin should not have to think about it:
//
// { status = 200, body = "text" }
// { status = 201, json = { any = "table" } }
// { status = 204 } -- no body
//
// Anything else — a string, a number, a table without status — is a handler bug
// and becomes an error rather than an empty 200, so the plugin author sees it.
func parseHandlerResponse(v interface{}) (*handlerResponse, error) {
m, ok := v.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("handler must return a table, got %T", v)
}
out := &handlerResponse{Status: http.StatusOK}
if s, ok := m["status"]; ok {
switch n := s.(type) {
case float64:
out.Status = int(n)
case int:
out.Status = n
default:
return nil, fmt.Errorf("status must be a number, got %T", s)
}
}
if b, ok := m["body"].(string); ok {
out.Body = b
}
if h, ok := m["headers"].(map[string]interface{}); ok {
out.Headers = make(map[string]string, len(h))
for k, hv := range h {
out.Headers[k] = asString(hv)
}
}
if j, ok := m["json"]; ok && j != nil {
out.JSON = j
}
// An empty table is a handler bug — it means the author forgot to say what to
// return. A table carrying ONLY a status is not: `{status=204}` is the normal
// way to answer with no body, and treating it as an error would make the
// correct spelling unusable.
if out.Body == "" && out.JSON == nil && out.Headers == nil && len(m) == 0 {
return nil, fmt.Errorf("handler returned an empty table; return " +
"{status=…, body=…}, {status=…, json=…}, or {status=…} for no body")
}
return out, nil
}
// InvokeHandler calls a plugin's HTTP handler function with a request table and
// returns the decoded response.
//
// It reuses the same guard-in-Lua contract as the hooks: the function is called
// through __llmsproxy_call_hook, so a plugin error comes back as (nil, message)
// and never as a C-level abort.
func (ps *Plugins) InvokeHandler(plugin, fnName string, req *handlerRequest) (*handlerResponse, error) {
p := ps.find(plugin)
if p == nil {
return nil, fmt.Errorf("plugin %s not found", plugin)
}
p.mu.Lock()
w := p.state
p.mu.Unlock()
if w == nil {
return nil, fmt.Errorf("plugin %s has no state", plugin)
}
// The whole call happens under the plugin lock, network waits included —
// same property as Call, and for the same reason: one Lua state.
p.mu.Lock()
defer p.mu.Unlock()
L := w.L
L.SetTop(0)
defer L.SetTop(0)
L.GetGlobal(pluginGlobal)
if L.IsNil(-1) {
return nil, fmt.Errorf("plugin table missing")
}
plug := L.GetTop()
L.GetGlobal(hookGuardName)
if L.IsNil(-1) {
L.Pop(1)
}
L.GetField(plug, fnName)
if !L.IsFunction(-1) {
return nil, fmt.Errorf("handler %s not found in plugin %s", fnName, plugin)
}
pushGoValue(L, plainForLua(map[string]interface{}{
"method": req.Method,
"path": req.Path,
"query": stringMapToAny(req.Query),
"params": stringMapToAny(req.Params),
"headers": stringMapToAny(req.Headers),
"body": req.Body,
"role": req.Role,
"key": req.Key,
}))
if err := L.Call(2, 2); err != nil {
return nil, err
}
errMsg := ""
if L.GetTop() >= 2 {
if !L.IsNil(-1) {
errMsg = L.ToString(-1)
}
L.Pop(1)
}
if errMsg != "" {
return nil, fmt.Errorf("plugin %s: %s", plugin, errMsg)
}
if L.GetTop() < 1 || L.IsNil(-1) {
return nil, fmt.Errorf("handler %s returned nothing", fnName)
}
var raw interface{}
if err := luaToJSON(L, -1, &raw); err != nil {
return nil, err
}
return parseHandlerResponse(raw)
}
func stringMapToAny(m map[string]string) map[string]interface{} {
if len(m) == 0 {
return map[string]interface{}{}
}
out := make(map[string]interface{}, len(m))
for k, v := range m {
out[k] = v
}
return out
}
// HandlerRequest is the request table handed to a plugin HTTP handler.
// Exported because the gateway package constructs it.
type HandlerRequest = handlerRequest
// HandlerResponse is what a handler returns, after decoding.
type HandlerResponse = handlerResponse
// WriteHandlerResponse renders a handler response onto the gateway's writer.
func WriteHandlerResponse(w http.ResponseWriter, r *HandlerResponse) {
writeHTTPHandlerResponse(w, r)
}
// HTTPHandlers returns the registered endpoints across all plugins, each tagged
// with its owner so the dispatcher knows which Lua state to enter.
func (ps *Plugins) HTTPHandlers() []HTTPHandler {
ps.mu.RLock()
defer ps.mu.RUnlock()
var out []HTTPHandler
for _, p := range ps.plugins {
if p.http == nil || p.LoadError != "" {
continue
}
for _, declared := range p.http {
// effectiveAccess applies the operator's per-endpoint override, so the
// dispatcher enforces what the WebUI shows rather than what the .lua
// declared.
h := ps.effectiveAccess(p, declared)
h.Plugin = p.Info.Name
out = append(out, h)
}
}
return out
}
// Allows is the exported form of the access rule, so the gateway dispatcher does
// not need a duplicate copy of it in its own package.
func (h HTTPHandler) Allows(role, keyName string) bool { return h.allows(role, keyName) }
// HTTPEndpointAccess is the operator's view of one endpoint's permissions,
// returned by /api/plugins so the WebUI can render what is exposed without
// parsing .lua files.
type HTTPEndpointAccess struct {
Plugin string `json:"plugin"`
Path string `json:"path"`
FullPath string `json:"full_path"`
Method string `json:"method"`
Handler string `json:"handler"`
Roles []string `json:"roles,omitempty"`
Keys []string `json:"keys,omitempty"`
Public bool `json:"public"`
Description string `json:"description,omitempty"`
// OverrideRoles / OverrideKeys carry the operator's own restriction, which
// REPLACES whatever the plugin declared. Empty means "use the plugin's".
//
// This is the mechanism the admin uses to narrow (or widen) an endpoint's
// reachable keys without editing the plugin: a bundled .lua is overwritten on
// the next upgrade, and a hand-installed one is not something the operator
// should have to maintain.
OverrideRoles []string `json:"override_roles,omitempty"`
OverrideKeys []string `json:"override_keys,omitempty"`
}
// endpointAccess returns the access table for every registered endpoint, with
// operator overrides folded in.
func (ps *Plugins) endpointAccess() []HTTPEndpointAccess {
ps.mu.RLock()
defer ps.mu.RUnlock()
return ps.endpointAccessLocked()
}
// endpointAccessLocked is the body of endpointAccess for callers that already
// hold ps.mu — VisibilityOverrides walks the same map and must not re-lock.
func (ps *Plugins) endpointAccessLocked() []HTTPEndpointAccess {
var out []HTTPEndpointAccess
for _, p := range ps.plugins {
if p.http == nil || p.LoadError != "" {
continue
}
for _, h := range p.http {
// The override key is per ENDPOINT (plugin#path), not per plugin.
// Reading the plugin-level key here reported the plugin's own
// audience override as if it governed every endpoint, and reported
// no endpoint override at all — so the UI drew the DECLARED roles
// after an operator had saved a change, and re-saving from that
// display silently reverted the restriction.
ovRoles, ovKeys := splitAccessSpec(ps.visibility[endpointOverrideKey(p.Info.Name, h.Path)])
out = append(out, HTTPEndpointAccess{
Plugin: p.Info.Name, Path: h.Path, FullPath: h.routePath(),
Method: h.Method, Handler: h.Handler,
Roles: h.Roles, Keys: h.Keys, Public: h.Public,
Description: h.Description,
OverrideRoles: ovRoles, OverrideKeys: ovKeys,
})
}
}
return out
}
// EndpointAccess exposes the table to the gateway package.
func (ps *Plugins) EndpointAccess() []HTTPEndpointAccess { return ps.endpointAccess() }
// SetEndpointAccess narrows or widens one endpoint's reachable keys/roles.
//
// It is an OVERRIDE, applied on top of what the plugin declared, and stored in
// the same per-plugin visibility map the UI audience uses. An empty value clears
// it. Overriding per endpoint rather than per plugin is what the operator
// actually wants: /health is fine for every key while /balance is not, and
// those two live in the same plugin.
func (ps *Plugins) SetEndpointAccess(plugin, path, spec string) error {
p := ps.find(plugin)
if p == nil {
return fmt.Errorf("plugin %s not found", plugin)
}
found := false
for _, h := range p.http {
if h.Path == path {
found = true
break
}
}
if !found {
return fmt.Errorf("plugin %s has no endpoint %q", plugin, path)
}
ps.mu.Lock()
defer ps.mu.Unlock()
if ps.visibility == nil {
ps.visibility = visibilityOverrides{}
}
key := endpointOverrideKey(plugin, path)
if strings.TrimSpace(spec) == "" {
delete(ps.visibility, key)
} else {
ps.visibility[key] = spec
}
ps.saveAccessOverridesLocked()
return nil
}
// endpointOverrideKey namespaces a per-endpoint override so it cannot collide
// with a whole-plugin audience override of the same plugin name.
func endpointOverrideKey(plugin, path string) string {
return plugin + "#" + path
}
// effectiveAccess applies an endpoint override to the declared handler.
func (ps *Plugins) effectiveAccess(p *Plugin, h HTTPHandler) HTTPHandler {
ps.mu.RLock()
ov := ps.visibility[endpointOverrideKey(p.Info.Name, h.Path)]
ps.mu.RUnlock()
if ov == "" {
return h
}
out := h
out.Roles, out.Keys = nil, nil
out.Public = false
for _, part := range strings.Split(ov, ",") {
part = strings.TrimSpace(part)
switch {
case part == "":
case part == "public":
out.Public = true
case strings.HasPrefix(part, "@"):
out.Keys = append(out.Keys, strings.TrimPrefix(part, "@"))
default:
out.Roles = append(out.Roles, part)
}
}
return out
}
// knownKeyNames is set by the gateway during wiring so ValidateAccessSpec can
// refuse an allowlist naming a key that does not exist.
//
// Without the check, an allowlist naming a typo ("homeagent" vs "home_agent")
// is stored, saved, shown in the WebUI as configured — and refuses every caller,
// including the one the operator meant. The failure looks like the endpoint being
// broken rather than the name being wrong.
var knownKeyNames func() []string
// SetKeyNameLookup registers the source of configured gateway key names.
func SetKeyNameLookup(fn func() []string) { knownKeyNames = fn }
// ValidateAccessSpec checks an access spec against the configured keys and roles.
//
// Roles are checked against the two that exist (admin, user) rather than left
// free-form: a role that does not exist produces an endpoint nobody can reach,
// which is the same silent failure as a bad key name.
func (ps *Plugins) ValidateAccessSpec(spec string) error {
spec = strings.TrimSpace(spec)
if spec == "" {
return nil
}
known := map[string]bool{"public": true, "admin": true, "user": true}
if knownKeyNames != nil {
for _, n := range knownKeyNames() {
if n != "" {
known[strings.ToLower(n)] = true
}
}
}
for _, part := range strings.Split(spec, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
name := part
if strings.HasPrefix(name, "@") {
name = strings.TrimPrefix(name, "@")
}
if name == "" {
return fmt.Errorf("access entry %q names no key", part)
}
if !known[strings.ToLower(name)] {
return fmt.Errorf("access entry %q is neither a role (admin/user/public) "+
"nor a configured key name", part)
}
}
return nil
}
// splitAccessSpec turns an override string into role and key lists. "@name" is a
// key; a bare word is a role. Shared by the reporting path and the enforcement
// path so the two can never disagree about what an override means.
func splitAccessSpec(spec string) (roles, keys []string) {
if strings.TrimSpace(spec) == "" {
return nil, nil
}
for _, part := range strings.Split(spec, ",") {
part = strings.TrimSpace(part)
switch {
case part == "", part == "public":
// "public" is an enforcement concern, not a listing; it is reported
// via the Public field.
case strings.HasPrefix(part, "@"):
keys = append(keys, strings.TrimPrefix(part, "@"))
default:
roles = append(roles, part)
}
}
return roles, keys
}

View File

@ -1,430 +0,0 @@
package lua
// Plugin-registered HTTP endpoints.
//
// Three properties have to hold, and none of them is visible from the plugin's
// own point of view:
//
// 1. Auth and roles are enforced by the GATEWAY. A plugin that forgets to
// declare roles gets admin-only, not open — the failure mode of "public by
// omission" is an internet-scannable endpoint inside the gateway process.
// 2. A handler error is a value, never a panic. The pcall guard is the same one
// the hooks use, for the same reason.
// 3. The plugin cannot steer the gateway's own response framing: a nonsense
// status becomes a 500, and Content-Length / hop-by-hop headers are dropped.
//
// These tests drive the declaration layer and the response writer directly; the
// router-level dispatch is covered in the gateway package.
import (
"net/http"
"net/http/httptest"
"testing"
)
const httpPlugin = `
local P = {}
P.name = "api"
P.version = "1.0.0"
P.exports = {}
P.http = {
{
path = "health",
handler = "health",
roles = { "admin" },
description = "liveness",
},
{
path = "echo/:what",
method = "POST",
handler = "echo",
roles = { "admin", "user" },
},
}
function P.health(req)
return { status = 200, json = { ok = true, role = req.role, key = req.key } }
end
function P.echo(req)
return { status = 201, json = { what = req.params.what, body = req.body } }
end
return P
`
func TestPluginRegistersHTTPHandlers(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
if err := ps.LoadSource("api", httpPlugin); err != nil {
t.Fatal(err)
}
hs := ps.HTTPHandlers()
if len(hs) != 2 {
t.Fatalf("expected 2 registered endpoints, got %d: %+v", len(hs), hs)
}
for _, h := range hs {
if h.Plugin != "api" {
t.Errorf("handler %q has no owner; the dispatcher could not route it", h.Path)
}
if h.Method == "" {
t.Errorf("handler %q has no method; the gateway would answer 405 forever", h.Path)
}
}
}
func TestHandlerRoleRules(t *testing.T) {
// A handler with no roles and no public opt-out is ADMIN-ONLY. This is the
// decision the whole feature turns on: "I forgot to declare roles" must not
// mean "anyone on the internet".
open := HTTPHandler{Path: "x", Handler: "h"}
if open.allows("user", "") {
t.Error("an undeclared handler must default to admin-only")
}
if !open.allows("admin", "") {
t.Error("an undeclared handler must still serve admin")
}
// The explicit opt-out is how a plugin says "this one really is public".
pub := HTTPHandler{Path: "x", Handler: "h", Public: true}
if !pub.allows("user", "") || !pub.allows("", "") {
t.Error("public=true must admit any role")
}
// Declared roles.
scoped := HTTPHandler{Path: "x", Roles: []string{"admin", "user"}}
if !scoped.allows("user", "homeagent") || !scoped.allows("admin", "admin") {
t.Error("a listed role must be admitted")
}
if (HTTPHandler{Path: "x", Roles: []string{"admin"}}).allows("user", "homeagent") {
t.Error("an unlisted role must be refused")
}
}
// Naming keys is how the operator grants one collaborator access without opening
// the endpoint to every user key. Two properties matter and neither is obvious:
// a named key wins even when the role rule would have refused it, and the named
// list REPLACES the role rule rather than adding to it.
func TestHandlerKeyRules(t *testing.T) {
h := HTTPHandler{Path: "x", Roles: []string{"admin"}, Keys: []string{"homeagent"}}
if !h.allows("user", "homeagent") {
t.Error("a named key must be admitted even though its role is not listed")
}
// The replacement semantics: listing one key must NOT also admit every user
// key through the role rule, which is what an OR would have done.
if h.allows("user", "club") {
t.Error("naming keys must replace the role rule, not add to it")
}
if h.allows("user", "") {
t.Error("an unnamed key must not match a named-key allowlist")
}
// Admin is always admitted, so a typo in the list never locks the operator
// out of the endpoint they are trying to tighten.
if !h.allows("admin", "admin") {
t.Error("admin must always reach an endpoint")
}
// Name matching is case-insensitive; a config written by hand varies.
if !(HTTPHandler{Path: "x", Keys: []string{"HomeAgent"}}).allows("user", "homeagent") {
t.Error("key names should match case-insensitively")
}
}
// The stored override replaces whatever the plugin declared — this is the admin
// editing access without touching a .lua that an upgrade would overwrite.
func TestEndpointAccessOverrideReplacesDeclaration(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
if err := ps.LoadSource("api", httpPlugin); err != nil {
t.Fatal(err)
}
// health is declared admin-only; widen it to one named key.
if err := ps.SetEndpointAccess("api", "health", "@homeagent"); err != nil {
t.Fatal(err)
}
var found *HTTPHandler
for _, h := range ps.HTTPHandlers() {
if h.Path == "health" {
hh := h
found = &hh
}
}
if found == nil {
t.Fatal("health endpoint disappeared after an access override")
}
if len(found.Roles) != 0 {
t.Errorf("the declared roles should be replaced, got %v", found.Roles)
}
if len(found.Keys) != 1 || found.Keys[0] != "homeagent" {
t.Errorf("override keys = %v, want [homeagent]", found.Keys)
}
if !found.allows("user", "homeagent") || found.allows("user", "club") {
t.Error("the override is not in force")
}
// Clearing restores the plugin's own declaration.
if err := ps.SetEndpointAccess("api", "health", ""); err != nil {
t.Fatal(err)
}
for _, h := range ps.HTTPHandlers() {
if h.Path == "health" {
if len(h.Keys) != 0 {
t.Errorf("clearing the override should restore the declaration, got %v", h.Keys)
}
return
}
}
t.Error("health endpoint vanished")
}
func TestSetEndpointAccessRejectsUnknownTarget(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
if err := ps.LoadSource("api", httpPlugin); err != nil {
t.Fatal(err)
}
if err := ps.SetEndpointAccess("api", "nope", "@homeagent"); err == nil {
t.Error("setting access on a path the plugin does not expose must fail loudly")
}
if err := ps.SetEndpointAccess("nosuchplugin", "health", "@homeagent"); err == nil {
t.Error("setting access for an unknown plugin must fail loudly")
}
}
// A stored allowlist naming a key that does not exist refuses every caller while
// looking perfectly configured. ValidateAccessSpec exists to catch that at write
// time instead.
func TestValidateAccessSpec(t *testing.T) {
SetKeyNameLookup(func() []string { return []string{"homeagent", "club"} })
t.Cleanup(func() { SetKeyNameLookup(nil) })
for _, ok := range []string{"admin", "user", "public", "@homeagent",
"admin,@homeagent,@club", " admin , @club "} {
if err := (&Plugins{}).ValidateAccessSpec(ok); err != nil {
t.Errorf("spec %q should be accepted: %v", ok, err)
}
}
for _, bad := range []string{"@nosuchkey", "superuser", "@", "admin,@typo_key"} {
if err := (&Plugins{}).ValidateAccessSpec(bad); err == nil {
t.Errorf("spec %q must be refused: it is neither a role nor a configured key", bad)
}
}
if err := (&Plugins{}).ValidateAccessSpec(""); err != nil {
t.Errorf("an empty spec clears the override and must be accepted: %v", err)
}
}
func TestHandlerPathValidationRejectsEscapes(t *testing.T) {
// ":name" with empty segments around it is legal — the trims below reduce it
// to ":name". What must be refused is an empty PARAM NAME, which cannot be
// captured into request.params.
for _, bad := range []string{"", " ", "a/../b", "a//b", "wild*card", "q?x", "/:/"} {
if err := validateHandlerPath(bad); err == nil {
t.Errorf("path %q must be rejected: it can escape the mount or make the "+
"route table ambiguous", bad)
}
}
for _, ok := range []string{"health", "a/b", "echo/:what", "v1/things/:id", "/:name/"} {
if err := validateHandlerPath(ok); err != nil {
t.Errorf("path %q should be accepted: %v", ok, err)
}
}
}
func TestHandlerMethodValidation(t *testing.T) {
if m, err := normalizeHandlerMethod(""); err != nil || m != http.MethodGet {
t.Errorf("an empty method should default to GET, got %q %v", m, err)
}
if _, err := normalizeHandlerMethod("TRACE"); err == nil {
t.Error("TRACE must be refused")
}
if m, err := normalizeHandlerMethod("post"); err != nil || m != http.MethodPost {
t.Errorf("method should normalise to upper case, got %q %v", m, err)
}
}
func TestInvokeHandlerReturnsStructuredData(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
if err := ps.LoadSource("api", httpPlugin); err != nil {
t.Fatal(err)
}
resp, err := ps.InvokeHandler("api", "health", &HandlerRequest{
Method: http.MethodGet, Path: "health", Role: "admin", Key: "sk-test",
})
if err != nil {
t.Fatalf("invoke: %v", err)
}
if resp.Status != 200 {
t.Errorf("status = %d, want 200", resp.Status)
}
j, ok := resp.JSON.(map[string]interface{})
if !ok {
t.Fatalf("json = %T, want a table (the handler's nested value must survive)", resp.JSON)
}
if j["ok"] != true || j["role"] != "admin" {
t.Errorf("handler data crossed wrong: %v", j)
}
}
func TestInvokeHandlerParamsAndBodyReachLua(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
if err := ps.LoadSource("api", httpPlugin); err != nil {
t.Fatal(err)
}
resp, err := ps.InvokeHandler("api", "echo", &HandlerRequest{
Method: http.MethodPost, Path: "echo/thing",
Params: map[string]string{"what": "thing"},
Body: `{"n":1}`, Role: "user",
})
if err != nil {
t.Fatalf("invoke: %v", err)
}
j, _ := resp.JSON.(map[string]interface{})
if resp.Status != 201 {
t.Errorf("status = %d, want 201 (the handler chose it)", resp.Status)
}
if j["what"] != "thing" {
t.Errorf("path param did not reach the handler: %v", j)
}
if j["body"] != `{"n":1}` {
t.Errorf("body did not reach the handler: %v", j)
}
}
// A handler that raises must produce an error, never a process abort — the same
// SIGSEGV class of bug the hook guard exists for.
func TestInvokeHandlerErrorIsValueNotPanic(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
const boom = `
local P = {}
P.name = "boom"
P.version = "1.0.0"
P.exports = {}
P.http = { { path = "blow", handler = "blow", roles = { "admin" } } }
function P.blow(req)
error("handler exploded")
end
return P
`
if err := ps.LoadSource("boom", boom); err != nil {
t.Fatal(err)
}
if _, err := ps.InvokeHandler("boom", "blow", &HandlerRequest{Role: "admin"}); err == nil {
t.Fatal("a raising handler must surface as an error")
}
}
// The plugin does not get to decide how the gateway frames its response.
func TestHandlerResponseFramingIsKernelOwned(t *testing.T) {
cases := []struct {
name string
in *HandlerResponse
want int
}{
{"status 0 becomes 200", &HandlerResponse{Status: 0, Body: "x"}, 200},
{"status 9999 becomes 500", &HandlerResponse{Status: 9999, Body: "x"}, 500},
{"status -1 becomes 500", &HandlerResponse{Status: -1, Body: "x"}, 500},
{"explicit 404 is kept", &HandlerResponse{Status: 404, Body: "x"}, 404},
}
for _, c := range cases {
w := httptest.NewRecorder()
writeHTTPHandlerResponse(w, c.in)
if w.Code != c.want {
t.Errorf("%s: status = %d, want %d", c.name, w.Code, c.want)
}
}
// A handler must not be able to desync the gateway's own framing.
w := httptest.NewRecorder()
writeHTTPHandlerResponse(w, &HandlerResponse{
Status: 200, Body: "hello",
Headers: map[string]string{
"Content-Length": "99999",
"Connection": "close",
"Transfer-Encoding": "chunked",
"X-Custom": "kept",
},
})
if got := w.Header().Get("Content-Length"); got != "" {
t.Errorf("a plugin set Content-Length (%s); the body length is the kernel's", got)
}
if got := w.Header().Get("X-Custom"); got != "kept" {
t.Errorf("a normal header should survive, got %q", got)
}
}
// A malformed declaration must not take the whole plugin down.
func TestBadHTTPRouteDoesNotBreakPluginLoad(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
const mixed = `
local P = {}
P.name = "mixed"
P.version = "1.0.0"
P.exports = {}
P.state = { seen = true }
P.ui = { page = { page_id = "m", title = "M", mount = "<div/>" } }
P.http = {
{ path = "../escape", handler = "h", roles = { "admin" } },
{ path = "fine", handler = "h", roles = { "admin" } },
}
function P.h(req) return { status = 200, body = "ok" } end
return P
`
if err := ps.LoadSource("mixed", mixed); err != nil {
t.Fatalf("a bad route must not fail the plugin load: %v", err)
}
// The good route survives; the bad one is simply absent.
hs := ps.HTTPHandlers()
if len(hs) != 1 || hs[0].Path != "fine" {
t.Fatalf("expected only the valid route, got %+v", hs)
}
// And the plugin's own page/state still work.
if ps.State("mixed") == nil {
t.Error("the plugin's state was lost because of a bad route")
}
}
func TestParseHandlerResponseShapes(t *testing.T) {
if _, err := parseHandlerResponse("just a string"); err == nil {
t.Error("a non-table return must be an error, not an empty 200")
}
if _, err := parseHandlerResponse(map[string]interface{}{}); err == nil {
t.Error("an empty table should say so rather than returning a blank 200")
}
r, err := parseHandlerResponse(map[string]interface{}{
"status": float64(204), "headers": map[string]interface{}{"X-A": "b"},
})
if err != nil {
t.Fatal(err)
}
if r.Status != 204 || r.Headers["X-A"] != "b" {
t.Errorf("decoded %+v", r)
}
}
// TestEndpointAccessReportsPerEndpointOverride guards the reporting side of the
// override store.
//
// SetEndpointAccess already keyed overrides by plugin#path, but endpointAccess
// read the plugin-level key. After an operator saved a restriction on ONE
// endpoint, /api/plugins kept reporting the plugin's DECLARED roles, so the
// plugin page drew the pre-change value and a later save from that screen
// reverted the restriction. Authorization kept working — only the display and
// the round trip through the UI were broken, which is why it went unnoticed.
func TestEndpointAccessReportsPerEndpointOverride(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
if err := ps.LoadSource("api", httpPlugin); err != nil {
t.Fatal(err)
}
// httpPlugin registers "health" and "echo/:what".
if err := ps.SetEndpointAccess("api", "health", "user"); err != nil {
t.Fatalf("SetEndpointAccess: %v", err)
}
got := map[string][]string{}
for _, e := range ps.EndpointAccess() {
got[e.Path] = e.OverrideRoles
}
if len(got["health"]) != 1 || got["health"][0] != "user" {
t.Errorf("health override not reported to the UI: %+v", got["health"])
}
if len(got["echo/:what"]) != 0 {
t.Errorf("echo inherited an override it never had: %+v", got["echo/:what"])
}
}

View File

@ -1,217 +0,0 @@
package lua
// The plugin HTTP bridge is the one capability that lets a .lua file — installed
// from a textarea in the WebUI — reach out on the gateway's own network. That
// makes its DENY rules the security boundary, and these tests exist to pin the
// refusals rather than the happy path.
//
// The failure this guards against is concrete: an operator installs a quota
// plugin that reads a vendor's balance, and a later plugin (or a compromised
// one) posts that gateway key material to an attacker, or reads the cloud
// metadata endpoint, because __llmsproxy_http happened to be a general HTTP
// client.
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
golua "github.com/aarzilli/golua/lua"
)
// newTestState returns a bare Lua state for the argument-decoding tests.
func newTestState(t *testing.T) *golua.State {
t.Helper()
L := golua.NewState()
t.Cleanup(L.Close)
L.OpenLibs()
return L
}
func TestPluginHTTPAllowlistRejectsUnlistedHost(t *testing.T) {
allow := newHTTPAllowlist([]string{"api.example.com"})
for _, raw := range []string{
"https://evil.test/steal",
"https://169.254.169.254/latest/meta-data/",
"https://api.example.com.evil.test/x", // suffix trick
"https://user@evil.test/",
} {
if _, err := allow.checkURL(raw); err == nil {
t.Fatalf("allowlist permitted %q — a plugin must not reach hosts "+
"the operator never configured as a source", raw)
}
}
}
func TestPluginHTTPAllowlistAcceptsConfiguredHost(t *testing.T) {
allow := newHTTPAllowlist([]string{"api.example.com"})
for _, raw := range []string{
"https://api.example.com/alpha/credits",
"https://API.EXAMPLE.COM/x", // host compare must be case-insensitive
"https://api.example.com:443/x",
} {
if _, err := allow.checkURL(raw); err != nil {
t.Fatalf("allowlist refused configured host %q: %v", raw, err)
}
}
}
func TestPluginHTTPRefusesPlainHTTPOffLoopback(t *testing.T) {
allow := newHTTPAllowlist([]string{"api.example.com"})
// Allowed host, but plain HTTP would put the API key on the wire in clear.
if _, err := allow.checkURL("http://api.example.com/alpha"); err == nil {
t.Fatal("plain http to an allowed non-loopback host must be refused")
}
// Loopback stays allowed: it cannot leave the machine, and the billing rules
// UI reads the gateway's own API over plain HTTP in dev setups.
for _, raw := range []string{"http://127.0.0.1:8081/api/x", "http://localhost:8081/x"} {
if _, err := allow.checkURL(raw); err != nil {
t.Fatalf("loopback http must stay usable, got %v for %q", err, raw)
}
}
}
func TestPluginHTTPRejectsBadSchemesAndMissingURL(t *testing.T) {
allow := newHTTPAllowlist([]string{"api.example.com"})
for _, raw := range []string{"file:///etc/passwd", "ftp://api.example.com/x", "gopher://x"} {
if _, err := allow.checkURL(raw); err == nil {
t.Fatalf("scheme in %q must be refused", raw)
}
}
// An empty allowlist must deny, not fall open.
if _, err := newHTTPAllowlist(nil).checkURL("https://api.example.com/x"); err == nil {
t.Fatal("an unconfigured gateway must deny outbound HTTP entirely")
}
}
// A redirect is a hop the allowlist never saw. Following it would let an
// allowed host bounce a plugin's request (and its headers) anywhere.
func TestPluginHTTPDoesNotFollowRedirects(t *testing.T) {
if httpClient.CheckRedirect == nil {
t.Fatal("CheckRedirect must be set: following a redirect escapes the allowlist")
}
req, _ := http.NewRequest(http.MethodGet, "https://api.example.com/x", nil)
err := httpClient.CheckRedirect(req, []*http.Request{req})
if err != http.ErrUseLastResponse {
t.Fatalf("CheckRedirect must return ErrUseLastResponse so the 3xx is "+
"handed back unfollowed, got %v", err)
}
}
func TestPluginHTTPTimeoutAndBodyAreClamped(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
allow := newHTTPAllowlist([]string{"127.0.0.1"})
u, err := allow.checkURL(srv.URL)
if err != nil {
t.Fatalf("loopback test server must be allowed: %v", err)
}
// An absurd timeout is clamped instead of honoured.
res, err := performHTTP(u, httpRequestSpec{Timeout: 24 * time.Hour})
if err != nil {
t.Fatalf("clamped request failed: %v", err)
}
if res.Status != 200 || !strings.Contains(res.Body, `"ok"`) {
t.Fatalf("unexpected result: status=%d body=%q", res.Status, res.Body)
}
}
func TestPluginHTTPOversizedBodyIsRefused(t *testing.T) {
big := strings.Repeat("x", httpMaxResponse+64)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(big))
}))
defer srv.Close()
allow := newHTTPAllowlist([]string{"127.0.0.1"})
u, _ := allow.checkURL(srv.URL)
// Refusing beats truncating: a truncated body parses as corrupt JSON and the
// plugin would report a parse error instead of the real cause.
if _, err := performHTTP(u, httpRequestSpec{}); err == nil {
t.Fatal("an oversized response must be refused, not silently truncated")
}
}
func TestPluginHTTPErrorPathIsPairNotRaise(t *testing.T) {
// Lua-side contract: the bridge returns (nil, message) on every failure so a
// plugin mistake never raises through golua (see the SIGSEGV note on
// hookGuardSrc — an escaping error can take the gateway down).
allow := newHTTPAllowlist([]string{"api.example.com"})
if _, err := allow.checkURL("https://evil.test/"); err == nil {
t.Fatal("expected refusal")
}
// readHTTPRequestSpec must reject a non-table without touching the stack.
L := newTestState(t)
if _, err := readHTTPRequestSpec(L); err == nil {
t.Fatal("a missing argument table must be an error, not a panic")
}
}
// The bridge must be reachable under the name plugins actually call.
//
// This exists because the registration once used the CONSTANT'S IDENTIFIER as
// the Lua global name (`registerFn(L, "httpGlobal", …)`) rather than its VALUE,
// so the bridge was published as `httpGlobal` while every plugin called
// `__llmsproxy_http`. The failure only appeared at runtime, as
// "attempt to call global '__llmsproxy_http' (a nil value)" on the first real
// request — after the unit tests were green, because they drove the Go-side
// helpers directly and never went through Lua name resolution.
//
// So this test boots a real plugin and calls the bridge the way a plugin does.
func TestPluginHTTPBridgeIsReachableUnderItsLuaName(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
ps.SetOutboundHosts([]string{"127.0.0.1"})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"credits":{"monthlyCredits":1.5}}`))
}))
defer srv.Close()
// The plugin asks for a loopback URL, which is allowed without being
// configured as a source. It type-checks both the table and arg.url, so a
// malformed call is reported by the plugin rather than as a bewildering URL
// parse error from the bridge.
src := `
local P = {}
P.name = "httpprobe"
P.version = "1.0.0"
P.exports = { "probe" }
-- Call passes the WHOLE argument table as a single Lua argument, which is the
-- same convention the hooks use and what a plugin page's pluginAPI.call ends up
-- producing. Taking the table (not a bare string) is the correct shape.
function P.probe(arg)
if type(arg) ~= "table" then return { error = "probe takes the args table, got " .. type(arg) } end
if type(arg.url) ~= "string" then return { error = "arg.url must be a string, got " .. type(arg.url) } end
local res, err = ` + httpGlobal + `({ url = arg.url, method = "GET" })
if res == nil then return { error = tostring(err) } end
return { status = res.status, body = res.body }
end
return P
`
if err := ps.LoadSource("httpprobe", src); err != nil {
t.Fatalf("load: %v", err)
}
got, err := ps.Call("httpprobe", "probe", map[string]interface{}{"url": srv.URL})
if err != nil {
t.Fatalf("call: %v", err)
}
m, _ := got.(map[string]interface{})
if m == nil {
t.Fatalf("probe returned %T, want a table", got)
}
// A nil global surfaces as {error = "attempt to call global ... (a nil value)"} —
// which is a table too, so the error field is what distinguishes it.
if e, ok := m["error"]; ok {
t.Fatalf("the bridge is not reachable as %s: %v", httpGlobal, e)
}
if s, _ := m["status"].(float64); int(s) != 200 {
t.Fatalf("status=%v body=%v, want 200 from the loopback test server", m["status"], m["body"])
}
if body, _ := m["body"].(string); !strings.Contains(body, "monthlyCredits") {
t.Fatalf("body=%q, want the server's JSON", body)
}
}

View File

@ -1,118 +0,0 @@
package lua
// Per-caller state scoping.
//
// Role visibility answers "may this role see the plugin at all". It does not
// answer "which PART of its data", and for the billing plugin those are
// different questions: the global figures (total spend, every source, every
// model) are deployment data, while the per-key buckets describe one caller.
//
// So a plugin may declare a split, and the gateway then hands a non-admin
// caller only the buckets that are theirs. The plugin declares it because only
// the plugin author knows which of its buckets are global.
import "strings"
// StateScope is a plugin's declaration of which state buckets are global.
type StateScope struct {
// Global names the buckets that describe the whole deployment. Anything not
// listed is treated as per-caller and filtered.
Global []string `json:"global,omitempty"`
// Depth gives the nesting level of each PER-CALLER bucket: 1 for
// {maskedKey: bucket}, 2 for {day: {maskedKey: bucket}}. It is declared
// because both shapes are maps and sniffing would confuse an empty bucket
// with a nested one.
Depth map[string]int `json:"depth,omitempty"`
}
// IsGlobal reports whether a top-level state key is deployment-wide.
func (s *StateScope) IsGlobal(key string) bool {
if s == nil {
return true
}
for _, g := range s.Global {
if g == key {
return true
}
}
return false
}
// StateScopeFor returns a plugin's declared split, or nil when it declares none.
func (ps *Plugins) StateScopeFor(name string) *StateScope {
ps.mu.RLock()
defer ps.mu.RUnlock()
for _, p := range ps.plugins {
if p.Info.Name != name || p.UI == nil {
continue
}
return p.UI.StateScope
}
return nil
}
// compileDepthMap normalizes the declared depth table, defaulting to 1 for a
// per-caller bucket the author did not annotate.
func compileDepthMap(in map[string]int) map[string]int {
if len(in) == 0 {
return nil
}
out := make(map[string]int, len(in))
for k, v := range in {
if v < 1 {
v = 1
}
out[strings.TrimSpace(k)] = v
}
return out
}
// DepthOf returns the declared nesting level for a per-caller bucket, 1 by
// default. A bucket the author did not annotate is assumed flat, which is the
// common shape ({maskedKey: row}).
func (s *StateScope) DepthOf(key string) int {
if s == nil || s.Depth == nil {
return 1
}
if d, ok := s.Depth[key]; ok {
return d
}
return 1
}
// VisibilityOverrides returns every operator override in force, keyed by plugin
// name. An absent name means the plugin's own declaration applies.
//
// The WebUI needs this to show which visibility is IMPOSED, not merely which is
// in effect: an operator comparing two deployments should not have to open a
// .lua to learn whether a page is admin-only by the plugin's choice or by their
// own override.
func (ps *Plugins) VisibilityOverrides() map[string]string {
ps.mu.RLock()
defer ps.mu.RUnlock()
if len(ps.visibility) == 0 {
return nil
}
epKeys := make(map[string]struct{})
for _, e := range ps.endpointAccessLocked() {
epKeys[endpointOverrideKey(e.Plugin, e.Path)] = struct{}{}
}
out := make(map[string]string, len(ps.visibility))
for k, v := range ps.visibility {
// Per-endpoint overrides live in the SAME map under a "name#path" key.
// They are endpoint policy, not a plugin audience, so leaking them here
// both mislabels them and lets the plugin page write an audience of the
// form "probe#hello" that no code ever reads as one.
// Per-endpoint overrides share this map under a "name#path" key. They
// are endpoint policy, not a plugin audience: reporting them here both
// mislabels them and invites the plugin page to save an audience of the
// form "probe#hello" that nothing reads as an audience. The set of
// endpoint keys is already computed, so ask it rather than re-deriving
// the shape from the key.
if _, isEndpoint := epKeys[k]; isEndpoint {
continue
}
out[k] = v
}
return out
}

View File

@ -1,193 +0,0 @@
package lua
// Plugin visibility: a plugin page is not automatically public data.
//
// The billing page renders GLOBAL cost across every source and the quota page
// renders upstream balances. Both were served to any authenticated key because
// /api/ui-inject applied no role filter — measured, not theorised: a user-role
// key pulled the 18KB billing state and read the source names and the $8.86
// total straight out of it.
//
// These tests pin the DENIALS. A visibility feature whose tests only assert that
// an admin still sees everything passes while the leak is wide open.
import (
"testing"
)
func TestAudienceAllows(t *testing.T) {
cases := []struct {
want, role string
ok bool
why string
}{
{"", "user", true, "unset means everyone"},
{"all", "user", true, "explicit all"},
{"admin", "admin", true, "exact match"},
{"admin", "user", false, "admin-only excludes user"},
{"user", "user", true, "user-only admits user"},
{"user", "admin", false, "user-only excludes admin"},
{"admin,user", "user", true, "list admits a listed role"},
{" admin , user ", "user", true, "whitespace is not significant"},
{"ADMIN", "user", false, "case is normalised"},
{"*", "user", false, `"*" must NOT be a synonym for all`},
}
for _, c := range cases {
if got := audienceAllows(c.want, c.role); got != c.ok {
t.Errorf("audienceAllows(%q, %q) = %v, want %v (%s)",
c.want, c.role, got, c.ok, c.why)
}
}
}
func TestPluginVisibilityHidesPageFromUserRole(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
// One admin-only plugin, one open plugin: the filter must drop exactly one
// and leave the other intact, or a plugin author has no way to ship a mixed
// set.
const adminOnly = `
local P = {}
P.name = "adminonly"
P.version = "1.0.0"
P.exports = {}
P.ui = { visibility = "admin", page = { page_id = "secret", title = "Secret", mount = "<div>secret</div>" } }
return P
`
const open = `
local P = {}
P.name = "openpage"
P.version = "1.0.0"
P.exports = {}
P.ui = { page = { page_id = "open", title = "Open", mount = "<div>open</div>" } }
return P
`
for name, src := range map[string]string{"adminonly": adminOnly, "openpage": open} {
if err := ps.LoadSource(name, src); err != nil {
t.Fatalf("load %s: %v", name, err)
}
}
admin := ps.UIForRole("admin")
if len(admin.Pages) != 2 {
t.Errorf("admin should see both pages, got %d", len(admin.Pages))
}
user := ps.UIForRole("user")
for _, pg := range user.Pages {
if pg.PageID == "secret" {
t.Fatal("a user key can see the admin-only page — this is the leak")
}
}
if len(user.Pages) != 1 || user.Pages[0].PageID != "open" {
t.Fatalf("user should see exactly the open page, got %v", pageIDs(user.Pages))
}
}
// The operator must be able to impose visibility the plugin did not choose —
// that is the whole point of the override, since a bundled .lua cannot be edited
// without being overwritten on the next upgrade.
func TestPluginVisibilityOverrideBeatsDeclaration(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
const open = `
local P = {}
P.name = "openpage"
P.version = "1.0.0"
P.exports = {}
P.ui = { page = { page_id = "open", title = "Open", mount = "<div>open</div>" } }
return P
`
if err := ps.LoadSource("openpage", open); err != nil {
t.Fatal(err)
}
if len(ps.UIForRole("user").Pages) != 1 {
t.Fatal("precondition: the page should start visible to user")
}
ps.SetPluginVisibility("openpage", "admin")
if got := ps.UIForRole("user").Pages; len(got) != 0 {
t.Fatalf("the operator override was ignored: user still sees %v", pageIDs(got))
}
if ps.VisibilityOverride("openpage") != "admin" {
t.Error("the override should be reportable so /api/plugins can show it")
}
// Clearing restores the plugin's own declaration.
ps.SetPluginVisibility("openpage", "")
if got := ps.UIForRole("user").Pages; len(got) != 1 {
t.Fatalf("clearing the override should restore visibility, got %v", pageIDs(got))
}
}
// A page may narrow visibility below the plugin default, so one plugin can ship
// a public summary beside an admin-only detail page.
func TestPluginPageCanNarrowBelowPluginDefault(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
const mixed = `
local P = {}
P.name = "mixed"
P.version = "1.0.0"
P.exports = {}
P.ui = {
pages = {
{ page_id = "summary", title = "Summary", mount = "<div>s</div>" },
{ page_id = "details", title = "Details", mount = "<div>d</div>", audience = "admin" },
},
}
return P
`
if err := ps.LoadSource("mixed", mixed); err != nil {
t.Fatal(err)
}
user := ps.UIForRole("user")
if len(user.Pages) != 1 || user.Pages[0].PageID != "summary" {
t.Fatalf("user should see only the summary, got %v", pageIDs(user.Pages))
}
if len(ps.UIForRole("admin").Pages) != 2 {
t.Error("admin should see both pages")
}
}
func TestPluginStateVisibleTo(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
const adminOnly = `
local P = {}
P.name = "adminonly"
P.version = "1.0.0"
P.exports = {}
P.state = { secret = "x" }
P.ui = { visibility = "admin", page = { page_id = "s", title = "S", mount = "<div/>" } }
return P
`
if err := ps.LoadSource("adminonly", adminOnly); err != nil {
t.Fatal(err)
}
// The data has to follow the page: a hidden UI with an open state endpoint is
// the same leak with one more step.
if ps.StateVisibleTo("adminonly", "user") {
t.Error("user must not read an admin-only plugin's state")
}
if !ps.StateVisibleTo("adminonly", "admin") {
t.Error("admin must still read it")
}
// Unknown plugin: unchanged behaviour, so a widget that is not installed does
// not start answering 403.
if !ps.StateVisibleTo("nosuchplugin", "user") {
t.Error("an unknown plugin name must not become forbidden; that would change " +
"behaviour for plugins that simply are not installed")
}
}
func pageIDs(pages []*UIPage) []string {
out := make([]string, 0, len(pages))
for _, p := range pages {
if p != nil {
out = append(out, p.PageID)
}
}
return out
}

View File

@ -32,7 +32,6 @@ package lua
// less featureful, so the split is explicit and documented.
import (
"log"
"encoding/json"
"fmt"
"os"
@ -127,13 +126,6 @@ type UIExtension struct {
// Elements are snippets injected into EXISTING pages, keyed by target page
// id (e.g. "status", "keys"). Order within a target is plugin load order.
Elements []UIElement `json:"elements,omitempty"`
// Visibility is the plugin-wide default audience, applied to every page and
// element this plugin contributes. A per-page Audience narrows it further.
Visibility string `json:"visibility,omitempty"`
// StateScope splits the plugin's published state into global and per-caller
// buckets, for a plugin that is readable by more than one role but must not
// hand one caller another's data.
StateScope *StateScope `json:"state_scope,omitempty"`
}
// UIPage is a plugin-provided page.
@ -142,20 +134,6 @@ type UIPage struct {
Title string `json:"title"` // sidebar label
Icon string `json:"icon"` // optional inline SVG or short glyph
Order int `json:"order"` // sidebar sort key (default 100)
// Audience limits which API-key roles may see this page. "" and "all" mean
// every authenticated role; "admin" hides it from user keys; a comma list
// ("admin,user") names the roles allowed.
//
// This exists because a plugin page is not automatically public data. The
// billing page shows GLOBAL cost across every source, and the quota page
// shows upstream balances — neither belongs to whoever happens to hold a
// user key, yet both were served to them because /api/ui-inject applied no
// role filter at all.
//
// The plugin declares its own default and the gateway may override it (see
// SetPluginVisibility), because the plugin author cannot know the operator's
// policy but the operator must be able to impose one.
Audience string `json:"audience,omitempty"`
// Mount is the page body. It may contain <script> and <style>; the kernel
// executes scripts AFTER injecting the HTML so the DOM exists, and exposes
// `pluginAPI` to them (see docs/plugins.md).
@ -167,10 +145,6 @@ type UIElement struct {
// Target is the id of the pane to inject into: "status", "chat", "keys",
// "sort", "sources" or "adapters".
Target string `json:"target"`
// Audience is the same role filter as UIPage.Audience. An injected tile can
// leak exactly what a whole page would — the billing status tile shows total
// cost — so the field exists here too rather than only on pages.
Audience string `json:"audience,omitempty"`
// Anchor is where in the target pane: "top", "bottom" or "before:<sel>" /
// "after:<sel>" for a CSS selector. Empty = "bottom".
Anchor string `json:"anchor,omitempty"`
@ -205,14 +179,6 @@ type Plugin struct {
// reasoning as a systemd unit being masked rather than removed.
Disabled bool
dir string
// http lists endpoints this plugin mounts under /api/plugins/http/. Read at
// load time, never mutated afterwards.
http []HTTPHandler
// exports lists the functions this plugin allows the WebUI to call through
// POST /api/plugins/{name}/call/{fn}. It is read once at load from
// plugin.exports and never changes, so Call can check it without taking a
// Lua read on the hot path.
exports []string
// script is the plugin's source, kept so a reload can rebuild its state.
script string
// Builtin marks a plugin that shipped with the gateway. The UI shows it as
@ -262,16 +228,9 @@ type Plugins struct {
// direct log call so the plugin package stays free of a logging dependency
// and tests can capture the warnings.
logf func(format string, args ...interface{})
// visibility holds the operator's per-plugin audience overrides, keyed by
// plugin name. Consulted by UIForRole and the call endpoint's authorisation.
visibility visibilityOverrides
// stateDirDisabled turns persistence off. Used by tests that assert state
// starts empty, and by an embedder that has no writable plugin dir.
stateDirDisabled bool
// allowHTTP bounds plugin outbound requests to operator-declared hosts.
// nil means deny-all: an unconfigured gateway must not silently grant a
// plugin unrestricted network access.
allowHTTP *httpAllowlist
// save coalesces state writes into one background goroutine.
//
// A hook must NOT write synchronously: the billing plugin mutates state on
@ -453,10 +412,6 @@ func NewPlugins(vm *VM, dir string) *Plugins {
stageFuncs: map[Stage][]hookCall{},
hookErr: newHookErrors(),
dir: dir,
// Deny-by-default. NewPlugins is called during wiring, before any
// source list is known; SetOutboundHosts replaces this with the real
// set. A plugin loaded in that window simply cannot make requests.
allowHTTP: newHTTPAllowlist(nil),
}
// The saver is started even with no dir: a gateway can be given a plugin dir
// later, and a saver that only exists when dir != "" would silently never
@ -474,27 +429,6 @@ func NewPlugins(vm *VM, dir string) *Plugins {
// DisableStatePersistence turns persistence off (tests/benchmarks only).
func (ps *Plugins) DisableStatePersistence() { ps.stateDirDisabled = true }
// SetOutboundHosts declares which hosts plugins may contact via
// __llmsproxy_http. The gateway passes the host of every configured source:
// those are operator-declared upstreams, and reusing that set keeps a plugin's
// reach no wider than the deployment already trusts. An empty list means no
// outbound host is allowed at all — plugins then still load, they just cannot
// make requests.
func (ps *Plugins) SetOutboundHosts(hosts []string) {
ps.mu.Lock()
defer ps.mu.Unlock()
ps.allowHTTP = newHTTPAllowlist(hosts)
}
// outboundAllowlist returns the current allowlist. bootPluginState reads it
// before any concurrent reload can swap it, and a nil result is treated as
// "deny everything" rather than "allow everything".
func (ps *Plugins) outboundAllowlist() *httpAllowlist {
ps.mu.RLock()
defer ps.mu.RUnlock()
return ps.allowHTTP
}
func (ps *Plugins) Close() {
if ps == nil {
return
@ -512,12 +446,6 @@ func (ps *Plugins) LoadDir() error {
if ps.dir == "" {
return nil
}
// Overrides first: they are keyed by plugin name and must be in place before
// anything reads them, and a reload must not blank a restriction the
// operator set earlier in this same process.
if err := ps.LoadAccessOverrides(); err != nil {
log.Printf("[core] plugin access overrides not loaded: %v", err)
}
if _, err := os.Stat(ps.dir); os.IsNotExist(err) {
return nil
}
@ -568,17 +496,10 @@ func (ps *Plugins) SeedBundled() error {
// bootPluginState compiles one plugin into a fresh Lua state and stores its
// returned table under pluginGlobal. It is the plugin counterpart of
// adapterPool.boot, minus the pooling: a plugin keeps exactly one state.
func bootPluginState(code, name string, allow *httpAllowlist) (*worker, error) {
func bootPluginState(code, name string) (*worker, error) {
L := golua.NewState()
L.OpenLibs()
setupGlobals(L)
// Only plugins get __llmsproxy_http. Adapters are operator-supplied config
// and go through setupGlobals too, so installing the bridge here — not in
// setupGlobals — is what keeps the capability scoped to installed .lua
// files.
if allow != nil {
registerFn(L, httpGlobal, luaHTTPFn(L, allow))
}
if err := L.DoString(code); err != nil {
L.Close()
return nil, fmt.Errorf("compile plugin %s: %w", name, err)
@ -616,7 +537,7 @@ func (ps *Plugins) LoadSource(name, code string) error {
// One state, held for the plugin's lifetime (see Plugin.state): a hook that
// accumulates into plugin.state would otherwise split its totals across
// whichever worker it happened to run on.
w, err := bootPluginState(code, name, ps.outboundAllowlist())
w, err := bootPluginState(code, name)
if err != nil {
p.LoadError = err.Error()
ps.append(p)
@ -671,22 +592,6 @@ func (ps *Plugins) LoadSource(name, code string) error {
p.UI = ui
}
// ---- http endpoints ----
p.http = readPluginHTTP(w.L)
if ui, ok := readPluginUI(w.L); ok {
p.UI = ui
if ui.StateScope != nil {
ui.StateScope.Depth = compileDepthMap(ui.StateScope.Depth)
}
}
// ---- exports ----
// Read here, while the state is locked and before restore() runs: the list is
// static for the life of the plugin, so Call never has to re-enter Lua just
// to decide whether a call is allowed.
p.exports = readPluginExports(w.L)
// Restore persisted state AFTER the plugin compiled and registered its
// hooks, so the restore overwrites the compiled-in defaults instead of being
// overwritten by them. Doing it earlier would mean a restart resets the
@ -770,47 +675,9 @@ func pluginHookName(L *golua.State, stage string) (string, bool) {
}
// readPluginUI reads the optional ui extension block.
// readPluginExports collects plugin.exports, the list of functions the WebUI is
// allowed to invoke through POST /api/plugins/{name}/call/{fn}.
//
// It accepts either an array of names or a map whose KEYS are the names — both
// shapes read naturally in Lua and authors reach for either. A missing or
// non-conforming table yields nil, which Call treats as "nothing is callable":
// a plugin that never declared an export simply has no callable surface.
func readPluginExports(L *golua.State) []string {
L.GetGlobal(pluginGlobal)
if L.IsNil(-1) {
L.SetTop(0)
return nil
}
L.GetField(-1, "exports")
if L.Type(-1) != golua.LUA_TTABLE {
L.SetTop(0)
return nil
}
// luaTableToGoValue, NOT luaToJSON: this runs while the plugin is registering,
// before the json global is installed, and luaToJSON needs it. Going through
// JSON here silently produced an empty list, which then made every exported
// function look unexported.
raw := luaTableToGoValue(L, -1)
L.SetTop(0)
arr, ok := raw.([]interface{})
if !ok {
return nil
}
list := make([]string, 0, len(arr))
for _, v := range arr {
if s := asString(v); s != "" {
list = append(list, s)
}
}
return list
}
func readPluginUI(L *golua.State) (*UIExtension, bool) {
L.GetGlobal(pluginGlobal)
if L.IsNil(-1) {
L.SetTop(0)
return nil, false
}
L.GetField(-1, "ui")
@ -818,67 +685,12 @@ func readPluginUI(L *golua.State) (*UIExtension, bool) {
L.SetTop(0)
return nil, false
}
// luaTableToGoValue, NOT luaToJSON. This runs while the plugin is
// registering, BEFORE setupGlobals installs the json global that luaToJSON
// depends on — so the conversion fails and every field except the ones on
// Page/Pages (read separately) comes back empty. That is why visibility and
// state_scope silently read as unset even though the .lua declares them:
// the third field in this file to be lost to the same cause, after
// readPluginExports and readPluginHTTP.
var ui UIExtension
raw := luaTableToGoValue(L, -1)
L.SetTop(0)
if m, ok := raw.(map[string]interface{}); ok {
ui.Visibility = asString(m["visibility"])
if scope, ok := m["state_scope"].(map[string]interface{}); ok {
sc := &StateScope{}
if gs, ok := scope["global"].([]interface{}); ok {
for _, g := range gs {
if s := asString(g); s != "" {
sc.Global = append(sc.Global, s)
}
}
}
if ds, ok := scope["depth"].(map[string]interface{}); ok {
sc.Depth = map[string]int{}
for k, v := range ds {
if n, ok := asFloat(v); ok {
sc.Depth[k] = int(n)
}
}
sc.Depth = compileDepthMap(sc.Depth)
}
ui.StateScope = sc
}
if pg, ok := m["page"].(map[string]interface{}); ok {
ui.Page = uipageFromValue(pg)
}
if pgs, ok := m["pages"].([]interface{}); ok {
for _, it := range pgs {
if pm, ok := it.(map[string]interface{}); ok {
ui.Pages = append(ui.Pages, uipageFromValue(pm))
}
}
}
if els, ok := m["elements"].([]interface{}); ok {
for _, it := range els {
em, ok := it.(map[string]interface{})
if !ok {
continue
}
el := UIElement{
Target: asString(em["target"]),
Anchor: asString(em["anchor"]),
Mount: asString(em["mount"]),
Audience: asString(em["audience"]),
}
if n, ok := asFloat(em["order"]); ok {
el.Order = int(n)
}
ui.Elements = append(ui.Elements, el)
}
}
if err := luaToJSON(L, -1, &ui); err != nil {
L.SetTop(0)
return nil, false
}
L.SetTop(0)
if ui.Page == nil && len(ui.Pages) == 0 && len(ui.Elements) == 0 {
return nil, false
}
@ -1126,140 +938,6 @@ func (ps *Plugins) HookErrors() map[string]map[string]interface{} {
}
// UI returns the merged UI extensions to inject into the WebUI.
// audienceAllows reports whether a role is permitted by an audience string.
//
// The grammar is deliberately small, because it is authored in two places (a
// .lua file and config.yaml) and a richer language would only give someone a way
// to express a rule nobody audits:
//
// "" or "all" everyone
// "admin" admin role only
// "user" user role only
// "admin,user" either
//
// "*" is deliberately NOT a synonym for "all": an audience that looks
// restrictive in a config file while meaning everything is exactly the kind of
// setting that fails silently.
func audienceAllows(want, role string) bool {
want = strings.ToLower(strings.TrimSpace(want))
if want == "" || want == "all" {
return true
}
role = strings.ToLower(strings.TrimSpace(role))
for _, part := range strings.Split(want, ",") {
if strings.TrimSpace(part) == role {
return true
}
}
return false
}
// visibilityOverrides holds the operator's per-plugin audience overrides.
//
// A plugin declares its own default and the operator may force one. Both sides
// need to be able to: the plugin author does not know the deployment's policy,
// and the operator must not have to edit a bundled .lua — which the next
// upgrade would overwrite anyway.
type visibilityOverrides map[string]string
// SetPluginVisibility records an operator override for a plugin's audience. An
// empty value clears the override and restores the plugin's own default.
func (ps *Plugins) SetPluginVisibility(plugin, audience string) {
ps.mu.Lock()
defer ps.mu.Unlock()
if ps.visibility == nil {
ps.visibility = visibilityOverrides{}
}
plugin = strings.TrimSpace(plugin)
audience = strings.TrimSpace(audience)
if audience == "" {
delete(ps.visibility, plugin)
} else {
ps.visibility[plugin] = audience
}
ps.saveAccessOverridesLocked()
}
// VisibilityOverride returns the operator's audience override for a plugin,
// or "" when none is set. Exposed so /api/plugins can show which visibility is
// imposed rather than only the one in effect.
func (ps *Plugins) VisibilityOverride(plugin string) string {
ps.mu.RLock()
defer ps.mu.RUnlock()
return ps.visibility[plugin]
}
// uiAudience resolves the audience in force for a plugin: the operator's
// override if set, else the plugin's declared default.
func (ps *Plugins) uiAudience(p *Plugin, declared string) string {
ps.mu.RLock()
override := ps.visibility[p.Info.Name]
ps.mu.RUnlock()
if override != "" {
return override
}
return declared
}
// UIForRole returns the UI a given role may see.
//
// Filtering happens HERE, not in the HTTP layer, so every consumer sees the same
// view — the WebUI boot payload, the Electron shell and the tests. Filtering at
// one call site means the others quietly keep serving everything.
//
// A plugin's page/element can narrow visibility further than its own default
// (a public summary page beside an admin-only one), so the role check runs on
// the resolved plugin audience AND on each item's own declaration.
func (ps *Plugins) UIForRole(role string) *UIExtension {
full := ps.UI()
if full == nil {
return &UIExtension{}
}
// An admin sees everything, including anything a plugin hides from itself
// by mistake: anything else makes an override unverifiable from the browser.
if role == "admin" {
return full
}
out := &UIExtension{}
// The plugin-level default is per plugin, so pages are filtered by walking
// plugins rather than pages: a page id alone does not say who owns it.
for _, p := range ps.plugins {
if p.UI == nil || p.LoadError != "" {
continue
}
aud := ps.uiAudience(p, p.UI.Visibility)
if !audienceAllows(aud, role) {
continue
}
for _, pg := range p.UI.Pages {
if pg == nil || !audienceAllows(pg.Audience, role) {
continue
}
out.Pages = append(out.Pages, pg)
}
for _, el := range p.UI.Elements {
if !audienceAllows(el.Audience, role) {
continue
}
out.Elements = append(out.Elements, el)
}
// Page (singular) is the other half of the same declaration and both
// merge in UI(). Handling only Pages made every plugin that used the
// single-page form disappear for a user key — silently, with no error,
// because "this plugin contributes nothing to you" is indistinguishable
// from "this plugin is not installed".
if p.UI.Page != nil && audienceAllows(p.UI.Page.Audience, role) {
if out.Page == nil {
out.Page = p.UI.Page
}
if !containsPage(out.Pages, p.UI.Page.PageID) {
out.Pages = append(out.Pages, p.UI.Page)
}
}
}
return out
}
func (ps *Plugins) UI() *UIExtension {
if u := ps.ui.Load(); u != nil {
return u
@ -1936,319 +1614,3 @@ func (ps *Plugins) invoke(p *Plugin, fn string, payload map[string]interface{})
ps.markDirtyLocked(p)
return out, nil
}
// Call invokes a plugin function that the plugin declared in plugin.exports.
//
// The gate is the declaration, not the caller: a plugin lists the handful of
// entry points its own UI is allowed to reach, and anything absent is refused.
// Without that list this endpoint would be a remote Lua evaluator — it could
// call the plugin's private helpers, hand them crafted arguments, and read
// whatever the plugin can see, which for a plugin holding an API key means
// handing that key to anyone who can POST to the gateway.
//
// The return value is JSON-shaped rather than restricted to a map, because a
// quota report is a nested structure with numbers and booleans that must not be
// flattened. Hook results still go through invoke, whose map-typed contract the
// state merge depends on.
func (ps *Plugins) Call(name, fn string, args map[string]interface{}) (interface{}, error) {
ps.mu.RLock()
var p *Plugin
for _, c := range ps.plugins {
if c.Info.Name == name {
p = c
break
}
}
ps.mu.RUnlock()
if p == nil {
return nil, fmt.Errorf("plugin %s not found", name)
}
if !pluginExportAllowed(p, fn) {
return nil, fmt.Errorf("plugin %s does not export %q", name, fn)
}
p.mu.Lock()
w := p.state
if w == nil {
p.mu.Unlock()
return nil, fmt.Errorf("no state for plugin %s", name)
}
defer p.mu.Unlock()
L := w.L
L.SetTop(0)
defer L.SetTop(0)
L.GetGlobal(pluginGlobal)
if L.IsNil(-1) {
return nil, fmt.Errorf("plugin table missing")
}
plug := L.GetTop()
L.GetGlobal(hookGuardName)
if L.IsNil(-1) {
L.Pop(1)
}
L.GetField(plug, fn)
if !L.IsFunction(-1) {
return nil, fmt.Errorf("export %s missing", fn)
}
if args == nil {
args = map[string]interface{}{}
}
pushGoValue(L, plainForLua(args))
if err := L.Call(2, 2); err != nil {
return nil, err
}
errMsg := ""
if L.GetTop() >= 2 {
if !L.IsNil(-1) {
errMsg = L.ToString(-1)
}
L.Pop(1)
}
if errMsg != "" {
return nil, fmt.Errorf("plugin %s: %s", name, errMsg)
}
if L.GetTop() < 1 || L.IsNil(-1) {
return nil, nil
}
var out interface{}
if err := luaToJSON(L, -1, &out); err != nil {
return nil, nil
}
return out, nil
}
// pluginExportAllowed reports whether fn is in the plugin's exports list.
// An absent list means nothing is callable — the safe reading, since silently
// allowing all would turn an undeclared plugin into a remote evaluator.
func pluginExportAllowed(p *Plugin, fn string) bool {
for _, e := range p.exports {
if e == fn {
return true
}
}
return false
}
// StateVisibleTo reports whether a role may read a plugin's published state.
//
// The audience is resolved exactly as for the UI: operator override first, then
// the plugin's declaration. A plugin with neither is readable by any
// authenticated role, which is the pre-existing behaviour and the reason a
// newly installed widget keeps working without an edit.
// HTTPHandler is one endpoint a plugin registers on the gateway's router.
//
// A plugin that only receives hooks (billing) has nothing to expose. A plugin
// that computes something the operator may want to query from OUTSIDE the
// browser — a health probe, a CI check, another tool — needs a route, and
// hard-coding one in the gateway would mean every such plugin needs a gateway
// change. So the plugin declares the path, the method and the role it requires,
// and the gateway mounts it.
//
// The handler is a Lua function taking (request) and returning (response). Both
// are plain tables: request carries method/path/query/headers/body, response
// carries status/headers/body/json. Nothing here can reach the Go side except
// through those two values, so the surface a plugin can express is bounded by
// what a table can say.
type HTTPHandler struct {
// Path is the route suffix under /api/plugins/http/. It may contain ":name"
// segments, captured into request.params.
Path string `json:"path"`
// Method is the HTTP method; "" or GET. Only GET/POST/PUT/DELETE/PATCH are
// accepted — an arbitrary verb would need a switch in the router for no gain.
Method string `json:"method,omitempty"`
// Roles are the API-key roles allowed to call it. EMPTY MEANS EVERYONE, which
// is the wrong default for an endpoint a plugin invented: an unauthenticated
// internet scan should not be able to ask it questions. RequireRole exists
// so a plugin that really wants public access has to say so.
Roles []string `json:"roles,omitempty"`
// Keys names SPECIFIC gateway keys allowed to call it, by their configured
// name (the `name:` on a gateway_keys entry), never by the secret.
//
// Names rather than secrets for three reasons: a secret is rotated without
// notice and a rule written against it would silently stop matching; putting
// secrets in a plugin file scatters credentials across the install; and the
// operator reads names. A key with no name cannot be named here.
//
// When Keys is non-empty it is the WHOLE rule, not an addition to Roles. An
// OR would mean an operator writing a two-key allowlist had also granted
// every user key, which is the opposite of what they meant.
Keys []string `json:"keys,omitempty"`
// RequireAuth defaults to true in practice: the gateway refuses a handler
// with neither Roles nor an explicit opt-out.
Public bool `json:"public,omitempty"`
// Handler is the name of the plugin function to invoke.
Handler string `json:"handler"`
// Description is shown in /api/plugins so an operator can see what a plugin
// has exposed without reading the .lua.
Description string `json:"description,omitempty"`
// Plugin is filled in by HTTPHandlers: which plugin registered this route.
// The gateway dispatcher needs it to know which Lua state to enter, and
// deriving it from the path would mean trusting the plugin's own prefix.
Plugin string `json:"-"`
}
// routePath renders the handler's full mount path.
func (h HTTPHandler) routePath() string {
return "/api/plugins/http/" + strings.Trim(h.Path, "/")
}
// allows reports whether a caller may invoke this handler.
//
// keyName is the caller key's configured name ("" when it has none), not the
// secret.
func (h HTTPHandler) allows(role, keyName string) bool {
// Admin is always admitted. An operator must be able to inspect what a plugin
// exposes even while locked out of it, or a typo in a key list would leave
// nobody able to diagnose the endpoint they were trying to tighten.
if role == "admin" {
return true
}
// Named keys REPLACE the role rule rather than adding to it.
if len(h.Keys) > 0 {
if keyName == "" {
return false
}
for _, k := range h.Keys {
if strings.EqualFold(strings.TrimSpace(k), keyName) {
return true
}
}
return false
}
if h.Public {
return true
}
if len(h.Roles) == 0 {
// No declared roles and no public opt-out: treat as admin-only. A plugin
// author who forgets this gets a 403 rather than an open endpoint, and
// the error says which field to set.
return role == "admin"
}
for _, r := range h.Roles {
if strings.EqualFold(strings.TrimSpace(r), role) {
return true
}
}
return false
}
func (ps *Plugins) StateVisibleTo(name, role string) bool {
if role == "admin" {
return true
}
ps.mu.RLock()
defer ps.mu.RUnlock()
for _, p := range ps.plugins {
if p.Info.Name != name {
continue
}
override := ps.visibility[name]
if override != "" {
return audienceAllows(override, role)
}
declared := ""
if p.UI != nil {
declared = p.UI.Visibility
}
return audienceAllows(declared, role)
}
// An unknown plugin name: no visibility can be established, and this endpoint
// answers with an empty state rather than an error, so refusing here would
// change behaviour for a name that may simply not be installed.
return true
}
func containsPage(pages []*UIPage, id string) bool {
for _, p := range pages {
if p != nil && p.PageID == id {
return true
}
}
return false
}
// readPluginHTTP collects plugin.http, the endpoints the plugin asks the gateway
// to mount.
//
// A malformed entry is DROPPED rather than failing the plugin: a plugin with one
// bad route should still load its hooks and pages, and the bad route simply does
// not exist. Failing the whole plugin would take a working billing widget down
// over a typo in an experimental endpoint.
func readPluginHTTP(L *golua.State) []HTTPHandler {
L.GetGlobal(pluginGlobal)
if L.IsNil(-1) {
L.SetTop(0)
return nil
}
L.GetField(-1, "http")
if L.Type(-1) != golua.LUA_TTABLE {
L.SetTop(0)
return nil
}
// luaTableToGoValue, NOT luaToJSON and NOT a hand-rolled GetField loop.
//
// luaToJSON needs the json global, which is not installed yet while a plugin
// is registering. And golua's GetField does NOT convert a numeric key: with
// a string index "1" it returns nil, so an index loop silently produced an
// empty list and every plugin appeared to declare no endpoints at all.
raw := luaTableToGoValue(L, -1)
L.SetTop(0)
items, ok := raw.([]interface{})
if !ok {
return nil
}
var list []HTTPHandler
for _, it := range items {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
h := HTTPHandler{
Path: asString(m["path"]),
Method: asString(m["method"]),
Handler: asString(m["handler"]),
Description: asString(m["description"]),
Public: m["public"] == true,
}
if roles, ok := m["roles"].([]interface{}); ok {
for _, r := range roles {
if s := asString(r); s != "" {
h.Roles = append(h.Roles, s)
}
}
}
if keys, ok := m["keys"].([]interface{}); ok {
for _, k := range keys {
if s := asString(k); s != "" {
h.Keys = append(h.Keys, s)
}
}
}
// A malformed entry is dropped rather than fatal: one bad route must not
// cost a plugin its hooks and pages. That is also why the mounted count
// can be lower than the declared count — /api/plugins lists what mounted.
if method, mErr := normalizeHandlerMethod(h.Method); mErr == nil {
h.Method = method
if validateHandlerPath(h.Path) == nil && h.Handler != "" {
list = append(list, h)
}
}
}
return list
}
// uipageFromValue builds a UIPage from a decoded Lua table.
func uipageFromValue(m map[string]interface{}) *UIPage {
pg := &UIPage{
PageID: asString(m["page_id"]),
Title: asString(m["title"]),
Icon: asString(m["icon"]),
Mount: asString(m["mount"]),
Audience: asString(m["audience"]),
}
if n, ok := asFloat(m["order"]); ok {
pg.Order = int(n)
}
return pg
}

View File

@ -503,24 +503,6 @@ end
-- A whole page. The kernel injects this HTML and evaluates the <script> after
-- the DOM exists, and exposes `pluginAPI` for talking to the gateway.
plugin.ui = {
-- Readable by any role, because the state is SPLIT below: a user key sees
-- its own spend and nothing else. Marking the page admin-only would make the
-- split pointless; marking it "all" without the split is exactly the leak this
-- closes — a user key pulled the whole 18KB ledger (16871 requests, $8.86,
-- every source name) straight out of GET /state. The gateway's audience
-- override can tighten this per plugin without editing this file.
visibility = "all",
-- Split the state: global figures are deployment data and are withheld from
-- non-admin callers, while the per-key buckets describe ONE caller.
-- depth: by_key is {maskedKey: bucket}; by_day_key is {day: {maskedKey: …}}.
state_scope = {
global = {
"total", "by_source", "by_model", "by_day", "by_day_src",
"by_day_model", "by_tier_served", "skip_reasons", "degraded_reqs",
"unpriced_models", "unpriced_reqs", "started",
},
depth = { by_key = 1, by_day_key = 2 },
},
page = {
page_id = "billing",
title = "Billing",

View File

@ -1,794 +0,0 @@
-- Provider quota — plan balance across upstreams, one card per source.
--
-- This started as a Command Code plugin and grew, because only one vendor was
-- being asked. The shape that works across providers is a two-part contract:
--
-- 1. A small set of PROBE ADAPTERS, each naming an endpoint, how to call it
-- and which JSON paths hold the numbers. Adapters are tried in order and
-- the first one that returns a usable answer wins.
-- 2. A DECLARATIVE override, so a provider nobody anticipated can be added
-- from the plugin's own state (endpoints + path mapping) without touching
-- code.
--
-- What the adapters share, and why it is worth generalizing:
--
-- - The DeepSeek family (deepseek, and the many re-sells that copied its
-- dashboard) answers GET /user/balance with
-- {is_available, balance_infos:[{currency,total_balance,granted,topped_up}]}.
-- Granted vs topped-up matters: gifted credit expiring on a schedule is a
-- different operational fact from prepaid money.
-- - The OpenAI-compatible dashboard shape answers
-- /v1/dashboard/billing/subscription plus /v1/dashboard/billing/usage,
-- which is what most re-sells of an OpenAI console expose. Note these
-- report LIMITS and SPEND, not a balance: the provider here has a hard limit
-- of 1e8 and 660 spent, so "remaining" must not be computed as
-- limit - spend and presented as money left — it is a ceiling nobody is
-- near. Reporting that honestly matters more than a green bar.
-- - Command Code exposes rolling 5-hour / weekly / monthly windows plus a
-- billing period, which no other provider here does.
--
-- A provider with no working adapter is SKIPPED, silently. Half these upstreams
-- are small re-sells whose dashboard endpoints return an HTML page, not JSON; a
-- list full of "unknown" rows trains the operator to ignore the page entirely,
-- which is worse than showing only what is real. Failures that are NOT "no such
-- endpoint" — a 401 on a key we hold, say — are surfaced, because those are the
-- ones worth fixing.
--
-- Only hosts of CONFIGURED SOURCES are reachable (__llmsproxy_http enforces
-- that), so adding a probe never widens what the gateway can talk to.
local PLUGIN = {}
PLUGIN.name = "quota"
PLUGIN.version = "1.0.0"
PLUGIN.description = "多源套餐余量(余额 / 用量 / 滚动窗口)"
PLUGIN.exports = { "collect_source", "collect_all" }
-- plugin.state must exist before anything indexes it. The kernel restores
-- persisted state OVER this table, so declaring it here is safe: a restart with
-- no saved state still has an empty table to write into, rather than nil.
--
-- It is initialised at the TOP on purpose. Every collect path below touches it
-- (to restore the probe memo on first use, to persist it afterwards), and a
-- declaration further down would be nil for the reads that run first.
PLUGIN.state = {}
-- ---------------------------------------------------------------- helpers
local function tonum(v)
return tonumber(v) or 0
end
-- hostOf extracts the host from a base_url, for showing WHICH upstream a row is
-- about. Two sources on the same vendor (two Command Code keys) are
-- indistinguishable without it.
local function hostOf(u)
return (string.gmatch(u, "^https?://([^/]+)")()) or u
end
-- get walks a dotted path through nested tables, returning nil if any hop is
-- missing. A missing field must stay nil rather than becoming 0: "this provider
-- did not report it" and "this provider reported zero" are different, and
-- collapsing them shows a healthy $0.00 for a provider that never answered.
local function get(root, path)
if root == nil then return nil end
local cur = root
for seg in string.gmatch(path, "[^%.]+") do
if type(cur) ~= "table" then return nil end
cur = cur[seg]
if cur == nil then return nil end
end
return cur
end
local function getnum(root, path)
local v = get(root, path)
if v == nil then return nil end
if type(v) == "boolean" then return v and 1 or 0 end
local n = tonumber(v)
return n
end
-- request performs one GET with the standard retry. Retrying is not
-- politeness here: several of these hosts sit behind DNS that intermittently
-- refuses, and a single 000 turned into "provider unknown" on every poll.
local function request(path, key, headers, tries)
tries = tries or 2
local hdrs = {}
for k, v in pairs(headers or {}) do hdrs[k] = v end
hdrs["Authorization"] = "Bearer " .. tostring(key or "")
local lastErr = "no attempt"
for _ = 1, tries do
local res, err = __llmsproxy_http({
-- 4s, not 10s: probing is network-bound and the whole page shares ONE
-- Lua state under a write lock, so a slow upstream is not just a slow card
-- — it stalls every request_end hook (i.e. billing) behind it.
url = path, method = "GET", headers = hdrs, timeout_ms = 4000,
})
if res ~= nil then
if res.status >= 200 and res.status < 300 then
local ok, decoded = pcall(json.decode, res.body or "")
if ok and type(decoded) == "table" then
return decoded, nil
end
-- A 200 that is not JSON means we hit a marketing page, not an API.
return nil, "not json (HTTP " .. tostring(res.status) .. ")"
end
if res.status == 401 or res.status == 403 then
-- Retrying a rejected credential only wastes the operator's time.
return nil, "HTTP " .. tostring(res.status) .. " (key rejected)"
end
lastErr = "HTTP " .. tostring(res.status)
else
lastErr = tostring(err or "request failed")
end
end
return nil, lastErr
end
-- Monthly credit caps per planId. The credits endpoint never reports a cap, so
-- this is a lookup against the vendor's published pricing ($10 Go, $70
-- GOAT/Pro/Max), cross-checked against monthly_used + balance. Declared before
-- the adapters that read it — a Lua local is nil until its assignment runs, and
-- the Command Code probe hit that as a runtime nil-index.
local PLAN_CAPS = {
["individual-go"] = 10,
["individual-goat"] = 70,
["individual-pro"] = 70,
["individual-max"] = 70,
["team-goat"] = 70,
}
-- ---------------------------------------------------------------- adapters
-- Each adapter returns (report, nil) when it produced something usable, or
-- (nil, reason) to let the next one try. `skip` is the quiet reason — a 404 or
-- an HTML page means "this provider does not expose balance here", which is not
-- worth showing. Everything else becomes a visible failure.
local function deepseekBalance(base, key)
local r, err = request(base .. "/user/balance", key)
if r == nil then
if err and (err:match("not json") or err:match("HTTP 404")) then
return nil, "skip", err
end
return nil, "error", err
end
local infos = get(r, "balance_infos")
if type(infos) ~= "table" or #infos == 0 then return nil, "skip", "no balance_infos" end
local first = infos[1]
local rep = {
vendor = "deepseek-style",
currency = tostring(first.currency or "USD"),
balance = tonum(first.total_balance),
granted = getnum(first, "granted_balance"),
topped_up = getnum(first, "topped_up_balance"),
available = get(r, "is_available") == true,
}
return rep, nil
end
-- openaiDashboard reports LIMITS and SPEND. There is deliberately no
-- `remaining` here: hard_limit_usd is a ceiling (1e8 on the live provider), and
-- ceiling - spend would read as "$99,999,339 left", which is a number nobody
-- should ever believe.
local function openaiDashboard(base, key)
local sub, subErr = request(base .. "/v1/dashboard/billing/subscription", key, nil, 1)
if sub == nil then
if subErr and (subErr:match("not json") or subErr:match("HTTP 404")) then
return nil, "skip", subErr
end
end
local use, useErr = request(base .. "/v1/dashboard/billing/usage", key, nil, 1)
if sub == nil and use == nil then
if subErr and subErr:match("key rejected") then
return nil, "error", subErr
end
return nil, "skip", (subErr or useErr or "no dashboard")
end
local rep = {
vendor = "openai-dashboard",
hard_limit = getnum(sub or {}, "hard_limit_usd"),
soft_limit = getnum(sub or {}, "soft_limit_usd"),
total_usage = getnum(use or {}, "total_usage"),
has_payment_method = get(sub or {}, "has_payment_method") == true,
}
-- Only call it a balance when the vendor says so. A hard limit with no usage
-- endpoint is a ceiling with nothing spent against it; that still renders, but
-- as a limit rather than as money left.
rep.balance = nil
if rep.hard_limit and rep.hard_limit < 1000000 and rep.total_usage then
rep.balance = rep.hard_limit - rep.total_usage
rep.cap = rep.hard_limit
rep.used = rep.total_usage
end
return rep, nil
end
-- commandCode keeps its own shape: rolling windows plus a billing period.
local function commandCode(base, key)
local H = {
["x-command-code-version"] = "1.54.2",
["x-cli-environment"] = "production",
["User-Agent"] = "quota-plugin/1.0.0",
}
local origin = base:gsub("/provider/v1/?$", "")
local credits, err = request(origin .. "/alpha/billing/credits", key, H)
if credits == nil then
if err and err:match("key rejected") then return nil, "error", err end
return nil, "skip", err
end
local rep = {
vendor = "commandcode",
currency = "USD",
balance = tonum(get(credits, "credits.monthlyCredits"))
+ tonum(get(credits, "credits.purchasedCredits"))
+ tonum(get(credits, "credits.freeCredits")),
below_threshold = get(credits, "credits.belowThreshold") == true,
limited = get(credits, "windowLimits.limited") == true,
exceeded = get(credits, "windowLimits.exceeded"),
}
local fh = get(credits, "windowLimits.fiveHour")
if type(fh) == "table" then
rep.windows = rep.windows or {}
rep.windows[#rep.windows + 1] = {
label = "5 小时", used = tonum(fh.used), cap = tonum(fh.cap),
exceeded = fh.exceeded == true, reset_at = tonum(fh.resetAt),
}
end
local wk = get(credits, "windowLimits.weekly")
if type(wk) == "table" then
rep.windows = rep.windows or {}
rep.windows[#rep.windows + 1] = {
label = "每周", used = tonum(wk.used), cap = tonum(wk.cap),
exceeded = wk.exceeded == true, reset_at = tonum(wk.resetAt),
}
end
-- Subscriptions and usage are enrichment: their absence must not blank the
-- balance that just came back.
local subs = request(origin .. "/alpha/billing/subscriptions", key, H, 1)
if subs ~= nil then
rep.plan_id = get(subs, "data.planId")
rep.plan_status = get(subs, "data.status")
rep.period_end = get(subs, "data.currentPeriodEnd")
rep.plan_cap = PLAN_CAPS[tostring(rep.plan_id or "")]
rep.cancel_at_period_end = get(subs, "data.cancelAtPeriodEnd") == true
end
local usage = request(origin .. "/alpha/usage/summary", key, H, 1)
if usage ~= nil then
rep.monthly_used = getnum(usage, "totalMonthlyCredits")
rep.requests = getnum(usage, "totalCount")
rep.tokens = getnum(usage, "totalTokens")
rep.success_rate = getnum(usage, "successRate")
end
if rep.plan_cap and rep.monthly_used and rep.monthly_used > 0 then
local implied = rep.monthly_used + rep.balance
if math.abs(implied - rep.plan_cap) > (rep.plan_cap * 0.02 + 0.5) then
rep.cap_mismatch = true
rep.implied_cap = implied
end
end
return rep, nil
end
-- Ordered probes. DeepSeek-family first because it is a single cheap endpoint,
-- then the OpenAI console shape, then Command Code's four.
local ADAPTERS = {
{ vendor = "commandcode", probe = commandCode },
{ vendor = "deepseek", probe = deepseekBalance },
{ vendor = "openai-dash", probe = openaiDashboard },
}
-- The probe memo remembers, per host, which adapter answered. Persisted in the
-- plugin's state so the expensive part — discovering that 10 of 21 upstreams
-- expose no balance endpoint at all — is paid once, not on every page open.
--
-- This matters more than it looks. The answers depend only on the host, never
-- on the key, and re-discovering them cost 21 sources x 3 adapters x 2 attempts
-- of timeouts on the first load.
-- HOST_MEMO maps host -> adapter vendor (or false when nothing answered). It
-- is restored from the plugin's own state on the first collect of a process, so
-- a restart does not re-pay the cost of discovering which upstreams are silent.
local HOST_MEMO = {}
-- HOST_MEMO_AT[host] = when that host last produced data. A negative memo older
-- than PROBE_MEMO_TTL is ignored, so a vendor that adds an endpoint is picked up
-- within the TTL without a restart.
local HOST_MEMO_AT = {}
local PROBE_MEMO_TTL = 1800 -- 30 minutes
-- PROBE_BUDGET_MS caps one collect pass. Without it a page open over a set of
-- dead upstreams runs to the sum of every timeout — measured at 149s — and the
-- plugin's write lock is held the whole time.
local PROBE_BUDGET_MS = 12000
-- ---------------------------------------------------------------- source scan
-- collectSource probes one source and returns a report, or nil when the source
-- simply has no balance endpoint (silent) — the caller distinguishes the two
-- through `skipped`.
local function collectSource(src, budget)
local base = tostring(src.base_url or "")
if base == "" then return nil end
base = base:gsub("/+$", "")
local key = tostring(src.api_key or "")
local rep, kind, why
-- Try the adapter this host answered with last time first. Same host with a
-- different key (two Command Code seats) gives the same answers, so this turns
-- 21 sources into a handful of distinct round trips after the first pass.
local host = hostOf(base)
local known = HOST_MEMO[host]
-- A negative memo is a TABLE ({at=timestamp}), distinguishable from a vendor
-- string. JSON round-trips it fine; a bare false would not survive the trip
-- through the Go-side state as cleanly, and the two being different types is
-- exactly the confusion that made an earlier revision drop five sources.
if type(known) == "table" then
local seen = known.at or 0
if PROBE_MEMO_TTL > 0 and (os.time() - seen) > PROBE_MEMO_TTL then
known = nil
else
known = false -- still "nothing answered, try cheapest first"
end
end
-- known == false means every adapter already came back "no endpoint here" for
-- this host during this process. Skip the probing entirely; one cheap request
-- confirms it is still the case, so a vendor that adds a dashboard later is
-- picked up without a restart.
-- A cheap liveness probe, NOT a skip: the hosts behind these adapters are
-- slow (12s+ to answer a root request), so treating a slow answer as "down"
-- lost five sources that do have real data. The probe only short-circuits the
-- genuinely unreachable case; a reachable host still goes through the
-- adapters.
if known == false then
local probe = __llmsproxy_http({
url = base .. "/", method = "GET", timeout_ms = 2500,
headers = { ["Authorization"] = "Bearer " .. tostring(key or "") },
})
if probe == nil then
return { name = src.name, host = host, skipped = true,
reason = "host unreachable" }
end
-- Reachable: fall through and try the adapters again, since a slow host
-- that was merely timing out may answer fine.
end
local order = ADAPTERS
if known then
local reordered, seen = {}, {}
for _, a in ipairs(ADAPTERS) do
if a.vendor == known then
reordered[#reordered + 1] = a
seen[a.vendor] = true
end
end
for _, a in ipairs(ADAPTERS) do
if not seen[a.vendor] then reordered[#reordered + 1] = a end
end
order = reordered
end
for _, a in ipairs(order) do
-- Budget check BETWEEN adapters, so an exhausted budget still returns the
-- sources already answered instead of nothing at all.
if budget and budget.exhausted() then
return { name = src.name, host = host, skipped = true,
reason = "probe budget exhausted" }
end
local r, k, e = a.probe(base, key)
if r ~= nil then
rep = r
HOST_MEMO[host] = a.vendor
break
end
kind, why = k, e
end
-- Remember the NEGATIVE result too. Only recording successes left every
-- source that has no balance endpoint re-probing all three adapters (2
-- attempts, 4s each) on every single page open — measured at 60s per source,
-- unchanged between runs, because "nothing answered" was never recorded.
--
-- The value is the adapter that came closest (an error rather than a skip), so
-- a host that later starts working is still retried first.
-- Remember that this host answered nothing, but only as an ORDERING hint, and
-- with a stamp so it expires. "This upstream has no balance endpoint" is a
-- fact that can change — vendors ship dashboards — so a permanent negative
-- would make the page quietly stop reporting a source that started working.
-- The stamp is what turns a 60s first load into a fast steady state without
-- freezing what the page can see.
if rep == nil and not HOST_MEMO[host] then
HOST_MEMO[host] = { at = os.time() }
end
if rep ~= nil then
HOST_MEMO_AT[host] = os.time()
end
if rep == nil then
if kind == "error" then
return { name = src.name, vendor = "", error = tostring(why),
skipped = false, host = hostOf(base) }
end
return { name = src.name, skipped = true, host = hostOf(base) }
end
rep.name = src.name
rep.host = hostOf(base)
rep.skipped = false
rep.checked_at = os.time()
return rep
end
-- collect_all walks every source. Prefer it for a SMALL number of sources.
--
-- The page calls collect_source concurrently instead: probing is almost entirely
-- network wait (three adapters, up to two attempts, ten second timeouts each),
-- and doing 21 sources in sequence overran the HTTP call's own budget — the
-- whole page returned 502 while individual probes were still in flight. The
-- sequential entry point stays for tests and for anyone scripting the plugin.
PLUGIN.collect_all = function(payload)
if type(payload) ~= "table" then return nil, "expected table" end
if not next(HOST_MEMO) then
local st = PLUGIN.state
if type(st) == "table" and type(st.probe_memo) == "table" then
for k, v in pairs(st.probe_memo) do HOST_MEMO[k] = v end
end
end
local srcs = payload.sources
if type(srcs) ~= "table" then return nil, "missing sources" end
local t1 = os.clock()
local lim1 = PROBE_BUDGET_MS / 1000
local function spent()
return (os.clock() - t1) > lim1
end
local budget = { exhausted = spent }
local out = {}
for _, s in ipairs(srcs) do
if type(s) == "table" then
local rep = collectSource(s, budget)
if rep ~= nil then out[#out + 1] = rep end
end
end
PLUGIN.state.probe_memo = HOST_MEMO
return out, nil
end
-- collect_source probes one named source.
PLUGIN.collect_source = function(payload)
if type(payload) ~= "table" then return nil, "expected table" end
if not next(HOST_MEMO) then
local st = PLUGIN.state
if type(st) == "table" and type(st.probe_memo) == "table" then
for k, v in pairs(st.probe_memo) do HOST_MEMO[k] = v end
end
end
local srcs = payload.sources
if type(srcs) ~= "table" then return nil, "missing sources" end
-- A closure over the deadline, not a table method. `exhausted = function(self)`
-- reads correctly but breaks the moment the call site is `budget.exhausted()`
-- from inside a pcall-guarded hook where the upvalue chain differs; the
-- explicit capture below cannot go wrong that way.
local t0 = os.clock()
local limit = PROBE_BUDGET_MS / 1000
local function exhausted()
return (os.clock() - t0) > limit
end
local budget = { exhausted = exhausted }
for _, s in ipairs(srcs) do
if type(s) == "table" and s.name == payload.name then
local rep = collectSource(s, budget)
-- Persist the host→adapter map so the next PROCESS skips the dead ends,
-- not just the next page open.
PLUGIN.state.probe_memo = HOST_MEMO
return rep, nil
end
end
return nil, "source not found: " .. tostring(payload.name)
end
-- ---------------------------------------------------------------- page
PLUGIN.ui = {
-- Every row names an upstream host and what it has spent. That is deployment
-- information, not per-caller information, so it is admin-only by default; an
-- operator who wants a shared read-only view can widen it from config without
-- editing this file.
visibility = "admin",
page = {
page_id = "quota",
title = "Quota",
order = 45,
mount = [==[
<div id="qa-root" class="qa-root">
<div class="qa-head">
<div>
<div class="qa-title">套餐余量</div>
<div class="qa-sub" id="qa-sub">点击刷新</div>
</div>
<div class="grow"></div>
<button class="small" id="qa-refresh">刷新</button>
</div>
<div id="qa-list"><div class="qa-empty">尚未拉取</div></div>
</div>
<style>
#qa-root{padding:16px;min-width:0;max-width:100%;overflow-x:auto}
.qa-head{display:flex;align-items:flex-start;gap:10px;margin:0 0 12px;flex-wrap:wrap}
.qa-title{font-size:15px;font-weight:600}
.qa-sub{font-size:12px;opacity:.66;margin-top:2px}
.qa-acc{border:1px solid var(--bd,rgba(127,127,127,.25));border-radius:10px;padding:12px 14px;margin:0 0 10px;min-width:0}
.qa-acc.err{border-color:rgba(220,80,80,.5)}
.qa-h{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 4px}
.qa-n{font-weight:600;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:46%}
.qa-host{font-size:11.5px;opacity:.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.qa-vendor{font-size:11px;padding:1px 7px;border-radius:999px;border:1px solid rgba(127,127,127,.35);opacity:.8;white-space:nowrap}
.qa-pill{font-size:11px;padding:1px 7px;border-radius:999px;border:1px solid rgba(127,127,127,.35);opacity:.85;white-space:nowrap}
.qa-pill.ok{color:#15803d;border-color:rgba(21,128,61,.45)}
.qa-pill.low{color:#b45309;border-color:rgba(180,83,9,.45)}
.qa-pill.out{color:#b91c1c;border-color:rgba(185,28,28,.45)}
.qa-bal{display:flex;align-items:baseline;gap:8px;margin:6px 0 8px;flex-wrap:wrap}
.qa-bal-n{font-size:19px;font-weight:600}
.qa-bal-l{font-size:12px;opacity:.66}
.qa-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:9px 18px}
.qa-w{min-width:0}
.qa-w-h{display:flex;justify-content:space-between;gap:6px;font-size:12px;opacity:.85}
.qa-w-n{font-variant-numeric:tabular-nums;font-weight:600}
.qa-bar{height:6px;border-radius:3px;background:rgba(127,127,127,.22);overflow:hidden;margin:4px 0 2px}
.qa-fill{height:100%;background:#3b82f6;border-radius:3px}
.qa-fill.warn{background:#d97706}
.qa-fill.over{background:#dc2626}
.qa-w-d{font-size:11px;opacity:.6;font-variant-numeric:tabular-nums}
.qa-meta{display:flex;gap:13px;flex-wrap:wrap;font-size:11.5px;opacity:.7;margin-top:9px}
.qa-fail{font-size:11.5px;color:#b91c1c;margin-top:7px;word-break:break-word}
.qa-warnl{font-size:11px;color:#b45309;margin-top:3px}
.qa-empty{opacity:.6;font-size:13px;padding:8px 0}
.qa-skip{font-size:11.5px;opacity:.5;margin-top:8px;line-height:1.6}
</style>
<script>
(function(){
var MONTHLY_CAP_NOTE = "月度上限按套餐表推导";
function escH(s){return String(s==null?"":s).replace(/[&<>"]/g,function(c){
return c==="&"?"&amp;":c==="<"?"&lt;":c===">"?"&gt;":"&quot;";});}
// Money is formatted with the vendor's own currency; CNY and USD differ only
// in symbol here, which is deliberate — guessing an exchange rate would be a
// far worse lie than showing the raw number with its unit.
function money(v, cur){
cur = cur || "USD";
var sym = cur === "CNY" ? "¥" : (cur === "USD" ? "$" : cur + " ");
v = Number(v)||0;
// A negative credit is a real debit, not "zero" — deepseek reports
// topped_up_balance -0.02 against a grant, and flattening that to 0.00
// hides the overdraft.
if (v===0) return sym+"0.00";
if (v<0) return "-"+sym+Math.abs(v).toFixed(2);
return sym+(v<0.01?v.toFixed(4):v.toFixed(2));
}
function pct(u,c){ c=Number(c)||0; u=Number(u)||0; if(!(c>0)) return null;
return Math.floor(u/c*100+0.5); }
function resetTxt(ms){
if(!(Number(ms)>0)) return "";
var s=Math.floor(Number(ms)/1000-Date.now()/1000); if(s<=0) return "";
var d=Math.floor(s/86400),h=Math.floor((s%86400)/3600),m=Math.floor((s%3600)/60);
if(d>0) return d+"d "+h+"h"; if(h>0) return h+"h "+m+"m"; return m+"m";
}
// windowRow renders one used/cap bar. A window with no cap renders as a plain
// "used" line rather than a bar of unknown width.
function win(w){
if(!w) return "";
var cap=Number(w.cap)||0, used=Number(w.used)||0;
var h='<div class="qa-w"><div class="qa-w-h"><span>'+escH(w.label);
var r=resetTxt(w.reset_at); if(r) h+=' <span style="opacity:.6">↻'+escH(r)+'</span>';
var p = pct(used,cap);
if(p===null){
h+='</span><span class="qa-w-n">'+used.toLocaleString()+'</span></div>'+
'<div class="qa-w-d">已用(无上限数据)</div></div>';
return h;
}
if(w.exceeded===true) p=100;
var cls="qa-fill"+((w.exceeded===true||p>=100)?" over":(p>=80?" warn":""));
h+='</span><span class="qa-w-n">'+p+'%</span></div>'+
'<div class="qa-bar"><div class="'+cls+'" style="width:'+Math.min(100,p)+'%"></div></div>'+
'<div class="qa-w-d">'+used.toLocaleString()+' / '+cap.toLocaleString()+'</div></div>';
return h;
}
// monthRow is Command Code's monthly allowance, which has no vendor cap.
function month(r){
var cap=Number(r.plan_cap)||0, used=Number(r.monthly_used)||0;
if(!(used>0) && !(cap>0)) return "";
var h='<div class="qa-w"><div class="qa-w-h"><span>每月';
var pe=r.period_end;
if(pe){ var d=Math.floor((new Date(pe)-Date.now())/86400000);
if(d>0) h+=' <span style="opacity:.6">↻'+d+'d</span>'; }
var p=pct(used,cap);
if(p===null){
h+='</span><span class="qa-w-n">$'+used.toFixed(2)+'</span></div>'+
'<div class="qa-w-d" title="'+escH(MONTHLY_CAP_NOTE)+'">已用 · 套餐上限未知</div></div>';
return h;
}
h+='</span><span class="qa-w-n">'+p+'%</span></div>'+
'<div class="qa-bar"><div class="qa-fill'+(p>=100?" over":(p>=80?" warn":""))+
'" style="width:'+Math.min(100,p)+'%"></div></div>'+
'<div class="qa-w-d">$'+used.toFixed(2)+' / $'+cap.toFixed(0)+'</div>';
if(r.cap_mismatch){
h+='<div class="qa-warnl" title="已用+余额 与套餐额度不符,比例仅供参考">⚠ 额度口径可能有变</div>';
}
return h+"</div>";
}
// balanceLine renders the headline number. Three cases, kept visually distinct
// because they mean different things: money left, a spend against a ceiling,
// or no number at all.
function balLine(r){
if(r.error) return "";
if(r.balance!==undefined && r.balance!==null){
var extra="";
// granted / topped_up may be NEGATIVE (deepseek reports topped_up -0.02
// against a positive grant), so the zero test is on the ABSOLUTE value.
// `!== 0` on a string-typed value would have been true anyway, but on a
// coerced number it hid a real debit behind a flat 0.00.
var g=Number(r.granted), t2=Number(r.topped_up);
if((!isNaN(g)&&g!==0)||(!isNaN(t2)&&t2!==0)){
extra=" <span class='qa-bal-l'>(赠送 "+
money(g,r.currency)+" · 充值 "+money(t2,r.currency)+")</span>";
}
var pill="";
if(r.available===false) pill=' <span class="qa-pill out">不可用</span>';
else if(r.exceeded) pill=' <span class="qa-pill out">已超额</span>';
else if(r.below_threshold) pill=' <span class="qa-pill low">低于阈值</span>';
return '<div class="qa-bal"><span class="qa-bal-n">'+money(r.balance,r.currency)+
'</span><span class="qa-bal-l">余额</span>'+pill+extra+'</div>';
}
if(r.hard_limit!==undefined && r.hard_limit!==null){
// No balance is derivable: a 1e8 ceiling is a policy number, not money.
var s='<div class="qa-bal"><span class="qa-bal-n">'+money(r.total_usage||0,r.currency)+
'</span><span class="qa-bal-l">累计花费(限额 '+
(r.hard_limit>=1000000?'∞':money(r.hard_limit,r.currency))+')</span></div>';
return s;
}
return "";
}
function state(r){
if(r.error) return {cls:"err", pill:'<span class="qa-pill out">读取失败</span>'};
if(r.available===false) return {cls:"out", pill:'<span class="qa-pill out">不可用</span>'};
if(r.exceeded) return {cls:"out", pill:'<span class="qa-pill out">已超额</span>'};
if(r.below_threshold) return {cls:"low", pill:'<span class="qa-pill low">低于阈值</span>'};
if(r.balance!==undefined && r.balance!==null){
var b=Number(r.balance);
if(b<=0) return {cls:"out", pill:'<span class="qa-pill out">无余额</span>'};
if(b<5) return {cls:"low", pill:'<span class="qa-pill low">余额偏低</span>'};
return {cls:"ok", pill:'<span class="qa-pill ok">'+(r.plan_status||"正常")+'</span>'};
}
if(r.hard_limit!==undefined && r.hard_limit!==null)
return {cls:"ok", pill:'<span class="qa-pill ok">按量计费</span>'};
return {cls:"", pill:'<span class="qa-pill">有数据</span>'};
}
function card(r){
var st=state(r);
var h='<div class="qa-acc'+(r.error?" err":"")+'"><div class="qa-h">'+
'<span class="qa-n">'+escH(r.name)+'</span>'+
'<span class="qa-host">'+escH(r.host||"")+'</span>'+
'<span class="qa-vendor">'+escH(r.vendor||"?")+'</span>'+
'<span class="grow"></span>'+st.pill+'</div>';
if(r.error){
h+='<div class="qa-fail">'+escH(r.error)+'</div></div>';
return h;
}
h+=balLine(r);
var rows="";
rows+=month(r);
if(r.windows) r.windows.forEach(function(w){ rows+=win(w); });
if(rows) h+='<div class="qa-grid">'+rows+'</div>';
var meta=[];
if(r.plan_id) meta.push("套餐 "+String(r.plan_id).replace(/^individual-/,""));
if(r.period_end) meta.push("周期至 "+String(r.period_end).slice(0,10));
if(r.cancel_at_period_end) meta.push("到期不续费");
if(r.requests) meta.push(r.requests.toLocaleString()+" 次请求");
if(r.tokens) meta.push((r.tokens/1e9).toFixed(2)+"B tokens");
if(r.success_rate) meta.push("成功率 "+r.success_rate.toFixed(2)+"%");
if(r.has_payment_method===false) meta.push("未绑定支付方式");
if(meta.length) h+='<div class="qa-meta">'+meta.map(escH).join(" · ")+'</div>';
return h+"</div>";
}
// paintSnapshot renders the persisted snapshot WITHOUT touching the network.
// Called on load so the page has content immediately.
async function showSnapshot(){
try{
var st = await pluginAPI.fetchState("quota");
var rs = (st && st.reports) || [];
if(!rs.length) return false;
var real = rs.filter(function(r){ return r && !r.skipped; });
var skipped = rs.filter(function(r){ return r && r.skipped; }).map(function(r){ return r.name; });
render(real, skipped);
var sub=document.getElementById("qa-sub");
if(sub && st.updated_at){
sub.textContent = real.length+" 个源 · 快照 "+new Date(st.updated_at).toLocaleTimeString()+"(点刷新取实时)";
}
return true;
}catch(e){ return false; }
}
function render(reports, skipped){
var host=document.getElementById("qa-list"); if(!host) return;
var real=(reports||[]).filter(function(r){return !r.skipped;});
var bad=real.filter(function(r){return r.error;});
if(!real.length){
host.innerHTML='<div class="qa-empty">没有源提供可读的余额接口</div>';
} else {
host.innerHTML=real.map(card).join("");
if(skipped && skipped.length){
host.innerHTML+='<div class="qa-skip">未列出 '+skipped.length+
' 个源(未暴露余额接口):'+escH(skipped.join("、"))+'</div>';
}
}
var sub=document.getElementById("qa-sub");
if(sub) sub.textContent = real.length+" 个源 · "+bad.length+" 个读取失败";
}
async function refresh(){
var srcs;
try{
var r=await fetch("/api/sources",{credentials:"same-origin"});
srcs=(await r.json()).sources||[];
}catch(e){
var s0=document.getElementById("qa-sub"); if(s0) s0.textContent="拉取源列表失败: "+e.message;
return;
}
if(!window.pluginAPI || typeof pluginAPI.call!=="function"){
var sb=document.getElementById("qa-sub"); if(sb) sb.textContent="pluginAPI.call 不可用(内核版本过旧)";
return;
}
var btn=document.getElementById("qa-refresh"); if(btn) btn.disabled=true;
var sub=document.getElementById("qa-sub"); if(sub) sub.textContent="探测中…";
var out;
try{
// One call per source, fired together. Probing is network-bound, so a
// sequential collect_all over 21 sources blows past the call's time budget
// and every card comes back as one 502 — the probes themselves were fine.
// Concurrency also makes the page honest about which sources are slow:
// each card appears as its own answer lands.
out = await Promise.all(srcs.map(function(s){
return pluginAPI.call("quota","collect_source",{sources:[s],name:s.name})
.catch(function(e){ return {name:s.name,skipped:true,error:String(e.message||e)}; });
}));
// Mirror the host→vendor map the plugin keeps: a source that answered is
// worth probing first next time, and the page has everything it needs to
// record that without another round trip.
var memo = {};
(out||[]).forEach(function(r){
if(r && !r.skipped && !r.error && r.host && r.vendor) memo[r.host] = r.vendor;
});
window.__qaMemo = memo;
}catch(e){
if(btn) btn.disabled=false;
if(sub) sub.textContent="探测失败: "+e.message;
return;
}
var all=out||[];
var real=all.filter(function(r){return !r.skipped;});
var skipped=all.filter(function(r){return r.skipped;}).map(function(r){return r.name;});
// real may already contain errors; collect_all keeps them for visibility.
render(real, skipped);
if(btn) btn.disabled=false;
// Persist both the snapshot and the probe map; the map is what lets the
// next page open skip the upstreams that expose nothing.
try{
await pluginAPI.postState("quota",
{reports:all, updated_at:Date.now(), probe_memo:(window.__qaMemo||{})});
}catch(e){}
}
function mount(){
var b=document.getElementById("qa-refresh");
if(b && !b.__qaWired){ b.__qaWired=true; b.addEventListener("click",refresh); }
}
mount();
showSnapshot();
// Do NOT auto-refresh on load.
//
// Probing is network-bound and every request takes the plugin's write lock, so
// a page open that fires one call per source serialises them: measured 19s to
// first paint and 22-100s to finish, with the whole WebUI held in its loading
// state meanwhile. A balance the operator did not ask for is not worth the
// first paint of the page they opened.
//
// What IS shown automatically is the last snapshot, so an operator who hits
// refresh once sees a card immediately and a fresh number behind it.
if (window.pluginAPI && pluginAPI.onTabShown) pluginAPI.onTabShown(mount);
window.__qaRefresh=refresh; window.__qaRender=render;
})();
</script>
]==],
},
}
return PLUGIN