@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
@@ -24,19 +24,39 @@ export interface DevServices {
24
24
  readonly storage: ServiceBinding;
25
25
  readonly stateDir: string;
26
26
  /**
27
- * The app directory itself, carried rather than re-derived from `stateDir`. A boot that needs to
28
- * read the app's own `app.config.ts` — `loadInboxRetention` does — otherwise has to undo the
29
- * `join(root, '.x')` above, and a `dirname` that silently disagrees with this file's join is a
30
- * path bug nothing would catch.
27
+ * The app directory itself, carried rather than re-derived from `stateDir`. A boot that needs
28
+ * to read the app's own `app.config.ts` — `startServices`' `loadAppConfig` does — otherwise has
29
+ * to undo the `join(root, '.x')` above, and a `dirname` that silently disagrees with this
30
+ * file's join is a path bug nothing would catch.
31
31
  */
32
32
  readonly root: string;
33
33
  }
34
34
 
35
35
  export type Env = Readonly<Record<string, string | undefined>>;
36
36
 
37
+ /**
38
+ * An embedded binding's `url`: `<scheme>://` and then the HOST path, verbatim — a scheme-tagged
39
+ * path, NOT a URL. `pglite://D:\\app\\.x\\pgdata` is what Windows gets, and no URL parser reads it
40
+ * back (a `\\` is not a separator to one, a space would need encoding). Nothing parses it: db's
41
+ * `pgliteDataDir` and `startStorage` strip the prefix, and the report goes through `safeUrlLabel`,
42
+ * which answers the binding's name for anything that is not a URL. `Bun.pathToFileURL` was the
43
+ * other option, and would have made both readers decode as well as strip.
44
+ */
45
+ export const embeddedLocation = (scheme: 'pglite' | 'file', path: string): string =>
46
+ `${scheme}://${path}`;
47
+
37
48
  const nonEmpty = (value: string | undefined): string | undefined =>
38
49
  value === undefined || value.trim().length === 0 ? undefined : value;
39
50
 
51
+ const s3EndpointOf = (env: Env): string | undefined => nonEmpty(env['S3_ENDPOINT']);
52
+
53
+ /**
54
+ * THE storage choice: a non-empty `S3_ENDPOINT` is object storage, anything else the embedded disk
55
+ * — which alone mints its own signed URLs and so needs `STORAGE_SIGNING_SECRET`. Exported so the
56
+ * deploy contract (`framework-env.ts`) asks this function rather than restating the rule.
57
+ */
58
+ export const storageIsExternal = (env: Env): boolean => s3EndpointOf(env) !== undefined;
59
+
40
60
  /**
41
61
  * Embedded Postgres is PGlite on disk under `.x/`, so a restart keeps the data and a `x db reset`
42
62
  * is a directory delete rather than a container dance.
@@ -51,7 +71,7 @@ export function resolveServices(
51
71
  // (`e2e-app.ts`) instead of resetting the developer's own, beside a running `x dev`.
52
72
  const stateDir = nonEmpty(env['ULTIMATE_STATE_DIR']) ?? join(root, '.x');
53
73
  const databaseUrl = nonEmpty(env['DATABASE_URL']);
54
- const s3Endpoint = nonEmpty(env['S3_ENDPOINT']);
74
+ const s3Endpoint = s3EndpointOf(env);
55
75
  // Created only when something will actually live in it: the embedded database and the local disk
56
76
  // do, in-process events never touch the disk. A container whose bindings are external runs
57
77
  // non-root over a read-only app directory, and a mkdir there is an EACCES at boot for a directory
@@ -67,7 +87,7 @@ export function resolveServices(
67
87
  ? {
68
88
  name: 'db',
69
89
  mode: 'embedded',
70
- url: `pglite://${join(stateDir, 'pgdata')}`,
90
+ url: embeddedLocation('pglite', join(stateDir, 'pgdata')),
71
91
  detail: 'PGlite in this process — set DATABASE_URL to use a real Postgres',
72
92
  }
73
93
  : { name: 'db', mode: 'external', url: databaseUrl, detail: 'DATABASE_URL' },
@@ -77,7 +97,7 @@ export function resolveServices(
77
97
  ? {
78
98
  name: 'storage',
79
99
  mode: 'embedded',
80
- url: `file://${join(stateDir, 'storage')}`,
100
+ url: embeddedLocation('file', join(stateDir, 'storage')),
81
101
  detail: 'local directory — set S3_ENDPOINT to use S3',
82
102
  }
83
103
  : { name: 'storage', mode: 'external', url: s3Endpoint, detail: 'S3_ENDPOINT' },
@@ -4,26 +4,22 @@
4
4
  // and lru unconditionally, redis on `REDIS_URL` and cdn on any real purge driver, so the key was
5
5
  // declared, validated at boot, documented, and read by nothing.
6
6
 
7
- import { existsSync } from 'node:fs';
8
- import { join } from 'node:path';
9
7
  import type { CacheTier, PurgeDriver } from '@ultimat3/cache';
10
8
  import {
11
9
  CacheDriverUnavailableError,
12
- createCdnTier,
13
- createLruTier,
14
- createMemoTier,
15
- createRedisTier,
10
+ cdnTier,
16
11
  isNoopPurgeDriver,
12
+ lruTier,
13
+ memoTier,
17
14
  receiveInvalidationBroadcast,
15
+ redisTier,
18
16
  registerInvalidationBroadcast,
19
17
  registerTier,
20
18
  resetTiers,
21
19
  } from '@ultimat3/cache';
22
- import type { CacheTierName } from '@ultimat3/core';
23
- import { CACHE_TIERS, defineConfig, logger, renderThrowable } from '@ultimat3/core';
20
+ import type { AppConfig, CacheTierName } from '@ultimat3/core';
21
+ import { backoffDelay, defineConfig, logger, renderThrowable } from '@ultimat3/core';
24
22
  import type { Transport, TransportSubscription } from '@ultimat3/realtime/server';
25
- import { APP_CONFIG_EXPORT } from './app-auth';
26
- import { APP_CONFIG_FILE } from './app-root';
27
23
  import type { Env } from './runtime-bindings';
28
24
 
29
25
  /**
@@ -41,11 +37,17 @@ export interface CacheTiersOptions {
41
37
  /**
42
38
  * `config.cache.tiers`, verbatim. REQUIRED, and that is the enforcement (axiom 3): a boot that
43
39
  * has not read the app's declaration cannot call this function at all — it is a type error, not
44
- * a convention to remember. `loadCacheTiers` is what a boot holding only a root calls for it.
40
+ * a convention to remember. `cacheTiersOf` is what a boot holding the loaded config calls for it.
45
41
  */
46
42
  readonly tiers: readonly CacheTierName[];
43
+ /** The wait before re-subscribing after `attempt` failures (1-based). Injected by a test. */
44
+ readonly subscribeRetryMs?: ((attempt: number) => number) | undefined;
47
45
  }
48
46
 
47
+ /** 1 s doubling to 30 s, full jitter: a bus that is down is retried without a fleet in lockstep. */
48
+ const defaultSubscribeRetryMs = (attempt: number): number =>
49
+ backoffDelay({ attempt, base: 1_000, max: 30_000, jitter: 'full' });
50
+
49
51
  /**
50
52
  * The rungs an app that declares none gets — ASKED of `defineConfig` rather than written out, for
51
53
  * two reasons that point the same way: `defaults()` is private to `@ultimat3/core`, and a second
@@ -56,32 +58,9 @@ export const DEFAULT_CACHE_TIERS: readonly CacheTierName[] = defineConfig({
56
58
  name: 'cache-defaults',
57
59
  }).cache.tiers;
58
60
 
59
- const isRecord = (value: unknown): value is Record<string, unknown> =>
60
- typeof value === 'object' && value !== null;
61
-
62
- const isTierList = (value: unknown): value is readonly CacheTierName[] =>
63
- Array.isArray(value) && value.every((entry) => CACHE_TIERS.some((name) => name === entry));
64
-
65
- /**
66
- * `cache.tiers` out of the app's own `app.config.ts` — the sibling of `app-auth.ts`'s
67
- * `loadSignInPath`, and structural for the same reason: `defineConfig` returns a plain object, and
68
- * a config that resolved through an older core simply has no `cache` section.
69
- *
70
- * A list this refuses cannot come from `defineConfig` — `validate()` rejects an unknown rung one
71
- * `await import` above this line — so the fallback is for a hand-written config object, and the
72
- * two rungs every app starts from are the honest answer for one.
73
- */
74
- export async function loadCacheTiers(root: string): Promise<readonly CacheTierName[]> {
75
- const configPath = join(root, APP_CONFIG_FILE);
76
- if (!existsSync(configPath)) return DEFAULT_CACHE_TIERS;
77
- const module = (await import(configPath)) as Record<string, unknown>;
78
- const config = module[APP_CONFIG_EXPORT];
79
- if (!isRecord(config)) return DEFAULT_CACHE_TIERS;
80
- const cache = config['cache'];
81
- if (!isRecord(cache)) return DEFAULT_CACHE_TIERS;
82
- const { tiers } = cache;
83
- return isTierList(tiers) ? tiers : DEFAULT_CACHE_TIERS;
84
- }
61
+ /** `cache.tiers` out of the config `startServices` loads once; the defaults for a root with none. */
62
+ export const cacheTiersOf = (config: AppConfig | undefined): readonly CacheTierName[] =>
63
+ config?.cache.tiers ?? DEFAULT_CACHE_TIERS;
85
64
 
86
65
  /**
87
66
  * `REDIS_URL` is the same "an unset variable means the embedded default" law the db, events,
@@ -106,9 +85,9 @@ function redisUrl(env: Env): string | undefined {
106
85
  function buildTier(name: CacheTierName, options: CacheTiersOptions): CacheTier {
107
86
  switch (name) {
108
87
  case 'request-memo':
109
- return createMemoTier();
88
+ return memoTier();
110
89
  case 'lru':
111
- return createLruTier();
90
+ return lruTier();
112
91
  case 'redis':
113
92
  if (redisUrl(options.env) === undefined) {
114
93
  throw new CacheDriverUnavailableError({
@@ -118,7 +97,7 @@ function buildTier(name: CacheTierName, options: CacheTiersOptions): CacheTier {
118
97
  fix: 'set REDIS_URL in .env, or drop the redis tier from cache.tiers in app.config.ts',
119
98
  });
120
99
  }
121
- return createRedisTier();
100
+ return redisTier();
122
101
  case 'cdn':
123
102
  // A noop tier would put a `cdn` line in every invalidation report claiming keys an edge that
124
103
  // does not exist had accepted — and the `/_x` cache panel renders those reports, so the lie
@@ -131,7 +110,7 @@ function buildTier(name: CacheTierName, options: CacheTiersOptions): CacheTier {
131
110
  fix: 'set FASTLY_API_TOKEN and FASTLY_SERVICE_ID in .env, or CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID, or drop the cdn tier from cache.tiers in app.config.ts',
132
111
  });
133
112
  }
134
- return createCdnTier({ purge: options.purge });
113
+ return cdnTier({ purge: options.purge });
135
114
  }
136
115
  }
137
116
 
@@ -176,24 +155,36 @@ export function startCacheTiers(options: CacheTiersOptions): () => Promise<void>
176
155
  registerInvalidationBroadcast(async (wireTags) => {
177
156
  await options.transport.publish(CACHE_INVALIDATE_SUBJECT, JSON.stringify(wireTags));
178
157
  });
179
- // Not awaited HERE: the boot must not block on a subscribe, and a bus that refuses one is a
180
- // process that misses peer invalidations, never a process that fails to start. The PROMISE is
181
- // held rather than a handle assigned inside a `.then`, because the release ran first whenever
182
- // `stop()` beat the round trip — a NATS bus plus a boot that throws in `bootRoles`, or a test
183
- // that boots and stops immediately — and the subscription that landed afterwards was live with
184
- // nobody left holding it. `mcp-host.ts`'s lazy `started` is the same shape.
185
- const subscribing: Promise<TransportSubscription | undefined> = options.transport
186
- .subscribe(CACHE_INVALIDATE_SUBJECT, (payload: string) => {
187
- void applyBroadcast(payload);
188
- })
189
- .catch((error: unknown) => {
190
- logger.warn('cache.broadcast.subscribe-failed', { error: broadcastErrorText(error) });
191
- return undefined;
192
- });
158
+ // Not awaited HERE: the boot must not block on a subscribe. The PROMISE is held rather than a
159
+ // handle assigned inside a `.then`, because the release ran first whenever `stop()` beat the round
160
+ // trip and the subscription that landed afterwards was live with nobody left holding it.
161
+ // RETRIED until it lands or the release runs (s1-con #8): a refused boot subscribe used to be
162
+ // logged once and never asked again, so the process missed every peer invalidation for its life.
163
+ const released = Promise.withResolvers<undefined>();
164
+ let stopped = false;
165
+ const retryMs = options.subscribeRetryMs ?? defaultSubscribeRetryMs;
166
+ const subscribing = (async (): Promise<TransportSubscription | undefined> => {
167
+ for (let attempt = 1; !stopped; attempt += 1) {
168
+ try {
169
+ return await options.transport.subscribe(CACHE_INVALIDATE_SUBJECT, (payload: string) => {
170
+ void applyBroadcast(payload);
171
+ });
172
+ } catch (error) {
173
+ logger.warn('cache.broadcast.subscribe-failed', {
174
+ error: broadcastErrorText(error),
175
+ attempt,
176
+ });
177
+ await Promise.race([Bun.sleep(retryMs(attempt)), released.promise]);
178
+ }
179
+ }
180
+ return undefined;
181
+ })();
193
182
 
194
183
  // `resetTiers()` drops the registry AND the broadcast in one call: this boot is the only thing
195
184
  // that registers either, and a tier left behind would purge for a process that has stopped.
196
185
  return async () => {
186
+ stopped = true;
187
+ released.resolve(undefined);
197
188
  (await subscribing)?.unsubscribe();
198
189
  resetTiers();
199
190
  };
@@ -2,7 +2,8 @@
2
2
  // decides for pages only, from the SAME `Policy` object every other surface evaluates — the route
3
3
  // table's declared permission — so a denial in `x dev` is the one production produces.
4
4
 
5
- import { actorOf, explainActionPathMiss } from '@ultimat3/action';
5
+ import { explainActionPathMiss } from '@ultimat3/action';
6
+ import { actorOf } from '@ultimat3/core';
6
7
  import type { AuthzDecision, ServerHooks } from '@ultimat3/http';
7
8
  import { asCtx, configuredAuthenticator } from '@ultimat3/http';
8
9
  import type { KnownPermission, Policy } from '@ultimat3/policy';
@@ -0,0 +1,23 @@
1
+ // A container boot over an idempotency store that keeps its records per process. The boot installs
2
+ // the shared Postgres store (`runtime-queue.ts`); an app module that swapped in a per-process one
3
+ // after it left `idempotent: true` meaning "once per replica" — with nothing to say so, because
4
+ // `assertIdempotencyScope` only fires on a `'shared'` DECLARATION and nothing infers a replica count.
5
+
6
+ import { getIdempotencyStore, type IdempotencyStore } from '@ultimat3/action';
7
+ import { logger } from '@ultimat3/core';
8
+
9
+ /** `true` when it warned. Said, not refused: one replica is a real deployment and is correct. */
10
+ export function warnIfIdempotencyProcessScoped(
11
+ store: IdempotencyStore = getIdempotencyStore(),
12
+ ): boolean {
13
+ if (store.scope === 'shared') return false;
14
+ logger.warn('X_CONFIG_INVALID', {
15
+ code: 'X_CONFIG_INVALID',
16
+ cause:
17
+ 'the idempotency store this process ended its boot with keeps records per process, so on more than one replica a retried idempotent action that lands on another replica runs its handler again — docker/helm/values.yaml runs roles.web.replicas: 3',
18
+ // Declaring the scope is the enforcement: `assertIdempotencyScope` then refuses the store.
19
+ fix: "configureIdempotency({ scope: 'shared' })",
20
+ scope: store.scope ?? 'undeclared',
21
+ });
22
+ return true;
23
+ }
@@ -0,0 +1,100 @@
1
+ // What one `isr` request is answered with: a page from the store, or the redirect ITS OWN `load`
2
+ // decided. A returned discriminated outcome, never a throw: the controller's regeneration treats
3
+ // anything thrown as a failure and logs it, and a redirect is an ordinary answer, not a failure.
4
+
5
+ import type { RedirectIntent, RequestContext, RouteParams } from '@ultimat3/http';
6
+ import { asCtx, takeRedirect } from '@ultimat3/http';
7
+ import type { RenderResult, RouteData, RouteEntry } from '@ultimat3/render';
8
+ import { routeDataFor, routeStatusOf } from '@ultimat3/render';
9
+ import type { IsrController, IsrEntry } from '@ultimat3/render/server';
10
+ import { isrKey } from '@ultimat3/render/server';
11
+
12
+ export type IsrOutcome =
13
+ | { readonly kind: 'page'; readonly result: RenderResult }
14
+ | { readonly kind: 'redirect'; readonly to: RedirectIntent };
15
+
16
+ export interface IsrRequest {
17
+ readonly entry: RouteEntry;
18
+ /** What `load` and `meta` are given; `url` is a string because that is what `ld.*` embeds. */
19
+ readonly data: { readonly url: string; readonly params: RouteParams } & Record<string, unknown>;
20
+ readonly ctx: RequestContext;
21
+ readonly isr: IsrController;
22
+ /** The route's whole document for what `load` returned (`runtime-render.ts`'s `documentFrom`). */
23
+ readonly document: (loaded: RouteData) => Promise<string>;
24
+ }
25
+
26
+ /**
27
+ * The producer's answer when `load` redirected. A 3xx status is never a page — `withStatus` refuses
28
+ * one by name, because a document has no `Location` — so a 3xx entry can only be this marker, and a
29
+ * request that JOINED the flight reads it as "the leader's load redirected for the leader".
30
+ */
31
+ const isRedirectMarker = (entry: IsrEntry): boolean => {
32
+ // An entry stored before statuses were (`status` absent) is a 200, and never this marker.
33
+ const { status } = entry;
34
+ return status !== undefined && status >= 300 && status < 400;
35
+ };
36
+
37
+ /**
38
+ * A redirect means the key holds no page. Deleting drops the stale copy, so the next request is a
39
+ * miss and decides again. `markStale` records an invalidation of the key AFTER the regeneration's
40
+ * fence was sampled, and the controller refuses to store a render its fence no longer covers — so
41
+ * the marker this producer returns is never published, and never served from the store.
42
+ */
43
+ function dropPage(isr: IsrController, key: string): void {
44
+ isr.store().delete(key);
45
+ isr.markStale(key);
46
+ }
47
+
48
+ /**
49
+ * `isr`: a store hit is answered WITHOUT `load` — resolving it before the mode choice, as every
50
+ * other mode does, made each hit cost a full SSR's database work. So `load` and its redirect live
51
+ * inside the producer, which runs on a miss and on a stale page's regeneration.
52
+ *
53
+ * Concurrent misses on one key share ONE producer run, and only a PAGE may be shared. A redirect is
54
+ * the request's own (its `load` may have read a cookie), so a request that joined a run whose load
55
+ * redirected decides for itself, exactly as the static single-flight in `appRoutes` does.
56
+ */
57
+ export async function isrOutcome(request: IsrRequest): Promise<IsrOutcome> {
58
+ const { entry, data, ctx, isr } = request;
59
+ // `isrKey(url, locale)`, never `url.pathname`: the query is part of what was rendered — `meta`
60
+ // reads `data.url` — so two URLs differing only in their query are two documents (#171). The
61
+ // locale is `ctx.locale`, the one the `locale` stage negotiated for THIS request.
62
+ const key = isrKey(new URL(data.url), asCtx(ctx).locale);
63
+ let decided: RedirectIntent | undefined;
64
+ // `{ html, status }`, never the bare string: the entry stores the status beside the HTML and
65
+ // serves it on every hit, so a 404 under `isr` is a 404 for its whole TTL.
66
+ const served = await isr.serve(key, async () => {
67
+ const loaded = await routeDataFor(entry.config, data);
68
+ const to = takeRedirect(ctx);
69
+ if (to === undefined)
70
+ return { html: await request.document(loaded), status: routeStatusOf(loaded) };
71
+ // Set by THIS request's producer only. On a stale page it runs behind the answer, and when it
72
+ // has already decided by the time `serve` returns, the redirect is still this request's own.
73
+ decided = to;
74
+ dropPage(isr, key);
75
+ return { html: '', status: to.status };
76
+ });
77
+ if (decided !== undefined) return { kind: 'redirect', to: decided };
78
+ if (!isRedirectMarker(served.entry)) return { kind: 'page', result: served.result };
79
+ return ownOutcome(request);
80
+ }
81
+
82
+ /**
83
+ * A joiner whose leader's load redirected: its own `load`, answered for this request alone. A page
84
+ * here is NOT stored — the store was just told the key has none — so it is `private, no-store`;
85
+ * the next request to miss stores one.
86
+ */
87
+ async function ownOutcome(request: IsrRequest): Promise<IsrOutcome> {
88
+ const loaded = await routeDataFor(request.entry.config, request.data);
89
+ const to = takeRedirect(request.ctx);
90
+ if (to !== undefined) return { kind: 'redirect', to };
91
+ const body = await request.document(loaded);
92
+ return {
93
+ kind: 'page',
94
+ result: {
95
+ status: routeStatusOf(loaded),
96
+ headers: { 'cache-control': 'private, no-store' },
97
+ body,
98
+ },
99
+ };
100
+ }
@@ -0,0 +1,36 @@
1
+ // The ISR controller a boot serves pages through: built, ATTACHED as the framework's revalidator,
2
+ // and handed back with the detach the boot puts on its stop list. One function for `x dev` and the
3
+ // container, so neither can build a controller `invalidateTags` never reaches (plan 101, s2-con #1).
4
+
5
+ import type { IsrController, IsrStore } from '@ultimat3/render/server';
6
+ import { isrController } from '@ultimat3/render/server';
7
+
8
+ export interface AttachedIsr {
9
+ readonly isr: IsrController;
10
+ /** Detaches the revalidator and drops this controller's graph edges; idempotent. */
11
+ readonly release: () => void;
12
+ }
13
+
14
+ /**
15
+ * Unattached, a `revalidate: { tags }` page (no TTL) is fresh until invalidated — and nothing
16
+ * invalidates it: `invalidateTags` calls the registered revalidator, which was nobody.
17
+ */
18
+ export function attachedIsr(options: {
19
+ readonly buildId: string;
20
+ readonly store?: IsrStore;
21
+ }): AttachedIsr {
22
+ const isr = isrController({
23
+ buildId: options.buildId,
24
+ ...(options.store === undefined ? {} : { store: options.store }),
25
+ });
26
+ const detach = isr.attach();
27
+ let released = false;
28
+ return {
29
+ isr,
30
+ release: () => {
31
+ if (released) return;
32
+ released = true;
33
+ detach();
34
+ },
35
+ };
36
+ }
@@ -1,85 +1,120 @@
1
- // `jobs.queues`, `jobs.concurrency` and `jobs.visibilityTimeoutMs`, read out of the app's own
2
- // `app.config.ts` for the `worker` role — the sibling of `runtime-realtime.ts`, and structural
3
- // for its reason. Until 22.0.0 `createWorker` was called with none of them: every worker served
4
- // `default` at its own concurrency whatever the config said, so a `mail` queue ran nothing.
1
+ // The queues a `worker` serves and the slots each gets: `jobs.queues`, `jobs.concurrency` and
2
+ // `jobs.visibilityTimeoutMs` off the app's own `app.config.ts`, and `WORKER_QUEUES` off the
3
+ // Deployment's env — the one per-Deployment answer, since every Deployment runs the same image and
4
+ // the same config (issues #673, #676).
5
5
 
6
- // why: Bun ships no path-joining API — `Object.keys(Bun)` has `file`, `write`, `Glob`,
7
- // `pathToFileURL` and `fileURLToPath`, and nothing that joins a path.
8
- import { join } from 'node:path';
6
+ import type { AppConfig, JobsConcurrency } from '@ultimat3/core';
7
+ import { ConfigInvalidError, logger, renderCauseValue, renderFixShellArg } from '@ultimat3/core';
9
8
  import { registeredJobs, type WorkerOptions } from '@ultimat3/jobs';
10
- import { APP_CONFIG_EXPORT } from './app-auth';
11
- import { APP_CONFIG_FILE } from './app-root';
9
+ import type { Env } from './runtime-bindings';
10
+
11
+ /** The env key a worker Deployment names its queues with: `WORKER_QUEUES=banks,banks-long`. */
12
+ export const WORKER_QUEUES_ENV = 'WORKER_QUEUES';
12
13
 
13
14
  /** What the worker takes from the config. An absent number keeps the worker's own default. */
14
15
  export interface WorkerConfig {
15
16
  readonly queues: readonly string[];
16
- readonly concurrency: number | undefined;
17
+ /**
18
+ * `true` when `WORKER_QUEUES` named `queues`: they are served EXACTLY, never widened by the
19
+ * queues registered jobs are enqueued on. `false` is the union `workerQueuesFor` explains.
20
+ */
21
+ readonly exact: boolean;
22
+ readonly concurrency: JobsConcurrency | undefined;
17
23
  readonly visibilityTimeoutMs: number | undefined;
18
24
  }
19
25
 
26
+ /** A root with no `app.config.ts`: no queue of its own, and the worker's own timings. */
20
27
  const NO_WORKER_CONFIG: WorkerConfig = Object.freeze({
21
28
  queues: [],
29
+ exact: false,
22
30
  concurrency: undefined,
23
31
  visibilityTimeoutMs: undefined,
24
32
  });
25
33
 
26
- const isRecord = (value: unknown): value is Record<string, unknown> =>
27
- typeof value === 'object' && value !== null;
28
-
29
34
  /**
30
- * A number is handed on AS WRITTEN, never screened here: `createWorker`'s `resolveWorkerTimings`
31
- * is the one refusal for a non-finite knob, and core's `defineConfig` screens both counts first.
35
+ * `WORKER_QUEUES`, read: `undefined` when unset or blank (`ROLE`'s rule — an empty value is a
36
+ * template that said nothing), else its names trimmed and deduplicated in the order written. An
37
+ * empty entry (`banks,,long`) is REFUSED, not skipped: it is a queue somebody meant to name, and
38
+ * the worker that dropped it would leave that queue's jobs waiting with nothing in the log.
32
39
  */
33
- const numberOf = (value: unknown): number | undefined =>
34
- typeof value === 'number' ? value : undefined;
40
+ export function workerQueuesFromEnv(env: Env): readonly string[] | undefined {
41
+ const raw = env[WORKER_QUEUES_ENV];
42
+ if (raw === undefined || raw.trim() === '') return undefined;
43
+ const names = raw.split(',').map((name) => name.trim());
44
+ const named = [...new Set(names.filter((name) => name !== ''))];
45
+ if (!names.includes('')) return named;
46
+ throw new ConfigInvalidError({
47
+ cause: `${WORKER_QUEUES_ENV} ${renderCauseValue(raw)} contains an empty queue name — every comma-separated entry must name a queue`,
48
+ fix: `WORKER_QUEUES=${renderFixShellArg(named.join(','), '<queue>,<queue>')} on this Deployment — or unset WORKER_QUEUES to serve jobs.queues and every registered job's queue`,
49
+ meta: { key: WORKER_QUEUES_ENV },
50
+ });
51
+ }
35
52
 
36
- export async function loadWorkerConfig(root: string): Promise<WorkerConfig> {
37
- const path = join(root, APP_CONFIG_FILE);
38
- if (!(await Bun.file(path).exists())) return NO_WORKER_CONFIG;
39
- const module = (await import(path)) as Record<string, unknown>;
40
- const config = module[APP_CONFIG_EXPORT];
41
- const jobs = isRecord(config) ? config['jobs'] : undefined;
42
- if (!isRecord(jobs)) return NO_WORKER_CONFIG;
43
- const queues = Array.isArray(jobs['queues'])
44
- ? jobs['queues'].filter((queue): queue is string => typeof queue === 'string' && queue !== '')
45
- : [];
46
- return {
47
- queues,
48
- concurrency: numberOf(jobs['concurrency']),
49
- visibilityTimeoutMs: numberOf(jobs['visibilityTimeoutMs']),
50
- };
53
+ export function workerConfigOf(config: AppConfig | undefined, env: Env = {}): WorkerConfig {
54
+ const fromEnv = workerQueuesFromEnv(env);
55
+ const base =
56
+ config === undefined
57
+ ? NO_WORKER_CONFIG
58
+ : {
59
+ queues: config.jobs.queues,
60
+ exact: false,
61
+ concurrency: config.jobs.concurrency,
62
+ visibilityTimeoutMs: config.jobs.visibilityTimeoutMs,
63
+ };
64
+ return fromEnv === undefined ? base : { ...base, queues: fromEnv, exact: true };
51
65
  }
52
66
 
53
67
  /**
54
68
  * The queues a worker serves: the configured ones, then every queue a registered job is enqueued
55
69
  * on. The union and not the list, decided 2026-09-23: `x new` and the deployed demo configure
56
70
  * `['<app>-default']` while a `job()` naming no queue lands on `default`, so obeying the list alone
57
- * would leave every such job queued for ever — the silent direction. `undefined` when both are
58
- * empty, which is `createWorker`'s own `['default']`.
71
+ * would leave every such job queued for ever — the silent direction. `exact` (a Deployment that
72
+ * set `WORKER_QUEUES`) is the list alone: that operator split the queues across Deployments on
73
+ * purpose, and widening one to the rest is what made their grace periods mean nothing (#673).
74
+ * `undefined` when nothing is served, which is `jobWorker`'s own `['default']`.
59
75
  */
60
76
  export function workerQueuesFor(
61
77
  configured: readonly string[],
62
78
  registered: readonly string[],
79
+ exact = false,
63
80
  ): readonly string[] | undefined {
64
- const served = [...new Set([...configured, ...registered])];
81
+ const served = [...new Set(exact ? configured : [...configured, ...registered])];
65
82
  return served.length === 0 ? undefined : served;
66
83
  }
67
84
 
85
+ /**
86
+ * An exact worker's one boot line about what it leaves: the queues registered jobs are enqueued
87
+ * on that it will never claim. A warning and not a refusal — another Deployment serving them is
88
+ * the whole point of the split — but this process cannot see that one, so it says what to check.
89
+ */
90
+ function reportUnserved(served: readonly string[], registered: readonly string[]): void {
91
+ const queues = [...new Set(registered)].filter((queue) => !served.includes(queue)).sort();
92
+ if (queues.length === 0) return;
93
+ logger.warn('jobs.worker.queue-unserved', {
94
+ queues,
95
+ served,
96
+ fix: `x jobs ls --json — every queue listed with no worker needs one: WORKER_QUEUES=<queue>,<queue> on another worker Deployment, or add it to this one's`,
97
+ });
98
+ }
99
+
68
100
  type WorkerConfigOptions = Pick<WorkerOptions, 'queues' | 'concurrency' | 'visibilityTimeoutMs'>;
69
101
 
70
102
  /**
71
- * The `createWorker` options the config supplies, read at START so every job the app registered
103
+ * The `jobWorker` options the config supplies, read at START so every job the app registered
72
104
  * on import is counted. A key the config leaves unset is omitted, never passed as `undefined`, so
73
105
  * the worker's own default applies (`exactOptionalPropertyTypes`).
74
106
  */
75
107
  export function workerOptionsFor(config: WorkerConfig | undefined): WorkerConfigOptions {
76
- const queues = workerQueuesFor(
77
- config?.queues ?? [],
78
- registeredJobs().map((handle) => handle.queue),
79
- );
108
+ const registered = registeredJobs().map((handle) => handle.queue);
109
+ const exact = config?.exact === true;
110
+ const queues = workerQueuesFor(config?.queues ?? [], registered, exact);
111
+ if (exact) reportUnserved(queues ?? [], registered);
112
+ // Passed as written: `jobWorker` reads a table by own key and runs a queue it does not name at
113
+ // core's `JOBS_CONCURRENCY_DEFAULT` — the one slot default, so no second table is built here.
114
+ const concurrency = config?.concurrency;
80
115
  return {
81
116
  ...(queues === undefined ? {} : { queues }),
82
- ...(config?.concurrency === undefined ? {} : { concurrency: config.concurrency }),
117
+ ...(concurrency === undefined ? {} : { concurrency }),
83
118
  ...(config?.visibilityTimeoutMs === undefined
84
119
  ? {}
85
120
  : { visibilityTimeoutMs: config.visibilityTimeoutMs }),
@@ -1,5 +1,5 @@
1
1
  // Where a `sync` node's changes come from when this process boots one. Production decodes the
2
- // write-ahead log (`x dev --role replicator`, `PgLogicalReplicationFeed`, a real `DATABASE_URL`).
2
+ // write-ahead log (`x dev --role replicator`, `postgresChangeFeed()`, a real `DATABASE_URL`).
3
3
  // The embedded database has no walsender, and until 2026-09-05 nothing stood in for it: a live
4
4
  // subscription under `x dev` took its snapshot and then heard nothing, so every `--live` query in
5
5
  // every scaffolded app was dead in development — which is where an author first tries one.
@@ -0,0 +1,26 @@
1
+ // The `mail` section of `app.config.ts` the boot obeys: `mail.retainMime`, handed to
2
+ // `@ultimat3/mail`'s `selectMailDriver` — the one option an environment cannot carry. Selection
3
+ // still reads env for WHICH transport; the config only says what the chosen one keeps.
4
+
5
+ import type { AppConfig } from '@ultimat3/core';
6
+ import type { MailEnvironment, MailSelection, MailSelectOptions } from '@ultimat3/mail';
7
+ import { selectMailDriver } from '@ultimat3/mail';
8
+
9
+ /** No config file, or `retainMime: false`, is no option at all — never `retainMime: undefined`. */
10
+ export function mailSelectOptionsOf(config: AppConfig | undefined): MailSelectOptions {
11
+ const retain = config?.mail.retainMime ?? false;
12
+ if (retain === false) return {};
13
+ return { retainMime: retain.maxBytes === undefined ? {} : { maxBytes: retain.maxBytes } };
14
+ }
15
+
16
+ /**
17
+ * The boot's mail selection. Pure, as `selectMailDriver` is: it builds a transport and dials
18
+ * nothing, so a cap above mail's ceiling or retention asked of Resend refuses the boot here.
19
+ */
20
+ export const selectAppMailDriver = (
21
+ env: MailEnvironment,
22
+ config: AppConfig | undefined,
23
+ ): MailSelection =>
24
+ // `retainMimeKey`: mail judges the cap before it picks a transport — the ceiling is its number —
25
+ // and names the `app.config.ts` key in the refusal, so the boot restates no check of its own.
26
+ selectMailDriver(env, { ...mailSelectOptionsOf(config), retainMimeKey: 'mail.retainMime' });
@@ -0,0 +1,29 @@
1
+ // The web role's boot asks once whether `x auth seal-mfa` still has work to do (wiki/Upgrading.md,
2
+ // the operator step for sealed MFA secrets). `x doctor` asks the same question only when someone
3
+ // runs it; an unsealed secret is a user refused at the second factor, so the boot says so too.
4
+
5
+ import { countUnsealedMfaSecrets, postgresAuthAdapter } from '@ultimat3/auth';
6
+ import { logger } from '@ultimat3/core';
7
+ import type { DbClient } from '@ultimat3/db';
8
+
9
+ /**
10
+ * Answers the count it logged, `0` when there was nothing to say or nothing could be asked. Never
11
+ * throws: a database that cannot answer this is the readiness check's to report, not a reason for
12
+ * a pod to refuse traffic its other users can be served.
13
+ */
14
+ export async function warnUnsealedMfaSecrets(client: DbClient): Promise<number> {
15
+ let unsealed = 0;
16
+ try {
17
+ unsealed = await countUnsealedMfaSecrets({ adapter: postgresAuthAdapter(client) });
18
+ } catch {
19
+ return 0;
20
+ }
21
+ if (unsealed === 0) return 0;
22
+ logger.warn('X_MFA_SECRET_UNSEALED', {
23
+ code: 'X_MFA_SECRET_UNSEALED',
24
+ cause: `${unsealed} user(s) hold a second-factor secret that is not sealed; the framework never reads a plaintext secret, so each of them is refused at the second factor until it is sealed`,
25
+ fix: 'x auth seal-mfa --json',
26
+ unsealed,
27
+ });
28
+ return unsealed;
29
+ }