@rebasepro/server-postgres 0.20.1-canary.g4d882ca → 0.21.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/PostgresBackendDriver.d.ts +33 -3
- package/dist/auth/services.d.ts +19 -1
- package/dist/{backup-cli-DSpQyqcG.js → backup-cli-Bp-ou6o2.js} +2 -2
- package/dist/{backup-cli-DSpQyqcG.js.map → backup-cli-Bp-ou6o2.js.map} +1 -1
- package/dist/{backup-service-DA7a6SUV.js → backup-service-BNLwvxuy.js} +2 -2
- package/dist/{backup-service-DA7a6SUV.js.map → backup-service-BNLwvxuy.js.map} +1 -1
- package/dist/cli.js +7 -7
- package/dist/column-plan-helpers-CpILzHJS.js.map +1 -1
- package/dist/{doctor-D5SrGJ5P.js → doctor-ByFWL_ET.js} +25 -6
- package/dist/doctor-ByFWL_ET.js.map +1 -0
- package/dist/{ensure-collection-policies-CHO0moXQ.js → ensure-collection-policies-CQGHln-y.js} +2 -2
- package/dist/{ensure-collection-policies-CHO0moXQ.js.map → ensure-collection-policies-CQGHln-y.js.map} +1 -1
- package/dist/{ensure-tables-CYMtuuOd.js → ensure-tables-BnEvEJPr.js} +2 -2
- package/dist/ensure-tables-BnEvEJPr.js.map +1 -0
- package/dist/history/HistoryService.d.ts +16 -2
- package/dist/history/ensure-history-table.d.ts +7 -1
- package/dist/index.es.js +1580 -1085
- package/dist/index.es.js.map +1 -1
- package/dist/plan-schema-Hgl62S-w.js.map +1 -1
- package/dist/{rls-bootstrap-sql-BlzsOUtz.js → rls-bootstrap-sql-BHA6jLtj.js} +4 -3
- package/dist/{rls-bootstrap-sql-BlzsOUtz.js.map → rls-bootstrap-sql-BHA6jLtj.js.map} +1 -1
- package/dist/{rls-enforcement-BV12vBwQ.js → rls-enforcement-C6Xk0lA6.js} +20 -20
- package/dist/{rls-enforcement-BV12vBwQ.js.map → rls-enforcement-C6Xk0lA6.js.map} +1 -1
- package/dist/schema/doctor-cli.js +2 -2
- package/dist/services/PersistService.d.ts +11 -0
- package/dist/services/RelationService.d.ts +39 -0
- package/dist/services/RelationWriteService.d.ts +32 -1
- package/dist/services/dataService.d.ts +2 -0
- package/dist/services/junction-writes.d.ts +58 -2
- package/dist/services/write-transaction-scope.d.ts +68 -0
- package/dist/types.d.ts +18 -0
- package/package.json +7 -7
- package/dist/doctor-D5SrGJ5P.js.map +0 -1
- package/dist/ensure-tables-CYMtuuOd.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ensure-tables-BnEvEJPr.js","names":[],"sources":["../src/auth/schema-version.ts","../src/auth/ensure-tables.ts"],"sourcesContent":["import { sql } from \"drizzle-orm\";\nimport { NodePgDatabase } from \"drizzle-orm/node-postgres\";\nimport type { CollectionConfig } from \"@rebasepro/types\";\n\n/**\n * The auth schema version this runtime expects to find in the database.\n *\n * Bump this whenever a migration in `ensureAuthTablesExist` makes the schema\n * unreadable by the runtime that came before it — that is, whenever a *previous*\n * version's auth queries would break against the migrated shape. Additive\n * changes (a new nullable column nobody older references) do not need a bump.\n *\n * History. Note that 1 is a label for an era, not a value any database holds:\n * stamping did not exist then, so an era-1 database reads as unstamped\n * (`null`), and 2 is the first version ever actually written. The numbering\n * starts at 2 only because two schema eras already existed when it was\n * introduced; it could just as well have started at 1. It is not worth\n * renumbering now — deployed databases already carry 2, and lowering the\n * constant would make them look newer than the runtime and refuse the boot.\n *\n * 1 — Device-session refresh tokens. A row *was* a session, identified by\n * `unique_device_session UNIQUE (uid, user_agent, ip_address)`, and\n * `createToken` upserted with `ON CONFLICT (uid, user_agent, ip_address)`.\n * 2 — Session-scoped, rotation-safe refresh tokens: `session_id`, `revoked`,\n * `rotated_at`, `session_started_at`, and `unique_device_session`\n * dropped because two live tokens of one session share all three columns.\n *\n * The 1 → 2 migration is why this file exists. Dropping the constraint is\n * one-way: a version-1 runtime deployed afterwards boots perfectly, logs\n * `✅ Auth tables ready` (its `CREATE TABLE IF NOT EXISTS` never revisits the\n * existing table, so it cannot re-add the constraint), answers `/health` with\n * 200 — and then fails every single login and refresh with SQLSTATE 42P10,\n * because its `ON CONFLICT` names a constraint that no longer exists. A silent\n * total auth outage behind a green health check. The stamp below turns that\n * into a boot refusal.\n */\nexport const AUTH_SCHEMA_VERSION = 2;\n\n/** Key under which the version is stored in the auth schema's meta table. */\nconst VERSION_KEY = \"auth_schema_version\";\n\n/**\n * Columns `refresh_tokens` must have for the current runtime's auth write path\n * to work. Checked by the health probe so a database that drifted *below* this\n * runtime is reported as unhealthy rather than discovered one failed login at a\n * time. Kept in step with the migration in `ensureAuthTablesExist`.\n */\nconst REQUIRED_REFRESH_TOKEN_COLUMNS = [\"session_id\", \"revoked\", \"rotated_at\", \"session_started_at\"];\n\n/**\n * A constraint whose *presence* means the database is still at version 1, in a\n * shape this runtime's rotation logic cannot write to: it makes two live tokens\n * of one rotating session collide.\n */\nconst RETIRED_REFRESH_TOKEN_CONSTRAINT = \"unique_device_session\";\n\n/**\n * Thrown when the database was migrated by a runtime newer than this one.\n *\n * Distinct class rather than a bare `Error` because `ensureAuthTablesExist`\n * wraps its migrations in a catch that deliberately swallows failures and\n * continues — every other problem there is better survived than crashed on.\n * This one is not, so the catch rethrows on this type specifically.\n */\nexport class AuthSchemaVersionError extends Error {\n readonly databaseVersion: number;\n readonly runtimeVersion: number;\n\n constructor(databaseVersion: number, runtimeVersion: number) {\n super(\n `Auth schema version mismatch: the database is at version ${databaseVersion}, ` +\n `but this runtime understands version ${runtimeVersion}.\\n\\n` +\n \"A newer version of the framework has already migrated this database. Running this \" +\n \"older runtime against it would boot cleanly and then fail every login and token \" +\n \"refresh, because the auth schema it expects no longer exists.\\n\\n\" +\n \"Refusing to start. Deploy a framework version at or above the one that migrated \" +\n \"this database, or restore the database from a backup taken before the upgrade.\"\n );\n this.name = \"AuthSchemaVersionError\";\n this.databaseVersion = databaseVersion;\n this.runtimeVersion = runtimeVersion;\n }\n}\n\n/**\n * The schema the auth tables live in, derived exactly as `ensureAuthTablesExist`\n * derives it. Shared so the two cannot drift: a stamp written to one schema and\n * read from another would read as \"never stamped\" forever.\n */\nexport function resolveAuthSchema(collection?: CollectionConfig): string {\n if (!collection) return \"rebase\";\n const usersSchema = (\"schema\" in collection && typeof collection.schema === \"string\")\n ? collection.schema\n : \"public\";\n return usersSchema === \"public\" ? \"rebase\" : usersSchema;\n}\n\n/**\n * Read the stamped version, or `null` when the database has never been stamped.\n *\n * `null` is not an error and must not be treated as one: every database\n * provisioned before this file existed is unstamped, and so is every fresh one.\n * Uses `to_regclass` rather than selecting straight from the table so a missing\n * schema or table is a `null` rather than a thrown 42P01.\n */\nexport async function readAuthSchemaVersion(\n db: NodePgDatabase,\n authSchema: string\n): Promise<number | null> {\n const qualified = `\"${authSchema}\".\"schema_meta\"`;\n const exists = await db.execute(sql`SELECT to_regclass(${qualified}) IS NOT NULL AS present`);\n if (!(exists.rows[0] as { present: boolean } | undefined)?.present) return null;\n\n const result = await db.execute(sql`\n SELECT value FROM ${sql.raw(qualified)} WHERE key = ${VERSION_KEY}\n `);\n const raw = (result.rows[0] as { value: string } | undefined)?.value;\n if (raw === undefined) return null;\n\n const parsed = Number.parseInt(raw, 10);\n // A meta row we cannot parse is treated as unstamped rather than as version\n // 0: refusing to boot over a garbled string would be a worse failure than\n // the drift it is meant to catch.\n return Number.isFinite(parsed) ? parsed : null;\n}\n\n/**\n * Refuse to run against a database a newer runtime has already migrated.\n *\n * Deliberately one-directional. A database *older* than this runtime is the\n * normal upgrade path — the migrations in `ensureAuthTablesExist` are about to\n * bring it forward, so it is not an error. Only the reverse is unrecoverable.\n */\nexport async function assertAuthSchemaCompatible(\n db: NodePgDatabase,\n authSchema: string\n): Promise<void> {\n const databaseVersion = await readAuthSchemaVersion(db, authSchema);\n if (databaseVersion !== null && databaseVersion > AUTH_SCHEMA_VERSION) {\n throw new AuthSchemaVersionError(databaseVersion, AUTH_SCHEMA_VERSION);\n }\n}\n\n/**\n * Record that this runtime's migrations have been applied.\n *\n * Called at the end of `ensureAuthTablesExist`, so a boot that failed partway\n * through leaves the older stamp in place and the next boot migrates again.\n */\nexport async function stampAuthSchemaVersion(\n db: NodePgDatabase,\n authSchema: string\n): Promise<void> {\n const qualified = `\"${authSchema}\".\"schema_meta\"`;\n await db.execute(sql`\n CREATE TABLE IF NOT EXISTS ${sql.raw(qualified)} (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL,\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL\n )\n `);\n await db.execute(sql`\n INSERT INTO ${sql.raw(qualified)} (key, value)\n VALUES (${VERSION_KEY}, ${String(AUTH_SCHEMA_VERSION)})\n ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()\n `);\n}\n\n/** What {@link probeAuthSchema} found. */\nexport interface AuthSchemaProbeResult {\n /** False when this runtime cannot be trusted to serve auth against this database. */\n healthy: boolean;\n /** The stamped version, or `null` on a database that predates stamping. */\n databaseVersion: number | null;\n /** {@link AUTH_SCHEMA_VERSION}. */\n runtimeVersion: number;\n /** Human-readable descriptions of each mismatch found. Empty when healthy. */\n problems: string[];\n}\n\n/**\n * Check that the auth schema is one this runtime can actually write to.\n *\n * Two independent checks, because either alone has a blind spot:\n *\n * - The **stamp** catches a runtime older than the database. It is the precise\n * signal, but it is blind on every database provisioned before stamping\n * existed — which today is all of them.\n * - The **structure** catches a database older than the runtime, and works on\n * unstamped databases. It is what makes this useful immediately rather than\n * one upgrade cycle from now.\n *\n * Never throws: a probe that fails to run reports unhealthy with the reason, so\n * a broken check surfaces as a degraded health response rather than a 500 from\n * the health endpoint itself.\n */\nexport async function probeAuthSchema(\n db: NodePgDatabase,\n authSchema: string\n): Promise<AuthSchemaProbeResult> {\n const problems: string[] = [];\n let databaseVersion: number | null = null;\n\n try {\n databaseVersion = await readAuthSchemaVersion(db, authSchema);\n if (databaseVersion !== null && databaseVersion > AUTH_SCHEMA_VERSION) {\n problems.push(\n `database is at auth schema version ${databaseVersion}, this runtime understands ` +\n `${AUTH_SCHEMA_VERSION} — it was migrated by a newer framework version`\n );\n }\n\n const refreshTokens = `\"${authSchema}\".\"refresh_tokens\"`;\n const present = await db.execute(sql`SELECT to_regclass(${refreshTokens}) IS NOT NULL AS present`);\n if (!(present.rows[0] as { present: boolean } | undefined)?.present) {\n // Not a problem in itself: auth may simply not be configured on this\n // deployment, and the table is created on demand at boot when it is.\n return { healthy: problems.length === 0, databaseVersion, runtimeVersion: AUTH_SCHEMA_VERSION, problems };\n }\n\n const columns = await db.execute(sql`\n SELECT column_name FROM information_schema.columns\n WHERE table_schema = ${authSchema} AND table_name = 'refresh_tokens'\n `);\n const found = new Set((columns.rows as { column_name: string }[]).map(row => row.column_name));\n const missing = REQUIRED_REFRESH_TOKEN_COLUMNS.filter(column => !found.has(column));\n if (missing.length > 0) {\n problems.push(\n `refresh_tokens is missing ${missing.join(\", \")} — the auth migrations have not been ` +\n \"applied to this database, so token rotation will fail\"\n );\n }\n\n const retired = await db.execute(sql`\n SELECT 1 FROM pg_constraint c\n JOIN pg_class t ON t.oid = c.conrelid\n JOIN pg_namespace n ON n.oid = t.relnamespace\n WHERE n.nspname = ${authSchema}\n AND t.relname = 'refresh_tokens'\n AND c.conname = ${RETIRED_REFRESH_TOKEN_CONSTRAINT}\n `);\n if (retired.rows.length > 0) {\n problems.push(\n `refresh_tokens still carries ${RETIRED_REFRESH_TOKEN_CONSTRAINT} — concurrent token ` +\n \"rotation for one session will fail on it\"\n );\n }\n } catch (error: unknown) {\n problems.push(\n `auth schema probe failed: ${error instanceof Error ? error.message : String(error)}`\n );\n }\n\n return {\n healthy: problems.length === 0,\n databaseVersion,\n runtimeVersion: AUTH_SCHEMA_VERSION,\n problems\n };\n}\n","import { sql } from \"drizzle-orm\";\nimport { NodePgDatabase } from \"drizzle-orm/node-postgres\";\nimport { logger } from \"@rebasepro/server\";\nimport { revokeInternalTableAccess } from \"@rebasepro/common\";\nimport type { CollectionConfig } from \"@rebasepro/types\";\nimport { AUTH_USERS_COLUMNS, authUsersColumnSql } from \"../schema/auth-users-columns\";\nimport { RLS_BOOTSTRAP_STATEMENTS } from \"../schema/rls-bootstrap-sql\";\nimport {\n AuthSchemaVersionError,\n assertAuthSchemaCompatible,\n resolveAuthSchema,\n stampAuthSchemaVersion\n} from \"./schema-version\";\n\n\n/**\n * Auto-create auth tables if they don't exist.\n *\n * @param db — Drizzle database instance\n * @param collection — The collection that represents auth users.\n * When omitted, a default `rebase.users` table is created.\n */\nexport async function ensureAuthTablesExist(db: NodePgDatabase, collection?: CollectionConfig): Promise<void> {\n logger.debug(\"🔍 Checking auth tables...\");\n\n // Before anything else, and deliberately outside the catch below: refuse to\n // run against a database that a newer framework version has already\n // migrated. Everything past this point is best-effort by design, which is\n // exactly the wrong posture for an incompatibility that would otherwise\n // surface as a fully booted server failing every login.\n await assertAuthSchemaCompatible(db, resolveAuthSchema(collection));\n\n try {\n // Resolve dynamic user table name and ID type from the collection\n let usersTableName = '\"rebase\".\"users\"';\n let userIdType = \"TEXT\";\n let usersSchema = \"rebase\";\n let resolvedTable = \"users\";\n if (collection) {\n resolvedTable = (\"table\" in collection && typeof collection.table === \"string\")\n ? collection.table\n : collection.slug;\n usersSchema = (\"schema\" in collection && typeof collection.schema === \"string\")\n ? collection.schema\n : \"public\";\n usersTableName = usersSchema === \"public\"\n ? `\"${resolvedTable}\"`\n : `\"${usersSchema}\".\"${resolvedTable}\"`;\n\n // Derive ID column type from collection properties.\n //\n // `\"increment\"`, not `\"autoincrement\"`. The latter was tested for\n // here and exists nowhere in the type system — the union is\n // `boolean | \"manual\" | \"increment\" | string` — so the INTEGER branch\n // was unreachable and an integer-keyed auth collection fell through\n // to TEXT. Introspection below hid it whenever the table already\n // existed; on a database where it did not, this created\n // `id TEXT DEFAULT gen_random_uuid()::text` for a collection that\n // declares a number, and every `uid` foreign key was typed to match\n // the wrong thing.\n const idProp = collection.properties?.id;\n if (idProp) {\n const isId = (\"isId\" in idProp) ? (idProp as Record<string, unknown>).isId : undefined;\n if (isId === \"uuid\") {\n userIdType = \"UUID\";\n } else if (isId === \"increment\") {\n userIdType = \"INTEGER\";\n }\n // Otherwise keep TEXT as default\n }\n }\n\n // Introspect the database to find the actual type of usersTableName's ID column if the table exists\n try {\n const result = await db.execute(sql`\n SELECT data_type \n FROM information_schema.columns \n WHERE table_schema = ${usersSchema} \n AND table_name = ${resolvedTable} \n AND column_name = 'id'\n `);\n if (result && result.rows && result.rows.length > 0) {\n const dbType = String((result.rows[0] as { data_type: string }).data_type).toUpperCase();\n if (dbType === \"UUID\") {\n userIdType = \"UUID\";\n } else if (dbType === \"INTEGER\" || dbType === \"SMALLINT\" || dbType === \"BIGINT\") {\n userIdType = \"INTEGER\";\n } else {\n userIdType = \"TEXT\";\n }\n logger.debug(`✨ Detected ${usersTableName}.id type from database: ${dbType}. Using user_id type: ${userIdType}`);\n }\n } catch (err) {\n // Ignore introspection errors, fallback to derived/default type\n logger.warn(`⚠️ Failed to introspect ${usersTableName}.id type from database, falling back to config type: ${userIdType}`, { error: err });\n }\n\n\n // ── Create schemas (idempotent) ──────────────────────────────────\n if (usersSchema !== \"public\") {\n await db.execute(sql`CREATE SCHEMA IF NOT EXISTS ${sql.raw(usersSchema)}`);\n }\n await db.execute(sql`CREATE SCHEMA IF NOT EXISTS rebase`);\n\n const authSchema = usersSchema === \"public\" ? \"rebase\" : usersSchema;\n const userIdentitiesTable = `\"${authSchema}\".\"user_identities\"`;\n const refreshTokensTableName = `\"${authSchema}\".\"refresh_tokens\"`;\n const passwordResetTokensTableName = `\"${authSchema}\".\"password_reset_tokens\"`;\n const appConfigTableName = `\"${authSchema}\".\"app_config\"`;\n\n // ── Create users table (idempotent) ─────────────────────────────\n // The users table MUST be created before any dependent auth tables\n // (user_identities, refresh_tokens, etc.) because they all hold\n // foreign keys referencing users(id). When a developer runs\n // `pnpm dev` for the first time without `db:migrate`, this ensures\n // the server can self-bootstrap.\n const idDefault = userIdType === \"UUID\"\n ? \"DEFAULT gen_random_uuid()\"\n : userIdType === \"INTEGER\"\n ? \"GENERATED ALWAYS AS IDENTITY\"\n : \"DEFAULT gen_random_uuid()::text\";\n\n // Identifiers for the constraint and indexes reconciled further down.\n // Derived from the resolved table name so two auth tables in different\n // schemas cannot collide, and truncated to Postgres's 63-byte identifier\n // limit here rather than letting the server truncate silently — the\n // `IF NOT EXISTS` guards below have to compare against the same name\n // Postgres actually stored, or they re-run forever.\n const authIdentifier = (suffix: string) => `${resolvedTable}_${suffix}`.slice(0, 63);\n const emailLengthConstraint = `\"${authIdentifier(\"email_length_check\")}\"`;\n const emailLowerUniqueIndex = authIdentifier(\"email_lower_key\");\n const verificationTokenIndex = authIdentifier(\"email_verification_token_idx\");\n\n // Every string column here is TEXT, deliberately. In Postgres VARCHAR(n)\n // and TEXT are the same type with the same storage and the same\n // performance; the only difference is a length check, and none of these\n // columns wants one. The widths this table used to carry were inherited\n // MySQL habit (255) and they were all wrong in the same direction —\n // `password_hash VARCHAR(255)` against a 193-char scrypt string left 62\n // characters of headroom in front of a KEY_LENGTH constant living in\n // another package, and `photo_url VARCHAR(500)` rejected the `data:` URIs\n // and long signed URLs that OAuth providers hand back. A limit worth\n // having is a CHECK — alterable without a table rewrite, unlike a type\n // modifier — which is why `email` has one and nothing else does.\n //\n // The column list comes from AUTH_USERS_COLUMNS rather than being spelled\n // out here, because this is not the only place that creates this table:\n // `db push` and the boot-time collection ensure do too, and when the\n // three lists were maintained separately they disagreed and boot order\n // silently decided which shape the database got. The `email` CHECK is\n // appended rather than listed there — it is a named constraint the\n // migration below has to be able to add separately, `NOT VALID`, to a\n // table that already holds rows.\n const usersColumnDdl = AUTH_USERS_COLUMNS\n .map((spec) => spec.column === \"email\"\n ? `${spec.column} ${authUsersColumnSql(spec)} CONSTRAINT ${emailLengthConstraint} CHECK (length(email) <= 320)`\n : `${spec.column} ${authUsersColumnSql(spec)}`)\n .join(\",\\n \");\n await db.execute(sql`\n CREATE TABLE IF NOT EXISTS ${sql.raw(usersTableName)} (\n id ${sql.raw(userIdType)} PRIMARY KEY ${sql.raw(idDefault)},\n ${sql.raw(usersColumnDdl)}\n )\n `);\n\n // ── Migration: auth FK column user_id → uid, phase 1 (expand) ───────\n // Must run BEFORE the dependent tables below: CREATE TABLE IF NOT\n // EXISTS never revisits an existing table, so a database provisioned\n // before this migration still lacks `uid` — and the\n // CREATE INDEX ... (uid) statements that follow would fail on it.\n //\n // Deliberately NOT a plain RENAME. Both Cloud Run and Kubernetes roll\n // deploys, so old and new pods serve the same database at the same time,\n // and a rollback puts old code back in front of a migrated database. A\n // rename breaks every auth query on whichever side is out of step.\n // Instead: add `uid`, backfill it, drop the NOT NULL on `user_id`, and\n // keep the two in sync with a trigger, so a backend of either era can\n // read and write. `scripts/drop-legacy-auth-user-id.sql` removes the\n // column once no old backend remains (phase 2, contract).\n //\n // Idempotent throughout: every step is guarded on catalogue state.\n const legacyFkTables = [\n \"user_identities\",\n \"refresh_tokens\",\n \"password_reset_tokens\",\n \"magic_link_tokens\",\n \"mfa_factors\",\n \"recovery_codes\"\n ];\n\n // Only on a database that actually carries the legacy column. This whole\n // block is a 0.x compatibility shim, and it used to run unconditionally —\n // so every brand-new database was provisioned with a trigger function\n // written to reconcile a column it can never have, permanently, as part\n // of its first boot. A fresh install should not ship someone else's\n // migration history.\n // The table list is inlined rather than bound: drizzle expands a JS\n // array into a parameter TUPLE — `ANY(($2, $3, …))` — which Postgres\n // rejects, and the thrown error is swallowed by the catch around this\n // whole function, so auth would silently stop provisioning. These are\n // module-level constants, not input.\n const legacyFkTableList = legacyFkTables.map(t => `'${t}'`).join(\", \");\n const legacyUserIdPresent = await db.execute(sql`\n SELECT 1\n FROM information_schema.columns\n WHERE table_schema = ${authSchema}\n AND table_name IN (${sql.raw(legacyFkTableList)})\n AND column_name = 'user_id'\n LIMIT 1\n `);\n\n if (legacyUserIdPresent.rows.length > 0) {\n await db.execute(sql`\n CREATE OR REPLACE FUNCTION ${sql.raw(`\"${authSchema}\"`)}.sync_uid_user_id() RETURNS trigger AS $$\n BEGIN\n IF NEW.uid IS NULL AND NEW.user_id IS NOT NULL THEN\n NEW.uid := NEW.user_id;\n ELSIF NEW.user_id IS NULL AND NEW.uid IS NOT NULL THEN\n NEW.user_id := NEW.uid;\n END IF;\n RETURN NEW;\n END $$ LANGUAGE plpgsql\n `);\n }\n\n for (const authTable of legacyUserIdPresent.rows.length > 0 ? legacyFkTables : []) {\n const qualified = `\"${authSchema}\".\"${authTable}\"`;\n await db.execute(sql`\n DO $$\n DECLARE\n has_legacy boolean;\n has_uid boolean;\n BEGIN\n SELECT\n bool_or(column_name = 'user_id'),\n bool_or(column_name = 'uid')\n INTO has_legacy, has_uid\n FROM information_schema.columns\n WHERE table_schema = ${sql.raw(`'${authSchema}'`)}\n AND table_name = ${sql.raw(`'${authTable}'`)};\n\n -- Table absent, or already uid-only (a fresh install, or\n -- phase 2 already run): nothing to do.\n IF has_legacy IS NOT TRUE THEN\n RETURN;\n END IF;\n\n IF has_uid IS NOT TRUE THEN\n EXECUTE ${sql.raw(`'ALTER TABLE ${qualified} ADD COLUMN uid ${userIdType} REFERENCES ${usersTableName}(id) ON DELETE CASCADE'`)};\n EXECUTE ${sql.raw(`'UPDATE ${qualified} SET uid = user_id WHERE uid IS NULL'`)};\n EXECUTE ${sql.raw(`'CREATE INDEX IF NOT EXISTS idx_${authTable}_uid ON ${qualified}(uid)'`)};\n END IF;\n\n -- New code inserts uid and never user_id, so the legacy\n -- column can no longer be NOT NULL. The trigger below\n -- backfills it, but the constraint is checked first.\n EXECUTE ${sql.raw(`'ALTER TABLE ${qualified} ALTER COLUMN user_id DROP NOT NULL'`)};\n\n EXECUTE ${sql.raw(`'DROP TRIGGER IF EXISTS sync_uid_user_id ON ${qualified}'`)};\n 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()'`)};\n END $$\n `);\n }\n\n // ── Create dependent auth tables (idempotent) ───────────────────\n\n // Create user_identities table\n await db.execute(sql`\n CREATE TABLE IF NOT EXISTS ${sql.raw(userIdentitiesTable)} (\n id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,\n uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,\n provider TEXT NOT NULL,\n provider_id TEXT NOT NULL,\n profile_data JSONB,\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n UNIQUE(provider, provider_id)\n )\n `);\n\n // Create indexes on user_identities\n await db.execute(sql`\n CREATE INDEX IF NOT EXISTS idx_user_identities_user \n ON ${sql.raw(userIdentitiesTable)}(uid)\n `);\n\n\n // Create refresh tokens table. One row per TOKEN, grouped into a\n // sign-in by session_id — deliberately without a uniqueness rule on\n // (uid, user_agent, ip_address); see the schema module for why that\n // constraint had to go.\n await db.execute(sql`\n CREATE TABLE IF NOT EXISTS ${sql.raw(refreshTokensTableName)} (\n id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,\n uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,\n session_id TEXT NOT NULL DEFAULT gen_random_uuid()::text,\n token_hash TEXT NOT NULL UNIQUE,\n expires_at TIMESTAMP WITH TIME ZONE NOT NULL,\n revoked BOOLEAN DEFAULT FALSE NOT NULL,\n rotated_at TIMESTAMP WITH TIME ZONE,\n session_started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,\n user_agent TEXT,\n ip_address TEXT,\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()\n )\n `);\n\n // Create index on token_hash for faster lookups\n await db.execute(sql`\n CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash \n ON ${sql.raw(refreshTokensTableName)}(token_hash)\n `);\n\n // Create index on uid for cleanup operations\n await db.execute(sql`\n CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user \n ON ${sql.raw(refreshTokensTableName)}(uid)\n `);\n\n // Create password reset tokens table\n await db.execute(sql`\n CREATE TABLE IF NOT EXISTS ${sql.raw(passwordResetTokensTableName)} (\n id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,\n uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,\n token_hash TEXT NOT NULL UNIQUE,\n expires_at TIMESTAMP WITH TIME ZONE NOT NULL,\n used_at TIMESTAMP WITH TIME ZONE,\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()\n )\n `);\n\n // Create index on token_hash for password reset lookups\n await db.execute(sql`\n CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_hash \n ON ${sql.raw(passwordResetTokensTableName)}(token_hash)\n `);\n\n // Create index on uid for password reset cleanup\n await db.execute(sql`\n CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user \n ON ${sql.raw(passwordResetTokensTableName)}(uid)\n `);\n\n // Create magic link tokens table\n const magicLinkTokensTableName = `\"${authSchema}\".\"magic_link_tokens\"`;\n await db.execute(sql`\n CREATE TABLE IF NOT EXISTS ${sql.raw(magicLinkTokensTableName)} (\n id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,\n uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,\n token_hash TEXT NOT NULL UNIQUE,\n expires_at TIMESTAMP WITH TIME ZONE NOT NULL,\n used_at TIMESTAMP WITH TIME ZONE,\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()\n )\n `);\n\n // Create index on token_hash for magic link lookups\n await db.execute(sql`\n CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_hash \n ON ${sql.raw(magicLinkTokensTableName)}(token_hash)\n `);\n\n // Create index on uid for magic link cleanup\n await db.execute(sql`\n CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_user \n ON ${sql.raw(magicLinkTokensTableName)}(uid)\n `);\n\n // Create app config table\n await db.execute(sql`\n CREATE TABLE IF NOT EXISTS ${sql.raw(appConfigTableName)} (\n key TEXT PRIMARY KEY,\n value JSONB NOT NULL,\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()\n )\n `);\n\n // The RLS helper functions every generated policy calls. They live in\n // `rebase`, alongside the tables above — Rebase creates exactly one\n // schema in a user's database. Advisory-locked so concurrent HMR\n // reloads cannot race on `CREATE OR REPLACE`.\n //\n // The same statements the migration preamble carries, from the same\n // constant — these definitions being identical across the boot path and\n // the migration stream is the whole point of having them in one place.\n // One call per statement: this handle speaks the extended query\n // protocol, which rejects multi-command strings.\n await db.transaction(async (tx) => {\n await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init'))`);\n for (const statement of RLS_BOOTSTRAP_STATEMENTS) {\n await tx.execute(sql.raw(statement));\n }\n });\n\n // Seed default roles if none exist\n // (no-op: roles are now stored inline on the users table)\n\n // ── Migration: reconcile the full users column set (safe for existing tables) ──\n // CREATE TABLE IF NOT EXISTS never revisits an existing table, so a\n // database provisioned by an older framework era is missing every\n // column added since. Each column the auth services read or write must\n // be back-filled here, or upgraded deployments break on the first\n // statement that references it.\n //\n // `email` is skipped: it has existed since the first era, so it is never\n // the missing one, and `ADD COLUMN … NOT NULL` with no default fails on\n // a table with rows.\n for (const spec of AUTH_USERS_COLUMNS) {\n if (spec.column === \"email\") continue;\n await db.execute(sql`\n ALTER TABLE ${sql.raw(usersTableName)}\n ADD COLUMN IF NOT EXISTS ${sql.raw(`${spec.column} ${authUsersColumnSql(spec)}`)}\n `);\n }\n\n // Which of the columns below the table actually has. An adopted table —\n // one this framework did not create, which the column-name resolution in\n // `services.ts` exists to support — may be missing any of them, and every\n // statement past this point has to tolerate that rather than abort the\n // whole migration block.\n const usersColumns = await db.execute(sql`\n SELECT column_name, data_type, is_nullable, column_default\n FROM information_schema.columns\n WHERE table_schema = ${usersSchema} AND table_name = ${resolvedTable}\n `);\n type UsersColumnRow = {\n column_name: string;\n data_type: string;\n is_nullable: \"YES\" | \"NO\";\n column_default: string | null;\n };\n const usersColumnRows = usersColumns.rows as UsersColumnRow[];\n const usersColumnTypes = new Map(usersColumnRows.map(row => [row.column_name, row.data_type]));\n const usersColumnState = new Map(usersColumnRows.map(row => [row.column_name, row]));\n\n // ── Migration: restore defaults and NOT NULL that another creator dropped ──\n // `ADD COLUMN IF NOT EXISTS` above only creates what is MISSING. A column\n // that exists with the wrong shape stays wrong forever — and until\n // AUTH_USERS_COLUMNS became the single source, that was the normal\n // outcome rather than an edge case: whichever of `db push`, boot-ensure\n // and this function reached the table first decided its constraints, so\n // a managed deploy ended up with a nullable `email`, a `roles` with no\n // `'{}'` default, and an `email_verified` that could be NULL.\n //\n // Ordered DEFAULT → back-fill → SET NOT NULL, because SET NOT NULL is\n // checked against existing rows: without the back-fill it throws on the\n // very databases that need it. `email` can carry no default, so a NULL\n // there is not repairable automatically — say so and leave the column\n // alone rather than inventing an address.\n for (const spec of AUTH_USERS_COLUMNS) {\n const state = usersColumnState.get(spec.column);\n if (!state) continue;\n\n if (spec.default !== undefined && state.column_default === null) {\n await db.execute(sql`\n ALTER TABLE ${sql.raw(usersTableName)}\n ALTER COLUMN ${sql.raw(`\"${spec.column}\"`)} SET DEFAULT ${sql.raw(spec.default)}\n `);\n logger.info(`🔧 Restored the default on ${usersTableName}.${spec.column}`);\n }\n\n if (!spec.notNull || state.is_nullable !== \"YES\") continue;\n\n if (spec.default !== undefined) {\n await db.execute(sql`\n UPDATE ${sql.raw(usersTableName)}\n SET ${sql.raw(`\"${spec.column}\"`)} = ${sql.raw(spec.default)}\n WHERE ${sql.raw(`\"${spec.column}\"`)} IS NULL\n `);\n }\n try {\n await db.execute(sql`\n ALTER TABLE ${sql.raw(usersTableName)}\n ALTER COLUMN ${sql.raw(`\"${spec.column}\"`)} SET NOT NULL\n `);\n logger.info(`🔧 Restored NOT NULL on ${usersTableName}.${spec.column}`);\n } catch (err) {\n logger.warn(\n `⚠️ ${usersTableName}.${spec.column} should be NOT NULL but still holds NULLs, so the ` +\n \"constraint was not applied. Fill or remove those rows and restart: \" +\n (err instanceof Error ? err.message : String(err))\n );\n }\n }\n\n // ── Migration: VARCHAR(n) → TEXT on the users string columns ────────\n // Tables created before the widths came off still carry them. Postgres\n // treats varchar(n) → text as binary-coercible with no stricter\n // constraint, so this is a catalogue-only change: no table rewrite, no\n // index rebuild, just a brief ACCESS EXCLUSIVE lock. Guarded on the\n // current type so it runs once and is a pure catalogue read thereafter.\n for (const column of [\"email\", \"display_name\", \"photo_url\", \"password_hash\", \"email_verification_token\"]) {\n if (usersColumnTypes.get(column) !== \"character varying\") continue;\n await db.execute(sql`\n ALTER TABLE ${sql.raw(usersTableName)}\n ALTER COLUMN ${sql.raw(`\"${column}\"`)} TYPE TEXT\n `);\n logger.info(`🔧 Widened ${usersTableName}.${column} from VARCHAR(n) to TEXT`);\n }\n\n // ── Migration: case-insensitive email identity ──────────────────────\n // `getUserByEmail` has always searched `email.toLowerCase()` while the\n // write path stored whatever it was handed, leaving normalisation to a\n // convention every caller had to remember. A row that reached the table\n // with mixed case is then invisible to every lookup — the account exists,\n // login reports no such user, and the plain UNIQUE on `email` does not\n // stop a second row differing only in case, because it compares bytes.\n //\n // Fixed on both sides: `mapPayload` now folds on write, and this index\n // makes the database agree. A unique index on lower(email) is strictly\n // stronger than the byte-exact UNIQUE that older tables carry, so the\n // old constraint is left alone — it can no longer fire on anything the\n // new one would allow.\n //\n // Deliberately no AUTH_SCHEMA_VERSION bump: a runtime that predates this\n // migration keeps working against the migrated table (all of its own\n // write paths already lower-cased), which is exactly the additive case\n // the version stamp is documented not to cover.\n if (usersColumnTypes.has(\"email\")) {\n const indexPresent = await db.execute(sql`\n SELECT 1 FROM pg_class c\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE n.nspname = ${usersSchema} AND c.relname = ${emailLowerUniqueIndex} AND c.relkind = 'i'\n `);\n if (indexPresent.rows.length === 0) {\n // Case-collisions already in the table would make the unique\n // index impossible to build. Report them and leave the table\n // alone: the next boot retries, so fixing the rows is all the\n // operator has to do. Failing loudly beats folding the emails\n // and letting CREATE INDEX pick which account survives.\n const collisions = await db.execute(sql`\n SELECT lower(email) AS normalized, count(*)::int AS occurrences\n FROM ${sql.raw(usersTableName)}\n WHERE email IS NOT NULL\n GROUP BY lower(email)\n HAVING count(*) > 1\n LIMIT 10\n `);\n if (collisions.rows.length > 0) {\n const sample = (collisions.rows as { normalized: string; occurrences: number }[])\n .map(row => `${row.normalized} (×${row.occurrences})`)\n .join(\", \");\n logger.error(\n `❌ Cannot enforce case-insensitive email uniqueness on ${usersTableName}: ` +\n `these addresses already exist more than once, differing only in case — ${sample}. ` +\n \"Merge or delete the duplicates and restart; until then two accounts can share \" +\n \"one address and only the lower-cased one is reachable by login.\"\n );\n } else {\n const folded = await db.execute(sql`\n UPDATE ${sql.raw(usersTableName)}\n SET email = lower(email)\n WHERE email IS NOT NULL AND email <> lower(email)\n `);\n if (folded.rowCount) {\n logger.info(`🔧 Lower-cased ${folded.rowCount} email address(es) in ${usersTableName}`);\n }\n await db.execute(sql`\n CREATE UNIQUE INDEX IF NOT EXISTS ${sql.raw(`\"${emailLowerUniqueIndex}\"`)}\n ON ${sql.raw(usersTableName)} (lower(email))\n `);\n logger.info(`✅ Email uniqueness on ${usersTableName} is now case-insensitive`);\n }\n }\n }\n\n // ── Migration: bound the email column's length ──────────────────────\n // The only length limit on this table worth keeping. 320 is the RFC 5321\n // maximum (64-char local part + @ + 255-char domain), and it matters here\n // beyond tidiness: `email` carries a btree index, and a sufficiently long\n // value fails index insertion with an error that says nothing about\n // email. NOT VALID so an adopted table with a long row still migrates —\n // it binds all new writes, which is the part that matters.\n if (usersColumnTypes.has(\"email\")) {\n const checkPresent = await db.execute(sql`\n SELECT 1 FROM pg_constraint c\n JOIN pg_class t ON t.oid = c.conrelid\n JOIN pg_namespace n ON n.oid = t.relnamespace\n WHERE n.nspname = ${usersSchema}\n AND t.relname = ${resolvedTable}\n AND c.conname = ${authIdentifier(\"email_length_check\")}\n `);\n if (checkPresent.rows.length === 0) {\n await db.execute(sql`\n ALTER TABLE ${sql.raw(usersTableName)}\n ADD CONSTRAINT ${sql.raw(emailLengthConstraint)} CHECK (length(email) <= 320) NOT VALID\n `);\n }\n }\n\n // ── Index: email verification token lookups ─────────────────────────\n // `getUserByVerificationToken` filters on this column, which had no\n // index — every click of a verification link was a sequential scan of\n // the whole users table. Partial, because the column is NULL for every\n // user who is not mid-verification, which is nearly all of them.\n if (usersColumnTypes.has(\"email_verification_token\")) {\n await db.execute(sql`\n CREATE INDEX IF NOT EXISTS ${sql.raw(`\"${verificationTokenIndex}\"`)}\n ON ${sql.raw(usersTableName)} (email_verification_token)\n WHERE email_verification_token IS NOT NULL\n `);\n }\n\n // ── Migration: refresh_tokens become session-scoped, rotation-safe ──\n // Two shapes are reconciled here, on EVERY table named refresh_tokens\n // in whatever schema it lives (a database provisioned by an older era\n // can carry the table in a different schema than the one this run\n // derives, and auth would then read a table nobody migrated):\n //\n // 1. The new columns. A token is now a member of a session\n // (`session_id`) and is retained after rotation (`revoked`,\n // `rotated_at`) so a replayed token can be recognised instead of\n // looking like a forgery. `session_started_at` is carried across\n // rotations so `users.tokens_valid_after` cannot be outrun.\n // 2. The removal of `unique_device_session`. It made (uid,\n // user_agent, ip_address) the identity of a session, which evicted\n // a second browser profile behind one NAT and churned rows as\n // phones changed networks. UA and IP are metadata now.\n //\n // Every existing row is adopted rather than dropped: it keeps its\n // token_hash, gets a session of its own, and stays unrevoked — so the\n // sessions live in browsers right now survive the upgrade rather than\n // everyone being signed out by the fix for being signed out.\n try {\n const rtTables = await db.execute(sql`\n SELECT table_schema, table_name\n FROM information_schema.tables\n WHERE table_name = 'refresh_tokens'\n `);\n const found = (rtTables.rows as { table_schema: string; table_name: string }[]);\n logger.debug(`🔍 refresh_tokens reconcile: found ${found.length} table(s): ${found.map(r => `\"${r.table_schema}\".\"${r.table_name}\"`).join(\", \") || \"(none)\"}`);\n for (const { table_schema } of found) {\n const qualified = `\"${table_schema}\".\"refresh_tokens\"`;\n try {\n // Added nullable, then back-filled, then constrained: adding\n // `session_id NOT NULL DEFAULT gen_random_uuid()` in one step\n // would stamp every existing row with the SAME uuid on some\n // Postgres versions, silently merging every live session into\n // one that a single logout would then wipe.\n await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_id TEXT`);\n await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS revoked BOOLEAN DEFAULT FALSE NOT NULL`);\n await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS rotated_at TIMESTAMP WITH TIME ZONE`);\n await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_started_at TIMESTAMP WITH TIME ZONE`);\n // Nullable with no default and no back-fill: a row written\n // before this column existed says nothing about whether a\n // second factor was presented, and the reader treats \"says\n // nothing\" as `aal1` — the restrictive answer. Stamping\n // every existing row would be inventing evidence.\n await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS aal TEXT`);\n\n // One session per pre-existing row: under the old model a row\n // WAS a device session, and there is no record of which rows\n // descended from the same sign-in.\n await db.execute(sql`\n UPDATE ${sql.raw(qualified)}\n SET session_id = gen_random_uuid()::text\n WHERE session_id IS NULL\n `);\n await db.execute(sql`\n UPDATE ${sql.raw(qualified)}\n SET session_started_at = COALESCE(created_at, NOW())\n WHERE session_started_at IS NULL\n `);\n await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_id SET DEFAULT gen_random_uuid()::text`);\n await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_started_at SET DEFAULT NOW()`);\n // SET NOT NULL only once the back-fill above has definitely\n // run; on a table that somehow still holds a NULL this throws\n // and is caught below rather than failing the boot.\n await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_id SET NOT NULL`);\n await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_started_at SET NOT NULL`);\n\n await db.execute(sql`\n CREATE INDEX IF NOT EXISTS idx_refresh_tokens_session\n ON ${sql.raw(qualified)}(session_id)\n `);\n\n // The device-session constraint is now actively harmful: two\n // live tokens of one session (a rotation in flight) share a\n // uid, and usually a user agent and IP too.\n await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} DROP CONSTRAINT IF EXISTS unique_device_session`);\n logger.debug(`✅ refresh_tokens reconciled for session-scoped rotation: ${qualified}`);\n } catch (perTableError: unknown) {\n logger.warn(`⚠️ refresh_tokens reconcile failed for ${qualified}: ${perTableError instanceof Error ? perTableError.message : String(perTableError)}`);\n }\n }\n } catch (migrationError: unknown) {\n logger.warn(`⚠️ refresh_tokens session migration skipped: ${migrationError instanceof Error ? migrationError.message : String(migrationError)}`);\n }\n\n // ── Migration: Copy roles from legacy junction table to inline column ──\n // If the old rebase.user_roles and rebase.roles tables exist, migrate\n // the data into the new TEXT[] column then drop the legacy tables.\n try {\n const legacyCheck = await db.execute(sql`\n SELECT EXISTS (\n SELECT 1 FROM information_schema.tables\n WHERE table_schema = 'rebase' AND table_name = 'user_roles'\n ) AS has_user_roles\n `);\n const hasLegacyTables = (legacyCheck.rows[0] as { has_user_roles: boolean }).has_user_roles;\n\n if (hasLegacyTables) {\n logger.info(\"🔄 Migrating roles from legacy user_roles table...\");\n // Update users' roles column from the junction table\n await db.execute(sql`\n UPDATE ${sql.raw(usersTableName)} u\n SET roles = COALESCE((\n SELECT array_agg(ur.role_id)\n FROM \"rebase\".\"user_roles\" ur\n WHERE ur.user_id = u.id\n ), '{}')\n WHERE u.roles = '{}' OR u.roles IS NULL\n `);\n\n // Drop legacy tables (junction first due to FK)\n await db.execute(sql`DROP TABLE IF EXISTS \"rebase\".\"user_roles\" CASCADE`);\n await db.execute(sql`DROP TABLE IF EXISTS \"rebase\".\"roles\" CASCADE`);\n logger.info(\"✅ Legacy roles tables migrated and dropped\");\n }\n } catch (migrationError: unknown) {\n // Non-fatal: log and continue — the column exists and will work\n logger.warn(`⚠️ Legacy roles migration skipped: ${migrationError instanceof Error ? migrationError.message : String(migrationError)}`);\n }\n\n // ── MFA tables ──────────────────────────────────────────────────────\n const mfaFactorsTableName = `\"${authSchema}\".\"mfa_factors\"`;\n const mfaChallengesTableName = `\"${authSchema}\".\"mfa_challenges\"`;\n const recoveryCodesTableName = `\"${authSchema}\".\"recovery_codes\"`;\n\n // Create mfa_factors table\n await db.execute(sql`\n CREATE TABLE IF NOT EXISTS ${sql.raw(mfaFactorsTableName)} (\n id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,\n uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,\n factor_type TEXT NOT NULL DEFAULT 'totp',\n secret_encrypted TEXT NOT NULL,\n friendly_name TEXT,\n verified BOOLEAN DEFAULT FALSE,\n last_used_counter BIGINT,\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()\n )\n `);\n\n // Create indexes on mfa_factors\n await db.execute(sql`\n CREATE INDEX IF NOT EXISTS idx_mfa_factors_user\n ON ${sql.raw(mfaFactorsTableName)}(uid)\n `);\n\n // Create mfa_challenges table\n await db.execute(sql`\n CREATE TABLE IF NOT EXISTS ${sql.raw(mfaChallengesTableName)} (\n id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,\n factor_id TEXT NOT NULL REFERENCES ${sql.raw(mfaFactorsTableName)}(id) ON DELETE CASCADE,\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n verified_at TIMESTAMP WITH TIME ZONE,\n ip_address TEXT,\n attempts INTEGER NOT NULL DEFAULT 0,\n expires_at TIMESTAMP WITH TIME ZONE NOT NULL\n )\n `);\n\n // Create indexes on mfa_challenges\n await db.execute(sql`\n CREATE INDEX IF NOT EXISTS idx_mfa_challenges_factor\n ON ${sql.raw(mfaChallengesTableName)}(factor_id)\n `);\n\n // ── Migration: replay and brute-force state on the MFA tables ───────\n // Both are additive and nullable-or-defaulted, so a runtime that\n // predates them reads the tables unchanged. `last_used_counter` records\n // the TOTP step a factor has already spent (RFC 6238 §5.2); `attempts`\n // bounds how many guesses one challenge will take before it is dead.\n // Without them the code paths degrade to \"no replay protection, rate\n // limiters only\" rather than failing, which is why this is a warn.\n try {\n await db.execute(sql`ALTER TABLE ${sql.raw(mfaFactorsTableName)} ADD COLUMN IF NOT EXISTS last_used_counter BIGINT`);\n await db.execute(sql`ALTER TABLE ${sql.raw(mfaChallengesTableName)} ADD COLUMN IF NOT EXISTS attempts INTEGER NOT NULL DEFAULT 0`);\n } catch (mfaMigrationError: unknown) {\n logger.warn(`⚠️ MFA hardening columns skipped: ${mfaMigrationError instanceof Error ? mfaMigrationError.message : String(mfaMigrationError)}`);\n }\n\n // Create recovery_codes table\n await db.execute(sql`\n CREATE TABLE IF NOT EXISTS ${sql.raw(recoveryCodesTableName)} (\n id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,\n uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,\n code_hash TEXT NOT NULL,\n used_at TIMESTAMP WITH TIME ZONE,\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()\n )\n `);\n\n // Create indexes on recovery_codes\n await db.execute(sql`\n CREATE INDEX IF NOT EXISTS idx_recovery_codes_user\n ON ${sql.raw(recoveryCodesTableName)}(uid)\n `);\n\n // ── Migration: clear stale FORCE ROW LEVEL SECURITY (older RLS model) ──\n // The current model never emits FORCE: privileged auth writes run as\n // the table owner and rely on the owner bypassing plain ENABLE RLS\n // (see generate-postgres-ddl-logic). A table still carrying FORCE from\n // an older framework era binds the owner too, so the first user\n // registration after an upgrade fails with SQLSTATE 42501. Reconcile\n // on boot; only tables actually flagged get the ALTER (and its lock).\n try {\n // Every table this function creates, not a subset. `magic_link_tokens`\n // and `schema_meta` were missing here while their six siblings were\n // listed — so on a database carrying FORCE from the older RLS model,\n // magic-link sign-in kept failing 42501 after the upgrade that was\n // supposed to fix exactly that, and only for the one auth method.\n const authTablePairs: [string, string][] = [\n [usersSchema, resolvedTable],\n [authSchema, \"user_identities\"],\n [authSchema, \"refresh_tokens\"],\n [authSchema, \"password_reset_tokens\"],\n [authSchema, \"magic_link_tokens\"],\n [authSchema, \"app_config\"],\n [authSchema, \"mfa_factors\"],\n [authSchema, \"mfa_challenges\"],\n [authSchema, \"recovery_codes\"],\n [authSchema, \"schema_meta\"]\n ];\n for (const [schemaName, tableName] of authTablePairs) {\n const forced = await db.execute(sql`\n SELECT 1\n FROM pg_class c\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE n.nspname = ${schemaName}\n AND c.relname = ${tableName}\n AND c.relforcerowsecurity\n `);\n if (forced.rows.length > 0) {\n await db.execute(sql`\n ALTER TABLE ${sql.raw(`\"${schemaName}\".\"${tableName}\"`)}\n NO FORCE ROW LEVEL SECURITY\n `);\n logger.warn(\n `🔧 Cleared stale FORCE ROW LEVEL SECURITY on \"${schemaName}\".\"${tableName}\" ` +\n \"(legacy RLS model — it binds the owner connection and breaks privileged auth writes)\"\n );\n }\n }\n } catch (rlsReconcileError: unknown) {\n // Non-fatal: the connection may lack ownership on a pre-provisioned\n // table; registration will still fail loudly (42501) if FORCE remains.\n logger.warn(\n `⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ` +\n `${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`\n );\n }\n\n // Stamped last of the MIGRATIONS, so a boot that died partway through\n // the ones above leaves the older stamp in place and the next boot runs\n // them again.\n await stampAuthSchemaVersion(db, authSchema);\n\n // ── Keep the end-user role out of auth's tables ─────────────────────\n // These carry session token hashes, TOTP secrets and recovery codes, and\n // none of them has RLS — they are not collections, so nothing ever\n // compiled a policy for them. Meanwhile the role provisioning grants\n // `rebase_user` DML on every table in this schema, and its\n // ALTER DEFAULT PRIVILEGES reaches the ones created right here, after it\n // ran. So the grant has to come back off; see `revokeInternalTableSql`\n // for why a revoke rather than an empty RLS policy set.\n //\n // After the stamp deliberately: `schema_meta` is created BY the stamp,\n // so revoking first would leave the one table holding this database's\n // schema version writable by every signed-in user until the next boot.\n // Nothing below re-runs the migrations, so the stamp's guarantee holds.\n await revokeInternalTableAccess(\n async (text) => { await db.execute(sql.raw(text)); },\n authSchema,\n {\n onError: (table, err) => logger.warn(\n `🔐 Could not revoke authenticated-role access to \"${authSchema}\".\"${table}\": ` +\n (err instanceof Error ? err.message : String(err))\n )\n }\n );\n\n logger.debug(\"✅ Auth tables ready\");\n } catch (error) {\n // The one failure that must not be survived. Continuing here is what\n // produced a server that answered /health with 200 while every login\n // returned 500 — the incompatibility is total, so crashing is the\n // kinder outcome: an orchestrator will not route traffic to a pod that\n // never came up.\n if (error instanceof AuthSchemaVersionError) throw error;\n logger.error(\"❌ Failed to create auth tables\", { error });\n logger.warn(\"⚠️ Continuing without creating auth tables.\");\n }\n}\n\n"],"mappings":"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"}
|
|
@@ -15,7 +15,15 @@ export type HistoryEntry = EntityHistoryEntry;
|
|
|
15
15
|
export declare class HistoryService {
|
|
16
16
|
private db;
|
|
17
17
|
retention: HistoryRetentionConfig;
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Whether {@link RECORD_HISTORY_FUNCTION} exists, so an entry recorded
|
|
20
|
+
* inside a write goes on that write's transaction. Without it, entries go
|
|
21
|
+
* through the pool and commit on their own.
|
|
22
|
+
*/
|
|
23
|
+
private readonly inTransaction;
|
|
24
|
+
constructor(db: NodePgDatabase, retention?: Partial<HistoryRetentionConfig>, options?: {
|
|
25
|
+
inTransaction?: boolean;
|
|
26
|
+
});
|
|
19
27
|
/**
|
|
20
28
|
* Record a history entry for a row change.
|
|
21
29
|
*
|
|
@@ -32,7 +40,13 @@ export declare class HistoryService {
|
|
|
32
40
|
* anyway, and nobody found out.
|
|
33
41
|
*
|
|
34
42
|
* So it throws, and the driver awaits it inside the write's transaction.
|
|
35
|
-
* The row and its history entry commit together or neither does
|
|
43
|
+
* The row and its history entry commit together or neither does — which
|
|
44
|
+
* needs the entry written *on* that transaction, not merely while it is
|
|
45
|
+
* open. This service is built once, on the pool, and for a long time it
|
|
46
|
+
* wrote there: a batch that rolled back after its first row left that
|
|
47
|
+
* row's entry behind, describing a row that never existed. Inside a write
|
|
48
|
+
* it now goes through {@link RECORD_HISTORY_FUNCTION} on the write's own
|
|
49
|
+
* transaction (found through `currentWriteScope`). That is a
|
|
36
50
|
* real trade — a broken `rebase.entity_history` now fails writes to the
|
|
37
51
|
* collections that declare history, where before it failed quietly — and it
|
|
38
52
|
* is the right side of it for a table that exists to answer "who changed
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
2
|
+
/** How a history entry gets written from inside the write it records. */
|
|
3
|
+
export declare const RECORD_HISTORY_FUNCTION = "rebase.record_history";
|
|
2
4
|
/**
|
|
3
5
|
* Auto-create the row history table if it doesn't exist.
|
|
4
6
|
* This runs on startup when history is enabled, following the same
|
|
5
7
|
* pattern as `ensureAuthTablesExist`.
|
|
8
|
+
*
|
|
9
|
+
* Resolves `true` when {@link RECORD_HISTORY_FUNCTION} exists, so an entry can
|
|
10
|
+
* be written on the write's own transaction; `false` sends entries through the
|
|
11
|
+
* pool as before, committing on their own.
|
|
6
12
|
*/
|
|
7
|
-
export declare function ensureHistoryTableExists(db: NodePgDatabase): Promise<
|
|
13
|
+
export declare function ensureHistoryTableExists(db: NodePgDatabase): Promise<boolean>;
|