@rebasepro/common 0.17.3 → 0.18.1
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 +4 -0
- package/dist/collections/CollectionRegistry.d.ts +1 -1
- package/dist/collections/default-collections.d.ts +15 -84
- package/dist/data/buildRebaseData.d.ts +1 -1
- package/dist/data/filter-dialect.d.ts +11 -0
- package/dist/data/sort-dialect.d.ts +15 -3
- package/dist/index.es.js +375 -63
- package/dist/index.es.js.map +1 -1
- package/dist/util/builders.d.ts +69 -24
- package/dist/util/callback-errors.d.ts +77 -0
- package/dist/util/callback-errors.test.d.ts +1 -0
- package/dist/util/index.d.ts +1 -0
- package/dist/util/policy/evaluatePolicy.d.ts +6 -0
- package/dist/util/relations.d.ts +41 -0
- package/dist/util/table-name.test.d.ts +1 -0
- package/package.json +26 -22
- package/src/collections/CollectionRegistry.ts +0 -485
- package/src/collections/default-collections.ts +0 -109
- package/src/collections/index.ts +0 -2
- package/src/data/buildRebaseData.ts +0 -816
- package/src/data/buildRoutedRebaseData.ts +0 -103
- package/src/data/filter-conditions.ts +0 -46
- package/src/data/filter-dialect.ts +0 -737
- package/src/data/paginate.ts +0 -334
- package/src/data/query_builder.ts +0 -176
- package/src/data/resolveDataSource.ts +0 -135
- package/src/data/sort-dialect.ts +0 -237
- package/src/index.ts +0 -11
- package/src/table-classification.ts +0 -109
- package/src/types/json-logic-js.d.ts +0 -8
- package/src/util/auth-default-policies.ts +0 -215
- package/src/util/builders.ts +0 -82
- package/src/util/callbacks.ts +0 -122
- package/src/util/collections.ts +0 -117
- package/src/util/common.ts +0 -2
- package/src/util/conditions.ts +0 -168
- package/src/util/email.ts +0 -32
- package/src/util/entities.ts +0 -282
- package/src/util/enums.ts +0 -26
- package/src/util/identity.ts +0 -202
- package/src/util/index.ts +0 -21
- package/src/util/internal-tables.test.ts +0 -188
- package/src/util/internal-tables.ts +0 -197
- package/src/util/junction-policies.ts +0 -355
- package/src/util/paths.ts +0 -27
- package/src/util/permissions.test.ts +0 -866
- package/src/util/permissions.ts +0 -206
- package/src/util/pg-column-to-property.ts +0 -377
- package/src/util/policy/evaluatePolicy.ts +0 -194
- package/src/util/policy/index.ts +0 -4
- package/src/util/policy/policyToPostgres.ts +0 -263
- package/src/util/policy/securityRuleToConditions.ts +0 -67
- package/src/util/policy/sqlToPolicy.ts +0 -422
- package/src/util/relations.ts +0 -236
- package/src/util/resolutions.ts +0 -534
- package/src/util/resolve-relation.ts +0 -243
- package/src/util/storage.ts +0 -177
- package/src/util/string-column-length.ts +0 -31
|
@@ -1,422 +0,0 @@
|
|
|
1
|
-
import { ANONYMOUS_USER_ID, ANONYMOUS_USER_IDS, LiteralPolicyOperand, PolicyExpression, policy, rewriteLegacyRlsFunctions } from "@rebasepro/types";
|
|
2
|
-
import { toSnakeCase } from "@rebasepro/utils";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* A tiny, regex-based SQL "parser" for security rules.
|
|
6
|
-
*
|
|
7
|
-
* This is NOT a full SQL parser. It is designed to handle the subset of SQL
|
|
8
|
-
* commonly used in `USING` and `WITH CHECK` clauses, enough to drive the
|
|
9
|
-
* optimistic client-side UI decision.
|
|
10
|
-
*
|
|
11
|
-
* It handles:
|
|
12
|
-
* - `field = 'literal'`
|
|
13
|
-
* - `field != 'literal'`
|
|
14
|
-
* - `field = current_setting('app.uid')` (or the legacy `app.user_id`)
|
|
15
|
-
* - `A AND B`, `A OR B` — only where the keyword is at the top level
|
|
16
|
-
* - `true`
|
|
17
|
-
* - `IN (...)` (as optimistic true)
|
|
18
|
-
*
|
|
19
|
-
* For anything it doesn't understand, it returns a `raw` expression, which
|
|
20
|
-
* the evaluator treats as "unknown" (and usually optimistic true).
|
|
21
|
-
*
|
|
22
|
-
* **This output also round-trips back into DDL** via `policyToPostgres` (the
|
|
23
|
-
* schema/policy generators), so decomposing a clause the parser only partly
|
|
24
|
-
* understands is not a cosmetic mistake — it emits invalid SQL. When in doubt,
|
|
25
|
-
* prefer `raw`: it is reproduced verbatim.
|
|
26
|
-
*/
|
|
27
|
-
/** True when `keyword` starts at `i` as a standalone word. */
|
|
28
|
-
function isKeywordAt(upper: string, i: number, keyword: string): boolean {
|
|
29
|
-
if (!upper.startsWith(keyword, i)) return false;
|
|
30
|
-
const before = i === 0 ? " " : upper[i - 1];
|
|
31
|
-
const after = upper[i + keyword.length] ?? " ";
|
|
32
|
-
return /[\s()]/.test(before) && /[\s()]/.test(after);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Split `sql` on a boolean keyword, but only where it sits at paren depth 0 and
|
|
37
|
-
* outside a string literal. Returns null when it never does, so the caller
|
|
38
|
-
* leaves the clause alone.
|
|
39
|
-
*
|
|
40
|
-
* This used to be `sql.split(/ AND /i)`, which tore subqueries in half: the
|
|
41
|
-
* `AND` inside
|
|
42
|
-
* `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id = rebase.uid())`
|
|
43
|
-
* split the expression, and re-emitting the halves produced
|
|
44
|
-
* `(EXISTS (...) AND m.user_id = rebase.uid())`
|
|
45
|
-
* where `m` is no longer in scope — SQL that Postgres rejects outright with
|
|
46
|
-
* "missing FROM-clause entry for table". Returning null instead keeps such a
|
|
47
|
-
* clause as a `raw` expression, which round-trips verbatim.
|
|
48
|
-
*/
|
|
49
|
-
function splitTopLevel(sql: string, keyword: "AND" | "OR"): string[] | null {
|
|
50
|
-
const upper = sql.toUpperCase();
|
|
51
|
-
const parts: string[] = [];
|
|
52
|
-
let depth = 0;
|
|
53
|
-
let inString = false;
|
|
54
|
-
let start = 0;
|
|
55
|
-
|
|
56
|
-
for (let i = 0; i < sql.length; i++) {
|
|
57
|
-
const ch = sql[i];
|
|
58
|
-
if (inString) {
|
|
59
|
-
if (ch === "'") {
|
|
60
|
-
if (sql[i + 1] === "'") i++; // '' escapes a quote inside a literal
|
|
61
|
-
else inString = false;
|
|
62
|
-
}
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
if (ch === "'") { inString = true; continue; }
|
|
66
|
-
if (ch === "(") { depth++; continue; }
|
|
67
|
-
if (ch === ")") { depth--; continue; }
|
|
68
|
-
if (depth === 0 && isKeywordAt(upper, i, keyword)) {
|
|
69
|
-
parts.push(sql.slice(start, i));
|
|
70
|
-
i += keyword.length - 1;
|
|
71
|
-
start = i + 1;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if (parts.length === 0) return null;
|
|
76
|
-
parts.push(sql.slice(start));
|
|
77
|
-
const trimmedParts = parts.map(p => p.trim()).filter(p => p.length > 0);
|
|
78
|
-
return trimmedParts.length > 1 ? trimmedParts : null;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Drop redundant wrapping parens (`(a AND b)` → `a AND b`), never `(a) AND (b)`. */
|
|
82
|
-
function stripOuterParens(sql: string): string {
|
|
83
|
-
let s = sql.trim();
|
|
84
|
-
for (;;) {
|
|
85
|
-
if (!s.startsWith("(") || !s.endsWith(")")) return s;
|
|
86
|
-
let depth = 0;
|
|
87
|
-
let inString = false;
|
|
88
|
-
let wraps = true;
|
|
89
|
-
for (let i = 0; i < s.length; i++) {
|
|
90
|
-
const ch = s[i];
|
|
91
|
-
if (inString) {
|
|
92
|
-
if (ch === "'") {
|
|
93
|
-
if (s[i + 1] === "'") i++;
|
|
94
|
-
else inString = false;
|
|
95
|
-
}
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
if (ch === "'") { inString = true; continue; }
|
|
99
|
-
if (ch === "(") depth++;
|
|
100
|
-
else if (ch === ")") {
|
|
101
|
-
depth--;
|
|
102
|
-
if (depth === 0 && i < s.length - 1) { wraps = false; break; }
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
if (!wraps) return s;
|
|
106
|
-
s = s.slice(1, -1).trim();
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export function sqlToPolicy(sql: string): PolicyExpression {
|
|
111
|
-
// Normalised before anything else looks at it, so every pattern below only
|
|
112
|
-
// has to know the current spelling. A database migrated by a pre-1.0 release
|
|
113
|
-
// still holds `auth.uid()` in its policy bodies until the next push or boot
|
|
114
|
-
// recompiles them — and until then the admin UI reads those bodies back
|
|
115
|
-
// through here. Without this they parse as opaque `raw`, and the framework's
|
|
116
|
-
// own policies get badged as hand-written drift.
|
|
117
|
-
//
|
|
118
|
-
// Normalising rather than accepting both spellings throughout is deliberate:
|
|
119
|
-
// it also means a legacy policy that falls through to `raw` is stored in the
|
|
120
|
-
// new spelling, so editing and saving one in the Studio migrates it.
|
|
121
|
-
const trimmed = stripOuterParens(rewriteLegacyRlsFunctions(sql).trim());
|
|
122
|
-
|
|
123
|
-
if (trimmed.toLowerCase() === "true") return policy.true();
|
|
124
|
-
if (trimmed.toLowerCase() === "false") return policy.false();
|
|
125
|
-
|
|
126
|
-
// Handle roles overlap (&&)
|
|
127
|
-
// Matches: string_to_array(rebase.roles(), ',') && ARRAY['admin', 'editor']
|
|
128
|
-
const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\s*\[(.+)\]$/i);
|
|
129
|
-
if (overlapMatch) {
|
|
130
|
-
const roles = overlapMatch[1].split(",").map(s => s.trim().replace(/^'|'$/g, ""));
|
|
131
|
-
return policy.rolesOverlap(roles);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Handle roles containment (@>)
|
|
135
|
-
// Matches: string_to_array(rebase.roles(), ',') @> ARRAY['admin']
|
|
136
|
-
const containMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\s*\[(.+)\]$/i);
|
|
137
|
-
if (containMatch) {
|
|
138
|
-
const roles = containMatch[1].split(",").map(s => s.trim().replace(/^'|'$/g, ""));
|
|
139
|
-
return policy.rolesContain(roles);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// OR binds looser than AND, so it splits first.
|
|
143
|
-
const orParts = splitTopLevel(trimmed, "OR");
|
|
144
|
-
if (orParts) return policy.or(...orParts.map(sqlToPolicy));
|
|
145
|
-
|
|
146
|
-
const andParts = splitTopLevel(trimmed, "AND");
|
|
147
|
-
if (andParts) return policy.and(...andParts.map(sqlToPolicy));
|
|
148
|
-
|
|
149
|
-
// Handle = and !=
|
|
150
|
-
const match = trimmed.match(/^(.+?)\s*(!?=)\s*(.+)$/);
|
|
151
|
-
if (match) {
|
|
152
|
-
const [, leftStr, op, rightStr] = match;
|
|
153
|
-
const left = parseOperand(leftStr.trim());
|
|
154
|
-
const right = parseOperand(rightStr.trim());
|
|
155
|
-
if (left && right) {
|
|
156
|
-
return policy.compare(left, op === "=" ? "eq" : "neq", right);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Fallback to raw — the NORMALISED text, not the input. Storing the input
|
|
161
|
-
// verbatim would mean a legacy policy read out of a database, edited in the
|
|
162
|
-
// Studio and saved, writes `auth.uid()` back into the project's config: a
|
|
163
|
-
// call to a function 1.0 no longer creates.
|
|
164
|
-
return policy.raw(trimmed);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Literals from other BaaS platforms that people compare `rebase.uid()` against
|
|
169
|
-
* out of habit. Mirrors the driver's `FOREIGN_CONVENTION_ROLES` guard on
|
|
170
|
-
* `pgRoles`, one surface over: the same muscle memory inside a `using:` string
|
|
171
|
-
* is the more dangerous spelling, because it inverts a rule instead of
|
|
172
|
-
* emptying a table.
|
|
173
|
-
*/
|
|
174
|
-
/**
|
|
175
|
-
* A `Map`, not an object literal.
|
|
176
|
-
*
|
|
177
|
-
* As `Record<string, string>` this was indexed with a literal taken straight
|
|
178
|
-
* out of a policy, so every key on `Object.prototype` answered: a rule
|
|
179
|
-
* comparing `rebase.uid()` to `"valueOf"`, `"toString"`, `"constructor"` or
|
|
180
|
-
* `"hasOwnProperty"` found a truthy "platform" and reported an anonymous-grant
|
|
181
|
-
* risk that does not exist — with the matched function interpolated into the
|
|
182
|
-
* explanation as the platform's name. A security warning that fires on
|
|
183
|
-
* innocent input is worse than none: it is what teaches people to skip the
|
|
184
|
-
* warnings that are real.
|
|
185
|
-
*
|
|
186
|
-
* Same shape as the prototype-pollution class swept out of `setIn`, `getIn`,
|
|
187
|
-
* `mergeDeep` and `unflattenObject` — a data-derived key reaching a plain
|
|
188
|
-
* object. Found by a property test, on the input `"valueOf"`.
|
|
189
|
-
*/
|
|
190
|
-
const FOREIGN_CONVENTION_UIDS = new Map<string, string>([
|
|
191
|
-
["anon", "Supabase"],
|
|
192
|
-
["authenticated", "Supabase"],
|
|
193
|
-
["service_role", "Supabase"]
|
|
194
|
-
]);
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* The same foreign literals, as a pattern for SQL that could not be parsed
|
|
198
|
-
* back into structure.
|
|
199
|
-
*/
|
|
200
|
-
const FOREIGN_UID_LITERAL_SQL = new RegExp(
|
|
201
|
-
String.raw`rebase\.uid\(\)\s*=\s*'(${[...FOREIGN_CONVENTION_UIDS.keys()].join("|")})'`,
|
|
202
|
-
"i"
|
|
203
|
-
);
|
|
204
|
-
|
|
205
|
-
/** A clause that reads as a lockdown but admits anonymous callers. */
|
|
206
|
-
export interface AnonymousGrantRisk {
|
|
207
|
-
/** Which spelling was found. */
|
|
208
|
-
pattern: "foreign-uid-literal" | "uid-not-null";
|
|
209
|
-
/** The offending fragment — the literal, or the SQL that is a tautology. */
|
|
210
|
-
detail: string;
|
|
211
|
-
/** Why it admits anonymous callers, and what to write instead. */
|
|
212
|
-
explanation: string;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* `rebase.uid() IS NOT NULL` in raw SQL, the clause that is always true.
|
|
217
|
-
*
|
|
218
|
-
* Both schema spellings, because this runs over policy bodies read back from a
|
|
219
|
-
* database, and one migrated by a pre-1.0 release still holds `auth.uid()`.
|
|
220
|
-
* A security check that stops recognising a dangerous clause because the
|
|
221
|
-
* framework renamed a function is a check that silently turns off.
|
|
222
|
-
*/
|
|
223
|
-
const UID_NOT_NULL = /\b(?:rebase|auth)\.uid\(\)\s+IS\s+NOT\s+NULL/i;
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* Find clauses that read as "signed-in users only" but admit anonymous callers.
|
|
227
|
-
*
|
|
228
|
-
* Both spellings come from the same place — Supabase, where its own `auth.uid()`
|
|
229
|
-
* really is NULL for an anonymous request. Rebase substitutes
|
|
230
|
-
* {@link ANONYMOUS_USER_ID} instead (a blank id would read back as NULL, which
|
|
231
|
-
* is how the trusted *server* context is recognised), so:
|
|
232
|
-
*
|
|
233
|
-
* - `rebase.uid() IS NOT NULL` is a tautology on the user path, and
|
|
234
|
-
* - `rebase.uid() != 'anon'` excludes one spelling of anonymous and admits the
|
|
235
|
-
* other. This one is not hypothetical and was not only a foreign habit:
|
|
236
|
-
* rebase's own request path reported `'anon'` while everything that compiled
|
|
237
|
-
* or checked a policy used `'anonymous'`, so whichever literal an author
|
|
238
|
-
* picked, half the anonymous callers walked through. See
|
|
239
|
-
* {@link ANONYMOUS_USER_IDS}.
|
|
240
|
-
*
|
|
241
|
-
* Either one turns a lockdown into a full grant, and neither looks wrong. No
|
|
242
|
-
* real user id is ever one of these literals, and a user-context request is
|
|
243
|
-
* never NULL, so a match is always a mistake rather than a deliberate check.
|
|
244
|
-
*
|
|
245
|
-
* Structured expressions are checked too, not just parsed SQL: `policy.compare`
|
|
246
|
-
* can spell the same mistake.
|
|
247
|
-
*/
|
|
248
|
-
export function findAnonymousGrants(expr: PolicyExpression): AnonymousGrantRisk[] {
|
|
249
|
-
const found: AnonymousGrantRisk[] = [];
|
|
250
|
-
|
|
251
|
-
const visit = (e: PolicyExpression): void => {
|
|
252
|
-
switch (e.kind) {
|
|
253
|
-
case "and":
|
|
254
|
-
case "or":
|
|
255
|
-
e.operands.forEach(visit);
|
|
256
|
-
return;
|
|
257
|
-
case "not":
|
|
258
|
-
visit(e.operand);
|
|
259
|
-
return;
|
|
260
|
-
case "existsIn":
|
|
261
|
-
visit(e.where);
|
|
262
|
-
return;
|
|
263
|
-
case "raw": {
|
|
264
|
-
if (UID_NOT_NULL.test(e.sql)) {
|
|
265
|
-
found.push({
|
|
266
|
-
pattern: "uid-not-null",
|
|
267
|
-
detail: e.sql,
|
|
268
|
-
explanation: "`rebase.uid() IS NOT NULL` is true for every request that came from a client, " +
|
|
269
|
-
`including anonymous ones — they carry '${ANONYMOUS_USER_ID}', not NULL. ` +
|
|
270
|
-
"Use `condition: policy.authenticated()` to mean \"signed in\"."
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
// The foreign literals have to be looked for here too, not only
|
|
274
|
-
// in `compare`. `sqlToPolicy` falls back to `raw` for anything
|
|
275
|
-
// it cannot structure — an `EXISTS (...)` subquery always does —
|
|
276
|
-
// so a policy read back from the database arrives as one opaque
|
|
277
|
-
// string. Checking only the tautology meant a genuine
|
|
278
|
-
// `rebase.uid() = 'anon'` inside an `existsIn` was structurally
|
|
279
|
-
// undetectable once round-tripped, and the caller read the empty
|
|
280
|
-
// result as "no risks found".
|
|
281
|
-
const foreign = FOREIGN_UID_LITERAL_SQL.exec(e.sql);
|
|
282
|
-
if (foreign) {
|
|
283
|
-
const literal = foreign[1];
|
|
284
|
-
found.push({
|
|
285
|
-
pattern: "foreign-uid-literal",
|
|
286
|
-
detail: literal,
|
|
287
|
-
explanation: `'${literal}' is a ${FOREIGN_CONVENTION_UIDS.get(literal)} convention. Rebase ` +
|
|
288
|
-
`reports an anonymous request as '${ANONYMOUS_USER_ID}', so comparing against ` +
|
|
289
|
-
`'${literal}' passes for every caller. Use \`condition: policy.authenticated()\` to ` +
|
|
290
|
-
"mean \"signed in\"."
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
case "compare": {
|
|
296
|
-
const literal = [e.left, e.right].find(o => o.kind === "literal") as LiteralPolicyOperand | undefined;
|
|
297
|
-
const comparesUid = e.left.kind === "authUid" || e.right.kind === "authUid";
|
|
298
|
-
if (!comparesUid || typeof literal?.value !== "string") return;
|
|
299
|
-
const platform = FOREIGN_CONVENTION_UIDS.get(literal.value);
|
|
300
|
-
if (!platform) return;
|
|
301
|
-
found.push({
|
|
302
|
-
pattern: "foreign-uid-literal",
|
|
303
|
-
detail: literal.value,
|
|
304
|
-
explanation: `'${literal.value}' is a ${platform} convention. Rebase reports an anonymous ` +
|
|
305
|
-
`request as '${ANONYMOUS_USER_ID}', so comparing against '${literal.value}' passes for ` +
|
|
306
|
-
"every caller. Use `condition: policy.authenticated()` to mean \"signed in\" — it " +
|
|
307
|
-
`compiles to NOT IN (${ANONYMOUS_USER_IDS.map(v => `'${v}'`).join(", ")}), covering ` +
|
|
308
|
-
"every spelling rebase has reported rather than whichever one you remember."
|
|
309
|
-
});
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
default:
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
315
|
-
};
|
|
316
|
-
|
|
317
|
-
visit(expr);
|
|
318
|
-
return found;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
function parseOperand(str: string) {
|
|
322
|
-
// current_setting('app.uid') or rebase.uid(). `app.user_id` is the
|
|
323
|
-
// pre-rename spelling and stays parseable: policies are data, so a
|
|
324
|
-
// database provisioned before the rename still holds rules written
|
|
325
|
-
// against it, and round-tripping one must not silently drop the operand.
|
|
326
|
-
//
|
|
327
|
-
// ANCHORED, and that is the whole point. These tests used to be
|
|
328
|
-
// unanchored — `.test(str)` rather than `^…$` — so any operand text that
|
|
329
|
-
// merely *contained* a uid call was replaced wholesale by the call itself.
|
|
330
|
-
// Everything else in the expression was discarded with it, including a
|
|
331
|
-
// leading `NOT (`:
|
|
332
|
-
//
|
|
333
|
-
// NOT (rebase.uid() = rebase.uid()) parsed as rebase.uid() = rebase.uid()
|
|
334
|
-
//
|
|
335
|
-
// A deny became an unconditional grant. The realistic spelling is a
|
|
336
|
-
// hand-written defensive rule with a uid call on both sides —
|
|
337
|
-
// COALESCE(rebase.uid(), '') = COALESCE(owner_id, rebase.uid())
|
|
338
|
-
// — which collapsed to the same tautology. This is not confined to the
|
|
339
|
-
// admin UI: `securityRuleToConditions` feeds a rule's raw `using:` string
|
|
340
|
-
// through here, and the Postgres DDL generators compile the result, so the
|
|
341
|
-
// tautology was written into the database as the policy body.
|
|
342
|
-
//
|
|
343
|
-
// An operand this cannot identify exactly must return null, which drops the
|
|
344
|
-
// whole clause to `raw` and reproduces it verbatim. That is the rule the
|
|
345
|
-
// rest of this file already follows: when in doubt, prefer `raw`.
|
|
346
|
-
if (/^current_setting\s*\(\s*'app\.(uid|user_id)'\s*\)$/i.test(str) || /^rebase\.uid\(\)$/i.test(str)) {
|
|
347
|
-
return policy.authUid();
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// Literal string: 'value', with `''` decoded back to a single quote.
|
|
351
|
-
//
|
|
352
|
-
// `quoteLiteral` doubles every quote on the way out, and this did not undo
|
|
353
|
-
// it, so a literal containing an apostrophe grew on every trip: O'Brien →
|
|
354
|
-
// O''Brien → O''''Brien, doubling each time a policy was read back and
|
|
355
|
-
// recompiled. Past the first trip the emitted policy compares against a
|
|
356
|
-
// string no row holds.
|
|
357
|
-
const literal = parseSingleQuoted(str);
|
|
358
|
-
if (literal !== null) {
|
|
359
|
-
return policy.literal(literal);
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// Unquoted literals, which must be recognised BEFORE the bare-word branch
|
|
363
|
-
// below or they are read as column names.
|
|
364
|
-
//
|
|
365
|
-
// `quoteLiteral` emits booleans, numbers and null unquoted, so `a = false`
|
|
366
|
-
// came back as a comparison against a *field* called `false`, and `a = 42`
|
|
367
|
-
// against a field called `42`. The recompiled SQL is identical either way,
|
|
368
|
-
// which is why this survived a round-trip check on the SQL — but the
|
|
369
|
-
// expression is now wrong, and the expression is what the admin UI
|
|
370
|
-
// evaluates. Against a row with no `a`, Postgres denies (`NULL = false` is
|
|
371
|
-
// not true) while the JS evaluator compared two missing columns, found them
|
|
372
|
-
// equal, and allowed. That is precisely the client/database drift the
|
|
373
|
-
// shared PolicyExpression model exists to make impossible.
|
|
374
|
-
//
|
|
375
|
-
// Unambiguous in both directions: a SQL identifier cannot begin with a
|
|
376
|
-
// digit, and bare `true`/`false`/`null` are always the literals — a column
|
|
377
|
-
// so named would have to be double-quoted to be referenced at all.
|
|
378
|
-
if (/^-?\d+$/.test(str)) return policy.literal(Number(str));
|
|
379
|
-
if (/^-?\d*\.\d+$/.test(str)) return policy.literal(Number(str));
|
|
380
|
-
if (/^true$/i.test(str)) return policy.literal(true);
|
|
381
|
-
if (/^false$/i.test(str)) return policy.literal(false);
|
|
382
|
-
if (/^null$/i.test(str)) return policy.literal(null);
|
|
383
|
-
|
|
384
|
-
// Bare field name — but only one that survives the snake-casing the
|
|
385
|
-
// compiler will apply to it. `toSnakeCase("_")` is the empty string, and a
|
|
386
|
-
// field that compiles to an empty column reference emits `= 'x'`, which is
|
|
387
|
-
// a syntax error at CREATE POLICY time. Such a name is left to `raw`, where
|
|
388
|
-
// it round-trips verbatim instead. `toSnakeCase` itself is not touched:
|
|
389
|
-
// column names derived by it are already in shipped databases.
|
|
390
|
-
if (/^\w+$/.test(str) && toSnakeCase(str) !== "") {
|
|
391
|
-
return policy.field(str);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
return null;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* Decode a single-quoted SQL literal, or null when `str` is not exactly one.
|
|
399
|
-
*
|
|
400
|
-
* Rejecting is as important as decoding: `'a' = 'b'` is two literals and an
|
|
401
|
-
* operator, not one literal whose body contains a quote, and a regex anchored
|
|
402
|
-
* on the outer quotes would happily read it as the latter. Every interior quote
|
|
403
|
-
* must therefore be part of a `''` pair.
|
|
404
|
-
*/
|
|
405
|
-
function parseSingleQuoted(str: string): string | null {
|
|
406
|
-
if (str.length < 2 || !str.startsWith("'") || !str.endsWith("'")) return null;
|
|
407
|
-
const body = str.slice(1, -1);
|
|
408
|
-
let out = "";
|
|
409
|
-
for (let i = 0; i < body.length; i++) {
|
|
410
|
-
if (body[i] !== "'") {
|
|
411
|
-
out += body[i];
|
|
412
|
-
continue;
|
|
413
|
-
}
|
|
414
|
-
if (body[i + 1] === "'") {
|
|
415
|
-
out += "'";
|
|
416
|
-
i++;
|
|
417
|
-
continue;
|
|
418
|
-
}
|
|
419
|
-
return null; // a bare quote — `str` is not a single literal
|
|
420
|
-
}
|
|
421
|
-
return out;
|
|
422
|
-
}
|
package/src/util/relations.ts
DELETED
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
import { CollectionConfig, isRelationalCollectionConfig, Property, ResolvedRelation, RelationProperty } from "@rebasepro/types";
|
|
2
|
-
import { toSnakeCase, toWireKey } from "@rebasepro/utils";
|
|
3
|
-
|
|
4
|
-
import { resolveRelation } from "./resolve-relation";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Whether the target rows are shared with other parents — a many-to-many, or a
|
|
8
|
-
* multi-hop `via` chain.
|
|
9
|
-
*
|
|
10
|
-
* Decides what a write "through" the relation may touch: a shared target
|
|
11
|
-
* belongs to every parent that links it, so the parent owns the *link* and not
|
|
12
|
-
* the row. The backend enforces that (an unlink rather than a delete) and the
|
|
13
|
-
* admin renders it (remove-from-parent rather than delete).
|
|
14
|
-
*
|
|
15
|
-
* Now a field on the resolved relation rather than a re-derivation, so both
|
|
16
|
-
* sides read the same answer instead of each computing one.
|
|
17
|
-
*/
|
|
18
|
-
export function isJunctionBackedRelation(relation: ResolvedRelation): boolean {
|
|
19
|
-
return relation.shared;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** WeakMap cache — same collection instance always yields the same relation map. */
|
|
23
|
-
const _resolvedRelationsCache = new WeakMap<CollectionConfig, Record<string, ResolvedRelation>>();
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Every relation a collection declares, keyed by the name it is addressed by.
|
|
27
|
-
*
|
|
28
|
-
* A relation reaches the map from either of two places — the collection's
|
|
29
|
-
* `relations` array, or a `relation` property that declares one inline — and is
|
|
30
|
-
* keyed by its resolved `relationName`, which is what a nested path segment,
|
|
31
|
-
* an `include` key and an admin tab all match against.
|
|
32
|
-
*
|
|
33
|
-
* Resolution no longer swallows failures. It used to wrap each relation in a
|
|
34
|
-
* `try/catch` that dropped anything it could not work out, so a
|
|
35
|
-
* mis-declared relation silently vanished instead of being reported; with the
|
|
36
|
-
* kind declared, the only remaining failure is a `target` that does not resolve,
|
|
37
|
-
* which is worth hearing about.
|
|
38
|
-
*/
|
|
39
|
-
export function resolveCollectionRelations(
|
|
40
|
-
collection: CollectionConfig
|
|
41
|
-
): Record<string, ResolvedRelation> {
|
|
42
|
-
const cached = _resolvedRelationsCache.get(collection);
|
|
43
|
-
if (cached) return cached;
|
|
44
|
-
|
|
45
|
-
if (!isRelationalCollectionConfig(collection)) return {};
|
|
46
|
-
|
|
47
|
-
const relations: Record<string, ResolvedRelation> = {};
|
|
48
|
-
|
|
49
|
-
for (const relation of collection.relations ?? []) {
|
|
50
|
-
const resolved = resolveRelation(relation, collection);
|
|
51
|
-
relations[resolved.relationName] = resolved;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// A property declaring a relation inline is registered under the property
|
|
55
|
-
// key as well: the fetch layer hydrates the result back onto that key, and
|
|
56
|
-
// it is the name the admin addresses the field by.
|
|
57
|
-
for (const [propertyKey, property] of Object.entries(collection.properties ?? {})) {
|
|
58
|
-
if ((property as Property)?.type !== "relation") continue;
|
|
59
|
-
const declared = (property as RelationProperty).relation;
|
|
60
|
-
if (!declared || relations[propertyKey]) continue;
|
|
61
|
-
|
|
62
|
-
relations[propertyKey] = resolveRelation(declared, collection, propertyKey);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
_resolvedRelationsCache.set(collection, relations);
|
|
66
|
-
return relations;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* The path of the collection a relation property points at, derived from the
|
|
71
|
-
* property alone.
|
|
72
|
-
*
|
|
73
|
-
* A preview holds a property and a value and no collection, so it cannot call
|
|
74
|
-
* `resolveRelationProperty`. It does not need to: both forms that carry a
|
|
75
|
-
* target — the stamped `resolvedRelation` and the inline `relation` — name it
|
|
76
|
-
* directly. Only the third form, a relation declared by name in the
|
|
77
|
-
* collection's `relations` array, is out of reach, and that one has no target
|
|
78
|
-
* to read without the collection anyway.
|
|
79
|
-
*
|
|
80
|
-
* This is what lets a preview render a relation column that arrived as a bare
|
|
81
|
-
* foreign key: the id says *which* row, the declared target says *which
|
|
82
|
-
* collection*, and `RelationPreview` fetches the rest. Without it a scalar id
|
|
83
|
-
* is indistinguishable from a value of the wrong type.
|
|
84
|
-
*/
|
|
85
|
-
export function getRelationTargetPath(property: RelationProperty): string | undefined {
|
|
86
|
-
const stamped = property.resolvedRelation?.targetSlug;
|
|
87
|
-
if (stamped) return stamped;
|
|
88
|
-
|
|
89
|
-
const target = property.relation?.target;
|
|
90
|
-
if (typeof target !== "function") return undefined;
|
|
91
|
-
try {
|
|
92
|
-
return target()?.slug;
|
|
93
|
-
} catch (_e) {
|
|
94
|
-
// A thunk reaching into a module that has not finished initialising:
|
|
95
|
-
// there is no target to name yet, and a preview is not worth throwing over.
|
|
96
|
-
return undefined;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export function getTableName(collection: CollectionConfig): string {
|
|
101
|
-
if (isRelationalCollectionConfig(collection)) {
|
|
102
|
-
return collection.table ?? toSnakeCase(collection.slug) ?? toSnakeCase(collection.name);
|
|
103
|
-
}
|
|
104
|
-
return toSnakeCase(collection.slug) ?? toSnakeCase(collection.name);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* A JavaScript identifier: what a generated `export const <name> =` needs.
|
|
109
|
-
*
|
|
110
|
-
* Deliberately the same shape the two schema generators already define
|
|
111
|
-
* privately — this is the third place that needed it, and the first two guard
|
|
112
|
-
* property keys and member accesses while nothing guarded the variable name
|
|
113
|
-
* itself.
|
|
114
|
-
*/
|
|
115
|
-
const JS_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* The variable name a generated table is bound to.
|
|
119
|
-
*
|
|
120
|
-
* Camel-cases underscores, and then guarantees the result is a legal
|
|
121
|
-
* identifier. It did only the first, so a table name that is legal in Postgres
|
|
122
|
-
* and not in JavaScript produced a `schema.generated.ts` that does not parse:
|
|
123
|
-
*
|
|
124
|
-
* `2024_archive` → `export const 2024Archive = pgTable(…)`
|
|
125
|
-
* "An identifier or keyword cannot immediately follow
|
|
126
|
-
* a numeric literal"
|
|
127
|
-
* `reporting.events` → `export const reporting.events = pgTable(…)`
|
|
128
|
-
* "',' expected"
|
|
129
|
-
*
|
|
130
|
-
* That file is imported by the server, so the failure is not one broken
|
|
131
|
-
* collection — `rebase build` and `db push` fail at tsc for the whole
|
|
132
|
-
* directory. And it is reachable from a documented flow: `rebase init` against
|
|
133
|
-
* a database holding a table called `2024_archive` writes a collection file
|
|
134
|
-
* that parses and a schema file that does not.
|
|
135
|
-
*
|
|
136
|
-
* **A no-op for every name that already worked**, which is what makes changing
|
|
137
|
-
* a derived name safe here: the only inputs whose output changes are the ones
|
|
138
|
-
* that produced a syntax error, and nothing can be running against those.
|
|
139
|
-
* Separators become camel case rather than disappearing, so `reporting.events`
|
|
140
|
-
* and `reporting_events` do not collide into one variable.
|
|
141
|
-
*/
|
|
142
|
-
export function getTableVarName(tableName: string): string {
|
|
143
|
-
const camel = tableName.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase());
|
|
144
|
-
if (JS_IDENTIFIER.test(camel)) return camel;
|
|
145
|
-
|
|
146
|
-
const sanitised = camel
|
|
147
|
-
// Any other separator gets the same treatment `_` did, so two tables
|
|
148
|
-
// differing only by separator keep differing.
|
|
149
|
-
.replace(/[^A-Za-z0-9_$]+([A-Za-z0-9])?/g, (_, char?: string) =>
|
|
150
|
-
(char ? char.toUpperCase() : ""))
|
|
151
|
-
// A leading digit is legal in Postgres and not in JavaScript. Prefixed
|
|
152
|
-
// rather than stripped, so `2024_archive` and `archive` stay distinct.
|
|
153
|
-
.replace(/^([0-9])/, "t$1");
|
|
154
|
-
|
|
155
|
-
return JS_IDENTIFIER.test(sanitised) ? sanitised : `t${sanitised}`;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
export function getEnumVarName(tableName: string, propName: string): string {
|
|
159
|
-
const tableVar = getTableVarName(tableName);
|
|
160
|
-
const propVar = propName.charAt(0).toUpperCase() + propName.slice(1);
|
|
161
|
-
return `${tableVar}${propVar}`;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export function getColumnName(fullColumn: string): string {
|
|
165
|
-
return fullColumn.includes(".") ? fullColumn.split(".").pop()! : fullColumn;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* The field key a database column is served and addressed under.
|
|
170
|
-
*
|
|
171
|
-
* A column has two names and they are not the same name. `author_id` is what
|
|
172
|
-
* Postgres stores; `authorId` is the key on the JSON row, the key in the
|
|
173
|
-
* generated Drizzle table, and the key a caller writes in `where` and
|
|
174
|
-
* `orderBy`. Every place that starts from a column and has to reach a row, a
|
|
175
|
-
* Drizzle table or a payload goes through here, so there is one answer rather
|
|
176
|
-
* than one per call site — the two that disagreed put `displayName` and
|
|
177
|
-
* `author_id` on the same API.
|
|
178
|
-
*
|
|
179
|
-
* A declared property is the authority when there is one, because its key *is*
|
|
180
|
-
* the wire name and `columnName` is the only thing that ever renamed the
|
|
181
|
-
* column:
|
|
182
|
-
*
|
|
183
|
-
* 1. an explicit `columnName` equal to this column;
|
|
184
|
-
* 2. a property whose key is literally the column (an author who wrote
|
|
185
|
-
* `author_id:` meant `author_id` on the wire, and gets it);
|
|
186
|
-
* 3. a property whose key snake-cases to the column, which is the default
|
|
187
|
-
* mapping — `authorId` → `author_id`.
|
|
188
|
-
*
|
|
189
|
-
* With no property in the way — a foreign key derived from a relation, which
|
|
190
|
-
* usually has none — the name is derived: {@link toWireKey}.
|
|
191
|
-
*
|
|
192
|
-
* Note the fallback is *not* the column verbatim. That was the old behaviour
|
|
193
|
-
* and it is precisely the defect: a derived foreign key reached the wire under
|
|
194
|
-
* its column name while every hand-authored field beside it was camelCase.
|
|
195
|
-
*/
|
|
196
|
-
export function fieldKeyForColumn(collection: CollectionConfig | undefined, column: string): string {
|
|
197
|
-
const properties = collection?.properties;
|
|
198
|
-
if (properties) {
|
|
199
|
-
for (const [key, prop] of Object.entries(properties)) {
|
|
200
|
-
const columnName = (prop as { columnName?: unknown } | undefined)?.columnName;
|
|
201
|
-
if (typeof columnName === "string" && columnName === column) return key;
|
|
202
|
-
}
|
|
203
|
-
for (const key of Object.keys(properties)) {
|
|
204
|
-
if (key === column) return key;
|
|
205
|
-
if (toSnakeCase(key) === column) return key;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
return toWireKey(column);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Look up a relation by key with forgiving normalization.
|
|
213
|
-
*
|
|
214
|
-
* `resolveCollectionRelations` stores each relation under a single canonical
|
|
215
|
-
* key (no aliases). This helper tries the given key as-is, then falls back to
|
|
216
|
-
* slug form (underscores → hyphens) and snake_case form (hyphens → underscores)
|
|
217
|
-
* so that callers that receive a key from external input (URL path segments,
|
|
218
|
-
* user-provided config, etc.) can still find the right entry.
|
|
219
|
-
*/
|
|
220
|
-
export function findRelation(
|
|
221
|
-
resolvedRelations: Record<string, ResolvedRelation>,
|
|
222
|
-
key: string
|
|
223
|
-
): ResolvedRelation | undefined {
|
|
224
|
-
// Exact match first
|
|
225
|
-
if (resolvedRelations[key]) return resolvedRelations[key];
|
|
226
|
-
|
|
227
|
-
// Try slug form (e.g. "company_id" → "company-id")
|
|
228
|
-
const slugKey = key.replace(/_/g, "-");
|
|
229
|
-
if (slugKey !== key && resolvedRelations[slugKey]) return resolvedRelations[slugKey];
|
|
230
|
-
|
|
231
|
-
// Try snake_case form (e.g. "company-id" → "company_id")
|
|
232
|
-
const snakeKey = key.replace(/-/g, "_");
|
|
233
|
-
if (snakeKey !== key && resolvedRelations[snakeKey]) return resolvedRelations[snakeKey];
|
|
234
|
-
|
|
235
|
-
return undefined;
|
|
236
|
-
}
|