@oj-module/cli 0.2.8 → 0.2.15
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.
- package/devkit/README.md +70 -24
- package/devkit/framework-dev.md +14 -5
- package/devkit/manual.md +69 -6
- package/devkit/module-dev-and-cdn-playground.md +197 -0
- package/devkit/sql-builder-missing-capabilities-pr.md +38 -4
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +262 -45
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/build-backend.test.ts +64 -0
- package/src/__tests__/versions.test.ts +26 -0
- package/src/build.ts +9 -0
- package/src/bundle.ts +8 -5
- package/src/contract/__tests__/market-client.integration.test.ts +13 -10
- package/src/index.ts +6 -0
- package/src/init.ts +22 -0
- package/src/lifecycle/__tests__/bundle.test.ts +3 -3
- package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/en-US.json +2 -1
- package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/zh-CN.json +2 -1
- package/src/lifecycle/__tests__/manifest-contract.test.ts +70 -1
- package/src/manifest-contract.ts +25 -2
- package/src/market/__tests__/bootstrap.test.ts +204 -0
- package/src/market/__tests__/build-index.test.ts +154 -0
- package/src/market/__tests__/cdn-layout.test.ts +96 -0
- package/src/market/__tests__/failure-playbook.test.ts +33 -0
- package/src/market/__tests__/fixture.test.ts +48 -0
- package/src/market/__tests__/publish.test.ts +109 -0
- package/src/market/__tests__/report.test.ts +51 -0
- package/src/market/__tests__/setup.test.ts +114 -0
- package/src/market/__tests__/verify.test.ts +111 -0
- package/src/market/bootstrap.ts +255 -0
- package/src/market/build-index.ts +159 -0
- package/src/market/cdn-layout.ts +122 -0
- package/src/market/command.ts +139 -0
- package/src/market/failure-playbook.ts +57 -0
- package/src/market/fixture.ts +138 -0
- package/src/market/index-builder.ts +76 -0
- package/src/market/publish.ts +156 -0
- package/src/market/report.ts +67 -0
- package/src/market/scan.ts +99 -0
- package/src/market/setup.ts +144 -0
- package/src/market/trial.ts +121 -0
- package/src/market/verify.ts +298 -0
- package/src/market/walkthrough.ts +506 -0
- package/src/oj.ts +3 -1
- package/src/preview-config.ts +14 -0
- package/src/preview.ts +4 -3
- package/src/sign.ts +21 -6
- package/src/usage.ts +19 -0
- package/src/vendor.ts +4 -2
- package/src/versions.ts +28 -0
- package/templates/api/.ojm-api-exempt.json +6 -2
- package/templates/api/config.yaml +20 -0
- package/templates/api/src/_platform/_lifecycle/steps/extract.ts +8 -8
- package/templates/api/src/_platform/_lifecycle/uninstall.ts +21 -53
- package/templates/api/src/_platform/_shared/service/domain/contacts.ts +15 -10
- package/templates/api/src/_platform/_shared/service/domain/solution-perms.ts +8 -6
- package/templates/api/src/_platform/_shared/service/infra/oplog.ts +16 -23
- package/templates/api/src/_platform/_shared/service/infra/query.ts +18 -0
- package/templates/api/src/_platform/_shared/service/infra/rate.ts +4 -1
- package/templates/api/src/_platform/fixtures/l1-test-user.sql +13 -0
- package/templates/api/src/_platform/manifest.yaml +1 -0
- package/templates/api/src/_platform/migrations/0013__users_profile.sql +4 -0
- package/templates/api/src/_platform/schema.yaml +12 -0
- package/templates/api/src/auth/_shared/session.ts +2 -2
- package/templates/api/src/demo/todos/api.ts +8 -9
- package/templates/api/src/notification/_shared/notify.ts +64 -26
- package/templates/api/src/notification/api.ts +1 -1
- package/templates/api/src/notification/manifest.yaml +1 -0
- package/templates/api/src/notification/migrations/0004__notifications_broadcast_hidden.sql +12 -0
- package/templates/api/src/notification/schema.yaml +7 -0
- package/templates/api/src/notification/seed.sql +5 -4
- package/templates/api/src/personal-center/profile/api.ts +63 -0
- package/templates/api/src/web/user-info/api.ts +3 -1
- package/templates/api/tests/fixture.ts +8 -16
- package/templates/api/tests/l1-builder.test.ts +91 -0
- package/templates/api/tests/lifecycle/installer.spec.ts +1 -4
- package/templates/api/tests/lifecycle/market-api.spec.ts +1 -3
- package/templates/api/tests/lifecycle/steps-fs.spec.ts +5 -8
- package/templates/api/tests/lifecycle/uninstall.spec.ts +28 -88
- package/templates/api/tests/query-utils.spec.ts +85 -1
- package/templates/api/tests/rbac-settings.spec.ts +64 -19
- package/templates/cdn/README.md +48 -0
- package/templates/global.d.ts +36 -2
- package/templates/init.include.yaml +3 -2
- package/templates/openapi.json +55 -10
- package/templates/web/src/notification/entry.ts +46 -9
- package/templates/web/src/notification/locales/en-US.json +19 -0
- package/templates/web/src/notification/locales/zh-CN.json +19 -0
- package/templates/web/src/notification/pages/center/index.tsx +131 -0
- package/templates/web/src/oj-market/entry.ts +8 -2
- package/templates/web/src/personal-center/__client/api.schemas.ts +9 -0
- package/templates/web/src/personal-center/__client/api.ts +25 -1
- package/templates/web/src/personal-center/entry.ts +5 -0
- package/templates/web/src/personal-center/pages/my-profile/index.tsx +25 -3
- package/templates/web/src/system/entry.ts +2 -1
- package/templates/web.config.ts +10 -7
- package/vendor/host-versions.json +3 -3
- package/templates/api/src/notifications/api.ts +0 -29
- package/templates/api/src/notifications/manifest.yaml +0 -5
- package/templates/web/src/notifications/__client/api.schemas.ts +0 -8
- package/templates/web/src/notifications/__client/api.ts +0 -80
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// market/report spec(T12 收尾):报告白黑名单——白名单内容落盘,黑名单字段写盘前被掩码。
|
|
2
|
+
import { mkdtempSync, readFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { FAILURE_PLAYBOOK } from "../failure-playbook";
|
|
8
|
+
import { sanitizeReportText, writeWalkthroughReport } from "../report";
|
|
9
|
+
|
|
10
|
+
describe("given 含黑名单字段的文本 When 过脱敏", () => {
|
|
11
|
+
it("then Bearer token / JWT / PEM / jwt_secret 全部被掩码,白名单内容(指纹/sha256/key_id)原样保留", () => {
|
|
12
|
+
const text = [
|
|
13
|
+
"authorization: Bearer abc.def-123~",
|
|
14
|
+
"jwt: eyJhbGciOiJ.eyJzdWI.SflKxw",
|
|
15
|
+
"-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMG\n-----END PRIVATE KEY-----",
|
|
16
|
+
"jwt_secret: hunter2",
|
|
17
|
+
"fingerprint 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 / key_id demo.publisher / sha256 abc123",
|
|
18
|
+
].join("\n");
|
|
19
|
+
const out = sanitizeReportText(text);
|
|
20
|
+
expect(out).not.toContain("abc.def-123");
|
|
21
|
+
expect(out).not.toContain("eyJhbGciOiJ");
|
|
22
|
+
expect(out).not.toContain("MIGHAgEAMBMG");
|
|
23
|
+
expect(out).not.toContain("hunter2");
|
|
24
|
+
expect(out).toContain("Bearer ***");
|
|
25
|
+
expect(out).toContain("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"); // 白名单原样
|
|
26
|
+
expect(out).toContain("demo.publisher");
|
|
27
|
+
// 幂等:二次过检不再变化
|
|
28
|
+
expect(sanitizeReportText(out)).toBe(out);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("given 一次 walkthrough 的行 When 落盘报告", () => {
|
|
33
|
+
it("then 写出 AC 表 + 文案表,且 detail 里的 token 被掩码", () => {
|
|
34
|
+
const dir = mkdtempSync(join(tmpdir(), "ojm-report-"));
|
|
35
|
+
const file = writeWalkthroughReport({
|
|
36
|
+
rows: [
|
|
37
|
+
{ key: "install", title: "安装(11 段)", ok: true, detail: "" },
|
|
38
|
+
{ key: "neg-x", title: "负向示例", ok: false, detail: "泄漏 Authorization: Bearer leAk.ed-tok~en" },
|
|
39
|
+
],
|
|
40
|
+
port: 49999,
|
|
41
|
+
playbook: FAILURE_PLAYBOOK,
|
|
42
|
+
logDir: dir,
|
|
43
|
+
});
|
|
44
|
+
const text = readFileSync(file, "utf-8");
|
|
45
|
+
expect(file).toContain(join(dir, "walkthrough-"));
|
|
46
|
+
expect(text).toContain("| install | PASS | 安装(11 段) |");
|
|
47
|
+
expect(text).toContain("| neg-x | FAIL |");
|
|
48
|
+
expect(text).toContain("sig_missing"); // 文案表段在场
|
|
49
|
+
expect(text).not.toContain("leAk.ed-tok"); // 黑名单兜底生效
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// ojm market setup spec(T5):首跑打开市场地址。
|
|
2
|
+
//
|
|
3
|
+
// 两条必守:① 写出的地址必须是 config 推导的**绝对 URL**(dev 形态下相对路径会拿到 index.html,
|
|
4
|
+
// 见设计 §3.1.1);② seed admin 处于 must_change 悬挂期时,登录拿的是限权 token,
|
|
5
|
+
// 必须先改密拿全权 token 才写得了 system 设置——这条不闭环,首跑就直接卡死。
|
|
6
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
|
|
11
|
+
import { setupMarketUrl } from "../setup";
|
|
12
|
+
|
|
13
|
+
const MOUNTS = " - prefix: /api\n api: src\n - prefix: /cdn\n web: ../cdn\n";
|
|
14
|
+
const CONFIG = `mounts:\n${MOUNTS}server:\n host: 127.0.0.1\n port: 9778\n`;
|
|
15
|
+
const MARKET_KEY = "framework.market_cdn_url";
|
|
16
|
+
|
|
17
|
+
interface Call {
|
|
18
|
+
url: string
|
|
19
|
+
method: string
|
|
20
|
+
body?: Record<string, unknown>
|
|
21
|
+
auth?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 假站点:可编排 login 是否受限、settings 是否拒绝 */
|
|
25
|
+
function fakeSite(opts: { restricted?: boolean, rejectSettings?: boolean, rejectLogin?: boolean } = {}) {
|
|
26
|
+
const calls: Call[] = [];
|
|
27
|
+
const fetchFn = (async (url: string, init?: { method?: string, body?: string, headers?: Record<string, string> }): Promise<Response> => {
|
|
28
|
+
const method = init?.method ?? "GET";
|
|
29
|
+
const body = init?.body === undefined ? undefined : JSON.parse(init.body) as Record<string, unknown>;
|
|
30
|
+
calls.push({ url, method, body, auth: init?.headers?.authorization });
|
|
31
|
+
if (url.endsWith("/api/auth/login")) {
|
|
32
|
+
if (opts.rejectLogin === true)
|
|
33
|
+
return json({ code: 401, msg: "用户名或密码错误", data: null });
|
|
34
|
+
return json({ code: 0, msg: "ok", data: { access_token: "token-limited", restricted: opts.restricted === true } });
|
|
35
|
+
}
|
|
36
|
+
if (url.endsWith("/api/auth/password"))
|
|
37
|
+
return json({ code: 0, msg: "ok", data: { access_token: "token-full", refresh_token: "r", expires_in: 3600 } });
|
|
38
|
+
if (url.endsWith("/api/system/settings")) {
|
|
39
|
+
if (opts.rejectSettings === true)
|
|
40
|
+
return json({ code: 403, msg: "must_change", data: null });
|
|
41
|
+
return json({ code: 0, msg: "ok", data: true });
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`未预期的请求:${method} ${url}`);
|
|
44
|
+
}) as unknown as typeof fetch;
|
|
45
|
+
return { calls, fetchFn };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function json(payload: unknown): Response {
|
|
49
|
+
return { ok: true, status: 200, text: async () => JSON.stringify(payload) } as unknown as Response;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function newProject(): string {
|
|
53
|
+
const root = mkdtempSync(join(tmpdir(), "ojm-market-setup-"));
|
|
54
|
+
mkdirSync(join(root, "api"), { recursive: true });
|
|
55
|
+
writeFileSync(join(root, "api", "config.yaml"), CONFIG, "utf-8");
|
|
56
|
+
mkdirSync(join(root, "cdn"), { recursive: true });
|
|
57
|
+
return root;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("given 站点可直连且凭据未悬挂 When setup", () => {
|
|
61
|
+
it("then 直接写入 config 推导的绝对市场地址", async () => {
|
|
62
|
+
const root = newProject();
|
|
63
|
+
const site = fakeSite();
|
|
64
|
+
|
|
65
|
+
const result = await setupMarketUrl({ projectRoot: root, user: "admin", pass: "123456", url: "http://127.0.0.1:9778", fetchFn: site.fetchFn });
|
|
66
|
+
|
|
67
|
+
expect(result.cdnBaseUrl).toBe("http://127.0.0.1:9778/cdn");
|
|
68
|
+
const put = site.calls.find(c => c.url.endsWith("/api/system/settings"))!;
|
|
69
|
+
expect(put.method).toBe("PUT");
|
|
70
|
+
expect(put.body).toEqual({ key: MARKET_KEY, value: "http://127.0.0.1:9778/cdn" });
|
|
71
|
+
expect(result.changedPassword).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("given seed admin 处于 must_change 悬挂期 When setup", () => {
|
|
76
|
+
it("then 先改密换全权 token,再写设置(且后续请求用新 token)", async () => {
|
|
77
|
+
const root = newProject();
|
|
78
|
+
const site = fakeSite({ restricted: true });
|
|
79
|
+
|
|
80
|
+
const result = await setupMarketUrl({ projectRoot: root, user: "admin", pass: "123456", newPass: "newpass-123", url: "http://127.0.0.1:9778", fetchFn: site.fetchFn });
|
|
81
|
+
|
|
82
|
+
expect(result.changedPassword).toBe(true);
|
|
83
|
+
const pwd = site.calls.find(c => c.url.endsWith("/api/auth/password"))!;
|
|
84
|
+
expect(pwd.method).toBe("PUT");
|
|
85
|
+
expect(pwd.body).toEqual({ old_password: "123456", new_password: "newpass-123" });
|
|
86
|
+
const put = site.calls.find(c => c.url.endsWith("/api/system/settings"))!;
|
|
87
|
+
expect(put.auth).toBe("Bearer token-full");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("given 站点不可达 When setup", () => {
|
|
92
|
+
it("then 抛人话错误提示先起站点,不写任何东西", async () => {
|
|
93
|
+
const root = newProject();
|
|
94
|
+
const failing = async (): Promise<Response> => {
|
|
95
|
+
throw new Error("ECONNREFUSED");
|
|
96
|
+
};
|
|
97
|
+
const fetchFn = failing as unknown as typeof fetch;
|
|
98
|
+
|
|
99
|
+
await expect(setupMarketUrl({ projectRoot: root, user: "admin", pass: "123456", url: "http://127.0.0.1:9778", fetchFn }))
|
|
100
|
+
.rejects
|
|
101
|
+
.toThrow(/ojm dev/);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("given 改密后写设置仍被拒 When setup", () => {
|
|
106
|
+
it("then 抛人话错误而不是静默成功", async () => {
|
|
107
|
+
const root = newProject();
|
|
108
|
+
const site = fakeSite({ restricted: true, rejectSettings: true });
|
|
109
|
+
|
|
110
|
+
await expect(setupMarketUrl({ projectRoot: root, user: "admin", pass: "123456", newPass: "newpass-123", url: "http://127.0.0.1:9778", fetchFn: site.fetchFn }))
|
|
111
|
+
.rejects
|
|
112
|
+
.toThrow(/must_change|权限|设置/);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// ojm market verify spec(T7):离线验包——不起站点、不装、不要凭据。
|
|
2
|
+
//
|
|
3
|
+
// 钉死两条:① 四类校验分列(完整性 / 包级签 / 索引签 / 结构),篡改任一项都必须在对应项上打叉;
|
|
4
|
+
// ② 结论恒带边界声明——验签过 ≠ 代码无恶意,这一点写错比校验写错更危险。
|
|
5
|
+
import { Buffer } from "node:buffer";
|
|
6
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
7
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
|
|
12
|
+
import { buildIndex } from "../build-index";
|
|
13
|
+
import { publishPackage } from "../publish";
|
|
14
|
+
import { verifyMarket } from "../verify";
|
|
15
|
+
|
|
16
|
+
const MOUNTS = " - prefix: /api\n api: src\n - prefix: /cdn\n web: ../cdn\n";
|
|
17
|
+
const CONFIG = `mounts:\n${MOUNTS}server:\n host: 127.0.0.1\n port: 9778\n`;
|
|
18
|
+
const KEY_ID = "demo.publisher";
|
|
19
|
+
|
|
20
|
+
function testPem(): string {
|
|
21
|
+
return generateKeyPairSync("ed25519").privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function newProject(): string {
|
|
25
|
+
const root = mkdtempSync(join(tmpdir(), "ojm-market-verify-"));
|
|
26
|
+
mkdirSync(join(root, "api"), { recursive: true });
|
|
27
|
+
writeFileSync(join(root, "api", "config.yaml"), CONFIG, "utf-8");
|
|
28
|
+
mkdirSync(join(root, "cdn"), { recursive: true });
|
|
29
|
+
return root;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 落一个包并重建索引,返回工程根 */
|
|
33
|
+
async function seed(): Promise<string> {
|
|
34
|
+
const root = newProject();
|
|
35
|
+
await publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: testPem() });
|
|
36
|
+
await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
|
|
37
|
+
return root;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function itemOf(result: Awaited<ReturnType<typeof verifyMarket>>, name: string): { ok: boolean, detail: string } | undefined {
|
|
41
|
+
return result.checks.find(c => c.item === name);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("given 一个正常落地的包 When 离线验包", () => {
|
|
45
|
+
it("then 四类校验全绿且结论带边界声明", async () => {
|
|
46
|
+
const root = await seed();
|
|
47
|
+
|
|
48
|
+
const result = await verifyMarket({ projectRoot: root });
|
|
49
|
+
|
|
50
|
+
expect(result.passed).toBe(true);
|
|
51
|
+
expect(itemOf(result, "完整性")?.ok).toBe(true);
|
|
52
|
+
expect(itemOf(result, "包级签名")?.ok).toBe(true);
|
|
53
|
+
expect(itemOf(result, "索引签名")?.ok).toBe(true);
|
|
54
|
+
expect(itemOf(result, "manifest 结构")?.ok).toBe(true);
|
|
55
|
+
expect(result.conclusion).toContain("不构成内容安全性背书");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("given 包体被改了一个字节 When 离线验包", () => {
|
|
60
|
+
it("then 完整性与包级签名双双打叉(sha256 不符 + 验签不过)", async () => {
|
|
61
|
+
const root = await seed();
|
|
62
|
+
const tgz = join(root, "cdn", "demo-solution-0.1.0.tgz");
|
|
63
|
+
const bytes = Buffer.from(readFileSync(tgz));
|
|
64
|
+
bytes[bytes.length - 1] = bytes[bytes.length - 1]! ^ 0xFF;
|
|
65
|
+
writeFileSync(tgz, bytes);
|
|
66
|
+
|
|
67
|
+
const result = await verifyMarket({ projectRoot: root });
|
|
68
|
+
|
|
69
|
+
expect(result.passed).toBe(false);
|
|
70
|
+
expect(itemOf(result, "完整性")?.ok).toBe(false);
|
|
71
|
+
expect(itemOf(result, "包级签名")?.ok).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("given 包级签名被删掉 When 离线验包", () => {
|
|
76
|
+
it("then 包级签名打叉并给出 sig_missing 口径的指引", async () => {
|
|
77
|
+
const root = await seed();
|
|
78
|
+
rmSync(join(root, "cdn", "demo-solution-0.1.0.tgz.sig"));
|
|
79
|
+
|
|
80
|
+
const result = await verifyMarket({ projectRoot: root });
|
|
81
|
+
|
|
82
|
+
expect(itemOf(result, "包级签名")?.ok).toBe(false);
|
|
83
|
+
expect(itemOf(result, "包级签名")?.detail).toMatch(/sig|签名/);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("given 索引被手改过 When 离线验包", () => {
|
|
88
|
+
it("then 索引签名打叉(索引签名是完整性层,篡改必须可见)", async () => {
|
|
89
|
+
const root = await seed();
|
|
90
|
+
const bundles = join(root, "cdn", "bundles.yaml");
|
|
91
|
+
writeFileSync(bundles, `${readFileSync(bundles, "utf-8")}\n# tampered\n`);
|
|
92
|
+
|
|
93
|
+
const result = await verifyMarket({ projectRoot: root });
|
|
94
|
+
|
|
95
|
+
expect(itemOf(result, "索引签名")?.ok).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("given 落了 tgz 但没重建索引 When 带 --rebuild-check 验包", () => {
|
|
100
|
+
it("then 报漂移:索引缺该包条目", async () => {
|
|
101
|
+
const root = newProject();
|
|
102
|
+
await publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: testPem() });
|
|
103
|
+
await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
|
|
104
|
+
await publishPackage({ projectRoot: root, fixture: "other-solution@0.1.0", id: KEY_ID, privateKeyPem: testPem() });
|
|
105
|
+
|
|
106
|
+
const result = await verifyMarket({ projectRoot: root, rebuildCheck: true });
|
|
107
|
+
|
|
108
|
+
expect(result.drift?.length ?? 0).toBeGreaterThan(0);
|
|
109
|
+
expect(result.drift?.join(" ")).toMatch(/other-solution/);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 隔离站点自举(T8):`ojm market trial` / `walkthrough` 的地基。
|
|
3
|
+
*
|
|
4
|
+
* 目标:给你一个「别人家的站点」来试装——不碰你的工程、DB、信任库、端口。
|
|
5
|
+
* 隔离六项各有落点:
|
|
6
|
+
* - **端口**:临时根 config 写入空闲端口(startOj 从 config 读端口,故派生 config 是唯一真相);
|
|
7
|
+
* - **DB**:oj 的 sqlite 按 config 所在目录解析,临时根内自然隔离(dbPath 暴露供断言);
|
|
8
|
+
* - **密钥**:发布方 Ed25519 由公开 seed 经 SHA-256 派生(照 fixtures/sig 夹具手法),
|
|
9
|
+
* 私钥只在内存,临时根内无 PEM(站点自己的开发证书走 mintDevCert,属站点身份);
|
|
10
|
+
* - **凭据**:隔离库是全新 seed,admin 口令即种子默认值,与真站点凭据零交集;
|
|
11
|
+
* - **信任库**:`.solutions/trusted-keys.yaml` 只在安装器写入时生成,全新根内不存在;
|
|
12
|
+
* - **CDN 目录**:临时根内 cdn/,由本模块自建(publish + build-index 直落)。
|
|
13
|
+
*
|
|
14
|
+
* 提库来源:`scripts/verify-solution-smoke/bootstrap-host.mjs`(同手法,脚本形态);
|
|
15
|
+
* 本文件是它的 cli 内嵌版(计划 §9.1:phase1 从这里提库而非重写)。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { OjProcess } from "../oj";
|
|
19
|
+
import { Buffer } from "node:buffer";
|
|
20
|
+
import { createHash, createPrivateKey, randomBytes } from "node:crypto";
|
|
21
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
22
|
+
import net from "node:net";
|
|
23
|
+
import { tmpdir } from "node:os";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
|
|
26
|
+
import process from "node:process";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
import { buildBackend } from "../build";
|
|
29
|
+
import { exportRawPublicKeyHex, fingerprintOfPublicKey } from "../lifecycle/sign";
|
|
30
|
+
import { startOj } from "../oj";
|
|
31
|
+
import { mintDevCert } from "../oj-cert";
|
|
32
|
+
import { resolveOjBin } from "../vendor";
|
|
33
|
+
import { buildIndex } from "./build-index";
|
|
34
|
+
import { parseFixtureSpec } from "./fixture";
|
|
35
|
+
import { publishPackage } from "./publish";
|
|
36
|
+
|
|
37
|
+
/** 种子管理员(隔离库全新 seed;与 templates seed.sql 一致) */
|
|
38
|
+
const SEED_ADMIN_USER = "admin";
|
|
39
|
+
const SEED_ADMIN_PASS = "123456";
|
|
40
|
+
|
|
41
|
+
/** Ed25519 PKCS#8 DER 前缀(RFC 8410 固定头;与 gen-signed-fixtures.mjs 同一手法) */
|
|
42
|
+
const ED25519_PKCS8_PREFIX = "302e020100300506032b657004220420";
|
|
43
|
+
|
|
44
|
+
export interface IsolatedPublisher {
|
|
45
|
+
keyId: string
|
|
46
|
+
privateKeyPem: string
|
|
47
|
+
publicKeyHex: string
|
|
48
|
+
fingerprint: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface IsolatedSite {
|
|
52
|
+
/** 临时根(含 api/ bin/ cdn/) */
|
|
53
|
+
root: string
|
|
54
|
+
port: number
|
|
55
|
+
baseUrl: string
|
|
56
|
+
apiBase: string
|
|
57
|
+
cdnDir: string
|
|
58
|
+
cdnBaseUrl: string
|
|
59
|
+
/** sqlite 库路径(config 目录相对解析;断言隔离用) */
|
|
60
|
+
dbPath: string
|
|
61
|
+
/** 一次性发布方身份(私钥只在内存) */
|
|
62
|
+
publisher: IsolatedPublisher
|
|
63
|
+
/** 隔离库的种子管理员 */
|
|
64
|
+
admin: { user: string, pass: string }
|
|
65
|
+
/** 销毁隔离根与 oj 进程;幂等 */
|
|
66
|
+
cleanup: () => Promise<void>
|
|
67
|
+
/** 销毁清单(--keep 时给人看这里到底有什么) */
|
|
68
|
+
teardownReport: () => string[]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface BootstrapOptions {
|
|
72
|
+
/** 源工程(提供 bin/oj 与 cli 内置模板);缺省 process.cwd() */
|
|
73
|
+
projectRoot?: string
|
|
74
|
+
/** 落进隔离 CDN 的 fixture;缺省 demo-solution@0.1.0 */
|
|
75
|
+
fixture?: string
|
|
76
|
+
/** 保留现场(cleanup 不删根,只打清单) */
|
|
77
|
+
keep?: boolean
|
|
78
|
+
/** 测试注入;缺省真 startOj */
|
|
79
|
+
starter?: (configPath: string, base: string, apiSrc: string) => OjProcess
|
|
80
|
+
/** 日志旁路(测试收集用) */
|
|
81
|
+
onLog?: (line: string) => void
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function bootstrapIsolatedSite(opts: BootstrapOptions = {}): Promise<IsolatedSite> {
|
|
85
|
+
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
86
|
+
const log = (line: string) => (opts.onLog ?? ((l: string) => console.log(l)))(line);
|
|
87
|
+
|
|
88
|
+
const ojBin = resolveOjBin(projectRoot);
|
|
89
|
+
if (!existsSync(ojBin)) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`[ojm] 源工程未安装 oj 二进制:${ojBin}\n`
|
|
92
|
+
+ "下一步:在源工程目录跑一次 `ojm vendor`(经 npm 包 @oj-bin/oj 下载),再重试。",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const root = mkdtempSync(path.join(tmpdir(), "ojm-trial-"));
|
|
97
|
+
const apiDir = path.join(root, "api");
|
|
98
|
+
const configPath = path.join(apiDir, "config.yaml");
|
|
99
|
+
const cdnDir = path.join(root, "cdn");
|
|
100
|
+
const dbPath = path.join(apiDir, "db.sqlite");
|
|
101
|
+
|
|
102
|
+
// 1) api 树 + config(模板由 cli 包内置,init 同源;与 cli→templates 依赖红线不冲突——那是运行期文件而非 import)
|
|
103
|
+
const cliRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
104
|
+
const templateApi = path.join(cliRoot, "templates", "api");
|
|
105
|
+
if (!existsSync(path.join(templateApi, "config.yaml"))) {
|
|
106
|
+
throw new Error(`[ojm] cli 内置模板缺失:${templateApi}(@oj-module/cli 安装不完整?)`);
|
|
107
|
+
}
|
|
108
|
+
cpSync(path.join(templateApi, "src"), path.join(apiDir, "src"), { recursive: true });
|
|
109
|
+
writeFileSync(configPath, readFileSync(path.join(templateApi, "config.yaml"), "utf-8"), "utf-8");
|
|
110
|
+
|
|
111
|
+
// 2) oj 二进制与插件(startOj 按 <root>/bin/oj 解析;plugins 让 auth 插件可用)
|
|
112
|
+
mkdirSync(path.join(root, "bin"), { recursive: true });
|
|
113
|
+
cpSync(ojBin, path.join(root, "bin", "oj"), { recursive: true });
|
|
114
|
+
const versionMarker = path.join(projectRoot, "bin", ".oj-version");
|
|
115
|
+
if (existsSync(versionMarker)) {
|
|
116
|
+
cpSync(versionMarker, path.join(root, "bin", ".oj-version"));
|
|
117
|
+
}
|
|
118
|
+
const pluginsDir = path.join(projectRoot, "bin", "plugins");
|
|
119
|
+
if (existsSync(pluginsDir)) {
|
|
120
|
+
cpSync(pluginsDir, path.join(root, "bin", "plugins"), { recursive: true });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 3) 派生 config:空闲端口 / 随机 jwt / CDN 挂载指绝对路径 / fs 段
|
|
124
|
+
const port = await freePort();
|
|
125
|
+
let cfg = readFileSync(configPath, "utf-8");
|
|
126
|
+
cfg = cfg.replace("port: 9778", `port: ${port}`);
|
|
127
|
+
if (!cfg.includes(`port: ${port}`)) {
|
|
128
|
+
throw new Error(`[ojm] 模板 config 未含 端口: 9778 字面(模板结构变了?):${configPath}`);
|
|
129
|
+
}
|
|
130
|
+
cfg = cfg.replace("__JWT_SECRET__", randomBytes(32).toString("hex"));
|
|
131
|
+
// 最小 SPA 挂载:真实部署里前端路由(深检会逐条探活)由站点壳承接;没有 web 挂载时
|
|
132
|
+
// 这些路径一律 404,深检会判成 anomaly。这里放一个 index.html 就够(不构建前端)。
|
|
133
|
+
// 挂在 /cdn 之后(前缀特异性匹配,顺序无关,仅为可读)。
|
|
134
|
+
const siteDir = path.join(root, "site");
|
|
135
|
+
mkdirSync(siteDir, { recursive: true });
|
|
136
|
+
writeFileSync(path.join(siteDir, "index.html"), "<!doctype html><html><body>ojm trial site</body></html>\n", "utf-8");
|
|
137
|
+
cfg = cfg.replace("web: ../cdn", `web: ${cdnDir}\n - prefix: /\n web: ${siteDir}\n spa: true`);
|
|
138
|
+
// 安装目标恒为 release 部署:装好的模块落在 api/dist,dev 形态(--api-path src)发现不了。
|
|
139
|
+
// 故隔离站点以 dist 起站,platform.root 同步改写(oj 校验 root 必须是生效的 api 挂载目录)。
|
|
140
|
+
cfg = cfg.replace(" root: src", " root: dist");
|
|
141
|
+
// auth 守卫在装配期读 anonymous_paths,安装器注册的新匿名端点要重启才生效(首次安装 smoke 会 401)。
|
|
142
|
+
// 隔离站点直接预置被测方案的探活路径——与 scripts/verify-solution-smoke/bootstrap-host.mjs 同手法,
|
|
143
|
+
// 让 trial 验的是安装机制本身,而不是重启时序。
|
|
144
|
+
const healthPath = `/${parseFixtureSpec(opts.fixture).name}/ping`;
|
|
145
|
+
if (!cfg.includes(`- ${healthPath}`))
|
|
146
|
+
cfg = cfg.replace(/^(\s*)- \/system\/login-methods$/m, `$1- /system/login-methods\n$1- ${healthPath}`);
|
|
147
|
+
// fs 段:模板自 0.2.13 起已自带(安装器落盘面);旧模板没有时才补,避免重复键
|
|
148
|
+
if (!/^fs:/m.test(cfg))
|
|
149
|
+
cfg = `${cfg.trimEnd()}\nfs:\n root: .\n readonly: false\n`;
|
|
150
|
+
writeFileSync(configPath, cfg, "utf-8");
|
|
151
|
+
|
|
152
|
+
// 4) 站点开发证书(oj serve 无绕过开关;mintDevCert 落 api/config 三件套)
|
|
153
|
+
await mintDevCert(path.join(apiDir, "config"));
|
|
154
|
+
|
|
155
|
+
// 5) 一次性发布方身份:seed 派生,私钥只在内存
|
|
156
|
+
const keyId = "trial.publisher";
|
|
157
|
+
const publisher = derivePublisherKey(randomBytes(16).toString("hex"));
|
|
158
|
+
|
|
159
|
+
// 6) 隔离 CDN:落 fixture 包 + 重建索引(publish/buildIndex 直落临时根)
|
|
160
|
+
mkdirSync(cdnDir, { recursive: true });
|
|
161
|
+
await publishPackage({ projectRoot: root, fixture: opts.fixture, id: keyId, privateKeyPem: publisher.privateKeyPem });
|
|
162
|
+
await buildIndex({ projectRoot: root, id: keyId, privateKeyPem: publisher.privateKeyPem });
|
|
163
|
+
|
|
164
|
+
// 7) 后端构建(api/dist + versions.json;安装与 reloading 都依赖它)
|
|
165
|
+
await buildBackend(root);
|
|
166
|
+
|
|
167
|
+
// 7b) 起 oj(starter 注入供测试);ready 失败即整体作废,不留孤儿
|
|
168
|
+
const starter = opts.starter ?? ((cfgPath: string, base: string, apiSrc: string) => startOj(cfgPath, base, apiSrc));
|
|
169
|
+
let oj: OjProcess;
|
|
170
|
+
try {
|
|
171
|
+
oj = starter(configPath, "/api", path.join(apiDir, "dist"));
|
|
172
|
+
await oj.ready;
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
rmSync(root, { recursive: true, force: true });
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
log(`[ojm] 隔离站点就绪:http://127.0.0.1:${port}(根 ${root})`);
|
|
179
|
+
|
|
180
|
+
// 8) cleanup:停进程 → 按 keep 决定删不删根 → 摘信号;全程幂等
|
|
181
|
+
let done = false;
|
|
182
|
+
let cleanup: () => Promise<void>;
|
|
183
|
+
const onSignal = () => {
|
|
184
|
+
void cleanup();
|
|
185
|
+
};
|
|
186
|
+
cleanup = async () => {
|
|
187
|
+
if (done) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
done = true;
|
|
191
|
+
process.off("SIGINT", onSignal);
|
|
192
|
+
process.off("SIGTERM", onSignal);
|
|
193
|
+
await oj.stop().catch(() => {});
|
|
194
|
+
if (opts.keep === true) {
|
|
195
|
+
log(`[ojm] --keep:隔离根保留在 ${root}(内容见销毁清单)`);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
rmSync(root, { recursive: true, force: true });
|
|
199
|
+
log("[ojm] 隔离站点已销毁");
|
|
200
|
+
};
|
|
201
|
+
process.on("SIGINT", onSignal);
|
|
202
|
+
process.on("SIGTERM", onSignal);
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
root,
|
|
206
|
+
port,
|
|
207
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
208
|
+
apiBase: "/api",
|
|
209
|
+
cdnDir,
|
|
210
|
+
cdnBaseUrl: `http://127.0.0.1:${port}/cdn`,
|
|
211
|
+
dbPath,
|
|
212
|
+
publisher: { keyId, privateKeyPem: publisher.privateKeyPem, publicKeyHex: publisher.publicKeyHex, fingerprint: fingerprintOfPublicKey(publisher.publicKeyHex) },
|
|
213
|
+
admin: { user: SEED_ADMIN_USER, pass: SEED_ADMIN_PASS },
|
|
214
|
+
cleanup,
|
|
215
|
+
teardownReport: () => teardownReport(root, port),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* seed 派生一次性 Ed25519 身份(照 fixtures/sig 夹具手法:公开 seed → SHA-256 → PKCS#8 DER)。
|
|
221
|
+
* 私钥只在内存;label 每次随机,故每次 trial 都是不同身份(不会与任何既有信任库条目撞)。
|
|
222
|
+
* 导出供 walkthrough 的 neg-stranger op 派生「未登记 key」(DRY:不重写派生手法)。
|
|
223
|
+
*/
|
|
224
|
+
export function derivePublisherKey(label: string): { privateKeyPem: string, publicKeyHex: string } {
|
|
225
|
+
const seed = createHash("sha256").update(`ojm-trial-publisher:${label}`).digest();
|
|
226
|
+
const der = Buffer.concat([Buffer.from(ED25519_PKCS8_PREFIX, "hex"), seed]);
|
|
227
|
+
const key = createPrivateKey({ key: der, format: "der", type: "pkcs8" });
|
|
228
|
+
const privateKeyPem = key.export({ type: "pkcs8", format: "pem" }).toString();
|
|
229
|
+
return { privateKeyPem, publicKeyHex: exportRawPublicKeyHex(privateKeyPem) };
|
|
230
|
+
}
|
|
231
|
+
/** 空闲端口:listen(0) 取端口后立刻归还(本地一次性使用,竞态可接受) */
|
|
232
|
+
function freePort(): Promise<number> {
|
|
233
|
+
return new Promise((resolve, reject) => {
|
|
234
|
+
const server = net.createServer();
|
|
235
|
+
server.once("error", reject);
|
|
236
|
+
server.listen(0, "127.0.0.1", () => {
|
|
237
|
+
const address = server.address();
|
|
238
|
+
const port = typeof address === "object" && address !== null ? address.port : 0;
|
|
239
|
+
server.close(() => resolve(port));
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** 销毁清单:--keep 时告诉人这里到底有什么、怎么手动清 */
|
|
245
|
+
function teardownReport(root: string, port: number): string[] {
|
|
246
|
+
const lines = [
|
|
247
|
+
`隔离根:${root}`,
|
|
248
|
+
" api/ 站点树(src + config.yaml + config/ 证书 + db.sqlite)",
|
|
249
|
+
" bin/ oj 二进制与插件(拷贝自源工程)",
|
|
250
|
+
" cdn/ 隔离 CDN(fixture 包 + 两份索引 + 签名)",
|
|
251
|
+
`监听端口:127.0.0.1:${port}(进程已停)`,
|
|
252
|
+
`手动清理:rm -rf ${root}`,
|
|
253
|
+
];
|
|
254
|
+
return lines;
|
|
255
|
+
}
|