@rebasepro/common 0.8.0 → 0.9.1-canary.09aaf62

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 (65) hide show
  1. package/README.md +5 -5
  2. package/dist/collections/CollectionRegistry.d.ts +16 -16
  3. package/dist/collections/default-collections.d.ts +5 -1
  4. package/dist/data/buildRebaseData.d.ts +44 -3
  5. package/dist/data/buildRoutedRebaseData.d.ts +14 -9
  6. package/dist/data/filter-dialect.d.ts +18 -4
  7. package/dist/data/query_builder.d.ts +1 -1
  8. package/dist/data/resolveDataSource.d.ts +1 -1
  9. package/dist/data/sort-dialect.d.ts +41 -0
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.es.js +1236 -179
  12. package/dist/index.es.js.map +1 -1
  13. package/dist/util/auth-default-policies.d.ts +22 -0
  14. package/dist/util/builders.d.ts +19 -56
  15. package/dist/util/callbacks.d.ts +3 -3
  16. package/dist/util/collections.d.ts +4 -4
  17. package/dist/util/entities.d.ts +2 -2
  18. package/dist/util/filter-operator-resolution.d.ts +32 -0
  19. package/dist/util/identity.d.ts +83 -0
  20. package/dist/util/index.d.ts +4 -0
  21. package/dist/util/junction-policies.d.ts +108 -0
  22. package/dist/util/navigation_from_path.d.ts +4 -4
  23. package/dist/util/navigation_utils.d.ts +3 -3
  24. package/dist/util/parent_references_from_path.d.ts +2 -2
  25. package/dist/util/permissions.d.ts +6 -6
  26. package/dist/util/policy/evaluatePolicy.d.ts +8 -1
  27. package/dist/util/policy/index.d.ts +1 -0
  28. package/dist/util/policy/policyToPostgres.d.ts +14 -2
  29. package/dist/util/policy/sqlToPolicy.d.ts +24 -14
  30. package/dist/util/references.d.ts +2 -2
  31. package/dist/util/relations.d.ts +5 -5
  32. package/dist/util/resolutions.d.ts +2 -2
  33. package/package.json +7 -8
  34. package/src/collections/CollectionRegistry.ts +36 -36
  35. package/src/collections/default-collections.ts +2 -0
  36. package/src/data/buildRebaseData.ts +430 -60
  37. package/src/data/buildRoutedRebaseData.ts +22 -16
  38. package/src/data/filter-dialect.ts +151 -60
  39. package/src/data/query_builder.ts +11 -2
  40. package/src/data/resolveDataSource.ts +1 -1
  41. package/src/data/sort-dialect.ts +56 -0
  42. package/src/index.ts +1 -0
  43. package/src/util/auth-default-policies.ts +152 -0
  44. package/src/util/builders.ts +25 -99
  45. package/src/util/callbacks.ts +8 -8
  46. package/src/util/collections.ts +4 -4
  47. package/src/util/entities.ts +4 -4
  48. package/src/util/filter-operator-resolution.ts +81 -0
  49. package/src/util/identity.ts +166 -0
  50. package/src/util/index.ts +4 -0
  51. package/src/util/junction-policies.ts +353 -0
  52. package/src/util/navigation_from_path.ts +4 -4
  53. package/src/util/navigation_utils.ts +8 -8
  54. package/src/util/parent_references_from_path.ts +3 -3
  55. package/src/util/permissions.test.ts +2 -2
  56. package/src/util/permissions.ts +7 -7
  57. package/src/util/policy/evaluatePolicy.ts +26 -4
  58. package/src/util/policy/index.ts +1 -0
  59. package/src/util/policy/policyToPostgres.ts +123 -17
  60. package/src/util/policy/sqlToPolicy.ts +190 -13
  61. package/src/util/references.ts +2 -2
  62. package/src/util/relations.ts +12 -12
  63. package/src/util/resolutions.ts +5 -5
  64. package/dist/index.umd.js +0 -2901
  65. package/dist/index.umd.js.map +0 -1
@@ -1,5 +1,38 @@
1
- import { EntityCollection, PolicyExpression, PolicyOperand, PolicyCompareOperator, Property } from "@rebasepro/types";
1
+ import { ANONYMOUS_USER_ID, CollectionConfig, PolicyExpression, PolicyOperand, PolicyCompareOperator, Property, ExistsInPolicyExpression } from "@rebasepro/types";
2
2
  import { toSnakeCase } from "@rebasepro/utils";
3
+ import { getTableName } from "../relations";
4
+
5
+ /**
6
+ * Options for {@link policyToPostgres}.
7
+ */
8
+ export interface PolicyCompileOptions {
9
+ /**
10
+ * Resolve a collection by slug. Required to compile
11
+ * {@link ExistsInPolicyExpression} (`policy.existsIn`) — the compiler needs
12
+ * the joined collection to derive its table name / schema. When omitted, the
13
+ * join table falls back to a snake_cased slug.
14
+ */
15
+ resolveCollection?: (slug: string) => CollectionConfig | undefined;
16
+ }
17
+
18
+ /**
19
+ * The lexical scope threaded through compilation. It changes when we descend
20
+ * into an `existsIn` subquery: inside it, `field` refers to the joined table
21
+ * (aliased) while `outerField` refers to the outer RLS row (table-qualified).
22
+ */
23
+ interface CompileScope {
24
+ /** Collection whose columns a bare `field` operand resolves against. */
25
+ fieldCollection?: CollectionConfig;
26
+ /** SQL prefix for `field` operands (`""` at top level, `"alias".` in a subquery). */
27
+ fieldPrefix: string;
28
+ /** The outer RLS collection, for `outerField` operands. */
29
+ outerCollection?: CollectionConfig;
30
+ /** SQL prefix for `outerField` operands (`""` at top level, `"schema"."table".` in a subquery). */
31
+ outerPrefix: string;
32
+ resolveCollection?: (slug: string) => CollectionConfig | undefined;
33
+ /** Monotonic counter for generating unique subquery aliases. */
34
+ alias: { n: number };
35
+ }
3
36
 
4
37
  /**
5
38
  * Compiles a {@link PolicyExpression} to a PostgreSQL boolean SQL string,
@@ -9,7 +42,18 @@ import { toSnakeCase } from "@rebasepro/utils";
9
42
  * {@link evaluatePolicy}); the Postgres schema generators call it so that DDL
10
43
  * and the admin UI derive from the exact same expression.
11
44
  */
12
- export function policyToPostgres(expr: PolicyExpression, collection?: EntityCollection): string {
45
+ export function policyToPostgres(expr: PolicyExpression, collection?: CollectionConfig, options?: PolicyCompileOptions): string {
46
+ return compile(expr, {
47
+ fieldCollection: collection,
48
+ fieldPrefix: "",
49
+ outerCollection: collection,
50
+ outerPrefix: "",
51
+ resolveCollection: options?.resolveCollection,
52
+ alias: { n: 0 }
53
+ });
54
+ }
55
+
56
+ function compile(expr: PolicyExpression, scope: CompileScope): string {
13
57
  switch (expr.kind) {
14
58
  case "true":
15
59
  return "true";
@@ -18,30 +62,76 @@ export function policyToPostgres(expr: PolicyExpression, collection?: EntityColl
18
62
  case "and":
19
63
  return expr.operands.length === 0
20
64
  ? "true"
21
- : expr.operands.map(o => `(${policyToPostgres(o, collection)})`).join(" AND ");
65
+ : expr.operands.map(o => `(${compile(o, scope)})`).join(" AND ");
22
66
  case "or":
23
67
  return expr.operands.length === 0
24
68
  ? "false"
25
- : expr.operands.map(o => `(${policyToPostgres(o, collection)})`).join(" OR ");
69
+ : expr.operands.map(o => `(${compile(o, scope)})`).join(" OR ");
26
70
  case "not":
27
- // Render the common `auth.uid() IS NULL` (unauthenticated) form directly.
28
- if (expr.operand.kind === "authenticated") return "auth.uid() IS NULL";
29
- return `NOT (${policyToPostgres(expr.operand, collection)})`;
30
- case "compare":
31
- return `${operandToSql(expr.left, collection)} ${COMPARE_SQL[expr.op]} ${operandToSql(expr.right, collection)}`;
71
+ return `NOT (${compile(expr.operand, scope)})`;
72
+ case "compare": {
73
+ // `auth.uid()` returns text; cast the column side so uuid / integer
74
+ // id columns compare cleanly instead of failing with
75
+ // "operator does not exist: uuid = text" at CREATE POLICY time.
76
+ const castForAuthUid = (operand: PolicyOperand, sqlText: string, other: PolicyOperand): string =>
77
+ other.kind === "authUid" && (operand.kind === "field" || operand.kind === "outerField")
78
+ ? `(${sqlText})::text`
79
+ : sqlText;
80
+ const leftSql = castForAuthUid(expr.left, operandToSql(expr.left, scope), expr.right);
81
+ const rightSql = castForAuthUid(expr.right, operandToSql(expr.right, scope), expr.left);
82
+ return `${leftSql} ${COMPARE_SQL[expr.op]} ${rightSql}`;
83
+ }
32
84
  case "rolesOverlap":
33
85
  return `string_to_array(auth.roles(), ',') && ${rolesArraySql(expr.roles)}`;
34
86
  case "rolesContain":
35
87
  return `string_to_array(auth.roles(), ',') @> ${rolesArraySql(expr.roles)}`;
36
88
  case "authenticated":
37
- return "auth.uid() IS NOT NULL";
89
+ // `IS NOT NULL` alone is a tautology on the user path: every
90
+ // user-context request sets `app.user_id`, and an anonymous one sets
91
+ // it to the sentinel. Excluding the sentinel is what makes this mean
92
+ // "signed in" rather than "anyone at all".
93
+ return `auth.uid() IS NOT NULL AND auth.uid() <> ${quoteLiteral(ANONYMOUS_USER_ID)}`;
94
+ case "serverContext":
95
+ // Only the built-in server flows leave `app.user_id` unset.
96
+ return "auth.uid() IS NULL";
97
+ case "existsIn":
98
+ return compileExistsIn(expr, scope);
38
99
  case "raw":
39
- // Full-power escape hatch: `{column}` references resolve to the bare
40
- // column name (matching the previous raw-SQL behavior).
41
- return expr.sql.replace(/\{(\w+)\}/g, (_, col) => col);
100
+ // Full-power escape hatch: `{column}` denotes a column of the outer
101
+ // RLS row. It must be table-qualified, not bare: raw SQL may open its
102
+ // own subquery over the same table, and there a bare name binds to the
103
+ // inner scope, collapsing `m.x = {x}` into the tautology `m.x = m.x`.
104
+ return expr.sql.replace(/\{(\w+)\}/g, (_, col) =>
105
+ `${outerQualifier(scope)}${resolveColumnName(col, scope.outerCollection)}`);
42
106
  }
43
107
  }
44
108
 
109
+ /**
110
+ * Compiles `existsIn` to a correlated `EXISTS (SELECT 1 FROM <join> WHERE ...)`.
111
+ * Inside the subquery, `field` operands bind to the aliased join table and
112
+ * `outerField` operands bind to the (table-qualified) outer RLS row.
113
+ */
114
+ function compileExistsIn(expr: ExistsInPolicyExpression, scope: CompileScope): string {
115
+ const join = scope.resolveCollection?.(expr.collection);
116
+ const joinTable = join ? getTableName(join) : toSnakeCase(expr.collection);
117
+ const joinSchema = schemaOf(join) ?? schemaOf(scope.outerCollection) ?? "public";
118
+ const alias = `_ex${scope.alias.n++}`;
119
+
120
+ // `outerField` inside the subquery must be qualified with the outer table,
121
+ // otherwise a bare column name would bind to the joined table instead.
122
+ const outerPrefix = outerQualifier(scope);
123
+
124
+ const innerScope: CompileScope = {
125
+ fieldCollection: join,
126
+ fieldPrefix: `"${alias}".`,
127
+ outerCollection: scope.outerCollection,
128
+ outerPrefix,
129
+ resolveCollection: scope.resolveCollection,
130
+ alias: scope.alias
131
+ };
132
+ return `EXISTS (SELECT 1 FROM "${joinSchema}"."${joinTable}" "${alias}" WHERE ${compile(expr.where, innerScope)})`;
133
+ }
134
+
45
135
  const COMPARE_SQL: Record<PolicyCompareOperator, string> = {
46
136
  eq: "=",
47
137
  neq: "!=",
@@ -51,10 +141,12 @@ const COMPARE_SQL: Record<PolicyCompareOperator, string> = {
51
141
  gte: ">="
52
142
  };
53
143
 
54
- function operandToSql(operand: PolicyOperand, collection?: EntityCollection): string {
144
+ function operandToSql(operand: PolicyOperand, scope: CompileScope): string {
55
145
  switch (operand.kind) {
56
146
  case "field":
57
- return resolveColumnName(operand.name, collection);
147
+ return `${scope.fieldPrefix}${resolveColumnName(operand.name, scope.fieldCollection)}`;
148
+ case "outerField":
149
+ return `${scope.outerPrefix}${resolveColumnName(operand.name, scope.outerCollection)}`;
58
150
  case "literal":
59
151
  return quoteLiteral(operand.value);
60
152
  case "authUid":
@@ -64,7 +156,21 @@ function operandToSql(operand: PolicyOperand, collection?: EntityCollection): st
64
156
  }
65
157
  }
66
158
 
67
- function resolveColumnName(propName: string, collection?: EntityCollection): string {
159
+ /**
160
+ * SQL prefix that qualifies a column of the outer RLS row (`"schema"."table".`),
161
+ * or `""` when the collection is unknown.
162
+ */
163
+ function outerQualifier(scope: CompileScope): string {
164
+ const table = scope.outerCollection ? getTableName(scope.outerCollection) : undefined;
165
+ if (!table) return "";
166
+ return `"${schemaOf(scope.outerCollection) ?? "public"}"."${table}".`;
167
+ }
168
+
169
+ function schemaOf(collection?: CollectionConfig): string | undefined {
170
+ return (collection as { schema?: string } | undefined)?.schema || undefined;
171
+ }
172
+
173
+ function resolveColumnName(propName: string, collection?: CollectionConfig): string {
68
174
  const prop = collection?.properties?.[propName] as Property | undefined;
69
175
  if (prop && "columnName" in prop && typeof (prop as { columnName?: unknown }).columnName === "string") {
70
176
  return (prop as { columnName: string }).columnName;
@@ -80,6 +186,6 @@ function quoteLiteral(value: string | number | boolean | null): string {
80
186
  }
81
187
 
82
188
  /** Sorted, single-quoted `ARRAY['a','b']` — matches the generators' output. */
83
- function rolesArraySql(roles: string[]): string {
189
+ function rolesArraySql(roles: readonly string[]): string {
84
190
  return `ARRAY[${[...roles].sort().map(r => `'${r}'`).join(",")}]`;
85
191
  }
@@ -1,4 +1,4 @@
1
- import { PolicyExpression, policy } from "@rebasepro/types";
1
+ import { ANONYMOUS_USER_ID, LiteralPolicyOperand, PolicyExpression, policy } from "@rebasepro/types";
2
2
 
3
3
  /**
4
4
  * A tiny, regex-based SQL "parser" for security rules.
@@ -11,15 +11,103 @@ import { PolicyExpression, policy } from "@rebasepro/types";
11
11
  * - `field = 'literal'`
12
12
  * - `field != 'literal'`
13
13
  * - `field = current_setting('app.user_id')`
14
- * - `A AND B`
14
+ * - `A AND B`, `A OR B` — only where the keyword is at the top level
15
15
  * - `true`
16
16
  * - `IN (...)` (as optimistic true)
17
17
  *
18
18
  * For anything it doesn't understand, it returns a `raw` expression, which
19
19
  * the evaluator treats as "unknown" (and usually optimistic true).
20
+ *
21
+ * **This output also round-trips back into DDL** via `policyToPostgres` (the
22
+ * schema/policy generators), so decomposing a clause the parser only partly
23
+ * understands is not a cosmetic mistake — it emits invalid SQL. When in doubt,
24
+ * prefer `raw`: it is reproduced verbatim.
20
25
  */
26
+ /** True when `keyword` starts at `i` as a standalone word. */
27
+ function isKeywordAt(upper: string, i: number, keyword: string): boolean {
28
+ if (!upper.startsWith(keyword, i)) return false;
29
+ const before = i === 0 ? " " : upper[i - 1];
30
+ const after = upper[i + keyword.length] ?? " ";
31
+ return /[\s()]/.test(before) && /[\s()]/.test(after);
32
+ }
33
+
34
+ /**
35
+ * Split `sql` on a boolean keyword, but only where it sits at paren depth 0 and
36
+ * outside a string literal. Returns null when it never does, so the caller
37
+ * leaves the clause alone.
38
+ *
39
+ * This used to be `sql.split(/ AND /i)`, which tore subqueries in half: the
40
+ * `AND` inside
41
+ * `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id = auth.uid())`
42
+ * split the expression, and re-emitting the halves produced
43
+ * `(EXISTS (...) AND m.user_id = auth.uid())`
44
+ * where `m` is no longer in scope — SQL that Postgres rejects outright with
45
+ * "missing FROM-clause entry for table". Returning null instead keeps such a
46
+ * clause as a `raw` expression, which round-trips verbatim.
47
+ */
48
+ function splitTopLevel(sql: string, keyword: "AND" | "OR"): string[] | null {
49
+ const upper = sql.toUpperCase();
50
+ const parts: string[] = [];
51
+ let depth = 0;
52
+ let inString = false;
53
+ let start = 0;
54
+
55
+ for (let i = 0; i < sql.length; i++) {
56
+ const ch = sql[i];
57
+ if (inString) {
58
+ if (ch === "'") {
59
+ if (sql[i + 1] === "'") i++; // '' escapes a quote inside a literal
60
+ else inString = false;
61
+ }
62
+ continue;
63
+ }
64
+ if (ch === "'") { inString = true; continue; }
65
+ if (ch === "(") { depth++; continue; }
66
+ if (ch === ")") { depth--; continue; }
67
+ if (depth === 0 && isKeywordAt(upper, i, keyword)) {
68
+ parts.push(sql.slice(start, i));
69
+ i += keyword.length - 1;
70
+ start = i + 1;
71
+ }
72
+ }
73
+
74
+ if (parts.length === 0) return null;
75
+ parts.push(sql.slice(start));
76
+ const trimmedParts = parts.map(p => p.trim()).filter(p => p.length > 0);
77
+ return trimmedParts.length > 1 ? trimmedParts : null;
78
+ }
79
+
80
+ /** Drop redundant wrapping parens (`(a AND b)` → `a AND b`), never `(a) AND (b)`. */
81
+ function stripOuterParens(sql: string): string {
82
+ let s = sql.trim();
83
+ for (;;) {
84
+ if (!s.startsWith("(") || !s.endsWith(")")) return s;
85
+ let depth = 0;
86
+ let inString = false;
87
+ let wraps = true;
88
+ for (let i = 0; i < s.length; i++) {
89
+ const ch = s[i];
90
+ if (inString) {
91
+ if (ch === "'") {
92
+ if (s[i + 1] === "'") i++;
93
+ else inString = false;
94
+ }
95
+ continue;
96
+ }
97
+ if (ch === "'") { inString = true; continue; }
98
+ if (ch === "(") depth++;
99
+ else if (ch === ")") {
100
+ depth--;
101
+ if (depth === 0 && i < s.length - 1) { wraps = false; break; }
102
+ }
103
+ }
104
+ if (!wraps) return s;
105
+ s = s.slice(1, -1).trim();
106
+ }
107
+ }
108
+
21
109
  export function sqlToPolicy(sql: string): PolicyExpression {
22
- const trimmed = sql.trim();
110
+ const trimmed = stripOuterParens(sql.trim());
23
111
 
24
112
  if (trimmed.toLowerCase() === "true") return policy.true();
25
113
  if (trimmed.toLowerCase() === "false") return policy.false();
@@ -40,17 +128,12 @@ export function sqlToPolicy(sql: string): PolicyExpression {
40
128
  return policy.rolesContain(roles);
41
129
  }
42
130
 
43
- // Handle OR
44
- if (trimmed.toUpperCase().includes(" OR ")) {
45
- const parts = trimmed.split(/ OR /i);
46
- return policy.or(...parts.map(sqlToPolicy));
47
- }
131
+ // OR binds looser than AND, so it splits first.
132
+ const orParts = splitTopLevel(trimmed, "OR");
133
+ if (orParts) return policy.or(...orParts.map(sqlToPolicy));
48
134
 
49
- // Handle AND (very basic split, doesn't handle nested parens properly)
50
- if (trimmed.toUpperCase().includes(" AND ")) {
51
- const parts = trimmed.split(/ AND /i);
52
- return policy.and(...parts.map(sqlToPolicy));
53
- }
135
+ const andParts = splitTopLevel(trimmed, "AND");
136
+ if (andParts) return policy.and(...andParts.map(sqlToPolicy));
54
137
 
55
138
  // Handle = and !=
56
139
  const match = trimmed.match(/^(.+?)\s*(!?=)\s*(.+)$/);
@@ -67,6 +150,100 @@ export function sqlToPolicy(sql: string): PolicyExpression {
67
150
  return policy.raw(sql);
68
151
  }
69
152
 
153
+ /**
154
+ * Literals from other BaaS platforms that people compare `auth.uid()` against
155
+ * out of habit. Mirrors the driver's `FOREIGN_CONVENTION_ROLES` guard on
156
+ * `pgRoles`, one surface over: the same muscle memory inside a `using:` string
157
+ * is the more dangerous spelling, because it inverts a rule instead of
158
+ * emptying a table.
159
+ */
160
+ const FOREIGN_CONVENTION_UIDS: Record<string, string> = {
161
+ anon: "Supabase",
162
+ authenticated: "Supabase",
163
+ service_role: "Supabase"
164
+ };
165
+
166
+ /** A clause that reads as a lockdown but admits anonymous callers. */
167
+ export interface AnonymousGrantRisk {
168
+ /** Which spelling was found. */
169
+ pattern: "foreign-uid-literal" | "uid-not-null";
170
+ /** The offending fragment — the literal, or the SQL that is a tautology. */
171
+ detail: string;
172
+ /** Why it admits anonymous callers, and what to write instead. */
173
+ explanation: string;
174
+ }
175
+
176
+ /** `auth.uid() IS NOT NULL` in raw SQL, the clause that is always true. */
177
+ const UID_NOT_NULL = /auth\.uid\(\)\s+IS\s+NOT\s+NULL/i;
178
+
179
+ /**
180
+ * Find clauses that read as "signed-in users only" but admit anonymous callers.
181
+ *
182
+ * Both spellings come from the same place — Supabase, where `auth.uid()` really
183
+ * is NULL for an anonymous request. Rebase substitutes
184
+ * {@link ANONYMOUS_USER_ID} instead (a blank id would read back as NULL, which
185
+ * is how the trusted *server* context is recognised), so:
186
+ *
187
+ * - `auth.uid() IS NOT NULL` is a tautology on the user path, and
188
+ * - `auth.uid() != 'anon'` compares against a string no caller ever has.
189
+ *
190
+ * Either one turns a lockdown into a full grant, and neither looks wrong. No
191
+ * real user id is ever one of these literals, and a user-context request is
192
+ * never NULL, so a match is always a mistake rather than a deliberate check.
193
+ *
194
+ * Structured expressions are checked too, not just parsed SQL: `policy.compare`
195
+ * can spell the same mistake.
196
+ */
197
+ export function findAnonymousGrants(expr: PolicyExpression): AnonymousGrantRisk[] {
198
+ const found: AnonymousGrantRisk[] = [];
199
+
200
+ const visit = (e: PolicyExpression): void => {
201
+ switch (e.kind) {
202
+ case "and":
203
+ case "or":
204
+ e.operands.forEach(visit);
205
+ return;
206
+ case "not":
207
+ visit(e.operand);
208
+ return;
209
+ case "existsIn":
210
+ visit(e.where);
211
+ return;
212
+ case "raw":
213
+ if (UID_NOT_NULL.test(e.sql)) {
214
+ found.push({
215
+ pattern: "uid-not-null",
216
+ detail: e.sql,
217
+ explanation: "`auth.uid() IS NOT NULL` is true for every request that came from a client, " +
218
+ `including anonymous ones — they carry '${ANONYMOUS_USER_ID}', not NULL. ` +
219
+ "Use `condition: policy.authenticated()` to mean \"signed in\"."
220
+ });
221
+ }
222
+ return;
223
+ case "compare": {
224
+ const literal = [e.left, e.right].find(o => o.kind === "literal") as LiteralPolicyOperand | undefined;
225
+ const comparesUid = e.left.kind === "authUid" || e.right.kind === "authUid";
226
+ if (!comparesUid || typeof literal?.value !== "string") return;
227
+ const platform = FOREIGN_CONVENTION_UIDS[literal.value];
228
+ if (!platform) return;
229
+ found.push({
230
+ pattern: "foreign-uid-literal",
231
+ detail: literal.value,
232
+ explanation: `'${literal.value}' is a ${platform} convention. Rebase reports an anonymous ` +
233
+ `request as '${ANONYMOUS_USER_ID}', so comparing against '${literal.value}' passes for ` +
234
+ "every caller. Use `condition: policy.authenticated()` to mean \"signed in\"."
235
+ });
236
+ return;
237
+ }
238
+ default:
239
+ return;
240
+ }
241
+ };
242
+
243
+ visit(expr);
244
+ return found;
245
+ }
246
+
70
247
  function parseOperand(str: string) {
71
248
  // current_setting('app.user_id') or auth.uid()
72
249
  if (/current_setting\s*\(\s*'app\.user_id'\s*\)/i.test(str) || /auth\.uid\(\)/i.test(str)) {
@@ -1,6 +1,6 @@
1
- import { EntityCollection } from "@rebasepro/types";
1
+ import { CollectionConfig } from "@rebasepro/types";
2
2
 
3
- export function getEntityImagePreviewPropertyKey<M extends Record<string, unknown>>(collection: EntityCollection<M>): string | undefined {
3
+ export function getEntityImagePreviewPropertyKey<M extends Record<string, unknown>>(collection: CollectionConfig<M>): string | undefined {
4
4
 
5
5
  // find first storage property of type image
6
6
  for (const key in collection.properties) {
@@ -1,18 +1,18 @@
1
- import { EntityCollection, getDataSourceCapabilities, Property, Relation, RelationProperty } from "@rebasepro/types";
1
+ import { CollectionConfig, getDataSourceCapabilities, Property, Relation, RelationProperty } from "@rebasepro/types";
2
2
  import { toSnakeCase } from "@rebasepro/utils";
3
3
  import { generateForeignKeyName } from "@rebasepro/utils";
4
4
 
5
5
  export function sanitizeRelation(
6
6
  relation: Partial<Relation>,
7
- sourceCollection: EntityCollection,
8
- resolveCollection?: (slugOrTable: string) => EntityCollection | undefined
7
+ sourceCollection: CollectionConfig,
8
+ resolveCollection?: (slugOrTable: string) => CollectionConfig | undefined
9
9
  ): Relation {
10
10
  if (!relation.target) {
11
11
  throw new Error("Relation is missing a `target` collection.");
12
12
  }
13
13
 
14
14
  const rawTarget = relation.target;
15
- let targetCollection: EntityCollection | undefined;
15
+ let targetCollection: CollectionConfig | undefined;
16
16
 
17
17
  if (typeof rawTarget === "string") {
18
18
  if (resolveCollection) {
@@ -20,7 +20,7 @@ export function sanitizeRelation(
20
20
  }
21
21
  if (!targetCollection) {
22
22
  targetCollection = { slug: rawTarget,
23
- name: rawTarget } as EntityCollection;
23
+ name: rawTarget } as CollectionConfig;
24
24
  }
25
25
  } else if (typeof rawTarget === "function") {
26
26
  const evaluated = rawTarget();
@@ -30,13 +30,13 @@ name: rawTarget } as EntityCollection;
30
30
  }
31
31
  if (!targetCollection) {
32
32
  targetCollection = { slug: evaluated,
33
- name: evaluated } as EntityCollection;
33
+ name: evaluated } as CollectionConfig;
34
34
  }
35
35
  } else {
36
36
  targetCollection = evaluated;
37
37
  }
38
38
  } else if (rawTarget && typeof rawTarget === "object") {
39
- targetCollection = rawTarget as EntityCollection;
39
+ targetCollection = rawTarget as CollectionConfig;
40
40
  }
41
41
 
42
42
  if (!targetCollection) {
@@ -198,10 +198,10 @@ name: evaluated } as EntityCollection;
198
198
  }
199
199
 
200
200
  /** WeakMap cache — same collection instance always yields the same relation map. */
201
- const _resolvedRelationsCache = new WeakMap<EntityCollection, Record<string, Relation>>();
201
+ const _resolvedRelationsCache = new WeakMap<CollectionConfig, Record<string, Relation>>();
202
202
 
203
203
  export function resolveCollectionRelations(
204
- collection: EntityCollection
204
+ collection: CollectionConfig
205
205
  ): Record<string, Relation> {
206
206
  const cached = _resolvedRelationsCache.get(collection);
207
207
  if (cached) return cached;
@@ -251,7 +251,7 @@ export function resolveCollectionRelations(
251
251
 
252
252
  // We previously skipped if the underlying relation was already registered under
253
253
  // its canonical relationName in section 1. But we need to keep the property mapping
254
- // for EntityFetchService to hydrate the relation back to the correct property key.
254
+ // for FetchService to hydrate the relation back to the correct property key.
255
255
  // Deduplication for Drizzle schema generation is handled in generate-drizzle-schema-logic.ts.
256
256
 
257
257
  if (!relation.relationName) {
@@ -275,7 +275,7 @@ export function resolvePropertyRelation({
275
275
  }: {
276
276
  propertyKey: string;
277
277
  property: Property;
278
- sourceCollection: EntityCollection;
278
+ sourceCollection: CollectionConfig;
279
279
  }): Relation | undefined {
280
280
  if (property.type !== "relation") return undefined;
281
281
 
@@ -304,7 +304,7 @@ export function resolvePropertyRelation({
304
304
  return undefined;
305
305
  }
306
306
 
307
- export function getTableName(collection: EntityCollection): string {
307
+ export function getTableName(collection: CollectionConfig): string {
308
308
  if (getDataSourceCapabilities(collection.engine).supportsRelations) {
309
309
  return collection.table ?? toSnakeCase(collection.slug) ?? toSnakeCase(collection.name);
310
310
  }
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ArrayProperty,
3
3
  AuthController,
4
- EntityCollection,
4
+ CollectionConfig,
5
5
  EnumValueConfig,
6
6
  EnumValues,
7
7
  NumberProperty,
@@ -341,7 +341,7 @@ export function resolveEnumValues(input: EnumValues): EnumValueConfig[] | undefi
341
341
  }
342
342
 
343
343
 
344
- export function getSubcollections<M extends Record<string, unknown> = Record<string, unknown>>(collection: EntityCollection<M>): EntityCollection<Record<string, unknown>>[] {
344
+ export function getSubcollections<M extends Record<string, unknown> = Record<string, unknown>>(collection: CollectionConfig<M>): CollectionConfig<Record<string, unknown>>[] {
345
345
  if (collection.childCollections) {
346
346
  return collection.childCollections() ?? [];
347
347
  }
@@ -371,7 +371,7 @@ export function getSubcollections<M extends Record<string, unknown> = Record<str
371
371
  }
372
372
  }
373
373
 
374
- const baseOverrides: Partial<EntityCollection> = { slug: relationKey };
374
+ const baseOverrides: Partial<CollectionConfig> = { slug: relationKey };
375
375
  if (customName) {
376
376
  baseOverrides.name = customName;
377
377
  baseOverrides.singularName = customName;
@@ -379,8 +379,8 @@ export function getSubcollections<M extends Record<string, unknown> = Record<str
379
379
 
380
380
  const targetWithOverrides = { ...target,
381
381
  ...baseOverrides };
382
- return (r.overrides ? mergeDeep(targetWithOverrides, r.overrides) : targetWithOverrides) as EntityCollection<Record<string, unknown>>;
383
- }).filter((c: EntityCollection<Record<string, unknown>> | undefined): c is EntityCollection<Record<string, unknown>> => Boolean(c));
382
+ return (r.overrides ? mergeDeep(targetWithOverrides, r.overrides) : targetWithOverrides) as CollectionConfig<Record<string, unknown>>;
383
+ }).filter((c: CollectionConfig<Record<string, unknown>> | undefined): c is CollectionConfig<Record<string, unknown>> => Boolean(c));
384
384
  }
385
385
 
386
386
  return [];