@c9up/warden 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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -0
  3. package/index.darwin-arm64.node +0 -0
  4. package/index.darwin-x64.node +0 -0
  5. package/index.linux-arm64-gnu.node +0 -0
  6. package/index.linux-x64-gnu.node +0 -0
  7. package/index.win32-x64-msvc.node +0 -0
  8. package/package.json +81 -0
  9. package/scripts/copy-napi.mjs +67 -0
  10. package/src/AuthManager.ts +228 -0
  11. package/src/AuthRateLimiter.ts +105 -0
  12. package/src/Guard.ts +68 -0
  13. package/src/RefreshTokenStore.ts +67 -0
  14. package/src/TokenBlacklist.ts +58 -0
  15. package/src/WardenProvider.ts +84 -0
  16. package/src/bouncer/AuthorizationResponse.ts +38 -0
  17. package/src/bouncer/BasePolicy.ts +66 -0
  18. package/src/bouncer/Bouncer.ts +236 -0
  19. package/src/bouncer/PolicyAuthorizer.ts +125 -0
  20. package/src/bouncer/decorators.ts +44 -0
  21. package/src/bouncer/evaluate.ts +104 -0
  22. package/src/bouncer/policyContext.ts +53 -0
  23. package/src/bouncer/types.ts +67 -0
  24. package/src/config.ts +48 -0
  25. package/src/configure.ts +59 -0
  26. package/src/errors.ts +25 -0
  27. package/src/firstcontact/FirstContactManager.ts +54 -0
  28. package/src/firstcontact/drivers/GitHubDriver.ts +78 -0
  29. package/src/firstcontact/drivers/GoogleDriver.ts +81 -0
  30. package/src/firstcontact/types.ts +38 -0
  31. package/src/index.ts +77 -0
  32. package/src/middleware.ts +283 -0
  33. package/src/native.ts +53 -0
  34. package/src/rights/MemoryRightsStore.ts +92 -0
  35. package/src/rights/RightsResolver.ts +84 -0
  36. package/src/rights/types.ts +58 -0
  37. package/src/services/main.ts +40 -0
  38. package/src/standalone.ts +128 -0
  39. package/src/strategies/ApiKeyStrategy.ts +54 -0
  40. package/src/strategies/JwtStrategy.ts +217 -0
  41. package/src/strategies/SessionStrategy.ts +105 -0
@@ -0,0 +1,58 @@
1
+ /**
2
+ * TokenBlacklist — revoke JWT tokens before expiry.
3
+ *
4
+ * Stores JTI (JWT ID) with expiry. Supports Memory and Redis drivers.
5
+ *
6
+ * @implements MISS-6
7
+ */
8
+
9
+ export interface BlacklistDriver {
10
+ add(jti: string, expiresAt: number): Promise<void>;
11
+ has(jti: string): Promise<boolean>;
12
+ cleanup(): Promise<void>;
13
+ }
14
+
15
+ /** In-memory blacklist for development. */
16
+ export class MemoryBlacklistDriver implements BlacklistDriver {
17
+ private entries: Map<string, number> = new Map();
18
+
19
+ async add(jti: string, expiresAt: number): Promise<void> {
20
+ this.entries.set(jti, expiresAt);
21
+ }
22
+
23
+ async has(jti: string): Promise<boolean> {
24
+ const expiresAt = this.entries.get(jti);
25
+ if (expiresAt === undefined) return false;
26
+ if (expiresAt < Date.now()) {
27
+ this.entries.delete(jti);
28
+ return false;
29
+ }
30
+ return true;
31
+ }
32
+
33
+ async cleanup(): Promise<void> {
34
+ const now = Date.now();
35
+ for (const [jti, exp] of this.entries) {
36
+ if (exp < now) this.entries.delete(jti);
37
+ }
38
+ }
39
+ }
40
+
41
+ export class TokenBlacklist {
42
+ constructor(private driver: BlacklistDriver) {}
43
+
44
+ /** Revoke a token by its JTI claim. */
45
+ async revoke(jti: string, expiresAt: number): Promise<void> {
46
+ await this.driver.add(jti, expiresAt);
47
+ }
48
+
49
+ /** Check if a token JTI is blacklisted. */
50
+ async isRevoked(jti: string): Promise<boolean> {
51
+ return this.driver.has(jti);
52
+ }
53
+
54
+ /** Remove expired entries. */
55
+ async cleanup(): Promise<void> {
56
+ return this.driver.cleanup();
57
+ }
58
+ }
@@ -0,0 +1,84 @@
1
+ import { AuthManager } from "./AuthManager.js";
2
+ import type { WardenConfig } from "./config.js";
3
+ import { WardenError } from "./errors.js";
4
+ import { MemoryRightsStore } from "./rights/MemoryRightsStore.js";
5
+ import { RightsResolver } from "./rights/RightsResolver.js";
6
+ import { _setAuth } from "./services/main.js";
7
+ import { JwtStrategy } from "./strategies/JwtStrategy.js";
8
+
9
+ interface WardenContainer {
10
+ singleton(token: unknown, factory: () => unknown): void;
11
+ resolve(token: unknown): unknown;
12
+ }
13
+
14
+ interface WardenConfigStore {
15
+ get<T = unknown>(key: string): T | undefined;
16
+ }
17
+
18
+ export interface WardenAppContext {
19
+ container: WardenContainer;
20
+ config: WardenConfigStore;
21
+ }
22
+
23
+ export default class WardenProvider {
24
+ constructor(protected app: WardenAppContext) {}
25
+
26
+ register() {
27
+ // Register JwtStrategy first so it's available before AuthManager resolves.
28
+ // The previous version nested the JwtStrategy registration inside the
29
+ // AuthManager factory — that was order-dependent and fragile.
30
+ const config = this.app.config.get<WardenConfig>("auth");
31
+
32
+ // Fail-fast at register-time so the operator gets a clear actionable
33
+ // error during boot instead of an opaque runtime crash on the first
34
+ // protected request. Previously, missing `config.auth` produced an
35
+ // AuthManager with `strategies: {}` and `defaultStrategy: 'jwt'`,
36
+ // passing the (then-permissive) constructor and erroring deep in the
37
+ // middleware loop.
38
+ if (!config?.jwt) {
39
+ throw new WardenError(
40
+ "WARDEN_NO_AUTH_CONFIG",
41
+ `@c9up/warden: no authentication strategies configured. Set config.warden.auth.jwt (or another strategy) in your reamrc.ts before registering WardenProvider.`,
42
+ );
43
+ }
44
+
45
+ const jwt = new JwtStrategy(config.jwt);
46
+ this.app.container.singleton(JwtStrategy, () => jwt);
47
+
48
+ // Rights layer (Epic 56): one resolver singleton backs BOTH the coarse
49
+ // RBAC helpers (injected into AuthManager below) and — once 56.6 lands —
50
+ // the Bouncer construction, so a coarse question and a policy question
51
+ // resolve through the SAME instance (single unification point, AC-E3).
52
+ // The store defaults to an in-memory driver and is exposed (by class)
53
+ // so an app can seed roles/grants at boot, or supply its own via
54
+ // `config.auth.rights.store` (AD5).
55
+ const rightsStore = config.rights?.store ?? new MemoryRightsStore();
56
+ const rightsResolver = new RightsResolver(rightsStore);
57
+ this.app.container.singleton(RightsResolver, () => rightsResolver);
58
+ if (rightsStore instanceof MemoryRightsStore) {
59
+ this.app.container.singleton(MemoryRightsStore, () => rightsStore);
60
+ }
61
+
62
+ this.app.container.singleton(AuthManager, () => {
63
+ const strategies: Record<string, JwtStrategy> = { jwt };
64
+ return new AuthManager({
65
+ defaultStrategy: config.defaultStrategy ?? "jwt",
66
+ strategies,
67
+ rights: rightsResolver,
68
+ });
69
+ });
70
+ // String alias so consumers that can't import the AuthManager class
71
+ // (e.g. @c9up/station, which stays agnostic of warden) can resolve
72
+ // it by name. Mirrors the convention other providers follow
73
+ // (events → "bus", rosetta → "i18n"). Without this, Station's
74
+ // `container.resolve("auth")` threw and its admin auth gate
75
+ // silently fell back to open-mode.
76
+ this.app.container.singleton("auth", () =>
77
+ this.app.container.resolve(AuthManager),
78
+ );
79
+ }
80
+
81
+ async boot() {
82
+ _setAuth(this.app.container.resolve(AuthManager) as AuthManager);
83
+ }
84
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * AuthorizationResponse — the value type every Bouncer check resolves to.
3
+ *
4
+ * Layer 2 (EVALUATION) of Warden's unified authorization (Epic 56). Faithful
5
+ * to AdonisJS Bouncer's `AuthorizationResponse`: instances are produced only by
6
+ * the `allow()` / `deny()` static constructors (the constructor is private), a
7
+ * denial defaults to HTTP 403, and a `translation` field is carried for shape
8
+ * parity with Adonis's i18n hook (always `undefined` in 56.2 — D6).
9
+ */
10
+ export class AuthorizationResponse {
11
+ readonly authorized: boolean;
12
+ readonly message?: string;
13
+ readonly status?: number;
14
+ /** Parity placeholder for an i18n binding (always undefined in 56.2 — D6). */
15
+ readonly translation?: { identifier: string; data?: Record<string, unknown> };
16
+
17
+ private constructor(
18
+ authorized: boolean,
19
+ message?: string,
20
+ status?: number,
21
+ translation?: { identifier: string; data?: Record<string, unknown> },
22
+ ) {
23
+ this.authorized = authorized;
24
+ this.message = message;
25
+ this.status = status;
26
+ this.translation = translation;
27
+ }
28
+
29
+ /** Authorized response (no status). */
30
+ static allow(): AuthorizationResponse {
31
+ return new AuthorizationResponse(true);
32
+ }
33
+
34
+ /** Denied response; `status` defaults to 403 (D6). */
35
+ static deny(message?: string, status = 403): AuthorizationResponse {
36
+ return new AuthorizationResponse(false, message, status);
37
+ }
38
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * BasePolicy — extend it to group authorization checks for a resource. Action
3
+ * methods are positional `(user, resource?) => AuthorizerResponse` (D3); decorate
4
+ * a method with `@allowGuest()` / `@action({ allowGuest: true })` to let a guest
5
+ * (null user) reach it. The optional `before` / `after` hooks run around every
6
+ * action in the Adonis evaluation order (D5 — see PolicyAuthorizer).
7
+ */
8
+
9
+ import type { UserPayload } from "../AuthManager.js";
10
+ import type { EffectivePermissions, Scope } from "../rights/types.js";
11
+ import type { AuthorizationResponse } from "./AuthorizationResponse.js";
12
+ import { emptyPermissions, getPolicyContext } from "./policyContext.js";
13
+ import type { HookResponse } from "./types.js";
14
+
15
+ export abstract class BasePolicy {
16
+ /**
17
+ * Runs before the action. A non-`undefined` return short-circuits the action
18
+ * (a `boolean`/`AuthorizationResponse` becomes the result) — this is how a
19
+ * moderator bypass or an early `deny('not found', 404)` works, including for
20
+ * a guest. `undefined`/`void` falls through to the action.
21
+ */
22
+ before?(
23
+ user: UserPayload | null,
24
+ action: string,
25
+ ...args: unknown[]
26
+ ): HookResponse;
27
+
28
+ /**
29
+ * Runs after the action (or after a `before` short-circuit), receiving the
30
+ * resolved response. A non-`undefined` return overrides it; `undefined`
31
+ * keeps it.
32
+ */
33
+ after?(
34
+ user: UserPayload | null,
35
+ action: string,
36
+ result: AuthorizationResponse,
37
+ ): HookResponse;
38
+
39
+ /**
40
+ * The Bouncer's active scope for the in-flight check (56.3). Defaults to
41
+ * `"global"` when the policy is used without a Bouncer context.
42
+ */
43
+ protected get scope(): Scope {
44
+ return getPolicyContext(this)?.scope ?? "global";
45
+ }
46
+
47
+ /**
48
+ * The resolved `EffectivePermissions` for the Bouncer's `(user, scope)` (56.3):
49
+ * role-derived ∪ ACL grants with global→tenant inheritance (per 56.1). A guest,
50
+ * a no-resolver Bouncer, or a standalone policy ⇒ empty permissions.
51
+ */
52
+ protected get permissions(): EffectivePermissions {
53
+ return getPolicyContext(this)?.permissions ?? emptyPermissions("global");
54
+ }
55
+
56
+ /**
57
+ * Tenant-isolation helper (D5 — enforceable, not automatic). `true` under the
58
+ * `global` scope (single-tenant — no boundary); under `{ tenant: T }` it is
59
+ * `resource.tenantId === T`. A policy enforces explicitly, e.g.
60
+ * `if (!this.sameTenant(post)) return false`.
61
+ */
62
+ protected sameTenant(resource: { tenantId?: string | null }): boolean {
63
+ const scope = this.scope;
64
+ return scope === "global" ? true : resource.tenantId === scope.tenant;
65
+ }
66
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Bouncer — Layer 2 (EVALUATION) of Warden's unified authorization (Epic 56).
3
+ *
4
+ * A Bouncer-faithful evaluation core (verified context7 `/adonisjs/bouncer`,
5
+ * 2026-06-01): standalone abilities via `Bouncer.ability`, class-based policies
6
+ * via `with(Policy)`, the four verbs `allows`/`denies`/`authorize`/`execute`,
7
+ * and guest-denied-by-default. The user is `UserPayload | null` (guest = null,
8
+ * D3); `authorize` throws `WARDEN_AUTHORIZATION_FAILURE` carrying `status: 403`
9
+ * (D2). 56.2 ships PURE mechanics — it does NOT consult the Layer-1
10
+ * `RightsResolver` (D4); abilities/policy methods receive `(user, ...args)` and
11
+ * the developer writes the predicate, exactly as Adonis does.
12
+ */
13
+
14
+ import type { UserPayload } from "../AuthManager.js";
15
+ import { WardenError } from "../errors.js";
16
+ import type { RightsResolver } from "../rights/RightsResolver.js";
17
+ import type { EffectivePermissions, Scope } from "../rights/types.js";
18
+ import { AuthorizationResponse } from "./AuthorizationResponse.js";
19
+ import type { BasePolicy } from "./BasePolicy.js";
20
+ import { evaluate, isAction, throwAuthorizationFailure } from "./evaluate.js";
21
+ import { PolicyAuthorizer } from "./PolicyAuthorizer.js";
22
+ import { emptyPermissions } from "./policyContext.js";
23
+ import type {
24
+ Ability,
25
+ AbilityOptions,
26
+ AuthorizerResponse,
27
+ BouncerContext,
28
+ } from "./types.js";
29
+
30
+ export class Bouncer {
31
+ readonly #user: UserPayload | null;
32
+ readonly #abilities: Record<string, Ability<never[]>>;
33
+ readonly #policies: Record<string, new () => BasePolicy>;
34
+ readonly #scope: Scope;
35
+ readonly #resolver: RightsResolver | undefined;
36
+ /** Memoized resolution — a Bouncer is fixed per `(user, scope)`, so resolve once (D3). */
37
+ #resolved: Promise<EffectivePermissions> | undefined;
38
+
39
+ constructor(
40
+ user: UserPayload | null,
41
+ abilities?: Record<string, Ability<never[]>>,
42
+ policies?: Record<string, new () => BasePolicy>,
43
+ context?: BouncerContext,
44
+ ) {
45
+ this.#user = user;
46
+ this.#abilities = abilities ?? {};
47
+ this.#policies = policies ?? {};
48
+ this.#scope = context?.scope ?? "global";
49
+ this.#resolver = context?.resolver;
50
+ }
51
+
52
+ /** The active resolution scope (default `"global"`). */
53
+ get scope(): Scope {
54
+ return this.#scope;
55
+ }
56
+
57
+ /**
58
+ * Resolve this Bouncer's `(user, scope)` to `EffectivePermissions`, memoized
59
+ * once per instance (D3). Guest (`user === null`) or no resolver ⇒ empty
60
+ * permissions — `resolve()` is never called for a guest (D9). 56.6 relocates
61
+ * this cache to the request context without changing the policy-facing API.
62
+ */
63
+ #resolvePermissions(): Promise<EffectivePermissions> {
64
+ if (this.#resolved === undefined) {
65
+ this.#resolved =
66
+ this.#resolver !== undefined && this.#user !== null
67
+ ? this.#resolver.resolve(this.#user, this.#scope)
68
+ : Promise.resolve(emptyPermissions(this.#scope));
69
+ }
70
+ return this.#resolved;
71
+ }
72
+
73
+ /**
74
+ * Define a standalone ability. Guest (null user) denied by default; pass
75
+ * `{ allowGuest: true }` to let the callback run for a guest (D5 step 2).
76
+ */
77
+ static ability<Args extends unknown[]>(
78
+ callback: (user: UserPayload, ...args: Args) => AuthorizerResponse,
79
+ ): Ability<Args>;
80
+ static ability<Args extends unknown[]>(
81
+ options: AbilityOptions,
82
+ callback: (user: UserPayload | null, ...args: Args) => AuthorizerResponse,
83
+ ): Ability<Args>;
84
+ static ability(
85
+ optionsOrCallback:
86
+ | AbilityOptions
87
+ | ((...args: never[]) => AuthorizerResponse),
88
+ maybeCallback?: (...args: never[]) => AuthorizerResponse,
89
+ ): Ability {
90
+ let allowGuest = false;
91
+ let raw: unknown;
92
+ if (typeof optionsOrCallback === "function") {
93
+ raw = optionsOrCallback;
94
+ } else {
95
+ allowGuest = optionsOrCallback.allowGuest ?? false;
96
+ raw = maybeCallback;
97
+ }
98
+ // The typed overloads above carry the real callback signature for the
99
+ // caller; the impl param is the universal-function supertype so both
100
+ // overloads are accepted. `isAction` reinterprets it as the invokable
101
+ // `Action` shape (a type guard, not a cast — a function's parameter types
102
+ // are not observable at runtime).
103
+ if (!isAction(raw)) {
104
+ throw new WardenError(
105
+ "INVALID_ABILITY",
106
+ "Bouncer.ability requires a callback alongside the options object.",
107
+ { hint: "Bouncer.ability({ allowGuest: true }, (user) => ...)" },
108
+ );
109
+ }
110
+ const callback = raw;
111
+ const execute = (
112
+ user: UserPayload | null,
113
+ ...args: unknown[]
114
+ ): AuthorizerResponse => callback(user, ...args);
115
+ return { allowGuest, execute };
116
+ }
117
+
118
+ /** Convenience — equivalent to `AuthorizationResponse.deny` (Adonis `bouncer.deny`). */
119
+ deny(message?: string, status?: number): AuthorizationResponse {
120
+ return AuthorizationResponse.deny(message, status);
121
+ }
122
+
123
+ /** Open a policy for checks (D8 — fresh `new PolicyClass()` per check). */
124
+ with(policy: (new () => BasePolicy) | string): PolicyAuthorizer {
125
+ const factory =
126
+ typeof policy === "string"
127
+ ? this.#resolvePolicyClass(policy)
128
+ : () => new policy();
129
+ // The PolicyAuthorizer inherits the Bouncer's scope + the shared memoized
130
+ // resolve so every policy check sees the active `(user, scope)` (AC1/AC3).
131
+ return new PolicyAuthorizer(this.#user, factory, this.#scope, () =>
132
+ this.#resolvePermissions(),
133
+ );
134
+ }
135
+
136
+ /** Run an ability check and resolve to the full response. */
137
+ execute<Args extends unknown[]>(
138
+ ability: Ability<Args>,
139
+ ...args: Args
140
+ ): Promise<AuthorizationResponse>;
141
+ execute(ability: string, ...args: unknown[]): Promise<AuthorizationResponse>;
142
+ execute(
143
+ ability: string | Ability<never[]>,
144
+ ...args: unknown[]
145
+ ): Promise<AuthorizationResponse> {
146
+ return this.#evaluateAbility(ability, args);
147
+ }
148
+
149
+ /** True iff the ability is authorized. Never throws on denial. */
150
+ allows<Args extends unknown[]>(
151
+ ability: Ability<Args>,
152
+ ...args: Args
153
+ ): Promise<boolean>;
154
+ allows(ability: string, ...args: unknown[]): Promise<boolean>;
155
+ async allows(
156
+ ability: string | Ability<never[]>,
157
+ ...args: unknown[]
158
+ ): Promise<boolean> {
159
+ return (await this.#evaluateAbility(ability, args)).authorized;
160
+ }
161
+
162
+ /** Boolean negation of {@link allows}. Never throws on denial. */
163
+ denies<Args extends unknown[]>(
164
+ ability: Ability<Args>,
165
+ ...args: Args
166
+ ): Promise<boolean>;
167
+ denies(ability: string, ...args: unknown[]): Promise<boolean>;
168
+ async denies(
169
+ ability: string | Ability<never[]>,
170
+ ...args: unknown[]
171
+ ): Promise<boolean> {
172
+ return !(await this.#evaluateAbility(ability, args)).authorized;
173
+ }
174
+
175
+ /** Resolves on allow; throws `WARDEN_AUTHORIZATION_FAILURE` on deny (D2). */
176
+ authorize<Args extends unknown[]>(
177
+ ability: Ability<Args>,
178
+ ...args: Args
179
+ ): Promise<void>;
180
+ authorize(ability: string, ...args: unknown[]): Promise<void>;
181
+ async authorize(
182
+ ability: string | Ability<never[]>,
183
+ ...args: unknown[]
184
+ ): Promise<void> {
185
+ const response = await this.#evaluateAbility(ability, args);
186
+ if (!response.authorized) {
187
+ throwAuthorizationFailure(response);
188
+ }
189
+ }
190
+
191
+ async #evaluateAbility(
192
+ ability: string | Ability<never[]>,
193
+ args: unknown[],
194
+ ): Promise<AuthorizationResponse> {
195
+ const resolved =
196
+ typeof ability === "string" ? this.#resolveAbility(ability) : ability;
197
+ return evaluate({
198
+ user: this.#user,
199
+ action: typeof ability === "string" ? ability : "(ability)",
200
+ allowGuest: resolved.allowGuest,
201
+ run: (user) => resolved.execute(user, ...args),
202
+ args,
203
+ });
204
+ }
205
+
206
+ #resolveAbility(name: string): Ability<never[]> {
207
+ const found = this.#abilities[name];
208
+ if (found === undefined) {
209
+ throw new WardenError(
210
+ "UNKNOWN_ABILITY",
211
+ `No ability "${name}" is registered on this Bouncer.`,
212
+ { hint: "Register it via new Bouncer(user, { [name]: ability })." },
213
+ );
214
+ }
215
+ return found;
216
+ }
217
+
218
+ #resolvePolicyClass(name: string): () => BasePolicy {
219
+ // Lazy: the lookup runs when a verb constructs the policy, so an unknown
220
+ // name surfaces as a promise rejection (like every other verb error),
221
+ // not a synchronous throw at `with()` time.
222
+ return () => {
223
+ const ctor = this.#policies[name];
224
+ if (ctor === undefined) {
225
+ throw new WardenError(
226
+ "UNKNOWN_POLICY",
227
+ `No policy "${name}" is registered on this Bouncer.`,
228
+ {
229
+ hint: "Register it via new Bouncer(user, abilities, { [name]: Policy }).",
230
+ },
231
+ );
232
+ }
233
+ return new ctor();
234
+ };
235
+ }
236
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * PolicyAuthorizer — the object returned by `bouncer.with(Policy)`. Exposes the
3
+ * same four verbs as the Bouncer, dispatched to a named action method on a
4
+ * freshly-constructed policy instance (D8 — `new PolicyClass()` per check). The
5
+ * `before`/`after` hooks and the per-method `@allowGuest` rule run through the
6
+ * shared D5 pipeline.
7
+ */
8
+
9
+ import type { UserPayload } from "../AuthManager.js";
10
+ import { WardenError } from "../errors.js";
11
+ import type { EffectivePermissions, Scope } from "../rights/types.js";
12
+ import type { AuthorizationResponse } from "./AuthorizationResponse.js";
13
+ import { BasePolicy } from "./BasePolicy.js";
14
+ import { getActionMetadata } from "./decorators.js";
15
+ import {
16
+ type Action,
17
+ evaluate,
18
+ isAction,
19
+ throwAuthorizationFailure,
20
+ } from "./evaluate.js";
21
+ import { emptyPermissions, setPolicyContext } from "./policyContext.js";
22
+
23
+ /**
24
+ * Resolve a named action method declared anywhere on the policy's own subclass
25
+ * chain (so an action inherited from an intermediate policy base class still
26
+ * dispatches). Rejects `constructor`, the `before`/`after` hooks, and any
27
+ * `BasePolicy`/`Object.prototype` member so a verb call can never dispatch to a
28
+ * non-action method — a guard the adversarial review specifically probes.
29
+ */
30
+ function resolveActionMethod(policy: BasePolicy, action: string): Action {
31
+ // Reject the hooks + constructor by name, then accept an action declared
32
+ // anywhere on the policy's own subclass chain — the immediate prototype up to
33
+ // (but not including) BasePolicy.prototype / Object.prototype — so a method
34
+ // inherited from an intermediate policy base class (`class A extends B`) still
35
+ // dispatches, while BasePolicy's own members (scope/permissions getters) and
36
+ // Object.prototype members stay unreachable.
37
+ const isHookOrCtor =
38
+ action === "constructor" || action === "before" || action === "after";
39
+ let declaredHere = false;
40
+ for (
41
+ let proto: object | null = Object.getPrototypeOf(policy);
42
+ proto !== null &&
43
+ proto !== BasePolicy.prototype &&
44
+ proto !== Object.prototype;
45
+ proto = Object.getPrototypeOf(proto)
46
+ ) {
47
+ if (Object.hasOwn(proto, action)) {
48
+ declaredHere = true;
49
+ break;
50
+ }
51
+ }
52
+ const candidate: unknown = Reflect.get(policy, action);
53
+ if (isHookOrCtor) {
54
+ declaredHere = false;
55
+ }
56
+ if (!declaredHere || !isAction(candidate)) {
57
+ throw new WardenError(
58
+ "UNKNOWN_POLICY_ACTION",
59
+ `Policy "${policy.constructor.name}" has no action "${action}"`,
60
+ { hint: "Declare the action as a method on the policy class." },
61
+ );
62
+ }
63
+ return candidate.bind(policy);
64
+ }
65
+
66
+ export class PolicyAuthorizer {
67
+ readonly #user: UserPayload | null;
68
+ readonly #factory: () => BasePolicy;
69
+ readonly #scope: Scope;
70
+ readonly #resolvePermissions: () => Promise<EffectivePermissions>;
71
+
72
+ constructor(
73
+ user: UserPayload | null,
74
+ factory: () => BasePolicy,
75
+ scope: Scope = "global",
76
+ resolvePermissions: () => Promise<EffectivePermissions> = () =>
77
+ Promise.resolve(emptyPermissions(scope)),
78
+ ) {
79
+ this.#user = user;
80
+ this.#factory = factory;
81
+ this.#scope = scope;
82
+ this.#resolvePermissions = resolvePermissions;
83
+ }
84
+
85
+ /** Run a check and resolve to the full response (D8 — fresh policy per check). */
86
+ async execute(
87
+ action: string,
88
+ ...args: unknown[]
89
+ ): Promise<AuthorizationResponse> {
90
+ const policy = this.#factory();
91
+ // Attach the active scope + resolved permissions BEFORE dispatch so the
92
+ // before/method/after pipeline all read `this.scope` / `this.permissions`.
93
+ const permissions = await this.#resolvePermissions();
94
+ setPolicyContext(policy, { scope: this.#scope, permissions });
95
+ const method = resolveActionMethod(policy, action);
96
+ const options = getActionMetadata(policy, action);
97
+ return evaluate({
98
+ user: this.#user,
99
+ action,
100
+ allowGuest: options.allowGuest ?? false,
101
+ run: (user) => method(user, ...args),
102
+ args,
103
+ before: policy.before?.bind(policy),
104
+ after: policy.after?.bind(policy),
105
+ });
106
+ }
107
+
108
+ /** True iff the action is authorized. Never throws on denial. */
109
+ async allows(action: string, ...args: unknown[]): Promise<boolean> {
110
+ return (await this.execute(action, ...args)).authorized;
111
+ }
112
+
113
+ /** Boolean negation of {@link allows}. Never throws on denial. */
114
+ async denies(action: string, ...args: unknown[]): Promise<boolean> {
115
+ return !(await this.execute(action, ...args)).authorized;
116
+ }
117
+
118
+ /** Resolves on allow; throws `WARDEN_AUTHORIZATION_FAILURE` on deny (D2). */
119
+ async authorize(action: string, ...args: unknown[]): Promise<void> {
120
+ const response = await this.execute(action, ...args);
121
+ if (!response.authorized) {
122
+ throwAuthorizationFailure(response);
123
+ }
124
+ }
125
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Bouncer policy decorators — `@allowGuest()` / `@action()` mark policy methods
3
+ * that a guest (null user) may reach. Mirrors the `Guard.ts` idiom (D9):
4
+ * `reflect-metadata` side-effect import, a `Symbol.for` key, the legacy
5
+ * `MethodDecorator` signature, and a `getActionMetadata` reader.
6
+ */
7
+
8
+ // Side-effect import: registers `Reflect.defineMetadata` / `getMetadata`. Pulled
9
+ // in here (not transitively) so warden stays self-sufficient when published.
10
+ import "reflect-metadata";
11
+ import type { AbilityOptions } from "./types.js";
12
+
13
+ /** Action metadata key — Symbol.for ensures cross-module accessibility. */
14
+ const ACTION_KEY = Symbol.for("warden:bouncer:action");
15
+
16
+ /**
17
+ * `@action({ allowGuest })` — configure a policy method's evaluation options.
18
+ */
19
+ export function action(options: AbilityOptions): MethodDecorator {
20
+ return (target, propertyKey) => {
21
+ Reflect.defineMetadata(ACTION_KEY, options, target, propertyKey);
22
+ };
23
+ }
24
+
25
+ /**
26
+ * `@allowGuest()` — let a guest (null user) reach this policy method.
27
+ * Equivalent to `@action({ allowGuest: true })`.
28
+ */
29
+ export function allowGuest(): MethodDecorator {
30
+ return action({ allowGuest: true });
31
+ }
32
+
33
+ /**
34
+ * Read a policy method's action metadata. Walks the prototype chain
35
+ * (`Reflect.getMetadata`) so it resolves from a policy INSTANCE — legacy
36
+ * decorators write metadata onto the class prototype, where the instance
37
+ * inherits it. Absent ⇒ `{}` (⇒ `allowGuest` falsy ⇒ guest denied).
38
+ */
39
+ export function getActionMetadata(
40
+ target: object,
41
+ propertyKey: string | symbol,
42
+ ): AbilityOptions {
43
+ return Reflect.getMetadata(ACTION_KEY, target, propertyKey) ?? {};
44
+ }