@rebasepro/common 0.13.0 → 0.13.1-canary.g249daa1

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.
@@ -1,4 +1,5 @@
1
1
  import {
2
+ DEFAULT_LIST_LIMIT,
2
3
  FilterValues,
3
4
  FieldPath,
4
5
  FindAllParams,
@@ -70,6 +71,35 @@ export class RebasePaginationError extends Error {
70
71
  export type PageFinder<M extends Record<string, unknown> = Record<string, unknown>> =
71
72
  (params: FindParams<M>) => Promise<FindResult<M>>;
72
73
 
74
+ /**
75
+ * Resolve `limit`/`offset`/`page` into the window a read will actually use.
76
+ *
77
+ * Lives here, next to the walk, for the reason at the top of this file: every
78
+ * transport has to mean the same thing by "page two". Four of them did not —
79
+ * the REST layer strode by {@link DEFAULT_LIST_LIMIT}, the local-first
80
+ * evaluator by {@link DEFAULT_PAGE_SIZE}, the in-process accessor by 20, and
81
+ * the published type documented a fourth number. Pages that overlap or skip
82
+ * rows are the mildest of those outcomes.
83
+ *
84
+ * `page` wins over `offset`, as {@link FindParams} documents. `driverOffset`
85
+ * is the value to hand a driver: it stays `undefined` when the caller named no
86
+ * offset, because keyset pagination seeks with a `where` clause and must not
87
+ * look like it is paging by offset.
88
+ */
89
+ export function resolveFindWindow(
90
+ params?: Pick<FindParams, "limit" | "offset" | "page">
91
+ ): { limit: number; offset: number; driverOffset: number | undefined } {
92
+ const limit = params?.limit ?? DEFAULT_LIST_LIMIT;
93
+ const offset = params?.page != null
94
+ ? Math.max(0, (params.page - 1) * limit)
95
+ : (params?.offset ?? 0);
96
+ return {
97
+ limit,
98
+ offset,
99
+ driverOffset: params?.page != null ? offset : params?.offset
100
+ };
101
+ }
102
+
73
103
  function normalizePageSize(raw: number | undefined): number {
74
104
  if (raw === undefined || !Number.isFinite(raw)) return DEFAULT_PAGE_SIZE;
75
105
  return Math.max(1, Math.floor(raw));
@@ -6,7 +6,8 @@ import {
6
6
  LogicalCondition,
7
7
  QueryBuilderInterface,
8
8
  WhereFilterOp,
9
- WhereValue
9
+ WhereValue,
10
+ type ComputedSortField
10
11
  } from "@rebasepro/types";
11
12
 
12
13
  export function or(...conditions: (FilterCondition | LogicalCondition)[]): LogicalCondition {
@@ -80,7 +81,7 @@ export class QueryBuilder<M extends Record<string, unknown> = Record<string, unk
80
81
  * @example
81
82
  * client.collection('users').orderBy('createdAt', 'desc').find()
82
83
  */
83
- orderBy(column: keyof M & string, direction: "asc" | "desc" = "asc"): this {
84
+ orderBy(column: (keyof M & string) | ComputedSortField, direction: "asc" | "desc" = "asc"): this {
84
85
  this.params.orderBy = [column, direction];
85
86
  return this;
86
87
  }
@@ -104,8 +105,29 @@ export class QueryBuilder<M extends Record<string, unknown> = Record<string, unk
104
105
  /**
105
106
  * Set a free-text search string if supported by the backend.
106
107
  */
107
- search(searchString: string): this {
108
+ search(searchString: string, options?: { explain?: boolean }): this {
108
109
  this.params.searchString = searchString;
110
+ if (options?.explain !== undefined) this.params.searchExplain = options.explain;
111
+ return this;
112
+ }
113
+
114
+ /**
115
+ * Order rows by nearest-neighbour distance to `vector`, closest first.
116
+ *
117
+ * Postgres only, over a property declared as `type: "vector"`. Rows come
118
+ * back with a `_distance`; `where` filters before the ordering.
119
+ */
120
+ vectorSearch(
121
+ property: string,
122
+ vector: number[],
123
+ options?: { distance?: "cosine" | "l2" | "inner_product"; threshold?: number }
124
+ ): this {
125
+ this.params.vectorSearch = {
126
+ property,
127
+ vector,
128
+ ...(options?.distance !== undefined && { distance: options.distance }),
129
+ ...(options?.threshold !== undefined && { threshold: options.threshold })
130
+ };
109
131
  return this;
110
132
  }
111
133
 
@@ -77,3 +77,59 @@ export function resolveDataSource(
77
77
  capabilities: getDataSourceCapabilities(engine)
78
78
  };
79
79
  }
80
+
81
+ /**
82
+ * Does a SQL toolchain own this collection's storage?
83
+ *
84
+ * "Owns the storage" means: something generates a table for it, pushes that
85
+ * table to a database, plans its RLS policies, and reports it as drifted when
86
+ * the two disagree. That is true of a Postgres collection and false of a
87
+ * Firestore or MongoDB one, whose documents live in a store Rebase never
88
+ * migrates — and the two were never told apart. Every stage of the SQL
89
+ * toolchain took "the collections" to mean *all* of them, so a Firestore
90
+ * collection declared next to the Postgres ones got a `pgTable` in the
91
+ * generated schema, a `CREATE TABLE` at boot, RLS policies, and a place in the
92
+ * `db push` include list — where its name shielding a same-named real table
93
+ * from Atlas's exclude list is the one that can lose data.
94
+ *
95
+ * The answer is the resolved engine's {@link DataSourceCapabilities}, not a
96
+ * name check: an engine registered through `registerDataSourceCapabilities`
97
+ * gets the same treatment as the built-in ones.
98
+ *
99
+ * Deliberately answers **true** for an engine nobody has heard of. Build-time
100
+ * tooling (the CLI, the schema generator) has no data-source registry to
101
+ * resolve a `dataSource` key against, so an unknown key resolves to an unknown
102
+ * engine — and the cost of the two mistakes is not symmetric. Wrongly
103
+ * including a collection generates a table nothing writes to; wrongly excluding
104
+ * one silently stops generating a table the app is serving from. Declare
105
+ * `engine` on a collection that is not SQL-backed and this is exact.
106
+ */
107
+ export function isRelationalCollection(
108
+ collection: DataSourceResolvable | undefined,
109
+ registry?: DataSourceRegistry
110
+ ): boolean {
111
+ // The collection's own `engine` wins over a registered definition's. That
112
+ // is the opposite of {@link resolveDataSource}'s precedence, deliberately:
113
+ // there a definition describes where the data *goes*, so it should override;
114
+ // here the question is what the author said this collection is, and a
115
+ // collection declaring `engine: "firestore"` with no `dataSource` must not
116
+ // come back as the default source's engine and be handed a table.
117
+ const engine = collection?.engine
118
+ ?? (collection?.dataSource ? resolveDataSource(collection, registry).engine : undefined);
119
+ return getDataSourceCapabilities(engine).supportsRelations;
120
+ }
121
+
122
+ /**
123
+ * The subset of `collections` a SQL toolchain owns — see
124
+ * {@link isRelationalCollection}.
125
+ *
126
+ * Every stage that generates SQL from collections starts by calling this, so
127
+ * the rule lives in one place rather than being re-decided per generator. It
128
+ * keeps the input order.
129
+ */
130
+ export function relationalCollections<C extends DataSourceResolvable>(
131
+ collections: readonly C[],
132
+ registry?: DataSourceRegistry
133
+ ): C[] {
134
+ return collections.filter(collection => isRelationalCollection(collection, registry));
135
+ }
@@ -14,14 +14,14 @@ import {
14
14
  // ── defineCollection ─────────────────────────────────────────────────────
15
15
  // A smarter builder that uses `const` type-parameter inference (TS 5.0+)
16
16
  // to capture literal property types automatically. This gives you
17
- // autocomplete on `titleProperty`, `sort`, `propertiesOrder`, `fixedFilter`,
17
+ // autocomplete on `display.title`, `sort`, `propertiesOrder`, `fixedFilter`,
18
18
  // callbacks, etc. — without writing `as const` or passing manual generics.
19
19
 
20
20
  /**
21
21
  * Define a PostgreSQL-backed collection with full type inference.
22
22
  *
23
23
  * The `const P` generic captures literal property types from your
24
- * `properties` object, which enables autocomplete on `titleProperty`,
24
+ * `properties` object, which enables autocomplete on `display.title`,
25
25
  * `sort`, `propertiesOrder`, `fixedFilter`, and entity callbacks.
26
26
  *
27
27
  * @example
@@ -34,7 +34,7 @@ import {
34
34
  * name: { name: "Name", type: "string", validation: { required: true } },
35
35
  * price: { name: "Price", type: "number" },
36
36
  * },
37
- * titleProperty: "name", // ✅ autocomplete: "name" | "price"
37
+ * display: { title: "name" }, // ✅ autocomplete: "name" | "price"
38
38
  * sort: ["price", "asc"], // ✅ autocomplete on first element
39
39
  * });
40
40
  * ```
@@ -3,8 +3,8 @@ import {
3
3
  ArrayProperty,
4
4
  AuthState,
5
5
  ConditionContext,
6
+ ConditionRule,
6
7
  EnumValueConfig,
7
- JsonLogicRule,
8
8
  NumberProperty,
9
9
  PropertyConditions,
10
10
  Property,
@@ -66,9 +66,14 @@ export function registerConditionOperations(): void {
66
66
  }
67
67
 
68
68
  /**
69
- * Evaluate a JSON Logic rule against the given context.
69
+ * Evaluate a condition against the given context.
70
+ *
71
+ * A condition may be stated as a literal instead of a rule — `hidden: true`
72
+ * rather than `hidden: { "==": [1, 1] }` — and a literal is already its own
73
+ * answer, so it is returned rather than handed to the evaluator.
70
74
  */
71
- export function evaluateCondition(rule: JsonLogicRule, context: ConditionContext): unknown {
75
+ export function evaluateCondition(rule: ConditionRule, context: ConditionContext): unknown {
76
+ if (typeof rule === "boolean") return rule;
72
77
  // Ensure operations are registered
73
78
  registerConditionOperations();
74
79
  return jsonLogic.apply(rule, context);
@@ -143,10 +143,24 @@ export function getRelationFrom<M extends Record<string, unknown>>(entity: Entit
143
143
  * have `id` and `path` fields — these are relation-shaped objects from
144
144
  * edge cases in the data pipeline (REST fallback, stale cache, custom data source).
145
145
  *
146
+ * When `targetPath` is given, also accepts a bare id. A relation column is a
147
+ * foreign key, and the REST layer returns it as the scalar it is; only some
148
+ * fetch paths hydrate it into an object. Which form a caller sees therefore
149
+ * depends on how the row was loaded, and a caller that only accepted objects
150
+ * reported half of its own data as a type error. The declared target is the
151
+ * missing half: with it, an id is a relation that has not been fetched yet.
152
+ *
146
153
  * Returns null if the value cannot be coerced.
147
154
  */
148
- export function normalizeToEntityRelation(value: unknown, propertyType?: string): EntityRelation | null {
155
+ export function normalizeToEntityRelation(value: unknown, propertyType?: string, targetPath?: string): EntityRelation | null {
149
156
  if (value instanceof EntityRelation) return value;
157
+
158
+ if (targetPath && (typeof value === "string" || typeof value === "number")) {
159
+ // An empty string is an unset foreign key, not row "".
160
+ if (value === "") return null;
161
+ return new EntityRelation(value, targetPath);
162
+ }
163
+
150
164
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
151
165
 
152
166
  const obj = value as Record<string, unknown>;
package/src/util/index.ts CHANGED
@@ -18,3 +18,4 @@ export * from "./junction-policies";
18
18
  export * from "./conditions";
19
19
  export * from "./pg-column-to-property";
20
20
  export * from "./string-column-length";
21
+ export * from "./internal-tables";
@@ -0,0 +1,154 @@
1
+ /**
2
+ * The tables Rebase creates for its own bookkeeping, and the SQL that keeps the
3
+ * end-user role away from them.
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * Authenticated requests run as {@link REBASE_USER_ROLE}, and the boot-time role
8
+ * provisioning grants that role `SELECT, INSERT, UPDATE, DELETE` on every table
9
+ * in the schemas a project uses — including `rebase`, because a project's own
10
+ * collections are allowed to live there (the scaffold puts `users` there). It
11
+ * also sets `ALTER DEFAULT PRIVILEGES`, so a table created *later* by the
12
+ * migrating role inherits the same grant.
13
+ *
14
+ * Every framework-internal table is created later: auth's tables come up during
15
+ * `initializeAuth`, `api_keys` during route mounting, `cron_logs` when the first
16
+ * job registers, `idempotency_keys` on the first request that carries a key. So
17
+ * they all inherited full DML for the end-user role — and none of them enables
18
+ * row-level security, because none of them is a collection with
19
+ * `securityRules`. Measured on a freshly provisioned database, `SET ROLE
20
+ * rebase_user` could read `rebase.refresh_tokens` (session token hashes),
21
+ * `rebase.mfa_factors` (`secret_encrypted`), `rebase.recovery_codes`, and
22
+ * `rebase.api_keys` (including its `admin` flag), and insert into
23
+ * `rebase.app_config`.
24
+ *
25
+ * Nothing routes a user-context query at those tables today, so this was not
26
+ * reachable over the API. That is the wrong thing to depend on: the documented
27
+ * model is that RLS is the authorization boundary, and these tables sat outside
28
+ * it. The boundary is now a privilege boundary instead — the role simply cannot
29
+ * address them.
30
+ *
31
+ * ## Why REVOKE rather than ENABLE ROW LEVEL SECURITY
32
+ *
33
+ * RLS with no policy denies every row, which is the same outcome, but it is the
34
+ * *weaker* statement: it leaves the grant in place, so a later policy — or a
35
+ * `FORCE` flag cleared by some future migration — reopens the table. There is no
36
+ * row of `refresh_tokens` any end user should ever reach, so the honest encoding
37
+ * is "this role has no privilege here at all". It also keeps the owner
38
+ * connection (which auth actually runs on) completely unaffected.
39
+ *
40
+ * ## Keeping it true
41
+ *
42
+ * `packages/rls-check` scans the `rebase` schema — it used to skip it as a
43
+ * "platform" schema — and its `rls-disabled` check fires on exactly the
44
+ * condition this module removes: RLS off *and* a DML grant to a reachable role.
45
+ * So a table added here without a revoke is caught by `pnpm rls:check`, not by
46
+ * someone re-reading this file.
47
+ */
48
+
49
+ /**
50
+ * The Postgres role authenticated requests run as.
51
+ *
52
+ * Defined here rather than in the Postgres driver because both the driver (which
53
+ * provisions the role) and this module (which revokes on its behalf) need it,
54
+ * and a second spelling of a role name is a silent no-op waiting to happen.
55
+ */
56
+ export const REBASE_USER_ROLE = "rebase_user";
57
+
58
+ /**
59
+ * Framework-internal table names, unqualified.
60
+ *
61
+ * Deliberately NOT including `users`: the auth user table is also a collection,
62
+ * with `securityRules`, RLS enabled and policies applied. Users read their own
63
+ * row through it — revoking there would break sign-in.
64
+ *
65
+ * `atlas_schema_revisions` is Atlas's migration ledger, which lands in `rebase`
66
+ * because `db migrate apply` passes `--revisions-schema rebase`.
67
+ */
68
+ export const REBASE_INTERNAL_TABLES: readonly string[] = [
69
+ // auth
70
+ "user_identities",
71
+ "refresh_tokens",
72
+ "password_reset_tokens",
73
+ "magic_link_tokens",
74
+ "mfa_factors",
75
+ "mfa_challenges",
76
+ "recovery_codes",
77
+ "app_config",
78
+ "schema_meta",
79
+ // platform services
80
+ "api_keys",
81
+ "cron_logs",
82
+ "cron_claims",
83
+ "idempotency_keys",
84
+ "entity_history",
85
+ "branches",
86
+ // realtime channels — authorization for these lives in the channel rules the
87
+ // server evaluates before it reads or writes, never in a row policy
88
+ "channel_messages",
89
+ "channel_cursors",
90
+ "channel_presence",
91
+ // migration bookkeeping
92
+ "atlas_schema_revisions"
93
+ ];
94
+
95
+ /** Postgres identifiers this module is willing to interpolate. */
96
+ const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;
97
+
98
+ /**
99
+ * A single statement that takes every privilege on `schema.table` away from the
100
+ * end-user role.
101
+ *
102
+ * Wrapped in a `DO` block guarded on `pg_roles` for two reasons, both of which
103
+ * happen in practice:
104
+ *
105
+ * - the role does not exist when the connection is unprivileged (Rebase then
106
+ * relies on native RLS rather than a role switch), and a bare `REVOKE` on a
107
+ * missing role is an error, not a no-op;
108
+ * - the table may not exist yet — `cron_logs` never appears in a project with
109
+ * no cron jobs — and `to_regclass` returning NULL has to be tolerated too.
110
+ *
111
+ * One command, so it is safe on handles that speak the extended query protocol
112
+ * and reject multi-statement strings.
113
+ */
114
+ export function revokeInternalTableSql(schema: string, table: string): string {
115
+ if (!SAFE_IDENTIFIER.test(schema)) {
116
+ throw new Error(`Refusing to build SQL with an unsafe schema name: ${JSON.stringify(schema)}`);
117
+ }
118
+ if (!SAFE_IDENTIFIER.test(table)) {
119
+ throw new Error(`Refusing to build SQL with an unsafe table name: ${JSON.stringify(table)}`);
120
+ }
121
+ const qualified = `"${schema}"."${table}"`;
122
+ return `
123
+ DO $rebase_revoke$
124
+ BEGIN
125
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}')
126
+ AND to_regclass('${qualified}') IS NOT NULL THEN
127
+ EXECUTE 'REVOKE ALL ON ${qualified} FROM ${REBASE_USER_ROLE}';
128
+ END IF;
129
+ END
130
+ $rebase_revoke$;
131
+ `.trim();
132
+ }
133
+
134
+ /**
135
+ * Revoke on every internal table in `schema`, one statement at a time.
136
+ *
137
+ * Best-effort per table: a connection that does not own one of them (a
138
+ * pre-provisioned database, a platform-managed ledger) cannot revoke on it, and
139
+ * that must not take down a boot. The caller decides how loud to be — `onError`
140
+ * exists so the driver can warn without this module importing a logger.
141
+ */
142
+ export async function revokeInternalTableAccess(
143
+ execute: (sql: string) => Promise<unknown>,
144
+ schema: string,
145
+ options?: { tables?: readonly string[]; onError?: (table: string, error: unknown) => void }
146
+ ): Promise<void> {
147
+ for (const table of options?.tables ?? REBASE_INTERNAL_TABLES) {
148
+ try {
149
+ await execute(revokeInternalTableSql(schema, table));
150
+ } catch (error) {
151
+ options?.onError?.(table, error);
152
+ }
153
+ }
154
+ }
@@ -1,4 +1,4 @@
1
- import { ANONYMOUS_USER_IDS, CollectionConfig, PolicyExpression, PolicyOperand, PolicyCompareOperator, Property, ExistsInPolicyExpression } from "@rebasepro/types";
1
+ import { ANONYMOUS_USER_IDS, CollectionConfig, PolicyExpression, PolicyOperand, PolicyCompareOperator, Property, ExistsInPolicyExpression, RLS_ROLES_SQL, RLS_UID_SQL, rewriteLegacyRlsFunctions } from "@rebasepro/types";
2
2
  import { toSnakeCase } from "@rebasepro/utils";
3
3
  import { getTableName } from "../relations";
4
4
 
@@ -70,7 +70,7 @@ function compile(expr: PolicyExpression, scope: CompileScope): string {
70
70
  case "not":
71
71
  return `NOT (${compile(expr.operand, scope)})`;
72
72
  case "compare": {
73
- // `auth.uid()` returns text; cast the column side so uuid / integer
73
+ // `rebase.uid()` returns text; cast the column side so uuid / integer
74
74
  // id columns compare cleanly instead of failing with
75
75
  // "operator does not exist: uuid = text" at CREATE POLICY time.
76
76
  const castForAuthUid = (operand: PolicyOperand, sqlText: string, other: PolicyOperand): string =>
@@ -82,9 +82,9 @@ function compile(expr: PolicyExpression, scope: CompileScope): string {
82
82
  return `${leftSql} ${COMPARE_SQL[expr.op]} ${rightSql}`;
83
83
  }
84
84
  case "rolesOverlap":
85
- return `string_to_array(auth.roles(), ',') && ${rolesArraySql(expr.roles)}`;
85
+ return `string_to_array(${RLS_ROLES_SQL}, ',') && ${rolesArraySql(expr.roles)}`;
86
86
  case "rolesContain":
87
- return `string_to_array(auth.roles(), ',') @> ${rolesArraySql(expr.roles)}`;
87
+ return `string_to_array(${RLS_ROLES_SQL}, ',') @> ${rolesArraySql(expr.roles)}`;
88
88
  case "authenticated":
89
89
  // `IS NOT NULL` alone is a tautology on the user path: every
90
90
  // user-context request sets `app.uid`, and an anonymous one sets
@@ -96,19 +96,32 @@ function compile(expr: PolicyExpression, scope: CompileScope): string {
96
96
  // policy compiled here may be enforced against an older server that
97
97
  // still reports `'anon'`, which is exactly how excluding one
98
98
  // spelling turned this helper into a grant. See ANONYMOUS_USER_IDS.
99
- return `auth.uid() IS NOT NULL AND auth.uid() NOT IN (${ANONYMOUS_USER_IDS.map(quoteLiteral).join(", ")})`;
99
+ return `${RLS_UID_SQL} IS NOT NULL AND ${RLS_UID_SQL} NOT IN (${ANONYMOUS_USER_IDS.map(quoteLiteral).join(", ")})`;
100
100
  case "serverContext":
101
101
  // Only the built-in server flows leave `app.uid` unset.
102
- return "auth.uid() IS NULL";
102
+ return `${RLS_UID_SQL} IS NULL`;
103
103
  case "existsIn":
104
104
  return compileExistsIn(expr, scope);
105
- case "raw":
105
+ case "raw": {
106
+ // A project written against a pre-1.0 release may still spell the
107
+ // helpers `auth.uid()`. Rewritten rather than rejected: the rule
108
+ // means exactly the same thing, the developer cannot be expected to
109
+ // have read a changelog mid-deploy, and the alternative is a policy
110
+ // that compiles cleanly and then denies every row at runtime because
111
+ // it calls a function that no longer exists.
112
+ //
113
+ // The counterpart is `warnOnLegacyRlsFunctions`, which says so once
114
+ // at boot with the file to edit — silence here would leave the old
115
+ // spelling working forever and make the migration permanent.
116
+ const sqlText = rewriteLegacyRlsFunctions(expr.sql);
117
+
106
118
  // Full-power escape hatch: `{column}` denotes a column of the outer
107
119
  // RLS row. It must be table-qualified, not bare: raw SQL may open its
108
120
  // own subquery over the same table, and there a bare name binds to the
109
121
  // inner scope, collapsing `m.x = {x}` into the tautology `m.x = m.x`.
110
- return expr.sql.replace(/\{(\w+)\}/g, (_, col) =>
122
+ return sqlText.replace(/\{(\w+)\}/g, (_, col) =>
111
123
  `${outerQualifier(scope)}${resolveColumnName(col, scope.outerCollection)}`);
124
+ }
112
125
  }
113
126
  }
114
127
 
@@ -156,9 +169,9 @@ function operandToSql(operand: PolicyOperand, scope: CompileScope): string {
156
169
  case "literal":
157
170
  return quoteLiteral(operand.value);
158
171
  case "authUid":
159
- return "auth.uid()";
172
+ return RLS_UID_SQL;
160
173
  case "authRoles":
161
- return "string_to_array(auth.roles(), ',')";
174
+ return `string_to_array(${RLS_ROLES_SQL}, ',')`;
162
175
  }
163
176
  }
164
177
 
@@ -1,4 +1,4 @@
1
- import { ANONYMOUS_USER_ID, ANONYMOUS_USER_IDS, LiteralPolicyOperand, PolicyExpression, policy } from "@rebasepro/types";
1
+ import { ANONYMOUS_USER_ID, ANONYMOUS_USER_IDS, LiteralPolicyOperand, PolicyExpression, policy, rewriteLegacyRlsFunctions } from "@rebasepro/types";
2
2
 
3
3
  /**
4
4
  * A tiny, regex-based SQL "parser" for security rules.
@@ -38,9 +38,9 @@ function isKeywordAt(upper: string, i: number, keyword: string): boolean {
38
38
  *
39
39
  * This used to be `sql.split(/ AND /i)`, which tore subqueries in half: the
40
40
  * `AND` inside
41
- * `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id = auth.uid())`
41
+ * `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id = rebase.uid())`
42
42
  * split the expression, and re-emitting the halves produced
43
- * `(EXISTS (...) AND m.user_id = auth.uid())`
43
+ * `(EXISTS (...) AND m.user_id = rebase.uid())`
44
44
  * where `m` is no longer in scope — SQL that Postgres rejects outright with
45
45
  * "missing FROM-clause entry for table". Returning null instead keeps such a
46
46
  * clause as a `raw` expression, which round-trips verbatim.
@@ -107,22 +107,32 @@ function stripOuterParens(sql: string): string {
107
107
  }
108
108
 
109
109
  export function sqlToPolicy(sql: string): PolicyExpression {
110
- const trimmed = stripOuterParens(sql.trim());
110
+ // Normalised before anything else looks at it, so every pattern below only
111
+ // has to know the current spelling. A database migrated by a pre-1.0 release
112
+ // still holds `auth.uid()` in its policy bodies until the next push or boot
113
+ // recompiles them — and until then the admin UI reads those bodies back
114
+ // through here. Without this they parse as opaque `raw`, and the framework's
115
+ // own policies get badged as hand-written drift.
116
+ //
117
+ // Normalising rather than accepting both spellings throughout is deliberate:
118
+ // it also means a legacy policy that falls through to `raw` is stored in the
119
+ // new spelling, so editing and saving one in the Studio migrates it.
120
+ const trimmed = stripOuterParens(rewriteLegacyRlsFunctions(sql).trim());
111
121
 
112
122
  if (trimmed.toLowerCase() === "true") return policy.true();
113
123
  if (trimmed.toLowerCase() === "false") return policy.false();
114
124
 
115
125
  // Handle roles overlap (&&)
116
- // Matches: string_to_array(auth.roles(), ',') && ARRAY['admin', 'editor']
117
- const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\s*\[(.+)\]$/i);
126
+ // Matches: string_to_array(rebase.roles(), ',') && ARRAY['admin', 'editor']
127
+ const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\s*\[(.+)\]$/i);
118
128
  if (overlapMatch) {
119
129
  const roles = overlapMatch[1].split(",").map(s => s.trim().replace(/^'|'$/g, ""));
120
130
  return policy.rolesOverlap(roles);
121
131
  }
122
132
 
123
133
  // Handle roles containment (@>)
124
- // Matches: string_to_array(auth.roles(), ',') @> ARRAY['admin']
125
- const containMatch = trimmed.match(/^string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\s*\[(.+)\]$/i);
134
+ // Matches: string_to_array(rebase.roles(), ',') @> ARRAY['admin']
135
+ const containMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\s*\[(.+)\]$/i);
126
136
  if (containMatch) {
127
137
  const roles = containMatch[1].split(",").map(s => s.trim().replace(/^'|'$/g, ""));
128
138
  return policy.rolesContain(roles);
@@ -146,12 +156,15 @@ export function sqlToPolicy(sql: string): PolicyExpression {
146
156
  }
147
157
  }
148
158
 
149
- // Fallback to raw
150
- return policy.raw(sql);
159
+ // Fallback to raw — the NORMALISED text, not the input. Storing the input
160
+ // verbatim would mean a legacy policy read out of a database, edited in the
161
+ // Studio and saved, writes `auth.uid()` back into the project's config: a
162
+ // call to a function 1.0 no longer creates.
163
+ return policy.raw(trimmed);
151
164
  }
152
165
 
153
166
  /**
154
- * Literals from other BaaS platforms that people compare `auth.uid()` against
167
+ * Literals from other BaaS platforms that people compare `rebase.uid()` against
155
168
  * out of habit. Mirrors the driver's `FOREIGN_CONVENTION_ROLES` guard on
156
169
  * `pgRoles`, one surface over: the same muscle memory inside a `using:` string
157
170
  * is the more dangerous spelling, because it inverts a rule instead of
@@ -173,19 +186,26 @@ export interface AnonymousGrantRisk {
173
186
  explanation: string;
174
187
  }
175
188
 
176
- /** `auth.uid() IS NOT NULL` in raw SQL, the clause that is always true. */
177
- const UID_NOT_NULL = /auth\.uid\(\)\s+IS\s+NOT\s+NULL/i;
189
+ /**
190
+ * `rebase.uid() IS NOT NULL` in raw SQL, the clause that is always true.
191
+ *
192
+ * Both schema spellings, because this runs over policy bodies read back from a
193
+ * database, and one migrated by a pre-1.0 release still holds `auth.uid()`.
194
+ * A security check that stops recognising a dangerous clause because the
195
+ * framework renamed a function is a check that silently turns off.
196
+ */
197
+ const UID_NOT_NULL = /\b(?:rebase|auth)\.uid\(\)\s+IS\s+NOT\s+NULL/i;
178
198
 
179
199
  /**
180
200
  * Find clauses that read as "signed-in users only" but admit anonymous callers.
181
201
  *
182
- * Both spellings come from the same place — Supabase, where `auth.uid()` really
183
- * is NULL for an anonymous request. Rebase substitutes
202
+ * Both spellings come from the same place — Supabase, where its own `auth.uid()`
203
+ * really is NULL for an anonymous request. Rebase substitutes
184
204
  * {@link ANONYMOUS_USER_ID} instead (a blank id would read back as NULL, which
185
205
  * is how the trusted *server* context is recognised), so:
186
206
  *
187
- * - `auth.uid() IS NOT NULL` is a tautology on the user path, and
188
- * - `auth.uid() != 'anon'` excludes one spelling of anonymous and admits the
207
+ * - `rebase.uid() IS NOT NULL` is a tautology on the user path, and
208
+ * - `rebase.uid() != 'anon'` excludes one spelling of anonymous and admits the
189
209
  * other. This one is not hypothetical and was not only a foreign habit:
190
210
  * rebase's own request path reported `'anon'` while everything that compiled
191
211
  * or checked a policy used `'anonymous'`, so whichever literal an author
@@ -219,7 +239,7 @@ export function findAnonymousGrants(expr: PolicyExpression): AnonymousGrantRisk[
219
239
  found.push({
220
240
  pattern: "uid-not-null",
221
241
  detail: e.sql,
222
- explanation: "`auth.uid() IS NOT NULL` is true for every request that came from a client, " +
242
+ explanation: "`rebase.uid() IS NOT NULL` is true for every request that came from a client, " +
223
243
  `including anonymous ones — they carry '${ANONYMOUS_USER_ID}', not NULL. ` +
224
244
  "Use `condition: policy.authenticated()` to mean \"signed in\"."
225
245
  });
@@ -252,11 +272,11 @@ export function findAnonymousGrants(expr: PolicyExpression): AnonymousGrantRisk[
252
272
  }
253
273
 
254
274
  function parseOperand(str: string) {
255
- // current_setting('app.uid') or auth.uid(). `app.user_id` is the
275
+ // current_setting('app.uid') or rebase.uid(). `app.user_id` is the
256
276
  // pre-rename spelling and stays parseable: policies are data, so a
257
277
  // database provisioned before the rename still holds rules written
258
278
  // against it, and round-tripping one must not silently drop the operand.
259
- if (/current_setting\s*\(\s*'app\.(uid|user_id)'\s*\)/i.test(str) || /auth\.uid\(\)/i.test(str)) {
279
+ if (/current_setting\s*\(\s*'app\.(uid|user_id)'\s*\)/i.test(str) || /rebase\.uid\(\)/i.test(str)) {
260
280
  return policy.authUid();
261
281
  }
262
282
 
@@ -66,6 +66,37 @@ export function resolveCollectionRelations(
66
66
  return relations;
67
67
  }
68
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
+
69
100
  export function getTableName(collection: CollectionConfig): string {
70
101
  if (isRelationalCollectionConfig(collection)) {
71
102
  return collection.table ?? toSnakeCase(collection.slug) ?? toSnakeCase(collection.name);