@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
@@ -1,18 +1,20 @@
1
1
  /**
2
- * `ojm market verify`(T7 + REG-CS5 三级化):离线验包——不起站点、不装、不要凭据、不要 DB。
2
+ * `ojm market verify`(A6 瘦身):离线验包——不起站点、不装、不要凭据、不要 DB。
3
3
  *
4
- * 分四类列结果(包级与索引级**分开**,因为语义不同):
5
- * - 完整性:tgz 实际 sha256 与索引声明的 archive.sha256 是否一致(三级化后声明住在分片正文里);
6
- * - 包级签名:`<tgz>.sig` 能否用包内公钥复核规范串 `<name>@<version>:<sha256>`——**这是信任锚**;
7
- * - 索引签名:索引字节与其 `.sig`——只是完整性层(防目录/索引被改),不是信任锚;
8
- * - manifest 结构:必填字段、内嵌原生清单 desc 是否非空(oj 装配面必填)、同方案各版本 0001 迁移是否逐字节一致。
4
+ * v4 DB 目录化后**只剩包级面**(计划退役清单点名:generationCheck/driftOf/declaredHashes
5
+ * 等索引面整建制删除——目录真源已移交站点 market_release 表,索引文件不复存在):
6
+ * - 包级签名:`<tgz>.sig` 能否用包内公钥复核规范串 `<registry_id>:<name>@<version>:<sha256>`
7
+ * ——**这是信任锚**(安装侧 TOFU 的同一道验签);
8
+ * - manifest 结构:必填字段、内嵌原生清单 desc 是否非空(oj 装配面必填)、同方案各版本
9
+ * 0001 迁移是否逐字节一致。
10
+ *
11
+ * 完整性(sha256 对索引声明比对)随之退役——声明真源是站点 DB 行,离线 CLI 无声明可比;
12
+ * 字节-声明一致性由上传端点服务端重算 sha256 硬约束在写通道守住。
9
13
  *
10
14
  * 复用既有原语,一行密码学都不重写:`lifecycle/sign.ts` 的 canonicalString/verifyCanonical、
11
- * `publish.ts` 的 indexCanonical/shardPinsOf、`node:crypto` 的 sha256。
15
+ * `scan.ts` 的 readTgz、`node:crypto` 的 sha256。
12
16
  */
13
17
 
14
- import type { IndexKind } from "../publish";
15
- import type { IndexedPackage } from "./index-builder";
16
18
  import { createHash } from "node:crypto";
17
19
  import fs from "node:fs";
18
20
  import path from "node:path";
@@ -20,7 +22,6 @@ import path from "node:path";
20
22
  import process from "node:process";
21
23
  import { parse as yamlParse } from "yaml";
22
24
  import { CANON, canonicalString, verifyCanonical } from "../lifecycle/sign";
23
- import { indexCanonical, indexSeqOf, indexTimesOf, shardPinsOf } from "../publish";
24
25
  import { resolveCdnLayout } from "./cdn-layout";
25
26
  import { readTgz } from "./scan";
26
27
 
@@ -28,11 +29,7 @@ export interface VerifyCheck {
28
29
  item: string
29
30
  ok: boolean
30
31
  detail: string
31
- /**
32
- * 失败码(REG-CS1):可区分的三种拒因,供安装侧与报告侧共用同一套判定。
33
- * `index_sig_missing` 缺 `.sig` / `index_sig_mismatch` 签名不符 /
34
- * `index_generation_mismatch` 跨文件不同代(运维事故,与安全事件分开报)。
35
- */
32
+ /** 失败码:可区分的拒因(包级面),供报告侧共用同一套判定 */
36
33
  code?: string
37
34
  }
38
35
 
@@ -40,37 +37,20 @@ export interface VerifyOptions {
40
37
  projectRoot?: string
41
38
  /** 验指定的 tgz(缺省扫 cdn/ 下全部包) */
42
39
  file?: string
43
- /** 比对扫描重建出的索引与盘上索引,检出漂移 */
44
- rebuildCheck?: boolean
45
40
  }
46
41
 
47
42
  export interface VerifyResult {
48
43
  checks: VerifyCheck[]
49
44
  passed: boolean
50
45
  conclusion: string
51
- /** `--rebuild-check` 的漂移描述;未传该开关时为 undefined */
52
- drift?: string[]
53
46
  }
54
47
 
55
48
  const BOUNDARY = "完整性 / 来源校验通过——不构成内容安全性背书";
56
49
 
57
- /** 三级索引的读面快照:catalog 正文 + 各分片正文(REG-CS5:catalog.yaml + shards/<n>.yaml) */
58
- interface IndexSnapshot {
59
- catalogPath: string
60
- catalogText: string | null
61
- shardsDir: string
62
- /** 片号 → 正文(盘上现存、可解析的片;缺文件/不可解析 → null) */
63
- shardTexts: Map<number, string | null>
64
- }
65
-
66
50
  export function verifyMarket(opts: VerifyOptions = {}): VerifyResult {
67
51
  const projectRoot = opts.projectRoot ?? process.cwd();
68
52
  const layout = resolveCdnLayout(projectRoot);
69
53
  const checks: VerifyCheck[] = [];
70
- const drift: string[] = [];
71
-
72
- const index = readIndexSnapshot(layout.dir);
73
- const declared = declaredHashes(index);
74
54
 
75
55
  const tgzFiles = opts.file !== undefined
76
56
  ? [opts.file]
@@ -79,7 +59,7 @@ export function verifyMarket(opts: VerifyOptions = {}): VerifyResult {
79
59
  for (const file of tgzFiles) {
80
60
  const tgzPath = path.isAbsolute(file) ? file : path.join(layout.dir, file);
81
61
  if (!fs.existsSync(tgzPath)) {
82
- checks.push({ item: "完整性", ok: false, detail: `${file}:文件不存在` });
62
+ checks.push({ item: "包级签名", ok: false, detail: `${file}:文件不存在` });
83
63
  continue;
84
64
  }
85
65
  const bytes = new Uint8Array(fs.readFileSync(tgzPath));
@@ -87,66 +67,25 @@ export function verifyMarket(opts: VerifyOptions = {}): VerifyResult {
87
67
  const name = nameOf(file);
88
68
  const version = versionOf(file);
89
69
 
90
- // 文件名解析对含连字符的 slug / 预发布版本(x-y-z.tgz)不可靠,故以「内容摘要」兜底匹配:
91
- // 优先按文件名定位声明;定位不到时改用 sha256 命中索引任一声明条目,
92
- // 避免预发布包被「未声明」误判而静默放过篡改。
93
- const declaredShas = new Set(declared.values());
94
- const want = declared.get(`${name}@${version}`);
95
- const ok = want === undefined ? declaredShas.has(sha256) : want === sha256;
96
- checks.push({
97
- item: "完整性",
98
- ok,
99
- detail: want === undefined
100
- ? (declaredShas.has(sha256)
101
- ? `${file}:文件名未解析到精确版本,但 sha256 与索引某条目一致(${sha256.slice(0, 12)}…)`
102
- : `${file}:索引未声明该文件名对应版本,且 sha256 与索引任何条目都不一致(疑似篡改或未发布)(${sha256.slice(0, 12)}…)`)
103
- : `${file}:${want === sha256 ? `sha256 与索引一致(${sha256.slice(0, 12)}…)` : `sha256 不一致(索引 ${want.slice(0, 12)}… vs 实际 ${sha256.slice(0, 12)}…)`}`,
104
- });
105
-
106
70
  checks.push(signatureCheck(tgzPath, name, version, sha256));
107
71
  checks.push(...structureCheck(bytes, file));
108
72
  }
109
-
110
- checks.push(indexSigCheck(path.join(layout.dir, "catalog.yaml.sig"), "catalog.yaml", "catalog", index.catalogText));
111
- for (const [no, text] of index.shardTexts)
112
- checks.push(indexSigCheck(path.join(index.shardsDir, `${no}.yaml.sig`), `shards/${no}.yaml`, "shard", text));
113
- checks.push(generationCheck(index));
114
- checks.push(expiryCheck(index.catalogText));
115
73
  checks.push(...migrationConsistencyCheck(layout.dir, tgzFiles));
116
74
 
117
- if (opts.rebuildCheck === true)
118
- drift.push(...driftOf(layout.dir, index));
119
-
120
- const passed = checks.every(c => c.ok) && drift.length === 0;
75
+ const passed = checks.every(c => c.ok);
121
76
  return {
122
77
  checks,
123
78
  passed,
124
79
  conclusion: passed
125
80
  ? `全部校验通过(${checks.length} 项)。${BOUNDARY}——验签过只说明来源与完整性,不说明代码无害。`
126
81
  : `存在未通过项。${BOUNDARY}:未通过项须逐条排查后再发布。`,
127
- drift: opts.rebuildCheck === true ? drift : undefined,
128
82
  };
129
83
  }
130
84
 
131
- /** 读三级索引快照:catalog + 盘上 `shards/<n>.yaml` 全集(不含 .sig——签名在各 check 里按路径读) */
132
- function readIndexSnapshot(cdnDir: string): IndexSnapshot {
133
- const shardsDir = path.join(cdnDir, "shards");
134
- const shardTexts = new Map<number, string | null>();
135
- if (fs.existsSync(shardsDir)) {
136
- for (const f of fs.readdirSync(shardsDir).filter(f => /^\d+\.yaml$/.test(f)).sort()) {
137
- const no = Number.parseInt(f, 10);
138
- if (Number.isInteger(no) && no >= 1)
139
- shardTexts.set(no, readOptional(path.join(shardsDir, f)));
140
- }
141
- }
142
- const catalogPath = path.join(cdnDir, "catalog.yaml");
143
- return { catalogPath, catalogText: readOptional(catalogPath), shardsDir, shardTexts };
144
- }
145
-
146
85
  /**
147
86
  * `.sig` 里验签只用到 public_key / value 两字段;缺任一即不可复核。
148
- * 抽成类型守卫:包级与索引级两处验签共用(DRY),也顺带让调用点的布尔式保持单行
149
- * (本仓 indent-binary-ops 与 no-mixed-spaces-and-tabs 对续行缩进的要求互相打架)。
87
+ * 抽成类型守卫让调用点的布尔式保持单行(本仓 indent-binary-ops 与 no-mixed-spaces-and-tabs
88
+ * 对续行缩进的要求互相打架)。
150
89
  */
151
90
  function hasSigFields(sig: { public_key?: string, value?: string, canon?: string, registry_id?: string }): sig is { public_key: string, value: string, canon?: string, registry_id?: string } {
152
91
  return typeof sig.public_key === "string" && typeof sig.value === "string";
@@ -178,127 +117,10 @@ function signatureCheck(tgzPath: string, name: string, version: string, sha256:
178
117
  }
179
118
  }
180
119
 
181
- /**
182
- * 索引级验签(REG-CS1①:fail-closed,缺签名即拒,不再「运行期容忍」)。
183
- *
184
- * 为什么不能容忍:容忍等于把「索引完整性」交给发布方的自觉——谁改了 catalog.yaml 都能装。
185
- * 规范串带 seq(`index:<kind>:<seq>:<sha256>`),故 `.sig` 必须带 seq 才可能验过。
186
- * 分片(kind=shard)的 seq 不取自正文(纯内容无元字段),由 catalog 的 `shards` 钉值在
187
- * 同代校验里核对;此处只验「签名本身与字节匹配且覆盖代次」。
188
- */
189
- function indexSigCheck(sigPath: string, label: string, kind: IndexKind, text: string | null): VerifyCheck {
190
- if (text === null)
191
- return { item: "索引签名", ok: true, detail: `${label} 不存在,跳过` };
192
- if (!fs.existsSync(sigPath)) {
193
- return {
194
- item: "索引签名",
195
- ok: false,
196
- code: "index_sig_missing",
197
- detail: `${label}.sig 缺失(index_sig_missing)——完整性无保障,安装侧会拒装。下一步:① ojm market build-index --id <publisher.id> 重建并签名;② 或对既有索引补签 ojm publish <目录> --id <publisher.id>`,
198
- };
199
- }
200
- try {
201
- const sig = JSON.parse(fs.readFileSync(sigPath, "utf-8")) as { public_key?: string, value?: string, seq?: unknown };
202
- const seq = sigSeqOf(sig);
203
- if (seq === null)
204
- return { item: "索引签名", ok: false, code: "index_sig_mismatch", detail: `${label}.sig 缺 seq 字段——签名未覆盖代次,请用当前 ojm 重签` };
205
- const ok = hasSigFields(sig) && verifyCanonical(sig.public_key, indexCanonical(kind, seq, text), sig.value);
206
- return {
207
- item: "索引签名",
208
- ok,
209
- code: ok ? undefined : "index_sig_mismatch",
210
- detail: ok
211
- ? `${label} 验签通过(第 ${seq} 代)`
212
- : `${label} 验签失败(index_sig_mismatch)——索引被改或签名不符`,
213
- };
214
- }
215
- catch (error) {
216
- return { item: "索引签名", ok: false, code: "index_sig_mismatch", detail: `${label}.sig 不可解析:${(error as Error).message}` };
217
- }
218
- }
219
-
220
- /** `.sig` 的 seq 字段(非正整数即 null——代次必须是可比较的整数) */
221
- function sigSeqOf(sig: { seq?: unknown }): number | null {
222
- const seq = sig.seq;
223
- return typeof seq === "number" && Number.isInteger(seq) && seq >= 1 ? seq : null;
224
- }
225
-
226
- /**
227
- * REG-CS1② 同代校验(三级化):代次真源 = **catalog**(顶层 `seq` + `shards` 钉值表)。
228
- *
229
- * 每片被 catalog 钉住一个代次,其 `.sig.seq` 必须等于钉值——不允许把「新总目录」和
230
- * 「对不上代的分册」拼起来用(红队 F2:写索引写到一半进程被杀就会留下这种中间态)。
231
- * 未变的片沿用旧代次是**设计允许**的(内容逐字节未动,拼起来永远同代同内容),
232
- * 判定口径因此是「签名代次 == catalog 钉值」而非「== catalog 代次」。
233
- * 这是**运维事故**不是安全事件,所以错误码与「签名不符」分开,提示也指向「稍后重试」。
234
- */
235
- export function generationCheck(index: IndexSnapshot): VerifyCheck {
236
- const present = index.catalogText !== null;
237
- if (!present && index.shardTexts.size === 0)
238
- return { item: "索引同代", ok: true, detail: "索引文件缺失,跳过同代校验" };
239
- if (!present) {
240
- return { item: "索引同代", ok: false, code: "index_generation_mismatch", detail: `catalog.yaml 缺失但 shards/ 里有 ${index.shardTexts.size} 片——源正在重建索引或索引被手改,请稍后重试(index_generation_mismatch)` };
241
- }
242
- const catalogSeq = indexSeqOf(index.catalogText!);
243
- if (catalogSeq === null)
244
- return { item: "索引同代", ok: false, code: "index_generation_mismatch", detail: "catalog.yaml 缺顶层 seq 字段——用 ojm market build-index 重建索引" };
245
- // catalog 正文代次与签名代次必须一致(防「改了正文没换签名」的半个写入)
246
- const catalogSigSeq = sigSeqOf(readSigJson(`${index.catalogPath}.sig`));
247
- if (catalogSigSeq !== catalogSeq)
248
- return { item: "索引同代", ok: false, code: "index_generation_mismatch", detail: `catalog.yaml 正文是第 ${catalogSeq} 代、签名是第 ${String(catalogSigSeq ?? "无")} 代——源正在重建索引,请稍后重试(index_generation_mismatch)` };
249
- const pins = shardPinsOf(index.catalogText!);
250
- for (const [no, pinSeq] of pins) {
251
- // 钉值分册的正文必须存在:catalog 引用了某分册但盘上缺失 → 安装会 404,须判失败而非放过。
252
- if (!fs.existsSync(path.join(index.shardsDir, `${no}.yaml`))) {
253
- return { item: "索引同代", ok: false, code: "index_generation_mismatch", detail: `shards/${no}.yaml 正文缺失(catalog 钉第 ${pinSeq} 代)——分册丢失或源正在重建索引,请稍后重试(index_generation_mismatch)` };
254
- }
255
- const sig = readSigJson(path.join(index.shardsDir, `${no}.yaml.sig`));
256
- const sigSeq = sigSeqOf(sig);
257
- if (sigSeq === null)
258
- return { item: "索引同代", ok: false, code: "index_generation_mismatch", detail: `shards/${no}.yaml 签名代次不可判定(catalog 钉第 ${pinSeq} 代)——请用当前 ojm 重签` };
259
- if (sigSeq !== pinSeq)
260
- return { item: "索引同代", ok: false, code: "index_generation_mismatch", detail: `shards/${no}.yaml 签名是第 ${sigSeq} 代,catalog 钉的是第 ${pinSeq} 代——源正在重建索引,请稍后重试(index_generation_mismatch)` };
261
- }
262
- return { item: "索引同代", ok: true, detail: `catalog 第 ${catalogSeq} 代,${pins.size} 片代次与钉值一致` };
263
- }
264
-
265
- /** 读 .sig JSON(不可解析 → 空对象,由调用方按 fail-closed 处理) */
266
- function readSigJson(sigPath: string): { seq?: unknown } {
267
- try {
268
- return JSON.parse(fs.readFileSync(sigPath, "utf-8")) as { seq?: unknown };
269
- }
270
- catch {
271
- return {};
272
- }
273
- }
274
-
275
- /**
276
- * REG-CS2 索引时效:catalog 自带 `issued_at` / `expires_at`,过了失效时刻的索引不能再用来装。
277
- * 与站点 `market/api.ts guardCatalogFreshness` 同一判定(CLI/API 一致,D1)。
278
- */
279
- export function expiryCheck(text: string | null): VerifyCheck {
280
- if (text === null)
281
- return { item: "索引时效", ok: true, detail: "索引文件缺失,跳过时效校验" };
282
- const times = indexTimesOf(text);
283
- if (times === null)
284
- return { item: "索引时效", ok: false, code: "index_expired", detail: "catalog.yaml 缺 issued_at / expires_at——索引未带时效,用 ojm market build-index 重建" };
285
- const now = Math.floor(Date.now() / 1000);
286
- const ok = now <= times.expires_at;
287
- const at = new Date(times.expires_at * 1000).toISOString();
288
- return {
289
- item: "索引时效",
290
- ok,
291
- code: ok ? undefined : "index_expired",
292
- detail: ok
293
- ? `索引有效至 ${at}`
294
- : `索引已于 ${at} 失效(index_expired)——源未在其自声明的有效期内重建,请重建后再发布`,
295
- };
296
- }
297
-
298
120
  /**
299
121
  * manifest 结构:必填字段 + 内嵌原生清单 desc 非空(等值要求已移除,见 §9.3)。
300
122
  * 必填不含 archive:契约 C2-29 允许 Form A 整体省略,`ojm build --bundle` 对真实方案的
301
- * 落盘形态即省略(fixture 会写 file 占位)——把它当必填会让一切真实工程包验包必红。
123
+ * 落盘形态即省略(fixture 会写 file 占位)——把它当必填会让一切真实工程包验包必红。
302
124
  */
303
125
  function structureCheck(bytes: Uint8Array, file: string): VerifyCheck[] {
304
126
  let entries: ReturnType<typeof readTgz>;
@@ -376,81 +198,6 @@ function migrationConsistencyCheck(cdnDir: string, files: string[]): VerifyCheck
376
198
  return out;
377
199
  }
378
200
 
379
- /** 扫描重建出的索引与盘上索引双向比对:检出「落了包没重建 / 索引被手改 / 索引多条目」 */
380
- function driftOf(cdnDir: string, index: IndexSnapshot): string[] {
381
- const out: string[] = [];
382
- const files = fs.readdirSync(cdnDir).filter(f => f.endsWith(".tgz")).sort();
383
- const scanned: IndexedPackage[] = [];
384
- for (const file of files) {
385
- const bytes = new Uint8Array(fs.readFileSync(path.join(cdnDir, file)));
386
- const entries = readTgz(bytes);
387
- const root = entries.find(e => /^(?:[^/]+\/)?manifest\.yaml$/.test(e.name));
388
- if (root === undefined)
389
- continue;
390
- const manifest = (yamlParse(root.content.toString("utf-8")) ?? {}) as Record<string, unknown>;
391
- const name = String(manifest.name ?? "");
392
- const version = String(manifest.version ?? "");
393
- if (name === "" || version === "")
394
- continue;
395
- scanned.push({ name, version, file, sha256: createHash("sha256").update(bytes).digest("hex"), size: bytes.byteLength, manifest });
396
- }
397
- if (index.catalogText === null) {
398
- if (scanned.length > 0)
399
- out.push(`盘上没有 catalog.yaml,但目录里有 ${scanned.length} 个包——索引从未重建`);
400
- return out;
401
- }
402
- // 三级化后版本明细住在分片里:全集 = 各片 solutions[].versions
403
- const inIndex = new Set<string>();
404
- for (const text of index.shardTexts.values()) {
405
- if (text === null)
406
- continue;
407
- const doc = (yamlParse(text) ?? { solutions: [] }) as { solutions?: Array<Record<string, unknown>> };
408
- for (const solution of doc.solutions ?? []) {
409
- const name = String(solution.name ?? "");
410
- for (const v of (solution.versions ?? []) as Array<Record<string, unknown>>)
411
- inIndex.add(`${name}@${String(v.version ?? "")}`);
412
- }
413
- }
414
- const onDisk = new Set(scanned.map(p => `${p.name}@${p.version}`));
415
- for (const pkg of scanned) {
416
- if (!inIndex.has(`${pkg.name}@${pkg.version}`))
417
- out.push(`索引缺条目:${pkg.name}@${pkg.version}(${pkg.file})——落了包但没跑 ojm market build-index`);
418
- }
419
- // 反向漂移:索引钉了某个版本、盘上却没有对应 tgz(包被删/被挤走没重建)——
420
- // 只查正向会让「索引答应有、实际没有」悄悄漏过去,安装侧拉到 404 才知道
421
- for (const key of inIndex) {
422
- if (onDisk.has(key))
423
- continue;
424
- const [name, version] = key.split("@") as [string, string];
425
- out.push(`索引多条目:${name}@${version}——索引声明了该版本但 cdn/ 里没有 ${name}-${version}.tgz,跑 ojm market build-index 重建`);
426
- }
427
- return out;
428
- }
429
-
430
- /** 索引声明的 `name@version → sha256`(三级化后声明住在分片正文的 archive.sha256) */
431
- function declaredHashes(index: IndexSnapshot): Map<string, string> {
432
- const out = new Map<string, string>();
433
- for (const text of index.shardTexts.values()) {
434
- if (text === null)
435
- continue;
436
- const doc = (yamlParse(text) ?? { solutions: [] }) as { solutions?: Array<Record<string, unknown>> };
437
- for (const solution of doc.solutions ?? []) {
438
- const name = String(solution.name ?? "");
439
- for (const v of (solution.versions ?? []) as Array<Record<string, unknown>>) {
440
- const archive = v.archive as Record<string, unknown> | undefined;
441
- const sha = archive?.sha256;
442
- if (typeof sha === "string")
443
- out.set(`${name}@${String(v.version ?? "")}`, sha);
444
- }
445
- }
446
- }
447
- return out;
448
- }
449
-
450
- function readOptional(file: string): string | null {
451
- return fs.existsSync(file) ? fs.readFileSync(file, "utf-8") : null;
452
- }
453
-
454
201
  /** `<name>-<version>.tgz` → name(取最后一个 - 之前,版本段不含 -) */
455
202
  function nameOf(file: string): string {
456
203
  const base = path.basename(file).replace(/\.tgz$/, "");
@@ -6,9 +6,10 @@
6
6
  *
7
7
  * 输出是一张 AC 表 + 汇总行;有 FAIL 即退出码 1。`--strict` 首败即停(调试用)。
8
8
  *
9
- * T12 追加:upgrade / rollback(主链路)与 neg-tamper / nosig / badindex / stranger(负向)。
9
+ * T12 追加:upgrade / rollback(主链路)与 neg-tamper / nosig / stranger(负向)。
10
+ * A6:索引面负向(neg-badindex)随索引机制退役删除——写通道门禁(cdn-upload.spec)承接。
10
11
  * neg-* 统一用 0.0.9 版本(低于主链路 latest 0.2.0,永远成不了 latest)与主链路隔离;篡改的文件在 finally 里回抄原始字节(不重签),
11
- * 收尾摘包重铺索引——否则后续 op 全挂。
12
+ * 收尾摘包除目录行——否则后续 op 全挂。
12
13
  */
13
14
 
14
15
  import type { TaskRecord } from "../site";
@@ -20,11 +21,10 @@ import fs from "node:fs";
20
21
  import path from "node:path";
21
22
  import process from "node:process";
22
23
  import { resolveLayout } from "../layout";
23
- import { writeIndexFile } from "../publish";
24
24
  import { signCommand } from "../sign";
25
25
  import { callApi, login, pollTask, tenantHeaders, unwrapEnvelope } from "../site";
26
26
  import { bootstrapIsolatedSite, derivePublisherKey } from "./bootstrap";
27
- import { buildIndex } from "./build-index";
27
+ import { nullReleaseSig, removeMarketRelease, seedMarketRelease } from "./db-seed";
28
28
  import { FAILURE_PLAYBOOK } from "./failure-playbook";
29
29
  import { buildEvilZipSlipTgz, parseFixtureSpec } from "./fixture";
30
30
  import { publishPackage } from "./publish";
@@ -84,7 +84,7 @@ function installSucceeded(task: TaskRecord): boolean {
84
84
  /**
85
85
  * neg-* 专用版本:与主链路(0.1.0/0.2.0)完全隔离,收尾整包摘除。
86
86
  * 必须低于主链路 latest(0.2.0),否则发布后它会成为 latest,摘除时 REG-CS2 单调性守卫
87
- * 会把它判成「latest 回退」而拒建索引,导致负向用例的安装拒绝断言没机会跑(9.9.9 曾踩此坑)。
87
+ * 会让它成为 latest 干扰主链路,导致负向用例的安装拒绝断言没机会跑(9.9.9 曾踩此坑)。
88
88
  */
89
89
  const NEG_VERSION = "0.0.9";
90
90
  /** neg-* 独立方案名:与主链路 demo-solution 隔离,避免共享迁移账本(OJ-9 降级守卫会误拦 neg 包的低 seq 迁移)与 latest 守卫串扰 */
@@ -93,12 +93,13 @@ const NEG_NAME = "neg-solution";
93
93
  const OPS: Op[] = [
94
94
  {
95
95
  key: "index",
96
- title: "市场索引出卡片",
96
+ title: "市场目录出卡片",
97
97
  run: async (ctx) => {
98
- const rows = await callApi<Array<Record<string, unknown>>>(fetch, ctx.base, ctx.token, "GET", "/api/_platform/market/index");
99
- const hit = (rows ?? []).find(r => r.name === ctx.name);
98
+ const page = await callApi<{ list?: Array<Record<string, unknown>> }>(fetch, ctx.base, ctx.token, "GET", "/api/oj-market/index");
99
+ const rows = page?.list ?? [];
100
+ const hit = rows.find(r => r.name === ctx.name);
100
101
  return hit === undefined
101
- ? { ok: false, detail: `索引里没有 ${ctx.name}(拿到 ${(rows ?? []).length} 张卡)` }
102
+ ? { ok: false, detail: `目录里没有 ${ctx.name}(拿到 ${rows.length} 张卡)` }
102
103
  : { ok: true, detail: `${ctx.name} 在列,latest=${String(hit.latest)},available=${JSON.stringify(hit.available ?? [])}` };
103
104
  },
104
105
  },
@@ -126,10 +127,10 @@ const OPS: Op[] = [
126
127
  title: "禁用 / 启用",
127
128
  run: async (ctx) => {
128
129
  await ensureInstalled(ctx);
129
- const off = await callApi<{ ok?: boolean, message?: string }>(fetch, ctx.base, ctx.token, "POST", "/api/_platform/market/enable", { name: ctx.name, enabled: false });
130
+ const off = await callApi<{ ok?: boolean, message?: string }>(fetch, ctx.base, ctx.token, "POST", "/api/oj-market/enable", { name: ctx.name, enabled: false });
130
131
  if (off.ok !== true)
131
132
  return { ok: false, detail: `禁用被拒:${off.message ?? "?"}` };
132
- const on = await callApi<{ ok?: boolean, message?: string }>(fetch, ctx.base, ctx.token, "POST", "/api/_platform/market/enable", { name: ctx.name, enabled: true });
133
+ const on = await callApi<{ ok?: boolean, message?: string }>(fetch, ctx.base, ctx.token, "POST", "/api/oj-market/enable", { name: ctx.name, enabled: true });
133
134
  return on.ok === true
134
135
  ? { ok: true, detail: "disable → enable 双向成功" }
135
136
  : { ok: false, detail: `启用被拒:${on.message ?? "?"}` };
@@ -158,7 +159,7 @@ const OPS: Op[] = [
158
159
  key: "history",
159
160
  title: "操作历史可读",
160
161
  run: async (ctx) => {
161
- const rows = await callApi<unknown[]>(fetch, ctx.base, ctx.token, "GET", "/api/_platform/market/history");
162
+ const rows = await callApi<unknown[]>(fetch, ctx.base, ctx.token, "GET", "/api/oj-market/history");
162
163
  const n = Array.isArray(rows) ? rows.length : 0;
163
164
  return n > 0 ? { ok: true, detail: `历史 ${n} 条` } : { ok: false, detail: "历史为空" };
164
165
  },
@@ -228,7 +229,7 @@ const OPS: Op[] = [
228
229
  if (installed === null)
229
230
  await ensureInstalled(ctx); // 未装过:先装 ctx.version
230
231
  const target = installed ?? ctx.version;
231
- const res = await callApi<{ ok?: boolean, message?: string }>(fetch, ctx.base, ctx.token, "POST", "/api/_platform/market/uninstall", { name: ctx.name });
232
+ const res = await callApi<{ ok?: boolean, message?: string }>(fetch, ctx.base, ctx.token, "POST", "/api/oj-market/uninstall", { name: ctx.name });
232
233
  if (res.ok !== true)
233
234
  return { ok: false, detail: `卸载被拒:${res.message ?? "?"}` };
234
235
  const dir = path.join(ctx.site.root, "api", "dist", `${ctx.name}-${target}`);
@@ -255,7 +256,7 @@ const OPS: Op[] = [
255
256
  return {
256
257
  ok,
257
258
  detail: ok
258
- ? "failed@verifying(sha256 与索引不符,C2-29 fail-closed),零落盘"
259
+ ? "failed@verifying(包体 sha256 与目录行不符,C2-29 fail-closed),零落盘"
259
260
  : `status=${task.status} stage=${task.stage} noDrop=${noDrop}:${task.message}`,
260
261
  };
261
262
  }
@@ -267,47 +268,18 @@ const OPS: Op[] = [
267
268
  },
268
269
  {
269
270
  key: "neg-nosig",
270
- title: "负向:缺包级签名(sig_missing)",
271
+ title: "负向:目录行无签名(sig_missing)",
271
272
  run: async (ctx) => {
272
273
  await publishVersion(ctx, NEG_VERSION, NEG_NAME);
273
- const tgz = path.join(ctx.site.cdnDir, `${NEG_NAME}-${NEG_VERSION}.tgz`);
274
- const sig = `${tgz}.sig`;
275
- const origSig = fs.readFileSync(sig);
276
274
  try {
277
- fs.rmSync(sig);
278
- await buildIndex({ projectRoot: ctx.site.root, id: ctx.site.publisher.keyId, privateKeyPem: ctx.site.publisher.privateKeyPem }); // Form B 缺 archive.signature
275
+ // 遗留目录行形态(未经回填):签名四列 NULL → 合成 Form B 缺 archive.signature
276
+ nullReleaseSig(ctx.site.dbPath, NEG_NAME, NEG_VERSION);
279
277
  const task = await installOnce(ctx, NEG_VERSION, NEG_NAME);
280
278
  const reason = task.error?.reason ?? task.message;
281
279
  const ok = task.status === "failed" && reason.includes("sig_missing");
282
280
  return { ok, detail: ok ? "failed,失败码 sig_missing(G4 硬 fail-closed)" : `status=${task.status}:${reason}` };
283
281
  }
284
282
  finally {
285
- fs.writeFileSync(sig, origSig); // 回抄原始字节
286
- await removeVersion(ctx, NEG_VERSION, NEG_NAME);
287
- }
288
- },
289
- },
290
- {
291
- key: "neg-badindex",
292
- title: "负向:索引篡改(验签拒载)",
293
- run: async (ctx) => {
294
- await publishVersion(ctx, NEG_VERSION, NEG_NAME);
295
- const catalog = path.join(ctx.site.cdnDir, "catalog.yaml");
296
- const orig = fs.readFileSync(catalog);
297
- try {
298
- fs.appendFileSync(catalog, "# tampered\n"); // 索引字节变、签名失配
299
- let msg = "";
300
- try {
301
- await installOnce(ctx, NEG_VERSION, NEG_NAME);
302
- }
303
- catch (error) {
304
- msg = (error as Error).message; // 预期:安装 POST 即 502,任务都未创建
305
- }
306
- const ok = msg.includes("签名校验失败");
307
- return { ok, detail: ok ? "安装 POST 即 502 拒载(G4 fail-closed),任务未创建" : `未按预期拒装:${msg === "" ? "安装竟成功" : msg}` };
308
- }
309
- finally {
310
- writeIndexFile(catalog, orig.toString("utf-8")); // 回抄原始字节(也走唯一写入口,不留下直写索引的路径)
311
283
  await removeVersion(ctx, NEG_VERSION, NEG_NAME);
312
284
  }
313
285
  },
@@ -325,16 +297,16 @@ const OPS: Op[] = [
325
297
  const before = await trustKeys(ctx); // 信任库快照(F3 diff 基线)
326
298
  try {
327
299
  signCommand(tgz, { id: "stranger.publisher", privateKeyPem: stranger.privateKeyPem, registryId: "trial.registry" }); // 覆盖侧车签名(REG-CS3 v2 规范串首段需 registry 身份)
328
- await buildIndex({ projectRoot: ctx.site.root, id: "stranger.publisher", privateKeyPem: stranger.privateKeyPem });
300
+ seedMarketRelease(ctx.site.dbPath, { tgzPath: tgz }); // 重 seed:目录行换陌生 key(upsert 覆盖)
329
301
  const first = await installOnce(ctx, NEG_VERSION, NEG_NAME);
330
302
  if (first.status !== "needs_trust")
331
303
  return { ok: false, detail: `未停 needs_trust:status=${first.status}:${first.message}` };
332
- await callApi(fetch, ctx.base, ctx.token, "POST", "/api/_platform/market/trust", { task_id: first.id });
304
+ await callApi(fetch, ctx.base, ctx.token, "POST", "/api/oj-market/trust", { task_id: first.id });
333
305
  const second = await installOnce(ctx, NEG_VERSION, NEG_NAME);
334
306
  if (!installSucceeded(second))
335
307
  return { ok: false, detail: `确认后重装未 done:status=${second.status}:${second.message}` };
336
308
  // 收尾(F3):显式 revoke 陌生 key,快照 diff 断言无残留——不靠 uninstall 兜底(它本就不摘信任)
337
- const del = await callApi<{ revoked?: boolean }>(fetch, ctx.base, ctx.token, "DELETE", `/api/_platform/market/trust/key?key_id=${encodeURIComponent("stranger.publisher")}`);
309
+ const del = await callApi<{ revoked?: boolean }>(fetch, ctx.base, ctx.token, "DELETE", `/api/oj-market/trust/key?key_id=${encodeURIComponent("stranger.publisher")}`);
338
310
  const after = await trustKeys(ctx);
339
311
  const revoked = del.revoked === true;
340
312
  const noResidue = JSON.stringify(after) === JSON.stringify(before);
@@ -349,6 +321,9 @@ const OPS: Op[] = [
349
321
  finally {
350
322
  fs.writeFileSync(tgz, orig); // 回抄原始字节,不重签
351
323
  fs.writeFileSync(sig, origSig);
324
+ // 目录行回滚为原发布者(摘除 + 重 seed 原字节),再走清场
325
+ removeMarketRelease(ctx.site.dbPath, NEG_NAME, NEG_VERSION);
326
+ seedMarketRelease(ctx.site.dbPath, { tgzPath: tgz });
352
327
  await removeVersion(ctx, NEG_VERSION, NEG_NAME);
353
328
  }
354
329
  },
@@ -358,7 +333,7 @@ const OPS: Op[] = [
358
333
  title: "负向:zip-slip 越界包(R1 拒收)",
359
334
  run: async (ctx) => {
360
335
  const evil = path.join(ctx.site.cdnDir, `${ctx.name}-evil.tgz`);
361
- buildEvilZipSlipTgz(evil); // 成员含 ../evil.sql,无需发布进索引(file 源直传)
336
+ buildEvilZipSlipTgz(evil); // 成员含 ../evil.sql,无需进目录(file 源直传)
362
337
  const distDir = resolveLayout(ctx.site.root).apiDist;
363
338
  const before = fs.readdirSync(distDir).sort(); // 零副作用基线:0.0.9 目录可能是前序 neg-stranger 的合法产物
364
339
  try {
@@ -435,7 +410,7 @@ export async function walkthrough(opts: WalkthroughOptions = {}): Promise<Walkth
435
410
  async function install(ctx: Ctx, version: string): Promise<TaskRecord> {
436
411
  let task = await installOnce(ctx, version);
437
412
  if (task.status === "needs_trust") {
438
- await callApi(fetch, ctx.base, ctx.token, "POST", "/api/_platform/market/trust", { task_id: task.id });
413
+ await callApi(fetch, ctx.base, ctx.token, "POST", "/api/oj-market/trust", { task_id: task.id });
439
414
  task = await installOnce(ctx, version);
440
415
  }
441
416
  if (installSucceeded(task))
@@ -444,7 +419,7 @@ async function install(ctx: Ctx, version: string): Promise<TaskRecord> {
444
419
  }
445
420
 
446
421
  async function installOnce(ctx: Ctx, version: string, name: string = ctx.name): Promise<TaskRecord> {
447
- const started = await callApi<{ task_id?: string, id?: string }>(fetch, ctx.base, ctx.token, "POST", "/api/_platform/market/install", { name, version, source: "cdn" });
422
+ const started = await callApi<{ task_id?: string, id?: string }>(fetch, ctx.base, ctx.token, "POST", "/api/oj-market/install", { name, version, source: "cdn" });
448
423
  const taskId = String(started.task_id ?? started.id ?? "");
449
424
  if (taskId === "")
450
425
  throw new Error("[ojm] 安装接口未返回 task id");
@@ -458,17 +433,17 @@ async function installFile(ctx: Ctx, tgzPath: string, version: string): Promise<
458
433
  form.append("version", version);
459
434
  form.append("source", "file");
460
435
  form.append("file", new Blob([fs.readFileSync(tgzPath)], { type: "application/gzip" }), path.basename(tgzPath));
461
- const res = await fetch(`${ctx.base}/api/_platform/market/install`, {
436
+ const res = await fetch(`${ctx.base}/api/oj-market/install`, {
462
437
  method: "POST",
463
438
  headers: { authorization: `Bearer ${ctx.token}`, ...tenantHeaders() },
464
439
  body: form,
465
440
  });
466
- await unwrapEnvelope(res); // 非 0 code 抛人话(400 zip-slip / 502 索引等)
441
+ await unwrapEnvelope(res); // 非 0 code 抛人话(400 zip-slip 等)
467
442
  }
468
443
 
469
444
  /** 信任库 key_id 快照(GET /market/trust;neg-stranger 的 diff 断言用) */
470
445
  async function trustKeys(ctx: Ctx): Promise<string[]> {
471
- const res = await callApi<{ keys?: Array<{ key_id?: string }> }>(fetch, ctx.base, ctx.token, "GET", "/api/_platform/market/trust");
446
+ const res = await callApi<{ keys?: Array<{ key_id?: string }> }>(fetch, ctx.base, ctx.token, "GET", "/api/oj-market/trust");
472
447
  return (res.keys ?? []).map(k => k.key_id ?? "").filter(id => id !== "").sort();
473
448
  }
474
449
 
@@ -482,23 +457,23 @@ async function ensureInstalled(ctx: Ctx): Promise<void> {
482
457
  throw new Error(`前置安装失败:status=${task.status}:${task.message}`);
483
458
  }
484
459
 
485
- /** 索引卡片(本方案一行;upgrade/rollback/uninstall 与 index op 同一消费面) */
460
+ /** 目录卡片(本方案一行;upgrade/rollback/uninstall 与 index op 同一消费面) */
486
461
  async function indexCard(ctx: Ctx): Promise<Record<string, unknown> | undefined> {
487
- const rows = await callApi<Array<Record<string, unknown>>>(fetch, ctx.base, ctx.token, "GET", "/api/_platform/market/index");
488
- return (rows ?? []).find(r => r.name === ctx.name);
462
+ const page = await callApi<{ list?: Array<Record<string, unknown>> }>(fetch, ctx.base, ctx.token, "GET", "/api/oj-market/index");
463
+ return (page?.list ?? []).find(r => r.name === ctx.name);
489
464
  }
490
465
 
491
- /** 发布某版本 fixture 进隔离 CDN 并重铺索引(upgrade/rollback/neg-* 的共用前置) */
466
+ /** 发布某版本 fixture 进隔离站点(落字节 + seed 目录行——upgrade/rollback/neg-* 共用前置) */
492
467
  async function publishVersion(ctx: Ctx, version: string, name: string = ctx.name): Promise<void> {
493
- await publishPackage({ projectRoot: ctx.site.root, fixture: `${name}@${version}`, id: ctx.site.publisher.keyId, privateKeyPem: ctx.site.publisher.privateKeyPem, registryId: "trial.registry" });
494
- await buildIndex({ projectRoot: ctx.site.root, id: ctx.site.publisher.keyId, privateKeyPem: ctx.site.publisher.privateKeyPem });
468
+ const published = await publishPackage({ projectRoot: ctx.site.root, fixture: `${name}@${version}`, id: ctx.site.publisher.keyId, privateKeyPem: ctx.site.publisher.privateKeyPem, registryId: "trial.registry" });
469
+ seedMarketRelease(ctx.site.dbPath, { tgzPath: published.tgzPath });
495
470
  }
496
471
 
497
- /** neg op 收尾:整包摘除(tgz + 侧车签名)并重铺索引,恢复主链路原状 */
472
+ /** neg op 收尾:整包摘除(tgz + 侧车签名 + 目录行),恢复主链路原状 */
498
473
  async function removeVersion(ctx: Ctx, version: string, name: string = ctx.name): Promise<void> {
499
474
  for (const file of [`${name}-${version}.tgz`, `${name}-${version}.tgz.sig`])
500
475
  fs.rmSync(path.join(ctx.site.cdnDir, file), { force: true });
501
- await buildIndex({ projectRoot: ctx.site.root, id: ctx.site.publisher.keyId, privateKeyPem: ctx.site.publisher.privateKeyPem });
476
+ removeMarketRelease(ctx.site.dbPath, name, version);
502
477
  }
503
478
 
504
479
  export const WALKTHROUGH_OPS = OPS.map(op => op.key);