@ultimat3/http
Advanced tools
| // The inbound half of the framework's webhook mechanism: prove a request was signed by the holder | ||
| // of a shared secret, recently, over the bytes it actually carries. It is a plain function and not | ||
| // a pipeline stage because a receiver is an ordinary `api/` route — the secret is per sender, and | ||
| // only the route knows which one applies. | ||
| // | ||
| // THE FORMAT IS `@ultimat3/core`'s (`webhook-signature.ts`) and is not re-declared here. That | ||
| // module is at the tier both halves can reach: `@ultimat3/jobs` (tier 3) signs a delivery and its | ||
| // boundary forbids this package, and this package (tier 2) may not reach tier 3. What stays here | ||
| // is the POLICY — what counts as fresh, how large a body may be, and which refusal a receiver | ||
| // answers with. | ||
| import type { Clock } from '@ultimat3/core'; | ||
| import { | ||
| isCanonicalWebhookField, | ||
| parseWebhookSignatureHeader, | ||
| readWithinLimit, | ||
| systemClock, | ||
| timingSafeEqual, | ||
| WEBHOOK_FIELD_MAX, | ||
| WEBHOOK_ID_HEADER, | ||
| WEBHOOK_SIGNATURE_HEADER, | ||
| WEBHOOK_TOPIC_HEADER, | ||
| webhookMac, | ||
| } from '@ultimat3/core'; | ||
| import { bodyInvalid, webhookSignatureInvalid, webhookSignatureStale } from './errors'; | ||
| /** | ||
| * How far a delivery's timestamp may sit from this clock, either way. Five minutes is the window | ||
| * every sender in the wild already assumes, and it is a REPLAY BOUND, not a latency allowance: a | ||
| * captured request stops being usable after it, which is the only thing that keeps an intercepted | ||
| * delivery from being replayable forever. | ||
| */ | ||
| export const DEFAULT_WEBHOOK_TOLERANCE_MS = 300_000; | ||
| /** | ||
| * Restated rather than read from `HttpConfig.bodyLimitBytes` (same number, `config.ts`): this | ||
| * function runs inside a route handler with a raw `Request` and no pipeline config in scope, and a | ||
| * receiver that must hold a 4 MB payload says so here rather than by widening every route's cap. | ||
| */ | ||
| export const DEFAULT_WEBHOOK_BODY_LIMIT = 1_048_576; | ||
| export interface WebhookVerifyOptions { | ||
| /** The shared secret for THIS sender. Never logged, never rendered into a refusal. */ | ||
| readonly secret: string; | ||
| /** Defaults to `DEFAULT_WEBHOOK_TOLERANCE_MS`. */ | ||
| readonly toleranceMs?: number; | ||
| /** Defaults to `DEFAULT_WEBHOOK_BODY_LIMIT`. Enforced while the body streams. */ | ||
| readonly maxBytes?: number; | ||
| /** Defaults to `systemClock`. A window no test can freeze is a window no test pins. */ | ||
| readonly clock?: Clock; | ||
| } | ||
| export interface VerifiedWebhook { | ||
| /** | ||
| * The sender's id for this event, signed and therefore unforgeable. It is the DEDUPE key: a | ||
| * delivery replayed inside the tolerance window verifies again by design, and this is what lets | ||
| * a receiver notice. The seen-set is the app's table — the framework has nowhere to keep one. | ||
| */ | ||
| readonly eventId: string; | ||
| /** The sender's routing label. Carried and signed, never interpreted (axiom 8). */ | ||
| readonly topic: string; | ||
| /** | ||
| * The exact text the signature covers. Parse THIS, never `request.json()` — the body stream is | ||
| * spent, and a re-serialisation would not be the bytes that were signed. | ||
| */ | ||
| readonly body: string; | ||
| readonly signedAtMs: number; | ||
| } | ||
| /** | ||
| * Prove the request came from the holder of `secret`, inside the tolerance window, over the bytes | ||
| * it carries — and answer what was signed. | ||
| * | ||
| * The order is deliberate: the mac is checked BEFORE the window, so `X_WEBHOOK_SIGNATURE_STALE` | ||
| * means "authentic and old" and never "unreadable and old". An operator reading it goes to a clock | ||
| * or a replay, which is what that code is for. | ||
| */ | ||
| export async function verifyWebhookSignature( | ||
| request: Request, | ||
| options: WebhookVerifyOptions, | ||
| ): Promise<VerifiedWebhook> { | ||
| const pathname = new URL(request.url).pathname; | ||
| const signature = parseWebhookSignatureHeader(request.headers.get(WEBHOOK_SIGNATURE_HEADER)); | ||
| if (signature === undefined) { | ||
| throw webhookSignatureInvalid( | ||
| pathname, | ||
| `no readable ${WEBHOOK_SIGNATURE_HEADER} on the request`, | ||
| ); | ||
| } | ||
| const eventId = request.headers.get(WEBHOOK_ID_HEADER) ?? ''; | ||
| const topic = request.headers.get(WEBHOOK_TOPIC_HEADER) ?? ''; | ||
| if (!isCanonicalWebhookField(eventId) || !isCanonicalWebhookField(topic)) { | ||
| throw webhookSignatureInvalid( | ||
| pathname, | ||
| `${WEBHOOK_ID_HEADER} and ${WEBHOOK_TOPIC_HEADER} must each be 1-${WEBHOOK_FIELD_MAX} characters and carry no ":"`, | ||
| ); | ||
| } | ||
| const maxBytes = options.maxBytes ?? DEFAULT_WEBHOOK_BODY_LIMIT; | ||
| // Through core's counting reader, the same one `UltimateRequest.#read` uses: a sender that | ||
| // announces no length must not be able to make this handler hold an unbounded payload before the | ||
| // signature it was never going to pass is even computed. | ||
| const read = await readWithinLimit(request.body, maxBytes); | ||
| if ('over' in read) { | ||
| throw bodyInvalid(pathname, [`body is at least ${read.over} bytes, limit is ${maxBytes}`]); | ||
| } | ||
| // The mac is core's, over the RAW bytes: an HMAC is over a byte stream, so hashing the prefix | ||
| // and then the body is identical to hashing one string — and it never round-trips a body that is | ||
| // not valid UTF-8 through a decoder before the mac is taken over it. | ||
| const expected = webhookMac({ | ||
| secret: options.secret, | ||
| timestampText: signature.timestampText, | ||
| eventId, | ||
| topic, | ||
| body: read.bytes, | ||
| }); | ||
| // `timingSafeEqual`, never `===`: this is a mac comparison, and where the two first differ is | ||
| // exactly what a timing oracle needs to forge one byte at a time. | ||
| if (!timingSafeEqual(expected, signature.mac)) { | ||
| throw webhookSignatureInvalid(pathname, 'the signature does not match the body that arrived'); | ||
| } | ||
| const signedAtMs = signature.timestampSeconds * 1_000; | ||
| const toleranceMs = options.toleranceMs ?? DEFAULT_WEBHOOK_TOLERANCE_MS; | ||
| const skewMs = Math.abs((options.clock ?? systemClock).now().getTime() - signedAtMs); | ||
| // Both directions: a sender whose clock runs ahead is the same replay window pointed the other | ||
| // way, and accepting the future half doubles it. | ||
| if (skewMs > toleranceMs) throw webhookSignatureStale(pathname, skewMs, toleranceMs); | ||
| return { eventId, topic, body: new TextDecoder().decode(read.bytes), signedAtMs }; | ||
| } |
+103
-3
@@ -44,3 +44,3 @@ # @ultimat3/http | ||
| asks whether a key is READ, and this is the mirror question. | ||
| - **`asCtx` is a WIDENING the compiler checks, never a cast.** `RequestContext extends Ctx`, and | ||
| - **`asCtx` is a WIDENING the compiler checks, never a cast.** `RequestContext extends Ctx` and | ||
| `asCtx` is the identity function. It used to be `ctx as unknown as Ctx` over an object that set | ||
@@ -55,2 +55,42 @@ none of `clock`, `now`, `logger`, `signal` or `services` — so `ctx.now()` threw | ||
| `ctx.clientBuildId`, read only by `assertBuild()`. | ||
| **`RequestContext extends Ctx` again, and this file no longer BUILDS a context — it composes | ||
| one** (`As of 2026-08-24`). `Ctx extends CtxServices`, and `CtxServices` is the seam an app | ||
| augments (`declare module '@ultimat3/core'`) to declare `ctx.posts` — so in an APP's program | ||
| every service it declared became a REQUIRED member of `createRequestContext`'s object literal, | ||
| and this file failed to compile inside `examples/dummy` with `TS2739: missing posts, orgs` while | ||
| the framework's own gate, which augments nothing, stayed green. The framework cannot set members | ||
| only the app's boot knows about. | ||
| `createRequestContext` now spreads `createContext()`'s result. Those members arrive WITH the | ||
| base, so the literal is checked in full and there is **no assertion left in this file** — | ||
| `withServices`, which existed for one release, is gone. `packages/core/src/context.ts` keeps the | ||
| framework's one irreducible `as Ctx` and its header states, with the four measured alternatives, | ||
| why it cannot be removed below a major. | ||
| Composing is also one constructor for one shape instead of two. This file used to re-derive | ||
| `clock`, `now`, the logger child, `signal`, `deadlineAt` and the service bag, so core could fix | ||
| any of them and this surface would keep the old answer — which is exactly what happened to the | ||
| bag (see the bullet below). `defineService` factories now install here too, for the same reason: | ||
| they are `createContext`'s, and this is `createContext`. | ||
| `type-pins.ts` carries `_RequestContextIsACtx`, because `asCtx` is a function body and a future | ||
| edit answering a failure there with a cast would delete the enforcement and leave the comment. | ||
| The reverse direction is FALSE by design — core's `Ctx` carries no `requestHeaders`, which is | ||
| what `assertInRequest` proves one way at runtime. | ||
| - **`defineService` used to be a job-and-CLI feature, and nothing said so** (`As of 2026-08-24`). | ||
| Two halves, and together they made services unreachable on the surface an app spends its life | ||
| on. This file built its own service bag from `RequestContextInit.services` alone and never | ||
| called core's `installedServices()`; `pipeline.ts:224` passes NO `services` at all. So a | ||
| `defineService('posts', …)` an app registered at boot was installed for a job, a task and a CLI | ||
| command and for nothing else — over HTTP `ctx.services` was `{}` and `useService('posts')` threw | ||
| `X_SERVICE_MISSING`. And the bag, even when one was passed, was never spread ONTO the context, | ||
| so `ctx.posts` — the spelling `docs/architecture/15-adding-a-feature.md` writes in its worked | ||
| example — read `undefined` beside a populated `ctx.services`. | ||
| Composing `createContext` fixes both at once, which is the argument for composing: the installer | ||
| is core's and so is the spread. Services go on FIRST so a service an app named `actor` or | ||
| `logger` loses to the request's own field and stays reachable as `ctx.services['actor']`; the | ||
| context's meaning never depends on what an app named a service. `context.test.ts` pins the | ||
| factory install, the spread and the collision order. | ||
| - **The two inbound ids are read BEFORE the context and the span, in `correlation.ts`.** `startSpan` | ||
@@ -129,2 +169,34 @@ resolves its parent from `currentSpanContext()`, which reads `ctx.traceId`, so a `traceparent` | ||
| both keyed by the request id the caller was given. | ||
| - **The problem document carries the ISSUE LIST, and the opacity rule applies to it** | ||
| (`As of 2026-08-24`). `ProblemDocument.issues` is a top-level extension member — RFC 9457 §3.2 | ||
| puts extension members at the document root and every Ultimate extension already is one | ||
| (`code`, `cause`, `fix`, `docs`, `requestId`); there is no bag. `@ultimat3/action` has attached | ||
| the list to `meta.issues` since `InputInvalidError` grew its third parameter and **nothing | ||
| carried it**, so every app in this framework recovered per-field form errors by splitting | ||
| `cause` on `'; '` — guesswork the moment a message contains the separator. | ||
| Four rules, and the third is the one most likely to be dropped by a later edit. | ||
| `issuesOf` is TOTAL and module-private, written in `retryAfterOf`'s shape and for its reason: | ||
| `meta` is a property read on a value this package did not build, in the frame that decides what | ||
| the caller sees. It is **all-or-nothing** — a client that finds `issues` uses it INSTEAD of | ||
| `cause`, so one unreadable entry drops the whole list back to the prose line rather than | ||
| shipping a subset, which would be a rejection the user never sees and a form reporting itself | ||
| valid. The member is **absent** when there is none, never `undefined` and never `[]`: | ||
| `JSON.stringify` drops an `undefined`, but this interface is read directly by `error-page.ts` | ||
| and by tests, and `[]` claims "validated clean" about a request that was just refused — which | ||
| the first implementation of this reader emitted, because `Array.isArray([])` is true and the | ||
| loop simply does not run. A list past `MAX_PROBLEM_ISSUES` (100) is dropped WHOLE for the same | ||
| all-or-nothing reason, and because `@ultimat3/action`'s `issuesFromWire` bounds it identically | ||
| on arrival: sending it is a body that costs the wire and answers nothing. That package is tier 3, | ||
| so the number is restated here and pinned on this side. And it is **dropped under exactly the condition | ||
| that blanks `title`/`detail`/`cause`** — an issue list on an unclassified 5xx is precisely the | ||
| internal detail `INTERNAL_CAUSE` exists to withhold, because it names the fields and the | ||
| expectations of something the caller was never meant to see inside. `X_INPUT_INVALID` is a | ||
| declared 4xx and so is never opaque. | ||
| `received` is forced to `''` and every entry is rebuilt member by member, never spread. Not | ||
| redundancy with `toValidationIssues`, which forces the same thing: this is the boundary where | ||
| the value LEAVES the process, a conforming library's own issue object is first-class here and | ||
| routinely carries the rejected value, and `@ultimat3/schema`'s `describeValue` exists because a | ||
| password-strength rule once wrote mistyped passwords into the log index. | ||
| - **A rejected value is a log FIELD, never part of the message.** `logger.emit()` redacts `bound`, | ||
@@ -437,2 +509,29 @@ `contextFields` and `fields` — and never `msg` — so `logger.error(\`${code}: ${cause}\`)` in the | ||
| is refused too — what cannot be shown to hold is not assumed to hold. | ||
| - **`verifyWebhookSignature` is a FUNCTION, never a pipeline stage** (`As of 2026-08-24`). The | ||
| secret is per SENDER, and only the route knows which sender it is serving; a stage would need one | ||
| secret for the whole app or a table this package has no business holding. It reads the body | ||
| through core's `readWithinLimit` — the same counting reader `UltimateRequest.#read` uses — so it | ||
| composes with the cap rather than defeating it, and it answers the raw text so the caller parses | ||
| the bytes that were signed rather than a re-serialisation of them. | ||
| The order inside it is load-bearing: the mac is checked BEFORE the freshness window, so | ||
| `X_WEBHOOK_SIGNATURE_STALE` means *authentic and old* and never *unreadable and old* — an | ||
| operator reading it goes to a clock or a replay, which is the only reason the second code exists. | ||
| The window is `Math.abs`, both directions: a sender whose clock runs ahead is the same replay | ||
| window pointed the other way, and accepting the future half doubles it. The timestamp is parsed | ||
| digits-only because `Number('nope')` is `NaN` and `NaN > toleranceMs` is FALSE — the one guard | ||
| whose failure mode is "the check does not run". `:` is refused in the id and the topic because | ||
| one mac over `v1:t:evt:01HZ:orders.paid:<body>` would otherwise authenticate two different | ||
| id/topic splits. The comparison is `timingSafeEqual` and may never become `===`; `bun run | ||
| secret-compare` is the mechanical half and `mac`/`signature` are names it reads. | ||
| **The FORMAT is `@ultimat3/core`'s and is not re-declared here** (`As of 2026-08-24`). | ||
| `packages/core/src/webhook-signature.ts` owns the canonical string, the mac and the parse; | ||
| this file owns the POLICY — what counts as fresh, how large a body may be, and which refusal a | ||
| receiver answers with. It shipped for one release as two implementations, here and in | ||
| `@ultimat3/jobs`, held together by a hex literal asserted in two test files: this package is | ||
| tier 2 and may not reach tier 3, that one's boundary forbids `http`, so the one copy lives at | ||
| the tier both can reach — the argument `timing-safe-equal.ts` makes for itself. The two literal | ||
| vectors stay until `scripts/webhook-round-trip.test.ts` replaces them. **Never re-declare the | ||
| canonical string here.** | ||
| - Never throw a bare `Error` — use a factory from `errors.ts`. | ||
@@ -463,3 +562,3 @@ - No `any`. Validation goes through Standard Schema (`validate.ts`), not a vendor API. | ||
| | `error-map.ts` | the code → status table, closed, plus the app's half (`registerErrorStatus`) | | ||
| | `error-facts.ts` | every RENDERING of a throwable: `factsOf()`, the problem document, the three terminal lines | | ||
| | `error-facts.ts` | every RENDERING of a throwable: `factsOf()`, the problem document (including the issue list and the opacity rule over it), the three terminal lines | | ||
| | `hooks.ts` | the seams: `authenticate`, `authorize`, `devNotices` + the app's `configureAuthenticator()` | | ||
@@ -469,3 +568,3 @@ | `type-pins.ts` | compile-time claims about `AuthzDecision`'s shape — source, because `tsc` never reads a `.test.ts` | | ||
| | `overlay-style.ts` | the overlay's one stylesheet, split out so `security-headers.ts` hashes it | | ||
| | `context.ts` | `RequestContext` + the single `Ctx` adapter (`asCtx`) + the inbound-header readers | | ||
| | `context.ts` | `RequestContext` (core's `Ctx` plus the request's own), composed from `createContext`, the single `Ctx` adapter (`asCtx`) and the inbound-header readers | | ||
| | `redirect.ts` | the intent slot a handler that cannot return a `Response` fills | | ||
@@ -482,2 +581,3 @@ | `auth-redirect.ts` | where an unauthenticated browser goes, and where it comes back to | | ||
| | `csrf.ts` | the origin proof an unsafe method from a credentialed browser must carry | | ||
| | `webhook-verify.ts` | the INBOUND webhook: the canonical string, the constant-time mac check and the replay window. The outbound half is `webhook()` in `@ultimat3/jobs`, which this package can never import | | ||
| | `locale.ts` | WHERE the request's locale and zone are read from — header and cookie NAMES only, plus `readCookie`. It negotiates nothing | | ||
@@ -484,0 +584,0 @@ | `rate-limit-buckets.ts` | the one point routes and config meet: a route's own bucket, registered or refused | |
+5
-5
| { | ||
| "name": "@ultimat3/http", | ||
| "version": "12.0.0", | ||
| "version": "13.0.0", | ||
| "description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors", | ||
@@ -34,7 +34,7 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@ultimat3/core": "12.0.0", | ||
| "@ultimat3/i18n": "12.0.0", | ||
| "@ultimat3/schema": "12.0.0", | ||
| "@ultimat3/time": "12.0.0" | ||
| "@ultimat3/core": "13.0.0", | ||
| "@ultimat3/i18n": "13.0.0", | ||
| "@ultimat3/schema": "13.0.0", | ||
| "@ultimat3/time": "13.0.0" | ||
| } | ||
| } |
+58
-1
@@ -116,2 +116,12 @@ # @ultimat3/http 🌐 | ||
| ```ts | ||
| import { | ||
| createServer, | ||
| defineHttpConfig, | ||
| type RateLimitStore, | ||
| type Route, | ||
| } from '@ultimat3/http'; | ||
| declare const routes: readonly Route[]; | ||
| declare const myStore: RateLimitStore; // postgresRateLimitStore({ executor }), say | ||
| createServer({ | ||
@@ -238,2 +248,4 @@ routes, | ||
| ```ts | ||
| import { createServer, defineHttpConfig, json } from '@ultimat3/http'; | ||
| const handle = createServer({ | ||
@@ -250,2 +262,46 @@ routes: [{ method: 'GET', path: '/posts/:id', meta: { name: 'posts.show', auth: 'public' }, | ||
| ## Inbound webhooks | ||
| `verifyWebhookSignature(request, { secret })` is the receiving half of the framework's webhook | ||
| mechanism. It is a plain function and not a pipeline stage, because the secret is **per sender** | ||
| and only the route knows which one applies. | ||
| ```ts | ||
| // apps/web/api/webhooks/partner/route.ts | ||
| import { verifyWebhookSignature } from '@ultimat3/http'; | ||
| declare const env: { readonly PARTNER_WEBHOOK_SECRET: string }; // the app's defineEnv() result | ||
| // The seen-set and the dispatch are the app's — this package has nowhere to keep either. | ||
| declare function alreadyHandled(eventId: string): Promise<boolean>; | ||
| declare function handle(topic: string, payload: unknown, eventId: string): Promise<void>; | ||
| export async function POST(request: Request): Promise<Response> { | ||
| const { eventId, topic, body } = await verifyWebhookSignature(request, { | ||
| secret: env.PARTNER_WEBHOOK_SECRET, | ||
| }); | ||
| // Parse the bytes that were SIGNED. Never `request.json()` — the stream is spent, and a | ||
| // re-serialisation would not be the bytes the mac covers. | ||
| const payload: unknown = JSON.parse(body); | ||
| if (await alreadyHandled(eventId)) return new Response(null, { status: 200 }); | ||
| await handle(topic, payload, eventId); | ||
| return new Response(null, { status: 202 }); | ||
| } | ||
| ``` | ||
| | Property | How | | ||
| |---|---| | ||
| | constant time | the mac is compared with `@ultimat3/core`'s `timingSafeEqual`, never `===` — where two macs first differ is exactly what a timing oracle forges one byte at a time | | ||
| | a replay expires | the signature's timestamp is checked against `toleranceMs` (5 minutes by default), **both** directions — a sender whose clock runs ahead is the same window pointed the other way | | ||
| | a replay is detectable | `eventId` is signed and returned, so it cannot be moved in transit; the seen-set is your table, because the framework has nowhere to keep one | | ||
| | moving the timestamp breaks it | the timestamp is inside the canonical string, so editing `t=` on a captured request invalidates the mac | | ||
| | the raw bytes are what is verified | the body is read through core's counting reader — the same one `UltimateRequest` uses — so a sender that declares no length cannot make this handler hold an unbounded payload | | ||
| | the mac is checked BEFORE the window | `X_WEBHOOK_SIGNATURE_STALE` means *authentic and old*, never *unreadable and old*, so an operator reading it goes to a clock or a replay | | ||
| `X_WEBHOOK_SIGNATURE_INVALID` and `X_WEBHOOK_SIGNATURE_STALE` are both **401**: the request is | ||
| well formed and carried a credential, and the credential is what failed. Neither triggers the | ||
| sign-in redirect, which keys on `X_UNAUTHENTICATED` alone. | ||
| The sending half is `webhook()` in `@ultimat3/jobs`. Neither package may import the other, so the | ||
| canonical string is stated in both and pinned by one literal vector asserted in both test files. | ||
| ## Errors | ||
@@ -255,3 +311,4 @@ | ||
| · `X_FORBIDDEN` · `X_RATE_LIMITED` · `X_BUILD_SKEW` · `X_ROUTE_CONFLICT` | ||
| · `X_CORS_CONFIG_INVALID` · `X_RATE_LIMIT_NOT_SHARED` | ||
| · `X_CORS_CONFIG_INVALID` · `X_RATE_LIMIT_NOT_SHARED` · `X_WEBHOOK_SIGNATURE_INVALID` | ||
| · `X_WEBHOOK_SIGNATURE_STALE` | ||
@@ -258,0 +315,0 @@ One `factsOf()` feeds three renderings — terminal, `application/problem+json`, dev |
+58
-34
@@ -9,2 +9,3 @@ // The per-request context. It is created by the pipeline before any user code runs | ||
| type Ctx, | ||
| createContext, | ||
| isAnonymous, | ||
@@ -14,3 +15,2 @@ type Logger, | ||
| type Role, | ||
| logger as rootLogger, | ||
| type ServiceBag, | ||
@@ -33,11 +33,19 @@ systemClock, | ||
| /** | ||
| * The per-request context, and — through `asCtx` — core's `Ctx` itself. Every member `Ctx` | ||
| * declares is declared here and SET by `createRequestContext`, because `asCtx` used to be | ||
| * `as unknown as Ctx` over an object missing five of them (`clock`, `now`, `logger`, `signal`, | ||
| * `services`). The assertion type-checked and every reader threw at runtime: `ctx.now()` in | ||
| * `@ultimat3/action`'s audit trail, `useService()`, `throwIfAborted()`. The cast is gone, so | ||
| * a member core adds is a build error in this file until it is set. The `extends` is what makes | ||
| * that true rather than aspirational — and it carries `CtxServices`' index signature, which is | ||
| * what an app augments for `ctx.posts`; `noPropertyAccessFromIndexSignature` keeps `ctx.typo` a | ||
| * build error all the same. | ||
| * The per-request context, and — through `asCtx` — core's `Ctx` itself. | ||
| * | ||
| * `extends Ctx` again, `As of 2026-08-24`, and it is safe again for one reason: this file no | ||
| * longer BUILDS a `Ctx`, it composes one. `Ctx extends CtxServices`, an app augments | ||
| * `CtxServices` with `declare module` to declare `ctx.posts`, and every service it declared then | ||
| * became a required member of every context literal in the framework — this file failed to | ||
| * compile inside `examples/dummy` with `TS2739: missing posts, orgs` while the framework's own | ||
| * gate, which augments nothing, stayed green. `createRequestContext` now spreads | ||
| * `createContext()`'s result, so the members only an app's boot can supply arrive with it and the | ||
| * literal below is checked in full. | ||
| * | ||
| * The `extends` is therefore back to doing what it was always claimed to do: a member core adds | ||
| * to `Ctx` is set here — by `base` — or this file does not compile. `asCtx` is the identity | ||
| * function and never an assertion; `as unknown as Ctx` is what it used to be, over an object | ||
| * missing `clock`, `now`, `logger`, `signal` and `services`, and every reader threw at runtime. | ||
| * `CtxServices`' index signature is what an app augments; `noPropertyAccessFromIndexSignature` | ||
| * keeps `ctx.typo` a build error all the same. | ||
| */ | ||
@@ -151,8 +159,2 @@ export interface RequestContext extends Ctx { | ||
| /** | ||
| * One signal for every context built without one, so "no cancellation here" costs no allocation | ||
| * and `ctx.signal.aborted` is still a read rather than a `TypeError`. The same shape core uses. | ||
| */ | ||
| const NEVER_ABORTED: AbortSignal = new AbortController().signal; | ||
| export const createRequestContext = (init: RequestContextInit): RequestContext => { | ||
@@ -165,3 +167,43 @@ const clock = init.clock ?? systemClock; | ||
| const traceId = init.traceId ?? newTraceId(); | ||
| // COMPOSED from core's constructor rather than built beside it, and that is what deletes the | ||
| // last cast in this file. `createContext` returns a `Ctx` that already carries the app's | ||
| // `CtxServices` augmentation, so spreading it hands this literal the members only the app's boot | ||
| // could supply — and the return below is checked in full, with nothing asserted anywhere. | ||
| // | ||
| // It is also one constructor for one shape instead of two. This file used to re-derive `clock`, | ||
| // `now`, the logger child, `signal`, `deadlineAt` and the service bag itself, so core could fix | ||
| // any of them and the HTTP surface would keep the old answer — which is exactly what happened to | ||
| // the bag: core has spread services ONTO the context since it shipped and this file never did, | ||
| // so an app declaring `ctx.posts` the documented way read `undefined` over HTTP while | ||
| // `ctx.services.posts` beside it was populated. Composing makes that class of drift unwritable. | ||
| // | ||
| // `defineService` factories now install on this surface too, for the same reason: they are | ||
| // `createContext`'s and this is `createContext`. | ||
| const base = createContext({ | ||
| requestId, | ||
| traceId, | ||
| role: init.role, | ||
| // The build this PROCESS serves. The client's claim goes to `clientBuildId` below, where only | ||
| // `assertBuild()` reads it — the two shared this name until `asCtx` was checked. | ||
| buildId: init.config.buildId ?? 'dev', | ||
| // What the request gets before the `locale` stage runs, and what it keeps if the stage is | ||
| // never reached (a refusal in `admit`). The owners' configured fallbacks, never a third one. | ||
| locale: localeConfig().fallback, | ||
| tz: timeConfig().defaultZone, | ||
| clock, | ||
| ...(init.logger === undefined ? {} : { logger: init.logger }), | ||
| // Absent means a request nothing can cancel, which is core's `neverAborted` — the same shape | ||
| // this file kept its own singleton for. | ||
| ...(init.signal === undefined ? {} : { signal: init.signal }), | ||
| // `null` and "not set" are one fact to core, whose own field is `number | null`. | ||
| ...(init.deadlineAt === undefined || init.deadlineAt === null | ||
| ? {} | ||
| : { deadlineAt: init.deadlineAt }), | ||
| ...(init.services === undefined ? {} : { services: init.services }), | ||
| }); | ||
| return { | ||
| ...base, | ||
| // Everything below is either this package's own or a core member the PIPELINE rewrites: the | ||
| // mutable slots are re-declared here so a stage can write them, and they must therefore be | ||
| // this object's own properties rather than the frozen base's. | ||
| requestId, | ||
@@ -173,3 +215,2 @@ traceId, | ||
| method: init.method.toUpperCase(), | ||
| role: init.role, | ||
| config: init.config, | ||
@@ -181,22 +222,5 @@ ip: init.ip ?? null, | ||
| requestHeaders: new Headers(init.requestHeaders), | ||
| // The build this PROCESS serves, resolved the way core resolves it. The client's claim goes | ||
| // to `clientBuildId` below, where only `assertBuild()` reads it. | ||
| buildId: init.config.buildId ?? 'dev', | ||
| clock, | ||
| now: () => clock.now(), | ||
| // A child, so `ctx.logger` carries the ids even where core's ALS injector cannot see the | ||
| // context — a callback that outlived the request scope, a logger passed to a driver. | ||
| logger: (init.logger ?? rootLogger).child({ requestId, traceId }), | ||
| signal: init.signal ?? NEVER_ABORTED, | ||
| deadlineAt: init.deadlineAt ?? null, | ||
| // Frozen and explicit. `defineService` factories are NOT installed here: core does not | ||
| // export the installer, so the honest answer for a service nothing passed is | ||
| // `X_SERVICE_MISSING` from `useService()` — which is what it exists to raise — rather than | ||
| // the `TypeError: undefined is not an object` a missing bag produced. | ||
| services: Object.freeze({ ...(init.services ?? {}) }), | ||
| params: {}, | ||
| route: undefined, | ||
| actor: anonymousActor(), | ||
| // What the request gets before the `locale` stage runs, and what it keeps if the stage is | ||
| // never reached (a refusal in `admit`). The owners' configured fallbacks, never a third one. | ||
| locale: localeConfig().fallback, | ||
@@ -203,0 +227,0 @@ tz: timeConfig().defaultZone, |
+93
-0
@@ -6,2 +6,3 @@ // Every RENDERING of a throwable the framework has: the normalised facts, the RFC-9457 problem | ||
| import { ERROR_DOCS_URL, renderCauseValue, singleLine, stringField } from '@ultimat3/core'; | ||
| import type { ValidationIssue } from '@ultimat3/schema'; | ||
| import { declaredStatusFor, statusFor } from './error-map'; | ||
@@ -107,2 +108,78 @@ import { HTTP_ERROR_TITLES } from './errors'; | ||
| /** | ||
| * A list this long is not a form's worth of rejections; it is a body meant to be expensive. The | ||
| * same bound `@ultimat3/action`'s `issuesFromWire` applies on arrival, restated because that | ||
| * package is tier 3 and this one is tier 2 — `error-facts.test.ts` pins the number on this side. | ||
| */ | ||
| const MAX_PROBLEM_ISSUES = 100; | ||
| /** | ||
| * The rejections a validation failure carried, addressed by path — or `undefined`. | ||
| * | ||
| * `@ultimat3/action` attaches the list to `meta.issues` (`InputInvalidError`'s third parameter), | ||
| * and until this reader existed nothing carried it across the wire: a client rendering a form | ||
| * recovered per-field errors by splitting `cause` on `'; '`, which is guesswork the moment a | ||
| * message contains the separator. | ||
| * | ||
| * Total, for `retryAfterOf`'s reason directly above: `meta` is a property read on a value this | ||
| * package did not build, in the frame that decides what the caller sees. | ||
| * | ||
| * ALL-OR-NOTHING, and that is the load-bearing rule. A client that finds `issues` uses it INSTEAD | ||
| * of `cause`, so a partly-read list is a rejection the user never sees and a form that reports | ||
| * itself valid when it is not. One unreadable entry drops the whole list back to the prose line. | ||
| * | ||
| * Every entry is rebuilt MEMBER BY MEMBER and `received` is forced empty — never a spread. Not | ||
| * redundancy with `toValidationIssues`, which forces the same thing today: this is the boundary | ||
| * where the value leaves the process, and a future producer of `meta.issues` need not have gone | ||
| * through that helper. A conforming library's own issue object is first-class in this framework | ||
| * and routinely carries the rejected VALUE; `packages/schema/src/describe-value.ts` exists because | ||
| * a password-strength rule once wrote mistyped passwords into the log index. | ||
| * | ||
| * Module-private, unlike `retryAfterOf`: that one has a second caller (`stages.ts` writes it onto | ||
| * the header) and this one has exactly one, `toProblem`. An exported reader nobody outside calls | ||
| * is a public API that promises support it has never been asked for. | ||
| */ | ||
| function issuesOf(error: unknown): readonly ValidationIssue[] | undefined { | ||
| if (typeof error !== 'object' || error === null) return undefined; | ||
| try { | ||
| const meta: unknown = (error as Record<string, unknown>)['meta']; | ||
| if (typeof meta !== 'object' || meta === null) return undefined; | ||
| const raw: unknown = (meta as Record<string, unknown>)['issues']; | ||
| // EMPTY is `undefined`, never `[]`. `Array.isArray([])` is true and the loop below would | ||
| // simply not run, so an empty list reached the document as `issues: []` — which tells a client | ||
| // "we validated and found nothing wrong" about a request that was just refused. | ||
| // | ||
| // TOO LONG is `undefined` too, and dropped WHOLE rather than truncated: a subset is the | ||
| // silent-drop this reader refuses everywhere else. The typed client bounds the same list at | ||
| // `MAX_WIRE_ISSUES` and would refuse it on arrival anyway (`packages/action/src/wire-issues.ts`), | ||
| // so sending it is a body that costs the wire and answers nothing — and that package is tier 3, | ||
| // so the number is restated here rather than imported. | ||
| if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_PROBLEM_ISSUES) { | ||
| return undefined; | ||
| } | ||
| const issues: ValidationIssue[] = []; | ||
| for (const entry of raw as readonly unknown[]) { | ||
| if (typeof entry !== 'object' || entry === null) return undefined; | ||
| const fields = entry as Record<string, unknown>; | ||
| const path: unknown = fields['path']; | ||
| const message: unknown = fields['message']; | ||
| const expected: unknown = fields['expected']; | ||
| // `path` and `message` are what a form binding addresses a control by and what it renders; | ||
| // an entry missing either is not usable, and a usable subset beside an unusable one is the | ||
| // silent-drop this list refuses. | ||
| if (typeof path !== 'string' || typeof message !== 'string') return undefined; | ||
| issues.push({ | ||
| path, | ||
| expected: typeof expected === 'string' ? expected : message, | ||
| // Forced, never copied. See the paragraph above. | ||
| received: '', | ||
| message, | ||
| }); | ||
| } | ||
| return issues; | ||
| } catch { | ||
| return undefined; | ||
| } | ||
| } | ||
| /** | ||
| * RFC-9457 `type`, per code. A URN, and deliberately not a URL: `type` is the document's PRIMARY | ||
@@ -132,2 +209,12 @@ * identifier for the problem KIND — a client switches on it — while `docs` is where a human goes | ||
| readonly requestId: string | undefined; | ||
| /** | ||
| * The rejections, addressed by path, for a failure that produced them. TOP-LEVEL and not an | ||
| * extension bag: RFC 9457 §3.2 puts extension members at the document root, and every Ultimate | ||
| * extension already is one (`code`, `cause`, `fix`, `docs`, `requestId`). | ||
| * | ||
| * ABSENT when there are none — never `undefined`, never `[]`. `JSON.stringify` drops an | ||
| * `undefined` member, but this interface is read directly by `error-page.ts` and by tests, and | ||
| * `[]` says "validated clean", which is a different and false claim. | ||
| */ | ||
| readonly issues?: readonly ValidationIssue[] | undefined; | ||
| } | ||
@@ -171,2 +258,7 @@ | ||
| const opaque = meta.dev !== true && isUnclassifiedFailure(facts.code, facts.status); | ||
| // Dropped under EXACTLY the condition that blanks `title`, `detail` and `cause`. An issue list | ||
| // on a failure nobody classified is precisely the internal detail `INTERNAL_CAUSE` exists to | ||
| // withhold — it names the fields and the expectations of something the caller was never meant to | ||
| // see the inside of. `X_INPUT_INVALID` is a declared 4xx, so it is never opaque. | ||
| const issues = opaque ? undefined : issuesOf(error); | ||
| return { | ||
@@ -183,2 +275,3 @@ type: problemTypeFor(facts.code), | ||
| requestId: meta.requestId, | ||
| ...(issues === undefined ? {} : { issues }), | ||
| }; | ||
@@ -185,0 +278,0 @@ }; |
+88
-0
@@ -72,2 +72,11 @@ // The one place a framework error code becomes an HTTP status. A table, not a | ||
| X_CSRF_BLOCKED: 403, | ||
| // 401 for both, and never 400: an inbound webhook is well formed and carries a CREDENTIAL — a | ||
| // timestamped hmac over its own bytes — so what failed is authentication, not the request. Never | ||
| // 403 either, which means an authenticated caller was refused, and there is no authenticated | ||
| // caller here. Two codes rather than one because the repairs differ and a sender's dashboard | ||
| // shows the status: `INVALID` is the wrong secret or a rewritten body, `STALE` is a skewed clock | ||
| // or a delivery being replayed off a capture. Neither triggers `signInRedirect`, which keys on | ||
| // `X_UNAUTHENTICATED` alone — a webhook sender is not a browser and has no session to go get. | ||
| X_WEBHOOK_SIGNATURE_INVALID: 401, | ||
| X_WEBHOOK_SIGNATURE_STALE: 401, | ||
| // @ultimat3/action — the code every primitive throws when the CALLER's input fails the schema | ||
@@ -161,2 +170,25 @@ // the primitive declared. 400 because that is what the published OpenAPI operation promises for | ||
| X_APPROXIMATE_COUNT_FILTERED: 500, | ||
| // The two search refusals, 500 for the reason the three above are: nothing the caller sends | ||
| // changes either answer. An entity with no searchable column needs a `searchable()` on one, and | ||
| // a driver that cannot answer a full-text match needs the Postgres one — both are edits to the | ||
| // app, and both carry a `fix:` that an unmapped 5xx would blank (`toProblem` replaces an | ||
| // undeclared code's cause with `INTERNAL_CAUSE`). | ||
| X_SEARCH_UNDECLARED: 500, | ||
| X_SEARCH_IN_MEMORY: 500, | ||
| // The three state-machine refusals, and they are deliberately THREE statuses rather than one: | ||
| // the machine says the transition does not exist, the row says it is somewhere else, or the | ||
| // column says there is no machine at all — three different readers and three different repairs. | ||
| // | ||
| // 422 and not 400: the request is well formed and its schema passed. The transition the caller | ||
| // named is not one this machine has, which is the same shape as `X_INVARIANT_VIOLATED` above and | ||
| // takes its status. Refused before any statement opens a connection, so nothing was written. | ||
| X_STATE_TRANSITION_ILLEGAL: 422, | ||
| // 409, the lost update caught. The row moved between the read the caller decided on and the | ||
| // write it asked for — nothing is wrong with either, and the repair is re-read and retry, which | ||
| // is precisely what a 409 tells a client to do. A 422 would say "your request is unusable", | ||
| // which is false: the identical request succeeds a moment later. | ||
| X_STATE_CONFLICT: 409, | ||
| // 500, the same shelf as `X_SEARCH_UNDECLARED`: a column with no machine is a declaration the | ||
| // app has not written, and no request changes that. | ||
| X_STATE_UNDECLARED: 500, | ||
| // @ultimat3/db — the constraints a request trips, both 409. db's own `fix:` for the unique | ||
@@ -181,2 +213,42 @@ // violation says "answer 409, which is what a raced signup is", and `X_ENTITY_DUPLICATE` — the | ||
| X_ACTION_JOB_UNBRIDGED: 500, | ||
| // Every `X_WEBHOOK_*` below is OUTBOUND and is thrown inside a worker: `ROLE=worker` opens no | ||
| // HTTP port, so none of them ever answers a request. The rows exist for the reason | ||
| // `X_ACTION_JOB_UNBRIDGED`'s does — this table is the closed one, and a code with no row is a | ||
| // 500 anyway. The INBOUND pair (`X_WEBHOOK_SIGNATURE_*`, 401) is @ultimat3/http's and sits with | ||
| // the rest of this package's codes above; these are the ones a delivery ends on. | ||
| X_WEBHOOK_ENDPOINT_UNKNOWN: 500, | ||
| X_WEBHOOK_ENDPOINT_INVALID: 500, | ||
| X_WEBHOOK_ENDPOINT_DISABLED: 500, | ||
| X_WEBHOOK_EVENT_UNKNOWN: 500, | ||
| X_WEBHOOK_EVENT_INVALID: 500, | ||
| X_WEBHOOK_DELIVERY_FAILED: 500, | ||
| X_WEBHOOK_DELIVERY_THROTTLED: 500, | ||
| X_WEBHOOK_DELIVERY_REJECTED: 500, | ||
| // Same class again: an export pass runs in a worker, and both codes refuse the DECLARATION — | ||
| // a `row()` that answers columns nobody declared, and a page too big to hold. Neither is | ||
| // anything a caller sent. | ||
| X_EXPORT_ROW_INVALID: 500, | ||
| X_EXPORT_PART_TOO_LARGE: 500, | ||
| // @ultimat3/notify — five 500s and one 502, and the split is who failed. | ||
| // | ||
| // The five are the app's own declaration: a notifier with no channels, one channel named twice, | ||
| // a digest window on a bulk channel, a store nothing installed, and a fan-out past the per-run | ||
| // ceiling. Every `fix:` on those five names a code edit or a boot call, so nothing a caller | ||
| // sends changes any of them — `X_NOTIFY_FANOUT_TOO_WIDE` is the only one a request can even | ||
| // INFLUENCE (an action that notifies a whole org), and the repair is still `bulkChannel()` or a | ||
| // paged `backfill()`, never the request. | ||
| X_NOTIFY_CHANNELS_EMPTY: 500, | ||
| X_NOTIFY_CHANNEL_DUPLICATE: 500, | ||
| X_NOTIFY_FANOUT_TOO_WIDE: 500, | ||
| X_NOTIFY_STORE_MISSING: 500, | ||
| X_NOTIFY_DIGEST_UNSUPPORTED: 500, | ||
| // 502, and it is the one row on this table that answers for somebody else's server. This code | ||
| // WRAPS a provider rejection — `NotifyDeliveryFailedError` takes the caught value and renders it | ||
| // — so the thing that failed is the channel's upstream, not this process. It is thrown inside a | ||
| // job step today (`x jobs show <notifier> --json` is its own `fix:`), so nothing reaches a | ||
| // request and the number is unobservable either way; the row is chosen for the day that stops | ||
| // being true, and the asymmetry decides it. A wrong 502 costs nothing. A wrong 500 pages the | ||
| // on-call for an email provider's outage, because `stages.ts` reports every `status >= 500` to | ||
| // the error monitor — which is the failure this whole table exists to stop. | ||
| X_NOTIFY_DELIVERY_FAILED: 502, | ||
| // @ultimat3/policy | ||
@@ -235,2 +307,18 @@ X_POLICY_MISSING: 500, | ||
| X_STORAGE_ORG_MISMATCH: 404, | ||
| // @ultimat3/ui — a form control whose `name` is not a usable field path. The owning slice argued | ||
| // for NO ROW, on the grounds that this is a render-time developer error that can never reach | ||
| // HTTP, and the argument is right about the code and wrong about the table. | ||
| // | ||
| // `scripts/error-map-backlog.ts` is the only "no row" this table has, and its own header says | ||
| // what an entry there means: "NOT a claim that the code can never cross HTTP … a claim that | ||
| // nobody has decided yet", with the ratchet promising only that the undecided set never grows. | ||
| // This code HAS been decided, so a pin would record the opposite of what is known and grow the | ||
| // one list that may not grow. | ||
| // | ||
| // So it takes the answer every other decided-and-unreachable code takes — `X_CORS_CONFIG_INVALID`, | ||
| // `X_ACTION_JOB_UNBRIDGED`, `X_RATE_LIMIT_NOT_SHARED`. The row is NOT a claim that it reaches a | ||
| // request. A code with no row already answers 500 (`DEFAULT_STATUS`); the row changes nothing at | ||
| // runtime and makes that answer a reviewed one instead of an accident, which is the whole reason | ||
| // this table is closed. | ||
| X_UI_FORM_PATH_INVALID: 500, | ||
| // @ultimat3/mail | ||
@@ -237,0 +325,0 @@ // The deployment configured no transport. It reaches a caller only through an inline |
+38
-0
@@ -37,2 +37,4 @@ // The HTTP layer's stable error codes. Every throw in this package goes through a | ||
| 'X_CSRF_BLOCKED', | ||
| 'X_WEBHOOK_SIGNATURE_INVALID', | ||
| 'X_WEBHOOK_SIGNATURE_STALE', | ||
| ] as const; | ||
@@ -92,2 +94,4 @@ | ||
| X_CSRF_BLOCKED: 'a credentialed write arrived from an origin that is not allowed to make it', | ||
| X_WEBHOOK_SIGNATURE_INVALID: 'the inbound webhook is not signed by the holder of this secret', | ||
| X_WEBHOOK_SIGNATURE_STALE: 'the inbound webhook is signed correctly and is too old to accept', | ||
| }; | ||
@@ -384,1 +388,35 @@ | ||
| }); | ||
| /** | ||
| * The inbound delivery is not signed by the holder of this route's secret — a wrong secret, a | ||
| * body something rewrote in transit, or a header this format does not define. | ||
| * | ||
| * 401 rather than 400: the request is well formed and carried a CREDENTIAL, and the credential is | ||
| * what failed. Rather than 403, which means an authenticated caller was refused, and there is no | ||
| * authenticated caller here. `reason` names only what the framework chose — never the signature | ||
| * that arrived, never the secret, and never the body — because a `cause` reaches both the caller | ||
| * and the log store, and a credential in either is a leak wearing a diagnostic's clothes. | ||
| */ | ||
| export const webhookSignatureInvalid = (pathname: string, reason: string): HttpError => | ||
| new HttpError({ | ||
| code: 'X_WEBHOOK_SIGNATURE_INVALID', | ||
| cause: `${pathname} refused an inbound webhook: ${reason}`, | ||
| fix: 'sign the delivery with the secret this endpoint was registered under, or re-read the secret from your sender dashboard and pass it as verifyWebhookSignature(request, { secret })', | ||
| }); | ||
| /** | ||
| * Signed correctly, and outside the replay window. Its own code because the repair is a different | ||
| * one: a sender's clock, or a delivery being replayed off a capture. Same 401 — the credential is | ||
| * a TIMESTAMPED one, and this is the expiry half of it. | ||
| */ | ||
| export const webhookSignatureStale = ( | ||
| pathname: string, | ||
| skewMs: number, | ||
| toleranceMs: number, | ||
| ): HttpError => | ||
| new HttpError({ | ||
| code: 'X_WEBHOOK_SIGNATURE_STALE', | ||
| cause: `${pathname} received a valid signature ${skewMs}ms from this clock, and the window is ${toleranceMs}ms`, | ||
| fix: 'sync the sending host clock with NTP, or widen the window with verifyWebhookSignature(request, { secret, toleranceMs: 600_000 }) if the sender queues deliveries for longer than that', | ||
| meta: { skewMs, toleranceMs }, | ||
| }); |
+18
-0
@@ -5,2 +5,12 @@ // The public surface of @ultimat3/http. Explicit, never `export *`: what is not | ||
| export type { RenderMode } from '@ultimat3/core'; | ||
| // The wire format is `@ultimat3/core`'s and is RE-EXPORTED, never re-declared: it is one module at | ||
| // the tier both halves can reach, because `@ultimat3/jobs` signs a delivery, this package verifies | ||
| // one, and neither may import the other. Re-exported here so a receiver route needs one import. | ||
| export { | ||
| isCanonicalWebhookField, | ||
| WEBHOOK_ID_HEADER, | ||
| WEBHOOK_SIGNATURE_HEADER, | ||
| WEBHOOK_SIGNATURE_VERSION, | ||
| WEBHOOK_TOPIC_HEADER, | ||
| } from '@ultimat3/core'; | ||
| export type { AppHttpConfig, BootOwnedHttpKey } from './app-config'; | ||
@@ -81,2 +91,4 @@ export { configuredHttp, configureHttp, mergeHttpConfig, resetHttpConfig } from './app-config'; | ||
| unauthenticated, | ||
| webhookSignatureInvalid, | ||
| webhookSignatureStale, | ||
| } from './errors'; | ||
@@ -197,1 +209,7 @@ export type { ForwardedInput, ForwardedSplit } from './forwarded'; | ||
| export { formatIssue, validate, validateSync } from './validate'; | ||
| export type { VerifiedWebhook, WebhookVerifyOptions } from './webhook-verify'; | ||
| export { | ||
| DEFAULT_WEBHOOK_BODY_LIMIT, | ||
| DEFAULT_WEBHOOK_TOLERANCE_MS, | ||
| verifyWebhookSignature, | ||
| } from './webhook-verify'; |
+15
-0
@@ -6,3 +6,5 @@ // Compile-time pins for the shapes this package declares but never constructs. Source, not a | ||
| import type { Ctx } from '@ultimat3/core'; | ||
| import type { HttpConfig, HttpConfigInput } from './config'; | ||
| import type { RequestContext } from './context'; | ||
| import type { AuthzDecision } from './hooks'; | ||
@@ -74,1 +76,14 @@ | ||
| // file only pins what a derivation cannot say. | ||
| /** | ||
| * `RequestContext` IS a `Ctx`, so `asCtx` stays a checked widening rather than an assertion. | ||
| * | ||
| * `asCtx` already carries this claim at its own call site and this pin is not a duplicate of it: | ||
| * `asCtx` is a function body, and a future edit answering a failure there with a cast would delete | ||
| * the enforcement and leave the comment. A pin has nothing to cast. | ||
| * | ||
| * The direction that matters is this one and not the reverse — `Ctx extends RequestContext` is | ||
| * FALSE by design, because core's `Ctx` carries no `requestHeaders`, which is precisely what | ||
| * `assertInRequest` exists to prove one way at runtime. | ||
| */ | ||
| export type _RequestContextIsACtx = Assert<RequestContext extends Ctx ? true : false>; |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
378291
10.09%42
2.44%5963
7.07%327
21.11%+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
Updated
Updated
Updated
Updated