@oj-module/cli 0.2.68 → 0.2.71
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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.1xzahckt10.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/global.d.ts +6 -4
- package/templates/init.include.yaml +16 -11
- package/templates/openapi.json +3971 -3663
- 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/service/domain/users.ts +7 -1
- 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/api/system/users/api.ts +3 -0
- package/templates/src/app/web/_platform/__client/api.schemas.ts +0 -113
- package/templates/src/app/web/_platform/__client/index.ts +1 -333
- package/templates/src/app/web/system/__client/api.schemas.ts +2 -0
- 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/rbac-users.spec.ts +8 -0
- package/templates/tests/solutions-entry.spec.ts +55 -0
- package/templates/tsconfig.paths.json +1 -0
- package/templates/types/stream-probes/stream-handlers.ts +34 -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
|
@@ -1,42 +1,41 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
// 编排端点 +
|
|
3
|
-
//
|
|
1
|
+
import type { BundledEntry } from "#/_platform/_lifecycle/bundle-converge";
|
|
2
|
+
// 编排端点 + 市场目录(P1-12,R9/ISP;v4 §四:市场域 api 自 _platform 迁入本模块):/api/oj-market/*
|
|
3
|
+
// 端点族同文件嵌套 handler 组(oj v0.1.64 组形态,notification/api.ts 同款;组名 = 路径段)。
|
|
4
4
|
// 门(R9):install/task/index 全 assertSuperAdmin 独占(市场页是「市场即样例」的第一方特权
|
|
5
5
|
// 例外,R29;权限码不构成门);meta 登录即可(peerPlatform 预检先于判权)。
|
|
6
|
-
// 职责边界(ISP):端点只做 门禁 +
|
|
6
|
+
// 职责边界(ISP):端点只做 门禁 + 目录解析(安装器不拼 URL,installer.ts 同纪律)+
|
|
7
7
|
// InstallRequest 组装;任务生命周期/失败三分/终态审计全归 runInstall 状态机(P1-10),端点不重复编排。
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// 服务端 max-age=60, stale-while-revalidate=60 + 索引自身 expires_at 共同承载)。
|
|
8
|
+
// 目录真源 = market_release 表(DB 目录化 v4;签名索引机制整建制退役)——install 解析/index
|
|
9
|
+
// 卡片/releases 历史全部直读 DB 合成 Form B 形状,包字节仍从 CDN 基址静态拉。
|
|
10
|
+
// CDN 基址:sys_setting `framework.market_cdn_url`(PRD §5.3,缺省空 = 市场不可用,可读错误不崩)。
|
|
12
11
|
// 站点路径缺省(marketEnv):oj serve release 布局生产口径(P1-16 接线 B3/B4)——共享根 = serve
|
|
13
12
|
// 挂载根 dist(锁 dist/manifests.yaml、版本目录 dist/<name>-<ver>/、versions.json 同根),
|
|
14
13
|
// 暂存/任务/sidecar 留 dist 外(R13 不与装配树冲突);全字段可注入覆盖(测试 stub 同款)。
|
|
15
14
|
// file 源上传形态:照 personal-center/upload/api.ts 先例(http.files 双路径,大文件走 blob);
|
|
16
15
|
// 上传包先过 R1 三步范式(list→validate→extract)取出 manifest.yaml 供 preflight——
|
|
17
16
|
// 未校验不解包,zip-slip 在信任边界外被拒。
|
|
18
|
-
import type { FetchFace } from "
|
|
19
|
-
import type { ExecFace } from "
|
|
20
|
-
import type { ReloadFace } from "
|
|
21
|
-
import type { FetchInitFace } from "
|
|
22
|
-
import
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import { getTask, readTasks } from "
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
17
|
+
import type { FetchFace } from "#/_platform/_lifecycle/steps/download";
|
|
18
|
+
import type { ExecFace } from "#/_platform/_lifecycle/steps/extract";
|
|
19
|
+
import type { ReloadFace } from "#/_platform/_lifecycle/steps/reload";
|
|
20
|
+
import type { FetchInitFace } from "#/_platform/_shared/site-facade";
|
|
21
|
+
import { atomicWriteText, fsFace } from "#/_platform/_lifecycle/atomic";
|
|
22
|
+
import { convergeBundledSolutions, pruneBundledMarker } from "#/_platform/_lifecycle/bundle-converge";
|
|
23
|
+
import { recordHistory } from "#/_platform/_lifecycle/history";
|
|
24
|
+
import { latestAppliedSeqOf, reconcile, runInstall, setEnabled } from "#/_platform/_lifecycle/installer";
|
|
25
|
+
import { localArchiveManifestOf } from "#/_platform/_lifecycle/local-archive";
|
|
26
|
+
import { compareVersions } from "#/_platform/_lifecycle/semver";
|
|
27
|
+
import { sha384B64 } from "#/_platform/_lifecycle/sha384";
|
|
28
|
+
import { createStateStore } from "#/_platform/_lifecycle/state";
|
|
29
|
+
import { getTask, readTasks } from "#/_platform/_lifecycle/tasks";
|
|
30
|
+
import { createTrustStore } from "#/_platform/_lifecycle/trust";
|
|
31
|
+
import { computeUninstallImpact, runUninstall } from "#/_platform/_lifecycle/uninstall";
|
|
32
|
+
import { respond } from "#/_platform/_shared/respond";
|
|
33
|
+
import { assertSuperAdmin } from "#/_platform/_shared/service/infra/access";
|
|
34
|
+
import { pageArgs } from "#/_platform/_shared/service/infra/query";
|
|
35
|
+
import { journalHeadOf, readCanonicalOrigin, readSiteVar, siteOrigin, withSiteOrigin } from "#/_platform/_shared/site-facade";
|
|
36
|
+
import { backfillReleases } from "./backfill";
|
|
38
37
|
import { BOOTSTRAP_KEY, seedBootstrapKey } from "./bootstrap";
|
|
39
|
-
import {
|
|
38
|
+
import { authenticateSignedKey, verifyCanonicalSig } from "./cdn/signed-auth";
|
|
40
39
|
|
|
41
40
|
// 站点编排面共享真源落 _shared/site-facade(P2-16 真机构建钉出:api.ts 是路由入口不可被模块内
|
|
42
41
|
// import);此处 re-export 保持既有引用面(market-api.spec / 历史 import 路径)不变。
|
|
@@ -46,6 +45,9 @@ export type { FetchInitFace };
|
|
|
46
45
|
/** 平台编排面版本(镜像 _platform/manifest.yaml version;peerPlatform 预检口径,manifest 升版须同步) */
|
|
47
46
|
const API_VERSION = "0.1.0";
|
|
48
47
|
|
|
48
|
+
/** 目录协议版本(v4 §三:api_version 给契约演进留逃生门;跨站消费侧据此判可解析性) */
|
|
49
|
+
const CATALOG_API_VERSION = "v1";
|
|
50
|
+
|
|
49
51
|
export interface MarketEnv {
|
|
50
52
|
/** 共享根(manifests.yaml 所在目录 = 版本目录父级,D3 单 mount 形态) */
|
|
51
53
|
sharedRoot: string
|
|
@@ -71,11 +73,8 @@ export interface MarketEnv {
|
|
|
71
73
|
canonicalOrigin?: string
|
|
72
74
|
/** CDN 基址(缺省读 sys_setting `framework.market_cdn_url`;测试注入) */
|
|
73
75
|
cdnBase?: () => Promise<string | null>
|
|
74
|
-
/**
|
|
75
|
-
|
|
76
|
-
* (ETag/If-None-Match 条件请求);本地目录索引 / 测试夹具可直注(localIndexSource)。
|
|
77
|
-
*/
|
|
78
|
-
indexSource?: IndexSource
|
|
76
|
+
/** cdn/ 字节仓库目录(上传端点与回填命令共用;缺省 "cdn") */
|
|
77
|
+
cdnDir?: string
|
|
79
78
|
/** 注入面(DIP):缺省回落 isolate 全局 fetch / exec.run / server.reloadConfig / 本地 sha384(过渡实现) */
|
|
80
79
|
fetch?: FetchFace
|
|
81
80
|
exec?: ExecFace
|
|
@@ -163,6 +162,7 @@ export function marketEnv(): MarketEnv {
|
|
|
163
162
|
taskDir: "modules/.install-tasks",
|
|
164
163
|
statePath: "modules/.solutions/state.yaml",
|
|
165
164
|
trustPath: ".solutions/trusted-keys.yaml",
|
|
165
|
+
cdnDir: "cdn",
|
|
166
166
|
dbPath: "db.sqlite",
|
|
167
167
|
siteModulesDir: "dist/web/modules",
|
|
168
168
|
modulesJsonPath: "dist/web/modules.json",
|
|
@@ -218,18 +218,6 @@ async function requireCanonicalOrigin(env: MarketEnv): Promise<string> {
|
|
|
218
218
|
return co;
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
/**
|
|
222
|
-
* REG-CS6:相对市场基址(`market_cdn_url=/cdn` 本站点自供)**必须**有 canonical origin 才能补全为
|
|
223
|
-
* 同源绝对 URL——绝对基址(外部真 CDN)不需要。缺失/非法即明确报错并指向具体设置项
|
|
224
|
-
* (config.yaml `vars.canonical_origin`),绝不回落请求头 Host(伪造 Host 会牵引出站)。
|
|
225
|
-
*/
|
|
226
|
-
async function assertOriginForRelativeBase(env: MarketEnv, base: string): Promise<void> {
|
|
227
|
-
if (/^https?:\/\//i.test(base)) {
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
await requireCanonicalOrigin(env);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
221
|
function execOf(env: MarketEnv): ExecFace {
|
|
234
222
|
const g = globalThis as { exec?: { run: ExecFace["run"] } };
|
|
235
223
|
return env.exec ?? { run: (argv, opts) => g.exec!.run(argv, opts) };
|
|
@@ -291,234 +279,6 @@ async function cdnBaseOf(env: MarketEnv): Promise<string | null> {
|
|
|
291
279
|
}
|
|
292
280
|
|
|
293
281
|
/** 索引签名算法标识(与 ojm publish 同源:Ed25519;C2-35) */
|
|
294
|
-
const INDEX_SIG_ALG = "Ed25519";
|
|
295
|
-
|
|
296
|
-
/** 索引规范串(REG-CS1,与 ojm publish indexCanonical 逐字一致):`index:<kind>:<seq>:<sha256hex>` */
|
|
297
|
-
function indexCanonical(env: MarketEnv, kind: IndexKind, seq: number, text: string): string {
|
|
298
|
-
const sha256 = sha256Of(env)(new TextEncoder().encode(text));
|
|
299
|
-
return `index:${kind}:${seq}:${sha256}`;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/** Ed25519 验签面(P2-3;env 注入优先,缺省回落 isolate 全局 crypto.verifyEd25519) */
|
|
303
|
-
function verifyEd25519Of(env: MarketEnv): (publicKey: string, msg: string, sigBase64: string) => boolean {
|
|
304
|
-
const g = globalThis as { crypto?: { verifyEd25519?: (pk: string, msg: string, sig: string) => boolean } };
|
|
305
|
-
return env.verifyEd25519 ?? ((pk, msg, sig) => g.crypto!.verifyEd25519!(pk, msg, sig));
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/** 验签判定:ok + 代次(读方据此做同代校验;seq 为 null = 旧格式签名,一律拒) */
|
|
309
|
-
interface IndexSigVerdict {
|
|
310
|
-
ok: boolean
|
|
311
|
-
seq: number | null
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* 索引签名校验(REG-CS1,G4 fail-closed 口径):解析 detached .sig 六字段 → 结构校验 →
|
|
316
|
-
* 重算规范串(含 seq)→ verifyEd25519。返回 ok 仅当签名完整、覆盖代次且匹配索引字节。
|
|
317
|
-
* 注:发布方信任(key 是否在信任库/bootstrap)由安装期 TOFU/信任库承载,此处仅做完整性(防 CDN 索引篡改)。
|
|
318
|
-
*/
|
|
319
|
-
function verifyIndexSig(env: MarketEnv, kind: IndexKind, text: string, sigText: string): IndexSigVerdict {
|
|
320
|
-
let sig: unknown;
|
|
321
|
-
try {
|
|
322
|
-
sig = JSON.parse(sigText);
|
|
323
|
-
}
|
|
324
|
-
catch {
|
|
325
|
-
return { ok: false, seq: null };
|
|
326
|
-
}
|
|
327
|
-
if (!isObj(sig))
|
|
328
|
-
return { ok: false, seq: null };
|
|
329
|
-
const { alg, value, public_key: publicKey, seq } = sig as { alg?: unknown, value?: unknown, public_key?: unknown, seq?: unknown };
|
|
330
|
-
if (alg !== INDEX_SIG_ALG || typeof value !== "string" || typeof publicKey !== "string")
|
|
331
|
-
return { ok: false, seq: null };
|
|
332
|
-
// 签名未覆盖代次 = 旧格式(无法防「新总目录 + 旧分册」),拒
|
|
333
|
-
if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 1)
|
|
334
|
-
return { ok: false, seq: null };
|
|
335
|
-
return { ok: verifyEd25519Of(env)(publicKey, indexCanonical(env, kind, seq, text), value), seq };
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/** 索引正文的代次(顶层 `seq`;纯内容形态(分片)无顶层键 → null,代次只由 .sig 承载) */
|
|
339
|
-
async function indexSeqOfText(text: string): Promise<number | null> {
|
|
340
|
-
const g = globalThis as { yaml?: { parse: (t: string) => Promise<unknown> } };
|
|
341
|
-
try {
|
|
342
|
-
const doc = await g.yaml!.parse(text) as unknown;
|
|
343
|
-
if (doc === null || typeof doc !== "object" || Array.isArray(doc))
|
|
344
|
-
return null;
|
|
345
|
-
const seq = (doc as Record<string, unknown>).seq;
|
|
346
|
-
return typeof seq === "number" && Number.isInteger(seq) && seq >= 1 ? seq : null;
|
|
347
|
-
}
|
|
348
|
-
catch {
|
|
349
|
-
return null;
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/** 缺签名/空签名的人话(两步可执行下一步,与 CLI `ojm market verify` 同一文案口径) */
|
|
354
|
-
const SIG_MISSING_NEXT = "完整性无保障,已拒载。下一步:① 让源执行 ojm market build-index --id <publisher.id> 重建并签名;② 或对既有索引补签 ojm publish <目录> --id <publisher.id>";
|
|
355
|
-
|
|
356
|
-
/**
|
|
357
|
-
* 拉取索引并校验签名(REG-CS1,G4 fail-closed)——三级化后(catalog + shards):
|
|
358
|
-
* ① `.sig` 缺失 / 空 / 拉不到 → **拒载**(502)——不再「滚动发布期容忍」:
|
|
359
|
-
* 容忍等于把索引完整性交给发布方自觉,谁改了索引都能装。
|
|
360
|
-
* ② 签名不符或未覆盖代次 → 拒载(index_sig_mismatch)。
|
|
361
|
-
* ③ 同代校验改「钉值」口径:catalog 正文 `shards: {<片号>: <代次>}` 钉每片的代次,
|
|
362
|
-
* 分片签名 seq 必须等于钉值(`expectSeq`)——不等即拒(index_generation_mismatch,
|
|
363
|
-
* 提示「源正在重建索引」)。byte 级相同的分片可跨代复用,故钉值而非恒等于 catalog 代次。
|
|
364
|
-
* ④ catalog 另过两道新鲜度闸(REG-CS2,见 `guardCatalogFreshness`):时效过期 `index_expired`、
|
|
365
|
-
* seq 回退 `index_stale`——回放与镜像落后在此处被挡住。
|
|
366
|
-
*
|
|
367
|
-
* `no` = 分片号(catalog 省略);`expectSeq` = catalog 对该片的钉值(取 catalog 本身时省略)。
|
|
368
|
-
* 关键:四类拒载都在 try 之外抛出——fail-closed 不能被任何 catch 吞掉。
|
|
369
|
-
*/
|
|
370
|
-
async function fetchIndex(env: MarketEnv, source: IndexSource, base: string, kind: IndexKind, no?: number, expectSeq?: number): Promise<{ text: string, seq: number }> {
|
|
371
|
-
// 人话里的文件名与 CDN 布局逐字一致(indexPathOf 单点):shards/<n>.yaml,不是 shard/<n>.yaml
|
|
372
|
-
const label = indexPathOf(kind, no);
|
|
373
|
-
const file = await source.read(kind, no);
|
|
374
|
-
if (file === null)
|
|
375
|
-
fail(502, `市场索引 ${label} 拉取失败(请检查市场地址与网络)`);
|
|
376
|
-
const sigText = file.sig;
|
|
377
|
-
if (sigText === null || sigText.trim() === "")
|
|
378
|
-
fail(502, `市场索引 ${label} 缺签名文件 ${label}.sig(index_sig_missing)——${SIG_MISSING_NEXT}`);
|
|
379
|
-
const verdict = verifyIndexSig(env, kind, file.body, sigText);
|
|
380
|
-
if (!verdict.ok || verdict.seq === null)
|
|
381
|
-
fail(502, `市场索引 ${label} 签名校验失败(index_sig_mismatch:索引被篡改、签名不符或签名未覆盖代次,G4 fail-closed)——请重新发布并签名`);
|
|
382
|
-
const seq = verdict.seq;
|
|
383
|
-
// 正文自带代次时(catalog)必须与签名一致——防止「改了正文没换签名」的半个写入;
|
|
384
|
-
// 分片正文是纯内容(无顶层 seq),代次只由签名 + catalog 钉值携带,故不过此闸。
|
|
385
|
-
const textSeq = await indexSeqOfText(file.body);
|
|
386
|
-
if (textSeq !== null && textSeq !== seq)
|
|
387
|
-
fail(502, `市场索引 ${label} 正文是第 ${textSeq} 代、签名是第 ${seq} 代(index_generation_mismatch)——源正在重建索引,请稍后重试`);
|
|
388
|
-
if (expectSeq !== undefined && expectSeq !== seq)
|
|
389
|
-
fail(502, `市场索引 ${label} 签名是第 ${seq} 代,总目录钉的是第 ${expectSeq} 代(index_generation_mismatch)——源正在重建索引,请稍后重试`);
|
|
390
|
-
// ④ 新鲜度只在 catalog 上判一次:代次真源与时效盖章都在它身上,分片跟着走
|
|
391
|
-
if (kind === "catalog")
|
|
392
|
-
await guardCatalogFreshness(env, base, file.body, seq);
|
|
393
|
-
return { text: file.body, seq };
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
/**
|
|
397
|
-
* REG-CS2 索引新鲜度(防回放/降级):总目录过了签名之后还要过时效与代次水位两道闸。
|
|
398
|
-
*
|
|
399
|
-
* - `expires_at` 已过 → `index_expired`:索引自带失效时刻,源停止运营超过 TTL 就不再可信。
|
|
400
|
-
* (§5.3 可用性通道的「窗口内用缓存副本」由 HTTP 缓存层兑现——服务端
|
|
401
|
-
* `max-age=60, stale-while-revalidate=60` + 读取器条件请求;到达读取器手里的副本
|
|
402
|
-
* 自身过期即拒,与§5.3 不冲突。)
|
|
403
|
-
* - `seq` 小于本源见过的最大代次 → `index_stale`:镜像落后或旧索引被回放。
|
|
404
|
-
* - 水位读不到/写不进 → `index_state_corrupt`:fail-closed,不静默重置成「第一次见」。
|
|
405
|
-
*/
|
|
406
|
-
async function guardCatalogFreshness(env: MarketEnv, base: string, text: string, seq: number): Promise<void> {
|
|
407
|
-
const times = await indexTimesOfText(text);
|
|
408
|
-
if (times === null)
|
|
409
|
-
fail(502, "市场索引 catalog.yaml 缺 issued_at / expires_at(index_expired)——索引未带时效,请让源执行 ojm market build-index 重建");
|
|
410
|
-
const now = env.now?.() ?? Math.floor(Date.now() / 1000);
|
|
411
|
-
if (now > times.expires_at)
|
|
412
|
-
fail(502, `市场索引 catalog.yaml 已于 ${new Date(times.expires_at * 1000).toISOString()} 失效(index_expired)——源未在其自声明的有效期内重建索引,请让源执行 ojm market build-index 后再装`);
|
|
413
|
-
|
|
414
|
-
let verdict: SourceSeqVerdict;
|
|
415
|
-
try {
|
|
416
|
-
verdict = await guardAndRecordSourceSeq(base, seq);
|
|
417
|
-
}
|
|
418
|
-
catch {
|
|
419
|
-
fail(502, "市场索引代次水位写不进去(index_state_corrupt)——reg_source_seq 不可写,防回放保护失效,已拒载。请检查站点数据库权限后重试");
|
|
420
|
-
}
|
|
421
|
-
if (verdict.ok)
|
|
422
|
-
return;
|
|
423
|
-
if (verdict.code === "index_stale")
|
|
424
|
-
fail(502, `市场索引 catalog.yaml 是第 ${seq} 代,本源已见过第 ${verdict.prev} 代(index_stale)——源可能被回放或镜像落后,请核实源状态后再装`);
|
|
425
|
-
fail(502, "市场索引代次水位不可读(index_state_corrupt)——reg_source_seq 记录损坏,请检查站点数据库后重试(不静默重置,否则防回放形同虚设)");
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
/**
|
|
429
|
-
* catalog 解析(REG-CS5:包名 → 分片号 + 片号 → 代次钉值)。畸形即拒不猜——索引是签名过的
|
|
430
|
-
* 不可信输入,结构破损与字节被改同类对待(G4 fail-closed)。
|
|
431
|
-
*/
|
|
432
|
-
async function parseCatalog(text: string, base: string): Promise<{ pins: Map<number, number>, solutions: Array<{ name: string, shards: number[] }> }> {
|
|
433
|
-
const g = globalThis as { yaml?: { parse: (t: string) => Promise<unknown> } };
|
|
434
|
-
let doc: unknown;
|
|
435
|
-
try {
|
|
436
|
-
doc = await g.yaml!.parse(text);
|
|
437
|
-
}
|
|
438
|
-
catch {
|
|
439
|
-
fail(502, `市场索引解析失败:${base}/catalog.yaml 不是合法索引`);
|
|
440
|
-
}
|
|
441
|
-
if (!isObj(doc))
|
|
442
|
-
fail(502, `市场索引解析失败:${base}/catalog.yaml 不是键值文档`);
|
|
443
|
-
const pins = new Map<number, number>();
|
|
444
|
-
if (isObj(doc.shards)) {
|
|
445
|
-
for (const [k, v] of Object.entries(doc.shards)) {
|
|
446
|
-
const no = Number(k);
|
|
447
|
-
if (Number.isInteger(no) && no >= 1 && typeof v === "number" && Number.isInteger(v) && v >= 1)
|
|
448
|
-
pins.set(no, v);
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
if (pins.size === 0)
|
|
452
|
-
fail(502, "市场索引 catalog.yaml 的分片钉值为空(index_generation_mismatch)——索引结构破损,请让源执行 ojm market build-index 重建");
|
|
453
|
-
const solutions: Array<{ name: string, shards: number[] }> = [];
|
|
454
|
-
for (const s of Array.isArray(doc.solutions) ? doc.solutions : []) {
|
|
455
|
-
if (!isObj(s) || typeof s.name !== "string" || s.name === "")
|
|
456
|
-
continue;
|
|
457
|
-
// 片号须是已钉值正整数:指向未钉片的方案 = 结构破损,拒载(不静默跳过,那会伪装成「没有该方案」)
|
|
458
|
-
const nos = Array.isArray(s.shards) ? s.shards.filter((n): n is number => typeof n === "number" && Number.isInteger(n) && n >= 1 && pins.has(n)) : [];
|
|
459
|
-
if (nos.length === 0)
|
|
460
|
-
fail(502, `市场索引 catalog.yaml 中方案「${s.name}」未指向任何已钉分片(index_generation_mismatch)——索引结构破损,请让源执行 ojm market build-index 重建`);
|
|
461
|
-
solutions.push({ name: s.name, shards: nos });
|
|
462
|
-
}
|
|
463
|
-
return { pins, solutions };
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
/** 分片内的方案条目(版本条目 = Form B manifest:name/version/archive/yanked/yanked_at + manifest 全字段) */
|
|
467
|
-
interface ShardSolution {
|
|
468
|
-
name: string
|
|
469
|
-
latest: string | null
|
|
470
|
-
versions: Array<Record<string, unknown>>
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
/** 分片解析(纯内容无 meta:`{solutions: [{name, latest, versions}]}`;结构破损即拒,不猜) */
|
|
474
|
-
async function parseShard(text: string, label: string): Promise<ShardSolution[]> {
|
|
475
|
-
const g = globalThis as { yaml?: { parse: (t: string) => Promise<unknown> } };
|
|
476
|
-
let doc: unknown;
|
|
477
|
-
try {
|
|
478
|
-
doc = await g.yaml!.parse(text);
|
|
479
|
-
}
|
|
480
|
-
catch {
|
|
481
|
-
fail(502, `市场索引 ${label} 不是合法索引(解析失败)`);
|
|
482
|
-
}
|
|
483
|
-
if (!isObj(doc) || !Array.isArray(doc.solutions))
|
|
484
|
-
fail(502, `市场索引 ${label} 解析失败:缺 solutions 数组——索引结构破损,请让源执行 ojm market build-index 重建`);
|
|
485
|
-
const out: ShardSolution[] = [];
|
|
486
|
-
for (const s of doc.solutions) {
|
|
487
|
-
if (!isObj(s) || typeof s.name !== "string" || s.name === "")
|
|
488
|
-
continue;
|
|
489
|
-
out.push({
|
|
490
|
-
name: s.name,
|
|
491
|
-
latest: typeof s.latest === "string" ? s.latest : null,
|
|
492
|
-
versions: Array.isArray(s.versions) ? s.versions.filter(isObj) : [],
|
|
493
|
-
});
|
|
494
|
-
}
|
|
495
|
-
return out;
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
/** 索引读取源(DIP,REG-CS5③):注入面优先,缺省按 CDN 基址 + fetchOf 构造 HTTP 源 */
|
|
499
|
-
async function indexSourceOf(env: MarketEnv, base: string): Promise<IndexSource> {
|
|
500
|
-
return env.indexSource ?? httpIndexSource(await fetchOf(env), base);
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
/** 索引时效(顶层 `issued_at` / `expires_at`;缺任一或非数 → null,按 REG-CS2 拒) */
|
|
504
|
-
async function indexTimesOfText(text: string): Promise<{ issued_at: number, expires_at: number } | null> {
|
|
505
|
-
const g = globalThis as { yaml?: { parse: (t: string) => Promise<unknown> } };
|
|
506
|
-
try {
|
|
507
|
-
const doc = await g.yaml!.parse(text) as unknown;
|
|
508
|
-
if (doc === null || typeof doc !== "object" || Array.isArray(doc))
|
|
509
|
-
return null;
|
|
510
|
-
const issuedAt = (doc as Record<string, unknown>).issued_at;
|
|
511
|
-
const expiresAt = (doc as Record<string, unknown>).expires_at;
|
|
512
|
-
if (typeof issuedAt !== "number" || typeof expiresAt !== "number")
|
|
513
|
-
return null;
|
|
514
|
-
return { issued_at: issuedAt, expires_at: expiresAt };
|
|
515
|
-
}
|
|
516
|
-
catch {
|
|
517
|
-
return null;
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
/** http.user.id → username(operator 历史留痕,uninstall 端点同款反查;查询失败不阻断编排) */
|
|
522
282
|
async function operatorOf(): Promise<string | null> {
|
|
523
283
|
const uid = (http as { user?: { id?: unknown } }).user?.id;
|
|
524
284
|
if (uid == null) {
|
|
@@ -579,59 +339,32 @@ interface ResolvedBundle {
|
|
|
579
339
|
}
|
|
580
340
|
|
|
581
341
|
/**
|
|
582
|
-
*
|
|
583
|
-
*
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
342
|
+
* DB 目录解析(v4 §四:安装解析改走 market_release 表——签名索引机制退役,目录行即 Form B
|
|
343
|
+
* 条目真源;安装器不拼 URL,市场侧职责同纪律)。latest = 未 yanked 的 semver max(API 计算,
|
|
344
|
+
* 非存储列);精确版本被 yank → 409 人话(REG-CS4 语义保留);archive 由服务端组表正名
|
|
345
|
+
* (formBEntryOf 逐字 <name>-<version>.tgz,P2-11 投毒加固语义随服务端构造自然成立)。
|
|
346
|
+
* 分发包 URL = CDN 基址 + file(包字节仍静态拉,不变)。
|
|
587
347
|
*/
|
|
588
|
-
async function
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
// 否则 yank 掉最高版会把 latest 主动下调,被误判成索引回退;latest 本就指向未下线的最高版。
|
|
609
|
-
const liveSorted = sol.versions.filter(v => v.yanked !== true).map(v => String(v.version)).sort(compareVersions);
|
|
610
|
-
const highest = liveSorted.length === 0 ? null : liveSorted[liveSorted.length - 1]!;
|
|
611
|
-
if (sol.latest !== null && highest !== null && compareVersions(sol.latest, highest) < 0)
|
|
612
|
-
fail(502, `市场索引中方案「${name}」的 latest 是 ${sol.latest},低于已声明的最高版本 ${highest}(index_latest_regressed)——索引被回退,请核实源状态后再装`);
|
|
613
|
-
const version = reqVersion ?? sol.latest;
|
|
614
|
-
if (version !== null) {
|
|
615
|
-
const hit = sol.versions.find(v => String(v.version) === version);
|
|
616
|
-
if (hit !== undefined) {
|
|
617
|
-
// REG-CS4 yank:精确指定被下线版本 → 拒装(latest 路径不命中,源方 rebuilt 时 latest 已跳过 yanked);
|
|
618
|
-
// 已装实例不受影响(不在此解析路径内)。错误信息明示「版本已下线」而非「无此版本」,避免误导。
|
|
619
|
-
if (reqVersion !== null && hit.yanked === true)
|
|
620
|
-
fail(409, `方案「${name}」版本「${version}」已被发布方下线(REG-CS4 yank),无法安装;已安装实例不受影响。如需继续请联系发布方或改用其他版本`);
|
|
621
|
-
// P2-11 索引投毒加固:archive.file 逐字 <name>-<version>.tgz(索引是签名过的不可信输入,
|
|
622
|
-
// 过弱形态可拼宿主任意路径;逐字等值排除 ../、绝对路径、异名、异版本)
|
|
623
|
-
const archive = isObj(hit.archive) ? hit.archive : {};
|
|
624
|
-
const file = typeof archive.file === "string" ? archive.file : "";
|
|
625
|
-
if (file !== `${name}-${version}.tgz`) {
|
|
626
|
-
fail(502, `索引分发包 file「${file}」须逐字为 <name>-<version>.tgz(「${name}-${version}.tgz」)——索引投毒加固(P2-11),疑似被篡改的索引`);
|
|
627
|
-
}
|
|
628
|
-
// 版本条目即 Form B manifest(原 manifests.yaml 并入分片):取到条目即取到 manifest
|
|
629
|
-
return { version, archiveUrl: `${base}/${file}`, manifest: hit };
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
// 版本不在这片(更低分片或真没有):继续往低片找
|
|
633
|
-
}
|
|
634
|
-
fail(404, `市场索引中无方案「${name}」版本「${reqVersion ?? "latest"}」(${base}/catalog.yaml → shards)`);
|
|
348
|
+
async function resolveFromDb(base: string, name: string, reqVersion: string | null): Promise<ResolvedBundle> {
|
|
349
|
+
const rows = await db.table("market_release")
|
|
350
|
+
.select(["product", "version", "key_id", "sha256", "size", "yanked", "sig_alg", "sig_value", "sig_public_key", "sig_registry_id", "manifest"])
|
|
351
|
+
.where({ field: "product", op: "eq", value: name })
|
|
352
|
+
.all() as Array<Record<string, unknown>>;
|
|
353
|
+
if (rows.length === 0)
|
|
354
|
+
fail(404, `市场目录中无方案「${name}」——若为存量分发包,请站点 superadmin 先执行目录回填(扫描 cdn/ 存量补录 market_release)后再安装`);
|
|
355
|
+
const live = rows.filter(r => Number(r.yanked) !== 1).map(r => String(r.version)).sort(compareVersions);
|
|
356
|
+
const version = reqVersion ?? (live.length === 0 ? null : live[live.length - 1]!);
|
|
357
|
+
if (version === null)
|
|
358
|
+
fail(409, `方案「${name}」的全部版本均已被发布方下线,无法安装;已安装实例不受影响。如需继续请联系发布方`);
|
|
359
|
+
const row = rows.find(r => String(r.version) === version);
|
|
360
|
+
if (row === undefined)
|
|
361
|
+
fail(404, `市场目录中无方案「${name}」版本「${version}」——可用版本:${live.join("、") || "(无)"}`);
|
|
362
|
+
if (reqVersion !== null && Number(row.yanked) === 1)
|
|
363
|
+
fail(409, `方案「${name}」版本「${version}」已被发布方下线(yank),无法安装;已安装实例不受影响。如需继续请联系发布方或改用其他版本`);
|
|
364
|
+
const entry = formBEntryOf(name, row);
|
|
365
|
+
const archive = isObj(entry.archive) ? entry.archive : {};
|
|
366
|
+
const file = typeof archive.file === "string" ? archive.file : `${name}-${version}.tgz`;
|
|
367
|
+
return { version, archiveUrl: `${base}/${file}`, manifest: entry };
|
|
635
368
|
}
|
|
636
369
|
|
|
637
370
|
/** 上传载荷字节(upload/api.ts 先例双路径:小文件字节在手,大文件已流式落 blob 须走 blob.get) */
|
|
@@ -667,37 +400,6 @@ async function uploadSig(): Promise<Record<string, unknown> | null> {
|
|
|
667
400
|
return parsed as Record<string, unknown>;
|
|
668
401
|
}
|
|
669
402
|
|
|
670
|
-
/**
|
|
671
|
-
* file 源 manifest 提取:上传包先过 R1 三步范式(tar -tvzf 列表 → validateTarListing →
|
|
672
|
-
* tar -xzf -C staging)再读 manifest.yaml——未校验不解包,zip-slip 拒在信任边界外。
|
|
673
|
-
* 预解包产物与安装器 extracting 段同目录幂等(staging 就位即止,rename 归 lock_commit)。
|
|
674
|
-
*/
|
|
675
|
-
async function manifestOfUpload(env: MarketEnv, bytes: Uint8Array, exec: ExecFace): Promise<unknown> {
|
|
676
|
-
const f = fsFace();
|
|
677
|
-
const staged = `${env.staging}/upload.tgz`; // 常量名不含用户输入,无穿越面;download 段按 manifest.archive.file 重写正名
|
|
678
|
-
await f.mkdir(env.staging, { recursive: true });
|
|
679
|
-
await f.writeFile(staged, bytes);
|
|
680
|
-
const listed = await exec.run(["tar", "-tvzf", staged]);
|
|
681
|
-
if (listed.code !== 0)
|
|
682
|
-
fail(400, `上传包列表失败(tar -tvzf):${(listed.stderr || listed.stdout).trim()}——包体损坏?`);
|
|
683
|
-
// 铁律②:tar 输出恒带尾随换行,先 trim 尾部空行再 split(extract.ts 同款)
|
|
684
|
-
const lines = listed.stdout.replace(/\n+$/, "").split("\n");
|
|
685
|
-
const v = validateTarListing(lines);
|
|
686
|
-
if (!v.ok)
|
|
687
|
-
fail(400, `上传包含非法成员「${v.offender}」——R1 zip-slip 防护拒收(list→validate→extract 三步范式)`);
|
|
688
|
-
const extracted = await exec.run(["tar", "-xzf", staged, "-C", env.staging]);
|
|
689
|
-
if (extracted.code !== 0)
|
|
690
|
-
fail(400, `上传包解包失败(tar -xzf):${(extracted.stderr || extracted.stdout).trim()}`);
|
|
691
|
-
await flattenBundleRoot(env.staging);
|
|
692
|
-
const g = globalThis as { yaml?: { parse: (text: string) => Promise<unknown> } };
|
|
693
|
-
try {
|
|
694
|
-
return await g.yaml!.parse(new TextDecoder().decode(await f.readFile(`${env.staging}/manifest.yaml`)));
|
|
695
|
-
}
|
|
696
|
-
catch (e) {
|
|
697
|
-
return fail(400, `上传包 manifest.yaml 不可解析:${String(e)}`);
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
|
|
701
403
|
/**
|
|
702
404
|
* 取消安装文件握手(P3-12 端点接线,U5/P3-8 备案①落地):oj isolate 按请求隔离,内存信号
|
|
703
405
|
* 不共享——改走任务目录下标记文件 `${taskDir}/.cancel-<name>`(跨 isolate 唯一共享面 = FS)。
|
|
@@ -730,37 +432,12 @@ async function installBody(env: MarketEnv): Promise<{ task_id: string }> {
|
|
|
730
432
|
const bytes = await uploadBytes();
|
|
731
433
|
if (bytes === null)
|
|
732
434
|
fail(400, "source=file 须随 multipart 上传分发包(http.files)");
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
const m = isObj(manifest) ? manifest : {};
|
|
736
|
-
// 身份:body 权威、manifest 兜底;两者俱在且冲突即拒(防锁名/注册名分叉的坏装)
|
|
737
|
-
const mVersion = typeof m.version === "string" ? m.version : "";
|
|
738
|
-
if (reqVersion !== null && mVersion !== "" && mVersion !== reqVersion)
|
|
739
|
-
fail(400, `body.version「${reqVersion}」与包内 manifest.version「${mVersion}」不一致——请核对分发包`);
|
|
740
|
-
const mName = typeof m.name === "string" ? m.name : "";
|
|
741
|
-
if (mName !== "" && mName !== name)
|
|
742
|
-
fail(400, `body.name「${name}」与包内 manifest.name「${mName}」不一致——请核对分发包`);
|
|
743
|
-
if (reqVersion === null && mVersion === "")
|
|
744
|
-
fail(400, "version 不可得(body.version 与包内 manifest.version 均缺失)");
|
|
745
|
-
// Form A 补摘要(C2-29 布局消费点,P1-13 真机实测——附录 B-10):ojm build --bundle 产物的
|
|
746
|
-
// 包内 manifest 是 Form A(archive 仅 file,sha256/size 由 P3 publish 填充于 Form B),而安装器
|
|
747
|
-
// verify 段对 archive.sha256 fail-closed。file 源字节在手——上传时点即算摘要补成可校验清单
|
|
748
|
-
// (verify 复核同批字节,R10 语义保持);CDN 源不受影响(Form B 真源自带摘要)。
|
|
749
|
-
// 正名(P2-11 索引投毒加固):archive.file 逐字 <name>-<version>.tgz——name(C2-01)/version
|
|
750
|
-
// (无路径分隔符)拼出的单文件名安全时即正名(download 段按其落盘);否则退回常量名
|
|
751
|
-
// upload.tgz(此刻尚未过预检,恶意命名不提前 400 也不给穿越面——预检稍后照拒)。
|
|
752
|
-
const effectiveVersion = reqVersion ?? mVersion;
|
|
753
|
-
const namedFile = /^[a-z][a-z0-9-]{0,63}$/.test(name) && /^[0-9A-Z.-]+$/i.test(effectiveVersion) && !effectiveVersion.includes("..")
|
|
754
|
-
? `${name}-${effectiveVersion}.tgz`
|
|
755
|
-
: "upload.tgz";
|
|
756
|
-
m.archive = { file: namedFile, sha256: sha256Of(env)(bytes), size: bytes.byteLength };
|
|
757
|
-
// detached 侧车签名注入(P2-3,G4 补裁):同目录 .sig 随 multipart 第二文件上传——
|
|
758
|
-
// 与 Form B archive.signature 同结构注入,verify 段统一验签(六码分流)
|
|
435
|
+
// Form A 补摘要 + 正名 + 侧车注入:local-archive.ts 共享管线(bundle 收敛同用;
|
|
436
|
+
// C2-29/P2-11/P2-3 口径注释全在该处,此处不重复)
|
|
759
437
|
const sig = await uploadSig();
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
version = reqVersion ?? mVersion;
|
|
438
|
+
const local = await localArchiveManifestOf({ stagingDir: env.staging, exec: execOf(env), sha256Hex: sha256Of(env) }, { name, reqVersion, bytes, sig });
|
|
439
|
+
manifest = local.manifest;
|
|
440
|
+
version = local.version;
|
|
764
441
|
archiveUrl = `${env.staging}/upload.tgz`;
|
|
765
442
|
fetch = async () => ({ ok: true, status: 200, arrayBuffer: async () => bytes.slice().buffer as ArrayBuffer });
|
|
766
443
|
}
|
|
@@ -770,7 +447,7 @@ async function installBody(env: MarketEnv): Promise<{ task_id: string }> {
|
|
|
770
447
|
fail(503, "未配置市场地址(framework.market_cdn_url)——市场安装不可用,请先在系统设置配置 CDN 基址");
|
|
771
448
|
// REG-CS6:安装全程(相对基址补全 + 冒烟探针)都需 canonical origin;缺失/非法即明确报错
|
|
772
449
|
await requireCanonicalOrigin(env);
|
|
773
|
-
const r = await
|
|
450
|
+
const r = await resolveFromDb(base, name, reqVersion);
|
|
774
451
|
manifest = r.manifest;
|
|
775
452
|
version = r.version;
|
|
776
453
|
archiveUrl = r.archiveUrl;
|
|
@@ -838,6 +515,76 @@ async function installBody(env: MarketEnv): Promise<{ task_id: string }> {
|
|
|
838
515
|
}
|
|
839
516
|
}
|
|
840
517
|
|
|
518
|
+
/**
|
|
519
|
+
* 随站 bundle 收敛执行(Phase B T3b):读 cdn/bundles.json 意图标记,按上拉不下拉
|
|
520
|
+
* 规则对齐第一方包(纯编排在 _platform bundle-converge.ts)。本地归档安装与
|
|
521
|
+
* installBody 同纪律(Form A 补摘要共享管线 + runInstall 组装);失败一律吞咽——
|
|
522
|
+
* 收敛是增强面,永不阻断消费端(方案 §5「失败降级不阻断」)。
|
|
523
|
+
*/
|
|
524
|
+
async function runBundleConverge(env: MarketEnv): Promise<void> {
|
|
525
|
+
try {
|
|
526
|
+
const cdnDir = env.cdnDir ?? "cdn";
|
|
527
|
+
const r = await convergeBundledSolutions({
|
|
528
|
+
markerPath: `${cdnDir}/bundles.json`,
|
|
529
|
+
cdnDir,
|
|
530
|
+
statePath: env.statePath,
|
|
531
|
+
installLocal: (entry, archive) => installBundledArchive(env, entry, archive),
|
|
532
|
+
// 失败冷却(终审 Important-2):持久失败(如 BOOTSTRAP_KEY 未回填期的 needs_trust、
|
|
533
|
+
// 包被篡改)不在每次 index 请求全量重装——10 分钟窗内跳过,窗外自愈重试
|
|
534
|
+
cooldownPath: `${env.taskDir}/.bundle-converge-cooldown.json`,
|
|
535
|
+
});
|
|
536
|
+
// 失败观测(终审 Important-2):冷却窗内每请求重试一次记一条审计,超窗后安静——
|
|
537
|
+
// 连续失败有痕可查,不刷爆 market_history
|
|
538
|
+
for (const failed of r.failed) {
|
|
539
|
+
const m = failed.match(/^([a-z][a-z0-9-]{0,63})@([^(]+)(/);
|
|
540
|
+
if (m)
|
|
541
|
+
await recordHistory({ solution: m[1]!, version: m[2]!, action: "install", status: "failed", operator: "bundle-converge", message: `随站 bundle 收敛失败(10 分钟后自愈重试):${failed}` });
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
// marker 损坏/状态不可读等静默——下次对账再试
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** 本地归档安装:manifest 补全 → bootstrap 播种 → runInstall(operator=机器身份留痕) */
|
|
550
|
+
async function installBundledArchive(env: MarketEnv, entry: BundledEntry, archive: { bytes: Uint8Array, sig: unknown | null }): Promise<void> {
|
|
551
|
+
const { manifest, version } = await localArchiveManifestOf(
|
|
552
|
+
{ stagingDir: env.staging, exec: execOf(env), sha256Hex: sha256Of(env) },
|
|
553
|
+
{ name: entry.name, reqVersion: entry.version, bytes: archive.bytes, sig: archive.sig },
|
|
554
|
+
);
|
|
555
|
+
await seedBootstrapKey(env.trustPath, env.bootstrapKey !== undefined ? env.bootstrapKey : BOOTSTRAP_KEY);
|
|
556
|
+
const bytes = archive.bytes;
|
|
557
|
+
await runInstall({
|
|
558
|
+
manifest,
|
|
559
|
+
name: entry.name,
|
|
560
|
+
version,
|
|
561
|
+
operator: "bundle-converge",
|
|
562
|
+
state: await createStateStore(env.statePath),
|
|
563
|
+
archiveUrl: `${env.staging}/bundle-${entry.name}.tgz`,
|
|
564
|
+
sharedRoot: env.sharedRoot,
|
|
565
|
+
staging: env.staging,
|
|
566
|
+
taskDir: env.taskDir,
|
|
567
|
+
dbPath: env.dbPath,
|
|
568
|
+
siteModulesDir: env.siteModulesDir,
|
|
569
|
+
modulesJsonPath: env.modulesJsonPath,
|
|
570
|
+
apiPrefix: env.apiPrefix,
|
|
571
|
+
configPath: env.configPath,
|
|
572
|
+
fetch: async () => ({ ok: true, status: 200, arrayBuffer: async () => bytes.slice().buffer as ArrayBuffer }),
|
|
573
|
+
exec: execOf(env),
|
|
574
|
+
reload: reloadOf(env),
|
|
575
|
+
sha384B64: sha384Of(env),
|
|
576
|
+
sha256Hex: env.sha256Hex,
|
|
577
|
+
verifyEd25519: env.verifyEd25519,
|
|
578
|
+
ledgerHead: env.ledgerHeadOf ? await env.ledgerHeadOf(entry.name) : (env.ledgerHead ?? null),
|
|
579
|
+
hostVersions: await hostVersionsOf(env),
|
|
580
|
+
hostApiVersion: API_VERSION,
|
|
581
|
+
capabilities: capabilitiesOf(),
|
|
582
|
+
trustPath: env.trustPath,
|
|
583
|
+
force: false,
|
|
584
|
+
registryId: env.registryId,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
841
588
|
interface IndexRow {
|
|
842
589
|
name: string
|
|
843
590
|
latest: string | null
|
|
@@ -866,143 +613,102 @@ interface IndexRow {
|
|
|
866
613
|
operator: string | null
|
|
867
614
|
}
|
|
868
615
|
|
|
869
|
-
/**
|
|
870
|
-
|
|
616
|
+
/**
|
|
617
|
+
* 一页产品的最近一次安装操作人(批量版,minor M6:一次 op:"in" 查询替代逐产品串行——
|
|
618
|
+
* 倒序下每产品首行即最新;表不存在/查询失败 → 空 Map,观测面不阻断索引,全员 null)。
|
|
619
|
+
*/
|
|
620
|
+
async function lastInstallOperatorsOf(names: string[]): Promise<Map<string, string | null>> {
|
|
621
|
+
const map = new Map<string, string | null>();
|
|
622
|
+
if (names.length === 0)
|
|
623
|
+
return map;
|
|
871
624
|
try {
|
|
872
625
|
const rows = await db.table("market_history")
|
|
873
|
-
.select(["operator"])
|
|
874
|
-
.where({ field: "solution", op: "eq", value: name })
|
|
626
|
+
.select(["solution", "operator"])
|
|
875
627
|
.where({ field: "action", op: "eq", value: "install" })
|
|
628
|
+
.where({ field: "solution", op: "in", value: names })
|
|
876
629
|
.orderBy([{ field: "created_at", dir: "desc" }, { field: "rowid", dir: "desc" }])
|
|
877
|
-
.
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
630
|
+
.all() as Array<{ solution: unknown, operator: unknown }>;
|
|
631
|
+
for (const r of rows) {
|
|
632
|
+
const name = String(r.solution);
|
|
633
|
+
if (!map.has(name))
|
|
634
|
+
map.set(name, typeof r.operator === "string" ? r.operator : null);
|
|
635
|
+
}
|
|
881
636
|
}
|
|
882
637
|
catch {
|
|
883
|
-
|
|
638
|
+
// 观测面失败不阻断索引(与单查版同口径)
|
|
884
639
|
}
|
|
640
|
+
return map;
|
|
885
641
|
}
|
|
886
642
|
|
|
887
643
|
/**
|
|
888
|
-
* GET index
|
|
889
|
-
*
|
|
890
|
-
*
|
|
891
|
-
*
|
|
892
|
-
*
|
|
893
|
-
*
|
|
894
|
-
*
|
|
644
|
+
* GET index 主体(v4 §四:DB 目录分页数据源)——market_release 行按 product 聚合 ⊕ 本地
|
|
645
|
+
* sidecar state join(installed/enabled/trust_state/key_id 是站点本地语义,目录 API 不带)。
|
|
646
|
+
* 行字段口径不变(展示字段不变,卡片 UI 加翻页):latest = 未 yanked semver max(API 计算,
|
|
647
|
+
* 非存储列);changelog/publisher/summary/description/license 取 latest 版 manifest 列
|
|
648
|
+
* (manifestSummaryOf JSON 解析,缺字段 → null);publisher_key = latest 行 key_id(发布者
|
|
649
|
+
* 身份单一真相);available = 未 yanked 版本升序全集(回滚候选)。规模小 → 全量行 JS 聚合 +
|
|
650
|
+
* 内存分页(catalog 同口径);本地独有方案(DB 无行)殿后。对账即服务(P1-10 接线③)不变。
|
|
895
651
|
*/
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
shardDocs: Map<number, ShardSolution[]>
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
/**
|
|
905
|
-
* 读整份签名索引(catalog + 它指向的全部分片)。
|
|
906
|
-
*
|
|
907
|
-
* 市场卡片(indexBody)与发布历史(syncReleases)都要「全量版本视图」,读取与验签只有这一份:
|
|
908
|
-
* 两处各写一遍 `fetchIndex` 循环,迟早漂移成「卡片看得见、历史看不见」。
|
|
909
|
-
*/
|
|
910
|
-
async function readIndexSnapshot(env: MarketEnv): Promise<IndexSnapshot> {
|
|
911
|
-
const base = await cdnBaseOf(env);
|
|
912
|
-
if (base === null)
|
|
913
|
-
fail(503, "未配置市场地址(framework.market_cdn_url)——卡片区显示「未配置市场地址」(PRD §5.3)");
|
|
914
|
-
await assertOriginForRelativeBase(env, base);
|
|
915
|
-
const source = await indexSourceOf(env, base);
|
|
916
|
-
const catalog = await fetchIndex(env, source, base, "catalog");
|
|
917
|
-
const times = await indexTimesOfText(catalog.text);
|
|
918
|
-
const { pins, solutions } = await parseCatalog(catalog.text, base);
|
|
919
|
-
// 拉取 catalog 指向的全部分片(去重、升序确定性);每片都过签名 + 钉值同代校验
|
|
920
|
-
const shardDocs = new Map<number, ShardSolution[]>();
|
|
921
|
-
for (const no of [...pins.keys()].sort((a, b) => a - b)) {
|
|
922
|
-
const shard = await fetchIndex(env, source, base, "shard", no, pins.get(no)!);
|
|
923
|
-
shardDocs.set(no, await parseShard(shard.text, `shards/${no}.yaml`));
|
|
924
|
-
}
|
|
925
|
-
return { base, issuedAt: times?.issued_at ?? null, solutions, shardDocs };
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
async function indexBody(env: MarketEnv): Promise<IndexRow[]> {
|
|
652
|
+
async function indexBody(env: MarketEnv, q: Record<string, unknown>): Promise<{ list: IndexRow[], total: number, page: number, size: number }> {
|
|
653
|
+
// 随站 bundle 收敛(Phase B T3b,「对账即服务」同源):首装/升级 riding 首个 index 请求——
|
|
654
|
+
// oj 启动段 ext_boot 有 2s 看门狗(oj-bin runtime.rs BOOT_TIMEOUT),安装全程不可在 boot
|
|
655
|
+
// 内跑;此处幂等(已对齐即全 skip),失败吞咽不阻断索引(方案 §5 失败降级)
|
|
656
|
+
await runBundleConverge(env);
|
|
929
657
|
// 对账即服务(P1-10 接线③):市场页刷新即对账时点——锁 ↔ 账本 head 自愈 + enabled 不一致
|
|
930
658
|
// 自动修复先行(失败只记日志,不阻断索引),失败站点的市场页每次打开都在尝试自愈
|
|
931
659
|
await reconcileOf(env);
|
|
932
|
-
const {
|
|
660
|
+
const { page, size, offset } = pageArgs({ page: q.page, size: q.size });
|
|
661
|
+
const rows = await db.table("market_release")
|
|
662
|
+
.select(["product", "version", "key_id", "yanked", "manifest"])
|
|
663
|
+
.all() as Array<Record<string, unknown>>;
|
|
664
|
+
const byProduct = new Map<string, Array<Record<string, unknown>>>();
|
|
665
|
+
for (const r of rows) {
|
|
666
|
+
const product = String(r.product);
|
|
667
|
+
const list = byProduct.get(product) ?? [];
|
|
668
|
+
list.push(r);
|
|
669
|
+
byProduct.set(product, list);
|
|
670
|
+
}
|
|
933
671
|
const local = (await createStateStore(env.statePath)).read().solutions;
|
|
934
|
-
|
|
935
|
-
const
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
if (sol === undefined)
|
|
947
|
-
continue;
|
|
948
|
-
if (latest === null)
|
|
949
|
-
latest = sol.latest;
|
|
950
|
-
merged.push(...sol.versions);
|
|
951
|
-
}
|
|
672
|
+
// 本地独有(DB 无行)也列出:目录表空 ≠ 本地没装(回填前旧世界)
|
|
673
|
+
for (const name of Object.keys(local)) {
|
|
674
|
+
if (!byProduct.has(name))
|
|
675
|
+
byProduct.set(name, []);
|
|
676
|
+
}
|
|
677
|
+
const names = [...byProduct.keys()].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
|
|
678
|
+
const total = names.length;
|
|
679
|
+
// operator 批量预取(一次 in 查询;缺产品 → null,与逐行单查口径一致)
|
|
680
|
+
const operators = await lastInstallOperatorsOf(names.slice(offset, offset + size));
|
|
681
|
+
const list: IndexRow[] = [];
|
|
682
|
+
for (const name of names.slice(offset, offset + size)) {
|
|
683
|
+
const vers = byProduct.get(name)!;
|
|
952
684
|
const side = local[name];
|
|
953
|
-
|
|
954
|
-
const
|
|
955
|
-
const
|
|
956
|
-
|
|
957
|
-
const m =
|
|
958
|
-
|
|
959
|
-
// manifest.author 是自报字段,二者不同。缺签名/畸形 → null(不拿自报值冒充)。
|
|
960
|
-
const rawArchive: unknown = m?.archive;
|
|
961
|
-
const rawSig: unknown = isObj(rawArchive) ? rawArchive.signature : undefined;
|
|
962
|
-
const publisherKey = isObj(rawSig) && typeof rawSig.key_id === "string" ? rawSig.key_id : null;
|
|
963
|
-
rows.push({
|
|
685
|
+
const live = vers.filter(r => Number(r.yanked) !== 1).map(r => String(r.version)).sort(compareVersions);
|
|
686
|
+
const latest = live.length === 0 ? null : live[live.length - 1]!;
|
|
687
|
+
const latestRow = latest === null ? undefined : vers.find(r => String(r.version) === latest);
|
|
688
|
+
const installedRow = side === undefined ? undefined : vers.find(r => String(r.version) === side.version);
|
|
689
|
+
const m = manifestSummaryOf(latestRow) ?? manifestSummaryOf(installedRow);
|
|
690
|
+
list.push({
|
|
964
691
|
name,
|
|
965
692
|
latest,
|
|
966
693
|
installed: side?.version ?? null,
|
|
967
694
|
enabled: side?.enabled ?? null,
|
|
968
695
|
upgradable: latest !== null && side !== undefined && compareVersions(side.version, latest) < 0,
|
|
969
|
-
available,
|
|
696
|
+
available: live,
|
|
970
697
|
changelog: m?.changelog ?? null,
|
|
971
698
|
publisher: typeof m?.author === "string" ? m.author : null,
|
|
972
|
-
publisher_key:
|
|
699
|
+
publisher_key: latestRow?.key_id != null ? String(latestRow.key_id) : null,
|
|
973
700
|
license: typeof m?.license === "string" ? m.license : null,
|
|
974
701
|
summary: typeof m?.summary === "string" ? m.summary : null,
|
|
975
702
|
description: typeof m?.description === "string" ? m.description : null,
|
|
976
703
|
trust_state: side?.trust_state ?? null,
|
|
977
704
|
key_id: side?.archive?.key_id ?? null,
|
|
978
|
-
operator:
|
|
705
|
+
operator: operators.get(name) ?? null,
|
|
979
706
|
});
|
|
980
707
|
}
|
|
981
|
-
|
|
982
|
-
if (!seen.has(name)) {
|
|
983
|
-
rows.push({
|
|
984
|
-
name,
|
|
985
|
-
latest: null,
|
|
986
|
-
installed: side.version,
|
|
987
|
-
enabled: side.enabled,
|
|
988
|
-
upgradable: false,
|
|
989
|
-
available: [],
|
|
990
|
-
changelog: null,
|
|
991
|
-
publisher: null,
|
|
992
|
-
publisher_key: null,
|
|
993
|
-
license: null,
|
|
994
|
-
summary: null,
|
|
995
|
-
description: null,
|
|
996
|
-
trust_state: side.trust_state ?? null,
|
|
997
|
-
key_id: side.archive?.key_id ?? null,
|
|
998
|
-
operator: await lastInstallOperatorOf(name),
|
|
999
|
-
});
|
|
1000
|
-
}
|
|
1001
|
-
}
|
|
1002
|
-
return rows;
|
|
708
|
+
return { list, total, page, size };
|
|
1003
709
|
}
|
|
1004
710
|
|
|
1005
|
-
/**
|
|
711
|
+
/** 发布历史行(market_release 上传写入行;v4 §四:数据源换 DB 直读,索引 syncReleases 退役) */
|
|
1006
712
|
interface ReleaseRow {
|
|
1007
713
|
id: number
|
|
1008
714
|
product: string
|
|
@@ -1011,101 +717,193 @@ interface ReleaseRow {
|
|
|
1011
717
|
sha256: string | null
|
|
1012
718
|
size: number | null
|
|
1013
719
|
published_at: number
|
|
1014
|
-
/** 时间来源:file = tgz
|
|
1015
|
-
published_at_source: "file" | "
|
|
720
|
+
/** 时间来源:file = 回填扫描读 tgz mtime;upload = 上传端点接收时刻(DB 目录化后唯一真源口径) */
|
|
721
|
+
published_at_source: "file" | "upload"
|
|
1016
722
|
yanked: number
|
|
1017
723
|
}
|
|
1018
724
|
|
|
725
|
+
// ── v4 §三:目录 JSON API(匿名可读,DB 直读合成单协议 shape)────────────────────────────
|
|
726
|
+
// 市场目录真源 = market_release 表(上传端点直写);catalog 清单聚合 + detail 单包详情
|
|
727
|
+
// 喂同一 Form B 条目 shape(与历史 shards 条目同构),远端市场走 HTTP、本站直读 DB——
|
|
728
|
+
// 「单协议」指 shape 统一,非传输统一。Cache-Control: no-store(防中间代理缓存陈旧目录)。
|
|
729
|
+
// 错误文案卫生:匿名读端点的 message 不得含路径/SQL/内部拓扑。
|
|
730
|
+
|
|
1019
731
|
/**
|
|
1020
|
-
*
|
|
1021
|
-
*
|
|
1022
|
-
*
|
|
1023
|
-
*
|
|
1024
|
-
* 与 `build-index` 在推荐流程里紧邻执行,两者差值只有重建索引的耗时。
|
|
1025
|
-
*
|
|
1026
|
-
* 只对**首次入库**的版本调一次(已入库的 published_at 不再改写)——历史一旦记录就是事实,
|
|
1027
|
-
* 不该被后续重新发布/重新建索引刷新。
|
|
732
|
+
* 组表 Form B 版本条目:manifest 列(上传端点抽取物)为底,archive 由服务端组表——
|
|
733
|
+
* 存量 manifest 内嵌 archive.file 不可信(防旧行/直写行夹带路径穿越),逐字正名
|
|
734
|
+
* <name>-<version>.tgz(P2-11 索引投毒加固同源);签名 key_id 以行 key_id 为准
|
|
735
|
+
* (发布者身份单一真相;加密学校验在 install verify 段用 public_key + canonical)。
|
|
1028
736
|
*/
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
if (Number.isFinite(at) && at > 0)
|
|
1041
|
-
return { at, source: "file" };
|
|
737
|
+
function formBEntryOf(product: string, row: Record<string, unknown>): Record<string, unknown> {
|
|
738
|
+
const file = `${product}-${String(row.version)}.tgz`;
|
|
739
|
+
let stored: Record<string, unknown> | null = null;
|
|
740
|
+
if (typeof row.manifest === "string" && row.manifest !== "") {
|
|
741
|
+
try {
|
|
742
|
+
const parsed: unknown = JSON.parse(row.manifest);
|
|
743
|
+
if (isObj(parsed))
|
|
744
|
+
stored = parsed;
|
|
745
|
+
}
|
|
746
|
+
catch {
|
|
747
|
+
stored = null; // 坏行不猜:回退最小形态(观测面降级,不整页 500)
|
|
1042
748
|
}
|
|
1043
749
|
}
|
|
1044
|
-
|
|
1045
|
-
|
|
750
|
+
const base: Record<string, unknown> = stored ?? { name: product, version: String(row.version) };
|
|
751
|
+
// archive 服务端组表(base 底展开后整体覆盖):存量 manifest 内嵌 archive.file 是包作者
|
|
752
|
+
// 自报字段不可信(可能 stale/穿越形态)——file 逐字正名 <name>-<version>.tgz(构造即校验,
|
|
753
|
+
// P2-11 索引投毒加固的组表处承继);sha256/size/签名取行列(上传端点服务端重算值)。
|
|
754
|
+
const archive: Record<string, unknown> = { file };
|
|
755
|
+
if (typeof row.sha256 === "string")
|
|
756
|
+
archive.sha256 = row.sha256;
|
|
757
|
+
if (typeof row.size === "number")
|
|
758
|
+
archive.size = row.size;
|
|
759
|
+
if (typeof row.sig_alg === "string" && row.sig_alg !== "")
|
|
760
|
+
archive.signature = { alg: row.sig_alg, key_id: row.key_id, public_key: row.sig_public_key, value: row.sig_value, canon: "v2", registry_id: typeof row.sig_registry_id === "string" ? row.sig_registry_id : "" };
|
|
761
|
+
return { ...base, name: product, version: String(row.version), yanked: Number(row.yanked) === 1, archive };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** market_release 行 → manifest 摘要对象(catalog 展示字段源;NULL/坏行 → null 降级) */
|
|
765
|
+
function manifestSummaryOf(row: Record<string, unknown> | undefined): Record<string, unknown> | null {
|
|
766
|
+
if (row === undefined)
|
|
767
|
+
return null;
|
|
768
|
+
if (typeof row.manifest !== "string" || row.manifest === "")
|
|
769
|
+
return null;
|
|
770
|
+
try {
|
|
771
|
+
const parsed: unknown = JSON.parse(row.manifest);
|
|
772
|
+
return isObj(parsed) ? parsed : null;
|
|
773
|
+
}
|
|
774
|
+
catch {
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
1046
777
|
}
|
|
1047
778
|
|
|
1048
779
|
/**
|
|
1049
|
-
*
|
|
1050
|
-
*
|
|
1051
|
-
*
|
|
1052
|
-
*
|
|
1053
|
-
*
|
|
1054
|
-
*
|
|
1055
|
-
* 索引拉取/验签失败由调用方兜住(历史是观测面,不阻断页面:返回已入库行 + syncError)。
|
|
780
|
+
* GET /market/catalog 主体:全量行内存聚合(规模小,真分页留待规模信号)+ 内存分页。
|
|
781
|
+
* 清单不含 yanked 行(全 yanked 的 product 整项消失);latest = 未 yanked 的 semver max
|
|
782
|
+
* (API 计算,非存储列——index_latest_regressed 守卫随索引退役自然消失);排序键钉死
|
|
783
|
+
* published_at desc, product asc(产品级 published_at 取其版本最大值 = 最近发布时刻;
|
|
784
|
+
* 键唯一确定性 ⇒ 翻页不重复不丢条目);page/size 复用 pageArgs(非法收敛、size 钳 200、
|
|
785
|
+
* 默认 20)。响应 PageResult 惯例的 page/size 回显 + truncated 截断标记,items 为协议字段名。
|
|
1056
786
|
*/
|
|
1057
|
-
async function
|
|
1058
|
-
const
|
|
1059
|
-
const
|
|
1060
|
-
|
|
787
|
+
async function catalogBody(env: Pick<MarketEnv, "registryId" | "configPath">, q: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
788
|
+
const { page, size, offset } = pageArgs({ page: q.page, size: q.size });
|
|
789
|
+
const rows = await db.table("market_release")
|
|
790
|
+
.select(["product", "version", "key_id", "published_at", "yanked", "manifest"])
|
|
791
|
+
.all() as Array<Record<string, unknown>>;
|
|
792
|
+
// 聚合:product → 未 yanked 行组(清单不含 yanked 行)
|
|
793
|
+
const byProduct = new Map<string, Array<Record<string, unknown>>>();
|
|
794
|
+
for (const r of rows) {
|
|
795
|
+
if (Number(r.yanked) === 1)
|
|
796
|
+
continue;
|
|
797
|
+
const list = byProduct.get(String(r.product)) ?? [];
|
|
798
|
+
list.push(r);
|
|
799
|
+
byProduct.set(String(r.product), list);
|
|
800
|
+
}
|
|
801
|
+
const items = [...byProduct.entries()].map(([product, list]) => {
|
|
802
|
+
const bySemver = [...list].sort((a, b) => compareVersions(String(a.version), String(b.version)));
|
|
803
|
+
const latestRow = bySemver[bySemver.length - 1]!;
|
|
804
|
+
const summary = manifestSummaryOf(latestRow);
|
|
805
|
+
return {
|
|
806
|
+
product,
|
|
807
|
+
latest: String(latestRow.version),
|
|
808
|
+
publisher: typeof latestRow.key_id === "string" ? latestRow.key_id : null,
|
|
809
|
+
versions_count: list.length,
|
|
810
|
+
...(typeof summary?.summary === "string" ? { summary: summary.summary } : {}),
|
|
811
|
+
_at: Math.max(...list.map(r => Number(r.published_at))),
|
|
812
|
+
};
|
|
813
|
+
});
|
|
814
|
+
items.sort((a, b) => (b._at - a._at) || (a.product < b.product ? -1 : a.product > b.product ? 1 : 0));
|
|
815
|
+
const total = items.length;
|
|
816
|
+
const slice = items.slice(offset, offset + size).map(({ _at: _ignoredAt, ...item }) => {
|
|
817
|
+
void _ignoredAt;
|
|
818
|
+
return item;
|
|
819
|
+
});
|
|
820
|
+
(json as { header: (k: string, v: string) => void }).header("Cache-Control", "no-store");
|
|
821
|
+
// registry_id(签名推送 canonical 首段 = 站点身份)随目录暴露——CLI 推送前发现面;
|
|
822
|
+
// 解析口径与上传/yank 端点验面同源(注入优先,回落 config vars.registry_id)
|
|
823
|
+
const registryId = env.registryId ?? await readSiteVar(env.configPath ?? "config.yaml", "registry_id");
|
|
824
|
+
return { api_version: CATALOG_API_VERSION, registry_id: registryId, page, size, total, truncated: offset + slice.length < total, items: slice };
|
|
825
|
+
}
|
|
1061
826
|
|
|
1062
|
-
|
|
1063
|
-
|
|
827
|
+
/**
|
|
828
|
+
* GET /market/catalog/detail 主体:按 name 取全版本(详情**保留 yanked 条目**——安装侧
|
|
829
|
+
* 「精确版本拒装 409 人话」依赖条目可见 + yanked 标记;清单摘除/详情保留分离保全两侧
|
|
830
|
+
* 语义)。versions semver 降序;latest = 未 yanked semver max(全 yanked → null);
|
|
831
|
+
* ?version= 精确过滤(无匹配 → 空数组,200;404 只对未知 name)。
|
|
832
|
+
*/
|
|
833
|
+
async function detailBody(q: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
834
|
+
const name = String(q.name ?? "").trim();
|
|
835
|
+
if (name === "")
|
|
836
|
+
fail(400, "name is required");
|
|
837
|
+
const rows = await db.table("market_release")
|
|
838
|
+
.select(["product", "version", "key_id", "sha256", "size", "yanked", "sig_alg", "sig_value", "sig_public_key", "sig_registry_id", "manifest"])
|
|
839
|
+
.where({ field: "product", op: "eq", value: name })
|
|
840
|
+
.all() as Array<Record<string, unknown>>;
|
|
841
|
+
if (rows.length === 0)
|
|
842
|
+
fail(404, `市场目录中无方案「${name}」`);
|
|
843
|
+
const versionFilter = q.version == null || q.version === "" ? null : String(q.version);
|
|
844
|
+
const versions = rows
|
|
845
|
+
.filter(r => versionFilter === null || String(r.version) === versionFilter)
|
|
846
|
+
.map(r => formBEntryOf(name, r))
|
|
847
|
+
.sort((a, b) => compareVersions(String(b.version), String(a.version)));
|
|
848
|
+
const live = versions.filter(v => v.yanked !== true).map(v => String(v.version)).sort(compareVersions);
|
|
849
|
+
(json as { header: (k: string, v: string) => void }).header("Cache-Control", "no-store");
|
|
850
|
+
return { product: name, latest: live.length === 0 ? null : live[live.length - 1]!, versions };
|
|
851
|
+
}
|
|
1064
852
|
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
}
|
|
853
|
+
// ── v4 §三:yank 端点(签名下线/恢复,目录行级)───────────────────────────────────────────
|
|
854
|
+
// POST /market/yank:body { product, version, reason?, by?, restore? } + X-OJM 头签名。
|
|
855
|
+
// canonical `market-yank:<registry_id>:<product>:<version>`(无内容哈希——重放退化为幂等,
|
|
856
|
+
// 同上传端点推理);授权 = key_id 须匹配行 key_id(发布者本人;不符 403,遗留 NULL 行
|
|
857
|
+
// 走 superadmin 管理通道);版本不存在 404;重复 yank/unyank 幂等 200。yank 不动已装方案
|
|
858
|
+
// (G5 口径)——仅目录摘除 + 阻断新装/升级(安装解析的 yanked 精确版本拒装 409 人话,
|
|
859
|
+
// §四)。审计 by/reason 落 market_history(operator = key_id)。
|
|
860
|
+
const YANK_PRODUCT_RE = /^[a-z][a-z0-9-]{0,63}$/; // 与 cdn 上传白名单同源口径(C2-01)
|
|
861
|
+
const YANK_VERSION_RE = /^\d[0-9A-Z.+-]{0,63}$/i;
|
|
862
|
+
|
|
863
|
+
async function yankBody(env: MarketEnv): Promise<Record<string, unknown>> {
|
|
864
|
+
const body = isObj(http.body) ? http.body : {};
|
|
865
|
+
const product = String(body.product ?? "").trim();
|
|
866
|
+
const version = String(body.version ?? "").trim();
|
|
867
|
+
if (!YANK_PRODUCT_RE.test(product) || !YANK_VERSION_RE.test(version))
|
|
868
|
+
fail(400, `product/version 非法:「${product}@${version}」——product ^[a-z][a-z0-9-]{0,63}$、版本数字开头`);
|
|
869
|
+
const restore = body.restore === true;
|
|
870
|
+
const reason = typeof body.reason === "string" ? body.reason.slice(0, 200) : "";
|
|
871
|
+
const by = typeof body.by === "string" ? body.by.slice(0, 64) : "";
|
|
872
|
+
// 鉴权(与上传端点共享验面:头 → 信任库 → 限速 → registry_id)+ canonical 验签
|
|
873
|
+
const { keyId, publicKey, registryId, signature } = await authenticateSignedKey({ trustPath: env.trustPath, registryId: env.registryId, configPath: env.configPath, verifyEd25519: env.verifyEd25519 }, "market-yank");
|
|
874
|
+
const canonical = `market-yank:${registryId}:${product}:${version}`;
|
|
875
|
+
if (!verifyCanonicalSig({ verifyEd25519: env.verifyEd25519 }, publicKey, canonical, signature))
|
|
876
|
+
fail(401, "yank 签名验签失败:canonical 不符(字节级重放防护由内容寻址承载,yank 重放退化为幂等)——已拒");
|
|
877
|
+
const rows = await db.table("market_release")
|
|
878
|
+
.select(["id", "key_id", "yanked"])
|
|
879
|
+
.where({ field: "product", op: "eq", value: product })
|
|
880
|
+
.where({ field: "version", op: "eq", value: version })
|
|
881
|
+
.limit(1)
|
|
882
|
+
.all() as Array<{ id: unknown, key_id: unknown, yanked: unknown }>;
|
|
883
|
+
if (rows.length === 0)
|
|
884
|
+
fail(404, `市场目录中无「${product}@${version}」——无法下线/恢复不存在的版本`);
|
|
885
|
+
const row = rows[0]!;
|
|
886
|
+
const auditMsg = `by=${by === "" ? keyId : by}${reason === "" ? "" : ` reason=${reason}`}`;
|
|
887
|
+
if (row.key_id == null || row.key_id === "") {
|
|
888
|
+
await recordHistory({ solution: product, version, action: restore ? "unyank" : "yank", status: "failed", operator: keyId, message: `403 该行无发布者(遗留行):${auditMsg}` });
|
|
889
|
+
fail(403, `「${product}@${version}」是遗留目录行(无发布者绑定)——yank/恢复请走 superadmin 管理通道`);
|
|
890
|
+
}
|
|
891
|
+
if (row.key_id !== keyId) {
|
|
892
|
+
await recordHistory({ solution: product, version, action: restore ? "unyank" : "yank", status: "failed", operator: keyId, message: `403 非发布者(行 key_id=${String(row.key_id)}):${auditMsg}` });
|
|
893
|
+
fail(403, `key「${keyId}」不是「${product}@${version}」的发布者(发布 key = ${String(row.key_id)})——仅发布者本人可下线/恢复,换主 = 站点 superadmin 操作`);
|
|
1102
894
|
}
|
|
895
|
+
const target = restore ? 0 : 1;
|
|
896
|
+
if (Number(row.yanked) === target)
|
|
897
|
+
return { ok: true, product, version, yanked: target === 1, idempotent: true };
|
|
898
|
+
await db.table("market_release").update({ yanked: target }).where({ field: "id", op: "eq", value: row.id }).run();
|
|
899
|
+
await recordHistory({ solution: product, version, action: restore ? "unyank" : "yank", status: "success", operator: keyId, message: auditMsg });
|
|
900
|
+
return { ok: true, product, version, yanked: target === 1 };
|
|
1103
901
|
}
|
|
1104
902
|
|
|
1105
903
|
/** 四端点组(oj v0.1.64 嵌套组:组名 = 路径段);全业务体经 respond 收口(status 语义不落 500) */
|
|
1106
904
|
export function createMarketApi(env: MarketEnv) {
|
|
1107
905
|
return {
|
|
1108
|
-
// POST /api/
|
|
906
|
+
// POST /api/oj-market/install —— 安装编排(superadmin,R9)
|
|
1109
907
|
"install": {
|
|
1110
908
|
async post() {
|
|
1111
909
|
await respond(async () => {
|
|
@@ -1113,7 +911,7 @@ export function createMarketApi(env: MarketEnv) {
|
|
|
1113
911
|
return await installBody(env);
|
|
1114
912
|
});
|
|
1115
913
|
},
|
|
1116
|
-
// POST /api/
|
|
914
|
+
// POST /api/oj-market/install/cancel —— 取消进行中的安装(superadmin,U5/P3-12)。
|
|
1117
915
|
// 按方案名寻址(R17 任务互斥:同名同时至多一个 running install);写取消标记文件
|
|
1118
916
|
// (isolate 按请求隔离,FS 是唯一跨请求通道),runInstall 的 cancel 闭包在段间/migrate
|
|
1119
917
|
// spawn 逐块探测命中 → h.kill + 回退装前态(P3-8 语义);终态 failed/partial + cancelled
|
|
@@ -1137,7 +935,7 @@ export function createMarketApi(env: MarketEnv) {
|
|
|
1137
935
|
},
|
|
1138
936
|
},
|
|
1139
937
|
},
|
|
1140
|
-
// GET /api/
|
|
938
|
+
// GET /api/oj-market/task?id= —— 任务轮询消费面(superadmin,§10.2 800ms 轮询)。
|
|
1141
939
|
// 简报 `task/:id` 段参数形态在模板 http 面无先例,照 contacts/detail?id= 惯例走 query(报告备案)。
|
|
1142
940
|
"task": {
|
|
1143
941
|
async get() {
|
|
@@ -1153,7 +951,7 @@ export function createMarketApi(env: MarketEnv) {
|
|
|
1153
951
|
});
|
|
1154
952
|
},
|
|
1155
953
|
},
|
|
1156
|
-
// POST /api/
|
|
954
|
+
// POST /api/oj-market/enable —— 生命周期开关(superadmin;P1-13 瘦客户端触发面)。
|
|
1157
955
|
// 端点只做门禁 + 取参 + SetEnabledInput 组装,四处同写全归 setEnabled(D12,installer.ts 同纪律);
|
|
1158
956
|
// 领域拒绝(未装/空锁/版本目录缺失)返回 {ok:false, message} 200——操作已执行、被域逻辑拒绝,
|
|
1159
957
|
// 人话在 message,CLI/市场页照 ok 位渲染。
|
|
@@ -1180,7 +978,7 @@ export function createMarketApi(env: MarketEnv) {
|
|
|
1180
978
|
});
|
|
1181
979
|
},
|
|
1182
980
|
},
|
|
1183
|
-
// GET /api/
|
|
981
|
+
// GET /api/oj-market/meta —— 登录即可({apiVersion},peerPlatform 预检;非 superadmin)
|
|
1184
982
|
"meta": {
|
|
1185
983
|
async get() {
|
|
1186
984
|
await respond(async () => {
|
|
@@ -1199,6 +997,24 @@ export function createMarketApi(env: MarketEnv) {
|
|
|
1199
997
|
// DELETE /market/trust/key?key_id= 吊销(G5:只阻断新装/升级,不动已装方案)
|
|
1200
998
|
// 端点只做门禁 + 取参 + operator 反查;读写/原子写/首次绑定/吊销语义全归 trust.ts(ISP,同 enable/uninstall 纪律)。
|
|
1201
999
|
// del 动词(oj 运行期命名,devkit §http:DELETE 的方法名是 del 不是 delete)+ query 取参(system/roles del 同款先例)。
|
|
1000
|
+
// POST /api/oj-market/backfill —— 站点侧目录回填(v4 §六,三用:回填=恢复=对账)。
|
|
1001
|
+
// superadmin 一次性触发(升级后/灾备后);扫 cdn/ 存量包 upsert market_release,
|
|
1002
|
+
// 幂等可重入;无 CLI 依赖(远端纯站点经本端点自愈目录)。语义详见 backfill.ts。
|
|
1003
|
+
"backfill": {
|
|
1004
|
+
async post() {
|
|
1005
|
+
await respond(async () => {
|
|
1006
|
+
await assertSuperAdmin();
|
|
1007
|
+
return await backfillReleases({
|
|
1008
|
+
cdnDir: env.cdnDir ?? "cdn",
|
|
1009
|
+
staging: env.staging,
|
|
1010
|
+
exec: env.exec,
|
|
1011
|
+
sha256Hex: env.sha256Hex,
|
|
1012
|
+
verifyEd25519: env.verifyEd25519,
|
|
1013
|
+
now: env.now,
|
|
1014
|
+
});
|
|
1015
|
+
});
|
|
1016
|
+
},
|
|
1017
|
+
},
|
|
1202
1018
|
"trust": {
|
|
1203
1019
|
async get() {
|
|
1204
1020
|
await respond(async () => {
|
|
@@ -1253,16 +1069,43 @@ export function createMarketApi(env: MarketEnv) {
|
|
|
1253
1069
|
},
|
|
1254
1070
|
},
|
|
1255
1071
|
},
|
|
1256
|
-
// GET /api/
|
|
1072
|
+
// GET /api/oj-market/index —— 市场索引聚合(superadmin,R9)
|
|
1257
1073
|
"index": {
|
|
1258
1074
|
async get() {
|
|
1259
1075
|
await respond(async () => {
|
|
1260
1076
|
await assertSuperAdmin();
|
|
1261
|
-
return await indexBody(env);
|
|
1077
|
+
return await indexBody(env, (http.query ?? {}) as Record<string, unknown>);
|
|
1078
|
+
});
|
|
1079
|
+
},
|
|
1080
|
+
},
|
|
1081
|
+
// GET /api/oj-market/catalog —— 市场目录清单(v4 §三:**匿名可读**,分页 JSON;
|
|
1082
|
+
// DB 直读合成单协议 shape,远端市场消费同一 shape)。no-store 防中间代理缓存陈旧目录。
|
|
1083
|
+
"catalog": {
|
|
1084
|
+
async get() {
|
|
1085
|
+
await respond(async () => {
|
|
1086
|
+
return await catalogBody(env, (http.query ?? {}) as Record<string, unknown>);
|
|
1262
1087
|
});
|
|
1263
1088
|
},
|
|
1089
|
+
// GET /api/oj-market/catalog/detail?name=[&version=] —— 单包详情(匿名可读;
|
|
1090
|
+
// 保留 yanked 条目 + 服务端组表 archive 正名,安装解析数据源,§四)
|
|
1091
|
+
detail: {
|
|
1092
|
+
async get() {
|
|
1093
|
+
await respond(async () => {
|
|
1094
|
+
return await detailBody((http.query ?? {}) as Record<string, unknown>);
|
|
1095
|
+
});
|
|
1096
|
+
},
|
|
1097
|
+
},
|
|
1264
1098
|
},
|
|
1265
|
-
//
|
|
1099
|
+
// POST /api/oj-market/yank —— 签名下线/恢复(v4 §三,发布者本人;清单摘除/详情保留)。
|
|
1100
|
+
// yank 不动已装方案(G5),仅目录摘除 + 阻断新装/升级;重复操作幂等 200;legacy 行 403 指向 superadmin。
|
|
1101
|
+
"yank": {
|
|
1102
|
+
async post() {
|
|
1103
|
+
await respond(async () => {
|
|
1104
|
+
return await yankBody(env);
|
|
1105
|
+
});
|
|
1106
|
+
},
|
|
1107
|
+
},
|
|
1108
|
+
// GET /api/oj-market/uninstall-impact?name= —— 卸载影响面预检(superadmin,R9)。
|
|
1266
1109
|
// 返回前端二次确认所需的影响计数(角色数 + 受影响用户数),不改动任何状态。
|
|
1267
1110
|
"uninstall-impact": {
|
|
1268
1111
|
async get() {
|
|
@@ -1281,7 +1124,7 @@ export function createMarketApi(env: MarketEnv) {
|
|
|
1281
1124
|
});
|
|
1282
1125
|
},
|
|
1283
1126
|
},
|
|
1284
|
-
// POST /api/
|
|
1127
|
+
// POST /api/oj-market/uninstall —— 卸载编排(superadmin,R9)。
|
|
1285
1128
|
// 端点只做门禁 + 取参 + operator 反查(历史留痕),四处同删全归 runUninstall(uninstall.ts 同纪律);
|
|
1286
1129
|
// 领域拒绝(未装/空锁拒启)返回 {ok:false, message} 200——人话在 message,CLI/市场页照 ok 位渲染。
|
|
1287
1130
|
"uninstall": {
|
|
@@ -1305,12 +1148,17 @@ export function createMarketApi(env: MarketEnv) {
|
|
|
1305
1148
|
// 其余领域拒绝(空锁拒启/级联失败等)维持 200 {ok:false} 人话在 message。
|
|
1306
1149
|
if (!r.ok && r.reason === "not-installed")
|
|
1307
1150
|
fail(404, r.message);
|
|
1151
|
+
// 卸载墓碑(终审 Important-1):随站 bundle 的意图标记摘除本方案——否则
|
|
1152
|
+
// 下次 index 收敛会把这次故意卸载当漂移自动重装,「卸载后仅可经 system 域
|
|
1153
|
+
// 入口重装」落空。重装路径:system 页引导 backfill 重建目录行后手动安装。
|
|
1154
|
+
if (r.ok)
|
|
1155
|
+
await pruneBundledMarker(`${(env.cdnDir ?? "cdn")}/bundles.json`, name);
|
|
1308
1156
|
return r;
|
|
1309
1157
|
});
|
|
1310
1158
|
},
|
|
1311
1159
|
},
|
|
1312
1160
|
|
|
1313
|
-
// GET /api/
|
|
1161
|
+
// GET /api/oj-market/history —— 市场操作历史(只读消费面,供前端 HistoryDrawer)。
|
|
1314
1162
|
// 平台级审计面(P4 上收 _platform):install/enable/disable/uninstall/upgrade/rollback 留痕
|
|
1315
1163
|
// 由 _lifecycle/history.ts recordHistory 写入;本端点仅登录用户可读(非匿名名单,Bearer 守卫保护)。
|
|
1316
1164
|
// 表不存在/查询失败一律返回空数组(历史是观测面,不阻断主流程,与终态审计附录 B-5 同语义)。
|
|
@@ -1326,24 +1174,16 @@ export function createMarketApi(env: MarketEnv) {
|
|
|
1326
1174
|
},
|
|
1327
1175
|
},
|
|
1328
1176
|
|
|
1329
|
-
// GET /api/
|
|
1177
|
+
// GET /api/oj-market/releases —— 发布历史(登录即可读;与市场卡片同口径的「浏览开放」)。
|
|
1330
1178
|
//
|
|
1331
|
-
//
|
|
1332
|
-
//
|
|
1333
|
-
// 不阻断查询:返回已入库行 + syncError,页面照常显示历史并提示「暂时读不到索引」。
|
|
1179
|
+
// 数据源 = market_release 上传写入行(v4 §四 DB 目录化:索引对账 syncReleases 已退役,
|
|
1180
|
+
// 发布历史即目录表按 published_at 倒序分页直读,零索引依赖)。
|
|
1334
1181
|
"releases": {
|
|
1335
1182
|
async get() {
|
|
1336
1183
|
await respond(async () => {
|
|
1337
1184
|
const uid = (http as { user?: { id?: unknown } }).user?.id;
|
|
1338
1185
|
if (uid == null)
|
|
1339
1186
|
fail(401, "unauthorized");
|
|
1340
|
-
let syncError: string | null = null;
|
|
1341
|
-
try {
|
|
1342
|
-
await syncReleases(env);
|
|
1343
|
-
}
|
|
1344
|
-
catch (e) {
|
|
1345
|
-
syncError = e instanceof Error ? e.message : String(e);
|
|
1346
|
-
}
|
|
1347
1187
|
const q = (http.query ?? {}) as Record<string, unknown>;
|
|
1348
1188
|
const { page, size, offset } = pageArgs({ page: q.page, size: q.limit });
|
|
1349
1189
|
const total = Number(
|
|
@@ -1357,7 +1197,7 @@ export function createMarketApi(env: MarketEnv) {
|
|
|
1357
1197
|
.limit(size)
|
|
1358
1198
|
.offset(offset)
|
|
1359
1199
|
.all() as unknown as ReleaseRow[];
|
|
1360
|
-
return { list, total, page, size
|
|
1200
|
+
return { list, total, page, size };
|
|
1361
1201
|
});
|
|
1362
1202
|
},
|
|
1363
1203
|
},
|
|
@@ -1392,7 +1232,51 @@ marketApi.trust.key.post.schema = { body: { type: "object", required: ["key_id",
|
|
|
1392
1232
|
marketApi.trust.key.post.response = { type: "object", additionalProperties: true };
|
|
1393
1233
|
marketApi.trust.key.del.schema = { query: { type: "object", required: ["key_id"], properties: { key_id: { type: "string" } } } };
|
|
1394
1234
|
marketApi.trust.key.del.response = { type: "object", additionalProperties: true };
|
|
1395
|
-
marketApi.index.get.
|
|
1235
|
+
marketApi.index.get.schema = {
|
|
1236
|
+
query: {
|
|
1237
|
+
type: "object",
|
|
1238
|
+
properties: { page: { type: "number", minimum: 1 }, size: { type: "number", minimum: 1, maximum: 200 } },
|
|
1239
|
+
},
|
|
1240
|
+
};
|
|
1241
|
+
marketApi.index.get.response = {
|
|
1242
|
+
type: "object",
|
|
1243
|
+
required: ["list", "total", "page", "size"],
|
|
1244
|
+
properties: {
|
|
1245
|
+
list: { type: "array", items: { type: "object", additionalProperties: true } },
|
|
1246
|
+
total: { type: "number" },
|
|
1247
|
+
page: { type: "number" },
|
|
1248
|
+
size: { type: "number" },
|
|
1249
|
+
},
|
|
1250
|
+
};
|
|
1251
|
+
marketApi.catalog.get.schema = {
|
|
1252
|
+
query: {
|
|
1253
|
+
type: "object",
|
|
1254
|
+
properties: { page: { type: "number", minimum: 1 }, size: { type: "number", minimum: 1, maximum: 200 } },
|
|
1255
|
+
},
|
|
1256
|
+
};
|
|
1257
|
+
marketApi.catalog.get.response = { type: "object", additionalProperties: true };
|
|
1258
|
+
marketApi.catalog.detail.get.schema = {
|
|
1259
|
+
query: {
|
|
1260
|
+
type: "object",
|
|
1261
|
+
required: ["name"],
|
|
1262
|
+
properties: { name: { type: "string" }, version: { type: "string" } },
|
|
1263
|
+
},
|
|
1264
|
+
};
|
|
1265
|
+
marketApi.catalog.detail.get.response = { type: "object", additionalProperties: true };
|
|
1266
|
+
marketApi.yank.post.schema = {
|
|
1267
|
+
body: {
|
|
1268
|
+
type: "object",
|
|
1269
|
+
required: ["product", "version"],
|
|
1270
|
+
properties: {
|
|
1271
|
+
product: { type: "string" },
|
|
1272
|
+
version: { type: "string" },
|
|
1273
|
+
reason: { type: "string" },
|
|
1274
|
+
by: { type: "string" },
|
|
1275
|
+
restore: { type: "boolean" },
|
|
1276
|
+
},
|
|
1277
|
+
},
|
|
1278
|
+
};
|
|
1279
|
+
marketApi.yank.post.response = { type: "object", additionalProperties: true };
|
|
1396
1280
|
marketApi["uninstall-impact"].get.schema = { query: { type: "object", required: ["name"], properties: { name: { type: "string" } } } };
|
|
1397
1281
|
marketApi["uninstall-impact"].get.response = { type: "object", additionalProperties: true };
|
|
1398
1282
|
marketApi.uninstall.post.schema = { body: { type: "object", required: ["name"], properties: { name: { type: "string" } } } };
|
|
@@ -1415,7 +1299,6 @@ marketApi.releases.get.response = {
|
|
|
1415
1299
|
total: { type: "number" },
|
|
1416
1300
|
page: { type: "number" },
|
|
1417
1301
|
size: { type: "number" },
|
|
1418
|
-
syncError: { type: ["string", "null"] },
|
|
1419
1302
|
},
|
|
1420
1303
|
};
|
|
1421
1304
|
|