@oj-module/cli 0.2.30 → 0.2.43

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.
Files changed (115) hide show
  1. package/devkit/manual.md +5 -5
  2. package/devkit/module-dev-and-cdn-playground.md +1 -1
  3. package/package.json +2 -2
  4. package/shell-dist/assets/runtime.js +2 -2
  5. package/shell-dist/index.html +2 -2
  6. package/shell-dist/versions.json +3 -3
  7. package/src/__tests__/cli.test.ts +23 -14
  8. package/src/__tests__/list.test.ts +176 -0
  9. package/src/__tests__/registry-cli.test.ts +522 -0
  10. package/src/__tests__/registry-export.test.ts +319 -0
  11. package/src/__tests__/registry-import.test.ts +336 -0
  12. package/src/__tests__/registry-sign.test.ts +352 -0
  13. package/src/cli-output.ts +81 -0
  14. package/src/cli.ts +298 -39
  15. package/src/config-cmd.ts +100 -0
  16. package/src/contract/__tests__/handler-scan.test.ts +74 -2
  17. package/src/contract/handler-scan.ts +27 -2
  18. package/src/enable.ts +1 -1
  19. package/src/exit-codes.ts +33 -0
  20. package/src/index.ts +5 -9
  21. package/src/install.ts +120 -38
  22. package/src/lifecycle/__tests__/authoring.test.ts +6 -5
  23. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.bad.sig +3 -2
  24. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.sig +3 -2
  25. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.unknown.sig +3 -2
  26. package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.d.mts +5 -4
  27. package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.mjs +9 -6
  28. package/src/lifecycle/__tests__/helpers.ts +10 -6
  29. package/src/lifecycle/__tests__/install.test.ts +72 -15
  30. package/src/lifecycle/__tests__/manifest-contract.test.ts +40 -1
  31. package/src/lifecycle/__tests__/publish.test.ts +174 -33
  32. package/src/lifecycle/__tests__/sig-fixtures.test.ts +9 -6
  33. package/src/lifecycle/__tests__/sign.test.ts +9 -8
  34. package/src/lifecycle/__tests__/smoke.test.ts +1 -1
  35. package/src/lifecycle/__tests__/uninstall.test.ts +4 -4
  36. package/src/lifecycle/sign.ts +8 -7
  37. package/src/list.ts +146 -0
  38. package/src/manifest-contract.ts +11 -5
  39. package/src/market/__tests__/bootstrap.test.ts +4 -1
  40. package/src/market/__tests__/build-index.test.ts +529 -42
  41. package/src/market/__tests__/fixture.test.ts +17 -1
  42. package/src/market/__tests__/publish.test.ts +8 -7
  43. package/src/market/__tests__/setup.test.ts +4 -4
  44. package/src/market/__tests__/sharding.test.ts +90 -0
  45. package/src/market/__tests__/verify.test.ts +237 -13
  46. package/src/market/__tests__/yank-ledger.test.ts +60 -0
  47. package/src/market/bootstrap.ts +8 -3
  48. package/src/market/build-index.ts +407 -40
  49. package/src/market/cdn-layout.ts +3 -2
  50. package/src/market/command.ts +40 -6
  51. package/src/market/failure-playbook.ts +1 -1
  52. package/src/market/fixture.ts +20 -3
  53. package/src/market/index-builder.ts +120 -47
  54. package/src/market/publish.ts +3 -1
  55. package/src/market/setup.ts +3 -3
  56. package/src/market/sharding.ts +94 -0
  57. package/src/market/trial.ts +1 -1
  58. package/src/market/verify.ts +220 -53
  59. package/src/market/walkthrough.ts +7 -6
  60. package/src/market/yank-ledger.ts +91 -0
  61. package/src/publish.ts +250 -38
  62. package/src/registry-export.ts +405 -0
  63. package/src/registry-import.ts +410 -0
  64. package/src/registry-sidecar.ts +84 -0
  65. package/src/registry-sign.ts +322 -0
  66. package/src/registry-source.ts +192 -0
  67. package/src/sign.ts +13 -10
  68. package/src/site.ts +4 -4
  69. package/src/trust.ts +1 -1
  70. package/src/uninstall.ts +14 -2
  71. package/src/usage.ts +130 -25
  72. package/src/view.ts +94 -0
  73. package/templates/cdn/README.md +35 -5
  74. package/templates/config.yaml +46 -96
  75. package/templates/init.include.yaml +1 -1
  76. package/templates/src/_platform/api/_lifecycle/atomic.ts +3 -0
  77. package/templates/src/_platform/api/_lifecycle/installer.ts +44 -7
  78. package/templates/src/_platform/api/_lifecycle/limits.ts +21 -0
  79. package/templates/src/_platform/api/_lifecycle/source-seq.ts +88 -0
  80. package/templates/src/_platform/api/_lifecycle/steps/download.ts +15 -0
  81. package/templates/src/_platform/api/_lifecycle/steps/extract.ts +61 -2
  82. package/templates/src/_platform/api/_lifecycle/steps/preflight.ts +55 -6
  83. package/templates/src/_platform/api/_lifecycle/steps/register.ts +2 -1
  84. package/templates/src/_platform/api/_lifecycle/steps/verify.ts +22 -8
  85. package/templates/src/_platform/api/_lifecycle/tgz-guard.ts +96 -0
  86. package/templates/src/_platform/api/_lifecycle/types.ts +6 -3
  87. package/templates/src/_platform/api/_lifecycle/verify-checks.ts +8 -0
  88. package/templates/src/_platform/api/_shared/service/domain/solution-perms.ts +9 -1
  89. package/templates/src/_platform/api/_shared/site-facade.ts +62 -11
  90. package/templates/src/_platform/api/manifest.yaml +1 -0
  91. package/templates/src/_platform/api/market/api.ts +362 -99
  92. package/templates/src/_platform/api/market/index-source.ts +112 -0
  93. package/templates/src/_platform/api/migrations/0014__reg_source_seq.mysql.sql +9 -0
  94. package/templates/src/_platform/api/migrations/0014__reg_source_seq.pg.sql +7 -0
  95. package/templates/src/_platform/api/migrations/0014__reg_source_seq.sql +8 -0
  96. package/templates/src/_platform/api/schema.yaml +8 -0
  97. package/templates/src/_platform/api/solutions/api.ts +8 -3
  98. package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +13 -0
  99. package/templates/src/oj-market/web/__tests__/InstallDialog.test.tsx +38 -0
  100. package/templates/src/oj-market/web/components/CardItem.tsx +10 -0
  101. package/templates/src/oj-market/web/components/InstallDialog.tsx +18 -0
  102. package/templates/src/oj-market/web/locales/en-US.json +1 -0
  103. package/templates/src/oj-market/web/locales/zh-CN.json +1 -0
  104. package/templates/src/oj-market/web/types.ts +3 -1
  105. package/templates/tests/lifecycle/installer.spec.ts +24 -0
  106. package/templates/tests/lifecycle/market-api.spec.ts +641 -85
  107. package/templates/tests/lifecycle/preflight.spec.ts +52 -0
  108. package/templates/tests/lifecycle/sig-fixtures.spec.ts +7 -4
  109. package/templates/tests/lifecycle/source-seq.spec.ts +55 -0
  110. package/templates/tests/lifecycle/steps-fs.spec.ts +166 -7
  111. package/templates/tests/lifecycle/tgz-guard.spec.ts +133 -1
  112. package/templates/tests/lifecycle/verify-checks.spec.ts +27 -13
  113. package/templates/tests/lifecycle/verify-report.spec.ts +3 -1
  114. package/templates/tests/rbac-solution-perms.spec.ts +41 -0
  115. package/vendor/host-versions.json +3 -3
@@ -1,107 +1,61 @@
1
- # oj 服务配置(字段语义见 oj 后端开发手册 oj-api-dev / 或 oj --help)。
2
- # 本文件由 ojm init 生成;config 内相对路径相对本文件所在目录。
3
- # 更多能力见手册 §10:vars / broker / tasks / oidc / ws / html_meta / secrets。
4
- #
5
- # ---- 证书与密钥的唯一落点:`./config/`(= 工程根/config/,本文件同级的子目录)----
6
- # 三件套由 `ojm init` 现场签发(dev 缺证时 `ojm dev` 也会补签),逐把的作用与后果:
7
- #
8
- # ① private.pem —— 证书签发私钥(RSA 2048)。只用来给 ③ 签名,**oj 不读它**,
9
- # 也绝不出部署机、不入仓。丢了不需要恢复:重新签发一套即可(旧的 ③ 作废)。
10
- # ② public.pem —— 与 ① 成对的公钥,oj 启动时拿它验 ③ 的 RS256 签名。
11
- # 配错路径或与 ③ 不是同一对 → oj 验签失败、启动即拒(读作「证书无效」)。
12
- # 由 server.public_key_path 引用。
13
- # ③ cert.jws —— JWS 证书(RS256 紧凑三段,payload 仅 nbf/exp),**oj 的启动闸门**:
14
- # 缺失、过期、签名对不上 → fail-fast 拒启,devkit 手册明确**无任何 config/CLI 绕过开关**。
15
- # 默认有效期 3650 天(短有效期等于定时炸弹);过期只能重新签发一套 ①②③。
16
- # 由 server.certificate_path 引用。
17
- #
18
- # 另有一对与上面无关的密钥(jwt_secret 密封用,init 密封成功后追加到本文件末尾):
19
- # secrets.private_key_path ./config/secrets-private.pem 解 jwt_secret 密文的私钥,只放部署机
20
- # secrets.public_key_path ./config/secrets-public.pem 加密用的公钥,可随代码分发
21
- # (密文形如 ENC 后跟方括号包裹的字串;未密封时 config 里是 32 字节十六进制明文)
22
- #
23
- # ojm 侧的解析入口是 `resolveCertDir()`,由本文件位置派生;改落点只需动那一处 + 本段注释。
1
+ # oj 服务配置(字段手册见 devkit oj-api-dev / oj --help)。由 ojm init 生成,相对路径相对本文件所在目录。
24
2
 
25
- # 站点挂载(oj v0.1.58 起顶层扁平表:一行=一个 URL 前缀+一个目录,api/web 恰好其一)。
26
- # 被删除的 server.api_prefix / app_spa_fallback 已由 mounts 取代;
27
- # ojm dev 的 SPA 深链回落由 ojm 自建静态层负责;ojm preview 为单服务形态,
28
- # 现场生成派生 config(.ojm-preview.yaml,注入下面的 web 挂载 + spa: true),无需手改。
29
- #
30
- # **这里没有 api 挂载是有意的**:本工程是 `src/<name>/{api,web}` 布局,模块分散在
31
- # `src/<name>/api` 下,而 mounts 的 api 项按 `<目录>/<模块>/manifest.yaml` 单层解析、
32
- # 不会递归(实测报 `module 'x' missing manifest.yaml`)。源码形态由 `ojm dev` 用
33
- # `--module-root src` 供给,产物形态由 `ojm preview` 用 `--api-path dist/api` 供给;
34
- # 两者与 mounts 的 api 项同时出现还会报「module declared in multiple places」。
35
- # 手写部署(不经 ojm)时才需要自己加回一条 `- prefix: /api` + `api: dist`。
3
+ # ---- 证书三件套在 ./config/(init 已现场签发)----
4
+ # private.pem 签发私钥:只用于给 cert.jws 签名,oj 不读它;泄露或丢失就重签一套,旧的作废
5
+ # public.pem oj 启动时验 cert.jws 签名用(server.public_key_path)
6
+ # cert.jws 启动闸门:缺失/过期/签名不符直接拒启,无 config/CLI 绕过(默认 10 年有效期)
7
+ # 另有一对 secrets-*.pem:init 密封 jwt_secret 后追加到本文件末尾,同样只放部署机。
8
+
9
+ # 站点挂载:一行 = 一个 URL 前缀 + 一个目录(api/web 二选一)。
10
+ # 这里不放 api 挂载是有意的:本工程是 src/<name>/{api,web} 布局,源码由 ojm dev
11
+ # 以 --module-root src 供给、产物由 ojm preview 以 --api-path dist/api 供给,都不走 mounts。
12
+ # 手写部署(不用 ojm)时才需要加回一条 `- prefix: /api` + `api: dist`。
36
13
  mounts:
37
- # 本地模拟 CDN:直出工程根 cdn/(内容见 cdn/README.md)。
38
- # seed 的 framework.market_cdn_url 缺省即 /cdn(本站点自供),新工程免 market setup。
39
- # 红线:生产请删掉这一条挂载,并把 system 设置 framework.market_cdn_url 指向真 CDN(绝对地址)。
40
- # 不得给此挂载设 spa——缺文件必须返回 404,回落 index.html 会让市场解析崩。
14
+ # 本地市场 CDN:直出工程根 cdn/。seed 里 framework.market_cdn_url 缺省就是 /cdn,本站点自供免 setup。
15
+ # 生产删掉这条,并把该设置指向真 CDN(绝对地址)。不能设 spa:缺文件必须 404,
16
+ # 回落 index.html 会让市场解析崩。
41
17
  - prefix: /cdn
42
18
  web: ./cdn
43
- # 生产若由 oj 直出前端(前后端同进程,即 ojm preview 的生产形态),
44
- # 取消下一行注释并先 build 出 dist/web(preview 会自动注入等价挂载,无需手改):
19
+ # 读面缓存 60s:yank 最坏 120s 生效。304 由 oj 静态层自带,这里只管新鲜窗口。
20
+ headers:
21
+ Cache-Control: 'max-age=60, stale-while-revalidate=60'
22
+ # 生产要 oj 直出前端(前后端同端口的 SPA,即 ojm preview 形态)就解开下面三行,先 ojm build。
23
+ # preview 自己会注入等价挂载,平时不用动。
45
24
  # - prefix: "/"
46
- # web: "./dist/web" # 相对 config.yaml → 工程根/dist/web
25
+ # web: "./dist/web" # 相对本文件 → 工程根/dist/web
47
26
  # spa: true
48
- # 嵌套 handler 组(oj v0.1.64)无需配置开关,组织方式见 api.ts 顶部注释。
49
27
 
50
28
  server:
51
- host: 127.0.0.1 # 显式钉死:oj 默认绑 [::1],localhost 解析序在反代下有坑
29
+ host: 127.0.0.1 # 钉死 127.0.0.1:oj 默认绑 [::1],localhost 在反代下解析有坑
52
30
  port: 9778
53
31
  public_key_path: ./config/public.pem
54
32
  certificate_path: ./config/cert.jws
55
- # ---- 迁移门禁 ----
56
- # auto = dev 缺省(启动即应用);release 缺省是 verify(账本落后拒启,先 oj migrate)。
57
- # 显式写出来:dev 的隐式 DB 副作用不再靠「模式默认值」猜,release 也不会因缺省拒启。
58
- migrate_on_start: auto
59
- # ---- 入参契约(v0.1.44)----
60
- # 契约生成的 .schema 在此生效:违反即 400 且 handler 不执行。
61
- # 关掉等于退回「后端零校验」,仅在 .schema 与旧 handler 冲突时临时逃生。
62
- schema_validation: true
63
- # ---- 单 isolate 堆限额(v0.1.40,默认 256MiB,下限 32MiB)----
64
- js_heap_limit_bytes: 268435456
65
- # ---- 路由级超时(v0.1.30):pattern 按声明序首个命中,且是含 base 的全路径 ----
66
- route_timeouts: []
67
- # ---- 统一响应头(v0.1.30)----
68
- response_headers: {}
69
- # ---- 热更端点(ojm install 的 reloading 段消费;显式关闭 = 安装器 reloading 段可读失败)----
70
- # ojm:reload
71
- reload_api: true
72
- # ---- 直传路由 PUT {base}/blob/{key} 的体积上限(v0.1.30,默认 1GiB)----
73
- # 与 max_upload_bytes(multipart,默认 10MB)是两套闸,别混。
74
- blob_upload_max_bytes: 1073741824
75
- # ---- CORS(v0.1.35):缺这一段 = 不挂 CORS 层;credentials 必须配显式 origins ----
33
+ migrate_on_start: auto # auto=启动即迁移;release 建议 verify(账本落后拒启,先 oj migrate)
34
+ schema_validation: true # 契约 .schema 违反即 400;仅当 .schema 与旧 handler 冲突时临时关
35
+ js_heap_limit_bytes: 268435456 # 单 isolate 堆限额(默认 256MiB,下限 32MiB)
36
+ route_timeouts: [] # 路由级超时;pattern 按声明序首个命中,写含 base 的全路径
37
+ response_headers: {} # 统一响应头
38
+ reload_api: true # 热更端点 /ojm:reload:安装器的 reloading 段要用,关掉安装会失败
39
+ blob_upload_max_bytes: 1073741824 # PUT blob 单文件上限(默认 1GiB;与 multipart 的 10MB 是两道闸)
76
40
  cors:
77
- origins: [] # 留空 = 放行任意源;要带凭据则必须列出具体源
41
+ origins: [] # 空 = 放行任意源;要带凭据必须列具体源
78
42
  credentials: false
79
43
  db:
80
44
  default: 'sqlite://db.sqlite' # 相对 config 目录;缺文件自动建空库
81
45
 
82
- # ---- 安装序号(ojm install 每次安装自增一次,作为 reload 热更触发变更——unchanged 判定基于
83
- # config 文档 sha,无落盘变更则热更恒失败。勿删行/勿手工回写旧值。)----
84
46
  vars:
47
+ # ojm install 每次安装自增一次,用来触发 reload 热更。别删行、别手工回写旧值。
85
48
  ojm_install_seq: 0
49
+ # 站点对外地址。装方案时服务端拿它把 /cdn 这类相对地址补全成绝对 URL(深检探针也用它)。
50
+ # 故意不读请求头 Host(防伪造 Host 牵引出站):不配 = fail-closed,不会替你猜。
51
+ # canonical_origin: "https://your-site.example.com"
86
52
 
87
- # ---- 平台 exec 白名单(ojm install 的解压/迁移两处消费;模板逐字对齐 lifecycle steps 命令构造,
88
- # 不得增删——多一个 token 都会 NotCapable)----
89
53
  platform:
90
- # 平台特权模块名单:方案安装器的 reloading 段调 server.reloadConfig 触发热更。
91
- # 缺这条 → 装在最后一步报 NotCapable: module '_platform' is not platform-privileged。
92
- modules: [_platform]
93
- # 受控根 = 后端模块源码根(`src/<name>/api` 的父目录,即 src)。ojm preview 派生 config
94
- # 时会改写为 dist,保持与 `--api-path` 同源;手写部署请按实际模块根填写。
95
- root: src
96
- # ---- U6 信任库加固(P3-1;P2-2 红线 spike 结论)----
97
- # 威胁:devkit 0.1.75 语义下 fs: 段对全部模块生效(jail = fs.root),且 trusted_paths 只增不减——
98
- # 信任库落在第三方方案模块可写的 fs.root 内即可被其伪造信任锚。加固须两端配合:
99
- # ① 本模板已将信任库默认路径移出 modules/ 安装子树(见 _lifecycle 默认 `.solutions/trusted-keys.yaml`,
100
- # 落在 fs.root 之外的部署根,第三方方案模块 jail 之外);
101
- # ② **部署侧**须按 oj 语义补 `platform.trusted_paths`(指向运行时信任库目录,该目录须先存在——
102
- # oj 对不存在的 trusted_paths 装配 fail-fast,故不可在模板里硬编码尚未创建的目录)显式授权给
103
- # _platform 特权模块写信任库,并收窄第三方方案模块的 fs.root 至其安装子树,方可彻底隔离。
104
- # 本仓负责①(代码路径);②属部署/上游配置,留作部署清单项(P3-1 备案,不硬编码以免 oj serve 装配失败)。
54
+ modules: [_platform] # 安装器热更 config 需要 _platform 特权;删了会在装到最后一步报 NotCapable
55
+ root: src # 后端模块源码根(src/<name>/api 的父目录);preview 派生 config 时改写成 dist
56
+ # 信任库默认落在安装子树之外(.solutions/trusted-keys.yaml),第三方方案模块碰不到。
57
+ # 部署侧还应补 platform.trusted_paths 授权 _platform 写库、并把第三方模块 fs.root 收窄到各自安装子树。
58
+ # 下面三条命令与安装器逐字对齐(含每个空格),一个 token 都不能动:
105
59
  exec:
106
60
  _platform:
107
61
  allow:
@@ -109,18 +63,14 @@ platform:
109
63
  - 'tar -xzf ${path:archive} -C ${path:dest}'
110
64
  - 'oj migrate --format json -d ${path:dir} ${raw:module}'
111
65
 
112
- # ---- 方案安装器的落盘面(fs.* 全局)----
113
- # 安装器要解包到 modules/.staging、写 dist/modules、备份 db.sqlite——全在 config 目录内。
114
- # 缺本段 = fs.* 抛 NotCapable,且失败发生在任务文件落盘之前:表现为「POST 安装返回 200、
115
- # 但 task 查询恒 404」,排查成本极高。生产收紧口径见上面 platform 段的 U6 说明
116
- # (fs.root 收窄到安装子树 + third-party 模块 jail 隔离属部署清单项)。
66
+ # 安装器落盘面:解包到 modules/.staging、写 dist/modules、备份 db.sqlite,全在 config 目录内。
67
+ # 缺这段会 NotCapable,且症状很阴:安装提交返回 200,但 task 查询恒 404。
117
68
  fs:
118
69
  root: .
119
70
  readonly: false
120
71
 
121
- # ---- LDAP 连接段(D1/PRD §4.5 两段式之连接段;oj 插件严格解析本段,未知键启动 fail-fast——只写下列键)----
122
- # 取消注释并填值即接通目录服务;应用映射段在 DB/设置页(ldap.enabled、ldap_base_dn、
123
- # ldap_uid_attr、ldap_email_attr,group=ldap,即时生效)。本段改动属 restart_pending,需重启。
72
+ # LDAP 连接段:解开注释填值即接通目录服务;字段映射在「系统设置 → LDAP」里配,改本段要重启。
73
+ # oj 严格解析这段,只写下面这些键,多一个键启动 fail-fast。
124
74
  # ldap:
125
75
  # url: "ldap://127.0.0.1:389"
126
76
  # bind_dn: ""
@@ -128,14 +78,14 @@ fs:
128
78
  # timeout_ms: 3000
129
79
  # start_tls: false
130
80
  # tls_skip_verify: false
81
+
131
82
  auth:
132
83
  jwt_secret: __JWT_SECRET__ # init 随机生成;生产必须更换
133
84
  access_token_duration: 2h
134
85
  refresh_token_duration: 720h
135
- anonymous_paths: # auth 端点是业务路由,必须显式匿名(漏列则握手 401,见 ojm 开发手册 §8)
86
+ anonymous_paths: # 不带 token 也能访问的路径;漏列则未登录访问一律 401
136
87
  - /auth/login
137
- - /auth/ldap-login # D1/AC1-12:LDAP 目录登录(匿名)
88
+ - /auth/ldap-login # LDAP 目录登录
138
89
  - /auth/refresh
139
90
  - /auth/logout
140
- # 登录页登录方式 Tabs 数据源(C5/AC2-2):未登录即可读
141
- - /system/login-methods
91
+ - /system/login-methods # 登录页「登录方式」Tabs 的数据源,未登录就要能读
@@ -27,7 +27,7 @@ include:
27
27
  # —— 工程级文件 ——
28
28
  - README.md
29
29
  # 本地模拟 CDN 目录:只带 README(它的存在让挂载目录不缺,站点才起得来)。
30
- # 产物 *.tgz / *.sig / bundles.yaml / manifests.yaml 一律不拷、不进发布包。
30
+ # 产物 *.tgz / *.sig / catalog.yaml / shards/ 一律不拷、不进发布包。
31
31
  - cdn/README.md
32
32
  - tsconfig.json
33
33
  - global.d.ts
@@ -15,6 +15,8 @@ export interface FsFace {
15
15
  copyFile: (src: string, dest: string) => Promise<void>
16
16
  /** 列目录成员名(不含路径;不含 . / ..) */
17
17
  readdir: (path: string) => Promise<string[]>
18
+ /** 取文件体积(REG-CS8 解压后实际磁盘占用复核用;oj 真机 stat 形状略有差异,缺失则复核降级跳过) */
19
+ stat?: (path: string) => Promise<{ size: number }>
18
20
  }
19
21
 
20
22
  /**
@@ -67,6 +69,7 @@ export function fsFace(): FsFace {
67
69
  names.push(e.name);
68
70
  return names;
69
71
  }),
72
+ stat: f.stat ?? undefined,
70
73
  };
71
74
  }
72
75
 
@@ -79,6 +79,8 @@ export interface InstallRequest extends InstallContext {
79
79
  trustPath?: string
80
80
  /** Ed25519 验签面(P2-3;缺省回落运行时全局 crypto.verifyEd25519,同 verify.ts) */
81
81
  verifyEd25519?: (publicKey: string, msg: string, sigBase64: string) => boolean
82
+ /** 本站点(registry)身份(REG-CS3 v2 防跨仓库重放);安装验签时与 .sig.registry_id 比对 */
83
+ registryId?: string
82
84
  /** 迁移账本 head(rollbackGuard 真源;缺省跳过禁降版判定,同 migrate.ts) */
83
85
  ledgerHead?: number | bigint | null
84
86
  /** 注册快照查询(smoke;缺省回落 queryRegistered 的 db 全局) */
@@ -297,6 +299,11 @@ export function declOf(req: InstallRequest): SolutionDecl {
297
299
  title: typeof r.title_key === "string" ? r.title_key : "",
298
300
  permissions: Array.isArray(r.permissions) ? r.permissions.map(String) : [],
299
301
  })),
302
+ // 顶层 permissions[] 的 code:无角色权限点(系统预置点)唯一能落库的来源,别在此丢弃
303
+ permissions: (Array.isArray(m.permissions) ? m.permissions : [])
304
+ .filter(isObj)
305
+ .map(p => (typeof p.code === "string" ? p.code : ""))
306
+ .filter(Boolean),
300
307
  settings,
301
308
  routes: (Array.isArray(m.routes) ? m.routes : []).filter(isObj).map(r => ({
302
309
  path: typeof r.path === "string" ? r.path : "",
@@ -331,26 +338,56 @@ async function deployWebStage(req: InstallRequest): Promise<StageResult> {
331
338
  return deployWeb({ ...req, stagingDir: req.staging, integrity });
332
339
  }
333
340
 
341
+ /** `ojm preview` 派生的 config 文件名(与 CLI 侧 preview-config.ts 的 PREVIEW_CONFIG_NAME 逐字一致) */
342
+ const PREVIEW_CONFIG_NAME = ".ojm-preview.yaml";
343
+
344
+ /** 与 config 同目录的 preview 派生文件路径(无目录分隔符 = 同目录裸名) */
345
+ function previewSiblingOf(configPath: string): string {
346
+ const cut = Math.max(configPath.lastIndexOf("/"), configPath.lastIndexOf("\\"));
347
+ return cut < 0 ? PREVIEW_CONFIG_NAME : `${configPath.slice(0, cut + 1)}${PREVIEW_CONFIG_NAME}`;
348
+ }
349
+
350
+ /** 把「安装序号自增 + 匿名端点接线」写到一份 config 文本上;`optional` 用于派生文件不存在即跳过 */
351
+ async function patchConfigAt(configPath: string, anonymous: string[], optional = false): Promise<string[]> {
352
+ let content: string;
353
+ try {
354
+ content = new TextDecoder().decode(await fsFace().readFile(configPath));
355
+ }
356
+ catch (e) {
357
+ if (optional)
358
+ return [];
359
+ throw e;
360
+ }
361
+ const bumped = bumpInstallSeq(content);
362
+ const patched = anonymous.length > 0 ? appendAnonymousPaths(bumped.content, anonymous) : { content: bumped.content, added: [] };
363
+ await atomicWriteText(configPath, patched.content);
364
+ return patched.added;
365
+ }
366
+
334
367
  /**
335
368
  * 热更段适配(B-11 reload 触发面):先落盘触发变更再 reloadAndAssert——reloadConfig 的
336
369
  * unchanged 判定基于 config 文档 sha(oj-bin reload_bridge 真源),不落盘变更则热更恒
337
370
  * unchanged 必失败。触发变更 = vars.ojm_install_seq 自增(PRD E5 实测先例:vars 段语义值
338
371
  * 改动 applied 可热生效,无需改 mounts),经 text-edit 纯文本行操作(R18,注释/键序逐字保留)。
372
+ *
373
+ * **preview 形态(`ojm preview`)**:服务端读的是同目录派生出来的 `.ojm-preview.yaml`(spa 挂载 +
374
+ * platform.root 改写到 dist),安装器改的 `config.yaml` 它根本不看——于是 reload 按文档 sha 判
375
+ * unchanged,安装恒定卡在 reloading。所以派生文件存在时同步写一份:源 config 仍是真相(preview
376
+ * 重启后按它重建派生文件),派生文件是当前活视图,两份一起变,reload 才看得到差异。
377
+ * 派生文件不存在(dev / 生产部署形态)就只写源 config,不无中生有。
339
378
  */
340
379
  async function reloadStage(req: InstallRequest): Promise<StageResult> {
341
380
  const path = req.configPath ?? "config.yaml";
342
381
  try {
343
- const content = new TextDecoder().decode(await fsFace().readFile(path));
344
- const bumped = bumpInstallSeq(content);
345
- // C2-26 anonymous 运行时接线(P2-15③,观察项 6 收口):方案匿名端点补进
346
- // auth.anonymous_paths(幂等;热改属 restart_pending——reload 消息已明示重启生效)
347
382
  const m = isObj(req.manifest) ? req.manifest : {};
348
383
  const api = isObj(m.api) ? m.api : {};
349
384
  const anonymous = Array.isArray(api.anonymous) ? api.anonymous.filter((x): x is string => typeof x === "string") : [];
350
- const patched = anonymous.length > 0 ? appendAnonymousPaths(bumped.content, anonymous) : { content: bumped.content, added: [] };
351
- await atomicWriteText(path, patched.content);
385
+ // C2-26 anonymous 运行时接线(P2-15③,观察项 6 收口):方案匿名端点补进
386
+ // auth.anonymous_paths(幂等;热改属 restart_pending——reload 消息已明示重启生效)
387
+ const added = await patchConfigAt(path, anonymous);
388
+ await patchConfigAt(previewSiblingOf(path), anonymous, true);
352
389
  // 交接给 smoke:本次真正新增的匿名路径在重启前恒 401(restart_pending)
353
- req.deferredAnonymous = patched.added;
390
+ req.deferredAnonymous = added;
354
391
  }
355
392
  catch (e) {
356
393
  return { stage: "reloading", ok: false, retryable: true, message: `热更失败:config.yaml 安装序号自增/匿名端点接线失败(${String(e)})——reload unchanged 判定基于 config 文档 sha,无变更则热更不可能生效(B-11,PRD E5 口径)` };
@@ -0,0 +1,21 @@
1
+ // 体积上限常量(REG-CS8 红队 F1,Q13 已拍板数值)。
2
+ //
3
+ // 全仓单源:下载闸 / 解压闸 / 上传侧(S4-2)三处共用同一组数字——改一处即可调,
4
+ // 杜绝「下载侧一个值、解压侧另一个值」的散落(红队 F1 原罪)。
5
+ //
6
+ // 语义分工:
7
+ // - MAX_PACKAGE_BYTES:分发包(.tgz,已压缩)体积上限。下载闸在 fetch 之前比对索引声明的
8
+ // archive.size 直接拒,不发请求、不读入内存——先中毒后验毒的源头堵死。
9
+ // - MAX_UNPACKED_BYTES:解压后未压缩总体积上限。解压炸弹(1MB → 2GB)的主闸:tar -tvzf 清单
10
+ // 已含真实未压缩尺寸,校验阶段即拒,零解压零落盘;解压后再复核实际磁盘占用兜底。
11
+ // - MAX_ENTRIES:tar 成员条目数上限。海量小文件 / zip-bomb 的条目数维度封顶。
12
+ // - MAX_SINGLE_ENTRY_BYTES:单成员未压缩体积上限。单文件解压炸弹(一个 50MB+ 的成员)封顶。
13
+
14
+ /** 分发包(.tgz)体积上限 = 50MB */
15
+ export const MAX_PACKAGE_BYTES = 50 * 1024 * 1024;
16
+ /** 解压后未压缩总体积上限 = 200MB */
17
+ export const MAX_UNPACKED_BYTES = 200 * 1024 * 1024;
18
+ /** tar 成员条目数上限 = 5000 */
19
+ export const MAX_ENTRIES = 5000;
20
+ /** 单成员未压缩体积上限 = 50MB */
21
+ export const MAX_SINGLE_ENTRY_BYTES = 50 * 1024 * 1024;
@@ -0,0 +1,88 @@
1
+ // REG-CS2 索引新鲜度(防回放/降级):站点按源记住「见过的最大 seq」,回退即拒。
2
+ //
3
+ // 记在站点 DB(`reg_source_seq`,0014 迁移)而不记在 CLI:受害者是**站点**——镜像把旧索引
4
+ // 回放给谁,谁就是被降级的一方(D1 已定,CLI/API 一致)。CLI 侧 `ojm market verify` 是
5
+ // 离线单点校验,没有「上次见过的 seq」,故不做这项判定。
6
+ //
7
+ // fail-closed 口径(与 REG-CS1 同源):
8
+ // - 读不到 / 读到非正整数 / 写入失败 —— 一律按 `index_state_corrupt` 拒,不静默重置。
9
+ // 静默重置等于把「防回放」降级成「每次都当第一次见」,攻击者先破坏状态再回放即穿。
10
+ // - 表不存在同样算读不到:迁移由 _platform 自带,起不来就该响,不该悄悄放行。
11
+
12
+ // db 为 oj isolate 注入全局(同模板 service 层惯例,history.ts 同款;测试由夹具装 globalThis.db)
13
+ declare const db: { table: (name: string) => any };
14
+
15
+ export const SOURCE_SEQ_TABLE = "reg_source_seq";
16
+
17
+ /** 判定:ok = 放行并已记录;!ok = 拒,`code` 区分「回放/降级」与「状态损坏」 */
18
+ export type SourceSeqVerdict = {
19
+ ok: true
20
+ prev: number | null
21
+ } | {
22
+ ok: false
23
+ code: "index_stale" | "index_state_corrupt"
24
+ prev: number | null
25
+ };
26
+
27
+ /**
28
+ * 比对并推进某个源的最大 seq。
29
+ *
30
+ * - 首次见(无行)→ 记录并放行;
31
+ * - 同 seq 重复拉取 → 放行(幂等,不误伤正常重复请求),不重复写;
32
+ * - seq 回退(< 记录值)→ `index_stale` 拒,且不覆盖记录(不能让回放把水位拉低)。
33
+ */
34
+ export async function guardAndRecordSourceSeq(source: string, seq: number): Promise<SourceSeqVerdict> {
35
+ const prev = await readSourceSeq(source);
36
+ if (prev === "corrupt")
37
+ return { ok: false, code: "index_state_corrupt", prev: null };
38
+ if (prev !== null && seq < prev)
39
+ return { ok: false, code: "index_stale", prev };
40
+ if (prev === seq)
41
+ return { ok: true, prev };
42
+ try {
43
+ await writeSourceSeq(source, seq);
44
+ }
45
+ catch {
46
+ // 写失败按 fail-closed 转「状态损坏」——不静默重置(否则防回放被降级成「每次都当第一次见」)
47
+ return { ok: false, code: "index_state_corrupt", prev: null };
48
+ }
49
+ return { ok: true, prev };
50
+ }
51
+
52
+ /** 读取记录:无行 → null;读不到或非正整数 → "corrupt"(bigint 是 pg/mysql 的整型口径,一并认) */
53
+ export async function readSourceSeq(source: string): Promise<number | null | "corrupt"> {
54
+ let rows: Array<{ max_seq?: unknown }>;
55
+ try {
56
+ rows = await db.table(SOURCE_SEQ_TABLE)
57
+ .select(["max_seq"])
58
+ .where({ field: "source_url", op: "eq", value: source })
59
+ .limit(1)
60
+ .all() as Array<{ max_seq?: unknown }>;
61
+ }
62
+ catch {
63
+ return "corrupt";
64
+ }
65
+ if (!Array.isArray(rows) || rows.length === 0)
66
+ return null;
67
+ const raw = rows[0]?.max_seq;
68
+ if (typeof raw === "bigint")
69
+ return raw >= 1n ? Number(raw) : "corrupt";
70
+ if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1)
71
+ return raw;
72
+ return "corrupt";
73
+ }
74
+
75
+ /**
76
+ * 记录某源的最大 seq:先 insert(首见),撞唯一键(并发首见同源)则降级为 update。
77
+ * 这样消除「SELECT 再 INSERT」的 TOCTOU——并发首装同源不再因唯一键冲突被误判成
78
+ * index_state_corrupt;任何写失败仍向上抛,由 guard 按 fail-closed 转成 index_state_corrupt。
79
+ */
80
+ export async function writeSourceSeq(source: string, seq: number): Promise<void> {
81
+ const now = Math.floor(Date.now() / 1000);
82
+ try {
83
+ await db.table(SOURCE_SEQ_TABLE).insert({ source_url: source, max_seq: seq, updated_at: now }).run();
84
+ }
85
+ catch {
86
+ await db.table(SOURCE_SEQ_TABLE).update({ max_seq: seq, updated_at: now }).where({ field: "source_url", op: "eq", value: source }).run();
87
+ }
88
+ }
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type { InstallContext } from "./preflight";
8
8
  import { fsFace } from "../atomic";
9
+ import { MAX_PACKAGE_BYTES } from "../limits";
9
10
 
10
11
  /** 最小 fetch 面(结构化:Node fetch/Response 天然满足;测试注入 stub 不真跑网络) */
11
12
  export type FetchFace = (url: string) => Promise<{
@@ -28,6 +29,8 @@ export interface StageResult {
28
29
  ok: boolean
29
30
  message: string
30
31
  retryable?: boolean
32
+ /** 终止态错误码(installer 据此置 task.error.reason);体积闸门命中恒 package_too_large */
33
+ code?: string
31
34
  }
32
35
 
33
36
  /**
@@ -62,6 +65,18 @@ export async function download(ctx: DownloadContext): Promise<StageResult> {
62
65
  return { stage: "downloading", ok: false, retryable: false, message: `下载失败:archive.file「${arch.file}」须逐字为 <name>-<version>.tgz(「${expected}」,P2-11 索引投毒加固)` };
63
66
  }
64
67
  const dest = `${ctx.staging}/${arch.file}`;
68
+ // REG-CS8 下载闸(红队 F1):先比对索引声明的 archive.size,超限直接拒——不发请求、不读入内存,
69
+ // 把「先中毒后验毒」的源头堵死(限量值全仓单源,与解压闸/上传侧共用 MAX_PACKAGE_BYTES)。
70
+ const declaredSize = arch.archive.size;
71
+ if (typeof declaredSize === "number" && Number.isFinite(declaredSize) && declaredSize > MAX_PACKAGE_BYTES) {
72
+ return {
73
+ stage: "downloading",
74
+ ok: false,
75
+ retryable: false,
76
+ code: "package_too_large",
77
+ message: `下载失败:分发包声明体积 ${declaredSize} 字节超过上限 ${MAX_PACKAGE_BYTES}(REG-CS8 package_too_large)——拒绝下载以省带宽与内存,请发布方拆分或压缩后重试`,
78
+ };
79
+ }
65
80
  try {
66
81
  const res = await ctx.fetch(ctx.archiveUrl);
67
82
  if (!res.ok) {
@@ -9,7 +9,8 @@
9
9
 
10
10
  import type { InstallContext } from "./preflight";
11
11
  import { fsFace } from "../atomic";
12
- import { validateTarListing } from "../tgz-guard";
12
+ import { MAX_UNPACKED_BYTES } from "../limits";
13
+ import { checkTarVolumeLimits, validateTarListing } from "../tgz-guard";
13
14
  import { archiveFileOf } from "./download";
14
15
 
15
16
  export interface ExecResult {
@@ -49,6 +50,8 @@ export interface StageResult {
49
50
  ok: boolean
50
51
  message: string
51
52
  retryable?: boolean
53
+ /** 终止态错误码(installer 据此置 task.error.reason);体积闸门命中恒为体积类 code */
54
+ code?: string
52
55
  }
53
56
 
54
57
  function isObj(v: unknown): v is Record<string, unknown> {
@@ -94,6 +97,17 @@ export async function extract(ctx: ExtractContext): Promise<StageResult> {
94
97
  message: `解压失败:包内容未通过安全校验(R1:offender「${v.offender}」为越界/链接成员)——零解压零落盘,附录 A extracting:若反复出现请勿使用该分发包`,
95
98
  };
96
99
  }
100
+ // 第二步(续)REG-CS8 体积闸门:复用同一份清单累加,零额外 IO、零解压前即拒
101
+ const vol = checkTarVolumeLimits(lines);
102
+ if (!vol.ok) {
103
+ return {
104
+ stage: "extracting",
105
+ ok: false,
106
+ retryable: false,
107
+ code: vol.code,
108
+ message: `解压失败:包内容超出体积上限(REG-CS8:${vol.code},offender「${vol.offender}」)——零解压零落盘,附录 A extracting:请发布方拆分或压缩后重试`,
109
+ };
110
+ }
97
111
  // 第三步 extract:校验通过才落盘;-C staging 就位即止,rename 由 P1-10 落锁段接管
98
112
  const extracted = await ctx.exec.run(buildExtractCmd(file, ctx.staging));
99
113
  if (extracted.code !== 0) {
@@ -109,7 +123,19 @@ export async function extract(ctx: ExtractContext): Promise<StageResult> {
109
123
  if (!c31.ok) {
110
124
  return { stage: "extracting", ok: false, retryable: false, message: c31.message };
111
125
  }
112
- return { stage: "extracting", ok: true, message: `解压完成:${lines.length} 个成员 → ${ctx.staging}/(单根已归平,§2.9 布局消费点;C2-31 复核通过;目录 rename 由 P1-10 落锁段接管)` };
126
+ // REG-CS8 解压后复核:清单尺寸可能由畸形 tar 瞒报(解压炸弹绕过清单口径),以实际磁盘占用兜底。
127
+ // 仅当 stat 面可用时执行(oj 真机提供;测试/缺失则降级跳过,LIST 闸已先拦)。
128
+ const recheck = await recheckStagingOccupancy(ctx);
129
+ if (!recheck.ok) {
130
+ return {
131
+ stage: "extracting",
132
+ ok: false,
133
+ retryable: false,
134
+ code: "unpacked_too_large",
135
+ message: `解压失败:实际磁盘占用 ${recheck.bytes} 字节超过上限 ${MAX_UNPACKED_BYTES}(REG-CS8 解压后复核:清单尺寸瞒报,已清场)——附录 A extracting:请发布方拆分或压缩后重试`,
136
+ };
137
+ }
138
+ return { stage: "extracting", ok: true, message: `解压完成:${lines.length} 个成员 → ${ctx.staging}/(单根已归平,§2.9 布局消费点;C2-31 复核通过;REG-CS8 解压后复核通过;目录 rename 由 P1-10 落锁段接管)` };
113
139
  }
114
140
  catch (e) {
115
141
  return { stage: "extracting", ok: false, retryable: true, message: `解压失败:${String(e)}——附录 A extracting(S1-2 exec.run tar 三步范式)` };
@@ -239,3 +265,36 @@ export async function flattenBundleRoot(staging: string): Promise<void> {
239
265
  await f.remove(root, { recursive: true });
240
266
  }
241
267
  }
268
+
269
+ /**
270
+ * REG-CS8 解压后实际磁盘占用复核(解压炸弹绕过清单口径的兜底):递归累加 staging 全部文件体积。
271
+ * 仅当 stat 面可用时执行;读盘失败/缺面 → 降级跳过(LIST 闸已先拦,不误杀正常包)。
272
+ * 注:staging 顶层的 .tgz 一并计入(已压缩,≤ MAX_PACKAGE_BYTES,对 200MB 阈值保守不影响正常包)。
273
+ */
274
+ async function recheckStagingOccupancy(ctx: ExtractContext): Promise<{ ok: true } | { ok: false, bytes: number }> {
275
+ const f = fsFace();
276
+ if (f.stat === undefined)
277
+ return { ok: true };
278
+ let total = 0;
279
+ const walk = async (dir: string): Promise<void> => {
280
+ const names = await f.readdir(dir);
281
+ for (const name of names) {
282
+ const p = `${dir}/${name}`;
283
+ try {
284
+ await f.readdir(p); // 失败 = 普通文件(与 flattenBundleRoot 同手法)
285
+ }
286
+ catch {
287
+ total += (await f.stat!(p)).size;
288
+ continue;
289
+ }
290
+ await walk(p);
291
+ }
292
+ };
293
+ try {
294
+ await walk(ctx.staging);
295
+ }
296
+ catch {
297
+ return { ok: true };
298
+ }
299
+ return total > MAX_UNPACKED_BYTES ? { ok: false, bytes: total } : { ok: true };
300
+ }