@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,42 +1,41 @@
1
- import type { SourceSeqVerdict } from "../_lifecycle/source-seq";
2
- // 编排端点 + 市场索引(P1-12,R9/ISP):/api/_platform/market/* 端点族同文件嵌套 handler 组
3
- // (oj v0.1.64 组形态,notification/api.ts 同款;组名 = 路径段,路由不变;嵌套组最深 4 层,oj bridge/mod.rs walker)。
1
+ import type { BundledEntry } from "#/_platform/_lifecycle/bundle-converge";
2
+ // 编排端点 + 市场目录(P1-12,R9/ISP;v4 §四:市场域 api 自 _platform 迁入本模块):/api/oj-market/*
3
+ // 端点族同文件嵌套 handler 组(oj v0.1.64 组形态,notification/api.ts 同款;组名 = 路径段)。
4
4
  // 门(R9):install/task/index 全 assertSuperAdmin 独占(市场页是「市场即样例」的第一方特权
5
5
  // 例外,R29;权限码不构成门);meta 登录即可(peerPlatform 预检先于判权)。
6
- // 职责边界(ISP):端点只做 门禁 + Form B 索引解析(安装器不拼 URL,installer.ts 同纪律)+
6
+ // 职责边界(ISP):端点只做 门禁 + 目录解析(安装器不拼 URL,installer.ts 同纪律)+
7
7
  // InstallRequest 组装;任务生命周期/失败三分/终态审计全归 runInstall 状态机(P1-10),端点不重复编排。
8
- // CDN 基址:sys_setting `framework.market_cdn_url`(PRD §5.3,缺省空 = 市场不可用,可读错误不崩);
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 共同承载)。
8
+ // 目录真源 = market_release 表(DB 目录化 v4;签名索引机制整建制退役)——install 解析/index
9
+ // 卡片/releases 历史全部直读 DB 合成 Form B 形状,包字节仍从 CDN 基址静态拉。
10
+ // CDN 基址:sys_setting `framework.market_cdn_url`(PRD §5.3,缺省空 = 市场不可用,可读错误不崩)。
12
11
  // 站点路径缺省(marketEnv):oj serve release 布局生产口径(P1-16 接线 B3/B4)——共享根 = serve
13
12
  // 挂载根 dist(锁 dist/manifests.yaml、版本目录 dist/<name>-<ver>/、versions.json 同根),
14
13
  // 暂存/任务/sidecar 留 dist 外(R13 不与装配树冲突);全字段可注入覆盖(测试 stub 同款)。
15
14
  // file 源上传形态:照 personal-center/upload/api.ts 先例(http.files 双路径,大文件走 blob);
16
15
  // 上传包先过 R1 三步范式(list→validate→extract)取出 manifest.yaml 供 preflight——
17
16
  // 未校验不解包,zip-slip 在信任边界外被拒。
18
- import type { FetchFace } from "../_lifecycle/steps/download";
19
- import type { ExecFace } from "../_lifecycle/steps/extract";
20
- import type { ReloadFace } from "../_lifecycle/steps/reload";
21
- import type { FetchInitFace } from "../_shared/site-facade";
22
- import type { IndexKind, IndexSource } from "./index-source";
23
- import { atomicWriteText, fsFace } from "../_lifecycle/atomic";
24
- import { latestAppliedSeqOf, reconcile, runInstall, setEnabled } from "../_lifecycle/installer";
25
- import { compareVersions } from "../_lifecycle/semver";
26
- import { sha384B64 } from "../_lifecycle/sha384";
27
- import { guardAndRecordSourceSeq } from "../_lifecycle/source-seq";
28
- import { createStateStore } from "../_lifecycle/state";
29
- import { flattenBundleRoot } from "../_lifecycle/steps/extract";
30
- import { getTask, readTasks } from "../_lifecycle/tasks";
31
- import { validateTarListing } from "../_lifecycle/tgz-guard";
32
- import { createTrustStore } from "../_lifecycle/trust";
33
- import { computeUninstallImpact, runUninstall } from "../_lifecycle/uninstall";
34
- import { respond } from "../_shared/respond";
35
- import { assertSuperAdmin } from "../_shared/service/infra/access";
36
- import { pageArgs } from "../_shared/service/infra/query";
37
- import { journalHeadOf, readCanonicalOrigin, siteOrigin, withSiteOrigin } from "../_shared/site-facade";
17
+ import type { FetchFace } from "#/_platform/_lifecycle/steps/download";
18
+ import type { ExecFace } from "#/_platform/_lifecycle/steps/extract";
19
+ import type { ReloadFace } from "#/_platform/_lifecycle/steps/reload";
20
+ import type { FetchInitFace } from "#/_platform/_shared/site-facade";
21
+ import { atomicWriteText, fsFace } from "#/_platform/_lifecycle/atomic";
22
+ import { convergeBundledSolutions, pruneBundledMarker } from "#/_platform/_lifecycle/bundle-converge";
23
+ import { recordHistory } from "#/_platform/_lifecycle/history";
24
+ import { latestAppliedSeqOf, reconcile, runInstall, setEnabled } from "#/_platform/_lifecycle/installer";
25
+ import { localArchiveManifestOf } from "#/_platform/_lifecycle/local-archive";
26
+ import { compareVersions } from "#/_platform/_lifecycle/semver";
27
+ import { sha384B64 } from "#/_platform/_lifecycle/sha384";
28
+ import { createStateStore } from "#/_platform/_lifecycle/state";
29
+ import { getTask, readTasks } from "#/_platform/_lifecycle/tasks";
30
+ import { createTrustStore } from "#/_platform/_lifecycle/trust";
31
+ import { computeUninstallImpact, runUninstall } from "#/_platform/_lifecycle/uninstall";
32
+ import { respond } from "#/_platform/_shared/respond";
33
+ import { assertSuperAdmin } from "#/_platform/_shared/service/infra/access";
34
+ import { pageArgs } from "#/_platform/_shared/service/infra/query";
35
+ import { journalHeadOf, readCanonicalOrigin, readSiteVar, siteOrigin, withSiteOrigin } from "#/_platform/_shared/site-facade";
36
+ import { backfillReleases } from "./backfill";
38
37
  import { BOOTSTRAP_KEY, seedBootstrapKey } from "./bootstrap";
39
- import { httpIndexSource, indexPathOf } from "./index-source";
38
+ import { authenticateSignedKey, verifyCanonicalSig } from "./cdn/signed-auth";
40
39
 
41
40
  // 站点编排面共享真源落 _shared/site-facade(P2-16 真机构建钉出:api.ts 是路由入口不可被模块内
42
41
  // import);此处 re-export 保持既有引用面(market-api.spec / 历史 import 路径)不变。
@@ -46,6 +45,9 @@ export type { FetchInitFace };
46
45
  /** 平台编排面版本(镜像 _platform/manifest.yaml version;peerPlatform 预检口径,manifest 升版须同步) */
47
46
  const API_VERSION = "0.1.0";
48
47
 
48
+ /** 目录协议版本(v4 §三:api_version 给契约演进留逃生门;跨站消费侧据此判可解析性) */
49
+ const CATALOG_API_VERSION = "v1";
50
+
49
51
  export interface MarketEnv {
50
52
  /** 共享根(manifests.yaml 所在目录 = 版本目录父级,D3 单 mount 形态) */
51
53
  sharedRoot: string
@@ -71,11 +73,8 @@ export interface MarketEnv {
71
73
  canonicalOrigin?: string
72
74
  /** CDN 基址(缺省读 sys_setting `framework.market_cdn_url`;测试注入) */
73
75
  cdnBase?: () => Promise<string | null>
74
- /**
75
- * 索引读取源注入(DIP,REG-CS5③):缺省按 CDN 基址 + fetchOf 构造 HTTP 源
76
- * (ETag/If-None-Match 条件请求);本地目录索引 / 测试夹具可直注(localIndexSource)。
77
- */
78
- indexSource?: IndexSource
76
+ /** cdn/ 字节仓库目录(上传端点与回填命令共用;缺省 "cdn") */
77
+ cdnDir?: string
79
78
  /** 注入面(DIP):缺省回落 isolate 全局 fetch / exec.run / server.reloadConfig / 本地 sha384(过渡实现) */
80
79
  fetch?: FetchFace
81
80
  exec?: ExecFace
@@ -163,6 +162,7 @@ export function marketEnv(): MarketEnv {
163
162
  taskDir: "modules/.install-tasks",
164
163
  statePath: "modules/.solutions/state.yaml",
165
164
  trustPath: ".solutions/trusted-keys.yaml",
165
+ cdnDir: "cdn",
166
166
  dbPath: "db.sqlite",
167
167
  siteModulesDir: "dist/web/modules",
168
168
  modulesJsonPath: "dist/web/modules.json",
@@ -218,18 +218,6 @@ async function requireCanonicalOrigin(env: MarketEnv): Promise<string> {
218
218
  return co;
219
219
  }
220
220
 
221
- /**
222
- * REG-CS6:相对市场基址(`market_cdn_url=/cdn` 本站点自供)**必须**有 canonical origin 才能补全为
223
- * 同源绝对 URL——绝对基址(外部真 CDN)不需要。缺失/非法即明确报错并指向具体设置项
224
- * (config.yaml `vars.canonical_origin`),绝不回落请求头 Host(伪造 Host 会牵引出站)。
225
- */
226
- async function assertOriginForRelativeBase(env: MarketEnv, base: string): Promise<void> {
227
- if (/^https?:\/\//i.test(base)) {
228
- return;
229
- }
230
- await requireCanonicalOrigin(env);
231
- }
232
-
233
221
  function execOf(env: MarketEnv): ExecFace {
234
222
  const g = globalThis as { exec?: { run: ExecFace["run"] } };
235
223
  return env.exec ?? { run: (argv, opts) => g.exec!.run(argv, opts) };
@@ -291,234 +279,6 @@ async function cdnBaseOf(env: MarketEnv): Promise<string | null> {
291
279
  }
292
280
 
293
281
  /** 索引签名算法标识(与 ojm publish 同源:Ed25519;C2-35) */
294
- const INDEX_SIG_ALG = "Ed25519";
295
-
296
- /** 索引规范串(REG-CS1,与 ojm publish indexCanonical 逐字一致):`index:<kind>:<seq>:<sha256hex>` */
297
- function indexCanonical(env: MarketEnv, kind: IndexKind, seq: number, text: string): string {
298
- const sha256 = sha256Of(env)(new TextEncoder().encode(text));
299
- return `index:${kind}:${seq}:${sha256}`;
300
- }
301
-
302
- /** Ed25519 验签面(P2-3;env 注入优先,缺省回落 isolate 全局 crypto.verifyEd25519) */
303
- function verifyEd25519Of(env: MarketEnv): (publicKey: string, msg: string, sigBase64: string) => boolean {
304
- const g = globalThis as { crypto?: { verifyEd25519?: (pk: string, msg: string, sig: string) => boolean } };
305
- return env.verifyEd25519 ?? ((pk, msg, sig) => g.crypto!.verifyEd25519!(pk, msg, sig));
306
- }
307
-
308
- /** 验签判定:ok + 代次(读方据此做同代校验;seq 为 null = 旧格式签名,一律拒) */
309
- interface IndexSigVerdict {
310
- ok: boolean
311
- seq: number | null
312
- }
313
-
314
- /**
315
- * 索引签名校验(REG-CS1,G4 fail-closed 口径):解析 detached .sig 六字段 → 结构校验 →
316
- * 重算规范串(含 seq)→ verifyEd25519。返回 ok 仅当签名完整、覆盖代次且匹配索引字节。
317
- * 注:发布方信任(key 是否在信任库/bootstrap)由安装期 TOFU/信任库承载,此处仅做完整性(防 CDN 索引篡改)。
318
- */
319
- function verifyIndexSig(env: MarketEnv, kind: IndexKind, text: string, sigText: string): IndexSigVerdict {
320
- let sig: unknown;
321
- try {
322
- sig = JSON.parse(sigText);
323
- }
324
- catch {
325
- return { ok: false, seq: null };
326
- }
327
- if (!isObj(sig))
328
- return { ok: false, seq: null };
329
- const { alg, value, public_key: publicKey, seq } = sig as { alg?: unknown, value?: unknown, public_key?: unknown, seq?: unknown };
330
- if (alg !== INDEX_SIG_ALG || typeof value !== "string" || typeof publicKey !== "string")
331
- return { ok: false, seq: null };
332
- // 签名未覆盖代次 = 旧格式(无法防「新总目录 + 旧分册」),拒
333
- if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 1)
334
- return { ok: false, seq: null };
335
- return { ok: verifyEd25519Of(env)(publicKey, indexCanonical(env, kind, seq, text), value), seq };
336
- }
337
-
338
- /** 索引正文的代次(顶层 `seq`;纯内容形态(分片)无顶层键 → null,代次只由 .sig 承载) */
339
- async function indexSeqOfText(text: string): Promise<number | null> {
340
- const g = globalThis as { yaml?: { parse: (t: string) => Promise<unknown> } };
341
- try {
342
- const doc = await g.yaml!.parse(text) as unknown;
343
- if (doc === null || typeof doc !== "object" || Array.isArray(doc))
344
- return null;
345
- const seq = (doc as Record<string, unknown>).seq;
346
- return typeof seq === "number" && Number.isInteger(seq) && seq >= 1 ? seq : null;
347
- }
348
- catch {
349
- return null;
350
- }
351
- }
352
-
353
- /** 缺签名/空签名的人话(两步可执行下一步,与 CLI `ojm market verify` 同一文案口径) */
354
- const SIG_MISSING_NEXT = "完整性无保障,已拒载。下一步:① 让源执行 ojm market build-index --id <publisher.id> 重建并签名;② 或对既有索引补签 ojm publish <目录> --id <publisher.id>";
355
-
356
- /**
357
- * 拉取索引并校验签名(REG-CS1,G4 fail-closed)——三级化后(catalog + shards):
358
- * ① `.sig` 缺失 / 空 / 拉不到 → **拒载**(502)——不再「滚动发布期容忍」:
359
- * 容忍等于把索引完整性交给发布方自觉,谁改了索引都能装。
360
- * ② 签名不符或未覆盖代次 → 拒载(index_sig_mismatch)。
361
- * ③ 同代校验改「钉值」口径:catalog 正文 `shards: {<片号>: <代次>}` 钉每片的代次,
362
- * 分片签名 seq 必须等于钉值(`expectSeq`)——不等即拒(index_generation_mismatch,
363
- * 提示「源正在重建索引」)。byte 级相同的分片可跨代复用,故钉值而非恒等于 catalog 代次。
364
- * ④ catalog 另过两道新鲜度闸(REG-CS2,见 `guardCatalogFreshness`):时效过期 `index_expired`、
365
- * seq 回退 `index_stale`——回放与镜像落后在此处被挡住。
366
- *
367
- * `no` = 分片号(catalog 省略);`expectSeq` = catalog 对该片的钉值(取 catalog 本身时省略)。
368
- * 关键:四类拒载都在 try 之外抛出——fail-closed 不能被任何 catch 吞掉。
369
- */
370
- async function fetchIndex(env: MarketEnv, source: IndexSource, base: string, kind: IndexKind, no?: number, expectSeq?: number): Promise<{ text: string, seq: number }> {
371
- // 人话里的文件名与 CDN 布局逐字一致(indexPathOf 单点):shards/<n>.yaml,不是 shard/<n>.yaml
372
- const label = indexPathOf(kind, no);
373
- const file = await source.read(kind, no);
374
- if (file === null)
375
- fail(502, `市场索引 ${label} 拉取失败(请检查市场地址与网络)`);
376
- const sigText = file.sig;
377
- if (sigText === null || sigText.trim() === "")
378
- fail(502, `市场索引 ${label} 缺签名文件 ${label}.sig(index_sig_missing)——${SIG_MISSING_NEXT}`);
379
- const verdict = verifyIndexSig(env, kind, file.body, sigText);
380
- if (!verdict.ok || verdict.seq === null)
381
- fail(502, `市场索引 ${label} 签名校验失败(index_sig_mismatch:索引被篡改、签名不符或签名未覆盖代次,G4 fail-closed)——请重新发布并签名`);
382
- const seq = verdict.seq;
383
- // 正文自带代次时(catalog)必须与签名一致——防止「改了正文没换签名」的半个写入;
384
- // 分片正文是纯内容(无顶层 seq),代次只由签名 + catalog 钉值携带,故不过此闸。
385
- const textSeq = await indexSeqOfText(file.body);
386
- if (textSeq !== null && textSeq !== seq)
387
- fail(502, `市场索引 ${label} 正文是第 ${textSeq} 代、签名是第 ${seq} 代(index_generation_mismatch)——源正在重建索引,请稍后重试`);
388
- if (expectSeq !== undefined && expectSeq !== seq)
389
- fail(502, `市场索引 ${label} 签名是第 ${seq} 代,总目录钉的是第 ${expectSeq} 代(index_generation_mismatch)——源正在重建索引,请稍后重试`);
390
- // ④ 新鲜度只在 catalog 上判一次:代次真源与时效盖章都在它身上,分片跟着走
391
- if (kind === "catalog")
392
- await guardCatalogFreshness(env, base, file.body, seq);
393
- return { text: file.body, seq };
394
- }
395
-
396
- /**
397
- * REG-CS2 索引新鲜度(防回放/降级):总目录过了签名之后还要过时效与代次水位两道闸。
398
- *
399
- * - `expires_at` 已过 → `index_expired`:索引自带失效时刻,源停止运营超过 TTL 就不再可信。
400
- * (§5.3 可用性通道的「窗口内用缓存副本」由 HTTP 缓存层兑现——服务端
401
- * `max-age=60, stale-while-revalidate=60` + 读取器条件请求;到达读取器手里的副本
402
- * 自身过期即拒,与§5.3 不冲突。)
403
- * - `seq` 小于本源见过的最大代次 → `index_stale`:镜像落后或旧索引被回放。
404
- * - 水位读不到/写不进 → `index_state_corrupt`:fail-closed,不静默重置成「第一次见」。
405
- */
406
- async function guardCatalogFreshness(env: MarketEnv, base: string, text: string, seq: number): Promise<void> {
407
- const times = await indexTimesOfText(text);
408
- if (times === null)
409
- fail(502, "市场索引 catalog.yaml 缺 issued_at / expires_at(index_expired)——索引未带时效,请让源执行 ojm market build-index 重建");
410
- const now = env.now?.() ?? Math.floor(Date.now() / 1000);
411
- if (now > times.expires_at)
412
- fail(502, `市场索引 catalog.yaml 已于 ${new Date(times.expires_at * 1000).toISOString()} 失效(index_expired)——源未在其自声明的有效期内重建索引,请让源执行 ojm market build-index 后再装`);
413
-
414
- let verdict: SourceSeqVerdict;
415
- try {
416
- verdict = await guardAndRecordSourceSeq(base, seq);
417
- }
418
- catch {
419
- fail(502, "市场索引代次水位写不进去(index_state_corrupt)——reg_source_seq 不可写,防回放保护失效,已拒载。请检查站点数据库权限后重试");
420
- }
421
- if (verdict.ok)
422
- return;
423
- if (verdict.code === "index_stale")
424
- fail(502, `市场索引 catalog.yaml 是第 ${seq} 代,本源已见过第 ${verdict.prev} 代(index_stale)——源可能被回放或镜像落后,请核实源状态后再装`);
425
- fail(502, "市场索引代次水位不可读(index_state_corrupt)——reg_source_seq 记录损坏,请检查站点数据库后重试(不静默重置,否则防回放形同虚设)");
426
- }
427
-
428
- /**
429
- * catalog 解析(REG-CS5:包名 → 分片号 + 片号 → 代次钉值)。畸形即拒不猜——索引是签名过的
430
- * 不可信输入,结构破损与字节被改同类对待(G4 fail-closed)。
431
- */
432
- async function parseCatalog(text: string, base: string): Promise<{ pins: Map<number, number>, solutions: Array<{ name: string, shards: number[] }> }> {
433
- const g = globalThis as { yaml?: { parse: (t: string) => Promise<unknown> } };
434
- let doc: unknown;
435
- try {
436
- doc = await g.yaml!.parse(text);
437
- }
438
- catch {
439
- fail(502, `市场索引解析失败:${base}/catalog.yaml 不是合法索引`);
440
- }
441
- if (!isObj(doc))
442
- fail(502, `市场索引解析失败:${base}/catalog.yaml 不是键值文档`);
443
- const pins = new Map<number, number>();
444
- if (isObj(doc.shards)) {
445
- for (const [k, v] of Object.entries(doc.shards)) {
446
- const no = Number(k);
447
- if (Number.isInteger(no) && no >= 1 && typeof v === "number" && Number.isInteger(v) && v >= 1)
448
- pins.set(no, v);
449
- }
450
- }
451
- if (pins.size === 0)
452
- fail(502, "市场索引 catalog.yaml 的分片钉值为空(index_generation_mismatch)——索引结构破损,请让源执行 ojm market build-index 重建");
453
- const solutions: Array<{ name: string, shards: number[] }> = [];
454
- for (const s of Array.isArray(doc.solutions) ? doc.solutions : []) {
455
- if (!isObj(s) || typeof s.name !== "string" || s.name === "")
456
- continue;
457
- // 片号须是已钉值正整数:指向未钉片的方案 = 结构破损,拒载(不静默跳过,那会伪装成「没有该方案」)
458
- const nos = Array.isArray(s.shards) ? s.shards.filter((n): n is number => typeof n === "number" && Number.isInteger(n) && n >= 1 && pins.has(n)) : [];
459
- if (nos.length === 0)
460
- fail(502, `市场索引 catalog.yaml 中方案「${s.name}」未指向任何已钉分片(index_generation_mismatch)——索引结构破损,请让源执行 ojm market build-index 重建`);
461
- solutions.push({ name: s.name, shards: nos });
462
- }
463
- return { pins, solutions };
464
- }
465
-
466
- /** 分片内的方案条目(版本条目 = Form B manifest:name/version/archive/yanked/yanked_at + manifest 全字段) */
467
- interface ShardSolution {
468
- name: string
469
- latest: string | null
470
- versions: Array<Record<string, unknown>>
471
- }
472
-
473
- /** 分片解析(纯内容无 meta:`{solutions: [{name, latest, versions}]}`;结构破损即拒,不猜) */
474
- async function parseShard(text: string, label: string): Promise<ShardSolution[]> {
475
- const g = globalThis as { yaml?: { parse: (t: string) => Promise<unknown> } };
476
- let doc: unknown;
477
- try {
478
- doc = await g.yaml!.parse(text);
479
- }
480
- catch {
481
- fail(502, `市场索引 ${label} 不是合法索引(解析失败)`);
482
- }
483
- if (!isObj(doc) || !Array.isArray(doc.solutions))
484
- fail(502, `市场索引 ${label} 解析失败:缺 solutions 数组——索引结构破损,请让源执行 ojm market build-index 重建`);
485
- const out: ShardSolution[] = [];
486
- for (const s of doc.solutions) {
487
- if (!isObj(s) || typeof s.name !== "string" || s.name === "")
488
- continue;
489
- out.push({
490
- name: s.name,
491
- latest: typeof s.latest === "string" ? s.latest : null,
492
- versions: Array.isArray(s.versions) ? s.versions.filter(isObj) : [],
493
- });
494
- }
495
- return out;
496
- }
497
-
498
- /** 索引读取源(DIP,REG-CS5③):注入面优先,缺省按 CDN 基址 + fetchOf 构造 HTTP 源 */
499
- async function indexSourceOf(env: MarketEnv, base: string): Promise<IndexSource> {
500
- return env.indexSource ?? httpIndexSource(await fetchOf(env), base);
501
- }
502
-
503
- /** 索引时效(顶层 `issued_at` / `expires_at`;缺任一或非数 → null,按 REG-CS2 拒) */
504
- async function indexTimesOfText(text: string): Promise<{ issued_at: number, expires_at: number } | null> {
505
- const g = globalThis as { yaml?: { parse: (t: string) => Promise<unknown> } };
506
- try {
507
- const doc = await g.yaml!.parse(text) as unknown;
508
- if (doc === null || typeof doc !== "object" || Array.isArray(doc))
509
- return null;
510
- const issuedAt = (doc as Record<string, unknown>).issued_at;
511
- const expiresAt = (doc as Record<string, unknown>).expires_at;
512
- if (typeof issuedAt !== "number" || typeof expiresAt !== "number")
513
- return null;
514
- return { issued_at: issuedAt, expires_at: expiresAt };
515
- }
516
- catch {
517
- return null;
518
- }
519
- }
520
-
521
- /** http.user.id → username(operator 历史留痕,uninstall 端点同款反查;查询失败不阻断编排) */
522
282
  async function operatorOf(): Promise<string | null> {
523
283
  const uid = (http as { user?: { id?: unknown } }).user?.id;
524
284
  if (uid == null) {
@@ -579,59 +339,32 @@ interface ResolvedBundle {
579
339
  }
580
340
 
581
341
  /**
582
- * Form B 索引解析(安装器不拼 URL——市场侧职责,installer.ts 同纪律):
583
- * catalog.yaml 定包名 → 分片号(无版本明细)→ 分片内按版本精确取(版本条目即 manifest,
584
- * 原 manifests.yaml 聚合清单并入分片,装一个包不再有第三份全量拉取)→ 分发包 URL =
585
- * base + 条目 archive.file。分片按号**降序**尝试:装箱口径下新版本进新片,最高片持有
586
- * 最新版——latest 安装只拉 1 片(REG-CS5 验收:一次安装索引请求 ≤ 1 catalog + 1 shard)。
342
+ * DB 目录解析(v4 §四:安装解析改走 market_release 表——签名索引机制退役,目录行即 Form B
343
+ * 条目真源;安装器不拼 URL,市场侧职责同纪律)。latest = 未 yanked 的 semver max(API 计算,
344
+ * 非存储列);精确版本被 yank → 409 人话(REG-CS4 语义保留);archive 由服务端组表正名
345
+ * (formBEntryOf 逐字 <name>-<version>.tgz,P2-11 投毒加固语义随服务端构造自然成立)。
346
+ * 分发包 URL = CDN 基址 + file(包字节仍静态拉,不变)。
587
347
  */
588
- async function resolveCdn(env: MarketEnv, base: string, name: string, reqVersion: string | null, fetch: FetchInitFace): Promise<ResolvedBundle> {
589
- const source = httpIndexSource(fetch, base);
590
- // 总目录(catalog)= 代次与时效真源;分片钉值同代才可用(REG-CS1③)
591
- const catalog = await fetchIndex(env, source, base, "catalog");
592
- const { pins, solutions } = await parseCatalog(catalog.text, base);
593
- const entry = solutions.find(s => s.name === name);
594
- if (entry === undefined)
595
- fail(404, `市场索引中无方案「${name}」(${base}/catalog.yaml)`);
596
- // 降序:最高片先试(latest 路径一片即中);精确老版本落在低片时继续往下,直到找到或找遍
597
- for (const no of [...entry.shards].sort((a, b) => b - a)) {
598
- const pin = pins.get(no);
599
- if (pin === undefined)
600
- fail(502, `市场索引 catalog.yaml 钉值缺 shards/${no}.yaml(index_generation_mismatch)——源正在重建索引,请稍后重试`);
601
- const shard = await fetchIndex(env, source, base, "shard", no, pin);
602
- const sol = (await parseShard(shard.text, `shards/${no}.yaml`)).find(s => s.name === name);
603
- if (sol === undefined)
604
- continue;
605
- // REG-CS2 `latest` 单调不降(三级化后移到分片条目):分片声明的 latest 不得低于本片
606
- // 已声明版本中的最高未下线版本。把 latest 往下改是「让站点装回旧版」的最短路径
607
- // (yank 之后回退声明),必须挡在解析处。已下线版本(REG-CS4 yanked)不计入——
608
- // 否则 yank 掉最高版会把 latest 主动下调,被误判成索引回退;latest 本就指向未下线的最高版。
609
- const liveSorted = sol.versions.filter(v => v.yanked !== true).map(v => String(v.version)).sort(compareVersions);
610
- const highest = liveSorted.length === 0 ? null : liveSorted[liveSorted.length - 1]!;
611
- if (sol.latest !== null && highest !== null && compareVersions(sol.latest, highest) < 0)
612
- fail(502, `市场索引中方案「${name}」的 latest 是 ${sol.latest},低于已声明的最高版本 ${highest}(index_latest_regressed)——索引被回退,请核实源状态后再装`);
613
- const version = reqVersion ?? sol.latest;
614
- if (version !== null) {
615
- const hit = sol.versions.find(v => String(v.version) === version);
616
- if (hit !== undefined) {
617
- // REG-CS4 yank:精确指定被下线版本 → 拒装(latest 路径不命中,源方 rebuilt 时 latest 已跳过 yanked);
618
- // 已装实例不受影响(不在此解析路径内)。错误信息明示「版本已下线」而非「无此版本」,避免误导。
619
- if (reqVersion !== null && hit.yanked === true)
620
- fail(409, `方案「${name}」版本「${version}」已被发布方下线(REG-CS4 yank),无法安装;已安装实例不受影响。如需继续请联系发布方或改用其他版本`);
621
- // P2-11 索引投毒加固:archive.file 逐字 <name>-<version>.tgz(索引是签名过的不可信输入,
622
- // 过弱形态可拼宿主任意路径;逐字等值排除 ../、绝对路径、异名、异版本)
623
- const archive = isObj(hit.archive) ? hit.archive : {};
624
- const file = typeof archive.file === "string" ? archive.file : "";
625
- if (file !== `${name}-${version}.tgz`) {
626
- fail(502, `索引分发包 file「${file}」须逐字为 <name>-<version>.tgz(「${name}-${version}.tgz」)——索引投毒加固(P2-11),疑似被篡改的索引`);
627
- }
628
- // 版本条目即 Form B manifest(原 manifests.yaml 并入分片):取到条目即取到 manifest
629
- return { version, archiveUrl: `${base}/${file}`, manifest: hit };
630
- }
631
- }
632
- // 版本不在这片(更低分片或真没有):继续往低片找
633
- }
634
- fail(404, `市场索引中无方案「${name}」版本「${reqVersion ?? "latest"}」(${base}/catalog.yaml → shards)`);
348
+ async function resolveFromDb(base: string, name: string, reqVersion: string | null): Promise<ResolvedBundle> {
349
+ const rows = await db.table("market_release")
350
+ .select(["product", "version", "key_id", "sha256", "size", "yanked", "sig_alg", "sig_value", "sig_public_key", "sig_registry_id", "manifest"])
351
+ .where({ field: "product", op: "eq", value: name })
352
+ .all() as Array<Record<string, unknown>>;
353
+ if (rows.length === 0)
354
+ fail(404, `市场目录中无方案「${name}」——若为存量分发包,请站点 superadmin 先执行目录回填(扫描 cdn/ 存量补录 market_release)后再安装`);
355
+ const live = rows.filter(r => Number(r.yanked) !== 1).map(r => String(r.version)).sort(compareVersions);
356
+ const version = reqVersion ?? (live.length === 0 ? null : live[live.length - 1]!);
357
+ if (version === null)
358
+ fail(409, `方案「${name}」的全部版本均已被发布方下线,无法安装;已安装实例不受影响。如需继续请联系发布方`);
359
+ const row = rows.find(r => String(r.version) === version);
360
+ if (row === undefined)
361
+ fail(404, `市场目录中无方案「${name}」版本「${version}」——可用版本:${live.join("、") || "(无)"}`);
362
+ if (reqVersion !== null && Number(row.yanked) === 1)
363
+ fail(409, `方案「${name}」版本「${version}」已被发布方下线(yank),无法安装;已安装实例不受影响。如需继续请联系发布方或改用其他版本`);
364
+ const entry = formBEntryOf(name, row);
365
+ const archive = isObj(entry.archive) ? entry.archive : {};
366
+ const file = typeof archive.file === "string" ? archive.file : `${name}-${version}.tgz`;
367
+ return { version, archiveUrl: `${base}/${file}`, manifest: entry };
635
368
  }
636
369
 
637
370
  /** 上传载荷字节(upload/api.ts 先例双路径:小文件字节在手,大文件已流式落 blob 须走 blob.get) */
@@ -667,37 +400,6 @@ async function uploadSig(): Promise<Record<string, unknown> | null> {
667
400
  return parsed as Record<string, unknown>;
668
401
  }
669
402
 
670
- /**
671
- * file 源 manifest 提取:上传包先过 R1 三步范式(tar -tvzf 列表 → validateTarListing →
672
- * tar -xzf -C staging)再读 manifest.yaml——未校验不解包,zip-slip 拒在信任边界外。
673
- * 预解包产物与安装器 extracting 段同目录幂等(staging 就位即止,rename 归 lock_commit)。
674
- */
675
- async function manifestOfUpload(env: MarketEnv, bytes: Uint8Array, exec: ExecFace): Promise<unknown> {
676
- const f = fsFace();
677
- const staged = `${env.staging}/upload.tgz`; // 常量名不含用户输入,无穿越面;download 段按 manifest.archive.file 重写正名
678
- await f.mkdir(env.staging, { recursive: true });
679
- await f.writeFile(staged, bytes);
680
- const listed = await exec.run(["tar", "-tvzf", staged]);
681
- if (listed.code !== 0)
682
- fail(400, `上传包列表失败(tar -tvzf):${(listed.stderr || listed.stdout).trim()}——包体损坏?`);
683
- // 铁律②:tar 输出恒带尾随换行,先 trim 尾部空行再 split(extract.ts 同款)
684
- const lines = listed.stdout.replace(/\n+$/, "").split("\n");
685
- const v = validateTarListing(lines);
686
- if (!v.ok)
687
- fail(400, `上传包含非法成员「${v.offender}」——R1 zip-slip 防护拒收(list→validate→extract 三步范式)`);
688
- const extracted = await exec.run(["tar", "-xzf", staged, "-C", env.staging]);
689
- if (extracted.code !== 0)
690
- fail(400, `上传包解包失败(tar -xzf):${(extracted.stderr || extracted.stdout).trim()}`);
691
- await flattenBundleRoot(env.staging);
692
- const g = globalThis as { yaml?: { parse: (text: string) => Promise<unknown> } };
693
- try {
694
- return await g.yaml!.parse(new TextDecoder().decode(await f.readFile(`${env.staging}/manifest.yaml`)));
695
- }
696
- catch (e) {
697
- return fail(400, `上传包 manifest.yaml 不可解析:${String(e)}`);
698
- }
699
- }
700
-
701
403
  /**
702
404
  * 取消安装文件握手(P3-12 端点接线,U5/P3-8 备案①落地):oj isolate 按请求隔离,内存信号
703
405
  * 不共享——改走任务目录下标记文件 `${taskDir}/.cancel-<name>`(跨 isolate 唯一共享面 = FS)。
@@ -730,37 +432,12 @@ async function installBody(env: MarketEnv): Promise<{ task_id: string }> {
730
432
  const bytes = await uploadBytes();
731
433
  if (bytes === null)
732
434
  fail(400, "source=file 须随 multipart 上传分发包(http.files)");
733
- const exec = execOf(env);
734
- manifest = await manifestOfUpload(env, bytes, exec);
735
- const m = isObj(manifest) ? manifest : {};
736
- // 身份:body 权威、manifest 兜底;两者俱在且冲突即拒(防锁名/注册名分叉的坏装)
737
- const mVersion = typeof m.version === "string" ? m.version : "";
738
- if (reqVersion !== null && mVersion !== "" && mVersion !== reqVersion)
739
- fail(400, `body.version「${reqVersion}」与包内 manifest.version「${mVersion}」不一致——请核对分发包`);
740
- const mName = typeof m.name === "string" ? m.name : "";
741
- if (mName !== "" && mName !== name)
742
- fail(400, `body.name「${name}」与包内 manifest.name「${mName}」不一致——请核对分发包`);
743
- if (reqVersion === null && mVersion === "")
744
- fail(400, "version 不可得(body.version 与包内 manifest.version 均缺失)");
745
- // Form A 补摘要(C2-29 布局消费点,P1-13 真机实测——附录 B-10):ojm build --bundle 产物的
746
- // 包内 manifest 是 Form A(archive 仅 file,sha256/size 由 P3 publish 填充于 Form B),而安装器
747
- // verify 段对 archive.sha256 fail-closed。file 源字节在手——上传时点即算摘要补成可校验清单
748
- // (verify 复核同批字节,R10 语义保持);CDN 源不受影响(Form B 真源自带摘要)。
749
- // 正名(P2-11 索引投毒加固):archive.file 逐字 <name>-<version>.tgz——name(C2-01)/version
750
- // (无路径分隔符)拼出的单文件名安全时即正名(download 段按其落盘);否则退回常量名
751
- // upload.tgz(此刻尚未过预检,恶意命名不提前 400 也不给穿越面——预检稍后照拒)。
752
- const effectiveVersion = reqVersion ?? mVersion;
753
- const namedFile = /^[a-z][a-z0-9-]{0,63}$/.test(name) && /^[0-9A-Z.-]+$/i.test(effectiveVersion) && !effectiveVersion.includes("..")
754
- ? `${name}-${effectiveVersion}.tgz`
755
- : "upload.tgz";
756
- m.archive = { file: namedFile, sha256: sha256Of(env)(bytes), size: bytes.byteLength };
757
- // detached 侧车签名注入(P2-3,G4 补裁):同目录 .sig 随 multipart 第二文件上传——
758
- // 与 Form B archive.signature 同结构注入,verify 段统一验签(六码分流)
435
+ // Form A 补摘要 + 正名 + 侧车注入:local-archive.ts 共享管线(bundle 收敛同用;
436
+ // C2-29/P2-11/P2-3 口径注释全在该处,此处不重复)
759
437
  const sig = await uploadSig();
760
- if (sig !== null) {
761
- m.archive = { ...(m.archive as Record<string, unknown>), signature: sig };
762
- }
763
- version = reqVersion ?? mVersion;
438
+ const local = await localArchiveManifestOf({ stagingDir: env.staging, exec: execOf(env), sha256Hex: sha256Of(env) }, { name, reqVersion, bytes, sig });
439
+ manifest = local.manifest;
440
+ version = local.version;
764
441
  archiveUrl = `${env.staging}/upload.tgz`;
765
442
  fetch = async () => ({ ok: true, status: 200, arrayBuffer: async () => bytes.slice().buffer as ArrayBuffer });
766
443
  }
@@ -770,7 +447,7 @@ async function installBody(env: MarketEnv): Promise<{ task_id: string }> {
770
447
  fail(503, "未配置市场地址(framework.market_cdn_url)——市场安装不可用,请先在系统设置配置 CDN 基址");
771
448
  // REG-CS6:安装全程(相对基址补全 + 冒烟探针)都需 canonical origin;缺失/非法即明确报错
772
449
  await requireCanonicalOrigin(env);
773
- const r = await resolveCdn(env, base, name, reqVersion, await fetchOf(env));
450
+ const r = await resolveFromDb(base, name, reqVersion);
774
451
  manifest = r.manifest;
775
452
  version = r.version;
776
453
  archiveUrl = r.archiveUrl;
@@ -838,6 +515,76 @@ async function installBody(env: MarketEnv): Promise<{ task_id: string }> {
838
515
  }
839
516
  }
840
517
 
518
+ /**
519
+ * 随站 bundle 收敛执行(Phase B T3b):读 cdn/bundles.json 意图标记,按上拉不下拉
520
+ * 规则对齐第一方包(纯编排在 _platform bundle-converge.ts)。本地归档安装与
521
+ * installBody 同纪律(Form A 补摘要共享管线 + runInstall 组装);失败一律吞咽——
522
+ * 收敛是增强面,永不阻断消费端(方案 §5「失败降级不阻断」)。
523
+ */
524
+ async function runBundleConverge(env: MarketEnv): Promise<void> {
525
+ try {
526
+ const cdnDir = env.cdnDir ?? "cdn";
527
+ const r = await convergeBundledSolutions({
528
+ markerPath: `${cdnDir}/bundles.json`,
529
+ cdnDir,
530
+ statePath: env.statePath,
531
+ installLocal: (entry, archive) => installBundledArchive(env, entry, archive),
532
+ // 失败冷却(终审 Important-2):持久失败(如 BOOTSTRAP_KEY 未回填期的 needs_trust、
533
+ // 包被篡改)不在每次 index 请求全量重装——10 分钟窗内跳过,窗外自愈重试
534
+ cooldownPath: `${env.taskDir}/.bundle-converge-cooldown.json`,
535
+ });
536
+ // 失败观测(终审 Important-2):冷却窗内每请求重试一次记一条审计,超窗后安静——
537
+ // 连续失败有痕可查,不刷爆 market_history
538
+ for (const failed of r.failed) {
539
+ const m = failed.match(/^([a-z][a-z0-9-]{0,63})@([^(]+)(/);
540
+ if (m)
541
+ await recordHistory({ solution: m[1]!, version: m[2]!, action: "install", status: "failed", operator: "bundle-converge", message: `随站 bundle 收敛失败(10 分钟后自愈重试):${failed}` });
542
+ }
543
+ }
544
+ catch {
545
+ // marker 损坏/状态不可读等静默——下次对账再试
546
+ }
547
+ }
548
+
549
+ /** 本地归档安装:manifest 补全 → bootstrap 播种 → runInstall(operator=机器身份留痕) */
550
+ async function installBundledArchive(env: MarketEnv, entry: BundledEntry, archive: { bytes: Uint8Array, sig: unknown | null }): Promise<void> {
551
+ const { manifest, version } = await localArchiveManifestOf(
552
+ { stagingDir: env.staging, exec: execOf(env), sha256Hex: sha256Of(env) },
553
+ { name: entry.name, reqVersion: entry.version, bytes: archive.bytes, sig: archive.sig },
554
+ );
555
+ await seedBootstrapKey(env.trustPath, env.bootstrapKey !== undefined ? env.bootstrapKey : BOOTSTRAP_KEY);
556
+ const bytes = archive.bytes;
557
+ await runInstall({
558
+ manifest,
559
+ name: entry.name,
560
+ version,
561
+ operator: "bundle-converge",
562
+ state: await createStateStore(env.statePath),
563
+ archiveUrl: `${env.staging}/bundle-${entry.name}.tgz`,
564
+ sharedRoot: env.sharedRoot,
565
+ staging: env.staging,
566
+ taskDir: env.taskDir,
567
+ dbPath: env.dbPath,
568
+ siteModulesDir: env.siteModulesDir,
569
+ modulesJsonPath: env.modulesJsonPath,
570
+ apiPrefix: env.apiPrefix,
571
+ configPath: env.configPath,
572
+ fetch: async () => ({ ok: true, status: 200, arrayBuffer: async () => bytes.slice().buffer as ArrayBuffer }),
573
+ exec: execOf(env),
574
+ reload: reloadOf(env),
575
+ sha384B64: sha384Of(env),
576
+ sha256Hex: env.sha256Hex,
577
+ verifyEd25519: env.verifyEd25519,
578
+ ledgerHead: env.ledgerHeadOf ? await env.ledgerHeadOf(entry.name) : (env.ledgerHead ?? null),
579
+ hostVersions: await hostVersionsOf(env),
580
+ hostApiVersion: API_VERSION,
581
+ capabilities: capabilitiesOf(),
582
+ trustPath: env.trustPath,
583
+ force: false,
584
+ registryId: env.registryId,
585
+ });
586
+ }
587
+
841
588
  interface IndexRow {
842
589
  name: string
843
590
  latest: string | null
@@ -866,143 +613,102 @@ interface IndexRow {
866
613
  operator: string | null
867
614
  }
868
615
 
869
- /** 最近一次安装操作人(回落 market_history 行;表不存在/查询失败/无行 → null,观测面不阻断索引) */
870
- async function lastInstallOperatorOf(name: string): Promise<string | null> {
616
+ /**
617
+ * 一页产品的最近一次安装操作人(批量版,minor M6:一次 op:"in" 查询替代逐产品串行——
618
+ * 倒序下每产品首行即最新;表不存在/查询失败 → 空 Map,观测面不阻断索引,全员 null)。
619
+ */
620
+ async function lastInstallOperatorsOf(names: string[]): Promise<Map<string, string | null>> {
621
+ const map = new Map<string, string | null>();
622
+ if (names.length === 0)
623
+ return map;
871
624
  try {
872
625
  const rows = await db.table("market_history")
873
- .select(["operator"])
874
- .where({ field: "solution", op: "eq", value: name })
626
+ .select(["solution", "operator"])
875
627
  .where({ field: "action", op: "eq", value: "install" })
628
+ .where({ field: "solution", op: "in", value: names })
876
629
  .orderBy([{ field: "created_at", dir: "desc" }, { field: "rowid", dir: "desc" }])
877
- .limit(1)
878
- .all() as Array<{ operator: unknown }>;
879
- const op = rows[0]?.operator;
880
- return typeof op === "string" ? op : null;
630
+ .all() as Array<{ solution: unknown, operator: unknown }>;
631
+ for (const r of rows) {
632
+ const name = String(r.solution);
633
+ if (!map.has(name))
634
+ map.set(name, typeof r.operator === "string" ? r.operator : null);
635
+ }
881
636
  }
882
637
  catch {
883
- return null;
638
+ // 观测面失败不阻断索引(与单查版同口径)
884
639
  }
640
+ return map;
885
641
  }
886
642
 
887
643
  /**
888
- * GET index 主体:CDN catalog + shards ⊕ 本地 sidecar state——CDN 序优先,本地独有方案殿后。
889
- *
890
- * 三级化后市场页的拉取形态:1 catalog + catalog 指向的全部分片(去重)——`available`
891
- * 回滚候选要版本全集,故页面比安装多拉几片;安装侧仍 ≤ 1 catalog + 1 shard(resolveCdn)。
892
- * 版本条目即 Form B manifest:latest 的 changelog/publisher/summary/description 直接从
893
- * 分片条目取,不再有独立的聚合清单拉取。分片拉取/验签失败即 502(REG-CS1:可用性不再
894
- * 压过完整性)。
644
+ * GET index 主体(v4 §四:DB 目录分页数据源)——market_release 行按 product 聚合 ⊕ 本地
645
+ * sidecar state join(installed/enabled/trust_state/key_id 是站点本地语义,目录 API 不带)。
646
+ * 行字段口径不变(展示字段不变,卡片 UI 加翻页):latest = 未 yanked semver max(API 计算,
647
+ * 非存储列);changelog/publisher/summary/description/license 取 latest 版 manifest 列
648
+ * (manifestSummaryOf JSON 解析,缺字段 → null);publisher_key = latest 行 key_id(发布者
649
+ * 身份单一真相);available = 未 yanked 版本升序全集(回滚候选)。规模小 → 全量行 JS 聚合 +
650
+ * 内存分页(catalog 同口径);本地独有方案(DB 无行)殿后。对账即服务(P1-10 接线③)不变。
895
651
  */
896
- interface IndexSnapshot {
897
- base: string
898
- /** catalog 的签名时效(发布时间兜底;索引缺时效 → null) */
899
- issuedAt: number | null
900
- solutions: Array<{ name: string, shards: number[] }>
901
- shardDocs: Map<number, ShardSolution[]>
902
- }
903
-
904
- /**
905
- * 读整份签名索引(catalog + 它指向的全部分片)。
906
- *
907
- * 市场卡片(indexBody)与发布历史(syncReleases)都要「全量版本视图」,读取与验签只有这一份:
908
- * 两处各写一遍 `fetchIndex` 循环,迟早漂移成「卡片看得见、历史看不见」。
909
- */
910
- async function readIndexSnapshot(env: MarketEnv): Promise<IndexSnapshot> {
911
- const base = await cdnBaseOf(env);
912
- if (base === null)
913
- fail(503, "未配置市场地址(framework.market_cdn_url)——卡片区显示「未配置市场地址」(PRD §5.3)");
914
- await assertOriginForRelativeBase(env, base);
915
- const source = await indexSourceOf(env, base);
916
- const catalog = await fetchIndex(env, source, base, "catalog");
917
- const times = await indexTimesOfText(catalog.text);
918
- const { pins, solutions } = await parseCatalog(catalog.text, base);
919
- // 拉取 catalog 指向的全部分片(去重、升序确定性);每片都过签名 + 钉值同代校验
920
- const shardDocs = new Map<number, ShardSolution[]>();
921
- for (const no of [...pins.keys()].sort((a, b) => a - b)) {
922
- const shard = await fetchIndex(env, source, base, "shard", no, pins.get(no)!);
923
- shardDocs.set(no, await parseShard(shard.text, `shards/${no}.yaml`));
924
- }
925
- return { base, issuedAt: times?.issued_at ?? null, solutions, shardDocs };
926
- }
927
-
928
- async function indexBody(env: MarketEnv): Promise<IndexRow[]> {
652
+ async function indexBody(env: MarketEnv, q: Record<string, unknown>): Promise<{ list: IndexRow[], total: number, page: number, size: number }> {
653
+ // 随站 bundle 收敛(Phase B T3b,「对账即服务」同源):首装/升级 riding 首个 index 请求——
654
+ // oj 启动段 ext_boot 有 2s 看门狗(oj-bin runtime.rs BOOT_TIMEOUT),安装全程不可在 boot
655
+ // 内跑;此处幂等(已对齐即全 skip),失败吞咽不阻断索引(方案 §5 失败降级)
656
+ await runBundleConverge(env);
929
657
  // 对账即服务(P1-10 接线③):市场页刷新即对账时点——锁 ↔ 账本 head 自愈 + enabled 不一致
930
658
  // 自动修复先行(失败只记日志,不阻断索引),失败站点的市场页每次打开都在尝试自愈
931
659
  await reconcileOf(env);
932
- const { solutions, shardDocs } = await readIndexSnapshot(env);
660
+ const { page, size, offset } = pageArgs({ page: q.page, size: q.size });
661
+ const rows = await db.table("market_release")
662
+ .select(["product", "version", "key_id", "yanked", "manifest"])
663
+ .all() as Array<Record<string, unknown>>;
664
+ const byProduct = new Map<string, Array<Record<string, unknown>>>();
665
+ for (const r of rows) {
666
+ const product = String(r.product);
667
+ const list = byProduct.get(product) ?? [];
668
+ list.push(r);
669
+ byProduct.set(product, list);
670
+ }
933
671
  const local = (await createStateStore(env.statePath)).read().solutions;
934
- const rows: IndexRow[] = [];
935
- const seen = new Set<string>();
936
- for (const entry of solutions) {
937
- const name = entry.name;
938
- if (seen.has(name))
939
- continue;
940
- seen.add(name);
941
- // 合并该包全部分片的版本条目(降序拼:最高片持有最新版,latest 取首片即得)
942
- const merged: Array<Record<string, unknown>> = [];
943
- let latest: string | null = null;
944
- for (const no of [...entry.shards].sort((a, b) => b - a)) {
945
- const sol = shardDocs.get(no)?.find(s => s.name === name);
946
- if (sol === undefined)
947
- continue;
948
- if (latest === null)
949
- latest = sol.latest;
950
- merged.push(...sol.versions);
951
- }
672
+ // 本地独有(DB 无行)也列出:目录表空 ≠ 本地没装(回填前旧世界)
673
+ for (const name of Object.keys(local)) {
674
+ if (!byProduct.has(name))
675
+ byProduct.set(name, []);
676
+ }
677
+ const names = [...byProduct.keys()].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
678
+ const total = names.length;
679
+ // operator 批量预取(一次 in 查询;缺产品 → null,与逐行单查口径一致)
680
+ const operators = await lastInstallOperatorsOf(names.slice(offset, offset + size));
681
+ const list: IndexRow[] = [];
682
+ for (const name of names.slice(offset, offset + size)) {
683
+ const vers = byProduct.get(name)!;
952
684
  const side = local[name];
953
- // available:版本全集(缺段/畸形 → [] 降级);分片内升序、跨片重排为全局升序
954
- const available = merged.map(v => typeof v.version === "string" ? v.version : "").filter(x => x !== "").sort(compareVersions);
955
- const manifestOf = (version: string | null): Record<string, unknown> | undefined =>
956
- version === null ? undefined : merged.find(v => String(v.version) === version);
957
- const m = manifestOf(latest) ?? manifestOf(side?.version ?? null);
958
- // 发布者:取最新版 Form B archive.signature.key_id(Ed25519 验签过的签名身份,离线可验);
959
- // manifest.author 是自报字段,二者不同。缺签名/畸形 → null(不拿自报值冒充)。
960
- const rawArchive: unknown = m?.archive;
961
- const rawSig: unknown = isObj(rawArchive) ? rawArchive.signature : undefined;
962
- const publisherKey = isObj(rawSig) && typeof rawSig.key_id === "string" ? rawSig.key_id : null;
963
- rows.push({
685
+ const live = vers.filter(r => Number(r.yanked) !== 1).map(r => String(r.version)).sort(compareVersions);
686
+ const latest = live.length === 0 ? null : live[live.length - 1]!;
687
+ const latestRow = latest === null ? undefined : vers.find(r => String(r.version) === latest);
688
+ const installedRow = side === undefined ? undefined : vers.find(r => String(r.version) === side.version);
689
+ const m = manifestSummaryOf(latestRow) ?? manifestSummaryOf(installedRow);
690
+ list.push({
964
691
  name,
965
692
  latest,
966
693
  installed: side?.version ?? null,
967
694
  enabled: side?.enabled ?? null,
968
695
  upgradable: latest !== null && side !== undefined && compareVersions(side.version, latest) < 0,
969
- available,
696
+ available: live,
970
697
  changelog: m?.changelog ?? null,
971
698
  publisher: typeof m?.author === "string" ? m.author : null,
972
- publisher_key: publisherKey,
699
+ publisher_key: latestRow?.key_id != null ? String(latestRow.key_id) : null,
973
700
  license: typeof m?.license === "string" ? m.license : null,
974
701
  summary: typeof m?.summary === "string" ? m.summary : null,
975
702
  description: typeof m?.description === "string" ? m.description : null,
976
703
  trust_state: side?.trust_state ?? null,
977
704
  key_id: side?.archive?.key_id ?? null,
978
- operator: await lastInstallOperatorOf(name),
705
+ operator: operators.get(name) ?? null,
979
706
  });
980
707
  }
981
- for (const [name, side] of Object.entries(local)) {
982
- if (!seen.has(name)) {
983
- rows.push({
984
- name,
985
- latest: null,
986
- installed: side.version,
987
- enabled: side.enabled,
988
- upgradable: false,
989
- available: [],
990
- changelog: null,
991
- publisher: null,
992
- publisher_key: null,
993
- license: null,
994
- summary: null,
995
- description: null,
996
- trust_state: side.trust_state ?? null,
997
- key_id: side.archive?.key_id ?? null,
998
- operator: await lastInstallOperatorOf(name),
999
- });
1000
- }
1001
- }
1002
- return rows;
708
+ return { list, total, page, size };
1003
709
  }
1004
710
 
1005
- /** 发布历史行(市场索引里出现过的每个 product@version) */
711
+ /** 发布历史行(market_release 上传写入行;v4 §四:数据源换 DB 直读,索引 syncReleases 退役) */
1006
712
  interface ReleaseRow {
1007
713
  id: number
1008
714
  product: string
@@ -1011,101 +717,193 @@ interface ReleaseRow {
1011
717
  sha256: string | null
1012
718
  size: number | null
1013
719
  published_at: number
1014
- /** 时间来源:file = tgz 的 Last-Modified;index = 索引生成时间兜底 */
1015
- published_at_source: "file" | "index"
720
+ /** 时间来源:file = 回填扫描读 tgz mtime;upload = 上传端点接收时刻(DB 目录化后唯一真源口径) */
721
+ published_at_source: "file" | "upload"
1016
722
  yanked: number
1017
723
  }
1018
724
 
725
+ // ── v4 §三:目录 JSON API(匿名可读,DB 直读合成单协议 shape)────────────────────────────
726
+ // 市场目录真源 = market_release 表(上传端点直写);catalog 清单聚合 + detail 单包详情
727
+ // 喂同一 Form B 条目 shape(与历史 shards 条目同构),远端市场走 HTTP、本站直读 DB——
728
+ // 「单协议」指 shape 统一,非传输统一。Cache-Control: no-store(防中间代理缓存陈旧目录)。
729
+ // 错误文案卫生:匿名读端点的 message 不得含路径/SQL/内部拓扑。
730
+
1019
731
  /**
1020
- * 取单版本的「发布时间」。
1021
- *
1022
- * 优先 HEAD 分发包读 `Last-Modified`——那是包落进 CDN 的时刻,也就是真正的发版时间。
1023
- * 读不到(源不吐该头 / 网络失败 / file 名缺失)才回落到索引生成时间:`ojm market publish`
1024
- * 与 `build-index` 在推荐流程里紧邻执行,两者差值只有重建索引的耗时。
1025
- *
1026
- * 只对**首次入库**的版本调一次(已入库的 published_at 不再改写)——历史一旦记录就是事实,
1027
- * 不该被后续重新发布/重新建索引刷新。
732
+ * 组表 Form B 版本条目:manifest 列(上传端点抽取物)为底,archive 由服务端组表——
733
+ * 存量 manifest 内嵌 archive.file 不可信(防旧行/直写行夹带路径穿越),逐字正名
734
+ * <name>-<version>.tgz(P2-11 索引投毒加固同源);签名 key_id 以行 key_id 为准
735
+ * (发布者身份单一真相;加密学校验在 install verify 段用 public_key + canonical)。
1028
736
  */
1029
- async function archivePublishedAt(env: MarketEnv, base: string, file: unknown, fallback: number): Promise<{ at: number, source: "file" | "index" }> {
1030
- if (typeof file !== "string" || file === "")
1031
- return { at: fallback, source: "index" };
1032
- try {
1033
- // HEAD 不在 FetchInitFace 的最小形状里(安装面只需要 GET),但运行时 fetch 原生支持:
1034
- // 这里显式窄化一次,避免为「读一个头」再造第二种下载实现。
1035
- const head = await fetchOf(env) as unknown as (url: string, init?: { method?: string }) => Promise<{ headers?: { get: (n: string) => string | null } }>;
1036
- const res = await head(`${base}/${encodeURIComponent(file)}`, { method: "HEAD" });
1037
- const raw = res.headers?.get?.("last-modified") ?? res.headers?.get?.("Last-Modified") ?? null;
1038
- if (raw !== null && raw !== "") {
1039
- const at = Math.floor(Date.parse(raw) / 1000);
1040
- if (Number.isFinite(at) && at > 0)
1041
- return { at, source: "file" };
737
+ function formBEntryOf(product: string, row: Record<string, unknown>): Record<string, unknown> {
738
+ const file = `${product}-${String(row.version)}.tgz`;
739
+ let stored: Record<string, unknown> | null = null;
740
+ if (typeof row.manifest === "string" && row.manifest !== "") {
741
+ try {
742
+ const parsed: unknown = JSON.parse(row.manifest);
743
+ if (isObj(parsed))
744
+ stored = parsed;
745
+ }
746
+ catch {
747
+ stored = null; // 坏行不猜:回退最小形态(观测面降级,不整页 500)
1042
748
  }
1043
749
  }
1044
- catch {}
1045
- return { at: fallback, source: "index" };
750
+ const base: Record<string, unknown> = stored ?? { name: product, version: String(row.version) };
751
+ // archive 服务端组表(base 底展开后整体覆盖):存量 manifest 内嵌 archive.file 是包作者
752
+ // 自报字段不可信(可能 stale/穿越形态)——file 逐字正名 <name>-<version>.tgz(构造即校验,
753
+ // P2-11 索引投毒加固的组表处承继);sha256/size/签名取行列(上传端点服务端重算值)。
754
+ const archive: Record<string, unknown> = { file };
755
+ if (typeof row.sha256 === "string")
756
+ archive.sha256 = row.sha256;
757
+ if (typeof row.size === "number")
758
+ archive.size = row.size;
759
+ if (typeof row.sig_alg === "string" && row.sig_alg !== "")
760
+ archive.signature = { alg: row.sig_alg, key_id: row.key_id, public_key: row.sig_public_key, value: row.sig_value, canon: "v2", registry_id: typeof row.sig_registry_id === "string" ? row.sig_registry_id : "" };
761
+ return { ...base, name: product, version: String(row.version), yanked: Number(row.yanked) === 1, archive };
762
+ }
763
+
764
+ /** market_release 行 → manifest 摘要对象(catalog 展示字段源;NULL/坏行 → null 降级) */
765
+ function manifestSummaryOf(row: Record<string, unknown> | undefined): Record<string, unknown> | null {
766
+ if (row === undefined)
767
+ return null;
768
+ if (typeof row.manifest !== "string" || row.manifest === "")
769
+ return null;
770
+ try {
771
+ const parsed: unknown = JSON.parse(row.manifest);
772
+ return isObj(parsed) ? parsed : null;
773
+ }
774
+ catch {
775
+ return null;
776
+ }
1046
777
  }
1047
778
 
1048
779
  /**
1049
- * 把索引里出现过的版本同步进 `market_release`(幂等:只补新行)。
1050
- *
1051
- * 为什么不做成「发版时由 CLI 上报」:`ojm market publish` 是纯本地操作(不登录、可离线),
1052
- * 加一条上报链路就得再造一套写端授权的信任面;而**签名索引本身就是发版事实**——
1053
- * 站点已经在读它,历史直接从这里落库,既不用新协议,也不会出现「上报成功但包里没有」的分叉。
1054
- *
1055
- * 索引拉取/验签失败由调用方兜住(历史是观测面,不阻断页面:返回已入库行 + syncError)。
780
+ * GET /market/catalog 主体:全量行内存聚合(规模小,真分页留待规模信号)+ 内存分页。
781
+ * 清单不含 yanked 行(全 yanked 的 product 整项消失);latest = 未 yanked 的 semver max
782
+ * (API 计算,非存储列——index_latest_regressed 守卫随索引退役自然消失);排序键钉死
783
+ * published_at desc, product asc(产品级 published_at 取其版本最大值 = 最近发布时刻;
784
+ * 键唯一确定性 ⇒ 翻页不重复不丢条目);page/size 复用 pageArgs(非法收敛、size 钳 200、
785
+ * 默认 20)。响应 PageResult 惯例的 page/size 回显 + truncated 截断标记,items 为协议字段名。
1056
786
  */
1057
- async function syncReleases(env: MarketEnv): Promise<void> {
1058
- const snapshot = await readIndexSnapshot(env);
1059
- const fallbackAt = snapshot.issuedAt ?? (env.now?.() ?? Math.floor(Date.now() / 1000));
1060
- const now = env.now?.() ?? Math.floor(Date.now() / 1000);
787
+ async function catalogBody(env: Pick<MarketEnv, "registryId" | "configPath">, q: Record<string, unknown>): Promise<Record<string, unknown>> {
788
+ const { page, size, offset } = pageArgs({ page: q.page, size: q.size });
789
+ const rows = await db.table("market_release")
790
+ .select(["product", "version", "key_id", "published_at", "yanked", "manifest"])
791
+ .all() as Array<Record<string, unknown>>;
792
+ // 聚合:product → 未 yanked 行组(清单不含 yanked 行)
793
+ const byProduct = new Map<string, Array<Record<string, unknown>>>();
794
+ for (const r of rows) {
795
+ if (Number(r.yanked) === 1)
796
+ continue;
797
+ const list = byProduct.get(String(r.product)) ?? [];
798
+ list.push(r);
799
+ byProduct.set(String(r.product), list);
800
+ }
801
+ const items = [...byProduct.entries()].map(([product, list]) => {
802
+ const bySemver = [...list].sort((a, b) => compareVersions(String(a.version), String(b.version)));
803
+ const latestRow = bySemver[bySemver.length - 1]!;
804
+ const summary = manifestSummaryOf(latestRow);
805
+ return {
806
+ product,
807
+ latest: String(latestRow.version),
808
+ publisher: typeof latestRow.key_id === "string" ? latestRow.key_id : null,
809
+ versions_count: list.length,
810
+ ...(typeof summary?.summary === "string" ? { summary: summary.summary } : {}),
811
+ _at: Math.max(...list.map(r => Number(r.published_at))),
812
+ };
813
+ });
814
+ items.sort((a, b) => (b._at - a._at) || (a.product < b.product ? -1 : a.product > b.product ? 1 : 0));
815
+ const total = items.length;
816
+ const slice = items.slice(offset, offset + size).map(({ _at: _ignoredAt, ...item }) => {
817
+ void _ignoredAt;
818
+ return item;
819
+ });
820
+ (json as { header: (k: string, v: string) => void }).header("Cache-Control", "no-store");
821
+ // registry_id(签名推送 canonical 首段 = 站点身份)随目录暴露——CLI 推送前发现面;
822
+ // 解析口径与上传/yank 端点验面同源(注入优先,回落 config vars.registry_id)
823
+ const registryId = env.registryId ?? await readSiteVar(env.configPath ?? "config.yaml", "registry_id");
824
+ return { api_version: CATALOG_API_VERSION, registry_id: registryId, page, size, total, truncated: offset + slice.length < total, items: slice };
825
+ }
1061
826
 
1062
- const existingRows = await db.table("market_release").select(["id", "product", "version", "yanked"]).all() as Array<Record<string, unknown>>;
1063
- const existing = new Map(existingRows.map(r => [`${String(r.product)}@${String(r.version)}`, { id: Number(r.id), yanked: Number(r.yanked) }]));
827
+ /**
828
+ * GET /market/catalog/detail 主体:按 name 取全版本(详情**保留 yanked 条目**——安装侧
829
+ * 「精确版本拒装 409 人话」依赖条目可见 + yanked 标记;清单摘除/详情保留分离保全两侧
830
+ * 语义)。versions semver 降序;latest = 未 yanked semver max(全 yanked → null);
831
+ * ?version= 精确过滤(无匹配 → 空数组,200;404 只对未知 name)。
832
+ */
833
+ async function detailBody(q: Record<string, unknown>): Promise<Record<string, unknown>> {
834
+ const name = String(q.name ?? "").trim();
835
+ if (name === "")
836
+ fail(400, "name is required");
837
+ const rows = await db.table("market_release")
838
+ .select(["product", "version", "key_id", "sha256", "size", "yanked", "sig_alg", "sig_value", "sig_public_key", "sig_registry_id", "manifest"])
839
+ .where({ field: "product", op: "eq", value: name })
840
+ .all() as Array<Record<string, unknown>>;
841
+ if (rows.length === 0)
842
+ fail(404, `市场目录中无方案「${name}」`);
843
+ const versionFilter = q.version == null || q.version === "" ? null : String(q.version);
844
+ const versions = rows
845
+ .filter(r => versionFilter === null || String(r.version) === versionFilter)
846
+ .map(r => formBEntryOf(name, r))
847
+ .sort((a, b) => compareVersions(String(b.version), String(a.version)));
848
+ const live = versions.filter(v => v.yanked !== true).map(v => String(v.version)).sort(compareVersions);
849
+ (json as { header: (k: string, v: string) => void }).header("Cache-Control", "no-store");
850
+ return { product: name, latest: live.length === 0 ? null : live[live.length - 1]!, versions };
851
+ }
1064
852
 
1065
- for (const sol of snapshot.shardDocs.values()) {
1066
- for (const entry of sol) {
1067
- for (const v of entry.versions) {
1068
- const version = typeof v.version === "string" ? v.version : "";
1069
- if (version === "")
1070
- continue;
1071
- const dedupeKey = `${entry.name}@${version}`;
1072
- const yanked = v.yanked === true ? 1 : 0;
1073
- const prior = existing.get(dedupeKey);
1074
- if (prior !== undefined) {
1075
- // 已入库的 published_at 是事实、不再改写;但 yank 状态是「当前是否在架」,跟随索引
1076
- if (prior.yanked !== yanked)
1077
- await db.table("market_release").update({ yanked }).where({ field: "id", op: "eq", value: prior.id }).run();
1078
- continue;
1079
- }
1080
- const archive = isObj(v.archive) ? v.archive : {};
1081
- const sig = isObj(archive.signature) ? archive.signature : {};
1082
- const { at, source } = await archivePublishedAt(env, snapshot.base, archive.file, fallbackAt);
1083
- try {
1084
- await db.table("market_release").insert({
1085
- product: entry.name,
1086
- version,
1087
- key_id: typeof sig.key_id === "string" ? sig.key_id : null,
1088
- sha256: typeof archive.sha256 === "string" ? archive.sha256 : null,
1089
- size: typeof archive.size === "number" ? archive.size : null,
1090
- published_at: at,
1091
- published_at_source: source,
1092
- yanked,
1093
- first_seen_at: now,
1094
- }).run();
1095
- existing.set(dedupeKey, { id: -1, yanked });
1096
- }
1097
- catch {
1098
- // 并发同步(两个页面同时打开)撞唯一键:另一路已写入,跳过即可
1099
- }
1100
- }
1101
- }
853
+ // ── v4 §三:yank 端点(签名下线/恢复,目录行级)───────────────────────────────────────────
854
+ // POST /market/yank:body { product, version, reason?, by?, restore? } + X-OJM 头签名。
855
+ // canonical `market-yank:<registry_id>:<product>:<version>`(无内容哈希——重放退化为幂等,
856
+ // 同上传端点推理);授权 = key_id 须匹配行 key_id(发布者本人;不符 403,遗留 NULL 行
857
+ // 走 superadmin 管理通道);版本不存在 404;重复 yank/unyank 幂等 200。yank 不动已装方案
858
+ // (G5 口径)——仅目录摘除 + 阻断新装/升级(安装解析的 yanked 精确版本拒装 409 人话,
859
+ // §四)。审计 by/reason 落 market_history(operator = key_id)。
860
+ const YANK_PRODUCT_RE = /^[a-z][a-z0-9-]{0,63}$/; // 与 cdn 上传白名单同源口径(C2-01)
861
+ const YANK_VERSION_RE = /^\d[0-9A-Z.+-]{0,63}$/i;
862
+
863
+ async function yankBody(env: MarketEnv): Promise<Record<string, unknown>> {
864
+ const body = isObj(http.body) ? http.body : {};
865
+ const product = String(body.product ?? "").trim();
866
+ const version = String(body.version ?? "").trim();
867
+ if (!YANK_PRODUCT_RE.test(product) || !YANK_VERSION_RE.test(version))
868
+ fail(400, `product/version 非法:「${product}@${version}」——product ^[a-z][a-z0-9-]{0,63}$、版本数字开头`);
869
+ const restore = body.restore === true;
870
+ const reason = typeof body.reason === "string" ? body.reason.slice(0, 200) : "";
871
+ const by = typeof body.by === "string" ? body.by.slice(0, 64) : "";
872
+ // 鉴权(与上传端点共享验面:头 → 信任库 → 限速 → registry_id)+ canonical 验签
873
+ const { keyId, publicKey, registryId, signature } = await authenticateSignedKey({ trustPath: env.trustPath, registryId: env.registryId, configPath: env.configPath, verifyEd25519: env.verifyEd25519 }, "market-yank");
874
+ const canonical = `market-yank:${registryId}:${product}:${version}`;
875
+ if (!verifyCanonicalSig({ verifyEd25519: env.verifyEd25519 }, publicKey, canonical, signature))
876
+ fail(401, "yank 签名验签失败:canonical 不符(字节级重放防护由内容寻址承载,yank 重放退化为幂等)——已拒");
877
+ const rows = await db.table("market_release")
878
+ .select(["id", "key_id", "yanked"])
879
+ .where({ field: "product", op: "eq", value: product })
880
+ .where({ field: "version", op: "eq", value: version })
881
+ .limit(1)
882
+ .all() as Array<{ id: unknown, key_id: unknown, yanked: unknown }>;
883
+ if (rows.length === 0)
884
+ fail(404, `市场目录中无「${product}@${version}」——无法下线/恢复不存在的版本`);
885
+ const row = rows[0]!;
886
+ const auditMsg = `by=${by === "" ? keyId : by}${reason === "" ? "" : ` reason=${reason}`}`;
887
+ if (row.key_id == null || row.key_id === "") {
888
+ await recordHistory({ solution: product, version, action: restore ? "unyank" : "yank", status: "failed", operator: keyId, message: `403 该行无发布者(遗留行):${auditMsg}` });
889
+ fail(403, `「${product}@${version}」是遗留目录行(无发布者绑定)——yank/恢复请走 superadmin 管理通道`);
890
+ }
891
+ if (row.key_id !== keyId) {
892
+ await recordHistory({ solution: product, version, action: restore ? "unyank" : "yank", status: "failed", operator: keyId, message: `403 非发布者(行 key_id=${String(row.key_id)}):${auditMsg}` });
893
+ fail(403, `key「${keyId}」不是「${product}@${version}」的发布者(发布 key = ${String(row.key_id)})——仅发布者本人可下线/恢复,换主 = 站点 superadmin 操作`);
1102
894
  }
895
+ const target = restore ? 0 : 1;
896
+ if (Number(row.yanked) === target)
897
+ return { ok: true, product, version, yanked: target === 1, idempotent: true };
898
+ await db.table("market_release").update({ yanked: target }).where({ field: "id", op: "eq", value: row.id }).run();
899
+ await recordHistory({ solution: product, version, action: restore ? "unyank" : "yank", status: "success", operator: keyId, message: auditMsg });
900
+ return { ok: true, product, version, yanked: target === 1 };
1103
901
  }
1104
902
 
1105
903
  /** 四端点组(oj v0.1.64 嵌套组:组名 = 路径段);全业务体经 respond 收口(status 语义不落 500) */
1106
904
  export function createMarketApi(env: MarketEnv) {
1107
905
  return {
1108
- // POST /api/_platform/market/install —— 安装编排(superadmin,R9)
906
+ // POST /api/oj-market/install —— 安装编排(superadmin,R9)
1109
907
  "install": {
1110
908
  async post() {
1111
909
  await respond(async () => {
@@ -1113,7 +911,7 @@ export function createMarketApi(env: MarketEnv) {
1113
911
  return await installBody(env);
1114
912
  });
1115
913
  },
1116
- // POST /api/_platform/market/install/cancel —— 取消进行中的安装(superadmin,U5/P3-12)。
914
+ // POST /api/oj-market/install/cancel —— 取消进行中的安装(superadmin,U5/P3-12)。
1117
915
  // 按方案名寻址(R17 任务互斥:同名同时至多一个 running install);写取消标记文件
1118
916
  // (isolate 按请求隔离,FS 是唯一跨请求通道),runInstall 的 cancel 闭包在段间/migrate
1119
917
  // spawn 逐块探测命中 → h.kill + 回退装前态(P3-8 语义);终态 failed/partial + cancelled
@@ -1137,7 +935,7 @@ export function createMarketApi(env: MarketEnv) {
1137
935
  },
1138
936
  },
1139
937
  },
1140
- // GET /api/_platform/market/task?id= —— 任务轮询消费面(superadmin,§10.2 800ms 轮询)。
938
+ // GET /api/oj-market/task?id= —— 任务轮询消费面(superadmin,§10.2 800ms 轮询)。
1141
939
  // 简报 `task/:id` 段参数形态在模板 http 面无先例,照 contacts/detail?id= 惯例走 query(报告备案)。
1142
940
  "task": {
1143
941
  async get() {
@@ -1153,7 +951,7 @@ export function createMarketApi(env: MarketEnv) {
1153
951
  });
1154
952
  },
1155
953
  },
1156
- // POST /api/_platform/market/enable —— 生命周期开关(superadmin;P1-13 瘦客户端触发面)。
954
+ // POST /api/oj-market/enable —— 生命周期开关(superadmin;P1-13 瘦客户端触发面)。
1157
955
  // 端点只做门禁 + 取参 + SetEnabledInput 组装,四处同写全归 setEnabled(D12,installer.ts 同纪律);
1158
956
  // 领域拒绝(未装/空锁/版本目录缺失)返回 {ok:false, message} 200——操作已执行、被域逻辑拒绝,
1159
957
  // 人话在 message,CLI/市场页照 ok 位渲染。
@@ -1180,7 +978,7 @@ export function createMarketApi(env: MarketEnv) {
1180
978
  });
1181
979
  },
1182
980
  },
1183
- // GET /api/_platform/market/meta —— 登录即可({apiVersion},peerPlatform 预检;非 superadmin)
981
+ // GET /api/oj-market/meta —— 登录即可({apiVersion},peerPlatform 预检;非 superadmin)
1184
982
  "meta": {
1185
983
  async get() {
1186
984
  await respond(async () => {
@@ -1199,6 +997,24 @@ export function createMarketApi(env: MarketEnv) {
1199
997
  // DELETE /market/trust/key?key_id= 吊销(G5:只阻断新装/升级,不动已装方案)
1200
998
  // 端点只做门禁 + 取参 + operator 反查;读写/原子写/首次绑定/吊销语义全归 trust.ts(ISP,同 enable/uninstall 纪律)。
1201
999
  // del 动词(oj 运行期命名,devkit §http:DELETE 的方法名是 del 不是 delete)+ query 取参(system/roles del 同款先例)。
1000
+ // POST /api/oj-market/backfill —— 站点侧目录回填(v4 §六,三用:回填=恢复=对账)。
1001
+ // superadmin 一次性触发(升级后/灾备后);扫 cdn/ 存量包 upsert market_release,
1002
+ // 幂等可重入;无 CLI 依赖(远端纯站点经本端点自愈目录)。语义详见 backfill.ts。
1003
+ "backfill": {
1004
+ async post() {
1005
+ await respond(async () => {
1006
+ await assertSuperAdmin();
1007
+ return await backfillReleases({
1008
+ cdnDir: env.cdnDir ?? "cdn",
1009
+ staging: env.staging,
1010
+ exec: env.exec,
1011
+ sha256Hex: env.sha256Hex,
1012
+ verifyEd25519: env.verifyEd25519,
1013
+ now: env.now,
1014
+ });
1015
+ });
1016
+ },
1017
+ },
1202
1018
  "trust": {
1203
1019
  async get() {
1204
1020
  await respond(async () => {
@@ -1253,16 +1069,43 @@ export function createMarketApi(env: MarketEnv) {
1253
1069
  },
1254
1070
  },
1255
1071
  },
1256
- // GET /api/_platform/market/index —— 市场索引聚合(superadmin,R9)
1072
+ // GET /api/oj-market/index —— 市场索引聚合(superadmin,R9)
1257
1073
  "index": {
1258
1074
  async get() {
1259
1075
  await respond(async () => {
1260
1076
  await assertSuperAdmin();
1261
- return await indexBody(env);
1077
+ return await indexBody(env, (http.query ?? {}) as Record<string, unknown>);
1078
+ });
1079
+ },
1080
+ },
1081
+ // GET /api/oj-market/catalog —— 市场目录清单(v4 §三:**匿名可读**,分页 JSON;
1082
+ // DB 直读合成单协议 shape,远端市场消费同一 shape)。no-store 防中间代理缓存陈旧目录。
1083
+ "catalog": {
1084
+ async get() {
1085
+ await respond(async () => {
1086
+ return await catalogBody(env, (http.query ?? {}) as Record<string, unknown>);
1262
1087
  });
1263
1088
  },
1089
+ // GET /api/oj-market/catalog/detail?name=[&version=] —— 单包详情(匿名可读;
1090
+ // 保留 yanked 条目 + 服务端组表 archive 正名,安装解析数据源,§四)
1091
+ detail: {
1092
+ async get() {
1093
+ await respond(async () => {
1094
+ return await detailBody((http.query ?? {}) as Record<string, unknown>);
1095
+ });
1096
+ },
1097
+ },
1264
1098
  },
1265
- // GET /api/_platform/market/uninstall-impact?name= —— 卸载影响面预检(superadmin,R9)。
1099
+ // POST /api/oj-market/yank —— 签名下线/恢复(v4 §三,发布者本人;清单摘除/详情保留)。
1100
+ // yank 不动已装方案(G5),仅目录摘除 + 阻断新装/升级;重复操作幂等 200;legacy 行 403 指向 superadmin。
1101
+ "yank": {
1102
+ async post() {
1103
+ await respond(async () => {
1104
+ return await yankBody(env);
1105
+ });
1106
+ },
1107
+ },
1108
+ // GET /api/oj-market/uninstall-impact?name= —— 卸载影响面预检(superadmin,R9)。
1266
1109
  // 返回前端二次确认所需的影响计数(角色数 + 受影响用户数),不改动任何状态。
1267
1110
  "uninstall-impact": {
1268
1111
  async get() {
@@ -1281,7 +1124,7 @@ export function createMarketApi(env: MarketEnv) {
1281
1124
  });
1282
1125
  },
1283
1126
  },
1284
- // POST /api/_platform/market/uninstall —— 卸载编排(superadmin,R9)。
1127
+ // POST /api/oj-market/uninstall —— 卸载编排(superadmin,R9)。
1285
1128
  // 端点只做门禁 + 取参 + operator 反查(历史留痕),四处同删全归 runUninstall(uninstall.ts 同纪律);
1286
1129
  // 领域拒绝(未装/空锁拒启)返回 {ok:false, message} 200——人话在 message,CLI/市场页照 ok 位渲染。
1287
1130
  "uninstall": {
@@ -1305,12 +1148,17 @@ export function createMarketApi(env: MarketEnv) {
1305
1148
  // 其余领域拒绝(空锁拒启/级联失败等)维持 200 {ok:false} 人话在 message。
1306
1149
  if (!r.ok && r.reason === "not-installed")
1307
1150
  fail(404, r.message);
1151
+ // 卸载墓碑(终审 Important-1):随站 bundle 的意图标记摘除本方案——否则
1152
+ // 下次 index 收敛会把这次故意卸载当漂移自动重装,「卸载后仅可经 system 域
1153
+ // 入口重装」落空。重装路径:system 页引导 backfill 重建目录行后手动安装。
1154
+ if (r.ok)
1155
+ await pruneBundledMarker(`${(env.cdnDir ?? "cdn")}/bundles.json`, name);
1308
1156
  return r;
1309
1157
  });
1310
1158
  },
1311
1159
  },
1312
1160
 
1313
- // GET /api/_platform/market/history —— 市场操作历史(只读消费面,供前端 HistoryDrawer)。
1161
+ // GET /api/oj-market/history —— 市场操作历史(只读消费面,供前端 HistoryDrawer)。
1314
1162
  // 平台级审计面(P4 上收 _platform):install/enable/disable/uninstall/upgrade/rollback 留痕
1315
1163
  // 由 _lifecycle/history.ts recordHistory 写入;本端点仅登录用户可读(非匿名名单,Bearer 守卫保护)。
1316
1164
  // 表不存在/查询失败一律返回空数组(历史是观测面,不阻断主流程,与终态审计附录 B-5 同语义)。
@@ -1326,24 +1174,16 @@ export function createMarketApi(env: MarketEnv) {
1326
1174
  },
1327
1175
  },
1328
1176
 
1329
- // GET /api/_platform/market/releases —— 发布历史(登录即可读;与市场卡片同口径的「浏览开放」)。
1177
+ // GET /api/oj-market/releases —— 发布历史(登录即可读;与市场卡片同口径的「浏览开放」)。
1330
1178
  //
1331
- // 数据源是签名索引本身:每次读取先把索引里出现过的 product@version 补进 market_release
1332
- // (只补新行,published_at 一次写定),再按发布时间倒序分页返回。索引拉不到/验签失败
1333
- // 不阻断查询:返回已入库行 + syncError,页面照常显示历史并提示「暂时读不到索引」。
1179
+ // 数据源 = market_release 上传写入行(v4 §四 DB 目录化:索引对账 syncReleases 已退役,
1180
+ // 发布历史即目录表按 published_at 倒序分页直读,零索引依赖)。
1334
1181
  "releases": {
1335
1182
  async get() {
1336
1183
  await respond(async () => {
1337
1184
  const uid = (http as { user?: { id?: unknown } }).user?.id;
1338
1185
  if (uid == null)
1339
1186
  fail(401, "unauthorized");
1340
- let syncError: string | null = null;
1341
- try {
1342
- await syncReleases(env);
1343
- }
1344
- catch (e) {
1345
- syncError = e instanceof Error ? e.message : String(e);
1346
- }
1347
1187
  const q = (http.query ?? {}) as Record<string, unknown>;
1348
1188
  const { page, size, offset } = pageArgs({ page: q.page, size: q.limit });
1349
1189
  const total = Number(
@@ -1357,7 +1197,7 @@ export function createMarketApi(env: MarketEnv) {
1357
1197
  .limit(size)
1358
1198
  .offset(offset)
1359
1199
  .all() as unknown as ReleaseRow[];
1360
- return { list, total, page, size, syncError };
1200
+ return { list, total, page, size };
1361
1201
  });
1362
1202
  },
1363
1203
  },
@@ -1392,7 +1232,51 @@ marketApi.trust.key.post.schema = { body: { type: "object", required: ["key_id",
1392
1232
  marketApi.trust.key.post.response = { type: "object", additionalProperties: true };
1393
1233
  marketApi.trust.key.del.schema = { query: { type: "object", required: ["key_id"], properties: { key_id: { type: "string" } } } };
1394
1234
  marketApi.trust.key.del.response = { type: "object", additionalProperties: true };
1395
- marketApi.index.get.response = { type: "array", items: { type: "object", additionalProperties: true } };
1235
+ marketApi.index.get.schema = {
1236
+ query: {
1237
+ type: "object",
1238
+ properties: { page: { type: "number", minimum: 1 }, size: { type: "number", minimum: 1, maximum: 200 } },
1239
+ },
1240
+ };
1241
+ marketApi.index.get.response = {
1242
+ type: "object",
1243
+ required: ["list", "total", "page", "size"],
1244
+ properties: {
1245
+ list: { type: "array", items: { type: "object", additionalProperties: true } },
1246
+ total: { type: "number" },
1247
+ page: { type: "number" },
1248
+ size: { type: "number" },
1249
+ },
1250
+ };
1251
+ marketApi.catalog.get.schema = {
1252
+ query: {
1253
+ type: "object",
1254
+ properties: { page: { type: "number", minimum: 1 }, size: { type: "number", minimum: 1, maximum: 200 } },
1255
+ },
1256
+ };
1257
+ marketApi.catalog.get.response = { type: "object", additionalProperties: true };
1258
+ marketApi.catalog.detail.get.schema = {
1259
+ query: {
1260
+ type: "object",
1261
+ required: ["name"],
1262
+ properties: { name: { type: "string" }, version: { type: "string" } },
1263
+ },
1264
+ };
1265
+ marketApi.catalog.detail.get.response = { type: "object", additionalProperties: true };
1266
+ marketApi.yank.post.schema = {
1267
+ body: {
1268
+ type: "object",
1269
+ required: ["product", "version"],
1270
+ properties: {
1271
+ product: { type: "string" },
1272
+ version: { type: "string" },
1273
+ reason: { type: "string" },
1274
+ by: { type: "string" },
1275
+ restore: { type: "boolean" },
1276
+ },
1277
+ },
1278
+ };
1279
+ marketApi.yank.post.response = { type: "object", additionalProperties: true };
1396
1280
  marketApi["uninstall-impact"].get.schema = { query: { type: "object", required: ["name"], properties: { name: { type: "string" } } } };
1397
1281
  marketApi["uninstall-impact"].get.response = { type: "object", additionalProperties: true };
1398
1282
  marketApi.uninstall.post.schema = { body: { type: "object", required: ["name"], properties: { name: { type: "string" } } } };
@@ -1415,7 +1299,6 @@ marketApi.releases.get.response = {
1415
1299
  total: { type: "number" },
1416
1300
  page: { type: "number" },
1417
1301
  size: { type: "number" },
1418
- syncError: { type: ["string", "null"] },
1419
1302
  },
1420
1303
  };
1421
1304