@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,357 @@
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/testing.ts
278
+ function nameOf(policies, route) {
279
+ for (const [name, policy] of Object.entries(policies ?? {})) {
280
+ if (policy.routes.includes(route))
281
+ return name;
282
+ }
283
+ return;
284
+ }
285
+ function simulate(access, args, registries) {
286
+ const session = args.as ? typeof args.as === "string" ? { userKey: "simulated", roleKeys: [args.as], factors: sessionFactorsFor(access, args.route) } : {
287
+ userKey: "simulated",
288
+ roleKeys: args.as.roleKeys,
289
+ factors: args.as.factors ?? sessionFactorsFor(access, args.route)
290
+ } : undefined;
291
+ const outcome = authorise({
292
+ access,
293
+ routeKey: args.route,
294
+ stage: args.stage,
295
+ realm: args.realm,
296
+ session,
297
+ roles: args.roles ?? []
298
+ });
299
+ const actionPolicy = access.actionPolicyFor(args.route);
300
+ const sessionPolicyName = nameOf(registries?.sessionPolicies, args.route);
301
+ const actionPolicyName = nameOf(registries?.actionPolicies, args.route);
302
+ if (!outcome.allow) {
303
+ return {
304
+ allowed: false,
305
+ status: outcome.status === 429 ? 403 : outcome.status,
306
+ reason: outcome.code,
307
+ sessionPolicy: sessionPolicyName,
308
+ actionPolicy: actionPolicyName,
309
+ missingFactors: outcome.missing,
310
+ requiresStepUp: Boolean(actionPolicy)
311
+ };
312
+ }
313
+ if (actionPolicy && args.enabledFactors) {
314
+ const { satisfiable, available } = resolveActionFactors(actionPolicy, args.enabledFactors);
315
+ if (!satisfiable) {
316
+ return {
317
+ allowed: false,
318
+ status: 403,
319
+ reason: "FACTORS_UNAVAILABLE",
320
+ sessionPolicy: sessionPolicyName,
321
+ actionPolicy: actionPolicyName,
322
+ missingFactors: actionPolicy.factors.filter((factor) => !available.includes(factor)),
323
+ requiresStepUp: true
324
+ };
325
+ }
326
+ }
327
+ return {
328
+ allowed: true,
329
+ status: 200,
330
+ reason: "ALLOWED",
331
+ sessionPolicy: sessionPolicyName,
332
+ actionPolicy: actionPolicyName,
333
+ requiresStepUp: Boolean(actionPolicy)
334
+ };
335
+ }
336
+ function sessionFactorsFor(access, route) {
337
+ return access.sessionPolicyFor(route)?.factors ?? [];
338
+ }
339
+ function reachableRoutes(access, roles, roleKeys, context = {}) {
340
+ return access.keys().filter((key) => simulate(access, {
341
+ route: key,
342
+ roles,
343
+ as: { roleKeys },
344
+ stage: context.stage,
345
+ realm: context.realm
346
+ }).allowed);
347
+ }
348
+ function unreachableRoutes(access, roles) {
349
+ const everyRole = roles.map((role) => role.roleKey);
350
+ const reachable = new Set(reachableRoutes(access, roles, everyRole));
351
+ return access.keys().filter((key) => !reachable.has(key));
352
+ }
353
+ export {
354
+ unreachableRoutes,
355
+ simulate,
356
+ reachableRoutes
357
+ };
package/package.json ADDED
@@ -0,0 +1,93 @@
1
+ {
2
+ "//": "Publishing happens from an operator's machine, not CI \u2014 CLAUDE.md records that the absence of CI is deliberate. npm's `provenance` attests a tarball was built by a recognised CI provider from a named commit, so it cannot be produced here: it was set, and the first publish failed with `Automatic provenance generation not supported for provider: null`. A setting that can never be satisfied is worse than none, because it reads as a guarantee nobody is getting. Restore it the day this publishes from CI, and not before.",
3
+ "name": "@forgezero/access",
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./fetch": {
15
+ "types": "./dist/fetch.d.ts",
16
+ "default": "./dist/fetch.js"
17
+ },
18
+ "./pipeline": {
19
+ "types": "./dist/pipeline.d.ts",
20
+ "default": "./dist/pipeline.js"
21
+ },
22
+ "./elysia": {
23
+ "types": "./dist/elysia.d.ts",
24
+ "default": "./dist/elysia.js"
25
+ },
26
+ "./client": {
27
+ "types": "./dist/client.d.ts",
28
+ "default": "./dist/client.js"
29
+ },
30
+ "./testing": {
31
+ "types": "./dist/testing.d.ts",
32
+ "default": "./dist/testing.js"
33
+ },
34
+ "./rate-limit": {
35
+ "types": "./dist/rate-limit.d.ts",
36
+ "default": "./dist/rate-limit.js"
37
+ },
38
+ "./security": {
39
+ "types": "./dist/security.d.ts",
40
+ "default": "./dist/security.js"
41
+ },
42
+ "./conditions": {
43
+ "types": "./dist/conditions.d.ts",
44
+ "default": "./dist/conditions.js"
45
+ },
46
+ "./effects": {
47
+ "types": "./dist/effects.d.ts",
48
+ "default": "./dist/effects.js"
49
+ },
50
+ "./authenticator": {
51
+ "types": "./dist/authenticator.d.ts",
52
+ "default": "./dist/authenticator.js"
53
+ },
54
+ "./ceremony-modes": {
55
+ "types": "./dist/ceremony-modes.d.ts",
56
+ "default": "./dist/ceremony-modes.js"
57
+ }
58
+ },
59
+ "scripts": {
60
+ "check": "tsc --noEmit",
61
+ "build": "bun build src/index.ts src/fetch.ts src/pipeline.ts src/elysia.ts src/client.ts src/testing.ts src/rate-limit.ts src/security.ts src/conditions.ts src/effects.ts src/authenticator.ts src/ceremony-modes.ts --root src --outdir dist --target browser --format esm --packages external && tsc --emitDeclarationOnly --declaration --noEmit false --outDir dist",
62
+ "prepublishOnly": "bun run check && bun run build"
63
+ },
64
+ "devDependencies": {
65
+ "typescript": "^5.6.0",
66
+ "@types/bun": "latest"
67
+ },
68
+ "description": "Route matrix, RBAC and security factors. Zero runtime dependencies \u2014 runs anywhere fetch does.",
69
+ "keywords": [
70
+ "rbac",
71
+ "authorization",
72
+ "permissions",
73
+ "routes",
74
+ "webauthn",
75
+ "passkey",
76
+ "mfa"
77
+ ],
78
+ "license": "MIT",
79
+ "homepage": "https://forgezero.net/docs/access",
80
+ "repository": {
81
+ "type": "git",
82
+ "url": "git+https://github.com/axxra/forgezero.git",
83
+ "directory": "packages/access"
84
+ },
85
+ "bugs": "https://github.com/axxra/forgezero/issues",
86
+ "sideEffects": false,
87
+ "types": "./dist/index.d.ts",
88
+ "files": [
89
+ "dist",
90
+ "README.md",
91
+ "LICENSE"
92
+ ]
93
+ }