@forgezero/access 0.1.13 → 0.1.15

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
@@ -36,6 +36,7 @@ These are supported consumer entry points, not every internal module shipped for
36
36
  | public entry | short description | runtime | details |
37
37
  |---|---|---|---|
38
38
  | @forgezero/access | Declare routes, factors and policies as orthogonal lists; authorise against them. | portable | [Reference + usage](#forgezero-access) |
39
+ | @forgezero/access/schema | Dependency-free JSON Schema builders, inference, Pick/Omit/Extend composition and runtime boundary parsing so request and response types have one source. | portable | [Reference + usage](#forgezero-access-schema) |
39
40
  | @forgezero/access/conditions | The twelve guards every project writes into `before`, each with the status its refusal deserves. | portable | [Reference + usage](#forgezero-access-conditions) |
40
41
  | @forgezero/access/effects | Audit, emit, meter, invalidate and notify — what happens after a request is decided, never failing it. | portable | [Reference + usage](#forgezero-access-effects) |
41
42
  | @forgezero/access/security | Constant-time comparison, CSPRNG tokens, HMAC, HKDF, AES-GCM sealing and redaction. Web Crypto only. | portable | [Reference + usage](#forgezero-access-security) |
@@ -50,6 +51,7 @@ These are supported consumer entry points, not every internal module shipped for
50
51
  | @forgezero/access/principal-session | Short-lived scope-bound delegated-principal sessions with sliding and absolute expiry. | portable | [Reference + usage](#forgezero-access-principal-session) |
51
52
  | @forgezero/access/authenticator | Authentication-source contracts for adding identity mechanisms without changing route policy. | portable | [Reference + usage](#forgezero-access-authenticator) |
52
53
  | @forgezero/access/ceremony-modes | Named security-ceremony modes and their session/action fulfilment semantics. | portable | [Reference + usage](#forgezero-access-ceremony-modes) |
54
+ | @forgezero/access/vault | Runtime-validated Service, Platform Authority and exact Deployment Vault coordinates and bindings. | portable | [Reference + usage](#forgezero-access-vault) |
53
55
 
54
56
  ## Commands
55
57
 
@@ -87,6 +89,29 @@ export const routes = defineRoutes({
87
89
  });
88
90
  ```
89
91
 
92
+ <a id="forgezero-access-schema"></a>
93
+ ## @forgezero/access/schema
94
+
95
+ Dependency-free JSON Schema builders, inference, Pick/Omit/Extend composition and runtime boundary parsing so request and response types have one source. This is a supported entry point. Import only the named values used by the adjacent task example; the declaration file remains the complete API reference.
96
+
97
+ ```text
98
+ import {
99
+ extend,
100
+ } from '@forgezero/access/schema';
101
+ ```
102
+
103
+ ## @forgezero/access/schema — Use this entry point
104
+
105
+ This minimal executable use imports one concrete value from this exact entry point without a wildcard or package-root detour. Keep the value or values the application actually needs; the full named inventory remains directly above it.
106
+
107
+ ```text
108
+ import {
109
+ extend,
110
+ } from '@forgezero/access/schema';
111
+
112
+ export const selectedCapability = extend;
113
+ ```
114
+
90
115
  <a id="forgezero-access-conditions"></a>
91
116
  ## @forgezero/access/conditions
92
117
 
@@ -409,6 +434,37 @@ import {
409
434
  export const selectedCapability = MAX_CUSTODIANS;
410
435
  ```
411
436
 
437
+ <a id="forgezero-access-vault"></a>
438
+ ## @forgezero/access/vault
439
+
440
+ Runtime-validated Service, Platform Authority and exact Deployment Vault coordinates and bindings. This is a supported entry point. Import only the named values used by the adjacent task example; the declaration file remains the complete API reference.
441
+
442
+ ```text
443
+ import {
444
+ DeploymentCredentialPurposeSchema,
445
+ } from '@forgezero/access/vault';
446
+ ```
447
+
448
+ ## @forgezero/access/vault — Declare an exact Vault domain and deployment binding
449
+
450
+ Service records, platform authority, and deployment inputs are distinct runtime-validated coordinates. Deployment credentials always name their exact deployment and environment.
451
+
452
+ ```text
453
+ import {
454
+ parseDeploymentVaultBinding,
455
+ } from '@forgezero/access/vault';
456
+
457
+ const binding = parseDeploymentVaultBinding({
458
+ vaultDomain: 'deployment',
459
+ realmKey: 'platform',
460
+ projectKey: 'fz',
461
+ deploymentKey: 'platform-api',
462
+ environment: 'development',
463
+ entryKey: 'cloudflare-management'
464
+ });
465
+ if (!binding) throw new Error('Invalid deployment Vault binding');
466
+ ```
467
+
412
468
  ## 1. Install
413
469
 
414
470
  Zero runtime dependencies. The core is fetch types plus plain JSON Schema, so it runs on Bun, Node 18+, Cloudflare Workers, Deno and every edge runtime.
@@ -584,7 +640,7 @@ A 428 is not a rejection: it names the missing proof and the same request succee
584
640
  ```text
585
641
  401 no session
586
642
  403 session is fine, the role lacks this route
587
- 404 route exists but not in this stage, realm or feature
643
+ 404 route is unavailable for this account, stage or feature
588
644
  409 a condition refused — state, balance, quota or approval
589
645
  412 the record changed since you read it
590
646
  422 the request did not match what the route accepts
@@ -59,14 +59,42 @@ function defineFactors(factors) {
59
59
  }
60
60
  return factors;
61
61
  }
62
+ var ROUTE_LIFECYCLE_STATES = ["active", "deprecated", "retired"];
62
63
  function page(label, options = {}) {
63
- return { kind: "page", label, accessGroup: "default", ...options };
64
+ return {
65
+ kind: "page",
66
+ label,
67
+ accessGroup: "default",
68
+ lifecycle: { version: 1, status: "active", introducedAt: "initial" },
69
+ ...options
70
+ };
64
71
  }
65
72
  function action(label, method, options = {}) {
66
- return { kind: "action", label, method, accessGroup: "default", ...options };
73
+ return {
74
+ kind: "action",
75
+ label,
76
+ method,
77
+ accessGroup: "default",
78
+ lifecycle: { version: 1, status: "active", introducedAt: "initial" },
79
+ ...options
80
+ };
67
81
  }
68
82
  function defineRoutes(routes) {
69
83
  for (const [key, route] of Object.entries(routes)) {
84
+ const lifecycle = route.lifecycle;
85
+ if (!Number.isSafeInteger(lifecycle.version) || lifecycle.version < 1) {
86
+ throw new AccessError("ROUTE_VERSION_INVALID", `"${key}": route version must be a positive integer.`);
87
+ }
88
+ const validTimestamp = (value) => Number.isFinite(Date.parse(value));
89
+ if (lifecycle.introducedAt !== "initial" && !validTimestamp(lifecycle.introducedAt)) {
90
+ throw new AccessError("ROUTE_LIFECYCLE_INVALID", `"${key}": introducedAt must be an ISO timestamp or initial.`);
91
+ }
92
+ if (lifecycle.status === "deprecated" && (!validTimestamp(lifecycle.deprecatedAt) || !validTimestamp(lifecycle.expiresAt) || Date.parse(lifecycle.deprecatedAt) > Date.parse(lifecycle.expiresAt) || lifecycle.replacement === key)) {
93
+ throw new AccessError("ROUTE_LIFECYCLE_INVALID", `"${key}": deprecated route lifecycle is invalid.`);
94
+ }
95
+ if (lifecycle.status === "retired" && (!validTimestamp(lifecycle.retiredAt) || lifecycle.replacement === key)) {
96
+ throw new AccessError("ROUTE_LIFECYCLE_INVALID", `"${key}": retired route lifecycle is invalid.`);
97
+ }
70
98
  if (!/^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/.test(route.accessGroup)) {
71
99
  throw new AccessError("ROUTE_ACCESS_INVALID", `"${key}": accessGroup must be a bounded name.`);
72
100
  }
@@ -224,6 +252,10 @@ function defineAccessControl(config) {
224
252
  const route = config.routes[key];
225
253
  if (!route)
226
254
  return false;
255
+ if (route.lifecycle?.status === "retired")
256
+ return false;
257
+ if (route.lifecycle?.status === "deprecated" && Date.parse(context?.now ?? new Date().toISOString()) >= Date.parse(route.lifecycle.expiresAt))
258
+ return false;
227
259
  if (route.feature && !features.includes(route.feature))
228
260
  return false;
229
261
  if (context?.stage && route.stages && !route.stages.includes(context.stage))
@@ -275,7 +307,7 @@ function impactOfDisabling(access, enabledAfter) {
275
307
  }
276
308
  return broken;
277
309
  }
278
- var VERSION = "0.1.13";
310
+ var VERSION = "0.1.15";
279
311
 
280
312
  // src/conditions.ts
281
313
  function define(condition, routes, refusals, run) {
package/dist/effects.js CHANGED
@@ -59,14 +59,42 @@ function defineFactors(factors) {
59
59
  }
60
60
  return factors;
61
61
  }
62
+ var ROUTE_LIFECYCLE_STATES = ["active", "deprecated", "retired"];
62
63
  function page(label, options = {}) {
63
- return { kind: "page", label, accessGroup: "default", ...options };
64
+ return {
65
+ kind: "page",
66
+ label,
67
+ accessGroup: "default",
68
+ lifecycle: { version: 1, status: "active", introducedAt: "initial" },
69
+ ...options
70
+ };
64
71
  }
65
72
  function action(label, method, options = {}) {
66
- return { kind: "action", label, method, accessGroup: "default", ...options };
73
+ return {
74
+ kind: "action",
75
+ label,
76
+ method,
77
+ accessGroup: "default",
78
+ lifecycle: { version: 1, status: "active", introducedAt: "initial" },
79
+ ...options
80
+ };
67
81
  }
68
82
  function defineRoutes(routes) {
69
83
  for (const [key, route] of Object.entries(routes)) {
84
+ const lifecycle = route.lifecycle;
85
+ if (!Number.isSafeInteger(lifecycle.version) || lifecycle.version < 1) {
86
+ throw new AccessError("ROUTE_VERSION_INVALID", `"${key}": route version must be a positive integer.`);
87
+ }
88
+ const validTimestamp = (value) => Number.isFinite(Date.parse(value));
89
+ if (lifecycle.introducedAt !== "initial" && !validTimestamp(lifecycle.introducedAt)) {
90
+ throw new AccessError("ROUTE_LIFECYCLE_INVALID", `"${key}": introducedAt must be an ISO timestamp or initial.`);
91
+ }
92
+ if (lifecycle.status === "deprecated" && (!validTimestamp(lifecycle.deprecatedAt) || !validTimestamp(lifecycle.expiresAt) || Date.parse(lifecycle.deprecatedAt) > Date.parse(lifecycle.expiresAt) || lifecycle.replacement === key)) {
93
+ throw new AccessError("ROUTE_LIFECYCLE_INVALID", `"${key}": deprecated route lifecycle is invalid.`);
94
+ }
95
+ if (lifecycle.status === "retired" && (!validTimestamp(lifecycle.retiredAt) || lifecycle.replacement === key)) {
96
+ throw new AccessError("ROUTE_LIFECYCLE_INVALID", `"${key}": retired route lifecycle is invalid.`);
97
+ }
70
98
  if (!/^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/.test(route.accessGroup)) {
71
99
  throw new AccessError("ROUTE_ACCESS_INVALID", `"${key}": accessGroup must be a bounded name.`);
72
100
  }
@@ -224,6 +252,10 @@ function defineAccessControl(config) {
224
252
  const route = config.routes[key];
225
253
  if (!route)
226
254
  return false;
255
+ if (route.lifecycle?.status === "retired")
256
+ return false;
257
+ if (route.lifecycle?.status === "deprecated" && Date.parse(context?.now ?? new Date().toISOString()) >= Date.parse(route.lifecycle.expiresAt))
258
+ return false;
227
259
  if (route.feature && !features.includes(route.feature))
228
260
  return false;
229
261
  if (context?.stage && route.stages && !route.stages.includes(context.stage))
@@ -275,7 +307,7 @@ function impactOfDisabling(access, enabledAfter) {
275
307
  }
276
308
  return broken;
277
309
  }
278
- var VERSION = "0.1.13";
310
+ var VERSION = "0.1.15";
279
311
 
280
312
  // src/security.ts
281
313
  var HEX = Array.from({ length: 256 }, (_, index) => index.toString(16).padStart(2, "0"));
package/dist/elysia.js CHANGED
@@ -59,14 +59,42 @@ function defineFactors(factors) {
59
59
  }
60
60
  return factors;
61
61
  }
62
+ var ROUTE_LIFECYCLE_STATES = ["active", "deprecated", "retired"];
62
63
  function page(label, options = {}) {
63
- return { kind: "page", label, accessGroup: "default", ...options };
64
+ return {
65
+ kind: "page",
66
+ label,
67
+ accessGroup: "default",
68
+ lifecycle: { version: 1, status: "active", introducedAt: "initial" },
69
+ ...options
70
+ };
64
71
  }
65
72
  function action(label, method, options = {}) {
66
- return { kind: "action", label, method, accessGroup: "default", ...options };
73
+ return {
74
+ kind: "action",
75
+ label,
76
+ method,
77
+ accessGroup: "default",
78
+ lifecycle: { version: 1, status: "active", introducedAt: "initial" },
79
+ ...options
80
+ };
67
81
  }
68
82
  function defineRoutes(routes) {
69
83
  for (const [key, route] of Object.entries(routes)) {
84
+ const lifecycle = route.lifecycle;
85
+ if (!Number.isSafeInteger(lifecycle.version) || lifecycle.version < 1) {
86
+ throw new AccessError("ROUTE_VERSION_INVALID", `"${key}": route version must be a positive integer.`);
87
+ }
88
+ const validTimestamp = (value) => Number.isFinite(Date.parse(value));
89
+ if (lifecycle.introducedAt !== "initial" && !validTimestamp(lifecycle.introducedAt)) {
90
+ throw new AccessError("ROUTE_LIFECYCLE_INVALID", `"${key}": introducedAt must be an ISO timestamp or initial.`);
91
+ }
92
+ if (lifecycle.status === "deprecated" && (!validTimestamp(lifecycle.deprecatedAt) || !validTimestamp(lifecycle.expiresAt) || Date.parse(lifecycle.deprecatedAt) > Date.parse(lifecycle.expiresAt) || lifecycle.replacement === key)) {
93
+ throw new AccessError("ROUTE_LIFECYCLE_INVALID", `"${key}": deprecated route lifecycle is invalid.`);
94
+ }
95
+ if (lifecycle.status === "retired" && (!validTimestamp(lifecycle.retiredAt) || lifecycle.replacement === key)) {
96
+ throw new AccessError("ROUTE_LIFECYCLE_INVALID", `"${key}": retired route lifecycle is invalid.`);
97
+ }
70
98
  if (!/^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/.test(route.accessGroup)) {
71
99
  throw new AccessError("ROUTE_ACCESS_INVALID", `"${key}": accessGroup must be a bounded name.`);
72
100
  }
@@ -224,6 +252,10 @@ function defineAccessControl(config) {
224
252
  const route = config.routes[key];
225
253
  if (!route)
226
254
  return false;
255
+ if (route.lifecycle?.status === "retired")
256
+ return false;
257
+ if (route.lifecycle?.status === "deprecated" && Date.parse(context?.now ?? new Date().toISOString()) >= Date.parse(route.lifecycle.expiresAt))
258
+ return false;
227
259
  if (route.feature && !features.includes(route.feature))
228
260
  return false;
229
261
  if (context?.stage && route.stages && !route.stages.includes(context.stage))
@@ -275,7 +307,7 @@ function impactOfDisabling(access, enabledAfter) {
275
307
  }
276
308
  return broken;
277
309
  }
278
- var VERSION = "0.1.13";
310
+ var VERSION = "0.1.15";
279
311
 
280
312
  // src/pipeline.ts
281
313
  function defineHandlers(routes, handlers) {
package/dist/fetch.js CHANGED
@@ -59,14 +59,42 @@ function defineFactors(factors) {
59
59
  }
60
60
  return factors;
61
61
  }
62
+ var ROUTE_LIFECYCLE_STATES = ["active", "deprecated", "retired"];
62
63
  function page(label, options = {}) {
63
- return { kind: "page", label, accessGroup: "default", ...options };
64
+ return {
65
+ kind: "page",
66
+ label,
67
+ accessGroup: "default",
68
+ lifecycle: { version: 1, status: "active", introducedAt: "initial" },
69
+ ...options
70
+ };
64
71
  }
65
72
  function action(label, method, options = {}) {
66
- return { kind: "action", label, method, accessGroup: "default", ...options };
73
+ return {
74
+ kind: "action",
75
+ label,
76
+ method,
77
+ accessGroup: "default",
78
+ lifecycle: { version: 1, status: "active", introducedAt: "initial" },
79
+ ...options
80
+ };
67
81
  }
68
82
  function defineRoutes(routes) {
69
83
  for (const [key, route] of Object.entries(routes)) {
84
+ const lifecycle = route.lifecycle;
85
+ if (!Number.isSafeInteger(lifecycle.version) || lifecycle.version < 1) {
86
+ throw new AccessError("ROUTE_VERSION_INVALID", `"${key}": route version must be a positive integer.`);
87
+ }
88
+ const validTimestamp = (value) => Number.isFinite(Date.parse(value));
89
+ if (lifecycle.introducedAt !== "initial" && !validTimestamp(lifecycle.introducedAt)) {
90
+ throw new AccessError("ROUTE_LIFECYCLE_INVALID", `"${key}": introducedAt must be an ISO timestamp or initial.`);
91
+ }
92
+ if (lifecycle.status === "deprecated" && (!validTimestamp(lifecycle.deprecatedAt) || !validTimestamp(lifecycle.expiresAt) || Date.parse(lifecycle.deprecatedAt) > Date.parse(lifecycle.expiresAt) || lifecycle.replacement === key)) {
93
+ throw new AccessError("ROUTE_LIFECYCLE_INVALID", `"${key}": deprecated route lifecycle is invalid.`);
94
+ }
95
+ if (lifecycle.status === "retired" && (!validTimestamp(lifecycle.retiredAt) || lifecycle.replacement === key)) {
96
+ throw new AccessError("ROUTE_LIFECYCLE_INVALID", `"${key}": retired route lifecycle is invalid.`);
97
+ }
70
98
  if (!/^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/.test(route.accessGroup)) {
71
99
  throw new AccessError("ROUTE_ACCESS_INVALID", `"${key}": accessGroup must be a bounded name.`);
72
100
  }
@@ -224,6 +252,10 @@ function defineAccessControl(config) {
224
252
  const route = config.routes[key];
225
253
  if (!route)
226
254
  return false;
255
+ if (route.lifecycle?.status === "retired")
256
+ return false;
257
+ if (route.lifecycle?.status === "deprecated" && Date.parse(context?.now ?? new Date().toISOString()) >= Date.parse(route.lifecycle.expiresAt))
258
+ return false;
227
259
  if (route.feature && !features.includes(route.feature))
228
260
  return false;
229
261
  if (context?.stage && route.stages && !route.stages.includes(context.stage))
@@ -275,7 +307,7 @@ function impactOfDisabling(access, enabledAfter) {
275
307
  }
276
308
  return broken;
277
309
  }
278
- var VERSION = "0.1.13";
310
+ var VERSION = "0.1.15";
279
311
 
280
312
  // src/pipeline.ts
281
313
  function defineHandlers(routes, handlers) {
package/dist/header.js CHANGED
@@ -59,14 +59,42 @@ function defineFactors(factors) {
59
59
  }
60
60
  return factors;
61
61
  }
62
+ var ROUTE_LIFECYCLE_STATES = ["active", "deprecated", "retired"];
62
63
  function page(label, options = {}) {
63
- return { kind: "page", label, accessGroup: "default", ...options };
64
+ return {
65
+ kind: "page",
66
+ label,
67
+ accessGroup: "default",
68
+ lifecycle: { version: 1, status: "active", introducedAt: "initial" },
69
+ ...options
70
+ };
64
71
  }
65
72
  function action(label, method, options = {}) {
66
- return { kind: "action", label, method, accessGroup: "default", ...options };
73
+ return {
74
+ kind: "action",
75
+ label,
76
+ method,
77
+ accessGroup: "default",
78
+ lifecycle: { version: 1, status: "active", introducedAt: "initial" },
79
+ ...options
80
+ };
67
81
  }
68
82
  function defineRoutes(routes) {
69
83
  for (const [key, route] of Object.entries(routes)) {
84
+ const lifecycle = route.lifecycle;
85
+ if (!Number.isSafeInteger(lifecycle.version) || lifecycle.version < 1) {
86
+ throw new AccessError("ROUTE_VERSION_INVALID", `"${key}": route version must be a positive integer.`);
87
+ }
88
+ const validTimestamp = (value) => Number.isFinite(Date.parse(value));
89
+ if (lifecycle.introducedAt !== "initial" && !validTimestamp(lifecycle.introducedAt)) {
90
+ throw new AccessError("ROUTE_LIFECYCLE_INVALID", `"${key}": introducedAt must be an ISO timestamp or initial.`);
91
+ }
92
+ if (lifecycle.status === "deprecated" && (!validTimestamp(lifecycle.deprecatedAt) || !validTimestamp(lifecycle.expiresAt) || Date.parse(lifecycle.deprecatedAt) > Date.parse(lifecycle.expiresAt) || lifecycle.replacement === key)) {
93
+ throw new AccessError("ROUTE_LIFECYCLE_INVALID", `"${key}": deprecated route lifecycle is invalid.`);
94
+ }
95
+ if (lifecycle.status === "retired" && (!validTimestamp(lifecycle.retiredAt) || lifecycle.replacement === key)) {
96
+ throw new AccessError("ROUTE_LIFECYCLE_INVALID", `"${key}": retired route lifecycle is invalid.`);
97
+ }
70
98
  if (!/^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/.test(route.accessGroup)) {
71
99
  throw new AccessError("ROUTE_ACCESS_INVALID", `"${key}": accessGroup must be a bounded name.`);
72
100
  }
@@ -224,6 +252,10 @@ function defineAccessControl(config) {
224
252
  const route = config.routes[key];
225
253
  if (!route)
226
254
  return false;
255
+ if (route.lifecycle?.status === "retired")
256
+ return false;
257
+ if (route.lifecycle?.status === "deprecated" && Date.parse(context?.now ?? new Date().toISOString()) >= Date.parse(route.lifecycle.expiresAt))
258
+ return false;
227
259
  if (route.feature && !features.includes(route.feature))
228
260
  return false;
229
261
  if (context?.stage && route.stages && !route.stages.includes(context.stage))
@@ -275,7 +307,7 @@ function impactOfDisabling(access, enabledAfter) {
275
307
  }
276
308
  return broken;
277
309
  }
278
- var VERSION = "0.1.13";
310
+ var VERSION = "0.1.15";
279
311
 
280
312
  // src/principal.ts
281
313
  var ATOM = /^[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,255}$/;
package/dist/index.d.ts CHANGED
@@ -127,8 +127,35 @@ export interface RouteContract {
127
127
  */
128
128
  response?: Record<number, unknown>;
129
129
  }
130
+ export declare const ROUTE_LIFECYCLE_STATES: readonly ["active", "deprecated", "retired"];
131
+ export type RouteLifecycleState = (typeof ROUTE_LIFECYCLE_STATES)[number];
132
+ /**
133
+ * Lifecycle of one exact public route version. Retired or expired versions are
134
+ * absent (404), so old code is neither compiled as an active fallback nor
135
+ * accidentally kept reachable forever.
136
+ */
137
+ export type RouteVersionLifecycle = Readonly<{
138
+ version: number;
139
+ status: 'active';
140
+ introducedAt: string;
141
+ }> | Readonly<{
142
+ version: number;
143
+ status: 'deprecated';
144
+ introducedAt: string;
145
+ deprecatedAt: string;
146
+ expiresAt: string;
147
+ replacement: string;
148
+ }> | Readonly<{
149
+ version: number;
150
+ status: 'retired';
151
+ introducedAt: string;
152
+ retiredAt: string;
153
+ replacement?: string;
154
+ }>;
130
155
  export interface RouteBase extends RouteContract {
131
156
  label: string;
157
+ /** Exact API/page version and its support window. */
158
+ lifecycle: RouteVersionLifecycle;
132
159
  /** Open host vocabulary used to scope delegated role assignments. */
133
160
  accessGroup: string;
134
161
  /** Disabled feature → 404, never 403. A 403 confirms the route exists. */
@@ -317,6 +344,7 @@ export interface AccessControl<R extends RouteRegistry> {
317
344
  exists(key: string, context?: {
318
345
  stage?: string;
319
346
  realm?: string;
347
+ now?: string;
320
348
  }): boolean;
321
349
  sessionPolicyFor(key: string): SessionPolicy<string> | undefined;
322
350
  actionPolicyFor(key: string): ActionPolicy<string> | undefined;
@@ -379,4 +407,4 @@ export declare function impactOfDisabling<R extends RouteRegistry>(access: Acces
379
407
  available: number;
380
408
  required: number;
381
409
  }[];
382
- export declare const VERSION = "0.1.13";
410
+ export declare const VERSION = "0.1.15";
package/dist/index.js CHANGED
@@ -59,14 +59,42 @@ function defineFactors(factors) {
59
59
  }
60
60
  return factors;
61
61
  }
62
+ var ROUTE_LIFECYCLE_STATES = ["active", "deprecated", "retired"];
62
63
  function page(label, options = {}) {
63
- return { kind: "page", label, accessGroup: "default", ...options };
64
+ return {
65
+ kind: "page",
66
+ label,
67
+ accessGroup: "default",
68
+ lifecycle: { version: 1, status: "active", introducedAt: "initial" },
69
+ ...options
70
+ };
64
71
  }
65
72
  function action(label, method, options = {}) {
66
- return { kind: "action", label, method, accessGroup: "default", ...options };
73
+ return {
74
+ kind: "action",
75
+ label,
76
+ method,
77
+ accessGroup: "default",
78
+ lifecycle: { version: 1, status: "active", introducedAt: "initial" },
79
+ ...options
80
+ };
67
81
  }
68
82
  function defineRoutes(routes) {
69
83
  for (const [key, route] of Object.entries(routes)) {
84
+ const lifecycle = route.lifecycle;
85
+ if (!Number.isSafeInteger(lifecycle.version) || lifecycle.version < 1) {
86
+ throw new AccessError("ROUTE_VERSION_INVALID", `"${key}": route version must be a positive integer.`);
87
+ }
88
+ const validTimestamp = (value) => Number.isFinite(Date.parse(value));
89
+ if (lifecycle.introducedAt !== "initial" && !validTimestamp(lifecycle.introducedAt)) {
90
+ throw new AccessError("ROUTE_LIFECYCLE_INVALID", `"${key}": introducedAt must be an ISO timestamp or initial.`);
91
+ }
92
+ if (lifecycle.status === "deprecated" && (!validTimestamp(lifecycle.deprecatedAt) || !validTimestamp(lifecycle.expiresAt) || Date.parse(lifecycle.deprecatedAt) > Date.parse(lifecycle.expiresAt) || lifecycle.replacement === key)) {
93
+ throw new AccessError("ROUTE_LIFECYCLE_INVALID", `"${key}": deprecated route lifecycle is invalid.`);
94
+ }
95
+ if (lifecycle.status === "retired" && (!validTimestamp(lifecycle.retiredAt) || lifecycle.replacement === key)) {
96
+ throw new AccessError("ROUTE_LIFECYCLE_INVALID", `"${key}": retired route lifecycle is invalid.`);
97
+ }
70
98
  if (!/^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/.test(route.accessGroup)) {
71
99
  throw new AccessError("ROUTE_ACCESS_INVALID", `"${key}": accessGroup must be a bounded name.`);
72
100
  }
@@ -224,6 +252,10 @@ function defineAccessControl(config) {
224
252
  const route = config.routes[key];
225
253
  if (!route)
226
254
  return false;
255
+ if (route.lifecycle?.status === "retired")
256
+ return false;
257
+ if (route.lifecycle?.status === "deprecated" && Date.parse(context?.now ?? new Date().toISOString()) >= Date.parse(route.lifecycle.expiresAt))
258
+ return false;
227
259
  if (route.feature && !features.includes(route.feature))
228
260
  return false;
229
261
  if (context?.stage && route.stages && !route.stages.includes(context.stage))
@@ -275,7 +307,7 @@ function impactOfDisabling(access, enabledAfter) {
275
307
  }
276
308
  return broken;
277
309
  }
278
- var VERSION = "0.1.13";
310
+ var VERSION = "0.1.15";
279
311
  export {
280
312
  stemOf,
281
313
  resolveActionFactors,
@@ -297,5 +329,6 @@ export {
297
329
  Settled,
298
330
  SCHEMA_VERSION,
299
331
  Refusal,
332
+ ROUTE_LIFECYCLE_STATES,
300
333
  AccessError
301
334
  };
package/dist/pipeline.js CHANGED
@@ -59,14 +59,42 @@ function defineFactors(factors) {
59
59
  }
60
60
  return factors;
61
61
  }
62
+ var ROUTE_LIFECYCLE_STATES = ["active", "deprecated", "retired"];
62
63
  function page(label, options = {}) {
63
- return { kind: "page", label, accessGroup: "default", ...options };
64
+ return {
65
+ kind: "page",
66
+ label,
67
+ accessGroup: "default",
68
+ lifecycle: { version: 1, status: "active", introducedAt: "initial" },
69
+ ...options
70
+ };
64
71
  }
65
72
  function action(label, method, options = {}) {
66
- return { kind: "action", label, method, accessGroup: "default", ...options };
73
+ return {
74
+ kind: "action",
75
+ label,
76
+ method,
77
+ accessGroup: "default",
78
+ lifecycle: { version: 1, status: "active", introducedAt: "initial" },
79
+ ...options
80
+ };
67
81
  }
68
82
  function defineRoutes(routes) {
69
83
  for (const [key, route] of Object.entries(routes)) {
84
+ const lifecycle = route.lifecycle;
85
+ if (!Number.isSafeInteger(lifecycle.version) || lifecycle.version < 1) {
86
+ throw new AccessError("ROUTE_VERSION_INVALID", `"${key}": route version must be a positive integer.`);
87
+ }
88
+ const validTimestamp = (value) => Number.isFinite(Date.parse(value));
89
+ if (lifecycle.introducedAt !== "initial" && !validTimestamp(lifecycle.introducedAt)) {
90
+ throw new AccessError("ROUTE_LIFECYCLE_INVALID", `"${key}": introducedAt must be an ISO timestamp or initial.`);
91
+ }
92
+ if (lifecycle.status === "deprecated" && (!validTimestamp(lifecycle.deprecatedAt) || !validTimestamp(lifecycle.expiresAt) || Date.parse(lifecycle.deprecatedAt) > Date.parse(lifecycle.expiresAt) || lifecycle.replacement === key)) {
93
+ throw new AccessError("ROUTE_LIFECYCLE_INVALID", `"${key}": deprecated route lifecycle is invalid.`);
94
+ }
95
+ if (lifecycle.status === "retired" && (!validTimestamp(lifecycle.retiredAt) || lifecycle.replacement === key)) {
96
+ throw new AccessError("ROUTE_LIFECYCLE_INVALID", `"${key}": retired route lifecycle is invalid.`);
97
+ }
70
98
  if (!/^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/.test(route.accessGroup)) {
71
99
  throw new AccessError("ROUTE_ACCESS_INVALID", `"${key}": accessGroup must be a bounded name.`);
72
100
  }
@@ -224,6 +252,10 @@ function defineAccessControl(config) {
224
252
  const route = config.routes[key];
225
253
  if (!route)
226
254
  return false;
255
+ if (route.lifecycle?.status === "retired")
256
+ return false;
257
+ if (route.lifecycle?.status === "deprecated" && Date.parse(context?.now ?? new Date().toISOString()) >= Date.parse(route.lifecycle.expiresAt))
258
+ return false;
227
259
  if (route.feature && !features.includes(route.feature))
228
260
  return false;
229
261
  if (context?.stage && route.stages && !route.stages.includes(context.stage))
@@ -275,7 +307,7 @@ function impactOfDisabling(access, enabledAfter) {
275
307
  }
276
308
  return broken;
277
309
  }
278
- var VERSION = "0.1.13";
310
+ var VERSION = "0.1.15";
279
311
 
280
312
  // src/pipeline.ts
281
313
  function defineHandlers(routes, handlers) {
@@ -59,14 +59,42 @@ function defineFactors(factors) {
59
59
  }
60
60
  return factors;
61
61
  }
62
+ var ROUTE_LIFECYCLE_STATES = ["active", "deprecated", "retired"];
62
63
  function page(label, options = {}) {
63
- return { kind: "page", label, accessGroup: "default", ...options };
64
+ return {
65
+ kind: "page",
66
+ label,
67
+ accessGroup: "default",
68
+ lifecycle: { version: 1, status: "active", introducedAt: "initial" },
69
+ ...options
70
+ };
64
71
  }
65
72
  function action(label, method, options = {}) {
66
- return { kind: "action", label, method, accessGroup: "default", ...options };
73
+ return {
74
+ kind: "action",
75
+ label,
76
+ method,
77
+ accessGroup: "default",
78
+ lifecycle: { version: 1, status: "active", introducedAt: "initial" },
79
+ ...options
80
+ };
67
81
  }
68
82
  function defineRoutes(routes) {
69
83
  for (const [key, route] of Object.entries(routes)) {
84
+ const lifecycle = route.lifecycle;
85
+ if (!Number.isSafeInteger(lifecycle.version) || lifecycle.version < 1) {
86
+ throw new AccessError("ROUTE_VERSION_INVALID", `"${key}": route version must be a positive integer.`);
87
+ }
88
+ const validTimestamp = (value) => Number.isFinite(Date.parse(value));
89
+ if (lifecycle.introducedAt !== "initial" && !validTimestamp(lifecycle.introducedAt)) {
90
+ throw new AccessError("ROUTE_LIFECYCLE_INVALID", `"${key}": introducedAt must be an ISO timestamp or initial.`);
91
+ }
92
+ if (lifecycle.status === "deprecated" && (!validTimestamp(lifecycle.deprecatedAt) || !validTimestamp(lifecycle.expiresAt) || Date.parse(lifecycle.deprecatedAt) > Date.parse(lifecycle.expiresAt) || lifecycle.replacement === key)) {
93
+ throw new AccessError("ROUTE_LIFECYCLE_INVALID", `"${key}": deprecated route lifecycle is invalid.`);
94
+ }
95
+ if (lifecycle.status === "retired" && (!validTimestamp(lifecycle.retiredAt) || lifecycle.replacement === key)) {
96
+ throw new AccessError("ROUTE_LIFECYCLE_INVALID", `"${key}": retired route lifecycle is invalid.`);
97
+ }
70
98
  if (!/^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/.test(route.accessGroup)) {
71
99
  throw new AccessError("ROUTE_ACCESS_INVALID", `"${key}": accessGroup must be a bounded name.`);
72
100
  }
@@ -224,6 +252,10 @@ function defineAccessControl(config) {
224
252
  const route = config.routes[key];
225
253
  if (!route)
226
254
  return false;
255
+ if (route.lifecycle?.status === "retired")
256
+ return false;
257
+ if (route.lifecycle?.status === "deprecated" && Date.parse(context?.now ?? new Date().toISOString()) >= Date.parse(route.lifecycle.expiresAt))
258
+ return false;
227
259
  if (route.feature && !features.includes(route.feature))
228
260
  return false;
229
261
  if (context?.stage && route.stages && !route.stages.includes(context.stage))
@@ -275,7 +307,7 @@ function impactOfDisabling(access, enabledAfter) {
275
307
  }
276
308
  return broken;
277
309
  }
278
- var VERSION = "0.1.13";
310
+ var VERSION = "0.1.15";
279
311
 
280
312
  // src/security.ts
281
313
  var HEX = Array.from({ length: 256 }, (_, index) => index.toString(16).padStart(2, "0"));