@ultimat3/http 29.3.2 → 30.0.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/byte-range.ts +69 -0
- 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 +24 -0
- package/src/error-map-tier-2.ts +16 -0
- package/src/error-map-tier-3.ts +30 -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 +20 -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 +22 -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
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Single responsibility: the one correct answer to a GET for an object held elsewhere — its stored
|
|
2
|
+
// type, its validator, one `Range:` window, the sandbox for a type a browser would run. `/_storage`,
|
|
3
|
+
// `/media`, a download and an app's own route for an object a stranger may fetch all answer here.
|
|
4
|
+
// Structural, because this package imports no storage: anything with these fields is servable.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
contentRangeOf,
|
|
8
|
+
parseByteRange,
|
|
9
|
+
RANGE_UNSATISFIABLE,
|
|
10
|
+
type RequestedRange,
|
|
11
|
+
} from './byte-range';
|
|
12
|
+
import type { CacheHint } from './response';
|
|
13
|
+
import { applyCacheHeaders } from './response';
|
|
14
|
+
import { storedObjectHeaders } from './stored-object-headers';
|
|
15
|
+
|
|
16
|
+
/** What the answer needs to know before it opens anything. `StorageObject` is one of these. */
|
|
17
|
+
export interface ServableObject {
|
|
18
|
+
/** The STORED type. An extension in the URL is the only party that can lie. */
|
|
19
|
+
readonly contentType: string;
|
|
20
|
+
readonly size: number;
|
|
21
|
+
/** The object's own validator. Absent or `''` (unknowable): no `etag` header, and no 304. */
|
|
22
|
+
readonly etag?: string | undefined;
|
|
23
|
+
/** Omitted, never invented: a made-up date is a validator a cache would go on to trust. */
|
|
24
|
+
readonly lastModified?: Date | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ObjectSource {
|
|
28
|
+
readonly object: ServableObject;
|
|
29
|
+
/**
|
|
30
|
+
* Exactly those bytes, or the whole object with no range. Opened only once a body will be sent: a
|
|
31
|
+
* 304 and a 416 open nothing. Takes the range so a disk can serve a window, not drop a prefix.
|
|
32
|
+
*/
|
|
33
|
+
readonly open: (range?: RequestedRange) => BodyInit | Promise<BodyInit>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The one thing asked of the request, so a test or a non-pipeline caller needs no `Request`. */
|
|
37
|
+
export interface ObjectRequest {
|
|
38
|
+
header(name: string): string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ObjectResponseOptions {
|
|
42
|
+
/** Applied to every answer. Omitted, the route's declared `meta.cache` decides as for any route. */
|
|
43
|
+
readonly cache?: CacheHint | undefined;
|
|
44
|
+
/** Replaces the stored-object sandbox rule, for a caller with its own, as a download has. */
|
|
45
|
+
readonly headers?: Readonly<Record<string, string>> | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `*`, a weak validator and a comma list all count — the etag is what has to match. */
|
|
49
|
+
export function etagMatches(header: string | null, etag: string): boolean {
|
|
50
|
+
if (header === null) return false;
|
|
51
|
+
if (header.trim() === '*') return true;
|
|
52
|
+
return header.split(',').some((candidate) => candidate.trim().replace(/^W\//, '') === etag);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The 304 for a client already holding `etag`, or nothing for the caller to go on and answer. It
|
|
57
|
+
* repeats the validator and whatever posture `headers` carry (RFC 9110 §15.4.5).
|
|
58
|
+
*/
|
|
59
|
+
export function notModified(
|
|
60
|
+
request: ObjectRequest,
|
|
61
|
+
etag: string,
|
|
62
|
+
headers?: HeadersInit,
|
|
63
|
+
): Response | undefined {
|
|
64
|
+
if (!etagMatches(request.header('if-none-match'), etag.replace(/^W\//, ''))) return undefined;
|
|
65
|
+
const answer = new Headers(headers);
|
|
66
|
+
answer.set('etag', etag);
|
|
67
|
+
return new Response(null, { status: 304, headers: answer });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether `if-range` still names these bytes. Compared strongly, and a date only if it is exactly
|
|
72
|
+
* the object's: a window of an older revision spliced onto this one is two files in one download.
|
|
73
|
+
*/
|
|
74
|
+
function ifRangeHolds(header: string | null, etag: string | undefined, modified?: Date): boolean {
|
|
75
|
+
if (header === null) return true;
|
|
76
|
+
const validator = header.trim();
|
|
77
|
+
if (validator.startsWith('W/')) return false;
|
|
78
|
+
if (validator.startsWith('"')) return etag === validator;
|
|
79
|
+
return modified?.toUTCString() === validator;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A provider's etag may arrive already quoted, and quoting it again answers an invalid header. */
|
|
83
|
+
const quotedEtag = (etag: string): string =>
|
|
84
|
+
etag.startsWith('"') || etag.startsWith('W/"') ? etag : `"${etag}"`;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Revalidation costs a request and no bytes, which is the trade an authorized response wants: the
|
|
88
|
+
* bytes do not change under a validator, but the actor's permission to read them can be revoked.
|
|
89
|
+
* The size is the object's description, so the length declared is the length `open` must deliver.
|
|
90
|
+
*/
|
|
91
|
+
export async function objectResponse(
|
|
92
|
+
request: ObjectRequest,
|
|
93
|
+
source: ObjectSource,
|
|
94
|
+
options: ObjectResponseOptions = {},
|
|
95
|
+
): Promise<Response> {
|
|
96
|
+
const { object } = source;
|
|
97
|
+
const headers = new Headers({
|
|
98
|
+
'content-type': object.contentType,
|
|
99
|
+
'accept-ranges': 'bytes',
|
|
100
|
+
// A type a browser would RUN (html, svg, xml) is a download, never a page on this origin.
|
|
101
|
+
...(options.headers ?? storedObjectHeaders(object.contentType)),
|
|
102
|
+
});
|
|
103
|
+
if (object.lastModified !== undefined) {
|
|
104
|
+
headers.set('last-modified', object.lastModified.toUTCString());
|
|
105
|
+
}
|
|
106
|
+
const answer = (body: BodyInit | null, status: number): Response => {
|
|
107
|
+
const response = new Response(body, { status, headers });
|
|
108
|
+
return options.cache === undefined ? response : applyCacheHeaders(response, options.cache);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const etag =
|
|
112
|
+
object.etag === undefined || object.etag === '' ? undefined : quotedEtag(object.etag);
|
|
113
|
+
if (etag !== undefined) {
|
|
114
|
+
headers.set('etag', etag);
|
|
115
|
+
if (notModified(request, etag) !== undefined) return answer(null, 304);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const { size } = object;
|
|
119
|
+
// A range of another revision is no range at all: the whole object, never a 416 either.
|
|
120
|
+
const range = ifRangeHolds(request.header('if-range'), etag, object.lastModified)
|
|
121
|
+
? parseByteRange(request.header('range'), size)
|
|
122
|
+
: undefined;
|
|
123
|
+
if (range === RANGE_UNSATISFIABLE) {
|
|
124
|
+
headers.set('content-range', `bytes */${String(size)}`);
|
|
125
|
+
return answer(null, 416);
|
|
126
|
+
}
|
|
127
|
+
if (range === undefined) {
|
|
128
|
+
headers.set('content-length', String(size));
|
|
129
|
+
return answer(await source.open(), 200);
|
|
130
|
+
}
|
|
131
|
+
headers.set('content-range', contentRangeOf(range, size));
|
|
132
|
+
headers.set('content-length', String(range.end - range.start + 1));
|
|
133
|
+
return answer(await source.open(range), 206);
|
|
134
|
+
}
|
package/src/overlay.ts
CHANGED
|
@@ -75,7 +75,16 @@ export const renderOverlay = (error: unknown, meta: OverlayMeta = {}): string =>
|
|
|
75
75
|
dev: true,
|
|
76
76
|
});
|
|
77
77
|
const where = `${meta.method ?? ''} ${meta.path ?? ''}`.trim();
|
|
78
|
-
|
|
78
|
+
// A whole document, `lang` and `<title>` included: an e2e test's accessibility audit reads the
|
|
79
|
+
// overlay like any page, and a fragment has neither.
|
|
80
|
+
return `<!doctype html>
|
|
81
|
+
<html lang="en">
|
|
82
|
+
<head>
|
|
83
|
+
<meta charset="utf-8">
|
|
84
|
+
<title>${escapeHtml(`${facts.code} ${facts.title}`)}</title>
|
|
85
|
+
<style>${OVERLAY_STYLE}</style>
|
|
86
|
+
</head>
|
|
87
|
+
<body>
|
|
79
88
|
<main>
|
|
80
89
|
<section class="card">
|
|
81
90
|
<h1>${escapeHtml(facts.code)} <span class="title">${escapeHtml(facts.title)}</span></h1>
|
|
@@ -105,7 +114,9 @@ export const renderOverlay = (error: unknown, meta: OverlayMeta = {}): string =>
|
|
|
105
114
|
<h2>json</h2>
|
|
106
115
|
<pre>${escapeHtml(JSON.stringify(problem, null, 2))}</pre>
|
|
107
116
|
</section>
|
|
108
|
-
</main
|
|
117
|
+
</main>
|
|
118
|
+
</body>
|
|
119
|
+
</html>`;
|
|
109
120
|
};
|
|
110
121
|
|
|
111
122
|
export const overlayResponse = (error: unknown, meta: OverlayMeta = {}): Response =>
|
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,
|