@rebasepro/server-postgres 0.9.1-canary.73476f2 → 0.9.1-canary.74adfbe
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 +43 -2
- package/dist/PostgresBootstrapper.d.ts +17 -1
- package/dist/auth/services.d.ts +68 -52
- package/dist/collections/buildRegistry.d.ts +27 -0
- package/dist/connection.d.ts +21 -0
- package/dist/data-transformer.d.ts +9 -2
- package/dist/index.es.js +2185 -775
- 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/doctor.d.ts +1 -1
- 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 +30 -0
- package/dist/security/rls-enforcement.d.ts +2 -2
- package/dist/services/FetchService.d.ts +4 -24
- package/dist/services/PersistService.d.ts +27 -1
- package/dist/services/RelationService.d.ts +34 -1
- package/dist/services/channel-history.d.ts +118 -0
- package/dist/services/collection-helpers.d.ts +79 -14
- package/dist/services/dataService.d.ts +3 -1
- package/dist/services/index.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +76 -2
- package/dist/services/row-pipeline.d.ts +63 -0
- package/package.json +13 -34
- package/src/PostgresBackendDriver.ts +183 -18
- package/src/PostgresBootstrapper.ts +80 -26
- package/src/auth/ensure-tables.ts +170 -28
- package/src/auth/services.ts +181 -150
- package/src/cli.ts +60 -0
- package/src/collections/buildRegistry.ts +59 -0
- package/src/connection.ts +61 -1
- package/src/data-transformer.ts +11 -9
- package/src/databasePoolManager.ts +2 -0
- package/src/schema/auth-bootstrap-sql.ts +7 -1
- package/src/schema/auth-schema.ts +13 -13
- package/src/schema/doctor.ts +45 -20
- package/src/schema/generate-drizzle-schema-logic.ts +24 -29
- package/src/schema/generate-postgres-ddl-logic.ts +76 -28
- 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-db.ts +19 -2
- package/src/schema/introspect-runtime.ts +1 -1
- package/src/security/policy-drift.test.ts +106 -1
- package/src/security/policy-drift.ts +56 -0
- package/src/security/rls-enforcement.ts +11 -5
- package/src/services/BranchService.ts +42 -10
- package/src/services/FetchService.ts +65 -270
- package/src/services/PersistService.ts +130 -14
- package/src/services/RelationService.ts +153 -94
- package/src/services/channel-history.ts +343 -0
- package/src/services/collection-helpers.ts +164 -47
- package/src/services/dataService.ts +3 -2
- package/src/services/index.ts +1 -0
- package/src/services/realtimeService.ts +238 -29
- package/src/services/row-pipeline.ts +239 -0
- package/src/utils/drizzle-conditions.ts +13 -0
- package/src/websocket.ts +34 -12
- package/dist/schema/auth-default-policies.d.ts +0 -10
- package/src/schema/auth-default-policies.ts +0 -132
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Implements the `BackendBootstrapper` interface for PostgreSQL.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { Relations, sql } from "drizzle-orm";
|
|
8
8
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
9
9
|
import { PgEnum, PgTable } from "drizzle-orm/pg-core";
|
|
10
10
|
import type { RebasePgTable } from "./types";
|
|
@@ -17,10 +17,12 @@ 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";
|
|
25
|
+
import { buildCollectionRegistry } from "./collections/buildRegistry";
|
|
24
26
|
import { DatabasePoolManager } from "./databasePoolManager";
|
|
25
27
|
import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
|
|
26
28
|
import { createEmailService, type EmailConfig, type EmailService, logger } from "@rebasepro/server";
|
|
@@ -51,6 +53,12 @@ export interface PostgresDriverConfig {
|
|
|
51
53
|
* (BaaS mode). Defaults to `public`.
|
|
52
54
|
*/
|
|
53
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;
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
/**
|
|
@@ -64,6 +72,15 @@ export interface PostgresDriverInternals {
|
|
|
64
72
|
realtimeService: RealtimeService;
|
|
65
73
|
driver: PostgresBackendDriver;
|
|
66
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>;
|
|
67
84
|
}
|
|
68
85
|
|
|
69
86
|
// Re-export from shared CLI error utilities
|
|
@@ -161,30 +178,17 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
|
|
|
161
178
|
}
|
|
162
179
|
|
|
163
180
|
const activeCollections = introspectedCollections ?? collections;
|
|
164
|
-
|
|
165
|
-
// Create a fresh registry for this driver
|
|
166
|
-
const registry = new PostgresCollectionRegistry();
|
|
167
|
-
if (activeCollections) {
|
|
168
|
-
registry.registerMultiple(activeCollections);
|
|
169
|
-
logger.info(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map(c => c.slug).join(", ")}]`);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
181
|
const schemaTables = introspectedTables ?? pgConfig.schema?.tables;
|
|
173
|
-
|
|
174
|
-
// Register tables
|
|
175
|
-
if (schemaTables) {
|
|
176
|
-
Object.values(schemaTables).forEach((table) => {
|
|
177
|
-
if (isTable(table)) {
|
|
178
|
-
const tableName = getTableName(table);
|
|
179
|
-
registry.registerTable(table as PgTable, tableName);
|
|
180
|
-
}
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
if (pgConfig.schema?.enums) registry.registerEnums(pgConfig.schema.enums as Record<string, PgEnum<[string, ...string[]]>>);
|
|
185
|
-
|
|
186
182
|
const schemaRelations = introspectedRelations ?? (pgConfig.schema?.relations as Record<string, Relations> | undefined);
|
|
187
|
-
|
|
183
|
+
|
|
184
|
+
// Create a fresh registry for this driver. Registration order is
|
|
185
|
+
// load-bearing, so it lives in one place — see `buildCollectionRegistry`.
|
|
186
|
+
const registry = buildCollectionRegistry({
|
|
187
|
+
collections: activeCollections,
|
|
188
|
+
tables: schemaTables,
|
|
189
|
+
enums: pgConfig.schema?.enums as Record<string, PgEnum<[string, ...string[]]>> | undefined,
|
|
190
|
+
relations: schemaRelations
|
|
191
|
+
});
|
|
188
192
|
|
|
189
193
|
// Patch Drizzle's PgArray columns to handle NULL values safely.
|
|
190
194
|
// Drizzle's mapFromDriverValue crashes with "value.map is not a function"
|
|
@@ -319,6 +323,16 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
|
|
|
319
323
|
}
|
|
320
324
|
}
|
|
321
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
|
+
|
|
322
336
|
// ── Realtime change source ───────────────────────────────────────
|
|
323
337
|
// Prefer DATABASE_DIRECT_URL to bypass PgBouncer for LISTEN/NOTIFY.
|
|
324
338
|
const directUrl = process.env.DATABASE_DIRECT_URL || pgConfig.connectionString;
|
|
@@ -346,6 +360,7 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
|
|
|
346
360
|
const wantsCdc = cdcMode !== "off";
|
|
347
361
|
const explicitCdc = cdcMode === "trigger" || cdcMode === "wal";
|
|
348
362
|
let cdcEnabled = false;
|
|
363
|
+
let provisionCdcForTables: PostgresDriverInternals["provisionCdcForTables"];
|
|
349
364
|
|
|
350
365
|
if (wantsCdc && !directUrl) {
|
|
351
366
|
const reason = "no direct database connection is available for the realtime LISTEN client (set DATABASE_DIRECT_URL)";
|
|
@@ -379,6 +394,11 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
|
|
|
379
394
|
await provisionTriggerCdc(cdcRunSql, cdcTables);
|
|
380
395
|
await realtimeService.enableCdc(directUrl);
|
|
381
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
|
+
};
|
|
382
402
|
logger.info(
|
|
383
403
|
`📡 [CDC] Realtime source = database-level change capture (mode: ${cdcMode === "wal" ? "wal→trigger" : "trigger"}). ` +
|
|
384
404
|
`All writes now emit realtime events regardless of origin.`
|
|
@@ -429,6 +449,14 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
|
|
|
429
449
|
);
|
|
430
450
|
const missing: Array<{ slug: string; table: string }> = [];
|
|
431
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
|
+
|
|
432
460
|
const schemaName = "schema" in col && col.schema ? col.schema : "public";
|
|
433
461
|
const tableName = registry.hasTableForCollection(
|
|
434
462
|
col.table ?? col.slug
|
|
@@ -443,8 +471,10 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
|
|
|
443
471
|
const checkName = resolvedTable ?? tableName;
|
|
444
472
|
const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
|
|
445
473
|
if (!dbTables.has(fullCheckName)) {
|
|
474
|
+
// Report what was actually looked up: an unqualified
|
|
475
|
+
// "users" sends people hunting for public.users.
|
|
446
476
|
missing.push({ slug: col.slug,
|
|
447
|
-
table:
|
|
477
|
+
table: fullCheckName });
|
|
448
478
|
}
|
|
449
479
|
}
|
|
450
480
|
if (missing.length > 0) {
|
|
@@ -478,7 +508,8 @@ table: checkName });
|
|
|
478
508
|
registry,
|
|
479
509
|
realtimeService,
|
|
480
510
|
driver,
|
|
481
|
-
poolManager
|
|
511
|
+
poolManager,
|
|
512
|
+
provisionCdcForTables
|
|
482
513
|
};
|
|
483
514
|
|
|
484
515
|
return {
|
|
@@ -507,6 +538,29 @@ table: checkName });
|
|
|
507
538
|
// ensureAuthTablesExist works with the collection abstraction — no Drizzle leakage.
|
|
508
539
|
await ensureAuthTablesExist(db, authCollection);
|
|
509
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
|
+
|
|
510
564
|
let emailService: EmailService | undefined;
|
|
511
565
|
if (authConfig.email) {
|
|
512
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
|
-
|
|
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
|
|
|
@@ -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:
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
//
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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)}(
|
|
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
|
-
|
|
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)}(
|
|
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 });
|