@rebasepro/server-postgres 0.17.3 → 0.18.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 (149) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/backup-argv.d.ts +19 -0
  4. package/dist/branch-argv.d.ts +28 -0
  5. package/dist/branch-prune.d.ts +77 -0
  6. package/dist/cli-collections-path.d.ts +31 -0
  7. package/dist/cli-errors.d.ts +123 -0
  8. package/dist/cli-flags.d.ts +101 -0
  9. package/dist/cli-helpers.d.ts +94 -10
  10. package/dist/collection-index-C7LopMyX.js +247 -0
  11. package/dist/collection-index-C7LopMyX.js.map +1 -0
  12. package/dist/databasePoolManager.d.ts +27 -0
  13. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
  14. package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
  15. package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
  16. package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
  17. package/dist/index.d.ts +12 -0
  18. package/dist/index.es.js +19307 -3059
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
  21. package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
  22. package/dist/schema/column-type-drift.d.ts +79 -0
  23. package/dist/schema/doctor.d.ts +42 -0
  24. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  25. package/dist/schema/generate-next-step.d.ts +30 -0
  26. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  27. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  28. package/dist/schema/introspect-db-queries.d.ts +34 -0
  29. package/dist/security/rls-enforcement.d.ts +63 -2
  30. package/dist/services/BranchService.d.ts +86 -1
  31. package/dist/services/collection-helpers.d.ts +1 -1
  32. package/dist/services/realtimeService.d.ts +30 -2
  33. package/dist/services/row-pipeline.d.ts +12 -1
  34. package/dist/utils/pg-error-utils.d.ts +3 -1
  35. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  36. package/package.json +32 -27
  37. package/dist/backup-service-DCk7KhhL.js +0 -9044
  38. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  39. package/dist/collection-index-BRUg10H5.js +0 -5690
  40. package/dist/collection-index-BRUg10H5.js.map +0 -1
  41. package/dist/connection-GOKU3Hu5.js +0 -277
  42. package/dist/connection-GOKU3Hu5.js.map +0 -1
  43. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  44. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  45. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  46. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  47. package/dist/src-DiB5RP2Z.js +0 -541
  48. package/dist/src-DiB5RP2Z.js.map +0 -1
  49. package/dist/websocket-BZ4H5wUz.js +0 -8198
  50. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  51. package/src/PostgresAdapter.ts +0 -91
  52. package/src/PostgresBackendDriver.ts +0 -1857
  53. package/src/PostgresBootstrapper.ts +0 -1157
  54. package/src/auth/ensure-tables.ts +0 -896
  55. package/src/auth/schema-version.ts +0 -260
  56. package/src/auth/services.ts +0 -1610
  57. package/src/backup/backup-cli.ts +0 -520
  58. package/src/backup/backup-cron.ts +0 -212
  59. package/src/backup/backup-logic.ts +0 -93
  60. package/src/backup/backup-service.ts +0 -485
  61. package/src/backup/index.ts +0 -12
  62. package/src/backup/pg-tools.ts +0 -403
  63. package/src/backup/retention.ts +0 -86
  64. package/src/cli-errors.ts +0 -265
  65. package/src/cli-helpers.ts +0 -569
  66. package/src/cli-output.ts +0 -43
  67. package/src/cli.ts +0 -1339
  68. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  69. package/src/collections/buildRegistry.ts +0 -68
  70. package/src/collections/validate-relations.ts +0 -402
  71. package/src/connection.ts +0 -333
  72. package/src/data-transformer.ts +0 -845
  73. package/src/databasePoolManager.ts +0 -95
  74. package/src/history/HistoryService.ts +0 -254
  75. package/src/history/ensure-history-table.ts +0 -53
  76. package/src/index.ts +0 -19
  77. package/src/interfaces.ts +0 -60
  78. package/src/module-dir.ts +0 -7
  79. package/src/schema/atlas-argv.ts +0 -94
  80. package/src/schema/auth-schema.ts +0 -298
  81. package/src/schema/auth-users-columns.ts +0 -131
  82. package/src/schema/carved-out-migration.ts +0 -404
  83. package/src/schema/classify-change.ts +0 -436
  84. package/src/schema/collection-index.ts +0 -427
  85. package/src/schema/collections-schema-version.ts +0 -103
  86. package/src/schema/destructive-sql.ts +0 -94
  87. package/src/schema/doctor-cli.ts +0 -66
  88. package/src/schema/doctor-policy-checks.ts +0 -105
  89. package/src/schema/doctor.ts +0 -837
  90. package/src/schema/drizzle-ddl.ts +0 -33
  91. package/src/schema/dynamic-tables.test.ts +0 -302
  92. package/src/schema/dynamic-tables.ts +0 -293
  93. package/src/schema/ensure-collection-policies.ts +0 -198
  94. package/src/schema/ensure-collection-tables.test.ts +0 -518
  95. package/src/schema/ensure-collection-tables.ts +0 -1267
  96. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  97. package/src/schema/generate-drizzle-schema.ts +0 -143
  98. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  99. package/src/schema/generate-postgres-ddl.ts +0 -180
  100. package/src/schema/generate-schema-commit.ts +0 -267
  101. package/src/schema/generated-schema-staleness.ts +0 -284
  102. package/src/schema/introspect-db-constraints.ts +0 -385
  103. package/src/schema/introspect-db-inference.ts +0 -255
  104. package/src/schema/introspect-db-logic.ts +0 -1387
  105. package/src/schema/introspect-db-naming.ts +0 -15
  106. package/src/schema/introspect-db-project.ts +0 -78
  107. package/src/schema/introspect-db-queries.ts +0 -326
  108. package/src/schema/introspect-db-structure.ts +0 -670
  109. package/src/schema/introspect-db-types.ts +0 -56
  110. package/src/schema/introspect-db.ts +0 -257
  111. package/src/schema/introspect-runtime.test.ts +0 -260
  112. package/src/schema/introspect-runtime.ts +0 -327
  113. package/src/schema/non-sql-collections.test.ts +0 -131
  114. package/src/schema/rls-bootstrap-sql.ts +0 -288
  115. package/src/schema/search-column.ts +0 -643
  116. package/src/schema/test-schema.ts +0 -11
  117. package/src/schema/vector-index.ts +0 -491
  118. package/src/security/anonymous-grants.test.ts +0 -73
  119. package/src/security/policy-drift.test.ts +0 -435
  120. package/src/security/policy-drift.ts +0 -473
  121. package/src/security/rls-enforcement.ts +0 -505
  122. package/src/services/BranchService.ts +0 -326
  123. package/src/services/FetchService.ts +0 -2115
  124. package/src/services/PersistService.ts +0 -557
  125. package/src/services/RelationService.ts +0 -978
  126. package/src/services/RelationWriteService.ts +0 -653
  127. package/src/services/cdc/CdcListener.ts +0 -103
  128. package/src/services/cdc/junction-tables.ts +0 -91
  129. package/src/services/cdc/trigger-cdc.ts +0 -173
  130. package/src/services/channel-bus/ChannelBus.ts +0 -44
  131. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  132. package/src/services/channel-bus/index.ts +0 -114
  133. package/src/services/channel-history.ts +0 -424
  134. package/src/services/channel-presence.ts +0 -185
  135. package/src/services/collection-helpers.ts +0 -420
  136. package/src/services/dataService.ts +0 -256
  137. package/src/services/index.ts +0 -14
  138. package/src/services/junction-writes.ts +0 -295
  139. package/src/services/nested-path.ts +0 -145
  140. package/src/services/pg-notify-listener.ts +0 -151
  141. package/src/services/realtimeService.ts +0 -2545
  142. package/src/services/row-pipeline.ts +0 -240
  143. package/src/services/write-denial.ts +0 -55
  144. package/src/types.ts +0 -4
  145. package/src/utils/connection-string.ts +0 -58
  146. package/src/utils/drizzle-conditions.ts +0 -2743
  147. package/src/utils/pg-array-null-patch.ts +0 -42
  148. package/src/utils/pg-error-utils.ts +0 -391
  149. package/src/websocket.ts +0 -855
@@ -1,896 +0,0 @@
1
- import { sql } from "drizzle-orm";
2
- import { NodePgDatabase } from "drizzle-orm/node-postgres";
3
- import { logger } from "@rebasepro/server";
4
- import { revokeInternalTableAccess } from "@rebasepro/common";
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";
8
- import {
9
- AuthSchemaVersionError,
10
- assertAuthSchemaCompatible,
11
- resolveAuthSchema,
12
- stampAuthSchemaVersion
13
- } from "./schema-version";
14
-
15
-
16
- /**
17
- * Auto-create auth tables if they don't exist.
18
- *
19
- * @param db — Drizzle database instance
20
- * @param collection — The collection that represents auth users.
21
- * When omitted, a default `rebase.users` table is created.
22
- */
23
- export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: CollectionConfig): Promise<void> {
24
- logger.debug("🔍 Checking auth tables...");
25
-
26
- // Before anything else, and deliberately outside the catch below: refuse to
27
- // run against a database that a newer framework version has already
28
- // migrated. Everything past this point is best-effort by design, which is
29
- // exactly the wrong posture for an incompatibility that would otherwise
30
- // surface as a fully booted server failing every login.
31
- await assertAuthSchemaCompatible(db, resolveAuthSchema(collection));
32
-
33
- try {
34
- // Resolve dynamic user table name and ID type from the collection
35
- let usersTableName = '"rebase"."users"';
36
- let userIdType = "TEXT";
37
- let usersSchema = "rebase";
38
- let resolvedTable = "users";
39
- if (collection) {
40
- resolvedTable = ("table" in collection && typeof collection.table === "string")
41
- ? collection.table
42
- : collection.slug;
43
- usersSchema = ("schema" in collection && typeof collection.schema === "string")
44
- ? collection.schema
45
- : "public";
46
- usersTableName = usersSchema === "public"
47
- ? `"${resolvedTable}"`
48
- : `"${usersSchema}"."${resolvedTable}"`;
49
-
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.
61
- const idProp = collection.properties?.id;
62
- if (idProp) {
63
- const isId = ("isId" in idProp) ? (idProp as unknown as Record<string, unknown>).isId : undefined;
64
- if (isId === "uuid") {
65
- userIdType = "UUID";
66
- } else if (isId === "increment") {
67
- userIdType = "INTEGER";
68
- }
69
- // Otherwise keep TEXT as default
70
- }
71
- }
72
-
73
- // Introspect the database to find the actual type of usersTableName's ID column if the table exists
74
- try {
75
- const result = await db.execute(sql`
76
- SELECT data_type
77
- FROM information_schema.columns
78
- WHERE table_schema = ${usersSchema}
79
- AND table_name = ${resolvedTable}
80
- AND column_name = 'id'
81
- `);
82
- if (result && result.rows && result.rows.length > 0) {
83
- const dbType = String((result.rows[0] as { data_type: string }).data_type).toUpperCase();
84
- if (dbType === "UUID") {
85
- userIdType = "UUID";
86
- } else if (dbType === "INTEGER" || dbType === "SMALLINT" || dbType === "BIGINT") {
87
- userIdType = "INTEGER";
88
- } else {
89
- userIdType = "TEXT";
90
- }
91
- logger.debug(`✨ Detected ${usersTableName}.id type from database: ${dbType}. Using user_id type: ${userIdType}`);
92
- }
93
- } catch (err) {
94
- // Ignore introspection errors, fallback to derived/default type
95
- logger.warn(`⚠️ Failed to introspect ${usersTableName}.id type from database, falling back to config type: ${userIdType}`, { error: err });
96
- }
97
-
98
-
99
- // ── Create schemas (idempotent) ──────────────────────────────────
100
- if (usersSchema !== "public") {
101
- await db.execute(sql`CREATE SCHEMA IF NOT EXISTS ${sql.raw(usersSchema)}`);
102
- }
103
- await db.execute(sql`CREATE SCHEMA IF NOT EXISTS rebase`);
104
-
105
- const authSchema = usersSchema === "public" ? "rebase" : usersSchema;
106
- const userIdentitiesTable = `"${authSchema}"."user_identities"`;
107
- const refreshTokensTableName = `"${authSchema}"."refresh_tokens"`;
108
- const passwordResetTokensTableName = `"${authSchema}"."password_reset_tokens"`;
109
- const appConfigTableName = `"${authSchema}"."app_config"`;
110
-
111
- // ── Create users table (idempotent) ─────────────────────────────
112
- // The users table MUST be created before any dependent auth tables
113
- // (user_identities, refresh_tokens, etc.) because they all hold
114
- // foreign keys referencing users(id). When a developer runs
115
- // `pnpm dev` for the first time without `db:migrate`, this ensures
116
- // the server can self-bootstrap.
117
- const idDefault = userIdType === "UUID"
118
- ? "DEFAULT gen_random_uuid()"
119
- : userIdType === "INTEGER"
120
- ? "GENERATED ALWAYS AS IDENTITY"
121
- : "DEFAULT gen_random_uuid()::text";
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 ");
159
- await db.execute(sql`
160
- CREATE TABLE IF NOT EXISTS ${sql.raw(usersTableName)} (
161
- id ${sql.raw(userIdType)} PRIMARY KEY ${sql.raw(idDefault)},
162
- ${sql.raw(usersColumnDdl)}
163
- )
164
- `);
165
-
166
- // ── Migration: auth FK column user_id → uid, phase 1 (expand) ───────
167
- // Must run BEFORE the dependent tables below: CREATE TABLE IF NOT
168
- // EXISTS never revisits an existing table, so a database provisioned
169
- // before this migration still lacks `uid` — and the
170
- // CREATE INDEX ... (uid) statements that follow would fail on it.
171
- //
172
- // Deliberately NOT a plain RENAME. Both Cloud Run and Kubernetes roll
173
- // deploys, so old and new pods serve the same database at the same time,
174
- // and a rollback puts old code back in front of a migrated database. A
175
- // rename breaks every auth query on whichever side is out of step.
176
- // Instead: add `uid`, backfill it, drop the NOT NULL on `user_id`, and
177
- // keep the two in sync with a trigger, so a backend of either era can
178
- // read and write. `scripts/drop-legacy-auth-user-id.sql` removes the
179
- // column once no old backend remains (phase 2, contract).
180
- //
181
- // Idempotent throughout: every step is guarded on catalogue state.
182
- const legacyFkTables = [
183
- "user_identities",
184
- "refresh_tokens",
185
- "password_reset_tokens",
186
- "magic_link_tokens",
187
- "mfa_factors",
188
- "recovery_codes"
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 : []) {
227
- const qualified = `"${authSchema}"."${authTable}"`;
228
- await db.execute(sql`
229
- DO $$
230
- DECLARE
231
- has_legacy boolean;
232
- has_uid boolean;
233
- BEGIN
234
- SELECT
235
- bool_or(column_name = 'user_id'),
236
- bool_or(column_name = 'uid')
237
- INTO has_legacy, has_uid
238
- FROM information_schema.columns
239
- WHERE table_schema = ${sql.raw(`'${authSchema}'`)}
240
- AND table_name = ${sql.raw(`'${authTable}'`)};
241
-
242
- -- Table absent, or already uid-only (a fresh install, or
243
- -- phase 2 already run): nothing to do.
244
- IF has_legacy IS NOT TRUE THEN
245
- RETURN;
246
- END IF;
247
-
248
- IF has_uid IS NOT TRUE THEN
249
- EXECUTE ${sql.raw(`'ALTER TABLE ${qualified} ADD COLUMN uid ${userIdType} REFERENCES ${usersTableName}(id) ON DELETE CASCADE'`)};
250
- EXECUTE ${sql.raw(`'UPDATE ${qualified} SET uid = user_id WHERE uid IS NULL'`)};
251
- EXECUTE ${sql.raw(`'CREATE INDEX IF NOT EXISTS idx_${authTable}_uid ON ${qualified}(uid)'`)};
252
- END IF;
253
-
254
- -- New code inserts uid and never user_id, so the legacy
255
- -- column can no longer be NOT NULL. The trigger below
256
- -- backfills it, but the constraint is checked first.
257
- EXECUTE ${sql.raw(`'ALTER TABLE ${qualified} ALTER COLUMN user_id DROP NOT NULL'`)};
258
-
259
- EXECUTE ${sql.raw(`'DROP TRIGGER IF EXISTS sync_uid_user_id ON ${qualified}'`)};
260
- 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()'`)};
261
- END $$
262
- `);
263
- }
264
-
265
- // ── Create dependent auth tables (idempotent) ───────────────────
266
-
267
- // Create user_identities table
268
- await db.execute(sql`
269
- CREATE TABLE IF NOT EXISTS ${sql.raw(userIdentitiesTable)} (
270
- id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
271
- uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
272
- provider TEXT NOT NULL,
273
- provider_id TEXT NOT NULL,
274
- profile_data JSONB,
275
- created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
276
- updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
277
- UNIQUE(provider, provider_id)
278
- )
279
- `);
280
-
281
- // Create indexes on user_identities
282
- await db.execute(sql`
283
- CREATE INDEX IF NOT EXISTS idx_user_identities_user
284
- ON ${sql.raw(userIdentitiesTable)}(uid)
285
- `);
286
-
287
-
288
- // Create refresh tokens table. One row per TOKEN, grouped into a
289
- // sign-in by session_id — deliberately without a uniqueness rule on
290
- // (uid, user_agent, ip_address); see the schema module for why that
291
- // constraint had to go.
292
- await db.execute(sql`
293
- CREATE TABLE IF NOT EXISTS ${sql.raw(refreshTokensTableName)} (
294
- id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
295
- uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
296
- session_id TEXT NOT NULL DEFAULT gen_random_uuid()::text,
297
- token_hash TEXT NOT NULL UNIQUE,
298
- expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
299
- revoked BOOLEAN DEFAULT FALSE NOT NULL,
300
- rotated_at TIMESTAMP WITH TIME ZONE,
301
- session_started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
302
- user_agent TEXT,
303
- ip_address TEXT,
304
- created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
305
- )
306
- `);
307
-
308
- // Create index on token_hash for faster lookups
309
- await db.execute(sql`
310
- CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash
311
- ON ${sql.raw(refreshTokensTableName)}(token_hash)
312
- `);
313
-
314
- // Create index on uid for cleanup operations
315
- await db.execute(sql`
316
- CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user
317
- ON ${sql.raw(refreshTokensTableName)}(uid)
318
- `);
319
-
320
- // Create password reset tokens table
321
- await db.execute(sql`
322
- CREATE TABLE IF NOT EXISTS ${sql.raw(passwordResetTokensTableName)} (
323
- id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
324
- uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
325
- token_hash TEXT NOT NULL UNIQUE,
326
- expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
327
- used_at TIMESTAMP WITH TIME ZONE,
328
- created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
329
- )
330
- `);
331
-
332
- // Create index on token_hash for password reset lookups
333
- await db.execute(sql`
334
- CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_hash
335
- ON ${sql.raw(passwordResetTokensTableName)}(token_hash)
336
- `);
337
-
338
- // Create index on uid for password reset cleanup
339
- await db.execute(sql`
340
- CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user
341
- ON ${sql.raw(passwordResetTokensTableName)}(uid)
342
- `);
343
-
344
- // Create magic link tokens table
345
- const magicLinkTokensTableName = `"${authSchema}"."magic_link_tokens"`;
346
- await db.execute(sql`
347
- CREATE TABLE IF NOT EXISTS ${sql.raw(magicLinkTokensTableName)} (
348
- id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
349
- uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
350
- token_hash TEXT NOT NULL UNIQUE,
351
- expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
352
- used_at TIMESTAMP WITH TIME ZONE,
353
- created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
354
- )
355
- `);
356
-
357
- // Create index on token_hash for magic link lookups
358
- await db.execute(sql`
359
- CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_hash
360
- ON ${sql.raw(magicLinkTokensTableName)}(token_hash)
361
- `);
362
-
363
- // Create index on uid for magic link cleanup
364
- await db.execute(sql`
365
- CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_user
366
- ON ${sql.raw(magicLinkTokensTableName)}(uid)
367
- `);
368
-
369
- // Create app config table
370
- await db.execute(sql`
371
- CREATE TABLE IF NOT EXISTS ${sql.raw(appConfigTableName)} (
372
- key TEXT PRIMARY KEY,
373
- value JSONB NOT NULL,
374
- updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
375
- )
376
- `);
377
-
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.
388
- await db.transaction(async (tx) => {
389
- await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init'))`);
390
- for (const statement of RLS_BOOTSTRAP_STATEMENTS) {
391
- await tx.execute(sql.raw(statement));
392
- }
393
- });
394
-
395
- // Seed default roles if none exist
396
- // (no-op: roles are now stored inline on the users table)
397
-
398
- // ── Migration: reconcile the full users column set (safe for existing tables) ──
399
- // CREATE TABLE IF NOT EXISTS never revisits an existing table, so a
400
- // database provisioned by an older framework era is missing every
401
- // column added since. Each column the auth services read or write must
402
- // be back-filled here, or upgraded deployments break on the first
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;
494
- await db.execute(sql`
495
- ALTER TABLE ${sql.raw(usersTableName)}
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
601
- `);
602
- }
603
-
604
- // ── Migration: refresh_tokens become session-scoped, rotation-safe ──
605
- // Two shapes are reconciled here, on EVERY table named refresh_tokens
606
- // in whatever schema it lives (a database provisioned by an older era
607
- // can carry the table in a different schema than the one this run
608
- // derives, and auth would then read a table nobody migrated):
609
- //
610
- // 1. The new columns. A token is now a member of a session
611
- // (`session_id`) and is retained after rotation (`revoked`,
612
- // `rotated_at`) so a replayed token can be recognised instead of
613
- // looking like a forgery. `session_started_at` is carried across
614
- // rotations so `users.tokens_valid_after` cannot be outrun.
615
- // 2. The removal of `unique_device_session`. It made (uid,
616
- // user_agent, ip_address) the identity of a session, which evicted
617
- // a second browser profile behind one NAT and churned rows as
618
- // phones changed networks. UA and IP are metadata now.
619
- //
620
- // Every existing row is adopted rather than dropped: it keeps its
621
- // token_hash, gets a session of its own, and stays unrevoked — so the
622
- // sessions live in browsers right now survive the upgrade rather than
623
- // everyone being signed out by the fix for being signed out.
624
- try {
625
- const rtTables = await db.execute(sql`
626
- SELECT table_schema, table_name
627
- FROM information_schema.tables
628
- WHERE table_name = 'refresh_tokens'
629
- `);
630
- const found = (rtTables.rows as { table_schema: string; table_name: string }[]);
631
- logger.debug(`🔍 refresh_tokens reconcile: found ${found.length} table(s): ${found.map(r => `"${r.table_schema}"."${r.table_name}"`).join(", ") || "(none)"}`);
632
- for (const { table_schema } of found) {
633
- const qualified = `"${table_schema}"."refresh_tokens"`;
634
- try {
635
- // Added nullable, then back-filled, then constrained: adding
636
- // `session_id NOT NULL DEFAULT gen_random_uuid()` in one step
637
- // would stamp every existing row with the SAME uuid on some
638
- // Postgres versions, silently merging every live session into
639
- // one that a single logout would then wipe.
640
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_id TEXT`);
641
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS revoked BOOLEAN DEFAULT FALSE NOT NULL`);
642
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS rotated_at TIMESTAMP WITH TIME ZONE`);
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`);
650
-
651
- // One session per pre-existing row: under the old model a row
652
- // WAS a device session, and there is no record of which rows
653
- // descended from the same sign-in.
654
- await db.execute(sql`
655
- UPDATE ${sql.raw(qualified)}
656
- SET session_id = gen_random_uuid()::text
657
- WHERE session_id IS NULL
658
- `);
659
- await db.execute(sql`
660
- UPDATE ${sql.raw(qualified)}
661
- SET session_started_at = COALESCE(created_at, NOW())
662
- WHERE session_started_at IS NULL
663
- `);
664
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_id SET DEFAULT gen_random_uuid()::text`);
665
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_started_at SET DEFAULT NOW()`);
666
- // SET NOT NULL only once the back-fill above has definitely
667
- // run; on a table that somehow still holds a NULL this throws
668
- // and is caught below rather than failing the boot.
669
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_id SET NOT NULL`);
670
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_started_at SET NOT NULL`);
671
-
672
- await db.execute(sql`
673
- CREATE INDEX IF NOT EXISTS idx_refresh_tokens_session
674
- ON ${sql.raw(qualified)}(session_id)
675
- `);
676
-
677
- // The device-session constraint is now actively harmful: two
678
- // live tokens of one session (a rotation in flight) share a
679
- // uid, and usually a user agent and IP too.
680
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} DROP CONSTRAINT IF EXISTS unique_device_session`);
681
- logger.debug(`✅ refresh_tokens reconciled for session-scoped rotation: ${qualified}`);
682
- } catch (perTableError: unknown) {
683
- logger.warn(`⚠️ refresh_tokens reconcile failed for ${qualified}: ${perTableError instanceof Error ? perTableError.message : String(perTableError)}`);
684
- }
685
- }
686
- } catch (migrationError: unknown) {
687
- logger.warn(`⚠️ refresh_tokens session migration skipped: ${migrationError instanceof Error ? migrationError.message : String(migrationError)}`);
688
- }
689
-
690
- // ── Migration: Copy roles from legacy junction table to inline column ──
691
- // If the old rebase.user_roles and rebase.roles tables exist, migrate
692
- // the data into the new TEXT[] column then drop the legacy tables.
693
- try {
694
- const legacyCheck = await db.execute(sql`
695
- SELECT EXISTS (
696
- SELECT 1 FROM information_schema.tables
697
- WHERE table_schema = 'rebase' AND table_name = 'user_roles'
698
- ) AS has_user_roles
699
- `);
700
- const hasLegacyTables = (legacyCheck.rows[0] as { has_user_roles: boolean }).has_user_roles;
701
-
702
- if (hasLegacyTables) {
703
- logger.info("🔄 Migrating roles from legacy user_roles table...");
704
- // Update users' roles column from the junction table
705
- await db.execute(sql`
706
- UPDATE ${sql.raw(usersTableName)} u
707
- SET roles = COALESCE((
708
- SELECT array_agg(ur.role_id)
709
- FROM "rebase"."user_roles" ur
710
- WHERE ur.user_id = u.id
711
- ), '{}')
712
- WHERE u.roles = '{}' OR u.roles IS NULL
713
- `);
714
-
715
- // Drop legacy tables (junction first due to FK)
716
- await db.execute(sql`DROP TABLE IF EXISTS "rebase"."user_roles" CASCADE`);
717
- await db.execute(sql`DROP TABLE IF EXISTS "rebase"."roles" CASCADE`);
718
- logger.info("✅ Legacy roles tables migrated and dropped");
719
- }
720
- } catch (migrationError: unknown) {
721
- // Non-fatal: log and continue — the column exists and will work
722
- logger.warn(`⚠️ Legacy roles migration skipped: ${migrationError instanceof Error ? migrationError.message : String(migrationError)}`);
723
- }
724
-
725
- // ── MFA tables ──────────────────────────────────────────────────────
726
- const mfaFactorsTableName = `"${authSchema}"."mfa_factors"`;
727
- const mfaChallengesTableName = `"${authSchema}"."mfa_challenges"`;
728
- const recoveryCodesTableName = `"${authSchema}"."recovery_codes"`;
729
-
730
- // Create mfa_factors table
731
- await db.execute(sql`
732
- CREATE TABLE IF NOT EXISTS ${sql.raw(mfaFactorsTableName)} (
733
- id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
734
- uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
735
- factor_type TEXT NOT NULL DEFAULT 'totp',
736
- secret_encrypted TEXT NOT NULL,
737
- friendly_name TEXT,
738
- verified BOOLEAN DEFAULT FALSE,
739
- last_used_counter BIGINT,
740
- created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
741
- updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
742
- )
743
- `);
744
-
745
- // Create indexes on mfa_factors
746
- await db.execute(sql`
747
- CREATE INDEX IF NOT EXISTS idx_mfa_factors_user
748
- ON ${sql.raw(mfaFactorsTableName)}(uid)
749
- `);
750
-
751
- // Create mfa_challenges table
752
- await db.execute(sql`
753
- CREATE TABLE IF NOT EXISTS ${sql.raw(mfaChallengesTableName)} (
754
- id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
755
- factor_id TEXT NOT NULL REFERENCES ${sql.raw(mfaFactorsTableName)}(id) ON DELETE CASCADE,
756
- created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
757
- verified_at TIMESTAMP WITH TIME ZONE,
758
- ip_address TEXT,
759
- attempts INTEGER NOT NULL DEFAULT 0,
760
- expires_at TIMESTAMP WITH TIME ZONE NOT NULL
761
- )
762
- `);
763
-
764
- // Create indexes on mfa_challenges
765
- await db.execute(sql`
766
- CREATE INDEX IF NOT EXISTS idx_mfa_challenges_factor
767
- ON ${sql.raw(mfaChallengesTableName)}(factor_id)
768
- `);
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
-
784
- // Create recovery_codes table
785
- await db.execute(sql`
786
- CREATE TABLE IF NOT EXISTS ${sql.raw(recoveryCodesTableName)} (
787
- id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
788
- uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
789
- code_hash TEXT NOT NULL,
790
- used_at TIMESTAMP WITH TIME ZONE,
791
- created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
792
- )
793
- `);
794
-
795
- // Create indexes on recovery_codes
796
- await db.execute(sql`
797
- CREATE INDEX IF NOT EXISTS idx_recovery_codes_user
798
- ON ${sql.raw(recoveryCodesTableName)}(uid)
799
- `);
800
-
801
- // ── Migration: clear stale FORCE ROW LEVEL SECURITY (older RLS model) ──
802
- // The current model never emits FORCE: privileged auth writes run as
803
- // the table owner and rely on the owner bypassing plain ENABLE RLS
804
- // (see generate-postgres-ddl-logic). A table still carrying FORCE from
805
- // an older framework era binds the owner too, so the first user
806
- // registration after an upgrade fails with SQLSTATE 42501. Reconcile
807
- // on boot; only tables actually flagged get the ALTER (and its lock).
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.
814
- const authTablePairs: [string, string][] = [
815
- [usersSchema, resolvedTable],
816
- [authSchema, "user_identities"],
817
- [authSchema, "refresh_tokens"],
818
- [authSchema, "password_reset_tokens"],
819
- [authSchema, "magic_link_tokens"],
820
- [authSchema, "app_config"],
821
- [authSchema, "mfa_factors"],
822
- [authSchema, "mfa_challenges"],
823
- [authSchema, "recovery_codes"],
824
- [authSchema, "schema_meta"]
825
- ];
826
- for (const [schemaName, tableName] of authTablePairs) {
827
- const forced = await db.execute(sql`
828
- SELECT 1
829
- FROM pg_class c
830
- JOIN pg_namespace n ON n.oid = c.relnamespace
831
- WHERE n.nspname = ${schemaName}
832
- AND c.relname = ${tableName}
833
- AND c.relforcerowsecurity
834
- `);
835
- if (forced.rows.length > 0) {
836
- await db.execute(sql`
837
- ALTER TABLE ${sql.raw(`"${schemaName}"."${tableName}"`)}
838
- NO FORCE ROW LEVEL SECURITY
839
- `);
840
- logger.warn(
841
- `🔧 Cleared stale FORCE ROW LEVEL SECURITY on "${schemaName}"."${tableName}" ` +
842
- "(legacy RLS model — it binds the owner connection and breaks privileged auth writes)"
843
- );
844
- }
845
- }
846
- } catch (rlsReconcileError: unknown) {
847
- // Non-fatal: the connection may lack ownership on a pre-provisioned
848
- // table; registration will still fail loudly (42501) if FORCE remains.
849
- logger.warn(
850
- `⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ` +
851
- `${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`
852
- );
853
- }
854
-
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.
858
- await stampAuthSchemaVersion(db, authSchema);
859
-
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");
885
- } catch (error) {
886
- // The one failure that must not be survived. Continuing here is what
887
- // produced a server that answered /health with 200 while every login
888
- // returned 500 — the incompatibility is total, so crashing is the
889
- // kinder outcome: an orchestrator will not route traffic to a pod that
890
- // never came up.
891
- if (error instanceof AuthSchemaVersionError) throw error;
892
- logger.error("❌ Failed to create auth tables", { error });
893
- logger.warn("⚠️ Continuing without creating auth tables.");
894
- }
895
- }
896
-