diff --git a/README.md b/README.md index 0f60a8e..8b51b93 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ npx skills remove runtime-image -g | [skill-forge](skills/skill-forge/SKILL.md) | 交互式创建新 Skill 的流水线,一问一答采集触发词/流程/红线,产出可直接使用的 SKILL.md | | [push-and-report](skills/push-and-report/SKILL.md) | 标准收尾流程:盘点 git 变更 → 英文 conventional commit → 推送远程 → SPMS 日报 | | [gitea-actions](skills/gitea-actions/SKILL.md) | 按统一规范给项目生成/评审优化 Gitea Actions 构建部署工作流(buildx 推 Gitea Packages → 本机重建容器 → 清理旧版本) | +| [dev-app](skills/dev-app/SKILL.md) | 按 TickShield 平台契约在独立仓库开发/审查/发布生态应用(listingKey 四位一体、四道闸、tenant_id 租户隔离、release-cli 上架部署),含模板组与静态检查脚本 | ## 目录结构 diff --git a/skills/dev-app/SKILL.md b/skills/dev-app/SKILL.md new file mode 100644 index 0000000..4bbe12e --- /dev/null +++ b/skills/dev-app/SKILL.md @@ -0,0 +1,191 @@ +--- +name: dev-app +description: 在独立仓库按 TickShield(刻盾云)平台契约开发、审查、发布生态应用(micro/service):listingKey 四位一体、app.manifest.json、tickshield-node-runtime 基础镜像、@aigrego/guard 四道闸与 tenant_id 租户隔离、micro 前端 portal-sdk 宿主桥、release-cli 发布与商店上架部署。凡用户要求「开发一个平台应用 / 新建 app / 写个 micro 应用 / 做个像 secret-manager / demo-app 那样的应用 / 在门户里加个自己的应用 / 接入 TickShield / 上架应用到商店 / 审查这个 app 能不能部署到平台 / 帮我看看接入还缺什么」,或带着 注册不上去 / 401 missing app credentials / 心跳 404 / host must equal listingKey / 四道闸 403 / 跨租户数据串了 / image registry not registered / invalid manifest skipped / 应用装上了但 /svc 不通 / PROD 白屏 CSP 报错 等症状,或提到 listingKey / app.manifest.json / 四道闸 / TDT / tenant_id / portal-sdk / release-cli / tickshield-node-runtime 时,务必使用本 skill——即使用户没说「应用接入」四个字。Use whenever the user develops, scaffolds, audits, onboards, publishes or deploys a TickShield platform app (micro or service) from an independent repository. 边界:非平台场景的通用 Dockerfile 选型评审用 runtime-image;通用 Gitea Actions 工作流规范用 gitea-actions;git 提交推送与日报用 push-and-report;平台基座本身(portal/gateway/packages/* 控制面)的开发不归本 skill。 +--- + +# dev-app — TickShield 生态应用开发规范(独立仓库 → 可直接部署上架) + +TickShield 生态应用的交付物只有两样:**镜像 + `app.manifest.json`**。注册/心跳/优雅下线/健康检查由基础镜像 `tickshield-node-runtime:base` 的 `runtime-entry.js` 全包;应用代码只需一个导出 `{handler, dbCheck?}` 的 ESM 入口,全量 SQL 走 `@aigrego/guard` 租户数据源,路由挂四道闸。本 skill 把这套契约锻成可执行的流程,两种工作模式: + +- **新建模式**:仓库没有 `app.manifest.json` / `Dockerfile` —— 交互采集四要素 → 照 `references/templates/` 生成骨架 +- **审查模式**:仓库已有接入痕迹 —— 对照 `references/checklist.md` 逐项审计 → 出修正清单 + +契约锚点:本 skill 基线 = TickShield 平台 v4.0 + APPSTORE-RUNTIME + FR-101 registries 白名单(2026-10)。与平台冲突时,以平台仓 `docs/reference/应用开发与上架指引.md` 和 `apps/portal/internal/modules/registry/manifest.go` 的校验代码为准,并回本仓库更新本 skill。`docs/reference/App出仓与集成指引.md`、`App开发者指南.html` 是**已废弃的历史文档**(@xgent 旧体系),禁止照搬。 + +## 总流程 + +``` +第 0 步 备料分流(判模式 + 采集四要素) +新建:第 1 步 生成骨架 → 第 2 步 静态自检(scripts/check-app.sh) +审查:第 1 步 逐项审计(checklist.md)→ 第 2 步 确认后修正 + 静态自检 +汇合:第 3 步 联调验证(需 dev 栈)→ 第 4 步 发布指引 → 第 5 步 汇报 +``` + +除非用户明确只要聊聊思路,完整走全部 5 步。 + +## 前置:先备齐 4 样(缺一样就不要开始) + +| 要什么 | 从哪来 | 放哪 | +|---|---|---| +| listingKey | 用户拍板(正则 `^[a-z0-9][a-z0-9-]{0,62}$`) | `app.manifest.json` + `.aigrego-registry.env` | +| 应用类型 | 用户拍板:`micro`(有门户内前端)/ `service`(无头后端) | `app.manifest.json` 的 `type` | +| 平台地址 | 用户告知(dev 默认 `http://localhost:8080`) | env `AIGREGO_PORTAL_URL`,不写死 | +| 应用凭证 `APP_SECRET` | **平台管理员在 console「应用市场」新建应用签发**,仅此一次明文可见 | 绝不落盘;CI 用 secrets 注入 env | + +缺料时停下来教用户怎么补(凭证找平台管理员签发;listingKey 让用户起个全局唯一的);**不要猜域名,不要从别的仓库/文档里翻凭证**。 + +## 第 0 步:备料分流 + +1. 看仓库根:无 `app.manifest.json` 且无 `Dockerfile` → 新建模式;否则 → 审查模式。 +2. 新建模式采集四要素(上表)。listingKey 必须用户亲口确认——它是**四位一体** key(== TDT `aud` == 容器名 == Docker 网络别名 == scope 命名空间),全局唯一、**不可变更**,改 key 等于换应用。 +3. scopes 约定 `.`(如 `my-app.read`/`my-app.write`),格式 `^[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)+$`(至少两段)。 + +→ 验收:四要素齐备且 listingKey 过正则、经用户确认;缺料则停在补料指引。 + +## 第 1 步(新建):生成骨架 + +照 `references/templates/` 复制并替换占位(`my-app` → 实际 listingKey),生成前列出文件清单给用户过目: + +- `app.manifest.json`(service / micro 两个模板选一) +- `Dockerfile`(`FROM tickshield-node-runtime:base`,照模板,不加 CMD) +- `server.js`(service 骨架:guard + tdb + assertTenantTables + logger + tracing + 示例路由) +- micro 型追加:`web/`(index.html / package.json / tsconfig.json / src/sdk.ts / src/api.ts / src/main.tsx) +- `.gitea/workflows/release.yaml`(照 `gitea-app-release.yaml`,提示配 secrets) +- `docs/PRD_app-.md` 草稿(照 `PRD_app-template.md`,文档状态标「草稿」) +- `.aigrego-registry.env.example` + 在 `.gitignore` 追加 `.aigrego-registry.env` + +生成后逐文件说明「这个文件哪几处必须改、哪几处永远别动」。 + +→ 验收:文件齐、占位全替换、用户确认清单。 + +## 第 1~2 步(审查):逐项审计 → 修正 + +1. 读 `references/checklist.md`,对照逐项审计,输出三档结论表:**通过 / 不符(die 级)/ 风险(warn 级)**,每项给证据(文件:行号)。 +2. 修正清单列给用户,**经确认后**逐项修;修完跑静态自检。 + +→ 验收:审计表覆盖 checklist 全部 die 级项;修正经用户逐批确认。 + +## 第 2 步:静态自检 + +```bash +bash <本skill目录>/scripts/check-app.sh <仓库根> +``` + +die 级不过 → 修了再往下走;warn 级列进汇报。check-app.sh 只查静态契约(manifest 字段/Dockerfile 三要素/入口接线/保留路径/密钥卫生),不替代第 3 步真环境联调。 + +→ 验收:脚本 exit 0(或仅剩 warn 且已列清单)。 + +## 第 3 步:联调验证(绝不可跳过的闸口) + +完成定义 = **dev 栈旁真环境走通**(平台 dev 栈未运行时,明说「未验证」并停在这里,不许拿静态自检绿冒充完成): + +1. 起容器(`container_name` == 网络别名 == listingKey,注入 `APP_KEY/APP_SECRET/PORTAL_API_URL` + 业务 env)→ portal `/api/services` 出现该实例且心跳鲜活。 +2. `/health` 直连与经网关 `/svc//health` 均返回**恰两键** `{"status":"ok","db":"ok"}`。 +3. 四道闸矩阵:匿名 → 401;跨 aud → 网关 401 / 直连 403 `gate=audience`;无 scope → 403 `gate=scope`;member 调 admin 路由 → 403 `gate=role`;无页面权限 → 403 `gate=acl`;合法调用 → 200。 +4. 租户隔离:A 租户写入,B 租户读 = 空集。 +5. micro 型追加:门户内打开应用,`sdk.ready()` 握手成功、acl 驱动页面显隐、`callService` 通。 + +平台仓 `scripts/verify-onboarding.sh` 是这六项的出口门基准(按其样式改写成自己 app 的演练路由)。 + +→ 验收:六项全过并留档;做不到的项在汇报里逐项声明「未验证」及原因。 + +## 第 4 步:发布指引 + +发布是**写操作**:默认只给命令草稿,用户显式说「发布」才执行。详见 `references/publish-deploy.md`: + +```bash +export AIGREGO_PORTAL_URL=<平台地址> APP_KEY= APP_SECRET= +aigrego-release whoami # 先验凭证(放在构建之前) +aigrego-release publish --manifest app.manifest.json [--image ] [--store] [--wait 600] +# micro 前端产物:aigrego-release publish --dist (包根必须直接含 index.html) +``` + +- 治理字段(`scopes`/`aclManifest`/`exchangeTargets`/`dependencies`)变更 → 202 进审核队列(exit 0 但**不生效**),批准前不影响已安装租户;首个版本直接生效。 +- 商店上架/授权/安装/启停/卸载是平台侧 console 动作;镜像 host 必须在 registries 白名单(空表=全拒)。 +- 输出「待平台侧操作」清单:auto_publish 开关、registries 登记、治理审核批准、授权安装——这些 Agent 做不到,不绕道不假装完成。 + +→ 验收:命令草稿正确(版本号 semver、镜像 tag 与 manifest.version 对齐);已执行的发布如实记录响应(200/202/4xx)。 + +## 第 5 步:汇报 + +固定汇报结构: + +- 做了什么:模式 / 生成或修改的文件 / 执行过的命令 +- 关键标识:listingKey、版本号、镜像 ref、关键文件路径 +- 被跳过的:未生成/未修的项及原因 +- 没写成的:占位残留、未替换项 +- 验证做到哪一步:静态自检结果;联调六项各自 过/未验证(**没跑就说没跑**) +- 待平台侧操作:凭证签发 / auto_publish / registries 白名单 / 审核 / 授权安装 +- 开放问题:用户未拍板的事项 + +## 硬约束 + +- ① **listingKey 一经确认不变更**。为什么:它是 TDT aud、容器名、网络别名、scope 命名空间的同一事实源,平台按 `UNIQUE(listing_key,host,port)` 记账;改 key = 新应用,旧数据/授权/安装全部悬空。 +- ② **Dockerfile 必须 `FROM tickshield-node-runtime:base`,不覆盖 CMD**。为什么:注册重试循环、心跳 404 自愈/409 停服等待、SIGTERM 先 deregister 再摘流、`/health` 三态信封全在 `runtime-entry.js`;自造入口 = 商店安装 30s 等自注册超时 op failed。 +- ③ **业务 SQL 一律走 `tdb.query`,INSERT 的 tenant_id 由应用显式写入**。为什么:SELECT/UPDATE/DELETE 自动改写追加 `tenant_id=$N` 参数;直连 pg 即绕开租户隔离与 FR-30 数据源路由——跨租户串数据是安全事故不是 bug。JOIN/子查询/UNION/CTE 被改写器 fail-closed 拒绝,确需时用 `tdb.queryUnscoped(sql, params, tenantId)` 并显式写租户过滤。 +- ④ **启动必须 `await guard.assertTenantTables(tdb, [业务表...])`,失败 `process.exit(1)`**。为什么:表缺 `tenant_id` 列时改写器静默失效变全表扫描;带病启动比不启动更糟(钉版原则同理:构建期缺料必须让构建失败可见)。 +- ⑤ **应用 API 路径禁用 `/api`、`/auth`、`/svc` 前缀**(含自定义域名下),用 `/v1/` 之类。为什么:这三个前缀是平台保留通道,网关静态转发/验签规则会截胡或放行错流量。 +- ⑥ **密钥/令牌不进日志、不落配置文件、不进 argv**。为什么:`@aigrego/logger` 的字段名自动脱敏只是兜底;CI 里命令行参数会进 `ps` 和构建日志。凭证走 env 注入。 +- ⑦ **manifest 不携带 `deployDescriptor`**。为什么:它仅平台管理员可写;自注册时会被剥离(记 warn+审计),submit 携带非管理员直接 403——带了也没用,还制造「以为生效」的错觉。 +- ⑧ **micro 前端:portal-sdk 用 vendor 版(` + + + diff --git a/skills/dev-app/references/templates/web/package.json b/skills/dev-app/references/templates/web/package.json new file mode 100644 index 0000000..d1edc54 --- /dev/null +++ b/skills/dev-app/references/templates/web/package.json @@ -0,0 +1,20 @@ +{ + "name": "my-app-web", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "my-app 微应用前端:bun 单文件构建,portal-sdk 经 vendor 全局注入(不打包 SDK)。", + "scripts": { + "build": "bun build src/main.tsx --outfile dist/app.bundle.js", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "typescript": "^5.5.0" + } +} diff --git a/skills/dev-app/references/templates/web/src/api.ts b/skills/dev-app/references/templates/web/src/api.ts new file mode 100644 index 0000000..89d7185 --- /dev/null +++ b/skills/dev-app/references/templates/web/src/api.ts @@ -0,0 +1,37 @@ +// 后端调用封装:一律经 sdk.callService 走宿主代发(宿主铸 aud= 用户态 TDT +// 代发 /svc/,iframe 零跨域),不直接 fetch /svc/...。 +// 业务错误约定 200+{ok:false,error:{code,message}} 信封;闸口错误(401/403)走传输层状态码。 +// 占位:全局替换 my-app → 你的 listingKey。 +import { sdk } from './sdk'; + +const APP_KEY = 'my-app'; + +export class ApiError extends Error { + constructor( + public status: number, + public code: string, + message: string, + ) { + super(message); + } +} + +export async function callApi( + path: string, + init?: { method?: string; body?: unknown }, +): Promise { + if (!sdk) throw new ApiError(0, 'no_host', '必须在门户内打开'); + const out = await sdk.callService<{ ok?: boolean; error?: { code: string; message: string } } & T>( + APP_KEY, + path, + { + method: init?.method ?? 'GET', + body: init?.body === undefined ? undefined : JSON.stringify(init.body), + headers: init?.body === undefined ? undefined : { 'Content-Type': 'application/json' }, + }, + ); + if (out && typeof out === 'object' && 'ok' in out && out.ok === false) { + throw new ApiError(200, out.error?.code ?? 'unknown', out.error?.message ?? 'unknown'); + } + return out as T; +} diff --git a/skills/dev-app/references/templates/web/src/main.tsx b/skills/dev-app/references/templates/web/src/main.tsx new file mode 100644 index 0000000..75ab4a5 --- /dev/null +++ b/skills/dev-app/references/templates/web/src/main.tsx @@ -0,0 +1,74 @@ +// micro 应用前端入口:ready() 握手 → acl 驱动路由守卫 → callService 调自有后端。 +// i18n 约定:只读宿主 localStorage['ts-lang'](zh-CN/en-US)+ 监听 storage 事件实时切换, +// 不反写;正式应用照此建 i18n 字典(zh-CN 为唯一事实来源),本骨架从简。 +// 占位:全局替换 my-app → 你的 listingKey。 +import React, { useEffect, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { sdk, initialRoute, canPage, type InitPayloadLike } from './sdk'; +import { callApi } from './api'; + +function HomePage() { + const [items, setItems] = useState<{ id: string; title: string }[]>([]); + const [error, setError] = useState(''); + useEffect(() => { + callApi<{ items: { id: string; title: string }[] }>('/v1/items') + .then((out) => setItems(out.items ?? [])) + .catch((e) => setError(String(e?.message ?? e))); + }, []); + return ( +
+

首页

+ {error &&

加载失败:{error}

} +
    + {items.map((it) => ( +
  • {it.title}
  • + ))} +
+
+ ); +} + +function SettingsPage() { + return ( +
+

设置

+

仅管理员可见(前端 acl 是 UX 闸,后端四道闸才是真闸)。

+
+ ); +} + +function App() { + const [init, setInit] = useState(null); + const [route, setRoute] = useState(initialRoute()); + const [failed, setFailed] = useState(''); + + useEffect(() => { + if (!sdk) { + setFailed('请在门户内打开本应用(宿主 iframe 桥不可用)。'); + return; + } + sdk.ready() + .then((payload) => setInit(payload)) + .catch((e) => setFailed(String(e?.message ?? e))); + }, []); + + function go(path: string) { + setRoute(path); + sdk?.routeSync(path); // 深链上报:刷新后宿主经 ?r= 恢复 + } + + if (failed) return
{failed}
; + if (!init) return
加载中…
; + + return ( +
+ + {route === '/settings' && canPage(init, 'settings') ? : } +
+ ); +} + +createRoot(document.getElementById('root')!).render(); diff --git a/skills/dev-app/references/templates/web/src/sdk.ts b/skills/dev-app/references/templates/web/src/sdk.ts new file mode 100644 index 0000000..8c99401 --- /dev/null +++ b/skills/dev-app/references/templates/web/src/sdk.ts @@ -0,0 +1,57 @@ +// portal-sdk 浏览器全局封装:vendor/portal-sdk.js 经 classic