@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/README.md +21 -0
- package/dist/PostgresBackendDriver.d.ts +18 -0
- package/dist/PostgresBootstrapper.d.ts +7 -1
- package/dist/auth/services.d.ts +93 -54
- package/dist/index.es.js +929 -224
- 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 +194 -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 +187 -19
- package/src/auth/services.ts +309 -170
- package/src/schema/auth-bootstrap-sql.ts +7 -1
- package/src/schema/auth-schema.ts +53 -15
- 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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/server-postgres",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
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/
|
|
75
|
-
"@rebasepro/
|
|
76
|
-
"@rebasepro/
|
|
77
|
-
"@rebasepro/
|
|
78
|
-
"@rebasepro/
|
|
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
|
|
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
|
|
1378
|
-
if (!
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
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)}(
|
|
209
|
+
ON ${sql.raw(userIdentitiesTable)}(uid)
|
|
135
210
|
`);
|
|
136
211
|
|
|
137
212
|
|
|
138
|
-
// Create refresh tokens table
|
|
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
|
-
|
|
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
|
|
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)}(
|
|
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
|
-
|
|
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
|
|
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)}(
|
|
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
|
-
|
|
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
|
|
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)}(
|
|
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
|
|
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
|
-
|
|
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)}(
|
|
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
|
-
|
|
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)}(
|
|
541
|
+
ON ${sql.raw(recoveryCodesTableName)}(uid)
|
|
374
542
|
`);
|
|
375
543
|
|
|
376
544
|
// ── Migration: clear stale FORCE ROW LEVEL SECURITY (older RLS model) ──
|