@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/config.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { CURRENCY_CODE_PATTERN } from '@ultimat3/schema';
|
|
|
8
8
|
// drift into two vocabularies with no map between them (issue #293).
|
|
9
9
|
import { CACHE_TIERS, type CacheTierName } from './cache-vocabulary';
|
|
10
10
|
import { countIssue } from './config-count';
|
|
11
|
+
import { configDefaults } from './config-defaults';
|
|
11
12
|
import { BASE_FIX, CACHE_TIER_FIX, TIMEZONE_FIX } from './config-fixes';
|
|
12
13
|
import type { DrainConfig, HealthConfig } from './config-health';
|
|
13
14
|
import { readinessModeIssue } from './config-health';
|
|
@@ -18,15 +19,24 @@ import type { NavigationConfig, NavigationSectionInput } from './config-navigati
|
|
|
18
19
|
import { mergeNavigation, navigationIssues } from './config-navigation';
|
|
19
20
|
import type { PwaConfig, PwaOfflineConfig } from './config-pwa';
|
|
20
21
|
import { PWA_FIX, pwaIssues } from './config-pwa';
|
|
22
|
+
import {
|
|
23
|
+
booleanIssue,
|
|
24
|
+
localeIssues,
|
|
25
|
+
nameListIssues,
|
|
26
|
+
oneOfIssue,
|
|
27
|
+
routePathIssue,
|
|
28
|
+
shapeIssues,
|
|
29
|
+
} from './config-shape';
|
|
21
30
|
import type { SeoConfig, SiteConfig, SiteSectionsInput } from './config-site';
|
|
22
31
|
import { mergeSite, siteIssues } from './config-site';
|
|
23
32
|
import { describeValue } from './error-render';
|
|
24
33
|
import { ConfigInvalidError } from './errors';
|
|
25
|
-
import {
|
|
34
|
+
import { readinessGraceIssue } from './lifecycle-grace';
|
|
26
35
|
import { ROLES, type Role } from './roles';
|
|
27
36
|
import { isIanaZoneName } from './time-zone-name';
|
|
28
37
|
|
|
29
|
-
export
|
|
38
|
+
export const THEME_MODES = ['light', 'dark', 'system'] as const;
|
|
39
|
+
export type ThemeMode = (typeof THEME_MODES)[number];
|
|
30
40
|
/**
|
|
31
41
|
* The buses `@ultimat3/realtime`'s `selectTransport` builds, and nothing else. `'redis'` was in
|
|
32
42
|
* this union until 22.0.0 with no Redis transport anywhere: it booted whatever `NATS_URL` chose.
|
|
@@ -99,8 +109,10 @@ export const INBOX_RETENTION_KEYS = ['inboxReadRetentionMs', 'inboxUnreadRetenti
|
|
|
99
109
|
* 3 applied to configuration: a value that produces neither a build error nor a runtime effect is
|
|
100
110
|
* worse than no field, because an SRE sets `poolSize: 3`, redeploys, and nothing changes.
|
|
101
111
|
*/
|
|
112
|
+
const DATABASE_DRIVERS = ['postgres'] as const;
|
|
113
|
+
|
|
102
114
|
export interface DatabaseConfig {
|
|
103
|
-
readonly driver:
|
|
115
|
+
readonly driver: (typeof DATABASE_DRIVERS)[number];
|
|
104
116
|
readonly ssl: boolean;
|
|
105
117
|
}
|
|
106
118
|
|
|
@@ -125,6 +137,8 @@ export interface CacheConfig {
|
|
|
125
137
|
readonly tiers: readonly CacheTierName[];
|
|
126
138
|
}
|
|
127
139
|
|
|
140
|
+
const JOB_BACKOFFS = ['exponential', 'fixed'] as const;
|
|
141
|
+
|
|
128
142
|
export interface JobsConfig {
|
|
129
143
|
/**
|
|
130
144
|
* No `driver`. It accepted `'postgres' | 'redis' | 'nats'`, was read by NOTHING, and boot always
|
|
@@ -140,7 +154,7 @@ export interface JobsConfig {
|
|
|
140
154
|
readonly queues: readonly string[];
|
|
141
155
|
readonly concurrency: number;
|
|
142
156
|
readonly maxAttempts: number;
|
|
143
|
-
readonly backoff:
|
|
157
|
+
readonly backoff: (typeof JOB_BACKOFFS)[number];
|
|
144
158
|
readonly visibilityTimeoutMs: number;
|
|
145
159
|
}
|
|
146
160
|
|
|
@@ -255,52 +269,6 @@ const NAME_RE = /^[a-z][a-z0-9-]{1,63}$/;
|
|
|
255
269
|
*/
|
|
256
270
|
const CURRENCY_RE = new RegExp(CURRENCY_CODE_PATTERN);
|
|
257
271
|
|
|
258
|
-
function isLocale(value: string): boolean {
|
|
259
|
-
try {
|
|
260
|
-
return Intl.getCanonicalLocales(value).length === 1;
|
|
261
|
-
} catch {
|
|
262
|
-
return false;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
type Sectioned = 'name' | 'site' | 'seo' | 'navigation' | 'islands';
|
|
267
|
-
function defaults(name: string): Omit<AppConfig, Sectioned> {
|
|
268
|
-
return {
|
|
269
|
-
locales: ['en'],
|
|
270
|
-
defaultLocale: 'en',
|
|
271
|
-
defaultTimeZone: 'UTC',
|
|
272
|
-
defaultCurrency: 'USD',
|
|
273
|
-
theme: { defaultMode: 'system', tokens: {} },
|
|
274
|
-
auth: { signInPath: null },
|
|
275
|
-
pwa: {
|
|
276
|
-
enabled: false,
|
|
277
|
-
offline: { fallback: null, image: null, font: null, neverCache: [], personalPages: 'never' },
|
|
278
|
-
backgroundSync: false,
|
|
279
|
-
push: false,
|
|
280
|
-
name: '',
|
|
281
|
-
colors: undefined,
|
|
282
|
-
},
|
|
283
|
-
roles: [...ROLES],
|
|
284
|
-
database: { driver: 'postgres', ssl: false },
|
|
285
|
-
cache: { defaultTtlMs: 60_000, tiers: ['request-memo', 'lru'] },
|
|
286
|
-
jobs: {
|
|
287
|
-
queues: [`${name}-default`],
|
|
288
|
-
concurrency: 8,
|
|
289
|
-
maxAttempts: 5,
|
|
290
|
-
backoff: 'exponential',
|
|
291
|
-
visibilityTimeoutMs: 30_000,
|
|
292
|
-
},
|
|
293
|
-
// ON by default since 22.0.0, when the boot began obeying the key: an app with no section
|
|
294
|
-
// keeps the `sync` node it always got, and `enabled: false` is the explicit opt-out.
|
|
295
|
-
realtime: { enabled: true, transport: 'memory', urlEnv: undefined },
|
|
296
|
-
notify: { inboxReadRetentionMs: undefined, inboxUnreadRetentionMs: undefined },
|
|
297
|
-
ai: { mcp: { expose: true, path: '/mcp' } },
|
|
298
|
-
// Read from the process env when the config is DEFINED — the same env the drain will run in.
|
|
299
|
-
drain: { readinessGraceMs: defaultReadinessGraceMs() },
|
|
300
|
-
health: { readiness: 'dependencies' },
|
|
301
|
-
};
|
|
302
|
-
}
|
|
303
|
-
|
|
304
272
|
function validate(config: AppConfig): void {
|
|
305
273
|
const issues: string[] = [];
|
|
306
274
|
// Zero or one entry: the zone's own remedy, carried only when the zone is what failed.
|
|
@@ -311,16 +279,13 @@ function validate(config: AppConfig): void {
|
|
|
311
279
|
// Same shape again: carried only when an installable app is missing what an install needs.
|
|
312
280
|
const pwaFix: string[] = [];
|
|
313
281
|
|
|
314
|
-
|
|
282
|
+
// `typeof` first: `NAME_RE.test(undefined)` tests the string "undefined", which matches.
|
|
283
|
+
if (typeof config.name !== 'string') {
|
|
284
|
+
issues.push(`name must be a string like "my-app", not ${describeValue(config.name)}`);
|
|
285
|
+
} else if (!NAME_RE.test(config.name)) {
|
|
315
286
|
issues.push(`name "${config.name}" must match ${String(NAME_RE)}`);
|
|
316
287
|
}
|
|
317
|
-
|
|
318
|
-
for (const locale of config.locales) {
|
|
319
|
-
if (!isLocale(locale)) issues.push(`locales contains "${locale}", not a BCP-47 tag`);
|
|
320
|
-
}
|
|
321
|
-
if (!config.locales.includes(config.defaultLocale)) {
|
|
322
|
-
issues.push(`defaultLocale "${config.defaultLocale}" is not in locales`);
|
|
323
|
-
}
|
|
288
|
+
localeIssues(config.locales, config.defaultLocale, issues);
|
|
324
289
|
// `@ultimat3/time`'s rule, restated because tier 0 cannot import tier 1 — see
|
|
325
290
|
// `time-zone-name.ts`. One validator means a zone `app.config.ts` accepts is a zone every
|
|
326
291
|
// `format` call, `task()` and `toZoned` below it can then do arithmetic in.
|
|
@@ -334,25 +299,33 @@ function validate(config: AppConfig): void {
|
|
|
334
299
|
issues.push(`defaultCurrency "${config.defaultCurrency}" is not a 3-letter ISO 4217 code`);
|
|
335
300
|
}
|
|
336
301
|
if (config.roles.length === 0) issues.push('roles must list at least one runtime role');
|
|
337
|
-
//
|
|
338
|
-
// `concurrency < 1` passed `NaN`, `2.5` and `Infinity
|
|
339
|
-
|
|
302
|
+
// ONE check per key, each against the key's own domain. A number is never a bare `< 1` — every
|
|
303
|
+
// comparison with `NaN` is false, so `concurrency < 1` passed `NaN`, `2.5` and `Infinity` — and
|
|
304
|
+
// a switch is never read for truthiness: `ssl: 'false'` and `enabled: 'false'` were both ON.
|
|
305
|
+
const perKey: readonly (string | undefined)[] = [
|
|
306
|
+
...config.roles.map((role) => oneOfIssue('roles', role, ROLES)),
|
|
340
307
|
countIssue('jobs.concurrency', config.jobs.concurrency, 1),
|
|
341
308
|
countIssue('jobs.maxAttempts', config.jobs.maxAttempts, 1),
|
|
342
309
|
countIssue('jobs.visibilityTimeoutMs', config.jobs.visibilityTimeoutMs, 1),
|
|
310
|
+
oneOfIssue('jobs.backoff', config.jobs.backoff, JOB_BACKOFFS),
|
|
343
311
|
countIssue('cache.defaultTtlMs', config.cache.defaultTtlMs, 0),
|
|
312
|
+
oneOfIssue('database.driver', config.database.driver, DATABASE_DRIVERS),
|
|
313
|
+
booleanIssue('database.ssl', config.database.ssl),
|
|
314
|
+
oneOfIssue('theme.defaultMode', config.theme.defaultMode, THEME_MODES),
|
|
315
|
+
// `null` is the documented "no redirect"; a path that is said must be one a browser can follow.
|
|
316
|
+
config.auth.signInPath === null
|
|
317
|
+
? undefined
|
|
318
|
+
: routePathIssue('auth.signInPath', config.auth.signInPath),
|
|
319
|
+
booleanIssue('ai.mcp.expose', config.ai.mcp.expose),
|
|
320
|
+
routePathIssue('ai.mcp.path', config.ai.mcp.path),
|
|
321
|
+
booleanIssue('realtime.enabled', config.realtime.enabled),
|
|
322
|
+
oneOfIssue('realtime.transport', config.realtime.transport, REALTIME_TRANSPORTS),
|
|
344
323
|
readinessGraceIssue(config.drain.readinessGraceMs),
|
|
345
324
|
readinessModeIssue(config.health.readiness),
|
|
346
325
|
];
|
|
347
|
-
for (const issue of
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
// anything else goes through `describeValue` rather than `${…}`.
|
|
351
|
-
const transport: unknown = config.realtime.transport;
|
|
352
|
-
if (!REALTIME_TRANSPORTS.some((known) => known === transport)) {
|
|
353
|
-
const said = typeof transport === 'string' ? `"${transport}"` : describeValue(transport);
|
|
354
|
-
issues.push(`realtime.transport ${said} is not one of ${REALTIME_TRANSPORTS.join(', ')}`);
|
|
355
|
-
} else if (transport === 'nats' && config.realtime.urlEnv === undefined) {
|
|
326
|
+
for (const issue of perKey) if (issue !== undefined) issues.push(issue);
|
|
327
|
+
nameListIssues('jobs.queues', config.jobs.queues, 'queue', issues);
|
|
328
|
+
if (config.realtime.transport === 'nats' && config.realtime.urlEnv === undefined) {
|
|
356
329
|
issues.push(`realtime.transport "nats" requires realtime.urlEnv`);
|
|
357
330
|
}
|
|
358
331
|
// BOTH RETENTION WINDOWS OR NEITHER — `undefined` is a real value here (never swept) and the
|
|
@@ -381,6 +354,9 @@ function validate(config: AppConfig): void {
|
|
|
381
354
|
// A rung the ladder cannot build is the defect this key had: `sortTiers` places a name by its
|
|
382
355
|
// index in `CACHE_TIERS`, and a name missing from it sorts to `-1` — AHEAD of the request memo.
|
|
383
356
|
// So an unknown tier is refused at boot rather than silently ignored or silently placed first.
|
|
357
|
+
// An EMPTY ladder is refused with the unknown rung: it builds no tier at all, so every read
|
|
358
|
+
// misses and nothing says the cache was configured away.
|
|
359
|
+
if (config.cache.tiers.length === 0) issues.push('cache.tiers must list at least one tier');
|
|
384
360
|
for (const tier of config.cache.tiers) {
|
|
385
361
|
if (CACHE_TIERS.includes(tier)) continue;
|
|
386
362
|
issues.push(`cache.tiers contains "${tier}", which is not one of ${CACHE_TIERS.join(', ')}`);
|
|
@@ -399,20 +375,14 @@ function validate(config: AppConfig): void {
|
|
|
399
375
|
}
|
|
400
376
|
|
|
401
377
|
/**
|
|
402
|
-
*
|
|
403
|
-
*
|
|
378
|
+
* Every layer, in order, merged per section and KEY BY KEY — see `layered`. `name` comes from the
|
|
379
|
+
* input alone because it identifies the app: an overlay may not rename it. No layer at all is the
|
|
380
|
+
* defaults, which is the reference `shapeIssues` compares each layer against.
|
|
404
381
|
*/
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
const base = defaults(input.name);
|
|
410
|
-
// Every layer, in order, merged per section and KEY BY KEY — see `layered`. `name` comes from
|
|
411
|
-
// the input alone because it identifies the app: an overlay may not rename it.
|
|
412
|
-
const layers: readonly AppConfigOverlay[] = [input, ...overlays];
|
|
413
|
-
|
|
414
|
-
const config: AppConfig = {
|
|
415
|
-
name: input.name,
|
|
382
|
+
function merge(name: string, layers: readonly AppConfigOverlay[]): AppConfig {
|
|
383
|
+
const base = configDefaults(name);
|
|
384
|
+
return {
|
|
385
|
+
name,
|
|
416
386
|
locales: lastSaid(
|
|
417
387
|
base.locales,
|
|
418
388
|
layers.map((layer) => layer.locales),
|
|
@@ -492,7 +462,28 @@ export function defineConfig(
|
|
|
492
462
|
...mergeNavigation(layers),
|
|
493
463
|
...mergeIslands(layers),
|
|
494
464
|
};
|
|
465
|
+
}
|
|
495
466
|
|
|
467
|
+
/**
|
|
468
|
+
* The single config entry point. Later overlays win, so `config/jobs.ts` can own jobs without
|
|
469
|
+
* touching `app.config.ts`.
|
|
470
|
+
*/
|
|
471
|
+
export function defineConfig(
|
|
472
|
+
input: AppConfigInput,
|
|
473
|
+
...overlays: readonly AppConfigOverlay[]
|
|
474
|
+
): AppConfig {
|
|
475
|
+
const layers: readonly AppConfigOverlay[] = [input, ...overlays];
|
|
476
|
+
// Structure FIRST, per layer and before the merge: a section written as `null` or a list
|
|
477
|
+
// written as a string is what `Object.entries` and `.length` raised a native `TypeError` on.
|
|
478
|
+
const issues: string[] = [];
|
|
479
|
+
// `navigation` is left out: `config-navigation.ts` carries a wrong shape through AS WRITTEN and
|
|
480
|
+
// refuses it in its own words, with the surfaces it accepts.
|
|
481
|
+
const reference = { ...merge(input.name, []), navigation: undefined };
|
|
482
|
+
for (const layer of layers) shapeIssues(reference, layer, issues);
|
|
483
|
+
if (issues.length > 0) {
|
|
484
|
+
throw new ConfigInvalidError({ cause: issues.join('; '), fix: BASE_FIX, meta: { issues } });
|
|
485
|
+
}
|
|
486
|
+
const config = merge(input.name, layers);
|
|
496
487
|
validate(config);
|
|
497
488
|
return Object.freeze(config);
|
|
498
489
|
}
|
package/src/context.ts
CHANGED
|
@@ -272,6 +272,18 @@ function screenDeadline(value: number | null | undefined): number | null {
|
|
|
272
272
|
return finiteOption('the request context', 'deadlineAt', value);
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
/**
|
|
276
|
+
* A patched signal is ADDED to the parent's, never swapped for it — the deadline's own rule, one
|
|
277
|
+
* field over. `patch ?? parent` made a step that brought its own signal blind to the request it
|
|
278
|
+
* runs inside: the client disconnected, the request timed out, and `ctx.signal.aborted` stayed
|
|
279
|
+
* false in the child. The shared never-aborting default is skipped rather than composed, so a
|
|
280
|
+
* context with no request behind it does not grow a listener per child.
|
|
281
|
+
*/
|
|
282
|
+
function composeSignal(parent: AbortSignal, patch: AbortSignal | undefined): AbortSignal {
|
|
283
|
+
if (patch === undefined || patch === parent) return parent;
|
|
284
|
+
return parent === neverAborted ? patch : AbortSignal.any([parent, patch]);
|
|
285
|
+
}
|
|
286
|
+
|
|
275
287
|
/**
|
|
276
288
|
* Derive a narrowed context — impersonation, a locale switch, a per-step abort signal.
|
|
277
289
|
* `requestId` is deliberately not patchable: one request, one id.
|
|
@@ -301,7 +313,7 @@ export function withChildContext<T>(patch: CtxPatch, fn: () => T): T {
|
|
|
301
313
|
// that and did not do it — a patched hour replaced a parent's second outright, and
|
|
302
314
|
// `remainingBudgetMs` then put the hour on `x-request-timeout-ms` for the next hop.
|
|
303
315
|
deadlineAt: earliest(screenDeadline(patch.deadlineAt) ?? undefined, parent.deadlineAt),
|
|
304
|
-
signal:
|
|
316
|
+
signal: composeSignal(parent.signal, patch.signal),
|
|
305
317
|
services: { ...carried, ...(patch.services ?? {}) },
|
|
306
318
|
});
|
|
307
319
|
return requestContext.run(child, fn);
|
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/core-error-codes.ts
CHANGED
|
@@ -54,6 +54,11 @@ const CORE_CODE_TITLES = {
|
|
|
54
54
|
X_REGISTRAR_CONFLICT: 'two different registrars are loaded for one primitive kind',
|
|
55
55
|
X_REGISTRAR_MISSING: 'no registrar is loaded for a primitive kind',
|
|
56
56
|
X_ROLE_INVALID: 'ROLE is not a known runtime role',
|
|
57
|
+
// `seal.ts`'s three. Titled here, not registered beside their classes the way the X_SECRETS_*
|
|
58
|
+
// set is, so `seal-errors.ts` runs nothing at import and is no `sideEffects` anchor.
|
|
59
|
+
X_SEAL_INVALID: 'a sealed value did not authenticate, or is not a sealed value',
|
|
60
|
+
X_SEAL_KEY_MISSING: 'no master key to seal or open a value with',
|
|
61
|
+
X_SEAL_KEY_UNKNOWN: 'a sealed value names a master key this process does not declare',
|
|
57
62
|
X_SERVICE_DUPLICATE: 'a service name is registered twice',
|
|
58
63
|
X_SERVICE_MISSING: 'service is not registered on the request context',
|
|
59
64
|
X_SHUTDOWN_TIMEOUT: 'graceful shutdown exceeded its deadline',
|
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
|
});
|
package/src/error-render.ts
CHANGED
|
@@ -188,9 +188,11 @@ export function renderFixLiteral(value: unknown, placeholder: string): string {
|
|
|
188
188
|
* denylist has to be right about every character every shell will ever read, and this only has to
|
|
189
189
|
* be right about the ones a fix line needs. No space, so a value is always one word; no leading
|
|
190
190
|
* `-` or `~`, because an argument starting with either is an OPTION or a home directory rather
|
|
191
|
-
* than the value it reads as.
|
|
191
|
+
* than the value it reads as. A leading `@` IS carried: a scoped package name starts with one, and
|
|
192
|
+
* `@` opens nothing in a POSIX shell — `$@` needs the `$`, an extglob `@(…)` the parenthesis, and
|
|
193
|
+
* neither is in the set.
|
|
192
194
|
*/
|
|
193
|
-
const SHELL_ARG_SAFE = /^[A-Za-z0-9
|
|
195
|
+
const SHELL_ARG_SAFE = /^[A-Za-z0-9/@][A-Za-z0-9._:/@=+,%~-]*$/;
|
|
194
196
|
|
|
195
197
|
/**
|
|
196
198
|
* The same value where the text is read by a SHELL. A `fix:` is a command meant to be pasted, so a
|
|
@@ -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/error-retry.ts
CHANGED
|
@@ -61,6 +61,14 @@ const CORE_ERROR_RETRY: ReadonlyMap<string, ErrorRetry> = new Map(
|
|
|
61
61
|
// The principal fence's twin of `X_SUPERSEDED`, listed for the same reason: re-sending a read
|
|
62
62
|
// from the previous principal's scope is refused identically every time.
|
|
63
63
|
X_CLIENT_SCOPE_CHANGED: 'terminal',
|
|
64
|
+
// `seal.ts`'s three, listed for `X_NOT_IMPLEMENTED`'s reason: a missing key, an undeclared
|
|
65
|
+
// key and a value that failed its tag are each the same answer on attempt five, and left
|
|
66
|
+
// unclassified a job opening a sealed column would spend its whole retry policy re-proving it.
|
|
67
|
+
// Here rather than through `registerErrorRetry` because they are core's own codes, which that
|
|
68
|
+
// function refuses, and a module-scope call would make `seal-errors.ts` a side-effect anchor.
|
|
69
|
+
X_SEAL_INVALID: 'terminal',
|
|
70
|
+
X_SEAL_KEY_MISSING: 'terminal',
|
|
71
|
+
X_SEAL_KEY_UNKNOWN: 'terminal',
|
|
64
72
|
} as const),
|
|
65
73
|
);
|
|
66
74
|
|
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
|
|