@ultimat3/core 21.0.0 → 22.1.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/src/lifecycle.ts CHANGED
@@ -7,6 +7,7 @@ import { UltimateError } from './errors';
7
7
  import { finiteCount } from './finite-option';
8
8
  import { settleWithin } from './lifecycle-deadline';
9
9
  import { lifecycleDrained } from './lifecycle-errors';
10
+ import { defaultReadinessGraceMs, readinessGraceIssue } from './lifecycle-grace';
10
11
  import { type LogFields, type Logger, logger as rootLogger } from './logger';
11
12
 
12
13
  export type HealthState = 'starting' | 'ready' | 'draining' | 'stopped';
@@ -54,6 +55,15 @@ export interface LifecycleOptions {
54
55
  * Screened where it is assigned: a whole number of milliseconds, 0 or more. `0` is "drain now".
55
56
  */
56
57
  readonly deadlineMs?: number | undefined;
58
+ /**
59
+ * How long `/readyz` answers 503 BEFORE the `accept` phase closes the listener — the time the
60
+ * endpoints controller and the ingress need to stop routing here. Closing on the flip itself left
61
+ * endpoints pointing at a closed socket, and a POST in that window got a 502. Added to
62
+ * `deadlineMs`, never taken from it. Unset: `defaultReadinessGraceMs()` of the process env at
63
+ * drain time — 0 in development/test, 5000 everywhere else, including a process naming no env.
64
+ * A whole number from 0 to 60000; 0 is no grace.
65
+ */
66
+ readonly readinessGraceMs?: number | undefined;
57
67
  readonly clock?: Clock | undefined;
58
68
  readonly logger?: Logger | undefined;
59
69
  }
@@ -111,6 +121,8 @@ interface Registration {
111
121
  const DEFAULT_DEADLINE_MS = 25_000;
112
122
 
113
123
  let deadlineMs = DEFAULT_DEADLINE_MS;
124
+ /** `undefined` means "the environment's default", read when a drain starts, not at import. */
125
+ let graceMs: number | undefined;
114
126
  let clock: Clock = systemClock;
115
127
  let log: Logger = rootLogger;
116
128
  let state: HealthState = 'starting';
@@ -131,6 +143,18 @@ export function configureLifecycle(options: LifecycleOptions): void {
131
143
  if (options.deadlineMs !== undefined) {
132
144
  deadlineMs = finiteCount('configureLifecycle', 'deadlineMs', options.deadlineMs, 0);
133
145
  }
146
+ if (options.readinessGraceMs !== undefined) {
147
+ const issue = readinessGraceIssue(options.readinessGraceMs);
148
+ if (issue !== undefined) {
149
+ throw new UltimateError({
150
+ code: 'X_CONFIG_INVALID',
151
+ cause: issue,
152
+ fix: 'pass configureLifecycle({ readinessGraceMs: 5_000 }) — or set drain: { readinessGraceMs: 5_000 } in app.config.ts',
153
+ meta: { key: 'drain.readinessGraceMs' },
154
+ });
155
+ }
156
+ graceMs = options.readinessGraceMs;
157
+ }
134
158
  if (options.clock !== undefined) {
135
159
  clock = options.clock;
136
160
  startedAtMono = clock.monotonic();
@@ -316,6 +340,11 @@ export function drainDeadlineMs(): number {
316
340
  return deadlineMs;
317
341
  }
318
342
 
343
+ /** The grace the next drain will wait out — configured, else the environment's default. */
344
+ export function readinessGraceMs(): number {
345
+ return graceMs ?? defaultReadinessGraceMs();
346
+ }
347
+
319
348
  /**
320
349
  * What is left of that budget. Read per hook, not per phase: the deadline bounds the WHOLE drain,
321
350
  * so a hook that spent it leaves nothing for the ones behind it — which is what
@@ -354,10 +383,20 @@ async function runPhase(phase: ShutdownPhase, reason: ShutdownReason): Promise<v
354
383
  }
355
384
  }
356
385
 
357
- /** The three phases, in order, under one budget. Never rejects — `drain()` depends on that. */
358
- async function runDrain(signal: string, reason: ShutdownReason): Promise<void> {
386
+ /**
387
+ * The three phases, in order, under one budget. Never rejects — `drain()` depends on that.
388
+ *
389
+ * The readiness grace runs FIRST and OUTSIDE the budget: `state` is already `draining`, so
390
+ * `/readyz` answers 503 while the listener still accepts what was routed here before the flip.
391
+ * The deadline starts after it, so a chart's `terminationGracePeriodSeconds` must exceed
392
+ * `readinessGraceMs + deadlineMs`.
393
+ */
394
+ async function runDrain(signal: string): Promise<void> {
359
395
  try {
360
- report('info', 'draining', { signal, deadlineMs, inflight });
396
+ const grace = readinessGraceMs();
397
+ report('info', 'draining', { signal, deadlineMs, readinessGraceMs: grace, inflight });
398
+ if (grace > 0) await Bun.sleep(grace);
399
+ const reason: ShutdownReason = { signal, deadlineAt: systemClock.monotonic() + deadlineMs };
361
400
  await runPhase('accept', reason);
362
401
 
363
402
  // Real monotonic, like `deadlineAt` itself: `waitForIdle` sleeps on a real `setTimeout`, and
@@ -401,7 +440,6 @@ async function runDrain(signal: string, reason: ShutdownReason): Promise<void> {
401
440
  export function drain(signal = 'manual'): Promise<void> {
402
441
  if (drainPromise !== undefined) return drainPromise;
403
442
  state = 'draining';
404
- const reason: ShutdownReason = { signal, deadlineAt: systemClock.monotonic() + deadlineMs };
405
443
  let published!: () => void;
406
444
  drainPromise = new Promise<void>((resolve) => {
407
445
  published = resolve;
@@ -409,41 +447,10 @@ export function drain(signal = 'manual'): Promise<void> {
409
447
  // Both settle paths, for the reason `installSignalHandlers` gives below: `runDrain` cannot
410
448
  // reject today — that is its `try/finally`, not luck — and a rejected memo would re-reject for
411
449
  // every later caller and end the process the drain was trying to end cleanly.
412
- void runDrain(signal, reason).then(published, published);
450
+ void runDrain(signal).then(published, published);
413
451
  return drainPromise;
414
452
  }
415
453
 
416
- export interface SignalHandlerOptions {
417
- readonly signals?: readonly ProcessSignal[] | undefined;
418
- /** Call `process.exit()` once drained. Off in tests. */
419
- readonly exit?: boolean | undefined;
420
- }
421
-
422
- /** Install SIGTERM/SIGINT handling. Returns an uninstall function. */
423
- export function installSignalHandlers(options?: SignalHandlerOptions): () => void {
424
- const signals: readonly ProcessSignal[] = options?.signals ?? ['SIGTERM', 'SIGINT'];
425
- const handlers = new Map<ProcessSignal, () => void>();
426
-
427
- for (const signal of signals) {
428
- const handler = (): void => {
429
- // Attached on BOTH settle paths, for the reason `settleWithin` gives: an unhandled rejection
430
- // ends the process before the drain does, and the exit is what the kubelet is waiting for.
431
- // `drain()` cannot reject today — that is the `try/finally` above, not luck — and this is
432
- // the one line that keeps it true when someone changes the body.
433
- const done = (): void => {
434
- if (options?.exit === true) process.exit(0);
435
- };
436
- void drain(signal).then(done, done);
437
- };
438
- handlers.set(signal, handler);
439
- process.on(signal, handler);
440
- }
441
-
442
- return () => {
443
- for (const [signal, handler] of handlers) process.off(signal, handler);
444
- };
445
- }
446
-
447
454
  export function healthReport(): HealthReport {
448
455
  const checks = readinessChecks();
449
456
  return {
@@ -479,6 +486,7 @@ export function readyzPayload(): HealthPayload {
479
486
  /** Test-only: forget all hooks and return to `starting`. */
480
487
  export function resetLifecycle(): void {
481
488
  deadlineMs = DEFAULT_DEADLINE_MS;
489
+ graceMs = undefined;
482
490
  clock = systemClock;
483
491
  log = rootLogger;
484
492
  state = 'starting';
package/src/logger.ts CHANGED
@@ -304,6 +304,22 @@ function resolveLevel(declared: LogLevel): LogLevel {
304
304
  return declared;
305
305
  }
306
306
 
307
+ /** The keys a line owns. A caller field spelled like one is renamed, never allowed to replace it. */
308
+ const RESERVED_KEYS: ReadonlySet<string> = new Set(['ts', 'level', 'msg']);
309
+
310
+ /**
311
+ * The caller's fields with any reserved key moved to `field.<key>`. Spread after `level`, a field
312
+ * `{ level: 'debug' }` turned an `error` line into a `debug` one, so a level-filtered alert never
313
+ * saw it. Renamed rather than dropped: the value is still evidence.
314
+ */
315
+ function unreserved(fields: Record<string, unknown>): Record<string, unknown> {
316
+ const out: Record<string, unknown> = {};
317
+ for (const [key, value] of Object.entries(fields)) {
318
+ out[RESERVED_KEYS.has(key) ? `field.${key}` : key] = value;
319
+ }
320
+ return out;
321
+ }
322
+
307
323
  export function createLogger(options?: LoggerOptions): Logger {
308
324
  const level = options?.level === undefined ? envLevel() : resolveLevel(options.level);
309
325
  const bound = options?.fields ?? {};
@@ -313,13 +329,16 @@ export function createLogger(options?: LoggerOptions): Logger {
313
329
 
314
330
  function emit(lineLevel: LogLevel, message: string, fields?: LogFields): void {
315
331
  if (LEVEL_WEIGHT[lineLevel] < threshold) return;
332
+ const caller = {
333
+ ...redactFields(bound),
334
+ ...redactFields(contextFields() ?? {}),
335
+ ...redactFields(fields ?? {}),
336
+ };
316
337
  const line = {
317
338
  ts: timestamp(clock),
318
339
  level: lineLevel,
319
340
  msg: message,
320
- ...redactFields(bound),
321
- ...redactFields(contextFields() ?? {}),
322
- ...redactFields(fields ?? {}),
341
+ ...unreserved(caller),
323
342
  };
324
343
  writer(renderLine(line, lineLevel, message, line.ts), lineLevel);
325
344
  }
@@ -0,0 +1,52 @@
1
+ // The actor a route is rendered AS when the render exists only to be weighed — `x build`'s budget
2
+ // measurement, whose document is discarded. The default holds every permission and no app facts;
3
+ // a page that reads the app's own facts (`useActor()` over `actorFact`) failed `X_ACTOR_UNRESOLVED`
4
+ // under it, so the app declares its own here, from `app.config.ts`, with no import of the CLI.
5
+
6
+ import type { Actor } from './actor';
7
+ import { serviceActor } from './actor';
8
+ import { assert } from './assert';
9
+
10
+ /** The id every trace and log line under a default measurement render carries. */
11
+ export const MEASUREMENT_ACTOR_ID = 'x-build-measure';
12
+
13
+ /** What an app declares: the actor its authed pages render as while they are weighed. */
14
+ export type MeasurementActorFactory = () => Actor | Promise<Actor>;
15
+
16
+ let declared: MeasurementActorFactory | undefined;
17
+
18
+ /**
19
+ * Declare the measurement actor. Called at module scope in `app.config.ts`, the one file every
20
+ * build imports. The LAST declaration wins, because `x dev` re-imports that file on a save and a
21
+ * refusal there would be a reload that fails over a line nobody changed.
22
+ *
23
+ * Only a render that is WEIGHED AND DISCARDED uses it — never a published `site/` artifact, whose
24
+ * `load` must keep failing the build rather than render this actor's rows into a file for everyone.
25
+ */
26
+ export function defineMeasurementActor(factory: MeasurementActorFactory): void {
27
+ declared = factory;
28
+ }
29
+
30
+ /**
31
+ * `kind: 'service'` and `'*'`: weighing bytes needs no data authority, and an app's own
32
+ * `requireMember()` has nothing to resolve for a service actor, which is the honest answer.
33
+ */
34
+ const defaultMeasurementActor = (): Actor =>
35
+ serviceActor({ id: MEASUREMENT_ACTOR_ID, permissions: ['*'] });
36
+
37
+ /** The declared actor, or the framework's default. The build's ONE reader. */
38
+ export async function measurementActor(): Promise<Actor> {
39
+ if (declared === undefined) return defaultMeasurementActor();
40
+ const actor: Actor | undefined = await declared();
41
+ assert(
42
+ typeof actor === 'object' && actor !== null && typeof actor.id === 'string',
43
+ 'the factory handed to defineMeasurementActor() answered no actor',
44
+ 'return one from it in app.config.ts: defineMeasurementActor(() => userActor({ id: "measure", roles: ["member"] }))',
45
+ );
46
+ return actor;
47
+ }
48
+
49
+ /** Back to the default. For a test, and for nothing else. */
50
+ export function resetMeasurementActor(): void {
51
+ declared = undefined;
52
+ }
@@ -9,7 +9,15 @@ export const METRICS_PATH = '/metrics';
9
9
 
10
10
  export const METRICS_CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8';
11
11
 
12
- /** The exposition format escapes exactly these three, and nothing else. */
12
+ /**
13
+ * HELP's two escapes, and only those: the exposition format defines `\\` and `\n` for a docstring.
14
+ * `\"` is a label-value escape — in HELP a parser keeps it as a literal backslash before the quote.
15
+ */
16
+ function escapeHelp(value: string): string {
17
+ return value.replaceAll('\\', '\\\\').replaceAll('\n', '\\n');
18
+ }
19
+
20
+ /** A label VALUE escapes exactly these three, and nothing else. */
13
21
  function escapeLabel(value: string): string {
14
22
  return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n');
15
23
  }
@@ -60,7 +68,7 @@ function histogramLines(name: string, point: HistogramPoint): readonly string[]
60
68
  function metricLines(metric: ReadableMetric): readonly string[] {
61
69
  const { name, kind, description, unit } = metric.descriptor;
62
70
  const help = unit === '1' || unit === '' ? description : `${description} (${unit})`;
63
- const lines = [`# HELP ${name} ${escapeLabel(help)}`, `# TYPE ${name} ${kind}`];
71
+ const lines = [`# HELP ${name} ${escapeHelp(help)}`, `# TYPE ${name} ${kind}`];
64
72
  for (const point of metric.points) {
65
73
  if (kind === 'histogram' && isHistogramPoint(point)) {
66
74
  lines.push(...histogramLines(name, point));
package/src/metrics.ts CHANGED
@@ -391,11 +391,10 @@ export function counter(name: string, options?: InstrumentOptions): Counter {
391
391
  export function gauge(name: string, options?: GaugeOptions): Gauge {
392
392
  const instrument = declare(name, 'gauge', options ?? {});
393
393
  return {
394
- // The screen runs BEFORE the series is resolved, the order `counter.add` already had: a
395
- // refused value must cost nothing, and `seriesFor` is not a read — it MINTS a series, one of
396
- // a bounded number, keeps it for the life of the process and can trip the cardinality
397
- // ceiling. `a.b += finite(…)` evaluates the reference first, so the two cannot be folded
398
- // back into one expression.
394
+ // The screen runs BEFORE the series is resolved, as in `counter.add`: a refused value must
395
+ // cost nothing, and `seriesFor` MINTS a series — bounded in number, kept for the process's
396
+ // life, able to trip the cardinality ceiling. `a.b += finite(…)` evaluates the reference
397
+ // first, so the two cannot be folded into one expression.
399
398
  record(value, attributes = {}): void {
400
399
  if (!enabled) return;
401
400
  const observed = finite(name, value);
@@ -443,6 +442,10 @@ function pointsOf(instrument: Instrument): readonly MetricPoint[] {
443
442
  return [];
444
443
  }
445
444
  }
445
+ // Declared counter, no sample: 0, not absent (`rate()` needs a series) — until one exists.
446
+ const unsampled =
447
+ enabled && instrument.descriptor.kind === 'counter' && instrument.series.size === 0;
448
+ if (unsampled) return [{ attributes: {}, value: 0 }];
446
449
  return [...instrument.series.values()].map((series) =>
447
450
  instrument.descriptor.kind === 'histogram'
448
451
  ? {
@@ -122,7 +122,33 @@ export function otlpMetricExporter(options: OtlpMetricExporterOptions = {}): Otl
122
122
  const timeoutMs = assertFiniteOtlpBound('timeoutMs', options.timeoutMs ?? 10_000);
123
123
  const send = options.fetch ?? globalThis.fetch;
124
124
  let startedAtMs = options.startedAtMs;
125
- let inflight: Promise<void> = Promise.resolve();
125
+ /** The ONE POST in flight, and the one body waiting behind it — never a chain. */
126
+ let sending: Promise<void> | undefined;
127
+ let waiting: string | undefined;
128
+
129
+ /**
130
+ * One snapshot in flight and at most one waiting. Each export used to chain a POST behind the
131
+ * last, so a stalled collector held every snapshot taken while it stalled. Snapshots are
132
+ * CUMULATIVE, so the newest supersedes every older one still waiting — dropping those loses no
133
+ * count — and sending strictly one at a time keeps a counter from arriving out of order and
134
+ * reading as a reset. `postOtlp` never rejects; the `then` pair keeps that true for this loop.
135
+ */
136
+ const pump = (): Promise<void> => {
137
+ if (sending !== undefined) return sending;
138
+ const body = waiting;
139
+ waiting = undefined;
140
+ if (body === undefined) return Promise.resolve();
141
+ sending = postOtlp({ url, headers, body, timeoutMs, fetch: send })
142
+ .then(
143
+ () => undefined,
144
+ () => undefined,
145
+ )
146
+ .then(() => {
147
+ sending = undefined;
148
+ if (waiting !== undefined) void pump();
149
+ });
150
+ return sending;
151
+ };
126
152
 
127
153
  return {
128
154
  export(collection: MetricCollection): void {
@@ -143,18 +169,19 @@ export function otlpMetricExporter(options: OtlpMetricExporterOptions = {}): Otl
143
169
  });
144
170
  return;
145
171
  }
146
- // Chained, so a slow collector cannot make two snapshots arrive out of order and turn a
147
- // cumulative counter into an apparent reset. Chained on a SETTLED shadow, for the reason
148
- // `otlp-span-exporter.ts` spells out: a chain that carries a rejection forward stops calling
149
- // `postOtlp` for the life of the process, in silence.
150
- const settled = inflight.then(
151
- () => undefined,
152
- () => undefined,
153
- );
154
- inflight = settled.then(() => postOtlp({ url, headers, body, timeoutMs, fetch: send }));
172
+ waiting = body;
173
+ void pump();
155
174
  },
156
- flush(): Promise<void> {
157
- return inflight;
175
+ async flush(): Promise<void> {
176
+ // The one in flight, then the one that was waiting behind it — which that settle started.
177
+ // Bounded, so a timer exporting while this waits cannot keep a shutdown here forever.
178
+ for (
179
+ let round = 0;
180
+ round < 3 && (sending !== undefined || waiting !== undefined);
181
+ round += 1
182
+ ) {
183
+ await pump();
184
+ }
158
185
  },
159
186
  };
160
187
  }
@@ -136,14 +136,15 @@ export function otlpSpanExporter(options: OtlpSpanExporterOptions = {}): OtlpSpa
136
136
  );
137
137
  const send = options.fetch ?? globalThis.fetch;
138
138
  const queue: ReadableSpan[] = [];
139
- let inflight: Promise<void> = Promise.resolve();
139
+ /** The ONE batch in flight, or `undefined`. Never a chain: see `pump`. */
140
+ let sending: Promise<void> | undefined;
140
141
 
141
142
  const post = (batch: readonly ReadableSpan[]): Promise<void> => {
142
143
  const first = batch[0];
143
144
  if (first === undefined) return Promise.resolve();
144
145
  let body: string;
145
146
  try {
146
- // The one synchronous throw on this path, and the only way `inflight` can reject at all:
147
+ // The one synchronous throw on this path, and the only way a send could reject at all:
147
148
  // `AttributeValue` is a compile-time claim, so an attribute the app spelled as an object, a
148
149
  // bigint or a cycle reaches `anyValue`'s `value.map(...)` as a TypeError. Dropped with a
149
150
  // line, the same degradation `postOtlp` already applies to a collector that is down —
@@ -160,25 +161,47 @@ export function otlpSpanExporter(options: OtlpSpanExporterOptions = {}): OtlpSpa
160
161
  return postOtlp({ url, headers, body, timeoutMs, fetch: send });
161
162
  };
162
163
 
163
- const drainQueue = (): Promise<void> => {
164
- const batch = queue.splice(0, queue.length);
165
- // Chained, not concurrent: a collector reordering batches from one process turns a parent's
166
- // span arriving after its child into a broken trace on the read side. Chained on a SETTLED
167
- // shadow, because a chain that carries a rejection forward is poisoned for the life of the
168
- // process: `post` is never called again while the queue keeps emptying, so every later span is
169
- // dropped in silence and every timer tick mints a fresh unhandled rejection — which Bun ends
170
- // the process on. Same shape as `offline-queue.ts`'s drain chain.
171
- const settled = inflight.then(
164
+ /**
165
+ * At most ONE batch in flight, and at most `maxBatchSize` spans in it. The queue used to be
166
+ * moved wholesale into a promise chain behind the previous POST, so a stalled collector held
167
+ * every span ever exported — 100k with `maxQueueSize: 2048` — and the bound was a bound on the
168
+ * queue, not on memory. Now spans WAIT IN the queue while a POST is unsettled, where drop-oldest
169
+ * applies, and what is held is `maxQueueSize` plus one batch. One at a time also keeps batches
170
+ * in order, so a parent span never arrives after its child. A POST cannot reject (`postOtlp`
171
+ * degrades to a log line) and `post` catches its own serialisation throw, so nothing here can
172
+ * poison the next send.
173
+ */
174
+ const pump = (): Promise<void> => {
175
+ if (sending !== undefined) return sending;
176
+ const batch = queue.splice(0, maxBatchSize);
177
+ if (batch.length === 0) return Promise.resolve();
178
+ const current = post(batch).then(
172
179
  () => undefined,
173
180
  () => undefined,
174
181
  );
175
- inflight = settled.then(() => post(batch));
176
- return inflight;
182
+ sending = current.then(() => {
183
+ sending = undefined;
184
+ // A full batch waiting behind a slow POST goes next without waiting for the timer.
185
+ if (queue.length >= maxBatchSize) void pump();
186
+ });
187
+ return sending;
188
+ };
189
+
190
+ /** Everything queued NOW, batch by batch. Bounded, so a busy exporter cannot keep it looping. */
191
+ const drainQueue = async (): Promise<void> => {
192
+ let rounds = Math.ceil(queue.length / maxBatchSize) + 1;
193
+ while (rounds > 0) {
194
+ rounds -= 1;
195
+ await pump();
196
+ if (queue.length === 0 && sending === undefined) return;
197
+ }
177
198
  };
178
199
 
179
200
  // Unref'd, so a pending flush never holds a draining process open — `shutdown()` is what
180
201
  // decides the last batch leaves, exactly as `startMetricExport` defers to the drain hook.
181
- const timer = setInterval(() => void drainQueue(), flushIntervalMs);
202
+ // `pump`, never `drainQueue`: while a POST is stalled `pump` answers the send already in flight
203
+ // and allocates nothing, where a drain per tick would park one more waiter per interval.
204
+ const timer = setInterval(() => void pump(), flushIntervalMs);
182
205
  timer.unref();
183
206
 
184
207
  return {
@@ -187,7 +210,7 @@ export function otlpSpanExporter(options: OtlpSpanExporterOptions = {}): OtlpSpa
187
210
  // the spans an operator wants during an incident are the ones happening now.
188
211
  if (queue.length >= maxQueueSize) queue.shift();
189
212
  queue.push(span);
190
- if (queue.length >= maxBatchSize) void drainQueue();
213
+ if (queue.length >= maxBatchSize) void pump();
191
214
  },
192
215
  flush(): Promise<void> {
193
216
  return drainQueue();
@@ -0,0 +1,51 @@
1
+ // Single responsibility: the one rule for "did this request carrying an AMBIENT credential come
2
+ // from this app?" — `@ultimat3/http`'s CSRF check asks it of an unsafe write, and
3
+ // `@ultimat3/realtime`'s sync node of a websocket upgrade. One rule, so a sibling origin cannot be
4
+ // let in by one surface and refused by the other.
5
+
6
+ /** The complete `Sec-Fetch-Site` vocabulary. Anything else was written by a non-browser. */
7
+ const KNOWN_SITES = new Set(['same-origin', 'same-site', 'cross-site', 'none']);
8
+
9
+ export interface OriginEvidence {
10
+ /** The origins this app is reached on. More than one when the scheme is not knowable here. */
11
+ readonly selfOrigins: readonly string[];
12
+ readonly origin: string | null;
13
+ readonly secFetchSite: string | null;
14
+ /** An EXACT allowance for a sibling origin — never a wildcard, never a suffix match. */
15
+ readonly listed: (origin: string) => boolean;
16
+ /** Where the operator adds an origin, named in the refusal: `http.cors.origins`, `SYNC_ORIGINS`. */
17
+ readonly listName: string;
18
+ }
19
+
20
+ export type OriginVerdict =
21
+ | { readonly ok: true }
22
+ /** Why it was refused, in terms the caller can act on. Never echoes a header verbatim. */
23
+ | { readonly ok: false; readonly reason: string };
24
+
25
+ /**
26
+ * `sec-fetch-site` first because it is the browser's own answer and cannot be set by script;
27
+ * `Origin` second, so an app that lists a sibling origin keeps working. `same-site` is NOT proof:
28
+ * a sibling subdomain is same-site, and a `SameSite=Lax` cookie rides its requests. A request with
29
+ * neither header is refused — "we could not tell" is the case this exists for.
30
+ */
31
+ export function proveSameOrigin(evidence: OriginEvidence): OriginVerdict {
32
+ const site = evidence.secFetchSite;
33
+ if (site === 'same-origin' || site === 'none') return { ok: true };
34
+ const origin = evidence.origin;
35
+ if (origin !== null && evidence.selfOrigins.includes(origin)) return { ok: true };
36
+ if (origin !== null && evidence.listed(origin)) return { ok: true };
37
+ // Only the four values a browser can send are quoted back. Anything else is a client that
38
+ // wrote the header itself, and echoing what it wrote is how a rejected value reaches the log
39
+ // store and the response body.
40
+ if (site !== null) {
41
+ const known = KNOWN_SITES.has(site) ? site : 'a value no browser sends';
42
+ return { ok: false, reason: `the request reported sec-fetch-site: ${known}` };
43
+ }
44
+ return {
45
+ ok: false,
46
+ reason:
47
+ origin === null
48
+ ? 'the request carried neither sec-fetch-site nor origin, so it cannot be shown to be same-origin'
49
+ : `the origin it declares is not this app and is not listed in ${evidence.listName}`,
50
+ };
51
+ }
@@ -6,9 +6,11 @@
6
6
  // `node:fs` sync, by necessity twice over: Bun.write takes no mode, and a world-readable master key
7
7
  // is the whole failure this file exists to prevent — and `installSecrets()` runs once, at boot,
8
8
  // before the process is serving anything, so there is nothing for an async read to overlap with.
9
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
9
+ // `renameSync` because Bun has no atomic-replace primitive, and `rmSync` to clear the temp file.
10
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
10
11
  // Bun exposes no path-join primitive.
11
12
  import { join } from 'node:path';
13
+ import { isUltimateError } from './errors';
12
14
  import type { SecretValues } from './secrets';
13
15
  import { masterKeyId, openSecrets, parseMasterKey, sealSecrets } from './secrets';
14
16
  import { SecretsFileMissingError, SecretsKeyMissingError } from './secrets-errors';
@@ -36,6 +38,38 @@ type EnvRecord = Record<string, string | undefined>;
36
38
  export const secretsPath = (root: string): string => join(root, SECRETS_FILE);
37
39
  export const masterKeyPath = (root: string): string => join(root, SECRETS_KEY_FILE);
38
40
  export const secretsFileExists = (root: string): boolean => existsSync(secretsPath(root));
41
+ /**
42
+ * Where `x secrets rotate` STAGES a new key before sealing the committed file with it; the rename
43
+ * that makes it live comes last. One spelling, here, for the CLI and the runtime alike.
44
+ */
45
+ export const stagedMasterKeyPath = (root: string): string => `${masterKeyPath(root)}.next`;
46
+
47
+ /**
48
+ * The committed file, opened with `key` — or, when that key no longer opens it and a rotation left
49
+ * a staged key, with the staged one. A crash between the seal and the rename leaves exactly that
50
+ * state, and a process booting then could not read its own secrets. READ-ONLY: finishing the
51
+ * rotation (the rename) is `x secrets`'s recovery, never a booting process's — two replicas racing
52
+ * a rename is how a key gets lost. An env key is never second-guessed: the platform owns it.
53
+ */
54
+ async function openWithStaged(
55
+ root: string,
56
+ key: MasterKeyRef,
57
+ ): Promise<{ readonly values: SecretValues; readonly key: MasterKeyRef }> {
58
+ try {
59
+ return { values: await readSecretsFile(root, key), key };
60
+ } catch (error) {
61
+ const staged = stagedMasterKeyPath(root);
62
+ const mismatch = isUltimateError(error) && error.code === 'X_SECRETS_KEY_MISMATCH';
63
+ if (!mismatch || key.source !== 'file' || !existsSync(staged)) throw error;
64
+ const candidate: MasterKeyRef = {
65
+ hex: readFileSync(staged, 'utf-8').trim(),
66
+ source: 'file',
67
+ at: staged,
68
+ };
69
+ // The staged key's own mismatch when neither opens it — the file's problem, not the key's.
70
+ return { values: await readSecretsFile(root, candidate), key: candidate };
71
+ }
72
+ }
39
73
 
40
74
  /**
41
75
  * Env var first, key file second. That order is what makes one image run everywhere: a container
@@ -83,10 +117,23 @@ export async function writeSecretsFile(
83
117
  return path;
84
118
  }
85
119
 
86
- /** Write the master key at 0600. Callers must have made the ignore rule true first. */
120
+ /**
121
+ * Write the master key at 0600. Callers must have made the ignore rule true first.
122
+ *
123
+ * Through a fresh temp file renamed over the target, never a write in place: `mode` applies only
124
+ * when a write CREATES the file, so rotating over a key that was 0644 left the new key 0644. The
125
+ * rename is also atomic — a reader sees the old key or the new one, never half of either.
126
+ */
87
127
  export function writeMasterKeyFile(root: string, keyHex: string): string {
88
128
  const path = masterKeyPath(root);
89
- writeFileSync(path, `${keyHex}\n`, { encoding: 'utf-8', mode: SECRETS_KEY_MODE });
129
+ const temp = `${path}.${crypto.randomUUID()}.tmp`;
130
+ try {
131
+ writeFileSync(temp, `${keyHex}\n`, { encoding: 'utf-8', mode: SECRETS_KEY_MODE, flag: 'wx' });
132
+ renameSync(temp, path);
133
+ } catch (error) {
134
+ rmSync(temp, { force: true });
135
+ throw error;
136
+ }
90
137
  return path;
91
138
  }
92
139
 
@@ -149,8 +196,7 @@ export async function installSecrets(
149
196
  skipped: [],
150
197
  };
151
198
  }
152
- const key = requireMasterKey(root, env);
153
- const values = await readSecretsFile(root, key);
199
+ const { values, key } = await openWithStaged(root, requireMasterKey(root, env));
154
200
  const installed: string[] = [];
155
201
  const skipped: string[] = [];
156
202
  for (const [name, value] of Object.entries(values)) {
@@ -27,11 +27,41 @@ export function endOfLiteral(text: string, from: number): number {
27
27
  for (let i = from + 1; i < text.length; i += 1) {
28
28
  if (text[i] === '\\') i += 1;
29
29
  else if (text[i] === quote) return i + 1;
30
+ else if (spansLines && text[i] === '$' && text[i + 1] === '{')
31
+ i = endOfInterpolation(text, i + 2) - 1;
30
32
  else if (!spansLines && text[i] === '\n') return from + 1;
31
33
  }
32
34
  return spansLines ? text.length : from + 1;
33
35
  }
34
36
 
37
+ /**
38
+ * Index just past the `}` closing a template's `${` whose body starts at `from`. The body is CODE:
39
+ * braces nest, and a string or a template inside it is skipped whole — a nested template's backtick
40
+ * read as the outer one's close desynced every literal after it, and `scripts/guards-doc.ts` lost
41
+ * every `code:` below the nesting to the gate. Comments in the body are skipped the same way.
42
+ */
43
+ function endOfInterpolation(text: string, from: number): number {
44
+ let depth = 1;
45
+ let i = from;
46
+ while (i < text.length) {
47
+ const ch = text[i] as string;
48
+ if (ch === '/' && (text[i + 1] === '/' || text[i + 1] === '*')) {
49
+ const line = text[i + 1] === '/';
50
+ const end = line ? text.indexOf('\n', i) : text.indexOf('*/', i + 2);
51
+ i = end === -1 ? text.length : line ? end : end + 2;
52
+ } else if (QUOTES.has(ch)) i = endOfLiteral(text, i);
53
+ else if (ch === '{') {
54
+ depth += 1;
55
+ i += 1;
56
+ } else if (ch === '}') {
57
+ depth -= 1;
58
+ i += 1;
59
+ if (depth === 0) return i;
60
+ } else i += 1;
61
+ }
62
+ return text.length;
63
+ }
64
+
35
65
  /**
36
66
  * Whether the `/` at `at` opens a regex rather than divides — the call no scanner without a parser
37
67
  * avoids. A regex cannot follow what ends an expression: an identifier that is not one of the words
package/src/type-pins.ts CHANGED
@@ -132,6 +132,15 @@ type _RealtimeConfigCarriesNoDeadField = Assert<
132
132
  Extract<keyof RealtimeConfig, DeadRealtimeField> extends never ? true : false
133
133
  >;
134
134
 
135
+ /**
136
+ * `'redis'` accepted, built by nothing, and booted whichever bus `NATS_URL` chose — removed in
137
+ * 22.0.0 when `selectTransport` began building what `transport` says. Re-adding it to the union is
138
+ * a type that promises a bus the framework does not have.
139
+ */
140
+ type _RealtimeTransportHasNoRedis = Assert<
141
+ 'redis' extends RealtimeConfig['transport'] ? false : true
142
+ >;
143
+
135
144
  /** And the input side with it — `Input<RealtimeConfig>` is what an `app.config.ts` writes. */
136
145
  type _RealtimeInputCarriesNoDeadField = Assert<
137
146
  Extract<keyof NonNullable<AppConfigInput['realtime']>, DeadRealtimeField> extends never