@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 +5 -5
- package/package.json +5 -5
- package/src/error-map-http.ts +2 -0
- package/src/error-titles.ts +2 -0
- package/src/index.ts +2 -0
- package/src/request-as.ts +118 -0
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)
|
|
25
|
-
|
|
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
|
|
223
|
-
| `stored-object-headers.ts` | inline only for raster images, audio, video; else `attachment` + `sandbox
|
|
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.
|
|
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.
|
|
49
|
-
"@ultimat3/i18n": "30.
|
|
50
|
-
"@ultimat3/schema": "30.
|
|
51
|
-
"@ultimat3/time": "30.
|
|
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
|
}
|
package/src/error-map-http.ts
CHANGED
|
@@ -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>>;
|
package/src/error-titles.ts
CHANGED
|
@@ -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
|
+
}
|