@warlock.js/access 5.24.0 → 5.25.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.
@@ -1,106 +1,106 @@
1
- ---
2
- name: implement-resolver
3
- description: 'Connect `@warlock.js/access` to your role/permission storage by implementing the `AccessResolver` contract (`resolveRoles` / `resolvePermissions`, optional `resolveTenant`) — for a DB-backed catalog, a user column, a pivot table, a token claim, or an external directory. The engine owns matching / caching / policies; the resolver only fetches. TRIGGER: `AccessResolver`, `DatabaseAccessResolver`, `resolveTenant`, "custom resolver", "where do roles come from", "roles in a token claim", "permissions from an external API", "implement resolver". Skip: the quickstart `DefaultAccessResolver` — `@warlock.js/access/configure-access/SKILL.md`.'
4
- ---
5
-
6
- # Implement a resolver
7
-
8
- The resolver is the ONE thing only you can write — your schema — and it's just a function over your own data. The engine does matching, caching, policies, and fail-closed for you.
9
-
10
- ```ts
11
- export interface AccessResolver {
12
- resolveRoles(user: Auth, tenant?: string): Promise<string[]>; // powers hasRole
13
- resolvePermissions(user: Auth, tenant?: string): Promise<string[]>; // powers can / authorize
14
- resolveTenant?(user: Auth): string | undefined; // optional ambient tenant
15
- }
16
- ```
17
-
18
- Register it: `access: { resolver: new MyResolver() }`.
19
-
20
- ## The default: dynamic, DB-backed (ejected)
21
-
22
- `npx warlock add access` ejects this `DatabaseAccessResolver` — roles come from the `user_roles` table, permissions are joined through the `roles` catalog table. This is what most apps run, because admins manage roles + permissions at runtime.
23
-
24
- ```ts
25
- import type { AccessResolver } from "@warlock.js/access";
26
- import type { Auth } from "@warlock.js/auth";
27
- import { Role } from "app/access/models/role";
28
- import { UserRole } from "app/access/models/user-role";
29
-
30
- export class DatabaseAccessResolver implements AccessResolver {
31
- public async resolveRoles(user: Auth, tenant?: string): Promise<string[]> {
32
- return UserRole.rolesFor(user, tenant);
33
- }
34
-
35
- public async resolvePermissions(user: Auth, tenant?: string): Promise<string[]> {
36
- const names = await this.resolveRoles(user, tenant);
37
-
38
- if (names.length === 0) return [];
39
-
40
- const roles = await Role.query().whereIn("name", names).get();
41
-
42
- return [...new Set(roles.flatMap((role) => role.permissions))];
43
- }
44
-
45
- // Multi-tenant? Uncomment to scope checks to the user's tenant. Derive it from
46
- // the user — never trust client-supplied request input for the tenant boundary:
47
- // public resolveTenant(user: Auth): string | undefined {
48
- // return user.get("organization_id");
49
- // }
50
- }
51
- ```
52
-
53
- ## Other recipes
54
-
55
- **A single role column, permissions mapped in code**
56
-
57
- ```ts
58
- class ColumnResolver implements AccessResolver {
59
- public constructor(private readonly roles: Record<string, string[]>) {}
60
- public async resolveRoles(user: Auth): Promise<string[]> {
61
- return [user.get("role")].filter(Boolean);
62
- }
63
- public async resolvePermissions(user: Auth): Promise<string[]> {
64
- return (await this.resolveRoles(user)).flatMap((role) => this.roles[role] ?? []);
65
- }
66
- }
67
- ```
68
-
69
- **Roles from a token claim (external IdP — no DB)**
70
-
71
- ```ts
72
- class ClaimResolver implements AccessResolver {
73
- public async resolveRoles(user: Auth): Promise<string[]> {
74
- return user.get("decodedAccessToken")?.roles ?? [];
75
- }
76
- public async resolvePermissions(user: Auth): Promise<string[]> {
77
- return user.get("decodedAccessToken")?.permissions ?? [];
78
- }
79
- }
80
- ```
81
-
82
- **Direct permissions per user (role is just a label)**
83
-
84
- ```ts
85
- class DirectResolver implements AccessResolver {
86
- public async resolveRoles(user: Auth): Promise<string[]> {
87
- return [user.get("role")];
88
- }
89
- public async resolvePermissions(user: Auth): Promise<string[]> {
90
- const rows = await UserPermission.query().where({ user_id: user.id }).get();
91
- return rows.map((row) => row.get("name") as string);
92
- }
93
- }
94
- ```
95
-
96
- ## Rules
97
-
98
- - **Only fetch — never cache inside the resolver.** The engine caches per `(user, tenant)`; caching twice causes stale grants. Invalidate with `access.flush(user, tenant)` when your data changes.
99
- - Return plain `string[]`. Wildcards (`orders.*`, `*`) in the returned permissions are honored by the engine.
100
- - `resolveRoles` and `resolvePermissions` may read **different** sources — roles and permissions are independent axes (a role can be a pure label with permissions granted directly).
101
- - Throwing from the resolver fails the check **closed** (denied + logged).
102
-
103
- ## See also
104
-
105
- - [`@warlock.js/access/configure-access/SKILL.md`](@warlock.js/access/configure-access/SKILL.md) — the quickstart `DefaultAccessResolver`.
106
- ```
1
+ ---
2
+ name: implement-resolver
3
+ description: 'Connect `@warlock.js/access` to your role/permission storage by implementing the `AccessResolver` contract (`resolveRoles` / `resolvePermissions`, optional `resolveTenant`) — for a DB-backed catalog, a user column, a pivot table, a token claim, or an external directory. The engine owns matching / caching / policies; the resolver only fetches. TRIGGER: `AccessResolver`, `DatabaseAccessResolver`, `resolveTenant`, "custom resolver", "where do roles come from", "roles in a token claim", "permissions from an external API", "implement resolver". Skip: the quickstart `DefaultAccessResolver` — `@warlock.js/access/configure-access/SKILL.md`.'
4
+ ---
5
+
6
+ # Implement a resolver
7
+
8
+ The resolver is the ONE thing only you can write — your schema — and it's just a function over your own data. The engine does matching, caching, policies, and fail-closed for you.
9
+
10
+ ```ts
11
+ export interface AccessResolver {
12
+ resolveRoles(user: Auth, tenant?: string): Promise<string[]>; // powers hasRole
13
+ resolvePermissions(user: Auth, tenant?: string): Promise<string[]>; // powers can / authorize
14
+ resolveTenant?(user: Auth): string | undefined; // optional ambient tenant
15
+ }
16
+ ```
17
+
18
+ Register it: `access: { resolver: new MyResolver() }`.
19
+
20
+ ## The default: dynamic, DB-backed (ejected)
21
+
22
+ `npx warlock add access` ejects this `DatabaseAccessResolver` — roles come from the `user_roles` table, permissions are joined through the `roles` catalog table. This is what most apps run, because admins manage roles + permissions at runtime.
23
+
24
+ ```ts
25
+ import type { AccessResolver } from "@warlock.js/access";
26
+ import type { Auth } from "@warlock.js/auth";
27
+ import { Role } from "app/access/models/role";
28
+ import { UserRole } from "app/access/models/user-role";
29
+
30
+ export class DatabaseAccessResolver implements AccessResolver {
31
+ public async resolveRoles(user: Auth, tenant?: string): Promise<string[]> {
32
+ return UserRole.rolesFor(user, tenant);
33
+ }
34
+
35
+ public async resolvePermissions(user: Auth, tenant?: string): Promise<string[]> {
36
+ const names = await this.resolveRoles(user, tenant);
37
+
38
+ if (names.length === 0) return [];
39
+
40
+ const roles = await Role.query().whereIn("name", names).get();
41
+
42
+ return [...new Set(roles.flatMap((role) => role.permissions))];
43
+ }
44
+
45
+ // Multi-tenant? Uncomment to scope checks to the user's tenant. Derive it from
46
+ // the user — never trust client-supplied request input for the tenant boundary:
47
+ // public resolveTenant(user: Auth): string | undefined {
48
+ // return user.get("organization_id");
49
+ // }
50
+ }
51
+ ```
52
+
53
+ ## Other recipes
54
+
55
+ **A single role column, permissions mapped in code**
56
+
57
+ ```ts
58
+ class ColumnResolver implements AccessResolver {
59
+ public constructor(private readonly roles: Record<string, string[]>) {}
60
+ public async resolveRoles(user: Auth): Promise<string[]> {
61
+ return [user.get("role")].filter(Boolean);
62
+ }
63
+ public async resolvePermissions(user: Auth): Promise<string[]> {
64
+ return (await this.resolveRoles(user)).flatMap((role) => this.roles[role] ?? []);
65
+ }
66
+ }
67
+ ```
68
+
69
+ **Roles from a token claim (external IdP — no DB)**
70
+
71
+ ```ts
72
+ class ClaimResolver implements AccessResolver {
73
+ public async resolveRoles(user: Auth): Promise<string[]> {
74
+ return user.get("decodedAccessToken")?.roles ?? [];
75
+ }
76
+ public async resolvePermissions(user: Auth): Promise<string[]> {
77
+ return user.get("decodedAccessToken")?.permissions ?? [];
78
+ }
79
+ }
80
+ ```
81
+
82
+ **Direct permissions per user (role is just a label)**
83
+
84
+ ```ts
85
+ class DirectResolver implements AccessResolver {
86
+ public async resolveRoles(user: Auth): Promise<string[]> {
87
+ return [user.get("role")];
88
+ }
89
+ public async resolvePermissions(user: Auth): Promise<string[]> {
90
+ const rows = await UserPermission.query().where({ user_id: user.id }).get();
91
+ return rows.map((row) => row.get("name") as string);
92
+ }
93
+ }
94
+ ```
95
+
96
+ ## Rules
97
+
98
+ - **Only fetch — never cache inside the resolver.** The engine caches per `(user, tenant)`; caching twice causes stale grants. Invalidate with `access.flush(user, tenant)` when your data changes.
99
+ - Return plain `string[]`. Wildcards (`orders.*`, `*`) in the returned permissions are honored by the engine.
100
+ - `resolveRoles` and `resolvePermissions` may read **different** sources — roles and permissions are independent axes (a role can be a pure label with permissions granted directly).
101
+ - Throwing from the resolver fails the check **closed** (denied + logged).
102
+
103
+ ## See also
104
+
105
+ - [`@warlock.js/access/configure-access/SKILL.md`](@warlock.js/access/configure-access/SKILL.md) — the quickstart `DefaultAccessResolver`.
106
+ ```
@@ -1,71 +1,71 @@
1
- ---
2
- name: manage-roles
3
- description: 'Assign and read roles in `@warlock.js/access` — the ejected `UserRole.assign` / `UserRole.revoke` (the `user_roles` table) followed by `access.flush`, plus `hasRole` / `hasAnyRole` / `hasAllRoles`. The role→permission catalog is the ejected `Role` table (dynamic). TRIGGER: `UserRole.assign`, `UserRole.revoke`, `access.flush`, `hasRole`, `hasAnyRole`, `hasAllRoles`, `Role` table, "give a user a role", "assign role", "check a user role", "roles per tenant". Skip: permission checks — `@warlock.js/access/check-permissions/SKILL.md`; resolver choice — `@warlock.js/access/configure-access/SKILL.md`.'
4
- ---
5
-
6
- # Manage roles
7
-
8
- Role storage is **ejected into your app** (`npx warlock add access`): a `UserRole` table (who holds which role) and a `Role` catalog table (role → permissions). Both are dynamic — admins manage them at runtime. The package itself only reads them through the `DatabaseAccessResolver`.
9
-
10
- ## Assign / revoke
11
-
12
- Assign and revoke with the ejected `UserRole` model. Both **flush the affected user's cached set for you** — no manual `access.flush` after a role change:
13
-
14
- ```ts
15
- import { UserRole } from "app/access/models/user-role";
16
-
17
- await UserRole.assign(user, "editor", "tenant-1"); // editor in tenant-1 (auto-flushes that user)
18
- await UserRole.assign(user, "viewer", "tenant-2"); // viewer in tenant-2 — same user, different role
19
-
20
- await UserRole.revoke(user, "editor", "tenant-1"); // auto-flushes too
21
- ```
22
-
23
- The `tenant` arg is optional — omit it for single-tenant apps (roles are stored globally). In a **multi-tenant** app, always pass the tenant: an omitted or unresolved tenant scopes to *global* roles only, never the union across tenants.
24
-
25
- ### When you still need `access.flush`
26
-
27
- `assign` / `revoke` cover the common case. The cache keys off `(user, tenant)`, so flush manually only for **out-of-band** changes those two methods can't see:
28
-
29
- ```ts
30
- import { access } from "@warlock.js/access";
31
-
32
- // A role's permission set changed in the `Role` catalog — every holder's cached
33
- // permissions are now stale, so flush each affected user:
34
- await access.flush(user, "tenant-1");
35
-
36
- // You changed a user's role membership WITHOUT UserRole (e.g. wrote the user's
37
- // `role` column directly, or a bulk SQL update):
38
- await access.flush(user, "tenant-1");
39
- ```
40
-
41
- `access.flush(user, tenant)` is per-user — it has no "drop everything" form. For a sweeping catalog change with many holders, flush each affected user, or let the entries expire on their own (`cache.ttl`, default `10m`).
42
-
43
- ## Read roles
44
-
45
- ```ts
46
- import { hasAllRoles, hasAnyRole, hasRole } from "@warlock.js/access";
47
-
48
- await hasRole(user, "editor"); // tenant is an optional 3rd arg
49
- await hasAnyRole(user, ["admin", "manager"]);
50
- await hasAllRoles(user, ["staff", "verified"]);
51
- ```
52
-
53
- > **Prefer permission checks over role checks.** Role checks couple your code to the role taxonomy; `can(user, "orders.update")` survives a role rename. Reach for `hasRole` only for coarse UI gating.
54
-
55
- ## The catalog (the `Role` table)
56
-
57
- A role's permissions live in the ejected `Role` table — `{ name, permissions }`. Add a role, or change what it grants, by writing rows; the `DatabaseAccessResolver` joins `user_roles` through it. No redeploy needed.
58
-
59
- ## The model (overridable)
60
-
61
- The ejected `UserRole` is a thin cascade model — `{ user_id, user_type, role, tenant }`. `user_id` defaults to `uuid` — edit the ejected migration in `src/app/access/models/user-role/migrations/` if your user ids are integers.
62
-
63
- ## Roles stored elsewhere?
64
-
65
- If roles live on the user (a `roles` column) or in a token claim, you don't use `UserRole` — you manage assignment your way and a resolver reads them. See [`implement-resolver`](@warlock.js/access/implement-resolver/SKILL.md).
66
-
67
- ## See also
68
-
69
- - [`@warlock.js/access/configure-access/SKILL.md`](@warlock.js/access/configure-access/SKILL.md) — choosing the resolver.
70
- - [`@warlock.js/access/implement-resolver/SKILL.md`](@warlock.js/access/implement-resolver/SKILL.md) — non-table role storage.
71
- ```
1
+ ---
2
+ name: manage-roles
3
+ description: 'Assign and read roles in `@warlock.js/access` — the ejected `UserRole.assign` / `UserRole.revoke` (the `user_roles` table) followed by `access.flush`, plus `hasRole` / `hasAnyRole` / `hasAllRoles`. The role→permission catalog is the ejected `Role` table (dynamic). TRIGGER: `UserRole.assign`, `UserRole.revoke`, `access.flush`, `hasRole`, `hasAnyRole`, `hasAllRoles`, `Role` table, "give a user a role", "assign role", "check a user role", "roles per tenant". Skip: permission checks — `@warlock.js/access/check-permissions/SKILL.md`; resolver choice — `@warlock.js/access/configure-access/SKILL.md`.'
4
+ ---
5
+
6
+ # Manage roles
7
+
8
+ Role storage is **ejected into your app** (`npx warlock add access`): a `UserRole` table (who holds which role) and a `Role` catalog table (role → permissions). Both are dynamic — admins manage them at runtime. The package itself only reads them through the `DatabaseAccessResolver`.
9
+
10
+ ## Assign / revoke
11
+
12
+ Assign and revoke with the ejected `UserRole` model. Both **flush the affected user's cached set for you** — no manual `access.flush` after a role change:
13
+
14
+ ```ts
15
+ import { UserRole } from "app/access/models/user-role";
16
+
17
+ await UserRole.assign(user, "editor", "tenant-1"); // editor in tenant-1 (auto-flushes that user)
18
+ await UserRole.assign(user, "viewer", "tenant-2"); // viewer in tenant-2 — same user, different role
19
+
20
+ await UserRole.revoke(user, "editor", "tenant-1"); // auto-flushes too
21
+ ```
22
+
23
+ The `tenant` arg is optional — omit it for single-tenant apps (roles are stored globally). In a **multi-tenant** app, always pass the tenant: an omitted or unresolved tenant scopes to *global* roles only, never the union across tenants.
24
+
25
+ ### When you still need `access.flush`
26
+
27
+ `assign` / `revoke` cover the common case. The cache keys off `(user, tenant)`, so flush manually only for **out-of-band** changes those two methods can't see:
28
+
29
+ ```ts
30
+ import { access } from "@warlock.js/access";
31
+
32
+ // A role's permission set changed in the `Role` catalog — every holder's cached
33
+ // permissions are now stale, so flush each affected user:
34
+ await access.flush(user, "tenant-1");
35
+
36
+ // You changed a user's role membership WITHOUT UserRole (e.g. wrote the user's
37
+ // `role` column directly, or a bulk SQL update):
38
+ await access.flush(user, "tenant-1");
39
+ ```
40
+
41
+ `access.flush(user, tenant)` is per-user — it has no "drop everything" form. For a sweeping catalog change with many holders, flush each affected user, or let the entries expire on their own (`cache.ttl`, default `10m`).
42
+
43
+ ## Read roles
44
+
45
+ ```ts
46
+ import { hasAllRoles, hasAnyRole, hasRole } from "@warlock.js/access";
47
+
48
+ await hasRole(user, "editor"); // tenant is an optional 3rd arg
49
+ await hasAnyRole(user, ["admin", "manager"]);
50
+ await hasAllRoles(user, ["staff", "verified"]);
51
+ ```
52
+
53
+ > **Prefer permission checks over role checks.** Role checks couple your code to the role taxonomy; `can(user, "orders.update")` survives a role rename. Reach for `hasRole` only for coarse UI gating.
54
+
55
+ ## The catalog (the `Role` table)
56
+
57
+ A role's permissions live in the ejected `Role` table — `{ name, permissions }`. Add a role, or change what it grants, by writing rows; the `DatabaseAccessResolver` joins `user_roles` through it. No redeploy needed.
58
+
59
+ ## The model (overridable)
60
+
61
+ The ejected `UserRole` is a thin cascade model — `{ user_id, user_type, role, tenant }`. `user_id` defaults to `uuid` — edit the ejected migration in `src/app/access/models/user-role/migrations/` if your user ids are integers.
62
+
63
+ ## Roles stored elsewhere?
64
+
65
+ If roles live on the user (a `roles` column) or in a token claim, you don't use `UserRole` — you manage assignment your way and a resolver reads them. See [`implement-resolver`](@warlock.js/access/implement-resolver/SKILL.md).
66
+
67
+ ## See also
68
+
69
+ - [`@warlock.js/access/configure-access/SKILL.md`](@warlock.js/access/configure-access/SKILL.md) — choosing the resolver.
70
+ - [`@warlock.js/access/implement-resolver/SKILL.md`](@warlock.js/access/implement-resolver/SKILL.md) — non-table role storage.
71
+ ```