@cfast/permissions 0.3.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";
@@ -345,5 +379,28 @@ declare class ForbiddenError extends Error {
345
379
  role: string | undefined;
346
380
  };
347
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
+ }
348
405
 
349
- 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, getTableName as j };
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 { g as CrudAction, F as ForbiddenError, b as PermissionAction, f as PermissionCheckResult, e as PermissionDescriptor } from './client-DKSiBBkt.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 WithLookups, d as WhereClause, G as Grant, e as PermissionDescriptor, f as PermissionCheckResult } from './client-DKSiBBkt.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, T as TableName, j as getTableName } from './client-DKSiBBkt.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.
15
- *
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`.
8
+ * Supports four calling styles:
9
+ *
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, g 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, g 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
  /**
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
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) {
@@ -15,9 +16,43 @@ function grant(action, subject, options) {
15
16
  }
16
17
 
17
18
  // src/define-permissions.ts
18
- 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) {
19
51
  const { roles, hierarchy } = config;
20
- const grantFn = grant;
52
+ const grantFn = schema ? (() => {
53
+ const { lookup, available } = buildSchemaLookup(schema);
54
+ return makeSchemaAwareGrant(lookup, available);
55
+ })() : grant;
21
56
  const grants = typeof config.grants === "function" ? config.grants(grantFn) : config.grants;
22
57
  const resolvedGrants = resolveHierarchy(roles, grants, hierarchy);
23
58
  return {
@@ -26,11 +61,20 @@ function buildPermissions(config) {
26
61
  resolvedGrants
27
62
  };
28
63
  }
29
- function definePermissions(config) {
30
- if (config === void 0) {
64
+ function definePermissions(configOrOptions) {
65
+ if (configOrOptions === void 0) {
31
66
  return (c) => buildPermissions(c);
32
67
  }
33
- 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;
34
78
  }
35
79
  function resolveHierarchy(roles, grants, hierarchy) {
36
80
  if (!hierarchy) {
@@ -193,6 +237,7 @@ function resolveGrants(permissions, userOrRoles) {
193
237
  export {
194
238
  CRUD_ACTIONS,
195
239
  ForbiddenError,
240
+ PermissionRegistrationError,
196
241
  can,
197
242
  checkPermissions,
198
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> = {
@@ -79,7 +84,7 @@ grant(action: PermissionAction, subject: DrizzleTable | string, options?: {
79
84
  - `PermissionAction`: `"read" | "create" | "update" | "delete" | "manage"`
80
85
  - `WhereClause`: `(columns, user, lookups) => DrizzleSQL | undefined`
81
86
  - `WithLookups`: `Record<string, (user, db) => Promise<unknown> | unknown>`
82
- - `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.
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.
83
88
 
84
89
  #### Cross-table grants via `with`
85
90
 
@@ -93,7 +98,7 @@ const { recipes, friendGrants } = schema;
93
98
 
94
99
  type AppUser = { id: string; roles: string[] };
95
100
 
96
- export const permissions = definePermissions<AppUser, typeof schema>()({
101
+ export const permissions = definePermissions<AppUser, typeof schema>({ schema })({
97
102
  roles: ["user"] as const,
98
103
  grants: (g) => ({
99
104
  user: [
@@ -168,6 +173,20 @@ class ForbiddenError extends Error {
168
173
  }
169
174
  ```
170
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
+
171
190
  ### `can(grants, action, table): boolean`
172
191
  ```typescript
173
192
  import { can } from "@cfast/permissions";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfast/permissions",
3
- "version": "0.3.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",