@oj-module/cli 0.2.68 → 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 (153) 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.1xzahckt10.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/global.d.ts +6 -4
  55. package/templates/init.include.yaml +16 -11
  56. package/templates/openapi.json +3971 -3663
  57. package/templates/src/.ojm-builtin.json +13 -0
  58. package/templates/src/README.md +5 -2
  59. package/templates/src/app/api/_platform/README.md +3 -2
  60. package/templates/src/app/api/_platform/_lifecycle/atomic.ts +7 -2
  61. package/templates/src/app/api/_platform/_lifecycle/bundle-converge.ts +158 -0
  62. package/templates/src/app/api/_platform/_lifecycle/history.ts +3 -2
  63. package/templates/src/app/api/_platform/_lifecycle/installer.ts +25 -5
  64. package/templates/src/app/api/_platform/_lifecycle/local-archive.ts +61 -0
  65. package/templates/src/app/api/_platform/_lifecycle/manifest-of-upload.ts +62 -0
  66. package/templates/src/app/api/_platform/_lifecycle/steps/preflight.ts +12 -2
  67. package/templates/src/app/api/_platform/_lifecycle/steps/reload.ts +4 -1
  68. package/templates/src/app/api/_platform/_lifecycle/steps/smoke.ts +8 -2
  69. package/templates/src/app/api/_platform/_lifecycle/types.ts +1 -1
  70. package/templates/src/app/api/_platform/_lifecycle/uninstall.ts +14 -5
  71. package/templates/src/app/api/_platform/_shared/service/domain/users.ts +7 -1
  72. package/templates/src/app/api/_platform/_shared/site-facade.ts +31 -0
  73. package/templates/src/app/api/_platform/manifest.yaml +1 -2
  74. package/templates/src/app/api/_platform/migrations/0003__market_catalog.mysql.sql +6 -0
  75. package/templates/src/app/api/_platform/migrations/0003__market_catalog.pg.sql +6 -0
  76. package/templates/src/app/api/_platform/migrations/0003__market_catalog.sql +18 -0
  77. package/templates/src/app/api/_platform/migrations/0004__market_drop_source_seq.mysql.sql +6 -0
  78. package/templates/src/app/api/_platform/migrations/0004__market_drop_source_seq.pg.sql +6 -0
  79. package/templates/src/app/api/_platform/migrations/0004__market_drop_source_seq.sql +6 -0
  80. package/templates/src/app/api/_platform/migrations/0005__market_sig_registry.mysql.sql +8 -0
  81. package/templates/src/app/api/_platform/migrations/0005__market_sig_registry.pg.sql +8 -0
  82. package/templates/src/app/api/_platform/migrations/0005__market_sig_registry.sql +8 -0
  83. package/templates/src/app/api/_platform/schema.yaml +8 -11
  84. package/templates/src/app/api/_platform/seed.mysql.sql +1 -1
  85. package/templates/src/app/api/_platform/seed.pg.sql +1 -1
  86. package/templates/src/app/api/_platform/seed.sql +1 -1
  87. package/templates/src/app/api/auth/_shared/wecom/sync.ts +6 -0
  88. package/templates/src/app/api/system/users/api.ts +3 -0
  89. package/templates/src/app/web/_platform/__client/api.schemas.ts +0 -113
  90. package/templates/src/app/web/_platform/__client/index.ts +1 -333
  91. package/templates/src/app/web/system/__client/api.schemas.ts +2 -0
  92. package/templates/src/app/web/system/locales/en-US.json +15 -1
  93. package/templates/src/app/web/system/locales/zh-CN.json +15 -1
  94. package/templates/src/app/web/system/pages/departments/sync-bar.tsx +9 -3
  95. package/templates/src/app/web/system/pages/roles/index.tsx +4 -8
  96. package/templates/src/app/web/system/pages/roles/role-label.ts +26 -0
  97. package/templates/src/app/web/system/pages/solutions/index.tsx +110 -0
  98. package/templates/src/app/web/system/pages/solutions/market-entry.ts +46 -0
  99. package/templates/src/app/web/system/pages/users/index.tsx +12 -2
  100. package/templates/src/app/web/system/pages/users/match-keyword.ts +19 -0
  101. package/templates/src/app/web/system/pages/users/roles.tsx +43 -14
  102. package/templates/src/app/web/system/routes.ts +18 -1
  103. package/templates/src/{app/api/_platform/market → oj-market/api}/api.ts +475 -592
  104. package/templates/src/oj-market/api/backfill.ts +221 -0
  105. package/templates/src/{app/api/_platform/market → oj-market/api}/bootstrap.ts +2 -2
  106. package/templates/src/oj-market/api/cdn/api.ts +312 -0
  107. package/templates/src/oj-market/api/cdn/shared.ts +104 -0
  108. package/templates/src/oj-market/api/cdn/signed-auth.ts +75 -0
  109. package/templates/src/oj-market/api/manifest.yaml +8 -0
  110. package/templates/src/oj-market/solution.yaml +21 -0
  111. package/templates/src/oj-market/web/__client/api.schemas.ts +167 -0
  112. package/templates/src/oj-market/web/__client/index.ts +509 -0
  113. package/templates/src/oj-market/web/__tests__/marketClient.fake.ts +6 -6
  114. package/templates/src/oj-market/web/api/client.ts +6 -6
  115. package/templates/src/oj-market/web/api/defaultClient.ts +31 -25
  116. package/templates/src/oj-market/web/entry.ts +5 -3
  117. package/templates/src/oj-market/web/hooks/useMarket.ts +6 -6
  118. package/templates/src/oj-market/web/pages/MarketApp.tsx +20 -3
  119. package/templates/src/oj-market/web/publish/locales/en-US.json +1 -1
  120. package/templates/src/oj-market/web/publish/locales/zh-CN.json +1 -1
  121. package/templates/src/oj-market/web/publish/pages/history.tsx +2 -11
  122. package/templates/src/oj-market/web/routes.ts +12 -0
  123. package/templates/src/oj-market/web/types.ts +14 -8
  124. package/templates/tests/lifecycle/anonymous-wiring.spec.ts +65 -0
  125. package/templates/tests/lifecycle/backfill.spec.ts +392 -0
  126. package/templates/tests/lifecycle/bootstrap.spec.ts +1 -1
  127. package/templates/tests/lifecycle/bundle-converge.spec.ts +264 -0
  128. package/templates/tests/lifecycle/cdn-upload.spec.ts +661 -0
  129. package/templates/tests/lifecycle/deploy-smoke.spec.ts +25 -0
  130. package/templates/tests/lifecycle/exec-grant.spec.ts +38 -0
  131. package/templates/tests/lifecycle/installer.spec.ts +36 -0
  132. package/templates/tests/lifecycle/market-api.spec.ts +448 -803
  133. package/templates/tests/lifecycle/preflight.spec.ts +18 -0
  134. package/templates/tests/lifecycle/trust.spec.ts +2 -2
  135. package/templates/tests/lifecycle/uninstall.spec.ts +36 -2
  136. package/templates/tests/lifecycle/verify-checks.spec.ts +1 -1
  137. package/templates/tests/rbac-users.spec.ts +8 -0
  138. package/templates/tests/solutions-entry.spec.ts +55 -0
  139. package/templates/tsconfig.paths.json +1 -0
  140. package/templates/types/stream-probes/stream-handlers.ts +34 -0
  141. package/templates/web.config.ts +6 -6
  142. package/vendor/host-versions.json +3 -3
  143. package/bin/oj +0 -4
  144. package/src/market/__tests__/build-index.test.ts +0 -641
  145. package/src/market/__tests__/sharding.test.ts +0 -90
  146. package/src/market/__tests__/yank-ledger.test.ts +0 -60
  147. package/src/market/build-index.ts +0 -526
  148. package/src/market/index-builder.ts +0 -149
  149. package/src/market/sharding.ts +0 -94
  150. package/src/market/yank-ledger.ts +0 -91
  151. package/templates/src/app/api/_platform/_lifecycle/source-seq.ts +0 -88
  152. package/templates/src/app/api/_platform/market/index-source.ts +0 -112
  153. package/templates/tests/lifecycle/source-seq.spec.ts +0 -55
@@ -1,149 +0,0 @@
1
- /**
2
- * Form B 索引聚合(T4 + REG-CS5 三级化):把「一批已扫出的包」变成分片索引的数据形状。
3
- *
4
- * 纯数据变换,不落 IO、不签名——IO 与签名归 `build-index.ts`(写入)与 `publish.ts`(补签)。
5
- * 形状严格对齐安装侧的消费口径(`market/api.ts resolveCdn`):`archive.file` 必须逐字
6
- * `<name>-<version>.tgz`,否则安装侧 P2-11 会判索引投毒直接 502。
7
- *
8
- * REG-CS5:v2 起索引三级化——`catalog.yaml`(包名 → 分片号,无版本明细)+ `shards/<n>.yaml`
9
- * (每片 ≤1000 版本)+ 版本条目内嵌 Form B manifest(原 manifests.yaml 的聚合清单并入分片,
10
- * 装一个包只拉 1 catalog + 1 shard,不再有第三份全量清单)。版本条目即 Form B 条目:
11
- * `archive` 带 file/sha256/size/signature,与旧 manifests.yaml 逐字同构,故安装侧
12
- * 「取版本条目即取 manifest」,少一次全量拉取。
13
- */
14
-
15
- import type { YankEntry } from "./yank-ledger";
16
-
17
- /** 分片内的方案条目:版本数组 = Form B manifest 条目(升序) */
18
- export interface BuiltSolution {
19
- name: string
20
- latest: string
21
- versions: Array<Record<string, unknown>>
22
- }
23
-
24
- export interface BuiltIndex {
25
- solutions: BuiltSolution[]
26
- }
27
-
28
- /**
29
- * 聚合索引数据(分片形态)。
30
- *
31
- * `yanked?`:yank 账本(`name@version -> 条目`,REG-CS4)。非零时:
32
- * - 各版本条目带 `yanked: true` + `yanked_at`(epoch 秒),安装侧 resolveCdn 据此拒精确安装;
33
- * - 该方案的 `latest` 跳过已下线版本(全部下线则降级取最高版,保证索引不空)。
34
- */
35
- export function buildIndexData(packages: IndexedPackage[], yanked?: Map<string, YankEntry>): BuiltIndex {
36
- const byName = new Map<string, IndexedPackage[]>();
37
- for (const pkg of packages) {
38
- const list = byName.get(pkg.name) ?? [];
39
- list.push(pkg);
40
- byName.set(pkg.name, list);
41
- }
42
-
43
- const solutions: BuiltSolution[] = [];
44
- for (const name of [...byName.keys()].sort()) {
45
- const list = [...byName.get(name)!].sort((a, b) => compareSemver(a.version, b.version));
46
- // latest 跳过 yanked(REG-CS4):已下线的版本绝不成为默认升级目标;全下线降级取最高版
47
- const live = list.filter(v => !isYanked(yanked, name, v.version));
48
- const latestSource = live.length > 0 ? live : list;
49
- const versions = list.map((v) => {
50
- const key = yanked !== undefined && isYanked(yanked, name, v.version)
51
- ? { yanked: true, yanked_at: yanked.get(`${name}@${v.version}`)!.at }
52
- : {};
53
- // 版本条目 = Form B manifest 条目:name/version/archive 与旧 manifests.yaml 逐字同构,
54
- // 顶层再并进版本裁决字段(yanked)。安装侧「取条目即取 manifest」。
55
- return {
56
- ...v.manifest,
57
- name,
58
- version: v.version,
59
- archive: {
60
- file: `${v.name}-${v.version}.tgz`,
61
- sha256: v.sha256,
62
- size: v.size,
63
- ...(v.signature === undefined ? {} : { signature: v.signature }),
64
- },
65
- ...key,
66
- };
67
- });
68
- solutions.push({ name, latest: latestSource[latestSource.length - 1]!.version, versions });
69
- }
70
- return { solutions };
71
- }
72
-
73
- /** 入索引的包(扫描产物;manifest 为包内 Form A,进分片时补成 Form B) */
74
- export interface IndexedPackage {
75
- name: string
76
- version: string
77
- /** tgz 文件名(由 name+version 派生,逐字约束在此处一次成型) */
78
- file: string
79
- sha256: string
80
- size: number
81
- /** 包内 manifest(Form A) */
82
- manifest: Record<string, unknown>
83
- /** 包级 detached 签名(`<tgz>.sig` 五字段);缺则 Form B 不带 signature,安装侧按 sig_missing 拒装 */
84
- signature?: Record<string, unknown>
85
- }
86
-
87
- function isYanked(yanked: Map<string, YankEntry> | undefined, name: string, version: string): boolean {
88
- return yanked !== undefined && yanked.has(`${name}@${version}`);
89
- }
90
-
91
- /**
92
- * semver 比较(与模板 `_lifecycle/semver.ts` 同源算法;prerelease 遵循 semver 2.0.0):
93
- * 数字标识符按数值、字母按字典序、数字 < 字母、标识符列表前缀相等时多者大;无 prerelease > 有 prerelease。
94
- * 取代旧版纯数值比较(旧版对 `1.0.0-rc.1` 这类预发布版本按 `1.0.0.0` 处理,latest 计算失真——
95
- * S2-0 红队 F 相关预发布序修复)。返回 -1|0|1,可直接作 Array.sort 比较器。
96
- */
97
- export function compareSemver(a: string, b: string): -1 | 0 | 1 {
98
- const va = splitVersion(a);
99
- const vb = splitVersion(b);
100
- const len = Math.max(va.core.length, vb.core.length);
101
- for (let i = 0; i < len; i++) {
102
- const diff = (va.core[i] ?? 0) - (vb.core[i] ?? 0);
103
- if (diff !== 0)
104
- return diff < 0 ? -1 : 1;
105
- }
106
- return comparePrerelease(va.pre, vb.pre);
107
- }
108
-
109
- /** 兼容旧名(过渡期别名;新调用请用 compareSemver) */
110
- export const compareVersion = compareSemver;
111
-
112
- function splitVersion(v: string): { core: number[], pre: string[] | null } {
113
- const hyphen = v.indexOf("-");
114
- const core = hyphen === -1 ? v : v.slice(0, hyphen);
115
- const prePart = hyphen === -1 ? null : v.slice(hyphen + 1);
116
- return {
117
- core: core.split(".").map(seg => Number.parseInt(seg, 10) || 0),
118
- pre: prePart == null ? null : prePart.split("."),
119
- };
120
- }
121
-
122
- function comparePrerelease(a: string[] | null, b: string[] | null): -1 | 0 | 1 {
123
- if (a === null || b === null) {
124
- return a === b ? 0 : a === null ? 1 : -1;
125
- }
126
- const len = Math.max(a.length, b.length);
127
- for (let i = 0; i < len; i++) {
128
- const ia = a[i];
129
- const ib = b[i];
130
- if (ia === undefined)
131
- return -1;
132
- if (ib === undefined)
133
- return 1;
134
- const na = /^\d+$/.test(ia);
135
- const nb = /^\d+$/.test(ib);
136
- if (na && nb) {
137
- const diff = Number.parseInt(ia, 10) - Number.parseInt(ib, 10);
138
- if (diff !== 0)
139
- return diff < 0 ? -1 : 1;
140
- }
141
- else if (na !== nb) {
142
- return na ? -1 : 1;
143
- }
144
- else if (ia !== ib) {
145
- return ia < ib ? -1 : 1;
146
- }
147
- }
148
- return 0;
149
- }
@@ -1,94 +0,0 @@
1
- /**
2
- * REG-CS5 装箱(D3):把「一批方案」装进分片,每片 ≤ `SHARD_MAX_VERSIONS` 个版本。
3
- *
4
- * 纯数据变换,不落 IO、不签名——编号与落盘归 `build-index.ts`。
5
- *
6
- * 三条策略(计划 §S2-1 子任务 2,D3 已定):
7
- * 1. **整包装箱**:一个方案不跨片,除非它自己的版本数就超限——那种极端包才按连续上限切片,
8
- * 且仍占连续片号(读方按片号倒序试取,最高片装最新版,`latest` 安装只拉 1 片)。
9
- * 2. **确定性**:输入方案顺序即输出顺序(调用方已按名称字典序排好),同样目录内容永远装出
10
- * 同样结果——「索引只取决于目录内容」这条性质靠它成立。
11
- * 3. **累加到上限开新片**:按版本数往当前片累加,放不下就开新片(计划原文「按版本数累加到
12
- * 上限开新片」,上限值经执行期实测校准,见下)。已有包因此天然不换片:只在它前面的内容变了才被挤动。
13
- *
14
- * 体积口径(需求 §7,2026-10-25 执行期实测校准):需求原文按「单条 Form B ≈535–684B」
15
- * 估 2000 版本/片 ≈1.2MB;但本仓参考 manifest 形态(fixture.ts solutionManifest,945B YAML)
16
- * 实测单条 Form B 条目 ≈1.2KB——2000/片 = 2.4MB,超计划 §S2-1「单片 ≤1.5MB」验收闸。
17
- * 校准为 1000 版本/片:参考形态下 ≈1.2MB,正中需求目标;fat manifest 的最坏面也随砍半。
18
- */
19
-
20
- import type { BuiltSolution } from "./index-builder";
21
-
22
- /** 单片最大版本数(REG-CS5:1000 版本 ≈1.2MB,计划验收 ≤1.5MB;实测依据见文件头) */
23
- export const SHARD_MAX_VERSIONS = 1000;
24
-
25
- /** 一个分片槽位(编号由 build-index 按内容匹配分配,这里只带内容) */
26
- export interface ShardSlot {
27
- /** 片内方案(顺序 = 装配顺序;版本在各方案内升序) */
28
- solutions: BuiltSolution[]
29
- /** 片内版本总数(≤SHARD_MAX_VERSIONS;超限包独占多片时每片恰为上限) */
30
- versions: number
31
- }
32
-
33
- /** 包名 → 所占片号(升序;读方倒序试取) */
34
- export type ShardAssignment = Map<string, number[]>;
35
-
36
- export interface PackedShards {
37
- /** 槽位序列(顺序即装配顺序;编号未分配,见 ShardSlot) */
38
- slots: ShardSlot[]
39
- /** 包名 → 片号(升序) */
40
- assignment: ShardAssignment
41
- }
42
-
43
- /**
44
- * 装箱。调用方保证 `solutions` 已按名称字典序(buildIndexData 的产出即如此)。
45
- *
46
- * 超限包(版本数 > `SHARD_MAX_VERSIONS`)切片时每片都是完整方案条目(带 name/latest),`latest` 恒取
47
- * 包级真值——读方片号倒序,最高片必含最新版本,`latest` 只在最高片被消费;低片里的
48
- * `latest` 是自洽兜底(指向本片最高版),不会被安装路径读到。
49
- */
50
- export function packShards(solutions: BuiltSolution[]): PackedShards {
51
- const slots: ShardSlot[] = [];
52
- const assignment: ShardAssignment = new Map();
53
- let current: ShardSlot = { solutions: [], versions: 0 };
54
-
55
- for (const solution of solutions) {
56
- if (solution.versions.length > SHARD_MAX_VERSIONS) {
57
- // 超限包:先把当前片收尾,再按连续上限独占多片
58
- if (current.solutions.length > 0) {
59
- slots.push(current);
60
- current = { solutions: [], versions: 0 };
61
- }
62
- const nos: number[] = [];
63
- for (let at = 0; at < solution.versions.length; at += SHARD_MAX_VERSIONS) {
64
- const chunk: BuiltSolution = {
65
- name: solution.name,
66
- latest: solution.latest,
67
- versions: solution.versions.slice(at, at + SHARD_MAX_VERSIONS),
68
- };
69
- slots.push({ solutions: [chunk], versions: chunk.versions.length });
70
- nos.push(slots.length); // 片号 = 槽位序号(1 起)
71
- }
72
- assignment.set(solution.name, nos);
73
- continue;
74
- }
75
- if (current.versions + solution.versions.length > SHARD_MAX_VERSIONS) {
76
- slots.push(current);
77
- current = { solutions: [], versions: 0 };
78
- }
79
- current.solutions.push(solution);
80
- current.versions += solution.versions.length;
81
- const nos = assignment.get(solution.name) ?? [];
82
- nos.push(slots.length + 1); // 当前片编号 = 已收尾片数 + 1
83
- assignment.set(solution.name, nos);
84
- }
85
- if (current.solutions.length > 0)
86
- slots.push(current);
87
-
88
- // 空索引也要有一个空片——catalog 的 shards 映射不为空,读方「拉 catalog 即知天下」
89
- // (无片可拉时装任何包都走 404 分支,与空索引时代同语义)
90
- if (slots.length === 0)
91
- slots.push({ solutions: [], versions: 0 });
92
-
93
- return { slots, assignment };
94
- }
@@ -1,91 +0,0 @@
1
- /**
2
- * yank 账本(REG-CS4):把「某版本已下线」这个事实从索引正文里抽出来,单独持久化。
3
- *
4
- * 为什么独立成账本:索引是**派生重建产物**(`build-index` 每次全量重扫 `cdn/*.tgz`),
5
- * tgz 文件本身不删除(yank ≠ delete)——所以「谁被下线」不能靠「文件不在」推断,必须有
6
- * 一份能跨重建存活的真源。账本就是这份真源:`cdn/yanked.json`(`name@version -> 条目`)。
7
- *
8
- * 重建索引时,`build-index` 读账本把对应版本标 `yanked: true` + `yanked_at`,并让 `latest`
9
- * 跳过已下线版本。`ojm market yank <name>@<version>` 写账本后顺手重建索引。
10
- */
11
-
12
- import fs from "node:fs";
13
- import path from "node:path";
14
-
15
- import { resolveCdnLayout } from "./cdn-layout";
16
-
17
- /** 单条 yank 记录(审计留痕:谁、何时、为何) */
18
- export interface YankEntry {
19
- /** 方案名 */
20
- name: string
21
- /** 版本 */
22
- version: string
23
- /** 下线时刻(epoch 秒) */
24
- at: number
25
- /** 操作人(CLI 本地形态来自 --by;registry 上线后由会话身份注入) */
26
- by: string
27
- /** 下线原因(可选,审计用) */
28
- reason?: string
29
- }
30
-
31
- /** 账本 key:`<name>@<version>`,与 install 侧 resolveCdn 的命中口径一致 */
32
- export function yankKey(name: string, version: string): string {
33
- return `${name}@${version}`;
34
- }
35
-
36
- /** 账本磁盘路径:`cdn/yanked.json` */
37
- export function yankLedgerPath(projectRoot: string): string {
38
- return path.join(resolveCdnLayout(projectRoot).dir, "yanked.json");
39
- }
40
-
41
- /**
42
- * 读账本。文件不存在 → 空表(视为「无下线记录」);非法 JSON → 抛错(fail-closed,不静默当空)。
43
- * 返回以 `yankKey` 为键的条目表,供 build-index 快速查某版本是否被下线。
44
- */
45
- export function readYankLedger(projectRoot: string): Map<string, YankEntry> {
46
- const file = yankLedgerPath(projectRoot);
47
- if (!fs.existsSync(file))
48
- return new Map();
49
- const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as unknown;
50
- if (!isObj(parsed))
51
- throw new Error(`[ojm] yank 账本 ${file} 不是合法 JSON 对象(应为 name@version → 条目 的映射)`);
52
- const out = new Map<string, YankEntry>();
53
- for (const [key, raw] of Object.entries(parsed)) {
54
- if (!isObj(raw))
55
- continue;
56
- const { name, version, at, by, reason } = raw as Record<string, unknown>;
57
- if (typeof name !== "string" || typeof version !== "string" || typeof at !== "number" || typeof by !== "string")
58
- continue;
59
- out.set(key, { name, version, at, by, ...(typeof reason === "string" ? { reason } : {}) });
60
- }
61
- return out;
62
- }
63
-
64
- /**
65
- * 标记某版本已下线(写账本)。幂等:重复 yank 同一版本不重复写、返回 created=false。
66
- * 目标版本在 `cdn/*.tgz` 中不存在 → 抛明确错误(REG-CS4 验收:对不存在的版本报明确错误)。
67
- */
68
- export function markYanked(
69
- projectRoot: string,
70
- name: string,
71
- version: string,
72
- opts: { by: string, reason?: string, now?: number },
73
- ): { created: boolean } {
74
- const file = yankLedgerPath(projectRoot);
75
- const dir = path.dirname(file);
76
- // 目标版本必须真实存在于 cdn(文件名严格 <name>-<version>.tgz)——yank 的是「已发布版本」
77
- if (!fs.existsSync(path.join(dir, `${name}-${version}.tgz`)))
78
- throw new Error(`[ojm] 无法下线 ${name}@${version}:cdn 目录下不存在 ${name}-${version}.tgz(只可下线已发布的版本)`);
79
- const ledger = readYankLedger(projectRoot);
80
- const key = yankKey(name, version);
81
- if (ledger.has(key))
82
- return { created: false };
83
- ledger.set(key, { name, version, at: opts.now ?? Math.floor(Date.now() / 1000), by: opts.by, ...(opts.reason !== undefined ? { reason: opts.reason } : {}) });
84
- fs.mkdirSync(dir, { recursive: true });
85
- fs.writeFileSync(file, `${JSON.stringify(Object.fromEntries(ledger), null, 2)}\n`);
86
- return { created: true };
87
- }
88
-
89
- function isObj(v: unknown): v is Record<string, unknown> {
90
- return v !== null && typeof v === "object" && !Array.isArray(v);
91
- }
@@ -1,88 +0,0 @@
1
- // REG-CS2 索引新鲜度(防回放/降级):站点按源记住「见过的最大 seq」,回退即拒。
2
- //
3
- // 记在站点 DB(`reg_source_seq`,0014 迁移)而不记在 CLI:受害者是**站点**——镜像把旧索引
4
- // 回放给谁,谁就是被降级的一方(D1 已定,CLI/API 一致)。CLI 侧 `ojm market verify` 是
5
- // 离线单点校验,没有「上次见过的 seq」,故不做这项判定。
6
- //
7
- // fail-closed 口径(与 REG-CS1 同源):
8
- // - 读不到 / 读到非正整数 / 写入失败 —— 一律按 `index_state_corrupt` 拒,不静默重置。
9
- // 静默重置等于把「防回放」降级成「每次都当第一次见」,攻击者先破坏状态再回放即穿。
10
- // - 表不存在同样算读不到:迁移由 _platform 自带,起不来就该响,不该悄悄放行。
11
-
12
- // db 为 oj isolate 注入全局(同模板 service 层惯例,history.ts 同款;测试由夹具装 globalThis.db)
13
- declare const db: { table: (name: string) => any };
14
-
15
- export const SOURCE_SEQ_TABLE = "reg_source_seq";
16
-
17
- /** 判定:ok = 放行并已记录;!ok = 拒,`code` 区分「回放/降级」与「状态损坏」 */
18
- export type SourceSeqVerdict = {
19
- ok: true
20
- prev: number | null
21
- } | {
22
- ok: false
23
- code: "index_stale" | "index_state_corrupt"
24
- prev: number | null
25
- };
26
-
27
- /**
28
- * 比对并推进某个源的最大 seq。
29
- *
30
- * - 首次见(无行)→ 记录并放行;
31
- * - 同 seq 重复拉取 → 放行(幂等,不误伤正常重复请求),不重复写;
32
- * - seq 回退(< 记录值)→ `index_stale` 拒,且不覆盖记录(不能让回放把水位拉低)。
33
- */
34
- export async function guardAndRecordSourceSeq(source: string, seq: number): Promise<SourceSeqVerdict> {
35
- const prev = await readSourceSeq(source);
36
- if (prev === "corrupt")
37
- return { ok: false, code: "index_state_corrupt", prev: null };
38
- if (prev !== null && seq < prev)
39
- return { ok: false, code: "index_stale", prev };
40
- if (prev === seq)
41
- return { ok: true, prev };
42
- try {
43
- await writeSourceSeq(source, seq);
44
- }
45
- catch {
46
- // 写失败按 fail-closed 转「状态损坏」——不静默重置(否则防回放被降级成「每次都当第一次见」)
47
- return { ok: false, code: "index_state_corrupt", prev: null };
48
- }
49
- return { ok: true, prev };
50
- }
51
-
52
- /** 读取记录:无行 → null;读不到或非正整数 → "corrupt"(bigint 是 pg/mysql 的整型口径,一并认) */
53
- export async function readSourceSeq(source: string): Promise<number | null | "corrupt"> {
54
- let rows: Array<{ max_seq?: unknown }>;
55
- try {
56
- rows = await db.table(SOURCE_SEQ_TABLE)
57
- .select(["max_seq"])
58
- .where({ field: "source_url", op: "eq", value: source })
59
- .limit(1)
60
- .all() as Array<{ max_seq?: unknown }>;
61
- }
62
- catch {
63
- return "corrupt";
64
- }
65
- if (!Array.isArray(rows) || rows.length === 0)
66
- return null;
67
- const raw = rows[0]?.max_seq;
68
- if (typeof raw === "bigint")
69
- return raw >= 1n ? Number(raw) : "corrupt";
70
- if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1)
71
- return raw;
72
- return "corrupt";
73
- }
74
-
75
- /**
76
- * 记录某源的最大 seq:先 insert(首见),撞唯一键(并发首见同源)则降级为 update。
77
- * 这样消除「SELECT 再 INSERT」的 TOCTOU——并发首装同源不再因唯一键冲突被误判成
78
- * index_state_corrupt;任何写失败仍向上抛,由 guard 按 fail-closed 转成 index_state_corrupt。
79
- */
80
- export async function writeSourceSeq(source: string, seq: number): Promise<void> {
81
- const now = Math.floor(Date.now() / 1000);
82
- try {
83
- await db.table(SOURCE_SEQ_TABLE).insert({ source_url: source, max_seq: seq, updated_at: now }).run();
84
- }
85
- catch {
86
- await db.table(SOURCE_SEQ_TABLE).update({ max_seq: seq, updated_at: now }).where({ field: "source_url", op: "eq", value: source }).run();
87
- }
88
- }
@@ -1,112 +0,0 @@
1
- // REG-CS5 三级索引读取源(DIP,S2-1③):把「索引字节从哪来」与「索引怎么校验」分开。
2
- // 本模块只负责取字节——HTTP(条件请求/304)与本地目录两实现;签名校验、同代钉值校验、
3
- // 新鲜度校验全部留在调用方 market/api.ts(fetchIndex),读写两侧不重复实现校验语义。
4
- //
5
- // 三级索引形态(与 ojm market build-index 落盘逐字一致):
6
- // catalog.yaml 总目录:{seq, issued_at, expires_at, shards: {<片号>: <代次钉值>},
7
- // solutions: [{name, shards: [<片号>...]}]}——无版本明细,提交点
8
- // shards/<n>.yaml 分片:{solutions: [{name, latest, versions: [<Form B 条目>]}]}——
9
- // 纯内容无 meta,代次由 .sig 与 catalog 钉值携带;版本条目即 manifest
10
-
11
- import type { FetchInitFace } from "../_shared/site-facade";
12
- import { fsFace } from "../_lifecycle/atomic";
13
-
14
- /** 索引文件种类(REG-CS1:签名覆盖 (kind, seq);与 ojm publish 的 IndexKind 逐字同集合) */
15
- export type IndexKind = "catalog" | "shard";
16
-
17
- /** 索引文件(正文 + detached 签名)。sig 缺失 → null:调用方按 index_sig_missing 拒载,不猜 */
18
- export interface IndexFile {
19
- body: string
20
- sig: string | null
21
- }
22
-
23
- /**
24
- * 索引读取源(S2-1③ DIP):`read` 返回 null = 拉不到(网络错 / 非 2xx / 文件不存在),
25
- * 拒载还是降级由调用方裁决——本模块不做任何校验决策、不抛业务错误。
26
- */
27
- export interface IndexSource {
28
- read: (kind: IndexKind, no?: number) => Promise<IndexFile | null>
29
- }
30
-
31
- /** 索引相对路径(CDN 布局单点:catalog.yaml + shards/<n>.yaml;片号由调用方校验为正整数) */
32
- export function indexPathOf(kind: IndexKind, no?: number): string {
33
- return kind === "catalog" ? "catalog.yaml" : `shards/${no}.yaml`;
34
- }
35
-
36
- /**
37
- * 条件请求缓存(进程/模块级,与 site-facade 的 canonicalOriginCache 同模式):
38
- * url → {etag, body}。oj isolate 若按请求新建则退化为「请求内去重」,若被运行时复用则
39
- * 跨请求生效——两种生命周期下语义都正确:304 时服务端已确认字节未变,缓存正文即可信。
40
- */
41
- const etagCache = new Map<string, { etag: string, body: string }>();
42
-
43
- /**
44
- * HTTP 索引源(S2-1③/④):ETag + If-None-Match 条件请求,304 走缓存正文不重传。
45
- *
46
- * **不再发 `cache-control: no-cache`**(旧口径):读面新鲜度改由服务端缓存头
47
- * (`Cache-Control: max-age=60, stale-while-revalidate=60`,Q8:yank 最坏 120s 生效)
48
- * 与条件请求共同承载。索引自身仍带 expires_at(REG-CS2 硬过期即拒)与 seq 水位
49
- * (防回放),缓存层只决定「少传字节」,不决定「信不信」——§5.3 可用性通道的
50
- * 「窗口内用缓存副本」由 HTTP 缓存层(中间层 + 服务端 ETag/304)兑现,读取器不存第二份真相。
51
- */
52
- export function httpIndexSource(fetch: FetchInitFace, base: string): IndexSource {
53
- const root = base.replace(/\/+$/, "");
54
- const readOne = async (rel: string): Promise<string | null> => {
55
- const url = `${root}/${rel}`;
56
- const cached = etagCache.get(url);
57
- let res: Awaited<ReturnType<FetchInitFace>>;
58
- try {
59
- res = await fetch(url, cached === undefined ? undefined : { headers: { "if-none-match": cached.etag } });
60
- }
61
- catch {
62
- return null; // 网络层失败:拉不到,拒载/降级决策归调用方
63
- }
64
- // 304:服务端认了我们的 ETag——正文未变,用缓存副本,不重传(BDD:重复拉取走 304)
65
- if (res.status === 304 && cached !== undefined)
66
- return cached.body;
67
- if (!res.ok)
68
- return null;
69
- const body = new TextDecoder().decode(await res.arrayBuffer());
70
- // 无 ETag 的响应不进缓存:没有条件请求的锚点,缓存它毫无意义
71
- const etag = res.headers?.get("etag") ?? res.headers?.get("ETag") ?? null;
72
- if (etag !== null && etag !== "")
73
- etagCache.set(url, { etag, body });
74
- return body;
75
- };
76
- return {
77
- read: async (kind, no) => {
78
- const rel = indexPathOf(kind, no);
79
- const [body, sig] = await Promise.all([readOne(rel), readOne(`${rel}.sig`)]);
80
- return body === null ? null : { body, sig };
81
- },
82
- };
83
- }
84
-
85
- /**
86
- * 本地目录索引源(S2-1③ 第二实现):索引就在本机目录(离线消费 / 自供直读 / 测试夹具),
87
- * 不经 HTTP。校验语义与 HTTP 实现完全同构——调用方看不出区别,这正是 DIP 的目的。
88
- */
89
- export function localIndexSource(dir: string): IndexSource {
90
- const root = dir.replace(/\/+$/, "");
91
- return {
92
- read: async (kind, no) => {
93
- const f = fsFace();
94
- const rel = indexPathOf(kind, no);
95
- let body: string;
96
- try {
97
- body = new TextDecoder().decode(await f.readFile(`${root}/${rel}`));
98
- }
99
- catch {
100
- return null; // 目录里没有这个索引文件
101
- }
102
- let sig: string | null = null;
103
- try {
104
- sig = new TextDecoder().decode(await f.readFile(`${root}/${rel}.sig`));
105
- }
106
- catch {
107
- sig = null; // 缺签名:调用方按 index_sig_missing 拒载(与 HTTP 实现同口径)
108
- }
109
- return { body, sig };
110
- },
111
- };
112
- }
@@ -1,55 +0,0 @@
1
- // BDD:源代次水位(REG-CS2 防回放)——站点按源记住「见过的最大 seq」,回退即拒。
2
- //
3
- // 表 `reg_source_seq` 由 _platform 0014 迁移建;夹具直读真实迁移文件,与迁移零漂移。
4
- // 四条负向是 G7 的重点:水位写坏 / 表不存在 / 值非法都必须**拒**,不能静默当「第一次见」——
5
- // 静默重置等于把「防回放」降级成「每次都当第一次见」,攻击者先破坏状态再回放即可穿透。
6
- import { beforeEach, describe, expect, it } from "vitest";
7
- import { guardAndRecordSourceSeq, readSourceSeq, SOURCE_SEQ_TABLE } from "../../src/app/api/_platform/_lifecycle/source-seq";
8
- import { installDb } from "../fixture";
9
-
10
- const SRC = "https://cdn.example.com/solutions";
11
-
12
- let dbo: any;
13
-
14
- beforeEach(() => {
15
- dbo = installDb();
16
- });
17
-
18
- describe("given 站点第一次见某个源 When 拉到它的索引", () => {
19
- it("then 记录水位并放行(prev = null)", async () => {
20
- expect(await readSourceSeq(SRC)).toBeNull();
21
- expect(await guardAndRecordSourceSeq(SRC, 3)).toEqual({ ok: true, prev: null });
22
- expect(await readSourceSeq(SRC)).toBe(3);
23
- });
24
- });
25
-
26
- describe("given 同一代重复拉取 When 再判一次", () => {
27
- it("then 幂等放行,且不改写水位(不误伤正常重复请求)", async () => {
28
- await guardAndRecordSourceSeq(SRC, 5);
29
- expect(await guardAndRecordSourceSeq(SRC, 5)).toEqual({ ok: true, prev: 5 });
30
- expect(await readSourceSeq(SRC)).toBe(5);
31
- });
32
- });
33
-
34
- describe("given 源返回更旧的代次 When 判水位", () => {
35
- it("then 拒 index_stale,且不把水位拉低(回放改不了水位)", async () => {
36
- await guardAndRecordSourceSeq(SRC, 7);
37
- expect(await guardAndRecordSourceSeq(SRC, 3)).toEqual({ ok: false, code: "index_stale", prev: 7 });
38
- expect(await readSourceSeq(SRC)).toBe(7);
39
- });
40
- });
41
-
42
- describe("given 水位被写成非法值 When 判水位", () => {
43
- it("then 按 index_state_corrupt 拒(不静默重置成「没见过」)", async () => {
44
- dbo.prepare(`INSERT INTO ${SOURCE_SEQ_TABLE} (source_url, max_seq, updated_at) VALUES (?, ?, ?)`).run(SRC, 0, 1);
45
- expect(await readSourceSeq(SRC)).toBe("corrupt");
46
- expect(await guardAndRecordSourceSeq(SRC, 9)).toEqual({ ok: false, code: "index_state_corrupt", prev: null });
47
- });
48
- });
49
-
50
- describe("given 水位表不存在(站点未 migrate)When 判水位", () => {
51
- it("then 按 index_state_corrupt 拒(fail-closed,不悄悄放行)", async () => {
52
- dbo.exec(`DROP TABLE ${SOURCE_SEQ_TABLE}`);
53
- expect(await guardAndRecordSourceSeq(SRC, 1)).toEqual({ ok: false, code: "index_state_corrupt", prev: null });
54
- });
55
- });