174 lines
14 KiB
Markdown
174 lines
14 KiB
Markdown
# TickShield 平台接入契约全集
|
||||
|
|
|
|||
|
|
基线:TickShield v4.0 + APPSTORE-RUNTIME + FR-101(2026-10)。出处为平台仓文件,冲突时以平台仓代码为准。
|
|||
|
|
|
|||
|
|
## 1. listingKey 四位一体
|
|||
|
|
|
|||
|
|
`listingKey` == TDT 的 `aud` == 服务容器网络别名 == 安装态 appKey == scope 命名空间。全局唯一、不可变更。
|
|||
|
|
|
|||
|
|
- 格式:`^[a-z0-9][a-z0-9-]{0,62}$`(平台统一校验 `registry/models.go` `ValidListingKey`)
|
|||
|
|
- 应用类型 `type` 仅两值:`micro`(有前端,沙箱 iframe 嵌入门户)|`service`(无头后端);空值按 `service` 处理
|
|||
|
|
- 状态机 `draft → published → installed → active`,与路由层脱钩(draft 的注册实例同样获得路由)
|
|||
|
|
|
|||
|
|
## 2. app.manifest.json 字段全集
|
|||
|
|
|
|||
|
|
校验实现:`apps/portal/internal/modules/registry/manifest.go` `ValidateManifest`。
|
|||
|
|
|
|||
|
|
| 字段 | 必填 | 约束 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `listingKey` | 是 | 上方正则 |
|
|||
|
|
| `name` | 是 | 非空纯字符串(不支持多语言对象) |
|
|||
|
|
| `version` | 是 | 严格 semver(含 prerelease/build 段) |
|
|||
|
|
| `type` | 否 | `micro`|`service`,缺省 = `service` |
|
|||
|
|
| `icon` | 否 | ≤64 字符 |
|
|||
|
|
| `color` | 否 | `^#[0-9A-Fa-f]{6}$` |
|
|||
|
|
| `cat` | 否 | ≤64 字符 |
|
|||
|
|
| `tagline` | 否 | ≤120 字符 |
|
|||
|
|
| `desc` | 否 | ≤2000 字符 |
|
|||
|
|
| `scopes` | 否 | 每项 `^[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)+$`(至少两段,如 `my-app.read`) |
|
|||
|
|
| `exchangeTargets` | 否 | 每项必须是合法 listingKey;声明本应用可 token-exchange 的目标 |
|
|||
|
|
| `dependencies` | 否 | 每项必须是合法 listingKey |
|
|||
|
|
| `navItems` | 否 | 每项 `id`/`label`/`path` 必填,`icon` 可选 |
|
|||
|
|
| `dashboard.widgets` | 否 | JSON key 字面量带个点;每项 `key`/`type` 必填 |
|
|||
|
|
| `aclManifest` | 否 | `{pages:[{key,label,audience,defaultForMember,actions:[{key,label}]}]}`;`audience` ∈ `admin`/`viewer`/`all`(缺省);`defaultForMember` 仅为声明性标记(零绑定=零权限,不参与默认补足) |
|
|||
|
|
| `deployDescriptor` | 否 | **仅平台管理员可写**;应用仓库不携带(自注册时被剥离,submit 非管理员携带 → 403) |
|
|||
|
|
|
|||
|
|
**治理字段** = `scopes`/`aclManifest`/`dependencies`/`exchangeTargets`:集合语义比较(忽略顺序),有变更 → 202 进审核队列,批准前不生效、不影响已安装租户;**首个版本直接生效**(无基线可 diff)。展示元信息与 version 不算治理字段,直接生效。
|
|||
|
|
|
|||
|
|
PID 权限点由 aclManifest 派生:页面 `<listingKey>:page:<key>`、动作 `<listingKey>:action:<key>`,可带 `:read`/`:write` 档。
|
|||
|
|
|
|||
|
|
## 3. 注册 / 心跳 / 下线(service 型容器)
|
|||
|
|
|
|||
|
|
端点(均走 `X-App-Key` + `X-App-Secret` 凭证头,bcrypt 比对;同 IP+key 10min 连错 10 次锁 429):
|
|||
|
|
|
|||
|
|
- `POST /api/register` `{listingKey, version?, host?, port?, manifest?}`
|
|||
|
|
- `port` 缺省 8080(1–65535);`host` 缺省 = listingKey,**显式给出必须等于 listingKey**(否则 400)
|
|||
|
|
- `manifest` 可省略(纯实例重注册);携带则须完整合法且 `manifest.listingKey` == body listingKey
|
|||
|
|
- 应用不存在 + 无 manifest → 403 `unknown app`;有 manifest → 建行;已存在 + manifest → 治理 diff
|
|||
|
|
- `latest_version` 仅在上报版本 semver ≥ 当前值时拨动(防旧容器重启回拨)——**回滚部署须显式发新版本号**
|
|||
|
|
- `POST /api/heartbeat` `{listingKey, host?, port?}`:命中 active → 200;**无实例行 → 404**(重注册自愈);**dead → 原地复活**;**stopped 管理态 → 409 绝不复活**(保持心跳等恢复)
|
|||
|
|
- `POST /api/deregister`:摘实例 + 重写路由
|
|||
|
|
- 超时剔除:心跳 >90s 未到账 → 实例标 `dead` 摘流(30s 心跳间隔允许连丢 2 次)
|
|||
|
|
|
|||
|
|
**基础镜像 `runtime-entry.js` 已内置全部上述行为**:注册 2s 重试直到成功、404 自动重注册、409 保持等待、SIGTERM/SIGINT 先 deregister(3s 超时)→ 等 2.5s 摘流 → 关 listener(上限 10s)。应用不写任何注册/心跳代码。
|
|||
|
|
|
|||
|
|
### 环境变量约定
|
|||
|
|
|
|||
|
|
| 变量 | 默认 | 说明 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `PORTAL_API_URL` | `http://portal:3000` | Portal 控制面地址 |
|
|||
|
|
| `APP_KEY` | `app` | == listingKey |
|
|||
|
|
| `APP_SECRET` | 空 | 平台签发凭证 |
|
|||
|
|
| `MANIFEST_PATH` | `/app/app.manifest.json` | 镜像内 manifest |
|
|||
|
|
| `APP_ENTRY` | `/app/server.js` | 业务入口(ESM) |
|
|||
|
|
| `PORT` | `8080` | 监听端口 |
|
|||
|
|
| `HEARTBEAT_INTERVAL_MS` | `30000` | 心跳间隔 |
|
|||
|
|
|
|||
|
|
应用自加:`DATABASE_URL`、`REDIS_URL`(订阅 TENANT_DS_CHANGE 即时切库)、`TENANT_DS_<KEY>`(物理库租户数据源,前缀 + key 大写、`-`→`_`)。
|
|||
|
|
|
|||
|
|
### 业务入口契约(APP_ENTRY)
|
|||
|
|
|
|||
|
|
ESM 模块,named export:
|
|||
|
|
|
|||
|
|
```js
|
|||
|
|
export async function handler(req, res) { /* node:http 原生,自行 writeHead/end */ }
|
|||
|
|
export async function dbCheck() { /* 探活依赖(如 SELECT 1),抛错即不健康;可选 */ }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- `handler` 缺省 → default export 函数兜底;都没有 → 业务路径 404
|
|||
|
|
- 启动副作用(连库、自检、迁移)写模块顶层 top-level await;**失败必须 `process.exit(1)`**
|
|||
|
|
- `/health` 由基础镜像提供:dbCheck 2s 内过 → `200 {"status":"ok","db":"ok"}`;失败 → 503 两键 error;无 dbCheck → `200 {"status":"ok","db":"unknown"}`
|
|||
|
|
|
|||
|
|
## 4. 四道闸(@aigrego/guard)
|
|||
|
|
|
|||
|
|
```js
|
|||
|
|
import { createGuard } from '@aigrego/guard';
|
|||
|
|
const guard = createGuard({ portalUrl, appKey, appSecret }); // 三个必填,缺即抛错
|
|||
|
|
const dispatch = guard.routes({
|
|||
|
|
'GET /notes': { scope: 'my-app.read', handler }, // scope 闸
|
|||
|
|
'GET /admin/x': { role: 'admin', handler }, // role 闸(superadmin 放行)
|
|||
|
|
'GET /settings': { scope: 'my-app.read', role: 'admin', pid: 'my-app:page:settings', handler },
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- 闸门顺序固定:**identity(401)→ audience → scope → role → acl(后四者 403)**;只有 identity 返回 401
|
|||
|
|
- identity 闸是**远程自省**(POST `/api/tokens/introspect`,带应用凭证),`active:true` 才放行;进程内缓存 60s;Portal 不可达 → 503 `portal_unreachable`
|
|||
|
|
- audience 闸在应用侧再验 `claims.aud === appKey`(防同网段直连绕过网关)
|
|||
|
|
- `spec.role` 等值匹配,`'admin'` 对 superadmin 放行;`spec.pid` 走权限点匹配(`"*"` 全放、`<appKey>:*`、`<appKey>:page:*`、`xxx.*` 前缀子树、精确相等)
|
|||
|
|
- `guard.routes` 只做精确匹配;路径参数自行拆段(参考 secret-manager `matchRoute` 范式),spec 集中登记闸口矩阵
|
|||
|
|
- 服务态令牌无 role/permissions claims——`role:'admin'` 的写端点天然挡住服务态令牌
|
|||
|
|
- 错误形态 `{error, gate}` JSON;handler 第三参拿 `claims`(含 `tenant_id`/`sub`/`scopes` 等)
|
|||
|
|
|
|||
|
|
## 5. 租户隔离红线(tdb)
|
|||
|
|
|
|||
|
|
```js
|
|||
|
|
const tdb = guard.createTenantDb({
|
|||
|
|
defaultDsn: process.env.DATABASE_URL,
|
|||
|
|
envPrefix: 'TENANT_DS_',
|
|||
|
|
redisUrl: process.env.REDIS_URL, // FR-30 热迁移即时切库;缺省降级 60s 轮询
|
|||
|
|
createPool: (dsn) => new pg.Pool({ connectionString: dsn }),
|
|||
|
|
// migrate: async (pool, tenantId) => { ... } // 每租户每数据源跑一次,失败该租户 fail-closed
|
|||
|
|
});
|
|||
|
|
await tdb.start();
|
|||
|
|
await guard.assertTenantTables(tdb, ['my_table']); // 每张业务表必须含 tenant_id 列,缺一拒启动
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- `tdb.query(sql, params)`:从 ALS 取 `claims.tenant_id`,SELECT/UPDATE/DELETE 自动改写追加 `tenant_id = $N`(参数占位,永不明文内联)
|
|||
|
|
- **INSERT 与 DDL 不改写**——INSERT 的 tenant_id 由应用显式写入(`VALUES ($1,...)` 第一参传 `claims.tenant_id`)
|
|||
|
|
- 改写器 fail-closed 白名单:JOIN、子查询、UNION/EXCEPT/INTERSECT、CTE、多表 FROM 一律抛错 → 改用 `tdb.queryUnscoped(sql, params, tenantId)` 并显式写租户过滤(省略 tenantId → 400)
|
|||
|
|
- 无租户上下文 → 403 `tenant_context_missing`;未知租户 → 403 `tenant_unknown`(不碰任何库)
|
|||
|
|
- 租户→数据源映射启动时拉 `GET /api/v1/tenants/datasources`,每 60s 轮询;传 redisUrl 则订阅即时切换;DSN 解析 `dsnMap[key] ?? env[TENANT_DS_<KEY>] ?? defaultDsn`
|
|||
|
|
- **唯一被认可的裸 pg 池**是 `/health` 的 dbCheck 探活池(`SELECT 1`);其余直连 pg = 红线
|
|||
|
|
- 收尾 `await tdb.stop()`
|
|||
|
|
|
|||
|
|
## 6. 日志与链路追踪(NFR-6)
|
|||
|
|
|
|||
|
|
```js
|
|||
|
|
import { createLogger } from '@aigrego/logger';
|
|||
|
|
const logger = createLogger({ service: 'my-app', level: 'info' });
|
|||
|
|
logger.info('request', { method, path, status, ms }); // 每请求一行
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- 每行一个 JSON 写 stdout;字段名含 `secret|password|token|authorization|api[-_]key` 任意深度自动脱敏 `[redacted]`——但仍靠变量命名纪律,密钥值不进 fields
|
|||
|
|
- `trace_id` 自动:tracing ALS → guard ALS claims;runtime-entry 已把 handler 包进 tracing ALS(入站 `traceparent` 续链)
|
|||
|
|
- **自建出站 HTTP 调用**(调别的 app 的 `/svc`)照 `ambientTraceparent()` 范式注入 `traceparent` 头
|
|||
|
|
|
|||
|
|
## 7. micro 前端接入(portal-sdk 宿主桥)
|
|||
|
|
|
|||
|
|
- 前端产物由 Portal 同源托管 `/apps/<key>/_/*`(未登录 302、深链 index.html 兜底);iframe sandbox 固定属性;**PROD CSP `default-src 'self'` 无 unsafe-inline——JS 必须外置文件**
|
|||
|
|
- **SDK 不打进 bundle**:`<script src="/apps/<key>/_/vendor/portal-sdk.js">` 注入全局 `createPortalClient`;vendor 文件 = 平台 `portal-sdk` 的 dist/browser.js 拷贝
|
|||
|
|
- 接入序列:`sdk.ready()` 握手 → `InitPayload{appKey,host,theme,locale,user,tenant,route,fullscreen,acl}`;不在 iframe 内运行会抛错 → 降级友好页
|
|||
|
|
- **后端调用一律 `sdk.callService(appKey, path, init)`**(宿主铸 `aud=<key>` 用户态 TDT 代发 `/svc/<key><path>`,零跨域);业务错误约定 `200+{ok:false,error:{code,message}}` 信封,闸口错误走传输层状态码
|
|||
|
|
- `sdk.getToken(scopes?)` 宿主缓存续期(窄令牌不复用为宽)
|
|||
|
|
- 深链:`?r=` 恢复 + `routeSync(path)` 上报 + `onRoute` 回推;`setBreadcrumbs`(≤6 级);`setDirty` 拦关闭
|
|||
|
|
- **ACL 前端收口**:握手 `init.acl`(admin → `bypass:true`;member → 仅 `defaultForMember` 页 PID 列表)驱动页面显隐与前端路由守卫——**只是 UX 闸,真闸是后端四道闸**
|
|||
|
|
- i18n:只读宿主 `localStorage['ts-lang']`(zh-CN/en-US)+ 监听 storage 事件实时切换,不反写;zh-CN 字典为唯一事实来源
|
|||
|
|
- 皮肤令牌 `--sk-*` 属门户自身,不辐射 iframe 内应用;micro 应用样式自管
|
|||
|
|
- 产物发布:`aigrego-release publish --dist <dir>`,包根必须直接含 `index.html`(≤64MB,原子替换);index.html 用**绝对路径** `/apps/<key>/_/...` 引用资源
|
|||
|
|
|
|||
|
|
## 8. 令牌(TDT)与跨应用交换
|
|||
|
|
|
|||
|
|
- RS256;claims:`iss:"tickshield-portal"`、`sub`、`tenant_id?`、`aud`==listingKey、`scopes[]`、`role?`、`permissions?`(仅用户态)、`token_use:"service"|"user"`、`act?`、`jti/iat/exp`
|
|||
|
|
- TTL:服务态 1h / 用户态 2h / 交换令牌 15min
|
|||
|
|
- 网关 `/svc/*` 纯本地验签:RS256 + exp(30s 宽容)+ **`aud` == 路径 listingKey**;Authorization 原样透传给应用(guard 自省是第二道)
|
|||
|
|
- FR-30 迁移冻结窗口:非 GET 且租户冻结 → 503 + `Retry-After: 2`,应用应把 503 当可重试
|
|||
|
|
- 取令牌:用户态走门户 `POST /api/tokens/mint {aud}`(iframe 应用永远用 `sdk.getToken`/`callService`);服务态 `POST /oauth/token {grant_type:"client_credentials", tenant_id}` + 应用凭证(目标租户须已安装本应用;进程内缓存、提前 5min 刷新)
|
|||
|
|
- 令牌交换:`POST /oauth/token {grant_type:"token-exchange", subject_token, audience:<目标key>, scope?}`——前置:目标在调用方 manifest `exchangeTargets` 里、目标已安装于该租户、用户态需逐用户 consent(服务态豁免但 subject 须带 tenant_id)、`scope` 收窄须目标 scopes 子集(否则 403 `SCOPE_NOT_ALLOWED`)
|
|||
|
|
- 交换所得带 `act` claim 的代理令牌**被 `/api/v1/*` 平台公共服务拒收**,只能走 `/svc/<aud>` 通道
|
|||
|
|
|
|||
|
|
## 9. 部署形态约束
|
|||
|
|
|
|||
|
|
- **容器名 == 网络别名 == listingKey**(BUG-45 契约:自报异值可投毒路由/把启停打到任意容器)
|
|||
|
|
- 业务容器必须接入网关所在 Docker 网络(路由承载网);网关 `resolver 127.0.0.11 valid=10s`
|
|||
|
|
- 固定监听端口约定 8080(商店安装可指定其他 port,平台经 `PORT` env 注入)
|
|||
|
|
- 商店运行时容器 spec(固定模板,API 只收 `{image, port, env}`):Labels `tickshield.app/tickshield.managed`;资源限额默认 512Mi/1核/256pids(只可收窄);保留 env 禁覆盖(`APP_KEY/APP_SECRET/PORTAL_API_URL/PORT`,安装时轮换签发新 secret);唯一允许的卷 = micro 门户托管产物目录只读挂 `/app/portal-public:ro`;**禁挂 docker.sock/宿主路径**;RestartPolicy `unless-stopped`
|
|||
|
|
- 安装主流程:轮换签发 secret → Pull(10min 超时)→ Create → Start → **等容器自注册(30s)** → 状态推进 + 补授权租户安装行 + 重写路由。30s 未自注册 → op failed(多为镜像不含自注册循环——所以必须 FROM 基础镜像)
|
|||
|
|
- 租户可见性 = 已授权 ∧ 已上架 ∧ 运行中;授权即安装(授权变更自动建/停 `app_installs` 行);收权停用数据保留
|
|||
|
|
- 授权语义:租户无 grant 行 = 全量授予(向后兼容);有行 = 白名单模式
|
|||
|
|
|
|||
|
|
## 10. 文档与工程规范(继承平台约定)
|
|||
|
|
|
|||
|
|
- 生态应用 PRD 命名 `docs/PRD_app-<listingKey>.md`;开头必备:项目代号 / 应用名称 / listingKey / 文档版本 / 文档状态 / 对标产品;必须明确 Goals 与 Non-Goals;用户故事格式「作为 [角色],我希望 [行为],以便 [价值]」
|
|||
|
|
- 代码注释绑定 FR-/NFR-/TKT-/BUG- 编号,写清"为什么";注释/日志/文档用中文
|
|||
|
|
- 钉版原则:基础镜像 tag、包版本一律钉死;构建期缺料必须让构建失败可见,不允许带病上线
|
|||
|
|
- 测试数据卫生:测试实体用登记前缀;清理脚本 `trap cleanup EXIT`
|