@cfast/permissions 0.1.0 → 0.2.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Schmidt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,6 +1,9 @@
1
1
  // src/types.ts
2
2
  var DRIZZLE_NAME_SYMBOL = /* @__PURE__ */ Symbol.for("drizzle:Name");
3
3
  function getTableName(table) {
4
+ if (typeof table === "string") {
5
+ return table;
6
+ }
4
7
  const name = Reflect.get(table, DRIZZLE_NAME_SYMBOL);
5
8
  return typeof name === "string" ? name : "unknown";
6
9
  }
@@ -10,7 +13,7 @@ var CRUD_ACTIONS = ["read", "create", "update", "delete"];
10
13
  var ForbiddenError = class extends Error {
11
14
  /** The action that was denied (e.g., `"delete"`). */
12
15
  action;
13
- /** The Drizzle table the action targeted. */
16
+ /** The Drizzle table or string table name the action targeted. */
14
17
  table;
15
18
  /** The role that lacked the permission, or `undefined` if not specified. */
16
19
  role;
@@ -17,12 +17,46 @@ type DrizzleSQL = {
17
17
  getSQL(): unknown;
18
18
  };
19
19
  /**
20
- * Extracts the table name string from a Drizzle table reference.
20
+ * A schema map: an object mapping table names to Drizzle table references.
21
21
  *
22
- * @param table - A Drizzle table object containing the `drizzle:Name` symbol.
23
- * @returns The table name, or `"unknown"` if the symbol is not present.
22
+ * Typically the result of `import * as schema from "./schema"`. Used as the
23
+ * `TTables` generic parameter for {@link definePermissions}, {@link can}, and
24
+ * the curried {@link grant} callback so that string subjects (e.g.
25
+ * `"projects"`) are constrained to known table names at compile time.
24
26
  */
25
- declare function getTableName(table: DrizzleTable): string;
27
+ type SchemaMap = Record<string, DrizzleTable>;
28
+ /**
29
+ * Extracts the string-literal union of valid table names from a {@link SchemaMap}.
30
+ *
31
+ * Given `typeof schema` (where `schema` exports tables as named bindings),
32
+ * `TableName<typeof schema>` is the union of all exported table-key strings.
33
+ */
34
+ type TableName<TTables extends SchemaMap> = Extract<keyof TTables, string>;
35
+ /**
36
+ * The set of acceptable subject inputs for a grant or `can()` check.
37
+ *
38
+ * - A {@link DrizzleTable} object reference (the original form, always allowed).
39
+ * - A string-literal table name from {@link TableName}, constrained to the
40
+ * provided `TTables` schema map at compile time when one is supplied.
41
+ * - The literal `"all"` for grants that apply to every table.
42
+ */
43
+ type SubjectInput<TTables extends SchemaMap = SchemaMap> = DrizzleTable | TableName<TTables> | "all";
44
+ /**
45
+ * Extracts the table name string from a Drizzle table reference, or returns
46
+ * a string subject as-is.
47
+ *
48
+ * Accepts both:
49
+ * - A Drizzle table object containing the `drizzle:Name` symbol — the symbol
50
+ * is read via `Reflect.get`.
51
+ * - A bare string (e.g., a table-name literal like `"projects"`) — returned
52
+ * unchanged so callers can use the same key for grant matching whether the
53
+ * subject was declared as an object or a string.
54
+ *
55
+ * @param table - A Drizzle table object or a string subject key.
56
+ * @returns The table name, the input string, or `"unknown"` if a symbol-less
57
+ * object was provided.
58
+ */
59
+ declare function getTableName(table: DrizzleTable | string): string;
26
60
  /**
27
61
  * A permission action: one of the four CRUD operations, or `"manage"` for all.
28
62
  *
@@ -63,24 +97,37 @@ declare const CRUD_ACTIONS: readonly CrudAction[];
63
97
  type WhereClause = (columns: Record<string, unknown>, user: unknown) => DrizzleSQL | undefined;
64
98
  /**
65
99
  * A single permission grant: an action on a subject, optionally restricted by a `where` clause.
100
+ *
101
+ * `subject` may be a Drizzle table object, a string table name (e.g.
102
+ * `"projects"`), or the literal `"all"` for grants that apply to every table.
103
+ * String and object subjects are normalized to the same key by
104
+ * {@link getTableName} during matching, so the two forms are interchangeable
105
+ * at runtime.
66
106
  */
67
107
  type Grant = {
68
108
  /** The permitted operation. `"manage"` is shorthand for all four CRUD actions. */
69
109
  action: PermissionAction;
70
- /** The Drizzle table this grant applies to, or `"all"` for every table. */
71
- subject: DrizzleTable | "all";
110
+ /**
111
+ * The subject of the grant: a Drizzle table object, a string table name,
112
+ * or `"all"` for every table.
113
+ */
114
+ subject: DrizzleTable | string;
72
115
  /** Optional row-level filter that restricts which rows this grant covers. */
73
116
  where?: WhereClause;
74
117
  };
75
118
  /**
76
- * Type-safe grant builder function, parameterized by the user type.
119
+ * Type-safe grant builder function, parameterized by the user type and an
120
+ * optional schema map.
77
121
  *
78
122
  * Used when `grants` is provided as a callback in {@link PermissionsConfig}
79
- * so that `where` clauses receive a correctly typed `user` parameter.
123
+ * so that `where` clauses receive a correctly typed `user` parameter and
124
+ * string subjects are constrained to known table names from `TTables`.
80
125
  *
81
126
  * @typeParam TUser - The user type passed to `where` clause callbacks.
127
+ * @typeParam TTables - Optional schema map (e.g. `typeof schema`) used to
128
+ * constrain string subjects to known table-name literals.
82
129
  */
83
- type GrantFn<TUser> = (action: PermissionAction, subject: DrizzleTable | "all", options?: {
130
+ type GrantFn<TUser, TTables extends SchemaMap = SchemaMap> = (action: PermissionAction, subject: SubjectInput<TTables>, options?: {
84
131
  where?: (columns: Record<string, unknown>, user: TUser) => DrizzleSQL | undefined;
85
132
  }) => Grant;
86
133
  /**
@@ -90,19 +137,27 @@ type GrantFn<TUser> = (action: PermissionAction, subject: DrizzleTable | "all",
90
137
  * This is what makes client-side permission introspection possible: you can check whether a
91
138
  * role has the right grants without knowing the specific row being accessed.
92
139
  *
140
+ * The `table` field accepts either a Drizzle table object or a string table
141
+ * name; both forms are normalized to the same key by {@link getTableName}.
142
+ *
93
143
  * @example
94
144
  * ```typescript
95
145
  * const descriptor: PermissionDescriptor = {
96
146
  * action: "update",
97
- * table: posts,
147
+ * table: posts, // object form
148
+ * };
149
+ *
150
+ * const descriptor2: PermissionDescriptor = {
151
+ * action: "create",
152
+ * table: "posts", // string form
98
153
  * };
99
154
  * ```
100
155
  */
101
156
  type PermissionDescriptor = {
102
157
  /** The operation being checked. */
103
158
  action: PermissionAction;
104
- /** The Drizzle table the operation targets. */
105
- table: DrizzleTable;
159
+ /** The Drizzle table or string table name the operation targets. */
160
+ table: DrizzleTable | string;
106
161
  };
107
162
  /**
108
163
  * Result of a permission check via {@link checkPermissions}.
@@ -120,12 +175,14 @@ type PermissionCheckResult = {
120
175
  *
121
176
  * @typeParam TRoles - Tuple of role name string literals (use `as const`).
122
177
  * @typeParam TUser - The user type for typed `where` clauses (defaults to `unknown`).
178
+ * @typeParam TTables - Optional schema map used to constrain string subjects
179
+ * inside the `grants` callback to known table names.
123
180
  */
124
- type PermissionsConfig<TRoles extends readonly string[], TUser = unknown> = {
181
+ type PermissionsConfig<TRoles extends readonly string[], TUser = unknown, TTables extends SchemaMap = SchemaMap> = {
125
182
  /** All roles in the application, declared with `as const` for type inference. */
126
183
  roles: TRoles;
127
184
  /** A map from role to grant arrays, or a callback that receives a typed `grant` function. */
128
- grants: Record<TRoles[number], Grant[]> | ((grant: GrantFn<TUser>) => Record<TRoles[number], Grant[]>);
185
+ grants: Record<TRoles[number], Grant[]> | ((grant: GrantFn<TUser, TTables>) => Record<TRoles[number], Grant[]>);
129
186
  /** Optional role hierarchy declaring which roles inherit from which. */
130
187
  hierarchy?: Partial<Record<TRoles[number], TRoles[number][]>>;
131
188
  };
@@ -151,8 +208,8 @@ type Permissions<TRoles extends readonly string[] = readonly string[]> = {
151
208
  type ForbiddenErrorOptions = {
152
209
  /** The action that was denied. */
153
210
  action: PermissionAction;
154
- /** The Drizzle table the action targeted. */
155
- table: DrizzleTable;
211
+ /** The Drizzle table or string table name the action targeted. */
212
+ table: DrizzleTable | string;
156
213
  /** The role that lacked the permission, if known. */
157
214
  role?: string;
158
215
  /** The full list of permission descriptors that were checked. */
@@ -168,8 +225,8 @@ type ForbiddenErrorOptions = {
168
225
  declare class ForbiddenError extends Error {
169
226
  /** The action that was denied (e.g., `"delete"`). */
170
227
  readonly action: PermissionAction;
171
- /** The Drizzle table the action targeted. */
172
- readonly table: DrizzleTable;
228
+ /** The Drizzle table or string table name the action targeted. */
229
+ readonly table: DrizzleTable | string;
173
230
  /** The role that lacked the permission, or `undefined` if not specified. */
174
231
  readonly role: string | undefined;
175
232
  /** The full list of permission descriptors that were checked. */
@@ -194,4 +251,4 @@ declare class ForbiddenError extends Error {
194
251
  };
195
252
  }
196
253
 
197
- export { CRUD_ACTIONS as C, type DrizzleTable as D, ForbiddenError as F, type Grant as G, type PermissionsConfig as P, type WhereClause as W, type Permissions as a, type PermissionAction as b, type PermissionDescriptor as c, type PermissionCheckResult as d, type CrudAction as e, type GrantFn as f, getTableName as g };
254
+ export { CRUD_ACTIONS as C, type DrizzleTable as D, ForbiddenError as F, type Grant as G, type PermissionsConfig as P, type SchemaMap as S, type TableName as T, type WhereClause as W, type Permissions as a, type PermissionAction as b, type SubjectInput as c, type PermissionDescriptor as d, type PermissionCheckResult as e, type CrudAction as f, type GrantFn as g, getTableName as h };
package/dist/client.d.ts CHANGED
@@ -1 +1 @@
1
- export { e as CrudAction, F as ForbiddenError, b as PermissionAction, d as PermissionCheckResult, c as PermissionDescriptor } from './client-CJBFS0IS.js';
1
+ export { f as CrudAction, F as ForbiddenError, b as PermissionAction, e as PermissionCheckResult, d as PermissionDescriptor } from './client-C7xdwHTk.js';
package/dist/client.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  ForbiddenError
3
- } from "./chunk-I35WLWUH.js";
3
+ } from "./chunk-NCLN5YBQ.js";
4
4
  export {
5
5
  ForbiddenError
6
6
  };
package/dist/index.d.ts CHANGED
@@ -1,13 +1,17 @@
1
- import { P as PermissionsConfig, a as Permissions, b as PermissionAction, D as DrizzleTable, W as WhereClause, G as Grant, c as PermissionDescriptor, d as PermissionCheckResult } from './client-CJBFS0IS.js';
2
- export { C as CRUD_ACTIONS, e as CrudAction, F as ForbiddenError, f as GrantFn, g as getTableName } from './client-CJBFS0IS.js';
1
+ import { P as PermissionsConfig, a as Permissions, S as SchemaMap, b as PermissionAction, c as SubjectInput, W as WhereClause, G as Grant, d as PermissionDescriptor, e as PermissionCheckResult } from './client-C7xdwHTk.js';
2
+ export { C as CRUD_ACTIONS, f as CrudAction, D as DrizzleTable, F as ForbiddenError, g as GrantFn, T as TableName, h as getTableName } from './client-C7xdwHTk.js';
3
3
 
4
4
  /**
5
5
  * Creates a permission configuration that can be shared between server-side
6
6
  * enforcement (`@cfast/db`) and client-side introspection (`@cfast/actions`).
7
7
  *
8
- * Supports two calling styles:
8
+ * Supports three calling styles:
9
9
  * - **Direct:** `definePermissions(config)` when no custom user type is needed.
10
- * - **Curried:** `definePermissions<MyUser>()(config)` to get typed `where` clause user parameters.
10
+ * - **Curried (user only):** `definePermissions<MyUser>()(config)` for typed
11
+ * `where` clause user parameters.
12
+ * - **Curried (user + tables):** `definePermissions<MyUser, typeof schema>()(config)`
13
+ * to additionally constrain string subjects passed to the `grant` callback
14
+ * to known table names from the schema map.
11
15
  *
12
16
  * @param config - The permissions configuration with roles, grants, and optional hierarchy.
13
17
  * @returns A {@link Permissions} object containing roles, raw grants, and hierarchy-expanded `resolvedGrants`.
@@ -16,8 +20,10 @@ export { C as CRUD_ACTIONS, e as CrudAction, F as ForbiddenError, f as GrantFn,
16
20
  * ```typescript
17
21
  * import { definePermissions, grant } from "@cfast/permissions";
18
22
  * import { eq } from "drizzle-orm";
19
- * import { posts, comments } from "./schema";
23
+ * import * as schema from "./schema";
24
+ * const { posts, comments } = schema;
20
25
  *
26
+ * // Direct form — accepts table objects
21
27
  * const permissions = definePermissions({
22
28
  * roles: ["anonymous", "user", "admin"] as const,
23
29
  * grants: {
@@ -27,15 +33,29 @@ export { C as CRUD_ACTIONS, e as CrudAction, F as ForbiddenError, f as GrantFn,
27
33
  * user: [
28
34
  * grant("read", posts),
29
35
  * grant("create", posts),
30
- * grant("update", posts, { where: (p, u) => eq(p.authorId, u.id) }),
31
36
  * ],
32
37
  * admin: [grant("manage", "all")],
33
38
  * },
34
39
  * });
40
+ *
41
+ * // Curried form — string subjects constrained to known tables
42
+ * type AuthUser = { id: string };
43
+ * const perms = definePermissions<AuthUser, typeof schema>()({
44
+ * roles: ["user", "admin"] as const,
45
+ * grants: (grant) => ({
46
+ * user: [
47
+ * grant("read", "posts"), // string form
48
+ * grant("update", posts, { // object form still works
49
+ * where: (p, u) => eq(p.authorId, u.id),
50
+ * }),
51
+ * ],
52
+ * admin: [grant("manage", "all")],
53
+ * }),
54
+ * });
35
55
  * ```
36
56
  */
37
57
  declare function definePermissions<TRoles extends readonly string[]>(config: PermissionsConfig<TRoles>): Permissions<TRoles>;
38
- declare function definePermissions<TUser>(): <TRoles extends readonly string[]>(config: PermissionsConfig<TRoles, TUser>) => Permissions<TRoles>;
58
+ declare function definePermissions<TUser, TTables extends SchemaMap = SchemaMap>(): <TRoles extends readonly string[]>(config: PermissionsConfig<TRoles, TUser, TTables>) => Permissions<TRoles>;
39
59
 
40
60
  /**
41
61
  * Declares that a role can perform an action on a subject, optionally restricted
@@ -44,8 +64,15 @@ declare function definePermissions<TUser>(): <TRoles extends readonly string[]>(
44
64
  * Used inside the `grants` map of {@link definePermissions} to build permission rules.
45
65
  * A grant without a `where` clause applies to all rows.
46
66
  *
67
+ * Subjects accept three forms (all interchangeable at runtime):
68
+ * - A {@link DrizzleTable} object reference (the original form).
69
+ * - A string table name (e.g. `"projects"`) — when called via the `grant`
70
+ * callback inside `definePermissions<User, typeof schema>()`, strings are
71
+ * constrained to known table names by TypeScript.
72
+ * - The literal `"all"` for grants that apply to every table.
73
+ *
47
74
  * @param action - The operation being permitted (`"read"`, `"create"`, `"update"`, `"delete"`, or `"manage"` for all four).
48
- * @param subject - A Drizzle table reference, or `"all"` to apply to every table.
75
+ * @param subject - A Drizzle table reference, a string table name, or `"all"` to apply to every table.
49
76
  * @param options - Optional configuration.
50
77
  * @param options.where - A Drizzle filter function `(columns, user) => SQL` that restricts which rows this grant covers.
51
78
  * @returns A {@link Grant} object for use in a permissions configuration.
@@ -56,9 +83,13 @@ declare function definePermissions<TUser>(): <TRoles extends readonly string[]>(
56
83
  * import { eq } from "drizzle-orm";
57
84
  * import { posts } from "./schema";
58
85
  *
59
- * // Unrestricted read on all posts
86
+ * // Object form (always works)
60
87
  * grant("read", posts);
61
88
  *
89
+ * // String form (works standalone; type-checked when called via the
90
+ * // `grant` callback inside `definePermissions<User, typeof schema>()`)
91
+ * grant("read", "posts");
92
+ *
62
93
  * // Only allow updating own posts
63
94
  * grant("update", posts, {
64
95
  * where: (post, user) => eq(post.authorId, user.id),
@@ -68,7 +99,7 @@ declare function definePermissions<TUser>(): <TRoles extends readonly string[]>(
68
99
  * grant("manage", "all");
69
100
  * ```
70
101
  */
71
- declare function grant(action: PermissionAction, subject: DrizzleTable | "all", options?: {
102
+ declare function grant<TTables extends SchemaMap = SchemaMap>(action: PermissionAction, subject: SubjectInput<TTables>, options?: {
72
103
  where?: WhereClause;
73
104
  }): Grant;
74
105
 
@@ -79,25 +110,34 @@ declare function grant(action: PermissionAction, subject: DrizzleTable | "all",
79
110
  * object), `can` works directly with the user's resolved grants — the same grants
80
111
  * available in loaders, actions, and client-side via `useActions`.
81
112
  *
113
+ * Accepts either a Drizzle table object or a string table name. The two forms
114
+ * are interchangeable and resolve to the same underlying key. To get
115
+ * compile-time validation that a string table name actually exists in your
116
+ * schema, supply the schema map as a generic argument: `can<typeof schema>(...)`.
117
+ *
118
+ * @typeParam TTables - Optional schema map (e.g. `typeof schema`) used to
119
+ * constrain string subjects to known table-name literals.
82
120
  * @param grants - The user's resolved permission grants.
83
121
  * @param action - The permission action to check (e.g., `"create"`, `"read"`).
84
- * @param table - The Drizzle table object to check against.
122
+ * @param table - The Drizzle table object or string table name to check against.
85
123
  * @returns `true` if any grant permits the action on the table.
86
124
  *
87
125
  * @example
88
126
  * ```ts
89
127
  * import { can } from "@cfast/permissions";
128
+ * import * as schema from "../db/schema";
90
129
  *
91
- * // In a loader
92
- * if (!can(ctx.auth.grants, "create", posts)) {
93
- * throw redirect("/");
94
- * }
130
+ * // Object form (always works)
131
+ * if (!can(ctx.auth.grants, "create", schema.posts)) throw redirect("/");
132
+ *
133
+ * // String form (always allowed; type-checked when a schema generic is supplied)
134
+ * if (!can<typeof schema>(ctx.auth.grants, "create", "posts")) throw redirect("/");
95
135
  *
96
136
  * // In a component
97
- * {can(grants, "update", posts) && <Button>Edit</Button>}
137
+ * {can(grants, "update", schema.posts) && <Button>Edit</Button>}
98
138
  * ```
99
139
  */
100
- declare function can(grants: Grant[], action: PermissionAction, table: DrizzleTable): boolean;
140
+ declare function can<TTables extends SchemaMap = SchemaMap>(grants: Grant[], action: PermissionAction, table: SubjectInput<TTables>): boolean;
101
141
 
102
142
  /**
103
143
  * Checks whether a role satisfies a set of permission descriptors.
@@ -133,6 +173,14 @@ declare function can(grants: Grant[], action: PermissionAction, table: DrizzleTa
133
173
  */
134
174
  declare function checkPermissions(role: string, permissions: Permissions, descriptors: PermissionDescriptor[]): PermissionCheckResult;
135
175
 
176
+ /**
177
+ * Minimal user shape accepted by {@link resolveGrants}: any object with a
178
+ * `roles` array. The function reads `.roles` and forwards them to the
179
+ * underlying merge logic.
180
+ */
181
+ type UserWithRoles = {
182
+ roles: readonly string[];
183
+ };
136
184
  /**
137
185
  * Resolves and merges grants for multiple roles into a single flat array.
138
186
  *
@@ -144,10 +192,27 @@ declare function checkPermissions(role: string, permissions: Permissions, descri
144
192
  *
145
193
  * This is used when a user has multiple roles and their grants need to be combined.
146
194
  *
195
+ * Two calling styles are supported (both equivalent):
196
+ * - **User object (preferred):** `resolveGrants(permissions, user)` where
197
+ * `user` has a `roles` field. The function extracts roles internally.
198
+ * - **Roles array (legacy):** `resolveGrants(permissions, roles)` where
199
+ * `roles` is a string array. Still supported for backwards compatibility.
200
+ *
147
201
  * @param permissions - The permissions object from {@link definePermissions}.
148
- * @param roles - Array of role names whose grants should be merged.
202
+ * @param userOrRoles - Either a user object with a `roles` field, or an
203
+ * array of role names whose grants should be merged.
149
204
  * @returns A deduplicated array of {@link Grant} objects with merged `where` clauses.
205
+ *
206
+ * @example
207
+ * ```ts
208
+ * // Preferred: pass the user directly
209
+ * const grants = resolveGrants(permissions, user);
210
+ *
211
+ * // Legacy: pass roles array (still works)
212
+ * const grants = resolveGrants(permissions, user.roles);
213
+ * ```
150
214
  */
151
- declare function resolveGrants(permissions: Permissions, roles: string[]): Grant[];
215
+ declare function resolveGrants(permissions: Permissions, user: UserWithRoles): Grant[];
216
+ declare function resolveGrants(permissions: Permissions, roles: readonly string[]): Grant[];
152
217
 
153
- export { DrizzleTable, Grant, PermissionAction, PermissionCheckResult, PermissionDescriptor, Permissions, PermissionsConfig, WhereClause, can, checkPermissions, definePermissions, grant, resolveGrants };
218
+ export { Grant, PermissionAction, PermissionCheckResult, PermissionDescriptor, Permissions, PermissionsConfig, SchemaMap, SubjectInput, type UserWithRoles, WhereClause, can, checkPermissions, definePermissions, grant, resolveGrants };
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  CRUD_ACTIONS,
3
3
  ForbiddenError,
4
4
  getTableName
5
- } from "./chunk-I35WLWUH.js";
5
+ } from "./chunk-NCLN5YBQ.js";
6
6
 
7
7
  // src/grant.ts
8
8
  function grant(action, subject, options) {
@@ -60,18 +60,21 @@ function resolveHierarchy(roles, grants, hierarchy) {
60
60
 
61
61
  // src/can.ts
62
62
  function can(grants, action, table) {
63
+ const targetKey = getTableName(table);
63
64
  return grants.some((g) => {
64
65
  const actionOk = g.action === action || g.action === "manage";
65
- const subjectOk = g.subject === "all" || typeof g.subject === "object" && getTableName(g.subject) === getTableName(table);
66
- return actionOk && subjectOk;
66
+ if (!actionOk) return false;
67
+ if (g.subject === "all") return true;
68
+ return getTableName(g.subject) === targetKey;
67
69
  });
68
70
  }
69
71
 
70
72
  // src/check.ts
71
73
  function grantMatches(g, action, table) {
72
74
  const actionOk = g.action === action || g.action === "manage";
73
- const subjectOk = g.subject === "all" || g.subject === table || getTableName(g.subject) === getTableName(table);
74
- return actionOk && subjectOk;
75
+ if (!actionOk) return false;
76
+ if (g.subject === "all") return true;
77
+ return getTableName(g.subject) === getTableName(table);
75
78
  }
76
79
  function hasGrantFor(grants, action, table) {
77
80
  return grants.some((g) => grantMatches(g, action, table));
@@ -110,7 +113,11 @@ import { or } from "drizzle-orm";
110
113
  function toSQLWrapper(value) {
111
114
  return value;
112
115
  }
113
- function resolveGrants(permissions, roles) {
116
+ function isUserWithRoles(value) {
117
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Array.isArray(value.roles);
118
+ }
119
+ function resolveGrants(permissions, userOrRoles) {
120
+ const roles = isUserWithRoles(userOrRoles) ? userOrRoles.roles : userOrRoles;
114
121
  const allGrants = [];
115
122
  for (const role of roles) {
116
123
  const roleGrants = permissions.resolvedGrants[role];
@@ -122,16 +129,18 @@ function resolveGrants(permissions, roles) {
122
129
  const groups = /* @__PURE__ */ new Map();
123
130
  const subjectIds = /* @__PURE__ */ new Map();
124
131
  let nextId = 0;
125
- function getSubjectId(subject) {
132
+ function getSubjectKey(subject) {
133
+ if (subject === "all") return "all";
134
+ if (typeof subject === "string") return `s:${subject}`;
126
135
  let id = subjectIds.get(subject);
127
136
  if (id === void 0) {
128
137
  id = nextId++;
129
138
  subjectIds.set(subject, id);
130
139
  }
131
- return id;
140
+ return `o:${id}`;
132
141
  }
133
142
  for (const g of allGrants) {
134
- const key = `${g.action}:${getSubjectId(g.subject)}`;
143
+ const key = `${g.action}:${getSubjectKey(g.subject)}`;
135
144
  let group = groups.get(key);
136
145
  if (!group) {
137
146
  group = { action: g.action, subject: g.subject, wheres: [] };
package/llms.txt CHANGED
@@ -43,6 +43,23 @@ const permissions = definePermissions<MyUser>()({
43
43
  });
44
44
  ```
45
45
 
46
+ **Curried form with schema** to constrain string subjects to known table names at compile time:
47
+ ```typescript
48
+ import * as schema from "./schema";
49
+
50
+ const permissions = definePermissions<MyUser, typeof schema>()({
51
+ roles: ["member", "admin"] as const,
52
+ grants: (grant) => ({
53
+ member: [
54
+ grant("read", "projects"), // ✓ string form, type-checked
55
+ grant("read", schema.projects), // ✓ object form still works
56
+ // grant("read", "unknownTable"), // ✗ TypeScript error
57
+ ],
58
+ admin: [grant("manage", "all")],
59
+ }),
60
+ });
61
+ ```
62
+
46
63
  **Returns** `Permissions<TRoles>`:
47
64
  ```typescript
48
65
  type Permissions<TRoles> = {
@@ -54,26 +71,40 @@ type Permissions<TRoles> = {
54
71
 
55
72
  ### `grant(action, subject, options?): Grant`
56
73
  ```typescript
57
- grant(action: PermissionAction, subject: DrizzleTable | "all", options?: { where?: WhereClause }): Grant
74
+ grant(action: PermissionAction, subject: DrizzleTable | string, options?: { where?: WhereClause }): Grant
58
75
  ```
59
76
  - `PermissionAction`: `"read" | "create" | "update" | "delete" | "manage"`
60
77
  - `WhereClause`: `(columns, user) => DrizzleSQL | undefined`
78
+ - `subject` may be a Drizzle table object, a string table name (e.g. `"projects"`), or `"all"`. String and object forms are interchangeable at runtime — both are normalized to the same key by `getTableName()`. To get compile-time validation that a string subject is a known table, use the `definePermissions<User, typeof schema>()` curried form.
61
79
 
62
80
  ### `checkPermissions(role, permissions, descriptors): PermissionCheckResult`
63
81
  ```typescript
64
82
  import { checkPermissions } from "@cfast/permissions";
65
83
 
66
84
  const result = checkPermissions("user", permissions, [
67
- { action: "update", table: posts },
85
+ { action: "update", table: posts }, // object form
86
+ { action: "create", table: "comments" }, // string form also accepted
68
87
  ]);
69
88
  result.permitted; // boolean
70
89
  result.denied; // PermissionDescriptor[]
71
90
  result.reasons; // string[]
72
91
  ```
73
92
 
74
- ### `resolveGrants(permissions, roles): Grant[]`
93
+ ### `resolveGrants(permissions, userOrRoles): Grant[]`
75
94
  Merges grants from multiple roles into a flat array. Deduplicates by action+subject and OR-merges WHERE clauses.
76
95
 
96
+ Two calling styles are supported (both equivalent):
97
+
98
+ ```typescript
99
+ // Preferred: pass the user directly — the function extracts user.roles
100
+ const grants = resolveGrants(permissions, user);
101
+
102
+ // Legacy: pass the roles array yourself (still supported)
103
+ const grants = resolveGrants(permissions, user.roles);
104
+ ```
105
+
106
+ The user-object form accepts any value with a `roles: readonly string[]` field, so it works directly with the user shape from `@cfast/auth` without manual extraction.
107
+
77
108
  ### `ForbiddenError`
78
109
  ```typescript
79
110
  import { ForbiddenError } from "@cfast/permissions";
@@ -90,13 +121,23 @@ class ForbiddenError extends Error {
90
121
  ### `can(grants, action, table): boolean`
91
122
  ```typescript
92
123
  import { can } from "@cfast/permissions";
93
- function can(grants: Grant[], action: PermissionAction, table: DrizzleTable): boolean
124
+ function can<TTables extends Record<string, DrizzleTable> = Record<string, DrizzleTable>>(
125
+ grants: Grant[],
126
+ action: PermissionAction,
127
+ table: DrizzleTable | string,
128
+ ): boolean
94
129
  ```
95
130
  Quick grant-level permission check that works directly with resolved grants. Unlike `checkPermissions` (which takes a role string + full permissions object), `can` works with the user's resolved grants -- the same grants available in loaders, actions, and client-side. Returns `true` if any grant permits the action on the table (including `manage`/`all` wildcards).
96
131
 
132
+ Accepts either a Drizzle table object or a string table name. To get compile-time validation that a string is an actual table, supply the schema map as a generic argument.
133
+
97
134
  ```typescript
98
- can(grants, "create", posts) // boolean
99
- can(grants, "delete", comments) // → boolean
135
+ import * as schema from "../db/schema";
136
+
137
+ can(grants, "create", schema.posts) // ✓ object form
138
+ can(grants, "create", "posts") // ✓ string form (untyped)
139
+ can<typeof schema>(grants, "create", "posts") // ✓ string form, type-checked
140
+ // can<typeof schema>(grants, "create", "unknownTable") // ✗ TypeScript error
100
141
  ```
101
142
 
102
143
  ### `CRUD_ACTIONS`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfast/permissions",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Isomorphic, composable permission system with Drizzle-native row-level access control",
5
5
  "keywords": [
6
6
  "cfast",
@@ -36,13 +36,6 @@
36
36
  "publishConfig": {
37
37
  "access": "public"
38
38
  },
39
- "scripts": {
40
- "build": "tsup src/index.ts src/client.ts --format esm --dts",
41
- "dev": "tsup src/index.ts src/client.ts --format esm --dts --watch",
42
- "typecheck": "tsc --noEmit",
43
- "lint": "eslint src/",
44
- "test": "vitest run"
45
- },
46
39
  "peerDependencies": {
47
40
  "drizzle-orm": ">=0.35"
48
41
  },
@@ -50,5 +43,12 @@
50
43
  "tsup": "^8",
51
44
  "typescript": "^5.7",
52
45
  "vitest": "^4.1.0"
46
+ },
47
+ "scripts": {
48
+ "build": "tsup src/index.ts src/client.ts --format esm --dts",
49
+ "dev": "tsup src/index.ts src/client.ts --format esm --dts --watch",
50
+ "typecheck": "tsc --noEmit",
51
+ "lint": "eslint src/",
52
+ "test": "vitest run"
53
53
  }
54
- }
54
+ }