@oj-module/cli 0.2.30 → 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 (115) 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/runtime.js +2 -2
  5. package/shell-dist/index.html +2 -2
  6. package/shell-dist/versions.json +3 -3
  7. package/src/__tests__/cli.test.ts +23 -14
  8. package/src/__tests__/list.test.ts +176 -0
  9. package/src/__tests__/registry-cli.test.ts +522 -0
  10. package/src/__tests__/registry-export.test.ts +319 -0
  11. package/src/__tests__/registry-import.test.ts +336 -0
  12. package/src/__tests__/registry-sign.test.ts +352 -0
  13. package/src/cli-output.ts +81 -0
  14. package/src/cli.ts +298 -39
  15. package/src/config-cmd.ts +100 -0
  16. package/src/contract/__tests__/handler-scan.test.ts +74 -2
  17. package/src/contract/handler-scan.ts +27 -2
  18. package/src/enable.ts +1 -1
  19. package/src/exit-codes.ts +33 -0
  20. package/src/index.ts +5 -9
  21. package/src/install.ts +120 -38
  22. package/src/lifecycle/__tests__/authoring.test.ts +6 -5
  23. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.bad.sig +3 -2
  24. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.sig +3 -2
  25. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.unknown.sig +3 -2
  26. package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.d.mts +5 -4
  27. package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.mjs +9 -6
  28. package/src/lifecycle/__tests__/helpers.ts +10 -6
  29. package/src/lifecycle/__tests__/install.test.ts +72 -15
  30. package/src/lifecycle/__tests__/manifest-contract.test.ts +40 -1
  31. package/src/lifecycle/__tests__/publish.test.ts +174 -33
  32. package/src/lifecycle/__tests__/sig-fixtures.test.ts +9 -6
  33. package/src/lifecycle/__tests__/sign.test.ts +9 -8
  34. package/src/lifecycle/__tests__/smoke.test.ts +1 -1
  35. package/src/lifecycle/__tests__/uninstall.test.ts +4 -4
  36. package/src/lifecycle/sign.ts +8 -7
  37. package/src/list.ts +146 -0
  38. package/src/manifest-contract.ts +11 -5
  39. package/src/market/__tests__/bootstrap.test.ts +4 -1
  40. package/src/market/__tests__/build-index.test.ts +529 -42
  41. package/src/market/__tests__/fixture.test.ts +17 -1
  42. package/src/market/__tests__/publish.test.ts +8 -7
  43. package/src/market/__tests__/setup.test.ts +4 -4
  44. package/src/market/__tests__/sharding.test.ts +90 -0
  45. package/src/market/__tests__/verify.test.ts +205 -12
  46. package/src/market/__tests__/yank-ledger.test.ts +60 -0
  47. package/src/market/bootstrap.ts +8 -3
  48. package/src/market/build-index.ts +407 -40
  49. package/src/market/cdn-layout.ts +3 -2
  50. package/src/market/command.ts +40 -6
  51. package/src/market/failure-playbook.ts +1 -1
  52. package/src/market/fixture.ts +20 -3
  53. package/src/market/index-builder.ts +120 -47
  54. package/src/market/publish.ts +3 -1
  55. package/src/market/setup.ts +3 -3
  56. package/src/market/sharding.ts +94 -0
  57. package/src/market/trial.ts +1 -1
  58. package/src/market/verify.ts +213 -50
  59. package/src/market/walkthrough.ts +7 -6
  60. package/src/market/yank-ledger.ts +91 -0
  61. package/src/publish.ts +250 -38
  62. package/src/registry-export.ts +405 -0
  63. package/src/registry-import.ts +410 -0
  64. package/src/registry-sidecar.ts +84 -0
  65. package/src/registry-sign.ts +322 -0
  66. package/src/registry-source.ts +192 -0
  67. package/src/sign.ts +13 -10
  68. package/src/site.ts +4 -4
  69. package/src/trust.ts +1 -1
  70. package/src/uninstall.ts +14 -2
  71. package/src/usage.ts +130 -25
  72. package/src/view.ts +94 -0
  73. package/templates/cdn/README.md +35 -5
  74. package/templates/config.yaml +10 -0
  75. package/templates/init.include.yaml +1 -1
  76. package/templates/src/_platform/api/_lifecycle/atomic.ts +3 -0
  77. package/templates/src/_platform/api/_lifecycle/installer.ts +44 -7
  78. package/templates/src/_platform/api/_lifecycle/limits.ts +21 -0
  79. package/templates/src/_platform/api/_lifecycle/source-seq.ts +88 -0
  80. package/templates/src/_platform/api/_lifecycle/steps/download.ts +15 -0
  81. package/templates/src/_platform/api/_lifecycle/steps/extract.ts +61 -2
  82. package/templates/src/_platform/api/_lifecycle/steps/preflight.ts +55 -6
  83. package/templates/src/_platform/api/_lifecycle/steps/register.ts +2 -1
  84. package/templates/src/_platform/api/_lifecycle/steps/verify.ts +22 -8
  85. package/templates/src/_platform/api/_lifecycle/tgz-guard.ts +96 -0
  86. package/templates/src/_platform/api/_lifecycle/types.ts +6 -3
  87. package/templates/src/_platform/api/_lifecycle/verify-checks.ts +8 -0
  88. package/templates/src/_platform/api/_shared/service/domain/solution-perms.ts +9 -1
  89. package/templates/src/_platform/api/_shared/site-facade.ts +62 -11
  90. package/templates/src/_platform/api/manifest.yaml +1 -0
  91. package/templates/src/_platform/api/market/api.ts +362 -99
  92. package/templates/src/_platform/api/market/index-source.ts +112 -0
  93. package/templates/src/_platform/api/migrations/0014__reg_source_seq.mysql.sql +9 -0
  94. package/templates/src/_platform/api/migrations/0014__reg_source_seq.pg.sql +7 -0
  95. package/templates/src/_platform/api/migrations/0014__reg_source_seq.sql +8 -0
  96. package/templates/src/_platform/api/schema.yaml +8 -0
  97. package/templates/src/_platform/api/solutions/api.ts +8 -3
  98. package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +13 -0
  99. package/templates/src/oj-market/web/__tests__/InstallDialog.test.tsx +38 -0
  100. package/templates/src/oj-market/web/components/CardItem.tsx +10 -0
  101. package/templates/src/oj-market/web/components/InstallDialog.tsx +18 -0
  102. package/templates/src/oj-market/web/locales/en-US.json +1 -0
  103. package/templates/src/oj-market/web/locales/zh-CN.json +1 -0
  104. package/templates/src/oj-market/web/types.ts +3 -1
  105. package/templates/tests/lifecycle/installer.spec.ts +24 -0
  106. package/templates/tests/lifecycle/market-api.spec.ts +641 -85
  107. package/templates/tests/lifecycle/preflight.spec.ts +52 -0
  108. package/templates/tests/lifecycle/sig-fixtures.spec.ts +7 -4
  109. package/templates/tests/lifecycle/source-seq.spec.ts +55 -0
  110. package/templates/tests/lifecycle/steps-fs.spec.ts +166 -7
  111. package/templates/tests/lifecycle/tgz-guard.spec.ts +133 -1
  112. package/templates/tests/lifecycle/verify-checks.spec.ts +27 -13
  113. package/templates/tests/lifecycle/verify-report.spec.ts +3 -1
  114. package/templates/tests/rbac-solution-perms.spec.ts +41 -0
  115. package/vendor/host-versions.json +3 -3
@@ -1,3 +1,4 @@
1
+ import type { SourceSeqVerdict } from "../_lifecycle/source-seq";
1
2
  // 编排端点 + 市场索引(P1-12,R9/ISP):/api/_platform/market/* 端点族同文件嵌套 handler 组
2
3
  // (oj v0.1.64 组形态,notification/api.ts 同款;组名 = 路径段,路由不变;嵌套组最深 4 层,oj bridge/mod.rs walker)。
3
4
  // 门(R9):install/task/index 全 assertSuperAdmin 独占(市场页是「市场即样例」的第一方特权
@@ -5,7 +6,9 @@
5
6
  // 职责边界(ISP):端点只做 门禁 + Form B 索引解析(安装器不拼 URL,installer.ts 同纪律)+
6
7
  // InstallRequest 组装;任务生命周期/失败三分/终态审计全归 runInstall 状态机(P1-10),端点不重复编排。
7
8
  // CDN 基址:sys_setting `framework.market_cdn_url`(PRD §5.3,缺省空 = 市场不可用,可读错误不崩);
8
- // 索引/清单拉取恒带 Cache-Control: no-cache(索引时效性)。
9
+ // 索引拉取走 IndexSource DIP(REG-CS5 三级化:catalog.yaml + shards/<n>.yaml)——读面带
10
+ // ETag/If-None-Match 条件请求(304 不重传),不再发 cache-control: no-cache(Q8:新鲜度由
11
+ // 服务端 max-age=60, stale-while-revalidate=60 + 索引自身 expires_at 共同承载)。
9
12
  // 站点路径缺省(marketEnv):oj serve release 布局生产口径(P1-16 接线 B3/B4)——共享根 = serve
10
13
  // 挂载根 dist(锁 dist/manifests.yaml、版本目录 dist/<name>-<ver>/、versions.json 同根),
11
14
  // 暂存/任务/sidecar 留 dist 外(R13 不与装配树冲突);全字段可注入覆盖(测试 stub 同款)。
@@ -16,10 +19,12 @@ import type { FetchFace } from "../_lifecycle/steps/download";
16
19
  import type { ExecFace } from "../_lifecycle/steps/extract";
17
20
  import type { ReloadFace } from "../_lifecycle/steps/reload";
18
21
  import type { FetchInitFace } from "../_shared/site-facade";
22
+ import type { IndexKind, IndexSource } from "./index-source";
19
23
  import { atomicWriteText, fsFace } from "../_lifecycle/atomic";
20
24
  import { latestAppliedSeqOf, reconcile, runInstall, setEnabled } from "../_lifecycle/installer";
21
25
  import { compareVersions } from "../_lifecycle/semver";
22
26
  import { sha384B64 } from "../_lifecycle/sha384";
27
+ import { guardAndRecordSourceSeq } from "../_lifecycle/source-seq";
23
28
  import { createStateStore } from "../_lifecycle/state";
24
29
  import { flattenBundleRoot } from "../_lifecycle/steps/extract";
25
30
  import { getTask, readTasks } from "../_lifecycle/tasks";
@@ -28,8 +33,9 @@ import { createTrustStore } from "../_lifecycle/trust";
28
33
  import { computeUninstallImpact, runUninstall } from "../_lifecycle/uninstall";
29
34
  import { respond } from "../_shared/respond";
30
35
  import { assertSuperAdmin } from "../_shared/service/infra/access";
31
- import { journalHeadOf, siteOrigin, withSiteOrigin } from "../_shared/site-facade";
36
+ import { journalHeadOf, readCanonicalOrigin, siteOrigin, withSiteOrigin } from "../_shared/site-facade";
32
37
  import { BOOTSTRAP_KEY, seedBootstrapKey } from "./bootstrap";
38
+ import { httpIndexSource, indexPathOf } from "./index-source";
33
39
 
34
40
  // 站点编排面共享真源落 _shared/site-facade(P2-16 真机构建钉出:api.ts 是路由入口不可被模块内
35
41
  // import);此处 re-export 保持既有引用面(market-api.spec / 历史 import 路径)不变。
@@ -60,8 +66,15 @@ export interface MarketEnv {
60
66
  apiPrefix: string
61
67
  /** 站点 config.yaml 路径(reloading 段 vars.ojm_install_seq 自增落盘处;缺省 config.yaml) */
62
68
  configPath?: string
69
+ /** canonical origin(REG-CS6:相对 URL 补全用,取自 config.yaml vars.canonical_origin,绝不读请求头 Host) */
70
+ canonicalOrigin?: string
63
71
  /** CDN 基址(缺省读 sys_setting `framework.market_cdn_url`;测试注入) */
64
72
  cdnBase?: () => Promise<string | null>
73
+ /**
74
+ * 索引读取源注入(DIP,REG-CS5③):缺省按 CDN 基址 + fetchOf 构造 HTTP 源
75
+ * (ETag/If-None-Match 条件请求);本地目录索引 / 测试夹具可直注(localIndexSource)。
76
+ */
77
+ indexSource?: IndexSource
65
78
  /** 注入面(DIP):缺省回落 isolate 全局 fetch / exec.run / server.reloadConfig / 本地 sha384(过渡实现) */
66
79
  fetch?: FetchFace
67
80
  exec?: ExecFace
@@ -77,12 +90,16 @@ export interface MarketEnv {
77
90
  ledgerHead?: number | bigint | null
78
91
  /** 迁移账本 head 真源(P1-16 接线①:缺省读 oj journal 表 `_oj_migrations` max(version),查询失败 → null 不猜) */
79
92
  ledgerHeadOf?: (name: string) => Promise<number | bigint | null>
93
+ /** 当前时刻(epoch 秒;REG-CS2 索引时效判定基准,测试注入;缺省 Date.now) */
94
+ now?: () => number
80
95
  /**
81
96
  * 宿主 versions.json(C2-09 peerRuntime 比对真源,键 = 包名)。缺省读 `<sharedRoot>/versions.json`
82
97
  * (ojm 宿主构建产物:与 manifests.yaml 同层,即 release 根,B-11 接线);不可得 → undefined,
83
98
  * manifest 声明 peerRuntime 即预检 fail-closed 拒装(C2-09 语义保持)。
84
99
  */
85
100
  hostVersions?: Record<string, string>
101
+ /** 本站点(registry)身份(REG-CS3 v2 防跨仓库重放):安装验签时与 .sig.registry_id 比对 */
102
+ registryId?: string
86
103
  }
87
104
 
88
105
  /**
@@ -149,6 +166,7 @@ export function marketEnv(): MarketEnv {
149
166
  siteModulesDir: "dist/web/modules",
150
167
  modulesJsonPath: "dist/web/modules.json",
151
168
  apiPrefix: "/api",
169
+ configPath: "config.yaml",
152
170
  ledgerHeadOf: journalHeadOf,
153
171
  };
154
172
  }
@@ -162,10 +180,53 @@ function fail(status: number, msg: string): never {
162
180
  throw Object.assign(new Error(msg), { status });
163
181
  }
164
182
 
165
- function fetchOf(env: MarketEnv): FetchInitFace {
166
- // 相对基址(feature:/cdn = 本站点自供)按请求源补全同源绝对 URL;绝对 http(s) 直通。
167
- // 无请求上下文(无 Host)不猜原点、原样透传;runInstall 侧的 withSiteOrigin 包裹幂等。
168
- return withSiteOrigin(env.fetch ?? (globalThis as { fetch?: FetchInitFace }).fetch as FetchInitFace);
183
+ /**
184
+ * canonical origin 惰性解析(REG-CS6):env.canonicalOrigin 若已由测试/部署注入则直接用,
185
+ * 否则读 config.yaml `vars.canonical_origin`(异步,隔离环境只有异步读盘面)。解析失败/缺省 → undefined
186
+ * (withSiteOrigin = 不补全、相对 URL 原样透传,fail-closed 不回落请求头)。
187
+ */
188
+ async function resolveCanonicalOrigin(env: MarketEnv): Promise<string | undefined> {
189
+ if (env.canonicalOrigin !== undefined) {
190
+ return env.canonicalOrigin;
191
+ }
192
+ if (!env.configPath) {
193
+ return undefined;
194
+ }
195
+ return (await readCanonicalOrigin(env.configPath)) ?? undefined;
196
+ }
197
+
198
+ async function fetchOf(env: MarketEnv): Promise<FetchInitFace> {
199
+ // 相对基址(feature:/cdn = 本站点自供)按配置的 canonical origin 补全同源绝对 URL;绝对 http(s) 直通。
200
+ // REG-CS6:绝不读请求头 Host/x-forwarded-proto——伪造 Host 不能牵引出站。无 canonical origin 则不补全
201
+ // (相对 URL 原样透传,由下层 fetch 判不可用),fail-closed 而非回落请求头。
202
+ const co = await resolveCanonicalOrigin(env);
203
+ return withSiteOrigin(env.fetch ?? (globalThis as { fetch?: FetchInitFace }).fetch as FetchInitFace, co);
204
+ }
205
+
206
+ /**
207
+ * REG-CS6:安装需 canonical origin——① 相对市场基址(`market_cdn_url=/cdn` 本站点自供)要补全为
208
+ * 同源绝对 URL;② 冒烟探针 URL 恒为站点相对路径(`/api/<模块>/<路由>`,isolate fetch 无同源 base,
209
+ * 相对恒 TypeError)。缺失/非法即明确报错并指向具体设置项(config.yaml `vars.canonical_origin`),
210
+ * 绝不回落请求头 Host(伪造 Host 会牵引出站)。
211
+ */
212
+ async function requireCanonicalOrigin(env: MarketEnv): Promise<string> {
213
+ const co = await resolveCanonicalOrigin(env);
214
+ if (co === undefined || siteOrigin(co) === null) {
215
+ fail(503, "安装需要站点 canonical origin(相对市场基址「/cdn」自供需补全为同源绝对 URL,且冒烟探针 URL 恒为站点相对路径):请在 config.yaml 配置 vars.canonical_origin(站点真实对外地址,如 https://your-site.example.com)。REG-CS6 不读请求头 Host(防伪造 Host 牵引出站),缺失即拒而非回落请求头");
216
+ }
217
+ return co;
218
+ }
219
+
220
+ /**
221
+ * REG-CS6:相对市场基址(`market_cdn_url=/cdn` 本站点自供)**必须**有 canonical origin 才能补全为
222
+ * 同源绝对 URL——绝对基址(外部真 CDN)不需要。缺失/非法即明确报错并指向具体设置项
223
+ * (config.yaml `vars.canonical_origin`),绝不回落请求头 Host(伪造 Host 会牵引出站)。
224
+ */
225
+ async function assertOriginForRelativeBase(env: MarketEnv, base: string): Promise<void> {
226
+ if (/^https?:\/\//i.test(base)) {
227
+ return;
228
+ }
229
+ await requireCanonicalOrigin(env);
169
230
  }
170
231
 
171
232
  function execOf(env: MarketEnv): ExecFace {
@@ -204,21 +265,13 @@ async function cdnBaseOf(env: MarketEnv): Promise<string | null> {
204
265
  }
205
266
  }
206
267
 
207
- /** CDN 文本拉取(bundles.yaml/manifests.yaml):恒带 Cache-Control: no-cache(索引时效性) */
208
- async function fetchText(fetch: FetchInitFace, url: string): Promise<string> {
209
- const res = await fetch(url, { headers: { "cache-control": "no-cache" } });
210
- if (!res.ok)
211
- fail(502, `索引拉取失败:${url} 返回 HTTP ${res.status}(请检查市场地址与网络)`);
212
- return new TextDecoder().decode(await res.arrayBuffer());
213
- }
214
-
215
268
  /** 索引签名算法标识(与 ojm publish 同源:Ed25519;C2-35) */
216
269
  const INDEX_SIG_ALG = "Ed25519";
217
270
 
218
- /** 索引规范串(P3-7,与 ojm publish indexCanonical 逐字一致):`index:<kind>:<sha256hex>` */
219
- function indexCanonical(env: MarketEnv, kind: "bundles" | "manifests", text: string): string {
271
+ /** 索引规范串(REG-CS1,与 ojm publish indexCanonical 逐字一致):`index:<kind>:<seq>:<sha256hex>` */
272
+ function indexCanonical(env: MarketEnv, kind: IndexKind, seq: number, text: string): string {
220
273
  const sha256 = sha256Of(env)(new TextEncoder().encode(text));
221
- return `index:${kind}:${sha256}`;
274
+ return `index:${kind}:${seq}:${sha256}`;
222
275
  }
223
276
 
224
277
  /** Ed25519 验签面(P2-3;env 注入优先,缺省回落 isolate 全局 crypto.verifyEd25519) */
@@ -227,46 +280,217 @@ function verifyEd25519Of(env: MarketEnv): (publicKey: string, msg: string, sigBa
227
280
  return env.verifyEd25519 ?? ((pk, msg, sig) => g.crypto!.verifyEd25519!(pk, msg, sig));
228
281
  }
229
282
 
283
+ /** 验签判定:ok + 代次(读方据此做同代校验;seq 为 null = 旧格式签名,一律拒) */
284
+ interface IndexSigVerdict {
285
+ ok: boolean
286
+ seq: number | null
287
+ }
288
+
230
289
  /**
231
- * 索引签名校验(P3-7,G4 fail-closed 口径):解析 detached .sig 五字段 → 结构校验 →
232
- * 重算规范串 → verifyEd25519。返回 true 仅当签名完整且匹配索引字节;任一结构/验签失败 → false。
290
+ * 索引签名校验(REG-CS1,G4 fail-closed 口径):解析 detached .sig 六字段 → 结构校验 →
291
+ * 重算规范串(含 seq)→ verifyEd25519。返回 ok 仅当签名完整、覆盖代次且匹配索引字节。
233
292
  * 注:发布方信任(key 是否在信任库/bootstrap)由安装期 TOFU/信任库承载,此处仅做完整性(防 CDN 索引篡改)。
234
293
  */
235
- function verifyIndexSig(env: MarketEnv, kind: "bundles" | "manifests", text: string, sigText: string): boolean {
294
+ function verifyIndexSig(env: MarketEnv, kind: IndexKind, text: string, sigText: string): IndexSigVerdict {
236
295
  let sig: unknown;
237
296
  try {
238
297
  sig = JSON.parse(sigText);
239
298
  }
240
299
  catch {
241
- return false;
300
+ return { ok: false, seq: null };
242
301
  }
243
- if (!isObj(sig) || sig.alg !== INDEX_SIG_ALG || typeof sig.value !== "string" || typeof sig.public_key !== "string")
244
- return false;
245
- const canonical = indexCanonical(env, kind, text);
246
- return verifyEd25519Of(env)(sig.public_key, canonical, sig.value);
302
+ if (!isObj(sig))
303
+ return { ok: false, seq: null };
304
+ const { alg, value, public_key: publicKey, seq } = sig as { alg?: unknown, value?: unknown, public_key?: unknown, seq?: unknown };
305
+ if (alg !== INDEX_SIG_ALG || typeof value !== "string" || typeof publicKey !== "string")
306
+ return { ok: false, seq: null };
307
+ // 签名未覆盖代次 = 旧格式(无法防「新总目录 + 旧分册」),拒
308
+ if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 1)
309
+ return { ok: false, seq: null };
310
+ return { ok: verifyEd25519Of(env)(publicKey, indexCanonical(env, kind, seq, text), value), seq };
247
311
  }
248
312
 
313
+ /** 索引正文的代次(顶层 `seq`;纯内容形态(分片)无顶层键 → null,代次只由 .sig 承载) */
314
+ async function indexSeqOfText(text: string): Promise<number | null> {
315
+ const g = globalThis as { yaml?: { parse: (t: string) => Promise<unknown> } };
316
+ try {
317
+ const doc = await g.yaml!.parse(text) as unknown;
318
+ if (doc === null || typeof doc !== "object" || Array.isArray(doc))
319
+ return null;
320
+ const seq = (doc as Record<string, unknown>).seq;
321
+ return typeof seq === "number" && Number.isInteger(seq) && seq >= 1 ? seq : null;
322
+ }
323
+ catch {
324
+ return null;
325
+ }
326
+ }
327
+
328
+ /** 缺签名/空签名的人话(两步可执行下一步,与 CLI `ojm market verify` 同一文案口径) */
329
+ const SIG_MISSING_NEXT = "完整性无保障,已拒载。下一步:① 让源执行 ojm market build-index --id <publisher.id> 重建并签名;② 或对既有索引补签 ojm publish <目录> --id <publisher.id>";
330
+
249
331
  /**
250
- * 拉取索引并校验签名(P3-7):索引字节先取;若同目录存在非空 `<kind>.yaml.sig` 则校验,
251
- * 篡改/不符/格式破损 → fail-closed 拒载(502,G4);`.sig` 缺失或空(滚动发布期)暂容忍
252
- * (备案:缺失即拒的硬开关留后续 rollout)。
253
- * 关键:验签失败须抛出 try 之外——fail-closed 不能被「缺失容忍」的 catch 吞掉。
332
+ * 拉取索引并校验签名(REG-CS1,G4 fail-closed)——三级化后(catalog + shards):
333
+ * ① `.sig` 缺失 / 空 / 拉不到 → **拒载**(502)——不再「滚动发布期容忍」:
334
+ * 容忍等于把索引完整性交给发布方自觉,谁改了索引都能装。
335
+ * ② 签名不符或未覆盖代次 → 拒载(index_sig_mismatch)。
336
+ * ③ 同代校验改「钉值」口径:catalog 正文 `shards: {<片号>: <代次>}` 钉每片的代次,
337
+ * 分片签名 seq 必须等于钉值(`expectSeq`)——不等即拒(index_generation_mismatch,
338
+ * 提示「源正在重建索引」)。byte 级相同的分片可跨代复用,故钉值而非恒等于 catalog 代次。
339
+ * ④ catalog 另过两道新鲜度闸(REG-CS2,见 `guardCatalogFreshness`):时效过期 `index_expired`、
340
+ * seq 回退 `index_stale`——回放与镜像落后在此处被挡住。
341
+ *
342
+ * `no` = 分片号(catalog 省略);`expectSeq` = catalog 对该片的钉值(取 catalog 本身时省略)。
343
+ * 关键:四类拒载都在 try 之外抛出——fail-closed 不能被任何 catch 吞掉。
254
344
  */
255
- async function fetchIndex(env: MarketEnv, fetch: FetchInitFace, base: string, kind: "bundles" | "manifests"): Promise<string> {
256
- const text = await fetchText(fetch, `${base}/${kind}.yaml`);
257
- let sigText: string | null = null;
345
+ async function fetchIndex(env: MarketEnv, source: IndexSource, base: string, kind: IndexKind, no?: number, expectSeq?: number): Promise<{ text: string, seq: number }> {
346
+ // 人话里的文件名与 CDN 布局逐字一致(indexPathOf 单点):shards/<n>.yaml,不是 shard/<n>.yaml
347
+ const label = indexPathOf(kind, no);
348
+ const file = await source.read(kind, no);
349
+ if (file === null)
350
+ fail(502, `市场索引 ${label} 拉取失败(请检查市场地址与网络)`);
351
+ const sigText = file.sig;
352
+ if (sigText === null || sigText.trim() === "")
353
+ fail(502, `市场索引 ${label} 缺签名文件 ${label}.sig(index_sig_missing)——${SIG_MISSING_NEXT}`);
354
+ const verdict = verifyIndexSig(env, kind, file.body, sigText);
355
+ if (!verdict.ok || verdict.seq === null)
356
+ fail(502, `市场索引 ${label} 签名校验失败(index_sig_mismatch:索引被篡改、签名不符或签名未覆盖代次,G4 fail-closed)——请重新发布并签名`);
357
+ const seq = verdict.seq;
358
+ // 正文自带代次时(catalog)必须与签名一致——防止「改了正文没换签名」的半个写入;
359
+ // 分片正文是纯内容(无顶层 seq),代次只由签名 + catalog 钉值携带,故不过此闸。
360
+ const textSeq = await indexSeqOfText(file.body);
361
+ if (textSeq !== null && textSeq !== seq)
362
+ fail(502, `市场索引 ${label} 正文是第 ${textSeq} 代、签名是第 ${seq} 代(index_generation_mismatch)——源正在重建索引,请稍后重试`);
363
+ if (expectSeq !== undefined && expectSeq !== seq)
364
+ fail(502, `市场索引 ${label} 签名是第 ${seq} 代,总目录钉的是第 ${expectSeq} 代(index_generation_mismatch)——源正在重建索引,请稍后重试`);
365
+ // ④ 新鲜度只在 catalog 上判一次:代次真源与时效盖章都在它身上,分片跟着走
366
+ if (kind === "catalog")
367
+ await guardCatalogFreshness(env, base, file.body, seq);
368
+ return { text: file.body, seq };
369
+ }
370
+
371
+ /**
372
+ * REG-CS2 索引新鲜度(防回放/降级):总目录过了签名之后还要过时效与代次水位两道闸。
373
+ *
374
+ * - `expires_at` 已过 → `index_expired`:索引自带失效时刻,源停止运营超过 TTL 就不再可信。
375
+ * (§5.3 可用性通道的「窗口内用缓存副本」由 HTTP 缓存层兑现——服务端
376
+ * `max-age=60, stale-while-revalidate=60` + 读取器条件请求;到达读取器手里的副本
377
+ * 自身过期即拒,与§5.3 不冲突。)
378
+ * - `seq` 小于本源见过的最大代次 → `index_stale`:镜像落后或旧索引被回放。
379
+ * - 水位读不到/写不进 → `index_state_corrupt`:fail-closed,不静默重置成「第一次见」。
380
+ */
381
+ async function guardCatalogFreshness(env: MarketEnv, base: string, text: string, seq: number): Promise<void> {
382
+ const times = await indexTimesOfText(text);
383
+ if (times === null)
384
+ fail(502, "市场索引 catalog.yaml 缺 issued_at / expires_at(index_expired)——索引未带时效,请让源执行 ojm market build-index 重建");
385
+ const now = env.now?.() ?? Math.floor(Date.now() / 1000);
386
+ if (now > times.expires_at)
387
+ fail(502, `市场索引 catalog.yaml 已于 ${new Date(times.expires_at * 1000).toISOString()} 失效(index_expired)——源未在其自声明的有效期内重建索引,请让源执行 ojm market build-index 后再装`);
388
+
389
+ let verdict: SourceSeqVerdict;
258
390
  try {
259
- // 先取 .sig 字节:404 / 空体 → 视为缺失(滚动发布期容忍),不进验签分支
260
- const body = await fetchText(fetch, `${base}/${kind}.yaml.sig`);
261
- if (body.trim() !== "")
262
- sigText = body;
391
+ verdict = await guardAndRecordSourceSeq(base, seq);
263
392
  }
264
393
  catch {
265
- // .sig 缺失/不可读:滚动发布期容忍(不阻断索引加载),硬开关留后续 rollout
394
+ fail(502, "市场索引代次水位写不进去(index_state_corrupt)——reg_source_seq 不可写,防回放保护失效,已拒载。请检查站点数据库权限后重试");
395
+ }
396
+ if (verdict.ok)
397
+ return;
398
+ if (verdict.code === "index_stale")
399
+ fail(502, `市场索引 catalog.yaml 是第 ${seq} 代,本源已见过第 ${verdict.prev} 代(index_stale)——源可能被回放或镜像落后,请核实源状态后再装`);
400
+ fail(502, "市场索引代次水位不可读(index_state_corrupt)——reg_source_seq 记录损坏,请检查站点数据库后重试(不静默重置,否则防回放形同虚设)");
401
+ }
402
+
403
+ /**
404
+ * catalog 解析(REG-CS5:包名 → 分片号 + 片号 → 代次钉值)。畸形即拒不猜——索引是签名过的
405
+ * 不可信输入,结构破损与字节被改同类对待(G4 fail-closed)。
406
+ */
407
+ async function parseCatalog(text: string, base: string): Promise<{ pins: Map<number, number>, solutions: Array<{ name: string, shards: number[] }> }> {
408
+ const g = globalThis as { yaml?: { parse: (t: string) => Promise<unknown> } };
409
+ let doc: unknown;
410
+ try {
411
+ doc = await g.yaml!.parse(text);
412
+ }
413
+ catch {
414
+ fail(502, `市场索引解析失败:${base}/catalog.yaml 不是合法索引`);
415
+ }
416
+ if (!isObj(doc))
417
+ fail(502, `市场索引解析失败:${base}/catalog.yaml 不是键值文档`);
418
+ const pins = new Map<number, number>();
419
+ if (isObj(doc.shards)) {
420
+ for (const [k, v] of Object.entries(doc.shards)) {
421
+ const no = Number(k);
422
+ if (Number.isInteger(no) && no >= 1 && typeof v === "number" && Number.isInteger(v) && v >= 1)
423
+ pins.set(no, v);
424
+ }
425
+ }
426
+ if (pins.size === 0)
427
+ fail(502, "市场索引 catalog.yaml 的分片钉值为空(index_generation_mismatch)——索引结构破损,请让源执行 ojm market build-index 重建");
428
+ const solutions: Array<{ name: string, shards: number[] }> = [];
429
+ for (const s of Array.isArray(doc.solutions) ? doc.solutions : []) {
430
+ if (!isObj(s) || typeof s.name !== "string" || s.name === "")
431
+ continue;
432
+ // 片号须是已钉值正整数:指向未钉片的方案 = 结构破损,拒载(不静默跳过,那会伪装成「没有该方案」)
433
+ const nos = Array.isArray(s.shards) ? s.shards.filter((n): n is number => typeof n === "number" && Number.isInteger(n) && n >= 1 && pins.has(n)) : [];
434
+ if (nos.length === 0)
435
+ fail(502, `市场索引 catalog.yaml 中方案「${s.name}」未指向任何已钉分片(index_generation_mismatch)——索引结构破损,请让源执行 ojm market build-index 重建`);
436
+ solutions.push({ name: s.name, shards: nos });
437
+ }
438
+ return { pins, solutions };
439
+ }
440
+
441
+ /** 分片内的方案条目(版本条目 = Form B manifest:name/version/archive/yanked/yanked_at + manifest 全字段) */
442
+ interface ShardSolution {
443
+ name: string
444
+ latest: string | null
445
+ versions: Array<Record<string, unknown>>
446
+ }
447
+
448
+ /** 分片解析(纯内容无 meta:`{solutions: [{name, latest, versions}]}`;结构破损即拒,不猜) */
449
+ async function parseShard(text: string, label: string): Promise<ShardSolution[]> {
450
+ const g = globalThis as { yaml?: { parse: (t: string) => Promise<unknown> } };
451
+ let doc: unknown;
452
+ try {
453
+ doc = await g.yaml!.parse(text);
454
+ }
455
+ catch {
456
+ fail(502, `市场索引 ${label} 不是合法索引(解析失败)`);
457
+ }
458
+ if (!isObj(doc) || !Array.isArray(doc.solutions))
459
+ fail(502, `市场索引 ${label} 解析失败:缺 solutions 数组——索引结构破损,请让源执行 ojm market build-index 重建`);
460
+ const out: ShardSolution[] = [];
461
+ for (const s of doc.solutions) {
462
+ if (!isObj(s) || typeof s.name !== "string" || s.name === "")
463
+ continue;
464
+ out.push({
465
+ name: s.name,
466
+ latest: typeof s.latest === "string" ? s.latest : null,
467
+ versions: Array.isArray(s.versions) ? s.versions.filter(isObj) : [],
468
+ });
469
+ }
470
+ return out;
471
+ }
472
+
473
+ /** 索引读取源(DIP,REG-CS5③):注入面优先,缺省按 CDN 基址 + fetchOf 构造 HTTP 源 */
474
+ async function indexSourceOf(env: MarketEnv, base: string): Promise<IndexSource> {
475
+ return env.indexSource ?? httpIndexSource(await fetchOf(env), base);
476
+ }
477
+
478
+ /** 索引时效(顶层 `issued_at` / `expires_at`;缺任一或非数 → null,按 REG-CS2 拒) */
479
+ async function indexTimesOfText(text: string): Promise<{ issued_at: number, expires_at: number } | null> {
480
+ const g = globalThis as { yaml?: { parse: (t: string) => Promise<unknown> } };
481
+ try {
482
+ const doc = await g.yaml!.parse(text) as unknown;
483
+ if (doc === null || typeof doc !== "object" || Array.isArray(doc))
484
+ return null;
485
+ const issuedAt = (doc as Record<string, unknown>).issued_at;
486
+ const expiresAt = (doc as Record<string, unknown>).expires_at;
487
+ if (typeof issuedAt !== "number" || typeof expiresAt !== "number")
488
+ return null;
489
+ return { issued_at: issuedAt, expires_at: expiresAt };
490
+ }
491
+ catch {
492
+ return null;
266
493
  }
267
- if (sigText !== null && !verifyIndexSig(env, kind, text, sigText))
268
- fail(502, `市场索引 ${kind}.yaml 签名校验失败(索引被篡改或签名不符,G4 fail-closed)——请重新发布并签名`);
269
- return text;
270
494
  }
271
495
 
272
496
  /** http.user.id → username(operator 历史留痕,uninstall 端点同款反查;查询失败不阻断编排) */
@@ -331,31 +555,58 @@ interface ResolvedBundle {
331
555
 
332
556
  /**
333
557
  * Form B 索引解析(安装器不拼 URL——市场侧职责,installer.ts 同纪律):
334
- * bundles.yaml 定版本(缺省 latest,同名只展示最高版本 PRD §5.3)→ manifests.yaml 按
335
- * name+version 精确取 manifest → 分发包 URL = base + 索引 file 字段。
558
+ * catalog.yaml 定包名 → 分片号(无版本明细)→ 分片内按版本精确取(版本条目即 manifest,
559
+ * 原 manifests.yaml 聚合清单并入分片,装一个包不再有第三份全量拉取)→ 分发包 URL =
560
+ * base + 条目 archive.file。分片按号**降序**尝试:装箱口径下新版本进新片,最高片持有
561
+ * 最新版——latest 安装只拉 1 片(REG-CS5 验收:一次安装索引请求 ≤ 1 catalog + 1 shard)。
336
562
  */
337
563
  async function resolveCdn(env: MarketEnv, base: string, name: string, reqVersion: string | null, fetch: FetchInitFace): Promise<ResolvedBundle> {
338
- const g = globalThis as { yaml?: { parse: (text: string) => Promise<unknown> } };
339
- const bundles = await g.yaml!.parse(await fetchIndex(env, fetch, base, "bundles")) as { solutions?: unknown };
340
- const entry = (Array.isArray(bundles.solutions) ? bundles.solutions : []).find(s => isObj(s) && s.name === name);
341
- if (!isObj(entry))
342
- fail(404, `市场索引中无方案「${name}」(${base}/bundles.yaml)`);
343
- const versions = Array.isArray(entry.versions) ? entry.versions.filter(isObj) : [];
344
- const version = reqVersion ?? (typeof entry.latest === "string" ? entry.latest : null);
345
- const hit = versions.find(v => String(v.version) === version);
346
- if (version === null || !isObj(hit))
347
- fail(404, `市场索引中无方案「${name}」版本「${reqVersion ?? "latest"}」(${base}/bundles.yaml)`);
348
- const list = await g.yaml!.parse(await fetchIndex(env, fetch, base, "manifests")) as unknown;
349
- const manifest = (Array.isArray(list) ? list : []).find(m => isObj(m) && m.name === name && String(m.version) === version);
350
- if (!isObj(manifest))
351
- fail(404, `聚合清单缺 ${name}@${version} 的 manifest(manifests.yaml 与 bundles.yaml 不一致,Form B 真源破损)`);
352
- // P2-11 索引投毒加固:bundles.yaml 的 file 逐字 <name>-<version>.tgz(索引未签名属不可信输入,
353
- // 过弱形态可拼宿主任意路径;逐字等值排除 ../、绝对路径、异名、异版本)
354
- const file = typeof hit.file === "string" ? hit.file : "";
355
- if (file !== `${name}-${version}.tgz`) {
356
- fail(502, `索引分发包 file「${file}」须逐字为 <name>-<version>.tgz(「${name}-${version}.tgz」)——索引投毒加固(P2-11),疑似被篡改的 bundles.yaml`);
357
- }
358
- return { version, archiveUrl: `${base}/${file}`, manifest };
564
+ const source = httpIndexSource(fetch, base);
565
+ // 总目录(catalog)= 代次与时效真源;分片钉值同代才可用(REG-CS1③)
566
+ const catalog = await fetchIndex(env, source, base, "catalog");
567
+ const { pins, solutions } = await parseCatalog(catalog.text, base);
568
+ const entry = solutions.find(s => s.name === name);
569
+ if (entry === undefined)
570
+ fail(404, `市场索引中无方案「${name}」(${base}/catalog.yaml)`);
571
+ // 降序:最高片先试(latest 路径一片即中);精确老版本落在低片时继续往下,直到找到或找遍
572
+ for (const no of [...entry.shards].sort((a, b) => b - a)) {
573
+ const pin = pins.get(no);
574
+ if (pin === undefined)
575
+ fail(502, `市场索引 catalog.yaml 钉值缺 shards/${no}.yaml(index_generation_mismatch)——源正在重建索引,请稍后重试`);
576
+ const shard = await fetchIndex(env, source, base, "shard", no, pin);
577
+ const sol = (await parseShard(shard.text, `shards/${no}.yaml`)).find(s => s.name === name);
578
+ if (sol === undefined)
579
+ continue;
580
+ // REG-CS2 `latest` 单调不降(三级化后移到分片条目):分片声明的 latest 不得低于本片
581
+ // 已声明版本中的最高未下线版本。把 latest 往下改是「让站点装回旧版」的最短路径
582
+ // (yank 之后回退声明),必须挡在解析处。已下线版本(REG-CS4 yanked)不计入——
583
+ // 否则 yank 掉最高版会把 latest 主动下调,被误判成索引回退;latest 本就指向未下线的最高版。
584
+ const liveSorted = sol.versions.filter(v => v.yanked !== true).map(v => String(v.version)).sort(compareVersions);
585
+ const highest = liveSorted.length === 0 ? null : liveSorted[liveSorted.length - 1]!;
586
+ if (sol.latest !== null && highest !== null && compareVersions(sol.latest, highest) < 0)
587
+ fail(502, `市场索引中方案「${name}」的 latest 是 ${sol.latest},低于已声明的最高版本 ${highest}(index_latest_regressed)——索引被回退,请核实源状态后再装`);
588
+ const version = reqVersion ?? sol.latest;
589
+ if (version !== null) {
590
+ const hit = sol.versions.find(v => String(v.version) === version);
591
+ if (hit !== undefined) {
592
+ // REG-CS4 yank:精确指定被下线版本 → 拒装(latest 路径不命中,源方 rebuilt 时 latest 已跳过 yanked);
593
+ // 已装实例不受影响(不在此解析路径内)。错误信息明示「版本已下线」而非「无此版本」,避免误导。
594
+ if (reqVersion !== null && hit.yanked === true)
595
+ fail(409, `方案「${name}」版本「${version}」已被发布方下线(REG-CS4 yank),无法安装;已安装实例不受影响。如需继续请联系发布方或改用其他版本`);
596
+ // P2-11 索引投毒加固:archive.file 逐字 <name>-<version>.tgz(索引是签名过的不可信输入,
597
+ // 过弱形态可拼宿主任意路径;逐字等值排除 ../、绝对路径、异名、异版本)
598
+ const archive = isObj(hit.archive) ? hit.archive : {};
599
+ const file = typeof archive.file === "string" ? archive.file : "";
600
+ if (file !== `${name}-${version}.tgz`) {
601
+ fail(502, `索引分发包 file「${file}」须逐字为 <name>-<version>.tgz(「${name}-${version}.tgz」)——索引投毒加固(P2-11),疑似被篡改的索引`);
602
+ }
603
+ // 版本条目即 Form B manifest(原 manifests.yaml 并入分片):取到条目即取到 manifest
604
+ return { version, archiveUrl: `${base}/${file}`, manifest: hit };
605
+ }
606
+ }
607
+ // 版本不在这片(更低分片或真没有):继续往低片找
608
+ }
609
+ fail(404, `市场索引中无方案「${name}」版本「${reqVersion ?? "latest"}」(${base}/catalog.yaml → shards)`);
359
610
  }
360
611
 
361
612
  /** 上传载荷字节(upload/api.ts 先例双路径:小文件字节在手,大文件已流式落 blob 须走 blob.get) */
@@ -490,11 +741,13 @@ async function installBody(env: MarketEnv): Promise<{ task_id: string }> {
490
741
  const base = await cdnBaseOf(env);
491
742
  if (base === null)
492
743
  fail(503, "未配置市场地址(framework.market_cdn_url)——市场安装不可用,请先在系统设置配置 CDN 基址");
493
- const r = await resolveCdn(env, base, name, reqVersion, fetchOf(env));
744
+ // REG-CS6:安装全程(相对基址补全 + 冒烟探针)都需 canonical origin;缺失/非法即明确报错
745
+ await requireCanonicalOrigin(env);
746
+ const r = await resolveCdn(env, base, name, reqVersion, await fetchOf(env));
494
747
  manifest = r.manifest;
495
748
  version = r.version;
496
749
  archiveUrl = r.archiveUrl;
497
- fetch = fetchOf(env);
750
+ fetch = await fetchOf(env);
498
751
  }
499
752
  // 第一方 bootstrap key 播种(P2-12,G3 双轨第一轨):runInstall 之前——market 首装免 TOFU。
500
753
  // key 未回填(null)或信任库已初始化 → no-op(不与运维吊销对抗)
@@ -523,7 +776,7 @@ async function installBody(env: MarketEnv): Promise<{ task_id: string }> {
523
776
  modulesJsonPath: env.modulesJsonPath,
524
777
  apiPrefix: env.apiPrefix,
525
778
  configPath: env.configPath,
526
- fetch: withSiteOrigin(fetch),
779
+ fetch,
527
780
  exec: execOf(env),
528
781
  reload: reloadOf(env),
529
782
  sha384B64: sha384Of(env),
@@ -534,6 +787,7 @@ async function installBody(env: MarketEnv): Promise<{ task_id: string }> {
534
787
  // 宿主平台编排面 API 版本(C2-11 peerPlatform 比对真源;与 meta 端点 apiVersion、_platform/manifest.yaml 版本同源,手工同步)
535
788
  hostApiVersion: API_VERSION,
536
789
  trustPath: env.trustPath,
790
+ registryId: env.registryId,
537
791
  cancel: () => {
538
792
  if (!cancelled) {
539
793
  void fsFace().readFile(markerPath).then(() => {
@@ -562,10 +816,12 @@ interface IndexRow {
562
816
  upgradable: boolean
563
817
  /** CDN 版本全集(P2-11):回滚候选不止本站已装旧版;索引缺段/畸形 → [] 降级不炸 */
564
818
  available: string[]
565
- /** 最新版 changelog(C2-08,R27 卡片版本对比;manifests.yaml 不可得 → null) */
819
+ /** 最新版 changelog(C2-08,R27 卡片版本对比;分片条目缺 changelog → null) */
566
820
  changelog: unknown
567
- /** 最新版发布方(manifest author,C2-07;manifests.yaml 不可得 → null) */
821
+ /** 最新版发布方(manifest author,C2-07;分片条目缺 author → null) */
568
822
  publisher: string | null
823
+ /** 最新版许可(manifest license,C2-07 SPDX 简化式;分片条目缺/非字符串 → null) */
824
+ license: string | null
569
825
  /** 本地安装的发布方 key 状态(P2-6 sidecar;未装 → null) */
570
826
  trust_state: "trusted" | "untrusted" | "absent" | null
571
827
  /** 本地安装记录的签名 key id(sidecar archive.key_id;未装/P1 时代条目 → null) */
@@ -592,7 +848,14 @@ async function lastInstallOperatorOf(name: string): Promise<string | null> {
592
848
  }
593
849
  }
594
850
 
595
- /** GET index 主体:CDN bundles.yaml(no-cache)⊕ 本地 sidecar state——CDN 序优先,本地独有方案殿后 */
851
+ /**
852
+ * GET index 主体:CDN catalog + shards ⊕ 本地 sidecar state——CDN 序优先,本地独有方案殿后。
853
+ *
854
+ * 三级化后市场页的拉取形态:1 catalog + catalog 指向的全部分片(去重)——`available`
855
+ * 回滚候选要版本全集,故页面比安装多拉几片;安装侧仍 ≤ 1 catalog + 1 shard(resolveCdn)。
856
+ * 版本条目即 Form B manifest:latest 的 changelog/publisher 直接从分片条目取,不再有
857
+ * 独立的聚合清单拉取。分片拉取/验签失败即 502(REG-CS1:可用性不再压过完整性)。
858
+ */
596
859
  async function indexBody(env: MarketEnv): Promise<IndexRow[]> {
597
860
  // 对账即服务(P1-10 接线③):市场页刷新即对账时点——锁 ↔ 账本 head 自愈 + enabled 不一致
598
861
  // 自动修复先行(失败只记日志,不阻断索引),失败站点的市场页每次打开都在尝试自愈
@@ -600,43 +863,41 @@ async function indexBody(env: MarketEnv): Promise<IndexRow[]> {
600
863
  const base = await cdnBaseOf(env);
601
864
  if (base === null)
602
865
  fail(503, "未配置市场地址(framework.market_cdn_url)——卡片区显示「未配置市场地址」(PRD §5.3)");
603
- const g = globalThis as { yaml?: { parse: (text: string) => Promise<unknown> } };
604
- const text = await fetchIndex(env, fetchOf(env), base, "bundles");
605
- let bundles: { solutions?: unknown };
606
- try {
607
- bundles = await g.yaml!.parse(text) as { solutions?: unknown };
608
- }
609
- catch {
610
- fail(502, `市场索引解析失败:${base}/bundles.yaml 不是合法索引`);
866
+ await assertOriginForRelativeBase(env, base);
867
+ const source = await indexSourceOf(env, base);
868
+ const catalog = await fetchIndex(env, source, base, "catalog");
869
+ const { pins, solutions } = await parseCatalog(catalog.text, base);
870
+ // 拉取 catalog 指向的全部分片(去重、升序确定性);每片都过签名 + 钉值同代校验
871
+ const shardDocs = new Map<number, ShardSolution[]>();
872
+ for (const no of [...pins.keys()].sort((a, b) => a - b)) {
873
+ const shard = await fetchIndex(env, source, base, "shard", no, pins.get(no)!);
874
+ shardDocs.set(no, await parseShard(shard.text, `shards/${no}.yaml`));
611
875
  }
612
- const cdn = (Array.isArray(bundles.solutions) ? bundles.solutions : []).filter(isObj);
613
876
  const local = (await createStateStore(env.statePath)).read().solutions;
614
- // manifests.yaml 单次拉取供 changelog/publisher(P2-11):失败降级 null 不阻断索引(可用性优先)
615
- let manifests: Record<string, unknown>[] = [];
616
- try {
617
- const list = await g.yaml!.parse(await fetchIndex(env, fetchOf(env), base, "manifests"));
618
- if (Array.isArray(list))
619
- manifests = list.filter(isObj);
620
- }
621
- catch {
622
- // 降级:changelog/publisher 全 null,索引主体照常返回
623
- }
624
- const manifestOf = (name: string, version: string | null): Record<string, unknown> | undefined =>
625
- version === null ? undefined : manifests.find(m => m.name === name && String(m.version) === version);
626
877
  const rows: IndexRow[] = [];
627
878
  const seen = new Set<string>();
628
- for (const s of cdn) {
629
- const name = typeof s.name === "string" ? s.name : "";
630
- if (name === "" || seen.has(name))
879
+ for (const entry of solutions) {
880
+ const name = entry.name;
881
+ if (seen.has(name))
631
882
  continue;
632
883
  seen.add(name);
633
- const latest = typeof s.latest === "string" ? s.latest : null;
884
+ // 合并该包全部分片的版本条目(降序拼:最高片持有最新版,latest 取首片即得)
885
+ const merged: Array<Record<string, unknown>> = [];
886
+ let latest: string | null = null;
887
+ for (const no of [...entry.shards].sort((a, b) => b - a)) {
888
+ const sol = shardDocs.get(no)?.find(s => s.name === name);
889
+ if (sol === undefined)
890
+ continue;
891
+ if (latest === null)
892
+ latest = sol.latest;
893
+ merged.push(...sol.versions);
894
+ }
634
895
  const side = local[name];
635
- // available:versions[].version 字符串全集(缺段/畸形 → [] 降级)
636
- const available = (Array.isArray(s.versions) ? s.versions : [])
637
- .map(v => isObj(v) && typeof v.version === "string" ? v.version : "")
638
- .filter(x => x !== "");
639
- const m = manifestOf(name, latest) ?? manifestOf(name, side?.version ?? null);
896
+ // available:版本全集(缺段/畸形 → [] 降级);分片内升序、跨片重排为全局升序
897
+ const available = merged.map(v => typeof v.version === "string" ? v.version : "").filter(x => x !== "").sort(compareVersions);
898
+ const manifestOf = (version: string | null): Record<string, unknown> | undefined =>
899
+ version === null ? undefined : merged.find(v => String(v.version) === version);
900
+ const m = manifestOf(latest) ?? manifestOf(side?.version ?? null);
640
901
  rows.push({
641
902
  name,
642
903
  latest,
@@ -646,6 +907,7 @@ async function indexBody(env: MarketEnv): Promise<IndexRow[]> {
646
907
  available,
647
908
  changelog: m?.changelog ?? null,
648
909
  publisher: typeof m?.author === "string" ? m.author : null,
910
+ license: typeof m?.license === "string" ? m.license : null,
649
911
  trust_state: side?.trust_state ?? null,
650
912
  key_id: side?.archive?.key_id ?? null,
651
913
  operator: await lastInstallOperatorOf(name),
@@ -662,6 +924,7 @@ async function indexBody(env: MarketEnv): Promise<IndexRow[]> {
662
924
  available: [],
663
925
  changelog: null,
664
926
  publisher: null,
927
+ license: null,
665
928
  trust_state: side.trust_state ?? null,
666
929
  key_id: side.archive?.key_id ?? null,
667
930
  operator: await lastInstallOperatorOf(name),