@ultimat3/core 23.0.0 → 25.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 +29 -26
- package/README.md +98 -30
- package/package.json +4 -7
- package/src/actor.ts +9 -0
- package/src/address-class.ts +40 -4
- package/src/assert.ts +9 -5
- package/src/audit.ts +144 -0
- package/src/aws-sigv4.ts +275 -0
- package/src/backoff.ts +16 -0
- package/src/bunfs.ts +17 -0
- package/src/client-dispatch.ts +24 -3
- package/src/client-flight.ts +68 -13
- package/src/client-problem.ts +62 -6
- package/src/client-retry-after.ts +47 -0
- package/src/client-transport.ts +3 -1
- package/src/client-wire.ts +27 -3
- package/src/config-ai.ts +32 -0
- package/src/config-defaults.ts +53 -0
- package/src/config-fixes.ts +0 -10
- package/src/config-health.ts +9 -2
- package/src/config-jobs.ts +51 -0
- package/src/config-keys.ts +170 -0
- package/src/config-mail.ts +73 -0
- package/src/config-merge.ts +9 -1
- package/src/config-navigation.ts +1 -25
- package/src/config-pwa.ts +42 -5
- package/src/config-removed.ts +131 -0
- package/src/config-shape.ts +79 -0
- package/src/config-site.ts +14 -3
- package/src/config.ts +141 -173
- package/src/context.ts +29 -13
- package/src/cookie.ts +299 -0
- package/src/core-error-codes.ts +2 -0
- package/src/cursor-page.ts +41 -0
- package/src/cursor.ts +26 -5
- package/src/decimal-order.ts +5 -4
- package/src/deprecation.ts +77 -0
- package/src/dev-secrets.ts +18 -7
- package/src/drain-deadline.ts +43 -0
- package/src/env-example.ts +9 -29
- package/src/error-reporter-sentry.ts +7 -3
- package/src/errors.ts +18 -9
- package/src/exports/error-contract.ts +0 -1
- package/src/exports/observability.ts +1 -1
- package/src/exports/secrets.ts +3 -0
- package/src/finite-option.ts +1 -1
- package/src/flight-gate.ts +43 -16
- package/src/fnv1a.ts +19 -0
- package/src/generation-fence.ts +1 -1
- package/src/health-disclosure.ts +43 -0
- package/src/host-rules.ts +28 -1
- package/src/html-escape.ts +24 -0
- package/src/ids.ts +7 -7
- package/src/image/canvas.ts +76 -5
- package/src/image/errors.ts +3 -1
- package/src/image/pipeline.ts +17 -5
- package/src/image/png-pixels.ts +29 -6
- package/src/image/probe.ts +7 -2
- package/src/image/raster.ts +27 -2
- package/src/index.ts +99 -33
- package/src/iso-date.ts +1 -1
- package/src/lifecycle-errors.ts +1 -1
- package/src/lifecycle-readiness.ts +60 -2
- package/src/lifecycle-signals.ts +27 -2
- package/src/lifecycle-types.ts +96 -0
- package/src/lifecycle.ts +44 -133
- package/src/locale-direction.ts +1 -1
- package/src/logger.ts +92 -16
- package/src/mcp-exposure.ts +70 -8
- package/src/measurement-actor.ts +16 -1
- package/src/metric-errors.ts +32 -0
- package/src/metric-registry.ts +151 -0
- package/src/metric-series.ts +94 -0
- package/src/metrics.ts +9 -255
- 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.ts +4 -2
- package/src/pg-executor.ts +15 -0
- package/src/public-cause.ts +37 -0
- package/src/registrar.ts +22 -4
- package/src/retry.ts +40 -7
- 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/secrets-key-file.ts +139 -0
- package/src/secrets-store.ts +32 -16
- package/src/service.ts +5 -5
- package/src/single-flight.ts +1 -1
- package/src/source-mask.ts +14 -8
- package/src/store-mode.ts +23 -0
- package/src/telemetry.ts +1 -1
- package/src/theme-storage.ts +12 -0
- package/src/type-pins.ts +51 -1
- package/src/image/fixtures.ts +0 -263
- package/src/time-zone-name.ts +0 -14
package/src/cookie.ts
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// The one cookie header codec: `readCookie` reads a `Cookie:` request header, `serializeSetCookie`
|
|
2
|
+
// builds a `Set-Cookie` value. At tier 0 because three packages need it — auth and http at tier 2
|
|
3
|
+
// cannot import each other, i18n sits below both — and each copy had to rediscover the same thrown
|
|
4
|
+
// `URIError` on its own (`bun run flight-copies` refuses a fourth).
|
|
5
|
+
|
|
6
|
+
import { renderCauseValue } from './error-render';
|
|
7
|
+
import { UltimateError } from './errors';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A `Cookie:` header is attacker-controlled, and `decodeURIComponent('%')` throws a bare
|
|
11
|
+
* `URIError` — which escapes every coded path that reads through here: an OAuth callback would
|
|
12
|
+
* answer 500 instead of `X_OAUTH_STATE_INVALID`, and `curl -H 'Cookie: x-locale=%'` paged the
|
|
13
|
+
* on-call from the `locale` stage. The raw value is returned instead, so the caller's own
|
|
14
|
+
* rejection stays the readable failure; a raw value is still checked against a signature, a stored
|
|
15
|
+
* hash or a `supported` list, and none of them match a mangled one.
|
|
16
|
+
*/
|
|
17
|
+
const decodeCookieValue = (raw: string): string => {
|
|
18
|
+
try {
|
|
19
|
+
return decodeURIComponent(raw);
|
|
20
|
+
} catch {
|
|
21
|
+
return raw;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The value of cookie `name` in a `Cookie:` header, or `null` when the header or the cookie is
|
|
27
|
+
* absent. Never throws: the header is client-authored, so an unreadable value is the raw value and
|
|
28
|
+
* a header that is not a string at all is no cookie.
|
|
29
|
+
*/
|
|
30
|
+
export function readCookie(header: string | null | undefined, name: string): string | null {
|
|
31
|
+
if (typeof header !== 'string') return null;
|
|
32
|
+
for (const part of header.split(';')) {
|
|
33
|
+
const equals = part.indexOf('=');
|
|
34
|
+
if (equals === -1) continue;
|
|
35
|
+
if (part.slice(0, equals).trim() !== name) continue;
|
|
36
|
+
return decodeCookieValue(part.slice(equals + 1).trim());
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `SameSite`: `None` sends the cookie cross-site, so it is only accepted beside `Secure`. */
|
|
42
|
+
export type CookieSameSite = 'Strict' | 'Lax' | 'None';
|
|
43
|
+
/** Chromium's `Priority`, the order cookies are evicted in when a domain is over its quota. */
|
|
44
|
+
export type CookiePriority = 'Low' | 'Medium' | 'High';
|
|
45
|
+
|
|
46
|
+
export interface SetCookieOptions {
|
|
47
|
+
/** Seconds until expiry; `0` clears the cookie. A non-negative integer. */
|
|
48
|
+
readonly maxAge?: number;
|
|
49
|
+
/** Absolute expiry, written as an IMF-fixdate in UTC. Beside `maxAge`, `maxAge` wins in a browser. */
|
|
50
|
+
readonly expires?: Date;
|
|
51
|
+
/** Defaults to `/`. Must start with `/`: a browser silently replaces any other with its own. */
|
|
52
|
+
readonly path?: string;
|
|
53
|
+
/** Omitted by default, which scopes the cookie to the exact host that set it. */
|
|
54
|
+
readonly domain?: string;
|
|
55
|
+
/** Defaults to `true`. */
|
|
56
|
+
readonly secure?: boolean;
|
|
57
|
+
/** Defaults to `true`. */
|
|
58
|
+
readonly httpOnly?: boolean;
|
|
59
|
+
/** Defaults to `'Lax'`. */
|
|
60
|
+
readonly sameSite?: CookieSameSite;
|
|
61
|
+
/** CHIPS: keyed to the top-level site. Requires `secure`. */
|
|
62
|
+
readonly partitioned?: boolean;
|
|
63
|
+
readonly priority?: CookiePriority;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** RFC 6265 §4.1.1 `token`: any CHAR except CTLs and the separators `()<>@,;:\"/[]?={}`, SP, HT. */
|
|
67
|
+
const COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
68
|
+
/** Every UTF-16 unit outside RFC 6265 `cookie-octet` — plus `%`, which readCookie decodes. */
|
|
69
|
+
const NOT_COOKIE_OCTET = /[^\x21\x23\x24\x26-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]+/g;
|
|
70
|
+
/**
|
|
71
|
+
* A lone UTF-16 surrogate: it has no UTF-8 form, so `encodeURIComponent` would throw a bare
|
|
72
|
+
* `URIError`. Spelled out because `String#isWellFormed` is ES2024 and the lib is ES2023.
|
|
73
|
+
*/
|
|
74
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
75
|
+
/** A path a browser keeps verbatim: absolute, printable ASCII, no `;` to end the attribute early. */
|
|
76
|
+
const COOKIE_PATH = /^\/[\x21-\x3A\x3C-\x7E]*$/;
|
|
77
|
+
const DOMAIN_LABEL = '[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?';
|
|
78
|
+
const COOKIE_DOMAIN = new RegExp(`^\\.?${DOMAIN_LABEL}(?:\\.${DOMAIN_LABEL})*$`);
|
|
79
|
+
/** RFC 6265bis §5.6/§5.7: a browser ignores a pair over 4096 octets and an attribute over 1024. */
|
|
80
|
+
const MAX_PAIR_OCTETS = 4096;
|
|
81
|
+
const MAX_ATTRIBUTE_OCTETS = 1024;
|
|
82
|
+
const SAME_SITE: readonly string[] = ['Strict', 'Lax', 'None'];
|
|
83
|
+
const PRIORITY: readonly string[] = ['Low', 'Medium', 'High'];
|
|
84
|
+
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const;
|
|
85
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
86
|
+
|
|
87
|
+
const PARTITIONED_FIX =
|
|
88
|
+
'serializeSetCookie(name, value, { partitioned: true, secure: true }) # Partitioned requires Secure';
|
|
89
|
+
|
|
90
|
+
type CookieField = keyof SetCookieOptions | 'name' | 'value';
|
|
91
|
+
|
|
92
|
+
/** A `Set-Cookie` the caller asked for that a browser would drop, misread or let be injected into. */
|
|
93
|
+
export class CookieInvalidError extends UltimateError {
|
|
94
|
+
override readonly name = 'CookieInvalidError';
|
|
95
|
+
|
|
96
|
+
constructor(field: CookieField, cookie: string, reason: string, fix: string) {
|
|
97
|
+
super({
|
|
98
|
+
code: 'X_COOKIE_INVALID',
|
|
99
|
+
cause: `Set-Cookie ${renderCauseValue(cookie)}: ${field} ${reason}`,
|
|
100
|
+
fix,
|
|
101
|
+
meta: { field },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Percent-encodes exactly what `cookie-octet` excludes, and `%`. Minimal rather than
|
|
108
|
+
* `encodeURIComponent`, so a base64url token or a sealed value is written byte-identical — moving
|
|
109
|
+
* an existing cookie onto this does not change what a non-Ultimate reader sees — while
|
|
110
|
+
* `decodeURIComponent` in `readCookie` still inverts it exactly: what was passed is what is read.
|
|
111
|
+
*/
|
|
112
|
+
const encodeCookieValue = (value: string): string =>
|
|
113
|
+
value.replace(NOT_COOKIE_OCTET, (run) => encodeURIComponent(run));
|
|
114
|
+
|
|
115
|
+
const pad = (n: number): string => String(n).padStart(2, '0');
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* RFC 9110 §5.6.7 IMF-fixdate, read from the `getUTC*` fields: the zone is UTC by construction,
|
|
119
|
+
* never the process's, and no locale table can rename a weekday.
|
|
120
|
+
*/
|
|
121
|
+
function imfFixdate(at: Date): string {
|
|
122
|
+
const day = WEEKDAYS[at.getUTCDay()];
|
|
123
|
+
const month = MONTHS[at.getUTCMonth()];
|
|
124
|
+
const year = String(at.getUTCFullYear()).padStart(4, '0');
|
|
125
|
+
const time = `${pad(at.getUTCHours())}:${pad(at.getUTCMinutes())}:${pad(at.getUTCSeconds())}`;
|
|
126
|
+
return `${day}, ${pad(at.getUTCDate())} ${month} ${year} ${time} GMT`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* RFC 6265bis §4.1.3: a browser drops a prefixed cookie that breaks its prefix's rule, silently,
|
|
131
|
+
* and matches the prefix case-insensitively — so the refusal does too.
|
|
132
|
+
*/
|
|
133
|
+
function checkPrefix(name: string, secure: boolean, path: string, domain: string | undefined) {
|
|
134
|
+
const lower = name.toLowerCase();
|
|
135
|
+
if (lower.startsWith('__secure-') && !secure) {
|
|
136
|
+
throw new CookieInvalidError(
|
|
137
|
+
'name',
|
|
138
|
+
name,
|
|
139
|
+
'has the __Secure- prefix without Secure',
|
|
140
|
+
'serializeSetCookie(name, value, { secure: true }) # __Secure- requires Secure',
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (!lower.startsWith('__host-')) return;
|
|
144
|
+
if (!secure || domain !== undefined || path !== '/') {
|
|
145
|
+
throw new CookieInvalidError(
|
|
146
|
+
'name',
|
|
147
|
+
name,
|
|
148
|
+
'has the __Host- prefix, which requires Secure, no Domain and Path=/',
|
|
149
|
+
"serializeSetCookie(name, value, { secure: true, path: '/' }) # __Host- also forbids domain",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function checkAttribute(
|
|
155
|
+
field: 'path' | 'domain',
|
|
156
|
+
name: string,
|
|
157
|
+
value: string,
|
|
158
|
+
shape: RegExp,
|
|
159
|
+
fix: string,
|
|
160
|
+
) {
|
|
161
|
+
// The shape admits ASCII only, so once it matches `.length` is the octet count.
|
|
162
|
+
if (value.length <= MAX_ATTRIBUTE_OCTETS && shape.test(value)) return;
|
|
163
|
+
throw new CookieInvalidError(
|
|
164
|
+
field,
|
|
165
|
+
name,
|
|
166
|
+
`${renderCauseValue(value)} is not a valid ${field}`,
|
|
167
|
+
fix,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function checkEnum(
|
|
172
|
+
field: 'sameSite' | 'priority',
|
|
173
|
+
name: string,
|
|
174
|
+
value: string,
|
|
175
|
+
allowed: readonly string[],
|
|
176
|
+
) {
|
|
177
|
+
if (allowed.includes(value)) return;
|
|
178
|
+
const reason = `${renderCauseValue(value)} is not one of ${allowed.join(', ')}`;
|
|
179
|
+
throw new CookieInvalidError(
|
|
180
|
+
field,
|
|
181
|
+
name,
|
|
182
|
+
reason,
|
|
183
|
+
`serializeSetCookie(name, value, { ${field}: '${allowed[1]}' }) # one of ${allowed.join(', ')}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function expiresAttribute(name: string, expires: Date): string {
|
|
188
|
+
const year = expires instanceof Date ? expires.getUTCFullYear() : Number.NaN;
|
|
189
|
+
// Below 1601 a cookie parser rejects the date (RFC 6265 §5.1.1); above 9999 it is not 4 digits.
|
|
190
|
+
if (!(year >= 1601 && year <= 9999)) {
|
|
191
|
+
throw new CookieInvalidError(
|
|
192
|
+
'expires',
|
|
193
|
+
name,
|
|
194
|
+
`${renderCauseValue(expires)} is not a Date between the years 1601 and 9999`,
|
|
195
|
+
'serializeSetCookie(name, value, { expires: new Date(Date.now() + ms) }) # or maxAge in seconds',
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return `; Expires=${imfFixdate(expires)}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* One `Set-Cookie` header value — the only place the framework spells one. Defaults are the ones
|
|
203
|
+
* `@ultimat3/auth`'s session and OAuth cookies already ship: `Path=/; HttpOnly; Secure;
|
|
204
|
+
* SameSite=Lax`. The value is encoded so `readCookie` returns it exactly; anything a browser would
|
|
205
|
+
* silently drop, or that could end the header early, is an `X_COOKIE_INVALID` instead.
|
|
206
|
+
*/
|
|
207
|
+
export function serializeSetCookie(
|
|
208
|
+
name: string,
|
|
209
|
+
value: string,
|
|
210
|
+
options: SetCookieOptions = {},
|
|
211
|
+
): string {
|
|
212
|
+
if (!COOKIE_NAME.test(name)) {
|
|
213
|
+
const reason = "is not an RFC 6265 token (letters, digits and !#$%&'*+-.^_`|~ only)";
|
|
214
|
+
throw new CookieInvalidError(
|
|
215
|
+
'name',
|
|
216
|
+
name,
|
|
217
|
+
reason,
|
|
218
|
+
"serializeSetCookie('session_id', value) # letters, digits, - and _ only",
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
if (LONE_SURROGATE.test(value)) {
|
|
222
|
+
const fix =
|
|
223
|
+
'serializeSetCookie(name, value.toWellFormed()) # or base64url-encode binary data';
|
|
224
|
+
throw new CookieInvalidError('value', name, 'has a lone UTF-16 surrogate', fix);
|
|
225
|
+
}
|
|
226
|
+
const encoded = encodeCookieValue(value);
|
|
227
|
+
// `.length` IS the octet count: a token name and a percent-encoded value are ASCII only.
|
|
228
|
+
const pairOctets = name.length + encoded.length;
|
|
229
|
+
if (pairOctets > MAX_PAIR_OCTETS) {
|
|
230
|
+
const reason = `is ${pairOctets} octets as name=value encoded; a browser drops one over ${MAX_PAIR_OCTETS}`;
|
|
231
|
+
throw new CookieInvalidError(
|
|
232
|
+
'value',
|
|
233
|
+
name,
|
|
234
|
+
reason,
|
|
235
|
+
'serializeSetCookie(name, sessionId) # keep the data server-side, an id in the cookie',
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
const { maxAge, expires, domain, partitioned, priority } = options;
|
|
239
|
+
const path = options.path ?? '/';
|
|
240
|
+
const secure = options.secure ?? true;
|
|
241
|
+
const httpOnly = options.httpOnly ?? true;
|
|
242
|
+
const sameSite = options.sameSite ?? 'Lax';
|
|
243
|
+
checkPrefix(name, secure, path, domain);
|
|
244
|
+
checkEnum('sameSite', name, sameSite, SAME_SITE);
|
|
245
|
+
if (sameSite === 'None' && !secure) {
|
|
246
|
+
const reason = 'is None without Secure, which every current browser rejects';
|
|
247
|
+
throw new CookieInvalidError(
|
|
248
|
+
'sameSite',
|
|
249
|
+
name,
|
|
250
|
+
reason,
|
|
251
|
+
"serializeSetCookie(name, value, { sameSite: 'None', secure: true }) # None requires Secure",
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (partitioned === true && !secure) {
|
|
255
|
+
const reason = 'is set without Secure, which a browser rejects';
|
|
256
|
+
throw new CookieInvalidError('partitioned', name, reason, PARTITIONED_FIX);
|
|
257
|
+
}
|
|
258
|
+
let header = `${name}=${encoded}`;
|
|
259
|
+
if (maxAge !== undefined) {
|
|
260
|
+
if (!Number.isSafeInteger(maxAge) || maxAge < 0) {
|
|
261
|
+
const reason = `${renderCauseValue(maxAge)} is not a whole number of seconds >= 0`;
|
|
262
|
+
throw new CookieInvalidError(
|
|
263
|
+
'maxAge',
|
|
264
|
+
name,
|
|
265
|
+
reason,
|
|
266
|
+
'serializeSetCookie(name, value, { maxAge: Math.floor(ms / 1000) }) # 0 clears the cookie',
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
header += `; Max-Age=${maxAge}`;
|
|
270
|
+
}
|
|
271
|
+
if (expires !== undefined) header += expiresAttribute(name, expires);
|
|
272
|
+
if (domain !== undefined) {
|
|
273
|
+
checkAttribute(
|
|
274
|
+
'domain',
|
|
275
|
+
name,
|
|
276
|
+
domain,
|
|
277
|
+
COOKIE_DOMAIN,
|
|
278
|
+
"serializeSetCookie(name, value, { domain: 'example.com' }) # a bare host, or omit domain",
|
|
279
|
+
);
|
|
280
|
+
header += `; Domain=${domain}`;
|
|
281
|
+
}
|
|
282
|
+
checkAttribute(
|
|
283
|
+
'path',
|
|
284
|
+
name,
|
|
285
|
+
path,
|
|
286
|
+
COOKIE_PATH,
|
|
287
|
+
"serializeSetCookie(name, value, { path: '/app' }) # absolute, percent-encoded",
|
|
288
|
+
);
|
|
289
|
+
header += `; Path=${path}`;
|
|
290
|
+
if (httpOnly) header += '; HttpOnly';
|
|
291
|
+
if (secure) header += '; Secure';
|
|
292
|
+
header += `; SameSite=${sameSite}`;
|
|
293
|
+
if (partitioned === true) header += '; Partitioned';
|
|
294
|
+
if (priority !== undefined) {
|
|
295
|
+
checkEnum('priority', name, priority, PRIORITY);
|
|
296
|
+
header += `; Priority=${priority}`;
|
|
297
|
+
}
|
|
298
|
+
return header;
|
|
299
|
+
}
|
package/src/core-error-codes.ts
CHANGED
|
@@ -70,6 +70,8 @@ const CORE_CODE_TITLES = {
|
|
|
70
70
|
// place rather than by whichever package happened to raise one first.
|
|
71
71
|
X_TIMEOUT: 'operation exceeded its deadline',
|
|
72
72
|
X_UNREACHABLE: 'unreachable branch was reached',
|
|
73
|
+
X_SECRETS_KEY_ACL_FAILED: 'key file ACL not owner-only',
|
|
74
|
+
X_COOKIE_INVALID: 'a Set-Cookie value would be dropped, misread or injectable',
|
|
73
75
|
} as const;
|
|
74
76
|
|
|
75
77
|
export type CoreErrorCode = keyof typeof CORE_CODE_TITLES;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The framework's ONE page shape — an entity `findMany`, a query `.page()`, the `?_first` HTTP
|
|
2
|
+
// envelope and the typed client all answer it. Tier 0 so the repo (tier 2) and the read (tier 3)
|
|
3
|
+
// share it rather than each declaring a page whose `nextCursor` meant something different.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One page of a cursor-paginated listing, with ONE meaning: `nextCursor` is the cursor for the
|
|
7
|
+
* next page, and `null` exactly when `hasMore` is false.
|
|
8
|
+
*
|
|
9
|
+
* A union and not an interface, so the two facts cannot disagree in any value that typechecks:
|
|
10
|
+
* `{ nextCursor: null, hasMore: true }` is a build error, and `if (page.hasMore)` narrows
|
|
11
|
+
* `nextCursor` to `string`. `while (page.hasMore)` and `while (page.nextCursor !== null)` are the
|
|
12
|
+
* same loop and both stop on the last page, with no extra round trip to learn it was the last.
|
|
13
|
+
*
|
|
14
|
+
* Before 25.0.0 there were two pages: entity's own `Page` (null = last page, no `hasMore`) and query's
|
|
15
|
+
* `Page` (null only on an EMPTY page, `hasMore` carried "last") — so a `while (page.nextCursor)`
|
|
16
|
+
* loop was right on one and fetched an empty page past the end of the other.
|
|
17
|
+
*/
|
|
18
|
+
export type Page<Row> =
|
|
19
|
+
| {
|
|
20
|
+
readonly rows: readonly Row[];
|
|
21
|
+
/** Pass back as `cursor` (a repo) or `after` (a read) — `_after` on the wire. */
|
|
22
|
+
readonly nextCursor: string;
|
|
23
|
+
readonly hasMore: true;
|
|
24
|
+
}
|
|
25
|
+
| {
|
|
26
|
+
readonly rows: readonly Row[];
|
|
27
|
+
readonly nextCursor: null;
|
|
28
|
+
readonly hasMore: false;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The only constructor: `hasMore` is DERIVED from the cursor, never passed beside it, so a
|
|
33
|
+
* producer cannot answer "more" without saying where. A producer that learned "no more" passes
|
|
34
|
+
* `null` — the cursor it could have minted for its last row is not a next page. An empty string is
|
|
35
|
+
* no cursor (a falsy cursor would end a `while (page.nextCursor)` loop that `hasMore` continues).
|
|
36
|
+
*/
|
|
37
|
+
export function pageOf<Row>(rows: readonly Row[], nextCursor: string | null): Page<Row> {
|
|
38
|
+
return nextCursor === null || nextCursor === ''
|
|
39
|
+
? { rows, nextCursor: null, hasMore: false }
|
|
40
|
+
: { rows, nextCursor, hasMore: true };
|
|
41
|
+
}
|
package/src/cursor.ts
CHANGED
|
@@ -36,7 +36,17 @@ export class CursorInvalidError extends UltimateError {
|
|
|
36
36
|
* — a fixed literal rather than a per-process random one on purpose: a random secret would make
|
|
37
37
|
* a cursor issued by one instance fail on the next, and that failure only shows up under scale.
|
|
38
38
|
*/
|
|
39
|
-
const
|
|
39
|
+
export const DEV_CURSOR_SECRET = 'ultimate-dev-cursor-secret';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The env key a deployed process signs cursors with. Named once: signing below, the boot refusal
|
|
43
|
+
* (`dev-secrets.ts`'s `CursorSecretDevError`) and `@ultimat3/cli`'s deploy contract
|
|
44
|
+
* (`.env.example`, `x env check`, `x doctor`) all read this constant.
|
|
45
|
+
*/
|
|
46
|
+
export const CURSOR_SECRET_KEY = 'ULTIMATE_CURSOR_SECRET';
|
|
47
|
+
|
|
48
|
+
/** The one fix for `X_CURSOR_SECRET_DEV`, wherever it is reported — the boot and the CLI. */
|
|
49
|
+
export const CURSOR_SECRET_FIX = `export ${CURSOR_SECRET_KEY}="$(openssl rand -hex 32)"`;
|
|
40
50
|
|
|
41
51
|
/** `configureCursorSigning`'s value, when an app has called it. `undefined` means "read the env". */
|
|
42
52
|
let configured: string | undefined;
|
|
@@ -49,7 +59,10 @@ let configured: string | undefined;
|
|
|
49
59
|
* warned and nothing failed.
|
|
50
60
|
*/
|
|
51
61
|
function currentSecret(): string {
|
|
52
|
-
|
|
62
|
+
// `||`, never `??`: `ULTIMATE_CURSOR_SECRET=` (a blank compose or chart value) is the EMPTY
|
|
63
|
+
// string, which `??` keeps — an HMAC keyed by '' that anyone can forge, while
|
|
64
|
+
// `usesDevCursorSecret()` answered `false` and the boot check passed. Empty is unset.
|
|
65
|
+
return configured || Bun.env[CURSOR_SECRET_KEY] || DEV_CURSOR_SECRET;
|
|
53
66
|
}
|
|
54
67
|
|
|
55
68
|
/**
|
|
@@ -74,9 +87,17 @@ export function resetCursorSigning(): void {
|
|
|
74
87
|
configured = undefined;
|
|
75
88
|
}
|
|
76
89
|
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
90
|
+
/**
|
|
91
|
+
* True while cursors are signed with the shipped dev key. Bare, it asks THIS process — exactly what
|
|
92
|
+
* signing reads. With `env`, it asks that table the way signing would read it (`configured` first,
|
|
93
|
+
* then the key, empty counting as unset) — `@ultimat3/storage`'s `usesDevStorageSecret({ env })`
|
|
94
|
+
* twin, which `x env check` asks of a deploy's environment rather than of the CLI's own.
|
|
95
|
+
*/
|
|
96
|
+
export function usesDevCursorSecret(options?: {
|
|
97
|
+
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
98
|
+
}): boolean {
|
|
99
|
+
if (options?.env === undefined) return currentSecret() === DEV_CURSOR_SECRET;
|
|
100
|
+
return (configured || options.env[CURSOR_SECRET_KEY] || DEV_CURSOR_SECRET) === DEV_CURSOR_SECRET;
|
|
80
101
|
}
|
|
81
102
|
|
|
82
103
|
/** `base64url(payload).signature`. Opaque by contract: callers must never parse it. */
|
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. */
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A declared retirement, rendered as the two headers the standards already define — RFC 9745
|
|
3
|
+
* `Deprecation` and RFC 8594 `Sunset` — plus the successor link. Pure string and date maths, and
|
|
4
|
+
* deliberately throw-free: each caller raises its own `X_*` for a date it cannot render.
|
|
5
|
+
*
|
|
6
|
+
* One home for `@ultimat3/action` and `@ultimat3/query`, which are both tier 3 and so cannot share
|
|
7
|
+
* a module sideways; their twins had already drifted in prose. `HELPER_HOMES` refuses a third.
|
|
8
|
+
*/
|
|
9
|
+
import { counter } from './metrics';
|
|
10
|
+
|
|
11
|
+
export interface Deprecation {
|
|
12
|
+
/** When it was deprecated. ISO-8601, e.g. `'2026-08-01T00:00:00Z'`. */
|
|
13
|
+
readonly since: string;
|
|
14
|
+
/** When it stops answering. ISO-8601 — the date `Sunset` publishes and clients plan against. */
|
|
15
|
+
readonly sunset: string;
|
|
16
|
+
/** The export name of the replacement, projected to a `rel="successor-version"` link. */
|
|
17
|
+
readonly replacedBy?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type DeprecationField = 'since' | 'sunset';
|
|
21
|
+
|
|
22
|
+
export type DeprecationRender =
|
|
23
|
+
| {
|
|
24
|
+
readonly ok: true;
|
|
25
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
26
|
+
/** The same facts as data: OpenAPI `x-ultimate`, the descriptor and the manifest. */
|
|
27
|
+
readonly meta: Readonly<Record<string, string>>;
|
|
28
|
+
}
|
|
29
|
+
| { readonly ok: false; readonly field: DeprecationField; readonly value: string };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* How many calls a deprecated declaration is still taking — the number "can we remove it yet?"
|
|
33
|
+
* needs and the one nothing in the framework could answer. Attributes are the primitive and the
|
|
34
|
+
* declared NAME, both bounded by the size of the codebase; a caller id here would be an unbounded
|
|
35
|
+
* series, which is the cardinality mistake core's own overflow bucket exists to catch. Declared
|
|
36
|
+
* once, here: two declaring modules were two owners of one series.
|
|
37
|
+
*/
|
|
38
|
+
const deprecatedCalls = counter('deprecated_calls_total', {
|
|
39
|
+
unit: '{call}',
|
|
40
|
+
description: 'Calls served by a declaration that has been deprecated, by primitive and name',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export function recordDeprecatedCall(primitive: 'action' | 'query', name: string): void {
|
|
44
|
+
deprecatedCalls.add(1, { primitive, name });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `Deprecation` is a structured-field Date (`@` + unix seconds, RFC 9745); `Sunset` is an
|
|
49
|
+
* HTTP-date (IMF-fixdate, RFC 8594). Two spellings of one instant because two RFCs chose
|
|
50
|
+
* differently — never render one in the other's format, and never emit `Invalid Date`.
|
|
51
|
+
*/
|
|
52
|
+
export function renderDeprecation(
|
|
53
|
+
deprecation: Deprecation,
|
|
54
|
+
successorPath: string | undefined,
|
|
55
|
+
): DeprecationRender {
|
|
56
|
+
const since = Date.parse(deprecation.since);
|
|
57
|
+
if (Number.isNaN(since)) return { ok: false, field: 'since', value: deprecation.since };
|
|
58
|
+
const sunset = Date.parse(deprecation.sunset);
|
|
59
|
+
if (Number.isNaN(sunset)) return { ok: false, field: 'sunset', value: deprecation.sunset };
|
|
60
|
+
|
|
61
|
+
const headers: Record<string, string> = {
|
|
62
|
+
deprecation: `@${Math.floor(since / 1000)}`,
|
|
63
|
+
sunset: new Date(sunset).toUTCString(),
|
|
64
|
+
};
|
|
65
|
+
// The successor's URL, derived by the caller from its own route naming — the one the typed
|
|
66
|
+
// client uses. A link built here from the export name would be a second URL derivation.
|
|
67
|
+
if (successorPath !== undefined) {
|
|
68
|
+
headers['link'] = `<${successorPath}>; rel="successor-version"`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const meta: Record<string, string> = {
|
|
72
|
+
since: new Date(since).toISOString(),
|
|
73
|
+
sunset: new Date(sunset).toISOString(),
|
|
74
|
+
...(deprecation.replacedBy === undefined ? {} : { replacedBy: deprecation.replacedBy }),
|
|
75
|
+
};
|
|
76
|
+
return { ok: true, headers, meta };
|
|
77
|
+
}
|
package/src/dev-secrets.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// environment. `x doctor` reported it; nothing failed — so a production pod that forgot
|
|
3
3
|
// ULTIMATE_CURSOR_SECRET signed every cursor with a key published in this package.
|
|
4
4
|
|
|
5
|
-
import { usesDevCursorSecret } from './cursor';
|
|
5
|
+
import { CURSOR_SECRET_FIX, CURSOR_SECRET_KEY, usesDevCursorSecret } from './cursor';
|
|
6
6
|
import { isLocal } from './environment';
|
|
7
7
|
import { UltimateError } from './errors';
|
|
8
8
|
|
|
@@ -14,14 +14,14 @@ export class CursorSecretDevError extends UltimateError {
|
|
|
14
14
|
static readonly code = 'X_CURSOR_SECRET_DEV';
|
|
15
15
|
override readonly name = 'CursorSecretDevError';
|
|
16
16
|
|
|
17
|
-
//
|
|
17
|
+
// The key and the fix are `cursor.ts`'s constants, never spelled here: `x doctor` and
|
|
18
|
+
// `x env check` report this code with `CURSOR_SECRET_FIX` too, and one code has one fix.
|
|
18
19
|
constructor() {
|
|
19
20
|
super({
|
|
20
21
|
code: CursorSecretDevError.code,
|
|
21
|
-
cause:
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
meta: { variable: 'ULTIMATE_CURSOR_SECRET' },
|
|
22
|
+
cause: `${CURSOR_SECRET_KEY} is unset or empty, so this process signs cursors with the development key the framework ships — anyone can forge a page position`,
|
|
23
|
+
fix: CURSOR_SECRET_FIX,
|
|
24
|
+
meta: { variable: CURSOR_SECRET_KEY },
|
|
25
25
|
});
|
|
26
26
|
}
|
|
27
27
|
}
|
|
@@ -40,6 +40,17 @@ export interface DevSecretsOptions {
|
|
|
40
40
|
* signing reads it, so this refuses what the process WILL sign with, not what `env` claims.
|
|
41
41
|
*/
|
|
42
42
|
export function assertNoDevSecretsOutsideLocal(options: DevSecretsOptions = {}): void {
|
|
43
|
-
if (
|
|
43
|
+
if (!devSecretsRefused(options)) return;
|
|
44
44
|
if (usesDevCursorSecret()) throw new CursorSecretDevError();
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* THE rule for whether an environment refuses a shipped development secret: anything but
|
|
49
|
+
* `development`/`test`, and a table naming no environment counts as production (fails closed).
|
|
50
|
+
* The boot asks it above; `@ultimat3/cli` asks it of a deploy's table (`x env check`, `x doctor`),
|
|
51
|
+
* so a diagnostic cannot call green an environment the boot refuses. Throws
|
|
52
|
+
* `X_ENVIRONMENT_INVALID` on an unknown `ULTIMATE_ENV`, as the boot does.
|
|
53
|
+
*/
|
|
54
|
+
export function devSecretsRefused(options: DevSecretsOptions = {}): boolean {
|
|
55
|
+
return !isLocal({ env: options.env, fallback: 'production' });
|
|
56
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Single responsibility: the drain budget's default and its domain — `drain.deadlineMs` in
|
|
2
|
+
// `app.config.ts`. One module because the config validator, the lifecycle's own default and the
|
|
3
|
+
// chart's grace period must all mean the same number.
|
|
4
|
+
|
|
5
|
+
import { countIssue } from './config-count';
|
|
6
|
+
import { readinessGraceIssue } from './lifecycle-grace';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How long a SIGTERM'd process has to finish what it holds — in-flight requests, a running job's
|
|
10
|
+
* step — before the lifecycle abandons the rest. 25 s sits inside a 30 s kubelet grace with the
|
|
11
|
+
* teardown margin beside it; an app whose jobs run longer raises it, and the chart's
|
|
12
|
+
* `terminationGracePeriodSeconds` is derived from the raised number.
|
|
13
|
+
*/
|
|
14
|
+
export const DRAIN_DEADLINE_DEFAULT_MS = 25_000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* An hour. Past it a deploy is not draining, it is waiting on work that should checkpoint: a step
|
|
18
|
+
* is the replay unit, so a job longer than this belongs in more steps, not a longer drain.
|
|
19
|
+
*/
|
|
20
|
+
export const DRAIN_DEADLINE_MAX_MS = 3_600_000;
|
|
21
|
+
|
|
22
|
+
const DEADLINE_KEY = 'drain.deadlineMs';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Why a value is not a drain budget, or `undefined` when it is one. A whole number of milliseconds
|
|
26
|
+
* in `1 ≤ v ≤ 3600000`. Zero is refused here although `configureLifecycle` accepts it: "drain now"
|
|
27
|
+
* is a test's or a tool's decision, never one a deployed app makes by typing it into its config.
|
|
28
|
+
*/
|
|
29
|
+
export function drainDeadlineIssue(value: unknown): string | undefined {
|
|
30
|
+
const count = countIssue(DEADLINE_KEY, value, 1);
|
|
31
|
+
if (count !== undefined) return count;
|
|
32
|
+
return (value as number) > DRAIN_DEADLINE_MAX_MS
|
|
33
|
+
? `${DEADLINE_KEY} must be at most ${DRAIN_DEADLINE_MAX_MS} milliseconds — a longer drain is work that should checkpoint in steps, not a deploy waiting on it`
|
|
34
|
+
: undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The `drain` section's issues, one per key, for `defineConfig`'s validator. */
|
|
38
|
+
export function drainIssues(drain: {
|
|
39
|
+
readonly readinessGraceMs: unknown;
|
|
40
|
+
readonly deadlineMs: unknown;
|
|
41
|
+
}): readonly (string | undefined)[] {
|
|
42
|
+
return [readinessGraceIssue(drain.readinessGraceMs), drainDeadlineIssue(drain.deadlineMs)];
|
|
43
|
+
}
|
package/src/env-example.ts
CHANGED
|
@@ -2,16 +2,12 @@
|
|
|
2
2
|
// second hand-maintained list. Render it from the declarations, and report drift when the file on
|
|
3
3
|
// disk has fallen behind. Loading `.env` itself is Bun's job — see `envFileCandidates()`.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
//
|
|
6
|
+
// It REPORTS and never throws. `assertEnvExample` and its `EnvExampleDriftError` were deleted in
|
|
7
|
+
// 25.0.0: a second, weaker gate nothing called. `X_ENV_EXAMPLE_DRIFT` is `x verify`'s `manifest`
|
|
8
|
+
// step's finding (`@ultimat3/cli`'s `app-env.ts`), byte-for-byte against `renderEnvExample`.
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
static readonly code = 'X_ENV_EXAMPLE_DRIFT';
|
|
10
|
-
override readonly name = 'EnvExampleDriftError';
|
|
11
|
-
constructor(init: CodedErrorInit) {
|
|
12
|
-
super({ ...init, code: EnvExampleDriftError.code });
|
|
13
|
-
}
|
|
14
|
-
}
|
|
10
|
+
import type { EnvSchema, EnvVarDecl } from './env';
|
|
15
11
|
|
|
16
12
|
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
17
13
|
|
|
@@ -99,11 +95,10 @@ export interface EnvExampleReport {
|
|
|
99
95
|
/**
|
|
100
96
|
* In the file, not in the schema — never fatal, because apps set keys nothing declares.
|
|
101
97
|
*
|
|
102
|
-
* NOT reported on its own
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* public. `env-example.test.ts` pins both halves.
|
|
98
|
+
* NOT reported on its own. `ok` is `missing.length === 0`, so an example carrying only extra
|
|
99
|
+
* keys returns `ok: true`; the framework's reporter (`@ultimat3/cli`'s `app-env.ts`) builds its
|
|
100
|
+
* finding from `missing` only. A caller that wants it reads `checkEnvExample(...).extra` itself,
|
|
101
|
+
* which is why this stays public. `env-example.test.ts` pins it.
|
|
107
102
|
*/
|
|
108
103
|
readonly extra: readonly string[];
|
|
109
104
|
}
|
|
@@ -115,18 +110,3 @@ export function checkEnvExample(schema: EnvSchema, text: string): EnvExampleRepo
|
|
|
115
110
|
const extra = [...present].filter((key) => !declared.includes(key));
|
|
116
111
|
return { ok: missing.length === 0, missing, extra };
|
|
117
112
|
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Throws `X_ENV_EXAMPLE_DRIFT` when the committed example has fallen behind the schema — the
|
|
121
|
-
* failure an agent hits *before* a teammate hits `X_ENV_MISSING` on a variable nobody told them
|
|
122
|
-
* about.
|
|
123
|
-
*/
|
|
124
|
-
export function assertEnvExample(schema: EnvSchema, text: string, path = ENV_EXAMPLE_PATH): void {
|
|
125
|
-
const report = checkEnvExample(schema, text);
|
|
126
|
-
if (report.ok) return;
|
|
127
|
-
throw new EnvExampleDriftError({
|
|
128
|
-
cause: `${path} does not declare ${report.missing.join(', ')}, declared by defineEnv()`,
|
|
129
|
-
fix: `Bun.write('${path}', renderEnvExample(schema)) — regenerate it from the declarations`,
|
|
130
|
-
meta: { path, missing: report.missing, extra: report.extra },
|
|
131
|
-
});
|
|
132
|
-
}
|