@ultimat3/cli 23.0.0 → 25.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 (296) hide show
  1. package/CLAUDE.md +28 -13
  2. package/README.md +32 -7
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +30 -36
  6. package/src/api-registration.ts +89 -0
  7. package/src/app-artifacts.ts +1 -1
  8. package/src/app-auth.ts +4 -27
  9. package/src/app-boundaries.ts +17 -4
  10. package/src/app-config-load.ts +51 -0
  11. package/src/app-env.ts +26 -25
  12. package/src/app-load.ts +38 -14
  13. package/src/app-manifest.ts +4 -10
  14. package/src/app-mcp.ts +31 -46
  15. package/src/app-name.ts +52 -0
  16. package/src/app-permissions-borrowed.ts +123 -0
  17. package/src/app-permissions-site.ts +48 -0
  18. package/src/app-permissions.ts +7 -17
  19. package/src/app-root.ts +9 -5
  20. package/src/bin.ts +3 -7
  21. package/src/browser-launcher.ts +4 -2
  22. package/src/budgets-charged.ts +60 -0
  23. package/src/budgets-scan.ts +76 -0
  24. package/src/budgets-unmeasured.ts +91 -0
  25. package/src/budgets.ts +117 -154
  26. package/src/cdp-shot-driver.ts +20 -1
  27. package/src/cdp-shot-watch.ts +35 -31
  28. package/src/ci-log.ts +0 -0
  29. package/src/cmd-auth-spec.ts +16 -0
  30. package/src/cmd-auth.ts +65 -0
  31. package/src/cmd-build-spec.ts +7 -1
  32. package/src/cmd-build.ts +151 -22
  33. package/src/cmd-db-backfill.ts +26 -13
  34. package/src/cmd-db-branch.ts +2 -2
  35. package/src/cmd-db.ts +10 -6
  36. package/src/cmd-deploy-compose.ts +94 -0
  37. package/src/cmd-deploy-drain.ts +48 -0
  38. package/src/cmd-deploy-helm.ts +7 -6
  39. package/src/cmd-deploy-start-first.ts +178 -0
  40. package/src/cmd-deploy.ts +158 -36
  41. package/src/cmd-dev.ts +18 -356
  42. package/src/cmd-doctor-spec.ts +2 -9
  43. package/src/cmd-doctor.ts +57 -80
  44. package/src/cmd-env.ts +55 -16
  45. package/src/cmd-generate.ts +89 -32
  46. package/src/cmd-help.ts +19 -6
  47. package/src/cmd-i18n.ts +21 -17
  48. package/src/cmd-jobs-spec.ts +7 -22
  49. package/src/cmd-jobs.ts +33 -140
  50. package/src/cmd-manifest-spec.ts +1 -1
  51. package/src/cmd-manifest.ts +6 -25
  52. package/src/cmd-mcp.ts +71 -22
  53. package/src/cmd-new.ts +36 -8
  54. package/src/cmd-planned.ts +24 -8
  55. package/src/cmd-routes.ts +15 -5
  56. package/src/cmd-secrets.ts +47 -6
  57. package/src/cmd-shot-matrix.ts +22 -7
  58. package/src/cmd-shot-spec.ts +10 -2
  59. package/src/cmd-shot.ts +46 -41
  60. package/src/cmd-tasks.ts +2 -2
  61. package/src/cmd-test-spec.ts +1 -7
  62. package/src/cmd-test.ts +14 -30
  63. package/src/cmd-verify.ts +4 -4
  64. package/src/coverage-lcov.ts +4 -2
  65. package/src/db-backfill.ts +2 -2
  66. package/src/db-replay-engine.ts +4 -10
  67. package/src/db-subscribes.ts +27 -16
  68. package/src/dev-boot.ts +290 -0
  69. package/src/dev-child-watch.ts +63 -12
  70. package/src/dev-dashboard-guard.ts +76 -0
  71. package/src/dev-dashboard.ts +56 -30
  72. package/src/dev-lock.ts +100 -31
  73. package/src/dev-rebuild.ts +80 -0
  74. package/src/dev-restart-retire.ts +46 -0
  75. package/src/dev-route-table.ts +30 -8
  76. package/src/dev-supervisor.ts +114 -22
  77. package/src/dev-traces.ts +53 -20
  78. package/src/dispatch.ts +17 -13
  79. package/src/doctor-app-url.ts +39 -0
  80. package/src/doctor-auth.ts +95 -0
  81. package/src/doctor-offline.ts +6 -5
  82. package/src/document-options.ts +7 -0
  83. package/src/drift-replica-identity.ts +42 -0
  84. package/src/drift.ts +29 -6
  85. package/src/error-catalog.ts +6 -9
  86. package/src/error-codes.ts +29 -3
  87. package/src/error-page-csp.ts +14 -15
  88. package/src/error-unthrown.ts +29 -11
  89. package/src/errors.ts +54 -83
  90. package/src/exec.ts +8 -0
  91. package/src/favicon.ts +2 -2
  92. package/src/fix-command.ts +9 -1
  93. package/src/framework-env.ts +148 -0
  94. package/src/framework-schema-apply.ts +78 -0
  95. package/src/framework-schema-stamp.ts +96 -0
  96. package/src/framework-schema.ts +25 -3
  97. package/src/generate-catalog-locales.ts +79 -0
  98. package/src/generate-files.ts +4 -3
  99. package/src/generate-kinds.ts +18 -0
  100. package/src/generate-plural.ts +50 -0
  101. package/src/generate-shadow.ts +95 -0
  102. package/src/generate-write.ts +47 -13
  103. package/src/gh-target.ts +12 -7
  104. package/src/hold.ts +56 -1
  105. package/src/i18n-audit.ts +25 -9
  106. package/src/i18n-index.ts +5 -6
  107. package/src/i18n-registration.ts +7 -9
  108. package/src/import-scan.ts +28 -3
  109. package/src/index.ts +2 -6
  110. package/src/island-bundle.ts +57 -79
  111. package/src/island-capture.ts +1 -1
  112. package/src/island-duplicate-refusal.ts +57 -0
  113. package/src/island-duplicates.ts +221 -0
  114. package/src/island-entry-missing.ts +64 -0
  115. package/src/island-harness-route.ts +20 -4
  116. package/src/island-harness-script.ts +23 -2
  117. package/src/island-identity.ts +17 -14
  118. package/src/island-package-dedupe.ts +150 -0
  119. package/src/island-realtime.ts +36 -16
  120. package/src/island-routes.ts +2 -2
  121. package/src/island-runtime.ts +241 -0
  122. package/src/island-sources.ts +105 -0
  123. package/src/island-states-load.ts +6 -0
  124. package/src/island-store.ts +90 -6
  125. package/src/jobs-json.ts +0 -26
  126. package/src/jobs-report.ts +36 -20
  127. package/src/local-cli-handoff.ts +90 -0
  128. package/src/loopback-host.ts +13 -0
  129. package/src/manifest-staleness.ts +62 -0
  130. package/src/mcp-errors.ts +20 -4
  131. package/src/mcp-host.ts +22 -14
  132. package/src/mcp-test-run.ts +38 -0
  133. package/src/mcp-ui-inspect.ts +3 -2
  134. package/src/mcp-ui-interact.ts +3 -2
  135. package/src/mcp-ui.ts +8 -7
  136. package/src/measure-scope.ts +15 -10
  137. package/src/messages.ts +29 -16
  138. package/src/metrics-endpoint.ts +28 -3
  139. package/src/migration-extensions.ts +1 -1
  140. package/src/otlp-export.ts +2 -2
  141. package/src/output.ts +24 -0
  142. package/src/page-navigation.ts +7 -23
  143. package/src/page-speculation.ts +19 -31
  144. package/src/parse.ts +0 -8
  145. package/src/permission-grants.ts +3 -3
  146. package/src/posix-path.ts +30 -0
  147. package/src/prerender-locales.ts +6 -4
  148. package/src/prerender-out.ts +34 -7
  149. package/src/prerender-report.ts +58 -0
  150. package/src/prerender-served.ts +52 -0
  151. package/src/prerender.ts +71 -103
  152. package/src/pwa-artifacts.ts +33 -94
  153. package/src/pwa-manifest-locales.ts +9 -9
  154. package/src/registry.ts +2 -0
  155. package/src/retry-memo.ts +1 -1
  156. package/src/revalidated-response.ts +4 -3
  157. package/src/role-realtime.ts +7 -1
  158. package/src/role-replicator.ts +124 -29
  159. package/src/role-start.ts +69 -85
  160. package/src/role-sync.ts +119 -25
  161. package/src/root-env.ts +157 -28
  162. package/src/route-islands.ts +69 -0
  163. package/src/runtime-admin.ts +4 -1
  164. package/src/runtime-assets.ts +26 -9
  165. package/src/runtime-bindings.ts +27 -7
  166. package/src/runtime-cache.ts +46 -55
  167. package/src/runtime-hooks.ts +2 -1
  168. package/src/runtime-idempotency-scope.ts +23 -0
  169. package/src/runtime-isr-outcome.ts +100 -0
  170. package/src/runtime-isr.ts +36 -0
  171. package/src/runtime-jobs.ts +76 -41
  172. package/src/runtime-live-feed.ts +1 -1
  173. package/src/runtime-mail.ts +26 -0
  174. package/src/runtime-mfa-warning.ts +29 -0
  175. package/src/runtime-notify-retention.ts +10 -46
  176. package/src/runtime-overrides.ts +4 -4
  177. package/src/runtime-purge.ts +38 -16
  178. package/src/runtime-queue.ts +43 -22
  179. package/src/runtime-realtime.ts +11 -45
  180. package/src/runtime-render.ts +136 -114
  181. package/src/runtime-replica.ts +3 -8
  182. package/src/runtime-services.ts +49 -26
  183. package/src/runtime-storage-upload.ts +2 -2
  184. package/src/runtime-storage.ts +13 -8
  185. package/src/schema-diff.ts +16 -0
  186. package/src/schema-drift.ts +45 -15
  187. package/src/secrets-rotation.ts +8 -9
  188. package/src/seo-routes.ts +80 -9
  189. package/src/serve-boot.ts +51 -12
  190. package/src/serve-drain.ts +11 -33
  191. package/src/serve-env.ts +9 -6
  192. package/src/serve-retire.ts +95 -0
  193. package/src/serve-web.ts +31 -15
  194. package/src/serve.ts +27 -10
  195. package/src/server-barrels.ts +6 -3
  196. package/src/shot-browser.ts +1 -1
  197. package/src/shot-locale.ts +6 -33
  198. package/src/shot-settle.ts +3 -3
  199. package/src/shot-theme.ts +1 -1
  200. package/src/shot-viewport.ts +152 -0
  201. package/src/signal-shred.ts +26 -12
  202. package/src/site-asset-routes.ts +2 -2
  203. package/src/site-assets.ts +3 -5
  204. package/src/site-config.ts +9 -48
  205. package/src/site-seo.ts +7 -5
  206. package/src/sitemap-lastmod.ts +5 -0
  207. package/src/solid-loader.ts +12 -3
  208. package/src/source-files.ts +19 -3
  209. package/src/static-report.ts +5 -0
  210. package/src/stored-object-headers.ts +43 -0
  211. package/src/style-routes.ts +2 -2
  212. package/src/sw-artifacts.ts +8 -8
  213. package/src/sw-precache-plan.ts +34 -4
  214. package/src/sync-authenticator.ts +3 -3
  215. package/src/sync-url.ts +3 -3
  216. package/src/tasks-facts.ts +3 -29
  217. package/src/templates/action.ts +7 -4
  218. package/src/templates/backfill.ts +4 -4
  219. package/src/templates/entity.ts +2 -2
  220. package/src/templates/github/ci.yml.ts +28 -18
  221. package/src/templates/github/image.yml.ts +92 -0
  222. package/src/templates/github/runner.ts +18 -0
  223. package/src/templates/guard-bare-error.ts +16 -2
  224. package/src/templates/guard-unzoned-date.ts +56 -6
  225. package/src/templates/index.ts +1 -1
  226. package/src/templates/job-body-test.ts +2 -2
  227. package/src/templates/job.ts +6 -6
  228. package/src/templates/naming.ts +28 -0
  229. package/src/templates/query.ts +9 -8
  230. package/src/templates/resource-page.ts +9 -7
  231. package/src/templates/resource-service.ts +2 -2
  232. package/src/templates/route.ts +26 -14
  233. package/src/templates/scaffold-api.ts +2 -3
  234. package/src/templates/scaffold-app.ts +8 -3
  235. package/src/templates/scaffold-auth.ts +205 -70
  236. package/src/templates/scaffold-bin.ts +122 -0
  237. package/src/templates/scaffold-claude-commands.ts +11 -11
  238. package/src/templates/scaffold-container-compose.ts +10 -1
  239. package/src/templates/scaffold-container.ts +16 -8
  240. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  241. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  242. package/src/templates/scaffold-dashboard-example.ts +112 -172
  243. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  244. package/src/templates/scaffold-dashboard.ts +9 -5
  245. package/src/templates/scaffold-db-client.ts +4 -2
  246. package/src/templates/scaffold-db-package.ts +2 -2
  247. package/src/templates/scaffold-docs.ts +20 -77
  248. package/src/templates/scaffold-domain-package.ts +0 -4
  249. package/src/templates/scaffold-entries.ts +4 -3
  250. package/src/templates/scaffold-env.ts +6 -3
  251. package/src/templates/scaffold-gitattributes.ts +59 -0
  252. package/src/templates/scaffold-helm-fences.ts +76 -0
  253. package/src/templates/scaffold-helm-templates.ts +100 -19
  254. package/src/templates/scaffold-helm.ts +62 -5
  255. package/src/templates/scaffold-http.ts +1 -1
  256. package/src/templates/scaffold-i18n.ts +15 -2
  257. package/src/templates/scaffold-icon.ts +2 -2
  258. package/src/templates/scaffold-repo.ts +27 -16
  259. package/src/templates/scaffold-shell.ts +55 -37
  260. package/src/test-counts.ts +2 -6
  261. package/src/test-ignores.ts +25 -0
  262. package/src/test-only-shipped.ts +193 -0
  263. package/src/test-passes.ts +0 -15
  264. package/src/test-select.ts +7 -15
  265. package/src/test-shards.ts +40 -95
  266. package/src/test-workers.ts +26 -4
  267. package/src/theme-boot.ts +8 -33
  268. package/src/theme-brand.ts +66 -0
  269. package/src/trusted-hops-error.ts +17 -0
  270. package/src/trusted-hops.ts +49 -0
  271. package/src/unscanned-admin.ts +2 -2
  272. package/src/unthrown-codes.ts +26 -0
  273. package/src/verify-checks.ts +29 -48
  274. package/src/verify-coverage-run.ts +3 -2
  275. package/src/verify-deadline.ts +207 -33
  276. package/src/verify-merge.ts +108 -43
  277. package/src/verify-part-step.ts +98 -0
  278. package/src/verify-role-load-probe.ts +12 -2
  279. package/src/verify-role-load.ts +9 -4
  280. package/src/verify-run.ts +32 -8
  281. package/src/verify-shard.ts +13 -2
  282. package/src/verify-stalled.ts +152 -0
  283. package/src/verify-step.ts +2 -0
  284. package/src/verify-test-run.ts +3 -2
  285. package/src/verify-tests.ts +5 -3
  286. package/src/worker-bundle.ts +2 -2
  287. package/src/workspace-checks.ts +53 -7
  288. package/src/workspace-graph.ts +3 -2
  289. package/src/write-line.ts +82 -7
  290. package/src/browser-launcher-fake-html.ts +0 -80
  291. package/src/browser-launcher-fake.ts +0 -168
  292. package/src/build-errors.ts +0 -54
  293. package/src/jobs-drain.ts +0 -163
  294. package/src/scaffold-typecheck.ts +0 -227
  295. package/src/thrown-by.ts +0 -24
  296. package/src/verify-errors.ts +0 -58
@@ -7,12 +7,12 @@
7
7
  // `localDriver` SIGNS `/_storage/<disk>/<key>`, so a local `'/_storage'` here is a second statement
8
8
  // of one constant — and a signer and a reader that disagree serve 404 for every signed URL.
9
9
 
10
- import { actorOf } from '@ultimat3/action';
11
10
  import type { Actor } from '@ultimat3/core';
11
+ import { actorOf } from '@ultimat3/core';
12
12
  import type { CacheHint, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
13
13
  import { asCtx, unauthenticated } from '@ultimat3/http';
14
14
  import type { KnownPermission } from '@ultimat3/policy';
15
- import { can, codeOf, evaluate, forbidden, reasonOf } from '@ultimat3/policy';
15
+ import { can, codeOf, evaluate, policyForbidden, reasonOf } from '@ultimat3/policy';
16
16
  import type { Storage, StorageRead } from '@ultimat3/storage';
17
17
  import {
18
18
  assertSafeKey,
@@ -24,6 +24,7 @@ import {
24
24
  orgMismatch,
25
25
  } from '@ultimat3/storage';
26
26
  import { storageUploadRoute } from './runtime-storage-upload';
27
+ import { storedObjectHeaders } from './stored-object-headers';
27
28
 
28
29
  /**
29
30
  * The one capability that gates reading a stored object, on every disk. A permission and not a
@@ -87,7 +88,7 @@ export function authorizeStorageRead(input: StorageReadInput, ctx: RequestContex
87
88
  const reason = reasonOf(evaluation.decision) ?? 'denied';
88
89
  throw codeOf(evaluation.decision) === 'X_UNAUTHENTICATED'
89
90
  ? unauthenticated(ctx.url.pathname)
90
- : forbidden(policy.label, reason);
91
+ : policyForbidden(policy.label, reason);
91
92
  }
92
93
 
93
94
  /**
@@ -103,11 +104,10 @@ export function authorizeStorageRead(input: StorageReadInput, ctx: RequestContex
103
104
  */
104
105
  export function assertReadableKey(key: string, actor: Actor): string {
105
106
  const safe = assertSafeKey(key);
106
- // An actor with no org is inside no org, so every tenant-scoped key is somebody else's. Checked
107
- // before `isWithinOrg`, which reads an empty org as a malformed key and would blame the caller's
108
- // URL for the actor's missing claim.
107
+ // An actor with no org is inside no org, so every tenant-scoped key is somebody else's —
108
+ // `isWithinOrg` answers exactly that for an empty org, without throwing.
109
109
  const orgId = actor.orgId ?? '';
110
- if (isTenantScoped(safe) && (orgId === '' || !isWithinOrg(safe, orgId))) {
110
+ if (isTenantScoped(safe) && !isWithinOrg(safe, orgId)) {
111
111
  throw orgMismatch(safe, orgId);
112
112
  }
113
113
  return safe;
@@ -185,9 +185,14 @@ export function storageResponse(request: UltimateRequest, read: StorageRead): Re
185
185
  const headers = new Headers({
186
186
  'content-type': read.object.contentType,
187
187
  etag,
188
- 'last-modified': read.object.lastModified.toUTCString(),
189
188
  'accept-ranges': 'bytes',
189
+ // A type a browser would RUN (html, svg, xml) is a download, never a page on this origin.
190
+ ...storedObjectHeaders(read.object.contentType),
190
191
  });
192
+ // Omitted, never invented: a disk whose provider reported no date has none, and a `Last-Modified`
193
+ // made up for it is a validator a cache would go on to trust.
194
+ const modified = read.object.lastModified;
195
+ if (modified !== undefined) headers.set('last-modified', modified.toUTCString());
191
196
  // Revalidation costs a request and no bytes, which is the trade an authorized response wants:
192
197
  // the bytes never change under a key, but the actor's permission to read them can be revoked.
193
198
  if (etagMatches(request.header('if-none-match'), etag)) {
@@ -40,6 +40,11 @@ export interface SchemaDifference {
40
40
 
41
41
  const DECLARED_ONLY = 'is declared by the entities and no migration recorded it';
42
42
  const RECORDED_ONLY = 'was recorded by the newest migration and no entity declares it';
43
+ /** The difference's name — what `x db gen "record …"` is told to write down. */
44
+ const REPLICA_IDENTITY_FULL = 'replica identity full';
45
+ const REPLICA_IDENTITY_UNRECORDED =
46
+ 'needs REPLICA IDENTITY FULL — a channel with params routes deletes by its old row — and no ' +
47
+ 'migration recorded it';
43
48
 
44
49
  const columns = (names: readonly string[]): string => names.join(', ');
45
50
 
@@ -187,6 +192,17 @@ function compareTable(declared: TableDescription, recorded: TableDescription): S
187
192
  detail: `declares primary key (${columns(declared.primaryKey)}) and the migration recorded (${columns(recorded.primaryKey)})`,
188
193
  });
189
194
  }
195
+ // One direction: `replica-identity.ts` never reverts the identity, so a recorded FULL nothing
196
+ // needs any more is what `x db gen` leaves behind on purpose, not a difference.
197
+ if (declared.replicaIdentityFull === true && recorded.replicaIdentityFull !== true) {
198
+ out.push({
199
+ direction: 'unmigrated',
200
+ part: 'table',
201
+ table,
202
+ name: REPLICA_IDENTITY_FULL,
203
+ detail: REPLICA_IDENTITY_UNRECORDED,
204
+ });
205
+ }
190
206
  out.push(
191
207
  ...compareList({
192
208
  part: 'column',
@@ -15,10 +15,15 @@ import { existsSync } from 'node:fs';
15
15
  import { join } from 'node:path';
16
16
  import { ERROR_DOCS_URL } from '@ultimat3/core';
17
17
  import type { EntityDescriptionLike, UnrenderedDeclaration } from '@ultimat3/db';
18
- import { declaredSchema, snapshotOf, unrenderedOf } from '@ultimat3/db';
18
+ import { declaredSchema, shellInertIdentifier, snapshotOf, unrenderedOf } from '@ultimat3/db';
19
19
  import { describeEntities } from '@ultimat3/entity';
20
20
  import { loadApp } from './app-load';
21
21
  import { checkSourceDrift, DB_PACKAGE } from './drift';
22
+ import {
23
+ appReplicaIdentity,
24
+ type ReplicaIdentityWanted,
25
+ wantedReplicaIdentity,
26
+ } from './drift-replica-identity';
22
27
  import { MIGRATIONS_DIR, readMigrations } from './migrations';
23
28
  import type { Finding } from './output';
24
29
  import { diffDeclaredSchema, type SchemaDifference } from './schema-diff';
@@ -50,10 +55,17 @@ const appEntities: DeclaredEntities = async (root) => {
50
55
  * silently and the result is green. In that state the unrendered entry's own `fix:` is the
51
56
  * instruction: `@ultimat3/db` owns that wording, and a second one here would drift from it.
52
57
  */
58
+ interface Repair {
59
+ /** One runnable command or one edit — never a second option behind a shell comment. */
60
+ readonly fix: string;
61
+ /** Why this fix and not the obvious one, appended to the cause where the reader sees it. */
62
+ readonly why?: string;
63
+ }
64
+
53
65
  function repairFix(
54
66
  unrendered: readonly UnrenderedDeclaration[],
55
67
  difference: SchemaDifference,
56
- ): string {
68
+ ): Repair {
57
69
  // The entry ABOUT this difference first, then any entry at all. Reading `unrendered[0]`
58
70
  // unconditionally meant that in any app carrying an unrendered DEFAULT, every difference —
59
71
  // a dropped CHECK included — was answered with the default's edit, which is an instruction for
@@ -62,7 +74,7 @@ function repairFix(
62
74
  (entry) => entry.table === difference.table && entry.name === difference.name,
63
75
  );
64
76
  // The entry ABOUT this difference carries the edit that repairs it, so it is the whole answer.
65
- if (named !== undefined) return named.fix;
77
+ if (named !== undefined) return { fix: named.fix };
66
78
  // Otherwise `x db gen` is still unsafe — it would drop what the other entries name — but the
67
79
  // reader must NOT be handed a different declaration's edit as the instruction for this one.
68
80
  // Reading `unrendered[0]` did exactly that: a column default and an index each got
@@ -72,16 +84,26 @@ function repairFix(
72
84
  // app — not only for the one the entry names.
73
85
  const blocker = unrendered[0];
74
86
  if (blocker !== undefined) {
75
- return `${blocker.fix} # ${blocker.table}.${blocker.name} reaches no SQL, so regenerating would drop it; repair that before recording ${difference.name}`;
87
+ return {
88
+ fix: blocker.fix,
89
+ why: `${blocker.table}.${blocker.name} reaches no SQL, so regenerating would drop it — repair that before recording ${difference.name}`,
90
+ };
76
91
  }
77
92
  // "record", not "add": one finding covers a declaration the migrations never carried AND one they
78
- // carry differently, so `add` would be a wrong migration name for half of them. `undeclared` gets
79
- // both branches, in the order they are safe — regenerating emits the DROP, and the declaration
80
- // may have been LOST rather than removed, which is the whole reason this direction is its own
81
- // finding.
82
- return difference.direction === 'unmigrated'
83
- ? `x db gen "record ${difference.name}"`
84
- : `x db gen "drop ${difference.name}" # or re-declare ${difference.name} on the entity`;
93
+ // carry differently, so `add` would be a wrong migration name for half of them. `undeclared` runs
94
+ // the DROP, and the other branch — the declaration may have been LOST rather than removed, which
95
+ // is the whole reason this direction is its own finding — rides in the cause: a `fix:` is one
96
+ // command, and a `# or …` after it was a shell comment nobody's paste would run.
97
+ if (difference.direction === 'unmigrated') return { fix: `x db gen "record ${difference.name}"` };
98
+ // The name was read off a sidecar on disk and lands inside double quotes, where `$(…)` and a
99
+ // backtick still run: one the shell would act on is named in the cause only, never the command.
100
+ const inert = shellInertIdentifier(difference.name) !== null;
101
+ return {
102
+ fix: inert
103
+ ? `x db gen "drop ${difference.name}"`
104
+ : 'x db gen "drop what the entities no longer declare"',
105
+ why: `if ${difference.name} was lost rather than removed, re-declare it on the entity instead — this command drops it`,
106
+ };
85
107
  }
86
108
 
87
109
  /**
@@ -95,8 +117,10 @@ export const schemaDifferenceCause = (difference: SchemaDifference): string =>
95
117
  ? `table "${difference.table}" ${difference.detail}`
96
118
  : `${difference.part} "${difference.name}" on table "${difference.table}" ${difference.detail}`;
97
119
 
98
- function findingFor(difference: SchemaDifference, fix: string): Finding {
99
- const cause = schemaDifferenceCause(difference);
120
+ function findingFor(difference: SchemaDifference, repair: Repair): Finding {
121
+ const { fix } = repair;
122
+ const stated = schemaDifferenceCause(difference);
123
+ const cause = repair.why === undefined ? stated : `${stated} — ${repair.why}`;
100
124
  return difference.direction === 'unmigrated'
101
125
  ? { code: 'X_DB_SCHEMA_UNMIGRATED', cause, fix, docs: ERROR_DOCS_URL, at: MIGRATIONS_DIR }
102
126
  : { code: 'X_DB_SCHEMA_UNDECLARED', cause, fix, docs: ERROR_DOCS_URL, at: MIGRATIONS_DIR };
@@ -114,6 +138,7 @@ function findingFor(difference: SchemaDifference, fix: string): Finding {
114
138
  export async function checkSnapshotDrift(
115
139
  root: string,
116
140
  declared: DeclaredEntities = appEntities,
141
+ identity: ReplicaIdentityWanted = appReplicaIdentity,
117
142
  ): Promise<readonly Finding[]> {
118
143
  if (!existsSync(join(root, DB_PACKAGE))) return [];
119
144
  const entities = await declared(root);
@@ -122,7 +147,11 @@ export async function checkSnapshotDrift(
122
147
  if (migrations.length === 0) return [];
123
148
  const recorded = declaredSchema(migrations);
124
149
  if (recorded === undefined) return [];
125
- const differences = diffDeclaredSchema(snapshotOf(entities), recorded);
150
+ // Read only over a WHOLE registry (`entities` is `undefined` otherwise), the rule `x db gen`
151
+ // states for the same call: a short one makes every subscribed name in the missing module a typo.
152
+ const wanted = wantedReplicaIdentity(identity, new Set(entities.map((entity) => entity.table)));
153
+ if ('finding' in wanted) return [wanted.finding];
154
+ const differences = diffDeclaredSchema(snapshotOf(entities, wanted.tables), recorded);
126
155
  if (differences.length === 0) return [];
127
156
  // `recorded` and not the entities alone: whether an `assert` is a LOSS depends on whether a
128
157
  // migration recorded a CHECK for it, which only the sidecar knows.
@@ -145,8 +174,9 @@ export async function checkMigrationDrift(
145
174
  root: string,
146
175
  declared: DeclaredEntities = appEntities,
147
176
  hashDrift: (root: string) => Promise<readonly Finding[]> = checkSourceDrift,
177
+ identity: ReplicaIdentityWanted = appReplicaIdentity,
148
178
  ): Promise<readonly Finding[]> {
149
- const snapshot = await checkSnapshotDrift(root, declared);
179
+ const snapshot = await checkSnapshotDrift(root, declared, identity);
150
180
  if (snapshot.length > 0) return snapshot;
151
181
  return hashDrift(root);
152
182
  }
@@ -1,18 +1,19 @@
1
1
  // A master-key rotation in an order a crash cannot turn into data loss. The new key is STAGED at
2
- // `<key>.next` (0600) before anything is sealed with it, the committed file is sealed second, and
2
+ // `<key>.next` (owner-only: 0600, and an ACL on Windows) before anything is sealed with it, the committed file is sealed second, and
3
3
  // the rename that makes the new key live is last. Interrupted anywhere, some key on disk opens the
4
4
  // committed file — and `recoverRotation`, which every `x secrets` command runs first, finishes or
5
5
  // abandons the move by asking the file which key it answers to.
6
6
 
7
- import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; // why: the staged key is written and renamed synchronously at 0600, and Bun has no mode-setting write or rename.
7
+ import { existsSync, readFileSync, rmSync } from 'node:fs'; // why: Bun has no sync exists/remove; the key is WRITTEN and RENAMED only by core's owner-only writers.
8
8
  import type { MasterKeyRef, SecretValues } from '@ultimat3/core';
9
9
  import {
10
10
  generateMasterKey,
11
11
  isUltimateError,
12
12
  masterKeyPath,
13
+ promoteStagedMasterKey,
13
14
  readSecretsFile,
14
- SECRETS_KEY_MODE,
15
15
  stagedMasterKeyPath,
16
+ stageMasterKeyFile,
16
17
  writeSecretsFile,
17
18
  } from '@ultimat3/core';
18
19
 
@@ -24,14 +25,12 @@ const fileKey = (root: string, hex: string): MasterKeyRef => ({
24
25
 
25
26
  /** Stage, seal, then make live. Returns the key now in force. */
26
27
  export async function rotateMasterKey(root: string, values: SecretValues): Promise<MasterKeyRef> {
27
- const staged = stagedMasterKeyPath(root);
28
28
  const hex = generateMasterKey();
29
- rmSync(staged, { force: true });
30
- // `wx`: the mode applies only when a write CREATES the file, and a leftover could be 0644.
31
- writeFileSync(staged, `${hex}\n`, { encoding: 'utf-8', mode: SECRETS_KEY_MODE, flag: 'wx' });
29
+ // A fresh temp file renamed over any leftover: a staged key left 0644 is replaced, never reused.
30
+ stageMasterKeyFile(root, hex);
32
31
  const next = fileKey(root, hex);
33
32
  await writeSecretsFile(root, values, next);
34
- renameSync(staged, masterKeyPath(root));
33
+ promoteStagedMasterKey(root);
35
34
  return next;
36
35
  }
37
36
 
@@ -53,7 +52,7 @@ export async function recoverRotation(root: string, key: MasterKeyRef): Promise<
53
52
  // Throws the staged key's own mismatch when neither opens it — the committed file's problem,
54
53
  // which `X_SECRETS_KEY_MISMATCH`'s fix (restore it from git) answers.
55
54
  await readSecretsFile(root, candidate);
56
- renameSync(staged, masterKeyPath(root));
55
+ promoteStagedMasterKey(root);
57
56
  return candidate;
58
57
  }
59
58
  }
package/src/seo-routes.ts CHANGED
@@ -1,11 +1,14 @@
1
- // `GET /robots.txt` and `GET /sitemap.xml` from a running web role — the files the static export
2
- // writes, answered by the process for a deploy that serves its pages from a container. Mounted by
3
- // `x dev` and `runRole` alike, before the app's pages, for `style-routes.ts`' reason.
1
+ // `GET /robots.txt`, `GET /sitemap.xml` and its parts (`/sitemaps/<n>.xml`) from a running web
2
+ // role — the files the static export writes, for a deploy that serves its pages from a container.
3
+ // Mounted by `x dev` and `runRole` alike, before the app's pages, for `style-routes.ts`' reason.
4
4
 
5
5
  import { DEFAULT_ENVIRONMENT, type Environment, tryResolveEnvironment } from '@ultimat3/core';
6
6
  import type { Route, UltimateRequest } from '@ultimat3/http';
7
- import { applyCacheHeaders } from '@ultimat3/http';
7
+ import { applyCacheHeaders, jsonResponse, NO_STORE } from '@ultimat3/http';
8
+ import { SITEMAP_PARTS_DIR } from '@ultimat3/seo';
9
+ import { quoteArg } from './shell-quote';
8
10
  import { NO_SITE_SETTINGS, publicOrigin, type SiteSettings } from './site-config';
11
+ import type { SiteSeo } from './site-seo';
9
12
  import { ROBOTS_PATH, SITEMAP_PATH, siteSeo } from './site-seo';
10
13
 
11
14
  export interface SeoRoutesOptions {
@@ -26,22 +29,57 @@ function originOf(options: SeoRoutesOptions, request: UltimateRequest): string {
26
29
  }
27
30
 
28
31
  /**
29
- * Per request, never cached across one: `prerender()` may enumerate rows that change, and a
30
- * crawler asks for these a handful of times a day. An hour of shared cache is what a CDN keeps.
32
+ * An hour of shared cache is what a CDN keeps of either file, and what this process keeps of the
33
+ * answer behind both. `prerender()` may enumerate rows that change, so the memo is never longer
34
+ * than the response's own `max-age` — a crawler that bypasses the CDN sees what one behind it
35
+ * would. Before it, every `/robots.txt` hit recomputed the whole sitemap, each dynamic route's
36
+ * `prerender()` included.
31
37
  */
32
38
  const SEO_CACHE = { mode: 'public', maxAgeSeconds: 3600 } as const;
33
39
 
40
+ /**
41
+ * Origins remembered at once. With no declared origin the request's own is the key, and a `Host`
42
+ * header is the caller's to choose — so the memo is bounded, oldest out first.
43
+ */
44
+ const SEO_MEMO_ORIGINS = 8;
45
+
46
+ interface Memo {
47
+ readonly at: number;
48
+ readonly answer: Promise<SiteSeo>;
49
+ }
50
+
34
51
  export function seoRoutes(options: SeoRoutesOptions): readonly Route[] {
35
52
  const environment: Environment =
36
53
  tryResolveEnvironment({ env: options.env }) ?? DEFAULT_ENVIRONMENT;
37
- const answer = async (request: UltimateRequest) =>
38
- await siteSeo({
39
- baseUrl: originOf(options, request),
54
+ const compute = (baseUrl: string): Promise<SiteSeo> =>
55
+ siteSeo({
56
+ baseUrl,
40
57
  environment,
41
58
  disallow: options.site?.disallow ?? [],
42
59
  ...(options.site === undefined ? {} : { sitemap: options.site.sitemap }),
43
60
  ...(options.root === undefined ? {} : { root: options.root }),
44
61
  });
62
+ // Single-flight per origin: concurrent requests share the one computation in progress. A failed
63
+ // one is dropped, so the next request asks again rather than serving the failure for an hour.
64
+ const memo = new Map<string, Memo>();
65
+ const answer = (request: UltimateRequest): Promise<SiteSeo> => {
66
+ const origin = originOf(options, request);
67
+ const now = Date.now();
68
+ const kept = memo.get(origin);
69
+ if (kept !== undefined && now - kept.at < SEO_CACHE.maxAgeSeconds * 1000) return kept.answer;
70
+ memo.delete(origin);
71
+ if (memo.size >= SEO_MEMO_ORIGINS) {
72
+ const oldest = memo.keys().next().value;
73
+ if (oldest !== undefined) memo.delete(oldest);
74
+ }
75
+ const computed = compute(origin);
76
+ memo.set(origin, { at: now, answer: computed });
77
+ // Voided: the request awaits `computed` itself and reports its failure; this branch only forgets it.
78
+ void computed.catch(() => {
79
+ if (memo.get(origin)?.answer === computed) memo.delete(origin);
80
+ });
81
+ return computed;
82
+ };
45
83
 
46
84
  return [
47
85
  {
@@ -69,5 +107,38 @@ export function seoRoutes(options: SeoRoutesOptions): readonly Route[] {
69
107
  SEO_CACHE,
70
108
  ),
71
109
  },
110
+ {
111
+ method: 'GET',
112
+ path: `${SITEMAP_PARTS_DIR}/:file`,
113
+ meta: { name: 'seo.sitemapPart', auth: 'public', tags: ['seo'] },
114
+ // A part of a split sitemap, from the same answer the index came from — so a part the index
115
+ // names always exists here, and the static export writes the same paths.
116
+ handler: async (request: UltimateRequest): Promise<Response> => {
117
+ const seo = await answer(request);
118
+ const part = seo.sitemaps.find((file) => file.path === request.pathname);
119
+ if (part !== undefined && part.path !== SITEMAP_PATH) {
120
+ return applyCacheHeaders(
121
+ new Response(part.xml, {
122
+ headers: { 'content-type': 'application/xml; charset=utf-8' },
123
+ }),
124
+ SEO_CACHE,
125
+ );
126
+ }
127
+ const index = new URL(SITEMAP_PATH, originOf(options, request)).href;
128
+ const missing = jsonResponse(
129
+ {
130
+ ok: false,
131
+ error: {
132
+ code: 'X_ROUTE_NOT_FOUND',
133
+ cause: `${request.pathname} is no part of this site's sitemap: the index names ${String(Math.max(seo.sitemaps.length - 1, 0))} part(s)`,
134
+ fix: `curl -sS ${quoteArg(index)}`,
135
+ },
136
+ },
137
+ { status: 404 },
138
+ );
139
+ // `no-store`: the part may exist once the site grows, and a cached miss would hide it.
140
+ return applyCacheHeaders(missing, NO_STORE);
141
+ },
142
+ },
72
143
  ];
73
144
  }
package/src/serve-boot.ts CHANGED
@@ -5,16 +5,18 @@
5
5
  // (`serve-web.ts`) and the manifest projection are behind `await import()` in turn, so a worker's
6
6
  // module graph holds neither.
7
7
 
8
- import type { Role } from '@ultimat3/core';
8
+ import type { DrainConfig, LifecycleOptions, Role } from '@ultimat3/core';
9
9
  import { configureLifecycle, logger } from '@ultimat3/core';
10
10
  import type { MetricsEndpoint } from './metrics-endpoint';
11
11
  import { startOtlpExport } from './otlp-export';
12
12
  import { loadAppForRole, roleLoadFor } from './role-load';
13
13
  import { startRoles } from './role-start';
14
14
  import { resolveServices } from './runtime-bindings';
15
+ import { warnIfIdempotencyProcessScoped } from './runtime-idempotency-scope';
16
+ import { warnUnsealedMfaSecrets } from './runtime-mfa-warning';
15
17
  import { replicaOverrides } from './runtime-replica';
16
18
  import type { RunningServices } from './runtime-services';
17
- import { startServices } from './runtime-services';
19
+ import { releaseOrThrow, startServices } from './runtime-services';
18
20
  import { loadDrainConfig, loadHealthConfig } from './serve-drain';
19
21
  import { configureReporting, containerBinding, metricsPortFor, portFromEnv } from './serve-env';
20
22
  import { adoptPrebuiltStyles, reportBootBuilds, watchBootBuilds } from './serve-prebuilt';
@@ -42,6 +44,29 @@ async function buildIdFor(options: ServeOptions, role: Role): Promise<string> {
42
44
  return (await appManifest(options.root)).manifest.buildId;
43
45
  }
44
46
 
47
+ /** The roles that open an HTTP listener something routes to — the only ones a readiness grace serves. */
48
+ const LISTENING_ROLES: readonly Role[] = ['web', 'sync'];
49
+
50
+ /**
51
+ * `app.config.ts`'s `drain`, applied to the WHOLE process rather than only to the web server that
52
+ * `httpServer` hands it to. A role nothing routes to gets no readiness grace at all: the grace
53
+ * holds `/readyz` at 503 with the listener open so the endpoints catch up, and a worker has neither
54
+ * — it only went on claiming jobs for those seconds, then aborted them at the drain (s1-con #7).
55
+ *
56
+ * The budget (`deadlineMs`) is every role's: it is the time a worker's running job has to finish
57
+ * on a deploy, so it is the one key here a non-listening role needs MOST.
58
+ */
59
+ export function lifecycleForRole(
60
+ role: Role,
61
+ drain: Partial<DrainConfig> | undefined,
62
+ ): LifecycleOptions {
63
+ const budget = drain?.deadlineMs === undefined ? {} : { deadlineMs: drain.deadlineMs };
64
+ if (!LISTENING_ROLES.includes(role)) return { readinessGraceMs: 0, ...budget };
65
+ return drain?.readinessGraceMs === undefined
66
+ ? budget
67
+ : { readinessGraceMs: drain.readinessGraceMs, ...budget };
68
+ }
69
+
45
70
  /**
46
71
  * Everything `serveApp` does after the scrape listener is open: the services, then the roles over
47
72
  * them. One export, so `serve.ts` reaches the whole serving graph through ONE `await import()`.
@@ -53,11 +78,12 @@ export async function bootServing(boot: {
53
78
  readonly metrics: MetricsEndpoint;
54
79
  }): Promise<ServedApp> {
55
80
  const { options } = boot;
56
- const runtime = await startServices(
57
- resolveServices(options.root, options.env),
58
- options.env,
59
- options.runtime,
60
- );
81
+ const services = resolveServices(options.root, options.env);
82
+ // A serving role on a real database runs no DDL: `ROLE=migrate` applied this build's framework
83
+ // tables first, and this boot only verifies the stamp it left (`framework-schema-apply.ts`).
84
+ // The embedded database has no migrate step to have run, so it applies its own.
85
+ const schema = services.db.mode === 'external' ? 'verify' : 'apply';
86
+ const runtime = await startServices(services, options.env, options.runtime, schema);
61
87
  boot.acquired.push(() => runtime.stop());
62
88
  return bootRoles({ ...boot, runtime });
63
89
  }
@@ -84,6 +110,8 @@ async function bootRoles(boot: {
84
110
  for (const finding of loaded.findings) {
85
111
  logger.error('ultimate app module failed to load', { role, ...finding });
86
112
  }
113
+ // After the app's modules: an app swaps the store at import time, after `startServices`.
114
+ warnIfIdempotencyProcessScoped();
87
115
  const buildId = await buildIdFor(options, role);
88
116
  // Before the first socket opens: everything above this line fails loudly into the container's
89
117
  // own logs, everything below it is a served request, a claimed job or a routed frame.
@@ -102,6 +130,9 @@ async function bootRoles(boot: {
102
130
  role === 'web'
103
131
  ? await (await import('./serve-web')).webSurface(options, runtime, buildId)
104
132
  : undefined;
133
+ if (web !== undefined) acquired.push(web.release);
134
+ // The web role alone — it is where a second factor is asked for — and once per pod boot.
135
+ if (role === 'web') await warnUnsealedMfaSecrets(runtime.db);
105
136
  // Islands and stylesheets are the image build's to make (`x build --target prebuilt`). A role
106
137
  // that made either here served correctly and paid for it — seconds of CPU and a compiler's
107
138
  // memory, on every start of every replica — so it says so once, with the Dockerfile line.
@@ -112,6 +143,7 @@ async function bootRoles(boot: {
112
143
  // still wins — that is the deploy talking.
113
144
  const metricsPort = metricsPortFor(options.env, port, options.metricsPort);
114
145
  const drain = await loadDrainConfig(options.root);
146
+ configureLifecycle(lifecycleForRole(role, drain));
115
147
  // Process-wide, so every role's `/readyz` — web, sync, worker — answers in the declared mode.
116
148
  const health = await loadHealthConfig(options.root);
117
149
  if (health?.readiness !== undefined) configureLifecycle({ readiness: health.readiness });
@@ -155,11 +187,18 @@ async function bootRoles(boot: {
155
187
  running,
156
188
  runtime,
157
189
  async stop() {
158
- await running.stop();
159
- await runtime.stop();
160
- // Last: the exporters outlive the roles they were recording, so the drain's own spans and
161
- // the final counter snapshot still have somewhere to go.
162
- stopOtlp();
190
+ // Every step runs and the first failure is rethrown (`releaseOrThrow`): a refused role stop
191
+ // used to leave the services, the PGlite lock and the exporter behind it. Listed in boot
192
+ // order, released newest first — the roles; then the web surface, after the server drained
193
+ // (a bust landing mid-drain still marks the pages it is serving); then the services; LAST
194
+ // the exporter, so the drain's own spans and the final counter snapshot still have
195
+ // somewhere to go.
196
+ await releaseOrThrow([
197
+ stopOtlp,
198
+ () => runtime.stop(),
199
+ () => web?.release(),
200
+ () => running.stop(),
201
+ ]);
163
202
  },
164
203
  };
165
204
  }
@@ -1,41 +1,19 @@
1
- // `app.config.ts`'s `drain` and `health` sections, read for the production boot — the same import
2
- // every other config reader here makes (`theme-boot.ts`, `app-auth.ts`), because the config is a
3
- // module. Both shape what `/readyz` tells the load balancer.
1
+ // `app.config.ts`'s `drain` and `health` sections for the production boot, off the one loader
2
+ // (`app-config-load.ts`). Both shape what `/readyz` tells the load balancer.
4
3
 
5
4
  import type { DrainConfig, HealthConfig } from '@ultimat3/core';
6
- import { APP_CONFIG_EXPORT } from './app-auth';
7
- import { APP_CONFIG_FILE } from './app-root';
5
+ import { loadAppConfig } from './app-config-load';
8
6
 
9
- const isRecord = (value: unknown): value is Record<string, unknown> =>
10
- typeof value === 'object' && value !== null;
11
-
12
- /**
13
- * The declared section, or `undefined` when the app has none (a config not built by
14
- * `defineConfig`, or no config file): `@ultimat3/http` then keeps core's own default. A value that
15
- * is not a number is left for core to refuse, where the rule lives, rather than dropped here.
16
- */
7
+ /** The declared section, or `undefined` for a root with no config file — core keeps its default. */
17
8
  export async function loadDrainConfig(root: string): Promise<Partial<DrainConfig> | undefined> {
18
- const path = `${root}/${APP_CONFIG_FILE}`;
19
- if (!(await Bun.file(path).exists())) return undefined;
20
- const module = (await import(path)) as Record<string, unknown>;
21
- const config = module[APP_CONFIG_EXPORT];
22
- const drain = isRecord(config) ? config['drain'] : undefined;
23
- if (!isRecord(drain) || !Object.hasOwn(drain, 'readinessGraceMs')) return undefined;
24
- return { readinessGraceMs: drain['readinessGraceMs'] as number };
9
+ const drain = (await loadAppConfig(root))?.drain;
10
+ return drain === undefined
11
+ ? undefined
12
+ : { readinessGraceMs: drain.readinessGraceMs, deadlineMs: drain.deadlineMs };
25
13
  }
26
14
 
27
- /**
28
- * `health.readiness`, or `undefined` when the app declares none (core keeps `'dependencies'`). The
29
- * value is handed to `configureLifecycle` as written, so a bad one is refused by core's own rule.
30
- */
15
+ /** `health.readiness`, or `undefined` for a root with no config file (core keeps its default). */
31
16
  export async function loadHealthConfig(root: string): Promise<Partial<HealthConfig> | undefined> {
32
- const path = `${root}/${APP_CONFIG_FILE}`;
33
- if (!(await Bun.file(path).exists())) return undefined;
34
- const module = (await import(path)) as Record<string, unknown>;
35
- const config = module[APP_CONFIG_EXPORT];
36
- const health = isRecord(config)
37
- ? (config['health'] as Partial<HealthConfig> | undefined)
38
- : undefined;
39
- if (!isRecord(health) || health.readiness === undefined) return undefined;
40
- return { readiness: health.readiness };
17
+ const health = (await loadAppConfig(root))?.health;
18
+ return health === undefined ? undefined : { readiness: health.readiness };
41
19
  }
package/src/serve-env.ts CHANGED
@@ -42,22 +42,25 @@ export const containerBinding = (env: Env, hostname?: string): WebBinding => ({
42
42
  /**
43
43
  * `ROLE` is the one knob one image exposes. Validated rather than defaulted: a typo that fell back
44
44
  * to `web` would start a process that serves nothing the operator asked for and reports healthy.
45
+ * Empty or blank is unset, read the way `PORT` and `HOST` are — not a role name nobody typed.
45
46
  */
46
47
  export function roleFromEnv(env: Env): Role {
47
- const raw = env['ROLE'] ?? 'web';
48
- if (!isRole(raw)) throw new RoleUnknownError({ role: raw, known: ROLES });
49
- return raw;
48
+ const raw = env['ROLE']?.trim();
49
+ const role = raw === undefined || raw.length === 0 ? 'web' : raw;
50
+ if (!isRole(role)) throw new RoleUnknownError({ role, known: ROLES });
51
+ return role;
50
52
  }
51
53
 
52
54
  /**
53
- * `Number.parseInt` would read `80abc` as 80, so the whole string has to be a port — a
54
- * partially-parsed port is a deploy that binds somewhere nobody asked for.
55
+ * `Number.parseInt` would read `80abc` as 80, and `Number` reads `0x1F90` as 8080 and `8e3` as
56
+ * 8000, so the whole string has to be decimal digits — a port spelled any other way is a deploy
57
+ * that binds somewhere nobody wrote down.
55
58
  */
56
59
  function portValue(env: Env, name: string, fallback: number): number {
57
60
  const raw = env[name];
58
61
  if (raw === undefined || raw.trim().length === 0) return fallback;
59
62
  const port = Number(raw.trim());
60
- if (!Number.isInteger(port) || port < 0 || port > 65_535)
63
+ if (!/^\d+$/.test(raw.trim()) || !Number.isInteger(port) || port < 0 || port > 65_535)
61
64
  throw new PortInvalidError({ value: raw, name });
62
65
  return port;
63
66
  }