@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/dist/client.js ADDED
@@ -0,0 +1,107 @@
1
+ // src/client.ts
2
+ function buildPath(routeKey, params = {}) {
3
+ return "/" + routeKey.split("/").map((segment) => {
4
+ const named = /^\[(?:\.\.\.)?(\w+)\]$/.exec(segment);
5
+ if (!named)
6
+ return segment;
7
+ const value = params[named[1]];
8
+ if (value === undefined) {
9
+ throw new Error(`Missing route parameter "${named[1]}" for ${routeKey}`);
10
+ }
11
+ return encodeURIComponent(value);
12
+ }).join("/");
13
+ }
14
+ function withQuery(path, query) {
15
+ if (!query)
16
+ return path;
17
+ const search = new URLSearchParams;
18
+ for (const [name, value] of Object.entries(query)) {
19
+ if (value !== undefined && value !== null)
20
+ search.set(name, String(value));
21
+ }
22
+ const encoded = search.toString();
23
+ return encoded ? `${path}?${encoded}` : path;
24
+ }
25
+ function createClient(routes, options) {
26
+ const doFetch = options.fetch ?? globalThis.fetch;
27
+ const fulfilPath = options.fulfilPath ?? "/api/security/fulfil";
28
+ async function send(routeKey, method, args, requestKey) {
29
+ const path = withQuery(buildPath(routeKey, args.params), args.query);
30
+ const response = await doFetch(options.baseUrl + path, {
31
+ method,
32
+ credentials: "include",
33
+ headers: {
34
+ "content-type": "application/json",
35
+ ...options.headers,
36
+ ...args.headers,
37
+ ...requestKey ? { "x-security-request-key": requestKey } : {}
38
+ },
39
+ body: args.body === undefined ? undefined : JSON.stringify(args.body)
40
+ });
41
+ const payload = await response.json().catch(() => {
42
+ return;
43
+ });
44
+ if (response.ok) {
45
+ return { ok: true, status: response.status, data: payload };
46
+ }
47
+ if (response.status === 428) {
48
+ const key = response.headers.get("x-security-request-key");
49
+ if (key) {
50
+ return {
51
+ ok: false,
52
+ status: 428,
53
+ error: payload?.error ?? { code: "SECURITY_REQUIRED", message: "Security check required." },
54
+ errors: payload?.security ? [{ path: "", message: JSON.stringify({ ...payload.security, requestKey: key }) }] : undefined
55
+ };
56
+ }
57
+ }
58
+ return {
59
+ ok: false,
60
+ status: response.status,
61
+ error: payload?.error ?? { code: "REQUEST_FAILED", message: response.statusText },
62
+ errors: payload?.errors
63
+ };
64
+ }
65
+ async function call(routeKey, method, args) {
66
+ const first = await send(routeKey, method, args);
67
+ if (first.ok || first.status !== 428 || !options.onChallenge)
68
+ return first;
69
+ const encoded = first.errors?.[0]?.message;
70
+ if (!encoded)
71
+ return first;
72
+ const challenge = JSON.parse(encoded);
73
+ const proofs = await options.onChallenge(challenge);
74
+ if (!proofs?.length)
75
+ return first;
76
+ const fulfilled = await doFetch(options.baseUrl + fulfilPath, {
77
+ method: "POST",
78
+ credentials: "include",
79
+ headers: { "content-type": "application/json", ...options.headers },
80
+ body: JSON.stringify({ requestKey: challenge.requestKey, proofs })
81
+ });
82
+ if (!fulfilled.ok) {
83
+ const payload = await fulfilled.json().catch(() => {
84
+ return;
85
+ });
86
+ return {
87
+ ok: false,
88
+ status: fulfilled.status,
89
+ error: payload?.error ?? { code: "SECURITY_FAILED", message: "The security check was not accepted." }
90
+ };
91
+ }
92
+ return send(routeKey, method, args, challenge.requestKey);
93
+ }
94
+ const api = {};
95
+ for (const key of Object.keys(routes)) {
96
+ const route = routes[key];
97
+ if (route.kind !== "action")
98
+ continue;
99
+ const method = route.method;
100
+ api[key] = (args = {}) => call(key, method, args);
101
+ }
102
+ return api;
103
+ }
104
+ export {
105
+ createClient,
106
+ buildPath
107
+ };
@@ -0,0 +1,280 @@
1
+ import { type BeforeHandler, type RequestContext } from './index';
2
+ /**
3
+ * Conditions — the reusable `before` catalog.
4
+ *
5
+ * Every project rewrites the same twelve guards: load the record or 404, check
6
+ * it belongs to the caller, check its status allows this transition, check the
7
+ * balance, check the idempotency key, check the version. Written per route they
8
+ * drift; written per project they drift between projects. Written once here
9
+ * they are one thing to get right, and every project that adopts them inherits
10
+ * the fixes.
11
+ *
12
+ * ## What a condition is
13
+ *
14
+ * A `BeforeHandler` that either merges state or throws a `Refusal` carrying the
15
+ * status the client deserves. It never returns a response and never decides
16
+ * business outcomes — it decides whether the handler is allowed to run at all.
17
+ *
18
+ * ## Why they declare their refusals
19
+ *
20
+ * Each carries `refusals`, so the set of statuses a route can produce is
21
+ * derivable rather than hand-maintained. `assertDeclaredRefusals` uses it to
22
+ * fail the build when a route's `response` map omits a status its own
23
+ * conditions can throw — the drift that produces a client crashing on a 409 it
24
+ * was never told about.
25
+ *
26
+ * ## Order matters and is not enforced here
27
+ *
28
+ * `loadTarget` must precede anything reading the record. The pipeline runs the
29
+ * chain in declaration order and merges state as it goes, so the ordering lives
30
+ * in the declaration list where it is visible, not in a dependency graph nobody
31
+ * reads.
32
+ */
33
+ export interface RefusalSpec {
34
+ status: number;
35
+ code: string;
36
+ /** One clause, present tense: what has to be true for this to fire. */
37
+ when: string;
38
+ }
39
+ export interface Condition<K extends string> extends BeforeHandler<K> {
40
+ /** Stable name — appears in flow documentation and telemetry. */
41
+ condition: string;
42
+ refusals: readonly RefusalSpec[];
43
+ }
44
+ type State = Record<string, unknown>;
45
+ type Routes<K extends string> = {
46
+ routes: readonly K[];
47
+ };
48
+ export interface LoadTargetOptions<K extends string> extends Routes<K> {
49
+ /** State key the record lands under. Defaults to `target`. */
50
+ as?: string;
51
+ /** Where the identifier comes from. Defaults to a `id` path parameter. */
52
+ id?: (context: RequestContext) => string | undefined;
53
+ load: (id: string, context: RequestContext) => Promise<unknown> | unknown;
54
+ /** Refusal code. Defaults to NOT_FOUND — see the note on 404 below. */
55
+ code?: string;
56
+ }
57
+ /**
58
+ * Load the record this request acts on, or 404.
59
+ *
60
+ * First in almost every chain, for two reasons beyond the obvious. Step 8 binds
61
+ * a step-up proof to a specific record, which requires the record; and a
62
+ * condition that reads the database twice — once to check, once in the handler —
63
+ * is a TOCTOU window somebody will eventually land in.
64
+ */
65
+ export declare function loadTarget<K extends string>(options: LoadTargetOptions<K>): Condition<K>;
66
+ export interface RequireOwnerOptions<K extends string> extends Routes<K> {
67
+ of?: string;
68
+ owner: (record: never) => string | undefined;
69
+ /** Defaults to the session user. A tenant app passes the realm instead. */
70
+ subject?: (context: RequestContext) => string | undefined;
71
+ }
72
+ /**
73
+ * The record belongs to the caller — otherwise 404, deliberately not 403.
74
+ *
75
+ * A 403 confirms the record exists, which turns any sequential identifier into
76
+ * an enumeration oracle: an attacker walks the range and learns exactly which
77
+ * accounts, orders or entries are real. Answering 404 makes "not yours" and
78
+ * "not there" indistinguishable, which is the only honest answer to give
79
+ * somebody who should not know either way.
80
+ */
81
+ export declare function requireOwner<K extends string>(options: RequireOwnerOptions<K>): Condition<K>;
82
+ export interface RequireStateOptions<K extends string> extends Routes<K> {
83
+ of?: string;
84
+ status: (record: never) => string;
85
+ allowed: readonly string[];
86
+ code?: string;
87
+ }
88
+ /**
89
+ * The record is in a status this transition accepts, or 409.
90
+ *
91
+ * The guard that stops a withdrawal being approved twice, an invoice being
92
+ * settled after it was voided, a ceremony activating from a state it never
93
+ * reached. Checking status inside the handler works right up to the second
94
+ * concurrent request, and this at least fails them both loudly and with the
95
+ * allowed set in the payload so the UI can explain itself.
96
+ */
97
+ export declare function requireState<K extends string>(options: RequireStateOptions<K>): Condition<K>;
98
+ export interface RequireVersionOptions<K extends string> extends Routes<K> {
99
+ of?: string;
100
+ version: (record: never) => string | number;
101
+ /** Defaults to `If-Match`, falling back to a `version` body field. */
102
+ expected?: (context: RequestContext) => string | number | undefined;
103
+ }
104
+ /**
105
+ * Optimistic concurrency — 412 when the record moved under the caller.
106
+ *
107
+ * Last-write-wins is not a decision anybody makes on purpose; it is what you get
108
+ * by not making one. Two operators editing the same policy silently lose one of
109
+ * the edits, and nothing anywhere records that it happened.
110
+ */
111
+ export declare function requireVersion<K extends string>(options: RequireVersionOptions<K>): Condition<K>;
112
+ export interface IdempotencyStore {
113
+ /**
114
+ * Claim the key. `fresh` means proceed; `replay` carries the stored answer;
115
+ * `pending` means an identical request is still running.
116
+ *
117
+ * Must be atomic — a get-then-set implementation reintroduces exactly the
118
+ * double-spend this exists to prevent.
119
+ */
120
+ claim(key: string, route: string): Promise<{
121
+ state: 'fresh';
122
+ } | {
123
+ state: 'replay';
124
+ value: unknown;
125
+ status: number;
126
+ } | {
127
+ state: 'pending';
128
+ }>;
129
+ }
130
+ export interface RequireIdempotencyOptions<K extends string> extends Routes<K> {
131
+ store: IdempotencyStore;
132
+ /** Defaults to the `Idempotency-Key` header. */
133
+ key?: (context: RequestContext) => string | undefined;
134
+ /** True refuses a request without a key. Correct for anything that moves money. */
135
+ strict?: boolean;
136
+ }
137
+ /**
138
+ * At-most-once, for requests that must not happen twice.
139
+ *
140
+ * A retry after a timeout is not a client bug — it is what every HTTP client and
141
+ * every mobile network does. Without a claim the second attempt places a second
142
+ * order, and the user sees one confirmation for two withdrawals.
143
+ *
144
+ * A replay throws `Settled`, so the caller gets the ORIGINAL answer rather than
145
+ * an error. A 409 would be defensible and useless: a retrying client still would
146
+ * not learn the transfer succeeded.
147
+ */
148
+ export declare function requireIdempotency<K extends string>(options: RequireIdempotencyOptions<K>): Condition<K>;
149
+ export interface RequireUnlockedOptions<K extends string> extends Routes<K> {
150
+ unlocked: () => boolean | Promise<boolean>;
151
+ }
152
+ /**
153
+ * The vault is unlocked, or 423.
154
+ *
155
+ * 423 rather than 503 because the distinction is actionable: 503 says wait, 423
156
+ * says a human must unlock something. A client that retries a 423 forever is a
157
+ * client nobody told the difference.
158
+ */
159
+ export declare function requireUnlocked<K extends string>(options: RequireUnlockedOptions<K>): Condition<K>;
160
+ export interface RequireServiceOptions<K extends string> extends Routes<K> {
161
+ service: string;
162
+ /** Satisfied by a `@forgezero/providers` registry health check. */
163
+ healthy: (service: string) => boolean | Promise<boolean>;
164
+ }
165
+ /**
166
+ * A provider for this service is reachable, or 503 — checked before the handler
167
+ * does work it will have to undo.
168
+ *
169
+ * The failure this prevents is subtle: a route that writes a record and then
170
+ * sends an email leaves the record behind when every relay is down, and the
171
+ * user retries into a duplicate.
172
+ */
173
+ export declare function requireService<K extends string>(options: RequireServiceOptions<K>): Condition<K>;
174
+ export interface RequireNotFrozenOptions<K extends string> extends Routes<K> {
175
+ /** Return a reason to freeze; undefined to allow. */
176
+ frozen: (context: RequestContext, state: State) => Promise<string | undefined> | string | undefined;
177
+ }
178
+ /**
179
+ * Nothing has administratively halted this subject, or 423.
180
+ *
181
+ * Separate from status because it is orthogonal: an order can be perfectly valid
182
+ * and still not permitted to move because the account is under review, the asset
183
+ * is halted, or an operator pulled the switch. Folding it into a status enum
184
+ * means every state machine grows a `frozen` member and every transition has to
185
+ * remember it.
186
+ */
187
+ export declare function requireNotFrozen<K extends string>(options: RequireNotFrozenOptions<K>): Condition<K>;
188
+ export interface RequireTierOptions<K extends string> extends Routes<K> {
189
+ atLeast: number;
190
+ tier: (context: RequestContext, state: State) => Promise<number> | number;
191
+ /** Names the ladder, so the client knows where to send the user. */
192
+ ladder?: string;
193
+ code?: string;
194
+ }
195
+ /**
196
+ * The subject has reached a level, or 403 naming the gap.
197
+ *
198
+ * One mechanism, two uses that are the same shape: a plan tier gating a feature,
199
+ * and a verification tier gating an amount. Both are "a monotonic level the
200
+ * subject must have reached", and both need the CURRENT level in the refusal or
201
+ * the UI cannot render anything better than "denied".
202
+ */
203
+ export declare function requireTier<K extends string>(options: RequireTierOptions<K>): Condition<K>;
204
+ export interface RequireBalanceOptions<K extends string> extends Routes<K> {
205
+ /** Minor units. Never a float — see the note below. */
206
+ amount: (context: RequestContext, state: State) => Promise<bigint> | bigint;
207
+ available: (context: RequestContext, state: State) => Promise<bigint> | bigint;
208
+ asset?: (context: RequestContext, state: State) => string | undefined;
209
+ }
210
+ /**
211
+ * Enough available balance, or 409.
212
+ *
213
+ * `bigint` minor units, not `number`. A double cannot hold 0.1 + 0.2, and it
214
+ * cannot hold satoshi precision above ~90 million BTC-equivalents at all; the
215
+ * rounding shows up as a ledger that fails to balance by a few units a month
216
+ * and nobody can say why. Refusing a float at the type level is cheaper than
217
+ * finding that later.
218
+ *
219
+ * This checks AVAILABLE — free of holds — not total. A balance check against
220
+ * total lets two concurrent withdrawals each pass and the second overdraws.
221
+ */
222
+ export declare function requireBalance<K extends string>(options: RequireBalanceOptions<K>): Condition<K>;
223
+ export interface RequireQuotaOptions<K extends string> extends Routes<K> {
224
+ /** `24h`, `30d` — documentation for the client, the store owns the window. */
225
+ window: string;
226
+ cap: (context: RequestContext, state: State) => Promise<bigint> | bigint;
227
+ amount: (context: RequestContext, state: State) => Promise<bigint> | bigint;
228
+ used: (context: RequestContext, state: State) => Promise<bigint> | bigint;
229
+ code?: string;
230
+ }
231
+ /**
232
+ * A value cap over a window, which is NOT a rate limit.
233
+ *
234
+ * Rate limiting counts requests and protects the service; a quota counts value
235
+ * and protects the account. One withdrawal of the entire balance passes every
236
+ * rate limit ever written. They also fail differently — 429 means slow down,
237
+ * this means the answer will be the same until the window rolls — so they get
238
+ * different codes and the reset time is in the payload.
239
+ */
240
+ export declare function requireQuota<K extends string>(options: RequireQuotaOptions<K>): Condition<K>;
241
+ export interface ApprovalState {
242
+ status: 'pending' | 'approved' | 'rejected';
243
+ approvals?: number;
244
+ required?: number;
245
+ reason?: string;
246
+ }
247
+ export interface RequireApprovalOptions<K extends string> extends Routes<K> {
248
+ approval: (context: RequestContext, state: State) => Promise<ApprovalState> | ApprovalState;
249
+ }
250
+ /**
251
+ * Somebody ELSE approved this — which is not what action factors check.
252
+ *
253
+ * Action factors make the actor prove themselves freshly. This makes a second
254
+ * party sign off, and no amount of the first kind substitutes for the second:
255
+ * that is the entire point of four-eyes on a large withdrawal or a custodian
256
+ * change. Two mechanisms because they answer different questions, and merging
257
+ * them would let a sufficiently authenticated operator approve their own move.
258
+ */
259
+ export declare function requireApproval<K extends string>(options: RequireApprovalOptions<K>): Condition<K>;
260
+ /**
261
+ * Every status a route's conditions can throw is declared in its contract.
262
+ *
263
+ * The drift this catches: somebody adds `requireQuota` to a withdrawal route,
264
+ * the generated client never learns 409 is possible, and the UI renders "an
265
+ * unexpected error occurred" for the one refusal a user can actually act on.
266
+ * Cheap to check, invisible until a customer hits it.
267
+ */
268
+ export declare function assertDeclaredRefusals(args: {
269
+ routes: Record<string, {
270
+ response?: Record<number, unknown>;
271
+ }>;
272
+ conditions: readonly Condition<string>[];
273
+ /** Statuses handled centrally rather than per route. */
274
+ ignore?: readonly number[];
275
+ }): void;
276
+ /** Every refusal a route can produce, for documentation and generated clients. */
277
+ export declare function refusalsFor(routeKey: string, conditions: readonly Condition<string>[]): readonly (RefusalSpec & {
278
+ condition: string;
279
+ })[];
280
+ export {};