@rebasepro/server-postgres 0.13.1-canary.gef9608c → 0.14.0

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 (114) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/PostgresBootstrapper.d.ts +26 -0
  3. package/dist/auth/services.d.ts +21 -0
  4. package/dist/{src-CU6WZGYV.js → auth-users-columns-BfQHf9JE.js} +1111 -92
  5. package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
  6. package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
  7. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
  8. package/dist/cli-helpers.d.ts +56 -0
  9. package/dist/cli-output.d.ts +34 -0
  10. package/dist/data-transformer.d.ts +7 -2
  11. package/dist/data_driver-ULAyJEi9.js +193 -0
  12. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  13. package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
  14. package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
  15. package/dist/{ensure-collection-tables-BLIIACla.js → ensure-collection-tables-CbvaGuVn.js} +162 -16
  16. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  17. package/dist/index.es.js +1720 -946
  18. package/dist/index.es.js.map +1 -1
  19. package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
  20. package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
  21. package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
  22. package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
  23. package/dist/schema/auth-schema.d.ts +102 -0
  24. package/dist/schema/auth-users-columns.d.ts +97 -0
  25. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  26. package/dist/schema/doctor.d.ts +41 -25
  27. package/dist/schema/ensure-collection-policies.d.ts +33 -9
  28. package/dist/schema/ensure-collection-tables.d.ts +60 -6
  29. package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
  30. package/dist/schema/generate-postgres-ddl-logic.d.ts +48 -0
  31. package/dist/schema/introspect-db-inference.d.ts +8 -1
  32. package/dist/schema/introspect-db-logic.d.ts +49 -0
  33. package/dist/schema/introspect-db-project.d.ts +21 -0
  34. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  35. package/dist/schema/search-column.d.ts +248 -0
  36. package/dist/security/policy-drift.d.ts +34 -0
  37. package/dist/security/rls-enforcement.d.ts +61 -5
  38. package/dist/services/FetchService.d.ts +24 -0
  39. package/dist/services/PersistService.d.ts +21 -17
  40. package/dist/services/RelationService.d.ts +9 -57
  41. package/dist/services/RelationWriteService.d.ts +82 -0
  42. package/dist/services/collection-helpers.d.ts +42 -0
  43. package/dist/services/dataService.d.ts +3 -0
  44. package/dist/services/junction-writes.d.ts +82 -0
  45. package/dist/services/realtimeService.d.ts +139 -2
  46. package/dist/services/write-denial.d.ts +36 -0
  47. package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
  48. package/dist/src-DCdn3Val.js.map +1 -0
  49. package/dist/utils/drizzle-conditions.d.ts +124 -2
  50. package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
  51. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  52. package/package.json +8 -7
  53. package/src/PostgresBackendDriver.ts +172 -6
  54. package/src/PostgresBootstrapper.ts +136 -11
  55. package/src/auth/ensure-tables.ts +212 -91
  56. package/src/auth/services.ts +82 -5
  57. package/src/backup/backup-cli.ts +59 -57
  58. package/src/cli-errors.ts +6 -6
  59. package/src/cli-helpers.ts +124 -11
  60. package/src/cli-output.ts +43 -0
  61. package/src/cli.ts +299 -168
  62. package/src/collections/buildRegistry.ts +3 -1
  63. package/src/data-transformer.ts +129 -25
  64. package/src/history/ensure-history-table.ts +9 -2
  65. package/src/schema/auth-schema.ts +17 -1
  66. package/src/schema/auth-users-columns.ts +131 -0
  67. package/src/schema/doctor-cli.ts +14 -65
  68. package/src/schema/doctor-policy-checks.ts +105 -0
  69. package/src/schema/doctor.ts +149 -72
  70. package/src/schema/ensure-collection-policies.ts +99 -6
  71. package/src/schema/ensure-collection-tables.ts +366 -30
  72. package/src/schema/generate-drizzle-schema-logic.ts +146 -66
  73. package/src/schema/generate-drizzle-schema.ts +11 -10
  74. package/src/schema/generate-postgres-ddl-logic.ts +277 -10
  75. package/src/schema/generate-postgres-ddl.ts +38 -14
  76. package/src/schema/generated-schema-staleness.ts +14 -7
  77. package/src/schema/introspect-db-inference.ts +9 -2
  78. package/src/schema/introspect-db-logic.ts +251 -75
  79. package/src/schema/introspect-db-project.ts +78 -0
  80. package/src/schema/introspect-db.ts +42 -25
  81. package/src/schema/introspect-runtime.ts +14 -2
  82. package/src/schema/rls-bootstrap-sql.ts +288 -0
  83. package/src/schema/search-column.ts +643 -0
  84. package/src/security/anonymous-grants.test.ts +4 -2
  85. package/src/security/policy-drift.test.ts +104 -3
  86. package/src/security/policy-drift.ts +129 -7
  87. package/src/security/rls-enforcement.ts +150 -7
  88. package/src/services/BranchService.ts +5 -0
  89. package/src/services/FetchService.ts +243 -22
  90. package/src/services/PersistService.ts +68 -42
  91. package/src/services/RelationService.ts +37 -696
  92. package/src/services/RelationWriteService.ts +653 -0
  93. package/src/services/cdc/trigger-cdc.ts +5 -1
  94. package/src/services/channel-history.ts +14 -0
  95. package/src/services/channel-presence.ts +13 -0
  96. package/src/services/collection-helpers.ts +89 -4
  97. package/src/services/dataService.ts +3 -0
  98. package/src/services/junction-writes.ts +295 -0
  99. package/src/services/pg-notify-listener.ts +1 -1
  100. package/src/services/realtimeService.ts +347 -86
  101. package/src/services/write-denial.ts +55 -0
  102. package/src/utils/drizzle-conditions.ts +433 -35
  103. package/src/utils/pg-error-utils.ts +8 -3
  104. package/src/websocket.ts +113 -16
  105. package/dist/ensure-collection-policies-Bck0ky4u.js +0 -57
  106. package/dist/ensure-collection-policies-Bck0ky4u.js.map +0 -1
  107. package/dist/ensure-collection-tables-BLIIACla.js.map +0 -1
  108. package/dist/policy-CeA1JcxP.js +0 -105
  109. package/dist/policy-CeA1JcxP.js.map +0 -1
  110. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  111. package/dist/src-CU6WZGYV.js.map +0 -1
  112. package/dist/src-DoU9yPqq.js.map +0 -1
  113. package/dist/websocket-B2LsrINK.js.map +0 -1
  114. 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,
@@ -18,7 +21,7 @@ import {
18
21
  * When omitted, a default `rebase.users` table is created.
19
22
  */
20
23
  export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: CollectionConfig): Promise<void> {
21
- logger.info("🔍 Checking auth tables...");
24
+ logger.debug("🔍 Checking auth tables...");
22
25
 
23
26
  // Before anything else, and deliberately outside the catch below: refuse to
24
27
  // run against a database that a newer framework version has already
@@ -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
@@ -75,7 +88,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
75
88
  } else {
76
89
  userIdType = "TEXT";
77
90
  }
78
- logger.info(`✨ Detected ${usersTableName}.id type from database: ${dbType}. Using user_id type: ${userIdType}`);
91
+ logger.debug(`✨ Detected ${usersTableName}.id type from database: ${dbType}. Using user_id type: ${userIdType}`);
79
92
  }
80
93
  } catch (err) {
81
94
  // Ignore introspection errors, fallback to derived/default type
@@ -129,22 +142,24 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
129
142
  // and long signed URLs that OAuth providers hand back. A limit worth
130
143
  // having is a CHECK — alterable without a table rewrite, unlike a type
131
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 ");
132
159
  await db.execute(sql`
133
160
  CREATE TABLE IF NOT EXISTS ${sql.raw(usersTableName)} (
134
161
  id ${sql.raw(userIdType)} PRIMARY KEY ${sql.raw(idDefault)},
135
- email TEXT NOT NULL CONSTRAINT ${sql.raw(emailLengthConstraint)} CHECK (length(email) <= 320),
136
- display_name TEXT,
137
- photo_url TEXT,
138
- roles TEXT[] DEFAULT '{}' NOT NULL,
139
- password_hash TEXT,
140
- email_verified BOOLEAN DEFAULT FALSE NOT NULL,
141
- email_verification_token TEXT,
142
- email_verification_sent_at TIMESTAMP WITH TIME ZONE,
143
- is_anonymous BOOLEAN DEFAULT FALSE NOT NULL,
144
- metadata JSONB DEFAULT '{}' NOT NULL,
145
- tokens_valid_after TIMESTAMP WITH TIME ZONE,
146
- created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
147
- updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
162
+ ${sql.raw(usersColumnDdl)}
148
163
  )
149
164
  `);
150
165
 
@@ -164,26 +179,51 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
164
179
  // column once no old backend remains (phase 2, contract).
165
180
  //
166
181
  // Idempotent throughout: every step is guarded on catalogue state.
167
- await db.execute(sql`
168
- CREATE OR REPLACE FUNCTION ${sql.raw(`"${authSchema}"`)}.sync_uid_user_id() RETURNS trigger AS $$
169
- BEGIN
170
- IF NEW.uid IS NULL AND NEW.user_id IS NOT NULL THEN
171
- NEW.uid := NEW.user_id;
172
- ELSIF NEW.user_id IS NULL AND NEW.uid IS NOT NULL THEN
173
- NEW.user_id := NEW.uid;
174
- END IF;
175
- RETURN NEW;
176
- END $$ LANGUAGE plpgsql
177
- `);
178
-
179
- for (const authTable of [
182
+ const legacyFkTables = [
180
183
  "user_identities",
181
184
  "refresh_tokens",
182
185
  "password_reset_tokens",
183
186
  "magic_link_tokens",
184
187
  "mfa_factors",
185
188
  "recovery_codes"
186
- ]) {
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 : []) {
187
227
  const qualified = `"${authSchema}"."${authTable}"`;
188
228
  await db.execute(sql`
189
229
  DO $$
@@ -335,39 +375,21 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
335
375
  )
336
376
  `);
337
377
 
338
- // Create the `auth` schema with PostgreSQL RLS helper functions.
339
- await db.execute(sql`CREATE SCHEMA IF NOT EXISTS auth`);
340
-
341
- // 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.
342
388
  await db.transaction(async (tx) => {
343
389
  await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init'))`);
344
-
345
- // Falls back to the pre-rename `app.user_id` so a database that has
346
- // taken the new schema but is still served by an older backend keeps
347
- // resolving the principal. Keep in sync with AUTH_BOOTSTRAP_SQL.
348
- await tx.execute(sql`
349
- CREATE OR REPLACE FUNCTION auth.uid() RETURNS text AS $$
350
- SELECT COALESCE(
351
- NULLIF(current_setting('app.uid', true), ''),
352
- NULLIF(current_setting('app.user_id', true), '')
353
- );
354
- $$ LANGUAGE sql STABLE
355
- `);
356
-
357
- await tx.execute(sql`
358
- CREATE OR REPLACE FUNCTION auth.jwt() RETURNS jsonb AS $$
359
- SELECT COALESCE(
360
- NULLIF(current_setting('app.jwt', true), ''),
361
- '{}'
362
- )::jsonb;
363
- $$ LANGUAGE sql STABLE
364
- `);
365
-
366
- await tx.execute(sql`
367
- CREATE OR REPLACE FUNCTION auth.roles() RETURNS text AS $$
368
- SELECT COALESCE(NULLIF(current_setting('app.user_roles', true), ''), '');
369
- $$ LANGUAGE sql STABLE
370
- `);
390
+ for (const statement of RLS_BOOTSTRAP_STATEMENTS) {
391
+ await tx.execute(sql.raw(statement));
392
+ }
371
393
  });
372
394
 
373
395
  // Seed default roles if none exist
@@ -378,26 +400,16 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
378
400
  // database provisioned by an older framework era is missing every
379
401
  // column added since. Each column the auth services read or write must
380
402
  // be back-filled here, or upgraded deployments break on the first
381
- // statement that references it. `email` is deliberately absent: it has
382
- // existed since the first era and cannot be added NOT NULL safely.
383
- const userColumnBackfills = [
384
- "display_name TEXT",
385
- "photo_url TEXT",
386
- "roles TEXT[] DEFAULT '{}' NOT NULL",
387
- "password_hash TEXT",
388
- "email_verified BOOLEAN DEFAULT FALSE NOT NULL",
389
- "email_verification_token TEXT",
390
- "email_verification_sent_at TIMESTAMP WITH TIME ZONE",
391
- "is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
392
- "metadata JSONB DEFAULT '{}' NOT NULL",
393
- "tokens_valid_after TIMESTAMP WITH TIME ZONE",
394
- "created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
395
- "updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
396
- ];
397
- 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;
398
410
  await db.execute(sql`
399
411
  ALTER TABLE ${sql.raw(usersTableName)}
400
- ADD COLUMN IF NOT EXISTS ${sql.raw(columnDef)}
412
+ ADD COLUMN IF NOT EXISTS ${sql.raw(`${spec.column} ${authUsersColumnSql(spec)}`)}
401
413
  `);
402
414
  }
403
415
 
@@ -407,14 +419,69 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
407
419
  // statement past this point has to tolerate that rather than abort the
408
420
  // whole migration block.
409
421
  const usersColumns = await db.execute(sql`
410
- SELECT column_name, data_type
422
+ SELECT column_name, data_type, is_nullable, column_default
411
423
  FROM information_schema.columns
412
424
  WHERE table_schema = ${usersSchema} AND table_name = ${resolvedTable}
413
425
  `);
414
- const usersColumnTypes = new Map(
415
- (usersColumns.rows as { column_name: string; data_type: string }[])
416
- .map(row => [row.column_name, row.data_type])
417
- );
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
+ }
418
485
 
419
486
  // ── Migration: VARCHAR(n) → TEXT on the users string columns ────────
420
487
  // Tables created before the widths came off still carry them. Postgres
@@ -561,7 +628,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
561
628
  WHERE table_name = 'refresh_tokens'
562
629
  `);
563
630
  const found = (rtTables.rows as { table_schema: string; table_name: string }[]);
564
- logger.info(`🔍 refresh_tokens reconcile: found ${found.length} table(s): ${found.map(r => `"${r.table_schema}"."${r.table_name}"`).join(", ") || "(none)"}`);
631
+ logger.debug(`🔍 refresh_tokens reconcile: found ${found.length} table(s): ${found.map(r => `"${r.table_schema}"."${r.table_name}"`).join(", ") || "(none)"}`);
565
632
  for (const { table_schema } of found) {
566
633
  const qualified = `"${table_schema}"."refresh_tokens"`;
567
634
  try {
@@ -574,6 +641,12 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
574
641
  await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS revoked BOOLEAN DEFAULT FALSE NOT NULL`);
575
642
  await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS rotated_at TIMESTAMP WITH TIME ZONE`);
576
643
  await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_started_at TIMESTAMP WITH TIME ZONE`);
644
+ // Nullable with no default and no back-fill: a row written
645
+ // before this column existed says nothing about whether a
646
+ // second factor was presented, and the reader treats "says
647
+ // nothing" as `aal1` — the restrictive answer. Stamping
648
+ // every existing row would be inventing evidence.
649
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS aal TEXT`);
577
650
 
578
651
  // One session per pre-existing row: under the old model a row
579
652
  // WAS a device session, and there is no record of which rows
@@ -605,7 +678,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
605
678
  // live tokens of one session (a rotation in flight) share a
606
679
  // uid, and usually a user agent and IP too.
607
680
  await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} DROP CONSTRAINT IF EXISTS unique_device_session`);
608
- logger.info(`✅ refresh_tokens reconciled for session-scoped rotation: ${qualified}`);
681
+ logger.debug(`✅ refresh_tokens reconciled for session-scoped rotation: ${qualified}`);
609
682
  } catch (perTableError: unknown) {
610
683
  logger.warn(`⚠️ refresh_tokens reconcile failed for ${qualified}: ${perTableError instanceof Error ? perTableError.message : String(perTableError)}`);
611
684
  }
@@ -663,6 +736,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
663
736
  secret_encrypted TEXT NOT NULL,
664
737
  friendly_name TEXT,
665
738
  verified BOOLEAN DEFAULT FALSE,
739
+ last_used_counter BIGINT,
666
740
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
667
741
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
668
742
  )
@@ -682,6 +756,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
682
756
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
683
757
  verified_at TIMESTAMP WITH TIME ZONE,
684
758
  ip_address TEXT,
759
+ attempts INTEGER NOT NULL DEFAULT 0,
685
760
  expires_at TIMESTAMP WITH TIME ZONE NOT NULL
686
761
  )
687
762
  `);
@@ -692,6 +767,20 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
692
767
  ON ${sql.raw(mfaChallengesTableName)}(factor_id)
693
768
  `);
694
769
 
770
+ // ── Migration: replay and brute-force state on the MFA tables ───────
771
+ // Both are additive and nullable-or-defaulted, so a runtime that
772
+ // predates them reads the tables unchanged. `last_used_counter` records
773
+ // the TOTP step a factor has already spent (RFC 6238 §5.2); `attempts`
774
+ // bounds how many guesses one challenge will take before it is dead.
775
+ // Without them the code paths degrade to "no replay protection, rate
776
+ // limiters only" rather than failing, which is why this is a warn.
777
+ try {
778
+ await db.execute(sql`ALTER TABLE ${sql.raw(mfaFactorsTableName)} ADD COLUMN IF NOT EXISTS last_used_counter BIGINT`);
779
+ await db.execute(sql`ALTER TABLE ${sql.raw(mfaChallengesTableName)} ADD COLUMN IF NOT EXISTS attempts INTEGER NOT NULL DEFAULT 0`);
780
+ } catch (mfaMigrationError: unknown) {
781
+ logger.warn(`⚠️ MFA hardening columns skipped: ${mfaMigrationError instanceof Error ? mfaMigrationError.message : String(mfaMigrationError)}`);
782
+ }
783
+
695
784
  // Create recovery_codes table
696
785
  await db.execute(sql`
697
786
  CREATE TABLE IF NOT EXISTS ${sql.raw(recoveryCodesTableName)} (
@@ -717,15 +806,22 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
717
806
  // registration after an upgrade fails with SQLSTATE 42501. Reconcile
718
807
  // on boot; only tables actually flagged get the ALTER (and its lock).
719
808
  try {
809
+ // Every table this function creates, not a subset. `magic_link_tokens`
810
+ // and `schema_meta` were missing here while their six siblings were
811
+ // listed — so on a database carrying FORCE from the older RLS model,
812
+ // magic-link sign-in kept failing 42501 after the upgrade that was
813
+ // supposed to fix exactly that, and only for the one auth method.
720
814
  const authTablePairs: [string, string][] = [
721
815
  [usersSchema, resolvedTable],
722
816
  [authSchema, "user_identities"],
723
817
  [authSchema, "refresh_tokens"],
724
818
  [authSchema, "password_reset_tokens"],
819
+ [authSchema, "magic_link_tokens"],
725
820
  [authSchema, "app_config"],
726
821
  [authSchema, "mfa_factors"],
727
822
  [authSchema, "mfa_challenges"],
728
- [authSchema, "recovery_codes"]
823
+ [authSchema, "recovery_codes"],
824
+ [authSchema, "schema_meta"]
729
825
  ];
730
826
  for (const [schemaName, tableName] of authTablePairs) {
731
827
  const forced = await db.execute(sql`
@@ -756,11 +852,36 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
756
852
  );
757
853
  }
758
854
 
759
- // Stamped last, so a boot that died partway through the migrations above
760
- // leaves the older stamp in place and the next boot runs them again.
855
+ // Stamped last of the MIGRATIONS, so a boot that died partway through
856
+ // the ones above leaves the older stamp in place and the next boot runs
857
+ // them again.
761
858
  await stampAuthSchemaVersion(db, authSchema);
762
859
 
763
- logger.info("✅ Auth tables ready");
860
+ // ── Keep the end-user role out of auth's tables ─────────────────────
861
+ // These carry session token hashes, TOTP secrets and recovery codes, and
862
+ // none of them has RLS — they are not collections, so nothing ever
863
+ // compiled a policy for them. Meanwhile the role provisioning grants
864
+ // `rebase_user` DML on every table in this schema, and its
865
+ // ALTER DEFAULT PRIVILEGES reaches the ones created right here, after it
866
+ // ran. So the grant has to come back off; see `revokeInternalTableSql`
867
+ // for why a revoke rather than an empty RLS policy set.
868
+ //
869
+ // After the stamp deliberately: `schema_meta` is created BY the stamp,
870
+ // so revoking first would leave the one table holding this database's
871
+ // schema version writable by every signed-in user until the next boot.
872
+ // Nothing below re-runs the migrations, so the stamp's guarantee holds.
873
+ await revokeInternalTableAccess(
874
+ async (text) => { await db.execute(sql.raw(text)); },
875
+ authSchema,
876
+ {
877
+ onError: (table, err) => logger.warn(
878
+ `🔐 Could not revoke authenticated-role access to "${authSchema}"."${table}": ` +
879
+ (err instanceof Error ? err.message : String(err))
880
+ )
881
+ }
882
+ );
883
+
884
+ logger.debug("✅ Auth tables ready");
764
885
  } catch (error) {
765
886
  // The one failure that must not be survived. Continuing here is what
766
887
  // produced a server that answered /health with 200 while every login
@@ -25,6 +25,7 @@ import {
25
25
  RoleData as Role
26
26
  } from "@rebasepro/server";
27
27
  import { toSnakeCase, camelCase } from "@rebasepro/utils";
28
+ import { escapeLikePattern } from "../utils/drizzle-conditions";
28
29
 
29
30
  export type { Role };
30
31
 
@@ -368,7 +369,11 @@ export class UserService implements UserRepository {
368
369
  conditions.push(sql`${roleId} = ANY(${sql.raw(usersTableName)}.roles)`);
369
370
  }
370
371
  if (search) {
371
- const pattern = `%${search}%`;
372
+ // `search` is a substring search over the admin user list, not a
373
+ // pattern the caller writes: the same reasoning as the collection
374
+ // search path, so it shares that path's helper rather than growing
375
+ // a second copy that can drift. See `escapeLikePattern`.
376
+ const pattern = `%${escapeLikePattern(search)}%`;
372
377
  conditions.push(sql`(${sql.raw(usersTableName)}.${sql.raw(emailColumn)} ILIKE ${pattern} OR ${sql.raw(usersTableName)}.${sql.raw(displayNameColumn)} ILIKE ${pattern})`);
373
378
  }
374
379
 
@@ -591,7 +596,7 @@ export class RefreshTokenService {
591
596
  userAgent: this.refreshTokensTable.userAgent,
592
597
  ipAddress: this.refreshTokensTable.ipAddress
593
598
  } as unknown as Record<string, never>;
594
- for (const optional of ["sessionId", "rotatedAt", "revoked", "sessionStartedAt"]) {
599
+ for (const optional of ["sessionId", "rotatedAt", "revoked", "sessionStartedAt", "aal"]) {
595
600
  if (this.has(optional)) selection[optional] = this.col(optional);
596
601
  }
597
602
  return selection;
@@ -624,6 +629,11 @@ export class RefreshTokenService {
624
629
  };
625
630
  if (session && this.has("sessionId")) values.sessionId = session.id;
626
631
  if (session && this.has("sessionStartedAt")) values.sessionStartedAt = session.startedAt;
632
+ // Written on every token of the session, including the ones rotation
633
+ // mints, because refresh reads the level off whichever row was
634
+ // presented. A table without the column degrades to `aal1` on read,
635
+ // which is the restrictive answer rather than a bypass.
636
+ if (session?.aal && this.has("aal")) values.aal = session.aal;
627
637
 
628
638
  await this.db.insert(this.refreshTokensTable).values(values);
629
639
  }
@@ -1262,6 +1272,10 @@ collectionPermissions: null }
1262
1272
  return this.getMfaService().verifyMfaFactor(factorId);
1263
1273
  }
1264
1274
 
1275
+ async updateMfaFactorSecret(factorId: string, secretEncrypted: string): Promise<void> {
1276
+ return this.getMfaService().updateMfaFactorSecret(factorId, secretEncrypted);
1277
+ }
1278
+
1265
1279
  async deleteMfaFactor(factorId: string, uid: string): Promise<void> {
1266
1280
  return this.getMfaService().deleteMfaFactor(factorId, uid);
1267
1281
  }
@@ -1297,6 +1311,14 @@ collectionPermissions: null }
1297
1311
  async hasVerifiedMfaFactors(uid: string): Promise<boolean> {
1298
1312
  return this.getMfaService().hasVerifiedMfaFactors(uid);
1299
1313
  }
1314
+
1315
+ async claimMfaFactorCounter(factorId: string, counter: number): Promise<boolean> {
1316
+ return this.getMfaService().claimMfaFactorCounter(factorId, counter);
1317
+ }
1318
+
1319
+ async recordMfaChallengeAttempt(challengeId: string): Promise<number> {
1320
+ return this.getMfaService().recordMfaChallengeAttempt(challengeId);
1321
+ }
1300
1322
  }
1301
1323
 
1302
1324
  // =============================================================================
@@ -1362,7 +1384,7 @@ export class MfaService implements MfaRepository {
1362
1384
  async getMfaFactorById(factorId: string): Promise<(MfaFactor & { secretEncrypted: string }) | null> {
1363
1385
  const tableName = this.qualify("mfa_factors");
1364
1386
  const result = await this.db.execute(sql`
1365
- SELECT id, uid, factor_type, secret_encrypted, friendly_name, verified, created_at, updated_at
1387
+ SELECT id, uid, factor_type, secret_encrypted, friendly_name, verified, last_used_counter, created_at, updated_at
1366
1388
  FROM ${sql.raw(tableName)}
1367
1389
  WHERE id = ${factorId}
1368
1390
  `);
@@ -1377,11 +1399,36 @@ export class MfaService implements MfaRepository {
1377
1399
  secretEncrypted: row.secret_encrypted as string,
1378
1400
  friendlyName: (row.friendly_name as string | null) ?? undefined,
1379
1401
  verified: row.verified as boolean,
1402
+ // BIGINT comes back as a string from node-postgres.
1403
+ lastUsedCounter: row.last_used_counter === null || row.last_used_counter === undefined
1404
+ ? null
1405
+ : Number(row.last_used_counter),
1380
1406
  createdAt: new Date(row.created_at as string),
1381
1407
  updatedAt: new Date(row.updated_at as string)
1382
1408
  };
1383
1409
  }
1384
1410
 
1411
+ /**
1412
+ * Spend a TOTP time step, once and only once.
1413
+ *
1414
+ * One statement: the `WHERE` is the check, the `UPDATE` is the act, and
1415
+ * `RETURNING` reports which of two concurrent requests carrying the same
1416
+ * six digits won. Reading the counter and then writing it would let both
1417
+ * pass — the exact replay this closes.
1418
+ */
1419
+ async claimMfaFactorCounter(factorId: string, counter: number): Promise<boolean> {
1420
+ const tableName = this.qualify("mfa_factors");
1421
+ const result = await this.db.execute(sql`
1422
+ UPDATE ${sql.raw(tableName)}
1423
+ SET last_used_counter = ${counter}, updated_at = NOW()
1424
+ WHERE id = ${factorId}
1425
+ AND (last_used_counter IS NULL OR last_used_counter < ${counter})
1426
+ RETURNING id
1427
+ `);
1428
+
1429
+ return result.rows.length > 0;
1430
+ }
1431
+
1385
1432
  async verifyMfaFactor(factorId: string): Promise<void> {
1386
1433
  const tableName = this.qualify("mfa_factors");
1387
1434
  await this.db.execute(sql`
@@ -1391,6 +1438,15 @@ export class MfaService implements MfaRepository {
1391
1438
  `);
1392
1439
  }
1393
1440
 
1441
+ async updateMfaFactorSecret(factorId: string, secretEncrypted: string): Promise<void> {
1442
+ const tableName = this.qualify("mfa_factors");
1443
+ await this.db.execute(sql`
1444
+ UPDATE ${sql.raw(tableName)}
1445
+ SET secret_encrypted = ${secretEncrypted}, updated_at = NOW()
1446
+ WHERE id = ${factorId}
1447
+ `);
1448
+ }
1449
+
1394
1450
  async deleteMfaFactor(factorId: string, uid: string): Promise<void> {
1395
1451
  const tableName = this.qualify("mfa_factors");
1396
1452
  await this.db.execute(sql`
@@ -1422,7 +1478,7 @@ export class MfaService implements MfaRepository {
1422
1478
  async getMfaChallengeById(challengeId: string): Promise<MfaChallengeInfo | null> {
1423
1479
  const tableName = this.qualify("mfa_challenges");
1424
1480
  const result = await this.db.execute(sql`
1425
- SELECT id, factor_id, created_at, verified_at, ip_address, expires_at
1481
+ SELECT id, factor_id, created_at, verified_at, ip_address, attempts, expires_at
1426
1482
  FROM ${sql.raw(tableName)}
1427
1483
  WHERE id = ${challengeId} AND expires_at > NOW() AND verified_at IS NULL
1428
1484
  `);
@@ -1435,10 +1491,31 @@ export class MfaService implements MfaRepository {
1435
1491
  factorId: row.factor_id as string,
1436
1492
  createdAt: new Date(row.created_at as string),
1437
1493
  verifiedAt: row.verified_at ? new Date(row.verified_at as string) : undefined,
1438
- ipAddress: (row.ip_address as string | null) ?? undefined
1494
+ ipAddress: (row.ip_address as string | null) ?? undefined,
1495
+ attempts: Number(row.attempts ?? 0)
1439
1496
  };
1440
1497
  }
1441
1498
 
1499
+ /**
1500
+ * Count one failed guess against a challenge and report the new total.
1501
+ *
1502
+ * Incremented in the database rather than in the route so that guesses
1503
+ * arriving in parallel — the shape any real brute-force takes — cannot
1504
+ * share a single increment.
1505
+ */
1506
+ async recordMfaChallengeAttempt(challengeId: string): Promise<number> {
1507
+ const tableName = this.qualify("mfa_challenges");
1508
+ const result = await this.db.execute(sql`
1509
+ UPDATE ${sql.raw(tableName)}
1510
+ SET attempts = attempts + 1
1511
+ WHERE id = ${challengeId}
1512
+ RETURNING attempts
1513
+ `);
1514
+
1515
+ if (result.rows.length === 0) return 0;
1516
+ return Number((result.rows[0] as { attempts: number | string }).attempts);
1517
+ }
1518
+
1442
1519
  async verifyMfaChallenge(challengeId: string): Promise<void> {
1443
1520
  const tableName = this.qualify("mfa_challenges");
1444
1521
  await this.db.execute(sql`