@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
@@ -5,6 +5,13 @@
5
5
  * PostgREST-style dot-syntax strings (`eq.active`, `gt.18`, `in.(a,b)`).
6
6
  * Everything else speaks `FilterValues` exclusively.
7
7
  *
8
+ * Wire-format values are always strings — the wire format carries no type
9
+ * metadata, so type coercion is the responsibility of the server-side data
10
+ * driver which has access to the collection schema.
11
+ *
12
+ * Commas inside list values are backslash-escaped (`\,`), and literal
13
+ * backslashes are escaped as `\\`.
14
+ *
8
15
  * @module
9
16
  */
10
17
 
@@ -16,74 +23,130 @@ import {
16
23
  RestFilterOp,
17
24
  toCanonicalOp,
18
25
  LogicalCondition,
19
- FilterCondition
26
+ FilterCondition,
27
+ NULL_OPS
20
28
  } from "@rebasepro/types";
29
+ import { normalizeToEntityRelation } from "../util/entities";
21
30
 
22
31
  // ---------------------------------------------------------------------------
23
- // Value coercion (querystring → typed JS values)
32
+ // Value stringification
24
33
  // ---------------------------------------------------------------------------
25
34
 
26
- /**
27
- * Coerce a raw querystring value to its natural JS type.
28
- * - `"true"` / `"false"` → boolean
29
- * - `"null"` → null
30
- * - Numeric strings → number
31
- * - Everything else → string (unchanged)
32
- */
33
- function coerceValue(raw: string): unknown {
34
- if (raw === "true") return true;
35
- if (raw === "false") return false;
36
- if (raw === "null") return null;
37
- if (raw !== "" && !isNaN(Number(raw))) return Number(raw);
38
- return raw;
39
- }
40
-
41
35
  /**
42
36
  * Serialize a JS value to its querystring representation.
37
+ * `null` is serialized as the literal string `"null"`.
38
+ * Relation values (`EntityRelation` instances or `{ __type: "relation", id, path }`
39
+ * objects) are serialized as their raw id — the wire format only carries the
40
+ * value to compare against the FK column.
43
41
  */
44
42
  function stringifyValue(value: unknown): string {
45
43
  if (value === null) return "null";
46
- if (typeof value === "boolean") return String(value);
44
+ const relation = normalizeToEntityRelation(value);
45
+ if (relation) return String(relation.id);
47
46
  return String(value);
48
47
  }
49
48
 
49
+ // ---------------------------------------------------------------------------
50
+ // Comma escaping for list values
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /**
54
+ * Escape a single list item for the wire format.
55
+ * `\` → `\\`, `,` → `\,`
56
+ */
57
+ function escapeListItem(value: string): string {
58
+ return value.replace(/\\/g, "\\\\").replace(/,/g, "\\,");
59
+ }
60
+
61
+ /**
62
+ * Unescape a single list item from the wire format.
63
+ * `\\` → `\`, `\,` → `,`
64
+ */
65
+ function unescapeListItem(value: string): string {
66
+ let result = "";
67
+ for (let i = 0; i < value.length; i++) {
68
+ if (value[i] === "\\" && i + 1 < value.length) {
69
+ result += value[i + 1];
70
+ i++; // skip next char
71
+ } else {
72
+ result += value[i];
73
+ }
74
+ }
75
+ return result;
76
+ }
77
+
78
+ /**
79
+ * Split a parenthesized list string on unescaped commas.
80
+ * Input is the content between `(` and `)`.
81
+ *
82
+ * @example
83
+ * splitListItems("admin,editor") // ["admin", "editor"]
84
+ * splitListItems("hello\\, world,foo") // ["hello, world", "foo"]
85
+ */
86
+ function splitListItems(inner: string): string[] {
87
+ const items: string[] = [];
88
+ let current = "";
89
+ for (let i = 0; i < inner.length; i++) {
90
+ if (inner[i] === "\\" && i + 1 < inner.length) {
91
+ // Escaped character — consume both chars
92
+ current += inner[i] + inner[i + 1];
93
+ i++;
94
+ } else if (inner[i] === ",") {
95
+ items.push(unescapeListItem(current));
96
+ current = "";
97
+ } else {
98
+ current += inner[i];
99
+ }
100
+ }
101
+ items.push(unescapeListItem(current));
102
+ return items;
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Typed operator map lookups (no `as any`)
107
+ // ---------------------------------------------------------------------------
108
+
109
+ const REST_OP_LOOKUP = REST_TO_CANONICAL as Readonly<Record<string, WhereFilterOp | undefined>>;
110
+ const CANONICAL_OP_LOOKUP = CANONICAL_TO_REST as Readonly<Record<string, RestFilterOp | undefined>>;
111
+
50
112
  // ---------------------------------------------------------------------------
51
113
  // Serialize: FilterValues → REST querystring
52
114
  // ---------------------------------------------------------------------------
53
115
 
54
116
  /**
55
- * Serialize a single condition tuple to a PostgREST dot-string.
117
+ * Serialize a single canonical condition tuple to a PostgREST dot-string.
118
+ *
119
+ * Throws `TypeError` if the input is not a valid `[WhereFilterOp, unknown]` tuple.
56
120
  *
57
121
  * @example
58
122
  * serializeTuple(["==", "active"]) // "eq.active"
59
123
  * serializeTuple(["in", ["admin","editor"]]) // "in.(admin,editor)"
60
124
  * serializeTuple([">=", 18]) // "gte.18"
61
125
  */
62
- function serializeTuple(tuple: [WhereFilterOp, unknown] | unknown): string {
63
- // If it's already a string, it might be a PostgREST string (with dot)
64
- // or a raw value (without dot). In both cases, existing tests expect
65
- // them to be passed through or treated as simple equality if no dot.
66
- if (typeof tuple === "string") {
67
- if (tuple.includes(".")) {
68
- const dotIndex = tuple.indexOf(".");
69
- const prefix = tuple.substring(0, dotIndex);
70
- if ((REST_TO_CANONICAL as any)[prefix]) {
71
- return tuple;
72
- }
73
- }
74
- return tuple;
126
+ function serializeTuple(tuple: [WhereFilterOp, unknown]): string {
127
+ if (!Array.isArray(tuple) || tuple.length !== 2) {
128
+ throw new TypeError(
129
+ `serializeTuple: expected a [WhereFilterOp, value] tuple, got ${JSON.stringify(tuple)}`
130
+ );
75
131
  }
76
132
 
77
- // If it's NOT a canonical tuple [WhereFilterOp, value], treat as equality.
78
- if (!Array.isArray(tuple) || tuple.length !== 2 || typeof tuple[0] !== "string" || !(CANONICAL_TO_REST as any)[tuple[0]]) {
79
- return `eq.${stringifyValue(tuple)}`;
133
+ const [op, value] = tuple;
134
+
135
+ if (typeof op !== "string") {
136
+ throw new TypeError(
137
+ `serializeTuple: operator must be a string, got ${typeof op}`
138
+ );
80
139
  }
81
140
 
82
- const [op, value] = tuple as [WhereFilterOp, unknown];
83
- const restOp = CANONICAL_TO_REST[op];
141
+ const restOp = CANONICAL_OP_LOOKUP[op];
142
+ if (!restOp) {
143
+ throw new TypeError(
144
+ `serializeTuple: unknown operator "${op}". Valid operators: ${Object.keys(CANONICAL_TO_REST).join(", ")}`
145
+ );
146
+ }
84
147
 
85
148
  if (Array.isArray(value)) {
86
- const items = value.map(stringifyValue).join(",");
149
+ const items = value.map(v => escapeListItem(stringifyValue(v))).join(",");
87
150
  return `${restOp}.(${items})`;
88
151
  }
89
152
 
@@ -91,8 +154,11 @@ function serializeTuple(tuple: [WhereFilterOp, unknown] | unknown): string {
91
154
  }
92
155
 
93
156
  /**
94
- * Convert `FilterValues` to a PostgREST-style querystring record.
157
+ * Convert `FilterValues` (or `WireFilterValues`) to a PostgREST-style
158
+ * querystring record.
95
159
  *
160
+ * - Canonical `[WhereFilterOp, value]` tuples are serialized strictly.
161
+ * - Pre-serialized PostgREST strings (e.g. `"eq.published"`) are passed through.
96
162
  * - Single conditions produce a string value.
97
163
  * - Multiple conditions on the same field produce a string array (repeated params).
98
164
  *
@@ -102,22 +168,34 @@ function serializeTuple(tuple: [WhereFilterOp, unknown] | unknown): string {
102
168
  *
103
169
  * serializeFilter({ age: [[">=", 18], ["<", 65]] })
104
170
  * // → { age: ["gte.18", "lt.65"] }
171
+ *
172
+ * // Pre-serialized strings pass through unchanged:
173
+ * serializeFilter({ status: "eq.published" })
174
+ * // → { status: "eq.published" }
105
175
  */
106
176
  export function serializeFilter(
107
- filter: FilterValues<string> | Record<string, any>
177
+ filter: FilterValues<string> | Record<string, unknown>
108
178
  ): Record<string, string | string[]> {
109
179
  const result: Record<string, string | string[]> = {};
110
180
 
111
181
  for (const [field, condition] of Object.entries(filter)) {
112
182
  if (condition === undefined) continue;
113
183
 
184
+ // Pre-serialized PostgREST string — pass through unchanged.
185
+ // This supports WireFilterValues where values may already be
186
+ // serialized dot-strings like "eq.active" or raw strings like "true".
187
+ if (typeof condition === "string") {
188
+ result[field] = condition;
189
+ continue;
190
+ }
191
+
114
192
  // Multiple conditions on the same field: array of tuples
115
193
  // We detect this by checking if the first element is also an array.
116
194
  if (Array.isArray(condition) && condition.length > 0 && Array.isArray(condition[0])) {
117
- result[field] = (condition as any[]).map(serializeTuple);
195
+ result[field] = (condition as [WhereFilterOp, unknown][]).map(serializeTuple);
118
196
  } else {
119
- // Single condition (could be a tuple, a raw value, or an already-serialized string)
120
- result[field] = serializeTuple(condition);
197
+ // Single condition — must be a [WhereFilterOp, value] tuple
198
+ result[field] = serializeTuple(condition as [WhereFilterOp, unknown]);
121
199
  }
122
200
  }
123
201
 
@@ -131,34 +209,47 @@ export function serializeFilter(
131
209
  /**
132
210
  * Parse a single PostgREST dot-string into a `[WhereFilterOp, unknown]` tuple.
133
211
  *
134
- * If the string doesn't match a known operator prefix, falls back to
212
+ * All values are returned as strings — the wire format carries no type
213
+ * metadata, so coercion is the data driver's responsibility.
214
+ *
215
+ * If the string doesn't match a known operator prefix, it falls back to
135
216
  * `["==", originalString]` (treating the whole string as an equality value).
217
+ * This intentional defense handles values like `"user@host.com"` or
218
+ * `"1.2.3"` that happen to contain dots.
136
219
  */
137
220
  function deserializeSingle(raw: string): [WhereFilterOp, unknown] {
138
221
  const dotIndex = raw.indexOf(".");
139
222
  if (dotIndex === -1) {
140
- // No dot → equality on the raw value (coerced)
141
- return ["==", coerceValue(raw)];
223
+ // No dot → equality on the raw value (kept as string)
224
+ return ["==", raw];
142
225
  }
143
226
 
144
227
  const prefix = raw.substring(0, dotIndex);
145
228
  const rest = raw.substring(dotIndex + 1);
146
229
 
147
- // Check if the prefix is a known REST operator
148
- const canonicalOp = (REST_TO_CANONICAL as Record<string, WhereFilterOp | undefined>)[prefix];
230
+ // Check if the prefix is a known REST operator.
231
+ // This is the key defense against values like "eq.something" or "gt.foo"
232
+ // being misinterpreted — only known REST short-codes are treated as operators.
233
+ const canonicalOp = REST_OP_LOOKUP[prefix];
149
234
  if (!canonicalOp) {
150
235
  // Not a known operator (e.g., email "user@host.com" or version "1.2.3")
151
236
  // Treat the entire string as an equality value
152
237
  return ["==", raw];
153
238
  }
154
239
 
240
+ // Null-testing operators ignore their serialized value — normalize to null
241
+ // so the tuple round-trips stably (`isnull.null` → ["is-null", null]).
242
+ if (NULL_OPS.has(canonicalOp)) {
243
+ return [canonicalOp, null];
244
+ }
245
+
155
246
  // Parse list values: "(admin,editor)" → ["admin", "editor"]
156
247
  if (rest.startsWith("(") && rest.endsWith(")")) {
157
- const items = rest.slice(1, -1).split(",").map(s => coerceValue(s.trim()));
248
+ const items = splitListItems(rest.slice(1, -1));
158
249
  return [canonicalOp, items];
159
250
  }
160
251
 
161
- return [canonicalOp, coerceValue(rest)];
252
+ return [canonicalOp, rest];
162
253
  }
163
254
 
164
255
  /**
@@ -172,10 +263,10 @@ function deserializeSingle(raw: string): [WhereFilterOp, unknown] {
172
263
  * // → { status: ["==", "active"] }
173
264
  *
174
265
  * deserializeFilter({ age: ["gte.18", "lt.65"] })
175
- * // → { age: [[">=", 18], ["<", 65]] }
266
+ * // → { age: [[">=", "18"], ["<", "65"]] }
176
267
  */
177
268
  export function deserializeFilter(
178
- query: Record<string, any>
269
+ query: Record<string, unknown>
179
270
  ): FilterValues<string> {
180
271
  const result: FilterValues<string> = {};
181
272
 
@@ -244,9 +335,9 @@ export function serializeLogicalCondition(
244
335
  }
245
336
 
246
337
  // FilterCondition
247
- const restOp = (CANONICAL_TO_REST as any)[cond.operator] || "eq";
338
+ const restOp = CANONICAL_OP_LOOKUP[cond.operator] ?? "eq";
248
339
  if (Array.isArray(cond.value)) {
249
- const items = cond.value.map(stringifyValue).join(",");
340
+ const items = cond.value.map(v => escapeListItem(stringifyValue(v))).join(",");
250
341
  return `${cond.column}.${restOp}.(${items})`;
251
342
  }
252
343
  return `${cond.column}.${restOp}.${stringifyValue(cond.value)}`;
@@ -300,19 +391,19 @@ export function deserializeLogicalCondition(
300
391
 
301
392
  const secondDot = rest.indexOf(".");
302
393
  if (secondDot === -1) {
303
- // "column.value" — treat as equality
304
- return { column, operator: "==", value: coerceValue(rest) };
394
+ // "column.value" — treat as equality (value kept as string)
395
+ return { column, operator: "==", value: rest };
305
396
  }
306
397
 
307
398
  const opStr = rest.substring(0, secondDot);
308
- let valueStr = rest.substring(secondDot + 1);
399
+ const valueStr = rest.substring(secondDot + 1);
309
400
  const operator = toCanonicalOp(opStr) ?? "==";
310
401
 
311
- // Parse list values
402
+ // Parse list values with escape-aware splitting
312
403
  if (valueStr.startsWith("(") && valueStr.endsWith(")")) {
313
- const items = valueStr.slice(1, -1).split(",").map(s => coerceValue(s.trim()));
404
+ const items = splitListItems(valueStr.slice(1, -1));
314
405
  return { column, operator, value: items };
315
406
  }
316
407
 
317
- return { column, operator, value: coerceValue(valueStr) };
408
+ return { column, operator, value: valueStr };
318
409
  }
@@ -1,4 +1,13 @@
1
- import { FindParams, Entity, FindResponse, CollectionAccessor, QueryBuilderInterface, WhereFilterOp, LogicalCondition, WhereValue, FilterCondition } from "@rebasepro/types";
1
+ import {
2
+ CollectionAccessor,
3
+ FilterCondition,
4
+ FindParams,
5
+ FindResponse,
6
+ LogicalCondition,
7
+ QueryBuilderInterface,
8
+ WhereFilterOp,
9
+ WhereValue
10
+ } from "@rebasepro/types";
2
11
 
3
12
  export function or(...conditions: (FilterCondition | LogicalCondition)[]): LogicalCondition {
4
13
  return { type: "or",
@@ -67,7 +76,7 @@ export class QueryBuilder<M extends Record<string, unknown> = Record<string, unk
67
76
  * client.collection('users').orderBy('createdAt', 'desc').find()
68
77
  */
69
78
  orderBy(column: keyof M & string, direction: "asc" | "desc" = "asc"): this {
70
- this.params.orderBy = `${column}:${direction}`;
79
+ this.params.orderBy = [column, direction];
71
80
  return this;
72
81
  }
73
82
 
@@ -7,7 +7,7 @@ import {
7
7
 
8
8
  /**
9
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
10
+ * structural type (rather than the full `CollectionConfig`) keeps this usable
11
11
  * from anywhere — frontend router, backend registry, editor — without coupling
12
12
  * to the collection union.
13
13
  */
@@ -0,0 +1,56 @@
1
+ import type { OrderByTuple } from "@rebasepro/types";
2
+
3
+ /**
4
+ * Sort-order wire codec.
5
+ *
6
+ * This is the ONLY module that knows about the colon-delimited wire format
7
+ * (`"field:direction"`) used in HTTP query parameters.
8
+ * Everything else speaks {@link OrderByTuple} exclusively.
9
+ *
10
+ * Mirrors the filter architecture in `filter-dialect.ts`.
11
+ *
12
+ * @module
13
+ */
14
+
15
+ /**
16
+ * Serialize an {@link OrderByTuple} to the wire format `"field:direction"`.
17
+ *
18
+ * **Runtime tolerance:** if the input is already a well-formed wire string
19
+ * (from an untyped JS caller), it is returned unchanged.
20
+ * This is undocumented tolerance, not public API — don't rely on it.
21
+ *
22
+ * @param orderBy - A canonical `[field, direction]` tuple, or at runtime
23
+ * possibly a pre-serialized string (undocumented tolerance).
24
+ * @returns The wire-format string, or `undefined` if the input is falsy.
25
+ *
26
+ * @remarks
27
+ * Field names containing `:` are representable in the tuple form but
28
+ * **not** on the wire — this is an inherent limitation of the colon-delimited
29
+ * encoding and is not resolved here.
30
+ */
31
+ export function serializeOrderBy(orderBy?: OrderByTuple | string): string | undefined {
32
+ if (!orderBy) return undefined;
33
+ // Runtime tolerance: pass through a pre-serialized wire string unchanged.
34
+ if (typeof orderBy === "string") return orderBy;
35
+ return `${orderBy[0]}:${orderBy[1]}`;
36
+ }
37
+
38
+ /**
39
+ * Deserialize a wire-format `"field:direction"` string into an {@link OrderByTuple}.
40
+ *
41
+ * Lenient parsing (matches existing server behaviour):
42
+ * - Bare field name (no colon): `"name"` → `["name", "asc"]`
43
+ * - Unknown direction: `"name:foo"` → `["name", "asc"]`
44
+ * - Empty / falsy input: → `undefined`
45
+ *
46
+ * @param raw - The wire-format string from an HTTP query parameter.
47
+ * @returns The canonical tuple, or `undefined` if the input is empty/falsy.
48
+ */
49
+ export function deserializeOrderBy(raw?: string): OrderByTuple | undefined {
50
+ if (!raw) return undefined;
51
+ const idx = raw.indexOf(":");
52
+ if (idx === -1) return [raw, "asc"];
53
+ const field = raw.slice(0, idx);
54
+ const dir = raw.slice(idx + 1);
55
+ return [field, dir === "desc" ? "desc" : "asc"];
56
+ }
package/src/index.ts CHANGED
@@ -5,4 +5,5 @@ export * from "./data/buildRoutedRebaseData";
5
5
  export * from "./data/resolveDataSource";
6
6
  export * from "./data/query_builder";
7
7
  export * from "./data/filter-dialect";
8
+ export * from "./data/sort-dialect";
8
9
  export * from "./table-classification";
@@ -0,0 +1,152 @@
1
+ import { CollectionConfig, SecurityRule, SecurityOperation, AuthCollectionConfig, PolicyExpression, isPostgresCollectionConfig, policy } from "@rebasepro/types";
2
+ import { getTableName } from "./relations";
3
+
4
+ /**
5
+ * Default RLS policies injected by the schema generator.
6
+ *
7
+ * Rebase's enforcement model is unified: authenticated (user-context) requests
8
+ * run under the restricted `rebase_user` role, so Postgres RLS binds *every*
9
+ * statement — reads and writes. A collection's `securityRules` are the whole
10
+ * authorization model. The server context (auth flows, migrations,
11
+ * `dataAsAdmin`) runs as the owner and bypasses RLS.
12
+ *
13
+ * Because RLS default-denies, every collection is **locked by default**: with
14
+ * no rules, only the server context and admins can touch it. The generator
15
+ * injects that safe baseline:
16
+ *
17
+ * **For every collection**
18
+ * 1. A permissive **server-or-admin SELECT** grant.
19
+ * 2. A permissive **server-or-admin write** grant (insert/update/delete).
20
+ *
21
+ * Author `securityRules` are permissive and OR together, so explicit rules only
22
+ * *broaden* access from this locked baseline (e.g. "users read/write their own
23
+ * rows").
24
+ *
25
+ * **For auth collections additionally**
26
+ * 3. A permissive **self SELECT** grant (`id = auth.uid()`), so users can read
27
+ * their own row (profile, session bootstrap) without every app re-declaring
28
+ * it.
29
+ * 4. A **restrictive** admin write gate. Restrictive policies are AND'd with
30
+ * every other policy, so a write is rejected unless the caller is an admin
31
+ * (or the server context) — even if the author also wrote a permissive rule
32
+ * such as "a user may edit their own row". Without this, a permissive owner
33
+ * rule would let a user change their own `roles`.
34
+ *
35
+ * The server context is recognised as `auth.uid() IS NULL` (`policy.serverContext()`)
36
+ * — the built-in flows that run without a user (signup, migrations) set no user
37
+ * GUC — which also lets the owner connection satisfy these policies even under
38
+ * FORCE RLS. A *user* request never reaches that state: an anonymous one carries
39
+ * `ANONYMOUS_USER_ID`, precisely so it cannot pass for the server here.
40
+ *
41
+ * Opt out with `disableDefaultPolicies: true` to take full responsibility for
42
+ * the collection's RLS.
43
+ */
44
+ // Expressed structurally (not as raw SQL) so the admin UI can evaluate it
45
+ // exactly — the framework's most security-critical policies must be reflected
46
+ // precisely, not left as un-evaluable raw clauses. Compiles to
47
+ // `auth.uid() IS NULL OR (string_to_array(auth.roles(), ',') && ARRAY['admin'])`.
48
+ //
49
+ // `serverContext()`, emphatically not `not(authenticated())`: the server arm of
50
+ // this grant must match the server context and nothing else. Anonymous visitors
51
+ // are not signed in either, so a negated `authenticated()` would hand them the
52
+ // server-or-admin grant on every collection's default policy.
53
+ const SERVER_OR_ADMIN_EXPR: PolicyExpression = policy.or(
54
+ policy.serverContext(),
55
+ policy.rolesOverlap(["admin"])
56
+ );
57
+
58
+ /** Write operations that must be admin-gated by default on auth collections. */
59
+ const DEFAULT_GUARDED_OPS: SecurityOperation[] = ["insert", "update", "delete"];
60
+
61
+ /** Whether a collection is flagged as an authentication collection. */
62
+ function isAuthCollection(collection: CollectionConfig): boolean {
63
+ const auth = collection.auth;
64
+ return auth === true || (typeof auth === "object" && (auth as AuthCollectionConfig)?.enabled === true);
65
+ }
66
+
67
+ /** The property marked as the row id (falls back to `id`). */
68
+ function getIdPropertyName(collection: CollectionConfig): string {
69
+ for (const [name, prop] of Object.entries(collection.properties ?? {})) {
70
+ if (prop && typeof prop === "object" && "isId" in prop && (prop as { isId?: unknown }).isId) {
71
+ return name;
72
+ }
73
+ }
74
+ return "id";
75
+ }
76
+
77
+ /**
78
+ * Returns the security rules that should be applied to a collection: the
79
+ * author's explicit `securityRules` plus the framework defaults described in
80
+ * the module doc (baseline server/admin read for all collections; self-read
81
+ * and the admin write gate for auth collections).
82
+ *
83
+ * Collections that opt out via `disableDefaultPolicies` are returned unchanged.
84
+ */
85
+ export function getEffectiveSecurityRules(collection: CollectionConfig): SecurityRule[] {
86
+ const explicit = [...((isPostgresCollectionConfig(collection) ? collection.securityRules : undefined) ?? [])];
87
+
88
+ if (collection.disableDefaultPolicies) {
89
+ return explicit;
90
+ }
91
+
92
+ const tableName = getTableName(collection);
93
+ const injected: SecurityRule[] = [];
94
+
95
+ // Baseline read + write: the server context and admins can always operate.
96
+ // RLS default-denies under the user role, so without these a rule-less
97
+ // collection would be locked to everyone — including the admin studio.
98
+ // Author rules are permissive and broaden access from here.
99
+ injected.push({
100
+ name: `${tableName}_default_admin_read`,
101
+ operations: ["select"],
102
+ condition: SERVER_OR_ADMIN_EXPR
103
+ });
104
+ injected.push({
105
+ name: `${tableName}_default_admin_write`,
106
+ operations: [...DEFAULT_GUARDED_OPS],
107
+ condition: SERVER_OR_ADMIN_EXPR,
108
+ check: SERVER_OR_ADMIN_EXPR
109
+ });
110
+
111
+ if (isAuthCollection(collection)) {
112
+ // Self-read: a user can always read their own row.
113
+ injected.push({
114
+ name: `${tableName}_default_self_read`,
115
+ operations: ["select"],
116
+ condition: policy.compare(policy.field(getIdPropertyName(collection)), "eq", policy.authUid())
117
+ });
118
+
119
+ // Restrictive gate: AND'd with all other policies, so no permissive rule
120
+ // (e.g. an owner "edit your own row" rule) can let a non-admin change
121
+ // privileged columns like `roles`.
122
+ injected.push({
123
+ name: `${tableName}_require_admin_write`,
124
+ mode: "restrictive",
125
+ operations: [...DEFAULT_GUARDED_OPS],
126
+ condition: SERVER_OR_ADMIN_EXPR,
127
+ check: SERVER_OR_ADMIN_EXPR
128
+ });
129
+ }
130
+
131
+ return [...explicit, ...injected];
132
+ }
133
+
134
+ /**
135
+ * The framework defaults that {@link getEffectiveSecurityRules} would add to a
136
+ * collection, without the author's own rules.
137
+ *
138
+ * These policies appear in the database under names the author never wrote, and
139
+ * a permissive policy ORs with every other permissive policy — so someone
140
+ * reading their `securityRules` and then the real ACL sees more access than they
141
+ * declared. Dropping them by hand does nothing either: `db push` is declarative,
142
+ * so the next push asserts them again. Callers use this to say, in the generated
143
+ * DDL, which policies are injected and how to take them off.
144
+ */
145
+ export function getInjectedSecurityRules(collection: CollectionConfig): SecurityRule[] {
146
+ if (collection.disableDefaultPolicies) return [];
147
+
148
+ const explicitCount = ((isPostgresCollectionConfig(collection) ? collection.securityRules : undefined) ?? []).length;
149
+ // getEffectiveSecurityRules appends the defaults after the author's rules,
150
+ // so everything past the author's count is injected.
151
+ return getEffectiveSecurityRules(collection).slice(explicitCount);
152
+ }