@ultimat3/http 30.7.0 → 30.9.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 CHANGED
@@ -21,9 +21,8 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
21
21
  `trustedProxyHops`, `rateLimit.scope`) is a type error where an app writes it. `mergeHttpConfig`
22
22
  merges `security.csp.extend` per directive. `type-pins.ts` refuses a key on `HttpConfig` missing
23
23
  from `HttpConfigInput`.
24
- - **No drain deadline here**: `drain.deadlineMs` is the one budget (core, per role). Key
25
- `drainTimeoutMs` (deleted 25.0.0) is `X_CONFIG_INVALID` via `refuseDeletedHttpKeys`.
26
- **`ServerOptions.drain`** passes only `readinessGraceMs`, and only when declared.
24
+ - **No drain deadline here**: `drain.deadlineMs` is the one budget (core, per role); the deleted
25
+ key and `ServerOptions.drain`: `docs/history/http.md#no-drain-deadline`.
27
26
  - **`cors.origins: ['*']` with `credentials: true`, or `'null'` in the list, is `X_CORS_CONFIG_INVALID`.** A refused origin
28
27
  still gets `vary: origin`.
29
28
  - **`DEFAULT_CORS` is what `clientTransport` sends and reads**, off core's constants; `cors.test.ts` observes the transport's real traffic, so a new header fails it. `date` alone stays hidden: this server writes delta-seconds. An app's list replaces the default whole.
@@ -219,8 +218,8 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
219
218
  | `context.ts` | `RequestContext` (core's `Ctx` plus the request's own), composed from `ctxOf`, the single `Ctx` adapter (`asCtx`) and the inbound-header readers |
220
219
  | `redirect.ts` | the intent slot a handler that cannot return a `Response` fills |
221
220
  | `set-cookie.ts` | `setCookie`/`deleteCookie`: one line each onto `ctx.headers` |
222
- | `object-response.ts` | `objectResponse`, the one answer for an object held elsewhere, structural (no storage import); `notModified`, the one `if-none-match` answer |
223
- | `stored-object-headers.ts` | inline only for raster images, audio, video; else `attachment` + `sandbox`. Private to `objectResponse` |
221
+ | `object-response.ts` | `objectResponse`: an object held elsewhere, structural; `notModified`: the `if-none-match` answer |
222
+ | `stored-object-headers.ts` | inline only for raster images, audio, video; else `attachment` + `sandbox` (private) |
224
223
  | `auth-redirect.ts` | where an unauthenticated browser goes, and where it comes back to |
225
224
  | `cache-policy.ts` | the default `CacheHint` for a route that declared none — route AND actor — and the review of a declared one (`reviewedHint`) |
226
225
  | `error-log-level.ts` | the level of the `error-map` stage's one line: `error` for 5xx, `warn` for 401/403/429, `info` for the rest |
@@ -245,6 +244,7 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
245
244
  | `app-config.ts` | the app's own HTTP declaration (`configureHttp`) and the layering that keeps a boot fact above it |
246
245
  | `compress.ts` | the `compress` stage's rule: which answers leave gzipped |
247
246
  | `compress-read.ts` | the one body read that rule does: counted, deadline-bound, replayable |
247
+ | `request-as.ts` | `withRequestAs`: an exercise entry's `request` (cookies, headers) as an inbound request, for `x exercise` and the contract step |
248
248
 
249
249
  ## Commands
250
250
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/http",
3
- "version": "30.7.0",
3
+ "version": "30.9.0",
4
4
  "description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors",
5
5
  "keywords": [
6
6
  "ultimate",
@@ -45,9 +45,9 @@
45
45
  "test": "bun test"
46
46
  },
47
47
  "dependencies": {
48
- "@ultimat3/core": "30.7.0",
49
- "@ultimat3/i18n": "30.7.0",
50
- "@ultimat3/schema": "30.7.0",
51
- "@ultimat3/time": "30.7.0"
48
+ "@ultimat3/core": "30.9.0",
49
+ "@ultimat3/i18n": "30.9.0",
50
+ "@ultimat3/schema": "30.9.0",
51
+ "@ultimat3/time": "30.9.0"
52
52
  }
53
53
  }
@@ -94,4 +94,6 @@ export const HTTP_ERROR_STATUS = {
94
94
  X_MCP_IDEMPOTENCY_KEY_SHADOWED: 500,
95
95
  X_ROUTE_POST_INVALID: 500,
96
96
  X_ROUTE_NAVIGATION_INVALID: 500,
97
+ // @ultimat3/http — an exercise entry's request is not one a browser could send
98
+ X_EXERCISE_REQUEST_INVALID: 500,
97
99
  } satisfies Readonly<Record<string, number>>;
@@ -38,6 +38,7 @@ export const HTTP_OWNED_ERROR_CODES = [
38
38
  'X_WEBHOOK_SIGNATURE_STALE',
39
39
  'X_BEARER_MOUNT_INVALID',
40
40
  'X_RATE_LIMIT_STORE_TIMEOUT',
41
+ 'X_EXERCISE_REQUEST_INVALID',
41
42
  ] as const;
42
43
 
43
44
  export type HttpOwnedErrorCode = (typeof HTTP_OWNED_ERROR_CODES)[number];
@@ -73,6 +74,7 @@ export const HTTP_ERROR_TITLES: Readonly<Record<HttpOwnedErrorCode, string>> = {
73
74
  X_WEBHOOK_SIGNATURE_STALE: 'the inbound webhook is signed correctly and is too old to accept',
74
75
  X_BEARER_MOUNT_INVALID: 'a bearer mount declaration cannot be served as written',
75
76
  X_RATE_LIMIT_STORE_TIMEOUT: 'the shared rate-limit store did not answer inside its own deadline',
77
+ X_EXERCISE_REQUEST_INVALID: "an exercise entry's request is not one a browser could send",
76
78
  };
77
79
 
78
80
  // Registered at module load, unconditionally, in one call, so core's registry renders OUR title
package/src/index.ts CHANGED
@@ -201,6 +201,8 @@ export { RATE_LIMIT_STORE_DEADLINE_MS } from './rate-limit-store-deadline';
201
201
  export { setRedirect, takeRedirect } from './redirect';
202
202
  export type { QueryValues } from './request';
203
203
  export { UltimateRequest } from './request';
204
+ export type { ExerciseRequest, ExerciseRequestOption } from './request-as';
205
+ export { withRequestAs } from './request-as';
204
206
  export type { RequestFacts, RequestFactsInput } from './request-facts';
205
207
  export { requestFacts } from './request-facts';
206
208
  export type { CacheHint, RedirectIntent, RedirectStatus } from './response';
@@ -0,0 +1,118 @@
1
+ // A test-time call as an HTTP request reaches it: a fresh request, an actor seated on it, and the
2
+ // cookies and headers an exercise entry declares. Here, below action, query and testing, because
3
+ // the contract step and `x exercise` both make this call and one build of it is the only one.
4
+
5
+ import type { Actor } from '@ultimat3/core';
6
+ import {
7
+ anonymousActor,
8
+ renderCauseValue,
9
+ renderFixLiteral,
10
+ runWithContext,
11
+ UltimateError,
12
+ useContext,
13
+ withChildContext,
14
+ } from '@ultimat3/core';
15
+ import { defineHttpConfig } from './config';
16
+ import { asCtx, requestContext } from './context';
17
+
18
+ /** One exercise call's inbound request: `cookies` become its `Cookie:` header, never `headers`. */
19
+ export interface ExerciseRequest {
20
+ readonly cookies?: Readonly<Record<string, string>>;
21
+ readonly headers?: Readonly<Record<string, string>>;
22
+ }
23
+
24
+ /** The request, or how to build it for the actor a call runs as (`null`: signed out). */
25
+ export type ExerciseRequestOption =
26
+ | ExerciseRequest
27
+ | ((actor: Actor | null) => ExerciseRequest | Promise<ExerciseRequest>);
28
+
29
+ /** Where these requests say they came from: no host an app serves, and never sent. */
30
+ const EXERCISE_ORIGIN = 'https://exercise.test';
31
+
32
+ /** RFC 6265 `cookie-name` is an RFC 9110 `token`: a name outside it no browser sends. */
33
+ const COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
34
+
35
+ function refuse(name: string, why: string): never {
36
+ throw new UltimateError({
37
+ code: 'X_EXERCISE_REQUEST_INVALID',
38
+ cause: `defineExercise() gives ${renderCauseValue(name)} a request ${why}`,
39
+ fix: `defineExercise({ ${renderFixLiteral(name, '<name>')}: { request: { cookies: { <token>: '<value>' }, headers: { '<x-name>': '<value>' } } } }) # in apps/web/exercise.ts — a cookie goes in cookies, never a Cookie header`,
40
+ meta: { name },
41
+ });
42
+ }
43
+
44
+ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
45
+ typeof value === 'object' && value !== null && !Array.isArray(value);
46
+
47
+ /**
48
+ * The declared request as inbound headers. Each cookie value is percent-encoded, which
49
+ * `useRequestCookie` (core's `readCookie`) inverts exactly: the value declared is the value read.
50
+ */
51
+ function inboundHeaders(name: string, declared: unknown): Headers {
52
+ if (!isRecord(declared)) return refuse(name, 'that is not an object of cookies and headers');
53
+ const { cookies = {}, headers = {} } = declared;
54
+ if (!isRecord(cookies) || !isRecord(headers)) {
55
+ return refuse(name, 'whose cookies or headers is not an object of strings');
56
+ }
57
+ const inbound = new Headers();
58
+ for (const [key, value] of Object.entries(headers)) {
59
+ if (key.toLowerCase() === 'cookie') return refuse(name, 'with a Cookie header');
60
+ if (typeof value !== 'string') return refuse(name, `whose header ${key} is not a string`);
61
+ try {
62
+ inbound.set(key, value);
63
+ } catch {
64
+ return refuse(name, `whose header ${renderCauseValue(key)} Headers refuses`);
65
+ }
66
+ }
67
+ const pairs = Object.entries(cookies).map(([key, value]) => {
68
+ if (!COOKIE_NAME.test(key)) return refuse(name, `naming cookie ${renderCauseValue(key)}`);
69
+ if (typeof value !== 'string') return refuse(name, `whose cookie ${key} is not a string`);
70
+ return `${key}=${encodeURIComponent(value)}`;
71
+ });
72
+ if (pairs.length > 0) inbound.set('cookie', pairs.join('; '));
73
+ return inbound;
74
+ }
75
+
76
+ /**
77
+ * `fn` inside a fresh request as `actor`: a POST to `name`, its rate limits process-scoped,
78
+ * carrying `request` (none: no cookie, no header). A function `request` is awaited first, inside a
79
+ * bare request as the same actor, so it can mint a session for whoever acts. The actor is ON the
80
+ * request, so the caller passes `invoke` no `actor` — its impersonation path rebuilds a plain child
81
+ * context and drops the request's fields. The child is derived first for the services an actor
82
+ * binds, then the request's fields go over it, as `withAnonymousRequest` does.
83
+ */
84
+ export async function withRequestAs<T>(
85
+ name: string,
86
+ actor: Actor | null,
87
+ request: ExerciseRequestOption | undefined,
88
+ fn: () => Promise<T>,
89
+ ): Promise<T> {
90
+ const declared: ExerciseRequest | undefined =
91
+ typeof request === 'function'
92
+ ? await inRequest(name, actor, new Headers(), () => Promise.resolve(request(actor)))
93
+ : request;
94
+ const headers = declared === undefined ? new Headers() : inboundHeaders(name, declared);
95
+ return inRequest(name, actor, headers, fn);
96
+ }
97
+
98
+ function inRequest<T>(
99
+ name: string,
100
+ actor: Actor | null,
101
+ requestHeaders: Headers,
102
+ fn: () => Promise<T>,
103
+ ): Promise<T> {
104
+ const request = requestContext({
105
+ url: new URL(`/${encodeURIComponent(name)}`, EXERCISE_ORIGIN),
106
+ method: 'POST',
107
+ role: 'web',
108
+ config: defineHttpConfig({ rateLimit: { scope: 'process' } }),
109
+ requestHeaders,
110
+ });
111
+ return runWithContext(asCtx(request), () =>
112
+ withChildContext({ actor: actor ?? anonymousActor() }, () => {
113
+ const child = useContext();
114
+ const scope = { ...child, ...request, actor: child.actor };
115
+ return runWithContext(asCtx(scope), fn);
116
+ }),
117
+ );
118
+ }