@ultimat3/core 24.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 +24 -27
- package/README.md +71 -34
- 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 +18 -11
- 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 +1 -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 +0 -33
- package/src/config.ts +71 -94
- package/src/context.ts +16 -12
- package/src/cookie.ts +267 -3
- package/src/core-error-codes.ts +2 -0
- package/src/cursor-page.ts +41 -0
- package/src/cursor.ts +23 -5
- 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/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 +29 -14
- package/src/generation-fence.ts +1 -1
- package/src/ids.ts +7 -7
- package/src/image/canvas.ts +76 -5
- package/src/image/pipeline.ts +17 -5
- package/src/image/raster.ts +24 -1
- package/src/index.ts +89 -35
- 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 +16 -7
- 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/page.ts +4 -2
- package/src/registrar.ts +1 -0
- package/src/retry.ts +25 -5
- 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/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/context.ts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// parameters — otherwise every signature in the framework grows a `ctx` argument twice.
|
|
4
4
|
//
|
|
5
5
|
// THERE IS EXACTLY ONE ASSERTION IN THIS FILE AND IT IS IRREDUCIBLE (`As of 2026-08-24`). It is
|
|
6
|
-
// the `as Ctx` in `
|
|
6
|
+
// the `as Ctx` in `ctxOf`, and it is the LAST one: the second — over `preview` — is gone,
|
|
7
7
|
// because `CtxFacts` gives that value an honest type, and `@ultimat3/http`'s
|
|
8
|
-
// `
|
|
8
|
+
// `requestContext` now composes this function instead of building a second context beside
|
|
9
9
|
// it, so that package has none at all.
|
|
10
10
|
//
|
|
11
11
|
// Why the last one cannot go. `Ctx extends CtxServices`, and `CtxServices` is the seam an app
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
// Four alternatives were built and measured before this line was kept. Making the augmented half
|
|
21
21
|
// `Partial<CtxServices>` removes the assertion and turns `ctx.posts` into `PostRepo | undefined`
|
|
22
22
|
// for every app — true, and a breaking change to the documented seam. Requiring `CtxInit.services`
|
|
23
|
-
// to be a `CtxServices` moves the proof to the caller and breaks every internal `
|
|
23
|
+
// to be a `CtxServices` moves the proof to the caller and breaks every internal `ctxOf()`
|
|
24
24
|
// in an app's program, because an app typechecks the framework's sources through its project
|
|
25
|
-
// references. A generic `
|
|
25
|
+
// references. A generic `ctxOf<S>` returns a context no framework caller can pass where a
|
|
26
26
|
// `Ctx` is wanted. And an overload whose implementation signature returns the looser type compiles
|
|
27
27
|
// only through TypeScript's documented bivariance hole — the same assertion, laundered.
|
|
28
28
|
//
|
|
@@ -35,7 +35,7 @@ import { asyncContext } from './async-context';
|
|
|
35
35
|
import { type Clock, systemClock } from './clock';
|
|
36
36
|
import { UltimateError } from './errors';
|
|
37
37
|
import { finiteOption } from './finite-option';
|
|
38
|
-
import { traceId as newTraceId,
|
|
38
|
+
import { traceId as newTraceId, uuidV7 } from './ids';
|
|
39
39
|
import { type Logger, logger as rootLogger, setLoggerContextFields } from './logger';
|
|
40
40
|
import { installTraceHeaders } from './outbound-headers';
|
|
41
41
|
import { type Role, resolveRole } from './roles';
|
|
@@ -73,7 +73,7 @@ export interface ServiceBag {
|
|
|
73
73
|
* against a type carrying members only the app's boot knows about: `Ctx extends CtxServices`, an
|
|
74
74
|
* app augments `CtxServices` with `declare module`, and every service it declares then became a
|
|
75
75
|
* REQUIRED member of every context literal in the framework. `@ultimat3/http`'s
|
|
76
|
-
* `
|
|
76
|
+
* `requestContext` stopped compiling inside `examples/dummy` for exactly that reason
|
|
77
77
|
* (`TS2739: missing posts, orgs`), while the framework's own gate — which augments nothing —
|
|
78
78
|
* stayed green.
|
|
79
79
|
*
|
|
@@ -157,6 +157,10 @@ const requestContext = asyncContext<Ctx>('the request context');
|
|
|
157
157
|
|
|
158
158
|
const neverAborted = new AbortController().signal;
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* The framework's default locale — the ONE declaration: `@ultimat3/i18n` imports it rather than
|
|
162
|
+
* restating it (a second `'en'` there could drift from the context's own default).
|
|
163
|
+
*/
|
|
160
164
|
export const DEFAULT_LOCALE = 'en';
|
|
161
165
|
export const DEFAULT_TIME_ZONE = 'UTC';
|
|
162
166
|
|
|
@@ -164,9 +168,9 @@ function buildId(): string {
|
|
|
164
168
|
return process.env['BUILD_ID'] ?? 'dev';
|
|
165
169
|
}
|
|
166
170
|
|
|
167
|
-
export function
|
|
171
|
+
export function ctxOf(init: CtxInit = {}): Ctx {
|
|
168
172
|
const clock = init.clock ?? systemClock;
|
|
169
|
-
const requestId = init.requestId ??
|
|
173
|
+
const requestId = init.requestId ?? uuidV7(clock);
|
|
170
174
|
const trace = init.traceId ?? newTraceId();
|
|
171
175
|
const base = init.logger ?? rootLogger;
|
|
172
176
|
const explicit: ServiceBag = Object.freeze({ ...(init.services ?? {}) });
|
|
@@ -233,7 +237,7 @@ export function useContext(): Ctx {
|
|
|
233
237
|
throw new UltimateError({
|
|
234
238
|
code: 'X_NO_CONTEXT',
|
|
235
239
|
cause: 'useContext() was called outside of runWithContext()',
|
|
236
|
-
fix: 'wrap the entry point in runWithContext(
|
|
240
|
+
fix: 'wrap the entry point in runWithContext(ctxOf({ ... }), fn)',
|
|
237
241
|
});
|
|
238
242
|
}
|
|
239
243
|
return ctx;
|
|
@@ -291,13 +295,13 @@ function composeSignal(parent: AbortSignal, patch: AbortSignal | undefined): Abo
|
|
|
291
295
|
export function withChildContext<T>(patch: CtxPatch, fn: () => T): T {
|
|
292
296
|
const parent = useContext();
|
|
293
297
|
// A factory-managed service was built for the PARENT's actor; forwarding it verbatim into an
|
|
294
|
-
// impersonated child would answer every call with the parent's tenant. `
|
|
298
|
+
// impersonated child would answer every call with the parent's tenant. `ctxOf` below
|
|
295
299
|
// rebuilds every registered factory fresh against the child's own actor, so only services no
|
|
296
300
|
// factory owns — a hand-built mock nothing registered — carry forward unrebuilt.
|
|
297
301
|
const carried = Object.fromEntries(
|
|
298
302
|
Object.entries(parent.services).filter(([name]) => !isManagedService(name)),
|
|
299
303
|
);
|
|
300
|
-
const child =
|
|
304
|
+
const child = ctxOf({
|
|
301
305
|
requestId: parent.requestId,
|
|
302
306
|
traceId: patch.traceId ?? parent.traceId,
|
|
303
307
|
actor: patch.actor ?? parent.actor,
|
|
@@ -330,7 +334,7 @@ export function useService<T>(name: string): T {
|
|
|
330
334
|
throw new UltimateError({
|
|
331
335
|
code: 'X_SERVICE_MISSING',
|
|
332
336
|
cause: `"${name}" is not on ctx.services (have: ${Object.keys(ctx.services).join(', ')})`,
|
|
333
|
-
fix: `pass it in
|
|
337
|
+
fix: `pass it in ctxOf({ services: { ${name} } })`,
|
|
334
338
|
meta: { name },
|
|
335
339
|
});
|
|
336
340
|
}
|
package/src/cookie.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
// The one
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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';
|
|
4
8
|
|
|
5
9
|
/**
|
|
6
10
|
* A `Cookie:` header is attacker-controlled, and `decodeURIComponent('%')` throws a bare
|
|
@@ -33,3 +37,263 @@ export function readCookie(header: string | null | undefined, name: string): str
|
|
|
33
37
|
}
|
|
34
38
|
return null;
|
|
35
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;
|
|
@@ -52,7 +62,7 @@ function currentSecret(): string {
|
|
|
52
62
|
// `||`, never `??`: `ULTIMATE_CURSOR_SECRET=` (a blank compose or chart value) is the EMPTY
|
|
53
63
|
// string, which `??` keeps — an HMAC keyed by '' that anyone can forge, while
|
|
54
64
|
// `usesDevCursorSecret()` answered `false` and the boot check passed. Empty is unset.
|
|
55
|
-
return configured || Bun.env[
|
|
65
|
+
return configured || Bun.env[CURSOR_SECRET_KEY] || DEV_CURSOR_SECRET;
|
|
56
66
|
}
|
|
57
67
|
|
|
58
68
|
/**
|
|
@@ -77,9 +87,17 @@ export function resetCursorSigning(): void {
|
|
|
77
87
|
configured = undefined;
|
|
78
88
|
}
|
|
79
89
|
|
|
80
|
-
/**
|
|
81
|
-
|
|
82
|
-
|
|
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;
|
|
83
101
|
}
|
|
84
102
|
|
|
85
103
|
/** `base64url(payload).signature`. Opaque by contract: callers must never parse it. */
|
|
@@ -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
|
+
}
|