@forgezero/access 0.1.14 → 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 +56 -0
- package/dist/conditions.js +35 -3
- package/dist/effects.js +35 -3
- package/dist/elysia.js +35 -3
- package/dist/fetch.js +35 -3
- package/dist/header.js +35 -3
- package/dist/index.d.ts +29 -1
- package/dist/index.js +36 -3
- package/dist/pipeline.js +35 -3
- package/dist/principal-session.js +35 -3
- package/dist/principal.js +35 -3
- package/dist/schema.d.ts +78 -0
- package/dist/schema.js +171 -0
- package/dist/testing.js +35 -3
- package/dist/vault.d.ts +177 -0
- package/dist/vault.js +264 -0
- package/package.json +9 -1
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.
|
package/dist/conditions.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 {
|
|
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 {
|
|
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.
|
|
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 {
|
|
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 {
|
|
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.
|
|
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 {
|
|
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 {
|
|
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.
|
|
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 {
|
|
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 {
|
|
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.
|
|
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 {
|
|
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 {
|
|
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.
|
|
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.
|
|
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 {
|
|
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 {
|
|
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.
|
|
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 {
|
|
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 {
|
|
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.
|
|
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 {
|
|
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 {
|
|
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.
|
|
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"));
|