@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g18cfeb7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/{src-DlPBctw_.js → auth-users-columns-Do9mw5Y5.js} +318 -42
  3. package/dist/auth-users-columns-Do9mw5Y5.js.map +1 -0
  4. package/dist/{backup-service-CD8o_1Sl.js → backup-service-Bww-Lg0s.js} +2 -2
  5. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-Bww-Lg0s.js.map} +1 -1
  6. package/dist/cli-helpers.d.ts +1 -1
  7. package/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-DMUdRQdM.js} +2 -2
  8. package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-DMUdRQdM.js.map} +1 -1
  9. package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DSIxvLLD.js} +59 -15
  10. package/dist/ensure-collection-tables-DSIxvLLD.js.map +1 -0
  11. package/dist/index.es.js +534 -200
  12. package/dist/index.es.js.map +1 -1
  13. package/dist/{policy-CeA1JcxP.js → policy-CPkCqVTz.js} +4 -4
  14. package/dist/policy-CPkCqVTz.js.map +1 -0
  15. package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
  16. package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
  17. package/dist/schema/auth-users-columns.d.ts +97 -0
  18. package/dist/schema/ensure-collection-tables.d.ts +1 -1
  19. package/dist/schema/generate-drizzle-schema-logic.d.ts +1 -1
  20. package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -5
  21. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  22. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  23. package/dist/security/rls-enforcement.d.ts +53 -2
  24. package/dist/services/FetchService.d.ts +10 -7
  25. package/dist/services/dataService.d.ts +2 -0
  26. package/dist/services/realtimeService.d.ts +25 -21
  27. package/dist/{src-DoU9yPqq.js → src-C_wvdMnl.js} +91 -2
  28. package/dist/src-C_wvdMnl.js.map +1 -0
  29. package/dist/{websocket-B2LsrINK.js → websocket-D0TBU3ia.js} +3 -3
  30. package/dist/{websocket-B2LsrINK.js.map → websocket-D0TBU3ia.js.map} +1 -1
  31. package/package.json +9 -8
  32. package/src/PostgresBackendDriver.ts +165 -3
  33. package/src/PostgresBootstrapper.ts +41 -2
  34. package/src/auth/ensure-tables.ts +185 -86
  35. package/src/cli-helpers.ts +22 -9
  36. package/src/cli.ts +175 -30
  37. package/src/collections/validate-relations.ts +124 -17
  38. package/src/data-transformer.ts +13 -3
  39. package/src/history/ensure-history-table.ts +7 -0
  40. package/src/schema/auth-users-columns.ts +131 -0
  41. package/src/schema/doctor.ts +7 -5
  42. package/src/schema/ensure-collection-tables.ts +88 -19
  43. package/src/schema/generate-drizzle-schema-logic.ts +8 -2
  44. package/src/schema/generate-postgres-ddl-logic.ts +97 -13
  45. package/src/schema/generated-schema-staleness.ts +169 -0
  46. package/src/schema/introspect-db-logic.ts +1 -1
  47. package/src/schema/non-sql-collections.test.ts +131 -0
  48. package/src/schema/rls-bootstrap-sql.ts +288 -0
  49. package/src/security/anonymous-grants.test.ts +4 -2
  50. package/src/security/rls-enforcement.ts +141 -3
  51. package/src/services/BranchService.ts +5 -0
  52. package/src/services/FetchService.ts +10 -93
  53. package/src/services/PersistService.ts +14 -1
  54. package/src/services/channel-history.ts +8 -0
  55. package/src/services/channel-presence.ts +6 -0
  56. package/src/services/dataService.ts +2 -0
  57. package/src/services/realtimeService.ts +36 -33
  58. package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
  59. package/dist/policy-CeA1JcxP.js.map +0 -1
  60. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  61. package/dist/src-DlPBctw_.js.map +0 -1
  62. package/dist/src-DoU9yPqq.js.map +0 -1
  63. package/src/schema/auth-bootstrap-sql.ts +0 -47
@@ -0,0 +1,131 @@
1
+ /**
2
+ * A collection served by another engine is not this engine's to provision.
3
+ *
4
+ * Firestore and MongoDB collections are declared in the same
5
+ * `config/collections` directory as the Postgres ones — that is what
6
+ * `dataSource` routing is for — and every stage of the SQL toolchain used to
7
+ * read "the collections" as all of them. So a Firestore collection got a
8
+ * `pgTable` in the generated schema, a `CREATE TABLE` at boot, RLS policies,
9
+ * and an entry in the `db push` include list, while the app went on reading its
10
+ * documents from Firestore.
11
+ *
12
+ * The include list is the one that can lose data: a name on it is a name Atlas
13
+ * is *allowed to drop*, so a Firestore collection called `exercises` removed a
14
+ * real, unrelated `public.exercises` table's protection from the next
15
+ * auto-approved `db push`.
16
+ */
17
+ import { describe, expect, it } from "@jest/globals";
18
+ import type { CollectionConfig } from "@rebasepro/types";
19
+
20
+ import { generateSchema } from "./generate-drizzle-schema-logic";
21
+ import {
22
+ generatePostgresDdl,
23
+ generatePostgresPoliciesDdl,
24
+ planCollectionPolicies
25
+ } from "./generate-postgres-ddl-logic";
26
+ import { planCollectionSchemaEnsure, type ExistingSchema } from "./ensure-collection-tables";
27
+ import { getTableIncludesFromCollections } from "../cli-helpers";
28
+
29
+ const sqlCollection = {
30
+ name: "Customers",
31
+ slug: "customers",
32
+ properties: {
33
+ id: { name: "ID", type: "string", isId: "uuid" },
34
+ email: { name: "Email", type: "string" }
35
+ },
36
+ securityRules: [{ operation: "read", policy: "true" }]
37
+ } as unknown as CollectionConfig;
38
+
39
+ const firestoreCollection = {
40
+ name: "Exercises",
41
+ slug: "exercises",
42
+ engine: "firestore",
43
+ dataSource: "firestore",
44
+ properties: {
45
+ id: { name: "ID", type: "string" },
46
+ title: { name: "Title", type: "string" },
47
+ difficulty: {
48
+ name: "Difficulty",
49
+ type: "string",
50
+ enum: [{ id: "easy", label: "Easy" }, { id: "hard", label: "Hard" }]
51
+ }
52
+ }
53
+ } as unknown as CollectionConfig;
54
+
55
+ const mongoCollection = {
56
+ name: "Events",
57
+ slug: "events",
58
+ engine: "mongodb",
59
+ properties: { id: { name: "ID", type: "string" } }
60
+ } as unknown as CollectionConfig;
61
+
62
+ /** Routed by `dataSource` alone — no `engine` field to read. */
63
+ const dataSourceOnlyCollection = {
64
+ name: "Articles",
65
+ slug: "articles",
66
+ dataSource: "firestore",
67
+ properties: { id: { name: "ID", type: "string" } }
68
+ } as unknown as CollectionConfig;
69
+
70
+ const all = [sqlCollection, firestoreCollection, mongoCollection, dataSourceOnlyCollection];
71
+
72
+ describe("the Drizzle schema generator", () => {
73
+ it("emits a table for the Postgres collection only", async () => {
74
+ const schema = await generateSchema(all);
75
+
76
+ expect(schema).toContain("customers");
77
+ expect(schema).not.toContain("exercises");
78
+ expect(schema).not.toContain("events");
79
+ expect(schema).not.toContain("articles");
80
+ });
81
+
82
+ it("does not emit enum types for a document store's fields", async () => {
83
+ const schema = await generateSchema(all);
84
+ expect(schema).not.toContain("difficulty");
85
+ });
86
+ });
87
+
88
+ describe("the Postgres DDL generator", () => {
89
+ it("creates no table for a non-SQL collection", async () => {
90
+ const ddl = await generatePostgresDdl(all, { includePolicies: false });
91
+
92
+ expect(ddl).toContain('CREATE TABLE "public"."customers"');
93
+ expect(ddl).not.toContain("exercises");
94
+ expect(ddl).not.toContain("events");
95
+ });
96
+
97
+ it("plans no RLS for a store that has none", () => {
98
+ const policies = generatePostgresPoliciesDdl(all);
99
+ expect(policies).not.toContain("exercises");
100
+
101
+ const plans = planCollectionPolicies(all);
102
+ expect(plans.map(p => p.qualified)).toEqual(["public.customers"]);
103
+ });
104
+ });
105
+
106
+ describe("the boot-time schema ensure", () => {
107
+ const empty = (): ExistingSchema => ({ tables: new Map(), enums: new Set() });
108
+
109
+ it("creates nothing for collections another engine stores", () => {
110
+ const plan = planCollectionSchemaEnsure(all, empty());
111
+
112
+ expect(plan.actions.map(a => a.target)).toEqual(
113
+ expect.arrayContaining(["public.customers"])
114
+ );
115
+ for (const action of plan.actions) {
116
+ expect(action.target).not.toContain("exercises");
117
+ expect(action.target).not.toContain("events");
118
+ expect(action.target).not.toContain("articles");
119
+ }
120
+ });
121
+ });
122
+
123
+ describe("the db push include list", () => {
124
+ it("does not claim a table for a Firestore collection", async () => {
125
+ // Anything absent from this list is *excluded* from the declarative
126
+ // apply, which is what protects it from being dropped.
127
+ const includes = await getTableIncludesFromCollections(all);
128
+
129
+ expect(includes).toEqual(["public.customers"]);
130
+ });
131
+ });
@@ -0,0 +1,288 @@
1
+ import { LEGACY_RLS_SCHEMA, REBASE_SCHEMA } from "@rebasepro/types";
2
+
3
+ /**
4
+ * Canonical SQL bootstrap for the RLS helper functions.
5
+ *
6
+ * Generated RLS policies reference `rebase.uid()` / `rebase.roles()` /
7
+ * `rebase.jwt()`, so any SQL stream that can contain policies must be
8
+ * self-contained: it has to (re)create these helpers first. This matters for
9
+ * the migration directory in particular — Atlas replays migrations against a
10
+ * clean dev database where no out-of-band bootstrap has ever run, so a
11
+ * migration carrying policies without this preamble fails with
12
+ * "function rebase.uid() does not exist".
13
+ *
14
+ * Idempotent (`IF NOT EXISTS` / `OR REPLACE`) so it can be prepended to every
15
+ * policies block and re-applied freely. The runtime boot path
16
+ * (`auth/ensure-tables.ts`) creates the same functions under an advisory lock
17
+ * for HMR-safety; keep the definitions in sync.
18
+ *
19
+ * ## Creating the `rebase` schema here is now safe, and required
20
+ *
21
+ * It deliberately did not, once. These functions lived in a schema called
22
+ * `auth`, and the note here read: creating `rebase` would leak it into Atlas's
23
+ * replayed migration state, and — absent from the desired `schema.sql` — Atlas
24
+ * would then plan `DROP SCHEMA "rebase" CASCADE`, taking the auth tables with
25
+ * it. That reasoning still holds; what changed is the second half of it. The
26
+ * DDL generator now emits `CREATE SCHEMA IF NOT EXISTS "rebase"`
27
+ * unconditionally, so the schema is always in the desired state and the diff is
28
+ * empty. (It used to appear only when some collection happened to declare
29
+ * `schema: "rebase"` — true for the scaffold's users collection, and not a
30
+ * property anything guaranteed.) `db push` additionally excludes the whole
31
+ * schema from the declarative apply.
32
+ *
33
+ * See `@rebasepro/types`' `rls-functions` for why the functions moved out of
34
+ * `auth` at all: the short version is that the name was Supabase's, and
35
+ * `CREATE OR REPLACE FUNCTION auth.uid() RETURNS text` cannot be applied over
36
+ * Supabase's `RETURNS uuid` — Postgres refuses, and the refusal used to be
37
+ * swallowed.
38
+ */
39
+ /**
40
+ * The bootstrap as individual statements.
41
+ *
42
+ * Kept as an array because the two consumers need different shapes and only one
43
+ * of them can take a multi-command string: the migration preamble is written to
44
+ * a file and replayed by Atlas, but the boot path runs through drizzle, whose
45
+ * node-postgres handle speaks the extended query protocol and rejects more than
46
+ * one command per call. Splitting a joined string back apart on `$$;` would be
47
+ * a parser for a problem that does not need one.
48
+ */
49
+ export const RLS_BOOTSTRAP_STATEMENTS: readonly string[] = [
50
+ `CREATE SCHEMA IF NOT EXISTS ${REBASE_SCHEMA}`,
51
+
52
+ // Falls back to the pre-rename `app.user_id` so a database that has taken
53
+ // the new schema but is still served by an older backend keeps resolving
54
+ // the principal. Drop the COALESCE once no such deployment remains.
55
+ `CREATE OR REPLACE FUNCTION ${REBASE_SCHEMA}.uid() RETURNS text AS $$
56
+ SELECT COALESCE(
57
+ NULLIF(current_setting('app.uid', true), ''),
58
+ NULLIF(current_setting('app.user_id', true), '')
59
+ );
60
+ $$ LANGUAGE sql STABLE`,
61
+
62
+ `CREATE OR REPLACE FUNCTION ${REBASE_SCHEMA}.jwt() RETURNS jsonb AS $$
63
+ SELECT COALESCE(
64
+ NULLIF(current_setting('app.jwt', true), ''),
65
+ '{}'
66
+ )::jsonb;
67
+ $$ LANGUAGE sql STABLE`,
68
+
69
+ `CREATE OR REPLACE FUNCTION ${REBASE_SCHEMA}.roles() RETURNS text AS $$
70
+ SELECT COALESCE(NULLIF(current_setting('app.user_roles', true), ''), '');
71
+ $$ LANGUAGE sql STABLE`
72
+ ];
73
+
74
+ /** The same statements as one script, for migration files and raw clients. */
75
+ export const RLS_BOOTSTRAP_SQL =
76
+ "-- Rebase schema + RLS helper functions (required by the policies below)\n" +
77
+ RLS_BOOTSTRAP_STATEMENTS.map(s => `${s};`).join("\n\n") + "\n";
78
+
79
+ /**
80
+ * Removes the pre-1.0 `auth` schema, but only when Rebase is what put it there.
81
+ *
82
+ * ## Why this is safe against a Supabase database
83
+ *
84
+ * Two independent guards, and both have to pass:
85
+ *
86
+ * 1. **Each function is identified before it is dropped.** Ours returns `text`
87
+ * and reads the `app.uid` GUC; Supabase's returns `uuid` and reads
88
+ * `request.jwt.claims`. Nothing is dropped on a signature we did not write,
89
+ * so a Supabase database — where our `CREATE OR REPLACE` could never have
90
+ * succeeded in the first place, Postgres refusing to change a return type —
91
+ * matches nothing and this is a no-op.
92
+ * 2. **`DROP SCHEMA … RESTRICT`**, never CASCADE. If anything else at all still
93
+ * lives in `auth` (Supabase's `users` table, its other helpers), the drop
94
+ * fails and the schema stays. CASCADE here would be unrecoverable.
95
+ *
96
+ * ## Why it cannot run too early
97
+ *
98
+ * Postgres records a dependency from every RLS policy to the functions its body
99
+ * calls, so `DROP FUNCTION auth.uid()` fails for as long as a single policy
100
+ * still references it. That is the interlock, and it is load-bearing: the drop
101
+ * can only succeed once every policy has been recompiled to \`rebase.uid()\`.
102
+ * Callers therefore run this *after* applying policies, and treat a failure as
103
+ * "not yet — try again next boot" rather than as an error.
104
+ */
105
+ export const DROP_LEGACY_AUTH_SCHEMA_SQL = `
106
+ DO $rebase_drop_legacy$
107
+ DECLARE
108
+ dropped_any boolean := false;
109
+ BEGIN
110
+ -- Each function is matched on its own result type and body, so a schema
111
+ -- that merely shares the name keeps everything it has.
112
+ IF EXISTS (
113
+ SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
114
+ WHERE n.nspname = 'auth' AND p.proname = 'uid'
115
+ AND pg_get_function_result(p.oid) = 'text'
116
+ AND p.prosrc LIKE '%app.uid%'
117
+ ) THEN
118
+ DROP FUNCTION auth.uid();
119
+ dropped_any := true;
120
+ END IF;
121
+
122
+ IF EXISTS (
123
+ SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
124
+ WHERE n.nspname = 'auth' AND p.proname = 'jwt'
125
+ AND pg_get_function_result(p.oid) = 'jsonb'
126
+ AND p.prosrc LIKE '%app.jwt%'
127
+ ) THEN
128
+ DROP FUNCTION auth.jwt();
129
+ dropped_any := true;
130
+ END IF;
131
+
132
+ IF EXISTS (
133
+ SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
134
+ WHERE n.nspname = 'auth' AND p.proname = 'roles'
135
+ AND pg_get_function_result(p.oid) = 'text'
136
+ AND p.prosrc LIKE '%app.user_roles%'
137
+ ) THEN
138
+ DROP FUNCTION auth.roles();
139
+ dropped_any := true;
140
+ END IF;
141
+
142
+ -- RESTRICT: only an empty schema goes. Anything else in there — including a
143
+ -- Supabase installation left untouched above — keeps it.
144
+ IF dropped_any THEN
145
+ BEGIN
146
+ EXECUTE 'DROP SCHEMA auth RESTRICT';
147
+ EXCEPTION WHEN OTHERS THEN
148
+ NULL;
149
+ END;
150
+ END IF;
151
+ END
152
+ $rebase_drop_legacy$;
153
+ `;
154
+
155
+ /** Somebody's policy that still calls a pre-1.0 helper. */
156
+ export interface LegacyRlsDependent {
157
+ schema: string;
158
+ table: string;
159
+ policy: string;
160
+ }
161
+
162
+ /**
163
+ * Policies whose body still calls `auth.uid()` / `auth.roles()` / `auth.jwt()`.
164
+ *
165
+ * Postgres will not drop a function a policy depends on, so this is exactly the
166
+ * set standing between a database and losing the legacy schema. Rebase's own
167
+ * policies leave the list on the next push or boot, when they are recompiled —
168
+ * anything still here afterwards is hand-written, will never be recompiled by
169
+ * anybody, and is the reason the drop keeps being skipped. Silence there would
170
+ * leave an operator staring at a schema the release notes said would go.
171
+ */
172
+ export const LEGACY_RLS_DEPENDENTS_SQL = `
173
+ SELECT n.nspname AS schema, c.relname AS "table", p.polname AS policy
174
+ FROM pg_policy p
175
+ JOIN pg_class c ON c.oid = p.polrelid
176
+ JOIN pg_namespace n ON n.oid = c.relnamespace
177
+ WHERE pg_get_expr(p.polqual, p.polrelid) ~* '\\m${LEGACY_RLS_SCHEMA}\\.(uid|jwt|roles)\\s*\\('
178
+ OR pg_get_expr(p.polwithcheck, p.polrelid) ~* '\\m${LEGACY_RLS_SCHEMA}\\.(uid|jwt|roles)\\s*\\('
179
+ ORDER BY 1, 2, 3
180
+ `;
181
+
182
+ /** Somebody's *function* that still calls a pre-1.0 helper from its own body. */
183
+ export interface LegacyRlsFunctionDependent {
184
+ schema: string;
185
+ function: string;
186
+ }
187
+
188
+ /**
189
+ * Functions whose body calls `auth.uid()` / `auth.roles()` / `auth.jwt()`.
190
+ *
191
+ * This is the half `DROP FUNCTION ... RESTRICT` cannot see, and the reason it
192
+ * needs its own query. Postgres records a dependency for a *policy* that calls a
193
+ * function, which is why the drop is safe against the policies above — but a
194
+ * `LANGUAGE sql` function whose body is a **string literal** is not parsed when
195
+ * it is created, so nothing is recorded and `RESTRICT` has nothing to refuse on.
196
+ * The drop succeeds and the caller is left pointing at a function that no longer
197
+ * exists, which fails at *query* time rather than at boot.
198
+ *
199
+ * A downstream project building on these helpers is not hypothetical: the Rebase
200
+ * control plane defines `auth.is_org_member(uuid)` and `auth.is_org_admin(uuid)`
201
+ * in this very schema, each calling `auth.uid()` in a string body, and eleven of
202
+ * its row-level-security policies go through them. Every one of those would have
203
+ * started failing the first time a recompile left no policy referencing
204
+ * `auth.uid()` directly — the drop's own precondition.
205
+ *
206
+ * Matching on the body text is the only option available, and it is deliberately
207
+ * broad: a false positive costs a schema that stays one release longer and says
208
+ * why, while a false negative costs somebody their policies.
209
+ */
210
+ export const LEGACY_RLS_FUNCTION_DEPENDENTS_SQL = `
211
+ SELECT n.nspname AS schema, p.proname AS function
212
+ FROM pg_proc p
213
+ JOIN pg_namespace n ON n.oid = p.pronamespace
214
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
215
+ AND NOT (n.nspname = '${LEGACY_RLS_SCHEMA}' AND p.proname IN ('uid', 'jwt', 'roles'))
216
+ AND p.prosrc ~* '\\m${LEGACY_RLS_SCHEMA}\\.(uid|jwt|roles)\\s*\\('
217
+ ORDER BY 1, 2
218
+ `;
219
+
220
+ /**
221
+ * Retire the pre-1.0 `auth` schema, reporting what is holding it back.
222
+ *
223
+ * The shared implementation behind the CLI's post-push step and the runtime's
224
+ * post-policy step. Both used to just fire {@link DROP_LEGACY_AUTH_SCHEMA_SQL}
225
+ * and swallow whatever came back, which is right for the ordinary case — a
226
+ * table not recompiled *yet* — and wrong for the one that never resolves: a
227
+ * hand-written policy nothing will ever rewrite. Then the schema stays forever
228
+ * and nothing ever says why.
229
+ */
230
+ export async function dropLegacyAuthSchema(
231
+ run: (sql: string) => Promise<Record<string, unknown>[]>,
232
+ report: { info: (m: string) => void; warn: (m: string) => void }
233
+ ): Promise<void> {
234
+ let blockers: LegacyRlsDependent[];
235
+ try {
236
+ blockers = (await run(LEGACY_RLS_DEPENDENTS_SQL)) as unknown as LegacyRlsDependent[];
237
+ } catch {
238
+ return; // No catalogue access; nothing here is worth failing a boot for.
239
+ }
240
+
241
+ if (blockers.length > 0) {
242
+ report.warn(
243
+ `The pre-1.0 \`${LEGACY_RLS_SCHEMA}\` schema cannot be removed yet: ${blockers.length} ` +
244
+ `${blockers.length === 1 ? "policy still calls" : "policies still call"} ` +
245
+ `\`${LEGACY_RLS_SCHEMA}.uid()\` and friends, and Postgres will not drop a function a policy ` +
246
+ `depends on. Rebase's own policies are ` +
247
+ `rewritten automatically — anything listed here is hand-written SQL that has to be updated to ` +
248
+ `\`${REBASE_SCHEMA}.uid()\` by hand, after which the schema goes on its own:\n` +
249
+ blockers.map(b => ` • ${b.schema}.${b.table} → "${b.policy}"`).join("\n")
250
+ );
251
+ return;
252
+ }
253
+
254
+ // The half Postgres will not refuse on. See LEGACY_RLS_FUNCTION_DEPENDENTS_SQL:
255
+ // a string-literal SQL body records no dependency, so `DROP FUNCTION` drops
256
+ // out from under a caller that is still using it and nothing complains until
257
+ // a query runs.
258
+ let borrowers: LegacyRlsFunctionDependent[];
259
+ try {
260
+ borrowers = (await run(LEGACY_RLS_FUNCTION_DEPENDENTS_SQL)) as unknown as LegacyRlsFunctionDependent[];
261
+ } catch {
262
+ return;
263
+ }
264
+
265
+ if (borrowers.length > 0) {
266
+ report.warn(
267
+ `The pre-1.0 \`${LEGACY_RLS_SCHEMA}\` schema cannot be removed yet: ${borrowers.length} ` +
268
+ `${borrowers.length === 1 ? "function calls" : "functions call"} ` +
269
+ `\`${LEGACY_RLS_SCHEMA}.uid()\` and friends from its own body. Postgres records no dependency ` +
270
+ `for that — a SQL body written as a string literal is never parsed — so dropping the helpers ` +
271
+ `would leave these pointing at nothing, and they would fail when a query reached them rather ` +
272
+ `than now. Repoint them at \`${REBASE_SCHEMA}.uid()\`, after which the schema goes on its own:\n` +
273
+ borrowers.map(b => ` • ${b.schema}.${b.function}()`).join("\n")
274
+ );
275
+ return;
276
+ }
277
+
278
+ try {
279
+ await run(DROP_LEGACY_AUTH_SCHEMA_SQL);
280
+ } catch (err) {
281
+ // Something else depends on it, or the connection does not own it.
282
+ // Not fatal: the schema is inert either way now that no policy calls it.
283
+ report.info(
284
+ `Left the \`${LEGACY_RLS_SCHEMA}\` schema in place: ` +
285
+ (err instanceof Error ? err.message : String(err))
286
+ );
287
+ }
288
+ }
@@ -29,7 +29,9 @@ describe("warnOnAnonymousGrants", () => {
29
29
  it("reports both mistakes in the rule from the wild", () => {
30
30
  const warning = captureWarning(() => warnOnAnonymousGrants([collectionWith("talents", FROM_THE_WILD)]));
31
31
 
32
- expect(warning).toContain("auth.uid() IS NOT NULL");
32
+ // The rule is written in the pre-1.0 spelling and must still be
33
+ // detected; the advice is printed in the current one.
34
+ expect(warning).toContain("rebase.uid() IS NOT NULL");
33
35
  expect(warning).toContain("'anon' is a Supabase convention");
34
36
  expect(warning).toContain("policy.authenticated()");
35
37
  });
@@ -51,7 +53,7 @@ describe("warnOnAnonymousGrants", () => {
51
53
  it("catches the bare tautology, which carries no literal to notice", () => {
52
54
  const warning = captureWarning(() => warnOnAnonymousGrants([collectionWith("talents", "auth.uid() IS NOT NULL")]));
53
55
 
54
- expect(warning).toContain("auth.uid() IS NOT NULL");
56
+ expect(warning).toContain("rebase.uid() IS NOT NULL");
55
57
  expect(warning).not.toContain("Supabase convention");
56
58
  });
57
59
 
@@ -1,6 +1,13 @@
1
1
  import { sql as drizzleSql, SQL } from "drizzle-orm";
2
2
  import { ANONYMOUS_USER_ID, PolicyExpression, SecurityRule } from "@rebasepro/types";
3
- import { AnonymousGrantRisk, findAnonymousGrants, securityRuleToConditions } from "@rebasepro/common";
3
+ import {
4
+ AnonymousGrantRisk,
5
+ findAnonymousGrants,
6
+ REBASE_USER_ROLE,
7
+ revokeInternalTableAccess,
8
+ securityRuleToConditions
9
+ } from "@rebasepro/common";
10
+ import { REBASE_SCHEMA, usesLegacyRlsFunctions } from "@rebasepro/types";
4
11
  import { logger } from "@rebasepro/server";
5
12
 
6
13
  /**
@@ -36,8 +43,14 @@ import { logger } from "@rebasepro/server";
36
43
  * not an operator opt-in.
37
44
  */
38
45
 
39
- /** The restricted role every authenticated (user-context) request runs as. */
40
- export const REBASE_USER_ROLE = "rebase_user";
46
+ /**
47
+ * The restricted role every authenticated (user-context) request runs as.
48
+ *
49
+ * Re-exported, not re-declared: the same name is needed by
50
+ * `@rebasepro/common`'s internal-table revokes, and two spellings of a role name
51
+ * fail as a silent no-op rather than an error.
52
+ */
53
+ export { REBASE_USER_ROLE };
41
54
 
42
55
  /** Minimal SQL runner so callers can adapt drizzle or pg.Client. */
43
56
  export type RawSqlRunner = (sqlText: string) => Promise<Record<string, unknown>[]>;
@@ -69,6 +82,57 @@ const quoteIdent = (name: string): string => `"${name.replace(/"/g, "\"\"")}"`;
69
82
  /** DML the user role holds on managed tables (RLS still filters per row). */
70
83
  const USER_TABLE_PRIVILEGES = "SELECT, INSERT, UPDATE, DELETE";
71
84
 
85
+ /**
86
+ * Warn when the connection role shares its name with an existing schema.
87
+ *
88
+ * Postgres resolves unqualified names through `search_path`, which defaults to
89
+ * `"$user", public` — and `$user` is the connection ROLE. When a schema of that
90
+ * name exists it sits ahead of `public`, so every unqualified statement
91
+ * silently operates on it instead:
92
+ *
93
+ * CREATE TABLE posts (...); -- you meant public.posts; you got <role>.posts
94
+ *
95
+ * Nothing errors. You get a second table of the same name in the wrong schema,
96
+ * and reads that pin `public` cannot see it — which reads as "missing table" and
97
+ * sends people to re-run a push that creates a *third* copy. The bootstrapper
98
+ * has a whole branch dedicated to recognising the symptom after the fact.
99
+ *
100
+ * Rebase shipped straight into this: it creates a schema named `rebase` while
101
+ * every template named the database role `rebase` too. The scaffold uses
102
+ * `rebase_app` now, and every pool Rebase opens pins `search_path=public`
103
+ * (`pinSearchPath`), which covers the paths the framework controls. This covers
104
+ * the ones it does not — `psql`, `pg_dump`, drizzle-kit, a colleague's script,
105
+ * a hand-written migration — because the hazard is a property of the two NAMES,
106
+ * not of any one connection.
107
+ *
108
+ * A warning rather than a boot failure: the database works, the framework's own
109
+ * traffic is pinned, and refusing to start over a naming choice a user may have
110
+ * inherited would be worse than the risk.
111
+ */
112
+ export async function warnOnRoleSchemaCollision(run: RawSqlRunner): Promise<void> {
113
+ try {
114
+ const rows = await run(`
115
+ SELECT current_user AS role,
116
+ EXISTS (
117
+ SELECT 1 FROM pg_namespace n WHERE n.nspname = current_user
118
+ ) AS collides
119
+ `);
120
+ if (rows[0]?.collides !== true) return;
121
+ const role = String(rows[0]?.role ?? "the connection role");
122
+ logger.warn(
123
+ `⚠️ The database role "${role}" has the same name as a schema. Postgres resolves unqualified ` +
124
+ `names through \`search_path\`, which defaults to \`"$user", public\` — so "${role}" is searched ` +
125
+ `BEFORE public, and any unqualified \`CREATE TABLE\`/\`SELECT\` from a tool that does not pin the ` +
126
+ `path (psql, pg_dump, drizzle-kit, a hand-written migration) silently lands in "${role}" instead. ` +
127
+ `Rebase's own connections pin \`search_path=public\`, so the server is unaffected. To remove the ` +
128
+ `hazard entirely, connect as a role whose name is not also a schema — the scaffold uses ` +
129
+ `"rebase_app".`
130
+ );
131
+ } catch {
132
+ // A diagnostic must never be the reason a boot fails.
133
+ }
134
+ }
135
+
72
136
  export async function detectConnectionPosture(run: RawSqlRunner): Promise<ConnectionPosture> {
73
137
  const rows = await run(`
74
138
  SELECT current_user AS role,
@@ -180,6 +244,21 @@ export async function ensureAppRole(run: RawSqlRunner, schemas: string[]): Promi
180
244
  // migrations), so a migrate can never strand the user role.
181
245
  await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT ${USER_TABLE_PRIVILEGES} ON TABLES TO ${REBASE_USER_ROLE}`);
182
246
  await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT USAGE, SELECT ON SEQUENCES TO ${REBASE_USER_ROLE}`);
247
+
248
+ // The grants above are deliberately schema-wide — a project's own
249
+ // collections may live in `rebase`, and future tables must be reachable
250
+ // or a migration strands the role. Rebase's OWN tables are the exception:
251
+ // refresh tokens, MFA secrets, API keys and the rest carry no RLS and no
252
+ // row an end user should ever address. Taking the privilege back here
253
+ // covers every table that already exists; each creator revokes on the
254
+ // table it just made, for the boot that creates them for the first time.
255
+ await revokeInternalTableAccess(async (text) => { await run(text); }, schema, {
256
+ onError: (table, error) => logger.warn(
257
+ `🔐 [rls] Could not revoke "${REBASE_USER_ROLE}" access to "${schema}"."${table}" — ` +
258
+ "it stays reachable by authenticated requests: " +
259
+ (error instanceof Error ? error.message : String(error))
260
+ )
261
+ });
183
262
  }
184
263
 
185
264
  logger.info(`🔐 [rls] User role "${REBASE_USER_ROLE}" provisioned (schemas: ${uniqueSchemas.join(", ")})`);
@@ -293,6 +372,65 @@ export function warnOnAnonymousGrants(
293
372
  );
294
373
  }
295
374
 
375
+ /**
376
+ * Name the collections whose raw policy SQL still calls the pre-1.0 helpers.
377
+ *
378
+ * The compiler rewrites `auth.uid()` to `rebase.uid()` on the way into the
379
+ * database, so nothing is broken and no policy is wrong — which is exactly why
380
+ * this has to be said out loud. A silent rewrite that works forever is not a
381
+ * migration, it is a second supported spelling nobody wrote down, and the next
382
+ * person to read those rules will copy the old one.
383
+ *
384
+ * Only `raw` expressions can carry it. Structured rules (`policy.authUid()`,
385
+ * `policy.rolesOverlap(...)`) compile from the model and were never affected.
386
+ */
387
+ export function warnOnLegacyRlsFunctions(
388
+ collections: { slug?: string; securityRules?: readonly SecurityRule[] }[]
389
+ ): void {
390
+ const sites: string[] = [];
391
+
392
+ for (const collection of collections) {
393
+ for (const rule of collection.securityRules ?? []) {
394
+ const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
395
+ const carriesLegacy = [usingExpr, withCheckExpr]
396
+ .filter((e): e is PolicyExpression => e !== null)
397
+ .some(containsLegacyRlsCall);
398
+ if (!carriesLegacy) continue;
399
+
400
+ const site = `${collection.slug ?? "(unnamed)"} → "${rule.name ?? "(unnamed rule)"}"`;
401
+ if (!sites.includes(site)) sites.push(site);
402
+ }
403
+ }
404
+
405
+ if (sites.length === 0) return;
406
+
407
+ logger.warn(
408
+ `These security rules call the pre-1.0 RLS helpers (\`auth.uid()\`, \`auth.roles()\`, \`auth.jwt()\`). ` +
409
+ `They still work — the compiler rewrites them — but the functions now live in the \`rebase\` schema, ` +
410
+ `and the \`auth\` one is Supabase's. Update the raw SQL in these rules to \`${REBASE_SCHEMA}.uid()\` ` +
411
+ `and friends, or switch them to the structured helpers (\`policy.authUid()\`, \`policy.rolesOverlap()\`), ` +
412
+ `which never had to be spelled by hand:\n\n` +
413
+ sites.map(s => ` • ${s}`).join("\n") + "\n"
414
+ );
415
+ }
416
+
417
+ /** Whether any `raw` expression in the tree calls a pre-1.0 helper. */
418
+ function containsLegacyRlsCall(expr: PolicyExpression): boolean {
419
+ switch (expr.kind) {
420
+ case "raw":
421
+ return usesLegacyRlsFunctions(expr.sql);
422
+ case "and":
423
+ case "or":
424
+ return expr.operands.some(containsLegacyRlsCall);
425
+ case "not":
426
+ return containsLegacyRlsCall(expr.operand);
427
+ case "existsIn":
428
+ return containsLegacyRlsCall(expr.where);
429
+ default:
430
+ return false;
431
+ }
432
+ }
433
+
296
434
  /**
297
435
  * Reject `pgRoles` that this server can never satisfy.
298
436
  *
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { sql } from "drizzle-orm";
11
11
  import { BranchInfo } from "@rebasepro/types";
12
+ import { revokeInternalTableSql } from "@rebasepro/common";
12
13
  import { DrizzleClient } from "../interfaces";
13
14
  import { DatabasePoolManager } from "../databasePoolManager";
14
15
  import { extractPgError, extractCauseMessage } from "../utils/pg-error-utils";
@@ -124,6 +125,10 @@ export class BranchService {
124
125
  metadata JSONB DEFAULT '{}'
125
126
  );
126
127
  `));
128
+
129
+ // Not a collection, so no RLS — and it names every branch database on
130
+ // this server. The driver's schema-wide grant reaches it, so revoke.
131
+ await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "branches")));
127
132
  }
128
133
 
129
134
  /**