@forgezero/access 0.1.0 → 0.1.1

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/README.md CHANGED
@@ -67,9 +67,9 @@ integration suite.
67
67
  | `/testing` | decide without a server |
68
68
  | `/pipeline` · `/authenticator` | the resolver, and WebAuthn |
69
69
 
70
- Full documentation: **https://forgezero.net/docs/access**
70
+ Full documentation: **https://www.forgezero.net/docs/access**
71
71
 
72
72
  ## Licence
73
73
 
74
- MIT. Part of [ForgeZero](https://forgezero.net) — secrets, attested compute and
74
+ MIT. Part of [ForgeZero](https://www.forgezero.net) — secrets, attested compute and
75
75
  deploys — and usable entirely on its own, with no ForgeZero account.
@@ -288,7 +288,10 @@ function required(state, name, condition) {
288
288
  function loadTarget(options) {
289
289
  const as = options.as ?? "target";
290
290
  const code = options.code ?? "NOT_FOUND";
291
- const readId = options.id ?? ((context) => context.params.id);
291
+ const readId = options.id ?? ((context) => {
292
+ const id = context.params.id;
293
+ return typeof id === "string" ? id : undefined;
294
+ });
292
295
  return define("loadTarget", options.routes, [{ status: 404, code, when: "no record matches the identifier" }], async (context) => {
293
296
  const id = readId(context);
294
297
  if (!id)
package/dist/elysia.js CHANGED
@@ -286,6 +286,46 @@ function defineHandlers(routes, handlers) {
286
286
  }
287
287
  return handlers;
288
288
  }
289
+ function recordFromValidator(value, part) {
290
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
291
+ return value;
292
+ }
293
+ throw new AccessError("VALIDATOR_RESULT_INVALID", `The validator returned an unusable ${part} value.`);
294
+ }
295
+ function headersFromValidator(value) {
296
+ try {
297
+ return new Headers(value);
298
+ } catch {
299
+ throw new AccessError("VALIDATOR_RESULT_INVALID", "The validator returned an unusable headers value.");
300
+ }
301
+ }
302
+ function urlWithQuery(url, query) {
303
+ const validated = new URL(url);
304
+ validated.search = "";
305
+ for (const [name, value] of Object.entries(query)) {
306
+ const values = Array.isArray(value) ? value : [value];
307
+ for (const item of values) {
308
+ if (item !== undefined && item !== null)
309
+ validated.searchParams.append(name, String(item));
310
+ }
311
+ }
312
+ return validated;
313
+ }
314
+ async function responsePayload(response, routeKey) {
315
+ let text;
316
+ try {
317
+ text = await response.clone().text();
318
+ } catch {
319
+ throw new AccessError("RESPONSE_UNREADABLE", `"${routeKey}" returned a response that could not be inspected.`);
320
+ }
321
+ if (text.length === 0)
322
+ return;
323
+ try {
324
+ return JSON.parse(text);
325
+ } catch {
326
+ throw new AccessError("RESPONSE_NOT_JSON", `"${routeKey}" returned a non-JSON body for a declared response.`);
327
+ }
328
+ }
289
329
  var WINDOWS = { s: 1, m: 60, h: 3600, d: 86400 };
290
330
  function windowSeconds(window) {
291
331
  const match = /^(\d+)([smhd])$/.exec(window);
@@ -370,13 +410,15 @@ function createPipeline(options) {
370
410
  }
371
411
  }
372
412
  let body;
373
- const query = Object.fromEntries(url.searchParams);
413
+ let validatedParams = params;
414
+ let validatedQuery = Object.fromEntries(url.searchParams);
415
+ let validatedHeaders = new Headers(request.headers);
374
416
  if (validator) {
375
417
  const checks = [];
376
418
  if (route.params)
377
419
  checks.push(["params", route.params, params]);
378
420
  if (route.query)
379
- checks.push(["query", route.query, query]);
421
+ checks.push(["query", route.query, validatedQuery]);
380
422
  if (route.headers)
381
423
  checks.push(["headers", route.headers, Object.fromEntries(request.headers)]);
382
424
  if (route.body) {
@@ -393,8 +435,20 @@ function createPipeline(options) {
393
435
  errors: result2.errors
394
436
  });
395
437
  }
396
- if (where === "body")
397
- body = result2.value;
438
+ switch (where) {
439
+ case "params":
440
+ validatedParams = recordFromValidator(result2.value, "params");
441
+ break;
442
+ case "query":
443
+ validatedQuery = recordFromValidator(result2.value, "query");
444
+ break;
445
+ case "headers":
446
+ validatedHeaders = headersFromValidator(result2.value);
447
+ break;
448
+ case "body":
449
+ body = result2.value;
450
+ break;
451
+ }
398
452
  }
399
453
  } else if (route.body) {
400
454
  body = await request.json().catch(() => {
@@ -404,11 +458,11 @@ function createPipeline(options) {
404
458
  const context = {
405
459
  route: routeKey,
406
460
  method: route.method,
407
- url,
408
- params,
409
- query,
461
+ url: route.query && validator ? urlWithQuery(url, validatedQuery) : url,
462
+ params: validatedParams,
463
+ query: validatedQuery,
410
464
  body,
411
- headers: request.headers,
465
+ headers: validatedHeaders,
412
466
  session,
413
467
  realm,
414
468
  stage,
@@ -508,16 +562,19 @@ function createPipeline(options) {
508
562
  }
509
563
  const result = await (telemetry ? telemetry.span("handler", { "access.route": routeKey }, async () => handler(context, state)) : handler(context, state));
510
564
  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
- }
565
+ const status = result instanceof Response ? result.status : 200;
566
+ const schema = route.response[status];
567
+ if (!schema) {
568
+ throw new AccessError("RESPONSE_STATUS_UNDECLARED", `"${routeKey}" returned undeclared status ${status}.`);
569
+ }
570
+ const payload = result instanceof Response ? await responsePayload(result, routeKey) : result;
571
+ const check = validator.validate(schema, payload);
572
+ if (!check.ok) {
573
+ throw new AccessError("RESPONSE_MISMATCH", `"${routeKey}" returned status ${status} with a body that does not match its declaration.`);
517
574
  }
518
575
  }
519
576
  runAfter(result);
520
- record({ "access.outcome": "allowed", "access.status": 200 });
577
+ record({ "access.outcome": "allowed", "access.status": result instanceof Response ? result.status : 200 });
521
578
  return result instanceof Response ? result : new Response(JSON.stringify(result), { headers: { "content-type": "application/json" } });
522
579
  };
523
580
  }
package/dist/fetch.js CHANGED
@@ -286,6 +286,46 @@ function defineHandlers(routes, handlers) {
286
286
  }
287
287
  return handlers;
288
288
  }
289
+ function recordFromValidator(value, part) {
290
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
291
+ return value;
292
+ }
293
+ throw new AccessError("VALIDATOR_RESULT_INVALID", `The validator returned an unusable ${part} value.`);
294
+ }
295
+ function headersFromValidator(value) {
296
+ try {
297
+ return new Headers(value);
298
+ } catch {
299
+ throw new AccessError("VALIDATOR_RESULT_INVALID", "The validator returned an unusable headers value.");
300
+ }
301
+ }
302
+ function urlWithQuery(url, query) {
303
+ const validated = new URL(url);
304
+ validated.search = "";
305
+ for (const [name, value] of Object.entries(query)) {
306
+ const values = Array.isArray(value) ? value : [value];
307
+ for (const item of values) {
308
+ if (item !== undefined && item !== null)
309
+ validated.searchParams.append(name, String(item));
310
+ }
311
+ }
312
+ return validated;
313
+ }
314
+ async function responsePayload(response, routeKey) {
315
+ let text;
316
+ try {
317
+ text = await response.clone().text();
318
+ } catch {
319
+ throw new AccessError("RESPONSE_UNREADABLE", `"${routeKey}" returned a response that could not be inspected.`);
320
+ }
321
+ if (text.length === 0)
322
+ return;
323
+ try {
324
+ return JSON.parse(text);
325
+ } catch {
326
+ throw new AccessError("RESPONSE_NOT_JSON", `"${routeKey}" returned a non-JSON body for a declared response.`);
327
+ }
328
+ }
289
329
  var WINDOWS = { s: 1, m: 60, h: 3600, d: 86400 };
290
330
  function windowSeconds(window) {
291
331
  const match = /^(\d+)([smhd])$/.exec(window);
@@ -370,13 +410,15 @@ function createPipeline(options) {
370
410
  }
371
411
  }
372
412
  let body;
373
- const query = Object.fromEntries(url.searchParams);
413
+ let validatedParams = params;
414
+ let validatedQuery = Object.fromEntries(url.searchParams);
415
+ let validatedHeaders = new Headers(request.headers);
374
416
  if (validator) {
375
417
  const checks = [];
376
418
  if (route.params)
377
419
  checks.push(["params", route.params, params]);
378
420
  if (route.query)
379
- checks.push(["query", route.query, query]);
421
+ checks.push(["query", route.query, validatedQuery]);
380
422
  if (route.headers)
381
423
  checks.push(["headers", route.headers, Object.fromEntries(request.headers)]);
382
424
  if (route.body) {
@@ -393,8 +435,20 @@ function createPipeline(options) {
393
435
  errors: result2.errors
394
436
  });
395
437
  }
396
- if (where === "body")
397
- body = result2.value;
438
+ switch (where) {
439
+ case "params":
440
+ validatedParams = recordFromValidator(result2.value, "params");
441
+ break;
442
+ case "query":
443
+ validatedQuery = recordFromValidator(result2.value, "query");
444
+ break;
445
+ case "headers":
446
+ validatedHeaders = headersFromValidator(result2.value);
447
+ break;
448
+ case "body":
449
+ body = result2.value;
450
+ break;
451
+ }
398
452
  }
399
453
  } else if (route.body) {
400
454
  body = await request.json().catch(() => {
@@ -404,11 +458,11 @@ function createPipeline(options) {
404
458
  const context = {
405
459
  route: routeKey,
406
460
  method: route.method,
407
- url,
408
- params,
409
- query,
461
+ url: route.query && validator ? urlWithQuery(url, validatedQuery) : url,
462
+ params: validatedParams,
463
+ query: validatedQuery,
410
464
  body,
411
- headers: request.headers,
465
+ headers: validatedHeaders,
412
466
  session,
413
467
  realm,
414
468
  stage,
@@ -508,16 +562,19 @@ function createPipeline(options) {
508
562
  }
509
563
  const result = await (telemetry ? telemetry.span("handler", { "access.route": routeKey }, async () => handler(context, state)) : handler(context, state));
510
564
  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
- }
565
+ const status = result instanceof Response ? result.status : 200;
566
+ const schema = route.response[status];
567
+ if (!schema) {
568
+ throw new AccessError("RESPONSE_STATUS_UNDECLARED", `"${routeKey}" returned undeclared status ${status}.`);
569
+ }
570
+ const payload = result instanceof Response ? await responsePayload(result, routeKey) : result;
571
+ const check = validator.validate(schema, payload);
572
+ if (!check.ok) {
573
+ throw new AccessError("RESPONSE_MISMATCH", `"${routeKey}" returned status ${status} with a body that does not match its declaration.`);
517
574
  }
518
575
  }
519
576
  runAfter(result);
520
- record({ "access.outcome": "allowed", "access.status": 200 });
577
+ record({ "access.outcome": "allowed", "access.status": result instanceof Response ? result.status : 200 });
521
578
  return result instanceof Response ? result : new Response(JSON.stringify(result), { headers: { "content-type": "application/json" } });
522
579
  };
523
580
  }
package/dist/index.d.ts CHANGED
@@ -221,7 +221,8 @@ export interface RequestContext {
221
221
  route: string;
222
222
  method: Method;
223
223
  url: URL;
224
- params: Record<string, string>;
224
+ /** Validated route parameters. A schema may convert path text to numbers or booleans. */
225
+ params: Record<string, unknown>;
225
226
  query: Record<string, unknown>;
226
227
  body: unknown;
227
228
  headers: Headers;
package/dist/pipeline.js CHANGED
@@ -286,6 +286,46 @@ function defineHandlers(routes, handlers) {
286
286
  }
287
287
  return handlers;
288
288
  }
289
+ function recordFromValidator(value, part) {
290
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
291
+ return value;
292
+ }
293
+ throw new AccessError("VALIDATOR_RESULT_INVALID", `The validator returned an unusable ${part} value.`);
294
+ }
295
+ function headersFromValidator(value) {
296
+ try {
297
+ return new Headers(value);
298
+ } catch {
299
+ throw new AccessError("VALIDATOR_RESULT_INVALID", "The validator returned an unusable headers value.");
300
+ }
301
+ }
302
+ function urlWithQuery(url, query) {
303
+ const validated = new URL(url);
304
+ validated.search = "";
305
+ for (const [name, value] of Object.entries(query)) {
306
+ const values = Array.isArray(value) ? value : [value];
307
+ for (const item of values) {
308
+ if (item !== undefined && item !== null)
309
+ validated.searchParams.append(name, String(item));
310
+ }
311
+ }
312
+ return validated;
313
+ }
314
+ async function responsePayload(response, routeKey) {
315
+ let text;
316
+ try {
317
+ text = await response.clone().text();
318
+ } catch {
319
+ throw new AccessError("RESPONSE_UNREADABLE", `"${routeKey}" returned a response that could not be inspected.`);
320
+ }
321
+ if (text.length === 0)
322
+ return;
323
+ try {
324
+ return JSON.parse(text);
325
+ } catch {
326
+ throw new AccessError("RESPONSE_NOT_JSON", `"${routeKey}" returned a non-JSON body for a declared response.`);
327
+ }
328
+ }
289
329
  var WINDOWS = { s: 1, m: 60, h: 3600, d: 86400 };
290
330
  function windowSeconds(window) {
291
331
  const match = /^(\d+)([smhd])$/.exec(window);
@@ -370,13 +410,15 @@ function createPipeline(options) {
370
410
  }
371
411
  }
372
412
  let body;
373
- const query = Object.fromEntries(url.searchParams);
413
+ let validatedParams = params;
414
+ let validatedQuery = Object.fromEntries(url.searchParams);
415
+ let validatedHeaders = new Headers(request.headers);
374
416
  if (validator) {
375
417
  const checks = [];
376
418
  if (route.params)
377
419
  checks.push(["params", route.params, params]);
378
420
  if (route.query)
379
- checks.push(["query", route.query, query]);
421
+ checks.push(["query", route.query, validatedQuery]);
380
422
  if (route.headers)
381
423
  checks.push(["headers", route.headers, Object.fromEntries(request.headers)]);
382
424
  if (route.body) {
@@ -393,8 +435,20 @@ function createPipeline(options) {
393
435
  errors: result2.errors
394
436
  });
395
437
  }
396
- if (where === "body")
397
- body = result2.value;
438
+ switch (where) {
439
+ case "params":
440
+ validatedParams = recordFromValidator(result2.value, "params");
441
+ break;
442
+ case "query":
443
+ validatedQuery = recordFromValidator(result2.value, "query");
444
+ break;
445
+ case "headers":
446
+ validatedHeaders = headersFromValidator(result2.value);
447
+ break;
448
+ case "body":
449
+ body = result2.value;
450
+ break;
451
+ }
398
452
  }
399
453
  } else if (route.body) {
400
454
  body = await request.json().catch(() => {
@@ -404,11 +458,11 @@ function createPipeline(options) {
404
458
  const context = {
405
459
  route: routeKey,
406
460
  method: route.method,
407
- url,
408
- params,
409
- query,
461
+ url: route.query && validator ? urlWithQuery(url, validatedQuery) : url,
462
+ params: validatedParams,
463
+ query: validatedQuery,
410
464
  body,
411
- headers: request.headers,
465
+ headers: validatedHeaders,
412
466
  session,
413
467
  realm,
414
468
  stage,
@@ -508,16 +562,19 @@ function createPipeline(options) {
508
562
  }
509
563
  const result = await (telemetry ? telemetry.span("handler", { "access.route": routeKey }, async () => handler(context, state)) : handler(context, state));
510
564
  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
- }
565
+ const status = result instanceof Response ? result.status : 200;
566
+ const schema = route.response[status];
567
+ if (!schema) {
568
+ throw new AccessError("RESPONSE_STATUS_UNDECLARED", `"${routeKey}" returned undeclared status ${status}.`);
569
+ }
570
+ const payload = result instanceof Response ? await responsePayload(result, routeKey) : result;
571
+ const check = validator.validate(schema, payload);
572
+ if (!check.ok) {
573
+ throw new AccessError("RESPONSE_MISMATCH", `"${routeKey}" returned status ${status} with a body that does not match its declaration.`);
517
574
  }
518
575
  }
519
576
  runAfter(result);
520
- record({ "access.outcome": "allowed", "access.status": 200 });
577
+ record({ "access.outcome": "allowed", "access.status": result instanceof Response ? result.status : 200 });
521
578
  return result instanceof Response ? result : new Response(JSON.stringify(result), { headers: { "content-type": "application/json" } });
522
579
  };
523
580
  }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
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
2
  "name": "@forgezero/access",
4
- "version": "0.1.0",
3
+ "version": "0.1.1",
5
4
  "type": "module",
6
5
  "publishConfig": {
7
- "access": "public"
6
+ "access": "public",
7
+ "provenance": true
8
8
  },
9
9
  "exports": {
10
10
  ".": {
@@ -56,8 +56,9 @@
56
56
  "default": "./dist/ceremony-modes.js"
57
57
  }
58
58
  },
59
- "scripts": {
60
- "check": "tsc --noEmit",
59
+ "scripts": {
60
+ "check": "tsc --noEmit",
61
+ "prebuild": "rm -rf dist",
61
62
  "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
63
  "prepublishOnly": "bun run check && bun run build"
63
64
  },
@@ -76,13 +77,13 @@
76
77
  "mfa"
77
78
  ],
78
79
  "license": "MIT",
79
- "homepage": "https://forgezero.net/docs/access",
80
+ "homepage": "https://www.forgezero.net/docs/access",
80
81
  "repository": {
81
82
  "type": "git",
82
- "url": "git+https://github.com/axxra/forgezero.git",
83
- "directory": "packages/access"
83
+ "url": "git+https://github.com/forgezero-net/packages.git",
84
+ "directory": "access"
84
85
  },
85
- "bugs": "https://github.com/axxra/forgezero/issues",
86
+ "bugs": "https://github.com/forgezero-net/packages/issues",
86
87
  "sideEffects": false,
87
88
  "types": "./dist/index.d.ts",
88
89
  "files": [