@ultimat3/core 22.14.0 → 23.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -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
@@ -71,7 +74,7 @@ top-level `UltimateError` use in `error-codes.ts`.
71
74
  | which HTTP statuses are worth repeating | `retryable-status.ts` | `>= 500` plus 408, 409, 425, 429 |
72
75
  | 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
76
  | 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` |
77
+ | 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
78
  | 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
79
  | which row survives a conflict | `conflict-policy.ts` (`ConflictPolicy`, `resolveConflict`, `Row`) | read by `action`'s mutator and `realtime`'s rebase |
77
80
  | 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 |
@@ -80,6 +83,7 @@ top-level `UltimateError` use in `error-codes.ts`.
80
83
  | 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
84
  | 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
85
  | 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()` |
86
+ | 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
87
 
84
88
  - **`installSecrets()` is the ONLY path from `secrets.enc.json` to an app value**, landing in
85
89
  `process.env` before `defineEnv` reads it. The real environment always wins.
@@ -91,6 +95,14 @@ top-level `UltimateError` use in `error-codes.ts`.
91
95
  `X_SECRETS_KEY_MISMATCH`'s command carries no key id (it is read from a file). Its seven codes
92
96
  register through `registerErrorCodes()`, so `resetErrorCodes()` drops them — take
93
97
  `errorCodeSnapshot()` first. The envelope's `kid` lets *wrong key* and *edited file* be two codes.
98
+ - **`seal.ts` adds no variable for the CURRENT key** — `findMasterKey()`, the same call
99
+ `installSecrets()` makes. Retired keys are `ULTIMATE_SECRETS_RETIRED_KEYS`, written into
100
+ `secrets.enc.json` by `x secrets rotate`; the ring memo is keyed by its source strings, never by
101
+ time. Constants, `parseMasterKey`, `masterKeyId`, `importKey` and the base64 codec are
102
+ `secrets.ts`'s — no second set. `seal-errors.ts` runs nothing at import: its titles are in
103
+ `CORE_CODE_TITLES` and its retry class in `CORE_ERROR_RETRY`, so it is no `sideEffects` anchor.
104
+ The wire form `x1.<keyId>.<iv>.<ciphertext+tag>` and the AAD string are persisted data: a change
105
+ is `x2`, never an edit.
94
106
  - **`schema-error-codes.ts` registers `@ultimat3/schema`'s codes** (schema cannot call core), and
95
107
  derives their retry classification from the same set. It is a `SIDE_EFFECTS_ANCHORS` entry.
96
108
  - `timing-safe-equal.ts` is the one constant-time comparison (`@ultimat3/auth`, `@ultimat3/storage`).
@@ -134,7 +146,10 @@ top-level `UltimateError` use in `error-codes.ts`.
134
146
  `metrics.ts` is to `telemetry.ts` what a counter is to a span: always on, no-op exporter by
135
147
  default. `runtime-metrics.ts` is the only place that names a series the chart reads
136
148
  (`http_requests_total`, `connections`, `queue_depth`), keyed by `ScalingSignal` in
137
- `SCALING_METRICS`. One call site per package; a second is the bug:
149
+ `SCALING_METRICS`. `process-metrics.ts` names what the PROCESS costs (`process_*`: resident
150
+ memory, heap, external, CPU seconds, event-loop lag, start time, `process_info{role}`); server-only
151
+ — it reads `process`, so it is never exported from `page.ts`. One call site per package; a second
152
+ is the bug:
138
153
 
139
154
  | Recorder | The one caller |
140
155
  |---|---|
package/README.md CHANGED
@@ -36,6 +36,8 @@ Zero dependencies, zero `@ultimat3/*` imports.
36
36
  | a value that cannot be printed by accident | `secret.ts` |
37
37
  | the committed encrypted secrets envelope, AES-256-GCM | `secrets.ts` |
38
38
  | the two secrets files, and decrypted values → `defineEnv` | `secrets-store.ts` |
39
+ | one value sealed under the master key — `seal()` / `open()` | `seal.ts` |
40
+ | the key ring those work under: the current key plus retired ones | `seal-keys.ts` |
39
41
  | `defineConfig()` for `app.config.ts` | `config.ts` |
40
42
  | how overlays layer onto it — per section, key by key | `config-merge.ts` |
41
43
  | the `pwa` block — what an install needs, and the boot refusal when it is not there | `config-pwa.ts` |
@@ -43,7 +45,7 @@ Zero dependencies, zero `@ultimat3/*` imports.
43
45
  | runtime roles + `ROLE` resolution | `roles.ts` |
44
46
  | `Clock` — the only source of "now" | `clock.ts` |
45
47
  | UUIDv7, nanoid, branded ids | `ids.ts` |
46
- | structured JSON logging + redaction | `logger.ts` |
48
+ | 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
49
  | OTel-shaped spans, always on, no-op by default | `telemetry.ts` |
48
50
  | the sampling decision, and `OTEL_TRACES_SAMPLER*` | `sampler.ts` |
49
51
  | OTLP/HTTP JSON: endpoint, headers, value encoding | `otlp.ts` |
@@ -54,6 +56,7 @@ Zero dependencies, zero `@ultimat3/*` imports.
54
56
  | OTel-shaped counter / gauge / histogram, same seam | `metrics.ts` |
55
57
  | the `/metrics` scrape body | `metrics-text.ts` |
56
58
  | the series every process emits, incl. what the chart scales on | `runtime-metrics.ts` |
59
+ | 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
60
  | graceful drain, `/healthz`, `/readyz` | `lifecycle.ts` |
58
61
  | the readiness grace between `/readyz` → 503 and the listener closing (`drain.readinessGraceMs`) | `lifecycle-grace.ts` |
59
62
  | SIGTERM/SIGINT → the one drain | `lifecycle-signals.ts` |
@@ -193,9 +196,13 @@ a job boundary the class is gone and the `code` is what survives — match on th
193
196
  | `OtlpEndpointInvalidError` | `X_OTLP_ENDPOINT_INVALID` | `src/otlp.ts` |
194
197
  | `OtlpHeadersInvalidError` | `X_OTLP_HEADERS_INVALID` | `src/otlp.ts` |
195
198
  | `OtlpProtocolUnsupportedError` | `X_OTLP_PROTOCOL_UNSUPPORTED` | `src/otlp.ts` |
199
+ | `SealInvalidError` | `X_SEAL_INVALID` | `src/seal-errors.ts` |
200
+ | `SealKeyMissingError` | `X_SEAL_KEY_MISSING` | `src/seal-errors.ts` |
201
+ | `SealKeyUnknownError` | `X_SEAL_KEY_UNKNOWN` | `src/seal-errors.ts` |
196
202
  | `SecretsFileInvalidError` | `X_SECRETS_FILE_INVALID` | `src/secrets-errors.ts` |
197
203
  | `SecretsFileMissingError` | `X_SECRETS_FILE_MISSING` | `src/secrets-errors.ts` |
198
204
  | `SecretsKeyInvalidError` | `X_SECRETS_KEY_INVALID` | `src/secrets-errors.ts` |
205
+ | `SecretsRingKeyInvalidError` | `X_SECRETS_KEY_INVALID` — a malformed entry of `ULTIMATE_SECRETS_RETIRED_KEYS` | `src/secrets-errors.ts` |
199
206
  | `SecretsKeyMismatchError` | `X_SECRETS_KEY_MISMATCH` | `src/secrets-errors.ts` |
200
207
  | `SecretsKeyMissingError` | `X_SECRETS_KEY_MISSING` | `src/secrets-errors.ts` |
201
208
  | `SecretsPlaintextInvalidError` | `X_SECRETS_PLAINTEXT_INVALID` | `src/secrets-errors.ts` |
@@ -350,6 +357,56 @@ A missing file is not an error — an app may declare no secrets. A file with **
350
357
  is `X_SECRETS_KEY_MISSING` and fatal: a process that booted past its secrets authenticates against
351
358
  nothing and still reports healthy.
352
359
 
360
+ ## Seal one value
361
+
362
+ One function seals a value under the app's master key. Nothing above tier 0 writes its own AES call.
363
+
364
+ ```ts
365
+ import { openText, seal } from '@ultimat3/core';
366
+
367
+ export async function roundTrip(password: string): Promise<string> {
368
+ const purpose = 'scrape-session';
369
+ // 'x1.4f2a9c0d1e2b3a4f.<iv>.<ciphertext+tag>' — one string, base64url
370
+ const stored = await seal(password, { purpose });
371
+ return openText(stored, { purpose });
372
+ }
373
+ ```
374
+
375
+ A column is sealed by declaring it — `text().sealed()` in `@ultimat3/entity`, which derives the
376
+ purpose `entity:<table>.<column>` and calls this. Call `seal()` yourself only for a value that is
377
+ not a column.
378
+
379
+ | Export | Signature | |
380
+ |---|---|---|
381
+ | `seal` | `(plaintext: string \| Uint8Array, options: SealOptions) => Promise<string>` | always under the CURRENT key |
382
+ | `open` | `(sealed: string, options: SealPurposeOptions) => Promise<Uint8Array>` | picks the key the string names |
383
+ | `openText` | `(sealed: string, options: SealPurposeOptions) => Promise<string>` | the string spelling; no JSON helper |
384
+ | `sealAll` | `(plaintext: string \| Uint8Array, options: SealPurposeOptions) => Promise<readonly string[]>` | the deterministic seal under every declared key, current first |
385
+ | `isSealed` | `(value: unknown) => value is string` | shape only — tells a legacy plaintext row from a sealed one |
386
+ | `sealedKeyId` | `(sealed: string) => string` | what a re-seal `backfill()` compares to the current id |
387
+ | `sealKeyIds` | `(source?: SealKeySource) => Promise<{ current: string; retired: readonly string[] }>` | ids, never keys |
388
+ | `resolveSealKeys` | `(source?: SealKeySource) => Promise<SealKeyRing>` | the ring, resolved once — pass it as `keys` to seal or open MANY values in one operation |
389
+
390
+ `SealPurposeOptions` is `{ purpose: string; root?: string; env?: Record<string, string | undefined> }`;
391
+ `SealOptions` adds `deterministic?: boolean`. `root` and `env` default to the working directory and
392
+ `process.env`, exactly as `installSecrets()` does. `keys?: SealKeyRing` skips that lookup: a batch
393
+ resolves the ring once and hands it to every call — never kept past the operation, so the next one
394
+ sees a rotation.
395
+
396
+ | Rule | |
397
+ |---|---|
398
+ | Key | the one `x secrets` manages — `ULTIMATE_SECRETS_KEY` first, `.secrets.key` second. No second variable |
399
+ | `purpose` | REQUIRED, bound as additional authenticated data with the key id: a value sealed for `scrape-session` does not open as `entity:connections.password` |
400
+ | Wire form | `x1.<keyId>.<iv>.<ciphertext+tag>`. `keyId` is `masterKeyId`'s, so a rotated key is a named mismatch, never a garbled read |
401
+ | 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 |
402
+ | 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 |
403
+
404
+ **`deterministic: true` reveals equality.** The IV is an HMAC of the purpose and the plaintext, so
405
+ equal values seal to equal strings and a column can be matched by `=`. Anyone who can read the
406
+ stored strings sees which rows hold the same value; never use it for a low-entropy value (a
407
+ boolean, a status, a PIN). During a rotation one value has one sealed form per declared key —
408
+ match with `sealAll()`; uniqueness cannot be held across keys.
409
+
353
410
  ## Time, ids, telemetry, drain
354
411
 
355
412
  - Never call `Date.now()`. Take a `Clock`; tests pass `frozenClock('2026-07-26T10:00:00Z')`.
@@ -614,6 +671,7 @@ read; `@ultimat3/jobs` re-exports both rather than keeping a second pair.
614
671
  |---|---|---|
615
672
  | `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
673
  | `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 |
674
+ | `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
675
  | `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
676
  | `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
677
  | `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.14.0",
3
+ "version": "23.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.14.0"
43
+ "@ultimat3/schema": "23.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=…`. */
@@ -6,6 +6,7 @@
6
6
  // were: shape, merge and screen are one subject.
7
7
 
8
8
  import { describeValue } from './error-render';
9
+ import { ConfigInvalidError } from './errors';
9
10
 
10
11
  /**
11
12
  * The surfaces that render documents a browser navigates between. `api` answers JSON and `shared`
@@ -23,28 +24,146 @@ export type NavigationSurface = (typeof NAVIGATION_SURFACES)[number];
23
24
  */
24
25
  export interface NavigationConfig {
25
26
  readonly client: readonly NavigationSurface[];
27
+ readonly speculation: SpeculationConfig;
26
28
  }
27
29
 
30
+ /**
31
+ * How eagerly a browser may fetch a link's document before the click, on a page that carries no
32
+ * client router. `'moderate'`: on pointer rest or pointer down. `'conservative'`: on pointer down
33
+ * only. `'eager'` and `'immediate'` are not offered — they fetch links nobody pointed at.
34
+ */
35
+ export const SPECULATION_EAGERNESS = ['moderate', 'conservative'] as const;
36
+ export type SpeculationEagerness = (typeof SPECULATION_EAGERNESS)[number];
37
+
38
+ /**
39
+ * Speculation Rules (`<script type="speculationrules">`) for the documents a browser navigates
40
+ * between WITHOUT the client router — the 0kb answer to a slow full-page load. PREFETCH only, never
41
+ * prerender: a prefetch runs no script of the next page, so no analytics hit and no island boots
42
+ * for a page nobody opened. ON BY DEFAULT at `'moderate'`; `prefetch: false` emits nothing.
43
+ *
44
+ * Only pages the route table knows to be a pure read are candidates (`@ultimat3/cli`'s
45
+ * `page-speculation.ts`); `exclude` removes more, as URL patterns (`/blog/*`, `/legal/:doc`).
46
+ */
47
+ export interface SpeculationConfig {
48
+ readonly prefetch: SpeculationEagerness | false;
49
+ readonly exclude: readonly string[];
50
+ }
51
+
52
+ export const DEFAULT_SPECULATION: SpeculationConfig = Object.freeze({
53
+ prefetch: 'moderate',
54
+ exclude: Object.freeze([]) as readonly string[],
55
+ });
56
+
28
57
  export interface NavigationSection {
29
58
  readonly navigation: NavigationConfig;
30
59
  }
31
60
 
32
61
  export interface NavigationSectionInput {
33
- readonly navigation?: { readonly client?: readonly NavigationSurface[] | undefined } | undefined;
62
+ readonly navigation?:
63
+ | {
64
+ readonly client?: readonly NavigationSurface[] | undefined;
65
+ readonly speculation?:
66
+ | {
67
+ readonly prefetch?: SpeculationEagerness | false | undefined;
68
+ readonly exclude?: readonly string[] | undefined;
69
+ }
70
+ | undefined;
71
+ }
72
+ | undefined;
34
73
  }
35
74
 
36
- /** A whole-value key: the last layer that listed surfaces wins, as `locales` does. */
75
+ /**
76
+ * Whole-value keys: the last layer that listed surfaces wins, as `locales` does — and so does the
77
+ * last one that set `speculation.prefetch` or listed `speculation.exclude`, each on its own.
78
+ */
37
79
  export function mergeNavigation(layers: readonly NavigationSectionInput[]): NavigationSection {
38
80
  let client: readonly NavigationSurface[] = [];
81
+ let prefetch: SpeculationEagerness | false = DEFAULT_SPECULATION.prefetch;
82
+ let exclude: readonly string[] = DEFAULT_SPECULATION.exclude;
83
+ // A layer that wrote something other than an object (`speculation: 'off'`, `null`, a list) has
84
+ // no key to merge. It is carried through AS WRITTEN so `navigationIssues` refuses it — dropped
85
+ // here, the app would run at the default it believed it had turned off.
86
+ let unmergeable: { readonly said: unknown } | undefined;
39
87
  for (const layer of layers) {
40
88
  const said = layer.navigation?.client;
41
89
  if (said !== undefined) client = said;
90
+ const speculation: unknown = layer.navigation?.speculation;
91
+ if (speculation === undefined) continue;
92
+ if (!isSpeculationObject(speculation)) {
93
+ unmergeable ??= { said: speculation };
94
+ continue;
95
+ }
96
+ if (speculation.prefetch !== undefined) prefetch = speculation.prefetch;
97
+ if (speculation.exclude !== undefined) exclude = speculation.exclude;
98
+ }
99
+ const speculation =
100
+ unmergeable === undefined ? { prefetch, exclude } : (unmergeable.said as SpeculationConfig);
101
+ return { navigation: { client, speculation } };
102
+ }
103
+
104
+ type SpeculationInput = NonNullable<
105
+ NonNullable<NavigationSectionInput['navigation']>['speculation']
106
+ >;
107
+
108
+ const isSpeculationObject = (value: unknown): value is SpeculationInput =>
109
+ typeof value === 'object' && value !== null && !Array.isArray(value);
110
+
111
+ /**
112
+ * A pattern is emitted inside a JSON string the browser parses as a URL pattern: it must be a
113
+ * same-origin PATH, so anything not starting with `/` (a host, a scheme, `*`) is refused.
114
+ */
115
+ function speculationIssues(speculation: unknown, issues: string[]): void {
116
+ if (!isSpeculationObject(speculation)) {
117
+ issues.push(`navigation.speculation must be an object, not ${describeValue(speculation)}`);
118
+ return;
119
+ }
120
+ const { prefetch, exclude } = speculation as { prefetch?: unknown; exclude?: unknown };
121
+ if (prefetch !== false && !SPECULATION_EAGERNESS.some((known) => known === prefetch)) {
122
+ issues.push(
123
+ `navigation.speculation.prefetch must be ${SPECULATION_EAGERNESS.join(', ')} or false, not ${describeValue(prefetch)}`,
124
+ );
125
+ }
126
+ if (!Array.isArray(exclude)) {
127
+ issues.push(
128
+ `navigation.speculation.exclude must be a list of URL patterns, not ${describeValue(exclude)}`,
129
+ );
130
+ return;
131
+ }
132
+ for (const pattern of exclude as readonly unknown[]) {
133
+ if (typeof pattern !== 'string' || !pattern.startsWith('/')) {
134
+ issues.push(
135
+ `navigation.speculation.exclude contains ${describeValue(pattern)}, not a path pattern starting with "/"`,
136
+ );
137
+ }
138
+ }
139
+ }
140
+
141
+ /**
142
+ * `navigation.speculation` as some reader OUTSIDE `defineConfig` found it (`@ultimat3/cli` imports
143
+ * the app's config module structurally): the defaults for what it does not say, and the SAME
144
+ * refusal `defineConfig` gives for what it says wrongly. One validator — a second reader that
145
+ * coerced `'eager'` to `'moderate'` or dropped a bad pattern would serve rules the app never wrote.
146
+ */
147
+ export function resolveSpeculation(said: unknown): SpeculationConfig {
148
+ if (said === undefined) return DEFAULT_SPECULATION;
149
+ const { speculation } = mergeNavigation([
150
+ { navigation: { speculation: said as SpeculationInput } },
151
+ ]).navigation;
152
+ const issues: string[] = [];
153
+ speculationIssues(speculation, issues);
154
+ if (issues.length > 0) {
155
+ throw new ConfigInvalidError({
156
+ cause: issues.join('; '),
157
+ fix: 'Correct navigation.speculation in app.config.ts: prefetch is "moderate", "conservative" or false, and exclude is a list of path patterns starting with "/"',
158
+ meta: { issues },
159
+ });
42
160
  }
43
- return { navigation: { client } };
161
+ return speculation;
44
162
  }
45
163
 
46
164
  /** Appends every refusal the section earns to `issues`, `config.ts`' one list. */
47
165
  export function navigationIssues(config: NavigationSection, issues: string[]): void {
166
+ speculationIssues(config.navigation.speculation, issues);
48
167
  // `unknown`: an untyped config file reaches this validator with whatever it wrote.
49
168
  const client: unknown = config.navigation.client;
50
169
  if (!Array.isArray(client)) {
@@ -54,6 +54,11 @@ const CORE_CODE_TITLES = {
54
54
  X_REGISTRAR_CONFLICT: 'two different registrars are loaded for one primitive kind',
55
55
  X_REGISTRAR_MISSING: 'no registrar is loaded for a primitive kind',
56
56
  X_ROLE_INVALID: 'ROLE is not a known runtime role',
57
+ // `seal.ts`'s three. Titled here, not registered beside their classes the way the X_SECRETS_*
58
+ // set is, so `seal-errors.ts` runs nothing at import and is no `sideEffects` anchor.
59
+ X_SEAL_INVALID: 'a sealed value did not authenticate, or is not a sealed value',
60
+ X_SEAL_KEY_MISSING: 'no master key to seal or open a value with',
61
+ X_SEAL_KEY_UNKNOWN: 'a sealed value names a master key this process does not declare',
57
62
  X_SERVICE_DUPLICATE: 'a service name is registered twice',
58
63
  X_SERVICE_MISSING: 'service is not registered on the request context',
59
64
  X_SHUTDOWN_TIMEOUT: 'graceful shutdown exceeded its deadline',
@@ -188,9 +188,11 @@ export function renderFixLiteral(value: unknown, placeholder: string): string {
188
188
  * denylist has to be right about every character every shell will ever read, and this only has to
189
189
  * be right about the ones a fix line needs. No space, so a value is always one word; no leading
190
190
  * `-` or `~`, because an argument starting with either is an OPTION or a home directory rather
191
- * than the value it reads as.
191
+ * than the value it reads as. A leading `@` IS carried: a scoped package name starts with one, and
192
+ * `@` opens nothing in a POSIX shell — `$@` needs the `$`, an extglob `@(…)` the parenthesis, and
193
+ * neither is in the set.
192
194
  */
193
- const SHELL_ARG_SAFE = /^[A-Za-z0-9/][A-Za-z0-9._:/@=+,%~-]*$/;
195
+ const SHELL_ARG_SAFE = /^[A-Za-z0-9/@][A-Za-z0-9._:/@=+,%~-]*$/;
194
196
 
195
197
  /**
196
198
  * The same value where the text is read by a SHELL. A `fix:` is a command meant to be pasted, so a
@@ -61,6 +61,14 @@ const CORE_ERROR_RETRY: ReadonlyMap<string, ErrorRetry> = new Map(
61
61
  // The principal fence's twin of `X_SUPERSEDED`, listed for the same reason: re-sending a read
62
62
  // from the previous principal's scope is refused identically every time.
63
63
  X_CLIENT_SCOPE_CHANGED: 'terminal',
64
+ // `seal.ts`'s three, listed for `X_NOT_IMPLEMENTED`'s reason: a missing key, an undeclared
65
+ // key and a value that failed its tag are each the same answer on attempt five, and left
66
+ // unclassified a job opening a sealed column would spend its whole retry policy re-proving it.
67
+ // Here rather than through `registerErrorRetry` because they are core's own codes, which that
68
+ // function refuses, and a module-scope call would make `seal-errors.ts` a side-effect anchor.
69
+ X_SEAL_INVALID: 'terminal',
70
+ X_SEAL_KEY_MISSING: 'terminal',
71
+ X_SEAL_KEY_UNKNOWN: 'terminal',
64
72
  } as const),
65
73
  );
66
74
 
package/src/index.ts CHANGED
@@ -119,8 +119,15 @@ export type {
119
119
  NavigationSection,
120
120
  NavigationSectionInput,
121
121
  NavigationSurface,
122
+ SpeculationConfig,
123
+ SpeculationEagerness,
124
+ } from './config-navigation';
125
+ export {
126
+ DEFAULT_SPECULATION,
127
+ NAVIGATION_SURFACES,
128
+ resolveSpeculation,
129
+ SPECULATION_EAGERNESS,
122
130
  } from './config-navigation';
123
- export { NAVIGATION_SURFACES } from './config-navigation';
124
131
  export type {
125
132
  PwaColors,
126
133
  PwaConfig,
@@ -578,6 +585,9 @@ export type { Direction } from './locale-direction';
578
585
  export { directionOf, isRtl } from './locale-direction';
579
586
  export type { LocalePathSplit } from './locale-path';
580
587
  export { localeSegment, localizePath, splitLocalePath } from './locale-path';
588
+ // The process logger's test seam, beside nothing it groups with: where a default-writer line goes.
589
+ export type { LogSink } from './logger';
590
+ export { setLogSink } from './logger';
581
591
  export { isMcpExposed, type McpExposureDeclaration } from './mcp-exposure';
582
592
  export type { MeasurementActorFactory } from './measurement-actor';
583
593
  export {
@@ -597,12 +607,15 @@ export {
597
607
  CLIENT_NAVIGATION_LOCATION_HEADER,
598
608
  CLIENT_NAVIGATION_SCOPE_HEADER,
599
609
  CLIENT_NAVIGATION_SURFACE_HEADER,
610
+ CLIENT_PATH_STYLE_META,
600
611
  CLIENT_PERSIST_META,
601
612
  CLIENT_SCOPE_HEADER,
602
613
  CLIENT_SCOPE_META,
603
614
  CLIENT_SYNC_META,
604
615
  CLIENT_SYNC_WORKER_META,
605
616
  } from './page-meta';
617
+ export type { ProcessMetricsOptions, ProcessReading } from './process-metrics';
618
+ export { readProcess, resetProcessMetrics, startProcessMetrics } from './process-metrics';
606
619
  export { type CappedBody, readWithinLimit } from './read-capped';
607
620
  export type { RecordEnvelope, RecordRows } from './record-envelope';
608
621
  export { decodeRecordEnvelope, encodeRecordEnvelope, RECORDS_HEADER } from './record-envelope';
@@ -641,6 +654,20 @@ export {
641
654
  type OriginVerdict,
642
655
  proveSameOrigin,
643
656
  } from './same-origin';
657
+ export type { SealOptions, SealPurposeOptions } from './seal';
658
+ export { isSealed, open, openText, SEAL_VERSION, seal, sealAll, sealedKeyId } from './seal';
659
+ export type { SealInvalidReason } from './seal-errors';
660
+ export { SealInvalidError, SealKeyMissingError, SealKeyUnknownError } from './seal-errors';
661
+ export type { SealKeyRing, SealKeySource } from './seal-keys';
662
+ export {
663
+ resolveSealKeys,
664
+ SECRETS_RETIRED_KEYS_ENV,
665
+ sealKeyIds,
666
+ splitRetiredKeys,
667
+ } from './seal-keys';
668
+ // Beside the ring it is raised for, not in the `exports/secrets` group: same code as
669
+ // `SecretsKeyInvalidError`, a different variable to repair.
670
+ export { SecretsRingKeyInvalidError } from './secrets-errors';
644
671
  export {
645
672
  defineService,
646
673
  installedServices,
package/src/logger.ts CHANGED
@@ -121,6 +121,28 @@ export function setLogStream(stream: 'stdout' | 'stderr'): void {
121
121
  logStream = stream;
122
122
  }
123
123
 
124
+ /** What `setLogSink` installs: one complete JSON line, and the level it was written at. */
125
+ export type LogSink = (line: string, level: LogLevel) => void;
126
+
127
+ let logSink: LogSink | undefined;
128
+
129
+ /**
130
+ * TEST SEAM. Every line with no explicit `writer` goes to `sink` INSTEAD of the process's streams,
131
+ * until it is cleared with `undefined`. Returns the sink that was installed, so a caller restores
132
+ * rather than clears — the shape `setRowObserver` has, for the same shared-process reason.
133
+ *
134
+ * It exists for two callers. A test preload installs a sink that drops every line, so a green run
135
+ * prints its reporter and nothing else; and a test that asserts on what the PROCESS logger wrote
136
+ * installs one that collects, instead of patching `process.stdout`. A logger given its own
137
+ * `writer` never reaches it, and the level is untouched: this decides where a line goes, never
138
+ * whether it is written.
139
+ */
140
+ export function setLogSink(sink: LogSink | undefined): LogSink | undefined {
141
+ const previous = logSink;
142
+ logSink = sink;
143
+ return previous;
144
+ }
145
+
124
146
  /**
125
147
  * The second half of the same defect, one call deeper than `envLevel`. A module init made safe
126
148
  * that still reached `process.stdout` here would only move the `ReferenceError` from load to the
@@ -135,6 +157,10 @@ export function setLogStream(stream: 'stdout' | 'stderr'): void {
135
157
  * its log stream, and where there is a `process` this writes to the fd as it always did.
136
158
  */
137
159
  function defaultWriter(line: string, level: LogLevel): void {
160
+ if (logSink !== undefined) {
161
+ logSink(line, level);
162
+ return;
163
+ }
138
164
  const toStderr = logStream === 'stderr' || LEVEL_WEIGHT[level] >= LEVEL_WEIGHT.error;
139
165
  if (typeof process === 'undefined') {
140
166
  if (toStderr) console.error(line);
package/src/page-meta.ts CHANGED
@@ -27,6 +27,13 @@ export const CLIENT_BUILD_META = 'x-ultimate-build';
27
27
  */
28
28
  export const APP_UPDATE_MESSAGE = 'AppUpdateAvailable';
29
29
 
30
+ /**
31
+ * How this server turns an action's name into its URL (`defineApi({ http: { pathStyle } })`), for
32
+ * the browser's `actionPath`. Written only for a style other than the default: absent IS
33
+ * `'resource'`, so an app that declares nothing renders the bytes it always did.
34
+ */
35
+ export const CLIENT_PATH_STYLE_META = 'ultimate-path-style';
36
+
30
37
  /** Where the page's one socket dials: `/_x/sync`, or the deployment's absolute `SYNC_URL`. */
31
38
  export const CLIENT_SYNC_META = 'ultimate-sync';
32
39
 
package/src/page.ts CHANGED
@@ -43,6 +43,7 @@ export {
43
43
  CLIENT_NAVIGATION_LOCATION_HEADER,
44
44
  CLIENT_NAVIGATION_SCOPE_HEADER,
45
45
  CLIENT_NAVIGATION_SURFACE_HEADER,
46
+ CLIENT_PATH_STYLE_META,
46
47
  CLIENT_PERSIST_META,
47
48
  CLIENT_SCOPE_HEADER,
48
49
  CLIENT_SCOPE_META,