@rebasepro/server-postgres 0.9.1-canary.ff338b5 → 0.10.1-canary.a54c057
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/README.md +21 -0
- package/dist/PostgresBackendDriver.d.ts +18 -0
- package/dist/PostgresBootstrapper.d.ts +7 -1
- package/dist/auth/services.d.ts +53 -53
- package/dist/index.es.js +755 -191
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +1 -1
- package/dist/schema/auth-schema.d.ts +24 -24
- package/dist/schema/introspect-db-logic.d.ts +0 -5
- package/dist/schema/introspect-db-naming.d.ts +10 -0
- package/dist/security/policy-drift.d.ts +24 -0
- package/dist/security/rls-enforcement.d.ts +2 -2
- package/dist/services/channel-history.d.ts +118 -0
- package/dist/services/realtimeService.d.ts +69 -2
- package/package.json +7 -31
- package/src/PostgresBackendDriver.ts +56 -5
- package/src/PostgresBootstrapper.ts +18 -1
- package/src/auth/ensure-tables.ts +171 -17
- package/src/auth/services.ts +134 -133
- package/src/schema/auth-bootstrap-sql.ts +7 -1
- package/src/schema/auth-schema.ts +13 -13
- package/src/schema/introspect-db-inference.ts +1 -1
- package/src/schema/introspect-db-logic.ts +1 -10
- package/src/schema/introspect-db-naming.ts +15 -0
- package/src/schema/introspect-runtime.ts +1 -1
- package/src/security/policy-drift.test.ts +46 -0
- package/src/security/policy-drift.ts +70 -4
- package/src/security/rls-enforcement.ts +11 -5
- package/src/services/channel-history.ts +343 -0
- package/src/services/realtimeService.ts +198 -10
- package/src/websocket.ts +30 -11
|
@@ -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
|
-
|
|
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)}(
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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)}(
|
|
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
|
-
|
|
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
|
|
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)}(
|
|
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
|
-
|
|
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
|
|
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)}(
|
|
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
|
|
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
|
|
|
@@ -278,6 +358,80 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
278
358
|
`);
|
|
279
359
|
}
|
|
280
360
|
|
|
361
|
+
// ── Migration: reconcile refresh_tokens for the device-session upsert ──
|
|
362
|
+
// The refresh-token rotation does `INSERT ... ON CONFLICT (uid, user_agent,
|
|
363
|
+
// ip_address) DO UPDATE`, which requires the `unique_device_session`
|
|
364
|
+
// constraint. `CREATE TABLE IF NOT EXISTS` never adds it to a table that
|
|
365
|
+
// predates the constraint, so every login on such a database 500s with
|
|
366
|
+
// `42P10 no unique or exclusion constraint matching the ON CONFLICT`.
|
|
367
|
+
// Reconcile EVERY table named refresh_tokens, in whatever schema it lives,
|
|
368
|
+
// rather than only the derived name: a database provisioned by an older
|
|
369
|
+
// era can carry the table in a different schema than the one this run
|
|
370
|
+
// derives, and the login upsert then hits a table without the constraint.
|
|
371
|
+
try {
|
|
372
|
+
const rtTables = await db.execute(sql`
|
|
373
|
+
SELECT table_schema, table_name
|
|
374
|
+
FROM information_schema.tables
|
|
375
|
+
WHERE table_name = 'refresh_tokens'
|
|
376
|
+
`);
|
|
377
|
+
const found = (rtTables.rows as { table_schema: string; table_name: string }[]);
|
|
378
|
+
logger.info(`🔍 refresh_tokens reconcile: found ${found.length} table(s): ${found.map(r => `"${r.table_schema}"."${r.table_name}"`).join(", ") || "(none)"}`);
|
|
379
|
+
for (const { table_schema } of found) {
|
|
380
|
+
const qualified = `"${table_schema}"."refresh_tokens"`;
|
|
381
|
+
try {
|
|
382
|
+
// Old tables may lack these columns entirely.
|
|
383
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS user_agent TEXT`);
|
|
384
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS ip_address TEXT`);
|
|
385
|
+
// The upsert relies on '' (not NULL) so a device with no UA/IP collides.
|
|
386
|
+
await db.execute(sql`UPDATE ${sql.raw(qualified)} SET user_agent = '' WHERE user_agent IS NULL`);
|
|
387
|
+
await db.execute(sql`UPDATE ${sql.raw(qualified)} SET ip_address = '' WHERE ip_address IS NULL`);
|
|
388
|
+
// Drop pre-existing duplicates that would block the unique constraint,
|
|
389
|
+
// keeping one row per (uid, user_agent, ip_address).
|
|
390
|
+
await db.execute(sql`
|
|
391
|
+
DELETE FROM ${sql.raw(qualified)} a
|
|
392
|
+
USING ${sql.raw(qualified)} b
|
|
393
|
+
WHERE a.ctid < b.ctid
|
|
394
|
+
AND a.uid = b.uid
|
|
395
|
+
AND a.user_agent = b.user_agent
|
|
396
|
+
AND a.ip_address = b.ip_address
|
|
397
|
+
`);
|
|
398
|
+
// Log every unique constraint on the table so drift is visible.
|
|
399
|
+
const uniques = await db.execute(sql`
|
|
400
|
+
SELECT conname, pg_get_constraintdef(oid) AS def
|
|
401
|
+
FROM pg_constraint
|
|
402
|
+
WHERE conrelid = ${sql.raw(`'${qualified}'`)}::regclass AND contype = 'u'
|
|
403
|
+
`);
|
|
404
|
+
for (const u of uniques.rows as { conname: string; def: string }[]) {
|
|
405
|
+
logger.info(` ${qualified} unique: ${u.conname} → ${u.def}`);
|
|
406
|
+
}
|
|
407
|
+
// Does a usable unique constraint cover EXACTLY (uid, user_agent, ip_address)?
|
|
408
|
+
// Checking by name alone is not enough: an older era may carry a
|
|
409
|
+
// `unique_device_session` on different columns, which the ON CONFLICT
|
|
410
|
+
// cannot infer, so login keeps 500ing with 42P10.
|
|
411
|
+
const hasCorrect = (uniques.rows as { def: string }[]).some((u) => {
|
|
412
|
+
const cols = (u.def.match(/\(([^)]*)\)/)?.[1] || "")
|
|
413
|
+
.split(",").map((c) => c.trim().replace(/"/g, ""));
|
|
414
|
+
return cols.length === 3 && cols.includes("uid") && cols.includes("user_agent") && cols.includes("ip_address");
|
|
415
|
+
});
|
|
416
|
+
if (hasCorrect) {
|
|
417
|
+
logger.info(`✓ correct device-session unique already present on ${qualified}`);
|
|
418
|
+
} else {
|
|
419
|
+
// Remove a wrong-columned constraint squatting on the name, then add the right one.
|
|
420
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} DROP CONSTRAINT IF EXISTS unique_device_session`);
|
|
421
|
+
await db.execute(sql`
|
|
422
|
+
ALTER TABLE ${sql.raw(qualified)}
|
|
423
|
+
ADD CONSTRAINT unique_device_session UNIQUE (uid, user_agent, ip_address)
|
|
424
|
+
`);
|
|
425
|
+
logger.info(`✅ Added correct unique_device_session constraint to ${qualified}`);
|
|
426
|
+
}
|
|
427
|
+
} catch (perTableError: unknown) {
|
|
428
|
+
logger.warn(`⚠️ refresh_tokens reconcile failed for ${qualified}: ${perTableError instanceof Error ? perTableError.message : String(perTableError)}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
} catch (migrationError: unknown) {
|
|
432
|
+
logger.warn(`⚠️ refresh_tokens device-session migration skipped: ${migrationError instanceof Error ? migrationError.message : String(migrationError)}`);
|
|
433
|
+
}
|
|
434
|
+
|
|
281
435
|
// ── Migration: Copy roles from legacy junction table to inline column ──
|
|
282
436
|
// If the old rebase.user_roles and rebase.roles tables exist, migrate
|
|
283
437
|
// the data into the new TEXT[] column then drop the legacy tables.
|
|
@@ -322,7 +476,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
322
476
|
await db.execute(sql`
|
|
323
477
|
CREATE TABLE IF NOT EXISTS ${sql.raw(mfaFactorsTableName)} (
|
|
324
478
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
325
|
-
|
|
479
|
+
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
326
480
|
factor_type TEXT NOT NULL DEFAULT 'totp',
|
|
327
481
|
secret_encrypted TEXT NOT NULL,
|
|
328
482
|
friendly_name TEXT,
|
|
@@ -335,7 +489,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
335
489
|
// Create indexes on mfa_factors
|
|
336
490
|
await db.execute(sql`
|
|
337
491
|
CREATE INDEX IF NOT EXISTS idx_mfa_factors_user
|
|
338
|
-
ON ${sql.raw(mfaFactorsTableName)}(
|
|
492
|
+
ON ${sql.raw(mfaFactorsTableName)}(uid)
|
|
339
493
|
`);
|
|
340
494
|
|
|
341
495
|
// Create mfa_challenges table
|
|
@@ -360,7 +514,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
360
514
|
await db.execute(sql`
|
|
361
515
|
CREATE TABLE IF NOT EXISTS ${sql.raw(recoveryCodesTableName)} (
|
|
362
516
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
363
|
-
|
|
517
|
+
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
364
518
|
code_hash TEXT NOT NULL,
|
|
365
519
|
used_at TIMESTAMP WITH TIME ZONE,
|
|
366
520
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
@@ -370,7 +524,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
370
524
|
// Create indexes on recovery_codes
|
|
371
525
|
await db.execute(sql`
|
|
372
526
|
CREATE INDEX IF NOT EXISTS idx_recovery_codes_user
|
|
373
|
-
ON ${sql.raw(recoveryCodesTableName)}(
|
|
527
|
+
ON ${sql.raw(recoveryCodesTableName)}(uid)
|
|
374
528
|
`);
|
|
375
529
|
|
|
376
530
|
// ── Migration: clear stale FORCE ROW LEVEL SECURITY (older RLS model) ──
|