@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 +21 -0
- package/README.md +75 -0
- package/dist/authenticator.d.ts +118 -0
- package/dist/authenticator.js +186 -0
- package/dist/ceremony-modes.d.ts +71 -0
- package/dist/ceremony-modes.js +55 -0
- package/dist/client.d.ts +76 -0
- package/dist/client.js +107 -0
- package/dist/conditions.d.ts +280 -0
- package/dist/conditions.js +491 -0
- package/dist/effects.d.ts +168 -0
- package/dist/effects.js +505 -0
- package/dist/elysia.d.ts +78 -0
- package/dist/elysia.js +575 -0
- package/dist/fetch.d.ts +41 -0
- package/dist/fetch.js +587 -0
- package/dist/index.d.ts +379 -0
- package/dist/index.js +298 -0
- package/dist/pipeline.d.ts +92 -0
- package/dist/pipeline.js +528 -0
- package/dist/rate-limit.d.ts +54 -0
- package/dist/rate-limit.js +89 -0
- package/dist/security.d.ts +102 -0
- package/dist/security.js +141 -0
- package/dist/testing.d.ts +63 -0
- package/dist/testing.js +357 -0
- package/package.json +93 -0
|
@@ -0,0 +1,491 @@
|
|
|
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/conditions.ts
|
|
278
|
+
function define(condition, routes, refusals, run) {
|
|
279
|
+
return { condition, routes, refusals, run };
|
|
280
|
+
}
|
|
281
|
+
function required(state, name, condition) {
|
|
282
|
+
const value = state[name];
|
|
283
|
+
if (value === undefined) {
|
|
284
|
+
throw new Error(`${condition} needs state["${name}"], which is unset. Declare loadTarget before it in the before list.`);
|
|
285
|
+
}
|
|
286
|
+
return value;
|
|
287
|
+
}
|
|
288
|
+
function loadTarget(options) {
|
|
289
|
+
const as = options.as ?? "target";
|
|
290
|
+
const code = options.code ?? "NOT_FOUND";
|
|
291
|
+
const readId = options.id ?? ((context) => context.params.id);
|
|
292
|
+
return define("loadTarget", options.routes, [{ status: 404, code, when: "no record matches the identifier" }], async (context) => {
|
|
293
|
+
const id = readId(context);
|
|
294
|
+
if (!id)
|
|
295
|
+
throw new Refusal(404, code, "Not found.");
|
|
296
|
+
const record = await options.load(id, context);
|
|
297
|
+
if (record === undefined || record === null)
|
|
298
|
+
throw new Refusal(404, code, "Not found.");
|
|
299
|
+
return { [as]: record, [`${as}Id`]: id };
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
function requireOwner(options) {
|
|
303
|
+
const of = options.of ?? "target";
|
|
304
|
+
const subject = options.subject ?? ((context) => context.session?.userKey);
|
|
305
|
+
return define("requireOwner", options.routes, [{ status: 404, code: "NOT_FOUND", when: "the record belongs to somebody else" }], (context, state) => {
|
|
306
|
+
const record = required(state, of, "requireOwner");
|
|
307
|
+
const who = subject(context);
|
|
308
|
+
if (!who || options.owner(record) !== who)
|
|
309
|
+
throw new Refusal(404, "NOT_FOUND", "Not found.");
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
function requireState(options) {
|
|
313
|
+
const of = options.of ?? "target";
|
|
314
|
+
const code = options.code ?? "STATE_INVALID";
|
|
315
|
+
return define("requireState", options.routes, [{ status: 409, code, when: `the record is not ${options.allowed.join(" or ")}` }], (_context, state) => {
|
|
316
|
+
const record = required(state, of, "requireState");
|
|
317
|
+
const current = options.status(record);
|
|
318
|
+
if (!options.allowed.includes(current)) {
|
|
319
|
+
throw new Refusal(409, code, `This is not possible while the record is ${current}.`, {
|
|
320
|
+
status: current,
|
|
321
|
+
allowed: options.allowed
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
function requireVersion(options) {
|
|
327
|
+
const of = options.of ?? "target";
|
|
328
|
+
const read = options.expected ?? ((context) => context.headers.get("if-match") ?? context.body?.version);
|
|
329
|
+
return define("requireVersion", options.routes, [{ status: 412, code: "VERSION_CONFLICT", when: "the record changed since it was read" }], (context, state) => {
|
|
330
|
+
const record = required(state, of, "requireVersion");
|
|
331
|
+
const expected = read(context);
|
|
332
|
+
if (expected === undefined)
|
|
333
|
+
return;
|
|
334
|
+
const current = options.version(record);
|
|
335
|
+
if (String(current) !== String(expected).replace(/^"|"$/g, "")) {
|
|
336
|
+
throw new Refusal(412, "VERSION_CONFLICT", "This record changed since you loaded it. Reload and try again.", {
|
|
337
|
+
current: String(current)
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
function requireIdempotency(options) {
|
|
343
|
+
const read = options.key ?? ((context) => context.headers.get("idempotency-key") ?? undefined);
|
|
344
|
+
return define("requireIdempotency", options.routes, [
|
|
345
|
+
{ status: 400, code: "IDEMPOTENCY_KEY_REQUIRED", when: "strict and the header is absent" },
|
|
346
|
+
{ status: 409, code: "IDEMPOTENCY_IN_FLIGHT", when: "an identical request is still running" }
|
|
347
|
+
], async (context) => {
|
|
348
|
+
const key = read(context);
|
|
349
|
+
if (!key) {
|
|
350
|
+
if (options.strict) {
|
|
351
|
+
throw new Refusal(400, "IDEMPOTENCY_KEY_REQUIRED", "This request needs an Idempotency-Key header.");
|
|
352
|
+
}
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const claim = await options.store.claim(key, context.route);
|
|
356
|
+
if (claim.state === "replay")
|
|
357
|
+
throw new Settled(claim.value, claim.status);
|
|
358
|
+
if (claim.state === "pending") {
|
|
359
|
+
throw new Refusal(409, "IDEMPOTENCY_IN_FLIGHT", "This request is already being processed.", {}, true);
|
|
360
|
+
}
|
|
361
|
+
return { idempotencyKey: key };
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
function requireUnlocked(options) {
|
|
365
|
+
return define("requireUnlocked", options.routes, [{ status: 423, code: "VAULT_LOCKED", when: "the master seed is not resident" }], async () => {
|
|
366
|
+
if (!await options.unlocked()) {
|
|
367
|
+
throw new Refusal(423, "VAULT_LOCKED", "The vault is locked. Custodians must unlock it before this can run.");
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
function requireService(options) {
|
|
372
|
+
return define("requireService", options.routes, [{ status: 503, code: "SERVICE_UNAVAILABLE", when: "every provider for the service is offline" }], async () => {
|
|
373
|
+
if (!await options.healthy(options.service)) {
|
|
374
|
+
throw new Refusal(503, "SERVICE_UNAVAILABLE", `The ${options.service} service is unavailable. Try again shortly.`, { service: options.service }, true);
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
function requireNotFrozen(options) {
|
|
379
|
+
return define("requireNotFrozen", options.routes, [{ status: 423, code: "FROZEN", when: "an operator or rule halted this subject" }], async (context, state) => {
|
|
380
|
+
const reason = await options.frozen(context, state);
|
|
381
|
+
if (reason)
|
|
382
|
+
throw new Refusal(423, "FROZEN", reason, { frozen: true });
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
function requireTier(options) {
|
|
386
|
+
const code = options.code ?? "TIER_REQUIRED";
|
|
387
|
+
return define("requireTier", options.routes, [{ status: 403, code, when: `the subject is below tier ${options.atLeast}` }], async (context, state) => {
|
|
388
|
+
const current = await options.tier(context, state);
|
|
389
|
+
if (current < options.atLeast) {
|
|
390
|
+
throw new Refusal(403, code, "This needs a higher tier than the account currently holds.", {
|
|
391
|
+
required: options.atLeast,
|
|
392
|
+
current,
|
|
393
|
+
...options.ladder ? { ladder: options.ladder } : {}
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
function requireBalance(options) {
|
|
399
|
+
return define("requireBalance", options.routes, [{ status: 409, code: "INSUFFICIENT_BALANCE", when: "the available balance is below the amount" }], async (context, state) => {
|
|
400
|
+
const [amount, available] = await Promise.all([
|
|
401
|
+
options.amount(context, state),
|
|
402
|
+
options.available(context, state)
|
|
403
|
+
]);
|
|
404
|
+
if (amount <= 0n)
|
|
405
|
+
throw new Refusal(422, "AMOUNT_INVALID", "The amount must be positive.");
|
|
406
|
+
if (available < amount) {
|
|
407
|
+
throw new Refusal(409, "INSUFFICIENT_BALANCE", "The available balance does not cover this.", {
|
|
408
|
+
required: amount.toString(),
|
|
409
|
+
available: available.toString(),
|
|
410
|
+
...options.asset ? { asset: options.asset(context, state) } : {}
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
function requireQuota(options) {
|
|
416
|
+
const code = options.code ?? "QUOTA_EXCEEDED";
|
|
417
|
+
return define("requireQuota", options.routes, [{ status: 409, code, when: `the ${options.window} cap would be exceeded` }], async (context, state) => {
|
|
418
|
+
const [cap, amount, used] = await Promise.all([
|
|
419
|
+
options.cap(context, state),
|
|
420
|
+
options.amount(context, state),
|
|
421
|
+
options.used(context, state)
|
|
422
|
+
]);
|
|
423
|
+
if (used + amount > cap) {
|
|
424
|
+
throw new Refusal(409, code, `This exceeds the ${options.window} limit for this account.`, {
|
|
425
|
+
window: options.window,
|
|
426
|
+
cap: cap.toString(),
|
|
427
|
+
used: used.toString(),
|
|
428
|
+
remaining: (cap > used ? cap - used : 0n).toString()
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
function requireApproval(options) {
|
|
434
|
+
return define("requireApproval", options.routes, [
|
|
435
|
+
{ status: 409, code: "APPROVAL_PENDING", when: "the required approvals have not been collected" },
|
|
436
|
+
{ status: 403, code: "APPROVAL_REJECTED", when: "an approver rejected it" }
|
|
437
|
+
], async (context, state) => {
|
|
438
|
+
const approval = await options.approval(context, state);
|
|
439
|
+
if (approval.status === "rejected") {
|
|
440
|
+
throw new Refusal(403, "APPROVAL_REJECTED", approval.reason ?? "This was rejected by an approver.");
|
|
441
|
+
}
|
|
442
|
+
if (approval.status !== "approved") {
|
|
443
|
+
throw new Refusal(409, "APPROVAL_PENDING", "This is waiting on approval.", {
|
|
444
|
+
approvals: approval.approvals ?? 0,
|
|
445
|
+
required: approval.required ?? 1
|
|
446
|
+
}, true);
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
function assertDeclaredRefusals(args) {
|
|
451
|
+
const ignore = new Set(args.ignore ?? [401, 403, 404, 422, 428, 429, 500]);
|
|
452
|
+
const missing = [];
|
|
453
|
+
for (const condition of args.conditions) {
|
|
454
|
+
for (const routeKey of condition.routes) {
|
|
455
|
+
const route = args.routes[routeKey];
|
|
456
|
+
if (!route?.response)
|
|
457
|
+
continue;
|
|
458
|
+
for (const refusal of condition.refusals) {
|
|
459
|
+
if (ignore.has(refusal.status))
|
|
460
|
+
continue;
|
|
461
|
+
if (!(refusal.status in route.response)) {
|
|
462
|
+
missing.push(`${routeKey} can ${refusal.status} (${condition.condition}) but does not declare it`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (missing.length > 0) {
|
|
468
|
+
throw new Error(`Undeclared refusals:
|
|
469
|
+
${missing.join(`
|
|
470
|
+
`)}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
function refusalsFor(routeKey, conditions) {
|
|
474
|
+
return conditions.filter((condition) => condition.routes.includes(routeKey)).flatMap((condition) => condition.refusals.map((refusal) => ({ ...refusal, condition: condition.condition })));
|
|
475
|
+
}
|
|
476
|
+
export {
|
|
477
|
+
requireVersion,
|
|
478
|
+
requireUnlocked,
|
|
479
|
+
requireTier,
|
|
480
|
+
requireState,
|
|
481
|
+
requireService,
|
|
482
|
+
requireQuota,
|
|
483
|
+
requireOwner,
|
|
484
|
+
requireNotFrozen,
|
|
485
|
+
requireIdempotency,
|
|
486
|
+
requireBalance,
|
|
487
|
+
requireApproval,
|
|
488
|
+
refusalsFor,
|
|
489
|
+
loadTarget,
|
|
490
|
+
assertDeclaredRefusals
|
|
491
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { type AfterHandler, type RequestContext } from './index';
|
|
2
|
+
/**
|
|
3
|
+
* Effects — the reusable `after` catalog.
|
|
4
|
+
*
|
|
5
|
+
* What happens once a request has been decided: it is recorded, it is announced,
|
|
6
|
+
* it is metered, caches holding the old answer are dropped. Four things every
|
|
7
|
+
* project writes, all four easy to write in a way that is subtly wrong.
|
|
8
|
+
*
|
|
9
|
+
* ## Three rules every effect here obeys
|
|
10
|
+
*
|
|
11
|
+
* 1. **It cannot fail the request.** The pipeline queues after-handlers and
|
|
12
|
+
* swallows throws, and these swallow their own too. An audit sink being down
|
|
13
|
+
* must not turn a successful withdrawal into a 500 — the money already moved.
|
|
14
|
+
*
|
|
15
|
+
* 2. **It runs on refusals as well.** The pipeline calls the chain even when a
|
|
16
|
+
* `before` threw, because a log missing every denied request is missing
|
|
17
|
+
* exactly the requests worth having. Each effect below decides for itself
|
|
18
|
+
* whether a refusal is interesting; `audit` says yes, `meter` says no.
|
|
19
|
+
*
|
|
20
|
+
* 3. **It redacts.** Logs, spans and event payloads all end up somewhere the
|
|
21
|
+
* value was never meant to go. Every payload here goes through `redact`, so
|
|
22
|
+
* an effect is not the accidental exfiltration path.
|
|
23
|
+
*/
|
|
24
|
+
type State = Record<string, unknown>;
|
|
25
|
+
type Routes<K extends string> = {
|
|
26
|
+
routes: readonly K[];
|
|
27
|
+
};
|
|
28
|
+
export interface Effect<K extends string> extends AfterHandler<K> {
|
|
29
|
+
effect: string;
|
|
30
|
+
}
|
|
31
|
+
/** What the pipeline handed the after-chain, classified once instead of five times. */
|
|
32
|
+
export interface Settlement {
|
|
33
|
+
outcome: 'allowed' | 'denied' | 'error' | 'settled';
|
|
34
|
+
status: number;
|
|
35
|
+
code?: string;
|
|
36
|
+
result: unknown;
|
|
37
|
+
}
|
|
38
|
+
export declare function settlementOf(result: unknown): Settlement;
|
|
39
|
+
export interface AuditRecord {
|
|
40
|
+
route: string;
|
|
41
|
+
method: string;
|
|
42
|
+
outcome: Settlement['outcome'];
|
|
43
|
+
status: number;
|
|
44
|
+
code?: string;
|
|
45
|
+
userKey?: string;
|
|
46
|
+
realm?: string;
|
|
47
|
+
targetKey?: string;
|
|
48
|
+
atSec: number;
|
|
49
|
+
detail?: Record<string, unknown>;
|
|
50
|
+
}
|
|
51
|
+
export interface AuditSink {
|
|
52
|
+
/** Fire-and-forget. Anything that can reject must handle it internally. */
|
|
53
|
+
write(record: AuditRecord): void | Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
export interface AuditOptions<K extends string> extends Routes<K> {
|
|
56
|
+
sink: AuditSink;
|
|
57
|
+
/** Extra context worth keeping. Redacted before it is written. */
|
|
58
|
+
detail?: (context: RequestContext, state: State, settlement: Settlement) => Record<string, unknown>;
|
|
59
|
+
/** State key holding the record identifier. Defaults to `targetId`. */
|
|
60
|
+
target?: string;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Append to the audit trail — denials included.
|
|
64
|
+
*
|
|
65
|
+
* A trail of successes answers "what did we do" and not "who tried what", and
|
|
66
|
+
* the second question is the one asked during an incident. A run of 403s
|
|
67
|
+
* against admin routes from one session is the signal; a log that keeps only
|
|
68
|
+
* what succeeded has thrown it away.
|
|
69
|
+
*
|
|
70
|
+
* `atSec`, not `atMs`. Seconds match the TTL indexes the retention policy runs
|
|
71
|
+
* on, and mixing the two units in one collection has produced records expiring
|
|
72
|
+
* a thousand times too early more than once.
|
|
73
|
+
*/
|
|
74
|
+
export declare function audit<K extends string>(options: AuditOptions<K>): Effect<K>;
|
|
75
|
+
export interface OutboxEvent {
|
|
76
|
+
type: string;
|
|
77
|
+
route: string;
|
|
78
|
+
payload: Record<string, unknown>;
|
|
79
|
+
realm?: string;
|
|
80
|
+
atSec: number;
|
|
81
|
+
}
|
|
82
|
+
export interface Outbox {
|
|
83
|
+
publish(event: OutboxEvent): void | Promise<void>;
|
|
84
|
+
}
|
|
85
|
+
export interface EmitOptions<K extends string> extends Routes<K> {
|
|
86
|
+
outbox: Outbox;
|
|
87
|
+
type: string;
|
|
88
|
+
payload: (context: RequestContext, result: unknown, state: State) => Record<string, unknown> | undefined;
|
|
89
|
+
/** Emit on refusals too. Off by default — a failed attempt is not an event. */
|
|
90
|
+
onDenied?: boolean;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Announce that something happened.
|
|
94
|
+
*
|
|
95
|
+
* Named `emit`, not `webhook` or `publish`, because the transport is the
|
|
96
|
+
* outbox's business: same call whether it ends up in a queue, a webhook or a
|
|
97
|
+
* websocket fan-out. A route that knows it is sending a webhook is a route that
|
|
98
|
+
* has to change when the transport does.
|
|
99
|
+
*
|
|
100
|
+
* `payload` returning undefined skips the event — the honest way to express "an
|
|
101
|
+
* update that changed nothing is not news".
|
|
102
|
+
*/
|
|
103
|
+
export declare function emit<K extends string>(options: EmitOptions<K>): Effect<K>;
|
|
104
|
+
export interface UsageMeter {
|
|
105
|
+
record(usage: {
|
|
106
|
+
realm?: string;
|
|
107
|
+
userKey?: string;
|
|
108
|
+
unit: string;
|
|
109
|
+
quantity: number;
|
|
110
|
+
route: string;
|
|
111
|
+
atSec: number;
|
|
112
|
+
}): void | Promise<void>;
|
|
113
|
+
}
|
|
114
|
+
export interface MeterOptions<K extends string> extends Routes<K> {
|
|
115
|
+
meter: UsageMeter;
|
|
116
|
+
unit: string;
|
|
117
|
+
/** Defaults to 1 — one call, one unit. */
|
|
118
|
+
quantity?: (context: RequestContext, result: unknown, state: State) => number;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Count billable usage — successes only.
|
|
122
|
+
*
|
|
123
|
+
* Metering a refused request bills a customer for being told no, which is the
|
|
124
|
+
* kind of thing that ends up in a support thread and then a refund. It is also
|
|
125
|
+
* the default a naive implementation lands on, because the after-chain runs on
|
|
126
|
+
* denials too.
|
|
127
|
+
*/
|
|
128
|
+
export declare function meter<K extends string>(options: MeterOptions<K>): Effect<K>;
|
|
129
|
+
export interface CacheInvalidator {
|
|
130
|
+
drop(keys: readonly string[]): void | Promise<void>;
|
|
131
|
+
}
|
|
132
|
+
export interface InvalidateOptions<K extends string> extends Routes<K> {
|
|
133
|
+
cache: CacheInvalidator;
|
|
134
|
+
keys: (context: RequestContext, state: State) => readonly string[];
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Drop what this write made stale — successes only.
|
|
138
|
+
*
|
|
139
|
+
* Invalidating after a refusal is harmless and wasteful; invalidating after a
|
|
140
|
+
* `Settled` replay is neither, since the first attempt already did it. Both are
|
|
141
|
+
* cheap to get wrong and free to get right by classifying once.
|
|
142
|
+
*/
|
|
143
|
+
export declare function invalidate<K extends string>(options: InvalidateOptions<K>): Effect<K>;
|
|
144
|
+
export interface Notification {
|
|
145
|
+
to: string;
|
|
146
|
+
template: string;
|
|
147
|
+
data: Record<string, unknown>;
|
|
148
|
+
realm?: string;
|
|
149
|
+
}
|
|
150
|
+
export interface Notifier {
|
|
151
|
+
send(notification: Notification): void | Promise<void>;
|
|
152
|
+
}
|
|
153
|
+
export interface NotifyOptions<K extends string> extends Routes<K> {
|
|
154
|
+
notifier: Notifier;
|
|
155
|
+
template: string;
|
|
156
|
+
to: (context: RequestContext, result: unknown, state: State) => string | undefined;
|
|
157
|
+
data?: (context: RequestContext, result: unknown, state: State) => Record<string, unknown>;
|
|
158
|
+
onDenied?: boolean;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Tell somebody. Queued like every effect, never awaited.
|
|
162
|
+
*
|
|
163
|
+
* `onDenied` exists for one real case: a failed sign-in or a refused withdrawal
|
|
164
|
+
* is exactly what the account holder wants to hear about, and it is the only
|
|
165
|
+
* signal they get that somebody else has their password.
|
|
166
|
+
*/
|
|
167
|
+
export declare function notify<K extends string>(options: NotifyOptions<K>): Effect<K>;
|
|
168
|
+
export {};
|