@ultimat3/core 29.4.0 → 30.1.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 (89) hide show
  1. package/CLAUDE.md +45 -81
  2. package/README.md +137 -10
  3. package/package.json +19 -3
  4. package/src/address-class.ts +53 -12
  5. package/src/aws-sigv4.ts +104 -8
  6. package/src/breaker.ts +177 -0
  7. package/src/client-dispatch.ts +76 -17
  8. package/src/client-flight.ts +4 -4
  9. package/src/client-paths-pins.ts +92 -0
  10. package/src/client-problem.ts +25 -1
  11. package/src/client-scope.ts +3 -1
  12. package/src/client-stream.ts +106 -0
  13. package/src/client-transport.ts +16 -2
  14. package/src/config-assets.ts +64 -0
  15. package/src/config-client-errors.ts +132 -0
  16. package/src/config-defaults.ts +18 -4
  17. package/src/config-failure-capture.ts +70 -0
  18. package/src/config-health.ts +8 -0
  19. package/src/config-islands.ts +1 -1
  20. package/src/config-keys.ts +2 -0
  21. package/src/config-site.ts +16 -0
  22. package/src/config-time.ts +40 -0
  23. package/src/config.ts +37 -26
  24. package/src/context.ts +92 -10
  25. package/src/core-error-codes.ts +19 -0
  26. package/src/cursor.ts +7 -3
  27. package/src/deadline-signal.ts +57 -0
  28. package/src/declared-errors-openapi.ts +70 -0
  29. package/src/declared-errors-scan.ts +79 -0
  30. package/src/declared-errors.ts +185 -0
  31. package/src/define-error.ts +200 -0
  32. package/src/deprecation.ts +14 -3
  33. package/src/drain-deadline.ts +22 -0
  34. package/src/env.ts +8 -3
  35. package/src/error-fingerprint.ts +95 -0
  36. package/src/error-reporter-chain.ts +45 -0
  37. package/src/error-reporter-sentry.ts +4 -0
  38. package/src/error-reporter.ts +14 -1
  39. package/src/error-status-declared.ts +82 -0
  40. package/src/exercise-errors.ts +41 -0
  41. package/src/exercise-run.ts +176 -0
  42. package/src/exports/error-contract.ts +7 -0
  43. package/src/exports/observability.ts +26 -0
  44. package/src/failure-capture-flush.ts +93 -0
  45. package/src/failure-capture-mask.ts +54 -0
  46. package/src/failure-capture.ts +205 -0
  47. package/src/fill-wire.ts +30 -0
  48. package/src/finite-option.ts +7 -0
  49. package/src/health-disclosure.ts +2 -2
  50. package/src/hmac.ts +47 -0
  51. package/src/index.ts +172 -9
  52. package/src/json-storable.ts +21 -0
  53. package/src/keyed-fingerprint.ts +3 -7
  54. package/src/lifecycle.ts +5 -1
  55. package/src/logger.ts +1 -1
  56. package/src/otlp-metric-exporter.ts +7 -1
  57. package/src/otlp-post.ts +147 -0
  58. package/src/otlp-span-exporter.ts +7 -1
  59. package/src/otlp.ts +4 -37
  60. package/src/outbound-attempt.ts +235 -0
  61. package/src/outbound-dial.ts +103 -0
  62. package/src/outbound-errors.ts +193 -0
  63. package/src/outbound-hops.ts +135 -0
  64. package/src/outbound-read.ts +103 -0
  65. package/src/outbound-subpath.ts +26 -0
  66. package/src/outbound-types.ts +108 -0
  67. package/src/outbound.ts +223 -0
  68. package/src/page-meta.ts +8 -0
  69. package/src/page.ts +9 -0
  70. package/src/record-envelope-openapi.ts +6 -0
  71. package/src/record-envelope.ts +33 -9
  72. package/src/record-sink.ts +13 -0
  73. package/src/redis-client.ts +78 -0
  74. package/src/registrar.ts +11 -0
  75. package/src/render-node.ts +13 -0
  76. package/src/render-nodes.ts +137 -0
  77. package/src/retry-loop.ts +197 -0
  78. package/src/retry.ts +47 -145
  79. package/src/roles.ts +2 -2
  80. package/src/route-path-merge.ts +55 -0
  81. package/src/route-path-template.ts +124 -0
  82. package/src/runtime-metrics.ts +16 -1
  83. package/src/same-origin.ts +18 -2
  84. package/src/session-lost.ts +115 -0
  85. package/src/single-flight.ts +75 -40
  86. package/src/source-mask.ts +36 -2
  87. package/src/tool-description.ts +29 -0
  88. package/src/visible-interval.ts +153 -0
  89. package/src/webhook-signature.ts +9 -11
@@ -90,12 +90,19 @@ export interface SeoSitemapConfig {
90
90
  export interface SeoConfig {
91
91
  readonly robots: SeoRobotsConfig;
92
92
  readonly sitemap: SeoSitemapConfig;
93
+ /**
94
+ * `/llms.txt`: the `site/` pages' titles and descriptions for a language model. On by default,
95
+ * like the sitemap, over the same indexable pages; `false` serves an empty document and lifts the
96
+ * `seo` step's description rule (`X_SEO_LLMS_DESCRIPTION_MISSING`). Optional in the TYPE only.
97
+ */
98
+ readonly llms?: boolean | undefined;
93
99
  }
94
100
 
95
101
  /** `robots` is NESTED for `AiConfigInput`'s reason: `section` applies a patch one level deep. */
96
102
  export interface SeoConfigInput {
97
103
  readonly robots?: Input<SeoRobotsConfig> | undefined;
98
104
  readonly sitemap?: Input<SeoSitemapConfig> | undefined;
105
+ readonly llms?: boolean | undefined;
99
106
  }
100
107
 
101
108
  export interface SiteSections {
@@ -124,6 +131,11 @@ export function mergeSite(layers: readonly SiteSectionsInput[]): SiteSections {
124
131
  { extra: [], lastmod: 'none', sections: [], pageSize: SITEMAP_PAGE_SIZE_MAX },
125
132
  layers.map((layer) => layer.seo?.sitemap),
126
133
  ),
134
+ // The last layer that says, else on.
135
+ llms: layers.reduce<boolean>(
136
+ (on, layer) => (layer.seo?.llms === undefined ? on : layer.seo.llms),
137
+ true,
138
+ ),
127
139
  },
128
140
  };
129
141
  }
@@ -196,4 +208,8 @@ export function siteIssues(config: SiteSections, issues: string[]): void {
196
208
  );
197
209
  }
198
210
  issues.push(...sitemapSectionIssues(config.seo.sitemap.sections, config.seo.sitemap.pageSize));
211
+ const llms: unknown = config.seo.llms;
212
+ if (llms !== undefined && typeof llms !== 'boolean') {
213
+ issues.push(`seo.llms must be true or false, not ${describeValue(llms)}`);
214
+ }
199
215
  }
@@ -0,0 +1,40 @@
1
+ // Single responsibility: the `time` block of `app.config.ts` — whether `app/` documents carry the
2
+ // zone script that tells the next request the visitor's zone. No default zone lives here, ever:
3
+ // every format call names its zone. Shape, merge and screen are one subject; `config.ts` is full.
4
+
5
+ import { booleanIssue } from './config-shape';
6
+
7
+ /**
8
+ * OFF BY DEFAULT: `detect` writes a cookie (`x_timezone_detected`) on every `app/` document, and a
9
+ * cookie is the app's decision. On, a visitor's second page renders in their zone with no picker.
10
+ */
11
+ export interface AppTimeConfig {
12
+ readonly detect: boolean;
13
+ }
14
+
15
+ export const TIME_DEFAULTS: AppTimeConfig = Object.freeze({ detect: false });
16
+
17
+ export interface TimeSection {
18
+ readonly time: AppTimeConfig;
19
+ }
20
+
21
+ export interface TimeSectionInput {
22
+ readonly time?: { readonly detect?: boolean | undefined } | undefined;
23
+ }
24
+
25
+ /** The last layer that said `detect` wins it — every section's rule. */
26
+ export function mergeTime(layers: readonly TimeSectionInput[]): TimeSection {
27
+ let { detect } = TIME_DEFAULTS;
28
+ for (const layer of layers) {
29
+ if (layer.time?.detect !== undefined) detect = layer.time.detect;
30
+ }
31
+ return { time: { detect } };
32
+ }
33
+
34
+ /** Appends every refusal the section earns to `issues`, `config.ts`' one list. */
35
+ export function timeIssues(config: TimeSection, issues: string[]): void {
36
+ // `unknown`: an untyped config file reaches this validator with whatever it wrote.
37
+ const detect: unknown = config.time.detect;
38
+ const issue = booleanIssue('time.detect', detect);
39
+ if (issue !== undefined) issues.push(issue);
40
+ }
package/src/config.ts CHANGED
@@ -7,8 +7,11 @@
7
7
  // drift into two vocabularies with no map between them (issue #293).
8
8
  import { CACHE_TIERS, type CacheTierName } from './cache-vocabulary';
9
9
  import type { AiConfig, AiConfigInput } from './config-ai';
10
+ import * as assetsSection from './config-assets';
11
+ import * as clientErrors from './config-client-errors';
10
12
  import { countIssue } from './config-count';
11
13
  import { configDefaults } from './config-defaults';
14
+ import * as failureCapture from './config-failure-capture';
12
15
  import { BASE_FIX, CACHE_TIER_FIX } from './config-fixes';
13
16
  import { type DrainConfig, type HealthConfig, readinessModeIssue } from './config-health';
14
17
  import type { IslandsConfig, IslandsSectionInput } from './config-islands';
@@ -31,6 +34,7 @@ import {
31
34
  } from './config-shape';
32
35
  import type { SeoConfig, SiteConfig, SiteSectionsInput } from './config-site';
33
36
  import { mergeSite, siteIssues } from './config-site';
37
+ import * as timeSection from './config-time';
34
38
  import { drainIssues } from './drain-deadline';
35
39
  import { describeValue } from './error-render';
36
40
  import { ConfigInvalidError } from './errors';
@@ -102,21 +106,20 @@ export interface NotifyConfig {
102
106
  export const INBOX_RETENTION_KEYS = ['inboxReadRetentionMs', 'inboxUnreadRetentionMs'] as const;
103
107
  /**
104
108
  * Deliberately thin. `urlEnv`, `poolSize` and `schema` were removed 2026-08 because **nothing
105
- * read them** — the only reader of any `config.database.*` field in the repo was this file's own
106
- * validator, and each of the three was unfixable where it sat:
107
- *
108
- * `poolSize` was a dead second spelling of `DATABASE_POOL_MAX`; `client.ts` reads `DATABASE_URL`
109
- * as a literal, so no `urlEnv` could be honoured; nothing emits `SET search_path` for `schema`.
110
- *
111
- * Wiring them instead would need a tier-0 → tier-1 read the tier table forbids. Deleting is axiom
112
- * 3 applied to configuration: a value that produces neither a build error nor a runtime effect is
113
- * worse than no field, because an SRE sets `poolSize: 3`, redeploys, and nothing changes.
109
+ * read them**: `poolSize` was a dead second spelling of `DATABASE_POOL_MAX`, `client.ts` reads
110
+ * `DATABASE_URL` as a literal, and nothing emits `SET search_path`. A value that produces neither
111
+ * a build error nor a runtime effect is worse than no field (axiom 3).
114
112
  */
115
113
  const DATABASE_DRIVERS = ['postgres'] as const;
116
114
 
117
115
  export interface DatabaseConfig {
118
116
  readonly driver: (typeof DATABASE_DRIVERS)[number];
119
117
  readonly ssl: boolean;
118
+ /**
119
+ * Every `tenant:` table carries a forced policy (`x db gen`) the serving role must be subject to
120
+ * (`X_DB_ROW_SECURITY_BYPASSED`). Off by default: a transaction per bare statement is not free.
121
+ */
122
+ readonly rowSecurity: boolean;
120
123
  }
121
124
 
122
125
  /**
@@ -182,16 +185,10 @@ export interface JobsConfig {
182
185
  }
183
186
 
184
187
  /**
185
- * No `heartbeatMs`. It was declared here, defaulted to 15_000, and read by NOTHING — deleted
186
- * 2026-08-19. The socket beat is `new LiveClient({ heartbeatMs })`, browser code that cannot read
187
- * server config, and the presence beat is DERIVED (`PresenceRegistry.heartbeatMs` is
188
- * `max(1000, floor(ttlMs / 3))`). A second knob is a second number that can disagree with the one
189
- * it is a fraction of, and a knob nothing reads is a knob nothing enforces — axioms 1 and 3.
190
- *
191
- * No `tier` either (deleted 2026-08-23): no file read it, so `tier: 'local-first'` bought nothing.
192
- * An app's realtime tier is what it DECLARES — a `channel()` topic, a `live: true` query, a local
193
- * store — never a config key. `transport`, `urlEnv` and the two per-actor caps (read by the `sync`
194
- * role into its registry and node) are the fields code reads.
188
+ * No `heartbeatMs` (deleted 2026-08-19) and no `tier` (deleted 2026-08-23): read by nothing. The
189
+ * socket beat is `new LiveClient({ heartbeatMs })`, the presence beat is derived, and an app's
190
+ * realtime tier is what it DECLARES (a `channel()`, a `live: true` query) — never a config key.
191
+ * `transport`, `urlEnv` and the two per-actor caps are the fields code reads.
195
192
  */
196
193
  export interface RealtimeConfig {
197
194
  readonly enabled: boolean;
@@ -229,6 +226,10 @@ export interface AppConfig {
229
226
  readonly navigation: NavigationConfig;
230
227
  readonly islands: IslandsConfig;
231
228
  readonly mail: MailConfig;
229
+ readonly assets: assetsSection.AssetsConfig;
230
+ readonly clientErrors: clientErrors.ClientErrorsConfig;
231
+ readonly failureCapture: failureCapture.FailureCaptureConfig;
232
+ readonly time: timeSection.AppTimeConfig;
232
233
  }
233
234
 
234
235
  /**
@@ -246,7 +247,11 @@ export interface AppConfigInput
246
247
  extends SiteSectionsInput,
247
248
  NavigationSectionInput,
248
249
  IslandsSectionInput,
249
- MailSectionInput {
250
+ MailSectionInput,
251
+ assetsSection.AssetsSectionInput,
252
+ clientErrors.ClientErrorsSectionInput,
253
+ failureCapture.FailureCaptureSectionInput,
254
+ timeSection.TimeSectionInput {
250
255
  readonly name: string;
251
256
  readonly theme?: Input<ThemeConfig> | undefined;
252
257
  readonly auth?: Input<AuthConfig> | undefined;
@@ -300,6 +305,7 @@ function validate(config: AppConfig): void {
300
305
  countIssue('cache.defaultTtlMs', config.cache.defaultTtlMs, 0),
301
306
  oneOfIssue('database.driver', config.database.driver, DATABASE_DRIVERS),
302
307
  booleanIssue('database.ssl', config.database.ssl),
308
+ booleanIssue('database.rowSecurity', config.database.rowSecurity),
303
309
  oneOfIssue('theme.defaultMode', config.theme.defaultMode, THEME_MODES),
304
310
  // `null` is the documented "no redirect"; a path that is said must be one a browser can follow.
305
311
  config.auth.signInPath === null
@@ -321,12 +327,9 @@ function validate(config: AppConfig): void {
321
327
  if (config.realtime.transport === 'nats' && config.realtime.urlEnv === undefined) {
322
328
  issues.push(`realtime.transport "nats" requires realtime.urlEnv`);
323
329
  }
324
- // BOTH RETENTION WINDOWS OR NEITHER — `undefined` is a real value here (never swept) and the
325
- // only other legal one is a positive, finite count of milliseconds. Zero is refused rather than
326
- // read as "immediately": a sweep at age 0 deletes every row the instant it is written, which is
327
- // an inbox that silently receives nothing, and nobody types it on purpose. `describeValue`
328
- // rather than `${…}`: this validator is the boundary a JS app's untyped config crosses, so the
329
- // value here is `unknown` in practice however the interface types it.
330
+ // `undefined` (never swept) or a positive, finite count of ms. Zero is refused, never read as
331
+ // "immediately": a sweep at age 0 is an inbox that silently receives nothing. `describeValue`:
332
+ // this is the boundary a JS app's untyped config crosses, so the value is `unknown` in practice.
330
333
  for (const key of INBOX_RETENTION_KEYS) {
331
334
  const ms: unknown = config.notify[key];
332
335
  if (ms === undefined) continue;
@@ -345,6 +348,10 @@ function validate(config: AppConfig): void {
345
348
  navigationIssues(config, issues);
346
349
  islandsIssues(config, issues);
347
350
  mailIssues(config, issues);
351
+ assetsSection.assetsIssues(config, issues);
352
+ clientErrors.clientErrorsIssues(config, issues);
353
+ failureCapture.failureCaptureIssues(config, issues);
354
+ timeSection.timeIssues(config, issues);
348
355
 
349
356
  // A rung the ladder cannot build is the defect this key had: `sortTiers` places a name by its
350
357
  // index in `CACHE_TIERS`, and a name missing from it sorts to `-1` — AHEAD of the request memo.
@@ -441,6 +448,10 @@ function merge(name: string, layers: readonly AppConfigOverlay[]): AppConfig {
441
448
  ...mergeNavigation(layers),
442
449
  ...mergeIslands(layers),
443
450
  ...mergeMail(layers),
451
+ ...assetsSection.mergeAssets(layers),
452
+ ...clientErrors.mergeClientErrors(layers),
453
+ ...failureCapture.mergeFailureCapture(layers),
454
+ ...timeSection.mergeTime(layers),
444
455
  };
445
456
  }
446
457
 
package/src/context.ts CHANGED
@@ -34,7 +34,7 @@ import { type Actor, anonymousActor } from './actor';
34
34
  import { asyncContext } from './async-context';
35
35
  import { type Clock, systemClock } from './clock';
36
36
  import { DEFAULT_LOCALE, DEFAULT_TIME_ZONE } from './default-locale';
37
- import { UltimateError } from './errors';
37
+ import { isUltimateError, UltimateError } from './errors';
38
38
  import { finiteOption } from './finite-option';
39
39
  import { traceId as newTraceId, uuidV7 } from './ids';
40
40
  import { type Logger, logger as rootLogger, setLoggerContextFields } from './logger';
@@ -214,6 +214,13 @@ export function ctxOf(init: CtxInit = {}): Ctx {
214
214
  return Object.freeze(ctx);
215
215
  }
216
216
 
217
+ /** What bounds a `runToCompletion` scope, when its caller owns a ceiling. */
218
+ export interface CompletionBound {
219
+ readonly signal?: AbortSignal | undefined;
220
+ /** Epoch ms: what an outbound hop forwards as the budget left. */
221
+ readonly deadlineAt?: number | undefined;
222
+ }
223
+
217
224
  export function runWithContext<T>(ctx: Ctx, fn: () => T): T {
218
225
  // A request scope is what gives an outbound typed call a budget to forward; see the module.
219
226
  installTraceHeaders();
@@ -288,6 +295,27 @@ function composeSignal(parent: AbortSignal, patch: AbortSignal | undefined): Abo
288
295
  */
289
296
  export function withChildContext<T>(patch: CtxPatch, fn: () => T): T {
290
297
  const parent = useContext();
298
+ const child = derivedContext(parent, patch, {
299
+ // One request, one budget: a child scope inherits the deadline for the same reason it
300
+ // inherits `requestId`. A patch may SHORTEN it (a step with its own budget); nothing here
301
+ // lengthens it, because the socket the parent is answering does not move. `??` alone said
302
+ // that and did not do it — a patched hour replaced a parent's second outright, and
303
+ // `remainingBudgetMs` then put the hour on `x-request-timeout-ms` for the next hop.
304
+ deadlineAt: earliest(screenDeadline(patch.deadlineAt) ?? undefined, parent.deadlineAt),
305
+ signal: composeSignal(parent.signal, patch.signal),
306
+ });
307
+ return requestContext.run(child, fn);
308
+ }
309
+
310
+ /**
311
+ * The one build of a child context, narrowing (`withChildContext`) or not (`runToCompletion`): the
312
+ * caller decides the two members that say how long this work may run, everything else is the rule.
313
+ */
314
+ function derivedContext(
315
+ parent: Ctx,
316
+ patch: CtxPatch,
317
+ lifetime: { readonly deadlineAt: number | undefined; readonly signal: AbortSignal },
318
+ ): Ctx {
291
319
  // A factory-managed service was built for the PARENT's actor; forwarding it verbatim into an
292
320
  // impersonated child would answer every call with the parent's tenant. `ctxOf` below
293
321
  // rebuilds every registered factory fresh against the child's own actor, so only services no
@@ -295,7 +323,7 @@ export function withChildContext<T>(patch: CtxPatch, fn: () => T): T {
295
323
  const carried = Object.fromEntries(
296
324
  Object.entries(parent.services).filter(([name]) => !isManagedService(name)),
297
325
  );
298
- const child = ctxOf({
326
+ return ctxOf({
299
327
  requestId: parent.requestId,
300
328
  traceId: patch.traceId ?? parent.traceId,
301
329
  actor: patch.actor ?? parent.actor,
@@ -305,18 +333,72 @@ export function withChildContext<T>(patch: CtxPatch, fn: () => T): T {
305
333
  role: patch.role ?? parent.role,
306
334
  clock: patch.clock ?? parent.clock,
307
335
  logger: patch.logger ?? parent.logger,
308
- // One request, one budget: a child scope inherits the deadline for the same reason it
309
- // inherits `requestId`. A patch may SHORTEN it (a step with its own budget); nothing here
310
- // lengthens it, because the socket the parent is answering does not move. `??` alone said
311
- // that and did not do it — a patched hour replaced a parent's second outright, and
312
- // `remainingBudgetMs` then put the hour on `x-request-timeout-ms` for the next hop.
313
- deadlineAt: earliest(screenDeadline(patch.deadlineAt) ?? undefined, parent.deadlineAt),
314
- signal: composeSignal(parent.signal, patch.signal),
336
+ deadlineAt: lifetime.deadlineAt,
337
+ signal: lifetime.signal,
315
338
  services: { ...carried, ...(patch.services ?? {}) },
316
339
  });
340
+ }
341
+
342
+ /**
343
+ * Why this scope's work no longer belongs to anyone: the ambient signal's reason, when it was
344
+ * aborted with a CODED one, the same object. Coded means somebody was answered (`X_TIMEOUT`) or
345
+ * another attempt owns the run (a job's `X_ABORTED`, `X_DRAINING`, `X_JOB_TIMEOUT`,
346
+ * `X_JOB_SLOT_LOST`, `X_JOB_LEASE_LOST`), so a write landing now lands behind their back. A client
347
+ * hang-up is Bun's uncoded `AbortError`: nobody was told anything, and a click followed by a
348
+ * navigation must keep landing, so it answers `undefined`.
349
+ */
350
+ export function abandonment(): UltimateError | undefined {
351
+ const signal = tryUseContext()?.signal;
352
+ if (signal?.aborted !== true) return undefined;
353
+ return isUltimateError(signal.reason) ? signal.reason : undefined;
354
+ }
355
+
356
+ /**
357
+ * Run `fn` to its end whatever happens to the scope around it: a FRESH signal nothing aborts and no
358
+ * deadline, same request id and actor, managed services rebuilt over it. The one escape from
359
+ * `abandonment()`, which then has no special case; `retry()` and `outbound()` inside keep working,
360
+ * and both honour a `bound`'s signal and deadline as they do the request's.
361
+ * Unbounded by default, and work left hanging holds its slot for good: a caller owning work it cannot
362
+ * trust passes `bound` (`withIdempotency`'s `ceilingMs`, `onSettled`'s timeout), whose coded abort is
363
+ * abandonment again. Inside an open transaction wrap the whole `withTransaction`: its COMMIT is
364
+ * outside an inner scope, and refused. Outside a context it just runs `fn`.
365
+ */
366
+ export function runToCompletion<T>(fn: () => T, bound: CompletionBound = {}): T {
367
+ const parent = tryUseContext();
368
+ if (parent === undefined) return fn();
369
+ const child = completionScope(parent, {
370
+ deadlineAt: screenDeadline(bound.deadlineAt) ?? undefined,
371
+ signal: bound.signal ?? new AbortController().signal,
372
+ });
317
373
  return requestContext.run(child, fn);
318
374
  }
319
375
 
376
+ /**
377
+ * The lifetime a frozen `ctxOf` child can hold. A context that is not frozen is wider than core can
378
+ * name (`@ultimat3/http`'s request, a tier above): a rebuilt one drops its fields, and a copy would
379
+ * lose a slot written inside (`setRedirect`'s, read back on the original). So it is forwarded, and
380
+ * only the lifetime and what closed over it read differently: the same object, a fresh signal.
381
+ */
382
+ function completionScope(
383
+ parent: Ctx,
384
+ lifetime: { readonly deadlineAt: number | undefined; readonly signal: AbortSignal },
385
+ ): Ctx {
386
+ const child = derivedContext(parent, {}, lifetime);
387
+ if (Object.isFrozen(parent)) return child;
388
+ const fresh = new Map<string | symbol, unknown>([
389
+ ['signal', child.signal],
390
+ ['deadlineAt', child.deadlineAt],
391
+ ['services', child.services],
392
+ ]);
393
+ for (const name of Object.keys(child.services)) {
394
+ // Only what IS the service today: a service named `url` never shadows the request's own.
395
+ if (parent[name] === parent.services[name]) fresh.set(name, child.services[name]);
396
+ }
397
+ return new Proxy(parent, {
398
+ get: (target, key) => (fresh.has(key) ? fresh.get(key) : Reflect.get(target, key)),
399
+ });
400
+ }
401
+
320
402
  /** Resolve a late-bound service. Throws `X_SERVICE_MISSING` rather than returning undefined. */
321
403
  export function useService<T>(name: string): T {
322
404
  const ctx = useContext();
@@ -341,7 +423,7 @@ export function throwIfAborted(ctx: Ctx = useContext()): void {
341
423
  throw new UltimateError({
342
424
  code: 'X_ABORTED',
343
425
  cause: `request ${ctx.requestId} was aborted by the caller`,
344
- fix: 'add throwIfAborted(ctx) before expensive work, or pass fetch(url, { signal: ctx.signal }) — the caller is gone, so unwind instead of finishing',
426
+ fix: 'add throwIfAborted(ctx) before expensive work, and make each vendor call with outbound({ url, vendor, dial, deadlineMs }) from @ultimat3/core/outbound, which hears ctx.signal — the caller is gone, so unwind instead of finishing',
345
427
  meta: { requestId: ctx.requestId },
346
428
  });
347
429
  }
@@ -72,6 +72,25 @@ const CORE_CODE_TITLES = {
72
72
  X_UNREACHABLE: 'unreachable branch was reached',
73
73
  X_SECRETS_KEY_ACL_FAILED: 'key file ACL not owner-only',
74
74
  X_COOKIE_INVALID: 'a Set-Cookie value would be dropped, misread or injectable',
75
+ X_OUTBOUND_TIMEOUT: 'an outbound HTTP call passed its deadline or went idle',
76
+ X_OUTBOUND_UNREACHABLE: 'an outbound HTTP call got no response',
77
+ X_OUTBOUND_THROTTLED: 'an outbound HTTP call was told to wait',
78
+ X_OUTBOUND_UNAVAILABLE: 'an outbound HTTP call answered a retryable status',
79
+ X_OUTBOUND_REFUSED: 'an outbound HTTP call answered a non-retryable status',
80
+ X_OUTBOUND_TOO_LARGE: 'an outbound HTTP response body passed its byte cap',
81
+ X_OUTBOUND_HOST_BLOCKED: 'an outbound HTTP call refused the address it would reach',
82
+ X_OUTBOUND_REDIRECTED: 'an outbound HTTP call answered a redirect',
83
+ X_OUTBOUND_RETRY_UNSAFE: 'a retry was asked for on a write that is not idempotent',
84
+ X_OUTBOUND_BODY_INVALID: 'an outbound HTTP response did not match its schema',
85
+ X_BREAKER_OPEN: 'a circuit breaker is open and refused the call',
86
+ X_ERROR_DECLARATION_INVALID: 'a defineError declaration is malformed',
87
+ X_CLIENT_ERROR: 'a browser reported an uncaught failure',
88
+ X_HTTP_PATH_INPUT_CONFLICT:
89
+ 'a path parameter and the request body or query name one key with two values',
90
+ X_ERROR_UNDECLARED: 'a handler threw a 4xx code its errors: list does not declare',
91
+ X_ACTION_ERRORS_INVALID: 'an errors: list names a code that cannot be declared there',
92
+ X_CONTRACT_EXERCISE_ENVIRONMENT: 'a contract exercise was asked to run in a deployed environment',
93
+ X_CONTRACT_EXERCISE_UNSCOPED: 'a contract exercise could not roll back its writes',
75
94
  } as const;
76
95
 
77
96
  export type CoreErrorCode = keyof typeof CORE_CODE_TITLES;
package/src/cursor.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  // a silently wrong page. It is tamper-evidence, not authorization: policy still runs per page.
9
9
 
10
10
  import { UltimateError } from './errors';
11
+ import { hmac } from './hmac';
11
12
 
12
13
  export interface CursorPayload {
13
14
  /** What this cursor belongs to: one read plus its arguments. A cursor is not portable. */
@@ -45,8 +46,11 @@ export const DEV_CURSOR_SECRET = 'ultimate-dev-cursor-secret';
45
46
  */
46
47
  export const CURSOR_SECRET_KEY = 'ULTIMATE_CURSOR_SECRET';
47
48
 
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)"`;
49
+ /**
50
+ * The one fix for `X_CURSOR_SECRET_DEV`, wherever it is reported — the boot and the CLI. No
51
+ * `export`: it is gone on an agent's next shell call, and it never reaches a deployed process.
52
+ */
53
+ export const CURSOR_SECRET_FIX = `openssl rand -hex 32 # put the output in this deploy's secret store as ${CURSOR_SECRET_KEY}; this machine: ULTIMATE_ENV=development x env check`;
50
54
 
51
55
  /** `configureCursorSigning`'s value, when an app has called it. `undefined` means "read the env". */
52
56
  let configured: string | undefined;
@@ -132,7 +136,7 @@ export function decodeCursor(cursor: string, scope: string): CursorPayload {
132
136
 
133
137
  /** Truncated HMAC-SHA256. 128 bits is far past forging a page position. */
134
138
  function sign(body: string): string {
135
- return new Bun.CryptoHasher('sha256', currentSecret()).update(body).digest('hex').slice(0, 32);
139
+ return hmac('sha256', currentSecret(), body, 'hex').slice(0, 32);
136
140
  }
137
141
 
138
142
  /** Constant time: the comparison must not leak how much of a forged signature was right. */
@@ -0,0 +1,57 @@
1
+ // One request's signal: a deadline plus the caller's own signal, undone when the request settles.
2
+ // `AbortSignal.any([AbortSignal.timeout(ms), outer])` could not be undone: every read hung one
3
+ // listener on a session-long `outer` and armed a timer that outlived the answer. In core since
4
+ // `outbound()` arms one per attempt; `@ultimat3/scraping` imports it from `@ultimat3/core/outbound`.
5
+
6
+ /**
7
+ * The timer a request DEADLINE is armed on — not a wait, but a timer all the same, so a test hands
8
+ * its own and fires it rather than waiting. Cleared when the request settles, where
9
+ * `AbortSignal.timeout` stayed armed for its whole span after the answer had landed.
10
+ */
11
+ export interface DeadlineTimer {
12
+ set(fire: () => void, ms: number): unknown;
13
+ clear(handle: unknown): void;
14
+ }
15
+
16
+ export const platformDeadlineTimer: DeadlineTimer = Object.freeze({
17
+ set: (fire: () => void, ms: number): unknown => setTimeout(fire, ms),
18
+ clear: (handle: unknown): void => clearTimeout(handle as ReturnType<typeof setTimeout>),
19
+ });
20
+
21
+ export interface DeadlineSignal extends Disposable {
22
+ /** Aborts at the deadline (a `TimeoutError`, as `AbortSignal.timeout`'s) or with `outer`. */
23
+ readonly signal: AbortSignal;
24
+ /** Whether the DEADLINE fired — not the caller cancelling. What the timeout code is chosen by. */
25
+ timedOut(): boolean;
26
+ }
27
+
28
+ /**
29
+ * `ms` is the caller's already-screened deadline (a positive finite count). `[Symbol.dispose]`
30
+ * clears the timer and detaches from `outer`; idempotent, and it never aborts.
31
+ */
32
+ export function deadlineSignal(
33
+ ms: number,
34
+ outer?: AbortSignal,
35
+ timer: DeadlineTimer = platformDeadlineTimer,
36
+ ): DeadlineSignal {
37
+ const controller = new AbortController();
38
+ let fired = false;
39
+ const handle = timer.set(() => {
40
+ fired = true;
41
+ controller.abort(new DOMException('The operation timed out.', 'TimeoutError'));
42
+ }, ms);
43
+ const forward = (): void => controller.abort(outer?.reason);
44
+ if (outer?.aborted === true) forward();
45
+ else outer?.addEventListener('abort', forward, { once: true });
46
+ let open = true;
47
+ return {
48
+ signal: controller.signal,
49
+ timedOut: () => fired,
50
+ [Symbol.dispose]: () => {
51
+ if (!open) return;
52
+ open = false;
53
+ timer.clear(handle);
54
+ outer?.removeEventListener('abort', forward);
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,70 @@
1
+ // Single responsibility: an operation's `responses` with its declared `errors:` in them — one entry
2
+ // per status, the Problem narrowed to the declared `type`s. Shared by `@ultimat3/action`'s and
3
+ // `@ultimat3/query`'s documents (one tier, no sideways import); `@ultimat3/http`'s status reader
4
+ // and `problemTypeFor` are passed in, being a tier above this package.
5
+
6
+ import type { ErrorStatusOf } from './declared-errors';
7
+
8
+ export interface DeclaredResponsesWire {
9
+ readonly statusOf: ErrorStatusOf;
10
+ /** `problemTypeFor`: the `type` a Problem document carries for a code. */
11
+ readonly typeFor: (code: string) => string;
12
+ /** The `$ref` of the document's `Problem` schema. */
13
+ readonly problemRef: string;
14
+ }
15
+
16
+ /** The extension naming the declared codes a response may carry, in every case machine-readable. */
17
+ export const DECLARED_ERRORS_EXTENSION = 'x-ultimate-errors';
18
+
19
+ const descriptionOf = (held: unknown): string | undefined => {
20
+ if (typeof held !== 'object' || held === null) return undefined;
21
+ const description = (held as { readonly description?: unknown }).description;
22
+ return typeof description === 'string' ? description : undefined;
23
+ };
24
+
25
+ /**
26
+ * `responses`, member for member, when nothing is declared — an undeclared operation's bytes
27
+ * are today's. Each declared code joins the entry for its status, codes sorted. A status the
28
+ * framework already answers keeps its open `Problem` (it may carry codes no list names — a
29
+ * policy's denial, `X_INPUT_INVALID`) and gains the codes in its description and extension; a
30
+ * status only the list answers narrows `type` to an enum of exactly those codes.
31
+ */
32
+ export function withDeclaredResponses(
33
+ responses: Readonly<Record<string, unknown>>,
34
+ declared: readonly string[] | undefined,
35
+ wire: DeclaredResponsesWire,
36
+ ): Record<string, unknown> {
37
+ if (declared === undefined || declared.length === 0) return { ...responses };
38
+ const byStatus = new Map<string, string[]>();
39
+ for (const code of [...declared].sort()) {
40
+ const status = String(wire.statusOf(code));
41
+ byStatus.set(status, [...(byStatus.get(status) ?? []), code]);
42
+ }
43
+ const merged: Record<string, unknown> = { ...responses };
44
+ for (const [status, codes] of byStatus) {
45
+ const held = responses[status];
46
+ const listed = codes.join(' | ');
47
+ merged[status] =
48
+ held === undefined
49
+ ? {
50
+ description: listed,
51
+ content: {
52
+ 'application/problem+json': {
53
+ schema: {
54
+ allOf: [
55
+ { $ref: wire.problemRef },
56
+ { properties: { type: { enum: codes.map(wire.typeFor) } } },
57
+ ],
58
+ },
59
+ },
60
+ },
61
+ [DECLARED_ERRORS_EXTENSION]: codes,
62
+ }
63
+ : {
64
+ ...(held as Record<string, unknown>),
65
+ description: `${descriptionOf(held) ?? status} | ${listed}`,
66
+ [DECLARED_ERRORS_EXTENSION]: codes,
67
+ };
68
+ }
69
+ return merged;
70
+ }
@@ -0,0 +1,79 @@
1
+ // Single responsibility: the static half of `errors:` — the codes a handler's FIRST HOP names in
2
+ // its own source, as `X_…` literals and as `new <Class>` of a `defineError` class. Test-time only:
3
+ // it reads `Function.prototype.toString()` and the file a class was declared in, which an
4
+ // unminified server build keeps and nothing in production asks for.
5
+
6
+ import { definedErrorSites } from './define-error';
7
+
8
+ const CODE_LITERAL = /["'`](X(?:_[A-Z0-9]+)+)["'`]/g;
9
+ // Bun prints `new PostNotFound` with no parentheses when the call passes nothing, and keeps a
10
+ // namespace import's `E.PostNotFound`: the last segment is the binding a declaration exported.
11
+ const CONSTRUCTION = /\bnew\s+(?:[A-Za-z_$][\w$]*\.)*([A-Za-z_$][\w$]*)/g;
12
+
13
+ /**
14
+ * Every code `source` names, sorted: its literals, and each `new <Class>` whose name is a key of
15
+ * `classCodes`. First hop only — a helper the handler calls is its own source, reached by the
16
+ * dynamic half when a test runs the path.
17
+ */
18
+ export function scanHandlerSource(
19
+ source: string,
20
+ classCodes: ReadonlyMap<string, string>,
21
+ ): readonly string[] {
22
+ const codes = new Set<string>();
23
+ for (const match of source.matchAll(CODE_LITERAL)) codes.add(match[1] as string);
24
+ for (const match of source.matchAll(CONSTRUCTION)) {
25
+ const code = classCodes.get(match[1] as string);
26
+ if (code !== undefined) codes.add(code);
27
+ }
28
+ return [...codes].sort();
29
+ }
30
+
31
+ /** `file:line` out of one stack frame — `at /a/b.ts:8:29` or `at fn (file:///a/b.ts:8:29)`. */
32
+ export function frameSite(frame: string): { readonly file: string; readonly line: number } | null {
33
+ const match = /(?:\(|at\s+)(?:file:\/\/)?([^()\s][^()]*?):(\d+):\d+\)?\s*$/.exec(frame.trim());
34
+ if (match === null) return null;
35
+ return { file: match[1] as string, line: Number(match[2]) };
36
+ }
37
+
38
+ const BINDING = /([A-Za-z_$][\w$]*)\s*(?::[^=]*)?=\s*defineError\b/;
39
+
40
+ /**
41
+ * The name `defineError`'s result was bound to, read off the declaring line (or the one above it,
42
+ * for `const X =` broken before the call). `null` for an inline or re-bound class: the dynamic
43
+ * half still judges its throw.
44
+ */
45
+ export function bindingAt(text: string, line: number): string | null {
46
+ const lines = text.split('\n');
47
+ const declaring = lines[line - 1] ?? '';
48
+ const found = BINDING.exec(declaring) ?? BINDING.exec(`${lines[line - 2] ?? ''} ${declaring}`);
49
+ return found === null ? null : (found[1] as string);
50
+ }
51
+
52
+ /** Reads a declaring file. Injected so a test answers without a disk; `Bun.file` otherwise. */
53
+ export type SourceReader = (file: string) => Promise<string>;
54
+
55
+ const readWithBun: SourceReader = (file) => Bun.file(file).text();
56
+
57
+ /**
58
+ * Binding name → code, for every `defineError` class whose declaring line can still be read. A
59
+ * file that cannot be read contributes nothing: the scan is a first hop, never the verdict.
60
+ */
61
+ export async function definedErrorClassCodes(
62
+ read: SourceReader = readWithBun,
63
+ ): Promise<ReadonlyMap<string, string>> {
64
+ const byName = new Map<string, string>();
65
+ const texts = new Map<string, Promise<string | null>>();
66
+ for (const [code, frame] of definedErrorSites()) {
67
+ const site = frameSite(frame);
68
+ if (site === null) continue;
69
+ let text = texts.get(site.file);
70
+ if (text === undefined) {
71
+ text = read(site.file).catch(() => null);
72
+ texts.set(site.file, text);
73
+ }
74
+ const source = await text;
75
+ const name = source === null ? null : bindingAt(source, site.line);
76
+ if (name !== null) byName.set(name, code);
77
+ }
78
+ return byName;
79
+ }