@fuguejs/http-auth 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # @fuguejs/http-auth
2
+
3
+ A generic **authenticated-REST capability** for the Fugue DAG framework. Any
4
+ DAG that must call a token-auth'd REST API declares `requires: ["authedHttp"]`
5
+ and reads `ctx.authedHttp`. The capability mints and caches a boot-scoped bearer
6
+ token (a generic OAuth2-style password/operator grant), injects it into every
7
+ request, validates responses against Zod schemas, and returns `Result` — no
8
+ exception escapes any method.
9
+
10
+ This package is **not specific to any single API**. All auth and base-location
11
+ configuration arrives via the factory; nothing is read from `process.env` here
12
+ (FR-060).
13
+
14
+ ## Install
15
+
16
+ ```sh
17
+ bun add @fuguejs/http-auth
18
+ ```
19
+
20
+ `@fuguejs/framework` and `zod` are peer dependencies.
21
+
22
+ ## Usage
23
+
24
+ ```ts
25
+ import { createHttpAuthAdapter } from "@fuguejs/http-auth";
26
+ import { z } from "zod";
27
+
28
+ const authedHttp = createHttpAuthAdapter({
29
+ baseUrl: "https://api.example.com",
30
+ defaultHeaders: { Accept: "application/json" },
31
+ timeoutMs: 10_000,
32
+ auth: {
33
+ tokenUrl: "https://auth.example.com/oauth/token",
34
+ grantType: "operator_password",
35
+ params: { brand_key: "acme" }, // static extra form fields
36
+ basicAuth: { username: "id", password: "secret" }, // optional HTTP Basic
37
+ credentials: { username: "operator", password: "s3cret" },
38
+ },
39
+ });
40
+
41
+ // Register with the host:
42
+ const sharedInfra = { /* ... */ capabilities: [authedHttp] };
43
+
44
+ // In a node:
45
+ const CustomerSchema = z.object({ id: z.string(), name: z.string() });
46
+
47
+ createFetchNode({
48
+ id: "fetch-customer",
49
+ requires: ["authedHttp"] as const,
50
+ fetch: (input, ctx) =>
51
+ ctx.authedHttp.get(`/customers/${input.id}`, { schema: CustomerSchema }),
52
+ });
53
+ ```
54
+
55
+ ## Capability surface
56
+
57
+ `ctx.authedHttp` exposes:
58
+
59
+ | Method | Body | Notes |
60
+ | ------------------------------- | ---- | -------------------------------------- |
61
+ | `get(path, { schema, ... })` | no | |
62
+ | `post(path, { schema, body })` | yes | body is JSON-stringified |
63
+ | `put(path, { schema, body })` | yes | |
64
+ | `patch(path, { schema, body })` | yes | |
65
+ | `delete(path, { schema, ... })` | no | |
66
+
67
+ No-body verbs (`get`/`delete`) take `AuthedRequestOpts`:
68
+ `{ schema, headers?, timeoutMs? }`. Body verbs (`post`/`put`/`patch`) take
69
+ `AuthedBodyRequestOpts`, which additionally carries `body?` and `contentType?`.
70
+ The body/no-body split lives in the option types alone — passing `body` to a
71
+ no-body verb is a compile error. Every method returns
72
+ `Promise<Result<T, FrameworkError>>`.
73
+
74
+ ## Token management
75
+
76
+ - **One boot-scoped cached token**, shared across all requests, minted lazily on
77
+ first use and refreshed when absent or expired (with a 30s clock-skew guard).
78
+ - **Single-flight refresh**: a burst of concurrent callers arriving after expiry
79
+ mints exactly one token, not N.
80
+ - **401 retry**: on a `401` from any verb, the token is invalidated, re-minted,
81
+ and the original request retried exactly once.
82
+
83
+ The boot-scoped cache means steady-state requests inject the cached token without
84
+ a per-request auth round-trip (NFR-001/SC-001). The token and credentials are
85
+ never logged and never returned from any method (NFR-010).
86
+
87
+ ## Error mapping
88
+
89
+ Mirrors the framework's built-in HTTP capability. The same classification applies
90
+ on **both** the token-mint path and the request path.
91
+
92
+ | Failure | `FrameworkError.kind` | Retriable? | Why |
93
+ | --------------------------------------------- | ----------------------------- | ---------- | ------------------------------------------------------------------------------------ |
94
+ | network failure | `transient` | yes | A connectivity blip should be retried. |
95
+ | our own timeout (deadline abort, msg "timeout") | `transient` | yes | A slow endpoint should be retried. |
96
+ | non-timeout `AbortError` (caller/node cancel) | `node-crash` (non-retriable) | **no** | A deliberate cancellation must not silently auto-retry the work it stopped. |
97
+ | HTTP 5xx | `transient` (with httpStatus) | yes | Server-side fault, typically transient. |
98
+ | HTTP 429 (Too Many Requests) | `transient` (with httpStatus) | yes | Rate-limit — the textbook back-off-and-retry signal. |
99
+ | HTTP 408 (Request Timeout) | `transient` (with httpStatus) | yes | The server timed the request out — retry. |
100
+ | HTTP 4xx (other non-401) | `node-crash` (non-retriable) | **no** | Deterministic rejection; the same request would just fail again. |
101
+ | invalid JSON / schema mismatch | `node-crash` (non-retriable) | **no** | Deterministic payload defect. |
102
+ | 401 persisting after a token refresh | `node-crash` (non-retriable) | **no** | A second consecutive 401 is settled auth failure, not a transient blip. |
103
+
104
+ Timeout vs. cancellation: our **own** deadline abort (we fire it with the message
105
+ `"timeout"`) stays `transient` (retriable), but a non-timeout `AbortError` — a
106
+ caller/node cancellation, including a health-check deadline cancelling its mint —
107
+ maps to a non-retriable `node-crash`, because auto-retrying cancelled work defeats
108
+ the cancellation.
109
+
110
+ ## Lifecycle
111
+
112
+ The handle returned by `createHttpAuthAdapter` participates in the runtime
113
+ lifecycle:
114
+
115
+ - `connect()` mints the first token (a bad credential fails boot, not the first
116
+ run).
117
+ - `healthCheck()` forces a fresh token-mint round-trip, racing a 5s timeout.
118
+ - `close()` is a no-op (no connection pool to drain).
119
+
120
+ ## Testing
121
+
122
+ Use `createFakeAuthedHttpCapability` to test DAG nodes without network or token
123
+ machinery:
124
+
125
+ ```ts
126
+ import { createFakeAuthedHttpCapability } from "@fuguejs/http-auth";
127
+
128
+ const fake = createFakeAuthedHttpCapability({
129
+ "GET /customers/123": { id: "123", name: "Alice" },
130
+ "POST /orders": { body: { orderId: "ord-1" } },
131
+ "GET /customers/999": { status: 404, body: "Not Found" },
132
+ });
133
+ ```
134
+
135
+ For unit-testing the real client/provider, inject a fake `fetch` seam via the
136
+ `fetch` config option — no network and no mocking framework required.
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@fuguejs/http-auth",
3
+ "version": "0.3.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/peterstorm/fugue.git",
7
+ "directory": "packages/http-auth"
8
+ },
9
+ "type": "module",
10
+ "main": "src/index.ts",
11
+ "exports": {
12
+ ".": "./src/index.ts"
13
+ },
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "typecheck": "tsc --noEmit",
17
+ "test": "bun test"
18
+ },
19
+ "peerDependencies": {
20
+ "@fuguejs/framework": "0.3.0",
21
+ "zod": "^4.3.6"
22
+ },
23
+ "peerDependenciesMeta": {
24
+ "@fuguejs/framework": {
25
+ "optional": false
26
+ },
27
+ "zod": {
28
+ "optional": false
29
+ }
30
+ },
31
+ "devDependencies": {
32
+ "@fuguejs/framework": "0.3.0",
33
+ "@types/bun": "latest",
34
+ "zod": "^4.3.6"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "files": [
40
+ "src",
41
+ "!src/__tests__"
42
+ ]
43
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,445 @@
1
+ /**
2
+ * Generic OAuth2-style token provider for `@fuguejs/http-auth`.
3
+ *
4
+ * Mints a single boot-scoped bearer token via an `application/x-www-form-urlencoded`
5
+ * password/operator grant and caches it. The token is shared across every request;
6
+ * it is minted lazily on first use, refreshed when absent or expired, and
7
+ * invalidated on a `401` so the next `get()` re-mints.
8
+ *
9
+ * Concurrency: a burst of callers arriving after expiry mints exactly ONE token,
10
+ * not N — a single in-flight refresh promise de-dups concurrent refreshes.
11
+ *
12
+ * NFR-010: the token and credentials never leave this module — `get()` returns
13
+ * the bearer string only to the client that injects it into an `Authorization`
14
+ * header, and nothing here logs the token or credentials.
15
+ *
16
+ * @see FR-060 — all credentials/locations arrive via config; nothing read from env here.
17
+ */
18
+
19
+ import { z } from "zod";
20
+ import type { Result, FrameworkError } from "@fuguejs/framework";
21
+ import { ok, err, nodeId } from "@fuguejs/framework";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Branded bearer token
25
+ // ---------------------------------------------------------------------------
26
+
27
+ declare const __bearerBrand: unique symbol;
28
+
29
+ /**
30
+ * A validated bearer token string. Branded so it cannot be confused with an
31
+ * arbitrary string (e.g. a username, a header value) at a call site.
32
+ */
33
+ export type BearerToken = string & { readonly [__bearerBrand]: "BearerToken" };
34
+
35
+ const asBearerToken = (raw: string): BearerToken => raw as BearerToken;
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Fetch seam (single-method port → function type)
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * The slice of the Fetch API the package actually uses. Tests inject a fake;
43
+ * production passes the platform `fetch`. A single-method port collapses to a
44
+ * function type — no class, no interface, no mocking framework.
45
+ */
46
+ export type FetchLike = (
47
+ url: string,
48
+ init: {
49
+ readonly method: string;
50
+ readonly headers: Record<string, string>;
51
+ readonly body?: string;
52
+ readonly signal?: AbortSignal;
53
+ },
54
+ ) => Promise<FetchResponseLike>;
55
+
56
+ /** The slice of `Response` the package reads. */
57
+ export interface FetchResponseLike {
58
+ readonly ok: boolean;
59
+ readonly status: number;
60
+ readonly statusText: string;
61
+ readonly text: () => Promise<string>;
62
+ readonly json: () => Promise<unknown>;
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Configuration
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Form-field credentials for the password/operator grant. The two mandatory
71
+ * grant fields are modelled explicitly; any further static fields (a second
72
+ * factor, a device id) go in `extra` — mirroring how `AuthConfig.params`
73
+ * carries optional extras. An open index signature is deliberately avoided so a
74
+ * stray non-string field cannot widen the type and so the shape stays honest.
75
+ */
76
+ export interface GrantCredentials {
77
+ readonly username: string;
78
+ readonly password: string;
79
+ /** Any further static credential fields (e.g. a second factor). */
80
+ readonly extra?: Readonly<Record<string, string>>;
81
+ }
82
+
83
+ /** Optional HTTP Basic auth applied to the token request itself. */
84
+ export interface BasicAuth {
85
+ readonly username: string;
86
+ readonly password: string;
87
+ }
88
+
89
+ /**
90
+ * Generic OAuth2-style token-grant configuration. All values arrive via config
91
+ * — nothing is hardcoded or read from `process.env` here (FR-060).
92
+ */
93
+ export interface AuthConfig {
94
+ /** Absolute URL of the token endpoint. */
95
+ readonly tokenUrl: string;
96
+ /** OAuth2 `grant_type` value (e.g. `"password"`, `"operator_password"`). */
97
+ readonly grantType: string;
98
+ /** Static extra form fields merged into the grant body (e.g. a brand key). */
99
+ readonly params?: Readonly<Record<string, string>>;
100
+ /** Optional HTTP Basic auth header on the token request. */
101
+ readonly basicAuth?: BasicAuth;
102
+ /**
103
+ * Form-field credentials (username/password and any extras) for resource-owner
104
+ * style grants (`password`/`operator_password`).
105
+ *
106
+ * OPTIONAL because a two-legged `client_credentials` grant carries NO
107
+ * resource-owner username/password — the client authenticates solely via
108
+ * `basicAuth` (HTTP Basic `client_id:client_secret`) and any non-secret extras
109
+ * (brand key, operator) ride in `params`. When omitted, `buildGrantBody` emits
110
+ * neither `username` nor `password`, so the body is exactly
111
+ * `grant_type=client_credentials&<params>` — what an OAuth2 client-credentials
112
+ * token endpoint expects (sending empty `username=`/`password=` would otherwise
113
+ * be rejected as an `invalid_request`).
114
+ */
115
+ readonly credentials?: GrantCredentials;
116
+ /**
117
+ * Request timeout for the token mint, in ms. Falls back to the client's
118
+ * default when absent.
119
+ */
120
+ readonly timeoutMs?: number;
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Token provider port
125
+ // ---------------------------------------------------------------------------
126
+
127
+ /**
128
+ * Internal capability the client depends on to obtain a bearer token. `get()`
129
+ * returns a cached token or mints one; `invalidate()` drops the cache so the
130
+ * next `get()` re-mints (used on a `401`).
131
+ *
132
+ * `get()` accepts an optional `AbortSignal`: when a mint is actually performed
133
+ * (cache miss), aborting the signal cancels the in-flight fetch so a caller that
134
+ * has given up (e.g. a health check that hit its deadline) does not leave an
135
+ * orphaned mint that could later repopulate the cache (split-brain). A cancelled
136
+ * mint maps to a non-retriable `node-crash` (see `mapTokenError`).
137
+ */
138
+ export interface TokenProvider {
139
+ get(signal?: AbortSignal): Promise<Result<BearerToken, FrameworkError>>;
140
+ invalidate(): void;
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Token grant — response processing is pure
145
+ // ---------------------------------------------------------------------------
146
+
147
+ const AUTH_NODE_ID = nodeId("http-auth-token");
148
+
149
+ /** Sentinel: how long before real expiry we treat a token as stale (clock skew). */
150
+ const EXPIRY_SKEW_MS = 30_000;
151
+
152
+ /**
153
+ * Zod schema for a generic OAuth2 token response. `expires_in` is optional —
154
+ * when absent the token is treated as non-expiring within the boot scope.
155
+ * Unknown extra fields (scope, refresh_token, …) are tolerated and ignored.
156
+ */
157
+ const TokenResponseSchema = z.object({
158
+ access_token: z.string().min(1),
159
+ expires_in: z.number().optional(),
160
+ });
161
+
162
+ /** Base64-encode `user:pass` for an HTTP Basic header without leaking via logs. */
163
+ const basicAuthHeader = (basic: BasicAuth): string => {
164
+ const raw = `${basic.username}:${basic.password}`;
165
+ // `Buffer` exists under Node and Bun; `btoa` is the browser/edge fallback.
166
+ const encoded =
167
+ typeof Buffer !== "undefined"
168
+ ? Buffer.from(raw, "utf8").toString("base64")
169
+ : btoa(raw);
170
+ return `Basic ${encoded}`;
171
+ };
172
+
173
+ /** Build the `x-www-form-urlencoded` grant body from config. */
174
+ const buildGrantBody = (auth: AuthConfig): string => {
175
+ const params = new URLSearchParams();
176
+ params.set("grant_type", auth.grantType);
177
+ // Resource-owner credentials are emitted ONLY when present. A
178
+ // `client_credentials` grant omits them entirely (the client authenticates via
179
+ // `basicAuth`); emitting empty `username=`/`password=` would make a compliant
180
+ // token endpoint reject the request as `invalid_request`.
181
+ if (auth.credentials) {
182
+ params.set("username", auth.credentials.username);
183
+ params.set("password", auth.credentials.password);
184
+ if (auth.credentials.extra)
185
+ for (const [k, v] of Object.entries(auth.credentials.extra)) params.set(k, v);
186
+ }
187
+ if (auth.params) for (const [k, v] of Object.entries(auth.params)) params.set(k, v);
188
+ return params.toString();
189
+ };
190
+
191
+ /**
192
+ * HTTP statuses that are retriable despite being non-5xx: `429 Too Many
193
+ * Requests` (rate-limit — back off and retry) and `408 Request Timeout` (the
194
+ * server timed the request out — retry). These are the textbook retriable
195
+ * signals, so we classify them `transient` rather than a non-retriable crash.
196
+ */
197
+ const RETRIABLE_HTTP_STATUSES: ReadonlySet<number> = new Set([408, 429]);
198
+
199
+ /**
200
+ * Map a token-mint failure to a `FrameworkError`. The token/credentials are
201
+ * never included in the message (NFR-010).
202
+ *
203
+ * Retriability policy (see the README error-mapping table):
204
+ * - `network` / `timeout` (our own timeout abort): `transient` — a blip or a
205
+ * slow endpoint should be retried.
206
+ * - `abort` (a non-timeout `AbortError`, i.e. caller/node cancellation):
207
+ * non-retriable `node-crash` — a deliberate cancellation must NOT silently
208
+ * auto-retry the very work the caller asked to stop.
209
+ * - HTTP `5xx`, `429` (rate-limit), `408` (request timeout): `transient` — all
210
+ * retriable per `RETRIABLE_HTTP_STATUSES` + the 5xx range.
211
+ * - any other non-2xx `4xx` or an unparseable body: non-retriable `node-crash`
212
+ * (a deterministic rejection — retrying with the same credentials/body would
213
+ * just fail again).
214
+ */
215
+ const mapTokenError = (
216
+ kind: "network" | "timeout" | "abort" | "http" | "parse",
217
+ detail: string,
218
+ status?: number,
219
+ ): FrameworkError => {
220
+ if (kind === "network" || kind === "timeout") {
221
+ return { kind: "transient", nodeId: AUTH_NODE_ID, message: `Token mint ${kind}: ${detail}` };
222
+ }
223
+ // A deliberate (non-timeout) cancellation: do not auto-retry what was cancelled.
224
+ if (kind === "abort") {
225
+ return {
226
+ kind: "node-crash",
227
+ nodeId: AUTH_NODE_ID,
228
+ message: `Token mint aborted: ${detail}`,
229
+ retriability: "non-retriable",
230
+ };
231
+ }
232
+ // 5xx + rate-limit (429) + request-timeout (408) are the retriable HTTP signals.
233
+ if (kind === "http" && status !== undefined && (status >= 500 || RETRIABLE_HTTP_STATUSES.has(status))) {
234
+ return { kind: "transient", nodeId: AUTH_NODE_ID, message: `Token mint HTTP ${status}`, httpStatus: status };
235
+ }
236
+ if (kind === "http") {
237
+ return {
238
+ kind: "node-crash",
239
+ nodeId: AUTH_NODE_ID,
240
+ message: `Token mint rejected: HTTP ${status ?? "?"}`,
241
+ retriability: "non-retriable",
242
+ };
243
+ }
244
+ return {
245
+ kind: "node-crash",
246
+ nodeId: AUTH_NODE_ID,
247
+ message: `Token mint response invalid: ${detail}`,
248
+ retriability: "non-retriable",
249
+ };
250
+ };
251
+
252
+ /** A minted token plus the absolute epoch-ms it expires (or `null` if non-expiring). */
253
+ interface CachedToken {
254
+ readonly token: BearerToken;
255
+ readonly expiresAtMs: number | null;
256
+ }
257
+
258
+ /**
259
+ * Perform the token grant over the injected fetch seam. Side-effecting I/O is
260
+ * isolated here; response *processing* (schema, expiry computation) is pure.
261
+ */
262
+ const mintToken = async (
263
+ auth: AuthConfig,
264
+ doFetch: FetchLike,
265
+ now: () => number,
266
+ defaultTimeoutMs: number | undefined,
267
+ externalSignal?: AbortSignal,
268
+ ): Promise<Result<CachedToken, FrameworkError>> => {
269
+ const headers: Record<string, string> = {
270
+ Accept: "application/json",
271
+ "Content-Type": "application/x-www-form-urlencoded",
272
+ };
273
+ if (auth.basicAuth) headers.Authorization = basicAuthHeader(auth.basicAuth);
274
+
275
+ const timeoutMs = auth.timeoutMs ?? defaultTimeoutMs;
276
+ // A single controller drives BOTH the internal timeout and any external
277
+ // cancellation. The timeout aborts with an Error("timeout") (→ transient);
278
+ // an external abort propagates a plain AbortError (→ non-retriable
279
+ // node-crash), so the two causes stay distinguishable in the catch.
280
+ let controller: AbortController | undefined;
281
+ let timer: ReturnType<typeof setTimeout> | undefined;
282
+ let onExternalAbort: (() => void) | undefined;
283
+ if (timeoutMs != null || externalSignal) {
284
+ controller = new AbortController();
285
+ const ctrl = controller;
286
+ if (timeoutMs != null) {
287
+ timer = setTimeout(() => ctrl.abort(new Error("timeout")), timeoutMs);
288
+ }
289
+ if (externalSignal) {
290
+ if (externalSignal.aborted) ctrl.abort();
291
+ else {
292
+ onExternalAbort = () => ctrl.abort();
293
+ externalSignal.addEventListener("abort", onExternalAbort, { once: true });
294
+ }
295
+ }
296
+ }
297
+
298
+ try {
299
+ const response = await doFetch(auth.tokenUrl, {
300
+ method: "POST",
301
+ headers,
302
+ body: buildGrantBody(auth),
303
+ signal: controller?.signal,
304
+ });
305
+
306
+ if (!response.ok) {
307
+ // Best-effort drain so a keep-alive socket is not left dangling; the body
308
+ // is intentionally NOT surfaced in the error to avoid leaking secrets.
309
+ await response.text().catch(() => "");
310
+ return err(mapTokenError("http", "non-2xx", response.status));
311
+ }
312
+
313
+ let body: unknown;
314
+ try {
315
+ body = await response.json();
316
+ } catch {
317
+ // Do NOT surface the JSON-parse error text: V8/Bun parse messages echo a
318
+ // snippet of the offending body, which for a token endpoint can carry the
319
+ // access_token. A static detail keeps the credential out of logs/errors.
320
+ return err(mapTokenError("parse", "malformed JSON response"));
321
+ }
322
+
323
+ const parsed = TokenResponseSchema.safeParse(body);
324
+ if (!parsed.success) {
325
+ // Surface only the failing field PATHS, never zod's full message (which can
326
+ // interpolate received values for some issue kinds) — the body may carry a
327
+ // secret. Paths name which fields were wrong without echoing their values.
328
+ const paths = parsed.error.issues
329
+ .map((i) => (i.path.length > 0 ? i.path.join(".") : "(root)"))
330
+ .join(", ");
331
+ return err(mapTokenError("parse", `unexpected token response shape (${paths})`));
332
+ }
333
+
334
+ const expiresAtMs =
335
+ parsed.data.expires_in !== undefined
336
+ ? now() + parsed.data.expires_in * 1000 - EXPIRY_SKEW_MS
337
+ : null;
338
+
339
+ return ok({ token: asBearerToken(parsed.data.access_token), expiresAtMs });
340
+ } catch (error) {
341
+ // Distinguish OUR timeout abort from an external/foreign cancellation. The
342
+ // abort reason carried on the controller's signal is the source of truth: a
343
+ // signal-respecting fetch rejects with that reason. Our timeout aborts with
344
+ // `Error("timeout")`; an external cancel aborts with no/other reason.
345
+ const reason: unknown = controller?.signal.reason;
346
+ const isOurTimeout =
347
+ (reason instanceof Error && reason.message === "timeout") ||
348
+ (error instanceof Error && (error.message === "timeout" || error.name === "TimeoutError"));
349
+ const isAbort =
350
+ controller?.signal.aborted === true ||
351
+ (error instanceof Error && error.name === "AbortError");
352
+
353
+ // Our OWN timeout → transient: a slow auth endpoint should be retried.
354
+ if (isOurTimeout) {
355
+ return err(mapTokenError("timeout", `after ${timeoutMs}ms`));
356
+ }
357
+ // A non-timeout abort means the caller/node cancelled the mint → must NOT
358
+ // auto-retry the cancelled work; map to a non-retriable node-crash.
359
+ if (isAbort) {
360
+ return err(mapTokenError("abort", "request cancelled"));
361
+ }
362
+ return err(mapTokenError("network", error instanceof Error ? error.message : String(error)));
363
+ } finally {
364
+ if (timer != null) clearTimeout(timer);
365
+ if (onExternalAbort && externalSignal) externalSignal.removeEventListener("abort", onExternalAbort);
366
+ }
367
+ };
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // Provider factory — the one justified piece of encapsulated mutable state
371
+ // ---------------------------------------------------------------------------
372
+
373
+ export interface TokenProviderDeps {
374
+ readonly auth: AuthConfig;
375
+ readonly fetch: FetchLike;
376
+ /** Epoch-ms clock seam; defaults to `Date.now`. Injected by tests. */
377
+ readonly now?: () => number;
378
+ /** Default mint timeout when `auth.timeoutMs` is absent. */
379
+ readonly defaultTimeoutMs?: number;
380
+ }
381
+
382
+ /**
383
+ * Build a boot-scoped `TokenProvider`. The cached token and the single
384
+ * in-flight refresh promise are the only mutable state; both are closed over
385
+ * and never escape. Exported for testing — the adapter factory wires the real
386
+ * fetch.
387
+ */
388
+ export const createTokenProvider = (deps: TokenProviderDeps): TokenProvider => {
389
+ const now = deps.now ?? (() => Date.now());
390
+ let cached: CachedToken | null = null;
391
+ // De-dups concurrent refreshes: the first caller to find the cache empty
392
+ // starts the mint and parks its promise here; later callers in the same burst
393
+ // await the same promise instead of starting their own mint.
394
+ let inflight: Promise<Result<CachedToken, FrameworkError>> | null = null;
395
+ // Cache generation. `invalidate()` bumps it; a refresh captures the
396
+ // generation when it STARTS and only writes `cached` on settle if the
397
+ // generation is unchanged. This closes the lost-invalidate race: an
398
+ // `invalidate()` that lands while a mint is in flight must win — the
399
+ // resolving mint must not repopulate `cached` with the token the caller just
400
+ // asked to drop.
401
+ let generation = 0;
402
+
403
+ const isFresh = (entry: CachedToken): boolean =>
404
+ entry.expiresAtMs === null || entry.expiresAtMs > now();
405
+
406
+ const refresh = (signal?: AbortSignal): Promise<Result<CachedToken, FrameworkError>> => {
407
+ if (inflight) return inflight;
408
+ const startedGeneration = generation;
409
+ const p = mintToken(deps.auth, deps.fetch, now, deps.defaultTimeoutMs, signal)
410
+ .then((result) => {
411
+ // Only write the cache if no invalidate() intervened since this mint
412
+ // started — otherwise the just-invalidated token would be resurrected.
413
+ if (result.ok && generation === startedGeneration) cached = result.value;
414
+ return result;
415
+ })
416
+ .finally(() => {
417
+ // Clear the in-flight slot so a later expiry can mint afresh. A failed
418
+ // mint leaves `cached` untouched (still null/stale) so the next call
419
+ // retries rather than serving a poisoned entry.
420
+ inflight = null;
421
+ });
422
+ inflight = p;
423
+ return p;
424
+ };
425
+
426
+ return {
427
+ get: async (signal?: AbortSignal): Promise<Result<BearerToken, FrameworkError>> => {
428
+ if (cached && isFresh(cached)) return ok(cached.token);
429
+ const result = await refresh(signal);
430
+ return result.ok ? ok(result.value.token) : err(result.error);
431
+ },
432
+
433
+ invalidate: (): void => {
434
+ cached = null;
435
+ // Bump the generation so any in-flight mint's `.then` write is discarded.
436
+ generation += 1;
437
+ },
438
+ };
439
+ };
440
+
441
+ // ---------------------------------------------------------------------------
442
+ // Exports for the client / adapter
443
+ // ---------------------------------------------------------------------------
444
+
445
+ export { AUTH_NODE_ID, basicAuthHeader, buildGrantBody, mapTokenError, mintToken };
package/src/client.ts ADDED
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Authenticated REST client for `@fuguejs/http-auth`.
3
+ *
4
+ * Wraps the injected fetch seam with:
5
+ * - automatic injection of the managed bearer token as `Authorization: Bearer …`
6
+ * - Zod validation of every response body (`Result`, never throws)
7
+ * - FrameworkError mapping mirroring the framework's built-in HTTP capability
8
+ * (timeout/network/5xx → `transient`; invalid JSON/4xx/schema mismatch →
9
+ * non-retriable `node-crash`)
10
+ * - a single `401` retry: on a `401` from any verb, the token is invalidated,
11
+ * re-minted, and the original request retried exactly once.
12
+ *
13
+ * NFR-010: the token is read from the provider per request and placed only in
14
+ * the outbound header — it is never logged nor returned from any method.
15
+ */
16
+
17
+ import type { z } from "zod";
18
+ import type { Result, FrameworkError } from "@fuguejs/framework";
19
+ import { ok, err, nodeId, frameworkError } from "@fuguejs/framework";
20
+ import type { TokenProvider, FetchLike } from "./auth.js";
21
+
22
+ const CLIENT_NODE_ID = nodeId("http-auth-client");
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Request options & capability surface
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /** Options for an authenticated request without a body (GET/DELETE). */
29
+ export interface AuthedRequestOpts<T> {
30
+ /** Zod schema the response body is validated against. */
31
+ readonly schema: z.ZodType<T>;
32
+ /** Extra headers merged over the configured defaults (per-call override). */
33
+ readonly headers?: Readonly<Record<string, string>>;
34
+ /** Per-call timeout in ms; falls back to the client default. */
35
+ readonly timeoutMs?: number;
36
+ }
37
+
38
+ /**
39
+ * Options for an authenticated request WITH a JSON body (POST/PUT/PATCH). The
40
+ * body/no-body split is modelled exactly once, here: `body` is a first-class
41
+ * field of the body-verb opts (not bolted on at the method signature). It is
42
+ * optional because a body verb with no payload is legitimate (e.g. a `POST`
43
+ * that signals an action), but it lives on this type alone so `get`/`delete`
44
+ * cannot accept it and no unsafe cast is needed to read it.
45
+ */
46
+ export interface AuthedBodyRequestOpts<T> extends AuthedRequestOpts<T> {
47
+ /** The request payload; always JSON-stringified. Omit for a body-less action. */
48
+ readonly body?: unknown;
49
+ /** Content-Type override (header only); the body is always JSON-stringified. */
50
+ readonly contentType?: string;
51
+ }
52
+
53
+ /**
54
+ * The capability nodes see on `ctx.authedHttp`. Every method returns `Result`
55
+ * — no exception escapes — and auto-injects the managed bearer token. The
56
+ * body/no-body distinction is carried by the opts types: `get`/`delete` take
57
+ * `AuthedRequestOpts` (no `body`), `post`/`put`/`patch` take
58
+ * `AuthedBodyRequestOpts` (with `body`).
59
+ */
60
+ export interface AuthedHttpCapability {
61
+ get<T>(path: string, opts: AuthedRequestOpts<T>): Promise<Result<T, FrameworkError>>;
62
+ post<T>(path: string, opts: AuthedBodyRequestOpts<T>): Promise<Result<T, FrameworkError>>;
63
+ put<T>(path: string, opts: AuthedBodyRequestOpts<T>): Promise<Result<T, FrameworkError>>;
64
+ patch<T>(path: string, opts: AuthedBodyRequestOpts<T>): Promise<Result<T, FrameworkError>>;
65
+ delete<T>(path: string, opts: AuthedRequestOpts<T>): Promise<Result<T, FrameworkError>>;
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Pure helpers
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /** Join base URL and path, treating an absolute `path` as already complete. */
73
+ export const buildUrl = (baseUrl: string, path: string): string => {
74
+ if (path.startsWith("http://") || path.startsWith("https://")) return path;
75
+ const base = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
76
+ const suffix = path.startsWith("/") ? path : `/${path}`;
77
+ return `${base}${suffix}`;
78
+ };
79
+
80
+ const makeTransientError = (message: string, httpStatus?: number): FrameworkError =>
81
+ frameworkError.transient(CLIENT_NODE_ID, message, httpStatus);
82
+
83
+ const makeNodeCrashError = (message: string): FrameworkError => ({
84
+ kind: "node-crash",
85
+ nodeId: CLIENT_NODE_ID,
86
+ message,
87
+ retriability: "non-retriable",
88
+ });
89
+
90
+ /**
91
+ * HTTP statuses that are retriable despite being non-5xx: `429 Too Many
92
+ * Requests` (rate-limit — back off and retry) and `408 Request Timeout`. These
93
+ * are the textbook retriable signals, so we classify them `transient` rather
94
+ * than a non-retriable crash. Mirrors the token-mint path in `auth.ts`.
95
+ */
96
+ const RETRIABLE_HTTP_STATUSES: ReadonlySet<number> = new Set([408, 429]);
97
+
98
+ /** A non-2xx response is retriable when it is 5xx, 429 (rate-limit) or 408 (timeout). */
99
+ const isRetriableHttpStatus = (status: number): boolean =>
100
+ status >= 500 || RETRIABLE_HTTP_STATUSES.has(status);
101
+
102
+ /**
103
+ * The raw outcome of a single fetch attempt, before token-refresh logic. We
104
+ * surface `401` distinctly so the caller can decide to invalidate + retry,
105
+ * rather than baking the retry into the low-level send.
106
+ */
107
+ type SendOutcome<T> =
108
+ | { readonly tag: "ok"; readonly value: T }
109
+ | { readonly tag: "unauthorized" }
110
+ | { readonly tag: "error"; readonly error: FrameworkError };
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Client configuration
114
+ // ---------------------------------------------------------------------------
115
+
116
+ export interface AuthedClientConfig {
117
+ readonly baseUrl: string;
118
+ readonly defaultHeaders?: Readonly<Record<string, string>>;
119
+ readonly timeoutMs?: number;
120
+ }
121
+
122
+ export interface AuthedClientDeps {
123
+ readonly config: AuthedClientConfig;
124
+ readonly tokens: TokenProvider;
125
+ readonly fetch: FetchLike;
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Single send attempt (I/O isolated)
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /**
133
+ * The body payload for a single send. `body === undefined` means a body-less
134
+ * request (GET/DELETE, or a body verb invoked with no payload); `contentType`
135
+ * overrides the default `application/json` header. Extracted from
136
+ * `AuthedBodyRequestOpts` at the (statically body-typed) call site so neither
137
+ * `sendOnce` nor `execute` needs an unsafe cast to read body-only fields off
138
+ * the no-body opts type.
139
+ */
140
+ interface RequestBody {
141
+ readonly body: unknown | undefined;
142
+ readonly contentType?: string;
143
+ }
144
+
145
+ const NO_BODY: RequestBody = { body: undefined };
146
+
147
+ const sendOnce = async <T>(
148
+ deps: AuthedClientDeps,
149
+ method: string,
150
+ path: string,
151
+ token: string,
152
+ payload: RequestBody,
153
+ opts: AuthedRequestOpts<T>,
154
+ ): Promise<SendOutcome<T>> => {
155
+ const fullUrl = buildUrl(deps.config.baseUrl, path);
156
+ const timeoutMs = opts.timeoutMs ?? deps.config.timeoutMs;
157
+ const body = payload.body;
158
+
159
+ const headers: Record<string, string> = {
160
+ ...deps.config.defaultHeaders,
161
+ ...opts.headers,
162
+ Authorization: `Bearer ${token}`,
163
+ };
164
+ if (body !== undefined && !headers["Content-Type"] && !headers["content-type"]) {
165
+ headers["Content-Type"] = payload.contentType ?? "application/json";
166
+ }
167
+
168
+ let controller: AbortController | undefined;
169
+ let timer: ReturnType<typeof setTimeout> | undefined;
170
+ if (timeoutMs != null) {
171
+ controller = new AbortController();
172
+ const ctrl = controller;
173
+ timer = setTimeout(() => ctrl.abort(new Error("timeout")), timeoutMs);
174
+ }
175
+
176
+ try {
177
+ const response = await deps.fetch(fullUrl, {
178
+ method,
179
+ headers,
180
+ body: body !== undefined ? JSON.stringify(body) : undefined,
181
+ signal: controller?.signal,
182
+ });
183
+
184
+ if (response.status === 401) {
185
+ await response.text().catch(() => "");
186
+ return { tag: "unauthorized" };
187
+ }
188
+
189
+ if (!response.ok) {
190
+ const text = await response.text().catch(() => "<body unreadable>");
191
+ // 5xx, 429 (rate-limit) and 408 (request timeout) are the retriable HTTP
192
+ // signals → transient. Every other non-2xx (deterministic 4xx) is a
193
+ // non-retriable node-crash: retrying the same request would fail again.
194
+ if (isRetriableHttpStatus(response.status)) {
195
+ return { tag: "error", error: makeTransientError(`HTTP ${response.status} ${response.statusText}: ${text.slice(0, 500)}`, response.status) };
196
+ }
197
+ return { tag: "error", error: makeNodeCrashError(`HTTP ${response.status} ${response.statusText}: ${text.slice(0, 500)}`) };
198
+ }
199
+
200
+ let responseBody: unknown;
201
+ try {
202
+ responseBody = await response.json();
203
+ } catch (parseError) {
204
+ return {
205
+ tag: "error",
206
+ error: makeNodeCrashError(
207
+ `Response body was not valid JSON: ${parseError instanceof Error ? parseError.message : String(parseError)} (${method} ${fullUrl})`,
208
+ ),
209
+ };
210
+ }
211
+
212
+ const parsed = opts.schema.safeParse(responseBody);
213
+ if (!parsed.success) {
214
+ return { tag: "error", error: makeNodeCrashError(`Response validation failed: ${parsed.error.message}`) };
215
+ }
216
+ return { tag: "ok", value: parsed.data };
217
+ } catch (error: unknown) {
218
+ // Distinguish OUR timeout abort from a foreign cancellation. The abort
219
+ // reason on the controller's signal is the source of truth (a
220
+ // signal-respecting fetch rejects with that reason): our timeout aborts with
221
+ // `Error("timeout")`; an external cancel aborts with no/other reason.
222
+ const reason: unknown = controller?.signal.reason;
223
+ const isOurTimeout =
224
+ (reason instanceof Error && reason.message === "timeout") ||
225
+ (error instanceof Error && (error.message === "timeout" || error.name === "TimeoutError"));
226
+ const isAbort =
227
+ controller?.signal.aborted === true ||
228
+ (error instanceof Error && error.name === "AbortError");
229
+
230
+ // Our OWN timeout → transient: a slow endpoint should be retried.
231
+ if (isOurTimeout) {
232
+ return { tag: "error", error: makeTransientError(`HTTP request timed out after ${timeoutMs}ms: ${method} ${fullUrl}`) };
233
+ }
234
+ // A non-timeout abort means the caller/node cancelled this request →
235
+ // non-retriable node-crash: auto-retrying cancelled work defeats the cancel.
236
+ if (isAbort) {
237
+ return { tag: "error", error: makeNodeCrashError(`HTTP request cancelled: ${method} ${fullUrl}`) };
238
+ }
239
+ return {
240
+ tag: "error",
241
+ error: makeTransientError(`HTTP request failed: ${error instanceof Error ? error.message : String(error)}`),
242
+ };
243
+ } finally {
244
+ if (timer != null) clearTimeout(timer);
245
+ }
246
+ };
247
+
248
+ /**
249
+ * Execute a request with token injection and a single `401` retry: mint, send;
250
+ * on `401` invalidate + re-mint + send once more. Any failure to mint a token
251
+ * short-circuits to that error. No exception escapes.
252
+ */
253
+ const execute = async <T>(
254
+ deps: AuthedClientDeps,
255
+ method: string,
256
+ path: string,
257
+ payload: RequestBody,
258
+ opts: AuthedRequestOpts<T>,
259
+ ): Promise<Result<T, FrameworkError>> => {
260
+ const first = await deps.tokens.get();
261
+ if (!first.ok) return err(first.error);
262
+
263
+ const outcome = await sendOnce(deps, method, path, first.value, payload, opts);
264
+ if (outcome.tag === "ok") return ok(outcome.value);
265
+ if (outcome.tag === "error") return err(outcome.error);
266
+
267
+ // outcome.tag === "unauthorized": invalidate, re-mint, retry exactly once.
268
+ deps.tokens.invalidate();
269
+ const second = await deps.tokens.get();
270
+ if (!second.ok) return err(second.error);
271
+
272
+ const retry = await sendOnce(deps, method, path, second.value, payload, opts);
273
+ if (retry.tag === "ok") return ok(retry.value);
274
+ if (retry.tag === "error") return err(retry.error);
275
+ // A second consecutive 401 — surface as a non-retriable auth failure rather
276
+ // than looping. The credentials/token are not included (NFR-010).
277
+ return err(makeNodeCrashError(`Authentication failed after token refresh: ${method} ${path} returned 401`));
278
+ };
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // Factory
282
+ // ---------------------------------------------------------------------------
283
+
284
+ /**
285
+ * Build an `AuthedHttpCapability` over an injected token provider and fetch
286
+ * seam. Exported for testing — `createHttpAuthAdapter` is the production entry
287
+ * point that owns provider construction and lifecycle.
288
+ */
289
+ export const createAuthedHttpClient = (deps: AuthedClientDeps): AuthedHttpCapability => ({
290
+ get: <T>(path: string, opts: AuthedRequestOpts<T>) =>
291
+ execute(deps, "GET", path, NO_BODY, opts),
292
+ post: <T>(path: string, opts: AuthedBodyRequestOpts<T>) =>
293
+ execute(deps, "POST", path, { body: opts.body, contentType: opts.contentType }, opts),
294
+ put: <T>(path: string, opts: AuthedBodyRequestOpts<T>) =>
295
+ execute(deps, "PUT", path, { body: opts.body, contentType: opts.contentType }, opts),
296
+ patch: <T>(path: string, opts: AuthedBodyRequestOpts<T>) =>
297
+ execute(deps, "PATCH", path, { body: opts.body, contentType: opts.contentType }, opts),
298
+ delete: <T>(path: string, opts: AuthedRequestOpts<T>) =>
299
+ execute(deps, "DELETE", path, NO_BODY, opts),
300
+ });
301
+
302
+ export { CLIENT_NODE_ID };
package/src/index.ts ADDED
@@ -0,0 +1,389 @@
1
+ /**
2
+ * @fuguejs/http-auth — generic authenticated-REST capability for Fugue.
3
+ *
4
+ * A reusable building block: any DAG that must call a token-auth'd REST API
5
+ * declares `requires: ["authedHttp"]` and reads `ctx.authedHttp`. The capability
6
+ * mints and caches a boot-scoped bearer token (generic OAuth2-style
7
+ * password/operator grant) and injects it into every request, validating
8
+ * responses against Zod schemas and returning `Result` — no exception escapes.
9
+ *
10
+ * Not specific to any single API: all auth and base-location config arrives via
11
+ * the factory (FR-060); nothing is read from `process.env` here.
12
+ *
13
+ * ## Usage
14
+ *
15
+ * ```ts
16
+ * import { createHttpAuthAdapter } from "@fuguejs/http-auth";
17
+ *
18
+ * const authedHttp = createHttpAuthAdapter({
19
+ * baseUrl: "https://api.example.com",
20
+ * defaultHeaders: { Accept: "application/json" },
21
+ * timeoutMs: 10_000,
22
+ * auth: {
23
+ * tokenUrl: "https://auth.example.com/oauth/token",
24
+ * grantType: "operator_password",
25
+ * params: { brand_key: "acme" },
26
+ * basicAuth: { username: "client-id", password: "client-secret" },
27
+ * credentials: { username: "operator", password: "s3cret" },
28
+ * },
29
+ * });
30
+ *
31
+ * // Register with the host:
32
+ * const sharedInfra = { ..., capabilities: [authedHttp] };
33
+ *
34
+ * // In a node:
35
+ * createFetchNode({
36
+ * id: "fetch-customer",
37
+ * requires: ["authedHttp"] as const,
38
+ * fetch: (input, ctx) =>
39
+ * ctx.authedHttp.get(`/customers/${input.id}`, { schema: CustomerSchema }),
40
+ * });
41
+ * ```
42
+ *
43
+ * ## Module Augmentation
44
+ *
45
+ * Augments `@fuguejs/framework`'s `CapabilityRegistry` to add `authedHttp`.
46
+ * After importing this package `requires: ["authedHttp"]` is valid and
47
+ * `ctx.authedHttp` is typed as `AuthedHttpCapability`.
48
+ *
49
+ * @satisfies FR-060, NFR-001/SC-001 (boot-scoped token cache), NFR-010 (no token leak)
50
+ */
51
+
52
+ import type { Result, FrameworkError, CapabilityHandle } from "@fuguejs/framework";
53
+ import { ok, err, nodeId, formatFrameworkError } from "@fuguejs/framework";
54
+
55
+ import {
56
+ createTokenProvider,
57
+ type AuthConfig,
58
+ type BasicAuth,
59
+ type GrantCredentials,
60
+ type FetchLike,
61
+ type FetchResponseLike,
62
+ type TokenProvider,
63
+ } from "./auth.js";
64
+ import {
65
+ createAuthedHttpClient,
66
+ type AuthedHttpCapability,
67
+ type AuthedRequestOpts,
68
+ type AuthedBodyRequestOpts,
69
+ } from "./client.js";
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Module augmentation
73
+ // ---------------------------------------------------------------------------
74
+
75
+ declare module "@fuguejs/framework" {
76
+ interface CapabilityRegistry {
77
+ /** Authenticated REST capability. Access via `ctx.authedHttp` in nodes. */
78
+ readonly authedHttp: AuthedHttpCapability;
79
+ }
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Re-exports (public surface)
84
+ // ---------------------------------------------------------------------------
85
+
86
+ export {
87
+ createTokenProvider,
88
+ createAuthedHttpClient,
89
+ };
90
+ export type {
91
+ AuthedHttpCapability,
92
+ AuthedRequestOpts,
93
+ AuthedBodyRequestOpts,
94
+ AuthConfig,
95
+ BasicAuth,
96
+ GrantCredentials,
97
+ FetchLike,
98
+ FetchResponseLike,
99
+ TokenProvider,
100
+ };
101
+ // Note: `BearerToken` is intentionally NOT exported. It is an internal brand —
102
+ // no public API consumes or produces it (the token never crosses the capability
103
+ // boundary, NFR-010), so exporting it would only leak an internal type.
104
+ export { buildUrl, type AuthedClientConfig } from "./client.js";
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Configuration
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /**
111
+ * Full configuration for the authenticated-HTTP adapter. ALL config — nothing
112
+ * hardcoded, nothing read from the environment here (FR-060).
113
+ */
114
+ export interface HttpAuthConfig {
115
+ /** Base URL prepended to relative request paths. */
116
+ readonly baseUrl: string;
117
+ /** Default headers applied to every request (per-call overridable). */
118
+ readonly defaultHeaders?: Readonly<Record<string, string>>;
119
+ /** Default request timeout in ms (per-call overridable). */
120
+ readonly timeoutMs?: number;
121
+ /** Token-grant configuration. */
122
+ readonly auth: AuthConfig;
123
+ /**
124
+ * Optional fetch seam override. Defaults to the platform `fetch`. Tests pass
125
+ * a fake; production omits this.
126
+ */
127
+ readonly fetch?: FetchLike;
128
+ /** Optional epoch-ms clock seam for token expiry; defaults to `Date.now`. */
129
+ readonly now?: () => number;
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Default fetch adapter (imperative shell)
134
+ // ---------------------------------------------------------------------------
135
+
136
+ /**
137
+ * Adapt the platform `fetch` to the narrow `FetchLike` seam. The vendor type
138
+ * (`globalThis.fetch`) is confined to this one adapter so the core never sees
139
+ * it.
140
+ */
141
+ const platformFetch: FetchLike = async (url, init) => {
142
+ const response = await fetch(url, {
143
+ method: init.method,
144
+ headers: init.headers,
145
+ body: init.body,
146
+ signal: init.signal,
147
+ });
148
+ return {
149
+ ok: response.ok,
150
+ status: response.status,
151
+ statusText: response.statusText,
152
+ text: () => response.text(),
153
+ json: () => response.json(),
154
+ };
155
+ };
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Adapter factory
159
+ // ---------------------------------------------------------------------------
160
+
161
+ /** Health-check token mints are bounded so a hung auth endpoint reports unhealthy. */
162
+ const HEALTH_CHECK_TIMEOUT_MS = 5_000;
163
+
164
+ /**
165
+ * Create an authenticated-HTTP capability handle.
166
+ *
167
+ * Lifecycle:
168
+ * - `connect()` mints the first token (fails boot if credentials are bad).
169
+ * - `healthCheck()` does a token-mint round-trip, racing a 5s timeout.
170
+ * - `close()` is a no-op (no pool to drain).
171
+ *
172
+ * The boot-scoped token cache means a steady-state request injects the cached
173
+ * token without a per-request auth round-trip (NFR-001/SC-001).
174
+ */
175
+ export const createHttpAuthAdapter = (config: HttpAuthConfig): CapabilityHandle<"authedHttp"> => {
176
+ const doFetch = config.fetch ?? platformFetch;
177
+
178
+ const tokens = createTokenProvider({
179
+ auth: config.auth,
180
+ fetch: doFetch,
181
+ now: config.now,
182
+ defaultTimeoutMs: config.timeoutMs,
183
+ });
184
+
185
+ const client = createAuthedHttpClient({
186
+ config: {
187
+ baseUrl: config.baseUrl,
188
+ defaultHeaders: config.defaultHeaders,
189
+ timeoutMs: config.timeoutMs,
190
+ },
191
+ tokens,
192
+ fetch: doFetch,
193
+ });
194
+
195
+ return {
196
+ name: "authedHttp",
197
+ client,
198
+
199
+ connect: async () => {
200
+ // Mint the first token so a bad credential fails boot, not the first run.
201
+ const minted = await tokens.get();
202
+ if (!minted.ok) {
203
+ // The error message is secret-free by construction (NFR-010).
204
+ throw new Error(`http-auth connect failed: ${describeError(minted.error)}`);
205
+ }
206
+ },
207
+
208
+ close: async () => {
209
+ // Stateless beyond the in-memory token cache — nothing to drain.
210
+ },
211
+
212
+ healthCheck: () => healthCheckWithTimeout(tokens, HEALTH_CHECK_TIMEOUT_MS),
213
+ };
214
+ };
215
+
216
+ /**
217
+ * Render a FrameworkError to a short, secret-free string for boot diagnostics.
218
+ * Delegates to the framework's exhaustive `formatFrameworkError` so a NEW
219
+ * `FrameworkError` variant can never silently lose context here — adding a kind
220
+ * without a case there is a compile error, which this inherits. (NFR-010: the
221
+ * error variants this package emits never embed the token/credentials.)
222
+ */
223
+ const describeError = (error: FrameworkError): string => formatFrameworkError(error);
224
+
225
+ /**
226
+ * Run a fresh token mint against a deadline. A hung auth endpoint reports
227
+ * unhealthy instead of stalling the caller, AND the underlying mint is actually
228
+ * cancelled on timeout via an `AbortController` — so an orphaned mint cannot
229
+ * later repopulate the cache (split-brain). Exported for testing.
230
+ */
231
+ export const healthCheckWithTimeout = async (
232
+ tokens: TokenProvider,
233
+ timeoutMs: number,
234
+ ): Promise<Result<void, string>> => {
235
+ // Drive the mint off this controller's signal so a deadline hit cancels the
236
+ // in-flight fetch rather than leaving it running detached.
237
+ const controller = new AbortController();
238
+ let timer: ReturnType<typeof setTimeout> | undefined = setTimeout(
239
+ () => controller.abort(new Error(`health check timed out after ${timeoutMs}ms`)),
240
+ timeoutMs,
241
+ );
242
+ try {
243
+ // Force a mint round-trip so the check exercises the real auth path. The
244
+ // signal cancels that mint on timeout (no orphaned, cache-populating fetch).
245
+ tokens.invalidate();
246
+ const result = await tokens.get(controller.signal);
247
+ if (result.ok) return ok(undefined);
248
+ // A cancelled mint surfaces as a node-crash whose message names the deadline;
249
+ // formatFrameworkError keeps it secret-free.
250
+ return err(describeError(result.error));
251
+ } catch (e) {
252
+ // get() is Result-based and must not throw; this is defence-in-depth.
253
+ return err(e instanceof Error ? e.message : String(e));
254
+ } finally {
255
+ if (timer != null) {
256
+ clearTimeout(timer);
257
+ timer = undefined;
258
+ }
259
+ }
260
+ };
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Fake for testing
264
+ // ---------------------------------------------------------------------------
265
+
266
+ /**
267
+ * A canned response for one route in the fake capability. A `status` outside
268
+ * 2xx produces the same error classification as the real client (5xx →
269
+ * transient, other non-2xx → non-retriable node-crash); a `matchBody` that
270
+ * returns `false` fails the route so a wrong-payload bug surfaces in tests.
271
+ *
272
+ * Construct one with {@link shapedRoute} — that brand is how the fake tells a
273
+ * shaped route apart from a raw payload, so a raw payload that happens to carry
274
+ * a `body`/`status` field is never misread as control metadata.
275
+ */
276
+ export interface FakeAuthedHttpRoute {
277
+ readonly status?: number;
278
+ readonly body: unknown;
279
+ readonly matchBody?: (body: unknown) => boolean;
280
+ }
281
+
282
+ /**
283
+ * Brand marking a route value as a shaped {@link FakeAuthedHttpRoute} rather
284
+ * than a raw verbatim payload. A unique symbol (not a `"body" in route` shape
285
+ * heuristic) so a raw payload can never accidentally look shaped — the only way
286
+ * to carry it is through {@link shapedRoute}.
287
+ */
288
+ const SHAPED_ROUTE: unique symbol = Symbol("fuguejs.http-auth.shapedRoute");
289
+
290
+ type ShapedAuthedHttpRoute = FakeAuthedHttpRoute & { readonly [SHAPED_ROUTE]: true };
291
+
292
+ /**
293
+ * Wrap a {@link FakeAuthedHttpRoute} so the fake treats it as control metadata
294
+ * (status / matchBody / explicit body) instead of a raw response payload. Any
295
+ * route value NOT built with this helper is returned verbatim, so payloads that
296
+ * legitimately contain a top-level `body` field round-trip unchanged.
297
+ *
298
+ * @example
299
+ * ```ts
300
+ * createFakeAuthedHttpCapability({
301
+ * "GET /customers/123": { id: "123", name: "Alice" }, // raw — returned verbatim
302
+ * "GET /raw": { id: "1", body: "note" }, // raw — `body` field preserved
303
+ * "POST /orders": shapedRoute({ body: { orderId: "ord-1" } }), // shaped — `body` is the response
304
+ * "GET /missing": shapedRoute({ status: 404, body: "Not Found" }),
305
+ * });
306
+ * ```
307
+ */
308
+ export const shapedRoute = (route: FakeAuthedHttpRoute): ShapedAuthedHttpRoute => ({
309
+ ...route,
310
+ [SHAPED_ROUTE]: true,
311
+ });
312
+
313
+ const isShapedRoute = (route: unknown): route is ShapedAuthedHttpRoute =>
314
+ typeof route === "object" && route !== null && (route as Record<symbol, unknown>)[SHAPED_ROUTE] === true;
315
+
316
+ /**
317
+ * In-memory fake `AuthedHttpCapability` for testing DAG nodes that use
318
+ * `ctx.authedHttp`. No network, no token machinery — routes match on
319
+ * `"METHOD /path"` (or the bare path). Mirrors `createFakeHttpCapability`.
320
+ *
321
+ * @remarks
322
+ * A route value is a *raw* payload (returned verbatim) unless it was built with
323
+ * {@link shapedRoute}, which brands it as control metadata (`status`/`matchBody`/
324
+ * explicit `body`). Detection is by that brand — NOT a `"body" in route` shape
325
+ * heuristic — so a raw payload that legitimately carries a top-level `body`
326
+ * field round-trips unchanged instead of being misread as a shaped route.
327
+ *
328
+ * @example
329
+ * ```ts
330
+ * const fake = createFakeAuthedHttpCapability({
331
+ * "GET /customers/123": { id: "123", name: "Alice" }, // raw
332
+ * "POST /orders": shapedRoute({ body: { orderId: "ord-1" } }), // shaped
333
+ * "GET /customers/999": shapedRoute({ status: 404, body: "Not Found" }),
334
+ * });
335
+ * ```
336
+ */
337
+ export const createFakeAuthedHttpCapability = (
338
+ routes: Readonly<Record<string, unknown>>,
339
+ ): CapabilityHandle<"authedHttp"> => {
340
+ const client: AuthedHttpCapability = {
341
+ get: async (path, opts) => matchRoute("GET", path, undefined, opts.schema, routes),
342
+ post: async (path, opts) => matchRoute("POST", path, opts.body, opts.schema, routes),
343
+ put: async (path, opts) => matchRoute("PUT", path, opts.body, opts.schema, routes),
344
+ patch: async (path, opts) => matchRoute("PATCH", path, opts.body, opts.schema, routes),
345
+ delete: async (path, opts) => matchRoute("DELETE", path, undefined, opts.schema, routes),
346
+ };
347
+ return { name: "authedHttp", client };
348
+ };
349
+
350
+ const FAKE_NODE_ID_KIND = "node-crash" as const;
351
+ const FAKE_NODE_ID = nodeId("http-auth-fake");
352
+
353
+ const matchRoute = <T>(
354
+ method: string,
355
+ path: string,
356
+ requestBody: unknown,
357
+ schema: import("zod").z.ZodType<T>,
358
+ routes: Readonly<Record<string, unknown>>,
359
+ ): Result<T, FrameworkError> => {
360
+ const key = `${method} ${path}`;
361
+ const route = routes[key] ?? routes[path];
362
+ if (route == null) {
363
+ return err({ kind: "transient", nodeId: FAKE_NODE_ID, message: `No fake route matched: ${key}` });
364
+ }
365
+
366
+ const shaped = isShapedRoute(route);
367
+
368
+ if (shaped) {
369
+ const matchBody = route.matchBody;
370
+ if (matchBody && !matchBody(requestBody)) {
371
+ return err({ kind: "transient", nodeId: FAKE_NODE_ID, message: `Fake route ${key}: request body did not match matchBody` });
372
+ }
373
+ const status = route.status;
374
+ if (status != null && (status < 200 || status >= 300)) {
375
+ const bodyText = String(route.body ?? "");
376
+ if (status >= 500) {
377
+ return err({ kind: "transient", nodeId: FAKE_NODE_ID, message: `HTTP ${status}: ${bodyText.slice(0, 500)}`, httpStatus: status });
378
+ }
379
+ return err({ kind: FAKE_NODE_ID_KIND, nodeId: FAKE_NODE_ID, message: `HTTP ${status}: ${bodyText.slice(0, 500)}`, retriability: "non-retriable" });
380
+ }
381
+ }
382
+
383
+ const body = shaped ? route.body : route;
384
+ const parsed = schema.safeParse(body);
385
+ if (!parsed.success) {
386
+ return err({ kind: FAKE_NODE_ID_KIND, nodeId: FAKE_NODE_ID, message: `Fake route response validation failed: ${parsed.error.message}`, retriability: "non-retriable" });
387
+ }
388
+ return ok(parsed.data);
389
+ };