feat: add publish-app skill; rename dev-app to app-dev

- publish-app: convert/add Gitea Actions deploy job to publish TickShield
  apps to the marketplace via aigrego-release (Go static binary) —
  --store auto-publish + --wait review gate; local-first CLI fetch
  (ensure-cli.sh) with minIO platform-matrix download + sha256 verify;
  workflow template and 12-item check script
- rename dev-app to app-dev per app-centric naming convention (no
  tickshield- prefix), documented in AGENTS.md and README
- app-dev boundary routes CI publish workflow work to publish-app;
  both skills are project-level installs only
This commit is contained in:
sunjunzhao committed 2026-10-19 16:38:22 +08:00
1 parent 5c9886ac24
commit 8abc6a4abf
25 files changed
+524 -5

No files matched your search

@@ -0,0 +1,18 @@
# TickShield 生态应用 Dockerfile(service 与 micro 后端通用)。
# 硬约束:FROM 基础镜像 + symlink 预置依赖 + 不覆盖 CMD。
# 构建上下文 = 本仓库根(docker build -f Dockerfile . 或 compose build.context 指到仓根)。
FROM tickshield-node-runtime:base
WORKDIR /app
COPY server.js package.json app.manifest.json ./
# 让 import 解析到基础镜像预置的 @aigrego/guard|logger|tracing 与 pg。
RUN ln -s /opt/xgent/runtime/node_modules /app/node_modules
# 默认值即此,显式写出便于审阅;不要覆盖 CMD/ENTRYPOINT(注册/心跳/优雅下线/health
# 全在基础镜像 runtime-entry.js)。
ENV APP_ENTRY=/app/server.js MANIFEST_PATH=/app/app.manifest.json
# micro 型前端产物不进镜像:CI 里 bun 构建 web/ 后经 aigrego-release publish --dist 上传,
# 由 Portal 同源托管 /apps/<key>/_/*。
@@ -0,0 +1,80 @@
# 刻盾云(TickShield)<应用名称> 应用 – 需求规格书 v0.1
**项目代号**:TickShield
**应用名称**:<应用名称>
**listingKey**:`<listingKey>`
**文档版本**:v0.1
**文档状态**:草稿
**对标产品**:<对标产品>
> 本文是 TickShield 平台生态应用 `<listingKey>` 的需求规格书,严格对齐平台接入规范
> (`app.manifest.json` 契约、四道闸鉴权、`tenant_id` 多租户隔离、`@aigrego/portal-sdk`
> 前端集成、`@aigrego/release-cli` 发布)。平台基座契约以平台仓 `docs/PRD_v4.0.md` 与
> `docs/Architecture_v4.0.md` 为准。
---
## 一、产品定位与目标
### 1.1 定位
<一段话:这个应用是什么、给谁用、解决什么问题。>
### 1.2 目标(Goals)
- **G1** <目标,可验收>
- **G2** <...>
### 1.3 非目标(Non-Goals)
- **NG1** <明确不做什么>
- **NG2** <...>
## 二、用户故事
- 作为 <角色>,我希望 <行为>,以便 <价值>。
## 三、应用接入规范(TickShield 契约)
- **listingKey**:`<listingKey>`(四位一体:TDT aud == 容器名 == 网络别名 == scope 命名空间,不可变更)
- **类型**:`micro` / `service`
- **scopes**:`<listingKey>.read` / `<listingKey>.write`(治理字段,变更进平台审核队列)
- **aclManifest 页面与权限点**:<列出 pages/actions,PID = `<listingKey>:page|action:<key>`>
- **exchangeTargets**:<跨应用调用目标,无则写「无」>
- **数据表与租户隔离**:<表清单,均含 tenant_id 列;全部 SQL 经 @aigrego/guard tdb>
- **部署形态**:镜像 `FROM tickshield-node-runtime:base`;监听 `PORT`(默认 8080);环境变量 <列出业务 env>
## 四、功能需求(FR)
| 编号 | 需求 | 验收标准 |
|---|---|---|
| FR-1 | <...> | <...> |
## 五、非功能需求(NFR)
| 编号 | 需求 | 指标 |
|---|---|---|
| NFR-1 | 性能 | <如 单取 P99≤50ms> |
| NFR-2 | 安全 | 四道闸矩阵全绿;跨租户空集;敏感值零命中日志 |
## 六、API 契约
| 方法 | 路径 | 闸口 spec | 说明 |
|---|---|---|---|
| GET | `/v1/items` | `scope: <listingKey>.read` | <...> |
| POST | `/v1/items` | `scope: <listingKey>.write` | <...> |
> 路径禁 `/api` `/auth` `/svc` 前缀(平台保留通道)。
## 七、验收清单
- [ ] 注册生效(`/api/services` 出现且心跳鲜活)
- [ ] `/health` 恰两键 `{"status":"ok","db":"ok"}`(直连 + 经网关)
- [ ] 四道闸矩阵:匿名 401 / 跨 aud 401·403 / 无 scope 403 / role 403 / acl 403 / 合法 200
- [ ] 租户隔离:跨租户读 = 空集
- [ ] (micro)SDK 握手 + acl 显隐 + callService 通
- [ ] 发布演练:`publish --manifest/--dist/--image` 全链路
## 八、范围边界与开放问题
- OQ-1:<开放问题>
@@ -0,0 +1,5 @@
# 应用身份配置(本文件是模板;真配置复制为 .aigrego-registry.env 并务必进 .gitignore)。
# release-cli 从配置文件只读 LISTING_KEY——APP_SECRET 写在这里会被告警并忽略(密钥不落盘)。
LISTING_KEY=my-app
# 平台地址建议走环境变量 AIGREGO_PORTAL_URL(随 dev/staging/prod 变),此处仅作缺省备注。
# AIGREGO_PORTAL_URL=http://localhost:8080
@@ -0,0 +1,36 @@
{
"listingKey": "my-app",
"type": "micro",
"name": "我的应用",
"version": "0.1.0",
"icon": "box",
"color": "#0d9488",
"cat": "tools",
"tagline": "一句话说明(≤120 字符)",
"desc": "应用描述(≤2000 字符)。",
"scopes": ["my-app.read", "my-app.write"],
"exchangeTargets": [],
"navItems": [
{ "id": "home", "label": "首页", "path": "/", "icon": "house" },
{ "id": "settings", "label": "设置", "path": "/settings", "icon": "settings" }
],
"footerNavItems": [
{ "id": "help", "label": "帮助", "path": "/help", "icon": "circle-help" }
],
"dashboard.widgets": [],
"aclManifest": {
"pages": [
{
"key": "home",
"label": "首页",
"audience": "viewer",
"defaultForMember": true,
"actions": [
{ "key": "item.create", "label": "新建条目" },
{ "key": "item.delete", "label": "删除条目" }
]
},
{ "key": "settings", "label": "设置", "audience": "admin", "defaultForMember": false }
]
}
}
@@ -0,0 +1,29 @@
{
"listingKey": "my-app",
"type": "service",
"name": "我的应用",
"version": "0.1.0",
"icon": "🧩",
"color": "#2563eb",
"cat": "tools",
"tagline": "一句话说明(≤120 字符)",
"desc": "应用描述(≤2000 字符)。",
"scopes": ["my-app.read", "my-app.write"],
"exchangeTargets": [],
"navItems": [],
"footerNavItems": [
{ "id": "docs", "label": "接入文档", "path": "/docs", "icon": "📖" }
],
"dashboard.widgets": [],
"aclManifest": {
"pages": [
{
"key": "settings",
"label": "设置",
"audience": "admin",
"defaultForMember": false,
"actions": [{ "key": "settings.manage", "label": "管理设置" }]
}
]
}
}
@@ -0,0 +1,50 @@
# Gitea Actions 应用自动上架模板(APPSTORE-RUNTIME)——复制到应用仓库 .gitea/workflows/ 下使用。
#
# 链路:推 tag → buildx 构建推送镜像到平台 registry → 经 release-cli(单文件 CLI,
# node 容器内执行,runner 无需装 node)提交 manifest → 记录镜像 → 自动上架。
#
# 前置:
# 1. 平台商店「新建应用」签发凭证,配到仓库 secrets:APP_KEY / APP_SECRET;
# 2. 商店应用详情开「auto_publish」(否则 CI 停在记录镜像,等平台管理员手动上架);
# 3. registry 推送凭证 secrets:REGISTRY_USER / REGISTRY_TOKEN(推送 livebook:8418 用)。
name: app-release
on:
push:
tags: ["v*"]
permissions:
contents: read
jobs:
publish:
runs-on: self-hosted
steps:
- name: Checkout tag source
run: |
rm -rf src && git clone --depth 1 --branch "$GITHUB_REF_NAME" . src
cd src && echo "commit: $(git rev-parse --short HEAD)"
- name: Build & push image(多架构)
run: |
IMAGE="${{ vars.APP_IMAGE_PREFIX || 'livebook:8418/projects' }}/$(basename $GITHUB_REPOSITORY)"
TAG="${GITHUB_REF_NAME#v}"
docker login -u "${{ secrets.REGISTRY_USER }}" -p "${{ secrets.REGISTRY_TOKEN }}" livebook:8418
docker buildx build --platform linux/amd64,linux/arm64 --push \
-t "$IMAGE:$TAG" -t "$IMAGE:latest" ./src
echo "IMAGE_REF=$IMAGE:$TAG" >> "$GITHUB_ENV"
- name: Submit manifest + record image + auto publish
run: |
docker run --rm \
-e AIGREGO_PORTAL_URL="${{ vars.TICKSHIELD_PORTAL_URL || 'http://gateway:8080' }}" \
-e APP_KEY="${{ secrets.APP_KEY }}" \
-e APP_SECRET="${{ secrets.APP_SECRET }}" \
-v "$PWD/src:/src" node:20-alpine \
sh -c 'node /src/cli/aigrego-release.mjs publish \
--manifest /src/app.manifest.json \
--image "$0" --store --wait 600' "$IMAGE_REF"
# CLI 单文件获取(仓库内不落盘时):
# sh -c 'wget -qO /tmp/cli.mjs https://code.innev.cn/images/tickshield-dist/raw/branch/main/cli/aigrego-release.mjs && node /tmp/cli.mjs ...'
# micro 应用前端产物(先 bun 构建 web/ 再上传,包根必须直接含 index.html):
# sh -c 'cd /src/web && bun build src/main.tsx --outfile dist/app.bundle.js && node /src/cli/aigrego-release.mjs publish --dist dist'
@@ -0,0 +1,130 @@
// my-app 业务入口(APP_ENTRY)。契约:导出 { handler, dbCheck? } 的 ESM 模块。
// 注册/心跳/优雅下线//health 由基础镜像 runtime-entry.js 全包——本文件不监听端口、
// 不写注册代码。启动副作用写模块顶层(top-level await),失败必须 process.exit(1)。
//
// 占位说明:全局替换 my-app → 你的 listingKey;my_table → 你的业务表;
// 路由表演示四种闸口组合,按业务改写。
import pg from 'pg';
import { createGuard } from '@aigrego/guard';
import { createLogger } from '@aigrego/logger';
const PORTAL_API_URL = (process.env.PORTAL_API_URL || 'http://portal:3000').replace(/\/+$/, '');
const APP_KEY = process.env.APP_KEY || 'my-app';
const APP_SECRET = process.env.APP_SECRET || '';
const DATABASE_URL = process.env.DATABASE_URL || '';
const logger = createLogger({ service: 'my-app' });
const guard = createGuard({ portalUrl: PORTAL_API_URL, appKey: APP_KEY, appSecret: APP_SECRET });
// 租户数据源:全部业务 SQL 的唯一合法通路(租户隔离红线)。
export const tdb = guard.createTenantDb({
defaultDsn: DATABASE_URL,
envPrefix: 'TENANT_DS_',
redisUrl: process.env.REDIS_URL, // FR-30 热迁移即时切库;缺省降级 60s 轮询
createPool: (dsn) => new pg.Pool({ connectionString: dsn }),
});
// /health 探活专用池(SELECT 1)——唯一被认可的裸 pg 用法。
const checkPool = new pg.Pool({ connectionString: DATABASE_URL, max: 1 });
try {
await tdb.start();
await guard.assertTenantTables(tdb, ['my_table']); // 每张业务表必须含 tenant_id 列
logger.info('startup checks passed', { tables: ['my_table'] });
} catch (error) {
logger.error('startup checks failed, refusing to serve', { error: error?.message });
process.exit(1);
}
function sendJson(res, status, payload) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
}
function readJsonBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
try {
resolve(chunks.length > 0 ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {});
} catch (error) {
reject(error);
}
});
req.on('error', reject);
});
}
// 路由表:闸口顺序固定 identity(401) → audience → scope → role → acl(403)。
// 路径禁 /api /auth /svc 前缀(平台保留通道),用 /v1/ 之类。
const dispatch = guard.routes({
'GET /v1/items': {
scope: 'my-app.read',
handler: async (req, res) => {
// SELECT 自动改写追加 tenant_id=$N(参数占位)。
const { rows } = await tdb.query(
'SELECT id, title, created_at FROM my_table ORDER BY created_at DESC',
);
sendJson(res, 200, { items: rows });
},
},
'POST /v1/items': {
scope: 'my-app.write',
handler: async (req, res, claims) => {
let body;
try {
body = await readJsonBody(req);
} catch {
sendJson(res, 400, { error: 'invalid_json' });
return;
}
const title = typeof body?.title === 'string' ? body.title.trim() : '';
if (!title) {
sendJson(res, 400, { error: 'title_required' });
return;
}
// INSERT 不改写——tenant_id 由应用显式写入(第一参 claims.tenant_id)。
const { rows } = await tdb.query(
'INSERT INTO my_table (tenant_id, title, created_by) VALUES ($1,$2,$3) RETURNING id',
[claims.tenant_id, title, claims.sub],
);
sendJson(res, 201, { id: rows[0].id });
},
},
'GET /v1/admin/stats': {
role: 'admin', // 服务态令牌无 role,天然过不了此闸
handler: async (req, res, claims) => {
const { rows } = await tdb.query('SELECT count(*)::int AS n FROM my_table');
sendJson(res, 200, { count: rows[0].n, tenant: claims.tenant_id });
},
},
'GET /v1/settings': {
scope: 'my-app.read',
role: 'admin',
pid: 'my-app:page:settings', // acl 闸:对照 aclManifest.pages
handler: async (req, res) => {
sendJson(res, 200, { settings: {} });
},
},
});
// runtime-entry 已把 handler 包进 tracing ALS(入站 traceparent 续链),
// logger 自动带 trace_id;自建出站 HTTP 调用记得注入 traceparent 头。
export async function handler(req, res) {
const start = Date.now();
try {
await dispatch(req, res);
} finally {
logger.info('request', {
method: req.method,
path: String(req.url || '').split('?')[0],
status: res.statusCode,
ms: Date.now() - start,
});
}
}
export async function dbCheck() {
await checkPool.query('SELECT 1');
}
@@ -0,0 +1,17 @@
<!doctype html>
<!-- micro 应用托管页:资源一律绝对路径 /apps/<key>/_/ 引用(门户同源托管 /apps/<key>/_/*)。
PROD CSP 严格 default-src 'self'——禁止内联脚本,JS 必须外置文件。
portal-sdk 用 vendor 版全局注入(不打进 bundle,桥协议版本与宿主同源)。
占位:全局替换 my-app → 你的 listingKey。 -->
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>我的应用</title>
</head>
<body>
<div id="root"></div>
<script src="/apps/my-app/_/vendor/portal-sdk.js"></script>
<script src="/apps/my-app/_/app.bundle.js"></script>
</body>
</html>
@@ -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"
}
}
@@ -0,0 +1,37 @@
// 后端调用封装:一律经 sdk.callService 走宿主代发(宿主铸 aud=<key> 用户态 TDT
// 代发 /svc/<key><path>,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<T = unknown>(
path: string,
init?: { method?: string; body?: unknown },
): Promise<T> {
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;
}
@@ -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 (
<main style={{ padding: 16 }}>
<h1>首页</h1>
{error && <p role="alert">加载失败:{error}</p>}
<ul>
{items.map((it) => (
<li key={it.id}>{it.title}</li>
))}
</ul>
</main>
);
}
function SettingsPage() {
return (
<main style={{ padding: 16 }}>
<h1>设置</h1>
<p>仅管理员可见(前端 acl 是 UX 闸,后端四道闸才是真闸)。</p>
</main>
);
}
function App() {
const [init, setInit] = useState<InitPayloadLike | null>(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 <main style={{ padding: 16 }}>{failed}</main>;
if (!init) return <main style={{ padding: 16 }}>加载中…</main>;
return (
<div>
<nav style={{ display: 'flex', gap: 12, padding: 12 }}>
{canPage(init, 'home') && <button onClick={() => go('/')}>首页</button>}
{canPage(init, 'settings') && <button onClick={() => go('/settings')}>设置</button>}
</nav>
{route === '/settings' && canPage(init, 'settings') ? <SettingsPage /> : <HomePage />}
</div>
);
}
createRoot(document.getElementById('root')!).render(<App />);
@@ -0,0 +1,57 @@
// portal-sdk 浏览器全局封装:vendor/portal-sdk.js 经 classic <script> 注入
// createPortalClient,应用源码不打包 SDK(保证与宿主桥协议版本同源)。
// 主题自管——不读握手 payload 里的 theme 硬编码。
// acl(admin→{bypass:true};member→仅 defaultForMember 页的 PID 列表)驱动页面可见性/
// 路由守卫——只是 UX 闸,真闸是后端四道闸。
// 占位:全局替换 my-app → 你的 listingKey。
export interface AclInit {
bypass?: boolean;
permissions?: { pid: string; scope?: string }[];
}
export interface InitPayloadLike {
user?: { id: string; name: string } | null;
route?: string | null;
acl?: AclInit;
}
export interface PortalClientLike {
ready(): Promise<InitPayloadLike>;
getToken(scopes?: string[]): Promise<string>;
callService<T = unknown>(
name: string,
path: string,
init?: { method?: string; body?: string; headers?: Record<string, string>; timeoutMs?: number },
): Promise<T>;
routeSync(path: string): void;
setDirty(dirty: boolean): void;
}
const factory = (globalThis as { createPortalClient?: () => PortalClientLike }).createPortalClient;
// 直接以顶层页面打开(/apps/<key>/_/ 静态托管路径)时 createPortalClient 抛
//「must run inside the portal host iframe」——降级为 sdk=null,由 UI 给友好提示。
function createClient(): PortalClientLike | null {
if (!factory) return null;
try {
return factory();
} catch {
return null;
}
}
export const sdk: PortalClientLike | null = createClient();
// initialRoute:宿主把 App 内部路由放在 iframe URL 的 ?r= 上(刷新可恢复深链)。
export function initialRoute(): string {
const r = new URLSearchParams(globalThis.location?.search ?? '').get('r');
return r && r.startsWith('/') && !r.startsWith('//') ? r : '/';
}
// 页面级可见性判定:pageKey ∈ aclManifest.pages;admin(bypass)全放行。
export function canPage(init: InitPayloadLike | null, pageKey: string): boolean {
const acl = init?.acl;
if (!acl) return true; // 旧宿主无 acl 字段时不设防(真闸在后端)
if (acl.bypass === true) return true;
return (acl.permissions ?? []).some((p) => p.pid === `my-app:page:${pageKey}`);
}
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["react", "react-dom"]
},
"include": ["src"]
}