@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.
- package/CLAUDE.md +23 -2
- package/README.md +93 -4
- package/package.json +2 -2
- package/src/client-paths.ts +26 -6
- package/src/config-defaults.ts +46 -0
- package/src/config-merge.ts +8 -0
- package/src/config-shape.ts +112 -0
- package/src/config-site.ts +14 -3
- package/src/config.ts +74 -83
- package/src/context.ts +13 -1
- package/src/cookie.ts +35 -0
- package/src/core-error-codes.ts +5 -0
- package/src/cursor.ts +4 -1
- package/src/decimal-order.ts +5 -4
- package/src/dev-secrets.ts +1 -1
- package/src/error-render.ts +4 -2
- package/src/error-reporter-sentry.ts +7 -3
- package/src/error-retry.ts +8 -0
- package/src/flight-gate.ts +16 -4
- package/src/fnv1a.ts +19 -0
- package/src/health-disclosure.ts +43 -0
- package/src/host-rules.ts +28 -1
- package/src/html-escape.ts +24 -0
- package/src/image/errors.ts +3 -1
- package/src/image/png-pixels.ts +29 -6
- package/src/image/probe.ts +7 -2
- package/src/image/raster.ts +3 -1
- package/src/index.ts +32 -0
- package/src/logger.ts +103 -10
- package/src/nearest-name.ts +11 -2
- package/src/otlp-metric-exporter.ts +1 -1
- package/src/otlp-span-exporter.ts +1 -1
- package/src/otlp.ts +44 -13
- package/src/page-meta.ts +7 -0
- package/src/page.ts +1 -0
- package/src/pg-executor.ts +15 -0
- package/src/process-metrics.ts +206 -0
- package/src/public-cause.ts +37 -0
- package/src/registrar.ts +21 -4
- package/src/retry.ts +15 -2
- package/src/route-rank.ts +36 -0
- package/src/same-origin.ts +1 -1
- package/src/sampler.ts +6 -2
- package/src/seal-errors.ts +76 -0
- package/src/seal-keys.ts +121 -0
- package/src/seal.ts +259 -0
- package/src/secrets-errors.ts +33 -1
- package/src/secrets.ts +21 -11
- package/src/source-mask.ts +14 -8
- package/src/store-mode.ts +23 -0
package/src/image/png-pixels.ts
CHANGED
|
@@ -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
|
-
/**
|
|
109
|
-
|
|
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
|
|
127
|
-
|
|
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
|
-
|
|
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}`,
|
package/src/image/probe.ts
CHANGED
|
@@ -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
|
-
/**
|
|
70
|
-
|
|
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;
|
package/src/image/raster.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
58
|
-
* `refreshToken` sat here for three releases matching nothing — and those
|
|
59
|
-
* names on `@ultimat3/auth`'s `OAuthTokens`.
|
|
60
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
/**
|
package/src/nearest-name.ts
CHANGED
|
@@ -27,7 +27,16 @@ const distance = (a: string, b: string): number => {
|
|
|
27
27
|
const MAX_EDITS = 3;
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
* The
|
|
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
|
-
|
|
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: `${
|
|
166
|
-
fix: `set ${
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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;
|
|
211
|
-
//
|
|
212
|
-
return Number.
|
|
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
|
|
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
|
-
|
|
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
|
@@ -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
|
+
}
|