@ultimat3/core 23.0.0 → 24.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cookie.ts ADDED
@@ -0,0 +1,35 @@
1
+ // The one `Cookie:` request-header reader. At tier 0 because three packages need it — auth and http
2
+ // at tier 2 cannot import each other, i18n sits below both — and each copy had to rediscover the
3
+ // same thrown `URIError` on its own (`bun run flight-copies` refuses a fourth).
4
+
5
+ /**
6
+ * A `Cookie:` header is attacker-controlled, and `decodeURIComponent('%')` throws a bare
7
+ * `URIError` — which escapes every coded path that reads through here: an OAuth callback would
8
+ * answer 500 instead of `X_OAUTH_STATE_INVALID`, and `curl -H 'Cookie: x-locale=%'` paged the
9
+ * on-call from the `locale` stage. The raw value is returned instead, so the caller's own
10
+ * rejection stays the readable failure; a raw value is still checked against a signature, a stored
11
+ * hash or a `supported` list, and none of them match a mangled one.
12
+ */
13
+ const decodeCookieValue = (raw: string): string => {
14
+ try {
15
+ return decodeURIComponent(raw);
16
+ } catch {
17
+ return raw;
18
+ }
19
+ };
20
+
21
+ /**
22
+ * The value of cookie `name` in a `Cookie:` header, or `null` when the header or the cookie is
23
+ * absent. Never throws: the header is client-authored, so an unreadable value is the raw value and
24
+ * a header that is not a string at all is no cookie.
25
+ */
26
+ export function readCookie(header: string | null | undefined, name: string): string | null {
27
+ if (typeof header !== 'string') return null;
28
+ for (const part of header.split(';')) {
29
+ const equals = part.indexOf('=');
30
+ if (equals === -1) continue;
31
+ if (part.slice(0, equals).trim() !== name) continue;
32
+ return decodeCookieValue(part.slice(equals + 1).trim());
33
+ }
34
+ return null;
35
+ }
package/src/cursor.ts CHANGED
@@ -49,7 +49,10 @@ let configured: string | undefined;
49
49
  * warned and nothing failed.
50
50
  */
51
51
  function currentSecret(): string {
52
- return configured ?? Bun.env['ULTIMATE_CURSOR_SECRET'] ?? DEV_SECRET;
52
+ // `||`, never `??`: `ULTIMATE_CURSOR_SECRET=` (a blank compose or chart value) is the EMPTY
53
+ // string, which `??` keeps — an HMAC keyed by '' that anyone can forge, while
54
+ // `usesDevCursorSecret()` answered `false` and the boot check passed. Empty is unset.
55
+ return configured || Bun.env['ULTIMATE_CURSOR_SECRET'] || DEV_SECRET;
53
56
  }
54
57
 
55
58
  /**
@@ -10,10 +10,11 @@
10
10
  * and a keyset page boundary was cut where the database never cuts one.
11
11
  *
12
12
  * It answers `undefined` rather than guessing, and that is the whole of its contract: a caller
13
- * that knows the column's declared kind (`@ultimat3/entity`'s `compareByKind`) asks; a caller that
14
- * does NOT know it — `@ultimat3/query`, whose `OrderKey` is a name and a direction — must not,
15
- * because Postgres orders a `text` column holding `"10"` and `"9"` lexically and a comparator
16
- * guessing "both sides look like decimals" would disagree with the SQL it printed.
13
+ * that knows the column's declared kind asks — `@ultimat3/entity`'s `numericOrder`, behind
14
+ * `compareByKind`, which `@ultimat3/query` calls with the kind it resolves from the entity — and a
15
+ * caller with NO kind in hand must not, because Postgres orders a `text` column holding `"10"` and
16
+ * `"9"` lexically and a comparator guessing "both sides look like decimals" would disagree with
17
+ * the SQL it printed.
17
18
  */
18
19
 
19
20
  /** A decimal, split so two of them can be compared exactly however long the digits run. */
@@ -19,7 +19,7 @@ export class CursorSecretDevError extends UltimateError {
19
19
  super({
20
20
  code: CursorSecretDevError.code,
21
21
  cause:
22
- 'ULTIMATE_CURSOR_SECRET is unset, so this process signs cursors with the development key the framework ships — anyone can forge a page position',
22
+ 'ULTIMATE_CURSOR_SECRET is unset or empty, so this process signs cursors with the development key the framework ships — anyone can forge a page position',
23
23
  fix: "x secrets set ULTIMATE_CURSOR_SECRET — or export ULTIMATE_CURSOR_SECRET from the platform's secret store",
24
24
  meta: { variable: 'ULTIMATE_CURSOR_SECRET' },
25
25
  });
@@ -7,7 +7,7 @@ import { renderThrowable } from './error-render';
7
7
  import type { ErrorReport, ErrorReporter, ErrorSeverity } from './error-reporter';
8
8
  import { type CodedErrorInit, UltimateError } from './errors';
9
9
  import { traceId } from './ids';
10
- import { logger } from './logger';
10
+ import { logger, redactFields } from './logger';
11
11
 
12
12
  export class ErrorReporterDsnInvalidError extends UltimateError {
13
13
  static readonly code = 'X_ERROR_REPORTER_DSN_INVALID';
@@ -108,6 +108,10 @@ function payloadOf(report: ErrorReport, eventId: string): Record<string, unknown
108
108
  },
109
109
  }),
110
110
  extra: {
111
+ // The caller's two records go through `redactFields`, and FIRST. Spread raw and last, a
112
+ // `bigint` or a cycle in `meta` made `JSON.stringify` throw — that error was never reported
113
+ // — `meta: { fix, stack }` replaced the framework's own, and `meta.password` left the box.
114
+ ...redactFields(report.scope.extra ?? {}),
111
115
  // The whole point of reporting the framework's contract instead of a message: whoever is
112
116
  // paged reads the runnable fix next to the failure.
113
117
  fix: report.fix,
@@ -115,8 +119,8 @@ function payloadOf(report: ErrorReport, eventId: string): Record<string, unknown
115
119
  ...(report.scope.requestId === undefined ? {} : { requestId: report.scope.requestId }),
116
120
  ...(report.scope.actorId === undefined ? {} : { actorId: report.scope.actorId }),
117
121
  ...(report.stack === undefined ? {} : { stack: report.stack }),
118
- ...(report.meta ?? {}),
119
- ...(report.scope.extra ?? {}),
122
+ // Under its own key, so no name an error author picks can collide with one above.
123
+ ...(report.meta === undefined ? {} : { meta: redactFields(report.meta) }),
120
124
  },
121
125
  exception: { values: [{ type: report.code, value: `${report.title} — ${report.cause}` }] },
122
126
  };
@@ -8,6 +8,7 @@
8
8
  // memory fault and answers it minutes late.
9
9
 
10
10
  import { UltimateError } from './errors';
11
+ import { finiteCount } from './finite-option';
11
12
 
12
13
  export interface FlightGateLimits {
13
14
  /** Work running at once. */
@@ -53,23 +54,34 @@ export function createFlightGate(
53
54
  options?: FlightGateOptions,
54
55
  ): FlightGate {
55
56
  const subject = options?.subject ?? 'in-flight work';
57
+ // Refused at CONSTRUCTION, because this pair wedges rather than fails: `active < NaN` and
58
+ // `waiters.length >= NaN` are both false, so every caller parks in a queue with no bound. Zero
59
+ // is a real value at both — "never wait" and, at the width, "refuse everything".
60
+ const maxConcurrent = finiteCount(
61
+ `createFlightGate (${subject})`,
62
+ 'maxConcurrent',
63
+ limits.maxConcurrent,
64
+ );
65
+ const maxQueued = finiteCount(`createFlightGate (${subject})`, 'maxQueued', limits.maxQueued);
56
66
  const waiters: Array<() => void> = [];
57
67
  let active = 0;
58
68
 
59
69
  const state = (): FlightGateState => ({
60
- maxConcurrent: limits.maxConcurrent,
61
- maxQueued: limits.maxQueued,
70
+ maxConcurrent,
71
+ maxQueued,
62
72
  active,
63
73
  queued: waiters.length,
64
74
  subject,
65
75
  });
66
76
 
67
77
  const acquire = async (): Promise<void> => {
68
- if (active < limits.maxConcurrent) {
78
+ if (active < maxConcurrent) {
69
79
  active += 1;
70
80
  return;
71
81
  }
72
- if (waiters.length >= limits.maxQueued) {
82
+ // A width of zero has no slot to hand over, so a waiter would never be resumed: the queue is
83
+ // for work that WILL run, and here none will.
84
+ if (maxConcurrent === 0 || waiters.length >= maxQueued) {
73
85
  const current = state();
74
86
  throw options?.overflow?.(current) ?? gateOverloaded(current);
75
87
  }
package/src/fnv1a.ts ADDED
@@ -0,0 +1,19 @@
1
+ // 32-bit FNV-1a: a BUCKET, never a key. Rollout buckets and factory seeds need a hash every process
2
+ // computes identically and synchronously; anything that decides who shares what is `fingerprint`
3
+ // (`canonical-json.ts`), because 2^32 values collide offline in seconds.
4
+
5
+ const FNV_OFFSET_BASIS = 0x811c_9dc5;
6
+ const FNV_PRIME = 0x0100_0193;
7
+
8
+ /**
9
+ * The published 32-bit FNV-1a over UTF-16 code units, unsigned. Pure and dependency-free, which is
10
+ * the property its two callers need: two nodes place one subject in one bucket without talking.
11
+ */
12
+ export function fnv1a(text: string): number {
13
+ let hash = FNV_OFFSET_BASIS;
14
+ for (let index = 0; index < text.length; index += 1) {
15
+ hash ^= text.charCodeAt(index);
16
+ hash = Math.imul(hash, FNV_PRIME);
17
+ }
18
+ return hash >>> 0;
19
+ }
@@ -0,0 +1,43 @@
1
+ // What `/healthz` and `/readyz` say, and to whom — ONE rule for every role's listener. Both answer
2
+ // outside every pipeline, so the body is a stranger's to read: everyone gets the verdict, and the
3
+ // build id, the in-flight count and the readiness check names go only to a listed peer.
4
+
5
+ import { classifyAddress } from './address-class';
6
+ import type { HealthReport } from './lifecycle';
7
+ import type { Role } from './roles';
8
+
9
+ /** The box itself: `kubectl exec`, a port-forward, a compose healthcheck, a sidecar scraper. */
10
+ export const DEFAULT_HEALTH_DETAIL_PEERS: readonly string[] = ['loopback'];
11
+
12
+ /** The verdict a stranger gets. An allow-list, so a field `HealthReport` gains is withheld by default. */
13
+ export interface PublicHealthBody {
14
+ readonly state: HealthReport['state'];
15
+ readonly ready: boolean;
16
+ readonly role: Role;
17
+ }
18
+
19
+ /** The body for one caller: the whole report for a listed peer, the verdict for anyone else. */
20
+ export function healthBody(
21
+ report: HealthReport,
22
+ role: Role,
23
+ detailed: boolean,
24
+ ): PublicHealthBody | (HealthReport & { readonly role: Role }) {
25
+ return detailed ? { ...report, role } : { state: report.state, ready: report.ready, role };
26
+ }
27
+
28
+ /**
29
+ * Whether `address` is one the list names: an entry is an address CLASS (`loopback`, `private`, …)
30
+ * or one exact IP literal. Pure, and total — it runs on an unauthenticated probe path, so a list
31
+ * that is not a list, an entry that is not a string and an address that is not a literal all
32
+ * answer `false` rather than throw: nothing can vouch for them, whatever the list says.
33
+ */
34
+ export function healthPeerListed(peers: readonly string[], address: string | null): boolean {
35
+ if (address === null || !Array.isArray(peers)) return false;
36
+ const kind = classifyAddress(address);
37
+ if (kind === undefined) return false;
38
+ const literal = address.trim().toLowerCase();
39
+ return peers.some(
40
+ (entry: unknown) =>
41
+ typeof entry === 'string' && (entry === kind || entry.trim().toLowerCase() === literal),
42
+ );
43
+ }
package/src/host-rules.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  // read. In core because two tier-5 packages drive a browser (`scraping`, and `cli`'s `x shot`) and
5
5
  // neither may import the other; two copies of this rule would be two answers to "may it leave".
6
6
 
7
+ import { classifyAddress } from './address-class';
8
+
7
9
  export type HostRule = string;
8
10
 
9
11
  /** The one spelling that means "every host", written out so it is visible in review. */
@@ -51,6 +53,31 @@ export function hostMatches(host: string, rule: HostRule): boolean {
51
53
  return normalised === cleaned;
52
54
  }
53
55
 
56
+ /** A rule that names a CLASS of hosts rather than one — the two spellings `hostMatches` widens. */
57
+ const isWildcard = (rule: HostRule): boolean => {
58
+ const cleaned = rule.trim();
59
+ return cleaned === ANY_HOST || cleaned.startsWith('*.');
60
+ };
61
+
62
+ /**
63
+ * The address-class FLOOR. A wildcard means "any site", and an address literal inside the network
64
+ * — loopback, RFC 1918, link-local, the metadata endpoint — is not a site: it is the request this
65
+ * module's header names. So a wildcard never admits one, and the opt-out is NAMING it: an exact
66
+ * rule (`'127.0.0.1'`, `'[::1]'`) is a line a reviewer can see, which `'*'` is not.
67
+ *
68
+ * What this cannot do is see through a NAME. `allowHosts: ['*']` still admits a hostname that
69
+ * resolves inward, because this function is synchronous and has no resolver — pinning the
70
+ * resolved address belongs to the driver that opens the connection, as `@ultimat3/jobs`'
71
+ * `webhook-target.ts` does for a webhook. The URL parser has already folded the numeric
72
+ * spellings (`2130706433`, `0x7f.1`) to dotted form, so they are classified as what they are.
73
+ */
74
+ function admits(host: string, rule: HostRule): boolean {
75
+ if (!hostMatches(host, rule)) return false;
76
+ if (!isWildcard(rule)) return true;
77
+ const kind = classifyAddress(host);
78
+ return kind === undefined || kind === 'public';
79
+ }
80
+
54
81
  /**
55
82
  * Fails CLOSED: a URL that cannot be parsed is refused. A driver handed a malformed request has
56
83
  * no way to know where it would have gone, and "we could not tell, so we let it through" is the
@@ -67,5 +94,5 @@ export function hostDecision(url: string, allowHosts: readonly HostRule[]): Host
67
94
  return { allowed: false, host: '' };
68
95
  }
69
96
  if (host === '') return { allowed: false, host };
70
- return { allowed: allowHosts.some((rule) => hostMatches(host, rule)), host };
97
+ return { allowed: allowHosts.some((rule) => admits(host, rule)), host };
71
98
  }
@@ -0,0 +1,24 @@
1
+ // The one HTML character table: how an untrusted value becomes inert text or attribute content.
2
+ // At tier 0 because every package that writes markup — http, mail, render, seo, the dashboards —
3
+ // can reach it, and a second table is one character away from a hole (`bun run flight-copies`).
4
+
5
+ /** A `Map`, so a lookup never reaches `Object.prototype` (`bun run proto-index`). */
6
+ const HTML_ESCAPES: ReadonlyMap<string, string> = new Map([
7
+ ['&', '&amp;'],
8
+ ['<', '&lt;'],
9
+ ['>', '&gt;'],
10
+ ['"', '&quot;'],
11
+ ["'", '&#39;'],
12
+ ]);
13
+
14
+ const HTML_SPECIAL = /[&<>"']/g;
15
+
16
+ /**
17
+ * Text content AND attribute values, in any quoting — one set for both, deliberately. A text-only
18
+ * subset is correct exactly until someone uses it for an attribute, and a no-`'` set until someone
19
+ * writes a single-quoted one. `&#39;` rather than `&apos;`: it is a numeric reference, so it means
20
+ * the same thing in HTML 4, HTML 5 and XML. One pass, so `&` is never escaped twice.
21
+ */
22
+ export function escapeHtml(value: string): string {
23
+ return value.replace(HTML_SPECIAL, (char) => HTML_ESCAPES.get(char) ?? char);
24
+ }
@@ -55,7 +55,9 @@ export const imageTooLarge = (
55
55
  ): ImageTooLargeError =>
56
56
  new ImageTooLargeError(
57
57
  cause,
58
- 'downscale the source before it reaches the pipeline, or raise MAX_IMAGE_PIXELS deliberately',
58
+ // No "or lift the ceiling": `MAX_IMAGE_PIXELS` is a constant, so a fix naming it as a knob
59
+ // sent a reader looking for a setting that does not exist.
60
+ 'downscale the source below the 64-megapixel ceiling before it reaches the pipeline, or route it through an ImageTransformDriver (a CDN or an external encoder) — MAX_IMAGE_PIXELS is fixed, not a setting',
59
61
  meta,
60
62
  );
61
63
 
@@ -3,6 +3,14 @@
3
3
  // safe zone `@ultimat3/pwa` promises is a composite. So this file exists for exactly that one hop:
4
4
  // Bun re-encodes to PNG, this reads the pixels back, `canvas.ts` blits, this writes them again.
5
5
 
6
+ // why: `Bun.inflateSync` takes no output bound — measured, it ignores `maxOutputLength` and
7
+ // returns the whole stream — and `DecompressionStream` is async where this seam is synchronous.
8
+ // `node:zlib` is the one inflate here that can be told when to stop. A NAMESPACE import, never a
9
+ // named one: the browser polyfill of this module has no `inflateRawSync`, and a named import of a
10
+ // missing export fails the BUNDLE of every browser graph that reaches the barrel
11
+ // (`async-context.test.ts` builds one) — for a function no browser ever calls.
12
+ import * as zlib from 'node:zlib';
13
+ import { stringField } from '../error-render';
6
14
  import { imageDecodeFailed, imageUnsupported } from './errors';
7
15
  import {
8
16
  adler32,
@@ -14,7 +22,7 @@ import {
14
22
  unshared,
15
23
  writeU32,
16
24
  } from './png-bytes';
17
- import { type Raster, rasterFrom } from './raster';
25
+ import { assertPixelBudget, type Raster, rasterFrom } from './raster';
18
26
 
19
27
  /** Truecolour with alpha, 8 bits per channel — the ONE shape `Raster` is. */
20
28
  const RGBA_COLOR_TYPE = 8 << 4;
@@ -105,8 +113,13 @@ function readHeader(bytes: Uint8Array): PngHeader {
105
113
  return { width: readU32(bytes, 16), height: readU32(bytes, 20) };
106
114
  }
107
115
 
108
- /** Every IDAT concatenated: a PNG may split its stream across any number of them. */
109
- function idatStream(bytes: Uint8Array): Uint8Array {
116
+ /**
117
+ * Every IDAT concatenated — a PNG may split its stream across any number of them — and inflated
118
+ * to AT MOST `limit` bytes, which is what the header says the pixels need. Inflating first and
119
+ * measuring after is the decompression bomb: deflate packs zeros ~1000:1, so a file of a few
120
+ * hundred kilobytes declaring 1x1 allocated hundreds of megabytes before anything compared it to 5.
121
+ */
122
+ function idatStream(bytes: Uint8Array, limit: number): Uint8Array {
110
123
  const parts: Uint8Array[] = [];
111
124
  let at = 8;
112
125
  while (at + 12 <= bytes.length) {
@@ -123,8 +136,16 @@ function idatStream(bytes: Uint8Array): Uint8Array {
123
136
  try {
124
137
  // The 2-byte zlib header and the 4-byte Adler-32 trailer are PNG's envelope, stripped here
125
138
  // so the payload inflates as RAW deflate — see the encoder above for the mirror image.
126
- return Bun.inflateSync(unshared(stream.subarray(2, stream.length - 4)), { windowBits: -15 });
127
- } catch {
139
+ return zlib.inflateRawSync(unshared(stream.subarray(2, stream.length - 4)), {
140
+ maxOutputLength: limit,
141
+ });
142
+ } catch (error) {
143
+ if (stringField(error, 'code') === 'ERR_BUFFER_TOO_LARGE') {
144
+ throw imageDecodeFailed(
145
+ `the PNG IDAT stream inflates to more than the ${limit} bytes its header's size needs`,
146
+ { length: stream.length, limit },
147
+ );
148
+ }
128
149
  throw imageDecodeFailed(`the PNG IDAT stream (${stream.length} bytes) could not be inflated`, {
129
150
  length: stream.length,
130
151
  });
@@ -171,8 +192,10 @@ function unfilter(raw: Uint8Array, width: number, height: number): Uint8ClampedA
171
192
  /** PNG bytes to RGBA pixels. Refuses anything but 8-bit RGBA, naming the pipeline that reads it. */
172
193
  export function decodeImage(bytes: Uint8Array): Raster {
173
194
  const { width, height } = readHeader(bytes);
174
- const raw = idatStream(bytes);
195
+ // From the HEADER, before the stream is touched: it bounds `expected`, which bounds the inflate.
196
+ assertPixelBudget(width, height, 'PNG');
175
197
  const expected = (width * BYTES_PER_PIXEL + 1) * height;
198
+ const raw = idatStream(bytes, expected);
176
199
  if (raw.length !== expected) {
177
200
  throw imageDecodeFailed(
178
201
  `the PNG inflates to ${raw.length} bytes but ${width}x${height} RGBA needs ${expected}`,
@@ -66,8 +66,13 @@ function requireBytes(bytes: Uint8Array, needed: number, format: string, missing
66
66
 
67
67
  const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] as const;
68
68
 
69
- /** `mif1` is the generic HEIF brand AVIF files carry; `avis` is an image sequence. */
70
- const AVIF_BRANDS: ReadonlySet<string> = new Set(['avif', 'avis', 'mif1']);
69
+ /**
70
+ * `avif` is a still, `avis` an image sequence. NOT `mif1`: that is the generic HEIF brand, which
71
+ * an AVIF file lists as a COMPATIBLE brand and a HEIC file lists too — counting it sniffed every
72
+ * iPhone photo as AVIF, and the probe then read (or failed to read) it as one. An AVIF whose major
73
+ * brand is `mif1` still names `avif` among its compatible brands, which the scan below reads.
74
+ */
75
+ const AVIF_BRANDS: ReadonlySet<string> = new Set(['avif', 'avis']);
71
76
 
72
77
  function isAvif(bytes: Uint8Array): boolean {
73
78
  if (!ascii(bytes, 4, 'ftyp')) return false;
@@ -25,7 +25,9 @@ export const MAX_IMAGE_PIXELS = 64_000_000;
25
25
  /** Checked from the header before a single byte is allocated. */
26
26
  export function assertPixelBudget(width: number, height: number, source: string): void {
27
27
  if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
28
- throw imageTooLarge(`${source} declares a ${width}x${height} image, which is not a size`, {
28
+ // Decode-failed, not too-large: a header declaring zero, a fraction or `NaN` pixels is
29
+ // inconsistent bytes, and the too-large fix — downscale it — has nothing to act on.
30
+ throw imageDecodeFailed(`${source} declares a ${width}x${height} image, which is not a size`, {
29
31
  width,
30
32
  height,
31
33
  source,
package/src/index.ts CHANGED
@@ -165,6 +165,8 @@ export {
165
165
  useService,
166
166
  withChildContext,
167
167
  } from './context';
168
+ /** The one `Cookie:` reader — auth, http and i18n each parsed the header and could not share it. */
169
+ export { readCookie } from './cookie';
168
170
  export type { CursorPayload } from './cursor';
169
171
  export {
170
172
  CursorInvalidError,
@@ -465,11 +467,15 @@ export type {
465
467
  FlightGateState,
466
468
  } from './flight-gate';
467
469
  export { createFlightGate, gateOverloaded } from './flight-gate';
470
+ export { fnv1a } from './fnv1a';
468
471
  export { formatBytes } from './format-bytes';
469
472
  export type { GenerationFence } from './generation-fence';
470
473
  export { createFence, isSuperseded } from './generation-fence';
474
+ export type { PublicHealthBody } from './health-disclosure';
475
+ export { DEFAULT_HEALTH_DETAIL_PEERS, healthBody, healthPeerListed } from './health-disclosure';
471
476
  export type { HostDecision, HostRule } from './host-rules';
472
477
  export { ANY_HOST, hostDecision, hostMatches } from './host-rules';
478
+ export { escapeHtml } from './html-escape';
473
479
  export type { Brand, Id } from './ids';
474
480
  export {
475
481
  isSpanId,
@@ -614,8 +620,11 @@ export {
614
620
  CLIENT_SYNC_META,
615
621
  CLIENT_SYNC_WORKER_META,
616
622
  } from './page-meta';
623
+ /** The structural Postgres seam http, auth, action and jobs share without a `@ultimat3/db` edge. */
624
+ export type { PgExecutor } from './pg-executor';
617
625
  export type { ProcessMetricsOptions, ProcessReading } from './process-metrics';
618
626
  export { readProcess, resetProcessMetrics, startProcessMetrics } from './process-metrics';
627
+ export { hasPublicCause, registerPublicCause, resetPublicCauses } from './public-cause';
619
628
  export { type CappedBody, readWithinLimit } from './read-capped';
620
629
  export type { RecordEnvelope, RecordRows } from './record-envelope';
621
630
  export { decodeRecordEnvelope, encodeRecordEnvelope, RECORDS_HEADER } from './record-envelope';
@@ -646,6 +655,7 @@ export { retry, retryDecision } from './retry';
646
655
  export { isRetryableStatus, RETRYABLE_STATUSES } from './retryable-status';
647
656
  export type { ResolveRoleOptions, Role, RoleInfo, ScalingSignal } from './roles';
648
657
  export { DEFAULT_ROLE, isRole, ROLE_INFO, ROLES, resolveRole } from './roles';
658
+ export { routeRank } from './route-rank';
649
659
  export type { HydrateStrategy, OfflineStrategy, RenderMode } from './route-vocabulary';
650
660
  export { HYDRATE_STRATEGIES, OFFLINE_STRATEGIES, RENDER_MODES } from './route-vocabulary';
651
661
  export { safeUrl, URL_ATTRIBUTES } from './safe-url';
@@ -678,6 +688,8 @@ export {
678
688
  export type { FlightJoin, Scheduler, SingleFlight, SingleFlightOptions } from './single-flight';
679
689
  export { createSingleFlight } from './single-flight';
680
690
  export { endOfLiteral, maskLiterals, QUOTES, stripComments } from './source-mask';
691
+ export type { StoreMode } from './store-mode';
692
+ export { STORE_MODES, storeMode } from './store-mode';
681
693
  export { timingSafeEqual } from './timing-safe-equal';
682
694
  export {
683
695
  frameworkVersion,
package/src/logger.ts CHANGED
@@ -54,11 +54,10 @@ export interface LoggerOptions {
54
54
  }
55
55
 
56
56
  /**
57
- * LOWERCASE, always: `isRedactedKey` lowercases its lookup, so `apiKey`/`accessToken`/
58
- * `refreshToken` sat here for three releases matching nothing — and those are the exact field
59
- * names on `@ultimat3/auth`'s `OAuthTokens`. Matching is exact-key and never substring, so a
60
- * spelling that is not in this set is not redacted: both the camel and the snake wire spelling of
61
- * each credential is listed. Add through `redactKeys()` (which lowercases) rather than here.
57
+ * The exact-key FAST PATH. LOWERCASE, always: `isRedactedKey` lowercases its lookup, so
58
+ * `apiKey`/`accessToken`/`refreshToken` sat here for three releases matching nothing — and those
59
+ * are the exact field names on `@ultimat3/auth`'s `OAuthTokens`. Add through `redactKeys()` (which
60
+ * lowercases) rather than here. A name this set misses still meets `CREDENTIAL_NAME` below.
62
61
  */
63
62
  const redactedKeys = new Set<string>([
64
63
  'password',
@@ -81,15 +80,69 @@ const redactedKeys = new Set<string>([
81
80
  'client_secret',
82
81
  'privatekey',
83
82
  'private_key',
83
+ // The framework's own columns (`@ultimat3/auth`): a hash is what an offline guess runs against.
84
+ 'passwordhash',
85
+ 'tokenhash',
86
+ 'keyhash',
84
87
  ]);
85
88
 
89
+ /**
90
+ * The second half, for the names no list can enumerate. Exact-key matching alone let every
91
+ * COMPOUND credential through — `currentPassword`, `mfaSecret`, `resetToken`, `recoveryCode` — and
92
+ * `@ultimat3/action`'s audit walk asks this same predicate, so each was persisted in clear.
93
+ *
94
+ * Tested against the key lowercased with `_` and `-` removed, so one pattern covers the camel, the
95
+ * snake and the header spelling. It names what BEARS a credential and nothing wider, because a
96
+ * redacted field is one an operator cannot correlate on:
97
+ *
98
+ * - `password` / `passphrase` anywhere — no ordinary field carries the word.
99
+ * - `secret` as the LAST word (`mfaSecret`, `webhookSecret`, `appSecrets`, `secretAccessKey`), so
100
+ * `clientSecretEnv` and `secretsPath` — a variable name and a path — stay readable.
101
+ * - a `token` is a bearer UNLESS its qualifier says it is not: fail closed, with the exceptions
102
+ * named. `idempotencyToken`, `pageToken`, `continuationToken`, `cursorToken`, `syncToken` are
103
+ * dedupe and paging keys an operator greps for; everything else ending in `token` — `resetToken`,
104
+ * `githubToken`, `NPM_TOKEN` — is redacted without a provider list to keep current. The PLURAL
105
+ * is the reverse: `maxTokens` / `inputTokens` are counts on every `@ultimat3/ai` usage line, so
106
+ * `tokens` is redacted only behind a bearer qualifier (`accessTokens`).
107
+ * - key MATERIAL by its qualifier (`apiKey`, `privateKey`, `signingKey`, `encryptionKey`,
108
+ * `masterKey`, `hmacKey`, `secretsKey`, `accessKey`, `retiredKeys`) and the id half of a key
109
+ * pair (`accessKeyId`). A LOOKUP key — `cacheKey`, `primaryKey`, `idempotencyKey` — and a key's
110
+ * own id (`signingKeyId`) carry no qualifier on this list and stay readable.
111
+ * - a value that EMBEDS a credential: `connectionString`, `dsn`, a registry `authConfig`, and the
112
+ * service URLs that carry `user:password@` (`databaseUrl`, `REDIS_URL`). A bare `url` does not.
113
+ * - the one-time codes by name. Never a `code` suffix: that is the error contract's own field.
114
+ * - a stored hash of any of them: it is what an offline guess runs against.
115
+ *
116
+ * Built from constant alternatives with no nested quantifier, so there is no input it backtracks on.
117
+ */
118
+ const CREDENTIAL_NAME = new RegExp(
119
+ [
120
+ 'passw(?:or)?d|passphrase',
121
+ 'secrets?$',
122
+ '(?:api|private|signing|encryption|master|hmac|secrets?|access|retired)keys?$|accesskeyid$',
123
+ '(?:token|key)hash(?:es)?$',
124
+ '(?<!idempotency|page|continuation|cursor|sync)token$',
125
+ '(?:access|refresh|id|session|reset|bearer|auth|api|csrf|xsrf|captcha|card|verification|invite|magic|magiclink|device|push|workload|oauth)tokens$',
126
+ 'authconfig$|connectionstring$|dsn$',
127
+ '(?:database|db|redis|replication|nats|smtp|amqp|mongo)ur[li]s?$',
128
+ '^totp$|totpcode$|otp$|otpcode$',
129
+ '(?:recovery|backup|mfa)codes?(?:hash(?:es)?)?$',
130
+ ].join('|'),
131
+ );
132
+
86
133
  /** Mark keys as secret everywhere. `defineEnv()` calls this for every `secret: true` var. */
87
134
  export function redactKeys(keys: Iterable<string>): void {
88
135
  for (const key of keys) redactedKeys.add(key.toLowerCase());
89
136
  }
90
137
 
138
+ /**
139
+ * The framework's ONE answer to "is this field a credential?" — the log line, the error monitor's
140
+ * envelope and `@ultimat3/action`'s audit row all ask it, so a value that is `[redacted]` in one
141
+ * cannot be plaintext in another.
142
+ */
91
143
  export function isRedactedKey(key: string): boolean {
92
- return redactedKeys.has(key.toLowerCase());
144
+ const lower = key.toLowerCase();
145
+ return redactedKeys.has(lower) || CREDENTIAL_NAME.test(lower.replace(/[_-]/g, ''));
93
146
  }
94
147
 
95
148
  /**
@@ -224,7 +277,12 @@ function entryValue(source: Record<string, unknown>, key: string, depth: number)
224
277
  }
225
278
  }
226
279
 
227
- function redactFields(fields: LogFields): Record<string, unknown> {
280
+ /**
281
+ * A caller's record made safe to SERIALISE and safe to SHIP: credentials replaced by key and by
282
+ * value, a bigint / cycle / hostile getter degraded per field. Exported for the one other sink
283
+ * that sends a caller's record off the box — `error-reporter-sentry.ts`.
284
+ */
285
+ export function redactFields(fields: LogFields): Record<string, unknown> {
228
286
  const out: Record<string, unknown> = {};
229
287
  const source = fields as Record<string, unknown>;
230
288
  // `Object.keys` before the values, so the read of each value is its own guarded step: a field
@@ -304,9 +362,18 @@ function timestamp(clock: Clock): string {
304
362
  */
305
363
  function envLevel(): LogLevel {
306
364
  const raw = typeof process === 'undefined' ? undefined : process.env['LOG_LEVEL'];
307
- return raw !== undefined && (LOG_LEVELS as readonly string[]).includes(raw)
308
- ? (raw as LogLevel)
309
- : 'info';
365
+ // Unset and EMPTY are the same answer — `LOG_LEVEL=` is how a compose file spells "not set".
366
+ if (raw === undefined || raw === '') return 'info';
367
+ // REFUSED, as `resolveLevel` refuses the same value from `createLogger({ level })`. It fell back
368
+ // to `info` in silence, so `LOG_LEVEL=verbose` — or `DEBUG`, the spelling half the ecosystem
369
+ // uses — gave an operator who asked for MORE lines fewer, and nothing said the variable was the
370
+ // reason. This runs at module init, so the refusal is the first thing the process prints.
371
+ assert(
372
+ (LOG_LEVELS as readonly string[]).includes(raw),
373
+ `LOG_LEVEL=${renderCauseValue(raw)} is not a log level`,
374
+ `set LOG_LEVEL=info (one of ${LOG_LEVELS.join(', ')}, lowercase), or unset LOG_LEVEL`,
375
+ );
376
+ return raw as LogLevel;
310
377
  }
311
378
 
312
379
  /**
@@ -27,7 +27,16 @@ const distance = (a: string, b: string): number => {
27
27
  const MAX_EDITS = 3;
28
28
 
29
29
  /**
30
- * The nearest candidate within `MAX_EDITS`, or `undefined` when nothing is close enough. Ties keep
30
+ * The cutoff for one pair: `MAX_EDITS`, but never as many edits as the longer name has
31
+ * characters. A fixed 3 is a typo in `migrate` and a different word in `db` — replacing ALL of a
32
+ * one- or two-letter input costs at most its length, so `nearestName('a', ['db', 'gen'])`
33
+ * answered `db` with nothing typed in common.
34
+ */
35
+ const cutoff = (input: string, candidate: string): number =>
36
+ Math.min(MAX_EDITS, Math.max(input.length, candidate.length) - 1);
37
+
38
+ /**
39
+ * The nearest candidate within its cutoff, or `undefined` when nothing is close enough. Ties keep
31
40
  * the FIRST candidate, which is the order the caller declared them in — `definePermissions([...])`
32
41
  * and a `CommandSpec` list are both authored orders, and a stable answer is what lets a test pin one.
33
42
  */
@@ -36,7 +45,7 @@ export const nearestName = (input: string, candidates: readonly string[]): strin
36
45
  let bestScore = MAX_EDITS + 1;
37
46
  for (const candidate of candidates) {
38
47
  const score = distance(input, candidate);
39
- if (score < bestScore) {
48
+ if (score < bestScore && score <= cutoff(input, candidate)) {
40
49
  best = candidate;
41
50
  bestScore = score;
42
51
  }
@@ -118,7 +118,7 @@ export interface OtlpMetricExporter extends MetricExporter {
118
118
  */
119
119
  export function otlpMetricExporter(options: OtlpMetricExporterOptions = {}): OtlpMetricExporter {
120
120
  const url = otlpEndpoint('metrics', options.endpoint);
121
- const headers = otlpHeaders(options.headers);
121
+ const headers = otlpHeaders(options.headers, process.env, 'metrics');
122
122
  const timeoutMs = assertFiniteOtlpBound('timeoutMs', options.timeoutMs ?? 10_000);
123
123
  const send = options.fetch ?? globalThis.fetch;
124
124
  let startedAtMs = options.startedAtMs;
@@ -126,7 +126,7 @@ export interface OtlpSpanExporter extends SpanExporter {
126
126
  */
127
127
  export function otlpSpanExporter(options: OtlpSpanExporterOptions = {}): OtlpSpanExporter {
128
128
  const url = otlpEndpoint('traces', options.endpoint);
129
- const headers = otlpHeaders(options.headers);
129
+ const headers = otlpHeaders(options.headers, process.env, 'traces');
130
130
  const maxBatchSize = assertFiniteOtlpBound('maxBatchSize', options.maxBatchSize ?? 512);
131
131
  const maxQueueSize = assertFiniteOtlpBound('maxQueueSize', options.maxQueueSize ?? 2048);
132
132
  const timeoutMs = assertFiniteOtlpBound('timeoutMs', options.timeoutMs ?? 10_000);