@rebasepro/server-postgres 0.18.0 → 0.19.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.
- package/dist/BranchService-ByLfM_Tt.js +360 -0
- package/dist/BranchService-ByLfM_Tt.js.map +1 -0
- package/dist/__vite-browser-external-BnuHet1e.js +46 -0
- package/dist/__vite-browser-external-BnuHet1e.js.map +1 -0
- package/dist/arg-Dni7MzLB.js +87 -0
- package/dist/arg-Dni7MzLB.js.map +1 -0
- package/dist/auth-users-columns-D2LBFrMH.js +116 -0
- package/dist/auth-users-columns-D2LBFrMH.js.map +1 -0
- package/dist/backup-cli-oM9WQVlV.js +408 -0
- package/dist/backup-cli-oM9WQVlV.js.map +1 -0
- package/dist/backup-service-Br3tqwsy.js +715 -0
- package/dist/backup-service-Br3tqwsy.js.map +1 -0
- package/dist/cli-errors-DxN4yzdH.js +657 -0
- package/dist/cli-errors-DxN4yzdH.js.map +1 -0
- package/dist/cli-output-CNdMql-L.js +47 -0
- package/dist/cli-output-CNdMql-L.js.map +1 -0
- package/dist/cli.js +2020 -0
- package/dist/cli.js.map +1 -0
- package/dist/{collection-index-C7LopMyX.js → collection-index-BHgT_TKd.js} +32 -3
- package/dist/{collection-index-C7LopMyX.js.map → collection-index-BHgT_TKd.js.map} +1 -1
- package/dist/{collections-schema-version-BMeu3cgv.js → collections-schema-version-T6cGvrDi.js} +1 -2
- package/dist/{collections-schema-version-BMeu3cgv.js.map → collections-schema-version-T6cGvrDi.js.map} +1 -1
- package/dist/databasePoolManager-Bj5FbeAs.js +349 -0
- package/dist/databasePoolManager-Bj5FbeAs.js.map +1 -0
- package/dist/doctor-D7HWmGzs.js +974 -0
- package/dist/doctor-D7HWmGzs.js.map +1 -0
- package/dist/{ensure-collection-policies-CagOzEPS.js → ensure-collection-policies-2c2NV8zX.js} +5 -25
- package/dist/ensure-collection-policies-2c2NV8zX.js.map +1 -0
- package/dist/ensure-collection-tables-D4wJzM4y.js +820 -0
- package/dist/ensure-collection-tables-D4wJzM4y.js.map +1 -0
- package/dist/ensure-tables-CIojK-Rx.js +650 -0
- package/dist/ensure-tables-CIojK-Rx.js.map +1 -0
- package/dist/generate-drizzle-schema-CwEgVyjO.js +123 -0
- package/dist/generate-drizzle-schema-CwEgVyjO.js.map +1 -0
- package/dist/generate-drizzle-schema-logic-so2Z_lyC.js +528 -0
- package/dist/generate-drizzle-schema-logic-so2Z_lyC.js.map +1 -0
- package/dist/generate-postgres-ddl-logic-CtnpIBVv.js +1312 -0
- package/dist/generate-postgres-ddl-logic-CtnpIBVv.js.map +1 -0
- package/dist/generated-schema-staleness-DQdfJl2A.js +157 -0
- package/dist/generated-schema-staleness-DQdfJl2A.js.map +1 -0
- package/dist/index.es.js +849 -10807
- package/dist/index.es.js.map +1 -1
- package/dist/introspect-db-logic-C6LQdTxj.js +1378 -0
- package/dist/introspect-db-logic-C6LQdTxj.js.map +1 -0
- package/dist/policy-drift-kNJ6X2Uj.js +313 -0
- package/dist/policy-drift-kNJ6X2Uj.js.map +1 -0
- package/dist/rls-bootstrap-sql-DLDnUjtx.js +7092 -0
- package/dist/rls-bootstrap-sql-DLDnUjtx.js.map +1 -0
- package/dist/{rls-enforcement-ClJoTboT.js → rls-enforcement-DeYEjYLt.js} +14 -3
- package/dist/{rls-enforcement-ClJoTboT.js.map → rls-enforcement-DeYEjYLt.js.map} +1 -1
- package/dist/schema/bin/generate-drizzle-schema.d.ts +1 -0
- package/dist/schema/bin/generate-postgres-ddl.d.ts +1 -0
- package/dist/schema/doctor-cli.js +129 -0
- package/dist/schema/doctor-cli.js.map +1 -0
- package/dist/schema/generate-drizzle-schema.d.ts +1 -1
- package/dist/schema/generate-drizzle-schema.js +24 -0
- package/dist/schema/generate-drizzle-schema.js.map +1 -0
- package/dist/schema/generate-postgres-ddl.d.ts +1 -1
- package/dist/schema/generate-postgres-ddl.js +138 -0
- package/dist/schema/generate-postgres-ddl.js.map +1 -0
- package/dist/schema/introspect-db.js +866 -0
- package/dist/schema/introspect-db.js.map +1 -0
- package/dist/source-Br7L7GOI.js +410 -0
- package/dist/source-Br7L7GOI.js.map +1 -0
- package/dist/vector-index-DkWxm2de.js +309 -0
- package/dist/vector-index-DkWxm2de.js.map +1 -0
- package/package.json +7 -7
- package/dist/ensure-collection-policies-CagOzEPS.js.map +0 -1
- package/dist/ensure-collection-tables-DbAhrjnG.js +0 -2732
- package/dist/ensure-collection-tables-DbAhrjnG.js.map +0 -1
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
import { createRequire as __createRequire } from "module";
|
|
2
|
+
__createRequire(import.meta.url);
|
|
3
|
+
import { r as __exportAll } from "./__vite-browser-external-BnuHet1e.js";
|
|
4
|
+
import { r as authUsersColumnSql, t as AUTH_USERS_COLUMNS } from "./auth-users-columns-D2LBFrMH.js";
|
|
5
|
+
import { n as RLS_BOOTSTRAP_STATEMENTS } from "./rls-bootstrap-sql-DLDnUjtx.js";
|
|
6
|
+
import { logger } from "@rebasepro/server";
|
|
7
|
+
import { sql } from "drizzle-orm";
|
|
8
|
+
import { revokeInternalTableAccess } from "@rebasepro/common";
|
|
9
|
+
/** Key under which the version is stored in the auth schema's meta table. */
|
|
10
|
+
var VERSION_KEY = "auth_schema_version";
|
|
11
|
+
/**
|
|
12
|
+
* Columns `refresh_tokens` must have for the current runtime's auth write path
|
|
13
|
+
* to work. Checked by the health probe so a database that drifted *below* this
|
|
14
|
+
* runtime is reported as unhealthy rather than discovered one failed login at a
|
|
15
|
+
* time. Kept in step with the migration in `ensureAuthTablesExist`.
|
|
16
|
+
*/
|
|
17
|
+
var REQUIRED_REFRESH_TOKEN_COLUMNS = [
|
|
18
|
+
"session_id",
|
|
19
|
+
"revoked",
|
|
20
|
+
"rotated_at",
|
|
21
|
+
"session_started_at"
|
|
22
|
+
];
|
|
23
|
+
/**
|
|
24
|
+
* A constraint whose *presence* means the database is still at version 1, in a
|
|
25
|
+
* shape this runtime's rotation logic cannot write to: it makes two live tokens
|
|
26
|
+
* of one rotating session collide.
|
|
27
|
+
*/
|
|
28
|
+
var RETIRED_REFRESH_TOKEN_CONSTRAINT = "unique_device_session";
|
|
29
|
+
/**
|
|
30
|
+
* Thrown when the database was migrated by a runtime newer than this one.
|
|
31
|
+
*
|
|
32
|
+
* Distinct class rather than a bare `Error` because `ensureAuthTablesExist`
|
|
33
|
+
* wraps its migrations in a catch that deliberately swallows failures and
|
|
34
|
+
* continues — every other problem there is better survived than crashed on.
|
|
35
|
+
* This one is not, so the catch rethrows on this type specifically.
|
|
36
|
+
*/
|
|
37
|
+
var AuthSchemaVersionError = class extends Error {
|
|
38
|
+
databaseVersion;
|
|
39
|
+
runtimeVersion;
|
|
40
|
+
constructor(databaseVersion, runtimeVersion) {
|
|
41
|
+
super(`Auth schema version mismatch: the database is at version ${databaseVersion}, but this runtime understands version ${runtimeVersion}.\n\nA newer version of the framework has already migrated this database. Running this older runtime against it would boot cleanly and then fail every login and token refresh, because the auth schema it expects no longer exists.
|
|
42
|
+
|
|
43
|
+
Refusing to start. Deploy a framework version at or above the one that migrated this database, or restore the database from a backup taken before the upgrade.`);
|
|
44
|
+
this.name = "AuthSchemaVersionError";
|
|
45
|
+
this.databaseVersion = databaseVersion;
|
|
46
|
+
this.runtimeVersion = runtimeVersion;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* The schema the auth tables live in, derived exactly as `ensureAuthTablesExist`
|
|
51
|
+
* derives it. Shared so the two cannot drift: a stamp written to one schema and
|
|
52
|
+
* read from another would read as "never stamped" forever.
|
|
53
|
+
*/
|
|
54
|
+
function resolveAuthSchema(collection) {
|
|
55
|
+
if (!collection) return "rebase";
|
|
56
|
+
const usersSchema = "schema" in collection && typeof collection.schema === "string" ? collection.schema : "public";
|
|
57
|
+
return usersSchema === "public" ? "rebase" : usersSchema;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Read the stamped version, or `null` when the database has never been stamped.
|
|
61
|
+
*
|
|
62
|
+
* `null` is not an error and must not be treated as one: every database
|
|
63
|
+
* provisioned before this file existed is unstamped, and so is every fresh one.
|
|
64
|
+
* Uses `to_regclass` rather than selecting straight from the table so a missing
|
|
65
|
+
* schema or table is a `null` rather than a thrown 42P01.
|
|
66
|
+
*/
|
|
67
|
+
async function readAuthSchemaVersion(db, authSchema) {
|
|
68
|
+
const qualified = `"${authSchema}"."schema_meta"`;
|
|
69
|
+
if (!(await db.execute(sql`SELECT to_regclass(${qualified}) IS NOT NULL AS present`)).rows[0]?.present) return null;
|
|
70
|
+
const raw = (await db.execute(sql`
|
|
71
|
+
SELECT value FROM ${sql.raw(qualified)} WHERE key = ${VERSION_KEY}
|
|
72
|
+
`)).rows[0]?.value;
|
|
73
|
+
if (raw === void 0) return null;
|
|
74
|
+
const parsed = Number.parseInt(raw, 10);
|
|
75
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Refuse to run against a database a newer runtime has already migrated.
|
|
79
|
+
*
|
|
80
|
+
* Deliberately one-directional. A database *older* than this runtime is the
|
|
81
|
+
* normal upgrade path — the migrations in `ensureAuthTablesExist` are about to
|
|
82
|
+
* bring it forward, so it is not an error. Only the reverse is unrecoverable.
|
|
83
|
+
*/
|
|
84
|
+
async function assertAuthSchemaCompatible(db, authSchema) {
|
|
85
|
+
const databaseVersion = await readAuthSchemaVersion(db, authSchema);
|
|
86
|
+
if (databaseVersion !== null && databaseVersion > 2) throw new AuthSchemaVersionError(databaseVersion, 2);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Record that this runtime's migrations have been applied.
|
|
90
|
+
*
|
|
91
|
+
* Called at the end of `ensureAuthTablesExist`, so a boot that failed partway
|
|
92
|
+
* through leaves the older stamp in place and the next boot migrates again.
|
|
93
|
+
*/
|
|
94
|
+
async function stampAuthSchemaVersion(db, authSchema) {
|
|
95
|
+
const qualified = `"${authSchema}"."schema_meta"`;
|
|
96
|
+
await db.execute(sql`
|
|
97
|
+
CREATE TABLE IF NOT EXISTS ${sql.raw(qualified)} (
|
|
98
|
+
key TEXT PRIMARY KEY,
|
|
99
|
+
value TEXT NOT NULL,
|
|
100
|
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
|
|
101
|
+
)
|
|
102
|
+
`);
|
|
103
|
+
await db.execute(sql`
|
|
104
|
+
INSERT INTO ${sql.raw(qualified)} (key, value)
|
|
105
|
+
VALUES (${VERSION_KEY}, ${String(2)})
|
|
106
|
+
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
|
107
|
+
`);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Check that the auth schema is one this runtime can actually write to.
|
|
111
|
+
*
|
|
112
|
+
* Two independent checks, because either alone has a blind spot:
|
|
113
|
+
*
|
|
114
|
+
* - The **stamp** catches a runtime older than the database. It is the precise
|
|
115
|
+
* signal, but it is blind on every database provisioned before stamping
|
|
116
|
+
* existed — which today is all of them.
|
|
117
|
+
* - The **structure** catches a database older than the runtime, and works on
|
|
118
|
+
* unstamped databases. It is what makes this useful immediately rather than
|
|
119
|
+
* one upgrade cycle from now.
|
|
120
|
+
*
|
|
121
|
+
* Never throws: a probe that fails to run reports unhealthy with the reason, so
|
|
122
|
+
* a broken check surfaces as a degraded health response rather than a 500 from
|
|
123
|
+
* the health endpoint itself.
|
|
124
|
+
*/
|
|
125
|
+
async function probeAuthSchema(db, authSchema) {
|
|
126
|
+
const problems = [];
|
|
127
|
+
let databaseVersion = null;
|
|
128
|
+
try {
|
|
129
|
+
databaseVersion = await readAuthSchemaVersion(db, authSchema);
|
|
130
|
+
if (databaseVersion !== null && databaseVersion > 2) problems.push(`database is at auth schema version ${databaseVersion}, this runtime understands 2 — it was migrated by a newer framework version`);
|
|
131
|
+
const refreshTokens = `"${authSchema}"."refresh_tokens"`;
|
|
132
|
+
if (!(await db.execute(sql`SELECT to_regclass(${refreshTokens}) IS NOT NULL AS present`)).rows[0]?.present) return {
|
|
133
|
+
healthy: problems.length === 0,
|
|
134
|
+
databaseVersion,
|
|
135
|
+
runtimeVersion: 2,
|
|
136
|
+
problems
|
|
137
|
+
};
|
|
138
|
+
const columns = await db.execute(sql`
|
|
139
|
+
SELECT column_name FROM information_schema.columns
|
|
140
|
+
WHERE table_schema = ${authSchema} AND table_name = 'refresh_tokens'
|
|
141
|
+
`);
|
|
142
|
+
const found = new Set(columns.rows.map((row) => row.column_name));
|
|
143
|
+
const missing = REQUIRED_REFRESH_TOKEN_COLUMNS.filter((column) => !found.has(column));
|
|
144
|
+
if (missing.length > 0) problems.push(`refresh_tokens is missing ${missing.join(", ")} — the auth migrations have not been applied to this database, so token rotation will fail`);
|
|
145
|
+
if ((await db.execute(sql`
|
|
146
|
+
SELECT 1 FROM pg_constraint c
|
|
147
|
+
JOIN pg_class t ON t.oid = c.conrelid
|
|
148
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
149
|
+
WHERE n.nspname = ${authSchema}
|
|
150
|
+
AND t.relname = 'refresh_tokens'
|
|
151
|
+
AND c.conname = ${RETIRED_REFRESH_TOKEN_CONSTRAINT}
|
|
152
|
+
`)).rows.length > 0) problems.push(`refresh_tokens still carries ${RETIRED_REFRESH_TOKEN_CONSTRAINT} — concurrent token rotation for one session will fail on it`);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
problems.push(`auth schema probe failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
healthy: problems.length === 0,
|
|
158
|
+
databaseVersion,
|
|
159
|
+
runtimeVersion: 2,
|
|
160
|
+
problems
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
//#endregion
|
|
164
|
+
//#region src/auth/ensure-tables.ts
|
|
165
|
+
var ensure_tables_exports = /* @__PURE__ */ __exportAll({ ensureAuthTablesExist: () => ensureAuthTablesExist });
|
|
166
|
+
/**
|
|
167
|
+
* Auto-create auth tables if they don't exist.
|
|
168
|
+
*
|
|
169
|
+
* @param db — Drizzle database instance
|
|
170
|
+
* @param collection — The collection that represents auth users.
|
|
171
|
+
* When omitted, a default `rebase.users` table is created.
|
|
172
|
+
*/
|
|
173
|
+
async function ensureAuthTablesExist(db, collection) {
|
|
174
|
+
logger.debug("🔍 Checking auth tables...");
|
|
175
|
+
await assertAuthSchemaCompatible(db, resolveAuthSchema(collection));
|
|
176
|
+
try {
|
|
177
|
+
let usersTableName = "\"rebase\".\"users\"";
|
|
178
|
+
let userIdType = "TEXT";
|
|
179
|
+
let usersSchema = "rebase";
|
|
180
|
+
let resolvedTable = "users";
|
|
181
|
+
if (collection) {
|
|
182
|
+
resolvedTable = "table" in collection && typeof collection.table === "string" ? collection.table : collection.slug;
|
|
183
|
+
usersSchema = "schema" in collection && typeof collection.schema === "string" ? collection.schema : "public";
|
|
184
|
+
usersTableName = usersSchema === "public" ? `"${resolvedTable}"` : `"${usersSchema}"."${resolvedTable}"`;
|
|
185
|
+
const idProp = collection.properties?.id;
|
|
186
|
+
if (idProp) {
|
|
187
|
+
const isId = "isId" in idProp ? idProp.isId : void 0;
|
|
188
|
+
if (isId === "uuid") userIdType = "UUID";
|
|
189
|
+
else if (isId === "increment") userIdType = "INTEGER";
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const result = await db.execute(sql`
|
|
194
|
+
SELECT data_type
|
|
195
|
+
FROM information_schema.columns
|
|
196
|
+
WHERE table_schema = ${usersSchema}
|
|
197
|
+
AND table_name = ${resolvedTable}
|
|
198
|
+
AND column_name = 'id'
|
|
199
|
+
`);
|
|
200
|
+
if (result && result.rows && result.rows.length > 0) {
|
|
201
|
+
const dbType = String(result.rows[0].data_type).toUpperCase();
|
|
202
|
+
if (dbType === "UUID") userIdType = "UUID";
|
|
203
|
+
else if (dbType === "INTEGER" || dbType === "SMALLINT" || dbType === "BIGINT") userIdType = "INTEGER";
|
|
204
|
+
else userIdType = "TEXT";
|
|
205
|
+
logger.debug(`✨ Detected ${usersTableName}.id type from database: ${dbType}. Using user_id type: ${userIdType}`);
|
|
206
|
+
}
|
|
207
|
+
} catch (err) {
|
|
208
|
+
logger.warn(`⚠️ Failed to introspect ${usersTableName}.id type from database, falling back to config type: ${userIdType}`, { error: err });
|
|
209
|
+
}
|
|
210
|
+
if (usersSchema !== "public") await db.execute(sql`CREATE SCHEMA IF NOT EXISTS ${sql.raw(usersSchema)}`);
|
|
211
|
+
await db.execute(sql`CREATE SCHEMA IF NOT EXISTS rebase`);
|
|
212
|
+
const authSchema = usersSchema === "public" ? "rebase" : usersSchema;
|
|
213
|
+
const userIdentitiesTable = `"${authSchema}"."user_identities"`;
|
|
214
|
+
const refreshTokensTableName = `"${authSchema}"."refresh_tokens"`;
|
|
215
|
+
const passwordResetTokensTableName = `"${authSchema}"."password_reset_tokens"`;
|
|
216
|
+
const appConfigTableName = `"${authSchema}"."app_config"`;
|
|
217
|
+
const idDefault = userIdType === "UUID" ? "DEFAULT gen_random_uuid()" : userIdType === "INTEGER" ? "GENERATED ALWAYS AS IDENTITY" : "DEFAULT gen_random_uuid()::text";
|
|
218
|
+
const authIdentifier = (suffix) => `${resolvedTable}_${suffix}`.slice(0, 63);
|
|
219
|
+
const emailLengthConstraint = `"${authIdentifier("email_length_check")}"`;
|
|
220
|
+
const emailLowerUniqueIndex = authIdentifier("email_lower_key");
|
|
221
|
+
const verificationTokenIndex = authIdentifier("email_verification_token_idx");
|
|
222
|
+
const usersColumnDdl = AUTH_USERS_COLUMNS.map((spec) => spec.column === "email" ? `${spec.column} ${authUsersColumnSql(spec)} CONSTRAINT ${emailLengthConstraint} CHECK (length(email) <= 320)` : `${spec.column} ${authUsersColumnSql(spec)}`).join(",\n ");
|
|
223
|
+
await db.execute(sql`
|
|
224
|
+
CREATE TABLE IF NOT EXISTS ${sql.raw(usersTableName)} (
|
|
225
|
+
id ${sql.raw(userIdType)} PRIMARY KEY ${sql.raw(idDefault)},
|
|
226
|
+
${sql.raw(usersColumnDdl)}
|
|
227
|
+
)
|
|
228
|
+
`);
|
|
229
|
+
const legacyFkTables = [
|
|
230
|
+
"user_identities",
|
|
231
|
+
"refresh_tokens",
|
|
232
|
+
"password_reset_tokens",
|
|
233
|
+
"magic_link_tokens",
|
|
234
|
+
"mfa_factors",
|
|
235
|
+
"recovery_codes"
|
|
236
|
+
];
|
|
237
|
+
const legacyFkTableList = legacyFkTables.map((t) => `'${t}'`).join(", ");
|
|
238
|
+
const legacyUserIdPresent = await db.execute(sql`
|
|
239
|
+
SELECT 1
|
|
240
|
+
FROM information_schema.columns
|
|
241
|
+
WHERE table_schema = ${authSchema}
|
|
242
|
+
AND table_name IN (${sql.raw(legacyFkTableList)})
|
|
243
|
+
AND column_name = 'user_id'
|
|
244
|
+
LIMIT 1
|
|
245
|
+
`);
|
|
246
|
+
if (legacyUserIdPresent.rows.length > 0) await db.execute(sql`
|
|
247
|
+
CREATE OR REPLACE FUNCTION ${sql.raw(`"${authSchema}"`)}.sync_uid_user_id() RETURNS trigger AS $$
|
|
248
|
+
BEGIN
|
|
249
|
+
IF NEW.uid IS NULL AND NEW.user_id IS NOT NULL THEN
|
|
250
|
+
NEW.uid := NEW.user_id;
|
|
251
|
+
ELSIF NEW.user_id IS NULL AND NEW.uid IS NOT NULL THEN
|
|
252
|
+
NEW.user_id := NEW.uid;
|
|
253
|
+
END IF;
|
|
254
|
+
RETURN NEW;
|
|
255
|
+
END $$ LANGUAGE plpgsql
|
|
256
|
+
`);
|
|
257
|
+
for (const authTable of legacyUserIdPresent.rows.length > 0 ? legacyFkTables : []) {
|
|
258
|
+
const qualified = `"${authSchema}"."${authTable}"`;
|
|
259
|
+
await db.execute(sql`
|
|
260
|
+
DO $$
|
|
261
|
+
DECLARE
|
|
262
|
+
has_legacy boolean;
|
|
263
|
+
has_uid boolean;
|
|
264
|
+
BEGIN
|
|
265
|
+
SELECT
|
|
266
|
+
bool_or(column_name = 'user_id'),
|
|
267
|
+
bool_or(column_name = 'uid')
|
|
268
|
+
INTO has_legacy, has_uid
|
|
269
|
+
FROM information_schema.columns
|
|
270
|
+
WHERE table_schema = ${sql.raw(`'${authSchema}'`)}
|
|
271
|
+
AND table_name = ${sql.raw(`'${authTable}'`)};
|
|
272
|
+
|
|
273
|
+
-- Table absent, or already uid-only (a fresh install, or
|
|
274
|
+
-- phase 2 already run): nothing to do.
|
|
275
|
+
IF has_legacy IS NOT TRUE THEN
|
|
276
|
+
RETURN;
|
|
277
|
+
END IF;
|
|
278
|
+
|
|
279
|
+
IF has_uid IS NOT TRUE THEN
|
|
280
|
+
EXECUTE ${sql.raw(`'ALTER TABLE ${qualified} ADD COLUMN uid ${userIdType} REFERENCES ${usersTableName}(id) ON DELETE CASCADE'`)};
|
|
281
|
+
EXECUTE ${sql.raw(`'UPDATE ${qualified} SET uid = user_id WHERE uid IS NULL'`)};
|
|
282
|
+
EXECUTE ${sql.raw(`'CREATE INDEX IF NOT EXISTS idx_${authTable}_uid ON ${qualified}(uid)'`)};
|
|
283
|
+
END IF;
|
|
284
|
+
|
|
285
|
+
-- New code inserts uid and never user_id, so the legacy
|
|
286
|
+
-- column can no longer be NOT NULL. The trigger below
|
|
287
|
+
-- backfills it, but the constraint is checked first.
|
|
288
|
+
EXECUTE ${sql.raw(`'ALTER TABLE ${qualified} ALTER COLUMN user_id DROP NOT NULL'`)};
|
|
289
|
+
|
|
290
|
+
EXECUTE ${sql.raw(`'DROP TRIGGER IF EXISTS sync_uid_user_id ON ${qualified}'`)};
|
|
291
|
+
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()'`)};
|
|
292
|
+
END $$
|
|
293
|
+
`);
|
|
294
|
+
}
|
|
295
|
+
await db.execute(sql`
|
|
296
|
+
CREATE TABLE IF NOT EXISTS ${sql.raw(userIdentitiesTable)} (
|
|
297
|
+
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
298
|
+
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
299
|
+
provider TEXT NOT NULL,
|
|
300
|
+
provider_id TEXT NOT NULL,
|
|
301
|
+
profile_data JSONB,
|
|
302
|
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
303
|
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
304
|
+
UNIQUE(provider, provider_id)
|
|
305
|
+
)
|
|
306
|
+
`);
|
|
307
|
+
await db.execute(sql`
|
|
308
|
+
CREATE INDEX IF NOT EXISTS idx_user_identities_user
|
|
309
|
+
ON ${sql.raw(userIdentitiesTable)}(uid)
|
|
310
|
+
`);
|
|
311
|
+
await db.execute(sql`
|
|
312
|
+
CREATE TABLE IF NOT EXISTS ${sql.raw(refreshTokensTableName)} (
|
|
313
|
+
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
314
|
+
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
315
|
+
session_id TEXT NOT NULL DEFAULT gen_random_uuid()::text,
|
|
316
|
+
token_hash TEXT NOT NULL UNIQUE,
|
|
317
|
+
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
318
|
+
revoked BOOLEAN DEFAULT FALSE NOT NULL,
|
|
319
|
+
rotated_at TIMESTAMP WITH TIME ZONE,
|
|
320
|
+
session_started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
|
|
321
|
+
user_agent TEXT,
|
|
322
|
+
ip_address TEXT,
|
|
323
|
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
324
|
+
)
|
|
325
|
+
`);
|
|
326
|
+
await db.execute(sql`
|
|
327
|
+
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash
|
|
328
|
+
ON ${sql.raw(refreshTokensTableName)}(token_hash)
|
|
329
|
+
`);
|
|
330
|
+
await db.execute(sql`
|
|
331
|
+
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user
|
|
332
|
+
ON ${sql.raw(refreshTokensTableName)}(uid)
|
|
333
|
+
`);
|
|
334
|
+
await db.execute(sql`
|
|
335
|
+
CREATE TABLE IF NOT EXISTS ${sql.raw(passwordResetTokensTableName)} (
|
|
336
|
+
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
337
|
+
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
338
|
+
token_hash TEXT NOT NULL UNIQUE,
|
|
339
|
+
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
340
|
+
used_at TIMESTAMP WITH TIME ZONE,
|
|
341
|
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
342
|
+
)
|
|
343
|
+
`);
|
|
344
|
+
await db.execute(sql`
|
|
345
|
+
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_hash
|
|
346
|
+
ON ${sql.raw(passwordResetTokensTableName)}(token_hash)
|
|
347
|
+
`);
|
|
348
|
+
await db.execute(sql`
|
|
349
|
+
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user
|
|
350
|
+
ON ${sql.raw(passwordResetTokensTableName)}(uid)
|
|
351
|
+
`);
|
|
352
|
+
const magicLinkTokensTableName = `"${authSchema}"."magic_link_tokens"`;
|
|
353
|
+
await db.execute(sql`
|
|
354
|
+
CREATE TABLE IF NOT EXISTS ${sql.raw(magicLinkTokensTableName)} (
|
|
355
|
+
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
356
|
+
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
357
|
+
token_hash TEXT NOT NULL UNIQUE,
|
|
358
|
+
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
359
|
+
used_at TIMESTAMP WITH TIME ZONE,
|
|
360
|
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
361
|
+
)
|
|
362
|
+
`);
|
|
363
|
+
await db.execute(sql`
|
|
364
|
+
CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_hash
|
|
365
|
+
ON ${sql.raw(magicLinkTokensTableName)}(token_hash)
|
|
366
|
+
`);
|
|
367
|
+
await db.execute(sql`
|
|
368
|
+
CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_user
|
|
369
|
+
ON ${sql.raw(magicLinkTokensTableName)}(uid)
|
|
370
|
+
`);
|
|
371
|
+
await db.execute(sql`
|
|
372
|
+
CREATE TABLE IF NOT EXISTS ${sql.raw(appConfigTableName)} (
|
|
373
|
+
key TEXT PRIMARY KEY,
|
|
374
|
+
value JSONB NOT NULL,
|
|
375
|
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
376
|
+
)
|
|
377
|
+
`);
|
|
378
|
+
await db.transaction(async (tx) => {
|
|
379
|
+
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init'))`);
|
|
380
|
+
for (const statement of RLS_BOOTSTRAP_STATEMENTS) await tx.execute(sql.raw(statement));
|
|
381
|
+
});
|
|
382
|
+
for (const spec of AUTH_USERS_COLUMNS) {
|
|
383
|
+
if (spec.column === "email") continue;
|
|
384
|
+
await db.execute(sql`
|
|
385
|
+
ALTER TABLE ${sql.raw(usersTableName)}
|
|
386
|
+
ADD COLUMN IF NOT EXISTS ${sql.raw(`${spec.column} ${authUsersColumnSql(spec)}`)}
|
|
387
|
+
`);
|
|
388
|
+
}
|
|
389
|
+
const usersColumnRows = (await db.execute(sql`
|
|
390
|
+
SELECT column_name, data_type, is_nullable, column_default
|
|
391
|
+
FROM information_schema.columns
|
|
392
|
+
WHERE table_schema = ${usersSchema} AND table_name = ${resolvedTable}
|
|
393
|
+
`)).rows;
|
|
394
|
+
const usersColumnTypes = new Map(usersColumnRows.map((row) => [row.column_name, row.data_type]));
|
|
395
|
+
const usersColumnState = new Map(usersColumnRows.map((row) => [row.column_name, row]));
|
|
396
|
+
for (const spec of AUTH_USERS_COLUMNS) {
|
|
397
|
+
const state = usersColumnState.get(spec.column);
|
|
398
|
+
if (!state) continue;
|
|
399
|
+
if (spec.default !== void 0 && state.column_default === null) {
|
|
400
|
+
await db.execute(sql`
|
|
401
|
+
ALTER TABLE ${sql.raw(usersTableName)}
|
|
402
|
+
ALTER COLUMN ${sql.raw(`"${spec.column}"`)} SET DEFAULT ${sql.raw(spec.default)}
|
|
403
|
+
`);
|
|
404
|
+
logger.info(`🔧 Restored the default on ${usersTableName}.${spec.column}`);
|
|
405
|
+
}
|
|
406
|
+
if (!spec.notNull || state.is_nullable !== "YES") continue;
|
|
407
|
+
if (spec.default !== void 0) await db.execute(sql`
|
|
408
|
+
UPDATE ${sql.raw(usersTableName)}
|
|
409
|
+
SET ${sql.raw(`"${spec.column}"`)} = ${sql.raw(spec.default)}
|
|
410
|
+
WHERE ${sql.raw(`"${spec.column}"`)} IS NULL
|
|
411
|
+
`);
|
|
412
|
+
try {
|
|
413
|
+
await db.execute(sql`
|
|
414
|
+
ALTER TABLE ${sql.raw(usersTableName)}
|
|
415
|
+
ALTER COLUMN ${sql.raw(`"${spec.column}"`)} SET NOT NULL
|
|
416
|
+
`);
|
|
417
|
+
logger.info(`🔧 Restored NOT NULL on ${usersTableName}.${spec.column}`);
|
|
418
|
+
} catch (err) {
|
|
419
|
+
logger.warn(`⚠️ ${usersTableName}.${spec.column} should be NOT NULL but still holds NULLs, so the constraint was not applied. Fill or remove those rows and restart: ` + (err instanceof Error ? err.message : String(err)));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
for (const column of [
|
|
423
|
+
"email",
|
|
424
|
+
"display_name",
|
|
425
|
+
"photo_url",
|
|
426
|
+
"password_hash",
|
|
427
|
+
"email_verification_token"
|
|
428
|
+
]) {
|
|
429
|
+
if (usersColumnTypes.get(column) !== "character varying") continue;
|
|
430
|
+
await db.execute(sql`
|
|
431
|
+
ALTER TABLE ${sql.raw(usersTableName)}
|
|
432
|
+
ALTER COLUMN ${sql.raw(`"${column}"`)} TYPE TEXT
|
|
433
|
+
`);
|
|
434
|
+
logger.info(`🔧 Widened ${usersTableName}.${column} from VARCHAR(n) to TEXT`);
|
|
435
|
+
}
|
|
436
|
+
if (usersColumnTypes.has("email")) {
|
|
437
|
+
if ((await db.execute(sql`
|
|
438
|
+
SELECT 1 FROM pg_class c
|
|
439
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
440
|
+
WHERE n.nspname = ${usersSchema} AND c.relname = ${emailLowerUniqueIndex} AND c.relkind = 'i'
|
|
441
|
+
`)).rows.length === 0) {
|
|
442
|
+
const collisions = await db.execute(sql`
|
|
443
|
+
SELECT lower(email) AS normalized, count(*)::int AS occurrences
|
|
444
|
+
FROM ${sql.raw(usersTableName)}
|
|
445
|
+
WHERE email IS NOT NULL
|
|
446
|
+
GROUP BY lower(email)
|
|
447
|
+
HAVING count(*) > 1
|
|
448
|
+
LIMIT 10
|
|
449
|
+
`);
|
|
450
|
+
if (collisions.rows.length > 0) {
|
|
451
|
+
const sample = collisions.rows.map((row) => `${row.normalized} (×${row.occurrences})`).join(", ");
|
|
452
|
+
logger.error(`❌ Cannot enforce case-insensitive email uniqueness on ${usersTableName}: these addresses already exist more than once, differing only in case — ${sample}. Merge or delete the duplicates and restart; until then two accounts can share one address and only the lower-cased one is reachable by login.`);
|
|
453
|
+
} else {
|
|
454
|
+
const folded = await db.execute(sql`
|
|
455
|
+
UPDATE ${sql.raw(usersTableName)}
|
|
456
|
+
SET email = lower(email)
|
|
457
|
+
WHERE email IS NOT NULL AND email <> lower(email)
|
|
458
|
+
`);
|
|
459
|
+
if (folded.rowCount) logger.info(`🔧 Lower-cased ${folded.rowCount} email address(es) in ${usersTableName}`);
|
|
460
|
+
await db.execute(sql`
|
|
461
|
+
CREATE UNIQUE INDEX IF NOT EXISTS ${sql.raw(`"${emailLowerUniqueIndex}"`)}
|
|
462
|
+
ON ${sql.raw(usersTableName)} (lower(email))
|
|
463
|
+
`);
|
|
464
|
+
logger.info(`✅ Email uniqueness on ${usersTableName} is now case-insensitive`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (usersColumnTypes.has("email")) {
|
|
469
|
+
if ((await db.execute(sql`
|
|
470
|
+
SELECT 1 FROM pg_constraint c
|
|
471
|
+
JOIN pg_class t ON t.oid = c.conrelid
|
|
472
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
473
|
+
WHERE n.nspname = ${usersSchema}
|
|
474
|
+
AND t.relname = ${resolvedTable}
|
|
475
|
+
AND c.conname = ${authIdentifier("email_length_check")}
|
|
476
|
+
`)).rows.length === 0) await db.execute(sql`
|
|
477
|
+
ALTER TABLE ${sql.raw(usersTableName)}
|
|
478
|
+
ADD CONSTRAINT ${sql.raw(emailLengthConstraint)} CHECK (length(email) <= 320) NOT VALID
|
|
479
|
+
`);
|
|
480
|
+
}
|
|
481
|
+
if (usersColumnTypes.has("email_verification_token")) await db.execute(sql`
|
|
482
|
+
CREATE INDEX IF NOT EXISTS ${sql.raw(`"${verificationTokenIndex}"`)}
|
|
483
|
+
ON ${sql.raw(usersTableName)} (email_verification_token)
|
|
484
|
+
WHERE email_verification_token IS NOT NULL
|
|
485
|
+
`);
|
|
486
|
+
try {
|
|
487
|
+
const found = (await db.execute(sql`
|
|
488
|
+
SELECT table_schema, table_name
|
|
489
|
+
FROM information_schema.tables
|
|
490
|
+
WHERE table_name = 'refresh_tokens'
|
|
491
|
+
`)).rows;
|
|
492
|
+
logger.debug(`🔍 refresh_tokens reconcile: found ${found.length} table(s): ${found.map((r) => `"${r.table_schema}"."${r.table_name}"`).join(", ") || "(none)"}`);
|
|
493
|
+
for (const { table_schema } of found) {
|
|
494
|
+
const qualified = `"${table_schema}"."refresh_tokens"`;
|
|
495
|
+
try {
|
|
496
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_id TEXT`);
|
|
497
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS revoked BOOLEAN DEFAULT FALSE NOT NULL`);
|
|
498
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS rotated_at TIMESTAMP WITH TIME ZONE`);
|
|
499
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_started_at TIMESTAMP WITH TIME ZONE`);
|
|
500
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS aal TEXT`);
|
|
501
|
+
await db.execute(sql`
|
|
502
|
+
UPDATE ${sql.raw(qualified)}
|
|
503
|
+
SET session_id = gen_random_uuid()::text
|
|
504
|
+
WHERE session_id IS NULL
|
|
505
|
+
`);
|
|
506
|
+
await db.execute(sql`
|
|
507
|
+
UPDATE ${sql.raw(qualified)}
|
|
508
|
+
SET session_started_at = COALESCE(created_at, NOW())
|
|
509
|
+
WHERE session_started_at IS NULL
|
|
510
|
+
`);
|
|
511
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_id SET DEFAULT gen_random_uuid()::text`);
|
|
512
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_started_at SET DEFAULT NOW()`);
|
|
513
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_id SET NOT NULL`);
|
|
514
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_started_at SET NOT NULL`);
|
|
515
|
+
await db.execute(sql`
|
|
516
|
+
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_session
|
|
517
|
+
ON ${sql.raw(qualified)}(session_id)
|
|
518
|
+
`);
|
|
519
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} DROP CONSTRAINT IF EXISTS unique_device_session`);
|
|
520
|
+
logger.debug(`✅ refresh_tokens reconciled for session-scoped rotation: ${qualified}`);
|
|
521
|
+
} catch (perTableError) {
|
|
522
|
+
logger.warn(`⚠️ refresh_tokens reconcile failed for ${qualified}: ${perTableError instanceof Error ? perTableError.message : String(perTableError)}`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
} catch (migrationError) {
|
|
526
|
+
logger.warn(`⚠️ refresh_tokens session migration skipped: ${migrationError instanceof Error ? migrationError.message : String(migrationError)}`);
|
|
527
|
+
}
|
|
528
|
+
try {
|
|
529
|
+
if ((await db.execute(sql`
|
|
530
|
+
SELECT EXISTS (
|
|
531
|
+
SELECT 1 FROM information_schema.tables
|
|
532
|
+
WHERE table_schema = 'rebase' AND table_name = 'user_roles'
|
|
533
|
+
) AS has_user_roles
|
|
534
|
+
`)).rows[0].has_user_roles) {
|
|
535
|
+
logger.info("🔄 Migrating roles from legacy user_roles table...");
|
|
536
|
+
await db.execute(sql`
|
|
537
|
+
UPDATE ${sql.raw(usersTableName)} u
|
|
538
|
+
SET roles = COALESCE((
|
|
539
|
+
SELECT array_agg(ur.role_id)
|
|
540
|
+
FROM "rebase"."user_roles" ur
|
|
541
|
+
WHERE ur.user_id = u.id
|
|
542
|
+
), '{}')
|
|
543
|
+
WHERE u.roles = '{}' OR u.roles IS NULL
|
|
544
|
+
`);
|
|
545
|
+
await db.execute(sql`DROP TABLE IF EXISTS "rebase"."user_roles" CASCADE`);
|
|
546
|
+
await db.execute(sql`DROP TABLE IF EXISTS "rebase"."roles" CASCADE`);
|
|
547
|
+
logger.info("✅ Legacy roles tables migrated and dropped");
|
|
548
|
+
}
|
|
549
|
+
} catch (migrationError) {
|
|
550
|
+
logger.warn(`⚠️ Legacy roles migration skipped: ${migrationError instanceof Error ? migrationError.message : String(migrationError)}`);
|
|
551
|
+
}
|
|
552
|
+
const mfaFactorsTableName = `"${authSchema}"."mfa_factors"`;
|
|
553
|
+
const mfaChallengesTableName = `"${authSchema}"."mfa_challenges"`;
|
|
554
|
+
const recoveryCodesTableName = `"${authSchema}"."recovery_codes"`;
|
|
555
|
+
await db.execute(sql`
|
|
556
|
+
CREATE TABLE IF NOT EXISTS ${sql.raw(mfaFactorsTableName)} (
|
|
557
|
+
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
558
|
+
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
559
|
+
factor_type TEXT NOT NULL DEFAULT 'totp',
|
|
560
|
+
secret_encrypted TEXT NOT NULL,
|
|
561
|
+
friendly_name TEXT,
|
|
562
|
+
verified BOOLEAN DEFAULT FALSE,
|
|
563
|
+
last_used_counter BIGINT,
|
|
564
|
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
565
|
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
566
|
+
)
|
|
567
|
+
`);
|
|
568
|
+
await db.execute(sql`
|
|
569
|
+
CREATE INDEX IF NOT EXISTS idx_mfa_factors_user
|
|
570
|
+
ON ${sql.raw(mfaFactorsTableName)}(uid)
|
|
571
|
+
`);
|
|
572
|
+
await db.execute(sql`
|
|
573
|
+
CREATE TABLE IF NOT EXISTS ${sql.raw(mfaChallengesTableName)} (
|
|
574
|
+
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
575
|
+
factor_id TEXT NOT NULL REFERENCES ${sql.raw(mfaFactorsTableName)}(id) ON DELETE CASCADE,
|
|
576
|
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
577
|
+
verified_at TIMESTAMP WITH TIME ZONE,
|
|
578
|
+
ip_address TEXT,
|
|
579
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
580
|
+
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
|
|
581
|
+
)
|
|
582
|
+
`);
|
|
583
|
+
await db.execute(sql`
|
|
584
|
+
CREATE INDEX IF NOT EXISTS idx_mfa_challenges_factor
|
|
585
|
+
ON ${sql.raw(mfaChallengesTableName)}(factor_id)
|
|
586
|
+
`);
|
|
587
|
+
try {
|
|
588
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(mfaFactorsTableName)} ADD COLUMN IF NOT EXISTS last_used_counter BIGINT`);
|
|
589
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(mfaChallengesTableName)} ADD COLUMN IF NOT EXISTS attempts INTEGER NOT NULL DEFAULT 0`);
|
|
590
|
+
} catch (mfaMigrationError) {
|
|
591
|
+
logger.warn(`⚠️ MFA hardening columns skipped: ${mfaMigrationError instanceof Error ? mfaMigrationError.message : String(mfaMigrationError)}`);
|
|
592
|
+
}
|
|
593
|
+
await db.execute(sql`
|
|
594
|
+
CREATE TABLE IF NOT EXISTS ${sql.raw(recoveryCodesTableName)} (
|
|
595
|
+
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
596
|
+
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
597
|
+
code_hash TEXT NOT NULL,
|
|
598
|
+
used_at TIMESTAMP WITH TIME ZONE,
|
|
599
|
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
600
|
+
)
|
|
601
|
+
`);
|
|
602
|
+
await db.execute(sql`
|
|
603
|
+
CREATE INDEX IF NOT EXISTS idx_recovery_codes_user
|
|
604
|
+
ON ${sql.raw(recoveryCodesTableName)}(uid)
|
|
605
|
+
`);
|
|
606
|
+
try {
|
|
607
|
+
const authTablePairs = [
|
|
608
|
+
[usersSchema, resolvedTable],
|
|
609
|
+
[authSchema, "user_identities"],
|
|
610
|
+
[authSchema, "refresh_tokens"],
|
|
611
|
+
[authSchema, "password_reset_tokens"],
|
|
612
|
+
[authSchema, "magic_link_tokens"],
|
|
613
|
+
[authSchema, "app_config"],
|
|
614
|
+
[authSchema, "mfa_factors"],
|
|
615
|
+
[authSchema, "mfa_challenges"],
|
|
616
|
+
[authSchema, "recovery_codes"],
|
|
617
|
+
[authSchema, "schema_meta"]
|
|
618
|
+
];
|
|
619
|
+
for (const [schemaName, tableName] of authTablePairs) if ((await db.execute(sql`
|
|
620
|
+
SELECT 1
|
|
621
|
+
FROM pg_class c
|
|
622
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
623
|
+
WHERE n.nspname = ${schemaName}
|
|
624
|
+
AND c.relname = ${tableName}
|
|
625
|
+
AND c.relforcerowsecurity
|
|
626
|
+
`)).rows.length > 0) {
|
|
627
|
+
await db.execute(sql`
|
|
628
|
+
ALTER TABLE ${sql.raw(`"${schemaName}"."${tableName}"`)}
|
|
629
|
+
NO FORCE ROW LEVEL SECURITY
|
|
630
|
+
`);
|
|
631
|
+
logger.warn(`🔧 Cleared stale FORCE ROW LEVEL SECURITY on "${schemaName}"."${tableName}" (legacy RLS model — it binds the owner connection and breaks privileged auth writes)`);
|
|
632
|
+
}
|
|
633
|
+
} catch (rlsReconcileError) {
|
|
634
|
+
logger.warn(`⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`);
|
|
635
|
+
}
|
|
636
|
+
await stampAuthSchemaVersion(db, authSchema);
|
|
637
|
+
await revokeInternalTableAccess(async (text) => {
|
|
638
|
+
await db.execute(sql.raw(text));
|
|
639
|
+
}, authSchema, { onError: (table, err) => logger.warn(`🔐 Could not revoke authenticated-role access to "${authSchema}"."${table}": ` + (err instanceof Error ? err.message : String(err))) });
|
|
640
|
+
logger.debug("✅ Auth tables ready");
|
|
641
|
+
} catch (error) {
|
|
642
|
+
if (error instanceof AuthSchemaVersionError) throw error;
|
|
643
|
+
logger.error("❌ Failed to create auth tables", { error });
|
|
644
|
+
logger.warn("⚠️ Continuing without creating auth tables.");
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
//#endregion
|
|
648
|
+
export { resolveAuthSchema as i, ensure_tables_exports as n, probeAuthSchema as r, ensureAuthTablesExist as t };
|
|
649
|
+
|
|
650
|
+
//# sourceMappingURL=ensure-tables-CIojK-Rx.js.map
|