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.
14 KiB
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.goValidListingKey) - 应用类型
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 不可达 → 503portal_unreachable - audience 闸在应用侧再验
claims.aud === appKey(防同网段直连绕过网关) spec.role等值匹配,'admin'对 superadmin 放行;spec.pid走权限点匹配("*"全放、<appKey>:*、<appKey>:page:*、xxx.*前缀子树、精确相等)guard.routes只做精确匹配;路径参数自行拆段(参考 secret-managermatchRoute范式),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;未知租户 → 403tenant_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 CSPdefault-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?}——前置:目标在调用方 manifestexchangeTargets里、目标已安装于该租户、用户态需逐用户 consent(服务态豁免但 subject 须带 tenant_id)、scope收窄须目标 scopes 子集(否则 403SCOPE_NOT_ALLOWED) - 交换所得带
actclaim 的代理令牌被/api/v1/*平台公共服务拒收,只能走/svc/<aud>通道
9. 部署形态约束
- 容器名 == 网络别名 == listingKey(BUG-45 契约:自报异值可投毒路由/把启停打到任意容器)
- 业务容器必须接入网关所在 Docker 网络(路由承载网);网关
resolver 127.0.0.11 valid=10s - 固定监听端口约定 8080(商店安装可指定其他 port,平台经
PORTenv 注入) - 商店运行时容器 spec(固定模板,API 只收
{image, port, env}):Labelstickshield.app/tickshield.managed;资源限额默认 512Mi/1核/256pids(只可收窄);保留 env 禁覆盖(APP_KEY/APP_SECRET/PORTAL_API_URL/PORT,安装时轮换签发新 secret);唯一允许的卷 = micro 门户托管产物目录只读挂/app/portal-public:ro;禁挂 docker.sock/宿主路径;RestartPolicyunless-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