@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.
- package/README.md +5 -5
- package/dist/collections/CollectionRegistry.d.ts +16 -16
- package/dist/collections/default-collections.d.ts +5 -1
- package/dist/data/buildRebaseData.d.ts +44 -3
- package/dist/data/buildRoutedRebaseData.d.ts +14 -9
- package/dist/data/filter-dialect.d.ts +18 -4
- package/dist/data/query_builder.d.ts +1 -1
- package/dist/data/resolveDataSource.d.ts +1 -1
- package/dist/data/sort-dialect.d.ts +41 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +1236 -179
- package/dist/index.es.js.map +1 -1
- package/dist/util/auth-default-policies.d.ts +22 -0
- package/dist/util/builders.d.ts +19 -56
- package/dist/util/callbacks.d.ts +3 -3
- package/dist/util/collections.d.ts +4 -4
- package/dist/util/entities.d.ts +2 -2
- package/dist/util/filter-operator-resolution.d.ts +32 -0
- package/dist/util/identity.d.ts +83 -0
- package/dist/util/index.d.ts +4 -0
- package/dist/util/junction-policies.d.ts +108 -0
- package/dist/util/navigation_from_path.d.ts +4 -4
- package/dist/util/navigation_utils.d.ts +3 -3
- package/dist/util/parent_references_from_path.d.ts +2 -2
- package/dist/util/permissions.d.ts +6 -6
- package/dist/util/policy/evaluatePolicy.d.ts +8 -1
- package/dist/util/policy/index.d.ts +1 -0
- package/dist/util/policy/policyToPostgres.d.ts +14 -2
- package/dist/util/policy/sqlToPolicy.d.ts +24 -14
- package/dist/util/references.d.ts +2 -2
- package/dist/util/relations.d.ts +5 -5
- package/dist/util/resolutions.d.ts +2 -2
- package/package.json +7 -8
- package/src/collections/CollectionRegistry.ts +36 -36
- package/src/collections/default-collections.ts +2 -0
- package/src/data/buildRebaseData.ts +430 -60
- package/src/data/buildRoutedRebaseData.ts +22 -16
- package/src/data/filter-dialect.ts +151 -60
- package/src/data/query_builder.ts +11 -2
- package/src/data/resolveDataSource.ts +1 -1
- package/src/data/sort-dialect.ts +56 -0
- package/src/index.ts +1 -0
- package/src/util/auth-default-policies.ts +152 -0
- package/src/util/builders.ts +25 -99
- package/src/util/callbacks.ts +8 -8
- package/src/util/collections.ts +4 -4
- package/src/util/entities.ts +4 -4
- package/src/util/filter-operator-resolution.ts +81 -0
- package/src/util/identity.ts +166 -0
- package/src/util/index.ts +4 -0
- package/src/util/junction-policies.ts +353 -0
- package/src/util/navigation_from_path.ts +4 -4
- package/src/util/navigation_utils.ts +8 -8
- package/src/util/parent_references_from_path.ts +3 -3
- package/src/util/permissions.test.ts +2 -2
- package/src/util/permissions.ts +7 -7
- package/src/util/policy/evaluatePolicy.ts +26 -4
- package/src/util/policy/index.ts +1 -0
- package/src/util/policy/policyToPostgres.ts +123 -17
- package/src/util/policy/sqlToPolicy.ts +190 -13
- package/src/util/references.ts +2 -2
- package/src/util/relations.ts +12 -12
- package/src/util/resolutions.ts +5 -5
- package/dist/index.umd.js +0 -2901
- 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
|
|
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
|
-
|
|
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]
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
83
|
-
|
|
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
|
|
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,
|
|
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
|
|
195
|
+
result[field] = (condition as [WhereFilterOp, unknown][]).map(serializeTuple);
|
|
118
196
|
} else {
|
|
119
|
-
// Single condition
|
|
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
|
-
*
|
|
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 (
|
|
141
|
-
return ["==",
|
|
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
|
-
|
|
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)
|
|
248
|
+
const items = splitListItems(rest.slice(1, -1));
|
|
158
249
|
return [canonicalOp, items];
|
|
159
250
|
}
|
|
160
251
|
|
|
161
|
-
return [canonicalOp,
|
|
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,
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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)
|
|
404
|
+
const items = splitListItems(valueStr.slice(1, -1));
|
|
314
405
|
return { column, operator, value: items };
|
|
315
406
|
}
|
|
316
407
|
|
|
317
|
-
return { column, operator, value:
|
|
408
|
+
return { column, operator, value: valueStr };
|
|
318
409
|
}
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
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 =
|
|
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 `
|
|
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
|
@@ -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
|
+
}
|