feat(plugins): 可见性、state 分区、注册式 HTTP 端点 + 按密钥授权

插件页面不是自动公开的数据。billing 显示跨全部源的成本,quota 显示上游余额,
而这两者都不属于持有 user 密钥的人 —— 但 /api/ui-inject 不做任何角色过滤,
所以两者都发给了任何已认证的密钥。实测(不是推断):club 这把 user 密钥拉到了
18672 字节的 billing state,里面是 16871 次请求 / $8.8616 / 21 个源名与各自
成本。

UIPage/UIElement 加 Audience,UIExtension 加 Visibility。语法刻意很小——"" 或
"all" 放行,"admin" 只给 admin,"admin,user" 列角色。**"*" 不是 all 的同义词**:
一个在配置文件里看起来受限、实际放行一切的东西,正是会静默失效的那种设置。

覆盖走 config.yaml 的 plugin_visibility,因为插件作者不知道本部署的策略,而运维
也不该为了改一行去编辑内置 .lua(下次升级就没了)。

过滤发生在 UIForRole 而非 HTTP 层:WebUI 启动载荷、Electron 外壳、测试都读同一
处,只在 HTTP 层过滤意味着其它消费者照旧全量泄漏。

角色可见性回答不了"哪一部分"。billing 里全局数字(total、by_source、by_model、
未定价告警)是部署数据,by_key / by_day_key 描述的是**一个**调用方。于是插件用
state_scope 声明哪些桶是全局的、哪些按调用方过滤,深度由插件给出(1 = {maskedKey:
桶},2 = {day: {maskedKey: 桶}})——两种形状都是 map,靠嗅探分不清"空桶"和"嵌套桶"。

不匹配的行**删除而不是置零**:置零会渲染成"你没花钱",还会泄露"别人有这一行"。
billing 因此改为 visibility="all" + state_scope,而不是 admin-only —— 后者会让
split 失去意义,而前者不加 split 正是这次要堵的泄露。

有些东西值得从浏览器**外面**查(CI 判断上游余额是否为零、监控探针、盒子上的
另一个工具)。billing 从不需要(它只收 hook),quota 需要。为每个插件硬编码一条
路由意味着每次都要改网关,而把任意插件代码塞进主 mux 也不是路由表该做的事。

插件在 plugin.http 里声明 path/method/roles/keys,挂在 /api/plugins/http/ 下。
请求与响应都是普通 table 穿过 Go/Lua 边界。两条由 Go 侧而非信任插件来保证的性质:
status 与 method 走白名单(否则插件能选一个 3xx + Location 把调用者弹到它指定的
URL),Content-Length 与逐跳头被丢弃(否则插件能让网关的响应分帧错位)。

roles 太粗:无法表达"给 homeagent 开放、其他人不开"。keys 按**密钥名**授权而非密钥
值——密钥值会被轮换,规则写死在上面会静默失配;把凭据散进插件文件也是问题;而且
运维读的是名字。

**keys 非空时它是全部规则**,不是与 roles 的并集。写成并集的话,运维写两条允许名单
就会顺带放行所有 user 密钥,正好与意图相反。admin 永远放行,否则名单里一个拼写
错误会让唯一能诊断它的人也进不去。

写入前校验密钥名真实存在:一份写着错别字名字的名单会被存下、保存、在 WebUI 里
显示为"已配置",然后拒绝每一个调用者——失败看起来像端点坏了,而不是名字错了。

readPluginUI / readPluginExports / readPluginHTTP 都在插件**注册期间**调用
luaToJSON,而它依赖 setupGlobals 才安装的 json 全局,于是静默返回空值。三处表现各
不同(页面字段为空、exports 看不见、端点数为 0),同一个原因。现在统一走
luaTableToGoValue。

readPluginHTTP 还额外踩了 golua 的 GetField 不做字符串→数字转换,用索引循环取数组
永远拿到 nil。

去掉角色过滤 → 3 条判据红(含"a user key can see the admin-only page");
去掉 page 级 audience → 1 条红。routes 层断言徽标齐全、方法与 path 校验、栈帧
归属、admin 豁免、{status=204} 合法、空表报错。

生产验证:admin 看到 14 个字段 / by_key 5 行 / total $8.86;user 看到 0 行。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
(cherry picked from commit a6c6e6e5c7)
This commit is contained in:
JianFeeeee
2026-10-04 10:51:07 +08:00
parent 40636abcc2
commit 5eef5ad635
13 changed files with 2091 additions and 379 deletions

View File

@ -0,0 +1,511 @@
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()
var out []HTTPEndpointAccess
for _, p := range ps.plugins {
if p.http == nil || p.LoadError != "" {
continue
}
ov := ps.visibility[p.Info.Name]
var ovRoles, ovKeys []string
if ov != "" {
for _, part := range strings.Split(ov, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// A key name is anything that is not a role: the override string
// carries both, so "@name" marks a key and a bare word a role.
if strings.HasPrefix(part, "@") {
ovKeys = append(ovKeys, strings.TrimPrefix(part, "@"))
} else {
ovRoles = append(ovRoles, part)
}
}
}
for _, h := range p.http {
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)
return nil
}
ps.visibility[key] = spec
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
}

View File

@ -0,0 +1,400 @@
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)
}
}

View File

@ -0,0 +1,81 @@
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
}

View File

@ -0,0 +1,193 @@
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

@ -126,6 +126,13 @@ 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.
@ -134,6 +141,20 @@ 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).
@ -145,6 +166,10 @@ 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"`
@ -179,6 +204,9 @@ 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
@ -233,6 +261,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
@ -633,6 +664,16 @@ 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
@ -762,6 +803,7 @@ func readPluginExports(L *golua.State) []string {
func readPluginUI(L *golua.State) (*UIExtension, bool) {
L.GetGlobal(pluginGlobal)
if L.IsNil(-1) {
L.SetTop(0)
return nil, false
}
L.GetField(-1, "ui")
@ -769,12 +811,67 @@ 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
if err := luaToJSON(L, -1, &ui); err != nil {
L.SetTop(0)
return nil, false
}
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 ui.Page == nil && len(ui.Pages) == 0 && len(ui.Elements) == 0 {
return nil, false
}
@ -1022,6 +1119,139 @@ 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)
return
}
ps.visibility[plugin] = audience
}
// 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
@ -1790,3 +2020,227 @@ func pluginExportAllowed(p *Plugin, fn string) bool {
}
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,6 +503,24 @@ 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,368 +0,0 @@
-- Command Code quota — multi-account plan balance for the gateway.
--
-- The billing plugin only ever reports what the gateway already measured, so it
-- never has to reach out. A plan balance is different: the number only exists
-- on the vendor's side, and the only way to learn it is to ask. Command Code
-- exposes /alpha/billing/credits (plus whoami / subscriptions / usage) on the
-- provider host, authenticated with the same API key the source already uses.
--
-- Accounts: one per configured source whose base_url is the Command Code API
-- host. Two keys on the same host are two separate accounts, and both are
-- shown — an operator with a spare seat needs to see both draining.
--
-- Every endpoint degrades independently. A vendor that retires one route must
-- not blank the others, and a key that has run out still returns HTTP 401 on
-- the inference path while credits reads fine, so the report has to survive
-- partial failure and say WHICH part failed rather than rendering a zero that
-- looks like "no quota left".
local PLUGIN = {}
PLUGIN.name = "ccquota"
PLUGIN.version = "1.0.0"
PLUGIN.description = "Command Code 多账户额度余量(5 小时 / 周 / 月窗口 + 余额)"
-- Command Code's API host. __llmsproxy_http only permits hosts that a
-- configured source already talks to, so this works exactly when such a source
-- exists — no separate allowlist to keep in sync, and no way to widen reach.
local API_HOST = "api.commandcode.ai"
-- These headers are required alongside the bearer token. Without them the
-- endpoints answer 401 even with a perfectly valid key, which looks identical
-- to a revoked key and costs a lot of time to tell apart.
--
-- The bearer token is NOT here on purpose: it is per-account, so fetchJSON
-- copies this table and injects it per request. An earlier version passed this
-- table straight through and every endpoint answered 401 — which reads exactly
-- like a revoked key, and sent me looking at the account instead of the plugin.
local CLI_VERSION = "1.54.2"
local REQUIRED_HEADERS = {
["x-command-code-version"] = CLI_VERSION,
["x-cli-environment"] = "production",
["User-Agent"] = "commandcode-quota/" .. CLI_VERSION,
}
-- Accounts are handed in from the page (which reads /api/sources and its own
-- key store). The kernel deliberately does NOT let a plugin read the config, so
-- this is the only channel — which also means the key never sits in Lua state
-- or in the persisted plugin file.
local ACCOUNTS = {}
-- ---------------------------------------------------------------- helpers
local function tonum(v)
return tonumber(v) or 0
end
-- usd formats a dollar amount the way the vendor's panel does: two decimals,
-- but never a bare "0" when there is a real fractional balance left. A $0.0811
-- balance rounds to $0.08, and showing "$0" there would read as "nothing left".
-- fetchJSON performs one request and returns (decodedTable, nil) or
-- (nil, errorMessage). It NEVER raises: an escaping error inside a plugin hook
-- can take the whole gateway down (see the hook-guard note in the kernel), so
-- every failure has to arrive as an ordinary value.
local function fetchJSON(path, key)
-- Copy, then add the token: mutating the shared table would leak one
-- account's key into every later request.
local headers = {}
for k, v in pairs(REQUIRED_HEADERS) do headers[k] = v end
headers["Authorization"] = "Bearer " .. tostring(key or "")
local res, err = __llmsproxy_http({
url = "https://" .. API_HOST .. path,
method = "GET",
headers = headers,
timeout_ms = 12000,
})
if res == nil then
return nil, tostring(err or "request failed")
end
-- Body arrives as a JSON string; the kernel's json.decode is the only parser
-- available and it raises on malformed input, so it is pcall'd here too.
local ok, decoded = pcall(json.decode, res.body)
if not ok then
return nil, "bad JSON from " .. path
end
return decoded, nil
end
-- authFailure recognises the one error that means "this key is not usable"
-- rather than "this endpoint is unhappy". Quota exhaustion and a revoked key
-- both surface as 401 here, and they need opposite operator responses.
-- ---------------------------------------------------------------- account report
-- collectAccount gathers one account's numbers. Each endpoint is independent:
-- credits is the one that matters, and the rest enrich it. A failure is
-- recorded per-endpoint instead of aborting, because "I could not read the
-- subscription" and "the account has no quota" must not look the same.
local function collectAccount(acct)
local report = {
name = acct.name,
label = acct.label,
failures = {},
checked_at = os.time(),
}
local who, werr = fetchJSON("/alpha/whoami", acct.key)
if who == nil then
report.failures[#report.failures + 1] = "whoami: " .. tostring(werr)
elseif type(who) == "table" and type(who.user) == "table" then
report.email = who.user.email
report.user = who.user.name or who.user.userName
end
local creds, cerr = fetchJSON("/alpha/billing/credits", acct.key)
local rawStatus = 0
if creds == nil then
report.failures[#report.failures + 1] = "credits: " .. tostring(cerr)
else
local c = creds.credits or {}
local wl = creds.windowLimits or {}
report.monthly = tonum(c.monthlyCredits)
report.purchased = tonum(c.purchasedCredits)
report.free = tonum(c.freeCredits)
report.total = report.monthly + report.purchased + report.free
report.limited = wl.limited == true
report.exceeded = wl.exceeded
report.fiveHour = wl.fiveHour
report.weekly = wl.weekly
rawStatus = 200
end
local subs, serr = fetchJSON("/alpha/billing/subscriptions", acct.key)
if subs == nil then
report.failures[#report.failures + 1] = "subscriptions: " .. tostring(serr)
elseif type(subs) == "table" and type(subs.data) == "table" then
report.plan_status = subs.data.status
report.plan_price = subs.data.metadata and nil
local md = subs.data.metadata
if type(md) == "table" then
report.plan_name = md.plan or md.name or md.tier
end
end
local usage, uerr = fetchJSON("/alpha/usage/summary", acct.key)
if usage == nil then
report.failures[#report.failures + 1] = "usage: " .. tostring(uerr)
elseif type(usage) == "table" then
report.requests = tonum(usage.totalCount)
report.spent = tonum(usage.totalCost)
report.tokens = tonum(usage.totalTokens)
report.success_rate = tonum(usage.successRate)
end
-- An account whose every endpoint failed is an auth problem, not a data
-- problem: state it plainly so nobody reads "—" as "zero quota".
if #report.failures >= 3 and report.total == nil then
report.auth_failed = true
end
return report
end
-- ---------------------------------------------------------------- page
PLUGIN.ui = {
page = {
page_id = "ccquota",
title = "CC Quota",
order = 45,
mount = [==[
<div id="ccq-root" class="ccq-root">
<div class="ccq-head">
<div>
<div class="ccq-title">Command Code 额度</div>
<div class="ccq-sub" id="ccq-sub">点击刷新以拉取各账户余量</div>
</div>
<div class="grow"></div>
<button class="small" id="ccq-refresh">刷新</button>
<span class="ccq-auto"></span>
</div>
<div id="ccq-list"><div class="ccq-empty">尚未拉取</div></div>
</div>
<style>
#ccq-root{padding:16px;min-width:0;max-width:100%;overflow-x:auto}
.ccq-head{display:flex;align-items:flex-start;gap:10px;margin:0 0 12px;flex-wrap:wrap}
.ccq-title{font-size:15px;font-weight:600}
.ccq-sub{font-size:12px;opacity:.66;margin-top:2px}
.ccq-auto{font-size:12px;opacity:.6}
.ccq-acc{border:1px solid var(--bd,rgba(127,127,127,.25));border-radius:10px;padding:12px 14px;margin:0 0 12px;min-width:0}
.ccq-acc.err{border-color:rgba(220,80,80,.5)}
.ccq-acc-h{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 4px}
.ccq-acc-n{font-weight:600;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:40%}
.ccq-mail{font-size:12px;opacity:.66;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ccq-pill{font-size:11px;padding:1px 7px;border-radius:999px;border:1px solid rgba(127,127,127,.35);opacity:.85;white-space:nowrap}
.ccq-pill.ok{color:#15803d;border-color:rgba(21,128,61,.45)}
.ccq-pill.low{color:#b45309;border-color:rgba(180,83,9,.45)}
.ccq-pill.out{color:#b91c1c;border-color:rgba(185,28,28,.45)}
.ccq-bal{display:flex;align-items:baseline;gap:8px;margin:6px 0 10px;flex-wrap:wrap}
.ccq-bal-n{font-size:20px;font-weight:600}
.ccq-bal-l{font-size:12px;opacity:.66}
.ccq-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px 18px}
.ccq-win{min-width:0}
.ccq-win-h{display:flex;justify-content:space-between;gap:6px;font-size:12px;opacity:.85}
.ccq-win-n{font-variant-numeric:tabular-nums;font-weight:600}
.ccq-bar{height:6px;border-radius:3px;background:rgba(127,127,127,.22);overflow:hidden;margin:4px 0 2px}
.ccq-bar-fill{height:100%;background:#3b82f6;border-radius:3px}
.ccq-bar-fill.warn{background:#d97706}
.ccq-bar-fill.over{background:#dc2626}
.ccq-win-d{font-size:11px;opacity:.6;font-variant-numeric:tabular-nums}
.ccq-reset{opacity:.6;margin-left:4px}
.ccq-meta{display:flex;gap:14px;flex-wrap:wrap;font-size:11.5px;opacity:.7;margin-top:10px}
.ccq-fail{font-size:11.5px;color:#b91c1c;margin-top:8px;word-break:break-word}
.ccq-empty{opacity:.6;font-size:13px;padding:8px 0}
</style>
<script>
(function(){
var HOST = "api.commandcode.ai";
var lastReports = [];
function escH(s){
return String(s==null?"":s).replace(/[&<>"]/g,function(c){
return c==="&"?"&amp;":c==="<"?"&lt;":c===">"?"&gt;":"&quot;";
});
}
function usd(v){
v = Number(v)||0;
if (v<=0) return "$0.00";
return "$"+(v<0.01?v.toFixed(4):v.toFixed(2));
}
function stateOf(r){
if (r.auth_failed) return {cls:"err", txt:"认证失败"};
if (r.exceeded) return {cls:"out", txt:"已超额"};
var t = Number(r.total)||0;
if (t<=0) return {cls:"out", txt:"无余额"};
if (t<5) return {cls:"low", txt:"余额偏低"};
return {cls:"ok", txt:r.plan_status||"正常"};
}
function win(label,w){
if(!w) return "";
var used=Number(w.used)||0, cap=Number(w.cap)||0;
var p = cap>0 ? Math.floor(used/cap*100+0.5) : null;
if(p===null) return "";
var over = w.exceeded===true;
if(over) p=100;
var cls = "ccq-bar-fill"+(over||p>=100?" over":(p>=80?" warn":""));
var reset="";
if(Number(w.resetAt)>0){
var s=Math.floor(w.resetAt/1000-Date.now()/1000);
if(s>0){
var d=Math.floor(s/86400),h=Math.floor((s%86400)/3600),m=Math.floor((s%3600)/60);
reset=(d>0?d+"d "+h+"h":(h>0?h+"h "+m+"m":m+"m"));
}
}
return '<div class="ccq-win"><div class="ccq-win-h"><span>'+escH(label)+
(reset?' <span class="ccq-reset">↻'+escH(reset)+'</span>':'')+
'</span><span class="ccq-win-n">'+p+'%</span></div>'+
'<div class="ccq-bar"><div class="'+cls+'" style="width:'+p+'%"></div></div>'+
'<div class="ccq-win-d">'+used.toLocaleString()+' / '+cap.toLocaleString()+'</div></div>';
}
function card(r){
var st = stateOf(r);
var h = '<div class="ccq-acc'+((r.auth_failed||(r.failures&&r.failures.length===4))?" err":"")+'">'+
'<div class="ccq-acc-h"><span class="ccq-acc-n">'+escH(r.name||"")+'</span>'+
(r.email?'<span class="ccq-mail">'+escH(r.email)+'</span>':'')+
'<span class="grow"></span>'+
'<span class="ccq-pill '+st.cls+'">'+escH(st.txt)+'</span></div>'+
'<div class="ccq-bal"><span class="ccq-bal-n">'+usd(r.total)+
'</span><span class="ccq-bal-l">月度余额'+
(Number(r.purchased)>0?'(含充值 $'+Number(r.purchased).toFixed(2)+')':'')+
'</span></div>'+
'<div class="ccq-grid">'+
win("5 小时", r.fiveHour)+win("每周", r.weekly)+
'</div>';
var meta=[];
if(r.requests) meta.push(r.requests.toLocaleString()+" 次请求");
if(r.spent) meta.push("累计 "+usd(r.spent));
if(r.tokens) meta.push((r.tokens/1e9).toFixed(2)+"B tokens");
if(r.success_rate) meta.push("成功率 "+r.success_rate.toFixed(2)+"%");
if(meta.length) h+='<div class="ccq-meta">'+meta.map(escH).join(" · ")+'</div>';
if(r.failures&&r.failures.length){
h+='<div class="ccq-fail">部分接口失败:'+r.failures.map(escH).join(";")+'</div>';
}
return h+"</div>";
}
function render(reports){
lastReports = reports||[];
var host=document.getElementById("ccq-list");
if(!host) return;
if(!lastReports.length){host.innerHTML='<div class="ccq-empty">没有可用的 Command Code 源</div>';return;}
host.innerHTML=lastReports.map(card).join("");
var sub=document.getElementById("ccq-sub");
if(sub) sub.textContent = lastReports.length+" 个账户 · "+
lastReports.filter(function(r){return r.auth_failed;}).length+" 个认证失败";
}
async function refresh(){
var srcs;
try{
var r=await fetch("/api/sources",{credentials:"same-origin"});
srcs=(await r.json()).sources||[];
}catch(e){ render([]); return; }
// Every source on the Command Code host is one account, keyed by its own
// API key — two keys on that host are two seats, and both must be listed.
var accts=srcs.filter(function(s){
try{ return new URL(s.base_url).hostname===HOST; }catch(e){ return false; }
});
if(!accts.length){ render([]); return; }
var btn=document.getElementById("ccq-refresh");
if(btn) btn.disabled=true;
var auto=document.getElementById("ccq-auto")||document.querySelector(".ccq-auto");
if(auto) auto.textContent="拉取中…";
var out=[];
var errs=0;
// One account at a time: the endpoints are independent, and a sequential
// loop means a hung vendor endpoint delays only its own row instead of
// blocking the whole page behind one promise.
for (var i=0;i<accts.length;i++){
var a=accts[i];
try{
var rep = await pluginAPI.call("ccquota","collect_account",
{name:a.name,label:a.name,key:a.api_key});
out.push(rep||{name:a.name,failures:["no result"]});
}catch(e){
errs++;
out.push({name:a.name,failures:[String(e.message||e)],auth_failed:true});
}
}
render(out);
if(btn) btn.disabled=false;
if(auto) auto.textContent="更新于 "+new Date().toLocaleTimeString();
try{ await pluginAPI.postState("ccquota",{reports:out,updated_at:Date.now()}); }catch(e){}
}
document.addEventListener("DOMContentLoaded",function(){
var b=document.getElementById("ccq-refresh");
if(b) b.addEventListener("click",refresh);
refresh();
});
window.__ccqRender = render;
window.__ccqRefresh = refresh;
})();
</script>
]==],
},
}
-- exports is what makes collect_account reachable from the plugin's own page.
-- Without this list the call endpoint refuses it: the endpoint is an HTTP
-- surface into the Lua state, so "which functions may it reach" has to be the
-- plugin's explicit answer rather than a default.
PLUGIN.exports = { "collect_account" }
-- collect_account gathers one account's report. It returns the report TABLE (not
-- a string) so the page receives structured numbers and booleans instead of
-- having to parse anything back out of a formatted blob.
PLUGIN.collect_account = function(acct)
if type(acct) ~= "table" then return nil, "expected account table" end
if acct.key == nil or acct.key == "" then
return { name = tostring(acct.name or ""), failures = { "missing api key" },
auth_failed = true }
end
local rep = collectAccount({
name = acct.name, label = acct.label, key = acct.key,
})
return rep, nil
end
return PLUGIN

View File

@ -484,6 +484,11 @@ 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",