@ultimat3/cli 22.15.0 → 24.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 (267) hide show
  1. package/CLAUDE.md +36 -13
  2. package/README.md +31 -8
  3. package/package.json +29 -28
  4. package/src/admin-catalog-keys.ts +16 -0
  5. package/src/admin-registration.ts +192 -0
  6. package/src/affected.ts +27 -35
  7. package/src/api-registration.ts +125 -11
  8. package/src/app-artifacts.ts +1 -1
  9. package/src/app-auth.ts +4 -27
  10. package/src/app-boundaries.ts +6 -20
  11. package/src/app-config-load.ts +51 -0
  12. package/src/app-env.ts +5 -11
  13. package/src/app-load.ts +55 -11
  14. package/src/app-manifest.ts +16 -8
  15. package/src/app-mcp.ts +10 -23
  16. package/src/app-permissions-borrowed.ts +129 -0
  17. package/src/app-permissions-site.ts +24 -0
  18. package/src/app-permissions.ts +0 -0
  19. package/src/app-reload-graph.ts +7 -30
  20. package/src/app-transport.ts +219 -0
  21. package/src/boundary-findings.ts +16 -4
  22. package/src/browser-transport.ts +160 -0
  23. package/src/budgets.ts +27 -34
  24. package/src/cdp-shot-driver.ts +3 -1
  25. package/src/cmd-auth-spec.ts +16 -0
  26. package/src/cmd-auth.ts +65 -0
  27. package/src/cmd-build-spec.ts +10 -3
  28. package/src/cmd-build.ts +107 -11
  29. package/src/cmd-db-backfill.ts +25 -12
  30. package/src/cmd-db.ts +66 -16
  31. package/src/cmd-deploy-helm.ts +7 -6
  32. package/src/cmd-deploy.ts +4 -4
  33. package/src/cmd-dev.ts +9 -8
  34. package/src/cmd-doctor-spec.ts +2 -9
  35. package/src/cmd-doctor.ts +75 -24
  36. package/src/cmd-env.ts +26 -2
  37. package/src/cmd-generate.ts +195 -47
  38. package/src/cmd-help.ts +19 -6
  39. package/src/cmd-i18n.ts +10 -9
  40. package/src/cmd-jobs-spec.ts +19 -3
  41. package/src/cmd-jobs.ts +136 -7
  42. package/src/cmd-manifest-spec.ts +1 -1
  43. package/src/cmd-manifest.ts +6 -25
  44. package/src/cmd-mcp.ts +46 -15
  45. package/src/cmd-new.ts +15 -1
  46. package/src/cmd-planned.ts +15 -8
  47. package/src/cmd-routes.ts +27 -3
  48. package/src/cmd-secrets-ring.ts +76 -0
  49. package/src/cmd-secrets-spec.ts +10 -2
  50. package/src/cmd-secrets.ts +95 -18
  51. package/src/cmd-shot-matrix.ts +1 -3
  52. package/src/cmd-shot.ts +12 -3
  53. package/src/cmd-tasks.ts +84 -15
  54. package/src/cmd-test-spec.ts +1 -7
  55. package/src/cmd-test.ts +13 -29
  56. package/src/cmd-verify.ts +13 -5
  57. package/src/comment-safe.ts +16 -0
  58. package/src/coverage-floor.ts +334 -0
  59. package/src/coverage-lcov.ts +193 -0
  60. package/src/coverage-source.ts +136 -0
  61. package/src/db-object-drift.ts +30 -0
  62. package/src/db-replay-engine.ts +115 -0
  63. package/src/db-schema-dump.ts +150 -0
  64. package/src/db-schema-refresh.ts +122 -0
  65. package/src/db-subscribes.ts +12 -4
  66. package/src/dev-dashboard-guard.ts +76 -0
  67. package/src/dev-dashboard.ts +51 -29
  68. package/src/dev-lock.ts +4 -7
  69. package/src/dev-route-table.ts +9 -0
  70. package/src/dispatch.ts +3 -6
  71. package/src/doctor-app-url.ts +39 -0
  72. package/src/doctor-auth.ts +95 -0
  73. package/src/doctor-coverage.ts +39 -0
  74. package/src/doctor-guards.ts +54 -0
  75. package/src/doctor-sealed.ts +132 -0
  76. package/src/document-graph.ts +82 -0
  77. package/src/drift-replica-identity.ts +42 -0
  78. package/src/error-catalog.ts +6 -9
  79. package/src/error-codes.ts +55 -3
  80. package/src/error-unthrown.ts +29 -11
  81. package/src/errors.ts +54 -83
  82. package/src/framework-schema-apply.ts +78 -0
  83. package/src/framework-schema-stamp.ts +96 -0
  84. package/src/framework-schema.ts +28 -2
  85. package/src/generate-files.ts +79 -12
  86. package/src/generate-grant-findings.ts +76 -0
  87. package/src/generate-grants.ts +77 -3
  88. package/src/generate-kinds.ts +52 -1
  89. package/src/generate-shadow.ts +46 -0
  90. package/src/generate-write.ts +62 -40
  91. package/src/generated-imports.ts +59 -0
  92. package/src/gh-target.ts +12 -7
  93. package/src/guard-sources.ts +104 -0
  94. package/src/guards.ts +23 -7
  95. package/src/handle-registration.ts +217 -0
  96. package/src/i18n-audit.ts +4 -1
  97. package/src/i18n-registration.ts +36 -0
  98. package/src/image-prepare.ts +51 -8
  99. package/src/import-closure.ts +165 -0
  100. package/src/index.ts +12 -5
  101. package/src/island-bundle.ts +3 -15
  102. package/src/island-store.ts +24 -16
  103. package/src/jobs-drain.ts +95 -21
  104. package/src/jobs-json.ts +50 -0
  105. package/src/jobs-report.ts +7 -4
  106. package/src/json-merge.ts +46 -0
  107. package/src/loopback-host.ts +13 -0
  108. package/src/manifest-staleness.ts +62 -0
  109. package/src/mcp-errors.ts +41 -4
  110. package/src/mcp-host.ts +16 -9
  111. package/src/mcp-test-run.ts +38 -0
  112. package/src/messages.ts +35 -0
  113. package/src/metrics-endpoint.ts +95 -24
  114. package/src/migration-extensions.ts +29 -0
  115. package/src/module-imports.ts +47 -0
  116. package/src/output.ts +24 -9
  117. package/src/page-navigation.ts +7 -23
  118. package/src/page-speculation.ts +5 -20
  119. package/src/permission-grants.ts +41 -9
  120. package/src/prerender-out.ts +34 -7
  121. package/src/prerender.ts +8 -5
  122. package/src/publish-closure.ts +101 -0
  123. package/src/pwa-artifacts.ts +26 -94
  124. package/src/registry.ts +2 -0
  125. package/src/revalidated-response.ts +4 -3
  126. package/src/role-load.ts +143 -0
  127. package/src/role-replicator.ts +16 -0
  128. package/src/role-start.ts +27 -5
  129. package/src/role-sync.ts +55 -20
  130. package/src/role-wake.ts +45 -0
  131. package/src/runtime-admin.ts +141 -0
  132. package/src/runtime-assets.ts +11 -7
  133. package/src/runtime-bindings.ts +4 -4
  134. package/src/runtime-cache.ts +38 -47
  135. package/src/runtime-hooks.ts +5 -2
  136. package/src/runtime-idempotency-scope.ts +23 -0
  137. package/src/runtime-isr.ts +36 -0
  138. package/src/runtime-jobs.ts +8 -33
  139. package/src/runtime-mfa-warning.ts +29 -0
  140. package/src/runtime-notify-retention.ts +10 -46
  141. package/src/runtime-overrides.ts +1 -1
  142. package/src/runtime-purge.ts +38 -16
  143. package/src/runtime-queue.ts +39 -11
  144. package/src/runtime-realtime.ts +9 -45
  145. package/src/runtime-render.ts +68 -35
  146. package/src/runtime-services.ts +22 -18
  147. package/src/runtime-storage.ts +10 -5
  148. package/src/scaffold-typecheck.ts +1 -0
  149. package/src/schema-diff.ts +16 -0
  150. package/src/schema-drift.ts +45 -15
  151. package/src/schema-dump-drift.ts +64 -0
  152. package/src/seo-routes.ts +80 -9
  153. package/src/serve-boot.ts +114 -158
  154. package/src/serve-drain.ts +9 -33
  155. package/src/serve-env.ts +9 -6
  156. package/src/serve-prebuilt-paths.ts +19 -0
  157. package/src/serve-prebuilt.ts +84 -0
  158. package/src/serve-web.ts +169 -0
  159. package/src/serve.ts +6 -9
  160. package/src/server-barrels.ts +54 -0
  161. package/src/shot-locale.ts +5 -23
  162. package/src/site-assets.ts +3 -5
  163. package/src/site-config.ts +9 -48
  164. package/src/site-seo.ts +1 -1
  165. package/src/sitemap-lastmod.ts +5 -0
  166. package/src/solid-loader.ts +12 -3
  167. package/src/source-edit.ts +49 -0
  168. package/src/source-files.ts +6 -0
  169. package/src/stored-object-headers.ts +43 -0
  170. package/src/sync-url.ts +2 -2
  171. package/src/tasks-facts.ts +3 -26
  172. package/src/templates/action.ts +147 -11
  173. package/src/templates/admin-catalog.ts +58 -0
  174. package/src/templates/admin-page.ts +36 -8
  175. package/src/templates/admin.ts +57 -17
  176. package/src/templates/backfill.ts +121 -87
  177. package/src/templates/entity.ts +140 -44
  178. package/src/templates/guard-animated-layout-property.ts +4 -11
  179. package/src/templates/guard-bare-error.ts +9 -13
  180. package/src/templates/guard-focus-visible.ts +4 -11
  181. package/src/templates/guard-image-dimensions.ts +6 -6
  182. package/src/templates/guard-island-without-states.ts +2 -4
  183. package/src/templates/guard-raw-breakpoint.ts +210 -0
  184. package/src/templates/guard-raw-colour.ts +4 -11
  185. package/src/templates/guard-raw-length.ts +375 -0
  186. package/src/templates/guard-raw-motion.ts +213 -0
  187. package/src/templates/guard-raw-shadow.ts +187 -0
  188. package/src/templates/guard-raw-z-index.ts +166 -0
  189. package/src/templates/guard-repo-raw-sql.ts +345 -0
  190. package/src/templates/guard-semantic-interactive.ts +6 -6
  191. package/src/templates/guard-undeclared-custom-property.ts +217 -0
  192. package/src/templates/guard-undefined-style-class.ts +215 -0
  193. package/src/templates/guard-untranslated-string.ts +6 -6
  194. package/src/templates/guard-unzoned-date.ts +9 -13
  195. package/src/templates/index.ts +3 -2
  196. package/src/templates/island.ts +34 -51
  197. package/src/templates/job-body-test.ts +121 -0
  198. package/src/templates/job.ts +23 -8
  199. package/src/templates/naming.ts +28 -0
  200. package/src/templates/policy.ts +16 -9
  201. package/src/templates/query.ts +242 -26
  202. package/src/templates/resource-create.ts +62 -18
  203. package/src/templates/resource-form-island.ts +58 -80
  204. package/src/templates/resource-page.ts +269 -0
  205. package/src/templates/resource-service.ts +96 -0
  206. package/src/templates/resource.ts +86 -61
  207. package/src/templates/route.ts +41 -35
  208. package/src/templates/scaffold-api.ts +55 -1
  209. package/src/templates/scaffold-app.ts +177 -53
  210. package/src/templates/scaffold-auth.ts +83 -19
  211. package/src/templates/scaffold-browser-client.ts +90 -0
  212. package/src/templates/scaffold-container-compose.ts +10 -1
  213. package/src/templates/scaffold-container.ts +20 -2
  214. package/src/templates/scaffold-dashboard-bare.ts +64 -2
  215. package/src/templates/scaffold-dashboard-example.ts +124 -31
  216. package/src/templates/scaffold-dashboard-shared.ts +1 -20
  217. package/src/templates/scaffold-dashboard.ts +1 -2
  218. package/src/templates/scaffold-db-client.ts +144 -0
  219. package/src/templates/scaffold-db-package.ts +90 -8
  220. package/src/templates/scaffold-docs.ts +50 -5
  221. package/src/templates/scaffold-guards.ts +54 -11
  222. package/src/templates/scaffold-helm-fences.ts +76 -0
  223. package/src/templates/scaffold-helm-templates.ts +32 -7
  224. package/src/templates/scaffold-helm.ts +31 -1
  225. package/src/templates/scaffold-http.ts +9 -7
  226. package/src/templates/scaffold-repo.ts +61 -3
  227. package/src/templates/scaffold-roles.ts +56 -9
  228. package/src/templates/scaffold-shell.ts +74 -105
  229. package/src/templates/scaffold-site.ts +45 -19
  230. package/src/templates/scaffold-ui-package.ts +18 -0
  231. package/src/templates/slice-foundation.ts +46 -8
  232. package/src/test-counts.ts +14 -2
  233. package/src/test-ignores.ts +25 -0
  234. package/src/test-passes.ts +0 -15
  235. package/src/test-select.ts +5 -14
  236. package/src/test-shards.ts +41 -93
  237. package/src/test-workers.ts +26 -4
  238. package/src/theme-boot.ts +6 -31
  239. package/src/transport-calls.ts +105 -0
  240. package/src/trusted-hops-error.ts +17 -0
  241. package/src/unscanned-admin.ts +54 -0
  242. package/src/unthrown-codes.ts +26 -0
  243. package/src/verify-checks.ts +43 -53
  244. package/src/verify-coverage-run.ts +190 -0
  245. package/src/verify-deadline.ts +353 -0
  246. package/src/verify-floor.ts +36 -9
  247. package/src/verify-merge.ts +171 -45
  248. package/src/verify-part-step.ts +90 -0
  249. package/src/verify-progress.ts +25 -0
  250. package/src/verify-role-load-probe.ts +54 -0
  251. package/src/verify-role-load.ts +148 -0
  252. package/src/verify-run.ts +116 -37
  253. package/src/verify-shard.ts +33 -11
  254. package/src/verify-stalled.ts +152 -0
  255. package/src/verify-step.ts +42 -1
  256. package/src/verify-tests.ts +83 -8
  257. package/src/workspace-checks.ts +50 -8
  258. package/src/workspace-dep-edit.ts +127 -0
  259. package/src/build-errors.ts +0 -54
  260. package/src/cmd-dev-fixture.ts +0 -204
  261. package/src/dev-live-fixture.ts +0 -122
  262. package/src/policy-fixture.ts +0 -78
  263. package/src/realtime-browser-probe-fixture.ts +0 -9
  264. package/src/role-start-fixture.ts +0 -78
  265. package/src/scaffold-fixture.ts +0 -160
  266. package/src/templates/island-fixture.ts +0 -76
  267. package/src/verify-errors.ts +0 -49
@@ -9,7 +9,7 @@ import { probeImage } from '@ultimat3/core';
9
9
  import type { CacheHint, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
10
10
  import { applyCacheHeaders } from '@ultimat3/http';
11
11
  import type { ImageQuery, ImageTransformDriver } from '@ultimat3/seo';
12
- import { builtinImageDriver, DEFAULT_WIDTHS, parseImageQuery } from '@ultimat3/seo';
12
+ import { builtinImageDriver, DEFAULT_WIDTHS, parseImageQuery, usableWidths } from '@ultimat3/seo';
13
13
  import type { ImageTransform, Storage, VariantFormat } from '@ultimat3/storage';
14
14
  import { isTenantScoped, isVariantFormat, variantKey } from '@ultimat3/storage';
15
15
  import { faviconRoute } from './favicon';
@@ -29,6 +29,7 @@ import {
29
29
  } from './runtime-storage';
30
30
  import { siteAssetRoutes } from './site-asset-routes';
31
31
  import { siteAssetTable } from './site-assets';
32
+ import { storedObjectHeaders } from './stored-object-headers';
32
33
 
33
34
  /**
34
35
  * Storage-backed images. `responsiveImage({ src: '/media/<key>' })` mints its variants under it.
@@ -58,21 +59,24 @@ const IMMUTABLE_IMAGE: CacheHint = { mode: 'immutable' };
58
59
  * blast radius and does not close it: 8192 stored objects per source, per format, is amplification
59
60
  * a tenant drives with a `for` loop.
60
61
  *
61
- * The set is `DEFAULT_WIDTHS` **plus the source's intrinsic width**, which is exactly what
62
- * `usableWidths` puts in a `srcset` — clamping to the constant alone would refuse the widest entry
63
- * of every image whose intrinsic width is not one of the eight, a URL the framework mints itself.
62
+ * The set is `usableWidths(intrinsic, DEFAULT_WIDTHS)` — the function that writes the `srcset`,
63
+ * asked rather than restated. A restatement (`DEFAULT_WIDTHS` plus the intrinsic width) disagreed
64
+ * with it for a source wider than `MAX_IMAGE_WIDTH`, whose widest candidate is the ceiling: that
65
+ * URL, minted by the framework itself, was decoded on every request and never stored.
64
66
  * Anything outside it is still SERVED: this decides what is written, not what is answered, so no
65
67
  * caller gains a new 4xx and the disk stops growing on a stranger's key.
66
68
  */
67
69
  const isMintableWidth = (width: number | undefined, intrinsic: number): boolean =>
68
- width === undefined || width === intrinsic || DEFAULT_WIDTHS.includes(width);
70
+ width === undefined || usableWidths(intrinsic, DEFAULT_WIDTHS).includes(width);
69
71
 
70
72
  const imageResponse = (bytes: Uint8Array, contentType: string, cache: CacheHint): Response =>
71
73
  applyCacheHeaders(
72
74
  // Copied, not passed through: a `Uint8Array<ArrayBufferLike>` may be backed by a
73
75
  // `SharedArrayBuffer`, which `Response` does not accept, and copying is what makes that true
74
76
  // by construction rather than by a cast that would only silence it.
75
- new Response(new Uint8Array(bytes), { headers: { 'content-type': contentType } }),
77
+ new Response(new Uint8Array(bytes), {
78
+ headers: { 'content-type': contentType, ...storedObjectHeaders(contentType) },
79
+ }),
76
80
  cache,
77
81
  );
78
82
 
@@ -174,7 +178,7 @@ export interface AssetRoutesOptions {
174
178
  /** App root. The source icon is resolved against it; storage keys never are. */
175
179
  readonly root: string;
176
180
  readonly storage: Storage;
177
- /** Replaces `builtinImageDriver` for `/media/*`. Omitted, core's PNG/JPEG pipeline. */
181
+ /** Replaces `builtinImageDriver` for `/media/*`. Omitted, core's PNG/JPEG/WebP pipeline. */
178
182
  readonly images?: ImageTransformDriver;
179
183
  /**
180
184
  * `manifest.webmanifest`, resolved at boot by `pwa-artifacts.ts`. Absent when the app declares
@@ -24,10 +24,10 @@ 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
  }
@@ -4,8 +4,6 @@
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,
@@ -19,11 +17,9 @@ import {
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,
@@ -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
  };
@@ -1,8 +1,8 @@
1
- // The two seams `@ultimat3/http` leaves open, bound to the packages that own them. `authorize`
1
+ // The seams `@ultimat3/http` leaves open, bound to the packages that own them. `authorize`
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 } from '@ultimat3/action';
5
+ import { actorOf, explainActionPathMiss } from '@ultimat3/action';
6
6
  import type { AuthzDecision, ServerHooks } from '@ultimat3/http';
7
7
  import { asCtx, configuredAuthenticator } from '@ultimat3/http';
8
8
  import type { KnownPermission, Policy } from '@ultimat3/policy';
@@ -57,6 +57,9 @@ export function devHooks(options: DevHookOptions = {}): ServerHooks {
57
57
  ...(authenticate === undefined ? {} : { authenticate }),
58
58
  ...(devNotices === undefined ? {} : { devNotices }),
59
59
  ...(errorPage === undefined ? {} : { errorPage }),
60
+ // Every web role, `x dev` and the container alike: a caller that derived an action's URL under
61
+ // the wrong `pathStyle` is told which one this app serves, not that no route exists.
62
+ explainMiss: explainActionPathMiss,
60
63
  authorize: (route, _request, ctx): AuthzDecision => {
61
64
  // An action route never arrives here: it carries `enforcedBy: 'handler'`, so the pipeline
62
65
  // never asks. `invoke` is its one evaluation, and the only one holding the row a row-level
@@ -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,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 { createIsrController } 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 = createIsrController({
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,14 +1,9 @@
1
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.
2
+ // `app.config.ts` for the `worker` role, out of the config `startServices` loads once. Until 22.0.0
3
+ // `createWorker` was called with none of them, so a `mail` queue ran nothing.
5
4
 
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';
5
+ import type { AppConfig } from '@ultimat3/core';
9
6
  import { registeredJobs, type WorkerOptions } from '@ultimat3/jobs';
10
- import { APP_CONFIG_EXPORT } from './app-auth';
11
- import { APP_CONFIG_FILE } from './app-root';
12
7
 
13
8
  /** What the worker takes from the config. An absent number keeps the worker's own default. */
14
9
  export interface WorkerConfig {
@@ -17,37 +12,17 @@ export interface WorkerConfig {
17
12
  readonly visibilityTimeoutMs: number | undefined;
18
13
  }
19
14
 
15
+ /** A root with no `app.config.ts`: no queue of its own, and the worker's own timings. */
20
16
  const NO_WORKER_CONFIG: WorkerConfig = Object.freeze({
21
17
  queues: [],
22
18
  concurrency: undefined,
23
19
  visibilityTimeoutMs: undefined,
24
20
  });
25
21
 
26
- const isRecord = (value: unknown): value is Record<string, unknown> =>
27
- typeof value === 'object' && value !== null;
28
-
29
- /**
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.
32
- */
33
- const numberOf = (value: unknown): number | undefined =>
34
- typeof value === 'number' ? value : undefined;
35
-
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
- };
22
+ export function workerConfigOf(config: AppConfig | undefined): WorkerConfig {
23
+ if (config === undefined) return NO_WORKER_CONFIG;
24
+ const { queues, concurrency, visibilityTimeoutMs } = config.jobs;
25
+ return { queues, concurrency, visibilityTimeoutMs };
51
26
  }
52
27
 
53
28
  /**
@@ -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 { BuiltinAdapter, countUnsealedMfaSecrets } 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: new BuiltinAdapter(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
+ }
@@ -1,18 +1,8 @@
1
- // `notify.inboxReadRetentionMs` and `notify.inboxUnreadRetentionMs`, read out of the app's own
2
- // `app.config.ts`. The sibling of `app-auth.ts`'s `loadSignInPath` and `runtime-cache.ts`'s
3
- // `loadCacheTiers`, and structural for the same reason: `defineConfig` returns a plain object, so
4
- // a config that resolved through an older core simply has no `notify` section.
5
- //
6
- // WHY A LOADER AND NOT A BOOT ARGUMENT: `startServices` has no `AppConfig` — the app's modules
7
- // import after it, which is the same reason `configureAuthLimiters` takes a factory. A key read
8
- // here is a key read from the file the operator edited, per boot.
1
+ // `notify.inboxReadRetentionMs` and `notify.inboxUnreadRetentionMs`, out of the config
2
+ // `startServices` loads once (`app-config-load.ts`) — before the app's modules import, so the window
3
+ // is read from the file the operator edited, per boot.
9
4
 
10
- // why: Bun ships no path-joining API — `Object.keys(Bun)` has `file`, `write`, `Glob`,
11
- // `pathToFileURL` and `fileURLToPath`, and nothing that joins a path.
12
- import { join } from 'node:path';
13
- import { INBOX_RETENTION_KEYS } from '@ultimat3/core';
14
- import { APP_CONFIG_EXPORT } from './app-auth';
15
- import { APP_CONFIG_FILE } from './app-root';
5
+ import type { AppConfig } from '@ultimat3/core';
16
6
 
17
7
  /**
18
8
  * The two windows in milliseconds, each `undefined` where the app named none.
@@ -32,38 +22,12 @@ export const NO_INBOX_RETENTION: InboxRetention = Object.freeze({
32
22
  unreadMs: undefined,
33
23
  });
34
24
 
35
- const isRecord = (value: unknown): value is Record<string, unknown> =>
36
- typeof value === 'object' && value !== null;
37
-
38
25
  /**
39
- * Re-screened here rather than trusted from `defineConfig`. That validator runs when the app
40
- * IMPORTS its config, and this loader imports the module for its export — so a config object
41
- * assembled by hand, or one that resolved through a core too old to validate the section, reaches
42
- * this line unchecked. A window that is not a positive finite number reads as absent: refusing
43
- * would take the sweep over the other four framework tables down with it, and the whole point of
44
- * the key is that not sweeping is a legal state.
26
+ * Core's validator (through the loader) refuses a window that is not a positive finite number, so
27
+ * a `NaN` cutoff — every row older than it, the inbox emptied on the first pass — cannot reach here.
45
28
  */
46
- const windowOf = (section: Record<string, unknown>, key: string): number | undefined => {
47
- const value = section[key];
48
- if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined;
49
- return value;
50
- };
51
-
52
- export async function loadInboxRetention(root: string): Promise<InboxRetention> {
53
- const configPath = join(root, APP_CONFIG_FILE);
54
- // `Bun.file(p).exists()` rather than `existsSync`: this function is already async, so the
55
- // `node:fs` import buys nothing here — and an import with nothing to say for itself is what
56
- // `bun run node-imports` refuses.
57
- if (!(await Bun.file(configPath).exists())) return NO_INBOX_RETENTION;
58
- const module = (await import(configPath)) as Record<string, unknown>;
59
- const config = module[APP_CONFIG_EXPORT];
60
- if (!isRecord(config)) return NO_INBOX_RETENTION;
61
- const notify = config['notify'];
62
- if (!isRecord(notify)) return NO_INBOX_RETENTION;
63
- // Keyed off core's own list rather than two string literals here, so the two names exist in one
64
- // place. What actually stops a third window being declared-and-never-read is
65
- // `bun run scripts/config-readers.ts` — every leaf key of `AppConfig` needs a reader or a pinned
66
- // reason, and it is the guard whose own header calls that "the framework's most repeated defect".
67
- const [readKey, unreadKey] = INBOX_RETENTION_KEYS;
68
- return { readMs: windowOf(notify, readKey), unreadMs: windowOf(notify, unreadKey) };
29
+ export function inboxRetentionOf(config: AppConfig | undefined): InboxRetention {
30
+ const notify = config?.notify;
31
+ if (notify === undefined) return NO_INBOX_RETENTION;
32
+ return { readMs: notify.inboxReadRetentionMs, unreadMs: notify.inboxUnreadRetentionMs };
69
33
  }
@@ -69,7 +69,7 @@ export interface RuntimeOverrides {
69
69
  * answers a document, and a protocol that fixes its own error shape is neither.
70
70
  */
71
71
  readonly routes?: readonly Route[];
72
- /** The `/media/*` transform. Omitted, `builtinImageDriver` — core's PNG/JPEG pipeline. */
72
+ /** The `/media/*` transform. Omitted, `builtinImageDriver` — core's PNG/JPEG/WebP pipeline. */
73
73
  readonly images?: ImageTransformDriver;
74
74
  /**
75
75
  * Who is dialling the `sync` node. Omitted, the app's own `configureAuthenticator()` is adapted
@@ -1,10 +1,11 @@
1
1
  // The retention sweep this boot owns: the framework tables that grow with traffic, the `purge()`
2
2
  // job that empties them and the `task` that fires it hourly.
3
3
  //
4
- // FIVE TARGETS over six tables, `As of 2026-08-27` — `x_idempotency`, `x_rate_limit`, the two
5
- // `x_auth` tables under one target, `x_notify_deliveries` and `x_notify_inbox`. Counted nowhere in
6
- // prose but here: this header said "three" for two releases after the notify tables joined the
7
- // boot's DDL, which is how `x_notify_inbox` became the one framework table nothing swept.
4
+ // SEVEN TARGETS over seven tables, `As of 2026-10-02` — `x_idempotency`, `x_rate_limit`, the
5
+ // `x_auth` limiter's table, `x_notify_deliveries`, `x_notify_inbox`, `x_notify_digests` and
6
+ // `x_job_events`. Counted nowhere in prose but here: this header said "three" for two releases
7
+ // after the notify tables joined the boot's DDL, which is how `x_notify_inbox` became the one
8
+ // framework table nothing swept.
8
9
  //
9
10
  // WHY here and not in the packages that own the tables: `postgresIdempotencyStore` (tier 3),
10
11
  // `postgresRateLimitStore` (tier 2) and `postgresAuthLimiter` (tier 2) cannot see each other and
@@ -20,9 +21,16 @@
20
21
  import type { PostgresIdempotencyStore } from '@ultimat3/action';
21
22
  import { purgeAuthLimits } from '@ultimat3/auth';
22
23
  import type { PostgresRateLimitStore } from '@ultimat3/http';
23
- import type { JobHandle, PurgeInput, PurgeTarget } from '@ultimat3/jobs';
24
- import { DEFAULT_PURGE_CRON, getJob, getTask, purge, task } from '@ultimat3/jobs';
25
- import { purgeNotifyDeliveries, purgeNotifyInbox } from '@ultimat3/notify';
24
+ import type { EventBus, JobHandle, PurgeInput, PurgeTarget } from '@ultimat3/jobs';
25
+ import {
26
+ DEFAULT_PURGE_CRON,
27
+ eventsPurgeTarget,
28
+ getJob,
29
+ getTask,
30
+ purge,
31
+ task,
32
+ } from '@ultimat3/jobs';
33
+ import { purgeNotifyDeliveries, purgeNotifyDigests, purgeNotifyInbox } from '@ultimat3/notify';
26
34
  import type { InboxRetention } from './runtime-notify-retention';
27
35
  import { NO_INBOX_RETENTION } from './runtime-notify-retention';
28
36
 
@@ -32,7 +40,7 @@ export const PURGE_JOB_NAME = 'x.purge';
32
40
  export const PURGE_TASK_NAME = 'x.purge.hourly';
33
41
 
34
42
  /**
35
- * The two stores this boot BUILT, handed over rather than rebuilt here. A second
43
+ * The stores this boot BUILT, handed over rather than rebuilt here. A second
36
44
  * `postgresIdempotencyStore({ executor })` would sweep on the default window even where the boot
37
45
  * had configured another — two answers to "how long is a record kept", and the shorter one
38
46
  * deletes reservations a retry is still entitled to.
@@ -45,15 +53,22 @@ export interface RetentionStores {
45
53
  readonly idempotency: PostgresIdempotencyStore;
46
54
  readonly rateLimit: PostgresRateLimitStore;
47
55
  /**
48
- * The app's own answer to "how long is an unread message kept", loaded from `app.config.ts` by
49
- * `loadInboxRetention`. Absent windows mean the inbox is never swept, which is the default and a
50
- * decision rather than an oversight — see `NotifyConfig` in `@ultimat3/core`.
56
+ * The bus this boot installed for `step.waitForEvent`. REQUIRED: `x_job_events` takes a row per
57
+ * publish and `find` only filters an expired one, so a boot that left it out is the table
58
+ * growing for the life of the deployment — which it did until this member existed.
59
+ */
60
+ readonly events: EventBus;
61
+ /**
62
+ * The app's own answer to "how long is an unread message kept", read off the config
63
+ * `startServices` loads (`inboxRetentionOf`). Absent windows mean the inbox is never swept,
64
+ * which is the default and a decision rather than an oversight — see `NotifyConfig` in
65
+ * `@ultimat3/core`.
51
66
  */
52
67
  readonly inboxRetention?: InboxRetention | undefined;
53
68
  }
54
69
 
55
70
  /**
56
- * The two notify tables, and neither store is in `RetentionStores` — deliberately, and for the
71
+ * The three notify tables, and no store is in `RetentionStores` — deliberately, and for the
57
72
  * reason `authTarget` is not either. `setNotifyStores` is an APP's boot line and runs when the
58
73
  * app's modules import, which is AFTER this install; `framework-schema.ts` says so where it
59
74
  * applies the DDL "whether or not this boot calls `setNotifyStores`". So the sweep can only ask,
@@ -79,6 +94,12 @@ function notifyTargets(retention: InboxRetention): readonly PurgeTarget[] {
79
94
  retention.unreadMs === undefined ? undefined : new Date(nowMs - retention.unreadMs),
80
95
  }),
81
96
  },
97
+ {
98
+ name: 'x_notify_digests',
99
+ // The job's clock, and the store's own window (`createPgDigestStore({ retentionMs })`, a week
100
+ // by default) — a closed window only its dead-lettered flush would ever have drained.
101
+ purgeExpired: (nowMs: number): Promise<number> => purgeNotifyDigests(nowMs),
102
+ },
82
103
  ];
83
104
  }
84
105
 
@@ -94,14 +115,13 @@ let installed: readonly PurgeTarget[] = [];
94
115
  let sweep: JobHandle<PurgeInput> | undefined;
95
116
 
96
117
  /**
97
- * `x_auth_failures` and `x_auth_lockouts` together, under the prefix they share: one target,
98
- * because `purgeAuthLimits()` clears both in one statement and two targets under one pair of
99
- * tables would be a second delete that removes nothing.
118
+ * `x_auth_lockouts` — every credential bucket, one row per key. The target keeps the `x_auth`
119
+ * name it has always reported under: it is what a dashboard and `PurgeReport.swept` already key on.
100
120
  */
101
121
  const authTarget: PurgeTarget = {
102
122
  name: 'x_auth',
103
123
  // No `nowMs`: a limiter built through the seam holds the clock its host handed it, which is the
104
- // clock every `at_ms` in those tables was written from.
124
+ // clock every instant in that table was written from.
105
125
  purgeExpired: () => purgeAuthLimits(),
106
126
  };
107
127
 
@@ -121,6 +141,8 @@ function retentionTargets(stores: RetentionStores): readonly PurgeTarget[] {
121
141
  },
122
142
  authTarget,
123
143
  ...notifyTargets(stores.inboxRetention ?? NO_INBOX_RETENTION),
144
+ // Its own clock, and so no `nowMs`: every instant in that table is the database's.
145
+ eventsPurgeTarget(stores.events),
124
146
  ];
125
147
  }
126
148