@sema-agent/settings-schema 10.1.0 → 11.0.0

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 (235) hide show
  1. package/CHANGELOG.md +419 -0
  2. package/README.md +141 -65
  3. package/bin/settings-schema.mjs +13 -6
  4. package/conformance/VERSION +1 -1
  5. package/conformance/baseline.json +3 -1
  6. package/conformance/cases/c01-entry-salvage-then-typo/expected.json +3 -1
  7. package/conformance/cases/c02-hosts-clamp-stacked/expected.json +12 -5
  8. package/conformance/cases/c02-hosts-clamp-stacked/meta.json +1 -1
  9. package/conformance/cases/c03-literal-dotted-top-key/expected.json +22 -2
  10. package/conformance/cases/c04-all-bad-collection-not-salvaged/expected.json +3 -1
  11. package/conformance/cases/c06-legacy-governance-lift/expected.json +11 -101
  12. package/conformance/cases/c06-legacy-governance-lift/meta.json +1 -1
  13. package/conformance/cases/c07-hooks-is-not-a-config-domain/expected.json +3 -1
  14. package/conformance/cases/c08-limits-unknown-carried-64/expected.json +203 -2
  15. package/conformance/cases/c09-limits-unknown-carried-65/expected.json +204 -2
  16. package/conformance/cases/c10-record-key-secret-redacted-twice/expected.json +3 -1
  17. package/conformance/cases/c11-unicode-unknown-keys/expected.json +20 -2
  18. package/conformance/cases/c12-prototype-name-top-keys/expected.json +3 -1
  19. package/conformance/cases/c13-models-field-null/expected.json +3 -1
  20. package/conformance/cases/c14-models-field-empty-array/expected.json +6 -1
  21. package/conformance/cases/c15-models-field-number/expected.json +3 -1
  22. package/conformance/cases/c16-deep-nesting-205/expected.json +14 -2
  23. package/conformance/cases/c17-empty-domain-file/expected.json +3 -1
  24. package/conformance/cases/c18-wire-envelope-not-an-object/expected.json +3 -1
  25. package/conformance/cases/c19-wire-envelope-no-version/expected.json +3 -1
  26. package/conformance/cases/c20-governance-absent-lifts/expected.json +11 -86
  27. package/conformance/cases/c20-governance-absent-lifts/meta.json +1 -1
  28. package/conformance/cases/c21-governance-empty-object-wins/expected.json +6 -5
  29. package/conformance/cases/c21-governance-empty-object-wins/input.json +1 -4
  30. package/conformance/cases/c21-governance-empty-object-wins/meta.json +1 -1
  31. package/conformance/cases/c23-mixed-validity-models/expected.json +3 -1
  32. package/conformance/cases/c24-valid-entries-with-unknown-keys/expected.json +22 -2
  33. package/conformance/cases/c25-published-roundtrip-zero-warnings/expected.json +6 -1
  34. package/conformance/cases/c26-hosts-array-1024/expected.json +3 -1
  35. package/conformance/cases/c27-hosts-array-1025/expected.json +6 -7175
  36. package/conformance/cases/c27-hosts-array-1025/meta.json +1 -1
  37. package/conformance/cases/c28-large-document-over-1mb/expected.json +14 -2
  38. package/conformance/cases/c29-salvage-then-domain-refine-fails/expected.json +3 -1
  39. package/conformance/cases/c30-version-triple-a-first/expected.json +6 -1
  40. package/conformance/cases/c31-version-triple-b-changed/expected.json +6 -1
  41. package/conformance/cases/c32-version-triple-a-restored/expected.json +6 -1
  42. package/conformance/cases/c33-limits-nested-carried-key/expected.json +18 -2
  43. package/conformance/cases/c34-skills-content-vs-manifest/expected.json +19 -3
  44. package/conformance/cases/c35-portable-domain-file-is-null/expected.json +3 -1
  45. package/conformance/cases/c36-skills-entry-without-content/expected.json +6 -2
  46. package/conformance/cases/c37-skills-field-null/expected.json +6 -2
  47. package/conformance/cases/c38-remote-exec-is-a-non-domain-file/expected.json +3 -1
  48. package/conformance/cases/c39-hidden-file-in-config-d/expected.json +3 -1
  49. package/conformance/cases/c40-s712-identity-only-what-reads-out/expected.json +11 -4
  50. package/conformance/cases/c41-s792-encoded-pem-identity/expected.json +9 -3
  51. package/conformance/cases/c42-s792-unknown-key-secret-redacted/expected.json +39 -2
  52. package/conformance/cases/c43-s792-form-and-deep-encoded-pem/expected.json +4 -1
  53. package/conformance/cases/c44-s792-utf8-escape-glued-token/expected.json +3 -1
  54. package/conformance/cases/c45-s792-identity-bounds-and-decode-depth/expected.json +19 -10
  55. package/conformance/cases/c45-s792-identity-bounds-and-decode-depth/meta.json +1 -1
  56. package/conformance/cases/{c46-s811-host-name-url-userinfo-characterization → c46-s811-host-name-url-userinfo-withheld}/expected.json +12 -5
  57. package/conformance/cases/c46-s811-host-name-url-userinfo-withheld/meta.json +9 -0
  58. package/conformance/cases/{c47-s811-roles-key-url-userinfo-characterization → c47-s811-roles-key-url-userinfo-withheld}/expected.json +23 -3
  59. package/conformance/cases/{c47-s811-roles-key-url-userinfo-characterization → c47-s811-roles-key-url-userinfo-withheld}/input.json +6 -0
  60. package/conformance/cases/c47-s811-roles-key-url-userinfo-withheld/meta.json +10 -0
  61. package/conformance/cases/c48-s935-model-headers-transported/expected.json +6 -1
  62. package/conformance/cases/c49-s935-model-headers-secret-entry-dropped/expected.json +3 -1
  63. package/conformance/cases/c50-s784-lane-host-legal/expected.json +3 -1
  64. package/conformance/cases/c51-s784-lane-e2b-legal/expected.json +3 -1
  65. package/conformance/cases/c52-s784-lane-k8s-legal/expected.json +3 -1
  66. package/conformance/cases/c53-s784-lane-ssh-legal/expected.json +3 -1
  67. package/conformance/cases/c54-s784-lane-adb-legal/expected.json +3 -1
  68. package/conformance/cases/c55-s784-lane-local-docker-legal/expected.json +3 -1
  69. package/conformance/cases/c56-s784-lane-device-legal/expected.json +3 -1
  70. package/conformance/cases/c57-s784-g1-enabled-no-lane/expected.json +3 -1
  71. package/conformance/cases/c58-s784-g2-multitenant-host/expected.json +3 -1
  72. package/conformance/cases/c59-s784-g3-k8s-missing-image/expected.json +3 -1
  73. package/conformance/cases/c60-s784-g3-e2b-missing-key/expected.json +3 -1
  74. package/conformance/cases/c61-s784-g3-other-lane-key/expected.json +3 -1
  75. package/conformance/cases/c62-s784-g7a-session-tidb/expected.json +3 -1
  76. package/conformance/cases/c63-s784-host-two-replicas/expected.json +3 -1
  77. package/conformance/cases/c64-s784-multitenant-local-store/expected.json +3 -1
  78. package/conformance/cases/c65-s784-pg-without-db/expected.json +3 -1
  79. package/conformance/cases/c66-s784-memory-backend-mismatch/expected.json +3 -1
  80. package/conformance/cases/c67-s784-retired-field-loud/expected.json +3 -1
  81. package/conformance/cases/c67-s784-retired-field-loud/meta.json +1 -1
  82. package/conformance/cases/c68-s784-device-local-store/expected.json +3 -1
  83. package/conformance/cases/c69-deep-encoded-token-past-every-reading/expected.json +3 -1
  84. package/conformance/cases/c70-rewritten-token-in-front-of-encoded-pem-header/expected.json +3 -1
  85. package/conformance/cases/c71-one-scanner-write-reads-display-forms/expected.json +119 -0
  86. package/conformance/cases/c71-one-scanner-write-reads-display-forms/input.json +24 -0
  87. package/conformance/cases/c71-one-scanner-write-reads-display-forms/meta.json +10 -0
  88. package/conformance/cases/c72-dangling-reference-write-seat-refuses/expected.json +111 -0
  89. package/conformance/cases/c72-dangling-reference-write-seat-refuses/input.json +26 -0
  90. package/conformance/cases/c72-dangling-reference-write-seat-refuses/meta.json +6 -0
  91. package/conformance/cases/cc01-mcp-toolfaces-non-object/expected.json +3 -1
  92. package/conformance/cases/cc02-mcp-source-key-is-transported/expected.json +3 -1
  93. package/conformance/cases/cc03-model-compat-unknown-subkey/expected.json +6 -1
  94. package/conformance/cases/cli01-model-name-nfc-vs-nfd/expected.json +11 -1
  95. package/conformance/cases/cli02-control-and-zero-width-chars/expected.json +11 -1
  96. package/conformance/cases/cli03-credential-pasted-into-unknown-key/expected.json +16 -2
  97. package/conformance/cases/cli04-env-value-with-newline/expected.json +3 -1
  98. package/conformance/cases/cli05-long-and-prefix-colliding-names/expected.json +6 -1
  99. package/conformance/cases/t01-absent-domain-key-not-null/expected.json +3 -1
  100. package/conformance/cases/t03-prototype-name-key-not-swallowed/expected.json +29 -2
  101. package/conformance/cases/t04-secret-literal-never-in-warning-value/expected.json +3 -1
  102. package/conformance/cases/z01-every-domain-empty-object/expected.json +8 -4
  103. package/conformance/cases/z01-every-domain-empty-object/input.json +0 -1
  104. package/conformance/cases/z01-every-domain-empty-object/meta.json +1 -1
  105. package/conformance/cases/z02-every-domain-unknown-key/expected.json +163 -23
  106. package/conformance/cases/z02-every-domain-unknown-key/input.json +0 -3
  107. package/conformance/cases/z02-every-domain-unknown-key/meta.json +1 -1
  108. package/dist/api/auth-bridge.d.ts +51 -181
  109. package/dist/api/auth-bridge.js +58 -33
  110. package/dist/api/auth.d.ts +37 -146
  111. package/dist/api/auth.js +16 -26
  112. package/dist/api/feedback.d.ts +87 -0
  113. package/dist/api/feedback.js +66 -0
  114. package/dist/api/pull-token.d.ts +32 -0
  115. package/dist/api/pull-token.js +88 -0
  116. package/dist/api/scopes.d.ts +82 -174
  117. package/dist/api/scopes.js +23 -18
  118. package/dist/bundle.d.ts +15 -7
  119. package/dist/bundle.js +28 -27
  120. package/dist/cli.d.ts +3 -1
  121. package/dist/cli.js +132 -81
  122. package/dist/config-d-file.d.ts +14 -0
  123. package/dist/config-d-file.js +67 -0
  124. package/dist/config-fns.d.ts +87 -186
  125. package/dist/config-fns.js +207 -262
  126. package/dist/conformance/contract-adapter.d.ts +9 -2
  127. package/dist/conformance/contract-adapter.js +15 -7
  128. package/dist/conformance/corpus.d.ts +3 -0
  129. package/dist/conformance/corpus.js +5 -0
  130. package/dist/conformance/derive.d.ts +3 -0
  131. package/dist/conformance/index.d.ts +2 -2
  132. package/dist/conformance/index.js +2 -2
  133. package/dist/conformance/run.js +67 -22
  134. package/dist/conformance/types.d.ts +31 -15
  135. package/dist/conformance/types.js +5 -0
  136. package/dist/conformance/warnings.d.ts +1 -1
  137. package/dist/conformance/warnings.js +1 -4
  138. package/dist/cross-domain.d.ts +66 -22
  139. package/dist/cross-domain.js +164 -93
  140. package/dist/cross-session.d.ts +29 -11
  141. package/dist/default-model.d.ts +43 -0
  142. package/dist/default-model.js +34 -0
  143. package/dist/execution-lane-index.d.ts +4 -8
  144. package/dist/execution-lane-index.js +4 -8
  145. package/dist/execution-lanes.d.ts +31 -8
  146. package/dist/execution-lanes.js +23 -5
  147. package/dist/file-edit.js +5 -2
  148. package/dist/file-store.d.ts +7 -6
  149. package/dist/file-store.js +37 -50
  150. package/dist/fleet.d.ts +364 -762
  151. package/dist/fleet.js +277 -224
  152. package/dist/hooks.d.ts +1151 -4737
  153. package/dist/hooks.js +59 -15
  154. package/dist/index.d.ts +10 -4
  155. package/dist/index.js +13 -7
  156. package/dist/integer.d.ts +11 -0
  157. package/dist/integer.js +11 -0
  158. package/dist/issue-path.d.ts +10 -0
  159. package/dist/issue-path.js +10 -0
  160. package/dist/loopback.d.ts +26 -0
  161. package/dist/loopback.js +54 -0
  162. package/dist/migration-receipt.d.ts +42 -0
  163. package/dist/migration-receipt.js +12 -0
  164. package/dist/one-time-migrations.d.ts +56 -0
  165. package/dist/one-time-migrations.js +819 -0
  166. package/dist/prompts-digest.d.ts +1 -1
  167. package/dist/prompts-digest.js +3 -2
  168. package/dist/reader.d.ts +2 -2
  169. package/dist/remote-exec.d.ts +45 -187
  170. package/dist/remote-exec.js +13 -7
  171. package/dist/resolve-roster.d.ts +18 -3
  172. package/dist/resolve-roster.js +28 -25
  173. package/dist/retired-envs.d.ts +24 -0
  174. package/dist/retired-envs.js +114 -0
  175. package/dist/retired-form.d.ts +31 -0
  176. package/dist/retired-form.js +28 -0
  177. package/dist/safety-merge-spec.d.ts +131 -191
  178. package/dist/safety-merge-spec.js +18 -14
  179. package/dist/scheduler-lifetime.d.ts +5 -0
  180. package/dist/scheduler-lifetime.js +7 -0
  181. package/dist/scheduler-store-migration.d.ts +14 -0
  182. package/dist/scheduler-store-migration.js +14 -0
  183. package/dist/scheduler-store-node.d.ts +24 -9
  184. package/dist/scheduler-store-node.js +25 -15
  185. package/dist/scheduler-store.d.ts +47 -14
  186. package/dist/scheduler-store.js +32 -61
  187. package/dist/secret-refs.d.ts +3 -3
  188. package/dist/secret-refs.js +1 -1
  189. package/dist/secret-scan.d.ts +14 -24
  190. package/dist/secret-scan.js +45 -23
  191. package/dist/sha256-core.d.ts +16 -0
  192. package/dist/sha256-core.js +138 -0
  193. package/dist/sha256.d.ts +6 -1
  194. package/dist/sha256.js +8 -98
  195. package/dist/skills-manifest.js +1 -1
  196. package/dist/types.d.ts +1501 -13536
  197. package/dist/types.js +494 -438
  198. package/dist/url-shape.d.ts +34 -0
  199. package/dist/url-shape.js +61 -0
  200. package/dist/web-admin-credential.d.ts +96 -0
  201. package/dist/{center-credential.js → web-admin-credential.js} +46 -43
  202. package/dist/web-admin-env.d.ts +33 -0
  203. package/dist/web-admin-env.js +33 -0
  204. package/dist/worker-row-verdict.d.ts +16 -0
  205. package/dist/worker-row-verdict.js +31 -0
  206. package/dist/workers-rows.d.ts +34 -0
  207. package/dist/workers-rows.js +42 -0
  208. package/dist/zod-unwrap.d.ts +11 -0
  209. package/dist/zod-unwrap.js +25 -0
  210. package/package.json +17 -9
  211. package/conformance/cases/c46-s811-host-name-url-userinfo-characterization/meta.json +0 -6
  212. package/conformance/cases/c47-s811-roles-key-url-userinfo-characterization/meta.json +0 -6
  213. package/conformance/migrate-workers/m01-g5-k8s-env-lifted/expected.json +0 -49
  214. package/conformance/migrate-workers/m01-g5-k8s-env-lifted/input.json +0 -26
  215. package/conformance/migrate-workers/m01-g5-k8s-env-lifted/meta.json +0 -4
  216. package/conformance/migrate-workers/m02-g5-idempotent/expected.json +0 -38
  217. package/conformance/migrate-workers/m02-g5-idempotent/input.json +0 -33
  218. package/conformance/migrate-workers/m02-g5-idempotent/meta.json +0 -5
  219. package/conformance/migrate-workers/m03-g5-refused-no-lane/expected.json +0 -71
  220. package/conformance/migrate-workers/m03-g5-refused-no-lane/input.json +0 -42
  221. package/conformance/migrate-workers/m03-g5-refused-no-lane/meta.json +0 -4
  222. package/conformance/migrate-workers/m04-g7b-session-tidb-lifted/expected.json +0 -52
  223. package/conformance/migrate-workers/m04-g7b-session-tidb-lifted/input.json +0 -30
  224. package/conformance/migrate-workers/m04-g7b-session-tidb-lifted/meta.json +0 -4
  225. package/conformance/migrate-workers/m05-row-verdict-reserved-env/expected.json +0 -82
  226. package/conformance/migrate-workers/m05-row-verdict-reserved-env/input.json +0 -68
  227. package/conformance/migrate-workers/m05-row-verdict-reserved-env/meta.json +0 -4
  228. package/dist/center-credential.d.ts +0 -116
  229. package/dist/issue-text.d.ts +0 -10
  230. package/dist/issue-text.js +0 -8
  231. package/dist/migrate-workers.d.ts +0 -71
  232. package/dist/migrate-workers.js +0 -351
  233. package/dist/migrate.d.ts +0 -255
  234. package/dist/migrate.js +0 -542
  235. /package/conformance/cases/{c46-s811-host-name-url-userinfo-characterization → c46-s811-host-name-url-userinfo-withheld}/input.json +0 -0
@@ -1,9 +1,9 @@
1
1
  /**
2
- * `api/scopes` — the MULTI-TENANT SCOPE wire contract for the registry (/api/v1/scopes* +
3
- * /api/v1/auth/scope). Frozen here so sema-registry (the implementation), the orchestrator, workers and
2
+ * `api/scopes` — the MULTI-TENANT SCOPE wire contract for web-admin (/api/v1/scopes* +
3
+ * /api/v1/auth/scope). Frozen here so web-admin (the implementation), the orchestrator, workers and
4
4
  * the sema shell import ONE source of truth — same "契约先行" discipline as `api/auth`.
5
5
  *
6
- * THE SCOPE MODEL (normative — the registry implements EXACTLY this):
6
+ * THE SCOPE MODEL (normative — web-admin implements EXACTLY this):
7
7
  *
8
8
  * - A **scope** is a tenant boundary: `{id, name, createdAt}`. `id` is a slug (SCOPE_ID_REGEX).
9
9
  * The reserved id `"global"` (GLOBAL_SCOPE) is the DEFAULT scope: it always exists, is never
@@ -13,10 +13,10 @@
13
13
  * and `viewer` in scope B.
14
14
  * - The **instance-level** `users.role` is unchanged and orthogonal: an instance `admin` administers
15
15
  * ALL scopes — inside any scope their effective role is always `"admin"` (see effectiveScopeRole).
16
- * - The **access JWT** gains an OPTIONAL `scope` claim (SCOPE_CLAIM) binding the token to its active
17
- * scope. A token WITHOUT the claim means `"global"` (tokenScopeOf) — full backward compatibility:
18
- * every pre-scope token keeps working, scoped to global. The refresh token is NOT scope-bound;
19
- * refresh re-mints per resolveTokenScope, and the client re-switches if it wants another scope.
16
+ * - The **access JWT** carries a `scope` claim (SCOPE_CLAIM) binding the token to its active scope —
17
+ * REQUIRED on read: a token without it is refused (readTokenScope ⇒ `scope_claim_missing`, answered 401);
18
+ * it is not read as `"global"`. The refresh token is NOT scope-bound; refresh re-mints per
19
+ * resolveTokenScope (always with the claim), and the client re-switches if it wants another scope.
20
20
  *
21
21
  * ENDPOINTS (paths in SCOPES_V1_PATHS / scopeMembersPath):
22
22
  *
@@ -37,13 +37,14 @@
37
37
  *
38
38
  * WIRE CASING: /api/v1/auth/scope is part of the OAuth face — snake_case on the wire (access_token,
39
39
  * expires_in), matching `api/auth`. The /api/v1/scopes management face uses this package's camelCase
40
- * (createdAt, scopeId) — it is a registry management API, not an OAuth endpoint.
40
+ * (createdAt, scopeId) — it is a web-admin management API, not an OAuth endpoint.
41
41
  *
42
42
  * Pure zod contract + pure decision functions — zero IO, browser-safe. The decision functions
43
- * (resolveTokenScope / canSwitchScope / effectiveScopeRole) are THE semantics: the registry
43
+ * (resolveTokenScope / canSwitchScope / effectiveScopeRole) are THE semantics: web-admin
44
44
  * implementation imports and calls these — it must not re-implement them.
45
45
  */
46
46
  import { z } from "zod";
47
+ import { INTEGER } from "../integer.js";
47
48
  import { USER_ROLES } from "../config-fns.js";
48
49
  import { ACCESS_TOKEN_TTL_SECONDS } from "./auth.js";
49
50
  // ── constants ────────────────────────────────────────────────────────────────────────────────────────────
@@ -53,14 +54,15 @@ export const GLOBAL_SCOPE = "global";
53
54
  export const RESERVED_SCOPE_IDS = [GLOBAL_SCOPE];
54
55
  /** Slug shape for scope ids: lowercase alphanumeric + inner dashes, 1–64 chars, no leading/trailing dash. */
55
56
  export const SCOPE_ID_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
56
- /** The scope endpoints, relative to the registry origin. `members` needs the scope id — use scopeMembersPath. */
57
+ /** The scope endpoints, relative to web-admin origin. `members` needs the scope id — use scopeMembersPath. */
57
58
  export const SCOPES_V1_PATHS = {
58
59
  /** GET (list) + POST (create). */
59
60
  scopes: "/api/v1/scopes",
60
61
  /** POST — mint a new access token bound to another scope. */
61
62
  authScope: "/api/v1/auth/scope",
62
63
  };
63
- /** The JWT claim carrying the token's active scope. OPTIONAL — absent means GLOBAL_SCOPE (tokenScopeOf). */
64
+ /** The JWT claim carrying the token's active scope. Every minted access token carries it; a token without it is refused on read
65
+ * ({@link readTokenScope}). */
64
66
  export const SCOPE_CLAIM = "scope";
65
67
  /** `/api/v1/scopes/{id}/members` for PUT (upsert) and DELETE (remove). */
66
68
  export function scopeMembersPath(scopeId) {
@@ -124,7 +126,7 @@ export const ScopeSwitchResponse = z
124
126
  access_token: z.string().min(1),
125
127
  token_type: z.literal("Bearer"),
126
128
  /** Access-token lifetime in seconds (ACCESS_TOKEN_TTL_SECONDS — same mint as api/auth). */
127
- expires_in: z.number().int().positive(),
129
+ expires_in: INTEGER.positive(),
128
130
  scope: ScopeId,
129
131
  role: ScopeRole,
130
132
  })
@@ -177,7 +179,7 @@ export function effectiveScopeRole(membershipRole, instanceRole) {
177
179
  }
178
180
  /**
179
181
  * THE token-minting scope decision — which scope (and role claim) a fresh access token binds to, given
180
- * the principal's scope memberships and instance role. Deterministic; the registry (Node/Next) imports
182
+ * the principal's scope memberships and instance role. Deterministic; web-admin (Node/Next) imports
181
183
  * and calls this at every mint (device-flow grant AND refresh). NORMATIVE RULES (verbatim, 任务书拍定):
182
184
  *
183
185
  * 1. **唯一成员 scope** — exactly one membership → bind that scope, role = that membership's effective
@@ -218,11 +220,14 @@ export function canSwitchScope(memberships, instanceRole, targetScope) {
218
220
  return true;
219
221
  return memberships.some((m) => m.scopeId === targetScope);
220
222
  }
223
+ /** Why {@link readTokenScope} refuses — a CLOSED set (one word today). */
224
+ export const TOKEN_SCOPE_REFUSALS = ["scope_claim_missing"];
221
225
  /**
222
- * Read the active scope off verified JWT claims: a non-empty string `scope` claim, else GLOBAL_SCOPE.
223
- * This is the ONE place the "missing claim = global" backward-compat rule lives — workers and the
224
- * registry both call this instead of defaulting ad hoc.
226
+ * Read the active scope off verified JWT claims — the ONE reader. A non-empty string `scope` claim ⇒ `{ scope }`; a claim that is
227
+ * missing, empty or not a string ⇒ `{ refused: "scope_claim_missing" }` (the reader answers 401). There is no default scope here:
228
+ * every access token web-admin mints carries the claim, an access token lives at most {@link ACCESS_TOKEN_TTL_SECONDS}, and a refresh
229
+ * re-mints one with the claim — a token without it is not a token this contract reads (it used to be read as `"global"`).
225
230
  */
226
- export function tokenScopeOf(claims) {
227
- return typeof claims.scope === "string" && claims.scope.length > 0 ? claims.scope : GLOBAL_SCOPE;
231
+ export function readTokenScope(claims) {
232
+ return typeof claims.scope === "string" && claims.scope.length > 0 ? { scope: claims.scope } : { refused: "scope_claim_missing" };
228
233
  }
package/dist/bundle.d.ts CHANGED
@@ -1,13 +1,21 @@
1
+ import { type PortableDomain } from "./file-store.js";
1
2
  import type { ConfigReader } from "./reader.js";
2
- import { type DomainName } from "./types.js";
3
- export type ConfigBundle = Partial<Record<DomainName, unknown>>;
3
+ /** A bundle carries PORTABLE domains only — the non-portable ones (`NON_PORTABLE_DOMAIN_REASONS`: workers / hosts / systems / projects /
4
+ * entitlement) never travel, and the three bundle functions are that table's other face (types narrow to {@link PortableDomain}; a
5
+ * JS / `as` caller is refused at run time). */
6
+ export type ConfigBundle = Partial<Record<PortableDomain, unknown>>;
4
7
  /** Read the (portable, by default) domains out of any ConfigReader → a bundle object. Absent domains are
5
8
  * omitted (not defaulted) so a re-import doesn't fabricate domains the source never set. */
6
- export declare function exportBundle(reader: ConfigReader, domains?: readonly DomainName[]): Promise<ConfigBundle>;
9
+ export declare function exportBundle(reader: ConfigReader, domains?: readonly PortableDomain[]): Promise<ConfigBundle>;
7
10
  /** Write a bundle to `<root>/config.d/<domain>.json` — each file atomic + VALIDATED (writeDomainFile parses
8
11
  * against the domain zod, so a malformed source domain fails loudly rather than writing garbage). Returns the
9
12
  * domains written. (The TOC owns `<root>`; secrets stay in `<root>/.env`, never in these files.) */
10
- export declare function writeBundle(root: string, bundle: ConfigBundle): Promise<DomainName[]>;
11
- /** Read a `<root>/config.d/*.json` bundle → raw domain configs (missing/empty file → omitted). The reverse of
12
- * writeBundle: feed into sema-registry's per-domain PUT to import, or into the resolver to run locally. */
13
- export declare function readBundle(root: string, domains?: readonly DomainName[]): Promise<ConfigBundle>;
13
+ export declare function writeBundle(root: string, bundle: ConfigBundle): Promise<PortableDomain[]>;
14
+ /** Read a `<root>/config.d/*.json` bundle → raw domain configs. The reverse of writeBundle: feed into web-admin's per-domain PUT to
15
+ * import, or into the resolver to run locally. Each file is read by the ONE `config.d/` file reading `FileConfigStore` uses
16
+ * (`./config-d-file`): a missing file → omitted (not fabricated); a present file that is empty / whitespace, bad JSON or not a JSON
17
+ * object is corrupt → throws (same sentence as `FileConfigStore`). A RETIRED domain's file in the directory (`config.d/runtime.json`,
18
+ * see `RETIRED_DOMAINS`) is read for its tombstone, whichever `domains` were asked for — the same judge and sentence as
19
+ * `FileConfigStore`: carrying facts ⇒ `RetiredDomainError` (a bundle assembled from the remaining files would silently drop those
20
+ * facts); absent / `{}` ⇒ left alone. */
21
+ export declare function readBundle(root: string, domains?: readonly PortableDomain[]): Promise<ConfigBundle>;
package/dist/bundle.js CHANGED
@@ -1,23 +1,28 @@
1
1
  /**
2
- * Round-trip between a config SOURCE (any {@link ConfigReader} — sema-registry's store OR a FileConfigStore)
2
+ * Round-trip between a config SOURCE (any {@link ConfigReader} — web-admin's store OR a FileConfigStore)
3
3
  * and a local `config.d/<domain>.json` BUNDLE. This is how a FLEET config seeds a TOC desktop (export → copy
4
- * the bundle + set .env → run locally) and how a local bundle seeds sema-registry (readBundle → PUT each domain
4
+ * the bundle + set .env → run locally) and how a local bundle seeds web-admin (readBundle → PUT each domain
5
5
  * via the existing domain API). The bundle = the RAW authorable domain configs; `skills` carry their full
6
6
  * bodies (SkillSpec.content is stored in full — NOT the /effective manifest), so there is no separate
7
7
  * body-bundling step and the round-trip is symmetric.
8
8
  *
9
9
  * Node-only (writeBundle/readBundle touch the filesystem) → imported via `@sema-agent/settings-schema/node`.
10
10
  */
11
- import { readFile } from "node:fs/promises";
12
- import path from "node:path";
13
11
  import { writeDomainFile } from "./file-edit.js";
14
12
  import { PORTABLE_DOMAINS } from "./file-store.js";
15
- import { isDomainName } from "./types.js";
13
+ import { configDPath, readConfigDFile, refuseRetiredDomainFiles } from "./config-d-file.js";
14
+ /** The runtime guard of the same rule (a name that is not even a domain is refused first — no path traversal). */
15
+ function assertPortable(d) {
16
+ if (!PORTABLE_DOMAINS.includes(d)) {
17
+ throw new Error(`"${d}" is not a portable domain — a bundle carries only ${PORTABLE_DOMAINS.join(" / ")} (the others never travel; see NON_PORTABLE_DOMAIN_REASONS)`);
18
+ }
19
+ }
16
20
  /** Read the (portable, by default) domains out of any ConfigReader → a bundle object. Absent domains are
17
21
  * omitted (not defaulted) so a re-import doesn't fabricate domains the source never set. */
18
22
  export async function exportBundle(reader, domains = PORTABLE_DOMAINS) {
19
23
  const out = {};
20
24
  for (const d of domains) {
25
+ assertPortable(d);
21
26
  const v = await reader.getDomain(d);
22
27
  if (v !== undefined)
23
28
  out[d] = v;
@@ -32,36 +37,32 @@ export async function writeBundle(root, bundle) {
32
37
  for (const [d, v] of Object.entries(bundle)) {
33
38
  if (v === undefined)
34
39
  continue;
40
+ assertPortable(d);
35
41
  await writeDomainFile(root, d, v);
36
42
  written.push(d);
37
43
  }
38
44
  return written;
39
45
  }
40
- /** Read a `<root>/config.d/*.json` bundle → raw domain configs (missing/empty file → omitted). The reverse of
41
- * writeBundle: feed into sema-registry's per-domain PUT to import, or into the resolver to run locally. */
46
+ /** Read a `<root>/config.d/*.json` bundle → raw domain configs. The reverse of writeBundle: feed into web-admin's per-domain PUT to
47
+ * import, or into the resolver to run locally. Each file is read by the ONE `config.d/` file reading `FileConfigStore` uses
48
+ * (`./config-d-file`): a missing file → omitted (not fabricated); a present file that is empty / whitespace, bad JSON or not a JSON
49
+ * object is corrupt → throws (same sentence as `FileConfigStore`). A RETIRED domain's file in the directory (`config.d/runtime.json`,
50
+ * see `RETIRED_DOMAINS`) is read for its tombstone, whichever `domains` were asked for — the same judge and sentence as
51
+ * `FileConfigStore`: carrying facts ⇒ `RetiredDomainError` (a bundle assembled from the remaining files would silently drop those
52
+ * facts); absent / `{}` ⇒ left alone. */
42
53
  export async function readBundle(root, domains = PORTABLE_DOMAINS) {
54
+ for (const d of domains) {
55
+ if (!PORTABLE_DOMAINS.includes(d)) {
56
+ configDPath(root, d); // a name that is not a domain at all is refused by the one name guard first (no `../` traversal)
57
+ assertPortable(d);
58
+ }
59
+ }
60
+ await refuseRetiredDomainFiles(root);
43
61
  const out = {};
44
62
  await Promise.all(domains.map(async (d) => {
45
- if (!isDomainName(d))
46
- throw new Error(`"${d}" is not a config domain — refusing to build a config.d path from it (no traversal)`);
47
- let text;
48
- try {
49
- text = await readFile(path.join(root, "config.d", `${d}.json`), "utf8");
50
- }
51
- catch (e) {
52
- if (e.code === "ENOENT")
53
- return; // absent file → omit (not fabricated)
54
- throw e;
55
- }
56
- const trimmed = text.trim();
57
- if (!trimmed)
58
- return;
59
- try {
60
- out[d] = JSON.parse(trimmed);
61
- }
62
- catch {
63
- throw new Error(`invalid JSON in config.d/${d}.json`); // sanitized: never echo the raw file text
64
- }
63
+ const v = await readConfigDFile(root, d);
64
+ if (v !== undefined)
65
+ out[d] = v;
65
66
  }));
66
67
  return out;
67
68
  }
package/dist/cli.d.ts CHANGED
@@ -1,7 +1,9 @@
1
- /** 入口注入的 I/O(核心不碰 node:fs)。`writeFileAtomic` 必须原子(写临时文件再改名):半写的 workers 文档比旧文档更糟。 */
1
+ /** 入口注入的 I/O(核心不碰 node:fs)。`writeFileAtomic` 必须原子(写临时文件再改名):半写的域文档比旧文档更糟。 */
2
2
  export interface SettingsSchemaCliIo {
3
+ readDir(path: string): Promise<string[]>;
3
4
  readFile(path: string): Promise<string>;
4
5
  writeFileAtomic(path: string, text: string): Promise<void>;
6
+ removeFile(path: string): Promise<void>;
5
7
  stdout(text: string): void;
6
8
  stderr(text: string): void;
7
9
  }
package/dist/cli.js CHANGED
@@ -1,34 +1,39 @@
1
1
  /**
2
- * `settings-schema` 命令行的核心(7.0.0)—— 子命令只有一个:`migrate-workers <workers.json> [--check]`,即
3
- * {@link migrateWorkersDomain} 的文件形(文件后端 / 离线用;中心在它的 workers 读口直接调同一只函数)。
2
+ * `settings-schema` 命令行的核心(11.0.0)—— 子命令只有一个:`migrate <config.d 目录> [--check]`,即一次性迁移
3
+ * `migrateStoredDocuments`(`./one-time-migrations`)的目录形(本地 config.d / 离线文件用;web-admin 在它起服时直接调同一只函数)。
4
+ * 目录被当作**一个 scope 的全集**(`coverage: "scope"`):目录里没有的域文件就是从未设过。
4
5
  *
5
6
  * 本模块不碰 `node:fs`:读写文件由入口 `bin/settings-schema.mjs` 注入(那是本包唯一用 fs 写文件的地方),所以测试可以直接驱动它。
6
7
  * 不进主桶 —— 它是 bin 的实现,不是契约面。
7
8
  *
8
- * 退出码:0 = 已写回 / 无事可做;1 = 有 worker 迁不了(refused)或迁出的文档整域读不进 7.0.0,**一字不写**;2 = 用法 / 读文件 /
9
- * JSON 错(此时 stdout 为空);3 = `--check` 且会有改动(不写)。**8.0.0**:`--check` 与真跑过同一只判定({@link verdictOf}),
10
- * 只在「有改动、且整域判得过」那一支上分开(pending vs applied)—— 整域会被拒的文档 `--check` 也报 refused / rc 1。
9
+ * 读哪些文件:目录里 `<名>.json`,名是配置域或retired 域(`runtime`)—— 便携与否不论(离线的 `workers.json` 一样迁);别的文件
10
+ * (`remote-exec.json`、隐藏文件、不认识的名)不读不动。
11
11
  *
12
- * ## 报告形(7.0.1:让「没写」在机读上不可能被读成「写了」)
13
- * 退出码 0 / 1 / 3 时 stdout 恰两行:
14
- * · 第 1 行 = **唯一结论行** `outcome=<refused|applied|noop|pending> written=<true|false>`;
15
- * · 第 2 行 = JSON 报告(`event: "workers_domain_migrated"`,只点名字段、不带值),同带 `outcome` / `written`。
16
- * `written` 不是独立字段:由 `outcome` 派生({@link writtenOf};只有 `applied` 写盘)。**键名带时态**:`written=true` 才有 `changed` /
17
- * `dropped`;`written=false`(refused / noop / pending)时是 `wouldChange` / `wouldDrop` —— 读旧字段 `changed` 的脚本在没写的时候
18
- * 读不到值,而不是读到一份「已迁」名单。结论行在**写盘成功之后**才出(写盘抛错 ⇒ 没有 `outcome=applied`)。
12
+ * 退出码:0 = 已写回 / 无事可做;1 = 有迁不了的坐标(收据 `refused`)**或**要写回的域文档整域读不进(整域终判,见 {@link verdictOf}),**一字不写**;
13
+ * 2 = 用法 / 读目录 / 读文件错,或某个域文件是坏文件(空 / 纯空白 / 坏 JSON / 不是 JSON 对象 —— 与 `FileConfigStore` 同一只判、同一句)(此时 stdout 为空);3 = `--check` 且会有改动、整域判得过(不写)。`--check` 与真跑过同一只判定,只在
14
+ * 「有改动、且整域判得过」那一支上分开(pending vs applied)—— 整域会被拒的目录 `--check` 也报 refused / rc 1。
15
+ *
16
+ * ## 报告形(让「没写」在机读上不可能被读成「写了」)
17
+ * 退出码 0 / 1 / 3 时 stdout 恰两行:第 1 行 = **唯一结论行** `outcome=<refused|applied|noop|pending> written=<true|false>`;第 2 行 = JSON 报告
18
+ * (`event: "stored_documents_migrated"`,逐条收据只点名坐标与处置、不带值),同带 `outcome` / `written`。`written` 由 `outcome` 派生
19
+ * (只有 `applied` 写盘)。**键名带时态**:`written=true` 才有 `applied`;没写(refused / noop / pending)时是 `wouldApply`。
20
+ * 结论行在**写盘成功之后**才出(写盘抛错 ⇒ 没有 `outcome=applied`)。写盘次序:先写改过 / 新建的文件,最后删被移除的retired 域文件 ——
21
+ * 中途断掉再跑一次,迁移照样恒等收尾(已搬过去的值在新位上,旧文件里同名键按「新位赢」丢弃)。
19
22
  */
20
- import { formatConfigPath, sanitizeZodError } from "./config-fns.js";
21
- import { MIGRATE_WORKERS_COMMAND } from "./execution-lane-index.js";
22
- import { issueText } from "./issue-text.js";
23
- import { migrateWorkersDomain } from "./migrate-workers.js";
24
- import { DOMAIN_SCHEMAS } from "./types.js";
23
+ import { z } from "zod";
24
+ import { formatConfigPath, parseDomain } from "./config-fns.js";
25
+ import { judgeConfigDText } from "./config-d-file.js";
26
+ import { migrateStoredDocuments } from "./one-time-migrations.js";
27
+ import { MIGRATE_COMMAND } from "./retired-form.js";
28
+ import { isDomainName, isRetiredDomainName } from "./types.js";
25
29
  const USAGE = [
26
- `usage: ${MIGRATE_WORKERS_COMMAND} <workers.json> [--check]`,
27
- " One-time 7.0.0 migration of a workers domain document ({ \"workers\": [...] }): env.REMOTE_EXEC + lane keys →",
28
- " runtime.execution, sessionBackend / coords.tidb* / runtime.db / tidbPasswordEnv → runtime.store. Writes the file in",
29
- " place only when every worker migrates; --check only reports (exit 3 if the file would change).",
30
+ `usage: ${MIGRATE_COMMAND} <config.d directory> [--check]`,
31
+ " Runs the one-time migrations (@sema-agent/settings-schema/one-time-migrations) over every <domain>.json in the directory, read as one",
32
+ " scope (a missing domain file = never set): writes the rewritten files in place and removes retired-domain files (runtime.json) only when",
33
+ " nothing is refused and every document it would write reads as a whole domain; --check runs the same verdict and only reports (exit 3",
34
+ " if something would change).",
30
35
  " stdout (exit 0 / 1 / 3): line 1 = outcome=<refused|applied|noop|pending> written=<true|false>, line 2 = the JSON report",
31
- " (changed / dropped only when written=true; otherwise wouldChange / wouldDrop).",
36
+ " (applied only when written=true; otherwise wouldApply).",
32
37
  ].join("\n");
33
38
  /** 这个结论写没写盘 —— 只有 `applied` 写。`written` 从这里派生,不是报告里一个可以和 `outcome` 各说各话的独立字段。 */
34
39
  function writtenOf(outcome) {
@@ -45,99 +50,145 @@ function writtenOf(outcome) {
45
50
  }
46
51
  }
47
52
  }
53
+ /** 报告里的一张收据(坐标出门形;不带值)。 */
54
+ function reportReceipt(r) {
55
+ const base = { migration: r.migration, document: r.document, path: formatConfigPath(r.path), appliedAs: r.appliedAs };
56
+ switch (r.appliedAs) {
57
+ case "moved":
58
+ return { ...base, to: `${r.to.document}:${formatConfigPath(r.to.path)}` };
59
+ case "refused":
60
+ return { ...base, reason: r.reason };
61
+ case "rewritten":
62
+ case "dropped-shadowed":
63
+ case "dropped-inert":
64
+ return base;
65
+ default: {
66
+ const unhandled = r;
67
+ throw new Error(`settings-schema: unhandled receipt ${JSON.stringify(unhandled)}`);
68
+ }
69
+ }
70
+ }
71
+ /** 整域终判的一条拒(issue 来自 `parseDomain`,已过本包唯一的脱敏器 —— 坐标段与句子都是出门形)。 */
72
+ function wholeDomainRefusal(document, path, reason) {
73
+ return { migration: null, document, path: formatConfigPath(path), appliedAs: "refused", reason };
74
+ }
48
75
  /**
49
- * 迁移结果 → 结论。顺序:逐只 refused → 无改动 ⇒ noop → **整域终判**(迁出来的文档读不读得进 7.0.0:名字 / 路由唯一 …)→ 有改动。
50
- * 7.0.1 把整域终判放在 `--check` 之后:同一份文档 `--check` 报 pending(rc 3)、真跑报 refused(rc 1),按 `--check` 做闸的脚本会放行
51
- * 一次必被拒的真跑;而整域终判拒时 `refused[]` 为空、拒因只在 stderr。现在两种跑法过同一只判定,`refused[]` 逐条带出被拒项。
76
+ * 迁移结果 → 结论。顺序:迁移步拒 → 无改动 ⇒ noop → **整域终判**(要写回的每个域文档 —— 改过的、新建的 —— 过读侧那一只
77
+ * `parseDomain`:名字 / 路由唯一这类跨行规则只有整域判得出)→ 有改动。整域终判在 `--check` 之前:否则同一个目录 `--check` 报 pending、
78
+ * 真跑写出一份读侧必拒的文档。没改的文件不归这里判(迁移不碰它,它读不读得进是读者的事)。
52
79
  */
53
- function verdictOf(m) {
54
- if (m.refused.length > 0) {
55
- return {
56
- kind: "refused",
57
- refused: m.refused.map((r) => ({ name: r.name, field: r.field, reason: r.reason })),
58
- why: `${m.refused.length} worker(s) cannot be migrated (see "refused") — fix them by hand and re-run`,
59
- };
80
+ function verdictOf(input, migrated, receipts) {
81
+ const refusedReceipts = receipts.filter((r) => r.appliedAs === "refused");
82
+ if (refusedReceipts.length > 0) {
83
+ return { kind: "refused", refused: refusedReceipts.map(reportReceipt), why: `${refusedReceipts.length} coordinate(s) cannot be migrated (see "refused") — fix them by hand and re-run` };
60
84
  }
61
- if (m.changed.length === 0)
85
+ if (receipts.length === 0)
62
86
  return { kind: "noop" };
63
- const whole = DOMAIN_SCHEMAS.workers.safeParse(m.doc);
64
- if (whole.success)
65
- return { kind: "changes" };
66
- const issues = sanitizeZodError(whole.error).issues;
67
- const workers = isRecord(m.doc) && Array.isArray(m.doc.workers) ? m.doc.workers : [];
68
- return {
69
- kind: "refused",
70
- refused: issues.map((i) => {
71
- const [head, index, ...rest] = i.path;
72
- if (head !== "workers" || typeof index !== "number")
73
- return { name: null, field: i.path, reason: issueText(i) };
74
- const entry = workers[index];
75
- return { name: isRecord(entry) && typeof entry.name === "string" ? entry.name : null, field: rest, reason: issueText(i) };
76
- }),
77
- why: `the migrated document does not read as a 7.0.0 workers domain — ${issues.map((i) => `${formatConfigPath(i.path)}: ${issueText(i)}`).join("; ")}`,
78
- };
87
+ const refused = [];
88
+ for (const [name, doc] of Object.entries(migrated)) {
89
+ if (!isDomainName(name) || (Object.prototype.hasOwnProperty.call(input, name) && input[name] === doc))
90
+ continue;
91
+ try {
92
+ parseDomain(name, doc);
93
+ }
94
+ catch (err) {
95
+ if (err instanceof z.ZodError)
96
+ for (const i of err.issues)
97
+ refused.push(wholeDomainRefusal(name, i.path, i.message));
98
+ else
99
+ refused.push(wholeDomainRefusal(name, [], err instanceof Error ? err.message : String(err)));
100
+ }
101
+ }
102
+ if (refused.length > 0) {
103
+ const documents = [...new Set(refused.map((r) => String(r.document)))];
104
+ return { kind: "refused", refused, why: `the migrated ${documents.join(" / ")} document(s) do not read as a whole domain (see "refused") — fix them by hand and re-run` };
105
+ }
106
+ return { kind: "changes" };
79
107
  }
80
- const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
81
- /** 一次发出报告:结论行 + JSON 行(一次 `stdout` 调用)。键名带时态:写了 ⇒ `changed` / `dropped`,没写 ⇒ `wouldChange` / `wouldDrop`。 */
82
- function reportOf(outcome, file, m, refused) {
108
+ /** 一次发出报告:结论行 + JSON 行(一次 `stdout` 调用)。 */
109
+ function reportOf(outcome, dir, applied, refused) {
83
110
  const written = writtenOf(outcome);
84
- const dropped = m.dropped.map((d) => ({ name: d.name, field: formatConfigPath(d.field) }));
85
111
  const json = JSON.stringify({
86
- event: "workers_domain_migrated",
112
+ event: "stored_documents_migrated",
87
113
  outcome,
88
114
  written,
89
- file,
90
- ...(written ? { changed: m.changed, dropped } : { wouldChange: m.changed, wouldDrop: dropped }),
91
- refused: refused.map((r) => ({ name: r.name, field: formatConfigPath(r.field), reason: r.reason })),
115
+ dir,
116
+ [written ? "applied" : "wouldApply"]: applied.map(reportReceipt),
117
+ refused,
92
118
  });
93
119
  return `outcome=${outcome} written=${written}\n${json}\n`;
94
120
  }
121
+ const join = (dir, name) => `${dir.replace(/[\\/]+$/, "")}/${name}`;
95
122
  export async function runSettingsSchemaCli(argv, io) {
96
123
  const [command, ...rest] = argv;
97
124
  const check = rest.includes("--check");
98
- const files = rest.filter((a) => a !== "--check");
99
- const file = files[0];
100
- if (command !== "migrate-workers" || files.length !== 1 || file === undefined || file.startsWith("-")) {
125
+ const dirs = rest.filter((a) => a !== "--check");
126
+ const dir = dirs[0];
127
+ if (command !== "migrate" || dirs.length !== 1 || dir === undefined || dir.startsWith("-")) {
101
128
  io.stderr(`${USAGE}\n`);
102
129
  return 2;
103
130
  }
104
- let text;
131
+ let names;
105
132
  try {
106
- text = await io.readFile(file);
133
+ names = await io.readDir(dir);
107
134
  }
108
135
  catch (err) {
109
- io.stderr(`settings-schema: cannot read ${file}: ${err instanceof Error ? err.message : String(err)}\n`);
136
+ io.stderr(`settings-schema: cannot read directory ${dir}: ${err instanceof Error ? err.message : String(err)}\n`);
110
137
  return 2;
111
138
  }
112
- let doc;
113
- try {
114
- doc = JSON.parse(text);
115
- }
116
- catch {
117
- io.stderr(`settings-schema: ${file} is not valid JSON\n`);
118
- return 2;
139
+ const docs = {};
140
+ for (const name of [...names].sort()) {
141
+ if (name.startsWith(".") || !name.endsWith(".json"))
142
+ continue;
143
+ const base = name.slice(0, -".json".length);
144
+ if (!isDomainName(base) && !isRetiredDomainName(base))
145
+ continue;
146
+ let text;
147
+ try {
148
+ text = await io.readFile(join(dir, name));
149
+ }
150
+ catch (err) {
151
+ io.stderr(`settings-schema: cannot read ${name}: ${err instanceof Error ? err.message : String(err)}\n`);
152
+ return 2;
153
+ }
154
+ try {
155
+ docs[base] = judgeConfigDText(base, text); // the one config.d file judge: empty / whitespace / bad JSON / not an object ⇒ same sentence as FileConfigStore
156
+ }
157
+ catch (err) {
158
+ io.stderr(`settings-schema: ${err instanceof Error ? err.message : String(err)}\n`);
159
+ return 2;
160
+ }
119
161
  }
120
- const m = migrateWorkersDomain(doc);
121
- const verdict = verdictOf(m);
162
+ const m = migrateStoredDocuments(docs, { coverage: "scope" });
163
+ const applied = m.receipts.filter((r) => r.appliedAs !== "refused");
164
+ const verdict = verdictOf(docs, m.docs, m.receipts);
122
165
  switch (verdict.kind) {
123
166
  case "refused":
124
- io.stdout(reportOf("refused", file, m, verdict.refused));
167
+ io.stdout(reportOf("refused", dir, applied, verdict.refused));
125
168
  io.stderr(`settings-schema: ${verdict.why}; nothing was written\n`);
126
169
  return 1;
127
170
  case "noop":
128
- io.stdout(reportOf("noop", file, m, []));
171
+ io.stdout(reportOf("noop", dir, [], []));
129
172
  return 0;
130
173
  case "changes":
131
- if (check) {
132
- io.stdout(reportOf("pending", file, m, []));
133
- return 3;
134
- }
135
- await io.writeFileAtomic(file, `${JSON.stringify(m.doc, null, 2)}\n`);
136
- io.stdout(reportOf("applied", file, m, [])); // 写盘成功之后才报 applied
137
- return 0;
174
+ break;
138
175
  default: {
139
176
  const unhandled = verdict;
140
177
  throw new Error(`settings-schema: unhandled verdict ${JSON.stringify(unhandled)}`);
141
178
  }
142
179
  }
180
+ if (check) {
181
+ io.stdout(reportOf("pending", dir, applied, []));
182
+ return 3;
183
+ }
184
+ for (const [name, doc] of Object.entries(m.docs)) {
185
+ if (Object.prototype.hasOwnProperty.call(docs, name) && docs[name] === doc)
186
+ continue;
187
+ await io.writeFileAtomic(join(dir, `${name}.json`), `${JSON.stringify(doc, null, 2)}\n`);
188
+ }
189
+ for (const name of Object.keys(docs))
190
+ if (!Object.prototype.hasOwnProperty.call(m.docs, name))
191
+ await io.removeFile(join(dir, `${name}.json`));
192
+ io.stdout(reportOf("applied", dir, applied, [])); // 写盘成功之后才报 applied
193
+ return 0;
143
194
  }
@@ -0,0 +1,14 @@
1
+ import { type DomainName, type RetiredDomainName } from "./types.js";
2
+ /** `<root>/config.d/<名>.json` 的路径 —— 名字的判也在这里(**一处**):不是配置域也不是 retired 域 ⇒ 拒(不拿用户给的名拼路径,防 `../` 穿越)。 */
3
+ export declare function configDPath(root: string, name: string): string;
4
+ /** 一个 `config.d/<名>.json` 在场时的**文本** → 它的值(纯判,零 I/O;见文件头的律):一个 JSON 对象 ⇒ 它;空 / 纯空白、坏 JSON、
5
+ * 不是对象 ⇒ 抛。读盘的入口(`readConfigDFile`)与自己读文本的命令行(`migrate`)都过它,四种坏形一句话。 */
6
+ export declare function judgeConfigDText(name: DomainName | RetiredDomainName, text: string): Record<string, unknown>;
7
+ /** 读一个 `config.d/<名>.json`:不存在(ENOENT)⇒ `undefined`;在场 ⇒ {@link judgeConfigDText}。 */
8
+ export declare function readConfigDFile(root: string, name: DomainName | RetiredDomainName): Promise<Record<string, unknown> | undefined>;
9
+ /**
10
+ * `config.d/` 里每个 **retired 域**的文件过一次墓碑(`refuseRetiredDomain`,与 `buildEffective` / `parseDomain` 同一只判官、同一句):带事实 ⇒
11
+ * `RetiredDomainError`;缺席 / `{}` ⇒ 交回读到的原文(调用方要它进版本 hash)。读 `config.d/` 的**每个入口**都先过它 —— `FileConfigStore`
12
+ * 的 `getDomain` / `getEffective`(于是走 `getDomain` 的 `exportBundle` 同答)与 `readBundle`;不论调用方要的是哪几个域。
13
+ */
14
+ export declare function refuseRetiredDomainFiles(root: string): Promise<Partial<Record<RetiredDomainName, unknown>>>;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * 包内私有(不进主桶、不进 exports 表):**一只** `config.d/<名>.json` 文件读法 —— `FileConfigStore`、`readBundle` 与命令行 `migrate` 读同一个目录,
3
+ * 同一种文件形同一个答案、同一句话。分两只:纯判 `judgeConfigDText`(文本 → 值 | 抛,零 I/O;命令行自己读文本,只调它)与读盘的
4
+ * `readConfigDFile`(调它);路径与名字的判只在 `configDPath`。
5
+ *
6
+ * 律:**只有不存在(ENOENT)= 缺席**(`undefined`);在场的文件必须是一个 JSON 对象。空 / 纯空白(半写、截断)、坏 JSON、解析出来不是对象
7
+ * (`null` / 数组 / 数 / 串 / 布尔)一律是**坏文件** ⇒ 抛(fail-closed:把一个被截断的文件读成缺席,就是静默丢掉那一个域 —— 例:截断的
8
+ * governance.json 丢掉它的护栏)。拒句只点名文件与形,从不回显文件原文。
9
+ */
10
+ import { readFile } from "node:fs/promises";
11
+ import path from "node:path";
12
+ import { refuseRetiredDomain } from "./config-fns.js";
13
+ import { RETIRED_DOMAINS, isDomainName, isRetiredDomainName } from "./types.js";
14
+ /** `<root>/config.d/<名>.json` 的路径 —— 名字的判也在这里(**一处**):不是配置域也不是 retired 域 ⇒ 拒(不拿用户给的名拼路径,防 `../` 穿越)。 */
15
+ export function configDPath(root, name) {
16
+ if (!isDomainName(name) && !isRetiredDomainName(name))
17
+ throw new Error(`"${name}" is not a config domain — refusing to build a config.d path from it (no traversal)`);
18
+ return path.join(root, "config.d", `${name}.json`);
19
+ }
20
+ /** 一个 `config.d/<名>.json` 在场时的**文本** → 它的值(纯判,零 I/O;见文件头的律):一个 JSON 对象 ⇒ 它;空 / 纯空白、坏 JSON、
21
+ * 不是对象 ⇒ 抛。读盘的入口(`readConfigDFile`)与自己读文本的命令行(`migrate`)都过它,四种坏形一句话。 */
22
+ export function judgeConfigDText(name, text) {
23
+ const trimmed = text.trim();
24
+ if (!trimmed)
25
+ throw new Error(`config.d/${name}.json is empty/whitespace — delete it for the domain default, or write a valid object`);
26
+ let parsed;
27
+ try {
28
+ parsed = JSON.parse(trimmed);
29
+ }
30
+ catch {
31
+ throw new Error(`invalid JSON in config.d/${name}.json`); // sanitized: never echo the raw file text into the error
32
+ }
33
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
34
+ const kind = parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed;
35
+ throw new Error(`config.d/${name}.json must be a JSON object (got ${kind}) — delete the file for the domain default, or write a valid object`);
36
+ }
37
+ return parsed;
38
+ }
39
+ /** 读一个 `config.d/<名>.json`:不存在(ENOENT)⇒ `undefined`;在场 ⇒ {@link judgeConfigDText}。 */
40
+ export async function readConfigDFile(root, name) {
41
+ let text;
42
+ try {
43
+ text = await readFile(configDPath(root, name), "utf8");
44
+ }
45
+ catch (e) {
46
+ if (e.code === "ENOENT")
47
+ return undefined;
48
+ throw e;
49
+ }
50
+ return judgeConfigDText(name, text);
51
+ }
52
+ /**
53
+ * `config.d/` 里每个 **retired 域**的文件过一次墓碑(`refuseRetiredDomain`,与 `buildEffective` / `parseDomain` 同一只判官、同一句):带事实 ⇒
54
+ * `RetiredDomainError`;缺席 / `{}` ⇒ 交回读到的原文(调用方要它进版本 hash)。读 `config.d/` 的**每个入口**都先过它 —— `FileConfigStore`
55
+ * 的 `getDomain` / `getEffective`(于是走 `getDomain` 的 `exportBundle` 同答)与 `readBundle`;不论调用方要的是哪几个域。
56
+ */
57
+ export async function refuseRetiredDomainFiles(root) {
58
+ const out = {};
59
+ for (const d of Object.keys(RETIRED_DOMAINS)) {
60
+ if (!isRetiredDomainName(d))
61
+ continue;
62
+ const raw = await readConfigDFile(root, d);
63
+ refuseRetiredDomain(d, raw);
64
+ out[d] = raw;
65
+ }
66
+ return out;
67
+ }