chore: initialize standalone business plugin repository
Business Plugins CI / check (plugin-admin) (push) Successful in 3m13s
Business Plugins CI / check (subscription-admin) (push) Successful in 1m41s

This commit is contained in:
Qiufeng
2026-08-27 23:36:08 +08:00
commit 5feae3ad41
59 changed files with 8950 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
name: Business Plugins CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
strategy:
matrix:
plugin:
- plugin-admin
- subscription-admin
defaults:
run:
working-directory: plugins/${{ matrix.plugin }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23'
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: go test -race ./... -count=1
- run: go vet ./...
- run: node --check ui/app.js
- run: sh -n build.sh
- if: matrix.plugin == 'subscription-admin'
run: |
sh -n package.sh
go run ./tools/manifestcheck
+24
View File
@@ -0,0 +1,24 @@
# Secrets and local configuration
.env
.env.*
!.env.example
# Build output and runtime data
bin/
dist/
build/
data/
.cache/
*.log
*.test
coverage.out
# OS/editor files
.DS_Store
.idea/
.vscode/
*.swp
# Browser test output
playwright-report/
test-results/
+165
View File
@@ -0,0 +1,165 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.
+24
View File
@@ -0,0 +1,24 @@
.PHONY: test vet check build package
test:
(cd plugins/plugin-admin && go test -race ./... -count=1)
(cd plugins/subscription-admin && go test -race ./... -count=1)
vet:
(cd plugins/plugin-admin && go vet ./...)
(cd plugins/subscription-admin && go vet ./...)
check: test vet
node --check plugins/plugin-admin/ui/app.js
node --check plugins/subscription-admin/ui/app.js
sh -n plugins/plugin-admin/build.sh plugins/plugin-admin/test/run-browser-check.sh
sh -n plugins/subscription-admin/build.sh plugins/subscription-admin/package.sh plugins/subscription-admin/test/run-browser-check.sh
(cd plugins/subscription-admin && go run ./tools/manifestcheck)
git diff --check
build:
(cd plugins/plugin-admin && ./build.sh)
(cd plugins/subscription-admin && ./build.sh)
package:
(cd plugins/subscription-admin && ./package.sh)
+44
View File
@@ -0,0 +1,44 @@
# Sub2API Business Plugins
独立的 Sub2API 业务插件仓库。插件作为独立服务运行,通过 Sub2API 的公开
HTTP API、管理员鉴权和 `custom_menu_items` 接入 Core;插件不导入 Core
源码、不连接 Core 数据库,也不修改 Core 的 Go、Vue、迁移或现有
`.s2plugin` transport ABI。
## 目录
- `plugins/plugin-admin`:通用插件管理控制面,负责清单、签名、安装、启用、
停用、升级、回滚、卸载、配置、健康检查、审计和菜单注入。
- `plugins/subscription-admin`:可选的订阅管理业务插件。它不是插件管理
控制面,只有安装、启用并应用菜单后才会出现。
- `docs/`:插件框架、清单、边界、架构、开发和验收契约。
两个插件都是独立 Go module,可以分别构建和发布。生产环境应使用独立的
低权限服务账号、HTTPS 反向代理、签名包和稳定的 Core API 兼容基线。
## 快速验证
```sh
(cd plugins/plugin-admin && go test -race ./... && go vet ./...)
(cd plugins/subscription-admin && go test -race ./... && go vet ./...)
(cd plugins/subscription-admin && go run ./tools/manifestcheck)
```
生成订阅插件包:
```sh
(cd plugins/subscription-admin && ./package.sh)
```
构建脚本只生成本地二进制或 `dist/` 包,不将它们提交到仓库。
## 接入顺序
1. 启动 `plugin-admin` 和需要的业务插件,各自监听独立端口。
2. 使用 Core 管理员账号登录插件服务;普通账号被拒绝。
3. 在 `plugin-admin` 上传并校验业务插件包,配置 loopback `service_url`。
4. 启用插件并完成健康检查。
5. 预览、确认并应用插件声明的管理员菜单。
Core 继续作为用户、余额、订阅、计费和用量账本的权威来源。插件浏览器端
不持有 Core JWT、Admin Key 或其他服务密钥。
+61
View File
@@ -0,0 +1,61 @@
# Business Plugin V1 验收矩阵
| ID | 类别 | 验收项 | 预期证据 | 状态 |
|---|---|---|---|---|
| AUTH-01 | 鉴权 | Core 管理员登录控制面 | `plugins/plugin-admin/main_test.go:TestAdminLoginDoesNotExposeCoreTokens`;本地浏览器登录 | passed |
| AUTH-02 | 鉴权 | Core 2FA 登录 | challenge 一次性消费,成功创建会话 | passed |
| AUTH-03 | 鉴权 | 普通用户登录和 API | `plugins/plugin-admin/main_test.go:TestOrdinaryCoreUserIsRejected` | passed |
| AUTH-04 | 会话 | 过期、撤销、登出和刷新 | `plugins/plugin-admin/main_test.go:TestRefreshRevalidatesAdminRole` | passed |
| AUTH-05 | CSRF | 所有写请求 | `plugins/plugin-admin/main_test.go:TestMutationRequiresCSRFAndIdempotency` | passed |
| SEC-01 | 秘密 | 浏览器、URL、HTML、JS、LocalStorage、下载、日志 | 登录/配置测试断言 token 和 secret 不回显;浏览器 DOM 未出现 Core token | passed |
| SEC-02 | 出站 | Core URL、重定向、代理和 SSRF | `TestHealthProbeRejectsRedirectAndRequiresReadiness`;loopback URL 校验 | passed |
| SEC-03 | 脱敏 | Core 响应和错误 | token/password/secret/cookie 不出现在响应和日志 | passed |
| MAN-01 | 清单 | 未知字段、尾随 JSON、路径跳转 | `plugins/plugin-admin/internal/manifest/manifest_test.go`;包上传 smoke | passed |
| MAN-02 | 签名 | Ed25519、key ID、哈希 | `manifest_test.go:TestSignatureAndKeyID`;生产不受信发布者路径 | passed |
| MAN-03 | 兼容 | Core baseline、tested versions、capability | `manifest_test.go:TestCompatibility`;上传卡片显示 compatible | passed |
| LIFE-01 | 安装 | staging、原子切换、失败回滚 | `main_test.go:TestPackageInspectionAndAtomicInstall`;真实上传后 active revision 可见 | passed |
| LIFE-02 | 启停 | enable/disable/drain | 停用路径有 SIGTERM + drain 超时逻辑;进程组清理和外部服务不误停 | passed |
| LIFE-03 | 升级 | 新 revision 健康后切换 | 失败升级保留 active;模式切换不继承端点;成功提交后才切换进程 | passed |
| LIFE-04 | 卸载 | 先停用再卸载 | 先提交注册表删除,成功后再清理插件资源,不删除 Core 数据 | passed |
| MENU-01 | 菜单 | preview/apply 自有 `custom_menu_items` | `main_test.go:TestMenuPreviewAndApplyPreserveOtherMenuItems` | passed |
| MENU-02 | 嵌入 | iframe 和新窗口 | 本地控制面三视口登录/刷新;插件提供独立登录和新窗口入口 | passed |
| API-01 | allowlist | 未声明路径和查询参数 | `allowedCorePath` 单元路径门禁;业务插件自身 allowlist 测试 | passed |
| API-02 | Core 错误 | 401/403/409/429/5xx | 失败关闭、刷新一次、错误脱敏和请求 ID 传播 | passed |
| UI-01 | 响应式 | 425px、900px、1440px | 本地 Browser 验收:三个视口 `scrollWidth == innerWidth`,插件卡片可见 | passed |
| OPS-01 | 健康 | healthz/readyz、版本和 request ID | 控制面 HTTP smoke + 插件清单检查;健康/就绪响应含版本 | passed |
| OPS-02 | 权限 | 低权限账号、secret 文件和网络 | systemd 示例使用低权限账号、禁止提权、限制读写目录 | passed |
| REG-01 | 重建 | 清空 projection/cache | 控制面注册表可从磁盘恢复;健康 command/external 插件启动时重探 | passed |
| REG-02 | 兼容 | Core 升级/降级和旧插件 | 未测试版本保持 disabled,启动恢复再次检查 baseline | passed |
## 命令门禁
控制面和每个业务插件至少执行:
```sh
go test ./... -count=1
go vet ./...
node --check <all-ui-scripts>
production build
manifest verification
git diff --check
```
浏览器验收必须保存三种视口截图、网络敏感字段扫描结果、iframe/新窗口登录结果、刷新恢复、停用、升级和回滚证据。Mock Core 只能证明契约;具备测试环境时必须追加真实 Core 登录、2FA、权限、分页和错误联调。
## 本轮证据
- `plugins/plugin-admin` 和 `plugins/subscription-admin`:`go test -race ./...`、`go vet ./...`、`node --check ui/app.js` 均通过。
- `plugins/subscription-admin/package.sh` 生成的 `.s2plugin` 已通过 `unzip -t`,并通过控制面真实上传接口进入 `disabled` 状态。
- 本地浏览器登录后,控制面首页显示“已登记插件”与订阅插件卡片;425、900、1440 视口均无横向溢出。
- 仍需部署环境追加:真实生产签名密钥、跨实例共享会话、真实 Core iframe 刷新和跨节点升级演练;这些属于部署级验证,不改变本地 V1 控制面契约。
截图证据保存在 `.playwright-cli/plugin-admin-v1-final/`、`.playwright-cli/plugin-admin-v1-sensitive/`、`.playwright-cli/subscription-admin-v1-final/` 和 `.playwright-cli/subscription-admin-v1-sensitive/`,每组包含 425px、900px、1440px 三种视口。
## 本地生命周期硬化证据
- `plugins/plugin-admin/main_test.go:TestRecoverExternalPluginAfterRestart` 验证外部服务重启后重新探测并保持健康。
- `plugins/plugin-admin/main_test.go:TestRecoverCommandPluginAfterRestart` 验证托管 command 插件重启后重新分配端口、启动进程组并探测健康/就绪。
- `plugins/plugin-admin/main_test.go:TestPluginLockSerializesLifecycleMutations` 验证同一插件生命周期互斥。
- `plugins/plugin-admin/main_test.go:TestIdempotencyKeyRejectsDifferentOperationHash` 和 `TestIdempotencyKeyReplaysSameBodyAndRetainsFailedOperation` 验证服务端请求体指纹、失败终态保留与冲突拒绝。
- `plugins/plugin-admin/main_test.go:TestUpgradeDoesNotCarryEndpointAcrossServiceModes` 验证 command/external 模式不继承错误端点。
- `go test -race ./... -count=1`、`go vet ./...`、全部 UI/测试脚本 `node --check`、包构建、`manifestcheck`、`unzip -t` 和 `git diff --check` 已通过。
+73
View File
@@ -0,0 +1,73 @@
# Business Plugin V1 架构与流程图
## 拓扑
```mermaid
flowchart TD
B[管理员浏览器] --> C[Core custom_menu_items]
C --> I[Core /custom/:id sandbox iframe]
I --> P[反向代理 /extensions/:plugin-id/]
P --> M[Plugin Control Plane]
M --> S[独立业务插件服务]
S --> A[Typed Core API Adapter]
A --> K[Core Auth/Admin API]
K --> D[Core 权威账本与审计]
M --> R[Plugin Registry / Revisions / Audit]
M --> H[Supervisor + healthz/readyz]
```
## 登录时序
```mermaid
sequenceDiagram
participant B as Browser
participant P as Plugin BFF
participant C as Core
B->>P: POST /login
P->>C: POST /api/v1/auth/login
C-->>P: access/refresh 或 2FA challenge
P->>C: POST /api/v1/auth/login/2fa (按需)
P->>C: GET /api/v1/auth/me
C-->>P: role=admin
P-->>B: HttpOnly plugin session + CSRF token
B->>P: GET /api/plugins
P->>C: Bearer Core JWT + X-Request-ID
P-->>B: 脱敏业务数据
```
## 生命周期
```mermaid
stateDiagram-v2
[*] --> discovered
discovered --> verified: manifest/signature/hash pass
verified --> installed: atomic staging
installed --> disabled
disabled --> starting: enable
starting --> healthy: health + contract pass
starting --> error: timeout/failure
healthy --> draining: disable/upgrade
draining --> disabled
healthy --> upgrading: new revision
upgrading --> healthy: new revision pass
upgrading --> rollback_pending: new revision fail
rollback_pending --> healthy: old revision restored
verified --> incompatible: version/capability mismatch
```
## 数据边界
```text
Core authoritative data
user / admin role / balance / plan / subscription / order / usage / billing / audit
▲
│ typed HTTPS API, server-side token only
▼
Plugin projection and UI
cache / filters / display index / plugin audit reference
▲
│ controlled by
▼
Plugin control plane
manifest / signature / revision / health / config / menu ownership / session
```
+38
View File
@@ -0,0 +1,38 @@
# Business Plugin V1 边界
## Core 负责
- 用户身份、密码、2FA、TokenVersion、会话撤销和管理员角色;
- 余额、余额流水、套餐、订阅、订单、用量、配额、计费和退款;
- 网关鉴权、请求路由、原子预留/结算、幂等和核心审计;
- Core 数据库 schema、迁移和事务;
- 现有 `.s2plugin` transport ABI 及 OpenAI OAuth 生命周期;
- `custom_menu_items` 的最终校验和页面可见性。
## 控制面负责
- 业务插件清单、签名、公钥、包哈希和 Core 兼容性;
- 插件安装目录、revision、active 指针和旧版本保留;
- 独立服务进程的启停、drain、健康、升级和回滚;
- 插件配置加密、会话、CSRF、权限和控制面审计;
- 由插件声明的管理员菜单 preview/apply;
- 只允许服务端调用的 Core API Adapter。
## 业务插件负责
- 自己的业务 UI、HTTP API、BFF 和领域逻辑;
- 自己声明的 Core API allowlist、字段映射、分页和错误展示;
- 可删除、可重建的只读 projection;
- 自己的健康端点、版本报告和配置校验;
- 不影响 Core 的独立发布和回滚。
## 明确禁止
- 插件前端持有 Core JWT、refresh token、Admin Key 或服务 secret;
- 插件直连 Core PostgreSQL、Redis、宿主文件目录或内部 Go 包;
- 插件自行判断余额、权限、配额、计费或网关放行;
- 用多个 Admin API 拼接一个本应由 Core 原子事务完成的购买/扣款/续费;
- 把业务插件声明成 `openai.oauth.outbound_transport.v1`;
- 通过 iframe URL、LocalStorage、查询参数或日志传递认证凭据;
- 由插件卸载流程删除 Core 账本、订阅或审计数据;
- 在 V1 承诺无感 SSO、远程任意下载、OS 沙箱或跨插件 RPC。
+46
View File
@@ -0,0 +1,46 @@
# Business Plugin V1 开发指南
## 目录模板
```text
my-business-plugin/
├── cmd/plugin/main.go
├── internal/auth/
├── internal/coreadapter/
├── internal/manifest/
├── internal/domain/
├── ui/index.html
├── ui/assets/
├── business-plugin-manifest.v1.json
├── deploy/
├── test/contract/
└── README.md
```
入口服务只负责加载配置、启动 HTTP server 和健康端点。鉴权、Core API、领域逻辑、manifest 校验和 UI 不要全部写在入口文件。
## 开发顺序
1. 先复制 manifest 模板,声明唯一 `plugin_id`、capability、Core baseline、服务健康路径、UI 入口和精确 allowlist。
2. 实现 Core Adapter 的 typed methods;禁止接受浏览器传入的任意 URL。
3. 实现管理员登录、2FA、HttpOnly session、CSRF、refresh budget、登出撤销和日志脱敏。
4. 实现领域 API 和 UI;浏览器只调用插件 BFF,不读取 Core token 或宿主存储。
5. 提供 `healthz`、`readyz`、版本和 request ID;失败时保持 fail-closed。
6. 由控制面执行签名、哈希、兼容性、启停、升级和回滚;插件服务不自行覆盖其他插件资源。
7. 用 deployment-managed `custom_menu_items` preview/apply 注入管理员入口。
## UI 约束
业务插件页面遵循 `docs/FRONTEND_DESIGN_GUIDELINES.md` 和 `UI.MD` 的控制高度、无衬线数字、响应式表格、无卡片嵌套和安全错误展示要求。iframe 与新窗口都必须可用;独立 origin 按部署配置 Cookie `SameSite=None; Secure`,同源反代优先使用 `Lax`。
## 测试最小集
- manifest 未知字段、尾随数据、路径穿越、签名和哈希;
- 管理员、普通用户、2FA、撤销、刷新、登出和 CSRF;
- Core API allowlist、查询过滤、401 单次 refresh、429/5xx 策略;
- secret 不出浏览器/日志/错误;
- health/readiness、启停、drain、升级、回滚和卸载;
- iframe、新窗口、刷新恢复、425/900/1440px 截图和横向溢出;
- 清空插件投影后从 Core 重建。
订阅插件的套餐、余额、订阅实例、同档多实例、单独续费和余额事务规则只写在订阅领域文档,不复制到本通用指南。
+212
View File
@@ -0,0 +1,212 @@
# Sub2API 独立业务插件框架 V1
状态:Accepted Contract / V1 参考实现已完成本地验收
本文定义与 Sub2API Core 解耦的通用业务插件框架。业务插件是独立服务、独立端口、独立版本和独立 UI;它可以通过现有管理员自定义菜单嵌入 Core,也可以在新窗口运行。订阅管理只是一个可选业务插件,不能成为框架后台、Core 热路径或插件生命周期的固定组成部分。
## 1. 目标
V1 需要提供一个独立的插件控制面,负责:
- 展示已登记、已安装和可升级的业务插件;
- 校验包清单、发布者签名、文件哈希和 Core 兼容范围;
- 安装、启用、停用、健康检查、升级、回滚、卸载和配置插件;
- 保存插件版本、服务地址、运行状态、菜单声明和操作审计;
- 通过 Core 现有管理员鉴权复用操作者身份;
- 将已启用插件的管理员菜单注入 `custom_menu_items`;
- 让每个业务插件仅通过自己的 BFF 调用 Core 明确允许的 API。
V1 不改变 Core Go/Vue、数据库迁移、现有鉴权、前端路由或 `.s2plugin` transport ABI。控制面自己的注册表、安装目录、进程和配置存储属于独立服务;它不连接 Core PostgreSQL、Redis 或宿主业务表。
## 2. 与现有插件的关系
| 类型 | 现有 `.s2plugin` transport | Business Plugin V1 |
|---|---|---|
| 运行方式 | Core 子进程 + gRPC | 独立服务 + HTTP/BFF |
| 能力 | `openai.oauth.outbound_transport.v1` | 由清单声明的业务能力 |
| 生命周期 | Core `PluginManager` | 独立 Plugin Control Plane |
| UI | 配置 iframe + UI Bridge | 业务后台/用户工具页面 |
| 数据边界 | Core 负责账号转发与计费 | Core 负责权威业务数据,插件只读/投影 |
| 菜单 | Core 固定插件管理页 | `custom_menu_items` 管理员入口 |
现有 `.s2plugin` 的 `TransportPlugin`、清单 schema 和 capability 校验保持不变。业务插件不能仅通过声明一个新 capability 假装获得 HTTP 路由、数据库、支付或订阅权限。
## 3. V1 拓扑
```text
管理员浏览器
│ Core 登录后的管理员菜单
▼
Core /custom/<menu-id>
│ sandbox iframe 或新窗口
▼
反向代理 /extensions/<plugin-id>/
▼
Business Plugin Control Plane
├─ 插件目录、签名、版本和状态
├─ 独立进程 supervisor
├─ 管理员会话和审计
└─ 插件 BFF / Core API Adapter
│ 仅发送服务端 Bearer Core JWT + X-Request-ID
▼
Sub2API Core 现有鉴权、Admin API 和领域账本
```
控制面可以托管插件进程,也可以把进程交给 systemd、容器或 Kubernetes;无论采用哪种 supervisor,控制面都必须能读取健康状态并保留旧版本回滚点。停用时先撤销自有菜单,再将托管进程置于有界终止流程(SIGTERM,最多等待 10 秒后强制结束);反向代理负责停止新请求,外部服务只做健康探测并由其部署者负责停机。
## 4. 角色和权限
- `plugin_admin`:安装、启停、升级、回滚、卸载、配置和菜单注入。
- `plugin_operator`:查看状态、日志摘要和健康诊断,不改变包或凭据。
- `plugin_readonly`:只读查看已登记插件。
V1 的控制面只允许 Core `role=admin` 登录。插件不创建第二套 Core 用户表;插件会话只保存 `plugin_id`、`admin_user_id`、角色、会话版本和过期时间。UI 隐藏按钮不等于授权,控制面和 Core API 均须重新校验权限。
## 5. 管理面契约
控制面内部 API 的最小集合:
```text
GET /healthz
POST /login
POST /login/2fa
POST /logout
GET /api/me
GET /api/plugins
GET /api/plugins/{id}
POST /api/plugins/{id}/install
POST /api/plugins/{id}/enable
POST /api/plugins/{id}/disable
POST /api/plugins/{id}/upgrade
POST /api/plugins/{id}/rollback
POST /api/plugins/{id}/uninstall
GET /api/plugins/{id}/config
PUT /api/plugins/{id}/config
GET /api/audit
POST /api/menu-items/preview
POST /api/menu-items/apply
```
所有写请求要求 CSRF、操作者会话和幂等键。幂等指纹由服务端根据方法、路径、查询和请求体计算,失败操作也保留终态,重复请求不会重新执行。安装、启用、升级、回滚和卸载必须返回 operation ID,并可通过插件详情查询最终状态。控制面不把上述路径注册到 Core,也不声称 Core 已存在 `/api/v1/plugin-host/*`。
## 6. 插件生命周期
```text
discovered -> verified -> installed -> disabled -> starting -> healthy
│ │
│ └── error
└── incompatible
healthy -> draining -> disabled
healthy -> upgrading -> healthy
healthy -> rollback_pending -> healthy
```
- `discovered`:目录或清单被发现,尚未验签。
- `verified`:清单、签名、哈希和兼容性通过。
- `installed`:版本包已安全写入 staging 并原子切换。
- `disabled`:已安装但不接收业务请求,菜单默认隐藏。
- `starting`:进程启动、端口和健康检查进行中。
- `healthy`:健康端点、就绪端点和(若响应提供)版本检查通过。
- `draining`:菜单已撤销,托管进程正在执行有界终止;在途请求由插件进程或反向代理按部署约定处理。
- `upgrading` / `rollback_pending`:新旧版本并存检查,只有健康版本成为 active。
- `error`:进程、健康、配置或 Core 合约失败,默认 fail-closed。
- `incompatible`:当前 Core baseline 或协议不满足清单要求。
已启用版本不能被原地覆盖。升级先安装新 revision、执行健康和契约检查,再原子更新 active revision;至少保留一个可回滚 revision。卸载只能作用于停用插件,不删除 Core 数据。控制面重启时重新校验 `healthy`/`enabled` 插件:托管 command 重新启动 active revision,外部服务重新探测 `service_url`;探测失败统一标记 `error` 并清空不可用端点。`backend.command` 与外部 `service_url` 是互斥运行模式,升级不会继承不属于新模式的旧端点。
## 7. 安装和包安全
安装包必须包含清单和 UI;若插件由控制面托管进程,再额外包含清单声明的服务文件:
```text
manifest.json
signature.json
ui/index.html
ui/assets/...
```
外部服务模式允许省略 `service/` 文件,但清单不得声明
`backend.command`,且启用前必须在控制面配置经过校验的 `service_url`。托管进程模式
必须声明 `backend.command`,并将该路径及二进制哈希放入包内。
控制面必须拒绝绝对路径、父目录跳转、重复条目、符号链接、未声明文件、超大文件和不匹配哈希。签名使用 Ed25519,签名覆盖 `manifest.json` 原始字节;清单中的 SHA-256 覆盖服务文件和 UI。发布者私钥不进入仓库、包、服务器或日志。
安装使用临时目录和原子 rename;失败不得破坏 active revision。包来源默认是管理员上传或受控本地目录,V1 不自动从互联网下载任意包。
## 8. Core API Adapter
每个插件清单声明精确的 `method + path` allowlist。控制面或插件 BFF 只能调用这些路径:
```http
POST /api/v1/auth/login
POST /api/v1/auth/login/2fa
POST /api/v1/auth/refresh
POST /api/v1/auth/logout
GET /api/v1/auth/me
GET /api/v1/settings/public
GET /api/v1/admin/<declared-read-endpoint>
```
`<declared-read-endpoint>` 只是文档占位符,实际清单必须列出具体路径、查询参数、分页上限和响应字段。浏览器永远不接触 Core JWT、refresh token 或 Admin Key;所有出站请求由服务端添加 `Authorization: Bearer ...` 和统一 `X-Request-ID`。
Core 返回 `401` 时,一次用户请求最多 refresh 一次;并发 refresh 必须按插件会话串行化。`403` 不重试,`429` 按 `Retry-After` 有上限退避,`5xx` 只重试明确幂等操作。响应按 DTO 或敏感键规则脱敏,不能把 token、密码、Cookie、secret 或完整凭据透传给 UI。
## 9. 会话和嵌入
插件登录调用 Core 现有 `/auth/login`、按需 `/auth/login/2fa`,再调用 `/auth/me` 校验管理员角色。Core token 只存插件服务端会话,浏览器只持有 HttpOnly、Secure、SameSite Cookie 和插件 CSRF token。
Core 的自定义页面当前使用 sandbox iframe,且不会自动继承 Core `localStorage` 登录态。因此 V1 必须同时提供新窗口入口;iframe 首屏显示插件登录页是已知行为。真正无感 SSO 需要 V1.1 的一次性 code/state 或受控 `postMessage` 交接,不得把 JWT 放在 URL。
菜单注入使用 Core 现有 `custom_menu_items`:
```json
{
"id": "DOMAIN_PLUGIN_ID",
"label": "DOMAIN_PLUGIN_LABEL",
"url": "https://CORE_ORIGIN/extensions/DOMAIN_PLUGIN_ID/",
"visibility": "admin",
"sort_order": 200
}
```
控制面只能创建和更新自己声明的 ID,保留其他管理员菜单;应用前展示 diff 并记录审计。停用或卸载时先隐藏/移除自己拥有的菜单项,再停止进程。
## 10. 数据归属
Core 始终是用户身份、余额、订阅、订单、用量、权限、计费和审计的权威来源。控制面只保存插件包、revision、状态、服务配置、菜单声明、会话和操作索引。业务插件可以保存可删除、可重建的 projection,但 projection 不得作为 Core 网关放行、扣费或权限判断依据。
## 11. 安全和可运维性
- 监听地址默认 loopback,生产通过 HTTPS 反向代理暴露。
- 插件进程使用独立低权限账号/容器、最小文件权限和出站网络 allowlist。
- 配置 secret 使用服务端加密存储或 secret manager,UI 只显示 configured/rotatable,不回显原值。
- 日志只记录插件 ID、revision、操作者、operation ID、资源 ID、request ID、状态和耗时。
- 进程崩溃、健康失败、Core 不兼容或签名错误均 fail-closed,不静默切换到未验证版本。
- 停用、升级、回滚和卸载必须可重复执行;Core 数据不随插件卸载删除。
## 12. 分阶段实施
### Phase 0:契约冻结
冻结 manifest、签名、revision、控制面 API、状态机、反代路径、菜单字段、会话属性和 Core API allowlist。
### Phase 1:通用控制面
实现管理员登录、插件目录、包校验、安装/启停、健康检查、配置加密、审计、菜单 preview/apply、systemd/container 适配和回滚骨架。
### Phase 2:业务插件适配
提供 `DOMAIN_PLUGIN_ID` 级别的 SDK/模板和契约测试。订阅管理作为首个独立业务插件接入,只实现自身领域页面和 Core 只读 API,不改变控制面。
### Phase 3:生产增强
评审多实例共享状态、短时 Plugin Access Token、无感 SSO、Core Host Adapter、远程 registry、灰度升级和跨节点 drain;这些能力需要单独版本和安全评审。
## 13. 非目标
- 不把业务插件注册为现有 OpenAI OAuth transport。
- 不把任意业务路由、数据库、支付、余额扣款或每请求计费放进控制面。
- 不创建 Core 用户表、插件版账本或绕过 Core 鉴权。
- 不通过 URL、iframe、LocalStorage、HTML、日志或下载文件传递凭据。
- 不承诺独立进程是操作系统级沙箱。
+78
View File
@@ -0,0 +1,78 @@
# Business Plugin Manifest V1
状态:Accepted Contract
业务插件清单由独立控制面读取和校验,Core 当前不会读取它。清单只声明插件身份、版本、能力、服务、UI、兼容性、发布者和 Core API 权限,不授予数据库、路由或 secret 权限。
## 1. 最小清单
```json
{
"schema_version": 1,
"plugin_id": "DOMAIN_PLUGIN_ID",
"name": "DOMAIN_PLUGIN_NAME",
"version": "1.0.0",
"core_api_baseline": "sub2api-0.1.183",
"tested_core_versions": ["0.1.183"],
"capabilities": ["DOMAIN_CAPABILITY_V1"],
"backend": {
"listen_env": "PLUGIN_PORT",
"health_path": "/healthz",
"readiness_path": "/readyz"
},
"ui": {
"entrypoint": "/admin/",
"menu": {
"id": "DOMAIN_PLUGIN_ID",
"label": "DOMAIN_PLUGIN_LABEL",
"visibility": "admin",
"sort_order": 200
}
},
"publisher": {
"key_id": "PUBLISHER_KEY_ID"
},
"core_api_allowlist": [
"POST /api/v1/auth/login",
"POST /api/v1/auth/login/2fa",
"POST /api/v1/auth/refresh",
"POST /api/v1/auth/logout",
"GET /api/v1/auth/me",
"GET /api/v1/settings/public",
"GET /api/v1/admin/DOMAIN_READ_ENDPOINT"
]
}
```
## 2. 字段规则
- `plugin_id`:小写、稳定、全局唯一;不得包含 `/`、空格或路径跳转。
- `version`:插件自身 SemVer,与 Core 版本和发行标签分离。
- `core_api_baseline`:插件编译和契约测试所针对的 Core 版本。
- `tested_core_versions`:只填写真实执行过契约测试的版本。
- `capabilities`:一个或多个业务能力 ID;控制面不为未实现的能力自动创建路由。
- `backend.health_path`、`readiness_path`:只能是插件服务根下的绝对 HTTP 路径(例如 `/healthz`、`/readyz`),不得包含主机、查询、片段或路径跳转。
- `ui.entrypoint`:只能指向包内 UI 资源。
- `ui.menu`:管理员菜单声明;控制面必须校验 ID 与插件 ID 绑定,`visibility` 只能是 `admin`。
- `publisher.key_id`:必须匹配受信任发布者配置。
- `core_api_allowlist`:方法和路径必须逐项列出,不允许通配符、任意 URL 或未声明查询参数。
## 3. 签名和哈希
```json
{
"algorithm": "ed25519",
"key_id": "PUBLISHER_KEY_ID",
"signature": "BASE64_SIGNATURE"
}
```
签名覆盖 `manifest.json` 原始字节。包内每个服务文件和 UI 文件的 SHA-256 由清单声明。验证器必须拒绝尾随 JSON、重复字段、未知字段、无效 Base64、大小写错误的哈希和不匹配的 key ID。
## 4. 兼容性门禁
1. 清单 schema 版本不匹配:`incompatible`。
2. Core 不在 `requires` 范围:`incompatible`。
3. Core 在范围内但未列入 `tested_core_versions`:安装后保持 disabled,要求管理员确认。
4. 业务能力、服务协议或 UI 版本不支持:禁止启用。
5. 升级只产生新 revision;旧 active revision 在新版本健康和契约测试通过前保持可用。
+73
View File
@@ -0,0 +1,73 @@
# Business Plugin V1 实现边界与验收
状态:控制面入口已实现;业务插件按包独立接入
## 1. 唯一入口
`plugins/plugin-admin` 是通用 Business Plugin V1 控制面。它的首页和默认菜单只能表达“插件管理”,不表达任何具体业务域,也不默认打开订阅、支付或其他业务页面。
控制面负责:
- 展示已登记、已安装和可升级的插件包;
- 校验清单、签名、文件哈希和 Core 兼容性;
- 安装、启用、停用、升级、回滚、卸载和配置;
- 显示运行状态、健康检查结果和操作审计;
- 对插件声明的管理员菜单执行预览和应用。
控制面不负责:
- 订阅商品、余额、订单、支付、配额或请求计费;
- 任何业务插件自己的页面和领域数据;
- Core 数据库、Core 用户表或 `.s2plugin` transport ABI。
## 2. 安装后注入流程
```text
管理员登录 plugin-admin
|
v
插件目录 -> 上传/选择业务插件包
|
v
清单 + 签名 + 哈希 + Core 兼容性校验
|
v
安装到独立 revision,初始为 disabled
|
v
启用 -> 启动独立服务端口 -> healthz/readyz/版本检查
|
v
菜单预览 -> 管理员确认 -> 应用 custom_menu_items
|
v
Core 管理员菜单出现该插件自己的入口
```
每个插件使用自己的 `plugin_id`、版本、端口、服务进程、UI 和菜单 ID。停用或卸载插件时,只移除该插件自己声明的菜单项,不触碰其他插件或 Core 数据。
## 3. 订阅插件的位置
`plugins/subscription-admin` 是第一个业务插件样例,而不是控制面。它只有在管理员通过 `plugin-admin` 安装、启用并应用菜单后才出现。卸载订阅插件只删除插件资源和自身投影,不删除 Core 的套餐、订阅、余额、订单、用量或审计。
订阅插件的 V1 只读取 Core 现有管理员 API;余额购买、续费、撤销和退款写操作必须等待版本化 Core 原子接口,不得把多个 Admin API 拼成一次购买。
## 4. 登录与安全边界
- 控制面和业务插件均复用 Core 管理员登录及 2FA,不创建插件用户表;普通 Core 用户统一拒绝。
- 浏览器只持有插件自己的 HttpOnly 会话和 CSRF token;Core access/refresh token、Admin Key 只存在插件服务端。
- 插件后端通过精确 allowlist 调用 Core API,不提供任意 URL 代理,不连接 Core PostgreSQL/Redis。
- 插件包必须签名;未签名包仅限开发环境 loopback 测试。
## 5. 功能验收最小条件
1. 打开 `plugin-admin` 首屏看到插件目录,而不是订阅页面。
2. 未安装订阅包时,目录可以为空,左侧不会出现订阅菜单。
3. 安装并启用一个包后,详情页显示该包的状态、版本和健康结果。
4. 菜单预览只新增该包自己的菜单项;应用后 Core 管理员菜单才出现该入口。
5. 停用或卸载后入口消失,其他菜单保持不变。
6. 425px、900px、1440px 三种视口均无横向溢出、遮挡或凭据泄漏。
## 6. 后续插件模板
新增业务插件只需提供独立清单、服务、UI、Core API allowlist 和菜单声明,并遵守本文件的安装生命周期。插件管理控制面不因新增业务域而增加订阅、支付或其他领域分支。
+393
View File
@@ -0,0 +1,393 @@
# Sub2API 独立业务插件框架 V1 RFC
状态:V1 通用插件控制面参考实现已落库;订阅业务插件只读适配与 Core Host Adapter/写操作仍为 Draft
本文规划一种不改动 Sub2API 核心代码、数据库和现有插件 ABI 的独立业务插件框架。当前 V1 控制面参考实现位于 `plugins/plugin-admin`,它负责插件清单、签名、安装、启停、升级、回滚、卸载、配置、审计和菜单注入;控制面本身不是订阅后台。每个业务插件(包括独立的 `plugins/subscription-admin`)作为可选的独立服务运行在自己的端口,通过控制面安装后再由部署层反向代理和现有“管理员可见自定义菜单”嵌入 Sub2API 页面。插件登录直接调用 Core 的现有鉴权,普通账号没有访问权限,也不复制 Core 用户表。
V1 已实现范围以 `plugins/plugin-admin` 控制面和本文“当前实现范围”章节为准;本文中的订阅业务插件只是首个适配样例。Core Host Adapter、短时 Plugin Access Token、无感 SSO 和余额写操作仍是后续版本设计,不代表当前 Core 已提供这些接口。
本文不是现有 `.s2plugin` 协议的直接改版。现有 `.s2plugin` 继续只负责 `openai.oauth.outbound_transport.v1`。本 RFC 的 V1 是部署级业务插件约定,不向当前 Core 增加新的路由、数据表或业务 RPC。
## 0. 约束与可行性边界
本版本必须同时满足以下约束:
- 不修改 Sub2API 的 Go、Vue、数据库迁移和现有鉴权实现;
- 不在插件内建立 Core 用户表,管理员身份以 Core 现有账号和角色为准;
- 插件后端独立监听 `PLUGIN_PORT`,由 Nginx/Caddy/Traefik 等部署层转发;
- 通过 Core 已有 `custom_menu_items` 配置添加 `visibility=admin` 的 iframe 菜单入口;
- 插件只在服务端调用 Core 现有 API,浏览器不持有 `x-api-key` 或 Core JWT;
- 第一阶段只搭框架、登录、权限、健康检查、嵌入和只读联调,不实现订阅购买写操作。
现有自定义菜单可以完成“把插件页面显示在 Sub2API 管理页面内”,但现有 iframe 使用 sandbox,且 Core 前端 JWT 保存在 `localStorage`,不会自动注入跨端口 iframe。因此在完全不改 Core 的前提下,V1 的登录方式是:插件登录页把凭据转交给插件后端,插件后端调用 Core 现有登录和二次验证接口,确认 `role=admin` 后只保留短时插件会话及服务端 Core token;这复用同一套 Core 用户和角色,不复制用户表,但不是无感知的当前页面会话共享。
如果以后要求“已登录 Core 后打开 iframe 立即无感登录”,需要一个很小的 Core 一次性登录交接接口或前端 `postMessage` 适配;这属于 V1.1,不应通过 URL 明文传递 JWT。反向代理只改变网络路径,不改变这一认证边界。
## 1. 决策摘要
### 1.1 推荐拓扑
```text
管理员浏览器
│ Core 页面中的 admin 自定义菜单
▼
Core `/custom/PLUGIN_ID`
│ 现有 sandbox iframe;只加载插件 UI,不共享 Core 存储
▼
反向代理 `/extensions/PLUGIN_ID/*`
│ 转发到独立服务 `127.0.0.1:PLUGIN_PORT`
▼
业务插件后台
├─ `/login` 接收管理员登录请求
├─ 服务端调用 Core `/api/v1/auth/login`(需要时调用 `/login/2fa`)
├─ 调用 Core `/api/v1/auth/me`,确认 `role=admin`
├─ 建立插件 HttpOnly 会话,Core access/refresh token 只在服务端保存
└─ 通过 Bearer Core JWT 调用现有 Core Admin API
▼
Sub2API Core 现有认证、Admin API 和订阅/余额账本
```
插件后台是独立服务,不以高权限子进程形式嵌入核心,也不直接连接核心数据库。V1 通过部署层完成端口映射,通过 Core 现有登录/2FA、`/auth/me` 和 Admin API 完成功能联调;浏览器永远不接触 Admin Key,也不接触 Core JWT。插件后端不建立用户表,只维护短期会话(单实例可使用内存,多实例可使用插件自己的 Redis/会话存储)。这里的前提是插件属于受信任的内部服务:登录密码会在一次请求中经过插件后端再转交 Core,但不落库、不写日志;若要求插件进程也完全接触不到密码,需要另行引入 Core SSO/OIDC。
如果部署环境只允许服务到服务调用,也可以把 Core Admin API Key 放在插件后端 secret 中作为临时 BFF 凭据;这时所有命令都以该 Key 对应的管理员身份执行,属于单一服务身份模式,不等同于当前浏览器管理员的 SSO,也不替代 V1 的管理员登录方案。
### 1.2 V1 的默认范围
- 插件拥有独立后台和独立发布版本。
- 只允许管理员登录;普通用户访问插件后台一律拒绝。
- 首版支持管理员登录、权限校验、健康检查、嵌入和只读订阅数据展示。
- 余额购买命令暂不实现,待框架验收后再复用现有订阅逻辑设计原子入口。
- 现有 `.s2plugin` 传输插件 ABI、路由和生命周期保持不变。
- 不实现插件任意注册核心路由、任意执行 SQL、任意读取宿主 Cookie、任意注入 Vue 路由或任意修改 Core 菜单组件。
## 2. 为什么不复用现有 `.s2plugin`
现有插件协议的能力是 `GetInfo`、`Health`、配置读写、配置测试和 `Forward` HTTP 流。它的清单只接受 `openai.oauth.outbound_transport.v1`,UI 也只是无管理员 Token 的配置 iframe。
因此它不适合安全承载以下业务:
- 管理员登录和角色授权;
- 订阅商品、余额购买和订单审计;
- 核心数据库事务或迁移;
- 用户页面、菜单、任意 HTTP 路由和支付/订阅回调。
如果把这些能力硬塞入现有协议,就会同时改变进程 ABI、权限模型、数据库边界和前端路由,反而扩大与上游的冲突面。V1 采用“外部业务插件 + Core 现有 API 适配器”作为清晰的新边界;需要 Core 新增接口的部分另列为 V1.1。
## 3. 术语和边界
| 术语 | 定义 |
|---|---|
| Core | Sub2API 主服务,拥有用户、余额、Group、Key、订阅和用量账本。 |
| Business Plugin | 独立部署的后台服务,提供一个业务域的管理 UI 和 BFF。 |
| Plugin UI | 由 Business Plugin 提供的页面,只调用自己的后端。 |
| Plugin Backend | Business Plugin 的服务端,保存插件配置、会话和操作幂等记录。 |
| Core API Adapter | V1 插件后端对 Core 现有 REST API 的服务端客户端,只允许访问明确的认证、管理员和只读业务端点。 |
| Future Host Adapter | V1.1 以后、需要修改 Core 才能提供的版本化业务 API;不属于本次无 Core 改动的实现范围。 |
| Plugin Session Credential | 插件自己的 HttpOnly 会话标识,不等于 Core JWT 或全局 Admin Key。 |
| Capability | 插件声明的业务能力,例如 `subscription.admin.v1`。 |
| Projection | 插件保存的只读或可重建副本,不是核心账本的权威数据。 |
## 4. 认证与权限模型
### 4.1 管理员登录
V1 采用“插件会话 + Core 现有登录”的两层模型,不创建插件用户表:
1. 浏览器打开插件 `/login`,只向插件后端提交 Core 管理员凭据和必要的 2FA 信息。
2. 插件后端服务端调用 Core `POST /api/v1/auth/login`;需要二次验证时继续调用 `POST /api/v1/auth/login/2fa`。
3. 插件后端用返回的 Core access token 调用 `GET /api/v1/auth/me`,确认用户状态正常且 `role=admin`。
4. 插件后端建立自己的短时 HttpOnly 会话;Core access/refresh token 只保存在插件服务端的会话存储中,不回传浏览器。
5. 插件业务请求只携带插件会话 Cookie,插件后端再用对应管理员的 Core Bearer token 调用允许的 Core API。
```text
浏览器 -> Plugin /login(插件会话 Cookie 尚未建立)
Plugin -> Core /api/v1/auth/login
Plugin -> Core /api/v1/auth/login/2fa(按 Core 返回的要求)
Plugin -> Core /api/v1/auth/me(确认 role=admin)
Plugin <- 建立 HttpOnly 插件会话
浏览器 -> Plugin /admin/*(只带插件会话 Cookie)
Plugin -> Core /api/v1/admin/*(只在服务端带 Bearer Core JWT)
```
这不是独立账号,也不是把 Core 用户复制到插件;密码只用于一次 Core 登录请求,插件不落库。插件会话可使用内存存储;多实例部署时使用插件自己的 Redis/会话存储,不连接 Core 数据库。Core 继续负责密码、2FA、限流、TokenVersion、撤销和管理员角色校验。
当前 Core 没有给自定义 iframe 提供 token handoff,因此“Core 已登录后打开 iframe 自动登录”不属于 V1。V1 允许在 iframe 内显示插件登录页;由于 sandbox/第三方 Cookie 策略可能让嵌入会话在刷新后失效,插件必须提供“新窗口打开插件”入口作为稳定登录路径。以后如需真正无感 SSO,另行设计一次性 code + state 或 `postMessage` 交接机制,JWT 不应放在 URL。
### 4.2 权限判定
- 默认拒绝所有普通用户、未登录用户和过期会话。
- 插件会话只包含 `plugin_id`、`admin_user_id`、角色、权限版本、签发时间和过期时间。
- V1 只定义 `plugin_admin` 角色;后续可增加 `subscription_operator`、`subscription_readonly`。
- 插件后端每次命令都携带对应 Core 管理员的 Bearer token,不使用“系统管理员”固定身份代替实际操作者(仅使用临时 Admin Key 的过渡模式除外)。
- Core 对每个现有 Admin API 操作再次做管理员角色、会话撤销和资源级授权,不把插件 UI 的按钮隐藏当作授权依据。
- 所有写操作要求 CSRF 防护、审计记录和幂等键;敏感操作可要求 step-up。
### 4.3 会话要求
- 会话 Cookie 必须 `HttpOnly`、`Secure`、`SameSite=Lax` 或更严格。
- 生产环境只允许 HTTPS;反向代理必须正确传递原始 Host 和协议。
- 登录使用短时一次性 state,绑定浏览器会话并防重放。
- 默认会话有效期 30 分钟,滑动续期上限 8 小时;登出立即撤销服务端会话。
- 独立 origin 嵌入时按浏览器策略使用 `SameSite=None; Secure`,同源反代优先使用 `Lax`;必须在目标浏览器验证刷新、退出和第三方 Cookie 行为。
- 插件 UI 不把 Core JWT、Admin Key 或服务凭据写入 LocalStorage、URL、HTML、日志或错误提示。
## 5. Admin Key 与服务凭据安全
### 5.1 绝对禁止的做法
- 不把全局 `x-api-key` 注入浏览器。
- 不把 Admin Key 放进插件静态 JS、HTML、iframe、URL query、下载文件或前端 sourcemap。
- 不让插件 UI 直接请求 Core Admin API。
- 不把 Admin Key 写入普通业务日志、请求追踪、错误响应、数据库明文或备份导出。
- 不让插件直接连接 Core PostgreSQL、Redis 或宿主文件目录。
### 5.2 V1 凭据分级
| 环境 | 凭据 | 用途 | 约束 |
|---|---|---|---|
| 开发 | Core 管理员的临时登录凭据 | 调用 Core `/auth/login` 联调 | 只由开发者输入到插件登录页,不写入代码、配置和日志。 |
| 测试 | Core 返回的 access/refresh token | 建立插件服务端会话 | 只存插件服务端会话存储,短 TTL,测试 Core 与测试管理员专用。 |
| 生产 | Core 返回的 access/refresh token | 代表实际登录的 Core 管理员调用现有 Admin API | 服务端加密保存或内存保存,按 Core TokenVersion/撤销结果失效;不回传浏览器。 |
V1 不要求新增 Plugin Access Token 服务,也不要求修改 Core。`ADMIN_API_KEY` 只作为服务到服务 BFF 的应急/测试凭据:它必须只存在插件后端 secret manager、受限环境变量或权限为 `0600` 的 secret 文件中,并由 Core Admin API 的 endpoint allowlist 限制。使用 Admin Key 时所有请求都以同一个管理员身份执行,审计粒度是服务身份级别,不得作为插件登录态下发给浏览器。
V1.1 如需在不保存 Core refresh token 的情况下运行,再设计 Core 签发的短时、限 scope Plugin Access Token;在 Core 提供该能力前,文档只把它视为未来接口。
### 5.3 凭据生命周期
1. 插件后端接收管理员登录请求,但不保存密码。
2. 插件后端调用 Core 登录/2FA,保存返回 token 到服务端会话,并绑定 `admin_user_id`。
3. 每次 Core 请求都使用 TLS 和 Core Bearer token;Core 继续校验签名、TokenVersion、会话绑定和角色。
4. access token 过期时只使用对应 refresh token 调用 Core `/auth/refresh`;刷新失败就销毁插件会话并要求重新登录。
5. 登出、Core 管理员撤销会话、停用插件或发现泄露时,立即删除插件会话;Admin Key 过渡模式由运维轮换并撤销。
### 5.4 服务端防护
- Core 现有 Admin API 由自身 `adminAuth`、JWT 会话和管理员角色保护;插件后端再使用出站 allowlist,只能访问事先批准的 Core API 路径。
- 插件后端不接受任意 URL 转发,也不把 Core API 代理能力暴露给插件 UI。
- 请求设置超时、重试上限和熔断;禁止在超时后盲目重放非幂等命令。
- 日志对 `Authorization`、`x-api-key`、Cookie、session、余额和个人信息做结构化脱敏。
- 记录 `plugin_id`、操作者、scope、资源 ID、幂等键和结果,不记录 secret 原文。
- 生产插件运行在独立低权限账号或容器中,限制文件、网络、系统调用和环境变量。
## 6. Core API Adapter V1(使用现有接口)
本节描述在“不修改 Sub2API”前提下插件可以使用的最小集成面。它不是 Core 已注册的插件协议,而是插件后端对现有 HTTP API 的严格 allowlist;实现前应根据目标版本在插件配置中冻结路径和响应字段。
### 6.1 认证头
```http
Authorization: Bearer CORE_ACCESS_TOKEN
X-Request-Id: UNIQUE_REQUEST_ID
```
`CORE_ACCESS_TOKEN` 只允许由插件后端的会话适配器发送。插件浏览器、静态资源和 iframe 消息中不得出现该 token。
### 6.2 V1 允许的接口类别
```text
POST /api/v1/auth/login
POST /api/v1/auth/login/2fa
POST /api/v1/auth/refresh
POST /api/v1/auth/logout
GET /api/v1/auth/me
GET /api/v1/settings/public
GET /api/v1/admin/payment/plans
GET /api/v1/admin/subscriptions
GET /api/v1/admin/subscriptions/{id}
GET /api/v1/admin/users/{id}
GET /api/v1/admin/users/{id}/subscriptions
```
实际插件先只开放只读管理员接口;不得把路径中的 `<read-only-endpoint>` 当作通配符,部署配置必须列出具体路径、方法和分页上限。现有 Core 没有 `/api/v1/plugin-host/*` 路由,V1 不调用或宣称该路径已存在。
### 6.3 V1 写操作边界
V1 框架和第一版订阅插件不实现余额扣款、订阅续期或撤销写操作。现有 Admin API 的多个调用不应拼成一笔购买,因为这样无法保证余额、订单、订阅和审计的单事务一致性。
未来要支持写操作,必须先在 Core 中增加版本化 Host Adapter 或等价的原子命令接口(需要 Core 代码、路由、审计和幂等存储变更),再由插件接入;这属于 V1.1,不是本轮“零 Core 改动”的交付物。
### 6.4 错误和重试
| HTTP | 含义 | 插件行为 |
|---|---|---|
| `401` | 凭据无效或已撤销 | 停止重试,提示重新注册/轮换。 |
| `403` | 管理员角色、会话或资源权限不足 | 不重试,记录操作者和资源。 |
| `409` | 幂等键冲突或业务状态冲突 | 查询 operation 状态后展示最终结果。 |
| `422` | 参数或余额业务校验失败 | 展示可读错误,不重试。 |
| `429` | Core 限流 | 按 `Retry-After` 有上限地重试。 |
| `5xx` | Core 暂时故障 | 只对明确幂等命令重试,指数退避。 |
## 7. 插件注册、清单与生命周期
### 7.1 与 `.s2plugin` 分离
Business Plugin V1 不直接套用现有 `manifest.schema.json`。建议新增独立的业务插件清单,例如 `business-plugin-manifest.v1.json`:
```json
{
"schema_version": 1,
"plugin_id": "example.subscription",
"name": "Example Subscription Admin",
"version": "0.1.0",
"core_api_baseline": "sub2api-0.1.183",
"capabilities": ["subscription.admin.v1"],
"tested_core_versions": ["0.1.183"],
"backend": { "health_path": "/healthz" },
"ui": { "entrypoint": "/admin" },
"publisher": { "key_id": "publisher-key-id" }
}
```
清单只声明能力和兼容范围,不授予数据库、路由或 secret 权限。V1 由部署脚本/反向代理保存清单、校验签名和允许的 Core API 路径;当前 Core 不会读取该清单,也没有业务插件注册表。插件发布包/容器镜像仍应签名,但签名验证属于部署门禁,不应写成现有 Core 能力。
### 7.2 状态机
```text
registered -> disabled -> enabled -> draining -> disabled
│ │ │
└────── incompatible └── error
```
- `registered`:部署层已登记清单和发布者,但未启用。
- `disabled`:服务存在但不接收业务请求。
- `enabled`:健康检查通过,允许插件后端调用列入 allowlist 的 Core API。
- `draining`:停止接收新命令,等待进行中的幂等命令完成。
- `error`:健康检查或 Core API 合约校验失败,默认 fail-closed。
- `incompatible`:插件清单或 Core API 版本不兼容,不允许启用。
### 7.3 升级和回滚
- 插件版本独立于 `backend/cmd/server/VERSION` 和 `frontend/package.json`。
- 升级前先执行健康检查、现有 Core API contract test 和插件自身数据备份检查。
- 新版本不兼容时保持旧版本运行,不自动覆盖正在启用的实例。
- 停用时等待进行中的命令完成;超过 drain 超时则拒绝新命令并标记待恢复。
- 插件卸载不删除 Core 订阅、余额和审计数据。
## 8. 数据归属
### 8.1 Core 权威数据
- 用户身份和管理员授权;
- 余额账本;
- 套餐价格、额度、覆盖 Group 和购买快照;
- 用户订阅状态、期限、配额和 reserved;
- 余额扣减、订阅创建/续期、撤销和审计;
- 请求计费、额度预留、结算和幂等。
### 8.2 Plugin 可拥有的数据
- 插件管理员会话和本地角色映射;
- 插件 UI 偏好、筛选条件和缓存;
- Core API operation 的本地查询索引;
- 供应商或业务侧的非权威展示配置。
插件数据库中的订阅副本必须可删除、可重建、带来源版本和更新时间。它不作为网关放行请求的依据。
## 9. 前端和菜单集成
V1 通过现有的管理员自定义菜单嵌入,不修改 Core 前端路由:
```text
Core 设置 -> custom_menu_items
id: example.subscription
visibility: admin
url: https://PLUGIN_ORIGIN/extensions/example.subscription/
Core `/custom/example.subscription`
└── sandbox iframe -> 反向代理 -> `127.0.0.1:PLUGIN_PORT`
```
`visibility=admin` 只负责隐藏普通账号的菜单入口,插件后端仍必须独立鉴权。现有 iframe 的 sandbox、跨端口 origin 和 Core JWT `localStorage` 使其不会自动共享 Core 登录态;因此 iframe 首屏显示插件登录页是 V1 的预期行为。插件也应提供“新窗口打开”,便于登录后保持自身会话。
生产部署建议把插件外部地址挂在与 Core 相同的 HTTPS 站点下,由 Nginx/Caddy 按路径反代到独立端口;这只减少浏览器跨域问题,不改变插件必须登录和服务端调用 Core 的事实。若使用独立 origin,必须在 Core CORS 中精确加入该 origin,禁止 `*`,并仅允许必要的 `Authorization` 请求头。
插件页面只调用自己的 `/plugin-api/*`,由插件后端调用 Core 现有认证和管理员 API。V1 不允许插件动态注入主应用 Vue 路由、修改核心菜单组件或覆盖全局 CSS;主应用只提供一个受权限控制的“业务插件”入口,插件内部菜单由插件自己管理。
## 10. 威胁模型与处置
| 威胁 | V1 处置 |
|---|---|
| XSS 窃取 Admin Key | 浏览器永远没有 Admin Key;Cookie HttpOnly;CSP 和输出编码。 |
| 插件前端伪造管理员命令 | 插件后端重新验证插件会话;Core 重新验证 Bearer token、管理员角色、操作者和资源权限。 |
| 插件后端日志泄露 secret | 统一日志脱敏,禁止记录 Authorization 和完整请求。 |
| 插件服务被攻破 | 限制 Token scope、TTL、出站地址和 Core API;立即撤销凭据。 |
| 重放余额购买 | Idempotency-Key + Core 事务记录 + 请求时间/nonce。 |
| CSRF | SameSite Cookie、CSRF token、Origin/Referer 校验。 |
| SSRF | 插件出站 allowlist;Core 现有 API 不接受任意 URL。 |
| 普通用户进入后台 | 插件登录调用 Core `/auth/me` 并要求 `role=admin`;所有插件路由默认 deny。 |
| 多实例状态漂移 | Core 是权威;插件状态通过健康检查和配置版本对齐。 |
| 插件卸载误删订阅 | 插件没有删除 Core 账本的权限,卸载只撤销凭据。 |
必须明确:独立插件进程不是操作系统级沙箱,签名只证明发布者和完整性,不证明代码无漏洞。生产部署仍需要低权限运行、网络隔离和可撤销凭据。
## 11. 测试门禁
### 11.1 Core API Adapter 合约测试
- Core 登录、`/login/2fa`、`/auth/me`、access/refresh 过期和撤销;
- 普通用户、停用用户、无效会话和跨插件会话均返回 `403`;
- 只读 Admin API 的字段脱敏、分页上限和资源权限;
- Core 超时、`401/403/429/5xx`、刷新 token 和重新登录;
- V1 明确没有 `/api/v1/plugin-host/*`,测试不能把未来接口当成现有接口。
### 11.2 插件后端测试
- Core 登录代理、2FA、会话过期、登出和 CSRF;
- Core access/refresh token 不进入响应、页面、日志和异常堆栈;
- Core `401/403/429/5xx` 的处理和刷新失败后的重新登录;
- 只允许配置中列出的 Core API endpoint;
- 插件服务重启后会话失效或从插件自己的会话存储恢复,不能依赖 Core 用户表。
### 11.3 浏览器和部署测试
- 管理员可登录,普通用户无法登录或访问任何后台 API;
- 不同屏幕下页面无横向泄露和敏感字段;
- 浏览器 DevTools 的请求、下载和页面源中没有 Admin Key;
- HTTPS、反向代理、容器低权限、secret 文件权限和日志脱敏;
- 停用、升级、回滚、凭据撤销后所有写操作按预期失败。
## 12. 分阶段实施计划(不含本次代码)
### Phase 0:冻结部署契约
- 选择外部服务部署方式(推荐同源反向代理 + 独立服务);
- 冻结插件端口、反向代理路径、`custom_menu_items` 字段和健康检查;
- 冻结允许调用的现有 Core API 路径、字段、分页和错误处理;
- 明确插件登录通过 Core `/auth/login`/`/login/2fa`,不创建用户表;
- 建立 Core token 会话销毁、Admin Key 过渡和日志脱敏方案。
### Phase 1:框架最小实现
- 独立服务目录、清单、发布者签名和部署兼容性检查;
- Plugin Backend 管理员登录和会话;
- Core access/refresh token 服务端会话适配器;
- 插件健康检查、启停、操作审计和同源入口;
- Core API allowlist、contract test 与本地示例插件。
### Phase 2:订阅插件试验
- 只读套餐、用户余额和订阅列表;
- 管理员操作页面、查询缓存和审计;
- 使用测试 Core 和测试账户,不连接生产余额;
- 余额购买、续费和撤销暂不实现,等待 Core 原子接口冻结。
### Phase 3:生产准备
- 未来 Core Host Adapter/短时 Plugin Access Token(若确认需要 Core 改动);
- step-up、密钥轮换和撤销;
- 多实例、备份恢复、升级回滚和故障演练;
- 通过安全、契约、浏览器和并发门禁后再启用。
## 13. 待确认事项
以下事项在写代码前必须确定:
1. V1 的订阅插件只允许管理员操作;普通用户购买页不纳入本框架首版。
2. 管理员登录是否按本文通过 Core `/auth/login` 和 `/login/2fa` 完成;确认不创建插件用户表。
3. 插件采用独立服务端口 + 同源反向代理,还是独立 origin;需确定生产网络拓扑。
4. V1 是否允许测试环境使用服务端专用 Admin Key;生产默认不使用,除非接受单一管理员审计语义。
5. 插件是否允许保存只读缓存;无论选择何种缓存,Core 必须始终是权威来源。
6. V1.1 是否需要真正无感 SSO 和 Core Host Adapter;若需要,单独立项修改 Core。
在这些问题确认前,不应开始实现插件协议、数据库表或前端页面。
+20
View File
@@ -0,0 +1,20 @@
# Business Plugins
这里存放独立业务插件的领域文档和适配说明。
## 框架入口
- [Business Plugin Framework V1](BUSINESS_PLUGIN_FRAMEWORK_V1.md)
- [Manifest V1](BUSINESS_PLUGIN_MANIFEST_V1.md)
- [Boundaries](BUSINESS_PLUGIN_BOUNDARIES.md)
- [Architecture](BUSINESS_PLUGIN_ARCHITECTURE.md)
- [Acceptance](BUSINESS_PLUGIN_ACCEPTANCE.md)
- [Development](BUSINESS_PLUGIN_DEVELOPMENT.md)
## 领域插件
- `subscription-admin`:独立管理员只读订阅插件。它是框架的第一个领域样例,不是通用插件后台,也不负责安装或管理其他插件。实现与运行方式见 [`../plugins/subscription-admin/README.md`](../plugins/subscription-admin/README.md)。
## 现有 `.s2plugin`
现有 OpenAI OAuth transport 插件不属于本仓库;Business Plugin V1 与 Core 的 transport 插件使用不同的包、运行时和生命周期协议。
+25
View File
@@ -0,0 +1,25 @@
# Repository Scope
本仓库只承载独立业务插件及其版本化契约,不承载 Sub2API Core。
## 依赖关系
```text
Sub2API Core(官方仓库)
^
| 公开 HTTP API / 管理员鉴权 / custom_menu_items
|
独立插件服务(本仓库)
```
插件不得依赖 Core 的 `internal` 包、Ent 生成代码、PostgreSQL、Redis 或
Core 前端源码。每个插件拥有自己的版本、端口、服务进程、UI、配置和发布
产物,并在清单中声明兼容的 Core 基线。
## 仓库边界
- Core 的用户、余额、订单、订阅、配额、计费和用量数据仍由 Core 管理。
- 插件只通过服务端 allowlist 调用 Core API。
- 浏览器只访问插件自己的会话 API,不接触 Core JWT 或 Admin Key。
- 插件菜单通过 `custom_menu_items` 注入;停用或卸载只移除插件自己的菜单。
- 订阅管理是可选领域插件,不是通用插件控制面的一部分。
+254
View File
@@ -0,0 +1,254 @@
# 余额订阅业务插件 V1 RFC
状态:V1 只读试验实现已落库;余额购买、续费、撤销仍为 V1.1 Draft
本文定义基于 [`PLUGIN_FRAMEWORK_V1_RFC.md`](./PLUGIN_FRAMEWORK_V1_RFC.md) 的第一个业务插件试验,对应实现为 `plugins/subscription-admin`。插件是独立端口的管理员后台,复用 Sub2API Core 的管理员鉴权,不创建 Core 用户表,也不直接连接 Core 数据库。当前实现已完成框架、管理员会话、只读套餐/余额/订阅/审计联调;余额购买、续费和撤销写操作后置到 Core 原子接口冻结之后。
## 1. 目标和范围
### 1.1 V1 目标
- 提供独立的管理员订阅后台,普通账号拒绝登录和访问;
- 插件登录调用 Core 现有 `/api/v1/auth/login`、`/api/v1/auth/login/2fa` 和 `/api/v1/auth/me`,不维护第二套用户密码;
- 展示 Core 中的套餐、用户余额和订阅实例状态;
- 通过现有 `custom_menu_items` 的 `visibility=admin` 入口嵌入 Core 页面,也支持新窗口打开;
- 插件可独立升级、停用和回滚,不影响 Core 网关、余额账本和已有订阅;
- 记录未来订阅写操作所需的业务语义、幂等和审计契约,但本阶段不执行扣款。
### 1.2 V1 非目标
- 不接入支付宝、微信、Stripe 或其他外部支付渠道;
- 不允许普通用户登录插件后台或在插件内自助购买;
- 不把余额、套餐、订阅额度复制成插件自己的权威账本;
- 不把每次网关请求改成调用插件 RPC;
- 不通过多个现有 Admin API 调用拼接一次购买;
- 不在本次设计阶段修改 Core 代码、数据库迁移、现有前端路由或 `.s2plugin` ABI。
## 2. 业务语义(供后续 Core 原子接口使用)
订阅插件以后需要保持当前业务语义,写操作必须由 Core 在单事务内完成:
1. **支持多个同档位**:同一用户可以拥有多个相同套餐/档位的订阅实例。每个实例有独立的 `subscription_id`、期限、配额、用量和审计记录;实例数量受套餐的上限字段约束。
2. **支持单独续费**:续费请求必须指定 `subscription_id`,只延长目标实例的期限或按 Core 规则生成续费记录,不影响同用户的其他同档位实例。
3. **不可由用户取消**:插件和用户端不提供“取消订阅”入口。管理员撤销属于单独的受控操作,需 Core 权限、原因、二次确认和审计;它不等同于用户取消。
4. **购买时快照**:价格、货币、有效期、额度、Group 覆盖和实例规则以购买时版本写入 Core 快照,后续编辑套餐不静默改写已购买实例。
5. **余额付款**:余额扣减、订阅创建/续费、余额流水、幂等记录和审计必须在 Core 同一个事务边界内完成。
这些语义是插件的业务约束,不代表当前 Core 已经提供了对应的业务插件 API。第一阶段只验证读取现有订阅数据,写入契约单独评审。
## 3. 推荐拓扑
```text
Core 管理后台
└─ custom_menu_items (visibility=admin)
└─ sandbox iframe / 新窗口
└─ 反向代理 -> Plugin `127.0.0.1:PLUGIN_PORT`
├─ Plugin /login -> Core /api/v1/auth/login (+ /login/2fa)
├─ Plugin /auth/me -> 只允许 role=admin
├─ HttpOnly 插件会话(不建 Core 用户表)
└─ 服务端 Bearer Core JWT -> 现有 Core Admin API(V1 只读)
```
插件 UI 只访问自己的 BFF;Core JWT、refresh token 和 Admin API Key 只在插件服务端会话或 secret 中出现。iframe 不会自动继承 Core `localStorage` 登录态,因此 V1 首屏显示插件登录页属于预期行为。sandbox 或第三方 Cookie 策略可能导致嵌入会话刷新后失效,插件必须提供新窗口登录路径;登录密码只在一次转发请求中经过插件后端,不落库、不写日志。
## 4. 权威边界
### 4.1 必须留在 Core
- 用户身份、管理员权限和资源授权;
- 用户余额和余额流水;
- 套餐价格、货币、有效期、额度、包含的 Group、实例模式和上限;
- 购买时的套餐快照;
- 订阅实例状态、期限、配额、reserved 和用量;
- 余额扣减、订阅创建/续费、撤销、退款/补偿;
- 额度预留、实际结算、幂等、防超卖、缓存失效和网关放行判定。
### 4.2 插件可以负责
- 管理员登录代理、插件会话和插件内角色;
- 套餐、用户和订阅的分页筛选与展示;
- 只读缓存、操作结果页和管理员审计视图;
- 未来写操作的确认表单,但提交必须调用 Core 原子命令;
- 插件 UI 的版本和发布。
插件的本地副本可删除、可重建、带来源版本和更新时间,不能作为网关授权或扣费依据。
## 5. V1 现有 Core API 适配
V1 不调用尚不存在的 `/api/v1/plugin-host/*`。插件后端通过严格 allowlist 调用 Core 已有接口,具体路径按部署的 Core 版本冻结:
```http
POST /api/v1/auth/login
POST /api/v1/auth/login/2fa
POST /api/v1/auth/refresh
POST /api/v1/auth/logout
GET /api/v1/auth/me
GET /api/v1/settings/public
GET /api/v1/admin/payment/plans
GET /api/v1/admin/subscriptions
GET /api/v1/admin/subscriptions/{id}
GET /api/v1/admin/users/{id}
GET /api/v1/admin/users/{id}/subscriptions
```
请求头只由插件后端添加:
```http
Authorization: Bearer CORE_ACCESS_TOKEN
X-Request-Id: UNIQUE_REQUEST_ID
```
列表接口必须设置分页、字段最小化、超时和审计。插件配置列出具体方法和路径,不能使用任意 URL 或宽泛通配符。Core 返回 `401` 时,插件先尝试一次 refresh;refresh 失败就销毁插件会话并要求重新登录。
## 6. 未来写操作契约(V1.1,当前不实现)
现有 Core Admin API 的 `assign`、`extend`、`revoke` 等操作属于 Core 管理面,当前不把它们组合成余额购买流程。要在插件内支持余额购买,Core 需要增加版本化原子命令或等价 Host Adapter,并在 Core 内完成余额、订单/购买记录、订阅和审计的一致性事务。
### 6.1 余额购买请求示例
```http
POST /api/v1/plugin-host/v1/subscription-purchases/balance
Authorization: Bearer PLUGIN_ACCESS_TOKEN
Idempotency-Key: subscription-purchase-UNIQUE_ID
Content-Type: application/json
{
"user_id": 123,
"plan_id": 12,
"expected_plan_version": 4,
"expected_price": "19.00",
"currency": "USD",
"request_id": "UNIQUE_REQUEST_ID"
}
```
该路径是未来接口示例,当前 Core 没有实现。Core 必须重新读取当前套餐和余额,`expected_*` 只用于发现界面陈旧;价格以字符串 decimal 处理,不使用二进制浮点数。
### 6.2 Core 事务要求
未来 Core 原子命令至少需要:
1. 校验操作者为管理员并通过资源级授权;
2. 锁定用户余额、套餐购买上限和目标订阅资源;
3. 校验套餐可售、Group 覆盖、余额和实例上限;
4. 写入购买快照;
5. 扣余额并写余额流水;
6. 创建新实例或仅续费指定 `subscription_id`;
7. 写唯一 operation、幂等记录和管理员审计;
8. 事务提交后失效相关缓存。
任一步失败都回滚整笔命令。插件不接触 SQL transaction,也不执行失败后的自行补偿。
### 6.3 操作状态和重试
```text
accepted -> processing -> completed
└── failed
```
同一 `Idempotency-Key` 重试返回第一次操作结果,不重复扣款。网络超时后插件查询 operation 状态,不再次创建购买。`401/403` 不重试;`429` 按 `Retry-After` 有上限退避;只有明确幂等命令才允许对 `5xx` 重试。
## 7. 同档位实例和续费规则
未来实现必须覆盖以下测试矩阵:
| 场景 | 预期结果 |
|---|---|
| 同一用户购买两个相同档位 | 产生两个独立实例,各自有期限、配额和 `subscription_id`。 |
| 续费实例 A | 只改变实例 A;实例 B 的期限和配额保持不变。 |
| 达到实例上限 | 返回稳定业务错误,不扣余额、不创建半成品订阅。 |
| 用户尝试取消 | 插件没有取消入口;Core 用户接口也不提供用户自助取消语义。 |
| 管理员撤销 | 走单独的受控 Core 操作,要求原因、审计和明确的退款/补偿规则。 |
| 两个管理员并发购买 | 由 Core 锁和幂等保证不超卖;失败方查询最终状态。 |
## 8. 套餐快照和变更
推荐后续 Core 设计提供不可变套餐版本或购买快照,至少包含价格、货币、有效期、三类额度、覆盖 Group、实例模式和上限。当前 schema 的运行时 Group 覆盖和套餐字段存在可变性,不能在本 RFC 中宣称已经提供完整快照保证。
套餐下架只影响新购买;已有订阅如何处理由 Core 现有授权和计费规则决定。若要迁移已有实例,需要单独的预览、影响数量、确认 token、幂等键和审计命令。
## 9. 管理员 UI V1
首版只读页面建议顺序:
1. 概览:Core 连接状态、插件版本、最后同步时间和健康状态;
2. 套餐:价格、货币、有效期、额度、覆盖 Group 和可售状态;
3. 用户订阅:按用户 ID、脱敏名称、订阅状态、实例档位和到期时间查询;
4. 订阅详情:展示单个 `subscription_id` 的期限、窗口额度、用量和来源版本;
5. 操作记录:展示操作者、Core request ID、结果和时间;
6. 设置:Core 地址、allowlist、会话/凭据状态和健康检查;secret 只允许轮换,不允许回显。
余额购买、单独续费和管理员撤销在 V1 只显示“未启用”状态,不渲染可提交按钮,避免误触发现有非原子接口。
## 10. 安全验收
- 页面源码、网络请求、下载文件和浏览器存储中没有 Admin Key 或 Core JWT;
- 普通用户使用 Core JWT 登录插件返回 `403`,直接访问插件 API 也返回 `403`;
- Core access/refresh token 不进入插件响应、页面、日志和异常堆栈;
- 插件后端只访问配置中的 Core API 路径,禁止 SSRF 和任意 URL 代理;
- 插件服务停用后,已有订阅仍按 Core 原有网关鉴权和计费逻辑运行;
- 清空插件本地缓存后,可从 Core 重建只读列表,不影响核心余额和订阅;
- 用户端没有取消订阅入口,管理员撤销(未来启用时)必须有原因、审计和幂等键。
## 11. 测试计划
### 11.1 插件后端
- 管理员登录、2FA、普通用户拒绝、会话过期、登出和 CSRF;
- Core token refresh、撤销和 Core `401/403/429/5xx` 处理;
- 套餐/余额/订阅查询的分页、字段脱敏、超时和缓存重建;
- allowlist 拒绝未声明路径、任意 URL 和跨插件会话;
- 多实例会话存储和服务重启后的会话策略。
### 11.2 未来 Core 写操作
- 两个同档位实例、目标实例单独续费和实例上限;
- 用户取消入口不存在,管理员撤销的审计和补偿规则;
- 余额不足、套餐下架、价格版本冲突、并发购买和幂等重试;
- 事务失败时余额、订单、订阅和审计均保持原状;
- 购买快照内容不可变、缓存只在提交后失效。
### 11.3 浏览器和部署
- 管理员可在 iframe 和新窗口完成登录;普通用户菜单不可见且 API 拒绝;
- 425px、900px、1440px 下无横向溢出、遮挡或敏感字段泄露;
- DevTools 请求、下载、页面源和日志中没有 secret;
- 反向代理、HTTPS、低权限运行、停用、升级和回滚流程可恢复。
## 12. 分阶段实施计划
### Phase 0:框架契约
- 冻结插件端口、反向代理路径、`custom_menu_items` 字段和健康检查;
- 冻结 Core 登录/2FA、管理员角色判断和现有只读 API allowlist;
- 确认会话存储、Core token 生命周期、日志脱敏和 secret 轮换;
- 不改 Core 代码、迁移、现有插件 ABI 或前端路由。
### Phase 1:插件框架最小实现
- 独立服务目录、清单、签名和部署兼容性检查;
- Core 登录代理、管理员角色校验、HttpOnly 插件会话和 CSRF;
- Core API BFF、健康检查、审计和只读示例页;
- 本地 contract test、浏览器验收和反向代理样例。
### Phase 2:订阅只读插件
- 套餐、用户余额、订阅实例和操作记录只读查询;
- 同档位实例/单独续费/不可取消语义的 UI 展示与测试数据;
- 只接入测试 Core,不连接生产余额。
### Phase 3:单独立项的 Core 写能力
- 评审并实现 Core 原子余额购买/续费/撤销接口;
- 增加 Core 幂等存储、审计和购买快照后,再接入插件写操作;
- 通过并发、事务、浏览器和回滚门禁后才启用生产 scope。
## 13. 实施前检查清单
- [ ] 插件只允许 Core `role=admin` 登录,且不创建用户表。
- [ ] 独立端口、反向代理路径和 `visibility=admin` 菜单入口已确定。
- [ ] iframe 登录页和新窗口登录页均可用,已知晓 V1 不提供无感 SSO。
- [ ] 只读 Core API allowlist、分页、字段脱敏和缓存策略已冻结。
- [ ] 同档位多实例、单独续费、用户不可取消和管理员撤销规则已确认。
- [ ] 余额购买写操作明确等待 Core 原子接口,不使用现有多个接口拼接。
- [ ] 测试 Core、测试管理员和测试订阅数据已准备,生产余额尚未接入。
+14
View File
@@ -0,0 +1,14 @@
CORE_BASE_URL=http://127.0.0.1:8080
PLUGIN_ENV=production
PLUGIN_HOST=127.0.0.1
PLUGIN_PORT=8090
PLUGIN_REGISTRY_DIR=/var/lib/sub2api/plugin-admin
PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.plugin-admin
PLUGIN_COOKIE_PATH=/extensions/qiu.plugin-admin/
PLUGIN_COOKIE_SECURE=false
PLUGIN_COOKIE_SAMESITE=lax
PLUGIN_FRAME_ANCESTORS='self'
PLUGIN_ALLOW_UNSIGNED=false
PLUGIN_CONFIG_KEY=generate-and-replace-with-a-random-32-byte-secret
# JSON object: {"publisher-key-id":"BASE64_ED25519_PUBLIC_KEY"}
PLUGIN_TRUSTED_PUBLISHERS={}
+15
View File
@@ -0,0 +1,15 @@
.PHONY: test build check browser-check
test:
go test ./... -count=1
build:
mkdir -p bin
CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o bin/plugin-admin .
check: test
node --check ui/app.js
git diff --check
browser-check:
./test/run-browser-check.sh
+76
View File
@@ -0,0 +1,76 @@
# Sub2API Business Plugin Control Plane V1
This directory contains the independent administrator-only control plane for
Business Plugins. It is deliberately separate from Sub2API Core and from the
existing `.s2plugin` OpenAI OAuth transport runtime.
The control plane owns the plugin catalog, signed package verification,
revision directories, lifecycle state, encrypted configuration metadata,
menu preview/apply, and its own audit log. Core remains authoritative for
users, administrator roles, balances, subscriptions, billing, and audit
records. The service never connects to Core PostgreSQL/Redis and never sends a
Core JWT or Admin Key to the browser.
## Run locally
```sh
CORE_BASE_URL=http://127.0.0.1:8080 \
PLUGIN_HOST=127.0.0.1 PLUGIN_PORT=8090 \
PLUGIN_REGISTRY_DIR=./data \
PLUGIN_ENV=development \
PLUGIN_ALLOW_UNSIGNED=true \
PLUGIN_CONFIG_KEY=local-development-secret-at-least-32-chars \
go run .
```
`PLUGIN_ALLOW_UNSIGNED=true` is a development-only switch. Production
packages must contain `signature.json`, use Ed25519, and match a trusted key
from `PLUGIN_TRUSTED_PUBLISHERS` (a JSON object of key ID to base64 public
key). `PLUGIN_CONFIG_KEY` is required in every environment; use a randomly
generated secret in production and keep it stable across restarts so encrypted
plugin configuration remains decryptable.
Open `/admin/` directly or expose the service through the reverse proxy in
`deploy/`. The first login is the existing Core administrator login; no plugin
user table is created. The control plane stores only a short-lived server-side
session and encrypted plugin configuration.
## Control-plane endpoints
```text
GET /healthz
GET /readyz
POST /login POST /login/2fa POST /logout
GET /api/me GET /api/plugins GET /api/plugins/{id}
POST /api/plugins/{id}/install (multipart field: package)
POST /api/plugins/{id}/upgrade (multipart field: package)
POST /api/plugins/{id}/enable|disable|rollback|uninstall
GET|PUT /api/plugins/{id}/config
POST /api/plugins/{id}/menu-preview|menu-apply
POST /api/menu-items/preview|apply (JSON: {"plugin_id":"..."})
GET /api/audit
```
Every mutation requires the plugin CSRF token and an `Idempotency-Key`. A
mutation returns an operation ID even when it completes synchronously. Failed
installation and upgrade never replace the active revision. Uninstall is
allowed only after disable and removes plugin files, not Core data.
## Plugin package
Packages are ZIP files with `manifest.json`, optional detached
`signature.json`, and declared `ui/` files. A package may also include
`service/` files when the control plane owns the plugin process; external
service packages omit `backend.command` and require a configured loopback
`service_url` before enabling. SHA-256 hashes in the manifest cover every
declared file. Absolute paths, traversal, duplicate entries, symlinks,
undeclared hashes, oversized files, unknown manifest fields, and untrusted
publishers are rejected before staging. Installation uses a per-plugin
revision directory and an atomic registry JSON update.
## Deliberate V1 limits
The control plane does not register Core routes, change Core migrations, run
arbitrary proxy URLs, provide transparent iframe SSO, or move billing and
subscription hot-path logic out of Core. A subscription manager remains a
separate business plugin that consumes its own typed Core API adapter.
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
mkdir -p "$ROOT/bin"
CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o "$ROOT/bin/plugin-admin" "$ROOT"
@@ -0,0 +1,33 @@
{
"schema_version": 1,
"plugin_id": "qiu.plugin-admin",
"name": "Business Plugin Control Plane",
"version": "1.0.0",
"core_api_baseline": "sub2api-0.1.183",
"tested_core_versions": ["0.1.183"],
"capabilities": ["plugin.admin.v1"],
"backend": {
"health_path": "/healthz",
"readiness_path": "/readyz",
"listen_env": "PLUGIN_PORT"
},
"ui": {
"entrypoint": "ui/index.html",
"menu": {
"id": "qiu.plugin-admin",
"label": "插件管理",
"visibility": "admin",
"sort_order": 190
}
},
"publisher": { "key_id": "qiu-plugin-admin-dev" },
"core_api_allowlist": [
"POST /api/v1/auth/login",
"POST /api/v1/auth/login/2fa",
"POST /api/v1/auth/refresh",
"POST /api/v1/auth/logout",
"GET /api/v1/auth/me",
"GET /api/v1/settings/public",
"GET /api/v1/admin/settings"
]
}
@@ -0,0 +1,8 @@
CORE_ORIGIN {
handle_path /extensions/qiu.plugin-admin/* {
reverse_proxy 127.0.0.1:8090
}
}
# Set PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.plugin-admin and
# PLUGIN_COOKIE_PATH=/extensions/qiu.plugin-admin/.
@@ -0,0 +1,7 @@
{
"id": "qiu.plugin-admin",
"label": "插件管理",
"url": "https://CORE_ORIGIN/extensions/qiu.plugin-admin/",
"visibility": "admin",
"sort_order": 190
}
@@ -0,0 +1,13 @@
location /extensions/qiu.plugin-admin/ {
proxy_pass http://127.0.0.1:8090/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
}
# Set PLUGIN_PUBLIC_BASE_PATH and PLUGIN_COOKIE_PATH to this same path.
# Keep the service bound to loopback and expose it only through HTTPS.
@@ -0,0 +1,22 @@
[Unit]
Description=Sub2API Business Plugin Control Plane
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=sub2api-plugin
Group=sub2api-plugin
WorkingDirectory=/opt/sub2api/plugin-admin
EnvironmentFile=/etc/sub2api/plugin-admin.env
ExecStart=/opt/sub2api/plugin-admin/bin/plugin-admin
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/sub2api/plugin-admin
[Install]
WantedBy=multi-user.target
+3
View File
@@ -0,0 +1,3 @@
module git.awaioi.com/awaioi/sub2api-add/plugins/plugin-admin
go 1.23
@@ -0,0 +1,414 @@
// Package manifest validates the standalone Business Plugin V1 manifest.
package manifest
import (
"bytes"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"regexp"
"sort"
"strings"
)
var (
pluginIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)+$`)
versionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`)
methodPattern = regexp.MustCompile(`^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)$`)
pathParam = regexp.MustCompile(`^\{[a-zA-Z][a-zA-Z0-9_-]*\}$`)
sha256Pattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
)
// RequiredAllowlist contains the Core endpoints needed by every plugin BFF.
// Domain-specific read/write endpoints must be appended by each plugin.
var requiredAllowlist = []string{
"POST /api/v1/auth/login",
"POST /api/v1/auth/login/2fa",
"POST /api/v1/auth/refresh",
"POST /api/v1/auth/logout",
"GET /api/v1/auth/me",
"GET /api/v1/settings/public",
}
type Manifest struct {
SchemaVersion int `json:"schema_version"`
PluginID string `json:"plugin_id"`
Name string `json:"name"`
Version string `json:"version"`
CoreAPIBaseline string `json:"core_api_baseline"`
Capabilities []string `json:"capabilities"`
TestedCoreVersions []string `json:"tested_core_versions"`
Backend Backend `json:"backend"`
UI UI `json:"ui"`
Publisher Publisher `json:"publisher"`
CoreAPIAllowlist []string `json:"core_api_allowlist"`
Files map[string]string `json:"files,omitempty"`
}
type Backend struct {
HealthPath string `json:"health_path"`
ReadinessPath string `json:"readiness_path"`
ListenEnv string `json:"listen_env"`
Command string `json:"command,omitempty"`
}
type UI struct {
Entrypoint string `json:"entrypoint"`
Menu Menu `json:"menu"`
}
type Menu struct {
ID string `json:"id"`
Label string `json:"label"`
Visibility string `json:"visibility"`
SortOrder int `json:"sort_order"`
URL string `json:"url,omitempty"`
}
type Publisher struct {
KeyID string `json:"key_id"`
}
type Signature struct {
Algorithm string `json:"algorithm"`
KeyID string `json:"key_id"`
Signature string `json:"signature"`
}
// Compatibility is the control-plane decision for the current Core version.
type Compatibility struct {
Compatible bool `json:"compatible"`
Tested bool `json:"tested"`
Status string `json:"status"`
Message string `json:"message"`
}
func Load(path string) (Manifest, []byte, error) {
raw, err := os.ReadFile(path)
if err != nil {
return Manifest{}, nil, err
}
var m Manifest
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
if err := rejectDuplicateJSONKeys(raw); err != nil {
return Manifest{}, nil, err
}
if err := dec.Decode(&m); err != nil {
return Manifest{}, nil, fmt.Errorf("decode manifest: %w", err)
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
if err == nil {
return Manifest{}, nil, errors.New("manifest contains trailing JSON")
}
return Manifest{}, nil, fmt.Errorf("decode manifest trailing data: %w", err)
}
if err := Validate(m); err != nil {
return Manifest{}, nil, err
}
return m, raw, nil
}
func Validate(m Manifest) error {
if m.SchemaVersion != 1 {
return errors.New("schema_version must be 1")
}
if !pluginIDPattern.MatchString(m.PluginID) || len(m.PluginID) > 160 {
return errors.New("invalid plugin_id")
}
if strings.TrimSpace(m.Name) == "" || len(m.Name) > 160 {
return errors.New("name is required and must be at most 160 characters")
}
if !versionPattern.MatchString(strings.TrimPrefix(m.Version, "v")) {
return errors.New("invalid plugin version")
}
baseline := strings.TrimPrefix(strings.TrimPrefix(m.CoreAPIBaseline, "sub2api-"), "v")
if !versionPattern.MatchString(baseline) {
return errors.New("invalid core_api_baseline")
}
if len(m.Capabilities) == 0 {
return errors.New("capabilities must contain at least one capability")
}
seenCaps := map[string]struct{}{}
for _, capability := range m.Capabilities {
if !pluginIDPattern.MatchString(capability) || len(capability) > 160 {
return fmt.Errorf("invalid capability: %s", capability)
}
if _, ok := seenCaps[capability]; ok {
return fmt.Errorf("duplicate capability: %s", capability)
}
seenCaps[capability] = struct{}{}
}
for _, version := range m.TestedCoreVersions {
if !versionPattern.MatchString(strings.TrimPrefix(version, "v")) {
return fmt.Errorf("invalid tested_core_versions entry: %s", version)
}
}
if err := validateEndpointPath(m.Backend.HealthPath); err != nil {
return fmt.Errorf("backend.health_path: %w", err)
}
if err := validateEndpointPath(m.Backend.ReadinessPath); err != nil {
return fmt.Errorf("backend.readiness_path: %w", err)
}
if m.Backend.Command != "" {
if err := validateRelativePath(m.Backend.Command); err != nil || !strings.HasPrefix(strings.ReplaceAll(m.Backend.Command, "\\", "/"), "service/") {
return errors.New("backend.command must be a safe path under service/")
}
}
if strings.TrimSpace(m.Backend.ListenEnv) == "" || !regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`).MatchString(m.Backend.ListenEnv) {
return errors.New("backend.listen_env is invalid")
}
if err := validateUIEntrypoint(m.UI.Entrypoint); err != nil {
return fmt.Errorf("ui.entrypoint: %w", err)
}
if m.UI.Menu.ID != m.PluginID || strings.TrimSpace(m.UI.Menu.Label) == "" || len(m.UI.Menu.Label) > 160 || m.UI.Menu.Visibility != "admin" || m.UI.Menu.SortOrder < 0 {
return errors.New("ui.menu must bind to plugin_id, use admin visibility, and have a valid label/order")
}
if m.UI.Menu.URL != "" {
u, err := url.Parse(strings.TrimSpace(m.UI.Menu.URL))
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return errors.New("ui.menu.url must be an absolute http(s) URL without credentials or query")
}
}
if strings.TrimSpace(m.Publisher.KeyID) == "" || len(m.Publisher.KeyID) > 160 {
return errors.New("publisher.key_id is required")
}
if len(m.CoreAPIAllowlist) < len(requiredAllowlist) {
return fmt.Errorf("core_api_allowlist must contain at least %d entries", len(requiredAllowlist))
}
seen := map[string]struct{}{}
for _, entry := range m.CoreAPIAllowlist {
if err := validateAllowlistEntry(entry); err != nil {
return err
}
if _, ok := seen[entry]; ok {
return fmt.Errorf("duplicate allowlist entry: %s", entry)
}
seen[entry] = struct{}{}
}
for _, required := range requiredAllowlist {
if _, ok := seen[required]; !ok {
return fmt.Errorf("missing required allowlist entry: %s", required)
}
}
for path, hash := range m.Files {
if err := validateRelativePath(path); err != nil || !sha256Pattern.MatchString(hash) {
return fmt.Errorf("invalid file hash declaration: %s", path)
}
}
if len(m.Files) > 0 && strings.HasPrefix(m.UI.Entrypoint, "ui/") {
if _, ok := m.Files[m.UI.Entrypoint]; !ok {
return errors.New("ui.entrypoint is missing from files")
}
}
return nil
}
func validatePluginPath(value string) error {
p := strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
if p == "" || strings.HasPrefix(p, "/") || strings.Contains(p, "\x00") || strings.Contains(p, "..") || strings.Contains(p, "*") || strings.Contains(p, "?") || strings.Contains(p, "#") || strings.Contains(p, ":") {
return errors.New("must be a safe plugin path")
}
return nil
}
func validateEndpointPath(value string) error {
p := strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
if p == "" || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") || strings.Contains(p, "\x00") || strings.Contains(p, "..") || strings.ContainsAny(p, "*?#") {
return errors.New("must be a safe absolute endpoint path")
}
return nil
}
func validateRelativePath(value string) error {
p := strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
// Reject rooted paths, traversal, URL/drive syntax, and glob/query fragments.
if p == "" || strings.HasPrefix(p, "/") || strings.Contains(p, "\x00") || strings.Contains(p, "..") || strings.Contains(p, "*") || strings.Contains(p, "?") || strings.Contains(p, "#") || strings.Contains(p, ":") {
return errors.New("must be a safe relative path")
}
return nil
}
func validateUIEntrypoint(value string) error {
p := strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
if p == "/admin" || p == "/admin/" {
return nil
}
return validateRelativePath(p)
}
func validateAllowlistEntry(entry string) error {
parts := strings.Fields(entry)
if len(parts) != 2 || !methodPattern.MatchString(parts[0]) {
return fmt.Errorf("invalid core_api_allowlist entry: %s", entry)
}
p := parts[1]
if !strings.HasPrefix(p, "/api/v1/") || strings.ContainsAny(p, "?#*") || strings.Contains(p, "//") || strings.Contains(p, "..") {
return fmt.Errorf("core_api_allowlist entry must be an exact Core path: %s", entry)
}
for _, segment := range strings.Split(strings.TrimPrefix(p, "/"), "/") {
if strings.Contains(segment, "{") || strings.Contains(segment, "}") {
if !pathParam.MatchString(segment) {
return fmt.Errorf("invalid path parameter in allowlist entry: %s", entry)
}
}
}
return nil
}
func VerifySignature(manifestBytes, signatureBytes, publicKeyBytes []byte) error {
if err := rejectDuplicateJSONKeys(signatureBytes); err != nil {
return err
}
var signature Signature
dec := json.NewDecoder(strings.NewReader(string(signatureBytes)))
dec.DisallowUnknownFields()
if err := dec.Decode(&signature); err != nil {
return fmt.Errorf("decode signature: %w", err)
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
return errors.New("signature contains trailing JSON")
}
if signature.Algorithm != "ed25519" || strings.TrimSpace(signature.KeyID) == "" {
return errors.New("signature must use ed25519 and include key_id")
}
publicKey, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(publicKeyBytes)))
if err != nil || len(publicKey) != ed25519.PublicKeySize {
return errors.New("invalid base64 ed25519 public key")
}
sig, err := base64.StdEncoding.DecodeString(signature.Signature)
if err != nil || len(sig) != ed25519.SignatureSize {
return errors.New("invalid base64 ed25519 signature")
}
if !ed25519.Verify(ed25519.PublicKey(publicKey), manifestBytes, sig) {
return errors.New("manifest signature verification failed")
}
return nil
}
func VerifyKeyID(signatureBytes []byte, expectedKeyID string) error {
if err := rejectDuplicateJSONKeys(signatureBytes); err != nil {
return err
}
var signature Signature
dec := json.NewDecoder(strings.NewReader(string(signatureBytes)))
dec.DisallowUnknownFields()
if err := dec.Decode(&signature); err != nil {
return fmt.Errorf("decode signature: %w", err)
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
return errors.New("signature contains trailing JSON")
}
if strings.TrimSpace(expectedKeyID) == "" || signature.KeyID != expectedKeyID {
return errors.New("signature key_id does not match manifest publisher")
}
return nil
}
// rejectDuplicateJSONKeys performs a token-level walk because encoding/json
// otherwise accepts duplicate object members and silently keeps the last one.
func rejectDuplicateJSONKeys(raw []byte) error {
dec := json.NewDecoder(bytes.NewReader(raw))
var walk func() error
walk = func() error {
tok, err := dec.Token()
if err != nil {
return err
}
delim, ok := tok.(json.Delim)
if !ok {
return nil
}
switch delim {
case '{':
seen := map[string]struct{}{}
for dec.More() {
key, err := dec.Token()
if err != nil {
return err
}
name, ok := key.(string)
if !ok {
return errors.New("object member name must be a string")
}
if _, exists := seen[name]; exists {
return fmt.Errorf("duplicate JSON object key: %s", name)
}
seen[name] = struct{}{}
if err := walk(); err != nil {
return err
}
}
end, err := dec.Token()
if err != nil {
return err
}
if end != json.Delim('}') {
return errors.New("invalid JSON object")
}
case '[':
for dec.More() {
if err := walk(); err != nil {
return err
}
}
end, err := dec.Token()
if err != nil {
return err
}
if end != json.Delim(']') {
return errors.New("invalid JSON array")
}
}
return nil
}
if err := walk(); err != nil {
return fmt.Errorf("invalid JSON: %w", err)
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
if err == nil {
return errors.New("JSON contains trailing data")
}
return fmt.Errorf("invalid JSON trailing data: %w", err)
}
return nil
}
func RequiredAllowlist() []string { return append([]string(nil), requiredAllowlist...) }
func (m Manifest) SortedCapabilities() []string {
out := append([]string(nil), m.Capabilities...)
sort.Strings(out)
return out
}
// EvaluateCompatibility applies the V1 rule that an untested Core is compatible
// but must remain disabled until an administrator explicitly accepts it.
func (m Manifest) EvaluateCompatibility(coreVersion string) Compatibility {
coreVersion = strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(coreVersion), "sub2api-"), "v")
baseline := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(m.CoreAPIBaseline), "sub2api-"), "v")
if coreVersion == "" || baseline == "" || coreVersion != baseline {
return Compatibility{Status: "incompatible", Message: "Core version does not match plugin baseline"}
}
tested := false
for _, version := range m.TestedCoreVersions {
v := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(version), "sub2api-"), "v")
if v == coreVersion {
tested = true
break
}
}
if !tested {
return Compatibility{Compatible: true, Status: "untested", Message: "Core version is compatible but not tested"}
}
return Compatibility{Compatible: true, Tested: true, Status: "compatible", Message: "Core version is tested"}
}
@@ -0,0 +1,119 @@
package manifest
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func validManifest() Manifest {
return Manifest{
SchemaVersion: 1, PluginID: "qiu.plugin-admin", Name: "Plugin Admin", Version: "1.0.0",
CoreAPIBaseline: "sub2api-0.1.183", Capabilities: []string{"plugin.admin.v1", "diagnostics.v1"},
TestedCoreVersions: []string{"0.1.183"},
Backend: Backend{HealthPath: "/healthz", ReadinessPath: "/readyz", ListenEnv: "PLUGIN_PORT"},
UI: UI{Entrypoint: "/admin/", Menu: Menu{ID: "qiu.plugin-admin", Label: "Plugin Admin", Visibility: "admin", SortOrder: 200}},
Publisher: Publisher{KeyID: "publisher-key"}, CoreAPIAllowlist: RequiredAllowlist(),
}
}
func TestValidateManifestAndRejectsUnsafeEntries(t *testing.T) {
m := validManifest()
if err := Validate(m); err != nil {
t.Fatal(err)
}
for name, mutate := range map[string]func(*Manifest){
"duplicate allowlist": func(m *Manifest) { m.CoreAPIAllowlist = append(m.CoreAPIAllowlist, m.CoreAPIAllowlist[0]) },
"wildcard": func(m *Manifest) { m.CoreAPIAllowlist = append(m.CoreAPIAllowlist, "GET /api/v1/admin/*") },
"menu mismatch": func(m *Manifest) { m.UI.Menu.ID = "other.plugin" },
"traversal": func(m *Manifest) { m.UI.Entrypoint = "/admin/../secret" },
"entrypoint URL": func(m *Manifest) { m.UI.Entrypoint = "https://example.invalid/ui.js" },
"file drive path": func(m *Manifest) { m.Files = map[string]string{"C:/plugin.js": strings.Repeat("a", 64)} },
"file traversal": func(m *Manifest) { m.Files = map[string]string{"ui/../plugin.js": strings.Repeat("a", 64)} },
} {
t.Run(name, func(t *testing.T) {
candidate := m
mutate(&candidate)
if err := Validate(candidate); err == nil {
t.Fatal("expected validation error")
}
})
}
}
func TestLoadRejectsUnknownAndTrailingJSON(t *testing.T) {
dir := t.TempDir()
for name, raw := range map[string]string{
"unknown": `{"schema_version":1,"extra":true}`,
"trailing": `{"schema_version":1} {}`,
"duplicate": `{"schema_version":1,"schema_version":1}`,
} {
path := filepath.Join(dir, name+".json")
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := Load(path); err == nil {
t.Fatalf("%s: expected error", name)
}
}
}
func TestLoadRejectsNestedDuplicateJSONKeys(t *testing.T) {
dir := t.TempDir()
raw := `{"schema_version":1,"backend":{"health_path":"/healthz","health_path":"/readyz"}}`
path := filepath.Join(dir, "nested-duplicate.json")
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := Load(path); err == nil {
t.Fatal("expected nested duplicate key error")
}
}
func TestSignatureAndKeyID(t *testing.T) {
publicKey, privateKey, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
manifestBytes := []byte(`{"schema_version":1}`)
signature := Signature{Algorithm: "ed25519", KeyID: "publisher-key", Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, manifestBytes))}
signatureBytes, err := json.Marshal(signature)
if err != nil {
t.Fatal(err)
}
publicKeyBytes := []byte(base64.StdEncoding.EncodeToString(publicKey))
if err := VerifyKeyID(signatureBytes, "publisher-key"); err != nil {
t.Fatal(err)
}
if err := VerifySignature(manifestBytes, signatureBytes, publicKeyBytes); err != nil {
t.Fatal(err)
}
if err := VerifySignature([]byte(`{"schema_version":2}`), signatureBytes, publicKeyBytes); err == nil {
t.Fatal("expected tamper failure")
}
if err := VerifyKeyID([]byte(strings.TrimSuffix(string(signatureBytes), "}")+"}{}"), "publisher-key"); err == nil {
t.Fatal("expected trailing signature failure")
}
duplicate := []byte(`{"algorithm":"ed25519","algorithm":"ed25519","key_id":"publisher-key","signature":""}`)
if err := VerifyKeyID(duplicate, "publisher-key"); err == nil {
t.Fatal("expected duplicate signature key failure")
}
}
func TestCompatibility(t *testing.T) {
m := validManifest()
if got := m.EvaluateCompatibility("sub2api-0.1.183"); !got.Compatible || !got.Tested || got.Status != "compatible" {
t.Fatalf("got %#v", got)
}
m.TestedCoreVersions = nil
if got := m.EvaluateCompatibility("0.1.183"); !got.Compatible || got.Tested || got.Status != "untested" {
t.Fatalf("got %#v", got)
}
if got := m.EvaluateCompatibility("0.1.184"); got.Compatible || got.Status != "incompatible" {
t.Fatalf("got %#v", got)
}
}
File diff suppressed because it is too large Load Diff
+990
View File
@@ -0,0 +1,990 @@
package main
import (
"archive/zip"
"bytes"
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.awaioi.com/awaioi/sub2api-add/plugins/plugin-admin/internal/manifest"
)
func testCore(t *testing.T, handler http.Handler) (*coreClient, *httptest.Server) {
t.Helper()
server := httptest.NewServer(handler)
client, err := newCoreClient(server.URL)
if err != nil {
server.Close()
t.Fatal(err)
}
return client, server
}
func adminSession(a *app) *http.Cookie {
now := time.Now()
id := "session"
a.sessions[id] = session{AccessToken: "ACCESS", RefreshToken: "REFRESH", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin", "email": "admin@example.com"}, CreatedAt: now, LastSeen: now}
a.sessionLocks[id] = &sync.Mutex{}
return &http.Cookie{Name: sessionCookieName, Value: id}
}
func validPackage(t *testing.T, id string) []byte {
return validPackageVersion(t, id, "1.0.0")
}
func validPackageVersion(t *testing.T, id, version string) []byte {
t.Helper()
ui := []byte("<!doctype html><title>plugin</title>")
manifestValue := map[string]any{
"schema_version": 1,
"plugin_id": id,
"name": "Example Plugin",
"version": version,
"core_api_baseline": "sub2api-0.1.183",
"tested_core_versions": []string{"0.1.183"},
"capabilities": []string{"example.v1"},
"backend": map[string]any{"health_path": "/healthz", "readiness_path": "/readyz", "listen_env": "PLUGIN_PORT"},
"ui": map[string]any{"entrypoint": "ui/index.html", "menu": map[string]any{"id": id, "label": "Example", "visibility": "admin", "sort_order": 200}},
"publisher": map[string]any{"key_id": "dev"},
"core_api_allowlist": []string{"POST /api/v1/auth/login", "POST /api/v1/auth/login/2fa", "POST /api/v1/auth/refresh", "POST /api/v1/auth/logout", "GET /api/v1/auth/me", "GET /api/v1/settings/public"},
}
manifestValue["files"] = map[string]string{"ui/index.html": sha256Hex(ui)}
manifestBytes, err := json.Marshal(manifestValue)
if err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for name, data := range map[string][]byte{"manifest.json": manifestBytes, "ui/index.html": ui} {
w, err := zw.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(data); err != nil {
t.Fatal(err)
}
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func signedPackage(t *testing.T, id, version string) ([]byte, ed25519.PublicKey) {
t.Helper()
raw := validPackageVersion(t, id, version)
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
t.Fatal(err)
}
entries := make(map[string][]byte, len(zr.File))
var manifestBytes []byte
for _, file := range zr.File {
reader, openErr := file.Open()
if openErr != nil {
t.Fatal(openErr)
}
data, readErr := io.ReadAll(reader)
_ = reader.Close()
if readErr != nil {
t.Fatal(readErr)
}
entries[file.Name] = data
if file.Name == "manifest.json" {
manifestBytes = data
}
}
publicKey, privateKey, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
signature := manifest.Signature{Algorithm: "ed25519", KeyID: "dev", Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, manifestBytes))}
signatureBytes, err := json.Marshal(signature)
if err != nil {
t.Fatal(err)
}
entries["signature.json"] = signatureBytes
var out bytes.Buffer
zw := zip.NewWriter(&out)
for name, data := range entries {
writer, createErr := zw.Create(name)
if createErr != nil {
t.Fatal(createErr)
}
if _, writeErr := writer.Write(data); writeErr != nil {
t.Fatal(writeErr)
}
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return out.Bytes(), publicKey
}
func uploadRequest(t *testing.T, path string, cookie *http.Cookie, csrf, idempotency string, archive []byte) *http.Request {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("package", "plugin.s2plugin")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(archive); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, path, &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("X-CSRF-Token", csrf)
req.Header.Set("Idempotency-Key", idempotency)
req.AddCookie(cookie)
return req
}
func sha256Hex(value []byte) string {
sum := sha256.Sum256(value)
return hex.EncodeToString(sum[:])
}
func TestAdminLoginDoesNotExposeCoreTokens(t *testing.T) {
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/login":
_, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"CORE_ACCESS","refresh_token":"CORE_REFRESH"}}`)
case "/api/v1/auth/me":
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin","email":"admin@example.com","access_token":"LEAK"}}`)
default:
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
}
}))
defer coreServer.Close()
reg, err := openRegistry(t.TempDir())
if err != nil {
t.Fatal(err)
}
a := newApp(core, reg, t.TempDir())
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"admin@example.com","password":"password"}`))
recorder := httptest.NewRecorder()
a.login(recorder, request)
if recorder.Code != http.StatusOK || strings.Contains(recorder.Body.String(), "CORE_ACCESS") || strings.Contains(recorder.Body.String(), "CORE_REFRESH") || strings.Contains(recorder.Body.String(), "LEAK") {
t.Fatalf("unexpected login response: %d %s", recorder.Code, recorder.Body.String())
}
if len(recorder.Result().Cookies()) != 1 || !recorder.Result().Cookies()[0].HttpOnly {
t.Fatalf("expected HttpOnly plugin cookie: %#v", recorder.Result().Cookies())
}
}
func TestDecodeJSONRejectsTrailingValuesAndOversizeBodies(t *testing.T) {
var input struct {
Name string `json:"name"`
}
trailing := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"plugin"}{}`))
if err := decodeJSON(trailing, &input, 1<<20); err == nil {
t.Fatal("expected concatenated JSON to be rejected")
}
oversized := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"plugin"}`))
if err := decodeJSON(oversized, &input, 4); err == nil {
t.Fatal("expected oversized JSON body to be rejected")
}
}
func TestOrdinaryCoreUserIsRejected(t *testing.T) {
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/v1/auth/login" {
_, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"A","refresh_token":"R"}}`)
return
}
_, _ = io.WriteString(w, `{"code":0,"data":{"id":2,"role":"user"}}`)
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
recorder := httptest.NewRecorder()
a.login(recorder, httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"user@example.com","password":"password"}`)))
if recorder.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestPackageInspectionAndAtomicInstall(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183")
reg, err := openRegistry(t.TempDir())
if err != nil {
t.Fatal(err)
}
a := newApp(nil, reg, filepath.Dir(reg.path))
a.allowUnsigned = true
raw := validPackage(t, "example.plugin")
info, err := a.inspectPackage(raw)
if err != nil {
t.Fatal(err)
}
p, err := a.installPackage(info)
if err != nil {
t.Fatal(err)
}
if p.State != "disabled" || p.ActiveRevision == "" {
t.Fatalf("unexpected installed record: %#v", p)
}
if _, err := os.Stat(filepath.Join(p.Revisions[0].Path, "ui", "index.html")); err != nil {
t.Fatal(err)
}
if _, err := a.inspectPackage(bytes.Replace(raw, []byte("ui/index.html"), []byte("../secret"), 1)); err == nil {
t.Fatal("expected invalid package")
}
}
func TestProductionPackageRequiresTrustedSignature(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183")
reg, _ := openRegistry(t.TempDir())
a := newApp(nil, reg, t.TempDir())
a.allowUnsigned = false
if _, err := a.inspectPackage(validPackage(t, "unsigned.plugin")); err == nil {
t.Fatal("expected unsigned package rejection")
}
raw, publicKey := signedPackage(t, "signed.plugin", "1.0.0")
a.trustedPublishers = map[string][]byte{"dev": publicKey}
if _, err := a.inspectPackage(raw); err != nil {
t.Fatalf("trusted signed package rejected: %v", err)
}
a.trustedPublishers = map[string][]byte{}
if _, err := a.inspectPackage(raw); err == nil {
t.Fatal("expected untrusted publisher rejection")
}
}
func TestDuplicateInstallCannotReplaceActiveRevision(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183")
reg, _ := openRegistry(t.TempDir())
a := newApp(nil, reg, t.TempDir())
a.allowUnsigned = true
first, err := a.installPackage(a.packageForTest(t, "duplicate.plugin", "1.0.0"))
if err != nil {
t.Fatal(err)
}
secondInfo := a.packageForTest(t, "duplicate.plugin", "2.0.0")
if _, err := a.installPackage(secondInfo); err == nil || !strings.Contains(err.Error(), "already installed") {
t.Fatalf("expected duplicate install rejection, got %v", err)
}
reg.mu.Lock()
current := reg.data.Plugins["duplicate.plugin"]
reg.mu.Unlock()
if current.ActiveRevision != first.ActiveRevision || current.Manifest.Version != "1.0.0" {
t.Fatalf("duplicate install replaced active revision: %#v", current)
}
}
func TestConfigIsEncryptedAndSecretsAreNotReturned(t *testing.T) {
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
p := pluginRecord{Manifest: manifest.Manifest{PluginID: "example.plugin", Name: "Example", Version: "1.0.0"}, State: "disabled", Revisions: []revision{}}
reg.data.Plugins[p.Manifest.PluginID] = p
now := time.Now()
a.sessions["sid"] = session{AccessToken: "A", RefreshToken: "R", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: now, LastSeen: now}
a.sessionLocks["sid"] = &sync.Mutex{}
req := httptest.NewRequest(http.MethodPut, "/api/plugins/example.plugin/config", strings.NewReader(`{"service_url":"http://127.0.0.1:18090","client_secret":"TOP-SECRET"}`))
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
req.Header.Set("X-CSRF-Token", "CSRF")
req.Header.Set("Idempotency-Key", "config-1")
rec := httptest.NewRecorder()
a.config(rec, req)
if rec.Code != http.StatusAccepted || strings.Contains(rec.Body.String(), "TOP-SECRET") {
t.Fatalf("config response leaked secret: %d %s", rec.Code, rec.Body.String())
}
reg.mu.Lock()
stored := reg.data.Plugins[p.Manifest.PluginID].ConfigCipher
reg.mu.Unlock()
if stored == "" || strings.Contains(stored, "TOP-SECRET") {
t.Fatalf("config was not encrypted: %q", stored)
}
get := httptest.NewRequest(http.MethodGet, "/api/plugins/example.plugin/config", nil)
get.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
getRec := httptest.NewRecorder()
a.config(getRec, get)
if getRec.Code != http.StatusOK || strings.Contains(getRec.Body.String(), "TOP-SECRET") || !strings.Contains(getRec.Body.String(), "configured") {
t.Fatalf("config metadata response: %d %s", getRec.Code, getRec.Body.String())
}
}
func TestMutationRequiresCSRFAndIdempotency(t *testing.T) {
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
a.sessions["sid"] = session{AccessToken: "A", RefreshToken: "R", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: time.Now(), LastSeen: time.Now()}
a.sessionLocks["sid"] = &sync.Mutex{}
req := httptest.NewRequest(http.MethodPost, "/api/plugins/nope/enable", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
req.Header.Set("Idempotency-Key", "enable-1")
rec := httptest.NewRecorder()
a.enable(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("missing csrf status=%d body=%s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodPost, "/api/plugins/nope/enable", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
req.Header.Set("X-CSRF-Token", "CSRF")
rec = httptest.NewRecorder()
a.enable(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("missing idempotency status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestIdempotencyKeyRejectsDifferentOperationHash(t *testing.T) {
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
a.sessions["sid"] = session{AccessToken: "A", RefreshToken: "R", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: time.Now(), LastSeen: time.Now()}
a.sessionLocks["sid"] = &sync.Mutex{}
first := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/enable", strings.NewReader(`{"payload":"a"}`))
first.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
first.Header.Set("X-CSRF-Token", "CSRF")
first.Header.Set("Idempotency-Key", "same-key")
op, _, ok := a.mutationAuth(httptest.NewRecorder(), first, "enable", "example.plugin")
if !ok || op.ID == "" {
t.Fatal("first operation was not allocated")
}
second := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/enable", strings.NewReader(`{"payload":"b"}`))
second.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
second.Header.Set("X-CSRF-Token", "CSRF")
second.Header.Set("Idempotency-Key", "same-key")
rec := httptest.NewRecorder()
_, _, ok = a.mutationAuth(rec, second, "enable", "example.plugin")
if ok || rec.Code != http.StatusConflict {
t.Fatalf("expected idempotency conflict: status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestIdempotencyKeyReplaysSameBodyAndRetainsFailedOperation(t *testing.T) {
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
a.sessions["sid"] = session{AccessToken: "A", RefreshToken: "R", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: time.Now(), LastSeen: time.Now()}
a.sessionLocks["sid"] = &sync.Mutex{}
newRequest := func() *http.Request {
req := httptest.NewRequest(http.MethodPut, "/api/plugins/example.plugin/config", strings.NewReader(`{"service_url":"http://127.0.0.1:18090"}`))
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
req.Header.Set("X-CSRF-Token", "CSRF")
req.Header.Set("Idempotency-Key", "config-same")
return req
}
first, _, ok := a.mutationAuth(httptest.NewRecorder(), newRequest(), "config", "example.plugin")
if !ok {
t.Fatal("first operation was not allocated")
}
if _, err := a.registry.finishOperation(first, errors.New("expected failure")); err != nil {
t.Fatal(err)
}
secondRecorder := httptest.NewRecorder()
_, _, ok = a.mutationAuth(secondRecorder, newRequest(), "config", "example.plugin")
if ok || secondRecorder.Code != http.StatusAccepted || !strings.Contains(secondRecorder.Body.String(), first.ID) || !strings.Contains(secondRecorder.Body.String(), `"failed"`) {
t.Fatalf("expected failed operation replay: status=%d body=%s", secondRecorder.Code, secondRecorder.Body.String())
}
}
func TestRefreshRevalidatesAdminRole(t *testing.T) {
var meCalls int
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/me":
meCalls++
if meCalls == 1 {
w.WriteHeader(http.StatusUnauthorized)
_, _ = io.WriteString(w, `{"code":401,"message":"expired"}`)
return
}
_, _ = io.WriteString(w, `{"code":0,"data":{"id":2,"role":"user"}}`)
case "/api/v1/auth/refresh":
_, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"NEW","refresh_token":"NEW-R"}}`)
case "/api/v1/auth/logout":
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
default:
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
}
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
now := time.Now()
a.sessions["sid"] = session{AccessToken: "OLD", RefreshToken: "R", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: now, LastSeen: now}
a.sessionLocks["sid"] = &sync.Mutex{}
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
rec := httptest.NewRecorder()
a.me(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected demoted user rejection: status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestHealthProbeRejectsRedirectAndRequiresReadiness(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" {
http.Redirect(w, r, "http://127.0.0.1:1/internal", http.StatusFound)
return
}
_, _ = io.WriteString(w, `{"status":"ready","version":"1.0.0"}`)
}))
defer server.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(nil, reg, t.TempDir())
p := pluginRecord{Manifest: manifest.Manifest{PluginID: "example.plugin", Version: "1.0.0", Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz"}}, Endpoint: server.URL}
if err := a.checkPluginHealth(&p); err == nil {
t.Fatal("expected redirecting health endpoint to fail closed")
}
}
func TestRoutesSetSecurityHeadersAndProtectAPI(t *testing.T) {
reg, _ := openRegistry(t.TempDir())
a := newApp(nil, reg, t.TempDir())
server := httptest.NewServer(a.routes())
defer server.Close()
response, err := server.Client().Get(server.URL + "/api/plugins")
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusUnauthorized || response.Header.Get("Content-Security-Policy") == "" || response.Header.Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("status=%d headers=%v", response.StatusCode, response.Header)
}
}
func TestMenuPreviewAndApplyPreserveOtherMenuItems(t *testing.T) {
var applied []byte
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/me":
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
case "/api/v1/admin/settings":
if r.Method == http.MethodPut {
applied, _ = io.ReadAll(r.Body)
}
_, _ = io.WriteString(w, `{"code":0,"data":{"custom_menu_items":[{"id":"core.home","label":"首页"}]}}`)
default:
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
}
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
a.sessions["sid"] = session{AccessToken: "ACCESS", RefreshToken: "REFRESH", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: time.Now(), LastSeen: time.Now()}
a.sessionLocks["sid"] = &sync.Mutex{}
menuURL := "http://127.0.0.1:18091"
reg.data.Plugins["example.plugin"] = pluginRecord{Manifest: manifest.Manifest{PluginID: "example.plugin", Name: "Example", Version: "1.0.0", UI: manifest.UI{Entrypoint: "ui/index.html", Menu: manifest.Menu{ID: "example.plugin", Label: "示例插件", Visibility: "admin", SortOrder: 200, URL: menuURL}}}, State: "healthy", ActiveRevision: "rev-1"}
cookie := &http.Cookie{Name: sessionCookieName, Value: "sid"}
preview := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/menu-preview", nil)
preview.AddCookie(cookie)
preview.Header.Set("X-CSRF-Token", "CSRF")
preview.Header.Set("Idempotency-Key", "menu-preview-1")
previewRec := httptest.NewRecorder()
a.menu(previewRec, preview, false)
if previewRec.Code != http.StatusOK || !strings.Contains(previewRec.Body.String(), "core.home") || !strings.Contains(previewRec.Body.String(), "example.plugin") {
t.Fatalf("unexpected menu preview: %d %s", previewRec.Code, previewRec.Body.String())
}
global := httptest.NewRequest(http.MethodPost, "/api/menu-items/preview", strings.NewReader(`{"plugin_id":"example.plugin"}`))
global.AddCookie(cookie)
global.Header.Set("X-CSRF-Token", "CSRF")
global.Header.Set("Idempotency-Key", "global-menu-preview-1")
globalRec := httptest.NewRecorder()
a.menuGlobal(globalRec, global, false)
if globalRec.Code != http.StatusOK || !strings.Contains(globalRec.Body.String(), "example.plugin") {
t.Fatalf("unexpected global menu preview: %d %s", globalRec.Code, globalRec.Body.String())
}
apply := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/menu-apply", nil)
apply.AddCookie(cookie)
apply.Header.Set("X-CSRF-Token", "CSRF")
apply.Header.Set("Idempotency-Key", "menu-apply-1")
applyRec := httptest.NewRecorder()
a.menu(applyRec, apply, true)
if applyRec.Code != http.StatusAccepted || len(applied) == 0 || !strings.Contains(string(applied), "core.home") || !strings.Contains(string(applied), "example.plugin") {
t.Fatalf("unexpected menu apply: %d body=%s request=%s", applyRec.Code, applyRec.Body.String(), applied)
}
}
func TestFailedUpgradeKeepsActiveRevision(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183")
pluginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/healthz" || r.URL.Path == "/readyz" {
_, _ = io.WriteString(w, `{"status":"ok","version":"1.0.0"}`)
return
}
http.NotFound(w, r)
}))
defer pluginServer.Close()
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
a.allowUnsigned = true
old, err := a.installPackage(a.packageForTest(t, "example.plugin", "1.0.0"))
if err != nil {
t.Fatal(err)
}
old.Endpoint = pluginServer.URL
old.State = "healthy"
reg.mu.Lock()
reg.data.Plugins["example.plugin"] = old
if err := reg.saveLocked(); err != nil {
reg.mu.Unlock()
t.Fatal(err)
}
reg.mu.Unlock()
a.sessions["sid"] = session{AccessToken: "ACCESS", RefreshToken: "REFRESH", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: time.Now(), LastSeen: time.Now()}
a.sessionLocks["sid"] = &sync.Mutex{}
req := uploadRequest(t, "/api/plugins/example.plugin/upgrade", &http.Cookie{Name: sessionCookieName, Value: "sid"}, "CSRF", "upgrade-1", validPackageVersion(t, "example.plugin", "2.0.0"))
rec := httptest.NewRecorder()
a.install(rec, req, true, "example.plugin")
if rec.Code != http.StatusAccepted || !strings.Contains(rec.Body.String(), "operation_id") {
t.Fatalf("unexpected upgrade response: %d %s", rec.Code, rec.Body.String())
}
reg.mu.Lock()
current := reg.data.Plugins["example.plugin"]
reg.mu.Unlock()
if current.ActiveRevision != old.ActiveRevision || current.PendingRevision == "" || current.State != "rollback_pending" || current.Manifest.Version != "1.0.0" {
t.Fatalf("active revision changed after failed upgrade: %#v", current)
}
pendingID := current.PendingRevision
var pendingPath string
for _, rev := range current.Revisions {
if rev.ID == pendingID {
pendingPath = rev.Path
}
}
if pendingPath == "" {
t.Fatal("failed upgrade did not retain pending revision path")
}
rollback := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/rollback", nil)
rollback.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
rollback.Header.Set("X-CSRF-Token", "CSRF")
rollback.Header.Set("Idempotency-Key", "rollback-after-failure")
rollbackRec := httptest.NewRecorder()
a.rollback(rollbackRec, rollback)
if rollbackRec.Code != http.StatusAccepted {
t.Fatalf("rollback failed: %d %s", rollbackRec.Code, rollbackRec.Body.String())
}
reg.mu.Lock()
restored := reg.data.Plugins["example.plugin"]
reg.mu.Unlock()
if restored.ActiveRevision != old.ActiveRevision || restored.PendingRevision != "" || restored.State != "healthy" || restored.Manifest.Version != "1.0.0" {
t.Fatalf("failed-upgrade rollback did not restore old revision: %#v", restored)
}
var retired bool
for _, rev := range restored.Revisions {
if rev.ID == pendingID {
retired = rev.Retired
}
}
if !retired {
t.Fatal("failed candidate was not marked retired")
}
if _, err := os.Stat(pendingPath); err != nil {
t.Fatalf("retired candidate path was removed before registry commit: %v", err)
}
}
func TestLifecycleEnableDisableUninstall(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183")
var applied []byte
pluginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/healthz" || r.URL.Path == "/readyz" {
_, _ = io.WriteString(w, `{"status":"ok","version":"1.0.0"}`)
return
}
http.NotFound(w, r)
}))
defer pluginServer.Close()
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/me":
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
case "/api/v1/admin/settings":
if r.Method == http.MethodPut {
applied, _ = io.ReadAll(r.Body)
}
_, _ = io.WriteString(w, `{"code":0,"data":{"custom_menu_items":[{"id":"core.home","label":"首页"},{"id":"example.plugin","label":"示例"}]}}`)
default:
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
}
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
a.allowUnsigned = true
p, err := a.installPackage(a.packageForTest(t, "example.plugin", "1.0.0"))
if err != nil {
t.Fatal(err)
}
p.Endpoint = pluginServer.URL
reg.mu.Lock()
reg.data.Plugins[p.Manifest.PluginID] = p
reg.mu.Unlock()
cookie := adminSession(a)
enable := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/enable", nil)
enable.AddCookie(cookie)
enable.Header.Set("X-CSRF-Token", "CSRF")
enable.Header.Set("Idempotency-Key", "enable-lifecycle")
enableRec := httptest.NewRecorder()
a.enable(enableRec, enable)
if enableRec.Code != http.StatusAccepted {
t.Fatalf("enable failed: %d %s", enableRec.Code, enableRec.Body.String())
}
reg.mu.Lock()
if reg.data.Plugins["example.plugin"].State != "healthy" {
t.Fatalf("plugin did not become healthy: %#v", reg.data.Plugins["example.plugin"])
}
reg.mu.Unlock()
disable := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/disable", nil)
disable.AddCookie(cookie)
disable.Header.Set("X-CSRF-Token", "CSRF")
disable.Header.Set("Idempotency-Key", "disable-lifecycle")
disableRec := httptest.NewRecorder()
a.disable(disableRec, disable)
if disableRec.Code != http.StatusAccepted || strings.Contains(string(applied), "example.plugin") {
t.Fatalf("disable did not remove own menu: %d body=%s request=%s", disableRec.Code, disableRec.Body.String(), applied)
}
uninstall := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/uninstall", nil)
uninstall.AddCookie(cookie)
uninstall.Header.Set("X-CSRF-Token", "CSRF")
uninstall.Header.Set("Idempotency-Key", "uninstall-lifecycle")
uninstallRec := httptest.NewRecorder()
a.uninstall(uninstallRec, uninstall)
if uninstallRec.Code != http.StatusAccepted {
t.Fatalf("uninstall failed: %d %s", uninstallRec.Code, uninstallRec.Body.String())
}
reg.mu.Lock()
_, exists := reg.data.Plugins["example.plugin"]
reg.mu.Unlock()
if exists {
t.Fatal("plugin remained in registry after uninstall")
}
}
func TestUninstallRegistryFailureRestoresRevisionPath(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183")
root := t.TempDir()
registryDir := t.TempDir()
reg, err := openRegistry(registryDir)
if err != nil {
t.Fatal(err)
}
pluginID := "restore.plugin"
revisionPath := filepath.Join(root, "installed", pluginID, "rev-1")
if err := os.MkdirAll(revisionPath, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(revisionPath, "marker"), []byte("keep"), 0o600); err != nil {
t.Fatal(err)
}
reg.data.Plugins[pluginID] = pluginRecord{
Manifest: manifest.Manifest{
PluginID: pluginID,
Name: "Restore",
Version: "1.0.0",
CoreAPIBaseline: "sub2api-0.1.183",
TestedCoreVersions: []string{"0.1.183"},
Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz", ListenEnv: "PLUGIN_PORT"},
UI: manifest.UI{Entrypoint: "ui/index.html", Menu: manifest.Menu{ID: pluginID, Label: "Restore", Visibility: "admin", SortOrder: 200, URL: "http://127.0.0.1:8090"}},
},
State: "disabled",
ActiveRevision: "rev-1",
Revisions: []revision{{ID: "rev-1", Version: "1.0.0", Path: revisionPath}},
UpdatedAt: time.Now().UTC(),
}
if err := reg.save(); err != nil {
t.Fatal(err)
}
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/me":
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
case "/api/v1/admin/settings":
if r.Method == http.MethodPut {
// Force the lifecycle registry commit to fail after the menu
// update, exercising path restoration as well as record rollback.
reg.path = t.TempDir()
}
_, _ = io.WriteString(w, `{"code":0,"data":{"custom_menu_items":[{"id":"`+pluginID+`","label":"Restore"}]}}`)
default:
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
}
}))
defer coreServer.Close()
a := newApp(core, reg, root)
cookie := adminSession(a)
req := httptest.NewRequest(http.MethodPost, "/api/plugins/"+pluginID+"/uninstall", nil)
req.AddCookie(cookie)
req.Header.Set("X-CSRF-Token", "CSRF")
req.Header.Set("Idempotency-Key", "uninstall-restore")
rec := httptest.NewRecorder()
a.uninstall(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected persistence failure, got %d body=%s", rec.Code, rec.Body.String())
}
reg.mu.Lock()
restored, exists := reg.data.Plugins[pluginID]
reg.mu.Unlock()
if !exists || len(restored.Revisions) != 1 || restored.Revisions[0].Path != revisionPath {
t.Fatalf("registry record was not restored: exists=%v record=%#v", exists, restored)
}
if _, err := os.Stat(filepath.Join(revisionPath, "marker")); err != nil {
t.Fatalf("revision path was not restored: %v", err)
}
}
func TestPluginLockSerializesLifecycleMutations(t *testing.T) {
reg, _ := openRegistry(t.TempDir())
a := newApp(nil, reg, t.TempDir())
firstEntered := make(chan struct{})
release := make(chan struct{})
secondEntered := make(chan struct{})
go func() {
unlock := a.lockPlugin("example.plugin")
close(firstEntered)
<-release
unlock()
}()
<-firstEntered
go func() {
unlock := a.lockPlugin("example.plugin")
close(secondEntered)
unlock()
}()
select {
case <-secondEntered:
t.Fatal("second plugin mutation acquired the lock concurrently")
case <-time.After(25 * time.Millisecond):
}
close(release)
select {
case <-secondEntered:
case <-time.After(time.Second):
t.Fatal("second plugin mutation did not proceed after release")
}
}
func TestRecoverExternalPluginAfterRestart(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/healthz" && r.URL.Path != "/readyz" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"status":"ok","version":"1.0.0"}`)
}))
defer server.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(nil, reg, t.TempDir())
p := pluginRecord{Manifest: manifest.Manifest{PluginID: "external.plugin", Name: "External", Version: "1.0.0", CoreAPIBaseline: "sub2api-0.1.183", TestedCoreVersions: []string{"0.1.183"}, Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz"}}, State: "healthy", ActiveRevision: "rev-1", Endpoint: server.URL, Revisions: []revision{{ID: "rev-1", Version: "1.0.0", Manifest: manifest.Manifest{PluginID: "external.plugin", Name: "External", Version: "1.0.0", CoreAPIBaseline: "sub2api-0.1.183", TestedCoreVersions: []string{"0.1.183"}, Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz"}}}}}
reg.data.Plugins[p.Manifest.PluginID] = p
if err := reg.save(); err != nil {
t.Fatal(err)
}
if err := a.recoverPlugins(); err != nil {
t.Fatal(err)
}
reg.mu.Lock()
recovered := reg.data.Plugins[p.Manifest.PluginID]
reg.mu.Unlock()
if recovered.State != "healthy" || recovered.Endpoint != server.URL || recovered.LastError != "" {
t.Fatalf("external plugin was not recovered: %#v", recovered)
}
}
func TestRecoverCommandPluginAfterRestart(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183")
if _, err := os.Stat("/bin/sh"); err != nil {
t.Skip("shell is unavailable")
}
root := t.TempDir()
pluginDir := filepath.Join(root, "installed", "command.plugin", "rev-1")
if err := os.MkdirAll(filepath.Join(pluginDir, "service"), 0o700); err != nil {
t.Fatal(err)
}
// The helper is a tiny Python HTTP server available in the local test
// environment; it binds the supervisor-provided loopback port.
command := filepath.Join(pluginDir, "service", "run.py")
source := "#!/usr/bin/env python3\nimport http.server, os\nclass H(http.server.BaseHTTPRequestHandler):\n def do_GET(self):\n if self.path in ('/healthz','/readyz'):\n body=b'{\\\"status\\\":\\\"ok\\\",\\\"version\\\":\\\"1.0.0\\\"}'\n self.send_response(200); self.send_header('Content-Type','application/json'); self.send_header('Content-Length',str(len(body))); self.end_headers(); self.wfile.write(body)\n else: self.send_response(404); self.end_headers()\n def log_message(self,*args): pass\nhttp.server.HTTPServer(('127.0.0.1', int(os.environ['PLUGIN_PORT'])), H).serve_forever()\n"
if err := os.WriteFile(command, []byte(source), 0o700); err != nil {
t.Fatal(err)
}
pythonPath, err := exec.LookPath("python3")
if err != nil {
t.Skip("python3 is unavailable")
}
source = strings.Replace(source, "#!/usr/bin/env python3", "#!"+pythonPath, 1)
reg, _ := openRegistry(filepath.Join(root, "registry"))
pluginManifest := manifest.Manifest{PluginID: "command.plugin", Name: "Command", Version: "1.0.0", CoreAPIBaseline: "sub2api-0.1.183", TestedCoreVersions: []string{"0.1.183"}, Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz", Command: "service/run.py", ListenEnv: "PLUGIN_PORT"}}
reg.data.Plugins[pluginManifest.PluginID] = pluginRecord{Manifest: pluginManifest, State: "healthy", ActiveRevision: "rev-1", Revisions: []revision{{ID: "rev-1", Version: "1.0.0", Path: pluginDir, Manifest: pluginManifest}}}
if err := reg.save(); err != nil {
t.Fatal(err)
}
a := newApp(nil, reg, root)
if err := a.recoverPlugins(); err != nil {
t.Fatal(err)
}
reg.mu.Lock()
recovered := reg.data.Plugins[pluginManifest.PluginID]
reg.mu.Unlock()
if recovered.State != "healthy" || !strings.HasPrefix(recovered.Endpoint, "http://127.0.0.1:") {
t.Fatalf("command plugin was not recovered: %#v", recovered)
}
a.stopPlugin(pluginManifest.PluginID)
}
func TestRegistryPersistenceErrorsAreObservable(t *testing.T) {
reg, _ := openRegistry(t.TempDir())
reg.path = t.TempDir()
if _, _, _, err := reg.operation("enable", "example.plugin", "key", 1, "rid", "hash"); err == nil {
t.Fatal("expected operation persistence error")
}
if len(reg.data.Operations) != 0 {
t.Fatal("failed operation allocation remained in memory")
}
reg.path = filepath.Join(t.TempDir(), "registry.json")
op, _, _, err := reg.operation("enable", "example.plugin", "key", 1, "rid", "hash")
if err != nil {
t.Fatal(err)
}
reg.path = t.TempDir()
if _, err := reg.finishOperation(op, nil); err == nil {
t.Fatal("expected operation finish persistence error")
}
reg.data.Audit = nil
if err := reg.addAudit(auditEvent{Action: "test"}); err == nil {
t.Fatal("expected audit persistence error")
}
}
func TestUpgradeDoesNotCarryEndpointAcrossServiceModes(t *testing.T) {
reg, _ := openRegistry(t.TempDir())
a := newApp(nil, reg, t.TempDir())
base := manifest.Manifest{PluginID: "mode.plugin", Name: "Mode", Version: "1.0.0", CoreAPIBaseline: "sub2api-0.1.183", TestedCoreVersions: []string{"0.1.183"}, Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz", ListenEnv: "PLUGIN_PORT", Command: "service/plugin"}}
old := pluginRecord{Manifest: base, State: "disabled", ActiveRevision: "old", Endpoint: "http://127.0.0.1:59001", Revisions: []revision{{ID: "old", Version: "1.0.0", Manifest: base}}}
reg.data.Plugins[base.PluginID] = old
newManifest := base
newManifest.Version = "2.0.0"
newManifest.Backend.Command = ""
newInfo := packageInfo{Manifest: newManifest, Archive: []byte("external"), Files: map[string][]byte{"ui/index.html": []byte("<title>mode</title>")}}
newManifest.Files = map[string]string{"ui/index.html": sha256Hex(newInfo.Files["ui/index.html"])}
newInfo.Manifest = newManifest
upgraded, err := a.installPackageMode(newInfo, true)
if err != nil {
t.Fatal(err)
}
if upgraded.Endpoint != "" {
t.Fatalf("command endpoint leaked into external revision: %q", upgraded.Endpoint)
}
externalBase := base
externalBase.Backend.Command = ""
externalBase.Version = "2.0.0"
reg.data.Plugins[base.PluginID] = pluginRecord{Manifest: externalBase, State: "disabled", ActiveRevision: "external", Endpoint: "http://127.0.0.1:59001", Revisions: []revision{{ID: "external", Version: "2.0.0", Manifest: externalBase}}}
commandManifest := newManifest
commandManifest.Version = "3.0.0"
commandManifest.Backend.Command = "service/plugin"
commandInfo := packageInfo{Manifest: commandManifest, Archive: []byte("command"), Files: map[string][]byte{"service/plugin": []byte("#!/bin/sh\nexit 0"), "ui/index.html": []byte("<title>mode</title>")}}
commandManifest.Files = map[string]string{"service/plugin": sha256Hex(commandInfo.Files["service/plugin"]), "ui/index.html": sha256Hex(commandInfo.Files["ui/index.html"])}
commandInfo.Manifest = commandManifest
commandUpgraded, err := a.installPackageMode(commandInfo, true)
if err != nil {
t.Fatal(err)
}
if commandUpgraded.Endpoint != "" {
t.Fatalf("external endpoint leaked into command revision: %q", commandUpgraded.Endpoint)
}
}
func TestTwoFactorLoginCreatesAdminSession(t *testing.T) {
var login2FACalled bool
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/login":
_, _ = io.WriteString(w, `{"code":0,"data":{"requires_2fa":true,"temp_token":"TEMP"}}`)
case "/api/v1/auth/login/2fa":
login2FACalled = true
_, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"ACCESS","refresh_token":"REFRESH"}}`)
case "/api/v1/auth/me":
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin","email":"admin@example.com"}}`)
case "/api/v1/auth/logout":
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
default:
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
}
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
loginRec := httptest.NewRecorder()
a.login(loginRec, httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"admin@example.com","password":"password"}`)))
if loginRec.Code != http.StatusOK || !strings.Contains(loginRec.Body.String(), "pending_token") {
t.Fatalf("expected 2fa challenge: %d %s", loginRec.Code, loginRec.Body.String())
}
var challenge struct {
PendingToken string `json:"pending_token"`
}
if err := json.Unmarshal(loginRec.Body.Bytes(), &challenge); err != nil || challenge.PendingToken == "" {
t.Fatalf("challenge token missing: %s", loginRec.Body.String())
}
body := fmt.Sprintf(`{"pending_token":%q,"totp_code":"123456"}`, challenge.PendingToken)
verifyRec := httptest.NewRecorder()
a.login2FA(verifyRec, httptest.NewRequest(http.MethodPost, "/login/2fa", strings.NewReader(body)))
if verifyRec.Code != http.StatusOK || !login2FACalled || len(verifyRec.Result().Cookies()) != 1 {
t.Fatalf("2fa login failed: %d %s", verifyRec.Code, verifyRec.Body.String())
}
}
func (a *app) packageForTest(t *testing.T, id, version string) packageInfo {
t.Helper()
raw := validPackageVersion(t, id, version)
info, err := a.inspectPackage(raw)
if err != nil {
t.Fatal(err)
}
return info
}
@@ -0,0 +1,40 @@
import { chromium } from 'playwright'
import fs from 'node:fs/promises'
import path from 'node:path'
const origin = process.env.PLUGIN_BROWSER_ORIGIN || 'http://127.0.0.1:18090'
const entry = `${origin}/admin/`
const outputDir = path.resolve(process.env.PLUGIN_SCREENSHOT_DIR || '.playwright-cli/plugin-admin')
const email = process.env.PLUGIN_TEST_EMAIL || 'admin@example.com'
const password = process.env.PLUGIN_TEST_PASSWORD || 'password'
await fs.mkdir(outputDir, { recursive: true })
const browser = await chromium.launch({ headless: true })
try {
for (const width of [425, 900, 1440]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, deviceScaleFactor: 1 })
const responseLeaks = []
page.on('response', async (response) => {
if (!response.headers()['content-type']?.includes('application/json')) return
try {
const body = await response.text()
if (/access_token|refresh_token|admin[_-]?api[_-]?key|password|client_secret/i.test(body)) responseLeaks.push(response.url())
} catch (_) {}
})
await page.goto(entry, { waitUntil: 'networkidle' })
await page.getByLabel('邮箱').fill(email)
await page.getByLabel('密码').fill(password)
await page.getByRole('button', { name: '登录' }).click()
await page.getByRole('heading', { name: '已登记插件' }).waitFor()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)
if (overflow) throw new Error(`horizontal overflow at ${width}px`)
const buttons = await page.locator('button, .file-button').evaluateAll((items) => items.filter((item) => item.getClientRects().length > 0 && getComputedStyle(item).visibility !== 'hidden').every((item) => item.getBoundingClientRect().height >= 28 && item.getBoundingClientRect().width >= 28))
if (!buttons) throw new Error(`control collapsed at ${width}px`)
await page.waitForTimeout(50)
if (responseLeaks.length) throw new Error(`sensitive response field exposed at ${width}px: ${responseLeaks.join(', ')}`)
await page.screenshot({ path: path.join(outputDir, `plugin-admin-${width}.png`), fullPage: true })
await page.close()
}
} finally {
await browser.close()
}
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
PLUGIN_BROWSER_ORIGIN=${PLUGIN_BROWSER_ORIGIN:-http://127.0.0.1:18090} \
PLUGIN_SCREENSHOT_DIR=${PLUGIN_SCREENSHOT_DIR:-$ROOT/.screenshots/plugin-admin} \
node "$ROOT/test/browser-check.mjs"
+65
View File
@@ -0,0 +1,65 @@
(() => {
'use strict'
const base = (window.__PLUGIN_BASE_PATH__ || '').replace(/\/$/, '')
const $ = (selector) => document.querySelector(selector)
let csrf = ''
let pendingToken = ''
let configPluginId = ''
const notice = (message, error = false) => {
const el = $('#notice'); el.textContent = message || ''; el.className = error ? 'notice error' : 'notice'
}
const api = async (path, options = {}) => {
const headers = new Headers(options.headers || {})
headers.set('Accept', 'application/json')
if (options.body && !(options.body instanceof FormData)) headers.set('Content-Type', 'application/json')
if (csrf && options.method && options.method !== 'GET') headers.set('X-CSRF-Token', csrf)
const response = await fetch(`${base}${path}`, { ...options, headers, credentials: 'same-origin' })
const data = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(data.error || `请求失败 (${response.status})`)
return data
}
const setLoggedIn = (user) => {
$('#login-panel').hidden = !!user
$('#app-panel').hidden = !user
$('#logout').hidden = !user
$('#operator').textContent = user ? (user.email || user.username || '管理员') : ''
}
const login = async (event) => {
event.preventDefault(); $('#login-error').textContent = ''
const body = Object.fromEntries(new FormData(event.currentTarget).entries())
try {
const result = await api('/login', { method: 'POST', body: JSON.stringify(body) })
if (result.requires_2fa) { pendingToken = result.pending_token; $('#login-form').hidden = true; $('#twofa-form').hidden = false; return }
csrf = result.csrf_token; setLoggedIn(result.user); await loadAll()
} catch (error) { $('#login-error').textContent = error.message }
}
const login2fa = async (event) => {
event.preventDefault(); $('#login-error').textContent = ''
const body = Object.fromEntries(new FormData(event.currentTarget).entries()); body.pending_token = pendingToken
try { const result = await api('/login/2fa', { method: 'POST', body: JSON.stringify(body) }); csrf = result.csrf_token; setLoggedIn(result.user); await loadAll() } catch (error) { $('#login-error').textContent = error.message }
}
const logout = async () => { try { await api('/logout', { method: 'POST' }) } catch (_) {} csrf = ''; setLoggedIn(null); $('#login-form').hidden = false; $('#twofa-form').hidden = true }
const badge = (state) => `<span class="badge badge-${String(state || '').replace(/[^a-z_]/g, '')}">${escapeHtml(state || 'unknown')}</span>`
const escapeHtml = (value) => String(value == null ? '' : value).replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]))
const loadPlugins = async () => {
const data = await api('/api/plugins'); const root = $('#plugins'); root.textContent = ''
if (!data.items || !data.items.length) { root.innerHTML = '<div class="empty">还没有登记业务插件</div>'; return }
for (const plugin of data.items) {
const card = document.createElement('article'); card.className = 'plugin-card'
card.innerHTML = `<div class="plugin-title"><div><h3>${escapeHtml(plugin.name)}</h3><p class="muted mono">${escapeHtml(plugin.plugin_id)} · v${escapeHtml(plugin.version)}</p></div>${badge(plugin.state)}</div><dl class="meta"><div><dt>能力</dt><dd>${(plugin.capabilities || []).map(escapeHtml).join(', ') || '未声明'}</dd></div><div><dt>活动 revision</dt><dd class="mono">${escapeHtml(plugin.active_revision || '-')}</dd></div><div><dt>Core 兼容性</dt><dd>${escapeHtml((plugin.compatibility || {}).status || 'unknown')}</dd></div></dl><p class="plugin-error">${escapeHtml(plugin.last_error || '')}</p><div class="card-actions"><button data-action="config" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">配置</button><label class="file-button">升级<input data-action="upgrade-file" data-id="${escapeHtml(plugin.plugin_id)}" type="file" accept=".zip,.s2plugin,application/zip"></label><button data-action="enable" data-id="${escapeHtml(plugin.plugin_id)}" class="button" ${plugin.state === 'healthy' ? 'disabled' : ''}>启用</button><button data-action="disable" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary" ${plugin.state !== 'healthy' ? 'disabled' : ''}>停用</button><button data-action="rollback" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">回滚</button><button data-action="menu-preview" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">菜单预览</button><button data-action="menu-apply" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">应用菜单</button><button data-action="uninstall" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-danger" ${plugin.state === 'healthy' ? 'disabled' : ''}>卸载</button></div>`
root.appendChild(card)
}
}
const loadAudit = async () => { const data = await api('/api/audit'); const root = $('#audit'); root.textContent = ''; for (const item of (data.items || []).slice().reverse()) { const row = document.createElement('div'); row.className = 'audit-row'; row.innerHTML = `<span>${escapeHtml(item.action)}</span><span class="mono">${escapeHtml(item.plugin_id || '-')}</span><span>${escapeHtml(item.result)}</span><time>${escapeHtml(item.time)}</time>`; root.appendChild(row) } }
const loadAll = async () => { try { await Promise.all([loadPlugins(), loadAudit()]); notice('') } catch (error) { notice(error.message, true) } }
const mutate = async (action, id) => { const key = `${action}-${id}-${Date.now()}`; try { const result = await api(`/api/plugins/${encodeURIComponent(id)}/${action}`, { method: 'POST', headers: { 'Idempotency-Key': key } }); notice(`操作已提交:${result.operation_id || result.state}`); await loadAll() } catch (error) { notice(error.message, true) } }
const openConfig = async (id) => { configPluginId = id; $('#config-plugin').textContent = id; $('#config-error').textContent = ''; const form = $('#config-form'); form.elements.service_url.value = ''; form.elements.public_url.value = ''; try { const data = await api(`/api/plugins/${encodeURIComponent(id)}/config`); form.elements.service_url.value = data.endpoint || ''; if (data.config && typeof data.config.public_url === 'string') form.elements.public_url.value = data.config.public_url; $('#config-dialog').showModal() } catch (error) { notice(error.message, true) } }
const saveConfig = async (event) => { event.preventDefault(); const form = event.currentTarget; const body = { service_url: form.elements.service_url.value.trim(), public_url: form.elements.public_url.value.trim() }; try { const result = await api(`/api/plugins/${encodeURIComponent(configPluginId)}/config`, { method: 'PUT', headers: { 'Idempotency-Key': `config-${configPluginId}-${Date.now()}` }, body: JSON.stringify(body) }); $('#config-dialog').close(); notice(`配置已保存:${result.operation_id || result.state}`); await loadAll() } catch (error) { $('#config-error').textContent = error.message } }
const upload = async (file) => { const form = new FormData(); form.append('package', file); try { const result = await api('/api/plugins/install', { method: 'POST', headers: { 'Idempotency-Key': `install-${Date.now()}` }, body: form }); notice(`安装已提交:${result.operation_id || result.state}`); await loadAll() } catch (error) { notice(error.message, true) } }
const uploadUpgrade = async (id, file) => { const form = new FormData(); form.append('package', file); try { const result = await api(`/api/plugins/${encodeURIComponent(id)}/upgrade`, { method: 'POST', headers: { 'Idempotency-Key': `upgrade-${id}-${Date.now()}` }, body: form }); notice(`升级已提交:${result.operation_id || result.state}`); await loadAll() } catch (error) { notice(error.message, true) } }
$('#login-form').addEventListener('submit', login); $('#twofa-form').addEventListener('submit', login2fa); $('#logout').addEventListener('click', logout); $('#refresh').addEventListener('click', loadAll); $('#audit-refresh').addEventListener('click', loadAudit); $('#config-form').addEventListener('submit', saveConfig); $('#config-close').addEventListener('click', () => $('#config-dialog').close()); $('#config-cancel').addEventListener('click', () => $('#config-dialog').close())
$('#package-file').addEventListener('change', (event) => { const file = event.target.files[0]; if (file) upload(file); event.target.value = '' })
$('#plugins').addEventListener('change', (event) => { const input = event.target.closest('[data-action="upgrade-file"]'); if (!input) return; const file = input.files[0]; if (file) uploadUpgrade(input.dataset.id, file); input.value = '' })
$('#plugins').addEventListener('click', (event) => { const button = event.target.closest('[data-action]'); if (!button || button.disabled) return; const action = button.dataset.action; const id = button.dataset.id; if (action === 'config') { openConfig(id); return } mutate(action, id) })
api('/api/me').then((data) => { csrf = data.csrf_token; setLoggedIn(data.user); loadAll() }).catch(() => setLoggedIn(null))
})()
+71
View File
@@ -0,0 +1,71 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>插件管理</title>
<link rel="stylesheet" href="../styles.css">
</head>
<body>
<main class="shell">
<header class="topbar">
<div>
<p class="eyebrow">SUB2API EXTENSIONS</p>
<h1>插件管理</h1>
<p class="muted">独立业务插件控制面</p>
</div>
<div class="top-actions">
<span id="operator" class="operator"></span>
<button id="logout" class="button button-quiet" hidden>退出</button>
</div>
</header>
<section id="login-panel" class="panel auth-panel">
<h2>管理员登录</h2>
<p class="muted">使用 Sub2API Core 管理员账号登录。普通账号没有访问权限。</p>
<form id="login-form" class="form-grid">
<label>邮箱<input name="email" type="email" autocomplete="username" required></label>
<label>密码<input name="password" type="password" autocomplete="current-password" required></label>
<button class="button" type="submit">登录</button>
</form>
<form id="twofa-form" class="form-grid" hidden>
<label>验证码<input name="totp_code" inputmode="numeric" maxlength="6" pattern="[0-9]{6}" required></label>
<button class="button" type="submit">验证并继续</button>
</form>
<p id="login-error" class="error" role="alert"></p>
</section>
<section id="app-panel" hidden>
<div class="toolbar">
<div>
<h2>已登记插件</h2>
<p class="muted">安装包会先验签、校验哈希和 Core 兼容性,再进入停用状态。</p>
</div>
<div class="toolbar-actions">
<label class="file-button">安装插件<input id="package-file" type="file" accept=".zip,.s2plugin,application/zip"></label>
<button id="refresh" class="button button-secondary" type="button">刷新</button>
</div>
</div>
<p id="notice" class="notice" role="status"></p>
<div id="plugins" class="plugin-list"></div>
<section class="panel audit-panel">
<div class="section-heading"><h2>操作审计</h2><button id="audit-refresh" class="button button-quiet" type="button">刷新</button></div>
<div id="audit" class="audit-list"></div>
</section>
</section>
</main>
<dialog id="config-dialog" class="config-dialog">
<form id="config-form" method="dialog" class="config-form">
<div class="section-heading"><h2>插件配置</h2><button id="config-close" class="button button-quiet" type="button">关闭</button></div>
<p id="config-plugin" class="muted mono"></p>
<label>服务地址<input name="service_url" type="url" placeholder="http://127.0.0.1:18090" required></label>
<label>菜单地址<input name="public_url" type="url" placeholder="https://CORE_ORIGIN/extensions/PLUGIN_ID/"></label>
<p class="muted">密钥只在服务端加密保存,页面不会回显原值。</p>
<div class="dialog-actions"><button id="config-cancel" class="button button-secondary" type="button">取消</button><button class="button" type="submit">保存配置</button></div>
<p id="config-error" class="error" role="alert"></p>
</form>
</dialog>
<script>window.__PLUGIN_BASE_PATH__ = __PLUGIN_BASE_PATH_JSON__;</script>
<script src="../app.js" defer></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #17202a; background: #f4f6f8; }
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; min-width: 320px; }
.shell { width: min(1120px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 56px; }
.topbar, .toolbar, .section-heading, .plugin-title, .card-actions, .top-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.topbar { padding-bottom: 24px; border-bottom: 1px solid #d9dee5; }
.eyebrow { color: #5a6877; font-size: 11px; letter-spacing: .12em; margin: 0 0 6px; }
h1, h2, h3, p { margin-top: 0; } h1 { font-size: 28px; margin-bottom: 4px; } h2 { font-size: 18px; margin-bottom: 8px; } h3 { margin-bottom: 4px; font-size: 17px; }
.muted { color: #687585; font-size: 13px; } .operator { color: #475569; font-size: 13px; }
.panel { background: #fff; border: 1px solid #d9dee5; border-radius: 6px; box-shadow: 0 2px 8px rgb(15 23 42 / 4%); }
.auth-panel { max-width: 480px; margin: 48px auto 0; padding: 24px; }
.form-grid { display: grid; gap: 14px; margin-top: 20px; } label { display: grid; gap: 6px; color: #334155; font-size: 13px; }
input { width: 100%; height: 36px; padding: 0 10px; border: 1px solid #c7d0da; border-radius: 4px; background: #fff; color: inherit; font: inherit; } input:focus { outline: 2px solid #a7c7ff; outline-offset: 1px; }
.button, .file-button { display: inline-flex; align-items: center; justify-content: center; min-height: 36px; padding: 0 14px; border: 1px solid #2563eb; border-radius: 4px; background: #2563eb; color: #fff; cursor: pointer; font: inherit; font-size: 13px; white-space: nowrap; }
.button:hover { background: #1d4ed8; } .button:disabled { opacity: .45; cursor: not-allowed; } .button-secondary { background: #fff; color: #1e40af; border-color: #b9c8dc; } .button-secondary:hover, .button-quiet:hover { background: #f3f6fa; } .button-quiet { background: transparent; color: #334155; border-color: transparent; } .button-danger { background: #fff; color: #b42318; border-color: #e1aaa4; }
.file-button input { display: none; }
#app-panel { margin-top: 32px; } .toolbar { margin-bottom: 18px; } .toolbar-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.notice { min-height: 20px; margin: 8px 0 14px; font-size: 13px; color: #17603a; } .error { color: #b42318; }
.plugin-list { display: grid; gap: 12px; }
.plugin-card { background: #fff; border: 1px solid #d9dee5; border-radius: 6px; padding: 18px; } .plugin-title { align-items: flex-start; } .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; }
.badge { display: inline-flex; padding: 4px 8px; border-radius: 999px; color: #334155; background: #e8edf2; font-size: 12px; } .badge-healthy { background: #d9f6e5; color: #146c43; } .badge-error, .badge-incompatible { background: #fde1df; color: #9b1c16; } .badge-starting, .badge-draining { background: #fff0c2; color: #7a4d00; }
.meta { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin: 18px 0 12px; } .meta div { min-width: 0; } dt { color: #687585; font-size: 12px; margin-bottom: 4px; } dd { margin: 0; overflow-wrap: anywhere; font-size: 13px; } .plugin-error { min-height: 18px; margin-bottom: 12px; font-size: 12px; color: #b42318; }
.card-actions { justify-content: flex-start; flex-wrap: wrap; } .empty { padding: 32px; text-align: center; color: #687585; border: 1px dashed #c7d0da; border-radius: 6px; background: #fff; }
.audit-panel { margin-top: 24px; padding: 18px; } .audit-list { display: grid; gap: 1px; } .audit-row { display: grid; grid-template-columns: 1.2fr 1.3fr .8fr 1.7fr; gap: 10px; padding: 9px 0; border-top: 1px solid #edf0f3; font-size: 12px; overflow-wrap: anywhere; }
.config-dialog { width: min(460px, calc(100% - 24px)); padding: 0; border: 0; border-radius: 6px; box-shadow: 0 18px 60px rgb(15 23 42 / 24%); } .config-dialog::backdrop { background: rgb(15 23 42 / 42%); } .config-form { display: grid; gap: 14px; padding: 22px; } .config-form .section-heading { margin-bottom: 4px; } .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
@media (max-width: 640px) { .shell { width: min(100% - 20px, 560px); padding-top: 20px; } .topbar, .toolbar { align-items: flex-start; flex-direction: column; } .top-actions { width: 100%; justify-content: space-between; } .meta { grid-template-columns: 1fr; gap: 9px; } .card-actions .button, .toolbar-actions .button, .file-button { flex: 1 1 130px; } .audit-row { grid-template-columns: 1fr 1fr; } }
+16
View File
@@ -0,0 +1,16 @@
CORE_BASE_URL=http://127.0.0.1:8080
PLUGIN_HOST=127.0.0.1
PLUGIN_PORT=8091
# Optional public path when mounted behind a reverse proxy, e.g.
# /extensions/qiu.subscription-admin
PLUGIN_PUBLIC_BASE_PATH=
# Limit the session cookie to the plugin mount path in production.
PLUGIN_COOKIE_PATH=
# Set true only behind HTTPS. Non-loopback listeners fail closed when false.
PLUGIN_COOKIE_SECURE=false
# lax (same-site proxy), strict, or none (cross-site iframe; requires Secure).
PLUGIN_COOKIE_SAMESITE=lax
# Space-separated frame ancestors. Keep 'self' for same-origin proxying.
PLUGIN_FRAME_ANCESTORS='self'
# Set true only when the immediate reverse proxy is trusted and supplies XFF.
PLUGIN_TRUST_PROXY=false
+14
View File
@@ -0,0 +1,14 @@
.PHONY: test build package check
test:
go test ./... -count=1
build:
./build.sh
package:
./package.sh
check: test
node --check ui/app.js
sh -n package.sh
+97
View File
@@ -0,0 +1,97 @@
# Sub2API Subscription Admin Business Plugin V1
这是一个独立运行的管理员只读业务插件,不是现有 `.s2plugin` transport 插件,也不是插件管理后台。它不导入 Sub2API `internal` 包,不连接 Core 数据库,也不修改 Core Go/Vue、迁移、路由或 `.s2plugin` ABI。
生产/集成环境由通用 `plugins/plugin-admin` 控制面安装、启用和升级本插件;本插件不会预装,也不会成为控制面首页。只有健康检查通过并由管理员执行菜单预览/应用后,Core 管理员菜单才会出现“订阅管理”入口。直接运行本目录仅用于本地开发和契约测试。
## 本地启动
```sh
CORE_BASE_URL=http://127.0.0.1:8080 \
PLUGIN_HOST=127.0.0.1 \
PLUGIN_PORT=8091 \
go run .
```
打开 `http://127.0.0.1:8091/admin/`。生产环境应通过 HTTPS 反向代理,并设置 `PLUGIN_COOKIE_SECURE=true`。挂载到子路径时同时设置 `PLUGIN_PUBLIC_BASE_PATH` 和 `PLUGIN_COOKIE_PATH`,例如 `/extensions/qiu.subscription-admin`。
## V1 范围
- Core 管理员账号登录和 Core 2FA;普通账号统一拒绝。
- 插件 HttpOnly、SameSite 会话和写请求 CSRF 校验。
- Core token 只保存在插件服务端内存会话中,不进入浏览器、URL、HTML、LocalStorage、响应或日志。
- 只读套餐、订阅列表、订阅详情和插件操作记录。
- Core access token 失效时最多刷新一次;刷新失败会销毁插件会话。
- 页面刷新会从插件会话恢复,并重新取得短期 CSRF token;服务端不会把 Core token 返回浏览器。
- 登录会读取 Core 公开验证码配置并透传 Turnstile、腾讯、阿里云或 GeeTest 的验证结果;验证码本身仍由 Core 校验。
- 余额购买、续费、撤销、退款和外部支付不在 V1,页面不渲染提交按钮。
## Core API allowlist
插件服务端仅调用这些明确路径:
```text
POST /api/v1/auth/login
POST /api/v1/auth/login/2fa
POST /api/v1/auth/refresh
POST /api/v1/auth/logout
GET /api/v1/auth/me
GET /api/v1/settings/public
GET /api/v1/admin/payment/plans
GET /api/v1/admin/subscriptions
GET /api/v1/admin/subscriptions/{id}
GET /api/v1/admin/users/{id}
GET /api/v1/admin/users/{id}/subscriptions
```
列表请求只接受 `page`、`page_size`、`limit`、`user_id`、`group_id`、`status`、`platform`、`sort_by`、`sort_order` 参数。插件不会代理任意 URL,也不会调用尚不存在的 `/api/v1/plugin-host/*`。
## Core 菜单与反向代理
控制面会依据清单中的菜单声明生成下面的 `custom_menu_items` 项;部署时无需手工写入订阅菜单:
```json
{
"id": "qiu.subscription-admin",
"label": "订阅管理",
"url": "https://CORE_ORIGIN/extensions/qiu.subscription-admin/",
"visibility": "admin",
"sort_order": 200
}
```
Core 自定义页面的 sandbox iframe 不会继承 Core `localStorage` 登录态,因此 V1 首屏显示插件登录页是预期行为;同时提供新窗口入口。不要把 JWT 放进 URL。
## 测试
```sh
go test ./... -count=1
node --check ui/app.js
```
`main_test.go` 覆盖 Core 路径 allowlist、查询参数过滤、管理员角色拒绝、插件 Cookie、Core token 不泄露、会话过期、请求 ID、登录限流和 2FA pending 一次性消费。浏览器验收脚本位于 `test/browser-check.mjs`,可使用本地 Mock Core 验证直连、子路径反代和三种视口。
## 生成可安装包
```sh
./package.sh
```
脚本生成 `dist/qiu.subscription-admin.s2plugin`,包内根文件名为
`manifest.json`,并包含清单声明哈希的 UI 文件。该插件采用外部服务模式:
安装后先独立启动 `subscription-admin`,再在 `plugin-admin` 的配置中填写
`service_url`(插件 loopback 地址)和 `public_url`(反向代理地址),然后执行
启用、健康检查和菜单应用。生产环境必须把签名文件通过
`SIGNATURE_FILE=/path/to/signature.json ./package.sh` 放入包内,并将对应公钥
加入控制面受信发布者配置;未签名包仅限 development + loopback。
## 清单和发布
`business-plugin-manifest.v1.json` 是部署层清单,不由 Core 读取。生产发布应由独立 CI 签名并校验清单、版本、健康路径和兼容的 Core 版本;不要把发布私钥放入仓库或插件包。插件版本独立于 `backend/cmd/server/VERSION`。
## 已知限制
- V1 使用内存会话,服务重启会要求重新登录;多实例部署需将会话存储替换为插件自有 Redis/共享会话服务。
- 现有 Core 自定义 iframe 没有 token handoff,V1 不提供无感 SSO;真正 SSO 需要单独的 V1.1 Core 交接接口。
- Core 当前套餐响应中的 `features` 可能是 JSON 字符串,UI 会兼容字符串和数组。
- Core 开启验证码时,管理员必须先完成对应提供商的挑战并将结果填入登录表单;插件不保存验证码票据。
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
mkdir -p "$ROOT/bin"
CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o "$ROOT/bin/subscription-admin" "$ROOT"
@@ -0,0 +1,44 @@
{
"schema_version": 1,
"plugin_id": "qiu.subscription-admin",
"name": "Subscription Admin",
"version": "0.1.1",
"core_api_baseline": "sub2api-0.1.183",
"capabilities": ["subscription.admin.v1"],
"tested_core_versions": ["0.1.183"],
"backend": {
"health_path": "/healthz",
"readiness_path": "/readyz",
"listen_env": "PLUGIN_PORT"
},
"ui": {
"entrypoint": "ui/index.html",
"menu": {
"id": "qiu.subscription-admin",
"label": "订阅管理",
"visibility": "admin",
"sort_order": 200
}
},
"publisher": {
"key_id": "qiu-subscription-admin-dev"
},
"core_api_allowlist": [
"POST /api/v1/auth/login",
"POST /api/v1/auth/login/2fa",
"POST /api/v1/auth/refresh",
"POST /api/v1/auth/logout",
"GET /api/v1/auth/me",
"GET /api/v1/settings/public",
"GET /api/v1/admin/payment/plans",
"GET /api/v1/admin/subscriptions",
"GET /api/v1/admin/subscriptions/{id}",
"GET /api/v1/admin/users/{id}",
"GET /api/v1/admin/users/{id}/subscriptions"
],
"files": {
"ui/index.html": "a189f81675f1bf7820111123221d8056111c86ca104fad2906e961fad6ef4697",
"ui/app.js": "51896358caa769c1fc6353613b92d02bbdd340a24dca567b4f86fcaf1f5f36ca",
"ui/styles.css": "d96dff24fa6f7d96a977f5d8f09986cedb987aad1bb26177b9bf4d7b5982ae54"
}
}
@@ -0,0 +1,12 @@
# Keep the plugin service private on loopback; expose it behind HTTPS.
CORE_ORIGIN {
handle_path /extensions/qiu.subscription-admin/* {
reverse_proxy 127.0.0.1:8091
}
}
# Start the plugin with:
# PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.subscription-admin
# PLUGIN_COOKIE_PATH=/extensions/qiu.subscription-admin/
# PLUGIN_COOKIE_SECURE=true
# PLUGIN_FRAME_ANCESTORS='self'
@@ -0,0 +1,7 @@
{
"id": "qiu.subscription-admin",
"label": "订阅管理",
"url": "https://CORE_ORIGIN/extensions/qiu.subscription-admin/",
"visibility": "admin",
"sort_order": 200
}
@@ -0,0 +1,17 @@
# Deploy beside the Core reverse proxy. Keep the plugin bound to loopback.
location /extensions/qiu.subscription-admin/ {
proxy_pass http://127.0.0.1:8091/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
}
# Start the plugin with:
# PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.subscription-admin
# PLUGIN_COOKIE_PATH=/extensions/qiu.subscription-admin/
# PLUGIN_COOKIE_SECURE=true
# PLUGIN_FRAME_ANCESTORS='self'
@@ -0,0 +1,22 @@
[Unit]
Description=Sub2API Subscription Admin business plugin
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=sub2api-plugin
Group=sub2api-plugin
WorkingDirectory=/opt/sub2api/subscription-admin
EnvironmentFile=/etc/sub2api/subscription-admin.env
ExecStart=/opt/sub2api/subscription-admin/bin/subscription-admin
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/sub2api/subscription-admin
[Install]
WantedBy=multi-user.target
+3
View File
@@ -0,0 +1,3 @@
module git.awaioi.com/awaioi/sub2api-add/plugins/subscription-admin
go 1.23
@@ -0,0 +1,204 @@
package manifest
import (
"crypto/ed25519"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"regexp"
"sort"
"strings"
)
var pluginIDPattern = regexp.MustCompile(`^[a-z0-9]+([._-][a-z0-9]+)+$`)
var versionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`)
var requiredAllowlist = []string{
"POST /api/v1/auth/login",
"POST /api/v1/auth/login/2fa",
"POST /api/v1/auth/refresh",
"POST /api/v1/auth/logout",
"GET /api/v1/auth/me",
"GET /api/v1/settings/public",
"GET /api/v1/admin/payment/plans",
"GET /api/v1/admin/subscriptions",
"GET /api/v1/admin/subscriptions/{id}",
"GET /api/v1/admin/users/{id}",
"GET /api/v1/admin/users/{id}/subscriptions",
}
type Manifest struct {
SchemaVersion int `json:"schema_version"`
PluginID string `json:"plugin_id"`
Name string `json:"name"`
Version string `json:"version"`
CoreAPIBaseline string `json:"core_api_baseline"`
Capabilities []string `json:"capabilities"`
TestedCoreVersions []string `json:"tested_core_versions"`
Backend struct {
HealthPath string `json:"health_path"`
ReadinessPath string `json:"readiness_path"`
ListenEnv string `json:"listen_env"`
} `json:"backend"`
UI struct {
Entrypoint string `json:"entrypoint"`
Menu struct {
ID string `json:"id"`
Label string `json:"label"`
Visibility string `json:"visibility"`
SortOrder int `json:"sort_order"`
} `json:"menu"`
} `json:"ui"`
Publisher struct {
KeyID string `json:"key_id"`
} `json:"publisher"`
CoreAPIAllowlist []string `json:"core_api_allowlist"`
Files map[string]string `json:"files,omitempty"`
}
type Signature struct {
Algorithm string `json:"algorithm"`
KeyID string `json:"key_id"`
Signature string `json:"signature"`
}
func Load(path string) (Manifest, []byte, error) {
raw, err := os.ReadFile(path)
if err != nil {
return Manifest{}, nil, err
}
var m Manifest
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
if err := dec.Decode(&m); err != nil {
return Manifest{}, nil, fmt.Errorf("decode manifest: %w", err)
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
if err == nil {
return Manifest{}, nil, errors.New("manifest contains trailing JSON")
}
return Manifest{}, nil, fmt.Errorf("decode manifest trailing data: %w", err)
}
if err := Validate(m); err != nil {
return Manifest{}, nil, err
}
return m, raw, nil
}
func Validate(m Manifest) error {
if m.SchemaVersion != 1 {
return fmt.Errorf("schema_version must be 1")
}
if !pluginIDPattern.MatchString(m.PluginID) {
return fmt.Errorf("invalid plugin_id")
}
if strings.TrimSpace(m.Name) == "" || len(m.Name) > 160 {
return fmt.Errorf("name is required and must be at most 160 characters")
}
if !versionPattern.MatchString(strings.TrimPrefix(m.Version, "v")) {
return fmt.Errorf("invalid plugin version")
}
baseline := strings.TrimPrefix(m.CoreAPIBaseline, "sub2api-")
if !versionPattern.MatchString(strings.TrimPrefix(baseline, "v")) {
return fmt.Errorf("invalid core_api_baseline")
}
if len(m.Capabilities) != 1 || m.Capabilities[0] != "subscription.admin.v1" {
return fmt.Errorf("capabilities must contain subscription.admin.v1 only")
}
for _, version := range m.TestedCoreVersions {
if !versionPattern.MatchString(strings.TrimPrefix(version, "v")) {
return fmt.Errorf("invalid tested_core_versions entry")
}
}
if m.Backend.HealthPath != "/healthz" || m.Backend.ReadinessPath != "/readyz" || m.Backend.ListenEnv != "PLUGIN_PORT" {
return fmt.Errorf("backend health_path/listen_env do not match V1 contract")
}
if (m.UI.Entrypoint != "/admin" && m.UI.Entrypoint != "/admin/" && m.UI.Entrypoint != "ui/index.html") || m.UI.Menu.ID != m.PluginID || strings.TrimSpace(m.UI.Menu.Label) == "" || m.UI.Menu.Visibility != "admin" || m.UI.Menu.SortOrder < 0 || m.Publisher.KeyID == "" {
return fmt.Errorf("ui.entrypoint/menu and publisher.key_id are required")
}
if len(m.CoreAPIAllowlist) != len(requiredAllowlist) {
return fmt.Errorf("core_api_allowlist must contain exactly %d entries", len(requiredAllowlist))
}
seen := make(map[string]struct{}, len(m.CoreAPIAllowlist))
for _, entry := range m.CoreAPIAllowlist {
if _, ok := seen[entry]; ok {
return fmt.Errorf("duplicate allowlist entry: %s", entry)
}
seen[entry] = struct{}{}
}
for _, required := range requiredAllowlist {
if _, ok := seen[required]; !ok {
return fmt.Errorf("missing allowlist entry: %s", required)
}
}
for file, hash := range m.Files {
if strings.TrimSpace(file) == "" || strings.HasPrefix(strings.ReplaceAll(file, "\\", "/"), "/") || strings.Contains(strings.ReplaceAll(file, "\\", "/"), "..") {
return fmt.Errorf("invalid file declaration: %s", file)
}
if _, err := hex.DecodeString(hash); err != nil || len(hash) != 64 {
return fmt.Errorf("invalid file hash declaration: %s", file)
}
}
return nil
}
// DeclaredFiles returns file paths in deterministic order for package tooling.
func DeclaredFiles(m Manifest) []string {
files := make([]string, 0, len(m.Files))
for file := range m.Files {
files = append(files, file)
}
sort.Strings(files)
return files
}
func VerifySignature(manifestBytes, signatureBytes, publicKeyBytes []byte) error {
var signature Signature
dec := json.NewDecoder(strings.NewReader(string(signatureBytes)))
dec.DisallowUnknownFields()
if err := dec.Decode(&signature); err != nil {
return fmt.Errorf("decode signature: %w", err)
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
if err == nil {
return errors.New("signature contains trailing JSON")
}
return fmt.Errorf("decode signature trailing data: %w", err)
}
if signature.Algorithm != "ed25519" || signature.KeyID == "" {
return errors.New("signature must use ed25519 and include key_id")
}
publicKey, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(publicKeyBytes)))
if err != nil || len(publicKey) != ed25519.PublicKeySize {
return errors.New("invalid base64 ed25519 public key")
}
sig, err := base64.StdEncoding.DecodeString(signature.Signature)
if err != nil || len(sig) != ed25519.SignatureSize {
return errors.New("invalid base64 ed25519 signature")
}
if !ed25519.Verify(ed25519.PublicKey(publicKey), manifestBytes, sig) {
return errors.New("manifest signature verification failed")
}
return nil
}
func VerifyKeyID(signatureBytes []byte, expectedKeyID string) error {
var signature Signature
if err := json.Unmarshal(signatureBytes, &signature); err != nil {
return fmt.Errorf("decode signature: %w", err)
}
if strings.TrimSpace(expectedKeyID) == "" || signature.KeyID != expectedKeyID {
return errors.New("signature key_id does not match manifest publisher")
}
return nil
}
func RequiredAllowlist() []string {
return append([]string(nil), requiredAllowlist...)
}
@@ -0,0 +1,62 @@
package manifest
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"testing"
)
func validManifest() Manifest {
var m Manifest
m.SchemaVersion = 1
m.PluginID = "qiu.subscription-admin"
m.Name = "Subscription Admin"
m.Version = "0.1.0"
m.CoreAPIBaseline = "sub2api-0.1.183"
m.Capabilities = []string{"subscription.admin.v1"}
m.TestedCoreVersions = []string{"0.1.183"}
m.Backend.HealthPath = "/healthz"
m.Backend.ReadinessPath = "/readyz"
m.Backend.ListenEnv = "PLUGIN_PORT"
m.UI.Entrypoint = "ui/index.html"
m.UI.Menu.ID = m.PluginID
m.UI.Menu.Label = "订阅管理"
m.UI.Menu.Visibility = "admin"
m.UI.Menu.SortOrder = 200
m.Publisher.KeyID = "test-key"
m.CoreAPIAllowlist = RequiredAllowlist()
return m
}
func TestValidateManifest(t *testing.T) {
if err := Validate(validManifest()); err != nil {
t.Fatal(err)
}
m := validManifest()
m.CoreAPIAllowlist = append(m.CoreAPIAllowlist, "GET /api/v1/admin/users")
if err := Validate(m); err == nil {
t.Fatal("expected exact allowlist validation failure")
}
}
func TestVerifySignature(t *testing.T) {
publicKey, privateKey, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
manifestBytes := []byte(`{"schema_version":1}`)
signature := Signature{Algorithm: "ed25519", KeyID: "test-key", Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, manifestBytes))}
signatureBytes, err := json.Marshal(signature)
if err != nil {
t.Fatal(err)
}
publicKeyBytes := []byte(base64.StdEncoding.EncodeToString(publicKey))
if err := VerifySignature(manifestBytes, signatureBytes, publicKeyBytes); err != nil {
t.Fatal(err)
}
manifestBytes[0] = '{'
if err := VerifySignature([]byte(`{"schema_version":2}`), signatureBytes, publicKeyBytes); err == nil {
t.Fatal("expected tamper failure")
}
}
File diff suppressed because it is too large Load Diff
+369
View File
@@ -0,0 +1,369 @@
package main
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func testCoreClient(t *testing.T, handler http.Handler) *coreClient {
t.Helper()
ts := httptest.NewServer(handler)
t.Cleanup(ts.Close)
c, err := newCoreClient(ts.URL)
if err != nil {
t.Fatal(err)
}
return c
}
func TestCoreClientAllowlistAndRequestID(t *testing.T) {
var gotPath, gotAuth, gotRequestID string
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotAuth, gotRequestID = r.URL.RequestURI(), r.Header.Get("Authorization"), r.Header.Get("X-Request-Id")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"role":"admin"}}`))
}))
if _, err := c.read(context.Background(), "/api/v1/admin/subscriptions?page=1&evil=ignored", "CORE-TOKEN"); err != nil {
t.Fatal(err)
}
if gotPath != "/api/v1/admin/subscriptions?page=1" {
t.Fatalf("path=%q", gotPath)
}
if gotAuth != "Bearer CORE-TOKEN" || gotRequestID == "" {
t.Fatalf("headers auth=%q request_id=%q", gotAuth, gotRequestID)
}
if _, err := c.read(context.Background(), "/api/v1/admin/payment/plans/1", "TOKEN"); err == nil {
t.Fatal("unexpected allowlist success")
}
}
func TestCoreReadQueryIsSanitized(t *testing.T) {
var gotPath string
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.RequestURI()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":[]}`))
}))
if _, err := c.read(context.Background(), "/api/v1/admin/subscriptions?page=1&evil=ignored", "TOKEN"); err != nil {
t.Fatal(err)
}
if gotPath != "/api/v1/admin/subscriptions?page=1" {
t.Fatalf("sanitized path=%q", gotPath)
}
}
func TestNewCoreClientRejectsNonAbsoluteOrQueryURL(t *testing.T) {
for _, base := range []string{"", "/api", "ftp://core", "https://core.test/?token=secret", "http://core.test", "https://user:pass@core.test"} {
if _, err := newCoreClient(base); err == nil {
t.Fatalf("expected invalid Core URL: %q", base)
}
}
for _, base := range []string{"http://127.0.0.1:8080", "http://[::1]:8080", "http://localhost:8080"} {
if _, err := newCoreClient(base); err != nil {
t.Fatalf("expected loopback URL to be accepted: %q: %v", base, err)
}
}
}
func TestCoreClientRejectsNonzeroEnvelopeCode(t *testing.T) {
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":422,"message":"bad","data":{}}`))
}))
if _, err := c.read(context.Background(), "/api/v1/admin/subscriptions", "TOKEN"); err == nil {
t.Fatal("expected nonzero Core envelope to fail")
}
}
func TestLoginRequiresAdminAndDoesNotReturnCoreToken(t *testing.T) {
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/login":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"CORE-TOKEN","refresh_token":"CORE-REFRESH"}}`))
case "/api/v1/auth/me":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":7,"role":"user","email":"user@example.com"}}`))
case "/api/v1/auth/logout":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
default:
t.Errorf("unexpected Core path %s", r.URL.Path)
}
}))
a := newApp(c, false)
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"user@example.com","password":"password"}`))
rec := httptest.NewRecorder()
a.login(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "CORE-TOKEN") || strings.Contains(rec.Body.String(), "CORE-REFRESH") {
t.Fatalf("core token leaked: %s", rec.Body.String())
}
}
func TestLoginAndReadProxyUsePluginCookie(t *testing.T) {
var readAuth string
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/login":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"CORE-TOKEN","refresh_token":"CORE-REFRESH"}}`))
case "/api/v1/auth/me":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"role":"admin","email":"admin@example.com"}}`))
case "/api/v1/admin/subscriptions":
readAuth = r.Header.Get("Authorization")
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"items":[],"total":0,"page":1,"page_size":20,"pages":1}}`))
default:
t.Errorf("unexpected Core path %s", r.URL.Path)
}
}))
a := newApp(c, false)
loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"admin@example.com","password":"password"}`))
loginRec := httptest.NewRecorder()
a.login(loginRec, loginReq)
if loginRec.Code != http.StatusOK || strings.Contains(loginRec.Body.String(), "CORE-TOKEN") {
t.Fatalf("login status/body: %d %s", loginRec.Code, loginRec.Body.String())
}
cookie := loginRec.Result().Cookies()[0]
readReq := httptest.NewRequest(http.MethodGet, "/api/subscriptions?page=1", nil)
readReq.AddCookie(cookie)
readRec := httptest.NewRecorder()
a.readProxy("/api/v1/admin/subscriptions")(readRec, readReq)
if readRec.Code != http.StatusOK || readAuth != "Bearer CORE-TOKEN" {
t.Fatalf("read status=%d auth=%q body=%s", readRec.Code, readAuth, readRec.Body.String())
}
}
func TestReadProxyStripsSensitiveCoreFields(t *testing.T) {
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/me":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"role":"admin"}}`))
case "/api/v1/admin/subscriptions":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"LEAK","items":[{"refresh_token":"LEAK2","id":1}]}}`))
default:
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
}
}))
a := newApp(c, false)
a.sessions["sid"] = session{accessToken: "TOKEN", csrfToken: "CSRF", createdAt: time.Now(), lastSeen: time.Now(), user: map[string]any{"id": 1}}
req := httptest.NewRequest(http.MethodGet, "/api/subscriptions", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
rec := httptest.NewRecorder()
a.readProxy("/api/v1/admin/subscriptions")(rec, req)
if strings.Contains(rec.Body.String(), "LEAK") || strings.Contains(rec.Body.String(), "refresh_token") {
t.Fatalf("sensitive field leaked: %s", rec.Body.String())
}
}
func TestCoreRevocationDestroysPluginSession(t *testing.T) {
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/v1/auth/me" {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"code":401,"message":"revoked"}`))
return
}
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
}))
a := newApp(c, false)
now := time.Now()
a.sessions["sid"] = session{accessToken: "TOKEN", csrfToken: "CSRF", createdAt: now, lastSeen: now, user: map[string]any{"id": 1}}
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
rec := httptest.NewRecorder()
a.me(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if _, ok := a.sessions["sid"]; ok {
t.Fatal("revoked Core session remained in plugin store")
}
}
func TestLogoutRevokesCoreRefreshToken(t *testing.T) {
var logoutCalls int32
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/v1/auth/logout" {
atomic.AddInt32(&logoutCalls, 1)
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
if body["refresh_token"] != "REFRESH" {
t.Errorf("refresh token=%q", body["refresh_token"])
}
}
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
}))
a := newApp(c, false)
a.sessions["sid"] = session{accessToken: "TOKEN", refreshToken: "REFRESH", csrfToken: "CSRF", createdAt: time.Now(), lastSeen: time.Now(), user: map[string]any{"id": 1}}
a.sessionLocks["sid"] = &sync.Mutex{}
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
req.Header.Set("X-CSRF-Token", "CSRF")
rec := httptest.NewRecorder()
a.logout(rec, req)
if rec.Code != http.StatusOK || atomic.LoadInt32(&logoutCalls) != 1 {
t.Fatalf("status=%d logout_calls=%d body=%s", rec.Code, logoutCalls, rec.Body.String())
}
}
func TestReadProxyRefreshesAtMostOncePerRequest(t *testing.T) {
var refreshCalls int32
var meCalls int32
var readCalls int32
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/me":
call := atomic.AddInt32(&meCalls, 1)
if call == 1 {
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"role":"admin"}}`))
} else {
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"role":"admin"}}`))
}
case "/api/v1/admin/subscriptions":
call := atomic.AddInt32(&readCalls, 1)
if call == 1 {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"code":401,"message":"expired"}`))
} else {
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"items":[]}}`))
}
case "/api/v1/auth/refresh":
atomic.AddInt32(&refreshCalls, 1)
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"NEW","refresh_token":"NEW-REFRESH"}}`))
default:
t.Errorf("unexpected Core path %s", r.URL.Path)
}
}))
a := newApp(c, false)
now := time.Now()
a.sessions["sid"] = session{accessToken: "TOKEN", refreshToken: "REFRESH", csrfToken: "CSRF", createdAt: now, lastSeen: now, user: map[string]any{"id": 1}}
a.sessionLocks["sid"] = &sync.Mutex{}
req := httptest.NewRequest(http.MethodGet, "/api/subscriptions", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
rec := httptest.NewRecorder()
a.readProxy("/api/v1/admin/subscriptions")(rec, req)
if rec.Code != http.StatusOK || atomic.LoadInt32(&refreshCalls) != 1 {
t.Fatalf("status=%d refresh_calls=%d body=%s", rec.Code, refreshCalls, rec.Body.String())
}
}
func TestSessionExpiry(t *testing.T) {
now := time.Now()
a := newApp(nil, false)
a.clock = func() time.Time { return now }
a.sessions["sid"] = session{accessToken: "TOKEN", csrfToken: "CSRF", createdAt: now, lastSeen: now.Add(-sessionTTL - time.Second), user: map[string]any{"id": 1}}
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
rec := httptest.NewRecorder()
a.me(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestTwoFactorPendingTokenIsSingleUse(t *testing.T) {
now := time.Now()
a := newApp(nil, false)
a.clock = func() time.Time { return now }
a.pending["pending"] = pendingLogin{tempToken: "CORE-TEMP", expires: now.Add(time.Minute)}
first := a.pending["pending"]
delete(a.pending, "pending")
if _, ok := a.pending["pending"]; ok || first.tempToken != "CORE-TEMP" {
t.Fatal("pending token was not consumed")
}
}
func TestAllowedReadPathRejectsTraversalAndUnknownRoutes(t *testing.T) {
for _, path := range []string{
"/api/v1/admin/subscriptions/1/progress",
"/api/v1/admin/users/1/subscriptions/extra",
"/api/v1/admin/payment/plans/1",
"/api/v1/admin/../users",
} {
if allowedReadPath(path) {
t.Fatalf("unexpected allowlist match: %s", path)
}
}
}
func TestRoutesProtectReadOnlyEndpointsAndSetSecurityHeaders(t *testing.T) {
a := newApp(nil, false)
server := httptest.NewServer(a.routes())
t.Cleanup(server.Close)
response, err := server.Client().Get(server.URL + "/api/plans")
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusUnauthorized {
t.Fatalf("status=%d", response.StatusCode)
}
if response.Header.Get("Content-Security-Policy") == "" || response.Header.Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("security headers missing: %#v", response.Header)
}
}
func TestRoutesPropagateRequestIDAndBootstrapSession(t *testing.T) {
var coreRequestID string
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
coreRequestID = r.Header.Get(requestIDHeader)
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/login":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"A","refresh_token":"R"}}`))
case "/api/v1/auth/me":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"role":"admin","email":"a@example.com"}}`))
case "/api/v1/auth/logout":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
default:
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
}
}))
a := newApp(c, false)
server := httptest.NewServer(a.routes())
t.Cleanup(server.Close)
request, _ := http.NewRequest(http.MethodPost, server.URL+"/login", strings.NewReader(`{"email":"a@example.com","password":"password"}`))
request.Header.Set(requestIDHeader, "client-request-123")
request.Header.Set("Content-Type", "application/json")
response, err := server.Client().Do(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusOK || response.Header.Get(requestIDHeader) != "client-request-123" || coreRequestID != "client-request-123" {
t.Fatalf("status=%d response_id=%q core_id=%q", response.StatusCode, response.Header.Get(requestIDHeader), coreRequestID)
}
cookies := response.Cookies()
if len(cookies) != 1 || !cookies[0].HttpOnly || cookies[0].SameSite != http.SameSiteLaxMode {
t.Fatalf("cookie=%#v", cookies)
}
bootstrap, _ := http.NewRequest(http.MethodGet, server.URL+"/api/me", nil)
bootstrap.AddCookie(cookies[0])
bootstrapResponse, err := server.Client().Do(bootstrap)
if err != nil {
t.Fatal(err)
}
if bootstrapResponse.StatusCode != http.StatusOK || !strings.Contains(readBody(t, bootstrapResponse), "csrf_token") {
t.Fatalf("bootstrap status=%d", bootstrapResponse.StatusCode)
}
}
func readBody(t *testing.T, response *http.Response) string {
t.Helper()
defer response.Body.Close()
data, err := io.ReadAll(response.Body)
if err != nil {
t.Fatal(err)
}
return string(data)
}
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
OUT=${OUT:-"$ROOT/dist/qiu.subscription-admin.s2plugin"}
command -v jq >/dev/null 2>&1 || { printf '%s\n' 'jq is required to build a package' >&2; exit 2; }
TMP=$(mktemp -d)
cleanup() {
rm -rf "$TMP"
}
trap cleanup EXIT INT TERM
mkdir -p "$TMP/ui" "$(dirname -- "$OUT")"
cp "$ROOT/ui/index.html" "$ROOT/ui/app.js" "$ROOT/ui/styles.css" "$TMP/ui/"
# Recompute declared hashes at package time so a UI change cannot produce an
# archive that the control plane rejects. The repository descriptor remains
# the human-readable source contract.
INDEX_HASH=$(shasum -a 256 "$ROOT/ui/index.html" | awk '{print $1}')
APP_HASH=$(shasum -a 256 "$ROOT/ui/app.js" | awk '{print $1}')
STYLES_HASH=$(shasum -a 256 "$ROOT/ui/styles.css" | awk '{print $1}')
jq --arg index_hash "$INDEX_HASH" --arg app_hash "$APP_HASH" --arg styles_hash "$STYLES_HASH" \
'.files["ui/index.html"]=$index_hash | .files["ui/app.js"]=$app_hash | .files["ui/styles.css"]=$styles_hash' \
"$ROOT/business-plugin-manifest.v1.json" > "$TMP/manifest.json"
if [ -n "${SIGNATURE_FILE:-}" ]; then
cp "$SIGNATURE_FILE" "$TMP/signature.json"
fi
OUT_DIR=$(CDPATH= cd -- "$(dirname -- "$OUT")" && pwd)
OUT_PATH="$OUT_DIR/$(basename -- "$OUT")"
(cd "$TMP" && zip -q -r "$OUT_PATH" manifest.json ui)
printf '%s\n' "$OUT_PATH"
@@ -0,0 +1,43 @@
import { chromium } from 'playwright'
import fs from 'node:fs/promises'
import path from 'node:path'
const origin = process.env.PLUGIN_BROWSER_ORIGIN || 'http://127.0.0.1:18081'
const entry = `${origin}/extensions/qiu.subscription-admin/admin/`
const outputDir = process.env.PLUGIN_SCREENSHOT_DIR || '.playwright-cli/subscription-admin'
await fs.mkdir(path.resolve(outputDir), { recursive: true })
const browser = await chromium.launch({ headless: true })
try {
for (const width of [425, 900, 1440]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, deviceScaleFactor: 1 })
const responseLeaks = []
page.on('response', async (response) => {
if (!response.headers()['content-type']?.includes('application/json')) return
try {
const body = await response.text()
if (/access_token|refresh_token|admin[_-]?api[_-]?key|password|client_secret/i.test(body)) responseLeaks.push(response.url())
} catch (_) {}
})
await page.goto(entry, { waitUntil: 'domcontentloaded' })
await page.getByLabel('管理员邮箱').fill('admin@example.com')
await page.getByLabel('密码').fill('password')
await page.getByRole('button', { name: '登录' }).click()
await page.locator('#app-view').waitFor({ state: 'visible' })
await page.waitForTimeout(50)
if (responseLeaks.length) throw new Error(`sensitive response field exposed at ${width}px: ${responseLeaks.join(', ')}`)
await page.getByRole('button', { name: '用户订阅' }).click()
await page.locator('#subscriptions-list table').waitFor()
await page.getByRole('button', { name: '下一页' }).click()
await page.getByRole('button', { name: '概览' }).click()
await page.reload({ waitUntil: 'domcontentloaded' })
await page.locator('#app-view').waitFor({ state: 'visible' })
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)
if (overflow) throw new Error(`horizontal overflow at ${width}px`)
await page.screenshot({ path: path.join(outputDir, `subscription-admin-${width}.png`), fullPage: true })
await page.close()
}
} finally {
await browser.close()
}
@@ -0,0 +1,80 @@
import http from 'node:http'
const port = Number(process.env.MOCK_CORE_PORT || 18080)
const token = process.env.MOCK_ACCESS_TOKEN || 'MOCK-ACCESS'
const refresh = process.env.MOCK_REFRESH_TOKEN || 'MOCK-REFRESH'
const users = new Map([
['admin@example.com', { id: 1, role: 'admin', email: 'admin@example.com', username: 'admin' }],
['user@example.com', { id: 2, role: 'user', email: 'user@example.com', username: 'user' }]
])
function json(res, status, body) {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
res.end(JSON.stringify(body))
}
async function body(req) {
const chunks = []
for await (const chunk of req) chunks.push(chunk)
return chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {}
}
function authorized(req) {
return req.headers.authorization === `Bearer ${token}` || req.headers.authorization === `Bearer NEW-MOCK-ACCESS`
}
function subscriptions(page = 1, pageSize = 50) {
const all = Array.from({ length: 57 }, (_, index) => ({
id: index + 1,
user_id: 1,
plan_id: 10 + (index % 2),
plan_name: index % 2 ? '专业版' : '基础版',
status: 'active',
starts_at: '2026-08-01T00:00:00Z',
expires_at: '2026-09-01T00:00:00Z',
cycle_usage_usd: index / 100,
cycle_quota_usd: 100,
user: { id: 1, username: 'admin', email: 'admin@example.com', balance: 123.45 }
}))
const start = (page - 1) * pageSize
return { items: all.slice(start, start + pageSize), total: all.length, page, page_size: pageSize, pages: Math.ceil(all.length / pageSize) }
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`)
const path = url.pathname
if (req.method === 'POST' && path === '/api/v1/auth/login') {
const input = await body(req)
const user = users.get(input.email)
if (!user || input.password !== 'password') return json(res, 401, { code: 401, message: 'invalid credentials' })
return json(res, 200, { code: 0, message: 'success', data: { access_token: token, refresh_token: refresh, user } })
}
if (req.method === 'POST' && path === '/api/v1/auth/refresh') {
const input = await body(req)
if (input.refresh_token !== refresh && input.refresh_token !== 'NEW-MOCK-REFRESH') return json(res, 401, { code: 401, message: 'expired' })
return json(res, 200, { code: 0, message: 'success', data: { access_token: 'NEW-MOCK-ACCESS', refresh_token: 'NEW-MOCK-REFRESH' } })
}
if (req.method === 'POST' && path === '/api/v1/auth/logout') return json(res, 200, { code: 0, message: 'success', data: {} })
if (req.method === 'GET' && path === '/api/v1/settings/public') return json(res, 200, { code: 0, message: 'success', data: { turnstile_enabled: false, geetest_captcha_enabled: false, tencent_captcha_enabled: false, aliyun_captcha_enabled: false } })
if (!authorized(req)) return json(res, 401, { code: 401, message: 'unauthorized' })
if (req.method === 'GET' && path === '/api/v1/auth/me') return json(res, 200, { code: 0, message: 'success', data: users.get('admin@example.com') })
if (req.method === 'GET' && path === '/api/v1/admin/payment/plans') {
return json(res, 200, { code: 0, message: 'success', data: [{ id: 10, name: '基础版', price: 9.9, currency: 'USD', validity_days: 30, validity_unit: 'day', for_sale: true, included_groups: [] }, { id: 11, name: '专业版', price: 19.9, currency: 'USD', validity_days: 30, validity_unit: 'day', for_sale: true, included_groups: [] }] })
}
if (req.method === 'GET' && path === '/api/v1/admin/subscriptions') {
const page = Number(url.searchParams.get('page') || 1)
const pageSize = Number(url.searchParams.get('page_size') || 50)
return json(res, 200, { code: 0, message: 'success', data: subscriptions(page, pageSize) })
}
const subscriptionMatch = path.match(/^\/api\/v1\/admin\/subscriptions\/(\d+)$/)
if (req.method === 'GET' && subscriptionMatch) return json(res, 200, { code: 0, message: 'success', data: subscriptions(1, 1).items[0] })
const userSubscriptionsMatch = path.match(/^\/api\/v1\/admin\/users\/(\d+)\/subscriptions$/)
if (req.method === 'GET' && userSubscriptionsMatch) return json(res, 200, { code: 0, message: 'success', data: subscriptions(1, 2).items })
const userMatch = path.match(/^\/api\/v1\/admin\/users\/(\d+)$/)
if (req.method === 'GET' && userMatch) return json(res, 200, { code: 0, message: 'success', data: { id: Number(userMatch[1]), username: 'admin', email: 'admin@example.com', balance: 123.45, frozen_balance: 0 } })
return json(res, 404, { code: 404, message: 'not found' })
})
server.listen(port, '127.0.0.1', () => {
process.stdout.write(`mock core listening on 127.0.0.1:${port}\n`)
})
@@ -0,0 +1,27 @@
import http from 'node:http'
const prefix = '/extensions/qiu.subscription-admin'
const targetPort = Number(process.env.PLUGIN_TARGET_PORT || 18082)
const port = Number(process.env.MOCK_PROXY_PORT || 18081)
const server = http.createServer((req, res) => {
if (!req.url.startsWith(prefix)) {
res.writeHead(404)
res.end('not found')
return
}
const forwardedPath = req.url.slice(prefix.length) || '/'
const proxy = http.request({ hostname: '127.0.0.1', port: targetPort, method: req.method, path: forwardedPath, headers: { ...req.headers, host: `127.0.0.1:${targetPort}` } }, (upstream) => {
const headers = { ...upstream.headers }
if (headers.location && headers.location.startsWith('/admin/')) headers.location = `${prefix}${headers.location}`
res.writeHead(upstream.statusCode || 502, headers)
upstream.pipe(res)
})
proxy.on('error', () => {
if (!res.headersSent) res.writeHead(502)
res.end('proxy error')
})
req.pipe(proxy)
})
server.listen(port, '127.0.0.1', () => process.stdout.write(`mock proxy listening on 127.0.0.1:${port}\n`))
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cleanup() {
kill "${PROXY_PID:-}" "${PLUGIN_PID:-}" "${CORE_PID:-}" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
MOCK_CORE_PORT=18080 node "$ROOT/test/mock-core.mjs" >/tmp/subscription-admin-mock-core.log 2>&1 & CORE_PID=$!
CORE_BASE_URL=http://127.0.0.1:18080 \
PLUGIN_HOST=127.0.0.1 \
PLUGIN_PORT=18082 \
PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.subscription-admin \
PLUGIN_COOKIE_PATH=/extensions/qiu.subscription-admin/ \
PLUGIN_COOKIE_SECURE=false \
go run "$ROOT" >/tmp/subscription-admin-plugin.log 2>&1 & PLUGIN_PID=$!
PLUGIN_TARGET_PORT=18082 MOCK_PROXY_PORT=18081 node "$ROOT/test/mock-proxy.mjs" >/tmp/subscription-admin-mock-proxy.log 2>&1 & PROXY_PID=$!
sleep 2
PLUGIN_BROWSER_ORIGIN=http://127.0.0.1:18081 PLUGIN_SCREENSHOT_DIR="$ROOT/.screenshots/subscription-admin" node "$ROOT/test/browser-check.mjs"
@@ -0,0 +1,45 @@
package main
import (
"flag"
"fmt"
"os"
"git.awaioi.com/awaioi/sub2api-add/plugins/subscription-admin/internal/manifest"
)
func main() {
manifestPath := flag.String("manifest", "business-plugin-manifest.v1.json", "manifest path")
signaturePath := flag.String("signature", "", "optional detached signature path")
publicKeyPath := flag.String("public-key", "", "base64 Ed25519 public key file")
flag.Parse()
m, raw, err := manifest.Load(*manifestPath)
if err != nil {
fatal(err)
}
if (*signaturePath == "") != (*publicKeyPath == "") {
fatal(fmt.Errorf("-signature and -public-key must be provided together"))
}
if *signaturePath != "" {
signature, err := os.ReadFile(*signaturePath)
if err != nil {
fatal(err)
}
publicKey, err := os.ReadFile(*publicKeyPath)
if err != nil {
fatal(err)
}
if err := manifest.VerifyKeyID(signature, m.Publisher.KeyID); err != nil {
fatal(err)
}
if err := manifest.VerifySignature(raw, signature, publicKey); err != nil {
fatal(err)
}
}
fmt.Printf("manifest valid: %s\n", *manifestPath)
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
+170
View File
@@ -0,0 +1,170 @@
(() => {
'use strict'
const state = {
csrf: '',
plans: [],
subscriptions: null,
subscriptionPage: 1,
subscriptionPageSize: 50,
captcha: { enabled: false, provider: '' }
}
const $ = (selector) => document.querySelector(selector)
const loginView = $('#login-view')
const appView = $('#app-view')
const loginForm = $('#login-form')
const basePath = (document.body.dataset.basePath || '').replace(/\/$/, '')
const route = (path) => `${basePath}${path}`
function showError(message, target = $('#app-error')) {
target.textContent = message || '请求失败'
target.hidden = !message
}
function clearError(target = $('#app-error')) { target.textContent = ''; target.hidden = true }
async function request(path, options = {}) {
const headers = new Headers(options.headers || {})
headers.set('Accept', 'application/json')
if (options.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json')
if (state.csrf && options.method && options.method !== 'GET') headers.set('X-CSRF-Token', state.csrf)
const response = await fetch(route(path), { ...options, headers, credentials: 'same-origin' })
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
if (response.status === 401) { state.csrf = ''; loginView.hidden = false; appView.hidden = true }
throw new Error(payload.error || '请求失败')
}
return payload
}
function unwrap(payload) { return payload && payload.code === 0 && Object.prototype.hasOwnProperty.call(payload, 'data') ? payload.data : payload }
function formatNumber(value) {
if (value === null || value === undefined || value === '') return '-'
const number = Number(value)
return Number.isFinite(number) ? number.toLocaleString('zh-CN', { maximumFractionDigits: 6 }) : String(value)
}
function formatDate(value) {
if (!value) return '-'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { dateStyle: 'medium', timeStyle: 'short' })
}
function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[character])) }
function statusLabel(value) { const safe = String(value || 'unknown'); return `<span class="pill pill-${safe.toLowerCase()}">${escapeHTML(safe)}</span>` }
function setView(name) {
document.querySelectorAll('.tab').forEach((button) => button.classList.toggle('active', button.dataset.view === name))
document.querySelectorAll('.view').forEach((view) => { view.hidden = view.id !== `view-${name}` })
if (name === 'plans' && !state.plans.length) loadPlans()
if (name === 'subscriptions' && !state.subscriptions) loadSubscriptions()
if (name === 'audit') loadAudit()
}
async function login(event) {
event.preventDefault(); clearError($('#login-error'))
const form = new FormData(loginForm); const button = loginForm.querySelector('button[type="submit"]'); button.disabled = true
try {
const captchaToken = String(form.get('captcha_token') || '').trim()
if (state.captcha.enabled && !captchaToken) throw new Error('请先完成安全验证并填写验证结果')
let payload = await request('/login', { method: 'POST', body: JSON.stringify({
email: form.get('email'),
password: form.get('password'),
turnstile_token: captchaToken || undefined,
tencent_captcha_ticket: state.captcha.provider === 'tencent' ? captchaToken || undefined : undefined,
tencent_captcha_randstr: state.captcha.provider === 'tencent' ? String(form.get('captcha_randstr') || '').trim() || undefined : undefined
}) })
if (payload.requires_2fa) {
const code = window.prompt('请输入管理员 2FA 验证码')
if (!code) throw new Error('需要 2FA 验证码')
payload = await request('/login/2fa', { method: 'POST', body: JSON.stringify({ pending_token: payload.pending_token, totp_code: code }) })
}
if (!payload.ok || !payload.csrf_token) throw new Error('登录响应无效')
state.csrf = payload.csrf_token; loginView.hidden = true; appView.hidden = false
const user = payload.user || {}; $('#operator').textContent = user.email || user.username || `管理员 #${user.id || '-'}`
await Promise.all([loadStatus(), loadOverview()])
} catch (error) { showError(error.message, $('#login-error')) } finally { button.disabled = false }
}
async function loadPlans() {
try { const payload = unwrap(await request('/api/plans')); state.plans = Array.isArray(payload) ? payload : []; $('#plan-count').textContent = formatNumber(state.plans.length); renderPlans(); return true }
catch (error) { showError(error.message); return false }
}
async function loadSubscriptions() {
try {
const params = new URLSearchParams(); const form = new FormData($('#subscription-filter'))
const userID = String(form.get('user_id') || '').trim(); const status = String(form.get('status') || '')
if (userID && !/^[1-9][0-9]*$/.test(userID)) throw new Error('用户 ID 必须是正整数')
let endpoint = '/api/subscriptions'
if (userID && !status) endpoint = `/api/users/${encodeURIComponent(userID)}/subscriptions`
if (endpoint === '/api/subscriptions') { if (userID) params.set('user_id', userID); if (status) params.set('status', status); params.set('page', String(state.subscriptionPage)); params.set('page_size', String(state.subscriptionPageSize)) }
const payload = unwrap(await request(`${endpoint}${params.toString() ? `?${params.toString()}` : ''}`)) || {}; state.subscriptions = payload
$('#subscription-count').textContent = formatNumber(Array.isArray(payload) ? payload.length : (payload.total ?? payload.items?.length ?? 0)); renderSubscriptions(payload); return true
} catch (error) { showError(error.message); return false }
}
async function loadHealth() { try { const response = await fetch(route('/healthz'), { credentials: 'same-origin', headers: { Accept: 'application/json' } }); return response.ok } catch { return false } }
async function loadOverview() {
clearError(); $('#sync-time').textContent = '同步中'
const results = await Promise.all([loadHealth(), loadPlans(), loadSubscriptions()])
const healthy = results.every(Boolean); const degraded = results.some(Boolean)
setCoreStatus(healthy ? 'ok' : degraded ? 'degraded' : 'bad')
$('#sync-time').textContent = degraded ? `最近同步:${formatDate(new Date().toISOString())}` : '同步失败'
}
async function loadAudit() { try { const payload = await request('/api/audit'); renderAudit(payload.items || []) } catch (error) { showError(error.message) } }
async function loadStatus() {
try { const payload = await request('/api/status'); $('#credential-status').textContent = payload.credential_state === 'server_managed' ? '服务端托管(不回显)' : '不可用' }
catch { $('#credential-status').textContent = '不可用' }
}
async function loadCaptchaConfig() {
try {
const payload = await request('/api/captcha-config'); state.captcha = payload
const panel = $('#captcha-panel')
if (!payload.enabled) { panel.hidden = true; return }
panel.hidden = false; $('#captcha-label').textContent = `${payload.provider || '安全'}验证`
const descriptions = { geetest: 'Core 已启用 GeeTest。请在官方验证组件完成挑战后,将返回的 JSON 验证结果粘贴到此处。', turnstile: 'Core 已启用 Cloudflare Turnstile。请完成挑战后填写返回的验证结果。', tencent: 'Core 已启用腾讯验证码。请填写 ticket,并在下方填写 randstr。', aliyun: 'Core 已启用阿里云验证码。请填写 captchaVerifyParam。' }
$('#captcha-help').textContent = descriptions[payload.provider] || '请完成 Core 配置的验证码后填写验证结果。'
$('#captcha-randstr-row').hidden = payload.provider !== 'tencent'
} catch { $('#captcha-panel').hidden = true }
}
async function loadBalance(userID) {
if (!/^[1-9][0-9]*$/.test(userID)) throw new Error('用户 ID 必须是正整数')
const payload = unwrap(await request(`/api/users/${encodeURIComponent(userID)}`)) || {}; $('#user-balance').textContent = formatNumber(payload.balance)
}
async function bootstrap() {
try {
const payload = await request('/api/me'); state.csrf = payload.csrf_token || ''
if (!state.csrf || !payload.user) throw new Error('session unavailable')
loginView.hidden = true; appView.hidden = false
const user = payload.user; $('#operator').textContent = user.email || user.username || `管理员 #${user.id || '-'}`
await Promise.all([loadCaptchaConfig(), loadStatus(), loadOverview()])
} catch { loginView.hidden = false; appView.hidden = true; await loadCaptchaConfig() }
}
function setCoreStatus(stateName) { const element = $('#core-status'); const labels = { ok: '已连接', degraded: '部分可用', bad: '不可用' }; element.textContent = labels[stateName] || '检查中'; element.className = `status ${stateName === 'ok' ? 'ok' : stateName === 'bad' ? 'bad' : ''}` }
function parseFeatures(features) { if (!features) return []; if (Array.isArray(features)) return features; if (typeof features !== 'string') return []; try { const parsed = JSON.parse(features); return Array.isArray(parsed) ? parsed : [] } catch { return features.split(/[,\n]/).map((item) => item.trim()).filter(Boolean) } }
function renderPlans() {
const container = $('#plans-list'); if (!state.plans.length) { container.innerHTML = '<p class="empty">暂无套餐数据</p>'; return }
container.innerHTML = state.plans.map((plan) => {
const groups = (plan.included_groups || []).map((group) => `<span class="tag">${escapeHTML(group.name || group.id)}</span>`).join('') || '<span class="muted">未返回分组</span>'
const features = parseFeatures(plan.features)
return `<article class="plan-card"><div class="card-heading"><div><h3>${escapeHTML(plan.name || plan.product_name || `套餐 #${plan.id}`)}</h3><p class="muted">${escapeHTML(plan.description || '')}</p></div>${plan.for_sale ? '<span class="pill pill-active">可售</span>' : '<span class="pill">下架</span>'}</div><div class="plan-price">${formatNumber(plan.price)} <small>${escapeHTML(plan.currency || '')}</small></div><dl class="facts"><div><dt>有效期</dt><dd>${formatNumber(plan.validity_days)} ${escapeHTML(plan.validity_unit || '天')}</dd></div><div><dt>周期额度</dt><dd>${formatNumber(plan.cycle_quota_usd)}</dd></div><div><dt>总额度</dt><dd>${formatNumber(plan.total_quota_usd)}</dd></div><div><dt>实例上限</dt><dd>${formatNumber(plan.max_subscriptions_per_user)}</dd></div></dl><div class="tags">${groups}</div>${features.length ? `<ul class="feature-list">${features.map((feature) => `<li>${escapeHTML(feature)}</li>`).join('')}</ul>` : ''}</article>`
}).join('')
}
function renderSubscriptions(payload) {
const container = $('#subscriptions-list'); const items = Array.isArray(payload) ? payload : (payload.items || []); const pagination = $('#subscription-pagination')
if (Array.isArray(payload) || !payload.pages) pagination.hidden = true
else { pagination.hidden = false; $('#subscription-page-info').textContent = `第 ${payload.page || state.subscriptionPage} / ${payload.pages} 页,共 ${formatNumber(payload.total)} 条`; $('#subscription-prev').disabled = (payload.page || state.subscriptionPage) <= 1; $('#subscription-next').disabled = (payload.page || state.subscriptionPage) >= payload.pages }
if (!items.length) { container.innerHTML = '<p class="empty">暂无订阅数据</p>'; return }
container.innerHTML = `<table><thead><tr><th>订阅实例</th><th>用户</th><th>套餐</th><th>状态</th><th>有效期</th><th>周期用量</th><th></th></tr></thead><tbody>${items.map((item) => `<tr><td><code>#${escapeHTML(item.id)}</code></td><td>${escapeHTML(item.user?.username || item.user?.email || item.user_id || '-')}</td><td>${escapeHTML(item.plan_name || item.plan_id || '-')}</td><td>${statusLabel(item.status)}</td><td>${formatDate(item.starts_at)}<br><span class="muted">至 ${formatDate(item.expires_at)}</span></td><td>${formatNumber(item.cycle_usage_usd)} / ${formatNumber(item.cycle_quota_usd)}</td><td><button class="link-button detail-button" data-id="${escapeHTML(item.id)}" type="button">详情</button></td></tr>`).join('')}</tbody></table>`
container.querySelectorAll('.detail-button').forEach((button) => button.addEventListener('click', () => showDetail(button.dataset.id)))
}
async function showDetail(id) { try { const payload = unwrap(await request(`/api/subscriptions/${encodeURIComponent(id)}`)); $('#detail-content').textContent = JSON.stringify(payload, null, 2); $('#detail-dialog').showModal() } catch (error) { showError(error.message) } }
function renderAudit(items) { const container = $('#audit-list'); if (!items.length) { container.innerHTML = '<p class="empty">暂无操作记录</p>'; return }; container.innerHTML = `<table><thead><tr><th>时间</th><th>动作</th><th>结果</th><th>用户 ID</th><th>请求 ID</th></tr></thead><tbody>${items.slice().reverse().map((item) => `<tr><td>${formatDate(item.time)}</td><td><code>${escapeHTML(item.action)}</code></td><td>${statusLabel(item.result)}</td><td>${escapeHTML(item.user_id ?? '-')}</td><td><code>${escapeHTML(item.request_id || '-')}</code></td></tr>`).join('')}</tbody></table>` }
loginForm.addEventListener('submit', login)
$('#logout').addEventListener('click', async () => { try { await request('/logout', { method: 'POST' }) } catch { /* session is cleared locally */ } state.csrf = ''; loginView.hidden = false; appView.hidden = true; loginForm.reset() })
$('#refresh-all').addEventListener('click', loadOverview)
$('#subscription-filter').addEventListener('submit', (event) => { event.preventDefault(); state.subscriptionPage = 1; state.subscriptions = null; loadSubscriptions() })
$('#subscription-prev').addEventListener('click', () => { if (state.subscriptionPage > 1) { state.subscriptionPage -= 1; loadSubscriptions() } })
$('#subscription-next').addEventListener('click', () => { const pages = Number(state.subscriptions?.pages || 0); if (state.subscriptionPage < pages) { state.subscriptionPage += 1; loadSubscriptions() } })
$('#balance-form').addEventListener('submit', async (event) => { event.preventDefault(); clearError(); const userID = String(new FormData(event.currentTarget).get('user_id') || '').trim(); try { await loadBalance(userID) } catch (error) { showError(error.message) } })
$('#close-detail').addEventListener('click', () => $('#detail-dialog').close())
document.querySelectorAll('.tab').forEach((button) => button.addEventListener('click', () => setView(button.dataset.view)))
document.querySelectorAll('.reload').forEach((button) => button.addEventListener('click', () => { if (button.dataset.target === 'plans') loadPlans(); if (button.dataset.target === 'subscriptions') { state.subscriptions = null; loadSubscriptions() }; if (button.dataset.target === 'audit') loadAudit() }))
void bootstrap()
})()
+9
View File
@@ -0,0 +1,9 @@
package ui
import "embed"
// FS contains the static administrator UI shipped with the business plugin.
// It is embedded so the service does not depend on a writable host directory.
//
//go:embed index.html app.js styles.css
var FS embed.FS
+122
View File
@@ -0,0 +1,122 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>订阅管理插件</title>
<link rel="stylesheet" href="__PLUGIN_BASE_PATH__/styles.css">
</head>
<body data-base-path="__PLUGIN_BASE_PATH__">
<main class="shell">
<section id="login-view" class="auth-panel" aria-labelledby="login-title">
<div class="eyebrow">SUB2API EXTENSION</div>
<h1 id="login-title">订阅管理</h1>
<p class="muted">使用 Sub2API 管理员账号登录。普通用户没有访问权限。</p>
<form id="login-form" class="stack" autocomplete="on">
<label>
<span>管理员邮箱</span>
<input name="email" type="email" autocomplete="username" required maxlength="254">
</label>
<label>
<span>密码</span>
<input name="password" type="password" autocomplete="current-password" required maxlength="200">
</label>
<fieldset id="captcha-panel" class="captcha-panel" hidden>
<legend id="captcha-label">安全验证</legend>
<p id="captcha-help" class="muted"></p>
<label>
<span>验证结果</span>
<textarea name="captcha_token" rows="3" maxlength="8192" spellcheck="false" autocomplete="off"></textarea>
</label>
<label id="captcha-randstr-row" hidden>
<span>验证随机串</span>
<input name="captcha_randstr" maxlength="512" autocomplete="off">
</label>
</fieldset>
<button class="primary" type="submit">登录</button>
<p id="login-error" class="error" role="alert" hidden></p>
</form>
</section>
<section id="app-view" hidden>
<header class="topbar">
<div>
<div class="eyebrow">SUB2API EXTENSION</div>
<h1>订阅管理</h1>
</div>
<div class="top-actions">
<span id="operator" class="operator"></span>
<button id="logout" class="secondary" type="button">退出</button>
</div>
</header>
<nav class="tabs" aria-label="插件页面">
<button class="tab active" data-view="overview" type="button">概览</button>
<button class="tab" data-view="plans" type="button">套餐</button>
<button class="tab" data-view="subscriptions" type="button">用户订阅</button>
<button class="tab" data-view="audit" type="button">操作记录</button>
<button class="tab" data-view="settings" type="button">设置</button>
</nav>
<p id="app-error" class="error" role="alert" hidden></p>
<section id="view-overview" class="view stack">
<div class="section-heading"><div><h2>连接概览</h2><p class="muted">Core 是套餐、余额和订阅账本的唯一来源。</p></div><button id="refresh-all" class="secondary" type="button">刷新数据</button></div>
<div class="metric-grid">
<article class="metric"><span>插件状态</span><strong class="status ok">运行中</strong><small>独立服务 · 只读模式</small></article>
<article class="metric"><span>Core 连接</span><strong id="core-status" class="status">检查中</strong><small id="sync-time">尚未同步</small></article>
<article class="metric"><span>可售套餐</span><strong id="plan-count">-</strong><small>来自 Core 套餐目录</small></article>
<article class="metric"><span>订阅实例</span><strong id="subscription-count">-</strong><small>当前分页结果</small></article>
<article class="metric"><span>用户余额</span><strong id="user-balance">-</strong><small>选择用户后显示 Core 余额</small></article>
</div>
<form id="balance-form" class="filter-row balance-form">
<label><span>查询用户余额</span><input name="user_id" inputmode="numeric" pattern="[1-9][0-9]*" placeholder="用户 ID" required></label>
<button class="secondary" type="submit">查询余额</button>
</form>
<div class="notice"><strong>只读试验版</strong><span>余额购买、续费、撤销和外部支付尚未启用。页面不会提交任何写操作。</span></div>
</section>
<section id="view-plans" class="view" hidden>
<div class="section-heading"><div><h2>套餐目录</h2><p class="muted">展示 Core 当前返回的价格、额度和覆盖分组。</p></div><button class="secondary reload" data-target="plans" type="button">刷新</button></div>
<div id="plans-list" class="card-list"></div>
</section>
<section id="view-subscriptions" class="view" hidden>
<div class="section-heading"><div><h2>用户订阅</h2><p class="muted">每个订阅实例独立展示,支持同档位多实例。</p></div><button class="secondary reload" data-target="subscriptions" type="button">刷新</button></div>
<form id="subscription-filter" class="filter-row">
<label><span>用户 ID</span><input name="user_id" inputmode="numeric" pattern="[0-9]*"></label>
<label><span>状态</span><select name="status"><option value="">全部</option><option value="active">active</option><option value="expired">expired</option><option value="revoked">revoked</option></select></label>
<button class="secondary" type="submit">筛选</button>
</form>
<div id="subscriptions-list" class="table-wrap"></div>
<div id="subscription-pagination" class="pagination" hidden>
<span id="subscription-page-info" class="muted"></span>
<div><button id="subscription-prev" class="secondary" type="button">上一页</button><button id="subscription-next" class="secondary" type="button">下一页</button></div>
</div>
</section>
<section id="view-audit" class="view" hidden>
<div class="section-heading"><div><h2>操作记录</h2><p class="muted">仅记录插件会话和只读查询结果,不记录 token 或密码。</p></div><button class="secondary reload" data-target="audit" type="button">刷新</button></div>
<div id="audit-list" class="table-wrap"></div>
</section>
<section id="view-settings" class="view stack" hidden>
<div class="section-heading"><div><h2>插件设置</h2><p class="muted">部署参数由服务端环境变量管理,页面不提供 secret 编辑入口。</p></div></div>
<div class="settings-list">
<div><span>运行模式</span><strong>只读 V1</strong></div>
<div><span>Core API allowlist</span><strong>10 个固定端点</strong></div>
<div><span>会话存储</span><strong>进程内存(重启后需重新登录)</strong></div>
<div><span>Core 凭据</span><strong id="credential-status">检查中</strong></div>
<div><span>余额购买 / 续费 / 撤销</span><strong class="status">未启用</strong></div>
</div>
</section>
</section>
</main>
<dialog id="detail-dialog" class="detail-dialog">
<div class="dialog-heading"><h2>订阅详情</h2><button id="close-detail" class="icon-button" type="button" aria-label="关闭">×</button></div>
<pre id="detail-content"></pre>
</dialog>
<script src="__PLUGIN_BASE_PATH__/app.js" defer></script>
</body>
</html>
+103
View File
@@ -0,0 +1,103 @@
:root { color-scheme: light dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #17202a; background: #f4f7fa; font-synthesis: none; }
@media (prefers-color-scheme: dark) { :root { color: #edf2f7; background: #11161c; } }
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; min-width: 320px; }
button, input, select { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: .6; }
.shell { width: min(1160px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 64px; }
.auth-panel { width: min(440px, 100%); margin: 10vh auto 0; padding: 32px; border: 1px solid #dce4eb; border-radius: 10px; background: #fff; box-shadow: 0 12px 36px rgb(20 38 56 / 8%); }
@media (prefers-color-scheme: dark) { .auth-panel, .metric, .plan-card, .notice, table, .detail-dialog { background: #18212b; border-color: #2b3947; } }
h1, h2, h3, p { margin: 0; }
h1 { margin-top: 6px; font-size: clamp(1.5rem, 3vw, 2.1rem); letter-spacing: 0; }
h2 { font-size: 1.2rem; }
h3 { font-size: 1rem; }
.eyebrow { color: #3977a9; font-size: .7rem; font-weight: 700; letter-spacing: .08em; }
.muted { color: #647384; font-size: .86rem; line-height: 1.5; }
@media (prefers-color-scheme: dark) { .muted { color: #a8b5c2; } }
.stack { display: grid; gap: 18px; }
form.stack { margin-top: 26px; }
label { display: grid; gap: 7px; color: #4f6070; font-size: .82rem; font-weight: 600; }
input, select, textarea { width: 100%; height: 36px; padding: 0 10px; border: 1px solid #cbd7e1; border-radius: 5px; color: inherit; background: transparent; outline: none; }
textarea { height: auto; min-height: 72px; padding: 8px 10px; resize: vertical; font: .78rem/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; }
input:focus, select:focus { border-color: #3977a9; box-shadow: 0 0 0 3px rgb(57 119 169 / 17%); }
button { min-height: 36px; border-radius: 5px; border: 1px solid transparent; padding: 0 14px; }
.primary { color: white; background: #3977a9; }
.primary:hover { background: #2e628e; }
.secondary { color: #2d536f; background: transparent; border-color: #b9c9d7; }
.secondary:hover { background: rgb(57 119 169 / 8%); }
.topbar, .section-heading, .card-heading, .top-actions, .filter-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.topbar { padding-bottom: 24px; border-bottom: 1px solid #d8e1e9; }
.top-actions { flex-wrap: wrap; justify-content: flex-end; }
.operator { max-width: 260px; overflow: hidden; color: #647384; font-size: .82rem; text-overflow: ellipsis; white-space: nowrap; }
.tabs { display: flex; gap: 6px; overflow-x: auto; padding: 18px 0; border-bottom: 1px solid #d8e1e9; }
.tab { color: #647384; background: transparent; border: 0; white-space: nowrap; }
.tab.active { color: #3977a9; box-shadow: inset 0 -2px #3977a9; }
.view { padding-top: 28px; }
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
.metric { min-height: 122px; display: grid; align-content: space-between; gap: 8px; padding: 18px; border: 1px solid #dce4eb; border-radius: 8px; background: #fff; }
.metric > span { color: #647384; font-size: .82rem; }
.metric strong { font-size: 1.55rem; font-variant-numeric: tabular-nums; }
.metric small { color: #7b8996; font-size: .75rem; }
.status { color: #647384; }
.status.ok { color: #2f8b5c; }
.status.bad { color: #c14f4f; }
.notice { display: flex; gap: 12px; align-items: baseline; padding: 14px 16px; border: 1px solid #c6dce9; border-left: 3px solid #3977a9; border-radius: 6px; background: #fff; font-size: .86rem; }
.card-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-top: 18px; }
.plan-card { display: grid; gap: 15px; padding: 20px; border: 1px solid #dce4eb; border-radius: 8px; background: #fff; }
.plan-price { color: #3977a9; font-size: 1.7rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.plan-price small { color: #647384; font-size: .8rem; font-weight: 500; }
.facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 0; }
.facts div { padding: 10px; border-radius: 5px; background: rgb(100 115 132 / 8%); }
.facts dt { color: #647384; font-size: .72rem; }
.facts dd { margin: 4px 0 0; font-size: .86rem; font-variant-numeric: tabular-nums; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; }
.tag, .pill { display: inline-flex; align-items: center; min-height: 24px; padding: 0 8px; border-radius: 99px; font-size: .72rem; white-space: nowrap; }
.tag { color: #3977a9; background: rgb(57 119 169 / 11%); }
.pill { color: #647384; background: rgb(100 115 132 / 12%); }
.pill-active, .pill-success { color: #2f8b5c; background: rgb(47 139 92 / 13%); }
.pill-expired, .pill-revoked, .pill-failed, .pill-bad { color: #bd5050; background: rgb(189 80 80 / 13%); }
.feature-list { display: grid; gap: 5px; margin: 0; padding-left: 18px; color: #647384; font-size: .82rem; }
.filter-row { justify-content: flex-start; flex-wrap: wrap; margin-top: 18px; }
.filter-row label { width: min(220px, 100%); }
.balance-form { margin-top: 16px; }
.captcha-panel { display: grid; gap: 10px; margin: 2px 0 0; padding: 12px; border: 1px solid #dce4eb; border-radius: 6px; }
.captcha-panel legend { padding: 0 4px; color: #4f6070; font-size: .82rem; font-weight: 600; }
.pagination { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 12px; }
.pagination > div { display: flex; gap: 8px; }
.settings-list { display: grid; gap: 1px; overflow: hidden; border: 1px solid #dce4eb; border-radius: 8px; background: #dce4eb; }
.settings-list > div { display: flex; justify-content: space-between; gap: 18px; padding: 15px 16px; background: #fff; font-size: .84rem; }
.settings-list strong { font-weight: 600; text-align: right; }
@media (prefers-color-scheme: dark) { .settings-list { border-color: #2b3947; background: #2b3947; } .settings-list > div { background: #18212b; } }
.table-wrap { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; margin-top: 18px; border: 1px solid #dce4eb; border-radius: 8px; }
table { width: 100%; min-width: 760px; border-collapse: collapse; background: #fff; }
th, td { padding: 13px 14px; border-bottom: 1px solid #e2e8ee; text-align: left; vertical-align: top; font-size: .82rem; white-space: nowrap; }
th { color: #647384; font-size: .74rem; font-weight: 600; background: rgb(100 115 132 / 6%); }
tr:last-child td { border-bottom: 0; }
code { color: #3977a9; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .78rem; }
.link-button { min-height: 28px; padding: 0; color: #3977a9; background: transparent; border: 0; }
.empty { padding: 32px; color: #647384; text-align: center; }
.error { color: #b84d4d; font-size: .84rem; }
.detail-dialog { width: min(720px, calc(100% - 28px)); max-height: min(700px, calc(100dvh - 28px)); padding: 0; border: 1px solid #dce4eb; border-radius: 8px; color: inherit; background: #fff; }
.detail-dialog::backdrop { background: rgb(15 27 39 / 42%); }
.dialog-heading { display: flex; justify-content: space-between; align-items: center; padding: 16px 18px; border-bottom: 1px solid #dce4eb; }
.icon-button { width: 32px; min-height: 32px; padding: 0; color: #647384; background: transparent; border: 0; font-size: 1.3rem; }
pre { max-height: 580px; overflow: auto; margin: 0; padding: 18px; font: .76rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
@media (max-width: 760px) {
.shell { width: min(100% - 20px, 600px); padding-top: 18px; }
.auth-panel { margin-top: 4vh; padding: 22px; }
.topbar, .section-heading { align-items: flex-start; flex-direction: column; }
.top-actions { width: 100%; justify-content: space-between; }
.metric-grid, .card-list { grid-template-columns: 1fr; }
.metric { min-height: 104px; }
.notice { align-items: flex-start; flex-direction: column; gap: 5px; }
.filter-row { align-items: stretch; flex-direction: column; }
.filter-row label { width: 100%; }
.filter-row button { width: 100%; }
.pagination { align-items: stretch; flex-direction: column; }
.pagination > div { width: 100%; }
.pagination button { flex: 1; }
.settings-list > div { align-items: flex-start; flex-direction: column; gap: 5px; }
.settings-list strong { text-align: left; }
}