@forgezero/access 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/dist/authenticator.d.ts +118 -0
- package/dist/authenticator.js +186 -0
- package/dist/ceremony-modes.d.ts +71 -0
- package/dist/ceremony-modes.js +55 -0
- package/dist/client.d.ts +76 -0
- package/dist/client.js +107 -0
- package/dist/conditions.d.ts +280 -0
- package/dist/conditions.js +491 -0
- package/dist/effects.d.ts +168 -0
- package/dist/effects.js +505 -0
- package/dist/elysia.d.ts +78 -0
- package/dist/elysia.js +575 -0
- package/dist/fetch.d.ts +41 -0
- package/dist/fetch.js +587 -0
- package/dist/index.d.ts +379 -0
- package/dist/index.js +298 -0
- package/dist/pipeline.d.ts +92 -0
- package/dist/pipeline.js +528 -0
- package/dist/rate-limit.d.ts +54 -0
- package/dist/rate-limit.js +89 -0
- package/dist/security.d.ts +102 -0
- package/dist/security.js +141 -0
- package/dist/testing.d.ts +63 -0
- package/dist/testing.js +357 -0
- package/package.json +93 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// src/rate-limit.ts
|
|
2
|
+
function memoryStore() {
|
|
3
|
+
const counters = new Map;
|
|
4
|
+
const sweep = (now) => {
|
|
5
|
+
if (counters.size < 1000)
|
|
6
|
+
return;
|
|
7
|
+
for (const [key, entry] of counters) {
|
|
8
|
+
if (entry.resetAt <= now)
|
|
9
|
+
counters.delete(key);
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
return {
|
|
13
|
+
async take(key, limit, windowSeconds) {
|
|
14
|
+
const now = Date.now();
|
|
15
|
+
sweep(now);
|
|
16
|
+
const entry = counters.get(key);
|
|
17
|
+
if (!entry || entry.resetAt <= now) {
|
|
18
|
+
counters.set(key, { count: 1, resetAt: now + windowSeconds * 1000 });
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
if (entry.count >= limit)
|
|
22
|
+
return false;
|
|
23
|
+
entry.count += 1;
|
|
24
|
+
return true;
|
|
25
|
+
},
|
|
26
|
+
reset: () => counters.clear(),
|
|
27
|
+
size: () => counters.size
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function redisStore(redis, prefix = "rl:") {
|
|
31
|
+
return {
|
|
32
|
+
async take(key, limit, windowSeconds) {
|
|
33
|
+
const namespaced = prefix + key;
|
|
34
|
+
const count = await redis.incr(namespaced);
|
|
35
|
+
if (count === 1)
|
|
36
|
+
await redis.expire(namespaced, windowSeconds);
|
|
37
|
+
return count <= limit;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function durableObjectStore(storage, prefix = "rl:") {
|
|
42
|
+
return {
|
|
43
|
+
async take(key, limit, windowSeconds) {
|
|
44
|
+
const namespaced = prefix + key;
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
const entry = await storage.get(namespaced);
|
|
47
|
+
if (!entry || entry.resetAt <= now) {
|
|
48
|
+
await storage.put(namespaced, { count: 1, resetAt: now + windowSeconds * 1000 });
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
if (entry.count >= limit)
|
|
52
|
+
return false;
|
|
53
|
+
await storage.put(namespaced, { count: entry.count + 1, resetAt: entry.resetAt });
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function slidingWindowStore(base) {
|
|
59
|
+
const windows = new Map;
|
|
60
|
+
return {
|
|
61
|
+
async take(key, limit, windowSeconds) {
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
const size = windowSeconds * 1000;
|
|
64
|
+
const entry = windows.get(key);
|
|
65
|
+
if (!entry || now - entry.startedAt >= size * 2) {
|
|
66
|
+
windows.set(key, { current: 1, previous: 0, startedAt: now });
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
const elapsed = now - entry.startedAt;
|
|
70
|
+
if (elapsed >= size) {
|
|
71
|
+
entry.previous = entry.current;
|
|
72
|
+
entry.current = 0;
|
|
73
|
+
entry.startedAt = now;
|
|
74
|
+
}
|
|
75
|
+
const overlap = 1 - (now - entry.startedAt) / size;
|
|
76
|
+
const estimated = entry.previous * overlap + entry.current;
|
|
77
|
+
if (estimated >= limit)
|
|
78
|
+
return false;
|
|
79
|
+
entry.current += 1;
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export {
|
|
85
|
+
slidingWindowStore,
|
|
86
|
+
redisStore,
|
|
87
|
+
memoryStore,
|
|
88
|
+
durableObjectStore
|
|
89
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security helpers — the primitives every project rewrites badly.
|
|
3
|
+
*
|
|
4
|
+
* Web Crypto only, so this runs unchanged on Bun, Node 18+, Workers and Deno.
|
|
5
|
+
* No dependency, and nothing here invents a construction: these are correct
|
|
6
|
+
* uses of standard primitives, written once so the next project does not get
|
|
7
|
+
* the timing comparison or the token entropy subtly wrong.
|
|
8
|
+
*/
|
|
9
|
+
export declare function toHex(bytes: Uint8Array): string;
|
|
10
|
+
export declare function fromHex(hex: string): Uint8Array;
|
|
11
|
+
/** URL-safe base64 without padding — safe in a path, a query or a cookie. */
|
|
12
|
+
export declare function toBase64Url(bytes: Uint8Array): string;
|
|
13
|
+
export declare function fromBase64Url(value: string): Uint8Array;
|
|
14
|
+
/**
|
|
15
|
+
* Compare in time independent of where the difference is.
|
|
16
|
+
*
|
|
17
|
+
* `a === b` returns on the first differing byte, so an attacker who can measure
|
|
18
|
+
* the response learns the prefix and recovers a token one character at a time.
|
|
19
|
+
* The length check leaks only the length, which is not secret.
|
|
20
|
+
*/
|
|
21
|
+
export declare function timingSafeEqual(a: string, b: string): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* A token with real entropy.
|
|
24
|
+
*
|
|
25
|
+
* Defaults to 32 bytes — 256 bits — because a 16-byte token is fine until it
|
|
26
|
+
* is a session identifier, and nobody revisits the default afterwards.
|
|
27
|
+
* `Math.random()` is not a CSPRNG and must never appear near a credential.
|
|
28
|
+
*/
|
|
29
|
+
export declare function randomToken(bytes?: number): string;
|
|
30
|
+
export declare function randomHex(bytes?: number): string;
|
|
31
|
+
/** Numeric OTP with uniform digits — see `randomInt` for why modulo is wrong. */
|
|
32
|
+
export declare function randomDigits(length?: number): string;
|
|
33
|
+
/**
|
|
34
|
+
* Uniform integer in [0, max).
|
|
35
|
+
*
|
|
36
|
+
* Rejection sampling rather than `% max`: modulo of a uniform byte is biased
|
|
37
|
+
* whenever 256 is not a multiple of max, which for a 10-digit OTP makes the
|
|
38
|
+
* first six digits measurably likelier. Small, real, and free to avoid.
|
|
39
|
+
*/
|
|
40
|
+
export declare function randomInt(max: number): number;
|
|
41
|
+
export declare function sha256(input: string | Uint8Array): Promise<string>;
|
|
42
|
+
/**
|
|
43
|
+
* Hash a bearer token for storage.
|
|
44
|
+
*
|
|
45
|
+
* Unsalted SHA-256 is correct HERE and wrong for passwords. A token already has
|
|
46
|
+
* 256 bits of entropy, so there is nothing to brute-force and a salt would only
|
|
47
|
+
* prevent the lookup we want. A password has perhaps 30 bits, which is why it
|
|
48
|
+
* needs a slow KDF instead.
|
|
49
|
+
*/
|
|
50
|
+
export declare const hashToken: typeof sha256;
|
|
51
|
+
export declare function hmacSha256(key: string | Uint8Array, message: string): Promise<string>;
|
|
52
|
+
/** Verify an HMAC without leaking where it diverged. */
|
|
53
|
+
export declare function verifyHmac(key: string | Uint8Array, message: string, signature: string): Promise<boolean>;
|
|
54
|
+
/**
|
|
55
|
+
* Derive a key from a high-entropy secret.
|
|
56
|
+
*
|
|
57
|
+
* `info` is domain separation and is not optional in practice: deriving two
|
|
58
|
+
* keys from one secret without it produces the SAME key, so a signing key and
|
|
59
|
+
* an encryption key become interchangeable and the separation you thought you
|
|
60
|
+
* had does not exist.
|
|
61
|
+
*/
|
|
62
|
+
export declare function hkdf(secret: Uint8Array, info: string, length?: number, salt?: Uint8Array<ArrayBuffer>): Promise<Uint8Array>;
|
|
63
|
+
export interface Sealed {
|
|
64
|
+
/** Nonce. Never reused with the same key — see `seal`. */
|
|
65
|
+
iv: string;
|
|
66
|
+
ciphertext: string;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* AES-256-GCM with additional authenticated data.
|
|
70
|
+
*
|
|
71
|
+
* `aad` binds the ciphertext to its context — tenant, project, entry, version.
|
|
72
|
+
* A blob moved anywhere else then fails its tag rather than decrypting, which
|
|
73
|
+
* turns a scoping bug into a loud failure instead of silent cross-tenant
|
|
74
|
+
* disclosure.
|
|
75
|
+
*
|
|
76
|
+
* The IV is 12 random bytes per call. GCM catastrophically loses
|
|
77
|
+
* confidentiality on nonce reuse, so it is never derived from a counter or a
|
|
78
|
+
* timestamp here.
|
|
79
|
+
*/
|
|
80
|
+
export declare function seal(key: Uint8Array, plaintext: string, aad?: string): Promise<Sealed>;
|
|
81
|
+
/** Throws on a tag mismatch — wrong key, tampering, or the wrong `aad`. */
|
|
82
|
+
export declare function open(key: Uint8Array, sealed: Sealed, aad?: string): Promise<string>;
|
|
83
|
+
export declare const nowSeconds: () => number;
|
|
84
|
+
/**
|
|
85
|
+
* Whether something is still live.
|
|
86
|
+
*
|
|
87
|
+
* Checked in the application even where a TTL index exists, because a database
|
|
88
|
+
* reaper runs on its own schedule — often a minute or more behind under load —
|
|
89
|
+
* so an expired row stays readable for a while after it died. Trusting the
|
|
90
|
+
* index alone means the session outlives its own expiry.
|
|
91
|
+
*/
|
|
92
|
+
export declare const isLive: (expiresAtSec: number, at?: number) => boolean;
|
|
93
|
+
declare const SECRET_NAME: RegExp;
|
|
94
|
+
/**
|
|
95
|
+
* Strip anything credential-shaped before logging or exporting.
|
|
96
|
+
*
|
|
97
|
+
* Logs, span attributes and error payloads all reach places the value was never
|
|
98
|
+
* meant to go, and every one of them is an exfiltration path nobody
|
|
99
|
+
* deliberately built.
|
|
100
|
+
*/
|
|
101
|
+
export declare function redact<T extends Record<string, unknown>>(value: T): Record<string, unknown>;
|
|
102
|
+
export { SECRET_NAME as secretFieldPattern };
|
package/dist/security.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// src/security.ts
|
|
2
|
+
var HEX = Array.from({ length: 256 }, (_, index) => index.toString(16).padStart(2, "0"));
|
|
3
|
+
function toHex(bytes) {
|
|
4
|
+
let out = "";
|
|
5
|
+
for (const byte of bytes)
|
|
6
|
+
out += HEX[byte];
|
|
7
|
+
return out;
|
|
8
|
+
}
|
|
9
|
+
function fromHex(hex) {
|
|
10
|
+
if (hex.length % 2 !== 0)
|
|
11
|
+
throw new Error("Hex string must have an even length.");
|
|
12
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
13
|
+
for (let index = 0;index < bytes.length; index += 1) {
|
|
14
|
+
bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
|
|
15
|
+
}
|
|
16
|
+
return bytes;
|
|
17
|
+
}
|
|
18
|
+
function toBase64Url(bytes) {
|
|
19
|
+
let binary = "";
|
|
20
|
+
for (const byte of bytes)
|
|
21
|
+
binary += String.fromCharCode(byte);
|
|
22
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
23
|
+
}
|
|
24
|
+
function fromBase64Url(value) {
|
|
25
|
+
const padded = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
26
|
+
const binary = atob(padded + "=".repeat((4 - padded.length % 4) % 4));
|
|
27
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
28
|
+
}
|
|
29
|
+
function timingSafeEqual(a, b) {
|
|
30
|
+
if (a.length !== b.length)
|
|
31
|
+
return false;
|
|
32
|
+
let difference = 0;
|
|
33
|
+
for (let index = 0;index < a.length; index += 1) {
|
|
34
|
+
difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
|
|
35
|
+
}
|
|
36
|
+
return difference === 0;
|
|
37
|
+
}
|
|
38
|
+
function randomToken(bytes = 32) {
|
|
39
|
+
const buffer = new Uint8Array(bytes);
|
|
40
|
+
crypto.getRandomValues(buffer);
|
|
41
|
+
return toBase64Url(buffer);
|
|
42
|
+
}
|
|
43
|
+
function randomHex(bytes = 32) {
|
|
44
|
+
const buffer = new Uint8Array(bytes);
|
|
45
|
+
crypto.getRandomValues(buffer);
|
|
46
|
+
return toHex(buffer);
|
|
47
|
+
}
|
|
48
|
+
function randomDigits(length = 6) {
|
|
49
|
+
let digits = "";
|
|
50
|
+
for (let index = 0;index < length; index += 1)
|
|
51
|
+
digits += randomInt(10).toString();
|
|
52
|
+
return digits;
|
|
53
|
+
}
|
|
54
|
+
function randomInt(max) {
|
|
55
|
+
if (max <= 0 || max > 256)
|
|
56
|
+
throw new Error("randomInt supports 1..256.");
|
|
57
|
+
const limit = Math.floor(256 / max) * max;
|
|
58
|
+
const buffer = new Uint8Array(1);
|
|
59
|
+
for (;; ) {
|
|
60
|
+
crypto.getRandomValues(buffer);
|
|
61
|
+
if (buffer[0] < limit)
|
|
62
|
+
return buffer[0] % max;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async function sha256(input) {
|
|
66
|
+
const bytes = typeof input === "string" ? new TextEncoder().encode(input) : input;
|
|
67
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
68
|
+
return toHex(new Uint8Array(digest));
|
|
69
|
+
}
|
|
70
|
+
var hashToken = sha256;
|
|
71
|
+
async function hmacSha256(key, message) {
|
|
72
|
+
const keyBytes = typeof key === "string" ? new TextEncoder().encode(key) : key;
|
|
73
|
+
const imported = await crypto.subtle.importKey("raw", keyBytes, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
74
|
+
const signature = await crypto.subtle.sign("HMAC", imported, new TextEncoder().encode(message));
|
|
75
|
+
return toHex(new Uint8Array(signature));
|
|
76
|
+
}
|
|
77
|
+
async function verifyHmac(key, message, signature) {
|
|
78
|
+
return timingSafeEqual(await hmacSha256(key, message), signature);
|
|
79
|
+
}
|
|
80
|
+
async function hkdf(secret, info, length = 32, salt = new Uint8Array(32)) {
|
|
81
|
+
const key = await crypto.subtle.importKey("raw", secret, "HKDF", false, ["deriveBits"]);
|
|
82
|
+
const bits = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt, info: new TextEncoder().encode(info) }, key, length * 8);
|
|
83
|
+
return new Uint8Array(bits);
|
|
84
|
+
}
|
|
85
|
+
async function seal(key, plaintext, aad) {
|
|
86
|
+
const iv = new Uint8Array(12);
|
|
87
|
+
crypto.getRandomValues(iv);
|
|
88
|
+
const imported = await crypto.subtle.importKey("raw", key, "AES-GCM", false, ["encrypt"]);
|
|
89
|
+
const ciphertext = await crypto.subtle.encrypt({
|
|
90
|
+
name: "AES-GCM",
|
|
91
|
+
iv,
|
|
92
|
+
...aad ? { additionalData: new TextEncoder().encode(aad) } : {}
|
|
93
|
+
}, imported, new TextEncoder().encode(plaintext));
|
|
94
|
+
return { iv: toBase64Url(iv), ciphertext: toBase64Url(new Uint8Array(ciphertext)) };
|
|
95
|
+
}
|
|
96
|
+
async function open(key, sealed, aad) {
|
|
97
|
+
const imported = await crypto.subtle.importKey("raw", key, "AES-GCM", false, ["decrypt"]);
|
|
98
|
+
const plaintext = await crypto.subtle.decrypt({
|
|
99
|
+
name: "AES-GCM",
|
|
100
|
+
iv: fromBase64Url(sealed.iv),
|
|
101
|
+
...aad ? { additionalData: new TextEncoder().encode(aad) } : {}
|
|
102
|
+
}, imported, fromBase64Url(sealed.ciphertext));
|
|
103
|
+
return new TextDecoder().decode(plaintext);
|
|
104
|
+
}
|
|
105
|
+
var nowSeconds = () => Math.floor(Date.now() / 1000);
|
|
106
|
+
var isLive = (expiresAtSec, at = nowSeconds()) => expiresAtSec > at;
|
|
107
|
+
var SECRET_NAME = /token|secret|password|passphrase|credential|authorization|cookie|apikey|api_key|private/i;
|
|
108
|
+
function redact(value) {
|
|
109
|
+
const out = {};
|
|
110
|
+
for (const [name, entry] of Object.entries(value)) {
|
|
111
|
+
if (SECRET_NAME.test(name))
|
|
112
|
+
out[name] = "[redacted]";
|
|
113
|
+
else if (entry && typeof entry === "object" && !Array.isArray(entry)) {
|
|
114
|
+
out[name] = redact(entry);
|
|
115
|
+
} else
|
|
116
|
+
out[name] = entry;
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
export {
|
|
121
|
+
verifyHmac,
|
|
122
|
+
toHex,
|
|
123
|
+
toBase64Url,
|
|
124
|
+
timingSafeEqual,
|
|
125
|
+
sha256,
|
|
126
|
+
SECRET_NAME as secretFieldPattern,
|
|
127
|
+
seal,
|
|
128
|
+
redact,
|
|
129
|
+
randomToken,
|
|
130
|
+
randomInt,
|
|
131
|
+
randomHex,
|
|
132
|
+
randomDigits,
|
|
133
|
+
open,
|
|
134
|
+
nowSeconds,
|
|
135
|
+
isLive,
|
|
136
|
+
hmacSha256,
|
|
137
|
+
hkdf,
|
|
138
|
+
hashToken,
|
|
139
|
+
fromHex,
|
|
140
|
+
fromBase64Url
|
|
141
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type AccessControl, type Role, type RouteRegistry } from './index';
|
|
2
|
+
/**
|
|
3
|
+
* Assert an access decision without standing up a server.
|
|
4
|
+
*
|
|
5
|
+
* A model nobody can test is a model nobody trusts. This is what lets a tenant
|
|
6
|
+
* write "this role cannot reach this route" as a unit test rather than clicking
|
|
7
|
+
* through an environment — and it answers WHICH policy decided, which is the
|
|
8
|
+
* part a 403 in a browser never tells you.
|
|
9
|
+
*/
|
|
10
|
+
export interface SimulateArgs {
|
|
11
|
+
route: string;
|
|
12
|
+
roles?: readonly Role[];
|
|
13
|
+
/** Shorthand: role keys with every session factor already proven. */
|
|
14
|
+
as?: string | {
|
|
15
|
+
roleKeys: readonly string[];
|
|
16
|
+
factors?: readonly string[];
|
|
17
|
+
};
|
|
18
|
+
stage?: string;
|
|
19
|
+
realm?: string;
|
|
20
|
+
enabledFactors?: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
export interface Verdict {
|
|
23
|
+
allowed: boolean;
|
|
24
|
+
status: 200 | 404 | 401 | 403 | 428;
|
|
25
|
+
reason: string;
|
|
26
|
+
/** Which policy decided. The thing a browser 403 never tells you. */
|
|
27
|
+
sessionPolicy?: string;
|
|
28
|
+
actionPolicy?: string;
|
|
29
|
+
missingFactors?: readonly string[];
|
|
30
|
+
/** True when a fresh proof would still be demanded after passing layer 1. */
|
|
31
|
+
requiresStepUp: boolean;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Run layer 1 and report what layer 2 would then require.
|
|
35
|
+
*
|
|
36
|
+
* Deliberately does NOT simulate satisfying a step-up: a fresh proof is by
|
|
37
|
+
* definition something only a human can supply, and a test helper that pretended
|
|
38
|
+
* otherwise would be teaching the wrong model.
|
|
39
|
+
*/
|
|
40
|
+
export declare function simulate<R extends RouteRegistry>(access: AccessControl<R>, args: SimulateArgs, registries?: {
|
|
41
|
+
sessionPolicies?: Record<string, {
|
|
42
|
+
routes: readonly string[];
|
|
43
|
+
}>;
|
|
44
|
+
actionPolicies?: Record<string, {
|
|
45
|
+
routes: readonly string[];
|
|
46
|
+
}>;
|
|
47
|
+
}): Verdict;
|
|
48
|
+
/**
|
|
49
|
+
* Every route a set of roles can reach. Answers "what can this role actually
|
|
50
|
+
* do" without reading the grant list by hand — which is the question an audit
|
|
51
|
+
* asks and a grant list answers badly.
|
|
52
|
+
*/
|
|
53
|
+
export declare function reachableRoutes<R extends RouteRegistry>(access: AccessControl<R>, roles: readonly Role[], roleKeys: readonly string[], context?: {
|
|
54
|
+
stage?: string;
|
|
55
|
+
realm?: string;
|
|
56
|
+
}): readonly string[];
|
|
57
|
+
/**
|
|
58
|
+
* Routes reachable by NOBODY — no role grants them.
|
|
59
|
+
*
|
|
60
|
+
* Usually a rename that left a grant behind, or a route added and never granted.
|
|
61
|
+
* Both ship silently: the route works in development, where you are an admin.
|
|
62
|
+
*/
|
|
63
|
+
export declare function unreachableRoutes<R extends RouteRegistry>(access: AccessControl<R>, roles: readonly Role[]): readonly string[];
|