@ultimat3/http 22.5.1 → 22.6.1

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
@@ -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.5.1",
3
+ "version": "22.6.1",
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.5.1",
35
- "@ultimat3/i18n": "22.5.1",
36
- "@ultimat3/schema": "22.5.1",
37
- "@ultimat3/time": "22.5.1"
34
+ "@ultimat3/core": "22.6.1",
35
+ "@ultimat3/i18n": "22.6.1",
36
+ "@ultimat3/schema": "22.6.1",
37
+ "@ultimat3/time": "22.6.1"
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: { ...DEFAULT_SECURITY, ...input.security, csp },
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,18 @@ 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 or a path derived before it, the openapi block, the MCP oauth block or an
88
+ // idempotent tool shadowing its key argument, a page's `post`) — never a request's
89
+ // answer; 500 if one ever escaped into a response: the deployment is wrong, the caller is not.
90
+ X_BEARER_MOUNT_INVALID: 500,
91
+ X_ACTION_HTTP_PATH_INVALID: 500,
92
+ X_ACTION_PATH_STYLE_INVALID: 500,
93
+ X_ACTION_PATH_DERIVED_EARLY: 500,
94
+ X_OPENAPI_CONFIG_INVALID: 500,
95
+ X_MCP_OAUTH_INVALID: 500,
96
+ X_MCP_IDEMPOTENCY_KEY_SHADOWED: 500,
97
+ X_ROUTE_POST_INVALID: 500,
86
98
  // @ultimat3/action — the code every primitive throws when the CALLER's input fails the schema
87
99
  // the primitive declared. 400 because that is what the published OpenAPI operation promises for
88
100
  // 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
- `${ctx.method} ${url.pathname}`,
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
- 'http.route': url.pathname,
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 span may carry the concrete path — a trace is sampled and thrown away. A
278
- // metric is a stored series per label set, so this is the route PATTERN
279
- // (`/posts/:id`), and `recordRequest` folds the status to its class for the same
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 = (
@@ -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:/wss: are required by the realtime tiers; blob: by streamed responses.
66
- 'connect-src': ["'self'", 'ws:', 'wss:', 'blob:'],
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
- if (hooks.authenticate !== undefined) {
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 hooks.authenticate(request, ctx)) ?? anonymousActor();
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) {