@rebasepro/common 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/collections/CollectionRegistry.d.ts +30 -2
  2. package/dist/collections/default-collections.d.ts +255 -2
  3. package/dist/data/buildRoutedRebaseData.d.ts +53 -0
  4. package/dist/data/filter-dialect.d.ts +61 -0
  5. package/dist/data/query_builder.d.ts +4 -4
  6. package/dist/data/resolveDataSource.d.ts +43 -0
  7. package/dist/index.d.ts +4 -0
  8. package/dist/index.es.js +777 -178
  9. package/dist/index.es.js.map +1 -1
  10. package/dist/index.umd.js +793 -176
  11. package/dist/index.umd.js.map +1 -1
  12. package/dist/table-classification.d.ts +47 -0
  13. package/dist/util/builders.d.ts +48 -1
  14. package/dist/util/callbacks.d.ts +6 -1
  15. package/dist/util/index.d.ts +1 -0
  16. package/dist/util/permissions.d.ts +26 -2
  17. package/dist/util/policy/evaluatePolicy.d.ts +31 -0
  18. package/dist/util/policy/index.d.ts +3 -0
  19. package/dist/util/policy/policyToPostgres.d.ts +10 -0
  20. package/dist/util/policy/securityRuleToConditions.d.ts +24 -0
  21. package/dist/util/policy/sqlToPolicy.d.ts +20 -0
  22. package/dist/util/storage.d.ts +26 -1
  23. package/package.json +3 -3
  24. package/src/collections/CollectionRegistry.ts +80 -16
  25. package/src/collections/default-collections.ts +4 -4
  26. package/src/data/buildRebaseData.ts +9 -120
  27. package/src/data/buildRoutedRebaseData.ts +97 -0
  28. package/src/data/filter-dialect.ts +318 -0
  29. package/src/data/query_builder.ts +10 -10
  30. package/src/data/resolveDataSource.ts +79 -0
  31. package/src/index.ts +4 -1
  32. package/src/table-classification.ts +109 -0
  33. package/src/util/builders.ts +78 -1
  34. package/src/util/callbacks.ts +8 -1
  35. package/src/util/index.ts +1 -0
  36. package/src/util/permissions.test.ts +5 -3
  37. package/src/util/permissions.ts +85 -158
  38. package/src/util/policy/evaluatePolicy.ts +146 -0
  39. package/src/util/policy/index.ts +3 -0
  40. package/src/util/policy/policyToPostgres.ts +85 -0
  41. package/src/util/policy/securityRuleToConditions.ts +67 -0
  42. package/src/util/policy/sqlToPolicy.ts +88 -0
  43. package/src/util/references.ts +1 -1
  44. package/src/util/relations.ts +8 -9
  45. package/src/util/resolutions.ts +6 -6
  46. package/src/util/storage.ts +34 -1
@@ -0,0 +1,79 @@
1
+ import {
2
+ DataSourceDefinition,
3
+ ResolvedDataSource,
4
+ DEFAULT_DATA_SOURCE_KEY,
5
+ getDataSourceCapabilities
6
+ } from "@rebasepro/types";
7
+
8
+ /**
9
+ * The subset of a collection needed to resolve its data source. Accepting a
10
+ * structural type (rather than the full `EntityCollection`) keeps this usable
11
+ * from anywhere — frontend router, backend registry, editor — without coupling
12
+ * to the collection union.
13
+ */
14
+ export interface DataSourceResolvable {
15
+ /** Preferred routing key. */
16
+ dataSource?: string;
17
+ /** Engine type discriminant (set on variant collection types). */
18
+ engine?: string;
19
+ /** Within-engine instance. */
20
+ databaseId?: string;
21
+ }
22
+
23
+ /** A lookup of data-source definitions by key. */
24
+ export type DataSourceRegistry = Record<string, DataSourceDefinition>;
25
+
26
+ /**
27
+ * Build a keyed registry from a list of {@link DataSourceDefinition}s.
28
+ * Later entries win on key collision.
29
+ */
30
+ export function createDataSourceRegistry(definitions?: DataSourceDefinition[]): DataSourceRegistry {
31
+ const registry: DataSourceRegistry = {};
32
+ for (const def of definitions ?? []) {
33
+ registry[def.key] = def;
34
+ }
35
+ return registry;
36
+ }
37
+
38
+ /**
39
+ * Resolve the effective data source for a collection — the single source of
40
+ * truth shared by the frontend router, the backend driver registry, and the
41
+ * editor's capability lookups.
42
+ *
43
+ * Resolution order:
44
+ * 1. The routing **key** is `collection.dataSource`, else
45
+ * {@link DEFAULT_DATA_SOURCE_KEY}.
46
+ * 2. If a definition is registered for that key, it provides `engine`,
47
+ * `transport`, and `databaseId`.
48
+ * 3. Otherwise values are synthesized: `engine` from `collection.engine`
49
+ * (or the key, or `"postgres"`), `transport` defaults to `"server"`,
50
+ * and `databaseId` from the collection.
51
+ *
52
+ * `capabilities` are always derived from the resolved `engine`, so two
53
+ * data sources sharing an engine share capabilities.
54
+ *
55
+ * @param collection the collection (or any object carrying the routing fields)
56
+ * @param registry optional registry of declared data sources
57
+ */
58
+ export function resolveDataSource(
59
+ collection: DataSourceResolvable | undefined,
60
+ registry?: DataSourceRegistry
61
+ ): ResolvedDataSource {
62
+ const key = collection?.dataSource ?? DEFAULT_DATA_SOURCE_KEY;
63
+ const def = registry?.[key];
64
+
65
+ const engine = def?.engine
66
+ ?? collection?.engine
67
+ ?? (key !== DEFAULT_DATA_SOURCE_KEY ? key : "postgres");
68
+
69
+ const transport = def?.transport ?? "server";
70
+ const databaseId = collection?.databaseId ?? def?.databaseId;
71
+
72
+ return {
73
+ key,
74
+ engine,
75
+ transport,
76
+ databaseId,
77
+ capabilities: getDataSourceCapabilities(engine)
78
+ };
79
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export * from "./util";
2
2
  export * from "./collections";
3
3
  export * from "./data/buildRebaseData";
4
+ export * from "./data/buildRoutedRebaseData";
5
+ export * from "./data/resolveDataSource";
4
6
  export * from "./data/query_builder";
5
-
7
+ export * from "./data/filter-dialect";
8
+ export * from "./table-classification";
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Table Classification
3
+ *
4
+ * Shared constants and pure functions for classifying database tables.
5
+ * Used by both the server-side PostgresBackendDriver and the Studio RLS editor.
6
+ */
7
+
8
+ /** Possible categories a database table can belong to. */
9
+ export type TableCategory = "rebase-internal" | "junction" | "user";
10
+
11
+ /** Schemas that are always considered Rebase-internal. */
12
+ export const REBASE_INTERNAL_SCHEMAS: readonly string[] = ["rebase", "auth"];
13
+
14
+ /** Table-name prefixes that mark a table as Rebase-internal regardless of schema. */
15
+ export const REBASE_INTERNAL_PREFIXES: readonly string[] = [
16
+ "_rebase_",
17
+ "_auth_",
18
+ "drizzle_",
19
+ ];
20
+
21
+ /**
22
+ * Synchronously classify a table based on naming conventions.
23
+ *
24
+ * @param tableName - The unqualified name of the table.
25
+ * @param schemaName - The schema the table belongs to (e.g. `"public"`, `"rebase"`).
26
+ * @returns `"rebase-internal"` when the table belongs to a reserved schema or
27
+ * carries a reserved prefix; `"user"` otherwise.
28
+ *
29
+ * @remarks
30
+ * Junction-table detection requires an async database query and is therefore
31
+ * **not** handled by this function. Use {@link detectJunctionTables} to obtain
32
+ * the set of junction tables, then reclassify as needed.
33
+ */
34
+ export function classifyTable(
35
+ tableName: string,
36
+ schemaName: string,
37
+ ): TableCategory {
38
+ if (
39
+ REBASE_INTERNAL_SCHEMAS.includes(schemaName) ||
40
+ REBASE_INTERNAL_PREFIXES.some((prefix) => tableName.startsWith(prefix))
41
+ ) {
42
+ return "rebase-internal";
43
+ }
44
+
45
+ return "user";
46
+ }
47
+
48
+ /**
49
+ * Convenience predicate that checks whether a table is Rebase-internal.
50
+ *
51
+ * @param tableName - The unqualified name of the table.
52
+ * @param schemaName - The schema the table belongs to.
53
+ * @returns `true` if the table is classified as `"rebase-internal"`.
54
+ */
55
+ export function isRebaseInternalTable(
56
+ tableName: string,
57
+ schemaName: string,
58
+ ): boolean {
59
+ return classifyTable(tableName, schemaName) === "rebase-internal";
60
+ }
61
+
62
+ /** SQL query that detects junction tables in the `public` schema. */
63
+ export const JUNCTION_TABLES_SQL = `
64
+ SELECT t.table_name
65
+ FROM information_schema.tables t
66
+ WHERE t.table_schema = 'public'
67
+ AND t.table_type = 'BASE TABLE'
68
+ AND NOT EXISTS (
69
+ SELECT 1
70
+ FROM information_schema.columns c
71
+ WHERE c.table_schema = t.table_schema
72
+ AND c.table_name = t.table_name
73
+ AND c.column_name NOT IN (
74
+ SELECT kcu.column_name
75
+ FROM information_schema.key_column_usage kcu
76
+ JOIN information_schema.table_constraints tc
77
+ ON tc.constraint_name = kcu.constraint_name
78
+ AND tc.table_schema = kcu.table_schema
79
+ WHERE tc.constraint_type = 'FOREIGN KEY'
80
+ AND kcu.table_schema = t.table_schema
81
+ AND kcu.table_name = t.table_name
82
+ )
83
+ )
84
+ `;
85
+
86
+ /**
87
+ * Asynchronously detect junction (link) tables in the `public` schema.
88
+ *
89
+ * A junction table is defined as a table where **every** column participates in
90
+ * at least one foreign-key constraint.
91
+ *
92
+ * @param executeSql - A callback that executes a raw SQL string and returns the
93
+ * resulting rows.
94
+ * @returns A `Set` containing the names of all detected junction tables.
95
+ */
96
+ export async function detectJunctionTables(
97
+ executeSql: (sql: string) => Promise<Record<string, unknown>[]>,
98
+ ): Promise<Set<string>> {
99
+ const rows = await executeSql(JUNCTION_TABLES_SQL);
100
+ const junctionTables = new Set<string>();
101
+
102
+ for (const row of rows) {
103
+ if (typeof row.table_name === "string") {
104
+ junctionTables.add(row.table_name);
105
+ }
106
+ }
107
+
108
+ return junctionTables;
109
+ }
@@ -7,9 +7,17 @@ import {
7
7
  EntityCollection,
8
8
  EnumValueConfig,
9
9
  EnumValues,
10
+ FirebaseCollection,
11
+ FirebaseProperties,
10
12
  GeopointProperty,
13
+ InferEntityType,
11
14
  MapProperty,
12
- NumberProperty, Properties,
15
+ MongoDBCollection,
16
+ MongoProperties,
17
+ NumberProperty,
18
+ PostgresCollection,
19
+ PostgresProperties,
20
+ Properties,
13
21
  Property,
14
22
  ReferenceProperty,
15
23
  StringProperty,
@@ -32,6 +40,75 @@ export function buildCollection<
32
40
  return collection;
33
41
  }
34
42
 
43
+ // ── defineCollection ─────────────────────────────────────────────────────
44
+ // A smarter builder that uses `const` type-parameter inference (TS 5.0+)
45
+ // to capture literal property types automatically. This gives you
46
+ // autocomplete on `titleProperty`, `sort`, `propertiesOrder`, `fixedFilter`,
47
+ // callbacks, etc. — without writing `as const` or passing manual generics.
48
+
49
+ /**
50
+ * Define a PostgreSQL-backed collection with full type inference.
51
+ *
52
+ * The `const P` generic captures literal property types from your
53
+ * `properties` object, which enables autocomplete on `titleProperty`,
54
+ * `sort`, `propertiesOrder`, `fixedFilter`, and entity callbacks.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * const products = defineCollection({
59
+ * name: "Products",
60
+ * slug: "products",
61
+ * table: "products",
62
+ * properties: {
63
+ * name: { name: "Name", type: "string", validation: { required: true } },
64
+ * price: { name: "Price", type: "number" },
65
+ * },
66
+ * titleProperty: "name", // ✅ autocomplete: "name" | "price"
67
+ * sort: ["price", "asc"], // ✅ autocomplete on first element
68
+ * });
69
+ * ```
70
+ *
71
+ * @group Builder
72
+ */
73
+ export function defineCollection<
74
+ const P extends PostgresProperties,
75
+ USER extends User = User
76
+ >(
77
+ collection: Omit<PostgresCollection<InferEntityType<P>, USER>, "properties"> & { properties: P }
78
+ ): PostgresCollection<InferEntityType<P>, USER> & { properties: P };
79
+
80
+ /**
81
+ * Define a Firestore-backed collection with full type inference.
82
+ * @group Builder
83
+ */
84
+ export function defineCollection<
85
+ const P extends FirebaseProperties,
86
+ USER extends User = User
87
+ >(
88
+ collection: Omit<FirebaseCollection<InferEntityType<P>, USER>, "properties"> & { properties: P }
89
+ ): FirebaseCollection<InferEntityType<P>, USER> & { properties: P };
90
+
91
+ /**
92
+ * Define a MongoDB-backed collection with full type inference.
93
+ * @group Builder
94
+ */
95
+ export function defineCollection<
96
+ const P extends MongoProperties,
97
+ USER extends User = User
98
+ >(
99
+ collection: Omit<MongoDBCollection<InferEntityType<P>, USER>, "properties"> & { properties: P }
100
+ ): MongoDBCollection<InferEntityType<P>, USER> & { properties: P };
101
+
102
+ /**
103
+ * Implementation — delegates to the correct overload at the type level.
104
+ * At runtime this is a plain identity function.
105
+ */
106
+ export function defineCollection(
107
+ collection: EntityCollection
108
+ ): EntityCollection {
109
+ return collection;
110
+ }
111
+
35
112
  /**
36
113
  * Identity function we use to defeat the type system of Typescript and preserve
37
114
  * the property keys.
@@ -1,4 +1,11 @@
1
- import { EntityCallbacks, Properties } from "@rebasepro/types";
1
+ import { EntityCallbacks, Properties, RebaseCallContext } from "@rebasepro/types";
2
+
3
+ /**
4
+ * Context passed to entity lifecycle callbacks.
5
+ * @group Models
6
+ */
7
+ export type EntityCallbackContext = RebaseCallContext;
8
+
2
9
 
3
10
  /**
4
11
  * Helper function to recursively check if there are any callbacks in the properties.
package/src/util/index.ts CHANGED
@@ -4,6 +4,7 @@ export * from "./entities";
4
4
  export * from "./enums";
5
5
  export * from "./paths";
6
6
  export * from "./resolutions";
7
+ export * from "./policy";
7
8
  export * from "./permissions";
8
9
  export * from "./references";
9
10
  export * from "./navigation_from_path";
@@ -162,9 +162,10 @@ roles: ["author"] }
162
162
  expect(canReadCollection(collection, mockAuthController)).toBe(true);
163
163
  });
164
164
 
165
- test("11. Empty roles array [] on rule grants access to everyone (public)", () => {
165
+ test("11. Empty roles array [] adds no role restriction (public rule stays public)", () => {
166
166
  const collection = createMockCollection([
167
167
  { operation: "insert",
168
+ access: "public",
168
169
  roles: [] }
169
170
  ]);
170
171
  expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
@@ -172,9 +173,10 @@ roles: [] }
172
173
  expect(canCreateEntity(collection, adminAuthController, "test", null)).toBe(true);
173
174
  });
174
175
 
175
- test("12. Undefined roles on rule grants access to everyone (public)", () => {
176
+ test("12. Undefined roles on a public rule grants access to everyone", () => {
176
177
  const collection = createMockCollection([
177
- { operation: "insert" }
178
+ { operation: "insert",
179
+ access: "public" }
178
180
  ]);
179
181
  expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
180
182
  expect(canCreateEntity(collection, unauthenticatedController, "test", null)).toBe(true);
@@ -1,4 +1,6 @@
1
- import { CollectionWithRelations, Entity, EntityCollection, getDataSourceCapabilities, SecurityRule, User } from "@rebasepro/types";
1
+ import { Entity, EntityCollection, getDataSourceCapabilities, SecurityOperation, SecurityRule, User } from "@rebasepro/types";
2
+ import { securityRuleToConditions } from "./policy/securityRuleToConditions";
3
+ import { evaluatePolicy, PolicyEvalContext, TriState } from "./policy/evaluatePolicy";
2
4
 
3
5
  /**
4
6
  * Minimal auth context for permission checking.
@@ -9,195 +11,120 @@ export interface AuthContext<USER extends User = User> {
9
11
  user: USER | null;
10
12
  }
11
13
 
12
- function evaluateAST<USER extends User, M extends Record<string, unknown>>(sqlString: string, auth: AuthContext<USER>, entity: Entity<M> | null): boolean {
13
- // This is a client-side SQL evaluator used *only* for optimistic UI updates.
14
- // It parses basic AND / OR statements to evaluate RLS without backend roundtrips.
15
- if (!entity) return true;
16
-
17
- // 1. Clean outer parentheses
18
- let cleanedSQL = sqlString.trim();
19
- while (cleanedSQL.startsWith("(") && cleanedSQL.endsWith(")")) {
20
- let openCount = 0;
21
- let isEnclosing = true;
22
- for (let i = 0; i < cleanedSQL.length - 1; i++) {
23
- if (cleanedSQL[i] === "(") openCount++;
24
- else if (cleanedSQL[i] === ")") openCount--;
25
- if (openCount === 0) {
26
- isEnclosing = false;
27
- break;
28
- }
29
- }
30
- if (isEnclosing) {
31
- cleanedSQL = cleanedSQL.substring(1, cleanedSQL.length - 1).trim();
32
- } else {
33
- break;
34
- }
35
- }
36
-
37
- // 2. Split top-level OR / AND
38
- const splitByTopLevel = (str: string, delimiter: string) => {
39
- const parts: string[] = [];
40
- let current = "";
41
- let openCount = 0;
42
- let i = 0;
43
- while (i < str.length) {
44
- if (str[i] === "(") openCount++;
45
- else if (str[i] === ")") openCount--;
46
-
47
- if (openCount === 0 && str.substring(i).toUpperCase().startsWith(delimiter)) {
48
- parts.push(current);
49
- current = "";
50
- i += delimiter.length;
51
- } else {
52
- current += str[i];
53
- i++;
54
- }
55
- }
56
- parts.push(current);
57
- return parts;
58
- };
59
-
60
- const orParts = splitByTopLevel(cleanedSQL, " OR ");
61
- if (orParts.length > 1) {
62
- return orParts.some(part => evaluateAST(part, auth, entity));
63
- }
64
-
65
- const andParts = splitByTopLevel(cleanedSQL, " AND ");
66
- if (andParts.length > 1) {
67
- return andParts.every(part => evaluateAST(part, auth, entity));
68
- }
69
-
70
- const upperSQL = cleanedSQL.toUpperCase();
71
-
72
- // 3. Fallback for unparseable complex queries
73
- if (upperSQL.includes(" IN ") || upperSQL.includes(" EXISTS ")) {
74
- return true;
75
- }
76
-
77
- // 4. Role array checks
78
- // Pattern: `string_to_array(auth.roles(), ',') && ARRAY['admin', 'editor']`
79
- const roleIntersectMatch = cleanedSQL.match(/string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\[(.*?)\]/i);
80
- if (roleIntersectMatch && roleIntersectMatch[1]) {
81
- const requiredRoles = roleIntersectMatch[1].split(",").map(r => r.trim().replace(/'/g, ""));
82
- const userRoles = auth.user?.roles || [];
83
- return requiredRoles.some(r => userRoles.includes(r));
84
- }
85
-
86
- // Pattern: `string_to_array(auth.roles(), ',') @> ARRAY['admin']`
87
- const roleContainMatch = cleanedSQL.match(/string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\[(.*?)\]/i);
88
- if (roleContainMatch && roleContainMatch[1]) {
89
- const requiredRoles = roleContainMatch[1].split(",").map(r => r.trim().replace(/'/g, ""));
90
- const userRoles = auth.user?.roles || [];
91
- return requiredRoles.every(r => userRoles.includes(r));
92
- }
93
-
94
- // 5. Existing ID patterns
95
- const pattern1 = new RegExp("^\\{?([a-zA-Z0-9_]+)\\}?\\s*=\\s*(?:current_setting\\s*\\(\\s*'app\\.user_id'\\s*\\)|auth\\.uid\\(\\))");
96
- const pattern2 = new RegExp("^(?:current_setting\\s*\\(\\s*'app\\.user_id'\\s*\\)|auth\\.uid\\(\\))\\s*=\\s*\\{?([a-zA-Z0-9_]+)\\}?");
97
-
98
- const match1 = cleanedSQL.match(pattern1);
99
- if (match1 && match1[1]) {
100
- return entity.values[match1[1]] === auth.user?.uid;
101
- }
14
+ /**
15
+ * How to resolve a policy result that cannot be decided client-side (a raw-SQL
16
+ * escape-hatch rule, or a row-column reference with no row in hand).
17
+ *
18
+ * - `"allow"` (default): optimistic — used for admin-UI gating, where Postgres
19
+ * remains the authoritative gate and hiding a working action is worse than
20
+ * showing one the server may reject.
21
+ * - `"deny"`: fail-closed — used by real enforcement callers (e.g. a driver
22
+ * applying policies in-process), so an undecidable rule never silently allows.
23
+ */
24
+ export type UnknownResolution = "allow" | "deny";
102
25
 
103
- const match2 = cleanedSQL.match(pattern2);
104
- if (match2 && match2[1]) {
105
- return entity.values[match2[1]] === auth.user?.uid;
106
- }
26
+ export interface CheckOperationOptions {
27
+ onUnknown?: UnknownResolution;
28
+ }
107
29
 
108
- // 6. Simple equality
109
- // Pattern: `field = 'value'` or `{field} != 'value'`
110
- const simpleEqualityMatch = cleanedSQL.match(/^\{?([\w_]+)\}?\s*(=|!=)\s*'([^']+)'$/i);
111
- if (simpleEqualityMatch) {
112
- const field = simpleEqualityMatch[1];
113
- const operator = simpleEqualityMatch[2];
114
- const value = simpleEqualityMatch[3];
115
- const entityValue = entity.values[field];
116
- if (operator === "=") return entityValue === value;
117
- if (operator === "!=") return entityValue !== value;
118
- }
30
+ /** Combine clause results with AND under three-valued (Kleene) logic. */
31
+ function kleeneAnd(values: TriState[]): TriState {
32
+ if (values.some(v => v === false)) return false;
33
+ if (values.some(v => v === "unknown")) return "unknown";
34
+ return true;
35
+ }
119
36
 
120
- return true; // Optimistic fallback for anything else
37
+ /** The operations a rule covers, mirroring the Postgres generator's resolution. */
38
+ function ruleOperations(rule: SecurityRule): SecurityOperation[] {
39
+ return rule.operations && rule.operations.length > 0
40
+ ? rule.operations
41
+ : [rule.operation ?? "all"];
121
42
  }
122
43
 
123
- function evaluateRule<USER extends User, M extends Record<string, unknown>>(rule: SecurityRule, auth: AuthContext<USER>, entity: Entity<M> | null): boolean {
44
+ function ruleApplies(rule: SecurityRule, targetOperation: SecurityOperation): boolean {
45
+ const ops = ruleOperations(rule);
46
+ return ops.includes(targetOperation) || ops.includes("all");
47
+ }
124
48
 
125
- if (rule.access === "public") return true;
49
+ /**
50
+ * Evaluate a single rule for one operation, returning a tri-state.
51
+ *
52
+ * A `null` clause (the rule contributes no condition for a required clause)
53
+ * denies — matching Postgres, which emits `USING (false)` / `WITH CHECK (false)`
54
+ * in that case. USING applies to SELECT/UPDATE/DELETE; WITH CHECK to
55
+ * INSERT/UPDATE; both must pass for UPDATE.
56
+ */
57
+ function evaluateRuleForOperation(rule: SecurityRule, ctx: PolicyEvalContext, targetOperation: SecurityOperation): TriState {
58
+ const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
59
+ const clause = (expr: typeof usingExpr): TriState => expr === null ? false : evaluatePolicy(expr, ctx);
126
60
 
127
- if (rule.ownerField) {
128
- if (!entity) {
129
- // null entity: optimistic — we can't evaluate ownership without data
130
- // Fall through to SQL checks below (if any). If none, will return true.
131
- } else {
132
- // Entity present: strictly check ownership. Fail immediately if mismatch.
133
- if (entity.values[rule.ownerField] !== auth.user?.uid) return false;
134
- }
135
- }
61
+ const needsUsing = targetOperation !== "insert";
62
+ const needsWithCheck = targetOperation === "insert" || targetOperation === "update";
136
63
 
137
- // In PostgreSQL RLS, USING and WITH CHECK have distinct semantics:
138
- // USING applies to existing rows (SELECT/UPDATE/DELETE read phase)
139
- // WITH CHECK applies to new/modified values (INSERT/UPDATE write phase)
140
- // Both must pass. We evaluate both independently.
141
- if (rule.using && !evaluateAST(rule.using, auth, entity)) return false;
142
- if (rule.withCheck && !evaluateAST(rule.withCheck, auth, entity)) return false;
64
+ const results: TriState[] = [];
65
+ if (needsUsing) results.push(clause(usingExpr));
66
+ if (needsWithCheck) results.push(clause(withCheckExpr));
67
+ return kleeneAnd(results);
68
+ }
143
69
 
144
- return true;
70
+ function resolveTriState(value: TriState, onUnknown: UnknownResolution): boolean {
71
+ if (value === "unknown") return onUnknown === "allow";
72
+ return value;
145
73
  }
146
74
 
75
+ /**
76
+ * Decide whether an operation is permitted for a user on a (possibly null) row,
77
+ * by evaluating the collection's security rules with the shared policy model —
78
+ * the same model compiled to Postgres RLS DDL, so the decision matches database
79
+ * enforcement for every non-raw rule.
80
+ *
81
+ * @param options.onUnknown how to treat rules that cannot be decided
82
+ * client-side (raw SQL, or row predicates with no row). Defaults to `"allow"`
83
+ * for optimistic UI gating; enforcement callers should pass `"deny"`.
84
+ */
147
85
  export function checkOperation<M extends Record<string, unknown>, USER extends User>(
148
86
  collection: EntityCollection<M>,
149
87
  authContext: AuthContext<USER>,
150
88
  entity: Entity<M> | null,
151
- targetOperation: "select" | "insert" | "update" | "delete"
89
+ targetOperation: SecurityOperation,
90
+ options?: CheckOperationOptions
152
91
  ): boolean {
153
- const securityRules = getDataSourceCapabilities(collection.driver).supportsRLS ? (collection as CollectionWithRelations).securityRules : undefined;
92
+ const onUnknown = options?.onUnknown ?? "allow";
93
+ const securityRules = getDataSourceCapabilities(collection.engine).supportsRLS ? collection.securityRules : undefined;
154
94
  if (!securityRules || securityRules.length === 0) {
155
95
  return true;
156
96
  }
157
97
 
158
- const applicableRules = securityRules.filter((r: SecurityRule) =>
159
- r.operation === targetOperation ||
160
- r.operation === "all" ||
161
- r.operations?.includes(targetOperation) ||
162
- r.operations?.includes("all")
163
- );
164
-
98
+ const applicableRules = securityRules.filter((r: SecurityRule) => ruleApplies(r, targetOperation));
165
99
  if (applicableRules.length === 0) return false;
166
100
 
167
- const userRoleIds = authContext.user?.roles ?? [];
168
- const userRoles = [...userRoleIds, "public"];
169
- const roleApplicableRules = applicableRules.filter((rule: SecurityRule) => {
170
- if (!rule.roles || rule.roles.length === 0) return true;
171
- return rule.roles.some((r: string) => userRoles.includes(r));
172
- });
173
-
174
- if (roleApplicableRules.length === 0) return false;
101
+ const ctx: PolicyEvalContext = {
102
+ uid: authContext.user?.uid,
103
+ roles: authContext.user?.roles ?? [],
104
+ entity
105
+ };
175
106
 
176
107
  let grantedByPermissive = false;
177
108
  let deniedByRestrictive = false;
109
+ let hasPermissive = false;
178
110
 
179
- for (const rule of roleApplicableRules) {
111
+ for (const rule of applicableRules) {
180
112
  const mode = rule.mode || "permissive";
181
- const passed = evaluateRule(rule, authContext, entity);
113
+ const passed = resolveTriState(evaluateRuleForOperation(rule, ctx, targetOperation), onUnknown);
182
114
 
183
- if (mode === "restrictive" && !passed) {
184
- deniedByRestrictive = true;
185
- break;
186
- }
187
-
188
- if (mode === "permissive" && passed) {
189
- grantedByPermissive = true;
115
+ if (mode === "restrictive") {
116
+ if (!passed) {
117
+ deniedByRestrictive = true;
118
+ break;
119
+ }
120
+ } else {
121
+ hasPermissive = true;
122
+ if (passed) grantedByPermissive = true;
190
123
  }
191
124
  }
192
125
 
193
126
  if (deniedByRestrictive) return false;
194
-
195
- const hasPermissive = roleApplicableRules.some((r: SecurityRule) => (r.mode || "permissive") === "permissive");
196
- if (hasPermissive) {
197
- return grantedByPermissive;
198
- } else {
199
- return false;
200
- }
127
+ return hasPermissive ? grantedByPermissive : false;
201
128
  }
202
129
 
203
130
  export function canReadCollection<M extends Record<string, unknown>, USER extends User>