@oj-module/cli 0.2.69 → 0.2.71
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/devkit/README.md +10 -8
- package/devkit/framework-dev.md +1 -1
- package/devkit/manual.md +45 -19
- package/devkit/module-dev-and-cdn-playground.md +20 -14
- package/devkit/publish-walkthrough.md +132 -43
- package/devkit/wecom-customer-reauth-guide.md +110 -0
- package/package.json +2 -2
- package/shell-dist/assets/{runtime.zrv2zhj9j0.js → runtime.7x99hmm020.js} +2 -2
- package/shell-dist/bundles/oj-market-0.2.0.tgz +0 -0
- package/shell-dist/bundles/oj-market-0.2.0.tgz.sig +8 -0
- package/shell-dist/index.html +4 -4
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/list.test.ts +7 -7
- package/src/__tests__/oj-health-host.test.ts +19 -0
- package/src/__tests__/site-tenant-header.test.ts +2 -2
- package/src/__tests__/templates-builtin-list.test.ts +47 -0
- package/src/__tests__/templates-schema-columns.test.ts +11 -2
- package/src/__tests__/templates-tenant-scope.test.ts +2 -3
- package/src/__tests__/users-roles-display.test.ts +84 -0
- package/src/bundle.ts +1 -1
- package/src/contract/__tests__/market-client.integration.test.ts +23 -22
- package/src/enable.ts +2 -2
- package/src/init.ts +45 -0
- package/src/install.ts +5 -5
- package/src/lifecycle/__tests__/install.test.ts +29 -29
- package/src/lifecycle/__tests__/market-bundle.test.ts +87 -0
- package/src/lifecycle/__tests__/publish.test.ts +8 -10
- package/src/lifecycle/__tests__/trust.test.ts +14 -14
- package/src/lifecycle/__tests__/uninstall.test.ts +4 -4
- package/src/list.ts +3 -3
- package/src/market/__tests__/bootstrap.test.ts +24 -7
- package/src/market/__tests__/failure-playbook.test.ts +0 -1
- package/src/market/__tests__/remote-push.test.ts +228 -0
- package/src/market/__tests__/verify.test.ts +45 -207
- package/src/market/bootstrap.ts +12 -7
- package/src/market/command.ts +33 -35
- package/src/market/db-seed.ts +129 -0
- package/src/market/failure-playbook.ts +3 -9
- package/src/market/fixture.ts +2 -6
- package/src/market/publish.ts +145 -89
- package/src/market/remote-push.ts +236 -0
- package/src/market/trial.ts +2 -2
- package/src/market/verify.ts +17 -270
- package/src/market/walkthrough.ts +38 -63
- package/src/oj.ts +13 -2
- package/src/preview.ts +5 -3
- package/src/publish.ts +3 -2
- package/src/scaffold-guard.ts +4 -0
- package/src/site.ts +1 -1
- package/src/trust.ts +8 -8
- package/src/uninstall.ts +2 -2
- package/src/usage.ts +23 -21
- package/templates/config.yaml +22 -3
- package/templates/init.include.yaml +16 -11
- package/templates/openapi.json +3948 -3655
- package/templates/src/.ojm-builtin.json +13 -0
- package/templates/src/README.md +5 -2
- package/templates/src/app/api/_platform/README.md +3 -2
- package/templates/src/app/api/_platform/_lifecycle/atomic.ts +7 -2
- package/templates/src/app/api/_platform/_lifecycle/bundle-converge.ts +158 -0
- package/templates/src/app/api/_platform/_lifecycle/history.ts +3 -2
- package/templates/src/app/api/_platform/_lifecycle/installer.ts +25 -5
- package/templates/src/app/api/_platform/_lifecycle/local-archive.ts +61 -0
- package/templates/src/app/api/_platform/_lifecycle/manifest-of-upload.ts +62 -0
- package/templates/src/app/api/_platform/_lifecycle/steps/preflight.ts +12 -2
- package/templates/src/app/api/_platform/_lifecycle/steps/reload.ts +4 -1
- package/templates/src/app/api/_platform/_lifecycle/steps/smoke.ts +8 -2
- package/templates/src/app/api/_platform/_lifecycle/types.ts +1 -1
- package/templates/src/app/api/_platform/_lifecycle/uninstall.ts +14 -5
- package/templates/src/app/api/_platform/_shared/site-facade.ts +31 -0
- package/templates/src/app/api/_platform/manifest.yaml +1 -2
- package/templates/src/app/api/_platform/migrations/0003__market_catalog.mysql.sql +6 -0
- package/templates/src/app/api/_platform/migrations/0003__market_catalog.pg.sql +6 -0
- package/templates/src/app/api/_platform/migrations/0003__market_catalog.sql +18 -0
- package/templates/src/app/api/_platform/migrations/0004__market_drop_source_seq.mysql.sql +6 -0
- package/templates/src/app/api/_platform/migrations/0004__market_drop_source_seq.pg.sql +6 -0
- package/templates/src/app/api/_platform/migrations/0004__market_drop_source_seq.sql +6 -0
- package/templates/src/app/api/_platform/migrations/0005__market_sig_registry.mysql.sql +8 -0
- package/templates/src/app/api/_platform/migrations/0005__market_sig_registry.pg.sql +8 -0
- package/templates/src/app/api/_platform/migrations/0005__market_sig_registry.sql +8 -0
- package/templates/src/app/api/_platform/schema.yaml +8 -11
- package/templates/src/app/api/_platform/seed.mysql.sql +1 -1
- package/templates/src/app/api/_platform/seed.pg.sql +1 -1
- package/templates/src/app/api/_platform/seed.sql +1 -1
- package/templates/src/app/api/auth/_shared/wecom/sync.ts +6 -0
- package/templates/src/app/web/_platform/__client/api.schemas.ts +0 -114
- package/templates/src/app/web/_platform/__client/index.ts +1 -333
- package/templates/src/app/web/system/locales/en-US.json +15 -1
- package/templates/src/app/web/system/locales/zh-CN.json +15 -1
- package/templates/src/app/web/system/pages/departments/sync-bar.tsx +9 -3
- package/templates/src/app/web/system/pages/roles/index.tsx +4 -8
- package/templates/src/app/web/system/pages/roles/role-label.ts +26 -0
- package/templates/src/app/web/system/pages/solutions/index.tsx +110 -0
- package/templates/src/app/web/system/pages/solutions/market-entry.ts +46 -0
- package/templates/src/app/web/system/pages/users/index.tsx +12 -2
- package/templates/src/app/web/system/pages/users/match-keyword.ts +19 -0
- package/templates/src/app/web/system/pages/users/roles.tsx +43 -14
- package/templates/src/app/web/system/routes.ts +18 -1
- package/templates/src/{app/api/_platform/market → oj-market/api}/api.ts +475 -592
- package/templates/src/oj-market/api/backfill.ts +221 -0
- package/templates/src/{app/api/_platform/market → oj-market/api}/bootstrap.ts +2 -2
- package/templates/src/oj-market/api/cdn/api.ts +312 -0
- package/templates/src/oj-market/api/cdn/shared.ts +104 -0
- package/templates/src/oj-market/api/cdn/signed-auth.ts +75 -0
- package/templates/src/oj-market/api/manifest.yaml +8 -0
- package/templates/src/oj-market/solution.yaml +21 -0
- package/templates/src/oj-market/web/__client/api.schemas.ts +167 -0
- package/templates/src/oj-market/web/__client/index.ts +509 -0
- package/templates/src/oj-market/web/__tests__/marketClient.fake.ts +6 -6
- package/templates/src/oj-market/web/api/client.ts +6 -6
- package/templates/src/oj-market/web/api/defaultClient.ts +31 -25
- package/templates/src/oj-market/web/entry.ts +5 -3
- package/templates/src/oj-market/web/hooks/useMarket.ts +6 -6
- package/templates/src/oj-market/web/pages/MarketApp.tsx +20 -3
- package/templates/src/oj-market/web/publish/locales/en-US.json +1 -1
- package/templates/src/oj-market/web/publish/locales/zh-CN.json +1 -1
- package/templates/src/oj-market/web/publish/pages/history.tsx +2 -11
- package/templates/src/oj-market/web/routes.ts +12 -0
- package/templates/src/oj-market/web/types.ts +14 -8
- package/templates/tests/lifecycle/anonymous-wiring.spec.ts +65 -0
- package/templates/tests/lifecycle/backfill.spec.ts +392 -0
- package/templates/tests/lifecycle/bootstrap.spec.ts +1 -1
- package/templates/tests/lifecycle/bundle-converge.spec.ts +264 -0
- package/templates/tests/lifecycle/cdn-upload.spec.ts +661 -0
- package/templates/tests/lifecycle/deploy-smoke.spec.ts +25 -0
- package/templates/tests/lifecycle/exec-grant.spec.ts +38 -0
- package/templates/tests/lifecycle/installer.spec.ts +36 -0
- package/templates/tests/lifecycle/market-api.spec.ts +448 -803
- package/templates/tests/lifecycle/preflight.spec.ts +18 -0
- package/templates/tests/lifecycle/trust.spec.ts +2 -2
- package/templates/tests/lifecycle/uninstall.spec.ts +36 -2
- package/templates/tests/lifecycle/verify-checks.spec.ts +1 -1
- package/templates/tests/solutions-entry.spec.ts +55 -0
- package/templates/tsconfig.paths.json +1 -0
- package/templates/web.config.ts +6 -6
- package/vendor/host-versions.json +3 -3
- package/bin/oj +0 -4
- package/src/market/__tests__/build-index.test.ts +0 -641
- package/src/market/__tests__/sharding.test.ts +0 -90
- package/src/market/__tests__/yank-ledger.test.ts +0 -60
- package/src/market/build-index.ts +0 -526
- package/src/market/index-builder.ts +0 -149
- package/src/market/sharding.ts +0 -94
- package/src/market/yank-ledger.ts +0 -91
- package/templates/src/app/api/_platform/_lifecycle/source-seq.ts +0 -88
- package/templates/src/app/api/_platform/market/index-source.ts +0 -112
- package/templates/tests/lifecycle/source-seq.spec.ts +0 -55
package/templates/src/README.md
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
## 框架自带的模块(不许改)
|
|
6
6
|
|
|
7
|
-
- `src/app/` ——
|
|
8
|
-
- `src/oj-market/` ——
|
|
7
|
+
- `src/app/` —— 框架内置页面与后端(登录、首页、个人中心、通知、系统管理、方案安装、认证等)
|
|
8
|
+
- `src/oj-market/` —— 解决方案市场的**编排面**:`/api/oj-market/*` 端点(安装/目录/下线/信任/
|
|
9
|
+
回填)随脚手架内置;市场 UI 本身是第一方**方案包**(出厂未装)——superadmin 经
|
|
10
|
+
system 域「方案安装」首装,`ojm init --yes` 落位的随站 bundle 由首个市场请求自动收敛
|
|
11
|
+
(上拉不下拉)。这一层只有 api 与生成物 `__client`,没有页面源码。
|
|
9
12
|
|
|
10
13
|
它们由上游统一维护、跟着框架版本走。**请不要修改、也不要删除这里的文件**:
|
|
11
14
|
|
|
@@ -17,11 +17,12 @@
|
|
|
17
17
|
- `domain/`:管理域共享服务(多模块共用的「系统管理」域逻辑,所有权归 `_platform` 的 `sys_*` 表)
|
|
18
18
|
- `users.ts` / `roles.ts` / `settings.ts` / `solution-perms.ts` / `contacts.ts`
|
|
19
19
|
2. **`_lifecycle/`** —— 方案生命周期引擎(安装/卸载/注册/验签/信任的声明式状态机,
|
|
20
|
-
|
|
20
|
+
与编排端点(市场域 api 已迁 oj-market 模块,见下)职责分离。
|
|
21
21
|
3. **编排 API 面** —— 经 oj 扫描 `src/**/api.ts` 暴露,路径前缀即目录名:
|
|
22
22
|
- `contacts/api.ts` → `/api/_platform/contacts`
|
|
23
|
-
- `market/api.ts` → `/api/_platform/market`(方案市场,全 superadmin 门禁,R9)
|
|
24
23
|
- `solutions/api.ts` → `/api/_platform/solutions`(方案路由/验签数据源)
|
|
24
|
+
- 市场域 api(方案市场/发布推送/生命周期编排/信任库)自 v4 起迁 `src/oj-market/api/`
|
|
25
|
+
→ `/api/oj-market/*`(模块 manifest 声明 `deps: [_platform]`;表与引擎仍归本内核)
|
|
25
26
|
|
|
26
27
|
## 边界约定
|
|
27
28
|
|
|
@@ -75,7 +75,8 @@ export function fsFace(): FsFace {
|
|
|
75
75
|
|
|
76
76
|
const encoder = new TextEncoder();
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
/** 字节原子写(上传端点落 tgz/.sig 用):同目录 tmp + rename,语义同 atomicWriteText */
|
|
79
|
+
export async function atomicWriteBytes(path: string, data: Uint8Array): Promise<void> {
|
|
79
80
|
const f = fsFace();
|
|
80
81
|
// tmp 与目标同目录:保证 rename 不跨文件系统;随机后缀防并发写者互踩同一 tmp
|
|
81
82
|
const slash = path.lastIndexOf("/");
|
|
@@ -86,7 +87,7 @@ export async function atomicWriteText(path: string, content: string): Promise<vo
|
|
|
86
87
|
await f.mkdir(dir, { recursive: true });
|
|
87
88
|
}
|
|
88
89
|
try {
|
|
89
|
-
await f.writeFile(tmp,
|
|
90
|
+
await f.writeFile(tmp, data);
|
|
90
91
|
await f.rename(tmp, path);
|
|
91
92
|
}
|
|
92
93
|
catch (e) {
|
|
@@ -99,3 +100,7 @@ export async function atomicWriteText(path: string, content: string): Promise<vo
|
|
|
99
100
|
throw e;
|
|
100
101
|
}
|
|
101
102
|
}
|
|
103
|
+
|
|
104
|
+
export async function atomicWriteText(path: string, content: string): Promise<void> {
|
|
105
|
+
await atomicWriteBytes(path, encoder.encode(content));
|
|
106
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// 随站 bundle 启动收敛(Phase B T3b,方案 §5「上拉不下拉」):
|
|
2
|
+
// 未装 → install;bundle > 已装 → upgrade;bundle ≤ 已装 → skip。
|
|
3
|
+
// bundle < 已装 是市场轨(用户经方案市场升过更高版本)的正常漂移态——不降级,
|
|
4
|
+
// 对齐 rollbackGuard 禁降版红线 OJ-9(降级唯一入口 = 站点回滚)。
|
|
5
|
+
//
|
|
6
|
+
// 挂点裁决(T3b):oj 启动段 ext_boot 有 2s 看门狗(oj-bin runtime.rs BOOT_TIMEOUT,
|
|
7
|
+
// terminate 的是正被借出跑请求的 isolate),安装全程(解压/迁移/重装配)不可在 boot
|
|
8
|
+
// 内跑——收敛编排由首个编排 API 请求触发(index 端点「对账即服务」先例),每次调用
|
|
9
|
+
// 幂等:已对齐即全 skip(只剩 marker/状态两次读)。单条目失败记 failed 续跑,不抛。
|
|
10
|
+
|
|
11
|
+
import { atomicWriteText, fsFace } from "./atomic";
|
|
12
|
+
import { compareVersions } from "./semver";
|
|
13
|
+
import { createStateStore } from "./state";
|
|
14
|
+
|
|
15
|
+
export type BundleConvergePlan = "install" | "upgrade" | "skip";
|
|
16
|
+
|
|
17
|
+
export function bundleConvergePlan(installed: string | null, bundle: string): BundleConvergePlan {
|
|
18
|
+
if (installed === null)
|
|
19
|
+
return "install";
|
|
20
|
+
return compareVersions(bundle, installed) > 0 ? "upgrade" : "skip";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 意图标记条目(init --yes 写 cdn/bundles.json;file = cdn/ 内 tgz 文件名) */
|
|
24
|
+
export interface BundledEntry {
|
|
25
|
+
name: string
|
|
26
|
+
version: string
|
|
27
|
+
file: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 安装闭包注入面:字节 + 旁车签名的本地归档安装(manifest 组装 + runInstall 在调用方) */
|
|
31
|
+
export interface BundleConvergeEnv {
|
|
32
|
+
/** 意图标记路径(缺省 <cdnDir>/bundles.json,调用方可显式传) */
|
|
33
|
+
markerPath: string
|
|
34
|
+
/** 本地字节仓库目录 */
|
|
35
|
+
cdnDir: string
|
|
36
|
+
/** sidecar 状态文件(读已装版本) */
|
|
37
|
+
statePath: string
|
|
38
|
+
installLocal: (entry: BundledEntry, archive: { bytes: Uint8Array, sig: unknown | null }) => Promise<void>
|
|
39
|
+
/** 失败冷却文件(可选;调用方启用才有冷却,缺省每次请求重试——测试与轻量场景) */
|
|
40
|
+
cooldownPath?: string
|
|
41
|
+
/** 当前时刻 epoch ms(测试注入;缺省 Date.now) */
|
|
42
|
+
now?: () => number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface BundleConvergeResult {
|
|
46
|
+
installed: string[]
|
|
47
|
+
upgraded: string[]
|
|
48
|
+
skipped: string[]
|
|
49
|
+
failed: string[]
|
|
50
|
+
/** 冷却窗内跳过(上次失败未满 10 分钟;调用方据此可观测连续失败) */
|
|
51
|
+
cooling: string[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 失败冷却窗:持久失败条目每次 index 请求全量重装是重试风暴——窗内不再重试(终审 Important-2) */
|
|
55
|
+
const COOLDOWN_MS = 10 * 60 * 1000;
|
|
56
|
+
|
|
57
|
+
/** 读冷却表(缺省 {});文件损坏按空表对待(冷却失效 = 保守重试,不阻断收敛) */
|
|
58
|
+
async function readCooldown(cooldownPath: string): Promise<Record<string, number>> {
|
|
59
|
+
try {
|
|
60
|
+
const parsed: unknown = JSON.parse(new TextDecoder().decode(await fsFace().readFile(cooldownPath)));
|
|
61
|
+
if (parsed !== null && typeof parsed === "object")
|
|
62
|
+
return Object.fromEntries(Object.entries(parsed as Record<string, unknown>).filter(([, v]) => typeof v === "number")) as Record<string, number>;
|
|
63
|
+
}
|
|
64
|
+
catch {}
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 卸载墓碑(终审 Important-1):方案被 superadmin 卸载时,调用方摘除其在意图标记里
|
|
70
|
+
* 的条目——否则「未装→装」收敛会把故意卸载当漂移纠正,下次 index 请求全自动重装,
|
|
71
|
+
* 计划特性 2「卸载后仅可经 system 域入口重装」落空。marker 缺失/损坏安静 no-op。
|
|
72
|
+
*/
|
|
73
|
+
export async function pruneBundledMarker(markerPath: string, name: string): Promise<void> {
|
|
74
|
+
const entries = await readMarker(markerPath);
|
|
75
|
+
if (entries === null)
|
|
76
|
+
return;
|
|
77
|
+
const rest = entries.filter(e => e.name !== name);
|
|
78
|
+
if (rest.length === entries.length)
|
|
79
|
+
return;
|
|
80
|
+
await atomicWriteText(markerPath, `${JSON.stringify({ solutions: rest }, null, "\t")}\n`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 读 marker:缺失/损坏/空清单 → null(零动作,旧工程与无 bundle 站点的安静态) */
|
|
84
|
+
async function readMarker(markerPath: string): Promise<BundledEntry[] | null> {
|
|
85
|
+
let text: string;
|
|
86
|
+
try {
|
|
87
|
+
text = new TextDecoder().decode(await fsFace().readFile(markerPath));
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const parsed: unknown = JSON.parse(text);
|
|
94
|
+
const list = (parsed as { solutions?: unknown }).solutions;
|
|
95
|
+
if (!Array.isArray(list) || list.length === 0)
|
|
96
|
+
return null;
|
|
97
|
+
const out: BundledEntry[] = [];
|
|
98
|
+
for (const raw of list) {
|
|
99
|
+
const e = raw as Partial<BundledEntry>;
|
|
100
|
+
if (typeof e?.name === "string" && typeof e.version === "string" && typeof e.file === "string")
|
|
101
|
+
out.push({ name: e.name, version: e.version, file: e.file });
|
|
102
|
+
}
|
|
103
|
+
return out.length === 0 ? null : out;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null; // 损坏 marker 不当场修(下次 init --yes 重写),收敛静默跳过
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 跑一遍对账(幂等;由编排 API 请求面调用,如 oj-market index 端点) */
|
|
111
|
+
export async function convergeBundledSolutions(env: BundleConvergeEnv): Promise<BundleConvergeResult> {
|
|
112
|
+
const result: BundleConvergeResult = { installed: [], upgraded: [], skipped: [], failed: [], cooling: [] };
|
|
113
|
+
const entries = await readMarker(env.markerPath);
|
|
114
|
+
if (entries === null)
|
|
115
|
+
return result;
|
|
116
|
+
const state = (await createStateStore(env.statePath)).read();
|
|
117
|
+
const now = env.now ?? (() => Date.now());
|
|
118
|
+
let cooldown: Record<string, number> | null = null;
|
|
119
|
+
if (env.cooldownPath !== undefined)
|
|
120
|
+
cooldown = await readCooldown(env.cooldownPath);
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
const label = `${entry.name}@${entry.version}`;
|
|
123
|
+
const plan = bundleConvergePlan(state.solutions[entry.name]?.version ?? null, entry.version);
|
|
124
|
+
if (plan === "skip") {
|
|
125
|
+
result.skipped.push(label);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (cooldown !== null && now() - (cooldown[entry.name] ?? 0) < COOLDOWN_MS) {
|
|
129
|
+
result.cooling.push(label);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const bytes = new Uint8Array(await fsFace().readFile(`${env.cdnDir}/${entry.file}`));
|
|
134
|
+
let sig: unknown | null = null;
|
|
135
|
+
try {
|
|
136
|
+
sig = JSON.parse(new TextDecoder().decode(await fsFace().readFile(`${env.cdnDir}/${entry.file}.sig`)));
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
sig = null; // 无旁车 → null,验签 fail-closed 由安装器 verify 段裁决(六码分流)
|
|
140
|
+
}
|
|
141
|
+
await env.installLocal(entry, { bytes, sig });
|
|
142
|
+
result[plan === "install" ? "installed" : "upgraded"].push(label);
|
|
143
|
+
if (cooldown !== null && cooldown[entry.name] !== undefined) {
|
|
144
|
+
delete cooldown[entry.name];
|
|
145
|
+
await atomicWriteText(env.cooldownPath!, JSON.stringify(cooldown));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch (e) {
|
|
149
|
+
// 降级不阻断:记 failed 续跑;冷却窗内(若启用)不再重试
|
|
150
|
+
result.failed.push(`${label}(${e instanceof Error ? e.message : String(e)})`);
|
|
151
|
+
if (cooldown !== null) {
|
|
152
|
+
cooldown[entry.name] = now();
|
|
153
|
+
await atomicWriteText(env.cooldownPath!, JSON.stringify(cooldown));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// market_history 留痕(P1-15a 缺口 A,P4 上收 _platform):install / enable / disable / uninstall
|
|
2
|
-
// 四类动作共用的平台级留痕出口(前端 HistoryDrawer
|
|
2
|
+
// 四类动作共用的平台级留痕出口(前端 HistoryDrawer 消费面);v4 起 publish(签名推送)/
|
|
3
|
+
// yank(目录下线)同通道留痕(成败俱记)。表由 _platform 自带迁移创建
|
|
3
4
|
// (0012__market_history),归平台核心所有;守卫式 insert 仍兜底表缺失/写失败,静默跳过,
|
|
4
5
|
// 不阻断主流程(历史是观测面,不是业务正确性依赖;与终态审计附录 B-5 同语义)。
|
|
5
6
|
// 参数化 insert,与 audit.ts 同纪律(值不拼字符串)。
|
|
@@ -10,7 +11,7 @@ declare const db: { exec: (sql: string, params: unknown[]) => Promise<unknown>,
|
|
|
10
11
|
export interface HistoryRow {
|
|
11
12
|
solution: string
|
|
12
13
|
version: string
|
|
13
|
-
action: "install" | "enable" | "disable" | "uninstall"
|
|
14
|
+
action: "install" | "enable" | "disable" | "uninstall" | "publish" | "yank" | "unyank"
|
|
14
15
|
status: string
|
|
15
16
|
/** 操作人(用户名;编排层可得则传,缺省 null) */
|
|
16
17
|
operator: string | null
|
|
@@ -234,12 +234,13 @@ async function lockCommitStage(req: InstallRequest): Promise<StageResult> {
|
|
|
234
234
|
// 留在 staging(deploying_web 段消费)。无 api 段(web-only 包,extracting C2-31 已放行)
|
|
235
235
|
// 回退整体 rename——版本目录 = 包内容(无 serve 装配面的残留形态)。
|
|
236
236
|
let src = req.staging;
|
|
237
|
+
let hasApiModule = true;
|
|
237
238
|
try {
|
|
238
239
|
await f.readdir(`${req.staging}/api/${req.name}-${req.version}`);
|
|
239
240
|
src = `${req.staging}/api/${req.name}-${req.version}`;
|
|
240
241
|
}
|
|
241
242
|
catch {
|
|
242
|
-
// 无 api 段:整体 rename
|
|
243
|
+
hasApiModule = false; // 无 api 段:整体 rename
|
|
243
244
|
}
|
|
244
245
|
try {
|
|
245
246
|
await f.rename(src, versionDir);
|
|
@@ -247,6 +248,14 @@ async function lockCommitStage(req: InstallRequest): Promise<StageResult> {
|
|
|
247
248
|
catch (e) {
|
|
248
249
|
return { stage: "lock_commit", ok: false, retryable: true, message: `生效切换失败:${src} rename 失败(${String(e)})——附录 A lock_commit:请稍后重试` };
|
|
249
250
|
}
|
|
251
|
+
if (!hasApiModule) {
|
|
252
|
+
// web-only 包 api 锁零变更(2026-11-07 市场线演练实锤):锁是 oj 装配真源(按原生七键
|
|
253
|
+
// 清单 parse_one),写锁会让 oj 拿 C2 方案清单当模块清单装配(reload 400),且双轨同名
|
|
254
|
+
// (第一方 oj-market 方案 vs 平台内建 oj-market 编排模块)锁条目会遮蔽内建模块。
|
|
255
|
+
// 生效面 = modules.json upsert + 路由/角色注册 + sidecar;版本目录归位作回滚素材/审计形态
|
|
256
|
+
// (web-only 降级不走市场回滚——OJ-9:降级唯一入口 = 站点回滚,双轨裁决)。
|
|
257
|
+
return { stage: "lock_commit", ok: true, message: `生效切换完成:${src} → ${versionDir} 就位(web-only 包:api 锁零变更,不遮蔽同名内建模块)` };
|
|
258
|
+
}
|
|
250
259
|
const c = await commitLockEntry(`${req.sharedRoot}/manifests.yaml`, req.name, req.version);
|
|
251
260
|
if (!c.ok) {
|
|
252
261
|
// 锁写失败回退 rename(锁未变则多余版本目录撤回,由锁前清场统一删;回退失败由 reconcile 兜底,D11)
|
|
@@ -337,15 +346,26 @@ async function deployWebStage(req: InstallRequest): Promise<StageResult> {
|
|
|
337
346
|
const web = isObj(m.web) ? m.web : undefined;
|
|
338
347
|
const entry = web !== undefined && typeof web.entry === "string" ? web.entry : undefined;
|
|
339
348
|
let integrity: string | undefined;
|
|
349
|
+
// web-only 包(无 api 段):lock_commit 回退「staging 整体 rename → 版本目录」(版本目录 = 包内容
|
|
350
|
+
// 形态,B-11 注释面),web 树随之归位版本目录、staging 已不存在——部署段须从版本目录消费
|
|
351
|
+
// (2026-11-07 市场线演练实锤:整体 rename 后 staging/web 读空致 deploying_web 必败)。
|
|
352
|
+
let stagingDir = req.staging;
|
|
340
353
|
if (entry !== undefined) {
|
|
341
354
|
try {
|
|
342
|
-
integrity = `sha384-${req.sha384B64(await fsFace().readFile(`${
|
|
355
|
+
integrity = `sha384-${req.sha384B64(await fsFace().readFile(`${stagingDir}/web/${entry}`))}`;
|
|
343
356
|
}
|
|
344
|
-
catch
|
|
345
|
-
|
|
357
|
+
catch {
|
|
358
|
+
const versionDir = `${req.sharedRoot}/${req.name}-${req.version}`;
|
|
359
|
+
try {
|
|
360
|
+
integrity = `sha384-${req.sha384B64(await fsFace().readFile(`${versionDir}/web/${entry}`))}`;
|
|
361
|
+
stagingDir = versionDir;
|
|
362
|
+
}
|
|
363
|
+
catch (e) {
|
|
364
|
+
return { stage: "deploying_web", ok: false, retryable: false, message: `部署失败:web.entry「${entry}」在 staging 与版本目录均不可读(${String(e)})——manifest 与产物错配(R10)` };
|
|
365
|
+
}
|
|
346
366
|
}
|
|
347
367
|
}
|
|
348
|
-
return deployWeb({ ...req, stagingDir
|
|
368
|
+
return deployWeb({ ...req, stagingDir, integrity });
|
|
349
369
|
}
|
|
350
370
|
|
|
351
371
|
/** `ojm preview` 派生的 config 文件名(与 CLI 侧 preview-config.ts 的 PREVIEW_CONFIG_NAME 逐字一致) */
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// 本地归档 manifest 补全(Phase B T3b 抽取:installBody file 源与 bundle 收敛共用)。
|
|
2
|
+
// ojm build --bundle 产物的包内 manifest 是 Form A(archive 仅 file,sha256/size 由
|
|
3
|
+
// publish 填充于 Form B),而安装器 verify 段对 archive.sha256 fail-closed——字节在手
|
|
4
|
+
// 的本地安装(multipart 上传 / 随站 bundle)须在安装时点补摘要成可校验清单
|
|
5
|
+
// (verify 复核同批字节,R10 语义保持;CDN 源不受影响,Form B 真源自带摘要)。
|
|
6
|
+
// 正名(P2-11 索引投毒加固):archive.file 逐字 <name>-<version>.tgz——name(C2-01)/
|
|
7
|
+
// version(无路径分隔符)拼出的单文件名安全时即正名;否则退回常量名(此刻尚未过预检,
|
|
8
|
+
// 恶意命名不提前拒也不给穿越面——预检稍后照拒)。
|
|
9
|
+
// detached 侧车签名注入(P2-3,G4 补裁):与 Form B archive.signature 同结构注入,
|
|
10
|
+
// verify 段统一验签(六码分流);sig 为 null(无旁车)时不注入,验签 fail-closed
|
|
11
|
+
// 由 verify 段按 sig_missing 裁决,本层不预判。
|
|
12
|
+
|
|
13
|
+
import type { ExecFace } from "./steps/extract";
|
|
14
|
+
import { manifestOfUpload } from "./manifest-of-upload";
|
|
15
|
+
|
|
16
|
+
export interface LocalArchivePrep {
|
|
17
|
+
/** 暂存目录(与安装器 staging 同纪律) */
|
|
18
|
+
stagingDir: string
|
|
19
|
+
/** exec 面(tar 列表/解包) */
|
|
20
|
+
exec: ExecFace
|
|
21
|
+
/** sha256 摘要面(archive 补摘要) */
|
|
22
|
+
sha256Hex: (data: Uint8Array) => string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface LocalArchiveInput {
|
|
26
|
+
/** 身份权威名(body/标记权威,包内 manifest 兜底;冲突即拒) */
|
|
27
|
+
name: string
|
|
28
|
+
/** 期望版本(null = 以包内 manifest.version 为准) */
|
|
29
|
+
reqVersion: string | null
|
|
30
|
+
/** tgz 字节 */
|
|
31
|
+
bytes: Uint8Array
|
|
32
|
+
/** detached 侧车签名(已解析的 SidecarSig 形状;null = 无旁车) */
|
|
33
|
+
sig: unknown | null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface LocalArchiveManifest {
|
|
37
|
+
manifest: Record<string, unknown>
|
|
38
|
+
version: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function localArchiveManifestOf(prep: LocalArchivePrep, input: LocalArchiveInput): Promise<LocalArchiveManifest> {
|
|
42
|
+
const manifest: unknown = await manifestOfUpload({ stagingDir: prep.stagingDir, bytes: input.bytes, exec: prep.exec });
|
|
43
|
+
const m = (manifest !== null && typeof manifest === "object" ? manifest : {}) as Record<string, unknown>;
|
|
44
|
+
// 身份:入参权威、manifest 兜底;两者俱在且冲突即拒(防锁名/注册名分叉的坏装)
|
|
45
|
+
const mVersion = typeof m.version === "string" ? m.version : "";
|
|
46
|
+
if (input.reqVersion !== null && mVersion !== "" && mVersion !== input.reqVersion)
|
|
47
|
+
throw Object.assign(new Error(`期望版本「${input.reqVersion}」与包内 manifest.version「${mVersion}」不一致——请核对分发包`), { status: 400 });
|
|
48
|
+
const mName = typeof m.name === "string" ? m.name : "";
|
|
49
|
+
if (mName !== "" && mName !== input.name)
|
|
50
|
+
throw Object.assign(new Error(`期望 name「${input.name}」与包内 manifest.name「${mName}」不一致——请核对分发包`), { status: 400 });
|
|
51
|
+
if (input.reqVersion === null && mVersion === "")
|
|
52
|
+
throw Object.assign(new Error("version 不可得(期望版本与包内 manifest.version 均缺失)"), { status: 400 });
|
|
53
|
+
const version = input.reqVersion ?? mVersion;
|
|
54
|
+
const namedFile = /^[a-z][a-z0-9-]{0,63}$/.test(input.name) && /^[0-9A-Z.-]+$/i.test(version) && !version.includes("..")
|
|
55
|
+
? `${input.name}-${version}.tgz`
|
|
56
|
+
: "upload.tgz";
|
|
57
|
+
m.archive = { file: namedFile, sha256: prep.sha256Hex(input.bytes), size: input.bytes.byteLength };
|
|
58
|
+
if (input.sig !== null)
|
|
59
|
+
m.archive = { ...(m.archive as Record<string, unknown>), signature: input.sig };
|
|
60
|
+
return { manifest: m, version };
|
|
61
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// 上传包 manifest 抽取共享管线(v4 §二:上传端点与回填命令同一抽取实现,勿各写一遍)。
|
|
2
|
+
// R1 三步范式(list→validate→extract)+ REG-CS8 体积闸(validate 与 extract 之间,
|
|
3
|
+
// 零解压零落盘拒解压炸弹)+ 解包扁平化 + manifest.yaml 解析。
|
|
4
|
+
// 原实现位于 market/api.ts manifestOfUpload(file 源安装用);上传端点落地时上收此处,
|
|
5
|
+
// 两侧共用。错误口径:带 status 的可读失败(respond 收口成 json.fail,D14/B5 同通道)。
|
|
6
|
+
|
|
7
|
+
import type { ExecFace } from "./steps/extract";
|
|
8
|
+
import { fsFace } from "./atomic";
|
|
9
|
+
import { flattenBundleRoot } from "./steps/extract";
|
|
10
|
+
import { checkTarVolumeLimits, validateTarListing } from "./tgz-guard";
|
|
11
|
+
|
|
12
|
+
/** 带 status 的可读失败(respond 收口:err.status → json.fail) */
|
|
13
|
+
function fail(status: number, msg: string): never {
|
|
14
|
+
throw Object.assign(new Error(msg), { status });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ManifestOfUploadInput {
|
|
18
|
+
/** 暂存目录(与安装器 staging 同纪律:包体落 upload.tgz 后解包,文件名常量无用户输入) */
|
|
19
|
+
stagingDir: string
|
|
20
|
+
/** 上传字节(tgz 包体) */
|
|
21
|
+
bytes: Uint8Array
|
|
22
|
+
/** exec 面(tar 列表/解包;注入 stub 不真跑) */
|
|
23
|
+
exec: ExecFace
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 抽取包内嵌 manifest 对象。校验序:
|
|
28
|
+
* ① tar -tvzf 列表(损坏包 400)→ ② validateTarListing(zip-slip 拒收 400)→
|
|
29
|
+
* ③ checkTarVolumeLimits(体积闸 413,零解压零落盘)→ ④ tar -xzf 解包(失败 400)→
|
|
30
|
+
* ⑤ flattenBundleRoot(单根目录包扁平化)→ ⑥ manifest.yaml 解析(不可解析 400)。
|
|
31
|
+
*/
|
|
32
|
+
export async function manifestOfUpload(input: ManifestOfUploadInput): Promise<unknown> {
|
|
33
|
+
const f = fsFace();
|
|
34
|
+
const staged = `${input.stagingDir}/upload.tgz`; // 常量名不含用户输入,无穿越面
|
|
35
|
+
await f.mkdir(input.stagingDir, { recursive: true });
|
|
36
|
+
await f.writeFile(staged, input.bytes);
|
|
37
|
+
const listed = await input.exec.run(["tar", "-tvzf", staged]);
|
|
38
|
+
if (listed.code !== 0)
|
|
39
|
+
fail(400, `上传包列表失败(tar -tvzf):${(listed.stderr || listed.stdout).trim()}——包体损坏?`);
|
|
40
|
+
// 铁律②:tar 输出恒带尾随换行,先 trim 尾部空行再 split(extract.ts 同款)
|
|
41
|
+
const lines = listed.stdout.replace(/\n+$/, "").split("\n");
|
|
42
|
+
const v = validateTarListing(lines);
|
|
43
|
+
if (!v.ok)
|
|
44
|
+
fail(400, `上传包含非法成员「${v.offender}」——R1 zip-slip 防护拒收(list→validate→extract 三步范式)`);
|
|
45
|
+
// REG-CS8 体积闸:清单在手即拒解压炸弹,先于一切解压/落盘动作
|
|
46
|
+
const vol = checkTarVolumeLimits(lines);
|
|
47
|
+
if (!vol.ok) {
|
|
48
|
+
const label = vol.code === "too_many_entries" ? "条目数超限" : vol.code === "unpacked_too_large" ? "解压后总体积超限" : "单成员体积超限";
|
|
49
|
+
fail(413, `上传包${label}(${vol.offender})——REG-CS8 体积闸拒收(解压炸弹防护),请缩减包体后重发`);
|
|
50
|
+
}
|
|
51
|
+
const extracted = await input.exec.run(["tar", "-xzf", staged, "-C", input.stagingDir]);
|
|
52
|
+
if (extracted.code !== 0)
|
|
53
|
+
fail(400, `上传包解包失败(tar -xzf):${(extracted.stderr || extracted.stdout).trim()}`);
|
|
54
|
+
await flattenBundleRoot(input.stagingDir);
|
|
55
|
+
const g = globalThis as { yaml?: { parse: (text: string) => Promise<unknown> } };
|
|
56
|
+
try {
|
|
57
|
+
return await g.yaml!.parse(new TextDecoder().decode(await f.readFile(`${input.stagingDir}/manifest.yaml`)));
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
return fail(400, `上传包 manifest.yaml 不可解析:${String(e)}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -55,6 +55,14 @@ const ANONYMOUS_RE = /^[a-z0-9][a-z0-9\-_.]*(?:\/[a-z0-9][a-z0-9\-_.]*)*$/;
|
|
|
55
55
|
/** P0 §3.2 匿名保留段黑名单(显式拒装清单;与 C2-26 段内收敛互补——C2-26 挡「跨段」,本清单挡「保留段本身」) */
|
|
56
56
|
const ANON_RESERVED_PREFIXES = ["_platform", "_system", "auth", "_lifecycle"];
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* 平台内建编排段(Phase B):市场编排面 api 随脚手架内置,config 出厂即匿名
|
|
60
|
+
* /oj-market/catalog|cdn/upload|yank(方案 §2「市场未装时端点仍在」)。这些条目与
|
|
61
|
+
* /auth/* 同属宿主内建而非方案注入——第一方纯 web 包(无 anonymous 声明)收敛/首装
|
|
62
|
+
* 时不得在 anonymous_residual 被误判为「同 prefix 换装残留」(2026-11-07 市场线演练实锤)。
|
|
63
|
+
*/
|
|
64
|
+
const BUILTIN_ORCH_SEGMENTS = new Set(["oj-market"]);
|
|
65
|
+
|
|
58
66
|
/** C2-17:路由 path 语法(首段首字符为字母;无参数段/通配符/尾斜杠) */
|
|
59
67
|
const ROUTE_PATH_RE = /^\/[a-z][a-z0-9\-_]*(?:\/[a-z0-9\-_]+)*$/;
|
|
60
68
|
|
|
@@ -286,7 +294,7 @@ export async function preflight(ctx: InstallContext): Promise<StageResult> {
|
|
|
286
294
|
else {
|
|
287
295
|
const host = ctx.hostApiVersion;
|
|
288
296
|
if (host === undefined) {
|
|
289
|
-
v.push({ clause: "C2-11", detail: `无法读取宿主平台 API 版本(GET /api/
|
|
297
|
+
v.push({ clause: "C2-11", detail: `无法读取宿主平台 API 版本(GET /api/oj-market/meta 不可用或本仓 API_VERSION 未注入),peerPlatform「${range}」强制比对失败(fail-closed)` });
|
|
290
298
|
}
|
|
291
299
|
else if (!rangeSatisfies(host, parsed[1], parsed[2])) {
|
|
292
300
|
const advice = caretUpgradeAdvice(host, parsed[1], parsed[2]);
|
|
@@ -399,7 +407,9 @@ export async function preflight(ctx: InstallContext): Promise<StageResult> {
|
|
|
399
407
|
// **保留段条目不在此查**:config 里 /auth/*、/_platform/* 等属宿主内建(如 /auth/login),方案永不
|
|
400
408
|
// 能注入(C2-26 段内收敛已挡住方案声明保留段匿名),故保留段 config 条目绝不会是方案残留——
|
|
401
409
|
// 纳入比对只会误伤宿主内建匿名端点(F4 假阳性)。
|
|
402
|
-
|
|
410
|
+
// **平台内建编排段同样不在此查**(BUILTIN_ORCH_SEGMENTS):编排面匿名条目出厂即在 config,
|
|
411
|
+
// 非任何方案注入;第一方纯 web 包(oj-market,无 anonymous 声明)依赖此豁免完成收敛/首装。
|
|
412
|
+
if (!BUILTIN_ORCH_SEGMENTS.has(apiSeg)) {
|
|
403
413
|
const existing = ctx.existingAnonymousPaths ?? [];
|
|
404
414
|
const residual: string[] = [];
|
|
405
415
|
// config 条目带前导斜杠(appendAnonymousPaths 统一补 /),归一去斜杠再取首段,
|
|
@@ -79,7 +79,7 @@ export function moduleSymbolOf(name: string, version: string, kind: "install" |
|
|
|
79
79
|
* - 附加段键(ctx.expected,如 mounts/tenant/tasks)逐一 ∈ applied(覆盖项保留)。
|
|
80
80
|
* 返回 {ok, message?} 供调用方生成人话。
|
|
81
81
|
*/
|
|
82
|
-
export function assertReloadApplied(r: ReloadResult, opts: { hasApi: boolean, kind: "install" | "uninstall", name: string, version: string, additional?: string[], /** 强制热更(oj 0.1.123+ reloadConfig(force)):unchanged 时须失败(强制装配未生效) */ force?: boolean }): { ok: boolean, message?: string } {
|
|
82
|
+
export function assertReloadApplied(r: ReloadResult, opts: { hasApi: boolean, kind: "install" | "uninstall", name: string, version: string, additional?: string[], /** 强制热更(oj 0.1.123+ reloadConfig(force)):unchanged 时须失败(强制装配未生效) */ force?: boolean, /** 卸载侧:锁条目是否真随卸载摘除——双轨同名(内建模块锁条目保留,lockRemoved=false)时 oj 装配面无锁 diff,unchanged 属预期(方案摘除发生在 modules.json/sidecar/route 面) */ lockRemoved?: boolean }): { ok: boolean, message?: string } {
|
|
83
83
|
if (typeof r !== "object" || r === null || !Array.isArray(r.applied) || typeof r.unchanged !== "boolean") {
|
|
84
84
|
return {
|
|
85
85
|
ok: false,
|
|
@@ -93,6 +93,9 @@ export function assertReloadApplied(r: ReloadResult, opts: { hasApi: boolean, ki
|
|
|
93
93
|
}
|
|
94
94
|
if (r.unchanged) {
|
|
95
95
|
if (opts.kind === "uninstall") {
|
|
96
|
+
// 双轨同名(2026-11-07 演练实锤):内建模块锁条目保留 → 装配面无锁 diff,unchanged 属预期。
|
|
97
|
+
if (opts.lockRemoved === false)
|
|
98
|
+
return { ok: true };
|
|
96
99
|
return {
|
|
97
100
|
ok: false,
|
|
98
101
|
message: "reloadConfig 返回 unchanged——卸载后锁未变化,疑似卸载空转(锁未摘除);请确认宿主已开启 reload_api 且 oj ≥ v0.1.122",
|
|
@@ -124,11 +124,17 @@ export async function smoke(ctx: SmokeContext): Promise<StageResult> {
|
|
|
124
124
|
const m = isObj(ctx.manifest) ? ctx.manifest : {};
|
|
125
125
|
const web = isObj(m.web) ? m.web : undefined;
|
|
126
126
|
|
|
127
|
-
// ① 探活:health 优先(2xx
|
|
127
|
+
// ① 探活:health 优先(2xx),缺省首个非匿名路由(连通即可)。
|
|
128
|
+
// 探活只对有 api 面的方案成立:纯 web 包的 routes[] 是页面路由(如 /oj-market),
|
|
129
|
+
// 塞进 api 前缀必 404——2026-11-07 oj-market 重装实锤(误探 /api/oj-market → smoke 失败 +
|
|
130
|
+
// 全量补偿回滚含 modules.json → 重装死循环)。无 api 段 = 无 api 面可探,无事可验视为过。
|
|
128
131
|
const notes: string[] = [];
|
|
129
132
|
try {
|
|
133
|
+
const api = isObj(m.api) ? m.api : undefined;
|
|
130
134
|
const health = typeof m.health === "string" && m.health !== "" ? m.health : undefined;
|
|
131
|
-
const target = health ?? firstNonAnonymousRoute(m);
|
|
135
|
+
const target = api === undefined ? undefined : (health ?? firstNonAnonymousRoute(m));
|
|
136
|
+
if (target === undefined && api === undefined)
|
|
137
|
+
notes.push("纯 web 方案无 api 面,探活跳过");
|
|
132
138
|
if (target !== undefined) {
|
|
133
139
|
const url = probeUrlOf(ctx.apiPrefix, target);
|
|
134
140
|
const res = await ctx.fetch(url, { headers: { [PROBE_TENANT_HEADER]: PROBE_TENANT } });
|
|
@@ -21,7 +21,7 @@ export interface InstallContext {
|
|
|
21
21
|
state: StateStore
|
|
22
22
|
/** 宿主 versions.json(C2-09 peerRuntime 比对真源;键 = 包名,runtime 取 @oj-module/runtime) */
|
|
23
23
|
hostVersions?: Record<string, string>
|
|
24
|
-
/** 宿主平台编排面 API 版本(C2-11 peerPlatform 比对真源;镜像 GET /api/
|
|
24
|
+
/** 宿主平台编排面 API 版本(C2-11 peerPlatform 比对真源;镜像 GET /api/oj-market/meta 的 apiVersion,即 market/api.ts 的 API_VERSION 常量,与 _platform/manifest.yaml 版本手工同步) */
|
|
25
25
|
hostApiVersion?: string
|
|
26
26
|
/**
|
|
27
27
|
* 宿主能力集(P0 §3.9:ojInfo().capabilities,装配期冻结、任何模块可读)。由调用方从 ojInfo 注入;
|
|
@@ -187,14 +187,23 @@ export async function runUninstall(input: UninstallInput): Promise<UninstallResu
|
|
|
187
187
|
if (lock[name] === undefined && side === undefined) {
|
|
188
188
|
return { ok: false, reason: "not-installed", message: `卸载失败:「${name}」未安装(锁与 sidecar 均无记录)` };
|
|
189
189
|
}
|
|
190
|
-
|
|
190
|
+
// 安装在哪:锁与 sidecar 均无记录 = 未安装(P3-11:细分 reason,端点据此回 404 资源缺失语义)
|
|
191
|
+
if (lock[name] === undefined && side === undefined) {
|
|
192
|
+
return { ok: false, reason: "not-installed", message: `卸载失败:「${name}」未安装(锁与 sidecar 均无记录)` };
|
|
193
|
+
}
|
|
194
|
+
// 版本解析 sidecar 优先(2026-11-07 市场线演练实锤:双轨同名——第一方 web-only 方案的锁
|
|
195
|
+
// 编排面)可能正占着锁的另一版本;「锁优先」会把内建版本当方案删,内建目录/锁条目被毁、
|
|
196
|
+
// 编排端点全灭。sidecar 是方案轨唯一真相;锁仅作 sidecar 缺失(旧工程/半残态)的兜底)。
|
|
197
|
+
const version = input.version ?? side?.version ?? lock[name];
|
|
191
198
|
if (version === undefined) {
|
|
192
199
|
return { ok: false, message: `卸载失败:「${name}」版本不可得(锁与 sidecar 均无版本)` };
|
|
193
200
|
}
|
|
194
|
-
|
|
195
|
-
//
|
|
201
|
+
// 锁条目归属裁决:sidecar 在 → 锁条目仅当与 sidecar 同版本才属本方案(api 轨锁随装写);
|
|
202
|
+
// sidecar 缺失(旧形态/半残)→ 锁条目按旧语义归属方案。同名不同轨的锁条目(内建模块)不摘。
|
|
203
|
+
const lockEntryIsSolution = side !== undefined ? lock[name] === side.version : lock[name] !== undefined;
|
|
196
204
|
const remaining = { ...lock };
|
|
197
|
-
|
|
205
|
+
if (lockEntryIsSolution)
|
|
206
|
+
delete remaining[name];
|
|
198
207
|
if (Object.keys(remaining).length === 0) {
|
|
199
208
|
return { ok: false, message: "卸载失败:摘除后锁为空(空锁拒启,S1-1.2)——站点至少保留一个已锁模块,不能卸载最后一个" };
|
|
200
209
|
}
|
|
@@ -252,7 +261,7 @@ export async function runUninstall(input: UninstallInput): Promise<UninstallResu
|
|
|
252
261
|
// 卸载空转回归守卫(§2.7):reload 须真实摘除模块——unchanged 即「锁摘了但装配没掉挂载」,
|
|
253
262
|
// 正是本 PR 修复的回归;api 模块还须 applied 含 -name@ver(web-only 无 api 段,oj 不产模块条目,仅校验 unchanged)。
|
|
254
263
|
const hasApi = await uninstallHasApi(name, version, input.sharedRoot);
|
|
255
|
-
const a = assertReloadApplied(r, { hasApi, kind: "uninstall", name, version });
|
|
264
|
+
const a = assertReloadApplied(r, { hasApi, kind: "uninstall", name, version, lockRemoved: lockEntryIsSolution });
|
|
256
265
|
if (!a.ok) {
|
|
257
266
|
try {
|
|
258
267
|
await writeLock(input.lockPath, lock);
|
|
@@ -82,6 +82,37 @@ export function withSiteOrigin(fetch: FetchInitFace, canonicalOrigin?: string |
|
|
|
82
82
|
return (url: string, init?: { headers?: Record<string, string>, redirect?: string }) => fetch(/^https?:\/\//i.test(url) ? url : `${o.proto}://${o.host}${url}`, init) as ReturnType<FetchFace>;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* 读站点 config.yaml `vars.<key>`(缺文件/缺字段/解析失败 → null)。
|
|
87
|
+
* 与 readCanonicalOrigin 同机制(fsFace + yaml 异步面 + 按 configPath 进程级缓存):
|
|
88
|
+
* vars 下部署期常量皆走此口,勿各写一份读盘。
|
|
89
|
+
*/
|
|
90
|
+
const siteVarCache = new Map<string, Promise<string | null>>();
|
|
91
|
+
|
|
92
|
+
export function readSiteVar(configPath: string | undefined, key: string): Promise<string | null> {
|
|
93
|
+
if (!configPath) {
|
|
94
|
+
return Promise.resolve(null);
|
|
95
|
+
}
|
|
96
|
+
const cacheKey = `${configPath}::${key}`;
|
|
97
|
+
const cached = siteVarCache.get(cacheKey);
|
|
98
|
+
if (cached !== undefined) {
|
|
99
|
+
return cached;
|
|
100
|
+
}
|
|
101
|
+
const p = (async () => {
|
|
102
|
+
try {
|
|
103
|
+
const bytes = await fsFace().readFile(configPath);
|
|
104
|
+
const doc = await (globalThis as { yaml?: { parse: (t: string) => Promise<unknown> } }).yaml?.parse(new TextDecoder().decode(bytes)) as { vars?: Record<string, unknown> } | undefined;
|
|
105
|
+
const v = doc?.vars?.[key];
|
|
106
|
+
return typeof v === "string" && v.trim() !== "" ? v.trim() : null;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
})();
|
|
112
|
+
siteVarCache.set(cacheKey, p);
|
|
113
|
+
return p;
|
|
114
|
+
}
|
|
115
|
+
|
|
85
116
|
/** 站点迁移账本 head(oj journal 真源 `_oj_migrations`;表缺失/查询失败 → null = 跳过禁降版判定,不猜) */
|
|
86
117
|
export async function journalHeadOf(name: string): Promise<number | bigint | null> {
|
|
87
118
|
try {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
name: _platform
|
|
2
|
-
desc: 平台内核(非业务模块、无自身业务路由前缀)。含三类:① 跨模块服务 _shared/service(infra 基础设施 + domain 管理域);② 生命周期引擎 _lifecycle(方案安装/卸载/注册/验签/信任状态机);③ 平台编排 API 面 /api/_platform/{contacts,
|
|
2
|
+
desc: 平台内核(非业务模块、无自身业务路由前缀)。含三类:① 跨模块服务 _shared/service(infra 基础设施 + domain 管理域);② 生命周期引擎 _lifecycle(方案安装/卸载/注册/验签/信任状态机);③ 平台编排 API 面 /api/_platform/{contacts,solutions}(由 contacts/api.ts、solutions/api.ts 暴露,全 superadmin 门禁)。市场域 api(市场目录/发布推送/方案生命周期编排/信任库)自 v4 起归 oj-market 模块(src/oj-market/api/,路由 /api/oj-market/*,deps 声明依赖本内核)。运行时安装的方案包不在此树。
|
|
3
3
|
version: 0.1.0
|
|
4
4
|
tables:
|
|
5
5
|
- _oj_migrations
|
|
@@ -16,7 +16,6 @@ tables:
|
|
|
16
16
|
- market_release
|
|
17
17
|
- sys_rate_window
|
|
18
18
|
- sys_login_throttle
|
|
19
|
-
- reg_source_seq
|
|
20
19
|
# 0016 组织架构领域(CRUD 基线 + 外部源适配器的落点;两张都是租户表)
|
|
21
20
|
- sys_department
|
|
22
21
|
- sys_dept_member
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
-- 0003__market_catalog(mysql 方言):列语义同 0003__market_catalog.sql 头注释。
|
|
2
|
+
-- 差异(沿用 0002 既有惯例):短文本收窄 VARCHAR;签名 value ≤ 255 base64 够用。
|
|
3
|
+
ALTER TABLE market_release ADD COLUMN sig_alg VARCHAR(16);
|
|
4
|
+
ALTER TABLE market_release ADD COLUMN sig_value VARCHAR(255);
|
|
5
|
+
ALTER TABLE market_release ADD COLUMN sig_public_key VARCHAR(64);
|
|
6
|
+
ALTER TABLE market_release ADD COLUMN manifest TEXT;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
-- 0003__market_catalog(postgres 方言):列语义同 0003__market_catalog.sql 头注释。
|
|
2
|
+
-- 差异(沿用 0002 既有惯例):TEXT 全保留。
|
|
3
|
+
ALTER TABLE market_release ADD COLUMN sig_alg TEXT;
|
|
4
|
+
ALTER TABLE market_release ADD COLUMN sig_value TEXT;
|
|
5
|
+
ALTER TABLE market_release ADD COLUMN sig_public_key TEXT;
|
|
6
|
+
ALTER TABLE market_release ADD COLUMN manifest TEXT;
|