@rebasepro/server-postgres 0.12.1-canary.gf5f1d39 → 0.13.1-canary.g18cfeb7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/dist/PostgresBackendDriver.d.ts +49 -2
  2. package/dist/PostgresBootstrapper.d.ts +25 -1
  3. package/dist/auth/services.d.ts +21 -0
  4. package/dist/{src-BbFOPJ1S.js → auth-users-columns-Do9mw5Y5.js} +602 -200
  5. package/dist/auth-users-columns-Do9mw5Y5.js.map +1 -0
  6. package/dist/backup/backup-service.d.ts +10 -1
  7. package/dist/backup/pg-tools.d.ts +47 -0
  8. package/dist/backup-service-Bww-Lg0s.js +8999 -0
  9. package/dist/backup-service-Bww-Lg0s.js.map +1 -0
  10. package/dist/cli-helpers.d.ts +40 -1
  11. package/dist/connection-BuZ97wsr.js +250 -0
  12. package/dist/connection-BuZ97wsr.js.map +1 -0
  13. package/dist/connection.d.ts +42 -0
  14. package/dist/ensure-collection-policies-DMUdRQdM.js +57 -0
  15. package/dist/ensure-collection-policies-DMUdRQdM.js.map +1 -0
  16. package/dist/ensure-collection-tables-DSIxvLLD.js +694 -0
  17. package/dist/ensure-collection-tables-DSIxvLLD.js.map +1 -0
  18. package/dist/index.es.js +1701 -10167
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/policy-CPkCqVTz.js +105 -0
  21. package/dist/policy-CPkCqVTz.js.map +1 -0
  22. package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
  23. package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
  24. package/dist/schema/auth-schema.d.ts +83 -144
  25. package/dist/schema/auth-users-columns.d.ts +97 -0
  26. package/dist/schema/ensure-collection-policies.d.ts +60 -0
  27. package/dist/schema/ensure-collection-tables.d.ts +45 -3
  28. package/dist/schema/generate-drizzle-schema-logic.d.ts +1 -1
  29. package/dist/schema/generate-postgres-ddl-logic.d.ts +137 -3
  30. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  31. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  32. package/dist/schema/introspect-db-logic.d.ts +94 -5
  33. package/dist/schema/introspect-db-queries.d.ts +119 -0
  34. package/dist/schema/introspect-db-structure.d.ts +263 -0
  35. package/dist/schema/introspect-db-types.d.ts +11 -0
  36. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  37. package/dist/security/rls-enforcement.d.ts +53 -2
  38. package/dist/services/FetchService.d.ts +14 -8
  39. package/dist/services/RelationService.d.ts +24 -1
  40. package/dist/services/channel-bus/index.d.ts +1 -7
  41. package/dist/services/collection-helpers.d.ts +36 -2
  42. package/dist/services/dataService.d.ts +5 -1
  43. package/dist/services/realtimeService.d.ts +25 -21
  44. package/dist/services/row-pipeline.d.ts +1 -1
  45. package/dist/{src-Zqwaw3P5.js → src-C_wvdMnl.js} +78 -145
  46. package/dist/src-C_wvdMnl.js.map +1 -0
  47. package/dist/utils/connection-string.d.ts +29 -0
  48. package/dist/utils/drizzle-conditions.d.ts +5 -4
  49. package/dist/utils/pg-error-utils.d.ts +35 -0
  50. package/dist/websocket-D0TBU3ia.js +530 -0
  51. package/dist/websocket-D0TBU3ia.js.map +1 -0
  52. package/package.json +17 -16
  53. package/src/PostgresAdapter.ts +21 -2
  54. package/src/PostgresBackendDriver.ts +169 -3
  55. package/src/PostgresBootstrapper.ts +233 -35
  56. package/src/auth/ensure-tables.ts +335 -81
  57. package/src/auth/services.ts +24 -2
  58. package/src/backup/backup-cli.ts +41 -2
  59. package/src/backup/backup-service.ts +38 -5
  60. package/src/backup/pg-tools.ts +96 -3
  61. package/src/cli-helpers.ts +92 -9
  62. package/src/cli.ts +219 -56
  63. package/src/collections/validate-relations.ts +136 -14
  64. package/src/connection.ts +73 -0
  65. package/src/data-transformer.ts +22 -6
  66. package/src/databasePoolManager.ts +5 -2
  67. package/src/history/ensure-history-table.ts +7 -0
  68. package/src/schema/auth-schema.ts +30 -19
  69. package/src/schema/auth-users-columns.ts +131 -0
  70. package/src/schema/doctor.ts +7 -5
  71. package/src/schema/ensure-collection-policies.ts +105 -0
  72. package/src/schema/ensure-collection-tables.test.ts +105 -9
  73. package/src/schema/ensure-collection-tables.ts +301 -44
  74. package/src/schema/generate-drizzle-schema-logic.ts +49 -7
  75. package/src/schema/generate-postgres-ddl-logic.ts +474 -27
  76. package/src/schema/generated-schema-staleness.ts +169 -0
  77. package/src/schema/introspect-db-constraints.ts +385 -0
  78. package/src/schema/introspect-db-inference.ts +18 -8
  79. package/src/schema/introspect-db-logic.ts +385 -71
  80. package/src/schema/introspect-db-queries.ts +326 -0
  81. package/src/schema/introspect-db-structure.ts +670 -0
  82. package/src/schema/introspect-db-types.ts +56 -0
  83. package/src/schema/introspect-db.ts +37 -80
  84. package/src/schema/introspect-runtime.test.ts +56 -8
  85. package/src/schema/introspect-runtime.ts +31 -9
  86. package/src/schema/non-sql-collections.test.ts +131 -0
  87. package/src/schema/rls-bootstrap-sql.ts +288 -0
  88. package/src/security/anonymous-grants.test.ts +4 -2
  89. package/src/security/policy-drift.test.ts +11 -3
  90. package/src/security/rls-enforcement.ts +141 -3
  91. package/src/services/BranchService.ts +71 -28
  92. package/src/services/FetchService.ts +100 -107
  93. package/src/services/PersistService.ts +34 -7
  94. package/src/services/RelationService.ts +249 -48
  95. package/src/services/channel-bus/index.ts +0 -9
  96. package/src/services/channel-history.ts +8 -0
  97. package/src/services/channel-presence.ts +6 -0
  98. package/src/services/collection-helpers.ts +69 -3
  99. package/src/services/dataService.ts +5 -1
  100. package/src/services/realtimeService.ts +38 -35
  101. package/src/services/row-pipeline.ts +1 -1
  102. package/src/utils/connection-string.ts +58 -0
  103. package/src/utils/drizzle-conditions.ts +31 -6
  104. package/src/utils/pg-error-utils.ts +65 -0
  105. package/src/websocket.ts +18 -9
  106. package/dist/chunk-DSJWtz9O.js +0 -40
  107. package/dist/ensure-collection-tables-CNTcZGvn.js +0 -304
  108. package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
  109. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  110. package/dist/src-BbFOPJ1S.js.map +0 -1
  111. package/dist/src-Zqwaw3P5.js.map +0 -1
  112. package/src/schema/auth-bootstrap-sql.ts +0 -47
@@ -1,7 +1,10 @@
1
1
  import { sql } from "drizzle-orm";
2
2
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
3
3
  import { logger } from "@rebasepro/server";
4
+ import { revokeInternalTableAccess } from "@rebasepro/common";
4
5
  import type { CollectionConfig } from "@rebasepro/types";
6
+ import { AUTH_USERS_COLUMNS, authUsersColumnSql } from "../schema/auth-users-columns";
7
+ import { RLS_BOOTSTRAP_STATEMENTS } from "../schema/rls-bootstrap-sql";
5
8
  import {
6
9
  AuthSchemaVersionError,
7
10
  assertAuthSchemaCompatible,
@@ -44,13 +47,23 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
44
47
  ? `"${resolvedTable}"`
45
48
  : `"${usersSchema}"."${resolvedTable}"`;
46
49
 
47
- // Derive ID column type from collection properties
50
+ // Derive ID column type from collection properties.
51
+ //
52
+ // `"increment"`, not `"autoincrement"`. The latter was tested for
53
+ // here and exists nowhere in the type system — the union is
54
+ // `boolean | "manual" | "increment" | string` — so the INTEGER branch
55
+ // was unreachable and an integer-keyed auth collection fell through
56
+ // to TEXT. Introspection below hid it whenever the table already
57
+ // existed; on a database where it did not, this created
58
+ // `id TEXT DEFAULT gen_random_uuid()::text` for a collection that
59
+ // declares a number, and every `uid` foreign key was typed to match
60
+ // the wrong thing.
48
61
  const idProp = collection.properties?.id;
49
62
  if (idProp) {
50
63
  const isId = ("isId" in idProp) ? (idProp as unknown as Record<string, unknown>).isId : undefined;
51
64
  if (isId === "uuid") {
52
65
  userIdType = "UUID";
53
- } else if (isId === "autoincrement") {
66
+ } else if (isId === "increment") {
54
67
  userIdType = "INTEGER";
55
68
  }
56
69
  // Otherwise keep TEXT as default
@@ -107,22 +120,46 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
107
120
  ? "GENERATED ALWAYS AS IDENTITY"
108
121
  : "DEFAULT gen_random_uuid()::text";
109
122
 
123
+ // Identifiers for the constraint and indexes reconciled further down.
124
+ // Derived from the resolved table name so two auth tables in different
125
+ // schemas cannot collide, and truncated to Postgres's 63-byte identifier
126
+ // limit here rather than letting the server truncate silently — the
127
+ // `IF NOT EXISTS` guards below have to compare against the same name
128
+ // Postgres actually stored, or they re-run forever.
129
+ const authIdentifier = (suffix: string) => `${resolvedTable}_${suffix}`.slice(0, 63);
130
+ const emailLengthConstraint = `"${authIdentifier("email_length_check")}"`;
131
+ const emailLowerUniqueIndex = authIdentifier("email_lower_key");
132
+ const verificationTokenIndex = authIdentifier("email_verification_token_idx");
133
+
134
+ // Every string column here is TEXT, deliberately. In Postgres VARCHAR(n)
135
+ // and TEXT are the same type with the same storage and the same
136
+ // performance; the only difference is a length check, and none of these
137
+ // columns wants one. The widths this table used to carry were inherited
138
+ // MySQL habit (255) and they were all wrong in the same direction —
139
+ // `password_hash VARCHAR(255)` against a 193-char scrypt string left 62
140
+ // characters of headroom in front of a KEY_LENGTH constant living in
141
+ // another package, and `photo_url VARCHAR(500)` rejected the `data:` URIs
142
+ // and long signed URLs that OAuth providers hand back. A limit worth
143
+ // having is a CHECK — alterable without a table rewrite, unlike a type
144
+ // modifier — which is why `email` has one and nothing else does.
145
+ //
146
+ // The column list comes from AUTH_USERS_COLUMNS rather than being spelled
147
+ // out here, because this is not the only place that creates this table:
148
+ // `db push` and the boot-time collection ensure do too, and when the
149
+ // three lists were maintained separately they disagreed and boot order
150
+ // silently decided which shape the database got. The `email` CHECK is
151
+ // appended rather than listed there — it is a named constraint the
152
+ // migration below has to be able to add separately, `NOT VALID`, to a
153
+ // table that already holds rows.
154
+ const usersColumnDdl = AUTH_USERS_COLUMNS
155
+ .map((spec) => spec.column === "email"
156
+ ? `${spec.column} ${authUsersColumnSql(spec)} CONSTRAINT ${emailLengthConstraint} CHECK (length(email) <= 320)`
157
+ : `${spec.column} ${authUsersColumnSql(spec)}`)
158
+ .join(",\n ");
110
159
  await db.execute(sql`
111
160
  CREATE TABLE IF NOT EXISTS ${sql.raw(usersTableName)} (
112
161
  id ${sql.raw(userIdType)} PRIMARY KEY ${sql.raw(idDefault)},
113
- email VARCHAR(255) UNIQUE NOT NULL,
114
- display_name VARCHAR(255),
115
- photo_url VARCHAR(500),
116
- roles TEXT[] DEFAULT '{}' NOT NULL,
117
- password_hash VARCHAR(255),
118
- email_verified BOOLEAN DEFAULT FALSE NOT NULL,
119
- email_verification_token VARCHAR(255),
120
- email_verification_sent_at TIMESTAMP WITH TIME ZONE,
121
- is_anonymous BOOLEAN DEFAULT FALSE NOT NULL,
122
- metadata JSONB DEFAULT '{}' NOT NULL,
123
- tokens_valid_after TIMESTAMP WITH TIME ZONE,
124
- created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
125
- updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
162
+ ${sql.raw(usersColumnDdl)}
126
163
  )
127
164
  `);
128
165
 
@@ -142,26 +179,51 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
142
179
  // column once no old backend remains (phase 2, contract).
143
180
  //
144
181
  // Idempotent throughout: every step is guarded on catalogue state.
145
- await db.execute(sql`
146
- CREATE OR REPLACE FUNCTION ${sql.raw(`"${authSchema}"`)}.sync_uid_user_id() RETURNS trigger AS $$
147
- BEGIN
148
- IF NEW.uid IS NULL AND NEW.user_id IS NOT NULL THEN
149
- NEW.uid := NEW.user_id;
150
- ELSIF NEW.user_id IS NULL AND NEW.uid IS NOT NULL THEN
151
- NEW.user_id := NEW.uid;
152
- END IF;
153
- RETURN NEW;
154
- END $$ LANGUAGE plpgsql
155
- `);
156
-
157
- for (const authTable of [
182
+ const legacyFkTables = [
158
183
  "user_identities",
159
184
  "refresh_tokens",
160
185
  "password_reset_tokens",
161
186
  "magic_link_tokens",
162
187
  "mfa_factors",
163
188
  "recovery_codes"
164
- ]) {
189
+ ];
190
+
191
+ // Only on a database that actually carries the legacy column. This whole
192
+ // block is a 0.x compatibility shim, and it used to run unconditionally —
193
+ // so every brand-new database was provisioned with a trigger function
194
+ // written to reconcile a column it can never have, permanently, as part
195
+ // of its first boot. A fresh install should not ship someone else's
196
+ // migration history.
197
+ // The table list is inlined rather than bound: drizzle expands a JS
198
+ // array into a parameter TUPLE — `ANY(($2, $3, …))` — which Postgres
199
+ // rejects, and the thrown error is swallowed by the catch around this
200
+ // whole function, so auth would silently stop provisioning. These are
201
+ // module-level constants, not input.
202
+ const legacyFkTableList = legacyFkTables.map(t => `'${t}'`).join(", ");
203
+ const legacyUserIdPresent = await db.execute(sql`
204
+ SELECT 1
205
+ FROM information_schema.columns
206
+ WHERE table_schema = ${authSchema}
207
+ AND table_name IN (${sql.raw(legacyFkTableList)})
208
+ AND column_name = 'user_id'
209
+ LIMIT 1
210
+ `);
211
+
212
+ if (legacyUserIdPresent.rows.length > 0) {
213
+ await db.execute(sql`
214
+ CREATE OR REPLACE FUNCTION ${sql.raw(`"${authSchema}"`)}.sync_uid_user_id() RETURNS trigger AS $$
215
+ BEGIN
216
+ IF NEW.uid IS NULL AND NEW.user_id IS NOT NULL THEN
217
+ NEW.uid := NEW.user_id;
218
+ ELSIF NEW.user_id IS NULL AND NEW.uid IS NOT NULL THEN
219
+ NEW.user_id := NEW.uid;
220
+ END IF;
221
+ RETURN NEW;
222
+ END $$ LANGUAGE plpgsql
223
+ `);
224
+ }
225
+
226
+ for (const authTable of legacyUserIdPresent.rows.length > 0 ? legacyFkTables : []) {
165
227
  const qualified = `"${authSchema}"."${authTable}"`;
166
228
  await db.execute(sql`
167
229
  DO $$
@@ -313,39 +375,21 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
313
375
  )
314
376
  `);
315
377
 
316
- // Create the `auth` schema with PostgreSQL RLS helper functions.
317
- await db.execute(sql`CREATE SCHEMA IF NOT EXISTS auth`);
318
-
319
- // Use an advisory transaction lock to serialize function recreation during HMR
378
+ // The RLS helper functions every generated policy calls. They live in
379
+ // `rebase`, alongside the tables above — Rebase creates exactly one
380
+ // schema in a user's database. Advisory-locked so concurrent HMR
381
+ // reloads cannot race on `CREATE OR REPLACE`.
382
+ //
383
+ // The same statements the migration preamble carries, from the same
384
+ // constant — these definitions being identical across the boot path and
385
+ // the migration stream is the whole point of having them in one place.
386
+ // One call per statement: this handle speaks the extended query
387
+ // protocol, which rejects multi-command strings.
320
388
  await db.transaction(async (tx) => {
321
389
  await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init'))`);
322
-
323
- // Falls back to the pre-rename `app.user_id` so a database that has
324
- // taken the new schema but is still served by an older backend keeps
325
- // resolving the principal. Keep in sync with AUTH_BOOTSTRAP_SQL.
326
- await tx.execute(sql`
327
- CREATE OR REPLACE FUNCTION auth.uid() RETURNS text AS $$
328
- SELECT COALESCE(
329
- NULLIF(current_setting('app.uid', true), ''),
330
- NULLIF(current_setting('app.user_id', true), '')
331
- );
332
- $$ LANGUAGE sql STABLE
333
- `);
334
-
335
- await tx.execute(sql`
336
- CREATE OR REPLACE FUNCTION auth.jwt() RETURNS jsonb AS $$
337
- SELECT COALESCE(
338
- NULLIF(current_setting('app.jwt', true), ''),
339
- '{}'
340
- )::jsonb;
341
- $$ LANGUAGE sql STABLE
342
- `);
343
-
344
- await tx.execute(sql`
345
- CREATE OR REPLACE FUNCTION auth.roles() RETURNS text AS $$
346
- SELECT COALESCE(NULLIF(current_setting('app.user_roles', true), ''), '');
347
- $$ LANGUAGE sql STABLE
348
- `);
390
+ for (const statement of RLS_BOOTSTRAP_STATEMENTS) {
391
+ await tx.execute(sql.raw(statement));
392
+ }
349
393
  });
350
394
 
351
395
  // Seed default roles if none exist
@@ -356,26 +400,204 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
356
400
  // database provisioned by an older framework era is missing every
357
401
  // column added since. Each column the auth services read or write must
358
402
  // be back-filled here, or upgraded deployments break on the first
359
- // statement that references it. `email` is deliberately absent: it has
360
- // existed since the first era and cannot be added NOT NULL safely.
361
- const userColumnBackfills = [
362
- "display_name VARCHAR(255)",
363
- "photo_url VARCHAR(500)",
364
- "roles TEXT[] DEFAULT '{}' NOT NULL",
365
- "password_hash VARCHAR(255)",
366
- "email_verified BOOLEAN DEFAULT FALSE NOT NULL",
367
- "email_verification_token VARCHAR(255)",
368
- "email_verification_sent_at TIMESTAMP WITH TIME ZONE",
369
- "is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
370
- "metadata JSONB DEFAULT '{}' NOT NULL",
371
- "tokens_valid_after TIMESTAMP WITH TIME ZONE",
372
- "created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
373
- "updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
374
- ];
375
- for (const columnDef of userColumnBackfills) {
403
+ // statement that references it.
404
+ //
405
+ // `email` is skipped: it has existed since the first era, so it is never
406
+ // the missing one, and `ADD COLUMN … NOT NULL` with no default fails on
407
+ // a table with rows.
408
+ for (const spec of AUTH_USERS_COLUMNS) {
409
+ if (spec.column === "email") continue;
410
+ await db.execute(sql`
411
+ ALTER TABLE ${sql.raw(usersTableName)}
412
+ ADD COLUMN IF NOT EXISTS ${sql.raw(`${spec.column} ${authUsersColumnSql(spec)}`)}
413
+ `);
414
+ }
415
+
416
+ // Which of the columns below the table actually has. An adopted table —
417
+ // one this framework did not create, which the column-name resolution in
418
+ // `services.ts` exists to support — may be missing any of them, and every
419
+ // statement past this point has to tolerate that rather than abort the
420
+ // whole migration block.
421
+ const usersColumns = await db.execute(sql`
422
+ SELECT column_name, data_type, is_nullable, column_default
423
+ FROM information_schema.columns
424
+ WHERE table_schema = ${usersSchema} AND table_name = ${resolvedTable}
425
+ `);
426
+ type UsersColumnRow = {
427
+ column_name: string;
428
+ data_type: string;
429
+ is_nullable: "YES" | "NO";
430
+ column_default: string | null;
431
+ };
432
+ const usersColumnRows = usersColumns.rows as UsersColumnRow[];
433
+ const usersColumnTypes = new Map(usersColumnRows.map(row => [row.column_name, row.data_type]));
434
+ const usersColumnState = new Map(usersColumnRows.map(row => [row.column_name, row]));
435
+
436
+ // ── Migration: restore defaults and NOT NULL that another creator dropped ──
437
+ // `ADD COLUMN IF NOT EXISTS` above only creates what is MISSING. A column
438
+ // that exists with the wrong shape stays wrong forever — and until
439
+ // AUTH_USERS_COLUMNS became the single source, that was the normal
440
+ // outcome rather than an edge case: whichever of `db push`, boot-ensure
441
+ // and this function reached the table first decided its constraints, so
442
+ // a managed deploy ended up with a nullable `email`, a `roles` with no
443
+ // `'{}'` default, and an `email_verified` that could be NULL.
444
+ //
445
+ // Ordered DEFAULT → back-fill → SET NOT NULL, because SET NOT NULL is
446
+ // checked against existing rows: without the back-fill it throws on the
447
+ // very databases that need it. `email` can carry no default, so a NULL
448
+ // there is not repairable automatically — say so and leave the column
449
+ // alone rather than inventing an address.
450
+ for (const spec of AUTH_USERS_COLUMNS) {
451
+ const state = usersColumnState.get(spec.column);
452
+ if (!state) continue;
453
+
454
+ if (spec.default !== undefined && state.column_default === null) {
455
+ await db.execute(sql`
456
+ ALTER TABLE ${sql.raw(usersTableName)}
457
+ ALTER COLUMN ${sql.raw(`"${spec.column}"`)} SET DEFAULT ${sql.raw(spec.default)}
458
+ `);
459
+ logger.info(`🔧 Restored the default on ${usersTableName}.${spec.column}`);
460
+ }
461
+
462
+ if (!spec.notNull || state.is_nullable !== "YES") continue;
463
+
464
+ if (spec.default !== undefined) {
465
+ await db.execute(sql`
466
+ UPDATE ${sql.raw(usersTableName)}
467
+ SET ${sql.raw(`"${spec.column}"`)} = ${sql.raw(spec.default)}
468
+ WHERE ${sql.raw(`"${spec.column}"`)} IS NULL
469
+ `);
470
+ }
471
+ try {
472
+ await db.execute(sql`
473
+ ALTER TABLE ${sql.raw(usersTableName)}
474
+ ALTER COLUMN ${sql.raw(`"${spec.column}"`)} SET NOT NULL
475
+ `);
476
+ logger.info(`🔧 Restored NOT NULL on ${usersTableName}.${spec.column}`);
477
+ } catch (err) {
478
+ logger.warn(
479
+ `⚠️ ${usersTableName}.${spec.column} should be NOT NULL but still holds NULLs, so the ` +
480
+ "constraint was not applied. Fill or remove those rows and restart: " +
481
+ (err instanceof Error ? err.message : String(err))
482
+ );
483
+ }
484
+ }
485
+
486
+ // ── Migration: VARCHAR(n) → TEXT on the users string columns ────────
487
+ // Tables created before the widths came off still carry them. Postgres
488
+ // treats varchar(n) → text as binary-coercible with no stricter
489
+ // constraint, so this is a catalogue-only change: no table rewrite, no
490
+ // index rebuild, just a brief ACCESS EXCLUSIVE lock. Guarded on the
491
+ // current type so it runs once and is a pure catalogue read thereafter.
492
+ for (const column of ["email", "display_name", "photo_url", "password_hash", "email_verification_token"]) {
493
+ if (usersColumnTypes.get(column) !== "character varying") continue;
376
494
  await db.execute(sql`
377
495
  ALTER TABLE ${sql.raw(usersTableName)}
378
- ADD COLUMN IF NOT EXISTS ${sql.raw(columnDef)}
496
+ ALTER COLUMN ${sql.raw(`"${column}"`)} TYPE TEXT
497
+ `);
498
+ logger.info(`🔧 Widened ${usersTableName}.${column} from VARCHAR(n) to TEXT`);
499
+ }
500
+
501
+ // ── Migration: case-insensitive email identity ──────────────────────
502
+ // `getUserByEmail` has always searched `email.toLowerCase()` while the
503
+ // write path stored whatever it was handed, leaving normalisation to a
504
+ // convention every caller had to remember. A row that reached the table
505
+ // with mixed case is then invisible to every lookup — the account exists,
506
+ // login reports no such user, and the plain UNIQUE on `email` does not
507
+ // stop a second row differing only in case, because it compares bytes.
508
+ //
509
+ // Fixed on both sides: `mapPayload` now folds on write, and this index
510
+ // makes the database agree. A unique index on lower(email) is strictly
511
+ // stronger than the byte-exact UNIQUE that older tables carry, so the
512
+ // old constraint is left alone — it can no longer fire on anything the
513
+ // new one would allow.
514
+ //
515
+ // Deliberately no AUTH_SCHEMA_VERSION bump: a runtime that predates this
516
+ // migration keeps working against the migrated table (all of its own
517
+ // write paths already lower-cased), which is exactly the additive case
518
+ // the version stamp is documented not to cover.
519
+ if (usersColumnTypes.has("email")) {
520
+ const indexPresent = await db.execute(sql`
521
+ SELECT 1 FROM pg_class c
522
+ JOIN pg_namespace n ON n.oid = c.relnamespace
523
+ WHERE n.nspname = ${usersSchema} AND c.relname = ${emailLowerUniqueIndex} AND c.relkind = 'i'
524
+ `);
525
+ if (indexPresent.rows.length === 0) {
526
+ // Case-collisions already in the table would make the unique
527
+ // index impossible to build. Report them and leave the table
528
+ // alone: the next boot retries, so fixing the rows is all the
529
+ // operator has to do. Failing loudly beats folding the emails
530
+ // and letting CREATE INDEX pick which account survives.
531
+ const collisions = await db.execute(sql`
532
+ SELECT lower(email) AS normalized, count(*)::int AS occurrences
533
+ FROM ${sql.raw(usersTableName)}
534
+ WHERE email IS NOT NULL
535
+ GROUP BY lower(email)
536
+ HAVING count(*) > 1
537
+ LIMIT 10
538
+ `);
539
+ if (collisions.rows.length > 0) {
540
+ const sample = (collisions.rows as { normalized: string; occurrences: number }[])
541
+ .map(row => `${row.normalized} (×${row.occurrences})`)
542
+ .join(", ");
543
+ logger.error(
544
+ `❌ Cannot enforce case-insensitive email uniqueness on ${usersTableName}: ` +
545
+ `these addresses already exist more than once, differing only in case — ${sample}. ` +
546
+ "Merge or delete the duplicates and restart; until then two accounts can share " +
547
+ "one address and only the lower-cased one is reachable by login."
548
+ );
549
+ } else {
550
+ const folded = await db.execute(sql`
551
+ UPDATE ${sql.raw(usersTableName)}
552
+ SET email = lower(email)
553
+ WHERE email IS NOT NULL AND email <> lower(email)
554
+ `);
555
+ if (folded.rowCount) {
556
+ logger.info(`🔧 Lower-cased ${folded.rowCount} email address(es) in ${usersTableName}`);
557
+ }
558
+ await db.execute(sql`
559
+ CREATE UNIQUE INDEX IF NOT EXISTS ${sql.raw(`"${emailLowerUniqueIndex}"`)}
560
+ ON ${sql.raw(usersTableName)} (lower(email))
561
+ `);
562
+ logger.info(`✅ Email uniqueness on ${usersTableName} is now case-insensitive`);
563
+ }
564
+ }
565
+ }
566
+
567
+ // ── Migration: bound the email column's length ──────────────────────
568
+ // The only length limit on this table worth keeping. 320 is the RFC 5321
569
+ // maximum (64-char local part + @ + 255-char domain), and it matters here
570
+ // beyond tidiness: `email` carries a btree index, and a sufficiently long
571
+ // value fails index insertion with an error that says nothing about
572
+ // email. NOT VALID so an adopted table with a long row still migrates —
573
+ // it binds all new writes, which is the part that matters.
574
+ if (usersColumnTypes.has("email")) {
575
+ const checkPresent = await db.execute(sql`
576
+ SELECT 1 FROM pg_constraint c
577
+ JOIN pg_class t ON t.oid = c.conrelid
578
+ JOIN pg_namespace n ON n.oid = t.relnamespace
579
+ WHERE n.nspname = ${usersSchema}
580
+ AND t.relname = ${resolvedTable}
581
+ AND c.conname = ${authIdentifier("email_length_check")}
582
+ `);
583
+ if (checkPresent.rows.length === 0) {
584
+ await db.execute(sql`
585
+ ALTER TABLE ${sql.raw(usersTableName)}
586
+ ADD CONSTRAINT ${sql.raw(emailLengthConstraint)} CHECK (length(email) <= 320) NOT VALID
587
+ `);
588
+ }
589
+ }
590
+
591
+ // ── Index: email verification token lookups ─────────────────────────
592
+ // `getUserByVerificationToken` filters on this column, which had no
593
+ // index — every click of a verification link was a sequential scan of
594
+ // the whole users table. Partial, because the column is NULL for every
595
+ // user who is not mid-verification, which is nearly all of them.
596
+ if (usersColumnTypes.has("email_verification_token")) {
597
+ await db.execute(sql`
598
+ CREATE INDEX IF NOT EXISTS ${sql.raw(`"${verificationTokenIndex}"`)}
599
+ ON ${sql.raw(usersTableName)} (email_verification_token)
600
+ WHERE email_verification_token IS NOT NULL
379
601
  `);
380
602
  }
381
603
 
@@ -562,15 +784,22 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
562
784
  // registration after an upgrade fails with SQLSTATE 42501. Reconcile
563
785
  // on boot; only tables actually flagged get the ALTER (and its lock).
564
786
  try {
787
+ // Every table this function creates, not a subset. `magic_link_tokens`
788
+ // and `schema_meta` were missing here while their six siblings were
789
+ // listed — so on a database carrying FORCE from the older RLS model,
790
+ // magic-link sign-in kept failing 42501 after the upgrade that was
791
+ // supposed to fix exactly that, and only for the one auth method.
565
792
  const authTablePairs: [string, string][] = [
566
793
  [usersSchema, resolvedTable],
567
794
  [authSchema, "user_identities"],
568
795
  [authSchema, "refresh_tokens"],
569
796
  [authSchema, "password_reset_tokens"],
797
+ [authSchema, "magic_link_tokens"],
570
798
  [authSchema, "app_config"],
571
799
  [authSchema, "mfa_factors"],
572
800
  [authSchema, "mfa_challenges"],
573
- [authSchema, "recovery_codes"]
801
+ [authSchema, "recovery_codes"],
802
+ [authSchema, "schema_meta"]
574
803
  ];
575
804
  for (const [schemaName, tableName] of authTablePairs) {
576
805
  const forced = await db.execute(sql`
@@ -601,10 +830,35 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
601
830
  );
602
831
  }
603
832
 
604
- // Stamped last, so a boot that died partway through the migrations above
605
- // leaves the older stamp in place and the next boot runs them again.
833
+ // Stamped last of the MIGRATIONS, so a boot that died partway through
834
+ // the ones above leaves the older stamp in place and the next boot runs
835
+ // them again.
606
836
  await stampAuthSchemaVersion(db, authSchema);
607
837
 
838
+ // ── Keep the end-user role out of auth's tables ─────────────────────
839
+ // These carry session token hashes, TOTP secrets and recovery codes, and
840
+ // none of them has RLS — they are not collections, so nothing ever
841
+ // compiled a policy for them. Meanwhile the role provisioning grants
842
+ // `rebase_user` DML on every table in this schema, and its
843
+ // ALTER DEFAULT PRIVILEGES reaches the ones created right here, after it
844
+ // ran. So the grant has to come back off; see `revokeInternalTableSql`
845
+ // for why a revoke rather than an empty RLS policy set.
846
+ //
847
+ // After the stamp deliberately: `schema_meta` is created BY the stamp,
848
+ // so revoking first would leave the one table holding this database's
849
+ // schema version writable by every signed-in user until the next boot.
850
+ // Nothing below re-runs the migrations, so the stamp's guarantee holds.
851
+ await revokeInternalTableAccess(
852
+ async (text) => { await db.execute(sql.raw(text)); },
853
+ authSchema,
854
+ {
855
+ onError: (table, err) => logger.warn(
856
+ `🔐 Could not revoke authenticated-role access to "${authSchema}"."${table}": ` +
857
+ (err instanceof Error ? err.message : String(err))
858
+ )
859
+ }
860
+ );
861
+
608
862
  logger.info("✅ Auth tables ready");
609
863
  } catch (error) {
610
864
  // The one failure that must not be survived. Continuing here is what
@@ -54,6 +54,28 @@ function getColumn(table: RebasePgTable | undefined, ...keys: string[]): RebaseP
54
54
  return key ? table[key] : undefined;
55
55
  }
56
56
 
57
+ /**
58
+ * The single definition of what an email address looks like in storage.
59
+ *
60
+ * Reads have always folded case; writes did not, and normalising was left to
61
+ * each caller. That asymmetry is only ever one forgotten `.toLowerCase()` away
62
+ * from a row no lookup can find — the account exists, every sign-in path
63
+ * reports no such user, and the byte-exact UNIQUE on the column does not stop a
64
+ * duplicate differing only in case. Applied on both sides here so the guarantee
65
+ * belongs to the repository rather than to its callers' discipline; the
66
+ * `lower(email)` unique index added in `ensureAuthTablesExist` is the database
67
+ * half of the same rule.
68
+ *
69
+ * Whitespace goes too: a trailing space survives the fold and reproduces the
70
+ * problem exactly.
71
+ *
72
+ * Re-exported rather than defined here: `@rebasepro/server` and
73
+ * `@rebasepro/server-mongo` write this column too, and a second copy of this
74
+ * rule is the defect it exists to prevent.
75
+ */
76
+ import { normalizeEmail } from "@rebasepro/common";
77
+ export { normalizeEmail };
78
+
57
79
  /**
58
80
  * PostgreSQL implementation of UserRepository.
59
81
  * Handles all user-related database operations using Drizzle ORM.
@@ -184,7 +206,7 @@ export class UserService implements UserRepository {
184
206
  const metadataKey = getColumnKey(this.usersTable, "metadata") || "metadata";
185
207
 
186
208
  if ("id" in data) payload[idKey] = data.id;
187
- if ("email" in data) payload[emailKey] = data.email;
209
+ if ("email" in data) payload[emailKey] = normalizeEmail(data.email);
188
210
  if ("passwordHash" in data) payload[passwordHashKey] = data.passwordHash;
189
211
  if ("displayName" in data) payload[displayNameKey] = data.displayName;
190
212
  if ("photoUrl" in data) payload[photoUrlKey] = data.photoUrl;
@@ -244,7 +266,7 @@ export class UserService implements UserRepository {
244
266
  async getUserByEmail(email: string): Promise<UserData | null> {
245
267
  const emailCol = getColumn(this.usersTable, "email");
246
268
  if (!emailCol) return null;
247
- const [row] = await this.db.select().from(this.usersTable).where(eq(emailCol, email.toLowerCase()));
269
+ const [row] = await this.db.select().from(this.usersTable).where(eq(emailCol, normalizeEmail(email)));
248
270
  return row ? this.mapRowToUser(row as Record<string, unknown>) : null;
249
271
  }
250
272
 
@@ -100,6 +100,8 @@ export async function backupCommand(rawArgs: string[]): Promise<void> {
100
100
  "--out": String,
101
101
  "--exclude-schema": [String],
102
102
  "--no-owner": Boolean,
103
+ "--enable-row-security": Boolean,
104
+ "--row-security-role": String,
103
105
  "-o": "--out"
104
106
  },
105
107
  { argv: rawArgs.slice(2), permissive: true }
@@ -129,6 +131,23 @@ export async function backupCommand(rawArgs: string[]): Promise<void> {
129
131
  }
130
132
  logger.info(chalk.gray(` Using pg_dump ${pf.toolMajor} against server ${pf.serverMajor}.`));
131
133
 
134
+ // Opt-in, and loud. With row security on, pg_dump stops refusing to read
135
+ // rows it cannot see and starts leaving them out — an exit-0 backup that
136
+ // is quietly short. Anyone choosing that should know they chose it.
137
+ const rowSecurity = args["--enable-row-security"]
138
+ ? { uid: "rebase-db-backup", roles: [args["--row-security-role"] || "admin"] }
139
+ : undefined;
140
+
141
+ if (rowSecurity) {
142
+ logger.warn("");
143
+ logger.warn(chalk.yellow(" ⚠ Dumping with row-level security ON."));
144
+ logger.warn(chalk.gray(` Reading as roles [${rowSecurity.roles.join(", ")}], which satisfies the generated`));
145
+ logger.warn(chalk.gray(" `admin_full_access` policy. This backup contains exactly the rows those"));
146
+ logger.warn(chalk.gray(" policies admit — a table whose policies have no admin rule comes out short,"));
147
+ logger.warn(chalk.gray(" and pg_dump will not say so. Prefer granting the dumping role BYPASSRLS."));
148
+ logger.warn("");
149
+ }
150
+
132
151
  try {
133
152
  if (dest.kind === "local") {
134
153
  // Honour an explicit `…/name.dump` path; otherwise treat it as a
@@ -141,7 +160,8 @@ export async function backupCommand(rawArgs: string[]): Promise<void> {
141
160
  fileName: explicitFile ? path.basename(dest.path) : undefined,
142
161
  excludeSchemas: args["--exclude-schema"],
143
162
  noOwner: args["--no-owner"],
144
- inheritStdio: true
163
+ inheritStdio: true,
164
+ rowSecurity
145
165
  });
146
166
  await assertDumpValid(dump.localFile);
147
167
  logger.info("");
@@ -156,7 +176,8 @@ export async function backupCommand(rawArgs: string[]): Promise<void> {
156
176
  dbName,
157
177
  excludeSchemas: args["--exclude-schema"],
158
178
  noOwner: args["--no-owner"],
159
- inheritStdio: true
179
+ inheritStdio: true,
180
+ rowSecurity
160
181
  });
161
182
  try {
162
183
  await assertDumpValid(dump.localFile);
@@ -395,6 +416,24 @@ ${chalk.green.bold("Options")}
395
416
  ${chalk.blue("--out, -o")} <dest> Local path or s3://…/gs://… URL (default: ./backups)
396
417
  ${chalk.blue("--exclude-schema")} <s> Exclude a schema (repeatable)
397
418
  ${chalk.blue("--no-owner")} Omit ownership commands from the dump
419
+ ${chalk.blue("--enable-row-security")} Dump as an admin subject instead of failing on RLS
420
+ ${chalk.red("(may produce a partial dump — see below)")}
421
+ ${chalk.blue("--row-security-role")} <r> Role to read as with the flag above (default: admin)
422
+
423
+ ${chalk.green.bold("Row-level security")}
424
+ On a managed Postgres the dumping role usually owns nothing and has no
425
+ BYPASSRLS, so pg_dump refuses:
426
+
427
+ ERROR: query would be affected by row-level security policy for table "..."
428
+
429
+ That refusal is the safe behaviour. --enable-row-security replaces it by
430
+ reading as an admin subject: Rebase sets app.uid/app.user_roles so the
431
+ generated admin_full_access policy admits the dump. The dump then contains
432
+ exactly what those policies admit ${chalk.red("and no error is raised for what they do not")} —
433
+ a table whose policies lack an admin rule comes out short, silently.
434
+
435
+ Granting the dumping role BYPASSRLS is the option that keeps a backup
436
+ meaning "every row".
398
437
 
399
438
  ${chalk.green.bold("Notes")}
400
439
  Backups may contain secrets and PII. Use private storage destinations and