diff --git a/docs/content/docs/concepts/cli.mdx b/docs/content/docs/concepts/cli.mdx index 1e9fed037b..42b4ebdfc9 100644 --- a/docs/content/docs/concepts/cli.mdx +++ b/docs/content/docs/concepts/cli.mdx @@ -43,7 +43,7 @@ npx auth@latest migrate plan npx auth@latest migrate plan --json > better-auth-migration-plan.json ``` -The JSON plan includes the adapter, dialect, sorted schema changes, blockers, and any release migration actions. A blocked plan exits with status `1`. +The JSON plan includes the adapter, dialect, selected and detected account identity strategies, account counts, issuer-resolution coverage, projected collisions, sorted schema changes, blockers, and any release migration actions. A blocked plan exits with status `1`. ### Apply a migration diff --git a/e2e/adapter/test/drizzle-adapter/migration-1-6-to-1-7.test.ts b/e2e/adapter/test/drizzle-adapter/migration-1-6-to-1-7.test.ts index 7fb11e4ba1..73e45426c6 100644 --- a/e2e/adapter/test/drizzle-adapter/migration-1-6-to-1-7.test.ts +++ b/e2e/adapter/test/drizzle-adapter/migration-1-6-to-1-7.test.ts @@ -19,6 +19,7 @@ it("migrates published 1.6.30 accounts and OAuth records through the Drizzle ada }); const currentOptions = { + account: { identityStrategy: "issuer" as const }, baseURL: "http://localhost:3000", emailAndPassword: { enabled: true }, plugins: [ @@ -94,7 +95,7 @@ it("migrates published 1.6.30 accounts and OAuth records through the Drizzle ada } }); -it("migrates Drizzle snake_case account columns without duplicating logical fields", async () => { +it("plans a default provider-scoped upgrade against Drizzle snake_case columns", async () => { const sqlite = new Database(":memory:"); try { sqlite.exec(` @@ -136,38 +137,27 @@ it("migrates Drizzle snake_case account columns without duplicating logical fiel schema, transaction: true, }); - const auth17 = betterAuth({ ...currentOptions, database }); - const inspection = await getMigrations(auth17.options); + const inspection = await getMigrations( + betterAuth({ ...currentOptions, database }).options, + ); - expect(inspection.accountIdentitySchema).toEqual({ - accountIdColumn: "account_id", - issuerColumn: "issuer", - table: "account", - }); - expect(inspection.migrationBlockers).toContainEqual({ - code: "required-column-backfill", - columns: ["issuer"], - table: "account", + expect(inspection.accountIdentity).toMatchObject({ + selectedStrategy: "provider-id", + detectedStrategy: "provider-id", + totalAccounts: 1, }); + expect(inspection.migrationBlockers).not.toContainEqual( + expect.objectContaining({ + code: "account-identity-strategy-mismatch", + }), + ); const accountFields = Object.keys( inspection.toBeAdded.find(({ table }) => table === "account")?.fields ?? {}, ); - expect(accountFields).toContain("issuer"); + expect(accountFields).not.toContain("issuer"); expect(accountFields).not.toContain("account_id"); expect(accountFields).not.toContain("provider_id"); - - await expect(migrateFrom16(auth17.options, {})).resolves.toMatchObject({ - accounts: { - migrated: 1, - providers: { credential: 1 }, - }, - }); - expect( - sqlite - .prepare("SELECT issuer FROM account WHERE id = ?") - .get("account-1"), - ).toEqual({ issuer: "local:credential" }); } finally { sqlite.close(); } diff --git a/e2e/adapter/test/kysely-adapter/migration-1-6-to-1-7.test.ts b/e2e/adapter/test/kysely-adapter/migration-1-6-to-1-7.test.ts index a244058311..65c4978c85 100644 --- a/e2e/adapter/test/kysely-adapter/migration-1-6-to-1-7.test.ts +++ b/e2e/adapter/test/kysely-adapter/migration-1-6-to-1-7.test.ts @@ -256,6 +256,7 @@ async function exerciseAccountAndOrganizationMigration({ await beforeMigrate?.(); const auth17 = betterAuth({ + account: { identityStrategy: "issuer" }, baseURL: "http://localhost:3000", database, emailAndPassword: { @@ -566,6 +567,80 @@ it("migrates users created by published 1.6.30 and authenticates them through 1. } }); +it("preserves a published 1.6.30 provider-scoped account through the 1.7 cutover", { + timeout: 60_000, +}, async () => { + const database = new DatabaseSync(":memory:"); + try { + const auth1630 = betterAuth1630({ + baseURL: "http://localhost:3000", + database, + emailAndPassword: { enabled: true }, + }); + await (await getMigrations1630(auth1630.options)).runMigrations(); + const source = await auth1630.api.signUpEmail({ + body: { + email: "provider-scoped@sqlite.example.com", + name: "Provider Scoped User", + password: "correct-horse-battery-staple", + }, + }); + const sourceContext = await auth1630.$context; + await sourceContext.internalAdapter.linkAccount({ + accountId: "published-google-subject", + providerId: "google", + userId: source.user.id, + }); + + const auth17 = betterAuth({ + account: { identityStrategy: "provider-id" }, + baseURL: "http://localhost:3000", + database, + emailAndPassword: { enabled: true }, + socialProviders: { + google: { + clientId: "google-client", + clientSecret: "google-secret", + }, + }, + }); + const migration = await migrateFrom16(auth17.options, {}); + expect(migration.accounts).toEqual({ + migrated: 0, + providers: {}, + }); + + const signIn = await auth17.api.signInEmail({ + body: { + email: "provider-scoped@sqlite.example.com", + password: "correct-horse-battery-staple", + }, + returnHeaders: true, + }); + const sessionCookie = signIn.headers.getSetCookie()[0]; + if (!sessionCookie) throw new Error("Expected 1.7 to create a session"); + const accounts = await auth17.api.listUserAccounts({ + headers: { cookie: sessionCookie }, + }); + expect(accounts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + providerId: "credential", + }), + expect.objectContaining({ + accountId: "published-google-subject", + providerId: "google", + }), + ]), + ); + expect(accounts.every((account) => account.issuer === undefined)).toBe( + true, + ); + } finally { + database.close(); + } +}); + it("rejects invalid OAuth data before changing the 1.6 database", async () => { const database = new DatabaseSync(":memory:"); try { @@ -599,6 +674,7 @@ it("rejects invalid OAuth data before changing the 1.6 database", async () => { database.exec(`UPDATE oauthApplication SET redirectUrls = ''`); const auth17 = betterAuth({ + account: { identityStrategy: "issuer" }, baseURL: "http://localhost:3000", database, emailAndPassword: { enabled: true }, @@ -705,6 +781,7 @@ it("rolls back the SQLite release migration when schema application is blocked", `); const auth17 = betterAuth({ + account: { identityStrategy: "issuer" }, baseURL: "http://localhost:3000", database, emailAndPassword: { enabled: true }, @@ -903,6 +980,7 @@ it("repairs a MySQL account table an earlier migration filled with empty issuers const auth17 = betterAuth({ baseURL: "http://localhost:3000", database: pool, + account: { identityStrategy: "issuer" }, emailAndPassword: { enabled: true }, }); await expect(migrateFrom16(auth17.options, {})).resolves.toMatchObject({ @@ -981,6 +1059,7 @@ it("ignores a matching account index outside the active PostgreSQL schema", { const auth17 = betterAuth({ baseURL: "http://localhost:3000", database: pool, + account: { identityStrategy: "issuer" }, emailAndPassword: { enabled: true }, }); await expect(migrateFrom16(auth17.options, {})).resolves.toMatchObject({ @@ -1253,6 +1332,7 @@ it("retires published 1.6.30 SCIM credentials with a custom account ID column an fields: { accountId: "externalAccountId", }, + identityStrategy: "issuer", }, baseURL: "http://localhost:3000", database: { @@ -1430,6 +1510,7 @@ it("checkpoints MySQL legacy tables and rolls back interrupted SCIM retirement", }, }; const auth17 = betterAuth({ + account: { identityStrategy: "issuer" }, baseURL: "http://localhost:3000", database: { db: currentDatabase, transaction: true, type: "mysql" }, emailAndPassword: { enabled: true }, diff --git a/e2e/adapter/test/prisma-adapter/migration-1-6-to-1-7.test.ts b/e2e/adapter/test/prisma-adapter/migration-1-6-to-1-7.test.ts index ead57f8e1e..97206bce98 100644 --- a/e2e/adapter/test/prisma-adapter/migration-1-6-to-1-7.test.ts +++ b/e2e/adapter/test/prisma-adapter/migration-1-6-to-1-7.test.ts @@ -49,6 +49,7 @@ it("migrates published 1.6.30 accounts and OAuth records through the Prisma adap adapter: new PrismaBetterSqlite3({ url: `file:${databasePath}` }), }); const auth17 = betterAuth({ + account: { identityStrategy: "issuer" }, baseURL: "http://localhost:3000", database: prismaAdapter(prisma, { provider: "sqlite", diff --git a/packages/better-auth/src/db/get-migration.ts b/packages/better-auth/src/db/get-migration.ts index 1c9ae71554..60633702dd 100644 --- a/packages/better-auth/src/db/get-migration.ts +++ b/packages/better-auth/src/db/get-migration.ts @@ -32,12 +32,14 @@ import { getSchemaFromAuthTables } from "./get-schema"; import type { MigrationDatabase } from "./migration-database"; import { getMigrationDatabase } from "./migration-database"; import type { + AccountIdentityMigrationAssessment, MigrateFrom16Options, ReleaseMigrationBlocker, } from "./release-migration"; import { describeMigrationDecisionBlocker, findReleaseMigrationBlockers, + inspectAccountIdentityMigration, inspectLegacyReleaseDataFrom16, inspectScimAccountsFrom16, migrateAccountIdentityFrom16, @@ -206,13 +208,15 @@ export interface RequiredColumnConstraintBlocker { table: string; } -export interface AccountIdentityMigrationSchema { - accountIdColumn: string; - issuerColumn: string; +export interface AccountIdentityStrategyMismatchBlocker { + code: "account-identity-strategy-mismatch"; + configuredStrategy: "provider-id"; + detectedStrategy: "issuer" | "mixed"; table: string; } export type MigrationBlocker = + | AccountIdentityStrategyMismatchBlocker | IndexColumnBoundsBlocker | RequiredColumnBackfillBlocker | RequiredColumnConstraintBlocker @@ -233,6 +237,9 @@ function isAccountIdentitySchemaBlocker( blocker.index.columns[1] === accountIdColumn ); } + if (blocker.code === "account-identity-strategy-mismatch") { + return false; + } return ( (blocker.code === "required-column-backfill" || blocker.code === "required-column-constraint") && @@ -246,7 +253,7 @@ function isAccountIdentitySchemaBlocker( export function isHandledByMigrationFrom16( config: BetterAuthOptions, blocker: MigrationBlocker, - physicalSchema?: AccountIdentityMigrationSchema | undefined, + accountIdentity?: AccountIdentityMigrationAssessment | undefined, ) { if ( blocker.code === "reprovision-data" || @@ -259,11 +266,13 @@ export function isHandledByMigrationFrom16( const accountSchema = getAuthTables(config).account; return isAccountIdentitySchemaBlocker( blocker, - physicalSchema?.table || accountSchema?.modelName || "account", - physicalSchema?.issuerColumn || + accountIdentity?.physicalSchema?.table || + accountSchema?.modelName || + "account", + accountIdentity?.physicalSchema?.issuerColumn || accountSchema?.fields.issuer?.fieldName || "issuer", - physicalSchema?.accountIdColumn || + accountIdentity?.physicalSchema?.accountIdColumn || accountSchema?.fields.accountId?.fieldName || "accountId", ); @@ -273,6 +282,11 @@ function createMigrationBlockerError(blocker: MigrationBlocker) { if (blocker.code === "index-column-bounds") { return new BetterAuthError(blocker.message); } + if (blocker.code === "account-identity-strategy-mismatch") { + return new BetterAuthError( + `Migration blocked: table "${blocker.table}" already uses ${blocker.detectedStrategy} account identity. Set account.identityStrategy to "issuer" to match the database.`, + ); + } if (blocker.code === "table-data-move") { return new BetterAuthError( `Migration blocked: move rows from retired table "${blocker.sourceTable}" to "${blocker.targetTable}" before applying the schema migration.`, @@ -725,7 +739,7 @@ function describeExistingTableIndexMisfit({ } const columnBackfillGuideUrl = - "https://better-auth.com/docs/guides/1-7-upgrade-guide#account-identity-is-scoped-by-issuer"; + "https://better-auth.com/docs/guides/1-7-upgrade-guide#choose-account-identity-strategy"; /** * Thrown when a migration plan refuses to run or compile because it would @@ -875,18 +889,14 @@ async function getMigrationsWithDatabase( const throwOnUnsafe = inspectionOptions.throwOnUnsafe !== false; const authTables = migrationDatabase.authTables; const betterAuthSchema = getSchemaFromAuthTables(authTables); - const accountIssuer = authTables.account && { - table: authTables.account.modelName, - column: authTables.account.fields.issuer?.fieldName || "issuer", - }; - const accountIdentitySchema = authTables.account - ? { - accountIdColumn: - authTables.account.fields.accountId?.fieldName || "accountId", - issuerColumn: authTables.account.fields.issuer?.fieldName || "issuer", - table: authTables.account.modelName, - } - : undefined; + const accountIssuerField = authTables.account?.fields.issuer; + const accountIssuer = + authTables.account && accountIssuerField + ? { + table: authTables.account.modelName, + column: accountIssuerField.fieldName || "issuer", + } + : undefined; const isAccountIssuerColumn = (table: string, column: string) => table === accountIssuer?.table && column === accountIssuer.column; const logger = createLogger(config.logger); @@ -1002,6 +1012,11 @@ async function getMigrationsWithDatabase( (table) => table.schema === currentSchema, ); } + const accountIdentity = await inspectAccountIdentityMigration( + config, + migrationDatabase, + tableMetadata, + ); const toBeCreated: { table: string; fields: Record; @@ -1018,6 +1033,19 @@ async function getMigrationsWithDatabase( name: string; }[] = []; const migrationBlockers: MigrationBlocker[] = []; + if ( + accountIdentity.selectedStrategy === "provider-id" && + (accountIdentity.detectedStrategy === "issuer" || + accountIdentity.detectedStrategy === "mixed") + ) { + migrationBlockers.push({ + code: "account-identity-strategy-mismatch", + configuredStrategy: "provider-id", + detectedStrategy: accountIdentity.detectedStrategy, + table: + migrationDatabase.inspectionAuthTables.account?.modelName || "account", + }); + } const plannedIndexes = new Map(); migrationBlockers.push( ...(await findReleaseMigrationBlockers({ @@ -1620,7 +1648,7 @@ async function getMigrationsWithDatabase( return compiled.join(";\n\n") + ";"; } return { - accountIdentitySchema, + accountIdentity, migrationTarget: { adapter: adapterId, dialect: dbType, @@ -1680,7 +1708,7 @@ export async function migrateFrom16( !isHandledByMigrationFrom16( config, blocker, - initialMigration.accountIdentitySchema, + initialMigration.accountIdentity, ), ); if (unhandledBlocker) throw createMigrationBlockerError(unhandledBlocker); diff --git a/packages/better-auth/src/db/migration-database.ts b/packages/better-auth/src/db/migration-database.ts index 9c355335a3..d001c0db34 100644 --- a/packages/better-auth/src/db/migration-database.ts +++ b/packages/better-auth/src/db/migration-database.ts @@ -44,6 +44,7 @@ import { getAdapter } from "./adapter-kysely"; export interface MigrationDatabase { adapterId: string; authTables: BetterAuthDBSchema; + inspectionAuthTables: BetterAuthDBSchema; databaseType: KyselyDatabaseType; inTransaction: boolean; introspectIndexes?: DatabaseIndexIntrospector | undefined; @@ -56,6 +57,51 @@ export interface MigrationDatabase { | undefined; } +function getInspectionAuthTables(config: BetterAuthOptions) { + return getAuthTables({ + ...config, + account: { + ...config.account, + identityStrategy: "issuer", + }, + }); +} + +function selectConfiguredPhysicalTables( + configuredTables: BetterAuthDBSchema, + physicalTables: BetterAuthDBSchema, +): BetterAuthDBSchema { + return Object.fromEntries( + Object.entries(configuredTables).map(([schemaKey, configuredTable]) => { + const physicalTable = physicalTables[schemaKey]; + if (!physicalTable) { + throw new BetterAuthError( + `Migration schema could not resolve model "${configuredTable.modelName}".`, + ); + } + const fields = Object.fromEntries( + Object.entries(configuredTable.fields).map(([fieldKey, field]) => { + const physicalField = physicalTable.fields[fieldKey]; + if (!physicalField) { + throw new BetterAuthError( + `Migration schema could not resolve field "${configuredTable.modelName}.${field.fieldName || fieldKey}".`, + ); + } + return [fieldKey, physicalField]; + }), + ); + return [ + schemaKey, + { + ...configuredTable, + modelName: physicalTable.modelName, + fields, + }, + ]; + }), + ); +} + function createMigrationRecordWriter( config: BetterAuthOptions, kysely: Kysely, @@ -178,11 +224,13 @@ function createMigrationDialect( export async function getMigrationDatabase(config: BetterAuthOptions) { const configuredAuthTables = getAuthTables(config); + const inspectionAuthTables = getInspectionAuthTables(config); const directDatabase = await createKyselyAdapter(config); if (directDatabase.kysely && directDatabase.databaseType) { const database: MigrationDatabase = { adapterId: "kysely", authTables: configuredAuthTables, + inspectionAuthTables, databaseType: directDatabase.databaseType, inTransaction: false, introspectIndexes: directDatabase.introspectIndexes, @@ -199,6 +247,7 @@ export async function getMigrationDatabase(config: BetterAuthOptions) { callback({ adapterId: database.adapterId, authTables: database.authTables, + inspectionAuthTables: database.inspectionAuthTables, databaseType: database.databaseType, inTransaction: true, introspectIndexes: database.introspectIndexes, @@ -228,9 +277,13 @@ export async function getMigrationDatabase(config: BetterAuthOptions) { `The ${adapter.id} adapter does not expose a SQL migration connection. Use \`auth generate\` and your database tooling instead.`, ); } - const authTables = migrationConnection.resolvePhysicalSchema - ? await migrationConnection.resolvePhysicalSchema(configuredAuthTables) - : configuredAuthTables; + const physicalInspectionAuthTables = migrationConnection.resolvePhysicalSchema + ? await migrationConnection.resolvePhysicalSchema(inspectionAuthTables) + : inspectionAuthTables; + const authTables = selectConfiguredPhysicalTables( + configuredAuthTables, + physicalInspectionAuthTables, + ); const createDatabase = ( connection: MigrationDatabaseConnection, inTransaction: boolean, @@ -241,6 +294,7 @@ export async function getMigrationDatabase(config: BetterAuthOptions) { const database: MigrationDatabase = { adapterId: adapter.id, authTables, + inspectionAuthTables: physicalInspectionAuthTables, databaseType: connection.dialect, inTransaction, kysely, diff --git a/packages/better-auth/src/db/release-migration.ts b/packages/better-auth/src/db/release-migration.ts index 243871b9a2..88c4bc4a21 100644 --- a/packages/better-auth/src/db/release-migration.ts +++ b/packages/better-auth/src/db/release-migration.ts @@ -291,9 +291,14 @@ function getMigrationDecisionBlockerKey(blocker: MigrationDecisionBlocker) { /** Why a provider's issuer cannot be derived from configuration alone. */ export type UnresolvedIssuerReason = "discovery-issuer" | "dynamic-issuer"; +type ProviderIdentityKind = "external" | "local"; + type ProviderIssuerResolution = - | { issuer: string } - | { unresolved: UnresolvedIssuerReason }; + | { identityKind: ProviderIdentityKind; issuer: string } + | { + identityKind: "external"; + unresolved: UnresolvedIssuerReason; + }; type ProviderIssuerEntry = readonly [string, ProviderIssuerResolution]; @@ -308,7 +313,9 @@ function resolveSocialProviderIssuer( provider: OAuthProvider, ): ProviderIssuerResolution { const issuer = resolveStaticOAuthAccountIssuer(provider); - return issuer ? { issuer } : { unresolved: "dynamic-issuer" }; + return issuer + ? { identityKind: "external", issuer } + : { identityKind: "external", unresolved: "dynamic-issuer" }; } function resolveGenericOAuthIssuers( @@ -323,16 +330,26 @@ function resolveGenericOAuthIssuers( entry as Partial; if (!providerId) return []; if (typeof accountIssuer === "function") { - return [[providerId, { unresolved: "dynamic-issuer" }]]; + return [ + [ + providerId, + { identityKind: "external", unresolved: "dynamic-issuer" }, + ], + ]; } if (accountIssuer === undefined && discoveryUrl) { - return [[providerId, { unresolved: "discovery-issuer" }]]; + return [ + [ + providerId, + { identityKind: "external", unresolved: "discovery-issuer" }, + ], + ]; } const issuer = resolveStaticOAuthAccountIssuer({ accountIssuer, id: providerId, }); - return issuer ? [[providerId, { issuer }]] : []; + return issuer ? [[providerId, { identityKind: "external", issuer }]] : []; }); } @@ -345,24 +362,28 @@ function resolvePluginIssuers( case "generic-oauth": return resolveGenericOAuthIssuers(pluginOptions); case "siwe": - return [["siwe", { issuer: createLocalAccountIssuer("siwe") }]]; + return [ + [ + "siwe", + { + identityKind: "local", + issuer: createLocalAccountIssuer("siwe"), + }, + ], + ]; default: return []; } }); } -/** - * Resolves the issuer every configured provider establishes at runtime, so a - * 1.6 migration only asks for the issuers the configuration cannot produce. - * - * Resolution reads configuration only: no plugin is initialized and no - * discovery document is fetched, so a provider whose issuer arrives with a - * discovery response or a provider response stays unresolved. - */ -export async function resolveConfiguredIssuers( +interface ConfiguredIssuerState extends ConfiguredAccountIssuers { + providerKinds: Record; +} + +async function resolveConfiguredIssuerState( config: BetterAuthOptions, -): Promise { +): Promise { const resolutions = new Map(); for (const [providerId, providerConfig] of Object.entries( config.socialProviders || {}, @@ -386,14 +407,34 @@ export async function resolveConfiguredIssuers( const issuers: Record = { credential: createLocalAccountIssuer("credential"), }; + const providerKinds: Record = { + credential: "local", + }; const unresolvedProviders: Record = {}; for (const [providerId, resolution] of resolutions) { + providerKinds[providerId] = resolution.identityKind; if ("issuer" in resolution) { issuers[providerId] = resolution.issuer; continue; } unresolvedProviders[providerId] = resolution.unresolved; } + return { issuers, providerKinds, unresolvedProviders }; +} + +/** + * Resolves the issuer every configured provider establishes at runtime, so a + * 1.6 migration only asks for the issuers the configuration cannot produce. + * + * Resolution reads configuration only: no plugin is initialized and no + * discovery document is fetched, so a provider whose issuer arrives with a + * discovery response or a provider response stays unresolved. + */ +export async function resolveConfiguredIssuers( + config: BetterAuthOptions, +): Promise { + const { issuers, unresolvedProviders } = + await resolveConfiguredIssuerState(config); return { issuers, unresolvedProviders }; } @@ -402,8 +443,8 @@ async function resolveMigrationAccountIssuers( options: MigrateFrom16Options, accountTable: string, blockers: MigrationDecisionBlocker[] | undefined, + configured: ConfiguredIssuerState, ) { - const configured = await resolveConfiguredIssuers(config); const accountIssuers = { ...configured.issuers }; for (const [providerId, requested] of Object.entries( options.accountIssuers || {}, @@ -531,6 +572,153 @@ interface LegacyAccountIdentityRow { providerId: string; } +export interface AccountIdentityMigrationAssessment { + selectedStrategy: "provider-id" | "issuer"; + detectedStrategy: "empty" | "provider-id" | "issuer" | "mixed"; + physicalSchema?: + | { + accountIdColumn: string; + issuerColumn: string; + table: string; + } + | undefined; + migrationRequired: boolean; + totalAccounts: number; + externalAccounts: number; + automaticIssuerResolution?: { resolved: number; total: number } | undefined; + projectedCollisions: number; + manualReviewProviders: string[]; +} + +/** Inspects the configured account-identity path without changing the database. */ +export async function inspectAccountIdentityMigration( + config: BetterAuthOptions, + database: MigrationDatabase, + existingTables: readonly TableMetadata[], +): Promise { + const accountSchema = database.inspectionAuthTables.account; + const selectedStrategy: AccountIdentityMigrationAssessment["selectedStrategy"] = + config.account?.identityStrategy === "issuer" ? "issuer" : "provider-id"; + const createEmptyAssessment = ( + physicalSchema?: AccountIdentityMigrationAssessment["physicalSchema"], + ): AccountIdentityMigrationAssessment => ({ + selectedStrategy, + detectedStrategy: "empty", + physicalSchema, + migrationRequired: false, + totalAccounts: 0, + externalAccounts: 0, + projectedCollisions: 0, + manualReviewProviders: [], + }); + if (!accountSchema) return createEmptyAssessment(); + const physicalSchema = { + accountIdColumn: accountSchema.fields.accountId?.fieldName || "accountId", + issuerColumn: accountSchema.fields.issuer?.fieldName || "issuer", + table: accountSchema.modelName || "account", + }; + const emptyAssessment = createEmptyAssessment(physicalSchema); + const accountTable = physicalSchema.table; + const tableMetadata = existingTables.find( + (table) => table.name === accountTable, + ); + if (!tableMetadata) return emptyAssessment; + const columns = new Set(tableMetadata.columns.map((column) => column.name)); + const accountIdColumn = physicalSchema.accountIdColumn; + const providerIdColumn = + accountSchema.fields.providerId?.fieldName || "providerId"; + const issuerColumn = physicalSchema.issuerColumn; + if (!columns.has(accountIdColumn) || !columns.has(providerIdColumn)) { + throw new BetterAuthError( + `Account identity inspection could not resolve the physical provider columns on table "${accountTable}".`, + ); + } + const rows = await sql< + Pick< + LegacyAccountIdentityRow, + "issuer" | "providerAccountId" | "providerId" + > + >` + SELECT + ${sql.ref(accountIdColumn)} AS "providerAccountId", + ${sql.ref(providerIdColumn)} AS "providerId", + ${columns.has(issuerColumn) ? sql.ref(issuerColumn) : sql`NULL`} AS "issuer" + FROM ${sql.table(accountTable)} + `.execute(database.kysely); + const accounts = rows.rows; + if (accounts.length === 0) return emptyAssessment; + const accountsWithIssuer = accounts.filter((account) => + Boolean(readStoredIssuer(account.issuer)), + ).length; + const detectedStrategy = + !columns.has(issuerColumn) || accountsWithIssuer === 0 + ? "provider-id" + : accountsWithIssuer === accounts.length + ? "issuer" + : "mixed"; + const configuredIssuers = await resolveConfiguredIssuerState(config); + const isExternal = (providerId: string) => + (configuredIssuers.providerKinds[providerId] ?? + (providerId === "credential" || providerId === "siwe" + ? "local" + : "external")) === "external"; + const externalAccounts = accounts.filter((account) => + isExternal(account.providerId), + ); + const manualReviewProviders = + selectedStrategy === "issuer" + ? [ + ...new Set( + externalAccounts + .filter( + (account) => + !readStoredIssuer(account.issuer) && + !configuredIssuers.issuers[account.providerId], + ) + .map((account) => account.providerId), + ), + ].sort() + : []; + const projectedIdentities = new Set(); + let projectedCollisions = 0; + for (const account of accounts) { + const namespace = + selectedStrategy === "provider-id" + ? account.providerId + : readStoredIssuer(account.issuer) || + configuredIssuers.issuers[account.providerId]; + if (!namespace) continue; + const identity = JSON.stringify([namespace, account.providerAccountId]); + if (projectedIdentities.has(identity)) projectedCollisions += 1; + else projectedIdentities.add(identity); + } + return { + selectedStrategy, + detectedStrategy, + physicalSchema, + migrationRequired: + selectedStrategy === "issuer" + ? detectedStrategy !== "issuer" + : detectedStrategy === "issuer" || detectedStrategy === "mixed", + totalAccounts: accounts.length, + externalAccounts: externalAccounts.length, + ...(selectedStrategy === "issuer" + ? { + automaticIssuerResolution: { + resolved: externalAccounts.filter( + (account) => + Boolean(readStoredIssuer(account.issuer)) || + Boolean(configuredIssuers.issuers[account.providerId]), + ).length, + total: externalAccounts.length, + }, + } + : {}), + projectedCollisions, + manualReviewProviders, + }; +} + function resolveDynamicAccountIssuers({ accounts, blockers, @@ -1681,6 +1869,7 @@ async function inspectAccountIdentityFrom16( migrationDatabase?: MigrationDatabase, blockers?: MigrationDecisionBlocker[], ) { + if (config.account?.identityStrategy !== "issuer") return undefined; const database = migrationDatabase ?? (await getMigrationDatabase(config)); const { authTables, kysely } = database; const accountSchema = authTables.account; @@ -1707,13 +1896,22 @@ async function inspectAccountIdentityFrom16( accountTableMetadata.columns.map((column) => column.name), ); if (!existingColumns.has(accountIdColumn)) return undefined; - + const accountIdentities = await sql` + SELECT + ${sql.ref(idColumn)} AS "id", + ${sql.ref(accountIdColumn)} AS "providerAccountId", + ${existingColumns.has(issuerColumn) ? sql.ref(issuerColumn) : sql`NULL`} AS "issuer", + ${sql.ref(providerIdColumn)} AS "providerId" + FROM ${sql.table(accountTable)} + `.execute(kysely); + const configuredIssuers = await resolveConfiguredIssuerState(config); const { accountIssuers, unresolvedProviders } = await resolveMigrationAccountIssuers( config, options, accountTable, blockers, + configuredIssuers, ); const providerInventory = await sql` @@ -1744,14 +1942,6 @@ async function inspectAccountIdentityFrom16( }); } - const accountIdentities = await sql` - SELECT - ${sql.ref(idColumn)} AS "id", - ${sql.ref(accountIdColumn)} AS "providerAccountId", - ${existingColumns.has(issuerColumn) ? sql.ref(issuerColumn) : sql`NULL`} AS "issuer", - ${sql.ref(providerIdColumn)} AS "providerId" - FROM ${sql.table(accountTable)} - `.execute(kysely); const accountIssuerByAccountId = resolveDynamicAccountIssuers({ accounts: accountIdentities.rows, blockers, @@ -1762,11 +1952,15 @@ async function inspectAccountIdentityFrom16( const projectedIdentities = new Set(); const reportedCollisions = new Set(); for (const account of accountIdentities.rows) { - const issuer = - readStoredIssuer(account.issuer) ?? - (unresolvedProviders[account.providerId] === "dynamic-issuer" - ? accountIssuerByAccountId[account.id] - : accountIssuers[account.providerId]); + const storedIssuer = readStoredIssuer(account.issuer); + const configuredIssuer = accountIssuers[account.providerId]; + let issuer = storedIssuer; + if (!issuer) { + issuer = + unresolvedProviders[account.providerId] === "dynamic-issuer" + ? accountIssuerByAccountId[account.id] + : configuredIssuer; + } if (issuer === null || issuer === undefined) continue; const identityKey = JSON.stringify([issuer, account.providerAccountId]); if (projectedIdentities.has(identityKey)) { @@ -1875,6 +2069,9 @@ export async function migrateAccountIdentityFrom16( options: MigrateFrom16Options, migrationDatabase?: MigrationDatabase, ): Promise { + if (config.account?.identityStrategy !== "issuer") { + return { migrated: 0, providers: {} }; + } const { databaseType, inTransaction, kysely } = migrationDatabase ?? (await getMigrationDatabase(config)); if ( diff --git a/packages/cli/src/commands/migrate.ts b/packages/cli/src/commands/migrate.ts index 87493760a4..5f3ec86e79 100644 --- a/packages/cli/src/commands/migrate.ts +++ b/packages/cli/src/commands/migrate.ts @@ -195,6 +195,23 @@ export function printHumanMigrationPlan( `Target: ${migrationPlan.target.adapter}/${migrationPlan.target.dialect}`, ); console.log(`Status: ${migrationPlan.status}`); + console.log( + `Account identity: ${migrationPlan.accountIdentity.selectedStrategy} (database: ${migrationPlan.accountIdentity.detectedStrategy})`, + ); + console.log( + `Accounts: ${migrationPlan.accountIdentity.totalAccounts} total, ${migrationPlan.accountIdentity.externalAccounts} external`, + ); + if (migrationPlan.accountIdentity.automaticIssuerResolution) { + console.log( + `Automatic issuer resolution: ${migrationPlan.accountIdentity.automaticIssuerResolution.resolved}/${migrationPlan.accountIdentity.automaticIssuerResolution.total}`, + ); + } + console.log( + `Projected collisions: ${migrationPlan.accountIdentity.projectedCollisions}`, + ); + console.log( + `Providers requiring manual issuer review: ${migrationPlan.accountIdentity.manualReviewProviders.join(", ") || "none"}`, + ); console.log( `Blockers: ${migrationPlan.blockers.map(({ code }) => code).join(", ") || "none"}`, ); @@ -285,7 +302,7 @@ export async function migrateAction(opts: unknown) { : yoctoSpinner({ text: "preparing migration..." }).start(); const { - accountIdentitySchema, + accountIdentity, toBeAdded, toBeAddedIndexes, toBeCreated, @@ -303,7 +320,7 @@ export async function migrateAction(opts: unknown) { toBeAddedIndexes.length > 0 || toBeCreated.length > 0; const isReleaseMigrationBlocker = (blocker: MigrationBlocker) => - isHandledByMigrationFrom16(config, blocker, accountIdentitySchema); + isHandledByMigrationFrom16(config, blocker, accountIdentity); let releaseMigrationBlockers = await collectReleaseMigrationBlockers( config, releaseMigrationOptions, @@ -327,6 +344,7 @@ export async function migrateAction(opts: unknown) { } const buildMigrationPlan = () => createMigrationPlan({ + accountIdentity, hasChanges, migrationBlockers: effectiveMigrationBlockers, migrationTarget, @@ -335,6 +353,7 @@ export async function migrateAction(opts: unknown) { actions: describeIrreversibleReleaseActions( legacyReleaseState, decisions, + accountIdentity, ), id: RELEASE_MIGRATION_ID, } @@ -475,6 +494,7 @@ export async function migrateAction(opts: unknown) { for (const action of describeIrreversibleReleaseActions( legacyReleaseState, decisions, + accountIdentity, )) { console.log("->", action); } diff --git a/packages/cli/src/commands/migration-interview.ts b/packages/cli/src/commands/migration-interview.ts index d70ae4c9a6..07085f71a6 100644 --- a/packages/cli/src/commands/migration-interview.ts +++ b/packages/cli/src/commands/migration-interview.ts @@ -142,6 +142,7 @@ async function inventoryScimAccountsToRetire( export function describeIrreversibleReleaseActions( state: LegacyReleaseDataState, decisions: MigrationDecisions | undefined, + accountIdentity: MigrationPlan["accountIdentity"], ): string[] { const actions: string[] = []; if (state.oauthApplication?.rowCount) { @@ -179,9 +180,15 @@ export function describeIrreversibleReleaseActions( if (renamed.length > 0) { actions.push(`rename ${renamed.join(", ")}`); } - actions.push( - "write the 1.7 account identity onto every existing account row", - ); + if ( + accountIdentity.selectedStrategy === "issuer" && + accountIdentity.migrationRequired && + accountIdentity.totalAccounts > 0 + ) { + actions.push( + "write the 1.7 account identity onto every existing account row", + ); + } return actions; } diff --git a/packages/cli/src/commands/migration-plan.test.ts b/packages/cli/src/commands/migration-plan.test.ts new file mode 100644 index 0000000000..e44a0372c0 --- /dev/null +++ b/packages/cli/src/commands/migration-plan.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from "vitest"; +import { printHumanMigrationPlan } from "./migrate"; +import { createMigrationPlan } from "./migration-plan"; + +describe("createMigrationPlan", () => { + it("reports the configured account identity path and mismatch remediation", () => { + const plan = createMigrationPlan({ + accountIdentity: { + selectedStrategy: "provider-id", + detectedStrategy: "issuer", + migrationRequired: true, + totalAccounts: 4, + externalAccounts: 3, + projectedCollisions: 0, + manualReviewProviders: [], + }, + hasChanges: false, + migrationBlockers: [ + { + code: "account-identity-strategy-mismatch", + configuredStrategy: "provider-id", + detectedStrategy: "issuer", + table: "account", + }, + ], + migrationTarget: { adapter: "drizzle", dialect: "postgres" }, + toBeAdded: [], + toBeAddedIndexes: [], + toBeCreated: [], + }); + + expect(plan.status).toBe("blocked"); + expect(plan.accountIdentity.selectedStrategy).toBe("provider-id"); + expect(plan.blockers[0]).toMatchObject({ + code: "account-identity-strategy-mismatch", + remediation: { + summary: + 'Set account.identityStrategy to "issuer" so runtime behavior matches the migrated database, then run the plan again.', + }, + }); + }); + + it("prints issuer readiness and manual-review providers before applying", () => { + const plan = createMigrationPlan({ + accountIdentity: { + selectedStrategy: "issuer", + detectedStrategy: "provider-id", + migrationRequired: true, + totalAccounts: 17, + externalAccounts: 3, + automaticIssuerResolution: { resolved: 2, total: 3 }, + projectedCollisions: 1, + manualReviewProviders: ["custom-oidc"], + }, + hasChanges: true, + migrationBlockers: [], + migrationTarget: { adapter: "prisma", dialect: "postgres" }, + toBeAdded: [], + toBeAddedIndexes: [], + toBeCreated: [], + }); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + printHumanMigrationPlan(plan, [], [], []); + const output = log.mock.calls + .map((parts) => parts.map(String).join(" ")) + .join("\n"); + expect(output).toContain( + "Account identity: issuer (database: provider-id)", + ); + expect(output).toContain("Automatic issuer resolution: 2/3"); + expect(output).toContain("Projected collisions: 1"); + expect(output).toContain( + "Providers requiring manual issuer review: custom-oidc", + ); + expect(output).toContain("No database changes were applied."); + } finally { + log.mockRestore(); + } + }); +}); diff --git a/packages/cli/src/commands/migration-plan.ts b/packages/cli/src/commands/migration-plan.ts index 6f09d04f60..ffe3f9e5c0 100644 --- a/packages/cli/src/commands/migration-plan.ts +++ b/packages/cli/src/commands/migration-plan.ts @@ -17,6 +17,7 @@ interface CreateMigrationPlanInput MigrationInspection, | "migrationBlockers" | "migrationTarget" + | "accountIdentity" | "toBeAdded" | "toBeAddedIndexes" | "toBeCreated" @@ -49,6 +50,7 @@ export type MigrationPlanBlocker = MigrationBlockerDetail & { }; export interface MigrationPlan { + accountIdentity: MigrationInspection["accountIdentity"]; blockers: MigrationPlanBlocker[]; changes: { addColumns: Array<{ columns: string[]; table: string }>; @@ -77,6 +79,8 @@ export interface ReleaseMigrationPlan { /** One actionable sentence naming the blocked table and the work it needs. */ export function describeMigrationBlocker(blocker: MigrationBlockerDetail) { switch (blocker.code) { + case "account-identity-strategy-mismatch": + return `${blocker.table}: the database uses ${blocker.detectedStrategy} account identity, but the configured strategy is ${blocker.configuredStrategy}.`; case "index-column-bounds": case "release-migration-error": return blocker.message; @@ -99,6 +103,8 @@ export function describeMigrationBlocker(blocker: MigrationBlockerDetail) { function summarizeMigrationRemediation(blocker: MigrationBlockerDetail) { switch (blocker.code) { + case "account-identity-strategy-mismatch": + return 'Set account.identityStrategy to "issuer" so runtime behavior matches the migrated database, then run the plan again.'; case "account-identity-collision": return `Merge or remove the duplicate rows in "${blocker.table}" so issuer "${blocker.issuer}" holds provider account id "${blocker.providerAccountId}" once, then migrate again.`; case "account-issuer-conflict": @@ -153,12 +159,14 @@ function summarizeMigrationRemediation(blocker: MigrationBlockerDetail) { function resolveMigrationGuideAnchor(blocker: MigrationBlockerDetail) { switch (blocker.code) { + case "account-identity-strategy-mismatch": + return "choose-account-identity-strategy"; case "account-identity-collision": case "account-issuer-conflict": case "account-issuer-decision-required": case "issuer-conflict": case "issuer-required": - return "account-identity-is-scoped-by-issuer"; + return "choose-account-identity-strategy"; case "reprovision-data": case "scim-decision-required": case "scim-inventory-mismatch": @@ -192,6 +200,7 @@ function getBlockerKey(blocker: MigrationBlockerDetail) { } export function createMigrationPlan({ + accountIdentity, hasChanges, migrationBlockers, migrationTarget, @@ -207,6 +216,7 @@ export function createMigrationPlan({ ]; return { formatVersion: 1, + accountIdentity, target: migrationTarget, status: blockers.length > 0 diff --git a/packages/cli/test/migrate-cli.test.ts b/packages/cli/test/migrate-cli.test.ts index b7489d735e..9fec575717 100644 --- a/packages/cli/test/migrate-cli.test.ts +++ b/packages/cli/test/migrate-cli.test.ts @@ -37,8 +37,8 @@ async function runCli(args: string[], cwd: string) { const projects: string[] = []; -// A pre-1.7 SQLite database: the account table predates the `issuer` column -// and already holds a row, so the guardrail must refuse to add it. +// A pre-1.7 SQLite database whose populated account table must remain +// provider-scoped when the 1.7 strategy is omitted. function createProject() { const cacheDir = path.join( process.cwd(), @@ -85,7 +85,7 @@ export const auth = betterAuth({ `INSERT INTO "account" ("id", "accountId", "providerId", "userId", "createdAt", "updatedAt") VALUES ('a1', 'g-1', 'google', 'u1', '2020-01-01', '2020-01-01')`, ); database.close(); - return { cwd }; + return { cwd, databasePath }; } async function createPublished16Project() { @@ -148,22 +148,30 @@ afterEach(() => { } }); -describe("auth migrate: refusing a destructive column add", () => { - it("exits 1 with a clean refusal and no stack trace", async () => { - const { cwd } = createProject(); +describe("auth migrate: preserving the default account identity", () => { + it("plans a populated 1.6 account table without adding issuer", async () => { + const { cwd, databasePath } = createProject(); const { exitCode, output } = await runCli( - ["migrate", "--config", "auth.ts", "--yes"], + ["migrate", "plan", "--config", "auth.ts", "--json"], cwd, ); - expect(exitCode).toBe(1); - expect(output).toContain( - "Migration blocked. No database changes were applied.", - ); - expect(output).toContain("[issuer-required]"); - expect(output).toContain( - "https://better-auth.com/docs/guides/1-7-upgrade-guide", - ); + expect(exitCode).toBe(0); + expect(JSON.parse(output)).toMatchObject({ + accountIdentity: { + selectedStrategy: "provider-id", + detectedStrategy: "provider-id", + }, + blockers: [], + }); + const database = new Database(databasePath); + expect( + database + .prepare("PRAGMA table_info(account)") + .all() + .map((column) => (column as { name: string }).name), + ).not.toContain("issuer"); + database.close(); expect(output).not.toContain("triggerUncaughtException"); expect(output).not.toContain("node:internal"); expect(output).not.toMatch(/at .*:\d+:\d+/); @@ -171,7 +179,7 @@ describe("auth migrate: refusing a destructive column add", () => { }); describe("auth migrate: upgrading a published 1.6.30 database", () => { - it("plans and applies the adjacent release migration through the built CLI", async () => { + it("keeps the published account schema and sign-in behavior unchanged", async () => { const { baseURL, credentials, cwd, databasePath, secret } = await createPublished16Project(); const sourceDatabase = new Database(databasePath); @@ -189,14 +197,12 @@ describe("auth migrate: upgrading a published 1.6.30 database", () => { ); expect(planned.exitCode).toBe(0); expect(JSON.parse(planned.output)).toMatchObject({ - blockers: [], - releaseMigration: { - actions: [ - "write the 1.7 account identity onto every existing account row", - ], - id: "1.6-to-1.7", + accountIdentity: { + selectedStrategy: "provider-id", + detectedStrategy: "provider-id", }, - status: "ready", + blockers: [], + status: "up-to-date", }); const plannedDatabase = new Database(databasePath); @@ -216,16 +222,18 @@ describe("auth migrate: upgrading a published 1.6.30 database", () => { expect(JSON.parse(applied.output)).toMatchObject({ mode: "apply", plan: { - releaseMigration: { id: "1.6-to-1.7" }, - status: "ready", + status: "up-to-date", }, - status: "applied", + status: "up-to-date", }); const migratedDatabase = new Database(databasePath); expect( - migratedDatabase.prepare("SELECT issuer, providerId FROM account").get(), - ).toEqual({ issuer: "local:credential", providerId: "credential" }); + migratedDatabase + .prepare("PRAGMA table_info(account)") + .all() + .map((column) => (column as { name: string }).name), + ).not.toContain("issuer"); const auth17 = betterAuth({ baseURL, database: migratedDatabase, @@ -243,8 +251,8 @@ describe("auth migrate: upgrading a published 1.6.30 database", () => { }); }); -describe("auth generate: emitting the refused migration with a warning", () => { - it("exits 0 and writes the warning banner alongside the computed statements", async () => { +describe("auth generate: preserving the default account identity", () => { + it("emits no issuer column or destructive warning", async () => { const { cwd } = createProject(); const { exitCode, output } = await runCli( ["generate", "--config", "auth.ts", "--output", "migration.sql", "--yes"], @@ -252,12 +260,10 @@ describe("auth generate: emitting the refused migration with a warning", () => { ); expect(exitCode).toBe(0); - expect(output).toContain("corrupts a populated database"); + expect(output).not.toContain("corrupts a populated database"); const sql = fs.readFileSync(path.join(cwd, "migration.sql"), "utf-8"); - expect(sql).toContain("DO NOT RUN THIS SCRIPT AS IT IS."); - expect(sql.toLowerCase()).toContain( - 'alter table "account" add column "issuer" text not null', - ); + expect(sql).not.toContain("DO NOT RUN THIS SCRIPT AS IT IS."); + expect(sql.toLowerCase()).not.toContain('column "issuer"'); }); }); diff --git a/packages/cli/test/migrate.test.ts b/packages/cli/test/migrate.test.ts index 4f7cc4310e..7bb822d015 100644 --- a/packages/cli/test/migrate.test.ts +++ b/packages/cli/test/migrate.test.ts @@ -317,6 +317,15 @@ describe("migration plan status", () => { */ it("reports pending release work when the schema is already current", () => { const plan = createMigrationPlan({ + accountIdentity: { + selectedStrategy: "provider-id", + detectedStrategy: "provider-id", + migrationRequired: false, + totalAccounts: 1, + externalAccounts: 0, + projectedCollisions: 0, + manualReviewProviders: [], + }, hasChanges: false, migrationBlockers: [], migrationTarget: { adapter: "kysely", dialect: "sqlite" }, @@ -334,6 +343,15 @@ describe("migration plan status", () => { it("prints blocker details and remediation in the human plan", () => { const plan = createMigrationPlan({ + accountIdentity: { + selectedStrategy: "issuer", + detectedStrategy: "provider-id", + migrationRequired: true, + totalAccounts: 1, + externalAccounts: 0, + projectedCollisions: 0, + manualReviewProviders: [], + }, hasChanges: true, migrationBlockers: [ { @@ -383,6 +401,7 @@ describe("migrate published 1.6.30 account data", () => { }); const auth17 = betterAuth({ + account: { identityStrategy: "issuer" }, baseURL: "http://localhost:3000", database: db, plugins: [ @@ -471,6 +490,7 @@ describe("migrate published 1.6.30 account data", () => { expect(sourceAccounts).toHaveLength(2); const auth17 = betterAuth({ + account: { identityStrategy: "issuer" }, baseURL: "http://localhost:3000", database: db, emailAndPassword: { @@ -601,6 +621,7 @@ describe("migrate published 1.6.30 account data", () => { }, }); const auth17 = betterAuth({ + account: { identityStrategy: "issuer" }, baseURL: "http://localhost:3000", database: db, emailAndPassword: { @@ -659,9 +680,11 @@ describe("migrate published 1.6.30 account data", () => { async function createReleaseDecisionFixture( db: Database.Database, { + identityStrategy = "issuer", sourceClientSecretStorage = "plain", targetClientSecretStorage = "hashed", }: { + identityStrategy?: "issuer" | "provider-id" | "unset"; sourceClientSecretStorage?: "encrypted" | "hashed" | "plain"; targetClientSecretStorage?: "encrypted" | "hashed"; } = {}, @@ -740,6 +763,7 @@ async function createReleaseDecisionFixture( }); const options17: BetterAuthOptions = { + ...(identityStrategy !== "unset" && { account: { identityStrategy } }), baseURL: "http://localhost:3000", database: db, emailAndPassword: { @@ -1027,9 +1051,11 @@ describe("migrate command modes", () => { }); describe("plan every unresolved 1.6.30 release decision", () => { - it("reports the missing issuer, consent strategy, and SCIM inventory in one run", async () => { + it("reports the consent strategy and SCIM inventory in one run", async () => { const db = new Database(":memory:"); - const { scimAccountId } = await createReleaseDecisionFixture(db); + const { scimAccountId } = await createReleaseDecisionFixture(db, { + identityStrategy: "unset", + }); const plan = await writeMigrationDecisions({ formatVersion: 1, migration: "1.6-to-1.7", @@ -1052,22 +1078,14 @@ describe("plan every unresolved 1.6.30 release decision", () => { expect(process.exitCode).toBe(1); const jsonPlan = JSON.parse(String(consoleLog.mock.calls[0]?.[0])) as { blockers: Array>; + releaseMigration?: { actions: string[] }; status: string; }; expect(jsonPlan.status).toBe("blocked"); + expect(jsonPlan.releaseMigration?.actions).not.toContain( + "write the 1.7 account identity onto every existing account row", + ); expect(jsonPlan.blockers).toEqual([ - { - accountCount: 1, - code: "issuer-required", - providerId: "workforce-fixture", - reason: "unconfigured-provider", - remediation: { - docs: "https://better-auth.com/docs/guides/1-7-upgrade-guide#account-identity-is-scoped-by-issuer", - summary: - 'Record the issuer for "workforce-fixture" under issuers in better-auth-migration.json, or run `auth migrate apply` in a terminal to answer it there.', - }, - table: "account", - }, { code: "scim-inventory-mismatch", missingAccountIds: [scimAccountId], @@ -1327,7 +1345,7 @@ describe("plan every unresolved 1.6.30 release decision", () => { ' Fix: Remove "credential" from the issuers in better-auth-migration.json to migrate these accounts as "local:credential", or configure the provider to establish "https://credential.example".', ); expect(consoleError).toHaveBeenCalledWith( - " Docs: https://better-auth.com/docs/guides/1-7-upgrade-guide#account-identity-is-scoped-by-issuer", + " Docs: https://better-auth.com/docs/guides/1-7-upgrade-guide#choose-account-identity-strategy", ); expect(consoleError).toHaveBeenCalledWith( `Resolve every blocker above in your 1.6 data, then run \`auth migrate apply ${plan}\` again. Upgrade guide: https://better-auth.com/docs/guides/1-7-upgrade-guide`, @@ -1388,6 +1406,7 @@ async function createResolvableAccountFixture(db: Database.Database) { }; await auth1630.api.signUpEmail({ body: credentials }); const options17: BetterAuthOptions = { + account: { identityStrategy: "issuer" }, baseURL: "http://localhost:3000", database: db, emailAndPassword: { @@ -1687,6 +1706,7 @@ describe("interview the unresolved 1.6.30 release decisions", () => { issuer: "local:credential", }); const auth17 = betterAuth({ + account: { identityStrategy: "issuer" }, baseURL: "http://localhost:3000", database: db, emailAndPassword: { enabled: true }, @@ -1953,6 +1973,7 @@ async function createRenamedLegacyClientFixture(db: Database.Database) { }, }); const options17: BetterAuthOptions = { + account: { identityStrategy: "issuer" }, baseURL: "http://localhost:3000", database: db, emailAndPassword: { @@ -2299,6 +2320,7 @@ describe("migrate published 1.6.30 organization team data", () => { }, }; const auth17 = betterAuth({ + account: { identityStrategy: "issuer" }, baseURL: "http://localhost:3000", database: db, emailAndPassword: {