@ultimat3/core 21.0.0 → 22.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 +189 -527
- package/README.md +39 -1
- package/package.json +2 -2
- package/src/address-class.ts +143 -0
- package/src/canonical-json.ts +24 -1
- package/src/client-flight.ts +5 -2
- package/src/config-count.ts +19 -0
- package/src/config-fixes.ts +23 -0
- package/src/config-merge.ts +36 -0
- package/src/config.ts +122 -65
- package/src/core-error-codes.ts +1 -0
- package/src/dev-secrets.ts +45 -0
- package/src/exports/secrets.ts +1 -0
- package/src/host-rules.ts +71 -0
- package/src/image/exif-orientation.ts +40 -0
- package/src/image/probe.ts +13 -1
- package/src/in-process-fetch.ts +39 -0
- package/src/index.ts +26 -29
- package/src/iso-date.ts +5 -0
- package/src/lifecycle-grace.ts +44 -0
- package/src/lifecycle-signals.ts +35 -0
- package/src/lifecycle.ts +44 -36
- package/src/logger.ts +22 -3
- package/src/measurement-actor.ts +52 -0
- package/src/metrics-text.ts +10 -2
- package/src/otlp-metric-exporter.ts +39 -12
- package/src/otlp-span-exporter.ts +38 -15
- package/src/secrets-store.ts +51 -5
- package/src/source-mask.ts +30 -0
- package/src/type-pins.ts +9 -0
- package/src/result.ts +0 -78
package/README.md
CHANGED
|
@@ -8,7 +8,6 @@ Zero dependencies, zero `@ultimat3/*` imports.
|
|
|
8
8
|
| `UltimateError`, the 3-line rendering, `--json` shape | `errors.ts` |
|
|
9
9
|
| rendering an app's value into a `cause` / `fix` without throwing | `error-render.ts` |
|
|
10
10
|
| code → `{ title, docs }` registry, `registerErrorCodes()` | `error-codes.ts` |
|
|
11
|
-
| `Result<T, E>` for boundaries where throwing is wrong | `result.ts` |
|
|
12
11
|
| the one lazy `AsyncLocalStorage`, every ambient scope in the framework | `async-context.ts` |
|
|
13
12
|
| request context on that seam | `context.ts` |
|
|
14
13
|
| `Actor` (`user \| service \| agent \| anonymous`) | `actor.ts` |
|
|
@@ -33,10 +32,12 @@ Zero dependencies, zero `@ultimat3/*` imports.
|
|
|
33
32
|
| typed env validated at boot | `env.ts` |
|
|
34
33
|
| `.env.example` rendered from that schema, and its drift check | `env-example.ts` |
|
|
35
34
|
| named environments + `ULTIMATE_ENV` resolution | `environment.ts` |
|
|
35
|
+
| the boot refusal of a shipped dev signing secret outside development/test — `X_CURSOR_SECRET_DEV` | `dev-secrets.ts` |
|
|
36
36
|
| a value that cannot be printed by accident | `secret.ts` |
|
|
37
37
|
| the committed encrypted secrets envelope, AES-256-GCM | `secrets.ts` |
|
|
38
38
|
| the two secrets files, and decrypted values → `defineEnv` | `secrets-store.ts` |
|
|
39
39
|
| `defineConfig()` for `app.config.ts` | `config.ts` |
|
|
40
|
+
| how overlays layer onto it — per section, key by key | `config-merge.ts` |
|
|
40
41
|
| the `pwa` block — what an install needs, and the boot refusal when it is not there | `config-pwa.ts` |
|
|
41
42
|
| the closed route vocabulary every renderer names | `route-vocabulary.ts` |
|
|
42
43
|
| runtime roles + `ROLE` resolution | `roles.ts` |
|
|
@@ -54,6 +55,9 @@ Zero dependencies, zero `@ultimat3/*` imports.
|
|
|
54
55
|
| the `/metrics` scrape body | `metrics-text.ts` |
|
|
55
56
|
| the series every process emits, incl. what the chart scales on | `runtime-metrics.ts` |
|
|
56
57
|
| graceful drain, `/healthz`, `/readyz` | `lifecycle.ts` |
|
|
58
|
+
| the readiness grace between `/readyz` → 503 and the listener closing (`drain.readinessGraceMs`) | `lifecycle-grace.ts` |
|
|
59
|
+
| SIGTERM/SIGINT → the one drain | `lifecycle-signals.ts` |
|
|
60
|
+
| which network an IP literal belongs to — `classifyAddress`, for SSRF screens | `address-class.ts` |
|
|
57
61
|
| the sockets this process opened, so a self-request is not egress | `listeners.ts` |
|
|
58
62
|
| `defineService('orgs', …)` → `ctx.orgs`, rebuilt per actor | `service.ts` |
|
|
59
63
|
| the registrar table one same-tier package reaches another through | `registrar.ts` |
|
|
@@ -164,6 +168,40 @@ Enforced, not documented: `x verify`'s `errors` step fails with `X_ERROR_RENDER_
|
|
|
164
168
|
parameter typed `unknown` reaches a `cause:` or `fix:` through `JSON.stringify`, `String()` or a
|
|
165
169
|
bare interpolation (`scripts/error-render.ts`).
|
|
166
170
|
|
|
171
|
+
### Error classes
|
|
172
|
+
|
|
173
|
+
Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
|
|
174
|
+
a job boundary the class is gone and the `code` is what survives — match on that.
|
|
175
|
+
|
|
176
|
+
| Class | Code | Declared in |
|
|
177
|
+
|---|---|---|
|
|
178
|
+
| `ConfigInvalidError` | `X_CONFIG_INVALID` | `src/errors.ts` |
|
|
179
|
+
| `CursorInvalidError` | `X_CURSOR_INVALID` | `src/cursor.ts` |
|
|
180
|
+
| `CursorSecretDevError` | `X_CURSOR_SECRET_DEV` | `src/dev-secrets.ts` |
|
|
181
|
+
| `EnvExampleDriftError` | `X_ENV_EXAMPLE_DRIFT` | `src/env-example.ts` |
|
|
182
|
+
| `EnvironmentInvalidError` | `X_ENVIRONMENT_INVALID` | `src/environment.ts` |
|
|
183
|
+
| `EnvMissingError` | `X_ENV_MISSING` | `src/errors.ts` |
|
|
184
|
+
| `ErrorReporterDsnInvalidError` | `X_ERROR_REPORTER_DSN_INVALID` | `src/error-reporter-sentry.ts` |
|
|
185
|
+
| `ImageDecodeFailedError` | `X_IMAGE_DECODE_FAILED` | `src/image/errors.ts` |
|
|
186
|
+
| `ImageTooLargeError` | `X_IMAGE_TOO_LARGE` | `src/image/errors.ts` |
|
|
187
|
+
| `ImageUnsupportedError` | `X_IMAGE_UNSUPPORTED` | `src/image/errors.ts` |
|
|
188
|
+
| `InternalError` | `X_INTERNAL` | `src/errors.ts` |
|
|
189
|
+
| `MetricCardinalityError` | `X_METRIC_CARDINALITY` | `src/metrics.ts` |
|
|
190
|
+
| `MetricNameInvalidError` | `X_METRIC_NAME_INVALID` | `src/metric-names.ts` |
|
|
191
|
+
| `MetricValueInvalidError` | `X_METRIC_VALUE_INVALID` | `src/metrics.ts` |
|
|
192
|
+
| `NotImplementedError` | `X_NOT_IMPLEMENTED` | `src/errors.ts` |
|
|
193
|
+
| `OtlpEndpointInvalidError` | `X_OTLP_ENDPOINT_INVALID` | `src/otlp.ts` |
|
|
194
|
+
| `OtlpHeadersInvalidError` | `X_OTLP_HEADERS_INVALID` | `src/otlp.ts` |
|
|
195
|
+
| `OtlpProtocolUnsupportedError` | `X_OTLP_PROTOCOL_UNSUPPORTED` | `src/otlp.ts` |
|
|
196
|
+
| `SecretsFileInvalidError` | `X_SECRETS_FILE_INVALID` | `src/secrets-errors.ts` |
|
|
197
|
+
| `SecretsFileMissingError` | `X_SECRETS_FILE_MISSING` | `src/secrets-errors.ts` |
|
|
198
|
+
| `SecretsKeyInvalidError` | `X_SECRETS_KEY_INVALID` | `src/secrets-errors.ts` |
|
|
199
|
+
| `SecretsKeyMismatchError` | `X_SECRETS_KEY_MISMATCH` | `src/secrets-errors.ts` |
|
|
200
|
+
| `SecretsKeyMissingError` | `X_SECRETS_KEY_MISSING` | `src/secrets-errors.ts` |
|
|
201
|
+
| `SecretsPlaintextInvalidError` | `X_SECRETS_PLAINTEXT_INVALID` | `src/secrets-errors.ts` |
|
|
202
|
+
| `SecretsTamperedError` | `X_SECRETS_TAMPERED` | `src/secrets-errors.ts` |
|
|
203
|
+
| `UltimateError` | any registered code — every class in the framework extends it | `src/errors.ts` |
|
|
204
|
+
|
|
167
205
|
## Context
|
|
168
206
|
|
|
169
207
|
```ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "22.0.0",
|
|
4
4
|
"description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -40,6 +40,6 @@
|
|
|
40
40
|
"test": "bun test"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@ultimat3/schema": "
|
|
43
|
+
"@ultimat3/schema": "22.0.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Single responsibility: which kind of network an IP address literal belongs to. Tier 0 because the
|
|
2
|
+
// SSRF screens that need it sit in packages that may not import each other — `@ultimat3/jobs`'
|
|
3
|
+
// webhook delivery (tier 3) and `@ultimat3/scraping` (tier 5) — and a copy per screen is how one
|
|
4
|
+
// of them comes to miss `::ffff:127.0.0.1`.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `reserved` is every range that is neither a host network nor public: multicast, broadcast,
|
|
8
|
+
* `240.0.0.0/4`, the documentation and benchmarking nets. A screen allowing only `public` refuses
|
|
9
|
+
* all of them, which is the point of naming them rather than calling them public.
|
|
10
|
+
*/
|
|
11
|
+
export type AddressClass =
|
|
12
|
+
| 'loopback'
|
|
13
|
+
| 'private'
|
|
14
|
+
| 'link-local'
|
|
15
|
+
| 'ula'
|
|
16
|
+
| 'cgnat'
|
|
17
|
+
| 'unspecified'
|
|
18
|
+
| 'reserved'
|
|
19
|
+
| 'public';
|
|
20
|
+
|
|
21
|
+
/** `[network, prefixBits]` over the 32-bit value; ordered, first match wins. */
|
|
22
|
+
const V4_RULES: readonly (readonly [number, number, AddressClass])[] = [
|
|
23
|
+
[0x00000000, 8, 'unspecified'],
|
|
24
|
+
[0x7f000000, 8, 'loopback'],
|
|
25
|
+
[0x0a000000, 8, 'private'],
|
|
26
|
+
[0xac100000, 12, 'private'],
|
|
27
|
+
[0xc0a80000, 16, 'private'],
|
|
28
|
+
[0xa9fe0000, 16, 'link-local'],
|
|
29
|
+
[0x64400000, 10, 'cgnat'],
|
|
30
|
+
[0xc0000000, 24, 'reserved'], // 192.0.0.0/24, IETF protocol assignments
|
|
31
|
+
[0xc0000200, 24, 'reserved'], // 192.0.2.0/24, TEST-NET-1
|
|
32
|
+
[0xc6120000, 15, 'reserved'], // 198.18.0.0/15, benchmarking
|
|
33
|
+
[0xc6336400, 24, 'reserved'], // 198.51.100.0/24, TEST-NET-2
|
|
34
|
+
[0xcb007100, 24, 'reserved'], // 203.0.113.0/24, TEST-NET-3
|
|
35
|
+
[0xe0000000, 4, 'reserved'], // multicast
|
|
36
|
+
[0xf0000000, 4, 'reserved'], // 240.0.0.0/4 and the broadcast address inside it
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Strict dotted quad: four decimal octets, no leading zero. `010.0.0.1` is octal to some parsers
|
|
41
|
+
* and decimal to others, so it is not an address this module will vouch for — a screen that
|
|
42
|
+
* refuses what it cannot classify is the only safe reader of it.
|
|
43
|
+
*/
|
|
44
|
+
function parseV4(text: string): number | undefined {
|
|
45
|
+
const parts = text.split('.');
|
|
46
|
+
if (parts.length !== 4) return undefined;
|
|
47
|
+
let value = 0;
|
|
48
|
+
for (const part of parts) {
|
|
49
|
+
if (!/^(?:0|[1-9]\d{0,2})$/.test(part)) return undefined;
|
|
50
|
+
const octet = Number(part);
|
|
51
|
+
if (octet > 255) return undefined;
|
|
52
|
+
value = value * 256 + octet;
|
|
53
|
+
}
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function classifyV4(value: number): AddressClass {
|
|
58
|
+
for (const [network, bits, kind] of V4_RULES) {
|
|
59
|
+
const size = 2 ** (32 - bits);
|
|
60
|
+
if (value >= network && value < network + size) return kind;
|
|
61
|
+
}
|
|
62
|
+
return 'public';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Eight 16-bit groups, or `undefined`. A trailing dotted quad fills the last two. */
|
|
66
|
+
function parseV6(text: string): readonly number[] | undefined {
|
|
67
|
+
let body = text;
|
|
68
|
+
let tail: number[] = [];
|
|
69
|
+
const lastColon = body.lastIndexOf(':');
|
|
70
|
+
if (body.includes('.', lastColon)) {
|
|
71
|
+
const v4 = parseV4(body.slice(lastColon + 1));
|
|
72
|
+
if (v4 === undefined) return undefined;
|
|
73
|
+
tail = [Math.floor(v4 / 0x10000), v4 % 0x10000];
|
|
74
|
+
// `::ffff:1.2.3.4` keeps `::ffff`; `::1.2.3.4` keeps its `::`, the one colon that is syntax.
|
|
75
|
+
const head = body.slice(0, lastColon + 1);
|
|
76
|
+
body = head.endsWith('::') ? head : head.slice(0, -1);
|
|
77
|
+
}
|
|
78
|
+
const halves = body.split('::');
|
|
79
|
+
if (halves.length > 2) return undefined;
|
|
80
|
+
const groups = (half: string): number[] | undefined => {
|
|
81
|
+
if (half === '') return [];
|
|
82
|
+
const out: number[] = [];
|
|
83
|
+
for (const group of half.split(':')) {
|
|
84
|
+
if (!/^[0-9a-f]{1,4}$/i.test(group)) return undefined;
|
|
85
|
+
out.push(Number.parseInt(group, 16));
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
};
|
|
89
|
+
const head = groups(halves[0] ?? '');
|
|
90
|
+
const rest = halves.length === 2 ? groups(halves[1] ?? '') : [];
|
|
91
|
+
if (head === undefined || rest === undefined) return undefined;
|
|
92
|
+
const width = head.length + rest.length + tail.length;
|
|
93
|
+
if (halves.length === 1) return width === 8 ? [...head, ...tail] : undefined;
|
|
94
|
+
if (width > 7) return undefined;
|
|
95
|
+
return [...head, ...new Array<number>(8 - width).fill(0), ...rest, ...tail];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const embeddedV4 = (g: readonly number[]): number => (g[6] ?? 0) * 0x10000 + (g[7] ?? 0);
|
|
99
|
+
|
|
100
|
+
function classifyV6(g: readonly number[]): AddressClass {
|
|
101
|
+
const zeroUpTo = (n: number): boolean => g.slice(0, n).every((group) => group === 0);
|
|
102
|
+
if (zeroUpTo(8)) return 'unspecified';
|
|
103
|
+
if (zeroUpTo(7) && g[7] === 1) return 'loopback';
|
|
104
|
+
// IPv4-mapped `::ffff:a.b.c.d`, IPv4-compatible `::a.b.c.d` and NAT64 `64:ff9b::/96`: each is
|
|
105
|
+
// routed to the IPv4 address it carries, so that address is what gets classified.
|
|
106
|
+
if (zeroUpTo(5) && g[5] === 0xffff) return classifyV4(embeddedV4(g));
|
|
107
|
+
if (zeroUpTo(6)) return classifyV4(embeddedV4(g));
|
|
108
|
+
if (g[0] === 0x64 && g[1] === 0xff9b && g.slice(2, 6).every((group) => group === 0)) {
|
|
109
|
+
return classifyV4(embeddedV4(g));
|
|
110
|
+
}
|
|
111
|
+
const first = g[0] ?? 0;
|
|
112
|
+
if ((first & 0xffc0) === 0xfe80) return 'link-local';
|
|
113
|
+
if ((first & 0xffc0) === 0xfec0) return 'private'; // deprecated site-local
|
|
114
|
+
if ((first & 0xfe00) === 0xfc00) return 'ula';
|
|
115
|
+
if ((first & 0xff00) === 0xff00) return 'reserved'; // multicast
|
|
116
|
+
if (first === 0x2001 && g[1] === 0x0db8) return 'reserved'; // documentation
|
|
117
|
+
if (first === 0x0100 && g.slice(1, 4).every((group) => group === 0)) return 'reserved'; // 100::/64
|
|
118
|
+
|
|
119
|
+
return 'public';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The class of an IP address LITERAL — IPv4, IPv6, bracketed `[::1]`, a zone id `fe80::1%eth0`,
|
|
124
|
+
* and every IPv6 form carrying an IPv4 address. `undefined` means "not an address literal": a
|
|
125
|
+
* hostname must be resolved first and each resolved address classified, never this string.
|
|
126
|
+
*/
|
|
127
|
+
export function classifyAddress(address: string): AddressClass | undefined {
|
|
128
|
+
let text = address.trim();
|
|
129
|
+
if (text.startsWith('[') && text.endsWith(']')) text = text.slice(1, -1);
|
|
130
|
+
const zone = text.indexOf('%');
|
|
131
|
+
if (zone !== -1 && text.includes(':')) text = text.slice(0, zone);
|
|
132
|
+
if (text.includes(':')) {
|
|
133
|
+
const groups = parseV6(text);
|
|
134
|
+
return groups === undefined ? undefined : classifyV6(groups);
|
|
135
|
+
}
|
|
136
|
+
const v4 = parseV4(text);
|
|
137
|
+
return v4 === undefined ? undefined : classifyV4(v4);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Fails CLOSED: anything but a literal classified `public` — a hostname included — is `false`. */
|
|
141
|
+
export function isPublicAddress(address: string): boolean {
|
|
142
|
+
return classifyAddress(address) === 'public';
|
|
143
|
+
}
|
package/src/canonical-json.ts
CHANGED
|
@@ -50,6 +50,27 @@ function hashCollection(value: Map<unknown, unknown> | Set<unknown>): string {
|
|
|
50
50
|
return `${value instanceof Map ? 'Map' : 'Set'}(${entries.sort().join(',')})`;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** Lowercase hex, two digits a byte. Not `toBase64`: this module ships to browsers that lack it. */
|
|
54
|
+
function hexOf(bytes: Uint8Array): string {
|
|
55
|
+
let out = '';
|
|
56
|
+
for (const byte of bytes) out += byte.toString(16).padStart(2, '0');
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Tagged, because a typed array's indices ARE own enumerable keys: `Uint8Array([1])` rendered
|
|
62
|
+
* `{"0":1}`, one key with the plain object `{ 0: 1 }`. The view's OWN window is read, never the
|
|
63
|
+
* whole backing buffer, and any view but a `Uint8Array` carries its type name — a `Uint16Array([1])`
|
|
64
|
+
* and a `Uint8Array([1, 0])` hold the same bytes and are different payloads.
|
|
65
|
+
*/
|
|
66
|
+
function hashBytes(value: ArrayBuffer | ArrayBufferView): string {
|
|
67
|
+
if (value instanceof ArrayBuffer) return `ArrayBuffer(${hexOf(new Uint8Array(value))})`;
|
|
68
|
+
const bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
69
|
+
const tag =
|
|
70
|
+
value instanceof Uint8Array ? 'Bytes' : Object.prototype.toString.call(value).slice(8, -1);
|
|
71
|
+
return `${tag}(${hexOf(bytes)})`;
|
|
72
|
+
}
|
|
73
|
+
|
|
53
74
|
/**
|
|
54
75
|
* JSON with object keys sorted at every depth, and every value a JSON document would fold onto
|
|
55
76
|
* `null` or `{}` given a token of its own. No timestamps, no insertion-order leaks.
|
|
@@ -64,7 +85,8 @@ export function canonicalJson(value: unknown): string {
|
|
|
64
85
|
case 'boolean':
|
|
65
86
|
return String(value);
|
|
66
87
|
case 'bigint':
|
|
67
|
-
|
|
88
|
+
// A bare token like `Date(…)`: quoted as `"5n"`, it was one key with the STRING `'5n'`.
|
|
89
|
+
return `BigInt(${value})`;
|
|
68
90
|
case 'undefined':
|
|
69
91
|
case 'function':
|
|
70
92
|
case 'symbol':
|
|
@@ -77,6 +99,7 @@ export function canonicalJson(value: unknown): string {
|
|
|
77
99
|
// every set alike.
|
|
78
100
|
if (value instanceof Date) return hashDate(value);
|
|
79
101
|
if (value instanceof Map || value instanceof Set) return hashCollection(value);
|
|
102
|
+
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return hashBytes(value);
|
|
80
103
|
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
81
104
|
const record = value as Record<string, unknown>;
|
|
82
105
|
const keys = Object.keys(record)
|
package/src/client-flight.ts
CHANGED
|
@@ -275,8 +275,11 @@ export function createClientFlight(options: ClientFlightOptions = {}): ClientFli
|
|
|
275
275
|
const work = (): Promise<T> =>
|
|
276
276
|
gate === undefined ? dispatch(plan) : gate.run(() => dispatch(plan));
|
|
277
277
|
// The single flight sits OUTSIDE the gate: a joiner takes no slot, so dedup relieves the
|
|
278
|
-
// ceiling instead of queueing behind it.
|
|
279
|
-
|
|
278
|
+
// ceiling instead of queueing behind it. Keyed by GENERATION too: `bump()` aborts the old
|
|
279
|
+
// flights, but each holds its key until its rejection settles, and a same-key read issued
|
|
280
|
+
// in that window joined the aborted one and was answered with its AbortError.
|
|
281
|
+
const key = plan.key === undefined ? undefined : JSON.stringify([issued, plan.key]);
|
|
282
|
+
return settle(key === undefined ? work() : flights.run(key, work), issued);
|
|
280
283
|
},
|
|
281
284
|
|
|
282
285
|
get inflight(): number {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Single responsibility: the numeric domain a config count or window must sit in. Takes the key as
|
|
2
|
+
// a string and the value as `unknown`, because `validate` is the boundary an untyped JS config
|
|
3
|
+
// crosses — a `'60s'` arrives here however the interface types the field.
|
|
4
|
+
|
|
5
|
+
import { describeValue } from './error-render';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Why `value` is not a whole number ≥ `min`, or `undefined` when it is one. `NaN`, `Infinity` and
|
|
9
|
+
* `2.5` each passed `concurrency < 1` — every comparison with `NaN` is false — and a fraction or an
|
|
10
|
+
* infinity then reaches `Array.from({ length })`, a `setTimeout` or a loop bound.
|
|
11
|
+
*/
|
|
12
|
+
export function countIssue(key: string, value: unknown, min: 0 | 1): string | undefined {
|
|
13
|
+
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= min) return undefined;
|
|
14
|
+
// A number is printed as itself — `describeValue` answers "a number" for 2.5 and -3 alike.
|
|
15
|
+
const shown = typeof value === 'number' ? numberText(value) : describeValue(value);
|
|
16
|
+
return `${key} must be a whole number ${min === 1 ? 'of at least 1' : 'of 0 or more'}, not ${shown}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const numberText = (value: number): string => `${value}`;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Single responsibility: the remedies `app.config.ts`'s validator appends to X_CONFIG_INVALID. Split
|
|
2
|
+
// from `config.ts` so the validator stays under its line ceiling; each string is carried only when
|
|
3
|
+
// its own key is what failed.
|
|
4
|
+
|
|
5
|
+
export const BASE_FIX = 'edit app.config.ts to fix the fields named in cause, then run: x verify';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Appended only when the zone is what failed. Axiom 4: an operator holding `'CET'` needs the
|
|
9
|
+
* spelling to write, and the two refused classes have different remedies — a single-label legacy
|
|
10
|
+
* name swaps mechanically, an abbreviation or an offset has no replacement at all because it names
|
|
11
|
+
* no jurisdiction. Deliberately parallel to `@ultimat3/time`'s `X_TIMEZONE_INVALID` fix, since the
|
|
12
|
+
* two refuse the same strings and an operator may meet either first.
|
|
13
|
+
*/
|
|
14
|
+
export const TIMEZONE_FIX =
|
|
15
|
+
"set defaultTimeZone to an Area/Location name, or UTC — list every accepted one with bun -e \"console.log(Intl.supportedValuesOf('timeZone').join('\\n'))\" — where a legacy single-label name swaps mechanically (Japan → Asia/Tokyo, GB → Europe/London, Universal → UTC), while an abbreviation or numeric offset (CET, EST5EDT, +01:00) carries no DST rule and has no replacement, so name the city whose clock you mean (Europe/Paris, America/New_York)";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Appended only when a tier name is what failed, and it names the rename rather than the rule: the
|
|
19
|
+
* three refused spellings are the ones 8.0.0 accepted, and two of them have a mechanical
|
|
20
|
+
* replacement while `isr` has none — it is a `RenderMode`, and no cache tier ever served it.
|
|
21
|
+
*/
|
|
22
|
+
export const CACHE_TIER_FIX =
|
|
23
|
+
"in app.config.ts, rewrite cache.tiers with the rung names the ladder serves — request-memo, lru, redis, cdn — where memo becomes request-memo and shared becomes redis, and isr is dropped: it is a render mode, so move it to render: 'isr' on the routes that want it";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Single responsibility: how `defineConfig` layers `app.config.ts` and its `config/*.ts` overlays —
|
|
2
|
+
// per section and key by key. Carries no config KEY on purpose: `config-readers` counts a property
|
|
3
|
+
// access outside `config.ts` as a reader, so this file only ever sees sections as opaque records.
|
|
4
|
+
|
|
5
|
+
/** A section's patch: every key optional, and an explicit `undefined` meaning "not said". */
|
|
6
|
+
export type Input<T> = { readonly [K in keyof T]?: T[K] | undefined };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Apply a partial section over its defaults. Explicit `undefined` never wins — that is what
|
|
10
|
+
* makes every config field deeply optional without `exactOptionalPropertyTypes` fighting back.
|
|
11
|
+
*/
|
|
12
|
+
export function section<T extends object>(base: T, patch: Input<T> | undefined): T {
|
|
13
|
+
if (patch === undefined) return base;
|
|
14
|
+
const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
|
15
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
16
|
+
if (value !== undefined) out[key] = value;
|
|
17
|
+
}
|
|
18
|
+
return out as T;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Every layer's patch applied in order, each one KEY BY KEY. The input and the overlays used to be
|
|
23
|
+
* `Object.assign`ed first and the section merged once, so `{ jobs: { maxAttempts: 9 } }` in an
|
|
24
|
+
* overlay replaced the base's whole `jobs` patch — its `queues` and `concurrency` fell back to the
|
|
25
|
+
* framework defaults — and an overlay's `{ realtime: undefined }` erased the base's section.
|
|
26
|
+
*/
|
|
27
|
+
export function layered<T extends object>(base: T, patches: readonly (Input<T> | undefined)[]): T {
|
|
28
|
+
return patches.reduce<T>((out, patch) => section(out, patch), base);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A whole-value key (`locales`, `roles`): the last layer that said something wins. */
|
|
32
|
+
export function lastSaid<T>(base: T, values: readonly (T | undefined)[]): T {
|
|
33
|
+
let out = base;
|
|
34
|
+
for (const value of values) if (value !== undefined) out = value;
|
|
35
|
+
return out;
|
|
36
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -7,15 +7,24 @@ import { CURRENCY_CODE_PATTERN } from '@ultimat3/schema';
|
|
|
7
7
|
// them. Declaring them here is what let `cache.tiers` and the ladder `@ultimat3/cache` orders by
|
|
8
8
|
// drift into two vocabularies with no map between them (issue #293).
|
|
9
9
|
import { CACHE_TIERS, type CacheTierName } from './cache-vocabulary';
|
|
10
|
+
import { countIssue } from './config-count';
|
|
11
|
+
import { BASE_FIX, CACHE_TIER_FIX, TIMEZONE_FIX } from './config-fixes';
|
|
12
|
+
import { type Input, lastSaid, layered } from './config-merge';
|
|
10
13
|
import type { PwaConfig, PwaOfflineConfig } from './config-pwa';
|
|
11
14
|
import { PWA_FIX, pwaIssues } from './config-pwa';
|
|
12
15
|
import { describeValue } from './error-render';
|
|
13
16
|
import { ConfigInvalidError } from './errors';
|
|
17
|
+
import { defaultReadinessGraceMs, readinessGraceIssue } from './lifecycle-grace';
|
|
14
18
|
import { ROLES, type Role } from './roles';
|
|
15
19
|
import { isIanaZoneName } from './time-zone-name';
|
|
16
20
|
|
|
17
21
|
export type ThemeMode = 'light' | 'dark' | 'system';
|
|
18
|
-
|
|
22
|
+
/**
|
|
23
|
+
* The buses `@ultimat3/realtime`'s `selectTransport` builds, and nothing else. `'redis'` was in
|
|
24
|
+
* this union until 22.0.0 with no Redis transport anywhere: it booted whatever `NATS_URL` chose.
|
|
25
|
+
*/
|
|
26
|
+
export const REALTIME_TRANSPORTS = ['memory', 'nats'] as const;
|
|
27
|
+
export type RealtimeTransport = (typeof REALTIME_TRANSPORTS)[number];
|
|
19
28
|
|
|
20
29
|
export interface ThemeConfig {
|
|
21
30
|
readonly defaultMode: ThemeMode;
|
|
@@ -172,6 +181,20 @@ export interface AiConfig {
|
|
|
172
181
|
readonly mcp: McpConfig;
|
|
173
182
|
}
|
|
174
183
|
|
|
184
|
+
/**
|
|
185
|
+
* How a SIGTERM'd process leaves the load balancer. Read by `@ultimat3/http`'s `createServer`
|
|
186
|
+
* (`ServerOptions.drain`), which hands it to core's `configureLifecycle`.
|
|
187
|
+
*/
|
|
188
|
+
export interface DrainConfig {
|
|
189
|
+
/**
|
|
190
|
+
* `/readyz` answers 503 for this long before the listener closes, so endpoints stop routing here
|
|
191
|
+
* first. Default 0 in development/test and 5000 everywhere else — a process naming NO environment
|
|
192
|
+
* included. A whole number, 0–60000. The chart's `terminationGracePeriodSeconds` must exceed it
|
|
193
|
+
* plus the drain budget.
|
|
194
|
+
*/
|
|
195
|
+
readonly readinessGraceMs: number;
|
|
196
|
+
}
|
|
197
|
+
|
|
175
198
|
export interface AppConfig {
|
|
176
199
|
readonly name: string;
|
|
177
200
|
readonly locales: readonly string[];
|
|
@@ -188,10 +211,9 @@ export interface AppConfig {
|
|
|
188
211
|
readonly realtime: RealtimeConfig;
|
|
189
212
|
readonly notify: NotifyConfig;
|
|
190
213
|
readonly ai: AiConfig;
|
|
214
|
+
readonly drain: DrainConfig;
|
|
191
215
|
}
|
|
192
216
|
|
|
193
|
-
type Input<T> = { readonly [K in keyof T]?: T[K] | undefined };
|
|
194
|
-
|
|
195
217
|
/** `mcp` is the only member, and it is NESTED — `Input<AiConfig>` would make it all-or-nothing. */
|
|
196
218
|
export interface AiConfigInput {
|
|
197
219
|
readonly mcp?: Input<McpConfig> | undefined;
|
|
@@ -224,24 +246,12 @@ export interface AppConfigInput {
|
|
|
224
246
|
readonly realtime?: Input<RealtimeConfig> | undefined;
|
|
225
247
|
readonly notify?: Input<NotifyConfig> | undefined;
|
|
226
248
|
readonly ai?: AiConfigInput | undefined;
|
|
249
|
+
readonly drain?: Input<DrainConfig> | undefined;
|
|
227
250
|
}
|
|
228
251
|
|
|
229
252
|
/** An overlay from `config/<concern>.ts`. No `name` — the base owns it. */
|
|
230
253
|
export type AppConfigOverlay = Omit<AppConfigInput, 'name'> & { readonly name?: string };
|
|
231
254
|
|
|
232
|
-
/**
|
|
233
|
-
* Apply a partial section over its defaults. Explicit `undefined` never wins — that is what
|
|
234
|
-
* makes every config field deeply optional without `exactOptionalPropertyTypes` fighting back.
|
|
235
|
-
*/
|
|
236
|
-
function section<T extends object>(base: T, patch: Input<T> | undefined): T {
|
|
237
|
-
if (patch === undefined) return base;
|
|
238
|
-
const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
|
239
|
-
for (const [key, value] of Object.entries(patch)) {
|
|
240
|
-
if (value !== undefined) out[key] = value;
|
|
241
|
-
}
|
|
242
|
-
return out as T;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
255
|
const NAME_RE = /^[a-z][a-z0-9-]{1,63}$/;
|
|
246
256
|
|
|
247
257
|
/**
|
|
@@ -287,32 +297,16 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
|
|
|
287
297
|
backoff: 'exponential',
|
|
288
298
|
visibilityTimeoutMs: 30_000,
|
|
289
299
|
},
|
|
290
|
-
|
|
300
|
+
// ON by default since 22.0.0, when the boot began obeying the key: an app with no section
|
|
301
|
+
// keeps the `sync` node it always got, and `enabled: false` is the explicit opt-out.
|
|
302
|
+
realtime: { enabled: true, transport: 'memory', urlEnv: undefined },
|
|
291
303
|
notify: { inboxReadRetentionMs: undefined, inboxUnreadRetentionMs: undefined },
|
|
292
304
|
ai: { mcp: { expose: true, path: '/mcp' } },
|
|
305
|
+
// Read from the process env when the config is DEFINED — the same env the drain will run in.
|
|
306
|
+
drain: { readinessGraceMs: defaultReadinessGraceMs() },
|
|
293
307
|
};
|
|
294
308
|
}
|
|
295
309
|
|
|
296
|
-
const BASE_FIX = 'edit app.config.ts to fix the fields named in cause, then run: x verify';
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* Appended only when the zone is what failed. Axiom 4: an operator holding `'CET'` needs the
|
|
300
|
-
* spelling to write, and the two refused classes have different remedies — a single-label legacy
|
|
301
|
-
* name swaps mechanically, an abbreviation or an offset has no replacement at all because it names
|
|
302
|
-
* no jurisdiction. Deliberately parallel to `@ultimat3/time`'s `X_TIMEZONE_INVALID` fix, since the
|
|
303
|
-
* two refuse the same strings and an operator may meet either first.
|
|
304
|
-
*/
|
|
305
|
-
const TIMEZONE_FIX =
|
|
306
|
-
"set defaultTimeZone to an Area/Location name, or UTC — list every accepted one with bun -e \"console.log(Intl.supportedValuesOf('timeZone').join('\\n'))\" — where a legacy single-label name swaps mechanically (Japan → Asia/Tokyo, GB → Europe/London, Universal → UTC), while an abbreviation or numeric offset (CET, EST5EDT, +01:00) carries no DST rule and has no replacement, so name the city whose clock you mean (Europe/Paris, America/New_York)";
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* Appended only when a tier name is what failed, and it names the rename rather than the rule: the
|
|
310
|
-
* three refused spellings are the ones 8.0.0 accepted, and two of them have a mechanical
|
|
311
|
-
* replacement while `isr` has none — it is a `RenderMode`, and no cache tier ever served it.
|
|
312
|
-
*/
|
|
313
|
-
const CACHE_TIER_FIX =
|
|
314
|
-
"in app.config.ts, rewrite cache.tiers with the rung names the ladder serves — request-memo, lru, redis, cdn — where memo becomes request-memo and shared becomes redis, and isr is dropped: it is a render mode, so move it to render: 'isr' on the routes that want it";
|
|
315
|
-
|
|
316
310
|
function validate(config: AppConfig): void {
|
|
317
311
|
const issues: string[] = [];
|
|
318
312
|
// Zero or one entry: the zone's own remedy, carried only when the zone is what failed.
|
|
@@ -346,10 +340,25 @@ function validate(config: AppConfig): void {
|
|
|
346
340
|
issues.push(`defaultCurrency "${config.defaultCurrency}" is not a 3-letter ISO 4217 code`);
|
|
347
341
|
}
|
|
348
342
|
if (config.roles.length === 0) issues.push('roles must list at least one runtime role');
|
|
349
|
-
|
|
343
|
+
// A domain per numeric key, never a bare `< 1`: every comparison with `NaN` is false, so the old
|
|
344
|
+
// `concurrency < 1` passed `NaN`, `2.5` and `Infinity`, and nothing screened the other three.
|
|
345
|
+
const counts: readonly (string | undefined)[] = [
|
|
346
|
+
countIssue('jobs.concurrency', config.jobs.concurrency, 1),
|
|
347
|
+
countIssue('jobs.maxAttempts', config.jobs.maxAttempts, 1),
|
|
348
|
+
countIssue('jobs.visibilityTimeoutMs', config.jobs.visibilityTimeoutMs, 1),
|
|
349
|
+
countIssue('cache.defaultTtlMs', config.cache.defaultTtlMs, 0),
|
|
350
|
+
readinessGraceIssue(config.drain.readinessGraceMs),
|
|
351
|
+
];
|
|
352
|
+
for (const issue of counts) if (issue !== undefined) issues.push(issue);
|
|
350
353
|
if (config.jobs.queues.length === 0) issues.push('jobs.queues must list at least one queue');
|
|
351
|
-
|
|
352
|
-
|
|
354
|
+
// An untyped config reaches here with whatever it wrote: a string is a name worth echoing, and
|
|
355
|
+
// anything else goes through `describeValue` rather than `${…}`.
|
|
356
|
+
const transport: unknown = config.realtime.transport;
|
|
357
|
+
if (!REALTIME_TRANSPORTS.some((known) => known === transport)) {
|
|
358
|
+
const said = typeof transport === 'string' ? `"${transport}"` : describeValue(transport);
|
|
359
|
+
issues.push(`realtime.transport ${said} is not one of ${REALTIME_TRANSPORTS.join(', ')}`);
|
|
360
|
+
} else if (transport === 'nats' && config.realtime.urlEnv === undefined) {
|
|
361
|
+
issues.push(`realtime.transport "nats" requires realtime.urlEnv`);
|
|
353
362
|
}
|
|
354
363
|
// BOTH RETENTION WINDOWS OR NEITHER — `undefined` is a real value here (never swept) and the
|
|
355
364
|
// only other legal one is a positive, finite count of milliseconds. Zero is refused rather than
|
|
@@ -401,35 +410,83 @@ export function defineConfig(
|
|
|
401
410
|
...overlays: readonly AppConfigOverlay[]
|
|
402
411
|
): AppConfig {
|
|
403
412
|
const base = defaults(input.name);
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
|
|
407
|
-
const merged: AppConfigInput = Object.assign({}, input, ...overlays, {
|
|
408
|
-
name: input.name,
|
|
409
|
-
}) as AppConfigInput;
|
|
413
|
+
// Every layer, in order, merged per section and KEY BY KEY — see `layered`. `name` comes from
|
|
414
|
+
// the input alone because it identifies the app: an overlay may not rename it.
|
|
415
|
+
const layers: readonly AppConfigOverlay[] = [input, ...overlays];
|
|
410
416
|
|
|
411
417
|
const config: AppConfig = {
|
|
412
|
-
name:
|
|
413
|
-
locales:
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
418
|
+
name: input.name,
|
|
419
|
+
locales: lastSaid(
|
|
420
|
+
base.locales,
|
|
421
|
+
layers.map((layer) => layer.locales),
|
|
422
|
+
),
|
|
423
|
+
defaultLocale: lastSaid(
|
|
424
|
+
base.defaultLocale,
|
|
425
|
+
layers.map((layer) => layer.defaultLocale),
|
|
426
|
+
),
|
|
427
|
+
defaultTimeZone: lastSaid(
|
|
428
|
+
base.defaultTimeZone,
|
|
429
|
+
layers.map((layer) => layer.defaultTimeZone),
|
|
430
|
+
),
|
|
431
|
+
defaultCurrency: lastSaid(
|
|
432
|
+
base.defaultCurrency,
|
|
433
|
+
layers.map((layer) => layer.defaultCurrency),
|
|
434
|
+
),
|
|
435
|
+
theme: layered(
|
|
436
|
+
base.theme,
|
|
437
|
+
layers.map((layer) => layer.theme),
|
|
438
|
+
),
|
|
439
|
+
auth: layered(
|
|
440
|
+
base.auth,
|
|
441
|
+
layers.map((layer) => layer.auth),
|
|
442
|
+
),
|
|
443
|
+
// Two merges, one per level: the outer one may not see `offline` at all, or it would drop the
|
|
444
|
+
// nested defaults `PwaConfigInput` exists to keep. Hence the cast — the outer patch is this
|
|
445
|
+
// block minus the key the inner merge owns.
|
|
422
446
|
pwa: {
|
|
423
|
-
...
|
|
424
|
-
|
|
447
|
+
...layered(
|
|
448
|
+
base.pwa,
|
|
449
|
+
layers.map((layer) => ({ ...layer.pwa, offline: undefined }) as Input<PwaConfig>),
|
|
450
|
+
),
|
|
451
|
+
offline: layered(
|
|
452
|
+
base.pwa.offline,
|
|
453
|
+
layers.map((layer) => layer.pwa?.offline),
|
|
454
|
+
),
|
|
455
|
+
},
|
|
456
|
+
roles: lastSaid(
|
|
457
|
+
base.roles,
|
|
458
|
+
layers.map((layer) => layer.roles),
|
|
459
|
+
),
|
|
460
|
+
database: layered(
|
|
461
|
+
base.database,
|
|
462
|
+
layers.map((layer) => layer.database),
|
|
463
|
+
),
|
|
464
|
+
cache: layered(
|
|
465
|
+
base.cache,
|
|
466
|
+
layers.map((layer) => layer.cache),
|
|
467
|
+
),
|
|
468
|
+
jobs: layered(
|
|
469
|
+
base.jobs,
|
|
470
|
+
layers.map((layer) => layer.jobs),
|
|
471
|
+
),
|
|
472
|
+
realtime: layered(
|
|
473
|
+
base.realtime,
|
|
474
|
+
layers.map((layer) => layer.realtime),
|
|
475
|
+
),
|
|
476
|
+
notify: layered(
|
|
477
|
+
base.notify,
|
|
478
|
+
layers.map((layer) => layer.notify),
|
|
479
|
+
),
|
|
480
|
+
ai: {
|
|
481
|
+
mcp: layered(
|
|
482
|
+
base.ai.mcp,
|
|
483
|
+
layers.map((layer) => layer.ai?.mcp),
|
|
484
|
+
),
|
|
425
485
|
},
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
realtime: section(base.realtime, merged.realtime),
|
|
431
|
-
notify: section(base.notify, merged.notify),
|
|
432
|
-
ai: { mcp: section(base.ai.mcp, merged.ai?.mcp) },
|
|
486
|
+
drain: layered(
|
|
487
|
+
base.drain,
|
|
488
|
+
layers.map((layer) => layer.drain),
|
|
489
|
+
),
|
|
433
490
|
};
|
|
434
491
|
|
|
435
492
|
validate(config);
|
package/src/core-error-codes.ts
CHANGED
|
@@ -16,6 +16,7 @@ const CORE_CODE_TITLES = {
|
|
|
16
16
|
X_CLIENT_TRANSPORT_FAILED: 'a browser request got no answer from the app',
|
|
17
17
|
X_CONFIG_INVALID: 'app.config.ts is invalid',
|
|
18
18
|
X_CURSOR_INVALID: 'pagination cursor is malformed, tampered with or from another query',
|
|
19
|
+
// `x doctor` reports it; `assertNoDevSecretsOutsideLocal()` throws it at boot.
|
|
19
20
|
X_CURSOR_SECRET_DEV: 'cursors are signed with the shipped development key',
|
|
20
21
|
X_DRAINING: 'process is draining and refuses new work',
|
|
21
22
|
X_ENV_EXAMPLE_DRIFT: '.env.example does not declare every variable the schema requires',
|