@ultimat3/http 22.5.1 → 22.6.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 +14 -1
- package/README.md +17 -0
- package/package.json +5 -5
- package/src/bearer-mount.ts +200 -0
- package/src/config.ts +25 -2
- package/src/error-map.ts +9 -0
- package/src/errors.ts +10 -0
- package/src/index.ts +9 -1
- package/src/pipeline.ts +19 -5
- package/src/router.ts +11 -1
- package/src/security-headers.ts +8 -2
- package/src/stages.ts +5 -2
package/CLAUDE.md
CHANGED
|
@@ -57,7 +57,14 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
57
57
|
- **`ctx.actor` is never null** — the `auth` stage turns the hook's `null` into `anonymousActor()`.
|
|
58
58
|
- **The context carries the inbound headers, never the `Request`** (`ctx.requestHeaders`,
|
|
59
59
|
`useRequestHeader` / `useRequestCookie`).
|
|
60
|
-
- **`hooks.authenticate` has one declaration site: `configureAuthenticator()`.**
|
|
60
|
+
- **`hooks.authenticate` has one declaration site: `configureAuthenticator()`.** A route may
|
|
61
|
+
REPLACE it with `meta.authenticate` (never run beside it): `bearerMount` sets it so a session
|
|
62
|
+
cookie authenticates nothing on `/v1/*`.
|
|
63
|
+
- **`bearerMount` re-serves existing `Route`s, never re-projects them** (`bearer-mount.ts`): same
|
|
64
|
+
handler, policy, idempotency; adds the credential (bearer only, `WWW-Authenticate` on 401), the
|
|
65
|
+
cut (outside the token's scopes = `X_ROUTE_NOT_FOUND`, the MCP rule), and a per-token bucket
|
|
66
|
+
keyed by a SHA-256 of the token. Bad prefix / unknown name / double claim:
|
|
67
|
+
`X_BEARER_MOUNT_INVALID` at construction.
|
|
61
68
|
- **`hooks.devNotices` is called only inside the `config.dev && wantsOverlay` branch.**
|
|
62
69
|
|
|
63
70
|
## Rules — the pipeline
|
|
@@ -67,6 +74,11 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
67
74
|
`finalize.ts` owns the tail. Imports go `pipeline.ts` → `stages.ts`; a stage reads
|
|
68
75
|
`StageRunnersInput`, never `PipelineDeps`. A new stage is an entry in both `PIPELINE_STAGES` and the
|
|
69
76
|
`Record<StageName, StageRun>` table, with a `why` and a test.
|
|
77
|
+
- **The request span is named by the route PATTERN** (`GET /r/:token`, `routeSpanName`), started
|
|
78
|
+
as the bare method and renamed after the match; `http.route` is the pattern or `unmatched`. A
|
|
79
|
+
concrete URL carries tokens and is attacker-chosen — never a span name, attribute or label.
|
|
80
|
+
- **The CSP `connect-src` is `'self' blob:`** — no bare `ws:`/`wss:`. A cross-origin sync node is
|
|
81
|
+
the boot's `csp.extend` of that origin exactly. `security.hsts` merges key by key.
|
|
70
82
|
- **The two inbound ids are read BEFORE the context and the span** (`correlation.ts`, core's
|
|
71
83
|
`parseTraceparent`). `x-request-id` is gated on `trustProxy`; `traceparent` deliberately is not.
|
|
72
84
|
- **Every proxy-supplied header goes through `forwardedElement(header, hops)`** — the entry at
|
|
@@ -211,6 +223,7 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
211
223
|
| `webhook-verify.ts` | the INBOUND webhook: the canonical string, the constant-time mac check and the replay window. The outbound half is `webhook()` in `@ultimat3/jobs`, which this package can never import |
|
|
212
224
|
| `locale.ts` | WHERE the request's locale and zone are read from — header and cookie NAMES only, plus `readCookie`. It negotiates nothing |
|
|
213
225
|
| `rate-limit-buckets.ts` | the one point routes and config meet: a route's own bucket, registered or refused |
|
|
226
|
+
| `bearer-mount.ts` | a second door onto existing routes: `Authorization: Bearer` on a prefix, the scope cut, the per-token allowance |
|
|
214
227
|
| `app-config.ts` | the app's own HTTP declaration (`configureHttp`) and the layering that keeps a boot fact above it |
|
|
215
228
|
|
|
216
229
|
## Commands
|
package/README.md
CHANGED
|
@@ -286,6 +286,23 @@ every stage (middleware, finalize, security headers), and an upgraded request mu
|
|
|
286
286
|
halves above cannot carry a case, the fallback is a long-poll `action` (`GET`-shaped, returning
|
|
287
287
|
`{ frames, cursor }` and re-called on return), which is what the app that asked shipped.
|
|
288
288
|
|
|
289
|
+
### A second door: `bearerMount` (`/v1/*`)
|
|
290
|
+
|
|
291
|
+
`As of 22.6.0`. `bearerMount({ prefix, routes, scopes, resolveToken, rateLimit?, rateLimitStore? })`
|
|
292
|
+
re-serves routes the app already projects under a prefix — same handler, same policy — and changes
|
|
293
|
+
exactly three things. Apps declare it through `defineApi({ http: { mounts } })` (`@ultimat3/action`);
|
|
294
|
+
`@ultimat3/cli` builds it in both boots over `apiRoutes()`.
|
|
295
|
+
|
|
296
|
+
| On the mount | Answer |
|
|
297
|
+
|---|---|
|
|
298
|
+
| credential | `Authorization: Bearer` ONLY — `meta.authenticate` replaces the app's authenticator, so a session cookie authenticates nothing here (and a bearer call needs no CSRF proof) |
|
|
299
|
+
| no / unresolved token | 401, `WWW-Authenticate: Bearer` / `Bearer error="invalid_token"` |
|
|
300
|
+
| a primitive outside the token's `scopes` | 404 `X_ROUTE_NOT_FOUND`, identical to an unknown path — MCP's hidden-tool rule |
|
|
301
|
+
| per-token allowance | `RateLimit-Limit/-Remaining/-Reset` on every answer, 429 + `Retry-After` past it; keyed by a SHA-256 of the token |
|
|
302
|
+
| paths | `/api/<x>` → `<prefix>/<x>`, `/_x/query/<x>` → `GET <prefix>/<x>` (`mountedPath`) |
|
|
303
|
+
|
|
304
|
+
`RouteMeta.authenticate` is the mechanism: a route that states one is reached only through it.
|
|
305
|
+
|
|
289
306
|
## Inbound webhooks
|
|
290
307
|
|
|
291
308
|
`verifyWebhookSignature(request, { secret })` is the receiving half of the framework's webhook
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/http",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.6.0",
|
|
4
4
|
"description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "22.
|
|
35
|
-
"@ultimat3/i18n": "22.
|
|
36
|
-
"@ultimat3/schema": "22.
|
|
37
|
-
"@ultimat3/time": "22.
|
|
34
|
+
"@ultimat3/core": "22.6.0",
|
|
35
|
+
"@ultimat3/i18n": "22.6.0",
|
|
36
|
+
"@ultimat3/schema": "22.6.0",
|
|
37
|
+
"@ultimat3/time": "22.6.0"
|
|
38
38
|
}
|
|
39
39
|
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// A second door onto routes the app already projects: `Authorization: Bearer` on a prefix
|
|
2
|
+
// (`/v1/*`), exposing a declared cut of them, per token. Built OVER existing `Route`s — the
|
|
3
|
+
// handler, the policy, the idempotency and the input schema are the ones the `/api` route runs,
|
|
4
|
+
// so there is no second projection of an action to drift from the first. What the mount adds is
|
|
5
|
+
// the credential (only the bearer token authenticates here; a session cookie reaches nothing), the
|
|
6
|
+
// cut (a primitive outside the caller's scopes answers 404, the way MCP answers an unknown tool),
|
|
7
|
+
// and a per-token allowance with `RateLimit-*` and `Retry-After`.
|
|
8
|
+
|
|
9
|
+
import type { Actor, Clock } from '@ultimat3/core';
|
|
10
|
+
import { ACTION_PATH_PREFIX, QUERY_PATH_PREFIX, systemClock } from '@ultimat3/core';
|
|
11
|
+
import type { RequestContext } from './context';
|
|
12
|
+
import { bearerMountInvalid, routeNotFound } from './errors';
|
|
13
|
+
import type { RateLimitDecision, RateLimitStore } from './rate-limit';
|
|
14
|
+
import { memoryRateLimitStore, toBucket } from './rate-limit';
|
|
15
|
+
import { rateLimited } from './rate-limit-errors';
|
|
16
|
+
import type { UltimateRequest } from './request';
|
|
17
|
+
import type { Route, RouteMeta } from './router';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What a token resolves to. Structurally `@ultimat3/mcp`'s `ResolvedToken`, so an app hands the
|
|
21
|
+
* ONE resolver its MCP endpoint already uses (`resolveToken`) to this mount unchanged.
|
|
22
|
+
*/
|
|
23
|
+
export interface BearerCaller {
|
|
24
|
+
readonly actor: Actor;
|
|
25
|
+
/** What the token was issued to do; matched against the mount's `scopes` map. */
|
|
26
|
+
readonly scopes: ReadonlySet<string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `null` for a malformed, unknown, revoked or expired token — all one indistinguishable 401. */
|
|
30
|
+
export type BearerResolver = (token: string) => Promise<BearerCaller | null> | BearerCaller | null;
|
|
31
|
+
|
|
32
|
+
export interface BearerMountRateLimit {
|
|
33
|
+
readonly limit: number;
|
|
34
|
+
readonly windowMs: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface BearerMountInput {
|
|
38
|
+
/** `'/v1'` — lowercase segments; never `/api` or `/_x`, which the framework serves itself. */
|
|
39
|
+
readonly prefix: string;
|
|
40
|
+
/** The projected routes the cut is taken from (`apiRoutes()`): matched by `meta.name`. */
|
|
41
|
+
readonly routes: readonly Route[];
|
|
42
|
+
/**
|
|
43
|
+
* Scope → the primitives it covers, BY NAME — the shape `defineAppMcp({ scopes })` takes, so an
|
|
44
|
+
* app passes one map to both. The union is everything the mount serves; a token sees a
|
|
45
|
+
* primitive only while it carries the scope that names it.
|
|
46
|
+
*/
|
|
47
|
+
readonly scopes: Readonly<Record<string, readonly string[]>>;
|
|
48
|
+
readonly resolveToken: BearerResolver;
|
|
49
|
+
/** Requests per window per TOKEN (keyed by a hash of the token itself, never the user). */
|
|
50
|
+
readonly rateLimit?: BearerMountRateLimit | undefined;
|
|
51
|
+
/** Where the per-token buckets live. Defaults to per-process memory; a fleet passes a shared one. */
|
|
52
|
+
readonly rateLimitStore?: RateLimitStore | undefined;
|
|
53
|
+
readonly clock?: Clock | undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A mount prefix: `/v1`, `/public/v2`. Lowercase segments, no parameters, no trailing slash. */
|
|
57
|
+
const PREFIX = /^(\/[a-z0-9][a-z0-9._~-]*)+$/;
|
|
58
|
+
|
|
59
|
+
/** The namespaces a mount may never shadow. */
|
|
60
|
+
const RESERVED = [ACTION_PATH_PREFIX, '/_x'];
|
|
61
|
+
|
|
62
|
+
/** `Authorization: Bearer <token>`, the only accepted form. No query-string tokens. */
|
|
63
|
+
export function bearerTokenOf(header: string | null): string | null {
|
|
64
|
+
if (header === null) return null;
|
|
65
|
+
const match = /^Bearer\s+(\S+)$/i.exec(header.trim());
|
|
66
|
+
return match?.[1] ?? null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The path a projected route is served at under `prefix`: its framework namespace (`/api`,
|
|
71
|
+
* `/_x/query`) replaced by the prefix, so `POST /api/create-case` is `POST /v1/create-case` and
|
|
72
|
+
* `GET /_x/query/case-list` is `GET /v1/case-list`. A route outside both keeps its whole path.
|
|
73
|
+
*/
|
|
74
|
+
export function mountedPath(prefix: string, path: string): string {
|
|
75
|
+
for (const namespace of [QUERY_PATH_PREFIX, ACTION_PATH_PREFIX]) {
|
|
76
|
+
if (path.startsWith(`${namespace}/`)) return `${prefix}${path.slice(namespace.length)}`;
|
|
77
|
+
}
|
|
78
|
+
return `${prefix}${path}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Hex SHA-256 prefix: a per-token key that never stores or logs the token itself. */
|
|
82
|
+
const tokenKey = (token: string): string =>
|
|
83
|
+
new Bun.CryptoHasher('sha256').update(token).digest('hex').slice(0, 32);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The mounted routes. Refuses at construction (`X_BEARER_MOUNT_INVALID`) a prefix that is not a
|
|
87
|
+
* plain path or would shadow `/api` / `/_x`, a scope naming a primitive no route carries, and two
|
|
88
|
+
* primitives that would land on one mounted path.
|
|
89
|
+
*/
|
|
90
|
+
export function bearerMount(input: BearerMountInput): readonly Route[] {
|
|
91
|
+
const { prefix } = input;
|
|
92
|
+
if (!PREFIX.test(prefix)) {
|
|
93
|
+
throw bearerMountInvalid(prefix, 'the prefix is not a lowercase path like /v1');
|
|
94
|
+
}
|
|
95
|
+
for (const reserved of RESERVED) {
|
|
96
|
+
if (prefix === reserved || prefix.startsWith(`${reserved}/`)) {
|
|
97
|
+
throw bearerMountInvalid(
|
|
98
|
+
prefix,
|
|
99
|
+
`the prefix would shadow ${reserved}, which the framework serves`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// name → the scope that covers it. One scope per primitive, as MCP's `withScopes` holds.
|
|
105
|
+
const scopeOf = new Map<string, string>();
|
|
106
|
+
for (const [scope, names] of Object.entries(input.scopes)) {
|
|
107
|
+
for (const name of names) {
|
|
108
|
+
const claimed = scopeOf.get(name);
|
|
109
|
+
if (claimed !== undefined && claimed !== scope) {
|
|
110
|
+
throw bearerMountInvalid(
|
|
111
|
+
prefix,
|
|
112
|
+
`${name} is claimed by two scopes, ${claimed} and ${scope}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
scopeOf.set(name, scope);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const byName = new Map<string, Route[]>();
|
|
119
|
+
for (const route of input.routes) {
|
|
120
|
+
const list = byName.get(route.meta.name) ?? [];
|
|
121
|
+
list.push(route);
|
|
122
|
+
byName.set(route.meta.name, list);
|
|
123
|
+
}
|
|
124
|
+
const missing = [...scopeOf.keys()].filter((name) => !byName.has(name)).sort();
|
|
125
|
+
if (missing.length > 0) {
|
|
126
|
+
throw bearerMountInvalid(prefix, `no projected route is named ${missing.join(', ')}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const bucket =
|
|
130
|
+
input.rateLimit === undefined ? undefined : toBucket(`bearer mount ${prefix}`, input.rateLimit);
|
|
131
|
+
const store = input.rateLimitStore ?? memoryRateLimitStore();
|
|
132
|
+
const clock = input.clock ?? systemClock;
|
|
133
|
+
// The caller each request resolved to, keyed by ITS context — never a module-level slot two
|
|
134
|
+
// concurrent requests could share.
|
|
135
|
+
const callers = new WeakMap<RequestContext, { caller: BearerCaller; key: string }>();
|
|
136
|
+
|
|
137
|
+
const authenticate: NonNullable<RouteMeta['authenticate']> = async (request, ctx) => {
|
|
138
|
+
const token = bearerTokenOf(request.header('authorization'));
|
|
139
|
+
if (token === null) {
|
|
140
|
+
ctx.headers.set('www-authenticate', 'Bearer');
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const caller = await input.resolveToken(token);
|
|
144
|
+
if (caller === null) {
|
|
145
|
+
ctx.headers.set('www-authenticate', 'Bearer error="invalid_token"');
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
callers.set(ctx, { caller, key: tokenKey(token) });
|
|
149
|
+
return caller.actor;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const spend = async (key: string, ctx: RequestContext): Promise<void> => {
|
|
153
|
+
if (bucket === undefined) return;
|
|
154
|
+
const decision: RateLimitDecision = await store.take(
|
|
155
|
+
`${prefix}|token:${key}`,
|
|
156
|
+
bucket,
|
|
157
|
+
1,
|
|
158
|
+
clock.now().getTime(),
|
|
159
|
+
);
|
|
160
|
+
ctx.headers.set('ratelimit-limit', String(decision.limit));
|
|
161
|
+
ctx.headers.set('ratelimit-remaining', String(decision.remaining));
|
|
162
|
+
ctx.headers.set(
|
|
163
|
+
'ratelimit-reset',
|
|
164
|
+
String(Math.max(0, Math.ceil((decision.resetAtMs - clock.now().getTime()) / 1000))),
|
|
165
|
+
);
|
|
166
|
+
// `rateLimited` carries the seconds in `meta`, which the error-map stage turns into
|
|
167
|
+
// `Retry-After` — the same 429 every other limit in the framework answers.
|
|
168
|
+
if (!decision.allowed) throw rateLimited(`${prefix}|token`, decision.retryAfterSeconds);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const mounted: Route[] = [];
|
|
172
|
+
const seen = new Map<string, string>();
|
|
173
|
+
for (const [name, scope] of [...scopeOf.entries()].sort(([a], [b]) => (a < b ? -1 : 1))) {
|
|
174
|
+
for (const route of byName.get(name) ?? []) {
|
|
175
|
+
const path = mountedPath(prefix, route.path);
|
|
176
|
+
const slot = `${route.method} ${path}`;
|
|
177
|
+
const owner = seen.get(slot);
|
|
178
|
+
if (owner !== undefined) {
|
|
179
|
+
throw bearerMountInvalid(prefix, `${name} and ${owner} would both be served at ${slot}`);
|
|
180
|
+
}
|
|
181
|
+
seen.set(slot, name);
|
|
182
|
+
mounted.push({
|
|
183
|
+
method: route.method,
|
|
184
|
+
path,
|
|
185
|
+
meta: { ...route.meta, auth: 'required', authenticate },
|
|
186
|
+
handler: async (request: UltimateRequest, ctx: RequestContext) => {
|
|
187
|
+
const resolved = callers.get(ctx);
|
|
188
|
+
// Hidden, never forbidden: a 403 would confirm the primitive exists to a token that
|
|
189
|
+
// was not issued for it — the enumeration MCP's catalog refuses the same way.
|
|
190
|
+
if (resolved === undefined || !resolved.caller.scopes.has(scope)) {
|
|
191
|
+
throw routeNotFound(ctx.method, ctx.url.pathname);
|
|
192
|
+
}
|
|
193
|
+
await spend(resolved.key, ctx);
|
|
194
|
+
return await route.handler(request, ctx);
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return mounted;
|
|
200
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -97,12 +97,30 @@ export interface HttpConfigInput {
|
|
|
97
97
|
readonly tz?: Partial<TimeZoneConfig>;
|
|
98
98
|
readonly cors?: Partial<CorsConfig>;
|
|
99
99
|
readonly csrf?: Partial<CsrfConfig>;
|
|
100
|
-
readonly security?: Partial<Omit<SecurityConfig, 'csp'>> & {
|
|
100
|
+
readonly security?: Partial<Omit<SecurityConfig, 'csp' | 'hsts'>> & {
|
|
101
101
|
readonly csp?: Partial<SecurityConfig['csp']>;
|
|
102
|
+
/**
|
|
103
|
+
* Merged over `DEFAULT_SECURITY.hsts` key by key, so `{ preload: true }` alone is the preload
|
|
104
|
+
* opt-in (submit the host at hstspreload.org only after that). `null` sends no HSTS at all.
|
|
105
|
+
*/
|
|
106
|
+
readonly hsts?: Partial<NonNullable<SecurityConfig['hsts']>> | null;
|
|
102
107
|
};
|
|
103
108
|
readonly rateLimit?: Partial<RateLimitConfig>;
|
|
104
109
|
}
|
|
105
110
|
|
|
111
|
+
/** Key by key over the default two-year policy; `null` is the one way to send none. */
|
|
112
|
+
const resolveHsts = (
|
|
113
|
+
input: Partial<NonNullable<SecurityConfig['hsts']>> | null | undefined,
|
|
114
|
+
): SecurityConfig['hsts'] => {
|
|
115
|
+
if (input === null) return null;
|
|
116
|
+
const base = DEFAULT_SECURITY.hsts ?? {
|
|
117
|
+
maxAgeSeconds: 63_072_000,
|
|
118
|
+
includeSubdomains: true,
|
|
119
|
+
preload: false,
|
|
120
|
+
};
|
|
121
|
+
return { ...base, ...input };
|
|
122
|
+
};
|
|
123
|
+
|
|
106
124
|
/**
|
|
107
125
|
* `basePath` is stripped before matching so route paths never encode the mount point.
|
|
108
126
|
* Matching is on a segment boundary: a mount at `/api` owns `/api` and `/api/...` but
|
|
@@ -268,7 +286,12 @@ export const defineHttpConfig = (input: HttpConfigInput = {}): HttpConfig => {
|
|
|
268
286
|
tz: { ...DEFAULT_TZ_CONFIG, ...input.tz },
|
|
269
287
|
cors,
|
|
270
288
|
csrf: { ...DEFAULT_CSRF, ...input.csrf },
|
|
271
|
-
security: {
|
|
289
|
+
security: {
|
|
290
|
+
...DEFAULT_SECURITY,
|
|
291
|
+
...input.security,
|
|
292
|
+
csp,
|
|
293
|
+
hsts: resolveHsts(input.security?.hsts),
|
|
294
|
+
},
|
|
272
295
|
rateLimit: resolveRateLimitConfig(input.rateLimit),
|
|
273
296
|
};
|
|
274
297
|
};
|
package/src/error-map.ts
CHANGED
|
@@ -83,6 +83,15 @@ export const ERROR_STATUS = {
|
|
|
83
83
|
// `X_UNAUTHENTICATED` alone — a webhook sender is not a browser and has no session to go get.
|
|
84
84
|
X_WEBHOOK_SIGNATURE_INVALID: 401,
|
|
85
85
|
X_WEBHOOK_SIGNATURE_STALE: 401,
|
|
86
|
+
// Boot refusals of the app's HTTP declaration (a bearer mount, an action's pinned path or the
|
|
87
|
+
// app's path style, the openapi block, the MCP oauth block, a page's `post`) — never a request's
|
|
88
|
+
// answer; 500 if one ever escaped into a response: the deployment is wrong, the caller is not.
|
|
89
|
+
X_BEARER_MOUNT_INVALID: 500,
|
|
90
|
+
X_ACTION_HTTP_PATH_INVALID: 500,
|
|
91
|
+
X_ACTION_PATH_STYLE_INVALID: 500,
|
|
92
|
+
X_OPENAPI_CONFIG_INVALID: 500,
|
|
93
|
+
X_MCP_OAUTH_INVALID: 500,
|
|
94
|
+
X_ROUTE_POST_INVALID: 500,
|
|
86
95
|
// @ultimat3/action — the code every primitive throws when the CALLER's input fails the schema
|
|
87
96
|
// the primitive declared. 400 because that is what the published OpenAPI operation promises for
|
|
88
97
|
// it, and because a missing row made a typo'd uuid a 500: the caller was told the server broke,
|
package/src/errors.ts
CHANGED
|
@@ -38,6 +38,7 @@ export const HTTP_OWNED_ERROR_CODES = [
|
|
|
38
38
|
'X_CSRF_BLOCKED',
|
|
39
39
|
'X_WEBHOOK_SIGNATURE_INVALID',
|
|
40
40
|
'X_WEBHOOK_SIGNATURE_STALE',
|
|
41
|
+
'X_BEARER_MOUNT_INVALID',
|
|
41
42
|
] as const;
|
|
42
43
|
|
|
43
44
|
/**
|
|
@@ -101,6 +102,7 @@ export const HTTP_ERROR_TITLES: Readonly<Record<HttpOwnedErrorCode, string>> = {
|
|
|
101
102
|
X_CSRF_BLOCKED: 'a credentialed write arrived from an origin that is not allowed to make it',
|
|
102
103
|
X_WEBHOOK_SIGNATURE_INVALID: 'the inbound webhook is not signed by the holder of this secret',
|
|
103
104
|
X_WEBHOOK_SIGNATURE_STALE: 'the inbound webhook is signed correctly and is too old to accept',
|
|
105
|
+
X_BEARER_MOUNT_INVALID: 'a bearer mount declaration cannot be served as written',
|
|
104
106
|
};
|
|
105
107
|
|
|
106
108
|
// Registered at module load, unconditionally, in one call, so core's registry renders OUR title
|
|
@@ -216,6 +218,14 @@ export const bodyInvalid = (
|
|
|
216
218
|
fix: `x routes --json # find ${pathname}, then send a body matching its input schema`,
|
|
217
219
|
});
|
|
218
220
|
|
|
221
|
+
/** At construction: a hole in a scope, or a prefix shadowing `/api` or `/_x`, never serves. */
|
|
222
|
+
export const bearerMountInvalid = (prefix: string, reason: string): HttpError =>
|
|
223
|
+
new HttpError({
|
|
224
|
+
code: 'X_BEARER_MOUNT_INVALID',
|
|
225
|
+
cause: `the bearer mount at ${JSON.stringify(prefix)} cannot be served: ${reason}`,
|
|
226
|
+
fix: "declare mounts: [{ prefix: '/v1', scopes: { 'cases:read': ['caseList'] }, resolveToken }] in defineApi({ http }) — a prefix of lowercase segments that is not /api or /_x, naming only registered actions and queries (x routes --json lists them)",
|
|
227
|
+
});
|
|
228
|
+
|
|
219
229
|
export const unauthenticated = (pathname: string): HttpError =>
|
|
220
230
|
new HttpError({
|
|
221
231
|
code: 'X_UNAUTHENTICATED',
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,13 @@ export {
|
|
|
15
15
|
export type { AppHttpConfig, BootOwnedHttpKey } from './app-config';
|
|
16
16
|
export { configuredHttp, configureHttp, mergeHttpConfig, resetHttpConfig } from './app-config';
|
|
17
17
|
export { NEXT_PARAM, nextAfterSignIn, signInRedirect } from './auth-redirect';
|
|
18
|
+
export type {
|
|
19
|
+
BearerCaller,
|
|
20
|
+
BearerMountInput,
|
|
21
|
+
BearerMountRateLimit,
|
|
22
|
+
BearerResolver,
|
|
23
|
+
} from './bearer-mount';
|
|
24
|
+
export { bearerMount, bearerTokenOf, mountedPath } from './bearer-mount';
|
|
18
25
|
export type { HttpConfig, HttpConfigInput } from './config';
|
|
19
26
|
export { defineHttpConfig, MAX_PROXY_HOPS } from './config';
|
|
20
27
|
export type { ActorView, RequestContext, RequestContextInit } from './context';
|
|
@@ -33,7 +40,7 @@ export type { InboundCorrelation } from './correlation';
|
|
|
33
40
|
export type { CorsConfig } from './cors';
|
|
34
41
|
export { allowedOrigin, corsHeaders, DEFAULT_CORS, originListed, preflight } from './cors';
|
|
35
42
|
export type { CsrfCheckInput, CsrfConfig, CsrfMode, CsrfVerdict } from './csrf';
|
|
36
|
-
export { checkCsrf, csrfBlocked } from './csrf';
|
|
43
|
+
export { checkCsrf, csrfBlocked, selfOrigin } from './csrf';
|
|
37
44
|
export type { Deadline } from './deadline';
|
|
38
45
|
export { REQUEST_TIMEOUT_HEADER, resolveTimeoutMs, startDeadline } from './deadline';
|
|
39
46
|
export type { ErrorFacts, ProblemDocument } from './error-facts';
|
|
@@ -66,6 +73,7 @@ export {
|
|
|
66
73
|
} from './error-status';
|
|
67
74
|
export type { HttpErrorCode } from './errors';
|
|
68
75
|
export {
|
|
76
|
+
bearerMountInvalid,
|
|
69
77
|
bodyInvalid,
|
|
70
78
|
buildSkew,
|
|
71
79
|
draining,
|
package/src/pipeline.ts
CHANGED
|
@@ -96,6 +96,13 @@ export const PIPELINE_STAGES: readonly StageDoc[] = [
|
|
|
96
96
|
},
|
|
97
97
|
];
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* `GET /posts/:id` — the route pattern, or the bare method when nothing matched (OpenTelemetry's
|
|
101
|
+
* HTTP server span rule). Exported for the test that pins it; never built from `url.pathname`.
|
|
102
|
+
*/
|
|
103
|
+
export const routeSpanName = (method: string, routePath: string | undefined): string =>
|
|
104
|
+
routePath === undefined ? method : `${method} ${routePath}`;
|
|
105
|
+
|
|
99
106
|
export interface PipelineDeps {
|
|
100
107
|
readonly table: RouteTable;
|
|
101
108
|
readonly config?: HttpConfig;
|
|
@@ -251,7 +258,11 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
|
|
|
251
258
|
// a handler calls — sees the same context object without threading it by hand.
|
|
252
259
|
return await runWithContext(asCtx(ctx), () =>
|
|
253
260
|
withSpan(
|
|
254
|
-
|
|
261
|
+
// The METHOD alone until the router has spoken — never the concrete path. A URL is
|
|
262
|
+
// attacker-chosen and carries capability tokens (`/r/<token>`, `/ics/<token>.ics`), and
|
|
263
|
+
// a span name is exported verbatim to every collector. `routeSpanName` renames it to
|
|
264
|
+
// the route PATTERN once one matched.
|
|
265
|
+
ctx.method,
|
|
255
266
|
async (span) => {
|
|
256
267
|
// This package's ONE metrics call site. `finally`, not the happy line: `execute`
|
|
257
268
|
// answers every stage's throw with a problem response, but a counter that skipped the
|
|
@@ -262,6 +273,7 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
|
|
|
262
273
|
try {
|
|
263
274
|
const response = await execute(request, ctx, deadline);
|
|
264
275
|
status = response.status;
|
|
276
|
+
span.updateName(routeSpanName(ctx.method, ctx.route?.path));
|
|
265
277
|
// The root span of every request carried no attributes at all, so an exporter got a
|
|
266
278
|
// name and a duration and nothing to correlate: which request, which outcome. These
|
|
267
279
|
// four are what a reader joins on — `x-request-id` off the response, the status the
|
|
@@ -269,14 +281,16 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
|
|
|
269
281
|
span.setAttributes({
|
|
270
282
|
'http.request_id': ctx.requestId,
|
|
271
283
|
'http.method': ctx.method,
|
|
272
|
-
|
|
284
|
+
// The PATTERN (`/r/:token`), the OpenTelemetry meaning of `http.route` — the
|
|
285
|
+
// concrete path leaked every token a URL carries into the trace store.
|
|
286
|
+
'http.route': ctx.route?.path ?? UNMATCHED_ROUTE,
|
|
273
287
|
'http.status_code': response.status,
|
|
274
288
|
});
|
|
275
289
|
return response;
|
|
276
290
|
} finally {
|
|
277
|
-
// The
|
|
278
|
-
//
|
|
279
|
-
//
|
|
291
|
+
// The route PATTERN (`/posts/:id`), exactly as the span above is named: a metric is
|
|
292
|
+
// a stored series per label set, and a concrete path is attacker-chosen and may
|
|
293
|
+
// carry a token. And `recordRequest` folds the status to its class for the same
|
|
280
294
|
// reason. Nothing here is attacker-chosen or per-user.
|
|
281
295
|
recordRequest({
|
|
282
296
|
method: ctx.method,
|
package/src/router.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// param branch would also match, and a dead end in the static branch still falls
|
|
11
11
|
// back to the param branch. Two routes that would tie are a build error
|
|
12
12
|
// (`X_ROUTE_CONFLICT`) rather than a coin flip.
|
|
13
|
-
import type { RenderMode } from '@ultimat3/core';
|
|
13
|
+
import type { Actor, RenderMode } from '@ultimat3/core';
|
|
14
14
|
import type { RequestContext } from './context';
|
|
15
15
|
import { routeConflict } from './errors';
|
|
16
16
|
import type { Bucket } from './rate-limit';
|
|
@@ -79,6 +79,16 @@ export interface RouteMeta {
|
|
|
79
79
|
* serves for `/` cannot negotiate, so the served process must not either.
|
|
80
80
|
*/
|
|
81
81
|
readonly localeSource?: 'path' | 'request';
|
|
82
|
+
/**
|
|
83
|
+
* THIS route's authenticator, in place of the app's `configureAuthenticator()` — never beside
|
|
84
|
+
* it. A route that states one is reached only through the credential it names: a bearer mount
|
|
85
|
+
* (`bearerMount`) sets it so a session cookie authenticates NOTHING there, which is also what
|
|
86
|
+
* keeps a cross-site form from riding a cookie into it. `null` is anonymous, as the hook's is.
|
|
87
|
+
*/
|
|
88
|
+
readonly authenticate?: (
|
|
89
|
+
request: UltimateRequest,
|
|
90
|
+
ctx: RequestContext,
|
|
91
|
+
) => Promise<Actor | null> | Actor | null;
|
|
82
92
|
}
|
|
83
93
|
|
|
84
94
|
export type RouteHandler = (
|
package/src/security-headers.ts
CHANGED
|
@@ -62,8 +62,14 @@ const baseline = (config: SecurityConfig): Record<string, readonly string[]> =>
|
|
|
62
62
|
'style-src-attr': ["'unsafe-inline'"],
|
|
63
63
|
'img-src': ["'self'", 'data:', 'blob:'],
|
|
64
64
|
'font-src': ["'self'"],
|
|
65
|
-
// ws
|
|
66
|
-
|
|
65
|
+
// `'self'` and nothing wider. The bare `ws:`/`wss:` schemes this used to carry let a script
|
|
66
|
+
// injected anywhere on the page open a socket to ANY host — an exfiltration channel the rest of
|
|
67
|
+
// the policy exists to close. CSP Level 3 matches `'self'` against the page's own host over
|
|
68
|
+
// ws/wss as well (every evergreen engine, As of 2026-09), which is where the sync node is
|
|
69
|
+
// served on every rung that shares the page origin. A sync node on ANOTHER origin (`SYNC_URL`)
|
|
70
|
+
// is added by the boot through `csp.extend` — its origin exactly, never a scheme.
|
|
71
|
+
// blob: is required by streamed responses.
|
|
72
|
+
'connect-src': ["'self'", 'blob:'],
|
|
67
73
|
'worker-src': ["'self'", 'blob:'],
|
|
68
74
|
'manifest-src': ["'self'"],
|
|
69
75
|
'media-src': ["'self'", 'blob:'],
|
package/src/stages.ts
CHANGED
|
@@ -203,10 +203,13 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
203
203
|
},
|
|
204
204
|
|
|
205
205
|
auth: async (request, ctx) => {
|
|
206
|
-
|
|
206
|
+
// A route's own authenticator REPLACES the app's, never runs beside it: a bearer mount must
|
|
207
|
+
// not also accept the session cookie the app's hook would resolve (`RouteMeta.authenticate`).
|
|
208
|
+
const authenticate = ctx.route?.meta.authenticate ?? hooks.authenticate;
|
|
209
|
+
if (authenticate !== undefined) {
|
|
207
210
|
// The hook says "anonymous" with null; the context says it with core's anonymous actor,
|
|
208
211
|
// because `asCtx` publishes this object as a `Ctx` and `Ctx.actor` is never null.
|
|
209
|
-
ctx.actor = (await
|
|
212
|
+
ctx.actor = (await authenticate(request, ctx)) ?? anonymousActor();
|
|
210
213
|
// The `user` rung the `locale` stage could not know: re-resolved through the same owners,
|
|
211
214
|
// so a cookie the reader chose still beats a saved locale where i18n's order says it does.
|
|
212
215
|
if (ctx.actor.locale !== undefined || ctx.actor.tz !== undefined) {
|