@oj-module/cli 0.2.29 → 0.2.42
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/manual.md +5 -5
- package/devkit/module-dev-and-cdn-playground.md +1 -1
- package/package.json +2 -2
- package/shell-dist/assets/{index-CnJsHOe-.js → index-FYt9CjFd.js} +50 -17
- package/shell-dist/assets/runtime.js +2 -2
- package/shell-dist/index.html +3 -3
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/cli.test.ts +23 -14
- package/src/__tests__/list.test.ts +176 -0
- package/src/__tests__/registry-cli.test.ts +522 -0
- package/src/__tests__/registry-export.test.ts +319 -0
- package/src/__tests__/registry-import.test.ts +336 -0
- package/src/__tests__/registry-sign.test.ts +352 -0
- package/src/cli-output.ts +81 -0
- package/src/cli.ts +298 -39
- package/src/config-cmd.ts +100 -0
- package/src/contract/__tests__/handler-scan.test.ts +74 -2
- package/src/contract/handler-scan.ts +27 -2
- package/src/enable.ts +1 -1
- package/src/exit-codes.ts +33 -0
- package/src/index.ts +5 -9
- package/src/install.ts +120 -38
- package/src/lifecycle/__tests__/authoring.test.ts +6 -5
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.bad.sig +3 -2
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.sig +3 -2
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.unknown.sig +3 -2
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.d.mts +5 -4
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.mjs +9 -6
- package/src/lifecycle/__tests__/helpers.ts +10 -6
- package/src/lifecycle/__tests__/install.test.ts +72 -15
- package/src/lifecycle/__tests__/manifest-contract.test.ts +40 -1
- package/src/lifecycle/__tests__/publish.test.ts +174 -33
- package/src/lifecycle/__tests__/sig-fixtures.test.ts +9 -6
- package/src/lifecycle/__tests__/sign.test.ts +9 -8
- package/src/lifecycle/__tests__/smoke.test.ts +1 -1
- package/src/lifecycle/__tests__/uninstall.test.ts +4 -4
- package/src/lifecycle/sign.ts +8 -7
- package/src/list.ts +146 -0
- package/src/manifest-contract.ts +11 -5
- package/src/market/__tests__/bootstrap.test.ts +4 -1
- package/src/market/__tests__/build-index.test.ts +529 -42
- package/src/market/__tests__/fixture.test.ts +17 -1
- package/src/market/__tests__/publish.test.ts +8 -7
- package/src/market/__tests__/setup.test.ts +4 -4
- package/src/market/__tests__/sharding.test.ts +90 -0
- package/src/market/__tests__/verify.test.ts +205 -12
- package/src/market/__tests__/yank-ledger.test.ts +60 -0
- package/src/market/bootstrap.ts +8 -3
- package/src/market/build-index.ts +407 -40
- package/src/market/cdn-layout.ts +3 -2
- package/src/market/command.ts +40 -6
- package/src/market/failure-playbook.ts +1 -1
- package/src/market/fixture.ts +20 -3
- package/src/market/index-builder.ts +120 -47
- package/src/market/publish.ts +3 -1
- package/src/market/setup.ts +3 -3
- package/src/market/sharding.ts +94 -0
- package/src/market/trial.ts +1 -1
- package/src/market/verify.ts +213 -50
- package/src/market/walkthrough.ts +7 -6
- package/src/market/yank-ledger.ts +91 -0
- package/src/publish.ts +250 -38
- package/src/registry-export.ts +405 -0
- package/src/registry-import.ts +410 -0
- package/src/registry-sidecar.ts +84 -0
- package/src/registry-sign.ts +322 -0
- package/src/registry-source.ts +192 -0
- package/src/sign.ts +13 -10
- package/src/site.ts +4 -4
- package/src/trust.ts +1 -1
- package/src/uninstall.ts +14 -2
- package/src/usage.ts +130 -25
- package/src/view.ts +94 -0
- package/templates/cdn/README.md +35 -5
- package/templates/config.yaml +10 -0
- package/templates/init.include.yaml +1 -1
- package/templates/src/_platform/api/_lifecycle/atomic.ts +3 -0
- package/templates/src/_platform/api/_lifecycle/installer.ts +44 -7
- package/templates/src/_platform/api/_lifecycle/limits.ts +21 -0
- package/templates/src/_platform/api/_lifecycle/source-seq.ts +88 -0
- package/templates/src/_platform/api/_lifecycle/steps/download.ts +15 -0
- package/templates/src/_platform/api/_lifecycle/steps/extract.ts +61 -2
- package/templates/src/_platform/api/_lifecycle/steps/preflight.ts +55 -6
- package/templates/src/_platform/api/_lifecycle/steps/register.ts +2 -1
- package/templates/src/_platform/api/_lifecycle/steps/verify.ts +22 -8
- package/templates/src/_platform/api/_lifecycle/tgz-guard.ts +96 -0
- package/templates/src/_platform/api/_lifecycle/types.ts +6 -3
- package/templates/src/_platform/api/_lifecycle/verify-checks.ts +8 -0
- package/templates/src/_platform/api/_shared/service/domain/solution-perms.ts +9 -1
- package/templates/src/_platform/api/_shared/site-facade.ts +62 -11
- package/templates/src/_platform/api/manifest.yaml +1 -0
- package/templates/src/_platform/api/market/api.ts +362 -99
- package/templates/src/_platform/api/market/index-source.ts +112 -0
- package/templates/src/_platform/api/migrations/0014__reg_source_seq.mysql.sql +9 -0
- package/templates/src/_platform/api/migrations/0014__reg_source_seq.pg.sql +7 -0
- package/templates/src/_platform/api/migrations/0014__reg_source_seq.sql +8 -0
- package/templates/src/_platform/api/schema.yaml +8 -0
- package/templates/src/_platform/api/solutions/api.ts +8 -3
- package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +13 -0
- package/templates/src/oj-market/web/__tests__/InstallDialog.test.tsx +38 -0
- package/templates/src/oj-market/web/components/CardItem.tsx +10 -0
- package/templates/src/oj-market/web/components/InstallDialog.tsx +18 -0
- package/templates/src/oj-market/web/locales/en-US.json +1 -0
- package/templates/src/oj-market/web/locales/zh-CN.json +1 -0
- package/templates/src/oj-market/web/types.ts +3 -1
- package/templates/src/system/web/entry.ts +5 -2
- package/templates/tests/lifecycle/installer.spec.ts +24 -0
- package/templates/tests/lifecycle/market-api.spec.ts +641 -85
- package/templates/tests/lifecycle/preflight.spec.ts +52 -0
- package/templates/tests/lifecycle/sig-fixtures.spec.ts +7 -4
- package/templates/tests/lifecycle/source-seq.spec.ts +55 -0
- package/templates/tests/lifecycle/steps-fs.spec.ts +166 -7
- package/templates/tests/lifecycle/tgz-guard.spec.ts +133 -1
- package/templates/tests/lifecycle/verify-checks.spec.ts +27 -13
- package/templates/tests/lifecycle/verify-report.spec.ts +3 -1
- package/templates/tests/rbac-solution-perms.spec.ts +41 -0
- package/vendor/host-versions.json +3 -3
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SourceSeqVerdict } from "../_lifecycle/source-seq";
|
|
1
2
|
// 编排端点 + 市场索引(P1-12,R9/ISP):/api/_platform/market/* 端点族同文件嵌套 handler 组
|
|
2
3
|
// (oj v0.1.64 组形态,notification/api.ts 同款;组名 = 路径段,路由不变;嵌套组最深 4 层,oj bridge/mod.rs walker)。
|
|
3
4
|
// 门(R9):install/task/index 全 assertSuperAdmin 独占(市场页是「市场即样例」的第一方特权
|
|
@@ -5,7 +6,9 @@
|
|
|
5
6
|
// 职责边界(ISP):端点只做 门禁 + Form B 索引解析(安装器不拼 URL,installer.ts 同纪律)+
|
|
6
7
|
// InstallRequest 组装;任务生命周期/失败三分/终态审计全归 runInstall 状态机(P1-10),端点不重复编排。
|
|
7
8
|
// CDN 基址:sys_setting `framework.market_cdn_url`(PRD §5.3,缺省空 = 市场不可用,可读错误不崩);
|
|
8
|
-
//
|
|
9
|
+
// 索引拉取走 IndexSource DIP(REG-CS5 三级化:catalog.yaml + shards/<n>.yaml)——读面带
|
|
10
|
+
// ETag/If-None-Match 条件请求(304 不重传),不再发 cache-control: no-cache(Q8:新鲜度由
|
|
11
|
+
// 服务端 max-age=60, stale-while-revalidate=60 + 索引自身 expires_at 共同承载)。
|
|
9
12
|
// 站点路径缺省(marketEnv):oj serve release 布局生产口径(P1-16 接线 B3/B4)——共享根 = serve
|
|
10
13
|
// 挂载根 dist(锁 dist/manifests.yaml、版本目录 dist/<name>-<ver>/、versions.json 同根),
|
|
11
14
|
// 暂存/任务/sidecar 留 dist 外(R13 不与装配树冲突);全字段可注入覆盖(测试 stub 同款)。
|
|
@@ -16,10 +19,12 @@ import type { FetchFace } from "../_lifecycle/steps/download";
|
|
|
16
19
|
import type { ExecFace } from "../_lifecycle/steps/extract";
|
|
17
20
|
import type { ReloadFace } from "../_lifecycle/steps/reload";
|
|
18
21
|
import type { FetchInitFace } from "../_shared/site-facade";
|
|
22
|
+
import type { IndexKind, IndexSource } from "./index-source";
|
|
19
23
|
import { atomicWriteText, fsFace } from "../_lifecycle/atomic";
|
|
20
24
|
import { latestAppliedSeqOf, reconcile, runInstall, setEnabled } from "../_lifecycle/installer";
|
|
21
25
|
import { compareVersions } from "../_lifecycle/semver";
|
|
22
26
|
import { sha384B64 } from "../_lifecycle/sha384";
|
|
27
|
+
import { guardAndRecordSourceSeq } from "../_lifecycle/source-seq";
|
|
23
28
|
import { createStateStore } from "../_lifecycle/state";
|
|
24
29
|
import { flattenBundleRoot } from "../_lifecycle/steps/extract";
|
|
25
30
|
import { getTask, readTasks } from "../_lifecycle/tasks";
|
|
@@ -28,8 +33,9 @@ import { createTrustStore } from "../_lifecycle/trust";
|
|
|
28
33
|
import { computeUninstallImpact, runUninstall } from "../_lifecycle/uninstall";
|
|
29
34
|
import { respond } from "../_shared/respond";
|
|
30
35
|
import { assertSuperAdmin } from "../_shared/service/infra/access";
|
|
31
|
-
import { journalHeadOf, siteOrigin, withSiteOrigin } from "../_shared/site-facade";
|
|
36
|
+
import { journalHeadOf, readCanonicalOrigin, siteOrigin, withSiteOrigin } from "../_shared/site-facade";
|
|
32
37
|
import { BOOTSTRAP_KEY, seedBootstrapKey } from "./bootstrap";
|
|
38
|
+
import { httpIndexSource, indexPathOf } from "./index-source";
|
|
33
39
|
|
|
34
40
|
// 站点编排面共享真源落 _shared/site-facade(P2-16 真机构建钉出:api.ts 是路由入口不可被模块内
|
|
35
41
|
// import);此处 re-export 保持既有引用面(market-api.spec / 历史 import 路径)不变。
|
|
@@ -60,8 +66,15 @@ export interface MarketEnv {
|
|
|
60
66
|
apiPrefix: string
|
|
61
67
|
/** 站点 config.yaml 路径(reloading 段 vars.ojm_install_seq 自增落盘处;缺省 config.yaml) */
|
|
62
68
|
configPath?: string
|
|
69
|
+
/** canonical origin(REG-CS6:相对 URL 补全用,取自 config.yaml vars.canonical_origin,绝不读请求头 Host) */
|
|
70
|
+
canonicalOrigin?: string
|
|
63
71
|
/** CDN 基址(缺省读 sys_setting `framework.market_cdn_url`;测试注入) */
|
|
64
72
|
cdnBase?: () => Promise<string | null>
|
|
73
|
+
/**
|
|
74
|
+
* 索引读取源注入(DIP,REG-CS5③):缺省按 CDN 基址 + fetchOf 构造 HTTP 源
|
|
75
|
+
* (ETag/If-None-Match 条件请求);本地目录索引 / 测试夹具可直注(localIndexSource)。
|
|
76
|
+
*/
|
|
77
|
+
indexSource?: IndexSource
|
|
65
78
|
/** 注入面(DIP):缺省回落 isolate 全局 fetch / exec.run / server.reloadConfig / 本地 sha384(过渡实现) */
|
|
66
79
|
fetch?: FetchFace
|
|
67
80
|
exec?: ExecFace
|
|
@@ -77,12 +90,16 @@ export interface MarketEnv {
|
|
|
77
90
|
ledgerHead?: number | bigint | null
|
|
78
91
|
/** 迁移账本 head 真源(P1-16 接线①:缺省读 oj journal 表 `_oj_migrations` max(version),查询失败 → null 不猜) */
|
|
79
92
|
ledgerHeadOf?: (name: string) => Promise<number | bigint | null>
|
|
93
|
+
/** 当前时刻(epoch 秒;REG-CS2 索引时效判定基准,测试注入;缺省 Date.now) */
|
|
94
|
+
now?: () => number
|
|
80
95
|
/**
|
|
81
96
|
* 宿主 versions.json(C2-09 peerRuntime 比对真源,键 = 包名)。缺省读 `<sharedRoot>/versions.json`
|
|
82
97
|
* (ojm 宿主构建产物:与 manifests.yaml 同层,即 release 根,B-11 接线);不可得 → undefined,
|
|
83
98
|
* manifest 声明 peerRuntime 即预检 fail-closed 拒装(C2-09 语义保持)。
|
|
84
99
|
*/
|
|
85
100
|
hostVersions?: Record<string, string>
|
|
101
|
+
/** 本站点(registry)身份(REG-CS3 v2 防跨仓库重放):安装验签时与 .sig.registry_id 比对 */
|
|
102
|
+
registryId?: string
|
|
86
103
|
}
|
|
87
104
|
|
|
88
105
|
/**
|
|
@@ -149,6 +166,7 @@ export function marketEnv(): MarketEnv {
|
|
|
149
166
|
siteModulesDir: "dist/web/modules",
|
|
150
167
|
modulesJsonPath: "dist/web/modules.json",
|
|
151
168
|
apiPrefix: "/api",
|
|
169
|
+
configPath: "config.yaml",
|
|
152
170
|
ledgerHeadOf: journalHeadOf,
|
|
153
171
|
};
|
|
154
172
|
}
|
|
@@ -162,10 +180,53 @@ function fail(status: number, msg: string): never {
|
|
|
162
180
|
throw Object.assign(new Error(msg), { status });
|
|
163
181
|
}
|
|
164
182
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
183
|
+
/**
|
|
184
|
+
* canonical origin 惰性解析(REG-CS6):env.canonicalOrigin 若已由测试/部署注入则直接用,
|
|
185
|
+
* 否则读 config.yaml `vars.canonical_origin`(异步,隔离环境只有异步读盘面)。解析失败/缺省 → undefined
|
|
186
|
+
* (withSiteOrigin = 不补全、相对 URL 原样透传,fail-closed 不回落请求头)。
|
|
187
|
+
*/
|
|
188
|
+
async function resolveCanonicalOrigin(env: MarketEnv): Promise<string | undefined> {
|
|
189
|
+
if (env.canonicalOrigin !== undefined) {
|
|
190
|
+
return env.canonicalOrigin;
|
|
191
|
+
}
|
|
192
|
+
if (!env.configPath) {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
return (await readCanonicalOrigin(env.configPath)) ?? undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function fetchOf(env: MarketEnv): Promise<FetchInitFace> {
|
|
199
|
+
// 相对基址(feature:/cdn = 本站点自供)按配置的 canonical origin 补全同源绝对 URL;绝对 http(s) 直通。
|
|
200
|
+
// REG-CS6:绝不读请求头 Host/x-forwarded-proto——伪造 Host 不能牵引出站。无 canonical origin 则不补全
|
|
201
|
+
// (相对 URL 原样透传,由下层 fetch 判不可用),fail-closed 而非回落请求头。
|
|
202
|
+
const co = await resolveCanonicalOrigin(env);
|
|
203
|
+
return withSiteOrigin(env.fetch ?? (globalThis as { fetch?: FetchInitFace }).fetch as FetchInitFace, co);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* REG-CS6:安装需 canonical origin——① 相对市场基址(`market_cdn_url=/cdn` 本站点自供)要补全为
|
|
208
|
+
* 同源绝对 URL;② 冒烟探针 URL 恒为站点相对路径(`/api/<模块>/<路由>`,isolate fetch 无同源 base,
|
|
209
|
+
* 相对恒 TypeError)。缺失/非法即明确报错并指向具体设置项(config.yaml `vars.canonical_origin`),
|
|
210
|
+
* 绝不回落请求头 Host(伪造 Host 会牵引出站)。
|
|
211
|
+
*/
|
|
212
|
+
async function requireCanonicalOrigin(env: MarketEnv): Promise<string> {
|
|
213
|
+
const co = await resolveCanonicalOrigin(env);
|
|
214
|
+
if (co === undefined || siteOrigin(co) === null) {
|
|
215
|
+
fail(503, "安装需要站点 canonical origin(相对市场基址「/cdn」自供需补全为同源绝对 URL,且冒烟探针 URL 恒为站点相对路径):请在 config.yaml 配置 vars.canonical_origin(站点真实对外地址,如 https://your-site.example.com)。REG-CS6 不读请求头 Host(防伪造 Host 牵引出站),缺失即拒而非回落请求头");
|
|
216
|
+
}
|
|
217
|
+
return co;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* REG-CS6:相对市场基址(`market_cdn_url=/cdn` 本站点自供)**必须**有 canonical origin 才能补全为
|
|
222
|
+
* 同源绝对 URL——绝对基址(外部真 CDN)不需要。缺失/非法即明确报错并指向具体设置项
|
|
223
|
+
* (config.yaml `vars.canonical_origin`),绝不回落请求头 Host(伪造 Host 会牵引出站)。
|
|
224
|
+
*/
|
|
225
|
+
async function assertOriginForRelativeBase(env: MarketEnv, base: string): Promise<void> {
|
|
226
|
+
if (/^https?:\/\//i.test(base)) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
await requireCanonicalOrigin(env);
|
|
169
230
|
}
|
|
170
231
|
|
|
171
232
|
function execOf(env: MarketEnv): ExecFace {
|
|
@@ -204,21 +265,13 @@ async function cdnBaseOf(env: MarketEnv): Promise<string | null> {
|
|
|
204
265
|
}
|
|
205
266
|
}
|
|
206
267
|
|
|
207
|
-
/** CDN 文本拉取(bundles.yaml/manifests.yaml):恒带 Cache-Control: no-cache(索引时效性) */
|
|
208
|
-
async function fetchText(fetch: FetchInitFace, url: string): Promise<string> {
|
|
209
|
-
const res = await fetch(url, { headers: { "cache-control": "no-cache" } });
|
|
210
|
-
if (!res.ok)
|
|
211
|
-
fail(502, `索引拉取失败:${url} 返回 HTTP ${res.status}(请检查市场地址与网络)`);
|
|
212
|
-
return new TextDecoder().decode(await res.arrayBuffer());
|
|
213
|
-
}
|
|
214
|
-
|
|
215
268
|
/** 索引签名算法标识(与 ojm publish 同源:Ed25519;C2-35) */
|
|
216
269
|
const INDEX_SIG_ALG = "Ed25519";
|
|
217
270
|
|
|
218
|
-
/** 索引规范串(
|
|
219
|
-
function indexCanonical(env: MarketEnv, kind:
|
|
271
|
+
/** 索引规范串(REG-CS1,与 ojm publish indexCanonical 逐字一致):`index:<kind>:<seq>:<sha256hex>` */
|
|
272
|
+
function indexCanonical(env: MarketEnv, kind: IndexKind, seq: number, text: string): string {
|
|
220
273
|
const sha256 = sha256Of(env)(new TextEncoder().encode(text));
|
|
221
|
-
return `index:${kind}:${sha256}`;
|
|
274
|
+
return `index:${kind}:${seq}:${sha256}`;
|
|
222
275
|
}
|
|
223
276
|
|
|
224
277
|
/** Ed25519 验签面(P2-3;env 注入优先,缺省回落 isolate 全局 crypto.verifyEd25519) */
|
|
@@ -227,46 +280,217 @@ function verifyEd25519Of(env: MarketEnv): (publicKey: string, msg: string, sigBa
|
|
|
227
280
|
return env.verifyEd25519 ?? ((pk, msg, sig) => g.crypto!.verifyEd25519!(pk, msg, sig));
|
|
228
281
|
}
|
|
229
282
|
|
|
283
|
+
/** 验签判定:ok + 代次(读方据此做同代校验;seq 为 null = 旧格式签名,一律拒) */
|
|
284
|
+
interface IndexSigVerdict {
|
|
285
|
+
ok: boolean
|
|
286
|
+
seq: number | null
|
|
287
|
+
}
|
|
288
|
+
|
|
230
289
|
/**
|
|
231
|
-
* 索引签名校验(
|
|
232
|
-
*
|
|
290
|
+
* 索引签名校验(REG-CS1,G4 fail-closed 口径):解析 detached .sig 六字段 → 结构校验 →
|
|
291
|
+
* 重算规范串(含 seq)→ verifyEd25519。返回 ok 仅当签名完整、覆盖代次且匹配索引字节。
|
|
233
292
|
* 注:发布方信任(key 是否在信任库/bootstrap)由安装期 TOFU/信任库承载,此处仅做完整性(防 CDN 索引篡改)。
|
|
234
293
|
*/
|
|
235
|
-
function verifyIndexSig(env: MarketEnv, kind:
|
|
294
|
+
function verifyIndexSig(env: MarketEnv, kind: IndexKind, text: string, sigText: string): IndexSigVerdict {
|
|
236
295
|
let sig: unknown;
|
|
237
296
|
try {
|
|
238
297
|
sig = JSON.parse(sigText);
|
|
239
298
|
}
|
|
240
299
|
catch {
|
|
241
|
-
return false;
|
|
300
|
+
return { ok: false, seq: null };
|
|
242
301
|
}
|
|
243
|
-
if (!isObj(sig)
|
|
244
|
-
return false;
|
|
245
|
-
const
|
|
246
|
-
|
|
302
|
+
if (!isObj(sig))
|
|
303
|
+
return { ok: false, seq: null };
|
|
304
|
+
const { alg, value, public_key: publicKey, seq } = sig as { alg?: unknown, value?: unknown, public_key?: unknown, seq?: unknown };
|
|
305
|
+
if (alg !== INDEX_SIG_ALG || typeof value !== "string" || typeof publicKey !== "string")
|
|
306
|
+
return { ok: false, seq: null };
|
|
307
|
+
// 签名未覆盖代次 = 旧格式(无法防「新总目录 + 旧分册」),拒
|
|
308
|
+
if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 1)
|
|
309
|
+
return { ok: false, seq: null };
|
|
310
|
+
return { ok: verifyEd25519Of(env)(publicKey, indexCanonical(env, kind, seq, text), value), seq };
|
|
247
311
|
}
|
|
248
312
|
|
|
313
|
+
/** 索引正文的代次(顶层 `seq`;纯内容形态(分片)无顶层键 → null,代次只由 .sig 承载) */
|
|
314
|
+
async function indexSeqOfText(text: string): Promise<number | null> {
|
|
315
|
+
const g = globalThis as { yaml?: { parse: (t: string) => Promise<unknown> } };
|
|
316
|
+
try {
|
|
317
|
+
const doc = await g.yaml!.parse(text) as unknown;
|
|
318
|
+
if (doc === null || typeof doc !== "object" || Array.isArray(doc))
|
|
319
|
+
return null;
|
|
320
|
+
const seq = (doc as Record<string, unknown>).seq;
|
|
321
|
+
return typeof seq === "number" && Number.isInteger(seq) && seq >= 1 ? seq : null;
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** 缺签名/空签名的人话(两步可执行下一步,与 CLI `ojm market verify` 同一文案口径) */
|
|
329
|
+
const SIG_MISSING_NEXT = "完整性无保障,已拒载。下一步:① 让源执行 ojm market build-index --id <publisher.id> 重建并签名;② 或对既有索引补签 ojm publish <目录> --id <publisher.id>";
|
|
330
|
+
|
|
249
331
|
/**
|
|
250
|
-
* 拉取索引并校验签名(
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
332
|
+
* 拉取索引并校验签名(REG-CS1,G4 fail-closed)——三级化后(catalog + shards):
|
|
333
|
+
* ① `.sig` 缺失 / 空 / 拉不到 → **拒载**(502)——不再「滚动发布期容忍」:
|
|
334
|
+
* 容忍等于把索引完整性交给发布方自觉,谁改了索引都能装。
|
|
335
|
+
* ② 签名不符或未覆盖代次 → 拒载(index_sig_mismatch)。
|
|
336
|
+
* ③ 同代校验改「钉值」口径:catalog 正文 `shards: {<片号>: <代次>}` 钉每片的代次,
|
|
337
|
+
* 分片签名 seq 必须等于钉值(`expectSeq`)——不等即拒(index_generation_mismatch,
|
|
338
|
+
* 提示「源正在重建索引」)。byte 级相同的分片可跨代复用,故钉值而非恒等于 catalog 代次。
|
|
339
|
+
* ④ catalog 另过两道新鲜度闸(REG-CS2,见 `guardCatalogFreshness`):时效过期 `index_expired`、
|
|
340
|
+
* seq 回退 `index_stale`——回放与镜像落后在此处被挡住。
|
|
341
|
+
*
|
|
342
|
+
* `no` = 分片号(catalog 省略);`expectSeq` = catalog 对该片的钉值(取 catalog 本身时省略)。
|
|
343
|
+
* 关键:四类拒载都在 try 之外抛出——fail-closed 不能被任何 catch 吞掉。
|
|
254
344
|
*/
|
|
255
|
-
async function fetchIndex(env: MarketEnv,
|
|
256
|
-
|
|
257
|
-
|
|
345
|
+
async function fetchIndex(env: MarketEnv, source: IndexSource, base: string, kind: IndexKind, no?: number, expectSeq?: number): Promise<{ text: string, seq: number }> {
|
|
346
|
+
// 人话里的文件名与 CDN 布局逐字一致(indexPathOf 单点):shards/<n>.yaml,不是 shard/<n>.yaml
|
|
347
|
+
const label = indexPathOf(kind, no);
|
|
348
|
+
const file = await source.read(kind, no);
|
|
349
|
+
if (file === null)
|
|
350
|
+
fail(502, `市场索引 ${label} 拉取失败(请检查市场地址与网络)`);
|
|
351
|
+
const sigText = file.sig;
|
|
352
|
+
if (sigText === null || sigText.trim() === "")
|
|
353
|
+
fail(502, `市场索引 ${label} 缺签名文件 ${label}.sig(index_sig_missing)——${SIG_MISSING_NEXT}`);
|
|
354
|
+
const verdict = verifyIndexSig(env, kind, file.body, sigText);
|
|
355
|
+
if (!verdict.ok || verdict.seq === null)
|
|
356
|
+
fail(502, `市场索引 ${label} 签名校验失败(index_sig_mismatch:索引被篡改、签名不符或签名未覆盖代次,G4 fail-closed)——请重新发布并签名`);
|
|
357
|
+
const seq = verdict.seq;
|
|
358
|
+
// 正文自带代次时(catalog)必须与签名一致——防止「改了正文没换签名」的半个写入;
|
|
359
|
+
// 分片正文是纯内容(无顶层 seq),代次只由签名 + catalog 钉值携带,故不过此闸。
|
|
360
|
+
const textSeq = await indexSeqOfText(file.body);
|
|
361
|
+
if (textSeq !== null && textSeq !== seq)
|
|
362
|
+
fail(502, `市场索引 ${label} 正文是第 ${textSeq} 代、签名是第 ${seq} 代(index_generation_mismatch)——源正在重建索引,请稍后重试`);
|
|
363
|
+
if (expectSeq !== undefined && expectSeq !== seq)
|
|
364
|
+
fail(502, `市场索引 ${label} 签名是第 ${seq} 代,总目录钉的是第 ${expectSeq} 代(index_generation_mismatch)——源正在重建索引,请稍后重试`);
|
|
365
|
+
// ④ 新鲜度只在 catalog 上判一次:代次真源与时效盖章都在它身上,分片跟着走
|
|
366
|
+
if (kind === "catalog")
|
|
367
|
+
await guardCatalogFreshness(env, base, file.body, seq);
|
|
368
|
+
return { text: file.body, seq };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* REG-CS2 索引新鲜度(防回放/降级):总目录过了签名之后还要过时效与代次水位两道闸。
|
|
373
|
+
*
|
|
374
|
+
* - `expires_at` 已过 → `index_expired`:索引自带失效时刻,源停止运营超过 TTL 就不再可信。
|
|
375
|
+
* (§5.3 可用性通道的「窗口内用缓存副本」由 HTTP 缓存层兑现——服务端
|
|
376
|
+
* `max-age=60, stale-while-revalidate=60` + 读取器条件请求;到达读取器手里的副本
|
|
377
|
+
* 自身过期即拒,与§5.3 不冲突。)
|
|
378
|
+
* - `seq` 小于本源见过的最大代次 → `index_stale`:镜像落后或旧索引被回放。
|
|
379
|
+
* - 水位读不到/写不进 → `index_state_corrupt`:fail-closed,不静默重置成「第一次见」。
|
|
380
|
+
*/
|
|
381
|
+
async function guardCatalogFreshness(env: MarketEnv, base: string, text: string, seq: number): Promise<void> {
|
|
382
|
+
const times = await indexTimesOfText(text);
|
|
383
|
+
if (times === null)
|
|
384
|
+
fail(502, "市场索引 catalog.yaml 缺 issued_at / expires_at(index_expired)——索引未带时效,请让源执行 ojm market build-index 重建");
|
|
385
|
+
const now = env.now?.() ?? Math.floor(Date.now() / 1000);
|
|
386
|
+
if (now > times.expires_at)
|
|
387
|
+
fail(502, `市场索引 catalog.yaml 已于 ${new Date(times.expires_at * 1000).toISOString()} 失效(index_expired)——源未在其自声明的有效期内重建索引,请让源执行 ojm market build-index 后再装`);
|
|
388
|
+
|
|
389
|
+
let verdict: SourceSeqVerdict;
|
|
258
390
|
try {
|
|
259
|
-
|
|
260
|
-
const body = await fetchText(fetch, `${base}/${kind}.yaml.sig`);
|
|
261
|
-
if (body.trim() !== "")
|
|
262
|
-
sigText = body;
|
|
391
|
+
verdict = await guardAndRecordSourceSeq(base, seq);
|
|
263
392
|
}
|
|
264
393
|
catch {
|
|
265
|
-
|
|
394
|
+
fail(502, "市场索引代次水位写不进去(index_state_corrupt)——reg_source_seq 不可写,防回放保护失效,已拒载。请检查站点数据库权限后重试");
|
|
395
|
+
}
|
|
396
|
+
if (verdict.ok)
|
|
397
|
+
return;
|
|
398
|
+
if (verdict.code === "index_stale")
|
|
399
|
+
fail(502, `市场索引 catalog.yaml 是第 ${seq} 代,本源已见过第 ${verdict.prev} 代(index_stale)——源可能被回放或镜像落后,请核实源状态后再装`);
|
|
400
|
+
fail(502, "市场索引代次水位不可读(index_state_corrupt)——reg_source_seq 记录损坏,请检查站点数据库后重试(不静默重置,否则防回放形同虚设)");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* catalog 解析(REG-CS5:包名 → 分片号 + 片号 → 代次钉值)。畸形即拒不猜——索引是签名过的
|
|
405
|
+
* 不可信输入,结构破损与字节被改同类对待(G4 fail-closed)。
|
|
406
|
+
*/
|
|
407
|
+
async function parseCatalog(text: string, base: string): Promise<{ pins: Map<number, number>, solutions: Array<{ name: string, shards: number[] }> }> {
|
|
408
|
+
const g = globalThis as { yaml?: { parse: (t: string) => Promise<unknown> } };
|
|
409
|
+
let doc: unknown;
|
|
410
|
+
try {
|
|
411
|
+
doc = await g.yaml!.parse(text);
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
fail(502, `市场索引解析失败:${base}/catalog.yaml 不是合法索引`);
|
|
415
|
+
}
|
|
416
|
+
if (!isObj(doc))
|
|
417
|
+
fail(502, `市场索引解析失败:${base}/catalog.yaml 不是键值文档`);
|
|
418
|
+
const pins = new Map<number, number>();
|
|
419
|
+
if (isObj(doc.shards)) {
|
|
420
|
+
for (const [k, v] of Object.entries(doc.shards)) {
|
|
421
|
+
const no = Number(k);
|
|
422
|
+
if (Number.isInteger(no) && no >= 1 && typeof v === "number" && Number.isInteger(v) && v >= 1)
|
|
423
|
+
pins.set(no, v);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (pins.size === 0)
|
|
427
|
+
fail(502, "市场索引 catalog.yaml 的分片钉值为空(index_generation_mismatch)——索引结构破损,请让源执行 ojm market build-index 重建");
|
|
428
|
+
const solutions: Array<{ name: string, shards: number[] }> = [];
|
|
429
|
+
for (const s of Array.isArray(doc.solutions) ? doc.solutions : []) {
|
|
430
|
+
if (!isObj(s) || typeof s.name !== "string" || s.name === "")
|
|
431
|
+
continue;
|
|
432
|
+
// 片号须是已钉值正整数:指向未钉片的方案 = 结构破损,拒载(不静默跳过,那会伪装成「没有该方案」)
|
|
433
|
+
const nos = Array.isArray(s.shards) ? s.shards.filter((n): n is number => typeof n === "number" && Number.isInteger(n) && n >= 1 && pins.has(n)) : [];
|
|
434
|
+
if (nos.length === 0)
|
|
435
|
+
fail(502, `市场索引 catalog.yaml 中方案「${s.name}」未指向任何已钉分片(index_generation_mismatch)——索引结构破损,请让源执行 ojm market build-index 重建`);
|
|
436
|
+
solutions.push({ name: s.name, shards: nos });
|
|
437
|
+
}
|
|
438
|
+
return { pins, solutions };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** 分片内的方案条目(版本条目 = Form B manifest:name/version/archive/yanked/yanked_at + manifest 全字段) */
|
|
442
|
+
interface ShardSolution {
|
|
443
|
+
name: string
|
|
444
|
+
latest: string | null
|
|
445
|
+
versions: Array<Record<string, unknown>>
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** 分片解析(纯内容无 meta:`{solutions: [{name, latest, versions}]}`;结构破损即拒,不猜) */
|
|
449
|
+
async function parseShard(text: string, label: string): Promise<ShardSolution[]> {
|
|
450
|
+
const g = globalThis as { yaml?: { parse: (t: string) => Promise<unknown> } };
|
|
451
|
+
let doc: unknown;
|
|
452
|
+
try {
|
|
453
|
+
doc = await g.yaml!.parse(text);
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
fail(502, `市场索引 ${label} 不是合法索引(解析失败)`);
|
|
457
|
+
}
|
|
458
|
+
if (!isObj(doc) || !Array.isArray(doc.solutions))
|
|
459
|
+
fail(502, `市场索引 ${label} 解析失败:缺 solutions 数组——索引结构破损,请让源执行 ojm market build-index 重建`);
|
|
460
|
+
const out: ShardSolution[] = [];
|
|
461
|
+
for (const s of doc.solutions) {
|
|
462
|
+
if (!isObj(s) || typeof s.name !== "string" || s.name === "")
|
|
463
|
+
continue;
|
|
464
|
+
out.push({
|
|
465
|
+
name: s.name,
|
|
466
|
+
latest: typeof s.latest === "string" ? s.latest : null,
|
|
467
|
+
versions: Array.isArray(s.versions) ? s.versions.filter(isObj) : [],
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
return out;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** 索引读取源(DIP,REG-CS5③):注入面优先,缺省按 CDN 基址 + fetchOf 构造 HTTP 源 */
|
|
474
|
+
async function indexSourceOf(env: MarketEnv, base: string): Promise<IndexSource> {
|
|
475
|
+
return env.indexSource ?? httpIndexSource(await fetchOf(env), base);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** 索引时效(顶层 `issued_at` / `expires_at`;缺任一或非数 → null,按 REG-CS2 拒) */
|
|
479
|
+
async function indexTimesOfText(text: string): Promise<{ issued_at: number, expires_at: number } | null> {
|
|
480
|
+
const g = globalThis as { yaml?: { parse: (t: string) => Promise<unknown> } };
|
|
481
|
+
try {
|
|
482
|
+
const doc = await g.yaml!.parse(text) as unknown;
|
|
483
|
+
if (doc === null || typeof doc !== "object" || Array.isArray(doc))
|
|
484
|
+
return null;
|
|
485
|
+
const issuedAt = (doc as Record<string, unknown>).issued_at;
|
|
486
|
+
const expiresAt = (doc as Record<string, unknown>).expires_at;
|
|
487
|
+
if (typeof issuedAt !== "number" || typeof expiresAt !== "number")
|
|
488
|
+
return null;
|
|
489
|
+
return { issued_at: issuedAt, expires_at: expiresAt };
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
return null;
|
|
266
493
|
}
|
|
267
|
-
if (sigText !== null && !verifyIndexSig(env, kind, text, sigText))
|
|
268
|
-
fail(502, `市场索引 ${kind}.yaml 签名校验失败(索引被篡改或签名不符,G4 fail-closed)——请重新发布并签名`);
|
|
269
|
-
return text;
|
|
270
494
|
}
|
|
271
495
|
|
|
272
496
|
/** http.user.id → username(operator 历史留痕,uninstall 端点同款反查;查询失败不阻断编排) */
|
|
@@ -331,31 +555,58 @@ interface ResolvedBundle {
|
|
|
331
555
|
|
|
332
556
|
/**
|
|
333
557
|
* Form B 索引解析(安装器不拼 URL——市场侧职责,installer.ts 同纪律):
|
|
334
|
-
*
|
|
335
|
-
*
|
|
558
|
+
* catalog.yaml 定包名 → 分片号(无版本明细)→ 分片内按版本精确取(版本条目即 manifest,
|
|
559
|
+
* 原 manifests.yaml 聚合清单并入分片,装一个包不再有第三份全量拉取)→ 分发包 URL =
|
|
560
|
+
* base + 条目 archive.file。分片按号**降序**尝试:装箱口径下新版本进新片,最高片持有
|
|
561
|
+
* 最新版——latest 安装只拉 1 片(REG-CS5 验收:一次安装索引请求 ≤ 1 catalog + 1 shard)。
|
|
336
562
|
*/
|
|
337
563
|
async function resolveCdn(env: MarketEnv, base: string, name: string, reqVersion: string | null, fetch: FetchInitFace): Promise<ResolvedBundle> {
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
564
|
+
const source = httpIndexSource(fetch, base);
|
|
565
|
+
// 总目录(catalog)= 代次与时效真源;分片钉值同代才可用(REG-CS1③)
|
|
566
|
+
const catalog = await fetchIndex(env, source, base, "catalog");
|
|
567
|
+
const { pins, solutions } = await parseCatalog(catalog.text, base);
|
|
568
|
+
const entry = solutions.find(s => s.name === name);
|
|
569
|
+
if (entry === undefined)
|
|
570
|
+
fail(404, `市场索引中无方案「${name}」(${base}/catalog.yaml)`);
|
|
571
|
+
// 降序:最高片先试(latest 路径一片即中);精确老版本落在低片时继续往下,直到找到或找遍
|
|
572
|
+
for (const no of [...entry.shards].sort((a, b) => b - a)) {
|
|
573
|
+
const pin = pins.get(no);
|
|
574
|
+
if (pin === undefined)
|
|
575
|
+
fail(502, `市场索引 catalog.yaml 钉值缺 shards/${no}.yaml(index_generation_mismatch)——源正在重建索引,请稍后重试`);
|
|
576
|
+
const shard = await fetchIndex(env, source, base, "shard", no, pin);
|
|
577
|
+
const sol = (await parseShard(shard.text, `shards/${no}.yaml`)).find(s => s.name === name);
|
|
578
|
+
if (sol === undefined)
|
|
579
|
+
continue;
|
|
580
|
+
// REG-CS2 `latest` 单调不降(三级化后移到分片条目):分片声明的 latest 不得低于本片
|
|
581
|
+
// 已声明版本中的最高未下线版本。把 latest 往下改是「让站点装回旧版」的最短路径
|
|
582
|
+
// (yank 之后回退声明),必须挡在解析处。已下线版本(REG-CS4 yanked)不计入——
|
|
583
|
+
// 否则 yank 掉最高版会把 latest 主动下调,被误判成索引回退;latest 本就指向未下线的最高版。
|
|
584
|
+
const liveSorted = sol.versions.filter(v => v.yanked !== true).map(v => String(v.version)).sort(compareVersions);
|
|
585
|
+
const highest = liveSorted.length === 0 ? null : liveSorted[liveSorted.length - 1]!;
|
|
586
|
+
if (sol.latest !== null && highest !== null && compareVersions(sol.latest, highest) < 0)
|
|
587
|
+
fail(502, `市场索引中方案「${name}」的 latest 是 ${sol.latest},低于已声明的最高版本 ${highest}(index_latest_regressed)——索引被回退,请核实源状态后再装`);
|
|
588
|
+
const version = reqVersion ?? sol.latest;
|
|
589
|
+
if (version !== null) {
|
|
590
|
+
const hit = sol.versions.find(v => String(v.version) === version);
|
|
591
|
+
if (hit !== undefined) {
|
|
592
|
+
// REG-CS4 yank:精确指定被下线版本 → 拒装(latest 路径不命中,源方 rebuilt 时 latest 已跳过 yanked);
|
|
593
|
+
// 已装实例不受影响(不在此解析路径内)。错误信息明示「版本已下线」而非「无此版本」,避免误导。
|
|
594
|
+
if (reqVersion !== null && hit.yanked === true)
|
|
595
|
+
fail(409, `方案「${name}」版本「${version}」已被发布方下线(REG-CS4 yank),无法安装;已安装实例不受影响。如需继续请联系发布方或改用其他版本`);
|
|
596
|
+
// P2-11 索引投毒加固:archive.file 逐字 <name>-<version>.tgz(索引是签名过的不可信输入,
|
|
597
|
+
// 过弱形态可拼宿主任意路径;逐字等值排除 ../、绝对路径、异名、异版本)
|
|
598
|
+
const archive = isObj(hit.archive) ? hit.archive : {};
|
|
599
|
+
const file = typeof archive.file === "string" ? archive.file : "";
|
|
600
|
+
if (file !== `${name}-${version}.tgz`) {
|
|
601
|
+
fail(502, `索引分发包 file「${file}」须逐字为 <name>-<version>.tgz(「${name}-${version}.tgz」)——索引投毒加固(P2-11),疑似被篡改的索引`);
|
|
602
|
+
}
|
|
603
|
+
// 版本条目即 Form B manifest(原 manifests.yaml 并入分片):取到条目即取到 manifest
|
|
604
|
+
return { version, archiveUrl: `${base}/${file}`, manifest: hit };
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
// 版本不在这片(更低分片或真没有):继续往低片找
|
|
608
|
+
}
|
|
609
|
+
fail(404, `市场索引中无方案「${name}」版本「${reqVersion ?? "latest"}」(${base}/catalog.yaml → shards)`);
|
|
359
610
|
}
|
|
360
611
|
|
|
361
612
|
/** 上传载荷字节(upload/api.ts 先例双路径:小文件字节在手,大文件已流式落 blob 须走 blob.get) */
|
|
@@ -490,11 +741,13 @@ async function installBody(env: MarketEnv): Promise<{ task_id: string }> {
|
|
|
490
741
|
const base = await cdnBaseOf(env);
|
|
491
742
|
if (base === null)
|
|
492
743
|
fail(503, "未配置市场地址(framework.market_cdn_url)——市场安装不可用,请先在系统设置配置 CDN 基址");
|
|
493
|
-
|
|
744
|
+
// REG-CS6:安装全程(相对基址补全 + 冒烟探针)都需 canonical origin;缺失/非法即明确报错
|
|
745
|
+
await requireCanonicalOrigin(env);
|
|
746
|
+
const r = await resolveCdn(env, base, name, reqVersion, await fetchOf(env));
|
|
494
747
|
manifest = r.manifest;
|
|
495
748
|
version = r.version;
|
|
496
749
|
archiveUrl = r.archiveUrl;
|
|
497
|
-
fetch = fetchOf(env);
|
|
750
|
+
fetch = await fetchOf(env);
|
|
498
751
|
}
|
|
499
752
|
// 第一方 bootstrap key 播种(P2-12,G3 双轨第一轨):runInstall 之前——market 首装免 TOFU。
|
|
500
753
|
// key 未回填(null)或信任库已初始化 → no-op(不与运维吊销对抗)
|
|
@@ -523,7 +776,7 @@ async function installBody(env: MarketEnv): Promise<{ task_id: string }> {
|
|
|
523
776
|
modulesJsonPath: env.modulesJsonPath,
|
|
524
777
|
apiPrefix: env.apiPrefix,
|
|
525
778
|
configPath: env.configPath,
|
|
526
|
-
fetch
|
|
779
|
+
fetch,
|
|
527
780
|
exec: execOf(env),
|
|
528
781
|
reload: reloadOf(env),
|
|
529
782
|
sha384B64: sha384Of(env),
|
|
@@ -534,6 +787,7 @@ async function installBody(env: MarketEnv): Promise<{ task_id: string }> {
|
|
|
534
787
|
// 宿主平台编排面 API 版本(C2-11 peerPlatform 比对真源;与 meta 端点 apiVersion、_platform/manifest.yaml 版本同源,手工同步)
|
|
535
788
|
hostApiVersion: API_VERSION,
|
|
536
789
|
trustPath: env.trustPath,
|
|
790
|
+
registryId: env.registryId,
|
|
537
791
|
cancel: () => {
|
|
538
792
|
if (!cancelled) {
|
|
539
793
|
void fsFace().readFile(markerPath).then(() => {
|
|
@@ -562,10 +816,12 @@ interface IndexRow {
|
|
|
562
816
|
upgradable: boolean
|
|
563
817
|
/** CDN 版本全集(P2-11):回滚候选不止本站已装旧版;索引缺段/畸形 → [] 降级不炸 */
|
|
564
818
|
available: string[]
|
|
565
|
-
/** 最新版 changelog(C2-08,R27
|
|
819
|
+
/** 最新版 changelog(C2-08,R27 卡片版本对比;分片条目缺 changelog → null) */
|
|
566
820
|
changelog: unknown
|
|
567
|
-
/** 最新版发布方(manifest author,C2-07
|
|
821
|
+
/** 最新版发布方(manifest author,C2-07;分片条目缺 author → null) */
|
|
568
822
|
publisher: string | null
|
|
823
|
+
/** 最新版许可(manifest license,C2-07 SPDX 简化式;分片条目缺/非字符串 → null) */
|
|
824
|
+
license: string | null
|
|
569
825
|
/** 本地安装的发布方 key 状态(P2-6 sidecar;未装 → null) */
|
|
570
826
|
trust_state: "trusted" | "untrusted" | "absent" | null
|
|
571
827
|
/** 本地安装记录的签名 key id(sidecar archive.key_id;未装/P1 时代条目 → null) */
|
|
@@ -592,7 +848,14 @@ async function lastInstallOperatorOf(name: string): Promise<string | null> {
|
|
|
592
848
|
}
|
|
593
849
|
}
|
|
594
850
|
|
|
595
|
-
/**
|
|
851
|
+
/**
|
|
852
|
+
* GET index 主体:CDN catalog + shards ⊕ 本地 sidecar state——CDN 序优先,本地独有方案殿后。
|
|
853
|
+
*
|
|
854
|
+
* 三级化后市场页的拉取形态:1 catalog + catalog 指向的全部分片(去重)——`available`
|
|
855
|
+
* 回滚候选要版本全集,故页面比安装多拉几片;安装侧仍 ≤ 1 catalog + 1 shard(resolveCdn)。
|
|
856
|
+
* 版本条目即 Form B manifest:latest 的 changelog/publisher 直接从分片条目取,不再有
|
|
857
|
+
* 独立的聚合清单拉取。分片拉取/验签失败即 502(REG-CS1:可用性不再压过完整性)。
|
|
858
|
+
*/
|
|
596
859
|
async function indexBody(env: MarketEnv): Promise<IndexRow[]> {
|
|
597
860
|
// 对账即服务(P1-10 接线③):市场页刷新即对账时点——锁 ↔ 账本 head 自愈 + enabled 不一致
|
|
598
861
|
// 自动修复先行(失败只记日志,不阻断索引),失败站点的市场页每次打开都在尝试自愈
|
|
@@ -600,43 +863,41 @@ async function indexBody(env: MarketEnv): Promise<IndexRow[]> {
|
|
|
600
863
|
const base = await cdnBaseOf(env);
|
|
601
864
|
if (base === null)
|
|
602
865
|
fail(503, "未配置市场地址(framework.market_cdn_url)——卡片区显示「未配置市场地址」(PRD §5.3)");
|
|
603
|
-
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
866
|
+
await assertOriginForRelativeBase(env, base);
|
|
867
|
+
const source = await indexSourceOf(env, base);
|
|
868
|
+
const catalog = await fetchIndex(env, source, base, "catalog");
|
|
869
|
+
const { pins, solutions } = await parseCatalog(catalog.text, base);
|
|
870
|
+
// 拉取 catalog 指向的全部分片(去重、升序确定性);每片都过签名 + 钉值同代校验
|
|
871
|
+
const shardDocs = new Map<number, ShardSolution[]>();
|
|
872
|
+
for (const no of [...pins.keys()].sort((a, b) => a - b)) {
|
|
873
|
+
const shard = await fetchIndex(env, source, base, "shard", no, pins.get(no)!);
|
|
874
|
+
shardDocs.set(no, await parseShard(shard.text, `shards/${no}.yaml`));
|
|
611
875
|
}
|
|
612
|
-
const cdn = (Array.isArray(bundles.solutions) ? bundles.solutions : []).filter(isObj);
|
|
613
876
|
const local = (await createStateStore(env.statePath)).read().solutions;
|
|
614
|
-
// manifests.yaml 单次拉取供 changelog/publisher(P2-11):失败降级 null 不阻断索引(可用性优先)
|
|
615
|
-
let manifests: Record<string, unknown>[] = [];
|
|
616
|
-
try {
|
|
617
|
-
const list = await g.yaml!.parse(await fetchIndex(env, fetchOf(env), base, "manifests"));
|
|
618
|
-
if (Array.isArray(list))
|
|
619
|
-
manifests = list.filter(isObj);
|
|
620
|
-
}
|
|
621
|
-
catch {
|
|
622
|
-
// 降级:changelog/publisher 全 null,索引主体照常返回
|
|
623
|
-
}
|
|
624
|
-
const manifestOf = (name: string, version: string | null): Record<string, unknown> | undefined =>
|
|
625
|
-
version === null ? undefined : manifests.find(m => m.name === name && String(m.version) === version);
|
|
626
877
|
const rows: IndexRow[] = [];
|
|
627
878
|
const seen = new Set<string>();
|
|
628
|
-
for (const
|
|
629
|
-
const name =
|
|
630
|
-
if (
|
|
879
|
+
for (const entry of solutions) {
|
|
880
|
+
const name = entry.name;
|
|
881
|
+
if (seen.has(name))
|
|
631
882
|
continue;
|
|
632
883
|
seen.add(name);
|
|
633
|
-
|
|
884
|
+
// 合并该包全部分片的版本条目(降序拼:最高片持有最新版,latest 取首片即得)
|
|
885
|
+
const merged: Array<Record<string, unknown>> = [];
|
|
886
|
+
let latest: string | null = null;
|
|
887
|
+
for (const no of [...entry.shards].sort((a, b) => b - a)) {
|
|
888
|
+
const sol = shardDocs.get(no)?.find(s => s.name === name);
|
|
889
|
+
if (sol === undefined)
|
|
890
|
+
continue;
|
|
891
|
+
if (latest === null)
|
|
892
|
+
latest = sol.latest;
|
|
893
|
+
merged.push(...sol.versions);
|
|
894
|
+
}
|
|
634
895
|
const side = local[name];
|
|
635
|
-
// available
|
|
636
|
-
const available =
|
|
637
|
-
|
|
638
|
-
.
|
|
639
|
-
const m = manifestOf(
|
|
896
|
+
// available:版本全集(缺段/畸形 → [] 降级);分片内升序、跨片重排为全局升序
|
|
897
|
+
const available = merged.map(v => typeof v.version === "string" ? v.version : "").filter(x => x !== "").sort(compareVersions);
|
|
898
|
+
const manifestOf = (version: string | null): Record<string, unknown> | undefined =>
|
|
899
|
+
version === null ? undefined : merged.find(v => String(v.version) === version);
|
|
900
|
+
const m = manifestOf(latest) ?? manifestOf(side?.version ?? null);
|
|
640
901
|
rows.push({
|
|
641
902
|
name,
|
|
642
903
|
latest,
|
|
@@ -646,6 +907,7 @@ async function indexBody(env: MarketEnv): Promise<IndexRow[]> {
|
|
|
646
907
|
available,
|
|
647
908
|
changelog: m?.changelog ?? null,
|
|
648
909
|
publisher: typeof m?.author === "string" ? m.author : null,
|
|
910
|
+
license: typeof m?.license === "string" ? m.license : null,
|
|
649
911
|
trust_state: side?.trust_state ?? null,
|
|
650
912
|
key_id: side?.archive?.key_id ?? null,
|
|
651
913
|
operator: await lastInstallOperatorOf(name),
|
|
@@ -662,6 +924,7 @@ async function indexBody(env: MarketEnv): Promise<IndexRow[]> {
|
|
|
662
924
|
available: [],
|
|
663
925
|
changelog: null,
|
|
664
926
|
publisher: null,
|
|
927
|
+
license: null,
|
|
665
928
|
trust_state: side.trust_state ?? null,
|
|
666
929
|
key_id: side.archive?.key_id ?? null,
|
|
667
930
|
operator: await lastInstallOperatorOf(name),
|