@ultimat3/http 1.1.0 → 2.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 +285 -0
- package/README.md +99 -2
- package/package.json +6 -3
- package/src/auth-redirect.ts +81 -0
- package/src/cache-policy.ts +24 -0
- package/src/config.ts +67 -7
- package/src/context.ts +187 -36
- package/src/correlation.ts +44 -0
- package/src/cors.ts +33 -5
- package/src/csrf.ts +85 -0
- package/src/deadline.ts +79 -0
- package/src/error-map.ts +204 -4
- package/src/errors.ts +334 -5
- package/src/finalize.ts +70 -0
- package/src/forwarded.ts +94 -0
- package/src/hooks.ts +39 -4
- package/src/index.ts +60 -23
- package/src/locale.ts +34 -82
- package/src/overlay-style.ts +43 -0
- package/src/overlay.ts +59 -41
- package/src/peer-identity.ts +107 -0
- package/src/pipeline.ts +175 -294
- package/src/rate-limit-buckets.ts +86 -0
- package/src/rate-limit.ts +197 -9
- package/src/redirect.ts +29 -0
- package/src/request.ts +47 -13
- package/src/response.ts +34 -8
- package/src/router.ts +63 -13
- package/src/security-headers.ts +34 -13
- package/src/server.ts +44 -7
- package/src/stages.ts +381 -0
- package/src/type-pins.ts +48 -0
- package/src/validate.ts +13 -2
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# @ultimat3/http
|
|
2
|
+
|
|
3
|
+
Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
4
|
+
|
|
5
|
+
## Boundary
|
|
6
|
+
|
|
7
|
+
- May import: `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n`, `@ultimat3/time` — tiers 0
|
|
8
|
+
and 1, which is the whole rule. There is no extra restriction here; the line used to read
|
|
9
|
+
"core, schema. Nothing else, ever", stated no reason, and was stricter than the tier table.
|
|
10
|
+
**What it bought was three re-implementations.** `locale.ts` carried its own `negotiateLocale`,
|
|
11
|
+
`isValidTimeZone` and `resolveTimeZone`, and each disagreed with its owner about the same
|
|
12
|
+
request: an app shipping `{ en, fr }` resolved `ctx.locale` to `'en'` forever, a switcher writing
|
|
13
|
+
the documented `LOCALE_COOKIE` (`x_locale`) was read by nothing because this package spelled it
|
|
14
|
+
`x-locale`, and `x-timezone: +01:00` — a fixed offset with no DST rules — became `ctx.tz` and
|
|
15
|
+
threw four packages later. Adding a tier-1 import is cheaper than a fourth divergence.
|
|
16
|
+
- May NOT import `@ultimat3/policy` or `@ultimat3/entity` — same tier. Authz and auth
|
|
17
|
+
come in via `ServerHooks` (`hooks.ts`), declared structurally.
|
|
18
|
+
- `@ultimat3/action` (tier 3) is what wires policy into `hooks.authorize`.
|
|
19
|
+
|
|
20
|
+
## Rules
|
|
21
|
+
|
|
22
|
+
- Route `meta.auth` is required. Never default a route to public.
|
|
23
|
+
- **`asCtx` is a WIDENING the compiler checks, never a cast.** `RequestContext extends Ctx`, and
|
|
24
|
+
`asCtx` is the identity function. It used to be `ctx as unknown as Ctx` over an object that set
|
|
25
|
+
none of `clock`, `now`, `logger`, `signal` or `services` — so `ctx.now()` threw
|
|
26
|
+
`TypeError: ctx.now is not a function` on every audited action served over HTTP,
|
|
27
|
+
`useService()` threw a `TypeError` instead of the `X_SERVICE_MISSING` it exists to raise, and
|
|
28
|
+
`throwIfAborted()` — the documented cancellation seam — was inert on the one surface where a
|
|
29
|
+
caller can actually go away. Never reintroduce the assertion: the type error IS the enforcement,
|
|
30
|
+
and it is a type-pin rather than a `.test.ts` because `tsconfig.json` excludes tests.
|
|
31
|
+
`ctx.buildId` is core's meaning — the build this PROCESS serves; the CLIENT's claim is
|
|
32
|
+
`ctx.clientBuildId`, read only by `assertBuild()`.
|
|
33
|
+
- **The two inbound ids are read BEFORE the context and the span, in `correlation.ts`.** `startSpan`
|
|
34
|
+
resolves its parent from `currentSpanContext()`, which reads `ctx.traceId`, so a `traceparent`
|
|
35
|
+
parsed by a stage arrived one frame after the span's context was already frozen: the caller's
|
|
36
|
+
trace was discarded, the root span carried a dashed UUIDv7 no collector accepts as a trace id,
|
|
37
|
+
and the log lines beside it quoted a third value. The `request-id` and `trace` stages now only
|
|
38
|
+
PUBLISH what was decided — they do not decide. The regex is core's `parseTraceparent`, one copy.
|
|
39
|
+
- **Every proxy-supplied header goes through `forwardedElement(header, hops)` and nothing else.**
|
|
40
|
+
`trustProxy` documented reading `x-forwarded-for` and had no reader at all, so behind any ingress
|
|
41
|
+
every anonymous request keyed to the proxy — one `auth` bucket (capacity 10) for the whole
|
|
42
|
+
internet, and one scanner enough to 429 every signup on the fleet. The entry read is
|
|
43
|
+
`entries.length - hops`, never `[0]`, which is whatever the client typed; a chain shorter than
|
|
44
|
+
declared trusts nothing rather than falling back leftward. `trustProxy` defaults to **false** and
|
|
45
|
+
requires `trustedProxyHops` (`X_TRUST_PROXY_UNSET` at `defineHttpConfig`) — it also gates the
|
|
46
|
+
`x-request-id` echo, and a direct caller choosing its own request id poisons log correlation.
|
|
47
|
+
`x-forwarded-proto` rides the same rule, which is what finally emits HSTS behind a
|
|
48
|
+
TLS-terminating ingress, and so does Envoy's `x-forwarded-client-cert` (`peer-identity.ts`).
|
|
49
|
+
**A peer certificate read from an untrusted hop is worse than none, because it authenticates** —
|
|
50
|
+
so `ctx.peer` is `null` for an untrusted deployment, a missing header and a short chain alike.
|
|
51
|
+
`ctx.peer` is never an actor: `hooks.authenticate` is the one funnel, through
|
|
52
|
+
`verifyWorkloadToken()` -> `actorFromService()` in `@ultimat3/auth`.
|
|
53
|
+
- **One deadline per request, and it is what makes `ctx.signal` exist.** `deadline.ts` holds the
|
|
54
|
+
`AbortController` and the timer; `config.requestTimeoutMs` (30s, `0` disables) is the budget and
|
|
55
|
+
a caller may SHORTEN it with `x-request-timeout-ms`, never lengthen it. Two halves, both needed:
|
|
56
|
+
the abort is what cooperative code unwinds on, and the race in `execute` is what answers the
|
|
57
|
+
socket when a handler never looked at the signal. `X_TIMEOUT` is borrowed (core's concept) and
|
|
58
|
+
already mapped to 504. Always `deadline.clear()` in the `finally` — a live timer keeps the event
|
|
59
|
+
loop from going idle, so a process that answered everything still refuses to exit.
|
|
60
|
+
- **`admit` is the second stage, and it refuses before ANY work.** `isDraining()` had no reader in
|
|
61
|
+
this package while this file claimed the layer answered 503 on it; past `config.maxInflight`
|
|
62
|
+
(1000, `0` disables) a request is shed `X_OVERLOADED` with `retry-after`. Both set the header on
|
|
63
|
+
`ctx.headers`, which the `response` stage merges, rather than teaching `error-map` a second
|
|
64
|
+
special case. The in-flight number is core's `inflightCount()` — the same counter `beginWork()`
|
|
65
|
+
in `server.ts` maintains — never a private one, for the same reason the drain phase is core's.
|
|
66
|
+
A refusal that costs as much as a served request is not load shedding.
|
|
67
|
+
- **`csrf` sits after `auth` and before `body`, and CORS cannot replace it.**
|
|
68
|
+
`application/x-www-form-urlencoded` is a CORS-*simple* content type, so a cross-site
|
|
69
|
+
`<form method="post">` is SENT and EXECUTED with the session cookie attached and
|
|
70
|
+
`cors.origins: []` only withholds the reply — long after the refund went through. After auth
|
|
71
|
+
because only an AMBIENT credential can be forged into (anonymous and bearer callers are exempt);
|
|
72
|
+
before body so a rejected write never allocates its payload. `sec-fetch-site: same-origin`, an
|
|
73
|
+
`Origin` equal to this app, or an `Origin` already in `cors.origins`; anything else is
|
|
74
|
+
`X_CSRF_BLOCKED` (403, never 401 — the caller IS signed in, which is the problem). The self
|
|
75
|
+
origin is built from `ctx.https`, not `url.protocol`, or every legitimate post behind a
|
|
76
|
+
TLS-terminating ingress would be refused. **`mode: 'token'` is deliberately NOT shipped** — a
|
|
77
|
+
double-submit token needs a cookie issuer and a form-field helper at tier 4/5, and a half-built
|
|
78
|
+
token mode is worse than an honest `'origin' | 'off'`.
|
|
79
|
+
- **A rejected value is a log FIELD, never part of the message.** `logger.emit()` redacts `bound`,
|
|
80
|
+
`contextFields` and `fields` — and never `msg` — so `logger.error(\`${code}: ${cause}\`)` in the
|
|
81
|
+
`error-map` stage wrote a rejected password verbatim into the log store, at 4xx, which is logged
|
|
82
|
+
and not reported and therefore kept for the full retention. The message is the CODE alone. The
|
|
83
|
+
other half is `@ultimat3/schema`'s `describeValue` (shape, never content) and it is the
|
|
84
|
+
load-bearing one; this half is what makes the value redactable at all.
|
|
85
|
+
- **A browser that fails `auth: 'required'` is redirected; an agent gets the problem document.**
|
|
86
|
+
One condition, two audiences, decided once in `auth-redirect.ts` and applied in the `error-map`
|
|
87
|
+
stage before the overlay. `config.signInPath` is `null` until an app names its page, because a
|
|
88
|
+
framework that guessed `/signin` would send an app spelling it `/login` to a 404 — strictly
|
|
89
|
+
worse than the JSON. The round trip is `?next=`, and `nextAfterSignIn` is the ONE reader of it:
|
|
90
|
+
anything that is not a same-origin path falls back, or the page that hands out a session
|
|
91
|
+
becomes an open redirect. **A control character is an off-site destination**: a browser deletes
|
|
92
|
+
TAB, CR and LF from a `Location` before parsing it, so `/%09/evil.test` decodes to a value that
|
|
93
|
+
starts with one slash, passes a prefix check and is then followed as `//evil.test`. The prefix
|
|
94
|
+
checks are not the last word — the value is re-parsed against an origin no relative path can
|
|
95
|
+
reach, and anything that resolves off it falls back. Nothing here throws either: `?next=%` is a
|
|
96
|
+
bare `URIError`, and this runs while the pipeline is already rendering a 401.
|
|
97
|
+
- **The body cap is enforced while reading, never after.** `UltimateRequest.#read` pulls the body
|
|
98
|
+
through a counting reader and cancels the stream the moment the running total passes
|
|
99
|
+
`bodyLimitBytes`. `content-length` is a courtesy — a `transfer-encoding: chunked` request
|
|
100
|
+
declares none, so `arrayBuffer()` allocated a 10GB payload in full before measuring it. Multipart
|
|
101
|
+
goes through the same capped bytes (re-parsed by `Response.formData()` off the announced
|
|
102
|
+
boundary) rather than being handed to the runtime as an unbounded stream, which is what left it
|
|
103
|
+
with no byte guard at all when the length was undeclared.
|
|
104
|
+
- **The cache default reads the ACTOR, not just the route, and `vary` is added and never set.**
|
|
105
|
+
`meta.auth` is only `'public' | 'required'`, so the page that greets a signed-in visitor by name
|
|
106
|
+
is a `'public'` route: keying the default off the route alone put that visitor's personalised
|
|
107
|
+
HTML in a shared cache for 60 seconds. A request whose actor is not anonymous is `private`;
|
|
108
|
+
an anonymous one stays shared-cacheable and carries `vary: accept-language, cookie`. Both halves
|
|
109
|
+
are required — either alone leaves the hole. `addVary` (`response.ts`) is how the `response`
|
|
110
|
+
stage merges CORS's `vary: origin` into the cache stage's key instead of replacing it.
|
|
111
|
+
- **`cors.origins: ['*']` with `credentials: true` is refused at `defineHttpConfig`.** No browser
|
|
112
|
+
accepts that pair, and `allowedOrigin` answering `null` for it meant the natural "open it up"
|
|
113
|
+
edit emitted no CORS headers at all, silently, on every request — with `DEFAULT_CORS.credentials`
|
|
114
|
+
(true) as the half nobody thinks to look at. `X_CORS_CONFIG_INVALID`, at config time, with the
|
|
115
|
+
one-line edit in the `fix`. A REFUSED origin still gets `vary: origin`: without it a shared cache
|
|
116
|
+
files the un-CORS'd body under the URL alone and hands it to an allowed origin next.
|
|
117
|
+
- **HSTS is emitted only when https is affirmed.** `securityHeaders(config, { https })` defaults to
|
|
118
|
+
NOT sending it — the pipeline is the one caller that knows, and it passes `ctx.https`. The guard
|
|
119
|
+
read `!== false`, so every other caller sent a two-year `includeSubDomains` for a connection
|
|
120
|
+
nothing had established was secure, which is the opposite of what the comment above it promised.
|
|
121
|
+
- **`meta.enforcedBy` says who evaluates `meta.policy`, and the `authz` stage obeys it.**
|
|
122
|
+
`'pipeline'` (the default, and what a page wants) means the stage decides through
|
|
123
|
+
`hooks.authorize`; `'handler'` means the handler is the one evaluation and the stage returns
|
|
124
|
+
without deciding — no hook required, and none consulted. An action route says `'handler'`
|
|
125
|
+
because `@ultimat3/action`'s `invoke` loads the row a row-level rule reads and this stage
|
|
126
|
+
cannot. Deciding in both places is two authz systems, and the one that answers first is the
|
|
127
|
+
one holding less.
|
|
128
|
+
- **`ctx.actor` is never null.** `asCtx` publishes the request context itself as core's `Ctx`,
|
|
129
|
+
and `Ctx.actor` is an `Actor` — so "nobody" is core's anonymous actor, not `null`. The
|
|
130
|
+
`authenticate` hook still says it with `null`; the `auth` stage is where that becomes
|
|
131
|
+
`anonymousActor()`. A null here reaches every `ctx.actor` reader in the framework as a contract
|
|
132
|
+
violation that only shows up on the first unauthenticated request.
|
|
133
|
+
- **The lifecycle is three files, and the split is by responsibility, not by length.** `pipeline.ts`
|
|
134
|
+
owns the ORDER (`PIPELINE_STAGES`, the phases, the run loop, ALS, the span and the one metrics
|
|
135
|
+
call); `stages.ts` owns what each stage does and declares the vocabulary (`StageName`,
|
|
136
|
+
`StageRun`, `Stage`) beside the implementations it names; `finalize.ts` owns the promise that the
|
|
137
|
+
tail answers rather than rejects. Imports go one way — `pipeline.ts` → `stages.ts` — because a
|
|
138
|
+
stage body reads `StageRunnersInput`, an explicit list of what a stage may depend on, and never
|
|
139
|
+
`PipelineDeps`. Adding a stage means an entry in **both** `PIPELINE_STAGES` and the
|
|
140
|
+
`Record<StageName, StageRun>` table; the record type is what makes forgetting one a build error.
|
|
141
|
+
- Never add a stage to `PIPELINE_STAGES` without a `why` and a test.
|
|
142
|
+
- **The `locale` stage decides WHERE, the owners decide WHAT.** It reads a header and a cookie and
|
|
143
|
+
hands the raw strings to `@ultimat3/i18n`'s `resolveLocale` and `@ultimat3/time`'s
|
|
144
|
+
`resolveTimeZone`; it must never negotiate, validate or canonicalize one itself. The two answers
|
|
145
|
+
land on `ctx.locale` and `ctx.tz` — **core's own declared fields, the framework's only ambient
|
|
146
|
+
store for either** — so `currentLocale()` and `currentTimeZone()` answer for this request once
|
|
147
|
+
`pipeline.ts` publishes the context into the ALS. `@ultimat3/time` kept a second store
|
|
148
|
+
(`ctx['timeZone']`) with zero writers until 1.3.0, and the whole cost was silent: every
|
|
149
|
+
`@ultimat3/ui` server render formatted its dates in UTC however the request arrived. A default
|
|
150
|
+
for either value is `configureTime({ defaultZone })` / `defineCatalogs({ default })`, never a
|
|
151
|
+
third copy in `HttpConfig`.
|
|
152
|
+
- **`toBucket` lives here, not in `@ultimat3/action`.** `action` and `query` are the same tier and
|
|
153
|
+
can never import each other, so the only conversion between `{ limit, windowMs }` and a `Bucket`
|
|
154
|
+
sitting in one of them is why a `query` could not declare a rate limit at all. It is beside
|
|
155
|
+
`Bucket` and the maths it validates, and it throws http's own `X_RATE_LIMIT_INVALID`.
|
|
156
|
+
- Statuses live in `error-map.ts` only. No other file writes a status number. The framework's
|
|
157
|
+
table (`ERROR_STATUS`) is closed; an app declares its own codes' statuses with
|
|
158
|
+
`registerErrorStatus()`, which refuses a code the framework already holds. Without that half,
|
|
159
|
+
every app code was 500 and `pipeline.ts` paged the on-call for a wrong password.
|
|
160
|
+
- **The context carries the inbound headers, never the `Request`.** `ctx.requestHeaders` is set
|
|
161
|
+
once at construction; `useRequestHeader` / `useRequestCookie` are what app code reads, and
|
|
162
|
+
`UltimateRequest.cookie()` is what `hooks.authenticate` reads. A `Request` on the context is a
|
|
163
|
+
second body reader past the size cap, the content-type parse and the cache.
|
|
164
|
+
- **`hooks.authenticate` has one declaration site: `configureAuthenticator()`.** A single value,
|
|
165
|
+
not a list — two answers to "who is this?" is two identities per request. `@ultimat3/auth` is
|
|
166
|
+
the same tier and can never import this package, so the app is what wires them together.
|
|
167
|
+
- **`hooks.devNotices` is dev-only, and the overlay path is the only place it is called.**
|
|
168
|
+
`OverlayNotice` is declared structurally in `overlay.ts` because the packages that produce one
|
|
169
|
+
— `@ultimat3/entity`'s N+1 codes, reported by `x dev` — are this tier or above and can never be
|
|
170
|
+
imported here, exactly as `AuthzDecision` is. The call sits INSIDE the
|
|
171
|
+
`config.dev && wantsOverlay` branch: the overlay is a notice's only surface, so a production
|
|
172
|
+
process, or an agent that asked for problem+json, must not pay a diagnostic's per-request cost
|
|
173
|
+
for findings nothing renders. No notices means no card, byte for byte.
|
|
174
|
+
- **`matchRoute` never throws — a pathname is whatever the client typed.** `decodeURIComponent`
|
|
175
|
+
is called only through `router.ts`'s guarded `decodeSegment`, and a segment that will not decode
|
|
176
|
+
answers `{ reason: 'path-invalid', segment }` → `X_PATH_INVALID` → 400. A bare `URIError` here
|
|
177
|
+
reached `factsOf` as `X_INTERNAL`, so a `%ZZ` answered 500 and paged the on-call for a typo.
|
|
178
|
+
Only the branch that would have decoded fails: static segments are compared raw, so a path that
|
|
179
|
+
reaches no param or wildcard is still a 404 and precedence is unchanged.
|
|
180
|
+
- **`handle()` resolves to a Response or the server has no answer at all.** The request phases are
|
|
181
|
+
guarded by `execute`'s own `try`; the two that run after them are guarded in `finalize.ts`, and
|
|
182
|
+
neither guard is optional. A finalize stage that refuses the response it was handed degrades to
|
|
183
|
+
`X_PIPELINE_FINALIZE_FAILED` (500), and the chain runs a **second** pass over that problem
|
|
184
|
+
document — whose headers are writable — so the request id, CORS and the security headers still
|
|
185
|
+
reach the client. Two passes, never a loop. A throw inside the recover stage (an app's `onError`,
|
|
186
|
+
a `devNotices` producer) is answered with the problem document for the error the request actually
|
|
187
|
+
hit: the stage that renders a throw has nothing left to render its own. Every degraded answer goes
|
|
188
|
+
*through* the recover stage, never around it — reporting, logging and the overlay each keep one
|
|
189
|
+
call site.
|
|
190
|
+
- **The memory rate-limit store is bounded, and the eviction order is part of the guarantee.**
|
|
191
|
+
The key falls back to the connection address (`rateLimitKey`), so a scan rotating through an
|
|
192
|
+
IPv6 /64 mints one entry per request — an unbounded map hands the flood the process. Every
|
|
193
|
+
entry carries `forgetAtMs`, the instant a refilled bucket becomes indistinguishable from a
|
|
194
|
+
missing one, and the sweep drops those for free. `DEFAULT_MAX_RATE_LIMIT_KEYS` is the backstop,
|
|
195
|
+
and it evicts the entries **closest to full** first: throwing away a spent bucket is a free
|
|
196
|
+
reset for whoever spent it, so the most-throttled key is the last one to go. Never swap that
|
|
197
|
+
comparator for insertion order or an LRU — recency is not the same as worthlessness here.
|
|
198
|
+
- **Where the limiter's counters live is DECLARED by the app, never inferred, and refused at
|
|
199
|
+
boot — and there is no default.** `DEFAULT_RATE_LIMIT` carries no `scope`, so
|
|
200
|
+
`resolveRateLimitConfig` refuses `X_RATE_LIMIT_SCOPE_UNSET` at `defineHttpConfig` when a limiter
|
|
201
|
+
that is ENABLED has not been told. `'process'` used to be the default, which made "nobody asked"
|
|
202
|
+
and "the app said one replica" the same value while `docker/helm/values.yaml` runs three — so
|
|
203
|
+
`assertRateLimitScope` below, which only fires on a `'shared'` declaration, could never see the
|
|
204
|
+
silent case. A disabled limiter owes no declaration: nothing is enforced, so nothing can be
|
|
205
|
+
wrong. The rest of the check is unchanged: `RateLimitStore.scope` says what a driver provides; `config.rateLimit.scope` says what
|
|
206
|
+
the deployment requires; `assertRateLimitScope` compares them once, inside `createPipeline` —
|
|
207
|
+
the one construction path `createServer`, the tests and any embedder all share. `'shared'` over
|
|
208
|
+
a per-process store is `X_RATE_LIMIT_NOT_SHARED` before the socket opens, because the failure it
|
|
209
|
+
replaces is silent: the limiter's counters are **per process**, and `docker/helm/values.yaml`
|
|
210
|
+
runs `roles.web.replicas: 3` before its HPA has said anything, so every configured bucket was
|
|
211
|
+
being enforced three times over with a green `x verify`. Nothing here reads the
|
|
212
|
+
environment to guess a replica count — an app that scales is the only thing that knows. The
|
|
213
|
+
supported way to install one is `createServer({ rateLimitStore })`, which builds the limiter
|
|
214
|
+
through `createRateLimiter` and hands it to the `PipelineDeps.limiter` seam that already
|
|
215
|
+
existed; never add a second limiter entry point beside it.
|
|
216
|
+
- **A bucket a route names is a bucket something must register.** `meta.rateLimit` selects by
|
|
217
|
+
name and `meta.rateLimitBucket` carries the numbers; `withRouteBuckets` (`rate-limit-buckets.ts`)
|
|
218
|
+
merges them into `config.rateLimit.buckets` at construction, in `createServer` and again in
|
|
219
|
+
`createPipeline` — idempotently, since the store-backed limiter is built from the merged config
|
|
220
|
+
and `bucketFor` must see the same table the pipeline does. It has to happen there: routes do not
|
|
221
|
+
exist when `defineHttpConfig` builds the table, so a name declared and never registered fell
|
|
222
|
+
through to `default` — an action declaring `limit: 5` ran on 120 burst while its OpenAPI
|
|
223
|
+
operation published 5. **Precedence is refusal, not a winner.** An identical restatement passes;
|
|
224
|
+
any disagreement, with the config or with another route, is `X_RATE_LIMIT_BUCKET_CONFLICT` before
|
|
225
|
+
the socket opens — the same shape as `assertRateLimitScope` and as `@ultimat3/auth`'s
|
|
226
|
+
`AuthLimiter` policy check, and for the same reason: the declaration that lost would go on being
|
|
227
|
+
read as enforced. Never make one side the default winner.
|
|
228
|
+
- **Registering into the config is only half of it — the installed LIMITER must hold the bucket
|
|
229
|
+
too.** `createRateLimiter` closes over its config, so a limiter handed to `PipelineDeps.limiter`
|
|
230
|
+
resolves names against the table it was built with; one built before the routes existed misses
|
|
231
|
+
the route's name, falls through `bucketFor` to `default`, and was measured at 120 burst and 21
|
|
232
|
+
of 21 requests allowed for a route declaring 5. `RateLimiter.buckets` publishes that table —
|
|
233
|
+
declared, never inferred, exactly as `RateLimitStore.scope` is — and `assertRouteBuckets` runs
|
|
234
|
+
beside `assertRateLimitScope` in `createPipeline`. **Refused, never rebound**: a `RateLimiter`
|
|
235
|
+
is opaque, so rebinding means discarding the caller's limiter and the store it carries, and a
|
|
236
|
+
caller who built their own may have meant their own numbers. A limiter that declares no table
|
|
237
|
+
is refused too — what cannot be shown to hold is not assumed to hold.
|
|
238
|
+
- Never throw a bare `Error` — use a factory from `errors.ts`.
|
|
239
|
+
- No `any`. Validation goes through Standard Schema (`validate.ts`), not a vendor API.
|
|
240
|
+
- Health endpoints answer outside the pipeline, on purpose.
|
|
241
|
+
- **Lifecycle belongs to core.** `server.ts` uses `beginWork()`, `markReady()`,
|
|
242
|
+
`drain()` and `healthzPayload()`/`readyzPayload()`. Never keep a private `state` or
|
|
243
|
+
in-flight counter — core waits on work it does not know about, so a private counter
|
|
244
|
+
hangs every deploy at the `inflight` phase.
|
|
245
|
+
- **Borrowed error codes are never titled or registered here.** `X_FORBIDDEN` is policy's,
|
|
246
|
+
`X_UNAUTHENTICATED` is auth's; both sit in `HTTP_BORROWED_ERROR_CODES`, which carries codes
|
|
247
|
+
only. `HTTP_ERROR_TITLES` holds owned codes, and `registerErrorCodes` takes it whole and
|
|
248
|
+
unguarded — declaring a borrowed one throws `X_ERROR_CODE_DUPLICATE` at import, which is the
|
|
249
|
+
point. `factsOf` therefore reads a borrowed code's title off the error itself, never the map.
|
|
250
|
+
- Tests must not touch the network — the preload seals `fetch`. Socket tests live in
|
|
251
|
+
`e2e/` and run with `bun test packages/http/e2e`, sealed: `start()` calls core's
|
|
252
|
+
`markListening()`, so the seal treats our own port as self, not egress. Never unseal.
|
|
253
|
+
|
|
254
|
+
## Files
|
|
255
|
+
|
|
256
|
+
| File | Job |
|
|
257
|
+
|---|---|
|
|
258
|
+
| `pipeline.ts` | the ORDER the stages run in — the framework's guarantee — and the one loop that drives a request through them |
|
|
259
|
+
| `stages.ts` | what each stage DOES, one entry per `StageName`, plus the stage vocabulary the other two import |
|
|
260
|
+
| `finalize.ts` | the tail of that lifecycle, guarded: a throw after the handler degrades, never rejects |
|
|
261
|
+
| `router.ts` | trie matcher, precedence static > param > wildcard, `path-invalid` for a segment that will not decode |
|
|
262
|
+
| `error-map.ts` | code → status table + `factsOf()` |
|
|
263
|
+
| `hooks.ts` | the seams: `authenticate`, `authorize`, `devNotices` + the app's `configureAuthenticator()` |
|
|
264
|
+
| `type-pins.ts` | compile-time claims about `AuthzDecision`'s shape — source, because `tsc` never reads a `.test.ts` |
|
|
265
|
+
| `overlay.ts` | the dev error page: the same code/cause/fix as the terminal, plus any notices |
|
|
266
|
+
| `overlay-style.ts` | the overlay's one stylesheet, split out so `security-headers.ts` hashes it |
|
|
267
|
+
| `context.ts` | `RequestContext` + the single `Ctx` adapter (`asCtx`) + the inbound-header readers |
|
|
268
|
+
| `redirect.ts` | the intent slot a handler that cannot return a `Response` fills |
|
|
269
|
+
| `auth-redirect.ts` | where an unauthenticated browser goes, and where it comes back to |
|
|
270
|
+
| `cache-policy.ts` | the default `CacheHint` for a route that declared none — route AND actor |
|
|
271
|
+
| `rate-limit.ts` | the token-bucket maths, the store interface, the memory driver and `toBucket` |
|
|
272
|
+
| `correlation.ts` | the inbound request id and trace, read before the context and the span exist |
|
|
273
|
+
| `forwarded.ts` | one hop-indexed reader for every header a trusted proxy writes |
|
|
274
|
+
| `peer-identity.ts` | Envoy XFCC -> `ctx.peer`, on that same trust rule |
|
|
275
|
+
| `deadline.ts` | the per-request `AbortController`, the timer and `X_TIMEOUT` |
|
|
276
|
+
| `csrf.ts` | the origin proof an unsafe method from a credentialed browser must carry |
|
|
277
|
+
| `locale.ts` | WHERE the request's locale and zone are read from — header and cookie NAMES only, plus `readCookie`. It negotiates nothing |
|
|
278
|
+
| `rate-limit-buckets.ts` | the one point routes and config meet: a route's own bucket, registered or refused |
|
|
279
|
+
|
|
280
|
+
## Commands
|
|
281
|
+
|
|
282
|
+
```
|
|
283
|
+
bun test packages/http
|
|
284
|
+
bun run --filter @ultimat3/http typecheck
|
|
285
|
+
```
|
package/README.md
CHANGED
|
@@ -14,14 +14,18 @@ to skip.
|
|
|
14
14
|
| typed request (params, query, body) | `request.ts` |
|
|
15
15
|
| response constructors + `problem()` | `response.ts` |
|
|
16
16
|
| code → status, `factsOf()` | `error-map.ts` |
|
|
17
|
-
| token-bucket limiting | `rate-limit.ts` |
|
|
17
|
+
| token-bucket limiting, `toBucket` | `rate-limit.ts` |
|
|
18
18
|
| CORS, CSP/HSTS | `cors.ts`, `security-headers.ts` |
|
|
19
|
+
| CSRF (origin proof for a credentialed write) | `csrf.ts` |
|
|
20
|
+
| the request deadline and `ctx.signal` | `deadline.ts` |
|
|
21
|
+
| the caller's real address behind a proxy | `forwarded.ts` |
|
|
22
|
+
| the inbound request id and trace, read before the span | `correlation.ts` |
|
|
19
23
|
| dev error overlay | `overlay.ts` |
|
|
20
24
|
|
|
21
25
|
## The pipeline is the guarantee
|
|
22
26
|
|
|
23
27
|
```
|
|
24
|
-
request-id → trace → context → locale → auth → rate-limit → body → authz
|
|
28
|
+
request-id → admit → trace → context → locale → auth → rate-limit → csrf → body → authz
|
|
25
29
|
→ handler → cache-headers → (error-map) → response
|
|
26
30
|
```
|
|
27
31
|
|
|
@@ -30,11 +34,103 @@ asserts the order; `/_x` renders it. Ordering rules worth restating:
|
|
|
30
34
|
|
|
31
35
|
| Rule | Reason |
|
|
32
36
|
|---|---|
|
|
37
|
+
| admit second | a draining or saturated process refuses before any work — no route match, no auth, no body |
|
|
33
38
|
| auth before rate-limit | limiter keys per actor/tenant, not per NAT address |
|
|
39
|
+
| csrf after auth | only a caller holding an AMBIENT credential can be forged into; bearer and anonymous are exempt |
|
|
40
|
+
| csrf before body | a forged write never makes the server allocate its payload |
|
|
34
41
|
| rate-limit before body | a limited request never allocates its payload |
|
|
35
42
|
| body before authz | policies take parsed input as their subject |
|
|
36
43
|
| cache-headers before response | a directive can never drop a security header |
|
|
37
44
|
|
|
45
|
+
What the lifecycle refuses on the caller's behalf, `As of 2026-08`:
|
|
46
|
+
|
|
47
|
+
| Guard | Answer |
|
|
48
|
+
|---|---|
|
|
49
|
+
| a body past `bodyLimitBytes` | read through the stream and abandoned the instant the running total crosses the limit — `content-length` or not, multipart included — as `X_BODY_INVALID` |
|
|
50
|
+
| a request carrying an identity on an `auth: 'public'` route | `cache-control: private`, never `s-maxage`; an anonymous one is shared-cacheable and keyed `vary: accept-language, cookie` |
|
|
51
|
+
| a cross-origin request from an origin the allow-list refuses | no `access-control-allow-origin`, but always `vary: origin`, so a shared cache never answers an allowed origin out of the refusal's slot |
|
|
52
|
+
| `cors.origins: ['*']` with `credentials: true` | `X_CORS_CONFIG_INVALID` at `defineHttpConfig`, because a browser accepts that pair from nobody |
|
|
53
|
+
| `?next=` carrying anything but a same-origin path | the fallback — including a value whose TAB/CR/LF a browser strips back into `//evil.test` |
|
|
54
|
+
| HSTS | emitted only when the connection is affirmatively https (`ctx.https`); never by the zero-argument default |
|
|
55
|
+
| `rateLimit.scope: 'shared'` on a per-process store | `X_RATE_LIMIT_NOT_SHARED` at `createServer`, because N replicas each holding their own counters enforce N × every configured number |
|
|
56
|
+
| a route's own bucket and a configured bucket of that name disagreeing | `X_RATE_LIMIT_BUCKET_CONFLICT` at `createServer`, because the loser would be a number someone read and nothing applied |
|
|
57
|
+
| an injected limiter that does not hold a bucket a route declares | `X_RATE_LIMIT_BUCKET_UNBOUND` at `createPipeline`, because the name would fall through to `default` — measured at 120 burst for a route declaring 5 |
|
|
58
|
+
| a config that never declared `rateLimit.scope` | `X_RATE_LIMIT_SCOPE_UNSET` at `defineHttpConfig`. **Breaking, `As of 2026-08`**: `'process'` used to be the default, so "nobody asked" and "the app said one replica" were the same value while the chart runs three |
|
|
59
|
+
| `trustProxy: true` with no `trustedProxyHops` | `X_TRUST_PROXY_UNSET` at `defineHttpConfig`. **Breaking, `As of 2026-08`**: `trustProxy` now defaults to `false`, and `x-forwarded-for` is read at `entries.length - hops` — never at `[0]`, which is whatever the client typed |
|
|
60
|
+
| a credentialed unsafe method that cannot be shown to be same-origin | `X_CSRF_BLOCKED` (403). `sec-fetch-site: same-origin`, `Origin` equal to this app, or an `Origin` in `cors.origins` — anything else is refused before the body is read |
|
|
61
|
+
| a request past `requestTimeoutMs` (30s) | `ctx.signal` aborts and the socket is answered `X_TIMEOUT` (504); a caller may shorten the deadline with `x-request-timeout-ms`, never lengthen it |
|
|
62
|
+
| a request while the process is draining | `X_DRAINING` (503) + `retry-after`, which is what `isDraining()` was always documented to do here and had no reader for |
|
|
63
|
+
| a request past `maxInflight` (1000) | `X_OVERLOADED` (503) + `retry-after`, shed in the `admit` stage before any work |
|
|
64
|
+
|
|
65
|
+
`handle()` resolves to a Response, always — a stage that throws after the handler, or while
|
|
66
|
+
rendering another stage's throw, degrades to `X_PIPELINE_FINALIZE_FAILED` (500, the stage named in
|
|
67
|
+
`cause`) and the chain finishes that document instead. `finalize.ts` owns that promise.
|
|
68
|
+
|
|
69
|
+
## Rate limiting holds where the app says it holds
|
|
70
|
+
|
|
71
|
+
The counters live in a `RateLimitStore`. `memoryRateLimitStore()` is the default and is **one
|
|
72
|
+
process' worth of state**, so a deployment at `replicas: 3` enforces every bucket three times over.
|
|
73
|
+
The app declares which it needs and passes the store that provides it — the store is the only
|
|
74
|
+
thing that knows where its counters live, and a framework that inferred the answer from the
|
|
75
|
+
environment would get it wrong on the first deployment that scaled differently.
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
createServer({
|
|
79
|
+
routes,
|
|
80
|
+
config: defineHttpConfig({ rateLimit: { scope: 'shared' } }), // this limit is the fleet's
|
|
81
|
+
rateLimitStore: myStore, // whose own scope is 'shared'
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
| Declared | Store | Result |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| nothing | any | `X_RATE_LIMIT_SCOPE_UNSET` at `defineHttpConfig` — **breaking, `As of 2026-08`** |
|
|
88
|
+
| `'process'` | any | boots; the limit is per replica, which is what was asked for |
|
|
89
|
+
| `'shared'` | `scope: 'shared'` | boots; one bucket for the fleet |
|
|
90
|
+
| `'shared'` | `scope: 'process'`, or `enabled: false` | `X_RATE_LIMIT_NOT_SHARED` at boot |
|
|
91
|
+
|
|
92
|
+
There is no default. `'process'` used to be one, which made "the app never said" and "the app
|
|
93
|
+
said one replica" the same value while `docker/helm/values.yaml` runs three — and
|
|
94
|
+
`X_RATE_LIMIT_NOT_SHARED` only fires on a `'shared'` declaration, so the silent case was exactly
|
|
95
|
+
the one nobody declared. A limiter with `enabled: false` owes no declaration: nothing is enforced,
|
|
96
|
+
so nothing can be wrong.
|
|
97
|
+
|
|
98
|
+
`rateLimitStore` feeds the `PipelineDeps.limiter` seam rather than sitting beside it: the bucket
|
|
99
|
+
maths stays in `createRateLimiter`, so every driver agrees on the numbers. **No shared store ships
|
|
100
|
+
yet, `As of 2026-08`** — `memoryRateLimitStore()` is the only implementation in the framework.
|
|
101
|
+
|
|
102
|
+
### A route may bring its own bucket
|
|
103
|
+
|
|
104
|
+
`meta.rateLimit` names a bucket; `meta.rateLimitBucket` is the numbers that bucket must hold.
|
|
105
|
+
`withRouteBuckets` registers them at construction — `createServer` and `createPipeline` both apply
|
|
106
|
+
it, idempotently — because `defineHttpConfig` runs before any route exists and cannot have them.
|
|
107
|
+
Without that half, a name nothing defined fell through `bucketFor` to `default`: an action
|
|
108
|
+
declaring `limit: 5` ran on 120 burst, and the number reached the OpenAPI document all the same.
|
|
109
|
+
|
|
110
|
+
| Declared | Configured under the same name | Result |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| nothing | — | `default`, unchanged — most routes |
|
|
113
|
+
| numbers | nothing | the route's numbers, registered |
|
|
114
|
+
| numbers | the same numbers | boots; a restatement is not a disagreement |
|
|
115
|
+
| numbers | different numbers | `X_RATE_LIMIT_BUCKET_CONFLICT` at boot |
|
|
116
|
+
|
|
117
|
+
Neither source wins a disagreement, because whichever lost would stay a number an author read and
|
|
118
|
+
nothing enforced. `toBucket` — in **this** package, beside `Bucket` and the maths it validates, because `action` and
|
|
119
|
+
`query` are the same tier and can never import each other — is the one conversion from a
|
|
120
|
+
declaration's
|
|
121
|
+
`{ limit, windowMs }` to a bucket's `{ capacity, refillPerSecond }`, and it refuses a pair the
|
|
122
|
+
limiter could not run on — including one whose two halves look fine and whose **division** does
|
|
123
|
+
not, like `{ limit: Number.MAX_VALUE, windowMs: 1 }` computing to an infinite refill.
|
|
124
|
+
|
|
125
|
+
Registering into the config is only half of it, because the limiter resolves names against the
|
|
126
|
+
table **it** closed over. `RateLimiter.buckets` publishes that table — declared, never inferred,
|
|
127
|
+
the same rule as `RateLimitStore.scope` — and `assertRouteBuckets` compares it against the routes
|
|
128
|
+
at construction. A limiter passed to `PipelineDeps.limiter` that cannot enforce a declared bucket
|
|
129
|
+
is refused rather than rebound: a `RateLimiter` is opaque, so rebinding would mean discarding the
|
|
130
|
+
store it carries, and a caller who built their own limiter may have meant their own numbers. Pass
|
|
131
|
+
the **store** — `createServer({ routes, rateLimitStore })` — and the pipeline builds the limiter
|
|
132
|
+
from the merged table for you.
|
|
133
|
+
|
|
38
134
|
## Routing
|
|
39
135
|
|
|
40
136
|
Precedence is structural, not declaration-ordered: **static > param > wildcard**,
|
|
@@ -58,6 +154,7 @@ through to `fetch`. Method resolution stays ours so a 405 still carries problem+
|
|
|
58
154
|
|
|
59
155
|
`X_ROUTE_NOT_FOUND` · `X_METHOD_NOT_ALLOWED` · `X_BODY_INVALID` · `X_UNAUTHENTICATED`
|
|
60
156
|
· `X_FORBIDDEN` · `X_RATE_LIMITED` · `X_BUILD_SKEW` · `X_ROUTE_CONFLICT`
|
|
157
|
+
· `X_CORS_CONFIG_INVALID` · `X_RATE_LIMIT_NOT_SHARED`
|
|
61
158
|
|
|
62
159
|
One `factsOf()` feeds three renderings — terminal, `application/problem+json`, dev
|
|
63
160
|
overlay — so the `code`/`cause`/`fix` strings can never diverge.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/http",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
21
|
"!src/**/*.test.ts",
|
|
22
|
+
"CLAUDE.md",
|
|
22
23
|
"README.md",
|
|
23
24
|
"LICENSE"
|
|
24
25
|
],
|
|
@@ -30,7 +31,9 @@
|
|
|
30
31
|
"test": "bun test"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
|
-
"@ultimat3/core": "
|
|
34
|
-
"@ultimat3/
|
|
34
|
+
"@ultimat3/core": "2.0.0",
|
|
35
|
+
"@ultimat3/i18n": "2.0.0",
|
|
36
|
+
"@ultimat3/schema": "2.0.0",
|
|
37
|
+
"@ultimat3/time": "2.0.0"
|
|
35
38
|
}
|
|
36
39
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// What an unauthenticated *browser* gets instead of a problem document. An agent and an RPC
|
|
2
|
+
// client want `X_UNAUTHENTICATED` as JSON with a fix line; a person following a link wants the
|
|
3
|
+
// sign-in page. One condition, two audiences, decided here so the error stage stays one branch.
|
|
4
|
+
|
|
5
|
+
import type { RequestContext } from './context';
|
|
6
|
+
import { wantsOverlay } from './overlay';
|
|
7
|
+
import type { RedirectIntent } from './response';
|
|
8
|
+
|
|
9
|
+
/** The query parameter carrying where the visitor was going. One spelling, both halves. */
|
|
10
|
+
export const NEXT_PARAM = 'next';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Where to send a browser that hit an `auth: 'required'` route with no session, or `undefined`
|
|
14
|
+
* when the problem document is still the right answer.
|
|
15
|
+
*
|
|
16
|
+
* `signInPath` is `null` by default and the redirect is off until an app sets it: a framework
|
|
17
|
+
* that guessed `/signin` would send every unauthenticated visitor of an app that spells it
|
|
18
|
+
* `/login` to a 404, which is strictly worse than the JSON it replaced.
|
|
19
|
+
*
|
|
20
|
+
* 303, not 302: the request that failed authz may have been a form POST, and 303 turns the
|
|
21
|
+
* follow-up into the GET the sign-in page actually is. Same reasoning as `setRedirect`.
|
|
22
|
+
*/
|
|
23
|
+
export function signInRedirect(input: {
|
|
24
|
+
readonly code: string;
|
|
25
|
+
readonly signInPath: string | null;
|
|
26
|
+
readonly request: Request;
|
|
27
|
+
readonly ctx: Pick<RequestContext, 'url' | 'method'>;
|
|
28
|
+
}): RedirectIntent | undefined {
|
|
29
|
+
const { code, signInPath, request, ctx } = input;
|
|
30
|
+
if (code !== 'X_UNAUTHENTICATED' || signInPath === null) return undefined;
|
|
31
|
+
// The same question `wantsOverlay` asks — "does this client render HTML?" — and deliberately
|
|
32
|
+
// the same answer, so a client cannot get the overlay in dev and JSON in production.
|
|
33
|
+
if (!wantsOverlay(request)) return undefined;
|
|
34
|
+
// A sign-in page that declares `auth: 'required'` by mistake would otherwise redirect to
|
|
35
|
+
// itself forever, and a browser reports that as a bare "too many redirects" with no code.
|
|
36
|
+
if (ctx.url.pathname === signInPath) return undefined;
|
|
37
|
+
const next = `${ctx.url.pathname}${ctx.url.search}`;
|
|
38
|
+
return { location: `${signInPath}?${NEXT_PARAM}=${encodeURIComponent(next)}`, status: 303 };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The other half of the round trip: where to send someone once they HAVE signed in.
|
|
43
|
+
*
|
|
44
|
+
* Everything except a same-origin path is refused and `fallback` is used instead. `?next=`
|
|
45
|
+
* arrives from the URL bar, so it is attacker-controlled by definition — an unchecked value here
|
|
46
|
+
* is an open redirect on a page whose entire job is to hold a session, which is the exact shape
|
|
47
|
+
* phishing wants: a real domain, a real login, a hop to somewhere else.
|
|
48
|
+
*
|
|
49
|
+
* Refused: an absolute URL (`https://evil.test/x`), a scheme-relative one (`//evil.test`), a
|
|
50
|
+
* backslash the browser normalises to a slash (`/\evil.test`), a value carrying a TAB, CR or LF,
|
|
51
|
+
* and anything not starting `/`.
|
|
52
|
+
*
|
|
53
|
+
* The control characters are not cosmetic. A browser DELETES tab, CR and LF from a `Location`
|
|
54
|
+
* before it parses one, so `/%09/evil.test` decodes to `/\t/evil.test` — which starts with a
|
|
55
|
+
* single slash, passes a prefix check, and is then parsed as `//evil.test`. The URL parser
|
|
56
|
+
* strips them the same way, which is why the last word here is the parse: whatever a client
|
|
57
|
+
* would actually resolve has to still be a path on this origin.
|
|
58
|
+
*/
|
|
59
|
+
export function nextAfterSignIn(raw: string | null | undefined, fallback: string): string {
|
|
60
|
+
if (raw === null || raw === undefined || raw === '') return fallback;
|
|
61
|
+
// `?next=%` is a bare `URIError`, and this runs while the pipeline is already rendering a 401.
|
|
62
|
+
let value: string;
|
|
63
|
+
try {
|
|
64
|
+
value = decodeURIComponent(raw);
|
|
65
|
+
} catch {
|
|
66
|
+
return fallback;
|
|
67
|
+
}
|
|
68
|
+
if (!value.startsWith('/')) return fallback;
|
|
69
|
+
if (value.startsWith('//') || value.startsWith('/\\')) return fallback;
|
|
70
|
+
if (/[\t\r\n]/.test(value)) return fallback;
|
|
71
|
+
// An origin no relative path could reach, so any value that resolves off it left this origin.
|
|
72
|
+
const base = 'http://x.invalid';
|
|
73
|
+
let resolved: URL;
|
|
74
|
+
try {
|
|
75
|
+
resolved = new URL(value, base);
|
|
76
|
+
} catch {
|
|
77
|
+
return fallback;
|
|
78
|
+
}
|
|
79
|
+
if (resolved.origin !== base) return fallback;
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Single responsibility: what a response may be cached as when neither the handler nor the route
|
|
2
|
+
// declared a hint. Split out of `pipeline.ts` because the answer is not the route's alone — the
|
|
3
|
+
// actor is half of it, and that is the half a cache bug in this framework would come from.
|
|
4
|
+
|
|
5
|
+
import type { Actor } from '@ultimat3/core';
|
|
6
|
+
import { isAnonymous } from '@ultimat3/core';
|
|
7
|
+
import type { CacheHint } from './response';
|
|
8
|
+
import type { Route } from './router';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Authenticated responses are never shared-cacheable; that default is not overridable.
|
|
12
|
+
*
|
|
13
|
+
* The ACTOR decides it, not `meta.auth` alone. `RouteMeta.auth` is `'public' | 'required'`, and the
|
|
14
|
+
* commonest page in any app — public, but greeting you by name when you are signed in — is
|
|
15
|
+
* `'public'`: keying only off the route handed that signed-in user's personalised HTML to a CDN for
|
|
16
|
+
* 60 seconds, which then served it to everyone else. A request carrying an identity is `private`
|
|
17
|
+
* whatever the route says. The other half is `vary: cookie` on the shared path (`response.ts`);
|
|
18
|
+
* either alone leaves the hole open.
|
|
19
|
+
*/
|
|
20
|
+
export const defaultCache = (route: Route | undefined, actor: Actor): CacheHint => {
|
|
21
|
+
if (route === undefined || route.meta.auth === 'required') return { mode: 'no-store' };
|
|
22
|
+
if (!isAnonymous(actor)) return { mode: 'private', maxAgeSeconds: 0 };
|
|
23
|
+
return { mode: 'public', maxAgeSeconds: 0, sMaxAgeSeconds: 60, staleWhileRevalidateSeconds: 600 };
|
|
24
|
+
};
|