@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/CLAUDE.md +6 -0
- package/README.md +34 -3
- package/package.json +2 -2
- 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/cursor.ts +4 -1
- package/src/decimal-order.ts +5 -4
- package/src/dev-secrets.ts +1 -1
- package/src/error-reporter-sentry.ts +7 -3
- 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 +12 -0
- package/src/logger.ts +77 -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/pg-executor.ts +15 -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/secrets-errors.ts +14 -3
- package/src/source-mask.ts +14 -8
- package/src/store-mode.ts +23 -0
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
|
-
|
|
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
|
/**
|
package/src/decimal-order.ts
CHANGED
|
@@ -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
|
|
14
|
-
*
|
|
15
|
-
* because Postgres orders a `text` column holding `"10"` and
|
|
16
|
-
* guessing "both sides look like decimals" would disagree with
|
|
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. */
|
package/src/dev-secrets.ts
CHANGED
|
@@ -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
|
-
|
|
119
|
-
...(report.
|
|
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
|
};
|
package/src/flight-gate.ts
CHANGED
|
@@ -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
|
|
61
|
-
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 <
|
|
78
|
+
if (active < maxConcurrent) {
|
|
69
79
|
active += 1;
|
|
70
80
|
return;
|
|
71
81
|
}
|
|
72
|
-
|
|
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) =>
|
|
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
|
+
['&', '&'],
|
|
8
|
+
['<', '<'],
|
|
9
|
+
['>', '>'],
|
|
10
|
+
['"', '"'],
|
|
11
|
+
["'", '''],
|
|
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. `'` rather than `'`: 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
|
+
}
|
package/src/image/errors.ts
CHANGED
|
@@ -55,7 +55,9 @@ export const imageTooLarge = (
|
|
|
55
55
|
): ImageTooLargeError =>
|
|
56
56
|
new ImageTooLargeError(
|
|
57
57
|
cause,
|
|
58
|
-
|
|
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
|
|
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,
|
|
@@ -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
|
|
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
|
/**
|
|
@@ -224,7 +277,12 @@ function entryValue(source: Record<string, unknown>, key: string, depth: number)
|
|
|
224
277
|
}
|
|
225
278
|
}
|
|
226
279
|
|
|
227
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
/**
|
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);
|