@oj-module/cli 0.2.8 → 0.2.14

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 (98) hide show
  1. package/devkit/README.md +70 -24
  2. package/devkit/framework-dev.md +14 -5
  3. package/devkit/manual.md +9 -6
  4. package/devkit/module-dev-and-cdn-playground.md +197 -0
  5. package/devkit/sql-builder-missing-capabilities-pr.md +38 -4
  6. package/package.json +2 -2
  7. package/shell-dist/assets/runtime.js +130 -45
  8. package/shell-dist/index.html +2 -2
  9. package/shell-dist/versions.json +3 -3
  10. package/src/__tests__/build-backend.test.ts +64 -0
  11. package/src/__tests__/versions.test.ts +26 -0
  12. package/src/build.ts +9 -0
  13. package/src/bundle.ts +8 -5
  14. package/src/contract/__tests__/market-client.integration.test.ts +13 -10
  15. package/src/index.ts +6 -0
  16. package/src/init.ts +22 -0
  17. package/src/lifecycle/__tests__/bundle.test.ts +3 -3
  18. package/src/market/__tests__/bootstrap.test.ts +204 -0
  19. package/src/market/__tests__/build-index.test.ts +154 -0
  20. package/src/market/__tests__/cdn-layout.test.ts +96 -0
  21. package/src/market/__tests__/failure-playbook.test.ts +33 -0
  22. package/src/market/__tests__/fixture.test.ts +48 -0
  23. package/src/market/__tests__/publish.test.ts +109 -0
  24. package/src/market/__tests__/report.test.ts +51 -0
  25. package/src/market/__tests__/setup.test.ts +114 -0
  26. package/src/market/__tests__/verify.test.ts +111 -0
  27. package/src/market/bootstrap.ts +255 -0
  28. package/src/market/build-index.ts +159 -0
  29. package/src/market/cdn-layout.ts +122 -0
  30. package/src/market/command.ts +139 -0
  31. package/src/market/failure-playbook.ts +57 -0
  32. package/src/market/fixture.ts +138 -0
  33. package/src/market/index-builder.ts +76 -0
  34. package/src/market/publish.ts +156 -0
  35. package/src/market/report.ts +67 -0
  36. package/src/market/scan.ts +99 -0
  37. package/src/market/setup.ts +144 -0
  38. package/src/market/trial.ts +121 -0
  39. package/src/market/verify.ts +298 -0
  40. package/src/market/walkthrough.ts +506 -0
  41. package/src/oj.ts +3 -1
  42. package/src/preview-config.ts +14 -0
  43. package/src/preview.ts +4 -3
  44. package/src/sign.ts +21 -6
  45. package/src/usage.ts +19 -0
  46. package/src/vendor.ts +4 -2
  47. package/src/versions.ts +28 -0
  48. package/templates/api/.ojm-api-exempt.json +6 -2
  49. package/templates/api/config.yaml +20 -0
  50. package/templates/api/src/_platform/_lifecycle/steps/extract.ts +8 -8
  51. package/templates/api/src/_platform/_lifecycle/uninstall.ts +21 -53
  52. package/templates/api/src/_platform/_shared/service/domain/contacts.ts +15 -10
  53. package/templates/api/src/_platform/_shared/service/domain/solution-perms.ts +8 -6
  54. package/templates/api/src/_platform/_shared/service/infra/oplog.ts +16 -23
  55. package/templates/api/src/_platform/_shared/service/infra/query.ts +18 -0
  56. package/templates/api/src/_platform/_shared/service/infra/rate.ts +4 -1
  57. package/templates/api/src/_platform/fixtures/l1-test-user.sql +13 -0
  58. package/templates/api/src/_platform/manifest.yaml +1 -0
  59. package/templates/api/src/_platform/migrations/0013__users_profile.sql +4 -0
  60. package/templates/api/src/_platform/schema.yaml +12 -0
  61. package/templates/api/src/auth/_shared/session.ts +2 -2
  62. package/templates/api/src/demo/todos/api.ts +8 -9
  63. package/templates/api/src/notification/_shared/notify.ts +64 -26
  64. package/templates/api/src/notification/api.ts +1 -1
  65. package/templates/api/src/notification/manifest.yaml +1 -0
  66. package/templates/api/src/notification/migrations/0004__notifications_broadcast_hidden.sql +12 -0
  67. package/templates/api/src/notification/schema.yaml +7 -0
  68. package/templates/api/src/notification/seed.sql +5 -4
  69. package/templates/api/src/personal-center/profile/api.ts +63 -0
  70. package/templates/api/src/web/user-info/api.ts +3 -1
  71. package/templates/api/tests/fixture.ts +8 -16
  72. package/templates/api/tests/l1-builder.test.ts +91 -0
  73. package/templates/api/tests/lifecycle/installer.spec.ts +1 -4
  74. package/templates/api/tests/lifecycle/market-api.spec.ts +1 -3
  75. package/templates/api/tests/lifecycle/steps-fs.spec.ts +5 -8
  76. package/templates/api/tests/lifecycle/uninstall.spec.ts +28 -88
  77. package/templates/api/tests/query-utils.spec.ts +85 -1
  78. package/templates/api/tests/rbac-settings.spec.ts +64 -19
  79. package/templates/cdn/README.md +48 -0
  80. package/templates/global.d.ts +36 -2
  81. package/templates/init.include.yaml +3 -2
  82. package/templates/openapi.json +55 -10
  83. package/templates/web/src/notification/entry.ts +46 -9
  84. package/templates/web/src/notification/locales/en-US.json +19 -0
  85. package/templates/web/src/notification/locales/zh-CN.json +19 -0
  86. package/templates/web/src/notification/pages/center/index.tsx +131 -0
  87. package/templates/web/src/oj-market/entry.ts +8 -2
  88. package/templates/web/src/personal-center/__client/api.schemas.ts +9 -0
  89. package/templates/web/src/personal-center/__client/api.ts +25 -1
  90. package/templates/web/src/personal-center/entry.ts +5 -0
  91. package/templates/web/src/personal-center/pages/my-profile/index.tsx +25 -3
  92. package/templates/web/src/system/entry.ts +2 -1
  93. package/templates/web.config.ts +10 -7
  94. package/vendor/host-versions.json +3 -3
  95. package/templates/api/src/notifications/api.ts +0 -29
  96. package/templates/api/src/notifications/manifest.yaml +0 -5
  97. package/templates/web/src/notifications/__client/api.schemas.ts +0 -8
  98. package/templates/web/src/notifications/__client/api.ts +0 -80
@@ -0,0 +1,506 @@
1
+ /**
2
+ * `ojm market walkthrough`(T10–T12):在隔离站点上把市场主链路跑一遍,逐项给 AC。
3
+ *
4
+ * 每个 op 的形状固定:setup → act → assert → cleanup(cleanup 在 finally)。`--only <op>` 可单跑,
5
+ * 每个 op 的 setup 自己建立前置态(不依赖前序 op 的残留)——这是「原子」的可测定义。
6
+ *
7
+ * 输出是一张 AC 表 + 汇总行;有 FAIL 即退出码 1。`--strict` 首败即停(调试用)。
8
+ *
9
+ * T12 追加:upgrade / rollback(主链路)与 neg-tamper / nosig / badindex / stranger(负向)。
10
+ * neg-* 统一用 9.9.9 版本与主链路隔离;篡改的文件在 finally 里回抄原始字节(不重签),
11
+ * 收尾摘包重铺索引——否则后续 op 全挂。
12
+ */
13
+
14
+ import type { TaskRecord } from "../site";
15
+ import type { IsolatedSite } from "./bootstrap";
16
+ import { Buffer } from "node:buffer";
17
+ import { randomBytes } from "node:crypto";
18
+ import fs from "node:fs";
19
+
20
+ import path from "node:path";
21
+ import process from "node:process";
22
+ import { signCommand } from "../sign";
23
+ import { callApi, login, pollTask, unwrapEnvelope } from "../site";
24
+ import { bootstrapIsolatedSite, derivePublisherKey } from "./bootstrap";
25
+ import { buildIndex } from "./build-index";
26
+ import { FAILURE_PLAYBOOK } from "./failure-playbook";
27
+ import { buildEvilZipSlipTgz, parseFixtureSpec } from "./fixture";
28
+ import { publishPackage } from "./publish";
29
+ import { writeWalkthroughReport } from "./report";
30
+ import { setupMarketUrl } from "./setup";
31
+
32
+ interface Ctx {
33
+ site: IsolatedSite
34
+ base: string
35
+ token: string
36
+ name: string
37
+ version: string
38
+ log: (line: string) => void
39
+ }
40
+
41
+ interface OpResult {
42
+ ok: boolean
43
+ detail: string
44
+ }
45
+
46
+ interface Op {
47
+ /** 命令里 --only 用的名字 */
48
+ key: string
49
+ title: string
50
+ run: (ctx: Ctx) => Promise<OpResult>
51
+ }
52
+
53
+ export interface WalkthroughOptions {
54
+ projectRoot?: string
55
+ fixture?: string
56
+ /** 只跑某个 op */
57
+ only?: string
58
+ /** 保留隔离现场 */
59
+ keep?: boolean
60
+ /** 首败即停 */
61
+ strict?: boolean
62
+ onLog?: (line: string) => void
63
+ }
64
+
65
+ export interface WalkthroughResult {
66
+ ok: boolean
67
+ rows: Array<{ key: string, title: string, ok: boolean, detail: string }>
68
+ port: number
69
+ }
70
+
71
+ /** neg-* 专用版本:与主链路(0.1.0/0.2.0)完全隔离,收尾整包摘除 */
72
+ const NEG_VERSION = "9.9.9";
73
+
74
+ const OPS: Op[] = [
75
+ {
76
+ key: "index",
77
+ title: "市场索引出卡片",
78
+ run: async (ctx) => {
79
+ const rows = await callApi<Array<Record<string, unknown>>>(fetch, ctx.base, ctx.token, "GET", "/api/_platform/market/index");
80
+ const hit = (rows ?? []).find(r => r.name === ctx.name);
81
+ return hit === undefined
82
+ ? { ok: false, detail: `索引里没有 ${ctx.name}(拿到 ${(rows ?? []).length} 张卡)` }
83
+ : { ok: true, detail: `${ctx.name} 在列,latest=${String(hit.latest)},available=${JSON.stringify(hit.available ?? [])}` };
84
+ },
85
+ },
86
+ {
87
+ key: "install",
88
+ title: "安装(11 段)",
89
+ run: async (ctx) => {
90
+ const task = await install(ctx, ctx.version);
91
+ const done = task.status === "done";
92
+ return { ok: done, detail: done ? `${task.stages.length} 段全 ok` : `status=${task.status} stage=${task.stage ?? "?"}:${task.message}` };
93
+ },
94
+ },
95
+ {
96
+ key: "reinstall",
97
+ title: "同版本重装走重装路径",
98
+ run: async (ctx) => {
99
+ await ensureInstalled(ctx);
100
+ const task = await install(ctx, ctx.version);
101
+ const ok = task.status === "done";
102
+ return { ok, detail: ok ? "重装成功(未报方案名占用)" : `status=${task.status}:${task.message}` };
103
+ },
104
+ },
105
+ {
106
+ key: "enable",
107
+ title: "禁用 / 启用",
108
+ run: async (ctx) => {
109
+ await ensureInstalled(ctx);
110
+ const off = await callApi<{ ok?: boolean, message?: string }>(fetch, ctx.base, ctx.token, "POST", "/api/_platform/market/enable", { name: ctx.name, enabled: false });
111
+ if (off.ok !== true)
112
+ return { ok: false, detail: `禁用被拒:${off.message ?? "?"}` };
113
+ const on = await callApi<{ ok?: boolean, message?: string }>(fetch, ctx.base, ctx.token, "POST", "/api/_platform/market/enable", { name: ctx.name, enabled: true });
114
+ return on.ok === true
115
+ ? { ok: true, detail: "disable → enable 双向成功" }
116
+ : { ok: false, detail: `启用被拒:${on.message ?? "?"}` };
117
+ },
118
+ },
119
+ {
120
+ key: "verify",
121
+ title: "站点深检",
122
+ run: async (ctx) => {
123
+ await ensureInstalled(ctx);
124
+ const res = await callApi<{ ok?: boolean, checks?: Array<{ id?: string, severity?: string }>, advise?: unknown }>(fetch, ctx.base, ctx.token, "POST", `/api/_platform/solutions/verify?name=${encodeURIComponent(ctx.name)}`);
125
+ // 本 op 断言「深检可用且报告结构完整」,不做裁决。checks 是问题清单:pitfall 10 修复后
126
+ // 全绿站点恒为空数组——空是健康态不是端点异常(此前的非空断言其实寄生在 ledger.stale 误报上)。
127
+ if (typeof res.ok !== "boolean" || !Array.isArray(res.checks) || !Array.isArray(res.advise))
128
+ return { ok: false, detail: "深检报告结构破损(缺 ok/checks/advise 字段)" };
129
+ const errors = res.checks.filter(c => c.severity === "error").map(c => c.id ?? "?");
130
+ return {
131
+ ok: true,
132
+ detail: res.checks.length === 0
133
+ ? "报告结构完整,checks 为零问题(账本/RBAC/设置/路由/web 全绿)"
134
+ : `报告结构完整(${res.checks.length} 项问题);error 项:${[...new Set(errors)].join("/") || "无"}`,
135
+ };
136
+ },
137
+ },
138
+ {
139
+ key: "history",
140
+ title: "操作历史可读",
141
+ run: async (ctx) => {
142
+ const rows = await callApi<unknown[]>(fetch, ctx.base, ctx.token, "GET", "/api/_platform/market/history");
143
+ const n = Array.isArray(rows) ? rows.length : 0;
144
+ return n > 0 ? { ok: true, detail: `历史 ${n} 条` } : { ok: false, detail: "历史为空" };
145
+ },
146
+ },
147
+ {
148
+ key: "upgrade",
149
+ title: "升级(0.1.0 → 0.2.0)",
150
+ run: async (ctx) => {
151
+ await ensureInstalled(ctx); // 前置:0.1.0 在列(账本 head=1)
152
+ await publishVersion(ctx, "0.2.0");
153
+ const task = await install(ctx, "0.2.0");
154
+ if (task.status !== "done")
155
+ return { ok: false, detail: `升级安装未 done:status=${task.status} stage=${task.stage ?? "?"}:${task.message}` };
156
+ const card = await indexCard(ctx);
157
+ const distDir = path.join(ctx.site.root, "api", "dist");
158
+ const dir010 = path.join(distDir, `${ctx.name}-0.1.0`);
159
+ const dir020 = path.join(distDir, `${ctx.name}-0.2.0`);
160
+ const mig010 = path.join(dir010, "migrations", "0001__init.sql");
161
+ const mig020 = path.join(dir020, "migrations", "0001__init.sql");
162
+ const installedOk = card?.installed === "0.2.0";
163
+ const bothAvailable = JSON.stringify(card?.available ?? []).includes("0.1.0") && JSON.stringify(card?.available ?? []).includes("0.2.0");
164
+ const dirsKept = fs.existsSync(dir010) && fs.existsSync(dir020);
165
+ const migSame = fs.existsSync(mig010) && fs.existsSync(mig020) && fs.readFileSync(mig010).equals(fs.readFileSync(mig020));
166
+ const ok = installedOk && bothAvailable && dirsKept && migSame;
167
+ return {
168
+ ok,
169
+ detail: ok
170
+ ? "installed=0.2.0,双版本在列且目录共存(旧版不 prune),0001 迁移逐字节一致"
171
+ : `installedOk=${installedOk} bothAvailable=${bothAvailable} dirsKept=${dirsKept} migSame=${migSame}`,
172
+ };
173
+ },
174
+ },
175
+ {
176
+ key: "rollback",
177
+ title: "回滚拦截(账本超前,OJ-9)",
178
+ run: async (ctx) => {
179
+ // 前置:0.2.0 已装(账本 head=2)。--only 单跑时自建(upgrade op 不依赖前序残留,此处同纪律)。
180
+ let card = await indexCard(ctx);
181
+ if (card?.installed !== "0.2.0") {
182
+ await publishVersion(ctx, "0.2.0");
183
+ const pre = await install(ctx, "0.2.0");
184
+ if (pre.status !== "done")
185
+ return { ok: false, detail: `前置安装 0.2.0 未 done:status=${pre.status}:${pre.message}` };
186
+ card = await indexCard(ctx);
187
+ }
188
+ const distDir = path.join(ctx.site.root, "api", "dist");
189
+ const before = fs.readdirSync(distDir).sort(); // 零副作用基线:0.1.0 目录是前序合法安装的产物,不代表本次落盘
190
+ const task = await installOnce(ctx, "0.1.0"); // 包内最大 seq 1 < head 2
191
+ const blocked = task.status === "failed" && task.stage === "migrating";
192
+ const untouched = JSON.stringify(fs.readdirSync(distDir).sort()) === JSON.stringify(before);
193
+ const kept = card?.installed === "0.2.0";
194
+ const ok = blocked && untouched && kept;
195
+ return {
196
+ ok,
197
+ detail: ok
198
+ ? "failed@migrating(宿主层/oj migrate 双层拦截之一),dist 内容零变化,installed 仍 0.2.0"
199
+ : `blocked=${blocked}(status=${task.status} stage=${task.stage})untouched=${untouched} kept=${kept}:${task.message}`,
200
+ };
201
+ },
202
+ },
203
+ {
204
+ key: "uninstall",
205
+ title: "卸载清场",
206
+ run: async (ctx) => {
207
+ const card = await indexCard(ctx);
208
+ const installed = typeof card?.installed === "string" && card.installed !== "" ? card.installed : null;
209
+ if (installed === null)
210
+ await ensureInstalled(ctx); // 未装过:先装 ctx.version
211
+ const target = installed ?? ctx.version;
212
+ const res = await callApi<{ ok?: boolean, message?: string }>(fetch, ctx.base, ctx.token, "POST", "/api/_platform/market/uninstall", { name: ctx.name });
213
+ if (res.ok !== true)
214
+ return { ok: false, detail: `卸载被拒:${res.message ?? "?"}` };
215
+ const dir = path.join(ctx.site.root, "api", "dist", `${ctx.name}-${target}`);
216
+ return fs.existsSync(dir)
217
+ ? { ok: false, detail: `卸载后目录仍在:${dir}` }
218
+ : { ok: true, detail: `${ctx.name}@${target} 锁 / 目录 / 注册面清场` };
219
+ },
220
+ },
221
+ {
222
+ key: "neg-tamper",
223
+ title: "负向:包体篡改(sha256 不符)",
224
+ run: async (ctx) => {
225
+ await publishVersion(ctx, NEG_VERSION);
226
+ const tgz = path.join(ctx.site.cdnDir, `${ctx.name}-${NEG_VERSION}.tgz`);
227
+ const orig = fs.readFileSync(tgz);
228
+ try {
229
+ const tampered = Buffer.from(orig);
230
+ tampered[Math.floor(tampered.length / 2)] ^= 0x01; // 翻转中间一个字节
231
+ fs.writeFileSync(tgz, tampered);
232
+ const task = await installOnce(ctx, NEG_VERSION);
233
+ const rejected = task.status === "failed" && task.stage === "verifying";
234
+ const noDrop = !fs.existsSync(path.join(ctx.site.root, "api", "dist", `${ctx.name}-${NEG_VERSION}`));
235
+ const ok = rejected && noDrop;
236
+ return {
237
+ ok,
238
+ detail: ok
239
+ ? "failed@verifying(sha256 与索引不符,C2-29 fail-closed),零落盘"
240
+ : `status=${task.status} stage=${task.stage} noDrop=${noDrop}:${task.message}`,
241
+ };
242
+ }
243
+ finally {
244
+ fs.writeFileSync(tgz, orig); // 回抄原始字节,不重签
245
+ await removeVersion(ctx, NEG_VERSION);
246
+ }
247
+ },
248
+ },
249
+ {
250
+ key: "neg-nosig",
251
+ title: "负向:缺包级签名(sig_missing)",
252
+ run: async (ctx) => {
253
+ await publishVersion(ctx, NEG_VERSION);
254
+ const tgz = path.join(ctx.site.cdnDir, `${ctx.name}-${NEG_VERSION}.tgz`);
255
+ const sig = `${tgz}.sig`;
256
+ const origSig = fs.readFileSync(sig);
257
+ try {
258
+ fs.rmSync(sig);
259
+ await buildIndex({ projectRoot: ctx.site.root, id: ctx.site.publisher.keyId, privateKeyPem: ctx.site.publisher.privateKeyPem }); // Form B 缺 archive.signature
260
+ const task = await installOnce(ctx, NEG_VERSION);
261
+ const reason = task.error?.reason ?? task.message;
262
+ const ok = task.status === "failed" && reason.includes("sig_missing");
263
+ return { ok, detail: ok ? "failed,失败码 sig_missing(G4 硬 fail-closed)" : `status=${task.status}:${reason}` };
264
+ }
265
+ finally {
266
+ fs.writeFileSync(sig, origSig); // 回抄原始字节
267
+ await removeVersion(ctx, NEG_VERSION);
268
+ }
269
+ },
270
+ },
271
+ {
272
+ key: "neg-badindex",
273
+ title: "负向:索引篡改(验签拒载)",
274
+ run: async (ctx) => {
275
+ await publishVersion(ctx, NEG_VERSION);
276
+ const bundles = path.join(ctx.site.cdnDir, "bundles.yaml");
277
+ const orig = fs.readFileSync(bundles);
278
+ try {
279
+ fs.appendFileSync(bundles, "# tampered\n"); // 索引字节变、签名失配
280
+ let msg = "";
281
+ try {
282
+ await installOnce(ctx, NEG_VERSION);
283
+ }
284
+ catch (error) {
285
+ msg = (error as Error).message; // 预期:安装 POST 即 502,任务都未创建
286
+ }
287
+ const ok = msg.includes("签名校验失败");
288
+ return { ok, detail: ok ? "安装 POST 即 502 拒载(G4 fail-closed),任务未创建" : `未按预期拒装:${msg === "" ? "安装竟成功" : msg}` };
289
+ }
290
+ finally {
291
+ fs.writeFileSync(bundles, orig); // 回抄原始字节
292
+ await removeVersion(ctx, NEG_VERSION);
293
+ }
294
+ },
295
+ },
296
+ {
297
+ key: "neg-stranger",
298
+ title: "负向:未登记 key(TOFU 闭环)",
299
+ run: async (ctx) => {
300
+ await publishVersion(ctx, NEG_VERSION);
301
+ const tgz = path.join(ctx.site.cdnDir, `${ctx.name}-${NEG_VERSION}.tgz`);
302
+ const sig = `${tgz}.sig`;
303
+ const orig = fs.readFileSync(tgz);
304
+ const origSig = fs.readFileSync(sig);
305
+ const stranger = derivePublisherKey(randomBytes(16).toString("hex")); // 未登记身份,私钥只在内存
306
+ const before = await trustKeys(ctx); // 信任库快照(F3 diff 基线)
307
+ try {
308
+ signCommand(tgz, { id: "stranger.publisher", privateKeyPem: stranger.privateKeyPem }); // 覆盖侧车签名
309
+ await buildIndex({ projectRoot: ctx.site.root, id: "stranger.publisher", privateKeyPem: stranger.privateKeyPem });
310
+ const first = await installOnce(ctx, NEG_VERSION);
311
+ if (first.status !== "needs_trust")
312
+ return { ok: false, detail: `未停 needs_trust:status=${first.status}:${first.message}` };
313
+ await callApi(fetch, ctx.base, ctx.token, "POST", "/api/_platform/market/trust", { task_id: first.id });
314
+ const second = await installOnce(ctx, NEG_VERSION);
315
+ if (second.status !== "done")
316
+ return { ok: false, detail: `确认后重装未 done:status=${second.status}:${second.message}` };
317
+ // 收尾(F3):显式 revoke 陌生 key,快照 diff 断言无残留——不靠 uninstall 兜底(它本就不摘信任)
318
+ const del = await callApi<{ revoked?: boolean }>(fetch, ctx.base, ctx.token, "DELETE", `/api/_platform/market/trust/key?key_id=${encodeURIComponent("stranger.publisher")}`);
319
+ const after = await trustKeys(ctx);
320
+ const revoked = del.revoked === true;
321
+ const noResidue = JSON.stringify(after) === JSON.stringify(before);
322
+ const ok = revoked && noResidue;
323
+ return {
324
+ ok,
325
+ detail: ok
326
+ ? "needs_trust → confirm → done;stranger key 已 revoke,信任库快照 diff 为空"
327
+ : `revoked=${revoked} noResidue=${noResidue}(before=${before.length} after=${after.length})`,
328
+ };
329
+ }
330
+ finally {
331
+ fs.writeFileSync(tgz, orig); // 回抄原始字节,不重签
332
+ fs.writeFileSync(sig, origSig);
333
+ await removeVersion(ctx, NEG_VERSION);
334
+ }
335
+ },
336
+ },
337
+ {
338
+ key: "neg-evil",
339
+ title: "负向:zip-slip 越界包(R1 拒收)",
340
+ run: async (ctx) => {
341
+ const evil = path.join(ctx.site.cdnDir, `${ctx.name}-evil.tgz`);
342
+ buildEvilZipSlipTgz(evil); // 成员含 ../evil.sql,无需发布进索引(file 源直传)
343
+ const distDir = path.join(ctx.site.root, "api", "dist");
344
+ const before = fs.readdirSync(distDir).sort(); // 零副作用基线:9.9.9 目录可能是前序 neg-stranger 的合法产物
345
+ try {
346
+ let msg = "";
347
+ try {
348
+ await installFile(ctx, evil, NEG_VERSION);
349
+ }
350
+ catch (error) {
351
+ msg = (error as Error).message; // 预期:上传校验 400,任务未创建
352
+ }
353
+ const rejected = msg.includes("zip-slip");
354
+ const untouched = JSON.stringify(fs.readdirSync(distDir).sort()) === JSON.stringify(before);
355
+ const ok = rejected && untouched;
356
+ return {
357
+ ok,
358
+ detail: ok
359
+ ? "上传即 400(R1 zip-slip 防护拒在解包前),dist 内容零变化"
360
+ : `rejected=${rejected} untouched=${untouched}:${msg === "" ? "安装竟成功" : msg}`,
361
+ };
362
+ }
363
+ finally {
364
+ fs.rmSync(evil, { force: true });
365
+ }
366
+ },
367
+ },
368
+ ];
369
+
370
+ export async function walkthrough(opts: WalkthroughOptions = {}): Promise<WalkthroughResult> {
371
+ const log = opts.onLog ?? ((line: string) => console.log(line));
372
+ const spec = parseFixtureSpec(opts.fixture);
373
+ const site = await bootstrapIsolatedSite({ projectRoot: opts.projectRoot, fixture: opts.fixture, keep: opts.keep, onLog: log });
374
+ try {
375
+ const newPass = "trial-pass-1";
376
+ await setupMarketUrl({ projectRoot: site.root, url: site.baseUrl, user: site.admin.user, pass: site.admin.pass, newPass });
377
+ const token = await login(fetch, site.baseUrl, site.admin.user, newPass);
378
+ const ctx: Ctx = { site, base: site.baseUrl, token, name: spec.name, version: spec.version, log };
379
+
380
+ const selected = opts.only === undefined ? OPS : OPS.filter(op => op.key === opts.only);
381
+ if (selected.length === 0)
382
+ throw new Error(`[ojm] 未知 op「${opts.only}」——可用:${OPS.map(op => op.key).join(" / ")}`);
383
+
384
+ const rows: WalkthroughResult["rows"] = [];
385
+ for (const op of selected) {
386
+ let row: WalkthroughResult["rows"][number];
387
+ try {
388
+ const result = await op.run(ctx);
389
+ row = { key: op.key, title: op.title, ok: result.ok, detail: result.detail };
390
+ }
391
+ catch (error) {
392
+ row = { key: op.key, title: op.title, ok: false, detail: (error as Error).message };
393
+ }
394
+ rows.push(row);
395
+ log(`${row.ok ? "✓" : "✗"} ${op.key.padEnd(10)} ${op.title}${row.ok ? "" : ` —— ${row.detail}`}`);
396
+ if (!row.ok && opts.strict === true)
397
+ break;
398
+ }
399
+
400
+ const failed = rows.filter(r => !r.ok).length;
401
+ log(`\n${failed === 0 ? "全部通过" : `${failed} 项未过`}(${rows.length} 项):${rows.filter(r => r.ok).length} PASS / ${failed} FAIL`);
402
+ // 报告落盘(白名单内容 + 黑名单脱敏兜底):AC 表 + 汇总 + 失败文案表
403
+ const reportPath = writeWalkthroughReport({ rows, port: site.port, playbook: FAILURE_PLAYBOOK });
404
+ log(`[ojm] 报告已落盘 → ${reportPath}`);
405
+ return { ok: failed === 0, rows, port: site.port };
406
+ }
407
+ finally {
408
+ await site.cleanup();
409
+ }
410
+ }
411
+
412
+ /** 安装 + 轮询;needs_trust 时自动确认(隔离站点,无外部信任可言) */
413
+ async function install(ctx: Ctx, version: string): Promise<TaskRecord> {
414
+ let task = await installOnce(ctx, version);
415
+ if (task.status === "needs_trust") {
416
+ await callApi(fetch, ctx.base, ctx.token, "POST", "/api/_platform/market/trust", { task_id: task.id });
417
+ task = await installOnce(ctx, version);
418
+ }
419
+ return task;
420
+ }
421
+
422
+ async function installOnce(ctx: Ctx, version: string): Promise<TaskRecord> {
423
+ const started = await callApi<{ task_id?: string, id?: string }>(fetch, ctx.base, ctx.token, "POST", "/api/_platform/market/install", { name: ctx.name, version, source: "cdn" });
424
+ const taskId = String(started.task_id ?? started.id ?? "");
425
+ if (taskId === "")
426
+ throw new Error("[ojm] 安装接口未返回 task id");
427
+ return pollTask(fetch, ctx.base, ctx.token, taskId, { interval0: 400, intervalMax: 2000, timeoutMs: 120_000 });
428
+ }
429
+
430
+ /** file 源 multipart 上传(与 install.ts 同形态;neg-evil 用它递 zip-slip 恶意包) */
431
+ async function installFile(ctx: Ctx, tgzPath: string, version: string): Promise<void> {
432
+ const form = new FormData();
433
+ form.append("name", ctx.name);
434
+ form.append("version", version);
435
+ form.append("source", "file");
436
+ form.append("file", new Blob([fs.readFileSync(tgzPath)], { type: "application/gzip" }), path.basename(tgzPath));
437
+ const res = await fetch(`${ctx.base}/api/_platform/market/install`, {
438
+ method: "POST",
439
+ headers: { authorization: `Bearer ${ctx.token}` },
440
+ body: form,
441
+ });
442
+ await unwrapEnvelope(res); // 非 0 code 抛人话(400 zip-slip / 502 索引等)
443
+ }
444
+
445
+ /** 信任库 key_id 快照(GET /market/trust;neg-stranger 的 diff 断言用) */
446
+ async function trustKeys(ctx: Ctx): Promise<string[]> {
447
+ const res = await callApi<{ keys?: Array<{ key_id?: string }> }>(fetch, ctx.base, ctx.token, "GET", "/api/_platform/market/trust");
448
+ return (res.keys ?? []).map(k => k.key_id ?? "").filter(id => id !== "").sort();
449
+ }
450
+
451
+ /** 前置态:保证目标版本已装(其他 op 不依赖前序 op 的残留) */
452
+ async function ensureInstalled(ctx: Ctx): Promise<void> {
453
+ const card = await indexCard(ctx);
454
+ if (card?.installed === ctx.version)
455
+ return;
456
+ const task = await install(ctx, ctx.version);
457
+ if (task.status !== "done")
458
+ throw new Error(`前置安装失败:status=${task.status}:${task.message}`);
459
+ }
460
+
461
+ /** 索引卡片(本方案一行;upgrade/rollback/uninstall 与 index op 同一消费面) */
462
+ async function indexCard(ctx: Ctx): Promise<Record<string, unknown> | undefined> {
463
+ const rows = await callApi<Array<Record<string, unknown>>>(fetch, ctx.base, ctx.token, "GET", "/api/_platform/market/index");
464
+ return (rows ?? []).find(r => r.name === ctx.name);
465
+ }
466
+
467
+ /** 发布某版本 fixture 进隔离 CDN 并重铺索引(upgrade/rollback/neg-* 的共用前置) */
468
+ async function publishVersion(ctx: Ctx, version: string): Promise<void> {
469
+ await publishPackage({ projectRoot: ctx.site.root, fixture: `${ctx.name}@${version}`, id: ctx.site.publisher.keyId, privateKeyPem: ctx.site.publisher.privateKeyPem });
470
+ await buildIndex({ projectRoot: ctx.site.root, id: ctx.site.publisher.keyId, privateKeyPem: ctx.site.publisher.privateKeyPem });
471
+ }
472
+
473
+ /** neg op 收尾:整包摘除(tgz + 侧车签名)并重铺索引,恢复主链路原状 */
474
+ async function removeVersion(ctx: Ctx, version: string): Promise<void> {
475
+ for (const file of [`${ctx.name}-${version}.tgz`, `${ctx.name}-${version}.tgz.sig`])
476
+ fs.rmSync(path.join(ctx.site.cdnDir, file), { force: true });
477
+ await buildIndex({ projectRoot: ctx.site.root, id: ctx.site.publisher.keyId, privateKeyPem: ctx.site.publisher.privateKeyPem });
478
+ }
479
+
480
+ export const WALKTHROUGH_OPS = OPS.map(op => op.key);
481
+
482
+ export async function walkthroughCommand(argv: string[]): Promise<void> {
483
+ const opts: WalkthroughOptions = {};
484
+ for (let i = 0; i < argv.length; i++) {
485
+ const a = argv[i]!;
486
+ if (a === "--keep") {
487
+ opts.keep = true;
488
+ continue;
489
+ }
490
+ if (a === "--strict") {
491
+ opts.strict = true;
492
+ continue;
493
+ }
494
+ if (a === "--only" || a === "--fixture") {
495
+ const v = argv[i + 1] ?? "";
496
+ i++;
497
+ if (a === "--only")
498
+ opts.only = v;
499
+ else
500
+ opts.fixture = v;
501
+ }
502
+ }
503
+ const result = await walkthrough(opts);
504
+ if (!result.ok)
505
+ process.exitCode = 1;
506
+ }
package/src/oj.ts CHANGED
@@ -113,7 +113,9 @@ export function startOj(
113
113
  });
114
114
  // spawn 本身失败(如 ENOENT)只发 error 不发 exit,必须单独兜住
115
115
  child.once("error", (err) => {
116
- finish(new Error(`[ojm] 无法启动 oj(${binPath}):${err.message}`));
116
+ const enoent = (err as NodeJS.ErrnoException).code === "ENOENT";
117
+ const hint = enoent ? "\n下一步:oj 二进制尚未安装(它在 .gitignore 里,不随仓库分发)。\n在本工程目录跑一次 `ojm vendor`(经 npm 包 @oj-bin/oj 下载),再重试本命令。" : "";
118
+ finish(new Error(`[ojm] 无法启动 oj(${binPath}):${err.message}${hint}`));
117
119
  });
118
120
  const timeout = setTimeout(() => {
119
121
  finish(new Error(
@@ -30,6 +30,12 @@ export const PREVIEW_CONFIG_NAME = ".ojm-preview.yaml";
30
30
  export interface DerivePreviewConfigOptions {
31
31
  /** 全站产物目录(layout.distDir),写入 web 挂载的绝对路径 */
32
32
  siteDir: string
33
+ /**
34
+ * release 形态的后端模块根(api/dist)。给了且原 config 声明了 platform.modules 时,
35
+ * 把 `platform.root` 一并改写到该目录——特权模块的受控根必须与 `--api-path` 同源,
36
+ * 否则 release 下 reloadConfig 会按 dev 的 src 判定归属(装好的方案热更不生效)。
37
+ */
38
+ apiRoot?: string
33
39
  /** 显式端口时覆盖 server.port;缺省沿用原 config(oj 代码默认 9778) */
34
40
  port?: number
35
41
  /** 原 config 文本(测试注入);缺省读 api/config.yaml */
@@ -120,6 +126,14 @@ export function derivePreviewConfigText(configText: string, opts: DerivePreviewC
120
126
  }
121
127
  doc.mounts = mounts;
122
128
 
129
+ // platform.root 跟随 api 挂载根:dev = src,release(preview)= dist
130
+ const platform = doc.platform;
131
+ if (opts.apiRoot !== undefined && platform !== null && typeof platform === "object" && !Array.isArray(platform)) {
132
+ const mods = (platform as Record<string, unknown>).modules;
133
+ if (Array.isArray(mods) && mods.length > 0)
134
+ (platform as Record<string, unknown>).root = opts.apiRoot;
135
+ }
136
+
123
137
  if (opts.port !== undefined) {
124
138
  const server = doc.server !== null && typeof doc.server === "object" && !Array.isArray(doc.server)
125
139
  ? { ...(doc.server as Record<string, unknown>) }
package/src/preview.ts CHANGED
@@ -40,7 +40,7 @@ export interface PreviewOptions {
40
40
  /** 注入桩 oj 进程;默认 startOj */
41
41
  ojStarter?: (configPath: string, base: string, apiPath: string, extraArgs: string[]) => OjProcess
42
42
  /** 注入桩派生 config 落盘(测试);默认 writePreviewConfig */
43
- configWriter?: (projectRoot: string, opts: { siteDir: string, port?: number }) => PreviewConfigFiles
43
+ configWriter?: (projectRoot: string, opts: { siteDir: string, port?: number, apiRoot?: string }) => PreviewConfigFiles
44
44
  }
45
45
 
46
46
  export interface PreviewHandle {
@@ -81,8 +81,9 @@ export async function runPreview(projectRoot: string, opts: PreviewOptions = {})
81
81
 
82
82
  // 派生 config 在 migrate 成功后落盘:失败即退路径零残留
83
83
  const base = readOjApiPrefix(configPath);
84
- const writer = opts.configWriter ?? ((root: string, o: { siteDir: string, port?: number }) => writePreviewConfig(root, o));
85
- const preview = writer(projectRoot, { siteDir, port: opts.port });
84
+ const writer = opts.configWriter ?? ((root: string, o: { siteDir: string, port?: number, apiRoot?: string }) => writePreviewConfig(root, o));
85
+ // apiRoot 交给派生器:release 下 platform.root 必须指向 api/dist(与 --api-path 同源)
86
+ const preview = writer(projectRoot, { siteDir, port: opts.port, apiRoot: apiDist });
86
87
 
87
88
  // / api 挂载与注入的 / web 挂载配对时 oj 不开 SPA 回落(oj 语义,见 devkit 手册 §13)
88
89
  if (hasRootApiMount(parseMounts(readFileSync(configPath, "utf-8"), configPath)))
package/src/sign.ts CHANGED
@@ -17,6 +17,12 @@ export interface SignOptions {
17
17
  id?: string
18
18
  /** 私钥 PEM 文件(缺省 ~/.ojm/keys/<id>/private.pem) */
19
19
  key?: string
20
+ /**
21
+ * 内存私钥 PEM(一次性/隔离场景专用,与 --key 二选一)。
22
+ * 用途:`ojm market trial` 的一次性 keypair 不落盘,经此直接传进来;
23
+ * 给了它就跳过文件存在性检查,也永远不读真 ~/.ojm/keys(计划 §3.3.1)。
24
+ */
25
+ privateKeyPem?: string
20
26
  /** 写完离线自检(verifyCanonical 复核) */
21
27
  check?: boolean
22
28
  /** 测试注入;缺省 process.cwd() */
@@ -46,10 +52,17 @@ export function signCommand(tgz: string, opts: SignOptions = {}): SignResult {
46
52
  if (!fs.existsSync(tgz))
47
53
  throw new Error(`[ojm] 方案包不存在:${tgz}`);
48
54
  const cwd = opts.cwd ?? process.cwd();
49
- // key 解析:--key 显式文件优先;否则 ~/.ojm/keys/<id>/private.pem(--id 必填)
50
- let keyFile: string;
55
+ // key 解析:内存 PEM(不落盘)> --key 显式文件 > ~/.ojm/keys/<id>/private.pem(--id 必填)
56
+ let keyFile = "";
51
57
  let keyId: string;
52
- if (opts.key !== undefined) {
58
+ let memoryPem: string | undefined;
59
+ if (opts.privateKeyPem !== undefined && opts.privateKeyPem !== "") {
60
+ memoryPem = opts.privateKeyPem;
61
+ keyId = opts.id ?? "";
62
+ if (keyId === "")
63
+ throw new Error("[ojm] 使用内存私钥时须同时给 --id <publisher.id>(.sig 的发布方标识;C2-37 命名空间)");
64
+ }
65
+ else if (opts.key !== undefined) {
53
66
  keyFile = path.resolve(cwd, opts.key);
54
67
  keyId = opts.id ?? keyIdFromKeyPath(path.relative(os.homedir(), keyFile)) ?? "";
55
68
  if (keyId === "")
@@ -63,13 +76,15 @@ export function signCommand(tgz: string, opts: SignOptions = {}): SignResult {
63
76
  }
64
77
  if (!isValidKeyId(keyId))
65
78
  throw new Error(`[ojm] key_id 命名空间非法(C2-37):「${keyId}」`);
66
- if (!fs.existsSync(keyFile))
67
- throw new Error(`[ojm] 私钥文件不存在:${keyFile}(用 ojm keygen --id ${keyId} 生成,或 --key 指定)`);
79
+ if (memoryPem === undefined) {
80
+ if (!fs.existsSync(keyFile!))
81
+ throw new Error(`[ojm] 私钥文件不存在:${keyFile}(用 ojm keygen --id ${keyId} 生成,或 --key 指定)`);
82
+ }
68
83
 
69
84
  const bytes = new Uint8Array(fs.readFileSync(tgz));
70
85
  const sha256 = createHash("sha256").update(bytes).digest("hex");
71
86
  const canonical = canonicalString(deriveName(tgz), deriveVersion(tgz), sha256);
72
- const privateKeyPem = readPrivateKeyPem(keyFile);
87
+ const privateKeyPem = memoryPem ?? readPrivateKeyPem(keyFile!);
73
88
  const value = signCanonical(privateKeyPem, canonical);
74
89
  const publicKeyHex = exportRawPublicKeyHex(privateKeyPem);
75
90
  const sig = { alg: SIG_ALG, key_id: keyId, public_key: publicKeyHex, value, canon: CANON };
package/src/usage.ts CHANGED
@@ -65,6 +65,25 @@ export function usageText(): string {
65
65
  (与 archive .sig 同构,公钥取 ~/.ojm/keys/<id>/private.pem,缺省 --id 推导)→ 写回
66
66
  <dir>。CDN 侧 resolveCdn/index 拉取索引时校验签名(篡改/不符即拒,G4 fail-closed)。
67
67
  --check 离线自检两份索引签名
68
+ ojm market setup [--url <站点地址>] [--user <u> --pass <p>] [--new-pass <p>]
69
+ 把系统设置 framework.market_cdn_url 指向本地模拟 CDN(工程根 cdn/,由
70
+ api/config.yaml 的 /cdn 挂载直出)。首跑若账号处于首次改密期(seed admin
71
+ must_change=1),会自动改密换全权 token 后再写入。
72
+ ojm market publish [--fixture <name>@<version>] [--real <方案工程>] [--id <key>] [--key <pem>] [--rebuild-index] [--check]
73
+ 把方案包落进 cdn/:合成 fixture(或 --real 走 ojm build --bundle)→ 确定性 tgz
74
+ → 包级 detached 签名 <name>-<version>.tgz.sig。**不碰索引**(D-1:索引由
75
+ build-index 扫描重建,多人发布才不会互相覆盖)。
76
+ ojm market build-index [--id <key>] [--key <pem>] [--check]
77
+ 扫描 cdn/*.tgz、提取包内 manifest,全量重建 bundles.yaml + manifests.yaml 并签名。
78
+ 结果只取决于目录内容:连续执行字节一致。
79
+ ojm market verify [--file <tgz>] [--rebuild-index]
80
+ 离线验包(不起站点、不装、不要凭据):sha256 完整性 / 包级 Ed25519 验签 /
81
+ 索引验签 / manifest 结构(含 C2-31 desc===summary、各版本 0001 迁移一致)分列输出。
82
+ --rebuild-index 额外比对「扫描重建的索引」与盘上索引,检出落了包却没重建的漂移。
83
+ 注意:结论恒带边界声明——校验通过只说明来源与完整性,不构成内容安全性背书。
84
+ ojm market trial [--fixture <name>@<version>] [--keep]
85
+ 在一次性隔离站点(随机端口 + 一次性 DB/密钥/凭据/信任库/CDN)里跑完整安装,
86
+ 跑完即毁;--keep 保留现场供排查。用于确认「这个包到底能不能装」,不污染你的工程。
68
87
  ojm test|exec|openapi|migrate|schema [args...]
69
88
  透传给 oj 同名子命令(自动补 -c <project>/api/config.yaml;其余参数原样转发)
70
89
  例:ojm test --anonymous / ojm migrate --db report / ojm openapi --check
package/src/vendor.ts CHANGED
@@ -32,9 +32,11 @@ export const NPM_PKG = "@oj-bin/oj";
32
32
  * docs/superpowers/specs/2026-10-14-ojm-0.1.18-design.md);**0.1.20 抬到 0.1.73**:
33
33
  * 平级 `.response` 出参声明位自 oj v0.1.72 起、`oj openapi` 的 `del`→`delete`
34
34
  * 操作键归一自 v0.1.73 起,`ojm api` 的 openapi 流水线以这两者为前提
35
- * (见 CHANGELOG 0.1.20 / docs/guides/api-codegen-openapi.md)。
35
+ * (见 CHANGELOG 0.1.20 / docs/guides/api-codegen-openapi.md)。**0.2.13 抬到 0.1.81**:
36
+ * 模板侧 `solution-perms.ts` 权限级联清理用 `startswith` 操作键(v0.1.81 补齐,
37
+ * 构造器迁移收尾),oj < 0.1.81 时卸载方案报 `unknown variant startswith` fail-closed。
36
38
  */
37
- export const MIN_OJ_VERSION = "0.1.73";
39
+ export const MIN_OJ_VERSION = "0.1.81";
38
40
 
39
41
  /** 按段数值比较(字符串比较会把 0.1.9 判成大于 0.1.69);非数字段按 0 处理 */
40
42
  export function isSupportedOjVersion(version: string): boolean {