@ultimat3/core 22.15.0 → 24.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CLAUDE.md +23 -2
  2. package/README.md +93 -4
  3. package/package.json +2 -2
  4. package/src/client-paths.ts +26 -6
  5. package/src/config-defaults.ts +46 -0
  6. package/src/config-merge.ts +8 -0
  7. package/src/config-shape.ts +112 -0
  8. package/src/config-site.ts +14 -3
  9. package/src/config.ts +74 -83
  10. package/src/context.ts +13 -1
  11. package/src/cookie.ts +35 -0
  12. package/src/core-error-codes.ts +5 -0
  13. package/src/cursor.ts +4 -1
  14. package/src/decimal-order.ts +5 -4
  15. package/src/dev-secrets.ts +1 -1
  16. package/src/error-render.ts +4 -2
  17. package/src/error-reporter-sentry.ts +7 -3
  18. package/src/error-retry.ts +8 -0
  19. package/src/flight-gate.ts +16 -4
  20. package/src/fnv1a.ts +19 -0
  21. package/src/health-disclosure.ts +43 -0
  22. package/src/host-rules.ts +28 -1
  23. package/src/html-escape.ts +24 -0
  24. package/src/image/errors.ts +3 -1
  25. package/src/image/png-pixels.ts +29 -6
  26. package/src/image/probe.ts +7 -2
  27. package/src/image/raster.ts +3 -1
  28. package/src/index.ts +32 -0
  29. package/src/logger.ts +103 -10
  30. package/src/nearest-name.ts +11 -2
  31. package/src/otlp-metric-exporter.ts +1 -1
  32. package/src/otlp-span-exporter.ts +1 -1
  33. package/src/otlp.ts +44 -13
  34. package/src/page-meta.ts +7 -0
  35. package/src/page.ts +1 -0
  36. package/src/pg-executor.ts +15 -0
  37. package/src/process-metrics.ts +206 -0
  38. package/src/public-cause.ts +37 -0
  39. package/src/registrar.ts +21 -4
  40. package/src/retry.ts +15 -2
  41. package/src/route-rank.ts +36 -0
  42. package/src/same-origin.ts +1 -1
  43. package/src/sampler.ts +6 -2
  44. package/src/seal-errors.ts +76 -0
  45. package/src/seal-keys.ts +121 -0
  46. package/src/seal.ts +259 -0
  47. package/src/secrets-errors.ts +33 -1
  48. package/src/secrets.ts +21 -11
  49. package/src/source-mask.ts +14 -8
  50. package/src/store-mode.ts +23 -0
@@ -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,37 @@
1
+ // Single responsibility: the ONE answer to "may a caller read this 5xx code's `cause`?". Moved down
2
+ // from `@ultimat3/http`'s `problem-meta.ts` because three renderers send an error off the box — the
3
+ // HTTP problem document, the MCP error data and the agent `tool_result` — and only the first asked.
4
+ // Tier 0, so `@ultimat3/mcp` and `@ultimat3/ai` (tier 4) can ask it without importing each other.
5
+
6
+ /**
7
+ * The 5xx codes whose `cause` a caller may read. Every other 5xx document carries the code and the
8
+ * request id and a fixed sentence: `X_DB_STATEMENT_FAILED` has a status row, so the old "blank only
9
+ * what nobody classified" rule served the Postgres message and the SQL statement in a production
10
+ * 500. The framework's four are refusals whose cause IS the instruction — back off, retry.
11
+ */
12
+ const FRAMEWORK_PUBLIC_CAUSE: ReadonlySet<string> = new Set([
13
+ 'X_DRAINING',
14
+ 'X_OVERLOADED',
15
+ 'X_FLIGHT_GATE_OVERLOADED',
16
+ 'X_TIMEOUT',
17
+ ]);
18
+ const APP_PUBLIC_CAUSE = new Set<string>();
19
+
20
+ /** Whether a 5xx document for `code` may carry its authored `cause`. */
21
+ export const hasPublicCause = (code: string): boolean =>
22
+ FRAMEWORK_PUBLIC_CAUSE.has(code) || APP_PUBLIC_CAUSE.has(code);
23
+
24
+ /**
25
+ * The WRITE half, and not an app's door: an app declares a public cause through
26
+ * `registerProblemMeta({ CODE: { publicCause: true } })` in `@ultimat3/http`, which refuses a
27
+ * framework-owned code first and then calls this. The set lives here only so the predicate above
28
+ * has one table to read whichever renderer asks.
29
+ */
30
+ export const registerPublicCause = (code: string): void => {
31
+ APP_PUBLIC_CAUSE.add(code);
32
+ };
33
+
34
+ /** Test seam. Production registers once at boot and never unregisters. */
35
+ export const resetPublicCauses = (): void => {
36
+ APP_PUBLIC_CAUSE.clear();
37
+ };
package/src/registrar.ts CHANGED
@@ -27,6 +27,23 @@ export const PRIMITIVE_KINDS = [
27
27
 
28
28
  export type PrimitiveKind = (typeof PRIMITIVE_KINDS)[number];
29
29
 
30
+ /**
31
+ * The package that ANNOUNCES each kind's registrar — a kind is not a package name. Both `fix:`
32
+ * lines below spliced `@ultimat3/${kind}`, so a missing `task` registrar told its reader to
33
+ * `bun add @ultimat3/task`, a package the registry has never had. A `Record` over the union, so a
34
+ * ninth kind fails to compile here before it can ship a fix that 404s.
35
+ */
36
+ export const PRIMITIVE_PACKAGES = Object.freeze<Record<PrimitiveKind, string>>({
37
+ action: '@ultimat3/action',
38
+ entity: '@ultimat3/entity',
39
+ job: '@ultimat3/jobs',
40
+ mutator: '@ultimat3/action',
41
+ policy: '@ultimat3/policy',
42
+ query: '@ultimat3/query',
43
+ route: '@ultimat3/render',
44
+ task: '@ultimat3/jobs',
45
+ });
46
+
30
47
  /** One factory over one primitive: the export's name, the package that ships it, what it returns. */
31
48
  export interface PrimitiveFactory {
32
49
  readonly factory: string;
@@ -103,9 +120,9 @@ export function registerPrimitiveRegistrar(kind: PrimitiveKind, registrar: Modul
103
120
  code: 'X_REGISTRAR_CONFLICT',
104
121
  cause: `two different ${kind} registrars are loaded, so ${kind} primitives would split across two registries`,
105
122
  // One command, because a `fix:` is pasted verbatim: collapsing every range on the package
106
- // to one resolved version is the repair. `bun pm why @ultimat3/<kind>` names the dependents
107
- // when a range genuinely disagrees and the update cannot converge on its own.
108
- fix: `bun update @ultimat3/${kind}`,
123
+ // to one resolved version is the repair. `bun pm why <package>` names the dependents when
124
+ // a range genuinely disagrees and the update cannot converge on its own.
125
+ fix: `bun update ${PRIMITIVE_PACKAGES[kind]}`,
109
126
  meta: { kind },
110
127
  });
111
128
  }
@@ -127,7 +144,7 @@ export function primitiveRegistrar(kind: PrimitiveKind): ModuleRegistrar {
127
144
  throw new UltimateError({
128
145
  code: 'X_REGISTRAR_MISSING',
129
146
  cause: `no ${kind} registrar is loaded, so ${kind} primitives cannot be registered`,
130
- fix: `bun add @ultimat3/${kind}`,
147
+ fix: `bun add ${PRIMITIVE_PACKAGES[kind]}`,
131
148
  meta: { kind },
132
149
  });
133
150
  }
package/src/retry.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  import { type BackoffCurve, backoffDelay, type JitterMode, type Random } from './backoff';
7
7
  import { systemClock } from './clock';
8
8
  import { classifyThrown, type ErrorRetry, statedDelayMs } from './error-retry';
9
+ import { finiteCount, finiteOption } from './finite-option';
9
10
 
10
11
  export interface RetryPolicy {
11
12
  /** Total attempts INCLUDING the first. `attempts: 1` means no retry. */
@@ -63,6 +64,9 @@ export function retryDecision(
63
64
  error: unknown,
64
65
  random?: Random,
65
66
  ): RetryDecision {
67
+ // Screened HERE and not only in `retry()`: this function is exported so a caller can write its
68
+ // own loop, and `attempt >= NaN` is false for every attempt — the loop that asks it never ends.
69
+ const attempts = finiteCount('a retry policy', 'attempts', policy.attempts);
66
70
  const classification = classifyThrown(error);
67
71
  const stop = (stoppedBy: RetryStopReason): RetryDecision => ({
68
72
  retry: false,
@@ -74,7 +78,7 @@ export function retryDecision(
74
78
  });
75
79
 
76
80
  if (classification === 'terminal') return stop('terminal');
77
- if (attempt >= policy.attempts) return stop('attempts-exhausted');
81
+ if (attempt >= attempts) return stop('attempts-exhausted');
78
82
 
79
83
  const computed = backoffDelay({
80
84
  attempt,
@@ -111,6 +115,16 @@ export async function retry<T>(
111
115
  policy: RetryPolicy,
112
116
  deps: RetryDeps,
113
117
  ): Promise<T> {
118
+ // Both bounds are refused BEFORE the first try: a policy that cannot stop the loop is a defect in
119
+ // the call, and running the work once first would report it as the work's own failure.
120
+ // `finiteOption` for the budget, not `finiteCount`: it is a duration a caller computes from a
121
+ // monotonic clock, so a fraction is real and a spent (negative) one means "do not wait at all".
122
+ // Zero stays legal and means what it always did — one try, no retry (`retry.test.ts` pins it).
123
+ finiteCount('a retry policy', 'attempts', policy.attempts);
124
+ const budget =
125
+ policy.timeBudgetMs === undefined
126
+ ? undefined
127
+ : finiteOption('a retry policy', 'timeBudgetMs', policy.timeBudgetMs);
114
128
  const now = deps.now ?? ((): number => systemClock.monotonic());
115
129
  // Read once even when no budget is set: a clock call per attempt would be a cost the common case
116
130
  // does not owe. `startedAt` is only compared against when `timeBudgetMs` is present.
@@ -122,7 +136,6 @@ export async function retry<T>(
122
136
  } catch (error) {
123
137
  const decision = retryDecision(policy, attempt, error, deps.random);
124
138
  if (!decision.retry) throw error;
125
- const budget = policy.timeBudgetMs;
126
139
  // Decided BEFORE the wait, never after: a loop that sleeps and then discovers it is out of
127
140
  // budget has already spent the caller's deadline on a wait nobody could use.
128
141
  if (budget !== undefined && now() - startedAt + decision.delayMs > budget) throw error;
@@ -0,0 +1,36 @@
1
+ // Single responsibility: the ONE precedence between route patterns, as an integer. Tier 0 because
2
+ // three tier-4 readers need it and may not import each other — `@ultimat3/render`'s
3
+ // `compilePattern` (ISR, sitemap extras, the admin), and `@ultimat3/pwa`'s worker rule order.
4
+
5
+ /**
6
+ * Per segment, how strongly it claims a pathname: a literal 3, a `:param` 2, a `*catch-all` 1, and
7
+ * 4 where the pattern has already ENDED — `/` outranks `/*rest` and `/docs` outranks `/docs/*path`
8
+ * at the bare prefix, as the request router's own terminal outranks its catch-all.
9
+ */
10
+ const SEGMENT_WEIGHT = { literal: 3, param: 2, catchAll: 1, ended: 4 } as const;
11
+ const WEIGHT_BASE = 5;
12
+ /** 5^22 < 2^53: every rank is an exact integer. A deeper pattern ties past its 22nd segment. */
13
+ export const ROUTE_RANK_SEGMENTS = 22;
14
+
15
+ /**
16
+ * The request router's precedence (`@ultimat3/http`'s trie) as ONE number, higher wins: segment by
17
+ * segment, the first segment where two patterns differ decides — literal over `:param` over
18
+ * `*catch-all`. Positional, never a sum: the 100/10/1 sum this replaced ranked `/:a/b/c` above
19
+ * `/a/:x/:y` for `/a/b/c`, where the trie takes the literal first segment. Compare two ranks only
20
+ * with each other; the value itself means nothing.
21
+ */
22
+ export function routeRank(pattern: string): number {
23
+ const weights = pattern
24
+ .split('/')
25
+ .filter((segment) => segment.length > 0)
26
+ .map((segment) => {
27
+ if (segment.startsWith('*')) return SEGMENT_WEIGHT.catchAll;
28
+ if (segment.startsWith(':')) return SEGMENT_WEIGHT.param;
29
+ return SEGMENT_WEIGHT.literal;
30
+ });
31
+ let rank = 0;
32
+ for (let i = 0; i < ROUTE_RANK_SEGMENTS; i += 1) {
33
+ rank = rank * WEIGHT_BASE + (weights[i] ?? SEGMENT_WEIGHT.ended);
34
+ }
35
+ return rank;
36
+ }
@@ -13,7 +13,7 @@ export interface OriginEvidence {
13
13
  readonly secFetchSite: string | null;
14
14
  /** An EXACT allowance for a sibling origin — never a wildcard, never a suffix match. */
15
15
  readonly listed: (origin: string) => boolean;
16
- /** Where the operator adds an origin, named in the refusal: `http.cors.origins`, `SYNC_ORIGINS`. */
16
+ /** Where the operator adds an origin, named in the refusal: `http.cors.origins`, `APP_URL`. */
17
17
  readonly listName: string;
18
18
  }
19
19
 
package/src/sampler.ts CHANGED
@@ -136,9 +136,13 @@ export function samplerFromEnv(
136
136
  return ratioSampler(ratio);
137
137
  case 'parentbased_always_off':
138
138
  return parentBasedRatioSampler(0);
139
+ case 'parentbased_always_on':
140
+ // Its own case because it takes NO arg: sharing the ratio branch let a leftover
141
+ // `OTEL_TRACES_SAMPLER_ARG=0.1` thin the roots of a sampler whose name says always.
142
+ return parentBasedRatioSampler(1);
139
143
  default:
140
- // `parentbased_always_on`, `parentbased_traceidratio` and the unset case are one sampler:
141
- // honour the parent, else the ratio — which is 1 when nothing set an arg.
144
+ // `parentbased_traceidratio` and the unset case are one sampler: honour the parent, else
145
+ // the ratio — which is 1 when nothing set an arg.
142
146
  return parentBasedRatioSampler(ratio);
143
147
  }
144
148
  }
@@ -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
+ }
@@ -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
+ }