From 72c96db528e966f31bae0750ae744c1310cd03c6 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 7 Oct 2026 16:47:39 -0400 Subject: [PATCH 01/23] feat(migrate): opt in to creating unverified identifiers reserved An email or phone the source never verified is attached unverified: the user cannot sign in with it, and another user can claim it. That stays the default. `--reserve-unverified`, or a yes at the prompt a human gets when the file has any, creates them reserved on POST /v1/users instead: usable for sign-in and locked to the user. `-y`, `--json` and agent mode never ask. A reserved identifier also meets an email or phone requirement, so the checks let those users through. Both halves are checked live in the E2E suite against the test app. Co-Authored-By: Claude Opus 5.5 --- .changeset/migrate-reserve-unverified.md | 5 + .../cli-core/src/commands/migrate/README.md | 19 +++- .../src/commands/migrate/import-users.test.ts | 91 +++++++++++++++++++ .../src/commands/migrate/import-users.ts | 80 ++++++++++++++-- .../src/commands/migrate/index.test.ts | 1 + .../cli-core/src/commands/migrate/index.ts | 4 + .../src/commands/migrate/lib/checks.test.ts | 12 +++ .../src/commands/migrate/lib/checks.ts | 23 ++++- .../commands/migrate/run-interactive.test.ts | 60 +++++++++++- packages/cli-core/src/commands/migrate/run.ts | 32 ++++++- test/e2e/migrate.test.ts | 36 ++++++++ 11 files changed, 343 insertions(+), 20 deletions(-) create mode 100644 .changeset/migrate-reserve-unverified.md diff --git a/.changeset/migrate-reserve-unverified.md b/.changeset/migrate-reserve-unverified.md new file mode 100644 index 000000000..c7e902bc1 --- /dev/null +++ b/.changeset/migrate-reserve-unverified.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add `clerk migrate import --reserve-unverified`, which creates the emails and phones a source never verified as reserved (usable for sign-in, locked to the user) instead of unverified. At a terminal, the import asks. `clerk migrate` is still experimental and needs `CLERK_EXPERIMENTAL=migrate`. diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 94f3b7def..8505cb7d7 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -707,6 +707,7 @@ clerk migrate import users.json --source clerk --new-run --yes clerk migrate import users.json --source clerk --json --yes clerk migrate import users.json --source clerk --require-password --yes clerk migrate import users.json --source clerk --skip-legal-checks --yes +clerk migrate import users.json --source workos --reserve-unverified --yes clerk migrate import users.json --source firebase --firebase-signer-key SIGNER_KEY \ --firebase-salt-separator SALT_SEPARATOR --firebase-rounds 8 --firebase-mem-cost 14 --yes clerk migrate import users.json --source clerk --runs-dir ./runs --yes @@ -724,6 +725,7 @@ clerk migrate import # a human is asked | `--new-run` | Start a new run instead of [continuing](#re-running) an earlier one | | `--require-password` | Import only users that carry a password digest | | `--skip-legal-checks` | Import users with no legal acceptance into an instance requiring it | +| `--reserve-unverified` | Create [unverified identifiers](#verified-vs-unverified-identifiers) reserved, not unverified | | `--firebase-signer-key ` | Firebase base64 signer key (overrides the export file), or `CLERK_FIREBASE_SIGNER_KEY` (does not) | | `--firebase-salt-separator ` | Firebase base64 salt separator, or `CLERK_FIREBASE_SALT_SEPARATOR` | | `--firebase-rounds ` | Firebase scrypt rounds, or `CLERK_FIREBASE_ROUNDS` | @@ -856,8 +858,8 @@ after them. They sort the users four ways: not checked - it lacks an identifier the instance requires. An email or phone counts only when it is verified, because an unverified one is attached after the - user exists. A required phone dropped for not being in E.164 form is named - as such + user exists, or with `--reserve-unverified`, which creates it reserved. A + required phone dropped for not being in E.164 form is named as such - it has no identifier left once the emails and phones Clerk would refuse are stripped: those of an instance that neither has them on nor signs in or does MFA with them. A username is kept, as Clerk stores it with @@ -1367,6 +1369,19 @@ unconfirmed address there would silently promote it. A Clerk export keeps an unverified primary email or phone unverified. +**Unverified or reserved.** By default an unverified identifier is attached +after the user exists (`POST /v1/email_addresses`, `verified: false`). The user +cannot sign in with it, and another user can claim it by verifying it first. +`--reserve-unverified`, or a yes at the prompt a human gets when the file has +any, creates them **reserved** instead, on `POST /v1/users` through +`email_address_identification_status` / `phone_number_identification_status`. +A reserved identifier is unverified, but usable for sign-in and locked to the +user, and becomes verified the first time the user signs in with it. That is +how most source platforms treat an unconfirmed address, but it lets the user +sign in with one nobody proved they own, so it is opt-in. `-y`, `--json` and +agent mode never ask, and keep them unverified without the flag. A continued +run uses whichever the flag or answer says that time. + ### Firebase hash parameters Firebase uses a modified scrypt, so Clerk needs the project's four parameters diff --git a/packages/cli-core/src/commands/migrate/import-users.test.ts b/packages/cli-core/src/commands/migrate/import-users.test.ts index 9a0af5d24..0feb1d881 100644 --- a/packages/cli-core/src/commands/migrate/import-users.test.ts +++ b/packages/cli-core/src/commands/migrate/import-users.test.ts @@ -84,6 +84,42 @@ describe("buildCreateUserBody", () => { ]); }); + // Only the create can make a reserved identifier, so it goes here, after the + // verified primary, with a status per address in the same order. + test("sends unverified identifiers reserved when asked, in order", () => { + const target = user({ + email: "a@x.dev", + unverifiedEmailAddresses: ["c@x.dev"], + unverifiedPhoneNumbers: ["+15555550101"], + }); + expect(buildCreateUserBody(target, splitIdentifiers(target), true, true)).toMatchObject({ + email_address: ["a@x.dev", "c@x.dev"], + email_address_identification_status: ["verified", "reserved"], + phone_number: ["+15555550101"], + phone_number_identification_status: ["reserved"], + }); + }); + + test("makes a reserved email the primary when there is no verified one", () => { + const target = user({ email: undefined, unverifiedEmailAddresses: ["c@x.dev"] }); + expect(buildCreateUserBody(target, splitIdentifiers(target), true, true)).toMatchObject({ + email_address: ["c@x.dev"], + email_address_identification_status: ["reserved"], + }); + }); + + test("sends no status arrays by default, or with nothing to reserve", () => { + const unverified = user({ unverifiedEmailAddresses: ["c@x.dev"] }); + const body = buildCreateUserBody(unverified, splitIdentifiers(unverified), true); + expect(body.email_address).toEqual(["a@x.dev"]); + expect(body).not.toHaveProperty("email_address_identification_status"); + + const verified = user(); + expect( + buildCreateUserBody(verified, splitIdentifiers(verified), true, true), + ).not.toHaveProperty("email_address_identification_status"); + }); + // Allowlists and blocklists police sign-ups; these users already signed up. test("skips the instance's sign-up restrictions", () => { expect(buildCreateUserBody(user(), splitIdentifiers(user()), true)).toMatchObject({ @@ -333,6 +369,29 @@ describe("importUsers", () => { expect(requests.filter((r) => r.url.endsWith("/v1/phone_numbers"))).toHaveLength(1); }); + test("creates unverified identifiers reserved rather than attaching them", async () => { + stub(() => ok("user_created")); + + await importUsers({ + users: [user({ email: ["a@x.dev", "b@x.dev"], unverifiedEmailAddresses: ["c@x.dev"] })], + secretKey: "sk_test_x", + limits: LIMITS, + record, + reserveUnverified: true, + }); + + expect(requests.find((r) => r.url.endsWith("/v1/users"))?.body).toMatchObject({ + email_address: ["a@x.dev", "c@x.dev"], + email_address_identification_status: ["verified", "reserved"], + }); + expect( + requests.filter((r) => r.url.endsWith("/v1/email_addresses")).map((r) => r.body), + ).toEqual([ + { user_id: "user_created", email_address: "b@x.dev", primary: false, verified: true }, + ]); + expect(lines.at(-1)).not.toHaveProperty("pending"); + }); + test("marks a user whose password the source dropped", async () => { stub(() => ok("user_created")); @@ -604,6 +663,38 @@ describe("importUsers", () => { ); }); + test("drops a refused phone's statuses with it", async () => { + stub((url, attempt) => + url.endsWith("/v1/users") && attempt === 1 + ? new Response( + JSON.stringify({ + errors: [ + { + code: "form_param_format_invalid", + message: "bad phone", + meta: { param_name: "phone_number" }, + }, + ], + }), + { status: 422 }, + ) + : ok("user_created"), + ); + + await importUsers({ + users: [user({ unverifiedPhoneNumbers: ["+31612345678"] })], + secretKey: "sk_test_x", + limits: LIMITS, + record, + reserveUnverified: true, + }); + + expect(requests[0]?.body).toHaveProperty("phone_number_identification_status"); + expect(requests[1]?.body).not.toHaveProperty("phone_number"); + expect(requests[1]?.body).not.toHaveProperty("phone_number_identification_status"); + expect(lines.at(-1)?.error).toContain("Failed to add phone +31612345678"); + }); + test("does not retry without the phone when it is the only identifier", async () => { stub( () => diff --git a/packages/cli-core/src/commands/migrate/import-users.ts b/packages/cli-core/src/commands/migrate/import-users.ts index 67c37867b..94e0fc14e 100644 --- a/packages/cli-core/src/commands/migrate/import-users.ts +++ b/packages/cli-core/src/commands/migrate/import-users.ts @@ -75,23 +75,59 @@ export function splitIdentifiers(user: User): Identifiers { }; } +/** + * The values and parallel statuses for one identifier kind on `POST /v1/users`. + * + * A reserved identifier is unverified, but usable for sign-in and locked to + * this user. Only the create can make one, so with `reserve` the unverified + * identifiers go here instead of being attached afterwards. + */ +function createIdentifiers( + verified: string | undefined, + unverified: string[], + reserve: boolean, +): { values: string[]; statuses?: string[] } { + const values = [...(verified ? [verified] : []), ...(reserve ? unverified : [])]; + if (!reserve || unverified.length === 0) return { values }; + return { + values, + statuses: values.map((_, index) => (verified && index === 0 ? "verified" : "reserved")), + }; +} + /** * Builds the `POST /v1/users` request body. * * Optional fields are omitted rather than sent as null so Clerk applies its own * defaults for anything the source platform did not record. + * + * @param reserveUnverified - Create the identifiers the source never verified + * as reserved, in this request, rather than attaching them unverified. */ export function buildCreateUserBody( user: User, identifiers: Identifiers, skipPasswordRequirement: boolean, + reserveUnverified = false, ): Record { // The instance's allowlist, blocklist, disposable-email and subaddress rules // police sign-ups. These users already signed up, on the source platform. const body: Record = { external_id: user.userId, skip_restriction_checks: true }; - if (identifiers.primaryEmail) body.email_address = [identifiers.primaryEmail]; - if (identifiers.primaryPhone) body.phone_number = [identifiers.primaryPhone]; + const emails = createIdentifiers( + identifiers.primaryEmail, + identifiers.unverifiedEmails, + reserveUnverified, + ); + if (emails.values.length > 0) body.email_address = emails.values; + if (emails.statuses) body.email_address_identification_status = emails.statuses; + const phones = createIdentifiers( + identifiers.primaryPhone, + identifiers.unverifiedPhones, + reserveUnverified, + ); + if (phones.values.length > 0) body.phone_number = phones.values; + if (phones.statuses) body.phone_number_identification_status = phones.statuses; if (user.firstName) body.first_name = user.firstName; if (user.lastName) body.last_name = user.lastName; if (user.username) body.username = user.username; @@ -137,6 +173,8 @@ type CreateContext = { halt: AbortSignal; /** Aborted by a Ctrl-C, the quota or a halt. No create goes out after it. */ stop: AbortSignal; + /** Unverified identifiers go on the create as reserved, not attached after. */ + reserveUnverified: boolean; }; /** A request a stop kept from going out: neither a failure nor unknown. */ @@ -153,8 +191,19 @@ export function outcomeUnknown(error: unknown): boolean { return true; } -/** The extra identifiers a user carries, in the order they are attached. */ -export function pendingIdentifiers(identifiers: Identifiers): PendingIdentifier[] { +/** + * The extra identifiers a user carries, in the order they are attached. + * + * @param reserveUnverified - The unverified ones went on the create as + * reserved, so there is nothing to attach for them. + */ +export function pendingIdentifiers( + identifiers: Identifiers, + reserveUnverified = false, +): PendingIdentifier[] { + if (reserveUnverified) { + identifiers = { ...identifiers, unverifiedEmails: [], unverifiedPhones: [] }; + } return [ ...identifiers.additionalEmails.map((value) => ({ kind: "email" as const, @@ -276,7 +325,12 @@ async function createUser( { stop: ctx.stop }, ); - const body = buildCreateUserBody(user, identifiers, skipPasswordRequirement); + const body = buildCreateUserBody( + user, + identifiers, + skipPasswordRequirement, + ctx.reserveUnverified, + ); // The run's marker, with whatever private metadata the source brought: // without it a create cut off mid-flight could never be told from a user // someone else made with the same external_id. It replaces a marker the @@ -301,11 +355,15 @@ async function createUser( error instanceof BapiError && (error.code === "unsupported_country_code" || (error.code === "form_param_format_invalid" && error.meta?.param_name === "phone_number")); - if (!phoneRefused || !identifiers.primaryEmail) throw error; - const { phone_number: _dropped, ...withoutPhone } = body; + if (!phoneRefused || !body.email_address) throw error; + const { + phone_number: dropped, + phone_number_identification_status: _statuses, + ...withoutPhone + } = body; response = await create(withoutPhone); phoneRefusal = error.longMessage ?? error.message; - notes.push(`Failed to add phone ${identifiers.primaryPhone}: ${phoneRefusal}`); + notes.push(`Failed to add phone ${(dropped as string[]).join(", ")}: ${phoneRefusal}`); } // Untracked, the user could never be undone. Thrown, the outcome is unknown, @@ -338,6 +396,8 @@ export type ImportUsersOptions = { adopted?: Map; /** Allow users that carry no password. */ skipPasswordRequirement?: boolean; + /** Create the identifiers the source never verified as reserved. */ + reserveUnverified?: boolean; /** Carried into the summary so the report covers the whole file. */ validationFailed?: number; /** Receives the counts as each user finishes. */ @@ -361,6 +421,7 @@ export async function importUsers(options: ImportUsersOptions): Promise(), skipPasswordRequirement = true, + reserveUnverified = false, validationFailed = 0, progress: report, } = options; @@ -396,6 +457,7 @@ export async function importUsers(options: ImportUsersOptions): Promise 0 ? { ...line, pending: toAttach } : line); if (created.phoneRefusal) { const reason = normalizeErrorMessage(created.phoneRefusal); diff --git a/packages/cli-core/src/commands/migrate/index.test.ts b/packages/cli-core/src/commands/migrate/index.test.ts index d3e5857c8..2426b1cfa 100644 --- a/packages/cli-core/src/commands/migrate/index.test.ts +++ b/packages/cli-core/src/commands/migrate/index.test.ts @@ -55,6 +55,7 @@ describe("registerMigrate", () => { "--new-run", "--require-password", "--skip-legal-checks", + "--reserve-unverified", "--firebase-signer-key", "--firebase-salt-separator", "--firebase-rounds", diff --git a/packages/cli-core/src/commands/migrate/index.ts b/packages/cli-core/src/commands/migrate/index.ts index ffc826d7f..f03b8fda7 100644 --- a/packages/cli-core/src/commands/migrate/index.ts +++ b/packages/cli-core/src/commands/migrate/index.ts @@ -95,6 +95,10 @@ export function registerMigrate(program: Program, env: NodeJS.ProcessEnv = proce "--skip-legal-checks", "Import users with no legal acceptance on record into an instance that requires it", ) + .option( + "--reserve-unverified", + "Create emails and phones the source never verified as reserved (usable for sign-in, locked to the user) instead of unverified", + ) .option( "--firebase-signer-key ", "Firebase base64 signer key (overrides the export file), or CLERK_FIREBASE_SIGNER_KEY (does not)", diff --git a/packages/cli-core/src/commands/migrate/lib/checks.test.ts b/packages/cli-core/src/commands/migrate/lib/checks.test.ts index a479c9ec8..cd078ef17 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.test.ts @@ -272,6 +272,18 @@ describe("rejects", () => { }); }); + // Reserved is usable for sign-in, so it meets the requirement (checked live + // against the E2E test app, which requires an email). + test("a reserved email meets an email requirement", async () => { + expect( + await reasonsOf({ + settings: EMAIL_REQUIRED, + reserveUnverified: true, + users: [user("unverified", { email: undefined, unverifiedEmailAddresses: ["u@x.dev"] })], + }), + ).toEqual({}); + }); + test("a password that is not the shape its hasher says", async () => { expect( await reasonsOf({ diff --git a/packages/cli-core/src/commands/migrate/lib/checks.ts b/packages/cli-core/src/commands/migrate/lib/checks.ts index 82b3ae1d3..d2c9f0c2b 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.ts @@ -128,6 +128,8 @@ export type CheckInput = { * sending `skip_legal_checks`. Without it they are rejected. */ skipLegalChecks?: boolean; + /** Unverified identifiers are created reserved, so they meet a requirement. */ + reserveUnverified?: boolean; /** * Clerk IDs a continued run found behind its own in-flight creates: finding * them in the instance is expected. @@ -194,21 +196,32 @@ export function hashShapeProblem(password: string, hasher: string): string | und * * An email or phone counts only when it is verified: an unverified one is * attached after the user exists, so it cannot satisfy a sign-up requirement. + * With `reserveUnverified` it goes on the create as reserved, which does. */ function missingRequiredIdentifier( user: User, settings: UserSettingsJSON | null, + reserveUnverified = false, ): string | undefined { if (!settings) return undefined; const required = (attribute: AttributeName) => isRequired(settings, attribute); const identifiers = splitIdentifiers(user); + const reserved = (unverified: string[]) => reserveUnverified && unverified.length > 0; - if (required("email_address") && !identifiers.primaryEmail) { + if ( + required("email_address") && + !identifiers.primaryEmail && + !reserved(identifiers.unverifiedEmails) + ) { return identifiers.unverifiedEmails.length > 0 ? "only has an unverified email, and this instance requires an email" : "no email, which this instance requires"; } - if (required("phone_number") && !identifiers.primaryPhone) { + if ( + required("phone_number") && + !identifiers.primaryPhone && + !reserved(identifiers.unverifiedPhones) + ) { return identifiers.unverifiedPhones.length > 0 ? "only has an unverified phone number, and this instance requires one" : "no phone number, which this instance requires"; @@ -1075,11 +1088,11 @@ export async function checkImport(input: CheckInput): Promise { : undefined) ?? // A required phone the file has, but only in a form Clerk refuses here. (phoned.dropped && - missingRequiredIdentifier(user, input.settings) && - !missingRequiredIdentifier(emailed, input.settings) + missingRequiredIdentifier(user, input.settings, input.reserveUnverified) && + !missingRequiredIdentifier(emailed, input.settings, input.reserveUnverified) ? "its phones aren't in E.164 form, and this instance requires a phone number (numeric usernames are on)" : undefined) ?? - missingRequiredIdentifier(user, input.settings) ?? + missingRequiredIdentifier(user, input.settings, input.reserveUnverified) ?? // Stripping the identifiers the instance has off can leave nothing to // sign in with; Clerk would still create the user. (!hasAnyIdentifier(sent) diff --git a/packages/cli-core/src/commands/migrate/run-interactive.test.ts b/packages/cli-core/src/commands/migrate/run-interactive.test.ts index 34b985a6a..d888930b1 100644 --- a/packages/cli-core/src/commands/migrate/run-interactive.test.ts +++ b/packages/cli-core/src/commands/migrate/run-interactive.test.ts @@ -30,6 +30,8 @@ const mockPassword = mock(async () => "" as unknown); let confirmAnswer = true; /** User settings the instance serves, or none (the settings read fails). */ let instanceSettings: Record | undefined; +/** The answer to the reserved-identifiers question, apart from consent. */ +let reserveAnswer = true; /** Every confirmation the run put up, in order — the wording is the assertion. */ let confirmMessages: string[] = []; let originalMode: string | undefined; @@ -44,7 +46,7 @@ mock.module("../../lib/listage.ts", () => ({ mock.module("../../lib/prompts.ts", () => ({ confirm: async ({ message }: { message: string }) => { confirmMessages.push(message); - return confirmAnswer; + return message.includes("never verified") ? reserveAnswer : confirmAnswer; }, multiselect: async () => [], text: (...args: unknown[]) => mockText(...(args as [])), @@ -66,7 +68,7 @@ let workDir: string; let configDir: string; let originalCwd: string; let originalFetch: typeof globalThis.fetch; -let requests: { method: string; url: string }[]; +let requests: { method: string; url: string; body: unknown }[]; const EXPORT = [ { id: "u1", primary_email_address: "a@x.dev" }, @@ -102,6 +104,7 @@ beforeEach(() => { requests = []; confirmAnswer = true; instanceSettings = undefined; + reserveAnswer = true; confirmMessages = []; mockSelect.mockReset(); mockText.mockReset(); @@ -114,7 +117,11 @@ beforeEach(() => { globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { const url = new URL(input.toString()); const method = init?.method ?? "GET"; - requests.push({ method, url: url.toString() }); + requests.push({ + method, + url: url.toString(), + body: init?.body ? JSON.parse(init.body as string) : null, + }); if (url.pathname === "/v1/instance") { return Response.json({ object: "instance", id: "ins_1", environment_type: "development" }); } @@ -384,3 +391,50 @@ describe("legal consent", () => { expect(created()).toHaveLength(0); }); }); + +describe("unverified identifiers", () => { + const withUnverified = [ + { id: "u1", primary_email_address: "a@x.dev" }, + { id: "u2", primary_email_address: "b@x.dev", unverified_email_addresses: "c@x.dev" }, + ]; + const statuses = () => + created().map((r) => (r.body as Record)?.email_address_identification_status); + + beforeEach(() => { + fs.writeFileSync(path.join(workDir, "export.json"), JSON.stringify(withUnverified)); + }); + + test("asks whether to reserve them, before consent, and a yes creates them reserved", async () => { + await run(importOptions); + + expect(confirmMessages).toEqual([ + "1 user has an email or phone the source never verified. Create them reserved (usable for sign-in, locked to the user) instead of unverified?", + "Import 2 users?", + ]); + expect(statuses()).toEqual([undefined, ["verified", "reserved"]]); + }); + + test("a no keeps them unverified, attached after the user exists", async () => { + reserveAnswer = false; + + await run(importOptions); + + expect(statuses()).toEqual([undefined, undefined]); + expect(requests.some((r) => new URL(r.url).pathname === "/v1/email_addresses")).toBe(true); + }); + + // `-y` is consent to write, not a yes to making unconfirmed addresses usable. + test("--yes does not ask, and keeps them unverified", async () => { + await run({ ...importOptions, yes: true }); + + expect(confirmMessages).toEqual([]); + expect(statuses()).toEqual([undefined, undefined]); + }); + + test("--reserve-unverified does not ask, and creates them reserved", async () => { + await run({ ...importOptions, reserveUnverified: true }); + + expect(confirmMessages).toEqual(["Import 2 users?"]); + expect(statuses()).toEqual([undefined, ["verified", "reserved"]]); + }); +}); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index ee6afbb75..e44b1663d 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -45,7 +45,7 @@ import { NEXT_STEPS, printAgentNextSteps } from "../../lib/next-steps.ts"; import { confirm } from "../../lib/prompts.ts"; import { interruptedExitCode } from "../../lib/signals.ts"; import { withGutter, withSpinner } from "../../lib/spinner.ts"; -import { importUsers } from "./import-users.ts"; +import { importUsers, splitIdentifiers } from "./import-users.ts"; import { ALREADY_IMPORTED, checkImport, type ImportChecks } from "./lib/checks.ts"; import { fetchInstanceSettings, fetchUserCount } from "./lib/clerk-config.ts"; import { readEnvelope, type ExportEnvelope } from "./lib/export-file.ts"; @@ -128,6 +128,12 @@ export type MigrateRunOptions = { * Without it, a prompt asks; where nobody can be asked, they are rejected. */ skipLegalChecks?: boolean; + /** + * Create the emails and phones the source never verified as reserved, not + * unverified. Without it, a prompt asks; where nobody can be asked, they + * stay unverified. + */ + reserveUnverified?: boolean; /** Check against the instance, report, and write nothing. */ dryRun?: boolean; /** Import the users that pass, and record the rest as skipped. */ @@ -778,6 +784,7 @@ function commandFor( if ((!fromExport || options.envelopeSource) && source) parts.push("--source", quoteArg(source)); if (options.newRun) parts.push("--new-run"); parts.push(...importFlags(options)); + if (options.reserveUnverified) parts.push("--reserve-unverified"); // Names, not `<…>`: pasted as is, a shell reads `` as a redirect. if (options.firebaseSignerKey) parts.push("--firebase-signer-key", "SIGNER_KEY"); if (options.firebaseSaltSeparator !== undefined) @@ -1192,10 +1199,32 @@ async function runImport(rawOptions: MigrateRunOptions, lock: ImportLock): Promi options = { ...options, skipLegalChecks }; } + // Unverified stays the default: reserved makes an address the source never + // confirmed usable for sign-in, which is the operator's call to make. + let reserveUnverified = options.reserveUnverified ?? false; + const withUnverified = users.filter((user) => { + const { unverifiedEmails, unverifiedPhones } = splitIdentifiers(user); + return unverifiedEmails.length > 0 || unverifiedPhones.length > 0; + }).length; + // `-y` is consent to write, not a yes to this, so it does not ask. + if ( + !reserveUnverified && + withUnverified > 0 && + !options.dryRun && + !options.yes && + canPrompt(options) + ) { + reserveUnverified = await confirm({ + message: `${plural(withUnverified, "user")} ${withUnverified === 1 ? "has" : "have"} an email or phone the source never verified. Create them reserved (usable for sign-in, locked to the user) instead of unverified?`, + default: false, + }); + } + const checks = await withSpinner("Checking users against the instance...", async (spinner) => checkImport({ users, skipLegalChecks, + reserveUnverified, failures, unknownFields: loaded.unknownFields, ...(supabaseRows ? { supabaseRows } : {}), @@ -1440,6 +1469,7 @@ async function runImport(rawOptions: MigrateRunOptions, lock: ImportLock): Promi attachOnly, adopted, skipPasswordRequirement: !options.requirePassword, + reserveUnverified, progress, }), ) diff --git a/test/e2e/migrate.test.ts b/test/e2e/migrate.test.ts index 1fc5f97e7..dc33796d8 100644 --- a/test/e2e/migrate.test.ts +++ b/test/e2e/migrate.test.ts @@ -11,6 +11,9 @@ * - A user whose only email is unverified, imported into an instance that * requires an email, is refused by Clerk. The import's checks reject that * user up front on the strength of this test, so it checks both halves. + * - The same user imported with `--reserve-unverified` is created, with that + * email reserved and primary. The checks let them through on the strength + * of this test. * * Requires `CLERK_PLATFORM_API_KEY` and `CLERK_CLI_TEST_APP_ID`. Locally, run * via `bun run test:e2e:op` so 1Password resolves both in-memory. @@ -279,3 +282,36 @@ test("a user whose only email is unverified is refused where email is required", }), ]); }, 60_000); + +test("--reserve-unverified creates an unverified-only user with the email reserved", async () => { + const hex = randomBytes(6).toString("hex"); + const email = `e2e-${hex}+clerk_test@clerkcookie.com`; + const file = writeExport([{ id: `sb_${hex}`, email }]); + + const imported = await cli([ + "migrate", + "import", + file, + "--source", + "supabase", + "--reserve-unverified", + "--yes", + "--json", + ]); + const { run } = JSON.parse(imported.stdout.toString()) as { run: { id: string } }; + const [line] = latestLines(run.id); + expect(line).toMatchObject({ status: "created" }); + + const fetched = await cli(["api", `/users/${line?.clerkId as string}`]); + const user = JSON.parse(fetched.stdout.toString()) as { + primary_email_address_id: string; + email_addresses: { id: string; email_address: string; reserved: boolean }[]; + }; + expect(user.email_addresses).toEqual([ + expect.objectContaining({ + id: user.primary_email_address_id, + email_address: email, + reserved: true, + }), + ]); +}, 60_000); From 5ff93b5515babcaf6c1f617d73b9afda2891c190 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 7 Oct 2026 19:11:36 -0400 Subject: [PATCH 02/23] fix(migrate): attach what an adopted user's create left out, in its own mode A continued run adopts a user whose earlier create got no answer, and only attaches the rest. Which unverified identifiers were left came from this run's --reserve-unverified, not the earlier create's, so switching the flag lost them or attached duplicates. The creating line now records a reserved create, and an adopted user follows it. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/import-users.test.ts | 56 +++++++++++++++++++ .../src/commands/migrate/import-users.ts | 21 ++++++- .../src/commands/migrate/lib/run-store.ts | 5 ++ .../cli-core/src/commands/migrate/run.test.ts | 36 ++++++++++++ packages/cli-core/src/commands/migrate/run.ts | 3 + 5 files changed, 119 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/import-users.test.ts b/packages/cli-core/src/commands/migrate/import-users.test.ts index 0feb1d881..bb4d1543e 100644 --- a/packages/cli-core/src/commands/migrate/import-users.test.ts +++ b/packages/cli-core/src/commands/migrate/import-users.test.ts @@ -617,6 +617,62 @@ describe("importUsers", () => { expect(lines.at(-1)).toMatchObject({ clerkId: "user_found", status: "created" }); }); + // The `creating` line says what the create did, so a continued run that + // adopts the user knows what is left to attach. + test("marks a creating line reserved only when the create reserved something", async () => { + stub(() => ok("user_created")); + + await importUsers({ + users: [ + user({ userId: "u1", unverifiedEmailAddresses: ["c@x.dev"] }), + user({ userId: "u2", email: "b@x.dev" }), + ], + secretKey: "sk_test_x", + limits: LIMITS, + record, + reserveUnverified: true, + }); + + expect(allLines.filter((line) => line.status === "creating")).toEqual([ + { sourceId: "u1", status: "creating", reserved: true }, + { sourceId: "u2", status: "creating" }, + ]); + }); + + // Adopted, the user is not created again, so its create's mode decides what + // is still missing, not this run's flag. + test("attaches an adopted user's unverified email its create left out, even with the flag", async () => { + stub(() => ok("idn_1")); + + await importUsers({ + users: [user({ unverifiedEmailAddresses: ["c@x.dev"] })], + adopted: new Map([["u1", "user_found"]]), + secretKey: "sk_test_x", + limits: LIMITS, + record, + reserveUnverified: true, + }); + + expect(requests.map((r) => r.body)).toEqual([ + { user_id: "user_found", email_address: "c@x.dev", primary: false, verified: false }, + ]); + }); + + test("attaches nothing for an adopted user whose create reserved them, even without the flag", async () => { + stub(() => ok("idn_1")); + + await importUsers({ + users: [user({ unverifiedEmailAddresses: ["c@x.dev"] })], + adopted: new Map([["u1", "user_found"]]), + adoptedReserved: new Set(["u1"]), + secretKey: "sk_test_x", + limits: LIMITS, + record, + }); + + expect(requests).toEqual([]); + }); + // Shapes from clerk_go's apierror: the country error carries its own code // and no param_name; the E.164 error is a form error on phone_number. test.each([ diff --git a/packages/cli-core/src/commands/migrate/import-users.ts b/packages/cli-core/src/commands/migrate/import-users.ts index 94e0fc14e..df8bd6cb9 100644 --- a/packages/cli-core/src/commands/migrate/import-users.ts +++ b/packages/cli-core/src/commands/migrate/import-users.ts @@ -394,6 +394,12 @@ export type ImportUsersOptions = { * not created again; only their extra identifiers are sent. */ adopted?: Map; + /** + * The adopted users whose create reserved their unverified identifiers, from + * its `creating` line. The rest had them attached unverified, whatever this + * run's `reserveUnverified` says. + */ + adoptedReserved?: Set; /** Allow users that carry no password. */ skipPasswordRequirement?: boolean; /** Create the identifiers the source never verified as reserved. */ @@ -420,6 +426,7 @@ export async function importUsers(options: ImportUsersOptions): Promise(), + adoptedReserved = new Set(), skipPasswordRequirement = true, reserveUnverified = false, validationFailed = 0, @@ -510,6 +517,12 @@ export async function importUsers(options: ImportUsersOptions): Promise 0 || identifiers.unverifiedPhones.length > 0); let sent = false; try { created = adoptedId @@ -517,7 +530,11 @@ export async function importUsers(options: ImportUsersOptions): Promise createUser(ctx, user, identifiers, skipPasswordRequirement, () => { - record({ sourceId: user.userId, status: "creating" }); + record({ + sourceId: user.userId, + status: "creating", + ...(reserved ? { reserved: true } : {}), + }); sent = true; }), { @@ -574,7 +591,7 @@ export async function importUsers(options: ImportUsersOptions): Promise 0 ? { ...line, pending: toAttach } : line); if (created.phoneRefusal) { const reason = normalizeErrorMessage(created.phoneRefusal); diff --git a/packages/cli-core/src/commands/migrate/lib/run-store.ts b/packages/cli-core/src/commands/migrate/lib/run-store.ts index e327817ad..e18c9207f 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -131,6 +131,11 @@ export type UserLine = { */ code?: string; passwordDropped?: boolean; + /** + * On a `creating` line: the create sent this user's unverified identifiers + * reserved. A continued run that adopts the user attaches none of them. + */ + reserved?: boolean; }; /** What `runs` shows for a run: its stored status, or how it stopped. */ diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index d7c4b49c2..a767ba868 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -2072,6 +2072,42 @@ describe("run", () => { expect(readRun(runsDir(), first!.id)?.status).toBe("complete"); }); + // The `creating` line records what the create did; a continued run reads it + // back rather than guessing from this run's flags. + test("an adopted user whose create reserved its unverified email gets no attach for it", async () => { + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([ + { id: "u1", primary_email_address: "a@x.dev" }, + { id: "u2", primary_email_address: "b@x.dev", unverified_email_addresses: "c@x.dev" }, + ]), + ); + stubClerk({ failing: new Set(["u2"]) }); + await run(baseOptions); + const [first] = listRuns(runsDir()); + fs.appendFileSync( + path.join(runsDir(), first!.id, "users.ndjson"), + `${JSON.stringify({ sourceId: "u2", status: "creating", reserved: true })}\n`, + ); + interrupt(first!.id); + + requests = []; + process.exitCode = 0; + stubClerk({ + existing: [ + { id: "user_found", external_id: "u2", private_metadata: { clerkMigrateRun: first!.id } }, + ], + }); + await run(baseOptions); + + expect(created()).toEqual([]); + expect(requests.filter((r) => r.url.endsWith("/v1/email_addresses"))).toEqual([]); + expect(latestUserLines(runsDir(), first!.id).get("u2")).toMatchObject({ + status: "created", + clerkId: "user_found", + }); + }); + // A reject would stop a create; an adopted user needs none. Rejected, it // stayed `creating` and the run could never finish. test("an adopted user a check would reject still finishes the run", async () => { diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index e44b1663d..90de41df3 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -1089,9 +1089,11 @@ async function runImport(rawOptions: MigrateRunOptions, lock: ImportLock): Promi const done = new Map(); const attachOnly: UserLine[] = []; const inFlight: string[] = []; + const inFlightReserved = new Set(); if (continued) { for (const line of latestUserLines(runsDir, continued.id).values()) { if (line.status === "creating") inFlight.push(line.sourceId); + if (line.status === "creating" && line.reserved) inFlightReserved.add(line.sourceId); if (line.status !== "created" || !line.clerkId) continue; done.set(line.sourceId, line.clerkId); if (line.pending?.length) attachOnly.push(line); @@ -1468,6 +1470,7 @@ async function runImport(rawOptions: MigrateRunOptions, lock: ImportLock): Promi runId: run.record.id, attachOnly, adopted, + adoptedReserved: inFlightReserved, skipPasswordRequirement: !options.requirePassword, reserveUnverified, progress, From 8427a20f2e4a74593834f963499e33df6121251d Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 7 Oct 2026 19:12:40 -0400 Subject: [PATCH 03/23] fix(migrate): keep prompt answers in the commands a run suggests A yes to the reserve or legal-checks prompt only set a local, so the --allow-partial command printed on a refusal left the flag out, and running it would reject the users that answer let through. Co-Authored-By: Claude Opus 5.5 --- .../commands/migrate/run-interactive.test.ts | 25 +++++++++++++++++++ packages/cli-core/src/commands/migrate/run.ts | 3 +++ 2 files changed, 28 insertions(+) diff --git a/packages/cli-core/src/commands/migrate/run-interactive.test.ts b/packages/cli-core/src/commands/migrate/run-interactive.test.ts index d888930b1..1003063c2 100644 --- a/packages/cli-core/src/commands/migrate/run-interactive.test.ts +++ b/packages/cli-core/src/commands/migrate/run-interactive.test.ts @@ -431,6 +431,31 @@ describe("unverified identifiers", () => { expect(statuses()).toEqual([undefined, undefined]); }); + // A yes at the prompt is a choice; the re-run the refusal suggests must + // keep it, or it would reject the users it let through. + test("a yes carries into the command a refusal suggests", async () => { + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([ + ...withUnverified, + { + id: "u3", + primary_email_address: "d@x.dev", + password_digest: "d", + password_hasher: "rot13", + }, + ]), + ); + + const error = (await run(importOptions).catch((caught: unknown) => caught)) as { + examples?: { command: string }[]; + }; + + expect(error.examples?.map((example) => example.command)).toEqual([ + "clerk migrate import export.json --source clerk --reserve-unverified --secret-key --allow-partial --yes", + ]); + }); + test("--reserve-unverified does not ask, and creates them reserved", async () => { await run({ ...importOptions, reserveUnverified: true }); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 90de41df3..909db7ca7 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -1221,6 +1221,9 @@ async function runImport(rawOptions: MigrateRunOptions, lock: ImportLock): Promi default: false, }); } + // The answers carry into every command printed from here on, so a + // suggested re-run does not quietly drop what the operator chose. + options = { ...options, skipLegalChecks, reserveUnverified }; const checks = await withSpinner("Checking users against the instance...", async (spinner) => checkImport({ From 56e348faef3877f9186a0540ff63082938c34d8b Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 7 Oct 2026 19:13:21 -0400 Subject: [PATCH 04/23] fix(migrate): offer no email fix that --reserve-unverified already covers With reservation, a user whose only email is unverified meets an email requirement, but the checks still offered to make email optional. A user with no email at all still gets that fix. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/lib/checks.ts | 11 +++++---- .../cli-core/src/commands/migrate/run.test.ts | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/lib/checks.ts b/packages/cli-core/src/commands/migrate/lib/checks.ts index d2c9f0c2b..58cc40c74 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.ts @@ -882,10 +882,13 @@ function buildFixes(input: CheckInput, users: User[]): Fix[] { // An unverified email does not satisfy a required one, so a file of only // unverified addresses flags the requirement even when every user has one. - const unverifiedOnly = users.some((user) => { - const identifiers = splitIdentifiers(user); - return !identifiers.primaryEmail && identifiers.unverifiedEmails.length > 0; - }); + // Created reserved, it does, and making email optional fixes nothing. + const unverifiedOnly = + !input.reserveUnverified && + users.some((user) => { + const identifiers = splitIdentifiers(user); + return !identifiers.primaryEmail && identifiers.unverifiedEmails.length > 0; + }); const flagged = report.blocking.slice(); if ( unverifiedOnly && diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index a767ba868..a5f80cb92 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -1480,6 +1480,30 @@ describe("run", () => { ); }); + // Reserved meets the requirement, so neither the reject nor the fix applies. + test("with --reserve-unverified, an unverified-only user is neither rejected nor a fix", async () => { + stubClerk({ + settings: { + attributes: { email_address: { enabled: true, required: true } }, + // A way in besides a password, so users without one import. + enterprise_sso: { enabled: true }, + }, + }); + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([ + { id: "u1", primary_email_address: "a@x.dev" }, + { id: "u2", unverified_email_addresses: "b@x.dev" }, + ]), + ); + + await run({ ...baseOptions, dryRun: true, reserveUnverified: true }); + + expect(captured.err).not.toContain("only has an unverified email"); + expect(captured.err).not.toContain("required_for_sign_up"); + expect(process.exitCode).toBe(0); + }); + test("legal consent: refused without --skip-legal-checks, sent with skip_legal_checks with it", async () => { stubClerk({ settings: { From c32d569112f391ea9f7a9b637ae5486de7738bc0 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 00:21:25 -0400 Subject: [PATCH 05/23] fix(migrate): keep the verified phone when Clerk refuses a reserved one With --reserve-unverified, `phone_number` carries the verified phone and the reserved ones. When Clerk refused any of them, the retry dropped the whole array, so the verified phone went too, and a user with no email failed outright. The retry now sends the verified phone alone first, and drops every phone only if Clerk refuses that as well. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/import-users.test.ts | 67 +++++++++++++++++++ .../src/commands/migrate/import-users.ts | 47 +++++++++---- 2 files changed, 102 insertions(+), 12 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/import-users.test.ts b/packages/cli-core/src/commands/migrate/import-users.test.ts index bb4d1543e..639a09878 100644 --- a/packages/cli-core/src/commands/migrate/import-users.test.ts +++ b/packages/cli-core/src/commands/migrate/import-users.test.ts @@ -751,6 +751,73 @@ describe("importUsers", () => { expect(lines.at(-1)?.error).toContain("Failed to add phone +31612345678"); }); + describe("a refused phone among reserved ones", () => { + const refusePhone = (refusals: number) => + stub((url, attempt) => + url.endsWith("/v1/users") && attempt <= refusals + ? new Response( + JSON.stringify({ + errors: [ + { + code: "form_param_format_invalid", + message: "bad phone", + meta: { param_name: "phone_number" }, + }, + ], + }), + { status: 422 }, + ) + : ok("user_created"), + ); + const withPhones = (fields: Partial = {}) => + user({ phone: "+15555550100", unverifiedPhoneNumbers: ["+31612345678"], ...fields }); + + // Any of the phones may be the refused one; the verified one goes alone first. + test("keeps the verified phone, dropping only the reserved ones", async () => { + refusePhone(1); + await importUsers({ + users: [withPhones()], + secretKey: "sk_test_x", + limits: LIMITS, + record, + reserveUnverified: true, + }); + + expect(requests[1]?.body).toMatchObject({ phone_number: ["+15555550100"] }); + expect(requests[1]?.body).not.toHaveProperty("phone_number_identification_status"); + expect(lines.at(-1)?.error).toContain("Failed to add phone +31612345678:"); + expect(lines.at(-1)?.error).not.toContain("+15555550100"); + }); + + test("creates a user with no email on its verified phone", async () => { + refusePhone(1); + const summary = await importUsers({ + users: [withPhones({ email: undefined })], + secretKey: "sk_test_x", + limits: LIMITS, + record, + reserveUnverified: true, + }); + + expect(summary).toMatchObject({ successful: 1, failed: 0 }); + }); + + test("drops every phone only when the verified one is refused too", async () => { + refusePhone(2); + await importUsers({ + users: [withPhones()], + secretKey: "sk_test_x", + limits: LIMITS, + record, + reserveUnverified: true, + }); + + expect(requests).toHaveLength(3); + expect(requests[2]?.body).not.toHaveProperty("phone_number"); + expect(lines.at(-1)?.error).toContain("Failed to add phone +15555550100, +31612345678:"); + }); + }); + test("does not retry without the phone when it is the only identifier", async () => { stub( () => diff --git a/packages/cli-core/src/commands/migrate/import-users.ts b/packages/cli-core/src/commands/migrate/import-users.ts index df8bd6cb9..b00a80f54 100644 --- a/packages/cli-core/src/commands/migrate/import-users.ts +++ b/packages/cli-core/src/commands/migrate/import-users.ts @@ -177,6 +177,19 @@ type CreateContext = { reserveUnverified: boolean; }; +/** + * True when Clerk refused the create for a phone. The country error names no + * parameter, only its own code. A phone another user holds also names + * `phone_number`, and fails the user. + */ +function isPhoneRefusal(error: unknown): error is BapiError { + return ( + error instanceof BapiError && + (error.code === "unsupported_country_code" || + (error.code === "form_param_format_invalid" && error.meta?.param_name === "phone_number")) + ); +} + /** A request a stop kept from going out: neither a failure nor unknown. */ class NotSentError extends Error {} @@ -349,21 +362,31 @@ async function createUser( } catch (error) { // A phone Clerk refuses (a country it does not support, a number that is // not E.164) should not cost a user who has an email to be created under. - // The country error names no parameter, only its own code. A phone another - // user holds also names `phone_number`, and fails the user. - const phoneRefused = - error instanceof BapiError && - (error.code === "unsupported_country_code" || - (error.code === "form_param_format_invalid" && error.meta?.param_name === "phone_number")); - if (!phoneRefused || !body.email_address) throw error; + if (!isPhoneRefusal(error)) throw error; const { - phone_number: dropped, - phone_number_identification_status: _statuses, + phone_number: sent, + phone_number_identification_status: statuses, ...withoutPhone } = body; - response = await create(withoutPhone); - phoneRefusal = error.longMessage ?? error.message; - notes.push(`Failed to add phone ${(dropped as string[]).join(", ")}: ${phoneRefusal}`); + let refusal = error; + let dropped = sent as string[]; + // With reserved phones on the create, any one of them may be the refused + // one: the verified phone alone goes first, so it is not lost with them. + if (statuses && identifiers.primaryPhone) { + try { + response = await create({ ...withoutPhone, phone_number: [identifiers.primaryPhone] }); + dropped = dropped.filter((phone) => phone !== identifiers.primaryPhone); + } catch (retryError) { + if (!isPhoneRefusal(retryError)) throw retryError; + refusal = retryError; + } + } + if (!response) { + if (!body.email_address) throw refusal; + response = await create(withoutPhone); + } + phoneRefusal = refusal.longMessage ?? refusal.message; + notes.push(`Failed to add phone ${dropped.join(", ")}: ${phoneRefusal}`); } // Untracked, the user could never be undone. Thrown, the outcome is unknown, From 517a83591a0b5cb94fe312f03564c0116662223d Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 00:22:29 -0400 Subject: [PATCH 06/23] fix(migrate): check reserved identifiers for duplicates too With --reserve-unverified, the unverified emails and phones go on the create, so a clash on one fails the whole create. The duplicate checks matched only the primary email and phone, so the dry run passed users the import would then fail. Both checks, in the file and in the instance, now match what the create sends, reserved identifiers included, and only when they are reserved. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/lib/checks.test.ts | 28 ++++++++++++ .../src/commands/migrate/lib/checks.ts | 45 +++++++++++++------ 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/lib/checks.test.ts b/packages/cli-core/src/commands/migrate/lib/checks.test.ts index cd078ef17..c5090c8f7 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.test.ts @@ -237,6 +237,34 @@ describe("rejects", () => { ); }); + // --reserve-unverified puts unverified identifiers on the create, so they + // clash there as a primary would; without it they are attached after. + test.each([ + [ + true, + { + b: "email is also used by an earlier user in the file, which is kept", + c: "email is already used by a user in the instance", + }, + ], + [false, {}], + ])( + "with reserveUnverified %p, unverified emails that clash are duplicates", + async (reserveUnverified, expected) => { + existing = [{ id: "user_1", email_addresses: [{ email_address: "taken@x.dev" }] }]; + expect( + await reasonsOf({ + reserveUnverified, + users: [ + user("a", { unverifiedEmailAddresses: ["shared@x.dev"] }), + user("b", { unverifiedEmailAddresses: ["shared@x.dev"] }), + user("c", { unverifiedEmailAddresses: ["taken@x.dev"] }), + ], + }), + ).toEqual(expected); + }, + ); + // The source's order decides which duplicate survives, so the dry run says. test("a duplicate names the earlier user kept in its place", async () => { const checks = await checkImport( diff --git a/packages/cli-core/src/commands/migrate/lib/checks.ts b/packages/cli-core/src/commands/migrate/lib/checks.ts index 58cc40c74..75271e4d8 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.ts @@ -580,7 +580,10 @@ const DUPLICATE_SOURCE_ID = "duplicate source ID in the file"; * * @returns Each duplicate's reason, and the earlier user kept in its place. */ -function findFileDuplicates(users: User[]): { +function findFileDuplicates( + users: User[], + reserveUnverified = false, +): { reasons: Map; keptBy: Map; } { @@ -600,9 +603,7 @@ function findFileDuplicates(users: User[]): { } seenIds.add(user.userId); - const { primaryEmail, primaryPhone } = splitIdentifiers(user); - const ownEmails = primaryEmail ? [primaryEmail] : []; - const ownPhones = primaryPhone ? [primaryPhone] : []; + const { emails: ownEmails, phones: ownPhones } = sentIdentifiers(user, reserveUnverified); const emailOwner = ownEmails.map((email) => emails.get(email.toLowerCase())).find(Boolean); const phoneOwner = ownPhones.map((phone) => phones.get(phoneKey(phone))).find(Boolean); @@ -634,8 +635,26 @@ function findFileDuplicates(users: User[]): { } /** - * Users the instance already holds, found by source ID, primary email, - * primary phone or username. + * The emails and phones `POST /v1/users` sends for a user: the verified + * primary, plus the unverified ones when they are created reserved. + */ +function sentIdentifiers(user: User, reserveUnverified: boolean) { + const { primaryEmail, primaryPhone, unverifiedEmails, unverifiedPhones } = splitIdentifiers(user); + return { + emails: [ + ...(primaryEmail ? [primaryEmail] : []), + ...(reserveUnverified ? unverifiedEmails : []), + ], + phones: [ + ...(primaryPhone ? [primaryPhone] : []), + ...(reserveUnverified ? unverifiedPhones : []), + ], + }; +} + +/** + * Users the instance already holds, found by source ID, the emails and phones + * the create sends, or username. * * Only what `POST /v1/users` itself carries is looked up: an extra email that * collides is attached after the user exists, fails on its own, and is noted @@ -651,10 +670,10 @@ async function findInstanceDuplicates( const byUsername = new Map(); for (const user of users) { - const identifiers = splitIdentifiers(user); + const sent = sentIdentifiers(user, input.reserveUnverified ?? false); byExternalId.set(user.userId, user.userId); - if (identifiers.primaryEmail) byEmail.set(identifiers.primaryEmail.toLowerCase(), user.userId); - if (identifiers.primaryPhone) byPhone.set(phoneKey(identifiers.primaryPhone), user.userId); + for (const email of sent.emails) byEmail.set(email.toLowerCase(), user.userId); + for (const phone of sent.phones) byPhone.set(phoneKey(phone), user.userId); if (typeof user.username === "string" && user.username) { byUsername.set(user.username.toLowerCase(), user.userId); } @@ -1130,10 +1149,10 @@ export async function checkImport(input: CheckInput): Promise { // An adopted user already holds its email, phone and username in Clerk, so // it claims them first, wherever it sits in the file: a fresh record sharing // one is the create Clerk would refuse, and is not kept in its place. - const { reasons: fileDuplicates, keptBy } = findFileDuplicates([ - ...passed.filter(adopted), - ...passed.filter((user) => !adopted(user)), - ]); + const { reasons: fileDuplicates, keptBy } = findFileDuplicates( + [...passed.filter(adopted), ...passed.filter((user) => !adopted(user))], + input.reserveUnverified, + ); let candidates: User[] = []; for (const user of passed) { const kept = keptBy.get(user); From a228b5edd0b685b94521dc918386dd0648df3daf Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 00:23:47 -0400 Subject: [PATCH 07/23] fix(migrate): count an adopted user's reserved email toward a required one A continue without --reserve-unverified checked every user as if nothing were reserved. An adopted user whose cut-off create had reserved its only email (the `creating` line says `reserved: true`) was rejected for a missing required email, and since adopted users are not recorded as rejects, it stayed unfinished. The checks now treat those users as reserved, whatever this run's flag says. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/lib/checks.ts | 12 ++++-- .../cli-core/src/commands/migrate/run.test.ts | 39 +++++++++++++++++++ packages/cli-core/src/commands/migrate/run.ts | 2 + 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/lib/checks.ts b/packages/cli-core/src/commands/migrate/lib/checks.ts index 75271e4d8..11c25221b 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.ts @@ -130,6 +130,11 @@ export type CheckInput = { skipLegalChecks?: boolean; /** Unverified identifiers are created reserved, so they meet a requirement. */ reserveUnverified?: boolean; + /** + * Source IDs of adopted users whose create reserved their unverified + * identifiers: those exist reserved, whatever this run's flag says. + */ + reservedSourceIds?: Set; /** * Clerk IDs a continued run found behind its own in-flight creates: finding * them in the instance is expected. @@ -1098,6 +1103,7 @@ export async function checkImport(input: CheckInput): Promise { user.password && user.passwordHasher ? hashShapeProblem(user.password, user.passwordHasher) : undefined; + const reserve = input.reserveUnverified || input.reservedSourceIds?.has(user.userId); const reason = adopted(original) ? undefined : (original.skipReason ?? @@ -1110,11 +1116,11 @@ export async function checkImport(input: CheckInput): Promise { : undefined) ?? // A required phone the file has, but only in a form Clerk refuses here. (phoned.dropped && - missingRequiredIdentifier(user, input.settings, input.reserveUnverified) && - !missingRequiredIdentifier(emailed, input.settings, input.reserveUnverified) + missingRequiredIdentifier(user, input.settings, reserve) && + !missingRequiredIdentifier(emailed, input.settings, reserve) ? "its phones aren't in E.164 form, and this instance requires a phone number (numeric usernames are on)" : undefined) ?? - missingRequiredIdentifier(user, input.settings, input.reserveUnverified) ?? + missingRequiredIdentifier(user, input.settings, reserve) ?? // Stripping the identifiers the instance has off can leave nothing to // sign in with; Clerk would still create the user. (!hasAnyIdentifier(sent) diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index a5f80cb92..c4425179b 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -2132,6 +2132,45 @@ describe("run", () => { }); }); + // Created reserved by the run that stopped: its unverified email exists + // reserved, and meets the requirement, though this run has no flag. + test("an adopted user created reserved meets a required email without the flag", async () => { + const settings = { + attributes: { email_address: { enabled: true, required: true } }, + enterprise_sso: { enabled: true }, + }; + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([ + { id: "u1", primary_email_address: "a@x.dev" }, + { id: "u2", unverified_email_addresses: "c@x.dev" }, + ]), + ); + stubClerk({ settings, failing: new Set(["u2"]) }); + await run({ ...baseOptions, reserveUnverified: true }); + const [first] = listRuns(runsDir()); + fs.appendFileSync( + path.join(runsDir(), first!.id, "users.ndjson"), + `${JSON.stringify({ sourceId: "u2", status: "creating", reserved: true })}\n`, + ); + interrupt(first!.id); + + requests = []; + process.exitCode = 0; + stubClerk({ + settings, + existing: [ + { id: "user_found", external_id: "u2", private_metadata: { clerkMigrateRun: first!.id } }, + ], + }); + await run(baseOptions); + + expect(latestUserLines(runsDir(), first!.id).get("u2")).toMatchObject({ + status: "created", + clerkId: "user_found", + }); + }); + // A reject would stop a create; an adopted user needs none. Rejected, it // stayed `creating` and the run could never finish. test("an adopted user a check would reject still finishes the run", async () => { diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 909db7ca7..771608178 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -1247,6 +1247,8 @@ async function runImport(rawOptions: MigrateRunOptions, lock: ImportLock): Promi schedule, adoptedClerkIds: new Set(adopted.values()), adoptedSourceIds: new Set(adopted.keys()), + // Their `creating` line says how the create ran, not this run's flag. + reservedSourceIds: new Set([...adopted.keys()].filter((id) => inFlightReserved.has(id))), spinner, }), ); From dd8f77854b19480ed853037fac588695fde0f893 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 13:32:00 -0400 Subject: [PATCH 08/23] fix(migrate): check an adopted user's identifiers in its own create's mode On a continue with --reserve-unverified, the checks treated every user's unverified identifiers as reserved, adopted users included. An adopted user whose cut-off create did not reserve them was then rejected for sharing one with an earlier record, though it would only be attached after. The checks now take an adopted user's mode from its `creating` line, and the flag only for everyone else, in the duplicate checks and the required-identifier check alike. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/lib/checks.test.ts | 15 +++++++++++ .../src/commands/migrate/lib/checks.ts | 26 ++++++++++++++----- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/lib/checks.test.ts b/packages/cli-core/src/commands/migrate/lib/checks.test.ts index c5090c8f7..d795be2bd 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.test.ts @@ -265,6 +265,21 @@ describe("rejects", () => { }, ); + // An adopted user's create already ran without reserving, so its unverified + // email is attached later, whatever this run's flag says: no clash. + test("an adopted user's unverified email counts by its own create's mode", async () => { + expect( + await reasonsOf({ + reserveUnverified: true, + adoptedSourceIds: new Set(["b"]), + users: [ + user("a", { unverifiedEmailAddresses: ["shared@x.dev"] }), + user("b", { unverifiedEmailAddresses: ["shared@x.dev"] }), + ], + }), + ).toEqual({}); + }); + // The source's order decides which duplicate survives, so the dry run says. test("a duplicate names the earlier user kept in its place", async () => { const checks = await checkImport( diff --git a/packages/cli-core/src/commands/migrate/lib/checks.ts b/packages/cli-core/src/commands/migrate/lib/checks.ts index 11c25221b..171166602 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.ts @@ -143,7 +143,9 @@ export type CheckInput = { /** * Source IDs of those adopted users. Each already exists in Clerk, so none * is rejected: the checks stop creates, and these need none. They count - * toward no quota either; the instance's live count has them already. + * toward no quota either; the instance's live count has them already. Their + * create ran in the mode its `creating` line records, so this run's flag + * does not decide what they reserved. */ adoptedSourceIds?: Set; spinner?: SpinnerControls; @@ -587,7 +589,7 @@ const DUPLICATE_SOURCE_ID = "duplicate source ID in the file"; */ function findFileDuplicates( users: User[], - reserveUnverified = false, + reserves: (user: User) => boolean = () => false, ): { reasons: Map; keptBy: Map; @@ -608,7 +610,7 @@ function findFileDuplicates( } seenIds.add(user.userId); - const { emails: ownEmails, phones: ownPhones } = sentIdentifiers(user, reserveUnverified); + const { emails: ownEmails, phones: ownPhones } = sentIdentifiers(user, reserves(user)); const emailOwner = ownEmails.map((email) => emails.get(email.toLowerCase())).find(Boolean); const phoneOwner = ownPhones.map((phone) => phones.get(phoneKey(phone))).find(Boolean); @@ -639,6 +641,18 @@ function findFileDuplicates( return { reasons, keptBy }; } +/** + * Whether a user's unverified identifiers are (or will be) created reserved: + * an adopted user's by what its create recorded, everyone else's by this + * run's flag. + */ +function reservesFor(input: CheckInput): (user: User) => boolean { + return (user) => + input.adoptedSourceIds?.has(user.userId) + ? Boolean(input.reservedSourceIds?.has(user.userId)) + : Boolean(input.reserveUnverified); +} + /** * The emails and phones `POST /v1/users` sends for a user: the verified * primary, plus the unverified ones when they are created reserved. @@ -675,7 +689,7 @@ async function findInstanceDuplicates( const byUsername = new Map(); for (const user of users) { - const sent = sentIdentifiers(user, input.reserveUnverified ?? false); + const sent = sentIdentifiers(user, reservesFor(input)(user)); byExternalId.set(user.userId, user.userId); for (const email of sent.emails) byEmail.set(email.toLowerCase(), user.userId); for (const phone of sent.phones) byPhone.set(phoneKey(phone), user.userId); @@ -1103,7 +1117,7 @@ export async function checkImport(input: CheckInput): Promise { user.password && user.passwordHasher ? hashShapeProblem(user.password, user.passwordHasher) : undefined; - const reserve = input.reserveUnverified || input.reservedSourceIds?.has(user.userId); + const reserve = reservesFor(input)(user); const reason = adopted(original) ? undefined : (original.skipReason ?? @@ -1157,7 +1171,7 @@ export async function checkImport(input: CheckInput): Promise { // one is the create Clerk would refuse, and is not kept in its place. const { reasons: fileDuplicates, keptBy } = findFileDuplicates( [...passed.filter(adopted), ...passed.filter((user) => !adopted(user))], - input.reserveUnverified, + reservesFor(input), ); let candidates: User[] = []; for (const user of passed) { From 1b2a399137350bcb7faba9ac5ddeebd14d8f94f5 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 17:21:08 -0400 Subject: [PATCH 09/23] fix(migrate): count a reserved email or phone as a way in past a disabled provider Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/lib/checks.test.ts | 18 ++++++++++++++++++ .../src/commands/migrate/lib/checks.ts | 10 ++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/lib/checks.test.ts b/packages/cli-core/src/commands/migrate/lib/checks.test.ts index d795be2bd..a53c29d01 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.test.ts @@ -480,6 +480,24 @@ describe("rejects", () => { ).toEqual({ u: "only signs in with Discord, which is not enabled in Clerk" }); }); + // A reserved identifier signs in by code, and is verified the first time. + test("a reserved email is a way in for a supabase user on a disabled provider", async () => { + expect( + await reasonsOf({ + settings: settings({ + email_address: { + enabled: true, + used_for_first_factor: true, + first_factors: ["email_link"], + }, + }), + reserveUnverified: true, + supabaseRows: [{ id: "u", raw_app_meta_data: { providers: ["discord"] } }], + users: [user("u", { email: undefined, unverifiedEmailAddresses: ["u@x.dev"] })], + }), + ).toEqual({}); + }); + // Clerk can't turn on a provider it doesn't offer, so none is suggested. test("a provider Clerk doesn't offer is named as such, with no fix", async () => { const rows = [ diff --git a/packages/cli-core/src/commands/migrate/lib/checks.ts b/packages/cli-core/src/commands/migrate/lib/checks.ts index 171166602..d46c4808b 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.ts @@ -768,16 +768,18 @@ function findDisabledProviderRejects(input: CheckInput): Map { const { excludedIds } = findUsersWithOnlyDisabledProviders(input.supabaseRows, disabled); // A disabled provider only strands a user with no other way in: a verified - // email or phone the instance signs in with by code or link still works. + // or reserved email or phone the instance signs in with by code or link + // still works. const strategies = firstFactorStrategies(input.settings); const usersById = new Map(input.users.map((user) => [user.userId, user])); + const reserves = reservesFor(input); const canSignInOtherwise = (id: string) => { const user = usersById.get(id); if (!user) return false; - const { primaryEmail, primaryPhone } = splitIdentifiers(user); + const { emails, phones } = sentIdentifiers(user, reserves(user)); return ( - (Boolean(primaryEmail) && (strategies.has("email_code") || strategies.has("email_link"))) || - (Boolean(primaryPhone) && strategies.has("phone_code")) + (emails.length > 0 && (strategies.has("email_code") || strategies.has("email_link"))) || + (phones.length > 0 && strategies.has("phone_code")) ); }; // Each reject names only that user's own providers. From 17de2ab55f9f7fd60a717d5f2e7839ec8b68a42c Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 17:22:57 -0400 Subject: [PATCH 10/23] fix(migrate): attach the reserved phones a refusal kept off the create one by one Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 6 +++ .../src/commands/migrate/import-users.test.ts | 32 +++++++++++-- .../src/commands/migrate/import-users.ts | 45 +++++++++++++++---- 3 files changed, 72 insertions(+), 11 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 8505cb7d7..a19cdc006 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -967,6 +967,12 @@ email: the create is retried without the phone. The user counts as imported, and the summary lists them under "Imported without their phone", by Clerk's reason (`result.warnings` in `--json`). +With [reserved](#verified-vs-unverified-identifiers) phones on the create, the +refused one may be any of them, so the verified phone is retried alone first. +Reserved phones the refusal kept off, when Clerk's answer can't say which was +refused, are attached one by one once the user exists, unverified: only the +refused one is lost. + #### Throughput Defaults follow Clerk's documented `POST /v1/users` limits: 100 req/s for diff --git a/packages/cli-core/src/commands/migrate/import-users.test.ts b/packages/cli-core/src/commands/migrate/import-users.test.ts index 639a09878..736c57208 100644 --- a/packages/cli-core/src/commands/migrate/import-users.test.ts +++ b/packages/cli-core/src/commands/migrate/import-users.test.ts @@ -802,7 +802,8 @@ describe("importUsers", () => { expect(summary).toMatchObject({ successful: 1, failed: 0 }); }); - test("drops every phone only when the verified one is refused too", async () => { + // The first refusal may have been the verified phone's alone. + test("drops the verified phone refused alone, and attaches the reserved ones after", async () => { refusePhone(2); await importUsers({ users: [withPhones()], @@ -812,9 +813,34 @@ describe("importUsers", () => { reserveUnverified: true, }); - expect(requests).toHaveLength(3); expect(requests[2]?.body).not.toHaveProperty("phone_number"); - expect(lines.at(-1)?.error).toContain("Failed to add phone +15555550100, +31612345678:"); + expect(lines.at(-1)?.error).toContain("Failed to add phone +15555550100:"); + expect(requests[3]).toMatchObject({ + url: expect.stringContaining("/v1/phone_numbers"), + body: { phone_number: "+31612345678", verified: false }, + }); + }); + + // Any one of them may be the refused one, so none is lost with it. + test("attaches each reserved phone on its own when it can't tell which was refused", async () => { + refusePhone(1); + await importUsers({ + users: [withPhones({ unverifiedPhoneNumbers: ["+31612345678", "+31612345679"] })], + secretKey: "sk_test_x", + limits: LIMITS, + record, + reserveUnverified: true, + }); + + expect(requests[1]?.body).toMatchObject({ phone_number: ["+15555550100"] }); + const attached = requests + .filter((request) => request.url.includes("/v1/phone_numbers")) + .map((request) => request.body); + expect(attached).toEqual([ + expect.objectContaining({ phone_number: "+31612345678", verified: false }), + expect.objectContaining({ phone_number: "+31612345679", verified: false }), + ]); + expect(lines.at(-1)?.error ?? "").not.toContain("Failed to add phone"); }); }); diff --git a/packages/cli-core/src/commands/migrate/import-users.ts b/packages/cli-core/src/commands/migrate/import-users.ts index b00a80f54..c843ea0b0 100644 --- a/packages/cli-core/src/commands/migrate/import-users.ts +++ b/packages/cli-core/src/commands/migrate/import-users.ts @@ -306,13 +306,21 @@ async function attachAll( }; } +type CreatedUser = { + clerkUserId: string; + notes: string[]; + /** Reserved phones a refusal kept off the create, to attach one by one. */ + attachLater?: string[]; + phoneRefusal?: string; +}; + /** * Creates one user, retrying without a phone Clerk refuses. * * @param sending - Called as each `POST /v1/users` goes out, so the run * records the user only once a create may actually land. - * @returns The Clerk ID, a note when the phone was dropped, and Clerk's - * reason for refusing it. + * @returns The Clerk ID, a note when the phone was dropped, Clerk's reason + * for refusing it, and the reserved phones still to attach. */ async function createUser( ctx: CreateContext, @@ -320,7 +328,7 @@ async function createUser( identifiers: Identifiers, skipPasswordRequirement: boolean, sending: () => void, -): Promise<{ clerkUserId: string; notes: string[]; phoneRefusal?: string }> { +): Promise { // A Ctrl-C or a full instance hands the slot on to queued creates; none // of them is sent, and none waits for a paced turn to find that out. const create = async (body: Record) => @@ -356,6 +364,7 @@ async function createUser( } const notes: string[] = []; let phoneRefusal: string | undefined; + let attachLater: string[] = []; let response; try { response = await create(body); @@ -385,8 +394,18 @@ async function createUser( if (!body.email_address) throw refusal; response = await create(withoutPhone); } - phoneRefusal = refusal.longMessage ?? refusal.message; - notes.push(`Failed to add phone ${dropped.join(", ")}: ${phoneRefusal}`); + // Which reserved phone was refused is unknown when there are several, or + // when the verified one was refused alone: each is attached on its own once + // the user exists, unverified, so only a refused one is lost. + const reservedDropped = dropped.filter((phone) => phone !== identifiers.primaryPhone); + if (statuses && (refusal !== error || reservedDropped.length > 1)) { + attachLater = reservedDropped; + dropped = dropped.filter((phone) => !attachLater.includes(phone)); + } + if (dropped.length > 0) { + phoneRefusal = refusal.longMessage ?? refusal.message; + notes.push(`Failed to add phone ${dropped.join(", ")}: ${phoneRefusal}`); + } } // Untracked, the user could never be undone. Thrown, the outcome is unknown, @@ -395,7 +414,12 @@ async function createUser( if (typeof clerkUserId !== "string" || !clerkUserId) { throw new Error("Clerk answered POST /v1/users without a user ID"); } - return { clerkUserId, notes, ...(phoneRefusal ? { phoneRefusal } : {}) }; + return { + clerkUserId, + notes, + attachLater, + ...(phoneRefusal ? { phoneRefusal } : {}), + }; } export type ImportUsersOptions = { @@ -538,7 +562,7 @@ export async function importUsers(options: ImportUsersOptions): Promise => { const retries: string[] = []; const identifiers = splitIdentifiers(user); - let created: { clerkUserId: string; notes: string[]; phoneRefusal?: string }; + let created: CreatedUser; const adoptedId = adopted.get(user.userId); // What the create did with the unverified identifiers decides what is left // to attach: an adopted user's create may have run in the other mode. @@ -614,7 +638,12 @@ export async function importUsers(options: ImportUsersOptions): Promise ({ + kind: "phone" as const, + value, + verified: false, + })); + const toAttach = [...pendingIdentifiers(identifiers, reserved), ...retried]; record(toAttach.length > 0 ? { ...line, pending: toAttach } : line); if (created.phoneRefusal) { const reason = normalizeErrorMessage(created.phoneRefusal); From 71c77ccb9c18a8e05858889228c18c64c833e3b0 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 21:13:08 -0400 Subject: [PATCH 11/23] docs(migrate): name the phone endpoint too, and say an adopted user keeps its recorded mode Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index a19cdc006..365f8b153 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -1376,7 +1376,8 @@ unconfirmed address there would silently promote it. A Clerk export keeps an unverified primary email or phone unverified. **Unverified or reserved.** By default an unverified identifier is attached -after the user exists (`POST /v1/email_addresses`, `verified: false`). The user +after the user exists (`POST /v1/email_addresses` or `POST /v1/phone_numbers`, +`verified: false`). The user cannot sign in with it, and another user can claim it by verifying it first. `--reserve-unverified`, or a yes at the prompt a human gets when the file has any, creates them **reserved** instead, on `POST /v1/users` through @@ -1386,7 +1387,9 @@ user, and becomes verified the first time the user signs in with it. That is how most source platforms treat an unconfirmed address, but it lets the user sign in with one nobody proved they own, so it is opt-in. `-y`, `--json` and agent mode never ask, and keep them unverified without the flag. A continued -run uses whichever the flag or answer says that time. +run uses whichever the flag or answer says that time for the users it creates; +a user it adopts keeps the mode its first create recorded (`reserved` on its +`creating` line). ### Firebase hash parameters From eabd3ff9911ddddb948be37e660a1d5a654a9551 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 14 Oct 2026 19:49:16 -0400 Subject: [PATCH 12/23] fix(migrate): count no email Clerk refuses as a way in for a user on a disabled provider canSignInOtherwise read the user's emails before dropRefusedEmails, so an email the create would strip (an unroutable domain, say) kept a Supabase user whose only provider is off. It now reads what the create sends. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/lib/checks.test.ts | 18 ++++++++++++++++++ .../src/commands/migrate/lib/checks.ts | 3 ++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/migrate/lib/checks.test.ts b/packages/cli-core/src/commands/migrate/lib/checks.test.ts index a53c29d01..91b329790 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.test.ts @@ -464,6 +464,24 @@ describe("rejects", () => { ).toEqual({ neither: "only signs in with Discord, which is not enabled in Clerk" }); }); + // The create would strip it, so it can't be what saves the user. + test("an email Clerk refuses is no way in for a supabase user on a disabled provider", async () => { + expect( + await reasonsOf({ + settings: settings({ + email_address: { + enabled: true, + used_for_first_factor: true, + first_factors: ["email_code"], + }, + username: { enabled: true }, + }), + supabaseRows: [{ id: "u", raw_app_meta_data: { providers: ["discord"] } }], + users: [user("u", { email: "u@x.test", username: "uname" })], + }), + ).toEqual({ u: "only signs in with Discord, which is not enabled in Clerk" }); + }); + test("an unverified email is no way in for a supabase user on a disabled provider", async () => { expect( await reasonsOf({ diff --git a/packages/cli-core/src/commands/migrate/lib/checks.ts b/packages/cli-core/src/commands/migrate/lib/checks.ts index d46c4808b..769090825 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.ts @@ -776,7 +776,8 @@ function findDisabledProviderRejects(input: CheckInput): Map { const canSignInOtherwise = (id: string) => { const user = usersById.get(id); if (!user) return false; - const { emails, phones } = sentIdentifiers(user, reserves(user)); + // An email Clerk refuses is stripped before the create, so it is no way in. + const { emails, phones } = sentIdentifiers(dropRefusedEmails(user).user, reserves(user)); return ( (emails.length > 0 && (strategies.has("email_code") || strategies.has("email_link"))) || (phones.length > 0 && strategies.has("phone_code")) From 849d90e8c23b9c9cf92dc917b4b292c24e4217d3 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 14 Oct 2026 19:49:16 -0400 Subject: [PATCH 13/23] fix(migrate): keep a run's reserve choice for its continue, and say a reserved collision rejects the user The reserve choice came only from the flag or the prompt each time, so a run a human answered yes for, continued under -y, --json or in agent mode, created the rest unverified. A run now records reserveUnverified; a continue reuses it without asking and refuses --reserve-unverified on a run that kept them unverified. A run from before this asks as it did. Reserved, an unverified identifier is checked for duplicates, so one another user holds rejects the whole user. The prompt and the README now say so. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 17 +++++-- .../src/commands/migrate/lib/run-store.ts | 5 ++ .../commands/migrate/run-interactive.test.ts | 50 ++++++++++++++++++- packages/cli-core/src/commands/migrate/run.ts | 18 ++++++- 4 files changed, 84 insertions(+), 6 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 365f8b153..ae0a92769 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -1386,10 +1386,19 @@ A reserved identifier is unverified, but usable for sign-in and locked to the user, and becomes verified the first time the user signs in with it. That is how most source platforms treat an unconfirmed address, but it lets the user sign in with one nobody proved they own, so it is opt-in. `-y`, `--json` and -agent mode never ask, and keep them unverified without the flag. A continued -run uses whichever the flag or answer says that time for the users it creates; -a user it adopts keeps the mode its first create recorded (`reserved` on its -`creating` line). +agent mode never ask, and keep them unverified without the flag. + +A run records its choice (`reserveUnverified` in `run.json`), and a continued +run reuses it without asking, so every user of one run is created the same +way. `--reserve-unverified` on a continue of a run that kept them unverified is +refused; pass `--new-run` to change modes. A user a run adopts keeps the mode +its first create recorded (`reserved` on its `creating` line). + +Reserved, an unverified identifier is checked for duplicates like a verified +one, because it goes on the user's create: one that an instance user or an +earlier record in the file already holds rejects the whole user, where without +reserving only that address would fail to attach, with a note. If that rejects +users you want, import without reserving. ### Firebase hash parameters diff --git a/packages/cli-core/src/commands/migrate/lib/run-store.ts b/packages/cli-core/src/commands/migrate/lib/run-store.ts index e18c9207f..29fc68dd5 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -103,6 +103,11 @@ export type RunRecord = { * not everyone, so it finishes `partial` and importing it says so. */ truncated?: boolean; + /** + * Whether an import created unverified emails and phones reserved. A + * continue reuses it, so every user of one run is created the same way. + */ + reserveUnverified?: boolean; /** The import run an undo reverses. */ undoes?: string; /** The undo run that reversed this one. */ diff --git a/packages/cli-core/src/commands/migrate/run-interactive.test.ts b/packages/cli-core/src/commands/migrate/run-interactive.test.ts index 1003063c2..a7a0ffeb8 100644 --- a/packages/cli-core/src/commands/migrate/run-interactive.test.ts +++ b/packages/cli-core/src/commands/migrate/run-interactive.test.ts @@ -408,12 +408,60 @@ describe("unverified identifiers", () => { await run(importOptions); expect(confirmMessages).toEqual([ - "1 user has an email or phone the source never verified. Create them reserved (usable for sign-in, locked to the user) instead of unverified?", + "1 user has an email or phone the source never verified. Create them reserved (usable for sign-in, locked to the user) instead of unverified? A reserved one another user already holds rejects its user, not just that address.", "Import 2 users?", ]); expect(statuses()).toEqual([undefined, ["verified", "reserved"]]); }); + /** Rewrites the only run as one a crash stopped after creating u1, and returns its folder. */ + const interruptAfterU1 = (patch: Record = {}): string => { + const [id] = fs.readdirSync(runsDir()); + const dir = path.join(runsDir(), id!); + const record = JSON.parse(fs.readFileSync(path.join(dir, "run.json"), "utf-8")); + delete record.finishedAt; + fs.writeFileSync(path.join(dir, "run.json"), JSON.stringify({ ...record, ...patch })); + const lines = fs.readFileSync(path.join(dir, "users.ndjson"), "utf-8").split("\n"); + fs.writeFileSync( + path.join(dir, "users.ndjson"), + lines.filter((line) => line.includes('"u1"')).join("\n") + "\n", + ); + requests = []; + confirmMessages = []; + return dir; + }; + + // A human said yes, and the continue runs under -y, which never asks. + test("a continued run creates the rest in the mode it recorded, without asking", async () => { + await run(importOptions); + interruptAfterU1(); + + await run({ ...importOptions, yes: true }); + + expect(confirmMessages).toEqual([]); + expect(statuses()).toEqual([["verified", "reserved"]]); + }); + + test("refuses --reserve-unverified on a continue of a run that kept them unverified", async () => { + reserveAnswer = false; + await run(importOptions); + interruptAfterU1(); + + await expect(run({ ...importOptions, yes: true, reserveUnverified: true })).rejects.toThrow( + /created its users' unverified emails and phones unverified/, + ); + expect(created()).toHaveLength(0); + }); + + test("a run recorded before the mode was asks again", async () => { + await run(importOptions); + interruptAfterU1({ reserveUnverified: undefined }); + + await run(importOptions); + + expect(confirmMessages.some((message) => message.includes("never verified"))).toBe(true); + }); + test("a no keeps them unverified, attached after the user exists", async () => { reserveAnswer = false; diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 771608178..196a014c9 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -1083,6 +1083,20 @@ async function runImport(rawOptions: MigrateRunOptions, lock: ImportLock): Promi // checked or sent again. Those whose extra identifiers never attached // get just the attaches. const continued = resume.kind === "continue" ? resume.record : undefined; + // The users this run created were made in its reserve mode, so the rest + // are too: a continue reuses the mode rather than asking, and refuses a + // flag that asks for the other. A run from before this was recorded has + // none, and continues as it did. + const recordedReserve = continued?.reserveUnverified; + if (recordedReserve !== undefined) { + if (options.reserveUnverified && !recordedReserve) { + throwUsageError( + `Run ${continued!.id} created its users' unverified emails and phones unverified, so --reserve-unverified would create the rest differently. Nothing was imported.\n` + + "Continue it without --reserve-unverified, or pass --new-run to start a new run.", + ); + } + options = { ...options, reserveUnverified: recordedReserve }; + } // What the plan below is built from; checked again once the run's lock // is held, since a prompt can wait while an undo or a continue writes. const plannedLines = continued ? readUserLines(runsDir, continued.id).length : undefined; @@ -1211,13 +1225,14 @@ async function runImport(rawOptions: MigrateRunOptions, lock: ImportLock): Promi // `-y` is consent to write, not a yes to this, so it does not ask. if ( !reserveUnverified && + recordedReserve === undefined && withUnverified > 0 && !options.dryRun && !options.yes && canPrompt(options) ) { reserveUnverified = await confirm({ - message: `${plural(withUnverified, "user")} ${withUnverified === 1 ? "has" : "have"} an email or phone the source never verified. Create them reserved (usable for sign-in, locked to the user) instead of unverified?`, + message: `${plural(withUnverified, "user")} ${withUnverified === 1 ? "has" : "have"} an email or phone the source never verified. Create them reserved (usable for sign-in, locked to the user) instead of unverified? A reserved one another user already holds rejects its user, not just that address.`, default: false, }); } @@ -1446,6 +1461,7 @@ async function runImport(rawOptions: MigrateRunOptions, lock: ImportLock): Promi ...(input.fromExport ? { fromExport: input.fromExport } : {}), ...(options.envelopeSource ? { envelopeSource: options.envelopeSource } : {}), ...(firebaseHash ? { firebaseHash } : {}), + reserveUnverified, flags, }); // Printed now, not on the way out: on a Ctrl-C the signal handler exits From 43c0c246dd086a246c993ce6634868bd7e33819e Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 15 Oct 2026 02:06:12 -0400 Subject: [PATCH 14/23] fix(migrate): attach the reserved phones a cut-off retry left out when a continue adopts the user Every create attempt wrote `creating` with `reserved: true`, including the retry after a refused phone that sends without the reserved phones. Cut off before its answer, a continue adopted the user as reserved and never attached those phones. Each attempt's `creating` line now lists the reserved phones its body left out as `pending`, and a continue that adopts the user attaches them, unverified; the reserved emails aren't sent again. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 4 +- .../src/commands/migrate/import-users.test.ts | 30 ++++++++++++ .../src/commands/migrate/import-users.ts | 32 +++++++++++-- .../cli-core/src/commands/migrate/run.test.ts | 47 +++++++++++++++++++ packages/cli-core/src/commands/migrate/run.ts | 8 ++++ 5 files changed, 116 insertions(+), 5 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index ae0a92769..a7f620efd 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -1392,7 +1392,9 @@ A run records its choice (`reserveUnverified` in `run.json`), and a continued run reuses it without asking, so every user of one run is created the same way. `--reserve-unverified` on a continue of a run that kept them unverified is refused; pass `--new-run` to change modes. A user a run adopts keeps the mode -its first create recorded (`reserved` on its `creating` line). +its first create recorded (`reserved` on its `creating` line). When Clerk refused a phone and the retry went out +without the reserved phones, that line lists them as `pending`, and a continue +that adopts the user attaches them, unverified. Reserved, an unverified identifier is checked for duplicates like a verified one, because it goes on the user's create: one that an instance user or an diff --git a/packages/cli-core/src/commands/migrate/import-users.test.ts b/packages/cli-core/src/commands/migrate/import-users.test.ts index 736c57208..fd15e6db3 100644 --- a/packages/cli-core/src/commands/migrate/import-users.test.ts +++ b/packages/cli-core/src/commands/migrate/import-users.test.ts @@ -673,6 +673,29 @@ describe("importUsers", () => { expect(requests).toEqual([]); }); + // The retry that dropped them was cut off before its answer: the reserved + // phones it left out are attached, unverified, as they would have been. + test("attaches the reserved phones an adopted user's retried create left out", async () => { + stub(() => ok("idn_1")); + + await importUsers({ + users: [ + user({ unverifiedEmailAddresses: ["c@x.dev"], unverifiedPhoneNumbers: ["+31612345678"] }), + ], + adopted: new Map([["u1", "user_found"]]), + adoptedReserved: new Set(["u1"]), + adoptedPending: new Map([["u1", ["+31612345678"]]]), + secretKey: "sk_test_x", + limits: LIMITS, + record, + }); + + // The email went on the create reserved, so it is not attached again. + expect(requests.map((r) => r.body)).toEqual([ + { user_id: "user_found", phone_number: "+31612345678", primary: false, verified: false }, + ]); + }); + // Shapes from clerk_go's apierror: the country error carries its own code // and no param_name; the E.164 error is a form error on phone_number. test.each([ @@ -785,6 +808,13 @@ describe("importUsers", () => { expect(requests[1]?.body).toMatchObject({ phone_number: ["+15555550100"] }); expect(requests[1]?.body).not.toHaveProperty("phone_number_identification_status"); + // Recorded before the retry goes out, so a continue can attach them. + const creating = allLines.filter((line) => line.status === "creating"); + expect(creating[0]).not.toHaveProperty("pending"); + expect(creating[1]).toMatchObject({ + reserved: true, + pending: [{ kind: "phone", value: "+31612345678", verified: false }], + }); expect(lines.at(-1)?.error).toContain("Failed to add phone +31612345678:"); expect(lines.at(-1)?.error).not.toContain("+15555550100"); }); diff --git a/packages/cli-core/src/commands/migrate/import-users.ts b/packages/cli-core/src/commands/migrate/import-users.ts index c843ea0b0..f08acce91 100644 --- a/packages/cli-core/src/commands/migrate/import-users.ts +++ b/packages/cli-core/src/commands/migrate/import-users.ts @@ -327,7 +327,8 @@ async function createUser( user: User, identifiers: Identifiers, skipPasswordRequirement: boolean, - sending: () => void, + /** Called as each attempt goes out, with what it sends. */ + sending: (body: Record) => void, ): Promise { // A Ctrl-C or a full instance hands the slot on to queued creates; none // of them is sent, and none waits for a paced turn to find that out. @@ -335,7 +336,7 @@ async function createUser( ctx.schedule( async () => { if (ctx.stop.aborted) throw new NotSentError(); - sending(); + sending(body); return bapiRequest({ method: "POST", path: "/v1/users", @@ -447,6 +448,12 @@ export type ImportUsersOptions = { * run's `reserveUnverified` says. */ adoptedReserved?: Set; + /** + * Reserved phones an adopted user's create left out, from its `creating` + * line: the retry after a refused phone sends without them. Attached once + * the user is adopted, unverified, as they would have been. + */ + adoptedPending?: Map; /** Allow users that carry no password. */ skipPasswordRequirement?: boolean; /** Create the identifiers the source never verified as reserved. */ @@ -474,6 +481,7 @@ export async function importUsers(options: ImportUsersOptions): Promise(), adoptedReserved = new Set(), + adoptedPending = new Map(), skipPasswordRequirement = true, reserveUnverified = false, validationFailed = 0, @@ -573,14 +581,30 @@ export async function importUsers(options: ImportUsersOptions): Promise - createUser(ctx, user, identifiers, skipPasswordRequirement, () => { + createUser(ctx, user, identifiers, skipPasswordRequirement, (body) => { + // A retry after a refused phone sends without the reserved + // ones. Cut off before its answer, the user would be adopted + // as reserved, those phones never attached. + const sentPhones = new Set((body.phone_number as string[] | undefined) ?? []); + const leftOut = reserved + ? identifiers.unverifiedPhones.filter((phone) => !sentPhones.has(phone)) + : []; record({ sourceId: user.userId, status: "creating", ...(reserved ? { reserved: true } : {}), + ...(leftOut.length > 0 + ? { + pending: leftOut.map((value) => ({ + kind: "phone" as const, + value, + verified: false, + })), + } + : {}), }); sent = true; }), diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index c4425179b..ea2bcc057 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -2132,6 +2132,53 @@ describe("run", () => { }); }); + // The retry after a refused phone sent without the reserved phones, and was + // cut off: they are attached, the reserved email isn't sent again. + test("an adopted user gets the reserved phones its cut-off retry left out", async () => { + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([ + { id: "u1", primary_email_address: "a@x.dev" }, + { + id: "u2", + primary_email_address: "b@x.dev", + unverified_email_addresses: "c@x.dev", + unverified_phone_numbers: "+31612345678", + }, + ]), + ); + stubClerk({ failing: new Set(["u2"]) }); + await run(baseOptions); + const [first] = listRuns(runsDir()); + fs.appendFileSync( + path.join(runsDir(), first!.id, "users.ndjson"), + `${JSON.stringify({ + sourceId: "u2", + status: "creating", + reserved: true, + pending: [{ kind: "phone", value: "+31612345678", verified: false }], + })}\n`, + ); + interrupt(first!.id); + + requests = []; + process.exitCode = 0; + stubClerk({ + existing: [ + { id: "user_found", external_id: "u2", private_metadata: { clerkMigrateRun: first!.id } }, + ], + }); + await run(baseOptions); + + expect(created()).toEqual([]); + expect(requests.filter((r) => r.url.endsWith("/v1/email_addresses"))).toEqual([]); + expect( + requests.filter((r) => r.url.endsWith("/v1/phone_numbers")).map((r) => r.body), + ).toEqual([ + { user_id: "user_found", phone_number: "+31612345678", primary: false, verified: false }, + ]); + }); + // Created reserved by the run that stopped: its unverified email exists // reserved, and meets the requirement, though this run has no flag. test("an adopted user created reserved meets a required email without the flag", async () => { diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 196a014c9..194f18902 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -1104,10 +1104,17 @@ async function runImport(rawOptions: MigrateRunOptions, lock: ImportLock): Promi const attachOnly: UserLine[] = []; const inFlight: string[] = []; const inFlightReserved = new Set(); + const inFlightPending = new Map(); if (continued) { for (const line of latestUserLines(runsDir, continued.id).values()) { if (line.status === "creating") inFlight.push(line.sourceId); if (line.status === "creating" && line.reserved) inFlightReserved.add(line.sourceId); + if (line.status === "creating" && line.pending?.length) { + inFlightPending.set( + line.sourceId, + line.pending.filter((entry) => entry.kind === "phone").map((entry) => entry.value), + ); + } if (line.status !== "created" || !line.clerkId) continue; done.set(line.sourceId, line.clerkId); if (line.pending?.length) attachOnly.push(line); @@ -1492,6 +1499,7 @@ async function runImport(rawOptions: MigrateRunOptions, lock: ImportLock): Promi attachOnly, adopted, adoptedReserved: inFlightReserved, + adoptedPending: inFlightPending, skipPasswordRequirement: !options.requirePassword, reserveUnverified, progress, From fbe496d21e3817253c7cd62a198a4054f327c27f Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 16:22:53 -0400 Subject: [PATCH 15/23] fix(migrate): drop loose phones before counting a way in past a disabled provider, and record a legacy run's reservation answer - With numeric usernames on, a phone not in E.164 form counted as a way in for a Supabase user on a disabled provider, though the create strips it. The user was then created with no way to sign in. The sign-in check now strips those phones first, as the create does. - A continue of a run recorded before the reservation choice asked, but never wrote the answer, so a later `-y` continue created the rest unverified. The continued record now carries it. - README: what counts as a way in, the duplicate check's "passes the checks", a legacy run asking once, and a pending phone Clerk refuses again. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 33 ++++++++++--------- .../src/commands/migrate/lib/checks.test.ts | 20 +++++++++++ .../src/commands/migrate/lib/checks.ts | 6 ++-- .../commands/migrate/run-interactive.test.ts | 2 ++ packages/cli-core/src/commands/migrate/run.ts | 8 ++++- 5 files changed, 51 insertions(+), 18 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index a7f620efd..b78441f6f 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -856,10 +856,11 @@ after them. They sort the users four ways: any other user, with a warning. Spaces around a phone don't count, as Clerk trims them. Phones the instance has off are stripped anyway, so they are not checked - - it lacks an identifier the instance requires. An email or phone counts - only when it is verified, because an unverified one is attached after the - user exists, or with `--reserve-unverified`, which creates it reserved. A - required phone dropped for not being in E.164 form is named as such + - it lacks an identifier the instance requires. An email or phone counts when + it is verified, or when it is created reserved (`--reserve-unverified`, or + an adopted user's recorded mode). An unverified one attached after the user + exists does not. A required phone dropped for not being in E.164 form is + named as such - it has no identifier left once the emails and phones Clerk would refuse are stripped: those of an instance that neither has them on nor signs in or does MFA with them. A username is kept, as Clerk stores it with @@ -884,9 +885,9 @@ after them. They sort the users four ways: to 15, `scrypt_firebase`, `argon2i`/`argon2id` and `scrypt_werkzeug` are checked; other hashers are not, and Clerk refuses a bad one at create) - Supabase: its only providers are ones Clerk has off, or doesn't offer at all - (Figma, Kakao, Keycloak, WorkOS, Zoom, Fly), and it has no verified email or - phone the instance signs in with by code or link. The checks offer to turn - on the first kind; nothing can turn on the second + (Figma, Kakao, Keycloak, WorkOS, Zoom, Fly), and it has no verified or + reserved email or phone the instance signs in with by code or link. The + checks offer to turn on the first kind; nothing can turn on the second - its source ID, primary email, primary phone or username repeats an earlier user in the file that passes the checks above. Only what the create sends counts: an extra email is attached after it, and a stripped @@ -1389,18 +1390,20 @@ sign in with one nobody proved they own, so it is opt-in. `-y`, `--json` and agent mode never ask, and keep them unverified without the flag. A run records its choice (`reserveUnverified` in `run.json`), and a continued -run reuses it without asking, so every user of one run is created the same -way. `--reserve-unverified` on a continue of a run that kept them unverified is +run reuses it without asking, so every user of one run is created the same way. +A run recorded before the choice existed asks once, and records the answer. +`--reserve-unverified` on a continue of a run that kept them unverified is refused; pass `--new-run` to change modes. A user a run adopts keeps the mode -its first create recorded (`reserved` on its `creating` line). When Clerk refused a phone and the retry went out -without the reserved phones, that line lists them as `pending`, and a continue -that adopts the user attaches them, unverified. +its first create recorded (`reserved` on its `creating` line). When Clerk +refused a phone and the retry went out without the reserved phones, that line +lists them as `pending`, and a continue that adopts the user attaches them, +unverified (one Clerk refuses again fails with a note). Reserved, an unverified identifier is checked for duplicates like a verified one, because it goes on the user's create: one that an instance user or an -earlier record in the file already holds rejects the whole user, where without -reserving only that address would fail to attach, with a note. If that rejects -users you want, import without reserving. +earlier record in the file that passes the checks already holds rejects the +whole user, where without reserving only that address would fail to attach, with +a note. If that rejects users you want, import without reserving. ### Firebase hash parameters diff --git a/packages/cli-core/src/commands/migrate/lib/checks.test.ts b/packages/cli-core/src/commands/migrate/lib/checks.test.ts index 91b329790..818bafa37 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.test.ts @@ -482,6 +482,26 @@ describe("rejects", () => { ).toEqual({ u: "only signs in with Discord, which is not enabled in Clerk" }); }); + test("a phone not in E.164 form is no way in for a supabase user on a disabled provider", async () => { + expect( + await reasonsOf({ + settings: { + ...settings({ + phone_number: { + enabled: true, + used_for_first_factor: true, + first_factors: ["phone_code"], + }, + username: { enabled: true }, + }), + username_settings: { min_length: 4, max_length: 64, allow_numeric_usernames: true }, + } as unknown as UserSettingsJSON, + supabaseRows: [{ id: "u", raw_app_meta_data: { providers: ["discord"] } }], + users: [user("u", { email: undefined, username: "12345", phone: "(555) 123-4567" })], + }), + ).toEqual({ u: "only signs in with Discord, which is not enabled in Clerk" }); + }); + test("an unverified email is no way in for a supabase user on a disabled provider", async () => { expect( await reasonsOf({ diff --git a/packages/cli-core/src/commands/migrate/lib/checks.ts b/packages/cli-core/src/commands/migrate/lib/checks.ts index 769090825..3a7fbd4ec 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.ts @@ -776,8 +776,10 @@ function findDisabledProviderRejects(input: CheckInput): Map { const canSignInOtherwise = (id: string) => { const user = usersById.get(id); if (!user) return false; - // An email Clerk refuses is stripped before the create, so it is no way in. - const { emails, phones } = sentIdentifiers(dropRefusedEmails(user).user, reserves(user)); + // An email Clerk refuses, or a phone not in E.164 form with numeric + // usernames on, is stripped before the create, so it is no way in. + const sent = dropLoosePhones(dropRefusedEmails(user).user, input.settings).user; + const { emails, phones } = sentIdentifiers(sent, reserves(user)); return ( (emails.length > 0 && (strategies.has("email_code") || strategies.has("email_link"))) || (phones.length > 0 && strategies.has("phone_code")) diff --git a/packages/cli-core/src/commands/migrate/run-interactive.test.ts b/packages/cli-core/src/commands/migrate/run-interactive.test.ts index a7a0ffeb8..1c5cd18ea 100644 --- a/packages/cli-core/src/commands/migrate/run-interactive.test.ts +++ b/packages/cli-core/src/commands/migrate/run-interactive.test.ts @@ -460,6 +460,8 @@ describe("unverified identifiers", () => { await run(importOptions); expect(confirmMessages.some((message) => message.includes("never verified"))).toBe(true); + // Recorded, so a later `-y` continue reserves the rest as this one did. + expect(listRuns(runsDir())[0]?.reserveUnverified).toBe(true); }); test("a no keeps them unverified, attached after the user exists", async () => { diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 194f18902..8893968f0 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -1456,7 +1456,13 @@ async function runImport(rawOptions: MigrateRunOptions, lock: ImportLock): Promi const flags = pickImportFlags(options); const run = continued ? continueWithExport( - continueRun(runsDir, withAttempt(continued, target, flags), plannedLines, carry), + // The answer is recorded, so a later `-y` continue keeps it. + continueRun( + runsDir, + { ...withAttempt(continued, target, flags), reserveUnverified }, + plannedLines, + carry, + ), input.fromExport, ) : startRun(runsDir, { From 58a2814a1508d56b87ecd735e77adc5b9d3368cd Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 17:54:03 -0400 Subject: [PATCH 16/23] fix(migrate): continue an earlier run in its own reservation mode from a --new-run refusal The refusal's "Drop --new-run" command took --reserve-unverified from the new run's options. A continue of a run that kept its users unverified refuses that flag, so the command failed as printed. It now uses the earlier run's recorded mode. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/run.test.ts | 17 +++++++++++++++++ packages/cli-core/src/commands/migrate/run.ts | 3 +++ 2 files changed, 20 insertions(+) diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index ea2bcc057..244bce921 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -1257,6 +1257,23 @@ describe("run", () => { expect(error.examples ?? []).toEqual([]); }); + // A continue of a run that kept them unverified refuses the flag. + test("--new-run's continue keeps the earlier run's reservation mode", async () => { + stubClerk({ failing: new Set(["u2"]) }); + await run(baseOptions); + const [first] = listRuns(runsDir()); + expect(first?.reserveUnverified).toBe(false); + captured.clear(); + stubClerk({ existing: [{ id: "user_u1", external_id: "u1" }] }); + + const error = (await run({ ...baseOptions, newRun: true, reserveUnverified: true }).catch( + (caught: unknown) => caught, + )) as CliError; + + expect(error.message).toContain(`Drop --new-run to continue run ${first!.id}.`); + expect(error.examples?.[0]?.command).not.toContain("--reserve-unverified"); + }); + // The continue would carry --require-password and leave the same users out. test("--new-run --require-password doesn't offer a continue that imports nobody", async () => { await run({ ...baseOptions, requirePassword: true }); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 8893968f0..6b523028d 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -1339,6 +1339,9 @@ async function runImport(rawOptions: MigrateRunOptions, lock: ImportLock): Promi newRun: false, allowPartial: options.allowPartial || recorded.allowPartial, skipLegalChecks: options.skipLegalChecks || recorded.skipLegalChecks, + // Its own mode: a continue refuses --reserve-unverified on a run + // that kept them unverified. + reserveUnverified: earlierRun.reserveUnverified ?? options.reserveUnverified, }, input.fromExport, ["--yes"], From 04767be2c23ea8942cf764cce3d379e97c067e46 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 21 Oct 2026 15:33:35 -0400 Subject: [PATCH 17/23] fix(migrate): keep reserved and left-out phones on a create that got no answer With --reserve-unverified, a create that got a network error or 5xx wrote a later `creating` line without `reserved` or `pending`. A continue reads only the latest line, so it adopted the user as unreserved, sent the email Clerk already held reserved, and never attached the phones the retried create had left out. The unknown-outcome line now keeps both from the attempt. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/import-users.test.ts | 55 +++++++++++++++++++ .../src/commands/migrate/import-users.ts | 15 ++++- .../cli-core/src/commands/migrate/run.test.ts | 39 +++++++++++++ 3 files changed, 106 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/import-users.test.ts b/packages/cli-core/src/commands/migrate/import-users.test.ts index fd15e6db3..ccc30f1a8 100644 --- a/packages/cli-core/src/commands/migrate/import-users.test.ts +++ b/packages/cli-core/src/commands/migrate/import-users.test.ts @@ -819,6 +819,61 @@ describe("importUsers", () => { expect(lines.at(-1)?.error).not.toContain("+15555550100"); }); + // A continue reads only the latest line: with no answer, it has to keep + // what the attempt reserved and left out, or the user is adopted as if + // nothing was reserved and the left-out phones are never attached. + test("keeps reserved and the left-out phones when the retried create gets no answer", async () => { + stub((url, attempt) => + !url.endsWith("/v1/users") + ? ok("idn_1") + : attempt === 1 + ? new Response( + JSON.stringify({ + errors: [ + { + code: "form_param_format_invalid", + message: "bad phone", + meta: { param_name: "phone_number" }, + }, + ], + }), + { status: 422 }, + ) + : new Response("upstream", { status: 503 }), + ); + + const summary = await importUsers({ + users: [withPhones()], + secretKey: "sk_test_x", + limits: LIMITS, + record, + reserveUnverified: true, + }); + + expect(summary.failed).toBe(1); + expect(allLines.at(-1)).toMatchObject({ + status: "creating", + reserved: true, + pending: [{ kind: "phone", value: "+31612345678", verified: false }], + error: expect.stringContaining("a re-run checks"), + }); + }); + + test("keeps reserved when a reserved create gets no answer", async () => { + stub(() => new Response("upstream", { status: 503 })); + + await importUsers({ + users: [user({ unverifiedEmailAddresses: ["c@x.dev"] })], + secretKey: "sk_test_x", + limits: LIMITS, + record, + reserveUnverified: true, + }); + + expect(allLines.at(-1)).toMatchObject({ status: "creating", reserved: true }); + expect(allLines.at(-1)).not.toHaveProperty("pending"); + }); + test("creates a user with no email on its verified phone", async () => { refusePhone(1); const summary = await importUsers({ diff --git a/packages/cli-core/src/commands/migrate/import-users.ts b/packages/cli-core/src/commands/migrate/import-users.ts index f08acce91..eb4bde001 100644 --- a/packages/cli-core/src/commands/migrate/import-users.ts +++ b/packages/cli-core/src/commands/migrate/import-users.ts @@ -534,15 +534,20 @@ export async function importUsers(options: ImportUsersOptions): Promise { failed++; processed++; tallyError(errorBreakdown, message); // With no answer, the user stays `creating`: Clerk may hold it, and a - // re-run looks it up before creating it again. The line still says why. + // re-run looks it up before creating it again. The line still says why, + // and keeps what the attempt reserved and left out: a continue reads only + // the latest line to adopt the user the way it was created. record({ sourceId: userId, status: unknown ? "creating" : "failed", + ...(unknown && attempt?.reserved ? { reserved: true } : {}), + ...(unknown && attempt?.pending ? { pending: attempt.pending } : {}), error: [message, ...notes].join("; "), code, }); @@ -579,6 +584,8 @@ export async function importUsers(options: ImportUsersOptions): Promise 0 || identifiers.unverifiedPhones.length > 0); let sent = false; + // The `creating` line of the latest attempt, for an unknown outcome to keep. + let attempt: UserLine | undefined; try { created = adoptedId ? { clerkUserId: adoptedId, notes: [], attachLater: adoptedPending.get(user.userId) ?? [] } @@ -592,7 +599,7 @@ export async function importUsers(options: ImportUsersOptions): Promise !sentPhones.has(phone)) : []; - record({ + attempt = { sourceId: user.userId, status: "creating", ...(reserved ? { reserved: true } : {}), @@ -605,7 +612,8 @@ export async function importUsers(options: ImportUsersOptions): Promise; + /** Source IDs whose `POST /v1/users` gets a 503: Clerk may or may not hold them. */ + unanswered?: Set; /** `GET /v1/instance` fails, so the target falls back to the key's stand-in ID. */ instanceDown?: boolean; /** `max_allowed_users` on `GET /v1/instance`; left out when undefined, as an older API does. */ @@ -177,6 +179,7 @@ describe("run", () => { } if (method === "POST" && url.pathname === "/v1/users") { const externalId = body?.external_id as string; + if (stub.unanswered?.has(externalId)) return new Response("upstream", { status: 503 }); if (stub.failing?.has(externalId)) { return Response.json( { @@ -2196,6 +2199,42 @@ describe("run", () => { ]); }); + // A reserved create with no answer: its latest line has to keep `reserved`, + // or the continue adopts the user as unreserved and attaches the email + // Clerk already holds reserved. + test("adopts a reserved create that got no answer as reserved", async () => { + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([ + { id: "u1", primary_email_address: "a@x.dev" }, + { id: "u2", primary_email_address: "b@x.dev", unverified_email_addresses: "c@x.dev" }, + ]), + ); + stubClerk({ unanswered: new Set(["u2"]) }); + await run({ ...baseOptions, reserveUnverified: true }); + const [first] = listRuns(runsDir()); + expect(latestUserLines(runsDir(), first!.id).get("u2")).toMatchObject({ + status: "creating", + reserved: true, + }); + + requests = []; + process.exitCode = 0; + stubClerk({ + existing: [ + { id: "user_found", external_id: "u2", private_metadata: { clerkMigrateRun: first!.id } }, + ], + }); + await run({ ...baseOptions, reserveUnverified: true }); + + expect(created()).toEqual([]); + expect(requests.filter((r) => r.url.endsWith("/v1/email_addresses"))).toEqual([]); + expect(latestUserLines(runsDir(), first!.id).get("u2")).toMatchObject({ + status: "created", + clerkId: "user_found", + }); + }); + // Created reserved by the run that stopped: its unverified email exists // reserved, and meets the requirement, though this run has no flag. test("an adopted user created reserved meets a required email without the flag", async () => { From 5b006bb9a1d3562615a24028a61e97e143ae89db Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 27 Oct 2026 15:53:16 -0400 Subject: [PATCH 18/23] fix(migrate): name --reserve-unverified in an unverified-only reject A user whose only email or phone is unverified is rejected where the instance requires one, and the fixes offered only making it optional. `--reserve-unverified` imports them, but only the prompt said so, and `--json` and agents never see it. The reject reason now names the flag and its tradeoff, as the legal-acceptance reject names --skip-legal-checks. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 2 +- packages/cli-core/src/commands/migrate/lib/checks.test.ts | 3 ++- packages/cli-core/src/commands/migrate/lib/checks.ts | 4 ++-- packages/cli-core/src/commands/migrate/run.test.ts | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index b78441f6f..d7e9b86b3 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -919,7 +919,7 @@ would be refused, and 0 otherwise. Checks 120 users checked ✗ 12 users rejected - 12: only has an unverified email, and this instance requires an email + 12: only has an unverified email, and this instance requires an email (--reserve-unverified imports it reserved: usable for sign-in, though never verified) u_17, u_22, u_40, u_51, u_88, and 7 more ⚠ Imported, but not everything comes across 6 users have a username, which this instance does not use: it is stored, and works only once usernames are turned on diff --git a/packages/cli-core/src/commands/migrate/lib/checks.test.ts b/packages/cli-core/src/commands/migrate/lib/checks.test.ts index 818bafa37..f7d554f51 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.test.ts @@ -310,7 +310,8 @@ describe("rejects", () => { ], }), ).toEqual({ - unverified: "only has an unverified email, and this instance requires an email", + unverified: + "only has an unverified email, and this instance requires an email (--reserve-unverified imports it reserved: usable for sign-in, though never verified)", none: "no email, which this instance requires", }); }); diff --git a/packages/cli-core/src/commands/migrate/lib/checks.ts b/packages/cli-core/src/commands/migrate/lib/checks.ts index 3a7fbd4ec..345da3024 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.ts @@ -221,7 +221,7 @@ function missingRequiredIdentifier( !reserved(identifiers.unverifiedEmails) ) { return identifiers.unverifiedEmails.length > 0 - ? "only has an unverified email, and this instance requires an email" + ? "only has an unverified email, and this instance requires an email (--reserve-unverified imports it reserved: usable for sign-in, though never verified)" : "no email, which this instance requires"; } if ( @@ -230,7 +230,7 @@ function missingRequiredIdentifier( !reserved(identifiers.unverifiedPhones) ) { return identifiers.unverifiedPhones.length > 0 - ? "only has an unverified phone number, and this instance requires one" + ? "only has an unverified phone number, and this instance requires one (--reserve-unverified imports it reserved: usable for sign-in, though never verified)" : "no phone number, which this instance requires"; } if (required("username") && !hasValue(user.username)) { diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index eb9472268..6cf77978c 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -1493,7 +1493,7 @@ describe("run", () => { await run({ ...baseOptions, dryRun: true }); expect(captured.err).toContain( - "only has an unverified email, and this instance requires an email", + "only has an unverified email, and this instance requires an email (--reserve-unverified imports it reserved: usable for sign-in, though never verified)", ); expect(captured.err).toContain( `clerk config patch --app APP_ID --instance ins_1 --json '{"auth_email":{"required_for_sign_up":false}}'`, From 530499b00e9b25f01576fa446b10f84440cac6f3 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 27 Oct 2026 16:48:26 -0400 Subject: [PATCH 19/23] fix(migrate): say a reserved identifier's source never verified it The unverified-only reject said --reserve-unverified imports the address "though never verified", but a reserved identifier becomes verified the first time the user signs in with it. It now says the source never verified it, which is the tradeoff. The E2E test still asserted the reason from before --reserve-unverified was named in it, and failed on every run; it now asserts this one. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 2 +- packages/cli-core/src/commands/migrate/lib/checks.test.ts | 2 +- packages/cli-core/src/commands/migrate/lib/checks.ts | 4 ++-- packages/cli-core/src/commands/migrate/run.test.ts | 2 +- test/e2e/migrate.test.ts | 3 ++- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index d7e9b86b3..9cd99a48f 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -919,7 +919,7 @@ would be refused, and 0 otherwise. Checks 120 users checked ✗ 12 users rejected - 12: only has an unverified email, and this instance requires an email (--reserve-unverified imports it reserved: usable for sign-in, though never verified) + 12: only has an unverified email, and this instance requires an email (--reserve-unverified imports it reserved: usable for sign-in, though the source never verified it) u_17, u_22, u_40, u_51, u_88, and 7 more ⚠ Imported, but not everything comes across 6 users have a username, which this instance does not use: it is stored, and works only once usernames are turned on diff --git a/packages/cli-core/src/commands/migrate/lib/checks.test.ts b/packages/cli-core/src/commands/migrate/lib/checks.test.ts index f7d554f51..ce6004a3e 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.test.ts @@ -311,7 +311,7 @@ describe("rejects", () => { }), ).toEqual({ unverified: - "only has an unverified email, and this instance requires an email (--reserve-unverified imports it reserved: usable for sign-in, though never verified)", + "only has an unverified email, and this instance requires an email (--reserve-unverified imports it reserved: usable for sign-in, though the source never verified it)", none: "no email, which this instance requires", }); }); diff --git a/packages/cli-core/src/commands/migrate/lib/checks.ts b/packages/cli-core/src/commands/migrate/lib/checks.ts index 345da3024..7f2588f64 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.ts @@ -221,7 +221,7 @@ function missingRequiredIdentifier( !reserved(identifiers.unverifiedEmails) ) { return identifiers.unverifiedEmails.length > 0 - ? "only has an unverified email, and this instance requires an email (--reserve-unverified imports it reserved: usable for sign-in, though never verified)" + ? "only has an unverified email, and this instance requires an email (--reserve-unverified imports it reserved: usable for sign-in, though the source never verified it)" : "no email, which this instance requires"; } if ( @@ -230,7 +230,7 @@ function missingRequiredIdentifier( !reserved(identifiers.unverifiedPhones) ) { return identifiers.unverifiedPhones.length > 0 - ? "only has an unverified phone number, and this instance requires one (--reserve-unverified imports it reserved: usable for sign-in, though never verified)" + ? "only has an unverified phone number, and this instance requires one (--reserve-unverified imports it reserved: usable for sign-in, though the source never verified it)" : "no phone number, which this instance requires"; } if (required("username") && !hasValue(user.username)) { diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 6cf77978c..067c90e9d 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -1493,7 +1493,7 @@ describe("run", () => { await run({ ...baseOptions, dryRun: true }); expect(captured.err).toContain( - "only has an unverified email, and this instance requires an email (--reserve-unverified imports it reserved: usable for sign-in, though never verified)", + "only has an unverified email, and this instance requires an email (--reserve-unverified imports it reserved: usable for sign-in, though the source never verified it)", ); expect(captured.err).toContain( `clerk config patch --app APP_ID --instance ins_1 --json '{"auth_email":{"required_for_sign_up":false}}'`, diff --git a/test/e2e/migrate.test.ts b/test/e2e/migrate.test.ts index dc33796d8..24710feda 100644 --- a/test/e2e/migrate.test.ts +++ b/test/e2e/migrate.test.ts @@ -278,7 +278,8 @@ test("a user whose only email is unverified is refused where email is required", expect(latestLines(run.id)).toEqual([ expect.objectContaining({ status: "skipped", - reason: "only has an unverified email, and this instance requires an email", + reason: + "only has an unverified email, and this instance requires an email (--reserve-unverified imports it reserved: usable for sign-in, though the source never verified it)", }), ]); }, 60_000); From d3ef52a5b6639c07daa5fb32a8c44546ac3794ae Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 28 Oct 2026 13:31:23 -0400 Subject: [PATCH 20/23] docs(migrate): say a reserved identifier is verified by a code or link The README said a reserved identifier becomes verified the first time the user signs in with it. Clerk promotes it only when the sign-in's first factor proves ownership, a code or link sent to it; signing in with a password, using the address as the identifier, leaves it reserved. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 9cd99a48f..e124b80d7 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -1384,8 +1384,8 @@ cannot sign in with it, and another user can claim it by verifying it first. any, creates them **reserved** instead, on `POST /v1/users` through `email_address_identification_status` / `phone_number_identification_status`. A reserved identifier is unverified, but usable for sign-in and locked to the -user, and becomes verified the first time the user signs in with it. That is -how most source platforms treat an unconfirmed address, but it lets the user +user, and becomes verified the first time the user signs in with a code or +link sent to it. That is how most source platforms treat an unconfirmed address, but it lets the user sign in with one nobody proved they own, so it is opt-in. `-y`, `--json` and agent mode never ask, and keep them unverified without the flag. From 9bca96e590f765124b14be70f9672de9131a4841 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 28 Oct 2026 17:44:41 -0400 Subject: [PATCH 21/23] docs(migrate): split the reserved-duplicate sentence in two The README put both outcomes of a duplicate reserved identifier in one sentence. It now says them separately: one another user holds rejects the whole user, and without reserving only that address fails to attach. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index e124b80d7..46db50bce 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -1399,11 +1399,11 @@ refused a phone and the retry went out without the reserved phones, that line lists them as `pending`, and a continue that adopts the user attaches them, unverified (one Clerk refuses again fails with a note). -Reserved, an unverified identifier is checked for duplicates like a verified -one, because it goes on the user's create: one that an instance user or an -earlier record in the file that passes the checks already holds rejects the -whole user, where without reserving only that address would fail to attach, with -a note. If that rejects users you want, import without reserving. +A reserved identifier is checked for duplicates like a verified one, since it +goes on the user's create. If an instance user, or an earlier record in the +file that passes the checks, already holds it, the whole user is rejected. +Without reserving, only that address fails to attach, with a note. If that +rejects users you want, import without reserving. ### Firebase hash parameters From c662cdcf209766d289ef6576be29afbc5b1f01c3 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 28 Oct 2026 19:03:45 -0400 Subject: [PATCH 22/23] docs(migrate): say production instances have no user limit The README said production instances have no user limit "by default", and that an older API leaves a production import unchecked until the first refusal. A comment in run.ts said a plan can impose one. Clerk sets `max_allowed_users` to null on every production instance, created or cloned, and refuses to set one there, so only a development instance has a limit. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 8 ++++---- packages/cli-core/src/commands/migrate/run.ts | 3 +-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 46db50bce..5edba5995 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -991,10 +991,10 @@ A non-numeric or non-positive value is ignored in favour of the default. An instance's user limit is checked with the other [checks](#checks), against the live count (`GET /v1/users/count`). The limit is the one Clerk reports as `max_allowed_users` on `GET /v1/instance`, on any instance; `null` there means -no limit. New development instances are created with a 100-user limit, and -production instances have none by default. An API that predates the field -leaves a development instance at `CLERK_MIGRATE_DEV_USER_LIMIT`, or 100, and a -production one unchecked: the import then stops at the first refusal. +no limit. Development instances are created with a 100-user limit. Production +instances have none: Clerk reports `null` for them and won't set one. An API +that predates the field leaves a development instance at +`CLERK_MIGRATE_DEV_USER_LIMIT`, or 100. `--allow-partial` imports up to the headroom. When a limit applies, `--json`'s `checks.quota` says where it came from (`limitSource`: `instance`, `env` or `default`). When no limit applies, `checks.quota` is omitted. diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 6b523028d..51dd2513d 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -230,8 +230,7 @@ export function explainErrors(errors: Iterable, instanceType: InstanceTy ); } - // Production has no user limit unless a plan imposes one, and the API's own - // message already names the fix ("upgrade to a paid plan") in that case. + // Only a development instance has a user limit; Clerk sets none on production. if ( instanceType === "dev" && all.some((error) => /You have reached your limit of \d+ users/.test(error)) From 3b8a25bedad3c86e91921e69618173323b1efdbd Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 30 Oct 2026 15:57:01 -0400 Subject: [PATCH 23/23] fix(migrate): don't suggest --reserve-unverified to a continue that refuses it A continue of a run that recorded `reserveUnverified: false` takes the run's mode, so its checks named --reserve-unverified in the unverified-only reject reasons, and adding the flag then exited 2 ("Continue it without --reserve-unverified, or pass --new-run"). On such a continue the email and phone reasons now leave the hint out. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/lib/checks.test.ts | 13 +++++++++++++ .../cli-core/src/commands/migrate/lib/checks.ts | 16 +++++++++++++--- packages/cli-core/src/commands/migrate/run.ts | 1 + 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/lib/checks.test.ts b/packages/cli-core/src/commands/migrate/lib/checks.test.ts index ce6004a3e..1f8816fbb 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.test.ts @@ -316,6 +316,19 @@ describe("rejects", () => { }); }); + // A continue of a run that kept them unverified refuses --reserve-unverified. + test("doesn't suggest --reserve-unverified to a continue that refuses it", async () => { + expect( + await reasonsOf({ + settings: EMAIL_REQUIRED, + reserveLocked: true, + users: [user("unverified", { email: undefined, unverifiedEmailAddresses: ["u@x.dev"] })], + }), + ).toEqual({ + unverified: "only has an unverified email, and this instance requires an email", + }); + }); + // Reserved is usable for sign-in, so it meets the requirement (checked live // against the E2E test app, which requires an email). test("a reserved email meets an email requirement", async () => { diff --git a/packages/cli-core/src/commands/migrate/lib/checks.ts b/packages/cli-core/src/commands/migrate/lib/checks.ts index 7f2588f64..57e5942a8 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.ts @@ -130,6 +130,11 @@ export type CheckInput = { skipLegalChecks?: boolean; /** Unverified identifiers are created reserved, so they meet a requirement. */ reserveUnverified?: boolean; + /** + * A continue of a run that kept unverified identifiers unverified. It refuses + * --reserve-unverified, so the reject reasons don't suggest it. + */ + reserveLocked?: boolean; /** * Source IDs of adopted users whose create reserved their unverified * identifiers: those exist reserved, whatever this run's flag says. @@ -204,16 +209,21 @@ export function hashShapeProblem(password: string, hasher: string): string | und * An email or phone counts only when it is verified: an unverified one is * attached after the user exists, so it cannot satisfy a sign-up requirement. * With `reserveUnverified` it goes on the create as reserved, which does. + * `suggestReserve` names that flag in the reason, unless the run refuses it. */ function missingRequiredIdentifier( user: User, settings: UserSettingsJSON | null, reserveUnverified = false, + suggestReserve = true, ): string | undefined { if (!settings) return undefined; const required = (attribute: AttributeName) => isRequired(settings, attribute); const identifiers = splitIdentifiers(user); const reserved = (unverified: string[]) => reserveUnverified && unverified.length > 0; + const hint = suggestReserve + ? " (--reserve-unverified imports it reserved: usable for sign-in, though the source never verified it)" + : ""; if ( required("email_address") && @@ -221,7 +231,7 @@ function missingRequiredIdentifier( !reserved(identifiers.unverifiedEmails) ) { return identifiers.unverifiedEmails.length > 0 - ? "only has an unverified email, and this instance requires an email (--reserve-unverified imports it reserved: usable for sign-in, though the source never verified it)" + ? `only has an unverified email, and this instance requires an email${hint}` : "no email, which this instance requires"; } if ( @@ -230,7 +240,7 @@ function missingRequiredIdentifier( !reserved(identifiers.unverifiedPhones) ) { return identifiers.unverifiedPhones.length > 0 - ? "only has an unverified phone number, and this instance requires one (--reserve-unverified imports it reserved: usable for sign-in, though the source never verified it)" + ? `only has an unverified phone number, and this instance requires one${hint}` : "no phone number, which this instance requires"; } if (required("username") && !hasValue(user.username)) { @@ -1139,7 +1149,7 @@ export async function checkImport(input: CheckInput): Promise { !missingRequiredIdentifier(emailed, input.settings, reserve) ? "its phones aren't in E.164 form, and this instance requires a phone number (numeric usernames are on)" : undefined) ?? - missingRequiredIdentifier(user, input.settings, reserve) ?? + missingRequiredIdentifier(user, input.settings, reserve, !input.reserveLocked) ?? // Stripping the identifiers the instance has off can leave nothing to // sign in with; Clerk would still create the user. (!hasAnyIdentifier(sent) diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 51dd2513d..97f16a7fc 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -1251,6 +1251,7 @@ async function runImport(rawOptions: MigrateRunOptions, lock: ImportLock): Promi users, skipLegalChecks, reserveUnverified, + reserveLocked: recordedReserve === false, failures, unknownFields: loaded.unknownFields, ...(supabaseRows ? { supabaseRows } : {}),