@ultimat3/core 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 (50) hide show
  1. package/CLAUDE.md +23 -2
  2. package/README.md +93 -4
  3. package/package.json +2 -2
  4. package/src/client-paths.ts +26 -6
  5. package/src/config-defaults.ts +46 -0
  6. package/src/config-merge.ts +8 -0
  7. package/src/config-shape.ts +112 -0
  8. package/src/config-site.ts +14 -3
  9. package/src/config.ts +74 -83
  10. package/src/context.ts +13 -1
  11. package/src/cookie.ts +35 -0
  12. package/src/core-error-codes.ts +5 -0
  13. package/src/cursor.ts +4 -1
  14. package/src/decimal-order.ts +5 -4
  15. package/src/dev-secrets.ts +1 -1
  16. package/src/error-render.ts +4 -2
  17. package/src/error-reporter-sentry.ts +7 -3
  18. package/src/error-retry.ts +8 -0
  19. package/src/flight-gate.ts +16 -4
  20. package/src/fnv1a.ts +19 -0
  21. package/src/health-disclosure.ts +43 -0
  22. package/src/host-rules.ts +28 -1
  23. package/src/html-escape.ts +24 -0
  24. package/src/image/errors.ts +3 -1
  25. package/src/image/png-pixels.ts +29 -6
  26. package/src/image/probe.ts +7 -2
  27. package/src/image/raster.ts +3 -1
  28. package/src/index.ts +32 -0
  29. package/src/logger.ts +103 -10
  30. package/src/nearest-name.ts +11 -2
  31. package/src/otlp-metric-exporter.ts +1 -1
  32. package/src/otlp-span-exporter.ts +1 -1
  33. package/src/otlp.ts +44 -13
  34. package/src/page-meta.ts +7 -0
  35. package/src/page.ts +1 -0
  36. package/src/pg-executor.ts +15 -0
  37. package/src/process-metrics.ts +206 -0
  38. package/src/public-cause.ts +37 -0
  39. package/src/registrar.ts +21 -4
  40. package/src/retry.ts +15 -2
  41. package/src/route-rank.ts +36 -0
  42. package/src/same-origin.ts +1 -1
  43. package/src/sampler.ts +6 -2
  44. package/src/seal-errors.ts +76 -0
  45. package/src/seal-keys.ts +121 -0
  46. package/src/seal.ts +259 -0
  47. package/src/secrets-errors.ts +33 -1
  48. package/src/secrets.ts +21 -11
  49. package/src/source-mask.ts +14 -8
  50. package/src/store-mode.ts +23 -0
package/CLAUDE.md CHANGED
@@ -48,6 +48,9 @@ top-level `UltimateError` use in `error-codes.ts`.
48
48
  - **A string's length is CODE POINTS** — `validators.ts` rejects in that unit and
49
49
  `json-schema.ts` publishes `minLength` in it. `parseId`/`uuidTimestamp` describe a rejected id and
50
50
  never echo it: a value baked into a message has no log field key to redact.
51
+ - **A test never patches `process.stdout` to read a log line**: `setLogSink(sink)` is the seam, and the
52
+ test preloads install one that drops every line (`LOG_LEVEL` named = not installed). It decides
53
+ WHERE a line goes, never whether — the level is untouched.
51
54
  - `logger.ts` must not import `context.ts` (`context.ts` injects ids via `setLoggerContextFields()`).
52
55
  It imports `secret.ts` one way only: `secret.ts` owns `REDACTED`, `logger.ts` re-exports it.
53
56
  - **`ActorFacts` is the app's extension point on `Actor`** (module augmentation). Core never declares
@@ -57,8 +60,10 @@ top-level `UltimateError` use in `error-codes.ts`.
57
60
  | Concept | Owner | Note |
58
61
  |---|---|---|
59
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`) |
60
64
  | what this process does | `roles.ts` (`ROLE`) | |
61
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` |
62
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 |
63
68
  | which build of the APP this is | `app-version.ts` (`APP_VERSION`) | one reader, `dev` by default |
64
69
  | the values | `env.ts` | `checkEnv().values` holds REAL secrets — printing goes through `maskedEnvValues()` |
@@ -71,15 +76,18 @@ top-level `UltimateError` use in `error-codes.ts`.
71
76
  | which HTTP statuses are worth repeating | `retryable-status.ts` | `>= 500` plus 408, 409, 425, 429 |
72
77
  | how long this request has left | `request-budget.ts` (`Ctx.deadlineAt`, `REQUEST_TIMEOUT_HEADER`) | `@ultimat3/http`'s `startDeadline` is the one writer of the instant; `traceHeaders()` the one writer of the header. A spent budget sends nothing, never `0` |
73
78
  | the above, composed into one typed-client call | `client-flight.ts` + `client-wire.ts` | shared by `@ultimat3/action` and `@ultimat3/query` (both tier 3), re-exported by both. Declares no code of its own |
74
- | the browser's one HTTP function, records envelope, per-tab handle and principal fence | `client-transport.ts`, `client-dispatch.ts`, `client-problem.ts`, `client-paths.ts`, `record-envelope.ts`, `record-sink.ts`, `client-scope.ts` | `pageClient()` is the ONE `globalThis` write (`Symbol.for('ultimate.client')`); the scope's listeners live ON the handle. `clientTransport` never value-imports `createClientFlight` or `traceHeaders()`: trace/budget headers reach it through the page handle's OUTBOUND SLOT (`outbound-headers.ts`). `isSuperseded` answers both `X_SUPERSEDED` and `X_CLIENT_SCOPE_CHANGED`. The fence never `bump()`s a caller's flight. A write never dedupes; a read dedupes only with a caller's `flight`. `pageClient()` reads `<meta name="ultimate-scope">` (`CLIENT_SCOPE_META`) once: content = principal, empty = anonymous (`null`), absent = UNSCOPED (`undefined`, nothing persisted). Byte figures (`As of 2026-09-23`): `clientTransport` 14,405 B, `pageClient` 8,853 B through the barrel, 332 B from its own module; `rpc`'s live in `packages/action/CLAUDE.md`, `queryClient`'s in `packages/query/CLAUDE.md` |
79
+ | the browser's one HTTP function, records envelope, per-tab handle and principal fence | `client-transport.ts`, `client-dispatch.ts`, `client-problem.ts`, `client-paths.ts`, `record-envelope.ts`, `record-sink.ts`, `client-scope.ts` | `pageClient()` is the ONE `globalThis` write (`Symbol.for('ultimate.client')`); the scope's listeners live ON the handle. `clientTransport` never value-imports `createClientFlight` or `traceHeaders()`: trace/budget headers reach it through the page handle's OUTBOUND SLOT (`outbound-headers.ts`). `isSuperseded` answers both `X_SUPERSEDED` and `X_CLIENT_SCOPE_CHANGED`. The fence never `bump()`s a caller's flight. A write never dedupes; a read dedupes only with a caller's `flight`. `pageClient()` reads `<meta name="ultimate-scope">` (`CLIENT_SCOPE_META`) once: content = principal, empty = anonymous (`null`), absent = UNSCOPED (`undefined`, nothing persisted). `actionPath(name)` with no `style` reads the document's `<meta name="ultimate-path-style">` (`renderedActionPathStyle()`; `CLIENT_PATH_STYLE_META` in `page-meta.ts`) and falls back to `'resource'`. Byte figures (`As of 2026-10-01`, `bun build --target=browser --minify`): `clientTransport` 14,251 B and `pageClient` 8,348 B through the barrel, 9,633 B and 1,156 B through `./page`; the whole `./page` entry 15,979 B against `page-bundle.test.ts`'s 16,384; `rpc`'s live in `packages/action/CLAUDE.md`, `queryClient`'s in `packages/query/CLAUDE.md` |
75
80
  | a write's public name | `write-digest.ts` (`writeDigest`, `isWriteDigest`, also on `./page`) + `write-origin.ts` (`withWriteOrigin`, `currentWriteOrigin`, `WRITE_ORIGIN_WAL_PREFIX`, server-only) | SHA-256 of an idempotency key, 32 hex; carried action → entity → WAL → realtime `records` frame. A malformed value runs the work unnamed: a label, never a gate |
76
81
  | which row survives a conflict | `conflict-policy.ts` (`ConflictPolicy`, `resolveConflict`, `Row`) | read by `action`'s mutator and `realtime`'s rebase |
77
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 |
78
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 |
79
86
  | a value that must not be printed | `secret.ts` | redacted by VALUE; `revealSecret()` is the one, greppable, way out |
80
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` |
81
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 |
82
89
  | the committed encrypted values | `secrets.ts` (envelope) + `secrets-store.ts` (files, `installSecrets`) | plaintext is a flat map of ENV NAMES; there is no `secrets.get()` |
90
+ | ONE sealed value | `seal.ts` (`seal`, `open`, `openText`, `sealAll`) + `seal-keys.ts` (the ring) + `seal-errors.ts` | the framework's one AES call above the envelope: `entity`'s sealed column and `scraping`'s stored session call it, never WebCrypto. `purpose` is required and is AAD |
83
91
 
84
92
  - **`installSecrets()` is the ONLY path from `secrets.enc.json` to an app value**, landing in
85
93
  `process.env` before `defineEnv` reads it. The real environment always wins.
@@ -91,6 +99,14 @@ top-level `UltimateError` use in `error-codes.ts`.
91
99
  `X_SECRETS_KEY_MISMATCH`'s command carries no key id (it is read from a file). Its seven codes
92
100
  register through `registerErrorCodes()`, so `resetErrorCodes()` drops them — take
93
101
  `errorCodeSnapshot()` first. The envelope's `kid` lets *wrong key* and *edited file* be two codes.
102
+ - **`seal.ts` adds no variable for the CURRENT key** — `findMasterKey()`, the same call
103
+ `installSecrets()` makes. Retired keys are `ULTIMATE_SECRETS_RETIRED_KEYS`, written into
104
+ `secrets.enc.json` by `x secrets rotate`; the ring memo is keyed by its source strings, never by
105
+ time. Constants, `parseMasterKey`, `masterKeyId`, `importKey` and the base64 codec are
106
+ `secrets.ts`'s — no second set. `seal-errors.ts` runs nothing at import: its titles are in
107
+ `CORE_CODE_TITLES` and its retry class in `CORE_ERROR_RETRY`, so it is no `sideEffects` anchor.
108
+ The wire form `x1.<keyId>.<iv>.<ciphertext+tag>` and the AAD string are persisted data: a change
109
+ is `x2`, never an edit.
94
110
  - **`schema-error-codes.ts` registers `@ultimat3/schema`'s codes** (schema cannot call core), and
95
111
  derives their retry classification from the same set. It is a `SIDE_EFFECTS_ANCHORS` entry.
96
112
  - `timing-safe-equal.ts` is the one constant-time comparison (`@ultimat3/auth`, `@ultimat3/storage`).
@@ -134,7 +150,10 @@ top-level `UltimateError` use in `error-codes.ts`.
134
150
  `metrics.ts` is to `telemetry.ts` what a counter is to a span: always on, no-op exporter by
135
151
  default. `runtime-metrics.ts` is the only place that names a series the chart reads
136
152
  (`http_requests_total`, `connections`, `queue_depth`), keyed by `ScalingSignal` in
137
- `SCALING_METRICS`. One call site per package; a second is the bug:
153
+ `SCALING_METRICS`. `process-metrics.ts` names what the PROCESS costs (`process_*`: resident
154
+ memory, heap, external, CPU seconds, event-loop lag, start time, `process_info{role}`); server-only
155
+ — it reads `process`, so it is never exported from `page.ts`. One call site per package; a second
156
+ is the bug:
138
157
 
139
158
  | Recorder | The one caller |
140
159
  |---|---|
@@ -177,6 +196,8 @@ default. `runtime-metrics.ts` is the only place that names a series the chart re
177
196
  WHOLE drain's; `DEFAULT_DEADLINE_MS` (25 s) always applies; it is real monotonic time
178
197
  (`systemClock`), never the injected `clock`. `drainDeadlineMs()` is the one decision point.
179
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.
180
201
  - **A readiness grace runs before the `accept` phase** (`lifecycle-grace.ts`): `/readyz` answers 503
181
202
  with the socket still open for `drain.readinessGraceMs`, ADDED to `deadlineMs` (a chart's
182
203
  `terminationGracePeriodSeconds` must exceed the sum — 5 s + 25 s by default). Unset: 0 in
package/README.md CHANGED
@@ -32,29 +32,38 @@ 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` |
38
39
  | the two secrets files, and decrypted values → `defineEnv` | `secrets-store.ts` |
40
+ | one value sealed under the master key — `seal()` / `open()` | `seal.ts` |
41
+ | the key ring those work under: the current key plus retired ones | `seal-keys.ts` |
39
42
  | `defineConfig()` for `app.config.ts` | `config.ts` |
40
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` |
41
46
  | the `pwa` block — what an install needs, and the boot refusal when it is not there | `config-pwa.ts` |
42
47
  | the closed route vocabulary every renderer names | `route-vocabulary.ts` |
48
+ | which of two route patterns wins a pathname (`routeRank`) | `route-rank.ts` |
43
49
  | runtime roles + `ROLE` resolution | `roles.ts` |
44
50
  | `Clock` — the only source of "now" | `clock.ts` |
45
51
  | UUIDv7, nanoid, branded ids | `ids.ts` |
46
- | structured JSON logging + redaction | `logger.ts` |
52
+ | structured JSON logging + redaction; `setLogSink(sink)` — the test seam that sends every default-writer line to a sink instead of the process's streams (a test preload drops them; a test asserting on the process logger collects them) | `logger.ts` |
47
53
  | OTel-shaped spans, always on, no-op by default | `telemetry.ts` |
48
54
  | the sampling decision, and `OTEL_TRACES_SAMPLER*` | `sampler.ts` |
49
55
  | OTLP/HTTP JSON: endpoint, headers, value encoding | `otlp.ts` |
50
56
  | `SpanExporter` on the wire, batched | `otlp-span-exporter.ts` |
51
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` |
52
59
  | `reportError` + the `ErrorReporter` seam, no-op by default | `error-reporter.ts` |
53
60
  | that seam on the wire, Sentry's envelope and DSN | `error-reporter-sentry.ts` |
54
61
  | OTel-shaped counter / gauge / histogram, same seam | `metrics.ts` |
55
62
  | the `/metrics` scrape body | `metrics-text.ts` |
56
63
  | the series every process emits, incl. what the chart scales on | `runtime-metrics.ts` |
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`) |
57
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` |
58
67
  | the readiness grace between `/readyz` → 503 and the listener closing (`drain.readinessGraceMs`) | `lifecycle-grace.ts` |
59
68
  | SIGTERM/SIGINT → the one drain | `lifecycle-signals.ts` |
60
69
  | which network an IP literal belongs to — `classifyAddress`, for SSRF screens | `address-class.ts` |
@@ -63,6 +72,13 @@ Zero dependencies, zero `@ultimat3/*` imports.
63
72
  | the registrar table one same-tier package reaches another through | `registrar.ts` |
64
73
  | decode → resize → encode, the one image pipeline (over `Bun.Image`) | `image/` |
65
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.
66
82
 
67
83
  ## Errors are instructions
68
84
 
@@ -193,9 +209,13 @@ a job boundary the class is gone and the `code` is what survives — match on th
193
209
  | `OtlpEndpointInvalidError` | `X_OTLP_ENDPOINT_INVALID` | `src/otlp.ts` |
194
210
  | `OtlpHeadersInvalidError` | `X_OTLP_HEADERS_INVALID` | `src/otlp.ts` |
195
211
  | `OtlpProtocolUnsupportedError` | `X_OTLP_PROTOCOL_UNSUPPORTED` | `src/otlp.ts` |
212
+ | `SealInvalidError` | `X_SEAL_INVALID` | `src/seal-errors.ts` |
213
+ | `SealKeyMissingError` | `X_SEAL_KEY_MISSING` | `src/seal-errors.ts` |
214
+ | `SealKeyUnknownError` | `X_SEAL_KEY_UNKNOWN` | `src/seal-errors.ts` |
196
215
  | `SecretsFileInvalidError` | `X_SECRETS_FILE_INVALID` | `src/secrets-errors.ts` |
197
216
  | `SecretsFileMissingError` | `X_SECRETS_FILE_MISSING` | `src/secrets-errors.ts` |
198
217
  | `SecretsKeyInvalidError` | `X_SECRETS_KEY_INVALID` | `src/secrets-errors.ts` |
218
+ | `SecretsRingKeyInvalidError` | `X_SECRETS_KEY_INVALID` — a malformed entry of `ULTIMATE_SECRETS_RETIRED_KEYS` | `src/secrets-errors.ts` |
199
219
  | `SecretsKeyMismatchError` | `X_SECRETS_KEY_MISMATCH` | `src/secrets-errors.ts` |
200
220
  | `SecretsKeyMissingError` | `X_SECRETS_KEY_MISSING` | `src/secrets-errors.ts` |
201
221
  | `SecretsPlaintextInvalidError` | `X_SECRETS_PLAINTEXT_INVALID` | `src/secrets-errors.ts` |
@@ -305,6 +325,10 @@ fallback of its own; the caller does.
305
325
  is not our key. This is the twin of `roles.ts` — `ROLE` says what the process does,
306
326
  `ULTIMATE_ENV` says which deploy it belongs to.
307
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
+
308
332
  ## A secret is redacted by value, not by name
309
333
 
310
334
  ```ts
@@ -313,7 +337,21 @@ logger.info('boot', { dsn }); // {"dsn":"[redacted]"}
313
337
  connect(revealSecret(dsn)); // the one greppable way out
314
338
  ```
315
339
 
316
- `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`
317
355
  box catches the other case: `String()`, template literals, `+`, `JSON.stringify`, `console.log`,
318
356
  the logger and an error's `meta` all render `[redacted]`, whatever key it sits under. It is
319
357
  frozen and everything but `label` is non-enumerable, so `{ ...dsn }` cannot spread the value back
@@ -350,6 +388,56 @@ A missing file is not an error — an app may declare no secrets. A file with **
350
388
  is `X_SECRETS_KEY_MISSING` and fatal: a process that booted past its secrets authenticates against
351
389
  nothing and still reports healthy.
352
390
 
391
+ ## Seal one value
392
+
393
+ One function seals a value under the app's master key. Nothing above tier 0 writes its own AES call.
394
+
395
+ ```ts
396
+ import { openText, seal } from '@ultimat3/core';
397
+
398
+ export async function roundTrip(password: string): Promise<string> {
399
+ const purpose = 'scrape-session';
400
+ // 'x1.4f2a9c0d1e2b3a4f.<iv>.<ciphertext+tag>' — one string, base64url
401
+ const stored = await seal(password, { purpose });
402
+ return openText(stored, { purpose });
403
+ }
404
+ ```
405
+
406
+ A column is sealed by declaring it — `text().sealed()` in `@ultimat3/entity`, which derives the
407
+ purpose `entity:<table>.<column>` and calls this. Call `seal()` yourself only for a value that is
408
+ not a column.
409
+
410
+ | Export | Signature | |
411
+ |---|---|---|
412
+ | `seal` | `(plaintext: string \| Uint8Array, options: SealOptions) => Promise<string>` | always under the CURRENT key |
413
+ | `open` | `(sealed: string, options: SealPurposeOptions) => Promise<Uint8Array>` | picks the key the string names |
414
+ | `openText` | `(sealed: string, options: SealPurposeOptions) => Promise<string>` | the string spelling; no JSON helper |
415
+ | `sealAll` | `(plaintext: string \| Uint8Array, options: SealPurposeOptions) => Promise<readonly string[]>` | the deterministic seal under every declared key, current first |
416
+ | `isSealed` | `(value: unknown) => value is string` | shape only — tells a legacy plaintext row from a sealed one |
417
+ | `sealedKeyId` | `(sealed: string) => string` | what a re-seal `backfill()` compares to the current id |
418
+ | `sealKeyIds` | `(source?: SealKeySource) => Promise<{ current: string; retired: readonly string[] }>` | ids, never keys |
419
+ | `resolveSealKeys` | `(source?: SealKeySource) => Promise<SealKeyRing>` | the ring, resolved once — pass it as `keys` to seal or open MANY values in one operation |
420
+
421
+ `SealPurposeOptions` is `{ purpose: string; root?: string; env?: Record<string, string | undefined> }`;
422
+ `SealOptions` adds `deterministic?: boolean`. `root` and `env` default to the working directory and
423
+ `process.env`, exactly as `installSecrets()` does. `keys?: SealKeyRing` skips that lookup: a batch
424
+ resolves the ring once and hands it to every call — never kept past the operation, so the next one
425
+ sees a rotation.
426
+
427
+ | Rule | |
428
+ |---|---|
429
+ | Key | the one `x secrets` manages — `ULTIMATE_SECRETS_KEY` first, `.secrets.key` second. No second variable |
430
+ | `purpose` | REQUIRED, bound as additional authenticated data with the key id: a value sealed for `scrape-session` does not open as `entity:connections.password` |
431
+ | Wire form | `x1.<keyId>.<iv>.<ciphertext+tag>`. `keyId` is `masterKeyId`'s, so a rotated key is a named mismatch, never a garbled read |
432
+ | Key ring | retired keys in `ULTIMATE_SECRETS_RETIRED_KEYS` (comma-separated hex). `x secrets rotate` writes the replaced key there, inside `secrets.enc.json`; `installSecrets()` carries it into the process; `x secrets rotate --drop <keyId>` removes it |
433
+ | Refusals | `X_SEAL_KEY_MISSING`, `X_SEAL_KEY_UNKNOWN`, `X_SEAL_INVALID` — all terminal. Never garbage, never the raw string back, and no reading of an unsealed value |
434
+
435
+ **`deterministic: true` reveals equality.** The IV is an HMAC of the purpose and the plaintext, so
436
+ equal values seal to equal strings and a column can be matched by `=`. Anyone who can read the
437
+ stored strings sees which rows hold the same value; never use it for a low-entropy value (a
438
+ boolean, a status, a PIN). During a rotation one value has one sealed form per declared key —
439
+ match with `sealAll()`; uniqueness cannot be held across keys.
440
+
353
441
  ## Time, ids, telemetry, drain
354
442
 
355
443
  - Never call `Date.now()`. Take a `Clock`; tests pass `frozenClock('2026-07-26T10:00:00Z')`.
@@ -497,7 +585,7 @@ never a silently wrong page.
497
585
  | | |
498
586
  |---|---|
499
587
  | Signature | truncated HMAC-SHA256, compared in constant time |
500
- | 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 |
501
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` |
502
590
  | Signed, not encrypted | the client already has these rows; what it must not do is *invent* a position |
503
591
  | `usesDevCursorSecret()` | true while the shipped dev key is in use |
@@ -592,7 +680,7 @@ nothing consulted it before deciding to try again. `As of 2026-08-23`.
592
680
  |---|---|---|
593
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 |
594
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 |
595
- | `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 |
596
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 `<` |
597
685
  | `isRetryableStatus(status)`, `RETRYABLE_STATUSES` | `>= 500`, plus 408, 409, 425, 429 | which HTTP answers are worth repeating |
598
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 |
@@ -614,6 +702,7 @@ read; `@ultimat3/jobs` re-exports both rather than keeping a second pair.
614
702
  |---|---|---|
615
703
  | `clientTransport({ method, url, body?, rawBody?, headers?, signal?, idempotencyKey?, flight?, fresh?, retry?, onResponse?, decodeError?, onEnvelope?, fetchImpl? })` | every browser request | `credentials: 'same-origin'`, JSON in and out, the `idempotency-key` header, a non-2xx `problem+json` back into the server's code (`meta.origin: 'remote'`) unless `decodeError` answers first, a network `TypeError` into `X_CLIENT_TRANSPORT_FAILED`. A GET is abortable on `rescope()` and deduped only when a `flight` is passed (`fresh` refuses to join); any other method is never deduped and never aborted by the fence. `rawBody` goes out verbatim with no default header and resolves `undefined`. `onResponse` sees headers before the body is read. `onEnvelope` sees the decoded records envelope after adoption — the one way to learn the order of `records[type]`. `fetchImpl` defaults to `globalThis.fetch`, read at call time |
616
704
  | `actionPath(name)`, `actionRoute(name)`, `queryPath(name)`, `QUERY_PATH_PREFIX`, `splitWords`, `pluralize` | the one URL rule | `publishPost` → `/api/posts/publish`, `liveFeed` → `/_x/query/live-feed`. Tier 0 so `action`, `query` and `realtime` derive one URL with no sideways import |
705
+ | `actionPath(name)` with no `style`, `renderedActionPathStyle()`, `CLIENT_PATH_STYLE_META` | the style nobody restates | `actionPath(name)` reads the document's `<meta name="ultimate-path-style">` stamp, so a browser caller derives under the style the server serves (`defineApi({ http: { pathStyle } })`). No document, no stamp or an unknown value is `'resource'` — a `'resource'` server writes no stamp. A named `style` is never overridden |
617
706
  | `RECORDS_HEADER`, `encodeRecordEnvelope`, `decodeRecordEnvelope`, `RecordRows` | `{ data, records?: { [type]: { [key]: Row } }, removed?: { [type]: key[] } }`, only behind `x-ultimate-records: 1` | how an answer carries entity rows without changing the wire of an answer that has none. A malformed envelope is `X_CLIENT_RECORD_ENVELOPE_INVALID` |
618
707
  | `pageClient()` → `{ store, socket, scope }`, `RecordSink` | one handle per TAB, not per module copy | every island bundle carries its own core; the handle lives on `globalThis` under one `Symbol.for` key so all of them resolve the same store. No store installed = records dropped |
619
708
  | `rescope(principal)`, `onRescope(fn)`, `isSuperseded(error)` | the principal fence | a principal change bumps the epoch and notifies synchronously; reads in flight reject `X_CLIENT_SCOPE_CHANGED`, writes complete but their records are not adopted. `isSuperseded` answers true for it and for `X_SUPERSEDED` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/core",
3
- "version": "22.15.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": "22.15.0"
43
+ "@ultimat3/schema": "24.0.0"
44
44
  }
45
45
  }
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * The ONE URL rule for the two HTTP primitives: an action's export name derives `POST
3
- * /api/<resource>/<verb>`, a query's derives `GET /_x/query/<kebab>`. Tier 0 and pure string math,
4
- * so `action`, `query` and `realtime` (all tier 3) derive the same URL with no sideways import and
5
- * a browser bundle pays a few hundred bytes for it. Moved verbatim from both packages' `naming.ts`.
3
+ * /api/<resource>/<verb>`, a query's derives `GET /_x/query/<kebab>`. Tier 0 and string math, so
4
+ * `action`, `query` and `realtime` (all tier 3) derive the same URL with no sideways import and a
5
+ * browser bundle pays a few hundred bytes for it. The one thing read rather than computed is the
6
+ * style a caller did not name: `actionPath` takes it from the document the server rendered.
6
7
  */
7
8
 
9
+ import { CLIENT_PATH_STYLE_META } from './page-meta';
10
+
8
11
  /** Irregular plurals we actually hit in domain models. A `Map`: the key is a caller's word. */
9
12
  const IRREGULAR: ReadonlyMap<string, string> = new Map([
10
13
  ['person', 'people'],
@@ -98,9 +101,26 @@ export function actionRoute(name: string, style: ActionPathStyle = 'resource'):
98
101
  return { verb: head, resource, path: `/api/${resource}/${head}` };
99
102
  }
100
103
 
101
- /** The path an action is POSTed to — `actionRoute(name, style).path`. */
102
- export function actionPath(name: string, style: ActionPathStyle = 'resource'): string {
103
- return actionRoute(name, style).path;
104
+ /**
105
+ * The style the SERVER stamped into this document (`<meta name="ultimate-path-style">`), or
106
+ * `undefined`: no document (a server, a worker, a script), no stamp (a `'resource'` server writes
107
+ * none), or a value this build does not know. A style is a runtime value and a type is erased, so
108
+ * this is the only way a browser learns it without the app restating it.
109
+ */
110
+ export function renderedActionPathStyle(): ActionPathStyle | undefined {
111
+ const doc: { querySelector?: (selector: string) => { content?: unknown } | null } | undefined =
112
+ Reflect.get(globalThis, 'document');
113
+ const stamped = doc?.querySelector?.(`meta[name="${CLIENT_PATH_STYLE_META}"]`)?.content;
114
+ return ACTION_PATH_STYLES.find((known) => known === stamped);
115
+ }
116
+
117
+ /**
118
+ * The path an action is POSTed to. A caller that names no `style` gets the document's — every
119
+ * browser caller (`rpc`, `useMutation`, the outbox replay) derives under the one the server serves
120
+ * — and `'resource'` where there is no document to ask. A named style is never overridden.
121
+ */
122
+ export function actionPath(name: string, style?: ActionPathStyle): string {
123
+ return actionRoute(name, style ?? renderedActionPathStyle() ?? 'resource').path;
104
124
  }
105
125
 
106
126
  /** `liveFeed` -> `/_x/query/live-feed`, read with `GET …?orgId=…`. */
@@ -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)) {