@rebasepro/server-postgres 0.9.1-canary.ff338b5 → 0.10.1-canary.18115ba

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/server-postgres",
3
3
  "type": "module",
4
- "version": "0.9.1-canary.ff338b5",
4
+ "version": "0.10.1-canary.18115ba",
5
5
  "description": "PostgreSQL data source backend implementation for Rebase with Drizzle ORM",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -29,30 +29,6 @@
29
29
  "backend",
30
30
  "rebase"
31
31
  ],
32
- "jest": {
33
- "transform": {
34
- "^.+\\.tsx?$": "ts-jest"
35
- },
36
- "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$",
37
- "testPathIgnorePatterns": [
38
- "/node_modules/",
39
- "test/e2e/"
40
- ],
41
- "moduleFileExtensions": [
42
- "ts",
43
- "tsx",
44
- "js",
45
- "jsx",
46
- "json",
47
- "node"
48
- ],
49
- "moduleNameMapper": {
50
- "^chalk$": "<rootDir>/test/mocks/chalk.cjs",
51
- "^\\.{1,2}/module-dir$": "<rootDir>/test/mocks/module-dir.cjs",
52
- "^@rebasepro/([a-z0-9-]+)$": "<rootDir>/../$1/src/index.ts",
53
- "^(\\.{1,2}/.*)\\.js$": "$1"
54
- }
55
- },
56
32
  "exports": {
57
33
  ".": {
58
34
  "types": "./dist/index.d.ts",
@@ -71,11 +47,11 @@
71
47
  "execa": "^9.6.1",
72
48
  "pg": "^8.21.0",
73
49
  "ws": "^8.21.0",
74
- "@rebasepro/server": "0.9.1-canary.ff338b5",
75
- "@rebasepro/types": "0.9.1-canary.ff338b5",
76
- "@rebasepro/codegen": "0.9.1-canary.ff338b5",
77
- "@rebasepro/common": "0.9.1-canary.ff338b5",
78
- "@rebasepro/utils": "0.9.1-canary.ff338b5"
50
+ "@rebasepro/codegen": "0.10.1-canary.18115ba",
51
+ "@rebasepro/common": "0.10.1-canary.18115ba",
52
+ "@rebasepro/utils": "0.10.1-canary.18115ba",
53
+ "@rebasepro/server": "0.10.1-canary.18115ba",
54
+ "@rebasepro/types": "0.10.1-canary.18115ba"
79
55
  },
80
56
  "devDependencies": {
81
57
  "@hono/node-server": "^2.0.9",
@@ -104,7 +80,7 @@
104
80
  "watch": "vite build --watch",
105
81
  "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json && node ../../scripts/assert-build-output.mjs",
106
82
  "test:lint": "eslint \"src/**\" --quiet",
107
- "test": "jest --passWithNoTests",
83
+ "test": "jest",
108
84
  "test:e2e": "vitest run --config vitest.e2e.config.ts",
109
85
  "clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f",
110
86
  "smoke:baas": "tsx scripts/smoke-baas.ts"
@@ -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),
@@ -1171,6 +1172,56 @@ export class PostgresBackendDriver implements DataDriver {
1171
1172
  return result.map((r: Record<string, unknown>) => r.rolname as string);
1172
1173
  }
1173
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
+
1174
1225
  async fetchCurrentDatabase(): Promise<string | undefined> {
1175
1226
  return this.poolManager?.defaultDatabaseName;
1176
1227
  }
@@ -1374,10 +1425,10 @@ export class AuthenticatedPostgresBackendDriver implements DataDriver {
1374
1425
  const pendingNotifications: PostgresBackendDriver["_pendingNotifications"] = [];
1375
1426
 
1376
1427
  const result = await this.delegate.db.transaction(async (tx) => {
1377
- let userId = this.user?.uid;
1378
- if (!userId) {
1428
+ let uid = this.user?.uid;
1429
+ if (!uid) {
1379
1430
  logger.warn("[DataDriver] User ID (uid) is missing for authenticated delegate. Using 'anonymous'. User object", { detail: this.user });
1380
- userId = "anonymous";
1431
+ uid = "anonymous";
1381
1432
  }
1382
1433
 
1383
1434
  const userRoles = this.user?.roles ?? [];
@@ -1396,7 +1447,7 @@ export class AuthenticatedPostgresBackendDriver implements DataDriver {
1396
1447
  //
1397
1448
  // Fails closed: if the switch cannot be performed, the transaction
1398
1449
  // aborts rather than falling back to an RLS-bypassing connection.
1399
- await applyAuthContext(tx, { userId, roles: userRoles }, this.delegate.rlsUserRole);
1450
+ await applyAuthContext(tx, { uid, roles: userRoles }, this.delegate.rlsUserRole);
1400
1451
 
1401
1452
  const txEntityService = new DataService(tx, this.delegate.registry);
1402
1453
  const txDelegate = new PostgresBackendDriver(tx, this.delegate.realtimeService, this.delegate.registry, this.user, this.delegate.poolManager, this.delegate.historyService);
@@ -1435,7 +1486,7 @@ export class AuthenticatedPostgresBackendDriver implements DataDriver {
1435
1486
  */
1436
1487
  private injectAuthContext(unsubscribe: () => void): () => void {
1437
1488
  const authContext = {
1438
- userId: this.user?.uid || "anonymous",
1489
+ uid: this.user?.uid || "anonymous",
1439
1490
  roles: this.user?.roles ?? []
1440
1491
  };
1441
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
  /**
@@ -316,6 +323,16 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
316
323
  }
317
324
  }
318
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
+
319
336
  // ── Realtime change source ───────────────────────────────────────
320
337
  // Prefer DATABASE_DIRECT_URL to bypass PgBouncer for LISTEN/NOTIFY.
321
338
  const directUrl = process.env.DATABASE_DIRECT_URL || pgConfig.connectionString;
@@ -107,18 +107,93 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
107
107
  email_verification_sent_at TIMESTAMP WITH TIME ZONE,
108
108
  is_anonymous BOOLEAN DEFAULT FALSE NOT NULL,
109
109
  metadata JSONB DEFAULT '{}' NOT NULL,
110
+ tokens_valid_after TIMESTAMP WITH TIME ZONE,
110
111
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
111
112
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
112
113
  )
113
114
  `);
114
115
 
116
+ // ── Migration: auth FK column user_id → uid, phase 1 (expand) ───────
117
+ // Must run BEFORE the dependent tables below: CREATE TABLE IF NOT
118
+ // EXISTS never revisits an existing table, so a database provisioned
119
+ // before this migration still lacks `uid` — and the
120
+ // CREATE INDEX ... (uid) statements that follow would fail on it.
121
+ //
122
+ // Deliberately NOT a plain RENAME. Both Cloud Run and Kubernetes roll
123
+ // deploys, so old and new pods serve the same database at the same time,
124
+ // and a rollback puts old code back in front of a migrated database. A
125
+ // rename breaks every auth query on whichever side is out of step.
126
+ // Instead: add `uid`, backfill it, drop the NOT NULL on `user_id`, and
127
+ // keep the two in sync with a trigger, so a backend of either era can
128
+ // read and write. `scripts/drop-legacy-auth-user-id.sql` removes the
129
+ // column once no old backend remains (phase 2, contract).
130
+ //
131
+ // Idempotent throughout: every step is guarded on catalogue state.
132
+ await db.execute(sql`
133
+ CREATE OR REPLACE FUNCTION ${sql.raw(`"${authSchema}"`)}.sync_uid_user_id() RETURNS trigger AS $$
134
+ BEGIN
135
+ IF NEW.uid IS NULL AND NEW.user_id IS NOT NULL THEN
136
+ NEW.uid := NEW.user_id;
137
+ ELSIF NEW.user_id IS NULL AND NEW.uid IS NOT NULL THEN
138
+ NEW.user_id := NEW.uid;
139
+ END IF;
140
+ RETURN NEW;
141
+ END $$ LANGUAGE plpgsql
142
+ `);
143
+
144
+ for (const authTable of [
145
+ "user_identities",
146
+ "refresh_tokens",
147
+ "password_reset_tokens",
148
+ "magic_link_tokens",
149
+ "mfa_factors",
150
+ "recovery_codes"
151
+ ]) {
152
+ const qualified = `"${authSchema}"."${authTable}"`;
153
+ await db.execute(sql`
154
+ DO $$
155
+ DECLARE
156
+ has_legacy boolean;
157
+ has_uid boolean;
158
+ BEGIN
159
+ SELECT
160
+ bool_or(column_name = 'user_id'),
161
+ bool_or(column_name = 'uid')
162
+ INTO has_legacy, has_uid
163
+ FROM information_schema.columns
164
+ WHERE table_schema = ${sql.raw(`'${authSchema}'`)}
165
+ AND table_name = ${sql.raw(`'${authTable}'`)};
166
+
167
+ -- Table absent, or already uid-only (a fresh install, or
168
+ -- phase 2 already run): nothing to do.
169
+ IF has_legacy IS NOT TRUE THEN
170
+ RETURN;
171
+ END IF;
172
+
173
+ IF has_uid IS NOT TRUE THEN
174
+ EXECUTE ${sql.raw(`'ALTER TABLE ${qualified} ADD COLUMN uid ${userIdType} REFERENCES ${usersTableName}(id) ON DELETE CASCADE'`)};
175
+ EXECUTE ${sql.raw(`'UPDATE ${qualified} SET uid = user_id WHERE uid IS NULL'`)};
176
+ EXECUTE ${sql.raw(`'CREATE INDEX IF NOT EXISTS idx_${authTable}_uid ON ${qualified}(uid)'`)};
177
+ END IF;
178
+
179
+ -- New code inserts uid and never user_id, so the legacy
180
+ -- column can no longer be NOT NULL. The trigger below
181
+ -- backfills it, but the constraint is checked first.
182
+ EXECUTE ${sql.raw(`'ALTER TABLE ${qualified} ALTER COLUMN user_id DROP NOT NULL'`)};
183
+
184
+ EXECUTE ${sql.raw(`'DROP TRIGGER IF EXISTS sync_uid_user_id ON ${qualified}'`)};
185
+ 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()'`)};
186
+ END $$
187
+ `);
188
+ }
189
+
115
190
  // ── Create dependent auth tables (idempotent) ───────────────────
116
191
 
117
192
  // Create user_identities table
118
193
  await db.execute(sql`
119
194
  CREATE TABLE IF NOT EXISTS ${sql.raw(userIdentitiesTable)} (
120
195
  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,
196
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
122
197
  provider TEXT NOT NULL,
123
198
  provider_id TEXT NOT NULL,
124
199
  profile_data JSONB,
@@ -131,21 +206,27 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
131
206
  // Create indexes on user_identities
132
207
  await db.execute(sql`
133
208
  CREATE INDEX IF NOT EXISTS idx_user_identities_user
134
- ON ${sql.raw(userIdentitiesTable)}(user_id)
209
+ ON ${sql.raw(userIdentitiesTable)}(uid)
135
210
  `);
136
211
 
137
212
 
138
- // Create refresh tokens table (includes user_agent, ip_address, and unique constraint)
213
+ // Create refresh tokens table. One row per TOKEN, grouped into a
214
+ // sign-in by session_id — deliberately without a uniqueness rule on
215
+ // (uid, user_agent, ip_address); see the schema module for why that
216
+ // constraint had to go.
139
217
  await db.execute(sql`
140
218
  CREATE TABLE IF NOT EXISTS ${sql.raw(refreshTokensTableName)} (
141
219
  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,
220
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
221
+ session_id TEXT NOT NULL DEFAULT gen_random_uuid()::text,
143
222
  token_hash TEXT NOT NULL UNIQUE,
144
223
  expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
224
+ revoked BOOLEAN DEFAULT FALSE NOT NULL,
225
+ rotated_at TIMESTAMP WITH TIME ZONE,
226
+ session_started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
145
227
  user_agent TEXT,
146
228
  ip_address TEXT,
147
- created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
148
- CONSTRAINT unique_device_session UNIQUE (user_id, user_agent, ip_address)
229
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
149
230
  )
150
231
  `);
151
232
 
@@ -155,17 +236,17 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
155
236
  ON ${sql.raw(refreshTokensTableName)}(token_hash)
156
237
  `);
157
238
 
158
- // Create index on user_id for cleanup operations
239
+ // Create index on uid for cleanup operations
159
240
  await db.execute(sql`
160
241
  CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user
161
- ON ${sql.raw(refreshTokensTableName)}(user_id)
242
+ ON ${sql.raw(refreshTokensTableName)}(uid)
162
243
  `);
163
244
 
164
245
  // Create password reset tokens table
165
246
  await db.execute(sql`
166
247
  CREATE TABLE IF NOT EXISTS ${sql.raw(passwordResetTokensTableName)} (
167
248
  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,
249
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
169
250
  token_hash TEXT NOT NULL UNIQUE,
170
251
  expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
171
252
  used_at TIMESTAMP WITH TIME ZONE,
@@ -179,10 +260,10 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
179
260
  ON ${sql.raw(passwordResetTokensTableName)}(token_hash)
180
261
  `);
181
262
 
182
- // Create index on user_id for password reset cleanup
263
+ // Create index on uid for password reset cleanup
183
264
  await db.execute(sql`
184
265
  CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user
185
- ON ${sql.raw(passwordResetTokensTableName)}(user_id)
266
+ ON ${sql.raw(passwordResetTokensTableName)}(uid)
186
267
  `);
187
268
 
188
269
  // Create magic link tokens table
@@ -190,7 +271,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
190
271
  await db.execute(sql`
191
272
  CREATE TABLE IF NOT EXISTS ${sql.raw(magicLinkTokensTableName)} (
192
273
  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,
274
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
194
275
  token_hash TEXT NOT NULL UNIQUE,
195
276
  expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
196
277
  used_at TIMESTAMP WITH TIME ZONE,
@@ -204,10 +285,10 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
204
285
  ON ${sql.raw(magicLinkTokensTableName)}(token_hash)
205
286
  `);
206
287
 
207
- // Create index on user_id for magic link cleanup
288
+ // Create index on uid for magic link cleanup
208
289
  await db.execute(sql`
209
290
  CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_user
210
- ON ${sql.raw(magicLinkTokensTableName)}(user_id)
291
+ ON ${sql.raw(magicLinkTokensTableName)}(uid)
211
292
  `);
212
293
 
213
294
  // Create app config table
@@ -226,9 +307,15 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
226
307
  await db.transaction(async (tx) => {
227
308
  await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init'))`);
228
309
 
310
+ // Falls back to the pre-rename `app.user_id` so a database that has
311
+ // taken the new schema but is still served by an older backend keeps
312
+ // resolving the principal. Keep in sync with AUTH_BOOTSTRAP_SQL.
229
313
  await tx.execute(sql`
230
314
  CREATE OR REPLACE FUNCTION auth.uid() RETURNS text AS $$
231
- SELECT NULLIF(current_setting('app.user_id', true), '');
315
+ SELECT COALESCE(
316
+ NULLIF(current_setting('app.uid', true), ''),
317
+ NULLIF(current_setting('app.user_id', true), '')
318
+ );
232
319
  $$ LANGUAGE sql STABLE
233
320
  `);
234
321
 
@@ -268,6 +355,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
268
355
  "email_verification_sent_at TIMESTAMP WITH TIME ZONE",
269
356
  "is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
270
357
  "metadata JSONB DEFAULT '{}' NOT NULL",
358
+ "tokens_valid_after TIMESTAMP WITH TIME ZONE",
271
359
  "created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
272
360
  "updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
273
361
  ];
@@ -278,6 +366,86 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
278
366
  `);
279
367
  }
280
368
 
369
+ // ── Migration: refresh_tokens become session-scoped, rotation-safe ──
370
+ // Two shapes are reconciled here, on EVERY table named refresh_tokens
371
+ // in whatever schema it lives (a database provisioned by an older era
372
+ // can carry the table in a different schema than the one this run
373
+ // derives, and auth would then read a table nobody migrated):
374
+ //
375
+ // 1. The new columns. A token is now a member of a session
376
+ // (`session_id`) and is retained after rotation (`revoked`,
377
+ // `rotated_at`) so a replayed token can be recognised instead of
378
+ // looking like a forgery. `session_started_at` is carried across
379
+ // rotations so `users.tokens_valid_after` cannot be outrun.
380
+ // 2. The removal of `unique_device_session`. It made (uid,
381
+ // user_agent, ip_address) the identity of a session, which evicted
382
+ // a second browser profile behind one NAT and churned rows as
383
+ // phones changed networks. UA and IP are metadata now.
384
+ //
385
+ // Every existing row is adopted rather than dropped: it keeps its
386
+ // token_hash, gets a session of its own, and stays unrevoked — so the
387
+ // sessions live in browsers right now survive the upgrade rather than
388
+ // everyone being signed out by the fix for being signed out.
389
+ try {
390
+ const rtTables = await db.execute(sql`
391
+ SELECT table_schema, table_name
392
+ FROM information_schema.tables
393
+ WHERE table_name = 'refresh_tokens'
394
+ `);
395
+ const found = (rtTables.rows as { table_schema: string; table_name: string }[]);
396
+ logger.info(`🔍 refresh_tokens reconcile: found ${found.length} table(s): ${found.map(r => `"${r.table_schema}"."${r.table_name}"`).join(", ") || "(none)"}`);
397
+ for (const { table_schema } of found) {
398
+ const qualified = `"${table_schema}"."refresh_tokens"`;
399
+ try {
400
+ // Added nullable, then back-filled, then constrained: adding
401
+ // `session_id NOT NULL DEFAULT gen_random_uuid()` in one step
402
+ // would stamp every existing row with the SAME uuid on some
403
+ // Postgres versions, silently merging every live session into
404
+ // one that a single logout would then wipe.
405
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_id TEXT`);
406
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS revoked BOOLEAN DEFAULT FALSE NOT NULL`);
407
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS rotated_at TIMESTAMP WITH TIME ZONE`);
408
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_started_at TIMESTAMP WITH TIME ZONE`);
409
+
410
+ // One session per pre-existing row: under the old model a row
411
+ // WAS a device session, and there is no record of which rows
412
+ // descended from the same sign-in.
413
+ await db.execute(sql`
414
+ UPDATE ${sql.raw(qualified)}
415
+ SET session_id = gen_random_uuid()::text
416
+ WHERE session_id IS NULL
417
+ `);
418
+ await db.execute(sql`
419
+ UPDATE ${sql.raw(qualified)}
420
+ SET session_started_at = COALESCE(created_at, NOW())
421
+ WHERE session_started_at IS NULL
422
+ `);
423
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_id SET DEFAULT gen_random_uuid()::text`);
424
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_started_at SET DEFAULT NOW()`);
425
+ // SET NOT NULL only once the back-fill above has definitely
426
+ // run; on a table that somehow still holds a NULL this throws
427
+ // and is caught below rather than failing the boot.
428
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_id SET NOT NULL`);
429
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_started_at SET NOT NULL`);
430
+
431
+ await db.execute(sql`
432
+ CREATE INDEX IF NOT EXISTS idx_refresh_tokens_session
433
+ ON ${sql.raw(qualified)}(session_id)
434
+ `);
435
+
436
+ // The device-session constraint is now actively harmful: two
437
+ // live tokens of one session (a rotation in flight) share a
438
+ // uid, and usually a user agent and IP too.
439
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} DROP CONSTRAINT IF EXISTS unique_device_session`);
440
+ logger.info(`✅ refresh_tokens reconciled for session-scoped rotation: ${qualified}`);
441
+ } catch (perTableError: unknown) {
442
+ logger.warn(`⚠️ refresh_tokens reconcile failed for ${qualified}: ${perTableError instanceof Error ? perTableError.message : String(perTableError)}`);
443
+ }
444
+ }
445
+ } catch (migrationError: unknown) {
446
+ logger.warn(`⚠️ refresh_tokens session migration skipped: ${migrationError instanceof Error ? migrationError.message : String(migrationError)}`);
447
+ }
448
+
281
449
  // ── Migration: Copy roles from legacy junction table to inline column ──
282
450
  // If the old rebase.user_roles and rebase.roles tables exist, migrate
283
451
  // the data into the new TEXT[] column then drop the legacy tables.
@@ -322,7 +490,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
322
490
  await db.execute(sql`
323
491
  CREATE TABLE IF NOT EXISTS ${sql.raw(mfaFactorsTableName)} (
324
492
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
325
- user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
493
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
326
494
  factor_type TEXT NOT NULL DEFAULT 'totp',
327
495
  secret_encrypted TEXT NOT NULL,
328
496
  friendly_name TEXT,
@@ -335,7 +503,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
335
503
  // Create indexes on mfa_factors
336
504
  await db.execute(sql`
337
505
  CREATE INDEX IF NOT EXISTS idx_mfa_factors_user
338
- ON ${sql.raw(mfaFactorsTableName)}(user_id)
506
+ ON ${sql.raw(mfaFactorsTableName)}(uid)
339
507
  `);
340
508
 
341
509
  // Create mfa_challenges table
@@ -360,7 +528,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
360
528
  await db.execute(sql`
361
529
  CREATE TABLE IF NOT EXISTS ${sql.raw(recoveryCodesTableName)} (
362
530
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
363
- user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
531
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
364
532
  code_hash TEXT NOT NULL,
365
533
  used_at TIMESTAMP WITH TIME ZONE,
366
534
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
@@ -370,7 +538,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
370
538
  // Create indexes on recovery_codes
371
539
  await db.execute(sql`
372
540
  CREATE INDEX IF NOT EXISTS idx_recovery_codes_user
373
- ON ${sql.raw(recoveryCodesTableName)}(user_id)
541
+ ON ${sql.raw(recoveryCodesTableName)}(uid)
374
542
  `);
375
543
 
376
544
  // ── Migration: clear stale FORCE ROW LEVEL SECURITY (older RLS model) ──