@ultimat3/core 23.0.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.
package/CLAUDE.md CHANGED
@@ -60,8 +60,10 @@ top-level `UltimateError` use in `error-codes.ts`.
60
60
  | Concept | Owner | Note |
61
61
  |---|---|---|
62
62
  | which deploy this is | `environment.ts` (`ULTIMATE_ENV`) | the twin of `ROLE`; never a second env var |
63
+ | one-home helpers | `store-mode` `html-escape` `cookie` `fnv1a` `pg-executor` | never copied (`X_HELPER_COPY`) |
63
64
  | what this process does | `roles.ts` (`ROLE`) | |
64
65
  | how a route renders, caches offline and hydrates | `route-vocabulary.ts` (`RENDER_MODES`, `OFFLINE_STRATEGIES`, `HYDRATE_STRATEGIES`) | every union is `(typeof ARRAY)[number]`, pinned in `type-pins.ts`; `scripts/render-modes.test.ts` refuses a second declaration. Re-export it, never restate it |
66
+ | which of two route patterns wins a pathname | `route-rank.ts` (`routeRank`) | the request router's order as one integer: segment by segment, literal 3 > `:param` 2 > `*catch-all` 1, ENDED 4, packed base 5 over 22 segments. Read by `@ultimat3/render`'s `compilePattern` and `@ultimat3/pwa`'s rule order — both tier 4, so the one copy lives here. `@ultimat3/http`'s trie encodes the same order by its walk, not by this number. Never a sum: 100/10/1 ranked `/:a/b/c` above `/a/:x/:y` |
65
67
  | which rungs a cache ladder has | `cache-vocabulary.ts` (`CACHE_TIERS`) | `@ultimat3/cache`'s `TIER_ORDER` IS this array. `isr` is a `RenderMode`, never a tier |
66
68
  | which build of the APP this is | `app-version.ts` (`APP_VERSION`) | one reader, `dev` by default |
67
69
  | the values | `env.ts` | `checkEnv().values` holds REAL secrets — printing goes through `maskedEnvValues()` |
@@ -79,6 +81,8 @@ top-level `UltimateError` use in `error-codes.ts`.
79
81
  | which row survives a conflict | `conflict-policy.ts` (`ConflictPolicy`, `resolveConflict`, `Row`) | read by `action`'s mutator and `realtime`'s rebase |
80
82
  | the four shapes of an async region | `async-state.ts` (`AsyncState`) | `realtime` returns it, `ui` renders it. `bun run render-modes` refuses a second status union sharing three members |
81
83
  | is this `unknown` a keyed record? | `json-object.ts` (`isJsonObject`) | narrows a shape; does not certify provenance |
84
+ | may a caller read this 5xx `cause`? | `public-cause.ts` (`hasPublicCause`) | one table for http, mcp, ai. `registerPublicCause` is `@ultimat3/http`'s `registerProblemMeta` writing it — never an app's door |
85
+ | is this field a credential? | `logger.ts` (`isRedactedKey`) | exact keys + `CREDENTIAL_NAME`; a bare `token` suffix is NOT one (`idempotencyToken`, `maxTokens`). Log line, monitor envelope and `action`'s audit ask it |
82
86
  | a value that must not be printed | `secret.ts` | redacted by VALUE; `revealSecret()` is the one, greppable, way out |
83
87
  | an `Intl` formatter cache, and the screen in front of it | `intl-cache.ts` (`cachedFormatter`, `canonicalLocale`, `assertLocale`, `MAX_CACHED_FORMATTERS`, `MAX_LOCALE_EXCERPT`) | a locale arrives from a header: refuse a non-tag (`X_LOCALE_INVALID`), key canonically AND bound the cache — never a copy of any of the three. The cause quotes at most `MAX_LOCALE_EXCERPT` (35) code points; the whole tag rides in `meta.locale` |
84
88
  | the text direction of a locale | `locale-direction.ts` (`directionOf`, `isRtl`, `Direction`) | re-exported by `@ultimat3/i18n`; lives here so `@ultimat3/ui` need not reach the i18n barrel |
@@ -192,6 +196,8 @@ is the bug:
192
196
  WHOLE drain's; `DEFAULT_DEADLINE_MS` (25 s) always applies; it is real monotonic time
193
197
  (`systemClock`), never the injected `clock`. `drainDeadlineMs()` is the one decision point.
194
198
  `settleWithin` attaches a rejection handler unconditionally.
199
+ - **Who a health endpoint tells what is ONE rule, here** (`health-disclosure.ts`): `healthBody` +
200
+ `healthPeerListed`, called by http and by realtime's sync listener. Never a second copy.
195
201
  - **A readiness grace runs before the `accept` phase** (`lifecycle-grace.ts`): `/readyz` answers 503
196
202
  with the socket still open for `drain.readinessGraceMs`, ADDED to `deadlineMs` (a chart's
197
203
  `terminationGracePeriodSeconds` must exceed the sum — 5 s + 25 s by default). Unset: 0 in
package/README.md CHANGED
@@ -32,6 +32,7 @@ Zero dependencies, zero `@ultimat3/*` imports.
32
32
  | typed env validated at boot | `env.ts` |
33
33
  | `.env.example` rendered from that schema, and its drift check | `env-example.ts` |
34
34
  | named environments + `ULTIMATE_ENV` resolution | `environment.ts` |
35
+ | which store backs a seam — `storeMode(env)`: `memory` under `test`, `database` everywhere else | `store-mode.ts` |
35
36
  | the boot refusal of a shipped dev signing secret outside development/test — `X_CURSOR_SECRET_DEV` | `dev-secrets.ts` |
36
37
  | a value that cannot be printed by accident | `secret.ts` |
37
38
  | the committed encrypted secrets envelope, AES-256-GCM | `secrets.ts` |
@@ -40,8 +41,11 @@ Zero dependencies, zero `@ultimat3/*` imports.
40
41
  | the key ring those work under: the current key plus retired ones | `seal-keys.ts` |
41
42
  | `defineConfig()` for `app.config.ts` | `config.ts` |
42
43
  | how overlays layer onto it — per section, key by key | `config-merge.ts` |
44
+ | what each key is when no layer says | `config-defaults.ts` |
45
+ | the shape screens that run before any rule reads a value — section, list, boolean, closed set, path, locale list | `config-shape.ts` |
43
46
  | the `pwa` block — what an install needs, and the boot refusal when it is not there | `config-pwa.ts` |
44
47
  | the closed route vocabulary every renderer names | `route-vocabulary.ts` |
48
+ | which of two route patterns wins a pathname (`routeRank`) | `route-rank.ts` |
45
49
  | runtime roles + `ROLE` resolution | `roles.ts` |
46
50
  | `Clock` — the only source of "now" | `clock.ts` |
47
51
  | UUIDv7, nanoid, branded ids | `ids.ts` |
@@ -51,6 +55,7 @@ Zero dependencies, zero `@ultimat3/*` imports.
51
55
  | OTLP/HTTP JSON: endpoint, headers, value encoding | `otlp.ts` |
52
56
  | `SpanExporter` on the wire, batched | `otlp-span-exporter.ts` |
53
57
  | `MetricExporter` on the wire | `otlp-metric-exporter.ts` |
58
+ | may a caller read this 5xx code's `cause`? `hasPublicCause(code)` — one predicate for the HTTP problem document, MCP error data and an agent `tool_result` | `public-cause.ts` |
54
59
  | `reportError` + the `ErrorReporter` seam, no-op by default | `error-reporter.ts` |
55
60
  | that seam on the wire, Sentry's envelope and DSN | `error-reporter-sentry.ts` |
56
61
  | OTel-shaped counter / gauge / histogram, same seam | `metrics.ts` |
@@ -58,6 +63,7 @@ Zero dependencies, zero `@ultimat3/*` imports.
58
63
  | the series every process emits, incl. what the chart scales on | `runtime-metrics.ts` |
59
64
  | what the process itself costs: `process_resident_memory_bytes`, `process_heap_used_bytes`, `process_heap_total_bytes`, `process_external_memory_bytes`, `process_cpu_seconds_total`, `process_event_loop_lag_seconds`, `process_start_time_seconds`, `process_info{role}` — **server-only**, never on `@ultimat3/core/page` | `process-metrics.ts` (`startProcessMetrics`, `readProcess`) |
60
65
  | graceful drain, `/healthz`, `/readyz` | `lifecycle.ts` |
66
+ | what a health endpoint tells whom — `healthBody(report, role, detailed)`, `healthPeerListed(peers, address)`, `DEFAULT_HEALTH_DETAIL_PEERS`; the one rule `@ultimat3/http` and the sync node's own listener both call | `health-disclosure.ts` |
61
67
  | the readiness grace between `/readyz` → 503 and the listener closing (`drain.readinessGraceMs`) | `lifecycle-grace.ts` |
62
68
  | SIGTERM/SIGINT → the one drain | `lifecycle-signals.ts` |
63
69
  | which network an IP literal belongs to — `classifyAddress`, for SSRF screens | `address-class.ts` |
@@ -66,6 +72,13 @@ Zero dependencies, zero `@ultimat3/*` imports.
66
72
  | the registrar table one same-tier package reaches another through | `registrar.ts` |
67
73
  | decode → resize → encode, the one image pipeline (over `Bun.Image`) | `image/` |
68
74
  | `assertNever`, `invariant` | `assert.ts` |
75
+ | the one HTML character table — `escapeHtml`, text and attributes alike (`& < > " '`) | `html-escape.ts` |
76
+ | the one `Cookie:` reader — `readCookie(header, name)`, `null` when absent, never a throw | `cookie.ts` |
77
+ | 32-bit FNV-1a — a BUCKET (rollouts, factory seeds), never a sharing key (`fingerprint` is) | `fnv1a.ts` |
78
+ | `PgExecutor` — the structural `query(text, values)` seam every Postgres store takes | `pg-executor.ts` |
79
+
80
+ Each of the four, and `fingerprint`, `storeMode` and render's `contentHash`, has ONE implementation:
81
+ `bun run flight-copies` refuses a second by its shape (`X_HELPER_COPY`), whatever it is named.
69
82
 
70
83
  ## Errors are instructions
71
84
 
@@ -312,6 +325,10 @@ fallback of its own; the caller does.
312
325
  is not our key. This is the twin of `roles.ts` — `ROLE` says what the process does,
313
326
  `ULTIMATE_ENV` says which deploy it belongs to.
314
327
 
328
+ `storeMode(Bun.env)` is the one answer to "memory store or database store?" for a seam with both —
329
+ `memory` under `test` (no database client is installed there), `database` everywhere else, `x dev`'s
330
+ embedded PGlite included. Never a `DATABASE_URL` truthiness check: `x dev` sets none.
331
+
315
332
  ## A secret is redacted by value, not by name
316
333
 
317
334
  ```ts
@@ -320,7 +337,21 @@ logger.info('boot', { dsn }); // {"dsn":"[redacted]"}
320
337
  connect(revealSecret(dsn)); // the one greppable way out
321
338
  ```
322
339
 
323
- `redactKeys()` catches a secret travelling under a name someone remembered to list. A `Secret`
340
+ `isRedactedKey(key)` is the one answer to "is this field a credential?" — the log line, the error
341
+ monitor's envelope and `@ultimat3/action`'s audit row all ask it. It matches the exact names
342
+ `redactKeys()` holds (`defineEnv` adds every `secret: true` variable) **and** a credential-bearing
343
+ name it was never told about: `password` / `passphrase` anywhere, `secret` as the last word, any
344
+ `…token` that is not a dedupe or paging key (`resetToken`, `githubToken`, `NPM_TOKEN`), key
345
+ material by its qualifier (`signingKey`, `masterKey`, `accessKeyId`), a value that embeds a
346
+ credential (`connectionString`, `dsn`, `databaseUrl`), the one-time codes
347
+ (`totpCode`, `recoveryCode`) and a stored hash of any of them (`passwordHash`, `tokenHash`,
348
+ `keyHash`). It deliberately leaves `idempotencyToken`, a paging token, `maxTokens` and an error
349
+ `code` readable — a redacted field is one an operator cannot correlate on.
350
+
351
+ `LOG_LEVEL` is refused when it is not one of `LOG_LEVELS` (lowercase), exactly as
352
+ `createLogger({ level })` refuses it; unset or empty is `info`.
353
+
354
+ A `Secret`
324
355
  box catches the other case: `String()`, template literals, `+`, `JSON.stringify`, `console.log`,
325
356
  the logger and an error's `meta` all render `[redacted]`, whatever key it sits under. It is
326
357
  frozen and everything but `label` is non-enumerable, so `{ ...dsn }` cannot spread the value back
@@ -554,7 +585,7 @@ never a silently wrong page.
554
585
  | | |
555
586
  |---|---|
556
587
  | Signature | truncated HMAC-SHA256, compared in constant time |
557
- | Secret | `configureCursorSigning()` at boot, else `ULTIMATE_CURSOR_SECRET`. **Read when a cursor is signed, never at import** — an app whose `openSecrets()` sets the variable during boot would otherwise sign every cursor with the dev key. Rotating it invalidates every open cursor |
588
+ | Secret | `configureCursorSigning()` at boot, else `ULTIMATE_CURSOR_SECRET`. An EMPTY value is unset — never an empty HMAC key — so `usesDevCursorSecret()` reports it and the boot refuses it outside a local environment. **Read when a cursor is signed, never at import** — an app whose `openSecrets()` sets the variable during boot would otherwise sign every cursor with the dev key. Rotating it invalidates every open cursor |
558
589
  | Also keys | `keyedFingerprint(value, purpose)` — `h1:<key id>:<HMAC>` over `canonicalJson`, under a per-purpose key derived from this secret; the fingerprint to PERSIST (`@ultimat3/action`'s idempotency `requestHash`). `compareFingerprint` answers `match` / `mismatch` / `unverifiable` (other key), and still checks a legacy bare `fingerprint()` exactly. Rotating the secret makes in-window stored fingerprints `unverifiable` |
559
590
  | Signed, not encrypted | the client already has these rows; what it must not do is *invent* a position |
560
591
  | `usesDevCursorSecret()` | true while the shipped dev key is in use |
@@ -649,7 +680,7 @@ nothing consulted it before deciding to try again. `As of 2026-08-23`.
649
680
  |---|---|---|
650
681
  | `backoffDelay({ attempt, base, max, factor?, curve?, jitter?, random? })` | one curve — `exponential \| linear \| fixed`, `full \| equal \| none` — 1-based `attempt`, clamped to `max` **before** jitter, rounded, and `0` rather than `NaN` | how long to wait. `random` is injectable, so a schedule is a unit test rather than a range |
651
682
  | `createSingleFlight({ deadlineMs?, schedule? })` → `run(key, work, join?)`, `size` | N callers on one key are ONE run | who pays for a miss. Eviction is identity-checked, so a load that settles late never drops the load that replaced it; `deadlineMs` frees the KEY a wedged load would hold forever — it never cancels the work and never rejects a joiner |
652
- | `createFlightGate({ maxConcurrent, maxQueued }, { subject?, overflow? })` | one bound, one queue, one refusal | how many at once. Past the queue the answer is `X_FLIGHT_GATE_OVERLOADED` (503) and never a longer queue; the slot is HANDED to a waiter, never released and re-acquired |
683
+ | `createFlightGate({ maxConcurrent, maxQueued }, { subject?, overflow? })` | one bound, one queue, one refusal | how many at once. Past the queue the answer is `X_FLIGHT_GATE_OVERLOADED` (503) and never a longer queue; the slot is HANDED to a waiter, never released and re-acquired. Both limits are screened at construction (`finiteCount`, 0 allowed); a width of 0 refuses every caller rather than queueing for a slot that never frees |
653
684
  | `createFence(subject)` → `generation()`, `bump()`, `guard(issued)` | whether an answer still applies | `X_SUPERSEDED` (499) and `isSuperseded(error)` — the piece nothing in the tree had. `guard` compares `!==`, never `<` |
654
685
  | `isRetryableStatus(status)`, `RETRYABLE_STATUSES` | `>= 500`, plus 408, 409, 425, 429 | which HTTP answers are worth repeating |
655
686
  | `retry(work, policy, { sleep, now?, random? })`, `retryDecision(policy, attempt, error, random?)` | the executor and the pure decision behind the classification | whether to try again at all. `createClientFlight` is its one caller in the framework; `jobs`, `ai` and `db` each keep their own loop and delegate only the arithmetic and the classification |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/core",
3
- "version": "23.0.0",
3
+ "version": "24.0.0",
4
4
  "description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,6 +40,6 @@
40
40
  "test": "bun test"
41
41
  },
42
42
  "dependencies": {
43
- "@ultimat3/schema": "23.0.0"
43
+ "@ultimat3/schema": "24.0.0"
44
44
  }
45
45
  }
@@ -0,0 +1,46 @@
1
+ // Single responsibility: the value every `app.config.ts` key has when no layer says otherwise.
2
+ // Split from `config.ts`, which sits at its 500-line ceiling; literals only, so it reads no key.
3
+
4
+ import type { AppConfig } from './config';
5
+ import { defaultReadinessGraceMs } from './lifecycle-grace';
6
+ import { ROLES } from './roles';
7
+
8
+ /** The keys `config-site.ts`, `config-navigation.ts` and `config-islands.ts` default themselves. */
9
+ type Sectioned = 'name' | 'site' | 'seo' | 'navigation' | 'islands';
10
+
11
+ export function configDefaults(name: string): Omit<AppConfig, Sectioned> {
12
+ return {
13
+ locales: ['en'],
14
+ defaultLocale: 'en',
15
+ defaultTimeZone: 'UTC',
16
+ defaultCurrency: 'USD',
17
+ theme: { defaultMode: 'system', tokens: {} },
18
+ auth: { signInPath: null },
19
+ pwa: {
20
+ enabled: false,
21
+ offline: { fallback: null, image: null, font: null, neverCache: [], personalPages: 'never' },
22
+ backgroundSync: false,
23
+ push: false,
24
+ name: '',
25
+ colors: undefined,
26
+ },
27
+ roles: [...ROLES],
28
+ database: { driver: 'postgres', ssl: false },
29
+ cache: { defaultTtlMs: 60_000, tiers: ['request-memo', 'lru'] },
30
+ jobs: {
31
+ queues: [`${name}-default`],
32
+ concurrency: 8,
33
+ maxAttempts: 5,
34
+ backoff: 'exponential',
35
+ visibilityTimeoutMs: 30_000,
36
+ },
37
+ // ON by default since 22.0.0, when the boot began obeying the key: an app with no section
38
+ // keeps the `sync` node it always got, and `enabled: false` is the explicit opt-out.
39
+ realtime: { enabled: true, transport: 'memory', urlEnv: undefined },
40
+ notify: { inboxReadRetentionMs: undefined, inboxUnreadRetentionMs: undefined },
41
+ ai: { mcp: { expose: true, path: '/mcp' } },
42
+ // Read from the process env when the config is DEFINED — the same env the drain will run in.
43
+ drain: { readinessGraceMs: defaultReadinessGraceMs() },
44
+ health: { readiness: 'dependencies' },
45
+ };
46
+ }
@@ -2,6 +2,8 @@
2
2
  // per section and key by key. Carries no config KEY on purpose: `config-readers` counts a property
3
3
  // access outside `config.ts` as a reader, so this file only ever sees sections as opaque records.
4
4
 
5
+ import { isJsonObject } from './json-object';
6
+
5
7
  /** A section's patch: every key optional, and an explicit `undefined` meaning "not said". */
6
8
  export type Input<T> = { readonly [K in keyof T]?: T[K] | undefined };
7
9
 
@@ -11,6 +13,12 @@ export type Input<T> = { readonly [K in keyof T]?: T[K] | undefined };
11
13
  */
12
14
  export function section<T extends object>(base: T, patch: Input<T> | undefined): T {
13
15
  if (patch === undefined) return base;
16
+ // A layer that wrote something other than an object (`null`, a string, a list) has no key to
17
+ // merge, and `Object.entries(null)` below was a native `TypeError` out of the validator's own
18
+ // caller. It is carried through AS WRITTEN — and stays, whatever later layers say — so the shape
19
+ // screen refuses it by name; dropped here, the app would run on defaults it believed it replaced.
20
+ if (!isJsonObject(base)) return base;
21
+ if (!isJsonObject(patch)) return patch as T;
14
22
  const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
15
23
  for (const [key, value] of Object.entries(patch)) {
16
24
  if (value !== undefined) out[key] = value;
@@ -0,0 +1,112 @@
1
+ // Single responsibility: the SHAPE half of `app.config.ts` validation — is this a section, a list,
2
+ // a boolean, one of a closed set — asked before any rule reads the value. Takes every key as a
3
+ // string and every value as `unknown`, and names no config key of its own: `config-readers` counts
4
+ // a property access outside the declaring files as a reader, so this file must not make one.
5
+
6
+ import { describeValue } from './error-render';
7
+ import { isJsonObject } from './json-object';
8
+
9
+ /** A string is worth echoing — it is the typo; anything else is described by shape. */
10
+ const said = (value: unknown): string =>
11
+ typeof value === 'string' ? `"${value}"` : describeValue(value);
12
+
13
+ /**
14
+ * Every section and list one LAYER wrote, compared with the same position in `reference` — the
15
+ * defaults merged with no layer, so the screen is derived and never a hand list of key names.
16
+ * Structure only: where the reference holds a section the layer may hold a section, where it holds
17
+ * a list, a list. `undefined` is a layer not saying; scalars are the per-key rules' business; and
18
+ * a position the reference leaves `null` or `undefined` (an optional block) is not judged here.
19
+ *
20
+ * It runs BEFORE the merge and its issues end the validation, because the merge and every rule
21
+ * after it read through the structure: `Object.entries(null)` and `null.length` are the native
22
+ * `TypeError`s the validator exists to replace with an instruction.
23
+ */
24
+ export function shapeIssues(reference: unknown, layer: unknown, issues: string[], path = ''): void {
25
+ if (!isJsonObject(reference) || !isJsonObject(layer)) return;
26
+ for (const [key, expected] of Object.entries(reference)) {
27
+ const at = path === '' ? key : `${path}.${key}`;
28
+ const value: unknown = layer[key];
29
+ if (value === undefined) continue;
30
+ if (Array.isArray(expected)) {
31
+ if (!Array.isArray(value)) issues.push(`${at} must be a list, not ${describeValue(value)}`);
32
+ } else if (isJsonObject(expected)) {
33
+ if (isJsonObject(value)) shapeIssues(expected, value, issues, at);
34
+ else issues.push(`${at} must be an object, not ${describeValue(value)}`);
35
+ }
36
+ }
37
+ }
38
+
39
+ /** Why `value` is not one of `allowed`, or `undefined` when it is. */
40
+ export function oneOfIssue(
41
+ key: string,
42
+ value: unknown,
43
+ allowed: readonly string[],
44
+ ): string | undefined {
45
+ if (allowed.some((known) => known === value)) return undefined;
46
+ return `${key} ${said(value)} is not one of ${allowed.join(', ')}`;
47
+ }
48
+
49
+ /**
50
+ * `typeof`, never truthiness: an untyped config writing `'false'` — a string out of an environment
51
+ * variable — is truthy, so the switch it meant to turn off stayed on and nothing said so.
52
+ */
53
+ export function booleanIssue(key: string, value: unknown): string | undefined {
54
+ return typeof value === 'boolean'
55
+ ? undefined
56
+ : `${key} must be true or false, not ${said(value)}`;
57
+ }
58
+
59
+ /** A route path the framework mounts or redirects to: absolute, or the browser resolves it. */
60
+ export function routePathIssue(key: string, value: unknown): string | undefined {
61
+ return typeof value === 'string' && value.startsWith('/')
62
+ ? undefined
63
+ : `${key} must be a path starting with /, not ${said(value)}`;
64
+ }
65
+
66
+ /** A list that names things: at least one entry, each a non-empty string, `what` each. */
67
+ export function nameListIssues(
68
+ key: string,
69
+ list: readonly unknown[],
70
+ what: string,
71
+ issues: string[],
72
+ ): void {
73
+ if (list.length === 0) issues.push(`${key} must list at least one ${what}`);
74
+ for (const entry of list) {
75
+ if (typeof entry !== 'string' || entry.trim() === '') {
76
+ issues.push(`${key} contains ${said(entry)}, not a ${what} name`);
77
+ }
78
+ }
79
+ }
80
+
81
+ function canonicalTag(tag: unknown): string | undefined {
82
+ if (typeof tag !== 'string') return undefined;
83
+ try {
84
+ const canonical = Intl.getCanonicalLocales(tag);
85
+ return canonical.length === 1 ? canonical[0] : undefined;
86
+ } catch {
87
+ return undefined;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * The locale list and the tag that must be in it. Two spellings of ONE locale are refused: the
93
+ * list keys a catalog, a route prefix and an `hreflang` each, and `['EN', 'en']` is two of every
94
+ * one of them for a single language.
95
+ */
96
+ export function localeIssues(tags: readonly unknown[], fallback: unknown, issues: string[]): void {
97
+ if (tags.length === 0) issues.push('locales must list at least one locale');
98
+ const seen = new Map<string, unknown>();
99
+ for (const tag of tags) {
100
+ const canonical = canonicalTag(tag);
101
+ if (canonical === undefined) {
102
+ issues.push(`locales contains ${said(tag)}, not a BCP-47 tag`);
103
+ } else if (seen.has(canonical)) {
104
+ issues.push(
105
+ `locales lists ${canonical} twice, as ${said(seen.get(canonical))} and ${said(tag)}`,
106
+ );
107
+ } else {
108
+ seen.set(canonical, tag);
109
+ }
110
+ }
111
+ if (!tags.includes(fallback)) issues.push(`defaultLocale ${said(fallback)} is not in locales`);
112
+ }
@@ -3,6 +3,7 @@
3
3
  // Split from `config.ts` for `config-pwa.ts`' reason: that file sits at its 500-line ceiling.
4
4
 
5
5
  import { type Input, layered } from './config-merge';
6
+ import { describeValue } from './error-render';
6
7
 
7
8
  export interface SiteConfig {
8
9
  /**
@@ -108,10 +109,20 @@ export function siteIssues(config: SiteSections, issues: string[]): void {
108
109
  const issue = originIssue(origin);
109
110
  if (issue !== undefined) issues.push(issue);
110
111
  }
111
- for (const path of config.seo.robots.disallow) {
112
- if (!path.startsWith('/')) issues.push(`seo.robots.disallow entry "${path}" must start with /`);
112
+ // `unknown` entries: an untyped config reaches here with whatever it listed, and `5.startsWith`
113
+ // was a native `TypeError` thrown by the validator itself.
114
+ for (const path of config.seo.robots.disallow as readonly unknown[]) {
115
+ if (typeof path !== 'string') {
116
+ issues.push(`seo.robots.disallow entry must be a path string, not ${describeValue(path)}`);
117
+ } else if (!path.startsWith('/')) {
118
+ issues.push(`seo.robots.disallow entry "${path}" must start with /`);
119
+ }
113
120
  }
114
- for (const path of config.seo.sitemap.extra) {
121
+ for (const path of config.seo.sitemap.extra as readonly unknown[]) {
122
+ if (typeof path !== 'string') {
123
+ issues.push(`seo.sitemap.extra entry must be a path string, not ${describeValue(path)}`);
124
+ continue;
125
+ }
115
126
  // A PATH, never a URL: every `<loc>` is built against the one declared origin, and a query or
116
127
  // a fragment names a variant of a page, which a sitemap lists by its canonical URL alone.
117
128
  if (!path.startsWith('/') || path.startsWith('//') || /[?#]/.test(path)) {
package/src/config.ts CHANGED
@@ -8,6 +8,7 @@ import { CURRENCY_CODE_PATTERN } from '@ultimat3/schema';
8
8
  // drift into two vocabularies with no map between them (issue #293).
9
9
  import { CACHE_TIERS, type CacheTierName } from './cache-vocabulary';
10
10
  import { countIssue } from './config-count';
11
+ import { configDefaults } from './config-defaults';
11
12
  import { BASE_FIX, CACHE_TIER_FIX, TIMEZONE_FIX } from './config-fixes';
12
13
  import type { DrainConfig, HealthConfig } from './config-health';
13
14
  import { readinessModeIssue } from './config-health';
@@ -18,15 +19,24 @@ import type { NavigationConfig, NavigationSectionInput } from './config-navigati
18
19
  import { mergeNavigation, navigationIssues } from './config-navigation';
19
20
  import type { PwaConfig, PwaOfflineConfig } from './config-pwa';
20
21
  import { PWA_FIX, pwaIssues } from './config-pwa';
22
+ import {
23
+ booleanIssue,
24
+ localeIssues,
25
+ nameListIssues,
26
+ oneOfIssue,
27
+ routePathIssue,
28
+ shapeIssues,
29
+ } from './config-shape';
21
30
  import type { SeoConfig, SiteConfig, SiteSectionsInput } from './config-site';
22
31
  import { mergeSite, siteIssues } from './config-site';
23
32
  import { describeValue } from './error-render';
24
33
  import { ConfigInvalidError } from './errors';
25
- import { defaultReadinessGraceMs, readinessGraceIssue } from './lifecycle-grace';
34
+ import { readinessGraceIssue } from './lifecycle-grace';
26
35
  import { ROLES, type Role } from './roles';
27
36
  import { isIanaZoneName } from './time-zone-name';
28
37
 
29
- export type ThemeMode = 'light' | 'dark' | 'system';
38
+ export const THEME_MODES = ['light', 'dark', 'system'] as const;
39
+ export type ThemeMode = (typeof THEME_MODES)[number];
30
40
  /**
31
41
  * The buses `@ultimat3/realtime`'s `selectTransport` builds, and nothing else. `'redis'` was in
32
42
  * this union until 22.0.0 with no Redis transport anywhere: it booted whatever `NATS_URL` chose.
@@ -99,8 +109,10 @@ export const INBOX_RETENTION_KEYS = ['inboxReadRetentionMs', 'inboxUnreadRetenti
99
109
  * 3 applied to configuration: a value that produces neither a build error nor a runtime effect is
100
110
  * worse than no field, because an SRE sets `poolSize: 3`, redeploys, and nothing changes.
101
111
  */
112
+ const DATABASE_DRIVERS = ['postgres'] as const;
113
+
102
114
  export interface DatabaseConfig {
103
- readonly driver: 'postgres';
115
+ readonly driver: (typeof DATABASE_DRIVERS)[number];
104
116
  readonly ssl: boolean;
105
117
  }
106
118
 
@@ -125,6 +137,8 @@ export interface CacheConfig {
125
137
  readonly tiers: readonly CacheTierName[];
126
138
  }
127
139
 
140
+ const JOB_BACKOFFS = ['exponential', 'fixed'] as const;
141
+
128
142
  export interface JobsConfig {
129
143
  /**
130
144
  * No `driver`. It accepted `'postgres' | 'redis' | 'nats'`, was read by NOTHING, and boot always
@@ -140,7 +154,7 @@ export interface JobsConfig {
140
154
  readonly queues: readonly string[];
141
155
  readonly concurrency: number;
142
156
  readonly maxAttempts: number;
143
- readonly backoff: 'exponential' | 'fixed';
157
+ readonly backoff: (typeof JOB_BACKOFFS)[number];
144
158
  readonly visibilityTimeoutMs: number;
145
159
  }
146
160
 
@@ -255,52 +269,6 @@ const NAME_RE = /^[a-z][a-z0-9-]{1,63}$/;
255
269
  */
256
270
  const CURRENCY_RE = new RegExp(CURRENCY_CODE_PATTERN);
257
271
 
258
- function isLocale(value: string): boolean {
259
- try {
260
- return Intl.getCanonicalLocales(value).length === 1;
261
- } catch {
262
- return false;
263
- }
264
- }
265
-
266
- type Sectioned = 'name' | 'site' | 'seo' | 'navigation' | 'islands';
267
- function defaults(name: string): Omit<AppConfig, Sectioned> {
268
- return {
269
- locales: ['en'],
270
- defaultLocale: 'en',
271
- defaultTimeZone: 'UTC',
272
- defaultCurrency: 'USD',
273
- theme: { defaultMode: 'system', tokens: {} },
274
- auth: { signInPath: null },
275
- pwa: {
276
- enabled: false,
277
- offline: { fallback: null, image: null, font: null, neverCache: [], personalPages: 'never' },
278
- backgroundSync: false,
279
- push: false,
280
- name: '',
281
- colors: undefined,
282
- },
283
- roles: [...ROLES],
284
- database: { driver: 'postgres', ssl: false },
285
- cache: { defaultTtlMs: 60_000, tiers: ['request-memo', 'lru'] },
286
- jobs: {
287
- queues: [`${name}-default`],
288
- concurrency: 8,
289
- maxAttempts: 5,
290
- backoff: 'exponential',
291
- visibilityTimeoutMs: 30_000,
292
- },
293
- // ON by default since 22.0.0, when the boot began obeying the key: an app with no section
294
- // keeps the `sync` node it always got, and `enabled: false` is the explicit opt-out.
295
- realtime: { enabled: true, transport: 'memory', urlEnv: undefined },
296
- notify: { inboxReadRetentionMs: undefined, inboxUnreadRetentionMs: undefined },
297
- ai: { mcp: { expose: true, path: '/mcp' } },
298
- // Read from the process env when the config is DEFINED — the same env the drain will run in.
299
- drain: { readinessGraceMs: defaultReadinessGraceMs() },
300
- health: { readiness: 'dependencies' },
301
- };
302
- }
303
-
304
272
  function validate(config: AppConfig): void {
305
273
  const issues: string[] = [];
306
274
  // Zero or one entry: the zone's own remedy, carried only when the zone is what failed.
@@ -311,16 +279,13 @@ function validate(config: AppConfig): void {
311
279
  // Same shape again: carried only when an installable app is missing what an install needs.
312
280
  const pwaFix: string[] = [];
313
281
 
314
- if (!NAME_RE.test(config.name)) {
282
+ // `typeof` first: `NAME_RE.test(undefined)` tests the string "undefined", which matches.
283
+ if (typeof config.name !== 'string') {
284
+ issues.push(`name must be a string like "my-app", not ${describeValue(config.name)}`);
285
+ } else if (!NAME_RE.test(config.name)) {
315
286
  issues.push(`name "${config.name}" must match ${String(NAME_RE)}`);
316
287
  }
317
- if (config.locales.length === 0) issues.push('locales must list at least one locale');
318
- for (const locale of config.locales) {
319
- if (!isLocale(locale)) issues.push(`locales contains "${locale}", not a BCP-47 tag`);
320
- }
321
- if (!config.locales.includes(config.defaultLocale)) {
322
- issues.push(`defaultLocale "${config.defaultLocale}" is not in locales`);
323
- }
288
+ localeIssues(config.locales, config.defaultLocale, issues);
324
289
  // `@ultimat3/time`'s rule, restated because tier 0 cannot import tier 1 — see
325
290
  // `time-zone-name.ts`. One validator means a zone `app.config.ts` accepts is a zone every
326
291
  // `format` call, `task()` and `toZoned` below it can then do arithmetic in.
@@ -334,25 +299,33 @@ function validate(config: AppConfig): void {
334
299
  issues.push(`defaultCurrency "${config.defaultCurrency}" is not a 3-letter ISO 4217 code`);
335
300
  }
336
301
  if (config.roles.length === 0) issues.push('roles must list at least one runtime role');
337
- // A domain per numeric key, never a bare `< 1`: every comparison with `NaN` is false, so the old
338
- // `concurrency < 1` passed `NaN`, `2.5` and `Infinity`, and nothing screened the other three.
339
- const counts: readonly (string | undefined)[] = [
302
+ // ONE check per key, each against the key's own domain. A number is never a bare `< 1` — every
303
+ // comparison with `NaN` is false, so `concurrency < 1` passed `NaN`, `2.5` and `Infinity` — and
304
+ // a switch is never read for truthiness: `ssl: 'false'` and `enabled: 'false'` were both ON.
305
+ const perKey: readonly (string | undefined)[] = [
306
+ ...config.roles.map((role) => oneOfIssue('roles', role, ROLES)),
340
307
  countIssue('jobs.concurrency', config.jobs.concurrency, 1),
341
308
  countIssue('jobs.maxAttempts', config.jobs.maxAttempts, 1),
342
309
  countIssue('jobs.visibilityTimeoutMs', config.jobs.visibilityTimeoutMs, 1),
310
+ oneOfIssue('jobs.backoff', config.jobs.backoff, JOB_BACKOFFS),
343
311
  countIssue('cache.defaultTtlMs', config.cache.defaultTtlMs, 0),
312
+ oneOfIssue('database.driver', config.database.driver, DATABASE_DRIVERS),
313
+ booleanIssue('database.ssl', config.database.ssl),
314
+ oneOfIssue('theme.defaultMode', config.theme.defaultMode, THEME_MODES),
315
+ // `null` is the documented "no redirect"; a path that is said must be one a browser can follow.
316
+ config.auth.signInPath === null
317
+ ? undefined
318
+ : routePathIssue('auth.signInPath', config.auth.signInPath),
319
+ booleanIssue('ai.mcp.expose', config.ai.mcp.expose),
320
+ routePathIssue('ai.mcp.path', config.ai.mcp.path),
321
+ booleanIssue('realtime.enabled', config.realtime.enabled),
322
+ oneOfIssue('realtime.transport', config.realtime.transport, REALTIME_TRANSPORTS),
344
323
  readinessGraceIssue(config.drain.readinessGraceMs),
345
324
  readinessModeIssue(config.health.readiness),
346
325
  ];
347
- for (const issue of counts) if (issue !== undefined) issues.push(issue);
348
- if (config.jobs.queues.length === 0) issues.push('jobs.queues must list at least one queue');
349
- // An untyped config reaches here with whatever it wrote: a string is a name worth echoing, and
350
- // anything else goes through `describeValue` rather than `${…}`.
351
- const transport: unknown = config.realtime.transport;
352
- if (!REALTIME_TRANSPORTS.some((known) => known === transport)) {
353
- const said = typeof transport === 'string' ? `"${transport}"` : describeValue(transport);
354
- issues.push(`realtime.transport ${said} is not one of ${REALTIME_TRANSPORTS.join(', ')}`);
355
- } else if (transport === 'nats' && config.realtime.urlEnv === undefined) {
326
+ for (const issue of perKey) if (issue !== undefined) issues.push(issue);
327
+ nameListIssues('jobs.queues', config.jobs.queues, 'queue', issues);
328
+ if (config.realtime.transport === 'nats' && config.realtime.urlEnv === undefined) {
356
329
  issues.push(`realtime.transport "nats" requires realtime.urlEnv`);
357
330
  }
358
331
  // BOTH RETENTION WINDOWS OR NEITHER — `undefined` is a real value here (never swept) and the
@@ -381,6 +354,9 @@ function validate(config: AppConfig): void {
381
354
  // A rung the ladder cannot build is the defect this key had: `sortTiers` places a name by its
382
355
  // index in `CACHE_TIERS`, and a name missing from it sorts to `-1` — AHEAD of the request memo.
383
356
  // So an unknown tier is refused at boot rather than silently ignored or silently placed first.
357
+ // An EMPTY ladder is refused with the unknown rung: it builds no tier at all, so every read
358
+ // misses and nothing says the cache was configured away.
359
+ if (config.cache.tiers.length === 0) issues.push('cache.tiers must list at least one tier');
384
360
  for (const tier of config.cache.tiers) {
385
361
  if (CACHE_TIERS.includes(tier)) continue;
386
362
  issues.push(`cache.tiers contains "${tier}", which is not one of ${CACHE_TIERS.join(', ')}`);
@@ -399,20 +375,14 @@ function validate(config: AppConfig): void {
399
375
  }
400
376
 
401
377
  /**
402
- * The single config entry point. Later overlays win, so `config/jobs.ts` can own jobs without
403
- * touching `app.config.ts`.
378
+ * Every layer, in order, merged per section and KEY BY KEY — see `layered`. `name` comes from the
379
+ * input alone because it identifies the app: an overlay may not rename it. No layer at all is the
380
+ * defaults, which is the reference `shapeIssues` compares each layer against.
404
381
  */
405
- export function defineConfig(
406
- input: AppConfigInput,
407
- ...overlays: readonly AppConfigOverlay[]
408
- ): AppConfig {
409
- const base = defaults(input.name);
410
- // Every layer, in order, merged per section and KEY BY KEY — see `layered`. `name` comes from
411
- // the input alone because it identifies the app: an overlay may not rename it.
412
- const layers: readonly AppConfigOverlay[] = [input, ...overlays];
413
-
414
- const config: AppConfig = {
415
- name: input.name,
382
+ function merge(name: string, layers: readonly AppConfigOverlay[]): AppConfig {
383
+ const base = configDefaults(name);
384
+ return {
385
+ name,
416
386
  locales: lastSaid(
417
387
  base.locales,
418
388
  layers.map((layer) => layer.locales),
@@ -492,7 +462,28 @@ export function defineConfig(
492
462
  ...mergeNavigation(layers),
493
463
  ...mergeIslands(layers),
494
464
  };
465
+ }
495
466
 
467
+ /**
468
+ * The single config entry point. Later overlays win, so `config/jobs.ts` can own jobs without
469
+ * touching `app.config.ts`.
470
+ */
471
+ export function defineConfig(
472
+ input: AppConfigInput,
473
+ ...overlays: readonly AppConfigOverlay[]
474
+ ): AppConfig {
475
+ const layers: readonly AppConfigOverlay[] = [input, ...overlays];
476
+ // Structure FIRST, per layer and before the merge: a section written as `null` or a list
477
+ // written as a string is what `Object.entries` and `.length` raised a native `TypeError` on.
478
+ const issues: string[] = [];
479
+ // `navigation` is left out: `config-navigation.ts` carries a wrong shape through AS WRITTEN and
480
+ // refuses it in its own words, with the surfaces it accepts.
481
+ const reference = { ...merge(input.name, []), navigation: undefined };
482
+ for (const layer of layers) shapeIssues(reference, layer, issues);
483
+ if (issues.length > 0) {
484
+ throw new ConfigInvalidError({ cause: issues.join('; '), fix: BASE_FIX, meta: { issues } });
485
+ }
486
+ const config = merge(input.name, layers);
496
487
  validate(config);
497
488
  return Object.freeze(config);
498
489
  }
package/src/context.ts CHANGED
@@ -272,6 +272,18 @@ function screenDeadline(value: number | null | undefined): number | null {
272
272
  return finiteOption('the request context', 'deadlineAt', value);
273
273
  }
274
274
 
275
+ /**
276
+ * A patched signal is ADDED to the parent's, never swapped for it — the deadline's own rule, one
277
+ * field over. `patch ?? parent` made a step that brought its own signal blind to the request it
278
+ * runs inside: the client disconnected, the request timed out, and `ctx.signal.aborted` stayed
279
+ * false in the child. The shared never-aborting default is skipped rather than composed, so a
280
+ * context with no request behind it does not grow a listener per child.
281
+ */
282
+ function composeSignal(parent: AbortSignal, patch: AbortSignal | undefined): AbortSignal {
283
+ if (patch === undefined || patch === parent) return parent;
284
+ return parent === neverAborted ? patch : AbortSignal.any([parent, patch]);
285
+ }
286
+
275
287
  /**
276
288
  * Derive a narrowed context — impersonation, a locale switch, a per-step abort signal.
277
289
  * `requestId` is deliberately not patchable: one request, one id.
@@ -301,7 +313,7 @@ export function withChildContext<T>(patch: CtxPatch, fn: () => T): T {
301
313
  // that and did not do it — a patched hour replaced a parent's second outright, and
302
314
  // `remainingBudgetMs` then put the hour on `x-request-timeout-ms` for the next hop.
303
315
  deadlineAt: earliest(screenDeadline(patch.deadlineAt) ?? undefined, parent.deadlineAt),
304
- signal: patch.signal ?? parent.signal,
316
+ signal: composeSignal(parent.signal, patch.signal),
305
317
  services: { ...carried, ...(patch.services ?? {}) },
306
318
  });
307
319
  return requestContext.run(child, fn);