@oj-module/cli 0.2.29 → 0.2.42

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 (117) hide show
  1. package/devkit/manual.md +5 -5
  2. package/devkit/module-dev-and-cdn-playground.md +1 -1
  3. package/package.json +2 -2
  4. package/shell-dist/assets/{index-CnJsHOe-.js → index-FYt9CjFd.js} +50 -17
  5. package/shell-dist/assets/runtime.js +2 -2
  6. package/shell-dist/index.html +3 -3
  7. package/shell-dist/versions.json +3 -3
  8. package/src/__tests__/cli.test.ts +23 -14
  9. package/src/__tests__/list.test.ts +176 -0
  10. package/src/__tests__/registry-cli.test.ts +522 -0
  11. package/src/__tests__/registry-export.test.ts +319 -0
  12. package/src/__tests__/registry-import.test.ts +336 -0
  13. package/src/__tests__/registry-sign.test.ts +352 -0
  14. package/src/cli-output.ts +81 -0
  15. package/src/cli.ts +298 -39
  16. package/src/config-cmd.ts +100 -0
  17. package/src/contract/__tests__/handler-scan.test.ts +74 -2
  18. package/src/contract/handler-scan.ts +27 -2
  19. package/src/enable.ts +1 -1
  20. package/src/exit-codes.ts +33 -0
  21. package/src/index.ts +5 -9
  22. package/src/install.ts +120 -38
  23. package/src/lifecycle/__tests__/authoring.test.ts +6 -5
  24. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.bad.sig +3 -2
  25. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.sig +3 -2
  26. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.unknown.sig +3 -2
  27. package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.d.mts +5 -4
  28. package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.mjs +9 -6
  29. package/src/lifecycle/__tests__/helpers.ts +10 -6
  30. package/src/lifecycle/__tests__/install.test.ts +72 -15
  31. package/src/lifecycle/__tests__/manifest-contract.test.ts +40 -1
  32. package/src/lifecycle/__tests__/publish.test.ts +174 -33
  33. package/src/lifecycle/__tests__/sig-fixtures.test.ts +9 -6
  34. package/src/lifecycle/__tests__/sign.test.ts +9 -8
  35. package/src/lifecycle/__tests__/smoke.test.ts +1 -1
  36. package/src/lifecycle/__tests__/uninstall.test.ts +4 -4
  37. package/src/lifecycle/sign.ts +8 -7
  38. package/src/list.ts +146 -0
  39. package/src/manifest-contract.ts +11 -5
  40. package/src/market/__tests__/bootstrap.test.ts +4 -1
  41. package/src/market/__tests__/build-index.test.ts +529 -42
  42. package/src/market/__tests__/fixture.test.ts +17 -1
  43. package/src/market/__tests__/publish.test.ts +8 -7
  44. package/src/market/__tests__/setup.test.ts +4 -4
  45. package/src/market/__tests__/sharding.test.ts +90 -0
  46. package/src/market/__tests__/verify.test.ts +205 -12
  47. package/src/market/__tests__/yank-ledger.test.ts +60 -0
  48. package/src/market/bootstrap.ts +8 -3
  49. package/src/market/build-index.ts +407 -40
  50. package/src/market/cdn-layout.ts +3 -2
  51. package/src/market/command.ts +40 -6
  52. package/src/market/failure-playbook.ts +1 -1
  53. package/src/market/fixture.ts +20 -3
  54. package/src/market/index-builder.ts +120 -47
  55. package/src/market/publish.ts +3 -1
  56. package/src/market/setup.ts +3 -3
  57. package/src/market/sharding.ts +94 -0
  58. package/src/market/trial.ts +1 -1
  59. package/src/market/verify.ts +213 -50
  60. package/src/market/walkthrough.ts +7 -6
  61. package/src/market/yank-ledger.ts +91 -0
  62. package/src/publish.ts +250 -38
  63. package/src/registry-export.ts +405 -0
  64. package/src/registry-import.ts +410 -0
  65. package/src/registry-sidecar.ts +84 -0
  66. package/src/registry-sign.ts +322 -0
  67. package/src/registry-source.ts +192 -0
  68. package/src/sign.ts +13 -10
  69. package/src/site.ts +4 -4
  70. package/src/trust.ts +1 -1
  71. package/src/uninstall.ts +14 -2
  72. package/src/usage.ts +130 -25
  73. package/src/view.ts +94 -0
  74. package/templates/cdn/README.md +35 -5
  75. package/templates/config.yaml +10 -0
  76. package/templates/init.include.yaml +1 -1
  77. package/templates/src/_platform/api/_lifecycle/atomic.ts +3 -0
  78. package/templates/src/_platform/api/_lifecycle/installer.ts +44 -7
  79. package/templates/src/_platform/api/_lifecycle/limits.ts +21 -0
  80. package/templates/src/_platform/api/_lifecycle/source-seq.ts +88 -0
  81. package/templates/src/_platform/api/_lifecycle/steps/download.ts +15 -0
  82. package/templates/src/_platform/api/_lifecycle/steps/extract.ts +61 -2
  83. package/templates/src/_platform/api/_lifecycle/steps/preflight.ts +55 -6
  84. package/templates/src/_platform/api/_lifecycle/steps/register.ts +2 -1
  85. package/templates/src/_platform/api/_lifecycle/steps/verify.ts +22 -8
  86. package/templates/src/_platform/api/_lifecycle/tgz-guard.ts +96 -0
  87. package/templates/src/_platform/api/_lifecycle/types.ts +6 -3
  88. package/templates/src/_platform/api/_lifecycle/verify-checks.ts +8 -0
  89. package/templates/src/_platform/api/_shared/service/domain/solution-perms.ts +9 -1
  90. package/templates/src/_platform/api/_shared/site-facade.ts +62 -11
  91. package/templates/src/_platform/api/manifest.yaml +1 -0
  92. package/templates/src/_platform/api/market/api.ts +362 -99
  93. package/templates/src/_platform/api/market/index-source.ts +112 -0
  94. package/templates/src/_platform/api/migrations/0014__reg_source_seq.mysql.sql +9 -0
  95. package/templates/src/_platform/api/migrations/0014__reg_source_seq.pg.sql +7 -0
  96. package/templates/src/_platform/api/migrations/0014__reg_source_seq.sql +8 -0
  97. package/templates/src/_platform/api/schema.yaml +8 -0
  98. package/templates/src/_platform/api/solutions/api.ts +8 -3
  99. package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +13 -0
  100. package/templates/src/oj-market/web/__tests__/InstallDialog.test.tsx +38 -0
  101. package/templates/src/oj-market/web/components/CardItem.tsx +10 -0
  102. package/templates/src/oj-market/web/components/InstallDialog.tsx +18 -0
  103. package/templates/src/oj-market/web/locales/en-US.json +1 -0
  104. package/templates/src/oj-market/web/locales/zh-CN.json +1 -0
  105. package/templates/src/oj-market/web/types.ts +3 -1
  106. package/templates/src/system/web/entry.ts +5 -2
  107. package/templates/tests/lifecycle/installer.spec.ts +24 -0
  108. package/templates/tests/lifecycle/market-api.spec.ts +641 -85
  109. package/templates/tests/lifecycle/preflight.spec.ts +52 -0
  110. package/templates/tests/lifecycle/sig-fixtures.spec.ts +7 -4
  111. package/templates/tests/lifecycle/source-seq.spec.ts +55 -0
  112. package/templates/tests/lifecycle/steps-fs.spec.ts +166 -7
  113. package/templates/tests/lifecycle/tgz-guard.spec.ts +133 -1
  114. package/templates/tests/lifecycle/verify-checks.spec.ts +27 -13
  115. package/templates/tests/lifecycle/verify-report.spec.ts +3 -1
  116. package/templates/tests/rbac-solution-perms.spec.ts +41 -0
  117. package/vendor/host-versions.json +3 -3
@@ -0,0 +1,88 @@
1
+ // REG-CS2 索引新鲜度(防回放/降级):站点按源记住「见过的最大 seq」,回退即拒。
2
+ //
3
+ // 记在站点 DB(`reg_source_seq`,0014 迁移)而不记在 CLI:受害者是**站点**——镜像把旧索引
4
+ // 回放给谁,谁就是被降级的一方(D1 已定,CLI/API 一致)。CLI 侧 `ojm market verify` 是
5
+ // 离线单点校验,没有「上次见过的 seq」,故不做这项判定。
6
+ //
7
+ // fail-closed 口径(与 REG-CS1 同源):
8
+ // - 读不到 / 读到非正整数 / 写入失败 —— 一律按 `index_state_corrupt` 拒,不静默重置。
9
+ // 静默重置等于把「防回放」降级成「每次都当第一次见」,攻击者先破坏状态再回放即穿。
10
+ // - 表不存在同样算读不到:迁移由 _platform 自带,起不来就该响,不该悄悄放行。
11
+
12
+ // db 为 oj isolate 注入全局(同模板 service 层惯例,history.ts 同款;测试由夹具装 globalThis.db)
13
+ declare const db: { table: (name: string) => any };
14
+
15
+ export const SOURCE_SEQ_TABLE = "reg_source_seq";
16
+
17
+ /** 判定:ok = 放行并已记录;!ok = 拒,`code` 区分「回放/降级」与「状态损坏」 */
18
+ export type SourceSeqVerdict = {
19
+ ok: true
20
+ prev: number | null
21
+ } | {
22
+ ok: false
23
+ code: "index_stale" | "index_state_corrupt"
24
+ prev: number | null
25
+ };
26
+
27
+ /**
28
+ * 比对并推进某个源的最大 seq。
29
+ *
30
+ * - 首次见(无行)→ 记录并放行;
31
+ * - 同 seq 重复拉取 → 放行(幂等,不误伤正常重复请求),不重复写;
32
+ * - seq 回退(< 记录值)→ `index_stale` 拒,且不覆盖记录(不能让回放把水位拉低)。
33
+ */
34
+ export async function guardAndRecordSourceSeq(source: string, seq: number): Promise<SourceSeqVerdict> {
35
+ const prev = await readSourceSeq(source);
36
+ if (prev === "corrupt")
37
+ return { ok: false, code: "index_state_corrupt", prev: null };
38
+ if (prev !== null && seq < prev)
39
+ return { ok: false, code: "index_stale", prev };
40
+ if (prev === seq)
41
+ return { ok: true, prev };
42
+ try {
43
+ await writeSourceSeq(source, seq);
44
+ }
45
+ catch {
46
+ // 写失败按 fail-closed 转「状态损坏」——不静默重置(否则防回放被降级成「每次都当第一次见」)
47
+ return { ok: false, code: "index_state_corrupt", prev: null };
48
+ }
49
+ return { ok: true, prev };
50
+ }
51
+
52
+ /** 读取记录:无行 → null;读不到或非正整数 → "corrupt"(bigint 是 pg/mysql 的整型口径,一并认) */
53
+ export async function readSourceSeq(source: string): Promise<number | null | "corrupt"> {
54
+ let rows: Array<{ max_seq?: unknown }>;
55
+ try {
56
+ rows = await db.table(SOURCE_SEQ_TABLE)
57
+ .select(["max_seq"])
58
+ .where({ field: "source_url", op: "eq", value: source })
59
+ .limit(1)
60
+ .all() as Array<{ max_seq?: unknown }>;
61
+ }
62
+ catch {
63
+ return "corrupt";
64
+ }
65
+ if (!Array.isArray(rows) || rows.length === 0)
66
+ return null;
67
+ const raw = rows[0]?.max_seq;
68
+ if (typeof raw === "bigint")
69
+ return raw >= 1n ? Number(raw) : "corrupt";
70
+ if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1)
71
+ return raw;
72
+ return "corrupt";
73
+ }
74
+
75
+ /**
76
+ * 记录某源的最大 seq:先 insert(首见),撞唯一键(并发首见同源)则降级为 update。
77
+ * 这样消除「SELECT 再 INSERT」的 TOCTOU——并发首装同源不再因唯一键冲突被误判成
78
+ * index_state_corrupt;任何写失败仍向上抛,由 guard 按 fail-closed 转成 index_state_corrupt。
79
+ */
80
+ export async function writeSourceSeq(source: string, seq: number): Promise<void> {
81
+ const now = Math.floor(Date.now() / 1000);
82
+ try {
83
+ await db.table(SOURCE_SEQ_TABLE).insert({ source_url: source, max_seq: seq, updated_at: now }).run();
84
+ }
85
+ catch {
86
+ await db.table(SOURCE_SEQ_TABLE).update({ max_seq: seq, updated_at: now }).where({ field: "source_url", op: "eq", value: source }).run();
87
+ }
88
+ }
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type { InstallContext } from "./preflight";
8
8
  import { fsFace } from "../atomic";
9
+ import { MAX_PACKAGE_BYTES } from "../limits";
9
10
 
10
11
  /** 最小 fetch 面(结构化:Node fetch/Response 天然满足;测试注入 stub 不真跑网络) */
11
12
  export type FetchFace = (url: string) => Promise<{
@@ -28,6 +29,8 @@ export interface StageResult {
28
29
  ok: boolean
29
30
  message: string
30
31
  retryable?: boolean
32
+ /** 终止态错误码(installer 据此置 task.error.reason);体积闸门命中恒 package_too_large */
33
+ code?: string
31
34
  }
32
35
 
33
36
  /**
@@ -62,6 +65,18 @@ export async function download(ctx: DownloadContext): Promise<StageResult> {
62
65
  return { stage: "downloading", ok: false, retryable: false, message: `下载失败:archive.file「${arch.file}」须逐字为 <name>-<version>.tgz(「${expected}」,P2-11 索引投毒加固)` };
63
66
  }
64
67
  const dest = `${ctx.staging}/${arch.file}`;
68
+ // REG-CS8 下载闸(红队 F1):先比对索引声明的 archive.size,超限直接拒——不发请求、不读入内存,
69
+ // 把「先中毒后验毒」的源头堵死(限量值全仓单源,与解压闸/上传侧共用 MAX_PACKAGE_BYTES)。
70
+ const declaredSize = arch.archive.size;
71
+ if (typeof declaredSize === "number" && Number.isFinite(declaredSize) && declaredSize > MAX_PACKAGE_BYTES) {
72
+ return {
73
+ stage: "downloading",
74
+ ok: false,
75
+ retryable: false,
76
+ code: "package_too_large",
77
+ message: `下载失败:分发包声明体积 ${declaredSize} 字节超过上限 ${MAX_PACKAGE_BYTES}(REG-CS8 package_too_large)——拒绝下载以省带宽与内存,请发布方拆分或压缩后重试`,
78
+ };
79
+ }
65
80
  try {
66
81
  const res = await ctx.fetch(ctx.archiveUrl);
67
82
  if (!res.ok) {
@@ -9,7 +9,8 @@
9
9
 
10
10
  import type { InstallContext } from "./preflight";
11
11
  import { fsFace } from "../atomic";
12
- import { validateTarListing } from "../tgz-guard";
12
+ import { MAX_UNPACKED_BYTES } from "../limits";
13
+ import { checkTarVolumeLimits, validateTarListing } from "../tgz-guard";
13
14
  import { archiveFileOf } from "./download";
14
15
 
15
16
  export interface ExecResult {
@@ -49,6 +50,8 @@ export interface StageResult {
49
50
  ok: boolean
50
51
  message: string
51
52
  retryable?: boolean
53
+ /** 终止态错误码(installer 据此置 task.error.reason);体积闸门命中恒为体积类 code */
54
+ code?: string
52
55
  }
53
56
 
54
57
  function isObj(v: unknown): v is Record<string, unknown> {
@@ -94,6 +97,17 @@ export async function extract(ctx: ExtractContext): Promise<StageResult> {
94
97
  message: `解压失败:包内容未通过安全校验(R1:offender「${v.offender}」为越界/链接成员)——零解压零落盘,附录 A extracting:若反复出现请勿使用该分发包`,
95
98
  };
96
99
  }
100
+ // 第二步(续)REG-CS8 体积闸门:复用同一份清单累加,零额外 IO、零解压前即拒
101
+ const vol = checkTarVolumeLimits(lines);
102
+ if (!vol.ok) {
103
+ return {
104
+ stage: "extracting",
105
+ ok: false,
106
+ retryable: false,
107
+ code: vol.code,
108
+ message: `解压失败:包内容超出体积上限(REG-CS8:${vol.code},offender「${vol.offender}」)——零解压零落盘,附录 A extracting:请发布方拆分或压缩后重试`,
109
+ };
110
+ }
97
111
  // 第三步 extract:校验通过才落盘;-C staging 就位即止,rename 由 P1-10 落锁段接管
98
112
  const extracted = await ctx.exec.run(buildExtractCmd(file, ctx.staging));
99
113
  if (extracted.code !== 0) {
@@ -109,7 +123,19 @@ export async function extract(ctx: ExtractContext): Promise<StageResult> {
109
123
  if (!c31.ok) {
110
124
  return { stage: "extracting", ok: false, retryable: false, message: c31.message };
111
125
  }
112
- return { stage: "extracting", ok: true, message: `解压完成:${lines.length} 个成员 → ${ctx.staging}/(单根已归平,§2.9 布局消费点;C2-31 复核通过;目录 rename 由 P1-10 落锁段接管)` };
126
+ // REG-CS8 解压后复核:清单尺寸可能由畸形 tar 瞒报(解压炸弹绕过清单口径),以实际磁盘占用兜底。
127
+ // 仅当 stat 面可用时执行(oj 真机提供;测试/缺失则降级跳过,LIST 闸已先拦)。
128
+ const recheck = await recheckStagingOccupancy(ctx);
129
+ if (!recheck.ok) {
130
+ return {
131
+ stage: "extracting",
132
+ ok: false,
133
+ retryable: false,
134
+ code: "unpacked_too_large",
135
+ message: `解压失败:实际磁盘占用 ${recheck.bytes} 字节超过上限 ${MAX_UNPACKED_BYTES}(REG-CS8 解压后复核:清单尺寸瞒报,已清场)——附录 A extracting:请发布方拆分或压缩后重试`,
136
+ };
137
+ }
138
+ return { stage: "extracting", ok: true, message: `解压完成:${lines.length} 个成员 → ${ctx.staging}/(单根已归平,§2.9 布局消费点;C2-31 复核通过;REG-CS8 解压后复核通过;目录 rename 由 P1-10 落锁段接管)` };
113
139
  }
114
140
  catch (e) {
115
141
  return { stage: "extracting", ok: false, retryable: true, message: `解压失败:${String(e)}——附录 A extracting(S1-2 exec.run tar 三步范式)` };
@@ -239,3 +265,36 @@ export async function flattenBundleRoot(staging: string): Promise<void> {
239
265
  await f.remove(root, { recursive: true });
240
266
  }
241
267
  }
268
+
269
+ /**
270
+ * REG-CS8 解压后实际磁盘占用复核(解压炸弹绕过清单口径的兜底):递归累加 staging 全部文件体积。
271
+ * 仅当 stat 面可用时执行;读盘失败/缺面 → 降级跳过(LIST 闸已先拦,不误杀正常包)。
272
+ * 注:staging 顶层的 .tgz 一并计入(已压缩,≤ MAX_PACKAGE_BYTES,对 200MB 阈值保守不影响正常包)。
273
+ */
274
+ async function recheckStagingOccupancy(ctx: ExtractContext): Promise<{ ok: true } | { ok: false, bytes: number }> {
275
+ const f = fsFace();
276
+ if (f.stat === undefined)
277
+ return { ok: true };
278
+ let total = 0;
279
+ const walk = async (dir: string): Promise<void> => {
280
+ const names = await f.readdir(dir);
281
+ for (const name of names) {
282
+ const p = `${dir}/${name}`;
283
+ try {
284
+ await f.readdir(p); // 失败 = 普通文件(与 flattenBundleRoot 同手法)
285
+ }
286
+ catch {
287
+ total += (await f.stat!(p)).size;
288
+ continue;
289
+ }
290
+ await walk(p);
291
+ }
292
+ };
293
+ try {
294
+ await walk(ctx.staging);
295
+ }
296
+ catch {
297
+ return { ok: true };
298
+ }
299
+ return total > MAX_UNPACKED_BYTES ? { ok: false, bytes: total } : { ok: true };
300
+ }
@@ -19,11 +19,15 @@ export interface StageResult {
19
19
  ok: boolean
20
20
  message: string
21
21
  retryable?: boolean
22
+ /** 终止态错误码(安装器据此置 task.error.reason);保留名命中恒 reserved_name */
23
+ code?: string
22
24
  }
23
25
 
24
26
  interface Violation {
25
27
  clause: string
26
28
  detail: string
29
+ /** 终止态错误码(安装器据此置 task.error.reason);保留名命中恒 reserved_name */
30
+ code?: string
27
31
  }
28
32
 
29
33
  // ── 契约 §2/§4 正则与清单(与契约现行文本逐字对应,改动须先改契约再同步夹具与双端实现)──
@@ -31,8 +35,13 @@ interface Violation {
31
35
  /** C2-01:name 白名单 */
32
36
  const NAME_RE = /^[a-z][a-z0-9-]{0,63}$/;
33
37
 
34
- /** §4.2 保留名清单(C2-01/13/14 共用;market 经 R9 恢复保留) */
35
- const RESERVED_NAMES = new Set(["_platform", "system", "auth", "market", "framework", "health", "plugins", "_system"]);
38
+ /** §4.2 保留名清单(C2-01/13/14 共用;market 经 R9 恢复保留)。REG-CS7 扩项:oj/ojm/registry 一并保留 */
39
+ const RESERVED_NAMES = new Set(["_platform", "system", "auth", "market", "framework", "health", "plugins", "_system", "oj", "ojm", "registry"]);
40
+
41
+ /** 保留名替代建议(REG-CS7:给可落地的 kebab-case 候选,而非只说「非法」;两候选恒非保留名) */
42
+ function reservedNameSuggestions(name: string): [string, string] {
43
+ return [`${name}-app`, `meta-${name}`];
44
+ }
36
45
 
37
46
  /** C2-02 字符集:[0-9A-Za-z.-](禁 +——oj validate_version 拒装,附录 B-1;禁 _——契约收紧) */
38
47
  const VERSION_CHARS_RE = /^[0-9A-Z.-]+$/i;
@@ -152,6 +161,34 @@ function caretSatisfies(host: string, base: string): boolean {
152
161
  return hm === 0 && hi === 0 && hp === bp;
153
162
  }
154
163
 
164
+ /**
165
+ * 升版反噬(红队 F4)的专属迁移提示:`host 0.2.x` + 声明 `^0.1.x`。
166
+ *
167
+ * 0.x 的 caret 语义是「minor 必须相等」(见 caretSatisfies),所以平台小版本一升,
168
+ * 所有声明 `^0.1.0` 的存量包**同时**装不上。这不是包写错了,是升级带来的兼容断点——
169
+ * 笼统回「不满足 peerPlatform」会把发布方引向「升方案版本」「改代码」两个错方向,
170
+ * 真正要改的只有这一行 range。
171
+ *
172
+ * 只对 0.x 给建议:`^1.0.0` 在 2.0.0 宿主上不满足是标准的语义化版本预期,没有迁移可言。
173
+ */
174
+ function caretUpgradeAdvice(host: string, op: string | undefined, base: string): string | null {
175
+ if (op !== "^")
176
+ return null;
177
+ const h = coreParts(host);
178
+ const b = coreParts(base);
179
+ if (h === null || b === null)
180
+ return null;
181
+ if (h[0] !== 0 || b[0] !== 0 || h[1] <= b[1])
182
+ return null;
183
+ return `这是平台小版本升级带来的兼容断点(0.x 下 ^ 只匹配同 minor:^${coreOf(base)} 等价于 ${coreOf(base).split(".")[0]}.${b[1]}.*),不是本方案的问题。请把 peerPlatform 改成「>=${coreOf(base)}」后重新打包——这一条要改的是声明,不是代码`;
184
+ }
185
+
186
+ /** 版本核心段文本(`0.1.0-rc.1` → `0.1.0`;cit:提示文案里只出现核心段,别把 prerelease 带进去) */
187
+ function coreOf(v: string): string {
188
+ const [major, minor, patch] = coreParts(v) ?? [0, 0, 0];
189
+ return `${major}.${minor}.${patch}`;
190
+ }
191
+
155
192
  export async function preflight(ctx: InstallContext): Promise<StageResult> {
156
193
  const v: Violation[] = [];
157
194
  const m = ctx.manifest;
@@ -173,7 +210,8 @@ export async function preflight(ctx: InstallContext): Promise<StageResult> {
173
210
  v.push({ clause: "C2-01", detail: `name「${String(m.name)}」不匹配 ^[a-z][a-z0-9-]{0,63}$(小写字母开头,仅小写字母/数字/连字符)` });
174
211
  }
175
212
  if (RESERVED_NAMES.has(name)) {
176
- v.push({ clause: "C2-01", detail: `name「${name}」是保留名(§4.2:_platform/system/auth/market/framework/health/plugins/_system),禁用于方案(R9)` });
213
+ const [a1, a2] = reservedNameSuggestions(name);
214
+ v.push({ clause: "C2-01", code: "reserved_name", detail: `name「${name}」为保留名(§4.2:${[...RESERVED_NAMES].join("/")}),禁用于方案(R9);替代名建议:${a1} / ${a2}` });
177
215
  }
178
216
 
179
217
  // ── C2-02 version:字符集 + oj 相容排除 ──
@@ -236,7 +274,13 @@ export async function preflight(ctx: InstallContext): Promise<StageResult> {
236
274
  v.push({ clause: "C2-11", detail: `无法读取宿主平台 API 版本(GET /api/_platform/market/meta 不可用或本仓 API_VERSION 未注入),peerPlatform「${range}」强制比对失败(fail-closed)` });
237
275
  }
238
276
  else if (!rangeSatisfies(host, parsed[1], parsed[2])) {
239
- v.push({ clause: "C2-11", detail: `宿主平台 API 版本 ${host} 不满足 peerPlatform 声明「${range}」(需升级宿主平台模块)` });
277
+ const advice = caretUpgradeAdvice(host, parsed[1], parsed[2]);
278
+ v.push({
279
+ clause: "C2-11",
280
+ detail: advice === null
281
+ ? `宿主平台 API 版本 ${host} 不满足 peerPlatform 声明「${range}」(需升级宿主平台模块)`
282
+ : `宿主平台 API 版本 ${host} 不满足 peerPlatform 声明「${range}」。${advice}`,
283
+ });
240
284
  }
241
285
  }
242
286
  }
@@ -263,7 +307,8 @@ export async function preflight(ctx: InstallContext): Promise<StageResult> {
263
307
  else {
264
308
  apiSeg = p.slice("/api/".length);
265
309
  if (RESERVED_NAMES.has(apiSeg)) {
266
- v.push({ clause: "C2-13", detail: `api.prefix 模块段「${apiSeg}」是保留名(§4.2),禁用` });
310
+ const [a1, a2] = reservedNameSuggestions(apiSeg);
311
+ v.push({ clause: "C2-13", code: "reserved_name", detail: `api.prefix 模块段「${apiSeg}」为保留名(§4.2:${[...RESERVED_NAMES].join("/")}),禁用;替代名建议:${a1} / ${a2}` });
267
312
  }
268
313
  if (apiSeg !== name) {
269
314
  const occ = occupier(apiSeg);
@@ -341,7 +386,8 @@ export async function preflight(ctx: InstallContext): Promise<StageResult> {
341
386
  else {
342
387
  const seg = rp.slice(1);
343
388
  if (RESERVED_NAMES.has(seg)) {
344
- v.push({ clause: "C2-14", detail: `web.route_prefix 首段「${seg}」是保留名(§4.2),禁用` });
389
+ const [a1, a2] = reservedNameSuggestions(seg);
390
+ v.push({ clause: "C2-14", code: "reserved_name", detail: `web.route_prefix 首段「${seg}」为保留名(§4.2:${[...RESERVED_NAMES].join("/")}),禁用;替代名建议:${a1} / ${a2}` });
345
391
  }
346
392
  if (seg !== name) {
347
393
  const occ = occupier(seg);
@@ -581,10 +627,13 @@ function report(v: Violation[]): StageResult {
581
627
  if (v.length === 0) {
582
628
  return { stage: "preflight", ok: true, message: "预检通过:manifest 满足 C2 契约(身份/兼容/占用/匿名/RBAC/路由/设置/health 安全预检全部通过)" };
583
629
  }
630
+ // 保留名命中(reserved_name)优先作为终止态错误码(红队 F5:撞保留名是确定性拒绝,非可重试)
631
+ const coded = v.find(x => x.code !== undefined);
584
632
  return {
585
633
  stage: "preflight",
586
634
  ok: false,
587
635
  retryable: false,
636
+ code: coded?.code,
588
637
  message: `预检未通过(${v.length} 项违规,全量列出):\n${v.map(x => `- 【${x.clause}】${x.detail}`).join("\n")}`,
589
638
  };
590
639
  }
@@ -117,7 +117,8 @@ export async function registerSolution(decl: SolutionDecl): Promise<RegisterResu
117
117
  }
118
118
 
119
119
  // RBAC(唯一出口):upsert + 声明消失标 deprecated,deprecated 清单原样汇入
120
- const perms = await upsertSolutionPerms({ module: decl.module, roles: decl.roles });
120
+ // permissions 透传顶层声明——无角色的权限点只能靠它落库(少了这行 = 管理面绑不了)
121
+ const perms = await upsertSolutionPerms({ module: decl.module, roles: decl.roles, permissions: decl.permissions });
121
122
 
122
123
  // 设置项 upsert:存在只覆定义列(value/update 语义 = 管理员改过的值不复活,AC1-15),
123
124
  // 不存在插 default_value(首装 value)
@@ -26,6 +26,12 @@ export interface VerifyContext extends InstallContext {
26
26
  trustPath?: string
27
27
  /** Ed25519 验签面;缺省回落 globalThis.crypto.verifyEd25519 */
28
28
  verifyEd25519?: VerifyEd25519Face
29
+ /**
30
+ * 本站点(registry)身份(REG-CS3 v2 防跨仓库重放):签名 .sig.registry_id 必须与之一致,
31
+ * 否则视为「A 签的包被 B 的 CDN 安装」→ canon_mismatch 拒装。缺省 undefined = 不启用跨仓比对
32
+ * (仅验签完整性),由站点装配时填入自身 registry id 才生效。
33
+ */
34
+ registryId?: string
29
35
  }
30
36
 
31
37
  export interface StageResult {
@@ -41,8 +47,8 @@ export interface StageResult {
41
47
  const HEX64 = /^[0-9a-f]{64}$/;
42
48
  /** 标准 base64(含 -/_ 即 base64url,一律 sig_format,禁归一) */
43
49
  const STD_B64 = /^[A-Z0-9+/]+={0,2}$/i;
44
- /** 规范串版本标记(契约 §2.11;不认识即 sig_format) */
45
- const CANON = "v1";
50
+ /** 规范串版本标记(REG-CS3 v2;不认识即 canon_stale 拒装) */
51
+ const CANON = "v2";
46
52
 
47
53
  interface Signature {
48
54
  alg: unknown
@@ -50,6 +56,8 @@ interface Signature {
50
56
  public_key: unknown
51
57
  value: unknown
52
58
  canon?: unknown
59
+ /** 签发方 registry 身份(REG-CS3 v2 规范串首段,跨仓重放判定真源) */
60
+ registry_id?: unknown
53
61
  }
54
62
 
55
63
  /** 签名失败结果统一出口(六码:retryable 恒 false、零解压零落盘;unknown_key 附 needsTrust) */
@@ -160,9 +168,9 @@ async function verifySignature(ctx: VerifyContext, bytes: Uint8Array, sha256: st
160
168
  if (typeof sig.value !== "string" || !STD_B64.test(sig.value)) {
161
169
  return sigFail("sig_format", "校验失败:签名 value 必须是标准 base64(sig_format;base64url 含 -/_ 不受理,不做归一)");
162
170
  }
163
- // ⑤ canon 版本标记
164
- if (sig.canon !== undefined && sig.canon !== CANON) {
165
- return sigFail("sig_format", `校验失败:规范串版本标记不认识(sig_format;本机支持 ${CANON}),实为「${String(sig.canon)}」`);
171
+ // ⑤ canon 版本标记(REG-CS3:v1 旧格式一律拒,逼重签;缺失即拒,不兼容旧 ojm)
172
+ if (sig.canon !== CANON) {
173
+ return sigFail("canon_stale", `校验失败:规范串版本过旧(canon_stale;本机要求 ${CANON},实为「${String(sig.canon)}」)——请用当前 ojm 重签,旧版 .sig 不再受理`);
166
174
  }
167
175
  // ⑥ 验签原语可得性(与 sha256Hex 同口径:不可得 = 拒装,不静默跳过)
168
176
  const face = verifyEd25519FaceOf(ctx);
@@ -186,8 +194,14 @@ async function verifySignature(ctx: VerifyContext, bytes: Uint8Array, sha256: st
186
194
  if (trustedKey !== sig.public_key) {
187
195
  return sigFail("sig_key", `校验失败:key_id「${sig.key_id}」的包内公钥与信任库绑定不一致(sig_key;首次绑定不可改——疑抢注/换钥,请发布方轮换为新 key_id)`);
188
196
  }
189
- // ⑧ 规范串 + 验签(双层语义:抛错 = sig_format,false = tampered,同拒装但分码)
190
- const canonical = `${ctx.name}@${ctx.version}:${sha256}`;
197
+ // ⑧ 规范串 + 验签(双层语义:抛错 = sig_format,false = signature_mismatch,同拒装但分码)
198
+ // REG-CS3 v2:规范串首段 = 签发方 registry 身份,防「A 签的包被 B 的 CDN 安装」跨仓重放。
199
+ const sigRegistryId = typeof sig.registry_id === "string" ? sig.registry_id : "";
200
+ const canonical = `${sigRegistryId}:${ctx.name}@${ctx.version}:${sha256}`;
201
+ // REG-CS3 跨仓重放:声明的 registry_id 必须与本站点身份一致(env.registryId 未配置则跳过此比对)
202
+ if (ctx.registryId !== undefined && sigRegistryId !== ctx.registryId) {
203
+ return sigFail("canon_mismatch", `校验失败:包归属 registry「${sigRegistryId}」与本站「${ctx.registryId}」不符(canon_mismatch;REG-CS3 防跨仓库重放)——疑似被挪到非签发方 registry 的 CDN 安装,已拒装`);
204
+ }
191
205
  let verified: boolean;
192
206
  try {
193
207
  verified = face(sig.public_key, canonical, sig.value);
@@ -197,7 +211,7 @@ async function verifySignature(ctx: VerifyContext, bytes: Uint8Array, sha256: st
197
211
  return sigFail("sig_format", `校验失败:验签输入格式错(sig_format):${String(e)}`);
198
212
  }
199
213
  if (!verified) {
200
- return sigFail("tampered", "校验失败:签名与包体不符(tampered;签名不符不落盘——请重新从可信源获取分发包与签名)");
214
+ return sigFail("signature_mismatch", "校验失败:签名与包体不符(signature_mismatch;REG-CS3 v2 规范串含 registry_id,签名不符不落盘——请重新从可信源获取分发包与签名)");
201
215
  }
202
216
  return {
203
217
  stage: "verifying",
@@ -5,6 +5,8 @@
5
5
  // 拒绝:绝对路径、任何 `..` 段、l/h 条目、空成员名、反斜杠路径(防御性);
6
6
  // `./` 前缀视为相对合法(归一化后必须在包根内)。
7
7
 
8
+ import { MAX_ENTRIES, MAX_SINGLE_ENTRY_BYTES, MAX_UNPACKED_BYTES } from "./limits";
9
+
8
10
  function extractMemberName(line: string): string {
9
11
  const arrow = line.indexOf(" -> ");
10
12
  const entry = arrow === -1 ? line : line.slice(0, arrow);
@@ -39,3 +41,97 @@ export function validateTarListing(lines: string[]): { ok: true } | { ok: false,
39
41
  }
40
42
  return { ok: true };
41
43
  }
44
+
45
+ // ── REG-CS8 体积闸门(红队 F1):复用同一份 tar 清单累加体积/条目数/单条目最大体积,不额外跑一次 tar ──
46
+
47
+ /** 体积闸门错误码(installer 据此置 task.error.reason;retryable 恒 false——体积超限非可重试) */
48
+ export type VolumeCode = "too_many_entries" | "unpacked_too_large" | "entry_too_large";
49
+
50
+ export interface TarVolume {
51
+ /** 成员条目数(含目录条目) */
52
+ entries: number
53
+ /** 未压缩总体积(字节) */
54
+ totalUncompressed: number
55
+ /** 单条目最大未压缩体积(字节) */
56
+ maxEntry: number
57
+ }
58
+
59
+ /**
60
+ * 从 `tar -tvzf` verbose 行提取未压缩体积(字节)。
61
+ * 体积恒位于「日期组」紧邻左侧、成员名之前;日期组跨实现形态:
62
+ * GNU: <size> [MM DD | YYYY-MM-DD] [HH:MM] —— 旧 mtime(>6 月)省略时刻,只剩日期
63
+ * BSD: <size> Mon DD [HH:MM | YYYY] —— 旧 mtime 省略时刻,只剩年份
64
+ * 旧实现只锚定 HH:MM,旧 mtime 行(无时刻)回退 0,使大包绕过 REG-CS8 体积闸门(zip bomb)。
65
+ * 这里分两路:含时刻的行仍按时间回退定位(兼容 GNU-ISO / GNU 月日 / BSD 三态);
66
+ * 无时刻的旧 mtime 行改锚定「日期组」(ISO 日期或月份名),取前一 token 为 size。
67
+ * 无日期组的畸形行回退 0。
68
+ */
69
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
70
+ const MONTH = /^[A-Z]{3}$/i;
71
+ const TIME = /^\d{1,2}:\d{2}(?::\d{2})?$/;
72
+ const DAY_OR_YEAR = /^\d{1,2}$|^\d{4}$/;
73
+
74
+ /** 定位日期组首 token 索引:含时刻则回退时间锚点;否则锚定 ISO 日期或「月份名 + 其后跟日/年份」 */
75
+ function findDateGroupStart(tokens: string[]): number {
76
+ const timeIdx = tokens.findIndex(t => TIME.test(t));
77
+ if (timeIdx >= 1)
78
+ return ISO_DATE.test(tokens[timeIdx - 1]!) ? timeIdx - 1 : timeIdx - 2;
79
+ for (let i = tokens.length - 1; i >= 0; i--) {
80
+ const t = tokens[i]!;
81
+ if (ISO_DATE.test(t))
82
+ return i;
83
+ if (MONTH.test(t)) {
84
+ const next = tokens[i + 1] ?? "";
85
+ if (DAY_OR_YEAR.test(next))
86
+ return i;
87
+ }
88
+ }
89
+ return -1;
90
+ }
91
+
92
+ export function extractTarEntrySize(line: string): number {
93
+ const tokens = line.trim().split(/\s+/);
94
+ if (tokens.length < 2)
95
+ return 0;
96
+ const dateGroupStart = findDateGroupStart(tokens);
97
+ if (dateGroupStart < 1)
98
+ return 0;
99
+ const n = Number(tokens[dateGroupStart - 1]!.replace(/,/g, ""));
100
+ return Number.isFinite(n) ? n : 0;
101
+ }
102
+
103
+ /** 累加清单体积统计(条目数 / 未压缩总体积 / 单条目最大体积) */
104
+ export function accumulateTarVolume(lines: string[]): TarVolume {
105
+ let entries = 0;
106
+ let totalUncompressed = 0;
107
+ let maxEntry = 0;
108
+ for (const line of lines) {
109
+ if (line.trim() === "")
110
+ continue;
111
+ entries++;
112
+ const size = extractTarEntrySize(line);
113
+ totalUncompressed += size;
114
+ if (size > maxEntry)
115
+ maxEntry = size;
116
+ }
117
+ return { entries, totalUncompressed, maxEntry };
118
+ }
119
+
120
+ /**
121
+ * 体积闸门(REG-CS8):复用 validateTarListing 已消费的同一份 tar 清单逐项累加——
122
+ * 条目数 / 未压缩总体积 / 单条目最大体积任一超限即拒,零解压零落盘。
123
+ * 检查序:条目数 → 总体积 → 单条目(先罚「整体维度」,再罚「单点维度」)。
124
+ */
125
+ export function checkTarVolumeLimits(lines: string[]): { ok: true, volume: TarVolume } | { ok: false, code: VolumeCode, offender: string, volume: TarVolume } {
126
+ const volume = accumulateTarVolume(lines);
127
+ if (volume.entries > MAX_ENTRIES) {
128
+ return { ok: false, code: "too_many_entries", offender: `${volume.entries} 个成员(上限 ${MAX_ENTRIES})`, volume };
129
+ }
130
+ if (volume.totalUncompressed > MAX_UNPACKED_BYTES) {
131
+ return { ok: false, code: "unpacked_too_large", offender: `${volume.totalUncompressed} 字节(上限 ${MAX_UNPACKED_BYTES})`, volume };
132
+ }
133
+ if (volume.maxEntry > MAX_SINGLE_ENTRY_BYTES) {
134
+ return { ok: false, code: "entry_too_large", offender: `${volume.maxEntry} 字节(上限 ${MAX_SINGLE_ENTRY_BYTES})`, volume };
135
+ }
136
+ return { ok: true, volume };
137
+ }
@@ -41,8 +41,11 @@ export interface StageResult {
41
41
  retryable?: boolean
42
42
  /** 取消安装(U5,P3-8):本 stage 被用户中止,运行器据其走「清场 + 锁/目录回退装前 + 审计末位」语义 */
43
43
  cancelled?: boolean
44
- /** 签名失败六码(P2-3,契约附录 A.2;缺省 = 非签名类失败) */
45
- code?: SigCode
44
+ /**
45
+ * 终止态错误码(installer 据此置 task.error.reason;各 step 自带字面量集合——签名六码见 SigCode、
46
+ * 保留名 reserved_name、体积闸 package_too_large 等;统一收敛为 string 供编排层记录与 i18n 兜底)
47
+ */
48
+ code?: string
46
49
  /** 未知 key_id 待 TOFU 确认:任务停 needs_trust 终态,staging 不清场供确认后重试 */
47
50
  needsTrust?: boolean
48
51
  /** 待确认的发布方 key(TaskRecord.trust 同源;UI/CLI 展示指纹) */
@@ -53,7 +56,7 @@ export interface StageResult {
53
56
  * 签名失败六码(契约 v1.3 附录 A.2,P2-3):retryable 恒 false、零解压零落盘。
54
57
  * 文案 key = `lifecycle.stage.verifying.<code>`(双语值 P2-5 落 locales)。
55
58
  */
56
- export type SigCode = "sig_missing" | "sig_alg" | "sig_key" | "tampered" | "unknown_key" | "sig_format";
59
+ export type SigCode = "sig_missing" | "sig_alg" | "sig_key" | "tampered" | "signature_mismatch" | "canon_stale" | "canon_mismatch" | "unknown_key" | "sig_format";
57
60
 
58
61
  /**
59
62
  * 安装阶段名(契约附录 A 十阶段逐字 + D11①扩展组 lock_commit + audit/done 终态)。
@@ -222,6 +222,14 @@ export interface SolutionsEnv {
222
222
  statePath: string
223
223
  modulesJsonPath?: string
224
224
  apiPrefix?: string
225
+ /**
226
+ * canonical origin(REG-CS6 Host 头 SSRF 修复):深检探针补全用的站点权威 origin,
227
+ * 取自 config.yaml `vars.canonical_origin`,**绝不读请求头 Host/x-forwarded-proto**。
228
+ * 缺失 → null,探针跳过(info 项),不猜、不回落请求头。
229
+ */
230
+ canonicalOrigin?: string
231
+ /** 站点 config.yaml 路径(canonical origin 读取处;缺省 config.yaml) */
232
+ configPath?: string
225
233
  /** 深检报告留存目录(默认随 statePath 同目录 verify-reports;P2-8) */
226
234
  verifyReportsDir?: string
227
235
  fetch?: FetchInitFace
@@ -15,6 +15,13 @@ export interface SolutionRoleDecl {
15
15
  export interface SolutionManifest {
16
16
  module: string
17
17
  roles: SolutionRoleDecl[]
18
+ /**
19
+ * 顶层 `permissions[]` 的 code 清单(契约 C2-15 的声明面)。
20
+ * 与 `roles[].permissions` **不是**上下级关系:C2-16 只要求角色引用的 code 先在此声明,
21
+ * 反过来「声明了但没有任何角色引用」是合法形态(系统预置权限点,由管理员在管理面自行分配)。
22
+ * 漏收集的后果是这类权限点根本不落库 → 管理面绑不上 → 声明形同虚设。
23
+ */
24
+ permissions?: string[]
18
25
  }
19
26
 
20
27
  export interface UpsertResult {
@@ -25,7 +32,8 @@ export interface UpsertResult {
25
32
  export async function upsertSolutionPerms(m: SolutionManifest): Promise<UpsertResult> {
26
33
  const deprecated: string[] = [];
27
34
  const declaredRoles = new Set(m.roles.map(r => r.name));
28
- const declaredPerms = new Set(m.roles.flatMap(r => r.permissions ?? []));
35
+ // 权限点全集 = 角色引用的 ∪ 顶层独立声明的(后者是无角色权限点唯一能落库的来源)
36
+ const declaredPerms = new Set([...m.roles.flatMap(r => r.permissions ?? []), ...(m.permissions ?? [])]);
29
37
 
30
38
  for (const role of m.roles) {
31
39
  const existing = await db.table("sys_role").select(["name"]).where({ field: "name", op: "eq", value: role.name }).all() as unknown[];