@forgezero/access 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ForgeZero
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # @forgezero/access
2
+
3
+ **Who may call what, decided from one table instead of scattered `if` statements.**
4
+
5
+ You declare every route once — the session it needs, the roles that reach it, the
6
+ factors it demands — and the same declaration authorises the request, renders the
7
+ navigation and fails the build when the two disagree.
8
+
9
+ Zero runtime dependencies. Bun, Node 18+, Deno, Cloudflare Workers, browsers —
10
+ anywhere `fetch` and Web Crypto exist.
11
+
12
+ ```bash
13
+ bun add @forgezero/access
14
+ ```
15
+
16
+ ## The idea in twelve lines
17
+
18
+ ```ts
19
+ import { defineRoutes, authorise } from '@forgezero/access';
20
+
21
+ const ACCESS = defineRoutes({
22
+ 'api/invoices': { group: 'session', factors: ['passkey'] },
23
+ 'api/invoices/refund': { group: 'admin', factors: ['passkey'], actionFactors: ['passkey'] }
24
+ });
25
+
26
+ // A ROLE IS A SET OF ROUTE KEYS. There is no second permission vocabulary to
27
+ // keep in sync, and a role naming a route that no longer exists is a type error.
28
+ const FINANCE = ['api/invoices', 'api/invoices/refund'] as const;
29
+
30
+ authorise(ACCESS, 'api/invoices/refund', session); // → allowed | Refusal
31
+ ```
32
+
33
+ ## What it gives you that a middleware does not
34
+
35
+ **Two layers, and the second never trusts the first.** A session factor is proved
36
+ once and persists — it answers *who is this*. An action factor is proved per call
37
+ and never persists — it answers *is this a human, now, for this record*. A
38
+ five-minute "recently verified" timestamp looks equivalent and is not: a
39
+ timestamp **is** a persisted factor, so a left-open laptop replays the privileged
40
+ action. `fulfilledActionFactors()` returns `[]` — always, by design.
41
+
42
+ **A refusal that says what happened.** `401` no session · `403` role lacks the
43
+ route · `404` route exists but not in this stage · `409` a condition refused ·
44
+ `423` locked, a human must act · `428` a factor is missing, and the headers name
45
+ which. Every one is distinct because "403" for all of them is how a support
46
+ queue fills up.
47
+
48
+ **Twelve guards already written.** `requireBalance`, `requireApproval`,
49
+ `requireQuota`, `requireFreshness` and the rest — the conditions every project
50
+ writes by hand, each already carrying the right status code.
51
+
52
+ **Testable with no server.** `@forgezero/access/testing` builds sessions and
53
+ asserts the decision directly, so the truth table is a unit test rather than an
54
+ integration suite.
55
+
56
+ ## Subpaths
57
+
58
+ | import | what it is |
59
+ |---|---|
60
+ | `@forgezero/access` | the matrix, roles, `authorise`, `Refusal` |
61
+ | `/conditions` | the twelve guards, each with its own status |
62
+ | `/effects` | audit, emit, meter, invalidate, notify — after the decision |
63
+ | `/security` | constant-time compare, CSPRNG tokens, HMAC, HKDF, AES-GCM sealing |
64
+ | `/rate-limit` | counters over a window — memory, Redis, or a Durable Object |
65
+ | `/elysia` · `/fetch` | adapters |
66
+ | `/client` | the browser half, including the 428 replay |
67
+ | `/testing` | decide without a server |
68
+ | `/pipeline` · `/authenticator` | the resolver, and WebAuthn |
69
+
70
+ Full documentation: **https://forgezero.net/docs/access**
71
+
72
+ ## Licence
73
+
74
+ MIT. Part of [ForgeZero](https://forgezero.net) — secrets, attested compute and
75
+ deploys — and usable entirely on its own, with no ForgeZero account.
@@ -0,0 +1,118 @@
1
+ /**
2
+ * A software WebAuthn authenticator, for tests.
3
+ *
4
+ * Passkeys are the wall every integration test hits. Registration and assertion
5
+ * need a real authenticator, so the usual answer is to stub the verifier — which
6
+ * removes the one thing worth testing and leaves every route behind it unproven.
7
+ * ForgeZero hit exactly that: the custodian ceremony, admin routes, tenant
8
+ * provisioning and the vault all sit behind a passkey, so none of them had ever
9
+ * been exercised end to end.
10
+ *
11
+ * This produces genuine credentials: real P-256 keys, real ES256 signatures over
12
+ * `authenticatorData || SHA-256(clientDataJSON)`, real CBOR attestation. A real
13
+ * verifier accepts them, and a tampered one is rejected — which is what makes it
14
+ * a test rather than a bypass.
15
+ *
16
+ * ## Deliberately NOT a security boundary
17
+ *
18
+ * The private key lives in memory and never leaves the process. Nothing here
19
+ * belongs in a production path, and nothing here weakens one: the server code
20
+ * under test is unchanged and unaware.
21
+ *
22
+ * ## PRF
23
+ *
24
+ * The PRF extension is what a custodian's share envelope is sealed with, so an
25
+ * authenticator without it cannot test custody at all. A real authenticator
26
+ * computes HMAC over a per-credential secret; so does this, which makes the
27
+ * output stable across assertions for one credential and unrelated between
28
+ * credentials — the two properties the envelope scheme depends on.
29
+ */
30
+ type CborValue = number | string | Uint8Array | Map<CborValue, CborValue>;
31
+ declare function cbor(value: CborValue): Uint8Array;
32
+ /**
33
+ * Web Crypto returns ECDSA as raw `r || s`; WebAuthn verifiers expect DER.
34
+ *
35
+ * Skipping this conversion produces a signature that is the right length, looks
36
+ * plausible, and fails verification with no useful message — which is a long
37
+ * afternoon the first time.
38
+ */
39
+ declare function toDer(raw: Uint8Array): Uint8Array;
40
+ /** Matches @simplewebauthn's RegistrationResponseJSON closely enough to pass to it. */
41
+ export interface RegistrationResponse {
42
+ id: string;
43
+ rawId: string;
44
+ type: 'public-key';
45
+ clientExtensionResults: Record<string, unknown>;
46
+ authenticatorAttachment?: 'platform' | 'cross-platform';
47
+ response: {
48
+ clientDataJSON: string;
49
+ attestationObject: string;
50
+ transports?: string[];
51
+ };
52
+ }
53
+ export interface AuthenticationResponse {
54
+ id: string;
55
+ rawId: string;
56
+ type: 'public-key';
57
+ clientExtensionResults: Record<string, unknown>;
58
+ response: {
59
+ clientDataJSON: string;
60
+ authenticatorData: string;
61
+ signature: string;
62
+ userHandle?: string;
63
+ };
64
+ }
65
+ export interface Credential {
66
+ id: string;
67
+ userHandle?: string;
68
+ signCount: number;
69
+ }
70
+ export interface AuthenticatorOptions {
71
+ rpId: string;
72
+ origin: string;
73
+ /** Off reproduces an authenticator that does not support PRF — worth testing. */
74
+ prf?: boolean;
75
+ /** Off makes `uv` false, so a route demanding user verification refuses. */
76
+ userVerified?: boolean;
77
+ }
78
+ /** Whatever `startRegistration` returned. Only these fields are read. */
79
+ export interface RegistrationOptions {
80
+ challenge: string;
81
+ user?: {
82
+ id?: string;
83
+ };
84
+ rp?: {
85
+ id?: string;
86
+ };
87
+ }
88
+ export interface AuthenticationOptions {
89
+ challenge: string;
90
+ allowCredentials?: readonly {
91
+ id: string;
92
+ }[];
93
+ }
94
+ export declare function createAuthenticator(options: AuthenticatorOptions): {
95
+ /** Create a credential. The response is what a browser would post back. */
96
+ register(registration: RegistrationOptions): Promise<RegistrationResponse>;
97
+ /** Assert. Picks the allowed credential, or the only one when unconstrained. */
98
+ authenticate(authentication: AuthenticationOptions): Promise<AuthenticationResponse>;
99
+ /**
100
+ * PRF output for a salt — the key a custodian's share envelope is sealed
101
+ * with. Stable for one credential, unrelated between credentials, and
102
+ * unavailable when the device does not support the extension.
103
+ */
104
+ prf(salt: string | Uint8Array, credentialId?: string): Promise<Uint8Array>;
105
+ list: () => readonly Credential[];
106
+ /** Simulate a lost device, which is what the recovery path exists for. */
107
+ forget(credentialId?: string): void;
108
+ };
109
+ export type Authenticator = ReturnType<typeof createAuthenticator>;
110
+ /** Exposed for tests that assert a tampered signature is actually rejected. */
111
+ export declare const encoding: {
112
+ b64url: (bytes: Uint8Array) => string;
113
+ fromB64url: (value: string) => Uint8Array;
114
+ toDer: typeof toDer;
115
+ cbor: typeof cbor;
116
+ sha256: (bytes: Uint8Array) => Promise<Uint8Array>;
117
+ };
118
+ export {};
@@ -0,0 +1,186 @@
1
+ // src/authenticator.ts
2
+ function cborHead(major, value) {
3
+ if (value < 24)
4
+ return new Uint8Array([major << 5 | value]);
5
+ if (value < 256)
6
+ return new Uint8Array([major << 5 | 24, value]);
7
+ if (value < 65536)
8
+ return new Uint8Array([major << 5 | 25, value >> 8, value & 255]);
9
+ return new Uint8Array([
10
+ major << 5 | 26,
11
+ value >>> 24 & 255,
12
+ value >>> 16 & 255,
13
+ value >>> 8 & 255,
14
+ value & 255
15
+ ]);
16
+ }
17
+ var concat = (...parts) => {
18
+ const total = parts.reduce((sum, part) => sum + part.length, 0);
19
+ const out = new Uint8Array(total);
20
+ let at = 0;
21
+ for (const part of parts) {
22
+ out.set(part, at);
23
+ at += part.length;
24
+ }
25
+ return out;
26
+ };
27
+ function cbor(value) {
28
+ if (typeof value === "number") {
29
+ return value < 0 ? cborHead(1, -value - 1) : cborHead(0, value);
30
+ }
31
+ if (typeof value === "string") {
32
+ const bytes = new TextEncoder().encode(value);
33
+ return concat(cborHead(3, bytes.length), bytes);
34
+ }
35
+ if (value instanceof Uint8Array)
36
+ return concat(cborHead(2, value.length), value);
37
+ const entries = [...value.entries()];
38
+ return concat(cborHead(5, entries.length), ...entries.map(([key, item]) => concat(cbor(key), cbor(item))));
39
+ }
40
+ function toDer(raw) {
41
+ const trim = (part) => {
42
+ let at = 0;
43
+ while (at < part.length - 1 && part[at] === 0)
44
+ at += 1;
45
+ const trimmed = part.slice(at);
46
+ return trimmed[0] & 128 ? concat(new Uint8Array([0]), trimmed) : trimmed;
47
+ };
48
+ const r = trim(raw.slice(0, 32));
49
+ const s = trim(raw.slice(32));
50
+ const body = concat(new Uint8Array([2, r.length]), r, new Uint8Array([2, s.length]), s);
51
+ return concat(new Uint8Array([48, body.length]), body);
52
+ }
53
+ var b64url = (bytes) => {
54
+ let binary = "";
55
+ for (const byte of bytes)
56
+ binary += String.fromCharCode(byte);
57
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
58
+ };
59
+ var fromB64url = (value) => {
60
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/");
61
+ const binary = atob(padded + "=".repeat((4 - padded.length % 4) % 4));
62
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
63
+ };
64
+ var sha256 = async (bytes) => new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
65
+ var AAGUID = new Uint8Array(16);
66
+ function createAuthenticator(options) {
67
+ const { rpId, origin, prf = true, userVerified = true } = options;
68
+ const credentials = new Map;
69
+ const authData = async (credential, attested) => {
70
+ const rpIdHash = await sha256(new TextEncoder().encode(rpId));
71
+ let flags = 1;
72
+ if (userVerified)
73
+ flags |= 4;
74
+ if (attested)
75
+ flags |= 64;
76
+ const counter = new Uint8Array(4);
77
+ new DataView(counter.buffer).setUint32(0, credential?.signCount ?? 0, false);
78
+ if (!attested || !credential)
79
+ return concat(rpIdHash, new Uint8Array([flags]), counter);
80
+ const raw = await crypto.subtle.exportKey("raw", credential.keys.publicKey);
81
+ const point = new Uint8Array(raw);
82
+ const cose = cbor(new Map([
83
+ [1, 2],
84
+ [3, -7],
85
+ [-1, 1],
86
+ [-2, point.slice(1, 33)],
87
+ [-3, point.slice(33, 65)]
88
+ ]));
89
+ const idLength = new Uint8Array(2);
90
+ new DataView(idLength.buffer).setUint16(0, credential.id.length, false);
91
+ return concat(rpIdHash, new Uint8Array([flags]), counter, AAGUID, idLength, credential.id, cose);
92
+ };
93
+ const clientData = (type, challenge) => new TextEncoder().encode(JSON.stringify({ type, challenge, origin, crossOrigin: false }));
94
+ const sign = async (credential, data, clientDataJSON) => {
95
+ const signed = concat(data, await sha256(clientDataJSON));
96
+ const raw = new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, credential.keys.privateKey, signed));
97
+ return toDer(raw);
98
+ };
99
+ return {
100
+ async register(registration) {
101
+ const keys = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, [
102
+ "sign",
103
+ "verify"
104
+ ]);
105
+ const id = new Uint8Array(32);
106
+ crypto.getRandomValues(id);
107
+ const prfSecret = new Uint8Array(32);
108
+ crypto.getRandomValues(prfSecret);
109
+ const credential = {
110
+ id,
111
+ keys,
112
+ userHandle: registration.user?.id,
113
+ prfSecret,
114
+ signCount: 0
115
+ };
116
+ credentials.set(b64url(id), credential);
117
+ const clientDataJSON = clientData("webauthn.create", registration.challenge);
118
+ const attestation = cbor(new Map([
119
+ ["fmt", "none"],
120
+ ["attStmt", new Map],
121
+ ["authData", await authData(credential, true)]
122
+ ]));
123
+ return {
124
+ id: b64url(id),
125
+ rawId: b64url(id),
126
+ type: "public-key",
127
+ authenticatorAttachment: "platform",
128
+ clientExtensionResults: prf ? { prf: { enabled: true } } : {},
129
+ response: {
130
+ clientDataJSON: b64url(clientDataJSON),
131
+ attestationObject: b64url(attestation),
132
+ transports: ["internal", "hybrid"]
133
+ }
134
+ };
135
+ },
136
+ async authenticate(authentication) {
137
+ const allowed = authentication.allowCredentials?.[0]?.id;
138
+ const key = allowed ?? [...credentials.keys()][0];
139
+ const credential = key ? credentials.get(key) : undefined;
140
+ if (!credential)
141
+ throw new Error("This authenticator holds no credential for that request.");
142
+ credential.signCount += 1;
143
+ const clientDataJSON = clientData("webauthn.get", authentication.challenge);
144
+ const data = await authData(credential, false);
145
+ return {
146
+ id: b64url(credential.id),
147
+ rawId: b64url(credential.id),
148
+ type: "public-key",
149
+ clientExtensionResults: {},
150
+ response: {
151
+ clientDataJSON: b64url(clientDataJSON),
152
+ authenticatorData: b64url(data),
153
+ signature: b64url(await sign(credential, data, clientDataJSON)),
154
+ ...credential.userHandle ? { userHandle: credential.userHandle } : {}
155
+ }
156
+ };
157
+ },
158
+ async prf(salt, credentialId) {
159
+ if (!prf)
160
+ throw new Error("This authenticator does not support the PRF extension.");
161
+ const key = credentialId ?? [...credentials.keys()][0];
162
+ const credential = key ? credentials.get(key) : undefined;
163
+ if (!credential)
164
+ throw new Error("This authenticator holds no credential.");
165
+ const imported = await crypto.subtle.importKey("raw", credential.prfSecret, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
166
+ const bytes = typeof salt === "string" ? new TextEncoder().encode(salt) : salt;
167
+ return new Uint8Array(await crypto.subtle.sign("HMAC", imported, bytes));
168
+ },
169
+ list: () => [...credentials.entries()].map(([id, credential]) => ({
170
+ id,
171
+ userHandle: credential.userHandle,
172
+ signCount: credential.signCount
173
+ })),
174
+ forget(credentialId) {
175
+ if (credentialId)
176
+ credentials.delete(credentialId);
177
+ else
178
+ credentials.clear();
179
+ }
180
+ };
181
+ }
182
+ var encoding = { b64url, fromB64url, toDer, cbor, sha256 };
183
+ export {
184
+ encoding,
185
+ createAuthenticator
186
+ };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Custodian threshold modes.
3
+ *
4
+ * A closed set chosen at ceremony time rather than free-form M and N. Two
5
+ * reasons: a typo like 7-of-3 is unrepresentable, and every mode here has been
6
+ * thought about — an operator picking numbers in a form has not.
7
+ */
8
+ export interface ThresholdMode {
9
+ id: string;
10
+ /** M — how many custodians must convene to unlock. */
11
+ threshold: number;
12
+ /** N — how many shares exist in total. */
13
+ total: number;
14
+ label: string;
15
+ description: string;
16
+ /**
17
+ * True when a single custodian can unlock alone. Shamir needs threshold >= 2,
18
+ * so these modes REPLICATE the seed instead of splitting it: every custodian
19
+ * holds the whole thing. That is a real weakening — one compromised custodian
20
+ * is a compromised vault — and it is why these are marked rather than mixed
21
+ * in silently.
22
+ */
23
+ replicated: boolean;
24
+ /** Stated on EVERY mode, not just the recommended one: an optional field
25
+ * disappears from the literal type where it is absent, and a consumer then
26
+ * cannot read it uniformly across the set. */
27
+ recommended: boolean;
28
+ }
29
+ export declare const THRESHOLD_MODES: readonly [{
30
+ readonly id: "1-of-2";
31
+ readonly threshold: 1;
32
+ readonly total: 2;
33
+ readonly label: "1 of 2";
34
+ readonly description: "Either custodian can unlock alone. Survives losing one person, but a single compromised custodian is enough to open the vault.";
35
+ readonly replicated: true;
36
+ readonly recommended: false;
37
+ }, {
38
+ readonly id: "1-of-3";
39
+ readonly threshold: 1;
40
+ readonly total: 3;
41
+ readonly label: "1 of 3";
42
+ readonly description: "Any custodian can unlock alone. Maximum availability, minimum protection against an insider.";
43
+ readonly replicated: true;
44
+ readonly recommended: false;
45
+ }, {
46
+ readonly id: "2-of-3";
47
+ readonly threshold: 2;
48
+ readonly total: 3;
49
+ readonly label: "2 of 3";
50
+ readonly description: "Two of three must convene. Survives losing one custodian, and no single person can unlock. The smallest genuinely split arrangement.";
51
+ readonly replicated: false;
52
+ readonly recommended: true;
53
+ }, {
54
+ readonly id: "3-of-5";
55
+ readonly threshold: 3;
56
+ readonly total: 5;
57
+ readonly label: "3 of 5";
58
+ readonly description: "Three of five must convene. Survives losing two custodians.";
59
+ readonly replicated: false;
60
+ readonly recommended: false;
61
+ }, {
62
+ readonly id: "4-of-7";
63
+ readonly threshold: 4;
64
+ readonly total: 7;
65
+ readonly label: "4 of 7";
66
+ readonly description: "Four of seven must convene. For larger governance groups.";
67
+ readonly replicated: false;
68
+ readonly recommended: false;
69
+ }];
70
+ export type ThresholdModeId = (typeof THRESHOLD_MODES)[number]['id'];
71
+ export declare function thresholdMode(id: string): ThresholdMode | undefined;
@@ -0,0 +1,55 @@
1
+ // src/ceremony-modes.ts
2
+ var THRESHOLD_MODES = [
3
+ {
4
+ id: "1-of-2",
5
+ threshold: 1,
6
+ total: 2,
7
+ label: "1 of 2",
8
+ description: "Either custodian can unlock alone. Survives losing one person, but a single compromised custodian is enough to open the vault.",
9
+ replicated: true,
10
+ recommended: false
11
+ },
12
+ {
13
+ id: "1-of-3",
14
+ threshold: 1,
15
+ total: 3,
16
+ label: "1 of 3",
17
+ description: "Any custodian can unlock alone. Maximum availability, minimum protection against an insider.",
18
+ replicated: true,
19
+ recommended: false
20
+ },
21
+ {
22
+ id: "2-of-3",
23
+ threshold: 2,
24
+ total: 3,
25
+ label: "2 of 3",
26
+ description: "Two of three must convene. Survives losing one custodian, and no single person can unlock. The smallest genuinely split arrangement.",
27
+ replicated: false,
28
+ recommended: true
29
+ },
30
+ {
31
+ id: "3-of-5",
32
+ threshold: 3,
33
+ total: 5,
34
+ label: "3 of 5",
35
+ description: "Three of five must convene. Survives losing two custodians.",
36
+ replicated: false,
37
+ recommended: false
38
+ },
39
+ {
40
+ id: "4-of-7",
41
+ threshold: 4,
42
+ total: 7,
43
+ label: "4 of 7",
44
+ description: "Four of seven must convene. For larger governance groups.",
45
+ replicated: false,
46
+ recommended: false
47
+ }
48
+ ];
49
+ function thresholdMode(id) {
50
+ return THRESHOLD_MODES.find((mode) => mode.id === id);
51
+ }
52
+ export {
53
+ thresholdMode,
54
+ THRESHOLD_MODES
55
+ };
@@ -0,0 +1,76 @@
1
+ import type { RouteRegistry } from './index';
2
+ /**
3
+ * The typed client, and the other half of the 428 handshake.
4
+ *
5
+ * Generated from the same declarations the server enforces, so a route that
6
+ * changes its body shape changes the client's types in the same commit. There
7
+ * is no second source to drift from.
8
+ *
9
+ * ## Why the handshake lives here
10
+ *
11
+ * The server answers 428 with a one-time key and the factors it will accept.
12
+ * Something has to mint a proof and replay the original request with that key
13
+ * attached — and without it `stepUp` is a wall rather than a challenge. That
14
+ * was specified server-side for a long time with nobody implementing the other
15
+ * end.
16
+ *
17
+ * The replay MUST surface the action's own failure. Security passing while the
18
+ * write fails is otherwise completely silent: the caller sees a resolved
19
+ * promise, the record is unchanged, and nothing anywhere says why.
20
+ */
21
+ export interface Problem {
22
+ code: string;
23
+ message: string;
24
+ }
25
+ export type ClientResult<T> = {
26
+ ok: true;
27
+ status: number;
28
+ data: T;
29
+ } | {
30
+ ok: false;
31
+ status: number;
32
+ error: Problem;
33
+ errors?: readonly {
34
+ path: string;
35
+ message: string;
36
+ }[];
37
+ };
38
+ export interface SecurityChallenge {
39
+ scope: 'session' | 'action';
40
+ route: string;
41
+ factors: readonly string[];
42
+ required: number;
43
+ requestKey: string;
44
+ }
45
+ /**
46
+ * Turns a challenge into proofs. Supplied by the host, because how a passkey or
47
+ * a TOTP code is collected is a UI decision the client cannot make.
48
+ *
49
+ * Returning `undefined` means the human declined — a cancel, not an error, and
50
+ * the original 428 is surfaced unchanged.
51
+ */
52
+ export interface ChallengeHandler {
53
+ (challenge: SecurityChallenge): Promise<{
54
+ factor: string;
55
+ proof: unknown;
56
+ }[] | undefined>;
57
+ }
58
+ export interface ClientOptions {
59
+ baseUrl: string;
60
+ onChallenge?: ChallengeHandler;
61
+ fetch?: typeof globalThis.fetch;
62
+ headers?: Record<string, string>;
63
+ /** Path that accepts a proof against a request key. */
64
+ fulfilPath?: string;
65
+ }
66
+ export interface RequestArgs {
67
+ params?: Record<string, string>;
68
+ query?: Record<string, unknown>;
69
+ body?: unknown;
70
+ headers?: Record<string, string>;
71
+ }
72
+ /** `api/orders/[id]` + `{id:'7'}` → `/api/orders/7` */
73
+ export declare function buildPath(routeKey: string, params?: Record<string, string>): string;
74
+ export declare function createClient<R extends RouteRegistry>(routes: R, options: ClientOptions): { [K in keyof R as R[K] extends {
75
+ kind: "action";
76
+ } ? K : never]: (args?: RequestArgs) => Promise<ClientResult<unknown>>; };