@cfast/permissions 0.2.0 → 0.4.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.
@@ -49,9 +49,26 @@ var ForbiddenError = class extends Error {
49
49
  };
50
50
  }
51
51
  };
52
+ var PermissionRegistrationError = class extends Error {
53
+ /** The unresolved subject string that was passed to `grant()`. */
54
+ subject;
55
+ /** The available table names (both JS keys and SQL names) from the schema. */
56
+ availableTables;
57
+ constructor(subject, availableTables) {
58
+ const sorted = [...availableTables].sort();
59
+ const list = sorted.length === 0 ? "<empty schema>" : sorted.map((n) => `"${n}"`).join(", ");
60
+ super(
61
+ `grant() subject "${subject}" does not match any table in the schema. Available tables (by JS key and SQL name): ${list}. Did you forget to pass the schema as the second generic to definePermissions<User, typeof schema>()?`
62
+ );
63
+ this.name = "PermissionRegistrationError";
64
+ this.subject = subject;
65
+ this.availableTables = sorted;
66
+ }
67
+ };
52
68
 
53
69
  export {
54
70
  getTableName,
55
71
  CRUD_ACTIONS,
56
- ForbiddenError
72
+ ForbiddenError,
73
+ PermissionRegistrationError
57
74
  };
@@ -17,27 +17,61 @@ type DrizzleSQL = {
17
17
  getSQL(): unknown;
18
18
  };
19
19
  /**
20
- * A schema map: an object mapping table names to Drizzle table references.
20
+ * A schema map: an object mapping JS schema keys to Drizzle table references.
21
21
  *
22
22
  * Typically the result of `import * as schema from "./schema"`. Used as the
23
23
  * `TTables` generic parameter for {@link definePermissions}, {@link can}, and
24
24
  * the curried {@link grant} callback so that string subjects (e.g.
25
- * `"projects"`) are constrained to known table names at compile time.
25
+ * `"projects"` or `"project_versions"`) are constrained to known table names
26
+ * at compile time.
26
27
  */
27
28
  type SchemaMap = Record<string, DrizzleTable>;
28
29
  /**
29
- * Extracts the string-literal union of valid table names from a {@link SchemaMap}.
30
+ * Structural shape of a Drizzle table's static metadata used purely for
31
+ * type-level SQL-name extraction.
30
32
  *
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
+ * Drizzle tables expose their SQL name on a readonly `_.name` field (see
34
+ * `drizzle-orm/table` `Table._.name`). By narrowing against this shape we can
35
+ * extract `"project_versions"` from `typeof projectVersions` even though the
36
+ * runtime value lives on a `Symbol.for("drizzle:Name")` key.
33
37
  */
34
- type TableName<TTables extends SchemaMap> = Extract<keyof TTables, string>;
38
+ type TableWithSqlName = {
39
+ _: {
40
+ name: string;
41
+ };
42
+ };
43
+ /**
44
+ * Extracts the SQL table name literal from a Drizzle table reference, or
45
+ * `never` when the argument is not a Drizzle table with a `_.name` field.
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * const postVersions = sqliteTable("post_versions", { ... });
50
+ * type N = SqlNameOf<typeof postVersions>; // "post_versions"
51
+ * ```
52
+ */
53
+ type SqlNameOf<T> = T extends TableWithSqlName ? T["_"]["name"] extends string ? T["_"]["name"] : never : never;
54
+ /**
55
+ * Extracts the string-literal union of valid subject keys from a
56
+ * {@link SchemaMap}, including **both** the JS schema keys (e.g.
57
+ * `"postVersions"`) and the SQL table names (e.g. `"post_versions"`).
58
+ *
59
+ * The runtime side of `definePermissions<User, Schema>()` builds a
60
+ * matching lookup table keyed by both forms, so the two string forms are
61
+ * fully interchangeable — whichever matches your mental model.
62
+ *
63
+ * Without this, string subjects were accidentally constrained to JS keys
64
+ * only (#177), causing confusion when a schema's JS key differs from its
65
+ * SQL table name (e.g. `documentVersions` vs `document_versions`).
66
+ */
67
+ type TableName<TTables extends SchemaMap> = Extract<keyof TTables, string> | SqlNameOf<TTables[keyof TTables]>;
35
68
  /**
36
69
  * The set of acceptable subject inputs for a grant or `can()` check.
37
70
  *
38
71
  * - 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.
72
+ * - A string-literal table name from {@link TableName} either the JS schema
73
+ * key (`"postVersions"`) or the SQL table name (`"post_versions"`) both
74
+ * resolve to the same underlying table at runtime.
41
75
  * - The literal `"all"` for grants that apply to every table.
42
76
  */
43
77
  type SubjectInput<TTables extends SchemaMap = SchemaMap> = DrizzleTable | TableName<TTables> | "all";
@@ -92,9 +126,61 @@ declare const CRUD_ACTIONS: readonly CrudAction[];
92
126
  *
93
127
  * @param columns - The table's column references for building filter expressions.
94
128
  * @param user - The current user object (from `@cfast/auth`).
129
+ * @param lookups - Resolved values from the grant's `with` map (see {@link Grant.with}).
130
+ * Empty object when the grant declares no `with` map. Each key holds the awaited
131
+ * result of the lookup function with the same name.
95
132
  * @returns A Drizzle SQL expression to restrict matching rows, or `undefined` for no restriction.
96
133
  */
97
- type WhereClause = (columns: Record<string, unknown>, user: unknown) => DrizzleSQL | undefined;
134
+ type WhereClause = (columns: Record<string, unknown>, user: unknown, lookups: Record<string, unknown>) => DrizzleSQL | undefined;
135
+ /**
136
+ * Minimal structural type for the read-only database handle passed to grant
137
+ * `with` lookup functions.
138
+ *
139
+ * Defined here so the permissions package stays independent of `@cfast/db`.
140
+ * The runtime instance is provided by `@cfast/db` (an unsafe-mode db) and
141
+ * is structurally compatible with this interface, so authors can call
142
+ * `db.query(table).findMany().run()` inside a lookup without casting.
143
+ *
144
+ * The `query` method is intentionally typed loosely (`unknown` arguments and
145
+ * results) to avoid pulling Drizzle generics into the permissions surface.
146
+ * Authors typically know exactly which table they are querying and cast the
147
+ * result inline.
148
+ */
149
+ interface LookupDb {
150
+ query: (table: DrizzleTable | string) => {
151
+ findMany: (options?: unknown) => {
152
+ run: (params?: unknown) => Promise<unknown[]>;
153
+ };
154
+ findFirst: (options?: unknown) => {
155
+ run: (params?: unknown) => Promise<unknown>;
156
+ };
157
+ };
158
+ }
159
+ /**
160
+ * A single prerequisite lookup function for a grant's `with` map.
161
+ *
162
+ * Lookups run **before** the main query and their resolved values are passed
163
+ * as the third argument to the grant's {@link WhereClause}. Use them to gather
164
+ * data from other tables that a row-level filter depends on (for example,
165
+ * the set of friend ids that a user can see content for).
166
+ *
167
+ * Lookups receive an unsafe-mode db handle so they can read freely without
168
+ * recursively triggering permission checks. Their results are cached for the
169
+ * lifetime of the per-request `Db` instance, so a single lookup runs at most
170
+ * once even when the same grant is consulted multiple times.
171
+ *
172
+ * @typeParam TUser - The user type (typed via `definePermissions<TUser>`).
173
+ */
174
+ type LookupFn<TUser = unknown> = (user: TUser, db: LookupDb) => Promise<unknown> | unknown;
175
+ /**
176
+ * A map of named prerequisite lookups for a grant.
177
+ *
178
+ * Each value is a {@link LookupFn} whose resolved result is passed to the
179
+ * grant's `where` clause via the third `lookups` argument under the same key.
180
+ *
181
+ * @typeParam TUser - The user type (typed via `definePermissions<TUser>`).
182
+ */
183
+ type WithLookups<TUser = unknown> = Record<string, LookupFn<TUser>>;
98
184
  /**
99
185
  * A single permission grant: an action on a subject, optionally restricted by a `where` clause.
100
186
  *
@@ -112,6 +198,37 @@ type Grant = {
112
198
  * or `"all"` for every table.
113
199
  */
114
200
  subject: DrizzleTable | string;
201
+ /**
202
+ * Optional prerequisite lookups that resolve **before** the main query and
203
+ * are passed to the {@link where} clause as its third argument.
204
+ *
205
+ * Each lookup receives the current user and an unsafe-mode db handle and
206
+ * may return any value (synchronously or as a promise). Results are cached
207
+ * per `Db` instance, so a single lookup runs at most once per request even
208
+ * when the same grant participates in multiple queries.
209
+ *
210
+ * @example
211
+ * ```ts
212
+ * grant("read", recipes, {
213
+ * with: {
214
+ * friendIds: async (user, db) => {
215
+ * const rows = await db
216
+ * .query(friendGrants)
217
+ * .findMany({ where: eq(friendGrants.grantee, user.id) })
218
+ * .run();
219
+ * return (rows as { target: string }[]).map((r) => r.target);
220
+ * },
221
+ * },
222
+ * where: (recipe, user, { friendIds }) =>
223
+ * or(
224
+ * eq(recipe.visibility, "public"),
225
+ * eq(recipe.authorId, user.id),
226
+ * inArray(recipe.authorId, friendIds as string[]),
227
+ * ),
228
+ * });
229
+ * ```
230
+ */
231
+ with?: WithLookups;
115
232
  /** Optional row-level filter that restricts which rows this grant covers. */
116
233
  where?: WhereClause;
117
234
  };
@@ -127,8 +244,20 @@ type Grant = {
127
244
  * @typeParam TTables - Optional schema map (e.g. `typeof schema`) used to
128
245
  * constrain string subjects to known table-name literals.
129
246
  */
130
- type GrantFn<TUser, TTables extends SchemaMap = SchemaMap> = (action: PermissionAction, subject: SubjectInput<TTables>, options?: {
131
- where?: (columns: Record<string, unknown>, user: TUser) => DrizzleSQL | undefined;
247
+ type GrantFn<TUser, TTables extends SchemaMap = SchemaMap> = <TWith extends WithLookups<TUser> = WithLookups<TUser>>(action: PermissionAction, subject: SubjectInput<TTables>, options?: {
248
+ /**
249
+ * Prerequisite lookups whose resolved values are passed to {@link where}
250
+ * via its third argument. See {@link Grant.with}.
251
+ */
252
+ with?: TWith;
253
+ /**
254
+ * Row-level filter. The third argument exposes the resolved values from
255
+ * {@link with}, keyed by the same names; pass an empty `with` map (or
256
+ * omit it) when the filter does not need cross-table data.
257
+ */
258
+ where?: (columns: Record<string, unknown>, user: TUser, lookups: {
259
+ [K in keyof TWith]: Awaited<ReturnType<TWith[K]>>;
260
+ }) => DrizzleSQL | undefined;
132
261
  }) => Grant;
133
262
  /**
134
263
  * Structural description of a permission requirement.
@@ -250,5 +379,28 @@ declare class ForbiddenError extends Error {
250
379
  role: string | undefined;
251
380
  };
252
381
  }
382
+ /**
383
+ * Error thrown at **permission-definition time** when a string-form subject
384
+ * passed to the `grant` callback inside
385
+ * `definePermissions<User, typeof schema>()` cannot be resolved to a real
386
+ * Drizzle table reference from the schema map.
387
+ *
388
+ * This is the runtime sibling of the compile-time constraint on
389
+ * {@link TableName} — it catches cases where the schema generic was skipped,
390
+ * the string was typed wrong (typo, wrong case), or the table was removed
391
+ * from the schema without updating the grant.
392
+ *
393
+ * Unlike {@link ForbiddenError} (thrown per-request on a permission check),
394
+ * `PermissionRegistrationError` is thrown **once at startup** from
395
+ * `definePermissions()` — so a misconfigured grant fails fast and loud
396
+ * instead of silently matching nothing at query time.
397
+ */
398
+ declare class PermissionRegistrationError extends Error {
399
+ /** The unresolved subject string that was passed to `grant()`. */
400
+ readonly subject: string;
401
+ /** The available table names (both JS keys and SQL names) from the schema. */
402
+ readonly availableTables: readonly string[];
403
+ constructor(subject: string, availableTables: readonly string[]);
404
+ }
253
405
 
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 };
406
+ export { CRUD_ACTIONS as C, type DrizzleTable as D, ForbiddenError as F, type Grant as G, type LookupDb as L, type PermissionsConfig as P, type SchemaMap as S, type TableName as T, type WithLookups as W, type Permissions as a, type PermissionAction as b, type SubjectInput as c, type WhereClause as d, type PermissionDescriptor as e, type PermissionCheckResult as f, type CrudAction as g, type GrantFn as h, type LookupFn as i, PermissionRegistrationError as j, type SqlNameOf as k, getTableName as l };
package/dist/client.d.ts CHANGED
@@ -1 +1 @@
1
- export { f as CrudAction, F as ForbiddenError, b as PermissionAction, e as PermissionCheckResult, d as PermissionDescriptor } from './client-C7xdwHTk.js';
1
+ export { g as CrudAction, F as ForbiddenError, b as PermissionAction, f as PermissionCheckResult, e as PermissionDescriptor } from './client-D6goQV8b.js';
package/dist/client.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  ForbiddenError
3
- } from "./chunk-NCLN5YBQ.js";
3
+ } from "./chunk-ISTOPBSB.js";
4
4
  export {
5
5
  ForbiddenError
6
6
  };
package/dist/index.d.ts CHANGED
@@ -1,20 +1,44 @@
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';
1
+ import { P as PermissionsConfig, a as Permissions, S as SchemaMap, b as PermissionAction, c as SubjectInput, W as WithLookups, d as WhereClause, G as Grant, e as PermissionDescriptor, f as PermissionCheckResult } from './client-D6goQV8b.js';
2
+ export { C as CRUD_ACTIONS, g as CrudAction, D as DrizzleTable, F as ForbiddenError, h as GrantFn, L as LookupDb, i as LookupFn, j as PermissionRegistrationError, k as SqlNameOf, T as TableName, l as getTableName } from './client-D6goQV8b.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 three calling styles:
9
- * - **Direct:** `definePermissions(config)` when no custom user type is needed.
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.
8
+ * Supports four calling styles:
15
9
  *
16
- * @param config - The permissions configuration with roles, grants, and optional hierarchy.
17
- * @returns A {@link Permissions} object containing roles, raw grants, and hierarchy-expanded `resolvedGrants`.
10
+ * 1. **Direct:** `definePermissions(config)` when no custom user type and
11
+ * no schema map is needed.
12
+ *
13
+ * 2. **Curried (user only):** `definePermissions<MyUser>()(config)` — types
14
+ * the `user` parameter inside `where` clauses. String subjects are still
15
+ * accepted but are **not** compile-time or runtime validated against a
16
+ * schema; they are stored as opaque strings and matched by `getTableName`.
17
+ *
18
+ * 3. **Curried (user + schema types only, no runtime schema):**
19
+ * `definePermissions<MyUser, typeof schema>()(config)` — constrains
20
+ * string subjects to `"jsKey" | "sql_name"` at compile time, but at
21
+ * runtime still stores strings opaquely. Prefer style (4) when you can.
22
+ *
23
+ * 4. **Curried with runtime schema (RECOMMENDED):**
24
+ * `definePermissions<MyUser, typeof schema>({ schema })(config)` — walks
25
+ * the schema at startup and resolves every string-form subject to the
26
+ * exact Drizzle table reference the schema uses. This is the only form
27
+ * that lets string-form grants work with Drizzle relational `with`
28
+ * queries, because Drizzle identifies tables by reference, not name
29
+ * (see issues #146, #175, #177).
30
+ *
31
+ * - Unknown strings throw {@link PermissionRegistrationError} **at
32
+ * startup** (fast, loud failure instead of silently matching nothing).
33
+ * - Both the JS key (`"documentVersions"`) and the SQL table name
34
+ * (`"document_versions"`) resolve to the same table reference.
35
+ * - Object-form subjects still work unchanged.
36
+ *
37
+ * @param configOrOptions - Either the permissions config (style 1) or the
38
+ * resolver options `{ schema }` (style 4). When omitted entirely, returns
39
+ * the curried form (styles 2 and 3).
40
+ * @returns A {@link Permissions} object, or a function that takes a config
41
+ * and returns one.
18
42
  *
19
43
  * @example
20
44
  * ```typescript
@@ -23,31 +47,30 @@ export { C as CRUD_ACTIONS, f as CrudAction, D as DrizzleTable, F as ForbiddenEr
23
47
  * import * as schema from "./schema";
24
48
  * const { posts, comments } = schema;
25
49
  *
26
- * // Direct form — accepts table objects
50
+ * // Style 1direct, accepts table objects
27
51
  * const permissions = definePermissions({
28
52
  * roles: ["anonymous", "user", "admin"] as const,
29
53
  * grants: {
30
54
  * anonymous: [
31
55
  * grant("read", posts, { where: (p) => eq(p.published, true) }),
32
56
  * ],
33
- * user: [
34
- * grant("read", posts),
35
- * grant("create", posts),
36
- * ],
57
+ * user: [grant("read", posts), grant("create", posts)],
37
58
  * admin: [grant("manage", "all")],
38
59
  * },
39
60
  * });
40
61
  *
41
- * // Curried form — string subjects constrained to known tables
62
+ * // Style 4recommended: runtime-resolved string subjects
42
63
  * type AuthUser = { id: string };
43
- * const perms = definePermissions<AuthUser, typeof schema>()({
64
+ * const perms = definePermissions<AuthUser, typeof schema>({ schema })({
44
65
  * roles: ["user", "admin"] as const,
45
66
  * grants: (grant) => ({
46
67
  * user: [
47
- * grant("read", "posts"), // string form
48
- * grant("update", posts, { // object form still works
68
+ * grant("read", "posts"), // JS key — resolves
69
+ * grant("create", "post_versions"), // SQL name also resolves
70
+ * grant("update", posts, { // object form still works
49
71
  * where: (p, u) => eq(p.authorId, u.id),
50
72
  * }),
73
+ * // grant("read", "unknownTable"), // throws at startup
51
74
  * ],
52
75
  * admin: [grant("manage", "all")],
53
76
  * }),
@@ -55,6 +78,9 @@ export { C as CRUD_ACTIONS, f as CrudAction, D as DrizzleTable, F as ForbiddenEr
55
78
  * ```
56
79
  */
57
80
  declare function definePermissions<TRoles extends readonly string[]>(config: PermissionsConfig<TRoles>): Permissions<TRoles>;
81
+ declare function definePermissions<TUser, TTables extends SchemaMap>(options: {
82
+ schema: TTables;
83
+ }): <TRoles extends readonly string[]>(config: PermissionsConfig<TRoles, TUser, TTables>) => Permissions<TRoles>;
58
84
  declare function definePermissions<TUser, TTables extends SchemaMap = SchemaMap>(): <TRoles extends readonly string[]>(config: PermissionsConfig<TRoles, TUser, TTables>) => Permissions<TRoles>;
59
85
 
60
86
  /**
@@ -95,11 +121,33 @@ declare function definePermissions<TUser, TTables extends SchemaMap = SchemaMap>
95
121
  * where: (post, user) => eq(post.authorId, user.id),
96
122
  * });
97
123
  *
124
+ * // Cross-table grant: read recipes that public, owned by the user, or
125
+ * // owned by a friend (resolved via a prerequisite lookup that runs once
126
+ * // per request and is cached for subsequent reads).
127
+ * grant("read", recipes, {
128
+ * with: {
129
+ * friendIds: async (user, db) => {
130
+ * const rows = await db
131
+ * .query(friendGrants)
132
+ * .findMany({ where: eq(friendGrants.grantee, user.id) })
133
+ * .run();
134
+ * return (rows as { target: string }[]).map((r) => r.target);
135
+ * },
136
+ * },
137
+ * where: (recipe, user, { friendIds }) =>
138
+ * or(
139
+ * eq(recipe.visibility, "public"),
140
+ * eq(recipe.authorId, user.id),
141
+ * inArray(recipe.authorId, friendIds as string[]),
142
+ * ),
143
+ * });
144
+ *
98
145
  * // Full access to everything
99
146
  * grant("manage", "all");
100
147
  * ```
101
148
  */
102
149
  declare function grant<TTables extends SchemaMap = SchemaMap>(action: PermissionAction, subject: SubjectInput<TTables>, options?: {
150
+ with?: WithLookups;
103
151
  where?: WhereClause;
104
152
  }): Grant;
105
153
 
@@ -215,4 +263,4 @@ type UserWithRoles = {
215
263
  declare function resolveGrants(permissions: Permissions, user: UserWithRoles): Grant[];
216
264
  declare function resolveGrants(permissions: Permissions, roles: readonly string[]): Grant[];
217
265
 
218
- export { Grant, PermissionAction, PermissionCheckResult, PermissionDescriptor, Permissions, PermissionsConfig, SchemaMap, SubjectInput, type UserWithRoles, WhereClause, can, checkPermissions, definePermissions, grant, resolveGrants };
266
+ export { Grant, PermissionAction, PermissionCheckResult, PermissionDescriptor, Permissions, PermissionsConfig, SchemaMap, SubjectInput, type UserWithRoles, WhereClause, WithLookups, can, checkPermissions, definePermissions, grant, resolveGrants };
package/dist/index.js CHANGED
@@ -1,22 +1,58 @@
1
1
  import {
2
2
  CRUD_ACTIONS,
3
3
  ForbiddenError,
4
+ PermissionRegistrationError,
4
5
  getTableName
5
- } from "./chunk-NCLN5YBQ.js";
6
+ } from "./chunk-ISTOPBSB.js";
6
7
 
7
8
  // src/grant.ts
8
9
  function grant(action, subject, options) {
9
10
  return {
10
11
  action,
11
12
  subject,
13
+ with: options?.with,
12
14
  where: options?.where
13
15
  };
14
16
  }
15
17
 
16
18
  // src/define-permissions.ts
17
- function buildPermissions(config) {
19
+ function buildSchemaLookup(schema) {
20
+ const lookup = /* @__PURE__ */ new Map();
21
+ const available = /* @__PURE__ */ new Set();
22
+ if (!schema) return { lookup, available: [] };
23
+ for (const [jsKey, table] of Object.entries(schema)) {
24
+ if (!table || typeof table !== "object" && typeof table !== "function") {
25
+ continue;
26
+ }
27
+ lookup.set(jsKey, table);
28
+ available.add(jsKey);
29
+ const sqlName = getTableName(table);
30
+ if (sqlName && sqlName !== "unknown") {
31
+ lookup.set(sqlName, table);
32
+ available.add(sqlName);
33
+ }
34
+ }
35
+ return { lookup, available: [...available] };
36
+ }
37
+ function makeSchemaAwareGrant(lookup, available) {
38
+ const schemaGrant = ((action, subject, options) => {
39
+ if (typeof subject !== "string" || subject === "all") {
40
+ return grant(action, subject, options);
41
+ }
42
+ const resolved = lookup.get(subject);
43
+ if (!resolved) {
44
+ throw new PermissionRegistrationError(subject, available);
45
+ }
46
+ return grant(action, resolved, options);
47
+ });
48
+ return schemaGrant;
49
+ }
50
+ function buildPermissions(config, schema) {
18
51
  const { roles, hierarchy } = config;
19
- const grantFn = grant;
52
+ const grantFn = schema ? (() => {
53
+ const { lookup, available } = buildSchemaLookup(schema);
54
+ return makeSchemaAwareGrant(lookup, available);
55
+ })() : grant;
20
56
  const grants = typeof config.grants === "function" ? config.grants(grantFn) : config.grants;
21
57
  const resolvedGrants = resolveHierarchy(roles, grants, hierarchy);
22
58
  return {
@@ -25,11 +61,20 @@ function buildPermissions(config) {
25
61
  resolvedGrants
26
62
  };
27
63
  }
28
- function definePermissions(config) {
29
- if (config === void 0) {
64
+ function definePermissions(configOrOptions) {
65
+ if (configOrOptions === void 0) {
30
66
  return (c) => buildPermissions(c);
31
67
  }
32
- return buildPermissions(config);
68
+ if (isSchemaOptions(configOrOptions)) {
69
+ const { schema } = configOrOptions;
70
+ return (c) => buildPermissions(c, schema);
71
+ }
72
+ return buildPermissions(configOrOptions);
73
+ }
74
+ function isSchemaOptions(value) {
75
+ if (typeof value !== "object" || value === null) return false;
76
+ const v = value;
77
+ return v.schema !== void 0 && typeof v.schema === "object" && v.schema !== null && v.roles === void 0;
33
78
  }
34
79
  function resolveHierarchy(roles, grants, hierarchy) {
35
80
  if (!hierarchy) {
@@ -143,36 +188,48 @@ function resolveGrants(permissions, userOrRoles) {
143
188
  const key = `${g.action}:${getSubjectKey(g.subject)}`;
144
189
  let group = groups.get(key);
145
190
  if (!group) {
146
- group = { action: g.action, subject: g.subject, wheres: [] };
191
+ group = { action: g.action, subject: g.subject, grants: [] };
147
192
  groups.set(key, group);
148
193
  }
149
- group.wheres.push(g.where);
194
+ group.grants.push(g);
150
195
  }
151
196
  const result = [];
152
197
  for (const group of groups.values()) {
153
- const hasUnrestricted = group.wheres.some((w) => w === void 0);
198
+ const hasUnrestricted = group.grants.some((g) => !g.where);
154
199
  if (hasUnrestricted) {
155
200
  result.push({ action: group.action, subject: group.subject });
156
- } else {
157
- const whereFns = group.wheres.filter(
158
- (w) => w !== void 0
201
+ continue;
202
+ }
203
+ const withLookups = group.grants.filter((g) => g.with !== void 0);
204
+ const plain = group.grants.filter((g) => g.with === void 0 && g.where);
205
+ for (const g of withLookups) {
206
+ result.push({
207
+ action: group.action,
208
+ subject: group.subject,
209
+ with: g.with,
210
+ where: g.where
211
+ });
212
+ }
213
+ if (plain.length === 1) {
214
+ result.push({
215
+ action: group.action,
216
+ subject: group.subject,
217
+ where: plain[0].where
218
+ });
219
+ } else if (plain.length > 1) {
220
+ const whereFns = plain.map(
221
+ (g) => g.where
222
+ );
223
+ const merged = (columns, user, lookups) => or(
224
+ ...whereFns.map(
225
+ (fn) => toSQLWrapper(fn(columns, user, lookups))
226
+ )
159
227
  );
160
- if (whereFns.length === 1) {
161
- result.push({
162
- action: group.action,
163
- subject: group.subject,
164
- where: whereFns[0]
165
- });
166
- } else {
167
- const merged = (columns, user) => or(
168
- ...whereFns.map((fn) => toSQLWrapper(fn(columns, user)))
169
- );
170
- result.push({
171
- action: group.action,
172
- subject: group.subject,
173
- where: merged
174
- });
175
- }
228
+ result.push({
229
+ action: group.action,
230
+ subject: group.subject,
231
+ where: merged
232
+ });
176
233
  }
177
234
  }
178
235
  return result;
@@ -180,6 +237,7 @@ function resolveGrants(permissions, userOrRoles) {
180
237
  export {
181
238
  CRUD_ACTIONS,
182
239
  ForbiddenError,
240
+ PermissionRegistrationError,
183
241
  can,
184
242
  checkPermissions,
185
243
  definePermissions,
package/llms.txt CHANGED
@@ -32,34 +32,39 @@ const permissions = definePermissions({
32
32
  });
33
33
  ```
34
34
 
35
- **Curried form** for typed user in WHERE clauses:
36
- ```typescript
37
- const permissions = definePermissions<MyUser>()({
38
- roles: [...] as const,
39
- grants: (grant) => ({
40
- user: [ grant("update", posts, { where: (post, user) => eq(post.authorId, user.id) }) ],
41
- // ...
42
- }),
43
- });
44
- ```
35
+ **Curried form with schema (RECOMMENDED)** pass the schema as a runtime argument to resolve string subjects to the exact same Drizzle table reference your schema uses. This is the only form that lets string-form grants work with Drizzle relational `with` queries, because `@cfast/db` identifies tables by reference (not name):
45
36
 
46
- **Curried form with schema** to constrain string subjects to known table names at compile time:
47
37
  ```typescript
48
38
  import * as schema from "./schema";
49
39
 
50
- const permissions = definePermissions<MyUser, typeof schema>()({
40
+ const permissions = definePermissions<MyUser, typeof schema>({ schema })({
51
41
  roles: ["member", "admin"] as const,
52
42
  grants: (grant) => ({
53
43
  member: [
54
- grant("read", "projects"), // ✓ string form, type-checked
44
+ grant("read", "projects"), // ✓ JS key
45
+ grant("read", "project_versions"), // ✓ SQL name (snake_case)
55
46
  grant("read", schema.projects), // ✓ object form still works
56
- // grant("read", "unknownTable"), // ✗ TypeScript error
47
+ // grant("read", "unknownTable"), // ✗ TypeScript error + runtime throw
57
48
  ],
58
49
  admin: [grant("manage", "all")],
59
50
  }),
60
51
  });
61
52
  ```
62
53
 
54
+ Both JS schema keys (e.g. `"projectVersions"`) and SQL table names (e.g. `"project_versions"`) resolve to the same table reference. Unknown strings throw `PermissionRegistrationError` at **startup** — fast, loud failure instead of silently matching nothing at query time.
55
+
56
+ **Curried form without runtime schema** — legacy shape that types the `user` parameter but does not resolve strings. String subjects are stored opaquely and matched by name only; cross-table `with` relational queries will silently return empty arrays. Prefer the recommended form above:
57
+
58
+ ```typescript
59
+ const permissions = definePermissions<MyUser>()({
60
+ roles: [...] as const,
61
+ grants: (grant) => ({
62
+ user: [ grant("update", posts, { where: (post, user) => eq(post.authorId, user.id) }) ],
63
+ // ...
64
+ }),
65
+ });
66
+ ```
67
+
63
68
  **Returns** `Permissions<TRoles>`:
64
69
  ```typescript
65
70
  type Permissions<TRoles> = {
@@ -71,11 +76,61 @@ type Permissions<TRoles> = {
71
76
 
72
77
  ### `grant(action, subject, options?): Grant`
73
78
  ```typescript
74
- grant(action: PermissionAction, subject: DrizzleTable | string, options?: { where?: WhereClause }): Grant
79
+ grant(action: PermissionAction, subject: DrizzleTable | string, options?: {
80
+ with?: WithLookups;
81
+ where?: WhereClause;
82
+ }): Grant
75
83
  ```
76
84
  - `PermissionAction`: `"read" | "create" | "update" | "delete" | "manage"`
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.
85
+ - `WhereClause`: `(columns, user, lookups) => DrizzleSQL | undefined`
86
+ - `WithLookups`: `Record<string, (user, db) => Promise<unknown> | unknown>`
87
+ - `subject` may be a Drizzle table object, a string table name (JS key **or** SQL name), or `"all"`. When called via the `grant` callback inside `definePermissions<User, typeof schema>({ schema })`, string subjects are resolved at startup to the exact same Drizzle table reference the schema uses, and unknown strings throw `PermissionRegistrationError`. When called standalone (not via the schema-aware callback), string subjects are stored opaquely and matched by name — prefer the resolved form so cross-table relational `with` queries work correctly.
88
+
89
+ #### Cross-table grants via `with`
90
+
91
+ When a row-level filter needs data from another table (e.g. "recipes are visible if the user is in the recipe author's friend list"), declare the prerequisite read in the `with` map. Each entry runs **before** the main query, its result is passed to `where` under the same name, and results are cached per-request via the `Db` instance — a lookup runs at most once per request even when consulted by multiple queries.
92
+
93
+ ```typescript
94
+ import { definePermissions } from "@cfast/permissions";
95
+ import { eq, inArray, or } from "drizzle-orm";
96
+ import * as schema from "./schema";
97
+ const { recipes, friendGrants } = schema;
98
+
99
+ type AppUser = { id: string; roles: string[] };
100
+
101
+ export const permissions = definePermissions<AppUser, typeof schema>({ schema })({
102
+ roles: ["user"] as const,
103
+ grants: (g) => ({
104
+ user: [
105
+ g("read", recipes, {
106
+ with: {
107
+ // db is an unsafe-mode handle so the lookup itself isn't filtered.
108
+ friendIds: async (user, db) => {
109
+ const rows = await db
110
+ .query(friendGrants)
111
+ .findMany({ where: eq(friendGrants.grantee, user.id) })
112
+ .run();
113
+ return (rows as { target: string }[]).map((r) => r.target);
114
+ },
115
+ },
116
+ where: (recipe, user, { friendIds }) =>
117
+ or(
118
+ eq(recipe.visibility, "public"),
119
+ eq(recipe.authorId, user.id),
120
+ inArray(recipe.authorId, friendIds as string[]),
121
+ ),
122
+ }),
123
+ ],
124
+ }),
125
+ });
126
+ ```
127
+
128
+ Notes on `with` semantics:
129
+ - Lookup functions receive the user and a `LookupDb` (the unsafe sibling of the per-request `Db`); they may return any value, sync or async.
130
+ - Multiple lookups in a single grant resolve in **parallel** before the where clause is invoked.
131
+ - Results live in a per-`Db` cache keyed by grant identity, so the same grant participating in many `findMany`/`findFirst`/`paginate`/`update`/`delete` calls during one request only triggers each lookup once.
132
+ - A sibling **unrestricted** grant (e.g. an admin role) collapses the group, so the lookup is skipped entirely.
133
+ - A `with`-grant is never folded into another grant's `where` closure during `resolveGrants` — it stays a standalone grant so its lookup map can resolve independently.
79
134
 
80
135
  ### `checkPermissions(role, permissions, descriptors): PermissionCheckResult`
81
136
  ```typescript
@@ -118,6 +173,20 @@ class ForbiddenError extends Error {
118
173
  }
119
174
  ```
120
175
 
176
+ Thrown at request time when a permission check fails (row-level denial).
177
+
178
+ ### `PermissionRegistrationError`
179
+ ```typescript
180
+ import { PermissionRegistrationError } from "@cfast/permissions";
181
+
182
+ class PermissionRegistrationError extends Error {
183
+ readonly subject: string; // the unresolved string
184
+ readonly availableTables: readonly string[]; // sorted JS keys + SQL names
185
+ }
186
+ ```
187
+
188
+ Thrown **at startup** from `definePermissions<User, typeof schema>({ schema })(...)` when a string-form grant subject does not match any table in the schema. Fast, loud failure so a typo or a removed table surfaces immediately instead of silently matching nothing at query time.
189
+
121
190
  ### `can(grants, action, table): boolean`
122
191
  ```typescript
123
192
  import { can } from "@cfast/permissions";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfast/permissions",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Isomorphic, composable permission system with Drizzle-native row-level access control",
5
5
  "keywords": [
6
6
  "cfast",