@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.
- package/CLAUDE.md +23 -2
- package/README.md +93 -4
- package/package.json +2 -2
- package/src/client-paths.ts +26 -6
- package/src/config-defaults.ts +46 -0
- package/src/config-merge.ts +8 -0
- package/src/config-shape.ts +112 -0
- package/src/config-site.ts +14 -3
- package/src/config.ts +74 -83
- package/src/context.ts +13 -1
- package/src/cookie.ts +35 -0
- package/src/core-error-codes.ts +5 -0
- package/src/cursor.ts +4 -1
- package/src/decimal-order.ts +5 -4
- package/src/dev-secrets.ts +1 -1
- package/src/error-render.ts +4 -2
- package/src/error-reporter-sentry.ts +7 -3
- package/src/error-retry.ts +8 -0
- package/src/flight-gate.ts +16 -4
- package/src/fnv1a.ts +19 -0
- package/src/health-disclosure.ts +43 -0
- package/src/host-rules.ts +28 -1
- package/src/html-escape.ts +24 -0
- package/src/image/errors.ts +3 -1
- package/src/image/png-pixels.ts +29 -6
- package/src/image/probe.ts +7 -2
- package/src/image/raster.ts +3 -1
- package/src/index.ts +32 -0
- package/src/logger.ts +103 -10
- package/src/nearest-name.ts +11 -2
- package/src/otlp-metric-exporter.ts +1 -1
- package/src/otlp-span-exporter.ts +1 -1
- package/src/otlp.ts +44 -13
- package/src/page-meta.ts +7 -0
- package/src/page.ts +1 -0
- package/src/pg-executor.ts +15 -0
- package/src/process-metrics.ts +206 -0
- package/src/public-cause.ts +37 -0
- package/src/registrar.ts +21 -4
- package/src/retry.ts +15 -2
- package/src/route-rank.ts +36 -0
- package/src/same-origin.ts +1 -1
- package/src/sampler.ts +6 -2
- package/src/seal-errors.ts +76 -0
- package/src/seal-keys.ts +121 -0
- package/src/seal.ts +259 -0
- package/src/secrets-errors.ts +33 -1
- package/src/secrets.ts +21 -11
- package/src/source-mask.ts +14 -8
- 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-
|
|
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`.
|
|
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
|
-
`
|
|
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": "
|
|
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": "
|
|
43
|
+
"@ultimat3/schema": "24.0.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
package/src/client-paths.ts
CHANGED
|
@@ -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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
/**
|
|
102
|
-
|
|
103
|
-
|
|
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
|
+
}
|
package/src/config-merge.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/config-site.ts
CHANGED
|
@@ -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
|
-
|
|
112
|
-
|
|
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)) {
|