fix(cli): select account identity migration strategy

This commit is contained in:
Gustavo Valverde
2026-08-25 07:23:34 -04:00
parent 7f772187fe
commit 4c0ec4231c
13 changed files with 637 additions and 139 deletions
+1 -1
View File
@@ -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
@@ -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();
}
@@ -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 },
@@ -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",
+50 -22
View File
@@ -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<string, DBFieldAttribute>;
@@ -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<string, ResolvedDBTableIndex>();
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);
@@ -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<unknown>,
@@ -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,
+229 -32
View File
@@ -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<GenericOAuthConfig>;
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<string, ProviderIdentityKind>;
}
async function resolveConfiguredIssuerState(
config: BetterAuthOptions,
): Promise<ConfiguredAccountIssuers> {
): Promise<ConfiguredIssuerState> {
const resolutions = new Map<string, ProviderIssuerResolution>();
for (const [providerId, providerConfig] of Object.entries(
config.socialProviders || {},
@@ -386,14 +407,34 @@ export async function resolveConfiguredIssuers(
const issuers: Record<string, string> = {
credential: createLocalAccountIssuer("credential"),
};
const providerKinds: Record<string, ProviderIdentityKind> = {
credential: "local",
};
const unresolvedProviders: Record<string, UnresolvedIssuerReason> = {};
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<ConfiguredAccountIssuers> {
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<AccountIdentityMigrationAssessment> {
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<string>();
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<LegacyAccountIdentityRow>`
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<AccountProviderCount>`
@@ -1744,14 +1942,6 @@ async function inspectAccountIdentityFrom16(
});
}
const accountIdentities = await sql<LegacyAccountIdentityRow>`
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<string>();
const reportedCollisions = new Set<string>();
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<MigratedAccountSummary> {
if (config.account?.identityStrategy !== "issuer") {
return { migrated: 0, providers: {} };
}
const { databaseType, inTransaction, kysely } =
migrationDatabase ?? (await getMigrationDatabase(config));
if (
+22 -2
View File
@@ -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);
}
@@ -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;
}
@@ -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();
}
});
});
+11 -1
View File
@@ -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
+41 -35
View File
@@ -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"');
});
});
+37 -15
View File
@@ -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<Record<string, unknown>>;
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: {