@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 +136 -0
- package/package.json +43 -0
- package/src/auth.ts +445 -0
- package/src/client.ts +302 -0
- package/src/index.ts +389 -0
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
|
+
};
|