@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.
- package/devkit/README.md +70 -24
- package/devkit/framework-dev.md +14 -5
- package/devkit/manual.md +9 -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 +130 -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/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,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ojm market setup`(T5):把 `framework.market_cdn_url` 指向本地模拟 CDN。
|
|
3
|
+
*
|
|
4
|
+
* 两件容易踩的事都收在这一处:
|
|
5
|
+
* 1. **地址必须是绝对的**,且端口来自 config 的 `server.port`——dev 形态下 dev server(5174)
|
|
6
|
+
* 只会把 `/api` 反代给 oj 子进程,`/cdn/*` 会被静态层回落成 index.html(设计 §3.1.1)。
|
|
7
|
+
* 2. **首跑凭据悬挂**:seed admin `must_change=1`,登录拿的是限权 token,写不了 system 设置;
|
|
8
|
+
* 须先 `PUT /api/auth/password` 改密换全权 token(该端点是悬挂期唯一可用写端)。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import process from "node:process";
|
|
13
|
+
|
|
14
|
+
import { resolveSite } from "../site";
|
|
15
|
+
import { resolveCdnLayout } from "./cdn-layout";
|
|
16
|
+
|
|
17
|
+
export const MARKET_CDN_URL_SETTING = "framework.market_cdn_url";
|
|
18
|
+
|
|
19
|
+
export interface SetupOptions {
|
|
20
|
+
projectRoot?: string
|
|
21
|
+
/** 站点地址(缺省取 OJM_URL 或 http://127.0.0.1:9778) */
|
|
22
|
+
url?: string
|
|
23
|
+
user?: string
|
|
24
|
+
pass?: string
|
|
25
|
+
/** 新口令;不给则随机生成并只在终端打印一次(不落盘、不进日志) */
|
|
26
|
+
newPass?: string
|
|
27
|
+
/** 测试注入;缺省全局 fetch */
|
|
28
|
+
fetchFn?: typeof fetch
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SetupResult {
|
|
32
|
+
siteUrl: string
|
|
33
|
+
cdnBaseUrl: string
|
|
34
|
+
/** 是否走了改密闭环(must_change 悬挂期) */
|
|
35
|
+
changedPassword: boolean
|
|
36
|
+
/** 随机生成的新口令(仅当未显式给 newPass 时才有值) */
|
|
37
|
+
generatedPassword?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function setupMarketUrl(opts: SetupOptions = {}): Promise<SetupResult> {
|
|
41
|
+
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
42
|
+
const layout = resolveCdnLayout(projectRoot);
|
|
43
|
+
const site = resolveSite({ url: opts.url, user: opts.user, pass: opts.pass });
|
|
44
|
+
const fetchFn = opts.fetchFn ?? fetch;
|
|
45
|
+
|
|
46
|
+
const token = await obtainToken(fetchFn, site.base, site.user, site.pass, opts.newPass, projectRoot);
|
|
47
|
+
|
|
48
|
+
await putSetting(fetchFn, site.base, token.value, MARKET_CDN_URL_SETTING, layout.baseUrl);
|
|
49
|
+
console.log(`[ojm] 市场地址已设置:${layout.baseUrl}`);
|
|
50
|
+
console.log(`[ojm] 市场页刷新后即可看到 ${pathOf(layout.dir)} 里的方案卡片`);
|
|
51
|
+
return {
|
|
52
|
+
siteUrl: site.base,
|
|
53
|
+
cdnBaseUrl: layout.baseUrl,
|
|
54
|
+
changedPassword: token.changedPassword,
|
|
55
|
+
generatedPassword: token.generatedPassword,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 登录并在必要时走改密闭环,返回可用 token */
|
|
60
|
+
async function obtainToken(fetchFn: typeof fetch, base: string, user: string, pass: string, newPass: string | undefined, projectRoot: string): Promise<{ value: string, changedPassword: boolean, generatedPassword?: string }> {
|
|
61
|
+
let login: { access_token?: string, restricted?: boolean };
|
|
62
|
+
try {
|
|
63
|
+
login = await postJson(fetchFn, `${base}/api/auth/login`, { username: user, password: pass });
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`[ojm] 连不上站点 ${base}:${(error as Error).message}\n`
|
|
68
|
+
+ "下一步:先起站点(ojm dev),再跑本命令。",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
const token = String(login.access_token ?? "");
|
|
72
|
+
if (token === "")
|
|
73
|
+
throw new Error("[ojm] 登录响应缺 access_token——核对站点版本与账号");
|
|
74
|
+
if (login.restricted !== true)
|
|
75
|
+
return { value: token, changedPassword: false };
|
|
76
|
+
|
|
77
|
+
// must_change 悬挂期:限权 token 写不了 system 设置,先改密换全权 token
|
|
78
|
+
const next = newPass ?? randomBytes(9).toString("base64url");
|
|
79
|
+
const bundle = await putJson(fetchFn, `${base}/api/auth/password`, token, { old_password: pass, new_password: next });
|
|
80
|
+
const full = String(bundle.access_token ?? "");
|
|
81
|
+
if (full === "") {
|
|
82
|
+
throw new Error(
|
|
83
|
+
"[ojm] 改密后未取得全权 token——账号仍处于 must_change 悬挂期。\n"
|
|
84
|
+
+ "下一步:用浏览器登录一次完成改密,或换一个已完成改密的超管账号重试。",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
console.log(`[ojm] 账号处于首次改密期,已改密并换取全权 token(新口令:${newPass === undefined ? next : "<由 --new-pass 指定>"})`);
|
|
88
|
+
if (newPass === undefined)
|
|
89
|
+
console.log("[ojm] 该口令仅此一次显示,不入日志不入盘——请自行留存");
|
|
90
|
+
void projectRoot;
|
|
91
|
+
return { value: full, changedPassword: true, generatedPassword: newPass === undefined ? next : undefined };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function putSetting(fetchFn: typeof fetch, base: string, token: string, key: string, value: string): Promise<void> {
|
|
95
|
+
try {
|
|
96
|
+
await putJson(fetchFn, `${base}/api/system/settings`, token, { key, value });
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
const message = (error as Error).message;
|
|
100
|
+
throw new Error(
|
|
101
|
+
`[ojm] 写入系统设置 ${key} 失败:${message}\n`
|
|
102
|
+
+ "下一步:确认账号是系统管理员(非仅超管模块权限),或用浏览器在「系统设置」里手动填写同一地址。",
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function postJson(fetchFn: typeof fetch, url: string, body: unknown): Promise<Record<string, unknown>> {
|
|
108
|
+
const res = await fetchFn(url, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { "content-type": "application/json" },
|
|
111
|
+
body: JSON.stringify(body),
|
|
112
|
+
});
|
|
113
|
+
return (await unwrap(res)) as Record<string, unknown>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function putJson(fetchFn: typeof fetch, url: string, token: string, body: unknown): Promise<Record<string, unknown>> {
|
|
117
|
+
const res = await fetchFn(url, {
|
|
118
|
+
method: "PUT",
|
|
119
|
+
headers: { "content-type": "application/json", "authorization": `Bearer ${token}` },
|
|
120
|
+
body: JSON.stringify(body),
|
|
121
|
+
});
|
|
122
|
+
return (await unwrap(res)) as Record<string, unknown>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** oj 信封解包({code,msg,data},code=0 为成) */
|
|
126
|
+
async function unwrap(res: Response): Promise<unknown> {
|
|
127
|
+
const text = await res.text();
|
|
128
|
+
let env: { code?: number, msg?: string, data?: unknown } | null = null;
|
|
129
|
+
try {
|
|
130
|
+
env = JSON.parse(text) as { code?: number, msg?: string, data?: unknown };
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
throw new Error(`[ojm] 站点响应不可解析(HTTP ${res.status}):${text.slice(0, 200)}`);
|
|
134
|
+
}
|
|
135
|
+
if (env === null || typeof env.code !== "number")
|
|
136
|
+
throw new Error(`[ojm] 站点响应缺 code 字段:${text.slice(0, 200)}`);
|
|
137
|
+
if (env.code !== 0)
|
|
138
|
+
throw new Error(`code=${env.code}:${env.msg ?? "未知错误"}`);
|
|
139
|
+
return env.data;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function pathOf(dir: string): string {
|
|
143
|
+
return dir;
|
|
144
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ojm market trial`(T9):在一次性隔离站点里跑完整安装,验证「这个包能不能装」。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:开发态装不了自己(见 devkit 教学文档 §2),而真站点装了要清场、且首次会污染
|
|
5
|
+
* 信任库。trial 给你一个「别人家的站点」:随机端口 + 一次性 DB + 一次性发布方密钥 + 隔离
|
|
6
|
+
* CDN,跑完即毁(--keep 保留现场)。
|
|
7
|
+
*
|
|
8
|
+
* 复用纪律:站点生命周期全在 `bootstrap.ts`,站点调用四件套全在 `site.ts`——本文件只编排,
|
|
9
|
+
* 不重写登录/轮询/取证。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { TaskRecord } from "../site";
|
|
13
|
+
|
|
14
|
+
import process from "node:process";
|
|
15
|
+
import { callApi, login, pollTask } from "../site";
|
|
16
|
+
import { bootstrapIsolatedSite } from "./bootstrap";
|
|
17
|
+
import { parseFixtureSpec } from "./fixture";
|
|
18
|
+
import { setupMarketUrl } from "./setup";
|
|
19
|
+
|
|
20
|
+
export interface TrialOptions {
|
|
21
|
+
/** 源工程(提供 bin/oj 与 cli 模板);缺省 process.cwd() */
|
|
22
|
+
projectRoot?: string
|
|
23
|
+
/** 待试装的方案;缺省 demo-solution@0.1.0 */
|
|
24
|
+
fixture?: string
|
|
25
|
+
/** 保留隔离现场(排查用) */
|
|
26
|
+
keep?: boolean
|
|
27
|
+
/** 首败即停(默认也停——单次试装没有后续步骤可续) */
|
|
28
|
+
strict?: boolean
|
|
29
|
+
onLog?: (line: string) => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TrialResult {
|
|
33
|
+
ok: boolean
|
|
34
|
+
/** 隔离站点端口(keep 时供人工访问) */
|
|
35
|
+
port: number
|
|
36
|
+
root: string
|
|
37
|
+
stages: TaskRecord["stages"]
|
|
38
|
+
message: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function trialInstall(opts: TrialOptions = {}): Promise<TrialResult> {
|
|
42
|
+
const log = opts.onLog ?? ((line: string) => console.log(line));
|
|
43
|
+
const spec = parseFixtureSpec(opts.fixture);
|
|
44
|
+
log(`[ojm] 试装 ${spec.name}@${spec.version}(隔离站点,跑完即毁)`);
|
|
45
|
+
|
|
46
|
+
const site = await bootstrapIsolatedSite({
|
|
47
|
+
projectRoot: opts.projectRoot,
|
|
48
|
+
fixture: opts.fixture,
|
|
49
|
+
keep: opts.keep,
|
|
50
|
+
onLog: log,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
// 1) 打开市场地址:隔离库是全新 seed,首登必然处于改密悬挂期 → setup 顺带完成改密
|
|
55
|
+
const newPass = "trial-pass-1";
|
|
56
|
+
await setupMarketUrl({
|
|
57
|
+
projectRoot: site.root,
|
|
58
|
+
url: site.baseUrl,
|
|
59
|
+
user: site.admin.user,
|
|
60
|
+
pass: site.admin.pass,
|
|
61
|
+
newPass,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const token = await login(fetch, site.baseUrl, site.admin.user, newPass);
|
|
65
|
+
|
|
66
|
+
// 2) 首次安装:预期停在 needs_trust(发布方是本次临时生成的身份)
|
|
67
|
+
let task = await installOnce(site.baseUrl, token, spec.name, spec.version);
|
|
68
|
+
if (task.status === "needs_trust") {
|
|
69
|
+
const fingerprint = task.trust?.fingerprint ?? "";
|
|
70
|
+
log(`[ojm] 待信任发布方 ${task.trust?.key_id ?? "?"},指纹 ${fingerprint.slice(0, 16)}…(隔离站点,自动确认)`);
|
|
71
|
+
await callApi(fetch, site.baseUrl, token, "POST", "/api/_platform/market/trust", { task_id: task.id });
|
|
72
|
+
task = await installOnce(site.baseUrl, token, spec.name, spec.version);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const ok = task.status === "done";
|
|
76
|
+
log(ok
|
|
77
|
+
? `[ojm] 试装通过:${spec.name}@${spec.version} 在隔离站点装成(${task.stages.length} 段全 ok)`
|
|
78
|
+
: `[ojm] 试装失败:status=${task.status} stage=${task.stage ?? "?"}——${task.message}`);
|
|
79
|
+
return { ok, port: site.port, root: site.root, stages: task.stages, message: task.message };
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
await site.cleanup();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 一次安装 + 轮询到终态 */
|
|
87
|
+
async function installOnce(base: string, token: string, name: string, version: string): Promise<TaskRecord> {
|
|
88
|
+
// 站点返回 {task_id}(安装同步跑完后回填);mock 兼容 id 形态
|
|
89
|
+
const started = await callApi<{ task_id?: string, id?: string }>(fetch, base, token, "POST", "/api/_platform/market/install", {
|
|
90
|
+
name,
|
|
91
|
+
version,
|
|
92
|
+
source: "cdn",
|
|
93
|
+
});
|
|
94
|
+
const taskId = String(started.task_id ?? started.id ?? "");
|
|
95
|
+
if (taskId === "")
|
|
96
|
+
throw new Error("[ojm] 安装接口未返回 task id——站点版本与 ojm 不匹配?");
|
|
97
|
+
return pollTask(fetch, base, token, taskId, { interval0: 500, intervalMax: 2000, timeoutMs: 120_000 });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 进程入口:`ojm market trial [--fixture name@ver] [--keep]` */
|
|
101
|
+
export async function trialCommand(argv: string[]): Promise<void> {
|
|
102
|
+
const opts: TrialOptions = {};
|
|
103
|
+
for (let i = 0; i < argv.length; i++) {
|
|
104
|
+
const a = argv[i]!;
|
|
105
|
+
if (a === "--keep") {
|
|
106
|
+
opts.keep = true;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (a === "--strict") {
|
|
110
|
+
opts.strict = true;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (a === "--fixture") {
|
|
114
|
+
opts.fixture = argv[i + 1] ?? "";
|
|
115
|
+
i++;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const result = await trialInstall(opts);
|
|
119
|
+
if (!result.ok)
|
|
120
|
+
process.exitCode = 1;
|
|
121
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ojm market verify`(T7):离线验包——不起站点、不装、不要凭据、不要 DB。
|
|
3
|
+
*
|
|
4
|
+
* 分四类列结果(包级与索引级**分开**,因为语义不同):
|
|
5
|
+
* - 完整性:tgz 实际 sha256 与索引声明的 archive.sha256 是否一致;
|
|
6
|
+
* - 包级签名:`<tgz>.sig` 能否用包内公钥复核规范串 `<name>@<version>:<sha256>`——**这是信任锚**;
|
|
7
|
+
* - 索引签名:索引字节与其 `.sig`——只是完整性层(防目录/索引被改),不是信任锚;
|
|
8
|
+
* - manifest 结构:必填字段、内嵌原生清单 desc 是否非空(oj 装配面必填)、同方案各版本 0001 迁移是否逐字节一致。
|
|
9
|
+
*
|
|
10
|
+
* 复用既有原语,一行密码学都不重写:`lifecycle/sign.ts` 的 canonicalString/verifyCanonical、
|
|
11
|
+
* `publish.ts` 的 indexCanonical、`node:crypto` 的 sha256。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { IndexedPackage } from "./index-builder";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import process from "node:process";
|
|
19
|
+
|
|
20
|
+
import { parse as yamlParse } from "yaml";
|
|
21
|
+
import { canonicalString, verifyCanonical } from "../lifecycle/sign";
|
|
22
|
+
import { indexCanonical } from "../publish";
|
|
23
|
+
import { resolveCdnLayout } from "./cdn-layout";
|
|
24
|
+
import { readTgz } from "./scan";
|
|
25
|
+
|
|
26
|
+
export interface VerifyCheck {
|
|
27
|
+
item: string
|
|
28
|
+
ok: boolean
|
|
29
|
+
detail: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface VerifyOptions {
|
|
33
|
+
projectRoot?: string
|
|
34
|
+
/** 验指定的 tgz(缺省扫 cdn/ 下全部包) */
|
|
35
|
+
file?: string
|
|
36
|
+
/** 比对扫描重建出的索引与盘上索引,检出漂移 */
|
|
37
|
+
rebuildCheck?: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface VerifyResult {
|
|
41
|
+
checks: VerifyCheck[]
|
|
42
|
+
passed: boolean
|
|
43
|
+
conclusion: string
|
|
44
|
+
/** `--rebuild-check` 的漂移描述;未传该开关时为 undefined */
|
|
45
|
+
drift?: string[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const BOUNDARY = "完整性 / 来源校验通过——不构成内容安全性背书";
|
|
49
|
+
|
|
50
|
+
export function verifyMarket(opts: VerifyOptions = {}): VerifyResult {
|
|
51
|
+
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
52
|
+
const layout = resolveCdnLayout(projectRoot);
|
|
53
|
+
const checks: VerifyCheck[] = [];
|
|
54
|
+
const drift: string[] = [];
|
|
55
|
+
|
|
56
|
+
const bundlesText = readOptional(path.join(layout.dir, "bundles.yaml"));
|
|
57
|
+
const manifestsText = readOptional(path.join(layout.dir, "manifests.yaml"));
|
|
58
|
+
const declared = declaredHashes(manifestsText);
|
|
59
|
+
|
|
60
|
+
const tgzFiles = opts.file !== undefined
|
|
61
|
+
? [opts.file]
|
|
62
|
+
: fs.existsSync(layout.dir) ? fs.readdirSync(layout.dir).filter(f => f.endsWith(".tgz")).sort() : [];
|
|
63
|
+
|
|
64
|
+
for (const file of tgzFiles) {
|
|
65
|
+
const tgzPath = path.isAbsolute(file) ? file : path.join(layout.dir, file);
|
|
66
|
+
if (!fs.existsSync(tgzPath)) {
|
|
67
|
+
checks.push({ item: "完整性", ok: false, detail: `${file}:文件不存在` });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const bytes = new Uint8Array(fs.readFileSync(tgzPath));
|
|
71
|
+
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
72
|
+
const name = nameOf(file);
|
|
73
|
+
const version = versionOf(file);
|
|
74
|
+
|
|
75
|
+
const want = declared.get(`${name}@${version}`);
|
|
76
|
+
checks.push({
|
|
77
|
+
item: "完整性",
|
|
78
|
+
ok: want === undefined || want === sha256,
|
|
79
|
+
detail: want === undefined
|
|
80
|
+
? `${file}:索引未声明该版本,只做了摘要计算(${sha256.slice(0, 12)}…)`
|
|
81
|
+
: `${file}:${want === sha256 ? `sha256 与索引一致(${sha256.slice(0, 12)}…)` : `sha256 不一致(索引 ${want.slice(0, 12)}… vs 实际 ${sha256.slice(0, 12)}…)`}`,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
checks.push(signatureCheck(tgzPath, name, version, sha256));
|
|
85
|
+
checks.push(...structureCheck(bytes, file));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
checks.push(indexSigCheck(layout.dir, "bundles", bundlesText));
|
|
89
|
+
checks.push(indexSigCheck(layout.dir, "manifests", manifestsText));
|
|
90
|
+
checks.push(...migrationConsistencyCheck(layout.dir, tgzFiles));
|
|
91
|
+
|
|
92
|
+
if (opts.rebuildCheck === true)
|
|
93
|
+
drift.push(...driftOf(layout.dir));
|
|
94
|
+
|
|
95
|
+
const passed = checks.every(c => c.ok) && drift.length === 0;
|
|
96
|
+
return {
|
|
97
|
+
checks,
|
|
98
|
+
passed,
|
|
99
|
+
conclusion: passed
|
|
100
|
+
? `全部校验通过(${checks.length} 项)。${BOUNDARY}——验签过只说明来源与完整性,不说明代码无害。`
|
|
101
|
+
: `存在未通过项。${BOUNDARY}:未通过项须逐条排查后再发布。`,
|
|
102
|
+
drift: opts.rebuildCheck === true ? drift : undefined,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* `.sig` 里验签只用到 public_key / value 两字段;缺任一即不可复核。
|
|
108
|
+
* 抽成类型守卫:包级与索引级两处验签共用(DRY),也顺带让调用点的布尔式保持单行
|
|
109
|
+
* (本仓 indent-binary-ops 与 no-mixed-spaces-and-tabs 对续行缩进的要求互相打架)。
|
|
110
|
+
*/
|
|
111
|
+
function hasSigFields(sig: { public_key?: string, value?: string }): sig is { public_key: string, value: string } {
|
|
112
|
+
return typeof sig.public_key === "string" && typeof sig.value === "string";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 包级 detached 验签:`<tgz>.sig` 的公钥复核规范串 */
|
|
116
|
+
function signatureCheck(tgzPath: string, name: string, version: string, sha256: string): VerifyCheck {
|
|
117
|
+
const sigPath = `${tgzPath}.sig`;
|
|
118
|
+
if (!fs.existsSync(sigPath))
|
|
119
|
+
return { item: "包级签名", ok: false, detail: `${path.basename(tgzPath)}:缺 .tgz.sig(sig_missing)——用 ojm market publish 重发,或向发布方索取签名包` };
|
|
120
|
+
try {
|
|
121
|
+
const sig = JSON.parse(fs.readFileSync(sigPath, "utf-8")) as { public_key?: string, value?: string };
|
|
122
|
+
const canonical = canonicalString(name, version, sha256);
|
|
123
|
+
const ok = hasSigFields(sig) && verifyCanonical(sig.public_key, canonical, sig.value);
|
|
124
|
+
return {
|
|
125
|
+
item: "包级签名",
|
|
126
|
+
ok,
|
|
127
|
+
detail: ok
|
|
128
|
+
? `${path.basename(tgzPath)}:Ed25519 验签通过(规范串 ${name}@${version}:${sha256.slice(0, 12)}…)`
|
|
129
|
+
: `${path.basename(tgzPath)}:验签失败——包体被改或签名不匹配`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
return { item: "包级签名", ok: false, detail: `${path.basename(sigPath)} 不可解析:${(error as Error).message}` };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 索引级验签(完整性层) */
|
|
138
|
+
function indexSigCheck(cdnDir: string, kind: "bundles" | "manifests", text: string | null): VerifyCheck {
|
|
139
|
+
if (text === null)
|
|
140
|
+
return { item: "索引签名", ok: true, detail: `${kind}.yaml 不存在,跳过` };
|
|
141
|
+
const sigPath = path.join(cdnDir, `${kind}.yaml.sig`);
|
|
142
|
+
if (!fs.existsSync(sigPath))
|
|
143
|
+
return { item: "索引签名", ok: true, detail: `${kind}.yaml.sig 缺失——运行期容忍(滚动发布期),完整性无保障` };
|
|
144
|
+
try {
|
|
145
|
+
const sig = JSON.parse(fs.readFileSync(sigPath, "utf-8")) as { public_key?: string, value?: string };
|
|
146
|
+
const ok = hasSigFields(sig) && verifyCanonical(sig.public_key, indexCanonical(kind, text), sig.value);
|
|
147
|
+
return { item: "索引签名", ok, detail: ok ? `${kind}.yaml 验签通过` : `${kind}.yaml 验签失败——索引被改或签名不符` };
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
return { item: "索引签名", ok: false, detail: `${kind}.yaml.sig 不可解析:${(error as Error).message}` };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** manifest 结构:必填字段 + 内嵌原生清单 desc 非空(等值要求已移除,见 §9.3) */
|
|
155
|
+
function structureCheck(bytes: Uint8Array, file: string): VerifyCheck[] {
|
|
156
|
+
let entries: ReturnType<typeof readTgz>;
|
|
157
|
+
try {
|
|
158
|
+
entries = readTgz(bytes);
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
return [{ item: "manifest 结构", ok: false, detail: `${file}:包无法解开(${(error as Error).message})` }];
|
|
162
|
+
}
|
|
163
|
+
const root = entries.find(e => /^(?:[^/]+\/)?manifest\.yaml$/.test(e.name));
|
|
164
|
+
if (root === undefined)
|
|
165
|
+
return [{ item: "manifest 结构", ok: false, detail: `${file}:包内缺 manifest.yaml` }];
|
|
166
|
+
const manifest = (yamlParse(root.content.toString("utf-8")) ?? {}) as Record<string, unknown>;
|
|
167
|
+
const missing = ["name", "version", "summary", "peerRuntime", "archive"].filter(k => manifest[k] === undefined);
|
|
168
|
+
const out: VerifyCheck[] = [{
|
|
169
|
+
item: "manifest 结构",
|
|
170
|
+
ok: missing.length === 0,
|
|
171
|
+
detail: missing.length === 0
|
|
172
|
+
? `${file}:必填字段齐备(name/version/summary/peerRuntime/archive)`
|
|
173
|
+
: `${file}:缺必填字段 ${missing.join(", ")}`,
|
|
174
|
+
}];
|
|
175
|
+
|
|
176
|
+
const inner = entries.find(e => /^api\/[^/]+\/manifest\.yaml$/.test(e.name));
|
|
177
|
+
if (inner !== undefined) {
|
|
178
|
+
const innerManifest = (yamlParse(inner.content.toString("utf-8")) ?? {}) as Record<string, unknown>;
|
|
179
|
+
const desc = innerManifest.desc;
|
|
180
|
+
const ok = typeof desc === "string" && desc.trim() !== "";
|
|
181
|
+
out.push({
|
|
182
|
+
item: "manifest 结构",
|
|
183
|
+
ok,
|
|
184
|
+
detail: ok
|
|
185
|
+
? `${file}:原生清单 desc 非空(oj 装配面必填)`
|
|
186
|
+
: `${file}:原生清单 desc 缺失或空白——oj 启动装配会失败`,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 同方案各版本的 0001 迁移必须逐字节一致(否则升级链的迁移校验会假失败) */
|
|
193
|
+
function migrationConsistencyCheck(cdnDir: string, files: string[]): VerifyCheck[] {
|
|
194
|
+
const byName = new Map<string, Array<{ version: string, bytes: string }>>();
|
|
195
|
+
for (const file of files) {
|
|
196
|
+
const abs = path.isAbsolute(file) ? file : path.join(cdnDir, file);
|
|
197
|
+
if (!fs.existsSync(abs))
|
|
198
|
+
continue;
|
|
199
|
+
let entries: ReturnType<typeof readTgz>;
|
|
200
|
+
try {
|
|
201
|
+
entries = readTgz(new Uint8Array(fs.readFileSync(abs)));
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const mig = entries.find(e => /\/migrations\/0001[^/]*\.sql$/.test(e.name));
|
|
207
|
+
if (mig === undefined)
|
|
208
|
+
continue;
|
|
209
|
+
const name = nameOf(file);
|
|
210
|
+
const list = byName.get(name) ?? [];
|
|
211
|
+
list.push({ version: versionOf(file), bytes: createHash("sha256").update(mig.content).digest("hex") });
|
|
212
|
+
byName.set(name, list);
|
|
213
|
+
}
|
|
214
|
+
const out: VerifyCheck[] = [];
|
|
215
|
+
for (const [name, list] of byName) {
|
|
216
|
+
if (list.length < 2)
|
|
217
|
+
continue;
|
|
218
|
+
const first = list[0]!.bytes;
|
|
219
|
+
const bad = list.filter(v => v.bytes !== first).map(v => v.version);
|
|
220
|
+
out.push({
|
|
221
|
+
item: "manifest 结构",
|
|
222
|
+
ok: bad.length === 0,
|
|
223
|
+
detail: bad.length === 0
|
|
224
|
+
? `${name}:各版本 0001 迁移逐字节一致`
|
|
225
|
+
: `${name}:0001 迁移在版本 ${bad.join(", ")} 与首版不一致——升级链会报迁移缺失,须只增不改`,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** 扫描重建出的索引与盘上索引比对,检出「落了包没重建 / 索引被手改」 */
|
|
232
|
+
function driftOf(cdnDir: string): string[] {
|
|
233
|
+
const out: string[] = [];
|
|
234
|
+
const files = fs.readdirSync(cdnDir).filter(f => f.endsWith(".tgz")).sort();
|
|
235
|
+
const scanned: IndexedPackage[] = [];
|
|
236
|
+
for (const file of files) {
|
|
237
|
+
const bytes = new Uint8Array(fs.readFileSync(path.join(cdnDir, file)));
|
|
238
|
+
const entries = readTgz(bytes);
|
|
239
|
+
const root = entries.find(e => /^(?:[^/]+\/)?manifest\.yaml$/.test(e.name));
|
|
240
|
+
if (root === undefined)
|
|
241
|
+
continue;
|
|
242
|
+
const manifest = (yamlParse(root.content.toString("utf-8")) ?? {}) as Record<string, unknown>;
|
|
243
|
+
const name = String(manifest.name ?? "");
|
|
244
|
+
const version = String(manifest.version ?? "");
|
|
245
|
+
if (name === "" || version === "")
|
|
246
|
+
continue;
|
|
247
|
+
scanned.push({ name, version, file, sha256: createHash("sha256").update(bytes).digest("hex"), size: bytes.byteLength, manifest });
|
|
248
|
+
}
|
|
249
|
+
const onDisk = readOptional(path.join(cdnDir, "bundles.yaml"));
|
|
250
|
+
if (onDisk === null) {
|
|
251
|
+
if (scanned.length > 0)
|
|
252
|
+
out.push(`盘上没有 bundles.yaml,但目录里有 ${scanned.length} 个包——索引从未重建`);
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
const parsed = (yamlParse(onDisk) ?? { solutions: [] }) as { solutions?: Array<Record<string, unknown>> };
|
|
256
|
+
const inIndex = new Set<string>();
|
|
257
|
+
for (const solution of parsed.solutions ?? []) {
|
|
258
|
+
const name = String(solution.name ?? "");
|
|
259
|
+
for (const v of (solution.versions ?? []) as Array<Record<string, unknown>>)
|
|
260
|
+
inIndex.add(`${name}@${String(v.version ?? "")}`);
|
|
261
|
+
}
|
|
262
|
+
for (const pkg of scanned) {
|
|
263
|
+
if (!inIndex.has(`${pkg.name}@${pkg.version}`))
|
|
264
|
+
out.push(`索引缺条目:${pkg.name}@${pkg.version}(${pkg.file})——落了包但没跑 ojm market build-index`);
|
|
265
|
+
}
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function declaredHashes(manifestsText: string | null): Map<string, string> {
|
|
270
|
+
const out = new Map<string, string>();
|
|
271
|
+
if (manifestsText === null)
|
|
272
|
+
return out;
|
|
273
|
+
const list = (yamlParse(manifestsText) ?? []) as Array<Record<string, unknown>>;
|
|
274
|
+
for (const item of Array.isArray(list) ? list : []) {
|
|
275
|
+
const archive = item.archive as Record<string, unknown> | undefined;
|
|
276
|
+
const sha = archive?.sha256;
|
|
277
|
+
if (typeof sha === "string")
|
|
278
|
+
out.set(`${String(item.name ?? "")}@${String(item.version ?? "")}`, sha);
|
|
279
|
+
}
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function readOptional(file: string): string | null {
|
|
284
|
+
return fs.existsSync(file) ? fs.readFileSync(file, "utf-8") : null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** `<name>-<version>.tgz` → name(取最后一个 - 之前,版本段不含 -) */
|
|
288
|
+
function nameOf(file: string): string {
|
|
289
|
+
const base = path.basename(file).replace(/\.tgz$/, "");
|
|
290
|
+
const at = base.lastIndexOf("-");
|
|
291
|
+
return at === -1 ? base : base.slice(0, at);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function versionOf(file: string): string {
|
|
295
|
+
const base = path.basename(file).replace(/\.tgz$/, "");
|
|
296
|
+
const at = base.lastIndexOf("-");
|
|
297
|
+
return at === -1 ? "" : base.slice(at + 1);
|
|
298
|
+
}
|