@oj-module/cli 0.2.69 → 0.2.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/devkit/README.md +10 -8
  2. package/devkit/framework-dev.md +1 -1
  3. package/devkit/manual.md +45 -19
  4. package/devkit/module-dev-and-cdn-playground.md +20 -14
  5. package/devkit/publish-walkthrough.md +132 -43
  6. package/devkit/wecom-customer-reauth-guide.md +110 -0
  7. package/package.json +2 -2
  8. package/shell-dist/assets/{runtime.zrv2zhj9j0.js → runtime.7x99hmm020.js} +2 -2
  9. package/shell-dist/bundles/oj-market-0.2.0.tgz +0 -0
  10. package/shell-dist/bundles/oj-market-0.2.0.tgz.sig +8 -0
  11. package/shell-dist/index.html +4 -4
  12. package/shell-dist/versions.json +3 -3
  13. package/src/__tests__/list.test.ts +7 -7
  14. package/src/__tests__/oj-health-host.test.ts +19 -0
  15. package/src/__tests__/site-tenant-header.test.ts +2 -2
  16. package/src/__tests__/templates-builtin-list.test.ts +47 -0
  17. package/src/__tests__/templates-schema-columns.test.ts +11 -2
  18. package/src/__tests__/templates-tenant-scope.test.ts +2 -3
  19. package/src/__tests__/users-roles-display.test.ts +84 -0
  20. package/src/bundle.ts +1 -1
  21. package/src/contract/__tests__/market-client.integration.test.ts +23 -22
  22. package/src/enable.ts +2 -2
  23. package/src/init.ts +45 -0
  24. package/src/install.ts +5 -5
  25. package/src/lifecycle/__tests__/install.test.ts +29 -29
  26. package/src/lifecycle/__tests__/market-bundle.test.ts +87 -0
  27. package/src/lifecycle/__tests__/publish.test.ts +8 -10
  28. package/src/lifecycle/__tests__/trust.test.ts +14 -14
  29. package/src/lifecycle/__tests__/uninstall.test.ts +4 -4
  30. package/src/list.ts +3 -3
  31. package/src/market/__tests__/bootstrap.test.ts +24 -7
  32. package/src/market/__tests__/failure-playbook.test.ts +0 -1
  33. package/src/market/__tests__/remote-push.test.ts +228 -0
  34. package/src/market/__tests__/verify.test.ts +45 -207
  35. package/src/market/bootstrap.ts +12 -7
  36. package/src/market/command.ts +33 -35
  37. package/src/market/db-seed.ts +129 -0
  38. package/src/market/failure-playbook.ts +3 -9
  39. package/src/market/fixture.ts +2 -6
  40. package/src/market/publish.ts +145 -89
  41. package/src/market/remote-push.ts +236 -0
  42. package/src/market/trial.ts +2 -2
  43. package/src/market/verify.ts +17 -270
  44. package/src/market/walkthrough.ts +38 -63
  45. package/src/oj.ts +13 -2
  46. package/src/preview.ts +5 -3
  47. package/src/publish.ts +3 -2
  48. package/src/scaffold-guard.ts +4 -0
  49. package/src/site.ts +1 -1
  50. package/src/trust.ts +8 -8
  51. package/src/uninstall.ts +2 -2
  52. package/src/usage.ts +23 -21
  53. package/templates/config.yaml +22 -3
  54. package/templates/init.include.yaml +16 -11
  55. package/templates/openapi.json +3948 -3655
  56. package/templates/src/.ojm-builtin.json +13 -0
  57. package/templates/src/README.md +5 -2
  58. package/templates/src/app/api/_platform/README.md +3 -2
  59. package/templates/src/app/api/_platform/_lifecycle/atomic.ts +7 -2
  60. package/templates/src/app/api/_platform/_lifecycle/bundle-converge.ts +158 -0
  61. package/templates/src/app/api/_platform/_lifecycle/history.ts +3 -2
  62. package/templates/src/app/api/_platform/_lifecycle/installer.ts +25 -5
  63. package/templates/src/app/api/_platform/_lifecycle/local-archive.ts +61 -0
  64. package/templates/src/app/api/_platform/_lifecycle/manifest-of-upload.ts +62 -0
  65. package/templates/src/app/api/_platform/_lifecycle/steps/preflight.ts +12 -2
  66. package/templates/src/app/api/_platform/_lifecycle/steps/reload.ts +4 -1
  67. package/templates/src/app/api/_platform/_lifecycle/steps/smoke.ts +8 -2
  68. package/templates/src/app/api/_platform/_lifecycle/types.ts +1 -1
  69. package/templates/src/app/api/_platform/_lifecycle/uninstall.ts +14 -5
  70. package/templates/src/app/api/_platform/_shared/site-facade.ts +31 -0
  71. package/templates/src/app/api/_platform/manifest.yaml +1 -2
  72. package/templates/src/app/api/_platform/migrations/0003__market_catalog.mysql.sql +6 -0
  73. package/templates/src/app/api/_platform/migrations/0003__market_catalog.pg.sql +6 -0
  74. package/templates/src/app/api/_platform/migrations/0003__market_catalog.sql +18 -0
  75. package/templates/src/app/api/_platform/migrations/0004__market_drop_source_seq.mysql.sql +6 -0
  76. package/templates/src/app/api/_platform/migrations/0004__market_drop_source_seq.pg.sql +6 -0
  77. package/templates/src/app/api/_platform/migrations/0004__market_drop_source_seq.sql +6 -0
  78. package/templates/src/app/api/_platform/migrations/0005__market_sig_registry.mysql.sql +8 -0
  79. package/templates/src/app/api/_platform/migrations/0005__market_sig_registry.pg.sql +8 -0
  80. package/templates/src/app/api/_platform/migrations/0005__market_sig_registry.sql +8 -0
  81. package/templates/src/app/api/_platform/schema.yaml +8 -11
  82. package/templates/src/app/api/_platform/seed.mysql.sql +1 -1
  83. package/templates/src/app/api/_platform/seed.pg.sql +1 -1
  84. package/templates/src/app/api/_platform/seed.sql +1 -1
  85. package/templates/src/app/api/auth/_shared/wecom/sync.ts +6 -0
  86. package/templates/src/app/web/_platform/__client/api.schemas.ts +0 -114
  87. package/templates/src/app/web/_platform/__client/index.ts +1 -333
  88. package/templates/src/app/web/system/locales/en-US.json +15 -1
  89. package/templates/src/app/web/system/locales/zh-CN.json +15 -1
  90. package/templates/src/app/web/system/pages/departments/sync-bar.tsx +9 -3
  91. package/templates/src/app/web/system/pages/roles/index.tsx +4 -8
  92. package/templates/src/app/web/system/pages/roles/role-label.ts +26 -0
  93. package/templates/src/app/web/system/pages/solutions/index.tsx +110 -0
  94. package/templates/src/app/web/system/pages/solutions/market-entry.ts +46 -0
  95. package/templates/src/app/web/system/pages/users/index.tsx +12 -2
  96. package/templates/src/app/web/system/pages/users/match-keyword.ts +19 -0
  97. package/templates/src/app/web/system/pages/users/roles.tsx +43 -14
  98. package/templates/src/app/web/system/routes.ts +18 -1
  99. package/templates/src/{app/api/_platform/market → oj-market/api}/api.ts +475 -592
  100. package/templates/src/oj-market/api/backfill.ts +221 -0
  101. package/templates/src/{app/api/_platform/market → oj-market/api}/bootstrap.ts +2 -2
  102. package/templates/src/oj-market/api/cdn/api.ts +312 -0
  103. package/templates/src/oj-market/api/cdn/shared.ts +104 -0
  104. package/templates/src/oj-market/api/cdn/signed-auth.ts +75 -0
  105. package/templates/src/oj-market/api/manifest.yaml +8 -0
  106. package/templates/src/oj-market/solution.yaml +21 -0
  107. package/templates/src/oj-market/web/__client/api.schemas.ts +167 -0
  108. package/templates/src/oj-market/web/__client/index.ts +509 -0
  109. package/templates/src/oj-market/web/__tests__/marketClient.fake.ts +6 -6
  110. package/templates/src/oj-market/web/api/client.ts +6 -6
  111. package/templates/src/oj-market/web/api/defaultClient.ts +31 -25
  112. package/templates/src/oj-market/web/entry.ts +5 -3
  113. package/templates/src/oj-market/web/hooks/useMarket.ts +6 -6
  114. package/templates/src/oj-market/web/pages/MarketApp.tsx +20 -3
  115. package/templates/src/oj-market/web/publish/locales/en-US.json +1 -1
  116. package/templates/src/oj-market/web/publish/locales/zh-CN.json +1 -1
  117. package/templates/src/oj-market/web/publish/pages/history.tsx +2 -11
  118. package/templates/src/oj-market/web/routes.ts +12 -0
  119. package/templates/src/oj-market/web/types.ts +14 -8
  120. package/templates/tests/lifecycle/anonymous-wiring.spec.ts +65 -0
  121. package/templates/tests/lifecycle/backfill.spec.ts +392 -0
  122. package/templates/tests/lifecycle/bootstrap.spec.ts +1 -1
  123. package/templates/tests/lifecycle/bundle-converge.spec.ts +264 -0
  124. package/templates/tests/lifecycle/cdn-upload.spec.ts +661 -0
  125. package/templates/tests/lifecycle/deploy-smoke.spec.ts +25 -0
  126. package/templates/tests/lifecycle/exec-grant.spec.ts +38 -0
  127. package/templates/tests/lifecycle/installer.spec.ts +36 -0
  128. package/templates/tests/lifecycle/market-api.spec.ts +448 -803
  129. package/templates/tests/lifecycle/preflight.spec.ts +18 -0
  130. package/templates/tests/lifecycle/trust.spec.ts +2 -2
  131. package/templates/tests/lifecycle/uninstall.spec.ts +36 -2
  132. package/templates/tests/lifecycle/verify-checks.spec.ts +1 -1
  133. package/templates/tests/solutions-entry.spec.ts +55 -0
  134. package/templates/tsconfig.paths.json +1 -0
  135. package/templates/web.config.ts +6 -6
  136. package/vendor/host-versions.json +3 -3
  137. package/bin/oj +0 -4
  138. package/src/market/__tests__/build-index.test.ts +0 -641
  139. package/src/market/__tests__/sharding.test.ts +0 -90
  140. package/src/market/__tests__/yank-ledger.test.ts +0 -60
  141. package/src/market/build-index.ts +0 -526
  142. package/src/market/index-builder.ts +0 -149
  143. package/src/market/sharding.ts +0 -94
  144. package/src/market/yank-ledger.ts +0 -91
  145. package/templates/src/app/api/_platform/_lifecycle/source-seq.ts +0 -88
  146. package/templates/src/app/api/_platform/market/index-source.ts +0 -112
  147. package/templates/tests/lifecycle/source-seq.spec.ts +0 -55
@@ -0,0 +1,221 @@
1
+ // 站点侧目录回填(计划 v4 §六——三用:回填=恢复=对账)。POST /api/oj-market/backfill
2
+ // (superadmin,R9)触发;远端纯站点可用(无 ojm CLI 依赖,一次 HTTP 调用)。
3
+ //
4
+ // 语义:扫 cdn/ 存量 `<name>-<version>.tgz` → 内嵌 manifest 抽取(与上传端点同一
5
+ // manifestOfUpload 管线,勿各写一遍)+ 旁车 .sig → upsert market_release。
6
+ // · 新行:published_at_source='file'(与上传通道 'upload' 区分);yanked=0。
7
+ // · 已存在行(对账/灾备重放):只重写内容列(key_id/sha256/size/sig 四列/manifest),
8
+ // published_at/published_at_source/first_seen_at/yanked 原样保留——审计连续、yank 不回魂。
9
+ // · 幂等可重入:重复跑行数不变,报告 unchanged 供对账。
10
+ // 降级(数据表):.sig 缺失/不可解析/与实收字节验签不绑定 → 行照落库但 sig 四列 NULL(安装验签 sig_missing
11
+ // fail-closed),文件名进 sigMissing;坏包(解不开/manifest 名实不符)进 skipped 带
12
+ // reason,不阻断其它文件;孤立 .sig(tgz 缺失)容忍不加 GC(须受信 key 才能制造)。
13
+
14
+ import type { FsFace } from "#/_platform/_lifecycle/atomic";
15
+ import type { ExecFace } from "#/_platform/_lifecycle/steps/extract";
16
+ import { fsFace } from "#/_platform/_lifecycle/atomic";
17
+ import { manifestOfUpload } from "#/_platform/_lifecycle/manifest-of-upload";
18
+
19
+ import { clampManifest, packageCanonicalOf, parseSidecarSig, parseUploadFile } from "./cdn/shared";
20
+
21
+ export interface BackfillEnv {
22
+ /** cdn/ 字节仓库目录(扫描源) */
23
+ cdnDir: string
24
+ /** 暂存目录(manifestOfUpload 解包用;常量名 staging,与上传端点分离防并发互踩) */
25
+ staging: string
26
+ /** 注入面(DIP):缺省回落 isolate 全局 exec / crypto.sha256Hex */
27
+ exec?: ExecFace
28
+ sha256Hex?: (data: Uint8Array) => string
29
+ /** Ed25519 验签面(旁车与实收字节绑定校验;缺省回落 isolate 全局 crypto.verifyEd25519) */
30
+ verifyEd25519?: (publicKey: string, msg: string, sigBase64: string) => boolean
31
+ /** 当前时刻(epoch 秒;新行 published_at/first_seen_at;缺省 Date.now) */
32
+ now?: () => number
33
+ }
34
+
35
+ export interface BackfillReport {
36
+ /** cdn/ 下识别为 `<name>-<version>.tgz` 的包数(孤立 .sig 不计) */
37
+ scanned: number
38
+ /** 新建行 + 内容列被重写的行 */
39
+ upserted: number
40
+ /** 已存在且内容列无变化(幂等对账锚) */
41
+ unchanged: number
42
+ /** 坏包:解不开 / manifest 名实不符 / 缺 name·version */
43
+ skipped: Array<{ file: string, reason: string }>
44
+ /** .sig 缺失或不可解析(行已落库,sig 四列 NULL) */
45
+ sigMissing: string[]
46
+ }
47
+
48
+ /** 单包扫描结果(行内容列集,upsert 判定与写入共用) */
49
+ interface RowContent {
50
+ key_id: string | null
51
+ sha256: string
52
+ size: number
53
+ sig_alg: string | null
54
+ sig_value: string | null
55
+ sig_public_key: string | null
56
+ sig_registry_id: string | null
57
+ manifest: string
58
+ }
59
+
60
+ function sha256Of(env: BackfillEnv): (data: Uint8Array) => string {
61
+ const g = globalThis as { crypto?: { sha256Hex?: (data: Uint8Array) => string } };
62
+ return env.sha256Hex ?? ((data: Uint8Array) => g.crypto!.sha256Hex!(data));
63
+ }
64
+
65
+ function verifyOf(env: BackfillEnv): (publicKey: string, msg: string, sigBase64: string) => boolean {
66
+ const g = globalThis as { crypto?: { verifyEd25519?: (pk: string, msg: string, sig: string) => boolean } };
67
+ return env.verifyEd25519 ?? ((pk, msg, sig) => g.crypto!.verifyEd25519!(pk, msg, sig));
68
+ }
69
+
70
+ function execOf(env: BackfillEnv): ExecFace {
71
+ const g = globalThis as { exec?: { run: ExecFace["run"] } };
72
+ return env.exec ?? { run: (argv, opts) => g.exec!.run(argv, opts) };
73
+ }
74
+
75
+ async function readFileOrNull(f: FsFace, path: string): Promise<Uint8Array | null> {
76
+ try {
77
+ return await f.readFile(path);
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /** 扫描单文件:解包抽 manifest + 旁车 .sig → 行内容列;不可回填的错误以 reason 抛出(调用方计入 skipped) */
85
+ async function scanPackage(f: FsFace, env: BackfillEnv, file: string, parsed: { name: string, version: string }): Promise<{ content: RowContent, sigMissing: boolean }> {
86
+ const bytes = await f.readFile(`${env.cdnDir}/${file}`);
87
+ const manifest = await manifestOfUpload({ stagingDir: `${env.staging}/backfill`, bytes, exec: execOf(env) });
88
+ const m = typeof manifest === "object" && manifest !== null && !Array.isArray(manifest) ? manifest as Record<string, unknown> : {};
89
+ if (typeof m.name !== "string" || m.name === "" || typeof m.version !== "string" || m.version === "")
90
+ throw new Error("包内 manifest 缺 name/version");
91
+ if (m.name !== parsed.name || m.version !== parsed.version)
92
+ throw new Error(`名实不符:文件名 ${parsed.name}-${parsed.version},包内 manifest ${String(m.name)}-${String(m.version)}`);
93
+
94
+ // 旁车 .sig:缺失/损坏/与实收字节不绑定一律降级(sig 四列 NULL,fail-closed 同口径)。
95
+ // 验签(终审 I2):结构合法不等于绑定——投毒旁车五字段全对但签的是别家字节,照验才挡得住。
96
+ let sig: ReturnType<typeof parseSidecarSig> | null = null;
97
+ const sigBytes = await readFileOrNull(f, `${env.cdnDir}/${file}.sig`);
98
+ if (sigBytes !== null) {
99
+ try {
100
+ sig = parseSidecarSig(new TextDecoder().decode(sigBytes));
101
+ const canonical = packageCanonicalOf(sig.registry_id, parsed.name, parsed.version, sha256Of(env)(bytes));
102
+ if (!verifyOf(env)(sig.public_key, canonical, sig.value))
103
+ sig = null; // 验签不绑定 → 视同缺签
104
+ }
105
+ catch {
106
+ sig = null;
107
+ }
108
+ }
109
+ const sigMissing = sig === null;
110
+ return {
111
+ content: {
112
+ key_id: sig?.key_id ?? null,
113
+ sha256: sha256Of(env)(bytes),
114
+ size: bytes.byteLength,
115
+ sig_alg: sig?.alg ?? null,
116
+ sig_value: sig?.value ?? null,
117
+ sig_public_key: sig?.public_key ?? null,
118
+ sig_registry_id: sig?.registry_id ?? null,
119
+ manifest: JSON.stringify(clampManifest(m)),
120
+ },
121
+ sigMissing,
122
+ };
123
+ }
124
+
125
+ /** 内容列集合相等判定(幂等对账:一字不差才算 unchanged) */
126
+ function contentEqual(row: Record<string, unknown>, c: RowContent): boolean {
127
+ const pairs: Array<[unknown, unknown]> = [
128
+ [row.sha256, c.sha256],
129
+ [Number(row.size), c.size],
130
+ [row.key_id ?? null, c.key_id],
131
+ [row.sig_alg ?? null, c.sig_alg],
132
+ [row.sig_value ?? null, c.sig_value],
133
+ [row.sig_public_key ?? null, c.sig_public_key],
134
+ [row.sig_registry_id ?? null, c.sig_registry_id],
135
+ [String(row.manifest ?? ""), c.manifest],
136
+ ];
137
+ return pairs.every(([a, b]) => a === b);
138
+ }
139
+
140
+ /**
141
+ * 回填主链:列目录 → 逐包扫描 → select 后 insert/update(审计列保留)。
142
+ * db 面走 oj 构造器全局(与上传端点同纪律);FS 失败(单文件读坏)按坏包计入 skipped。
143
+ */
144
+ export async function backfillReleases(env: BackfillEnv): Promise<BackfillReport> {
145
+ const f = fsFace();
146
+ const report: BackfillReport = { scanned: 0, upserted: 0, unchanged: 0, skipped: [], sigMissing: [] };
147
+ const names = await f.readdir(env.cdnDir);
148
+ for (const file of names.sort()) {
149
+ if (!file.endsWith(".tgz"))
150
+ continue; // 孤立 .sig 与杂文件容忍忽略
151
+ const parsed = parseUploadFile(file);
152
+ if (parsed === null || parsed.sig) {
153
+ report.skipped.push({ file, reason: "文件名不属于 <name>-<version>.tgz 形态" });
154
+ continue;
155
+ }
156
+ let scanned: Awaited<ReturnType<typeof scanPackage>>;
157
+ try {
158
+ scanned = await scanPackage(f, env, file, parsed);
159
+ }
160
+ catch (e) {
161
+ report.skipped.push({ file, reason: (e as Error).message });
162
+ continue;
163
+ }
164
+ report.scanned++;
165
+ if (scanned.sigMissing)
166
+ report.sigMissing.push(file);
167
+ const { content } = scanned;
168
+
169
+ const existing = (await db.table("market_release")
170
+ .select(["id", "sha256", "size", "key_id", "sig_alg", "sig_value", "sig_public_key", "sig_registry_id", "manifest"])
171
+ .where({ field: "product", op: "eq", value: parsed.name })
172
+ .where({ field: "version", op: "eq", value: parsed.version })
173
+ .limit(1)
174
+ .all()) as Array<Record<string, unknown>>;
175
+ const prior = existing[0];
176
+ if (prior !== undefined && contentEqual(prior, content)) {
177
+ report.unchanged++;
178
+ continue;
179
+ }
180
+ if (prior === undefined) {
181
+ const now = env.now?.() ?? Math.floor(Date.now() / 1000);
182
+ try {
183
+ await db.table("market_release").insert({
184
+ product: parsed.name,
185
+ version: parsed.version,
186
+ ...content,
187
+ published_at: now,
188
+ published_at_source: "file",
189
+ yanked: 0,
190
+ first_seen_at: now,
191
+ }).run();
192
+ }
193
+ catch (e) {
194
+ // 唯一键并发仲裁(minor M2):select 与 insert 之间他方上传先落行。重读定幂等/冲突——
195
+ // 同字节 → unchanged(他方行即真源);异字节 → skipped 记冲突(他方行不可被回填改写),
196
+ // 全量扫描不中断;重跑回填即可再对账。非撞键的底层错误(DB 故障)原样上抛不吞。
197
+ const raced = (await db.table("market_release")
198
+ .select(["id", "sha256", "size", "key_id", "sig_alg", "sig_value", "sig_public_key", "sig_registry_id", "manifest"])
199
+ .where({ field: "product", op: "eq", value: parsed.name })
200
+ .where({ field: "version", op: "eq", value: parsed.version })
201
+ .limit(1)
202
+ .all()) as Array<Record<string, unknown>>;
203
+ const racedRow = raced[0];
204
+ if (racedRow === undefined)
205
+ throw e; // 非撞键错误:真故障不遮
206
+ if (contentEqual(racedRow, content)) {
207
+ report.unchanged++;
208
+ continue;
209
+ }
210
+ report.skipped.push({ file, reason: `并发冲突:扫描期间「${parsed.name}@${parsed.version}」已被他方上传占用且字节不同——重跑回填对账(他方行未被改写)` });
211
+ continue;
212
+ }
213
+ }
214
+ else {
215
+ // 对账/灾备重放:只重写内容列;published_at/published_at_source/first_seen_at/yanked 保留
216
+ await db.table("market_release").update({ ...content }).where({ field: "id", op: "eq", value: prior.id }).run();
217
+ }
218
+ report.upserted++;
219
+ }
220
+ return report;
221
+ }
@@ -5,8 +5,8 @@
5
5
  // 私钥入仓即信任根基烧毁(tests/lifecycle/bootstrap.spec 钉全仓 grep 私钥 pattern 零命中)。
6
6
  // 播种纪律:只在信任库文件尚不存在时播种一次——不与运维吊销对抗(remove 后不复活,G5 语义)。
7
7
 
8
- import { fsFace } from "../_lifecycle/atomic";
9
- import { createTrustStore } from "../_lifecycle/trust";
8
+ import { fsFace } from "#/_platform/_lifecycle/atomic";
9
+ import { createTrustStore } from "#/_platform/_lifecycle/trust";
10
10
 
11
11
  export interface BootstrapKey {
12
12
  key_id: string
@@ -0,0 +1,312 @@
1
+ // CDN 上传端点(Phase A v4 §二):签名推送 + pair 约束 + DB 目录真源写行。
2
+ // POST /api/oj-market/cdn/upload?name=<file> —— 发布方(ojm market publish 远端模式)推
3
+ // .tgz + .sig 旁车;不经账号口令(企微用户无障碍),签名鉴权 = X-OJM-Key-Id / X-OJM-Signature
4
+ // 头 + 信任库 + Ed25519;无角色检查(信任库成员即发布身份)。
5
+ //
6
+ // 鉴权序(钉死):① 验头(403 fail-fast:key 须在信任库,不消费 body)→ 限速(按 key_id)→
7
+ // registry_id 解析(缺失 503)→ ② 消费 body(MAX_PACKAGE_BYTES 显式 413;反代 body cap 兜底)
8
+ // → ③ 服务端从实收字节**重算 sha256** 重构 canonical `cdn-upload:<registry_id>:<name>:<sha256>`
9
+ // 验签(换字节/跨站重放 → 401;不取 .sig 声明值——防签名-字节重绑)。站内重放(无 timestamp/
10
+ // nonce)因内容哈希绑定退化为同字节幂等,无实害——不引入时间戳。
11
+ //
12
+ // 处理序(钉死):查库比对(同 name+version:同字节 → 幂等 200 + 补缺文件〔自愈〕;异字节 →
13
+ // 409 **不碰盘**,防「文件先被换、DB 后拒」的字节持久损坏)→ staging 解包抽 manifest(R1 三步
14
+ // 范式 + REG-CS8 体积闸;manifest.name/version 与文件名逐字一致;summary/description 入库钳制)
15
+ // → 原子落盘(tmp+rename)→ db.tx 写行(唯一键并发仲裁:撞键重读比对 sha256 定幂等/冲突)。
16
+ // FS+DB 无同事务——db.tx 只包 DB 段;中断靠同字节重传自愈。任一步失败 CDN 零痕迹。
17
+ //
18
+ // name 所有权(v4 §一):product → 首发 key_id 绑定不可改(C2-38 同源纪律)——既有行 key_id
19
+ // 非 NULL 且不符 → 409;NULL(syncReleases 遗留)不建立所有权,首个上传采用并绑定;
20
+ // 换主 = 站点 superadmin 操作(端点不提供)。
21
+ //
22
+ // 匿名面:config.yaml auth.anonymous_paths 增 /api/oj-market/cdn/upload(restart_pending
23
+ // 冻结段,重启生效,发版说明提醒)。审计:发布成败落 market_history(action=publish,
24
+ // operator = key_id);验签前的请求(401/403/413/429/400 文件名)不是已知身份的发布行为,不留痕。
25
+
26
+ import type { ExecFace } from "#/_platform/_lifecycle/steps/extract";
27
+ import type { ParsedUploadFile } from "./shared";
28
+ import type { SignedAuthEnv } from "./signed-auth";
29
+ import { atomicWriteBytes, fsFace } from "#/_platform/_lifecycle/atomic";
30
+ import { recordHistory } from "#/_platform/_lifecycle/history";
31
+ import { MAX_PACKAGE_BYTES } from "#/_platform/_lifecycle/limits";
32
+
33
+ import { manifestOfUpload } from "#/_platform/_lifecycle/manifest-of-upload";
34
+ import { respond } from "#/_platform/_shared/respond";
35
+ import { clampManifest, fail, packageCanonicalOf, parseSidecarSig, parseUploadFile } from "./shared";
36
+ import { authenticateSignedKey, UPLOAD_RATE_LIMIT, verifyCanonicalSig } from "./signed-auth";
37
+
38
+ // 既有引用面兼容(tests/lifecycle/cdn-upload.spec.ts 等从本文件取)
39
+ export { UPLOAD_RATE_LIMIT };
40
+ export type { SignedAuthEnv };
41
+
42
+ /** 上传体积闸 = 分发包体积闸(单源 limits.ts,杜绝散落) */
43
+ export const MAX_UPLOAD_BYTES = MAX_PACKAGE_BYTES;
44
+
45
+ export interface CdnUploadEnv {
46
+ /** cdn/ 字节仓库目录(站点挂载的静态目录;暂存与任务不留其内——失败零可见痕迹) */
47
+ cdnDir: string
48
+ /** 上传暂存目录(解包抽 manifest;与安装器 staging 分离,防并发互踩) */
49
+ staging: string
50
+ /** 宿主信任库(.solutions/trusted-keys.yaml) */
51
+ trustPath: string
52
+ /** 本站点 registry 身份(canonical 首段 = 站点身份,防跨站重放);缺省读 config vars.registry_id */
53
+ registryId?: string
54
+ /** 站点 config.yaml 路径(vars.registry_id 读取处;缺省 config.yaml) */
55
+ configPath?: string
56
+ /** 注入面(DIP):缺省回落 isolate 全局 exec / crypto.sha256Hex / crypto.verifyEd25519 */
57
+ exec?: ExecFace
58
+ sha256Hex?: (data: Uint8Array) => string
59
+ verifyEd25519?: (publicKey: string, msg: string, sigBase64: string) => boolean
60
+ /** 当前时刻(epoch 秒;published_at/first_seen_at;测试注入,缺省 Date.now) */
61
+ now?: () => number
62
+ }
63
+
64
+ /** 生产缺省(全字段可注入覆盖,测试 stub 同款);路径相对 fs jail 根(= config.yaml 所在目录) */
65
+ export function cdnEnv(): CdnUploadEnv {
66
+ return {
67
+ cdnDir: "cdn",
68
+ staging: "modules/.staging-upload",
69
+ trustPath: ".solutions/trusted-keys.yaml",
70
+ configPath: "config.yaml",
71
+ };
72
+ }
73
+
74
+ function isObj(v: unknown): v is Record<string, unknown> {
75
+ return typeof v === "object" && v !== null && !Array.isArray(v);
76
+ }
77
+
78
+ // ── 注入面缺省回落 ─────────────────────────────────────────────────────────────────────────
79
+ function execOf(env: CdnUploadEnv): ExecFace {
80
+ const g = globalThis as { exec?: { run: ExecFace["run"] } };
81
+ return env.exec ?? { run: (argv, opts) => g.exec!.run(argv, opts) };
82
+ }
83
+
84
+ function sha256Of(env: CdnUploadEnv): (data: Uint8Array) => string {
85
+ const g = globalThis as { crypto?: { sha256Hex?: (data: Uint8Array) => string } };
86
+ return env.sha256Hex ?? ((data: Uint8Array) => g.crypto!.sha256Hex!(data));
87
+ }
88
+
89
+ function nowOf(env: CdnUploadEnv): number {
90
+ return env.now?.() ?? Math.floor(Date.now() / 1000);
91
+ }
92
+
93
+ /** 上传载荷字节(upload/api.ts 先例双路径:小文件字节在手,大文件已流式落 blob 须走 blob.get) */
94
+ async function uploadBytes(): Promise<Uint8Array | null> {
95
+ const h = http as { files?: Array<{ key?: string }>, file?: (i: number) => Promise<Uint8Array> };
96
+ if (!Array.isArray(h.files) || h.files.length === 0)
97
+ return null;
98
+ const meta = h.files[0]!;
99
+ return meta.key ? await blob.get(meta.key) : await h.file!(0);
100
+ }
101
+
102
+ async function readFileOrNull(path: string): Promise<Uint8Array | null> {
103
+ try {
104
+ return await fsFace().readFile(path);
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ /** 幂等自愈:盘上缺失才补写(存在即不动——绝不改写既有字节) */
112
+ async function healIfMissing(path: string, bytes: Uint8Array): Promise<void> {
113
+ if (await readFileOrNull(path) === null)
114
+ await atomicWriteBytes(path, bytes);
115
+ }
116
+
117
+ /** 发布审计(成败俱记 market_history;守卫式 insert,留痕失败不阻断主流程——history.ts 同语义) */
118
+ async function auditPublish(row: { name: string, version: string, keyId: string, status: "success" | "failed", message: string }): Promise<void> {
119
+ await recordHistory({
120
+ solution: row.name,
121
+ version: row.version,
122
+ action: "publish",
123
+ status: row.status,
124
+ operator: row.keyId,
125
+ message: row.message,
126
+ });
127
+ }
128
+
129
+ // ── 主体 ────────────────────────────────────────────────────────────────────────────────────
130
+ async function sigUploadBody(env: CdnUploadEnv, file: ParsedUploadFile, rawName: string, bytes: Uint8Array, sha256: string, keyId: string): Promise<Record<string, unknown>> {
131
+ const sigPath = `${env.cdnDir}/${rawName}`;
132
+ const existing = await readFileOrNull(sigPath);
133
+ if (existing !== null && sha256Of(env)(existing) === sha256) {
134
+ // 幂等重推留痕(minor M3):重推不是发布动作,但发布留痕口径成败俱记——补一条 success 审计
135
+ await auditPublish({ name: file.name, version: file.version, keyId, status: "success", message: `幂等重推 .sig 旁车(${rawName} 内容未变,行未动)` });
136
+ return { ok: true, name: rawName, kind: "sig", sha256, idempotent: true };
137
+ }
138
+ parseSidecarSig(new TextDecoder().decode(bytes)); // 结构校验(400 人话分流);内容列在 tgz pair 时从盘上旁车读取
139
+ // 已配对(market_release 行在)后禁止换签:pair 完整是已发布事实,签名与 tgz 绑定不可拆换
140
+ const paired = await db.table("market_release").select(["id"]).where({ field: "product", op: "eq", value: file.name }).where({ field: "version", op: "eq", value: file.version }).limit(1).all() as unknown[];
141
+ if (paired.length > 0 && existing !== null && sha256Of(env)(existing) !== sha256)
142
+ fail(409, `「${file.name}@${file.version}」的 .sig 已与 tgz 配对入库,不接受换签——请升 version 重发`);
143
+ await atomicWriteBytes(sigPath, bytes);
144
+ return { ok: true, name: rawName, kind: "sig", sha256 };
145
+ }
146
+
147
+ async function tgzUploadBody(env: CdnUploadEnv, file: ParsedUploadFile, rawName: string, bytes: Uint8Array, sha256: string, keyId: string): Promise<Record<string, unknown>> {
148
+ const tgzPath = `${env.cdnDir}/${rawName}`;
149
+ const sigPath = `${tgzPath}.sig`;
150
+
151
+ // pair 约束:sig 先 tgz 后——旁车缺失/损坏都不进解包
152
+ const sigBytes = await readFileOrNull(sigPath);
153
+ if (sigBytes === null)
154
+ fail(409, `pair 约束(sig 先 tgz 后):请先上传 ${rawName}.sig 旁车,再推 ${rawName}——tgz 未配对不入库`);
155
+ const sig = parseSidecarSig(new TextDecoder().decode(sigBytes));
156
+ // pair 验签(终审 I2):旁车必须与实收字节密码学绑定——受信 key 也可能作恶,预推
157
+ // 「形全实毒」旁车会让受害方 tgz 配对错签、该版本全部安装 fail-closed。验签在 pair
158
+ // 时做(sig 先行时 tgz 字节尚不存在,无法绑定);包级 canonical 首段 = 旁车自带签发方
159
+ // 身份(跨站签发合法),与上传鉴权用的站点 canonical 是两串。
160
+ if (!verifyCanonicalSig(env, sig.public_key, packageCanonicalOf(sig.registry_id, file.name, file.version, sha256), sig.value)) {
161
+ await auditPublish({ name: file.name, version: file.version, keyId, status: "failed", message: "409 旁车验签失败:签名与实收字节不绑定" });
162
+ fail(409, `「${file.name}@${file.version}」的 .sig 旁车验签失败——签名与实收字节不绑定(cdn/ 若残留他人旁车,须站点 superadmin 清理后重推)`);
163
+ }
164
+ if (sig.key_id !== keyId) {
165
+ await auditPublish({ name: file.name, version: file.version, keyId, status: "failed", message: `409 旁车 key 不符:${sig.key_id} ≠ 推送身份 ${keyId}` });
166
+ fail(409, `「${file.name}@${file.version}」的 .sig 旁车 key_id「${sig.key_id}」与推送身份「${keyId}」不符——旁车与包体须同一把发布 key(一行一锚)`);
167
+ }
168
+
169
+ // ① 查库比对(不碰盘仲裁:异字节在此被拒,CDN 字节绝无被持久损坏的窗口)
170
+ const prior = (await db.table("market_release")
171
+ .select(["id", "sha256"])
172
+ .where({ field: "product", op: "eq", value: file.name })
173
+ .where({ field: "version", op: "eq", value: file.version })
174
+ .limit(1)
175
+ .all()) as Array<{ id: unknown, sha256: unknown }>;
176
+ if (prior.length > 0) {
177
+ if (String(prior[0]!.sha256) === sha256) {
178
+ // 同字节幂等 + 补缺文件自愈(sig 先行缺失也一并补)
179
+ await healIfMissing(tgzPath, bytes);
180
+ await healIfMissing(sigPath, sigBytes);
181
+ return { ok: true, name: file.name, version: file.version, sha256, size: bytes.byteLength, key_id: keyId, idempotent: true };
182
+ }
183
+ await auditPublish({ name: file.name, version: file.version, keyId, status: "failed", message: "409 异字节同版本:同一版本号只允许一份字节" });
184
+ fail(409, `「${file.name}@${file.version}」已存在且字节不一致——同一版本号只允许一份字节,请升 version 重发(确需覆盖须站点侧删行,superadmin 操作)`);
185
+ }
186
+
187
+ // ② name 所有权:既有行(key_id 非 NULL)锁定首发 key;NULL(遗留行)不建立所有权
188
+ const ownerRows = await db.table("market_release").select(["key_id"]).where({ field: "product", op: "eq", value: file.name }).limit(1).all() as Array<{ key_id: unknown }>;
189
+ const owner = ownerRows[0]?.key_id;
190
+ if (typeof owner === "string" && owner !== "" && owner !== keyId) {
191
+ await auditPublish({ name: file.name, version: file.version, keyId, status: "failed", message: `409 name 所有权:${file.name} 已由 ${owner} 首发绑定` });
192
+ fail(409, `name 所有权:「${file.name}」已由 key「${owner}」首发绑定,key「${keyId}」不得发布——换主 = 站点 superadmin 操作`);
193
+ }
194
+
195
+ // ③ staging 解包抽 manifest(查库后的第一碰盘动作;体积闸/zip-slip/坏包全在此拒)
196
+ const manifest = await manifestOfUpload({ stagingDir: env.staging, bytes, exec: execOf(env) });
197
+ const m = isObj(manifest) ? manifest : {};
198
+ if (m.name !== file.name || m.version !== file.version)
199
+ fail(400, `包内 manifest「${String(m.name)}@${String(m.version)}」与上传文件名「${file.name}@${file.version}」不一致——请核对分发包`);
200
+ const stored = clampManifest(m);
201
+
202
+ // ④ 原子落盘(tmp+rename;此后任一步失败尽力回收本方字节,绝不改写他人文件)
203
+ await atomicWriteBytes(tgzPath, bytes);
204
+ const now = nowOf(env);
205
+ try {
206
+ // ⑤ db.tx 写行(只包 DB 段;FS+DB 无同事务,中断靠同字节重传自愈)。
207
+ // 所有权复核在事务内重读(minor M1):预检在事务外,两发布方并发首发同 product 的
208
+ // 竞态窗口(预检通过 → 他方先落行)只能由写事务内重读仲裁——SQLite 单写者序列化,
209
+ // 后入事务必见先提交行。
210
+ await db.tx(async (t) => {
211
+ const ownersNow = await t.table("market_release").select(["key_id"]).where({ field: "product", op: "eq", value: file.name }).limit(1).all() as Array<{ key_id: unknown }>;
212
+ const ownerNow = ownersNow[0]?.key_id;
213
+ if (typeof ownerNow === "string" && ownerNow !== "" && ownerNow !== keyId)
214
+ fail(409, `name 所有权:「${file.name}」已由 key「${ownerNow}」首发绑定(并发复核),key「${keyId}」不得发布——换主 = 站点 superadmin 操作`);
215
+ await t.table("market_release").insert({
216
+ product: file.name,
217
+ version: file.version,
218
+ key_id: keyId,
219
+ sha256,
220
+ size: bytes.byteLength,
221
+ published_at: now,
222
+ published_at_source: "upload",
223
+ yanked: 0,
224
+ first_seen_at: now,
225
+ sig_alg: sig.alg,
226
+ sig_value: sig.value,
227
+ sig_public_key: sig.public_key,
228
+ sig_registry_id: sig.registry_id,
229
+ manifest: JSON.stringify(stored),
230
+ }).run();
231
+ });
232
+ }
233
+ catch (e) {
234
+ // 唯一键并发仲裁:撞键 = 并发同版本上传先落一方——重读比对 sha256 定幂等/冲突。
235
+ // tx 内业务 fail(409 并发复核)也走此 catch:err.status 在,原样重抛(信封取 status)。
236
+ const status = (e as { status?: number }).status;
237
+ const again = (await db.table("market_release")
238
+ .select(["sha256"])
239
+ .where({ field: "product", op: "eq", value: file.name })
240
+ .where({ field: "version", op: "eq", value: file.version })
241
+ .limit(1)
242
+ .all()) as Array<{ sha256: unknown }>;
243
+ if (status === undefined && again.length > 0 && String(again[0]!.sha256) === sha256) {
244
+ await healIfMissing(tgzPath, bytes);
245
+ return { ok: true, name: file.name, version: file.version, sha256, size: bytes.byteLength, key_id: keyId, idempotent: true };
246
+ }
247
+ // 仲裁失败/业务拒绝:回收本方字节(盘上若已是他方字节则不动——按 sha256 判别)。
248
+ // minor M4:底层错误不原样上抛(SQL 文本泄进 500 信封)——人话出口,细节只进审计。
249
+ const onDisk = await readFileOrNull(tgzPath);
250
+ if (onDisk !== null && sha256Of(env)(onDisk) === sha256)
251
+ await fsFace().remove(tgzPath, { force: true }).catch(() => {});
252
+ if (typeof status === "number") {
253
+ await auditPublish({ name: file.name, version: file.version, keyId, status: "failed", message: `409 并发复核拒绝:${(e as Error).message}` });
254
+ throw e;
255
+ }
256
+ await auditPublish({ name: file.name, version: file.version, keyId, status: "failed", message: `写库失败/冲突:${String(e)}` });
257
+ fail(500, `「${file.name}@${file.version}」写库失败(已回收本次字节,cdn/ 无残留)——请重试;持续失败请站点 superadmin 查 market_history 审计明细`);
258
+ }
259
+ await auditPublish({ name: file.name, version: file.version, keyId, status: "success", message: `签名推送发布成功(${rawName},${bytes.byteLength} 字节)` });
260
+ return { ok: true, name: file.name, version: file.version, sha256, size: bytes.byteLength, key_id: keyId };
261
+ }
262
+
263
+ async function uploadBody(env: CdnUploadEnv): Promise<Record<string, unknown>> {
264
+ const q = (http as { query?: Record<string, unknown> }).query ?? {};
265
+ const rawName = String(q.name ?? "").trim();
266
+ const file = parseUploadFile(rawName);
267
+ if (file === null)
268
+ fail(400, `文件名非法:「${rawName}」——须为 <name>-<version>.tgz 或其 .sig 旁车(name ^[a-z][a-z0-9-]{0,63}$ 全小写、版本数字开头);路径逃逸/绝对路径/反斜杠/错扩展名一律拒`);
269
+
270
+ // ① 验面(403 fail-fast:信任库成员判定不消费 body)——共享序见 authenticateSignedKey
271
+ const { keyId, publicKey, registryId, signature } = await authenticateSignedKey(env, "cdn-upload");
272
+
273
+ // ② 消费 body(显式 413;反代 body cap 兜底是部署面责任)
274
+ const bytes = await uploadBytes();
275
+ if (bytes === null)
276
+ fail(400, "须随 multipart 上传文件字节(http.files)");
277
+ if (bytes.byteLength > MAX_UPLOAD_BYTES)
278
+ fail(413, `上传体积 ${bytes.byteLength} 字节超过上限 ${MAX_UPLOAD_BYTES}(与分发包体积闸同源)——请缩减包体;部署面请配反代 body cap 兜底`);
279
+
280
+ // ③ 服务端重算 sha256 重构 canonical 验签(硬约束:不取 .sig 声明值——防签名-字节重绑)
281
+ const sha256 = sha256Of(env)(bytes);
282
+ const canonical = `cdn-upload:${registryId}:${rawName}:${sha256}`;
283
+ if (!verifyCanonicalSig(env, publicKey, canonical, signature))
284
+ fail(401, "签名验签失败:服务端按实收字节重算 sha256 重构 canonical 验签——字节被换、registry 身份不符或签名无效,已拒(401)");
285
+
286
+ if (file.sig)
287
+ return await sigUploadBody(env, file, rawName, bytes, sha256, keyId);
288
+ return await tgzUploadBody(env, file, rawName, bytes, sha256, keyId);
289
+ }
290
+
291
+ /** 单端点组(oj 嵌套组:组名 = 路径段)→ POST /api/oj-market/cdn/upload */
292
+ export function createCdnUploadApi(env: CdnUploadEnv) {
293
+ return {
294
+ upload: {
295
+ async post() {
296
+ await respond(async () => {
297
+ return await uploadBody(env);
298
+ });
299
+ },
300
+ },
301
+ };
302
+ }
303
+
304
+ const cdnUploadApi = createCdnUploadApi(cdnEnv());
305
+
306
+ // 契约 I/O(schema-first,oj v0.1.72+):入参对齐 query 收口(name 必填),出参宽松。
307
+ cdnUploadApi.upload.post.schema = {
308
+ query: { type: "object", required: ["name"], properties: { name: { type: "string" } } },
309
+ };
310
+ cdnUploadApi.upload.post.response = { type: "object", additionalProperties: true };
311
+
312
+ export default cdnUploadApi;
@@ -0,0 +1,104 @@
1
+ // cdn 域共享非入口件(文件名白名单解析 / .sig 旁车五字段 / 入库钳制):
2
+ // api.ts 与 cdn/api.ts 都是路由入口(oj S 规则不可被模块内 import),回填(backfill.ts)
3
+ // 与两端共用本文件,勿各写一遍(与 manifestOfUpload 同一管线纪律)。
4
+ //
5
+ // 为什么在这里而非 _platform:文件名/旁车/钳制的消费方全在 oj-market 模块内
6
+ // (_platform 内核不经手发布文件名),归模块私有。
7
+
8
+ import { isValidKeyId } from "#/_platform/_lifecycle/trust";
9
+
10
+ // ── 文件名白名单(fail-closed:正则不匹配即 400/跳过,路径逃逸/绝对路径/反斜杠无处通过)──
11
+ // name:C2-01(^[a-z][a-z0-9-]{0,63}$,全小写锚);version:数字开头(semver 生态实况,
12
+ // 左起首个「name 合法且版本段数字开头」的分割点为确定性解析)。
13
+ const NAME_RE = /^[a-z][a-z0-9-]{0,63}$/;
14
+ const VERSION_RE = /^\d[0-9A-Z.+-]{0,63}$/i;
15
+
16
+ export interface ParsedUploadFile {
17
+ name: string
18
+ version: string
19
+ /** .sig 旁车 or .tgz 包体 */
20
+ sig: boolean
21
+ }
22
+
23
+ /** 文件名 → {name,version,sig};不属于「<name>-<version>.tgz[.sig]」形态 → null */
24
+ export function parseUploadFile(raw: string): ParsedUploadFile | null {
25
+ if (raw === "" || raw.length > 128 || raw.includes("/") || raw.includes("\\"))
26
+ return null;
27
+ const sig = raw.endsWith(".sig");
28
+ const base = sig ? raw.slice(0, -".sig".length) : raw;
29
+ if (!base.endsWith(".tgz"))
30
+ return null;
31
+ const stem = base.slice(0, -".tgz".length);
32
+ for (let i = stem.indexOf("-"); i !== -1; i = stem.indexOf("-", i + 1)) {
33
+ const name = stem.slice(0, i);
34
+ const version = stem.slice(i + 1);
35
+ if (NAME_RE.test(name) && VERSION_RE.test(version))
36
+ return { name, version, sig };
37
+ }
38
+ return null;
39
+ }
40
+
41
+ // ── .sig 旁车五字段(契约 §2.11 / C2-35,canon 恒 "v2")───────────────────────────────────
42
+ const PUBLIC_KEY_HEX = /^[0-9a-f]{64}$/;
43
+ const SIG_B64 = /^[A-Z0-9+/]+={0,2}$/i;
44
+
45
+ export interface SidecarSig {
46
+ alg: string
47
+ key_id: string
48
+ public_key: string
49
+ value: string
50
+ canon: string
51
+ /** 包级 canonical 首段(REG-CS3 签发方 registry 身份;install verify 重构规范串用,A5 增补落列) */
52
+ registry_id: string
53
+ }
54
+
55
+ function isObj(v: unknown): v is Record<string, unknown> {
56
+ return typeof v === "object" && v !== null && !Array.isArray(v);
57
+ }
58
+
59
+ /** 五字段结构校验(400 人话分流;加密学校验在 install verify 段——此处不重复) */
60
+ export function parseSidecarSig(text: string): SidecarSig {
61
+ let parsed: unknown;
62
+ try {
63
+ parsed = JSON.parse(text);
64
+ }
65
+ catch (e) {
66
+ return fail(400, `.sig 旁车不是合法 JSON(${String(e)})——应为 {alg,key_id,public_key,value,canon} 对象`);
67
+ }
68
+ if (!isObj(parsed))
69
+ fail(400, ".sig 旁车必须是 JSON 对象({alg,key_id,public_key,value,canon})");
70
+ const { alg, key_id: keyId, public_key: publicKey, value, canon, registry_id: registryId } = parsed;
71
+ if (alg !== "Ed25519")
72
+ fail(400, `.sig alg 须为 "Ed25519",实为「${String(alg)}」`);
73
+ if (typeof keyId !== "string" || !isValidKeyId(keyId))
74
+ fail(400, `.sig key_id 非法(C2-37 <publisher>.<id>):「${String(keyId)}」`);
75
+ if (typeof publicKey !== "string" || !PUBLIC_KEY_HEX.test(publicKey))
76
+ fail(400, `.sig public_key 须为 64 hex(Ed25519 32B raw):「${typeof publicKey === "string" ? `${publicKey.slice(0, 16)}…` : String(publicKey)}」`);
77
+ if (typeof value !== "string" || value === "" || !SIG_B64.test(value))
78
+ fail(400, ".sig value 须为非空标准 base64 签名");
79
+ if (canon !== "v2")
80
+ fail(400, `.sig canon 须为 "v2"(canon_stale 一律拒),实为「${String(canon)}」`);
81
+ if (typeof registryId !== "string" || registryId === "")
82
+ fail(400, `.sig registry_id 须为非空字符串(REG-CS3 签发方身份):「${String(registryId)}」`);
83
+ return { alg, key_id: keyId, public_key: publicKey, value, canon, registry_id: registryId };
84
+ }
85
+
86
+ /** 包级 canonical(REG-CS3 v2;与 packages/cli/src/lifecycle/sign.ts canonicalString 逐字一致——双端不可漂移) */
87
+ export function packageCanonicalOf(registryId: string, name: string, version: string, sha256: string): string {
88
+ return `${registryId}:${name}@${version}:${sha256}`;
89
+ }
90
+
91
+ /** 入库钳制(catalog 展示字段):summary ≤200 / description ≤2000 截断——全量 manifest 仍在包内 */
92
+ export function clampManifest(m: Record<string, unknown>): Record<string, unknown> {
93
+ const out = { ...m };
94
+ if (typeof out.summary === "string" && out.summary.length > 200)
95
+ out.summary = out.summary.slice(0, 200);
96
+ if (typeof out.description === "string" && out.description.length > 2000)
97
+ out.description = out.description.slice(0, 2000);
98
+ return out;
99
+ }
100
+
101
+ /** 带 status 的可读失败(respond 收口成 json.fail,D14/B5 同通道) */
102
+ export function fail(status: number, msg: string): never {
103
+ throw Object.assign(new Error(msg), { status });
104
+ }