@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
@@ -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,
@@ -585,6 +591,9 @@ export type { Direction } from './locale-direction';
585
591
  export { directionOf, isRtl } from './locale-direction';
586
592
  export type { LocalePathSplit } from './locale-path';
587
593
  export { localeSegment, localizePath, splitLocalePath } from './locale-path';
594
+ // The process logger's test seam, beside nothing it groups with: where a default-writer line goes.
595
+ export type { LogSink } from './logger';
596
+ export { setLogSink } from './logger';
588
597
  export { isMcpExposed, type McpExposureDeclaration } from './mcp-exposure';
589
598
  export type { MeasurementActorFactory } from './measurement-actor';
590
599
  export {
@@ -604,12 +613,18 @@ export {
604
613
  CLIENT_NAVIGATION_LOCATION_HEADER,
605
614
  CLIENT_NAVIGATION_SCOPE_HEADER,
606
615
  CLIENT_NAVIGATION_SURFACE_HEADER,
616
+ CLIENT_PATH_STYLE_META,
607
617
  CLIENT_PERSIST_META,
608
618
  CLIENT_SCOPE_HEADER,
609
619
  CLIENT_SCOPE_META,
610
620
  CLIENT_SYNC_META,
611
621
  CLIENT_SYNC_WORKER_META,
612
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';
625
+ export type { ProcessMetricsOptions, ProcessReading } from './process-metrics';
626
+ export { readProcess, resetProcessMetrics, startProcessMetrics } from './process-metrics';
627
+ export { hasPublicCause, registerPublicCause, resetPublicCauses } from './public-cause';
613
628
  export { type CappedBody, readWithinLimit } from './read-capped';
614
629
  export type { RecordEnvelope, RecordRows } from './record-envelope';
615
630
  export { decodeRecordEnvelope, encodeRecordEnvelope, RECORDS_HEADER } from './record-envelope';
@@ -640,6 +655,7 @@ export { retry, retryDecision } from './retry';
640
655
  export { isRetryableStatus, RETRYABLE_STATUSES } from './retryable-status';
641
656
  export type { ResolveRoleOptions, Role, RoleInfo, ScalingSignal } from './roles';
642
657
  export { DEFAULT_ROLE, isRole, ROLE_INFO, ROLES, resolveRole } from './roles';
658
+ export { routeRank } from './route-rank';
643
659
  export type { HydrateStrategy, OfflineStrategy, RenderMode } from './route-vocabulary';
644
660
  export { HYDRATE_STRATEGIES, OFFLINE_STRATEGIES, RENDER_MODES } from './route-vocabulary';
645
661
  export { safeUrl, URL_ATTRIBUTES } from './safe-url';
@@ -648,6 +664,20 @@ export {
648
664
  type OriginVerdict,
649
665
  proveSameOrigin,
650
666
  } from './same-origin';
667
+ export type { SealOptions, SealPurposeOptions } from './seal';
668
+ export { isSealed, open, openText, SEAL_VERSION, seal, sealAll, sealedKeyId } from './seal';
669
+ export type { SealInvalidReason } from './seal-errors';
670
+ export { SealInvalidError, SealKeyMissingError, SealKeyUnknownError } from './seal-errors';
671
+ export type { SealKeyRing, SealKeySource } from './seal-keys';
672
+ export {
673
+ resolveSealKeys,
674
+ SECRETS_RETIRED_KEYS_ENV,
675
+ sealKeyIds,
676
+ splitRetiredKeys,
677
+ } from './seal-keys';
678
+ // Beside the ring it is raised for, not in the `exports/secrets` group: same code as
679
+ // `SecretsKeyInvalidError`, a different variable to repair.
680
+ export { SecretsRingKeyInvalidError } from './secrets-errors';
651
681
  export {
652
682
  defineService,
653
683
  installedServices,
@@ -658,6 +688,8 @@ export {
658
688
  export type { FlightJoin, Scheduler, SingleFlight, SingleFlightOptions } from './single-flight';
659
689
  export { createSingleFlight } from './single-flight';
660
690
  export { endOfLiteral, maskLiterals, QUOTES, stripComments } from './source-mask';
691
+ export type { StoreMode } from './store-mode';
692
+ export { STORE_MODES, storeMode } from './store-mode';
661
693
  export { timingSafeEqual } from './timing-safe-equal';
662
694
  export {
663
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
  /**
@@ -121,6 +174,28 @@ export function setLogStream(stream: 'stdout' | 'stderr'): void {
121
174
  logStream = stream;
122
175
  }
123
176
 
177
+ /** What `setLogSink` installs: one complete JSON line, and the level it was written at. */
178
+ export type LogSink = (line: string, level: LogLevel) => void;
179
+
180
+ let logSink: LogSink | undefined;
181
+
182
+ /**
183
+ * TEST SEAM. Every line with no explicit `writer` goes to `sink` INSTEAD of the process's streams,
184
+ * until it is cleared with `undefined`. Returns the sink that was installed, so a caller restores
185
+ * rather than clears — the shape `setRowObserver` has, for the same shared-process reason.
186
+ *
187
+ * It exists for two callers. A test preload installs a sink that drops every line, so a green run
188
+ * prints its reporter and nothing else; and a test that asserts on what the PROCESS logger wrote
189
+ * installs one that collects, instead of patching `process.stdout`. A logger given its own
190
+ * `writer` never reaches it, and the level is untouched: this decides where a line goes, never
191
+ * whether it is written.
192
+ */
193
+ export function setLogSink(sink: LogSink | undefined): LogSink | undefined {
194
+ const previous = logSink;
195
+ logSink = sink;
196
+ return previous;
197
+ }
198
+
124
199
  /**
125
200
  * The second half of the same defect, one call deeper than `envLevel`. A module init made safe
126
201
  * that still reached `process.stdout` here would only move the `ReferenceError` from load to the
@@ -135,6 +210,10 @@ export function setLogStream(stream: 'stdout' | 'stderr'): void {
135
210
  * its log stream, and where there is a `process` this writes to the fd as it always did.
136
211
  */
137
212
  function defaultWriter(line: string, level: LogLevel): void {
213
+ if (logSink !== undefined) {
214
+ logSink(line, level);
215
+ return;
216
+ }
138
217
  const toStderr = logStream === 'stderr' || LEVEL_WEIGHT[level] >= LEVEL_WEIGHT.error;
139
218
  if (typeof process === 'undefined') {
140
219
  if (toStderr) console.error(line);
@@ -198,7 +277,12 @@ function entryValue(source: Record<string, unknown>, key: string, depth: number)
198
277
  }
199
278
  }
200
279
 
201
- 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> {
202
286
  const out: Record<string, unknown> = {};
203
287
  const source = fields as Record<string, unknown>;
204
288
  // `Object.keys` before the values, so the read of each value is its own guarded step: a field
@@ -278,9 +362,18 @@ function timestamp(clock: Clock): string {
278
362
  */
279
363
  function envLevel(): LogLevel {
280
364
  const raw = typeof process === 'undefined' ? undefined : process.env['LOG_LEVEL'];
281
- return raw !== undefined && (LOG_LEVELS as readonly string[]).includes(raw)
282
- ? (raw as LogLevel)
283
- : '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;
284
377
  }
285
378
 
286
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);
package/src/otlp.ts CHANGED
@@ -96,7 +96,10 @@ function parseEndpoint(signal: OtlpSignal, raw: string, perSignal: boolean, env:
96
96
  // The spec's own asymmetry, not ours: a per-signal endpoint is the full URL an operator chose,
97
97
  // while the generic one is a base the signal path is appended to.
98
98
  if (perSignal) return url.toString();
99
- return `${url.toString().replace(/\/+$/, '')}/v1/${signal}`;
99
+ // On the PATH, never on the string: `http://collector:4318?tenant=a` concatenated to
100
+ // `…/?tenant=a/v1/traces`, a request to `/` whose query merely ends in the receiver's path.
101
+ url.pathname = `${url.pathname.replace(/\/+$/, '')}/v1/${signal}`;
102
+ return url.toString();
100
103
  }
101
104
 
102
105
  /** The endpoint an operator configured, or `undefined` when they configured none. */
@@ -157,32 +160,43 @@ export function otlpEndpoint(
157
160
  * and no fix. Refused instead, naming the variable and the header KEY: the value is the
158
161
  * collector's credential and a `cause:` is folded into a log line.
159
162
  */
160
- function decodeHeaderValue(key: string, raw: string): string {
163
+ function decodeHeaderValue(variable: string, key: string, raw: string): string {
161
164
  try {
162
165
  return decodeURIComponent(raw);
163
166
  } catch {
164
167
  throw new OtlpHeadersInvalidError({
165
- cause: `${OTLP_HEADERS_KEY} carries a malformed percent-escape in the "${key}" value, so the header cannot be decoded`,
166
- fix: `set ${OTLP_HEADERS_KEY}=${key}=<encoded>, where <encoded> is what bun -e 'console.log(encodeURIComponent(process.argv[1]))' <value> prints — or drop the stray % from the "${key}" value if it was meant literally`,
168
+ cause: `${variable} carries a malformed percent-escape in the "${key}" value, so the header cannot be decoded`,
169
+ fix: `set ${variable}=${key}=<encoded>, where <encoded> is what bun -e 'console.log(encodeURIComponent(process.argv[1]))' <value> prints — or drop the stray % from the "${key}" value if it was meant literally`,
167
170
  meta: { header: key },
168
171
  });
169
172
  }
170
173
  }
171
174
 
172
- /** `key=value,key2=value2`, percent-decoded — the spec's format for collector auth headers. */
175
+ /**
176
+ * `key=value,key2=value2`, percent-decoded — the spec's format for collector auth headers.
177
+ *
178
+ * With a `signal`, `OTEL_EXPORTER_OTLP_<SIGNAL>_HEADERS` REPLACES the generic variable for that
179
+ * signal, which is the spec's rule and the one `ENDPOINT` and `PROTOCOL` already followed here:
180
+ * only the generic one was read, so a collector that authenticates traces and metrics with
181
+ * different keys got the same key on both and rejected one of them.
182
+ */
173
183
  export function otlpHeaders(
174
184
  explicit?: Readonly<Record<string, string>> | undefined,
175
185
  env: OtlpEnv = process.env,
186
+ signal?: OtlpSignal | undefined,
176
187
  ): Record<string, string> {
177
188
  const headers: Record<string, string> = { 'content-type': 'application/json' };
178
- const raw = env[OTLP_HEADERS_KEY];
189
+ const specific = signal === undefined ? undefined : signalKey(signal, 'HEADERS');
190
+ const variable =
191
+ specific !== undefined && (env[specific] ?? '').trim() !== '' ? specific : OTLP_HEADERS_KEY;
192
+ const raw = env[variable];
179
193
  if (raw !== undefined) {
180
194
  for (const pair of raw.split(',')) {
181
195
  const index = pair.indexOf('=');
182
196
  if (index <= 0) continue;
183
197
  const key = pair.slice(0, index).trim().toLowerCase();
184
198
  if (key === '') continue;
185
- headers[key] = decodeHeaderValue(key, pair.slice(index + 1).trim());
199
+ headers[key] = decodeHeaderValue(variable, key, pair.slice(index + 1).trim());
186
200
  }
187
201
  }
188
202
  for (const [key, value] of Object.entries(explicit ?? {})) headers[key.toLowerCase()] = value;
@@ -202,22 +216,39 @@ export interface OtlpKeyValue {
202
216
  readonly value: OtlpAnyValue;
203
217
  }
204
218
 
205
- function anyValue(value: AttributeValue): OtlpAnyValue {
219
+ /**
220
+ * `undefined` for a number the wire cannot spell. `NaN` and `±Infinity` serialise as
221
+ * `{"doubleValue":null}`, and a validating collector rejects the WHOLE batch for it — `postOtlp`
222
+ * only warns, so one bad gauge silently cost every span beside it. Dropped, as a missing
223
+ * attribute is the honest reading of "not a number".
224
+ */
225
+ function anyValue(value: AttributeValue): OtlpAnyValue | undefined {
206
226
  if (typeof value === 'string') return { stringValue: value };
207
227
  if (typeof value === 'boolean') return { boolValue: value };
208
228
  if (typeof value === 'number') {
229
+ if (!Number.isFinite(value)) return undefined;
209
230
  // `intValue` is a 64-bit field, so the JSON encoding spells it as a string. A float that
210
- // happens to be integral is still a double to whoever queries it; `Number.isInteger` is the
211
- // only signal available and matches what every other OTLP/JSON encoder does.
212
- return Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value };
231
+ // happens to be integral is still a double to whoever queries it; SAFE-integer is the signal,
232
+ // because past 2^53 `String(value)` is `"1e+21"` — exponent notation is not an int64.
233
+ return Number.isSafeInteger(value) ? { intValue: String(value) } : { doubleValue: value };
234
+ }
235
+ const values: OtlpAnyValue[] = [];
236
+ for (const item of value) {
237
+ const encoded = anyValue(item);
238
+ if (encoded !== undefined) values.push(encoded);
213
239
  }
214
- return { arrayValue: { values: value.map((item) => anyValue(item)) } };
240
+ return { arrayValue: { values } };
215
241
  }
216
242
 
217
243
  export function otlpAttributes(
218
244
  attributes: Readonly<Record<string, AttributeValue>>,
219
245
  ): readonly OtlpKeyValue[] {
220
- return Object.entries(attributes).map(([key, value]) => ({ key, value: anyValue(value) }));
246
+ const out: OtlpKeyValue[] = [];
247
+ for (const [key, raw] of Object.entries(attributes)) {
248
+ const value = anyValue(raw);
249
+ if (value !== undefined) out.push({ key, value });
250
+ }
251
+ return out;
221
252
  }
222
253
 
223
254
  /** Epoch ms -> the string of nanoseconds OTLP/JSON wants, without losing precision to a float. */
package/src/page-meta.ts CHANGED
@@ -27,6 +27,13 @@ export const CLIENT_BUILD_META = 'x-ultimate-build';
27
27
  */
28
28
  export const APP_UPDATE_MESSAGE = 'AppUpdateAvailable';
29
29
 
30
+ /**
31
+ * How this server turns an action's name into its URL (`defineApi({ http: { pathStyle } })`), for
32
+ * the browser's `actionPath`. Written only for a style other than the default: absent IS
33
+ * `'resource'`, so an app that declares nothing renders the bytes it always did.
34
+ */
35
+ export const CLIENT_PATH_STYLE_META = 'ultimate-path-style';
36
+
30
37
  /** Where the page's one socket dials: `/_x/sync`, or the deployment's absolute `SYNC_URL`. */
31
38
  export const CLIENT_SYNC_META = 'ultimate-sync';
32
39
 
package/src/page.ts CHANGED
@@ -43,6 +43,7 @@ export {
43
43
  CLIENT_NAVIGATION_LOCATION_HEADER,
44
44
  CLIENT_NAVIGATION_SCOPE_HEADER,
45
45
  CLIENT_NAVIGATION_SURFACE_HEADER,
46
+ CLIENT_PATH_STYLE_META,
46
47
  CLIENT_PERSIST_META,
47
48
  CLIENT_SCOPE_HEADER,
48
49
  CLIENT_SCOPE_META,
@@ -0,0 +1,15 @@
1
+ // The one structural Postgres seam: `query(text, values)` answering rows. Declared at tier 0 so
2
+ // `@ultimat3/http`, `auth`, `action` and `jobs` share it while staying free of `@ultimat3/db` —
3
+ // four packages each declared it, and a copy is a fifth place for the contract to drift.
4
+
5
+ /**
6
+ * One method, positional parameters. **`Bun.sql` does not satisfy it** — `Bun.sql.query` is
7
+ * `undefined`; it is a tagged template whose positional form is `unsafe`, so `{ executor: Bun.sql }`
8
+ * would `TypeError` on the first statement. What satisfies it is a client that already speaks
9
+ * `(text, values)`, wrapped in one line — `@ultimat3/cli`'s `pgExecutorFor(client)` over
10
+ * `@ultimat3/db`'s `DbClient.query({ text, values })` is the framework's own — or a transaction
11
+ * handle, which is a client on its own connection. It answers rows, never a command tag.
12
+ */
13
+ export interface PgExecutor {
14
+ query<R>(sql: string, params: readonly unknown[]): Promise<readonly R[]>;
15
+ }