@rebasepro/server-postgres 0.9.1-canary.a57c262 → 0.9.1-canary.a8dbf1c

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 (42) hide show
  1. package/README.md +21 -0
  2. package/dist/PostgresBackendDriver.d.ts +18 -0
  3. package/dist/PostgresBootstrapper.d.ts +17 -1
  4. package/dist/auth/services.d.ts +68 -52
  5. package/dist/connection.d.ts +21 -0
  6. package/dist/index.es.js +914 -226
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/schema/auth-bootstrap-sql.d.ts +1 -1
  9. package/dist/schema/auth-schema.d.ts +24 -24
  10. package/dist/schema/doctor.d.ts +1 -1
  11. package/dist/schema/introspect-db-logic.d.ts +0 -5
  12. package/dist/schema/introspect-db-naming.d.ts +10 -0
  13. package/dist/security/policy-drift.d.ts +30 -0
  14. package/dist/security/rls-enforcement.d.ts +2 -2
  15. package/dist/services/channel-history.d.ts +118 -0
  16. package/dist/services/realtimeService.d.ts +69 -2
  17. package/dist/services/row-pipeline.d.ts +5 -2
  18. package/package.json +8 -31
  19. package/src/PostgresBackendDriver.ts +74 -10
  20. package/src/PostgresBootstrapper.ts +69 -3
  21. package/src/auth/ensure-tables.ts +170 -28
  22. package/src/auth/services.ts +181 -150
  23. package/src/cli.ts +60 -0
  24. package/src/connection.ts +61 -1
  25. package/src/databasePoolManager.ts +2 -0
  26. package/src/schema/auth-bootstrap-sql.ts +7 -1
  27. package/src/schema/auth-schema.ts +13 -13
  28. package/src/schema/doctor.ts +45 -20
  29. package/src/schema/introspect-db-inference.ts +1 -1
  30. package/src/schema/introspect-db-logic.ts +1 -10
  31. package/src/schema/introspect-db-naming.ts +15 -0
  32. package/src/schema/introspect-db.ts +11 -1
  33. package/src/schema/introspect-runtime.ts +1 -1
  34. package/src/security/policy-drift.test.ts +106 -1
  35. package/src/security/policy-drift.ts +56 -0
  36. package/src/security/rls-enforcement.ts +11 -5
  37. package/src/services/PersistService.ts +9 -2
  38. package/src/services/channel-history.ts +343 -0
  39. package/src/services/realtimeService.ts +198 -10
  40. package/src/services/row-pipeline.ts +70 -7
  41. package/src/utils/drizzle-conditions.ts +13 -0
  42. package/src/websocket.ts +30 -11
@@ -111,6 +111,7 @@ export class PostgresBackendDriver implements DataDriver {
111
111
  executeSql: (...args: Parameters<NonNullable<DatabaseAdmin["executeSql"]>>) => this.executeSql(...args),
112
112
  fetchAvailableDatabases: () => this.fetchAvailableDatabases(),
113
113
  fetchAvailableRoles: () => this.fetchAvailableRoles(),
114
+ fetchApplicationRoles: () => this.fetchApplicationRoles(),
114
115
  fetchCurrentDatabase: () => this.fetchCurrentDatabase(),
115
116
  fetchUnmappedTables: (...args: Parameters<NonNullable<DatabaseAdmin["fetchUnmappedTables"]>>) => this.fetchUnmappedTables(...args),
116
117
  fetchTableMetadata: (...args: Parameters<NonNullable<DatabaseAdmin["fetchTableMetadata"]>>) => this.fetchTableMetadata(...args),
@@ -548,13 +549,26 @@ export class PostgresBackendDriver implements DataDriver {
548
549
  let updatedValues = values;
549
550
  const contextForCallback = this.buildCallContext();
550
551
 
551
- // Fetch previous values for callbacks AND history recording
552
+ // Fetch previous values for callbacks AND history recording. Same walk
553
+ // as the saved row the callbacks receive (`fetchOneForRest`), so
554
+ // `values` and `previousValues` compare like with like — a Date on one
555
+ // side and its ISO string on the other reads as a change that never
556
+ // happened.
552
557
  let previousValuesForHistory: Partial<M> | undefined;
553
558
  if (status === "existing" && id) {
554
- const existing = await this.dataService.fetchOne<M>(path, id, resolvedCollection?.databaseId);
555
- if (existing) {
556
- const { id: _existingId, ...existingValues } = existing;
557
- previousValuesForHistory = existingValues as Partial<M>;
559
+ try {
560
+ const existing = await this.dataService.getFetchService()
561
+ .fetchOneForRest(path, id, undefined, resolvedCollection?.databaseId);
562
+ if (existing) {
563
+ const { id: _existingId, ...existingValues } = existing;
564
+ previousValuesForHistory = existingValues as Partial<M>;
565
+ }
566
+ } catch (err) {
567
+ // Best-effort enrichment: callbacks and history run without
568
+ // previous values rather than the save failing on a read the
569
+ // write itself does not need (e.g. a collection whose key the
570
+ // registry cannot resolve).
571
+ logger.debug(`[save] Could not fetch previous values for "${path}"`, { detail: err instanceof Error ? err.message : String(err) });
558
572
  }
559
573
  }
560
574
 
@@ -1158,6 +1172,56 @@ export class PostgresBackendDriver implements DataDriver {
1158
1172
  return result.map((r: Record<string, unknown>) => r.rolname as string);
1159
1173
  }
1160
1174
 
1175
+ /**
1176
+ * Application-level roles actually in use in this project.
1177
+ *
1178
+ * Distinct from {@link fetchAvailableRoles}, which returns native
1179
+ * PostgreSQL roles from `pg_roles` (`postgres`, `rebase_user`, …). Those
1180
+ * are the roles the SQL editor can `SET ROLE` to. *These* are the strings
1181
+ * held in the users table's `roles` column, injected per-transaction as
1182
+ * `auth.roles()` and matched by `SecurityRule.roles`. Feeding the pg roles
1183
+ * into a `SecurityRule.roles` field produces a condition no user can ever
1184
+ * satisfy, so the two must not be conflated.
1185
+ *
1186
+ * Roles have no registry table — they were migrated out of
1187
+ * `rebase.user_roles` onto an inline `roles TEXT[]` column — so the live
1188
+ * set is derived from what is assigned. A role that is declared in a policy
1189
+ * but held by nobody yet cannot be discovered here; callers that need it
1190
+ * should union in the roles they already know about.
1191
+ */
1192
+ async fetchApplicationRoles(): Promise<string[]> {
1193
+ // The users table lives in `rebase` for a default (public) setup, but
1194
+ // follows the configured schema otherwise — locate it rather than
1195
+ // assuming. The `roles` ARRAY column is what makes it the auth table.
1196
+ const located = await this.executeSql(`
1197
+ SELECT table_schema, table_name
1198
+ FROM information_schema.columns
1199
+ WHERE column_name = 'roles'
1200
+ AND data_type = 'ARRAY'
1201
+ AND table_name = 'users'
1202
+ AND table_schema NOT IN ('information_schema', 'pg_catalog')
1203
+ ORDER BY (table_schema = 'rebase') DESC, table_schema
1204
+ LIMIT 1;
1205
+ `);
1206
+ if (located.length === 0) return [];
1207
+
1208
+ const schema = located[0].table_schema as string;
1209
+ const table = located[0].table_name as string;
1210
+ // Identifiers come from information_schema, not user input, but they
1211
+ // are still interpolated — quote them so odd-but-legal names survive.
1212
+ const qualified = `"${schema.replace(/"/g, "\"\"")}"."${table.replace(/"/g, "\"\"")}"`;
1213
+
1214
+ const rows = await this.executeSql(`
1215
+ SELECT DISTINCT unnest(roles) AS role
1216
+ FROM ${qualified}
1217
+ WHERE roles IS NOT NULL
1218
+ ORDER BY role;
1219
+ `);
1220
+ return rows
1221
+ .map((r) => r.role as string)
1222
+ .filter((r): r is string => typeof r === "string" && r.length > 0);
1223
+ }
1224
+
1161
1225
  async fetchCurrentDatabase(): Promise<string | undefined> {
1162
1226
  return this.poolManager?.defaultDatabaseName;
1163
1227
  }
@@ -1361,10 +1425,10 @@ export class AuthenticatedPostgresBackendDriver implements DataDriver {
1361
1425
  const pendingNotifications: PostgresBackendDriver["_pendingNotifications"] = [];
1362
1426
 
1363
1427
  const result = await this.delegate.db.transaction(async (tx) => {
1364
- let userId = this.user?.uid;
1365
- if (!userId) {
1428
+ let uid = this.user?.uid;
1429
+ if (!uid) {
1366
1430
  logger.warn("[DataDriver] User ID (uid) is missing for authenticated delegate. Using 'anonymous'. User object", { detail: this.user });
1367
- userId = "anonymous";
1431
+ uid = "anonymous";
1368
1432
  }
1369
1433
 
1370
1434
  const userRoles = this.user?.roles ?? [];
@@ -1383,7 +1447,7 @@ export class AuthenticatedPostgresBackendDriver implements DataDriver {
1383
1447
  //
1384
1448
  // Fails closed: if the switch cannot be performed, the transaction
1385
1449
  // aborts rather than falling back to an RLS-bypassing connection.
1386
- await applyAuthContext(tx, { userId, roles: userRoles }, this.delegate.rlsUserRole);
1450
+ await applyAuthContext(tx, { uid, roles: userRoles }, this.delegate.rlsUserRole);
1387
1451
 
1388
1452
  const txEntityService = new DataService(tx, this.delegate.registry);
1389
1453
  const txDelegate = new PostgresBackendDriver(tx, this.delegate.realtimeService, this.delegate.registry, this.user, this.delegate.poolManager, this.delegate.historyService);
@@ -1422,7 +1486,7 @@ export class AuthenticatedPostgresBackendDriver implements DataDriver {
1422
1486
  */
1423
1487
  private injectAuthContext(unsubscribe: () => void): () => void {
1424
1488
  const authContext = {
1425
- userId: this.user?.uid || "anonymous",
1489
+ uid: this.user?.uid || "anonymous",
1426
1490
  roles: this.user?.roles ?? []
1427
1491
  };
1428
1492
  const entries = Array.from(this.delegate.realtimeService.subscriptions.entries());
@@ -17,7 +17,8 @@ import {
17
17
  CollectionConfig,
18
18
  type HistoryConfig,
19
19
  InitializedDriver,
20
- RealtimeProvider
20
+ RealtimeProvider,
21
+ type RealtimeChannelsConfig
21
22
  } from "@rebasepro/types";
22
23
  import { PostgresBackendDriver } from "./PostgresBackendDriver";
23
24
  import { RealtimeService } from "./services/realtimeService";
@@ -52,6 +53,12 @@ export interface PostgresDriverConfig {
52
53
  * (BaaS mode). Defaults to `public`.
53
54
  */
54
55
  introspectionSchema?: string;
56
+ /**
57
+ * Realtime options. Currently only channel retention, which is opt-in:
58
+ * without rules here no channel keeps any history and broadcast stays
59
+ * fire-and-forget. See {@link ChannelRetentionRule}.
60
+ */
61
+ realtime?: RealtimeChannelsConfig;
55
62
  }
56
63
 
57
64
  /**
@@ -65,6 +72,15 @@ export interface PostgresDriverInternals {
65
72
  realtimeService: RealtimeService;
66
73
  driver: PostgresBackendDriver;
67
74
  poolManager?: DatabasePoolManager;
75
+ /**
76
+ * Attach CDC triggers to tables that did not exist when the driver
77
+ * bootstrapped. Only set when database-level capture is actually active.
78
+ *
79
+ * Auth owns its own tables and creates them later in boot, so at driver
80
+ * bootstrap they are legitimately missing and get skipped; without this
81
+ * they would stay uninstrumented until the next restart.
82
+ */
83
+ provisionCdcForTables?: (tables: CdcTableRef[]) => Promise<void>;
68
84
  }
69
85
 
70
86
  // Re-export from shared CLI error utilities
@@ -307,6 +323,16 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
307
323
  }
308
324
  }
309
325
 
326
+ // ── Channel history ──────────────────────────────────────────────
327
+ // Opt-in per channel pattern. With no rules this creates no tables
328
+ // and leaves broadcast on its original fire-and-forget path, so
329
+ // presence-only apps pay nothing for it.
330
+ try {
331
+ await realtimeService.configureChannelHistory(pgConfig.realtime?.channels);
332
+ } catch (err) {
333
+ logger.warn("⚠️ Could not initialize channel history tables — retained channels will not replay", { error: err });
334
+ }
335
+
310
336
  // ── Realtime change source ───────────────────────────────────────
311
337
  // Prefer DATABASE_DIRECT_URL to bypass PgBouncer for LISTEN/NOTIFY.
312
338
  const directUrl = process.env.DATABASE_DIRECT_URL || pgConfig.connectionString;
@@ -334,6 +360,7 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
334
360
  const wantsCdc = cdcMode !== "off";
335
361
  const explicitCdc = cdcMode === "trigger" || cdcMode === "wal";
336
362
  let cdcEnabled = false;
363
+ let provisionCdcForTables: PostgresDriverInternals["provisionCdcForTables"];
337
364
 
338
365
  if (wantsCdc && !directUrl) {
339
366
  const reason = "no direct database connection is available for the realtime LISTEN client (set DATABASE_DIRECT_URL)";
@@ -367,6 +394,11 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
367
394
  await provisionTriggerCdc(cdcRunSql, cdcTables);
368
395
  await realtimeService.enableCdc(directUrl);
369
396
  cdcEnabled = true;
397
+ // Boot steps that create their own tables (auth) run after
398
+ // this one and use it to instrument what they just created.
399
+ provisionCdcForTables = async (tables) => {
400
+ await provisionTriggerCdc(cdcRunSql, tables);
401
+ };
370
402
  logger.info(
371
403
  `📡 [CDC] Realtime source = database-level change capture (mode: ${cdcMode === "wal" ? "wal→trigger" : "trigger"}). ` +
372
404
  `All writes now emit realtime events regardless of origin.`
@@ -417,6 +449,14 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
417
449
  );
418
450
  const missing: Array<{ slug: string; table: string }> = [];
419
451
  for (const col of registeredCollections) {
452
+ // Auth owns its table and creates it later in this same
453
+ // boot (initializeAuth → ensureAuthTablesExist), so it is
454
+ // legitimately absent right now. Reporting it as drift
455
+ // tells the user to `db:push` a table that is about to
456
+ // exist — and on an introspected database, one that the
457
+ // database was never supposed to hold.
458
+ if ((col as { auth?: { enabled?: boolean } }).auth?.enabled) continue;
459
+
420
460
  const schemaName = "schema" in col && col.schema ? col.schema : "public";
421
461
  const tableName = registry.hasTableForCollection(
422
462
  col.table ?? col.slug
@@ -431,8 +471,10 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
431
471
  const checkName = resolvedTable ?? tableName;
432
472
  const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
433
473
  if (!dbTables.has(fullCheckName)) {
474
+ // Report what was actually looked up: an unqualified
475
+ // "users" sends people hunting for public.users.
434
476
  missing.push({ slug: col.slug,
435
- table: checkName });
477
+ table: fullCheckName });
436
478
  }
437
479
  }
438
480
  if (missing.length > 0) {
@@ -466,7 +508,8 @@ table: checkName });
466
508
  registry,
467
509
  realtimeService,
468
510
  driver,
469
- poolManager
511
+ poolManager,
512
+ provisionCdcForTables
470
513
  };
471
514
 
472
515
  return {
@@ -495,6 +538,29 @@ table: checkName });
495
538
  // ensureAuthTablesExist works with the collection abstraction — no Drizzle leakage.
496
539
  await ensureAuthTablesExist(db, authCollection);
497
540
 
541
+ // The driver bootstrapped before these tables existed, so CDC skipped
542
+ // them. Instrument them now, or writes to the user table emit no
543
+ // realtime events until the next restart.
544
+ if (authCollection && internals.provisionCdcForTables) {
545
+ const authSchema = "schema" in authCollection && typeof authCollection.schema === "string"
546
+ ? authCollection.schema
547
+ : "rebase";
548
+ const authTable = "table" in authCollection && typeof authCollection.table === "string"
549
+ ? authCollection.table
550
+ : authCollection.slug;
551
+ if (authTable) {
552
+ try {
553
+ await internals.provisionCdcForTables([{ schema: authSchema, table: authTable }]);
554
+ } catch (err) {
555
+ logger.warn(
556
+ `⚠️ [CDC] Could not attach change-capture to the auth table "${authSchema}.${authTable}" — ` +
557
+ "writes to it won't emit database-level events.",
558
+ { detail: err instanceof Error ? err.message : String(err) }
559
+ );
560
+ }
561
+ }
562
+ }
563
+
498
564
  let emailService: EmailService | undefined;
499
565
  if (authConfig.email) {
500
566
  emailService = createEmailService(authConfig.email as EmailConfig);
@@ -112,13 +112,87 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
112
112
  )
113
113
  `);
114
114
 
115
+ // ── Migration: auth FK column user_id → uid, phase 1 (expand) ───────
116
+ // Must run BEFORE the dependent tables below: CREATE TABLE IF NOT
117
+ // EXISTS never revisits an existing table, so a database provisioned
118
+ // before this migration still lacks `uid` — and the
119
+ // CREATE INDEX ... (uid) statements that follow would fail on it.
120
+ //
121
+ // Deliberately NOT a plain RENAME. Both Cloud Run and Kubernetes roll
122
+ // deploys, so old and new pods serve the same database at the same time,
123
+ // and a rollback puts old code back in front of a migrated database. A
124
+ // rename breaks every auth query on whichever side is out of step.
125
+ // Instead: add `uid`, backfill it, drop the NOT NULL on `user_id`, and
126
+ // keep the two in sync with a trigger, so a backend of either era can
127
+ // read and write. `scripts/drop-legacy-auth-user-id.sql` removes the
128
+ // column once no old backend remains (phase 2, contract).
129
+ //
130
+ // Idempotent throughout: every step is guarded on catalogue state.
131
+ await db.execute(sql`
132
+ CREATE OR REPLACE FUNCTION ${sql.raw(`"${authSchema}"`)}.sync_uid_user_id() RETURNS trigger AS $$
133
+ BEGIN
134
+ IF NEW.uid IS NULL AND NEW.user_id IS NOT NULL THEN
135
+ NEW.uid := NEW.user_id;
136
+ ELSIF NEW.user_id IS NULL AND NEW.uid IS NOT NULL THEN
137
+ NEW.user_id := NEW.uid;
138
+ END IF;
139
+ RETURN NEW;
140
+ END $$ LANGUAGE plpgsql
141
+ `);
142
+
143
+ for (const authTable of [
144
+ "user_identities",
145
+ "refresh_tokens",
146
+ "password_reset_tokens",
147
+ "magic_link_tokens",
148
+ "mfa_factors",
149
+ "recovery_codes"
150
+ ]) {
151
+ const qualified = `"${authSchema}"."${authTable}"`;
152
+ await db.execute(sql`
153
+ DO $$
154
+ DECLARE
155
+ has_legacy boolean;
156
+ has_uid boolean;
157
+ BEGIN
158
+ SELECT
159
+ bool_or(column_name = 'user_id'),
160
+ bool_or(column_name = 'uid')
161
+ INTO has_legacy, has_uid
162
+ FROM information_schema.columns
163
+ WHERE table_schema = ${sql.raw(`'${authSchema}'`)}
164
+ AND table_name = ${sql.raw(`'${authTable}'`)};
165
+
166
+ -- Table absent, or already uid-only (a fresh install, or
167
+ -- phase 2 already run): nothing to do.
168
+ IF has_legacy IS NOT TRUE THEN
169
+ RETURN;
170
+ END IF;
171
+
172
+ IF has_uid IS NOT TRUE THEN
173
+ EXECUTE ${sql.raw(`'ALTER TABLE ${qualified} ADD COLUMN uid ${userIdType} REFERENCES ${usersTableName}(id) ON DELETE CASCADE'`)};
174
+ EXECUTE ${sql.raw(`'UPDATE ${qualified} SET uid = user_id WHERE uid IS NULL'`)};
175
+ EXECUTE ${sql.raw(`'CREATE INDEX IF NOT EXISTS idx_${authTable}_uid ON ${qualified}(uid)'`)};
176
+ END IF;
177
+
178
+ -- New code inserts uid and never user_id, so the legacy
179
+ -- column can no longer be NOT NULL. The trigger below
180
+ -- backfills it, but the constraint is checked first.
181
+ EXECUTE ${sql.raw(`'ALTER TABLE ${qualified} ALTER COLUMN user_id DROP NOT NULL'`)};
182
+
183
+ EXECUTE ${sql.raw(`'DROP TRIGGER IF EXISTS sync_uid_user_id ON ${qualified}'`)};
184
+ EXECUTE ${sql.raw(`'CREATE TRIGGER sync_uid_user_id BEFORE INSERT OR UPDATE ON ${qualified} FOR EACH ROW EXECUTE FUNCTION "${authSchema}".sync_uid_user_id()'`)};
185
+ END $$
186
+ `);
187
+ }
188
+
115
189
  // ── Create dependent auth tables (idempotent) ───────────────────
116
190
 
117
191
  // Create user_identities table
118
192
  await db.execute(sql`
119
193
  CREATE TABLE IF NOT EXISTS ${sql.raw(userIdentitiesTable)} (
120
194
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
121
- user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
195
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
122
196
  provider TEXT NOT NULL,
123
197
  provider_id TEXT NOT NULL,
124
198
  profile_data JSONB,
@@ -131,7 +205,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
131
205
  // Create indexes on user_identities
132
206
  await db.execute(sql`
133
207
  CREATE INDEX IF NOT EXISTS idx_user_identities_user
134
- ON ${sql.raw(userIdentitiesTable)}(user_id)
208
+ ON ${sql.raw(userIdentitiesTable)}(uid)
135
209
  `);
136
210
 
137
211
 
@@ -139,13 +213,13 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
139
213
  await db.execute(sql`
140
214
  CREATE TABLE IF NOT EXISTS ${sql.raw(refreshTokensTableName)} (
141
215
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
142
- user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
216
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
143
217
  token_hash TEXT NOT NULL UNIQUE,
144
218
  expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
145
219
  user_agent TEXT,
146
220
  ip_address TEXT,
147
221
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
148
- CONSTRAINT unique_device_session UNIQUE (user_id, user_agent, ip_address)
222
+ CONSTRAINT unique_device_session UNIQUE (uid, user_agent, ip_address)
149
223
  )
150
224
  `);
151
225
 
@@ -155,17 +229,17 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
155
229
  ON ${sql.raw(refreshTokensTableName)}(token_hash)
156
230
  `);
157
231
 
158
- // Create index on user_id for cleanup operations
232
+ // Create index on uid for cleanup operations
159
233
  await db.execute(sql`
160
234
  CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user
161
- ON ${sql.raw(refreshTokensTableName)}(user_id)
235
+ ON ${sql.raw(refreshTokensTableName)}(uid)
162
236
  `);
163
237
 
164
238
  // Create password reset tokens table
165
239
  await db.execute(sql`
166
240
  CREATE TABLE IF NOT EXISTS ${sql.raw(passwordResetTokensTableName)} (
167
241
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
168
- user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
242
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
169
243
  token_hash TEXT NOT NULL UNIQUE,
170
244
  expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
171
245
  used_at TIMESTAMP WITH TIME ZONE,
@@ -179,10 +253,10 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
179
253
  ON ${sql.raw(passwordResetTokensTableName)}(token_hash)
180
254
  `);
181
255
 
182
- // Create index on user_id for password reset cleanup
256
+ // Create index on uid for password reset cleanup
183
257
  await db.execute(sql`
184
258
  CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user
185
- ON ${sql.raw(passwordResetTokensTableName)}(user_id)
259
+ ON ${sql.raw(passwordResetTokensTableName)}(uid)
186
260
  `);
187
261
 
188
262
  // Create magic link tokens table
@@ -190,7 +264,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
190
264
  await db.execute(sql`
191
265
  CREATE TABLE IF NOT EXISTS ${sql.raw(magicLinkTokensTableName)} (
192
266
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
193
- user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
267
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
194
268
  token_hash TEXT NOT NULL UNIQUE,
195
269
  expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
196
270
  used_at TIMESTAMP WITH TIME ZONE,
@@ -204,10 +278,10 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
204
278
  ON ${sql.raw(magicLinkTokensTableName)}(token_hash)
205
279
  `);
206
280
 
207
- // Create index on user_id for magic link cleanup
281
+ // Create index on uid for magic link cleanup
208
282
  await db.execute(sql`
209
283
  CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_user
210
- ON ${sql.raw(magicLinkTokensTableName)}(user_id)
284
+ ON ${sql.raw(magicLinkTokensTableName)}(uid)
211
285
  `);
212
286
 
213
287
  // Create app config table
@@ -226,9 +300,15 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
226
300
  await db.transaction(async (tx) => {
227
301
  await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init'))`);
228
302
 
303
+ // Falls back to the pre-rename `app.user_id` so a database that has
304
+ // taken the new schema but is still served by an older backend keeps
305
+ // resolving the principal. Keep in sync with AUTH_BOOTSTRAP_SQL.
229
306
  await tx.execute(sql`
230
307
  CREATE OR REPLACE FUNCTION auth.uid() RETURNS text AS $$
231
- SELECT NULLIF(current_setting('app.user_id', true), '');
308
+ SELECT COALESCE(
309
+ NULLIF(current_setting('app.uid', true), ''),
310
+ NULLIF(current_setting('app.user_id', true), '')
311
+ );
232
312
  $$ LANGUAGE sql STABLE
233
313
  `);
234
314
 
@@ -251,17 +331,32 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
251
331
  // Seed default roles if none exist
252
332
  // (no-op: roles are now stored inline on the users table)
253
333
 
254
- // ── Migration: Add is_anonymous column (safe for existing tables) ────
255
- await db.execute(sql`
256
- ALTER TABLE ${sql.raw(usersTableName)}
257
- ADD COLUMN IF NOT EXISTS is_anonymous BOOLEAN DEFAULT FALSE
258
- `);
259
-
260
- // ── Migration: Add inline roles column (safe for existing tables) ────
261
- await db.execute(sql`
262
- ALTER TABLE ${sql.raw(usersTableName)}
263
- ADD COLUMN IF NOT EXISTS roles TEXT[] DEFAULT '{}' NOT NULL
264
- `);
334
+ // ── Migration: reconcile the full users column set (safe for existing tables) ──
335
+ // CREATE TABLE IF NOT EXISTS never revisits an existing table, so a
336
+ // database provisioned by an older framework era is missing every
337
+ // column added since. Each column the auth services read or write must
338
+ // be back-filled here, or upgraded deployments break on the first
339
+ // statement that references it. `email` is deliberately absent: it has
340
+ // existed since the first era and cannot be added NOT NULL safely.
341
+ const userColumnBackfills = [
342
+ "display_name VARCHAR(255)",
343
+ "photo_url VARCHAR(500)",
344
+ "roles TEXT[] DEFAULT '{}' NOT NULL",
345
+ "password_hash VARCHAR(255)",
346
+ "email_verified BOOLEAN DEFAULT FALSE NOT NULL",
347
+ "email_verification_token VARCHAR(255)",
348
+ "email_verification_sent_at TIMESTAMP WITH TIME ZONE",
349
+ "is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
350
+ "metadata JSONB DEFAULT '{}' NOT NULL",
351
+ "created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
352
+ "updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
353
+ ];
354
+ for (const columnDef of userColumnBackfills) {
355
+ await db.execute(sql`
356
+ ALTER TABLE ${sql.raw(usersTableName)}
357
+ ADD COLUMN IF NOT EXISTS ${sql.raw(columnDef)}
358
+ `);
359
+ }
265
360
 
266
361
  // ── Migration: Copy roles from legacy junction table to inline column ──
267
362
  // If the old rebase.user_roles and rebase.roles tables exist, migrate
@@ -307,7 +402,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
307
402
  await db.execute(sql`
308
403
  CREATE TABLE IF NOT EXISTS ${sql.raw(mfaFactorsTableName)} (
309
404
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
310
- user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
405
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
311
406
  factor_type TEXT NOT NULL DEFAULT 'totp',
312
407
  secret_encrypted TEXT NOT NULL,
313
408
  friendly_name TEXT,
@@ -320,7 +415,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
320
415
  // Create indexes on mfa_factors
321
416
  await db.execute(sql`
322
417
  CREATE INDEX IF NOT EXISTS idx_mfa_factors_user
323
- ON ${sql.raw(mfaFactorsTableName)}(user_id)
418
+ ON ${sql.raw(mfaFactorsTableName)}(uid)
324
419
  `);
325
420
 
326
421
  // Create mfa_challenges table
@@ -345,7 +440,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
345
440
  await db.execute(sql`
346
441
  CREATE TABLE IF NOT EXISTS ${sql.raw(recoveryCodesTableName)} (
347
442
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
348
- user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
443
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
349
444
  code_hash TEXT NOT NULL,
350
445
  used_at TIMESTAMP WITH TIME ZONE,
351
446
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
@@ -355,9 +450,56 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
355
450
  // Create indexes on recovery_codes
356
451
  await db.execute(sql`
357
452
  CREATE INDEX IF NOT EXISTS idx_recovery_codes_user
358
- ON ${sql.raw(recoveryCodesTableName)}(user_id)
453
+ ON ${sql.raw(recoveryCodesTableName)}(uid)
359
454
  `);
360
455
 
456
+ // ── Migration: clear stale FORCE ROW LEVEL SECURITY (older RLS model) ──
457
+ // The current model never emits FORCE: privileged auth writes run as
458
+ // the table owner and rely on the owner bypassing plain ENABLE RLS
459
+ // (see generate-postgres-ddl-logic). A table still carrying FORCE from
460
+ // an older framework era binds the owner too, so the first user
461
+ // registration after an upgrade fails with SQLSTATE 42501. Reconcile
462
+ // on boot; only tables actually flagged get the ALTER (and its lock).
463
+ try {
464
+ const authTablePairs: [string, string][] = [
465
+ [usersSchema, resolvedTable],
466
+ [authSchema, "user_identities"],
467
+ [authSchema, "refresh_tokens"],
468
+ [authSchema, "password_reset_tokens"],
469
+ [authSchema, "app_config"],
470
+ [authSchema, "mfa_factors"],
471
+ [authSchema, "mfa_challenges"],
472
+ [authSchema, "recovery_codes"]
473
+ ];
474
+ for (const [schemaName, tableName] of authTablePairs) {
475
+ const forced = await db.execute(sql`
476
+ SELECT 1
477
+ FROM pg_class c
478
+ JOIN pg_namespace n ON n.oid = c.relnamespace
479
+ WHERE n.nspname = ${schemaName}
480
+ AND c.relname = ${tableName}
481
+ AND c.relforcerowsecurity
482
+ `);
483
+ if (forced.rows.length > 0) {
484
+ await db.execute(sql`
485
+ ALTER TABLE ${sql.raw(`"${schemaName}"."${tableName}"`)}
486
+ NO FORCE ROW LEVEL SECURITY
487
+ `);
488
+ logger.warn(
489
+ `🔧 Cleared stale FORCE ROW LEVEL SECURITY on "${schemaName}"."${tableName}" ` +
490
+ "(legacy RLS model — it binds the owner connection and breaks privileged auth writes)"
491
+ );
492
+ }
493
+ }
494
+ } catch (rlsReconcileError: unknown) {
495
+ // Non-fatal: the connection may lack ownership on a pre-provisioned
496
+ // table; registration will still fail loudly (42501) if FORCE remains.
497
+ logger.warn(
498
+ `⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ` +
499
+ `${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`
500
+ );
501
+ }
502
+
361
503
  logger.info("✅ Auth tables ready");
362
504
  } catch (error) {
363
505
  logger.error("❌ Failed to create auth tables", { error });