@rebasepro/server-postgres 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.
Files changed (70) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/{src-DlPBctw_.js → auth-users-columns-Dt9g712t.js} +721 -47
  3. package/dist/auth-users-columns-Dt9g712t.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 +57 -1
  7. package/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-CwYUliAa.js} +2 -2
  8. package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-CwYUliAa.js.map} +1 -1
  9. package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DRxaUG96.js} +86 -15
  10. package/dist/ensure-collection-tables-DRxaUG96.js.map +1 -0
  11. package/dist/index.es.js +765 -223
  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 +2 -2
  19. package/dist/schema/generate-drizzle-schema-logic.d.ts +1 -1
  20. package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -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/schema/search-column.d.ts +199 -0
  24. package/dist/security/rls-enforcement.d.ts +53 -2
  25. package/dist/services/FetchService.d.ts +25 -7
  26. package/dist/services/dataService.d.ts +3 -0
  27. package/dist/services/realtimeService.d.ts +27 -21
  28. package/dist/{src-DoU9yPqq.js → src-C_wvdMnl.js} +91 -2
  29. package/dist/src-C_wvdMnl.js.map +1 -0
  30. package/dist/utils/drizzle-conditions.d.ts +71 -2
  31. package/dist/{websocket-B2LsrINK.js → websocket-D0TBU3ia.js} +3 -3
  32. package/dist/websocket-D0TBU3ia.js.map +1 -0
  33. package/package.json +9 -8
  34. package/src/PostgresBackendDriver.ts +165 -3
  35. package/src/PostgresBootstrapper.ts +41 -2
  36. package/src/auth/ensure-tables.ts +185 -86
  37. package/src/cli-helpers.ts +129 -10
  38. package/src/cli.ts +232 -30
  39. package/src/collections/validate-relations.ts +124 -17
  40. package/src/data-transformer.ts +31 -3
  41. package/src/history/ensure-history-table.ts +7 -0
  42. package/src/schema/auth-users-columns.ts +131 -0
  43. package/src/schema/doctor.ts +7 -5
  44. package/src/schema/ensure-collection-tables.ts +165 -20
  45. package/src/schema/generate-drizzle-schema-logic.ts +33 -3
  46. package/src/schema/generate-postgres-ddl-logic.ts +266 -15
  47. package/src/schema/generate-postgres-ddl.ts +25 -2
  48. package/src/schema/generated-schema-staleness.ts +169 -0
  49. package/src/schema/introspect-db-logic.ts +1 -1
  50. package/src/schema/non-sql-collections.test.ts +131 -0
  51. package/src/schema/rls-bootstrap-sql.ts +288 -0
  52. package/src/schema/search-column.ts +558 -0
  53. package/src/security/anonymous-grants.test.ts +4 -2
  54. package/src/security/rls-enforcement.ts +141 -3
  55. package/src/services/BranchService.ts +5 -0
  56. package/src/services/FetchService.ts +148 -108
  57. package/src/services/PersistService.ts +14 -1
  58. package/src/services/RelationService.ts +2 -1
  59. package/src/services/channel-history.ts +8 -0
  60. package/src/services/channel-presence.ts +6 -0
  61. package/src/services/dataService.ts +3 -0
  62. package/src/services/realtimeService.ts +46 -37
  63. package/src/utils/drizzle-conditions.ts +223 -2
  64. package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
  65. package/dist/policy-CeA1JcxP.js.map +0 -1
  66. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  67. package/dist/src-DlPBctw_.js.map +0 -1
  68. package/dist/src-DoU9yPqq.js.map +0 -1
  69. package/dist/websocket-B2LsrINK.js.map +0 -1
  70. package/src/schema/auth-bootstrap-sql.ts +0 -47
@@ -19,6 +19,10 @@ import {
19
19
  RestFetchService,
20
20
  SaveManyProps,
21
21
  SaveProps,
22
+ StorageSource,
23
+ UpdateManyProps,
24
+ DeleteManyProps,
25
+ EntityValues,
22
26
  TableColumnInfo,
23
27
  TableForeignKeyInfo,
24
28
  TableJunctionInfo,
@@ -148,14 +152,33 @@ export class PostgresBackendDriver implements DataDriver {
148
152
  };
149
153
  }
150
154
 
155
+ /**
156
+ * Build the context handed to every collection callback.
157
+ *
158
+ * Note `data: this.data` — `this` is whichever driver is running the
159
+ * operation, so the callback's data plane inherits that driver's privilege.
160
+ * On a user request `AuthenticatedPostgresBackendDriver.withTransaction`
161
+ * constructs a fresh base driver bound to the RLS-scoped transaction and
162
+ * runs the operation on it, so `this.data` speaks through that connection
163
+ * and policies apply. On server-context work `this` is the base driver on
164
+ * the owner connection, and they do not. Pinned by the
165
+ * `"scopes context.data to the caller"` case in the `rls-enforcement` e2e
166
+ * suite, because it is the kind of property that is easy to break from a
167
+ * distance and impossible to notice.
168
+ *
169
+ * Previously returned through `as unknown as RebaseCallContext`, which
170
+ * disabled checking for the whole object and let `driver` — documented in
171
+ * the callbacks guide — sit on the runtime context while absent from the
172
+ * contract. Both are declared now, so this is a plain typed return.
173
+ */
151
174
  private buildCallContext(): RebaseCallContext {
152
175
  return {
153
176
  user: this.user,
154
177
  driver: this,
155
178
  data: this.data,
156
- client: this.client,
157
- storageSource: this.client?.storage
158
- } as unknown as RebaseCallContext;
179
+ client: this.client as RebaseCallContext["client"],
180
+ storageSource: this.client?.storage as StorageSource
181
+ };
159
182
  }
160
183
 
161
184
  private resolveCollectionCallbacks<M extends Record<string, unknown>>(collection: CollectionConfig<M> | undefined, path: string) {
@@ -863,6 +886,145 @@ export class PostgresBackendDriver implements DataDriver {
863
886
  });
864
887
  }
865
888
 
889
+ /**
890
+ * Update many rows through the same pipeline as {@link save}, in one
891
+ * transaction.
892
+ *
893
+ * Structurally the mirror of {@link saveMany} — same tx-bound sub-driver,
894
+ * same deferred notifications, same per-row error labelling — but it calls
895
+ * `save` with an explicit `id` and `status: "existing"`, which is precisely
896
+ * what `saveMany` cannot do: that one passes `status: "new"` and keeps the
897
+ * key inside `values`, so it inserts or upserts and can never target a
898
+ * particular row.
899
+ *
900
+ * All-or-nothing, so an id matching no row aborts the batch. A partial
901
+ * update is the outcome with no good recovery: the caller cannot tell which
902
+ * half landed without re-reading everything.
903
+ */
904
+ async updateMany<M extends Record<string, unknown>>({
905
+ path,
906
+ updates,
907
+ collection
908
+ }: UpdateManyProps<M>): Promise<Record<string, unknown>[]> {
909
+ return this.db.transaction(async (tx) => {
910
+ const txDriver = new PostgresBackendDriver(
911
+ tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService
912
+ );
913
+ txDriver.dataService = new DataService(tx, this.registry);
914
+ txDriver.client = this.client;
915
+ txDriver._deferNotifications = this._deferNotifications;
916
+ txDriver._pendingNotifications = this._pendingNotifications;
917
+
918
+ const saved: Record<string, unknown>[] = [];
919
+
920
+ for (let i = 0; i < updates.length; i++) {
921
+ const { id, values } = updates[i];
922
+ try {
923
+ // Read first so a missing row is a 404 rather than a silent
924
+ // no-op. `save` with status "existing" would otherwise write
925
+ // an UPDATE that matches nothing and report success.
926
+ const existing = await txDriver.fetchOne({
927
+ path,
928
+ id: String(id),
929
+ collection: collection as CollectionConfig
930
+ });
931
+ if (!existing) {
932
+ throw Object.assign(new Error(`No row with id ${JSON.stringify(id)}`), {
933
+ statusCode: 404,
934
+ code: "NOT_FOUND"
935
+ });
936
+ }
937
+
938
+ saved.push(await txDriver.save<M>({
939
+ path,
940
+ id: String(id),
941
+ values,
942
+ collection,
943
+ status: "existing"
944
+ }));
945
+ } catch (error) {
946
+ // Say which entry, as saveMany does: "the batch failed" is
947
+ // unactionable at a thousand rows.
948
+ throw Object.assign(
949
+ new Error(`Update ${i} of ${updates.length} (id ${JSON.stringify(id)}) failed: ${(error as Error)?.message ?? error}`, { cause: error }),
950
+ {
951
+ statusCode: (error as { statusCode?: number })?.statusCode,
952
+ code: (error as { code?: string })?.code,
953
+ name: (error as Error)?.name
954
+ }
955
+ );
956
+ }
957
+ }
958
+
959
+ return saved;
960
+ });
961
+ }
962
+
963
+ /**
964
+ * Delete many rows in one transaction, running the full delete pipeline —
965
+ * `beforeDelete`, the delete, `afterDelete` — for each.
966
+ *
967
+ * Looping the single-row {@link delete} rather than emitting one
968
+ * `DELETE ... WHERE id = ANY($1)` is the deliberate choice: a single
969
+ * statement would be faster and would skip every callback, so a collection
970
+ * relying on `beforeDelete` to veto or on `afterDelete` to clean up
971
+ * dependents would behave differently depending on how many rows the caller
972
+ * happened to delete at once. Same pipeline, one transaction.
973
+ */
974
+ async deleteMany<M extends Record<string, unknown>>({
975
+ path,
976
+ ids,
977
+ collection
978
+ }: DeleteManyProps<M>): Promise<void> {
979
+ await this.db.transaction(async (tx) => {
980
+ const txDriver = new PostgresBackendDriver(
981
+ tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService
982
+ );
983
+ txDriver.dataService = new DataService(tx, this.registry);
984
+ txDriver.client = this.client;
985
+ txDriver._deferNotifications = this._deferNotifications;
986
+ txDriver._pendingNotifications = this._pendingNotifications;
987
+
988
+ for (let i = 0; i < ids.length; i++) {
989
+ const id = ids[i];
990
+ try {
991
+ const existing = await txDriver.fetchOne({
992
+ path,
993
+ id: String(id),
994
+ collection: collection as CollectionConfig
995
+ });
996
+ if (!existing) {
997
+ throw Object.assign(new Error(`No row with id ${JSON.stringify(id)}`), {
998
+ statusCode: 404,
999
+ code: "NOT_FOUND"
1000
+ });
1001
+ }
1002
+
1003
+ await txDriver.delete<M>({
1004
+ row: {
1005
+ // The address from the caller, not read back off the
1006
+ // row: a row is only its columns, so `existing.id` is
1007
+ // undefined for any table not keyed on `id`.
1008
+ id: String(id),
1009
+ path,
1010
+ values: existing as Partial<EntityValues<M>>
1011
+ },
1012
+ collection
1013
+ });
1014
+ } catch (error) {
1015
+ throw Object.assign(
1016
+ new Error(`Delete ${i} of ${ids.length} (id ${JSON.stringify(id)}) failed: ${(error as Error)?.message ?? error}`, { cause: error }),
1017
+ {
1018
+ statusCode: (error as { statusCode?: number })?.statusCode,
1019
+ code: (error as { code?: string })?.code,
1020
+ name: (error as Error)?.name
1021
+ }
1022
+ );
1023
+ }
1024
+ }
1025
+ });
1026
+ }
1027
+
866
1028
  async delete<M extends Record<string, unknown>>({
867
1029
  row,
868
1030
  collection
@@ -37,7 +37,7 @@ import { ensureHistoryTableExists } from "./history/ensure-history-table";
37
37
  import { patchPgArrayNullSafety } from "./utils/pg-array-null-patch";
38
38
  import { buildCollectionsFromSchema, introspectSchema, readRlsStatus } from "./schema/introspect-runtime";
39
39
  import { buildDrizzleTablesFromSchema, buildDrizzleRelationsFromSchema } from "./schema/dynamic-tables";
40
- import { detectConnectionPosture, ensureAppRole, validatePolicyPgRoles, warnOnAnonymousGrants, REBASE_USER_ROLE, type RawSqlRunner } from "./security/rls-enforcement";
40
+ import { detectConnectionPosture, ensureAppRole, validatePolicyPgRoles, warnOnAnonymousGrants, warnOnLegacyRlsFunctions, warnOnRoleSchemaCollision, REBASE_USER_ROLE, type RawSqlRunner } from "./security/rls-enforcement";
41
41
  import { provisionTriggerCdc, type CdcTableRef } from "./services/cdc/trigger-cdc";
42
42
  import { collectJunctionLinks } from "./services/cdc/junction-tables";
43
43
  import { createChannelBus, resolveChannelBusSetting } from "./services/channel-bus";
@@ -372,12 +372,22 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
372
372
  const res = await schemaAwareDb.execute(sql.raw(text));
373
373
  return (res.rows ?? []) as Record<string, unknown>[];
374
374
  };
375
+ // Said before anything else touches the schema: if the role and
376
+ // a schema share a name, unqualified SQL from any tool that does
377
+ // not pin `search_path` has been landing in the wrong place, and
378
+ // that is worth knowing before reading the drift report below.
379
+ await warnOnRoleSchemaCollision(runSql);
380
+
375
381
  const posture = await detectConnectionPosture(runSql);
376
382
  if (posture.privileged) {
377
383
  const collectionSchemas = registry.getCollections()
378
384
  .map((c) => (c as { schema?: string }).schema)
379
385
  .filter((s): s is string => typeof s === "string");
380
- await ensureAppRole(runSql, ["public", "rebase", "auth", ...collectionSchemas]);
386
+ // `auth` is deliberately absent: the RLS helpers moved into
387
+ // `rebase`, and granting USAGE on a schema Rebase does not
388
+ // own would, on a Supabase database, hand the end-user role
389
+ // access to theirs.
390
+ await ensureAppRole(runSql, ["public", "rebase", ...collectionSchemas]);
381
391
  driver.rlsUserRole = REBASE_USER_ROLE;
382
392
  realtimeService.rlsUserRole = REBASE_USER_ROLE;
383
393
  logger.info(`🔐 RLS enforcement active: authenticated requests run as "${REBASE_USER_ROLE}" (connection "${posture.role}" bypasses RLS: ${posture.superuser ? "superuser" : posture.bypassRLS ? "BYPASSRLS" : "table owner"})`);
@@ -406,6 +416,11 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
406
416
  // a rule that reads as "signed in only" but is true for every
407
417
  // caller grants the data away rather than hiding it.
408
418
  warnOnAnonymousGrants(registry.getCollections() as never);
419
+
420
+ // Raw policy SQL written against the pre-1.0 helper schema. It
421
+ // is rewritten on compile, so this is the only place the project
422
+ // is ever told the spelling moved.
423
+ warnOnLegacyRlsFunctions(registry.getCollections() as never);
409
424
  }
410
425
 
411
426
  // Ensure branch metadata table exists when branching is available
@@ -870,6 +885,30 @@ schemaHealthCheck: () => probeAuthSchema(db, resolveAuthSchema(authCollection))
870
885
  `🔐 [rls] Could not fully apply policies to "${failure.table}" — it stays locked (denies) until this is resolved: ${failure.error}`
871
886
  );
872
887
  }
888
+
889
+ // Retire the pre-1.0 `auth` schema now that the policies above no
890
+ // longer call into it. Deliberately after, and deliberately quiet:
891
+ // Postgres refuses to drop a function an RLS policy still
892
+ // references, so on a database where some table has not been
893
+ // recompiled yet this is expected to fail and succeed on a later
894
+ // boot. See DROP_LEGACY_AUTH_SCHEMA_SQL for the guards that keep it
895
+ // off a Supabase `auth` schema.
896
+ try {
897
+ const { dropLegacyAuthSchema } = await import("./schema/rls-bootstrap-sql");
898
+ await dropLegacyAuthSchema(
899
+ async (text) => {
900
+ const res = await internals.db.execute(sql.raw(text));
901
+ return (res.rows ?? []) as Record<string, unknown>[];
902
+ },
903
+ { info: (m) => logger.info(m), warn: (m) => logger.warn(m) }
904
+ );
905
+ } catch (err) {
906
+ logger.info(
907
+ "Left the legacy `auth` schema in place: " +
908
+ (err instanceof Error ? err.message : String(err))
909
+ );
910
+ }
911
+
873
912
  return { applied: outcome.policiesApplied };
874
913
  },
875
914
 
@@ -1,7 +1,10 @@
1
1
  import { sql } from "drizzle-orm";
2
2
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
3
3
  import { logger } from "@rebasepro/server";
4
+ import { revokeInternalTableAccess } from "@rebasepro/common";
4
5
  import type { CollectionConfig } from "@rebasepro/types";
6
+ import { AUTH_USERS_COLUMNS, authUsersColumnSql } from "../schema/auth-users-columns";
7
+ import { RLS_BOOTSTRAP_STATEMENTS } from "../schema/rls-bootstrap-sql";
5
8
  import {
6
9
  AuthSchemaVersionError,
7
10
  assertAuthSchemaCompatible,
@@ -44,13 +47,23 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
44
47
  ? `"${resolvedTable}"`
45
48
  : `"${usersSchema}"."${resolvedTable}"`;
46
49
 
47
- // Derive ID column type from collection properties
50
+ // Derive ID column type from collection properties.
51
+ //
52
+ // `"increment"`, not `"autoincrement"`. The latter was tested for
53
+ // here and exists nowhere in the type system — the union is
54
+ // `boolean | "manual" | "increment" | string` — so the INTEGER branch
55
+ // was unreachable and an integer-keyed auth collection fell through
56
+ // to TEXT. Introspection below hid it whenever the table already
57
+ // existed; on a database where it did not, this created
58
+ // `id TEXT DEFAULT gen_random_uuid()::text` for a collection that
59
+ // declares a number, and every `uid` foreign key was typed to match
60
+ // the wrong thing.
48
61
  const idProp = collection.properties?.id;
49
62
  if (idProp) {
50
63
  const isId = ("isId" in idProp) ? (idProp as unknown as Record<string, unknown>).isId : undefined;
51
64
  if (isId === "uuid") {
52
65
  userIdType = "UUID";
53
- } else if (isId === "autoincrement") {
66
+ } else if (isId === "increment") {
54
67
  userIdType = "INTEGER";
55
68
  }
56
69
  // Otherwise keep TEXT as default
@@ -129,22 +142,24 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
129
142
  // and long signed URLs that OAuth providers hand back. A limit worth
130
143
  // having is a CHECK — alterable without a table rewrite, unlike a type
131
144
  // modifier — which is why `email` has one and nothing else does.
145
+ //
146
+ // The column list comes from AUTH_USERS_COLUMNS rather than being spelled
147
+ // out here, because this is not the only place that creates this table:
148
+ // `db push` and the boot-time collection ensure do too, and when the
149
+ // three lists were maintained separately they disagreed and boot order
150
+ // silently decided which shape the database got. The `email` CHECK is
151
+ // appended rather than listed there — it is a named constraint the
152
+ // migration below has to be able to add separately, `NOT VALID`, to a
153
+ // table that already holds rows.
154
+ const usersColumnDdl = AUTH_USERS_COLUMNS
155
+ .map((spec) => spec.column === "email"
156
+ ? `${spec.column} ${authUsersColumnSql(spec)} CONSTRAINT ${emailLengthConstraint} CHECK (length(email) <= 320)`
157
+ : `${spec.column} ${authUsersColumnSql(spec)}`)
158
+ .join(",\n ");
132
159
  await db.execute(sql`
133
160
  CREATE TABLE IF NOT EXISTS ${sql.raw(usersTableName)} (
134
161
  id ${sql.raw(userIdType)} PRIMARY KEY ${sql.raw(idDefault)},
135
- email TEXT NOT NULL CONSTRAINT ${sql.raw(emailLengthConstraint)} CHECK (length(email) <= 320),
136
- display_name TEXT,
137
- photo_url TEXT,
138
- roles TEXT[] DEFAULT '{}' NOT NULL,
139
- password_hash TEXT,
140
- email_verified BOOLEAN DEFAULT FALSE NOT NULL,
141
- email_verification_token TEXT,
142
- email_verification_sent_at TIMESTAMP WITH TIME ZONE,
143
- is_anonymous BOOLEAN DEFAULT FALSE NOT NULL,
144
- metadata JSONB DEFAULT '{}' NOT NULL,
145
- tokens_valid_after TIMESTAMP WITH TIME ZONE,
146
- created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
147
- updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
162
+ ${sql.raw(usersColumnDdl)}
148
163
  )
149
164
  `);
150
165
 
@@ -164,26 +179,51 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
164
179
  // column once no old backend remains (phase 2, contract).
165
180
  //
166
181
  // Idempotent throughout: every step is guarded on catalogue state.
167
- await db.execute(sql`
168
- CREATE OR REPLACE FUNCTION ${sql.raw(`"${authSchema}"`)}.sync_uid_user_id() RETURNS trigger AS $$
169
- BEGIN
170
- IF NEW.uid IS NULL AND NEW.user_id IS NOT NULL THEN
171
- NEW.uid := NEW.user_id;
172
- ELSIF NEW.user_id IS NULL AND NEW.uid IS NOT NULL THEN
173
- NEW.user_id := NEW.uid;
174
- END IF;
175
- RETURN NEW;
176
- END $$ LANGUAGE plpgsql
177
- `);
178
-
179
- for (const authTable of [
182
+ const legacyFkTables = [
180
183
  "user_identities",
181
184
  "refresh_tokens",
182
185
  "password_reset_tokens",
183
186
  "magic_link_tokens",
184
187
  "mfa_factors",
185
188
  "recovery_codes"
186
- ]) {
189
+ ];
190
+
191
+ // Only on a database that actually carries the legacy column. This whole
192
+ // block is a 0.x compatibility shim, and it used to run unconditionally —
193
+ // so every brand-new database was provisioned with a trigger function
194
+ // written to reconcile a column it can never have, permanently, as part
195
+ // of its first boot. A fresh install should not ship someone else's
196
+ // migration history.
197
+ // The table list is inlined rather than bound: drizzle expands a JS
198
+ // array into a parameter TUPLE — `ANY(($2, $3, …))` — which Postgres
199
+ // rejects, and the thrown error is swallowed by the catch around this
200
+ // whole function, so auth would silently stop provisioning. These are
201
+ // module-level constants, not input.
202
+ const legacyFkTableList = legacyFkTables.map(t => `'${t}'`).join(", ");
203
+ const legacyUserIdPresent = await db.execute(sql`
204
+ SELECT 1
205
+ FROM information_schema.columns
206
+ WHERE table_schema = ${authSchema}
207
+ AND table_name IN (${sql.raw(legacyFkTableList)})
208
+ AND column_name = 'user_id'
209
+ LIMIT 1
210
+ `);
211
+
212
+ if (legacyUserIdPresent.rows.length > 0) {
213
+ await db.execute(sql`
214
+ CREATE OR REPLACE FUNCTION ${sql.raw(`"${authSchema}"`)}.sync_uid_user_id() RETURNS trigger AS $$
215
+ BEGIN
216
+ IF NEW.uid IS NULL AND NEW.user_id IS NOT NULL THEN
217
+ NEW.uid := NEW.user_id;
218
+ ELSIF NEW.user_id IS NULL AND NEW.uid IS NOT NULL THEN
219
+ NEW.user_id := NEW.uid;
220
+ END IF;
221
+ RETURN NEW;
222
+ END $$ LANGUAGE plpgsql
223
+ `);
224
+ }
225
+
226
+ for (const authTable of legacyUserIdPresent.rows.length > 0 ? legacyFkTables : []) {
187
227
  const qualified = `"${authSchema}"."${authTable}"`;
188
228
  await db.execute(sql`
189
229
  DO $$
@@ -335,39 +375,21 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
335
375
  )
336
376
  `);
337
377
 
338
- // Create the `auth` schema with PostgreSQL RLS helper functions.
339
- await db.execute(sql`CREATE SCHEMA IF NOT EXISTS auth`);
340
-
341
- // Use an advisory transaction lock to serialize function recreation during HMR
378
+ // The RLS helper functions every generated policy calls. They live in
379
+ // `rebase`, alongside the tables above — Rebase creates exactly one
380
+ // schema in a user's database. Advisory-locked so concurrent HMR
381
+ // reloads cannot race on `CREATE OR REPLACE`.
382
+ //
383
+ // The same statements the migration preamble carries, from the same
384
+ // constant — these definitions being identical across the boot path and
385
+ // the migration stream is the whole point of having them in one place.
386
+ // One call per statement: this handle speaks the extended query
387
+ // protocol, which rejects multi-command strings.
342
388
  await db.transaction(async (tx) => {
343
389
  await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init'))`);
344
-
345
- // Falls back to the pre-rename `app.user_id` so a database that has
346
- // taken the new schema but is still served by an older backend keeps
347
- // resolving the principal. Keep in sync with AUTH_BOOTSTRAP_SQL.
348
- await tx.execute(sql`
349
- CREATE OR REPLACE FUNCTION auth.uid() RETURNS text AS $$
350
- SELECT COALESCE(
351
- NULLIF(current_setting('app.uid', true), ''),
352
- NULLIF(current_setting('app.user_id', true), '')
353
- );
354
- $$ LANGUAGE sql STABLE
355
- `);
356
-
357
- await tx.execute(sql`
358
- CREATE OR REPLACE FUNCTION auth.jwt() RETURNS jsonb AS $$
359
- SELECT COALESCE(
360
- NULLIF(current_setting('app.jwt', true), ''),
361
- '{}'
362
- )::jsonb;
363
- $$ LANGUAGE sql STABLE
364
- `);
365
-
366
- await tx.execute(sql`
367
- CREATE OR REPLACE FUNCTION auth.roles() RETURNS text AS $$
368
- SELECT COALESCE(NULLIF(current_setting('app.user_roles', true), ''), '');
369
- $$ LANGUAGE sql STABLE
370
- `);
390
+ for (const statement of RLS_BOOTSTRAP_STATEMENTS) {
391
+ await tx.execute(sql.raw(statement));
392
+ }
371
393
  });
372
394
 
373
395
  // Seed default roles if none exist
@@ -378,26 +400,16 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
378
400
  // database provisioned by an older framework era is missing every
379
401
  // column added since. Each column the auth services read or write must
380
402
  // be back-filled here, or upgraded deployments break on the first
381
- // statement that references it. `email` is deliberately absent: it has
382
- // existed since the first era and cannot be added NOT NULL safely.
383
- const userColumnBackfills = [
384
- "display_name TEXT",
385
- "photo_url TEXT",
386
- "roles TEXT[] DEFAULT '{}' NOT NULL",
387
- "password_hash TEXT",
388
- "email_verified BOOLEAN DEFAULT FALSE NOT NULL",
389
- "email_verification_token TEXT",
390
- "email_verification_sent_at TIMESTAMP WITH TIME ZONE",
391
- "is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
392
- "metadata JSONB DEFAULT '{}' NOT NULL",
393
- "tokens_valid_after TIMESTAMP WITH TIME ZONE",
394
- "created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
395
- "updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
396
- ];
397
- for (const columnDef of userColumnBackfills) {
403
+ // statement that references it.
404
+ //
405
+ // `email` is skipped: it has existed since the first era, so it is never
406
+ // the missing one, and `ADD COLUMN … NOT NULL` with no default fails on
407
+ // a table with rows.
408
+ for (const spec of AUTH_USERS_COLUMNS) {
409
+ if (spec.column === "email") continue;
398
410
  await db.execute(sql`
399
411
  ALTER TABLE ${sql.raw(usersTableName)}
400
- ADD COLUMN IF NOT EXISTS ${sql.raw(columnDef)}
412
+ ADD COLUMN IF NOT EXISTS ${sql.raw(`${spec.column} ${authUsersColumnSql(spec)}`)}
401
413
  `);
402
414
  }
403
415
 
@@ -407,14 +419,69 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
407
419
  // statement past this point has to tolerate that rather than abort the
408
420
  // whole migration block.
409
421
  const usersColumns = await db.execute(sql`
410
- SELECT column_name, data_type
422
+ SELECT column_name, data_type, is_nullable, column_default
411
423
  FROM information_schema.columns
412
424
  WHERE table_schema = ${usersSchema} AND table_name = ${resolvedTable}
413
425
  `);
414
- const usersColumnTypes = new Map(
415
- (usersColumns.rows as { column_name: string; data_type: string }[])
416
- .map(row => [row.column_name, row.data_type])
417
- );
426
+ type UsersColumnRow = {
427
+ column_name: string;
428
+ data_type: string;
429
+ is_nullable: "YES" | "NO";
430
+ column_default: string | null;
431
+ };
432
+ const usersColumnRows = usersColumns.rows as UsersColumnRow[];
433
+ const usersColumnTypes = new Map(usersColumnRows.map(row => [row.column_name, row.data_type]));
434
+ const usersColumnState = new Map(usersColumnRows.map(row => [row.column_name, row]));
435
+
436
+ // ── Migration: restore defaults and NOT NULL that another creator dropped ──
437
+ // `ADD COLUMN IF NOT EXISTS` above only creates what is MISSING. A column
438
+ // that exists with the wrong shape stays wrong forever — and until
439
+ // AUTH_USERS_COLUMNS became the single source, that was the normal
440
+ // outcome rather than an edge case: whichever of `db push`, boot-ensure
441
+ // and this function reached the table first decided its constraints, so
442
+ // a managed deploy ended up with a nullable `email`, a `roles` with no
443
+ // `'{}'` default, and an `email_verified` that could be NULL.
444
+ //
445
+ // Ordered DEFAULT → back-fill → SET NOT NULL, because SET NOT NULL is
446
+ // checked against existing rows: without the back-fill it throws on the
447
+ // very databases that need it. `email` can carry no default, so a NULL
448
+ // there is not repairable automatically — say so and leave the column
449
+ // alone rather than inventing an address.
450
+ for (const spec of AUTH_USERS_COLUMNS) {
451
+ const state = usersColumnState.get(spec.column);
452
+ if (!state) continue;
453
+
454
+ if (spec.default !== undefined && state.column_default === null) {
455
+ await db.execute(sql`
456
+ ALTER TABLE ${sql.raw(usersTableName)}
457
+ ALTER COLUMN ${sql.raw(`"${spec.column}"`)} SET DEFAULT ${sql.raw(spec.default)}
458
+ `);
459
+ logger.info(`🔧 Restored the default on ${usersTableName}.${spec.column}`);
460
+ }
461
+
462
+ if (!spec.notNull || state.is_nullable !== "YES") continue;
463
+
464
+ if (spec.default !== undefined) {
465
+ await db.execute(sql`
466
+ UPDATE ${sql.raw(usersTableName)}
467
+ SET ${sql.raw(`"${spec.column}"`)} = ${sql.raw(spec.default)}
468
+ WHERE ${sql.raw(`"${spec.column}"`)} IS NULL
469
+ `);
470
+ }
471
+ try {
472
+ await db.execute(sql`
473
+ ALTER TABLE ${sql.raw(usersTableName)}
474
+ ALTER COLUMN ${sql.raw(`"${spec.column}"`)} SET NOT NULL
475
+ `);
476
+ logger.info(`🔧 Restored NOT NULL on ${usersTableName}.${spec.column}`);
477
+ } catch (err) {
478
+ logger.warn(
479
+ `⚠️ ${usersTableName}.${spec.column} should be NOT NULL but still holds NULLs, so the ` +
480
+ "constraint was not applied. Fill or remove those rows and restart: " +
481
+ (err instanceof Error ? err.message : String(err))
482
+ );
483
+ }
484
+ }
418
485
 
419
486
  // ── Migration: VARCHAR(n) → TEXT on the users string columns ────────
420
487
  // Tables created before the widths came off still carry them. Postgres
@@ -717,15 +784,22 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
717
784
  // registration after an upgrade fails with SQLSTATE 42501. Reconcile
718
785
  // on boot; only tables actually flagged get the ALTER (and its lock).
719
786
  try {
787
+ // Every table this function creates, not a subset. `magic_link_tokens`
788
+ // and `schema_meta` were missing here while their six siblings were
789
+ // listed — so on a database carrying FORCE from the older RLS model,
790
+ // magic-link sign-in kept failing 42501 after the upgrade that was
791
+ // supposed to fix exactly that, and only for the one auth method.
720
792
  const authTablePairs: [string, string][] = [
721
793
  [usersSchema, resolvedTable],
722
794
  [authSchema, "user_identities"],
723
795
  [authSchema, "refresh_tokens"],
724
796
  [authSchema, "password_reset_tokens"],
797
+ [authSchema, "magic_link_tokens"],
725
798
  [authSchema, "app_config"],
726
799
  [authSchema, "mfa_factors"],
727
800
  [authSchema, "mfa_challenges"],
728
- [authSchema, "recovery_codes"]
801
+ [authSchema, "recovery_codes"],
802
+ [authSchema, "schema_meta"]
729
803
  ];
730
804
  for (const [schemaName, tableName] of authTablePairs) {
731
805
  const forced = await db.execute(sql`
@@ -756,10 +830,35 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
756
830
  );
757
831
  }
758
832
 
759
- // Stamped last, so a boot that died partway through the migrations above
760
- // leaves the older stamp in place and the next boot runs them again.
833
+ // Stamped last of the MIGRATIONS, so a boot that died partway through
834
+ // the ones above leaves the older stamp in place and the next boot runs
835
+ // them again.
761
836
  await stampAuthSchemaVersion(db, authSchema);
762
837
 
838
+ // ── Keep the end-user role out of auth's tables ─────────────────────
839
+ // These carry session token hashes, TOTP secrets and recovery codes, and
840
+ // none of them has RLS — they are not collections, so nothing ever
841
+ // compiled a policy for them. Meanwhile the role provisioning grants
842
+ // `rebase_user` DML on every table in this schema, and its
843
+ // ALTER DEFAULT PRIVILEGES reaches the ones created right here, after it
844
+ // ran. So the grant has to come back off; see `revokeInternalTableSql`
845
+ // for why a revoke rather than an empty RLS policy set.
846
+ //
847
+ // After the stamp deliberately: `schema_meta` is created BY the stamp,
848
+ // so revoking first would leave the one table holding this database's
849
+ // schema version writable by every signed-in user until the next boot.
850
+ // Nothing below re-runs the migrations, so the stamp's guarantee holds.
851
+ await revokeInternalTableAccess(
852
+ async (text) => { await db.execute(sql.raw(text)); },
853
+ authSchema,
854
+ {
855
+ onError: (table, err) => logger.warn(
856
+ `🔐 Could not revoke authenticated-role access to "${authSchema}"."${table}": ` +
857
+ (err instanceof Error ? err.message : String(err))
858
+ )
859
+ }
860
+ );
861
+
763
862
  logger.info("✅ Auth tables ready");
764
863
  } catch (error) {
765
864
  // The one failure that must not be survived. Continuing here is what