@ultimat3/http 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.
- package/CLAUDE.md +26 -51
- package/README.md +119 -6
- package/package.json +15 -5
- package/src/bearer-mount.ts +67 -12
- package/src/cache-policy.ts +8 -0
- package/src/compress-read.ts +115 -0
- package/src/compress.ts +173 -0
- package/src/config.ts +1 -7
- package/src/context.ts +50 -0
- package/src/cors.ts +43 -4
- package/src/csrf.ts +41 -8
- package/src/deadline.ts +2 -2
- package/src/error-facts.ts +26 -1
- package/src/error-map-http.ts +3 -0
- package/src/error-map-tier-0.ts +26 -0
- package/src/error-map-tier-1.ts +22 -0
- package/src/error-map-tier-2.ts +16 -0
- package/src/error-map-tier-3.ts +24 -0
- package/src/error-map-tier-4.ts +33 -1
- package/src/error-page.ts +6 -7
- package/src/error-status.ts +26 -17
- package/src/error-titles.ts +2 -0
- package/src/errors.ts +27 -12
- package/src/finalize.ts +1 -0
- package/src/index.ts +17 -2
- package/src/locale.ts +4 -1
- package/src/object-response.ts +134 -0
- package/src/overlay.ts +13 -2
- package/src/pipeline.ts +6 -1
- package/src/preferences.ts +4 -0
- package/src/problem-meta-framework.ts +21 -0
- package/src/problem-meta.ts +23 -15
- package/src/rate-limit-degrade.ts +10 -2
- package/src/rate-limit-postgres.ts +22 -9
- package/src/rate-limit-store-deadline.ts +60 -0
- package/src/response.ts +29 -5
- package/src/router.ts +9 -0
- package/src/server-websocket.ts +35 -0
- package/src/server.ts +8 -4
- package/src/stages.ts +18 -5
- package/src/stored-object-headers.ts +43 -0
- package/src/webhook-hmac.ts +73 -0
- package/src/webhook-verify.ts +26 -5
- package/src/websocket-mounts.ts +104 -0
package/src/pipeline.ts
CHANGED
|
@@ -93,7 +93,12 @@ export const PIPELINE_STAGES: readonly StageDoc[] = [
|
|
|
93
93
|
{
|
|
94
94
|
name: 'response',
|
|
95
95
|
phase: 'finalize',
|
|
96
|
-
why: '
|
|
96
|
+
why: 'after every stage that decides a header: a redirect answered to a client-router request becomes 204 + x-ultimate-location (the router follows it once, never fetch), then CORS, security headers, server-timing and the accumulated context headers are merged onto whatever the stages produced',
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: 'compress',
|
|
100
|
+
phase: 'finalize',
|
|
101
|
+
why: 'last: it reads the final cache-control, so a private answer can never be coded (BREACH), and rebuilds the response with every header already on it. Only a public text answer with a declared length is ever read, so no other response pays for this stage',
|
|
97
102
|
},
|
|
98
103
|
];
|
|
99
104
|
|
package/src/preferences.ts
CHANGED
|
@@ -31,7 +31,11 @@ export function resolvePreferences(
|
|
|
31
31
|
});
|
|
32
32
|
ctx.tz = resolveTimeZone({
|
|
33
33
|
cookie: readCookie(cookies, config.tz.cookie),
|
|
34
|
+
// Documented as a source (`?tz=` for share links) and never read until 2026-11, as `?locale=`.
|
|
35
|
+
query: ctx.url.searchParams.get('tz'),
|
|
34
36
|
header: request.header(config.tz.header),
|
|
37
|
+
// A document navigation sends no header: this is the browser's zone on a first `app/` page.
|
|
38
|
+
detected: readCookie(cookies, config.tz.detectedCookie),
|
|
35
39
|
user: ctx.actor.tz ?? null,
|
|
36
40
|
}).zone;
|
|
37
41
|
ctx.headers.set('content-language', ctx.locale);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The FRAMEWORK's half of `problem-meta.ts`: per framework code, the `meta` keys its problem
|
|
2
|
+
// document carries. A framework code's `meta` is operator-only by default (`problem-meta.ts`'s
|
|
3
|
+
// header names what lives there), and `registerProblemMeta` refuses every framework code — so
|
|
4
|
+
// without this table a refusal whose meta IS the caller's instruction never reached the caller.
|
|
5
|
+
//
|
|
6
|
+
// One reviewed table, never a per-class or per-instance option: an option on the error would let
|
|
7
|
+
// any throw site publish any key, and the rule is that a key crosses only where a reviewer wrote
|
|
8
|
+
// it here. `problem-meta-framework.test.ts` holds every row to the rules: a framework code, a 4xx,
|
|
9
|
+
// a key an app's own declaration could carry, and no secret-shaped name.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Code → the `meta` keys its document carries. A `Map`, read by own key (`APP_ERROR_STATUS`'s
|
|
13
|
+
* reason). A new row names the key's producer, so a rename there fails that package's test.
|
|
14
|
+
*/
|
|
15
|
+
export const FRAMEWORK_PROBLEM_META: ReadonlyMap<string, readonly string[]> = new Map([
|
|
16
|
+
// `@ultimat3/action`'s `MeterExceededError`: which allowance, its size, and when it resets — so a
|
|
17
|
+
// client can say "100 drafts a day, again at midnight UTC" without parsing `cause`. NOT
|
|
18
|
+
// `retryAfterSeconds` (already the `Retry-After` header) and not the counter's subject, which
|
|
19
|
+
// names a tenant (`meter-problem.test.ts` pins the producer's keys).
|
|
20
|
+
['X_METER_EXCEEDED', Object.freeze(['meter', 'limit', 'resetsAt'])],
|
|
21
|
+
]);
|
package/src/problem-meta.ts
CHANGED
|
@@ -15,9 +15,10 @@
|
|
|
15
15
|
// The public-cause predicate and both of its tables are `@ultimat3/core`'s (`public-cause.ts`): the
|
|
16
16
|
// MCP error data and the agent `tool_result` ask the question this package's problem document
|
|
17
17
|
// does. This file only WRITES the app half — a reader imports `hasPublicCause` from core.
|
|
18
|
-
import { registerPublicCause, resetPublicCauses } from '@ultimat3/core';
|
|
18
|
+
import { registerPublicCause, renderCauseValue, resetPublicCauses } from '@ultimat3/core';
|
|
19
19
|
import { ERROR_STATUS } from './error-map';
|
|
20
20
|
import { problemMetaInvalid } from './errors';
|
|
21
|
+
import { FRAMEWORK_PROBLEM_META } from './problem-meta-framework';
|
|
21
22
|
|
|
22
23
|
/** What a problem document's `meta` member holds — JSON, and nothing JSON cannot carry. */
|
|
23
24
|
export type ProblemMetaValue =
|
|
@@ -48,6 +49,18 @@ export const MAX_PROBLEM_META_BYTES = 4096;
|
|
|
48
49
|
const KEY = /^[A-Za-z_$][\w$]*$/;
|
|
49
50
|
const RESERVED_KEYS: ReadonlySet<string> = new Set(['issues', '__proto__']);
|
|
50
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Why `key` may not ride a document, or `undefined` when it may. The one rule for both halves: an
|
|
54
|
+
* app's `registerProblemMeta` throws on it, and the framework's table is held to it by a test.
|
|
55
|
+
*/
|
|
56
|
+
export function metaKeyRefusal(key: unknown): string | undefined {
|
|
57
|
+
if (typeof key !== 'string' || !KEY.test(key))
|
|
58
|
+
return `${renderCauseValue(key)} is not a meta key`;
|
|
59
|
+
if (key === 'issues') return '`issues` already rides at the top level of the document';
|
|
60
|
+
if (RESERVED_KEYS.has(key)) return `\`${key}\` is not a key a document may carry`;
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
51
64
|
/** Per app-owned code, the `meta` keys its documents carry. A `Map`, for `APP_ERROR_STATUS`'s reason. */
|
|
52
65
|
const DECLARED = new Map<string, readonly string[]>();
|
|
53
66
|
|
|
@@ -73,7 +86,8 @@ const sameKeys = (left: readonly string[], right: readonly string[]): boolean =>
|
|
|
73
86
|
*
|
|
74
87
|
* Framework-owned codes are refused, exactly as `registerErrorStatus` refuses them: their `meta`
|
|
75
88
|
* is where the framework keeps what a caller must not be handed, and an app that could declare
|
|
76
|
-
* `X_RATE_LIMITED: ['key']` would publish the limiter's internal key on every 429.
|
|
89
|
+
* `X_RATE_LIMITED: ['key']` would publish the limiter's internal key on every 429. The framework's
|
|
90
|
+
* own public keys are `problem-meta-framework.ts`'s one reviewed table, never an app's call.
|
|
77
91
|
*/
|
|
78
92
|
export const registerProblemMeta = (
|
|
79
93
|
declarations: Readonly<Record<string, ProblemMetaDeclaration>>,
|
|
@@ -95,17 +109,8 @@ export const registerProblemMeta = (
|
|
|
95
109
|
throw problemMetaInvalid(code, 'the key list is empty — omit the code instead');
|
|
96
110
|
}
|
|
97
111
|
for (const key of keys) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
if (RESERVED_KEYS.has(key)) {
|
|
102
|
-
throw problemMetaInvalid(
|
|
103
|
-
code,
|
|
104
|
-
key === 'issues'
|
|
105
|
-
? '`issues` already rides at the top level of the document'
|
|
106
|
-
: `\`${key}\` is not a key a document may carry`,
|
|
107
|
-
);
|
|
108
|
-
}
|
|
112
|
+
const refusal = metaKeyRefusal(key);
|
|
113
|
+
if (refusal !== undefined) throw problemMetaInvalid(code, refusal);
|
|
109
114
|
}
|
|
110
115
|
const existing = DECLARED.get(code);
|
|
111
116
|
if (existing !== undefined && !sameKeys(existing, keys)) {
|
|
@@ -122,9 +127,12 @@ export const resetProblemMeta = (): void => {
|
|
|
122
127
|
resetPublicCauses();
|
|
123
128
|
};
|
|
124
129
|
|
|
125
|
-
/**
|
|
130
|
+
/**
|
|
131
|
+
* The keys declared for a code, or `undefined` when nothing was: the framework's reviewed rows
|
|
132
|
+
* (`problem-meta-framework.ts`) first, then the app's. Every other framework code answers nothing.
|
|
133
|
+
*/
|
|
126
134
|
export const problemMetaKeysFor = (code: string): readonly string[] | undefined =>
|
|
127
|
-
DECLARED.get(code);
|
|
135
|
+
FRAMEWORK_PROBLEM_META.get(code) ?? DECLARED.get(code);
|
|
128
136
|
|
|
129
137
|
/**
|
|
130
138
|
* A code this package's table maps is the framework's. A `Set` of the table's own keys, for the
|
|
@@ -22,8 +22,16 @@ import { isUltimateError, logger } from '@ultimat3/core';
|
|
|
22
22
|
import type { RateLimitStore } from './rate-limit';
|
|
23
23
|
import { memoryRateLimitStore } from './rate-limit';
|
|
24
24
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Codes `@ultimat3/db` raises when no connection could run the statement at all, and the store's
|
|
27
|
+
* own deadline (`rate-limit-store-deadline.ts`): a database that has not answered in time is one
|
|
28
|
+
* this decision cannot wait for.
|
|
29
|
+
*/
|
|
30
|
+
const UNREACHABLE_CODES: ReadonlySet<string> = new Set([
|
|
31
|
+
'X_DB_UNAVAILABLE',
|
|
32
|
+
'X_DB_POOL_EXHAUSTED',
|
|
33
|
+
'X_RATE_LIMIT_STORE_TIMEOUT',
|
|
34
|
+
]);
|
|
27
35
|
|
|
28
36
|
/**
|
|
29
37
|
* SQLSTATEs that mean "this connection, right now", carried on `meta.sqlState` of an
|
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
// is a declaration nothing can satisfy while `docker/helm/values.yaml` runs `replicas: 3`.
|
|
4
4
|
// Statements are spelled out so an agent can run the exact one it saw in a log.
|
|
5
5
|
|
|
6
|
-
import type { PgExecutor } from '@ultimat3/core';
|
|
6
|
+
import type { PgExecutor, Scheduler } from '@ultimat3/core';
|
|
7
7
|
import type { RateLimitDecision, RateLimitScope, RateLimitStore } from './rate-limit';
|
|
8
8
|
import { rateLimitDecision } from './rate-limit';
|
|
9
9
|
import { degradedFallback, isRateLimitStoreUnreachable } from './rate-limit-degrade';
|
|
10
10
|
import { rateLimitStoreUnavailable } from './rate-limit-errors';
|
|
11
11
|
import { rateLimitPeek, refilledTokens } from './rate-limit-peek';
|
|
12
|
+
import { storeDeadline } from './rate-limit-store-deadline';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Installed by the boot, not by an app migration — the same rule `SQL_IDEMPOTENCY_TABLE` follows,
|
|
@@ -124,6 +125,14 @@ interface TakeRow {
|
|
|
124
125
|
|
|
125
126
|
export interface PostgresRateLimitStoreOptions {
|
|
126
127
|
readonly executor: PgExecutor;
|
|
128
|
+
/**
|
|
129
|
+
* How long one `take` or `peek` may wait for the database before this decision degrades to the
|
|
130
|
+
* pod's memory. Default `RATE_LIMIT_STORE_DEADLINE_MS` (250), measured in
|
|
131
|
+
* `rate-limit-store-deadline.ts`. The pool's own bound is the app's work's; this one is the limiter's.
|
|
132
|
+
*/
|
|
133
|
+
readonly deadlineMs?: number | undefined;
|
|
134
|
+
/** Injection seam for that deadline; production passes none. */
|
|
135
|
+
readonly schedule?: Scheduler | undefined;
|
|
127
136
|
}
|
|
128
137
|
|
|
129
138
|
export interface PostgresRateLimitStore extends RateLimitStore {
|
|
@@ -165,6 +174,7 @@ export function postgresRateLimitStore(
|
|
|
165
174
|
options: PostgresRateLimitStoreOptions,
|
|
166
175
|
): PostgresRateLimitStore {
|
|
167
176
|
const exec = options.executor;
|
|
177
|
+
const deadline = storeDeadline(options.deadlineMs, options.schedule);
|
|
168
178
|
// Where a decision is taken while the database is unreachable (`rate-limit-degrade.ts`). Still
|
|
169
179
|
// `scope: 'shared'` below: the store IS the fleet's, and the degraded window is logged, bounded
|
|
170
180
|
// by the outage, and a weaker limit rather than none.
|
|
@@ -181,13 +191,16 @@ export function postgresRateLimitStore(
|
|
|
181
191
|
async take(key, bucket, cost, nowMs): Promise<RateLimitDecision> {
|
|
182
192
|
let rows: readonly TakeRow[];
|
|
183
193
|
try {
|
|
184
|
-
rows = await
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
194
|
+
rows = await deadline.bound(
|
|
195
|
+
'take',
|
|
196
|
+
exec.query<TakeRow>(SQL_RATE_LIMIT_TAKE, [
|
|
197
|
+
key,
|
|
198
|
+
bucket.capacity,
|
|
199
|
+
bucket.refillPerSecond,
|
|
200
|
+
cost,
|
|
201
|
+
Math.floor(nowMs),
|
|
202
|
+
]),
|
|
203
|
+
);
|
|
191
204
|
} catch (error) {
|
|
192
205
|
return degrade(error, nowMs, () => fallback.store.take(key, bucket, cost, nowMs));
|
|
193
206
|
}
|
|
@@ -202,7 +215,7 @@ export function postgresRateLimitStore(
|
|
|
202
215
|
async peek(key, bucket, nowMs) {
|
|
203
216
|
let rows: readonly PeekRow[];
|
|
204
217
|
try {
|
|
205
|
-
rows = await exec.query<PeekRow>(SQL_RATE_LIMIT_PEEK, [key]);
|
|
218
|
+
rows = await deadline.bound('peek', exec.query<PeekRow>(SQL_RATE_LIMIT_PEEK, [key]));
|
|
206
219
|
} catch (error) {
|
|
207
220
|
return degrade(error, nowMs, () => fallback.store.peek(key, bucket, nowMs));
|
|
208
221
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// The shared rate-limit store's own deadline on one round trip. The pool's unanswered-statement
|
|
2
|
+
// bound (statement_timeout + 5 s) is sized for the app's work; a limiter that waits it out spends
|
|
3
|
+
// a request's whole budget deciding whether to admit it, then the request's own statement waits a
|
|
4
|
+
// second bound. Past this deadline the store degrades (`rate-limit-degrade.ts`) instead.
|
|
5
|
+
|
|
6
|
+
import { assert, finiteCount, MAX_TIMER_MS, type Scheduler } from '@ultimat3/core';
|
|
7
|
+
import { HttpError } from './errors';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Measured 2026-11-06, Bun 1.3, Postgres 17 on loopback, `web` pool (max 20), three runs each: a
|
|
11
|
+
* cold first statement (connect + SCRAM) 15–31 ms; a warm take p99 0.4 ms; 100 concurrent takes on
|
|
12
|
+
* ONE hot key (row-lock queue) max 11.3 ms. 250 ms is ~8x the worst cold connect and ~20x the
|
|
13
|
+
* worst contended take, and costs a blackholed request a quarter second instead of ~16 s.
|
|
14
|
+
*/
|
|
15
|
+
export const RATE_LIMIT_STORE_DEADLINE_MS = 250;
|
|
16
|
+
|
|
17
|
+
/** Unreffed: a deadline guards work in flight and must never keep a finished process alive. */
|
|
18
|
+
const unrefSchedule: Scheduler = (fn, ms) => {
|
|
19
|
+
const timer = setTimeout(fn, ms);
|
|
20
|
+
timer.unref?.();
|
|
21
|
+
return () => clearTimeout(timer);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export interface StoreDeadline {
|
|
25
|
+
/** `sending`'s answer, or `X_RATE_LIMIT_STORE_TIMEOUT` once `deadlineMs` passes first. */
|
|
26
|
+
bound<T>(statement: 'take' | 'peek', sending: Promise<T>): Promise<T>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const rateLimitStoreTimeout = (statement: string, deadlineMs: number): HttpError =>
|
|
30
|
+
new HttpError({
|
|
31
|
+
code: 'X_RATE_LIMIT_STORE_TIMEOUT',
|
|
32
|
+
cause: `the shared rate-limit store's ${statement} statement did not answer within ${String(deadlineMs)} ms, so this decision was taken from this pod's memory`,
|
|
33
|
+
fix: 'x doctor --json # names the database binding this pod cannot reach; a database that is slow rather than gone takes postgresRateLimitStore({ executor, deadlineMs }) raised in the boot that builds it',
|
|
34
|
+
meta: { statement, deadlineMs },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The abandoned statement is not cancelled — the executor has no seam for it — and the pool's own
|
|
39
|
+
* sweep still retires it at its bound; `Promise.race` keeps its late rejection handled meanwhile.
|
|
40
|
+
*/
|
|
41
|
+
export function storeDeadline(
|
|
42
|
+
deadlineMs: number = RATE_LIMIT_STORE_DEADLINE_MS,
|
|
43
|
+
schedule: Scheduler = unrefSchedule,
|
|
44
|
+
): StoreDeadline {
|
|
45
|
+
finiteCount('postgresRateLimitStore', 'deadlineMs', deadlineMs, 1);
|
|
46
|
+
assert(
|
|
47
|
+
deadlineMs <= MAX_TIMER_MS,
|
|
48
|
+
`postgresRateLimitStore deadlineMs is ${String(deadlineMs)}, past the longest delay a timer holds, so it would fire at once`,
|
|
49
|
+
`postgresRateLimitStore({ executor, deadlineMs: ${String(RATE_LIMIT_STORE_DEADLINE_MS)} }) # or any whole number of ms up to ${String(MAX_TIMER_MS)}`,
|
|
50
|
+
);
|
|
51
|
+
return {
|
|
52
|
+
bound<T>(statement: 'take' | 'peek', sending: Promise<T>): Promise<T> {
|
|
53
|
+
let cancel: () => void = () => undefined;
|
|
54
|
+
const late = new Promise<never>((_, reject) => {
|
|
55
|
+
cancel = schedule(() => reject(rateLimitStoreTimeout(statement, deadlineMs)), deadlineMs);
|
|
56
|
+
});
|
|
57
|
+
return Promise.race([sending, late]).finally(() => cancel());
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
package/src/response.ts
CHANGED
|
@@ -14,22 +14,46 @@ const withDefaults = (init: HeaderSource, defaults: Record<string, string>): Hea
|
|
|
14
14
|
return headers;
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
/** RFC 9110: these carry no content, so a `content-length` on one is a false statement. */
|
|
18
|
+
const CONTENTLESS_STATUS: ReadonlySet<number> = new Set([204, 205, 304]);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The length is the BYTES this body encodes to, declared because `new Response(string)` declares
|
|
22
|
+
* nothing and the `compress` stage never reads what it cannot bound. `stream` has none to declare.
|
|
23
|
+
* `string | undefined`: `JSON.stringify` answers `undefined` for a value it cannot render.
|
|
24
|
+
*/
|
|
25
|
+
const declaredLength = (body: string | undefined, init?: ResponseInit): Record<string, string> =>
|
|
26
|
+
body === undefined || (init?.status !== undefined && CONTENTLESS_STATUS.has(init.status))
|
|
27
|
+
? {}
|
|
28
|
+
: { 'content-length': String(Buffer.byteLength(body)) };
|
|
29
|
+
|
|
30
|
+
export const jsonResponse = <T>(body: T, init?: ResponseInit): Response => {
|
|
31
|
+
const text: string | undefined = JSON.stringify(body);
|
|
32
|
+
return new Response(text, {
|
|
19
33
|
...init,
|
|
20
|
-
headers: withDefaults(init, {
|
|
34
|
+
headers: withDefaults(init, {
|
|
35
|
+
'content-type': 'application/json; charset=utf-8',
|
|
36
|
+
...declaredLength(text, init),
|
|
37
|
+
}),
|
|
21
38
|
});
|
|
39
|
+
};
|
|
22
40
|
|
|
23
41
|
export const textResponse = (body: string, init?: ResponseInit): Response =>
|
|
24
42
|
new Response(body, {
|
|
25
43
|
...init,
|
|
26
|
-
headers: withDefaults(init, {
|
|
44
|
+
headers: withDefaults(init, {
|
|
45
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
46
|
+
...declaredLength(body, init),
|
|
47
|
+
}),
|
|
27
48
|
});
|
|
28
49
|
|
|
29
50
|
export const html = (markup: string, init?: ResponseInit): Response =>
|
|
30
51
|
new Response(markup, {
|
|
31
52
|
...init,
|
|
32
|
-
headers: withDefaults(init, {
|
|
53
|
+
headers: withDefaults(init, {
|
|
54
|
+
'content-type': 'text/html; charset=utf-8',
|
|
55
|
+
...declaredLength(markup, init),
|
|
56
|
+
}),
|
|
33
57
|
});
|
|
34
58
|
|
|
35
59
|
/**
|
package/src/router.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// (`X_ROUTE_CONFLICT`) rather than a coin flip.
|
|
13
13
|
import type { Actor, RenderMode } from '@ultimat3/core';
|
|
14
14
|
import type { RequestContext } from './context';
|
|
15
|
+
import { assertRouteCsrf } from './csrf';
|
|
15
16
|
import { routeConflict } from './errors';
|
|
16
17
|
import type { Bucket } from './rate-limit';
|
|
17
18
|
import type { UltimateRequest } from './request';
|
|
@@ -120,6 +121,13 @@ export interface RouteMeta {
|
|
|
120
121
|
request: UltimateRequest,
|
|
121
122
|
ctx: RequestContext,
|
|
122
123
|
) => Promise<Actor | null> | Actor | null;
|
|
124
|
+
/**
|
|
125
|
+
* `'cookieless'`: the `csrf` stage does not judge this route while its caller is anonymous —
|
|
126
|
+
* an RFC 6749 token, revocation or registration endpoint, client-authenticated or PKCE-bound,
|
|
127
|
+
* that a cross-origin client is MEANT to POST to. Only with `authenticate` declared
|
|
128
|
+
* (`X_CONFIG_INVALID` from `httpRouter`), so the app's cookie authenticator never runs here.
|
|
129
|
+
*/
|
|
130
|
+
readonly csrf?: 'cookieless';
|
|
123
131
|
}
|
|
124
132
|
|
|
125
133
|
export type RouteHandler = (
|
|
@@ -172,6 +180,7 @@ export const httpRouter = (routes: readonly Route[]): RouteTable => {
|
|
|
172
180
|
// not on the response path — `cacheControl` is total there by design, so the alternative is a
|
|
173
181
|
// log line once per request and a response that quietly falls back to heuristic caching.
|
|
174
182
|
assertRouteCache(route.meta.cache, route.meta.name, route.path);
|
|
183
|
+
assertRouteCsrf(route.meta, route.path);
|
|
175
184
|
let current = root;
|
|
176
185
|
const segments = pathSegments(route.path);
|
|
177
186
|
for (const [index, segment] of segments.entries()) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// What `httpServer` serves for its `websocket:` option — one mount or a list — as the ONE shape its
|
|
2
|
+
// listener dispatches: every owned path, one fetch, one handler set. A lone mount is handed through
|
|
3
|
+
// unwrapped (no socket of the sync node's pays for a join it does not need); two or more are joined
|
|
4
|
+
// by `joinWebSocketMounts`, which routes each upgrade, and each socket's frames, to its own mount.
|
|
5
|
+
|
|
6
|
+
import type { WebSocketMount } from './server';
|
|
7
|
+
import { type JoinedWebSocketMounts, joinWebSocketMounts } from './websocket-mounts';
|
|
8
|
+
|
|
9
|
+
/** `ServerOptions.websocket`: the sync node's socket, a device link, or both on one listener. */
|
|
10
|
+
export type WebSocketOption = WebSocketMount | readonly WebSocketMount[];
|
|
11
|
+
|
|
12
|
+
const isList = (option: WebSocketOption): option is readonly WebSocketMount[] =>
|
|
13
|
+
Array.isArray(option);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The served mounts, or `undefined` when there are none — then the listener opens no websocket at
|
|
17
|
+
* all, as it did before the option existed. `assertFree` is asked about EVERY path before anything
|
|
18
|
+
* is joined: a static route on the second mount's path shadows it exactly as it would the first.
|
|
19
|
+
*/
|
|
20
|
+
export function serverWebSockets(
|
|
21
|
+
option: WebSocketOption | undefined,
|
|
22
|
+
assertFree: (path: string) => void,
|
|
23
|
+
): JoinedWebSocketMounts | undefined {
|
|
24
|
+
const mounts = option === undefined ? [] : isList(option) ? option : [option];
|
|
25
|
+
for (const mount of mounts) assertFree(mount.path);
|
|
26
|
+
const [only] = mounts;
|
|
27
|
+
if (only === undefined) return undefined;
|
|
28
|
+
if (mounts.length > 1) return joinWebSocketMounts(mounts);
|
|
29
|
+
return {
|
|
30
|
+
paths: new Set([only.path]),
|
|
31
|
+
fetch: (request, server) => only.fetch(request, server),
|
|
32
|
+
// `never` is the mount's own socket type: the socket Bun hands back is the one its upgrade made.
|
|
33
|
+
websocket: only.websocket as JoinedWebSocketMounts['websocket'],
|
|
34
|
+
};
|
|
35
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { withRouteBuckets } from './rate-limit-buckets';
|
|
|
31
31
|
import { adoptRateLimitStore } from './rate-limit-installed';
|
|
32
32
|
import { jsonResponse } from './response';
|
|
33
33
|
import { describeRoutes, httpRouter, type Route } from './router';
|
|
34
|
+
import { serverWebSockets, type WebSocketOption } from './server-websocket';
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
37
|
* Beside its one caller rather than in `errors.ts`, which is at the 500-line ceiling — the
|
|
@@ -86,8 +87,12 @@ export interface ServerOptions {
|
|
|
86
87
|
* So a host may mount the socket on the port it already publishes, and the two-port topology
|
|
87
88
|
* stays exactly as it was: `x dev` does BOTH, `docker/` keeps its own `sync` service, and a
|
|
88
89
|
* client picks whichever origin it can actually reach.
|
|
90
|
+
*
|
|
91
|
+
* A LIST serves several on this one listener — the sync node's beside every declared
|
|
92
|
+
* `deviceSocket()` — each path checked free, each upgrade and its frames routed to its own mount
|
|
93
|
+
* (`server-websocket.ts`). An empty list is no websocket.
|
|
89
94
|
*/
|
|
90
|
-
readonly websocket?:
|
|
95
|
+
readonly websocket?: WebSocketOption;
|
|
91
96
|
/**
|
|
92
97
|
* `app.config.ts`'s `drain` section — pass `appConfig.drain`. Its `readinessGraceMs` is how long
|
|
93
98
|
* `/readyz` answers 503 before the `accept` phase closes this listener. Omitted, core's own
|
|
@@ -208,7 +213,6 @@ const buildServer = (options: ServerOptions, releaseStore: () => void): ServerHa
|
|
|
208
213
|
const readinessGraceMs = options.drain?.readinessGraceMs;
|
|
209
214
|
if (readinessGraceMs !== undefined) configureLifecycle({ readinessGraceMs });
|
|
210
215
|
|
|
211
|
-
const mount = options.websocket;
|
|
212
216
|
let server: BunServer | undefined;
|
|
213
217
|
let unregister: (() => void) | undefined;
|
|
214
218
|
let unregisterBodies: (() => void) | undefined;
|
|
@@ -304,7 +308,7 @@ const buildServer = (options: ServerOptions, releaseStore: () => void): ServerHa
|
|
|
304
308
|
throw websocketPathTaken(path, `the route \`${description.name}\``);
|
|
305
309
|
}
|
|
306
310
|
};
|
|
307
|
-
|
|
311
|
+
const mount = serverWebSockets(options.websocket, assertMountPathFree);
|
|
308
312
|
|
|
309
313
|
const handle: ServerHandle = {
|
|
310
314
|
role,
|
|
@@ -360,7 +364,7 @@ const buildServer = (options: ServerOptions, releaseStore: () => void): ServerHa
|
|
|
360
364
|
: Bun.serve({
|
|
361
365
|
...listen,
|
|
362
366
|
fetch: async (request, socket) =>
|
|
363
|
-
pathOf(request)
|
|
367
|
+
mount.paths.has(pathOf(request))
|
|
364
368
|
? await mount.fetch(request, socket as unknown as UpgradeTarget)
|
|
365
369
|
: await dispatch(request, socket),
|
|
366
370
|
websocket: mount.websocket,
|
package/src/stages.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// One function per stage name: what each stage of the lifecycle DOES. The package's three-way
|
|
2
2
|
// split, each file naming the other two — `pipeline.ts` owns the ORDER the stages run in and the
|
|
3
3
|
// run itself, `finalize.ts` owns the promise that the tail cannot reject, and this file owns the
|
|
4
|
-
// work. The vocabulary (`StageName`, `Stage`) lives here too, beside the
|
|
4
|
+
// work. The vocabulary (`StageName`, `Stage`) lives here too, beside the fifteen implementations
|
|
5
5
|
// it names, so `Record<StageName, StageRun>` below is the build error that catches a missing one.
|
|
6
6
|
|
|
7
7
|
import {
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from '@ultimat3/core';
|
|
16
16
|
import { signInRedirect } from './auth-redirect';
|
|
17
17
|
import { finalizeCacheHeaders } from './cache-stage';
|
|
18
|
+
import { compressResponse } from './compress';
|
|
18
19
|
import { type HttpConfig, stripBasePath } from './config';
|
|
19
20
|
import { elapsedMs, type RequestContext } from './context';
|
|
20
21
|
import { corsHeaders, preflight } from './cors';
|
|
@@ -66,7 +67,8 @@ export type StageName =
|
|
|
66
67
|
| 'handler'
|
|
67
68
|
| 'cache-headers'
|
|
68
69
|
| 'error-map'
|
|
69
|
-
| 'response'
|
|
70
|
+
| 'response'
|
|
71
|
+
| 'compress';
|
|
70
72
|
|
|
71
73
|
/**
|
|
72
74
|
* `request` may short-circuit by returning a Response.
|
|
@@ -79,7 +81,7 @@ export type StagePhase = 'request' | 'terminal' | 'recover' | 'finalize';
|
|
|
79
81
|
export interface StageDoc {
|
|
80
82
|
readonly name: StageName;
|
|
81
83
|
readonly phase: StagePhase;
|
|
82
|
-
/** Why the stage sits at this index.
|
|
84
|
+
/** Why the stage sits at this index, read by `pipeline.test.ts` and anyone reading the list. */
|
|
83
85
|
readonly why: string;
|
|
84
86
|
}
|
|
85
87
|
|
|
@@ -265,12 +267,15 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
265
267
|
selfOrigin: selfOrigin(ctx.url, ctx.https),
|
|
266
268
|
origin: request.header('origin'),
|
|
267
269
|
secFetchSite: request.header('sec-fetch-site'),
|
|
268
|
-
|
|
270
|
+
authorization: request.header('authorization'),
|
|
269
271
|
anonymous: isAnonymous(ctx.actor),
|
|
272
|
+
cookieless: ctx.route?.meta.csrf === 'cookieless',
|
|
270
273
|
cors: config.cors,
|
|
271
274
|
config: config.csrf,
|
|
272
275
|
});
|
|
273
|
-
if (!verdict.ok)
|
|
276
|
+
if (!verdict.ok) {
|
|
277
|
+
throw csrfBlocked(ctx.url.pathname, verdict.reason, ctx.ip, selfOrigin(ctx.url, ctx.https));
|
|
278
|
+
}
|
|
274
279
|
return undefined;
|
|
275
280
|
},
|
|
276
281
|
|
|
@@ -470,6 +475,14 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
470
475
|
response.headers.set('server-timing', `total;dur=${elapsedMs(ctx)}`);
|
|
471
476
|
return undefined;
|
|
472
477
|
},
|
|
478
|
+
|
|
479
|
+
compress: (request, ctx) =>
|
|
480
|
+
ctx.response === undefined
|
|
481
|
+
? undefined
|
|
482
|
+
: compressResponse(request.raw, ctx.response, {
|
|
483
|
+
anonymous: isAnonymous(ctx.actor),
|
|
484
|
+
signal: ctx.signal,
|
|
485
|
+
}),
|
|
473
486
|
};
|
|
474
487
|
return table;
|
|
475
488
|
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// How a stored object may be presented when the app's own origin serves it (plan 101, s1-sec L6):
|
|
2
|
+
// inline only for a type a browser renders without running anything, a sandboxed download otherwise.
|
|
3
|
+
// Read by `objectResponse`, the one way a stored object is answered.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Raster images, audio and video: what an `<img>`, `<audio>` or `<video>` presents and nothing
|
|
7
|
+
* executes. NOT `image/svg+xml` (it carries `<script>`), `text/html`, `application/xml` or PDF —
|
|
8
|
+
* any of them opened from the app's origin runs with the app's cookies and its `'self'` sources.
|
|
9
|
+
*/
|
|
10
|
+
const INLINE_TYPES: ReadonlySet<string> = new Set([
|
|
11
|
+
'image/png',
|
|
12
|
+
'image/jpeg',
|
|
13
|
+
'image/gif',
|
|
14
|
+
'image/webp',
|
|
15
|
+
'image/avif',
|
|
16
|
+
'audio/mpeg',
|
|
17
|
+
'audio/ogg',
|
|
18
|
+
'audio/wav',
|
|
19
|
+
'audio/webm',
|
|
20
|
+
'video/mp4',
|
|
21
|
+
'video/webm',
|
|
22
|
+
'video/ogg',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/** `image/PNG; charset=…` is `image/png`: parameters and case never widen the list. */
|
|
26
|
+
const essenceOf = (contentType: string): string =>
|
|
27
|
+
(contentType.split(';')[0] ?? '').trim().toLowerCase();
|
|
28
|
+
|
|
29
|
+
export const isInlineSafe = (contentType: string): boolean =>
|
|
30
|
+
INLINE_TYPES.has(essenceOf(contentType));
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The headers a stored object is served with beyond its type. `attachment` makes a navigation to an
|
|
34
|
+
* uploaded `text/html` a download instead of a page on the app's origin; an embedding element
|
|
35
|
+
* (`<img>`, `<video>`) ignores it, so nothing that displays an object inline stops working. The
|
|
36
|
+
* `sandbox` policy is for the browser that renders it anyway: an opaque origin, no script, no
|
|
37
|
+
* cookies. The security stage keeps it and adds the app's policy beside it.
|
|
38
|
+
*/
|
|
39
|
+
export function storedObjectHeaders(contentType: string): Readonly<Record<string, string>> {
|
|
40
|
+
return isInlineSafe(contentType)
|
|
41
|
+
? {}
|
|
42
|
+
: { 'content-disposition': 'attachment', 'content-security-policy': 'sandbox' };
|
|
43
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// `verifyBodyHmac`: the one vendor-neutral check many inbound webhooks share — HMAC-SHA256 under a
|
|
2
|
+
// UTF-8 secret over bytes the caller names, compared in constant time, under any secret of a
|
|
3
|
+
// rotation list. It does not decode a secret (Svix's base64 `whsec_` key is the mail receivers'),
|
|
4
|
+
// and the bytes and the header are the vendor's: no vendor verifier ships (axiom 8).
|
|
5
|
+
|
|
6
|
+
import { hmac, timingSafeEqual } from '@ultimat3/core';
|
|
7
|
+
import { webhookSignatureInvalid } from './errors';
|
|
8
|
+
import { assertWebhookSecrets } from './webhook-verify';
|
|
9
|
+
|
|
10
|
+
export interface BodyHmacOptions {
|
|
11
|
+
/** The bytes the vendor signed. A string is its UTF-8 encoding. */
|
|
12
|
+
readonly bytes: Uint8Array | string;
|
|
13
|
+
/** The shared secret, or the list a rotation keeps live — any one of them verifies. */
|
|
14
|
+
readonly secret: string | readonly string[];
|
|
15
|
+
/** The mac the request carried, header value as sent. Absent or empty is a refusal. */
|
|
16
|
+
readonly given: string | null | undefined;
|
|
17
|
+
readonly encoding: 'hex' | 'base64';
|
|
18
|
+
/** What precedes the mac in `given`: `sha256=` (GitHub), `v0=` (Slack). Compared, not stripped. */
|
|
19
|
+
readonly prefix?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* What a passing check answers: the bytes it verified, and their text when they are UTF-8. Read
|
|
24
|
+
* the event id from HERE, never from an unsigned header — an id outside the mac lets one captured
|
|
25
|
+
* signature replay under any id the attacker picks.
|
|
26
|
+
*/
|
|
27
|
+
export interface VerifiedBody {
|
|
28
|
+
readonly bytes: Uint8Array;
|
|
29
|
+
readonly text: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `fatal`: invalid UTF-8 is `null` text, never replacement characters an id could be read from. */
|
|
33
|
+
const strictUtf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Prove `given` is `prefix + HMAC-SHA256(secret, bytes)` for one of the secrets, and answer what
|
|
37
|
+
* was verified. Throws `X_WEBHOOK_SIGNATURE_INVALID` (401) on a mismatch and `X_CONFIG_INVALID`
|
|
38
|
+
* for no usable secret.
|
|
39
|
+
*/
|
|
40
|
+
export function verifyBodyHmac(options: BodyHmacOptions): VerifiedBody {
|
|
41
|
+
const secrets = assertWebhookSecrets(options.secret, 'verifyBodyHmac');
|
|
42
|
+
const given = options.given ?? '';
|
|
43
|
+
if (given === '') {
|
|
44
|
+
throw webhookSignatureInvalid(
|
|
45
|
+
'verifyBodyHmac',
|
|
46
|
+
'the request carried no signature',
|
|
47
|
+
'verifyBodyHmac',
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const prefix = options.prefix ?? '';
|
|
51
|
+
let matched = false;
|
|
52
|
+
// Every secret is tried, even after a match: which one matched is not worth a timing signal.
|
|
53
|
+
for (const secret of secrets) {
|
|
54
|
+
const expected = `${prefix}${hmac('sha256', secret, options.bytes, options.encoding)}`;
|
|
55
|
+
if (timingSafeEqual(expected, given)) matched = true;
|
|
56
|
+
}
|
|
57
|
+
if (!matched) {
|
|
58
|
+
throw webhookSignatureInvalid(
|
|
59
|
+
'verifyBodyHmac',
|
|
60
|
+
`no ${options.encoding} sha256 mac${prefix === '' ? '' : ` behind the "${prefix}" prefix`} under the ${secrets.length} configured secret(s) matches the bytes`,
|
|
61
|
+
'verifyBodyHmac',
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
const bytes =
|
|
65
|
+
typeof options.bytes === 'string' ? new TextEncoder().encode(options.bytes) : options.bytes;
|
|
66
|
+
let text: string | null;
|
|
67
|
+
try {
|
|
68
|
+
text = typeof options.bytes === 'string' ? options.bytes : strictUtf8.decode(bytes);
|
|
69
|
+
} catch {
|
|
70
|
+
text = null;
|
|
71
|
+
}
|
|
72
|
+
return { bytes, text };
|
|
73
|
+
}
|