diff --git a/.github/workflows/backup-checkpoint-qualification.yml b/.github/workflows/backup-checkpoint-qualification.yml index 3b90802cc13..d325d927e89 100644 --- a/.github/workflows/backup-checkpoint-qualification.yml +++ b/.github/workflows/backup-checkpoint-qualification.yml @@ -46,6 +46,8 @@ jobs: with: node-version: '24' cache: pnpm + - name: Resolve dependency manifests for this run + run: pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile - run: pnpm install --frozen-lockfile --ignore-scripts - name: Build workspace import prerequisites run: | diff --git a/doc/DATABASE.md b/doc/DATABASE.md index c5822df0f02..b231152f6d4 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -1,5 +1,28 @@ # Database +### Better Auth 1.7.16 upgrade + +Better Auth 1.7.3 and later identify accounts by `provider_id` and `account_id`. +They no longer write the `issuer` column that 1.7.0–1.7.2 required. Apply the +schema migration before starting the updated server. It makes `issuer` nullable, +keeps its existing values, removes the old issuer index, and adds a unique index +on the provider/account pair. This permits new credential signups and preserves +existing accounts. + +Before the upgrade, check for duplicate provider/account pairs: + +```sql +SELECT provider_id, account_id, count(*) +FROM account +GROUP BY provider_id, account_id +HAVING count(*) > 1; +``` + +Resolve any duplicates before migration. Keep different users separate. A duplicate +causes index creation to fail rather than leaving an ambiguous login identity. +Do not restore the issuer `NOT NULL` constraint on rollback while new rows have +null issuers. See the [Better Auth upgrade guide](https://better-auth.com/docs/guides/1-7-upgrade-guide). + ### Durable native provider backups `native_harness_backups` journals encrypted provider-session archives in object diff --git a/packages/db/src/better-auth-account-issuer-migration.test.ts b/packages/db/src/better-auth-account-issuer-migration.test.ts index a8264d99ec7..ed260aa9a15 100644 --- a/packages/db/src/better-auth-account-issuer-migration.test.ts +++ b/packages/db/src/better-auth-account-issuer-migration.test.ts @@ -1,17 +1,8 @@ -/** - * The `account.issuer` backfill has to be total. The column lands NOT NULL, so - * a row the UPDATE misses aborts the whole migration and leaves an upgraded - * install with no working auth at all. This suite rewinds the migration, - * seeds the pre-upgrade rows an existing deployment would have, and re-applies - * it — a backfill narrowed to one provider fails here. - */ - -import { createHash, randomUUID } from "node:crypto"; +import { randomUUID } from "node:crypto"; import fs from "node:fs"; import { getTableConfig } from "drizzle-orm/pg-core"; import postgres from "postgres"; import { afterEach, describe, expect, it } from "vitest"; -import { applyPendingMigrations } from "./client.js"; import { authAccounts } from "./schema/auth.js"; import { getEmbeddedPostgresTestSupport, @@ -21,10 +12,6 @@ import { const MIGRATION_FILE = "0230_better_auth_account_issuer.sql"; const UNIQUE_INDEX = "account_issuer_account_id_uq"; -// Better Auth's own issuer helpers: `createLocalAccountIssuer("credential")` -// for email/password and `createOAuthAccountIssuer(providerId)` for a social -// provider that declares no issuer of its own. Sign-in looks the credential -// account up by this exact value. const CREDENTIAL_ISSUER = "local:credential"; const OAUTH_ISSUER_PREFIX = "local:oauth:"; @@ -32,33 +19,32 @@ const cleanups: Array<() => Promise> = []; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; -async function migrationHash() { - const content = await fs.promises.readFile( - new URL(`./migrations/${MIGRATION_FILE}`, import.meta.url), - "utf8", - ); - return createHash("sha256").update(content).digest("hex"); +async function applySqlMigration(sql: postgres.Sql, file: string) { + const content = await fs.promises.readFile(new URL(`./migrations/${file}`, import.meta.url), "utf8"); + await sql.begin(async (transaction) => { + for (const statement of content.split("--> statement-breakpoint")) { + if (statement.trim()) await transaction.unsafe(statement); + } + }); } describe("account issuer schema", () => { - it("declares the column Better Auth requires and the key it indexes on", () => { + it("keeps issuer nullable and identifies accounts by provider and account ID", () => { const config = getTableConfig(authAccounts); const issuer = config.columns.find((column) => column.name === "issuer"); expect(issuer).toBeDefined(); - expect(issuer?.notNull).toBe(true); + expect(issuer?.notNull).toBe(false); - // Better Auth declares `(issuer, accountId)` unique on the account model - // and resolves accounts by that pair. - const unique = config.indexes.find((index) => index.config.name === UNIQUE_INDEX); + const unique = config.indexes.find((index) => index.config.name === "account_provider_account_id_uq"); expect(unique?.config.unique).toBe(true); expect(unique?.config.columns.map((column) => (column as { name: string }).name)).toEqual([ - "issuer", + "provider_id", "account_id", ]); }); }); -describeEmbeddedPostgres("account issuer migration", () => { +describeEmbeddedPostgres("historical issuer migration and patched provider identity upgrade", () => { // Reverse registration order, one at a time. The raw `postgres` client is not // registered with the module's client registry, so the cluster teardown does // not close it. Stopping the cluster while that client is still draining kills @@ -75,8 +61,7 @@ describeEmbeddedPostgres("account issuer migration", () => { const sql = postgres(database.connectionString, { max: 1 }); cleanups.push(async () => sql.end()); - // Rewind to the pre-upgrade shape: no issuer column, no unique index. - await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${await migrationHash()}`; + await sql`DROP INDEX IF EXISTS "account_provider_account_id_uq"`; await sql`DROP INDEX IF EXISTS ${sql(UNIQUE_INDEX)}`; await sql`ALTER TABLE "account" DROP COLUMN IF EXISTS "issuer"`; @@ -101,7 +86,7 @@ describeEmbeddedPostgres("account issuer migration", () => { (${googleAccountId}, 'google-subject-1', 'google', ${socialUserId}, NULL, now(), now()) `; - await applyPendingMigrations(database.connectionString); + await applySqlMigration(sql, MIGRATION_FILE); const rows = await sql<{ id: string; issuer: string }[]>` SELECT "id", "issuer" FROM "account" ORDER BY "id" @@ -133,5 +118,50 @@ describeEmbeddedPostgres("account issuer migration", () => { VALUES (${`account-${randomUUID()}`}, ${CREDENTIAL_ISSUER}, ${credentialUserId}, 'credential', ${credentialUserId}, now(), now()) `, ).rejects.toMatchObject({ code: "23505", constraint_name: UNIQUE_INDEX }); + + await applySqlMigration(sql, "0465_better_auth_account_identity.sql"); + const preserved = await sql`SELECT "id", "issuer" FROM "account" ORDER BY "id"`; + expect(preserved).toEqual(rows); + const [nullable] = await sql` + SELECT "is_nullable" FROM "information_schema"."columns" + WHERE "table_schema" = 'public' AND "table_name" = 'account' AND "column_name" = 'issuer' + `; + expect(nullable?.is_nullable).toBe("YES"); + await expect(sql` + INSERT INTO "account" ("id", "account_id", "provider_id", "user_id", "created_at", "updated_at") + VALUES (${randomUUID()}, ${credentialUserId}, 'credential', ${credentialUserId}, now(), now()) + `).rejects.toMatchObject({ code: "23505", constraint_name: "account_provider_account_id_uq" }); + await sql` + INSERT INTO "account" ("id", "account_id", "provider_id", "user_id", "created_at", "updated_at") + VALUES (${randomUUID()}, 'new-subject', 'credential', ${credentialUserId}, now(), now()) + `; + }, 30_000); +}); + +describeEmbeddedPostgres("provider identity migration with duplicate legacy keys", () => { + it("rejects ambiguous provider/account keys without changing legacy rows", async () => { + const database = await startEmbeddedPostgresTestDatabase("paperclip-account-duplicates-"); + const sql = postgres(database.connectionString, { max: 1 }); + try { + await sql`DROP INDEX "account_provider_account_id_uq"`; + await sql`ALTER TABLE "account" ALTER COLUMN "issuer" SET NOT NULL`; + await sql`CREATE UNIQUE INDEX "account_issuer_account_id_uq" ON "account" ("issuer", "account_id")`; + const userId = randomUUID(); + await sql`INSERT INTO "user" ("id", "name", "email", "created_at", "updated_at") + VALUES (${userId}, 'Legacy User', 'legacy@example.com', now(), now())`; + await sql`INSERT INTO "account" ("id", "issuer", "account_id", "provider_id", "user_id", "created_at", "updated_at") + VALUES (${randomUUID()}, 'issuer-a', 'same-subject', 'credential', ${userId}, now(), now()), + (${randomUUID()}, 'issuer-b', 'same-subject', 'credential', ${userId}, now(), now())`; + const before = await sql`SELECT "id", "issuer" FROM "account" ORDER BY "id"`; + await expect(applySqlMigration(sql, "0465_better_auth_account_identity.sql")) + .rejects.toMatchObject({ code: "23505" }); + expect(await sql`SELECT "id", "issuer" FROM "account" ORDER BY "id"`).toEqual(before); + const [column] = await sql`SELECT "is_nullable" FROM "information_schema"."columns" + WHERE "table_schema" = 'public' AND "table_name" = 'account' AND "column_name" = 'issuer'`; + expect(column?.is_nullable).toBe("NO"); + } finally { + await sql.end(); + await database.cleanup(); + } }, 30_000); }); diff --git a/packages/db/src/migrations/0465_better_auth_account_identity.sql b/packages/db/src/migrations/0465_better_auth_account_identity.sql new file mode 100644 index 00000000000..5145e3b237f --- /dev/null +++ b/packages/db/src/migrations/0465_better_auth_account_identity.sql @@ -0,0 +1,3 @@ +DROP INDEX "account_issuer_account_id_uq";--> statement-breakpoint +ALTER TABLE "account" ALTER COLUMN "issuer" DROP NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "account_provider_account_id_uq" ON "account" USING btree ("provider_id","account_id"); \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0460_snapshot.json b/packages/db/src/migrations/meta/0465_snapshot.json similarity index 99% rename from packages/db/src/migrations/meta/0460_snapshot.json rename to packages/db/src/migrations/meta/0465_snapshot.json index 38297d445c0..951e3c6deea 100644 --- a/packages/db/src/migrations/meta/0460_snapshot.json +++ b/packages/db/src/migrations/meta/0465_snapshot.json @@ -1,6 +1,6 @@ { - "id": "f93ccf18-7e86-4788-be77-b17430c86b2f", - "prevId": "e070677d-cfb8-4438-847c-29c4da7ca8c5", + "id": "38dbf2b9-da26-4c3a-b813-f37d0c19cba2", + "prevId": "e2a678da-05cc-4316-a40e-d85a69be01e3", "version": "7", "dialect": "postgresql", "tables": { @@ -6233,7 +6233,7 @@ "name": "issuer", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, "account_id": { "name": "account_id", @@ -6309,11 +6309,11 @@ } }, "indexes": { - "account_issuer_account_id_uq": { - "name": "account_issuer_account_id_uq", + "account_provider_account_id_uq": { + "name": "account_provider_account_id_uq", "columns": [ { - "expression": "issuer", + "expression": "provider_id", "isExpression": false, "asc": true, "nulls": "last" @@ -43580,6 +43580,28 @@ "method": "btree", "with": {} }, + "issues_company_legacy_origin_run_idx": { + "name": "issues_company_legacy_origin_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"issues\".\"origin_issue_id\" is null and \"issues\".\"origin_run_id\" is not null", + "concurrently": true, + "method": "btree", + "with": {} + }, "issues_company_project_workspace_idx": { "name": "issues_company_project_workspace_idx", "columns": [ @@ -49085,6 +49107,93 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.plugin_lifecycle_resource_cursors": { + "name": "plugin_lifecycle_resource_cursors", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "acknowledged_through_id": { + "name": "acknowledged_through_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "plugin_lifecycle_resource_cursors_plugin_id_plugins_id_fk": { + "name": "plugin_lifecycle_resource_cursors_plugin_id_plugins_id_fk", + "tableFrom": "plugin_lifecycle_resource_cursors", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_lifecycle_resource_cursors_company_id_companies_id_fk": { + "name": "plugin_lifecycle_resource_cursors_company_id_companies_id_fk", + "tableFrom": "plugin_lifecycle_resource_cursors", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_lifecycle_resource_cursors_pk": { + "name": "plugin_lifecycle_resource_cursors_pk", + "columns": [ + "plugin_id", + "company_id", + "resource_type", + "resource_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.plugin_logs": { "name": "plugin_logs", "schema": "", @@ -50949,6 +51058,12 @@ "primaryKey": false, "notNull": false }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "git_authorization_grant_id": { "name": "git_authorization_grant_id", "type": "uuid", @@ -51100,6 +51215,19 @@ "onDelete": "no action", "onUpdate": "no action" }, + "projects_environment_id_environments_id_fk": { + "name": "projects_environment_id_environments_id_fk", + "tableFrom": "projects", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, "projects_git_authorization_grant_id_connection_grants_id_fk": { "name": "projects_git_authorization_grant_id_connection_grants_id_fk", "tableFrom": "projects", @@ -59494,6 +59622,13 @@ "notNull": true, "default": "''" }, + "pending_reveal": { + "name": "pending_reveal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, "allowed_actions": { "name": "allowed_actions", "type": "jsonb", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 1deaa2d7742..efa85154e34 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -3242,6 +3242,13 @@ "when": 1794346237220, "tag": "0464_project_environment", "breakpoints": true + }, + { + "idx": 465, + "version": "7", + "when": 1794400732201, + "tag": "0465_better_auth_account_identity", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/auth.ts b/packages/db/src/schema/auth.ts index 1e82b8a1256..4679accedbb 100644 --- a/packages/db/src/schema/auth.ts +++ b/packages/db/src/schema/auth.ts @@ -21,25 +21,11 @@ export const authSessions = pgTable("session", { userId: text("user_id").notNull().references(() => authUsers.id, { onDelete: "cascade" }), }); -/** - * Better Auth's `account` model. Every column here has to exist for the - * Drizzle adapter to accept the table at all: the adapter validates the - * model's fields against the schema on each write and throws - * `The field "" does not exist in the "account" Drizzle schema` - * when one is missing, which surfaces as a 500 on sign-up. - * - * `issuer` is the account namespace Better Auth 1.7 added. It is - * `local:credential` for email/password accounts and - * `local:oauth:` for social accounts that do not declare an - * issuer of their own, and it pairs with `account_id` as the stable - * provider-side key — hence the unique index, which mirrors the - * `(issuer, accountId)` unique index Better Auth declares on the model. - */ export const authAccounts = pgTable( "account", { id: text("id").primaryKey(), - issuer: text("issuer").notNull(), + issuer: text("issuer"), accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: text("user_id").notNull().references(() => authUsers.id, { onDelete: "cascade" }), @@ -54,7 +40,7 @@ export const authAccounts = pgTable( updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), }, (table) => ({ - issuerAccountIdUq: uniqueIndex("account_issuer_account_id_uq").on(table.issuer, table.accountId), + providerAccountIdUq: uniqueIndex("account_provider_account_id_uq").on(table.providerId, table.accountId), }), ); diff --git a/server/package.json b/server/package.json index f6d4ab6491c..24877a9bd28 100644 --- a/server/package.json +++ b/server/package.json @@ -90,7 +90,7 @@ "ai": "7.0.130", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", - "better-auth": "1.7.2", + "better-auth": "1.7.16", "chat": "4.39.0", "chokidar": "^5.0.0", "compression": "^1.8.2", diff --git a/server/src/__tests__/better-auth-credential-signup.integration.test.ts b/server/src/__tests__/better-auth-credential-signup.integration.test.ts index aaf46716561..f4fd9643e71 100644 --- a/server/src/__tests__/better-auth-credential-signup.integration.test.ts +++ b/server/src/__tests__/better-auth-credential-signup.integration.test.ts @@ -28,11 +28,6 @@ const ORIGIN = "http://127.0.0.1:41999"; const EMAIL = "founder@example.com"; const PASSWORD = "correct-horse-battery-staple"; -// The issuer Better Auth stamps on an email/password account: -// `createLocalAccountIssuer("credential")`. Sign-in matches on it, so a row -// written with anything else is a row nobody can sign in as. -const CREDENTIAL_ISSUER = "local:credential"; - const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -107,7 +102,7 @@ describeEmbeddedPostgres("Better Auth credential sign-up against the real schema expect(accounts).toHaveLength(1); expect(accounts[0]).toMatchObject({ providerId: "credential", - issuer: CREDENTIAL_ISSUER, + issuer: null, }); expect(accounts[0]?.password).toBeTruthy(); @@ -119,5 +114,17 @@ describeEmbeddedPostgres("Better Auth credential sign-up against the real schema expect(signIn.status).toBe(200); expect(signIn.body?.user?.email).toBe(EMAIL); expect(sessionCookies(signIn).length).toBeGreaterThan(0); + + await db.update(authAccounts).set({ issuer: "local:credential" }); + const legacySignIn = await request(app) + .post("/api/auth/sign-in/email") + .set("origin", ORIGIN) + .send({ email: EMAIL, password: PASSWORD }); + expect(legacySignIn.status).toBe(200); + expect(legacySignIn.body?.user?.email).toBe(EMAIL); + + await expect(db.insert(authAccounts).values({ ...accounts[0]!, id: "duplicate-credential" })) + .rejects.toMatchObject({ cause: { code: "23505", constraint_name: "account_provider_account_id_uq" } }); + await db.insert(authAccounts).values({ ...accounts[0]!, id: "other-provider", providerId: "other" }); }); });