@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,92 @@
1
+ import { type AccessControl, type RequestContext, type RouteRegistry, type SessionContext } from './index';
2
+ /**
3
+ * The pipeline every adapter runs.
4
+ *
5
+ * Written once, here, because a security order re-implemented per framework is
6
+ * a security order that will differ per framework. An adapter's whole job is to
7
+ * translate a request in and a response out; it never decides anything.
8
+ *
9
+ * ## Order, and why each step sits where it does
10
+ *
11
+ * 1 match the PATTERN, never the pathname
12
+ * 2 exists stage · realm · feature → 404, never 403
13
+ * 3 session policy factors → 401 / 428
14
+ * 4 grants by stem → 403
15
+ * 5 rate before any work is done → 429
16
+ * 6 validate params · query · body → 422
17
+ * 7 before conditions; a Refusal keeps its status, Settled replays
18
+ * 8 action FRESH proof, bound to the record before loaded → 428
19
+ * 9 handler business logic only
20
+ * 10 response validated in development
21
+ * 11 after queued, never awaited, runs even when 7 threw
22
+ *
23
+ * Step 5 precedes validation so a flood of malformed bodies costs a counter
24
+ * increment rather than a parse. Step 8 follows 7 because binding a proof to a
25
+ * record requires the record — that is what stops a key minted for order A
26
+ * authorising order B.
27
+ */
28
+ export interface Handler {
29
+ (context: RequestContext, state: Record<string, unknown>): unknown | Promise<unknown>;
30
+ }
31
+ export type HandlerRegistry = Record<string, Handler>;
32
+ /** Handlers bind by route key, exactly one each. */
33
+ export declare function defineHandlers<R extends RouteRegistry>(routes: R, handlers: Partial<Record<Extract<keyof R, string>, Handler>>): HandlerRegistry;
34
+ /** Supplied by the host. The pipeline never learns how a session is stored. */
35
+ export interface SessionResolver {
36
+ (request: Request): Promise<SessionContext | undefined>;
37
+ }
38
+ export interface RateStore {
39
+ /** Returns false when the caller is over the limit. */
40
+ take(key: string, limit: number, windowSeconds: number): Promise<boolean>;
41
+ }
42
+ /** Validates one value against one schema. Satisfied by `@forgezero/runtime/schema`. */
43
+ export interface Validator {
44
+ validate(schema: unknown, value: unknown): {
45
+ ok: true;
46
+ value: unknown;
47
+ } | {
48
+ ok: false;
49
+ errors: readonly {
50
+ path: string;
51
+ message: string;
52
+ }[];
53
+ };
54
+ }
55
+ /** Mints and consumes one-time step-up keys. Storage is the host's business. */
56
+ export interface StepUpStore {
57
+ issue(args: {
58
+ route: string;
59
+ method: string;
60
+ target?: string;
61
+ targetKey?: string;
62
+ userKey: string;
63
+ factors: readonly string[];
64
+ required: number;
65
+ }): Promise<string>;
66
+ consume(key: string, args: {
67
+ route: string;
68
+ method: string;
69
+ targetKey?: string;
70
+ }): Promise<boolean>;
71
+ }
72
+ export interface Telemetry {
73
+ span<T>(name: string, attributes: Record<string, unknown>, run: () => Promise<T>): Promise<T>;
74
+ record(attributes: Record<string, unknown>): void;
75
+ }
76
+ export interface PipelineOptions<R extends RouteRegistry> {
77
+ access: AccessControl<R>;
78
+ handlers: HandlerRegistry;
79
+ session?: SessionResolver;
80
+ rateStore?: RateStore;
81
+ validator?: Validator;
82
+ stepUp?: StepUpStore;
83
+ telemetry?: Telemetry;
84
+ /** Off in production: re-validating every response is a cost, not a gate. */
85
+ validateResponses?: boolean;
86
+ stage?: () => string | undefined;
87
+ realm?: (request: Request) => string | undefined;
88
+ }
89
+ /** `15m` → 900. Throws rather than guessing, since a wrong window is silent. */
90
+ export declare function windowSeconds(window: string): number;
91
+ export declare function createPipeline<R extends RouteRegistry>(options: PipelineOptions<R>): (request: Request, routeKey: string, params?: Record<string, string>) => Promise<Response>;
92
+ export type Pipeline = ReturnType<typeof createPipeline>;
@@ -0,0 +1,528 @@
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
+
277
+ // src/pipeline.ts
278
+ function defineHandlers(routes, handlers) {
279
+ for (const key of Object.keys(handlers)) {
280
+ if (!(key in routes)) {
281
+ throw new AccessError("HANDLER_UNKNOWN_ROUTE", `Handler "${key}" is not a route.`);
282
+ }
283
+ if (routes[key].kind !== "action") {
284
+ throw new AccessError("HANDLER_ON_PAGE", `"${key}" is a page; only actions take handlers.`);
285
+ }
286
+ }
287
+ return handlers;
288
+ }
289
+ var WINDOWS = { s: 1, m: 60, h: 3600, d: 86400 };
290
+ function windowSeconds(window) {
291
+ const match = /^(\d+)([smhd])$/.exec(window);
292
+ if (!match)
293
+ throw new AccessError("RATE_WINDOW_INVALID", `"${window}" is not a window like 30s, 1m, 15m, 1h, 1d.`);
294
+ return Number(match[1]) * WINDOWS[match[2]];
295
+ }
296
+ var problem = (status, code, message, extra = {}) => new Response(JSON.stringify({ ok: false, error: { code, message }, ...extra }), {
297
+ status,
298
+ headers: { "content-type": "application/json" }
299
+ });
300
+ var outcomeResponse = (outcome) => {
301
+ const headers = { "content-type": "application/json" };
302
+ if (outcome.status === 428) {
303
+ headers["x-security-mode"] = "session";
304
+ if (outcome.missing)
305
+ headers["x-security-required"] = outcome.missing.join(",");
306
+ }
307
+ return new Response(JSON.stringify({
308
+ ok: false,
309
+ error: { code: outcome.code, message: refusalMessage(outcome.status) },
310
+ ...outcome.missing ? { missing: outcome.missing } : {}
311
+ }), { status: outcome.status, headers });
312
+ };
313
+ function refusalMessage(status) {
314
+ switch (status) {
315
+ case 404:
316
+ return "Not found.";
317
+ case 401:
318
+ return "Sign in to continue.";
319
+ case 403:
320
+ return "Your role cannot perform this action.";
321
+ case 428:
322
+ return "Complete the required security check to continue.";
323
+ case 429:
324
+ return "Too many requests. Try again shortly.";
325
+ default:
326
+ return "Refused.";
327
+ }
328
+ }
329
+ function createPipeline(options) {
330
+ const {
331
+ access,
332
+ handlers,
333
+ session: resolveSession,
334
+ rateStore,
335
+ validator,
336
+ stepUp,
337
+ telemetry,
338
+ validateResponses = false
339
+ } = options;
340
+ return async function run(request, routeKey, params = {}) {
341
+ const url = new URL(request.url);
342
+ const stage = options.stage?.();
343
+ const realm = options.realm?.(request);
344
+ const record = (attributes) => telemetry?.record(attributes);
345
+ record({ "access.route": routeKey, "access.realm": realm, "access.stage": stage });
346
+ if (!access.exists(routeKey, { stage, realm })) {
347
+ record({ "access.outcome": "denied", "access.status": 404, "access.reason": "not_found" });
348
+ return problem(404, "NOT_FOUND", "Not found.");
349
+ }
350
+ const route = access.get(routeKey);
351
+ const session = await resolveSession?.(request);
352
+ const roles = await access.source?.roles() ?? [];
353
+ const outcome = authorise({ access, routeKey, stage, realm, session, roles });
354
+ if (!outcome.allow) {
355
+ record({
356
+ "access.outcome": "denied",
357
+ "access.status": outcome.status,
358
+ "access.reason": outcome.code,
359
+ "access.policy": outcome.policy
360
+ });
361
+ return outcomeResponse(outcome);
362
+ }
363
+ const rate = access.ratePolicyFor(routeKey);
364
+ if (rate && rateStore) {
365
+ const subject = rate.by === "session" ? session?.userKey ?? "anon" : rate.by === "realm" ? realm ?? "none" : rate.by === "apiKey" ? request.headers.get("x-api-key") ?? "none" : request.headers.get("x-forwarded-for") ?? "unknown";
366
+ const allowed = await rateStore.take(`${routeKey}:${rate.by}:${subject}`, rate.limit, windowSeconds(rate.window));
367
+ if (!allowed) {
368
+ record({ "access.outcome": "denied", "access.status": 429, "access.reason": "rate_limited" });
369
+ return problem(429, "RATE_LIMITED", refusalMessage(429));
370
+ }
371
+ }
372
+ let body;
373
+ const query = Object.fromEntries(url.searchParams);
374
+ if (validator) {
375
+ const checks = [];
376
+ if (route.params)
377
+ checks.push(["params", route.params, params]);
378
+ if (route.query)
379
+ checks.push(["query", route.query, query]);
380
+ if (route.headers)
381
+ checks.push(["headers", route.headers, Object.fromEntries(request.headers)]);
382
+ if (route.body) {
383
+ body = await request.json().catch(() => {
384
+ return;
385
+ });
386
+ checks.push(["body", route.body, body]);
387
+ }
388
+ for (const [where, schema, value] of checks) {
389
+ const result2 = validator.validate(schema, value);
390
+ if (!result2.ok) {
391
+ record({ "access.outcome": "denied", "access.status": 422, "access.reason": `invalid_${where}` });
392
+ return problem(422, "INVALID_REQUEST", `The ${where} did not match what this route accepts.`, {
393
+ errors: result2.errors
394
+ });
395
+ }
396
+ if (where === "body")
397
+ body = result2.value;
398
+ }
399
+ } else if (route.body) {
400
+ body = await request.json().catch(() => {
401
+ return;
402
+ });
403
+ }
404
+ const context = {
405
+ route: routeKey,
406
+ method: route.method,
407
+ url,
408
+ params,
409
+ query,
410
+ body,
411
+ headers: request.headers,
412
+ session,
413
+ realm,
414
+ stage,
415
+ ok: (value) => new Response(JSON.stringify(value), { headers: { "content-type": "application/json" } })
416
+ };
417
+ const state = {};
418
+ const completed = [];
419
+ const before = access.beforeFor(routeKey);
420
+ let failure;
421
+ for (let index = 0;index < before.length; index += 1) {
422
+ try {
423
+ Object.assign(state, await before[index].run(context, state) ?? {});
424
+ completed.push(index);
425
+ } catch (error) {
426
+ failure = error;
427
+ break;
428
+ }
429
+ }
430
+ const runAfter = (result2) => {
431
+ queueMicrotask(() => {
432
+ for (const handler2 of [...access.afterFor(routeKey)].reverse()) {
433
+ try {
434
+ handler2.run(context, result2, state);
435
+ } catch {}
436
+ }
437
+ });
438
+ };
439
+ if (failure) {
440
+ runAfter(failure);
441
+ if (isSettled(failure)) {
442
+ record({ "access.outcome": "settled", "access.status": failure.status });
443
+ return failure.value instanceof Response ? failure.value : new Response(failure.status === 204 ? null : JSON.stringify(failure.value), {
444
+ status: failure.status,
445
+ headers: { "content-type": "application/json", ...failure.headers }
446
+ });
447
+ }
448
+ if (isRefusal(failure)) {
449
+ record({
450
+ "access.outcome": "denied",
451
+ "access.status": failure.status,
452
+ "access.reason": failure.code
453
+ });
454
+ return problem(failure.status, failure.code, failure.message, {
455
+ ...failure.details,
456
+ ...failure.retryable ? { retryable: true } : {}
457
+ });
458
+ }
459
+ record({ "access.outcome": "error", "access.reason": "before_threw" });
460
+ return problem(500, "INTERNAL", "The request could not be completed.");
461
+ }
462
+ const actionPolicy = access.actionPolicyFor(routeKey);
463
+ if (actionPolicy && session) {
464
+ const enabled = await access.source?.enabledFactors() ?? actionPolicy.factors;
465
+ const { satisfiable, available } = resolveActionFactors(actionPolicy, enabled);
466
+ if (!satisfiable) {
467
+ record({ "access.outcome": "denied", "access.status": 403, "access.reason": "factors_unavailable" });
468
+ return problem(403, "FACTORS_UNAVAILABLE", "A required security method is not enabled on this account.");
469
+ }
470
+ const presented = request.headers.get("x-security-request-key");
471
+ const targetKey = typeof body?.id === "string" ? body.id : undefined;
472
+ const consumed = presented && stepUp ? await stepUp.consume(presented, { route: routeKey, method: route.method, targetKey }) : false;
473
+ if (!consumed) {
474
+ const key = stepUp ? await stepUp.issue({
475
+ route: routeKey,
476
+ method: route.method,
477
+ target: actionPolicy.target,
478
+ targetKey,
479
+ userKey: session.userKey,
480
+ factors: available,
481
+ required: actionPolicy.required
482
+ }) : "";
483
+ record({ "access.outcome": "denied", "access.status": 428, "access.reason": "step_up_required" });
484
+ return new Response(JSON.stringify({
485
+ ok: false,
486
+ error: { code: "SECURITY_REQUIRED", message: refusalMessage(428) },
487
+ security: {
488
+ scope: "action",
489
+ route: routeKey,
490
+ factors: available,
491
+ required: actionPolicy.required,
492
+ satisfied: fulfilledActionFactors()
493
+ }
494
+ }), {
495
+ status: 428,
496
+ headers: {
497
+ "content-type": "application/json",
498
+ "x-security-mode": "action",
499
+ "x-security-request-key": key,
500
+ "x-security-required": available.join(",")
501
+ }
502
+ });
503
+ }
504
+ }
505
+ const handler = handlers[routeKey];
506
+ if (!handler) {
507
+ return problem(501, "NOT_IMPLEMENTED", "This route has no handler.");
508
+ }
509
+ const result = await (telemetry ? telemetry.span("handler", { "access.route": routeKey }, async () => handler(context, state)) : handler(context, state));
510
+ if (validateResponses && validator && route.response) {
511
+ const schema = route.response[200];
512
+ if (schema && !(result instanceof Response)) {
513
+ const check = validator.validate(schema, result);
514
+ if (!check.ok) {
515
+ throw new AccessError("RESPONSE_MISMATCH", `"${routeKey}" returned a shape it does not declare: ${check.errors[0]?.message}`);
516
+ }
517
+ }
518
+ }
519
+ runAfter(result);
520
+ record({ "access.outcome": "allowed", "access.status": 200 });
521
+ return result instanceof Response ? result : new Response(JSON.stringify(result), { headers: { "content-type": "application/json" } });
522
+ };
523
+ }
524
+ export {
525
+ windowSeconds,
526
+ defineHandlers,
527
+ createPipeline
528
+ };
@@ -0,0 +1,54 @@
1
+ import type { RateStore } from './pipeline';
2
+ /**
3
+ * Rate limit stores.
4
+ *
5
+ * The pipeline declares `RateStore` and never implements one, so a project on
6
+ * Redis, Durable Objects or a single process all satisfy the same interface.
7
+ *
8
+ * Every store here is a FIXED WINDOW, which is the honest simple choice: it
9
+ * permits up to 2× the limit across a window boundary. Sliding windows avoid
10
+ * that and cost either a sorted set per subject or arithmetic on two counters.
11
+ * For "10 sign-ins per 15 minutes" the burst is irrelevant; for a payment API it
12
+ * is not, and that is when to reach for `slidingWindowStore`.
13
+ */
14
+ /**
15
+ * Single process, no dependencies. Correct for one node and for tests, wrong
16
+ * the moment there are two — each replica keeps its own count, so N replicas
17
+ * permit N× the limit.
18
+ */
19
+ export declare function memoryStore(): RateStore & {
20
+ reset(): void;
21
+ size(): number;
22
+ };
23
+ /** Just enough of a Redis client to count, so this file imports nothing. */
24
+ export interface RedisLike {
25
+ incr(key: string): Promise<number>;
26
+ expire(key: string, seconds: number): Promise<unknown>;
27
+ }
28
+ /**
29
+ * Redis fixed window.
30
+ *
31
+ * `INCR` then `EXPIRE` only when the counter is 1. Setting the TTL on every
32
+ * increment slides the window forward on each request, so a steady stream of
33
+ * traffic keeps the key alive forever and the limit never resets — a bug that
34
+ * looks like the limiter working perfectly right up until nobody can call
35
+ * anything.
36
+ */
37
+ export declare function redisStore(redis: RedisLike, prefix?: string): RateStore;
38
+ /** Cloudflare Durable Object, or anything with the same get/put shape. */
39
+ export interface DurableStorageLike {
40
+ get<T>(key: string): Promise<T | undefined>;
41
+ put<T>(key: string, value: T): Promise<void>;
42
+ }
43
+ export declare function durableObjectStore(storage: DurableStorageLike, prefix?: string): RateStore;
44
+ /**
45
+ * Sliding window, approximated from two fixed windows.
46
+ *
47
+ * Weights the previous window by how much of it still overlaps, which removes
48
+ * the 2× boundary burst without keeping a timestamp per request. Costs two
49
+ * counters per subject instead of one — worth it where a burst actually matters
50
+ * and not worth it where it does not.
51
+ */
52
+ export declare function slidingWindowStore(base: RateStore & {
53
+ peek?: never;
54
+ }): RateStore;