@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
package/dist/effects.js
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
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/security.ts
|
|
278
|
+
var HEX = Array.from({ length: 256 }, (_, index) => index.toString(16).padStart(2, "0"));
|
|
279
|
+
function toHex(bytes) {
|
|
280
|
+
let out = "";
|
|
281
|
+
for (const byte of bytes)
|
|
282
|
+
out += HEX[byte];
|
|
283
|
+
return out;
|
|
284
|
+
}
|
|
285
|
+
function fromHex(hex) {
|
|
286
|
+
if (hex.length % 2 !== 0)
|
|
287
|
+
throw new Error("Hex string must have an even length.");
|
|
288
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
289
|
+
for (let index = 0;index < bytes.length; index += 1) {
|
|
290
|
+
bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
|
|
291
|
+
}
|
|
292
|
+
return bytes;
|
|
293
|
+
}
|
|
294
|
+
function toBase64Url(bytes) {
|
|
295
|
+
let binary = "";
|
|
296
|
+
for (const byte of bytes)
|
|
297
|
+
binary += String.fromCharCode(byte);
|
|
298
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
299
|
+
}
|
|
300
|
+
function fromBase64Url(value) {
|
|
301
|
+
const padded = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
302
|
+
const binary = atob(padded + "=".repeat((4 - padded.length % 4) % 4));
|
|
303
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
304
|
+
}
|
|
305
|
+
function timingSafeEqual(a, b) {
|
|
306
|
+
if (a.length !== b.length)
|
|
307
|
+
return false;
|
|
308
|
+
let difference = 0;
|
|
309
|
+
for (let index = 0;index < a.length; index += 1) {
|
|
310
|
+
difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
|
|
311
|
+
}
|
|
312
|
+
return difference === 0;
|
|
313
|
+
}
|
|
314
|
+
function randomToken(bytes = 32) {
|
|
315
|
+
const buffer = new Uint8Array(bytes);
|
|
316
|
+
crypto.getRandomValues(buffer);
|
|
317
|
+
return toBase64Url(buffer);
|
|
318
|
+
}
|
|
319
|
+
function randomHex(bytes = 32) {
|
|
320
|
+
const buffer = new Uint8Array(bytes);
|
|
321
|
+
crypto.getRandomValues(buffer);
|
|
322
|
+
return toHex(buffer);
|
|
323
|
+
}
|
|
324
|
+
function randomDigits(length = 6) {
|
|
325
|
+
let digits = "";
|
|
326
|
+
for (let index = 0;index < length; index += 1)
|
|
327
|
+
digits += randomInt(10).toString();
|
|
328
|
+
return digits;
|
|
329
|
+
}
|
|
330
|
+
function randomInt(max) {
|
|
331
|
+
if (max <= 0 || max > 256)
|
|
332
|
+
throw new Error("randomInt supports 1..256.");
|
|
333
|
+
const limit = Math.floor(256 / max) * max;
|
|
334
|
+
const buffer = new Uint8Array(1);
|
|
335
|
+
for (;; ) {
|
|
336
|
+
crypto.getRandomValues(buffer);
|
|
337
|
+
if (buffer[0] < limit)
|
|
338
|
+
return buffer[0] % max;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
async function sha256(input) {
|
|
342
|
+
const bytes = typeof input === "string" ? new TextEncoder().encode(input) : input;
|
|
343
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
344
|
+
return toHex(new Uint8Array(digest));
|
|
345
|
+
}
|
|
346
|
+
var hashToken = sha256;
|
|
347
|
+
async function hmacSha256(key, message) {
|
|
348
|
+
const keyBytes = typeof key === "string" ? new TextEncoder().encode(key) : key;
|
|
349
|
+
const imported = await crypto.subtle.importKey("raw", keyBytes, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
350
|
+
const signature = await crypto.subtle.sign("HMAC", imported, new TextEncoder().encode(message));
|
|
351
|
+
return toHex(new Uint8Array(signature));
|
|
352
|
+
}
|
|
353
|
+
async function verifyHmac(key, message, signature) {
|
|
354
|
+
return timingSafeEqual(await hmacSha256(key, message), signature);
|
|
355
|
+
}
|
|
356
|
+
async function hkdf(secret, info, length = 32, salt = new Uint8Array(32)) {
|
|
357
|
+
const key = await crypto.subtle.importKey("raw", secret, "HKDF", false, ["deriveBits"]);
|
|
358
|
+
const bits = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt, info: new TextEncoder().encode(info) }, key, length * 8);
|
|
359
|
+
return new Uint8Array(bits);
|
|
360
|
+
}
|
|
361
|
+
async function seal(key, plaintext, aad) {
|
|
362
|
+
const iv = new Uint8Array(12);
|
|
363
|
+
crypto.getRandomValues(iv);
|
|
364
|
+
const imported = await crypto.subtle.importKey("raw", key, "AES-GCM", false, ["encrypt"]);
|
|
365
|
+
const ciphertext = await crypto.subtle.encrypt({
|
|
366
|
+
name: "AES-GCM",
|
|
367
|
+
iv,
|
|
368
|
+
...aad ? { additionalData: new TextEncoder().encode(aad) } : {}
|
|
369
|
+
}, imported, new TextEncoder().encode(plaintext));
|
|
370
|
+
return { iv: toBase64Url(iv), ciphertext: toBase64Url(new Uint8Array(ciphertext)) };
|
|
371
|
+
}
|
|
372
|
+
async function open(key, sealed, aad) {
|
|
373
|
+
const imported = await crypto.subtle.importKey("raw", key, "AES-GCM", false, ["decrypt"]);
|
|
374
|
+
const plaintext = await crypto.subtle.decrypt({
|
|
375
|
+
name: "AES-GCM",
|
|
376
|
+
iv: fromBase64Url(sealed.iv),
|
|
377
|
+
...aad ? { additionalData: new TextEncoder().encode(aad) } : {}
|
|
378
|
+
}, imported, fromBase64Url(sealed.ciphertext));
|
|
379
|
+
return new TextDecoder().decode(plaintext);
|
|
380
|
+
}
|
|
381
|
+
var nowSeconds = () => Math.floor(Date.now() / 1000);
|
|
382
|
+
var isLive = (expiresAtSec, at = nowSeconds()) => expiresAtSec > at;
|
|
383
|
+
var SECRET_NAME = /token|secret|password|passphrase|credential|authorization|cookie|apikey|api_key|private/i;
|
|
384
|
+
function redact(value) {
|
|
385
|
+
const out = {};
|
|
386
|
+
for (const [name, entry] of Object.entries(value)) {
|
|
387
|
+
if (SECRET_NAME.test(name))
|
|
388
|
+
out[name] = "[redacted]";
|
|
389
|
+
else if (entry && typeof entry === "object" && !Array.isArray(entry)) {
|
|
390
|
+
out[name] = redact(entry);
|
|
391
|
+
} else
|
|
392
|
+
out[name] = entry;
|
|
393
|
+
}
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
// src/effects.ts
|
|
397
|
+
function settlementOf(result) {
|
|
398
|
+
if (isSettled(result))
|
|
399
|
+
return { outcome: "settled", status: result.status, result: result.value };
|
|
400
|
+
if (isRefusal(result))
|
|
401
|
+
return { outcome: "denied", status: result.status, code: result.code, result };
|
|
402
|
+
if (result instanceof Error)
|
|
403
|
+
return { outcome: "error", status: 500, code: "INTERNAL", result };
|
|
404
|
+
if (result instanceof Response)
|
|
405
|
+
return { outcome: result.ok ? "allowed" : "denied", status: result.status, result };
|
|
406
|
+
return { outcome: "allowed", status: 200, result };
|
|
407
|
+
}
|
|
408
|
+
function define(effect, routes, run) {
|
|
409
|
+
return {
|
|
410
|
+
effect,
|
|
411
|
+
routes,
|
|
412
|
+
run(context, result, state) {
|
|
413
|
+
try {
|
|
414
|
+
run(context, result, state);
|
|
415
|
+
} catch {}
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
function audit(options) {
|
|
420
|
+
const target = options.target ?? "targetId";
|
|
421
|
+
return define("audit", options.routes, (context, result, state) => {
|
|
422
|
+
const settlement = settlementOf(result);
|
|
423
|
+
const detail = options.detail?.(context, state, settlement);
|
|
424
|
+
options.sink.write({
|
|
425
|
+
route: context.route,
|
|
426
|
+
method: context.method,
|
|
427
|
+
outcome: settlement.outcome,
|
|
428
|
+
status: settlement.status,
|
|
429
|
+
code: settlement.code,
|
|
430
|
+
userKey: context.session?.userKey,
|
|
431
|
+
realm: context.realm,
|
|
432
|
+
targetKey: typeof state[target] === "string" ? state[target] : undefined,
|
|
433
|
+
atSec: Math.floor(Date.now() / 1000),
|
|
434
|
+
...detail ? { detail: redact(detail) } : {}
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
function emit(options) {
|
|
439
|
+
return define("emit", options.routes, (context, result, state) => {
|
|
440
|
+
const settlement = settlementOf(result);
|
|
441
|
+
if (settlement.outcome !== "allowed" && !options.onDenied)
|
|
442
|
+
return;
|
|
443
|
+
const payload = options.payload(context, settlement.result, state);
|
|
444
|
+
if (!payload)
|
|
445
|
+
return;
|
|
446
|
+
options.outbox.publish({
|
|
447
|
+
type: options.type,
|
|
448
|
+
route: context.route,
|
|
449
|
+
payload: redact(payload),
|
|
450
|
+
realm: context.realm,
|
|
451
|
+
atSec: Math.floor(Date.now() / 1000)
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
function meter(options) {
|
|
456
|
+
return define("meter", options.routes, (context, result, state) => {
|
|
457
|
+
const settlement = settlementOf(result);
|
|
458
|
+
if (settlement.outcome !== "allowed")
|
|
459
|
+
return;
|
|
460
|
+
const quantity = options.quantity?.(context, settlement.result, state) ?? 1;
|
|
461
|
+
if (quantity <= 0)
|
|
462
|
+
return;
|
|
463
|
+
options.meter.record({
|
|
464
|
+
realm: context.realm,
|
|
465
|
+
userKey: context.session?.userKey,
|
|
466
|
+
unit: options.unit,
|
|
467
|
+
quantity,
|
|
468
|
+
route: context.route,
|
|
469
|
+
atSec: Math.floor(Date.now() / 1000)
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
function invalidate(options) {
|
|
474
|
+
return define("invalidate", options.routes, (context, result, state) => {
|
|
475
|
+
if (settlementOf(result).outcome !== "allowed")
|
|
476
|
+
return;
|
|
477
|
+
const keys = options.keys(context, state);
|
|
478
|
+
if (keys.length > 0)
|
|
479
|
+
options.cache.drop(keys);
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
function notify(options) {
|
|
483
|
+
return define("notify", options.routes, (context, result, state) => {
|
|
484
|
+
const settlement = settlementOf(result);
|
|
485
|
+
if (settlement.outcome !== "allowed" && !options.onDenied)
|
|
486
|
+
return;
|
|
487
|
+
const to = options.to(context, settlement.result, state);
|
|
488
|
+
if (!to)
|
|
489
|
+
return;
|
|
490
|
+
options.notifier.send({
|
|
491
|
+
to,
|
|
492
|
+
template: options.template,
|
|
493
|
+
data: redact(options.data?.(context, settlement.result, state) ?? {}),
|
|
494
|
+
realm: context.realm
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
export {
|
|
499
|
+
settlementOf,
|
|
500
|
+
notify,
|
|
501
|
+
meter,
|
|
502
|
+
invalidate,
|
|
503
|
+
emit,
|
|
504
|
+
audit
|
|
505
|
+
};
|
package/dist/elysia.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type PipelineOptions } from './pipeline';
|
|
2
|
+
import type { RouteRegistry } from './index';
|
|
3
|
+
/**
|
|
4
|
+
* Elysia adapter.
|
|
5
|
+
*
|
|
6
|
+
* Glue over the shared pipeline, not a second implementation. Its whole job is
|
|
7
|
+
* to hand Elysia's matched route to `createPipeline` and turn the answer back
|
|
8
|
+
* into something Elysia returns.
|
|
9
|
+
*
|
|
10
|
+
* ## Two things that go wrong quietly
|
|
11
|
+
*
|
|
12
|
+
* **`.as('global')`.** Without it a guard silently does not run for routes
|
|
13
|
+
* mounted by a plugin — the most common way an Elysia security layer appears to
|
|
14
|
+
* work and does not. Every route registered here is inside this plugin, so the
|
|
15
|
+
* scope must be global or the plugin only guards itself.
|
|
16
|
+
*
|
|
17
|
+
* **`context.route`, not `context.path`.** Elysia exposes the matched PATTERN.
|
|
18
|
+
* Matching on the path makes `api/orders/[id]` a different route per id, which
|
|
19
|
+
* breaks grants, rate limits and step-up binding simultaneously and looks like
|
|
20
|
+
* nothing is wrong until an id changes.
|
|
21
|
+
*
|
|
22
|
+
* Route keys use `[id]`; Elysia uses `:id`. The translation happens here so a
|
|
23
|
+
* tenant writes one convention across every adapter.
|
|
24
|
+
*/
|
|
25
|
+
/** `api/orders/[id]/refund` → `/api/orders/:id/refund` */
|
|
26
|
+
export declare function toElysiaPath(routeKey: string): string;
|
|
27
|
+
/** Minimal shape of what we need from Elysia, so this file imports nothing. */
|
|
28
|
+
interface ElysiaLike {
|
|
29
|
+
route(method: string, path: string, handler: (context: ElysiaContext) => unknown): ElysiaLike;
|
|
30
|
+
as?(scope: 'global' | 'scoped'): ElysiaLike;
|
|
31
|
+
}
|
|
32
|
+
interface ElysiaContext {
|
|
33
|
+
request: Request;
|
|
34
|
+
params?: Record<string, string>;
|
|
35
|
+
route?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface ElysiaAdapterOptions<R extends RouteRegistry> extends PipelineOptions<R> {
|
|
38
|
+
/**
|
|
39
|
+
* Elysia constructor. Passed in rather than imported so this package keeps
|
|
40
|
+
* its zero-dependency claim — a project not using Elysia pulls none of it.
|
|
41
|
+
*/
|
|
42
|
+
Elysia: new (options?: {
|
|
43
|
+
prefix?: string;
|
|
44
|
+
name?: string;
|
|
45
|
+
}) => ElysiaLike;
|
|
46
|
+
prefix?: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Build an Elysia plugin that mounts every action route through the pipeline.
|
|
50
|
+
*
|
|
51
|
+
* Pages are not mounted: they are UI surface, and the matrix knows about them so
|
|
52
|
+
* navigation can be generated from the same source the API enforces.
|
|
53
|
+
*/
|
|
54
|
+
export declare function elysia<R extends RouteRegistry>(options: ElysiaAdapterOptions<R>): ElysiaLike;
|
|
55
|
+
/**
|
|
56
|
+
* OpenTelemetry bridge.
|
|
57
|
+
*
|
|
58
|
+
* Elysia emits its own HTTP spans; these nest inside them so a trace reads
|
|
59
|
+
* request → access decision → handler rather than two unrelated trees.
|
|
60
|
+
*
|
|
61
|
+
* `record` deliberately refuses anything that looks like a credential. A span
|
|
62
|
+
* exporter is an exfiltration path nobody thinks about, and an attribute named
|
|
63
|
+
* `token` reaches a third-party backend as plainly as a log line would.
|
|
64
|
+
*/
|
|
65
|
+
declare const FORBIDDEN_ATTRIBUTE: RegExp;
|
|
66
|
+
export interface OtelTracer {
|
|
67
|
+
startActiveSpan<T>(name: string, options: {
|
|
68
|
+
attributes?: Record<string, unknown>;
|
|
69
|
+
}, run: (span: {
|
|
70
|
+
setAttributes(attributes: Record<string, unknown>): void;
|
|
71
|
+
end(): void;
|
|
72
|
+
}) => Promise<T>): Promise<T>;
|
|
73
|
+
}
|
|
74
|
+
export declare function telemetryFrom(tracer: OtelTracer): {
|
|
75
|
+
span<T>(name: string, attributes: Record<string, unknown>, run: () => Promise<T>): Promise<T>;
|
|
76
|
+
record(attributes: Record<string, unknown>): void;
|
|
77
|
+
};
|
|
78
|
+
export { FORBIDDEN_ATTRIBUTE as forbiddenTelemetryAttribute };
|