@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.
Files changed (98) hide show
  1. package/CLAUDE.md +29 -26
  2. package/README.md +98 -30
  3. package/package.json +4 -7
  4. package/src/actor.ts +9 -0
  5. package/src/address-class.ts +40 -4
  6. package/src/assert.ts +9 -5
  7. package/src/audit.ts +144 -0
  8. package/src/aws-sigv4.ts +275 -0
  9. package/src/backoff.ts +16 -0
  10. package/src/bunfs.ts +17 -0
  11. package/src/client-dispatch.ts +24 -3
  12. package/src/client-flight.ts +68 -13
  13. package/src/client-problem.ts +62 -6
  14. package/src/client-retry-after.ts +47 -0
  15. package/src/client-transport.ts +3 -1
  16. package/src/client-wire.ts +27 -3
  17. package/src/config-ai.ts +32 -0
  18. package/src/config-defaults.ts +53 -0
  19. package/src/config-fixes.ts +0 -10
  20. package/src/config-health.ts +9 -2
  21. package/src/config-jobs.ts +51 -0
  22. package/src/config-keys.ts +170 -0
  23. package/src/config-mail.ts +73 -0
  24. package/src/config-merge.ts +9 -1
  25. package/src/config-navigation.ts +1 -25
  26. package/src/config-pwa.ts +42 -5
  27. package/src/config-removed.ts +131 -0
  28. package/src/config-shape.ts +79 -0
  29. package/src/config-site.ts +14 -3
  30. package/src/config.ts +141 -173
  31. package/src/context.ts +29 -13
  32. package/src/cookie.ts +299 -0
  33. package/src/core-error-codes.ts +2 -0
  34. package/src/cursor-page.ts +41 -0
  35. package/src/cursor.ts +26 -5
  36. package/src/decimal-order.ts +5 -4
  37. package/src/deprecation.ts +77 -0
  38. package/src/dev-secrets.ts +18 -7
  39. package/src/drain-deadline.ts +43 -0
  40. package/src/env-example.ts +9 -29
  41. package/src/error-reporter-sentry.ts +7 -3
  42. package/src/errors.ts +18 -9
  43. package/src/exports/error-contract.ts +0 -1
  44. package/src/exports/observability.ts +1 -1
  45. package/src/exports/secrets.ts +3 -0
  46. package/src/finite-option.ts +1 -1
  47. package/src/flight-gate.ts +43 -16
  48. package/src/fnv1a.ts +19 -0
  49. package/src/generation-fence.ts +1 -1
  50. package/src/health-disclosure.ts +43 -0
  51. package/src/host-rules.ts +28 -1
  52. package/src/html-escape.ts +24 -0
  53. package/src/ids.ts +7 -7
  54. package/src/image/canvas.ts +76 -5
  55. package/src/image/errors.ts +3 -1
  56. package/src/image/pipeline.ts +17 -5
  57. package/src/image/png-pixels.ts +29 -6
  58. package/src/image/probe.ts +7 -2
  59. package/src/image/raster.ts +27 -2
  60. package/src/index.ts +99 -33
  61. package/src/iso-date.ts +1 -1
  62. package/src/lifecycle-errors.ts +1 -1
  63. package/src/lifecycle-readiness.ts +60 -2
  64. package/src/lifecycle-signals.ts +27 -2
  65. package/src/lifecycle-types.ts +96 -0
  66. package/src/lifecycle.ts +44 -133
  67. package/src/locale-direction.ts +1 -1
  68. package/src/logger.ts +92 -16
  69. package/src/mcp-exposure.ts +70 -8
  70. package/src/measurement-actor.ts +16 -1
  71. package/src/metric-errors.ts +32 -0
  72. package/src/metric-registry.ts +151 -0
  73. package/src/metric-series.ts +94 -0
  74. package/src/metrics.ts +9 -255
  75. package/src/nearest-name.ts +11 -2
  76. package/src/otlp-metric-exporter.ts +1 -1
  77. package/src/otlp-span-exporter.ts +1 -1
  78. package/src/otlp.ts +44 -13
  79. package/src/page.ts +4 -2
  80. package/src/pg-executor.ts +15 -0
  81. package/src/public-cause.ts +37 -0
  82. package/src/registrar.ts +22 -4
  83. package/src/retry.ts +40 -7
  84. package/src/route-rank.ts +36 -0
  85. package/src/same-origin.ts +1 -1
  86. package/src/sampler.ts +6 -2
  87. package/src/secrets-errors.ts +14 -3
  88. package/src/secrets-key-file.ts +139 -0
  89. package/src/secrets-store.ts +32 -16
  90. package/src/service.ts +5 -5
  91. package/src/single-flight.ts +1 -1
  92. package/src/source-mask.ts +14 -8
  93. package/src/store-mode.ts +23 -0
  94. package/src/telemetry.ts +1 -1
  95. package/src/theme-storage.ts +12 -0
  96. package/src/type-pins.ts +51 -1
  97. package/src/image/fixtures.ts +0 -263
  98. 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
+ }
@@ -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 DEV_SECRET = 'ultimate-dev-cursor-secret';
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
- return configured ?? Bun.env['ULTIMATE_CURSOR_SECRET'] ?? DEV_SECRET;
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
- /** True while cursors are signed with the shipped dev key — `x doctor` reports it. */
78
- export function usesDevCursorSecret(): boolean {
79
- return currentSecret() === DEV_SECRET;
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. */
@@ -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 (`@ultimat3/entity`'s `compareByKind`) asks; a caller that
14
- * does NOT know it — `@ultimat3/query`, whose `OrderKey` is a name and a direction — must not,
15
- * because Postgres orders a `text` column holding `"10"` and `"9"` lexically and a comparator
16
- * guessing "both sides look like decimals" would disagree with the SQL it printed.
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
+ }
@@ -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
- // One secret today, so the fix is a literal: a spliced name would be a value in a pasted command.
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
- 'ULTIMATE_CURSOR_SECRET is unset, so this process signs cursors with the development key the framework ships — anyone can forge a page position',
23
- fix: "x secrets set ULTIMATE_CURSOR_SECRET — or export ULTIMATE_CURSOR_SECRET from the platform's secret store",
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 (isLocal({ env: options.env, fallback: 'production' })) return;
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
+ }
@@ -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
- import type { EnvSchema, EnvVarDecl } from './env';
6
- import { type CodedErrorInit, UltimateError } from './errors';
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
- export class EnvExampleDriftError extends UltimateError {
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, and the comment here said it was. `ok` is `missing.length === 0`, so
103
- * an example carrying only extra keys returns `ok: true` and `assertEnvExample` never builds an
104
- * error: the list reaches a surface only as `meta` on a drift some MISSING key already raised.
105
- * A caller that wants it reads `checkEnvExample(...).extra` itself, which is why this stays
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
- }