From 862bb46b777254f3b563a51c9b0ece8dec5e069d Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh <78609166+Wahbeh-Mohammad@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:32:30 +0300 Subject: [PATCH 1/4] =?UTF-8?q?Phase=205a=20=E2=80=94=20retry=20engine,=20?= =?UTF-8?q?its=20two=20adapters,=20and=20the=20shared=20FakeTransport=20(#?= =?UTF-8?q?40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(core): phase 5a — the retry engine, its two adapters, and the shared FakeTransport. Ships the retry pillar per product-spec/09-retry-and-resilience.md (RETRY-1..RETRY-45) and appendix C's RECOV-17..RECOV-34, following docs/superpowers/specs/2026-07-26-phase5a-retry-design.md. Per-requirement disposition in docs/superpowers/plans/2026-07-26-phase5a-retry-checklist.md. Executed out of numeric order, and 7a's first three tasks come with it. 5a's plan Prerequisite consumes 7a's config/{clock,http-date,retryable}.ts — Task 8 needs the Clock seam, Task 4 the shared RFC 1123 parser, Task 2 the retryable-status set — and its Global Constraints ban shipping private copies. Those three files are therefore built here verbatim from 7a's plan Tasks 1-3 (CFG-15..17, CFG-29..31, CFG-35). 7a's Tasks 4-10 are untouched, and none of the three enters the public barrel; 7a Task 10 still owns that decision. New packages/core/src/retry/, eight files, no folder barrel: - classify.ts — the two orthogonal axes (RETRY-1..8, 37). Retryability is an allow-list over an iterative, identity-tracking cause walk, which is what makes RETRY-25's fatal-error exclusion vacuous rather than coded: an unlisted throwable was never opted in. RETRY-23 vs RETRY-24 keys off the abort reason's name — AbortSignal.timeout() produces TimeoutError, a caller abort AbortError — which draws the line more precisely than the class hierarchy the reference describes. - backoff.ts, pacing.ts — the pure math and the server-hint parser. Totality is pacing.ts's defining property (RETRY-16): it never throws, and every failure path returns null, never 0, because 0 means "retry immediately" and is the opposite of what a server sending a malformed header is asking for. - settings.ts — RETRY-12's defaults, RECOV-34's construction validation, and totalTimeoutMs opt-in per RETRY-28's instruction to a unifying port. - engine.ts — one attempt loop, reached by both adapters, so RETRY-13/14 and RECOV-30's "must not drift" is structural rather than a discipline. - attempt-stamp.ts, retry-step.ts, retry-dispatch.ts — per-attempt stamping and the two ~30-line adapters. retryStep() closes PIPE-36 structurally: it is a factory returning a descriptor with stage: 'RETRY' baked in, so there is no class to subclass and no way to relocate it out of its pillar. Plus recovery/idempotency-key.ts (RECOV-32) and testing/fake-transport.ts, closing the roadmap's twice-punted FakeTransport deferral. countingResponse() counts release by both routes it can happen — cancel() for an abandoned response, pull()-to-EOF for one toHttpError() drained — because a helper counting cancel() alone reads zero on exactly the RETRY-35 path it exists to prove. Response instances are frozen, so the body stream is the only sanctioned observation point. StepContext gains signal and options (Task 1, additive). Cursor already carried both and threaded them into terminal dispatch, but no step could read either: RETRY-26's cancellable wait and RETRY-32 were unimplementable without the signal, and PIPE-17's "readable by any step" MUST was unsatisfied outright without the options — which is also the wire RETRY-41's per-call maxRetries override (HTTP-35) had been missing since Phase 1 designed the knob. Two dispositions worth reading before changing this code. RETRY-36's remap applies only to responses the engine discards: a response surviving the gates is returned live and unread, because toHttpError() drains the body and drops the headers irreversibly, and 4c's pillar signature must return a Response. And RETRY-41's "clamp a negative retry count to the default" is implemented as a rejection — it collides head-on with HTTP-35, also a MUST, which rejects precisely so the value cannot be silently reinterpreted; the port takes HTTP-35's line on both surfaces. Both are in the design's deviation ledger. Also tightens RequestOptionsBuilder.maxRetries to require a non-negative integer (changeset included). It rejected only value < 0, so Infinity and NaN reached a consumer as a retry ceiling that never terminates: unlike a negative value, which still fails a downstream >= 1 guard, a non-finite one makes "attempt >= ceiling" permanently false and the loop unbounded. Guarded at three layers — the setter, the step's per-call derivation, and a precondition in runWithRetry, the one choke point both adapters share. Not included, each recorded rather than left silent: RETRY-29 (MAY, unscheduled — it widens the classifier's input to server-controlled values and wants its own trust decision), RECOV-33 (Phase 7a Task 9), the two structured log events and RETRY-40's log-the-failure clause (Phase 7b Task 9 — 5a runs before 7b, and 7b needs this commit's FakeTransport, so the cycle only breaks in this direction), and public-barrel promotion of the step-authoring surface (Phase 5c, once the preset exists). open-items.md carries the review findings deliberately left open, including the same teardown-masking shape in Phase 3b's toHttpError. Nothing reaches the public barrel: packages/core/etc/core.api.md and packages/core/src/index.ts are byte-identical. 867 unit tests, plus a node-conformance case for the three runtime-divergent surfaces this phase touches — the TimeoutError naming the classifier keys off, the suppressed-trail shape across the native/fallback split, and the real timer/abort race inside defaultClock.sleep. Full gate sequence green. * fix(core): escape the TSDoc '>' that fails the API surface check. --- .../2026-08-26-max-retries-range-check.md | 14 + .../2026-07-26-phase5a-retry-checklist.md | 154 ++++ ...2026-07-23-nodejs-sdk-v1-roadmap-design.md | 14 + open-items.md | 182 ++++ packages/core/src/config/clock.test.ts | 77 ++ packages/core/src/config/clock.ts | 59 ++ packages/core/src/config/http-date.test.ts | 97 +++ packages/core/src/config/http-date.ts | 93 ++ packages/core/src/config/retryable.test.ts | 37 + packages/core/src/config/retryable.ts | 34 + .../core/src/http/request-options.test.ts | 22 + packages/core/src/http/request-options.ts | 19 +- packages/core/src/pipeline/cursor.test.ts | 129 +++ packages/core/src/pipeline/cursor.ts | 13 +- packages/core/src/pipeline/step.ts | 17 +- .../core/src/recovery/idempotency-key.test.ts | 111 +++ packages/core/src/recovery/idempotency-key.ts | 61 ++ packages/core/src/retry/attempt-stamp.test.ts | 59 ++ packages/core/src/retry/attempt-stamp.ts | 34 + packages/core/src/retry/backoff.test.ts | 115 +++ packages/core/src/retry/backoff.ts | 77 ++ packages/core/src/retry/classify.test.ts | 224 +++++ packages/core/src/retry/classify.ts | 97 +++ packages/core/src/retry/engine.test.ts | 798 ++++++++++++++++++ packages/core/src/retry/engine.ts | 415 +++++++++ packages/core/src/retry/pacing.test.ts | 243 ++++++ packages/core/src/retry/pacing.ts | 93 ++ .../core/src/retry/retry-dispatch.test.ts | 106 +++ packages/core/src/retry/retry-dispatch.ts | 61 ++ packages/core/src/retry/retry-step.test.ts | 193 +++++ packages/core/src/retry/retry-step.ts | 127 +++ packages/core/src/retry/settings.test.ts | 98 +++ packages/core/src/retry/settings.ts | 108 +++ .../core/src/testing/fake-transport.test.ts | 109 +++ packages/core/src/testing/fake-transport.ts | 141 ++++ test/node-conformance/retry.test.mjs | 221 +++++ 36 files changed, 4440 insertions(+), 12 deletions(-) create mode 100644 .changeset/2026-08-26-max-retries-range-check.md create mode 100644 docs/superpowers/plans/2026-07-26-phase5a-retry-checklist.md create mode 100644 open-items.md create mode 100644 packages/core/src/config/clock.test.ts create mode 100644 packages/core/src/config/clock.ts create mode 100644 packages/core/src/config/http-date.test.ts create mode 100644 packages/core/src/config/http-date.ts create mode 100644 packages/core/src/config/retryable.test.ts create mode 100644 packages/core/src/config/retryable.ts create mode 100644 packages/core/src/recovery/idempotency-key.test.ts create mode 100644 packages/core/src/recovery/idempotency-key.ts create mode 100644 packages/core/src/retry/attempt-stamp.test.ts create mode 100644 packages/core/src/retry/attempt-stamp.ts create mode 100644 packages/core/src/retry/backoff.test.ts create mode 100644 packages/core/src/retry/backoff.ts create mode 100644 packages/core/src/retry/classify.test.ts create mode 100644 packages/core/src/retry/classify.ts create mode 100644 packages/core/src/retry/engine.test.ts create mode 100644 packages/core/src/retry/engine.ts create mode 100644 packages/core/src/retry/pacing.test.ts create mode 100644 packages/core/src/retry/pacing.ts create mode 100644 packages/core/src/retry/retry-dispatch.test.ts create mode 100644 packages/core/src/retry/retry-dispatch.ts create mode 100644 packages/core/src/retry/retry-step.test.ts create mode 100644 packages/core/src/retry/retry-step.ts create mode 100644 packages/core/src/retry/settings.test.ts create mode 100644 packages/core/src/retry/settings.ts create mode 100644 packages/core/src/testing/fake-transport.test.ts create mode 100644 packages/core/src/testing/fake-transport.ts create mode 100644 test/node-conformance/retry.test.mjs diff --git a/.changeset/2026-08-26-max-retries-range-check.md b/.changeset/2026-08-26-max-retries-range-check.md new file mode 100644 index 0000000..76d0272 --- /dev/null +++ b/.changeset/2026-08-26-max-retries-range-check.md @@ -0,0 +1,14 @@ +--- +"@dexpace/core": patch +--- + +Tighten `RequestOptionsBuilder.maxRetries` validation: a defined value must now be a non-negative +integer. `Infinity`, `NaN`, and fractional values were previously accepted and now throw +`RequestOptionsValidationError`, the same way a negative value already did. + +A retry ceiling is a count of wire sends, so a non-finite one is as out of range as a negative one — +and worse in effect: a negative value still fails a downstream `>= 1` guard, while `Infinity` or +`NaN` makes a retry driver's `attempt >= ceiling` test permanently false and its loop unbounded. +HTTP-35's requirement is that an out-of-range retry count is a loud error at the call site that +supplied it, never a value reinterpreted somewhere downstream; this closes the half of that +requirement the setter did not implement. diff --git a/docs/superpowers/plans/2026-07-26-phase5a-retry-checklist.md b/docs/superpowers/plans/2026-07-26-phase5a-retry-checklist.md new file mode 100644 index 0000000..85596da --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-phase5a-retry-checklist.md @@ -0,0 +1,154 @@ +# Phase 5a — Retry Implementation Plan — Checklist + +Verification of [2026-07-26-phase5a-retry.md](./2026-07-26-phase5a-retry.md) against every requirement ID in +`docs/product-spec/09-retry-and-resilience.md` and appendix C's `RECOV-17`–`RECOV-34`, as dispositioned by +`docs/superpowers/specs/2026-07-26-phase5a-retry-design.md`. + +**Status: EXECUTED (2026-08-26).** Every task below is implemented, tested, and green across the full gate +sequence (`typecheck`, `lint`, `build`, `bun test` with coverage, `api:ci`, `lint:publish`, +`verify:dual-consumption`, `verify:consumer-types`, `verify:seam-1`, `verify:runtime-floor`, `test:node`, +`audit`). `packages/core/etc/core.api.md` and `packages/core/src/index.ts` are byte-identical to `main` — +nothing in this phase reaches the public barrel. + +**Legend:** ✅ Implemented and tested — 🚫 Not built (permanent simplification, named reason) — ⏳ Deferred +(named target phase) — N/A Not applicable in this port. + +## Executed out of numeric order: the Phase 7a prerequisite slice + +This plan's Prerequisite section requires Phase 7a's `config/` module to exist first — its Task 8 consumes the +`Clock` seam, its Task 4 imports the shared RFC 1123 parser, and its Task 2 re-exports the shared +retryable-status classifier. `packages/core/src/config/` did not exist. Rather than ship the private copies the +plan's Global Constraints ban, the three files 7a's plan specifies were built first, verbatim from +[2026-07-28-phase7a-configuration.md](./2026-07-28-phase7a-configuration.md) Tasks 1–3, with their tests: + +| File | Requirements | From | +|---|---|---| +| `packages/core/src/config/clock.ts` | `CFG-15`, `CFG-16`, `CFG-17` | 7a Task 1 | +| `packages/core/src/config/http-date.ts` | `CFG-29`, `CFG-30`, `CFG-31` | 7a Task 2 | +| `packages/core/src/config/retryable.ts` | `CFG-35` | 7a Task 3 | + +Phase 7a's own execution should mark these three tasks done rather than rebuild them; its Tasks 4–10 +(`identifiers`, `equality`, `configuration`, `proxy`, build-info, `client-identity-step`, barrel promotion) are +untouched here. These three are **not** promoted to the public barrel by this phase — 7a's Task 10 owns that +decision. + +## 9.1 The two independent axes + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| RETRY-1 | MUST | Single-sourced retryable status set — 408, 429, 5xx except 501/505 | ✅ | Task 2, re-exported from `config/retryable.ts` (`CFG-35`) rather than defined twice | +| RETRY-2 | MUST | Retryable-throwable classification walks the cause chain | ✅ | Task 2 — iterative, identity-tracking walk; a cyclic `cause` chain terminates instead of spinning, asserted directly | +| RETRY-3 | MUST | Retryability derived from the carried status, not a stored per-subclass flag | ✅ | Task 2 — `HttpStatusError.status` is consulted at classification time; there is no constant to get wrong | +| RETRY-4 | MUST | Transport-level failure (refused, TLS/DNS, socket read timeout, peer reset) retryable at the condition level | ✅ | Task 2 — such failures surface as `IoError` subclasses, which the allow-list admits unconditionally | +| RETRY-5 | MUST | Body-bearing request re-sendable iff its body is replayable | ✅ | Task 2 (`isResendable`), over Phase 3b's `Body.replayable` | +| RETRY-6 | MUST | Idempotent method set is `{GET, HEAD, OPTIONS, PUT, DELETE}`, single-sourced | ✅ | Task 2 imports Phase 1's `http/method.ts` `isIdempotent` (`HTTP-9`); nothing is restated | +| RETRY-7 | MUST | A bare non-idempotent POST is not re-sendable even with nothing to re-send | ✅ | Task 2, asserted; re-asserted end-to-end on both entry points (Tasks 8, 10) | +| RETRY-8 | MUST | BOTH axes must hold before a retry | ✅ | Task 2 (the two predicates), Task 8 (`decideRetry` gates them in order) | +| RETRY-37 | MUST | For a failure carrying a response the CONFIGURED status set is authoritative alone — widens and narrows | ✅ | Task 2 — `isRetryableFailure`'s second parameter; both directions asserted, and the built-in flag is not AND-ed in | + +## 9.2 Backoff and pacing + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| RETRY-9 | MUST | `initialDelay × multiplier^(attempt−1)`, 1-indexed, clamped to the cap | ✅ | Task 3, plus a `fast-check` monotonicity/cap property | +| RETRY-10 | MUST | Symmetric jitter over `[d(1−j/2), d(1+j/2)]`, midpoint `d`, `j=0` the identity | ✅ | Task 3 — window asserted exactly at both ends and by property test; a negative sample floors to zero | +| RETRY-11 | MUST | `attempt < 1` rejected; overflow saturates rather than throwing | ✅ | Task 3 — `invariant()` for the programmer error; `Math.min` absorbs `Infinity` into the cap | +| RETRY-12 | MUST | Defaults: 200 ms, ×2, 8 s cap, 20% jitter, 3 attempts | ✅ | Task 5 (`DEFAULT_RETRY_SETTINGS`) | +| RETRY-43 | MAY | Fixed-delay mode short-circuits backoff AND jitter | ✅ | Task 3 — a `MAY`, but `RETRY-39`'s MUST precedence chain names it as a step, so it is load-bearing | +| RETRY-15 | MUST | Recognized pacing forms: `Retry-After` seconds, `Retry-After` HTTP-date, `retry-after-ms`, `x-ms-retry-after-ms`, `X-RateLimit-Reset` | ✅ | Task 4 | +| RETRY-16 | MUST | The parser is TOTAL: never throws, every failure path returns "no hint" (`null`), never `0` | ✅ | Task 4 — asserted by `fast-check` over arbitrary strings, and separately that the result is `null` or a finite non-negative number | +| RETRY-17 | MUST | A validly-parsed instant already in the past yields `0` | ✅ | Task 4 (both the HTTP-date and `X-RateLimit-Reset` forms) | +| RETRY-18 | MUST | Every computed delta clamps to a 365-day ceiling | ✅ | Task 4 | +| RETRY-19 | MUST | Strict decimal grammar screens the numeric form before any float parse | ✅ | Task 4 — `30d`, `0x1p3`, `NaN`, `Infinity`, `1e3`, `+30`, and surrounding whitespace all rejected | +| RETRY-20 | MUST | A hint REPLACES the schedule for that one decision, unjittered, still budget-clamped | ✅ | Task 8 (`resolveDelay`), asserted end-to-end | +| RETRY-21 | MUST | Fixed precedence, first usable value wins | ✅ | Task 4 — including the fall-through from an unparseable `Retry-After` to `retry-after-ms` | +| RETRY-22 | MUST | A pacing-parse failure never masks the upstream failure | ✅ | Structural — the parser is total, so the original throwable is what the trail carries regardless; asserted in Task 8 | +| RETRY-13 | MUST | One backoff/classifier definition, no second copy | ✅ | Structural under ES modules — one `computeDelay`, one `parsePacingHint`, one status set; both adapters call the same `runWithRetry` | +| RETRY-14 | MUST | Both stacks' budgets denote the same number of sends | ✅ | Structural — there is one budget (`RetrySettings.maxAttempts`), so there is nothing to reconcile. `runWithRetry` asserts it is finite and `>= 1` once per call: a non-finite budget does not fail loudly on its own, it makes the attempt gate permanently false and the loop simply never stops, and this is the single choke point both adapters pass through | + +## 9.3 Cancellation, timeout, and the wait + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| RETRY-23 | MUST | Caller cancellation is never retryable | ✅ | Task 2 — keyed off the abort reason's `name`; `CancellationError` is outside the allow-list, asserted so a future re-parenting under `IoError` breaks loudly. The reference's "restore the interruption flag" half is N/A: `AbortSignal` is latched and observable by every later reader without re-assertion | +| RETRY-24 | MUST | A read timeout represented as an interrupted-I/O subtype stays retryable | ✅ | Task 2 — `AbortSignal.timeout()` aborts with a `DOMException` named `TimeoutError`; asserted bare and wrapped as a `cause`, and again under Node in `test/node-conformance/retry.test.mjs` | +| RETRY-25 | MUST | Never retry fatal errors (`OutOfMemoryError`, `StackOverflowError`) | N/A | Vacuous by construction — the classifier is an allow-list, so an unlisted throwable was never opted in. V8 has no catchable OOM class. Asserted anyway for a stack-overflow `RangeError` and a bare string throw | +| RETRY-26 | MUST | Cancellable inter-attempt wait that does not pin an execution carrier | ✅ | Task 8 (`waitFor`) — delegates to Phase 7a's `Clock.sleep` (`CFG-17`) rather than hand-rolling a second timer-versus-signal race, so the wait sits behind the injected seam and the unit suite stays deterministic. Node has no carriers to pin, so the substance is prompt cancellability (`XCUT-3`): asserted for an abort raised before the wait, for one raised while the wait is pending, and — against a REAL timer — in `test/node-conformance/retry.test.mjs` | +| RETRY-31 | MUST | The wait is non-blocking; a zero delay does not schedule a timer | ✅ | Task 8 — `await` on a timer yields the event loop; `delayMs <= 0` continues inline without reaching the clock at all, asserted by counting `sleep` invocations. The same guard keeps a caller `delayOverride` returning a negative number away from `Clock.sleep`'s negative-duration rejection | +| RETRY-32 | MUST | No further attempts once the caller has cancelled; a response arriving from an already-in-flight attempt closed rather than leaked | ✅ | Task 8 (the loop's first statement), Task 9 (asserted through the pipeline: zero wire sends). Second clause asserted both ways: a response arriving after the abort that the engine **discards** is released (`cancelCount` 1), while one that **ends the loop** is handed to the caller live and unclosed — ownership transfers rather than leaking, since the caller is the only reader that could close it. The design doc's blanket "any response arriving from an in-flight attempt is closed" describes only the first case | +| RETRY-33 | MUST | Every terminal path returns an outcome | ✅ | Task 8 — honored literally, not merely as a rejected promise. `stampAttempt`'s header build, `toHttpError`'s body drain, and a misbehaving injected clock's `sleep` can each throw; all three are folded into a failure outcome **carrying the trail**, because letting one escape as a bare rejection would silently discard every prior attempt `RETRY-34` requires to ride along. Asserted | +| RETRY-45 | MUST | Never shut down a caller-supplied scheduler | N/A | No scheduler object exists to own. The intent survives as `clearTimeout` hygiene on both wait exits, so no dangling timer keeps the event loop alive | + +## 9.4 Budgets, reconciliation, and the discard path + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| RETRY-27 | MUST | Total-timeout budget spanning attempts and delays; three independent abort conditions | ✅ | Task 8 — `budgetExhausted` (elapsed ≥ budget), `overshootsBudget` (elapsed + next delay > budget, which SURFACES rather than merely clamping), and `clampToBudget`. Both the abort and the clamp ship; `0` and `undefined` disable | +| RETRY-28 | MUST | A port that unifies the stacks makes the total timeout explicitly opt-in | ✅ | Task 5 — `totalTimeoutMs` is optional and undefined by default | +| RETRY-34 | MUST | On terminal failure every prior attempt's error rides along as suppressed; discarded on success; skip-self guard | ✅ | Task 8 (`withTrail`) — built through Phase 4b's `suppress()`, never `new SuppressedError(...)` (the native class reached Node only in 24.0.0; the floor is `>=20.3`). A reused instance never suppresses itself, asserted; the ≥3-attempt nested fold is asserted oldest-innermost | +| RETRY-35 | MUST | A discarded response's body is released, including when the retry decision throws | ✅ | Task 8 — the `finally` in `retireAndSchedule`; observed through `countingResponse`'s stream, never a spy on a frozen `Response` | +| RETRY-36 | MUST | A re-sent retryable-status response is remapped into a typed failure so the loop keeps evaluating the budget | ✅ (narrowed, ledgered) | Task 8 — the remap applies **only to responses the engine is discarding**. Gates run first; a response that survives them is returned live and unread. `toHttpError()` drains the body and drops the headers irreversibly, and 4c's pillar signature must return a `Response`. Full reasoning in the design doc; recorded in its deviation ledger | +| RETRY-30 | MUST | N retries must not build an N-deep continuation or stack chain | N/A | An `await` loop is already iterative — each iteration's frame is released before the next begins. No trampoline, re-arm flag, or pump is built. Same disposition class as 4c's `PIPE-29`/`PIPE-30` | + +## 9.5 Knobs, stamping, and re-drive + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| RETRY-38 | SHOULD | Stamp the 1-based attempt ordinal on a fresh per-attempt copy, preserving every other header | ✅ | Task 7 — disabled by default, in which case the original instance is returned and nothing is allocated | +| RETRY-39 | MUST | Delay precedence: caller override → server pacing → fixed delay → exponential backoff | ✅ | Task 8 (`resolveDelay`); the exception path skips the header step, having no headers | +| RETRY-40 | MUST | A throwing user delay-override is non-fatal; a throwing should-retry predicate aborts the call | ✅ (override half) / N/A (predicate half) | Task 8 — the override throw is caught and the schedule used instead, asserted. The predicate half is unreachable: 5a exposes no user should-retry predicate, so there is no caller code on that path to throw. The "log it" clause is Phase 7b's Task 9 (see Cross-phase below). Ledgered | +| RETRY-41 | MUST | Effective retry count is present-override-wins; zero means no retries; a negative configured value is clamped to the default | ✅ (override) / 🚫 (clamp — rejected instead, ledgered) | Task 9 reads `ctx.options?.maxRetries` and runs the engine with `maxAttempts = maxRetries + 1`, asserted both narrowing (`0` → one send) and widening (`2` → three sends). The per-call value is **revalidated** at the step: `RequestOptionsBuilder` rejects only a negative, which is weaker than `retrySettings()`'s `Number.isFinite(...) && >= 1`, so `Infinity`/`NaN` would otherwise reach `maxAttempts` and make the engine's attempt gate permanently false — an unbounded retry loop reachable from the public options API. The clamp collides head-on with `HTTP-35` (also MUST), which REJECTS a negative max-retries at construction precisely so it cannot be silently reinterpreted as "use default". The port takes `HTTP-35`'s line on both surfaces — the builder rejects the option, `retrySettings()` trips `invariant()` on a negative `maxAttempts` | +| RETRY-42 | MUST | Settings and every policy component immutable, stateless, and safe for concurrent invocation | ✅ | Task 5 (frozen settings, defensively copied status set), Task 8 (attempt count and start instant are locals, asserted by two concurrent `runWithRetry` calls over one settings object), Task 9 (the per-call derivation re-freezes rather than handing back a bare spread of a frozen source) | +| RETRY-44 | MUST | Re-execute the downstream chain with FRESH per-attempt continuation state | ✅ | Task 9 — `ctx.fork()` once per attempt, 4c's mechanism's first consumer. Task 10 is the recovery-side mirror: each attempt re-runs the whole chain, asserted by counting request-chain applications. The second clause (upstream steps must not mutate the shared in-flight request) is free — `Request` is immutable and frozen | +| RETRY-29 | MAY | Opt-in server-driven retry-classification override header | ⏳ | Not scheduled. Widens the classifier's input surface to server-controlled values; wants an explicit trust decision, not a default. Deferred Items Log | + +## Appendix C — `RECOV-17`–`RECOV-34` + +Appendix C files eighteen `RECOV-*` rows under "Recovery-chain pipeline primitives" that +`08-execution-pipelines.md` §8.2 never defines in prose — they are retry-engine requirements stated a second +time for the reference's second retry stack. This port collapses both stacks into one engine (`RETRY-28`), so +they collapse onto the same implementation. Phase 9's conformance sweep should read this table rather than +re-deriving it. + +| Appendix C | `§9` equivalent | Status | Where | +|---|---|---|---| +| RECOV-17 | `RETRY-1`, `RETRY-4`, `RETRY-8`, `RETRY-37` | ✅ | Task 2 (`classify.ts`); reached on this entry point by Task 10 | +| RECOV-18 | `RETRY-5`, `RETRY-6`, `RETRY-7` | ✅ | Task 2 | +| RECOV-19 | `RETRY-36` | ✅ (narrowed as above) | Task 8 | +| RECOV-20 | `RETRY-27` | ✅ | Task 8 — both the abort and the clamp | +| RECOV-21 | `RETRY-9`, `RETRY-10`, `RETRY-11` | ✅ | Task 3 — the same formula verbatim | +| RECOV-22 | `RETRY-20` | ✅ | Task 8 | +| RECOV-23 | `RETRY-16`, `RETRY-17` | ✅ | Task 4 — totality is the property test | +| RECOV-24 | `RETRY-15`, `RETRY-19`, `RETRY-21` | ✅ | Task 4 | +| RECOV-25 | `RETRY-15` (`X-RateLimit-Reset` clause) | ✅ | Task 4 — positive jitter bounded to `[100%, 120%]` INSIDE the parser, so many clients released at one reset instant do not stampede. A literal `Retry-After` receives no additional jitter (`RETRY-20`) | +| RECOV-26 | `RETRY-11`, `RETRY-18` | ✅ | Tasks 3, 4 | +| RECOV-27 | `RETRY-23`, `RETRY-26` | ✅ | Task 8 | +| RECOV-28 | `RETRY-42` | ✅ | Task 8 — per-call locals, asserted concurrently | +| RECOV-29 | `RETRY-22` | ✅ | Structural (total parser), asserted in Task 8 | +| RECOV-30 | `RETRY-13`, `RETRY-14` | ✅ | Structural here — one engine, no second stack to drift from. Both adapters (Tasks 9, 10) call the same `runWithRetry` | +| RECOV-31 | `RETRY-38` | ✅ | Task 7 | +| RECOV-32 | **none** (net-new) | ✅ | Task 11 — `recovery/idempotency-key.ts`. Method-gated (default `{POST, PUT, PATCH}`, defensively copied), respect-existing by default with the strategy **not** invoked in that case, strategy invoked at most once per applicable request, never mutating the input | +| RECOV-33 | **none** (net-new) | ⏳ | Phase 7a Task 9. Client-identity header stamping has no retry coupling; it is configuration-driven, so it travels with `CFG-*` | +| RECOV-34 | partial (`RETRY-11`, `RETRY-41`) | ✅ (settings validation) / 🚫 (configurable retryable-method set) | Task 5 — construction validation rejects negative or non-finite durations, `multiplier < 1.0`, `maxAttempts < 1`, and `jitter` outside `[0,1]`; the status set is a defensive copy. **No configurable retryable-METHOD set ships**: `RETRY-6`/`HTTP-9` fix the idempotent set and make Phase 1's `method.ts` its single source, so there is nothing per-instance to copy and no requirement obliges configurability. Ledgered | + +## Cross-phase obligations + +| Obligation | Status | Where | +|---|---|---| +| `PIPE-36` — a shipped pillar family locks its stage assignment | ✅ | Task 9 — satisfied structurally: `retryStep()` is a factory returning a descriptor with `stage: 'RETRY'` baked in. There is no class to subclass and no way for a caller to relocate it. Deferred out of 4c to "whichever future phase ships the first real pillar step family" — that is this one | +| `PIPE-17` — the caller's per-call options readable by any step | ✅ | Task 1 — `StepContext.options`, populated from the cursor's existing field, shared by reference across every fork. Previously threaded only into the terminal dispatch, leaving the clause unsatisfied outright | +| `StepContext.signal` — the 4c amendment | ✅ | Task 1 — additive and optional; no behavior change for any step that ignores it. `RETRY-26`'s cancellable wait and `RETRY-32`'s no-further-attempts rule are both unimplementable without it, and 5b/5c need the same access | +| 2026-07-28 Phase 7a retrofit (`Clock`, RFC 1123 parser, retryable-status single-sourcing) | ✅ | Applied — the three `config/` files exist and are imported, not duplicated. See the prerequisite-slice table above | +| 2026-07-28 Phase 7b retrofit (two `SHOULD`-level structured log events in `engine.ts`) | ⏳ Phase 7b Task 9 | **Deliberately NOT applied here**, per this plan's own 2026-07-29 correction: 5a executes before 7b, so an `observability/logger.js` import would not resolve, and 7b needs 5a's `FakeTransport`, so the dependency cannot run the other way. `engine.ts` carries a comment at its head marking both emission points and naming 7b's Task 9 as their owner. `RETRY-40`'s "log and fall back" is the same row — the fall-back half ships here, the log half there | +| `FakeTransport` — the twice-punted shared double | ✅ | Task 6 — `packages/core/src/testing/fake-transport.ts`, `@internal`. Scripted response sequences (last entry repeats), wire-send counting, and `countingResponse()`, the only sanctioned way to observe `Response.close()`: `Response` is frozen, so a spy over `close` throws. The counter observes release by BOTH routes — `cancel()` for an abandoned response, `pull()`-to-EOF for one the engine retired through `toHttpError()`'s bounded drain — because a helper counting `cancel()` alone would read zero on exactly the `RETRY-35` path it exists to prove | +| Node-runtime conformance (`CLAUDE.md`'s membership rule) | ✅ | `test/node-conformance/retry.test.mjs` — the `TimeoutError`-name classification that `RETRY-24` keys off (asserted against a real `AbortSignal.timeout()` with a ref'd deadline), the suppressed-trail shape across the native/fallback split, and release-on-discard over Node's own Web Streams | +| Public barrel unchanged | ✅ | Task 12 — `git diff --exit-code` on `core.api.md` and `index.ts` is empty. 4c left "do we publish a step-authoring surface" to the first phase shipping a pillar step; this phase answers **not yet**, because a caller cannot assemble a working pipeline until 5c ships the standard-resilience preset, and publishing `retryStep` alone would freeze `StepDescriptor`/`Stage`/`PipelineBuilder` shapes 5c may still reshape | + +## Deferred out of Phase 5a + +| Item | Target | Reason | +|---|---|---| +| `RETRY-29` — opt-in server-driven retry-classification override | Not scheduled | `MAY`. Widens the classifier's input surface to server-controlled values; wants an explicit trust decision, not a default | +| `RECOV-33` — client-identity header step | Phase 7a (Task 9) | Configuration-driven header composition with no retry coupling; belongs with `CFG-*` | +| Public-barrel promotion of `retryStep` and the step-authoring surface | Phase 5c | Needs the standard-resilience preset (`PIPE-24`, `PIPE-39`) and `PIPE-35`'s `seedFrom`, which need all three pillar steps installed | +| The two structured retry log events (`retry.attemptFailed`, `retry.exhausted`) | Phase 7b (Task 9) | Cycle-breaking: 5a cannot import `observability/`, 7b needs 5a's `FakeTransport` | diff --git a/docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md b/docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md index c6fabf7..ef7e45e 100644 --- a/docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md +++ b/docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md @@ -165,6 +165,9 @@ permanent simplification, not a postponement. | `RETRY-29` — opt-in server-driven retry-classification override header | Phase 5a brainstorm | Not scheduled | `MAY`. Lets a response header force or suppress the retry classification. Widens the classifier's input surface to server-controlled values, which is a trust decision deserving its own deliberation rather than a default. No caller identified | | `RECOV-33` — client-identity header step (Append/Replace token composition, blank-line suppression) | Phase 5a brainstorm | **Resolved in Phase 7a (design)** | One of only two appendix-C `RECOV-17`–`RECOV-34` rows with no `§9` `RETRY-*` twin (the other, `RECOV-32`'s idempotency key, shipped in 5a because retry preserves it per `RETRY-38`). Pure configuration-driven header composition with zero retry coupling, so it travels with `CFG-*` in 7a, ships as `clientIdentityStep()` consuming `CFG-36`'s build/runtime descriptor, and closes `NFR-15` alongside it. Lands when 7a's plan executes | | `StepContext.signal` **and** `StepContext.options` — exposing the call's `AbortSignal` and per-call `RequestOptions` to steps | Phase 5a brainstorm (`signal`); 2026-07-28 plans review (`options`) | **Phase 5a, Task 1** | Found during 5a's spec self-review: 4c's `Cursor` accepts and threads a `signal` but `StepContext` never exposed it, so no step could observe cancellation — `RETRY-26`'s cancellable wait and `RETRY-32`'s "no attempts after cancellation" were both unimplementable. A 2026-07-28 review found the identical gap for `options`: `Cursor` threads them to terminal dispatch but `PIPE-17`'s "readable by any step" MUST was unsatisfied, and with it `RETRY-41`'s per-call override (`RequestOptions.maxRetries`, `HTTP-35`'s "0 disables retries for this call") had no wire — Phase 1 designed the knob, nothing read it. Both fields land as one additive amendment in 5a Task 1; 5a Task 9 wires the retry override, 5c Task 14 wires the per-call auth descriptor. **2026-07-29:** 4c's own design and plan now record the `PIPE-17` half as a deferral naming 5a Task 1, so the MUST is no longer deferred silently (4c validation review, F1); 4c's plan also forbids adding the two fields early, since their shape belongs to their first reader | +| `RequestOptionsBuilder.maxRetries` accepts `Infinity`, `NaN`, and fractional values | Phase 5a code review (2026-08-26) | **Phase 10 (Deviation Reconciliation)** — or a Phase 1 fix with a changeset | `HTTP-35`'s stated intent is that an out-of-range retry count is a loud error, never silently reinterpreted, and the builder implements only the `< 0` half. `Number.isFinite`/integer are unchecked, so `maxRetries: Infinity` reaches a consumer as a budget that never terminates. Phase 5a found it because its per-call override feeds `maxAttempts` directly; 5a closed its own exposure at both ends (`retryStep`'s `effectiveSettings` and a precondition in `runWithRetry`), but the **builder** still accepts the value, so any future reader of the option inherits the trap. Tightening a public setter changes observable API behavior and needs a changeset, so it is recorded rather than folded into 5a | +| The two structured retry log events (`retry.attemptFailed`, `retry.exhausted`) and `RETRY-40`'s "log the failure" clause | Phase 5a execution (2026-08-26) | **Phase 7b, Task 9** | 5a's plan specifies all three emission points but its own 2026-07-29 correction forbids writing them: 5a executes before 7b, so an `observability/logger.js` import would not resolve, and 7b needs 5a's `FakeTransport`, so the dependency cannot run the other way. `engine.ts` carries a head comment marking the sites and naming 7b Task 9 as owner. `RETRY-40`'s non-fatal fall-back half **is** implemented in 5a; only the diagnostic half waits | +| Phase 7a Tasks 1-3 (`config/{clock,http-date,retryable}.ts`) executed early, as 5a's prerequisite | Phase 5a execution (2026-08-26) | **Executed — 7a's plan should mark Tasks 1-3 done, not rebuild them** | 5a's plan Prerequisite requires 7a's `config/` module to exist first (Task 8 consumes `Clock`, Task 4 imports `parseHttpDate`, Task 2 re-exports `isRetryableStatus`), and its Global Constraints ban shipping private copies. The three files were built verbatim from [7a's plan](../plans/2026-07-28-phase7a-configuration.md) Tasks 1-3 with their tests (22 tests, `CFG-15`-`CFG-17`, `CFG-29`-`CFG-31`, `CFG-35`). 7a's Tasks 4-10 are untouched, and none of the three is promoted to the public barrel — 7a Task 10 still owns that decision | | `SEAM-30` cleanup (cancel an orphaned response on the completion race) | Phase 2 | **Phase 8a** | Documented as a TSDoc contract obligation on `Transport.send()` in Phase 2; only a real Transport implementation has a response to actually cancel. Collapses onto `TRANSPORT-9` (and `ASYNC-5`, which collapses onto the same thing) per the [Phase 8 segmentation design](./2026-07-28-phase8-segmentation-design.md) §5.1 — closes as part of 8a's conformance suite, not separate work | | Byte-stream provider implementation (`ByteQueue`, `BufferedSource`/`Sink`, `TeeSink`) | Discussed in Phase 2 (`sdk-design/03` §3.1), built in | **Phase 3a** | `sdk-design-nodejs/03` covers this in the same document as Phase 2's other seams — the roadmap's phase split puts the *contract* in Phase 2 and the *implementation* in Phase 3a; don't conflate the two | | Every buffering **cap** — `BODY-19`'s configurable tap cap, `BODY-30`/`HTTP-52`'s 1 MiB error-body cap, `BODY-34`'s shared preview-size configuration | Phase 3a | **Resolved in Phase 3b (design)** | Deliberate placement, not an omission — §5 bounds nothing; every spec-mandated cap sits in §6, and 3b's design wires all three: the `withRequestLogging` tee's `tapCapBytes` (`BODY-19`), `toHttpError()`'s fixed 1 MiB error-body cap (`BODY-30`/`HTTP-52`), and one shared preview-size parameter threaded through both logging tees and `toHttpError` (`BODY-34`). The rejected `maxRetainedBytes`-on-`BufferedSource` reasoning stands — don't re-litigate. Lands when 3b's plan executes | @@ -251,6 +254,17 @@ as inapplicable to the whole port, not merely out of 8b's scope — a correction framing, recorded in 8b's design §3 and not requiring a Deferred Items Log row of its own since nothing was ever targeted at a phase to begin with. +**Status note (2026-08-26, Phase 5a EXECUTED).** Phase 5a is implemented and green across the full gate +sequence — the first phase to run out of numeric order, per the execution-order note above. Closed by this +execution: `PIPE-36`, `PIPE-17`'s "readable by any step" MUST (via `StepContext.options`), +`StepContext.signal`, the `FakeTransport` double, `RECOV-32`, and the `RECOV-17`-`RECOV-34` reconciliation — +each row above already anticipated 5a and is now satisfied in code rather than only at design level. Two new +rows were added: the Phase 7b log-event deferral, and the record that 7a's Tasks 1-3 were executed early as +5a's prerequisite. Still deferred out of 5a: `RETRY-29` (not scheduled), `RECOV-33` (7a Task 9), and +public-barrel promotion of the step-authoring surface (5c) — `packages/core/etc/core.api.md` is byte-identical +across the phase, which is that decision's mechanical proof. Per-requirement disposition: +[2026-07-26-phase5a-retry-checklist.md](../plans/2026-07-26-phase5a-retry-checklist.md). + **Status note (2026-07-28, Phase 9).** Phase 9 was brainstormed solo (user away from keyboard, `docs/knowledge/` as standing tie-breaker per standing precedent) and got a full design **and** a written implementation plan in one session: [design](./2026-07-28-phase9-cross-cutting-conformance-design.md) / diff --git a/open-items.md b/open-items.md new file mode 100644 index 0000000..2142aa0 --- /dev/null +++ b/open-items.md @@ -0,0 +1,182 @@ +# Open Items — Phase 5a (Retry) + +Findings from the Phase 5a code review (passes 1–3, 2026-08-26) that were **deliberately not fixed**, +each with the reason and the owner. Everything here is either correct-but-surprising behavior worth +pinning down, a limitation the platform imposes, or a defect whose fix belongs to another phase. + +Findings that *were* fixed are not listed — they are in the code and its tests. This file is only for +what is still open. + +**Status legend:** 🔴 defect, owner named — 🟡 accepted limitation — 🟢 correct, documented to stop a +future "fix" — 📄 documentation drift. + +--- + +## 🔴 `toHttpError`'s `finally` can let a teardown failure mask the drain failure + +**Where:** `packages/core/src/body/http-status-error.ts:106-109` (Phase 3b) + +```ts +} finally { + reader?.releaseLock(); + await response.close(); +} +``` + +`Response.close()` documents `@throws Whatever cancelling the body stream raises, other than the +TypeError a locked stream reports`. Awaiting it in a bare `finally` means a teardown failure replaces +whatever the `try` was propagating — the inversion `RECOV-12` forbids and `suppress()` exists to +prevent (`packages/core/src/suppress.ts` says so in its own doc comment, about native `using`). + +**Why it is not urgent:** cancelling an *errored* `ReadableStream` rejects with the stream's stored +error rather than invoking the source's `cancel` hook, so on the common path `close()` rethrows the +very error already propagating and the masking is unobservable. It becomes observable only for a +stream whose `cancel` hook fails independently of the read that failed. + +**Why it is not fixed here:** shipped Phase 3b code with its own tests, outside 5a's scope. Phase 5a +fixed the same shape at its own call site (`retry/engine.ts`'s `releaseQuietly` / +`withReleaseFailure`), which is what made the upstream instance visible. + +**Owner:** Phase 10 (Deviation Reconciliation), or a Phase 3b follow-up. + +--- + +## 🔴 `RequestOptionsBuilder.maxRetries` — fixed here, but the pattern deserves a sweep + +**Where:** `packages/core/src/http/request-options.ts` (Phase 1) + +Fixed in this phase (see `.changeset/2026-08-26-max-retries-range-check.md`): the setter rejected only +`value < 0`, so `Infinity`, `NaN`, and fractions reached a consumer as a retry budget that never +terminates. + +**What is still open:** the *class* of bug, not this instance. `timeoutMs` next door has the same +shape — it rejects `<= 0` and accepts `Infinity`/`NaN`. A non-finite timeout is less dangerous than a +non-finite retry ceiling (it degrades to "no deadline" rather than "never stop"), but it is the same +gap in the same requirement (`HTTP-35`), and no other numeric public setter has been audited. + +**Owner:** Phase 10, as a sweep over every public numeric setter — is the range check the full range, +or only its lower bound? + +--- + +## 🟡 `RetrySettings.retryableStatuses` is immutable by type, not at runtime + +**Where:** `packages/core/src/retry/settings.ts` + +`retrySettings()` returns `Object.freeze({...})`, but freeze is shallow and does not seal a `Set`'s +internal slots: anyone holding the settings object can still call `.add()` on the status set and +change policy for every later call. + +`RECOV-34`'s actual requirement — a *defensive copy* so a caller mutating **their own** source +collection cannot alter policy — is satisfied and tested. What is not achievable is `RETRY-42`'s +"immutable after construction" as a runtime guarantee. + +This is a deliberate house position, not an oversight: `config/retryable.ts` records it — *"`Object.freeze` +does not seal a `Set`'s internal slots, so a frozen `Set` would be a misleading no-op — typed +`ReadonlySet` instead, same treatment as Phase 1's `IDEMPOTENT_METHODS`."* A genuine runtime guarantee +would need a wrapper object with no mutators, which changes the shape every consumer reads. + +**Owner:** none. Recorded so the gap between the type-level and runtime guarantee is not rediscovered +as a bug. + +--- + +## 🟡 `RETRY-18`'s 365-day pacing ceiling is spec-mandated and operationally hazardous + +**Where:** `packages/core/src/retry/pacing.ts` + +A server that sends `X-RateLimit-Reset` in **milliseconds** instead of epoch seconds — a common +server-side mistake — produces a delta of roughly 56,000 years. `RETRY-18`/`RECOV-26` require +clamping to a 365-day ceiling, so the parser returns exactly that: a retry parked for a year, which +is indistinguishable from a hang. + +Nothing shortens it by default. `totalTimeoutMs` would, but `RETRY-28` makes it explicitly opt-in and +it is `undefined` by default. The caller's own `AbortSignal` is the only other exit. + +Implementing a tighter ceiling would be a deviation from a MUST, so the port complies. Recorded +because "spec-compliant" and "safe by default" diverge here, and the mitigation (set +`totalTimeoutMs`) is a caller decision that needs documenting when the retry surface is finally +published in Phase 5c. + +**Owner:** Phase 5c, as a documentation obligation on the public retry surface. + +--- + +## 🟡 `parsePacingHint` reads only the first value of a repeated header + +**Where:** `packages/core/src/retry/pacing.ts` + +`Headers.get()` returns the first value. Given `Retry-After: garbage` followed by `Retry-After: 5`, +the parser tries `garbage`, fails, falls through the remaining header names, and returns `null` — no +hint, fall back to backoff — rather than trying the second value. + +Safe (`RETRY-16`'s fallback is the conservative answer) and arguably correct, since a repeated +`Retry-After` is malformed to begin with. `RETRY-21`'s precedence is defined across header *names*, +not across duplicate values of one name, so nothing requires the second value to be tried. + +**Owner:** none. Recorded because "first usable value wins" reads, on a fast skim of `RETRY-21`, like +it should scan duplicates too. + +--- + +## 🟢 A fixed delay is deliberately not clamped to `maxDelayMs` + +**Where:** `packages/core/src/retry/backoff.ts` + +`computeDelay` returns `fixedDelayMs` before the cap is applied, so `fixedDelayMs: 3_600_000` with +`maxDelayMs: 8000` waits an hour. This looks like a missed clamp and is not: `RETRY-43` describes the +mode as *"zeroing the base and cap so only the fixed delay applies"* — the cap is part of the schedule +this mode replaces, not a bound that outlives it. + +Documented in the field's own TSDoc. Listed here so a future reviewer reaches the reasoning before +"fixing" it. + +--- + +## 🟢 A response that ends the retry loop is handed over live, not closed + +**Where:** `packages/core/src/retry/engine.ts` + +`RETRY-32` says *"any response that arrives from an already-in-flight attempt MUST be closed rather +than leaked."* The engine closes every response it **discards**. A response that survives the gates — +attempt cap reached, budget spent, status not retryable — is returned **live and unread**, even when +the caller has already aborted. + +That is not a leak: ownership transfers to the caller, which is the only reader that could close it, +and a `Promise` always resolves to its awaiter, so this port has no "value that can never be +delivered" case for the reference's orphan rule to bite on. Both halves are asserted. + +The narrowing is inseparable from `RETRY-36`'s disposition (`toHttpError` drains the body and drops +the headers irreversibly, and 4c's pillar signature must return a `Response`), which the phase design +already ledgers. + +--- + +## 📄 The Phase 5a design doc overstates the `RETRY-32` guarantee + +**Where:** `docs/superpowers/specs/2026-07-26-phase5a-retry-design.md`, "The wait" + +> `RETRY-32`: once the caller's signal is aborted the driver launches no further attempts, and any +> response arriving from an in-flight attempt is closed rather than leaked. + +The second clause describes only responses the engine discards — see the item above. The +implementation checklist carries the corrected wording; the design doc still carries the blanket +claim, and was left alone because it is a phase design of record, not a working document. + +**Owner:** Phase 9 (cross-cutting conformance), which reads these documents as its source. + +--- + +## 📄 Phase 7b still owes `engine.ts` two log events + +**Where:** `packages/core/src/retry/engine.ts` (head comment) + +`RETRY-40`'s "log the failure" clause and the two `SHOULD`-level structured events +(`retry.attemptFailed`, `retry.exhausted`) are specified in 5a's plan but written by Phase 7b Task 9 — +5a executes before 7b, and 7b depends on 5a's `FakeTransport`, so the cycle can only be broken in this +direction. The non-fatal half of `RETRY-40` **is** implemented here. + +Already recorded in the roadmap's Deferred Items Log; repeated here so this file is a complete picture +of what Phase 5a knowingly left undone. + +**Owner:** Phase 7b, Task 9. diff --git a/packages/core/src/config/clock.test.ts b/packages/core/src/config/clock.test.ts new file mode 100644 index 0000000..e547bbb --- /dev/null +++ b/packages/core/src/config/clock.test.ts @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/config/clock.test.ts +// Exercises: CFG-15 (three operations, shared default), CFG-16 (monotonic non-decreasing, meaningful +// only relative to itself), CFG-17 (sleep rejects negative, resolves promptly at zero, honors +// cancellation). +// Shipped ahead of Phase 7a as the prerequisite slice Phase 5a's plan names. +// docs/superpowers/plans/2026-07-28-phase7a-configuration.md Task 1 +import {describe, expect, test} from 'bun:test'; +import {defaultClock} from './clock.js'; + +/** + * Captures a rejection reason. `expect(...).rejects` is typed as returning `void` under this + * runner's type definitions, so awaiting it trips `@typescript-eslint/await-thenable`; this helper + * keeps the assertion honest without a lint suppression. + */ +async function rejectionOf(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error; + } + return undefined; +} + +describe('defaultClock', () => { + test('now() returns a plausible wall-clock epoch millisecond value', () => { + const before = Date.now(); + const value = defaultClock.now(); + const after = Date.now(); + expect(value).toBeGreaterThanOrEqual(before); + expect(value).toBeLessThanOrEqual(after); + }); + + test('monotonic() is non-decreasing across two readings', () => { + const first = defaultClock.monotonic(); + const second = defaultClock.monotonic(); + expect(second).toBeGreaterThanOrEqual(first); + }); + + test('sleep(0) resolves promptly', async () => { + const start = defaultClock.monotonic(); + await defaultClock.sleep(0); + expect(defaultClock.monotonic() - start).toBeLessThan(50); + }); + + test('sleep(negative) rejects', async () => { + expect(await rejectionOf(defaultClock.sleep(-1))).toBeDefined(); + }); + + test('sleep honors an already-aborted signal, rejecting with the abort reason', async () => { + const controller = new AbortController(); + controller.abort(new Error('cancelled')); + + const reason = await rejectionOf( + defaultClock.sleep(10_000, controller.signal), + ); + expect((reason as Error).message).toBe('cancelled'); + }); + + test('sleep honors cancellation mid-wait, resolving the race promptly rather than after the full delay', async () => { + const controller = new AbortController(); + const start = defaultClock.monotonic(); + const pending = defaultClock.sleep(60_000, controller.signal); + queueMicrotask(() => { + controller.abort(new Error('cancelled')); + }); + + expect(((await rejectionOf(pending)) as Error).message).toBe('cancelled'); + expect(defaultClock.monotonic() - start).toBeLessThan(50); + }); + + test('a real wait elapses at least the requested duration', async () => { + const start = defaultClock.monotonic(); + await defaultClock.sleep(20); + expect(defaultClock.monotonic() - start).toBeGreaterThanOrEqual(15); + }); +}); diff --git a/packages/core/src/config/clock.ts b/packages/core/src/config/clock.ts new file mode 100644 index 0000000..5fd4e7b --- /dev/null +++ b/packages/core/src/config/clock.ts @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/config/clock.ts + +/** + * CFG-15: an injectable seam for wall-clock instant, monotonic elapsed-time measurement, and a + * cancellable wait. One primitive for the JVM reference's blocking-sleep/scheduled-async-delay pair + * (CFG-15/17 vs. 18) -- Node has no carrier threads to distinguish "block this one" from "schedule + * that one" against, and every timer is already non-blocking. + * + * @internal + */ +export interface Clock { + /** Wall-clock epoch milliseconds. MAY move backwards; MUST NOT be used for elapsed-time math (CFG-16). */ + now(): number; + /** + * Monotonic elapsed-time counter (CFG-16). Absolute value is meaningless -- only differences + * between two readings are. + */ + monotonic(): number; + /** + * Resolves after `ms` milliseconds, or rejects with `signal`'s abort reason if it fires first + * (CFG-17). Rejects for a negative `ms`; resolves promptly (no timer scheduled) for `ms <= 0`. + * + * @param ms - the delay in milliseconds. + * @param signal - aborts the wait, rejecting with the signal's reason. + */ + sleep(ms: number, signal?: AbortSignal): Promise; +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + if (ms < 0) { + return Promise.reject( + new RangeError(`Clock.sleep: ms must be non-negative, got ${String(ms)}`), + ); + } + if (signal?.aborted === true) return Promise.reject(signal.reason as Error); + if (ms === 0) return Promise.resolve(); + + return new Promise((resolve, reject) => { + const settle = (): void => { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + resolve(); + }; + const onAbort = (): void => { + clearTimeout(timer); + reject(signal?.reason as Error); + }; + const timer = setTimeout(settle, ms); + signal?.addEventListener('abort', onAbort, {once: true}); + }); +} + +/** The platform-backed default (CFG-15's "a shared platform-backed default MUST be provided"). */ +export const defaultClock: Clock = { + now: () => Date.now(), + monotonic: () => globalThis.performance.now(), + sleep, +}; diff --git a/packages/core/src/config/http-date.test.ts b/packages/core/src/config/http-date.test.ts new file mode 100644 index 0000000..3647f7d --- /dev/null +++ b/packages/core/src/config/http-date.test.ts @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/config/http-date.test.ts +// Exercises: CFG-29 (canonical formatting), CFG-30 (tolerant parsing -- case-insensitive month, zone +// aliases, informational weekday), CFG-31 (strict on the rest -- blank input, missing comma both +// fail). Consumed by Phase 5a's pacing.ts for RETRY-15's HTTP-date form. +// docs/superpowers/plans/2026-07-28-phase7a-configuration.md Task 2 +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import {formatHttpDate, parseHttpDate} from './http-date.js'; + +describe('formatHttpDate', () => { + test('renders the canonical form with a zero-padded day, in UTC', () => { + const epochMs = Date.UTC(1994, 10, 6, 8, 49, 37); + expect(formatHttpDate(epochMs)).toBe('Sun, 06 Nov 1994 08:49:37 GMT'); + }); + + test('single-digit days are zero-padded', () => { + const epochMs = Date.UTC(2026, 0, 1, 0, 0, 0); + expect(formatHttpDate(epochMs)).toBe('Thu, 01 Jan 2026 00:00:00 GMT'); + }); +}); + +describe('parseHttpDate tolerance (CFG-30)', () => { + test('month names are case-insensitive', () => { + const canonical = parseHttpDate('Thu, 01 Jan 2026 00:00:10 GMT'); + expect(parseHttpDate('Thu, 01 JAN 2026 00:00:10 GMT')).toBe(canonical); + expect(parseHttpDate('Thu, 01 jan 2026 00:00:10 GMT')).toBe(canonical); + }); + + test('GMT, UTC, +0000, and +00:00 all normalize to the same instant', () => { + const gmt = parseHttpDate('Thu, 01 Jan 2026 00:00:10 GMT'); + expect(parseHttpDate('Thu, 01 Jan 2026 00:00:10 UTC')).toBe(gmt); + expect(parseHttpDate('Thu, 01 Jan 2026 00:00:10 +0000')).toBe(gmt); + expect(parseHttpDate('Thu, 01 Jan 2026 00:00:10 +00:00')).toBe(gmt); + }); + + test('the weekday token is informational only, even when wrong', () => { + const correct = parseHttpDate('Thu, 01 Jan 2026 00:00:10 GMT'); + expect(parseHttpDate('Mon, 01 Jan 2026 00:00:10 GMT')).toBe(correct); + }); + + test('a single-digit day is tolerated', () => { + expect(parseHttpDate('Thu, 1 Jan 2026 00:00:10 GMT')).toBe( + parseHttpDate('Thu, 01 Jan 2026 00:00:10 GMT'), + ); + }); +}); + +describe('parseHttpDate strictness (CFG-31)', () => { + test('blank input fails', () => { + expect(parseHttpDate('')).toBeNull(); + }); + + test('a missing comma after the weekday fails', () => { + expect(parseHttpDate('Mon 01 Jan 2024 00:00:00 GMT')).toBeNull(); + }); + + test('an out-of-range field is rejected, not silently rolled over', () => { + expect(parseHttpDate('Thu, 32 Jan 2026 00:00:10 GMT')).toBeNull(); + expect(parseHttpDate('Thu, 01 Jan 2026 24:00:10 GMT')).toBeNull(); + expect(parseHttpDate('Thu, 01 Foo 2026 00:00:10 GMT')).toBeNull(); + }); + + test('a four-digit year below 100 is rejected, not mapped into the 1900s', () => { + // `Date.UTC(26, ...)` applies legacy two-digit-year mapping, so an unguarded parse turns 0026 + // into 1926 -- a valid-looking instant 1900 years off, and the one input that would make a + // malformed Retry-After read as a PAST instant (retry immediately) instead of no-hint. + expect(parseHttpDate('Thu, 01 Jan 0026 00:00:00 GMT')).toBeNull(); + expect(parseHttpDate('Thu, 01 Jan 0099 00:00:00 GMT')).toBeNull(); + expect(parseHttpDate('Thu, 01 Jan 0100 00:00:00 GMT')).not.toBeNull(); + }); + + test('a leap second is admitted and normalized to the next real instant', () => { + // RFC 9110 allows second 60; there is no leap-second slot on this calendar, so the correct next + // instant is the following minute. Documented normalization, unlike the field rollovers above. + expect(parseHttpDate('Thu, 01 Jan 2026 00:00:60 GMT')).toBe( + parseHttpDate('Thu, 01 Jan 2026 00:01:00 GMT'), + ); + }); + + test('property: never throws for any string', () => { + fc.assert( + fc.property(fc.string(), value => { + expect(() => parseHttpDate(value)).not.toThrow(); + }), + ); + }); + + test('property: a formatted instant round-trips through parse', () => { + fc.assert( + fc.property(fc.integer({min: 0, max: 4_102_444_800_000}), epochMs => { + const truncated = Math.floor(epochMs / 1000) * 1000; + expect(parseHttpDate(formatHttpDate(truncated))).toBe(truncated); + }), + ); + }); +}); diff --git a/packages/core/src/config/http-date.ts b/packages/core/src/config/http-date.ts new file mode 100644 index 0000000..925e7ce --- /dev/null +++ b/packages/core/src/config/http-date.ts @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/config/http-date.ts + +const MONTHS = [ + 'jan', + 'feb', + 'mar', + 'apr', + 'may', + 'jun', + 'jul', + 'aug', + 'sep', + 'oct', + 'nov', + 'dec', +]; +const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +const HTTP_DATE = + /^(?:[A-Za-z]{3,9},\s+)?(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{2}):(\d{2}):(\d{2})\s+(?:GMT|UTC|\+00:?00)$/u; + +function pad2(value: number): string { + return value < 10 ? `0${String(value)}` : String(value); +} + +/** + * CFG-29: the canonical RFC 1123 HTTP-date form, always UTC, e.g. `Sun, 06 Nov 1994 08:49:37 GMT`. + * + * @param epochMs - the instant, in epoch milliseconds. + * @returns the canonical RFC 1123 rendering. + * + * @internal + */ +export function formatHttpDate(epochMs: number): string { + const date = new Date(epochMs); + const weekday = WEEKDAYS[date.getUTCDay()]; + const day = pad2(date.getUTCDate()); + const month = MONTHS[date.getUTCMonth()]?.replace(/^./u, c => + c.toUpperCase(), + ); + const year = date.getUTCFullYear(); + const time = `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}`; + return `${String(weekday)}, ${day} ${String(month)} ${String(year)} ${time} GMT`; +} + +/** + * CFG-30/CFG-31: a hand-written RFC 1123 parser, never `Date.parse` -- JS date-string parsing is + * permissive and non-standardized across engines, the opposite of a total parser's contract. + * Tolerant of an informational weekday (stripped, not validated -- CFG-30), a single-digit day, and + * case-insensitive month/zone tokens (GMT/UTC/+0000/+00:00 all normalize to zero offset). Strict on + * the rest: blank input and a missing post-weekday comma both fail (CFG-31); every field is + * range-checked so an out-of-range value is REJECTED rather than silently rolled over by `Date.UTC` + * into a valid but wrong instant. + * + * Total: never throws for any input. + * + * @param raw - the raw header value. + * @returns the instant in epoch milliseconds, or `null` when the value is not a valid HTTP-date. + * + * @internal + */ +export function parseHttpDate(raw: string): number | null { + const match = HTTP_DATE.exec(raw); + if (match === null) return null; + const day = Number(match[1] ?? ''); + const month = MONTHS.indexOf((match[2] ?? '').toLowerCase()); + const year = Number(match[3] ?? ''); + const hour = Number(match[4] ?? ''); + const minute = Number(match[5] ?? ''); + const second = Number(match[6] ?? ''); + // A four-digit year below 100 is REJECTED rather than parsed: `Date.UTC` applies legacy + // two-digit-year mapping to any year in [0, 99], so `0026` would silently become 1926 -- the + // "valid but wildly wrong instant" CFG-31's range checks exist to prevent, and the one input that + // would turn a malformed `Retry-After` into a past instant (RETRY-17's `0`, i.e. retry + // immediately) instead of no-hint (RETRY-16's fall back to backoff). + if (year < 100) return null; + if ( + month < 0 || + day < 1 || + day > 31 || + hour > 23 || + minute > 59 || + second > 60 + ) { + return null; + } + // `second === 60` is RFC 9110's leap-second allowance and is deliberately admitted. `Date.UTC` + // maps it to the first second of the following minute, which is the correct next real instant on + // a calendar with no leap-second slot -- a documented normalization, not the silent field + // rollover the range checks above reject. + return Date.UTC(year, month, day, hour, minute, second); +} diff --git a/packages/core/src/config/retryable.test.ts b/packages/core/src/config/retryable.test.ts new file mode 100644 index 0000000..69ef916 --- /dev/null +++ b/packages/core/src/config/retryable.test.ts @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/config/retryable.test.ts +// Exercises: CFG-35 (exactly 408, 429, and 5xx except 501/505 are retryable; this exact set is a +// hard contract where implemented). Re-exported by Phase 5a's classify.ts as RETRY-1's definition. +// docs/superpowers/plans/2026-07-28-phase7a-configuration.md Task 3 +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import {RETRYABLE_STATUSES, isRetryableStatus} from './retryable.js'; + +describe('isRetryableStatus', () => { + test('408 and 429 are retryable', () => { + expect(isRetryableStatus(408)).toBe(true); + expect(isRetryableStatus(429)).toBe(true); + }); + + test('500-599 are retryable except 501 and 505', () => { + expect(isRetryableStatus(500)).toBe(true); + expect(isRetryableStatus(503)).toBe(true); + expect(isRetryableStatus(599)).toBe(true); + expect(isRetryableStatus(501)).toBe(false); + expect(isRetryableStatus(505)).toBe(false); + }); + + test('other statuses are not retryable', () => { + for (const code of [200, 201, 301, 400, 401, 404, 409, 418, 499, 600]) { + expect(isRetryableStatus(code)).toBe(false); + } + }); + + test('the exported set and the predicate are the same source', () => { + fc.assert( + fc.property(fc.integer({min: 100, max: 700}), code => { + expect(isRetryableStatus(code)).toBe(RETRYABLE_STATUSES.has(code)); + }), + ); + }); +}); diff --git a/packages/core/src/config/retryable.ts b/packages/core/src/config/retryable.ts new file mode 100644 index 0000000..fc10bc9 --- /dev/null +++ b/packages/core/src/config/retryable.ts @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/config/retryable.ts + +function buildRetryableStatuses(): ReadonlySet { + const codes = new Set([408, 429]); + for (let code = 500; code <= 599; code += 1) { + // 501 Not Implemented and 505 HTTP Version Not Supported mean the server cannot fulfill the + // request regardless of how many times it is asked. + if (code !== 501 && code !== 505) codes.add(code); + } + return codes; +} + +/** + * CFG-35: the single retryable-status definition. `Object.freeze` does not seal a `Set`'s internal + * slots, so a frozen `Set` would be a misleading no-op -- typed `ReadonlySet` instead, same + * treatment as Phase 1's idempotent-method set. Phase 5a's retry engine re-exports this exact set + * rather than defining its own (RETRY-1/RETRY-13). + * + * @internal + */ +export const RETRYABLE_STATUSES: ReadonlySet = buildRetryableStatuses(); + +/** + * Whether a status code is retryable per CFG-35's fixed set. + * + * @param code - the numeric status code. + * @returns true when the code is in {@link RETRYABLE_STATUSES}. + * + * @internal + */ +export function isRetryableStatus(code: number): boolean { + return RETRYABLE_STATUSES.has(code); +} diff --git a/packages/core/src/http/request-options.test.ts b/packages/core/src/http/request-options.test.ts index aef49e0..59ccd7a 100644 --- a/packages/core/src/http/request-options.test.ts +++ b/packages/core/src/http/request-options.test.ts @@ -43,11 +43,33 @@ describe('maxRetries validation (HTTP-35)', () => { ); }); + test('rejects a non-finite maxRetries, which would make a retry loop unbounded', () => { + // Worse in effect than a negative value: a negative one still fails a downstream `>= 1` guard, + // while Infinity/NaN make an "attempt >= ceiling" test permanently false and the loop endless. + for (const value of [Number.POSITIVE_INFINITY, Number.NaN]) { + expect(() => RequestOptions.newBuilder().maxRetries(value)).toThrow( + RequestOptionsValidationError, + ); + } + }); + + test('rejects a fractional maxRetries, which is not a count of wire sends', () => { + expect(() => RequestOptions.newBuilder().maxRetries(1.5)).toThrow( + RequestOptionsValidationError, + ); + }); + test('accepts 0, meaning "disable retries for this call"', () => { expect(RequestOptions.newBuilder().maxRetries(0).build().maxRetries).toBe( 0, ); }); + + test('accepts a positive integer', () => { + expect(RequestOptions.newBuilder().maxRetries(3).build().maxRetries).toBe( + 3, + ); + }); }); describe('tags are defensively copied at build (HTTP-34)', () => { diff --git a/packages/core/src/http/request-options.ts b/packages/core/src/http/request-options.ts index a961ede..8670e5b 100644 --- a/packages/core/src/http/request-options.ts +++ b/packages/core/src/http/request-options.ts @@ -131,15 +131,24 @@ export class RequestOptionsBuilder implements Builder { * Sets the per-call retry ceiling. * * @param value - the maximum retries, or `undefined` for no override. `0` is accepted and means - * "disable retries for this call"; a negative count is rejected rather than silently - * reinterpreted (HTTP-35). + * "disable retries for this call"; anything that is not a non-negative integer is rejected rather + * than silently reinterpreted (HTTP-35). + * + * The range check is deliberately wider than "not negative". A retry ceiling is a count of wire + * sends, so `Infinity` and `NaN` are as out-of-range as `-1` -- and they are worse in effect: a + * negative value at least fails a downstream lower-bound guard, while a non-finite one makes a + * retry driver's "have I reached the ceiling" test permanently false and its loop unbounded. + * HTTP-35's point is that an out-of-range retry count is a loud error at the call site that + * supplied it, never a value reinterpreted somewhere downstream. + * * @returns this builder, for chaining. - * @throws {@link RequestOptionsValidationError} when a defined value is negative. + * @throws {@link RequestOptionsValidationError} when a defined value is negative, fractional, or + * not finite. */ maxRetries(value: number | undefined): this { - if (value !== undefined && value < 0) { + if (value !== undefined && !(Number.isInteger(value) && value >= 0)) { throw new RequestOptionsValidationError( - `maxRetries must not be negative, got ${String(value)}`, + `maxRetries must be a non-negative integer, got ${String(value)}`, ); } this.#maxRetries = value; diff --git a/packages/core/src/pipeline/cursor.test.ts b/packages/core/src/pipeline/cursor.test.ts index 27285c4..5538647 100644 --- a/packages/core/src/pipeline/cursor.test.ts +++ b/packages/core/src/pipeline/cursor.test.ts @@ -379,3 +379,132 @@ describe('Cursor fork (PIPE-15, PIPE-16, PIPE-17)', () => { ]); }); }); + +describe('StepContext.signal', () => { + test('a step observes the signal the cursor was constructed with', async () => { + const controller = new AbortController(); + const request = aRequest('https://example.com'); + const context = createRequestContext(request); + let observed: AbortSignal | undefined; + const descriptor: StepDescriptor = { + type: Symbol('observer'), + stage: 'LOGGING', + fn: async (_request, ctx) => { + observed = ctx.signal; + return ctx.next(); + }, + }; + + await new Cursor({ + steps: [descriptor], + transport: new RecordingTransport(aResponse(200)), + request, + context, + signal: controller.signal, + }).advance(); + + expect(observed).toBe(controller.signal); + }); + + test('signal is undefined when the cursor was constructed without one', async () => { + const request = aRequest('https://example.com'); + const context = createRequestContext(request); + let observed: AbortSignal | undefined = new AbortController().signal; + const descriptor: StepDescriptor = { + type: Symbol('observer'), + stage: 'LOGGING', + fn: async (_request, ctx) => { + observed = ctx.signal; + return ctx.next(); + }, + }; + + await new Cursor({ + steps: [descriptor], + transport: new RecordingTransport(aResponse(200)), + request, + context, + }).advance(); + + expect(observed).toBeUndefined(); + }); +}); + +describe('StepContext.signal on a pillar step', () => { + test('a pillar step observes the signal too', async () => { + const controller = new AbortController(); + const request = aRequest('https://example.com'); + const context = createRequestContext(request); + let observed: AbortSignal | undefined; + const descriptor: StepDescriptor = { + type: Symbol('observer'), + stage: 'RETRY', + fn: async (_request, ctx) => { + observed = ctx.signal; + return ctx.next(); + }, + }; + + await new Cursor({ + steps: [descriptor], + transport: new RecordingTransport(aResponse(200)), + request, + context, + signal: controller.signal, + }).advance(); + + expect(observed).toBe(controller.signal); + }); +}); + +describe('StepContext.options (PIPE-17)', () => { + test('a step reads the per-call options the cursor was constructed with', async () => { + const options = RequestOptions.newBuilder().maxRetries(0).build(); + const request = aRequest('https://example.com'); + const context = createRequestContext(request); + let observed: RequestOptions | undefined; + const descriptor: StepDescriptor = { + type: Symbol('observer'), + stage: 'LOGGING', + fn: async (_request, ctx) => { + observed = ctx.options; + return ctx.next(); + }, + }; + + await new Cursor({ + steps: [descriptor], + transport: new RecordingTransport(aResponse(200)), + request, + context, + options, + }).advance(); + + // PIPE-17: the same immutable instance, not a copy. + expect(observed).toBe(options); + }); + + test('options is undefined when the caller supplied none', async () => { + const request = aRequest('https://example.com'); + const context = createRequestContext(request); + let observed: RequestOptions | undefined = + RequestOptions.newBuilder().build(); + const descriptor: StepDescriptor = { + type: Symbol('observer'), + stage: 'LOGGING', + fn: async (_request, ctx) => { + observed = ctx.options; + return ctx.next(); + }, + }; + + await new Cursor({ + steps: [descriptor], + transport: new RecordingTransport(aResponse(200)), + request, + context, + }).advance(); + + expect(observed).toBeUndefined(); + }); +}); diff --git a/packages/core/src/pipeline/cursor.ts b/packages/core/src/pipeline/cursor.ts index 87a5570..7812c8a 100644 --- a/packages/core/src/pipeline/cursor.ts +++ b/packages/core/src/pipeline/cursor.ts @@ -86,14 +86,21 @@ export class Cursor { `pipeline cursor position ${String(position)} is within bounds but undefined`, ); const next = this.#continuationAt(position + 1, descriptor.stage); + // PIPE-17: `signal` and `options` are readable by every step, pillar or not, and are shared by + // reference across every fork -- never copied, so a step cannot diverge them per attempt. + const shared = { + next, + context: this.#context, + signal: this.#signal, + options: this.#options, + }; const ctx: StepContext = PILLAR_STAGES.has(descriptor.stage) ? { - next, - context: this.#context, + ...shared, fork: (): Next => this.#continuationAt(position + 1, descriptor.stage), } - : {next, context: this.#context}; + : shared; return descriptor.fn(this.#request, ctx); } diff --git a/packages/core/src/pipeline/step.ts b/packages/core/src/pipeline/step.ts index a309f0a..e1fa453 100644 --- a/packages/core/src/pipeline/step.ts +++ b/packages/core/src/pipeline/step.ts @@ -2,6 +2,7 @@ // packages/core/src/pipeline/step.ts import type {ExecutionContext} from '../context/context.js'; import type {Request} from '../http/request.js'; +import type {RequestOptions} from '../http/request-options.js'; import type {Response} from '../http/response.js'; import type {Stage} from './stage.js'; @@ -25,16 +26,24 @@ export type Next = (request?: Request) => Promise; * What a step receives on each invocation (PIPE-12). `fork` is present only when the invoking step occupies * a pillar stage (PIPE-15/16); an ordinary step's `ctx.fork` is `undefined`. * - * The call's per-call `options` and `AbortSignal` are deliberately absent here: `Cursor` carries both and - * threads them into the terminal dispatch, but PIPE-17's "readable by any step" clause has no reader until - * Phase 5a's retry engine, which adds both fields as one additive amendment (5a Task 1). - * * @internal */ export interface StepContext { readonly next: Next; readonly fork?: (() => Next) | undefined; readonly context: ExecutionContext; + /** + * The call's cancellation signal, threaded from the cursor (PIPE-13). Undefined when the caller + * supplied none. A pillar step that waits between drives (retry's backoff, auth's token fetch) + * MUST honor it (RETRY-26/RETRY-32). + */ + readonly signal?: AbortSignal | undefined; + /** + * The caller's per-call options, immutable and shared across every fork (PIPE-17: "readable by + * any step"). Undefined when the caller supplied none. The retry step reads `maxRetries` + * (RETRY-41/HTTP-35); the auth step reads the per-call auth descriptor (5c). + */ + readonly options?: RequestOptions | undefined; } /** diff --git a/packages/core/src/recovery/idempotency-key.test.ts b/packages/core/src/recovery/idempotency-key.test.ts new file mode 100644 index 0000000..4f98213 --- /dev/null +++ b/packages/core/src/recovery/idempotency-key.test.ts @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/recovery/idempotency-key.test.ts +// Exercises: RECOV-32 (method gating, respect-existing default, strategy invoked at most once per +// applicable request, other methods untouched, defensive method-set copy). +import {describe, expect, test} from 'bun:test'; +import {Headers} from '../http/headers.js'; +import type {Method} from '../http/method.js'; +import {Request} from '../http/request.js'; +import {idempotencyKeyStep} from './idempotency-key.js'; + +function aRequest( + method: 'GET' | 'POST' | 'PUT' | 'PATCH', + existing?: string, +): Request { + const builder = Request.newBuilder() + .method(method) + .url('https://example.com'); + if (existing === undefined) return builder.build(); + return builder + .headers(Headers.newBuilder().add('Idempotency-Key', existing).build()) + .build(); +} + +describe('idempotencyKeyStep', () => { + test('stamps the default header on POST, PUT, and PATCH', async () => { + const step = idempotencyKeyStep({generate: () => 'generated'}); + for (const method of ['POST', 'PUT', 'PATCH'] as const) { + const stamped = await step(aRequest(method)); + expect(stamped.headers.get('Idempotency-Key')).toBe('generated'); + } + }); + + test('passes other methods through untouched', async () => { + const step = idempotencyKeyStep({generate: () => 'generated'}); + const request = aRequest('GET'); + expect(await step(request)).toBe(request); + }); + + test('respects an existing header by default and does NOT invoke the strategy', async () => { + let invocations = 0; + const step = idempotencyKeyStep({ + generate: () => { + invocations += 1; + return 'generated'; + }, + }); + const request = aRequest('POST', 'caller-supplied'); + + const result = await step(request); + + expect(result).toBe(request); + expect(invocations).toBe(0); + }); + + test('overwrites an existing header when respectExisting is false', async () => { + const step = idempotencyKeyStep({ + generate: () => 'generated', + respectExisting: false, + }); + const stamped = await step(aRequest('POST', 'caller-supplied')); + expect(stamped.headers.get('Idempotency-Key')).toBe('generated'); + }); + + test('invokes the strategy at most once per applicable request', async () => { + let invocations = 0; + const step = idempotencyKeyStep({ + generate: () => { + invocations += 1; + return `key-${String(invocations)}`; + }, + }); + + await step(aRequest('POST')); + + expect(invocations).toBe(1); + }); +}); + +describe('idempotencyKeyStep configuration (RECOV-32)', () => { + test('honors a configured header name and method set', async () => { + const step = idempotencyKeyStep({ + generate: () => 'generated', + headerName: 'X-Request-Id', + methods: new Set(['GET']), + }); + expect((await step(aRequest('GET'))).headers.get('X-Request-Id')).toBe( + 'generated', + ); + const post = aRequest('POST'); + expect(await step(post)).toBe(post); + }); + + test('defensively copies the method set', async () => { + const methods = new Set(['GET']); + const step = idempotencyKeyStep({generate: () => 'generated', methods}); + methods.add('POST'); + + const post = aRequest('POST'); + + expect(await step(post)).toBe(post); + }); + + test('never mutates the request it was given', async () => { + const step = idempotencyKeyStep({generate: () => 'generated'}); + const request = aRequest('POST'); + + await step(request); + + expect(request.headers.get('Idempotency-Key')).toBeUndefined(); + }); +}); diff --git a/packages/core/src/recovery/idempotency-key.ts b/packages/core/src/recovery/idempotency-key.ts new file mode 100644 index 0000000..46a5e1b --- /dev/null +++ b/packages/core/src/recovery/idempotency-key.ts @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/recovery/idempotency-key.ts +import type {Method} from '../http/method.js'; +import type {Request} from '../http/request.js'; +import type {RequestStep} from './request-chain.js'; + +const DEFAULT_HEADER = 'Idempotency-Key'; +const DEFAULT_METHODS: readonly Method[] = ['POST', 'PUT', 'PATCH']; + +/** + * Everything {@link idempotencyKeyStep} accepts (RECOV-32). + * + * @internal + */ +export interface IdempotencyKeyOptions { + /** The key strategy. Invoked at most once per applicable request (RECOV-32). */ + readonly generate: () => string; + readonly headerName?: string | undefined; + /** Defaults to the non-idempotent write methods; defensively copied at construction. */ + readonly methods?: ReadonlySet | undefined; + /** When true (the default) a request already carrying the header is left entirely alone. */ + readonly respectExisting?: boolean | undefined; +} + +/** + * A `RequestStep` that stamps an idempotency key on write requests (RECOV-32). + * + * Runs ONCE per call, upstream of retry -- not per attempt. `retry/attempt-stamp.ts` is its sibling: + * that one writes the attempt ordinal on each per-attempt copy and preserves whatever this wrote + * (RETRY-38), so the server sees one stable key across every retry of the same logical request. + * + * @param options - the key strategy plus the header name, method set, and existing-key policy. + * @returns the request step to install in a `RequestRecoveryChain`. + * + * @internal + */ +export function idempotencyKeyStep( + options: IdempotencyKeyOptions, +): RequestStep { + const headerName = options.headerName ?? DEFAULT_HEADER; + const methods = new Set(options.methods ?? DEFAULT_METHODS); + const respectExisting = options.respectExisting ?? true; + + return (request: Request): Promise => { + if (!methods.has(request.method)) return Promise.resolve(request); + if (respectExisting && request.headers.get(headerName) !== undefined) { + return Promise.resolve(request); + } + return Promise.resolve( + request + .newBuilder() + .headers( + request.headers + .newBuilder() + .set(headerName, options.generate()) + .build(), + ) + .build(), + ); + }; +} diff --git a/packages/core/src/retry/attempt-stamp.test.ts b/packages/core/src/retry/attempt-stamp.test.ts new file mode 100644 index 0000000..9e27fca --- /dev/null +++ b/packages/core/src/retry/attempt-stamp.test.ts @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/retry/attempt-stamp.test.ts +// Exercises: RETRY-38/RECOV-31 (1-based ordinal on a FRESH copy, never mutating the template, +// preserving the idempotency key and every other header, zero-allocation no-op when disabled). +import {describe, expect, test} from 'bun:test'; +import {Headers} from '../http/headers.js'; +import {Request} from '../http/request.js'; +import {stampAttempt} from './attempt-stamp.js'; + +function aRequest(): Request { + return Request.newBuilder() + .method('POST') + .url('https://example.com') + .headers( + Headers.newBuilder() + .add('Idempotency-Key', 'abc-123') + .add('X-Trace', 't1') + .build(), + ) + .build(); +} + +describe('stampAttempt', () => { + test('returns the ORIGINAL instance when no header name is configured (RETRY-38)', () => { + const request = aRequest(); + expect(stampAttempt(request, 2, undefined)).toBe(request); + }); + + test('writes the 1-based ordinal under the configured header', () => { + const stamped = stampAttempt(aRequest(), 3, 'X-Attempt'); + expect(stamped.headers.get('X-Attempt')).toBe('3'); + }); + + test('never mutates the captured template', () => { + const request = aRequest(); + stampAttempt(request, 3, 'X-Attempt'); + expect(request.headers.get('X-Attempt')).toBeUndefined(); + }); + + test('preserves the idempotency key and every other header', () => { + const stamped = stampAttempt(aRequest(), 2, 'X-Attempt'); + expect(stamped.headers.get('Idempotency-Key')).toBe('abc-123'); + expect(stamped.headers.get('X-Trace')).toBe('t1'); + }); + + test('preserves method, url, and body', () => { + const request = aRequest(); + const stamped = stampAttempt(request, 2, 'X-Attempt'); + expect(stamped.method).toBe(request.method); + expect(stamped.url.href).toBe(request.url.href); + expect(stamped.body).toBe(request.body); + }); + + test('re-stamping replaces rather than appends', () => { + const once = stampAttempt(aRequest(), 2, 'X-Attempt'); + const twice = stampAttempt(once, 3, 'X-Attempt'); + expect(twice.headers.get('X-Attempt')).toBe('3'); + }); +}); diff --git a/packages/core/src/retry/attempt-stamp.ts b/packages/core/src/retry/attempt-stamp.ts new file mode 100644 index 0000000..f5e2005 --- /dev/null +++ b/packages/core/src/retry/attempt-stamp.ts @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/retry/attempt-stamp.ts +import type {Request} from '../http/request.js'; + +/** + * Stamps the 1-based attempt ordinal onto a FRESH copy of the request (RETRY-38/RECOV-31). + * + * The captured template is never mutated -- `Request` is immutable and frozen, so "stamping" means + * building a new value. `set()` replaces only the named header, so an idempotency key written + * upstream by `recovery/idempotency-key.ts` (RECOV-32) and every other header survive untouched. + * + * Disabled by default: when `headerName` is undefined this returns the ORIGINAL instance and + * allocates nothing, which is the zero-allocation no-op path RETRY-38 requires. + * + * @param request - the captured template, never mutated. + * @param attempt - the 1-based attempt ordinal. + * @param headerName - the header to stamp under, or undefined to disable stamping. + * @returns the stamped copy, or the original instance when stamping is disabled. + * + * @internal + */ +export function stampAttempt( + request: Request, + attempt: number, + headerName: string | undefined, +): Request { + if (headerName === undefined) return request; + return request + .newBuilder() + .headers( + request.headers.newBuilder().set(headerName, String(attempt)).build(), + ) + .build(); +} diff --git a/packages/core/src/retry/backoff.test.ts b/packages/core/src/retry/backoff.test.ts new file mode 100644 index 0000000..836ca61 --- /dev/null +++ b/packages/core/src/retry/backoff.test.ts @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/retry/backoff.test.ts +// Exercises: RETRY-9 (initialDelay * multiplier^(attempt-1), 1-indexed, capped), RETRY-10 (symmetric +// jitter bounds, midpoint, j=0 identity, negative floors to zero), RETRY-11 (attempt < 1 rejected, +// overflow saturates), RETRY-43 (fixed delay disables backoff AND jitter). +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import {computeDelay, type BackoffSettings} from './backoff.js'; + +const SETTINGS: BackoffSettings = { + initialDelayMs: 200, + multiplier: 2, + maxDelayMs: 8000, + jitter: 0, +}; +const never = (): number => 0.5; + +describe('exponential schedule', () => { + test('attempt 1 is the initial delay, 1-indexed (RETRY-9)', () => { + expect(computeDelay(1, SETTINGS, never)).toBe(200); + }); + + test('each attempt multiplies the previous (RETRY-9)', () => { + expect(computeDelay(2, SETTINGS, never)).toBe(400); + expect(computeDelay(3, SETTINGS, never)).toBe(800); + expect(computeDelay(4, SETTINGS, never)).toBe(1600); + }); + + test('growth is clamped to maxDelayMs (RETRY-9)', () => { + expect(computeDelay(20, SETTINGS, never)).toBe(8000); + }); + + test('an overflowing attempt saturates to the cap instead of throwing (RETRY-11)', () => { + expect(computeDelay(5000, SETTINGS, never)).toBe(8000); + expect(Number.isFinite(computeDelay(5000, SETTINGS, never))).toBe(true); + }); + + test('attempt < 1 is a programmer error (RETRY-11)', () => { + expect(() => computeDelay(0, SETTINGS, never)).toThrow(); + expect(() => computeDelay(-1, SETTINGS, never)).toThrow(); + }); +}); + +describe('symmetric jitter', () => { + const jittered: BackoffSettings = {...SETTINGS, jitter: 0.2}; + + test('jitter 0 returns the base delay unperturbed (RETRY-10)', () => { + expect(computeDelay(3, SETTINGS, () => 0)).toBe(800); + expect(computeDelay(3, SETTINGS, () => 1)).toBe(800); + }); + + test('the midpoint sample returns the base delay (RETRY-10)', () => { + expect(computeDelay(3, jittered, () => 0.5)).toBeCloseTo(800, 6); + }); + + test('the sample spans exactly [d(1-j/2), d(1+j/2)] (RETRY-10)', () => { + expect(computeDelay(3, jittered, () => 0)).toBeCloseTo(720, 6); + expect(computeDelay(3, jittered, () => 1)).toBeCloseTo(880, 6); + }); + + test('a negative sample floors to zero (RETRY-10)', () => { + const wide: BackoffSettings = { + initialDelayMs: 10, + multiplier: 1, + maxDelayMs: 10, + jitter: 1, + }; + expect(computeDelay(1, wide, () => -100)).toBe(0); + }); + + test('property: every sample lies inside the symmetric window (RETRY-10)', () => { + fc.assert( + fc.property( + fc.integer({min: 1, max: 12}), + fc.double({min: 0, max: 1, noNaN: true}), + fc.double({min: 0, max: 1, noNaN: true}), + (attempt, jitter, sample) => { + const settings: BackoffSettings = {...SETTINGS, jitter}; + const base = Math.min(200 * 2 ** (attempt - 1), 8000); + const delay = computeDelay(attempt, settings, () => sample); + expect(delay).toBeGreaterThanOrEqual(base * (1 - jitter / 2) - 1e-9); + expect(delay).toBeLessThanOrEqual(base * (1 + jitter / 2) + 1e-9); + }, + ), + ); + }); + + test('property: the unjittered delay never exceeds the cap and never decreases (RETRY-9)', () => { + fc.assert( + fc.property(fc.integer({min: 1, max: 200}), attempt => { + const delay = computeDelay(attempt, SETTINGS, never); + expect(delay).toBeLessThanOrEqual(SETTINGS.maxDelayMs); + expect(delay).toBeGreaterThanOrEqual( + computeDelay(Math.max(1, attempt - 1), SETTINGS, never), + ); + }), + ); + }); +}); + +describe('fixed delay (RETRY-43)', () => { + test('a fixed delay disables both backoff growth and jitter', () => { + const fixed: BackoffSettings = { + ...SETTINGS, + jitter: 0.5, + fixedDelayMs: 1234, + }; + expect(computeDelay(1, fixed, () => 0)).toBe(1234); + expect(computeDelay(9, fixed, () => 1)).toBe(1234); + }); + + test('a fixed delay of zero is honored, not treated as absent', () => { + expect(computeDelay(4, {...SETTINGS, fixedDelayMs: 0}, never)).toBe(0); + }); +}); diff --git a/packages/core/src/retry/backoff.ts b/packages/core/src/retry/backoff.ts new file mode 100644 index 0000000..1f01414 --- /dev/null +++ b/packages/core/src/retry/backoff.ts @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/retry/backoff.ts +import {invariant} from '../invariant.js'; + +/** + * The pure-math half of the retry schedule (RETRY-9..RETRY-11, RETRY-43). Carried inside + * `RetrySettings`, never constructed standalone by a caller. + * + * @internal + */ +export interface BackoffSettings { + readonly initialDelayMs: number; + readonly multiplier: number; + readonly maxDelayMs: number; + /** Symmetric jitter fraction in [0,1]; 0 disables perturbation (RETRY-10). */ + readonly jitter: number; + /** + * When set, forces a flat delay and makes the exponential path unreachable (RETRY-43). + * + * Deliberately NOT clamped to `maxDelayMs`: RETRY-43 describes the mode as "zeroing the base and + * cap so only the fixed delay applies", so the cap is part of the schedule this mode replaces + * rather than a bound that outlives it. A fixed delay longer than `maxDelayMs` is honored. + */ + readonly fixedDelayMs?: number | undefined; +} + +/** + * Draws uniformly from [delayMs*(1-jitter/2), delayMs*(1+jitter/2)], midpoint delayMs (RETRY-10). + * A negative sample from a hostile random source floors to zero rather than producing a negative + * delay. + */ +function applyJitter( + delayMs: number, + jitter: number, + random: () => number, +): number { + if (jitter === 0) return delayMs; + const width = delayMs * jitter; + return Math.max(0, delayMs - width / 2 + random() * width); +} + +/** + * The single backoff calculator (RETRY-13): `initialDelay * multiplier^(attempt-1)`, clamped to the + * cap, then jittered. `attempt` is 1-indexed, where 1 is the wait BEFORE the first retry (RETRY-9). + * + * Overflow-safe by construction (RETRY-11): a large attempt makes `**` return `Infinity`, which + * `Math.min` absorbs into the cap. It saturates; it never throws. + * + * `random` is injected so jitter is assertable rather than statistical -- the same determinism seam + * CFG-15 wants for the clock. + * + * @param attempt - the 1-indexed retry ordinal; 1 is the wait before the first retry. + * @param settings - the schedule's shape. + * @param random - the uniform [0,1) source jitter draws from. + * @returns the delay in milliseconds. + * @throws InvariantViolation when `attempt` is below 1 -- a programmer error, not an operational one + * (RETRY-11). + * + * @internal + */ +export function computeDelay( + attempt: number, + settings: BackoffSettings, + random: () => number, +): number { + invariant( + attempt >= 1, + `retry attempt must be 1-indexed and >= 1, got ${String(attempt)}`, + ); + if (settings.fixedDelayMs !== undefined) return settings.fixedDelayMs; + const growth = settings.initialDelayMs * settings.multiplier ** (attempt - 1); + return applyJitter( + Math.min(growth, settings.maxDelayMs), + settings.jitter, + random, + ); +} diff --git a/packages/core/src/retry/classify.test.ts b/packages/core/src/retry/classify.test.ts new file mode 100644 index 0000000..9b5206c --- /dev/null +++ b/packages/core/src/retry/classify.test.ts @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/retry/classify.test.ts +// Exercises: RETRY-1 (single-sourced status set, 501/505 excluded), RETRY-2 (iterative +// identity-tracking cause walk, cycle-safe), RETRY-3 (retryability derived from status, not a stored +// flag), RETRY-4 (transport failures always retryable), RETRY-5/6/7 (re-sendability), RETRY-8 (both +// axes required), RETRY-23/24 (cancellation vs timeout), RETRY-25 (allow-list makes the fatal +// exclusion vacuous), RETRY-37 (configured set is authoritative -- widens AND narrows). +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import {HttpStatusError} from '../body/http-status-error.js'; +import {stringBody} from '../body/simple-bodies.js'; +import {streamBody} from '../body/stream-body.js'; +import type {Body} from '../body/body.js'; +import {Request} from '../http/request.js'; +import {IoError} from '../io/errors.js'; +import {CancellationError} from '../seams/transport.js'; +import { + RETRYABLE_STATUSES, + isResendable, + isRetryableFailure, + isRetryableStatus, +} from './classify.js'; + +function aRequest(method: 'GET' | 'POST' | 'PUT', body?: Body): Request { + const builder = Request.newBuilder() + .method(method) + .url('https://example.com'); + return body === undefined ? builder.build() : builder.body(body).build(); +} + +describe('isRetryableStatus', () => { + test('408 and 429 are retryable', () => { + expect(isRetryableStatus(408)).toBe(true); + expect(isRetryableStatus(429)).toBe(true); + }); + + test('500-599 are retryable except 501 and 505', () => { + expect(isRetryableStatus(500)).toBe(true); + expect(isRetryableStatus(503)).toBe(true); + expect(isRetryableStatus(599)).toBe(true); + expect(isRetryableStatus(501)).toBe(false); + expect(isRetryableStatus(505)).toBe(false); + }); + + test('other statuses are not retryable', () => { + for (const code of [200, 201, 301, 400, 401, 404, 409, 418, 499, 600]) { + expect(isRetryableStatus(code)).toBe(false); + } + }); + + test('the exported set and the predicate are the same source', () => { + fc.assert( + fc.property(fc.integer({min: 100, max: 700}), code => { + expect(isRetryableStatus(code)).toBe(RETRYABLE_STATUSES.has(code)); + }), + ); + }); +}); + +describe('isRetryableFailure', () => { + test('an IoError is retryable', () => { + expect( + isRetryableFailure(new IoError('connection refused'), RETRYABLE_STATUSES), + ).toBe(true); + }); + + test('an IoError buried in the cause chain is retryable (RETRY-2)', () => { + const buried = new Error('wrapper', { + cause: new Error('middle', {cause: new IoError('reset')}), + }); + expect(isRetryableFailure(buried, RETRYABLE_STATUSES)).toBe(true); + }); + + test('a cyclic cause chain terminates instead of hanging (RETRY-2)', () => { + const first = new Error('first'); + const second = new Error('second', {cause: first}); + Object.defineProperty(first, 'cause', {value: second, configurable: true}); + + expect(isRetryableFailure(first, RETRYABLE_STATUSES)).toBe(false); + }); + + test('an HttpStatusError derives retryability from its status (RETRY-3)', () => { + expect( + isRetryableFailure( + new HttpStatusError(503, undefined, undefined), + RETRYABLE_STATUSES, + ), + ).toBe(true); + expect( + isRetryableFailure( + new HttpStatusError(501, undefined, undefined), + RETRYABLE_STATUSES, + ), + ).toBe(false); + }); + + test('the configured set is authoritative and can widen (RETRY-37)', () => { + const widened = new Set([...RETRYABLE_STATUSES, 404]); + expect( + isRetryableFailure( + new HttpStatusError(404, undefined, undefined), + widened, + ), + ).toBe(true); + }); + + test('the configured set is authoritative and can narrow (RETRY-37)', () => { + const narrowed = new Set([500]); + expect( + isRetryableFailure( + new HttpStatusError(503, undefined, undefined), + narrowed, + ), + ).toBe(false); + }); +}); + +describe('isRetryableFailure -- cancellation, timeouts, and the allow-list', () => { + test('a user abort is never retryable (RETRY-23)', () => { + const controller = new AbortController(); + controller.abort(); + expect( + isRetryableFailure(controller.signal.reason, RETRYABLE_STATUSES), + ).toBe(false); + }); + + test('a CancellationError is never retryable, even nested (RETRY-23, XCUT-1)', () => { + // Phase 2 declares `CancellationError extends DexpaceError`, NOT the IoError family, so the + // allow-list already excludes it. Asserted rather than assumed: were it ever re-parented under + // IoError, cancellation would silently become a retryable condition and XCUT-1 would break. + const cancelled = new CancellationError('caller aborted'); + expect(isRetryableFailure(cancelled, RETRYABLE_STATUSES)).toBe(false); + expect( + isRetryableFailure( + new Error('send failed', {cause: cancelled}), + RETRYABLE_STATUSES, + ), + ).toBe(false); + }); + + test('a throwing cause accessor ends the walk instead of masking the failure', () => { + // `cause` is an ordinary property, so a lazily-built one can raise from the read. Classifying a + // failure must never replace it with a classification error. + const hostile = new IoError('connection refused'); + Object.defineProperty(hostile, 'cause', { + get() { + throw new Error('hostile accessor'); + }, + }); + const wrapper = new Error('wrapper'); + Object.defineProperty(wrapper, 'cause', { + get() { + throw new Error('hostile accessor'); + }, + }); + + expect(isRetryableFailure(hostile, RETRYABLE_STATUSES)).toBe(true); + expect(isRetryableFailure(wrapper, RETRYABLE_STATUSES)).toBe(false); + }); + + test('a timeout abort is retryable (RETRY-24)', () => { + const reason = new DOMException('The operation timed out.', 'TimeoutError'); + expect(isRetryableFailure(reason, RETRYABLE_STATUSES)).toBe(true); + }); + + test('a timeout abort wrapped as a cause is retryable (RETRY-24)', () => { + const reason = new DOMException('The operation timed out.', 'TimeoutError'); + expect( + isRetryableFailure( + new Error('send failed', {cause: reason}), + RETRYABLE_STATUSES, + ), + ).toBe(true); + }); + + test('an unlisted throwable is not retryable, no deny-list needed (RETRY-25)', () => { + expect( + isRetryableFailure( + new RangeError('Maximum call stack size exceeded'), + RETRYABLE_STATUSES, + ), + ).toBe(false); + expect(isRetryableFailure(new TypeError('bad'), RETRYABLE_STATUSES)).toBe( + false, + ); + expect(isRetryableFailure('a bare string throw', RETRYABLE_STATUSES)).toBe( + false, + ); + expect(isRetryableFailure(undefined, RETRYABLE_STATUSES)).toBe(false); + }); +}); + +describe('isResendable', () => { + test('a body-less idempotent request is re-sendable (RETRY-5/6)', () => { + expect(isResendable(aRequest('GET'))).toBe(true); + expect(isResendable(aRequest('PUT'))).toBe(true); + }); + + test('a bare POST is NOT re-sendable even with nothing to resend (RETRY-7)', () => { + expect(isResendable(aRequest('POST'))).toBe(false); + }); + + test('a POST with a replayable body is re-sendable (RETRY-5)', () => { + expect(isResendable(aRequest('POST', stringBody('payload')))).toBe(true); + }); + + test('a request with a non-replayable body is NOT re-sendable (RETRY-5)', () => { + const oneShot = streamBody( + new ReadableStream({ + start: c => { + c.close(); + }, + }), + undefined, + 0, + ); + const request = Request.newBuilder() + .method('POST') + .url('https://example.com') + .body(oneShot) + .build(); + expect(isResendable(request)).toBe(false); + }); +}); diff --git a/packages/core/src/retry/classify.ts b/packages/core/src/retry/classify.ts new file mode 100644 index 0000000..7073395 --- /dev/null +++ b/packages/core/src/retry/classify.ts @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/retry/classify.ts +import {HttpStatusError} from '../body/http-status-error.js'; +import {isIdempotent} from '../http/method.js'; +import type {Request} from '../http/request.js'; +import {IoError} from '../io/errors.js'; + +// Phase 7a retrofit: RETRY-1's status set and predicate previously lived here as a private +// `buildRetryableStatuses()`/`RETRYABLE_STATUSES`/`isRetryableStatus`. Phase 7a's CFG-35 promotes the +// exact same set to a utility at `config/retryable.js` (for callers with no retry-engine +// dependency); this module re-exports that single source instead of keeping a second definition +// (RETRY-13's single-sourcing mandate, structural under ES modules). +export {RETRYABLE_STATUSES, isRetryableStatus} from '../config/retryable.js'; + +/** True for the abort reason `AbortSignal.timeout()` produces, false for a caller abort (RETRY-23/24). */ +function isTimeoutAbort(value: unknown): boolean { + return ( + typeof value === 'object' && + value !== null && + 'name' in value && + (value as {readonly name: unknown}).name === 'TimeoutError' + ); +} + +/** + * Reads `.cause` without trusting it. `cause` is an ordinary property, so a throwable built with a + * lazy or hostile accessor can raise from the read itself -- and this walk runs while classifying a + * failure that already happened, where a throw would replace the transport error with a + * classification error and turn a retryable condition into a terminal one. RETRY-22's rule that a + * secondary failure can never mask the upstream one applies here for the same reason it applies to + * the pacing parser: ending the walk is always a safe answer, raising never is. + */ +function causeOf(value: unknown): unknown { + if (typeof value !== 'object' || value === null || !('cause' in value)) { + return undefined; + } + try { + return (value as {readonly cause: unknown}).cause; + } catch { + return undefined; + } +} + +/** + * Retryability as an ALLOW-list (RETRY-2): a throwable qualifies only if it, or something in its + * cause chain, is an I/O error, a timeout, or a status the caller configured as retryable. The walk + * is iterative and identity-tracking, so a cyclic `cause` chain terminates instead of spinning. + * + * The allow-list shape is why RETRY-25 needs no code: a stack-overflow `RangeError` is non-retryable + * because it was never opted in, not because it was screened out. A caller's `AbortError` is + * likewise non-retryable for free (RETRY-23), while a `TimeoutError` is explicitly listed + * (RETRY-24). A transport-level failure -- connection refused, TLS or DNS failure, peer reset -- + * surfaces as an `IoError` subclass and is therefore retryable unconditionally at this level + * (RETRY-4). + * + * @param error - whatever was thrown; any value, not necessarily an `Error`. + * @param statuses - the CONFIGURED set, authoritative on its own -- it both widens and narrows + * relative to `RETRYABLE_STATUSES`, and the built-in classifier is not AND-ed in (RETRY-37). + * @returns true when the failure is a retryable condition. + * + * @internal + */ +export function isRetryableFailure( + error: unknown, + statuses: ReadonlySet, +): boolean { + const seen = new Set(); + let current = error; + while (current !== undefined && current !== null && !seen.has(current)) { + seen.add(current); + // RETRY-3: derived from the carried status at classification time, never a stored per-subclass flag. + if (current instanceof HttpStatusError) return statuses.has(current.status); + if (current instanceof IoError) return true; + if (isTimeoutAbort(current)) return true; + current = causeOf(current); + } + return false; +} + +/** + * The second, orthogonal axis (RETRY-5/RETRY-8): a body-less request is re-sendable iff its method + * is idempotent; a body-bearing one iff its body is replayable. A bare non-idempotent POST is + * therefore not re-sendable even though it has nothing to physically re-send -- the case RETRY-7 + * calls out explicitly. + * + * RETRY-6's `{GET, HEAD, OPTIONS, PUT, DELETE}` set is Phase 1's `http/method.ts` (HTTP-9), imported + * rather than restated. + * + * @param request - the request a retry would re-send. + * @returns true when the request may be sent again. + * + * @internal + */ +export function isResendable(request: Request): boolean { + const {body} = request; + return body === undefined ? isIdempotent(request.method) : body.replayable; +} diff --git a/packages/core/src/retry/engine.test.ts b/packages/core/src/retry/engine.test.ts new file mode 100644 index 0000000..7fb0d3f --- /dev/null +++ b/packages/core/src/retry/engine.test.ts @@ -0,0 +1,798 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/retry/engine.test.ts +// Exercises: RETRY-7/8 (both axes gate), RETRY-20 (a hint replaces the schedule, unjittered), RETRY-22 +// (a pacing failure never masks the upstream failure), RETRY-26/31 (cancellable wait, zero delay +// inline), RETRY-27/RECOV-20 (total-timeout budget with per-attempt shrinking), RETRY-32 (no attempts +// after cancellation), RETRY-34 (suppressed trail on failure, discarded on success, skip-self), +// RETRY-35/RECOV-16 (body released before the wait, bounded buffering), RETRY-36/RECOV-19 (503,503,200 +// terminates on the 200; a surviving response is returned LIVE), RETRY-39/40 (delay precedence; a +// throwing override is non-fatal), RETRY-42/RECOV-28 (per-call state). +import {describe, expect, test} from 'bun:test'; +import {HttpStatusError} from '../body/http-status-error.js'; +import type {Clock} from '../config/clock.js'; +import {Headers} from '../http/headers.js'; +import {Protocol} from '../http/protocol.js'; +import {Request} from '../http/request.js'; +import {Response} from '../http/response.js'; +import {Status} from '../http/status.js'; +import {IoError} from '../io/errors.js'; +import {failure, success, type Outcome} from '../recovery/outcome.js'; +import type {SuppressedErrorLike} from '../suppress.js'; +import {countingResponse} from '../testing/fake-transport.js'; +import {runWithRetry, type RetryConfig, type RetryDispatch} from './engine.js'; +import {retrySettings, type RetrySettings} from './settings.js'; + +const GET = Request.newBuilder().url('https://example.com').build(); +const BARE_POST = Request.newBuilder() + .method('POST') + .url('https://example.com') + .build(); + +/** + * The suppressed pair is asserted on SHAPE, never `instanceof SuppressedError`: the native class is + * absent on this package's Node floor (>=20.3), where `suppress()` returns a structural stand-in and + * an `instanceof` assertion would silently assert nothing. + */ +function isSuppressedShape(value: unknown): value is SuppressedErrorLike { + return ( + value instanceof Error && + value.name === 'SuppressedError' && + 'error' in value && + 'suppressed' in value + ); +} + +/** + * A fake Clock whose `now`/`monotonic` both advance only when a test advances `clockState.ms`, and + * whose `sleep` returns instantly while still honoring CFG-17's cancellation contract -- rejecting + * with the abort reason for an already-aborted signal. Modelling that half matters: the engine + * delegates its inter-attempt wait to `clock.sleep`, so a fake that always resolved would make + * RETRY-26's cancellation path untestable without a real timer. + */ +function fakeClock(clockState: {ms: number}): Clock { + return { + now: () => clockState.ms, + monotonic: () => clockState.ms, + sleep: (_ms, signal) => + signal?.aborted === true + ? Promise.reject(signal.reason as Error) + : Promise.resolve(), + }; +} + +/** A config whose clock advances only when a test advances it, jitter pinned to the midpoint. */ +function configOf( + overrides?: Partial, + clockState = {ms: 0}, +): RetryConfig { + return { + settings: retrySettings(overrides), + clock: fakeClock(clockState), + random: () => 0.5, + }; +} + +/** Serves outcomes in order; the last repeats. Records the requests it saw. */ +function scriptedDispatch( + script: readonly Outcome[], +): RetryDispatch & {calls: Request[]} { + const calls: Request[] = []; + const dispatch = (request: Request): Promise> => { + calls.push(request); + return Promise.resolve( + script[Math.min(calls.length - 1, script.length - 1)] ?? + failure(new Error('empty script')), + ); + }; + return Object.assign(dispatch, {calls}); +} + +describe('eligibility (RETRY-7/8)', () => { + test('a non-retryable failure is surfaced after exactly one attempt', async () => { + const dispatch = scriptedDispatch([failure(new TypeError('bad'))]); + const outcome = await runWithRetry(GET, dispatch, configOf()); + + expect(dispatch.calls).toHaveLength(1); + expect(outcome.kind).toBe('failure'); + }); + + test('a bare POST is not retried even on a retryable failure (RETRY-7)', async () => { + const dispatch = scriptedDispatch([failure(new IoError('reset'))]); + await runWithRetry(BARE_POST, dispatch, configOf()); + + expect(dispatch.calls).toHaveLength(1); + }); + + test('a retryable failure on an idempotent request exhausts the budget', async () => { + const dispatch = scriptedDispatch([failure(new IoError('reset'))]); + await runWithRetry( + GET, + dispatch, + configOf({maxAttempts: 3, fixedDelayMs: 0}), + ); + + expect(dispatch.calls).toHaveLength(3); + }); + + test('maxAttempts of 1 disables retries entirely', async () => { + const dispatch = scriptedDispatch([failure(new IoError('reset'))]); + await runWithRetry(GET, dispatch, configOf({maxAttempts: 1})); + + expect(dispatch.calls).toHaveLength(1); + }); +}); + +describe('status-driven retry (RETRY-36)', () => { + test('503, 503, 200 terminates on the 200', async () => { + const first = countingResponse(503); + const second = countingResponse(503); + const third = countingResponse(200); + const dispatch = scriptedDispatch([ + success(first.response), + success(second.response), + success(third.response), + ]); + + const outcome = await runWithRetry( + GET, + dispatch, + configOf({fixedDelayMs: 0}), + ); + + expect(dispatch.calls).toHaveLength(3); + expect(outcome).toEqual(success(third.response)); + }); + + test('each discarded response is released before the next attempt (RETRY-35)', async () => { + const first = countingResponse(503); + const second = countingResponse(200); + const dispatch = scriptedDispatch([ + success(first.response), + success(second.response), + ]); + + await runWithRetry(GET, dispatch, configOf({fixedDelayMs: 0})); + + expect(first.cancelCount()).toBe(1); + expect(second.cancelCount()).toBe(0); + }); + + test('a response that SURVIVES the gates is returned live and unread', async () => { + const only = countingResponse(503); + const dispatch = scriptedDispatch([success(only.response)]); + + const outcome = await runWithRetry( + GET, + dispatch, + configOf({maxAttempts: 1}), + ); + + expect(outcome).toEqual(success(only.response)); + expect(only.cancelCount()).toBe(0); + }); + + test('a non-retryable error status is returned as a live response, never remapped', async () => { + const only = countingResponse(404); + const dispatch = scriptedDispatch([success(only.response)]); + + const outcome = await runWithRetry( + GET, + dispatch, + configOf({fixedDelayMs: 0}), + ); + + expect(outcome).toEqual(success(only.response)); + expect(only.cancelCount()).toBe(0); + }); +}); + +describe('delay resolution (RETRY-39/40)', () => { + test('a caller override wins over every other source', async () => { + const clock = {ms: 0}; + const config: RetryConfig = { + ...configOf({fixedDelayMs: 5000}, clock), + delayOverride: () => 0, + }; + const dispatch = scriptedDispatch([ + failure(new IoError('reset')), + success(countingResponse(200).response), + ]); + + await runWithRetry(GET, dispatch, config); + + expect(dispatch.calls).toHaveLength(2); + }); + + test('a throwing override is non-fatal and falls back to the schedule (RETRY-40)', async () => { + const config: RetryConfig = { + ...configOf({fixedDelayMs: 0}), + delayOverride: () => { + throw new Error('override exploded'); + }, + }; + const dispatch = scriptedDispatch([ + failure(new IoError('reset')), + success(countingResponse(200).response), + ]); + + const outcome = await runWithRetry(GET, dispatch, config); + + expect(outcome.kind).toBe('success'); + expect(dispatch.calls).toHaveLength(2); + }); +}); + +describe('server pacing hints (RETRY-20/22)', () => { + test('a malformed pacing header never masks the upstream failure (RETRY-22)', async () => { + const response = countingResponse(503) + .response.newBuilder() + .headers(Headers.newBuilder().add('Retry-After', 'garbage').build()) + .build(); + const dispatch = scriptedDispatch([ + success(response), + success(countingResponse(200).response), + ]); + + const outcome = await runWithRetry( + GET, + dispatch, + configOf({fixedDelayMs: 0}), + ); + + expect(outcome.kind).toBe('success'); + }); + + test('a server pacing hint replaces the schedule for that decision (RETRY-20)', async () => { + const clock = {ms: 0}; + const response = countingResponse(503) + .response.newBuilder() + .headers(Headers.newBuilder().add('Retry-After', '0').build()) + .build(); + // fixedDelayMs would be 60s; the hint of 0 replaces it, so the test does not hang. + const dispatch = scriptedDispatch([ + success(response), + success(countingResponse(200).response), + ]); + + const outcome = await runWithRetry( + GET, + dispatch, + configOf({fixedDelayMs: 60_000}, clock), + ); + + expect(outcome.kind).toBe('success'); + expect(dispatch.calls).toHaveLength(2); + }); +}); + +describe('total-timeout budget (RETRY-27)', () => { + test('an exhausted budget stops the loop', async () => { + const clock = {ms: 0}; + const config = configOf({totalTimeoutMs: 50, fixedDelayMs: 0}, clock); + const calls: number[] = []; + const counting: RetryDispatch = (_request, attempt) => { + calls.push(attempt); + clock.ms += 40; + return Promise.resolve(failure(new IoError('reset'))); + }; + + await runWithRetry(GET, counting, config); + + expect(calls).toEqual([1, 2]); + }); + + test('a delay that would overshoot the budget is suppressed, not merely clamped', async () => { + const clock = {ms: 0}; + const config = configOf( + {totalTimeoutMs: 100, fixedDelayMs: 500, maxAttempts: 5}, + clock, + ); + const dispatch = scriptedDispatch([failure(new IoError('reset'))]); + + await runWithRetry(GET, dispatch, config); + + // elapsed(0) + 500 > 100, so the loop surfaces after the first send rather than sleeping out the + // remaining 100ms and dispatching a second attempt with no budget left (RETRY-27, RECOV-20). + expect(dispatch.calls).toHaveLength(1); + }); + + test('a zero budget means unbounded, not immediately exhausted', async () => { + const dispatch = scriptedDispatch([failure(new IoError('reset'))]); + await runWithRetry( + GET, + dispatch, + configOf({totalTimeoutMs: 0, maxAttempts: 3, fixedDelayMs: 0}), + ); + + expect(dispatch.calls).toHaveLength(3); + }); +}); + +describe('cancellation (RETRY-26/32)', () => { + test('an already-aborted signal launches no attempt at all', async () => { + const controller = new AbortController(); + controller.abort(); + const dispatch = scriptedDispatch([ + success(countingResponse(200).response), + ]); + + const outcome = await runWithRetry(GET, dispatch, { + ...configOf(), + signal: controller.signal, + }); + + expect(dispatch.calls).toHaveLength(0); + expect(outcome.kind).toBe('failure'); + }); + + test('aborting during the backoff wait stops the loop promptly', async () => { + const controller = new AbortController(); + const config: RetryConfig = { + ...configOf({fixedDelayMs: 60_000, maxAttempts: 5}), + signal: controller.signal, + }; + const dispatch: RetryDispatch = () => { + queueMicrotask(() => { + controller.abort(); + }); + return Promise.resolve(failure(new IoError('reset'))); + }; + + const outcome = await runWithRetry(GET, dispatch, config); + + expect(outcome.kind).toBe('failure'); + }); +}); + +describe('suppressed trail (RETRY-34)', () => { + test('prior attempt failures ride along as suppressed on the surfaced error', async () => { + const dispatch = scriptedDispatch([ + failure(new IoError('first')), + failure(new IoError('second')), + ]); + + const outcome = await runWithRetry( + GET, + dispatch, + configOf({maxAttempts: 2, fixedDelayMs: 0}), + ); + + expect(outcome.kind).toBe('failure'); + if (outcome.kind !== 'failure') return; + expect(isSuppressedShape(outcome.error)).toBe(true); + }); + + test('the trail is discarded entirely on eventual success', async () => { + const dispatch = scriptedDispatch([ + failure(new IoError('first')), + success(countingResponse(200).response), + ]); + + const outcome = await runWithRetry( + GET, + dispatch, + configOf({fixedDelayMs: 0}), + ); + + expect(outcome.kind).toBe('success'); + }); +}); + +describe('suppressed trail -- skip-self and single-attempt shapes (RETRY-34)', () => { + test('a reused instance never suppresses itself (RETRY-34 skip-self)', async () => { + const reused = new IoError('same instance every time'); + const dispatch = scriptedDispatch([failure(reused)]); + + const outcome = await runWithRetry( + GET, + dispatch, + configOf({maxAttempts: 3, fixedDelayMs: 0}), + ); + + expect(outcome).toEqual(failure(reused)); + }); + + test('a single failed attempt surfaces its error unwrapped', async () => { + const only = new TypeError('not retryable'); + const dispatch = scriptedDispatch([failure(only)]); + + expect(await runWithRetry(GET, dispatch, configOf())).toEqual( + failure(only), + ); + }); + + test('a discarded 503 becomes a buffered HttpStatusError in the trail (RECOV-16)', async () => { + // The 503 is DISCARDED (attempt 1 retries), so it is remapped and buffered into the trail; the + // second attempt's IoError is what the loop surfaces. A 503 that instead SURVIVES the gates is + // never remapped -- covered by 'a response that SURVIVES the gates is returned live and unread'. + const dispatch = scriptedDispatch([ + success(countingResponse(503).response), + failure(new IoError('final')), + ]); + + const outcome = await runWithRetry( + GET, + dispatch, + configOf({maxAttempts: 2, fixedDelayMs: 0}), + ); + + expect(outcome.kind).toBe('failure'); + if (outcome.kind !== 'failure') return; + expect(isSuppressedShape(outcome.error)).toBe(true); + if (!isSuppressedShape(outcome.error)) return; + expect(outcome.error.error).toBeInstanceOf(IoError); + expect(outcome.error.suppressed).toBeInstanceOf(HttpStatusError); + }); +}); + +describe('the inter-attempt wait (RETRY-26/31)', () => { + test('a positive delay is awaited through the injected clock, and the loop then continues', async () => { + const slept: number[] = []; + const config: RetryConfig = { + settings: retrySettings({fixedDelayMs: 250, maxAttempts: 2}), + clock: { + now: () => 0, + monotonic: () => 0, + sleep: ms => { + slept.push(ms); + return Promise.resolve(); + }, + }, + random: () => 0.5, + }; + const dispatch = scriptedDispatch([ + failure(new IoError('reset')), + success(countingResponse(200).response), + ]); + + const outcome = await runWithRetry(GET, dispatch, config); + + expect(outcome.kind).toBe('success'); + // RETRY-13: the wait goes through the single-sourced Clock seam, never a private timer. + expect(slept).toEqual([250]); + }); + + test('a zero delay short-circuits the clock entirely (RETRY-31)', async () => { + let sleeps = 0; + const config: RetryConfig = { + settings: retrySettings({fixedDelayMs: 0, maxAttempts: 2}), + clock: { + now: () => 0, + monotonic: () => 0, + sleep: () => { + sleeps += 1; + return Promise.resolve(); + }, + }, + random: () => 0.5, + }; + const dispatch = scriptedDispatch([ + failure(new IoError('reset')), + success(countingResponse(200).response), + ]); + + await runWithRetry(GET, dispatch, config); + + expect(sleeps).toBe(0); + }); +}); + +describe('the inter-attempt wait -- degenerate and hostile delays', () => { + test('a negative delay from a caller override never reaches the clock (RETRY-40)', async () => { + let sleeps = 0; + const config: RetryConfig = { + settings: retrySettings({maxAttempts: 2}), + clock: { + now: () => 0, + monotonic: () => 0, + sleep: () => { + sleeps += 1; + return Promise.reject(new RangeError('negative')); + }, + }, + random: () => 0.5, + delayOverride: () => -5, + }; + const dispatch = scriptedDispatch([ + failure(new IoError('reset')), + success(countingResponse(200).response), + ]); + + const outcome = await runWithRetry(GET, dispatch, config); + + expect(outcome.kind).toBe('success'); + expect(sleeps).toBe(0); + }); + + test('a clock whose sleep fails for a reason other than abort is not swallowed', async () => { + const config: RetryConfig = { + settings: retrySettings({fixedDelayMs: 10, maxAttempts: 3}), + clock: { + now: () => 0, + monotonic: () => 0, + sleep: () => Promise.reject(new RangeError('misbehaving clock')), + }, + random: () => 0.5, + }; + const dispatch = scriptedDispatch([failure(new IoError('reset'))]); + + const outcome = await runWithRetry(GET, dispatch, config); + + // Folded into the outcome rather than escaping as a bare rejection, and it stops the loop + // instead of silently becoming an extra attempt. + expect(outcome.kind).toBe('failure'); + if (outcome.kind !== 'failure') return; + expect(isSuppressedShape(outcome.error)).toBe(true); + if (!isSuppressedShape(outcome.error)) return; + expect(outcome.error.error).toBeInstanceOf(RangeError); + expect(dispatch.calls).toHaveLength(1); + }); +}); + +describe('cancellation while an attempt is in flight (RETRY-32)', () => { + test('a retryable response arriving after the abort is released, not leaked (RETRY-32)', async () => { + const controller = new AbortController(); + const inFlight = countingResponse(503); + const dispatch: RetryDispatch = () => { + // Aborts while this very attempt is in flight, so its response arrives to a cancelled call. + controller.abort(); + return Promise.resolve(success(inFlight.response)); + }; + + const outcome = await runWithRetry(GET, dispatch, { + ...configOf({fixedDelayMs: 0, maxAttempts: 3}), + signal: controller.signal, + }); + + expect(outcome.kind).toBe('failure'); + expect(inFlight.cancelCount()).toBe(1); + }); + + test('a response that ENDS the loop is handed to the caller live, even after an abort (RETRY-32)', async () => { + const controller = new AbortController(); + const arriving = countingResponse(200); + const dispatch: RetryDispatch = () => { + controller.abort(); + return Promise.resolve(success(arriving.response)); + }; + + const outcome = await runWithRetry(GET, dispatch, { + ...configOf({fixedDelayMs: 0}), + signal: controller.signal, + }); + + // Not a leak: ownership transfers to the caller, which is the only reader that could close it. + // RETRY-32's "closed rather than leaked" bites on responses the ENGINE discards, above. + expect(outcome).toEqual(success(arriving.response)); + expect(arriving.cancelCount()).toBe(0); + }); + + test('an abort raised WHILE the wait is pending settles it promptly (RETRY-26)', async () => { + const controller = new AbortController(); + const config: RetryConfig = { + ...configOf({fixedDelayMs: 60_000, maxAttempts: 5}), + signal: controller.signal, + }; + const dispatch: RetryDispatch = () => { + controller.abort(); + return Promise.resolve(failure(new IoError('reset'))); + }; + + const outcome = await runWithRetry(GET, dispatch, config); + + // The fake clock rejects with the abort reason; the engine absorbs it and the next iteration's + // RETRY-32 check is what actually stops the loop. + expect(outcome.kind).toBe('failure'); + }); +}); + +describe('a throwing attempt still carries the trail (RETRY-33/34)', () => { + test('a throw from inside the attempt is folded into a failure outcome, trail intact', async () => { + const calls: number[] = []; + const dispatch: RetryDispatch = (_request, attempt) => { + calls.push(attempt); + if (attempt === 1) return Promise.resolve(failure(new IoError('first'))); + throw new RangeError('decision blew up'); + }; + + const outcome = await runWithRetry( + GET, + dispatch, + configOf({maxAttempts: 3, fixedDelayMs: 0}), + ); + + expect(calls).toEqual([1, 2]); + expect(outcome.kind).toBe('failure'); + if (outcome.kind !== 'failure') return; + expect(isSuppressedShape(outcome.error)).toBe(true); + if (!isSuppressedShape(outcome.error)) return; + expect(outcome.error.error).toBeInstanceOf(RangeError); + // RETRY-34: attempt 1's failure would have been lost had the throw escaped as a rejection. + expect((outcome.error.suppressed as Error).message).toBe('first'); + }); +}); + +describe('the suppressed trail with more than two entries (RETRY-34)', () => { + test('three distinct attempt failures fold into a nested chain', async () => { + const dispatch = scriptedDispatch([ + failure(new IoError('first')), + failure(new IoError('second')), + failure(new IoError('third')), + ]); + + const outcome = await runWithRetry( + GET, + dispatch, + configOf({maxAttempts: 3, fixedDelayMs: 0}), + ); + + expect(outcome.kind).toBe('failure'); + if (outcome.kind !== 'failure') return; + expect(isSuppressedShape(outcome.error)).toBe(true); + if (!isSuppressedShape(outcome.error)) return; + expect((outcome.error.error as Error).message).toBe('third'); + // The two priors folded into a nested pair, oldest innermost. + const folded = outcome.error.suppressed; + expect(isSuppressedShape(folded)).toBe(true); + if (!isSuppressedShape(folded)) return; + expect((folded.error as Error).message).toBe('second'); + expect((folded.suppressed as Error).message).toBe('first'); + }); +}); + +describe('a failing release never becomes primary (RECOV-12, RETRY-35)', () => { + /** + * A response the engine must close ITSELF. A caller-widened sub-400 status makes `toHttpError` + * return null without consuming or closing (BODY-31 hands it back intact), so the engine's own + * release is the first and only close -- and unlike the 4xx/5xx path, where the body is already + * drained and `cancel()` is a no-op, here the source's cancel hook really runs and can fail. + */ + function uncancellableResponse(): Response { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.from([1])); + }, + cancel() { + throw new IoError('cancel blew up'); + }, + }); + return Response.newBuilder() + .request(GET) + .protocol(Protocol.HTTP_1_1) + .status(Status.of(204)) + .body(body) + .build(); + } + + test('a release that throws does not discard the retry decision it was released for', async () => { + let sends = 0; + const dispatch: RetryDispatch = () => { + sends += 1; + return Promise.resolve(success(uncancellableResponse())); + }; + + const outcome = await runWithRetry(GET, dispatch, { + ...configOf({ + maxAttempts: 2, + fixedDelayMs: 0, + retryableStatuses: new Set([204]), + }), + }); + + // The whole budget is spent and the surviving response is returned, exactly as if the release + // had succeeded. A bare `finally { await close() }` would instead have thrown the teardown + // failure out of the decision it was returning -- one send, and a cancel error where a retry + // decision belonged. + expect(sends).toBe(2); + expect(outcome.kind).toBe('success'); + }); +}); + +describe('a failing release -- masking and self-suppression', () => { + test('the drain failure stays primary when the release fails too', async () => { + const body = new ReadableStream({ + pull() { + throw new IoError('socket died mid-drain'); + }, + cancel() { + throw new IoError('cancel failed too'); + }, + }); + const hostile = Response.newBuilder() + .request(GET) + .protocol(Protocol.HTTP_1_1) + .status(Status.of(503)) + .body(body) + .build(); + const dispatch = scriptedDispatch([success(hostile)]); + + const outcome = await runWithRetry( + GET, + dispatch, + configOf({maxAttempts: 3, fixedDelayMs: 0}), + ); + + expect(outcome.kind).toBe('failure'); + if (outcome.kind !== 'failure') return; + const primary = isSuppressedShape(outcome.error) + ? outcome.error.error + : outcome.error; + expect((primary as Error).message).toBe('socket died mid-drain'); + }); + + test('a release failure is never suppressed under itself', async () => { + // `Response.close()` memoizes its release promise, and cancelling an ERRORED stream rejects with + // the stream's stored error rather than calling the cancel hook -- so the release hands back the + // very instance already propagating. Without an identity guard that value would suppress itself. + const body = new ReadableStream({ + pull() { + throw new IoError('socket died mid-drain'); + }, + cancel() { + throw new IoError('cancel failed too'); + }, + }); + const hostile = Response.newBuilder() + .request(GET) + .protocol(Protocol.HTTP_1_1) + .status(Status.of(503)) + .body(body) + .build(); + const dispatch = scriptedDispatch([success(hostile)]); + + const outcome = await runWithRetry( + GET, + dispatch, + configOf({maxAttempts: 3, fixedDelayMs: 0}), + ); + + expect(outcome.kind).toBe('failure'); + if (outcome.kind !== 'failure') return; + expect(isSuppressedShape(outcome.error)).toBe(false); + }); +}); + +describe('budget precondition', () => { + test('a non-finite maxAttempts is rejected at the engine, not left to loop forever', async () => { + // Both adapters reach the engine with settings a caller supplied. `retryStep` guards the + // per-call override route; this is the guard for every other route, including + // `dispatchWithRetry`, which takes a RetryConfig straight from its caller. + const rogue = { + ...retrySettings({fixedDelayMs: 0}), + maxAttempts: Number.POSITIVE_INFINITY, + }; + const dispatch = scriptedDispatch([failure(new IoError('reset'))]); + + const reason = await runWithRetry(GET, dispatch, { + ...configOf(), + settings: rogue, + }).then( + () => undefined, + (error: unknown) => error, + ); + + expect((reason as Error).message).toContain('finite count >= 1'); + expect(dispatch.calls).toHaveLength(0); + }); +}); + +describe('per-call state (RETRY-42, RECOV-28)', () => { + test('concurrent invocations do not clobber each other’s budget', async () => { + const settings = retrySettings({maxAttempts: 3, fixedDelayMs: 0}); + const config: RetryConfig = { + settings, + clock: fakeClock({ms: 0}), + random: () => 0.5, + }; + const left = scriptedDispatch([failure(new IoError('left'))]); + const right = scriptedDispatch([failure(new IoError('right'))]); + + await Promise.all([ + runWithRetry(GET, left, config), + runWithRetry(GET, right, config), + ]); + + expect(left.calls).toHaveLength(3); + expect(right.calls).toHaveLength(3); + }); +}); diff --git a/packages/core/src/retry/engine.ts b/packages/core/src/retry/engine.ts new file mode 100644 index 0000000..3f6da10 --- /dev/null +++ b/packages/core/src/retry/engine.ts @@ -0,0 +1,415 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/retry/engine.ts +import {HttpStatusError, toHttpError} from '../body/http-status-error.js'; +import {invariant} from '../invariant.js'; +import type {Clock} from '../config/clock.js'; +import type {Request} from '../http/request.js'; +import type {Response} from '../http/response.js'; +import {failure, type Outcome} from '../recovery/outcome.js'; +import {suppress} from '../suppress.js'; +import {stampAttempt} from './attempt-stamp.js'; +import {computeDelay} from './backoff.js'; +import {isResendable, isRetryableFailure} from './classify.js'; +import {parsePacingHint} from './pacing.js'; +import type {RetrySettings} from './settings.js'; + +// Phase 7b retrofit (deferred): RETRY-40's "log and fall back" and the two SHOULD-level structured +// events -- attempt-failed and retries-exhausted -- are specified in this plan's Task 8 but are +// APPLIED BY PHASE 7B's Task 9, not here. 5a executes before 7b, so an `observability/logger.js` +// import at this point would not resolve; 7b in turn needs 5a's FakeTransport, so the dependency +// cannot run the other way. See docs/superpowers/plans/2026-07-26-phase5a-retry.md's 2026-07-29 +// correction. + +/** + * One attempt: dispatch the (possibly stamped) request and report the outcome without throwing. + * + * @internal + */ +export type RetryDispatch = ( + request: Request, + attempt: number, +) => Promise>; + +/** + * Everything {@link runWithRetry} needs beyond the request and the dispatch callback, bundled into + * one trailing object so the function stays at ESLint's three-parameter ceiling. + * + * @internal + */ +export interface RetryConfig { + readonly settings: RetrySettings; + readonly signal?: AbortSignal | undefined; + /** + * Phase 7a's `Clock` seam (CFG-15). `clock.monotonic()` measures the total-timeout budget (CFG-16: + * elapsed-time math never uses wall-clock, which MAY move backwards); `clock.now()` supplies + * `parsePacingHint`'s wall-clock instant, since a `Retry-After` HTTP-date is an absolute instant, + * not an elapsed duration. Never `Date.now()` directly. + */ + readonly clock: Clock; + /** Injectable randomness -- jitter and the X-RateLimit-Reset spread both draw from it. */ + readonly random: () => number; + /** Highest-precedence delay source (RETRY-39). A throw is non-fatal (RETRY-40). */ + readonly delayOverride?: + ((attempt: number) => number | undefined) | undefined; +} + +interface LoopState { + readonly config: RetryConfig; + readonly request: Request; + readonly attempt: number; + readonly startedAt: number; +} + +type Decision = + | {readonly kind: 'stop'; readonly outcome: Outcome} + | {readonly kind: 'retry'; readonly error: unknown; readonly delayMs: number}; + +function elapsed(state: LoopState): number { + return state.config.clock.monotonic() - state.startedAt; +} + +/** A budget of `undefined` or `0` disables the deadline (RETRY-27, RECOV-20). */ +function budgetExhausted(state: LoopState): boolean { + const budget = state.config.settings.totalTimeoutMs; + if (budget === undefined || budget === 0) return false; + return elapsed(state) >= budget; +} + +/** + * RETRY-27's separate belt-and-braces clause ("the computed delay is additionally clamped so it + * cannot overshoot the budget"). Deliberately defensive: {@link overshootsBudget} runs first on the + * same delay and stops the loop unless `delay <= budget - elapsed`, so this `Math.min` narrows + * nothing except across the clock drift between the two `elapsed()` reads. It ships because the + * requirement lists it separately from the abort, not because a test can drive it. + */ +function clampToBudget(delayMs: number, state: LoopState): number { + const budget = state.config.settings.totalTimeoutMs; + if (budget === undefined || budget === 0) return delayMs; + return Math.max(0, Math.min(delayMs, budget - elapsed(state))); +} + +/** + * RETRY-27/RECOV-20's third abort condition: a delay that would push cumulative elapsed time PAST + * the budget is SUPPRESSED and the last failure surfaced, not merely shortened. The clamp above is + * the requirement's separate belt-and-braces clause, not a substitute for this check -- without it + * the loop would sleep out the remainder of the budget and then dispatch one more attempt with + * nothing left. + */ +function overshootsBudget(delayMs: number, state: LoopState): boolean { + const budget = state.config.settings.totalTimeoutMs; + if (budget === undefined || budget === 0) return false; + return elapsed(state) + delayMs > budget; +} + +/** + * RETRY-40: a throwing user override is ignored, never fatal. The "log the failure" half of RETRY-40 + * is Phase 7b's Task 9 (see the retrofit note at the top of this file). + */ +function callerOverride(state: LoopState): number | undefined { + const {delayOverride} = state.config; + if (delayOverride === undefined) return undefined; + try { + return delayOverride(state.attempt); + } catch { + return undefined; + } +} + +/** RETRY-39: caller override -> server pacing hint -> fixed delay -> exponential backoff. */ +function resolveDelay(hint: number | null, state: LoopState): number { + const override = callerOverride(state); + if (override !== undefined) return override; + // RETRY-20/RECOV-22: a hint REPLACES the schedule for this one decision and receives no additional + // symmetric jitter. + if (hint !== null) return hint; + return computeDelay( + state.attempt, + state.config.settings, + state.config.random, + ); +} + +/** + * Turns a response the loop is DISCARDING into the throwable its trail entry carries, buffering a + * bounded copy of the body (RETRY-35/RECOV-16). Only ever called on a response that already failed + * the gates -- a surviving response is returned live and untouched. + */ +async function retire(response: Response): Promise { + // toHttpError returns null for a sub-400 status, reachable only when a caller widens the + // retryable set to include one. Fabricating an HttpStatusError there carries a status outside + // BODY-31's 400-599 band -- a deliberate, narrow exception: the discarded response still owes + // RETRY-34 a trail entry, and inventing a leaf error class for a caller-opted-in edge would + // breach this phase's "no new error leaf classes" constraint for less benefit. The response is + // NOT consumed on this path (BODY-31 hands it back intact), so the caller's `finally` closes it. + return ( + (await toHttpError(response)) ?? + new HttpStatusError(response.status.code, undefined, undefined) + ); +} + +/** Marks "the response was released without incident", distinct from any value `close()` could throw. */ +const RELEASED_CLEANLY = Symbol('dexpace.retry.released'); + +/** What the schedule step decided, before the release outcome is folded in. */ +interface Schedule { + readonly error: unknown; + readonly delayMs: number; + readonly overshootsBudget: boolean; +} + +/** + * Reads the pacing hint off the STILL-OPEN response and resolves the delay. + * + * Ordering is load-bearing: `toHttpError` drains the body and drops the headers, so the hint must be + * read first. + */ +async function scheduleFrom( + outcome: Outcome, + state: LoopState, +): Promise { + // The exception path skips the header step, having no headers (RETRY-39). + const hint = + outcome.kind === 'success' + ? parsePacingHint( + outcome.value.headers, + state.config.clock.now(), + state.config.random, + ) + : null; + const error = + outcome.kind === 'success' ? await retire(outcome.value) : outcome.error; + const delayMs = resolveDelay(hint, state); + // Tested BEFORE the clamp: the clamp would hide the overshoot it exists to report. + return { + error, + overshootsBudget: overshootsBudget(delayMs, state), + delayMs: clampToBudget(delayMs, state), + }; +} + +/** + * Releases a discarded response, reporting rather than raising whatever release itself threw. + * + * `Response.close()` is documented to rethrow whatever cancelling the body raises (everything except + * the `TypeError` a locked stream reports), so it is not a call that can sit in a bare `finally`: + * there it would replace the value being returned, or replace an in-flight throwable with the + * teardown failure -- the exact inversion RECOV-12 forbids and `suppress()` exists to prevent. + */ +async function releaseQuietly( + response: Response | undefined, +): Promise { + if (response === undefined) return RELEASED_CLEANLY; + try { + await response.close(); + return RELEASED_CLEANLY; + } catch (error) { + return error; + } +} + +/** + * Keeps `primary` primary, with a release failure riding along as suppressed (RECOV-12, RETRY-22's + * "a teardown failure can never mask the upstream failure"). + * + * The identity guard is not decorative. `Response.close()` memoizes its release promise, so a close + * that already failed inside `toHttpError`'s own `finally` hands the SAME rejection back to the + * second caller -- without this check that instance would be suppressed under itself. + */ +function withReleaseFailure( + primary: unknown, + releaseFailure: unknown, +): unknown { + if (releaseFailure === RELEASED_CLEANLY || releaseFailure === primary) { + return primary; + } + return suppress( + primary, + releaseFailure, + 'releasing the discarded response failed', + ); +} + +/** + * Retires the response the loop is discarding and schedules the wait, releasing the response on + * every exit (RETRY-35's second clause) without ever letting the release outcome become primary. + * + * The budget-overshoot abort lands HERE rather than in `decideRetry`'s gate block because the delay + * it tests is not known until the pacing hint has been read off the live response. By that point the + * response is already retired, so RETRY-27's "surface the last failure unchanged" surfaces the + * retired `HttpStatusError` as a Failure -- never a live response, which is what the gates above + * return. + */ +async function retireAndSchedule( + outcome: Outcome, + state: LoopState, +): Promise { + const response = outcome.kind === 'success' ? outcome.value : undefined; + let schedule: Schedule; + try { + schedule = await scheduleFrom(outcome, state); + } catch (error) { + throw withReleaseFailure(error, await releaseQuietly(response)); + } + const error = withReleaseFailure( + schedule.error, + await releaseQuietly(response), + ); + return schedule.overshootsBudget + ? {kind: 'stop', outcome: failure(error)} + : {kind: 'retry', error, delayMs: schedule.delayMs}; +} + +function isRetryableOutcome( + outcome: Outcome, + settings: RetrySettings, +): boolean { + return outcome.kind === 'success' + ? settings.retryableStatuses.has(outcome.value.status.code) + : isRetryableFailure(outcome.error, settings.retryableStatuses); +} + +async function decideRetry( + outcome: Outcome, + state: LoopState, +): Promise { + const {settings} = state.config; + // RETRY-8: BOTH axes must hold. Gates run BEFORE any remap so a surviving response stays live. + if (!isRetryableOutcome(outcome, settings)) return {kind: 'stop', outcome}; + if (!isResendable(state.request)) return {kind: 'stop', outcome}; + if (state.attempt >= settings.maxAttempts) return {kind: 'stop', outcome}; + if (budgetExhausted(state)) return {kind: 'stop', outcome}; + return retireAndSchedule(outcome, state); +} + +/** + * RETRY-34: prior failures ride along as `suppressed` on the surfaced error; the surfaced instance + * itself is skipped, so a reused throwable cannot suppress itself. On success the trail is discarded + * whole. + * + * The suppressed pair is a binary shape, so N entries fold into a nested chain. Built through Phase + * 4b's `suppress()` helper rather than `new SuppressedError(...)`: the native class reached Node only + * in 24.0.0 and this package's floor is `>=20.3`, so the direct form neither type-checks nor runs + * there. Argument order is controlled explicitly -- native `using` disposal builds the pair the other + * way round, making the *later* error primary. + */ +function withTrail( + outcome: Outcome, + trail: readonly unknown[], +): Outcome { + if (outcome.kind === 'success') return outcome; + const prior = trail.filter(entry => entry !== outcome.error); + if (prior.length === 0) return outcome; + const folded = prior.reduce((accumulated, entry) => + suppress(entry, accumulated, 'earlier retry attempt failed'), + ); + return failure(suppress(outcome.error, folded, 'retry attempts exhausted')); +} + +/** + * RETRY-26/31: the cancellable inter-attempt wait. + * + * Delegates to Phase 7a's `Clock.sleep` (CFG-17) rather than hand-rolling a second + * `setTimeout`-plus-abort-listener: `sleep` already races the timer against the signal, clears the + * timer on both exits (RETRY-45's scheduler hygiene, which has no scheduler object to own in this + * port), and rejects promptly for a signal that aborted earlier. Duplicating it here would be the + * same second-implementation the Phase 7a retrofit removed for the RFC 1123 parser and the + * retryable-status set, and it would put the wait outside the injected seam -- forcing real timers + * into a unit suite `docs/knowledge/testing.md` requires to be deterministic. + * + * A non-positive delay short-circuits before `sleep` is reached: it continues inline with no timer + * (RETRY-31), which is reachable after RETRY-17's past-instant hint and after the budget clamp, and + * it is also what keeps a caller `delayOverride` returning a negative number out of `sleep`'s + * negative-duration rejection (RETRY-40 makes a bad override non-fatal). + * + * Cancellation RESOLVES here rather than propagating: RETRY-26 wants the loop's next iteration to + * observe the signal and stop through its own RETRY-32 path, so the abort rejection is the one + * expected failure and is deliberately absorbed. Any other rejection is re-thrown. + */ +async function waitFor(delayMs: number, config: RetryConfig): Promise { + if (delayMs <= 0) return; + try { + await config.clock.sleep(delayMs, config.signal); + } catch (error) { + // The only tolerable rejection is the abort reason CFG-17 rejects with; anything else (a + // misbehaving injected clock) must not be swallowed into a silent extra attempt. + if (config.signal?.aborted !== true) throw error; + } +} + +/** One attempt: stamp, dispatch, and decide. Extracted so the loop can wrap it in a single catch. */ +async function runAttempt( + dispatch: RetryDispatch, + state: LoopState, +): Promise { + const stamped = stampAttempt( + state.request, + state.attempt, + state.config.settings.attemptHeaderName, + ); + return decideRetry(await dispatch(stamped, state.attempt), state); +} + +/** + * The one retry loop (RETRY-13/RETRY-14, RECOV-30). Both entry points -- the RETRY pillar step and + * the recovery-chain wrapper -- call this, so the schedule, the classifier, and the budget cannot + * drift. + * + * Every piece of per-call state is a local (RETRY-42/RECOV-28): concurrent invocations sharing one + * `RetryConfig` cannot clobber each other's attempt count or start instant. + * + * RETRY-30's trampoline requirement is satisfied by the language: an `await` loop is already + * iterative, so N retries build no continuation chain and no stack growth. RETRY-33's "every + * terminal path returns an Outcome" is honored literally -- an attempt that throws is folded into a + * failure outcome carrying the trail, rather than left to surface as a bare rejected promise that + * would drop RETRY-34's suppressed attempts on the floor. + * + * @param request - the captured template every attempt re-sends. + * @param dispatch - performs one attempt and reports its outcome without throwing. + * @param config - settings, clock, randomness, signal, and the optional delay override. + * @returns the terminal outcome, with RETRY-34's suppressed trail attached on failure. + * + * @internal + */ +export async function runWithRetry( + request: Request, + dispatch: RetryDispatch, + config: RetryConfig, +): Promise> { + // The one precondition both adapters share. `retrySettings()` already enforces it on the + // configured route and `retryStep` re-enforces it on the per-call override route, but this is the + // single choke point every caller passes through -- and a non-finite budget does not fail loudly + // on its own: it makes the `attempt >= maxAttempts` gate permanently false, so the loop simply + // never stops. Asserted once per call, never per attempt. + invariant( + Number.isFinite(config.settings.maxAttempts) && + config.settings.maxAttempts >= 1, + `retry maxAttempts must be a finite count >= 1, got ${String(config.settings.maxAttempts)}`, + ); + const startedAt = config.clock.monotonic(); + const trail: unknown[] = []; + + for (let attempt = 1; ; attempt += 1) { + // RETRY-32: once the caller has cancelled, launch no further attempt. + if (config.signal?.aborted === true) { + return withTrail(failure(config.signal.reason), trail); + } + + try { + const decision = await runAttempt(dispatch, { + config, + request, + attempt, + startedAt, + }); + if (decision.kind === 'stop') return withTrail(decision.outcome, trail); + trail.push(decision.error); + await waitFor(decision.delayMs, config); + } catch (error) { + // RETRY-33 literally, not merely as a rejected promise. Three things under here can throw -- + // `stampAttempt`'s header build, `toHttpError`'s body drain, and a misbehaving injected + // clock's `sleep` -- and letting any of them escape would discard the whole suppressed trail + // RETRY-34 requires the surfaced failure to carry. + return withTrail(failure(error), trail); + } + } +} diff --git a/packages/core/src/retry/pacing.test.ts b/packages/core/src/retry/pacing.test.ts new file mode 100644 index 0000000..21a53cb --- /dev/null +++ b/packages/core/src/retry/pacing.test.ts @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/retry/pacing.test.ts +// Exercises: RETRY-15 (all recognized forms), RETRY-16/RECOV-23 (total, malformed -> null not 0), +// RETRY-17 (past instant -> 0), RETRY-18/RECOV-26 (365-day ceiling), RETRY-19 (strict decimal grammar +// before any float parse), RETRY-21/RECOV-24 (fixed precedence, first parseable wins), RECOV-25 +// (X-RateLimit-Reset positive jitter). +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import {Headers} from '../http/headers.js'; +import {parsePacingHint} from './pacing.js'; + +const NOW = Date.UTC(2026, 0, 1, 0, 0, 0); +const noJitter = (): number => 0; + +function headersOf(entries: Record): Headers { + let builder = Headers.newBuilder(); + for (const [name, value] of Object.entries(entries)) { + builder = builder.add(name, value); + } + return builder.build(); +} + +describe('Retry-After as delta-seconds (RETRY-15)', () => { + test('an integer is honored', () => { + expect( + parsePacingHint(headersOf({'Retry-After': '30'}), NOW, noJitter), + ).toBe(30_000); + }); + + test('a fractional value is honored to sub-second resolution', () => { + expect( + parsePacingHint(headersOf({'Retry-After': '1.5'}), NOW, noJitter), + ).toBe(1500); + }); + + test('zero is honored as an immediate retry', () => { + expect( + parsePacingHint(headersOf({'Retry-After': '0'}), NOW, noJitter), + ).toBe(0); + }); +}); + +describe('Retry-After as an HTTP-date (RETRY-15)', () => { + test('a full RFC 1123 date resolves to the delta', () => { + const value = 'Thu, 01 Jan 2026 00:00:10 GMT'; + expect( + parsePacingHint(headersOf({'Retry-After': value}), NOW, noJitter), + ).toBe(10_000); + }); + + test('a single-digit day is tolerated', () => { + const value = 'Thu, 1 Jan 2026 00:00:10 GMT'; + expect( + parsePacingHint(headersOf({'Retry-After': value}), NOW, noJitter), + ).toBe(10_000); + }); + + test('the informational weekday is ignored, even when wrong', () => { + const value = 'Mon, 01 Jan 2026 00:00:10 GMT'; + expect( + parsePacingHint(headersOf({'Retry-After': value}), NOW, noJitter), + ).toBe(10_000); + }); + + test('a date already in the past yields zero, not null (RETRY-17)', () => { + const value = 'Thu, 01 Jan 2026 00:00:00 GMT'; + expect( + parsePacingHint(headersOf({'Retry-After': value}), NOW + 5000, noJitter), + ).toBe(0); + }); + + test('a year below 100 yields no hint rather than an instant 1900 years in the past (RETRY-16)', () => { + // The regression this guards: `Date.UTC` maps a year in [0,99] into the 1900s, so an unguarded + // parse turns this into 1926 -- a delta so negative it clamps to 0, i.e. RETRY-17's "retry + // immediately". A malformed header must fall back to backoff, never hammer the server. + expect( + parsePacingHint( + headersOf({'Retry-After': 'Thu, 01 Jan 0026 00:00:00 GMT'}), + NOW, + noJitter, + ), + ).toBeNull(); + }); + + test('an out-of-range field is rejected rather than rolled over (RETRY-16)', () => { + expect( + parsePacingHint( + headersOf({'Retry-After': 'Thu, 32 Jan 2026 00:00:10 GMT'}), + NOW, + noJitter, + ), + ).toBeNull(); + expect( + parsePacingHint( + headersOf({'Retry-After': 'Thu, 01 Jan 2026 24:00:10 GMT'}), + NOW, + noJitter, + ), + ).toBeNull(); + expect( + parsePacingHint( + headersOf({'Retry-After': 'Thu, 01 Foo 2026 00:00:10 GMT'}), + NOW, + noJitter, + ), + ).toBeNull(); + }); +}); + +describe('strict decimal screening (RETRY-19)', () => { + test('type-suffixed, hex-float, NaN, and Infinity forms are rejected', () => { + for (const value of [ + '30d', + '30f', + '0x1p3', + 'NaN', + 'Infinity', + '-Infinity', + '1e3', + '+30', + ' 30 ', + ]) { + expect( + parsePacingHint(headersOf({'Retry-After': value}), NOW, noJitter), + ).toBeNull(); + } + }); + + test('a negative delta maps to no hint, never a zero delay (RETRY-16)', () => { + expect( + parsePacingHint(headersOf({'Retry-After': '-5'}), NOW, noJitter), + ).toBeNull(); + }); +}); + +describe('millisecond variants (RETRY-15)', () => { + test('retry-after-ms is honored', () => { + expect( + parsePacingHint(headersOf({'retry-after-ms': '250'}), NOW, noJitter), + ).toBe(250); + }); + + test('x-ms-retry-after-ms is honored', () => { + expect( + parsePacingHint(headersOf({'x-ms-retry-after-ms': '250'}), NOW, noJitter), + ).toBe(250); + }); + + test('a malformed millisecond value falls through to no hint', () => { + expect( + parsePacingHint(headersOf({'retry-after-ms': '25.5'}), NOW, noJitter), + ).toBeNull(); + }); +}); + +describe('X-RateLimit-Reset (RETRY-15, RECOV-25)', () => { + test('an epoch-seconds reset resolves to the delta', () => { + const reset = String(Math.floor(NOW / 1000) + 10); + expect( + parsePacingHint(headersOf({'X-RateLimit-Reset': reset}), NOW, noJitter), + ).toBe(10_000); + }); + + test('positive jitter tops out at 120% of the delta (RECOV-25)', () => { + const reset = String(Math.floor(NOW / 1000) + 10); + expect( + parsePacingHint(headersOf({'X-RateLimit-Reset': reset}), NOW, () => 1), + ).toBeCloseTo(12_000, 6); + }); + + test('a past reset yields zero (RETRY-17)', () => { + const reset = String(Math.floor(NOW / 1000) - 10); + expect( + parsePacingHint(headersOf({'X-RateLimit-Reset': reset}), NOW, () => 1), + ).toBe(0); + }); +}); + +describe('precedence (RETRY-21)', () => { + test('numeric Retry-After beats every other form', () => { + const headers = headersOf({ + 'Retry-After': '30', + 'retry-after-ms': '1', + 'x-ms-retry-after-ms': '2', + 'X-RateLimit-Reset': String(Math.floor(NOW / 1000) + 99), + }); + expect(parsePacingHint(headers, NOW, noJitter)).toBe(30_000); + }); + + test('an unparseable Retry-After falls through to retry-after-ms, not to null', () => { + const headers = headersOf({ + 'Retry-After': 'garbage', + 'retry-after-ms': '250', + }); + expect(parsePacingHint(headers, NOW, noJitter)).toBe(250); + }); + + test('retry-after-ms beats x-ms-retry-after-ms', () => { + const headers = headersOf({ + 'retry-after-ms': '250', + 'x-ms-retry-after-ms': '999', + }); + expect(parsePacingHint(headers, NOW, noJitter)).toBe(250); + }); +}); + +describe('bounds and totality', () => { + test('a huge delta is clamped to the 365-day ceiling (RETRY-18)', () => { + const yearMs = 365 * 24 * 60 * 60 * 1000; + expect( + parsePacingHint(headersOf({'Retry-After': '99999999999'}), NOW, noJitter), + ).toBe(yearMs); + }); + + test('no pacing header at all yields no hint', () => { + expect(parsePacingHint(headersOf({}), NOW, noJitter)).toBeNull(); + }); + + test('property: the parser never throws for any header value (RETRY-16)', () => { + fc.assert( + fc.property(fc.string(), value => { + const headers = Headers.newBuilder() + .add('Retry-After', value.replaceAll(/[\r\n\0]/gu, '')) + .build(); + expect(() => parsePacingHint(headers, NOW, noJitter)).not.toThrow(); + }), + ); + }); + + test('property: the result is null or a finite non-negative number, never NaN (RETRY-16)', () => { + fc.assert( + fc.property(fc.string(), value => { + const headers = Headers.newBuilder() + .add('Retry-After', value.replaceAll(/[\r\n\0]/gu, '')) + .build(); + const hint = parsePacingHint(headers, NOW, noJitter); + if (hint === null) return; + expect(Number.isFinite(hint)).toBe(true); + expect(hint).toBeGreaterThanOrEqual(0); + }), + ); + }); +}); diff --git a/packages/core/src/retry/pacing.ts b/packages/core/src/retry/pacing.ts new file mode 100644 index 0000000..42703aa --- /dev/null +++ b/packages/core/src/retry/pacing.ts @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/retry/pacing.ts +// Phase 7a retrofit: this module previously hand-rolled its own private RFC 1123 parser here (a +// HTTP_DATE regex, a MONTHS table, and a local `parseHttpDate` function, tolerant of an +// informational weekday and a single-digit day -- never `Date.parse`, since JS date-string parsing +// is permissive and non-standardized across engines, the opposite of RETRY-16's totality mandate). +// Phase 7a's `config/http-date.ts` is a superset (it adds the formatter this module never needed) +// built to the identical grammar, so that private copy is deleted and this line imports the shared +// one instead -- one RFC 1123 parser in the codebase, not two. +import {parseHttpDate} from '../config/http-date.js'; +import type {Headers} from '../http/headers.js'; + +/** RETRY-18/RECOV-26: every computed delta is clamped to this ceiling before use. */ +const MAX_PACING_MS = 365 * 24 * 60 * 60 * 1000; + +/** + * RETRY-19: the strict decimal grammar that screens a value BEFORE any float parse. Deliberately + * rejects a leading sign, exponent notation, whitespace, and every type-suffixed or hex-float form -- + * `Number()` would happily accept several of them and produce a wildly wrong instant. + */ +const DECIMAL_SECONDS = /^\d+(?:\.\d+)?$/u; +const DECIMAL_INTEGER = /^\d+$/u; + +function clampPacing(deltaMs: number): number { + return Math.min(Math.max(0, deltaMs), MAX_PACING_MS); +} + +function parseDeltaSeconds(raw: string): number | null { + if (!DECIMAL_SECONDS.test(raw)) return null; + const seconds = Number(raw); + return Number.isFinite(seconds) ? seconds * 1000 : null; +} + +function parseIntegerValue(raw: string | undefined): number | null { + if (raw === undefined || !DECIMAL_INTEGER.test(raw)) return null; + const value = Number(raw); + return Number.isFinite(value) ? value : null; +} + +function parseRetryAfter(raw: string, nowMs: number): number | null { + const seconds = parseDeltaSeconds(raw); + if (seconds !== null) return clampPacing(seconds); + const instant = parseHttpDate(raw); + return instant === null ? null : clampPacing(instant - nowMs); +} + +function parseRateLimitReset( + headers: Headers, + nowMs: number, + random: () => number, +): number | null { + const epochSeconds = parseIntegerValue(headers.get('X-RateLimit-Reset')); + if (epochSeconds === null) return null; + const delta = clampPacing(epochSeconds * 1000 - nowMs); + // RECOV-25: positive jitter to [100%,120%] so many clients released at one reset instant do not + // stampede. A literal Retry-After receives no such perturbation (RETRY-20). + return delta === 0 ? 0 : clampPacing(delta * (1 + random() * 0.2)); +} + +/** + * Resolves a server pacing hint from a response's headers, honoring the fixed precedence of + * RETRY-21/RECOV-24: `Retry-After` numeric, then `Retry-After` as an HTTP-date, then + * `retry-after-ms`, then `x-ms-retry-after-ms`, then `X-RateLimit-Reset`. First parseable value + * wins. + * + * TOTAL by contract (RETRY-16/RECOV-23): it never throws for any input. Malformed, negative, or + * out-of-range values map to `null` -- "no hint" -- so the caller falls back to exponential backoff. + * They MUST NOT map to `0`, which would hammer a server that just asked for room. `0` is reserved + * for a validly-parsed instant already in the past (RETRY-17). + * + * @param headers - the discarded response's headers, read while it is still live. + * @param nowMs - the wall-clock instant the date forms are measured against. + * @param random - the uniform [0,1) source RECOV-25's reset jitter draws from. + * @returns milliseconds to wait, or `null` when no usable hint is present. + * + * @internal + */ +export function parsePacingHint( + headers: Headers, + nowMs: number, + random: () => number, +): number | null { + const retryAfter = headers.get('Retry-After'); + if (retryAfter !== undefined) { + const parsed = parseRetryAfter(retryAfter, nowMs); + if (parsed !== null) return parsed; + } + const deltaMs = + parseIntegerValue(headers.get('retry-after-ms')) ?? + parseIntegerValue(headers.get('x-ms-retry-after-ms')); + if (deltaMs !== null) return clampPacing(deltaMs); + return parseRateLimitReset(headers, nowMs, random); +} diff --git a/packages/core/src/retry/retry-dispatch.test.ts b/packages/core/src/retry/retry-dispatch.test.ts new file mode 100644 index 0000000..4d82b0b --- /dev/null +++ b/packages/core/src/retry/retry-dispatch.test.ts @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/retry/retry-dispatch.test.ts +// Exercises: RECOV-17..20 (the recovery stack's retry lands here), RETRY-44 (each attempt re-runs the +// WHOLE recovery chain -- request chain, transport, response chain), RETRY-13/14/RECOV-30 (both entry +// points share one engine, so the schedule cannot drift). +import {describe, expect, test} from 'bun:test'; +import type {Clock} from '../config/clock.js'; +import {Request} from '../http/request.js'; +import {IoError} from '../io/errors.js'; +import {RequestRecoveryChain} from '../recovery/request-chain.js'; +import {ResponseRecoveryChain} from '../recovery/response-chain.js'; +import {FakeTransport, countingResponse} from '../testing/fake-transport.js'; +import {dispatchWithRetry, type RetryDispatchConfig} from './retry-dispatch.js'; +import {retrySettings} from './settings.js'; + +const GET = Request.newBuilder().url('https://example.com').build(); + +const zeroClock: Clock = { + now: () => 0, + monotonic: () => 0, + sleep: () => Promise.resolve(), +}; + +function configOf( + transport: FakeTransport, + requestSteps = new RequestRecoveryChain([]), +): RetryDispatchConfig { + return { + transport, + requestChain: requestSteps, + responseChain: new ResponseRecoveryChain([], []), + retry: { + settings: retrySettings({maxAttempts: 3, fixedDelayMs: 0}), + clock: zeroClock, + random: () => 0.5, + }, + }; +} + +/** + * Captures a rejection reason. `expect(...).rejects` is typed as returning `void` under this + * runner's type definitions, so awaiting it trips `@typescript-eslint/await-thenable`; this helper + * keeps the assertion honest without a lint suppression. + */ +async function rejectionOf(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error; + } + return undefined; +} + +describe('dispatchWithRetry', () => { + test('retries a transport failure and returns the eventual success', async () => { + const transport = new FakeTransport([ + new IoError('reset'), + countingResponse(200).response, + ]); + + const response = await dispatchWithRetry(GET, configOf(transport)); + + expect(response.status.code).toBe(200); + expect(transport.sendCount).toBe(2); + }); + + test('re-runs the request recovery chain on every attempt (RETRY-44)', async () => { + let applications = 0; + const chain = new RequestRecoveryChain([ + request => { + applications += 1; + return Promise.resolve(request); + }, + ]); + const transport = new FakeTransport([ + new IoError('reset'), + countingResponse(200).response, + ]); + + await dispatchWithRetry(GET, configOf(transport, chain)); + + expect(applications).toBe(2); + }); + + test('rethrows the terminal failure unchanged in shape', async () => { + const transport = new FakeTransport([new IoError('reset')]); + + expect( + await rejectionOf(dispatchWithRetry(GET, configOf(transport))), + ).toBeDefined(); + expect(transport.sendCount).toBe(3); + }); + + test('a bare POST is dispatched exactly once (RETRY-7 holds on this entry point too)', async () => { + const post = Request.newBuilder() + .method('POST') + .url('https://example.com') + .build(); + const transport = new FakeTransport([new IoError('reset')]); + + expect( + await rejectionOf(dispatchWithRetry(post, configOf(transport))), + ).toBeDefined(); + expect(transport.sendCount).toBe(1); + }); +}); diff --git a/packages/core/src/retry/retry-dispatch.ts b/packages/core/src/retry/retry-dispatch.ts new file mode 100644 index 0000000..b87b29e --- /dev/null +++ b/packages/core/src/retry/retry-dispatch.ts @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/retry/retry-dispatch.ts +import type {Request} from '../http/request.js'; +import type {Response} from '../http/response.js'; +import { + dispatchWithRecovery, + type DispatchConfig, +} from '../recovery/orchestrator.js'; +import {failure, fold, success} from '../recovery/outcome.js'; +import {runWithRetry, type RetryConfig, type RetryDispatch} from './engine.js'; + +/** + * 4b's `DispatchConfig` plus the retry policy the wrapper drives it with. + * + * @internal + */ +export interface RetryDispatchConfig extends DispatchConfig { + readonly retry: RetryConfig; +} + +function attemptVia(config: RetryDispatchConfig): RetryDispatch { + return async request => { + try { + return success(await dispatchWithRecovery(request, config)); + } catch (error) { + return failure(error); + } + }; +} + +/** + * The recovery-chain entry point for retry (RECOV-17..RECOV-20). + * + * NOT a `RecoveryStep` -- a recovery step receives an outcome and has no way to re-dispatch. This + * wraps 4b's orchestrator instead, mirroring its `(request, config)` shape, so each attempt re-runs + * the ENTIRE recovery chain: request chain, transport, response chain. That is the recovery-side + * mirror of what `ctx.fork()` does for the pillar step (RETRY-44). + * + * Shares `runWithRetry` with the pillar adapter, which is what makes RETRY-13/RETRY-14/RECOV-30's + * "the two stacks must not drift" structural rather than a discipline. + * + * @param request - the request to prepare, send, and possibly re-send. + * @param config - the recovery chains, transport, and retry policy. + * @returns the response of the terminal successful attempt. + * @throws Whatever the terminal Failure carries, with RETRY-34's suppressed trail attached. + * + * @internal + */ +export async function dispatchWithRetry( + request: Request, + config: RetryDispatchConfig, +): Promise { + const outcome = await runWithRetry(request, attemptVia(config), config.retry); + return fold( + outcome, + response => response, + error => { + throw error; + }, + ); +} diff --git a/packages/core/src/retry/retry-step.test.ts b/packages/core/src/retry/retry-step.test.ts new file mode 100644 index 0000000..5b8cca5 --- /dev/null +++ b/packages/core/src/retry/retry-step.test.ts @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/retry/retry-step.test.ts +// Exercises: PIPE-36 (stage assignment is baked into the descriptor, not subclassable), RETRY-44 (a +// FRESH continuation per attempt via ctx.fork), RETRY-8 (both axes still gate inside the pipeline), +// RETRY-32 (the step honors the call's signal, which only exists thanks to Task 1), RETRY-41/HTTP-35 +// (the per-call RequestOptions.maxRetries override, read via ctx.options from Task 1's amendment). +import {describe, expect, test} from 'bun:test'; +import { + createRequestContext, + type ExecutionContext, +} from '../context/context.js'; +import {Request} from '../http/request.js'; +import {RequestOptions} from '../http/request-options.js'; +import type {Response} from '../http/response.js'; +import {IoError} from '../io/errors.js'; +import {Cursor} from '../pipeline/cursor.js'; +import type {StepDescriptor} from '../pipeline/step.js'; +import {FakeTransport, countingResponse} from '../testing/fake-transport.js'; +import {RETRY_STEP_TYPE, retryStep} from './retry-step.js'; + +const GET = Request.newBuilder().url('https://example.com').build(); + +// Constructed inline rather than imported: 4c keeps `aRequestContext()` file-local to +// `cursor.test.ts`, and importing across `*.test.ts` files is not acceptable. +function aRequestContext(): ExecutionContext { + return createRequestContext(GET); +} + +function runThrough( + descriptor: StepDescriptor, + transport: FakeTransport, + signal?: AbortSignal, +): Promise { + return new Cursor({ + steps: [descriptor], + transport, + request: GET, + context: aRequestContext(), + signal, + }).advance(); +} + +/** + * Captures a rejection reason. `expect(...).rejects` is typed as returning `void` under this + * runner's type definitions, so awaiting it trips `@typescript-eslint/await-thenable`; this helper + * keeps the assertion honest without a lint suppression. + */ +async function rejectionOf(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error; + } + return undefined; +} + +describe('retryStep', () => { + test('is pinned to the RETRY pillar stage (PIPE-36)', () => { + const descriptor = retryStep(); + expect(descriptor.stage).toBe('RETRY'); + expect(descriptor.type).toBe(RETRY_STEP_TYPE); + }); + + test('re-drives the chain on a retryable status and returns the eventual success (RETRY-44)', async () => { + const succeeded = countingResponse(200).response; + const transport = new FakeTransport([ + countingResponse(503).response, + succeeded, + ]); + const descriptor = retryStep({settings: {maxAttempts: 3, fixedDelayMs: 0}}); + + const response = await runThrough(descriptor, transport); + + expect(transport.sendCount).toBe(2); + expect(response).toBe(succeeded); + }); + + test('each attempt gets a fresh continuation, so no cursor is reused (RETRY-44)', async () => { + const transport = new FakeTransport([ + new IoError('reset'), + new IoError('reset'), + countingResponse(200).response, + ]); + const descriptor = retryStep({settings: {maxAttempts: 3, fixedDelayMs: 0}}); + + await runThrough(descriptor, transport); + + expect(transport.sendCount).toBe(3); + }); + + test('rethrows the terminal failure rather than returning a failed outcome', async () => { + const boom = new IoError('reset'); + const transport = new FakeTransport([boom]); + const descriptor = retryStep({settings: {maxAttempts: 2, fixedDelayMs: 0}}); + + expect(await rejectionOf(runThrough(descriptor, transport))).toBeDefined(); + }); + + test('honors the call signal from StepContext (RETRY-32)', async () => { + const controller = new AbortController(); + controller.abort(); + const transport = new FakeTransport([countingResponse(200).response]); + const descriptor = retryStep(); + + expect( + await rejectionOf(runThrough(descriptor, transport, controller.signal)), + ).toBeDefined(); + expect(transport.sendCount).toBe(0); + }); +}); + +describe('retryStep per-call budget override (RETRY-41, HTTP-35)', () => { + test('per-call maxRetries: 0 disables retries for this call only (RETRY-41, HTTP-35)', async () => { + const the503 = countingResponse(503); + const transport = new FakeTransport([ + the503.response, + countingResponse(200).response, + ]); + const descriptor = retryStep({settings: {maxAttempts: 3, fixedDelayMs: 0}}); + const options = RequestOptions.newBuilder().maxRetries(0).build(); + + const response = await new Cursor({ + steps: [descriptor], + transport, + request: GET, + context: aRequestContext(), + options, + }).advance(); + + // The configured budget of 3 was overridden per call. + expect(transport.sendCount).toBe(1); + expect(response.status.code).toBe(503); + // A surviving response is returned LIVE and unread (RETRY-36's discarding-only remap). + expect(the503.cancelCount()).toBe(0); + }); + + test('a non-finite per-call maxRetries cannot reach the step at all', () => { + // First line of defence, and the one a caller actually meets: HTTP-35 rejects at the setter. + for (const value of [Number.POSITIVE_INFINITY, Number.NaN, 1.5]) { + expect(() => RequestOptions.newBuilder().maxRetries(value)).toThrow(); + } + }); + + test('the step re-checks it anyway, so a builder regression cannot make the loop unbounded', async () => { + // Backstop, exercised through a hand-shaped options object the public builder would refuse to + // produce. Worth asserting rather than trusting: the value lands directly in `maxAttempts`, and + // a non-finite budget does not fail loudly -- it makes `attempt >= maxAttempts` permanently + // false and the retry loop endless. + const transport = new FakeTransport([new IoError('reset')]); + const descriptor = retryStep({settings: {fixedDelayMs: 0}}); + const forged = { + maxRetries: Number.POSITIVE_INFINITY, + } as unknown as RequestOptions; + + const reason = await rejectionOf( + new Cursor({ + steps: [descriptor], + transport, + request: GET, + context: aRequestContext(), + options: forged, + }).advance(), + ); + + expect((reason as Error).message).toContain( + 'maxRetries must be a non-negative integer', + ); + expect(transport.sendCount).toBe(0); + }); +}); + +describe('retryStep per-call budget widening (RETRY-41)', () => { + test('per-call maxRetries widens the configured budget too (RETRY-41 is present-override-wins)', async () => { + const transport = new FakeTransport([ + countingResponse(503).response, + countingResponse(503).response, + countingResponse(200).response, + ]); + const descriptor = retryStep({settings: {maxAttempts: 1, fixedDelayMs: 0}}); + const options = RequestOptions.newBuilder().maxRetries(2).build(); + + await new Cursor({ + steps: [descriptor], + transport, + request: GET, + context: aRequestContext(), + options, + }).advance(); + + // 2 retries + the initial send. + expect(transport.sendCount).toBe(3); + }); +}); diff --git a/packages/core/src/retry/retry-step.ts b/packages/core/src/retry/retry-step.ts new file mode 100644 index 0000000..bfc7e1f --- /dev/null +++ b/packages/core/src/retry/retry-step.ts @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/retry/retry-step.ts +import {defaultClock, type Clock} from '../config/clock.js'; +import {invariant} from '../invariant.js'; +import type {Next, StepContext, StepDescriptor} from '../pipeline/step.js'; +import {failure, fold, success} from '../recovery/outcome.js'; +import {runWithRetry, type RetryConfig, type RetryDispatch} from './engine.js'; +import {retrySettings, type RetrySettings} from './settings.js'; + +/** Stable identity for pillar-slot occupancy and anchor matching (PIPE-6/PIPE-18). */ +export const RETRY_STEP_TYPE: unique symbol = Symbol('dexpace.retry'); + +/** + * Everything {@link retryStep} accepts. An options object rather than a bare `RetrySettings`: the + * engine's two injected seams (`clock`, `random`) and RETRY-39's caller delay-override all have to + * reach `RetryConfig`, and a no-argument `retryStep()` must stay the default-tuned pillar step + * (RETRY-12). + * + * @internal + */ +export interface RetryStepOptions { + readonly settings?: Partial | undefined; + readonly clock?: Clock | undefined; + readonly random?: (() => number) | undefined; + readonly delayOverride?: + ((attempt: number) => number | undefined) | undefined; +} + +/** Each attempt drives a FRESH one-shot continuation -- RETRY-44's per-attempt state, PIPE-15's fork. */ +function attemptVia(fork: () => Next): RetryDispatch { + return async request => { + try { + return success(await fork()(request)); + } catch (error) { + return failure(error); + } + }; +} + +/** + * RETRY-41/HTTP-35: the per-call `RequestOptions.maxRetries` override wins over the configured budget + * when present. The option counts retries; `maxAttempts` counts total sends, hence the `+ 1`. + * + * The value IS revalidated here. `RequestOptionsBuilder.maxRetries` rejects only a negative value, + * which is strictly weaker than the `Number.isFinite(...) && >= 1` guard `retrySettings()` applies + * to the configured budget -- it admits `Infinity`, `NaN`, and fractions. Any of the first two + * reaching `maxAttempts` makes the engine's `attempt >= maxAttempts` gate permanently false and the + * retry loop unbounded, so the per-call route must not be the one path into the engine that skips + * the check the configured route enforces. + * + * The derived object is frozen: a spread of a frozen source is NOT itself frozen, and RETRY-42 + * requires every policy component to be immutable after construction, not merely typed `readonly`. + */ +function effectiveSettings( + base: RetrySettings, + perCallMaxRetries: number | undefined, +): RetrySettings { + if (perCallMaxRetries === undefined) return base; + invariant( + Number.isInteger(perCallMaxRetries) && perCallMaxRetries >= 0, + `RequestOptions.maxRetries must be a non-negative integer, got ${String(perCallMaxRetries)}`, + ); + return Object.freeze({...base, maxAttempts: perCallMaxRetries + 1}); +} + +function configFrom( + base: RetryConfig, + ctx: Pick, +): RetryConfig { + return { + ...base, + settings: effectiveSettings(base.settings, ctx.options?.maxRetries), + signal: ctx.signal, + }; +} + +/** + * The RETRY pillar step. + * + * `stage: 'RETRY'` is baked into the descriptor this factory returns, which is how PIPE-36 ("a shipped + * pillar family must not be relocatable out of its pillar") is satisfied structurally: steps are + * functions carrying a descriptor, not classes with a subclassable stage assignment. + * + * `ctx.fork` is asserted rather than checked -- RETRY is in `PILLAR_STAGES`, so its absence means the + * descriptor was installed somewhere it cannot be, which is a programmer error. + * + * @param options - settings overrides and the injected clock, randomness, and delay override. + * @returns the descriptor to install in a pipeline's RETRY slot. + * + * @internal + */ +export function retryStep(options: RetryStepOptions = {}): StepDescriptor { + // Built ONCE per installed step, not per request: `retrySettings()` validates every field and + // takes a defensive copy of the retryable-status set, which is ~110 entries at the default. Only + // the per-call `maxRetries` override and the call's signal are genuinely per-request, and + // `configFrom` derives just those (RETRY-42: the policy is immutable and stateless after + // construction, so one instance is safe to share across concurrent calls). + const base: RetryConfig = { + settings: retrySettings(options.settings), + clock: options.clock ?? defaultClock, + random: options.random ?? ((): number => Math.random()), + delayOverride: options.delayOverride, + }; + return { + type: RETRY_STEP_TYPE, + stage: 'RETRY', + fn: async (request, ctx) => { + const {fork} = ctx; + invariant( + fork !== undefined, + 'retryStep must occupy the RETRY pillar stage', + ); + const outcome = await runWithRetry( + request, + attemptVia(fork), + configFrom(base, ctx), + ); + return fold( + outcome, + response => response, + error => { + throw error; + }, + ); + }, + }; +} diff --git a/packages/core/src/retry/settings.test.ts b/packages/core/src/retry/settings.test.ts new file mode 100644 index 0000000..df06545 --- /dev/null +++ b/packages/core/src/retry/settings.test.ts @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/retry/settings.test.ts +// Exercises: RETRY-12 (defaults), RETRY-14 (one budget, so nothing to reconcile), RETRY-27/28 (opt-in +// total timeout, 0 disables), RETRY-41 (a negative retry count is REJECTED, not clamped -- HTTP-35 +// wins the MUST-vs-MUST collision), RETRY-42 (immutable after construction), RECOV-34 (construction +// validation, defensive collection copies). +import {describe, expect, test} from 'bun:test'; +import {RETRYABLE_STATUSES} from './classify.js'; +import {DEFAULT_RETRY_SETTINGS, retrySettings} from './settings.js'; + +describe('defaults (RETRY-12)', () => { + test('ship the spec defaults', () => { + expect(DEFAULT_RETRY_SETTINGS.initialDelayMs).toBe(200); + expect(DEFAULT_RETRY_SETTINGS.multiplier).toBe(2); + expect(DEFAULT_RETRY_SETTINGS.maxDelayMs).toBe(8000); + expect(DEFAULT_RETRY_SETTINGS.jitter).toBe(0.2); + expect(DEFAULT_RETRY_SETTINGS.maxAttempts).toBe(3); + }); + + test('the total timeout is opt-in, undefined by default (RETRY-28)', () => { + expect(DEFAULT_RETRY_SETTINGS.totalTimeoutMs).toBeUndefined(); + }); + + test('the default retryable statuses are the single-sourced set', () => { + expect([...DEFAULT_RETRY_SETTINGS.retryableStatuses].sort()).toEqual( + [...RETRYABLE_STATUSES].sort(), + ); + }); +}); + +describe('validation (RECOV-34)', () => { + test('rejects a multiplier below 1.0', () => { + expect(() => retrySettings({multiplier: 0.5})).toThrow(); + }); + + test('rejects maxAttempts below 1, never clamping to the default (RETRY-41/HTTP-35)', () => { + expect(() => retrySettings({maxAttempts: 0})).toThrow(); + expect(() => retrySettings({maxAttempts: -3})).toThrow(); + }); + + test('accepts maxAttempts of 1, which disables retries', () => { + expect(retrySettings({maxAttempts: 1}).maxAttempts).toBe(1); + }); + + test('rejects a jitter outside [0,1]', () => { + expect(() => retrySettings({jitter: -0.1})).toThrow(); + expect(() => retrySettings({jitter: 1.1})).toThrow(); + }); + + test('rejects negative durations', () => { + expect(() => retrySettings({initialDelayMs: -1})).toThrow(); + expect(() => retrySettings({maxDelayMs: -1})).toThrow(); + expect(() => retrySettings({totalTimeoutMs: -1})).toThrow(); + expect(() => retrySettings({fixedDelayMs: -1})).toThrow(); + }); + + test('rejects non-finite durations', () => { + expect(() => retrySettings({initialDelayMs: Number.NaN})).toThrow(); + expect(() => + retrySettings({maxDelayMs: Number.POSITIVE_INFINITY}), + ).toThrow(); + }); + + test('rejects a malformed attempt header name at construction, not at the first retry', () => { + expect(() => + retrySettings({attemptHeaderName: 'X-Bad\r\nInjected'}), + ).toThrow(); + expect(() => retrySettings({attemptHeaderName: ''})).toThrow(); + }); + + test('accepts a valid attempt header name', () => { + expect( + retrySettings({attemptHeaderName: 'X-Attempt'}).attemptHeaderName, + ).toBe('X-Attempt'); + }); + + test('a total timeout of zero is legal and means unbounded (RETRY-27)', () => { + expect(retrySettings({totalTimeoutMs: 0}).totalTimeoutMs).toBe(0); + }); +}); + +describe('immutability (RETRY-42, RECOV-34)', () => { + test('the status set is defensively copied, so later caller mutation cannot change policy', () => { + const caller = new Set([500]); + const settings = retrySettings({retryableStatuses: caller}); + caller.add(404); + expect(settings.retryableStatuses.has(404)).toBe(false); + }); + + test('the returned settings object is frozen', () => { + const settings = retrySettings(); + expect(Object.isFrozen(settings)).toBe(true); + }); + + test('DEFAULT_RETRY_SETTINGS is frozen', () => { + expect(Object.isFrozen(DEFAULT_RETRY_SETTINGS)).toBe(true); + }); +}); diff --git a/packages/core/src/retry/settings.ts b/packages/core/src/retry/settings.ts new file mode 100644 index 0000000..3a69ca3 --- /dev/null +++ b/packages/core/src/retry/settings.ts @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/retry/settings.ts +import {hasForbiddenNameByte} from '../http/ascii-validation.js'; +import {invariant} from '../invariant.js'; +import type {BackoffSettings} from './backoff.js'; +import {RETRYABLE_STATUSES} from './classify.js'; + +/** + * The complete retry policy: the backoff schedule plus the budget, the authoritative status set, and + * the two opt-in knobs (RETRY-12, RETRY-27/28, RETRY-38, RECOV-34). + * + * Immutable and stateless after construction, so one instance is safe for concurrent invocation + * (RETRY-42/RECOV-28). + * + * @internal + */ +export interface RetrySettings extends BackoffSettings { + /** Total wire sends including the initial one; 1 disables retries (RETRY-14, RECOV-34). */ + readonly maxAttempts: number; + /** Authoritative on its own -- it both widens and narrows the built-in classifier (RETRY-37). */ + readonly retryableStatuses: ReadonlySet; + /** + * OPT-IN total-timeout budget spanning attempts and inter-attempt delays (RETRY-27). Undefined by + * default and `0` also disabling it -- RETRY-28 instructs a port that unifies the two reference + * retry stacks to make this explicitly opt-in rather than always-on. + */ + readonly totalTimeoutMs?: number | undefined; + /** When set, each attempt is stamped with its 1-based ordinal under this header (RETRY-38). */ + readonly attemptHeaderName?: string | undefined; +} + +/** + * RETRY-12's defaults: 200 ms initial delay, doubling, an 8 s cap, 20% symmetric jitter, and three + * total wire sends. + * + * @internal + */ +export const DEFAULT_RETRY_SETTINGS: RetrySettings = Object.freeze({ + initialDelayMs: 200, + multiplier: 2, + maxDelayMs: 8000, + jitter: 0.2, + maxAttempts: 3, + retryableStatuses: RETRYABLE_STATUSES, +}); + +function validateDuration(label: string, value: number | undefined): void { + if (value === undefined) return; + invariant( + Number.isFinite(value) && value >= 0, + `${label} must be a finite, non-negative duration, got ${String(value)}`, + ); +} + +/** + * Builds validated, frozen retry settings (RECOV-34). Invalid values are PROGRAMMER errors -- a + * caller passing `multiplier: 0.5` has a bug, not an operational failure -- so they trip + * `invariant()` rather than a typed error class. + * + * A negative `maxAttempts` is REJECTED, never clamped to the default: RETRY-41 says clamp, HTTP-35 + * (also MUST) says reject precisely so a negative value cannot be silently reinterpreted as "use + * default". The port takes HTTP-35's line on both surfaces. + * + * The status set is defensively copied at build time so later mutation of the caller's collection + * cannot alter policy (RECOV-34). + * + * @param overrides - the fields to change; everything else takes RETRY-12's default. + * @returns frozen, validated settings. + * @throws InvariantViolation for a negative or non-finite duration, a multiplier below 1.0, + * `maxAttempts` below 1, or a jitter outside [0,1]. + * + * @internal + */ +export function retrySettings( + overrides?: Partial, +): RetrySettings { + const merged = {...DEFAULT_RETRY_SETTINGS, ...overrides}; + validateDuration('initialDelayMs', merged.initialDelayMs); + validateDuration('maxDelayMs', merged.maxDelayMs); + validateDuration('totalTimeoutMs', merged.totalTimeoutMs); + validateDuration('fixedDelayMs', merged.fixedDelayMs); + invariant( + merged.multiplier >= 1, + `retry multiplier must be >= 1.0, got ${String(merged.multiplier)}`, + ); + invariant( + Number.isFinite(merged.maxAttempts) && merged.maxAttempts >= 1, + `retry maxAttempts must be >= 1 (1 disables retries), got ${String(merged.maxAttempts)}`, + ); + invariant( + merged.jitter >= 0 && merged.jitter <= 1, + `retry jitter must lie in [0,1], got ${String(merged.jitter)}`, + ); + // Validated HERE rather than left to the first stamped attempt (RETRY-38). `HeadersBuilder` + // rejects a malformed name (HTTP-26), so an unchecked value would surface as a throw from inside + // the retry loop on some later request -- a configuration mistake reported as a request failure, + // far from the call that made it, and only on the code path that actually retries. + invariant( + merged.attemptHeaderName === undefined || + (merged.attemptHeaderName.length > 0 && + !hasForbiddenNameByte(merged.attemptHeaderName)), + `retry attemptHeaderName must be a valid header name, got ${String(merged.attemptHeaderName)}`, + ); + return Object.freeze({ + ...merged, + retryableStatuses: new Set(merged.retryableStatuses), + }); +} diff --git a/packages/core/src/testing/fake-transport.test.ts b/packages/core/src/testing/fake-transport.test.ts new file mode 100644 index 0000000..84d05ae --- /dev/null +++ b/packages/core/src/testing/fake-transport.test.ts @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/testing/fake-transport.test.ts +// Exercises the double's own contract: scripted ordering, last-entry repetition, call recording, and +// the close-observation mechanism every later retry test depends on (RETRY-35/RETRY-36). +import {describe, expect, test} from 'bun:test'; +import {Request} from '../http/request.js'; +import {Status} from '../http/status.js'; +import {FakeTransport, countingResponse} from './fake-transport.js'; + +const request = Request.newBuilder().url('https://example.com').build(); + +/** + * Captures a rejection reason. `expect(...).rejects` is typed as returning `void` under this + * runner's type definitions, so awaiting it trips `@typescript-eslint/await-thenable`; this helper + * keeps the assertion honest without a lint suppression. + */ +async function rejectionOf(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error; + } + return undefined; +} + +describe('FakeTransport', () => { + test('serves scripted responses in order', async () => { + const first = countingResponse(503).response; + const second = countingResponse(200).response; + const transport = new FakeTransport([first, second]); + + expect(await transport.send(request)).toBe(first); + expect(await transport.send(request)).toBe(second); + }); + + test('repeats the last scripted entry once exhausted', async () => { + const only = countingResponse(200).response; + const transport = new FakeTransport([only]); + + await transport.send(request); + expect(await transport.send(request)).toBe(only); + expect(transport.sendCount).toBe(2); + }); + + test('a scripted Error is thrown, not returned', async () => { + const boom = new Error('connection refused'); + const transport = new FakeTransport([boom]); + + expect(await rejectionOf(transport.send(request))).toBe(boom); + }); + + test('records the request, options, and signal of every send', async () => { + const controller = new AbortController(); + const transport = new FakeTransport([countingResponse(200).response]); + + await transport.send(request, undefined, controller.signal); + + expect(transport.calls).toHaveLength(1); + expect(transport.calls[0]?.request).toBe(request); + expect(transport.calls[0]?.signal).toBe(controller.signal); + }); + + test('an empty script is a programmer error', () => { + expect(() => new FakeTransport([])).toThrow(); + }); + + test('close releases nothing and resolves', async () => { + const transport = new FakeTransport([countingResponse(200).response]); + + await transport.close(); + + expect(transport.sendCount).toBe(0); + }); +}); + +describe('countingResponse', () => { + test('reports the requested status', () => { + expect(countingResponse(503).response.status).toEqual(Status.of(503)); + }); + + test('cancelCount observes close without patching the frozen Response', async () => { + const {response, cancelCount} = countingResponse(503); + expect(cancelCount()).toBe(0); + + await response.close(); + + expect(cancelCount()).toBe(1); + }); + + test('close is idempotent, so the body is cancelled at most once', async () => { + const {response, cancelCount} = countingResponse(503); + + await response.close(); + await response.close(); + + expect(cancelCount()).toBe(1); + }); + + test('a fully drained body is observed as released too, via pull rather than cancel', async () => { + const {response, cancelCount} = countingResponse(503); + const reader = response.body?.getReader(); + for (;;) { + const chunk = await reader?.read(); + if (chunk === undefined || chunk.done) break; + } + + expect(cancelCount()).toBe(1); + }); +}); diff --git a/packages/core/src/testing/fake-transport.ts b/packages/core/src/testing/fake-transport.ts new file mode 100644 index 0000000..4d984fc --- /dev/null +++ b/packages/core/src/testing/fake-transport.ts @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/testing/fake-transport.ts +import {Protocol} from '../http/protocol.js'; +import {Request} from '../http/request.js'; +import type {RequestOptions} from '../http/request-options.js'; +import {Response} from '../http/response.js'; +import {Status} from '../http/status.js'; +import {invariant} from '../invariant.js'; +import type {Transport} from '../seams/transport.js'; + +/** + * One recorded wire send. + * + * @internal + */ +export interface FakeCall { + readonly request: Request; + readonly options: RequestOptions | undefined; + readonly signal: AbortSignal | undefined; +} + +/** + * A scripted `Transport` for multi-attempt tests (`@internal`, never exported from the package + * barrel). + * + * Entries are served in order; once exhausted the LAST entry repeats, so a script of + * `[error, response]` models "fails once, then succeeds forever" without counting attempts by hand. + * A `Response` entry is returned; an `Error` entry is thrown. + * + * **The repeat serves the same instance, not a fresh one.** An `Error` repeats harmlessly, but a + * trailing `Response` is a single object whose body a consumer may already have drained or closed -- + * so a script ending in a retryable-status response models "the same, already-retired response + * arrives again", which is not what a multi-attempt test usually means. Script one entry per + * expected wire send whenever the repeated entry is a `Response` the code under test consumes. + * + * @internal + */ +export class FakeTransport implements Transport { + readonly #script: readonly (Response | Error)[]; + readonly #calls: FakeCall[] = []; + + constructor(script: readonly (Response | Error)[]) { + invariant( + script.length > 0, + 'FakeTransport needs at least one scripted entry', + ); + this.#script = [...script]; + } + + /** Every send this double has served, in order. */ + get calls(): readonly FakeCall[] { + return this.#calls; + } + + /** Wire-send count -- what RETRY-27's budget and RETRY-32's no-further-attempts rule assert on. */ + get sendCount(): number { + return this.#calls.length; + } + + /** + * Records the send and serves the scripted entry at this position. + * + * @param request - the request being sent. + * @param options - the per-call options, recorded verbatim. + * @param signal - the call's abort signal, recorded verbatim. + * @returns the scripted `Response`. + * @throws the scripted `Error` when this position holds one. + */ + send( + request: Request, + options?: RequestOptions, + signal?: AbortSignal, + ): Promise { + const index = Math.min(this.#calls.length, this.#script.length - 1); + this.#calls.push({request, options, signal}); + const entry = this.#script[index]; + invariant(entry !== undefined, 'FakeTransport script index out of range'); + if (entry instanceof Error) return Promise.reject(entry); + return Promise.resolve(entry); + } + + /** + * No-op: the double owns no resources (SEAM-14's ownership rule). + */ + close(): Promise { + return Promise.resolve(); + } +} + +/** + * Builds a `Response` whose close can be OBSERVED. + * + * `Response` instances are `Object.freeze`d, so assigning a spy over `response.close` throws + * `TypeError: Cannot add property close, object is not extensible` under ESM strict mode. The only + * sanctioned observation point is the body stream itself. Every retry, redirect, and auth test that + * asserts a body was released uses this helper. + * + * `cancelCount()` counts RELEASE, by either of the two routes the engine can take, because the + * retire path and the abandon path release the same body differently: + * + * - abandoned unread -- `Response.close()` cancels the stream, firing `cancel()`; + * - retired -- `toHttpError()` DRAINS the body into its bounded buffer (HTTP-52), so the stream + * reaches EOF and the later `close()` finds nothing to cancel; `pull()` is the only hook that + * observes it. + * + * The stream MUST close (here, on the first `pull` after its single chunk is read). A + * `ReadableStream` that enqueues and never closes leaves `toHttpError()`'s drain awaiting a chunk + * that never arrives, and every engine test that discards a 503 hangs until the runner's timeout. + * + * @param status - the status code the response carries. + * @param request - the originating request; defaults to a bare GET. + * @returns the response and a counter reporting how many times its body was released. + * + * @internal + */ +export function countingResponse( + status: number, + request: Request = Request.newBuilder().url('https://example.com').build(), +): {response: Response; cancelCount: () => number} { + let releases = 0; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.from([1])); + }, + pull(controller) { + // Reached only once the single chunk has been read (default highWaterMark 1), i.e. a full drain. + releases += 1; + controller.close(); + }, + cancel() { + releases += 1; + }, + }); + const response = Response.newBuilder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .status(Status.of(status)) + .body(body) + .build(); + return {response, cancelCount: () => releases}; +} diff --git a/test/node-conformance/retry.test.mjs b/test/node-conformance/retry.test.mjs new file mode 100644 index 0000000..14fdf74 --- /dev/null +++ b/test/node-conformance/retry.test.mjs @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: MIT +// test/node-conformance/retry.test.mjs +// +// Phase 5a is a runtime-divergent surface at three specific points, and each one fails silently rather +// than loudly if the runtimes disagree: +// +// 1. `classify.ts` draws RETRY-23-vs-RETRY-24 (caller abort never retryable, read timeout still +// retryable) off the abort reason's `name`. That reason is a `DOMException` produced by +// `AbortSignal.timeout()`, whose class and `name` are the runtime's, not this package's -- if +// Node named it anything but `TimeoutError`, every timed-out request would silently stop being +// retried and `bun test` would still be green. +// 2. RETRY-34's suppressed trail goes through `suppress()`, which picks the native `SuppressedError` +// or the shape-compatible fallback depending on the runtime. Bun has the global; the declared +// floor (`engines.node >=20.3`) does not. The trail's SHAPE has to be identical either way. +// 3. RETRY-35/RECOV-16's "release the discarded response" rides on Web Streams: a retired response is +// drained to EOF by `toHttpError()`, an abandoned one is cancelled by `Response.close()`. Node's +// `cancel()`/`pull()` timing is an independent implementation of Bun's. +// 4. The inter-attempt wait itself is `defaultClock.sleep` (CFG-17), the one place the retry path +// touches a real `setTimeout` and a real `AbortSignal` listener. The unit suite injects a fake +// clock -- deliberately, so it stays deterministic -- which means the real timer/abort race is +// covered HERE and nowhere else. +// +// The engine itself is `@internal` with no public subpath in `exports`, so it is reached by direct +// `dist/` file path, per this suite's import rule. +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; +import {Protocol, Request, Response, Status} from '@dexpace/core'; +import { + isRetryableFailure, + RETRYABLE_STATUSES, +} from '../../packages/core/dist/retry/classify.js'; +import {runWithRetry} from '../../packages/core/dist/retry/engine.js'; +import {retrySettings} from '../../packages/core/dist/retry/settings.js'; +import {failure, success} from '../../packages/core/dist/recovery/outcome.js'; +import {defaultClock} from '../../packages/core/dist/config/clock.js'; + +const GET = Request.newBuilder().url('https://example.com').build(); + +const zeroClock = { + now: () => 0, + monotonic: () => 0, + sleep: () => Promise.resolve(), +}; + +function configOf(overrides) { + return { + settings: retrySettings(overrides), + clock: zeroClock, + random: () => 0.5, + }; +} + +/** Mirrors `testing/fake-transport.ts`'s helper: release is observable only through the body stream. */ +function countingResponse(status) { + let releases = 0; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.from([1])); + }, + pull(controller) { + releases += 1; + controller.close(); + }, + cancel() { + releases += 1; + }, + }); + const response = Response.newBuilder() + .request(GET) + .protocol(Protocol.HTTP_1_1) + .status(Status.of(status)) + .body(body) + .build(); + return {response, cancelCount: () => releases}; +} + +function scriptedDispatch(script) { + const calls = []; + const dispatch = request => { + calls.push(request); + return Promise.resolve( + script[Math.min(calls.length - 1, script.length - 1)], + ); + }; + dispatch.calls = calls; + return dispatch; +} + +describe('retry classification on the declared Node floor', () => { + it("names AbortSignal.timeout()'s reason TimeoutError, which RETRY-24 keys off", async () => { + const signal = AbortSignal.timeout(1); + // A ref'd deadline holds the loop open and fails the case if the abort never arrives -- awaiting + // the unref'd timer alone is what this suite's README warns against. + const aborted = await new Promise(resolve => { + const deadline = setTimeout(() => { + resolve(false); + }, 1000); + signal.addEventListener( + 'abort', + () => { + clearTimeout(deadline); + resolve(true); + }, + {once: true}, + ); + }); + + assert.equal(aborted, true); + assert.equal(signal.reason.name, 'TimeoutError'); + assert.equal(isRetryableFailure(signal.reason, RETRYABLE_STATUSES), true); + }); + + it('treats a caller abort as never retryable (RETRY-23)', () => { + const controller = new AbortController(); + controller.abort(); + + assert.equal(controller.signal.reason.name, 'AbortError'); + assert.equal( + isRetryableFailure(controller.signal.reason, RETRYABLE_STATUSES), + false, + ); + }); +}); + +describe('the retry engine on the declared Node floor', () => { + it('folds the suppressed trail into the same shape whether or not the runtime has SuppressedError', async () => { + // Timeout aborts, because they are the retryable throwable this suite can build without reaching + // into another `dist/` module -- two of them exhaust the budget and produce a two-entry trail. + const dispatch = scriptedDispatch([ + failure(new DOMException('timed out', 'TimeoutError')), + failure(new DOMException('timed out again', 'TimeoutError')), + ]); + + const outcome = await runWithRetry( + GET, + dispatch, + configOf({maxAttempts: 2, fixedDelayMs: 0}), + ); + + assert.equal(dispatch.calls.length, 2); + assert.equal(outcome.kind, 'failure'); + assert.equal(outcome.error.name, 'SuppressedError'); + assert.ok('error' in outcome.error); + assert.ok('suppressed' in outcome.error); + }); + + it('releases a discarded response through the drain route, over Node Web Streams (RETRY-35)', async () => { + const discarded = countingResponse(503); + const kept = countingResponse(200); + const dispatch = scriptedDispatch([ + success(discarded.response), + success(kept.response), + ]); + + const outcome = await runWithRetry( + GET, + dispatch, + configOf({fixedDelayMs: 0}), + ); + + assert.equal(outcome.kind, 'success'); + assert.equal(discarded.cancelCount(), 1); + assert.equal(kept.cancelCount(), 0); + }); + + it('returns a response that survives the gates live and unread (RETRY-36)', async () => { + const only = countingResponse(503); + const dispatch = scriptedDispatch([success(only.response)]); + + const outcome = await runWithRetry( + GET, + dispatch, + configOf({maxAttempts: 1}), + ); + + assert.equal(outcome.kind, 'success'); + assert.equal(only.cancelCount(), 0); + }); + + it('waits on a REAL timer between attempts and resumes the loop (RETRY-26/31)', async () => { + const dispatch = scriptedDispatch([ + failure(new DOMException('timed out', 'TimeoutError')), + success(countingResponse(200).response), + ]); + + const outcome = await runWithRetry(GET, dispatch, { + settings: retrySettings({fixedDelayMs: 1, maxAttempts: 2}), + clock: defaultClock, + random: () => 0.5, + }); + + assert.equal(outcome.kind, 'success'); + assert.equal(dispatch.calls.length, 2); + }); + + it('cuts a REAL pending wait short when the caller aborts (RETRY-26/32)', async () => { + const controller = new AbortController(); + const dispatch = () => { + // Aborts from a macrotask, so the loop is already inside `defaultClock.sleep`'s timer when it + // fires -- the abort LISTENER settles the race, not the already-aborted short-circuit. + setTimeout(() => { + controller.abort(); + }, 1); + return Promise.resolve( + failure(new DOMException('timed out', 'TimeoutError')), + ); + }; + const startedAt = defaultClock.monotonic(); + + const outcome = await runWithRetry(GET, dispatch, { + settings: retrySettings({fixedDelayMs: 60_000, maxAttempts: 5}), + clock: defaultClock, + random: () => 0.5, + signal: controller.signal, + }); + + assert.equal(outcome.kind, 'failure'); + // The point of the case: it returned instead of sleeping out the full 60s backoff. + assert.ok(defaultClock.monotonic() - startedAt < 5_000); + }); +}); From 18c4e36356962ffd58eb268d8a818cdd57c9ec5e Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh <78609166+Wahbeh-Mohammad@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:16:09 +0300 Subject: [PATCH 2/4] =?UTF-8?q?feat(core):=20phase=205b=20=E2=80=94=20the?= =?UTF-8?q?=20redirect=20pillar=20step=20and=20its=20marker=20guard.=20(#4?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the redirect pillar per product-spec/10-redirect-handling.md (REDIR-1..REDIR-27), following docs/superpowers/specs/2026-07-26-phase5b-redirect-design.md, and closes the roadmap's PIPE-40 deferral. Per-requirement disposition in docs/superpowers/plans/2026-07-26-phase5b-redirect-checklist.md. New packages/core/src/redirect/, seven files, no folder barrel: - codes.ts — the recognized {301,302,303,307,308} set and per-code method eligibility (REDIR-1..5). 303 is the only status branched on, so the four method-preserving codes cannot drift apart. - cross-origin.ts — the RFC 6454 origin tuple compared against the SEED (REDIR-8) and the credential-suppression marker (REDIR-11). - settings.ts — REDIR-17's defaults, REDIR-26's defensive copy, REDIR-27's configurable header. maxHops: 0 needs no special branch; it is the one value the ordinary cap gate always fails. - decide.ts — the pure per-hop decision. No I/O, no clock, no side effects beyond the Request it returns. - redirect-step.ts — the REDIRECT pillar. Every dispatch, including the first, takes a fresh ctx.fork(); ctx.next()'s single-invocation guard would trip on hop two (PIPE-15). stage: 'REDIRECT' is baked into the descriptor, which is how PIPE-36 is satisfied structurally. - strip-marker-step.ts — a POST_AUTH guard plus withRedirect(). - errors.ts — NonReplayableBodyError, SchemeDowngradeError. The cross-origin signal is a real header, not an in-process marker. A WeakSet is unforgeable and never touches the wire, but stage order is REDIRECT -> RETRY -> AUTH and 5a's attempt-stamping builds a fresh per-attempt Request copy when enabled — an identity-keyed signal would silently stop matching exactly when a retry sits between redirect and auth, which is when cross-origin credential suppression matters most. Stamping preserves headers, so a header survives that copy. REDIR-11 names its own porter caveat: in the reference only the auth step strips the marker, so a pipeline with none forwards it to the transport. 5b ships before 5c, so that is not a future concern here — it is a live leak this phase would otherwise ship. stripCrossOriginMarkerStep() occupies 4c's inert POST_AUTH slot, so nothing in 4c or 5c had to change, and it stays installed as a redundant backstop once 5c's auth step becomes the marker's real consumer. Two origin-shaped checks, two deliberately different reference points, easy to conflate. Cross-origin classification compares against the SEED for the whole chain (REDIR-8), so a foreign host cannot hand the credential back by redirecting to the seed's own origin. The downgrade guard compares the CURRENT hop against its target (REDIR-15), so an HTTPS->HTTP->HTTPS chain flags only the hop that actually downgraded. Location resolution ends with an explicit http:/https: gate. WHATWG URL parses javascript:, data:, file:, and mailto: without complaint and the downgrade guard waves all of them through (none is http:), so without the gate the step would dispatch a server-supplied javascript: target. The catch around new URL(raw, base) is a genuinely narrow path, not the general garbage guard it looks like: with a base supplied, a non-URL string resolves as a relative reference rather than throwing. One normative conflict, resolved and recorded rather than silently picked. PIPE-40 and REDIR-22 disagree, both at MUST, about the non-replayable-body path: PIPE-40 lists it among the responses "returned unclosed", REDIR-22(b) lists the same trigger among those "closed before the error propagates". REDIR-6 settles the control flow — that path "MUST fail with a clear error" — so it throws, and a response never returned cannot be returned unclosed; §10 also governs the redirect step's own lifecycle over the cross-cutting default, and closing is the safer reading, since the alternative leaks a body with no caller holding a reference. 5b closes and throws. One of the two spec sentences needs an erratum either way; deferred to Phase 10 and recorded in the design's Deviation Ledger and at open-items G1. REDIR-20's "fully override" is read as scoped to code/method eligibility only, not as license to bypass userinfo stripping, credential hygiene, the downgrade guard, replayability, or loop/cap detection — those are unconditional MUSTs elsewhere in the same chapter, and a predicate opting to follow a 307 with a single-use body still cannot make that body re-sendable. A judgment call on ambiguous wording; narrow to reverse, and flagged for Phase 9. One file lands outside redirect/. Review pass 1 found both close-before-throw paths replacing the very error they were meant to propagate, because Response.close() rethrows whatever cancelling the body raised. The fix needed releaseQuietly/withReleaseFailure, module-private inside 5a's retry/engine.ts; rather than a second copy of a helper whose identity guard is load-bearing they move to recovery/release.ts and both call sites import them. Behavior-neutral for 5a — the diff is one import added and the two functions removed verbatim, and 5a's suite passes untouched. The third close, releasing a superseded hop before the next drive, stays bare: there is no primary error to preserve and PIPE-40 makes the release itself part of the contract. REDIR-28's structured events, and REDIR-15's separate "surface it observably" obligation on a permitted downgrade, are NOT implemented. 5b executes before 7b, so an observability/logger.js import would not resolve, and 7b needs this step for its own retrofit test — the dependency cannot run the other way. 7b's Task 9 owns them, named in redirectStep()'s TSDoc. Two of the four events stay blocked even after that, behind a reason discriminant decide()'s 'return-current' variant does not carry; open-items G3. Nothing reaches the public barrel: core.api.md and src/index.ts are unchanged, and redirect/ gets no index.ts. 5c's promotion task is the first point any pillar-authoring surface goes public. Phase 5b's open and deferred items are registered as open-items.md section G, with its cross-phase deferrals in section D. That pass also found 4c and 5a were never registered at all; their absence there means "not reviewed", not "nothing found", and the file's header now says so. Gates: typecheck, lint, build, bun test --coverage (991), api, lint:publish, verify:dual-consumption, verify:consumer-types, verify:seam-1, verify:runtime-floor, audit, and test:node on both matrix legs — 20.3.0 and lts/* (v24.20.0) — all run on the pinned bun 1.3.14 rather than the local toolchain. The floor leg matters for this change specifically: Node 20.3.0 has no native SuppressedError, so recovery/release.ts takes the fallback branch there and the native one on 24. --- .changeset/2026-08-27-redirect-pillar-step.md | 75 ++ docs/open-items.md | 157 +++- .../2026-07-26-phase5b-redirect-checklist.md | 139 +++ .../2026-07-26-phase5b-redirect-design.md | 14 +- packages/core/src/recovery/release.test.ts | 99 ++ packages/core/src/recovery/release.ts | 63 ++ packages/core/src/redirect/codes.test.ts | 86 ++ packages/core/src/redirect/codes.ts | 70 ++ .../core/src/redirect/cross-origin.test.ts | 112 +++ packages/core/src/redirect/cross-origin.ts | 119 +++ packages/core/src/redirect/decide.test.ts | 877 ++++++++++++++++++ packages/core/src/redirect/decide.ts | 234 +++++ packages/core/src/redirect/errors.test.ts | 62 ++ packages/core/src/redirect/errors.ts | 61 ++ .../core/src/redirect/redirect-step.test.ts | 398 ++++++++ packages/core/src/redirect/redirect-step.ts | 147 +++ packages/core/src/redirect/settings.test.ts | 105 +++ packages/core/src/redirect/settings.ts | 114 +++ .../src/redirect/strip-marker-step.test.ts | 160 ++++ .../core/src/redirect/strip-marker-step.ts | 85 ++ packages/core/src/retry/engine.ts | 46 +- test/node-conformance/README.md | 1 + test/node-conformance/redirect.test.mjs | 239 +++++ 23 files changed, 3412 insertions(+), 51 deletions(-) create mode 100644 .changeset/2026-08-27-redirect-pillar-step.md create mode 100644 docs/superpowers/plans/2026-07-26-phase5b-redirect-checklist.md create mode 100644 packages/core/src/recovery/release.test.ts create mode 100644 packages/core/src/recovery/release.ts create mode 100644 packages/core/src/redirect/codes.test.ts create mode 100644 packages/core/src/redirect/codes.ts create mode 100644 packages/core/src/redirect/cross-origin.test.ts create mode 100644 packages/core/src/redirect/cross-origin.ts create mode 100644 packages/core/src/redirect/decide.test.ts create mode 100644 packages/core/src/redirect/decide.ts create mode 100644 packages/core/src/redirect/errors.test.ts create mode 100644 packages/core/src/redirect/errors.ts create mode 100644 packages/core/src/redirect/redirect-step.test.ts create mode 100644 packages/core/src/redirect/redirect-step.ts create mode 100644 packages/core/src/redirect/settings.test.ts create mode 100644 packages/core/src/redirect/settings.ts create mode 100644 packages/core/src/redirect/strip-marker-step.test.ts create mode 100644 packages/core/src/redirect/strip-marker-step.ts create mode 100644 test/node-conformance/redirect.test.mjs diff --git a/.changeset/2026-08-27-redirect-pillar-step.md b/.changeset/2026-08-27-redirect-pillar-step.md new file mode 100644 index 0000000..5cc4c9e --- /dev/null +++ b/.changeset/2026-08-27-redirect-pillar-step.md @@ -0,0 +1,75 @@ +--- +'@dexpace/core': patch +--- + +Add the redirect-following pillar step for product-spec §10 (`REDIR-1`–`REDIR-27`) and close `PIPE-40`. No +public API change. + +Everything this adds lives under `packages/core/src/redirect/` and none of it is re-exported from +`src/index.ts` — `packages/core/etc/core.api.md` is byte-identical before and after. `patch` rather than an +empty changeset because files under `packages/` did change: the published tarball carries the new +`dist/redirect/*.js`, and a consumer stepping through the package in a debugger will see them. + +One file landed outside `redirect/`: `packages/core/src/recovery/release.ts`, which is +`releaseQuietly`/`withReleaseFailure` extracted unchanged from `retry/engine.ts`. The redirect step needs +the same "a teardown failure never becomes primary" discipline `RECOV-12` already required of retry, and +the helper's identity guard is subtle enough that a second copy would drift. `engine.ts` now imports what +it used to define; its behavior and its suite are unchanged. + +What landed: `codes.ts` (the recognized `{301,302,303,307,308}` set and per-code method eligibility), +`cross-origin.ts` (the RFC 6454 origin tuple compared against the seed, plus the credential-suppression +marker header), `settings.ts` (validated, frozen policy with a defensively copied allowed-method set), +`decide.ts` (the pure per-hop decision), `redirect-step.ts` (the `REDIRECT` pillar adapter), and +`strip-marker-step.ts` (a `POST_AUTH` guard plus `withRedirect()`). Two new operational error leaves, +`NonReplayableBodyError` and `SchemeDowngradeError`, both `@internal` for now. + +Four design calls worth recording: + +- **The cross-origin suppression signal is a real header, not an in-process marker.** A `WeakSet` + keyed by object identity is unforgeable and never touches the wire, but stage order is + `REDIRECT → RETRY → AUTH` and 5a's attempt-stamping builds a fresh per-attempt `Request` copy when + enabled — an identity-keyed signal would silently stop matching exactly when a retry sits between + redirect and auth, which is when cross-origin credential suppression matters most. Stamping preserves + headers, so a header survives the intermediate copy. +- **A second, always-bundled step strips that marker independently of whether an auth step exists.** + `REDIR-11` itself names the porter caveat: in the reference only the auth step strips the signal, so a + pipeline with none forwards it to the transport. 5b ships before 5c, so that is not a future concern + here — it is a live leak this phase would otherwise ship. `stripCrossOriginMarkerStep()` occupies 4c's + inert `POST_AUTH` extension slot, so nothing in 4c or 5c had to change. +- **Two origin-shaped checks, two deliberately different reference points.** Cross-origin classification + compares against the **seed** origin for the whole chain (`REDIR-8`), so a foreign host cannot hand the + credential back by redirecting to the seed's own origin. The scheme-downgrade guard compares the + **current hop** against its target (`REDIR-15`), so an HTTPS→HTTP→HTTPS chain flags only the hop that + actually downgraded. Conflating them silently breaks one or the other. +- **A failing release never replaces the error it was supposed to let through.** `Response.close()` + rethrows whatever cancelling the body raised, so the two error paths that close before propagating + (`decideOrClose`, and the `'fail'` branch's `SchemeDowngradeError`) route through + `withReleaseFailure`: the decision error stays primary and the release failure rides along as + `suppressed`. The third close — releasing a superseded hop before the next drive — is deliberately + left bare, because there is no primary error to preserve and `PIPE-40` makes the release itself part + of the contract. +- **Location resolution ends with an explicit `http:`/`https:` gate.** WHATWG `URL` parses + `javascript:`, `data:`, `file:`, and `mailto:` without complaint, and the downgrade guard waves all of + them through (none is `http:`). Without the gate the step would dispatch a server-supplied + `javascript:` target. The `catch` around `new URL(raw, base)` is a genuinely narrow path, not the + general garbage guard it looks like: with a base supplied, a non-URL string resolves as a relative + reference rather than throwing. + +One normative conflict, resolved and recorded rather than silently picked: **`PIPE-40` and `REDIR-22` +disagree, both at `MUST`, about the non-replayable-body path.** `PIPE-40` lists it among the paths whose +in-flight response is "returned unclosed"; `REDIR-22`(b) lists the same trigger among those "closed before +the error propagates". `REDIR-6` settles the control flow — that path "MUST fail with a clear error" — so it +throws, and a response never returned cannot be returned unclosed. 5b closes and throws; the contradiction +is in the design's Deviation Ledger and deferred to Phase 10, which owns the erratum either way. + +Two known gaps, both recorded in the phase checklist: + +- **`REDIR-28`'s structured hop/loop/downgrade log events, and `REDIR-15`'s "surface it observably" clause + on a permitted downgrade, are not implemented here.** Phase 5b executes before Phase 7b, so + `redirect-step.ts` cannot import `observability/`, and 7b needs this step for its own retrofit test — + the dependency cannot run the other way. Phase 7b's Task 9 owns them, named in `redirectStep()`'s TSDoc. +- **`REDIR-20`'s predicate override is read as scoped to code/method eligibility only.** A configured + predicate replaces the built-in follow decision; it does not bypass userinfo stripping, credential + hygiene, the downgrade guard, the replayability gate, or loop/cap detection, all of which the same spec + document states as unconditional `MUST`s. Logged in the design's Deviation Ledger for Phase 10 and + flagged for re-confirmation at Phase 9's conformance sweep. diff --git a/docs/open-items.md b/docs/open-items.md index 1f8d44d..1a44bbb 100644 --- a/docs/open-items.md +++ b/docs/open-items.md @@ -4,11 +4,19 @@ Running register of everything known to be unmet, unverified, misreported, or de implemented portion of this project. Reviewed state: **scaffold milestone** (committed, `0ebdc79`), **Phase 1 — Core HTTP Domain Model** (branch `2-phase-1-core-http-domain-model`, uncommitted at time of review), **Phase 3a/3b**, **Phase 4a — Execution Context** (branch `7-phase-4a-execution-context`, three -review passes), and **Phase 4b — Recovery-Chain Primitives** (branch -`8-phase-4b-recovery-chain-primitives`). 4a and 4b are both merged into `9-phase-4c-stage-based-pipeline`. -Last reviewed **2026-08-26**. +review passes), **Phase 4b — Recovery-Chain Primitives** (branch +`8-phase-4b-recovery-chain-primitives`), and **Phase 5b — Redirect** (branch +`12-phase-5b-resilience-redirect`, three review passes). 4a and 4b are both merged into +`9-phase-4c-stage-based-pipeline`. Last reviewed **2026-08-27**. -Sections A–E below were written against Phase 1 and are re-verified at each review; section F is Phase 4b's. +**Two phases are shipped but were never registered here: 4c (stage-based pipeline) and 5a (retry).** Both are +merged and both have executed checklists, but neither ran the scan this file's maintenance rule asks for, so +their absence below means "not reviewed", not "nothing found". Section G was written without reviewing either, +and says nothing about them beyond what 5b's own work touched — the one 5a file 5b modified +(`retry/engine.ts`) is recorded at G9. + +Sections A–E below were written against Phase 1 and are re-verified at each review; section F is Phase 4b's, +section G is Phase 5b's. A requirement absent from this file is either satisfied or belongs to a phase that has not started. The point of the file is that nothing is unmet *silently* — every gap below is either scheduled against a named phase or @@ -254,6 +262,13 @@ No action now. Each is already owned by a named phase; this table exists so none | Self-identifying version metadata (real `User-Agent`) | NFR-15 | 7/8 | | | Publish + provenance CI job | NFR-16 | release | `prepublishOnly` wired; nothing published yet | | NFR-8 re-confirmed as a documented non-applicability | NFR-8 | 10 | No reflection-driven discovery surface exists by design | +| Redirect structured logging — hop, rejection, and permitted-downgrade events | REDIR-28, REDIR-15 (surfacing clause), XCUT-17(d) | 7b | Task 9. 5b executes before 7b and 7b needs 5b's step, so the import cannot run either way until then. See G2 | +| Redirect's loop-detected and malformed-Location events | REDIR-28 | none | Blocked behind a reason discriminant on `decide()`'s `'return-current'` variant, which no phase owns. See G3 | +| The cross-origin marker's *consumption* side — skip-stamping on a cross-origin re-issue | REDIR-11(b/c), XCUT-17(b), AUTH-29 | 5c | 5b produces the marker and defends it with an independent `POST_AUTH` guard; nothing yet reads it. See G7 | +| Auth re-runs per redirect hop | PIPE-2 | 5c | Needs an auth step to re-run | +| Public-barrel promotion of `redirectStep`/`withRedirect` and the step-authoring surface | — | 5c | Same "not yet" 5a's `retry/` shipped with. Publishing a pillar-authoring surface early would freeze `StepDescriptor`/`Stage`/`PipelineBuilder` shapes 5c may still reshape | +| Erratum for the `PIPE-40` / `REDIR-22` contradiction | PIPE-40 vs REDIR-22 | 10 | Behavior is chosen and tested; one of the two spec sentences still needs correcting. See G1 | +| Re-confirm the redirect predicate's scope over the safety mechanics | REDIR-20 | 9 | See G4 | --- @@ -359,6 +374,140 @@ phases execute, per this file's own maintenance rule. --- +--- + +## G. Phase 5b — Redirect + +Three review passes ran over this phase. Everything they found is either fixed in the branch or listed here. +Nothing below blocks the phase — `REDIR-1`–`REDIR-27` are satisfied, `PIPE-40` is closed, and every CI step is +green. `REDIR-28` is the one requirement in the chapter that ships unimplemented, and it is scheduled. + +### G1 — `PIPE-40` and `REDIR-22` contradict each other on the non-replayable-body path — **SCHEDULED** (Phase 10) + +Two `MUST`s naming the same trigger and prescribing opposite dispositions. + +`product-spec/08-execution-pipelines.md:20` (`PIPE-40`): "on paths that abandon a re-drive (redirect cycle, +**non-replayable body**, budget exhausted) the in-flight response MUST be returned unclosed." + +`product-spec/10-redirect-handling.md` (`REDIR-22`): "if building the follow-up throws (**non-replayable +body**, downgrade rejection) the current response MUST be closed before the error propagates." + +5b implements `REDIR-22` — closes, then throws — on three grounds: `REDIR-6` independently fixes the control +flow ("the operation MUST fail with a clear error naming replayability"), so the path throws and a response +never *returned* cannot be "returned unclosed"; specific governs general, since `§10` owns the redirect step's +lifecycle; and closing is the safer reading, because the alternative leaks a body on an error path with no +caller holding a reference to close it. `PIPE-40`'s other two named paths do genuinely return, and both return +unclosed as it requires. + +Not a code decision left open — the behavior is chosen, tested, and reasoned. What is open is that **one of the +two spec sentences needs an erratum**, which is Phase 10's to write. Recorded in the 5b design's Deviation +Ledger and asserted with the reasoning inline in `redirect-step.test.ts`. + +### G2 — `REDIR-28` and `REDIR-15`'s observability clause ship unimplemented — **SCHEDULED** (Phase 7b, Task 9) + +`REDIR-28` (SHOULD): hop, loop-detected, scheme-downgrade, and malformed-Location events as structured +records, URLs through a redactor. `REDIR-15` (MUST) carries a separate, easily-conflated obligation on the +*permitted* downgrade path — the `allowSchemeDowngrade` flag is the opt-in, and "MUST surface it observably" is +a second requirement on top of it. `XCUT-17`(d) restates the same pairing. + +None of it is implemented. 5b executes before 7b, so an `observability/logger.js` import here would not +resolve, and 7b needs 5b's redirect step for its own retrofit conformance test — the dependency cannot run the +other way. `redirectStep()`'s TSDoc names 7b's Task 9 as the owner. Same disposition, and the same +cycle-breaking reason, as 5a's two `engine.ts` events. + +**Note the MUST/SHOULD split when this is closed:** `REDIR-28` is a SHOULD, but `REDIR-15`'s surfacing clause +is part of a MUST. 7b's Task 9 closes both in one edit, so the distinction only matters if that task slips. + +### G3 — `Decision` carries no reason on `'return-current'`, so two of `REDIR-28`'s four events stay blocked — **DECIDE** + +`decide()`'s `'return-current'` variant is a bare `{kind}`. Nothing distinguishes loop-detected from +hop-cap-exceeded from normal termination from malformed-Location, so even after G2 lands, the hop, rejection, +and permitted-downgrade events can ship while **loop-detected and malformed-Location cannot**. `REDIR-28`'s +carve-out — that the malformed-Location event logs the raw Location string, since it failed to parse and +cannot be redacted — travels with that deferral. + +Reshaping `Decision` touches every assertion in `decide.test.ts`, which is why it was not done inside the 7b +retrofit's scope. It is a `SHOULD`, so nothing is violated by leaving it — but it is not owned by any phase +today, which is why this is DECIDE rather than SCHEDULED. Either schedule it (7b or 9) or accept the two +events as permanently unshipped and record that in `sdk-design-nodejs/10`. + +### G4 — `REDIR-20`'s "fully override" is read as scoped to code/method eligibility only — **DECIDE** + +The spec says a configured predicate "MUST fully override the built-in decision". 5b reads that as scoped to +the *code/method eligibility* question, not as license to bypass the safety mechanics that follow it — +userinfo stripping, credential hygiene, the downgrade guard, body replayability, and loop/cap detection — on +the grounds that those are stated as unconditional `MUST`s elsewhere in the same chapter and are not "should +this kind of redirect be followed" policy. A caller predicate opting to follow a 307 with a single-use body +still cannot make that body re-sendable. + +Defensible, and a test pins it. But it is a judgment call on genuinely ambiguous wording, made without the user +present. If wrong, the fix is narrow and mechanical: gate `decide()`'s step 3 onward behind the predicate's +answer. Flagged for re-confirmation at Phase 9's conformance sweep, or sooner. + +### G5 — The marker-stripping guard is not the last step before `SEND` — **WATCH** + +`REDIR-11`(c) requires the internal cross-origin marker be removed before dispatch. `stripCrossOriginMarkerStep()` +occupies `POST_AUTH`, but `STAGE_ORDER` runs six more stages after it — `PRE_LOGGING`, `LOGGING`, +`POST_LOGGING`, `PRE_SERDE`, `SERDE`, `POST_SERDE` — before `SEND`. A step installed in any of them runs +*closer to the wire than the guard* and could put the marker back. + +Not a defect today: no step exists in any of those stages, so the guard is effectively last. It is also not a +plausible accident — nothing would write that header by name. + +**Trigger:** a step installed after `POST_AUTH` that copies or synthesizes request headers wholesale rather +than setting named ones. 7b's `loggingStep` and 6a's serde step are the first two occupants of those stages; +neither should touch it, but neither has been read yet. + +### G6 — Loop detection keys on `href`, so a fragment-only difference is a distinct URI — **WATCH** + +`REDIR-16` says "recording every visited absolute URI". `visited` stores `URL.href`, which includes the +fragment — so `https://h/a` → `https://h/a#x` → `https://h/a#y` is three distinct entries, not a loop. + +Correct by the letter (a fragment is part of the URI) and harmless in practice, because `REDIR-17`'s hop cap +bounds the chain regardless — the default budget of 3 stops it. Worth recording only because the reasoning is +non-obvious and the alternative (stripping the fragment before the visited check) would be a silent behavior +change if someone "fixed" it later. + +Verified in the same pass that the *dangerous* normalizations do collapse: `HTTPS://EXAMPLE.COM/a` and +`https://example.com:443/a` both normalize to a href already in the set, so case and default-port variation +cannot be used to spin past the cap. Both are pinned by tests, in `bun test` and on Node's own URL parser. + +### G7 — `XCUT-17`(b)'s "not re-applied to the foreign host" half needs an auth layer — **SCHEDULED** (Phase 5c) + +`XCUT-17`(b) has two halves: strip `Cookie`/`Proxy-Authorization` on a cross-origin hop, **and** "ensure the +caller's credential is not re-applied to the foreign host". 5b ships the first and the *mechanism* for the +second — `REDIR-11`'s marker, plus an independent guard so it never reaches the wire — but nothing yet reads +the marker for its intended purpose. `AUTH-29`'s consumption side and `PIPE-2`'s auth-re-runs-per-hop clause +are the same deferral. Appendix B reaches redirect only through the `XCUT-17` line at +`appendix-b-conformance-test-checklist.md:81`, so this is the row Phase 9 will actually check. + +### G8 — The 5b design doc's process note claims it is uncommitted — **ACT** + +`docs/superpowers/specs/2026-07-26-phase5b-redirect-design.md:23` ends: "Not committed — left for the user to +review and commit if it holds up." It was committed in `c6603aa` ("Planning (#26)") and has since been amended +twice. The sentence is stale and should be dropped or rewritten; the rest of the process note (that the design +was authored autonomously and every judgment call is re-listed in the Deviation Ledger for challenge) is still +accurate and worth keeping. Left as-is rather than rewritten unilaterally, because it is the author's own +process note about their own delegation. + +### G9 — `retry/engine.ts` was edited by a phase that does not own it — **WATCH** + +5b's review pass 1 found both of its close-before-throw paths replacing the error they were meant to +propagate, because `Response.close()` rethrows whatever cancelling the body raised. The fix needed +`releaseQuietly`/`withReleaseFailure`, which existed as module-private helpers inside 5a's `retry/engine.ts`. +Rather than ship a second copy of a helper whose identity guard is load-bearing, they were extracted to +`packages/core/src/recovery/release.ts` and both call sites now import them. + +The move is behavior-neutral — the diff is one import added and the two functions removed verbatim, and 5a's +suite passes untouched — and the new module has its own tests at 100% coverage. Recorded because a file +belonging to a merged phase changed outside that phase's plan, which is exactly the kind of edit a later +conformance sweep should be able to find an explanation for. + +**Trigger:** none expected. Re-verify at Phase 9 that 5a's checklist rows for `RECOV-12`/`RETRY-22` still point +at code that exists where they say it does. + +--- + ## Maintaining this file Add an entry the moment a gap is found, not when it is fixed — the failure mode this file prevents is a diff --git a/docs/superpowers/plans/2026-07-26-phase5b-redirect-checklist.md b/docs/superpowers/plans/2026-07-26-phase5b-redirect-checklist.md new file mode 100644 index 0000000..f450158 --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-phase5b-redirect-checklist.md @@ -0,0 +1,139 @@ +# Phase 5b — Redirect Implementation Plan — Checklist + +Verification of [2026-07-26-phase5b-redirect.md](./2026-07-26-phase5b-redirect.md) against every requirement +ID in `docs/product-spec/10-redirect-handling.md` (`REDIR-1`–`REDIR-28`) plus `PIPE-40`, as dispositioned by +`docs/superpowers/specs/2026-07-26-phase5b-redirect-design.md`. + +**Status: EXECUTED (2026-08-27).** Every task below is implemented, tested, and green across the full gate +sequence (`typecheck`, `lint`, `build`, `bun test` with coverage, `api:ci`, `lint:publish`, +`verify:dual-consumption`, `verify:consumer-types`, `verify:seam-1`, `verify:runtime-floor`, `test:node`, +`audit`). `packages/core/etc/core.api.md` and `packages/core/src/index.ts` are byte-identical to this phase's +starting point (`862bb46`, Phase 5a) — nothing in this phase reaches the public barrel. (Stated against +the branch point, not `main`: `main` currently sits three commits back at `8e55792`, so a diff against +it would show Phases 3, 4, and 5a's barrel changes and prove nothing about this one.) + +**Legend:** ✅ Implemented and tested — 🚫 Not built (permanent simplification, named reason) — ⏳ Deferred +(named target phase) — N/A Not applicable in this port. + +## Files shipped + +| File | Requirements | Task | +|---|---|---| +| `packages/core/src/redirect/errors.ts` | `REDIR-6`, `REDIR-15` | 1 | +| `packages/core/src/redirect/codes.ts` | `REDIR-1`–`REDIR-5` | 2 | +| `packages/core/src/redirect/cross-origin.ts` | `REDIR-8`, `REDIR-11` | 3 | +| `packages/core/src/redirect/settings.ts` | `REDIR-17`, `REDIR-20`, `REDIR-26`, `REDIR-27` | 4 | +| `packages/core/src/redirect/decide.ts` | `REDIR-1`–`REDIR-21` | 5 | +| `packages/core/src/redirect/redirect-step.ts` | `REDIR-22`, `REDIR-23`, `PIPE-15`, `PIPE-36`, `PIPE-40` | 6 | +| `packages/core/src/redirect/strip-marker-step.ts` | `REDIR-11`(c) | 7 | +| `packages/core/src/recovery/release.ts` | `RECOV-12`, `RETRY-22`, `REDIR-22`(b) | review pass 1 | +| `test/node-conformance/redirect.test.mjs` | `REDIR-12`–`REDIR-14`, `REDIR-18`, `PIPE-40` on Node | 6 | + +Every production file has a colocated `*.test.ts`; 124 tests across the eight pairs. + +`recovery/release.ts` was not in the plan. It is `releaseQuietly`/`withReleaseFailure`, **extracted +unchanged** from `retry/engine.ts` during review pass 1 so this phase's two error paths consume them +rather than shipping a second copy of a helper whose identity guard is load-bearing. The move is +behavior-neutral for 5a — its suite passes untouched — and `engine.ts` now imports what it used to +define. + +## 10.1 Recognized codes and eligibility + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| REDIR-1 | MUST | Redirect attempted only for 301/302/303/307/308; any other status returned verbatim without consulting redirect logic | ✅ | Task 2 (`REDIRECT_STATUSES`, `isRecognizedRedirect`), Task 5 (`decide()`'s first statement, before any allocation) | +| REDIR-2 | MUST | 300/304/305 never auto-followed even with a Location; 305 never redirects to a server-chosen proxy | ✅ | Task 2 — excluded from the set by construction; asserted for all three codes *with* a Location present | +| REDIR-3 | MUST | 301/302 followed only when the ORIGINAL method is in the allowed set (default `{GET, HEAD}`); method AND body preserved, no automatic POST→GET rewrite | ✅ | Task 2 (`isEligibleByCode`), Task 5 (`buildFollowRequest` carries `current.method` and the builder-prefilled body through) | +| REDIR-4 | MUST | 307/308 preserve method and body, followed only when the method is in the allowed set | ✅ | Task 2 — same predicate; 303 is the only status branched on, so the four method-preserving codes cannot drift apart | +| REDIR-5 | MUST | 303 not followed by default; when opted in, re-issued as GET with the body dropped and every `Content-*` header removed case-insensitively; the original method is irrelevant to whether it is followed | ✅ | Task 2 (the `allow303`-only gate, asserted against an *empty* allowed-method set), Task 5 (`stripContentHeaders`, `method: 'GET'`, `body(undefined)`) | + +## 10.2 Body and credential hygiene + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| REDIR-6 | MUST | A followed method-preserving redirect re-sends the body, so it MUST be replayable; a non-replayable body fails with a clear error naming replayability, and the redirect is not attempted. 303 exempt | ✅ | Task 1 (`NonReplayableBodyError`), Task 5 — the gate is evaluated **before** any write is attempted, which is what separates it from 3b's `ConsumedBodyError` (a second-write failure). 303's exemption asserted with a single-use body | +| REDIR-7 | MUST | `Authorization` stripped before EVERY re-issue — same-origin and the 303 GET rebuild included | ✅ | Task 5 (`nextHopHeaders`, unconditional), asserted same-origin, cross-origin, on the 303 rebuild, and on a permitted downgrade; re-asserted end-to-end against the wire in Task 6 | +| REDIR-8 | MUST | Cross-origin iff the resolved target differs from the SEED origin in scheme, host (case-insensitive), or effective port (default when omitted) — never the immediately preceding hop | ✅ | Task 3 (`originOf`/`isCrossOrigin`) — the seed origin is computed once in the step and never advances with the chain (Task 6). A `fast-check` property asserts path/query/fragment never participate | +| REDIR-9 | MUST | On a cross-origin redirect (303 rebuild included), `Cookie` and `Proxy-Authorization` also stripped | ✅ | Task 5 | +| REDIR-10 | SHOULD | On a same-origin redirect the `Cookie` header is retained; only `Authorization` is stripped | ✅ | Task 5, asserted directly (both headers survive a same-origin hop) | +| REDIR-11 | MUST | A cross-origin re-issue carries an out-of-band signal telling the auth layer to skip stamping: (a) unforgeable — cleared on every re-issue before being conditionally set, (b) suppress-only, never causing a credential to be sent, (c) removed by the credential-attaching layer before dispatch | ✅ (a, b, c) | Task 3 (`CROSS_ORIGIN_MARKER_HEADER`, `withCrossOriginMarker` clears-then-sets in one `set` call), Task 5 (cleared unconditionally, set only when cross-origin), Task 7 (the `POST_AUTH` guard). (b) holds structurally — nothing in 5b reads the marker to *cause* a stamp; 5c's auth step is its first consumer. The porter caveat the requirement itself names ("a pipeline with no auth step forwards the internal marker to the transport") is closed here rather than left to 5c — see the cross-phase table **Review pass 1:** the guard step now early-returns when the marker is absent instead of rebuilding `Headers` and `Request` on every request through the pipeline, and `withRedirect()` removes any existing guard before re-installing, so a second call cannot seat a duplicate (`append` dedupes by `type` only for pillar stages) | +| REDIR-12 | MUST | Userinfo in the Location target dropped before re-issue; server-supplied embedded credentials never used | ✅ | Task 5 (`resolveLocation`), asserted in `bun test` and again on Node's own URL parser | + +## 10.3 Location resolution + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| REDIR-13 | MUST | Resolution preserves the wire-exact, already-percent-encoded path/query/fragment, bracketed IPv6 literal hosts, and explicit ports; `%2F`→`/` or `%26`→`&` re-encoding forbidden | ✅ | Task 5 — nothing decodes or re-encodes; the only mutation is clearing userinfo. Asserted for `%2F`/`%26` and for `[2001:db8::1]:8443`, and repeated in `test/node-conformance/redirect.test.mjs` because the parser is the runtime's, not this package's | +| REDIR-14 | MUST | A relative Location resolved against the CURRENT hop's request URL per RFC 3986; absolute values used as-is after userinfo stripping | ✅ | Task 5 — `new URL(raw, currentUrl)`. The two-hop test in Task 6 uses a *relative* second Location precisely so "current hop, not seed" is load-bearing | +| REDIR-18 | MUST | A malformed or unresolvable Location — invalid URI, illegal characters, or an unsupported/unknown scheme — MUST NOT throw; the step returns the current response unfollowed | ✅ (total) / ⏳ (the log) | Task 5 — totality asserted by a `fast-check` property over arbitrary strings sanitized only to what the *lenient* inbound header validator admits. The unsupported-scheme half needed an explicit `http:`/`https:` gate: WHATWG `URL` parses `javascript:`, `data:`, `file:`, and `mailto:` without complaint and the downgrade guard passes all of them. The requirement's "logs the condition" clause is deferred — see the deferral table | +| REDIR-19 | MUST | A missing or empty Location returns the response unfollowed | ✅ | Task 5, asserted for both the absent header and an empty value | +| REDIR-27 | MAY | The header the target is read from is configurable, default `Location` | ✅ | Task 4 (`locationHeader`), Task 5 (read through the setting), asserted with a custom header name. **Review pass 1:** the value was validated non-blank but stored untrimmed and never checked against the header-name grammar, while `Headers.get()` neither trims nor validates — so `' Location '` was accepted and then silently matched nothing, leaving every redirect unfollowed with no error at any layer. Now trimmed before storage and validated with `hasForbiddenNameByte` (HTTP-17), the same guard 5a applies to `attemptHeaderName` | + +## 10.4 Loop, cap, and downgrade + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| REDIR-15 | MUST | An HTTPS→HTTP downgrade across a single hop rejected by default with a clear error; opt-in permits it but MUST surface it observably; credential stripping applies regardless; evaluated per hop transition | ✅ (rejection, opt-in, stripping) / ⏳ (the observable surfacing) | Task 1 (`SchemeDowngradeError`), Task 5 — keyed to the CURRENT hop's scheme, not the seed's, so an HTTPS→HTTP→HTTPS chain flags only the hop that actually downgraded; asserted directly. Credential stripping on a *permitted* downgrade asserted separately. The "surface it observably" clause is a distinct obligation on the permitted path and is deferred with the rest of redirect's logging — see the deferral table | +| REDIR-16 | MUST | Loops detected by recording every visited absolute URI (seeded with the original request URI); revisiting one stops and returns the CURRENT response WITHOUT throwing, body left open | ✅ | Task 5 (the `visited` check), Task 6 (the set is seeded with the seed request's `href` and grown per followed hop). Asserted end-to-end: the loop response comes back identical and with `cancelCount() === 0` | +| REDIR-17 | MUST | Followed redirects capped by `maxHops` (default 3); on reaching the cap the last response is returned as-is even if itself a 3xx, without throwing; `maxHops: 0` disables following entirely | ✅ | Task 4 (default 3, `0` accepted as an ordinary value; **review pass 1** tightened the guard from finite-and-non-negative to `Number.isInteger`, so a fractional budget is rejected rather than silently truncated), Task 5 (`redirectsFollowed + 1 > maxHops`). Asserted end-to-end with a 4th 301 past a 3-hop cap — returned open, still a 301 — and with `maxHops: 0` on the first response. No special-case branch exists for `0`; the same gate produces it | +| REDIR-23 | SHOULD | Iterative loop, not unbounded recursion, so it is stack-safe regardless of `maxHops` | ✅ | Task 6 — a `for(;;)` with `await`; each iteration's frame is released before the next begins | + +## 10.5 The predicate and the fast path + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| REDIR-20 | MUST | A configured predicate fully overrides the built-in follow decision and receives a read-only, DEFENSIVELY COPIED condition snapshot (current response, redirects already followed, insertion-ordered visited set including the current request's URI) so it cannot mutate live cycle-detection state | ✅, scoped | Task 4 (`RedirectCondition`/`RedirectPredicate`), Task 5. The snapshot is a real `new Set(visited)` copy, not the live set typed `ReadonlySet` — the type is erased at runtime, and the assertion that a predicate casting it away cannot poison loop detection is a direct test. **The override is scoped to code/method eligibility only**, not to the safety mechanics that follow it (userinfo stripping, credential hygiene, downgrade rejection, replayability, loop/cap) — a judgment call on ambiguous wording, recorded in the design doc's Deviation Ledger and asserted as ledgered behavior | +| REDIR-21 | SHOULD | The non-redirect fast path short-circuits before allocating a snapshot and MUST NOT consult a predicate; a recognized 3xx ALWAYS allocates the snapshot and consults the predicate, even with no usable Location | ✅ | Task 5 — the recognized-status check is `decide()`'s first statement, asserted by a predicate that records whether it was called; the "even with no usable Location" half asserted with a 301 carrying no Location at all | + +## 10.6 Lifecycle, ordering, and immutability + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| REDIR-22 | MUST | Deterministic response-body lifecycle: (a) the prior redirect response closed before issuing a follow-up; (b) if building the follow-up throws, the current response closed before the error propagates; (c) on any "return current" outcome the response is left OPEN for the caller | ✅ | Task 6 — (a) the two-hop conformance test counts exactly one release per superseded hop; (b) `decideOrClose` wraps the decision because `decide()` invokes caller predicate code, asserted with a throwing predicate *and* with the downgrade rejection, both leaving `cancelCount() === 1`; (c) asserted on not-a-redirect, loop-detected, hop-cap, and cancelled paths. The error from (b) is rethrown **unchanged** — redirect's spec states no conversion, unlike `RETRY-40`. **Review pass 1:** both (b) paths originally did a bare `await response.close()`, which `Response.close()` is documented to reject from — so a failing release replaced the very error that was supposed to propagate. They now go through `releaseQuietly`/`withReleaseFailure`, keeping the decision error primary with the release failure as `suppressed` (`RECOV-12`); asserted for `SchemeDowngradeError` and for a caller predicate's own error. Path (a) is deliberately NOT quieted: there is no primary error to preserve, and `PIPE-40` makes the release part of the contract | +| REDIR-24 | MUST | The redirect follower wraps the credential-attaching layer — redirect OUTER, auth INSIDE, per hop | ✅ | Structural — 4c's `STAGE_ORDER` places `REDIRECT` before `AUTH`, and `redirectStep` is pinned to the `REDIRECT` pillar (`PIPE-36`), so a caller cannot invert the two. The clause's *consequence* — `REDIR-7`'s unconditional strip plus `REDIR-11`'s suppression signal — ships here; the auth step that runs inside the loop is 5c | +| REDIR-25 | MUST | The asynchronous pipeline MUST NOT follow redirects: no async redirect step ships, no async preset installs one, so a 3xx surfaces to the async caller verbatim | ✅ (preserved) | Structural — this phase ships **one** adapter, not 5a's two. There is no async redirect step and nothing to install one. The asymmetry with the sync pipeline is preserved rather than changed, so no documentation of a deviation is owed | +| REDIR-26 | MUST | The allowed-method set stored as an immutable defensive copy, decoupled from the caller's collection | ✅ | Task 4 — `new Set(merged.allowedMethods)`, asserted by mutating the caller's set after construction. Deliberately a copy and not a frozen `Set`: `Object.freeze` is shallow and does not disarm `Set.prototype.add`, so a "frozen set" would be a guarantee the runtime cannot keep. The settings object itself IS frozen | + +## 10.7 Observability + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| REDIR-28 | SHOULD | Each followed hop, loop detection, and scheme-downgrade event emitted as structured records, URLs through a redactor, redaction failures degraded to a placeholder; the malformed-Location event logs the raw string as the stated exception | ⏳ | **Phase 7b, Task 9.** 5b executes before 7b, so an `observability/logger.js` import here would not resolve — and 7b's own retrofit conformance test needs 5b's redirect step, so the dependency cannot run the other way. `redirect-step.ts` carries a TSDoc note at `redirectStep()` naming 7b's Task 9 as the owner. Same disposition, and the same cycle-breaking reason, as 5a's two `engine.ts` events | + +## Cross-cutting invariants (`§19`) — what appendix B actually checks for redirect + +Appendix B carries **no `REDIR-`-prefixed checkbox at all**. Redirect reaches its conformance checklist only +through the cross-cutting line at `appendix-b-conformance-test-checklist.md:81`, so these are the rows Phase 9 +will look for. + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| XCUT-16 | MUST | No credential stamped over a non-HTTPS transport; the guard applies only on the credential-attaching path, and a deliberately credential-free re-issue — explicitly "a marker-suppressed cross-origin redirect" — MAY proceed over any scheme | ✅ (5b's half) | Structural. 5b never attaches a credential; it only strips (`REDIR-7`) and signals suppression (`REDIR-11`). The carve-out this requirement names is exactly what `cross-origin.ts`'s marker produces. The enforcing half is 5c's auth step | +| XCUT-17 | MUST | Redirect credential hygiene: (a) strip `Authorization` before every re-issue, even same-origin; (b) cross-origin — judged against the seed, not the previous hop — additionally strip `Cookie`/`Proxy-Authorization` and ensure the caller's credential is not re-applied to the foreign host; (c) drop userinfo in the Location; (d) reject an HTTPS→HTTP downgrade by default, opt-in only, logging the deviation | ✅ (a, c, d-rejection) / ✅ (b, stripping half) / ⏳ (b's re-application half, and d's logging) | (a) Task 5, asserted same-origin, cross-origin, on the 303 rebuild, and on a permitted downgrade; every value stripped regardless of header casing. (b) Task 5 strips both, seed-judged; "not re-applied to the foreign host" needs an auth layer to refrain, so it is 5c's to close via the marker 5b produces. (c) Task 5, asserted in `bun test` and on Node's own parser. (d) Task 5 rejects by default with `SchemeDowngradeError` and permits only via `allowSchemeDowngrade`; the logging half travels with `REDIR-28` to Phase 7b | +| XCUT-19 | MUST | Default-deny log redaction of userinfo/query/fragment/headers/credentials/bodies | N/A in 5b | Vacuous while 5b emits nothing. Becomes live with Phase 7b's Task 9, which routes every URL field through `redactUrl()` | +| XCUT-20 | MUST | Observability never throws into the request path | N/A in 5b | Same — no emission sites exist here yet. 7b's `emitQuietly()` owns it | + +## Cross-phase obligations + +| Obligation | Status | Where | +|---|---|---| +| `PIPE-40` — a wrapping step releases every superseded intermediate response and never closes the one it hands back; on an abandoned re-drive the in-flight response is returned unclosed | ✅ **Resolved here** | Task 6's two-hop `FakeTransport` conformance test: three wire sends, exactly one release per intermediate response observed through `countingResponse()`'s stream hook, and `cancelCount() === 0` on the final response. Deferred out of 4c and targeted at "the first redirect step" by the roadmap — that is this phase. The abandon clause is asserted on the paths that genuinely return — loop detected, hop cap, and cancellation — each with `cancelCount() === 0`. **Its fourth named path, non-replayable body, is NOT one of them, and that is deliberate:** `PIPE-40` lists it among the responses "returned unclosed" while `REDIR-22`(b) lists the same trigger among those "closed before the error propagates", and `REDIR-6` settles the control flow by requiring that path to *fail with an error* rather than return. 5b closes and throws; the contradiction is recorded in the design's Deviation Ledger and deferred to Phase 10, and `redirect-step.test.ts` asserts the close-then-throw behavior with the reasoning inline | +| `PIPE-15` — a step that re-drives the chain takes a FRESH continuation per drive | ✅ | Task 6 — every dispatch, including the first, goes through `ctx.fork()`; `ctx.next()` is never called, since its single-invocation guard would trip on hop two | +| `PIPE-36` — a shipped pillar family locks its stage assignment | ✅ | Task 6 — satisfied structurally, as 5a's `retryStep` was: a factory returning a descriptor with `stage: 'REDIRECT'` baked in. Nothing to subclass, nothing to relocate | +| `PIPE-3` — the inert extension slots around each pillar | ✅ (consumed) | Task 7 — `stripCrossOriginMarkerStep()` is the first real occupant of `POST_AUTH`, which 4c shipped inert. No change to 4c was needed | +| `StepContext.signal` (5a's Task 1 amendment) | ✅ (consumed) | Task 6 — checked once per iteration, in the `follow` branch, before closing the hop and re-driving. No cancellable *wait* is needed here (unlike retry, nothing sleeps between hops), so this is one cheap read rather than a timer race | +| `FakeTransport` (5a's `@internal` double) | ✅ (reused unchanged) | Tasks 6 and 7 — consumed exactly as the roadmap said 5b and 5c would, with no edits to `testing/fake-transport.ts` | +| Node-runtime conformance (`CLAUDE.md`'s membership rule) | ✅ | `test/node-conformance/redirect.test.mjs` — Location resolution is delegated wholesale to the platform's WHATWG `URL`, an independent implementation on each runtime, and `PIPE-40`'s close counting rides on Web Streams. Thirteen cases: relative resolution, dot segments, protocol-relative, `%2F`/`%26` preservation, bracketed IPv6 with an explicit port, userinfo clearing, case/default-port normalization (what makes `REDIR-16`'s loop detection hold, since `visited` keys on `href`), non-URL-as-relative-reference, the malformed-absolute throw, the unsupported-scheme gate, and the three lifecycle paths | +| Public barrel unchanged | ✅ | Task 8 — `git diff --exit-code` on `core.api.md` and `index.ts` is empty, and `src/redirect/` gets no `index.ts`. Same "not yet" disposition 5a's `retry/` shipped with: 5c's promotion task is the first point any pillar-authoring surface goes public | + +## Deferred out of Phase 5b + +| Item | Target | Reason | +|---|---|---| +| `REDIR-28` — the hop, loop-detected, downgrade, and malformed-Location log events | Phase 7b (Task 9) | Cycle-breaking: 5b cannot import `observability/` (it does not exist at this plan's execution time), and 7b needs 5b's redirect step for its own retrofit conformance test. `redirect-step.ts` names 7b's Task 9 as the owner in its TSDoc | +| `REDIR-15`'s "surface it observably" clause on a *permitted* downgrade | Phase 7b (Task 9) | Travels with `REDIR-28`. The opt-in flag and the credential-stripping half both ship here; only the warning-level emission is outstanding. Note this is one obligation, not two: setting a boolean in a config file a year ago is not surfacing anything about the request that actually took the downgrade | +| A reason discriminant on `Decision`'s `'return-current'` variant | Not scheduled | 7b's amendment already flags this: without it, logging cannot distinguish loop-detected from hop-cap-exceeded from normal termination, so those two of `REDIR-28`'s four events stay open even after Task 9. Reshaping `Decision` touches every assertion in `decide.test.ts`; it is a `SHOULD`, so it did not earn that churn inside this phase | +| `AUTH-29` / the marker's *consumption* side — skip-stamping on a cross-origin re-issue, and the auth step's first-stripper role | Phase 5c | 5b only **produces** the marker and **defends** it with an independent guard. Nothing yet reads it for its intended purpose. When 5c ships, `stripCrossOriginMarkerStep()` stays installed as a redundant, idempotent backstop | +| `PIPE-2`'s auth-re-runs-per-hop clause | Phase 5c | Needs an auth step to re-run | +| The standard-resilience preset and public-barrel promotion of `redirectStep`/`withRedirect` | Phase 5c | The preset needs all three pillars installed; publishing a pillar-authoring surface early would freeze `StepDescriptor`/`Stage`/`PipelineBuilder` shapes 5c may still reshape | +| The predicate-override scope judgment (`REDIR-20`) | Phase 9 conformance sweep, or sooner | A judgment call made without the user present. Narrow and mechanical to reverse if wrong: gate `decide()`'s step 3 onward behind the predicate's answer. Recorded in the design doc's Deviation Ledger | diff --git a/docs/superpowers/specs/2026-07-26-phase5b-redirect-design.md b/docs/superpowers/specs/2026-07-26-phase5b-redirect-design.md index 480ee7a..0ea50af 100644 --- a/docs/superpowers/specs/2026-07-26-phase5b-redirect-design.md +++ b/docs/superpowers/specs/2026-07-26-phase5b-redirect-design.md @@ -339,6 +339,14 @@ Wiring redirect's log call sites is a one-file addition once Phase 7's `Logger` against a facade that doesn't exist yet would mean guessing its shape twice. Not re-litigated; consistent with 5a's precedent. +> **Amended again 2026-08-27 (execution).** The retrofit below is written as though it had already landed in +> this file's own code. It has not: Phase 5b executes *before* Phase 7b, so `redirect-step.ts` cannot import +> `observability/logger.js` or `observability/redaction.js` — and 7b needs 5b's redirect step for its own +> retrofit conformance test, so the dependency cannot run the other way. Phase 5b as executed emits **no** +> log events; `redirectStep()`'s TSDoc names Phase 7b's plan Task 9 as their owner, and the plan's own +> 2026-07-29 correction says the same. Read the paragraph below as 7b's specification of what it will add +> here, not as a description of shipped code. + *Current disposition:* `redirect-step.ts` emits three events via `getGlobalLogger()` — a per-hop event, a rejection event on the `'fail'` path, and the permitted-downgrade event described under "Scheme-downgrade guard". Two constraints bind every one of them, and neither is optional: @@ -399,8 +407,9 @@ hints — that parser is 5a's `pacing.ts`, untouched here). | Location resolution ends with an explicit `http:`/`https:` followable-scheme gate | Spec states "an unsupported scheme" is returned unfollowed, without saying how it is detected | WHATWG `URL` happily parses `javascript:`, `data:`, `file:`, and `mailto:`, and the scheme-downgrade guard passes them (none is `http:`) — without the gate the step would dispatch a server-supplied `javascript:` target | | The predicate's `RedirectCondition.visited` is a defensive copy, not the live set typed `ReadonlySet` | Spec: "a read-only, defensively-copied condition snapshot… so it cannot mutate the live cycle-detection state" | A `ReadonlySet` type annotation is erased at runtime; a predicate that casts it away could pre-seed or clear loop detection for the rest of the call. The spec's wording is about the object, not the type | | `maxHops: 0` is an ordinary cap value, not a special-cased early return | Spec states it as "disables redirect following entirely" | Falls out of the same cap gate every other `maxHops` value uses — a 0-hop budget always fails the "would this exceed the cap" check on the first follow attempt, producing identical observable behavior with no branch to get wrong | +| The non-replayable-body path CLOSES the in-flight response and throws, rather than returning it unclosed | **`PIPE-40` and `REDIR-22` contradict each other here, both at `MUST` level.** `PIPE-40`: "on paths that abandon a re-drive (redirect cycle, **non-replayable body**, budget exhausted) the in-flight response MUST be returned unclosed." `REDIR-22`: "if building the follow-up throws (**non-replayable body**, downgrade rejection) the current response MUST be closed before the error propagates." | Resolved for `REDIR-22`, on three grounds. (1) `REDIR-6` independently settles the control flow — "the operation MUST fail with a clear error naming replayability" — so this path throws, and a response that is never *returned* cannot be "returned unclosed"; `PIPE-40`'s clause is written about a returned value. (2) Specific governs general: `§10` owns the redirect step's lifecycle, `PIPE-40` states the cross-cutting default. (3) Closing is the safer reading — the alternative leaks a body on an error path with no caller holding a reference to close it. `PIPE-40`'s other two named paths (cycle, budget) DO return, and both return unclosed as it requires. Flagged for Phase 10; if reversed, the change is one branch in `redirect-step.ts` | | No stage-pipeline recovery-chain adapter | 5a shipped two adapters (pillar + recovery) over one retry engine | `pipeline.md`/`PIPE-*` states plainly there is no async redirect pillar — the async standard pipeline does not follow redirects at the pipeline layer at all, so there is no second consumer to adapt for | -| ~~Redirect logging not implemented~~ — **superseded 2026-07-28 by the Phase 7b retrofit**; hop, rejection, and permitted-downgrade events now ship, redacted and contained | Spec: `SHOULD` emit structured records per hop/loop/downgrade event | Was: `Logger`/`LogEvent` seam is Phase 7 per the roadmap's Deferred Items Log. Now: only the loop-detected and malformed-Location events remain deferred, both blocked on a reason discriminant `decide()`'s `Decision` does not carry | +| Redirect logging not implemented in this phase — the Phase 7b retrofit (hop, rejection, permitted-downgrade events, redacted and contained) is specified above but **applied by 7b's Task 9**, not by 5b | Spec: `SHOULD` emit structured records per hop/loop/downgrade event | Was: `Logger`/`LogEvent` seam is Phase 7 per the roadmap's Deferred Items Log. Now: only the loop-detected and malformed-Location events remain deferred, both blocked on a reason discriminant `decide()`'s `Decision` does not carry | ## Deferred Items (add to the roadmap's Deferred Items Log) @@ -408,5 +417,6 @@ hints — that parser is 5a's `pacing.ts`, untouched here). |---|---|---|---| | `PIPE-40` — 2-hop-redirect conformance clause | Phase 4c, targeted here by the roadmap | **Resolved in Phase 5b** | Satisfied by the two-hop `FakeTransport` test above (wire-send count, per-hop close, final-response-open) | | `AUTH-29` / marker *consumption* (skip-stamping on a cross-origin re-issue, first-stripper role) | This brainstorm | **Phase 5c** | 5b only produces the marker and defends it with an independent guard step; nothing yet reads it for its intended purpose (suppressing credential stamping) — that is 5c's auth step | -| Redirect structured logging (`SHOULD`-level hop/loop/downgrade events) | This brainstorm | **Partially resolved 2026-07-28 (Phase 7b)** | Hop, rejection, and permitted-downgrade events ship in `redirect-step.ts`, URLs through `redactUrl()`, emissions through `emitQuietly()`. The loop-detected and malformed-Location events remain open — both need a reason discriminant on `decide()`'s `'return-current'` variant | +| Redirect structured logging (`SHOULD`-level hop/loop/downgrade events) | This brainstorm | **Phase 7b, Task 9** | Specified 2026-07-28 and unbuilt as of 5b's execution: hop, rejection, and permitted-downgrade events, URLs through `redactUrl()`, emissions through `emitQuietly()`. The loop-detected and malformed-Location events stay open even after that — both need a reason discriminant on `decide()`'s `'return-current'` variant | | Redirect predicate's scope over safety mechanics (see Deviation Ledger) | This brainstorm | Re-confirm at Phase 9 conformance sweep, or sooner if the user disagrees | A judgment call made without the user present; narrow and mechanical to reverse if wrong | +| `PIPE-40` vs `REDIR-22` on the non-replayable-body path | Review pass 3 | **Phase 10 reconciliation** | Two `MUST`s naming the same trigger and prescribing opposite dispositions. 5b implements `REDIR-22` (close, then throw) for the reasons in the Deviation Ledger; whichever way Phase 10 lands, one of the two spec sentences needs an erratum rather than a silent port-side choice | diff --git a/packages/core/src/recovery/release.test.ts b/packages/core/src/recovery/release.test.ts new file mode 100644 index 0000000..1267788 --- /dev/null +++ b/packages/core/src/recovery/release.test.ts @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/recovery/release.test.ts +// Exercises: RECOV-12 (a teardown failure rides along as `suppressed` and never becomes primary), +// RETRY-22 and REDIR-22's shared consequence — the error that must propagate is the upstream/decision +// failure, not the release that ran on its way out. Extracted from `retry/engine.ts` in Phase 5b so the +// redirect step consumes it rather than shipping a second copy. +import {describe, expect, test} from 'bun:test'; +import {Headers} from '../http/headers.js'; +import {Protocol} from '../http/protocol.js'; +import {Request} from '../http/request.js'; +import {Response} from '../http/response.js'; +import {Status} from '../http/status.js'; +import type {SuppressedErrorLike} from '../suppress.js'; +import {releaseQuietly, withReleaseFailure} from './release.js'; + +const REQUEST = Request.newBuilder().url('https://example.com').build(); + +/** `cancel` decides the release outcome: `undefined` releases cleanly, an `Error` is rethrown by close. */ +function responseWith(cancelFailure?: Error): Response { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.from([1])); + }, + cancel() { + if (cancelFailure !== undefined) throw cancelFailure; + }, + }); + return Response.newBuilder() + .request(REQUEST) + .protocol(Protocol.HTTP_1_1) + .status(Status.of(200)) + .headers(Headers.newBuilder().build()) + .body(body) + .build(); +} + +describe('releaseQuietly', () => { + test('reports a clean release with a token withReleaseFailure treats as "nothing happened"', async () => { + const primary = new Error('upstream'); + const token = await releaseQuietly(responseWith()); + expect(withReleaseFailure(primary, token)).toBe(primary); + }); + + test('an absent response releases cleanly', async () => { + const primary = new Error('upstream'); + const token = await releaseQuietly(undefined); + expect(withReleaseFailure(primary, token)).toBe(primary); + }); + + test('reports rather than raises whatever close() threw', async () => { + const boom = new Error('cancel exploded'); + expect(await releaseQuietly(responseWith(boom))).toBe(boom); + }); + + test('a locked-stream TypeError is swallowed by close() itself, so the release reads clean', async () => { + const response = responseWith(); + const body = response.body; + expect(body).not.toBeNull(); + body?.getReader(); // hold the lock: cancel() now rejects with TypeError + const primary = new Error('upstream'); + expect(withReleaseFailure(primary, await releaseQuietly(response))).toBe( + primary, + ); + }); +}); + +describe('withReleaseFailure', () => { + test('keeps the primary primary and carries the release failure as suppressed (RECOV-12)', async () => { + const primary = new Error('upstream'); + const boom = new Error('cancel exploded'); + + const result = withReleaseFailure( + primary, + await releaseQuietly(responseWith(boom)), + ); + + expect(result).not.toBe(primary); + const suppressed = result as SuppressedErrorLike; + expect(suppressed.name).toBe('SuppressedError'); + expect(suppressed.error).toBe(primary); + expect(suppressed.suppressed).toBe(boom); + }); + + test('an identical instance is never suppressed under itself', () => { + // `Response.close()` memoizes its release promise, so a close that already failed hands the SAME + // rejection back to a second caller. Without the identity guard that instance wraps itself. + const shared = new Error('same instance twice'); + expect(withReleaseFailure(shared, shared)).toBe(shared); + }); + + test('a non-Error primary survives unchanged', async () => { + const boom = new Error('cancel exploded'); + const result = withReleaseFailure( + 'a bare string throw', + await releaseQuietly(responseWith(boom)), + ); + expect((result as SuppressedErrorLike).error).toBe('a bare string throw'); + }); +}); diff --git a/packages/core/src/recovery/release.ts b/packages/core/src/recovery/release.ts new file mode 100644 index 0000000..c9a3504 --- /dev/null +++ b/packages/core/src/recovery/release.ts @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/recovery/release.ts +import type {Response} from '../http/response.js'; +import {suppress} from '../suppress.js'; + +/** Marks "the response was released without incident", distinct from any value `close()` could throw. */ +const RELEASED_CLEANLY = Symbol('dexpace.recovery.released'); + +/** + * Releases a discarded response, reporting rather than raising whatever release itself threw. + * + * `Response.close()` is documented to rethrow whatever cancelling the body raises (everything except + * the `TypeError` a locked stream reports), so it is not a call that can sit in a bare `finally`: + * there it would replace the value being returned, or replace an in-flight throwable with the + * teardown failure -- the exact inversion RECOV-12 forbids and `suppress()` exists to prevent. + * + * @param response - the response to release, or `undefined` when there is none. + * @returns an opaque release token for {@link withReleaseFailure}: whatever `close()` threw, or a + * sentinel meaning it released cleanly. + * + * @internal + */ +export async function releaseQuietly( + response: Response | undefined, +): Promise { + if (response === undefined) return RELEASED_CLEANLY; + try { + await response.close(); + return RELEASED_CLEANLY; + } catch (error) { + return error; + } +} + +/** + * Keeps `primary` primary, with a release failure riding along as suppressed (RECOV-12, RETRY-22's + * "a teardown failure can never mask the upstream failure"; REDIR-22's equivalent, where the error + * that must propagate is the decision failure, not the teardown that ran on its way out). + * + * The identity guard is not decorative. `Response.close()` memoizes its release promise, so a close + * that already failed inside `toHttpError`'s own `finally` hands the SAME rejection back to the + * second caller -- without this check that instance would be suppressed under itself. + * + * @param primary - the throwable the caller actually needs to see. + * @param releaseFailure - the token {@link releaseQuietly} returned. + * @returns `primary` unchanged when the release was clean, otherwise a `SuppressedError`-shaped + * pairing with `primary` primary. + * + * @internal + */ +export function withReleaseFailure( + primary: unknown, + releaseFailure: unknown, +): unknown { + if (releaseFailure === RELEASED_CLEANLY || releaseFailure === primary) { + return primary; + } + return suppress( + primary, + releaseFailure, + 'releasing the discarded response failed', + ); +} diff --git a/packages/core/src/redirect/codes.test.ts b/packages/core/src/redirect/codes.test.ts new file mode 100644 index 0000000..5da7d55 --- /dev/null +++ b/packages/core/src/redirect/codes.test.ts @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/redirect/codes.test.ts +// Exercises: REDIR-1 (the recognized set is exactly {301,302,303,307,308}; any other status is returned +// verbatim without consulting redirect logic), REDIR-2 (300/304/305 are never auto-followed even with a +// Location), REDIR-3 (301/302 gated on method membership, default {GET,HEAD}), REDIR-4 (307/308 gated the +// same way), REDIR-5 (303 gated ONLY on the opt-in, independent of the original method). +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import type {Method} from '../http/method.js'; +import { + DEFAULT_ALLOWED_METHODS, + REDIRECT_STATUSES, + isEligibleByCode, + isRecognizedRedirect, +} from './codes.js'; + +describe('isRecognizedRedirect', () => { + test('301, 302, 303, 307, 308 are recognized', () => { + for (const code of [301, 302, 303, 307, 308]) { + expect(isRecognizedRedirect(code)).toBe(true); + } + }); + + test('300, 304, 305 are never recognized (REDIR-2)', () => { + for (const code of [300, 304, 305]) { + expect(isRecognizedRedirect(code)).toBe(false); + } + }); + + test('non-3xx statuses are not recognized (REDIR-1)', () => { + for (const code of [200, 404, 500]) { + expect(isRecognizedRedirect(code)).toBe(false); + } + }); + + test('the exported set and the predicate are the same source', () => { + fc.assert( + fc.property(fc.integer({min: 100, max: 599}), code => { + expect(isRecognizedRedirect(code)).toBe(REDIRECT_STATUSES.has(code)); + }), + ); + }); +}); + +describe('isEligibleByCode', () => { + const eligibility = { + allowedMethods: DEFAULT_ALLOWED_METHODS, + allow303: false, + }; + + test('301/302/307/308 are eligible for GET/HEAD, the default allowed set', () => { + for (const status of [301, 302, 307, 308]) { + expect(isEligibleByCode(status, 'GET', eligibility)).toBe(true); + expect(isEligibleByCode(status, 'HEAD', eligibility)).toBe(true); + } + }); + + test('301/302/307/308 are NOT eligible outside the allowed set (REDIR-3/REDIR-4)', () => { + for (const status of [301, 302, 307, 308]) { + expect(isEligibleByCode(status, 'POST', eligibility)).toBe(false); + } + }); + + test('a caller-widened allowed set makes POST eligible', () => { + const widened = { + allowedMethods: new Set(['GET', 'HEAD', 'POST']), + allow303: false, + }; + expect(isEligibleByCode(301, 'POST', widened)).toBe(true); + }); + + test('303 is never eligible by default, regardless of method (REDIR-5)', () => { + expect(isEligibleByCode(303, 'GET', eligibility)).toBe(false); + expect(isEligibleByCode(303, 'POST', eligibility)).toBe(false); + }); + + test('303 is eligible once opted in, regardless of method (REDIR-5)', () => { + const opted = {allowedMethods: DEFAULT_ALLOWED_METHODS, allow303: true}; + expect(isEligibleByCode(303, 'DELETE', opted)).toBe(true); + }); + + test('303 ignores the allowed-methods set entirely (REDIR-5)', () => { + const empty = {allowedMethods: new Set(), allow303: true}; + expect(isEligibleByCode(303, 'POST', empty)).toBe(true); + }); +}); diff --git a/packages/core/src/redirect/codes.ts b/packages/core/src/redirect/codes.ts new file mode 100644 index 0000000..460662b --- /dev/null +++ b/packages/core/src/redirect/codes.ts @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/redirect/codes.ts +import type {Method} from '../http/method.js'; + +/** + * REDIR-1/REDIR-2: the only statuses redirect logic is ever consulted for. 300, 304, and 305 are + * deliberately excluded even when they carry a `Location` -- 305 in particular must never redirect a + * request to a server-chosen proxy. + * + * @internal + */ +export const REDIRECT_STATUSES: ReadonlySet = new Set([ + 301, 302, 303, 307, 308, +]); + +/** + * REDIR-3/REDIR-4's default allowed-method set. + * + * @internal + */ +export const DEFAULT_ALLOWED_METHODS: ReadonlySet = new Set([ + 'GET', + 'HEAD', +]); + +/** + * REDIR-1: any status outside {@link REDIRECT_STATUSES} -- 2xx, 4xx, 5xx, and non-redirect 3xx alike -- + * is returned verbatim without consulting redirect logic at all. + * + * @param status - the response status code. + * @returns `true` when the status is one redirect logic may act on. + * + * @internal + */ +export function isRecognizedRedirect(status: number): boolean { + return REDIRECT_STATUSES.has(status); +} + +/** + * The policy slice {@link isEligibleByCode} reads. A `RedirectSettings` value satisfies this + * structurally, so callers pass their settings directly rather than building an adapter object. + * + * @internal + */ +export interface CodeEligibility { + readonly allowedMethods: ReadonlySet; + readonly allow303: boolean; +} + +/** + * REDIR-3/REDIR-4/REDIR-5: 301/302/307/308 are eligible only when the ORIGINAL method is in + * `allowedMethods` -- when followed, method and body are preserved, deliberately with no automatic + * POST-to-GET rewrite. 303 is eligible only when opted in via `allow303`, independent of method; the + * GET rebuild and body drop that follow are `decide.ts`'s job, not this predicate's. + * + * @param status - the response status code; assumed recognized. + * @param method - the current hop's request method. + * @param eligibility - the allowed-method set and the 303 opt-in. + * @returns `true` when code and method alone permit following. + * + * @internal + */ +export function isEligibleByCode( + status: number, + method: Method, + eligibility: CodeEligibility, +): boolean { + if (status === 303) return eligibility.allow303; + return eligibility.allowedMethods.has(method); +} diff --git a/packages/core/src/redirect/cross-origin.test.ts b/packages/core/src/redirect/cross-origin.test.ts new file mode 100644 index 0000000..2f6f0bc --- /dev/null +++ b/packages/core/src/redirect/cross-origin.test.ts @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/redirect/cross-origin.test.ts +// Exercises: REDIR-8 (the RFC 6454 origin tuple -- scheme, case-insensitive host, effective port -- +// compared against a fixed SEED origin, never the previous hop; path/query/fragment never participate), +// REDIR-11 (the credential-suppression marker is cleared-then-conditionally-set, so a server-supplied +// Location can never forge an inbound copy into a surviving one). +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import {Headers} from '../http/headers.js'; +import { + CROSS_ORIGIN_MARKER_HEADER, + clearCrossOriginMarker, + hasCrossOriginMarker, + isCrossOrigin, + originOf, + withCrossOriginMarker, +} from './cross-origin.js'; + +describe('originOf / isCrossOrigin', () => { + const seed = originOf(new URL('https://example.com/a')); + + test('identical scheme/host/port is same-origin', () => { + expect(isCrossOrigin(seed, new URL('https://example.com/b?x=1#y'))).toBe( + false, + ); + }); + + test('a differing path/query/fragment alone is never cross-origin', () => { + fc.assert( + fc.property(fc.webPath(), fc.string(), (path, fragment) => { + const target = new URL(`https://example.com${path}`); + target.hash = fragment.replaceAll(/[^\w-]/gu, ''); + expect(isCrossOrigin(seed, target)).toBe(false); + }), + ); + }); + + test('host comparison is case-insensitive', () => { + expect(isCrossOrigin(seed, new URL('https://EXAMPLE.com/b'))).toBe(false); + }); + + test('a differing host is cross-origin', () => { + expect(isCrossOrigin(seed, new URL('https://evil.example/b'))).toBe(true); + }); + + test('a differing scheme is cross-origin even on the same host', () => { + expect(isCrossOrigin(seed, new URL('http://example.com/b'))).toBe(true); + }); + + test('an explicit default port equals an omitted one', () => { + expect(isCrossOrigin(seed, new URL('https://example.com:443/b'))).toBe( + false, + ); + }); + + test('a non-default port is cross-origin', () => { + expect(isCrossOrigin(seed, new URL('https://example.com:8443/b'))).toBe( + true, + ); + }); + + test('a bracketed IPv6 literal host round-trips unchanged (REDIR-13)', () => { + const v6 = originOf(new URL('https://[2001:db8::1]:8443/a')); + expect(v6.host).toBe('[2001:db8::1]'); + expect(v6.port).toBe(8443); + expect(isCrossOrigin(v6, new URL('https://[2001:db8::1]:8443/b'))).toBe( + false, + ); + expect(isCrossOrigin(v6, new URL('https://[2001:db8::2]:8443/b'))).toBe( + true, + ); + }); + + test('comparison is against the SEED, not a previous hop', () => { + // simulates: seed(example.com) -> hop1(other.example, cross-origin) -> hop2(example.com again). + // Anchored to the seed, hop2 is same-origin again -- which is exactly why the comparison must not + // walk hop to hop: a foreign host must not be able to hand the credential back to its own origin. + expect(isCrossOrigin(seed, new URL('https://example.com/final'))).toBe( + false, + ); + }); +}); + +describe('the cross-origin marker', () => { + test('withCrossOriginMarker sets the header to 1', () => { + const headers = withCrossOriginMarker(Headers.newBuilder().build()); + expect(hasCrossOriginMarker(headers)).toBe(true); + expect(headers.get(CROSS_ORIGIN_MARKER_HEADER)).toBe('1'); + }); + + test('withCrossOriginMarker clears a forged inbound copy before setting its own', () => { + const forged = Headers.newBuilder() + .add(CROSS_ORIGIN_MARKER_HEADER, 'anything') + .build(); + const marked = withCrossOriginMarker(forged); + expect(marked.getAll(CROSS_ORIGIN_MARKER_HEADER)).toEqual(['1']); + }); + + test('clearCrossOriginMarker removes it', () => { + const marked = withCrossOriginMarker(Headers.newBuilder().build()); + expect(hasCrossOriginMarker(clearCrossOriginMarker(marked))).toBe(false); + }); + + test('clearCrossOriginMarker is idempotent when already absent', () => { + const bare = Headers.newBuilder().build(); + expect(hasCrossOriginMarker(clearCrossOriginMarker(bare))).toBe(false); + }); + + test('hasCrossOriginMarker is false when never set', () => { + expect(hasCrossOriginMarker(Headers.newBuilder().build())).toBe(false); + }); +}); diff --git a/packages/core/src/redirect/cross-origin.ts b/packages/core/src/redirect/cross-origin.ts new file mode 100644 index 0000000..b472f6e --- /dev/null +++ b/packages/core/src/redirect/cross-origin.ts @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/redirect/cross-origin.ts +import type {Headers} from '../http/headers.js'; + +/** + * The RFC 6454 origin tuple REDIR-8 compares on. Held as a value rather than reusing `URL.origin`'s + * string form so the port is already normalized to the scheme default and the host already lower-cased + * -- `URL.origin` renders an omitted default port and an explicit one identically, but does nothing for + * a scheme this SDK does not follow. + * + * @internal + */ +export interface Origin { + readonly scheme: string; + readonly host: string; + readonly port: number; +} + +const DEFAULT_PORT_BY_SCHEME: ReadonlyMap = new Map([ + ['http:', 80], + ['https:', 443], +]); + +/** REDIR-8: an omitted port normalizes to the scheme's default before comparison. */ +function effectivePort(url: URL): number { + if (url.port !== '') return Number(url.port); + return DEFAULT_PORT_BY_SCHEME.get(url.protocol.toLowerCase()) ?? 0; +} + +/** + * Extracts the comparable origin tuple. `URL.hostname` keeps a bracketed IPv6 literal bracketed and + * already lower-cases a registered name, so nothing here re-encodes the host (REDIR-13). + * + * @param url - the URL whose origin is wanted. + * @returns the normalized scheme/host/effective-port tuple. + * + * @internal + */ +export function originOf(url: URL): Origin { + return { + scheme: url.protocol.toLowerCase(), + host: url.hostname.toLowerCase(), + port: effectivePort(url), + }; +} + +/** + * REDIR-8: scheme/host(case-insensitive)/effective-port comparison against the SEED request's origin -- + * never the previous hop -- so a same-origin sub-redirect on a foreign host cannot re-expose the + * credential a cross-origin hop already stripped. `new URL(...)` never performs DNS resolution, so there + * is no `java.net.URL.equals()` hostname-resolution trap of the kind the JVM reference works around. + * + * @param seedOrigin - the origin of the ORIGINAL request, fixed for the whole chain. + * @param target - the resolved redirect target. + * @returns `true` when the target differs in scheme, host, or effective port. + * + * @internal + */ +export function isCrossOrigin(seedOrigin: Origin, target: URL): boolean { + const targetOrigin = originOf(target); + return ( + targetOrigin.scheme !== seedOrigin.scheme || + targetOrigin.host !== seedOrigin.host || + targetOrigin.port !== seedOrigin.port + ); +} + +/** + * REDIR-11's out-of-band signal, carried as a real header rather than an in-process marker. + * + * A `WeakSet` keyed by object identity was the alternative and is unforgeable, but stage order + * is REDIRECT -> RETRY -> AUTH and 5a's attempt-stamping builds a FRESH per-attempt `Request` copy when + * enabled -- an identity-keyed signal would silently stop matching the moment a retry sits between + * redirect and auth, which is exactly when cross-origin credential suppression must still hold. Stamping + * preserves headers, so a header survives that intermediate copy. `strip-marker-step.ts` is what keeps + * it off the wire. + * + * @internal + */ +export const CROSS_ORIGIN_MARKER_HEADER = + 'x-dexpace-internal-redirect-cross-origin'; + +/** + * REDIR-11(a): `HeadersBuilder.set` with a non-null value REPLACES the whole value list, so this is + * clear-then-set in one call -- a forged or stale inbound copy cannot survive alongside our own. + * + * @param headers - the next hop's headers so far. + * @returns headers carrying exactly one marker value. + * + * @internal + */ +export function withCrossOriginMarker(headers: Headers): Headers { + return headers.newBuilder().set(CROSS_ORIGIN_MARKER_HEADER, '1').build(); +} + +/** + * Idempotent -- clearing an already-absent header is a no-op. + * + * @param headers - the headers to strip the marker from. + * @returns headers with no marker. + * + * @internal + */ +export function clearCrossOriginMarker(headers: Headers): Headers { + return headers.newBuilder().set(CROSS_ORIGIN_MARKER_HEADER, null).build(); +} + +/** + * REDIR-11(b): the marker only ever SUPPRESSES credential stamping; nothing reads it to cause one. + * Phase 5c's auth step is its first real consumer. + * + * @param headers - the headers to inspect. + * @returns `true` when the marker is present. + * + * @internal + */ +export function hasCrossOriginMarker(headers: Headers): boolean { + return headers.has(CROSS_ORIGIN_MARKER_HEADER); +} diff --git a/packages/core/src/redirect/decide.test.ts b/packages/core/src/redirect/decide.test.ts new file mode 100644 index 0000000..f1b8bb9 --- /dev/null +++ b/packages/core/src/redirect/decide.test.ts @@ -0,0 +1,877 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/redirect/decide.test.ts +// Exercises every numbered step of decide()'s contract: REDIR-1/REDIR-2 (the non-redirect fast path and +// the never-followed 300/304/305), REDIR-21 (a recognized 3xx always allocates the snapshot and consults +// the predicate, even with no usable Location; a non-redirect status never does), REDIR-20 (the predicate +// fully overrides code/method eligibility, over a DEFENSIVELY COPIED snapshot), REDIR-14 (relative +// resolution against the CURRENT hop), REDIR-12 (userinfo dropped), REDIR-13 (no re-encoding of an +// already-percent-encoded path/query), REDIR-18/REDIR-19 (malformed, unsupported-scheme, and +// missing/empty Location all return-current without throwing), REDIR-16 (loop detection), REDIR-17 (the +// hop cap, including maxHops: 0), REDIR-15 (the per-hop HTTPS-to-HTTP guard), REDIR-6 (the body +// replayability gate; 303 exempt), REDIR-7 (Authorization always stripped), REDIR-9/REDIR-10 (Cookie and +// Proxy-Authorization stripped only cross-origin), REDIR-11 (the marker set only on a cross-origin hop), +// REDIR-5 (the 303 GET rebuild drops the body and every Content-* header), REDIR-3/REDIR-4 (a followed +// method-preserving redirect keeps the original method). +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import type {Body} from '../body/body.js'; +import {stringBody} from '../body/simple-bodies.js'; +import {streamBody} from '../body/stream-body.js'; +import {Headers} from '../http/headers.js'; +import type {Method} from '../http/method.js'; +import {Protocol} from '../http/protocol.js'; +import {Request} from '../http/request.js'; +import {Response} from '../http/response.js'; +import {Status} from '../http/status.js'; +import {CROSS_ORIGIN_MARKER_HEADER, originOf} from './cross-origin.js'; +import {decide, type RedirectContext} from './decide.js'; +import {NonReplayableBodyError, SchemeDowngradeError} from './errors.js'; +import {redirectSettings} from './settings.js'; + +interface RequestOpts { + readonly method?: Method; + readonly url?: string; + readonly headers?: Headers; + readonly body?: Body; +} + +function aRequest(opts: RequestOpts = {}): Request { + const builder = Request.newBuilder() + .method(opts.method ?? 'GET') + .url(opts.url ?? 'https://example.com/a') + .headers(opts.headers ?? Headers.newBuilder().build()); + return opts.body === undefined + ? builder.build() + : builder.body(opts.body).build(); +} + +/** A single-use body -- `replayable: false` is the only property the gate reads (BODY-9). */ +function oneShotBody(): Body { + return streamBody( + new ReadableStream({ + start: c => { + c.close(); + }, + }), + undefined, + 0, + ); +} + +/** + * Drops exactly what the LENIENT inbound header validator rejects -- C0 controls except HTAB, plus DEL -- + * mirroring `hasForbiddenInboundValueByte`. obs-text (>= 0x80) is legal on an inbound value and must + * reach `decide()` unfiltered, so it is deliberately kept. A code-point filter rather than a regex: the + * equivalent character class is a literal control-character range, which `no-control-regex` rejects for + * exactly the reason that does not apply to a deliberate sanitizer. + */ +function inboundSafe(raw: string): string { + let out = ''; + for (const ch of raw) { + const code = ch.codePointAt(0) ?? 0; + if ((code <= 0x1f && code !== 0x09) || code === 0x7f) continue; + out += ch; + } + return out; +} + +// `setInbound`, not `set`: these are RESPONSE headers, and the outbound-strict `set` rejects every +// non-ASCII byte -- which would make the totality property test below throw inside its own fixture +// rather than reaching the code under test (HTTP-19). +function aResponse( + status: number, + location?: string, + extraHeaders?: Headers, +): Response { + let headers = extraHeaders ?? Headers.newBuilder().build(); + if (location !== undefined) { + headers = headers.newBuilder().setInbound('Location', location).build(); + } + return Response.newBuilder() + .request(aRequest()) + .protocol(Protocol.HTTP_1_1) + .status(Status.of(status)) + .headers(headers) + .body(null) + .build(); +} + +function contextFor( + request: Request, + overrides?: Partial, +): RedirectContext { + return { + currentRequest: request, + seedOrigin: originOf(request.url), + visited: new Set([request.url.href]), + redirectsFollowed: 0, + ...overrides, + }; +} + +describe('the shared return-current value', () => { + test('is frozen -- one instance is handed to every caller on every no-follow path', () => { + const decision = decide( + aResponse(200), + contextFor(aRequest()), + redirectSettings(), + ); + expect(Object.isFrozen(decision)).toBe(true); + }); +}); + +describe('fast path', () => { + test('a non-3xx status returns-current without consulting anything (REDIR-1/REDIR-21)', () => { + let consulted = false; + const settings = redirectSettings({ + predicate: () => { + consulted = true; + return true; + }, + }); + const decision = decide(aResponse(200), contextFor(aRequest()), settings); + expect(decision).toEqual({kind: 'return-current'}); + expect(consulted).toBe(false); + }); + + test('300/304/305 are never followed even with a Location header (REDIR-2)', () => { + for (const status of [300, 304, 305]) { + const decision = decide( + aResponse(status, 'https://example.com/b'), + contextFor(aRequest()), + redirectSettings(), + ); + expect(decision).toEqual({kind: 'return-current'}); + } + }); +}); + +describe('predicate override', () => { + test('a configured predicate REPLACES code/method eligibility (REDIR-20)', () => { + const settings = redirectSettings({predicate: () => true}); + const decision = decide( + aResponse(301, 'https://example.com/b'), + contextFor(aRequest({method: 'POST'})), + settings, + ); + expect(decision.kind).toBe('follow'); + }); + + test('a predicate is consulted even with no usable Location (REDIR-21)', () => { + let observed = false; + const settings = redirectSettings({ + predicate: condition => { + observed = true; + expect(condition.redirectsFollowed).toBe(0); + expect(condition.visited.has('https://example.com/a')).toBe(true); + return true; + }, + }); + const decision = decide(aResponse(301), contextFor(aRequest()), settings); + expect(observed).toBe(true); + expect(decision).toEqual({kind: 'return-current'}); // still no Location to follow to + }); + + test('a predicate saying no wins over an otherwise-eligible code/method', () => { + const settings = redirectSettings({predicate: () => false}); + const decision = decide( + aResponse(301, 'https://example.com/b'), + contextFor(aRequest({method: 'GET'})), + settings, + ); + expect(decision).toEqual({kind: 'return-current'}); + }); + + test('the condition snapshot is a defensive COPY -- a predicate cannot poison loop detection', () => { + const live = new Set(['https://example.com/a']); + const settings = redirectSettings({ + predicate: condition => { + // A predicate that casts the readonly type away and tries to pre-seed the visited set. + (condition.visited as Set).add('https://example.com/b'); + return true; + }, + }); + const context = contextFor(aRequest(), {visited: live}); + const decision = decide( + aResponse(302, 'https://example.com/b'), + context, + settings, + ); + + expect(decision.kind).toBe('follow'); // the injected entry never reached the live set, so /b is unvisited + expect(live.has('https://example.com/b')).toBe(false); + }); + + test('the predicate does NOT bypass the safety mechanics (see the Deviation Ledger)', () => { + // A predicate opting into a 307 re-send cannot make a single-use body replayable. + const request = Request.newBuilder() + .method('POST') + .url('https://example.com/a') + .body(oneShotBody()) + .build(); + const decision = decide( + aResponse(307, 'https://example.com/b'), + contextFor(request), + redirectSettings({predicate: () => true}), + ); + expect(decision.kind).toBe('fail'); + }); +}); + +describe('Location resolution', () => { + test('a relative Location resolves against the current request URL (REDIR-14)', () => { + const decision = decide( + aResponse(302, '/next'), + contextFor(aRequest({url: 'https://example.com/a/b'})), + redirectSettings(), + ); + expect(decision.kind).toBe('follow'); + if (decision.kind === 'follow') { + expect(decision.nextRequest.url.href).toBe('https://example.com/next'); + } + }); + + test('an absolute Location is used as-is (REDIR-14)', () => { + const decision = decide( + aResponse(302, 'https://other.example/x'), + contextFor(aRequest()), + redirectSettings(), + ); + expect(decision.kind).toBe('follow'); + if (decision.kind === 'follow') { + expect(decision.nextRequest.url.href).toBe('https://other.example/x'); + } + }); + + test('userinfo embedded in the Location is dropped unconditionally (REDIR-12)', () => { + const decision = decide( + aResponse(302, 'https://user:pass@other.example/x'), + contextFor(aRequest()), + redirectSettings(), + ); + expect(decision.kind).toBe('follow'); + if (decision.kind === 'follow') { + expect(decision.nextRequest.url.username).toBe(''); + expect(decision.nextRequest.url.password).toBe(''); + expect(decision.nextRequest.url.href).toBe('https://other.example/x'); + } + }); + + test('an already-encoded path/query is never re-encoded (REDIR-13)', () => { + const decision = decide( + aResponse(302, 'https://example.com/a%2Fb?q=x%26y'), + contextFor(aRequest()), + redirectSettings(), + ); + expect(decision.kind).toBe('follow'); + if (decision.kind === 'follow') { + expect(decision.nextRequest.url.pathname).toBe('/a%2Fb'); + expect(decision.nextRequest.url.search).toBe('?q=x%26y'); + } + }); + + test('a bracketed IPv6 host and explicit port survive resolution (REDIR-13)', () => { + const decision = decide( + aResponse(302, 'https://[2001:db8::1]:8443/x'), + contextFor(aRequest()), + redirectSettings(), + ); + expect(decision.kind).toBe('follow'); + if (decision.kind === 'follow') { + expect(decision.nextRequest.url.hostname).toBe('[2001:db8::1]'); + expect(decision.nextRequest.url.port).toBe('8443'); + } + }); +}); + +describe('Location resolution -- the unfollowed paths', () => { + test('a missing Location returns-current (REDIR-19)', () => { + expect( + decide(aResponse(302), contextFor(aRequest()), redirectSettings()), + ).toEqual({ + kind: 'return-current', + }); + }); + + test('an empty Location returns-current (REDIR-19)', () => { + expect( + decide(aResponse(302, ''), contextFor(aRequest()), redirectSettings()), + ).toEqual({ + kind: 'return-current', + }); + }); + + test('an unparseable absolute Location returns-current rather than throwing (REDIR-18)', () => { + // A malformed ABSOLUTE form is the narrow case `new URL(raw, base)` actually throws on. + expect( + decide( + aResponse(302, 'http://['), + contextFor(aRequest()), + redirectSettings(), + ), + ).toEqual({ + kind: 'return-current', + }); + }); + + test('an unsupported scheme is returned unfollowed, never dispatched (REDIR-18)', () => { + for (const raw of [ + 'javascript:alert(1)', + 'data:text/html,x', + 'file:///etc/passwd', + 'mailto:a@b.c', + ]) { + expect( + decide(aResponse(302, raw), contextFor(aRequest()), redirectSettings()), + ).toEqual({ + kind: 'return-current', + }); + } + }); +}); + +describe('Location resolution -- totality and configuration', () => { + test('garbage that parses as a RELATIVE reference is followed, percent-encoded (REDIR-14)', () => { + // Documents WHATWG `URL` behavior deliberately: with a base supplied, a non-URL string is a + // relative reference, not a parse failure. The server said to go there, so we go there. + const decision = decide( + aResponse(302, ' not a url'), + contextFor(aRequest()), + redirectSettings(), + ); + expect(decision.kind).toBe('follow'); + if (decision.kind === 'follow') { + expect(decision.nextRequest.url.href).toBe( + 'https://example.com/not%20a%20url', + ); + } + }); + + test('the location header is configurable (REDIR-27)', () => { + const headers = Headers.newBuilder() + .setInbound('X-Redirect-To', 'https://example.com/b') + .build(); + const response = aResponse(302, undefined, headers); + const decision = decide( + response, + contextFor(aRequest()), + redirectSettings({locationHeader: 'X-Redirect-To'}), + ); + expect(decision.kind).toBe('follow'); + if (decision.kind === 'follow') { + expect(decision.nextRequest.url.href).toBe('https://example.com/b'); + } + }); + + test('property: decide() never throws for arbitrary garbage in Location (REDIR-18)', () => { + fc.assert( + fc.property(fc.string(), raw => { + expect(() => + decide( + aResponse(302, inboundSafe(raw)), + contextFor(aRequest()), + redirectSettings(), + ), + ).not.toThrow(); + }), + ); + }); +}); + +describe('loop detection', () => { + test('a Location matching an already-visited URI returns-current (REDIR-16)', () => { + const context = contextFor(aRequest({url: 'https://example.com/a'}), { + visited: new Set(['https://example.com/a', 'https://example.com/b']), + }); + expect( + decide( + aResponse(302, 'https://example.com/b'), + context, + redirectSettings(), + ), + ).toEqual({ + kind: 'return-current', + }); + }); + + test('a self-referencing Location returns-current (REDIR-16)', () => { + const context = contextFor(aRequest({url: 'https://example.com/a'})); + expect( + decide( + aResponse(302, 'https://example.com/a'), + context, + redirectSettings(), + ), + ).toEqual({ + kind: 'return-current', + }); + }); +}); + +describe('hop cap', () => { + test('following would exceed maxHops -> return-current (REDIR-17)', () => { + const context = contextFor(aRequest(), {redirectsFollowed: 3}); + const decision = decide( + aResponse(302, 'https://example.com/b'), + context, + redirectSettings({maxHops: 3}), + ); + expect(decision).toEqual({kind: 'return-current'}); + }); + + test('maxHops: 0 fails on the very first follow attempt (REDIR-17)', () => { + const decision = decide( + aResponse(302, 'https://example.com/b'), + contextFor(aRequest()), + redirectSettings({maxHops: 0}), + ); + expect(decision).toEqual({kind: 'return-current'}); + }); + + test('property: the hop cap bounds every synthetic chain regardless of length', () => { + fc.assert( + fc.property( + fc.integer({min: 0, max: 50}), + fc.integer({min: 1, max: 10}), + (followed, maxHops) => { + const context = contextFor(aRequest(), {redirectsFollowed: followed}); + const decision = decide( + aResponse(302, 'https://example.com/never-visited-before'), + context, + redirectSettings({maxHops}), + ); + if (followed + 1 > maxHops) { + expect(decision).toEqual({kind: 'return-current'}); + } else { + expect(decision.kind).toBe('follow'); + } + }, + ), + ); + }); +}); + +describe('scheme-downgrade guard', () => { + test('HTTPS to HTTP is rejected by default (REDIR-15)', () => { + const decision = decide( + aResponse(302, 'http://example.com/b'), + contextFor(aRequest({url: 'https://example.com/a'})), + redirectSettings(), + ); + expect(decision.kind).toBe('fail'); + if (decision.kind === 'fail') { + expect(decision.error).toBeInstanceOf(SchemeDowngradeError); + } + }); + + test('HTTPS to HTTP is permitted when allowSchemeDowngrade is set (REDIR-15)', () => { + const decision = decide( + aResponse(302, 'http://example.com/b'), + contextFor(aRequest({url: 'https://example.com/a'})), + redirectSettings({allowSchemeDowngrade: true}), + ); + expect(decision.kind).toBe('follow'); + }); + + test('credential stripping still applies on a permitted downgrade (REDIR-15)', () => { + const headers = Headers.newBuilder() + .add('Authorization', 'Bearer x') + .build(); + const decision = decide( + aResponse(302, 'http://example.com/b'), + contextFor(aRequest({url: 'https://example.com/a', headers})), + redirectSettings({allowSchemeDowngrade: true}), + ); + expect(decision.kind).toBe('follow'); + if (decision.kind === 'follow') { + expect(decision.nextRequest.headers.get('Authorization')).toBeUndefined(); + } + }); + + test('HTTP to HTTPS is never a downgrade', () => { + const decision = decide( + aResponse(302, 'https://example.com/b'), + contextFor(aRequest({url: 'http://example.com/a'})), + redirectSettings(), + ); + expect(decision.kind).toBe('follow'); + }); + + test('the guard is keyed to the CURRENT hop scheme, not the seed (REDIR-15)', () => { + // Seed is http, the current hop is already https (a prior upgrade) -- a further downgrade off THIS + // hop must still be caught even though the seed itself was http. + const context: RedirectContext = { + currentRequest: aRequest({url: 'https://example.com/mid'}), + seedOrigin: originOf(new URL('http://example.com/a')), + visited: new Set(['http://example.com/a', 'https://example.com/mid']), + redirectsFollowed: 1, + }; + const decision = decide( + aResponse(302, 'http://example.com/b'), + context, + redirectSettings(), + ); + expect(decision.kind).toBe('fail'); + }); +}); + +describe('body replayability gate', () => { + test('a method-preserving redirect with a non-replayable body fails (REDIR-6)', () => { + const request = Request.newBuilder() + .method('POST') + .url('https://example.com/a') + .body(oneShotBody()) + .build(); + const decision = decide( + aResponse(307, 'https://example.com/b'), + contextFor(request), + redirectSettings({ + allowedMethods: new Set(['GET', 'HEAD', 'POST']), + }), + ); + expect(decision.kind).toBe('fail'); + if (decision.kind === 'fail') { + expect(decision.error).toBeInstanceOf(NonReplayableBodyError); + } + }); + + test('a method-preserving redirect with a replayable body follows, body preserved (REDIR-6)', () => { + const request = Request.newBuilder() + .method('POST') + .url('https://example.com/a') + .body(stringBody('x')) + .build(); + const decision = decide( + aResponse(307, 'https://example.com/b'), + contextFor(request), + redirectSettings({ + allowedMethods: new Set(['GET', 'HEAD', 'POST']), + }), + ); + expect(decision.kind).toBe('follow'); + if (decision.kind === 'follow') { + expect(decision.nextRequest.body).toBeDefined(); + } + }); + + test('303 is exempt -- its body is dropped, not checked (REDIR-5/REDIR-6)', () => { + const request = Request.newBuilder() + .method('POST') + .url('https://example.com/a') + .body(oneShotBody()) + .build(); + const decision = decide( + aResponse(303, 'https://example.com/b'), + contextFor(request), + redirectSettings({allow303: true}), + ); + expect(decision.kind).toBe('follow'); + if (decision.kind === 'follow') { + expect(decision.nextRequest.body).toBeUndefined(); + } + }); +}); + +describe('header construction', () => { + test('Authorization is stripped unconditionally, even same-origin (REDIR-7)', () => { + const headers = Headers.newBuilder() + .add('Authorization', 'Bearer x') + .build(); + const request = aRequest({url: 'https://example.com/a', headers}); + const decision = decide( + aResponse(302, 'https://example.com/b'), + contextFor(request), + redirectSettings(), + ); + expect(decision.kind).toBe('follow'); + if (decision.kind === 'follow') { + expect(decision.nextRequest.headers.get('Authorization')).toBeUndefined(); + } + }); + + test('Cookie and Proxy-Authorization survive a same-origin hop (REDIR-10)', () => { + const headers = Headers.newBuilder() + .add('Cookie', 'a=b') + .add('Proxy-Authorization', 'y') + .build(); + const request = aRequest({url: 'https://example.com/a', headers}); + const decision = decide( + aResponse(302, 'https://example.com/b'), + contextFor(request), + redirectSettings(), + ); + expect(decision.kind).toBe('follow'); + if (decision.kind === 'follow') { + expect(decision.nextRequest.headers.get('Cookie')).toBe('a=b'); + expect(decision.nextRequest.headers.get('Proxy-Authorization')).toBe('y'); + } + }); + + test('Cookie and Proxy-Authorization are stripped on a cross-origin hop (REDIR-9)', () => { + const headers = Headers.newBuilder() + .add('Cookie', 'a=b') + .add('Proxy-Authorization', 'y') + .build(); + const request = aRequest({url: 'https://example.com/a', headers}); + const decision = decide( + aResponse(302, 'https://evil.example/b'), + contextFor(request), + redirectSettings(), + ); + expect(decision.kind).toBe('follow'); + if (decision.kind === 'follow') { + expect(decision.nextRequest.headers.get('Cookie')).toBeUndefined(); + expect( + decision.nextRequest.headers.get('Proxy-Authorization'), + ).toBeUndefined(); + } + }); +}); + +describe('header construction -- the cross-origin marker', () => { + test('the cross-origin marker is set only on a cross-origin follow (REDIR-11)', () => { + const sameOrigin = decide( + aResponse(302, 'https://example.com/b'), + contextFor(aRequest()), + redirectSettings(), + ); + const crossOrigin = decide( + aResponse(302, 'https://evil.example/b'), + contextFor(aRequest()), + redirectSettings(), + ); + expect(sameOrigin.kind === 'follow' && sameOrigin.crossOrigin).toBe(false); + expect(crossOrigin.kind === 'follow' && crossOrigin.crossOrigin).toBe(true); + }); + + test('a forged inbound marker never survives a same-origin hop (REDIR-11a)', () => { + const headers = Headers.newBuilder() + .add('x-dexpace-internal-redirect-cross-origin', '1') + .build(); + const request = aRequest({url: 'https://example.com/a', headers}); + const decision = decide( + aResponse(302, 'https://example.com/b'), + contextFor(request), + redirectSettings(), + ); + expect(decision.kind).toBe('follow'); + if (decision.kind === 'follow') { + expect( + decision.nextRequest.headers.has( + 'x-dexpace-internal-redirect-cross-origin', + ), + ).toBe(false); + } + }); +}); + +describe('header construction -- the 303 rebuild and method preservation', () => { + test('a 303 rebuild strips every Content-* header case-insensitively and forces GET (REDIR-5)', () => { + const headers = Headers.newBuilder() + .add('content-type', 'application/json') + .add('Content-Length', '3') + .add('CONTENT-ENCODING', 'gzip') + .add('X-Other', 'kept') + .build(); + const request = aRequest({ + method: 'POST', + url: 'https://example.com/a', + headers, + }); + const decision = decide( + aResponse(303, 'https://example.com/b'), + contextFor(request), + redirectSettings({allow303: true}), + ); + expect(decision.kind).toBe('follow'); + if (decision.kind === 'follow') { + expect(decision.nextRequest.method).toBe('GET'); + expect(decision.nextRequest.headers.get('Content-Type')).toBeUndefined(); + expect( + decision.nextRequest.headers.get('Content-Length'), + ).toBeUndefined(); + expect( + decision.nextRequest.headers.get('Content-Encoding'), + ).toBeUndefined(); + expect(decision.nextRequest.headers.get('X-Other')).toBe('kept'); + } + }); + + test('a 301/302/307/308 follow preserves the original method (REDIR-3/REDIR-4)', () => { + const decision = decide( + aResponse(307, 'https://example.com/b'), + contextFor( + aRequest({ + method: 'POST', + url: 'https://example.com/a', + body: stringBody('x'), + }), + ), + redirectSettings({ + allowedMethods: new Set(['GET', 'HEAD', 'POST']), + }), + ); + expect(decision.kind).toBe('follow'); + if (decision.kind === 'follow') { + expect(decision.nextRequest.method).toBe('POST'); + } + }); +}); + +describe('loop detection survives URL normalization', () => { + // `visited` keys on `URL.href`, which WHATWG normalizes -- so a server cannot spin the loop past the + // cap by varying only the case of the scheme/host or by writing the scheme's default port out. Both + // resolve to a href already in the set. Worth pinning: if `visited` ever keyed on the raw Location + // string instead, both of these would silently become followable and the guard would be evadable. + test('an uppercase scheme and host still hit the visited set (REDIR-16)', () => { + const request = aRequest({url: 'https://example.com/a'}); + expect( + decide( + aResponse(302, 'HTTPS://EXAMPLE.COM/a'), + contextFor(request), + redirectSettings(), + ), + ).toEqual({kind: 'return-current'}); + }); + + test("the scheme's default port written explicitly still hits the visited set (REDIR-16)", () => { + const request = aRequest({url: 'https://example.com/a'}); + expect( + decide( + aResponse(302, 'https://example.com:443/a'), + contextFor(request), + redirectSettings(), + ), + ).toEqual({kind: 'return-current'}); + }); +}); + +describe('Location forms RFC 3986 resolution has to get right', () => { + test('a protocol-relative Location inherits the scheme and is judged cross-origin (REDIR-14)', () => { + const decision = decide( + aResponse(302, '//other.example/x'), + contextFor(aRequest({url: 'https://example.com/a'})), + redirectSettings(), + ); + expect(decision.kind).toBe('follow'); + if (decision.kind === 'follow') { + expect(decision.nextRequest.url.href).toBe('https://other.example/x'); + expect(decision.crossOrigin).toBe(true); + } + }); + + test('a query-only Location keeps the path and does not re-encode (REDIR-13/REDIR-14)', () => { + const decision = decide( + aResponse(302, '?q=a%26b'), + contextFor(aRequest({url: 'https://example.com/a/b'})), + redirectSettings(), + ); + expect(decision.kind).toBe('follow'); + if (decision.kind === 'follow') { + expect(decision.nextRequest.url.href).toBe( + 'https://example.com/a/b?q=a%26b', + ); + } + }); + + test('dot segments resolve against the current hop (REDIR-14)', () => { + const cases: readonly (readonly [string, string])[] = [ + ['.', 'https://example.com/a/b/'], + ['..', 'https://example.com/a/'], + ['../../x', 'https://example.com/x'], + ]; + for (const [location, expected] of cases) { + const decision = decide( + aResponse(302, location), + contextFor(aRequest({url: 'https://example.com/a/b/c'})), + redirectSettings(), + ); + expect(decision.kind).toBe('follow'); + if (decision.kind === 'follow') { + expect(decision.nextRequest.url.href).toBe(expected); + } + } + }); +}); + +describe('credential and marker hygiene against multi-valued headers', () => { + test('every Authorization value is stripped, whatever its casing (REDIR-7)', () => { + const headers = Headers.newBuilder() + .add('authorization', 'Bearer x') + .add('AUTHORIZATION', 'Bearer y') + .build(); + const decision = decide( + aResponse(302, 'https://example.com/b'), + contextFor(aRequest({url: 'https://example.com/a', headers})), + redirectSettings(), + ); + expect(decision.kind).toBe('follow'); + if (decision.kind === 'follow') { + expect(decision.nextRequest.headers.getAll('Authorization')).toEqual([]); + } + }); + + test('a multi-valued forged marker collapses to exactly one own value (REDIR-11a)', () => { + // Clearing must precede the conditional set. If it did not, a server that got two marker values + // onto the request would leave the SDK appending a third rather than replacing both. + const headers = Headers.newBuilder() + .add(CROSS_ORIGIN_MARKER_HEADER, 'forged') + .add(CROSS_ORIGIN_MARKER_HEADER, 'twice') + .build(); + const decision = decide( + aResponse(302, 'https://evil.example/b'), + contextFor(aRequest({url: 'https://example.com/a', headers})), + redirectSettings(), + ); + expect(decision.kind).toBe('follow'); + if (decision.kind === 'follow') { + expect( + decision.nextRequest.headers.getAll(CROSS_ORIGIN_MARKER_HEADER), + ).toEqual(['1']); + } + }); + + test('the 303 rebuild clears an inbound marker too (REDIR-11a)', () => { + const headers = Headers.newBuilder() + .add(CROSS_ORIGIN_MARKER_HEADER, 'forged') + .add('Content-Type', 'application/json') + .build(); + const decision = decide( + aResponse(303, 'https://example.com/b'), + contextFor( + aRequest({method: 'POST', url: 'https://example.com/a', headers}), + ), + redirectSettings({allow303: true}), + ); + expect(decision.kind).toBe('follow'); + if (decision.kind === 'follow') { + expect(decision.nextRequest.headers.has(CROSS_ORIGIN_MARKER_HEADER)).toBe( + false, + ); + expect(decision.nextRequest.headers.has('Content-Type')).toBe(false); + } + }); +}); + +describe('the followed request carries the body instance itself', () => { + test('a replayable body is re-sent, not rebuilt (REDIR-3/REDIR-4/REDIR-6)', () => { + // The rewind is 3b's `writeTo` contract (BODY-9), not this step's -- so the step must hand the + // SAME body across, never a copy that would have its own materialize-once state. + const body = stringBody('x'); + const request = Request.newBuilder() + .method('POST') + .url('https://example.com/a') + .body(body) + .build(); + const decision = decide( + aResponse(307, 'https://example.com/b'), + contextFor(request), + redirectSettings({ + allowedMethods: new Set(['GET', 'HEAD', 'POST']), + }), + ); + expect(decision.kind).toBe('follow'); + if (decision.kind === 'follow') + expect(decision.nextRequest.body).toBe(body); + }); +}); diff --git a/packages/core/src/redirect/decide.ts b/packages/core/src/redirect/decide.ts new file mode 100644 index 0000000..8801a27 --- /dev/null +++ b/packages/core/src/redirect/decide.ts @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/redirect/decide.ts +import type {Headers} from '../http/headers.js'; +import type {Method} from '../http/method.js'; +import type {Request} from '../http/request.js'; +import type {Response} from '../http/response.js'; +import {isEligibleByCode, isRecognizedRedirect} from './codes.js'; +import { + clearCrossOriginMarker, + isCrossOrigin, + withCrossOriginMarker, + type Origin, +} from './cross-origin.js'; +import {NonReplayableBodyError, SchemeDowngradeError} from './errors.js'; +import type {RedirectCondition, RedirectSettings} from './settings.js'; + +/** + * Everything one hop's decision reads, bundled: `decide()` would otherwise take five positional + * parameters against the codebase's `max-params: 3`. + * + * `seedOrigin` is the ORIGINAL request's origin and never advances with the chain (REDIR-8); `visited` + * is the step's live cycle-detection set, seeded with the seed request's URI (REDIR-16). + * + * @internal + */ +export interface RedirectContext { + readonly currentRequest: Request; + readonly seedOrigin: Origin; + readonly visited: ReadonlySet; + readonly redirectsFollowed: number; +} + +/** + * One hop's outcome. `'return-current'` hands the live response back to the caller unclosed (REDIR-16, + * REDIR-17, REDIR-18, REDIR-19, PIPE-40); `'fail'` is the caller's to close before rethrowing (REDIR-22b). + * + * @internal + */ +export type Decision = + | { + readonly kind: 'follow'; + readonly nextRequest: Request; + readonly crossOrigin: boolean; + } + | {readonly kind: 'return-current'} + | {readonly kind: 'fail'; readonly error: Error}; + +// Frozen because it is SHARED: one instance is handed to every caller on every no-follow path, so an +// accidental write would corrupt every later decision in the process. `outcome.ts`'s `success`/`failure` +// build a fresh object per call and have no equivalent exposure. +const RETURN_CURRENT: Decision = Object.freeze({kind: 'return-current'}); + +/** + * The only schemes this SDK will re-issue a request against. Anything else -- `javascript:`, `data:`, + * `file:`, `mailto:` -- is REDIR-18's "unsupported scheme", returned unfollowed rather than dispatched. + */ +const FOLLOWABLE_SCHEMES: ReadonlySet = new Set(['http:', 'https:']); + +/** + * REDIR-14/REDIR-12/REDIR-13: resolves relative-or-absolute per RFC 3986 via WHATWG `URL`, drops + * userinfo, and never re-encodes an already-percent-encoded path/query/fragment. Total -- REDIR-18 says + * a malformed or unresolvable Location MUST NOT throw. + * + * Two things WHATWG `URL` does NOT do for us, both handled explicitly here: + * + * 1. **It almost never throws when a base is supplied.** `new URL(' not a url', 'https://example.com/a')` + * does not fail -- it resolves to `https://example.com/not%20a%20url`, because any string that is not + * a valid absolute URL is treated as a relative reference. So the `catch` below is a genuine but + * NARROW path (a malformed absolute form such as `http://[` still throws); it is not the general + * "garbage in the Location header" guard it might look like. Garbage that parses as a relative + * reference is followed, which is correct per RFC 3986 -- the server said so. + * 2. **It happily parses schemes we must never dispatch against.** `new URL('javascript:alert(1)', base)` + * succeeds, and the scheme-downgrade guard would wave it through (the target is not `http:`). The + * {@link FOLLOWABLE_SCHEMES} check is what makes REDIR-18's unsupported-scheme clause true rather + * than aspirational. + */ +function resolveLocation(raw: string | undefined, base: URL): URL | null { + if (raw === undefined || raw.trim() === '') return null; // REDIR-19: missing or empty. + try { + const resolved = new URL(raw, base); + if (!FOLLOWABLE_SCHEMES.has(resolved.protocol.toLowerCase())) return null; + // REDIR-12: assigning the empty string clears the component without touching path/query/fragment. + resolved.username = ''; + resolved.password = ''; + return resolved; + } catch { + return null; + } +} + +/** REDIR-5: the 303 GET rebuild drops every `Content-*` request header, matched case-insensitively. */ +function stripContentHeaders(headers: Headers): Headers { + let builder = headers.newBuilder(); + for (const name of headers.names()) { + if (name.toLowerCase().startsWith('content-')) + builder = builder.set(name, null); + } + return builder.build(); +} + +/** + * REDIR-7: `Authorization` is stripped on EVERY re-issue, same-origin and the 303 rebuild included. + * REDIR-9/REDIR-10: `Cookie` and `Proxy-Authorization` are origin-scoped, so they survive a same-origin + * hop and are stripped cross-origin. REDIR-11(a): the marker is cleared before it is conditionally set, + * so a forged or stale inbound copy can never survive a hop that should not carry it. + */ +function nextHopHeaders(headers: Headers, crossOrigin: boolean): Headers { + let builder = headers.newBuilder().set('Authorization', null); + if (crossOrigin) { + builder = builder.set('Cookie', null).set('Proxy-Authorization', null); + } + const cleared = clearCrossOriginMarker(builder.build()); + return crossOrigin ? withCrossOriginMarker(cleared) : cleared; +} + +interface FollowPlan { + readonly target: URL; + readonly status: number; + readonly crossOrigin: boolean; +} + +/** REDIR-3/REDIR-4 preserve method and body; REDIR-5 forces GET and drops the body. */ +function buildFollowRequest(current: Request, plan: FollowPlan): Request { + const {target, status, crossOrigin} = plan; + const is303 = status === 303; + const method: Method = is303 ? 'GET' : current.method; + let headers = nextHopHeaders(current.headers, crossOrigin); + if (is303) headers = stripContentHeaders(headers); + const builder = current + .newBuilder() + .url(target) + .method(method) + .headers(headers); + return is303 ? builder.body(undefined).build() : builder.build(); +} + +/** + * The per-hop redirect decision. Pure -- no I/O, no clock, no header-mutation side effects beyond the + * `nextRequest` value it returns -- mirroring 5a's split of `classify.ts`/`backoff.ts` away from the + * imperative loop. + * + * Step order: + * + * 1. **Fast path** (REDIR-1/REDIR-21): a status outside the recognized set short-circuits BEFORE + * allocating a condition snapshot and never consults a configured predicate. + * 2. **Snapshot and the follow/no-follow call** (REDIR-20/REDIR-21): any recognized 3xx allocates the + * snapshot and is offered to a configured predicate, EVEN with no usable Location. A configured + * predicate's boolean return IS the decision, replacing `isEligibleByCode`. + * 3. **Location resolution** (REDIR-12/13/14/18/19), including the followable-scheme gate. + * 4. **Loop detection** (REDIR-16). + * 5. **Hop cap** (REDIR-17) -- the one gate `maxHops: 0` always fails, which is what "disables redirect + * following entirely" reduces to; no separate branch. + * 6. **Scheme-downgrade guard** (REDIR-15), keyed to the CURRENT hop's scheme, not the seed's. This is a + * deliberately different reference point from step 8's seed-relative cross-origin check: downgrade + * catches a single transition wherever it happens, while cross-origin must stay anchored to the + * origin the credential was attached at, for the whole chain. + * 7. **Body-replayability gate** (REDIR-6); 303 is exempt because it drops the body. + * 8. **Cross-origin determination** (REDIR-8) and header construction for the next hop. + * + * **Scope of the predicate override.** REDIR-20's "MUST fully override the built-in decision" is read + * here as scoped to the code/method eligibility question only -- not as license to bypass steps 4-7's + * wire-safety invariants, which the same spec document states as unconditional MUSTs elsewhere. A + * caller predicate opting to follow a 307 with a non-replayable body still cannot make that body + * re-sendable. If this reading is wrong the fix is narrow and mechanical: gate step 3 onward behind the + * predicate's answer. Recorded in the design doc's Deviation Ledger. + * + * @param response - the hop's response. + * @param context - the current request, the seed origin, the live visited set, and the hop count. + * @param settings - the validated redirect policy. + * @returns whether to follow, return the current response, or fail. + * + * @internal + */ +export function decide( + response: Response, + context: RedirectContext, + settings: RedirectSettings, +): Decision { + if (!isRecognizedRedirect(response.status.code)) return RETURN_CURRENT; + + const {currentRequest, seedOrigin, visited, redirectsFollowed} = context; + // REDIR-20: the snapshot is defensively COPIED, not merely typed `ReadonlySet`. `visited` is the + // step's LIVE cycle-detection set, and the type annotation is erased at runtime -- a predicate that + // casts it away could otherwise pre-seed or clear loop detection for the rest of the call. The spec's + // wording is about the object, not the type. + const condition: RedirectCondition = { + response, + redirectsFollowed, + visited: new Set(visited), + }; + const eligible = + settings.predicate === undefined + ? isEligibleByCode(response.status.code, currentRequest.method, settings) + : settings.predicate(condition); + if (!eligible) return RETURN_CURRENT; + + // `Request.url` hands back a FRESH `URL` on every access (HTTP-5) -- read it once. + const currentUrl = currentRequest.url; + const target = resolveLocation( + response.headers.get(settings.locationHeader), + currentUrl, + ); + if (target === null) return RETURN_CURRENT; + if (visited.has(target.href)) return RETURN_CURRENT; + if (redirectsFollowed + 1 > settings.maxHops) return RETURN_CURRENT; + + if ( + currentUrl.protocol.toLowerCase() === 'https:' && + target.protocol.toLowerCase() === 'http:' && + !settings.allowSchemeDowngrade + ) { + return { + kind: 'fail', + error: new SchemeDowngradeError(currentUrl.href, target.href), + }; + } + + const status = response.status.code; + const body = currentRequest.body; + if (status !== 303 && body !== undefined && !body.replayable) { + return {kind: 'fail', error: new NonReplayableBodyError(target.href)}; + } + + const crossOrigin = isCrossOrigin(seedOrigin, target); + return { + kind: 'follow', + nextRequest: buildFollowRequest(currentRequest, { + target, + status, + crossOrigin, + }), + crossOrigin, + }; +} diff --git a/packages/core/src/redirect/errors.test.ts b/packages/core/src/redirect/errors.test.ts new file mode 100644 index 0000000..39a0c7f --- /dev/null +++ b/packages/core/src/redirect/errors.test.ts @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/redirect/errors.test.ts +// Exercises: REDIR-6 (a non-replayable body fails with a clear error NAMING replayability, rather than +// corrupting or truncating the re-send), REDIR-15 (an HTTPS->HTTP hop is rejected with a clear error by +// default). Both are operational failures a caller can legitimately hit mid-redirect, so both are typed +// error leaves rather than `invariant()` programmer-error assertions. +import {describe, expect, test} from 'bun:test'; +import {DexpaceError} from '../http/errors.js'; +import {NonReplayableBodyError, SchemeDowngradeError} from './errors.js'; + +describe('NonReplayableBodyError', () => { + test('names the target URL and mentions replayability', () => { + const error = new NonReplayableBodyError('https://example.com/next'); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(DexpaceError); + expect(error.name).toBe('NonReplayableBodyError'); + expect(error.message).toContain('https://example.com/next'); + expect(error.message.toLowerCase()).toContain('replayable'); + }); + + test('carries the target as a readonly field, not only in the message', () => { + // docs/knowledge/error-handling.md: identifying inputs are fields so they survive serialization + // and reach a structured log without anyone parsing the message back apart. + const error = new NonReplayableBodyError('https://example.com/next'); + expect(error.targetUrl).toBe('https://example.com/next'); + }); + + test('accepts a cause', () => { + const cause = new Error('underlying'); + expect( + new NonReplayableBodyError('https://example.com/next', {cause}).cause, + ).toBe(cause); + }); +}); + +describe('SchemeDowngradeError', () => { + test('names both the current and target URLs', () => { + const error = new SchemeDowngradeError( + 'https://example.com/a', + 'http://example.com/b', + ); + expect(error).toBeInstanceOf(DexpaceError); + expect(error.name).toBe('SchemeDowngradeError'); + expect(error.message).toContain('https://example.com/a'); + expect(error.message).toContain('http://example.com/b'); + }); + + test('carries both URLs as readonly fields, not only in the message', () => { + const error = new SchemeDowngradeError( + 'https://example.com/a', + 'http://example.com/b', + ); + expect(error.fromUrl).toBe('https://example.com/a'); + expect(error.toUrl).toBe('http://example.com/b'); + }); + + test('accepts a cause', () => { + const cause = new Error('underlying'); + const error = new SchemeDowngradeError('https://a', 'http://b', {cause}); + expect(error.cause).toBe(cause); + }); +}); diff --git a/packages/core/src/redirect/errors.ts b/packages/core/src/redirect/errors.ts new file mode 100644 index 0000000..f7d0e4f --- /dev/null +++ b/packages/core/src/redirect/errors.ts @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/redirect/errors.ts +import {DexpaceError} from '../http/errors.js'; + +/** + * REDIR-6: a method-preserving redirect (301/302/307/308) re-sends the original body, so the body must + * be replayable. Distinct from 3b's `ConsumedBodyError`, which fires on a SECOND write against an + * already-consumed single-use body -- this one is a fail-fast gate evaluated BEFORE any write is + * attempted, and names replayability specifically as the requirement demands. + * + * @internal + */ +export class NonReplayableBodyError extends DexpaceError { + /** + * The redirect target that would have received the re-send. + * + * Carried as a field, not only interpolated into the message, per + * `docs/knowledge/error-handling.md` -- so it survives serialization and reaches a structured log + * without anyone parsing the message back apart. Phase 7b's rejection event reads it directly. + */ + readonly targetUrl: string; + + /** + * @param targetUrl - the redirect target that would have received the re-send. + * @param options - standard error options; pass `{cause}` when wrapping a caught error. + */ + constructor(targetUrl: string, options?: ErrorOptions) { + super( + `cannot follow redirect to '${targetUrl}': request body is not replayable`, + options, + ); + this.targetUrl = targetUrl; + } +} + +/** + * REDIR-15: an HTTPS-to-HTTP hop, rejected unless `RedirectSettings.allowSchemeDowngrade` is set. + * Evaluated per hop transition, so an HTTPS-to-HTTP-to-HTTPS chain flags only the hop that downgraded. + * + * @internal + */ +export class SchemeDowngradeError extends DexpaceError { + /** The current hop's request URL -- the HTTPS side of the rejected transition. */ + readonly fromUrl: string; + /** The resolved redirect target -- the HTTP side of the rejected transition. */ + readonly toUrl: string; + + /** + * @param fromUrl - the current hop's request URL. + * @param toUrl - the resolved redirect target. + * @param options - standard error options; pass `{cause}` when wrapping a caught error. + */ + constructor(fromUrl: string, toUrl: string, options?: ErrorOptions) { + super( + `redirect from '${fromUrl}' to '${toUrl}' would downgrade HTTPS to HTTP`, + options, + ); + this.fromUrl = fromUrl; + this.toUrl = toUrl; + } +} diff --git a/packages/core/src/redirect/redirect-step.test.ts b/packages/core/src/redirect/redirect-step.test.ts new file mode 100644 index 0000000..79d50e1 --- /dev/null +++ b/packages/core/src/redirect/redirect-step.test.ts @@ -0,0 +1,398 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/redirect/redirect-step.test.ts +// Exercises: PIPE-36 (the stage is baked into the descriptor, not subclassable), PIPE-15 (every dispatch +// takes a FRESH ctx.fork() continuation -- ctx.next()'s single-invocation guard would trip on hop two), +// PIPE-40/REDIR-22 (the 2-hop conformance clause: wire-send count, per-hop close of each superseded +// response, the final response left OPEN for the caller), REDIR-22(b) (a throw out of the decision -- +// including from caller predicate code -- closes the current response before propagating), REDIR-16 +// (a detected loop returns the loop response open, without throwing), REDIR-15 (a rejected downgrade +// closes the current response and propagates SchemeDowngradeError), and the cancellation check (an +// already-aborted signal returns the current response open rather than issuing a further hop). +import {describe, expect, test} from 'bun:test'; +import { + createRequestContext, + type ExecutionContext, +} from '../context/context.js'; +import {streamBody} from '../body/stream-body.js'; +import {Headers} from '../http/headers.js'; +import type {Method} from '../http/method.js'; +import {Protocol} from '../http/protocol.js'; +import {Request} from '../http/request.js'; +import {Response} from '../http/response.js'; +import {Status} from '../http/status.js'; +import {Cursor} from '../pipeline/cursor.js'; +import type {StepDescriptor} from '../pipeline/step.js'; +import type {SuppressedErrorLike} from '../suppress.js'; +import {FakeTransport, countingResponse} from '../testing/fake-transport.js'; +import {NonReplayableBodyError, SchemeDowngradeError} from './errors.js'; +import {REDIRECT_STEP_TYPE, redirectStep} from './redirect-step.js'; + +const SEED = Request.newBuilder().url('https://example.com/start').build(); +const CANCEL_FAILURE = new Error('cancel exploded'); + +// Constructed inline rather than imported: 4c keeps `aRequestContext()` file-local to `cursor.test.ts`, +// and importing across `*.test.ts` files is not acceptable -- the same call 5a's `retry-step.test.ts` made. +function aRequestContext(request: Request = SEED): ExecutionContext { + return createRequestContext(request); +} + +function runThrough( + descriptor: StepDescriptor, + transport: FakeTransport, + signal?: AbortSignal, +): Promise { + return new Cursor({ + steps: [descriptor], + transport, + request: SEED, + context: aRequestContext(), + signal, + }).advance(); +} + +/** + * Captures a rejection reason. `expect(...).rejects` is typed as returning `void` under this runner's + * type definitions, so awaiting it trips `@typescript-eslint/await-thenable`; this helper keeps the + * assertion honest without a lint suppression. Same shape 5a's `retry-step.test.ts` settled on. + */ +async function rejectionOf(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error; + } + return undefined; +} + +// `FakeTransport` does not itself set a Location -- `decide()` reads it off `Response.headers`, so a +// scripted 3xx entry must carry one explicitly. `ResponseBuilder` carries the SAME body instance through +// `response.newBuilder()`, so the rebuilt response still reports through `countingResponse`'s counter. +// `setInbound`, not `set`: a Location is an inbound (response) header (HTTP-19). +function withLocation(response: Response, location: string): Response { + return response + .newBuilder() + .headers( + response.headers.newBuilder().setInbound('Location', location).build(), + ) + .build(); +} + +/** + * A response whose body `cancel()` REJECTS with a non-`TypeError` -- the one thing `Response.close()` + * is documented to rethrow. Models a transport releasing over an already-broken socket. + */ +function hostileResponse(status: number, location: string): Response { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.from([1])); + }, + cancel() { + throw CANCEL_FAILURE; + }, + }); + return Response.newBuilder() + .request(SEED) + .protocol(Protocol.HTTP_1_1) + .status(Status.of(status)) + .headers(Headers.newBuilder().setInbound('Location', location).build()) + .body(body) + .build(); +} + +describe('redirectStep', () => { + test('is pinned to the REDIRECT pillar stage (PIPE-36)', () => { + const descriptor = redirectStep(); + expect(descriptor.stage).toBe('REDIRECT'); + expect(descriptor.type).toBe(REDIRECT_STEP_TYPE); + }); + + test('closes PIPE-40: two chained 301s then a 200', async () => { + const first = countingResponse(301); + const second = countingResponse(301); + const third = countingResponse(200); + const hop1 = withLocation(first.response, 'https://example.com/mid'); + const hop2 = withLocation(second.response, '/final'); // relative, resolved against /mid + const transport = new FakeTransport([hop1, hop2, third.response]); + + const response = await runThrough(redirectStep(), transport); + + expect(transport.sendCount).toBe(3); + expect(first.cancelCount()).toBe(1); + expect(second.cancelCount()).toBe(1); + expect(third.cancelCount()).toBe(0); // left open for the caller + expect(response).toBe(third.response); + }); + + test('each hop is dispatched against the rewritten request (REDIR-7/REDIR-14)', async () => { + const first = countingResponse(301); + const final = countingResponse(200); + const seedWithAuth = Request.newBuilder() + .url('https://example.com/start') + .headers(Headers.newBuilder().add('Authorization', 'Bearer x').build()) + .build(); + const transport = new FakeTransport([ + withLocation(first.response, '/next'), + final.response, + ]); + + await new Cursor({ + steps: [redirectStep()], + transport, + request: seedWithAuth, + context: aRequestContext(seedWithAuth), + }).advance(); + + expect(transport.sendCount).toBe(2); + expect(transport.calls[0]?.request.headers.get('Authorization')).toBe( + 'Bearer x', + ); + expect(transport.calls[1]?.request.url.href).toBe( + 'https://example.com/next', + ); + expect( + transport.calls[1]?.request.headers.get('Authorization'), + ).toBeUndefined(); + }); + + test('a non-redirect response is returned open, untouched, on the very first hop', async () => { + const only = countingResponse(200); + const transport = new FakeTransport([only.response]); + + const response = await runThrough(redirectStep(), transport); + + expect(transport.sendCount).toBe(1); + expect(only.cancelCount()).toBe(0); + expect(response).toBe(only.response); + }); +}); + +describe('redirectStep -- termination without a throw', () => { + test('a loop is detected and the loop response returned open, not thrown (REDIR-16)', async () => { + const loopHop = countingResponse(301); + const located = withLocation(loopHop.response, 'https://example.com/start'); + const transport = new FakeTransport([located]); + + const response = await runThrough(redirectStep(), transport); + + expect(response).toBe(located); // Location === seed URI -> visited hit -> return-current, unclosed + expect(loopHop.cancelCount()).toBe(0); + expect(transport.sendCount).toBe(1); + }); + + test('the hop cap returns the last response as-is, even a 3xx, without throwing (REDIR-17)', async () => { + const hopA = countingResponse(301); + const hopB = countingResponse(301); + const hopC = countingResponse(301); + const capped = countingResponse(301); + const transport = new FakeTransport([ + withLocation(hopA.response, 'https://example.com/1'), + withLocation(hopB.response, 'https://example.com/2'), + withLocation(hopC.response, 'https://example.com/3'), + withLocation(capped.response, 'https://example.com/4'), + ]); + + const response = await runThrough(redirectStep(), transport); + + expect(transport.sendCount).toBe(4); // seed + 3 followed hops, the default cap + expect(response.status.code).toBe(301); + expect(capped.cancelCount()).toBe(0); // returned open even though it is itself a redirect + }); + + test('maxHops: 0 disables following entirely (REDIR-17)', async () => { + const only = countingResponse(301); + const located = withLocation(only.response, 'https://example.com/next'); + const transport = new FakeTransport([located]); + + const response = await runThrough(redirectStep({maxHops: 0}), transport); + + expect(transport.sendCount).toBe(1); + expect(response).toBe(located); + expect(only.cancelCount()).toBe(0); + }); +}); + +describe('redirectStep -- cancellation and the failure paths', () => { + test('an already-aborted signal returns the first hop response open, never dispatching a second', async () => { + const controller = new AbortController(); + controller.abort(); + const hop = countingResponse(301); + const located = withLocation(hop.response, 'https://example.com/next'); + const never = countingResponse(200); + const transport = new FakeTransport([located, never.response]); + + const response = await runThrough( + redirectStep(), + transport, + controller.signal, + ); + + expect(transport.sendCount).toBe(1); // the first hop always dispatches; the second never does + expect(response).toBe(located); // returned open -- the caller owns it + expect(hop.cancelCount()).toBe(0); + }); + + test('a rejected scheme downgrade closes the current response first (REDIR-15/REDIR-22b)', async () => { + const hop = countingResponse(301); + const located = withLocation(hop.response, 'http://example.com/next'); + const transport = new FakeTransport([located]); + + const error = await rejectionOf(runThrough(redirectStep(), transport)); + + expect(error).toBeInstanceOf(SchemeDowngradeError); + expect(hop.cancelCount()).toBe(1); // the hop's body is not leaked + }); + + test('a throwing predicate closes the current response before the error propagates (REDIR-22b)', async () => { + const hop = countingResponse(301); + const located = withLocation(hop.response, 'https://example.com/next'); + const transport = new FakeTransport([located]); + const boom = new Error('predicate exploded'); + const step = redirectStep({ + predicate: () => { + throw boom; + }, + }); + + const error = await rejectionOf(runThrough(step, transport)); + + expect(error).toBe(boom); // the caller's own error, not remapped to a redirect error type + expect(hop.cancelCount()).toBe(1); // decideOrClose closed it -- the hop's body is not leaked + }); + + test('a cross-origin hop carries the suppression marker to the next dispatch (REDIR-11)', async () => { + const hop = countingResponse(302); + const final = countingResponse(200); + const transport = new FakeTransport([ + withLocation(hop.response, 'https://other.example/next'), + final.response, + ]); + + await runThrough(redirectStep(), transport); + + expect( + transport.calls[1]?.request.headers.get( + 'x-dexpace-internal-redirect-cross-origin', + ), + ).toBe('1'); + }); +}); + +describe('redirectStep -- a failing release never masks the primary error', () => { + test('a rejecting close() keeps SchemeDowngradeError primary (REDIR-22b, RECOV-12)', async () => { + const transport = new FakeTransport([ + hostileResponse(301, 'http://example.com/next'), + ]); + + const error = await rejectionOf(runThrough(redirectStep(), transport)); + + // Without withReleaseFailure this was `Error: cancel exploded` -- the typed, caller-catchable, + // security-relevant error silently replaced by the teardown failure. + const suppressed = error as SuppressedErrorLike; + expect(suppressed.error).toBeInstanceOf(SchemeDowngradeError); + expect(suppressed.suppressed).toBe(CANCEL_FAILURE); + }); + + test("a rejecting close() keeps the caller's predicate error primary (REDIR-22b)", async () => { + const boom = new Error('predicate exploded'); + const transport = new FakeTransport([ + hostileResponse(301, 'https://example.com/next'), + ]); + const step = redirectStep({ + predicate: () => { + throw boom; + }, + }); + + const error = await rejectionOf(runThrough(step, transport)); + + const suppressed = error as SuppressedErrorLike; + expect(suppressed.error).toBe(boom); + expect(suppressed.suppressed).toBe(CANCEL_FAILURE); + }); + + test('a clean release leaves the primary error untouched, unwrapped', async () => { + const hop = countingResponse(301); + const transport = new FakeTransport([ + withLocation(hop.response, 'http://example.com/next'), + ]); + + const error = await rejectionOf(runThrough(redirectStep(), transport)); + + expect(error).toBeInstanceOf(SchemeDowngradeError); // NOT wrapped when nothing was suppressed + expect(hop.cancelCount()).toBe(1); + }); +}); + +describe("redirectStep -- REDIR-22(b)'s other named trigger, and concurrency", () => { + test('a non-replayable body closes the current response before the error propagates', async () => { + // REDIR-22(b) names exactly two triggers -- "non-replayable body, downgrade rejection". The + // downgrade one is covered above; this is the other. Note the deliberate reading of a conflict: + // PIPE-40's parenthetical lists "non-replayable body" among the paths whose in-flight response is + // "returned unclosed", which is the opposite disposition. REDIR-6 ("MUST fail with a clear error") + // and REDIR-22(b) agree that this path THROWS, and a response that is never returned cannot be + // "returned unclosed" -- so the redirect chapter governs. Recorded in the design's Deviation Ledger. + const oneShot = streamBody( + new ReadableStream({ + start: c => { + c.close(); + }, + }), + undefined, + 0, + ); + const seed = Request.newBuilder() + .method('POST') + .url('https://example.com/start') + .body(oneShot) + .build(); + const hop = countingResponse(307); + const transport = new FakeTransport([ + withLocation(hop.response, 'https://example.com/next'), + ]); + const step = redirectStep({ + allowedMethods: new Set(['GET', 'HEAD', 'POST']), + }); + + const error = await rejectionOf( + new Cursor({ + steps: [step], + transport, + request: seed, + context: aRequestContext(seed), + }).advance(), + ); + + expect(error).toBeInstanceOf(NonReplayableBodyError); + expect(hop.cancelCount()).toBe(1); // closed, not leaked + expect(transport.sendCount).toBe(1); // the redirect was not attempted + }); + + test('one descriptor drives concurrent calls without sharing loop state', async () => { + // Every piece of per-call state -- `visited`, `redirectsFollowed`, `request`, `seedOrigin` -- is a + // local inside `fn`, so a single installed descriptor is safe under concurrency. The same property + // 5a asserts for its own engine (RETRY-42/RECOV-28). + const step = redirectStep(); + const drive = async (host: string): Promise => { + const hop = countingResponse(301); + const final = countingResponse(200); + const transport = new FakeTransport([ + withLocation(hop.response, `https://${host}/final`), + final.response, + ]); + const seed = Request.newBuilder().url(`https://${host}/start`).build(); + const response = await new Cursor({ + steps: [step], + transport, + request: seed, + context: aRequestContext(seed), + }).advance(); + expect(response).toBe(final.response); + return transport.calls[1]?.request.url.href; + }; + + const [a, b] = await Promise.all([drive('a.example'), drive('b.example')]); + + expect(a).toBe('https://a.example/final'); + expect(b).toBe('https://b.example/final'); + }); +}); diff --git a/packages/core/src/redirect/redirect-step.ts b/packages/core/src/redirect/redirect-step.ts new file mode 100644 index 0000000..1674448 --- /dev/null +++ b/packages/core/src/redirect/redirect-step.ts @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/redirect/redirect-step.ts +import type {Request} from '../http/request.js'; +import type {Response} from '../http/response.js'; +import {invariant} from '../invariant.js'; +import type {StepDescriptor} from '../pipeline/step.js'; +import {releaseQuietly, withReleaseFailure} from '../recovery/release.js'; +import {originOf} from './cross-origin.js'; +import {decide, type Decision, type RedirectContext} from './decide.js'; +import {redirectSettings, type RedirectSettings} from './settings.js'; + +/** Stable identity for pillar-slot occupancy and anchor matching (PIPE-6/PIPE-18). @internal */ +export const REDIRECT_STEP_TYPE: unique symbol = Symbol('dexpace.redirect'); + +/** + * REDIR-22(b): if deciding or building the follow-up throws, the current response MUST be closed before + * the error propagates. `decide()` is pure EXCEPT that it invokes `settings.predicate`, which is caller + * code and may throw for reasons this step cannot enumerate; `Request`/`Headers` builder validation is a + * second, thinner vector. A raw `decide()` call in the loop would let either escape with the hop's + * response still open, leaking the body. + * + * The decision's error stays PRIMARY. `Response.close()` rethrows whatever cancelling the body raised + * (everything but the `TypeError` a locked stream reports), so a bare `await response.close()` here + * would replace the caller's own error with the teardown failure -- the inversion RECOV-12 forbids. + * `releaseQuietly`/`withReleaseFailure` (4b's helpers, shared with the retry engine) keep the primary + * primary and hang the release failure off it as `suppressed`. + * + * Beyond that the error is rethrown unchanged. Note the deliberate asymmetry with retry, where + * RETRY-40 converts a throwing predicate into a typed illegal-state error: redirect's spec states no + * such conversion, so a caller's own error passes through as its own. + */ +async function decideOrClose( + response: Response, + context: RedirectContext, + settings: RedirectSettings, +): Promise { + try { + return decide(response, context, settings); + } catch (error) { + throw withReleaseFailure(error, await releaseQuietly(response)); + } +} + +/** + * The REDIRECT pillar step (REDIR-1..REDIR-27, PIPE-40). + * + * `stage: 'REDIRECT'` is baked into the descriptor this factory returns, which is how PIPE-36 ("a shipped + * pillar family must not be relocatable out of its pillar") is satisfied structurally: steps are + * functions carrying a descriptor, not classes with a subclassable stage assignment. `ctx.fork` is + * asserted rather than checked -- REDIRECT is in `PILLAR_STAGES`, so its absence means the descriptor was + * installed somewhere it cannot be, which is a programmer error. + * + * Every dispatch, INCLUDING the first, goes through a fresh `ctx.fork()` -- never `ctx.next()` -- since + * the step may re-drive the downstream chain an unknown number of times and `next()`'s single-invocation + * guard would trip on the second hop (PIPE-15). + * + * **Response lifecycle** (PIPE-40/REDIR-22): a superseded intermediate response is closed before the next + * hop's dispatch; on `'fail'` the current response is closed before the error propagates; on every + * `'return-current'` outcome -- not-a-redirect, opted-out, malformed or missing Location, loop detected, + * hop cap reached -- the response is returned OPEN, the caller's to close. Close-responsibility passes + * outward. + * + * **What a caller catches.** Normally the decision's own error -- `SchemeDowngradeError`, + * `NonReplayableBodyError`, or whatever a caller predicate threw -- so `instanceof` works directly. In + * the one case where releasing that hop ALSO fails, the throw is a `SuppressedError`-shaped pairing + * (`suppress.ts`) carrying the decision error as `.error` and the release failure as `.suppressed`, per + * RECOV-12's "keep the primary primary". Code that must handle both reads `.error` when the caught value + * has one. The same shape 5a's retry engine already surfaces on its equivalent path. + * + * Iterative, not recursive, so it is stack-safe regardless of `maxHops` (REDIR-23): each `await` + * releases its iteration's frame before the next begins. + * + * `ctx.signal` is checked once per iteration, in the `follow` branch, BEFORE closing that hop's response + * and re-driving -- the only placement under which "return the current response, open" is meaningful, + * since `return-current` and `fail` already have their own disposition by the time it would run. No + * cancellable wait is needed (unlike retry, there is nothing to sleep between hops), so this is one cheap + * check rather than a timer race. + * + * **Caller obligation.** A caller installing this descriptor directly, rather than through + * `withRedirect()`, must also install `stripCrossOriginMarkerStep()` -- otherwise REDIR-11's internal + * marker reaches the transport whenever no auth step is present to strip it. + * + * Redirect's SHOULD-level structured logging (REDIR-28, and REDIR-15's observable surfacing of a + * permitted downgrade) is deliberately absent here: the `Logger` seam is Phase 7b, which executes after + * this phase and amends this file with its three emission sites in its own Task 9. + * + * @param overrides - redirect policy overrides; a zero-argument call yields the spec defaults. + * @returns the descriptor to install in a pipeline's REDIRECT slot. + * + * @internal + */ +export function redirectStep( + overrides?: Partial, +): StepDescriptor { + // Built ONCE per installed step, not per request: `redirectSettings()` validates every field and takes + // a defensive copy of the allowed-method set. The policy is immutable and stateless after construction, + // so one instance is safe to share across concurrent calls. + const settings = redirectSettings(overrides); + return { + type: REDIRECT_STEP_TYPE, + stage: 'REDIRECT', + fn: async (seedRequest, ctx) => { + const {fork, signal} = ctx; + invariant( + fork !== undefined, + 'redirectStep must occupy the REDIRECT pillar stage', + ); + // `Request.url` hands back a FRESH `URL` on every access (HTTP-5), so read it once. + // REDIR-8: fixed for the whole chain. REDIR-16: seeded with the ORIGINAL request's URI. + const seedUrl = seedRequest.url; + const seedOrigin = originOf(seedUrl); + const visited = new Set([seedUrl.href]); + let request: Request = seedRequest; + let redirectsFollowed = 0; + + for (;;) { + const response = await fork()(request); + const context: RedirectContext = { + currentRequest: request, + seedOrigin, + visited, + redirectsFollowed, + }; + const decision = await decideOrClose(response, context, settings); + + if (decision.kind === 'return-current') return response; + if (decision.kind === 'fail') { + // REDIR-22(b): closed before the error propagates, and the DECISION's error is the one that + // propagates -- a failing release rides along as `suppressed` rather than replacing it. + throw withReleaseFailure( + decision.error, + await releaseQuietly(response), + ); + } + if (signal?.aborted === true) return response; + + // REDIR-22(a): the superseded hop, released before the next drive. NOT quieted -- unlike the + // two paths above there is no primary error to keep primary, and PIPE-40 makes the release + // itself part of the contract, so a failure to release IS this call's failure. + await response.close(); + visited.add(decision.nextRequest.url.href); + redirectsFollowed += 1; + request = decision.nextRequest; + } + }, + }; +} diff --git a/packages/core/src/redirect/settings.test.ts b/packages/core/src/redirect/settings.test.ts new file mode 100644 index 0000000..52f5170 --- /dev/null +++ b/packages/core/src/redirect/settings.test.ts @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/redirect/settings.test.ts +// Exercises: REDIR-17 (maxHops default 3; 0 is an ordinary value, not a special-cased branch -- decide()'s +// hop-cap gate is what makes it "disable following"), REDIR-26 (the allowed-method set is stored as an +// immutable defensive COPY, so mutating the caller's collection afterwards cannot change policy), +// REDIR-27 (the location header is configurable, defaulting to 'Location'), REDIR-20 (the predicate slot), +// REDIR-3/4/5 (the default allowed-method set and the 303 opt-in default). +import {describe, expect, test} from 'bun:test'; +import type {Method} from '../http/method.js'; +import {DEFAULT_ALLOWED_METHODS} from './codes.js'; +import {DEFAULT_REDIRECT_SETTINGS, redirectSettings} from './settings.js'; + +describe('defaults', () => { + test('ship the spec defaults', () => { + expect(DEFAULT_REDIRECT_SETTINGS.maxHops).toBe(3); + expect(DEFAULT_REDIRECT_SETTINGS.allow303).toBe(false); + expect(DEFAULT_REDIRECT_SETTINGS.allowSchemeDowngrade).toBe(false); + expect(DEFAULT_REDIRECT_SETTINGS.locationHeader).toBe('Location'); + expect([...DEFAULT_REDIRECT_SETTINGS.allowedMethods].sort()).toEqual( + [...DEFAULT_ALLOWED_METHODS].sort(), + ); + }); + + test('no predicate by default', () => { + expect(DEFAULT_REDIRECT_SETTINGS.predicate).toBeUndefined(); + }); + + test('a zero-config call yields the defaults', () => { + expect(redirectSettings().maxHops).toBe(3); + expect(redirectSettings().locationHeader).toBe('Location'); + }); + + test('one field can be overridden without restating the rest', () => { + const settings = redirectSettings({allow303: true}); + expect(settings.allow303).toBe(true); + expect(settings.maxHops).toBe(3); + }); +}); + +describe('validation', () => { + test('rejects a negative maxHops', () => { + expect(() => redirectSettings({maxHops: -1})).toThrow(); + }); + + test('accepts maxHops of 0 as an ordinary value, not a special case', () => { + expect(redirectSettings({maxHops: 0}).maxHops).toBe(0); + }); + + test('rejects a non-finite maxHops', () => { + expect(() => redirectSettings({maxHops: Number.NaN})).toThrow(); + expect(() => + redirectSettings({maxHops: Number.POSITIVE_INFINITY}), + ).toThrow(); + }); + + test('rejects a fractional maxHops rather than silently truncating it', () => { + // `2.5` would otherwise pass the cap gate for two hops and fail on the third -- a budget the + // caller never wrote. Same `Number.isInteger` guard `retryStep`'s per-call override applies. + expect(() => redirectSettings({maxHops: 2.5})).toThrow(); + }); + + test('rejects a blank locationHeader', () => { + expect(() => redirectSettings({locationHeader: ''})).toThrow(); + expect(() => redirectSettings({locationHeader: ' '})).toThrow(); + }); + + test('rejects a locationHeader carrying a byte HTTP-17 forbids in a header name', () => { + // `Headers.get()` neither trims nor validates -- it lower-cases and looks up. An unvalidated name + // would therefore never throw and never match: redirects silently unfollowed, no error anywhere. + // The predicate is the codebase's own header-name rule (control bytes, DEL, non-ASCII), the same + // one `HeadersBuilder` applies -- printable ASCII such as a space is legal here and stays legal. + expect(() => + redirectSettings({locationHeader: 'Loc\u0000ation'}), + ).toThrow(); + expect(() => redirectSettings({locationHeader: 'Loc\u00e1tion'})).toThrow(); + }); + + test('stores locationHeader trimmed, so a padded name still matches', () => { + expect( + redirectSettings({locationHeader: ' Location '}).locationHeader, + ).toBe('Location'); + }); + + test('accepts a caller-supplied predicate', () => { + const predicate = (): boolean => true; + expect(redirectSettings({predicate}).predicate).toBe(predicate); + }); +}); + +describe('immutability', () => { + test('the allowed-methods set is defensively copied (REDIR-26)', () => { + const caller = new Set(['GET']); + const settings = redirectSettings({allowedMethods: caller}); + caller.add('POST'); + expect(settings.allowedMethods.has('POST')).toBe(false); + }); + + test('the returned settings object is frozen', () => { + expect(Object.isFrozen(redirectSettings())).toBe(true); + }); + + test('DEFAULT_REDIRECT_SETTINGS is frozen', () => { + expect(Object.isFrozen(DEFAULT_REDIRECT_SETTINGS)).toBe(true); + }); +}); diff --git a/packages/core/src/redirect/settings.ts b/packages/core/src/redirect/settings.ts new file mode 100644 index 0000000..31b7065 --- /dev/null +++ b/packages/core/src/redirect/settings.ts @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/redirect/settings.ts +import {hasForbiddenNameByte} from '../http/ascii-validation.js'; +import type {Method} from '../http/method.js'; +import type {Response} from '../http/response.js'; +import {invariant} from '../invariant.js'; +import {DEFAULT_ALLOWED_METHODS} from './codes.js'; + +/** + * REDIR-20's read-only condition snapshot. Allocated for EVERY recognized 3xx -- including one carrying + * no usable `Location` -- and never on the non-redirect fast path (REDIR-21). + * + * `visited` is insertion-ordered and includes the current request's URI. + * + * @internal + */ +export interface RedirectCondition { + readonly response: Response; + readonly redirectsFollowed: number; + readonly visited: ReadonlySet; +} + +/** + * REDIR-20: fully overrides the built-in code/method eligibility decision. It does NOT override the + * wire-safety mechanics that follow it -- credential stripping, the downgrade guard, body replayability, + * loop and hop-cap detection -- see `decide.ts`'s note on the scope of that override. + * + * @internal + */ +export type RedirectPredicate = ( + condition: Readonly, +) => boolean; + +/** + * Redirect policy. Every field is optional at the construction surface ({@link redirectSettings} takes a + * `Partial`), so a zero-config call yields the spec defaults and a caller can override one field without + * restating the rest. + * + * @internal + */ +export interface RedirectSettings { + /** REDIR-17: a non-negative integer, default 3. `0` disables following, with no special branch anywhere downstream. */ + readonly maxHops: number; + /** REDIR-3/REDIR-4: default `{GET, HEAD}`; stored as a defensive copy (REDIR-26). */ + readonly allowedMethods: ReadonlySet; + /** REDIR-5: 303 is not followed unless this is opted in. */ + readonly allow303: boolean; + /** REDIR-15: permits an HTTPS-to-HTTP hop, which is then surfaced observably by the step. */ + readonly allowSchemeDowngrade: boolean; + /** REDIR-27: the response header the target is read from, default `Location`. Stored trimmed. */ + readonly locationHeader: string; + /** REDIR-20: replaces the built-in code/method eligibility decision when present. */ + readonly predicate?: RedirectPredicate | undefined; +} + +/** + * The spec defaults, frozen. + * + * @internal + */ +export const DEFAULT_REDIRECT_SETTINGS: RedirectSettings = Object.freeze({ + maxHops: 3, + allowedMethods: DEFAULT_ALLOWED_METHODS, + allow303: false, + allowSchemeDowngrade: false, + locationHeader: 'Location', +}); + +/** + * Builds validated, frozen redirect settings. + * + * An invalid value is a PROGRAMMER error, the same split 5a's `retrySettings()` applied -- `invariant()`, + * not a new error leaf. `NonReplayableBodyError` and `SchemeDowngradeError` are the two OPERATIONAL + * failures a caller can legitimately hit mid-redirect; a bad `maxHops` is neither. + * + * `maxHops: 0` needs no special branch here or downstream: `decide()`'s hop-cap gate applies uniformly to + * every value, and a 0-hop budget simply fails it on the first follow attempt. + * + * `Object.freeze` is SHALLOW and does not disarm `Set.prototype.add` at all, so what REDIR-26 actually + * asks for is the defensive COPY below -- mutating the caller's collection afterwards cannot change + * policy. The `ReadonlySet` type is what keeps SDK-internal code from writing to it. A "frozen `Set`" + * would be a promise the runtime cannot keep; do not "fix" this with one. + * + * @param overrides - the fields to change; everything else takes the spec default. + * @returns frozen, validated settings. + * @throws InvariantViolation for a `maxHops` that is not a non-negative integer, or a `locationHeader` + * that is blank or carries a byte the header-name grammar forbids. + * + * @internal + */ +export function redirectSettings( + overrides?: Partial, +): RedirectSettings { + const merged = {...DEFAULT_REDIRECT_SETTINGS, ...overrides}; + invariant( + Number.isInteger(merged.maxHops) && merged.maxHops >= 0, + `redirect maxHops must be a non-negative integer, got ${String(merged.maxHops)}`, + ); + // Trimmed and STORED trimmed, then validated as a header name. `HeadersBuilder` trims and validates + // on the way in, but `Headers.get()` does neither -- it lower-cases the string and looks it up. So an + // untrimmed or malformed `locationHeader` would not throw anywhere: it would simply never match, and + // every redirect would come back unfollowed with no error at any layer. Same class of mistake 5a's + // `attemptHeaderName` check exists for, with a quieter failure mode. + const locationHeader = merged.locationHeader.trim(); + invariant( + locationHeader.length > 0 && !hasForbiddenNameByte(locationHeader), + `redirect locationHeader must be a valid header name, got '${merged.locationHeader}'`, + ); + return Object.freeze({ + ...merged, + locationHeader, + allowedMethods: new Set(merged.allowedMethods), + }); +} diff --git a/packages/core/src/redirect/strip-marker-step.test.ts b/packages/core/src/redirect/strip-marker-step.test.ts new file mode 100644 index 0000000..5faedfb --- /dev/null +++ b/packages/core/src/redirect/strip-marker-step.test.ts @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/redirect/strip-marker-step.test.ts +// Exercises: REDIR-11(c) (the internal cross-origin marker is removed before dispatch, INDEPENDENTLY of +// whether a credential-attaching layer runs -- the porter caveat the spec names, and a live leak today +// since no auth step exists until Phase 5c). The guard is an ordinary single-invocation step: it calls +// ctx.next() and never forks. Also: withRedirect() installs the pillar step and the guard together, so a +// caller reaching for redirect support gets the safety net without knowing the marker exists. +import {describe, expect, test} from 'bun:test'; +import { + createRequestContext, + type ExecutionContext, +} from '../context/context.js'; +import {Headers} from '../http/headers.js'; +import {Protocol} from '../http/protocol.js'; +import {Request} from '../http/request.js'; +import {Response} from '../http/response.js'; +import {Status} from '../http/status.js'; +import {PipelineBuilder} from '../pipeline/builder.js'; +import {Cursor} from '../pipeline/cursor.js'; +import {FakeTransport} from '../testing/fake-transport.js'; +import { + CROSS_ORIGIN_MARKER_HEADER, + withCrossOriginMarker, +} from './cross-origin.js'; +import {REDIRECT_STEP_TYPE} from './redirect-step.js'; +import { + STRIP_MARKER_STEP_TYPE, + stripCrossOriginMarkerStep, + withRedirect, +} from './strip-marker-step.js'; + +function aResponse(): Response { + return Response.newBuilder() + .request(Request.newBuilder().url('https://example.com').build()) + .protocol(Protocol.HTTP_1_1) + .status(Status.of(200)) + .headers(Headers.newBuilder().build()) + .body(null) + .build(); +} + +function aRequestContext(request: Request): ExecutionContext { + return createRequestContext(request); +} + +describe('stripCrossOriginMarkerStep', () => { + test('occupies POST_AUTH and is not a pillar step', () => { + const descriptor = stripCrossOriginMarkerStep(); + expect(descriptor.stage).toBe('POST_AUTH'); + expect(descriptor.type).toBe(STRIP_MARKER_STEP_TYPE); + }); + + test('clears a marker present on the request, then calls next (REDIR-11c)', async () => { + const marked = Request.newBuilder() + .url('https://example.com') + .headers(withCrossOriginMarker(Headers.newBuilder().build())) + .build(); + const transport = new FakeTransport([aResponse()]); + const cursor = new Cursor({ + steps: [stripCrossOriginMarkerStep()], + transport, + request: marked, + context: aRequestContext(marked), + }); + + await cursor.advance(); + + expect(transport.sendCount).toBe(1); + expect( + transport.calls[0]?.request.headers.get(CROSS_ORIGIN_MARKER_HEADER), + ).toBeUndefined(); + }); + + test('is a no-op when the marker is already absent', async () => { + const bare = Request.newBuilder() + .url('https://example.com') + .headers(Headers.newBuilder().add('X-Other', 'kept').build()) + .build(); + const transport = new FakeTransport([aResponse()]); + const cursor = new Cursor({ + steps: [stripCrossOriginMarkerStep()], + transport, + request: bare, + context: aRequestContext(bare), + }); + + await cursor.advance(); + + expect( + transport.calls[0]?.request.headers.has(CROSS_ORIGIN_MARKER_HEADER), + ).toBe(false); + expect(transport.calls[0]?.request.headers.get('X-Other')).toBe('kept'); // nothing else disturbed + // The guard runs on every request, so the common (unmarked) case must not rebuild anything: the + // request reaches the transport as the SAME instance it was handed. + expect(transport.calls[0]?.request).toBe(bare); + }); +}); + +describe('withRedirect', () => { + test('installs both the pillar step and the guard onto the builder', () => { + const runtime = withRedirect( + new PipelineBuilder(new FakeTransport([aResponse()])), + ).build(); + const types = runtime.steps.map(step => step.type); + expect(types).toContain(REDIRECT_STEP_TYPE); + expect(types).toContain(STRIP_MARKER_STEP_TYPE); + }); + + test('is idempotent -- a second call does not seat a second guard', () => { + // `PipelineBuilder.append` dedupes by `type` only for PILLAR stages (PIPE-6). POST_AUTH is not + // one, so without withRedirect()'s own `remove` the pillar half would be idempotent while the + // guard half silently duplicated. + const builder = new PipelineBuilder(new FakeTransport([aResponse()])); + const runtime = withRedirect(withRedirect(builder)).build(); + const types = runtime.steps.map(step => step.type); + expect(types.filter(type => type === STRIP_MARKER_STEP_TYPE)).toHaveLength( + 1, + ); + expect(types.filter(type => type === REDIRECT_STEP_TYPE)).toHaveLength(1); + }); + + test('the guard sits after the pillar step in flattened order', () => { + const runtime = withRedirect( + new PipelineBuilder(new FakeTransport([aResponse()])), + ).build(); + const types = runtime.steps.map(step => step.type); + expect(types.indexOf(REDIRECT_STEP_TYPE)).toBeLessThan( + types.indexOf(STRIP_MARKER_STEP_TYPE), + ); + }); + + test('a cross-origin redirect never reaches the wire carrying the marker (REDIR-11c)', async () => { + const hop = Response.newBuilder() + .request(Request.newBuilder().url('https://example.com/start').build()) + .protocol(Protocol.HTTP_1_1) + .status(Status.of(302)) + .headers( + Headers.newBuilder() + .setInbound('Location', 'https://other.example/next') + .build(), + ) + .body(null) + .build(); + const transport = new FakeTransport([hop, aResponse()]); + const runtime = withRedirect(new PipelineBuilder(transport)).build(); + const seed = Request.newBuilder().url('https://example.com/start').build(); + + await runtime.send(seed); + + // The redirect step set the marker for the (not-yet-existing) auth layer; the guard took it off + // again before the terminal dispatch. Without the guard this second send would carry it to the wire. + expect(transport.sendCount).toBe(2); + expect(transport.calls[1]?.request.url.href).toBe( + 'https://other.example/next', + ); + expect( + transport.calls[1]?.request.headers.has(CROSS_ORIGIN_MARKER_HEADER), + ).toBe(false); + }); +}); diff --git a/packages/core/src/redirect/strip-marker-step.ts b/packages/core/src/redirect/strip-marker-step.ts new file mode 100644 index 0000000..946ab6d --- /dev/null +++ b/packages/core/src/redirect/strip-marker-step.ts @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/redirect/strip-marker-step.ts +import type {PipelineBuilder} from '../pipeline/builder.js'; +import type {StepDescriptor} from '../pipeline/step.js'; +import {clearCrossOriginMarker, hasCrossOriginMarker} from './cross-origin.js'; +import {redirectStep} from './redirect-step.js'; +import type {RedirectSettings} from './settings.js'; + +/** Stable identity for anchor matching (PIPE-18). @internal */ +export const STRIP_MARKER_STEP_TYPE: unique symbol = Symbol( + 'dexpace.redirect.strip-marker', +); + +/** + * REDIR-11(c)'s independent safety net. + * + * The requirement says the signal MUST be removed by the credential-attaching layer before dispatch -- + * and names the porter caveat that in the reference only the auth step strips it, so "a pipeline with no + * auth step, including the sync standard-resilience preset, forwards the internal marker to the + * transport", recommending a robust port strip it independently of whether a credential layer runs. + * + * That is not a future concern here: 5b ships before 5c, so today there IS no auth step, and without this + * guard the marker would reach the wire on every cross-origin hop. `POST_AUTH` is 4c's inert + * user-installable extension slot (PIPE-3) -- inside AUTH, outside SEND -- so the guard needs no change to + * 4c's `Cursor` and no coordination with 5c. When 5c ships, its auth step becomes the marker's real + * CONSUMER and first stripper; this stays installed as a redundant, idempotent backstop, since stripping + * an already-absent header costs nothing. + * + * An ordinary single-invocation step: it calls `ctx.next()` once and never re-drives, so it needs no fork. + * + * @returns the descriptor to install in a pipeline's POST_AUTH slot. + * + * @internal + */ +export function stripCrossOriginMarkerStep(): StepDescriptor { + return { + type: STRIP_MARKER_STEP_TYPE, + stage: 'POST_AUTH', + // The guard runs on EVERY request through a redirect-enabled pipeline, while the marker is present + // only on a cross-origin hop -- so the common case must not pay for the rare one. Rebuilding is not + // cheap: `HeadersBuilder.build()` deep-copies every value list plus both name maps, and + // `Request.newBuilder()` re-parses the URL. The guard below is a single `Map.has`, and the + // "no-op when the marker is already absent" test pins the branch it introduces. + fn: (request, ctx) => { + if (!hasCrossOriginMarker(request.headers)) return ctx.next(); + return ctx.next( + request + .newBuilder() + .headers(clearCrossOriginMarker(request.headers)) + .build(), + ); + }, + }; +} + +/** + * Installs {@link redirectStep} and its bundled guard together, so a caller reaching for redirect support + * gets REDIR-11(c)'s safety net without needing to know the marker exists. A caller who installs + * `redirectStep()` directly against the builder's lower-level API is responsible for installing the guard + * too. + * + * Idempotent: calling it twice leaves one pillar step and one guard. A guard the caller had already + * installed is relocated to the tail of `POST_AUTH` rather than duplicated -- which is where it + * belongs anyway, since a step seated after it runs closer to `SEND` and could otherwise put the + * marker back. + * + * @param builder - the pipeline being assembled. + * @param overrides - redirect policy overrides; omitted yields the spec defaults. + * @returns the same builder, for chaining. + * + * @internal + */ +export function withRedirect( + builder: PipelineBuilder, + overrides?: Partial, +): PipelineBuilder { + // `remove` first so a second `withRedirect()` call does not seat a second guard. `append` dedupes by + // `type` only for PILLAR stages (PIPE-6), and `POST_AUTH` is not one -- so without this the pillar + // half of this call would be idempotent while the guard half silently duplicated. `remove` is a no-op + // when absent, and the type symbol is this module's own, so it can only ever match this guard. + return builder + .remove(STRIP_MARKER_STEP_TYPE) + .append(redirectStep(overrides)) + .append(stripCrossOriginMarkerStep()); +} diff --git a/packages/core/src/retry/engine.ts b/packages/core/src/retry/engine.ts index 3f6da10..e8d0a1d 100644 --- a/packages/core/src/retry/engine.ts +++ b/packages/core/src/retry/engine.ts @@ -6,6 +6,7 @@ import type {Clock} from '../config/clock.js'; import type {Request} from '../http/request.js'; import type {Response} from '../http/response.js'; import {failure, type Outcome} from '../recovery/outcome.js'; +import {releaseQuietly, withReleaseFailure} from '../recovery/release.js'; import {suppress} from '../suppress.js'; import {stampAttempt} from './attempt-stamp.js'; import {computeDelay} from './backoff.js'; @@ -147,9 +148,6 @@ async function retire(response: Response): Promise { ); } -/** Marks "the response was released without incident", distinct from any value `close()` could throw. */ -const RELEASED_CLEANLY = Symbol('dexpace.retry.released'); - /** What the schedule step decided, before the release outcome is folded in. */ interface Schedule { readonly error: unknown; @@ -187,48 +185,6 @@ async function scheduleFrom( }; } -/** - * Releases a discarded response, reporting rather than raising whatever release itself threw. - * - * `Response.close()` is documented to rethrow whatever cancelling the body raises (everything except - * the `TypeError` a locked stream reports), so it is not a call that can sit in a bare `finally`: - * there it would replace the value being returned, or replace an in-flight throwable with the - * teardown failure -- the exact inversion RECOV-12 forbids and `suppress()` exists to prevent. - */ -async function releaseQuietly( - response: Response | undefined, -): Promise { - if (response === undefined) return RELEASED_CLEANLY; - try { - await response.close(); - return RELEASED_CLEANLY; - } catch (error) { - return error; - } -} - -/** - * Keeps `primary` primary, with a release failure riding along as suppressed (RECOV-12, RETRY-22's - * "a teardown failure can never mask the upstream failure"). - * - * The identity guard is not decorative. `Response.close()` memoizes its release promise, so a close - * that already failed inside `toHttpError`'s own `finally` hands the SAME rejection back to the - * second caller -- without this check that instance would be suppressed under itself. - */ -function withReleaseFailure( - primary: unknown, - releaseFailure: unknown, -): unknown { - if (releaseFailure === RELEASED_CLEANLY || releaseFailure === primary) { - return primary; - } - return suppress( - primary, - releaseFailure, - 'releasing the discarded response failed', - ); -} - /** * Retires the response the loop is discarding and schedules the wait, releasing the response on * every exit (RETRY-35's second clause) without ever letting the release outcome become primary. diff --git a/test/node-conformance/README.md b/test/node-conformance/README.md index 35c9dc0..b234ffd 100644 --- a/test/node-conformance/README.md +++ b/test/node-conformance/README.md @@ -47,3 +47,4 @@ means Phase 4 (pipelines, where `NFR-11`'s async-framework-leak check lands) and | `seams.test.mjs` | `AbortSignal.any()` composition — folded in from the retired `scripts/verify-node-floor.mjs`, whose two assertions were the only Node coverage that existed before this suite — plus the `globalThis.crypto` floor assertion, made from ESM on purpose (Node 18 exposed `crypto` to CommonJS while leaving it undefined in ES modules) | | `io-byte-stream.test.mjs` | Phase 3a's `ByteQueue`, `BufferedSource` + views, `BufferedSink`, `TeeSink`, `writeAll` | | `body-lifecycle.test.mjs` | Phase 3b's public body surface over real Node Web Streams — reader-lock discipline, `pipeTo` ownership, multipart framing, error-body buffering | +| `redirect.test.mjs` | Phase 5b's Location resolution on Node's own WHATWG `URL` parser (relative resolution, percent-encoding preservation, userinfo clearing, bracketed IPv6, which malformed forms throw versus resolve as a relative reference) plus `PIPE-40`'s per-hop close discipline over real Node Web Streams | diff --git a/test/node-conformance/redirect.test.mjs b/test/node-conformance/redirect.test.mjs new file mode 100644 index 0000000..f4058ec --- /dev/null +++ b/test/node-conformance/redirect.test.mjs @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: MIT +// test/node-conformance/redirect.test.mjs +// +// Phase 5b is a runtime-divergent surface at two specific points, and both fail silently rather than +// loudly if the runtimes disagree: +// +// 1. `decide.ts` delegates ALL of REDIR-12/13/14/18 to the platform's WHATWG `URL` -- reference +// resolution, percent-encoding preservation, userinfo clearing, the bracketed-IPv6 form, and which +// malformed inputs throw versus resolve as a relative reference. Bun's URL parser is an independent +// implementation of Node's. A divergence here does not crash: `%2F` silently decoding to `/` would +// change the path structure of every followed redirect, and a Location that Node treats as a parse +// failure where Bun treats it as a relative reference would flip "return the 3xx unfollowed" into +// "dispatch a request nobody asked for" -- with `bun test` green throughout. +// 2. PIPE-40/REDIR-22's response-lifecycle discipline rides on Web Streams: each superseded hop is +// released by `Response.close()` (which cancels the body stream), and the final response must be +// left uncancelled. Node's `cancel()`/`pull()` timing is an independent implementation of Bun's, +// and the whole close-count assertion is observed through that hook. +// +// `redirect/` is `@internal` with no public subpath in `exports`, so it is reached by direct `dist/` +// file path, per this suite's import rule. +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; +import {Headers, Protocol, Request, Response, Status} from '@dexpace/core'; +import {createRequestContext} from '../../packages/core/dist/context/context.js'; +import {Cursor} from '../../packages/core/dist/pipeline/cursor.js'; +import {originOf} from '../../packages/core/dist/redirect/cross-origin.js'; +import {decide} from '../../packages/core/dist/redirect/decide.js'; +import {redirectStep} from '../../packages/core/dist/redirect/redirect-step.js'; +import {redirectSettings} from '../../packages/core/dist/redirect/settings.js'; +import { + FakeTransport, + countingResponse, +} from '../../packages/core/dist/testing/fake-transport.js'; + +const SEED_URL = 'https://example.com/start'; + +function aRequest(url = SEED_URL) { + return Request.newBuilder().url(url).build(); +} + +function aRedirect(location, status = 302) { + return Response.newBuilder() + .request(aRequest()) + .protocol(Protocol.HTTP_1_1) + .status(Status.of(status)) + .headers(Headers.newBuilder().setInbound('Location', location).build()) + .body(null) + .build(); +} + +function contextFor(request) { + return { + currentRequest: request, + seedOrigin: originOf(request.url), + visited: new Set([request.url.href]), + redirectsFollowed: 0, + }; +} + +function followedTarget(location, from = SEED_URL) { + const decision = decide( + aRedirect(location), + contextFor(aRequest(from)), + redirectSettings(), + ); + assert.equal(decision.kind, 'follow'); + return decision.nextRequest.url; +} + +function withLocation(response, location) { + return response + .newBuilder() + .headers( + response.headers.newBuilder().setInbound('Location', location).build(), + ) + .build(); +} + +describe("Location resolution on Node's own URL parser", () => { + it('resolves a relative reference against the current hop (REDIR-14)', () => { + assert.equal( + followedTarget('/next', 'https://example.com/a/b').href, + 'https://example.com/next', + ); + }); + + it('never re-encodes an already-percent-encoded path or query (REDIR-13)', () => { + const target = followedTarget('https://example.com/a%2Fb?q=x%26y'); + assert.equal(target.pathname, '/a%2Fb'); + assert.equal(target.search, '?q=x%26y'); + }); + + it('preserves a bracketed IPv6 literal host and an explicit port (REDIR-13)', () => { + const target = followedTarget('https://[2001:db8::1]:8443/x'); + assert.equal(target.hostname, '[2001:db8::1]'); + assert.equal(target.port, '8443'); + }); + + it('drops userinfo without disturbing the rest of the URL (REDIR-12)', () => { + const target = followedTarget('https://user:pass@other.example/x?q=1'); + assert.equal(target.username, ''); + assert.equal(target.password, ''); + assert.equal(target.href, 'https://other.example/x?q=1'); + }); + + it('treats a non-URL string as a relative reference, not a parse failure (REDIR-14)', () => { + // The behavior the `catch` in `resolveLocation` is deliberately NOT relied on for. If Node ever + // threw here where Bun resolves, the step would silently stop following a redirect it should follow. + assert.equal( + followedTarget(' not a url').href, + 'https://example.com/not%20a%20url', + ); + }); + + it('resolves dot segments per RFC 3986 (REDIR-14)', () => { + for (const [location, expected] of [ + ['.', 'https://example.com/a/b/'], + ['..', 'https://example.com/a/'], + ['../../x', 'https://example.com/x'], + ]) { + assert.equal( + followedTarget(location, 'https://example.com/a/b/c').href, + expected, + location, + ); + } + }); + + it('inherits the scheme for a protocol-relative Location (REDIR-14)', () => { + assert.equal( + followedTarget('//other.example/x').href, + 'https://other.example/x', + ); + }); + + it('normalizes case and the default port, which is what makes loop detection hold (REDIR-16)', () => { + // `visited` keys on `href`. If Node normalized differently from Bun here, a loop a Bun-run test + // says is caught would be followable on the runtime this package actually ships to. + assert.equal( + followedTarget('HTTPS://EXAMPLE.COM/a').href, + 'https://example.com/a', + ); + assert.equal( + followedTarget('https://example.com:443/a').href, + 'https://example.com/a', + ); + }); + + it('returns a malformed absolute form unfollowed rather than throwing (REDIR-18)', () => { + const decision = decide( + aRedirect('http://['), + contextFor(aRequest()), + redirectSettings(), + ); + assert.deepEqual(decision, {kind: 'return-current'}); + }); + + it('returns an unsupported scheme unfollowed, never dispatching it (REDIR-18)', () => { + for (const raw of [ + 'javascript:alert(1)', + 'data:text/html,x', + 'file:///etc/passwd', + ]) { + const decision = decide( + aRedirect(raw), + contextFor(aRequest()), + redirectSettings(), + ); + assert.deepEqual(decision, {kind: 'return-current'}, raw); + } + }); +}); + +describe('redirect response lifecycle over real Node Web Streams', () => { + it('closes every superseded hop and leaves the final response open (PIPE-40)', async () => { + const first = countingResponse(301); + const second = countingResponse(301); + const third = countingResponse(200); + const transport = new FakeTransport([ + withLocation(first.response, 'https://example.com/mid'), + withLocation(second.response, '/final'), + third.response, + ]); + const seed = aRequest(); + + const response = await new Cursor({ + steps: [redirectStep()], + transport, + request: seed, + context: createRequestContext(seed), + }).advance(); + + assert.equal(transport.sendCount, 3); + assert.equal(first.cancelCount(), 1); + assert.equal(second.cancelCount(), 1); + assert.equal(third.cancelCount(), 0); // close-responsibility passes outward to the caller + assert.equal(response, third.response); + }); + + it('returns a loop-detected response open, without throwing (REDIR-16/REDIR-22c)', async () => { + const loop = countingResponse(301); + const located = withLocation(loop.response, SEED_URL); + const transport = new FakeTransport([located]); + const seed = aRequest(); + + const response = await new Cursor({ + steps: [redirectStep()], + transport, + request: seed, + context: createRequestContext(seed), + }).advance(); + + assert.equal(transport.sendCount, 1); + assert.equal(response, located); + assert.equal(loop.cancelCount(), 0); + }); + + it("honors an already-aborted signal on Node's AbortSignal", async () => { + const controller = new AbortController(); + controller.abort(); + const hop = countingResponse(301); + const located = withLocation(hop.response, 'https://example.com/next'); + const never = countingResponse(200); + const transport = new FakeTransport([located, never.response]); + const seed = aRequest(); + + const response = await new Cursor({ + steps: [redirectStep()], + transport, + request: seed, + context: createRequestContext(seed), + signal: controller.signal, + }).advance(); + + assert.equal(transport.sendCount, 1); + assert.equal(response, located); + assert.equal(hop.cancelCount(), 0); + }); +}); From e452f94acf7fe9be8d5d6e3773e8bea8589d1d5e Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh <78609166+Wahbeh-Mohammad@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:47:52 +0300 Subject: [PATCH 3/4] =?UTF-8?q?feat(core):=20phase=205c=20=E2=80=94=20the?= =?UTF-8?q?=20auth=20pillar=20step=20and=20the=20public=20authoring=20surf?= =?UTF-8?q?ace.=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the authentication layer per product-spec/11-authentication.md (AUTH-1..AUTH-38), following docs/superpowers/specs/2026-07-26-phase5c-auth-design.md, and closes four roadmap deferrals: PIPE-35's seedFrom, AUTH-29's marker-consumption side (5b produced the marker), PIPE-24/PIPE-39's standard-resilience preset, and public-barrel promotion of the pillar-authoring surface. Per-requirement disposition in docs/superpowers/plans/2026-07-26-phase5c-auth-checklist.md. New packages/core/src/auth/, fifteen files, no folder barrel: - scheme.ts, requirement.ts, descriptor.ts, resolve.ts — the descriptor/ resolver model (AUTH-1..7). Pure data shapes and pure functions, no classes, so AUTH-7's "stateless, concurrency-safe, deterministic" falls out of the structure rather than being asserted about it. Tier selection is perCall ?? operation ?? client: the first tier PRESENT, never the first that succeeds, so a present-but-unsatisfiable override fails rather than silently demoting to a weaker one. - credential.ts — BearerToken, ApiKeyCredential, NameKeyCredential, TokenProvider (AUTH-8..11). Two shapes for two equality requirements. - challenge.ts — the RFC 7235 parser (AUTH-12/13), total by construction. - md5.ts — RFC 1321, hand-rolled. Web Crypto excludes MD5 on security grounds and RFC 7616 still requires it for interop, so the alternatives were an npm dependency (SEAM-1) or node:crypto (portability). - basic.ts, digest.ts, static-key.ts, composing-handler.ts — the stamping handlers (AUTH-14..26). - bearer-cache.ts — the single-flight three-zone token cache (AUTH-34..37). - auth-step.ts — the AUTH pillar (AUTH-27..33, 36, 38). - preset.ts — standardResilience() (PIPE-24/39). AUTH-27 mandates exactly one auth step, yet AUTH-30 names "the challenge hook" and AUTH-34..37 name "the bearer auth step" as if three things. Reconciled as one step, one pluggable challengeHook, and a scheme-dependent default body. AUTH-30's contract — consult the hook, close the original on a non-null replacement, re-drive once through a fresh chain copy, no nested re-challenge — governs every scheme uniformly; AUTH-23..26 and AUTH-34..37 describe what the DEFAULT does per resolved scheme. It is the only reading that satisfies all four and leaves both named mechanisms a home. Basic and Digest never stamp preemptively. Both are phrased entirely in terms of answering a parsed challenge, and Digest structurally cannot stamp before seeing the server's realm/nonce. OAUTH2 and API_KEY do; NO_AUTH never does. Flagged as an interpretation rather than a certainty — §11 states it neither way — and routed to Phase 9's sweep against any reference fixtures it turns up. The cross-origin marker suppresses the WHOLE hop, not just the outbound pass. 5b marks a cross-origin re-issue; this step is its intended consumer. It reads the marker, clears it unconditionally before either branch so it cannot reach the wire, skips both the HTTPS guard and stamping — and declines to answer a 401 on that hop. Answering would stamp exactly the credential the outbound pass withheld, onto a server-chosen foreign host, over a URL whose guard was deliberately skipped. The joint 5b+5c conformance test asserts a credential present on hop one, absent on the cross-origin hop, and re-stamped on return to the seed origin, which is also PIPE-2's per-redirect-hop clause. RequestOptions gains auth?: AuthDescriptor, giving AUTH-4's most-specific tier a genuinely per-call source through StepContext.options (PIPE-17). The operation tier still has none; no per-operation layer exists in this roadmap. PipelineBuilder.seedFrom(runtime, 'flatten' | 'nest') has no default mode, per PIPE-35's MUST that the choice be explicit rather than accidental. Runtime gains a transport getter, without which flatten is not implementable at all. THE PUBLIC BARREL CHANGES, for the first time since Phase 1. 5c is the first point a caller can assemble a working pipeline, which is why every prior phase withheld this. Promoted: Stage, STAGE_ORDER, PILLAR_STAGES, Step, StepContext, Next, StepDescriptor, PipelineBuilder, Runtime, retryStep, redirectStep, authStep, standardResilience — plus every type those signatures name, because a promoted function whose parameter type is @internal is an API a caller cannot call. Everything else under auth/ stays internal: a caller builds an AuthStepSettings from the exported factories, never handler internals. Review pass 1 found the promotion was not in the shipped artifact. The literal string "@internal" inside an explanatory comment above the context export made TypeScript's stripInternal — which substring-matches a declaration's whole leading comment range — delete the export outright, so ExecutionContext and its three members never reached dist/index.d.ts. api:ci was green because api-extractor had recorded the ae-forgotten-export warning as text INSIDE the committed report and was comparing report to report. Three changes, because the bug class matters more than the instance: the comment is reworded, ae-forgotten-export is now logLevel "error" so a forgotten export fails the gate, and verify-consumer-types.mjs compiles a consumer naming the whole promoted surface — the check this phase's plan claimed as its pass condition and which did not exist. Runtime's constructor is now private behind a createRuntime friend hook. Promoting the class published a field-wise constructor that bypassed every invariant build() enforces: new Runtime([authStep(a), authStep(b)], t) compiled and ran both, a direct AUTH-27 violation with no PillarCollisionError. Same reasoning CLAUDE.md already gives for every model in http/. BearerToken is a class, not an interface. AUTH-8 requires every credential type to redact its secret in any string or diagnostic form, and a frozen object literal prints and JSON-serializes its token in full. Making it nominal also closed an AUTH-9 bypass nobody had filed: TokenProvider returns BearerToken, so a provider could hand back an object literal and skip blank-token validation. Three defects review pass 2 found in this phase's own code, each now with a regression test. A rejecting Response.close() REPLACED the primary error on both AUTH-32 paths — a typed, catchable, @throws-documented PlaintextCredentialError silently becoming a teardown failure — in a file written one phase after 5b solved exactly that with releaseQuietly/ withReleaseFailure; both sites now use them, and RECOV-12's "never masks the primary" holds. A one-shot request body skipped the challenge hook entirely, so AUTH-36's eviction never fired and a revoked never-expiring token was re-sent forever; the fast path is deleted and only the replay dispatch is gated on replayability, which is what AUTH-31 actually says. And an unvalidated bearerMarginMs of NaN made every expiry comparison false, serving an expired token indefinitely; both margin doors now validate, per retrySettings' and redirectSettings' precedent. Single-flight no longer hands one caller's AbortSignal to shared work. A coalesced fetch is by definition not owned by one call, so A's abort was cancelling B's token fetch while B's own signal was inert. The shared fetch now takes no caller signal and each caller races the shared promise against its own; TokenProvider is back to the design doc's zero-argument shape, and a provider bounds itself with its own AbortSignal.timeout. A background refresh no longer kills the host process. An earlier shape re-threw an InvariantViolation out of the fire-and-forget catch, reasoning that programmer errors must crash loudly. But the throwable there comes from caller-supplied provider code, and re-raising it into a detached promise terminates the consumer's process asynchronously and unattributably — while the request that triggered it was served the still-valid cached token. AUTH-37 says a failed background refresh MUST NOT fail the in-flight request, full stop. The crash-loudly rule governs our own invariants where we detect them. A non-ASCII Digest realm no longer throws out of the step. Headers.setInbound accepts obs-text and Headers.set rejects it, so echoing a server's UTF-8 realm into Authorization threw HeaderValidationError — meaning AUTH-21's UTF-8 branch hashed correctly but could never reach the wire. parseDigestChallenge now declines such a challenge, so canHandle is false and the 401 surfaces unchanged per AUTH-33; a non-header-safe configured username fails fast at construction instead. RFC 7616 username* (RFC 5987) encoding is deferred and recorded. Review pass 3 read whole files rather than the diff and found a test whose NAME asserted behavior pass 2 had deleted, proving by mutation that it passed under both shapes. The same mutation sweep found four documented behaviors with no test that could fail: the abort-listener cleanup a comment promises, the Proxy-Authorization half of AUTH-28's replay guard — where a proxy credential could go out over plaintext with the suite green — the per-credential margin override, and AUTH-34's own 30-second default, now bracketed at 29999/30001 ms because a one-sided assertion admitted any margin above 20s. isProxy is gone from the handler interfaces: no implementation read it, and AUTH-25's choice lives in the step, where the header name is actually picked. Deferred, each recorded rather than left silent: standardResilience() gains loggingStep in Phase 7b Task 9 — 5c executes first, so an observability/logging-step.js import would not resolve, and the plan's own 2026-07-29 correction says to skip its retrofit blocks; AUTH-37's log-and-continue half, which has nowhere to go until a Logger exists; re-verification of the preemptive-stamping reading at Phase 9; RFC 7616 username*; and a per-operation AuthTiers source, which is unscoped. DigestChallengeUnsupportedError was cut instead — its only justification was a caller driving digestHandler() directly, which is internal, and removing an exported error class later would be a breaking change. open-items.md gains G10..G13: the context family and Step promoted beyond the plan's list as an accepted risk with Phase 7a as the trigger, the cut error leaf, AUTH-37's deferred logging, and two pre-existing cleanups this phase deliberately did not take. 1247 unit tests across 94 files, plus a node-conformance suite for the four runtime-divergent surfaces this phase touches — crypto.subtle.digest against RFC 7616 vectors, crypto.getRandomValues for AUTH-20's client nonce, btoa's UTF-8-vs-Latin-1 encoding for Basic, and the AbortSignal listener add/remove and Promise.race settling order the coalescing race rests on. Every one fails silently rather than loudly if Bun and Node disagree: a wrong digest is still well-formed hex. Full gate sequence green, including api:ci against the regenerated report and test:node on both matrix legs. --- .changeset/2026-08-27-resilience-auth.md | 116 ++ docs/open-items.md | 73 + .../2026-07-26-phase5c-auth-checklist.md | 230 +++ packages/core/api-extractor.json | 8 + packages/core/etc/core.api.md | 300 ++++ packages/core/src/auth/auth-step.test.ts | 1485 +++++++++++++++++ packages/core/src/auth/auth-step.ts | 785 +++++++++ packages/core/src/auth/basic.test.ts | 61 + packages/core/src/auth/basic.ts | 59 + packages/core/src/auth/bearer-cache.test.ts | 643 +++++++ packages/core/src/auth/bearer-cache.ts | 336 ++++ packages/core/src/auth/challenge.test.ts | 222 +++ packages/core/src/auth/challenge.ts | 326 ++++ .../core/src/auth/composing-handler.test.ts | 167 ++ packages/core/src/auth/composing-handler.ts | 106 ++ packages/core/src/auth/credential.test.ts | 193 +++ packages/core/src/auth/credential.ts | 316 ++++ packages/core/src/auth/descriptor.test.ts | 53 + packages/core/src/auth/descriptor.ts | 45 + packages/core/src/auth/digest.test.ts | 474 ++++++ packages/core/src/auth/digest.ts | 487 ++++++ packages/core/src/auth/errors.test.ts | 88 + packages/core/src/auth/errors.ts | 104 ++ packages/core/src/auth/md5.test.ts | 81 + packages/core/src/auth/md5.ts | 154 ++ packages/core/src/auth/preset.test.ts | 196 +++ packages/core/src/auth/preset.ts | 104 ++ packages/core/src/auth/requirement.test.ts | 99 ++ packages/core/src/auth/requirement.ts | 85 + packages/core/src/auth/resolve.test.ts | 161 ++ packages/core/src/auth/resolve.ts | 73 + packages/core/src/auth/scheme.ts | 19 + packages/core/src/auth/static-key.test.ts | 51 + packages/core/src/auth/static-key.ts | 61 + packages/core/src/config/clock.ts | 2 +- packages/core/src/context/context.ts | 22 +- packages/core/src/context/instrumentation.ts | 35 +- .../core/src/http/request-options.test.ts | 30 +- packages/core/src/http/request-options.ts | 66 +- packages/core/src/index.ts | 99 +- packages/core/src/pipeline/builder.test.ts | 113 +- packages/core/src/pipeline/builder.ts | 74 +- packages/core/src/pipeline/runtime.test.ts | 27 +- packages/core/src/pipeline/runtime.ts | 83 +- packages/core/src/pipeline/stage.ts | 6 +- packages/core/src/pipeline/step.ts | 33 +- packages/core/src/redirect/redirect-step.ts | 2 +- packages/core/src/redirect/settings.ts | 19 +- packages/core/src/retry/backoff.ts | 5 +- packages/core/src/retry/retry-step.ts | 28 +- packages/core/src/retry/settings.ts | 2 +- scripts/verify-consumer-types.mjs | 174 +- test/node-conformance/auth.test.mjs | 339 ++++ 53 files changed, 8838 insertions(+), 82 deletions(-) create mode 100644 .changeset/2026-08-27-resilience-auth.md create mode 100644 docs/superpowers/plans/2026-07-26-phase5c-auth-checklist.md create mode 100644 packages/core/src/auth/auth-step.test.ts create mode 100644 packages/core/src/auth/auth-step.ts create mode 100644 packages/core/src/auth/basic.test.ts create mode 100644 packages/core/src/auth/basic.ts create mode 100644 packages/core/src/auth/bearer-cache.test.ts create mode 100644 packages/core/src/auth/bearer-cache.ts create mode 100644 packages/core/src/auth/challenge.test.ts create mode 100644 packages/core/src/auth/challenge.ts create mode 100644 packages/core/src/auth/composing-handler.test.ts create mode 100644 packages/core/src/auth/composing-handler.ts create mode 100644 packages/core/src/auth/credential.test.ts create mode 100644 packages/core/src/auth/credential.ts create mode 100644 packages/core/src/auth/descriptor.test.ts create mode 100644 packages/core/src/auth/descriptor.ts create mode 100644 packages/core/src/auth/digest.test.ts create mode 100644 packages/core/src/auth/digest.ts create mode 100644 packages/core/src/auth/errors.test.ts create mode 100644 packages/core/src/auth/errors.ts create mode 100644 packages/core/src/auth/md5.test.ts create mode 100644 packages/core/src/auth/md5.ts create mode 100644 packages/core/src/auth/preset.test.ts create mode 100644 packages/core/src/auth/preset.ts create mode 100644 packages/core/src/auth/requirement.test.ts create mode 100644 packages/core/src/auth/requirement.ts create mode 100644 packages/core/src/auth/resolve.test.ts create mode 100644 packages/core/src/auth/resolve.ts create mode 100644 packages/core/src/auth/scheme.ts create mode 100644 packages/core/src/auth/static-key.test.ts create mode 100644 packages/core/src/auth/static-key.ts create mode 100644 test/node-conformance/auth.test.mjs diff --git a/.changeset/2026-08-27-resilience-auth.md b/.changeset/2026-08-27-resilience-auth.md new file mode 100644 index 0000000..43900e2 --- /dev/null +++ b/.changeset/2026-08-27-resilience-auth.md @@ -0,0 +1,116 @@ +--- +'@dexpace/core': minor +--- + +Ship the authentication layer (product-spec §11, `AUTH-1`–`AUTH-38`) and promote the pillar-authoring surface +to the public barrel. **This is the first release with new public API since Phase 1.** + +`minor`, not `patch`: `packages/core/etc/core.api.md` gains the whole pipeline-authoring surface plus the auth +configuration types its signatures name, and `RequestOptions` gains one member. Nothing is removed or +narrowed, so no consumer breaks. + +## What a caller can now do + +```ts +import { + ApiKeyCredential, + createAuthDescriptor, + createAuthRequirement, + standardResilience, +} from '@dexpace/core'; + +const client = standardResilience(transport, { + auth: { + credentials: {apiKey: {credential: new ApiKeyCredential(process.env.API_KEY ?? '')}}, + tiers: {client: createAuthDescriptor([createAuthRequirement('API_KEY')])}, + }, +}); +``` + +`standardResilience()` installs redirect, retry, and auth in that order — `AUTH-27`'s "redirect wraps retry +wraps auth" — so auth re-resolves and re-stamps per redirect hop and per retry attempt. `PipelineBuilder`, +`retryStep`, `redirectStep`, and `authStep` are exported for hand-assembling a pipeline instead, and +`PipelineBuilder.seedFrom(runtime, 'flatten' | 'nest')` composes one pipeline onto another with the choice +explicit rather than accidental (`PIPE-35`). + +## What landed + +The scheme-agnostic descriptor/resolver model (`AuthScheme`, `AuthRequirement`, `AuthDescriptor`, +`resolveAuthRequirement`), the credential types (`BearerToken`, `ApiKeyCredential`, `NameKeyCredential`, +`TokenProvider`), a total RFC 7235 challenge parser, a dependency-free MD5, the Basic/Digest/static-key +stamping handlers, a single-flight three-zone bearer token cache, and one AUTH pillar step tying them +together. `RequestOptions` gains `auth?: AuthDescriptor`, which fills `AUTH-4`'s most-specific `perCall` tier. + +Zero runtime dependencies still (`SEAM-1`). SHA-256 and the Digest client nonce go through +`globalThis.crypto`, and Basic stamping through `globalThis.btoa`, never `node:crypto` — the package stays +portable to browsers, Deno, and Workers. MD5 is hand-rolled because Web Crypto deliberately excludes it and +RFC 7616 still requires it for interop. + +## Design calls worth recording + +- **Basic and Digest never stamp preemptively.** Both are phrased in §11 entirely in terms of answering a + parsed challenge, and Digest structurally cannot stamp before seeing the server's `realm`/`nonce`. `OAUTH2` + and `API_KEY` do stamp preemptively; `NO_AUTH` never stamps. Flagged as an interpretation, not a certainty + — Phase 9's conformance sweep re-checks it. +- **One auth step with one pluggable challenge hook, not three mechanisms.** `AUTH-27` mandates exactly one + step, yet `AUTH-30`, `AUTH-23`–`AUTH-26`, and `AUTH-34`–`AUTH-37` read as three. Reconciled as one step, + one `challengeHook` extension point, and a scheme-dependent default body. A caller may override the hook + entirely — for a custom OAuth2 grant, say — and it takes precedence over every scheme default. +- **The cross-origin marker suppresses the WHOLE hop, not just the outbound pass.** The redirect step + (Phase 5b) marks a cross-origin re-issue; the auth step is that marker's intended consumer. It skips the + HTTPS guard, skips stamping, clears the marker so it never reaches the wire — and declines to answer a 401 + on that hop, because answering it would stamp exactly the credential the outbound pass withheld, onto a + server-chosen foreign host. +- **A `TokenProvider` takes no arguments and must carry its own deadline.** `AUTH-34` coalesces every + concurrent caller racing on a missing or expiring token onto ONE fetch, so that fetch belongs to no single + request — handing it one caller's signal would let a stranger's cancellation reject callers who never + aborted, and let a request that merely finished tear down a refresh others were joined to. Each caller + instead races its own wait against its own signal, cancelling the wait without cancelling the work. Because + nothing could ever populate a signal parameter, the type has none: write providers as + `() => fetchToken({signal: AbortSignal.timeout(5_000)})`. +- **`ChallengeHook` receives the call's signal.** Unlike a token fetch, a hook is not shared between callers, + so the same reasoning that withholds the signal above positively requires passing it here — a hook running a + custom OAuth2 refresh grant is network I/O on the request path. The hook's third parameter is optional and + additive: an existing two-argument hook still type-checks. `authStep` also declines to spend a second wire + send on the replay once the caller has aborted, and skips the hook entirely when the call was already + abandoned before the challenge arrived — matching the redirect and retry pillars. +- **A Digest challenge this client cannot echo is declined, not answered.** A received header may legally + carry non-ASCII (`Digest realm="café"` is a real RFC 7616 shape), but an outbound header value may not, and + loosening that is the request-splitting defence. Such a challenge is now reported as unsatisfiable, so the + 401 surfaces unchanged rather than the step throwing. A non-ASCII configured Digest *username* is caller + misconfiguration and is rejected up front; RFC 7616 `username*` encoding is not yet supported. +- **Every credential type is a nominal class that redacts its secret.** `BearerToken`, `ApiKeyCredential`, and + `NameKeyCredential` each hold their secret in a `#` field, so `console.log`, `util.inspect`, + `JSON.stringify`, and `Object.keys` all see a redacted form and never the value. Build them through + `createBearerToken`/the constructors — an object literal is not assignable, which is also what stops a + `TokenProvider` handing back a token that skipped the non-blank validation. +- **One clock for the whole pipeline.** `AuthStepSettings.clock` is the `now()` half of the same `Clock` + `RetryStepOptions.clock` takes, so one instance drives both pillars and a test cannot fake time for one and + forget the other. +- **`challengeHook` is the only challenge-reaction extension point.** There is deliberately no + `handlers` field: the built-in Basic and Digest handlers are internal, so a caller-supplied list could only + replace them wholesale, never compose with them. A hook covers the custom-scheme case with a shape a caller + can actually satisfy. +- **One bearer strategy, not two.** The reference ships a synchronous single-flight policy and a separate + async three-zone policy because it has two pipeline execution stories. This port has one, so the three-zone + policy ships unconditionally and `AUTH-34`'s non-blocking cached read is its fresh-zone branch. Same shape + as the retry engine's `RETRY-28` collapse. +- **`AUTH-31`'s replayability gate applies to every replacement — and gates only the replay.** The reference + gates only its sync step and recommends a port extend it; one unified step leaves exactly one place to apply + it. A non-replayable body skips the re-drive, but the challenge is still handled, so a 401 on a streaming + upload still evicts the token the server rejected instead of leaving it cached for every later request. +- **A refresh margin is validated, and so is a token's expiry.** `bearerMarginMs`, `BearerCredential.marginMs`, + and `createBearerToken`'s `expiresAt` must all be finite. Expiry is evaluated as `now + margin > expiresAt`, + which is `false` for `NaN` — an unvalidated margin (`Number(process.env.MARGIN_MS)` on an unset variable) + made the cache read a long-dead token as fresh and serve it forever without ever calling the provider again. +- **A failed background token refresh can never fail the request that triggered it.** `AUTH-37` says so + unconditionally, so the failure is swallowed unconditionally — including a programmer-error-shaped one. The + alternative re-raised it into a promise nobody awaits, which does not surface at the fault: it terminates the + host process asynchronously, unattributable to any request, while the request that triggered it had already + been served a valid token. +- **A failing response release never masks the error it was unwinding from.** If the challenge hook throws and + closing the 401's body then also fails, the hook's error stays primary and the teardown failure rides along + as `suppressed` (`RECOV-12`), matching the redirect and retry pillars. + +`standardResilience()` leaves the `LOGGING` slot empty — Phase 7b installs `loggingStep()` there and gives +`AUTH-37`'s failed-background-refresh case somewhere to be recorded. `SERDE` stays reserved. diff --git a/docs/open-items.md b/docs/open-items.md index 1a44bbb..188ad82 100644 --- a/docs/open-items.md +++ b/docs/open-items.md @@ -508,6 +508,79 @@ at code that exists where they say it does. --- +### G10 — Phase 5c publishes `Step`, the context family, and a PROVISIONAL `InstrumentationBundle` — **ACCEPTED RISK** + +5c's plan (Task 16 Step 5) lists exactly which symbols the public barrel gains. Two symbol groups had to be +promoted that the list does not name, because promoting `StepContext` and `StepDescriptor` forces them: + +- **`Step`** — `StepDescriptor.fn` is typed `Step`, so the type is reachable from a promoted signature. +- **`ExecutionContext`, `DispatchContext`, `RequestContext`, `ExchangeContext`, `InstrumentationBundle`** — + `StepContext.context` is typed `ExecutionContext`, which is a union ALIAS; api-extractor refuses to analyze + a re-exported union whose members are unexported, and a caller writing a custom step cannot type + `ctx.context` without them. + +**Narrowing `StepContext.context` was considered and rejected.** Cutting it down to `{readonly kind}` would +avoid publishing `InstrumentationBundle` — but `CTX-1` exists precisely so a step can read the exchange's +request and response, and every future logging and serde step needs that. Losing real capability to avoid +publishing a provisional type is the wrong trade. + +**The accepted risk is `InstrumentationBundle.activeSpan` and `.tracerFactory`.** Both are typed `unknown` +pending Phase 7a's tracing adapter. They are now documented as provisional *in the emitted `.d.ts`*, on the +interface and on each member, so a consumer reading either is warned in the same place they read the type. A +consumer warned in the declaration is the honest version of this tradeoff; publishing the type silently was +not. + +**Trigger:** Phase 7a. When the tracing adapter lands and those two members get concrete types, that is a +narrowing of what a caller receives and a widening of what they may pass — a `major` for anyone who read +either field. 7a owns that decision and the changeset wording for it. Phase 10 should confirm the promotion +as a whole was intended. + +### G11 — `DigestChallengeUnsupportedError` was speculative — **CLOSED: cut in Phase 5c** + +The design doc flagged this leaf as speculative and told the plan to cut it if no consumer materialized. None +did: `composingHandler()` returns `undefined` for an unsatisfiable challenge and `authStep()` leaves the 401 +unchanged either way, so nothing in `packages/core` ever constructed or caught it. Its stated reason to exist +— "for a caller driving `digestHandler()` directly" — was self-refuting, because `digestHandler` is internal +and absent from the barrel, so no caller could drive it directly. + +**Cut during Phase 5c's own shape review**, before it ever shipped: the class, its tests, its barrel export, +and the reference in `composing-handler.ts`'s doc comment are all gone. Removing an exported error class is a +breaking change, so doing it now cost nothing and doing it after release would have cost a `major`. If Phase +9's conformance sweep turns up a genuine need, adding it back is a `minor`. + +### G12 — `AUTH-37`'s failed-background-refresh logging is swallowed silently — **DEFERRED to Phase 7b** + +`AUTH-37` makes a failed background refresh non-fatal *and* expects it recorded. `bearer-cache.ts` swallows +the rejection explicitly and UNCONDITIONALLY — a bare `void` would leave an unhandled rejection that +terminates the process under Node's default policy, and the narrowed `catch` that briefly re-threw +`InvariantViolation` did exactly that, asynchronously and unattributable to any request, for a fault in +caller-supplied `TokenProvider` code. (That narrowing is gone; this entry described it for one revision +longer than the code did.) The log half has nowhere to go: no `Logger` seam exists until Phase 7b. + +**Trigger:** Phase 7b, alongside the `loggingStep()` install into `standardResilience()`'s empty `LOGGING` +slot and redirect's own three deferred emission sites. + +--- + +### G13 — two pre-existing cleanups Phase 5c's Reader pass found and deliberately did not take — **DEFERRED** + +Both predate 5c, sit in files Passes 1 and 2 declared settled, and were left alone rather than widening a +review pass into a refactor of earlier phases. + +1. **`hasForbiddenOutboundByte` breaks its own family's naming.** `packages/core/src/http/ascii-validation.ts` + exports `hasForbiddenNameByte`, `hasForbiddenInboundValueByte`, and `hasForbiddenOutboundByte` — the + outbound *value* predicate is the only one that omits `Value`. At `digest.ts:213` and `digest.ts:408` a + reader cannot tell from the call whether the name rule or the value rule is being applied, and the two + differ (HTAB is excepted by one and not the other). `hasForbiddenOutboundValueByte` restores the symmetry; + nine call sites outside the module. +2. **`PipelineBuilder`'s duplicated bucket lookup.** `insertAfter`, `insertBefore`, and `replace` each repeat + the same three lines — `const bucket = this.#buckets.get(anchor.stage);` plus an `invariant` whose message + is identical in all three. One `#requireBucket(stage)` collapses them. + +**Trigger:** whichever phase next edits `ascii-validation.ts` (1) or `pipeline/builder.ts` (2). + +--- + ## Maintaining this file Add an entry the moment a gap is found, not when it is fixed — the failure mode this file prevents is a diff --git a/docs/superpowers/plans/2026-07-26-phase5c-auth-checklist.md b/docs/superpowers/plans/2026-07-26-phase5c-auth-checklist.md new file mode 100644 index 0000000..bdc2db7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-phase5c-auth-checklist.md @@ -0,0 +1,230 @@ +# Phase 5c — Auth Implementation Plan — Checklist + +Verification of [2026-07-26-phase5c-auth.md](./2026-07-26-phase5c-auth.md) against every requirement ID in +`docs/product-spec/11-authentication.md` (`AUTH-1`–`AUTH-38`) plus `PIPE-2`, `PIPE-24`, `PIPE-35`, and +`PIPE-39`, as dispositioned by +`docs/superpowers/specs/2026-07-26-phase5c-auth-design.md`. + +**Status: EXECUTED (2026-08-27).** Every task below is implemented, tested, and green across the full gate +sequence (`typecheck`, `lint`, `build`, `bun test` with coverage, `api:ci`, `lint:publish`, +`verify:dual-consumption`, `verify:consumer-types`, `verify:seam-1`, `verify:runtime-floor`, `test:node`, +`audit`). + +**This is the one phase whose barrel and API report are EXPECTED to change.** Every prior phase asserted +`packages/core/src/index.ts` and `packages/core/etc/core.api.md` byte-identical to its starting point; 5c is +the first point a caller can assemble a working pipeline, so the pillar-authoring surface is promoted here. +See "Public-barrel promotion" below. + +**Phase 7b retrofit deliberately skipped.** The plan carries an amendment installing `loggingStep()` into the +preset's `LOGGING` slot, and its own 2026-07-29 correction says an agent executing this plan must skip those +blocks: 5c runs before 7b, so `observability/logging-step.js` does not resolve at this plan's execution time. +`standardResilience()` installs the three pillars that exist; Phase 7b's plan Task 9 installs the fourth. + +**Legend:** ✅ Implemented and tested — 🚫 Not built (permanent simplification, named reason) — ⏳ Deferred +(named target phase) — N/A Not applicable in this port. + +## Files shipped + +| File | Requirements | Task | +|---|---|---| +| `packages/core/src/auth/errors.ts` | `AUTH-6`, `AUTH-28`, `AUTH-35` | 1 | +| `packages/core/src/auth/scheme.ts` | `AUTH-1` | 2 | +| `packages/core/src/auth/requirement.ts` | `AUTH-2` | 3 | +| `packages/core/src/auth/descriptor.ts` | `AUTH-3` | 4 | +| `packages/core/src/auth/resolve.ts` | `AUTH-4`–`AUTH-7` | 5 | +| `packages/core/src/auth/credential.ts` | `AUTH-8`–`AUTH-11` | 6 | +| `packages/core/src/auth/challenge.ts` | `AUTH-12`, `AUTH-13` | 7 | +| `packages/core/src/auth/md5.ts` | `AUTH-15`, `AUTH-17` | 8 | +| `packages/core/src/auth/basic.ts` | `AUTH-14` | 9 | +| `packages/core/src/auth/digest.ts` | `AUTH-15`–`AUTH-22` | 10 | +| `packages/core/src/auth/static-key.ts` | `AUTH-26` | 11 | +| `packages/core/src/auth/composing-handler.ts` | `AUTH-23`–`AUTH-25` | 12 | +| `packages/core/src/auth/bearer-cache.ts` | `AUTH-34`–`AUTH-37` | 13 | +| `packages/core/src/auth/auth-step.ts` | `AUTH-27`–`AUTH-33`, `AUTH-36`, `AUTH-38` | 14 | +| `packages/core/src/auth/preset.ts` | `PIPE-24`, `PIPE-39` | 16 | +| `packages/core/src/http/request-options.ts` (amended) | `AUTH-4`'s `perCall` tier | 14 | +| `packages/core/src/pipeline/builder.ts` (amended) | `PIPE-35` | 15 | +| `packages/core/src/pipeline/runtime.ts` (amended) | `PIPE-35` (`get transport()`) | 15 | +| `packages/core/src/index.ts` (amended) | public-barrel promotion | 16 | +| `test/node-conformance/auth.test.mjs` | `AUTH-14`, `AUTH-15`, `AUTH-17`, `AUTH-20`, `AUTH-21`, `AUTH-30`–`AUTH-33` on Node | 17 | + +Every production file has a colocated `*.test.ts`. + +`test/node-conformance/auth.test.mjs` was not in the plan's file list. It is required by +`test/node-conformance/README.md`'s membership rule: 5c reaches three runtime-provided globals Bun implements +independently of Node — `crypto.subtle.digest`, `crypto.getRandomValues`, and `btoa` — and every one of them +fails silently rather than loudly if the runtimes disagree. A wrong SHA-256 digest is still a well-formed hex +string; a Latin-1/UTF-8 mismatch in Basic stamping is still valid-looking base64. + +## 11.1 The descriptor/resolver model + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| AUTH-1 | MUST | The scheme set is exactly `{OAUTH2, API_KEY, BASIC, DIGEST, NO_AUTH}`, `NO_AUTH` a sentinel rather than a wire scheme | ✅ | Task 2 — a string-literal union, not a TS `enum` (`erasableSyntaxOnly`). The five members are enforced by the TYPE, exhaustively at every branch (`preemptiveStamp` and `defaultChallengeHook` both close on `assertNever`), which is what makes adding a sixth a compile error rather than a silent fall-through. A companion `AUTH_SCHEMES` array shipped briefly and was cut: nothing enumerated it, and its only test asserted the array's five members against the union's five — the constant restated, not a behaviour | +| AUTH-2 | MUST | A requirement binds one scheme to its own scopes and params; immutable against post-construction mutation of the inputs; value equality over all three | ✅ | Task 3 — `createAuthRequirement` spreads `scopes` and copies `params` into a new `Map`, then freezes; `authRequirementsEqual` compares scheme, ordered scopes, and params. Scope ORDER is part of the value, asserted directly | +| AUTH-3 | MUST | A descriptor is a non-empty ordered preference list, rejects an empty list at construction, is immutable, and reports `allowsAnonymous` iff some requirement is `NO_AUTH` | ✅ | Task 4 — the empty-list rejection is `invariant()`, **not** a typed leaf: a caller assembling zero requirements has a bug, not an operational failure (`docs/knowledge/error-handling.md`'s programmer/operational split). This corrects the design doc's "`ArgumentError` reused from earlier phases" — no such class exists in any prior phase | +| AUTH-4 | MUST | Tier selection is per-call, then operation, then client; the first PRESENT tier is resolved against and a present-but-unsatisfiable tier never falls through | ✅ | Task 5 (`perCall ?? operation ?? client`), asserted with a satisfiable lower tier present under an unsatisfiable higher one. Task 14 gives `perCall` a genuinely per-call source via `RequestOptions.auth` and `StepContext.options` | +| AUTH-5 | MUST | Within the selected descriptor, the first requirement whose scheme is `NO_AUTH` or in the supplied available set wins, without inspecting any concrete credential | ✅ | Task 5 — `availableSchemes` is a `ReadonlySet`, and Task 14's `availableSchemesOf()` derives it from which credentials are configured, so no credential value can reach the resolver | +| AUTH-6 | MUST | All tiers absent is an argument error; an unsatisfiable selected descriptor fails with a distinct error carrying the required schemes in preference order and the available schemes | ✅ | Task 1 (`AuthResolutionError.unsatisfiable`, both lists as `readonly` FIELDS, copied), Task 5. All-tiers-absent is `invariant()`, per AUTH-3's note above; the test asserts it is NOT an `AuthResolutionError` | +| AUTH-7 | MUST | The resolver is stateless, concurrency-safe, and a deterministic pure function | ✅ | Task 5 — a module-level function with no captured state; asserted by identity (the same inputs return the very object the descriptor already holds, so nothing is allocated per call) | + +## 11.2 Credentials + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| AUTH-8 | MUST | Every credential redacts its secret in any string/diagnostic form without corrupting the real fields; bearer tokens have VALUE equality, key credentials REFERENCE identity | ✅ | Task 6 — **all three** credential types are classes holding their secret in a `#` field, each with `toString` AND `Symbol.for('nodejs.util.inspect.custom')`, because `console.log` does not route object arguments through `toString`. `#`, not TS `private`: redaction is a RUNTIME-privacy requirement, and `private` is erased, leaving the secret reachable through `Object.keys`/`JSON.stringify`/default inspect — all three asserted for all three types. `BearerToken` was a bare `{token, expiresAt}` object at first, which redacted NOTHING and failed this requirement outright; it keeps AUTH-8's VALUE equality through `bearerTokensEqual`, a pure function, exactly as the data object did. `ApiKeyCredential`/`NameKeyCredential` deliberately have NO `equals` override, so `===` already gives reference identity. None of the three exposes a public secret accessor: the static keys are read only through the internal `credentialKey()` friend hook, so no secret appears on the published `.d.ts` | +| AUTH-9 | MUST | Secret and identity fields validated non-blank at construction | ✅ | Task 6 — `invariant()` on all four (bearer token, API key, name-key name and key), asserted for `''` and whitespace-only. All three types are NOMINAL with private constructors, so the validation cannot be routed around: a `TokenProvider` returning an object literal no longer type-checks, which was reachable while `BearerToken` was a structural interface | +| AUTH-10 | MUST | Bearer expiry optional (absent = never locally expires), evaluated additively with a grace margin: expired iff `expiresAt` is set and `now + margin > expiresAt` | ✅ | Task 6 (`isBearerTokenExpired`), with the boundary asserted at `now === expiresAt` (NOT expired) and `now === expiresAt + 1` | +| AUTH-11 | MUST | Provider fetch errors propagate, are never cached, and reach an async caller through the async channel, never a synchronous throw | ✅ | Tasks 6 + 13 — the cache does not catch around the provider call, so this falls out of the structure rather than needing a branch; asserted by a rejecting provider followed by a clean refetch. `TokenProvider` takes no parameters at all, because a coalesced fetch belongs to no single call and nothing could correctly populate one — see the Deviation Ledger. A provider that fails SYNCHRONOUSLY (throwing before returning a promise, or returning a non-thenable) is normalized onto the async channel by `invokeProvider`, so `AUTH-38`'s uniform error model holds for it too; left bare it escaped past the background refresh's own `.catch` before that catch was attached | + +## 11.3 Challenge parsing + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| AUTH-12 | MUST | Parse RFC 7235 challenge headers: multiple comma-separated challenges, quoted values containing commas and `=`, backslash escapes, lower-cased scheme and param names, verbatim unquoted values, a bare scheme with an empty map, a token68 under a synthetic key | ✅ | Task 7 — hand-written with a quote-depth scanner, never `.split(',')`. The synthetic key is spelled `'token68'`, the requirement's own wording. A token68's trailing `=` padding (`Negotiate YWJj==`) is disambiguated from an auth-param by requiring a real token or quoted-string value after the `=` — only at the scheme tail, the one position RFC 7235 permits a positional token68 | +| AUTH-13 | MUST | Total: never throws; blank input yields `[]`; a malformed challenge recovers at the next top-level comma; an unterminated quoted string ends at EOF; params before a malformed tail are preserved | ✅ | Task 7 — a `fast-check` property asserts totality over arbitrary strings; two more assert quoted-comma non-splitting and single-challenge round-tripping. A comma inside a MALFORMED segment's quoted value is also not a recovery point, asserted separately | + +## 11.4 Stamping handlers + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| AUTH-14 | MUST | `Basic ` + base64(UTF-8(`username:password`)), computed once; `basic` accepted case-insensitively; `Authorization`/`Proxy-Authorization` chosen by the caller from which challenge header the status carried; credentials non-empty but whitespace PERMITTED per RFC 7617 | ✅ | Task 9 — the value is computed at construction and closed over; the laxer non-empty rule is deliberately NOT the credential types' `.trim()` check, asserted both ways. Case-insensitivity is implemented in `parseChallenges`, which lower-cases the scheme before any handler sees it | +| AUTH-15 | MUST | Digest supports exactly `{MD5, MD5-sess, SHA-256, SHA-256-sess}`, `qop=auth` or absent; declines `auth-int`-only, unsupported algorithms, and mutual-auth verification | ✅ | Task 10 — `SUPPORTED_ALGORITHMS` is the closed set; `auth-int`-only and `MD4` both asserted declined. Mutual auth (`Authentication-Info`) is never emitted or verified, which is the requirement's own disposition | +| AUTH-16 | MUST | Satisfiable iff scheme is `digest`, `realm`+`nonce` present, `qop` absent or containing `auth`, algorithm supported or absent (defaulting MD5), preferring the algorithm earliest in the CONFIGURED list regardless of wire order | ✅ | Task 10 (`parseDigestChallenge` + `rank`), Task 12 (`composingHandler` sorts by handler order then `rank`). `rank` is a plan-time addition to `ChallengeHandler`: `canHandle` alone answers yes/no per challenge and cannot express a preference among several a handler could equally satisfy — which is exactly what RFC 7616's repeated-challenge algorithm discovery produces | +| AUTH-17 | MUST | HA1/HA2/response per RFC 7616/2069, lower-case hex of the selected algorithm | ✅ | Tasks 8 + 10 — `computeDigestResponse` is exported and unit-tested against five independently-computed vectors (MD5 qop, MD5 no-qop, MD5-sess, SHA-256, SHA-256-sess) because `stamp()` draws a fresh random cnonce and can never be pinned end-to-end. MD5 is hand-rolled (Web Crypto excludes it) and checked against RFC 1321's own vectors plus the 55/56/64-byte padding boundaries | +| AUTH-18 | MUST | `nc` tracked per server nonce, starting at `00000001`, incrementing only on reuse, rendered as exactly 8 lower-case hex digits, low 32 bits on overflow | ✅ | Task 10 (`NonceCountStore`) — a `fast-check` property asserts strict monotonicity for a fixed nonce; a distinct nonce asserted to start fresh; a no-`qop` stamp asserted NOT to consume a count | +| AUTH-19 | SHOULD | The per-nonce store is bounded (default 1024) and drained under the cap; evicting a live nonce is harmless | ✅ | Task 10 — an insert-then-DRAIN-IN-A-LOOP, not a pre-insert single evict, per `docs/knowledge/concurrency-and-async.md`'s XCUT-14 rule for a server-keyed map. The distinguishing test bursts 4096 fresh nonces and asserts the map is at exactly the cap after every admit — a single-victim-per-insert store passes the "an evicted nonce restarts at 1" probe but fails this one | +| AUTH-20 | MUST | The client nonce comes from a cryptographically strong source with ≥128 bits of entropy | ✅ | Task 10 — `globalThis.crypto.getRandomValues()` over 16 bytes, never `Math.random()`; asserted 32 hex characters and distinct across calls, on Bun and again on Node | +| AUTH-21 | MUST | UTF-8 hash input when the challenge advertises `charset=UTF-8`, ISO-8859-1 otherwise | ✅ | Task 10 — asserted by a non-ASCII password hashing differently under the two, and identically for an all-ASCII input, on both runtimes | +| AUTH-22 | MUST | Quote/escape the appropriate fields, leave `qop`/`nc`/`algorithm` unquoted with the full algorithm spelling, use the request-target as the digest-uri, emit `cnonce`/`nc`/`qop` only when `qop` is negotiated | ✅ | Task 10 — `opaque` is echoed back quoted when the challenge carried one and omitted entirely otherwise (RFC 7616 requires the client return it unchanged; a server binding state to it rejects a request without it). A quote inside a realm asserted escaped. The digest-uri is `pathname + search`, asserted through the pillar step against `/a?q=1` | +| AUTH-23 | MUST | Composed handlers delegate to the first handler in DECLARATION order whose can-handle passes; the handler list is defensively copied | ✅ | Task 12 — handler order is the primary sort key and beats wire-order challenge position, asserted with `basic` first on the wire and `digest` first in configuration. A handler pushed onto the caller's array after construction is asserted invisible | +| AUTH-24 | MUST | Handlers are safe for concurrent invocation; a per-handler mutable counter such as Digest's `nc` yields correct, non-duplicated counts under concurrent reuse of one nonce | ✅ | Task 10 — `next()` is one synchronous read-increment-write with no `await` between the read and the write. Node and Bun have no preemptive interleaving mid-statement, so "thread-safe primitives" collapses to that, the same collapse 5a documented for `BODY-3` | +| AUTH-25 | MUST | `Authorization` for `WWW-Authenticate`, `Proxy-Authorization` for `Proxy-Authenticate`, selected by an explicit proxy flag; no header at all when nothing is satisfiable | ✅ | Task 12 (handlers return the VALUE half only), Task 14 (`pickChallengeHeader` reads only the header matching the STATUS, so a 401 carrying a stray `Proxy-Authenticate` is not answered — asserted). A 407 answered into `Proxy-Authorization` with `Authorization` absent is asserted end-to-end. The "explicit proxy flag" is `ChallengeSelection.isProxy` inside `auth-step.ts`, consumed by `answerHeaderName`; it is NOT threaded into `ChallengeHandler.stamp`, which was tried and removed — see the Deviation Ledger. `AUTH-28`'s replay guard covers `Proxy-Authorization` as well as `Authorization`, asserted separately | +| AUTH-26 | MUST | A static key is written into the configured header (default `Authorization`), prefixed by the configured prefix and exactly one space; stateless after construction | ✅ | Task 11 — uniform over both credential shapes; `NameKeyCredential.name` is deliberately NOT consulted as a header name (it is the non-secret half of the redacted `toString`; a caller wanting it passes `headerName` explicitly). An explicitly-empty prefix still contributes its space, so the option's absent state stays reachable | + +## 11.5 The AUTH pillar step + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| AUTH-27 | MUST | Exactly one auth step at the single AUTH pillar stage, nested inside both the redirect and retry loops, so auth executes per redirect hop and per retry attempt | ✅ | Task 14 — `stage: 'AUTH'` is baked into the descriptor, so PIPE-36's "not relocatable out of its pillar" holds structurally; `PILLAR_STAGES` already caps the slot at one. Task 16's preset installs redirect-then-retry-then-auth, asserted by flattened stage order | +| AUTH-28 | MUST | On ANY path where a credential will be attached, reject a non-HTTPS URL (case-insensitive) BEFORE any token fetch or header stamping, with an error naming the concrete step and the offending scheme | ✅ | Task 14, **both paths**. Outbound: skipped for `NO_AUTH` (matching the requirement's own qualifier), asserted to fire before the provider is called. Replay: the outbound guard is skipped entirely for `NO_AUTH` and nothing constrains a caller hook to preserve the URL, so a replacement carrying a credential header is guarded again — and the challenge response is closed before the throw, so the body is not leaked | +| AUTH-29 | MUST | A cross-origin re-issue marked by the redirect step is not stamped, has the internal marker stripped so it never reaches the wire, and skips the HTTPS guard; a same-origin re-issue is re-stamped normally. The mechanism can only SUPPRESS, never force | ✅ | Task 14 — the marker is read first and cleared unconditionally before either branch, so it cannot survive into a request built by the stamping logic. **Both halves**: the outbound suppression AND the challenge-reaction suppression — a marked hop returns its 401 untouched and unclosed, because answering it would stamp exactly the credential the outbound pass declined to send, onto a server-chosen foreign host, over a URL whose HTTPS guard was skipped. Suppress-only holds structurally: nothing reads the marker to cause a stamp. Joint conformance in Task 16 | +| AUTH-30 | MUST | A 401 with `WWW-Authenticate` consults the challenge hook; a non-null replacement closes the original and drives once through a fresh chain copy, with no further challenge handling; the default hook yields no replacement | ✅ | Task 14 — reconciled as ONE step with one pluggable hook and a scheme-dependent default body, not three mechanisms. Every dispatch goes through a fresh `ctx.fork()`. A second 401 on the replay is returned as-is (asserted: exactly two wire sends, not a loop). `API_KEY`/`NO_AUTH` never react, which is the requirement's literal "the default hook yields no replacement" | +| AUTH-31 | MUST | The replay is gated on body replayability: a non-replayable replacement skips the replay, surfaces the original unchanged, and MUST NOT close it | ✅ | Task 14, applied **uniformly** — the reference gates only its sync step and recommends (SHOULD) a port extend it; one unified step leaves exactly one place to apply it, closing that SHOULD. The gate covers the DISPATCH only, for both hook shapes: the hook always runs, and its replacement is then gated on `body.replayable`. There is deliberately no "skip the hook for a one-shot body" fast path — one shipped briefly and skipped `AUTH-36`'s eviction with it, which is recorded against `AUTH-36` below. Asserted for the default hook and for a caller hook returning a non-replayable replacement, including the response left uncancelled | +| AUTH-32 | MUST | A hook that throws, rejects, or throws synchronously closes the open 401 before propagating | ✅ | Task 14 (`runHook`) — asserted for a rejecting hook AND a synchronously-throwing one, both on Bun and on Node. The close goes through 4b's `releaseQuietly`/`withReleaseFailure`, not a bare `await response.close()`: `Response.close()` rethrows whatever cancelling the body raised, so the bare form discarded the hook's own error and surfaced the teardown failure in its place — the inversion `RECOV-12` forbids, and the one 5b's `decideOrClose` already guards against. `guardReplayScheme` was fixed the same way, where the masked error was `PlaintextCredentialError`. Asserted against a body whose `cancel()` rejects | +| AUTH-33 | MUST | A 401 without `WWW-Authenticate` is returned unchanged without consulting the hook | ✅ | Task 14 — asserted for a bare 401, for a 401 carrying only `Proxy-Authenticate`, and for a hook returning `undefined`; all three leave the response uncancelled | +| AUTH-38 | SHOULD | The HTTPS-guard failure and any hook error are delivered through the async channel, not a synchronous throw | ✅ | Task 14, satisfied structurally: the step's `fn` is `async`, so both become a rejected promise with no separate code path. Asserted by a hook that throws synchronously still surfacing as a rejection | + +## 11.6 The bearer token cache + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| AUTH-34 | MUST | Stamp `Authorization: Bearer ` from a token cached until a configurable refresh margin (default 30 s) before expiry; concurrent requests racing on a missing/expiring token yield at most one provider fetch, with a non-blocking hot-path read of a valid cached token | ✅ | Task 13 — one unified policy, not two stacks; the hot-path read is the fresh-zone branch of `AUTH-37`'s state machine. See the Deviation Ledger. The 30 s default is `AuthStepSettings.bearerMarginMs`; single-flight coalescing asserted at exactly one provider invocation for two concurrent callers — and, separately, for a burst of concurrent POST-EVICTION refreshes, which an earlier `refreshNow()` shape turned into one provider call per 401 (a mass revocation would have stampeded the identity provider); the method is now named `refreshPostEviction()`, because it may JOIN a sibling 401's fetch and what it actually guarantees is that no pre-eviction fetch is ever joined. The 30 s default is pinned from BOTH sides — a token expiring just inside it refreshes, one just outside it does not — since a single one-sided assertion cannot tell 30 s from 60 s, and `BearerCredential.marginMs`'s override and an explicit `0` are each asserted for EFFECT, not only for validation. The margin is validated as a finite, non-negative duration at BOTH doors (`bearerMarginMs` and `BearerCredential.marginMs`) and `createBearerToken` rejects a non-finite `expiresAt`: `nowMs + marginMs > expiresAt` is false for `NaN`, so an unvalidated margin made the cache read a long-dead token as fresh and serve it from the hot path forever, never calling the provider again | +| AUTH-35 | MUST | Reject a null token and a token already expired at fetch time (no margin); never cache a thrown provider error | ✅ | Task 13 — the null guard is a RUNTIME check at a deliberately widened boundary, because a plain-JS caller can return null regardless of `TokenProvider`'s non-nullable type. A rejection propagates through `finally` untouched, so nothing is cached; asserted by a clean refetch after each failure mode | +| AUTH-36 | MUST | On a 401 advertising a Bearer challenge, evict ONLY the exact cached token that produced it (matched on the stamped header value), re-stamp a single retry with a freshly fetched token, preserve a token another request already refreshed, surface the 401 unchanged when the rejected request carried no `Authorization` or the response advertises no Bearer challenge, and fire regardless of HTTP method | ✅ | Tasks 13 + 14 — `evict()` compares `` `Bearer ${cached.token}` `` to the rejected header value and RETURNS the survivor on a mismatch, which the hook then stamps. That return is what makes the preservation clause observable: the first shape preserved the token and then unconditionally fetched a replacement, overwriting it on the next tick and reducing the clause to a no-op. Asserted end-to-end with a gated two-drive interleaving — the second 401 stamps the preserved token and the provider is called twice, not three times. No method check exists anywhere on this path; AUTH-31's replayability gate is what protects a non-replayable body — and that gate now covers the DISPATCH only. An earlier shape short-circuited the whole hook for a one-shot body, which skipped the eviction too and left the token the server had just rejected in the cache; with `AUTH-10`'s never-expiring token that never aged out either, so a stream-only client re-sent the dead credential indefinitely. Asserted by two successive one-shot POSTs, where the second carries a freshly fetched token | +| AUTH-37 | MUST | A three-zone expiry policy without blocking: fresh stamps with no refresh; expiring-but-valid stamps immediately and kicks off a background refresh; expired/missing awaits a single-flight fetch; concurrent expiring/missing callers coalesce; a failed fetch is not cached; a failed BACKGROUND refresh is non-fatal | ✅ | Task 13 — all three zones asserted separately. The background rejection is swallowed EXPLICITLY and UNCONDITIONALLY: a bare `void` would leave an unhandled rejection that terminates the process under Node's default policy, and the narrowed catch that briefly rethrew `InvariantViolation` did exactly that for a fault in caller-supplied provider code — see the Deviation Ledger. A synchronously-failing provider is normalized onto the async channel first, so the non-fatal guarantee is not conditional on HOW the provider failed. The post-eviction path is `refreshPostEviction()`. It does NOT bypass coalescing — an earlier shape did, and turned a mass revocation into one provider call per 401 — it supersedes only fetches predating this eviction burst, joining a sibling 401's fetch at the same generation (see the `AUTH-34` row). A generation counter also stops a superseded fetch re-caching its token if it settles LAST | + +## Cross-phase requirements closed here + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| PIPE-2 | MUST | The pillar precedence chain, and specifically that auth executes per redirect hop and per retry attempt | ✅ | Task 16's joint conformance test — a `standardResilience()` runtime over a scripted `302 (cross-origin), 302 (back to seed origin), 200`: the credential is present on hop 1, ABSENT on the cross-origin hop, and RE-STAMPED on the return to the seed origin. 5a's own suite already covers the per-attempt dimension | +| PIPE-24 | MUST | The standard-resilience preset installs into empty pillar slots only, rejecting the whole call if any is occupied | ✅ *(satisfied VACUOUSLY — see the Deviation Ledger)* | Task 16 — true BY CONSTRUCTION: `standardResilience()` takes a `Transport` and always starts from a fresh `PipelineBuilder`, so no slot can be occupied. **The requirement's validate-and-reject half has no implementation, because no input can reach it** — that is recorded as a deviation rather than left implied, so Phase 9 does not look for a check that was never written. A caller layering the preset onto a customized builder reaches for `seedFrom` instead | +| PIPE-35 | SHOULD | Two unambiguous ways to seed from an existing pipeline — FLATTEN (copy steps and transport, same loops) vs NEST (opaque transport, separate loops) — with the choice explicit, never accidental (MUST) | ✅ | Task 15 — `mode` has no default value, so a caller cannot seed by accident. `flatten` re-buckets by each descriptor's OWN stage (asserted against seeded array position) and pillar collisions apply exactly as any append sequence. `nest` sets the runtime as the transport, asserted by the outer step running before the inner one and by both layers occupying the same pillar independently. `Runtime.transport` was added to make flatten implementable at all | +| PIPE-39 | SHOULD | Convenience constructors including a standard pipeline installing the default resilience pillars | ✅ | Task 16 — `standardResilience()` installs redirect (through 5b's `withRedirect`, which seats the `POST_AUTH` marker guard alongside), retry, and auth. Four descriptors total, asserted | + +## Public-barrel promotion + +`packages/core/src/index.ts` gains two groups, and `packages/core/etc/core.api.md` is regenerated to match. + +**Both pass conditions failed on the first attempt and are now enforced mechanically rather than asserted.** +This section previously claimed zero `ae-forgotten-export` warnings and a compile-only consumer smoke check +covering the promoted surface. Neither was true: the committed report carried a live +`ae-forgotten-export` for `ExecutionContext`, and `scripts/verify-consumer-types.mjs` still exercised only +Phase 3's body/response surface. The cause was a single word — an `@internal` token inside the prose comment +above the barrel's context-family export, which `stripInternal` (inherited from `gts/tsconfig-google.json`) +takes as an instruction to delete the whole export from the emitted `.d.ts`. `typecheck`, `build`, and +`api:ci` all passed over it, because api-extractor recorded the warning as report TEXT rather than failing. + +Both are now gates, not claims: + +- `packages/core/api-extractor.json` sets `ae-forgotten-export` to `logLevel: "error"` with + `addToApiReportFile: false`, so a forgotten export FAILS `api:ci` instead of being written into the report. +- `scripts/verify-consumer-types.mjs` compiles a consumer that names every promoted symbol — the context + family included — builds an `AuthStepSettings`, assembles both a hand-built `PipelineBuilder` pipeline and + a `standardResilience()` client, and annotates a custom `Step`, all importing ONLY from the package entry + point on the declared `lib` with `types: []`. + +- **Group 1, the authoring surface:** `Stage`, `STAGE_ORDER`, `PILLAR_STAGES`, `Step`, `StepContext`, `Next`, + `StepDescriptor`, `PipelineBuilder`, `Runtime`, `retryStep`, `redirectStep`, `authStep`, + `standardResilience`. +- **Group 2, everything those signatures name:** `RetryStepOptions`, `RetrySettings`, `BackoffSettings`, + `Clock`, `RedirectSettings`, `RedirectPredicate`, `RedirectCondition`, `StandardResilienceOptions`, + `AuthStepSettings`, `AuthCredentialSet`, `BasicCredential`, `DigestCredential`, `BearerCredential`, + `ApiKeyCredentialConfig`, `ChallengeHook`, `AuthTiers`, `AuthScheme`, `DigestAlgorithm`, `AuthDescriptor`, + `AuthRequirement`, `TokenProvider`, the factories + `createAuthDescriptor`/`createAuthRequirement`/`createBearerToken` and the equality helpers beside them, the + `ApiKeyCredential`/`NameKeyCredential`/`BearerToken` classes (all three NOMINAL — they carry a `#` field, so + no object literal substitutes, `API_KEY` would otherwise be unreachable, and a `TokenProvider` cannot return + a hand-built token that skips `AUTH-9`'s validation), and the two error leaves + `AuthResolutionError`/`PlaintextCredentialError`. +- **Deliberately NOT promoted, after review:** `Challenge`, `ChallengeHandler`, and `DigestUriContext`. They + were only reachable because `AuthStepSettings` carried a public `handlers` field — a field that promised + composability the package does not offer, since `basicHandler`/`digestHandler` stay internal, so supplying + one handler silently LOST the credential-derived ones. `handlers` was removed and the three types went back + to internal. `challengeHook` covers the custom-scheme case with a shape a caller can actually satisfy. + `DigestChallengeUnsupportedError` was cut for the same reason (`docs/open-items.md` G11). +- **Not in the plan's list, promoted as a forced consequence:** `Step`, and the whole context family + (`ExecutionContext`, `DispatchContext`, `RequestContext`, `ExchangeContext`, `InstrumentationBundle`). + `StepDescriptor.fn` names `Step`, and `StepContext.context` names `ExecutionContext`, which is a union + alias — api-extractor refuses to analyze it while its members are unexported. Promoting `StepContext` + without them would leave a caller unable to type a custom step's `ctx`. Narrowing `StepContext.context` + instead was considered and rejected — `CTX-1` exists so a step can read the exchange's request and response. + Recorded as an ACCEPTED RISK in `docs/open-items.md` G10, with `InstrumentationBundle`'s two provisional + `unknown` members documented as provisional in the emitted `.d.ts` itself. +- **Still internal:** everything else under `src/auth/` — `parseChallenges`, `md5.ts`, `basicHandler`, + `digestHandler`, `NonceCountStore`, `computeDigestResponse`, `composingHandler`, `BearerTokenCache`, + `stampStaticKey`, `availableSchemesOf`, `AUTH_STEP_TYPE` — plus 5b's `withRedirect`, + `stripCrossOriginMarkerStep`, and the cross-origin marker functions. A caller BUILDS an `AuthStepSettings` + from the Group 2 factories and hands it to `authStep()`/`standardResilience()`; it never constructs handler + internals. + +`RequestOptions` gains exactly one member, `auth?: AuthDescriptor`, and a matching builder method. + +## Deviation Ledger (for Phase 10) + +| Deviation | Reference behavior | Justification | +|---|---|---| +| One bearer strategy (async three-zone), not two | The reference ships a sync single-flight strategy and a separate async three-zone strategy | This port has one `Promise`-only execution model (4c), so `AUTH-34`'s hot-path read is a branch of `AUTH-37`'s state machine, not a second stack. Same reasoning and shape as 5a's `RETRY-28` collapse | +| `AUTH-31`'s replayability gate applied uniformly | The reference applies it on the sync auth step only, and SHOULDs a port extend it | One unified step leaves exactly one place to apply it; closes the spec's own SHOULD | +| Basic and Digest never stamp preemptively | Not stated either way in §11; inferred from `AUTH-14`/`AUTH-23`–`AUTH-25`'s exclusively challenge-driven phrasing | Digest structurally cannot stamp before seeing `realm`/`nonce`, and no separate "preemptive Basic" ID exists to contradict treating both uniformly. Flagged as an interpretation, not a certainty — Phase 9's conformance sweep should re-check it against any reference fixtures it turns up | +| `AUTH-3`/`AUTH-6` construction failures use `invariant()`, not a typed leaf | The design doc assumed an `ArgumentError` "reused from earlier phases" | No such class exists in any prior phase. Both cases are PROGRAMMER errors under `docs/knowledge/error-handling.md`'s split, which requires `invariant`/`assertNever`, not a handled error. A plan-time fix, not a deviation from working code | +| `TokenProvider` takes NO parameters; cancellation is caller-side, never provider-side | The reference's provider also takes no cancellation, so this ends up matching it exactly | Cancellation of a token fetch is caller-side by construction. `AUTH-34` makes the fetch SHARED by every caller coalesced onto it, so it is owned by no single call: handing it one caller's signal (a plan-time addition, shipped briefly as an optional `{signal}` bag) let a stranger's abort reject callers who never aborted — including one who supplied no signal at all — and let a request that merely finished tear down a refresh other requests were joined to. `bearer-cache.ts` races each caller's own WAIT against that caller's own signal instead, cancelling the wait without cancelling the work. Since nothing can ever populate a signal parameter, the parameter was cut rather than left documented-as-never-filled: a slot a caller writes code against and then finds inert is worse than no slot. `docs/knowledge/concurrency-and-async.md`'s "pass the caller's signal down to the I/O primitive" rule is deliberately not applied, because its premise — that the call owns the I/O — is false for a coalesced fetch; its "every external I/O call must carry a deadline" rule is discharged by `TokenProvider`'s TSDoc making an `AbortSignal.timeout` the provider's own obligation. **The type is back to the design doc's original shape**, `() => Promise` | +| `ChallengeHook` takes an options bag carrying the call signal | The reference's hook takes only the response and request | The hook is the sanctioned place for a custom OAuth2 refresh-token grant — network I/O on the request path — and unlike the token fetch it is NOT shared between callers, so the same rule that forbids handing a coalesced fetch one caller's signal positively requires handing the hook exactly that. Without it a hung hook pinned the auth step, every retry attempt nested under it, and the whole request. The parameter is optional and third, so an existing two-argument hook still type-checks. `authStep` checks the signal at two further points: BEFORE building or running the hook, so a call already abandoned when the challenge arrives never spends the default hook's IdP round trip (matching `redirectStep`'s pre-hop check), and again before the replay dispatch, for an abort that arrived while the hook was in flight. Both reads go through an `isAborted()` helper rather than an inline test — `AbortSignal.aborted` is a live getter, but TypeScript narrows it like an ordinary property and carries that narrowing across the `await`, so the second check does not compile when written inline. That is the compiler being confidently wrong about mutable external state, and `concurrency-and-async.md`'s re-validate-after-await rule is the one that governs | +| A Digest challenge whose echoed fields are not header-safe is DECLINED, not answered | `AUTH-22` says to quote and echo `realm`/`nonce`/`opaque`; it does not say what to do when they cannot be written | `HTTP-19` lets a received field-value carry obs-text, so `Digest realm="café"` — a real RFC 7616 shape, and the reason the spec has a `charset` parameter at all — arrives intact; `HTTP-18`'s outbound grammar will not let it back out, and relaxing that is off the table because it is the request-splitting defence. Building the header anyway threw `HeaderValidationError` out of the whole auth step, converting a challenge the caller could have inspected into an exception. `parseDigestChallenge` now declines, so `canHandle` is false and `AUTH-33` surfaces the 401 unchanged. **The consequence: `AUTH-21`'s UTF-8 branch is reachable for the HASH INPUT (a non-ASCII password works) but not for the realm ECHO.** A non-ASCII configured *username* is caller misconfiguration rather than wire data, so `digestHandler()` rejects it at construction instead | +| A failed background refresh is swallowed unconditionally, `InvariantViolation` included | `AUTH-37` says a failed background refresh MUST NOT fail the in-flight request (log-and-continue) | An earlier shape re-threw `InvariantViolation` from the fire-and-forget `.catch`, reasoning that a programmer error must crash loudly. That was wrong twice: the throw landed in a promise nobody awaits, so it did not surface at the fault — it killed the host process asynchronously, unattributable to any request, while the request that triggered it had already been served a valid token; and the fault it re-raised belongs to caller-supplied `TokenProvider` code, where a blank token is an operational fault (an empty environment variable, a malformed IdP payload) at least as often as a coding one. `docs/knowledge/error-handling.md`'s crash-loudly rule governs OUR invariants at the point WE detect them; it does not license re-raising someone else's failure into a detached promise | +| `docs/knowledge/error-handling.md` forbids "log and continue"; `AUTH-37` mandates it | `error-handling.md:22` — "a `catch` block must end in exactly one of three ways … 'log and continue' is none of these" | Standing, unresolved conflict between the styleguide and the normative spec, resolved in the spec's favour: `AUTH-37`'s clause is explicit and unconditional. The catch is blanket rather than narrowed to one expected type, which `error-handling.md:24` would also prefer otherwise, because the set of failures a caller-supplied provider can raise is not enumerable by this module. The LOG half is still missing and is tracked as a deferred item against Phase 7b | +| Duplicate auth-params within one challenge are last-wins | `AUTH-12` is silent on duplicates | RFC 7235's grammar does not admit them, so any input reaching this case is already malformed and `AUTH-13`'s leniency governs. `Map.set` gives last-wins for free; recorded because it is an unforced choice, not a derived one, and because parameter names are lower-cased first, so `realm` and `REALM` collide | +| `ChallengeHandler.stamp()` is async and takes an optional request context; `rank()` added | The design doc's prose gives `stamp()` a synchronous `string` return and no `rank` | SHA-256 Digest goes through `crypto.subtle.digest()`, which is asynchronous with no synchronous fallback; HA2 needs the method and request-target, which the challenge does not carry. `rank` is what expresses `AUTH-16`'s configured-preference-over-wire-order among several challenges one handler could equally satisfy | +| A generation counter guards the bearer cache against a superseded fetch | Not described either way | `refreshPostEviction()` drops the in-flight slot on the supersede branch, but the older fetch's own `then` would still publish its token into `cached` — re-caching exactly the token the server rejected whenever it settles after the fresh one. The counter is what makes `AUTH-37`'s "so the retry never re-sends the rejected token" hold in both resolution orders | +| `standardResilience()` installs only REDIRECT/RETRY/AUTH, not LOGGING | `docs/knowledge/pipeline.md`'s preset description includes instrumentation | Phase 7b has not shipped at this plan's execution point, and the plan's own 2026-07-29 correction routes the fourth `append` to 7b's Task 9. A scope boundary, not an omission | +| No async-variant preset | The reference's async standard pipeline (retry + instrumentation + caller-supplied scheduler) | 4c already dispositioned this port as one `Promise`-only execution model; there is no second pipeline to give a second preset to | +| The context family and `Step` promoted to public | Not addressed by the plan's Group 2 list | Forced by api-extractor: `StepDescriptor.fn` names `Step` and `StepContext.context` names the `ExecutionContext` union. See "Public-barrel promotion" above | +| `PIPE-24`'s validate-and-reject clause is structurally inexpressible here | `PIPE-24` requires the preset to validate up front that no target pillar is occupied and to reject the whole call, installing nothing, if any is | `standardResilience()` takes a `Transport`, not a builder or an existing pipeline, so it always starts from a fresh `PipelineBuilder` and no slot CAN be occupied. The requirement is satisfied vacuously — there is no code path implementing the validation, because there is no input that could fail it. Recorded so **Phase 9 does not hunt for a check that was never written**. A caller layering the preset onto a customized builder uses `PipelineBuilder.seedFrom(runtime, 'nest' \| 'flatten')`; if a future signature ever accepts a pre-populated builder, the validation becomes both expressible and mandatory | +| Helper functions sit ABOVE their callers in `auth-step.ts`, `bearer-cache.ts`, `digest.ts`, `challenge.ts` | `docs/knowledge/function-design.md` requires the step-down rule — each function above the functions it calls | Bottom-up (primitives first, the exported factory last) is the established shape of every step module since 5a's `retry-step.ts` and 5b's `redirect-step.ts`, and these four read as one family with them. Inverting four files to satisfy the rule costs more than the rule buys, and would leave 5c's modules the only ones ordered differently from their siblings. What WAS fixed is the inconsistency: `handleChallenge` was the single helper sitting below its caller, and now sits above it like the other sixteen | +| Several three-parameter functions take positional parameters, not an options object | `docs/knowledge/function-design.md` requires an options object at three or more parameters — stricter than this repo's `max-params: 3`, which they all pass | `createAuthRequirement(scheme, scopes, params)`, `digestHandler(username, password, options)`, `isBearerTokenExpired(token, nowMs, marginMs)` and `AuthResolutionError`'s `(message, requiredSchemes, availableSchemes)` all read unambiguously at their call sites, and every parameter is a distinct type, so no call can silently transpose two. The rule's real target is the boolean flag and the same-typed neighbour; both were fixed where they occurred — `hashHex` now takes a `HashInput` and `BearerTokenCache.refresh` takes an eviction generation rather than a `postEviction` boolean | +| `stamp` is the module's verb for a computation that RETURNS credential material rather than writing it | `docs/knowledge/naming-conventions.md` bars inventing a verb outside the client-verb taxonomy; `get`/`acquire` would be the sanctioned spellings | Seven symbols already share the vocabulary (`ChallengeHandler.stamp`, `ComposingHandler.stamp`, `stampStaticKey`, `BearerTokenCache.stamp`, `preemptiveStamp`, `stampContext`, and this checklist's own prose), and renaming half of them would leave the module reading in two dialects — worse than either end state. The vocabulary is now DEFINED once, in `ChallengeHandler`'s TSDoc: "to STAMP means to PRODUCE the value the caller writes, never to write it" | +| `ChallengeHandler.stamp` / `ComposingHandler.stamp` take no `isProxy` flag | `AUTH-25` phrases the origin-vs-proxy choice as "an explicit proxy flag" | The flag existed and was threaded from `auth-step.ts` through the composer into both handlers, and NEITHER read it: Basic's value is computed once at construction, and Digest's depends only on the challenge and the request-target. The only tests either could carry for it were tests asserting it changed nothing, which is a parameter kept alive by its own coverage. `AUTH-25`'s flag is still explicit — it is `ChallengeSelection.isProxy`, consumed by `answerHeaderName` in `auth-step.ts`, which is the one place that knows which challenge header the status carried. A future scheme whose VALUE varies by proxy-ness adds it back, and would then have something to assert | +| `AUTH-6`'s all-tiers-absent failure escapes the `DexpaceError` tree | `AUTH-6` calls for "an argument error" | It is an `InvariantViolation`, which extends `Error` directly, is internal-only, and is absent from the barrel — so a caller **cannot narrow on it**, and `catch (e) {if (e instanceof DexpaceError)}` misses it entirely. Sanctioned by the plan's Global Constraints (a caller with no tier configured has a bug, not an operational failure) and precedented by 5a's `retrySettings()` and 5b's `redirectSettings()`; recorded here because the residue is caller-visible, not merely internal. `authStep`'s `@throws` block DOES name it, precisely because a caller cannot narrow on it: the tag is the only place the failure mode is discoverable, so leaving it out would have hidden the one thing this row exists to record. (An earlier revision of this row claimed the opposite; the code was always the honest half.) | + +## Deferred Items (add to the roadmap's Deferred Items Log) + +| Item | Deferred from | Target | Reason | +|---|---|---|---| +| `standardResilience()` gains a `LOGGING` pillar step | Phase 5c | Phase 7b (Task 9) | No real logging step exists at 5c's execution point; the preset grows by one `append` | +| `AUTH-37`'s "log-and-continue" half for a failed background refresh | Phase 5c | Phase 7b | The failure is swallowed today — unconditionally, see the Deviation Ledger — because no `Logger` exists to record it. Until then a provider outage during a background refresh is invisible | +| Re-verification of the "Basic/Digest never preemptively stamp" reading against reference fixtures | Phase 5c | Phase 9 (conformance sweep) | Flagged as an interpretation in the Deviation Ledger, not a certainty | +| RFC 7616 §4 `username*` (RFC 5987) extended notation for a non-ASCII Digest username | Phase 5c | Unscoped | `digestHandler()` rejects a non-header-safe username at construction today. Implementing `username*` would let it be sent correctly rather than refused, and is the standard's own answer | +| A per-**operation** `AuthTiers` source | Phase 5c | Unscoped | `perCall` (via `RequestOptions.auth`) and `client` both have real sources as of Task 14; nothing in this roadmap ships a per-operation layer | +| ~~`DigestChallengeUnsupportedError` consumer confirmation~~ | Phase 5c | **CLOSED in 5c** | Cut before shipping rather than deferred: nothing constructed or caught it, and its stated purpose — a caller driving `digestHandler()` directly — was unreachable, since `digestHandler` is internal. Removing an exported error class is breaking, so cutting it now cost nothing. `docs/open-items.md` G11 | +| A caller-supplied `ChallengeHandler` list on `AuthStepSettings` | Phase 5c | Unscoped | `handlers` was removed at review: it forced three types onto the public barrel and could not compose with the built-in handlers, which stay internal. If a caller ever needs to ADD a handler rather than replace the whole reaction, the shape to ship is an append-semantics field plus public `basicHandler`/`digestHandler` factories — not the replace-semantics field that was cut | diff --git a/packages/core/api-extractor.json b/packages/core/api-extractor.json index 75aa61d..455423c 100644 --- a/packages/core/api-extractor.json +++ b/packages/core/api-extractor.json @@ -10,5 +10,13 @@ }, "dtsRollup": { "enabled": false + }, + "messages": { + "extractorMessageReporting": { + "ae-forgotten-export": { + "logLevel": "error", + "addToApiReportFile": false + } + } } } diff --git a/packages/core/etc/core.api.md b/packages/core/etc/core.api.md index bb60e45..ee19ab0 100644 --- a/packages/core/etc/core.api.md +++ b/packages/core/etc/core.api.md @@ -4,6 +4,106 @@ ```ts +// @public +export class ApiKeyCredential { + [INSPECT](): string; + constructor(key: string); + toString(): string; +} + +// @public +export interface ApiKeyCredentialConfig { + readonly credential: ApiKeyCredential | NameKeyCredential; + readonly headerName?: string | undefined; + readonly prefix?: string | undefined; +} + +// @public +export interface AuthCredentialSet { + readonly apiKey?: ApiKeyCredentialConfig | undefined; + readonly basic?: BasicCredential | undefined; + readonly bearer?: BearerCredential | undefined; + readonly digest?: DigestCredential | undefined; +} + +// @public +export interface AuthDescriptor { + readonly allowsAnonymous: boolean; + readonly requirements: readonly AuthRequirement[]; +} + +// @public +export interface AuthRequirement { + readonly params: ReadonlyMap; + readonly scheme: AuthScheme; + readonly scopes: readonly string[]; +} + +// @public +export function authRequirementsEqual(a: AuthRequirement, b: AuthRequirement): boolean; + +// @public +export class AuthResolutionError extends DexpaceError { + constructor(message: string, requiredSchemes?: readonly string[], availableSchemes?: readonly string[]); + readonly availableSchemes: readonly string[] | undefined; + readonly requiredSchemes: readonly string[] | undefined; + static unsatisfiable(requiredSchemes: readonly string[], availableSchemes: readonly string[]): AuthResolutionError; +} + +// @public +export type AuthScheme = 'OAUTH2' | 'API_KEY' | 'BASIC' | 'DIGEST' | 'NO_AUTH'; + +// @public +export function authStep(settings: AuthStepSettings): StepDescriptor; + +// @public +export interface AuthStepSettings { + readonly bearerMarginMs?: number | undefined; + readonly challengeHook?: ChallengeHook | undefined; + readonly clock?: Pick | undefined; + readonly credentials: AuthCredentialSet; + readonly tiers: AuthTiers; +} + +// @public +export interface AuthTiers { + readonly client?: AuthDescriptor | undefined; + readonly operation?: AuthDescriptor | undefined; + readonly perCall?: AuthDescriptor | undefined; +} + +// @public +export interface BackoffSettings { + readonly fixedDelayMs?: number | undefined; + readonly initialDelayMs: number; + readonly jitter: number; + readonly maxDelayMs: number; + readonly multiplier: number; +} + +// @public +export interface BasicCredential { + readonly password: string; + readonly username: string; +} + +// @public +export interface BearerCredential { + readonly marginMs?: number | undefined; + readonly provider: TokenProvider; +} + +// @public +export class BearerToken { + [INSPECT](): string; + readonly expiresAt: number | undefined; + get token(): string; + toString(): string; +} + +// @public +export function bearerTokensEqual(a: BearerToken, b: BearerToken): boolean; + // @public interface Body_2 { readonly contentLength: number; @@ -40,6 +140,18 @@ export class CancellationError extends DexpaceError { constructor(message: string, options?: ErrorOptions); } +// @public +export type ChallengeHook = (response: Response_2, request: Request_2, options?: { + readonly signal?: AbortSignal | undefined; +}) => Promise; + +// @public +export interface Clock { + monotonic(): number; + now(): number; + sleep(ms: number, signal?: AbortSignal): Promise; +} + // @public export function composeSignal(userSignal?: AbortSignal, timeoutMs?: number): AbortSignal | undefined; @@ -49,11 +161,37 @@ export class ConsumedBodyError extends DexpaceError { readonly bodyKind: string; } +// @public +export function createAuthDescriptor(requirements: readonly AuthRequirement[]): AuthDescriptor; + +// @public +export function createAuthRequirement(scheme: AuthScheme, scopes?: readonly string[], params?: ReadonlyMap): AuthRequirement; + +// @public +export function createBearerToken(token: string, expiresAt?: number): BearerToken; + // @public export class DexpaceError extends Error { constructor(message: string, options?: ErrorOptions); } +// @public +export type DigestAlgorithm = 'MD5' | 'MD5-sess' | 'SHA-256' | 'SHA-256-sess'; + +// @public +export interface DigestCredential { + readonly algorithmPreference?: readonly DigestAlgorithm[] | undefined; + readonly password: string; + readonly username: string; +} + +// @public +export interface DispatchContext { + readonly instrumentation: InstrumentationBundle; + readonly key: symbol; + readonly kind: 'dispatch'; +} + // @public export class DomainModelError extends DexpaceError { } @@ -72,6 +210,19 @@ export class ETag { export class EtagParseError extends DomainModelError { } +// @public +export interface ExchangeContext { + readonly instrumentation: InstrumentationBundle; + readonly key: symbol; + readonly kind: 'exchange'; + readonly operationName: string | undefined; + readonly request: Request_2; + readonly response: Response_2; +} + +// @public +export type ExecutionContext = DispatchContext | RequestContext | ExchangeContext; + // @public export class FormBodyValidationError extends DexpaceError { constructor(field: string, value: unknown, options?: ErrorOptions); @@ -160,6 +311,19 @@ export class HttpStatusError extends DexpaceError { readonly status: number; } +// @public +export interface InstrumentationBundle { + readonly activeSpan: unknown; + readonly isRemote: boolean; + readonly isValid: boolean; + readonly spanId: string; + readonly traceFlags: number; + readonly traceId: string; + readonly traceIdEncoding: string; + readonly tracerFactory: (operationName: string) => unknown; + readonly traceState: string; +} + // @public export function isBodyError(error: unknown): error is ConsumedBodyError | MultipartBoundaryError | FormBodyValidationError; @@ -225,6 +389,17 @@ export interface MultipartPart { readonly name: string; } +// @public +export class NameKeyCredential { + [INSPECT](): string; + constructor(name: string, key: string); + readonly name: string; + toString(): string; +} + +// @public +export type Next = (request?: Request_2) => Promise; + // @public export class OperationAssemblyError extends DexpaceError { constructor(message: string, parameterName: string); @@ -241,6 +416,32 @@ export interface OperationDescriptor { readonly query?: QueryParams | undefined; } +// @public +export const PILLAR_STAGES: ReadonlySet; + +// @public +export class PipelineBuilder { + constructor(transport: Transport); + append(descriptor: StepDescriptor): this; + appendAll(descriptors: readonly StepDescriptor[]): this; + build(): Runtime; + insertAfter(anchorType: symbol, descriptor: StepDescriptor): this; + insertBefore(anchorType: symbol, descriptor: StepDescriptor): this; + prepend(descriptor: StepDescriptor): this; + prependAll(descriptors: readonly StepDescriptor[]): this; + reload(descriptors: readonly StepDescriptor[]): this; + remove(type: symbol): this; + replace(anchorType: symbol, descriptor: StepDescriptor): this; + static seedFrom(runtime: Runtime, mode: 'flatten' | 'nest'): PipelineBuilder; +} + +// @public +export class PlaintextCredentialError extends DexpaceError { + constructor(stepName: string, scheme: string); + readonly scheme: string; + readonly stepName: string; +} + // @public export class Protocol { equals(other: Protocol): boolean; @@ -275,6 +476,29 @@ export class QueryParamsBuilder implements Builder { // @public export type RangeKind = 'bounded' | 'suffix' | 'open'; +// @public +export interface RedirectCondition { + readonly redirectsFollowed: number; + readonly response: Response_2; + readonly visited: ReadonlySet; +} + +// @public +export type RedirectPredicate = (condition: Readonly) => boolean; + +// @public +export interface RedirectSettings { + readonly allow303: boolean; + readonly allowedMethods: ReadonlySet; + readonly allowSchemeDowngrade: boolean; + readonly locationHeader: string; + readonly maxHops: number; + readonly predicate?: RedirectPredicate | undefined; +} + +// @public +export function redirectStep(overrides?: Partial): StepDescriptor; + // @public class Request_2 { get body(): Body_2 | undefined; @@ -321,8 +545,18 @@ export class RequestConditionsBuilder implements Builder { export class RequestConditionsValidationError extends DomainModelError { } +// @public +export interface RequestContext { + readonly instrumentation: InstrumentationBundle; + readonly key: symbol; + readonly kind: 'request'; + readonly operationName: string | undefined; + readonly request: Request_2; +} + // @public export class RequestOptions { + get auth(): AuthDescriptor | undefined; static readonly EMPTY: RequestOptions; get maxRetries(): number | undefined; static newBuilder(): RequestOptionsBuilder; @@ -333,6 +567,7 @@ export class RequestOptions { // @public export class RequestOptionsBuilder implements Builder { + auth(descriptor: AuthDescriptor | undefined): this; build(): RequestOptions; maxRetries(value: number | undefined): this; tags(entries: ReadonlyMap): this; @@ -376,6 +611,49 @@ export class ResponseBuilder implements Builder { status(status: Status): this; } +// @public +export interface RetrySettings extends BackoffSettings { + readonly attemptHeaderName?: string | undefined; + readonly maxAttempts: number; + readonly retryableStatuses: ReadonlySet; + readonly totalTimeoutMs?: number | undefined; +} + +// @public +export function retryStep(options?: RetryStepOptions): StepDescriptor; + +// @public +export interface RetryStepOptions { + readonly clock?: Clock | undefined; + readonly delayOverride?: ((attempt: number) => number | undefined) | undefined; + readonly random?: (() => number) | undefined; + readonly settings?: Partial | undefined; +} + +// @public +export class Runtime implements Transport { + close(): Promise; + send(request: Request_2, options?: RequestOptions, signal?: AbortSignal): Promise; + get steps(): readonly StepDescriptor[]; + get transport(): Transport; +} + +// @public +export type Stage = 'PRE_REDIRECT' | 'REDIRECT' | 'POST_REDIRECT' | 'PRE_RETRY' | 'RETRY' | 'POST_RETRY' | 'PRE_AUTH' | 'AUTH' | 'POST_AUTH' | 'PRE_LOGGING' | 'LOGGING' | 'POST_LOGGING' | 'PRE_SERDE' | 'SERDE' | 'POST_SERDE' | 'SEND'; + +// @public +export const STAGE_ORDER: readonly Stage[]; + +// @public +export function standardResilience(transport: Transport, options?: StandardResilienceOptions): Runtime; + +// @public +export interface StandardResilienceOptions { + readonly auth?: AuthStepSettings | undefined; + readonly redirect?: Partial | undefined; + readonly retry?: RetryStepOptions | undefined; +} + // @public export class Status { get code(): number; @@ -392,6 +670,25 @@ export class Status { static recognized(code: number): Status | undefined; } +// @public +export type Step = (request: Request_2, ctx: StepContext) => Promise; + +// @public +export interface StepContext { + readonly context: ExecutionContext; + readonly fork?: (() => Next) | undefined; + readonly next: Next; + readonly options?: RequestOptions | undefined; + readonly signal?: AbortSignal | undefined; +} + +// @public +export interface StepDescriptor { + readonly fn: Step; + readonly stage: Stage; + readonly type: symbol; +} + // @public export class StreamBody implements Body_2 { constructor(stream: ReadableStream, mediaType?: string, contentLength?: number); @@ -422,6 +719,9 @@ export function stringBody(text: string, mediaType?: string): StringBody; // @public export function toHttpError(response: Response_2): Promise; +// @public +export type TokenProvider = () => Promise; + // @public export interface Transport { close(): Promise; diff --git a/packages/core/src/auth/auth-step.test.ts b/packages/core/src/auth/auth-step.test.ts new file mode 100644 index 0000000..94873f4 --- /dev/null +++ b/packages/core/src/auth/auth-step.test.ts @@ -0,0 +1,1485 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/auth-step.test.ts +// Exercises: AUTH-27 (exactly one AUTH-stage descriptor, pinned to the pillar), AUTH-28 (HTTPS guard, +// NO_AUTH exempt, re-applied on the replay path), AUTH-29 (the cross-origin marker skips the guard and +// stamping, is cleared from the outbound headers, and suppresses the challenge reaction too -- so the +// credential cannot re-enter via the 401), AUTH-25 (a 407 is answered from Proxy-Authenticate into +// Proxy-Authorization), AUTH-30 (401 + WWW-Authenticate invokes the hook; a replacement re-drives +// exactly once through a fresh fork()), AUTH-31 (a non-replayable replacement body surfaces the +// original challenge unchanged and unclosed), AUTH-32 (a throwing hook closes the challenge response +// before propagating), AUTH-33 (no matching challenge header, or a hook yielding nothing -> unchanged), +// AUTH-36 (OAUTH2's default hook evicts the exact rejected token and re-stamps -- including behind a +// non-replayable body, where only the REPLAY is skipped), AUTH-4 (a per-call RequestOptions.auth +// descriptor overrides the configured tiers, via ctx.options), AUTH-5/AUTH-6 (resolution against the +// derived available-scheme set), RECOV-12 (a failing release never masks the primary error), +// AUTH-34/AUTH-35 (a refresh margin is validated as a finite, non-negative duration). +import {describe, expect, test} from 'bun:test'; +import {streamBody} from '../body/stream-body.js'; +import { + createRequestContext, + type ExecutionContext, +} from '../context/context.js'; +import {Headers} from '../http/headers.js'; +import {Protocol} from '../http/protocol.js'; +import {Request} from '../http/request.js'; +import {RequestOptions} from '../http/request-options.js'; +import {Response} from '../http/response.js'; +import {Status} from '../http/status.js'; +import {Cursor} from '../pipeline/cursor.js'; +import type {Transport} from '../seams/transport.js'; +import type {StepDescriptor} from '../pipeline/step.js'; +import {CROSS_ORIGIN_MARKER_HEADER} from '../redirect/cross-origin.js'; +import type {SuppressedErrorLike} from '../suppress.js'; +import {invariant} from '../invariant.js'; +import {FakeTransport, countingResponse} from '../testing/fake-transport.js'; +import { + AUTH_STEP_TYPE, + authStep, + availableSchemesOf, + type AuthCredentialSet, +} from './auth-step.js'; +import {createBearerToken, ApiKeyCredential} from './credential.js'; +import {createAuthDescriptor} from './descriptor.js'; +import {AuthResolutionError, PlaintextCredentialError} from './errors.js'; +import {createAuthRequirement} from './requirement.js'; +import type {AuthScheme} from './scheme.js'; + +// Constructed inline rather than imported: 4c keeps `aRequestContext()` file-local to `cursor.test.ts`, +// and importing across `*.test.ts` files is not acceptable -- the same call 5a's and 5b's step suites made. +function aRequestContext(request: Request): ExecutionContext { + return createRequestContext(request); +} + +function aRequest(url = 'https://example.com/a'): Request { + return Request.newBuilder().url(url).build(); +} + +function markedRequest(url: string): Request { + return Request.newBuilder() + .url(url) + .headers(Headers.newBuilder().add(CROSS_ORIGIN_MARKER_HEADER, '1').build()) + .build(); +} + +/** The optional per-drive inputs, bundled so `runThrough` stays within `max-params`. */ +interface DriveOverrides { + readonly request?: Request | undefined; + readonly options?: RequestOptions | undefined; + readonly signal?: AbortSignal | undefined; +} + +// `Transport`, not `FakeTransport`: the only thing this helper does with it is hand it to `Cursor`, +// and narrowing to what is actually used is what lets the gated double below be driven through it too +// (`docs/knowledge/api-design.md` -- accept the narrowest interface describing the members used). +function runThrough( + descriptor: StepDescriptor, + transport: Transport, + overrides: DriveOverrides = {}, +): Promise { + const request = overrides.request ?? aRequest(); + return new Cursor({ + steps: [descriptor], + transport, + request, + context: aRequestContext(request), + options: overrides.options, + signal: overrides.signal, + }).advance(); +} + +function tiersFor(scheme: AuthScheme): { + client: ReturnType; +} { + return {client: createAuthDescriptor([createAuthRequirement(scheme)])}; +} + +/** + * A challenge response: `countingResponse` plus the challenge header. `ResponseBuilder` carries the + * SAME body instance through `newBuilder()`, so the rebuilt response still reports through the + * original's release counter. `setInbound`, not `set`: these are inbound headers, and a real server may + * send obs-text in a realm (HTTP-19). + */ +function challengeResponse( + status: number, + headerName: string, + headerValue: string, +): {response: Response; cancelCount: () => number} { + const base = countingResponse(status); + const response = base.response + .newBuilder() + .headers( + base.response.headers + .newBuilder() + .setInbound(headerName, headerValue) + .build(), + ) + .build(); + return {response, cancelCount: base.cancelCount}; +} + +/** A one-shot request body: `StreamBody.replayable` is `false` (AUTH-31's gate). */ +function oneShotPost(url = 'https://example.com/a'): Request { + const stream = new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + return Request.newBuilder() + .method('POST') + .url(url) + .body(streamBody(stream, 'text/plain', 0)) + .build(); +} + +const CANCEL_FAILURE = new Error('cancel exploded'); + +/** + * A 401 whose body `cancel()` REJECTS with a non-`TypeError` -- the one thing `Response.close()` is + * documented to rethrow. Models a transport releasing over an already-broken socket. Same shape 5b's + * `redirect-step.test.ts` uses for its own RECOV-12 coverage. + */ +function hostileChallenge(value = 'Basic realm="x"'): Response { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.from([1])); + }, + cancel() { + throw CANCEL_FAILURE; + }, + }); + return Response.newBuilder() + .request(aRequest()) + .protocol(Protocol.HTTP_1_1) + .status(Status.of(401)) + .headers(Headers.newBuilder().setInbound('WWW-Authenticate', value).build()) + .body(body) + .build(); +} + +/** + * A macrotask boundary, so a fire-and-forget background refresh's whole then/finally chain has + * drained regardless of how many microtask hops it takes. Same helper `bearer-cache.test.ts` uses. + */ +function drainMacrotask(): Promise { + return new Promise(resolve => { + setTimeout(resolve, 0); + }); +} + +/** + * Drives three requests through `descriptor`, draining between them, and reports the `Authorization` + * value each one actually put on the wire. + * + * Three drives is the shortest sequence that can observe a refresh MARGIN at all: the first fills an + * empty cache (where no margin is consulted), the second is the one the margin either does or does + * not push into AUTH-37's expiring-but-valid zone, and the third reveals whether that zone's + * background refresh actually happened. + */ +async function stampsOverThreeDrives( + descriptor: StepDescriptor, +): Promise { + const transport = new FakeTransport([ + countingResponse(200).response, + countingResponse(200).response, + countingResponse(200).response, + ]); + for (let drive = 0; drive < 3; drive += 1) { + await runThrough(descriptor, transport); + await drainMacrotask(); + } + return transport.calls.map(call => call.request.headers.get('Authorization')); +} + +/** + * A provider issuing `t1` at `firstExpiresAt` and then `t2` far out of any margin's reach, counting + * its calls. Every test below pins the clock at 0, so `firstExpiresAt` IS t1's remaining lifetime. + */ +function agingTokenProvider(firstExpiresAt: number): { + readonly credentials: AuthCredentialSet; + readonly callCount: () => number; +} { + let issued = 0; + return { + credentials: { + bearer: { + provider: () => { + issued += 1; + return Promise.resolve( + createBearerToken( + `t${String(issued)}`, + issued === 1 ? firstExpiresAt : 10_000_000, + ), + ); + }, + }, + }, + callCount: () => issued, + }; +} + +async function rejectionOf(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error; + } + return undefined; +} + +describe('availableSchemesOf (AUTH-5)', () => { + test('is empty for an empty credential set', () => { + expect([...availableSchemesOf({})]).toEqual([]); + }); + + test('maps each configured credential to its scheme', () => { + const credentials: AuthCredentialSet = { + basic: {username: 'u', password: 'p'}, + digest: {username: 'u', password: 'p'}, + bearer: {provider: () => Promise.resolve(createBearerToken('t'))}, + apiKey: {credential: new ApiKeyCredential('k')}, + }; + expect([...availableSchemesOf(credentials)].sort()).toEqual([ + 'API_KEY', + 'BASIC', + 'DIGEST', + 'OAUTH2', + ]); + }); +}); + +describe('authStep: resolution and the preemptive stamp (AUTH-26..AUTH-28, AUTH-34)', () => { + test('is pinned to the AUTH pillar stage (AUTH-27)', () => { + const descriptor = authStep({credentials: {}, tiers: tiersFor('NO_AUTH')}); + expect(descriptor.stage).toBe('AUTH'); + expect(descriptor.type).toBe(AUTH_STEP_TYPE); + }); + + test('NO_AUTH stamps nothing and never triggers the HTTPS guard, even over plain HTTP (AUTH-28)', async () => { + const transport = new FakeTransport([countingResponse(200).response]); + const descriptor = authStep({credentials: {}, tiers: tiersFor('NO_AUTH')}); + + await runThrough(descriptor, transport, { + request: aRequest('http://example.com/a'), + }); + + expect( + transport.calls[0]?.request.headers.get('Authorization'), + ).toBeUndefined(); + }); + + test('API_KEY stamps preemptively via the configured header/prefix (AUTH-26)', async () => { + const transport = new FakeTransport([countingResponse(200).response]); + const credentials: AuthCredentialSet = { + apiKey: { + credential: new ApiKeyCredential('secret'), + headerName: 'X-Api-Key', + }, + }; + const descriptor = authStep({credentials, tiers: tiersFor('API_KEY')}); + + await runThrough(descriptor, transport); + + expect(transport.calls[0]?.request.headers.get('X-Api-Key')).toBe('secret'); + }); + + test('OAUTH2 stamps a cached bearer token preemptively (AUTH-34)', async () => { + const transport = new FakeTransport([ + countingResponse(200).response, + countingResponse(200).response, + ]); + let calls = 0; + const credentials: AuthCredentialSet = { + bearer: { + provider: () => { + calls += 1; + return Promise.resolve( + createBearerToken(`t${String(calls)}`, 100_000), + ); + }, + }, + }; + const descriptor = authStep({ + credentials, + tiers: tiersFor('OAUTH2'), + clock: {now: () => 0}, + }); + + await runThrough(descriptor, transport); + await runThrough(descriptor, transport); + + expect(transport.calls[0]?.request.headers.get('Authorization')).toBe( + 'Bearer t1', + ); + // The second call reads the still-fresh cached token rather than refetching. + expect(transport.calls[1]?.request.headers.get('Authorization')).toBe( + 'Bearer t1', + ); + expect(calls).toBe(1); + }); +}); + +describe('authStep: the HTTPS guard and tier resolution (AUTH-6/AUTH-28)', () => { + test('a credentialed scheme over plain HTTP throws PlaintextCredentialError before any send (AUTH-28)', async () => { + const transport = new FakeTransport([countingResponse(200).response]); + const credentials: AuthCredentialSet = { + apiKey: {credential: new ApiKeyCredential('secret')}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('API_KEY')}); + + const error = await rejectionOf( + runThrough(descriptor, transport, { + request: aRequest('http://example.com/a'), + }), + ); + + expect(error).toBeInstanceOf(PlaintextCredentialError); + expect((error as PlaintextCredentialError).scheme).toBe('API_KEY'); + expect(transport.sendCount).toBe(0); + }); + + test('the guard fires before the token fetch, not after (AUTH-28)', async () => { + const transport = new FakeTransport([countingResponse(200).response]); + let fetched = false; + const credentials: AuthCredentialSet = { + bearer: { + provider: () => { + fetched = true; + return Promise.resolve(createBearerToken('t', 100_000)); + }, + }, + }; + const descriptor = authStep({ + credentials, + tiers: tiersFor('OAUTH2'), + clock: {now: () => 0}, + }); + + await rejectionOf( + runThrough(descriptor, transport, { + request: aRequest('http://example.com/a'), + }), + ); + + expect(fetched).toBe(false); + }); + + test('an unsatisfiable tier surfaces AuthResolutionError (AUTH-6)', async () => { + const transport = new FakeTransport([countingResponse(200).response]); + const descriptor = authStep({credentials: {}, tiers: tiersFor('BASIC')}); + + const error = await rejectionOf(runThrough(descriptor, transport)); + + expect(error).toBeInstanceOf(AuthResolutionError); + expect(transport.sendCount).toBe(0); + }); + + test('BASIC/DIGEST never stamp preemptively -- the outbound request carries no Authorization', async () => { + const transport = new FakeTransport([countingResponse(200).response]); + const credentials: AuthCredentialSet = { + basic: {username: 'u', password: 'p'}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('BASIC')}); + + await runThrough(descriptor, transport); + + expect( + transport.calls[0]?.request.headers.get('Authorization'), + ).toBeUndefined(); + }); +}); + +describe('authStep: the cross-origin marker (AUTH-29)', () => { + test('AUTH-29: a cross-origin-marked request skips the guard and stamping, marker cleared', async () => { + const transport = new FakeTransport([countingResponse(200).response]); + const credentials: AuthCredentialSet = { + apiKey: {credential: new ApiKeyCredential('secret')}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('API_KEY')}); + // Plain HTTP -- would normally trip the guard, but the marker skips it (AUTH-29). + const marked = markedRequest('http://example.com/a'); + + await runThrough(descriptor, transport, {request: marked}); + + const sent = transport.calls[0]?.request; + expect(sent?.headers.get('Authorization')).toBeUndefined(); + expect(sent?.headers.has(CROSS_ORIGIN_MARKER_HEADER)).toBe(false); + }); + + test('AUTH-29: the marker is cleared even on the ordinary same-origin path', async () => { + // An unmarked request has nothing to clear, but a marked HTTPS request on a stamping path must + // still not forward the header -- clearing happens before the branch, not inside one of them. + const transport = new FakeTransport([countingResponse(200).response]); + const descriptor = authStep({credentials: {}, tiers: tiersFor('NO_AUTH')}); + + await runThrough(descriptor, transport, { + request: markedRequest('https://example.com/a'), + }); + + expect( + transport.calls[0]?.request.headers.has(CROSS_ORIGIN_MARKER_HEADER), + ).toBe(false); + }); + + test('AUTH-29: a cross-origin-marked request does NOT answer a challenge either', async () => { + // The suppression covers the whole hop. Answering the challenge here would stamp exactly the + // credential the outbound pass declined to send, onto the server-chosen foreign host, over a URL + // whose HTTPS guard was skipped -- the precise leak AUTH-29 exists to prevent. + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const transport = new FakeTransport([ + challenged.response, + countingResponse(200).response, + ]); + const credentials: AuthCredentialSet = { + basic: {username: 'u', password: 'p'}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('BASIC')}); + + const response = await runThrough(descriptor, transport, { + request: markedRequest('http://evil.example/a'), + }); + + expect(transport.sendCount).toBe(1); // no re-drive was attempted + expect(response).toBe(challenged.response); // unchanged and unclosed -- the caller owns it + expect(challenged.cancelCount()).toBe(0); + }); +}); + +describe('authStep: challenge detection (AUTH-25/AUTH-33)', () => { + test('a 407 is answered from Proxy-Authenticate into Proxy-Authorization (AUTH-25)', async () => { + const challenged = challengeResponse( + 407, + 'Proxy-Authenticate', + 'Basic realm="p"', + ); + const success = countingResponse(200); + const transport = new FakeTransport([ + challenged.response, + success.response, + ]); + const credentials: AuthCredentialSet = { + basic: {username: 'u', password: 'p'}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('BASIC')}); + + await runThrough(descriptor, transport); + + expect(transport.sendCount).toBe(2); + expect( + transport.calls[1]?.request.headers + .get('Proxy-Authorization') + ?.startsWith('Basic '), + ).toBe(true); + expect( + transport.calls[1]?.request.headers.get('Authorization'), + ).toBeUndefined(); + }); + + test('a 401 carrying only Proxy-Authenticate is NOT answered (AUTH-25)', async () => { + const challenged = challengeResponse( + 401, + 'Proxy-Authenticate', + 'Basic realm="p"', + ); + const transport = new FakeTransport([ + challenged.response, + countingResponse(200).response, + ]); + const credentials: AuthCredentialSet = { + basic: {username: 'u', password: 'p'}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('BASIC')}); + + const response = await runThrough(descriptor, transport); + + expect(transport.sendCount).toBe(1); + expect(response).toBe(challenged.response); + }); +}); + +describe('authStep: challenge detection, negative cases (AUTH-33)', () => { + test('a 401 without WWW-Authenticate is returned unchanged (AUTH-33)', async () => { + const the401 = countingResponse(401); + const transport = new FakeTransport([the401.response]); + const credentials: AuthCredentialSet = { + basic: {username: 'u', password: 'p'}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('BASIC')}); + + const response = await runThrough(descriptor, transport); + + expect(response).toBe(the401.response); + expect(transport.sendCount).toBe(1); + expect(the401.cancelCount()).toBe(0); + }); + + test('a non-challenge status is returned untouched', async () => { + const success = countingResponse(200); + const transport = new FakeTransport([success.response]); + const descriptor = authStep({credentials: {}, tiers: tiersFor('NO_AUTH')}); + + expect(await runThrough(descriptor, transport)).toBe(success.response); + }); +}); + +describe('authStep: the challenge replay (AUTH-30/AUTH-31)', () => { + test('a 401 with a Basic challenge re-drives exactly once with the stamped Authorization (AUTH-30)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const success = countingResponse(200); + const transport = new FakeTransport([ + challenged.response, + success.response, + ]); + const credentials: AuthCredentialSet = { + basic: {username: 'u', password: 'p'}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('BASIC')}); + + const response = await runThrough(descriptor, transport); + + expect(transport.sendCount).toBe(2); + expect( + transport.calls[1]?.request.headers + .get('Authorization') + ?.startsWith('Basic '), + ).toBe(true); + expect(response).toBe(success.response); + expect(challenged.cancelCount()).toBe(1); // AUTH-30: the original is closed before the re-drive + }); + + test('no nested re-challenge: a second 401 on the replay is returned as-is (AUTH-30)', async () => { + const first = challengeResponse(401, 'WWW-Authenticate', 'Basic realm="x"'); + const second = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const transport = new FakeTransport([first.response, second.response]); + const credentials: AuthCredentialSet = { + basic: {username: 'u', password: 'p'}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('BASIC')}); + + const response = await runThrough(descriptor, transport); + + expect(transport.sendCount).toBe(2); // exactly one replay, not a loop + expect(response).toBe(second.response); + expect(second.cancelCount()).toBe(0); // the surfaced response is the caller's, left open + }); +}); + +describe('authStep: answering a Digest challenge (AUTH-15..AUTH-22)', () => { + test('a Digest challenge is answered with a Digest header value (AUTH-15..22)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Digest realm="r", nonce="n", qop="auth"', + ); + const transport = new FakeTransport([ + challenged.response, + countingResponse(200).response, + ]); + const credentials: AuthCredentialSet = { + digest: {username: 'u', password: 'p'}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('DIGEST')}); + + await runThrough(descriptor, transport, { + request: aRequest('https://example.com/a?q=1'), + }); + + const value = transport.calls[1]?.request.headers.get('Authorization'); + expect(value?.startsWith('Digest ')).toBe(true); + // AUTH-22: the digest-uri is the request-target, path AND query. + expect(value).toContain('uri="/a?q=1"'); + }); +}); + +describe('authStep: the replayability gate (AUTH-31)', () => { + test('an unsatisfiable challenge leaves the response unchanged (AUTH-25/AUTH-33)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Negotiate abc123', + ); + const transport = new FakeTransport([ + challenged.response, + countingResponse(200).response, + ]); + const credentials: AuthCredentialSet = { + basic: {username: 'u', password: 'p'}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('BASIC')}); + + const response = await runThrough(descriptor, transport); + + expect(transport.sendCount).toBe(1); + expect(response).toBe(challenged.response); + expect(challenged.cancelCount()).toBe(0); + }); + + // AUTH-31 gates the DISPATCH only. The hook still runs for a one-shot body -- there is deliberately + // no "skip the hook when the body is one-shot" fast path (see `handleChallenge`), because OAUTH2's + // default hook evicts the rejected token on the way past and that work is not wasted. What this + // test pins is the replay gate's own three obligations; the eviction half is pinned separately by + // 'a revoked token is evicted even though the replay is skipped' below. + test('a non-replayable body surfaces the original 401 unchanged and unclosed, with no replay (AUTH-31)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const transport = new FakeTransport([challenged.response]); + const credentials: AuthCredentialSet = { + basic: {username: 'u', password: 'p'}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('BASIC')}); + + const response = await runThrough(descriptor, transport, { + request: oneShotPost(), + }); + + expect(response).toBe(challenged.response); + expect(transport.sendCount).toBe(1); // no replacement dispatch was attempted + expect(challenged.cancelCount()).toBe(0); // the caller owns it -- MUST NOT be closed + }); + + test('AUTH-31 also gates a caller hook that returns a non-replayable replacement', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const transport = new FakeTransport([challenged.response]); + const descriptor = authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), + challengeHook: () => Promise.resolve(oneShotPost()), + }); + + const response = await runThrough(descriptor, transport); + + expect(response).toBe(challenged.response); + expect(transport.sendCount).toBe(1); + expect(challenged.cancelCount()).toBe(0); + }); +}); + +describe('authStep: challenge-hook failure and override (AUTH-30/AUTH-32/AUTH-33)', () => { + test('a throwing challengeHook closes the 401 before propagating (AUTH-32)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const transport = new FakeTransport([challenged.response]); + const descriptor = authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), + challengeHook: () => Promise.reject(new Error('hook exploded')), + }); + + const error = await rejectionOf(runThrough(descriptor, transport)); + + expect((error as Error).message).toBe('hook exploded'); + expect(challenged.cancelCount()).toBe(1); + }); + + test('a hook throwing SYNCHRONOUSLY also closes the 401 (AUTH-32/AUTH-38)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const transport = new FakeTransport([challenged.response]); + const descriptor = authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), + challengeHook: (): Promise => { + throw new Error('sync boom'); + }, + }); + + const error = await rejectionOf(runThrough(descriptor, transport)); + + expect((error as Error).message).toBe('sync boom'); + expect(challenged.cancelCount()).toBe(1); + }); + + test('a hook yielding nothing leaves the 401 unchanged and unclosed (AUTH-33)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const transport = new FakeTransport([challenged.response]); + const descriptor = authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), + challengeHook: () => Promise.resolve(undefined), + }); + + const response = await runThrough(descriptor, transport); + + expect(response).toBe(challenged.response); + expect(challenged.cancelCount()).toBe(0); + }); +}); + +describe('authStep: hook override and non-reactive schemes (AUTH-30)', () => { + test('a caller-supplied challengeHook takes precedence over the scheme default (AUTH-30)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const transport = new FakeTransport([ + challenged.response, + countingResponse(200).response, + ]); + let hookInvoked = false; + const descriptor = authStep({ + credentials: {basic: {username: 'u', password: 'p'}}, + tiers: tiersFor('BASIC'), + challengeHook: (_response, request) => { + hookInvoked = true; + return Promise.resolve( + request + .newBuilder() + .headers( + request.headers + .newBuilder() + .set('Authorization', 'Custom xyz') + .build(), + ) + .build(), + ); + }, + }); + + await runThrough(descriptor, transport); + + expect(hookInvoked).toBe(true); + expect(transport.calls[1]?.request.headers.get('Authorization')).toBe( + 'Custom xyz', + ); + }); +}); + +describe('authStep: schemes with no reactive behavior (AUTH-30)', () => { + test('API_KEY does not react to a 401 -- static credentials have no reactive behavior (AUTH-30)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const transport = new FakeTransport([ + challenged.response, + countingResponse(200).response, + ]); + const credentials: AuthCredentialSet = { + apiKey: {credential: new ApiKeyCredential('secret')}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('API_KEY')}); + + const response = await runThrough(descriptor, transport); + + expect(transport.sendCount).toBe(1); + expect(response).toBe(challenged.response); + }); +}); + +describe('authStep: the OAUTH2 default hook (AUTH-36)', () => { + test('OAUTH2 default hook evicts the exact rejected token and re-stamps (AUTH-36)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Bearer realm="x"', + ); + const transport = new FakeTransport([ + challenged.response, + countingResponse(200).response, + ]); + let calls = 0; + const credentials: AuthCredentialSet = { + bearer: { + provider: () => { + calls += 1; + return Promise.resolve( + createBearerToken(`t${String(calls)}`, 100_000), + ); + }, + }, + }; + const descriptor = authStep({ + credentials, + tiers: tiersFor('OAUTH2'), + clock: {now: () => 0}, + }); + + await runThrough(descriptor, transport); + + expect(transport.calls[0]?.request.headers.get('Authorization')).toBe( + 'Bearer t1', + ); + // Evicted t1, fetched genuinely fresh. + expect(transport.calls[1]?.request.headers.get('Authorization')).toBe( + 'Bearer t2', + ); + expect(calls).toBe(2); + }); +}); + +/** + * Holds one nominated send until {@link GatedTransport.release} is called, so a two-drive + * interleaving can be pinned instead of left to the scheduler. Everything else delegates to the + * scripted double. + */ +class GatedTransport implements Transport { + readonly #inner: FakeTransport; + readonly #gatedEntry: number; + #entered = 0; + #release: (() => void) | undefined; + readonly #gate: Promise; + + constructor(inner: FakeTransport, gatedEntry: number) { + this.#inner = inner; + this.#gatedEntry = gatedEntry; + this.#gate = new Promise(resolve => { + this.#release = resolve; + }); + } + + release(): void { + this.#release?.(); + } + + async send(request: Request): Promise { + // Counted on ENTRY, not off the inner double's `sendCount`: a gated call has not reached the + // inner transport yet, so `sendCount` would still be pointing at the gated position and every + // later call would gate too -- a deadlock, which is exactly what the first shape of this did. + this.#entered += 1; + if (this.#entered === this.#gatedEntry) await this.#gate; + return this.#inner.send(request); + } + + async close(): Promise { + // Nothing to release; the inner double owns no resources. + } +} + +describe('authStep: OAUTH2 preserves a token another request refreshed (AUTH-36)', () => { + test('a 401 on a token the cache has already replaced stamps the survivor, with no second fetch', async () => { + // AUTH-36's "preserving a token another request already refreshed", at the seam where it is + // actually observable. Two drives both stamp `t1` off one single-flight fetch. Drive A's 401 + // runs to completion first -- evicting `t1` and caching `t2` -- and only then is drive B's 401 + // released. B's rejected header (`t1`) no longer matches the cache (`t2`), so the eviction + // PRESERVES `t2` and the retry stamps it. Burning a third provider call to re-derive the same + // token, which the earlier unconditional-`refreshNow()` shape did, is what makes the clause a + // no-op rather than a behaviour. + // Scripted in the order the inner double actually SEES them, which the gate pins: A's 401, A's + // replay, then B's 401 and B's replay once released. + const inner = new FakeTransport([ + challengeResponse(401, 'WWW-Authenticate', 'Bearer realm="x"').response, + countingResponse(200).response, + challengeResponse(401, 'WWW-Authenticate', 'Bearer realm="x"').response, + countingResponse(200).response, + ]); + const transport = new GatedTransport(inner, 2); // hold drive B's first send + let calls = 0; + const credentials: AuthCredentialSet = { + bearer: { + provider: () => { + calls += 1; + return Promise.resolve( + createBearerToken(`t${String(calls)}`, 100_000), + ); + }, + }, + }; + const descriptor = authStep({ + credentials, + tiers: tiersFor('OAUTH2'), + clock: {now: () => 0}, + }); + + const driveA = runThrough(descriptor, transport); + const driveB = runThrough(descriptor, transport); + await driveA; + transport.release(); + await driveB; + + expect(calls).toBe(2); // the initial fetch and A's post-eviction fetch. B fetched nothing. + expect(inner.calls[2]?.request.headers.get('Authorization')).toBe( + 'Bearer t1', // B's original stamp, the one the server rejected + ); + expect(inner.calls[3]?.request.headers.get('Authorization')).toBe( + 'Bearer t2', // the PRESERVED token, stamped without a third fetch + ); + }); +}); + +describe('authStep: OAUTH2 declines a non-Bearer challenge (AUTH-36)', () => { + test('OAUTH2 leaves a 401 unchanged when it advertises no Bearer challenge (AUTH-36)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const transport = new FakeTransport([ + challenged.response, + countingResponse(200).response, + ]); + let calls = 0; + const credentials: AuthCredentialSet = { + bearer: { + provider: () => { + calls += 1; + return Promise.resolve( + createBearerToken(`t${String(calls)}`, 100_000), + ); + }, + }, + }; + const descriptor = authStep({ + credentials, + tiers: tiersFor('OAUTH2'), + clock: {now: () => 0}, + }); + + const response = await runThrough(descriptor, transport); + + expect(transport.sendCount).toBe(1); + expect(response).toBe(challenged.response); + expect(calls).toBe(1); // no eviction-driven refetch + }); +}); + +describe('authStep: the replay HTTPS guard (AUTH-28)', () => { + test('AUTH-28 is re-applied to a challenge replacement that carries a credential', async () => { + // The outbound guard is SKIPPED for NO_AUTH, and nothing constrains a caller hook to preserve the + // URL -- so without a second guard a hook answering a challenge stamps a credential over plaintext. + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const transport = new FakeTransport([ + challenged.response, + countingResponse(200).response, + ]); + const descriptor = authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), // outbound guard skipped entirely + challengeHook: (_response, request) => + Promise.resolve( + request + .newBuilder() + .headers( + request.headers + .newBuilder() + .set('Authorization', 'Basic c3B5') + .build(), + ) + .build(), + ), + }); + + const error = await rejectionOf( + runThrough(descriptor, transport, { + request: aRequest('http://example.com/a'), + }), + ); + + expect(error).toBeInstanceOf(PlaintextCredentialError); + expect(transport.sendCount).toBe(1); // the replacement never reached the wire + expect(challenged.cancelCount()).toBe(1); // and the 401 was closed before the throw, not leaked + }); + + test('a credential-free replacement over plaintext is NOT blocked by the replay guard (AUTH-28/AUTH-29)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const success = countingResponse(200); + const transport = new FakeTransport([ + challenged.response, + success.response, + ]); + const descriptor = authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), + challengeHook: (_response, request) => + Promise.resolve( + request.newBuilder().url('http://example.com/b').build(), + ), + }); + + const response = await runThrough(descriptor, transport, { + request: aRequest('http://example.com/a'), + }); + + expect(transport.sendCount).toBe(2); + expect(response).toBe(success.response); + }); +}); + +describe('authStep: per-call configuration and injected seams (AUTH-4/AUTH-11)', () => { + test('a per-call RequestOptions.auth descriptor overrides the configured tiers (AUTH-4)', async () => { + const transport = new FakeTransport([countingResponse(200).response]); + const credentials: AuthCredentialSet = { + apiKey: { + credential: new ApiKeyCredential('secret'), + headerName: 'X-Api-Key', + }, + }; + // Configured tiers resolve to API_KEY; the per-call descriptor demands NO_AUTH and must win. + const descriptor = authStep({credentials, tiers: tiersFor('API_KEY')}); + const options = RequestOptions.newBuilder() + .auth(createAuthDescriptor([createAuthRequirement('NO_AUTH')])) + .build(); + + await runThrough(descriptor, transport, {options}); + + expect( + transport.calls[0]?.request.headers.get('X-Api-Key'), + ).toBeUndefined(); + }); + + test('a per-call descriptor that is unsatisfiable does NOT fall through to the client tier (AUTH-4)', async () => { + const transport = new FakeTransport([countingResponse(200).response]); + const credentials: AuthCredentialSet = { + apiKey: {credential: new ApiKeyCredential('secret')}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('API_KEY')}); + const options = RequestOptions.newBuilder() + .auth(createAuthDescriptor([createAuthRequirement('BASIC')])) + .build(); + + const error = await rejectionOf( + runThrough(descriptor, transport, {options}), + ); + + expect(error).toBeInstanceOf(AuthResolutionError); + expect(transport.sendCount).toBe(0); + }); +}); + +describe('authStep: answering an unrecognized scheme through challengeHook', () => { + // There is deliberately no `AuthStepSettings.handlers`: `challengeHook` is the ONE caller-facing + // extension point, and it covers the case a handler list was reaching for -- a scheme none of the + // built-in handlers recognizes -- without putting `ChallengeHandler` on the public barrel where + // neither `basicHandler` nor `digestHandler` is reachable to compose with. + test('a challengeHook answers a scheme no built-in handler recognizes', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Custom realm="x"', + ); + const transport = new FakeTransport([ + challenged.response, + countingResponse(200).response, + ]); + const descriptor = authStep({ + credentials: {basic: {username: 'u', password: 'p'}}, + tiers: tiersFor('BASIC'), + challengeHook: (_response, request) => + Promise.resolve( + request + .newBuilder() + .headers( + request.headers + .newBuilder() + .set('Authorization', 'Custom abc') + .build(), + ) + .build(), + ), + }); + + await runThrough(descriptor, transport); + + expect(transport.calls[1]?.request.headers.get('Authorization')).toBe( + 'Custom abc', + ); + }); +}); + +describe('authStep: the call signal', () => { + test('the call signal reaches the challenge hook (AUTH-30)', async () => { + // A hook is the sanctioned place for a custom OAuth2 refresh grant, i.e. network I/O on the + // request path, so it must be able to observe the caller's cancellation. + const transport = new FakeTransport([ + challengeResponse(401, 'WWW-Authenticate', 'Basic realm="x"').response, + countingResponse(200).response, + ]); + const controller = new AbortController(); + let observed: AbortSignal | undefined; + const descriptor = authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), + challengeHook: (_response, request, options) => { + observed = options?.signal; + return Promise.resolve(request); + }, + }); + const request = aRequest(); + + await new Cursor({ + steps: [descriptor], + transport, + request, + context: aRequestContext(request), + signal: controller.signal, + }).advance(); + + expect(observed).toBe(controller.signal); + }); + + // There is deliberately no "the provider is not given the call signal" test any more: after M6, + // `TokenProvider` is `() => Promise` and has no parameter to populate, so the property + // is structural. A test for it would only be exercising the type checker. +}); + +describe('authStep: a failing release never masks the primary error (RECOV-12)', () => { + test("a rejecting close() keeps the HOOK's own error primary (AUTH-32)", async () => { + // `Response.close()` rethrows whatever cancelling the body raised, so a bare + // `await response.close(); throw error;` discarded the hook's failure and surfaced the teardown + // failure in its place -- the inversion RECOV-12 forbids, and the one 5b's `decideOrClose` + // already guards against with the same two helpers. + const hookFailure = new Error('hook exploded'); + const descriptor = authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), + challengeHook: () => Promise.reject(hookFailure), + }); + + const error = await rejectionOf( + runThrough(descriptor, new FakeTransport([hostileChallenge()])), + ); + + const suppressed = error as SuppressedErrorLike; + expect(suppressed.error).toBe(hookFailure); + expect(suppressed.suppressed).toBe(CANCEL_FAILURE); + }); + + test('a rejecting close() keeps PlaintextCredentialError primary on the replay guard (AUTH-28)', async () => { + const descriptor = authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), + // A replacement that downgrades to http AND carries a credential: AUTH-28 must refuse it, and + // that refusal is what the caller has to be able to see. + challengeHook: () => + Promise.resolve( + Request.newBuilder() + .url('http://example.com/a') + .headers( + Headers.newBuilder().set('Authorization', 'Bearer t').build(), + ) + .build(), + ), + }); + + const error = await rejectionOf( + runThrough(descriptor, new FakeTransport([hostileChallenge()])), + ); + + const suppressed = error as SuppressedErrorLike; + expect(suppressed.error).toBeInstanceOf(PlaintextCredentialError); + expect(suppressed.suppressed).toBe(CANCEL_FAILURE); + }); +}); + +describe('authStep: a non-replayable body still evicts (AUTH-31 vs AUTH-36)', () => { + test('a revoked token is evicted even though the replay is skipped', async () => { + // AUTH-31 gates the REPLAY on replayability; AUTH-36's eviction is a separate sentence. An + // earlier shape skipped the whole hook for a one-shot body, which left the token the server had + // just rejected sitting in the cache -- and a token with no `expiresAt` (AUTH-10's "never locally + // expires") never aged out either, so a stream-only client re-sent the dead credential forever. + let issued = 0; + const credentials: AuthCredentialSet = { + bearer: { + provider: () => { + issued += 1; + return Promise.resolve(createBearerToken(`t${String(issued)}`)); + }, + }, + }; + const descriptor = authStep({credentials, tiers: tiersFor('OAUTH2')}); + + const first = challengeResponse( + 401, + 'WWW-Authenticate', + 'Bearer realm="x"', + ); + const firstDrive = await runThrough( + descriptor, + new FakeTransport([first.response]), + { + request: oneShotPost(), + }, + ); + const second = challengeResponse( + 401, + 'WWW-Authenticate', + 'Bearer realm="x"', + ); + const secondTransport = new FakeTransport([second.response]); + await runThrough(descriptor, secondTransport, {request: oneShotPost()}); + + // AUTH-31 still holds: the original is surfaced unchanged and NOT closed. + expect(firstDrive.status.code).toBe(401); + expect(first.cancelCount()).toBe(0); + // AUTH-36 now also holds: the second request carries a freshly fetched token, not the dead one. + expect(secondTransport.calls[0]?.request.headers.get('Authorization')).toBe( + 'Bearer t2', + ); + }); +}); + +describe('authStep: refresh-margin validation (AUTH-34/AUTH-35)', () => { + // `nowMs + marginMs > expiresAt` is false for a NaN margin, so BOTH the margin check and AUTH-35's + // no-margin check say "not expired" and the cache serves a dead token from the hot path forever. + // Same rule and wording 5a's `retrySettings()` and 5b's `redirectSettings()` apply. + test('rejects a non-finite bearerMarginMs', () => { + expect(() => + authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), + bearerMarginMs: Number.NaN, + }), + ).toThrow('finite, non-negative duration'); + }); + + test('rejects a negative bearerMarginMs', () => { + expect(() => + authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), + bearerMarginMs: -1, + }), + ).toThrow('finite, non-negative duration'); + }); + + test('rejects a non-finite per-credential marginMs', () => { + expect(() => + authStep({ + credentials: { + bearer: { + provider: () => Promise.resolve(createBearerToken('t')), + marginMs: Number.NaN, + }, + }, + tiers: tiersFor('OAUTH2'), + }), + ).toThrow('finite, non-negative duration'); + }); +}); + +describe('authStep: the bearer refresh margin, in effect (AUTH-34/AUTH-37)', () => { + // The margin was validated at construction but its EFFECT was unasserted: both + // `AuthStepSettings.bearerMarginMs`'s 30 s default and `BearerCredential.marginMs`'s override could + // be deleted outright and every test still passed. AUTH-34 names the 30 s default itself, and + // `marginMs` is public surface, so both need a test that fails when the number changes. + // The two tests below pin the default from BOTH sides, deliberately. A single "a token 20 s out + // gets refreshed" assertion is satisfied by any margin >= 20 s, so it cannot tell 30 s from 60 s; + // the pair brackets the boundary at exactly 30 000 ms. + test("a token expiring just INSIDE AUTH-34's 30 s default is refreshed in the background", async () => { + const aging = agingTokenProvider(29_999); + const descriptor = authStep({ + credentials: aging.credentials, + tiers: tiersFor('OAUTH2'), + clock: {now: () => 0}, + }); + + const stamped = await stampsOverThreeDrives(descriptor); + + // Drive 2 stamps the stale-but-valid t1 and kicks off the refresh; drive 3 sees t2 (AUTH-37). + expect(stamped).toEqual(['Bearer t1', 'Bearer t1', 'Bearer t2']); + expect(aging.callCount()).toBe(2); + }); + + test('a token expiring just OUTSIDE the 30 s default stays in the fresh zone', async () => { + const aging = agingTokenProvider(30_001); + const descriptor = authStep({ + credentials: aging.credentials, + tiers: tiersFor('OAUTH2'), + clock: {now: () => 0}, + }); + + const stamped = await stampsOverThreeDrives(descriptor); + + expect(stamped).toEqual(['Bearer t1', 'Bearer t1', 'Bearer t1']); + expect(aging.callCount()).toBe(1); + }); + + test('a per-credential marginMs overrides the step-wide one', async () => { + const aging = agingTokenProvider(29_999); + const bearer = aging.credentials.bearer; + invariant(bearer !== undefined, 'agingTokenProvider configures a bearer'); + const descriptor = authStep({ + credentials: {bearer: {...bearer, marginMs: 30_000}}, + tiers: tiersFor('OAUTH2'), + bearerMarginMs: 0, // the step-wide margin alone would leave t1 in the fresh zone forever + clock: {now: () => 0}, + }); + + const stamped = await stampsOverThreeDrives(descriptor); + + expect(stamped).toEqual(['Bearer t1', 'Bearer t1', 'Bearer t2']); + expect(aging.callCount()).toBe(2); + }); + + test('an explicit zero margin beats the default and suppresses the background refresh', async () => { + const aging = agingTokenProvider(29_999); // inside the default margin, outside a zero one + const descriptor = authStep({ + credentials: aging.credentials, + tiers: tiersFor('OAUTH2'), + bearerMarginMs: 0, + clock: {now: () => 0}, + }); + + const stamped = await stampsOverThreeDrives(descriptor); + + expect(stamped).toEqual(['Bearer t1', 'Bearer t1', 'Bearer t1']); + expect(aging.callCount()).toBe(1); + }); +}); + +describe('authStep: the replay HTTPS guard covers Proxy-Authorization too (AUTH-25/AUTH-28)', () => { + test('a replacement carrying only Proxy-Authorization over plaintext is refused', async () => { + // AUTH-28 says ANY path where a credential will be attached, and AUTH-25 makes + // `Proxy-Authorization` exactly such a path for a 407. The guard's `Authorization` arm was + // asserted and this one was not, so dropping it left a proxy credential able to go out over + // plaintext with the whole suite green. + const challenged = challengeResponse( + 407, + 'Proxy-Authenticate', + 'Basic realm="p"', + ); + const transport = new FakeTransport([ + challenged.response, + countingResponse(200).response, + ]); + const descriptor = authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), // outbound guard skipped entirely + challengeHook: (_response, request) => + Promise.resolve( + request + .newBuilder() + .headers( + request.headers + .newBuilder() + .set('Proxy-Authorization', 'Basic c3B5') + .build(), + ) + .build(), + ), + }); + + const error = await rejectionOf( + runThrough(descriptor, transport, { + request: aRequest('http://example.com/a'), + }), + ); + + expect(error).toBeInstanceOf(PlaintextCredentialError); + expect(transport.sendCount).toBe(1); // the replacement never reached the wire + expect(challenged.cancelCount()).toBe(1); // and the 407 was closed before the throw + }); +}); + +describe('authStep: a challenge this client cannot echo (AUTH-21/AUTH-22)', () => { + test('a non-ASCII Digest realm surfaces the 401 unchanged rather than throwing', async () => { + // HTTP-19 lets a received field-value carry obs-text, so `realm="café"` -- a real RFC 7616 shape, + // which is why the spec has a `charset` parameter at all -- reaches us intact. HTTP-18 will not + // let it back out. `parseDigestChallenge` declines, so AUTH-33 surfaces the 401 open and + // unchanged; building the header anyway threw HeaderValidationError out of the whole step. + const challenge = challengeResponse( + 401, + 'WWW-Authenticate', + 'Digest realm="café", nonce="n", algorithm=MD5, charset=UTF-8', + ); + const transport = new FakeTransport([challenge.response]); + const descriptor = authStep({ + credentials: {digest: {username: 'u', password: 'p'}}, + tiers: tiersFor('DIGEST'), + }); + + const response = await runThrough(descriptor, transport); + + expect(response.status.code).toBe(401); + expect(transport.sendCount).toBe(1); // no replay + expect(challenge.cancelCount()).toBe(0); // AUTH-33: returned open, the caller's to close + }); +}); + +describe('authStep: cancellation (AUTH-30)', () => { + test('a call already aborted when the challenge arrives never runs the hook at all', async () => { + // The default OAUTH2 hook does an IdP round trip and the BASIC/DIGEST one does key derivation. + // Neither is worth doing for a caller who has already gone, so the hook is not even built. + let hookRan = false; + const controller = new AbortController(); + controller.abort(); + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const transport = new FakeTransport([ + challenged.response, + countingResponse(200).response, + ]); + const descriptor = authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), + challengeHook: (_response, request) => { + hookRan = true; + return Promise.resolve(request); + }, + }); + + const response = await runThrough(descriptor, transport, { + signal: controller.signal, + }); + + expect(hookRan).toBe(false); + expect(transport.sendCount).toBe(1); + expect(response.status.code).toBe(401); + expect(challenged.cancelCount()).toBe(0); // returned open, the caller's to close + }); + + test('an abort arriving DURING the hook still spends no second wire send', async () => { + // `redirectStep` checks `signal?.aborted` before each hop and the retry engine before each + // attempt; the auth step must not be the one pillar that dispatches for a caller who has gone. + const controller = new AbortController(); + const transport = new FakeTransport([ + challengeResponse(401, 'WWW-Authenticate', 'Basic realm="x"').response, + countingResponse(200).response, + ]); + const descriptor = authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), + challengeHook: (_response, request) => { + controller.abort(); // the caller gives up while the hook is running + return Promise.resolve(request); + }, + }); + + const response = await runThrough(descriptor, transport, { + signal: controller.signal, + }); + + expect(response.status.code).toBe(401); // surfaced open, like every other no-replay outcome + expect(transport.sendCount).toBe(1); + }); +}); diff --git a/packages/core/src/auth/auth-step.ts b/packages/core/src/auth/auth-step.ts new file mode 100644 index 0000000..8bb15c9 --- /dev/null +++ b/packages/core/src/auth/auth-step.ts @@ -0,0 +1,785 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/auth-step.ts +import type {Clock} from '../config/clock.js'; +import type {Request} from '../http/request.js'; +import type {Response} from '../http/response.js'; +import {assertNever, invariant} from '../invariant.js'; +import type {StepDescriptor} from '../pipeline/step.js'; +import {releaseQuietly, withReleaseFailure} from '../recovery/release.js'; +import { + clearCrossOriginMarker, + hasCrossOriginMarker, +} from '../redirect/cross-origin.js'; +import {basicHandler} from './basic.js'; +import {BearerTokenCache} from './bearer-cache.js'; +import {parseChallenges} from './challenge.js'; +import type {Challenge, ChallengeHandler} from './challenge.js'; +import {composingHandler, type ComposingHandler} from './composing-handler.js'; +import type { + ApiKeyCredential, + NameKeyCredential, + TokenProvider, +} from './credential.js'; +import type {AuthDescriptor} from './descriptor.js'; +import {digestHandler} from './digest.js'; +import type {DigestAlgorithm} from './digest.js'; +import {PlaintextCredentialError} from './errors.js'; +import {resolveAuthRequirement, type AuthTiers} from './resolve.js'; +import type {AuthScheme} from './scheme.js'; +import {stampStaticKey} from './static-key.js'; + +/** + * Username and password for the `BASIC` scheme. + * + * @public + */ +export interface BasicCredential { + /** The user id (AUTH-14: non-empty; whitespace permitted). */ + readonly username: string; + /** The password (AUTH-14: non-empty; whitespace permitted). */ + readonly password: string; +} + +/** + * Username, password, and algorithm preference for the `DIGEST` scheme. + * + * @public + */ +export interface DigestCredential { + /** The user id. Must not be blank. */ + readonly username: string; + /** The password. Must not be blank. */ + readonly password: string; + /** + * Preferred-first order, and also the acceptable set (AUTH-16). Omitted means strongest-first over + * all four supported algorithms. + */ + readonly algorithmPreference?: readonly DigestAlgorithm[] | undefined; +} + +/** + * The token source and refresh margin for the `OAUTH2` scheme. + * + * @public + */ +export interface BearerCredential { + /** The token source (AUTH-11). */ + readonly provider: TokenProvider; + /** Per-credential refresh margin; falls back to {@link AuthStepSettings.bearerMarginMs}. */ + readonly marginMs?: number | undefined; +} + +/** + * The static key, header, and prefix for the `API_KEY` scheme (AUTH-26). + * + * @public + */ +export interface ApiKeyCredentialConfig { + /** The key. Both credential classes are nominal, so no object literal substitutes for one. */ + readonly credential: ApiKeyCredential | NameKeyCredential; + /** The header to write. Defaults to `Authorization`. */ + readonly headerName?: string | undefined; + /** A scheme prefix, written followed by exactly one space. */ + readonly prefix?: string | undefined; +} + +/** + * Which schemes a caller has actually configured a credential for. + * + * This shape is designed by this phase — neither the product spec nor the design doc names one. It is + * both the credential material the step stamps with, and the source `availableSchemesOf()` derives + * AUTH-5's `availableSchemes` from, which is what keeps resolution from ever inspecting a concrete + * credential value. + * + * @public + */ +export interface AuthCredentialSet { + /** Enables the `BASIC` scheme. */ + readonly basic?: BasicCredential | undefined; + /** Enables the `DIGEST` scheme. */ + readonly digest?: DigestCredential | undefined; + /** Enables the `OAUTH2` scheme. */ + readonly bearer?: BearerCredential | undefined; + /** Enables the `API_KEY` scheme. */ + readonly apiKey?: ApiKeyCredentialConfig | undefined; +} + +/** + * AUTH-5: derives the satisfiable-scheme set from which credentials are configured, without exposing + * any credential value to resolution. + * + * `NO_AUTH` is deliberately absent: AUTH-5 makes it satisfiable unconditionally, so membership here + * would be redundant and would let a caller's empty credential set read as "nothing is available" + * while a `NO_AUTH` requirement still resolves. + * + * @param credentials - the configured credential set. + * @returns the schemes with a matching credential. + * + * @internal + */ +export function availableSchemesOf( + credentials: AuthCredentialSet, +): ReadonlySet { + const schemes = new Set(); + if (credentials.basic !== undefined) schemes.add('BASIC'); + if (credentials.digest !== undefined) schemes.add('DIGEST'); + if (credentials.bearer !== undefined) schemes.add('OAUTH2'); + if (credentials.apiKey !== undefined) schemes.add('API_KEY'); + return schemes; +} + +/** + * The handler list is derived from `credentials`, digest-first — "callers order stronger schemes + * first" (AUTH-23). Both handlers need a username and password to do anything, so a zero-argument + * `[digestHandler(), basicHandler()]` default is not constructible, which is why this is derived + * rather than defaulted. + * + * There is deliberately no caller override. An earlier shape took `AuthStepSettings.handlers`, which + * forced `Challenge`/`ChallengeHandler`/`DigestUriContext` onto the public barrel to make the field + * callable — and then delivered less than it promised: `basicHandler`/`digestHandler` stay internal, + * so a caller supplying one handler silently LOST the credential-derived ones rather than composing + * with them. {@link AuthStepSettings.challengeHook} already covers the custom-scheme case end to end, + * with a shape a caller can actually satisfy. + */ +function buildHandlers( + credentials: AuthCredentialSet, +): readonly ChallengeHandler[] { + const handlers: ChallengeHandler[] = []; + if (credentials.digest !== undefined) { + handlers.push( + digestHandler(credentials.digest.username, credentials.digest.password, { + algorithmPreference: credentials.digest.algorithmPreference, + }), + ); + } + if (credentials.basic !== undefined) { + handlers.push( + basicHandler(credentials.basic.username, credentials.basic.password), + ); + } + return handlers; +} + +/** + * AUTH-30's pluggable 401/407 reaction. + * + * Returning `undefined` means "no replacement" — the challenge response is surfaced unchanged. A + * returned request is driven exactly once through a fresh copy of the downstream chain, with no + * further challenge handling on that drive. + * + * @public + */ +export type ChallengeHook = ( + response: Response, + request: Request, + options?: { + /** + * The calling request's cancellation, threaded straight through from `StepContext.signal`. + * + * A hook is the sanctioned place to run a custom OAuth2 refresh-token grant, which is external + * I/O on the request path -- and `docs/knowledge/concurrency-and-async.md` is explicit that a + * signal accepted at the top of a call chain must reach the actual I/O primitive, or it is + * decoration. Without this a hung hook pinned the auth step, every retry attempt nested under + * it, and the whole request, with no way for the caller to abort. + */ + readonly signal?: AbortSignal | undefined; + }, +) => Promise; + +/** + * Everything {@link authStep} accepts. + * + * @public + */ +export interface AuthStepSettings { + /** Which schemes are available, and the material to stamp them with. */ + readonly credentials: AuthCredentialSet; + /** + * The operation and client tiers, fixed at construction. The `perCall` slot may additionally be + * supplied per call via `RequestOptions.auth` (AUTH-4), which wins over any `perCall` value + * configured here. + */ + readonly tiers: AuthTiers; + /** + * Replaces the scheme-dependent default 401/407 reaction entirely — e.g. a custom OAuth2 + * refresh-token grant (AUTH-30). + */ + readonly challengeHook?: ChallengeHook | undefined; + /** + * Refresh margin ahead of a bearer token's expiry. + * + * @defaultValue 30000 — AUTH-34's "default 30 seconds". + */ + readonly bearerMarginMs?: number | undefined; + /** + * Wall-clock source for bearer expiry evaluation, injected so the three-zone policy is testable + * through the step and not only through the cache directly. Reading `Date.now()` inside the cache + * would be a second, uncontrollable clock — `bearer-cache.ts` takes an injected `nowMs` precisely so + * its one caller can supply a controllable one, and this is that caller. + * + * Typed as the `now()` half of {@link Clock}, not a bare `() => number` and not the whole `Clock`: + * `RetryStepOptions.clock` is a full `Clock`, and one instance has to satisfy both slots or a + * caller who fakes time for retry and forgets auth gets two clocks disagreeing inside one pipeline. + * Narrowing to the member actually used means no caller has to implement `monotonic`/`sleep` for a + * step that never sleeps. + * + * @defaultValue a `now()` reading `Date.now()` + */ + readonly clock?: Pick | undefined; +} + +/** + * A refresh margin must be a finite, non-negative duration -- the same rule and the same wording + * 5a's `retrySettings()` and 5b's `redirectSettings()` apply to every numeric setting they take, and + * an invalid value is a PROGRAMMER error there and here alike, so it trips `invariant()` rather than + * a typed error leaf. + * + * Not decorative. `isBearerTokenExpired` is `nowMs + marginMs > expiresAt`, so a `NaN` margin -- the + * shape `Number(process.env.MARGIN_MS)` produces for an unset variable -- makes BOTH the margin + * comparison and AUTH-35's no-margin comparison false. The cache then reads a long-dead token as + * fresh, returns it from the hot path, and never calls the provider again: a revoked credential + * stamped onto every request, indefinitely and silently. A large negative margin does the same. + */ +function validateMarginMs(label: string, value: number | undefined): void { + if (value === undefined) return; + invariant( + Number.isFinite(value) && value >= 0, + `${label} must be a finite, non-negative duration, got ${String(value)}`, + ); +} + +/** AUTH-4: a per-call descriptor (`RequestOptions.auth`, via `StepContext.options`) fills the perCall slot. */ +function effectiveTiers( + configured: AuthTiers, + perCall: AuthDescriptor | undefined, +): AuthTiers { + return perCall === undefined ? configured : {...configured, perCall}; +} + +/** Stable identity for pillar-slot occupancy and anchor matching (PIPE-6/PIPE-18). @internal */ +export const AUTH_STEP_TYPE: unique symbol = Symbol('dexpace.auth'); + +/** AUTH-28: case-insensitive, evaluated before any token fetch or header write. */ +function requireHttps(url: URL, scheme: AuthScheme): void { + if (url.protocol.toLowerCase() !== 'https:') { + throw new PlaintextCredentialError('authStep', scheme); + } +} + +interface StampContext { + readonly scheme: AuthScheme; + readonly credentials: AuthCredentialSet; + readonly bearerCache: BearerTokenCache; + readonly marginMs: number; + readonly nowMs: number; + readonly signal: AbortSignal | undefined; +} + +function withHeader(request: Request, name: string, value: string): Request { + return request + .newBuilder() + .headers(request.headers.newBuilder().set(name, value).build()) + .build(); +} + +/** + * Whether the caller has given up. + * + * A function, not two inline `signal?.aborted === true` tests, and the indirection is load-bearing: + * `AbortSignal.aborted` is a LIVE getter that flips while an `await` is outstanding, but TypeScript + * narrows it like an ordinary property and carries that narrowing straight across the await. The + * second check in {@link handleChallenge} -- the one that exists precisely because the world moved + * during the hook -- therefore reads as `'false | undefined' and 'true' have no overlap` and fails to + * compile, which is the compiler being confidently wrong about mutable external state. Routing every + * read through a call re-reads the getter each time. + * + * `docs/knowledge/concurrency-and-async.md`: "state checked before an `await` must be re-validated + * after every `await` that could have let the world move." + */ +function isAborted(signal: AbortSignal | undefined): boolean { + return signal?.aborted === true; +} + +/** AUTH-25: which challenge header carried the offer decides which header the answer goes into. */ +function answerHeaderName(isProxy: boolean): string { + return isProxy ? 'Proxy-Authorization' : 'Authorization'; +} + +/** + * `OAUTH2` and `API_KEY` stamp preemptively — no server round-trip is needed to know what to send. + * `BASIC`, `DIGEST`, and `NO_AUTH` never do: Digest structurally cannot stamp before seeing the + * server's `realm`/`nonce`, AUTH-14/AUTH-23–AUTH-25 phrase Basic entirely in terms of answering a + * parsed challenge, and `NO_AUTH` has nothing to stamp. + * + * An exhaustive `switch` closing on `assertNever`, not an if-chain: `AuthScheme` is a closed + * discriminant, and `docs/knowledge/data-modeling.md` bars an if-chain over one because it gives no + * exhaustiveness guarantee and falls through silently when a variant is added — and the value that + * would fall through here is a credential-stamping decision. + */ +async function preemptiveStamp( + request: Request, + context: StampContext, +): Promise { + switch (context.scheme) { + case 'OAUTH2': { + const bearer = context.credentials.bearer; + invariant( + bearer !== undefined, + 'resolved OAUTH2 but no bearer credential configured', + ); + const token = await context.bearerCache.stamp({ + provider: bearer.provider, + marginMs: bearer.marginMs ?? context.marginMs, + nowMs: context.nowMs, + signal: context.signal, + }); + return withHeader(request, 'Authorization', `Bearer ${token.token}`); + } + case 'API_KEY': { + const apiKey = context.credentials.apiKey; + invariant( + apiKey !== undefined, + 'resolved API_KEY but no apiKey credential configured', + ); + const {headerName, headerValue} = stampStaticKey( + apiKey.credential, + apiKey, + ); + return withHeader(request, headerName, headerValue); + } + case 'BASIC': + case 'DIGEST': + case 'NO_AUTH': + return request; // challenge-driven, or no credential at all + default: + return assertNever(context.scheme); + } +} + +/** + * What the outbound pass decided, carried into the challenge pass. + * + * `crossOrigin` has to survive past the dispatch: AUTH-29's suppression covers the WHOLE hop, so the + * challenge reaction needs the same answer the outbound pass computed, and the marker itself is gone + * from the request by then. + */ +interface OutboundPlan { + readonly crossOrigin: boolean; + readonly outbound: Request; +} + +/** + * AUTH-29 then AUTH-28, in that order. + * + * The cross-origin check comes FIRST, and the marker is cleared unconditionally before either branch + * — it must never reach the wire, and clearing up front means it cannot survive into a request built + * by the stamping logic. A marked hop then skips the HTTPS guard AND preemptive stamping entirely, + * forwarding the cleared request credential-free; AUTH-29 makes that skip deliberate, so a + * server-chosen downgrade hop is forwarded rather than hard-failing. + * + * On an unmarked hop the HTTPS guard runs only where a credential will actually be attached — + * `NO_AUTH` is exempt, matching AUTH-28's own qualifier — and before any token fetch or header write. + */ +async function planOutbound( + seedRequest: Request, + context: StampContext, +): Promise { + const crossOrigin = hasCrossOriginMarker(seedRequest.headers); + const cleared = seedRequest + .newBuilder() + .headers(clearCrossOriginMarker(seedRequest.headers)) + .build(); + + if (crossOrigin) return {crossOrigin, outbound: cleared}; + if (context.scheme !== 'NO_AUTH') requireHttps(cleared.url, context.scheme); + return {crossOrigin, outbound: await preemptiveStamp(cleared, context)}; +} + +interface ChallengeSelection { + readonly value: string; + readonly isProxy: boolean; +} + +/** + * AUTH-25: a 401 is answered from `WWW-Authenticate`, a 407 from `Proxy-Authenticate`. Reading only + * the header that matches the status keeps the pairing honest — a 401 carrying a stray + * `Proxy-Authenticate` must not produce a `Proxy-Authorization`, and vice versa. + */ +function pickChallengeHeader( + response: Response, +): ChallengeSelection | undefined { + if (response.status.code === 401) { + const www = response.headers.get('WWW-Authenticate'); + return www === undefined ? undefined : {value: www, isProxy: false}; + } + const proxy = response.headers.get('Proxy-Authenticate'); + return proxy === undefined ? undefined : {value: proxy, isProxy: true}; +} + +interface DefaultHookContext { + readonly scheme: AuthScheme; + readonly credentials: AuthCredentialSet; + readonly bearerCache: BearerTokenCache; + readonly composing: ComposingHandler; + readonly marginMs: number; + readonly nowMs: number; + readonly signal: AbortSignal | undefined; +} + +/** AUTH-36: evict the exact rejected token, fetch a genuinely fresh one, re-stamp once. */ +async function oauth2ChallengeHook( + request: Request, + selection: ChallengeSelection, + context: DefaultHookContext, +): Promise { + const bearer = context.credentials.bearer; + invariant( + bearer !== undefined, + 'resolved OAUTH2 but no bearer credential configured', + ); + const headerName = answerHeaderName(selection.isProxy); + const rejected = request.headers.get(headerName); + // AUTH-36: no Authorization on the rejected request -> surface the challenge unchanged. + if (rejected === undefined) return undefined; + const challenges: readonly Challenge[] = parseChallenges(selection.value); + if (!challenges.some(challenge => challenge.scheme === 'bearer')) { + return undefined; // AUTH-36: the response advertises no Bearer challenge + } + + // AUTH-36's preservation clause, made observable: `evict()` clears the cache only when the cached + // token IS the rejected one, and hands back the survivor otherwise. A survivor means another + // request already refreshed past this 401, so the retry stamps THAT rather than burning a second + // provider fetch to arrive at the same place. + const preserved = context.bearerCache.evict(rejected); + if (preserved !== undefined) { + return withHeader(request, headerName, `Bearer ${preserved.token}`); + } + + // AUTH-37's post-eviction clause: a fetch that STARTED after this 401, so the retry cannot re-send + // the rejected token. Plain `stamp()` would coalesce onto a fetch that may have started before this + // 401 came back, and AUTH-11 permits a provider that caches internally, so that fetch can resolve + // to exactly the token the server just rejected. `refreshPostEviction` still coalesces concurrent + // 401s onto one fetch (AUTH-34) -- it supersedes pre-401 fetches only. + // + // The margin is INERT on this path and is passed anyway: `refresh()` validates the fetched token + // against a zero margin (AUTH-35) and never reads `BearerFetch.marginMs`, which only `stamp()` + // consults. It is resolved identically to the preemptive path rather than hard-coded, so the two + // call sites cannot drift apart if `refresh()` ever grows a margin-dependent branch -- and so a + // reader comparing them does not have to work out which of two spellings is the intended one. + const token = await context.bearerCache.refreshPostEviction({ + provider: bearer.provider, + marginMs: bearer.marginMs ?? context.marginMs, + nowMs: context.nowMs, + signal: context.signal, + }); + return withHeader(request, headerName, `Bearer ${token.token}`); +} + +/** + * AUTH-23–AUTH-25: delegate to the composing handler; no replacement when nothing is satisfiable. + * + * `selection.isProxy` reaches {@link answerHeaderName} and nothing else. The handlers produce the + * header VALUE only, and neither of them varies it by proxy-ness, so the flag stops here rather than + * being threaded into a contract that cannot use it. + */ +async function basicDigestChallengeHook( + request: Request, + selection: ChallengeSelection, + context: DefaultHookContext, +): Promise { + const challenges = parseChallenges(selection.value); + const url = request.url; // HTTP-5: a fresh URL per access, so read it once. + const requestTarget = `${url.pathname}${url.search}`; + const value = await context.composing.stamp(challenges, { + method: request.method, + requestTarget, + }); + if (value === undefined) return undefined; + return withHeader(request, answerHeaderName(selection.isProxy), value); +} + +/** + * The scheme-dependent default hook body. AUTH-30's generic contract governs INVOCATION; this decides + * what each resolved scheme does with a parsed challenge. `API_KEY`/`NO_AUTH` never react — static or + * absent credentials have no reactive behavior, which is exactly AUTH-30's "the default hook yields no + * replacement". + * + * Exhaustive `switch` + `assertNever`, not an if-chain, for the same reason as `preemptiveStamp`: a + * sixth `AuthScheme` added later must not silently inherit the BASIC/DIGEST branch's stamping. + */ +async function defaultChallengeHook( + response: Response, + request: Request, + context: DefaultHookContext, +): Promise { + const selection = pickChallengeHeader(response); + if (selection === undefined) return undefined; + + switch (context.scheme) { + case 'OAUTH2': + return oauth2ChallengeHook(request, selection, context); + case 'BASIC': + case 'DIGEST': + return basicDigestChallengeHook(request, selection, context); + case 'API_KEY': + case 'NO_AUTH': + return undefined; + default: + return assertNever(context.scheme); + } +} + +/** + * AUTH-28 on the REPLAY path. The outbound guard is not sufficient here: it is skipped entirely for + * `NO_AUTH`, and nothing constrains a caller-supplied hook to preserve the request URL. A replay + * carrying a credential header is by definition "a path where a credential will be attached", and + * AUTH-28 says ANY such path. + * + * The challenge response is closed before the throw, for the same reason AUTH-32 closes it on a hook + * throw: this is past the point where the caller still owns it, so propagating unclosed leaks the body. + */ +async function guardReplayScheme( + replacement: Request, + response: Response, + scheme: AuthScheme, +): Promise { + const carriesCredential = + replacement.headers.has('Authorization') || + replacement.headers.has('Proxy-Authorization'); + if (!carriesCredential) return; + try { + requireHttps(replacement.url, scheme); + } catch (error) { + // The GUARD's error stays primary. `Response.close()` rethrows whatever cancelling the body + // raised, so a bare `await response.close()` here replaced `PlaintextCredentialError` -- typed, + // caller-catchable, security-relevant -- with the teardown failure, the inversion RECOV-12 + // forbids. Same helpers 4b built and 5b's `decideOrClose` uses. + throw withReleaseFailure(error, await releaseQuietly(response)); + } +} + +/** What {@link runHook} needs besides the hook itself. Bundled to stay inside `max-params`. */ +interface HookInvocation { + readonly response: Response; + readonly request: Request; + readonly signal: AbortSignal | undefined; +} + +/** + * AUTH-32: a hook that throws, or whose promise rejects, closes the open challenge response before the + * error propagates. + * + * The HOOK's error stays primary. `Response.close()` rethrows whatever cancelling the body raised, so + * a bare `await response.close()` here discarded the hook's own failure and surfaced the teardown + * failure in its place -- RECOV-12's "attaching any close error as suppressed so it never masks the + * primary", inverted. `releaseQuietly`/`withReleaseFailure` are 4b's helpers, shared with the retry + * engine and 5b's `decideOrClose`. + */ +async function runHook( + hook: ChallengeHook, + invocation: HookInvocation, +): Promise { + const {response, request, signal} = invocation; + try { + return await hook(response, request, {signal}); + } catch (error) { + throw withReleaseFailure(error, await releaseQuietly(response)); + } +} + +interface ChallengeDrive { + readonly response: Response; + readonly outbound: Request; + readonly fork: () => (request?: Request) => Promise; + readonly settings: AuthStepSettings; + readonly hookContext: DefaultHookContext; +} + +/** + * AUTH-30–AUTH-33: the 401/407 reaction, split out of the pillar closure to keep both under the + * 70-line cap and to give the response-lifecycle rules one place to live. + * + * The challenge response is returned OPEN — the caller's to close — on every no-replay outcome (no + * matching challenge header, a one-shot body, a hook yielding nothing, a non-replayable replacement). + * It is CLOSED before the replay dispatch, and before propagating a hook throw or a replay-path guard + * failure. + */ +async function handleChallenge(drive: ChallengeDrive): Promise { + const {response, outbound, fork, settings, hookContext} = drive; + + const selection = pickChallengeHeader(response); + // AUTH-33: no matching challenge header -> unchanged, and the hook is never consulted. + if (selection === undefined) return response; + + // There is deliberately NO "skip the hook when the body is one-shot" fast path here. An earlier + // shape had one, on the reasoning that the default hook would only fetch a replacement that is + // then thrown away -- which is wrong on inspection: OAUTH2's hook EVICTS the rejected token and + // populates the cache for every subsequent request, so the work is not wasted. Skipping it left a + // server-revoked token cached behind every non-replayable request, and a token with no `expiresAt` + // (AUTH-10's "never locally expires") never aged out either, so a stream-only client re-sent the + // dead credential forever. AUTH-36's eviction clause and AUTH-31's replay gate are separate + // sentences; only the DISPATCH below is gated. + + // The caller had already abandoned this call before the challenge even arrived. The default OAUTH2 + // hook does an IdP round trip and the BASIC/DIGEST one does key derivation, so running either here + // is pure waste -- and `redirectStep` makes the same call, returning the current response open + // rather than doing more work. The signal threaded into `runHook` below covers the other case: an + // abort arriving while a hook is already in flight. + if (isAborted(hookContext.signal)) return response; + + const hook: ChallengeHook = + settings.challengeHook ?? + ((res, req) => defaultChallengeHook(res, req, hookContext)); + + const replacement = await runHook(hook, { + response, + request: outbound, + signal: hookContext.signal, + }); + // AUTH-33: the hook yielded nothing -> the challenge response is surfaced unchanged and unclosed. + if (replacement === undefined) return response; + + // AUTH-31, applied uniformly: a non-replayable replacement body skips the replay, surfaces the + // original unchanged, and MUST NOT close it -- the caller owns it. The reference applies this gate on + // its sync step only and recommends a port extend it; with one unified step there is exactly one + // place to apply it, so it covers OAUTH2's evict-and-retry too. + if (replacement.body !== undefined && !replacement.body.replayable) { + return response; + } + + // The caller gave up WHILE the hook ran -- the pre-hook check above cannot see this one. Surfacing + // the challenge open and unclosed is the same answer every other no-replay outcome gives; spending + // a second wire send on a request nobody is waiting for is the one thing that must not happen. + if (isAborted(hookContext.signal)) return response; + + await guardReplayScheme(replacement, response, hookContext.scheme); // AUTH-28 + + await response.close(); // AUTH-30: the original is closed before the replacement is driven. + // AUTH-30: exactly once, through a FRESH chain copy, with no further challenge handling on it. + return fork()(replacement); +} + +/** + * The single AUTH pillar step (AUTH-27–AUTH-33). + * + * One pluggable challenge-reaction extension point ({@link AuthStepSettings.challengeHook}) with a + * scheme-dependent default body — not three competing mechanisms. AUTH-30's contract (consult the + * hook, close the original on a non-null replacement, re-drive once through a fresh chain copy, no + * nested re-challenge) governs every scheme uniformly; AUTH-23–AUTH-26 and AUTH-34–AUTH-37 describe + * what the DEFAULT hook does for each resolved scheme. + * + * `stage: 'AUTH'` is baked into the descriptor this factory returns, which is how PIPE-36 is satisfied + * structurally. `ctx.fork` is asserted rather than checked — AUTH is in `PILLAR_STAGES`, so its + * absence means the descriptor was installed somewhere it cannot be, a programmer error. Every + * dispatch, INCLUDING the first, goes through a fresh `ctx.fork()` rather than `ctx.next()`, since a + * challenge may drive the chain a second time and `next()`'s single-invocation guard would trip + * (PIPE-15). + * + * Nested inside both redirect (5b) and retry (5a) per AUTH-27's "redirect wraps retry wraps auth", so + * it re-resolves and re-stamps per redirect hop and per retry attempt (PIPE-2). + * + * Both challenge statuses are handled: a 401 is answered from `WWW-Authenticate` into `Authorization`, + * a 407 from `Proxy-Authenticate` into `Proxy-Authorization` (AUTH-25). A cross-origin-marked hop + * answers neither (AUTH-29). + * + * AUTH-38 is satisfied structurally: `fn` is `async`, so the HTTPS-guard failure and any hook error + * reach the caller as a rejected promise rather than a synchronous throw. + * + * @param settings - credentials, tiers, and the optional challenge hook and clock overrides. + * @returns the descriptor to install in a pipeline's AUTH slot. + * @throws PlaintextCredentialError — as a rejected promise — when the resolved scheme would attach a + * credential over a non-HTTPS URL (AUTH-28), on the outbound pass and again on a challenge replay. + * Recover by fixing the endpoint's scheme; retrying will not help. + * @throws AuthResolutionError — as a rejected promise — when the selected tier lists no scheme with a + * matching configured credential (AUTH-6; AUTH-4 governs only WHICH tier is selected), or when the + * token provider returns a null or already-expired token (AUTH-35). The first is a configuration + * fault; the second is transient and the next request retries the fetch. + * @throws HeaderValidationError — as a rejected promise — when the credential material will not fit in + * a header value: a `TokenProvider` yielding a token with a control character passes AUTH-9's + * non-blank check but fails HTTP-18's outbound grammar at the write. + * @throws InvariantViolation — synchronously from this factory when `bearerMarginMs` or + * `BearerCredential.marginMs` is not a finite, non-negative duration, or a configured Digest/Basic + * credential is blank or not header-safe; and as a rejected promise from `send()` when no auth tier + * is configured at all (AUTH-6). All are caller misconfigurations, not operational failures. + * @throws Anything a caller-supplied `TokenProvider` or `challengeHook` raises, unwrapped and + * unconverted — the same pass-through stance the redirect step takes for its `predicate`. + * + * @example + * ```ts + * const runtime = new PipelineBuilder(transport) + * .append(authStep({ + * credentials: {bearer: {provider: () => fetchToken({signal: AbortSignal.timeout(5_000)})}}, + * tiers: {client: createAuthDescriptor([createAuthRequirement('OAUTH2')])}, + * })) + * .build(); + * ``` + * + * @public + */ +export function authStep(settings: AuthStepSettings): StepDescriptor { + // Built ONCE per installed step, not per request. The bearer cache is the one piece of shared + // mutable state, and sharing it across calls is the point -- AUTH-34's single-flight coalescing only + // works if concurrent calls meet at the same instance. + const bearerCache = new BearerTokenCache(); + const availableSchemes = availableSchemesOf(settings.credentials); + const composing = composingHandler(buildHandlers(settings.credentials)); + validateMarginMs('authStep bearerMarginMs', settings.bearerMarginMs); + validateMarginMs( + 'BearerCredential marginMs', + settings.credentials.bearer?.marginMs, + ); + const bearerMarginMs = settings.bearerMarginMs ?? 30_000; + const readNow = settings.clock?.now.bind(settings.clock) ?? Date.now; + + return { + type: AUTH_STEP_TYPE, + stage: 'AUTH', + fn: async (seedRequest, ctx) => { + const {fork, signal} = ctx; + invariant( + fork !== undefined, + 'authStep must occupy the AUTH pillar stage', + ); + + const {scheme} = resolveAuthRequirement( + effectiveTiers(settings.tiers, ctx.options?.auth), + availableSchemes, + ); + // One clock read per hop, threaded into every expiry evaluation this hop performs, so the + // preemptive stamp and a challenge-driven refresh cannot disagree about "now" mid-call. + const nowMs = readNow(); + const stampContext: StampContext = { + scheme, + credentials: settings.credentials, + bearerCache, + marginMs: bearerMarginMs, + nowMs, + signal, + }; + + const {crossOrigin, outbound} = await planOutbound( + seedRequest, + stampContext, + ); + + const response = await fork()(outbound); + const status = response.status.code; + if (status !== 401 && status !== 407) return response; + + // AUTH-29, second half: the marker suppresses stamping for the WHOLE hop, not just the outbound + // pass. Answering a challenge here would stamp exactly the credential `planOutbound` declined + // to send -- onto the server-chosen foreign host, over a URL whose HTTPS guard was deliberately + // skipped. The challenge is the caller's to handle, so the response is returned untouched and + // unclosed. + if (crossOrigin) return response; + + return handleChallenge({ + response, + outbound, + fork, + settings, + hookContext: {...stampContext, composing}, + }); + }, + }; +} diff --git a/packages/core/src/auth/basic.test.ts b/packages/core/src/auth/basic.test.ts new file mode 100644 index 0000000..d2c9512 --- /dev/null +++ b/packages/core/src/auth/basic.test.ts @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/basic.test.ts +// Exercises: AUTH-14 ('Basic ' + base64(UTF-8(username:password)), computed once; accepts a basic +// challenge case-insensitively; whitespace-only credentials are PERMITTED -- RFC 7617's laxer rule, +// deliberately different from the credential types' stricter non-blank check in credential.ts), +// AUTH-25 (the handler returns the header VALUE only, never picks the header name). +import {describe, expect, test} from 'bun:test'; +import {InvariantViolation} from '../invariant.js'; +import {basicHandler} from './basic.js'; + +const basicChallenge = {scheme: 'basic', params: new Map()}; + +describe('basicHandler', () => { + test('produces "Basic " + base64(UTF-8(username:password))', async () => { + const handler = basicHandler('Aladdin', 'open sesame'); + const value = await handler.stamp(basicChallenge); + expect(value).toBe('Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=='); + }); + + test('handles non-ASCII credentials via UTF-8 encoding', async () => { + const handler = basicHandler('üser', 'päss'); + const value = await handler.stamp(basicChallenge); + expect(value.startsWith('Basic ')).toBe(true); + // A naive Latin-1 btoa would produce a different, wrong encoding. + expect(value).toBe( + `Basic ${btoa( + String.fromCharCode(...new TextEncoder().encode('üser:päss')), + )}`, + ); + }); + + test('canHandle accepts "basic" (parseChallenges already lower-cases the scheme)', () => { + const handler = basicHandler('u', 'p'); + expect(handler.canHandle(basicChallenge)).toBe(true); + expect(handler.canHandle({scheme: 'digest', params: new Map()})).toBe( + false, + ); + }); + + test('whitespace-only credentials are permitted (RFC 7617, laxer than credential.ts)', () => { + expect(() => basicHandler(' ', ' ')).not.toThrow(); + }); + + test('a truly empty username or password is rejected', () => { + expect(() => basicHandler('', 'p')).toThrow(InvariantViolation); + expect(() => basicHandler('u', '')).toThrow(InvariantViolation); + }); + + test('the encoded value is computed once, at construction', async () => { + const handler = basicHandler('u', 'p'); + const first = await handler.stamp(basicChallenge); + const second = await handler.stamp(basicChallenge); + expect(first).toBe(second); + }); + + test('declares no rank -- it has no algorithm variants to prefer among (AUTH-16)', () => { + // `'rank' in handler`, not `handler.rank` -- reading an unbound method off an object literal + // trips `@typescript-eslint/unbound-method`, and presence is what the assertion is about anyway. + expect('rank' in basicHandler('u', 'p')).toBe(false); + }); +}); diff --git a/packages/core/src/auth/basic.ts b/packages/core/src/auth/basic.ts new file mode 100644 index 0000000..c16d949 --- /dev/null +++ b/packages/core/src/auth/basic.ts @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/basic.ts +import {invariant} from '../invariant.js'; +import type {Challenge, ChallengeHandler} from './challenge.js'; + +/** + * `btoa` is Latin-1: it throws on any code point above U+00FF and mis-encodes the rest. Encoding to + * UTF-8 bytes first and handing `btoa` one character per byte is what makes a non-ASCII password + * base64 to the bytes RFC 7617 specifies. `globalThis.btoa` is used rather than `node:buffer` to keep + * the package portable (SEAM-1, `sdk-design-nodejs/06`). + */ +function toBase64Utf8(text: string): string { + const bytes = new TextEncoder().encode(text); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +/** + * The Basic challenge handler (AUTH-14). + * + * The header value is `Basic ` plus base64 of the UTF-8 encoding of `username:password`, computed + * ONCE at construction and closed over — "computed once" is AUTH-14's own wording, not a performance + * nicety. + * + * Credentials are validated as non-empty but whitespace IS permitted, per RFC 7617's laxer rule. + * This is deliberately NOT the stricter `.trim().length > 0` check `credential.ts`'s types apply: a + * caller intentionally using a whitespace-only password is unusual but RFC 7617-legal, and rejecting + * it here would be this port inventing a restriction the requirement declines to make. + * + * Challenge-reactive only, never preemptive: `authStep()` engages this handler on a 401/407, never on + * the outbound pass. See `auth-step.ts` for that reading of AUTH-14/AUTH-23–AUTH-25. + * + * @param username - the user id. Must be non-empty; whitespace permitted. + * @param password - the password. Must be non-empty; whitespace permitted. + * @returns a stateless handler that answers `basic` challenges. + * @throws InvariantViolation when either credential is empty — a caller misconfiguration. + * + * @internal + */ +export function basicHandler( + username: string, + password: string, +): ChallengeHandler { + invariant(username.length > 0, 'Basic username must not be empty'); + invariant(password.length > 0, 'Basic password must not be empty'); + const value = `Basic ${toBase64Utf8(`${username}:${password}`)}`; + + return { + // `challenge.scheme` arrives lower-cased from `parseChallenges`, which is where AUTH-14's + // case-insensitivity is actually implemented. + canHandle: (challenge: Challenge): boolean => challenge.scheme === 'basic', + // Zero parameters, and that is the whole contract: the value was computed at construction, so + // neither the challenge nor the request-target can change it. AUTH-25's + // Authorization/Proxy-Authorization choice is the caller's, made from which challenge header the + // status carried. + stamp: (): Promise => Promise.resolve(value), + }; +} diff --git a/packages/core/src/auth/bearer-cache.test.ts b/packages/core/src/auth/bearer-cache.test.ts new file mode 100644 index 0000000..23b1654 --- /dev/null +++ b/packages/core/src/auth/bearer-cache.test.ts @@ -0,0 +1,643 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/bearer-cache.test.ts +// Exercises: AUTH-34 (fresh-zone hot-path read, no refresh), AUTH-35 (null/expired provider result +// throws and is never cached; a rejecting provider propagates and is never cached), AUTH-37 +// (expiring-but-valid zone: stale value returned, background refresh fired, a FAILED background +// refresh non-fatal and not an unhandled rejection; expired/missing zone: single-flight await, +// concurrent callers coalesce to exactly one provider invocation; the post-eviction path +// (`refreshPostEviction`) fetches genuinely fresh, while concurrent post-eviction refreshes still +// coalesce onto ONE fetch), AUTH-36 +// (eviction matched on the stamped header value; the survivor is returned so the preservation clause +// is observable), AUTH-11 (a provider error propagates through the async channel and is never +// cached), AUTH-38 (a provider that fails SYNCHRONOUSLY still reaches the async channel, so a +// background refresh stays non-fatal and refreshPostEviction never throws synchronously), AUTH-34's +// cancellation shape (the shared fetch carries no caller signal; each caller races its own, and a +// long-lived signal reused across many fetches does not accumulate abort listeners). +// +// Every `nowMs` below is injected, and the cache validates fetched tokens against that SAME injected +// clock -- so `expiresAt` values are small synthetic epochs, not wall-clock instants. A cache that +// reached for `Date.now()` internally would reject every one of these tokens. +import {describe, expect, test} from 'bun:test'; +import {BearerTokenCache, type BearerFetch} from './bearer-cache.js'; +import { + createBearerToken, + type BearerToken, + type TokenProvider, +} from './credential.js'; +import {AuthResolutionError} from './errors.js'; + +function providerReturning(token: ReturnType): { + provider: TokenProvider; + callCount: () => number; +} { + let invocations = 0; + const provider: TokenProvider = () => { + invocations += 1; + return Promise.resolve(token); + }; + return {provider, callCount: () => invocations}; +} + +/** Fails the test if invoked. Used to assert a path did NOT reach the provider. */ +function unexpectedProvider(why: string): TokenProvider { + return () => Promise.reject(new Error(`provider must not be called: ${why}`)); +} + +/** The four fetch parameters are bundled (`BearerFetch`); this keeps the call sites readable. */ +function fetchWith( + provider: TokenProvider, + marginMs: number, + nowMs: number, +): BearerFetch { + return {provider, marginMs, nowMs, signal: undefined}; +} + +/** + * A macrotask boundary -- not a fixed number of microtask hops -- so a fire-and-forget refresh's whole + * then/finally chain has drained regardless of how many ticks it takes. + */ +function drainMacrotask(): Promise { + return new Promise(resolve => { + setTimeout(resolve, 0); + }); +} + +/** + * Captures a rejection reason. `expect(...).rejects` is typed as returning `void` under this runner's + * type definitions, so awaiting it trips `@typescript-eslint/await-thenable`; this helper keeps the + * assertion honest without a lint suppression. Same shape 5a's and 5b's step suites settled on. + */ +async function rejectionOf(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error; + } + return undefined; +} + +describe('BearerTokenCache: the fresh and expiring zones (AUTH-34/AUTH-37)', () => { + test('a fresh cached token is returned without invoking the provider (AUTH-34)', async () => { + const cache = new BearerTokenCache(); + const fresh = providerReturning(createBearerToken('t1', 10_000)); + await cache.stamp(fetchWith(fresh.provider, 1000, 0)); // primes the cache + // nowMs=0, expiresAt=10000, margin=1000 -- not expiring. + const result = await cache.stamp( + fetchWith(unexpectedProvider('the cached token is still fresh'), 1000, 0), + ); + expect(result.token).toBe('t1'); + expect(fresh.callCount()).toBe(1); + }); + + test('a token with no expiry is always in the fresh zone (AUTH-10/AUTH-34)', async () => { + const cache = new BearerTokenCache(); + await cache.stamp( + fetchWith(providerReturning(createBearerToken('forever')).provider, 0, 0), + ); + const result = await cache.stamp( + fetchWith( + unexpectedProvider('a token with no expiry never expires locally'), + 60_000, + Number.MAX_SAFE_INTEGER, + ), + ); + expect(result.token).toBe('forever'); + }); + + test('expiring-but-valid: returns the stale token AND fires a background refresh (AUTH-37)', async () => { + const cache = new BearerTokenCache(); + const initial = providerReturning(createBearerToken('t1', 1000)); + // primes: expiresAt=1000, nowMs=0, margin=500 -- not yet expiring + await cache.stamp(fetchWith(initial.provider, 500, 0)); + + const refreshed = providerReturning(createBearerToken('t2', 5000)); + // nowMs=900: expiring (900+500 > 1000) but not expired (900 > 1000 is false) + const result = await cache.stamp(fetchWith(refreshed.provider, 500, 900)); + expect(result.token).toBe('t1'); // stale value returned immediately + await drainMacrotask(); + const after = await cache.stamp( + fetchWith(unexpectedProvider('the refresh already cached t2'), 500, 900), + ); + expect(after.token).toBe('t2'); + }); +}); + +describe('BearerTokenCache: the expired/missing zone (AUTH-37)', () => { + test('expired/missing: awaits a fresh fetch', async () => { + const cache = new BearerTokenCache(); + // The FETCHED token must itself be valid at the injected `nowMs` -- a provider handing back an + // already-expired token is AUTH-35's rejection case, covered separately below. + const {provider, callCount} = providerReturning( + createBearerToken('t1', 10_000), + ); + const result = await cache.stamp(fetchWith(provider, 0, 5000)); // nothing cached + expect(result.token).toBe('t1'); + expect(callCount()).toBe(1); + }); + + test('an EXPIRED cached token awaits a fresh fetch rather than being stamped (AUTH-37)', async () => { + const cache = new BearerTokenCache(); + await cache.stamp( + fetchWith( + providerReturning(createBearerToken('t1', 1000)).provider, + 0, + 0, + ), + ); + const {provider, callCount} = providerReturning( + createBearerToken('t2', 9000), + ); + const result = await cache.stamp(fetchWith(provider, 0, 5000)); // t1 expired at 1000 + expect(result.token).toBe('t2'); + expect(callCount()).toBe(1); + }); +}); + +describe('BearerTokenCache: a failed background refresh is non-fatal (AUTH-37)', () => { + test('a FAILING background refresh is non-fatal and never becomes an unhandled rejection (AUTH-37)', async () => { + const cache = new BearerTokenCache(); + await cache.stamp( + fetchWith( + providerReturning(createBearerToken('t1', 1000)).provider, + 500, + 0, + ), + ); + + const failing: TokenProvider = () => + Promise.reject(new Error('refresh backend down')); + // Expiring-but-valid: stamps t1, refresh fails in the background. + const result = await cache.stamp(fetchWith(failing, 500, 900)); + // The still-valid token was already stamped -- the failure changes nothing. + expect(result.token).toBe('t1'); + + // Drain past the fire-and-forget chain; an unhandled rejection would surface here. + await drainMacrotask(); + + // t1 is still cached and still served -- a failed refresh must not evict what it failed to replace. + const after = await cache.stamp( + fetchWith( + unexpectedProvider('t1 is still cached and still valid at this nowMs'), + 0, + 900, + ), + ); + expect(after.token).toBe('t1'); + }); +}); + +describe('BearerTokenCache: single-flight and cancellation (AUTH-11/AUTH-34)', () => { + test('concurrent expired/missing callers coalesce to exactly one provider invocation (single-flight)', async () => { + let resolveProvider: + ((token: ReturnType) => void) | undefined; + let invocations = 0; + const provider: TokenProvider = () => { + invocations += 1; + return new Promise(resolve => { + resolveProvider = resolve; + }); + }; + const cache = new BearerTokenCache(); + + const first = cache.stamp(fetchWith(provider, 0, 0)); + const second = cache.stamp(fetchWith(provider, 0, 0)); + expect(invocations).toBe(1); // the second caller coalesced onto the first's in-flight fetch + + resolveProvider?.(createBearerToken('t1', 10_000)); + const [firstResult, secondResult] = await Promise.all([first, second]); + expect(firstResult.token).toBe('t1'); + expect(secondResult.token).toBe('t1'); + }); +}); + +describe('BearerTokenCache: cancellation is per-caller, not per-fetch (AUTH-34)', () => { + // A coalesced fetch is owned by no single call, so it carries no caller signal. That is structural + // rather than asserted: `TokenProvider` is `() => Promise` and has no parameter to + // populate. What IS asserted below is the behaviour that replaces it -- each caller races its own + // wait against its own signal. + test("an aborting caller stops waiting without cancelling a coalesced caller's fetch", async () => { + let resolveProvider: + ((token: ReturnType) => void) | undefined; + let invocations = 0; + const provider: TokenProvider = () => { + invocations += 1; + return new Promise(resolve => { + resolveProvider = resolve; + }); + }; + const cache = new BearerTokenCache(); + const controller = new AbortController(); + + const aborting = cache.stamp({ + provider, + marginMs: 0, + nowMs: 0, + signal: controller.signal, + }); + const patient = cache.stamp(fetchWith(provider, 0, 0)); // no signal at all + expect(invocations).toBe(1); + + controller.abort(new Error('caller A gave up')); + expect((await rejectionOf(aborting)) as Error).toHaveProperty( + 'message', + 'caller A gave up', + ); + + // The shared fetch was never cancelled, so B still gets its token. + resolveProvider?.(createBearerToken('t1', 10_000)); + expect((await patient).token).toBe('t1'); + expect(invocations).toBe(1); + }); + + test('a caller whose signal is already aborted rejects without starting a fetch', async () => { + const cache = new BearerTokenCache(); + const controller = new AbortController(); + controller.abort(new Error('already gone')); + + const rejected = await rejectionOf( + cache.stamp({ + provider: unexpectedProvider('the caller had already aborted'), + marginMs: 0, + nowMs: 0, + signal: controller.signal, + }), + ); + + expect(rejected as Error).toHaveProperty('message', 'already gone'); + }); +}); + +describe('BearerTokenCache: a signal outliving many fetches (AUTH-34)', () => { + test('a long-lived signal driving many sequential fetches still aborts exactly one waiter', async () => { + // `raceAbort` attaches an abort listener per WAIT and removes it in a `finally`. Without that + // removal a caller signal that outlives many token fetches -- one request driving a long + // paginated sweep, say -- accumulates one dead listener per fetch until Node's + // MaxListenersExceededWarning fires. The listener COUNT is asserted directly in + // `test/node-conformance/auth.test.mjs`, where `node:events`' `getEventListeners` is available; + // this is the behavioural half, on Bun: after many settled fetches the signal must still drive + // exactly the one waiter outstanding when it fires, not a backlog of stale ones. + const cache = new BearerTokenCache(); + const controller = new AbortController(); + let rejections = 0; + for (let round = 0; round < 8; round += 1) { + const {provider} = providerReturning( + createBearerToken(`t${String(round)}`, 10_000), + ); + await cache.stamp({ + provider, + marginMs: 0, + nowMs: 0, + signal: controller.signal, + }); + cache.evict(`Bearer t${String(round)}`); // force the next round back into the fetch path + } + + let release: ((token: BearerToken) => void) | undefined; + const parked = cache.stamp({ + provider: () => + new Promise(resolve => { + release = resolve; + }), + marginMs: 0, + nowMs: 0, + signal: controller.signal, + }); + controller.abort(new Error('the sweep was cancelled')); + if ((await rejectionOf(parked)) !== undefined) rejections += 1; + + expect(rejections).toBe(1); + release?.(createBearerToken('unused', 10_000)); + }); +}); + +describe('BearerTokenCache: a provider that fails SYNCHRONOUSLY (AUTH-37/AUTH-38)', () => { + // `TokenProvider` is caller-supplied and its declared return type is a promise, but a plain-JS + // provider can throw before returning one -- the same boundary AUTH-35's `null` guard distrusts. + const syncThrowing: TokenProvider = () => { + throw new Error('provider exploded synchronously'); + }; + + test('a failed BACKGROUND refresh stays non-fatal: the valid token is still stamped (AUTH-37)', async () => { + const cache = new BearerTokenCache(); + const {provider} = providerReturning(createBearerToken('t1', 1000)); + await cache.stamp(fetchWith(provider, 0, 0)); // primes the cache + + // nowMs=900, margin=200 -> expiring-but-valid. AUTH-37: stamp the stale token, refresh in the + // background, and the background failure MUST NOT fail this request. A bare `provider()` call + // threw straight out of `stamp` here, past the `void ... .catch(...)` that had not been attached + // yet, rejecting a request that had a perfectly good token to send. + const stamped = await cache.stamp(fetchWith(syncThrowing, 200, 900)); + + expect(stamped.token).toBe('t1'); + }); + + test('refreshPostEviction rejects rather than throwing synchronously (AUTH-38)', async () => { + const cache = new BearerTokenCache(); + + const rejected = await rejectionOf( + cache.refreshPostEviction(fetchWith(syncThrowing, 0, 0)), + ); + + expect(rejected as Error).toHaveProperty( + 'message', + 'provider exploded synchronously', + ); + }); + + test('stamp rejects rather than throwing synchronously on the expired/missing path', async () => { + const cache = new BearerTokenCache(); + + const rejected = await rejectionOf( + cache.stamp(fetchWith(syncThrowing, 0, 0)), + ); + + expect(rejected as Error).toHaveProperty( + 'message', + 'provider exploded synchronously', + ); + }); +}); + +describe('BearerTokenCache: provider failure handling (AUTH-11/AUTH-35)', () => { + test('a null provider result throws AuthResolutionError (AUTH-35)', async () => { + const cache = new BearerTokenCache(); + // A plain-JS caller can hand back null regardless of TokenProvider's non-nullable return type; + // AUTH-35 requires a RUNTIME guard, so the cast is the point of the test, not a workaround. + const nullish = (() => Promise.resolve(null)) as unknown as TokenProvider; + expect( + await rejectionOf(cache.stamp(fetchWith(nullish, 0, 0))), + ).toBeInstanceOf(AuthResolutionError); + }); + + test('an already-expired provider result throws and is never cached (AUTH-35)', async () => { + const cache = new BearerTokenCache(); + const alreadyExpired: TokenProvider = () => + Promise.resolve(createBearerToken('t1', -1)); // expiresAt in the past + expect( + await rejectionOf(cache.stamp(fetchWith(alreadyExpired, 0, 1000))), + ).toBeInstanceOf(AuthResolutionError); + + const {provider: recovers, callCount} = providerReturning( + createBearerToken('t2', 10_000), + ); + const result = await cache.stamp(fetchWith(recovers, 0, 1000)); + expect(result.token).toBe('t2'); + // The earlier rejection left nothing cached to short-circuit this call. + expect(callCount()).toBe(1); + }); + + test('a rejecting provider propagates and is never cached (AUTH-11)', async () => { + const cache = new BearerTokenCache(); + const boom = new Error('network down'); + const failing: TokenProvider = () => Promise.reject(boom); + expect(await rejectionOf(cache.stamp(fetchWith(failing, 0, 0)))).toBe(boom); + + const {provider: recovers} = providerReturning( + createBearerToken('t1', 10_000), + ); + const result = await cache.stamp(fetchWith(recovers, 0, 0)); + expect(result.token).toBe('t1'); // no stale rejection cached -- this call fetches cleanly + }); +}); + +describe('BearerTokenCache: refreshPostEviction supersedes a pre-401 fetch (AUTH-37)', () => { + test('does NOT coalesce onto a fetch that was already in flight', async () => { + // The exact hazard: a background refresh started BEFORE the 401 came back. AUTH-11 permits a + // provider that caches internally, so that older fetch can resolve to the very token the server + // rejected. A `stamp()` here would coalesce onto it and re-send the rejected token. + const cache = new BearerTokenCache(); + const resolvers: ((token: ReturnType) => void)[] = + []; + let invocations = 0; + const provider: TokenProvider = () => { + invocations += 1; + return new Promise(resolve => { + resolvers.push(resolve); + }); + }; + + const stale = cache.stamp(fetchWith(provider, 0, 0)); // starts fetch #1 and parks it in flight + expect(invocations).toBe(1); + + const fresh = cache.refreshPostEviction(fetchWith(provider, 0, 0)); + expect(invocations).toBe(2); // a SECOND provider call, not a handle on the first + + resolvers[0]?.(createBearerToken('rejected-token', 10_000)); + resolvers[1]?.(createBearerToken('genuinely-fresh', 10_000)); + await stale; + expect((await fresh).token).toBe('genuinely-fresh'); + }); + + test('a superseded fetch resolving LAST still cannot re-cache the rejected token', async () => { + // Same hazard, opposite resolution order -- the one a generation-less cache gets wrong: the + // pre-401 fetch settles after the fresh one and would otherwise overwrite it. + const cache = new BearerTokenCache(); + const resolvers: ((token: ReturnType) => void)[] = + []; + const provider: TokenProvider = () => + new Promise(resolve => { + resolvers.push(resolve); + }); + + const stale = cache.stamp(fetchWith(provider, 0, 0)); + const fresh = cache.refreshPostEviction(fetchWith(provider, 0, 0)); + + resolvers[1]?.(createBearerToken('genuinely-fresh', 10_000)); + await fresh; + resolvers[0]?.(createBearerToken('rejected-token', 10_000)); + await stale; + await drainMacrotask(); + + const served = await cache.stamp( + fetchWith( + unexpectedProvider('the fresh token is cached and valid'), + 0, + 0, + ), + ); + expect(served.token).toBe('genuinely-fresh'); + }); +}); + +describe('BearerTokenCache: refreshPostEviction caches and re-drives (AUTH-36/AUTH-37)', () => { + test('the EVICTION path supersedes a pre-401 fetch that resolves late', async () => { + // The sibling above drives `stamp` + `refreshPostEviction` directly. This one goes through AUTH-36's + // actual 401 sequence -- evict, then refreshPostEviction -- with the pre-401 background fetch resolving + // to exactly the token the server rejected, which AUTH-11 expressly permits a + // internally-caching provider to do. + const cache = new BearerTokenCache(); + let releasePreFetch: (() => void) | undefined; + const slow: TokenProvider = () => + new Promise(resolve => { + releasePreFetch = () => { + resolve(createBearerToken('rejected-token', 10_000)); + }; + }); + + await cache.stamp( + fetchWith( + providerReturning(createBearerToken('rejected-token', 1000)).provider, + 0, + 0, + ), + ); + await cache.stamp(fetchWith(slow, 200, 900)); // parks a pre-401 background fetch in flight + + expect(cache.evict('Bearer rejected-token')).toBeUndefined(); + const fresh = await cache.refreshPostEviction( + fetchWith( + providerReturning(createBearerToken('genuinely-fresh', 100_000)) + .provider, + 0, + 900, + ), + ); + expect(fresh.token).toBe('genuinely-fresh'); + + releasePreFetch?.(); + await drainMacrotask(); + + const served = await cache.stamp( + fetchWith( + unexpectedProvider('the post-eviction token is cached and valid'), + 0, + 1000, + ), + ); + expect(served.token).toBe('genuinely-fresh'); + }); + + test('caches its result like any other fetch', async () => { + const cache = new BearerTokenCache(); + const {provider, callCount} = providerReturning( + createBearerToken('t1', 10_000), + ); + await cache.refreshPostEviction(fetchWith(provider, 0, 0)); + const again = await cache.stamp( + fetchWith( + unexpectedProvider('refreshPostEviction() populated the cache'), + 0, + 0, + ), + ); + expect(again.token).toBe('t1'); + expect(callCount()).toBe(1); + }); +}); + +describe('BearerTokenCache: a 401 burst coalesces (AUTH-34/AUTH-37)', () => { + test('N concurrent post-eviction refreshes share ONE provider fetch, not N', async () => { + // A server-side revocation 401s every in-flight request at once. Superseding the pre-401 fetch is + // required (AUTH-37), but starting one provider call per 401 is the thundering herd AUTH-34's + // "at most one provider fetch" clause forbids. + const cache = new BearerTokenCache(); + let calls = 0; + let release: ((token: BearerToken) => void) | undefined; + const provider = (): Promise => { + calls += 1; + return new Promise(resolve => { + release = resolve; + }); + }; + + const burst = [ + cache.refreshPostEviction(fetchWith(provider, 0, 0)), + cache.refreshPostEviction(fetchWith(provider, 0, 0)), + cache.refreshPostEviction(fetchWith(provider, 0, 0)), + cache.refreshPostEviction(fetchWith(provider, 0, 0)), + ]; + release?.(createBearerToken('fresh', 10_000)); + const tokens = await Promise.all(burst); + + expect(calls).toBe(1); + expect(tokens.map(token => token.token)).toEqual([ + 'fresh', + 'fresh', + 'fresh', + 'fresh', + ]); + }); + + test('a LATER 401 still supersedes: it does not join the settled burst fetch', async () => { + const cache = new BearerTokenCache(); + const first = providerReturning(createBearerToken('t1', 10_000)); + await cache.refreshPostEviction(fetchWith(first.provider, 0, 0)); + const second = providerReturning(createBearerToken('t2', 10_000)); + + const result = await cache.refreshPostEviction( + fetchWith(second.provider, 0, 0), + ); + + expect(result.token).toBe('t2'); + expect(second.callCount()).toBe(1); + }); + + test('a stamp()-driven fetch sitting at the current generation is NOT joined by refreshPostEviction', async () => { + // The guard is `inFlightEvictionGeneration`, not the generation counter alone: an ordinary + // single-flight fetch must still be superseded, or a pre-401 fetch could hand back the very token + // the server just rejected (AUTH-37). + const cache = new BearerTokenCache(); + let release: ((token: BearerToken) => void) | undefined; + const stale = (): Promise => + new Promise(resolve => { + release = resolve; + }); + const pending = cache.stamp(fetchWith(stale, 0, 0)); + + const fresh = providerReturning(createBearerToken('fresh', 10_000)); + const result = await cache.refreshPostEviction( + fetchWith(fresh.provider, 0, 0), + ); + + expect(result.token).toBe('fresh'); + expect(fresh.callCount()).toBe(1); + release?.(createBearerToken('rejected', 10_000)); + await pending; + }); +}); + +describe('BearerTokenCache: evict (AUTH-36)', () => { + test('evicts only when the header value matches the exact cached token', async () => { + const cache = new BearerTokenCache(); + await cache.stamp( + fetchWith( + providerReturning(createBearerToken('t1', 10_000)).provider, + 0, + 0, + ), + ); + // The survivor is RETURNED, which is what makes AUTH-36's "preserving a token another request + // already refreshed" observable rather than a no-op the next fetch overwrites. + expect(cache.evict('Bearer some-other-token')?.token).toBe('t1'); + const result = await cache.stamp( + fetchWith( + unexpectedProvider('a non-matching evict() must not clear the cache'), + 0, + 0, + ), + ); + expect(result.token).toBe('t1'); + }); + + test('a matching evict() forces the next call to refetch', async () => { + const cache = new BearerTokenCache(); + await cache.stamp( + fetchWith( + providerReturning(createBearerToken('t1', 10_000)).provider, + 0, + 0, + ), + ); + expect(cache.evict('Bearer t1')).toBeUndefined(); + const {provider, callCount} = providerReturning( + createBearerToken('t2', 10_000), + ); + const result = await cache.stamp(fetchWith(provider, 0, 0)); + expect(result.token).toBe('t2'); + expect(callCount()).toBe(1); + }); + + test('is a no-op returning undefined when nothing is cached', () => { + expect(new BearerTokenCache().evict('Bearer anything')).toBeUndefined(); + }); +}); diff --git a/packages/core/src/auth/bearer-cache.ts b/packages/core/src/auth/bearer-cache.ts new file mode 100644 index 0000000..b793975 --- /dev/null +++ b/packages/core/src/auth/bearer-cache.ts @@ -0,0 +1,336 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/bearer-cache.ts +import { + isBearerTokenExpired, + type BearerToken, + type TokenProvider, +} from './credential.js'; +import {AuthResolutionError} from './errors.js'; + +/** + * The value {@link BearerTokenCache.inFlightEvictionGeneration} carries when the in-flight fetch (if + * any) came from the ordinary {@link BearerTokenCache.stamp} path. Never a real generation: the + * counter only ever increments from 0, so no post-eviction fetch can collide with it. + */ +const NO_EVICTION_GENERATION = -1; + +/** + * One token fetch's inputs. + * + * Bundled rather than passed positionally: `max-params` is 3, and + * `docs/knowledge/function-design.md` requires an options object at three or more parameters anyway. + * + * @internal + */ +export interface BearerFetch { + /** The token source (AUTH-11). */ + readonly provider: TokenProvider; + /** AUTH-34's refresh margin: how long before expiry a token counts as expiring. */ + readonly marginMs: number; + /** + * The injected clock reading. Never `Date.now()` inside this class — see {@link + * BearerTokenCache.refresh}. + */ + readonly nowMs: number; + /** + * The calling request's cancellation. + * + * It is NOT handed to the provider. A fetch reached through AUTH-34's single-flight coalescing is + * shared by every caller that joined it, so cancelling it on one caller's signal would reject + * callers who never aborted -- and a caller who supplied no signal at all. Instead each caller + * RACES the shared promise against its own signal ({@link raceAbort}): an aborting caller stops + * waiting, and the work the others are joined to keeps running. {@link TokenProvider} therefore + * takes no parameters at all, and carries the deadline obligation that follows from the fetch + * itself being uncancellable. + */ + readonly signal: AbortSignal | undefined; +} + +/** + * Calls `provider` so that a SYNCHRONOUS failure reaches the async channel like every other provider + * failure (AUTH-37, AUTH-38). + * + * A `TokenProvider` is caller-supplied code. Its declared return type is `Promise`, but + * a plain-JS provider can throw before returning, or return something that is not a promise at all -- + * the same boundary the `null | undefined` widening in {@link BearerTokenCache.refresh} already + * distrusts, distrusted the same way. Left as a bare `provider(...)` call, such a failure escaped past + * `stamp`'s `void ... .catch(...)` before the catch was ever attached, turning AUTH-37's expressly + * non-fatal background refresh into a fatal one and rejecting a request that had a perfectly good + * cached token to stamp. + * + * An `async` wrapper, NOT `Promise.resolve().then(provider)`: an async function body runs + * synchronously up to its first `await`, so the provider is still invoked in the same tick as the + * `inFlight` assignment. Deferring it by a microtask would put an await between the single-flight + * check and the assignment -- the one thing the guard's lock-free correctness rests on. Returning + * `provider()` unawaited is likewise deliberate: `await`ing it here would trip `return-await` outside + * a try, and buys nothing, because the `async` keyword already converts a synchronous throw into a + * rejection. + * + * No `signal` is passed because {@link TokenProvider} takes no parameters at all: a coalesced fetch + * is owned by no single call, so there is nothing a caller signal could correctly mean here. See + * {@link BearerFetch.signal} for what happens instead; the provider owns its own deadline. + * + * Collapse this back into a bare `provider()` call only if `TokenProvider` stops being + * caller-supplied code. + */ +async function invokeProvider(provider: TokenProvider): Promise { + return provider(); +} + +/** + * Starts (or joins) a fetch and awaits it, but stops waiting when `signal` aborts -- WITHOUT + * cancelling the fetch, which is shared by every caller coalesced onto it (AUTH-34). + * + * Takes a factory rather than a promise so the already-aborted check runs BEFORE any fetch is + * started, and so `start()` is still invoked in the caller's own synchronous span: an `async` + * function body runs to its first `await` synchronously, which is what keeps the single-flight + * assignment and the generation bump un-interleaved. + * + * `new Promise` with a synchronous executor adapting an event-emitter callback is the one shape + * `docs/knowledge/concurrency-and-async.md` sanctions for it, and the listener is removed on every + * exit so a long-lived caller signal does not accumulate one per token fetch. + * + * A `pending` that rejects after losing the race is still settled through `Promise.race`'s own + * handler, so it never becomes an unhandled rejection. + */ +async function raceAbort( + start: () => Promise, + signal: AbortSignal | undefined, +): Promise { + // Before `start()`, so an already-dead caller never opens a fetch it cannot use -- + // `concurrency-and-async.md`'s "check the signal before each expensive step". + if (signal?.aborted === true) throw signal.reason as Error; + const pending = start(); + if (signal === undefined) return pending; + let onAbort = (): void => undefined; + try { + return await Promise.race([ + pending, + new Promise((_resolve, reject) => { + onAbort = (): void => { + reject(signal.reason as Error); + }; + signal.addEventListener('abort', onAbort, {once: true}); + }), + ]); + } finally { + signal.removeEventListener('abort', onAbort); + } +} + +/** + * The single-flight, three-zone bearer token cache (AUTH-34, AUTH-35, AUTH-37). + * + * The async three-zone policy is shipped unconditionally. This port has one `Promise`-only pipeline + * execution model (4c), so AUTH-34's "non-blocking hot-path read of a valid cached token" is the + * fresh-zone branch of this same state machine, not a second stack — directly parallel to 5a's + * one-retry-engine disposition of RETRY-28. + * + * Single-flight is a plain field, not a lock. On Node and Bun the only hazard would be two logical + * calls both observing "no in-flight fetch" before either assigns the slot, and that cannot happen + * because nothing awaits between the check and the assignment in {@link BearerTokenCache.refresh} — + * the same synchronous-guard collapse as Digest's nonce counter. + * + * One instance per configured {@link TokenProvider}; every test constructs its own. + * + * @internal + */ +export class BearerTokenCache { + private cached: BearerToken | undefined; + private inFlight: Promise | undefined; + /** + * Bumped by {@link BearerTokenCache.refreshPostEviction} to supersede every fetch already in + * flight. A superseded fetch still resolves to its own caller, but must not publish its token into + * `cached` or clear the newer fetch's `inFlight` slot — otherwise a pre-401 fetch resolving late + * would re-cache exactly the token the server rejected, which is the outcome AUTH-37 forbids. + */ + private generation = 0; + /** + * The generation an EVICTION-DRIVEN fetch currently in flight was started at, or + * {@link NO_EVICTION_GENERATION} when the in-flight fetch (if any) came from the ordinary + * {@link BearerTokenCache.stamp} path. + * + * This is what lets {@link BearerTokenCache.refreshPostEviction} coalesce without re-opening the + * hazard it exists to close. A pre-401 fetch must never be joined -- it can resolve to the very + * token the server just rejected -- but two 401s on the SAME token arriving together should share + * one fetch, or a mass revocation turns every in-flight request into its own provider call, which + * is exactly the thundering herd AUTH-34's single-flight clause forbids. Comparing this against + * `generation` separates the two cases exactly: only a fetch started by `refreshPostEviction` AT + * the current generation is a genuine post-eviction fetch. + */ + private inFlightEvictionGeneration = NO_EVICTION_GENERATION; + + /** + * AUTH-34/AUTH-37's three zones: fresh (stamp, no refresh), expiring-but-valid (stamp the stale + * token, refresh in the background), expired or missing (await a fresh single-flight fetch). + * + * @param fetchOptions - the provider, margin, injected clock reading, and call signal. + * @returns the token to stamp. + * @throws AuthResolutionError when the provider yields null or an already-expired token (AUTH-35). + */ + async stamp(fetchOptions: BearerFetch): Promise { + const {marginMs, nowMs} = fetchOptions; + if (this.cached !== undefined) { + const expiring = isBearerTokenExpired(this.cached, nowMs, marginMs); + if (!expiring) return this.cached; // fresh zone: stamp, no refresh + const expired = isBearerTokenExpired(this.cached, nowMs, 0); // AUTH-35: no margin at fetch time + if (!expired) { + const stillValid = this.cached; + // Expiring-but-valid zone: fire-and-forget, and the catch is BLANKET on purpose. AUTH-37 is + // unconditional -- "a failed/unusable BACKGROUND refresh MUST NOT fail the in-flight + // request (log-and-continue)" -- and a bare `void this.refresh(...)` would leave the + // rejection unhandled, which under Node's default policy terminates the process. + // + // An earlier shape re-threw an InvariantViolation here, reasoning that a programmer error + // must crash loudly. That was wrong twice over. The throw landed in a promise nobody awaits, + // so it did not surface at the fault -- it killed the host process asynchronously, with no + // request to attribute it to, while the request that triggered it had already been served a + // valid token. And the fault it re-raised is not ours: a blank token from a caller-supplied + // `TokenProvider` is an operational fault (an empty environment variable, a malformed IdP + // payload) as often as a coding one. `error-handling.md`'s crash-loudly rule governs OUR + // invariants at the point WE detect them; it does not license re-raising someone else's + // failure into a detached promise. + // + // The half AUTH-37 asks for and this cannot yet do is the LOG in "log-and-continue" -- + // tracked as G12 against Phase 7b's logging step, and recorded in the phase checklist's + // Deviation Ledger alongside the standing `error-handling.md` conflict this sits on. + // + // Not raced against `fetchOptions.signal`: this refresh belongs to the cache, not to the + // request that happened to trigger it, and it must outlive that request's cancellation. + void this.refresh(fetchOptions, NO_EVICTION_GENERATION).catch( + () => undefined, + ); + return stillValid; + } + } + // Expired/missing zone: await a fresh single-flight fetch, but only until this caller's own + // signal fires (AUTH-34, and `concurrency-and-async.md`'s honour-the-signal rule). + return raceAbort( + () => this.refresh(fetchOptions, NO_EVICTION_GENERATION), + fetchOptions.signal, + ); + } + + /** + * AUTH-37's post-eviction path: a fetch guaranteed to have STARTED after a 401 in this eviction + * burst, never before one. + * + * {@link BearerTokenCache.stamp} is not a substitute. It routes through `refresh`, which hands back + * an already-in-flight promise — and that fetch may have started BEFORE the 401 arrived. AUTH-11 + * explicitly permits a provider that caches or refreshes internally, so such a fetch can resolve to + * the very token the server just rejected, which is precisely what AUTH-37's "re-stamp a single + * retry with a freshly fetched token" forbids. + * + * It does NOT bypass single-flight wholesale, which an earlier shape did: under a mass revocation + * every in-flight request gets its own 401, and starting one provider fetch per 401 is the + * thundering herd AUTH-34's "at most one provider fetch" clause exists to prevent. Coalescing is + * gated on {@link BearerTokenCache.inFlightEvictionGeneration} instead, so concurrent 401s share + * one post-eviction fetch while a pre-401 fetch is still always superseded. That is why the name + * is `refreshPostEviction` rather than `refreshNow`: this call may JOIN a sibling 401's fetch, and + * what it actually guarantees is that no fetch predating this eviction burst is ever joined. + * + * @param fetchOptions - the provider, margin, injected clock reading, and call signal. + * @returns the freshly fetched token. + * @throws AuthResolutionError when the provider yields null or an already-expired token (AUTH-35). + */ + // `async` for AUTH-38's uniform error model: this path runs caller-supplied provider code, and a + // provider that fails BEFORE returning a promise would otherwise throw synchronously out of a + // method whose declared return type is `Promise`. + async refreshPostEviction(fetchOptions: BearerFetch): Promise { + return raceAbort( + () => this.startPostEviction(fetchOptions), + fetchOptions.signal, + ); + } + + /** + * The join-or-supersede decision, split out so {@link raceAbort} can gate it on the caller's signal + * without the generation bump drifting out of the caller's synchronous span. Nothing awaits between + * the `inFlight` read and the write, which is what makes single-flight lock-free. + */ + private startPostEviction(fetchOptions: BearerFetch): Promise { + if ( + this.inFlight !== undefined && + this.inFlightEvictionGeneration === this.generation + ) { + // Another 401 in this same burst already started a post-eviction fetch: join it (AUTH-34). + return this.inFlight; + } + this.generation += 1; // supersede every fetch already in flight + this.inFlight = undefined; // drop the pre-401 fetch's claim on the slot before starting a new one + return this.refresh(fetchOptions, this.generation); + } + + /** + * AUTH-36: clears the cache only when the currently-cached token is the exact one that produced the + * 401, matched on the stamped header value — so a token another in-flight request already refreshed + * survives. + * + * The survivor is RETURNED, not merely left in place, because that is the only way AUTH-36's + * "preserving a token another request already refreshed" clause becomes observable. Preserving it + * and then unconditionally fetching a replacement — which is what the caller did before — overwrote + * the preserved token on the next tick and made the whole clause a no-op. + * + * @param rejectedHeaderValue - the `Authorization` value the rejected request carried. + * @returns the cached token when it is NOT the rejected one (another request already refreshed it, + * so the retry should stamp this instead of fetching again), or `undefined` when the rejected + * token was evicted or nothing was cached. + */ + evict(rejectedHeaderValue: string): BearerToken | undefined { + if (this.cached === undefined) return undefined; + if (`Bearer ${this.cached.token}` === rejectedHeaderValue) { + this.cached = undefined; + return undefined; + } + return this.cached; + } + + /** + * `nowMs` is threaded in rather than read from `Date.now()`: `stamp()` already takes an injected + * clock, and a refresh validating against the ambient wall clock while its caller reasons about an + * injected one would be a second, invisible clock — it would reject every token under synthetic + * time and be uncontrollable in production. + */ + private refresh( + fetchOptions: BearerFetch, + evictionGeneration: number, + ): Promise { + // Returns the RAW shared promise, never one raced against a caller signal. `raceAbort` is applied + // by the two public entry points instead, so the background refresh in `stamp()` -- which belongs + // to the cache rather than to any one request -- is deliberately left unraced. + if (this.inFlight !== undefined) return this.inFlight; // coalesce concurrent expiring/missing callers + const generation = this.generation; + // The generation is passed in rather than derived from a boolean flag: the ordinary path writes + // `NO_EVICTION_GENERATION` so a later `refreshPostEviction` cannot mistake a `stamp()`-driven + // fetch that happens to sit at the current generation for a post-eviction one and join it. + this.inFlightEvictionGeneration = evictionGeneration; + const pending = invokeProvider(fetchOptions.provider) + .then((token: BearerToken | null | undefined) => { + // `token` is widened at this ONE boundary on purpose. `TokenProvider`'s declared return type + // is non-nullable, so comparing the un-widened value against null trips + // `@typescript-eslint/no-unnecessary-condition` from the strict-type-checked tier -- but + // AUTH-35 requires a RUNTIME guard, because a plain-JS caller (or a mis-typed `any` + // boundary) can hand back null regardless of what the type says. Widening states that intent + // instead of suppressing the rule. + if ( + token === null || + token === undefined || + isBearerTokenExpired(token, fetchOptions.nowMs, 0) + ) { + throw new AuthResolutionError( + 'token provider returned a null or already-expired token', + ); // AUTH-35 + } + if (generation === this.generation) this.cached = token; + return token; + }) + .finally(() => { + // Never cache a rejection (AUTH-11/AUTH-35) -- it already propagates through `finally` + // untouched, so no `catch` is added. Guarded on the generation so a superseded fetch cannot + // clear a newer fetch's slot. + if (generation === this.generation) this.inFlight = undefined; + }); + this.inFlight = pending; + return pending; + } +} diff --git a/packages/core/src/auth/challenge.test.ts b/packages/core/src/auth/challenge.test.ts new file mode 100644 index 0000000..df5b787 --- /dev/null +++ b/packages/core/src/auth/challenge.test.ts @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/challenge.test.ts +// Exercises: AUTH-12 (scheme/param names lower-cased, values verbatim, token68 under its synthetic +// key), AUTH-13 (total: blank -> [], malformed recovers at the next top-level comma, unterminated +// quote ends at EOF, params before a malformed tail kept), and the multi-challenge/comma-ambiguity +// case that is the whole reason this parser cannot be a `.split(',')`. +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import {parseChallenges} from './challenge.js'; + +describe('a single challenge', () => { + test('scheme and param names are lower-cased; values are verbatim', () => { + const [challenge] = parseChallenges('BASIC Realm="MixedCase"'); + expect(challenge?.scheme).toBe('basic'); + expect(challenge?.params.get('realm')).toBe('MixedCase'); + }); + + test('a bare scheme with no params gets an empty parameter map', () => { + const [challenge] = parseChallenges('NTLM'); + expect(challenge?.scheme).toBe('ntlm'); + expect(challenge?.params.size).toBe(0); + }); + + test('a token68 value is recorded under the synthetic key', () => { + const [challenge] = parseChallenges( + 'Negotiate a87421000492aa874209af8bc028', + ); + expect(challenge?.scheme).toBe('negotiate'); + // AUTH-12 names this key literally as 'token68'. + expect(challenge?.params.get('token68')).toBe( + 'a87421000492aa874209af8bc028', + ); + }); + + test("token68's own '=' padding is part of the value, not an assignment", () => { + const [challenge] = parseChallenges('Negotiate YWJj=='); + expect(challenge?.params.get('token68')).toBe('YWJj=='); + }); + + test('an unquoted token value is accepted', () => { + const [challenge] = parseChallenges('Digest realm=simple, qop=auth'); + expect(challenge?.params.get('realm')).toBe('simple'); + expect(challenge?.params.get('qop')).toBe('auth'); + }); + + // Only the WRAPPER is frozen, and the name says so. `Object.freeze` on the `params` `Map` would + // not stop `.set()`, so freezing it would be a comment that lies; the `ReadonlyMap` TYPE is the + // only guard on the parameters, for the same reason `createAuthRequirement` records for its own + // params map -- `Challenge` is `@internal`, never reaches a consumer, and nothing in this package + // re-casts `Challenge['params']` back to `Map`. + test('the returned challenge wrapper is frozen', () => { + const [challenge] = parseChallenges('Basic realm="a"'); + expect(Object.isFrozen(challenge)).toBe(true); + }); +}); + +describe('multiple comma-separated challenges', () => { + test('a top-level comma between two DIFFERENT auth-params of the SAME challenge does not start a new one', () => { + const challenges = parseChallenges( + 'Digest realm="a", nonce="n", qop="auth"', + ); + expect(challenges).toHaveLength(1); + expect(challenges[0]?.params.get('realm')).toBe('a'); + expect(challenges[0]?.params.get('nonce')).toBe('n'); + expect(challenges[0]?.params.get('qop')).toBe('auth'); + }); + + test('two distinct challenges are both recovered, each with its own params', () => { + const challenges = parseChallenges( + 'Basic realm="a", Digest realm="b", nonce="n"', + ); + expect(challenges).toHaveLength(2); + expect(challenges[0]).toEqual({ + scheme: 'basic', + params: new Map([['realm', 'a']]), + }); + expect(challenges[1]?.scheme).toBe('digest'); + expect(challenges[1]?.params.get('realm')).toBe('b'); + expect(challenges[1]?.params.get('nonce')).toBe('n'); + }); + + test('a comma INSIDE a quoted value never splits the challenge', () => { + const [challenge] = parseChallenges('Digest realm="a, b", nonce="n"'); + expect(challenge?.params.get('realm')).toBe('a, b'); + expect(challenge?.params.get('nonce')).toBe('n'); + }); + + test('wire order is preserved', () => { + const challenges = parseChallenges('Digest realm="d", Basic realm="b"'); + expect(challenges.map(c => c.scheme)).toEqual(['digest', 'basic']); + }); +}); + +describe('quoted-string handling', () => { + test('a backslash escape is unquoted', () => { + const [challenge] = parseChallenges(String.raw`Digest realm="a\"b"`); + expect(challenge?.params.get('realm')).toBe('a"b'); + }); + + test('an unterminated quoted string terminates at end-of-input (AUTH-13)', () => { + const [challenge] = parseChallenges('Digest realm="abc'); + expect(challenge?.params.get('realm')).toBe('abc'); + }); + + test('an equals sign inside a quoted value is not an assignment', () => { + const [challenge] = parseChallenges('Digest realm="a=b", nonce="n"'); + expect(challenge?.params.get('realm')).toBe('a=b'); + expect(challenge?.params.get('nonce')).toBe('n'); + }); +}); + +describe('totality and recovery (AUTH-13)', () => { + test('blank input yields an empty list', () => { + expect(parseChallenges('')).toEqual([]); + expect(parseChallenges(' ')).toEqual([]); + }); + + test('a malformed segment recovers at the next top-level comma, keeping prior params', () => { + const challenges = parseChallenges( + 'Digest realm="a", =bad, Basic realm="b"', + ); + expect(challenges).toHaveLength(2); + expect(challenges[0]).toEqual({ + scheme: 'digest', + params: new Map([['realm', 'a']]), + }); + expect(challenges[1]).toEqual({ + scheme: 'basic', + params: new Map([['realm', 'b']]), + }); + }); + + test('a leading auth-param with no scheme ahead of it is discarded, not crashed on', () => { + const challenges = parseChallenges('realm="orphan", Basic realm="b"'); + expect(challenges).toHaveLength(1); + expect(challenges[0]?.scheme).toBe('basic'); + }); + + test('a comma inside the malformed segment’s quoted value is not a recovery point', () => { + const challenges = parseChallenges( + 'Basic realm="a", ="x,y", Digest realm="b"', + ); + expect(challenges.map(c => c.scheme)).toEqual(['basic', 'digest']); + }); + + test('an escaped quote inside a malformed segment does not end its quoted run', () => { + // The `\"` keeps the run open, so the comma that follows is still inside quotes and is not a + // recovery point; recovery lands on the one after the closing quote. + const challenges = parseChallenges( + String.raw`Basic realm="a", ="x\",y", Digest realm="b"`, + ); + expect(challenges.map(c => c.scheme)).toEqual(['basic', 'digest']); + }); + + test('stray commas collapse rather than emitting empty challenges', () => { + expect(parseChallenges(',,,')).toEqual([]); + expect( + parseChallenges('Basic,,Digest realm="b"').map(c => c.scheme), + ).toEqual(['basic', 'digest']); + }); +}); + +describe('parser totality, as properties (AUTH-13)', () => { + test('property: never throws for arbitrary input', () => { + fc.assert( + fc.property(fc.string(), raw => { + expect(() => parseChallenges(raw)).not.toThrow(); + }), + ); + }); + + test('property: never throws and always terminates over a metacharacter corpus', () => { + // `fc.string()` above rarely produces dense clusters of the characters that actually drive this + // parser's recovery branches, and one of those branches (`readSchemeTail` resetting to its saved + // position when a token68 read comes back empty) advances nothing on its own -- termination rests + // on the outer loop consuming instead. This enumerates that alphabet exhaustively at length 5. + const alphabet = [' ', ',', '=', '"', '\\', '/', '!', '@', 'a', '\t']; + for (let seed = 0; seed < 100_000; seed += 1) { + let text = ''; + let n = seed; + for (let i = 0; i < 5; i += 1) { + text += alphabet[n % alphabet.length] ?? ''; + n = Math.floor(n / alphabet.length); + } + expect(() => parseChallenges(text)).not.toThrow(); + } + }); + + test('a 100 KB quoted value stays linear and yields one challenge', () => { + expect( + parseChallenges(`Basic realm="${'x'.repeat(100_000)}"`), + ).toHaveLength(1); + }); + + test('property: a well-formed single challenge round-trips scheme + one param exactly', () => { + fc.assert( + fc.property( + fc.stringMatching(/^[A-Za-z][A-Za-z0-9]{1,10}$/u), + fc.stringMatching(/^[a-zA-Z0-9 ]{0,20}$/u), + (scheme, value) => { + const [challenge] = parseChallenges(`${scheme} realm="${value}"`); + expect(challenge?.scheme).toBe(scheme.toLowerCase()); + expect(challenge?.params.get('realm')).toBe(value); + }, + ), + ); + }); + + test('property: a comma inside a quoted value never splits the challenge', () => { + fc.assert( + fc.property(fc.stringMatching(/^[a-zA-Z0-9]{0,8}$/u), fragment => { + const challenges = parseChallenges( + `Digest realm="${fragment},${fragment}", nonce="n"`, + ); + expect(challenges).toHaveLength(1); + expect(challenges[0]?.params.get('realm')).toBe( + `${fragment},${fragment}`, + ); + }), + ); + }); +}); diff --git a/packages/core/src/auth/challenge.ts b/packages/core/src/auth/challenge.ts new file mode 100644 index 0000000..5428702 --- /dev/null +++ b/packages/core/src/auth/challenge.ts @@ -0,0 +1,326 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/challenge.ts + +/** + * One parsed RFC 7235 challenge (AUTH-12): the scheme plus its auth-params. + * + * Scheme and parameter names are lower-cased; parameter values are stored verbatim after unquoting. + * + * @internal + */ +export interface Challenge { + /** The challenge scheme, lower-cased. */ + readonly scheme: string; + /** Auth-params, keys lower-cased, values verbatim after unquoting. */ + readonly params: ReadonlyMap; +} + +/** + * What a handler needs from the request being stamped, beyond the challenge itself. + * + * Digest's HA2 is computed over the method and the request-target (RFC 7616 §3.4.3), neither of which + * the challenge carries. Passed as a small context object rather than the whole `Request` so a handler + * cannot reach the body or headers it has no business reading. + * + * @internal + */ +export interface DigestUriContext { + /** The request method, upper-case, as it goes on the wire. */ + readonly method: string; + /** The digest-uri: the request-target (path plus query) of the request being stamped. */ + readonly requestTarget: string; +} + +/** + * One challenge-reactive stamping strategy (AUTH-23–AUTH-25). Implemented by `basic.ts` and + * `digest.ts`, composed by `composing-handler.ts`. + * + * Throughout `auth/`, to STAMP means to PRODUCE the value the caller writes, never to write it: every + * `stamp` in this module (`ChallengeHandler.stamp`, `ComposingHandler.stamp`, `stampStaticKey`, + * `BearerTokenCache.stamp`) returns credential material and leaves the header write to `auth-step.ts`. + * + * Declared here rather than in `composing-handler.ts` so both implementations can be written before + * the composer exists, and because `challenge.ts` already owns the {@link Challenge} type both + * methods operate on. + * + * `stamp()` is asynchronous because Digest's SHA-256/SHA-256-sess algorithms compute HA1/HA2/response + * through `crypto.subtle.digest()`, and Web Crypto offers no synchronous digest to fall back to. + * Basic's implementation resolves immediately. + * + * @internal + */ +export interface ChallengeHandler { + /** + * AUTH-16/AUTH-25: whether this handler can answer `challenge`. + * + * @param challenge - the offered challenge. + * @returns `true` when this handler can produce a header value for it. + */ + canHandle(challenge: Challenge): boolean; + + /** + * Produces the header VALUE only — the caller picks `Authorization` vs `Proxy-Authorization` from + * which challenge header the status actually carried (AUTH-25). + * + * There is deliberately no `isProxy` parameter. It was one, threaded from `auth-step.ts` through + * `composing-handler.ts` into both implementations, and NEITHER read it: Basic's value is computed + * once at construction and Digest's depends only on the challenge and the request-target, so the + * only test either could carry for the parameter was one asserting it changed nothing. AUTH-25's + * origin-vs-proxy choice lives entirely in `auth-step.ts`'s `answerHeaderName`, which is the one + * place that knows which challenge header the status carried. A future scheme whose VALUE differs + * by proxy-ness would add it back — and would then have something to assert about it. + * + * @param challenge - the challenge being answered; `canHandle` has already passed. + * @param request - the request being stamped. Optional so a handler needing neither method nor + * target (Basic) is callable with one argument; Digest asserts its presence. + * @returns the header value. + */ + stamp(challenge: Challenge, request?: DigestUriContext): Promise; + + /** + * AUTH-16's "earliest in the configured preference list, not wire order": when a server offers + * several challenges a single handler could equally satisfy — RFC 7616 uses repeated Digest + * challenges differing only by `algorithm` as an algorithm-discovery mechanism — `canHandle` alone + * can only answer yes/no per challenge, never express a preference among them. + * + * Lower is more preferred. Optional: a handler with no algorithm variants (Basic) omits it and is + * treated as rank 0. + * + * @param challenge - the offered challenge. + * @returns the preference rank; lower wins. + */ + rank?(challenge: Challenge): number; +} + +/** + * AUTH-12 names this key literally: a token68 value is "recorded under a synthetic key", spelled + * `token68` in the requirement's own text. A genuine `token68=...` auth-param does not exist in RFC + * 7235's grammar — token68 is positional, never `name=value` — so no collision with a real parameter + * is possible. + */ +const TOKEN68_KEY = 'token68'; +const TOKEN_CHAR = /[!#$%&'*+\-.^_`|~0-9A-Za-z]/u; +const TOKEN68_CHAR = /[A-Za-z0-9\-._~+/]/u; + +interface Scanner { + readonly text: string; + pos: number; +} + +/** + * The one place BWS (RFC 7230's optional SP/HTAB run) is skipped, shared by the Scanner-driven walk + * and the two lookahead predicates below -- which cannot take a `Scanner`, because they must not + * advance one. + */ +function skipBwsFrom(text: string, from: number): number { + let index = from; + while (index < text.length && (text[index] === ' ' || text[index] === '\t')) { + index += 1; + } + return index; +} + +function skipSpaces(scanner: Scanner): void { + scanner.pos = skipBwsFrom(scanner.text, scanner.pos); +} + +function readToken(scanner: Scanner): string { + const start = scanner.pos; + while ( + scanner.pos < scanner.text.length && + TOKEN_CHAR.test(scanner.text[scanner.pos] ?? '') + ) + scanner.pos += 1; + return scanner.text.slice(start, scanner.pos); +} + +function readToken68Tail(scanner: Scanner): string { + const start = scanner.pos; + while (scanner.pos < scanner.text.length) { + const char = scanner.text[scanner.pos] ?? ''; + // '=' is token68's own padding, not an assignment: the caller only reaches here after ruling out + // a `name=value` reading. + if (!TOKEN68_CHAR.test(char) && char !== '=') break; + scanner.pos += 1; + } + return scanner.text.slice(start, scanner.pos); +} + +/** + * Honors backslash escapes (AUTH-12). An unterminated string ends at end-of-input rather than + * throwing (AUTH-13). + */ +function readQuotedString(scanner: Scanner): string { + scanner.pos += 1; // opening quote, already confirmed present by the caller + let value = ''; + while (scanner.pos < scanner.text.length) { + // `?? ''` throughout, not a cast: `noUncheckedIndexedAccess` types every index read as + // `string | undefined`, and the loop bound already rules the undefined out. + const char = scanner.text[scanner.pos] ?? ''; + if (char === '\\' && scanner.pos + 1 < scanner.text.length) { + value += scanner.text[scanner.pos + 1] ?? ''; + scanner.pos += 2; + continue; + } + if (char === '"') { + scanner.pos += 1; + return value; + } + value += char; + scanner.pos += 1; + } + return value; +} + +/** + * Consumes, without capturing, up to and including the next top-level comma — the recovery path for a + * malformed segment (AUTH-13). Quote depth is tracked, so a comma inside a quoted value is not a + * recovery point. + */ +function skipToNextTopLevelComma(scanner: Scanner): void { + let inQuotes = false; + while (scanner.pos < scanner.text.length) { + const char = scanner.text[scanner.pos]; + if (char === '\\' && inQuotes && scanner.pos + 1 < scanner.text.length) { + scanner.pos += 2; + continue; + } + if (char === '"') inQuotes = !inQuotes; + else if (char === ',' && !inQuotes) { + scanner.pos += 1; + return; + } + scanner.pos += 1; + } +} + +/** Whether the next non-whitespace character is the `=` of a `name=value` auth-param. */ +function peekIsParamAssignment(text: string, fromPos: number): boolean { + return text[skipBwsFrom(text, fromPos)] === '='; +} + +/** + * Whether a `name=value` reading is viable: an `=` followed, after optional BWS, by a real token or + * quoted-string value. + * + * Only the position immediately after a scheme name needs this stricter test, because that is the one + * place RFC 7235 permits a positional `token68` — and a token68 may END in one or more `=` (base64 + * padding), so `Negotiate YWJj==` would otherwise be misread as an auth-param `ywjj` with an empty + * value. Everywhere else inside a challenge the loose `=` test stands, so a genuinely empty auth-param + * value stays an empty auth-param rather than being re-read as a token68 that cannot appear there. + */ +function peekIsValuedParam(text: string, fromPos: number): boolean { + const equalsAt = skipBwsFrom(text, fromPos); + if (text[equalsAt] !== '=') return false; + const next = text[skipBwsFrom(text, equalsAt + 1)] ?? ''; + return next === '"' || TOKEN_CHAR.test(next); +} + +function readValue(scanner: Scanner): string { + return scanner.text[scanner.pos] === '"' + ? readQuotedString(scanner) + : readToken(scanner); +} + +interface MutableChallenge { + readonly scheme: string; + readonly params: Map; +} + +/** Reads one `name=value` pair into `current`. The caller has already confirmed the `=` follows. */ +function readParamInto( + scanner: Scanner, + name: string, + current: MutableChallenge, +): void { + skipSpaces(scanner); + scanner.pos += 1; // '=' + skipSpaces(scanner); + current.params.set(name.toLowerCase(), readValue(scanner)); + skipSpaces(scanner); + if (scanner.text[scanner.pos] === ',') scanner.pos += 1; +} + +/** Reads the optional token68-or-first-param tail immediately following a freshly-read scheme name. */ +function readSchemeTail(scanner: Scanner, current: MutableChallenge): void { + skipSpaces(scanner); + if (scanner.pos >= scanner.text.length || scanner.text[scanner.pos] === ',') + return; + const savedPos = scanner.pos; + const maybeName = readToken(scanner); + if (maybeName !== '' && peekIsValuedParam(scanner.text, scanner.pos)) { + readParamInto(scanner, maybeName, current); + return; + } + scanner.pos = savedPos; + const token68 = readToken68Tail(scanner); + if (token68 !== '') current.params.set(TOKEN68_KEY, token68); + skipSpaces(scanner); + if (scanner.text[scanner.pos] === ',') scanner.pos += 1; +} + +/** + * Parses an RFC 7235 `WWW-Authenticate`/`Proxy-Authenticate` value into its ordered challenge list + * (AUTH-12). + * + * Total by construction (AUTH-13): it never throws, for any input. Blank input yields `[]`; a + * malformed segment recovers at the next top-level comma through a quote-depth-tracked scan — never a + * naive `.split(',')`, which breaks on a quoted value containing a comma; params parsed before a + * malformed tail are kept; an unterminated quoted string terminates at end-of-input. + * + * Hand-written for the same reason 5a's RFC 1123 `Retry-After` date parser was: there is no built-in + * RFC 7235 parser to lean on, and a general-purpose header splitter would not honor quoted-string + * commas. + * + * @param headerValue - the raw header value. + * @returns the challenges, in wire order. Never throws. + * + * @internal + */ +export function parseChallenges(headerValue: string): readonly Challenge[] { + const challenges: MutableChallenge[] = []; + const scanner: Scanner = {text: headerValue, pos: 0}; + + for (;;) { + skipSpaces(scanner); + if (scanner.pos >= scanner.text.length) break; + if (scanner.text[scanner.pos] === ',') { + scanner.pos += 1; + continue; + } + + const token = readToken(scanner); + if (token === '') { + skipToNextTopLevelComma(scanner); + continue; + } + + if (peekIsParamAssignment(scanner.text, scanner.pos)) { + // A `name=value` with no scheme ahead of it: attach it to the challenge in progress, or discard + // the segment when the header opens with one. + const current = challenges.at(-1); + if (current === undefined) { + skipToNextTopLevelComma(scanner); + continue; + } + readParamInto(scanner, token, current); + continue; + } + + const current: MutableChallenge = { + scheme: token.toLowerCase(), + params: new Map(), + }; + challenges.push(current); + readSchemeTail(scanner, current); + } + + // `Object.freeze` is SHALLOW, and `params` is a `Map`, which `Object.freeze` cannot make read-only + // at all -- `.set()` still succeeds on a frozen Map. The `ReadonlyMap` TYPE is therefore the only + // guard on the parameters, exactly as `createAuthRequirement` documents for its own params map, and + // it holds for the same reason: `Challenge` is `@internal`, never reaches a consumer, and nothing + // in this package re-casts `Challenge['params']` back to `Map`. + return challenges.map(entry => + Object.freeze({scheme: entry.scheme, params: entry.params}), + ); +} diff --git a/packages/core/src/auth/composing-handler.test.ts b/packages/core/src/auth/composing-handler.test.ts new file mode 100644 index 0000000..ca11957 --- /dev/null +++ b/packages/core/src/auth/composing-handler.test.ts @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/composing-handler.test.ts +// Exercises: AUTH-23 (ordered handler list, defensively copied; first configured handler wins), +// AUTH-24 (handler order beats wire-order challenge position), AUTH-25 (returns the value half only +// -- no header-name decision here, and no header at all when nothing is satisfiable), and the +// rank-based tie-break that carries AUTH-16's algorithm preference. +import {describe, expect, test} from 'bun:test'; +import type {Challenge, ChallengeHandler} from './challenge.js'; +import {composingHandler} from './composing-handler.js'; + +function fakeHandler( + scheme: string, + value: string, + rank = 0, +): ChallengeHandler { + return { + canHandle: (challenge: Challenge): boolean => challenge.scheme === scheme, + stamp: (): Promise => Promise.resolve(value), + rank: (): number => rank, + }; +} + +/** + * Captures a rejection reason. `expect(...).rejects` is typed as returning `void` under this runner's + * type definitions, so awaiting it trips `@typescript-eslint/await-thenable`; this helper keeps the + * assertion honest without a lint suppression. Same shape 5a's and 5b's step suites settled on. + */ +async function rejectionOf(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error; + } + return undefined; +} + +describe('composingHandler', () => { + test('delegates to the first CONFIGURED handler that can satisfy any offered challenge', async () => { + const handler = composingHandler([ + fakeHandler('digest', 'digest-value'), + fakeHandler('basic', 'basic-value'), + ]); + const challenges: readonly Challenge[] = [ + {scheme: 'basic', params: new Map()}, + {scheme: 'digest', params: new Map()}, + ]; + // basic appears FIRST on the wire, but digest's HANDLER is configured first -- handler order wins. + expect(await handler.stamp(challenges)).toBe('digest-value'); + }); + + test('falls through to a later handler when the first cannot satisfy anything offered', async () => { + const handler = composingHandler([ + fakeHandler('digest', 'digest-value'), + fakeHandler('basic', 'basic-value'), + ]); + expect(await handler.stamp([{scheme: 'basic', params: new Map()}])).toBe( + 'basic-value', + ); + }); + + test('returns undefined when no handler can satisfy any offered challenge', async () => { + const handler = composingHandler([fakeHandler('digest', 'x')]); + expect( + await handler.stamp([{scheme: 'basic', params: new Map()}]), + ).toBeUndefined(); + }); + + test('returns undefined for an empty challenge list', async () => { + const handler = composingHandler([fakeHandler('digest', 'x')]); + expect(await handler.stamp([])).toBeUndefined(); + }); + + test('returns undefined when no handlers are configured at all', async () => { + const handler = composingHandler([]); + expect( + await handler.stamp([{scheme: 'digest', params: new Map()}]), + ).toBeUndefined(); + }); +}); + +describe('composingHandler ranking and delegation (AUTH-16/AUTH-23)', () => { + test('within one handler satisfying multiple challenges, rank breaks the tie', async () => { + const digestLike: ChallengeHandler = { + canHandle: challenge => challenge.scheme === 'digest', + stamp: challenge => + Promise.resolve( + `value-for-${challenge.params.get('algorithm') ?? 'default'}`, + ), + rank: challenge => + challenge.params.get('algorithm') === 'SHA-256' ? 0 : 1, + }; + const handler = composingHandler([digestLike]); + const challenges: readonly Challenge[] = [ + {scheme: 'digest', params: new Map([['algorithm', 'MD5']])}, + {scheme: 'digest', params: new Map([['algorithm', 'SHA-256']])}, + ]; + expect(await handler.stamp(challenges)).toBe('value-for-SHA-256'); + }); + + test('rank never outranks handler order -- a worse-ranked earlier handler still wins', async () => { + const handler = composingHandler([ + fakeHandler('digest', 'digest-value', 99), + fakeHandler('basic', 'basic-value', 0), + ]); + const challenges: readonly Challenge[] = [ + {scheme: 'basic', params: new Map()}, + {scheme: 'digest', params: new Map()}, + ]; + expect(await handler.stamp(challenges)).toBe('digest-value'); + }); + + test('a handler with no rank() defaults to 0 and does not crash the sort', async () => { + const noRank: ChallengeHandler = { + canHandle: (): boolean => true, + stamp: (): Promise => Promise.resolve('no-rank-value'), + }; + const handler = composingHandler([noRank]); + expect(await handler.stamp([{scheme: 'anything', params: new Map()}])).toBe( + 'no-rank-value', + ); + }); +}); + +describe('composingHandler isolation and error propagation (AUTH-23)', () => { + test('defensively copies the handler list at construction (AUTH-23)', async () => { + const handlers = [fakeHandler('basic', 'v1')]; + const handler = composingHandler(handlers); + handlers.push(fakeHandler('digest', 'v2')); + // 'digest' was pushed after construction, so the composed handler must not see it. + expect( + await handler.stamp([{scheme: 'digest', params: new Map()}]), + ).toBeUndefined(); + }); + + // There is deliberately no companion test for an `isProxy` flag. One was threaded through this + // composer into both handlers and NEITHER read it, so the only assertion either could carry was + // that it changed nothing; AUTH-25's origin-vs-proxy choice lives in `auth-step.ts`'s + // `answerHeaderName`, which is where it is actually asserted. + test('passes the request context through to the winning handler', async () => { + let observedRequest: {method: string; requestTarget: string} | undefined; + const recorder: ChallengeHandler = { + canHandle: (): boolean => true, + stamp: (_challenge, request): Promise => { + observedRequest = request; + return Promise.resolve('v'); + }, + }; + const handler = composingHandler([recorder]); + await handler.stamp([{scheme: 'x', params: new Map()}], { + method: 'GET', + requestTarget: '/y', + }); + expect(observedRequest).toEqual({method: 'GET', requestTarget: '/y'}); + }); + + test("a rejecting handler's failure propagates rather than being swallowed as 'no replacement'", async () => { + const boom: ChallengeHandler = { + canHandle: (): boolean => true, + stamp: (): Promise => + Promise.reject(new Error('handler blew up')), + }; + const error = await rejectionOf( + composingHandler([boom]).stamp([{scheme: 'x', params: new Map()}]), + ); + expect((error as Error).message).toBe('handler blew up'); + }); +}); diff --git a/packages/core/src/auth/composing-handler.ts b/packages/core/src/auth/composing-handler.ts new file mode 100644 index 0000000..8fa9012 --- /dev/null +++ b/packages/core/src/auth/composing-handler.ts @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/composing-handler.ts +import type { + Challenge, + ChallengeHandler, + DigestUriContext, +} from './challenge.js'; + +/** + * Ordered delegation over a fixed handler list (AUTH-23–AUTH-25). + * + * @internal + */ +export interface ComposingHandler { + /** + * Answers the best challenge any configured handler can satisfy. + * + * @param challenges - every challenge the response offered, in wire order. + * @param request - the request being stamped, for handlers that need method and target. + * @returns the header VALUE, or `undefined` when no handler can satisfy any offered challenge — + * which the auth step reads as "no replacement request" (AUTH-25). + */ + stamp( + challenges: readonly Challenge[], + request?: DigestUriContext, + ): Promise; +} + +interface Candidate { + readonly handlerIndex: number; + readonly rank: number; + readonly handler: ChallengeHandler; + readonly challenge: Challenge; +} + +function collectCandidates( + handlers: readonly ChallengeHandler[], + challenges: readonly Challenge[], +): Candidate[] { + const candidates: Candidate[] = []; + handlers.forEach((handler, handlerIndex) => { + for (const challenge of challenges) { + if (handler.canHandle(challenge)) { + candidates.push({ + handlerIndex, + rank: handler.rank?.(challenge) ?? 0, + handler, + challenge, + }); + } + } + }); + return candidates; +} + +/** + * AUTH-23: handler CONFIGURATION order is the primary key — "the first handler in declaration order + * whose can-handle check passes" wins regardless of where its satisfiable challenge sits on the wire. + * `rank` is the secondary key, carrying AUTH-16's algorithm-preference-over-wire-order rule within a + * single handler. + */ +function bestCandidate( + candidates: readonly Candidate[], +): Candidate | undefined { + return [...candidates].sort( + (a, b) => a.handlerIndex - b.handlerIndex || a.rank - b.rank, + )[0]; +} + +/** + * Composes an ordered handler list into one challenge answerer (AUTH-23–AUTH-25). + * + * The list is defensively copied at construction (AUTH-23), so a caller mutating its array afterwards + * cannot change which handlers this composer consults. Callers order stronger schemes first — the + * auth step builds `[digest, basic]`. + * + * Returns `undefined` — meaning "no replacement request" — when no handler can satisfy any offered + * challenge (AUTH-25). It never throws: an unsatisfiable challenge is an ordinary outcome the auth + * step turns into "leave the 401 unchanged" (AUTH-33), not an error condition. + * + * AUTH-25's `Authorization`-vs-`Proxy-Authorization` choice is NOT threaded through here: this + * composer and both handlers produce the VALUE half only, and `auth-step.ts` picks the header name + * from which challenge header the status actually carried. + * + * Handlers are stateless apart from Digest's per-nonce counter, which is safe for concurrent + * invocation (AUTH-24). + * + * @param handlers - the handlers, strongest first. + * @returns the composed handler. + * + * @internal + */ +export function composingHandler( + handlers: readonly ChallengeHandler[], +): ComposingHandler { + const configured = [...handlers]; + return { + stamp: async (challenges, request): Promise => { + const candidate = bestCandidate( + collectCandidates(configured, challenges), + ); + if (candidate === undefined) return undefined; + return candidate.handler.stamp(candidate.challenge, request); + }, + }; +} diff --git a/packages/core/src/auth/credential.test.ts b/packages/core/src/auth/credential.test.ts new file mode 100644 index 0000000..23f76c9 --- /dev/null +++ b/packages/core/src/auth/credential.test.ts @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/credential.test.ts +// Exercises: AUTH-8 (BearerToken is value-equal; ApiKeyCredential/NameKeyCredential are +// reference-equal via bare `===`, no equals() override; ALL THREE redact their secret in every +// string/diagnostic form and none is reachable through JSON.stringify/Object.keys), AUTH-9 (blank +// rejected as a programmer error, and every type is nominal so the validation cannot be routed +// around), AUTH-10 (expiry math: undefined never locally expires; expired iff nowMs + marginMs > +// expiresAt), AUTH-26 (`credentialKey()` is the sole read path for a static key's secret). +import {describe, expect, test} from 'bun:test'; +import {InvariantViolation} from '../invariant.js'; +import { + ApiKeyCredential, + BearerToken, + NameKeyCredential, + bearerTokensEqual, + createBearerToken, + credentialKey, + isBearerTokenExpired, +} from './credential.js'; + +// `util.inspect` is not imported: nothing else in `packages/core` reaches for a `node:` module, and +// the hook under test is reachable directly. `console.log`/`util.inspect` call exactly this method. +const INSPECT = Symbol.for('nodejs.util.inspect.custom'); + +function inspectOf( + value: ApiKeyCredential | BearerToken | NameKeyCredential, +): string { + const hooks = value as unknown as Record string) | undefined>; + const hook = hooks[INSPECT]; + expect(typeof hook).toBe('function'); + return hook === undefined ? '' : hook.call(value); +} + +describe('BearerToken', () => { + test('value equality over token + expiry', () => { + const a = createBearerToken('t', 1000); + const b = createBearerToken('t', 1000); + expect(bearerTokensEqual(a, b)).toBe(true); + }); + + test('differing token or expiry is not equal', () => { + expect( + bearerTokensEqual(createBearerToken('a'), createBearerToken('b')), + ).toBe(false); + expect( + bearerTokensEqual(createBearerToken('t', 1), createBearerToken('t', 2)), + ).toBe(false); + }); + + test('an absent expiry and a set expiry are not equal', () => { + expect( + bearerTokensEqual(createBearerToken('t'), createBearerToken('t', 1)), + ).toBe(false); + }); + + test('is frozen', () => { + expect(Object.isFrozen(createBearerToken('t'))).toBe(true); + }); + + test('rejects a blank or whitespace-only token (AUTH-9)', () => { + expect(() => createBearerToken('')).toThrow(InvariantViolation); + expect(() => createBearerToken(' ')).toThrow(InvariantViolation); + }); + + test('rejects a non-finite expiresAt', () => { + // `isBearerTokenExpired` is `nowMs + marginMs > expiresAt`, so a NaN expiry makes every + // comparison false: the token reads as permanently fresh, the cache serves it from the hot path + // forever, and no provider call ever happens to notice. Rejected at construction rather than + // discovered as a dead credential in production. + expect(() => createBearerToken('t', Number.NaN)).toThrow( + 'expiresAt must be a finite epoch', + ); + expect(() => createBearerToken('t', Number.POSITIVE_INFINITY)).toThrow( + 'expiresAt must be a finite epoch', + ); + }); + + test('undefined expiresAt never locally expires (AUTH-10)', () => { + const token = createBearerToken('t'); + expect(isBearerTokenExpired(token, Number.MAX_SAFE_INTEGER, 0)).toBe(false); + expect(isBearerTokenExpired(token, Number.MAX_SAFE_INTEGER, 60_000)).toBe( + false, + ); + }); + + test('expired iff nowMs + marginMs > expiresAt (AUTH-10)', () => { + const token = createBearerToken('t', 1000); + expect(isBearerTokenExpired(token, 999, 0)).toBe(false); + expect(isBearerTokenExpired(token, 1000, 0)).toBe(false); // exactly at expiry, not yet past it + expect(isBearerTokenExpired(token, 1001, 0)).toBe(true); + expect(isBearerTokenExpired(token, 900, 200)).toBe(true); // margin pushes it over + }); +}); + +describe('BearerToken redaction and nominality (AUTH-8/AUTH-9)', () => { + test('toString and inspect redact the token but keep the expiry', () => { + const token = createBearerToken('super-secret', 1000); + expect(token.toString()).not.toContain('super-secret'); + expect(String(token)).not.toContain('super-secret'); + expect(token.toString()).toContain('1000'); + expect(inspectOf(token)).not.toContain('super-secret'); + }); + + test('JSON.stringify and Object.keys cannot reach the token -- #private, not TS private', () => { + const token = createBearerToken('super-secret', 1000); + expect(JSON.stringify(token)).not.toContain('super-secret'); + expect(Object.keys(token)).toEqual(['expiresAt']); + }); + + test('the accessor still hands the real token to the stamping path', () => { + expect(createBearerToken('super-secret').token).toBe('super-secret'); + }); + + test('nominal: an object literal is not a BearerToken, so AUTH-9 cannot be bypassed', () => { + // The compile-time half is the point -- a `TokenProvider` returning `{token: '', expiresAt: + // undefined}` no longer type-checks -- and this asserts the runtime half: the constructor is + // private, so `createBearerToken` (which validates) is the only construction path. + const literal = {token: '', expiresAt: undefined}; + expect(literal instanceof BearerToken).toBe(false); + expect(createBearerToken('t') instanceof BearerToken).toBe(true); + }); +}); + +describe('ApiKeyCredential (AUTH-8)', () => { + test('two instances with identical fields are NOT equal -- reference identity only', () => { + expect( + new ApiKeyCredential('secret') === new ApiKeyCredential('secret'), + ).toBe(false); + }); + + test('toString and inspect redact the key', () => { + const credential = new ApiKeyCredential('super-secret'); + expect(credential.toString()).not.toContain('super-secret'); + expect(String(credential)).not.toContain('super-secret'); + expect(inspectOf(credential)).not.toContain('super-secret'); + }); + + test('JSON.stringify cannot reach the key either -- #private, not TS private', () => { + expect(JSON.stringify(new ApiKeyCredential('super-secret'))).not.toContain( + 'super-secret', + ); + expect(Object.keys(new ApiKeyCredential('super-secret'))).toEqual([]); + }); + + test('rejects a blank or whitespace-only key (AUTH-9)', () => { + expect(() => new ApiKeyCredential('')).toThrow(InvariantViolation); + expect(() => new ApiKeyCredential(' ')).toThrow(InvariantViolation); + }); + + test('the secret is reachable ONLY through the internal credentialKey() hook', () => { + const credential = new ApiKeyCredential('secret'); + expect(credentialKey(credential)).toBe('secret'); + // No public `key` accessor: the friend hook is the whole read path, so the secret never appears + // on the published surface (AUTH-8). + expect('key' in credential).toBe(false); + }); +}); + +describe('NameKeyCredential (AUTH-8)', () => { + test('two instances with identical fields are NOT equal', () => { + expect( + new NameKeyCredential('n', 'k') === new NameKeyCredential('n', 'k'), + ).toBe(false); + }); + + test('toString redacts the key but names the name', () => { + const credential = new NameKeyCredential('x-api-key', 'super-secret'); + expect(credential.toString()).toContain('x-api-key'); + expect(credential.toString()).not.toContain('super-secret'); + expect(inspectOf(credential)).not.toContain('super-secret'); + }); + + test('rejects a blank or whitespace-only name or key (AUTH-9)', () => { + expect(() => new NameKeyCredential('', 'k')).toThrow(InvariantViolation); + expect(() => new NameKeyCredential('n', '')).toThrow(InvariantViolation); + expect(() => new NameKeyCredential(' ', 'k')).toThrow(InvariantViolation); + }); + + test('the secret is reachable only through credentialKey(); .name stays public', () => { + const credential = new NameKeyCredential('x-api-key', 'secret'); + expect(credentialKey(credential)).toBe('secret'); + expect(credential.name).toBe('x-api-key'); + expect('key' in credential).toBe(false); + }); + + test('JSON.stringify reaches the name but never the key', () => { + const serialized = JSON.stringify( + new NameKeyCredential('x-api-key', 'super-secret'), + ); + expect(serialized).toContain('x-api-key'); + expect(serialized).not.toContain('super-secret'); + }); +}); diff --git a/packages/core/src/auth/credential.ts b/packages/core/src/auth/credential.ts new file mode 100644 index 0000000..f0581ee --- /dev/null +++ b/packages/core/src/auth/credential.ts @@ -0,0 +1,316 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/credential.ts +import {invariant} from '../invariant.js'; + +// No `as unique symbol` cast: TypeScript rejects `unique symbol` in a type assertion (TS1335). A +// `const` initialized directly by a `Symbol.for()` call already gets the `unique symbol` type, which +// is what makes it usable as a computed member name below. +const INSPECT: unique symbol = Symbol.for('nodejs.util.inspect.custom'); + +/** + * TypeScript has no friend classes, so {@link createBearerToken} -- a plain function, not a member -- + * reaches the private constructor through this module-scoped `let`, assigned exactly once inside the + * class's `static {}` block. Init-once wiring, not mutable state; the same shape every builder-based + * model in `src/http/` uses. + */ +let createToken: (token: string, expiresAt: number | undefined) => BearerToken; + +/** + * An OAuth2 bearer token (AUTH-8, AUTH-9, AUTH-10). + * + * A class with `#token`, not a frozen data object, for two reasons AUTH-8 and AUTH-9 make between + * them: + * + * - **Redaction.** AUTH-8 requires EVERY credential type to redact its secret in any + * string/diagnostic representation. A plain `{token, expiresAt}` object redacts nothing: + * `console.log` prints the token, `JSON.stringify` serializes it, and any structured logger walking + * the object graph carries it into a log sink. `#token` is unreachable to all three, and the + * `toString`/inspect pair below gives the redacted form those paths fall back to. `expiresAt` stays + * an ordinary public field -- AUTH-8 explicitly permits non-secret fields to remain visible. + * - **Nominality.** `TokenProvider` returns a `BearerToken`, so with a structural interface a provider + * could hand back an object literal and bypass AUTH-9's non-blank validation entirely. `#token` + * makes the type nominal and the `private` constructor makes {@link createBearerToken} the only way + * to build one, so the validation cannot be routed around. + * + * AUTH-8's VALUE equality is unaffected: it lives in {@link bearerTokensEqual}, a pure function over + * the two fields, exactly as it did when this was a data object. There is deliberately no `equals` + * member -- the key credentials below need reference identity, and keeping equality out of both + * classes keeps that distinction in one place. + * + * @public + */ +export class BearerToken { + readonly #token: string; + /** Epoch ms; `undefined` means "never locally expires" (AUTH-10). Non-secret, so visible. */ + readonly expiresAt: number | undefined; + + private constructor(token: string, expiresAt: number | undefined) { + this.#token = token; + this.expiresAt = expiresAt; + Object.freeze(this); + } + + static { + createToken = (token, expiresAt) => new BearerToken(token, expiresAt); + } + + /** The opaque token, never blank (AUTH-9). Read by the stamping path that writes the header. */ + get token(): string { + return this.#token; + } + + /** + * AUTH-8's redacted string form. The expiry survives; the token does not. + * + * @returns the representation with the token masked. + */ + toString(): string { + return `BearerToken{token=***, expiresAt=${String(this.expiresAt)}}`; + } + + /** + * The same redaction for `console.log`/`util.inspect`, which do not route object arguments through + * `toString`. Node-specific but harmless elsewhere -- an unrecognized well-known symbol is simply + * never read. + * + * @returns the redacted representation. + */ + [INSPECT](): string { + return this.toString(); + } +} + +/** + * Builds a frozen {@link BearerToken}. The only way to construct one: the constructor is `private`, so + * AUTH-9's validation below cannot be bypassed by a provider returning a hand-built value. + * + * @param token - the opaque token. Must not be blank. + * @param expiresAt - epoch ms at which the token expires, or `undefined` for "never locally expires". + * When present it must be a finite number. + * @returns the frozen token. + * @throws InvariantViolation when `token` is blank (AUTH-9), or when `expiresAt` is present but not + * finite -- both caller misconfigurations. + * + * @public + */ +export function createBearerToken( + token: string, + expiresAt?: number, +): BearerToken { + invariant(token.trim().length > 0, 'bearer token must not be blank'); // AUTH-9 + // A `NaN` expiry makes every comparison in `isBearerTokenExpired` false, so the token reads as + // permanently fresh and the cache stamps a dead credential forever -- silently, and with no + // provider call to notice. Rejected at construction for the same reason `authStep` rejects a + // non-finite margin: it is the identical bug arriving through the other door. + invariant( + expiresAt === undefined || Number.isFinite(expiresAt), + `bearer token expiresAt must be a finite epoch, got ${String(expiresAt)}`, + ); + return createToken(token, expiresAt); +} + +/** + * AUTH-8's VALUE equality for {@link BearerToken}, over the real token and expiry. + * + * A pure function rather than an `equals` member, deliberately: the two key credentials next door + * need REFERENCE identity, and keeping equality out of every credential class is what stops one of + * them acquiring value semantics by accident. The redacted string form has no bearing on it. + * + * @param a - the left token. + * @param b - the right token. + * @returns `true` when token and expiry both match. + * + * @public + */ +export function bearerTokensEqual(a: BearerToken, b: BearerToken): boolean { + return a.token === b.token && a.expiresAt === b.expiresAt; +} + +/** + * AUTH-10: expired at `nowMs` with grace margin `marginMs` if and only if an expiry is set and + * `nowMs + marginMs` strictly exceeds it. An exact hit on the expiry instant is NOT yet expired. + * + * @param token - the token to test. + * @param nowMs - the reference time, epoch ms. + * @param marginMs - the refresh grace margin in ms; `0` evaluates true expiry. + * @returns `true` when the token is expired under that margin. + * + * @internal + */ +export function isBearerTokenExpired( + token: BearerToken, + nowMs: number, + marginMs: number, +): boolean { + return token.expiresAt !== undefined && nowMs + marginMs > token.expiresAt; +} + +/** + * The friend-class hooks for the two key credentials' secrets. `static-key.ts` -- a different module, + * and the ONLY sanctioned reader -- reaches them through {@link credentialKey}. + * + * A public `get key()` would have been simpler and was the first shape here, but it re-opens exactly + * the leak the `#key` note below argues against: it puts the secret back on the published `.d.ts`, + * reachable as `credential.key` by any consumer, any diagnostic helper walking accessors, and any + * future logging step. Init-once wiring assigned in each class's `static {}` block, not mutable state. + */ +let readApiKey: (credential: ApiKeyCredential) => string; +let readNameKey: (credential: NameKeyCredential) => string; + +/** + * The in-package read hook for a static key credential's secret (AUTH-26's stamping path). + * + * Exported (still internal-only, absent from the package barrel) because the one caller -- + * `stampStaticKey` -- lives in another module and TypeScript has no friend-class visibility to + * express that with. + * + * @param credential - the credential whose secret is being stamped. + * @returns the raw key. + * + * @internal + */ +export function credentialKey( + credential: ApiKeyCredential | NameKeyCredential, +): string { + return credential instanceof ApiKeyCredential + ? readApiKey(credential) + : readNameKey(credential); +} + +/** + * A static API key (AUTH-8, AUTH-9, AUTH-26). + * + * AUTH-8 requires REFERENCE equality here — "two instances with identical fields are NOT equal" — so + * this is a class with a private field and deliberately NO `equals` override: `===`, the language + * default, already gives exactly those semantics. + * + * `#key`, not `private key`, is the deliberate exception to `docs/knowledge/data-modeling.md`'s + * `private`-by-default rule, and the same note requires the justification be written down: AUTH-8's + * redaction is a RUNTIME-privacy requirement, not a compile-time one. `private` is erased, leaving the + * secret reachable through `credential['key']`, `Object.keys`, `JSON.stringify`, and a default + * `util.inspect` — exactly the accidental-leak paths the redacted `toString`/inspect exist to close. + * `#key` is genuinely unreachable, and the nominality it induces is load-bearing besides: it is what + * stops a caller substituting an object literal for a validated credential. + * + * @public + */ +export class ApiKeyCredential { + readonly #key: string; + + /** + * @param key - the secret key. Must not be blank. + * @throws InvariantViolation when `key` is blank (AUTH-9). + */ + constructor(key: string) { + invariant(key.trim().length > 0, 'ApiKeyCredential key must not be blank'); // AUTH-9 + this.#key = key; + } + + static { + readApiKey = credential => credential.#key; + } + + /** + * AUTH-8's redacted string form. + * + * @returns a fixed representation with the key masked. + */ + toString(): string { + return 'ApiKeyCredential{key=***}'; + } + + /** + * The same redaction for `console.log`/`util.inspect`, which do not route object arguments through + * `toString`. Node-specific but harmless elsewhere — an unrecognized well-known symbol is simply + * never read. + * + * @returns the redacted representation. + */ + [INSPECT](): string { + return this.toString(); + } +} + +/** + * A named static key — the header-name/secret pair AUTH-26 stamps (AUTH-8, AUTH-9). + * + * `#key` for the same runtime-privacy reason as {@link ApiKeyCredential}; `name` is non-secret, which + * AUTH-8 explicitly permits to stay visible, so it is an ordinary public field. + * + * @public + */ +export class NameKeyCredential { + /** The non-secret identifier — a header name, a key id. AUTH-8 permits this to stay visible. */ + readonly name: string; + readonly #key: string; + + /** + * @param name - the non-secret identifier. Must not be blank. + * @param key - the secret key. Must not be blank. + * @throws InvariantViolation when either is blank (AUTH-9). + */ + constructor(name: string, key: string) { + invariant( + name.trim().length > 0, + 'NameKeyCredential name must not be blank', + ); // AUTH-9 + invariant(key.trim().length > 0, 'NameKeyCredential key must not be blank'); // AUTH-9 + this.name = name; + this.#key = key; + } + + static { + readNameKey = credential => credential.#key; + } + + /** + * AUTH-8's redacted string form: the name survives, the key does not. + * + * @returns the representation with the key masked. + */ + toString(): string { + return `NameKeyCredential{name=${this.name}, key=***}`; + } + + /** + * The same redaction for `console.log`/`util.inspect`. See {@link ApiKeyCredential} for why both + * hooks are needed. + * + * @returns the redacted representation. + */ + [INSPECT](): string { + return this.toString(); + } +} + +/** + * AUTH-11: an async token source. A plain function type, no class. + * + * A throwing or rejecting provider propagates and is never cached — `bearer-cache.ts` simply does not + * catch around the call, so that falls out of the structure rather than needing an explicit branch. + * + * **A provider MUST carry its own deadline.** It takes NO parameters, deliberately -- not even an + * optional `{signal}` bag. AUTH-34 coalesces every concurrent caller racing on a missing or expiring + * token onto ONE fetch, so that fetch belongs to no single request: handing it one caller's signal + * would let a stranger's cancellation reject callers who never aborted, including a caller who + * supplied no signal at all, and would let a request that merely finished tear down a refresh other + * requests are joined to. `bearer-cache.ts` races each caller's own WAIT against that caller's own + * signal instead, which cancels the wait without cancelling the work. + * + * Since nothing can ever populate a signal parameter, there is no signal parameter -- a slot + * documented as never filled is worse than no slot, because a caller writes code against it and then + * wonders why cancelling does nothing. The consequence is that nothing outside the provider can bound + * the fetch, so the provider must bound itself: + * + * ```ts + * const provider: TokenProvider = () => fetchToken({signal: AbortSignal.timeout(5_000)}); + * ``` + * + * `docs/knowledge/concurrency-and-async.md`'s "every external I/O call must carry a deadline" is the + * rule this discharges; its "pass the caller's signal down to the I/O primitive" rule is the one + * deliberately not applied here, because the premise it rests on -- that the call owns the I/O -- is + * false for a coalesced fetch. Recorded in the phase checklist's Deviation Ledger. + * + * @public + */ +export type TokenProvider = () => Promise; diff --git a/packages/core/src/auth/descriptor.test.ts b/packages/core/src/auth/descriptor.test.ts new file mode 100644 index 0000000..67340e1 --- /dev/null +++ b/packages/core/src/auth/descriptor.test.ts @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/descriptor.test.ts +// Exercises: AUTH-3 (non-empty, immutable, ordered; empty list rejected as a programmer error via +// invariant(), not a typed operational leaf -- see the plan's Global Constraints). +import {describe, expect, test} from 'bun:test'; +import {InvariantViolation} from '../invariant.js'; +import {createAuthDescriptor} from './descriptor.js'; +import {createAuthRequirement} from './requirement.js'; + +describe('createAuthDescriptor', () => { + test('preserves requirement order', () => { + const descriptor = createAuthDescriptor([ + createAuthRequirement('DIGEST'), + createAuthRequirement('BASIC'), + ]); + expect(descriptor.requirements.map(r => r.scheme)).toEqual([ + 'DIGEST', + 'BASIC', + ]); + }); + + test('allowsAnonymous is true iff any requirement is NO_AUTH', () => { + expect( + createAuthDescriptor([createAuthRequirement('NO_AUTH')]).allowsAnonymous, + ).toBe(true); + expect( + createAuthDescriptor([createAuthRequirement('BASIC')]).allowsAnonymous, + ).toBe(false); + expect( + createAuthDescriptor([ + createAuthRequirement('BASIC'), + createAuthRequirement('NO_AUTH'), + ]).allowsAnonymous, + ).toBe(true); + }); + + test('rejects an empty requirement list (AUTH-3) -- a programmer error, not AuthResolutionError', () => { + expect(() => createAuthDescriptor([])).toThrow(InvariantViolation); + }); + + test('is frozen, including the requirements array', () => { + const descriptor = createAuthDescriptor([createAuthRequirement('BASIC')]); + expect(Object.isFrozen(descriptor)).toBe(true); + expect(Object.isFrozen(descriptor.requirements)).toBe(true); + }); + + test('defensively copies the requirement list', () => { + const requirements = [createAuthRequirement('BASIC')]; + const descriptor = createAuthDescriptor(requirements); + requirements.push(createAuthRequirement('NO_AUTH')); + expect(descriptor.requirements.length).toBe(1); + }); +}); diff --git a/packages/core/src/auth/descriptor.ts b/packages/core/src/auth/descriptor.ts new file mode 100644 index 0000000..3afa42e --- /dev/null +++ b/packages/core/src/auth/descriptor.ts @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/descriptor.ts +import {invariant} from '../invariant.js'; +import type {AuthRequirement} from './requirement.js'; + +/** + * AUTH-3: a non-empty, immutable, ordered list of requirements in preference order. + * + * @public + */ +export interface AuthDescriptor { + /** The requirements, in preference order. Never empty. */ + readonly requirements: readonly AuthRequirement[]; + /** `true` if and only if some requirement's scheme is `NO_AUTH` (AUTH-3). */ + readonly allowsAnonymous: boolean; +} + +/** + * Builds a frozen {@link AuthDescriptor}, copying the requirement list so later caller-side mutation + * cannot reach the stored value (AUTH-3). + * + * An empty list is a PROGRAMMER error — a caller assembling zero requirements has a bug, not an + * operational failure — so it goes through `invariant()`, the same call 5a's `retrySettings()` and + * 5b's `redirectSettings()` made, rather than a typed error leaf. + * + * @param requirements - the requirements, in preference order. Must be non-empty. + * @returns the frozen descriptor. + * @throws InvariantViolation when `requirements` is empty (AUTH-3). + * + * @public + */ +export function createAuthDescriptor( + requirements: readonly AuthRequirement[], +): AuthDescriptor { + invariant( + requirements.length > 0, + 'AuthDescriptor requires at least one AuthRequirement', + ); + return Object.freeze({ + requirements: Object.freeze([...requirements]), + allowsAnonymous: requirements.some( + requirement => requirement.scheme === 'NO_AUTH', + ), + }); +} diff --git a/packages/core/src/auth/digest.test.ts b/packages/core/src/auth/digest.test.ts new file mode 100644 index 0000000..c43fd80 --- /dev/null +++ b/packages/core/src/auth/digest.test.ts @@ -0,0 +1,474 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/digest.test.ts +// Exercises: AUTH-15 (exactly {MD5, MD5-sess, SHA-256, SHA-256-sess}, qop=auth or absent, declines +// auth-int and unsupported algorithms), AUTH-16 (satisfiability: scheme/realm/nonce/qop/algorithm, +// and configured-preference order over wire order), AUTH-17 (HA1/HA2/response per RFC 7616/2069, +// verified against independently-computed vectors), AUTH-18/AUTH-19 (nonce count: starts at 1, +// increments only on nonce reuse, 8 lower-case hex digits, bounded and drained to the cap), +// AUTH-20 (client nonce from crypto.getRandomValues, >=128 bits), AUTH-21 (UTF-8 vs ISO-8859-1 by +// charset), AUTH-22 (quoting, and cnonce/nc/qop emitted only when qop negotiated), AUTH-25 +// (Authorization vs Proxy-Authorization is the CALLER's job -- stamp() returns only the value). +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import {InvariantViolation} from '../invariant.js'; +import type {Challenge, DigestUriContext} from './challenge.js'; +import { + NonceCountStore, + computeDigestResponse, + digestHandler, +} from './digest.js'; + +const REALM = 'testrealm@host.com'; +const NONCE = 'dcd98b7102dd2f0e8b11d0f600bfb0c093'; +const CNONCE = '0a4f113b'; +const NC = '00000001'; +const BASE = { + realm: REALM, + nonce: NONCE, + isUtf8: true, + method: 'GET', + uri: '/dir/index.html', + username: 'Mufasa', + password: 'Circle Of Life', + cnonce: CNONCE, + nc: NC, +} as const; + +const REQUEST_CONTEXT: DigestUriContext = { + method: 'GET', + requestTarget: '/dir/index.html', +}; + +function digestChallenge(params: Record): Challenge { + return {scheme: 'digest', params: new Map(Object.entries(params))}; +} + +/** + * Captures a rejection reason. `expect(...).rejects` is typed as returning `void` under this runner's + * type definitions, so awaiting it trips `@typescript-eslint/await-thenable`; this helper keeps the + * assertion honest without a lint suppression. Same shape 5a's and 5b's step suites settled on. + */ +async function rejectionOf(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error; + } + return undefined; +} + +describe('computeDigestResponse (verified against RFC 2617/7616 vectors)', () => { + test('MD5, qop=auth', async () => { + expect( + await computeDigestResponse({ + ...BASE, + algorithm: 'MD5', + hasQopAuth: true, + }), + ).toBe('6629fae49393a05397450978507c4ef1'); + }); + + test('MD5, no qop (RFC 2069 form)', async () => { + expect( + await computeDigestResponse({ + ...BASE, + algorithm: 'MD5', + hasQopAuth: false, + }), + ).toBe('670fd8c2df070c60b045671b8b24ff02'); + }); + + test('MD5-sess, qop=auth', async () => { + expect( + await computeDigestResponse({ + ...BASE, + algorithm: 'MD5-sess', + hasQopAuth: true, + }), + ).toBe('8e3825c57e897f5a0dec6c2d4e5059d0'); + }); + + test('SHA-256, qop=auth', async () => { + expect( + await computeDigestResponse({ + ...BASE, + algorithm: 'SHA-256', + hasQopAuth: true, + }), + ).toBe('5abdd07184ba512a22c53f41470e5eea7dcaa3a93a59b630c13dfe0a5dc6e38b'); + }); + + test('SHA-256-sess, qop=auth', async () => { + expect( + await computeDigestResponse({ + ...BASE, + algorithm: 'SHA-256-sess', + hasQopAuth: true, + }), + ).toBe('b8822e12417cb7750f4e2b8515f0dcf25b7dd26993e80bee1426201446a7f59b'); + }); +}); + +describe('computeDigestResponse charset and determinism (AUTH-17/AUTH-21)', () => { + test('AUTH-21: the charset changes the hash for a non-ASCII password', async () => { + const utf8 = await computeDigestResponse({ + ...BASE, + password: 'pässwörd', + algorithm: 'MD5', + hasQopAuth: true, + isUtf8: true, + }); + const latin1 = await computeDigestResponse({ + ...BASE, + password: 'pässwörd', + algorithm: 'MD5', + hasQopAuth: true, + isUtf8: false, + }); + expect(utf8).not.toBe(latin1); + }); + + test('AUTH-21: an all-ASCII input hashes identically under either charset', async () => { + const utf8 = await computeDigestResponse({ + ...BASE, + algorithm: 'MD5', + hasQopAuth: true, + isUtf8: true, + }); + const latin1 = await computeDigestResponse({ + ...BASE, + algorithm: 'MD5', + hasQopAuth: true, + isUtf8: false, + }); + expect(utf8).toBe(latin1); + }); + + test('is deterministic -- the same inputs recompute to the same response (AUTH-17)', async () => { + const input = {...BASE, algorithm: 'SHA-256', hasQopAuth: true} as const; + expect(await computeDigestResponse(input)).toBe( + await computeDigestResponse(input), + ); + }); +}); + +describe('NonceCountStore (AUTH-18/19)', () => { + test('starts at 1 for a first-seen nonce', () => { + expect(new NonceCountStore().next('n1')).toBe(1); + }); + + test('increments only on reuse of the SAME nonce', () => { + const store = new NonceCountStore(); + expect(store.next('n1')).toBe(1); + // A different nonce starts fresh; it does not inherit n1's count. + expect(store.next('n2')).toBe(1); + expect(store.next('n1')).toBe(2); + expect(store.next('n1')).toBe(3); + }); + + test('property: a fixed nonce produces a strictly increasing sequence', () => { + fc.assert( + fc.property(fc.integer({min: 1, max: 200}), calls => { + const fresh = new NonceCountStore(); + let previous = 0; + for (let i = 0; i < calls; i += 1) { + const count = fresh.next('fixed'); + expect(count).toBeGreaterThan(previous); + previous = count; + } + }), + ); + }); + + test('bounded at 1024 entries, oldest evicted first (AUTH-19)', () => { + const store = new NonceCountStore(); + for (let i = 0; i < 1024; i += 1) store.next(`nonce-${String(i)}`); + store.next('nonce-1024'); // 1025th distinct nonce -- evicts 'nonce-0' + expect(store.next('nonce-0')).toBe(1); // evicted -- starts over, not 2 + }); + + test('drains back UNDER the cap after every admit, not one victim per insert (AUTH-19/XCUT-14)', () => { + // The distinguishing case for drain-to-cap vs pre-insert check-then-evict: a long run of fresh + // server-chosen nonces. A single-victim-per-insert store stays pinned at (or above) the bound + // forever without converging; the loop must leave the map at exactly the cap after each admit. + const store = new NonceCountStore(); + for (let i = 0; i < 4096; i += 1) { + store.next(`burst-${String(i)}`); + expect(store.size).toBeLessThanOrEqual(1024); + } + expect(store.size).toBe(1024); + }); +}); + +describe('digestHandler: credential validation (AUTH-9/AUTH-22)', () => { + test('rejects blank credentials', () => { + expect(() => digestHandler('', 'p')).toThrow(InvariantViolation); + expect(() => digestHandler('u', ' ')).toThrow(InvariantViolation); + }); + + test('rejects a username that is not header-safe (AUTH-22)', () => { + // AUTH-22 writes the username verbatim into the Authorization value, and HTTP-18 admits only + // HTAB plus printable ASCII there. Caller configuration, so it fails fast and loudly at + // construction rather than being declined silently per request the way a server realm is. + // RFC 7616 §4's `username*` encoding would lift this and is deferred. + expect(() => digestHandler('björn', 'p')).toThrow('header-safe'); + }); + + test('canHandle declines a challenge whose realm cannot be echoed (AUTH-22/HTTP-18)', () => { + // A received field-value may legally carry obs-text (HTTP-19), so `realm="café"` reaches us + // intact -- but it cannot go back out. Declining makes AUTH-33 surface the 401 unchanged, which + // beats building the header anyway and throwing HeaderValidationError out of the whole step. + const handler = digestHandler('u', 'p'); + expect( + handler.canHandle( + digestChallenge({realm: 'café', nonce: NONCE, algorithm: 'MD5'}), + ), + ).toBe(false); + }); + + test('canHandle declines an opaque or nonce that cannot be echoed (AUTH-22/HTTP-18)', () => { + const handler = digestHandler('u', 'p'); + expect( + handler.canHandle( + digestChallenge({realm: REALM, nonce: NONCE, opaque: 'ö'}), + ), + ).toBe(false); + expect( + handler.canHandle(digestChallenge({realm: REALM, nonce: 'nö'})), + ).toBe(false); + }); +}); + +describe('digestHandler: challenge selection (AUTH-15/AUTH-16)', () => { + test('canHandle accepts a well-formed Digest challenge', () => { + const handler = digestHandler('u', 'p'); + expect( + handler.canHandle( + digestChallenge({realm: REALM, nonce: NONCE, qop: 'auth'}), + ), + ).toBe(true); + }); + + test('canHandle accepts a qop list that merely CONTAINS auth', () => { + const handler = digestHandler('u', 'p'); + expect( + handler.canHandle( + digestChallenge({realm: REALM, nonce: NONCE, qop: 'auth-int, auth'}), + ), + ).toBe(true); + }); + + test('canHandle rejects a non-Digest scheme', () => { + expect( + digestHandler('u', 'p').canHandle({scheme: 'basic', params: new Map()}), + ).toBe(false); + }); + + test('canHandle rejects a missing realm or nonce', () => { + const handler = digestHandler('u', 'p'); + expect(handler.canHandle(digestChallenge({nonce: NONCE}))).toBe(false); + expect(handler.canHandle(digestChallenge({realm: REALM}))).toBe(false); + }); + + test('canHandle declines an auth-int-only qop (AUTH-15)', () => { + const handler = digestHandler('u', 'p'); + expect( + handler.canHandle( + digestChallenge({realm: REALM, nonce: NONCE, qop: 'auth-int'}), + ), + ).toBe(false); + }); + + test('canHandle declines an unsupported algorithm (AUTH-15)', () => { + const handler = digestHandler('u', 'p'); + expect( + handler.canHandle( + digestChallenge({realm: REALM, nonce: NONCE, algorithm: 'MD4'}), + ), + ).toBe(false); + }); + + test('canHandle matches the algorithm name case-insensitively', () => { + const handler = digestHandler('u', 'p'); + expect( + handler.canHandle( + digestChallenge({realm: REALM, nonce: NONCE, algorithm: 'sha-256'}), + ), + ).toBe(true); + }); +}); + +describe('digestHandler stamping (AUTH-17..AUTH-22, AUTH-25)', () => { + test('canHandle defaults to MD5 when algorithm is absent', () => { + const handler = digestHandler('u', 'p', {algorithmPreference: ['MD5']}); + expect( + handler.canHandle(digestChallenge({realm: REALM, nonce: NONCE})), + ).toBe(true); + }); + + test('canHandle honors a caller-restricted algorithm preference', () => { + const handler = digestHandler('u', 'p', {algorithmPreference: ['SHA-256']}); + expect( + handler.canHandle( + digestChallenge({realm: REALM, nonce: NONCE, algorithm: 'MD5'}), + ), + ).toBe(false); + }); + + test('rank reflects preference-list order, for composing-handler.ts to sort by', () => { + const handler = digestHandler('u', 'p', { + algorithmPreference: ['SHA-256', 'MD5'], + }); + const sha = handler.rank?.( + digestChallenge({realm: REALM, nonce: NONCE, algorithm: 'SHA-256'}), + ); + const md5Rank = handler.rank?.( + digestChallenge({realm: REALM, nonce: NONCE, algorithm: 'MD5'}), + ); + expect(sha).toBeLessThan(md5Rank ?? Number.POSITIVE_INFINITY); + }); + + test('rank is worst-possible for a challenge it cannot handle', () => { + const handler = digestHandler('u', 'p'); + expect(handler.rank?.({scheme: 'basic', params: new Map()})).toBe( + Number.MAX_SAFE_INTEGER, + ); + }); + + test('stamp() produces a well-formed Digest header value, qop negotiated', async () => { + const handler = digestHandler('Mufasa', 'Circle Of Life'); + const challenge = digestChallenge({ + realm: REALM, + nonce: NONCE, + qop: 'auth', + }); + const value = await handler.stamp(challenge, REQUEST_CONTEXT); + expect(value.startsWith('Digest ')).toBe(true); + expect(value).toContain('username="Mufasa"'); + expect(value).toContain(`realm="${REALM}"`); + expect(value).toContain('uri="/dir/index.html"'); + expect(value).toContain('qop=auth'); + expect(value).toMatch(/nc=[0-9a-f]{8}/u); + expect(value).toMatch(/response="[0-9a-f]+"/u); + }); +}); + +describe('digestHandler nonce counting and preconditions (AUTH-18/AUTH-25)', () => { + test('stamp() draws a fresh >=128-bit client nonce per call (AUTH-20)', async () => { + const handler = digestHandler('u', 'p'); + const challenge = digestChallenge({ + realm: REALM, + nonce: NONCE, + qop: 'auth', + }); + const first = /cnonce="([0-9a-f]+)"/u.exec( + await handler.stamp(challenge, REQUEST_CONTEXT), + ); + const second = /cnonce="([0-9a-f]+)"/u.exec( + await handler.stamp(challenge, REQUEST_CONTEXT), + ); + expect(first?.[1]).toHaveLength(32); // 16 bytes rendered as hex + expect(first?.[1]).not.toBe(second?.[1]); + }); + + test('stamp() emits the FULL algorithm spelling, unquoted (AUTH-22)', async () => { + const handler = digestHandler('u', 'p'); + const value = await handler.stamp( + digestChallenge({ + realm: REALM, + nonce: NONCE, + qop: 'auth', + algorithm: 'SHA-256-sess', + }), + REQUEST_CONTEXT, + ); + expect(value).toContain('algorithm=SHA-256-sess'); + }); + + test('stamp() escapes a quote inside a realm rather than emitting it raw (AUTH-22)', async () => { + const handler = digestHandler('u', 'p'); + const value = await handler.stamp( + digestChallenge({realm: 'a"b', nonce: NONCE}), + REQUEST_CONTEXT, + ); + expect(value).toContain(String.raw`realm="a\"b"`); + }); +}); + +describe('digestHandler opaque and qop emission (AUTH-22)', () => { + test('stamp() echoes the challenge opaque back, quoted (AUTH-22)', async () => { + const handler = digestHandler('u', 'p'); + const challenge = digestChallenge({ + realm: REALM, + nonce: NONCE, + qop: 'auth', + opaque: '5ccc069c403ebaf9f0171e9517f40e41', + }); + const value = await handler.stamp(challenge, REQUEST_CONTEXT); + expect(value).toContain('opaque="5ccc069c403ebaf9f0171e9517f40e41"'); + }); + + test('stamp() omits opaque entirely when the challenge carried none (AUTH-22)', async () => { + const handler = digestHandler('u', 'p'); + const value = await handler.stamp( + digestChallenge({realm: REALM, nonce: NONCE}), + REQUEST_CONTEXT, + ); + expect(value).not.toContain('opaque'); + }); +}); + +describe('digestHandler nonce-count sequencing (AUTH-18)', () => { + test('stamp() omits cnonce/nc/qop when the challenge negotiated no qop (AUTH-22)', async () => { + const handler = digestHandler('u', 'p'); + const challenge = digestChallenge({realm: REALM, nonce: NONCE}); + const value = await handler.stamp(challenge, REQUEST_CONTEXT); + expect(value).not.toContain('qop='); + expect(value).not.toContain('cnonce='); + expect(value).not.toContain('nc='); + }); + + test('two successive stamp() calls against the SAME nonce increment nc (AUTH-18)', async () => { + const handler = digestHandler('u', 'p'); + const challenge = digestChallenge({ + realm: REALM, + nonce: NONCE, + qop: 'auth', + }); + const first = await handler.stamp(challenge, REQUEST_CONTEXT); + const second = await handler.stamp(challenge, REQUEST_CONTEXT); + expect(first).toContain('nc=00000001'); + expect(second).toContain('nc=00000002'); + }); + + test('a no-qop stamp does not consume a nonce count (AUTH-18/AUTH-22)', async () => { + const handler = digestHandler('u', 'p'); + await handler.stamp( + digestChallenge({realm: REALM, nonce: NONCE}), + REQUEST_CONTEXT, + ); + const withQop = await handler.stamp( + digestChallenge({realm: REALM, nonce: NONCE, qop: 'auth'}), + REQUEST_CONTEXT, + ); + expect(withQop).toContain('nc=00000001'); + }); + + test('stamp() rejects a challenge canHandle() would decline', async () => { + const handler = digestHandler('u', 'p'); + expect( + await rejectionOf( + handler.stamp({scheme: 'basic', params: new Map()}, REQUEST_CONTEXT), + ), + ).toBeInstanceOf(InvariantViolation); + }); + + test('stamp() rejects a missing DigestUriContext -- it cannot compute HA2 without one', async () => { + const handler = digestHandler('u', 'p'); + expect( + await rejectionOf( + handler.stamp(digestChallenge({realm: REALM, nonce: NONCE})), + ), + ).toBeInstanceOf(InvariantViolation); + }); +}); diff --git a/packages/core/src/auth/digest.ts b/packages/core/src/auth/digest.ts new file mode 100644 index 0000000..93ffddd --- /dev/null +++ b/packages/core/src/auth/digest.ts @@ -0,0 +1,487 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/digest.ts +import {hasForbiddenOutboundByte} from '../http/ascii-validation.js'; +import {invariant} from '../invariant.js'; +import type { + Challenge, + ChallengeHandler, + DigestUriContext, +} from './challenge.js'; +import {md5, toHex} from './md5.js'; + +/** + * AUTH-15: exactly these four algorithms are supported. `auth-int` and every other algorithm is + * declined rather than approximated. + * + * @public + */ +export type DigestAlgorithm = 'MD5' | 'MD5-sess' | 'SHA-256' | 'SHA-256-sess'; + +// `as const`, not a bare `readonly` annotation, so the CONSTANT_CASE is honest: `naming-conventions.md` +// reserves that casing for deeply immutable values, and a bare `readonly DigestAlgorithm[]` annotation +// is a compile-time claim only -- the array stays mutable at runtime through a cast. +const SUPPORTED_ALGORITHMS = [ + 'MD5', + 'MD5-sess', + 'SHA-256', + 'SHA-256-sess', +] as const satisfies readonly DigestAlgorithm[]; + +// Strongest first. AUTH-16 makes this list the PREFERENCE order, applied regardless of the order the +// server offered its challenges in. +const DEFAULT_ALGORITHM_PREFERENCE = [ + 'SHA-256-sess', + 'SHA-256', + 'MD5-sess', + 'MD5', +] as const satisfies readonly DigestAlgorithm[]; + +const NONCE_COUNT_LIMIT = 1024; + +/** + * Digest handler tuning. + * + * @internal + */ +export interface DigestOptions { + /** + * Preferred-first order, and also the ACCEPTABLE set: an algorithm absent from this list is + * declined outright (AUTH-16). Defaults to `['SHA-256-sess', 'SHA-256', 'MD5-sess', 'MD5']`. + */ + readonly algorithmPreference?: readonly DigestAlgorithm[] | undefined; +} + +function baseAlgorithm(algorithm: DigestAlgorithm): 'MD5' | 'SHA-256' { + return algorithm.startsWith('MD5') ? 'MD5' : 'SHA-256'; +} + +/** + * AUTH-21's non-UTF-8 branch. ISO-8859-1 is a byte-for-byte code-unit copy for every character it can + * represent; a character outside the codebook has no ISO-8859-1 encoding at all, and truncating is the + * same lossy answer every other Latin-1 encoder gives. A server that expects such a character is + * required to advertise `charset=UTF-8`, which routes to the other branch. + */ +function encodeLatin1(input: string): Uint8Array { + const bytes = new Uint8Array(input.length); + for (let i = 0; i < input.length; i += 1) bytes[i] = input.charCodeAt(i); + return bytes; +} + +/** + * AUTH-21's UTF-8 branch, copied into a freshly-allocated buffer. + * + * The copy is not incidental: `crypto.subtle.digest` takes a `BufferSource`, which excludes a view + * that might sit on a `SharedArrayBuffer`, and `TextEncoder.encode` is typed as the wider + * `Uint8Array`. Re-narrowing with a cast would assert something the type system + * cannot check; allocating an exact-typed buffer costs one copy of a string that is never more than a + * few hundred bytes. + */ +function encodeUtf8(input: string): Uint8Array { + const encoded = new TextEncoder().encode(input); + const bytes = new Uint8Array(encoded.length); + bytes.set(encoded); + return bytes; +} + +/** AUTH-17/AUTH-21: one hash, over one encoding of one string. */ +interface HashInput { + readonly base: 'MD5' | 'SHA-256'; + readonly input: string; + /** AUTH-21: UTF-8 when the challenge advertised `charset=UTF-8`, ISO-8859-1 otherwise. */ + readonly isUtf8: boolean; +} + +// An options object rather than three positional parameters: `function-design.md` requires one at +// three or more parameters, and unconditionally for any boolean parameter -- `hashHex(base, s, true)` +// says nothing at the call site about what the `true` selects. +async function hashHex({base, input, isUtf8}: HashInput): Promise { + const bytes = isUtf8 ? encodeUtf8(input) : encodeLatin1(input); + // MD5 is hand-rolled because Web Crypto excludes it; SHA-256 goes through Web Crypto rather than + // `node:crypto` to keep the package portable (SEAM-1, `sdk-design-nodejs/06`). + if (base === 'MD5') return toHex(md5(bytes)); + return toHex( + new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', bytes)), + ); +} + +/** + * The per-server-nonce request counter (AUTH-18, AUTH-19). + * + * `nc` starts at 1 for a first-seen nonce, increments only on reuse of that same nonce, and wraps to + * the low 32 bits on overflow. Bounded at 1024 entries with insertion-order eviction — `Map` + * iteration order IS insertion order, so the oldest key is `keys().next().value` and no separate LRU + * structure is needed. Evicting a live nonce is harmless: its count restarts at 1, which is + * spec-legal for a nonce the server has just re-issued. + * + * The eviction is an insert-THEN-drain, never a pre-insert check-then-evict: `next()` admits the + * nonce first and only then brings the map back under the cap, which is how + * `docs/knowledge/concurrency-and-async.md` (XCUT-14) and AUTH-19 both word it — "drained back under + * the cap after admitting a nonce". The key space is the SERVER's, since it picks the nonces, so a + * pre-insert evict would leave a burst sitting above the cap rather than converging to it. + * + * The drain is a `while` rather than an `if` purely as defence, and the distinction is NOT currently + * observable: `next()` grows the map by at most one entry per call, so the body can run at most once + * and the two spellings are equivalent today. The loop is what keeps the bound true if `NONCE_COUNT_LIMIT` + * is ever lowered at runtime or a second writer is ever added. + * + * AUTH-24's concurrency clause: `next()` is one synchronous read-increment-write with no `await` + * between the read and the write, so two concurrent callers cannot observe the same count. Node and + * Bun have no preemptive interleaving mid-statement — the same collapse 5a documented for BODY-3's + * materialize-once guard. + * + * @internal + */ +export class NonceCountStore { + private readonly counts = new Map(); + + /** + * The number of nonces currently tracked. + * + * Exposed so the bound itself is assertable — otherwise "drained back under the cap" is testable + * only through the indirect "an evicted nonce restarts at 1" probe, which passes for a + * single-victim-per-insert store that never converges. + */ + get size(): number { + return this.counts.size; + } + + /** + * Returns the nonce count to send with this request (AUTH-18). + * + * @param nonce - the server-chosen nonce being answered. + * @returns `1` the first time this nonce is seen, one more than the previous value on each reuse, + * wrapping to the low 32 bits on overflow. + */ + next(nonce: string): number { + const current = this.counts.get(nonce); + const count = current === undefined ? 1 : (current + 1) >>> 0; + this.counts.set(nonce, count); + + // The just-admitted nonce sits at the TAIL of insertion order (a `set` on an existing key leaves + // the map's size unchanged, so the loop is not entered at all on a reuse), which is why draining + // from the head can never evict the live nonce this call is answering with. + while (this.counts.size > NONCE_COUNT_LIMIT) { + const oldest = this.counts.keys().next().value; + invariant( + oldest !== undefined, + 'nonce-count store is over its bound but reports no oldest entry', + ); + this.counts.delete(oldest); + } + + return count; + } +} + +/** AUTH-18: exactly 8 lower-case hex digits. */ +function formatNonceCount(count: number): string { + return count.toString(16).padStart(8, '0'); +} + +/** AUTH-20: at least 128 bits from a CSPRNG. Never `Math.random()`. */ +function generateClientNonce(): string { + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + return toHex(bytes); +} + +interface ParsedDigestChallenge { + readonly algorithm: DigestAlgorithm; + readonly realm: string; + readonly nonce: string; + /** + * Whether `qop=auth` was negotiated. Named for what it HOLDS, not for the wire parameter: `qop` on + * the wire is a string (`auth`, `auth-int`), so a boolean called `qop` reads as that value. + */ + readonly hasQopAuth: boolean; + readonly isUtf8: boolean; + /** + * AUTH-22 names `opaque` in the must-quote list, which only makes sense if it is emitted: RFC 7616 + * requires the client return the server's opaque value unchanged, and a server that binds session + * state to it rejects a request that omits it. Absent when the challenge carried none. + */ + readonly opaque: string | undefined; +} + +/** + * AUTH-22 echoes `realm`, `nonce`, and `opaque` VERBATIM into the `Authorization` value, and HTTP-18's + * outbound grammar admits only HTAB plus printable ASCII. A received challenge is held to the laxer + * inbound rule (HTTP-19 permits obs-text, exactly so a Latin-1 field is not silently dropped), so a + * server may legitimately hand us a realm this client cannot echo -- `Digest realm="café"` is a real + * RFC 7616 shape, which is why the spec has a `charset` parameter at all. + * + * Declining such a challenge makes `canHandle` false, so the composer finds no candidate and AUTH-33 + * surfaces the 401 unchanged. That is strictly better than the alternative it replaces, which was to + * build the header anyway and throw `HeaderValidationError` out of the whole auth step -- turning a + * challenge the caller could have inspected into an exception. Relaxing the outbound rule instead was + * never an option: HTTP-17/18/19's strictness is the request-splitting defence. + * + * The consequence is that AUTH-21's UTF-8 branch is reachable for the HASH INPUT (where a non-ASCII + * password lives and works) but not for the realm ECHO. RFC 7616 §4's `username*` (RFC 5987) extended + * notation is the standard answer and is deferred; both are recorded in the Deviation Ledger. + */ +function isHeaderSafeEcho(info: { + readonly realm: string; + readonly nonce: string; + readonly opaque: string | undefined; +}): boolean { + return ( + !hasForbiddenOutboundByte(info.realm) && + !hasForbiddenOutboundByte(info.nonce) && + (info.opaque === undefined || !hasForbiddenOutboundByte(info.opaque)) + ); +} + +/** + * AUTH-16: satisfiable if and only if the scheme is `digest`, `realm` and `nonce` are both present, + * `qop` is absent or contains `auth`, the algorithm (defaulting to `MD5`) is in the caller's + * configured preference list, and every field AUTH-22 echoes back is header-safe + * ({@link isHeaderSafeEcho}). + */ +function parseDigestChallenge( + challenge: Challenge, + preference: readonly DigestAlgorithm[], +): ParsedDigestChallenge | undefined { + if (challenge.scheme !== 'digest') return undefined; + const realm = challenge.params.get('realm'); + const nonce = challenge.params.get('nonce'); + if (realm === undefined || nonce === undefined) return undefined; + + const qopRaw = challenge.params.get('qop'); + const hasQop = qopRaw !== undefined; + // AUTH-15: an `auth-int`-only challenge is DECLINED, not silently downgraded. + if ( + hasQop && + !qopRaw.split(',').some(entry => entry.trim().toLowerCase() === 'auth') + ) { + return undefined; + } + + const algorithmRaw = challenge.params.get('algorithm'); + const algorithm = + algorithmRaw === undefined + ? 'MD5' + : SUPPORTED_ALGORITHMS.find( + candidate => candidate.toLowerCase() === algorithmRaw.toLowerCase(), + ); + if (algorithm === undefined || !preference.includes(algorithm)) { + return undefined; + } + + const isUtf8 = + (challenge.params.get('charset') ?? '').toLowerCase() === 'utf-8'; + const info: ParsedDigestChallenge = { + algorithm, + realm, + nonce, + hasQopAuth: hasQop, + isUtf8, + opaque: challenge.params.get('opaque'), + }; + return isHeaderSafeEcho(info) ? info : undefined; +} + +/** + * Everything {@link computeDigestResponse} needs. Bundled into one object because the computation + * genuinely takes eleven inputs and `max-params` is 3. + * + * @internal + */ +export interface DigestComputationInput { + /** The negotiated algorithm; `-sess` variants fold the nonce and cnonce into HA1. */ + readonly algorithm: DigestAlgorithm; + /** The challenge's realm. */ + readonly realm: string; + /** The server-chosen nonce. */ + readonly nonce: string; + /** Whether `qop=auth` was negotiated. `false` selects RFC 2069's shorter response input. */ + readonly hasQopAuth: boolean; + /** AUTH-21: UTF-8 hash input when the challenge advertised `charset=UTF-8`, ISO-8859-1 otherwise. */ + readonly isUtf8: boolean; + /** The request method. */ + readonly method: string; + /** The digest-uri: the request-target. */ + readonly uri: string; + /** The user id. */ + readonly username: string; + /** The password. */ + readonly password: string; + /** The client nonce; ignored when `qop` is `false`. */ + readonly cnonce: string; + /** The 8-hex-digit nonce count; ignored when `qop` is `false`. */ + readonly nc: string; +} + +/** + * Computes HA1, HA2, and the Digest response per RFC 7616/2069 (AUTH-17), in lower-case hex. + * + * Exported, and taking a single bundled parameter, so it can be unit-tested directly against fixed, + * independently-verified vectors: `digestHandler()`'s own `stamp()` always generates a fresh random + * cnonce (AUTH-20), so its output can never be asserted against a fixed expected hash end-to-end. + * + * @param input - the full computation input. + * @returns the response value, lower-case hex. + * + * @internal + */ +export async function computeDigestResponse( + input: DigestComputationInput, +): Promise { + const base = baseAlgorithm(input.algorithm); + const isUtf8 = input.isUtf8; + const ha1Plain = await hashHex({ + base, + input: `${input.username}:${input.realm}:${input.password}`, + isUtf8, + }); + const ha1 = input.algorithm.endsWith('-sess') + ? await hashHex({ + base, + input: `${ha1Plain}:${input.nonce}:${input.cnonce}`, + isUtf8, + }) + : ha1Plain; + const ha2 = await hashHex({ + base, + input: `${input.method}:${input.uri}`, + isUtf8, + }); + const responseInput = input.hasQopAuth + ? `${ha1}:${input.nonce}:${input.nc}:${input.cnonce}:auth:${ha2}` + : `${ha1}:${input.nonce}:${ha2}`; + return hashHex({base, input: responseInput, isUtf8}); +} + +/** AUTH-22's quoting: a quoted-string with `\` and `"` escaped. */ +function quote(value: string): string { + return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`; +} + +interface HeaderValueParams { + readonly username: string; + readonly info: ParsedDigestChallenge; + readonly uri: string; + readonly response: string; + readonly cnonce: string; + readonly nc: string; +} + +/** + * AUTH-22: quotes `username`/`realm`/`nonce`/`uri`/`response`/`cnonce`/`opaque`; leaves + * `qop`/`nc`/`algorithm` unquoted, with the full algorithm spelling; emits `cnonce`/`nc`/`qop` only + * when `qop` was actually negotiated. + */ +function buildHeaderValue(params: HeaderValueParams): string { + const {username, info, uri, response, cnonce, nc} = params; + const parts = [ + `username=${quote(username)}`, + `realm=${quote(info.realm)}`, + `nonce=${quote(info.nonce)}`, + `uri=${quote(uri)}`, + `algorithm=${info.algorithm}`, + `response=${quote(response)}`, + ]; + // AUTH-22: `opaque` is quoted and echoed back verbatim when the challenge carried one. + if (info.opaque !== undefined) parts.push(`opaque=${quote(info.opaque)}`); + if (info.hasQopAuth) + parts.push('qop=auth', `nc=${nc}`, `cnonce=${quote(cnonce)}`); + return `Digest ${parts.join(', ')}`; +} + +/** + * The Digest challenge handler (AUTH-15–AUTH-22). + * + * Cryptographic primitives are split across two sources for portability: `md5.ts` for MD5/MD5-sess, + * which Web Crypto deliberately excludes, and `crypto.subtle.digest('SHA-256', …)` for the SHA-256 + * pair. The client nonce comes from `crypto.getRandomValues()` (AUTH-20). + * + * The per-nonce counter is the one piece of mutable state, and it needs no lock: nothing awaits + * between its read and its write (AUTH-24). + * + * Challenge-reactive only — Digest structurally cannot stamp before seeing the server's + * `realm`/`nonce`. + * + * @param username - the user id. Must not be blank, and must be header-safe (printable ASCII): + * AUTH-22 writes it into the `Authorization` value verbatim. + * @param password - the password. Must not be blank. May hold any character — it only ever reaches + * the hash input, which is where AUTH-21's UTF-8/Latin-1 choice applies. + * @param options - algorithm preference; omitted means strongest-first over all four. + * @returns a handler that answers satisfiable `digest` challenges. + * @throws InvariantViolation when either credential is blank, or the username carries a byte HTTP-18 + * forbids in an outbound header value — both caller misconfigurations. + * + * @internal + */ +export function digestHandler( + username: string, + password: string, + options?: DigestOptions, +): ChallengeHandler { + invariant(username.trim().length > 0, 'Digest username must not be blank'); + invariant(password.trim().length > 0, 'Digest password must not be blank'); + // Configuration, not wire data: a non-ASCII username is the caller's own mistake, so it fails fast + // and loudly at construction rather than being declined silently per-request the way an + // unechoable server realm is. RFC 7616 §4's `username*` encoding would lift this and is deferred. + invariant( + !hasForbiddenOutboundByte(username), + 'Digest username must be header-safe (printable ASCII); RFC 7616 username* encoding is not yet supported', + ); + const preference = + options?.algorithmPreference ?? DEFAULT_ALGORITHM_PREFERENCE; + const nonceCounts = new NonceCountStore(); + + return { + canHandle: (challenge: Challenge): boolean => + parseDigestChallenge(challenge, preference) !== undefined, + + // AUTH-16: among several Digest challenges differing only by algorithm, prefer the one earliest + // in the CONFIGURED list, not the one earliest on the wire. + rank: (challenge: Challenge): number => { + const parsed = parseDigestChallenge(challenge, preference); + return parsed === undefined + ? Number.MAX_SAFE_INTEGER + : preference.indexOf(parsed.algorithm); + }, + + stamp: async ( + challenge: Challenge, + request?: DigestUriContext, + ): Promise => { + const info = parseDigestChallenge(challenge, preference); + invariant( + info !== undefined, + 'digestHandler.stamp called with a challenge canHandle() would reject', + ); + invariant( + request !== undefined, + 'digestHandler.stamp requires a DigestUriContext (method + requestTarget)', + ); + + const cnonce = generateClientNonce(); + const nc = info.hasQopAuth + ? formatNonceCount(nonceCounts.next(info.nonce)) + : ''; + const response = await computeDigestResponse({ + algorithm: info.algorithm, + realm: info.realm, + nonce: info.nonce, + hasQopAuth: info.hasQopAuth, + isUtf8: info.isUtf8, + method: request.method, + uri: request.requestTarget, + username, + password, + cnonce, + nc, + }); + return buildHeaderValue({ + username, + info, + uri: request.requestTarget, + response, + cnonce, + nc, + }); + }, + }; +} diff --git a/packages/core/src/auth/errors.test.ts b/packages/core/src/auth/errors.test.ts new file mode 100644 index 0000000..3de4668 --- /dev/null +++ b/packages/core/src/auth/errors.test.ts @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/errors.test.ts +// Exercises: AUTH-6 (the resolution error names required and available schemes, and copies both +// lists onto its own frozen fields), AUTH-28 (the plaintext guard names the step and scheme), +// AUTH-35 (the resolution error's message-only construction path). +import {describe, expect, test} from 'bun:test'; +import {DexpaceError} from '../http/errors.js'; +import {AuthResolutionError, PlaintextCredentialError} from './errors.js'; + +describe('AuthResolutionError', () => { + test('a plain message constructs directly', () => { + const error = new AuthResolutionError( + 'token provider returned an expired token', + ); + expect(error.name).toBe('AuthResolutionError'); + expect(error.message).toContain('expired'); + }); + + test('the message-only path carries no scheme lists (AUTH-35)', () => { + const error = new AuthResolutionError('token provider returned null'); + expect(error.requiredSchemes).toBeUndefined(); + expect(error.availableSchemes).toBeUndefined(); + }); + + test('unsatisfiable() names both the required and available schemes', () => { + const error = AuthResolutionError.unsatisfiable( + ['BASIC', 'DIGEST'], + ['API_KEY'], + ); + expect(error.message).toContain('BASIC'); + expect(error.message).toContain('DIGEST'); + expect(error.message).toContain('API_KEY'); + }); + + test('unsatisfiable() also carries them as indexable fields, not only as prose (AUTH-6)', () => { + const error = AuthResolutionError.unsatisfiable( + ['BASIC', 'DIGEST'], + ['API_KEY'], + ); + expect(error.requiredSchemes).toEqual(['BASIC', 'DIGEST']); // preference order preserved + expect(error.availableSchemes).toEqual(['API_KEY']); + }); + + test('unsatisfiable() copies the caller arrays rather than aliasing them', () => { + const required = ['BASIC']; + const error = AuthResolutionError.unsatisfiable(required, []); + required.push('DIGEST'); + expect(error.requiredSchemes).toEqual(['BASIC']); + }); + + test('descends from DexpaceError, so a caller can catch the whole taxonomy', () => { + expect(new AuthResolutionError('x')).toBeInstanceOf(DexpaceError); + }); +}); + +describe('AuthResolutionError copies its scheme lists (AUTH-6)', () => { + test('the constructor copies, so a caller mutating its array cannot reach the error', () => { + const required = ['BASIC']; + const available = ['DIGEST']; + const error = new AuthResolutionError('nope', required, available); + required.push('OAUTH2'); + available.push('API_KEY'); + expect(error.requiredSchemes).toEqual(['BASIC']); + expect(error.availableSchemes).toEqual(['DIGEST']); + }); + + test('unsatisfiable() delegates to that one copy site', () => { + const required = ['BASIC']; + const error = AuthResolutionError.unsatisfiable(required, []); + required.push('DIGEST'); + expect(error.requiredSchemes).toEqual(['BASIC']); + }); +}); + +describe('PlaintextCredentialError', () => { + test('names the step and the resolved scheme', () => { + const error = new PlaintextCredentialError('authStep', 'BASIC'); + expect(error.message).toContain('authStep'); + expect(error.message).toContain('BASIC'); + }); + + test('carries them as fields too (error-handling.md)', () => { + const error = new PlaintextCredentialError('authStep', 'BASIC'); + expect(error.stepName).toBe('authStep'); + expect(error.scheme).toBe('BASIC'); + expect(error.name).toBe('PlaintextCredentialError'); + }); +}); diff --git a/packages/core/src/auth/errors.ts b/packages/core/src/auth/errors.ts new file mode 100644 index 0000000..174e36d --- /dev/null +++ b/packages/core/src/auth/errors.ts @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/errors.ts +import {DexpaceError} from '../http/errors.js'; + +/** + * AUTH-6 (the selected tier lists no satisfiable scheme) and AUTH-35 (a `TokenProvider` returned + * null or an already-expired token). + * + * AUTH-6, not AUTH-4: AUTH-4 governs only WHICH tier is selected — most-specific-present, with no + * fall-through — and it is AUTH-6 that requires "a distinct auth-resolution error (carrying both the + * required schemes in preference order and the available schemes)" when that tier turns out to be + * unsatisfiable. + * + * The scheme lists are `readonly` FIELDS, not only interpolated prose. AUTH-6 requires the error to + * carry both the required schemes in preference order and the available schemes, and + * `docs/knowledge/error-handling.md` requires identifying inputs to be `readonly` fields "so they + * survive serialization and appear in structured logs". Both are `undefined` on the AUTH-35 + * construction path, which has no scheme lists to carry. + * + * Typed `readonly string[]` rather than `readonly AuthScheme[]`: this module is the taxonomy leaf + * every other auth module depends on, and `scheme.ts` has no reason to depend back on it. A union of + * string literals is assignable to `string`, so callers pass `AuthScheme[]` values unchanged. + * + * @public + */ +export class AuthResolutionError extends DexpaceError { + /** The selected tier's schemes, in declared preference order. Absent on the AUTH-35 path. */ + readonly requiredSchemes: readonly string[] | undefined; + /** The schemes a credential was actually configured for. Absent on the AUTH-35 path. */ + readonly availableSchemes: readonly string[] | undefined; + + /** + * Both lists are COPIED, not aliased. They are typed `readonly` and this class is public surface, + * so a caller-owned array stored by reference would leave a `readonly` field whose contents change + * after the error was constructed. `unsatisfiable()` below delegates here rather than copying a + * second time, so there is exactly one copy site. + * + * @param message - the human-readable failure description. + * @param requiredSchemes - the selected tier's schemes, in preference order. + * @param availableSchemes - the schemes a credential was configured for. + */ + constructor( + message: string, + requiredSchemes?: readonly string[], + availableSchemes?: readonly string[], + ) { + super(message); + this.requiredSchemes = + requiredSchemes === undefined + ? undefined + : Object.freeze([...requiredSchemes]); + this.availableSchemes = + availableSchemes === undefined + ? undefined + : Object.freeze([...availableSchemes]); + } + + /** + * AUTH-6's unsatisfiable-descriptor case: the caller configured a tier, but none of its listed + * schemes has a matching credential. + * + * @param requiredSchemes - the selected tier's schemes, in preference order. + * @param availableSchemes - the schemes a credential was configured for. + * @returns the error, with both lists copied onto its own fields by the constructor. + */ + static unsatisfiable( + requiredSchemes: readonly string[], + availableSchemes: readonly string[], + ): AuthResolutionError { + return new AuthResolutionError( + `no requirement is satisfiable; required one of [${requiredSchemes.join(', ')}], available: [${availableSchemes.join(', ')}]`, + requiredSchemes, + availableSchemes, + ); + } +} + +/** + * AUTH-28: a credential would have been attached to a non-HTTPS URL. + * + * The offending URL is deliberately NOT carried — a URL can hold userinfo and query-string secrets, + * and `docs/knowledge/error-handling.md` bars interpolating secrets into a message that travels into + * logs. The step name and scheme identify the fault without that risk. + * + * @public + */ +export class PlaintextCredentialError extends DexpaceError { + /** The concrete step that refused, as AUTH-28 requires the error to name. */ + readonly stepName: string; + /** The resolved auth scheme whose credential would have been stamped. */ + readonly scheme: string; + + /** + * @param stepName - the concrete step that refused. + * @param scheme - the resolved auth scheme. + */ + constructor(stepName: string, scheme: string) { + super( + `${stepName} refuses to send a ${scheme} credential over a non-HTTPS URL`, + ); + this.stepName = stepName; + this.scheme = scheme; + } +} diff --git a/packages/core/src/auth/md5.test.ts b/packages/core/src/auth/md5.test.ts new file mode 100644 index 0000000..8348a2e --- /dev/null +++ b/packages/core/src/auth/md5.test.ts @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/md5.test.ts +// Exercises: AUTH-15, AUTH-17 (MD5 correctness against RFC 1321's own test vectors, and the +// lower-case hex rendering the Digest response is built from). +import {describe, expect, test} from 'bun:test'; +import {md5, toHex} from './md5.js'; + +function md5Hex(input: string): string { + return toHex(md5(new TextEncoder().encode(input))); +} + +describe('md5 (RFC 1321 test vectors)', () => { + test('the empty string', () => { + expect(md5Hex('')).toBe('d41d8cd98f00b204e9800998ecf8427e'); + }); + + test('"a"', () => { + expect(md5Hex('a')).toBe('0cc175b9c0f1b6a831c399e269772661'); + }); + + test('"abc"', () => { + expect(md5Hex('abc')).toBe('900150983cd24fb0d6963f7d28e17f72'); + }); + + test('"message digest"', () => { + expect(md5Hex('message digest')).toBe('f96b697d7cb7938d525a2f31aaf161d0'); + }); + + test('the lowercase alphabet, exercising a multi-block input', () => { + expect(md5Hex('abcdefghijklmnopqrstuvwxyz')).toBe( + 'c3fcd3d76192e4007dfb496cca67e13b', + ); + }); + + test('the 62-character alphanumeric vector', () => { + expect( + md5Hex('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'), + ).toBe('d174ab98d277d9f5a5611c2c9f419d9f'); + }); + + test('the 80-digit vector, exercising a two-block input', () => { + expect(md5Hex('1234567890'.repeat(8))).toBe( + '57edf4a22be3c955ac49da2e2107b67a', + ); + }); + + test('a 55-byte input, the last length that pads into a single block', () => { + expect(md5Hex('a'.repeat(55))).toBe('ef1772b6dff9a122358552954ad0df65'); + }); + + test('a 56-byte input, the first length that forces a second block', () => { + expect(md5Hex('a'.repeat(56))).toBe('3b0c8ac703f828b04c6c197006d17218'); + }); + + test('a 64-byte input, exactly one block before padding', () => { + expect(md5Hex('a'.repeat(64))).toBe('014842d480b571495a4a0363793f7367'); + }); + + test('non-ASCII bytes hash by their UTF-8 encoding', () => { + expect(md5Hex('é')).toBe('66ddcd97cfdeabb2f6fb8a999b4bc76f'); + }); + + test('the digest is 16 bytes', () => { + expect(md5(new Uint8Array()).length).toBe(16); + }); + + test('is pure -- the same input hashes identically twice', () => { + const input = new TextEncoder().encode('repeat me'); + expect(toHex(md5(input))).toBe(toHex(md5(input))); + }); +}); + +describe('toHex', () => { + test('pads each byte to two lower-case hex digits', () => { + expect(toHex(new Uint8Array([0, 15, 255]))).toBe('000fff'); + }); + + test('renders the empty array as the empty string', () => { + expect(toHex(new Uint8Array())).toBe(''); + }); +}); diff --git a/packages/core/src/auth/md5.ts b/packages/core/src/auth/md5.ts new file mode 100644 index 0000000..445361b --- /dev/null +++ b/packages/core/src/auth/md5.ts @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/md5.ts + +/** + * RFC 1321 MD5, hand-rolled and dependency-free. + * + * Web Crypto's `subtle.digest()` deliberately excludes MD5 — the algorithm is out of the standard on + * security grounds — yet RFC 7616 Digest still requires MD5/MD5-sess for interop with servers that + * have not adopted SHA-256 (AUTH-15). Adding an npm dependency for it would violate SEAM-1's + * zero-runtime-dependency rule, and reaching for `node:crypto` would cost the portability to + * browsers/Deno/Workers that `sdk-design-nodejs/06` picks Web Crypto to keep. + * + * @packageDocumentation + */ + +const SHIFTS = [ + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, + 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, + 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, + 21, +] as const; + +// `/*#__PURE__*/`, because this is a top-level CALL, and `docs/knowledge/performance.md` is explicit +// that modules must do no work at import time — a top-level call is a side effect the bundler must +// preserve, and that pins the module in the bundle. `@dexpace/core` declares `"sideEffects": false`; +// without the annotation a bundler cannot prove these 64 `Math.sin` calls are pure, so `md5.ts` and +// its table are retained by every consumer that transitively imports anything reaching them, +// including one that never touches Digest. +// Deeply immutable via the freeze, which is what earns the CONSTANT_CASE (naming-conventions.md). +const CONSTANTS: readonly number[] = /*#__PURE__*/ Object.freeze( + Array.from( + {length: 64}, + (_, i) => Math.floor(Math.abs(Math.sin(i + 1)) * 2 ** 32) >>> 0, + ), +); + +function leftRotate(value: number, bits: number): number { + return ((value << bits) | (value >>> (32 - bits))) >>> 0; +} + +/** RFC 1321 §3.1: pad to a multiple of 64 bytes with a single 0x80, zeros, then the original bit length. */ +function pad(message: Uint8Array): Uint8Array { + const bitLength = BigInt(message.length) * 8n; + const paddingLength = (((56 - ((message.length + 1) % 64)) % 64) + 64) % 64; + const result = new Uint8Array(message.length + 1 + paddingLength + 8); + result.set(message); + result[message.length] = 0x80; + // `result` is freshly allocated, so its byteOffset is 0 and the buffer view needs no offset. + new DataView(result.buffer).setBigUint64(result.length - 8, bitLength, true); + return result; +} + +/** The three state words RFC 1321's per-round auxiliary function reads. Bundled so `roundFunction` + * stays within `max-params`. */ +interface RoundWords { + readonly b: number; + readonly c: number; + readonly d: number; +} + +function roundFunction(i: number, words: RoundWords): number { + const {b, c, d} = words; + if (i < 16) return (b & c) | (~b & d); + if (i < 32) return (d & b) | (~d & c); + if (i < 48) return b ^ c ^ d; + return c ^ (b | ~d); +} + +function messageIndex(i: number): number { + if (i < 16) return i; + if (i < 32) return (5 * i + 1) % 16; + if (i < 48) return (3 * i + 5) % 16; + return (7 * i) % 16; +} + +interface State { + a: number; + b: number; + c: number; + d: number; +} + +function processBlock(words: readonly number[], state: State): State { + let {a, b, c, d} = state; + for (let i = 0; i < 64; i += 1) { + const f = + (roundFunction(i, {b, c, d}) + + a + + (CONSTANTS[i] ?? 0) + + (words[messageIndex(i)] ?? 0)) >>> + 0; + a = d; + d = c; + c = b; + b = (b + leftRotate(f, SHIFTS[i] ?? 0)) >>> 0; + } + return { + a: (state.a + a) >>> 0, + b: (state.b + b) >>> 0, + c: (state.c + c) >>> 0, + d: (state.d + d) >>> 0, + }; +} + +/** + * Computes the RFC 1321 MD5 digest of `message` (AUTH-15–AUTH-17). + * + * Pure: no shared state, no allocation the caller can observe, safe for concurrent invocation + * (AUTH-24). + * + * @param message - the bytes to hash. + * @returns the 16-byte digest. + * + * @internal + */ +export function md5(message: Uint8Array): Uint8Array { + const data = pad(message); + // `data` comes straight from `pad`, so it is a fresh, offset-0 view over its own buffer. + const view = new DataView(data.buffer); + let state: State = { + a: 0x67452301, + b: 0xefcdab89, + c: 0x98badcfe, + d: 0x10325476, + }; + + for (let chunkStart = 0; chunkStart < data.length; chunkStart += 64) { + const words = Array.from({length: 16}, (_, i) => + view.getUint32(chunkStart + i * 4, true), + ); + state = processBlock(words, state); + } + + const digest = new Uint8Array(16); + const outView = new DataView(digest.buffer); + outView.setUint32(0, state.a, true); + outView.setUint32(4, state.b, true); + outView.setUint32(8, state.c, true); + outView.setUint32(12, state.d, true); + return digest; +} + +/** + * Renders bytes as lower-case hex, two digits per byte — the form AUTH-17 requires for HA1/HA2 and + * the Digest response. + * + * @param bytes - the bytes to render. + * @returns the lower-case hex string, twice as long as `bytes`. + * + * @internal + */ +export function toHex(bytes: Uint8Array): string { + return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join(''); +} diff --git a/packages/core/src/auth/preset.test.ts b/packages/core/src/auth/preset.test.ts new file mode 100644 index 0000000..dcc64fa --- /dev/null +++ b/packages/core/src/auth/preset.test.ts @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/preset.test.ts +// Exercises: PIPE-24 ("installs into empty pillar slots only" -- true by construction, since the preset +// always starts from a fresh PipelineBuilder), PIPE-39 (installs exactly the pillars that exist), and +// jointly with 5b: PIPE-2's "auth executes per redirect hop, not once for the whole call" plus +// AUTH-29's marker-CONSUMPTION side (5b produced the marker and routed consumption here). +import {describe, expect, test} from 'bun:test'; +import {Request} from '../http/request.js'; +import type {Response} from '../http/response.js'; +import {PipelineBuilder} from '../pipeline/builder.js'; +import type {StepDescriptor} from '../pipeline/step.js'; +import {CROSS_ORIGIN_MARKER_HEADER} from '../redirect/cross-origin.js'; +import {REDIRECT_STEP_TYPE} from '../redirect/redirect-step.js'; +import {withRedirect} from '../redirect/strip-marker-step.js'; +import {RETRY_STEP_TYPE} from '../retry/retry-step.js'; +import {FakeTransport, countingResponse} from '../testing/fake-transport.js'; +import {AUTH_STEP_TYPE} from './auth-step.js'; +import {createBearerToken} from './credential.js'; +import {createAuthDescriptor} from './descriptor.js'; +import {standardResilience, type StandardResilienceOptions} from './preset.js'; +import {createAuthRequirement} from './requirement.js'; + +function aRequest(url = 'https://example.com/start'): Request { + return Request.newBuilder().url(url).build(); +} + +// `FakeTransport` does not itself set a Location -- 5b's `decide()` reads it off `Response.headers`, so a +// scripted 3xx entry must carry one explicitly. `setInbound`, not `set`: Location is an inbound header. +function withLocation(response: Response, location: string): Response { + return response + .newBuilder() + .headers( + response.headers.newBuilder().setInbound('Location', location).build(), + ) + .build(); +} + +function bearerOptions(): StandardResilienceOptions { + return { + auth: { + credentials: { + bearer: { + provider: () => Promise.resolve(createBearerToken('tok', 60_000)), + }, + }, + tiers: {client: createAuthDescriptor([createAuthRequirement('OAUTH2')])}, + clock: {now: () => 0}, + }, + }; +} + +describe('standardResilience', () => { + test('installs exactly the three pillars that exist, plus 5b’s marker guard (PIPE-24/PIPE-39)', () => { + const runtime = standardResilience( + new FakeTransport([countingResponse(200).response]), + bearerOptions(), + ); + const types = runtime.steps.map(step => step.type); + + expect(types).toContain(REDIRECT_STEP_TYPE); + expect(types).toContain(RETRY_STEP_TYPE); + expect(types).toContain(AUTH_STEP_TYPE); + // redirectStep + its POST_AUTH marker guard + retryStep + authStep. LOGGING and SERDE stay empty: + // LOGGING's real step ships in Phase 7b, which amends this preset in its own plan. + expect(types).toHaveLength(4); + }); + + test('the pillars flatten in redirect-then-retry-then-auth order (AUTH-27/PIPE-2)', () => { + const runtime = standardResilience( + new FakeTransport([countingResponse(200).response]), + bearerOptions(), + ); + const order = runtime.steps.map(step => step.stage); + + expect(order.indexOf('REDIRECT')).toBeLessThan(order.indexOf('RETRY')); + expect(order.indexOf('RETRY')).toBeLessThan(order.indexOf('AUTH')); + }); + + test('NO_AUTH is the default when no auth option is supplied', async () => { + const transport = new FakeTransport([countingResponse(200).response]); + const runtime = standardResilience(transport); + + await runtime.send(aRequest('https://example.com')); + + expect( + transport.calls[0]?.request.headers.get('Authorization'), + ).toBeUndefined(); + }); + + test('the default NO_AUTH step does not trip the HTTPS guard on a plain-HTTP call (AUTH-28)', async () => { + const transport = new FakeTransport([countingResponse(200).response]); + + await standardResilience(transport).send(aRequest('http://example.com')); + + expect(transport.sendCount).toBe(1); + }); +}); + +describe('standardResilience with redirects (PIPE-2 + AUTH-29, jointly with 5b)', () => { + test('joint conformance (PIPE-2 + AUTH-29): credential absent on the cross-origin hop, restamped on return to same-origin', async () => { + const toCrossOrigin = withLocation( + countingResponse(302).response, + 'https://evil.example/mid', + ); + const backToSeedOrigin = withLocation( + countingResponse(302).response, + 'https://example.com/final', + ); + const finalHop = countingResponse(200); + const transport = new FakeTransport([ + toCrossOrigin, + backToSeedOrigin, + finalHop.response, + ]); + + const runtime = standardResilience(transport, bearerOptions()); + const response = await runtime.send(aRequest()); + + expect(transport.calls).toHaveLength(3); + // Seed hop: same-origin, stamped. + expect(transport.calls[0]?.request.headers.get('Authorization')).toBe( + 'Bearer tok', + ); + // Cross-origin hop: suppressed (AUTH-29). + expect( + transport.calls[1]?.request.headers.get('Authorization'), + ).toBeUndefined(); + // Back to the seed origin: re-stamped, proving auth re-runs PER HOP (PIPE-2). + expect(transport.calls[2]?.request.headers.get('Authorization')).toBe( + 'Bearer tok', + ); + expect(response).toBe(finalHop.response); + }); +}); + +describe('the cross-origin marker is load-bearing on both sides (REDIR-11/AUTH-29)', () => { + test('the internal cross-origin marker never reaches the wire (REDIR-11/AUTH-29)', async () => { + const toCrossOrigin = withLocation( + countingResponse(302).response, + 'https://evil.example/mid', + ); + const transport = new FakeTransport([ + toCrossOrigin, + countingResponse(200).response, + ]); + + await standardResilience(transport, bearerOptions()).send(aRequest()); + + for (const call of transport.calls) { + expect(call.request.headers.has(CROSS_ORIGIN_MARKER_HEADER)).toBe(false); + } + }); + + test('neither the redirect guard nor the marker check alone is sufficient -- both are independently necessary', async () => { + // A minimal AUTH-stage step that IGNORES the cross-origin marker and always stamps -- standing in + // for "what would happen if 5c's marker check were removed". With THIS step installed instead of the + // real authStep(), the credential leaks onto the cross-origin hop, proving the marker suppresses + // something observable rather than headers merely happening to come out empty. + const leakyAuthStep: StepDescriptor = { + type: Symbol('leaky-auth'), + stage: 'AUTH', + fn: (request, ctx) => { + const stamped = request + .newBuilder() + .headers( + request.headers + .newBuilder() + .set('Authorization', 'Bearer leaked') + .build(), + ) + .build(); + return ctx.next(stamped); + }, + }; + const toCrossOrigin = withLocation( + countingResponse(302).response, + 'https://evil.example/mid', + ); + const transport = new FakeTransport([ + toCrossOrigin, + countingResponse(200).response, + ]); + + const runtime = withRedirect(new PipelineBuilder(transport)) + .append(leakyAuthStep) + .build(); + await runtime.send(aRequest()); + + // 5b's redirect step already strips Authorization unconditionally on every re-issue (REDIR-7), so + // this variant demonstrates the OTHER half: a leaky auth step re-attaches a credential redirect just + // stripped, proving 5b's stripping alone is not sufficient either. Both layers are load-bearing. + expect(transport.calls[1]?.request.headers.get('Authorization')).toBe( + 'Bearer leaked', + ); + }); +}); diff --git a/packages/core/src/auth/preset.ts b/packages/core/src/auth/preset.ts new file mode 100644 index 0000000..a844b71 --- /dev/null +++ b/packages/core/src/auth/preset.ts @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/preset.ts +import {PipelineBuilder} from '../pipeline/builder.js'; +import type {Runtime} from '../pipeline/runtime.js'; +import type {RedirectSettings} from '../redirect/settings.js'; +import {withRedirect} from '../redirect/strip-marker-step.js'; +import {retryStep, type RetryStepOptions} from '../retry/retry-step.js'; +import type {Transport} from '../seams/transport.js'; +import {authStep, type AuthStepSettings} from './auth-step.js'; +import {createAuthDescriptor} from './descriptor.js'; +import {createAuthRequirement} from './requirement.js'; + +/** + * Per-pillar overrides for {@link standardResilience}. Every slot is optional; an omitted one takes + * that pillar's own defaults. + * + * @public + */ +export interface StandardResilienceOptions { + /** Retry settings and injected seams; omitted yields 5a's spec defaults. */ + readonly retry?: RetryStepOptions | undefined; + /** Redirect policy overrides; omitted yields 5b's spec defaults. */ + readonly redirect?: Partial | undefined; + /** + * Auth configuration. Required if any credential tier is meant to apply; omitted installs a + * `NO_AUTH`-only step, which stamps nothing and never trips the HTTPS guard. + */ + readonly auth?: AuthStepSettings | undefined; +} + +// Built lazily rather than as a top-level `const NO_AUTH_SETTINGS = ...`: a module-scope factory call +// is import-time work a bundler must preserve (`docs/knowledge/performance.md`), and it would pin +// descriptor.ts/requirement.ts into every bundle that imports the preset. The allocation is per call, +// but the preset is constructed once per client, not per request. +function noAuthSettings(): AuthStepSettings { + return { + credentials: {}, + tiers: {client: createAuthDescriptor([createAuthRequirement('NO_AUTH')])}, + }; +} + +/** + * Assembles the standard resilience pipeline: redirect, then retry, then auth (PIPE-24, PIPE-39). + * + * The order is AUTH-27's "redirect wraps retry wraps auth", so the auth step re-resolves and + * re-stamps per redirect hop and per retry attempt (PIPE-2). Redirect is installed through 5b's + * `withRedirect()`, which seats the pillar step AND its `POST_AUTH` cross-origin-marker guard + * together, so the internal marker can never reach the wire. + * + * PIPE-24's "installs into empty pillar slots only" is true BY CONSTRUCTION: this function always + * starts from a fresh `PipelineBuilder`, so no slot can be occupied and no runtime check is needed. A + * caller wanting to layer this preset onto an already-customized builder reaches for + * {@link PipelineBuilder.seedFrom} (`'nest'` or `'flatten'`) rather than this function growing a + * "skip occupied slots" branch — the two features compose. + * + * `LOGGING` and `SERDE` stay empty. `LOGGING`'s real step ships in Phase 7b, which executes after this + * phase and amends this function with a fourth `append` in its own plan; `SERDE` remains reserved with + * no shipped behavior anywhere in this roadmap's current scope. That is a scope boundary, not a + * deviation — the reference's preset description includes instrumentation, and this preset grows to + * match once a real logging step exists. + * + * This function only assembles the pipeline. The failures below surface from the returned runtime's + * `send()`, and are documented here because this factory is where a caller chooses the auth + * configuration that determines whether they can occur at all. + * + * @param transport - the terminal transport. Never closed by the pipeline (PIPE-27). + * @param options - per-pillar overrides. + * @returns the built, immutable runtime. + * @throws PlaintextCredentialError — from the returned runtime's `send()` — when a credentialed scheme + * meets a non-HTTPS URL (AUTH-28). + * @throws AuthResolutionError — from the returned runtime's `send()` — when no configured credential + * satisfies the resolved auth tier (AUTH-6; AUTH-4 governs only WHICH tier is selected), or a token + * provider returns a null or already-expired token (AUTH-35). + * @throws HeaderValidationError — from the returned runtime's `send()` — when credential material + * will not fit in a header value (HTTP-18). + * @throws InvariantViolation — synchronously from this function — when any pillar's settings are + * invalid, including a non-finite bearer refresh margin or a non-header-safe Digest username. A + * caller-supplied `TokenProvider` or `challengeHook` error passes through `send()` unwrapped. + * + * @example + * ```ts + * const client = standardResilience(transport, { + * auth: { + * credentials: {apiKey: {credential: new ApiKeyCredential(process.env.API_KEY ?? '')}}, + * tiers: {client: createAuthDescriptor([createAuthRequirement('API_KEY')])}, + * }, + * }); + * const response = await client.send( + * Request.newBuilder().url('https://api.example.com/v1/things').build(), + * ); + * ``` + * + * @public + */ +export function standardResilience( + transport: Transport, + options: StandardResilienceOptions = {}, +): Runtime { + const builder = new PipelineBuilder(transport); + return withRedirect(builder, options.redirect) + .append(retryStep(options.retry)) + .append(authStep(options.auth ?? noAuthSettings())) + .build(); +} diff --git a/packages/core/src/auth/requirement.test.ts b/packages/core/src/auth/requirement.test.ts new file mode 100644 index 0000000..ddb2c26 --- /dev/null +++ b/packages/core/src/auth/requirement.test.ts @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/requirement.test.ts +// Exercises: AUTH-2 (frozen data shape, defensive copies of scopes/params, value equality). +import {describe, expect, test} from 'bun:test'; +import {authRequirementsEqual, createAuthRequirement} from './requirement.js'; + +describe('createAuthRequirement', () => { + test('defaults scopes to empty and params to an empty map', () => { + const requirement = createAuthRequirement('BASIC'); + expect(requirement.scopes).toEqual([]); + expect(requirement.params.size).toBe(0); + }); + + test('is frozen', () => { + expect(Object.isFrozen(createAuthRequirement('BASIC'))).toBe(true); + }); + + test('freezes the scopes array too', () => { + expect( + Object.isFrozen(createAuthRequirement('OAUTH2', ['read']).scopes), + ).toBe(true); + }); + + test('defensively copies the scopes array', () => { + const scopes = ['read']; + const requirement = createAuthRequirement('OAUTH2', scopes); + scopes.push('write'); + expect(requirement.scopes).toEqual(['read']); + }); + + test('defensively copies the params map', () => { + const params = new Map([['tenant', 'a']]); + const requirement = createAuthRequirement('OAUTH2', [], params); + params.set('tenant', 'b'); + expect(requirement.params.get('tenant')).toBe('a'); + }); +}); + +describe('authRequirementsEqual', () => { + test('true for identical scheme/scopes/params, regardless of construction order', () => { + const a = createAuthRequirement( + 'OAUTH2', + ['read', 'write'], + new Map([['tenant', 'x']]), + ); + const b = createAuthRequirement( + 'OAUTH2', + ['read', 'write'], + new Map([['tenant', 'x']]), + ); + expect(authRequirementsEqual(a, b)).toBe(true); + }); + + test('false for a differing scheme', () => { + expect( + authRequirementsEqual( + createAuthRequirement('BASIC'), + createAuthRequirement('DIGEST'), + ), + ).toBe(false); + }); + + test('false for differing scopes', () => { + const a = createAuthRequirement('OAUTH2', ['read']); + const b = createAuthRequirement('OAUTH2', ['write']); + expect(authRequirementsEqual(a, b)).toBe(false); + }); + + test('scope ORDER is part of the value, not a set comparison', () => { + const a = createAuthRequirement('OAUTH2', ['read', 'write']); + const b = createAuthRequirement('OAUTH2', ['write', 'read']); + expect(authRequirementsEqual(a, b)).toBe(false); + }); + + test('false for a differing scope count', () => { + const a = createAuthRequirement('OAUTH2', ['read']); + const b = createAuthRequirement('OAUTH2', ['read', 'write']); + expect(authRequirementsEqual(a, b)).toBe(false); + }); + + test('false for differing params', () => { + const a = createAuthRequirement('OAUTH2', [], new Map([['tenant', 'x']])); + const b = createAuthRequirement('OAUTH2', [], new Map([['tenant', 'y']])); + expect(authRequirementsEqual(a, b)).toBe(false); + }); + + test('false for a differing param count', () => { + const a = createAuthRequirement('OAUTH2', [], new Map([['tenant', 'x']])); + const b = createAuthRequirement( + 'OAUTH2', + [], + new Map([ + ['tenant', 'x'], + ['region', 'eu'], + ]), + ); + expect(authRequirementsEqual(a, b)).toBe(false); + }); +}); diff --git a/packages/core/src/auth/requirement.ts b/packages/core/src/auth/requirement.ts new file mode 100644 index 0000000..f69f2c1 --- /dev/null +++ b/packages/core/src/auth/requirement.ts @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/requirement.ts +import type {AuthScheme} from './scheme.js'; + +/** + * AUTH-2: one scheme bound to its own OAuth scopes and params. + * + * A frozen data shape plus a pure equality function — the same "data and functions, not objects" + * call 4a made for context types and 4c made for `Stage`, rather than a class with an `equals()` + * method. + * + * @public + */ +export interface AuthRequirement { + /** The bound scheme. */ + readonly scheme: AuthScheme; + /** Meaningful only for `OAUTH2`; preserved verbatim, never inspected by resolution (AUTH-2). */ + readonly scopes: readonly string[]; + /** Scheme-specific parameters, preserved verbatim and never inspected by resolution (AUTH-2). */ + readonly params: ReadonlyMap; +} + +/** + * Builds a frozen {@link AuthRequirement}, defensively copying both collections so a caller mutating + * its inputs afterwards cannot reach the stored value (AUTH-2). + * + * @param scheme - the scheme this requirement binds. + * @param scopes - OAuth scopes; meaningful only for `OAUTH2`. + * @param params - scheme-specific parameters. + * @returns the frozen requirement. + * + * @public + */ +export function createAuthRequirement( + scheme: AuthScheme, + scopes: readonly string[] = [], + params: ReadonlyMap = new Map(), +): AuthRequirement { + // `Object.freeze` is SHALLOW. `docs/knowledge/data-modeling.md` requires a frozen value object to + // hold only primitives or already-frozen/read-only values, never a mutable object that stays + // writable behind the freeze. `new Map(params)` satisfies AUTH-2's literal clause -- caller-side + // mutation cannot reach the stored value -- but leaves the copy itself writable behind the + // `ReadonlyMap` type. Rebuilding a frozen Map on every read is not worth it for a value this small + // and this rarely read; instead the copy is made once here, and nothing in this package ever + // re-casts `AuthRequirement['params']` back to `Map` (AUTH-2 also bars resolution from inspecting + // params at all), which is what keeps the `ReadonlyMap` type honest in practice. + return Object.freeze({ + scheme, + scopes: Object.freeze([...scopes]), + params: new Map(params), + }); +} + +function scopesEqual(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((scope, index) => scope === b[index]); +} + +function paramsEqual( + a: ReadonlyMap, + b: ReadonlyMap, +): boolean { + return ( + a.size === b.size && [...a].every(([key, value]) => b.get(key) === value) + ); +} + +/** + * AUTH-2's value-based equality: over scheme, scopes (ordered), and params. + * + * @param a - the left requirement. + * @param b - the right requirement. + * @returns `true` when all three components match. + * + * @public + */ +export function authRequirementsEqual( + a: AuthRequirement, + b: AuthRequirement, +): boolean { + return ( + a.scheme === b.scheme && + scopesEqual(a.scopes, b.scopes) && + paramsEqual(a.params, b.params) + ); +} diff --git a/packages/core/src/auth/resolve.test.ts b/packages/core/src/auth/resolve.test.ts new file mode 100644 index 0000000..8b87d37 --- /dev/null +++ b/packages/core/src/auth/resolve.test.ts @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/resolve.test.ts +// Exercises: AUTH-4 (perCall ?? operation ?? client, first PRESENT wins, no fallthrough on failure), +// AUTH-5 (first requirement whose scheme is NO_AUTH or in availableSchemes wins), AUTH-6 (all tiers +// absent is a programmer error), AUTH-7 (pure function, no hidden state). +import {describe, expect, test} from 'bun:test'; +import {InvariantViolation} from '../invariant.js'; +import {createAuthDescriptor} from './descriptor.js'; +import {AuthResolutionError} from './errors.js'; +import {createAuthRequirement} from './requirement.js'; +import {resolveAuthRequirement} from './resolve.js'; + +describe('tier selection (AUTH-4)', () => { + test('perCall wins when present, even if operation/client are also present', () => { + const requirement = resolveAuthRequirement( + { + perCall: createAuthDescriptor([createAuthRequirement('BASIC')]), + operation: createAuthDescriptor([createAuthRequirement('DIGEST')]), + client: createAuthDescriptor([createAuthRequirement('API_KEY')]), + }, + new Set(['BASIC', 'DIGEST', 'API_KEY']), + ); + expect(requirement.scheme).toBe('BASIC'); + }); + + test('operation wins over client when perCall is absent', () => { + const requirement = resolveAuthRequirement( + { + operation: createAuthDescriptor([createAuthRequirement('DIGEST')]), + client: createAuthDescriptor([createAuthRequirement('API_KEY')]), + }, + new Set(['DIGEST', 'API_KEY']), + ); + expect(requirement.scheme).toBe('DIGEST'); + }); + + test('client is used when it is the only tier present', () => { + const requirement = resolveAuthRequirement( + {client: createAuthDescriptor([createAuthRequirement('API_KEY')])}, + new Set(['API_KEY']), + ); + expect(requirement.scheme).toBe('API_KEY'); + }); + + test('a lower tier is NEVER consulted once a higher one is present, even if unsatisfiable', () => { + expect(() => + resolveAuthRequirement( + { + perCall: createAuthDescriptor([createAuthRequirement('DIGEST')]), + client: createAuthDescriptor([createAuthRequirement('BASIC')]), + }, + // would satisfy client's tier, but perCall is present and DIGEST is not available + new Set(['BASIC']), + ), + ).toThrow(AuthResolutionError); + }); + + test('an explicitly-undefined higher tier is treated as absent', () => { + const requirement = resolveAuthRequirement( + { + perCall: undefined, + client: createAuthDescriptor([createAuthRequirement('BASIC')]), + }, + new Set(['BASIC']), + ); + expect(requirement.scheme).toBe('BASIC'); + }); +}); + +describe('within-descriptor selection (AUTH-5)', () => { + test('the first requirement whose scheme is available wins, in preference order', () => { + const descriptor = createAuthDescriptor([ + createAuthRequirement('OAUTH2'), + createAuthRequirement('BASIC'), + ]); + const requirement = resolveAuthRequirement( + {client: descriptor}, + new Set(['BASIC']), + ); + expect(requirement.scheme).toBe('BASIC'); + }); + + test('NO_AUTH always wins regardless of availableSchemes', () => { + const descriptor = createAuthDescriptor([ + createAuthRequirement('NO_AUTH'), + createAuthRequirement('BASIC'), + ]); + const requirement = resolveAuthRequirement({client: descriptor}, new Set()); + expect(requirement.scheme).toBe('NO_AUTH'); + }); + + test('scopes and params are never inspected -- only the scheme decides', () => { + const descriptor = createAuthDescriptor([ + createAuthRequirement('OAUTH2', ['read'], new Map([['tenant', 'x']])), + ]); + const requirement = resolveAuthRequirement( + {client: descriptor}, + new Set(['OAUTH2']), + ); + expect(requirement.scopes).toEqual(['read']); + expect(requirement.params.get('tenant')).toBe('x'); + }); + + test('an unsatisfiable descriptor throws AuthResolutionError naming both schemes', () => { + const descriptor = createAuthDescriptor([createAuthRequirement('DIGEST')]); + try { + resolveAuthRequirement({client: descriptor}, new Set(['BASIC'])); + throw new Error('expected a throw'); + } catch (error) { + expect(error).toBeInstanceOf(AuthResolutionError); + expect((error as Error).message).toContain('DIGEST'); + expect((error as Error).message).toContain('BASIC'); + } + }); + + test('the thrown error carries required schemes in PREFERENCE order (AUTH-6)', () => { + const descriptor = createAuthDescriptor([ + createAuthRequirement('DIGEST'), + createAuthRequirement('OAUTH2'), + ]); + try { + resolveAuthRequirement({client: descriptor}, new Set(['BASIC'])); + throw new Error('expected a throw'); + } catch (error) { + expect((error as AuthResolutionError).requiredSchemes).toEqual([ + 'DIGEST', + 'OAUTH2', + ]); + expect((error as AuthResolutionError).availableSchemes).toEqual([ + 'BASIC', + ]); + } + }); +}); + +describe('AUTH-6: all tiers absent', () => { + test('is a programmer error, not AuthResolutionError', () => { + expect(() => resolveAuthRequirement({}, new Set())).toThrow( + InvariantViolation, + ); + expect(() => resolveAuthRequirement({}, new Set())).not.toThrow( + AuthResolutionError, + ); + }); +}); + +describe('AUTH-7: purity', () => { + test('the same inputs always resolve to an equal requirement', () => { + const descriptor = createAuthDescriptor([createAuthRequirement('BASIC')]); + const first = resolveAuthRequirement( + {client: descriptor}, + new Set(['BASIC']), + ); + const second = resolveAuthRequirement( + {client: descriptor}, + new Set(['BASIC']), + ); + // Same object identity: resolve() picks from the existing descriptor, building nothing new. + expect(first).toBe(second); + }); +}); diff --git a/packages/core/src/auth/resolve.ts b/packages/core/src/auth/resolve.ts new file mode 100644 index 0000000..4b7fdca --- /dev/null +++ b/packages/core/src/auth/resolve.ts @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/resolve.ts +import {invariant} from '../invariant.js'; +import type {AuthDescriptor} from './descriptor.js'; +import {AuthResolutionError} from './errors.js'; +import type {AuthRequirement} from './requirement.js'; +import type {AuthScheme} from './scheme.js'; + +/** + * AUTH-4's three configuration tiers, most specific first. Every slot is optional; at least one must + * be present at resolution time. + * + * @public + */ +export interface AuthTiers { + /** The per-call override, sourced from `RequestOptions.auth` by the AUTH pillar step. */ + readonly perCall?: AuthDescriptor | undefined; + /** The per-operation tier. No shipped source yet — see the roadmap's Deferred Items Log. */ + readonly operation?: AuthDescriptor | undefined; + /** The client-wide tier, fixed at step construction. */ + readonly client?: AuthDescriptor | undefined; +} + +/** + * Resolves the single {@link AuthRequirement} a call should satisfy (AUTH-4, AUTH-5, AUTH-7). + * + * Tier selection is `perCall ?? operation ?? client` — the first tier PRESENT, not the first that + * succeeds. If the selected tier lists no satisfiable scheme, {@link AuthResolutionError} is thrown + * naming that tier's schemes; a lower tier is never consulted, because the caller asked for the + * override explicitly (AUTH-4). + * + * Satisfiability is judged on scheme identity alone (AUTH-5): `NO_AUTH` always, otherwise membership + * in `availableSchemes`. No concrete credential value is ever inspected, which is why the caller + * derives `availableSchemes` from the credential types it configured rather than passing credentials + * in. + * + * Pure and stateless (AUTH-7): the returned requirement is the very object the descriptor already + * holds, not a copy. + * + * @param tiers - the three configuration tiers; at least one must be present. + * @param availableSchemes - the schemes a credential is actually configured for. + * @returns the first satisfiable requirement from the selected tier, in declared order. + * @throws AuthResolutionError when the selected tier lists no satisfiable scheme (AUTH-6). + * @throws InvariantViolation when every tier is absent — a caller misconfiguration, not an + * operational failure (AUTH-6, per the plan's Global Constraints). + * + * @public + */ +export function resolveAuthRequirement( + tiers: AuthTiers, + availableSchemes: ReadonlySet, +): AuthRequirement { + const descriptor = tiers.perCall ?? tiers.operation ?? tiers.client; + invariant( + descriptor !== undefined, + 'resolveAuthRequirement: at least one auth tier must be configured', + ); + + const match = descriptor.requirements.find( + requirement => + requirement.scheme === 'NO_AUTH' || + availableSchemes.has(requirement.scheme), + ); + if (match === undefined) { + const requiredSchemes = descriptor.requirements.map( + requirement => requirement.scheme, + ); + throw AuthResolutionError.unsatisfiable(requiredSchemes, [ + ...availableSchemes, + ]); + } + return match; +} diff --git a/packages/core/src/auth/scheme.ts b/packages/core/src/auth/scheme.ts new file mode 100644 index 0000000..618cb10 --- /dev/null +++ b/packages/core/src/auth/scheme.ts @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/scheme.ts + +/** + * AUTH-1: the recognized auth scheme set. `NO_AUTH` is a distinct sentinel meaning "may run + * anonymously / skip credential stamping", not a wire scheme. + * + * A string-literal union, not a TypeScript `enum` — `erasableSyntaxOnly` bars enums, and the scheme + * set has no behavior beyond identity and ordering. Same call 4c made for `Stage`. + * + * @public + */ +export type AuthScheme = 'OAUTH2' | 'API_KEY' | 'BASIC' | 'DIGEST' | 'NO_AUTH'; + +// There is deliberately no `AUTH_SCHEMES` array beside the union. One shipped briefly, documented +// "for enumeration", and nothing ever enumerated it: `availableSchemesOf` derives AUTH-5's set from +// which credentials are configured, and every other reader branches on the union exhaustively. Its +// only test asserted the array's five members against the union's five members, which is the +// constant restated rather than a behaviour, and would have passed against any five-element array. diff --git a/packages/core/src/auth/static-key.test.ts b/packages/core/src/auth/static-key.test.ts new file mode 100644 index 0000000..bb470d7 --- /dev/null +++ b/packages/core/src/auth/static-key.test.ts @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/static-key.test.ts +// Exercises: AUTH-26 (uniform over ApiKeyCredential/NameKeyCredential; default header Authorization; +// prefix + exactly one space when set; stateless -- no challenge involved). +import {describe, expect, test} from 'bun:test'; +import {ApiKeyCredential, NameKeyCredential} from './credential.js'; +import {stampStaticKey} from './static-key.js'; + +describe('stampStaticKey', () => { + test('defaults to the Authorization header, no prefix', () => { + const stamp = stampStaticKey(new ApiKeyCredential('secret')); + expect(stamp.headerName).toBe('Authorization'); + expect(stamp.headerValue).toBe('secret'); + }); + + test('applies a configured prefix with exactly one separating space', () => { + const stamp = stampStaticKey(new ApiKeyCredential('secret'), { + prefix: 'Bearer', + }); + expect(stamp.headerValue).toBe('Bearer secret'); + }); + + test('an empty prefix still contributes its separating space, rather than being ignored', () => { + // `undefined` means "no prefix"; `''` is a caller who explicitly configured one. Collapsing the + // two would make the option's absent state unreachable. + expect( + stampStaticKey(new ApiKeyCredential('secret'), {prefix: ''}).headerValue, + ).toBe(' secret'); + }); + + test('honors a configured header name', () => { + const stamp = stampStaticKey(new NameKeyCredential('x-api-key', 'secret'), { + headerName: 'X-Api-Key', + }); + expect(stamp.headerName).toBe('X-Api-Key'); + expect(stamp.headerValue).toBe('secret'); + }); + + test('treats NameKeyCredential uniformly with ApiKeyCredential -- only the secret is read, not .name', () => { + const stamp = stampStaticKey( + new NameKeyCredential('ignored-here', 'secret'), + ); + expect(stamp.headerName).toBe('Authorization'); + expect(stamp.headerValue).toBe('secret'); + }); + + test('is stateless -- the same credential stamps identically every call', () => { + const credential = new ApiKeyCredential('secret'); + expect(stampStaticKey(credential)).toEqual(stampStaticKey(credential)); + }); +}); diff --git a/packages/core/src/auth/static-key.ts b/packages/core/src/auth/static-key.ts new file mode 100644 index 0000000..7e8d480 --- /dev/null +++ b/packages/core/src/auth/static-key.ts @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/static-key.ts +import { + credentialKey, + type ApiKeyCredential, + type NameKeyCredential, +} from './credential.js'; + +/** + * Where and how a static key is written (AUTH-26). + * + * @internal + */ +export interface StaticKeyOptions { + /** The header to write. Defaults to `Authorization` (AUTH-26). */ + readonly headerName?: string | undefined; + /** A scheme prefix; when set it is written followed by exactly one space (AUTH-26). */ + readonly prefix?: string | undefined; +} + +/** + * The header name/value pair a static-key stamp produces. + * + * @internal + */ +export interface StaticKeyStamp { + /** The header to write. */ + readonly headerName: string; + /** The value to write, prefix already applied. */ + readonly headerValue: string; +} + +/** + * AUTH-26: writes the secret into the configured header, prefixed by the configured prefix and one + * space when set. + * + * Uniform over both credential shapes. `NameKeyCredential.name` is deliberately NOT consulted: it is + * non-secret metadata for the redacted `toString` in `credential.ts`, not a header name — a caller + * that wants the name to select the header passes it as `options.headerName`, explicitly. + * + * Stateless after construction, and no challenge is involved: a static key is stamped preemptively, + * never in reaction to a 401. + * + * @param credential - the API key or name-key credential to stamp. + * @param options - header name and prefix overrides. + * @returns the header name and value to write. + * + * @internal + */ +export function stampStaticKey( + credential: ApiKeyCredential | NameKeyCredential, + options?: StaticKeyOptions, +): StaticKeyStamp { + const headerName = options?.headerName ?? 'Authorization'; + // `credentialKey()`, not a public `credential.key` getter: AUTH-8's secret stays off the published + // surface and this module is the one sanctioned reader. + const key = credentialKey(credential); + const headerValue = + options?.prefix === undefined ? key : `${options.prefix} ${key}`; + return {headerName, headerValue}; +} diff --git a/packages/core/src/config/clock.ts b/packages/core/src/config/clock.ts index 5fd4e7b..f83290c 100644 --- a/packages/core/src/config/clock.ts +++ b/packages/core/src/config/clock.ts @@ -7,7 +7,7 @@ * (CFG-15/17 vs. 18) -- Node has no carrier threads to distinguish "block this one" from "schedule * that one" against, and every timer is already non-blocking. * - * @internal + * @public */ export interface Clock { /** Wall-clock epoch milliseconds. MAY move backwards; MUST NOT be used for elapsed-time math (CFG-16). */ diff --git a/packages/core/src/context/context.ts b/packages/core/src/context/context.ts index 99f6ce2..7a8a542 100644 --- a/packages/core/src/context/context.ts +++ b/packages/core/src/context/context.ts @@ -10,45 +10,59 @@ import { /** * Before any request (CTX-1). No `operationName` — CTX-16 introduces it at the request stage. * - * @internal + * @public */ export interface DispatchContext { + /** The discriminant. Branch on it to tell which promotion stage a step is observing. */ readonly kind: 'dispatch'; + /** This call's identity, unique per `Runtime.send()` and stable across every promotion (CTX-4/CTX-6). */ readonly key: symbol; + /** Correlation and tracing for this call, shared by reference across every promotion (CTX-2/CTX-3). */ readonly instrumentation: InstrumentationBundle; } /** * An outgoing request assembled (CTX-1). * - * @internal + * @public */ export interface RequestContext { + /** The discriminant. Branch on it to tell which promotion stage a step is observing. */ readonly kind: 'request'; + /** This call's identity, carried unchanged from the dispatch stage (CTX-4/CTX-6). */ readonly key: symbol; + /** Correlation and tracing for this call, carried by reference from the dispatch stage (CTX-2/CTX-3). */ readonly instrumentation: InstrumentationBundle; + /** The operation this call belongs to, or `undefined` when the caller named none (CTX-16). */ readonly operationName: string | undefined; + /** The assembled outbound request. Immutable; a step substitutes by passing a new one to `ctx.next`. */ readonly request: Request; } /** * A response arrived; terminal — no further promotion exists (CTX-1). * - * @internal + * @public */ export interface ExchangeContext { + /** The discriminant. Branch on it to tell which promotion stage a step is observing. */ readonly kind: 'exchange'; + /** This call's identity, carried unchanged from the dispatch stage (CTX-4/CTX-6). */ readonly key: symbol; + /** Correlation and tracing for this call, carried by reference from the dispatch stage (CTX-2/CTX-3). */ readonly instrumentation: InstrumentationBundle; + /** The operation this call belongs to, or `undefined` when the caller named none (CTX-16). */ readonly operationName: string | undefined; + /** The request that actually went on the wire — the substituted one, if a step replaced it (CTX-1). */ readonly request: Request; + /** The response that arrived. OPEN: whoever owns the drive owns closing its body. */ readonly response: Response; } /** * The three promotion-chain stages as one discriminated union, branched on `kind`. * - * @internal + * @public */ export type ExecutionContext = DispatchContext | RequestContext | ExchangeContext; diff --git a/packages/core/src/context/instrumentation.ts b/packages/core/src/context/instrumentation.ts index 8a14524..4bd4d15 100644 --- a/packages/core/src/context/instrumentation.ts +++ b/packages/core/src/context/instrumentation.ts @@ -2,21 +2,48 @@ // packages/core/src/context/instrumentation.ts /** - * Correlation/instrumentation bundle every execution context carries (CTX-14). `activeSpan` and - * `tracerFactory` are typed `unknown` rather than a Span/Tracer interface — nothing in this phase - * consumes either, and a real tracing adapter (deferred to Phase 7) owns their eventual shape. + * Correlation/instrumentation bundle every execution context carries (CTX-14), reachable from a + * custom step as `ctx.context.instrumentation`. * - * @internal + * **Two members are provisional.** `activeSpan` and `tracerFactory` are typed `unknown` rather than a + * Span/Tracer interface because nothing in the package consumes either yet, and the real tracing + * adapter — Phase 7a — owns their eventual shape. They are published as `unknown` deliberately, not + * accidentally: narrowing them later to a concrete Span/Tracer type is a widening of what a caller + * may pass and a narrowing of what they receive, so code that reads either today should treat it as + * opaque and re-check when 7a lands. Every other member below is stable. + * + * @public */ export interface InstrumentationBundle { + /** W3C trace-id, 32 lower-case hex characters. All-zero when tracing is disabled (CTX-15). */ readonly traceId: string; + /** W3C span-id, 16 lower-case hex characters. All-zero when tracing is disabled (CTX-15). */ readonly spanId: string; + /** W3C trace-flags byte; bit 0 is the sampled flag. `0` when tracing is disabled. */ readonly traceFlags: number; + /** W3C tracestate header value, verbatim. Empty when tracing is disabled. */ readonly traceState: string; + /** How `traceId`/`spanId` are encoded; `'none'` when tracing is disabled (CTX-15). */ readonly traceIdEncoding: string; + /** Whether this bundle carries a usable trace context. `false` for the disabled default (CTX-15). */ readonly isValid: boolean; + /** Whether the trace context was propagated in from a caller rather than started locally. */ readonly isRemote: boolean; + /** + * The span this call runs inside, or `undefined` when tracing is disabled. + * + * PROVISIONAL: typed `unknown` pending Phase 7a's tracing adapter — see this interface's own note. + */ readonly activeSpan: unknown; + /** + * Starts a child span for `operationName`. A no-op returning `undefined` when tracing is disabled. + * + * PROVISIONAL: the return type is `unknown` pending Phase 7a's tracing adapter — see this + * interface's own note. + * + * @param operationName - the operation to name the child span after. + * @returns the started span, or `undefined` when tracing is disabled. + */ readonly tracerFactory: (operationName: string) => unknown; } diff --git a/packages/core/src/http/request-options.test.ts b/packages/core/src/http/request-options.test.ts index 59ccd7a..33566c2 100644 --- a/packages/core/src/http/request-options.test.ts +++ b/packages/core/src/http/request-options.test.ts @@ -1,7 +1,10 @@ // SPDX-License-Identifier: MIT // packages/core/src/http/request-options.test.ts -// Exercises: HTTP-34 (EMPTY sentinel, defensive tag copy), HTTP-35 (timeout/maxRetries validation) +// Exercises: HTTP-34 (EMPTY sentinel, defensive tag copy), HTTP-35 (timeout/maxRetries validation), +// AUTH-4 (the per-call auth descriptor tier, added in Phase 5c) import {describe, expect, test} from 'bun:test'; +import {createAuthDescriptor} from '../auth/descriptor.js'; +import {createAuthRequirement} from '../auth/requirement.js'; import {RequestOptions} from './request-options.js'; import {RequestOptionsValidationError} from './errors.js'; @@ -13,6 +16,31 @@ describe('RequestOptions.EMPTY', () => { }); }); +describe('per-call auth descriptor (AUTH-4)', () => { + test('EMPTY carries no auth descriptor', () => { + expect(RequestOptions.EMPTY.auth).toBeUndefined(); + }); + + test('the builder stores and the accessor returns the same descriptor instance', () => { + const descriptor = createAuthDescriptor([createAuthRequirement('NO_AUTH')]); + expect(RequestOptions.newBuilder().auth(descriptor).build().auth).toBe( + descriptor, + ); + }); + + test('an explicit undefined clears the override', () => { + const descriptor = createAuthDescriptor([createAuthRequirement('NO_AUTH')]); + const builder = RequestOptions.newBuilder().auth(descriptor); + expect(builder.auth(undefined).build().auth).toBeUndefined(); + }); + + test('a derived builder carries the descriptor forward (HTTP-3)', () => { + const descriptor = createAuthDescriptor([createAuthRequirement('BASIC')]); + const original = RequestOptions.newBuilder().auth(descriptor).build(); + expect(original.newBuilder().build().auth).toBe(descriptor); + }); +}); + describe('timeout validation (HTTP-35)', () => { test('rejects zero or negative timeout', () => { expect(() => RequestOptions.newBuilder().timeoutMs(0)).toThrow( diff --git a/packages/core/src/http/request-options.ts b/packages/core/src/http/request-options.ts index 8670e5b..5ad89ab 100644 --- a/packages/core/src/http/request-options.ts +++ b/packages/core/src/http/request-options.ts @@ -1,20 +1,25 @@ // SPDX-License-Identifier: MIT // packages/core/src/http/request-options.ts +import type {AuthDescriptor} from '../auth/descriptor.js'; import type {Builder} from './builder.js'; import {RequestOptionsValidationError} from './errors.js'; +// eslint-disable-next-line max-params -- private, builder-internal plumbing; one parameter per HTTP-34 field let createRequestOptions: ( timeoutMs: number | undefined, maxRetries: number | undefined, tags: ReadonlyMap, + auth: AuthDescriptor | undefined, ) => RequestOptions; /** * Immutable per-call operational overrides that are deliberately *not* part of the wire form: a - * timeout, a max-retries count, and opaque string-keyed tags (HTTP-34). + * timeout, a max-retries count, opaque string-keyed tags (HTTP-34), and a per-call auth descriptor + * (AUTH-4). * - * Every field defaults to a "use the configured default" sentinel of `undefined`, and - * {@link RequestOptions.EMPTY} is the canonical override-nothing instance. + * Every scalar field defaults to a "use the configured default" sentinel of `undefined`; tags default + * to an empty map, which means the same thing. {@link RequestOptions.EMPTY} is the canonical + * override-nothing instance. * * `undefined` and `0` are different states for max-retries: `undefined` means "use the default", * while `0` means "disable retries for this call" (HTTP-35). @@ -25,28 +30,37 @@ export class RequestOptions { readonly #timeoutMs: number | undefined; readonly #maxRetries: number | undefined; readonly #tags: ReadonlyMap; + readonly #auth: AuthDescriptor | undefined; + // eslint-disable-next-line max-params -- private, builder-internal; one parameter per HTTP-34 field private constructor( timeoutMs: number | undefined, maxRetries: number | undefined, tags: ReadonlyMap, + auth: AuthDescriptor | undefined, ) { this.#timeoutMs = timeoutMs; this.#maxRetries = maxRetries; this.#tags = tags; + this.#auth = auth; Object.freeze(this); } static { - createRequestOptions = (timeoutMs, maxRetries, tags) => - new RequestOptions(timeoutMs, maxRetries, tags); + // eslint-disable-next-line max-params -- private, builder-internal plumbing; one parameter per HTTP-34 field + createRequestOptions = (timeoutMs, maxRetries, tags, auth) => + new RequestOptions(timeoutMs, maxRetries, tags, auth); } - /** The canonical "override nothing" instance: no timeout, no retry override, no tags. */ + /** + * The canonical "override nothing" instance: no timeout, no retry override, no tags, no per-call + * auth descriptor. + */ static readonly EMPTY = new RequestOptions( undefined, undefined, Object.freeze(new Map()), + undefined, ); /** @@ -68,7 +82,8 @@ export class RequestOptions { return new RequestOptionsBuilder() .timeoutMs(this.#timeoutMs) .maxRetries(this.#maxRetries) - .tags(this.#tags); + .tags(this.#tags) + .auth(this.#auth); } /** The per-call timeout in milliseconds, or `undefined` to use the configured default. */ @@ -93,6 +108,20 @@ export class RequestOptions { tag(key: string): string | undefined { return this.#tags.get(key); } + + /** + * The per-call auth descriptor, or `undefined` to use the configured tiers. + * + * Fills AUTH-4's most-specific `perCall` tier: when present it wins over any `perCall`, `operation`, + * or `client` descriptor the AUTH pillar step was constructed with, and a tier below it is never + * consulted even if this one turns out to be unsatisfiable. + * + * Returned by reference: an {@link AuthDescriptor} is frozen at construction, so there is nothing to + * copy defensively. + */ + get auth(): AuthDescriptor | undefined { + return this.#auth; + } } /** @@ -107,6 +136,7 @@ export class RequestOptionsBuilder implements Builder { #timeoutMs: number | undefined; #maxRetries: number | undefined; readonly #tags = new Map(); + #auth: AuthDescriptor | undefined; /** * Sets the per-call timeout. @@ -130,10 +160,6 @@ export class RequestOptionsBuilder implements Builder { /** * Sets the per-call retry ceiling. * - * @param value - the maximum retries, or `undefined` for no override. `0` is accepted and means - * "disable retries for this call"; anything that is not a non-negative integer is rejected rather - * than silently reinterpreted (HTTP-35). - * * The range check is deliberately wider than "not negative". A retry ceiling is a count of wire * sends, so `Infinity` and `NaN` are as out-of-range as `-1` -- and they are worse in effect: a * negative value at least fails a downstream lower-bound guard, while a non-finite one makes a @@ -141,6 +167,9 @@ export class RequestOptionsBuilder implements Builder { * HTTP-35's point is that an out-of-range retry count is a loud error at the call site that * supplied it, never a value reinterpreted somewhere downstream. * + * @param value - the maximum retries, or `undefined` for no override. `0` is accepted and means + * "disable retries for this call"; anything that is not a non-negative integer is rejected rather + * than silently reinterpreted (HTTP-35). * @returns this builder, for chaining. * @throws {@link RequestOptionsValidationError} when a defined value is negative, fractional, or * not finite. @@ -166,6 +195,20 @@ export class RequestOptionsBuilder implements Builder { return this; } + /** + * Sets the per-call auth descriptor, filling AUTH-4's `perCall` tier for this call only. + * + * No validation beyond the type: {@link createAuthDescriptor} already rejects an empty requirement + * list and freezes the result (AUTH-3), so any constructed descriptor is valid by construction. + * + * @param descriptor - the descriptor, or `undefined` for no override. + * @returns this builder, for chaining. + */ + auth(descriptor: AuthDescriptor | undefined): this { + this.#auth = descriptor; + return this; + } + /** * Copies and freezes the accumulated state into an immutable {@link RequestOptions}. * @@ -179,6 +222,7 @@ export class RequestOptionsBuilder implements Builder { this.#timeoutMs, this.#maxRetries, Object.freeze(new Map(this.#tags)), + this.#auth, ); } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a522929..8345c74 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,11 +1,18 @@ // SPDX-License-Identifier: MIT // packages/core/src/index.ts /** - * The immutable, transport-agnostic HTTP domain model at the heart of `@dexpace/core`. + * The transport-agnostic HTTP core of `@dexpace/core`: an immutable domain model, and the pipeline + * that drives it. * - * Every type here is frozen at construction and built through a builder or a static factory, so - * case-insensitivity, multi-value semantics, ordering, header-injection defenses, method/body - * legality, and total status handling are fixed once and behave identically under every transport. + * Every DOMAIN MODEL type — requests, responses, headers, bodies, status — is frozen at construction + * and reachable only through a builder or a static factory, so case-insensitivity, multi-value + * semantics, ordering, header-injection defenses, method/body legality, and total status handling are + * fixed once and behave identically under every transport. + * + * The PIPELINE surface promoted in Phase 5c is deliberately not held to that rule. `PipelineBuilder` + * is mutable by design and freezes only at `build()`; `Stage`, `Step`, `Next`, `StepContext`, and the + * settings records are plain types a caller writes literals for; `authStep`, `retryStep`, + * `redirectStep`, and `standardResilience` are factories returning descriptors and runtimes. * * The package has zero runtime dependencies. * @@ -59,3 +66,87 @@ export { } from './body/simple-bodies.js'; export {streamBody, type StreamBody} from './body/stream-body.js'; export {TypedResponse} from './body/typed-response.js'; + +// --------------------------------------------------------------------------------------------- +// The pillar-authoring surface, promoted in Phase 5c. +// +// 5c is the first point a caller can assemble a genuinely working pipeline -- all three resilience +// pillars plus the preset now exist. Promoting any earlier would have frozen shapes 5c still had +// latitude to reshape, which is why every prior phase deliberately exported nothing from here. +// --------------------------------------------------------------------------------------------- + +// Group 1: the authoring surface itself. +export type {Stage} from './pipeline/stage.js'; +export {PILLAR_STAGES, STAGE_ORDER} from './pipeline/stage.js'; +export type {Next, Step, StepContext, StepDescriptor} from './pipeline/step.js'; +export {PipelineBuilder} from './pipeline/builder.js'; +export {Runtime} from './pipeline/runtime.js'; +export {retryStep} from './retry/retry-step.js'; +export {redirectStep} from './redirect/redirect-step.js'; +export {authStep} from './auth/auth-step.js'; +export {standardResilience} from './auth/preset.js'; + +// Group 2: everything Group 1's signatures name. A promoted function whose parameter type is +// internal-only is an API a caller cannot call, and api-extractor reports each omission as +// `ae-forgotten-export`. +// +// The word "internal-only" above is deliberate and must not be spelled as the TSDoc tag: gts turns +// `stripInternal` on, and TypeScript tests the WHOLE leading comment range of a declaration for that +// tag as a substring -- so writing it in prose here silently deletes the export below from the +// emitted `.d.ts`. It did, for one commit. `api-extractor.json` now fails `api:ci` on the resulting +// `ae-forgotten-export`, and `verify:consumer-types` compiles these four names from the built +// package, so the same slip cannot ship twice. +// The whole context family, not just `ExecutionContext`: it is a union alias, and `StepContext.context` +// makes every member reachable from a promoted signature. A caller writing a custom step reads +// `ctx.context.kind` to tell which promotion stage it is in. +export type { + DispatchContext, + ExchangeContext, + ExecutionContext, + RequestContext, +} from './context/context.js'; +export type {InstrumentationBundle} from './context/instrumentation.js'; +export type {Clock} from './config/clock.js'; +export type {BackoffSettings} from './retry/backoff.js'; +export type {RetrySettings} from './retry/settings.js'; +export type {RetryStepOptions} from './retry/retry-step.js'; +export type { + RedirectCondition, + RedirectPredicate, + RedirectSettings, +} from './redirect/settings.js'; +export type {StandardResilienceOptions} from './auth/preset.js'; +export type { + ApiKeyCredentialConfig, + AuthCredentialSet, + AuthStepSettings, + BasicCredential, + BearerCredential, + ChallengeHook, + DigestCredential, +} from './auth/auth-step.js'; +export type {AuthTiers} from './auth/resolve.js'; +export type {AuthScheme} from './auth/scheme.js'; +export type {DigestAlgorithm} from './auth/digest.js'; + +// Factories, not bare interfaces: AUTH-3 validates and freezes inside `createAuthDescriptor`, and +// `ApiKeyCredential`/`NameKeyCredential`/`BearerToken` are NOMINAL -- they carry a `#` field, so no +// caller-side object literal is assignable and the AUTH-9 validation in each factory cannot be routed +// around. Without these, API_KEY and OAUTH2 auth are unreachable from outside the package. +// `BearerToken` is a VALUE export, not a type-only one: it is a class, and `TokenProvider` returns it. +export type {AuthDescriptor} from './auth/descriptor.js'; +export {createAuthDescriptor} from './auth/descriptor.js'; +export type {AuthRequirement} from './auth/requirement.js'; +export { + authRequirementsEqual, + createAuthRequirement, +} from './auth/requirement.js'; +export type {TokenProvider} from './auth/credential.js'; +export { + ApiKeyCredential, + BearerToken, + NameKeyCredential, + bearerTokensEqual, + createBearerToken, +} from './auth/credential.js'; +export {AuthResolutionError, PlaintextCredentialError} from './auth/errors.js'; diff --git a/packages/core/src/pipeline/builder.test.ts b/packages/core/src/pipeline/builder.test.ts index bea746c..1cdaa87 100644 --- a/packages/core/src/pipeline/builder.test.ts +++ b/packages/core/src/pipeline/builder.test.ts @@ -7,7 +7,8 @@ // (a missing anchor fails), PIPE-22 (an edit sequence flattens the same as constructing the final set from // scratch), PIPE-23 (a colliding reload leaves prior content untouched, and a same-type pillar repeat inside // one batch seats only one step), PIPE-25 (flatten order), PIPE-38 (appendAll preserves batch order; -// prependAll reverses it), PIPE-1/PIPE-2 (a built pipeline, driven: entry in STAGE_ORDER, exit reversed) +// prependAll reverses it), PIPE-1/PIPE-2 (a built pipeline, driven: entry in STAGE_ORDER, exit reversed), +// PIPE-35 (seedFrom's explicit, non-defaulted flatten-vs-nest modes) import {describe, expect, test} from 'bun:test'; import fc from 'fast-check'; import {Protocol} from '../http/protocol.js'; @@ -434,3 +435,113 @@ describe('PipelineBuilder batch-order properties (PIPE-38)', () => { ); }); }); + +// Module-scope, not describe-local: the seedFrom suite is split across sibling describes to stay +// inside `max-lines-per-function`, and both halves need these. +class RecordingTransport implements Transport { + readonly calls: Request[] = []; + + send(request: Request): Promise { + this.calls.push(request); + return Promise.resolve(aResponse(200)); + } + + close(): Promise { + return Promise.resolve(); + } +} + +function probeStep( + label: string, + stage: StepDescriptor['stage'], + order: string[], +): StepDescriptor { + return { + type: Symbol(label), + stage, + // A plain pass-through probe never re-drives, so `next()` suffices -- no fork needed. + fn: async (request, ctx) => { + order.push(label); + return ctx.next(request); + }, + }; +} + +describe('PipelineBuilder.seedFrom (PIPE-35)', () => { + test('flatten: seeded steps run in the SAME pass as newly appended ones, reusing the original transport', async () => { + const transport = new RecordingTransport(); + const order: string[] = []; + const seeded = new PipelineBuilder(transport) + .append(probeStep('seeded', 'LOGGING', order)) + .build(); + + const runtime = PipelineBuilder.seedFrom(seeded, 'flatten') + .append(probeStep('appended', 'SERDE', order)) + .build(); + + await runtime.send(aRequest('https://example.com')); + + expect(order).toEqual(['seeded', 'appended']); // one combined STAGE_ORDER pass + // The ORIGINAL transport is the terminal -- `seeded` itself is not in the chain. + expect(transport.calls).toHaveLength(1); + }); + + test('flatten: re-buckets each descriptor by its OWN stage, not by seeded array position', () => { + const transport = new RecordingTransport(); + const order: string[] = []; + const seeded = new PipelineBuilder(transport) + .append(probeStep('late', 'SERDE', order)) + .append(probeStep('early', 'PRE_REDIRECT', order)) + .build(); + + const runtime = PipelineBuilder.seedFrom(seeded, 'flatten').build(); + + expect(labelsOf(runtime)).toEqual(['early', 'late']); + }); + + test('flatten: pillar-collision rules apply exactly as any other append sequence', () => { + const transport = new RecordingTransport(); + const seeded = new PipelineBuilder(transport) + .append(descriptor('retry-a', 'RETRY')) + .build(); + + expect(() => + PipelineBuilder.seedFrom(seeded, 'flatten').append( + descriptor('retry-b', 'RETRY'), + ), + ).toThrow(PillarCollisionError); + }); +}); + +describe('PipelineBuilder.seedFrom nest mode (PIPE-35)', () => { + test('nest: the seeded runtime is an opaque Transport -- its steps run in a separate, inner pass', async () => { + const transport = new RecordingTransport(); + const order: string[] = []; + const seeded = new PipelineBuilder(transport) + .append(probeStep('inner', 'LOGGING', order)) + .build(); + + const runtime = PipelineBuilder.seedFrom(seeded, 'nest') + .append(probeStep('outer', 'LOGGING', order)) + .build(); + + await runtime.send(aRequest('https://example.com')); + + expect(order).toEqual(['outer', 'inner']); // the outer step runs BEFORE the nested runtime's + expect(transport.calls).toHaveLength(1); // still exactly one wire send at the bottom + }); + + test('nest: the same pillar may be occupied in BOTH layers -- they are separate builders', () => { + const transport = new RecordingTransport(); + const seeded = new PipelineBuilder(transport) + .append(descriptor('retry-inner', 'RETRY')) + .build(); + + const runtime = PipelineBuilder.seedFrom(seeded, 'nest') + .append(descriptor('retry-outer', 'RETRY')) + .build(); + + expect(labelsOf(runtime)).toEqual(['retry-outer']); + expect(runtime.transport).toBe(seeded); + }); +}); diff --git a/packages/core/src/pipeline/builder.ts b/packages/core/src/pipeline/builder.ts index 537d2ec..5216fd2 100644 --- a/packages/core/src/pipeline/builder.ts +++ b/packages/core/src/pipeline/builder.ts @@ -8,7 +8,7 @@ import { PillarCollisionError, ReservedStageError, } from './errors.js'; -import {Runtime} from './runtime.js'; +import {createRuntime, type Runtime} from './runtime.js'; import {PILLAR_STAGES, STAGE_ORDER, type Stage} from './stage.js'; import type {StepDescriptor} from './step.js'; @@ -21,7 +21,7 @@ interface AnchorLocation { * Assembles a stage-based pipeline via surgical edits (PIPE-7, PIPE-18..PIPE-24), flattening into an * immutable Runtime at build() time (PIPE-25). Mutable while being built; the produced Runtime is frozen. * - * @internal + * @public */ export class PipelineBuilder { readonly #buckets = new Map(); @@ -174,32 +174,74 @@ export class PipelineBuilder { reload(descriptors: readonly StepDescriptor[]): this { const admitted: StepDescriptor[] = []; const pillarTypes = new Map(); - for (const desc of descriptors) { - this.#rejectReservedStage(desc.stage, 'reload'); - if (!PILLAR_STAGES.has(desc.stage)) { - admitted.push(desc); + for (const descriptor of descriptors) { + this.#rejectReservedStage(descriptor.stage, 'reload'); + if (!PILLAR_STAGES.has(descriptor.stage)) { + admitted.push(descriptor); continue; } - const seenType = pillarTypes.get(desc.stage); - if (seenType === desc.type) continue; // PIPE-6: a repeat of the SAME type is idempotent, not a second step. + const seenType = pillarTypes.get(descriptor.stage); + // PIPE-6: a repeat of the SAME type is idempotent, not a second step. + if (seenType === descriptor.type) continue; if (seenType !== undefined) { - throw new PillarCollisionError(desc.stage, seenType, desc.type); // PIPE-5 + throw new PillarCollisionError( + descriptor.stage, + seenType, + descriptor.type, + ); // PIPE-5 } - pillarTypes.set(desc.stage, desc.type); - admitted.push(desc); + pillarTypes.set(descriptor.stage, descriptor.type); + admitted.push(descriptor); } // PIPE-4: `admitted` holds at most one entry per pillar stage by construction -- a same-type repeat was // skipped above rather than pushed, so a batch cannot install two steps onto one pillar the way the // incremental `append` path already refuses to. this.#buckets.clear(); - for (const desc of admitted) { - const bucket = this.#buckets.get(desc.stage); - if (bucket === undefined) this.#buckets.set(desc.stage, [desc]); - else bucket.push(desc); + for (const descriptor of admitted) { + const bucket = this.#buckets.get(descriptor.stage); + if (bucket === undefined) + this.#buckets.set(descriptor.stage, [descriptor]); + else bucket.push(descriptor); } return this; } + /** + * PIPE-35: derives a builder from an already-built `runtime`, under an explicit, non-defaulted + * `mode` — the requirement's own MUST is that the flatten-vs-nest choice be explicit, never + * accidental, so there is deliberately no default value. + * + * `flatten` re-buckets every seeded descriptor by its own stage and reuses `runtime`'s transport as + * the new builder's terminal, so seeded and newly-appended steps run in the SAME cursor pass. + * Pillar-collision rules apply exactly as they would to any other `append` sequence, because + * flatten IS an append sequence. + * + * Seeding re-seats the SAME descriptor objects, never copies: a `StepDescriptor` is a plain record + * around a closure, so any state that closure captured is now shared between `runtime` and the + * builder derived from it. `authStep`'s `BearerTokenCache` is the live example — a flattened + * builder shares one token cache, and therefore one single-flight slot, with the runtime it was + * seeded from. That is usually what a caller wants (AUTH-34's coalescing only works when concurrent + * calls meet at one instance), but it is sharing, not isolation; a caller who needs an independent + * cache constructs a fresh `authStep`. + * + * `nest` constructs a fresh builder whose transport IS `runtime`, treated as an opaque `Transport` + * — `Runtime implements Transport` (PIPE-26) makes this work with zero adapter code — so the new + * builder's own steps run once, outside `runtime`'s already-flattened loops. + * + * @param runtime - the built pipeline to seed from. + * @param mode - `'flatten'` to merge its steps into this builder's stages, `'nest'` to wrap it as + * this builder's transport. + * @returns a fresh builder seeded per `mode`. + * @throws PillarCollisionError in `'flatten'` mode when two seeded descriptors of different types + * claim one pillar stage (PIPE-5) — the same rule `append` enforces. + */ + static seedFrom(runtime: Runtime, mode: 'flatten' | 'nest'): PipelineBuilder { + if (mode === 'flatten') { + return new PipelineBuilder(runtime.transport).appendAll(runtime.steps); + } + return new PipelineBuilder(runtime); + } + /** PIPE-25: flattens stage buckets in declaration order, skipping SEND, into an immutable Runtime. */ build(): Runtime { const flattened: StepDescriptor[] = []; @@ -208,7 +250,7 @@ export class PipelineBuilder { const bucket = this.#buckets.get(stage); if (bucket !== undefined) flattened.push(...bucket); } - return new Runtime(flattened, this.#transport); // Runtime copies and freezes -- PIPE-10/PIPE-25. + return createRuntime(flattened, this.#transport); // Runtime copies and freezes -- PIPE-10/PIPE-25. } #rejectReservedStage(stage: Stage, operation: string): void { diff --git a/packages/core/src/pipeline/runtime.test.ts b/packages/core/src/pipeline/runtime.test.ts index 1b9a346..2d7cab7 100644 --- a/packages/core/src/pipeline/runtime.test.ts +++ b/packages/core/src/pipeline/runtime.test.ts @@ -22,7 +22,7 @@ import {Response} from '../http/response.js'; import {Status} from '../http/status.js'; import {invariant} from '../invariant.js'; import type {Transport} from '../seams/transport.js'; -import {exchangeSource, Runtime} from './runtime.js'; +import {createRuntime, exchangeSource} from './runtime.js'; import type {Step, StepDescriptor} from './step.js'; function aRequest(url: string): Request { @@ -73,7 +73,7 @@ describe('Runtime.send empty pipeline (PIPE-9)', () => { test('dispatches directly to the terminal transport, threading options and signal, no context installed', async () => { const canned = aResponse(200); const transport = new RecordingTransport(canned); - const runtime = new Runtime([], transport); + const runtime = createRuntime([], transport); const request = aRequest('https://example.com/a'); const signal = new AbortController().signal; const sizeBefore = contextStore.size; @@ -100,7 +100,7 @@ describe('Runtime.send context-store wiring (CTX-17, CTX-8)', () => { stage: 'PRE_LOGGING', fn: step, }; - const runtime = new Runtime( + const runtime = createRuntime( [descriptor], new RecordingTransport(aResponse(200)), ); @@ -129,7 +129,7 @@ describe('Runtime.send context-store wiring (CTX-17, CTX-8)', () => { stage: 'PRE_LOGGING', fn: step, }; - const runtime = new Runtime( + const runtime = createRuntime( [descriptor], new RecordingTransport(aResponse(200)), ); @@ -188,7 +188,7 @@ describe('Runtime.send request substitution reaches the wire (PIPE-14)', () => { }; const transport = new RecordingTransport(aResponse(200)); - await new Runtime([descriptor], transport).send(original); + await createRuntime([descriptor], transport).send(original); expect(transport.calls[0]?.request).toBe(substituted); }); @@ -206,7 +206,7 @@ describe('Runtime concurrency (PIPE-10, PIPE-11)', () => { stage: 'PRE_LOGGING', fn: rewrite, }; - const runtime = new Runtime([descriptor], transport); + const runtime = createRuntime([descriptor], transport); await Promise.all([ runtime.send(aRequest('https://example.com/a/')), @@ -229,7 +229,7 @@ describe('Runtime.steps (PIPE-25)', () => { stage: 'PRE_LOGGING', fn: async (_r, ctx) => ctx.next(), }; - const runtime = new Runtime( + const runtime = createRuntime( [descriptor], new RecordingTransport(aResponse(200)), ); @@ -238,7 +238,7 @@ describe('Runtime.steps (PIPE-25)', () => { }); test('the exposed view is frozen', () => { - const runtime = new Runtime([], new RecordingTransport(aResponse(200))); + const runtime = createRuntime([], new RecordingTransport(aResponse(200))); expect(Object.isFrozen(runtime.steps)).toBe(true); }); @@ -250,7 +250,10 @@ describe('Runtime.steps (PIPE-25)', () => { fn: async (_r, ctx) => ctx.next(), }; const source: StepDescriptor[] = [descriptor]; - const runtime = new Runtime(source, new RecordingTransport(aResponse(200))); + const runtime = createRuntime( + source, + new RecordingTransport(aResponse(200)), + ); source.push({...descriptor, type: Symbol('smuggled')}); @@ -271,11 +274,11 @@ describe('Runtime as a nested transport (PIPE-26)', () => { log.push(`exit:${label}`); return response; }; - const inner = new Runtime( + const inner = createRuntime( [{type: Symbol('inner'), stage: 'PRE_SERDE', fn: probe('inner')}], transport, ); - const outer = new Runtime( + const outer = createRuntime( [{type: Symbol('outer'), stage: 'PRE_REDIRECT', fn: probe('outer')}], inner, ); @@ -300,7 +303,7 @@ describe('Runtime as a nested transport (PIPE-26)', () => { describe('Runtime.close (PIPE-27)', () => { test('never calls the underlying transport close', async () => { const transport = new RecordingTransport(aResponse(200)); - const runtime = new Runtime([], transport); + const runtime = createRuntime([], transport); await runtime.close(); diff --git a/packages/core/src/pipeline/runtime.ts b/packages/core/src/pipeline/runtime.ts index 06a4e91..786e3c7 100644 --- a/packages/core/src/pipeline/runtime.ts +++ b/packages/core/src/pipeline/runtime.ts @@ -24,7 +24,7 @@ import type {StepDescriptor} from './step.js'; * never left the process, against CTX-1's "the exchange stage exposes the request and the response". Doing it * here rather than widening `promoteToExchange` with a request-override keeps promotion strictly additive. * - * Exported (still `@internal`, still absent from the package barrel) so its two branches can be asserted as + * Exported (still internal-only, still absent from the package barrel) so its two branches can be asserted as * the pure function they are. The alternative -- observing the exchange context end-to-end -- would require * patching `install` on the process-wide `contextStore` singleton, since `send()` evicts the entry in its own * `finally`. @@ -43,26 +43,46 @@ export function exchangeSource( }); } +/** + * TypeScript has no friend classes, so `PipelineBuilder` -- a different module -- reaches `Runtime`'s + * private constructor through this module-scoped `let`, assigned exactly once inside the class's + * `static {}` block. Init-once wiring, not mutable state, the same shape every builder-based model in + * `src/http/` uses (`createHeaders`, `createRequest`, ...). It is surfaced as {@link createRuntime} + * rather than kept module-local because the sanctioned construction site lives in another file. + */ +let create: (steps: readonly StepDescriptor[], transport: Transport) => Runtime; + /** * The built, immutable pipeline (PIPE-10, PIPE-25). Implements `Transport` itself (PIPE-26) -- Phase 2's * `Transport` SPI has one method (`send`), so there is no second `sendAsync` entry point to delegate through. * `close()` deliberately never touches the wrapped transport (PIPE-27): the pipeline never owns it. * - * @internal + * The constructor is TS-`private`, so no field-wise constructor appears in the emitted `.d.ts` and a + * consumer cannot assemble a `Runtime` around `PipelineBuilder.build()`'s validation. That matters + * now that this class is public surface: a hand-built `new Runtime([authStep(a), authStep(b)], t)` + * would put two steps in the single AUTH pillar slot (PIPE-4/PIPE-5, AUTH-27) and a hand-ordered step + * array would invert PIPE-2's pillar precedence chain, both without any collision error, because + * `Cursor` runs whatever array it is handed. `PipelineBuilder` is the only path that enforces either. + * + * @public */ export class Runtime implements Transport { readonly #steps: readonly StepDescriptor[]; readonly #transport: Transport; - constructor(steps: readonly StepDescriptor[], transport: Transport) { + private constructor(steps: readonly StepDescriptor[], transport: Transport) { // PIPE-10/PIPE-25: the built runtime is immutable, and `get steps()` hands out a read-only view. Copying - // and freezing here rather than trusting the caller makes both structural -- `PipelineBuilder` is not the - // only construction site (tests build one directly, and Phase 5+ may too), so an unfrozen array passed in - // would leave the "immutable after construction" guarantee resting on caller discipline. + // and freezing here rather than trusting the caller makes both structural -- `createRuntime` is reachable + // from any in-package caller, so an unfrozen array passed in would leave the "immutable after + // construction" guarantee resting on caller discipline. this.#steps = Object.freeze([...steps]); this.#transport = transport; } + static { + create = (steps, transport) => new Runtime(steps, transport); + } + /** * Drives `request` through the flattened step array and on to the wrapped transport, installing this * call's `ExecutionContext` in the store for the duration of the drive and evicting it again on both @@ -114,11 +134,62 @@ export class Runtime implements Transport { } } + /** + * A no-op, deliberately (PIPE-27). The pipeline never OWNS its terminal transport, so closing the + * runtime must not close the transport a caller handed it and may still be using elsewhere. The + * method exists only to satisfy the `Transport` SPI, so a `Runtime` can be nested as another + * pipeline's transport (PIPE-26) without the outer one leaking a close through. + * + * @returns a promise that is already resolved. + */ async close(): Promise { // PIPE-27: the pipeline never owns its transport and MUST NOT close it. } + /** + * The flattened step array, in the order the cursor drives it (PIPE-25). + * + * Frozen at construction, so the returned array is a read-only view and not a defensive copy — + * there is nothing a caller can mutate through it. + * + * @returns the ordered, immutable step array. + */ get steps(): readonly StepDescriptor[] { return this.#steps; // PIPE-25: "exposes a read-only, ordered view of its steps." } + + /** + * The wrapped terminal transport. + * + * Exposed for `PipelineBuilder.seedFrom(runtime, 'flatten')` (PIPE-35), which must reuse this + * runtime's own transport as the seeded builder's terminal — flatten mode is not implementable + * without it. Read-only: the pipeline never owns its transport (PIPE-27), so there is nothing to + * copy defensively and nothing a caller can change by holding the reference. + * + * @returns the transport this pipeline dispatches to innermost. + */ + get transport(): Transport { + return this.#transport; + } +} + +/** + * The in-package construction hook for {@link Runtime}, whose own constructor is `private` so no + * consumer can build one around `PipelineBuilder.build()`'s pillar and ordering validation. + * + * Exported (still internal-only, still absent from the package barrel) for the same reason + * {@link exchangeSource} is: the sanctioned caller -- `PipelineBuilder.build()` -- lives in a + * different module, and TypeScript has no friend-class visibility to express that with. + * + * @param steps - the flattened, stage-ordered step array. Copied and frozen. + * @param transport - the terminal transport. Never closed by the pipeline (PIPE-27). + * @returns the built, immutable runtime. + * + * @internal + */ +export function createRuntime( + steps: readonly StepDescriptor[], + transport: Transport, +): Runtime { + return create(steps, transport); } diff --git a/packages/core/src/pipeline/stage.ts b/packages/core/src/pipeline/stage.ts index 28f3272..3b1599c 100644 --- a/packages/core/src/pipeline/stage.ts +++ b/packages/core/src/pipeline/stage.ts @@ -8,7 +8,7 @@ * SHOULD extension slots around every pillar. `SEND` is terminal and reserved -- PIPE-8, flattening skips it * and `PipelineBuilder` rejects any attempt to install a step there. * - * @internal + * @public */ export type Stage = | 'PRE_REDIRECT' @@ -33,7 +33,7 @@ export type Stage = * further stage later is one splice here -- no existing `Stage` value needs to change, so there is no * numeric-gap "renumbering" concern to design around. * - * @internal + * @public */ export const STAGE_ORDER: readonly Stage[] = [ 'PRE_REDIRECT', @@ -54,7 +54,7 @@ export const STAGE_ORDER: readonly Stage[] = [ 'SEND', ]; -/** A pillar stage admits at most one step (PIPE-4). @internal */ +/** A pillar stage admits at most one step (PIPE-4). @public */ export const PILLAR_STAGES: ReadonlySet = new Set([ 'REDIRECT', 'RETRY', diff --git a/packages/core/src/pipeline/step.ts b/packages/core/src/pipeline/step.ts index e1fa453..f924572 100644 --- a/packages/core/src/pipeline/step.ts +++ b/packages/core/src/pipeline/step.ts @@ -18,7 +18,7 @@ import type {Stage} from './stage.js'; * @throws CursorAlreadyAdvancedError -- as a rejected promise -- when an already-invoked handle is * invoked a second time (PIPE-11/PIPE-15). * - * @internal + * @public */ export type Next = (request?: Request) => Promise; @@ -26,11 +26,29 @@ export type Next = (request?: Request) => Promise; * What a step receives on each invocation (PIPE-12). `fork` is present only when the invoking step occupies * a pillar stage (PIPE-15/16); an ordinary step's `ctx.fork` is `undefined`. * - * @internal + * @public */ export interface StepContext { + /** + * Advances the chain exactly once (PIPE-14/PIPE-15). The ordinary way a step delegates downstream; + * a step that never calls it short-circuits the rest of the pipeline. + */ readonly next: Next; + /** + * Mints a FRESH one-shot continuation, so a pillar step can drive the downstream chain more than + * once — retry's attempts, redirect's hops, auth's challenge replay (PIPE-15/PIPE-16). + * + * Present only when the invoking step occupies a pillar stage; `undefined` for an ordinary step. A + * step that forks more than once owns closing whatever response its own prior fork produced before + * forking again (PIPE-40). + */ readonly fork?: (() => Next) | undefined; + /** + * This call's execution context, at whichever promotion stage the drive has reached (CTX-1). + * Branch on `context.kind` to tell which: `'dispatch'` before a request exists, `'request'` once + * one is assembled, `'exchange'` once a response has arrived. Shared by reference across every + * fork, so it is the same object on every attempt and every hop. + */ readonly context: ExecutionContext; /** * The call's cancellation signal, threaded from the cursor (PIPE-13). Undefined when the caller @@ -54,7 +72,7 @@ export interface StepContext { * A step that forks more than once owns closing whatever response its own prior fork produced before * invoking `fork()` again (PIPE-40) -- that responsibility sits on the wrapping step, not on `Cursor`. * - * @internal + * @public */ export type Step = (request: Request, ctx: StepContext) => Promise; @@ -62,10 +80,17 @@ export type Step = (request: Request, ctx: StepContext) => Promise; * A registered step: its function plus the identity (`type`) PIPE-6's reference-identity pillar check and * PIPE-18/19's anchor-type matching both key off, and the `stage` it occupies. * - * @internal + * @public */ export interface StepDescriptor { + /** + * This step's identity. PIPE-6's pillar-occupancy check and PIPE-18/PIPE-19's anchor matching both + * compare it by REFERENCE, so a factory must mint one module-level symbol and reuse it across every + * descriptor it produces — never a fresh `Symbol()` per call. + */ readonly type: symbol; + /** The stage this step occupies. A pillar stage admits at most one step (PIPE-4/PIPE-5). */ readonly stage: Stage; + /** The step itself (PIPE-12). */ readonly fn: Step; } diff --git a/packages/core/src/redirect/redirect-step.ts b/packages/core/src/redirect/redirect-step.ts index 1674448..a53b9ee 100644 --- a/packages/core/src/redirect/redirect-step.ts +++ b/packages/core/src/redirect/redirect-step.ts @@ -87,7 +87,7 @@ async function decideOrClose( * @param overrides - redirect policy overrides; a zero-argument call yields the spec defaults. * @returns the descriptor to install in a pipeline's REDIRECT slot. * - * @internal + * @public */ export function redirectStep( overrides?: Partial, diff --git a/packages/core/src/redirect/settings.ts b/packages/core/src/redirect/settings.ts index 31b7065..2e217fc 100644 --- a/packages/core/src/redirect/settings.ts +++ b/packages/core/src/redirect/settings.ts @@ -12,11 +12,14 @@ import {DEFAULT_ALLOWED_METHODS} from './codes.js'; * * `visited` is insertion-ordered and includes the current request's URI. * - * @internal + * @public */ export interface RedirectCondition { + /** The 3xx response being judged. Open; the predicate MUST NOT consume or close its body. */ readonly response: Response; + /** How many hops this call has already followed, before the one under consideration. */ readonly redirectsFollowed: number; + /** Every URI seen on this call, insertion-ordered, including the current request's (REDIR-19). */ readonly visited: ReadonlySet; } @@ -25,18 +28,22 @@ export interface RedirectCondition { * wire-safety mechanics that follow it -- credential stripping, the downgrade guard, body replayability, * loop and hop-cap detection -- see `decide.ts`'s note on the scope of that override. * - * @internal + * @public */ export type RedirectPredicate = ( condition: Readonly, ) => boolean; /** - * Redirect policy. Every field is optional at the construction surface ({@link redirectSettings} takes a - * `Partial`), so a zero-config call yields the spec defaults and a caller can override one field without - * restating the rest. + * Redirect policy. Every field is optional at the construction surface -- the internal + * `redirectSettings()` factory takes a `Partial` -- so a zero-config call yields the spec defaults and a + * caller can override one field without restating the rest. `redirectStep()` and `standardResilience()` + * both accept that same `Partial`. * - * @internal + * The factory is named in plain prose rather than as a TSDoc link: it is internal and absent from the + * package barrel, so a link to it cannot resolve from a published declaration. + * + * @public */ export interface RedirectSettings { /** REDIR-17: a non-negative integer, default 3. `0` disables following, with no special branch anywhere downstream. */ diff --git a/packages/core/src/retry/backoff.ts b/packages/core/src/retry/backoff.ts index 1f01414..63f8700 100644 --- a/packages/core/src/retry/backoff.ts +++ b/packages/core/src/retry/backoff.ts @@ -6,11 +6,14 @@ import {invariant} from '../invariant.js'; * The pure-math half of the retry schedule (RETRY-9..RETRY-11, RETRY-43). Carried inside * `RetrySettings`, never constructed standalone by a caller. * - * @internal + * @public */ export interface BackoffSettings { + /** The first attempt's delay in milliseconds, before any multiplier or jitter (RETRY-9). */ readonly initialDelayMs: number; + /** The exponential growth factor applied per attempt: delay(n) = initialDelayMs * multiplier^n (RETRY-9). */ readonly multiplier: number; + /** The ceiling the exponential schedule saturates at, in milliseconds (RETRY-11). */ readonly maxDelayMs: number; /** Symmetric jitter fraction in [0,1]; 0 disables perturbation (RETRY-10). */ readonly jitter: number; diff --git a/packages/core/src/retry/retry-step.ts b/packages/core/src/retry/retry-step.ts index bfc7e1f..09b5534 100644 --- a/packages/core/src/retry/retry-step.ts +++ b/packages/core/src/retry/retry-step.ts @@ -16,12 +16,36 @@ export const RETRY_STEP_TYPE: unique symbol = Symbol('dexpace.retry'); * reach `RetryConfig`, and a no-argument `retryStep()` must stay the default-tuned pillar step * (RETRY-12). * - * @internal + * @public */ export interface RetryStepOptions { + /** + * Policy overrides. Any omitted field takes its spec default (RETRY-12). + * + * @defaultValue the spec defaults `retrySettings()` supplies + */ readonly settings?: Partial | undefined; + /** + * The wall-clock and sleep seam, injected so backoff is testable without real time. The same + * instance also satisfies `AuthStepSettings.clock`, so one `Clock` drives every pillar in a + * pipeline (CFG-15..CFG-18). + * + * @defaultValue a `Clock` over `Date.now`, `performance.now`, and a cancellable `setTimeout` + */ readonly clock?: Clock | undefined; + /** + * The randomness seam jitter draws from, in `[0, 1)`. Injected so a jittered schedule is + * assertable (RETRY-10). + * + * @defaultValue `Math.random` + */ readonly random?: (() => number) | undefined; + /** + * RETRY-39's caller override: returns the delay in milliseconds to use for `attempt`, or + * `undefined` to fall through to the configured schedule for that attempt. + * + * @defaultValue absent, so every attempt uses the configured schedule + */ readonly delayOverride?: ((attempt: number) => number | undefined) | undefined; } @@ -87,7 +111,7 @@ function configFrom( * @param options - settings overrides and the injected clock, randomness, and delay override. * @returns the descriptor to install in a pipeline's RETRY slot. * - * @internal + * @public */ export function retryStep(options: RetryStepOptions = {}): StepDescriptor { // Built ONCE per installed step, not per request: `retrySettings()` validates every field and diff --git a/packages/core/src/retry/settings.ts b/packages/core/src/retry/settings.ts index 3a69ca3..a16ee3f 100644 --- a/packages/core/src/retry/settings.ts +++ b/packages/core/src/retry/settings.ts @@ -12,7 +12,7 @@ import {RETRYABLE_STATUSES} from './classify.js'; * Immutable and stateless after construction, so one instance is safe for concurrent invocation * (RETRY-42/RECOV-28). * - * @internal + * @public */ export interface RetrySettings extends BackoffSettings { /** Total wire sends including the initial one; 1 disables retries (RETRY-14, RECOV-34). */ diff --git a/scripts/verify-consumer-types.mjs b/scripts/verify-consumer-types.mjs index a6f17ed..fdb6cb8 100644 --- a/scripts/verify-consumer-types.mjs +++ b/scripts/verify-consumer-types.mjs @@ -56,14 +56,73 @@ const workDir = mkdtempSync(join(tmpdir(), 'dexpace-consumer-types-')); // Exercises the surface most likely to reference a declaration the consumer's lib cannot resolve: // the resource-owning class, an async iterable/stream type, a generic, and a factory. +// +// It ALSO names every type the pillar-authoring surface promoted in Phase 5c, because a second defect +// got through every other gate too: an `@internal` token inside a prose comment above the barrel's +// context-family export made `stripInternal` delete that export from the emitted `.d.ts`. `typecheck` +// passed (the source says it is exported), `build` passed (tsc emitted happily), and `api:ci` passed +// because api-extractor recorded the resulting `ae-forgotten-export` as report TEXT. Nothing compiled +// the promoted names from outside the package, so nothing noticed. Naming them here is what makes +// that class of silent elision loud. const consumer = ` import { + ApiKeyCredential, + type ApiKeyCredentialConfig, + AuthResolutionError, + type AuthCredentialSet, + type AuthDescriptor, + type AuthRequirement, + type AuthScheme, + type AuthStepSettings, + type AuthTiers, + authRequirementsEqual, + authStep, + type BackoffSettings, + type BasicCredential, + type BearerCredential, + BearerToken, + bearerTokensEqual, type Body, byteArrayBody, + type ChallengeHook, + type Clock, + createAuthDescriptor, + createAuthRequirement, + createBearerToken, + type DigestAlgorithm, + type DigestCredential, + type DispatchContext, + type ExchangeContext, + type ExecutionContext, + type InstrumentationBundle, materialize, + NameKeyCredential, + type Next, + PILLAR_STAGES, + PipelineBuilder, + PlaintextCredentialError, + type RedirectCondition, + type RedirectPredicate, + type RedirectSettings, + redirectStep, + Request, + type RequestContext, Response, + type RetrySettings, + type RetryStepOptions, + retryStep, + Runtime, + type Stage, + STAGE_ORDER, + type StandardResilienceOptions, + standardResilience, Status, + type Step, + type StepContext, + type StepDescriptor, toHttpError, + type TokenProvider, + type Transport, TypedResponse, } from ${JSON.stringify(built)}; @@ -85,6 +144,110 @@ export function typed(wrapper: TypedResponse): Promise { export const bytes: Body = byteArrayBody(new Uint8Array([1]), 'application/octet-stream'); export const errorOf = toHttpError; export const ok: number = Status.of(200).code; + +// --- the pillar-authoring surface promoted in Phase 5c --- +export function kindOf(context: ExecutionContext): string { + return context.kind; +} +export function dispatchKey(context: DispatchContext): symbol { + return context.key; +} +export function requestOf(context: RequestContext): Request { + return context.request; +} +export function responseOf(context: ExchangeContext): Response { + return context.response; +} +export function traceOf(bundle: InstrumentationBundle): string { + return bundle.traceId; +} +export const customStep: Step = async (request, ctx: StepContext) => { + const advance: Next = ctx.fork?.() ?? ctx.next; + kindOf(ctx.context); + return advance(request); +}; +export const descriptor: StepDescriptor = { + type: Symbol('consumer.custom'), + stage: 'PRE_AUTH' satisfies Stage, + fn: customStep, +}; +export const stageCount: number = STAGE_ORDER.length + PILLAR_STAGES.size; + +export function assemble(transport: Transport): Runtime { + const provider: TokenProvider = async () => createBearerToken('t', Date.now() + 60_000); + const settings: AuthStepSettings = { + credentials: { + apiKey: {credential: new ApiKeyCredential('k'), prefix: 'ApiKey'}, + basic: {username: 'u', password: 'p'}, + digest: {username: 'u', password: 'p', algorithmPreference: ['SHA-256' satisfies DigestAlgorithm]}, + bearer: {provider, marginMs: 5_000}, + }, + tiers: { + client: createAuthDescriptor([ + createAuthRequirement('OAUTH2' satisfies AuthScheme, ['scope.read']), + createAuthRequirement('NO_AUTH'), + ]), + } satisfies AuthTiers, + challengeHook: (async () => undefined) satisfies ChallengeHook, + bearerMarginMs: 30_000, + clock: {now: () => Date.now()}, + }; + const options: StandardResilienceOptions = { + auth: settings, + retry: {settings: {maxAttempts: 3}} satisfies RetryStepOptions, + redirect: {maxHops: 2}, + }; + const hand = new PipelineBuilder(transport) + .append(redirectStep({maxHops: 2})) + .append(retryStep()) + .append(authStep(settings)) + .append(descriptor) + .build(); + const seeded = PipelineBuilder.seedFrom(hand, 'nest').build(); + return standardResilience(seeded, options); +} + +export function requirementEquality(a: AuthRequirement, b: AuthRequirement): boolean { + return authRequirementsEqual(a, b); +} +export function tokenEquality(a: BearerToken, b: BearerToken): boolean { + return bearerTokensEqual(a, b); +} +export function describeDescriptor(d: AuthDescriptor): boolean { + return d.allowsAnonymous; +} +export function nameKey(): NameKeyCredential { + return new NameKeyCredential('x-api-key', 'k'); +} +export function narrow(error: unknown): string | undefined { + if (error instanceof PlaintextCredentialError) return error.scheme; + if (error instanceof AuthResolutionError) return error.requiredSchemes?.[0]; + return undefined; +} +export function credentialSet(set: AuthCredentialSet): BasicCredential | undefined { + return set.basic; +} +export function bearerCredential(c: BearerCredential): TokenProvider { + return c.provider; +} +export function digestCredential(c: DigestCredential): string { + return c.username; +} +export function apiKeyConfig(c: ApiKeyCredentialConfig): string | undefined { + return c.headerName; +} +export function redirectPolicy(s: RedirectSettings, p: RedirectPredicate): boolean { + return p({response: undefined as unknown as Response, redirectsFollowed: s.maxHops, visited: new Set()}); +} +export function retryPolicy(s: RetrySettings, b: BackoffSettings): number { + return s.maxAttempts + b.initialDelayMs; +} +export function clockNow(c: Clock): number { + return c.now(); +} +export function conditionOf(c: RedirectCondition): number { + return c.redirectsFollowed; +} `; const tsconfig = { @@ -117,10 +280,12 @@ try { } catch (error) { const detail = `${error.stdout ?? ''}${error.stderr ?? ''}`.trim(); console.error( - 'consumer-types check FAILED: the published .d.ts does not compile against this workspace\n' + + "consumer-types check FAILED: the published .d.ts does not compile against this workspace's\n" + `own declared lib (${lib.join(', ')}) with types: [].\n\n${detail}\n\n` + - 'A declaration is reaching for a global that only a devDependency supplies. Either drop it, or\n' + - 'add the lib entry to tsconfig.base.json and raise engines.node to a runtime that has it.', + 'Either a declaration is reaching for a global that only a devDependency supplies (drop it, or\n' + + 'add the lib entry to tsconfig.base.json and raise engines.node to a runtime that has it), or\n' + + 'the barrel claims an export the emitted .d.ts does not actually carry -- check for an\n' + + '`@internal` token inside a comment above the export, which `stripInternal` deletes it for.', ); process.exit(1); } finally { @@ -128,5 +293,6 @@ try { } console.log( - `consumer-types check passed: dist/*.d.ts compiles on lib [${lib.join(', ')}] with types: []`, + `consumer-types check passed: dist/*.d.ts compiles on lib [${lib.join(', ')}] with types: [],\n` + + 'including every symbol the pillar-authoring surface promotes.', ); diff --git a/test/node-conformance/auth.test.mjs b/test/node-conformance/auth.test.mjs new file mode 100644 index 0000000..58dd39e --- /dev/null +++ b/test/node-conformance/auth.test.mjs @@ -0,0 +1,339 @@ +// SPDX-License-Identifier: MIT +// test/node-conformance/auth.test.mjs +// +// Phase 5c reaches three runtime-provided globals that Bun implements independently of Node, and every +// one of them fails SILENTLY rather than loudly if the two disagree: +// +// 1. `globalThis.crypto.subtle.digest('SHA-256', ...)` -- the SHA-256/SHA-256-sess Digest algorithms +// (AUTH-15/AUTH-17). A wrong digest is still a well-formed hex string, so a divergence produces a +// header the server rejects rather than an exception a test would catch. The RFC 7616 vectors here +// are the only thing that pins it. `bun test` covers the same vectors on Bun's implementation; this +// file covers Node's. +// 2. `globalThis.crypto.getRandomValues()` -- the >=128-bit client nonce (AUTH-20). Its absence from +// ESM on every Node 18 release is one of the two reasons `engines.node` reads `>=20.3`, so this is +// also the floor assertion for that global. +// 3. `globalThis.btoa` -- Basic stamping (AUTH-14). A Latin-1/UTF-8 mismatch on a non-ASCII password +// produces a valid-looking base64 blob that authenticates against nothing. +// +// A fourth surface is structural rather than platform-specific but is only observable through Web +// Streams: AUTH-30/AUTH-31/AUTH-32's response-lifecycle discipline is observed through +// `countingResponse()`'s `cancel()`/`pull()` hooks, and Node's timing there is an independent +// implementation of Bun's. +// +// A fifth was added at 5c's adversarial review: AUTH-34's single-flight fetch is shared, so it carries +// no caller signal and each caller instead races its own wait against its own `AbortSignal`. That rests +// on `AbortController`/`AbortSignal` listener add-and-remove semantics and on `Promise.race` settling +// order, both of which Bun implements independently of Node. A divergence here does not throw -- it +// either hangs a caller that aborted or rejects one that did not. +// +// The listener ACCOUNTING that shape depends on is asserted here rather than in `bearer-cache.test.ts` +// for two reasons: `node:events`' `getEventListeners` is the only portable way to count listeners on +// an `AbortSignal` without spying on `removeEventListener`, and no colocated unit test under +// `packages/core/src/` imports a `node:` builtin -- that is the portability posture `basic.ts` and +// `digest.ts` keep by reaching for Web Crypto and `btoa` instead. The Bun side asserts the leak's +// behavioural consequence instead. +// +// `auth/` is `@internal` apart from the barrel-promoted configuration surface, so the handler internals +// are reached by direct `dist/` file path, per this suite's import rule. +import assert from 'node:assert/strict'; +import {getEventListeners} from 'node:events'; +import {describe, it} from 'node:test'; +import { + Request, + authStep, + createAuthDescriptor, + createAuthRequirement, +} from '@dexpace/core'; +import {basicHandler} from '../../packages/core/dist/auth/basic.js'; +import {BearerTokenCache} from '../../packages/core/dist/auth/bearer-cache.js'; +import {createBearerToken} from '../../packages/core/dist/auth/credential.js'; +import { + computeDigestResponse, + digestHandler, +} from '../../packages/core/dist/auth/digest.js'; +import {md5, toHex} from '../../packages/core/dist/auth/md5.js'; +import {createRequestContext} from '../../packages/core/dist/context/context.js'; +import {Cursor} from '../../packages/core/dist/pipeline/cursor.js'; +import { + FakeTransport, + countingResponse, +} from '../../packages/core/dist/testing/fake-transport.js'; + +const REALM = 'testrealm@host.com'; +const NONCE = 'dcd98b7102dd2f0e8b11d0f600bfb0c093'; +const VECTOR = { + realm: REALM, + nonce: NONCE, + isUtf8: true, + method: 'GET', + uri: '/dir/index.html', + username: 'Mufasa', + password: 'Circle Of Life', + cnonce: '0a4f113b', + nc: '00000001', +}; + +function digestChallenge(params) { + return {scheme: 'digest', params: new Map(Object.entries(params))}; +} + +function aRequest(url = 'https://example.com/a') { + return Request.newBuilder().url(url).build(); +} + +function runThrough(descriptor, transport, request = aRequest()) { + return new Cursor({ + steps: [descriptor], + transport, + request, + context: createRequestContext(request), + }).advance(); +} + +function challengeResponse(status, headerName, headerValue) { + const base = countingResponse(status); + const response = base.response + .newBuilder() + .headers( + base.response.headers + .newBuilder() + .setInbound(headerName, headerValue) + .build(), + ) + .build(); + return {response, cancelCount: base.cancelCount}; +} + +describe('Web Crypto SHA-256 under Node (AUTH-15/AUTH-17)', () => { + it('computes the RFC 7616 SHA-256 response, qop=auth', async () => { + assert.equal( + await computeDigestResponse({ + ...VECTOR, + algorithm: 'SHA-256', + hasQopAuth: true, + }), + '5abdd07184ba512a22c53f41470e5eea7dcaa3a93a59b630c13dfe0a5dc6e38b', + ); + }); + + it('computes the RFC 7616 SHA-256-sess response, qop=auth', async () => { + assert.equal( + await computeDigestResponse({ + ...VECTOR, + algorithm: 'SHA-256-sess', + hasQopAuth: true, + }), + 'b8822e12417cb7750f4e2b8515f0dcf25b7dd26993e80bee1426201446a7f59b', + ); + }); + + it('computes the RFC 7616 MD5 response, qop=auth, through the hand-rolled digest', async () => { + assert.equal( + await computeDigestResponse({ + ...VECTOR, + algorithm: 'MD5', + hasQopAuth: true, + }), + '6629fae49393a05397450978507c4ef1', + ); + }); + + it('pins the hand-rolled MD5 primitive itself against the RFC 1321 "abc" vector', async () => { + assert.equal( + toHex(md5(new TextEncoder().encode('abc'))), + '900150983cd24fb0d6963f7d28e17f72', + ); + }); + + it('hashes UTF-8 and ISO-8859-1 inputs differently for a non-ASCII password (AUTH-21)', async () => { + const utf8 = await computeDigestResponse({ + ...VECTOR, + password: 'pässwörd', + algorithm: 'SHA-256', + hasQopAuth: true, + isUtf8: true, + }); + const latin1 = await computeDigestResponse({ + ...VECTOR, + password: 'pässwörd', + algorithm: 'SHA-256', + hasQopAuth: true, + isUtf8: false, + }); + assert.notEqual(utf8, latin1); + }); +}); + +describe('crypto.getRandomValues under Node (AUTH-20)', () => { + it('draws a fresh 128-bit client nonce per stamp', async () => { + const handler = digestHandler('u', 'p'); + const challenge = digestChallenge({ + realm: REALM, + nonce: NONCE, + qop: 'auth', + }); + const request = {method: 'GET', requestTarget: '/x'}; + + const first = /cnonce="([0-9a-f]+)"/u.exec( + await handler.stamp(challenge, request), + ); + const second = /cnonce="([0-9a-f]+)"/u.exec( + await handler.stamp(challenge, request), + ); + + assert.equal(first[1].length, 32); // 16 bytes as hex + assert.notEqual(first[1], second[1]); + }); +}); + +describe('globalThis.btoa under Node (AUTH-14)', () => { + it('base64-encodes the UTF-8 bytes of an ASCII credential', async () => { + const value = await basicHandler('Aladdin', 'open sesame').stamp({ + scheme: 'basic', + params: new Map(), + }); + assert.equal(value, 'Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=='); + }); + + it('base64-encodes the UTF-8 bytes -- not the Latin-1 code units -- of a non-ASCII credential', async () => { + const value = await basicHandler('üser', 'päss').stamp({ + scheme: 'basic', + params: new Map(), + }); + const utf8 = new TextEncoder().encode('üser:päss'); + assert.equal(value, `Basic ${btoa(String.fromCharCode(...utf8))}`); + // A naive `btoa('üser:päss')` would produce a different, shorter string on any runtime that + // accepted it at all -- this is the assertion that catches an encoder swap. + assert.notEqual( + value, + `Basic ${Buffer.from('üser:päss', 'latin1').toString('base64')}`, + ); + }); +}); + +describe('challenge response lifecycle over Node Web Streams (AUTH-30/AUTH-31/AUTH-32)', () => { + const tiers = { + client: createAuthDescriptor([createAuthRequirement('BASIC')]), + }; + const credentials = {basic: {username: 'u', password: 'p'}}; + + it('closes the original 401 before re-driving, and leaves the replacement response open', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const success = countingResponse(200); + const transport = new FakeTransport([ + challenged.response, + success.response, + ]); + + const response = await runThrough( + authStep({credentials, tiers}), + transport, + ); + + assert.equal(transport.sendCount, 2); + assert.equal(challenged.cancelCount(), 1); + assert.equal(success.cancelCount(), 0); + assert.equal(response, success.response); + }); + + it('closes the 401 before propagating a throwing challenge hook (AUTH-32)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const transport = new FakeTransport([challenged.response]); + const descriptor = authStep({ + credentials: {}, + tiers: {client: createAuthDescriptor([createAuthRequirement('NO_AUTH')])}, + challengeHook: () => Promise.reject(new Error('hook exploded')), + }); + + await assert.rejects(runThrough(descriptor, transport), /hook exploded/u); + assert.equal(challenged.cancelCount(), 1); + }); + + it('leaves an unanswerable 401 open and unclosed -- the caller owns it (AUTH-33)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Negotiate abc123', + ); + const transport = new FakeTransport([ + challenged.response, + countingResponse(200).response, + ]); + + const response = await runThrough( + authStep({credentials, tiers}), + transport, + ); + + assert.equal(transport.sendCount, 1); + assert.equal(challenged.cancelCount(), 0); + assert.equal(response, challenged.response); + }); +}); + +describe('single-flight cancellation over Node AbortSignal (AUTH-34)', () => { + it('leaves no abort listener behind on a signal reused across many token fetches', async () => { + // `raceAbort` adds one `abort` listener per WAIT and removes it in a `finally`. Drop that + // removal and nothing in the suite fails, but a caller signal outliving many fetches -- one + // request driving a long paginated sweep -- accumulates a dead listener per fetch until Node's + // MaxListenersExceededWarning fires. The signal is never aborted here, so `{once: true}` cannot + // do the cleanup for us: only the explicit removal can. + const cache = new BearerTokenCache(); + const controller = new AbortController(); + + for (let round = 0; round < 12; round += 1) { + const token = createBearerToken(`t${round}`, 10_000); + await cache.stamp({ + provider: () => Promise.resolve(token), + marginMs: 0, + nowMs: 0, + signal: controller.signal, + }); + cache.evict(`Bearer t${round}`); // send the next round back down the fetch path + } + + assert.equal(getEventListeners(controller.signal, 'abort').length, 0); + }); + + it("an aborting caller stops waiting without cancelling a coalesced caller's fetch", async () => { + let release; + let invocations = 0; + const provider = () => { + invocations += 1; + return new Promise(resolve => { + release = resolve; + }); + }; + const cache = new BearerTokenCache(); + const controller = new AbortController(); + + const aborting = cache.stamp({ + provider, + marginMs: 0, + nowMs: 0, + signal: controller.signal, + }); + const patient = cache.stamp({ + provider, + marginMs: 0, + nowMs: 0, + signal: undefined, + }); + assert.equal(invocations, 1); + + controller.abort(new Error('caller A gave up')); + await assert.rejects(aborting, /caller A gave up/u); + + release(createBearerToken('t1', 10_000)); + assert.equal((await patient).token, 't1'); + assert.equal(invocations, 1); + }); +}); From db4a0990a0a89ad456426961fc15ff8f28979785 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 27 Aug 2026 12:56:22 +0300 Subject: [PATCH 4/4] docs: add changeset for retry pillar and engine. --- .../2026-08-26-retry-pillar-and-engine.md | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 .changeset/2026-08-26-retry-pillar-and-engine.md diff --git a/.changeset/2026-08-26-retry-pillar-and-engine.md b/.changeset/2026-08-26-retry-pillar-and-engine.md new file mode 100644 index 0000000..0f4a5a6 --- /dev/null +++ b/.changeset/2026-08-26-retry-pillar-and-engine.md @@ -0,0 +1,118 @@ +--- +'@dexpace/core': patch +--- + +Add the retry pillar for product-spec §9 (`RETRY-1`–`RETRY-45`) and appendix C's `RECOV-17`–`RECOV-34`, plus +the Phase 7a `config/` prerequisite slice and the shared `FakeTransport`. No public API change. + +Everything this adds lives under `packages/core/src/{retry,config,testing}/` and none of it is re-exported +from `src/index.ts` — `packages/core/etc/core.api.md` is byte-identical before and after. `patch` rather than +an empty changeset because files under `packages/` did change: the published tarball carries the new +`dist/retry/*.js`, `dist/config/*.js`, and `dist/testing/*.js`, and a consumer stepping through the package in +a debugger will see them. (The one behavior change a caller can observe from outside — tightening +`RequestOptionsBuilder.maxRetries` to a non-negative integer — ships under its own changeset.) + +Public-barrel promotion of `retryStep` and the step-authoring surface is deliberately **not** in this release. +A caller cannot assemble a working pipeline until the standard-resilience preset exists, and publishing +`retryStep` alone would freeze `StepDescriptor`/`Stage`/`PipelineBuilder` shapes that still had latitude to +move. Phase 5c owns that promotion. + +## What landed + +`packages/core/src/retry/`, eight files, no folder barrel: + +- **`classify.ts`** — the two orthogonal axes (`RETRY-1`–`RETRY-8`, `RETRY-37`). Retryability is an + ALLOW-list over an iterative, identity-tracking cause walk; `isResendable` is the second axis over + `Body.replayable` and Phase 1's `isIdempotent`. +- **`backoff.ts`, `pacing.ts`** — the pure math and the server-hint parser, split away from the imperative + loop. +- **`settings.ts`** — `RETRY-12`'s defaults, `RECOV-34`'s construction validation, and `totalTimeoutMs` as an + opt-in. +- **`engine.ts`** — the one attempt loop both adapters reach. +- **`attempt-stamp.ts`, `retry-step.ts`, `retry-dispatch.ts`** — per-attempt stamping and the two thin + adapters: the `RETRY` pillar step and the recovery-chain wrapper. + +Plus `recovery/idempotency-key.ts` (`RECOV-32`) and `testing/fake-transport.ts`, which closes the roadmap's +twice-punted `FakeTransport` deferral. + +Two files outside those folders changed, both additively. `StepContext` gains `signal` and `options` +(`PIPE-13`/`PIPE-17`): `Cursor` already carried both and threaded them into terminal dispatch, but no step +could read either, so `RETRY-26`'s cancellable wait and `RETRY-32` were unimplementable and `PIPE-17`'s +"readable by any step" MUST was unsatisfied outright — which is also the wire `RETRY-41`'s per-call +`maxRetries` override (`HTTP-35`) had been missing since Phase 1 designed the knob. + +## Executed out of numeric order: the Phase 7a prerequisite slice + +`config/clock.ts` (`CFG-15`–`CFG-17`), `config/http-date.ts` (`CFG-29`–`CFG-31`), and `config/retryable.ts` +(`CFG-35`) are built here, verbatim from Phase 7a's plan Tasks 1–3, because 5a's Global Constraints ban +shipping the private copies that would otherwise be needed: Task 8 consumes the `Clock` seam, Task 4 imports +the shared RFC 1123 parser, and Task 2 re-exports the shared retryable-status set instead of defining it a +second time. Phase 7a's Tasks 4–10 are untouched, and none of the three enters the public barrel — 7a's Task +10 still owns that decision. + +## Design calls worth recording + +- **One retry loop, reached by both adapters.** `RETRY-13`/`RETRY-14` and `RECOV-30` require the pillar stack + and the recovery-chain stack not to drift. `runWithRetry` is the single choke point both call, so the + schedule, the classifier, and the budget cannot diverge — structural, not a discipline. Every piece of + per-call state is a local (`RETRY-42`/`RECOV-28`), so concurrent invocations sharing one config cannot + clobber each other's attempt count or start instant. +- **`RETRY-25`'s fatal-error exclusion needs no code.** Because classification is an allow-list, a + stack-overflow `RangeError` is non-retryable for never having been opted in, not for having been screened + out. A caller `AbortError` is likewise non-retryable for free (`RETRY-23`), while `TimeoutError` is + explicitly listed (`RETRY-24`) — keying off the abort reason's `name` draws that line more precisely than + the class hierarchy the reference describes. +- **The pacing parser is total, and a failure never maps to `0`.** `RETRY-16` makes never-throwing the + defining property; every malformed, negative, or out-of-range value maps to `null` ("no hint", fall back to + backoff). `0` is reserved for a validly-parsed instant already in the past (`RETRY-17`) — mapping a + malformed header to `0` would hammer a server that just asked for room. `X-RateLimit-Reset` receives + `RECOV-25`'s positive [100%, 120%] jitter so a fleet released at one reset instant does not stampede; a + literal `Retry-After` receives none (`RETRY-20`). +- **`RETRY-36`'s remap applies only to responses the engine DISCARDS.** A response surviving the gates is + returned live and unread: `toHttpError()` drains the body and drops the headers irreversibly, and 4c's + pillar signature must return a `Response`. This is also why the pacing hint is read BEFORE the retire step + — that ordering is load-bearing, not stylistic. +- **`RETRY-27`'s budget clause is implemented as three separate checks, deliberately.** A delay that would + push cumulative elapsed time past the budget SUPPRESSES the retry and surfaces the last failure; the + `Math.min` clamp beside it is the requirement's separately-listed belt-and-braces clause and narrows + nothing except across clock drift between two `elapsed()` reads. It ships because the requirement lists it + separately, not because a test can drive it. +- **A non-finite retry ceiling is guarded at three layers.** Unlike a negative value, which still fails a + downstream `>= 1` guard, `Infinity` or `NaN` makes `attempt >= ceiling` permanently false and the loop + unbounded. The setter, the step's per-call derivation, and a `runWithRetry` precondition each reject it — + the precondition being the one choke point both adapters pass through. +- **`RETRY-41`'s "clamp a negative retry count to the default" is implemented as a REJECTION.** It collides + head-on with `HTTP-35`, also a MUST, which rejects precisely so the value cannot be silently reinterpreted + downstream. The port takes `HTTP-35`'s line on both surfaces; recorded in the design's Deviation Ledger. +- **The inter-attempt wait delegates to `Clock.sleep`.** `CFG-17` already races the timer against the signal, + clears it on both exits (`RETRY-45`'s scheduler hygiene, which has no scheduler object to own in this + port), and rejects promptly for a signal that aborted earlier. Hand-rolling a second `setTimeout`-plus- + listener would put the wait outside the injected seam and force real timers into a suite that must stay + deterministic. Cancellation RESOLVES rather than propagates, so the loop's next iteration observes the + signal and stops through its own `RETRY-32` path. +- **`RETRY-33`'s "every terminal path returns an Outcome" is honored literally.** An attempt that throws is + folded into a failure outcome carrying the trail rather than left to surface as a bare rejected promise, + which would drop `RETRY-34`'s suppressed attempts on the floor. The trail folds through Phase 4b's + `suppress()` helper, not `new SuppressedError(...)`: the native class reached Node only in 24.0.0 and this + package's floor is `>=20.3`. Argument order is controlled explicitly — native `using` disposal builds the + pair the other way round, making the LATER error primary. +- **`RETRY-30`'s trampoline requirement is satisfied by the language.** An `await` loop is already iterative, + so N retries build no continuation chain and no stack growth. +- **`PIPE-36` is satisfied structurally.** `retryStep()` is a factory returning a descriptor with + `stage: 'RETRY'` baked in — no class to subclass, no way for a caller to relocate a shipped pillar family + out of its pillar. 4c deferred this to "whichever future phase ships the first real pillar step family"; + this is that phase. +- **`countingResponse()` counts release by BOTH routes it can happen** — `cancel()` for an abandoned + response, `pull()`-to-EOF for one `toHttpError()` drained. A helper counting `cancel()` alone reads zero on + exactly the `RETRY-35` path it exists to prove. + +## Known gaps, each recorded rather than left silent + +- **`RETRY-29`** (opt-in server-driven retry-classification override) is a `MAY` and is unscheduled: it + widens the classifier's input surface to server-controlled values and wants an explicit trust decision, not + a default. +- **`RECOV-33`** (client-identity header step) belongs with the `CFG-*` work and is Phase 7a's Task 9. +- **`RETRY-40`'s "log the failure" clause and the two SHOULD-level structured events** (`retry.attemptFailed`, + `retry.exhausted`) are not implemented here. 5a executes before 7b, so an `observability/logger.js` import + would not resolve; 7b in turn needs this phase's `FakeTransport`, so the cycle only breaks in this + direction. Phase 7b's Task 9 owns them, named in `engine.ts`'s retrofit note.