@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.
@@ -0,0 +1,379 @@
1
+ /**
2
+ * @forgezero/access — declare your security posture as seven lists.
3
+ *
4
+ * Zero runtime dependencies. The contract is `fetch` types plus plain JSON
5
+ * Schema, so the core runs on Bun, Node 18+, Cloudflare Workers, Deno and every
6
+ * runtime implementing the WinterTC baseline. Adapters are glue over it.
7
+ *
8
+ * ## The seven declarations
9
+ *
10
+ * 1 FACTORS how a proof is verified
11
+ * 2 ROUTES every route, with its full request/response contract
12
+ * 3 SESSION POLICIES who you must be → bind route keys
13
+ * 4 ACTION POLICIES fresh proof for this call → bind route keys
14
+ * 5 BEFORE HANDLERS run first, produce state → bind route keys
15
+ * 6 AFTER HANDLERS run last, queued → bind route keys
16
+ * 7 RATE POLICIES how often → bind route keys
17
+ *
18
+ * Lists 3–7 are ORTHOGONAL. A route's session requirement has nothing to do
19
+ * with which `before` it runs, so each list is auditable on its own — "which
20
+ * routes need a fresh proof" is one list, not a scan of every declaration.
21
+ *
22
+ * The alternative was security declared per route. It is unauditable: a new
23
+ * route can silently omit a factor, and reviewing the surface means reading
24
+ * every handler. Here, omission fails the build.
25
+ *
26
+ * This file must never import a sibling package. `packages.test.ts` enforces it.
27
+ */
28
+ /**
29
+ * Present from the first release with no migrations, because adding a version
30
+ * field later means guessing what unversioned data meant — and every consumer
31
+ * who stored a policy before v2 becomes a support ticket.
32
+ */
33
+ export declare const SCHEMA_VERSION: 1;
34
+ export interface Versioned {
35
+ /** Absent means 1 — the release before the field was read. */
36
+ version?: number;
37
+ }
38
+ /**
39
+ * Refuses documents NEWER than this build rather than ignoring unknown fields.
40
+ * Silently dropping a field is how a v2 policy that added a factor gets
41
+ * enforced as if it had none.
42
+ */
43
+ export declare function assertReadable(document: Versioned, what: string): void;
44
+ export declare class AccessError extends Error {
45
+ readonly code: string;
46
+ constructor(code: string, message: string);
47
+ }
48
+ /**
49
+ * A condition refusing the request, with the status it deserves.
50
+ *
51
+ * Without this every guard in a `before` chain collapsed to 500, which is a lie:
52
+ * "this withdrawal is already approved" is a 409 the client can act on, and a
53
+ * 500 tells it to retry — which is precisely the wrong thing to do with money.
54
+ *
55
+ * `retryable` is stated rather than inferred from the status. 409 is permanent
56
+ * for a state conflict and transient for a lock held by another request, and
57
+ * only the condition that threw knows which.
58
+ */
59
+ export declare class Refusal extends Error {
60
+ readonly status: number;
61
+ readonly code: string;
62
+ readonly details: Record<string, unknown>;
63
+ readonly retryable: boolean;
64
+ /** Branded so `isRefusal` survives two copies of this package in one process. */
65
+ readonly refusal: true;
66
+ constructor(status: number, code: string, message: string, details?: Record<string, unknown>, retryable?: boolean);
67
+ }
68
+ export declare function isRefusal(error: unknown): error is Refusal;
69
+ /**
70
+ * The request is already answered — stop, and return this.
71
+ *
72
+ * An idempotent replay is not a refusal and not an error: the caller asked for
73
+ * something that already happened and is owed the original answer. Modelling it
74
+ * as a 409 would be correct-ish and useless, because a retrying client would
75
+ * still not learn the transfer succeeded.
76
+ */
77
+ export declare class Settled extends Error {
78
+ readonly value: unknown;
79
+ readonly status: number;
80
+ readonly headers: Record<string, string>;
81
+ readonly settled: true;
82
+ constructor(value: unknown, status?: number, headers?: Record<string, string>);
83
+ }
84
+ export declare function isSettled(error: unknown): error is Settled;
85
+ export type FactorKind = 'session' | 'action' | 'both';
86
+ export interface FactorContext {
87
+ /**
88
+ * Server-issued and single-use. A fixed challenge makes a captured proof
89
+ * replayable forever, which is why key DERIVATION and AUTHENTICATION must
90
+ * never share one.
91
+ */
92
+ challenge: string;
93
+ userKey?: string;
94
+ secret(name: string): Promise<string>;
95
+ }
96
+ export interface FactorSpec<Proof = unknown> extends Versioned {
97
+ kind: FactorKind;
98
+ label: string;
99
+ /** JSON Schema. The admin UI renders its fields; `writeOnly` goes to a vault. */
100
+ credentials?: Record<string, unknown>;
101
+ verify(context: FactorContext, proof: Proof): Promise<boolean>;
102
+ /** Delivery tracking, so "is this method actually tested" is a query. */
103
+ status?: 'shipped' | 'active' | 'planned';
104
+ tests?: readonly string[];
105
+ surface?: string;
106
+ }
107
+ export type FactorRegistry = Record<string, FactorSpec<never>>;
108
+ export declare function defineFactors<F extends FactorRegistry>(factors: F): F;
109
+ export type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
110
+ /** One shared refusal shape, so `ok:false` narrowing works across every route. */
111
+ export interface Problem {
112
+ ok: false;
113
+ error: {
114
+ code: string;
115
+ message: string;
116
+ };
117
+ }
118
+ export interface RouteContract {
119
+ params?: unknown;
120
+ query?: unknown;
121
+ body?: unknown;
122
+ headers?: unknown;
123
+ /**
124
+ * Keyed by status, not one success type. A route that can 409 and does not
125
+ * say so produces a client that crashes on it — and a generated client can
126
+ * only narrow correctly when every branch is declared.
127
+ */
128
+ response?: Record<number, unknown>;
129
+ }
130
+ export interface RouteBase extends RouteContract {
131
+ label: string;
132
+ /** Disabled feature → 404, never 403. A 403 confirms the route exists. */
133
+ feature?: string;
134
+ /** Outside its stage → 404, for the same reason. */
135
+ stages?: readonly string[];
136
+ /** Wrong realm → 404. Stops a tenant session being scored against platform routes. */
137
+ realms?: readonly string[];
138
+ /**
139
+ * Named preconditions that must hold before the route is reachable at all,
140
+ * independent of who is asking.
141
+ *
142
+ * Deliberately opaque strings rather than an enum. A secrets platform gates
143
+ * on `vault-unlocked`, a marketplace on `kyc-complete`, a game on
144
+ * `season-open` — the package cannot know the vocabulary and has no business
145
+ * inventing one. It carries the declaration; the host decides what each name
146
+ * means and supplies the answers to `authorise`.
147
+ *
148
+ * An unmet gate is 423, not 403: the caller's rights are fine and something
149
+ * has to change in the system, not in their session.
150
+ */
151
+ gates?: readonly string[];
152
+ /**
153
+ * How a grant on an ancestor is treated.
154
+ *
155
+ * `inherit` — holding `orders` covers `orders/refund` and everything under it.
156
+ * `own` — only a grant naming this exact key covers it.
157
+ *
158
+ * `own` exists because inheritance is the right default and the wrong
159
+ * behaviour for the handful of routes that are genuinely more dangerous than
160
+ * their parent. Granting somebody the settings page should not silently hand
161
+ * them the one action under it that deletes the tenant.
162
+ */
163
+ grantMode?: 'inherit' | 'own';
164
+ /**
165
+ * Another route key whose grant ALSO grants this one.
166
+ *
167
+ * For pairs where a permission genuinely implies another and the key names do
168
+ * not nest — issuing a credit implies being able to debit it back.
169
+ */
170
+ sharesGrantWith?: string;
171
+ }
172
+ export interface PageRoute extends RouteBase {
173
+ kind: 'page';
174
+ icon?: string;
175
+ nav?: string;
176
+ /** Heading this item sits under, so navigation cannot disagree with the matrix. */
177
+ navSection?: string;
178
+ }
179
+ export interface ActionRoute extends RouteBase {
180
+ kind: 'action';
181
+ method: Method;
182
+ /** Owning page. Drives grant inheritance across the `api/` boundary. */
183
+ page?: string;
184
+ }
185
+ export type RouteSpec = PageRoute | ActionRoute;
186
+ export type RouteRegistry = Record<string, RouteSpec>;
187
+ export declare function page(label: string, options?: Omit<Partial<PageRoute>, 'kind' | 'label'>): PageRoute;
188
+ export declare function action(label: string, method: Method, options?: Omit<Partial<ActionRoute>, 'kind' | 'label' | 'method'>): ActionRoute;
189
+ export declare function defineRoutes<R extends RouteRegistry>(routes: R): R;
190
+ /** Every binding names route keys, and every key is checked against ROUTES. */
191
+ export interface Binding<K extends string> {
192
+ routes: readonly K[];
193
+ }
194
+ export interface SessionPolicy<K extends string> extends Binding<K>, Versioned {
195
+ /** Empty means public. */
196
+ factors: readonly string[];
197
+ }
198
+ export interface ActionPolicy<K extends string> extends Binding<K>, Versioned {
199
+ factors: readonly string[];
200
+ /**
201
+ * A COUNT, not a mode: 1 is any-one, `factors.length` is all-of, and
202
+ * anything between is M-of-N with no special case.
203
+ */
204
+ required: number;
205
+ /** Binds the proof to one record. A key minted for A cannot authorise B. */
206
+ target?: string;
207
+ }
208
+ export interface BeforeHandler<K extends string> extends Binding<K> {
209
+ run(context: RequestContext, state: Record<string, unknown>): unknown;
210
+ }
211
+ export interface AfterHandler<K extends string> extends Binding<K> {
212
+ run(context: RequestContext, result: unknown, state: Record<string, unknown>): void;
213
+ }
214
+ export interface RatePolicy<K extends string> extends Binding<K>, Versioned {
215
+ /** `30s`, `1m`, `15m`, `1h`, `1d`. */
216
+ window: string;
217
+ limit: number;
218
+ by: 'ip' | 'session' | 'apiKey' | 'realm';
219
+ }
220
+ export interface RequestContext {
221
+ route: string;
222
+ method: Method;
223
+ url: URL;
224
+ params: Record<string, string>;
225
+ query: Record<string, unknown>;
226
+ body: unknown;
227
+ headers: Headers;
228
+ session?: SessionContext;
229
+ realm?: string;
230
+ stage?: string;
231
+ ok(value: unknown): Response;
232
+ }
233
+ export interface SessionContext {
234
+ userKey: string;
235
+ roleKeys: readonly string[];
236
+ factors: readonly string[];
237
+ }
238
+ export interface Role extends Versioned {
239
+ roleKey: string;
240
+ label: string;
241
+ grants: readonly string[];
242
+ }
243
+ /**
244
+ * Where roles come from. Two implementations ship; a ForgeZero vault is a third
245
+ * somebody writes. The package never learns which store it talks to.
246
+ */
247
+ export interface PolicySource {
248
+ roles(): Promise<readonly Role[]>;
249
+ enabledFactors(): Promise<readonly string[]>;
250
+ }
251
+ export declare function codePolicy(config: {
252
+ roles: readonly Role[];
253
+ enabledFactors: readonly string[];
254
+ }): PolicySource;
255
+ export declare function externalPolicy(reader: PolicySource): PolicySource;
256
+ /**
257
+ * A route key reduced to the thing a grant actually names.
258
+ *
259
+ * Two reductions, and both exist because a grant is a PERMISSION and a route key
260
+ * is a URL — they are not the same vocabulary and should not be forced to be.
261
+ *
262
+ * The `api/` prefix goes because holding `orders` should cover the page and
263
+ * every action behind it; making a role list both is bookkeeping that drifts.
264
+ *
265
+ * `strip` handles a routing prefix that carries no permission meaning. A
266
+ * multi-tenant platform serving `/t/acme/workspace` and `/workspace` from one
267
+ * matrix means the same permission in both places — leaving the prefix in would
268
+ * force every role to spell out a URL shape, and would orphan every tenant route
269
+ * from the page it belongs to. Optional, because a single-tenant app has no such
270
+ * prefix and should not pay for the concept.
271
+ */
272
+ export declare function stemOf(routeKey: string, strip?: RegExp): string;
273
+ export interface GrantOptions {
274
+ /** Defaults to `inherit`. */
275
+ mode?: 'inherit' | 'own';
276
+ /** Another key whose grant also covers this route. */
277
+ sharesGrantWith?: string;
278
+ /** Routing prefix with no permission meaning — see `stemOf`. */
279
+ strip?: RegExp;
280
+ }
281
+ /**
282
+ * Does any held grant cover this route?
283
+ *
284
+ * Default-DENY. A principal with no matching grant is refused, so creating a
285
+ * role grants nothing until somebody is actually given it.
286
+ */
287
+ export declare function grantsRoute(grants: readonly string[], routeKey: string, options?: GrantOptions): boolean;
288
+ /**
289
+ * Fresh proof is ALWAYS required. Returns nothing, on purpose.
290
+ *
291
+ * A five-minute `verifiedAt` timestamp looks equivalent and is not: a timestamp
292
+ * IS a persisted factor, so a left-open laptop or a stolen cookie replays the
293
+ * privileged action. Returning nothing makes that impossible by construction.
294
+ */
295
+ export declare function fulfilledActionFactors(): readonly string[];
296
+ export interface AccessControlConfig<R extends RouteRegistry> extends Versioned {
297
+ factors: FactorRegistry;
298
+ routes: R;
299
+ sessionPolicies: Record<string, SessionPolicy<Extract<keyof R, string>>>;
300
+ actionPolicies?: Record<string, ActionPolicy<Extract<keyof R, string>>>;
301
+ beforeHandlers?: Record<string, BeforeHandler<Extract<keyof R, string>>>;
302
+ afterHandlers?: Record<string, AfterHandler<Extract<keyof R, string>>>;
303
+ ratePolicies?: Record<string, RatePolicy<Extract<keyof R, string>>>;
304
+ features?: readonly string[];
305
+ source?: PolicySource;
306
+ }
307
+ export interface AccessControl<R extends RouteRegistry> {
308
+ version: number;
309
+ routes: R;
310
+ features: readonly string[];
311
+ source?: PolicySource;
312
+ keys(): readonly (keyof R & string)[];
313
+ get(key: string): RouteSpec | undefined;
314
+ exists(key: string, context?: {
315
+ stage?: string;
316
+ realm?: string;
317
+ }): boolean;
318
+ sessionPolicyFor(key: string): SessionPolicy<string> | undefined;
319
+ actionPolicyFor(key: string): ActionPolicy<string> | undefined;
320
+ ratePolicyFor(key: string): RatePolicy<string> | undefined;
321
+ beforeFor(key: string): readonly BeforeHandler<string>[];
322
+ afterFor(key: string): readonly AfterHandler<string>[];
323
+ }
324
+ /**
325
+ * Assemble and validate.
326
+ *
327
+ * Everything is checked at construction rather than at first request. A route
328
+ * whose required count exceeds its factor list can never be called, and finding
329
+ * that out in production means finding it out from a customer.
330
+ */
331
+ export declare function defineAccessControl<R extends RouteRegistry>(config: AccessControlConfig<R>): AccessControl<R>;
332
+ export type Outcome = {
333
+ allow: true;
334
+ } | {
335
+ allow: false;
336
+ status: 404 | 401 | 403 | 428 | 429;
337
+ code: string;
338
+ /** Which policy refused, so a 403 is debuggable without reading config. */
339
+ policy?: string;
340
+ missing?: readonly string[];
341
+ };
342
+ /**
343
+ * Layer 1 — does this caller reach the handler at all?
344
+ *
345
+ * Layer 2 runs later, after `before` has loaded the record a fresh proof binds
346
+ * to. Splitting them is what lets a proof name a target.
347
+ */
348
+ export declare function authorise<R extends RouteRegistry>(args: {
349
+ access: AccessControl<R>;
350
+ routeKey: string;
351
+ stage?: string;
352
+ realm?: string;
353
+ session?: SessionContext;
354
+ roles: readonly Role[];
355
+ }): Outcome;
356
+ /**
357
+ * Which of an action policy's factors this deployment can satisfy.
358
+ *
359
+ * If the intersection is smaller than the required count the route is
360
+ * UNAVAILABLE — never quietly weakened to whatever remains, which is the
361
+ * failure that turns disabling an unused login method into a silently
362
+ * downgraded refund flow.
363
+ */
364
+ export declare function resolveActionFactors(policy: ActionPolicy<string>, enabled: readonly string[]): {
365
+ available: readonly string[];
366
+ required: number;
367
+ satisfiable: boolean;
368
+ };
369
+ /**
370
+ * Which routes a proposed factor change would break — computed BEFORE saving,
371
+ * so an admin disabling a method sees the damage rather than discovering it
372
+ * during an incident.
373
+ */
374
+ export declare function impactOfDisabling<R extends RouteRegistry>(access: AccessControl<R>, enabledAfter: readonly string[]): readonly {
375
+ route: string;
376
+ available: number;
377
+ required: number;
378
+ }[];
379
+ export declare const VERSION = "0.1.0";
package/dist/index.js ADDED
@@ -0,0 +1,298 @@
1
+ // src/index.ts
2
+ var SCHEMA_VERSION = 1;
3
+ function assertReadable(document, what) {
4
+ const version = document.version ?? 1;
5
+ if (version > SCHEMA_VERSION) {
6
+ throw new AccessError("ACCESS_VERSION_TOO_NEW", `${what} is version ${version}; this build reads ${SCHEMA_VERSION}. Upgrade @forgezero/access.`);
7
+ }
8
+ }
9
+
10
+ class AccessError extends Error {
11
+ code;
12
+ constructor(code, message) {
13
+ super(message);
14
+ this.code = code;
15
+ this.name = "AccessError";
16
+ }
17
+ }
18
+
19
+ class Refusal extends Error {
20
+ status;
21
+ code;
22
+ details;
23
+ retryable;
24
+ refusal = true;
25
+ constructor(status, code, message, details = {}, retryable = false) {
26
+ super(message);
27
+ this.status = status;
28
+ this.code = code;
29
+ this.details = details;
30
+ this.retryable = retryable;
31
+ this.name = "Refusal";
32
+ }
33
+ }
34
+ function isRefusal(error) {
35
+ return typeof error === "object" && error !== null && error.refusal === true;
36
+ }
37
+
38
+ class Settled extends Error {
39
+ value;
40
+ status;
41
+ headers;
42
+ settled = true;
43
+ constructor(value, status = 200, headers = {}) {
44
+ super("Already settled.");
45
+ this.value = value;
46
+ this.status = status;
47
+ this.headers = headers;
48
+ this.name = "Settled";
49
+ }
50
+ }
51
+ function isSettled(error) {
52
+ return typeof error === "object" && error !== null && error.settled === true;
53
+ }
54
+ function defineFactors(factors) {
55
+ for (const [id, factor] of Object.entries(factors)) {
56
+ if (factor.status === "shipped" && !(factor.tests?.length ?? 0)) {
57
+ throw new AccessError("FACTOR_UNTESTED", `Factor "${id}" is marked shipped with no tests. A method nobody exercises is a claim.`);
58
+ }
59
+ }
60
+ return factors;
61
+ }
62
+ function page(label, options = {}) {
63
+ return { kind: "page", label, ...options };
64
+ }
65
+ function action(label, method, options = {}) {
66
+ return { kind: "action", label, method, ...options };
67
+ }
68
+ function defineRoutes(routes) {
69
+ for (const [key, route] of Object.entries(routes)) {
70
+ const isApi = key.startsWith("api/");
71
+ if (isApi !== (route.kind === "action")) {
72
+ throw new AccessError("ROUTE_KIND_MISMATCH", `"${key}": routes under api/ must be actions, and actions must be under api/.`);
73
+ }
74
+ if (route.kind === "action" && route.page && !(route.page in routes)) {
75
+ throw new AccessError("ROUTE_DANGLING_PAGE", `"${key}" names page "${route.page}", which does not exist.`);
76
+ }
77
+ if (route.kind === "action" && route.response) {
78
+ for (const status of Object.keys(route.response)) {
79
+ if (!route.response[Number(status)]) {
80
+ throw new AccessError("ROUTE_EMPTY_RESPONSE", `"${key}" declares status ${status} with no schema.`);
81
+ }
82
+ }
83
+ }
84
+ }
85
+ return routes;
86
+ }
87
+ function codePolicy(config) {
88
+ return {
89
+ roles: async () => config.roles,
90
+ enabledFactors: async () => config.enabledFactors
91
+ };
92
+ }
93
+ function externalPolicy(reader) {
94
+ return {
95
+ async roles() {
96
+ const roles = await reader.roles();
97
+ for (const role of roles)
98
+ assertReadable(role, `role "${role.roleKey}"`);
99
+ return roles;
100
+ },
101
+ enabledFactors: () => reader.enabledFactors()
102
+ };
103
+ }
104
+ function stemOf(routeKey, strip) {
105
+ const withoutApi = routeKey.startsWith("api/") ? routeKey.slice(4) : routeKey;
106
+ return strip ? withoutApi.replace(strip, "") : withoutApi;
107
+ }
108
+ function grantsRoute(grants, routeKey, options = {}) {
109
+ const { mode = "inherit", sharesGrantWith, strip } = options;
110
+ const stem = stemOf(routeKey, strip);
111
+ const shared = sharesGrantWith ? stemOf(sharesGrantWith, strip) : undefined;
112
+ return grants.some((grant) => {
113
+ const granted = stemOf(grant, strip);
114
+ if (stem === granted || granted === routeKey)
115
+ return true;
116
+ if (shared !== undefined && (granted === shared || granted === sharesGrantWith))
117
+ return true;
118
+ return mode === "inherit" && stem.startsWith(granted + "/");
119
+ });
120
+ }
121
+ function fulfilledActionFactors() {
122
+ return [];
123
+ }
124
+ function defineAccessControl(config) {
125
+ assertReadable(config, "access control");
126
+ const routeKeys = new Set(Object.keys(config.routes));
127
+ const actionKeys = Object.entries(config.routes).filter(([, route]) => route.kind === "action").map(([key]) => key);
128
+ const checkKeys = (listName, name, keys) => {
129
+ if (keys.length === 0) {
130
+ throw new AccessError("POLICY_ORPHANED", `${listName} "${name}" binds no routes.`);
131
+ }
132
+ for (const key of keys) {
133
+ if (!routeKeys.has(key)) {
134
+ throw new AccessError("POLICY_UNKNOWN_ROUTE", `${listName} "${name}" names "${key}", which is not a route.`);
135
+ }
136
+ }
137
+ };
138
+ const sessionOf = new Map;
139
+ for (const [name, policy] of Object.entries(config.sessionPolicies)) {
140
+ checkKeys("Session policy", name, policy.routes);
141
+ for (const key of policy.routes) {
142
+ if (sessionOf.has(key)) {
143
+ throw new AccessError("SESSION_DUPLICATE", `"${key}" is in more than one session policy.`);
144
+ }
145
+ sessionOf.set(key, policy);
146
+ }
147
+ for (const factor of policy.factors) {
148
+ const spec = config.factors[factor];
149
+ if (!spec)
150
+ throw new AccessError("FACTOR_UNKNOWN", `Session policy "${name}" names unknown factor "${factor}".`);
151
+ if (spec.kind === "action") {
152
+ throw new AccessError("FACTOR_WRONG_KIND", `"${factor}" is action-only and cannot establish a session.`);
153
+ }
154
+ }
155
+ }
156
+ const unclassified = [...routeKeys].filter((key) => !sessionOf.has(key));
157
+ if (unclassified.length > 0) {
158
+ throw new AccessError("SESSION_INCOMPLETE", `No session policy covers: ${unclassified.join(", ")}. Every route needs exactly one.`);
159
+ }
160
+ const actionOf = new Map;
161
+ for (const [name, policy] of Object.entries(config.actionPolicies ?? {})) {
162
+ checkKeys("Action policy", name, policy.routes);
163
+ if (policy.required < 1 || policy.required > policy.factors.length) {
164
+ throw new AccessError("ACTION_UNSATISFIABLE", `Action policy "${name}" requires ${policy.required} of ${policy.factors.length} factors.`);
165
+ }
166
+ for (const factor of policy.factors) {
167
+ const spec = config.factors[factor];
168
+ if (!spec)
169
+ throw new AccessError("FACTOR_UNKNOWN", `Action policy "${name}" names unknown factor "${factor}".`);
170
+ if (spec.kind === "session") {
171
+ throw new AccessError("FACTOR_WRONG_KIND", `"${factor}" is session-only and cannot authorise an action.`);
172
+ }
173
+ }
174
+ for (const key of policy.routes) {
175
+ if (actionOf.has(key)) {
176
+ throw new AccessError("ACTION_DUPLICATE", `"${key}" is in more than one action policy.`);
177
+ }
178
+ actionOf.set(key, policy);
179
+ }
180
+ }
181
+ const rateOf = new Map;
182
+ const rateLists = Object.entries(config.ratePolicies ?? {});
183
+ for (const [name, policy] of rateLists) {
184
+ checkKeys("Rate policy", name, policy.routes);
185
+ for (const key of policy.routes) {
186
+ if (rateOf.has(key)) {
187
+ throw new AccessError("RATE_DUPLICATE", `"${key}" is in more than one rate policy.`);
188
+ }
189
+ rateOf.set(key, policy);
190
+ }
191
+ }
192
+ if (rateLists.length > 0) {
193
+ const unrated = actionKeys.filter((key) => !rateOf.has(key));
194
+ if (unrated.length > 0) {
195
+ throw new AccessError("RATE_INCOMPLETE", `No rate policy covers: ${unrated.join(", ")}. Declaring any means declaring all.`);
196
+ }
197
+ }
198
+ const beforeOf = new Map;
199
+ for (const [name, handler] of Object.entries(config.beforeHandlers ?? {})) {
200
+ checkKeys("Before handler", name, handler.routes);
201
+ for (const key of handler.routes) {
202
+ beforeOf.set(key, [...beforeOf.get(key) ?? [], handler]);
203
+ }
204
+ }
205
+ const afterOf = new Map;
206
+ for (const [name, handler] of Object.entries(config.afterHandlers ?? {})) {
207
+ checkKeys("After handler", name, handler.routes);
208
+ for (const key of handler.routes) {
209
+ afterOf.set(key, [...afterOf.get(key) ?? [], handler]);
210
+ }
211
+ }
212
+ const features = config.features ?? [];
213
+ return {
214
+ version: config.version ?? SCHEMA_VERSION,
215
+ routes: config.routes,
216
+ features,
217
+ source: config.source,
218
+ keys: () => Object.keys(config.routes),
219
+ get: (key) => config.routes[key],
220
+ exists(key, context) {
221
+ const route = config.routes[key];
222
+ if (!route)
223
+ return false;
224
+ if (route.feature && !features.includes(route.feature))
225
+ return false;
226
+ if (context?.stage && route.stages && !route.stages.includes(context.stage))
227
+ return false;
228
+ if (context?.realm && route.realms && !route.realms.includes(context.realm))
229
+ return false;
230
+ return true;
231
+ },
232
+ sessionPolicyFor: (key) => sessionOf.get(key),
233
+ actionPolicyFor: (key) => actionOf.get(key),
234
+ ratePolicyFor: (key) => rateOf.get(key),
235
+ beforeFor: (key) => beforeOf.get(key) ?? [],
236
+ afterFor: (key) => afterOf.get(key) ?? []
237
+ };
238
+ }
239
+ function authorise(args) {
240
+ const { access, routeKey, stage, realm, session, roles } = args;
241
+ if (!access.exists(routeKey, { stage, realm })) {
242
+ return { allow: false, status: 404, code: "NOT_FOUND" };
243
+ }
244
+ const policy = access.sessionPolicyFor(routeKey);
245
+ if (!policy || policy.factors.length === 0)
246
+ return { allow: true };
247
+ if (!session)
248
+ return { allow: false, status: 401, code: "AUTH_REQUIRED" };
249
+ const missing = policy.factors.filter((factor) => !session.factors.includes(factor));
250
+ if (missing.length > 0) {
251
+ return { allow: false, status: 428, code: "SECURITY_REQUIRED", missing };
252
+ }
253
+ const grants = roles.filter((role) => session.roleKeys.includes(role.roleKey)).flatMap((role) => role.grants);
254
+ if (!grantsRoute(grants, routeKey)) {
255
+ return { allow: false, status: 403, code: "ACCESS_DENIED" };
256
+ }
257
+ return { allow: true };
258
+ }
259
+ function resolveActionFactors(policy, enabled) {
260
+ const available = policy.factors.filter((factor) => enabled.includes(factor));
261
+ return { available, required: policy.required, satisfiable: available.length >= policy.required };
262
+ }
263
+ function impactOfDisabling(access, enabledAfter) {
264
+ const broken = [];
265
+ for (const key of access.keys()) {
266
+ const policy = access.actionPolicyFor(key);
267
+ if (!policy)
268
+ continue;
269
+ const { available, required, satisfiable } = resolveActionFactors(policy, enabledAfter);
270
+ if (!satisfiable)
271
+ broken.push({ route: key, available: available.length, required });
272
+ }
273
+ return broken;
274
+ }
275
+ var VERSION = "0.1.0";
276
+ export {
277
+ stemOf,
278
+ resolveActionFactors,
279
+ page,
280
+ isSettled,
281
+ isRefusal,
282
+ impactOfDisabling,
283
+ grantsRoute,
284
+ fulfilledActionFactors,
285
+ externalPolicy,
286
+ defineRoutes,
287
+ defineFactors,
288
+ defineAccessControl,
289
+ codePolicy,
290
+ authorise,
291
+ assertReadable,
292
+ action,
293
+ VERSION,
294
+ Settled,
295
+ SCHEMA_VERSION,
296
+ Refusal,
297
+ AccessError
298
+ };