@ultimat3/core 22.15.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 +17 -2
- package/README.md +59 -1
- package/package.json +2 -2
- package/src/client-paths.ts +26 -6
- package/src/core-error-codes.ts +5 -0
- package/src/error-render.ts +4 -2
- package/src/error-retry.ts +8 -0
- package/src/index.ts +20 -0
- package/src/logger.ts +26 -0
- package/src/page-meta.ts +7 -0
- package/src/page.ts +1 -0
- package/src/process-metrics.ts +206 -0
- 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 +21 -0
- package/src/secrets.ts +21 -11
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-
|
|
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`.
|
|
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": "
|
|
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": "
|
|
43
|
+
"@ultimat3/schema": "23.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=…`. */
|
package/src/core-error-codes.ts
CHANGED
|
@@ -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',
|
package/src/error-render.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/error-retry.ts
CHANGED
|
@@ -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
|
@@ -585,6 +585,9 @@ export type { Direction } from './locale-direction';
|
|
|
585
585
|
export { directionOf, isRtl } from './locale-direction';
|
|
586
586
|
export type { LocalePathSplit } from './locale-path';
|
|
587
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';
|
|
588
591
|
export { isMcpExposed, type McpExposureDeclaration } from './mcp-exposure';
|
|
589
592
|
export type { MeasurementActorFactory } from './measurement-actor';
|
|
590
593
|
export {
|
|
@@ -604,12 +607,15 @@ export {
|
|
|
604
607
|
CLIENT_NAVIGATION_LOCATION_HEADER,
|
|
605
608
|
CLIENT_NAVIGATION_SCOPE_HEADER,
|
|
606
609
|
CLIENT_NAVIGATION_SURFACE_HEADER,
|
|
610
|
+
CLIENT_PATH_STYLE_META,
|
|
607
611
|
CLIENT_PERSIST_META,
|
|
608
612
|
CLIENT_SCOPE_HEADER,
|
|
609
613
|
CLIENT_SCOPE_META,
|
|
610
614
|
CLIENT_SYNC_META,
|
|
611
615
|
CLIENT_SYNC_WORKER_META,
|
|
612
616
|
} from './page-meta';
|
|
617
|
+
export type { ProcessMetricsOptions, ProcessReading } from './process-metrics';
|
|
618
|
+
export { readProcess, resetProcessMetrics, startProcessMetrics } from './process-metrics';
|
|
613
619
|
export { type CappedBody, readWithinLimit } from './read-capped';
|
|
614
620
|
export type { RecordEnvelope, RecordRows } from './record-envelope';
|
|
615
621
|
export { decodeRecordEnvelope, encodeRecordEnvelope, RECORDS_HEADER } from './record-envelope';
|
|
@@ -648,6 +654,20 @@ export {
|
|
|
648
654
|
type OriginVerdict,
|
|
649
655
|
proveSameOrigin,
|
|
650
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';
|
|
651
671
|
export {
|
|
652
672
|
defineService,
|
|
653
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
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Single responsibility: the series every Ultimate PROCESS emits about itself — memory, CPU,
|
|
2
|
+
// event-loop lag, start time and which role it is. `runtime-metrics.ts` names what a role does;
|
|
3
|
+
// this names what the process costs, so "what is growing?" is a query and not a guess. Runs
|
|
4
|
+
// nothing at import: the first `startProcessMetrics()` declares the instruments.
|
|
5
|
+
|
|
6
|
+
import { type Clock, systemClock } from './clock';
|
|
7
|
+
import { finiteCount } from './finite-option';
|
|
8
|
+
import type { Counter, Gauge, Histogram } from './metrics';
|
|
9
|
+
import { counter, gauge, histogram } from './metrics';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One reading of the process, in bytes and seconds — everything `process` answers in microseconds.
|
|
13
|
+
* No object count and no collection count: `bun:jsc`'s `heapStats()` runs a full collection to
|
|
14
|
+
* answer (9 ms on an empty framework process, measured 2026-10-01), which a scrape must not cause.
|
|
15
|
+
*/
|
|
16
|
+
export interface ProcessReading {
|
|
17
|
+
/** Resident set size: what the kernel charges the container for. */
|
|
18
|
+
readonly rss: number;
|
|
19
|
+
/** Bytes of JavaScript heap in use, garbage not yet collected included. */
|
|
20
|
+
readonly heapUsed: number;
|
|
21
|
+
/** Bytes the engine has reserved for the heap, used or not. */
|
|
22
|
+
readonly heapTotal: number;
|
|
23
|
+
/** Bytes held outside the heap on behalf of heap objects: buffers, strings, compiled code. */
|
|
24
|
+
readonly external: number;
|
|
25
|
+
/** User plus system CPU seconds consumed since the process started. */
|
|
26
|
+
readonly cpuSeconds: number;
|
|
27
|
+
/** Seconds since the process started — what places `process_start_time_seconds`. */
|
|
28
|
+
readonly uptimeSeconds: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* How often the event loop is asked how late it is, and the only work this module does unasked.
|
|
33
|
+
* One timer wake a second: measured at 0.12 millicore on top of the 1.8 an idle Bun process
|
|
34
|
+
* already spends (20 s of `process.cpuUsage()`, sampler on against off, 2026-10-01).
|
|
35
|
+
*/
|
|
36
|
+
export const EVENT_LOOP_SAMPLE_MS = 1000;
|
|
37
|
+
|
|
38
|
+
/** A stalled loop is the signal, so the buckets run from a millisecond to a frozen process. */
|
|
39
|
+
export const EVENT_LOOP_LAG_BOUNDS: readonly number[] = Object.freeze([
|
|
40
|
+
0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 10,
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
/** The default reading: `process.memoryUsage()` and `process.cpuUsage()`, ~12 µs together. */
|
|
44
|
+
export function readProcess(): ProcessReading {
|
|
45
|
+
const memory = process.memoryUsage();
|
|
46
|
+
const cpu = process.cpuUsage();
|
|
47
|
+
return {
|
|
48
|
+
rss: memory.rss,
|
|
49
|
+
heapUsed: memory.heapUsed,
|
|
50
|
+
heapTotal: memory.heapTotal,
|
|
51
|
+
external: memory.external,
|
|
52
|
+
cpuSeconds: (cpu.user + cpu.system) / 1_000_000,
|
|
53
|
+
uptimeSeconds: process.uptime(),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ProcessMetricsOptions {
|
|
58
|
+
/** What this process is: a `ROLE`, or `x dev`'s several joined. The `process_info` label. */
|
|
59
|
+
readonly role: string;
|
|
60
|
+
/** Defaults to `readProcess`. Injected by a test. */
|
|
61
|
+
readonly read?: (() => ProcessReading) | undefined;
|
|
62
|
+
readonly clock?: Clock | undefined;
|
|
63
|
+
/** The sampler's timer. Injected by a test; the default is an unref'd `setInterval`. */
|
|
64
|
+
readonly every?: ((tick: () => void, intervalMs: number) => () => void) | undefined;
|
|
65
|
+
readonly sampleMs?: number | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface Instruments {
|
|
69
|
+
readonly cpu: Counter;
|
|
70
|
+
readonly lag: Histogram;
|
|
71
|
+
readonly info: Gauge;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The live source. The observers below are declared ONCE and read through it, because a gauge
|
|
75
|
+
// redeclared with a different `observe` is refused (`X_METRIC_NAME_INVALID`) and a process may
|
|
76
|
+
// start, stop and start this again — every `x dev` reload does.
|
|
77
|
+
let source: (() => ProcessReading) | undefined;
|
|
78
|
+
let startedAtSeconds = 0;
|
|
79
|
+
let instruments: Instruments | undefined;
|
|
80
|
+
// What the counter already holds, kept across a stop and a start so neither loses the CPU spent
|
|
81
|
+
// before the first sample — a boot is where most of it goes — nor counts it twice.
|
|
82
|
+
let cpuCounted = 0;
|
|
83
|
+
// The start that owns `source` and the sampler, compared by identity: two starts may share a
|
|
84
|
+
// reader (the default one), so the reader cannot say which start a stop belongs to.
|
|
85
|
+
let active: { readonly stopTimer: () => void } | undefined;
|
|
86
|
+
// The role `process_info` last said 1 for, so a start under another role sets it back to 0 —
|
|
87
|
+
// one process, one role, even across a stop and a start.
|
|
88
|
+
let infoRole: string | undefined;
|
|
89
|
+
|
|
90
|
+
/** 0 while stopped: a scrape between a stop and a start reads a flat line, never a throw. */
|
|
91
|
+
const observed = (pick: (reading: ProcessReading) => number) => (): number =>
|
|
92
|
+
source === undefined ? 0 : pick(source());
|
|
93
|
+
|
|
94
|
+
function declare(): Instruments {
|
|
95
|
+
if (instruments !== undefined) return instruments;
|
|
96
|
+
gauge('process_resident_memory_bytes', {
|
|
97
|
+
unit: 'By',
|
|
98
|
+
description: 'Resident set size of this process',
|
|
99
|
+
observe: observed((reading) => reading.rss),
|
|
100
|
+
});
|
|
101
|
+
gauge('process_heap_used_bytes', {
|
|
102
|
+
unit: 'By',
|
|
103
|
+
description: 'JavaScript heap in use, garbage not yet collected included',
|
|
104
|
+
observe: observed((reading) => reading.heapUsed),
|
|
105
|
+
});
|
|
106
|
+
gauge('process_heap_total_bytes', {
|
|
107
|
+
unit: 'By',
|
|
108
|
+
description: 'JavaScript heap reserved by the engine',
|
|
109
|
+
observe: observed((reading) => reading.heapTotal),
|
|
110
|
+
});
|
|
111
|
+
gauge('process_external_memory_bytes', {
|
|
112
|
+
unit: 'By',
|
|
113
|
+
description: 'Memory held outside the heap for heap objects: buffers, strings, compiled code',
|
|
114
|
+
observe: observed((reading) => reading.external),
|
|
115
|
+
});
|
|
116
|
+
gauge('process_start_time_seconds', {
|
|
117
|
+
unit: 's',
|
|
118
|
+
description: 'When this process started, in seconds since the Unix epoch',
|
|
119
|
+
observe: () => startedAtSeconds,
|
|
120
|
+
});
|
|
121
|
+
instruments = {
|
|
122
|
+
cpu: counter('process_cpu_seconds_total', {
|
|
123
|
+
unit: 's',
|
|
124
|
+
description: 'User and system CPU time consumed by this process',
|
|
125
|
+
}),
|
|
126
|
+
lag: histogram('process_event_loop_lag_seconds', {
|
|
127
|
+
unit: 's',
|
|
128
|
+
description: `How late the event loop ran a ${String(EVENT_LOOP_SAMPLE_MS)}ms timer, sampled once per interval`,
|
|
129
|
+
bounds: EVENT_LOOP_LAG_BOUNDS,
|
|
130
|
+
}),
|
|
131
|
+
info: gauge('process_info', {
|
|
132
|
+
unit: '1',
|
|
133
|
+
description: 'Always 1; the labels say which role this process runs',
|
|
134
|
+
}),
|
|
135
|
+
};
|
|
136
|
+
return instruments;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const unrefInterval = (tick: () => void, intervalMs: number): (() => void) => {
|
|
140
|
+
const timer = setInterval(tick, intervalMs);
|
|
141
|
+
timer.unref();
|
|
142
|
+
return () => clearInterval(timer);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** Test-only: forget the CPU already counted, beside `resetMetrics()` dropping the counter. */
|
|
146
|
+
export function resetProcessMetrics(): void {
|
|
147
|
+
active?.stopTimer();
|
|
148
|
+
active = undefined;
|
|
149
|
+
source = undefined;
|
|
150
|
+
cpuCounted = 0;
|
|
151
|
+
infoRole = undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Starts the process series and the one sampler behind two of them, and answers the stop. Called
|
|
156
|
+
* by whatever opens the scrape listener, so every role that can be scraped reports itself.
|
|
157
|
+
*
|
|
158
|
+
* CPU is a COUNTER fed by the sampler rather than a gauge read at scrape time: `rate()` over a
|
|
159
|
+
* counter survives a restart, and an observed gauge named `_total` would lie about its type.
|
|
160
|
+
*/
|
|
161
|
+
export function startProcessMetrics(options: ProcessMetricsOptions): () => void {
|
|
162
|
+
const clock = options.clock ?? systemClock;
|
|
163
|
+
const read = options.read ?? readProcess;
|
|
164
|
+
const sampleMs = finiteCount(
|
|
165
|
+
'startProcessMetrics',
|
|
166
|
+
'sampleMs',
|
|
167
|
+
options.sampleMs ?? EVENT_LOOP_SAMPLE_MS,
|
|
168
|
+
1,
|
|
169
|
+
);
|
|
170
|
+
const declared = declare();
|
|
171
|
+
// A start that replaces a live one ends its sampler: two timers would sample twice.
|
|
172
|
+
active?.stopTimer();
|
|
173
|
+
source = read;
|
|
174
|
+
// Uptime subtracted from now: the process started before this module was asked.
|
|
175
|
+
startedAtSeconds = Math.floor(clock.now().getTime() / 1000 - read().uptimeSeconds);
|
|
176
|
+
if (infoRole !== undefined && infoRole !== options.role) {
|
|
177
|
+
declared.info.record(0, { role: infoRole });
|
|
178
|
+
}
|
|
179
|
+
infoRole = options.role;
|
|
180
|
+
declared.info.record(1, { role: options.role });
|
|
181
|
+
|
|
182
|
+
const countCpu = (): void => {
|
|
183
|
+
const cpu = read().cpuSeconds;
|
|
184
|
+
// Monotonic by construction, and guarded anyway: a counter refuses a negative delta.
|
|
185
|
+
if (cpu > cpuCounted) declared.cpu.add(cpu - cpuCounted);
|
|
186
|
+
cpuCounted = Math.max(cpu, cpuCounted);
|
|
187
|
+
};
|
|
188
|
+
countCpu();
|
|
189
|
+
let due = clock.monotonic() + sampleMs;
|
|
190
|
+
const stopTimer = (options.every ?? unrefInterval)(() => {
|
|
191
|
+
const now = clock.monotonic();
|
|
192
|
+
// Late by this much; a timer that fires early reports no lag rather than a negative one.
|
|
193
|
+
declared.lag.record(Math.max(0, now - due) / 1000);
|
|
194
|
+
due = now + sampleMs;
|
|
195
|
+
countCpu();
|
|
196
|
+
}, sampleMs);
|
|
197
|
+
|
|
198
|
+
const owner = { stopTimer };
|
|
199
|
+
active = owner;
|
|
200
|
+
return () => {
|
|
201
|
+
stopTimer();
|
|
202
|
+
if (active !== owner) return;
|
|
203
|
+
active = undefined;
|
|
204
|
+
source = undefined;
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Single responsibility: the three X_SEAL_* refusals and the errors that carry them. Three codes
|
|
2
|
+
// rather than one "it will not open" because each names a different fact — no key at all, a key
|
|
3
|
+
// this process was not given, a value that did not authenticate — and a different thing to do
|
|
4
|
+
// next. No error here carries a key, a plaintext or the sealed string itself. Titles live in
|
|
5
|
+
// `core-error-codes.ts` and the retry class in `error-retry.ts`, so this module runs nothing at
|
|
6
|
+
// import and stays out of every bundle that does not seal.
|
|
7
|
+
|
|
8
|
+
import { renderCauseValue } from './error-render';
|
|
9
|
+
import { UltimateError } from './errors';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* No key in the environment and none on disk. Never a pass-through: storing the plaintext because
|
|
13
|
+
* the key was missing is the failure a sealed column exists to prevent, and it would look healthy.
|
|
14
|
+
*/
|
|
15
|
+
export class SealKeyMissingError extends UltimateError {
|
|
16
|
+
constructor(input: { envVar: string; keyPath: string }) {
|
|
17
|
+
super({
|
|
18
|
+
code: 'X_SEAL_KEY_MISSING',
|
|
19
|
+
cause: `${input.envVar} is unset and ${input.keyPath} does not exist, so there is no master key to seal or open a value with`,
|
|
20
|
+
fix: 'x secrets init # or, where the key already exists: export ULTIMATE_SECRETS_KEY="$(cat .secrets.key)"',
|
|
21
|
+
meta: { keyPath: input.keyPath },
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The value names a key id the ring does not hold — a rotation whose retired key was dropped
|
|
28
|
+
* before the re-seal finished, or a value copied from another environment. `keyId` is matched
|
|
29
|
+
* against 16 hex characters before it gets here, so it is safe to print; `declared` is computed.
|
|
30
|
+
*/
|
|
31
|
+
export class SealKeyUnknownError extends UltimateError {
|
|
32
|
+
constructor(input: { keyId: string; declared: readonly string[] }) {
|
|
33
|
+
super({
|
|
34
|
+
code: 'X_SEAL_KEY_UNKNOWN',
|
|
35
|
+
cause: `the sealed value names master key ${input.keyId}, which is not among the declared keys: ${input.declared.join(', ')} (current first)`,
|
|
36
|
+
// The variable is written out, not interpolated: `x errors explain` prints this line with no
|
|
37
|
+
// instance behind it. `seal.test.ts` holds it equal to `SECRETS_RETIRED_KEYS_ENV`.
|
|
38
|
+
fix: `x secrets edit # put the retired key back in ULTIMATE_SECRETS_RETIRED_KEYS (64 hex characters, comma-separated) and keep it there until the re-seal backfill() has finished`,
|
|
39
|
+
meta: { keyId: input.keyId, declared: [...input.declared] },
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type SealInvalidReason = 'malformed' | 'unauthenticated';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Either the string is not a sealed value at all, or the tag rejected it. AEAD cannot tell a wrong
|
|
48
|
+
* purpose from changed bytes — both are "the tag did not verify" — so the cause names both rather
|
|
49
|
+
* than guessing one and sending the reader after the wrong thing.
|
|
50
|
+
*/
|
|
51
|
+
export class SealInvalidError extends UltimateError {
|
|
52
|
+
constructor(
|
|
53
|
+
input:
|
|
54
|
+
| { reason: 'malformed'; purpose?: string | undefined; length: number }
|
|
55
|
+
| { reason: 'unauthenticated'; purpose: string; keyId: string },
|
|
56
|
+
) {
|
|
57
|
+
const purpose =
|
|
58
|
+
input.purpose === undefined ? '' : ` for purpose ${renderCauseValue(input.purpose)}`;
|
|
59
|
+
super({
|
|
60
|
+
code: 'X_SEAL_INVALID',
|
|
61
|
+
cause:
|
|
62
|
+
input.reason === 'malformed'
|
|
63
|
+
? `a ${input.length}-character string read${purpose} is not a sealed value (x1.<keyId>.<iv>.<ciphertext>) — it was never sealed, or it was truncated`
|
|
64
|
+
: `the value did not authenticate under master key ${input.keyId}${purpose}: it was sealed for a different purpose, or its bytes changed after it was sealed — AES-GCM cannot tell the two apart`,
|
|
65
|
+
// ONE literal covering both conditions, so `x errors explain` prints it without an instance.
|
|
66
|
+
// A string that was never sealed is almost always a column sealed AFTER rows were written,
|
|
67
|
+
// and no key fixes that: there is no reading of an unsealed value, so the rows are migrated.
|
|
68
|
+
fix: 'x secrets show --json # a value that failed its tag: confirms the key id in force, and the value must be re-entered. A value that was never sealed — a column sealed after rows were written — is migrated: add a NEW .sealed() column, copy into it with a backfill(), drop the old one',
|
|
69
|
+
meta: {
|
|
70
|
+
reason: input.reason,
|
|
71
|
+
...(input.purpose === undefined ? {} : { purpose: input.purpose }),
|
|
72
|
+
...(input.reason === 'unauthenticated' ? { keyId: input.keyId } : {}),
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/seal-keys.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Single responsibility: the key ring `seal()` and `open()` work under. The CURRENT key is the one
|
|
2
|
+
// `x secrets` already manages — `ULTIMATE_SECRETS_KEY` first, `.secrets.key` second, through
|
|
3
|
+
// `findMasterKey`, no second variable. RETIRED keys are one more env var, which `x secrets rotate`
|
|
4
|
+
// writes into the committed file and `installSecrets()` carries into the process like any secret.
|
|
5
|
+
|
|
6
|
+
import { SealKeyMissingError } from './seal-errors';
|
|
7
|
+
import { importKey, masterKeyId, parseMasterKey } from './secrets';
|
|
8
|
+
import { findMasterKey, masterKeyPath, SECRETS_KEY_ENV } from './secrets-store';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Master keys that no longer seal but still open: 64 hex characters each, separated by commas or
|
|
12
|
+
* whitespace. A secret like any other — it lives in `secrets.enc.json` under this name, sealed by
|
|
13
|
+
* the current key, so a deploy is handed ONE key and the ring travels in the repository.
|
|
14
|
+
*/
|
|
15
|
+
export const SECRETS_RETIRED_KEYS_ENV = 'ULTIMATE_SECRETS_RETIRED_KEYS';
|
|
16
|
+
|
|
17
|
+
type EnvRecord = Record<string, string | undefined>;
|
|
18
|
+
|
|
19
|
+
/** Where the keys are read from. The same two fields, with the same defaults, as `installSecrets`. */
|
|
20
|
+
export interface SealKeySource {
|
|
21
|
+
/** The app root holding `.secrets.key`. Defaults to the process's working directory. */
|
|
22
|
+
readonly root?: string | undefined;
|
|
23
|
+
/** Read for the current key and the retired ring. Defaults to `process.env`. */
|
|
24
|
+
readonly env?: EnvRecord | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SealKey {
|
|
28
|
+
/** `masterKeyId`'s — the id a sealed string carries. */
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly aes: CryptoKey;
|
|
31
|
+
/** HMAC key the deterministic IV is derived under; never the AES key itself. */
|
|
32
|
+
readonly mac: CryptoKey;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SealKeyRing {
|
|
36
|
+
readonly current: SealKey;
|
|
37
|
+
/** Current first, then retired in declaration order. */
|
|
38
|
+
readonly keys: readonly SealKey[];
|
|
39
|
+
/** The same keys by the id a sealed string names — built once, read on every `open()`. */
|
|
40
|
+
readonly byId: ReadonlyMap<string, SealKey>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const encoder = new TextEncoder();
|
|
44
|
+
const MAC_DOMAIN = encoder.encode('ultimate.seal.iv.v1');
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The MAC key is DERIVED from the master key under a fixed label rather than being the master key:
|
|
48
|
+
* one key used for both AES-GCM and HMAC has no known break, and no proof either.
|
|
49
|
+
*/
|
|
50
|
+
async function sealKey(hex: string, at: string, variable?: string): Promise<SealKey> {
|
|
51
|
+
const raw = parseMasterKey(hex, at, variable);
|
|
52
|
+
const hmac = { name: 'HMAC', hash: 'SHA-256' } as const;
|
|
53
|
+
const master = await crypto.subtle.importKey('raw', raw, hmac, false, ['sign']);
|
|
54
|
+
const derived = await crypto.subtle.sign('HMAC', master, MAC_DOMAIN);
|
|
55
|
+
return {
|
|
56
|
+
id: await masterKeyId(raw),
|
|
57
|
+
aes: await importKey(raw),
|
|
58
|
+
mac: await crypto.subtle.importKey('raw', derived, hmac, false, ['sign']),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The retired ring as written: hex entries, in order, blanks dropped. Nothing is validated here. */
|
|
63
|
+
export function splitRetiredKeys(raw: string | undefined): readonly string[] {
|
|
64
|
+
return (raw ?? '').split(/[\s,]+/).filter((entry) => entry.length > 0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function buildRing(
|
|
68
|
+
current: string,
|
|
69
|
+
currentAt: string,
|
|
70
|
+
retired: string,
|
|
71
|
+
): Promise<SealKeyRing> {
|
|
72
|
+
const first = await sealKey(current, currentAt);
|
|
73
|
+
const keys = [first];
|
|
74
|
+
for (const [index, hex] of splitRetiredKeys(retired).entries()) {
|
|
75
|
+
// A malformed entry is refused by position, never skipped: a ring that silently lost a key
|
|
76
|
+
// surfaces later as X_SEAL_KEY_UNKNOWN on a row, far from the edit that caused it.
|
|
77
|
+
const next = await sealKey(
|
|
78
|
+
hex,
|
|
79
|
+
`${SECRETS_RETIRED_KEYS_ENV} (entry ${index + 1})`,
|
|
80
|
+
SECRETS_RETIRED_KEYS_ENV,
|
|
81
|
+
);
|
|
82
|
+
if (!keys.some((key) => key.id === next.id)) keys.push(next);
|
|
83
|
+
}
|
|
84
|
+
return { current: first, keys, byId: new Map(keys.map((key) => [key.id, key])) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The last ring, kept by the exact strings it was built from. Importing a key is three WebCrypto
|
|
88
|
+
// calls and a list read opens hundreds of values, so the ring is built once — but it is keyed by
|
|
89
|
+
// its SOURCE, never by time or by process, so a rotated key file or a changed variable is a new
|
|
90
|
+
// ring on the very next call. Only a resolved ring is kept; a refusal is recomputed.
|
|
91
|
+
let memo: { readonly source: string; readonly ring: Promise<SealKeyRing> } | undefined;
|
|
92
|
+
|
|
93
|
+
/** The ring in force now, or `X_SEAL_KEY_MISSING`. A malformed key is `X_SECRETS_KEY_INVALID`. */
|
|
94
|
+
export function resolveSealKeys(source: SealKeySource = {}): Promise<SealKeyRing> {
|
|
95
|
+
const root = source.root ?? process.cwd();
|
|
96
|
+
const env = source.env ?? (process.env as EnvRecord);
|
|
97
|
+
const found = findMasterKey(root, env);
|
|
98
|
+
if (found === undefined) {
|
|
99
|
+
return Promise.reject(
|
|
100
|
+
new SealKeyMissingError({ envVar: SECRETS_KEY_ENV, keyPath: masterKeyPath(root) }),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const retired = env[SECRETS_RETIRED_KEYS_ENV] ?? '';
|
|
104
|
+
const key = `${found.hex}\n${retired}`;
|
|
105
|
+
if (memo?.source === key) return memo.ring;
|
|
106
|
+
const ring = buildRing(found.hex, found.at, retired);
|
|
107
|
+
const entry = { source: key, ring };
|
|
108
|
+
memo = entry;
|
|
109
|
+
ring.catch(() => {
|
|
110
|
+
if (memo === entry) memo = undefined;
|
|
111
|
+
});
|
|
112
|
+
return ring;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The ids a sealed value may name right now. Safe to print: an id is not a key. */
|
|
116
|
+
export async function sealKeyIds(
|
|
117
|
+
source: SealKeySource = {},
|
|
118
|
+
): Promise<{ readonly current: string; readonly retired: readonly string[] }> {
|
|
119
|
+
const ring = await resolveSealKeys(source);
|
|
120
|
+
return { current: ring.current.id, retired: ring.keys.slice(1).map((key) => key.id) };
|
|
121
|
+
}
|
package/src/seal.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// Single responsibility: seal ONE value under the app's master key and open it back. The wire form
|
|
2
|
+
// is one string, `x1.<keyId>.<iv>.<ciphertext+tag>`, AES-256-GCM through WebCrypto with a REQUIRED
|
|
3
|
+
// purpose bound in as additional authenticated data. `secrets.ts` is the env envelope — a file of
|
|
4
|
+
// many values; this is the per-value form a column or a stored session holds.
|
|
5
|
+
|
|
6
|
+
import { assert } from './assert';
|
|
7
|
+
import { UltimateError } from './errors';
|
|
8
|
+
import { SealInvalidError, SealKeyUnknownError } from './seal-errors';
|
|
9
|
+
import type { SealKey, SealKeyRing, SealKeySource } from './seal-keys';
|
|
10
|
+
import { resolveSealKeys } from './seal-keys';
|
|
11
|
+
import {
|
|
12
|
+
decodeBase64,
|
|
13
|
+
encodeBase64,
|
|
14
|
+
SECRETS_ALG,
|
|
15
|
+
SECRETS_IV_BYTES,
|
|
16
|
+
SECRETS_TAG_BYTES,
|
|
17
|
+
} from './secrets';
|
|
18
|
+
|
|
19
|
+
/** The format tag. A string without it was never sealed by this function. */
|
|
20
|
+
export const SEAL_VERSION = 'x1';
|
|
21
|
+
|
|
22
|
+
/** What a value was sealed FOR — `entity:connections.password`, `scrape-session`. Never optional. */
|
|
23
|
+
export interface SealPurposeOptions extends SealKeySource {
|
|
24
|
+
/** Bound into the tag: a value sealed for one purpose does not open as another. */
|
|
25
|
+
readonly purpose: string;
|
|
26
|
+
/**
|
|
27
|
+
* A ring already resolved — `await resolveSealKeys()` — for a caller sealing or opening MANY
|
|
28
|
+
* values in one operation. Without it every call finds the master key again (an environment
|
|
29
|
+
* read, or a file read in a checkout); with it a 500-row page asks once. Never held past the
|
|
30
|
+
* operation: a ring kept across requests would outlive a rotation.
|
|
31
|
+
*/
|
|
32
|
+
readonly keys?: SealKeyRing | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ringFor = (options: SealPurposeOptions): Promise<SealKeyRing> | SealKeyRing =>
|
|
36
|
+
options.keys ?? resolveSealKeys(options);
|
|
37
|
+
|
|
38
|
+
export interface SealOptions extends SealPurposeOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Derive the IV from the purpose and the plaintext instead of drawing it, so equal values seal
|
|
41
|
+
* to equal strings and a column can be looked up by equality. It REVEALS EQUALITY to anyone who
|
|
42
|
+
* can read the stored strings: two rows holding the same value are visibly the same. Never use
|
|
43
|
+
* it for a low-entropy value (a boolean, a status, a PIN, a date of birth) — the handful of
|
|
44
|
+
* possible ciphertexts is a lookup table. Under a rotation the same value seals differently per
|
|
45
|
+
* key; `sealAll` returns every candidate.
|
|
46
|
+
*/
|
|
47
|
+
readonly deterministic?: boolean | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 12 bytes are exactly 16 unpadded base64url characters; a 16-byte tag alone is 22.
|
|
51
|
+
const SEALED = /^x1\.([0-9a-f]{16})\.([A-Za-z0-9_-]{16})\.([A-Za-z0-9_-]{22,})$/;
|
|
52
|
+
|
|
53
|
+
const encoder = new TextEncoder();
|
|
54
|
+
|
|
55
|
+
const toUrl = (bytes: Uint8Array<ArrayBuffer>): string =>
|
|
56
|
+
encodeBase64(bytes).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
|
57
|
+
|
|
58
|
+
function fromUrl(text: string): Uint8Array<ArrayBuffer> {
|
|
59
|
+
const standard = text.replaceAll('-', '+').replaceAll('_', '/');
|
|
60
|
+
return decodeBase64(standard.padEnd(standard.length + ((4 - (standard.length % 4)) % 4), '='));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A fresh copy either way, so a caller's buffer is never the one WebCrypto is handed. */
|
|
64
|
+
const bytesOf = (plaintext: string | Uint8Array): Uint8Array<ArrayBuffer> =>
|
|
65
|
+
typeof plaintext === 'string' ? encoder.encode(plaintext) : new Uint8Array(plaintext);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The bytes the tag covers besides the ciphertext: the format, the algorithm, the key id and the
|
|
69
|
+
* purpose. A string moved to another column, or relabelled with another key id, fails the tag.
|
|
70
|
+
*/
|
|
71
|
+
const additionalData = (keyId: string, purpose: string): Uint8Array<ArrayBuffer> =>
|
|
72
|
+
encoder.encode(
|
|
73
|
+
`ultimate.seal|${SEAL_VERSION}|alg=${SECRETS_ALG}|kid=${keyId}|purpose=${purpose}`,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
function requirePurpose(purpose: unknown): asserts purpose is string {
|
|
77
|
+
assert(
|
|
78
|
+
typeof purpose === 'string' && purpose.length > 0,
|
|
79
|
+
'seal() and open() were called without a purpose, and the purpose is what stops a value sealed for one column opening as another',
|
|
80
|
+
"pass purpose: '<what the value is for>' — seal(value, { purpose: 'entity:<table>.<column>' })",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The deterministic IV: HMAC-SHA-256 over the length-prefixed purpose and the plaintext, cut to
|
|
86
|
+
* GCM's 96 bits. Length-prefixed so (`a`, `bc`) and (`ab`, `c`) are different inputs. A repeated
|
|
87
|
+
* (key, IV) pair then means a repeated (purpose, plaintext) — the same ciphertext, which is the
|
|
88
|
+
* mode's stated leak and not a nonce reuse across two messages.
|
|
89
|
+
*/
|
|
90
|
+
async function derivedIv(
|
|
91
|
+
key: SealKey,
|
|
92
|
+
purpose: string,
|
|
93
|
+
plaintext: Uint8Array<ArrayBuffer>,
|
|
94
|
+
): Promise<Uint8Array<ArrayBuffer>> {
|
|
95
|
+
const label = encoder.encode(purpose);
|
|
96
|
+
const material = new Uint8Array(4 + label.length + plaintext.length);
|
|
97
|
+
new DataView(material.buffer).setUint32(0, label.length);
|
|
98
|
+
material.set(label, 4);
|
|
99
|
+
material.set(plaintext, 4 + label.length);
|
|
100
|
+
const mac = await crypto.subtle.sign('HMAC', key.mac, material);
|
|
101
|
+
return new Uint8Array(mac).slice(0, SECRETS_IV_BYTES);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function sealUnder(
|
|
105
|
+
key: SealKey,
|
|
106
|
+
plaintext: Uint8Array<ArrayBuffer>,
|
|
107
|
+
purpose: string,
|
|
108
|
+
deterministic: boolean,
|
|
109
|
+
): Promise<string> {
|
|
110
|
+
const iv = deterministic
|
|
111
|
+
? await derivedIv(key, purpose, plaintext)
|
|
112
|
+
: crypto.getRandomValues(new Uint8Array(SECRETS_IV_BYTES));
|
|
113
|
+
const sealed = await crypto.subtle.encrypt(
|
|
114
|
+
{
|
|
115
|
+
name: 'AES-GCM',
|
|
116
|
+
iv,
|
|
117
|
+
additionalData: additionalData(key.id, purpose),
|
|
118
|
+
tagLength: SECRETS_TAG_BYTES * 8,
|
|
119
|
+
},
|
|
120
|
+
key.aes,
|
|
121
|
+
plaintext,
|
|
122
|
+
);
|
|
123
|
+
return `${SEAL_VERSION}.${key.id}.${toUrl(iv)}.${toUrl(new Uint8Array(sealed))}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Seal one value under the CURRENT master key — the one `x secrets` manages, found where
|
|
128
|
+
* `installSecrets()` finds it.
|
|
129
|
+
*
|
|
130
|
+
* ```ts
|
|
131
|
+
* const stored = await seal(password, { purpose: 'entity:connections.password' });
|
|
132
|
+
* const password = await openText(stored, { purpose: 'entity:connections.password' });
|
|
133
|
+
* ```
|
|
134
|
+
*
|
|
135
|
+
* `X_SEAL_KEY_MISSING` when there is no key: the plaintext is never returned in its place.
|
|
136
|
+
*/
|
|
137
|
+
export async function seal(plaintext: string | Uint8Array, options: SealOptions): Promise<string> {
|
|
138
|
+
requirePurpose(options.purpose);
|
|
139
|
+
const ring = await ringFor(options);
|
|
140
|
+
return sealUnder(
|
|
141
|
+
ring.current,
|
|
142
|
+
bytesOf(plaintext),
|
|
143
|
+
options.purpose,
|
|
144
|
+
options.deterministic === true,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The DETERMINISTIC seal of one value under every declared key, current first. During a rotation
|
|
150
|
+
* a row written before it holds the old key's string and a row written after holds the new one's,
|
|
151
|
+
* so an equality lookup has to match either: `where column in (…sealAll(value))`. Outside a
|
|
152
|
+
* rotation this is one string. Uniqueness cannot be held across keys — one value has two forms.
|
|
153
|
+
*/
|
|
154
|
+
export async function sealAll(
|
|
155
|
+
plaintext: string | Uint8Array,
|
|
156
|
+
options: SealPurposeOptions,
|
|
157
|
+
): Promise<readonly string[]> {
|
|
158
|
+
requirePurpose(options.purpose);
|
|
159
|
+
const ring = await ringFor(options);
|
|
160
|
+
const bytes = bytesOf(plaintext);
|
|
161
|
+
return Promise.all(ring.keys.map((key) => sealUnder(key, bytes, options.purpose, true)));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
interface SealedParts {
|
|
165
|
+
readonly keyId: string;
|
|
166
|
+
readonly iv: string;
|
|
167
|
+
readonly body: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function partsOf(value: unknown): SealedParts | undefined {
|
|
171
|
+
const match = typeof value === 'string' ? SEALED.exec(value) : null;
|
|
172
|
+
const [keyId, iv, body] = [match?.[1], match?.[2], match?.[3]];
|
|
173
|
+
if (keyId === undefined || iv === undefined || body === undefined) return undefined;
|
|
174
|
+
// No whole number of bytes encodes to 4n+1 base64 characters: a truncated write, not a value.
|
|
175
|
+
return body.length % 4 === 1 ? undefined : { keyId, iv, body };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const malformed = (sealed: unknown, purpose?: string): SealInvalidError =>
|
|
179
|
+
new SealInvalidError({
|
|
180
|
+
reason: 'malformed',
|
|
181
|
+
purpose,
|
|
182
|
+
length: typeof sealed === 'string' ? sealed.length : 0,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Whether a value has the SHAPE of a sealed string. Not a claim that it opens: it exists so a
|
|
187
|
+
* caller migrating a plaintext column can tell an unsealed legacy row from a sealed one without
|
|
188
|
+
* attempting a decryption — `open()` itself never falls back to the raw string.
|
|
189
|
+
*/
|
|
190
|
+
export function isSealed(value: unknown): value is string {
|
|
191
|
+
return partsOf(value) !== undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The id of the key a sealed string names — what a re-seal `backfill()` compares to the current. */
|
|
195
|
+
export function sealedKeyId(sealed: string): string {
|
|
196
|
+
const parts = partsOf(sealed);
|
|
197
|
+
if (parts === undefined) throw malformed(sealed);
|
|
198
|
+
return parts.keyId;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Open a sealed string to its bytes. Three refusals, in the order the facts become knowable: the
|
|
203
|
+
* string is not a sealed value (`X_SEAL_INVALID`), it names a key this process does not declare
|
|
204
|
+
* (`X_SEAL_KEY_UNKNOWN` — read off the string before any decryption is attempted, so a rotation
|
|
205
|
+
* is never reported as tampering), or the tag rejected it under the purpose given
|
|
206
|
+
* (`X_SEAL_INVALID`).
|
|
207
|
+
*/
|
|
208
|
+
export async function open(
|
|
209
|
+
sealed: string,
|
|
210
|
+
options: SealPurposeOptions,
|
|
211
|
+
): Promise<Uint8Array<ArrayBuffer>> {
|
|
212
|
+
requirePurpose(options.purpose);
|
|
213
|
+
const parts = partsOf(sealed);
|
|
214
|
+
if (parts === undefined) throw malformed(sealed, options.purpose);
|
|
215
|
+
const { keyId, iv, body } = parts;
|
|
216
|
+
const ring = await ringFor(options);
|
|
217
|
+
// A lookup by the id the string names. An id is public — it is in every sealed value — and a
|
|
218
|
+
// `Map` says so: nothing here is compared byte by byte against a secret.
|
|
219
|
+
const key = ring.byId.get(keyId);
|
|
220
|
+
if (key === undefined) {
|
|
221
|
+
throw new SealKeyUnknownError({
|
|
222
|
+
keyId,
|
|
223
|
+
declared: ring.keys.map((one) => one.id),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
228
|
+
{
|
|
229
|
+
name: 'AES-GCM',
|
|
230
|
+
iv: fromUrl(iv),
|
|
231
|
+
additionalData: additionalData(keyId, options.purpose),
|
|
232
|
+
tagLength: SECRETS_TAG_BYTES * 8,
|
|
233
|
+
},
|
|
234
|
+
key.aes,
|
|
235
|
+
fromUrl(body),
|
|
236
|
+
);
|
|
237
|
+
return new Uint8Array(plaintext);
|
|
238
|
+
} catch {
|
|
239
|
+
// No `sourceError`, for `openSecrets`' reason: WebCrypto's OperationError says nothing more.
|
|
240
|
+
throw new SealInvalidError({ reason: 'unauthenticated', purpose: options.purpose, keyId });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* `open()` for a value that was sealed from a string. Bytes that are not UTF-8 are refused rather
|
|
246
|
+
* than decoded with replacement characters: a caller would store the repaired text back.
|
|
247
|
+
*/
|
|
248
|
+
export async function openText(sealed: string, options: SealPurposeOptions): Promise<string> {
|
|
249
|
+
const bytes = await open(sealed, options);
|
|
250
|
+
try {
|
|
251
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
252
|
+
} catch {
|
|
253
|
+
throw new UltimateError({
|
|
254
|
+
code: 'X_INVARIANT',
|
|
255
|
+
cause: `the value opened to ${bytes.length} byte(s) that are not UTF-8 text, so it was sealed from bytes and openText() cannot return it`,
|
|
256
|
+
fix: 'call open() instead of openText() for a value sealed from a Uint8Array',
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
package/src/secrets-errors.ts
CHANGED
|
@@ -84,6 +84,27 @@ export class SecretsKeyInvalidError extends UltimateError {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* The same condition under the same code, for a key that is NOT the current one: a malformed entry
|
|
89
|
+
* of a ring variable (`ULTIMATE_SECRETS_RETIRED_KEYS`). Its own class because its repair is an
|
|
90
|
+
* edit of that variable — the current key is fine, and re-exporting it changes nothing — and
|
|
91
|
+
* because a class has one literal `fix:`, which is what `x errors explain` prints without an
|
|
92
|
+
* instance. A variable name that is not one never reaches the line.
|
|
93
|
+
*/
|
|
94
|
+
export class SecretsRingKeyInvalidError extends UltimateError {
|
|
95
|
+
constructor(input: { at: string; found: number; expected: number; variable: string }) {
|
|
96
|
+
const variable = ENV_VAR_NAME.test(input.variable)
|
|
97
|
+
? input.variable
|
|
98
|
+
: 'the variable the cause names';
|
|
99
|
+
super({
|
|
100
|
+
code: 'X_SECRETS_KEY_INVALID',
|
|
101
|
+
cause: `the master key in ${input.at} is ${input.found} character(s); an AES-256 key is ${input.expected} lowercase hex characters`,
|
|
102
|
+
fix: `x secrets edit # ${variable} holds ${input.expected}-character lowercase hex keys separated by commas: correct or remove the entry the cause names`,
|
|
103
|
+
meta: { at: input.at },
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
87
108
|
/**
|
|
88
109
|
* A well-formed key that is not the one this file was sealed with. Distinguishable from tampering
|
|
89
110
|
* only because the envelope carries a key id — a domain-separated SHA-256 of the key, which is
|
package/src/secrets.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
SecretsKeyInvalidError,
|
|
9
9
|
SecretsKeyMismatchError,
|
|
10
10
|
SecretsPlaintextInvalidError,
|
|
11
|
+
SecretsRingKeyInvalidError,
|
|
11
12
|
SecretsTamperedError,
|
|
12
13
|
} from './secrets-errors';
|
|
13
14
|
|
|
@@ -65,14 +66,15 @@ function decodeHex(hex: string): Uint8Array<ArrayBuffer> {
|
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
// `btoa`/`atob` rather than node:buffer — both are standard globals, and a chunk loop avoids the
|
|
68
|
-
// stack blow-up `String.fromCharCode(...bytes)` hits on a spread of any size.
|
|
69
|
-
|
|
69
|
+
// stack blow-up `String.fromCharCode(...bytes)` hits on a spread of any size. Exported for
|
|
70
|
+
// `seal.ts`, which writes the same bytes in the URL-safe alphabet — one codec, never a second.
|
|
71
|
+
export function encodeBase64(bytes: Uint8Array<ArrayBuffer>): string {
|
|
70
72
|
let binary = '';
|
|
71
73
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
72
74
|
return btoa(binary);
|
|
73
75
|
}
|
|
74
76
|
|
|
75
|
-
function decodeBase64(text: string): Uint8Array<ArrayBuffer> {
|
|
77
|
+
export function decodeBase64(text: string): Uint8Array<ArrayBuffer> {
|
|
76
78
|
const binary = atob(text);
|
|
77
79
|
const bytes = new Uint8Array(binary.length);
|
|
78
80
|
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
@@ -84,15 +86,22 @@ export function generateMasterKey(): string {
|
|
|
84
86
|
return encodeHex(crypto.getRandomValues(new Uint8Array(SECRETS_KEY_BYTES)));
|
|
85
87
|
}
|
|
86
88
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
+
/**
|
|
90
|
+
* 64 lowercase hex characters, or `X_SECRETS_KEY_INVALID`. Whitespace is trimmed, never repaired.
|
|
91
|
+
* `variable` names the variable a key OTHER than the current one was read from, so the refusal's
|
|
92
|
+
* fix edits that variable instead of re-exporting a current key that is fine.
|
|
93
|
+
*/
|
|
94
|
+
export function parseMasterKey(
|
|
95
|
+
raw: string,
|
|
96
|
+
at: string,
|
|
97
|
+
variable?: string,
|
|
98
|
+
): Uint8Array<ArrayBuffer> {
|
|
89
99
|
const hex = raw.trim();
|
|
90
100
|
if (hex.length !== SECRETS_KEY_HEX_LENGTH || !HEX_KEY.test(hex)) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
});
|
|
101
|
+
const shape = { at, found: hex.length, expected: SECRETS_KEY_HEX_LENGTH };
|
|
102
|
+
throw variable === undefined
|
|
103
|
+
? new SecretsKeyInvalidError(shape)
|
|
104
|
+
: new SecretsRingKeyInvalidError({ ...shape, variable });
|
|
96
105
|
}
|
|
97
106
|
return decodeHex(hex);
|
|
98
107
|
}
|
|
@@ -119,7 +128,8 @@ export async function masterKeyId(key: Uint8Array<ArrayBuffer>): Promise<string>
|
|
|
119
128
|
const additionalData = (header: Omit<SecretsEnvelope, 'iv' | 'ct'>): Uint8Array<ArrayBuffer> =>
|
|
120
129
|
encoder.encode(`ultimate.secrets|v=${header.v}|alg=${header.alg}|kid=${header.kid}`);
|
|
121
130
|
|
|
122
|
-
|
|
131
|
+
/** The AES-256-GCM key object, non-extractable. Shared with `seal-keys.ts`: one import, one usage set. */
|
|
132
|
+
export const importKey = (key: Uint8Array<ArrayBuffer>): Promise<CryptoKey> =>
|
|
123
133
|
crypto.subtle.importKey('raw', key, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
|
|
124
134
|
|
|
125
135
|
/**
|