Files
skills/skills/dev-app/references/contract.md
T
sunjunzhao 5de73214f8 feat: add dev-app skill for TickShield app development contract
Guides and constrains eco-app development in independent repos against the
TickShield platform contract: listingKey identity, app.manifest.json fields,
tickshield-node-runtime base image, guard four-gate authz, tenant_id isolation,
portal-sdk host bridge for micro frontends, release-cli publish and store
deployment. Ships copy-ready templates (manifest/Dockerfile/server.js/web
skeleton/Gitea release workflow/PRD) plus a zero-dependency static checker
(scripts/check-app.sh) covering die/warn contract items.
2026-10-12 12:28:24 +08:00

14 KiB
Raw Blame History

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:

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)

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)

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)

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