@ultimat3/http
Advanced tools
| // The page a BROWSER gets when a request fails and this process is not in dev: Rails' `404.html`, | ||
| // with the framework's own words. The dev overlay stays the dev answer — it prints the cause, the | ||
| // fix and the stack, which is exactly what a visitor may never see — so this renderer shows the | ||
| // status, the code and the request id and nothing else off the throwable. | ||
| // | ||
| // An app overrides it with a file per status; `ServerHooks.errorPage` is the seam that reads one, | ||
| // because this package cannot see a disk. | ||
| import { singleLine } from '@ultimat3/core'; | ||
| import type { InterpolationVars } from '@ultimat3/i18n'; | ||
| import { t } from '@ultimat3/i18n'; | ||
| import { escapeHtml } from './html-render'; | ||
| import { OVERLAY_STYLE } from './overlay-style'; | ||
| import { html } from './response'; | ||
| /** | ||
| * Where "Built with Ultimate" goes. Two literals and not a derivation off `ERROR_DOCS_URL`: the | ||
| * wiki page it points at is a different destination that merely shares a prefix today. They live | ||
| * here, next to their one renderer — a second surface that wants them (a CLI banner, a scaffolded | ||
| * footer) is what would move them to `@ultimat3/core`. | ||
| */ | ||
| export const ERROR_PAGE_LINKS = Object.freeze<Record<'repository' | 'homepage', string>>({ | ||
| repository: 'https://github.com/developerz-ai/ultimate', | ||
| homepage: 'https://www.developerz.ai', | ||
| }); | ||
| /** One key group in the framework catalog's `errors.*` namespace, which is what this page renders. */ | ||
| export type ErrorPageGroup = | ||
| | 'notFound' | ||
| | 'forbidden' | ||
| | 'unauthorized' | ||
| | 'rateLimited' | ||
| | 'serverError' | ||
| | 'unavailable' | ||
| | 'badRequest'; | ||
| /** Where the page's one link goes. A closed set, because a fourth destination is a design change. */ | ||
| export type ErrorPageAction = 'home' | 'retry' | 'signIn'; | ||
| export interface ErrorPageInput { | ||
| readonly status: number; | ||
| readonly code: string; | ||
| /** | ||
| * The pathname that failed, when there IS one. Supplied to the translator so an app that | ||
| * overrides `errors.notFound.body` can name it; the framework's own sentence deliberately does | ||
| * not, because the same renderer writes `404.html` into a static export, where no request | ||
| * exists and a reflected path would be a sentence with a hole in it. | ||
| */ | ||
| readonly path?: string | undefined; | ||
| /** `x-request-id` for this request. Absent for a page built with no request behind it. */ | ||
| readonly requestId?: string | undefined; | ||
| /** BCP-47 tag for `<html lang>`; the copy itself comes from the ambient translator. */ | ||
| readonly locale: string; | ||
| /** `route.meta.policy` — the only thing a 403 page can name, and it names a rule, never a row. */ | ||
| readonly permission?: string | undefined; | ||
| readonly retryAfterSeconds?: number | undefined; | ||
| /** `config.signInPath`, or null when the app has not declared one. */ | ||
| readonly signInPath?: string | null; | ||
| } | ||
| export interface ErrorPageCopy { | ||
| readonly group: ErrorPageGroup; | ||
| readonly action: ErrorPageAction; | ||
| readonly vars: InterpolationVars; | ||
| } | ||
| /** | ||
| * The statuses with words of their own. A `Map` and not an object literal for `statusFor`'s | ||
| * reason one file over: the key is a number this package computed, but a table read by a value is | ||
| * a prototype member away from answering a function. | ||
| */ | ||
| const BY_STATUS = new Map<number, { group: ErrorPageGroup; action: ErrorPageAction }>([ | ||
| [401, { group: 'unauthorized', action: 'signIn' }], | ||
| [403, { group: 'forbidden', action: 'home' }], | ||
| [404, { group: 'notFound', action: 'home' }], | ||
| [429, { group: 'rateLimited', action: 'retry' }], | ||
| [503, { group: 'unavailable', action: 'retry' }], | ||
| ]); | ||
| /** | ||
| * The variables that group's sentence needs, or `undefined` when this request cannot supply one. | ||
| * `interpolate` renders a missing variable as `⟦permission⟧` — loud, and correct for an author, | ||
| * but it is a bracketed token in front of a visitor — so a group that cannot be filled degrades to | ||
| * the class below instead of shipping the hole. | ||
| */ | ||
| function varsFor(group: ErrorPageGroup, input: ErrorPageInput): InterpolationVars | undefined { | ||
| if (group === 'notFound') return input.path === undefined ? {} : { path: singleLine(input.path) }; | ||
| if (group === 'serverError') | ||
| return input.requestId === undefined ? undefined : { traceId: singleLine(input.requestId) }; | ||
| if (group === 'forbidden') | ||
| return input.permission === undefined | ||
| ? undefined | ||
| : { permission: singleLine(input.permission) }; | ||
| if (group === 'rateLimited') | ||
| return input.retryAfterSeconds === undefined ? undefined : { seconds: input.retryAfterSeconds }; | ||
| return {}; | ||
| } | ||
| /** 4xx and 5xx each have one sentence that needs nothing, which is what a degrade falls to. */ | ||
| const classPlan = (status: number): { group: ErrorPageGroup; action: ErrorPageAction } => | ||
| status < 500 | ||
| ? { group: 'badRequest', action: 'home' } | ||
| : { group: 'serverError', action: 'retry' }; | ||
| /** | ||
| * Which words this status gets. Derived from the status alone — never from the code — because | ||
| * `ERROR_STATUS` already decided what a code MEANS to a client, and a second table keyed by code | ||
| * would be a second answer to that question. | ||
| */ | ||
| export function resolveErrorPageCopy(input: ErrorPageInput): ErrorPageCopy { | ||
| const plan = BY_STATUS.get(input.status) ?? classPlan(input.status); | ||
| const vars = varsFor(plan.group, input); | ||
| if (vars !== undefined) return { ...plan, vars }; | ||
| const fallback = classPlan(input.status); | ||
| const fallbackVars = varsFor(fallback.group, input); | ||
| if (fallbackVars !== undefined) return { ...fallback, vars: fallbackVars }; | ||
| // The last rung, and it is a rung rather than a `?? {}` because the ladder has to END on copy | ||
| // that needs nothing: one group in this table asks for no variable, and landing anywhere else | ||
| // ships `⟦traceId⟧` to a visitor. | ||
| return { group: 'badRequest', action: 'home', vars: {} }; | ||
| } | ||
| const hrefFor = (copy: ErrorPageCopy, input: ErrorPageInput): string => { | ||
| if (copy.action === 'signIn' && input.signInPath !== null && input.signInPath !== undefined) | ||
| return input.signInPath; | ||
| // A retry has to be the page the visitor was on; with no request behind the page there is no | ||
| // such address, so it degrades to the one link that is always right. | ||
| return (copy.action === 'retry' ? input.path : undefined) ?? '/'; | ||
| }; | ||
| const link = (href: string, label: string): string => | ||
| `<a href="${escapeHtml(href)}">${escapeHtml(label)}</a>`; | ||
| /** | ||
| * The framework's page. One document, every status — the words are a catalog lookup and the shape | ||
| * never changes, so an app that wants a different shape ships its own file rather than configuring | ||
| * this one into something else. | ||
| * | ||
| * The key is built from the group, which is what makes `errors.*` one namespace with one reader | ||
| * instead of seven call sites that can each drift from the table above. | ||
| */ | ||
| export function renderErrorPage(input: ErrorPageInput): string { | ||
| const copy = resolveErrorPageCopy(input); | ||
| const title = t(`errors.${copy.group}.title`); | ||
| return `<!doctype html> | ||
| <html lang="${escapeHtml(input.locale)}"> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| <meta name="robots" content="noindex"> | ||
| <title>${escapeHtml(`${String(input.status)} ${title}`)}</title> | ||
| <style>${OVERLAY_STYLE}</style> | ||
| </head> | ||
| <body> | ||
| <main> | ||
| <section class="card"> | ||
| <p class="status">${escapeHtml(String(input.status))}</p> | ||
| <h1>${escapeHtml(title)}</h1> | ||
| <p class="lede">${escapeHtml(t(`errors.${copy.group}.body`, copy.vars))}</p> | ||
| <p>${link(hrefFor(copy, input), t(`errors.${copy.group}.action`))}</p> | ||
| <dl> | ||
| <dt>code</dt><dd>${escapeHtml(singleLine(input.code))}</dd>${ | ||
| input.requestId === undefined | ||
| ? '' | ||
| : ` | ||
| <dt>request</dt><dd>${escapeHtml(singleLine(input.requestId))}</dd>` | ||
| } | ||
| </dl> | ||
| </section> | ||
| <footer class="card"> | ||
| ${link(ERROR_PAGE_LINKS.repository, t('errors.page.builtWith'))} | ||
| ${link(ERROR_PAGE_LINKS.homepage, 'developerz.ai')} | ||
| </footer> | ||
| </main> | ||
| </body> | ||
| </html>`; | ||
| } | ||
| export interface ErrorPageOptions { | ||
| /** The app's own file for this status, if it has one. */ | ||
| readonly override?: string | undefined; | ||
| /** Headers the refusal computed — `retry-after` today, and nothing else so far. */ | ||
| readonly headers?: Readonly<Record<string, string>> | undefined; | ||
| } | ||
| /** | ||
| * The answer itself. `override` is the app's own file, served BYTE FOR BYTE — a framework that | ||
| * interpolated a `{{status}}` into it would be a second template language, and a page an app | ||
| * cannot predict is not an override. | ||
| * | ||
| * `no-store`, always: an error page filed by a shared cache under the URL that failed is served to | ||
| * the next visitor after the incident is over. `retry-after` rides along for the same reason it | ||
| * rides on the problem document — a 503 that does not say when to come back comes back at once. | ||
| */ | ||
| export const errorPageResponse = ( | ||
| input: ErrorPageInput, | ||
| options: ErrorPageOptions = {}, | ||
| ): Response => | ||
| html(options.override ?? renderErrorPage(input), { | ||
| status: input.status, | ||
| headers: { 'cache-control': 'no-store', ...options.headers }, | ||
| }); |
| // The two questions every HTML answer this package renders has to ask, in one place: is the caller | ||
| // a browser, and how does a value become markup. Both were the dev overlay's private helpers while | ||
| // the production error page needs the identical answers — a second accept sniff would let a client | ||
| // get the overlay in dev and JSON in production, and a second escape set is one hole away. | ||
| /** | ||
| * `'` is escaped even though every attribute the renderers write is double-quoted: the escape set | ||
| * is what the next author reads as the guarantee, and a single-quoted attribute written later | ||
| * would inherit a hole nothing here would have flagged. | ||
| */ | ||
| export const escapeHtml = (value: string): string => | ||
| value | ||
| .replaceAll('&', '&') | ||
| .replaceAll('<', '<') | ||
| .replaceAll('>', '>') | ||
| .replaceAll('"', '"') | ||
| .replaceAll("'", '''); | ||
| /** | ||
| * Does this caller render HTML? One sniff, three readers — the dev overlay, the production error | ||
| * page and the sign-in redirect — so a browser cannot be handed a page by one of them and a | ||
| * problem document by the next for the same request. | ||
| */ | ||
| export const acceptsHtml = (request: Request): boolean => | ||
| (request.headers.get('accept') ?? '').includes('text/html'); |
+44
-0
@@ -87,2 +87,13 @@ # @ultimat3/http | ||
| token mode is worse than an honest `'origin' | 'off'`. | ||
| - **An unclassified 5xx tells the CALLER nothing off the throwable** (`As of 2026-08-23`). | ||
| `error-page.ts` has always shown a browser the status, the code and the request id and said so in | ||
| its header; `toProblem` rendered `facts.cause`, which for a 500 nobody classified falls through to | ||
| the exception's own `message` — a driver's DSN, the row Postgres rejected, an absolute path. One | ||
| condition, two audiences, and they disagreed. The discriminator is a code nobody declared a status | ||
| for, plus `X_INTERNAL` itself: core's `toError()` wraps a caught value into an `InternalError` | ||
| whose cause is `renderCauseValue(value)`, so the framework's own word for "unclassified" is where | ||
| the leak arrives. `toProblem(error, { dev })` is the seam and `dev` DEFAULTS TO FALSE: the | ||
| `error-map` stage is the one call site that can see the config, and every degraded `problem()` in | ||
| the tail must stay opaque. The real text is not lost — it is the log field and the error report, | ||
| both keyed by the request id the caller was given. | ||
| - **A rejected value is a log FIELD, never part of the message.** `logger.emit()` redacts `bound`, | ||
@@ -94,2 +105,7 @@ `contextFields` and `fields` — and never `msg` — so `logger.error(\`${code}: ${cause}\`)` in the | ||
| load-bearing one; this half is what makes the value redactable at all. | ||
| - **A repeated field is a LIST, in all three parsers.** `collectFields` in `request.ts` is the one | ||
| collector for the query, `application/x-www-form-urlencoded` and `multipart/form-data`. The last | ||
| two were `Object.fromEntries`, which keeps the LAST value: a checkbox group posting `tags` three | ||
| times reached the body schema as one string, while the query parser three functions up had built | ||
| an array for the same shape since it shipped. | ||
| - **A rejected BODY is not a log field either — `bodyInvalid`'s `issues` may name only what the | ||
@@ -125,2 +141,13 @@ framework chose** (`As of 2026-08-19`). `request.ts` built `could not parse ${type}: ${String(error)}`, | ||
| with no byte guard at all when the length was undeclared. | ||
| - **The `cache-headers` stage is the ONE owner of the final cache answer, `As of 2026-08-23`.** It | ||
| used to apply the actor-aware default only when nothing had set a header — and every page route | ||
| in every app sets one, because `@ultimat3/render`'s `ssrHeaders` writes | ||
| `public, max-age=0, s-maxage=30, stale-while-revalidate=300` for any route that declares no | ||
| `policy`, which is exactly what `x g route --surface app` scaffolds. So the rule below was | ||
| unreachable for the surface it was written for. A render mode states the MODE's intent; this | ||
| stage decides, and `offersSharedCache` is the discriminator: a shared answer for an identified | ||
| request becomes `PRIVATE_CACHE`, an anonymous one gains `SHARED_CACHE_VARY`. `immutable` is left | ||
| alone in both directions — it asserts the body is a function of the URL, which is what a | ||
| content-addressed island chunk is, and demoting those would re-download every chunk on every | ||
| navigation for every signed-in user. | ||
| - **The cache default reads the ACTOR, not just the route, and `vary` is added and never set.** | ||
@@ -133,2 +160,19 @@ `meta.auth` is only `'public' | 'required'`, so the page that greets a signed-in visitor by name | ||
| stage merges CORS's `vary: origin` into the cache stage's key instead of replacing it. | ||
| - **A `security.csp.extend` entry is refused at `defineHttpConfig` unless it can only emit the | ||
| directive it names.** A directive name and a source both go into the header VERBATIM and the | ||
| header's own separators are `;` and ` `, so `extend: { 'x; script-src *': [] }` was not one badly | ||
| named directive — it was a second directive nobody declared, widening the one this package locks | ||
| down hardest. `X_CSP_DIRECTIVE_INVALID`, at boot, because there is no encoding for a CSP source | ||
| and escaping one at emission time is not a repair. `buildCsp` builds through a **`Map`** for the | ||
| other half of the same class: `directives[name]` was a computed read of an object literal keyed by | ||
| a caller-chosen name, so `extend: { toString: [...] }` spread a function off `Object.prototype` | ||
| and threw a bare `TypeError` at boot. `proto-index` cannot see it — `baseline()` is what produces | ||
| the object. | ||
| - **`config.drainTimeoutMs` is `number | null`, and `null` is the default** (`As of 2026-08-23`). | ||
| `createServer` calls `configureLifecycle({ deadlineMs })` only when an app DECLARED one. It used | ||
| to call it unconditionally with a value `defineHttpConfig` had defaulted to 15s, so an app that | ||
| wrote `configureLifecycle({ deadlineMs: 600_000 })` — the edit `X_SHUTDOWN_TIMEOUT`'s own `fix:` | ||
| prints — had it silently reverted by the next line of boot, in every process that serves web. | ||
| "Nobody said" and "the app said 15 seconds" are different claims and only one of them may move a | ||
| process-global deadline. | ||
| - **`cors.origins: ['*']` with `credentials: true` is refused at `defineHttpConfig`.** No browser | ||
@@ -135,0 +179,0 @@ accepts that pair, and `allowedOrigin` answering `null` for it meant the natural "open it up" |
+5
-5
| { | ||
| "name": "@ultimat3/http", | ||
| "version": "10.0.0", | ||
| "version": "11.0.0", | ||
| "description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors", | ||
@@ -34,7 +34,7 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@ultimat3/core": "10.0.0", | ||
| "@ultimat3/i18n": "10.0.0", | ||
| "@ultimat3/schema": "10.0.0", | ||
| "@ultimat3/time": "10.0.0" | ||
| "@ultimat3/core": "11.0.0", | ||
| "@ultimat3/i18n": "11.0.0", | ||
| "@ultimat3/schema": "11.0.0", | ||
| "@ultimat3/time": "11.0.0" | ||
| } | ||
| } |
+4
-1
@@ -50,3 +50,6 @@ # @ultimat3/http 🌐 | ||
| | 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` | | ||
| | 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` | | ||
| | 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, x-timezone`. **Whatever the handler wrote**, `As of 2026-08-23`: the `cache-headers` stage REVIEWS a declared `cache-control` instead of standing down, because `@ultimat3/render`'s `ssrHeaders` offers every page without a `policy` to a CDN for 30s. An `immutable` answer is left alone — a content-addressed body is a function of its URL | | ||
| | a 5xx nobody declared a status for | the code, the request id and a `fix:`; never the exception's own text. `error-page.ts` locked the browser out of it, and the problem document handed the same string to an agent — a driver's DSN, the row Postgres rejected. The real text goes to the log and the error report. `dev: true` renders it in full | | ||
| | a `security.csp.extend` key that is not a CSP token, or a source carrying `;`, `,` or a space | `X_CSP_DIRECTIVE_INVALID` at `defineHttpConfig` — `{ 'x; script-src *': [] }` is a second directive nobody declared | | ||
| | a repeated form field | a LIST, exactly as a repeated query parameter is. One collector for query, urlencoded and multipart; `Object.fromEntries` kept the last value, so a checkbox group reached the schema as one string | | ||
| | 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 | | ||
@@ -53,0 +56,0 @@ | `cors.origins: ['*']` with `credentials: true` | `X_CORS_CONFIG_INVALID` at `defineHttpConfig`, because a browser accepts that pair from nobody | |
@@ -6,3 +6,3 @@ // What an unauthenticated *browser* gets instead of a problem document. An agent and an RPC | ||
| import type { RequestContext } from './context'; | ||
| import { wantsOverlay } from './overlay'; | ||
| import { acceptsHtml } from './html-render'; | ||
| import type { RedirectIntent } from './response'; | ||
@@ -32,5 +32,5 @@ | ||
| if (code !== 'X_UNAUTHENTICATED' || signInPath === null) return undefined; | ||
| // The same question `wantsOverlay` asks — "does this client render HTML?" — and deliberately | ||
| // the same answer, so a client cannot get the overlay in dev and JSON in production. | ||
| if (!wantsOverlay(request)) return undefined; | ||
| // The same question the overlay and the error page ask — "does this client render HTML?" — and | ||
| // deliberately the same answer, so a client cannot get a page in dev and JSON in production. | ||
| if (!acceptsHtml(request)) return undefined; | ||
| // A sign-in page that declares `auth: 'required'` by mistake would otherwise redirect to | ||
@@ -37,0 +37,0 @@ // itself forever, and a browser reports that as a bare "too many redirects" with no code. |
+19
-1
@@ -10,3 +10,21 @@ // Single responsibility: what a response may be cached as when neither the handler nor the route | ||
| /** The one answer for an identified request, whoever asked. */ | ||
| export const PRIVATE_CACHE: CacheHint = { mode: 'private', maxAgeSeconds: 0 }; | ||
| // `public` or an `s-maxage` is an OFFER to a shared cache; `immutable` withdraws the question, | ||
| // because it asserts the body is a function of the URL alone — which is what a content-addressed | ||
| // island chunk or image is, and demoting those would re-download every chunk on every navigation | ||
| // for every signed-in user. | ||
| const OFFERS_SHARED = /(?:^|,)\s*(?:public\b|s-maxage=)/i; | ||
| const IMMUTABLE = /(?:^|,)\s*immutable\b/i; | ||
| /** | ||
| * Whether a `cache-control` a HANDLER wrote offers the response to a shared cache. A render mode | ||
| * states the MODE's intent — `ssr` offers an ungated page to a CDN for 30 seconds — and the actor | ||
| * is the half it cannot see, so the stage reviews the declaration rather than deferring to it. | ||
| */ | ||
| export const offersSharedCache = (declared: string): boolean => | ||
| OFFERS_SHARED.test(declared) && !IMMUTABLE.test(declared); | ||
| /** | ||
| * Authenticated responses are never shared-cacheable; that default is not overridable. | ||
@@ -23,4 +41,4 @@ * | ||
| if (route === undefined || route.meta.auth === 'required') return { mode: 'no-store' }; | ||
| if (!isAnonymous(actor)) return { mode: 'private', maxAgeSeconds: 0 }; | ||
| if (!isAnonymous(actor)) return PRIVATE_CACHE; | ||
| return { mode: 'public', maxAgeSeconds: 0, sMaxAgeSeconds: 60, staleWhileRevalidateSeconds: 600 }; | ||
| }; |
+20
-9
@@ -14,3 +14,3 @@ // The HTTP slice of `app.config.ts`. One resolver, so a value is either a locked | ||
| import { type RateLimitConfig, resolveRateLimitConfig } from './rate-limit'; | ||
| import { DEFAULT_SECURITY, type SecurityConfig } from './security-headers'; | ||
| import { assertCspExtend, DEFAULT_SECURITY, type SecurityConfig } from './security-headers'; | ||
@@ -60,4 +60,14 @@ export interface HttpConfig { | ||
| readonly maxInflight: number; | ||
| /** How long SIGTERM waits for in-flight requests before hard-stopping. */ | ||
| readonly drainTimeoutMs: number; | ||
| /** | ||
| * How long SIGTERM waits for in-flight requests before hard-stopping, or `null` when this app | ||
| * has not said and core's own deadline stands. | ||
| * | ||
| * `null` and not a 15s default, because the two are different claims and only one of them may | ||
| * reach `configureLifecycle`. `createServer` applied the resolved number unconditionally, so an | ||
| * app that had already written `configureLifecycle({ deadlineMs: 600_000 })` — the edit | ||
| * `X_SHUTDOWN_TIMEOUT`'s own `fix:` line prints — had it silently reverted by the next line of | ||
| * boot, in every process that serves web. Declaring this key IS declaring the drain budget for | ||
| * the whole process; leaving it out is declining to. | ||
| */ | ||
| readonly drainTimeoutMs: number | null; | ||
| readonly locale: LocaleConfig; | ||
@@ -128,2 +138,7 @@ readonly tz: TimeZoneConfig; | ||
| if (trustProxy && input.trustedProxyHops === undefined) throw trustProxyUnset(); | ||
| const csp = { ...DEFAULT_SECURITY.csp, reportOnly: dev, ...input.security?.csp }; | ||
| // Beside `assertCorsConfig`, and for its reason: a merged value is the only one that can be | ||
| // judged, and a directive name that is not a token would otherwise be a bare `TypeError` out of | ||
| // the first response's header build — or worse, a second directive nobody declared. | ||
| assertCspExtend(csp.extend); | ||
| return { | ||
@@ -144,3 +159,3 @@ port: input.port ?? Number.parseInt(env('PORT') ?? '3000', 10), | ||
| maxInflight: input.maxInflight ?? 1_000, | ||
| drainTimeoutMs: input.drainTimeoutMs ?? 15_000, | ||
| drainTimeoutMs: input.drainTimeoutMs ?? null, | ||
| locale: { ...DEFAULT_LOCALE_CONFIG, ...input.locale }, | ||
@@ -150,9 +165,5 @@ tz: { ...DEFAULT_TZ_CONFIG, ...input.tz }, | ||
| csrf: { ...DEFAULT_CSRF, ...input.csrf }, | ||
| security: { | ||
| ...DEFAULT_SECURITY, | ||
| ...input.security, | ||
| csp: { ...DEFAULT_SECURITY.csp, reportOnly: dev, ...input.security?.csp }, | ||
| }, | ||
| security: { ...DEFAULT_SECURITY, ...input.security, csp }, | ||
| rateLimit: resolveRateLimitConfig(input.rateLimit), | ||
| }; | ||
| }; |
+37
-4
@@ -41,2 +41,3 @@ // The one place a framework error code becomes an HTTP status. A table, not a | ||
| X_CORS_CONFIG_INVALID: 500, | ||
| X_CSP_DIRECTIVE_INVALID: 500, | ||
| // Thrown while the server is being constructed, so no request is ever answered with it either. | ||
@@ -428,15 +429,47 @@ // The row exists because this table is the closed one: a code missing from it is a 500 anyway, | ||
| /** The title a caller gets for a failure the framework cannot name. */ | ||
| const INTERNAL_TITLE = 'unhandled server error'; | ||
| /** | ||
| * The cause a caller gets for one. An unclassified 5xx has no `cause` of its own, so `factsOf` | ||
| * falls through to the throwable's `message` — a driver's DSN, the row Postgres rejected, an | ||
| * absolute path — and `toProblem` handed it to whoever asked. `error-page.ts` locked the BROWSER | ||
| * out of exactly this and said so in its header; the two audiences then disagreed about one | ||
| * condition. The real text is not lost: the `error-map` stage logs it as a redactable FIELD and | ||
| * reports every 5xx to the error monitor, both keyed by the request id below. | ||
| */ | ||
| const INTERNAL_CAUSE = | ||
| 'the server failed while handling this request; the details are in this process\u2019s logs and ' + | ||
| 'error reports, under this request id'; | ||
| /** | ||
| * A 5xx nobody declared a status for — not the framework's table, not the app's | ||
| * `registerErrorStatus` — or one whose code is `X_INTERNAL`. That is the discriminator, and not | ||
| * `status >= 500` alone: a declared code has an authored cause, and blanking `X_DRAINING`'s would | ||
| * take away the one instruction in it. | ||
| * | ||
| * `X_INTERNAL` is in the framework's table and still belongs here, because it is the framework's | ||
| * own word for "nobody classified this": `factsOf` mints it for a throwable carrying no code, and | ||
| * core's `toError()` wraps a caught value into an `InternalError` whose cause is | ||
| * `renderCauseValue(value)` — the driver's message, verbatim. Nothing in an `X_INTERNAL` is | ||
| * actionable by the caller; the code and the request id are. | ||
| */ | ||
| const isUnclassifiedFailure = (code: string, status: number): boolean => | ||
| status >= 500 && | ||
| (code === 'X_INTERNAL' || (frameworkStatus(code) === undefined && !APP_ERROR_STATUS.has(code))); | ||
| export const toProblem = ( | ||
| error: unknown, | ||
| meta: { instance?: string; requestId?: string } = {}, | ||
| meta: { instance?: string; requestId?: string; dev?: boolean } = {}, | ||
| ): ProblemDocument => { | ||
| const facts = factsOf(error); | ||
| const opaque = meta.dev !== true && isUnclassifiedFailure(facts.code, facts.status); | ||
| return { | ||
| type: problemTypeFor(facts.code), | ||
| title: facts.title, | ||
| title: opaque ? INTERNAL_TITLE : facts.title, | ||
| status: facts.status, | ||
| detail: facts.cause, | ||
| detail: opaque ? INTERNAL_CAUSE : facts.cause, | ||
| instance: meta.instance, | ||
| code: facts.code, | ||
| cause: facts.cause, | ||
| cause: opaque ? INTERNAL_CAUSE : facts.cause, | ||
| fix: facts.fix, | ||
@@ -443,0 +476,0 @@ docs: facts.docs, |
+16
-0
@@ -26,2 +26,3 @@ // The HTTP layer's stable error codes. Every throw in this package goes through a | ||
| 'X_CORS_CONFIG_INVALID', | ||
| 'X_CSP_DIRECTIVE_INVALID', | ||
| 'X_RATE_LIMIT_NOT_SHARED', | ||
@@ -79,2 +80,3 @@ 'X_RATE_LIMIT_BUCKET_CONFLICT', | ||
| X_CORS_CONFIG_INVALID: 'the cors config can never produce a working response', | ||
| X_CSP_DIRECTIVE_INVALID: 'a csp extension would emit something other than the directive it names', | ||
| X_RATE_LIMIT_NOT_SHARED: 'the rate limit is declared fleet-wide and the store is per-process', | ||
@@ -295,2 +297,16 @@ X_RATE_LIMIT_BUCKET_CONFLICT: 'a route and the config declare different numbers for one bucket', | ||
| /** | ||
| * At `defineHttpConfig`. A directive name and a source both go into the header VERBATIM, and the | ||
| * header's own separators are `;` and ` ` — so `extend: { 'x; script-src *': [] }` is not one | ||
| * badly named directive, it is a second directive nobody declared, widening the one this | ||
| * framework locks down hardest. Refused where it is written rather than escaped where it is | ||
| * emitted: there is no encoding for a CSP directive, so the only total answer is not to have one. | ||
| */ | ||
| export const cspDirectiveInvalid = (where: string, value: string): HttpError => | ||
| new HttpError({ | ||
| code: 'X_CSP_DIRECTIVE_INVALID', | ||
| cause: `${where} is not a csp token: ${JSON.stringify(value)}`, | ||
| fix: 'in app.config.ts write one http.security.csp.extend entry per directive, each source its own array element — a directive name is [a-z][a-z0-9-]*, and no source may contain a space, a comma or a semicolon', | ||
| }); | ||
| export const routeConflict = (path: string, detail: string): HttpError => | ||
@@ -297,0 +313,0 @@ new HttpError({ |
+14
-0
@@ -40,2 +40,16 @@ // The seams the HTTP layer cannot own itself: who the actor is (auth lives in `@ultimat3/auth`, | ||
| readonly devNotices?: (ctx: RequestContext) => readonly OverlayNotice[]; | ||
| /** | ||
| * The app's OWN error page for a status, served byte for byte, or `undefined` to render the | ||
| * framework's. A seam and not a config value because the answer lives on a disk this package | ||
| * cannot see: `@ultimat3/cli` reads `apps/web/site/errors/<status>.html`, per request, so a file | ||
| * dropped into a running server takes effect without a restart — the rule `/favicon.ico` | ||
| * already follows for the same class of file. | ||
| * | ||
| * Consulted only on the production HTML path: a dev process answers a browser with the overlay, | ||
| * and an agent gets the problem document in both. | ||
| */ | ||
| readonly errorPage?: ( | ||
| status: number, | ||
| ctx: RequestContext, | ||
| ) => Promise<string | undefined> | string | undefined; | ||
| } | ||
@@ -42,0 +56,0 @@ |
+14
-0
@@ -40,2 +40,15 @@ // The public surface of @ultimat3/http. Explicit, never `export *`: what is not | ||
| } from './error-map'; | ||
| export type { | ||
| ErrorPageAction, | ||
| ErrorPageCopy, | ||
| ErrorPageGroup, | ||
| ErrorPageInput, | ||
| ErrorPageOptions, | ||
| } from './error-page'; | ||
| export { | ||
| ERROR_PAGE_LINKS, | ||
| errorPageResponse, | ||
| renderErrorPage, | ||
| resolveErrorPageCopy, | ||
| } from './error-page'; | ||
| export type { HttpErrorCode } from './errors'; | ||
@@ -77,2 +90,3 @@ export { | ||
| export { configureAuthenticator, configuredAuthenticator, resetAuthenticator } from './hooks'; | ||
| export { acceptsHtml, escapeHtml } from './html-render'; | ||
| export type { LocaleConfig, TimeZoneConfig } from './locale'; | ||
@@ -79,0 +93,0 @@ export { DEFAULT_LOCALE_CONFIG, DEFAULT_TZ_CONFIG, readCookie } from './locale'; |
+11
-3
@@ -1,4 +0,6 @@ | ||
| // The dev overlay's stylesheet, split from the document that carries it so `security-headers.ts` | ||
| // can hash it into the CSP without importing the renderer — and so the hash is computed from the | ||
| // one copy of the text, never from a constant that drifted away from what the `<style>` holds. | ||
| // The stylesheet of every document this package renders — the dev overlay and the production | ||
| // error page — split from both so `security-headers.ts` can hash it into the CSP without importing | ||
| // a renderer, and so the hash is computed from the one copy of the text rather than from a constant | ||
| // that drifted away from what the `<style>` holds. ONE stylesheet and not two: a second inline body | ||
| // is a second `style-src` hash, and the page that shipped without one renders unstyled. | ||
@@ -43,2 +45,8 @@ // Token definitions live here and nowhere else; every rule below uses var(). | ||
| .notices dt { overflow-wrap: anywhere; } | ||
| /* The production error page's three rules. The status is the one number a visitor reads first, so | ||
| it leads at display size in the muted role — never the danger role, which the overlay's h1 owns | ||
| and which says "a defect" rather than "this page moved". */ | ||
| .status { font-size: 2.5rem; line-height: 1.1; margin: 0; color: var(--x-muted); } | ||
| .lede { margin: .25rem 0 1rem; } | ||
| footer { display: flex; flex-wrap: wrap; gap: 1rem; color: var(--x-muted); } | ||
| `; |
+11
-14
@@ -6,16 +6,6 @@ // The dev error overlay. It renders the SAME facts object the terminal prints and | ||
| import { factsOf, renderErrorLines, toProblem } from './error-map'; | ||
| import { acceptsHtml, escapeHtml } from './html-render'; | ||
| import { OVERLAY_STYLE } from './overlay-style'; | ||
| import { html } from './response'; | ||
| // `'` is escaped even though every attribute below is double-quoted: the escape set is what the | ||
| // next author reads as the guarantee, and a single-quoted attribute written later would inherit a | ||
| // hole nothing here would have flagged. | ||
| const escapeHtml = (value: string): string => | ||
| value | ||
| .replaceAll('&', '&') | ||
| .replaceAll('<', '<') | ||
| .replaceAll('>', '>') | ||
| .replaceAll('"', '"') | ||
| .replaceAll("'", '''); | ||
| /** | ||
@@ -82,2 +72,5 @@ * An `href` is a SCHEME decision, not an escaping one: `javascript:alert(1)` survives every entity | ||
| ...(meta.path === undefined ? {} : { instance: meta.path }), | ||
| // The overlay is rendered on the dev branch and nowhere else (`stages.ts`), and its whole job | ||
| // is showing the developer the cause a production caller may not see. | ||
| dev: true, | ||
| }); | ||
@@ -124,4 +117,8 @@ const where = `${meta.method ?? ''} ${meta.path ?? ''}`.trim(); | ||
| /** Dev only, and only when the caller is a browser: agents and RPC want problem+json. */ | ||
| export const wantsOverlay = (request: Request): boolean => | ||
| (request.headers.get('accept') ?? '').includes('text/html'); | ||
| /** | ||
| * Dev only, and only when the caller is a browser: agents and RPC want problem+json. The sniff | ||
| * itself is `acceptsHtml`, shared with the production error page — the two documents answer the | ||
| * same caller in two environments, so asking the question twice is how one of them starts | ||
| * disagreeing with the other. | ||
| */ | ||
| export const wantsOverlay = (request: Request): boolean => acceptsHtml(request); |
+10
-5
@@ -29,5 +29,5 @@ // The typed request. Handlers never touch the raw `Request`: params, query and body | ||
| */ | ||
| const parseQuery = (url: URL): QueryValues => { | ||
| const out: Record<string, string | string[]> = Object.create(null); | ||
| for (const [key, value] of url.searchParams) { | ||
| const collectFields = <T>(entries: Iterable<[string, T]>): Record<string, T | T[]> => { | ||
| const out: Record<string, T | T[]> = Object.create(null); | ||
| for (const [key, value] of entries) { | ||
| const existing = out[key]; | ||
@@ -41,2 +41,4 @@ if (existing === undefined) out[key] = value; | ||
| const parseQuery = (url: URL): QueryValues => collectFields(url.searchParams); | ||
| export class UltimateRequest { | ||
@@ -189,3 +191,6 @@ readonly raw: Request; | ||
| }).formData(); | ||
| return Object.fromEntries(form); | ||
| // `collectFields`, never `Object.fromEntries`: a repeated name is a LIST here for the | ||
| // reason it is one in the query — a checkbox group posts its name once per checked box, | ||
| // and collapsing to the last one silently discards every other answer. | ||
| return collectFields(form); | ||
| } catch (error) { | ||
@@ -205,3 +210,3 @@ // The parser's own message is a diagnostic, not an instruction, and it quotes the bytes | ||
| if (type === 'application/x-www-form-urlencoded') { | ||
| return Object.fromEntries(new URLSearchParams(body)); | ||
| return collectFields(new URLSearchParams(body)); | ||
| } | ||
@@ -208,0 +213,0 @@ if (type.startsWith('text/')) return body; |
+26
-6
| // Response constructors. Every response in the framework is built here so that | ||
| // content types, charsets and cache semantics are decided once instead of per route. | ||
| import { TIMEZONE_HEADER } from '@ultimat3/time'; | ||
| import { toProblem } from './error-map'; | ||
@@ -76,3 +77,12 @@ | ||
| error: unknown, | ||
| meta: { instance?: string; requestId?: string; headers?: Record<string, string> } = {}, | ||
| meta: { | ||
| instance?: string; | ||
| requestId?: string; | ||
| headers?: Record<string, string>; | ||
| /** | ||
| * `config.dev`. Absent means NOT dev: a degraded path that cannot see the config must not be | ||
| * the one that reveals an unclassified 500's text, and the stage that can see it passes it. | ||
| */ | ||
| dev?: boolean; | ||
| } = {}, | ||
| ): Response => { | ||
@@ -82,2 +92,3 @@ const document = toProblem(error, { | ||
| ...(meta.requestId === undefined ? {} : { requestId: meta.requestId }), | ||
| ...(meta.dev === undefined ? {} : { dev: meta.dev }), | ||
| }); | ||
@@ -139,2 +150,12 @@ return new Response(JSON.stringify(document), { | ||
| /** | ||
| * The request dimensions a SHARED copy of a response is keyed on. `cookie` because every session | ||
| * in this framework travels in one; `accept-language` and the time-zone header because both are | ||
| * ambient inputs to a server render — they become `ctx.locale` and `ctx.tz`, which is what | ||
| * `@ultimat3/ui` formats every date with — so the body is a function of them and a cache that | ||
| * ignores one hands the next visitor the previous one's document. One list, two readers: the hint | ||
| * this file applies, and the `cache-headers` stage for a shared `cache-control` a handler wrote. | ||
| */ | ||
| export const SHARED_CACHE_VARY: readonly string[] = ['accept-language', 'cookie', TIMEZONE_HEADER]; | ||
| /** Mutates the response headers in place — responses are per-request, never shared. */ | ||
@@ -148,7 +169,6 @@ export const applyCacheHeaders = (response: Response, hint: CacheHint): Response => { | ||
| // URL, and every session in this framework travels in a cookie — so without it the first | ||
| // signed-in render of a public page is what every later visitor is served. | ||
| return addVary( | ||
| response, | ||
| hint.vary ?? (hint.mode === 'public' ? ['accept-language', 'cookie'] : []), | ||
| ); | ||
| // signed-in render of a public page is what every later visitor is served. `SHARED_CACHE_VARY` | ||
| // rather than a literal: the stage that reviews a handler's own `cache-control` adds the same | ||
| // dimensions, and two lists is one of them missing the key that mattered. | ||
| return addVary(response, hint.vary ?? (hint.mode === 'public' ? SHARED_CACHE_VARY : [])); | ||
| }; | ||
@@ -155,0 +175,0 @@ |
@@ -6,2 +6,3 @@ // Locked security headers. The CSP admits exactly what the framework itself emits — a service | ||
| import { cspDirectiveInvalid } from './errors'; | ||
| import { OVERLAY_STYLE } from './overlay-style'; | ||
@@ -76,8 +77,38 @@ | ||
| /** A CSP directive name, per the grammar. Lowercase because that is what this file emits. */ | ||
| const DIRECTIVE_NAME = /^[a-z][a-z0-9-]*$/; | ||
| /** | ||
| * A source expression may not carry one of the header's own separators. Checked and never escaped: | ||
| * there is no encoding for a CSP source, so the only total answer is refusing the value. | ||
| */ | ||
| const SOURCE_DELIMITER = /[\s;,]/; | ||
| /** | ||
| * Refuse an `extend` entry that would emit something other than the directive it names. Called | ||
| * from `defineHttpConfig`, beside `assertCorsConfig`, so the refusal lands at boot rather than on | ||
| * the first response — and never per request, where this runs for every header built. | ||
| */ | ||
| export const assertCspExtend = (extend: Readonly<Record<string, readonly string[]>>): void => { | ||
| for (const [name, sources] of Object.entries(extend)) { | ||
| if (!DIRECTIVE_NAME.test(name)) throw cspDirectiveInvalid('a csp directive name', name); | ||
| for (const source of sources) { | ||
| if (SOURCE_DELIMITER.test(source)) { | ||
| throw cspDirectiveInvalid(`a source of ${name}`, source); | ||
| } | ||
| } | ||
| } | ||
| }; | ||
| export const buildCsp = (config: SecurityConfig): string => { | ||
| const directives = baseline(config); | ||
| // A `Map`, never the record: `directives[name]` was a computed read of an object LITERAL keyed | ||
| // by a name the caller chose, so `extend: { toString: [...] }` read a FUNCTION off | ||
| // `Object.prototype` and the spread beside it threw a bare `TypeError` at boot — and | ||
| // `directives['__proto__'] = […]` would have run the prototype setter instead of adding a | ||
| // directive. `proto-index` cannot see either, because `baseline()` is what produces the object. | ||
| const directives = new Map<string, readonly string[]>(Object.entries(baseline(config))); | ||
| for (const [name, sources] of Object.entries(config.csp.extend)) { | ||
| directives[name] = [...(directives[name] ?? []), ...sources]; | ||
| directives.set(name, [...(directives.get(name) ?? []), ...sources]); | ||
| } | ||
| const parts = Object.entries(directives).map(([name, sources]) => `${name} ${sources.join(' ')}`); | ||
| const parts = [...directives].map(([name, sources]) => `${name} ${sources.join(' ')}`); | ||
| if (config.csp.reportUri !== null) parts.push(`report-uri ${config.csp.reportUri}`); | ||
@@ -84,0 +115,0 @@ return parts.join('; '); |
+6
-2
@@ -96,4 +96,8 @@ // We own the server lifecycle instead of exposing `Bun.serve` directly, because ALS | ||
| // The one HTTP-owned knob feeds core's deadline, so there is a single drain budget. | ||
| configureLifecycle({ deadlineMs: config.drainTimeoutMs }); | ||
| // The one HTTP-owned knob feeds core's deadline, so there is a single drain budget — and only | ||
| // when this app DECLARED it. Unconditional, with `defineHttpConfig` defaulting the number, this | ||
| // line reverted `configureLifecycle({ deadlineMs: 600_000 })` — the edit `X_SHUTDOWN_TIMEOUT`'s | ||
| // own `fix:` prints — back to 15s on every boot that serves web, silently. "Nobody said" and | ||
| // "the app said 15 seconds" are different claims and `null` is what keeps them apart. | ||
| if (config.drainTimeoutMs !== null) configureLifecycle({ deadlineMs: config.drainTimeoutMs }); | ||
@@ -100,0 +104,0 @@ let server: BunServer | undefined; |
+61
-18
@@ -17,3 +17,3 @@ // One function per stage name: what each stage of the lifecycle DOES. The package's three-way | ||
| import { signInRedirect } from './auth-redirect'; | ||
| import { defaultCache } from './cache-policy'; | ||
| import { defaultCache, offersSharedCache, PRIVATE_CACHE } from './cache-policy'; | ||
| import { type HttpConfig, stripBasePath } from './config'; | ||
@@ -24,2 +24,3 @@ import { actorView, elapsedMs, type RequestContext } from './context'; | ||
| import { factsOf, retryAfterOf } from './error-map'; | ||
| import { errorPageResponse } from './error-page'; | ||
| import { | ||
@@ -37,9 +38,10 @@ bodyInvalid, | ||
| import type { ServerHooks } from './hooks'; | ||
| import { acceptsHtml } from './html-render'; | ||
| import { readCookie } from './locale'; | ||
| import { compose, type Middleware } from './middleware'; | ||
| import { overlayResponse, wantsOverlay } from './overlay'; | ||
| import { overlayResponse } from './overlay'; | ||
| import { type RateLimiter, rateLimitKey } from './rate-limit'; | ||
| import { rateLimited } from './rate-limit-errors'; | ||
| import type { UltimateRequest } from './request'; | ||
| import { addVary, applyCacheHeaders, problem, redirect } from './response'; | ||
| import { addVary, applyCacheHeaders, problem, redirect, SHARED_CACHE_VARY } from './response'; | ||
| import { matchRoute, type Route, type RouteHandler, type RouteTable } from './router'; | ||
@@ -294,3 +296,4 @@ import { securityHeaders } from './security-headers'; | ||
| if (response === undefined) return undefined; | ||
| if (!response.headers.has('cache-control')) { | ||
| const declared = response.headers.get('cache-control'); | ||
| if (declared === null) { | ||
| applyCacheHeaders( | ||
@@ -300,7 +303,20 @@ response, | ||
| ); | ||
| return undefined; | ||
| } | ||
| // A declaration is the MODE's intent, never the last word: `@ultimat3/render`'s `ssrHeaders` | ||
| // offers any route without a `policy` to a CDN for 30 seconds, and `meta.auth` is | ||
| // `'public' | 'required'` — so the page that greets a signed-in visitor by name is a | ||
| // `'public'` route whose own header says `s-maxage`. This stage is the one owner of the | ||
| // final answer, which is why it REVIEWS what the handler wrote instead of standing down; | ||
| // the rule beside it was otherwise unreachable for every page route in every app. | ||
| if (!offersSharedCache(declared)) return undefined; | ||
| if (!isAnonymous(ctx.actor)) { | ||
| applyCacheHeaders(response, PRIVATE_CACHE); | ||
| return undefined; | ||
| } | ||
| addVary(response, SHARED_CACHE_VARY); | ||
| return undefined; | ||
| }, | ||
| 'error-map': (request, ctx) => { | ||
| 'error-map': async (request, ctx) => { | ||
| const error = ctx.error; | ||
@@ -344,15 +360,2 @@ const facts = factsOf(error); | ||
| if (toSignIn !== undefined) return redirect(toSignIn.location, toSignIn.status); | ||
| if (config.dev && wantsOverlay(request.raw)) { | ||
| // Asked for inside the branch, never above it: the overlay is the only surface a notice | ||
| // has, so a production process — or an agent that asked for json — must not pay a | ||
| // diagnostic's per-request cost to produce findings nothing will render. | ||
| const notices = hooks.devNotices?.(ctx) ?? []; | ||
| return overlayResponse(error, { | ||
| requestId: ctx.requestId, | ||
| method: ctx.method, | ||
| path: ctx.url.pathname, | ||
| buildId: config.buildId, | ||
| ...(notices.length === 0 ? {} : { notices }), | ||
| }); | ||
| } | ||
| // The limiter's own decision first — it is the live one and it knows this request's bucket — | ||
@@ -374,2 +377,38 @@ // then whatever the THROWABLE computed. Only the first half existed, so every other refusal | ||
| const retryAfter = seconds === undefined ? {} : { 'retry-after': String(seconds) }; | ||
| // One sniff, two documents: a browser is never handed a problem document, and an agent is | ||
| // never handed a page. Which of the two a browser gets is the ENVIRONMENT — the overlay | ||
| // prints the cause, the fix and the stack, which is what a visitor may never see. | ||
| if (acceptsHtml(request.raw)) { | ||
| if (config.dev) { | ||
| // Asked for inside the branch, never above it: the overlay is the only surface a notice | ||
| // has, so a production process — or an agent that asked for json — must not pay a | ||
| // diagnostic's per-request cost to produce findings nothing will render. | ||
| const notices = hooks.devNotices?.(ctx) ?? []; | ||
| return overlayResponse(error, { | ||
| requestId: ctx.requestId, | ||
| method: ctx.method, | ||
| path: ctx.url.pathname, | ||
| buildId: config.buildId, | ||
| ...(notices.length === 0 ? {} : { notices }), | ||
| }); | ||
| } | ||
| return errorPageResponse( | ||
| { | ||
| status: facts.status, | ||
| code: facts.code, | ||
| path: ctx.url.pathname, | ||
| requestId: ctx.requestId, | ||
| locale: ctx.locale, | ||
| // The rule the 403 page may name, and the one the `authz` stage was evaluating — | ||
| // never a row, never the actor. `forbidden`'s own `fix:` already cites this field. | ||
| ...(ctx.route?.meta.policy === undefined ? {} : { permission: ctx.route.meta.policy }), | ||
| ...(seconds === undefined ? {} : { retryAfterSeconds: seconds }), | ||
| signInPath: config.signInPath, | ||
| }, | ||
| // The app's own file, read per request by whoever mounted the hook. A throw here is | ||
| // caught by `recoverWith` and degrades to the problem document, which is the answer a | ||
| // page whose renderer failed can still give. | ||
| { override: await hooks.errorPage?.(facts.status, ctx), headers: retryAfter }, | ||
| ); | ||
| } | ||
| return problem(error, { | ||
@@ -379,2 +418,6 @@ instance: ctx.url.pathname, | ||
| headers: retryAfter, | ||
| // The one call site that can see the config, so it is the one that may reveal an | ||
| // unclassified 500's own text. Every other `problem()` in this package is a degraded | ||
| // path and stays opaque by default. | ||
| dev: config.dev, | ||
| }); | ||
@@ -381,0 +424,0 @@ }, |
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
315375
9.87%39
5.41%5229
8.58%224
1.36%+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
Updated
Updated
Updated
Updated