diff --git a/apps/backend/prisma/migrations/20260501000000_auth_data_migration_jobs/migration.sql b/apps/backend/prisma/migrations/20260501000000_auth_data_migration_jobs/migration.sql new file mode 100644 index 0000000000..efa3ed3355 --- /dev/null +++ b/apps/backend/prisma/migrations/20260501000000_auth_data_migration_jobs/migration.sql @@ -0,0 +1,33 @@ +CREATE TABLE "AuthDataMigrationJob" ( + "tenancyId" UUID NOT NULL, + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "projectId" TEXT NOT NULL, + "branchId" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "encryptedCredentials" JSONB NOT NULL, + "createdByProjectUserId" UUID, + "attemptCount" INTEGER NOT NULL DEFAULT 0, + "maxAttempts" INTEGER NOT NULL DEFAULT 5, + "nextAttemptAt" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP, + "startedAt" TIMESTAMP(3), + "finishedAt" TIMESTAMP(3), + "lastErrorExternalMessage" TEXT, + "lastErrorInternalDetails" JSONB, + "result" JSONB, + + CONSTRAINT "AuthDataMigrationJob_pkey" PRIMARY KEY ("tenancyId", "id"), + CONSTRAINT "AuthDataMigrationJob_tenancyId_fkey" FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "AuthDataMigrationJob_provider_valid" CHECK ("provider" IN ('workos', 'clerk', 'authjs', 'auth0', 'supabase', 'better_auth')), + CONSTRAINT "AuthDataMigrationJob_status_valid" CHECK ("status" IN ('PENDING', 'RUNNING', 'WAITING_RETRY', 'SUCCEEDED', 'FAILED')), + CONSTRAINT "AuthDataMigrationJob_attempts_valid" CHECK ("attemptCount" >= 0 AND "maxAttempts" > 0), + CONSTRAINT "AuthDataMigrationJob_terminal_finished" CHECK ( + ("status" IN ('SUCCEEDED', 'FAILED') AND "finishedAt" IS NOT NULL) + OR ("status" NOT IN ('SUCCEEDED', 'FAILED')) + ) +); + +CREATE INDEX "AuthDataMigrationJob_queue_idx" ON "AuthDataMigrationJob"("status", "nextAttemptAt", "createdAt"); +CREATE INDEX "AuthDataMigrationJob_tenancy_created_idx" ON "AuthDataMigrationJob"("tenancyId", "createdAt"); diff --git a/apps/backend/prisma/migrations/20260501000000_auth_data_migration_jobs/tests/job-table-constraints.ts b/apps/backend/prisma/migrations/20260501000000_auth_data_migration_jobs/tests/job-table-constraints.ts new file mode 100644 index 0000000000..ecec154f97 --- /dev/null +++ b/apps/backend/prisma/migrations/20260501000000_auth_data_migration_jobs/tests/job-table-constraints.ts @@ -0,0 +1,51 @@ +import { randomUUID } from "crypto"; +import type { Sql } from "postgres"; +import { expect } from "vitest"; + +export const preMigration = async (sql: Sql) => { + const projectId = `test-${randomUUID()}`; + const tenancyId = randomUUID(); + + await sql`INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") VALUES (${projectId}, NOW(), NOW(), 'Auth Migration Test', '', false)`; + await sql`INSERT INTO "Tenancy" ("id", "createdAt", "updatedAt", "projectId", "branchId", "hasNoOrganization") VALUES (${tenancyId}::uuid, NOW(), NOW(), ${projectId}, 'main', 'TRUE'::"BooleanTrue")`; + + return { projectId, tenancyId }; +}; + +export const postMigration = async (sql: Sql, ctx: Awaited>) => { + const jobId = randomUUID(); + + await sql` + INSERT INTO "AuthDataMigrationJob" ( + "tenancyId", "id", "projectId", "branchId", "provider", "encryptedCredentials" + ) + VALUES ( + ${ctx.tenancyId}::uuid, ${jobId}::uuid, ${ctx.projectId}, 'main', 'better_auth', '{"ciphertext_base64":"abc"}'::jsonb + ) + `; + + const rows = await sql` + SELECT "status", "attemptCount", "maxAttempts" + FROM "AuthDataMigrationJob" + WHERE "tenancyId" = ${ctx.tenancyId}::uuid AND "id" = ${jobId}::uuid + `; + expect(rows).toHaveLength(1); + expect(rows[0].status).toBe("PENDING"); + expect(rows[0].attemptCount).toBe(0); + expect(rows[0].maxAttempts).toBe(5); + + await expect(sql` + INSERT INTO "AuthDataMigrationJob" ( + "tenancyId", "projectId", "branchId", "provider", "status", "encryptedCredentials" + ) + VALUES ( + ${ctx.tenancyId}::uuid, ${ctx.projectId}, 'main', 'unknown', 'PENDING', '{"ciphertext_base64":"abc"}'::jsonb + ) + `).rejects.toThrow(/AuthDataMigrationJob_provider_valid/); + + await expect(sql` + UPDATE "AuthDataMigrationJob" + SET "status" = 'SUCCEEDED', "finishedAt" = NULL + WHERE "tenancyId" = ${ctx.tenancyId}::uuid AND "id" = ${jobId}::uuid + `).rejects.toThrow(/AuthDataMigrationJob_terminal_finished/); +}; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 1b20c77b17..f656880f58 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -73,6 +73,7 @@ model Tenancy { organizationId String? @db.Uuid hasNoOrganization BooleanTrue? emailOutboxes EmailOutbox[] + authDataMigrationJobs AuthDataMigrationJob[] sessionReplays SessionReplay[] sessionReplayChunks SessionReplayChunk[] managedEmailDomains ManagedEmailDomain[] @@ -1076,6 +1077,39 @@ model EmailOutboxProcessingMetadata { lastExecutedAt DateTime? } +model AuthDataMigrationJob { + tenancyId String @db.Uuid + id String @default(uuid()) @db.Uuid + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + projectId String + branchId String + provider String + status String @default("PENDING") + + encryptedCredentials Json + createdByProjectUserId String? @db.Uuid + + attemptCount Int @default(0) + maxAttempts Int @default(5) + nextAttemptAt DateTime? @default(now()) + + startedAt DateTime? + finishedAt DateTime? + + lastErrorExternalMessage String? + lastErrorInternalDetails Json? + result Json? + + tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade) + + @@id([tenancyId, id]) + @@index([status, nextAttemptAt, createdAt], map: "AuthDataMigrationJob_queue_idx") + @@index([tenancyId, createdAt], map: "AuthDataMigrationJob_tenancy_created_idx") +} + model EmailDraft { tenancyId String @db.Uuid diff --git a/apps/backend/scripts/run-cron-jobs.ts b/apps/backend/scripts/run-cron-jobs.ts index 0eea97a0ff..f7c37b60e1 100644 --- a/apps/backend/scripts/run-cron-jobs.ts +++ b/apps/backend/scripts/run-cron-jobs.ts @@ -6,6 +6,7 @@ import { Result } from "@stackframe/stack-shared/dist/utils/results"; const endpoints = [ "/api/latest/internal/external-db-sync/sequencer", "/api/latest/internal/external-db-sync/poller", + "/api/latest/internal/auth-migrations/process", ]; async function main() { diff --git a/apps/backend/src/app/api/latest/internal/auth-migrations/[id]/retry/route.tsx b/apps/backend/src/app/api/latest/internal/auth-migrations/[id]/retry/route.tsx new file mode 100644 index 0000000000..c513262a48 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/auth-migrations/[id]/retry/route.tsx @@ -0,0 +1,49 @@ +import { retryAuthMigrationJob } from "@/lib/auth-migrations/jobs"; +import { authMigrationProviders } from "@/lib/auth-migrations/types"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, adminAuthTypeSchema, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; + +const jobResponseSchema = yupObject({ + id: yupString().uuid().defined(), + provider: yupString().oneOf(authMigrationProviders).defined(), + status: yupString().oneOf(["PENDING", "RUNNING", "WAITING_RETRY", "SUCCEEDED", "FAILED"]).defined(), + attempt_count: yupNumber().integer().defined(), + max_attempts: yupNumber().integer().defined(), + next_attempt_at_millis: yupNumber().nullable().defined(), + started_at_millis: yupNumber().nullable().defined(), + finished_at_millis: yupNumber().nullable().defined(), + last_error_external_message: yupString().nullable().defined(), + result: yupMixed().nullable(), + created_at_millis: yupNumber().defined(), + updated_at_millis: yupNumber().defined(), +}).defined(); + +export const POST = createSmartRouteHandler({ + metadata: { + hidden: true, + summary: "Retry auth migration job", + description: "Moves a failed or waiting provider migration job back to the pending queue.", + tags: ["Migrations"], + }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + params: yupObject({ + id: yupString().uuid().defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: jobResponseSchema, + }), + handler: async ({ auth, params }) => { + return { + statusCode: 200, + bodyType: "json", + body: await retryAuthMigrationJob(auth.tenancy.id, params.id), + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/auth-migrations/[id]/route.tsx b/apps/backend/src/app/api/latest/internal/auth-migrations/[id]/route.tsx new file mode 100644 index 0000000000..8dee7ca532 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/auth-migrations/[id]/route.tsx @@ -0,0 +1,49 @@ +import { getAuthMigrationJob } from "@/lib/auth-migrations/jobs"; +import { authMigrationProviders } from "@/lib/auth-migrations/types"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, adminAuthTypeSchema, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; + +const jobResponseSchema = yupObject({ + id: yupString().uuid().defined(), + provider: yupString().oneOf(authMigrationProviders).defined(), + status: yupString().oneOf(["PENDING", "RUNNING", "WAITING_RETRY", "SUCCEEDED", "FAILED"]).defined(), + attempt_count: yupNumber().integer().defined(), + max_attempts: yupNumber().integer().defined(), + next_attempt_at_millis: yupNumber().nullable().defined(), + started_at_millis: yupNumber().nullable().defined(), + finished_at_millis: yupNumber().nullable().defined(), + last_error_external_message: yupString().nullable().defined(), + result: yupMixed().nullable(), + created_at_millis: yupNumber().defined(), + updated_at_millis: yupNumber().defined(), +}).defined(); + +export const GET = createSmartRouteHandler({ + metadata: { + hidden: true, + summary: "Get auth migration job", + description: "Gets a provider migration job for the current project branch.", + tags: ["Migrations"], + }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + params: yupObject({ + id: yupString().uuid().defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: jobResponseSchema, + }), + handler: async ({ auth, params }) => { + return { + statusCode: 200, + bodyType: "json", + body: await getAuthMigrationJob(auth.tenancy.id, params.id), + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/auth-migrations/process/route.tsx b/apps/backend/src/app/api/latest/internal/auth-migrations/process/route.tsx new file mode 100644 index 0000000000..5b6df21207 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/auth-migrations/process/route.tsx @@ -0,0 +1,48 @@ +import { runAuthMigrationQueueStep } from "@/lib/auth-migrations/jobs"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; + +export const GET = createSmartRouteHandler({ + metadata: { + hidden: true, + summary: "Process auth migration queue step", + description: "Internal endpoint invoked by cron to advance auth provider migration jobs.", + tags: ["Migrations"], + }, + request: yupObject({ + auth: yupObject({}).nullable().optional(), + method: yupString().oneOf(["GET"]).defined(), + headers: yupObject({ + "authorization": yupTuple([yupString()]).defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + claimed: yupNumber().integer().defined(), + reset_stuck: yupNumber().integer().defined(), + }).defined(), + }), + handler: async ({ headers }) => { + const authHeader = headers.authorization[0]; + if (authHeader !== `Bearer ${getEnvVariable('CRON_SECRET')}`) { + throw new StatusError(401, "Unauthorized"); + } + + const result = await runAuthMigrationQueueStep(); + return { + statusCode: 200, + bodyType: "json", + body: { + claimed: result.claimed, + reset_stuck: result.resetStuck, + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/auth-migrations/route.tsx b/apps/backend/src/app/api/latest/internal/auth-migrations/route.tsx new file mode 100644 index 0000000000..ef02387f5d --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/auth-migrations/route.tsx @@ -0,0 +1,101 @@ +import { createAuthMigrationJob, listAuthMigrationJobs } from "@/lib/auth-migrations/jobs"; +import { validateAuthMigrationCredentials } from "@/lib/auth-migrations/providers"; +import { authMigrationProviders, type AuthMigrationCredentials } from "@/lib/auth-migrations/types"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, adminAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; + +const jobResponseSchema = yupObject({ + id: yupString().uuid().defined(), + provider: yupString().oneOf(authMigrationProviders).defined(), + status: yupString().oneOf(["PENDING", "RUNNING", "WAITING_RETRY", "SUCCEEDED", "FAILED"]).defined(), + attempt_count: yupNumber().integer().defined(), + max_attempts: yupNumber().integer().defined(), + next_attempt_at_millis: yupNumber().nullable().defined(), + started_at_millis: yupNumber().nullable().defined(), + finished_at_millis: yupNumber().nullable().defined(), + last_error_external_message: yupString().nullable().defined(), + result: yupMixed().nullable(), + created_at_millis: yupNumber().defined(), + updated_at_millis: yupNumber().defined(), +}).defined(); + +function assertCredentialsObject(credentials: unknown): AuthMigrationCredentials { + if (typeof credentials !== "object" || credentials === null || Array.isArray(credentials)) { + throw new StatusError(400, "credentials must be an object."); + } + return credentials as AuthMigrationCredentials; +} + +export const GET = createSmartRouteHandler({ + metadata: { + hidden: true, + summary: "List auth migration jobs", + description: "Lists provider migration jobs for the current project branch.", + tags: ["Migrations"], + }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + items: yupArray(jobResponseSchema).defined(), + }).defined(), + }), + handler: async ({ auth }) => { + return { + statusCode: 200, + bodyType: "json", + body: { + items: await listAuthMigrationJobs(auth.tenancy.id), + }, + }; + }, +}); + +export const POST = createSmartRouteHandler({ + metadata: { + hidden: true, + summary: "Create auth migration job", + description: "Creates a queued auth provider migration job for the current project branch.", + tags: ["Migrations"], + }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema.defined(), + tenancy: adaptSchema.defined(), + user: adaptSchema.optional(), + }).defined(), + body: yupObject({ + provider: yupString().oneOf(authMigrationProviders).defined(), + credentials: yupMixed().defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: jobResponseSchema, + }), + handler: async ({ auth, body }) => { + const credentials = assertCredentialsObject(body.credentials); + validateAuthMigrationCredentials(body.provider, credentials); + const job = await createAuthMigrationJob({ + tenancyId: auth.tenancy.id, + projectId: auth.tenancy.project.id, + branchId: auth.tenancy.branchId, + provider: body.provider, + credentials, + createdByProjectUserId: auth.user?.id ?? null, + }); + return { + statusCode: 200, + bodyType: "json", + body: job, + }; + }, +}); diff --git a/apps/backend/src/lib/auth-migrations/better-auth-persistence.test.ts b/apps/backend/src/lib/auth-migrations/better-auth-persistence.test.ts new file mode 100644 index 0000000000..2d4b1d1f8f --- /dev/null +++ b/apps/backend/src/lib/auth-migrations/better-auth-persistence.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; +import { createBetterAuthStackPersistence } from "./better-auth-persistence"; + +describe("createBetterAuthStackPersistence", () => { + it("captures Better Auth adapter writes and turns them into a Stack import plan", async () => { + const persistence = createBetterAuthStackPersistence(); + + await persistence.adapter.create({ + model: "user", + data: { + id: "user-1", + email: "ada@example.com", + emailVerified: true, + name: "Ada Lovelace", + }, + }); + await persistence.adapter.create({ + model: "account", + data: { + id: "account-1", + userId: "user-1", + providerId: "credential", + accountId: "ada@example.com", + password: "$2b$12$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + }); + await persistence.adapter.create({ + model: "organization", + data: { + id: "org-1", + name: "Analytical Engines", + slug: "engines", + }, + }); + await persistence.adapter.create({ + model: "member", + data: { + id: "member-1", + userId: "user-1", + organizationId: "org-1", + role: "owner", + }, + }); + + expect(persistence.snapshot()).toMatchInlineSnapshot(` + { + "memberships": [ + { + "externalId": "member-1", + "externalOrganizationId": "org-1", + "externalUserId": "user-1", + "metadata": { + "better_auth": { + "created_at": null, + "id": "member-1", + "updated_at": null, + }, + }, + "role": "owner", + }, + ], + "organizations": [ + { + "displayName": "Analytical Engines", + "externalId": "org-1", + "metadata": { + "better_auth": { + "created_at": null, + "id": "org-1", + "metadata": null, + "slug": "engines", + "updated_at": null, + }, + }, + "profileImageUrl": null, + }, + ], + "source": "better_auth", + "users": [ + { + "displayName": "Ada Lovelace", + "externalId": "user-1", + "metadata": { + "better_auth": { + "created_at": null, + "id": "user-1", + "updated_at": null, + }, + }, + "oauthAccounts": [], + "passwordHash": "$2b$12$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "primaryEmail": "ada@example.com", + "primaryEmailVerified": true, + "profileImageUrl": null, + "restricted": null, + }, + ], + } + `); + + expect(persistence.buildPlan()).toMatchInlineSnapshot(` + { + "memberships": [ + { + "externalMembershipId": "member-1", + "externalOrganizationId": "org-1", + "externalUserId": "user-1", + "metadata": { + "better_auth": { + "created_at": null, + "id": "member-1", + "updated_at": null, + }, + }, + "role": "owner", + }, + ], + "teams": [ + { + "body": { + "display_name": "Analytical Engines", + "server_metadata": { + "better_auth": { + "created_at": null, + "id": "org-1", + "metadata": null, + "slug": "engines", + "updated_at": null, + }, + "migration": { + "organization_id": "org-1", + "source": "better_auth", + }, + }, + }, + "externalOrganizationId": "org-1", + }, + ], + "users": [ + { + "body": { + "display_name": "Ada Lovelace", + "password_hash": "$2b$12$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "primary_email": "ada@example.com", + "primary_email_auth_enabled": true, + "primary_email_verified": true, + "server_metadata": { + "better_auth": { + "created_at": null, + "id": "user-1", + "updated_at": null, + }, + "migration": { + "source": "better_auth", + "user_id": "user-1", + }, + }, + }, + "externalUserId": "user-1", + }, + ], + } + `); + }); +}); diff --git a/apps/backend/src/lib/auth-migrations/better-auth-persistence.ts b/apps/backend/src/lib/auth-migrations/better-auth-persistence.ts new file mode 100644 index 0000000000..69bd9c457d --- /dev/null +++ b/apps/backend/src/lib/auth-migrations/better-auth-persistence.ts @@ -0,0 +1,229 @@ +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import type { BetterAuthPersistenceAdapter, BetterAuthPersistenceRecord, BetterAuthWhere, ExternalAuthSnapshot, ExternalMembership, ExternalOAuthAccount, ExternalOrganization, ExternalRestriction, ExternalUser, JsonObject, JsonValue, StackImportOptions, StackMigrationPlan } from "./types"; +import { buildStackMigrationPlan } from "./stack-plan"; + +export type BetterAuthStackPersistence = { + adapter: BetterAuthPersistenceAdapter, + snapshot(): ExternalAuthSnapshot, + buildPlan(options?: StackImportOptions): StackMigrationPlan, +}; + +function assertString(value: JsonValue | undefined, field: string, model: string): string { + if (typeof value !== "string") { + throw new StackAssertionError(`Better Auth ${model}.${field} must be a string during Stack Auth migration`, { value }); + } + return value; +} + +function optionalString(value: JsonValue | undefined, field: string, model: string): string | null { + if (value == null) return null; + if (typeof value !== "string") { + throw new StackAssertionError(`Better Auth ${model}.${field} must be a string during Stack Auth migration`, { value }); + } + return value; +} + +function optionalBoolean(value: JsonValue | undefined, field: string, model: string): boolean { + if (value == null) return false; + if (typeof value !== "boolean") { + throw new StackAssertionError(`Better Auth ${model}.${field} must be a boolean during Stack Auth migration`, { value }); + } + return value; +} + +function toJsonObject(value: JsonValue | undefined, field: string, model: string): JsonObject { + if (value == null) return {}; + if (typeof value === "object" && !Array.isArray(value)) return value; + throw new StackAssertionError(`Better Auth ${model}.${field} must be an object during Stack Auth migration`, { value }); +} + +function createdUpdatedMetadata(record: BetterAuthPersistenceRecord): JsonObject { + return { + better_auth: { + id: record.id, + created_at: optionalString(record.createdAt, "createdAt", "record"), + updated_at: optionalString(record.updatedAt, "updatedAt", "record"), + }, + }; +} + +function matchesWhere(record: BetterAuthPersistenceRecord, where: BetterAuthWhere | undefined): boolean { + if (where == null || where.length === 0) return true; + return where.every((clause) => { + const recordValue = record[clause.field]; + switch (clause.operator ?? "eq") { + case "eq": { + return recordValue === clause.value; + } + case "ne": { + return recordValue !== clause.value; + } + case "in": { + return Array.isArray(clause.value) && clause.value.includes(recordValue); + } + case "contains": { + return typeof recordValue === "string" && typeof clause.value === "string" && recordValue.includes(clause.value); + } + case "starts_with": { + return typeof recordValue === "string" && typeof clause.value === "string" && recordValue.startsWith(clause.value); + } + case "ends_with": { + return typeof recordValue === "string" && typeof clause.value === "string" && recordValue.endsWith(clause.value); + } + default: { + throw new StackAssertionError(`Unsupported Better Auth where operator ${String(clause.operator)}`, { clause }); + } + } + }); +} + +function cloneRecord(record: BetterAuthPersistenceRecord): BetterAuthPersistenceRecord { + return JSON.parse(JSON.stringify(record)) as BetterAuthPersistenceRecord; +} + +function pushRecord(table: Map, model: string, data: JsonObject): BetterAuthPersistenceRecord { + const id = assertString(data.id, "id", model); + if (table.has(id)) { + throw new StackAssertionError(`Better Auth ${model} record ${id} was created twice during Stack Auth migration`); + } + const record = { ...data, id }; + table.set(id, record); + return cloneRecord(record); +} + +function collectOAuthAccounts(accounts: BetterAuthPersistenceRecord[], userId: string, email: string | null): ExternalOAuthAccount[] { + return accounts + .filter((account) => account.userId === userId && account.providerId !== "credential") + .map((account) => ({ + providerId: assertString(account.providerId, "providerId", "account"), + accountId: assertString(account.accountId, "accountId", "account"), + email, + })); +} + +function collectPasswordHash(accounts: BetterAuthPersistenceRecord[], userId: string): string | null { + const credentialAccounts = accounts.filter((account) => account.userId === userId && account.providerId === "credential"); + if (credentialAccounts.length > 1) { + throw new StackAssertionError(`Better Auth user ${userId} has multiple credential accounts`); + } + return optionalString(credentialAccounts[0]?.password, "password", "account"); +} + +function collectRestriction(record: BetterAuthPersistenceRecord): ExternalRestriction | null { + if (record.banned !== true) return null; + return { + reason: optionalString(record.banReason, "banReason", "user") ?? "Imported as banned", + privateDetails: `Better Auth user ${record.id} was banned during migration.`, + }; +} + +export function createBetterAuthStackPersistence(): BetterAuthStackPersistence { + const tables = new Map>(); + + function getTable(model: string): Map { + const existing = tables.get(model); + if (existing) return existing; + const created = new Map(); + tables.set(model, created); + return created; + } + + const adapter: BetterAuthPersistenceAdapter = { + async create(input) { + return pushRecord(getTable(input.model), input.model, input.data); + }, + async findOne(input) { + return [...getTable(input.model).values()].find((record) => matchesWhere(record, input.where)) ?? null; + }, + async findMany(input) { + const offset = input.offset ?? 0; + const limit = input.limit ?? Number.POSITIVE_INFINITY; + return [...getTable(input.model).values()] + .filter((record) => matchesWhere(record, input.where)) + .slice(offset, offset + limit) + .map(cloneRecord); + }, + async update(input) { + const match = [...getTable(input.model).values()].find((record) => matchesWhere(record, input.where)); + if (match == null) return null; + Object.assign(match, input.update); + return cloneRecord(match); + }, + async updateMany(input) { + const matches = [...getTable(input.model).values()].filter((record) => matchesWhere(record, input.where)); + for (const match of matches) Object.assign(match, input.update); + return matches.length; + }, + async delete(input) { + const table = getTable(input.model); + const match = [...table.values()].find((record) => matchesWhere(record, input.where)); + if (match != null) table.delete(match.id); + }, + async deleteMany(input) { + const table = getTable(input.model); + const matches = [...table.values()].filter((record) => matchesWhere(record, input.where)); + for (const match of matches) table.delete(match.id); + return matches.length; + }, + async count(input) { + return [...getTable(input.model).values()].filter((record) => matchesWhere(record, input.where)).length; + }, + }; + + function snapshot(): ExternalAuthSnapshot { + const usersTable = getTable("user"); + const accounts = [...getTable("account").values()]; + const organizationsTable = getTable("organization"); + const membersTable = getTable("member"); + + const users: ExternalUser[] = [...usersTable.values()].map((record) => { + const email = optionalString(record.email, "email", "user"); + return { + externalId: record.id, + primaryEmail: email, + primaryEmailVerified: optionalBoolean(record.emailVerified, "emailVerified", "user"), + displayName: optionalString(record.name, "name", "user"), + profileImageUrl: optionalString(record.image, "image", "user"), + passwordHash: collectPasswordHash(accounts, record.id), + oauthAccounts: collectOAuthAccounts(accounts, record.id, email), + restricted: collectRestriction(record), + metadata: createdUpdatedMetadata(record), + }; + }); + + const organizations: ExternalOrganization[] = [...organizationsTable.values()].map((record) => { + const baseMetadata = createdUpdatedMetadata(record); + return { + externalId: record.id, + displayName: assertString(record.name, "name", "organization"), + profileImageUrl: optionalString(record.logo, "logo", "organization"), + metadata: { + ...baseMetadata, + better_auth: { + ...toJsonObject(baseMetadata.better_auth, "better_auth", "organization"), + slug: optionalString(record.slug, "slug", "organization"), + metadata: record.metadata ?? null, + }, + }, + }; + }); + + const memberships: ExternalMembership[] = [...membersTable.values()].map((record) => ({ + externalId: record.id, + externalUserId: assertString(record.userId, "userId", "member"), + externalOrganizationId: assertString(record.organizationId, "organizationId", "member"), + role: optionalString(record.role, "role", "member"), + metadata: createdUpdatedMetadata(record), + })); + + return { source: "better_auth", users, organizations, memberships }; + } + + return { + adapter, + snapshot, + buildPlan(options?: StackImportOptions) { + return buildStackMigrationPlan(snapshot(), options); + }, + }; +} diff --git a/apps/backend/src/lib/auth-migrations/crypto.ts b/apps/backend/src/lib/auth-migrations/crypto.ts new file mode 100644 index 0000000000..ef861833dd --- /dev/null +++ b/apps/backend/src/lib/auth-migrations/crypto.ts @@ -0,0 +1,27 @@ +import { decodeBase64, encodeBase64 } from "@stackframe/stack-shared/dist/utils/bytes"; +import { encrypt, decrypt } from "@stackframe/stack-shared/dist/utils/crypto"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { Result } from "@stackframe/stack-shared/dist/utils/results"; +import type { AuthMigrationCredentials, EncryptedMigrationCredentials } from "./types"; + +const encryptionPurpose = "stack-auth-provider-migration-credentials"; + +export async function encryptMigrationCredentials(credentials: AuthMigrationCredentials): Promise { + const ciphertext = await encrypt({ + purpose: encryptionPurpose, + secret: getEnvVariable("STACK_SERVER_SECRET"), + value: new TextEncoder().encode(JSON.stringify(credentials)), + }); + return { + ciphertext_base64: encodeBase64(ciphertext), + }; +} + +export async function decryptMigrationCredentials(encrypted: EncryptedMigrationCredentials): Promise { + const plaintext = Result.orThrow(await decrypt({ + purpose: encryptionPurpose, + secret: getEnvVariable("STACK_SERVER_SECRET"), + cipher: decodeBase64(encrypted.ciphertext_base64), + })); + return JSON.parse(new TextDecoder().decode(plaintext)) as AuthMigrationCredentials; +} diff --git a/apps/backend/src/lib/auth-migrations/jobs.ts b/apps/backend/src/lib/auth-migrations/jobs.ts new file mode 100644 index 0000000000..d51cffb821 --- /dev/null +++ b/apps/backend/src/lib/auth-migrations/jobs.ts @@ -0,0 +1,334 @@ +import { Prisma } from "@/generated/prisma/client"; +import { getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; +import { globalPrismaClient } from "@/prisma-client"; +import { StackAssertionError, StatusError, errorToNiceString } from "@stackframe/stack-shared/dist/utils/errors"; +import { decryptMigrationCredentials, encryptMigrationCredentials } from "./crypto"; +import { importPlanToStackAuth } from "./stack-import"; +import { prepareAuthMigration } from "./providers"; +import type { AuthMigrationCredentials, AuthMigrationJobRow, AuthMigrationProvider, AuthMigrationStatus, EncryptedMigrationCredentials, JsonObject } from "./types"; + +const MAX_ATTEMPTS = 5; +const BASE_RETRY_DELAY_MS = 20_000; +const STUCK_RUNNING_TIMEOUT_MS = 20 * 60 * 1000; + +type ClaimedAuthMigrationJobRow = AuthMigrationJobRow & { + encrypted_credentials: EncryptedMigrationCredentials, +}; + +export type AuthMigrationJobApi = { + id: string, + provider: AuthMigrationProvider, + status: AuthMigrationStatus, + attempt_count: number, + max_attempts: number, + next_attempt_at_millis: number | null, + started_at_millis: number | null, + finished_at_millis: number | null, + last_error_external_message: string | null, + result: unknown, + created_at_millis: number, + updated_at_millis: number, +}; + +function rowToApi(row: AuthMigrationJobRow): AuthMigrationJobApi { + return { + id: row.id, + provider: row.provider, + status: row.status, + attempt_count: row.attempt_count, + max_attempts: row.max_attempts, + next_attempt_at_millis: row.next_attempt_at?.getTime() ?? null, + started_at_millis: row.started_at?.getTime() ?? null, + finished_at_millis: row.finished_at?.getTime() ?? null, + last_error_external_message: row.last_error_external_message, + result: row.result, + created_at_millis: row.created_at.getTime(), + updated_at_millis: row.updated_at.getTime(), + }; +} + +function selectJobSql(whereSql: Prisma.Sql): Prisma.Sql { + return Prisma.sql` + SELECT + "id", + "tenancyId" AS "tenancy_id", + "projectId" AS "project_id", + "branchId" AS "branch_id", + "provider", + "status", + "createdByProjectUserId" AS "created_by_project_user_id", + "attemptCount" AS "attempt_count", + "maxAttempts" AS "max_attempts", + "nextAttemptAt" AS "next_attempt_at", + "startedAt" AS "started_at", + "finishedAt" AS "finished_at", + "lastErrorExternalMessage" AS "last_error_external_message", + "lastErrorInternalDetails" AS "last_error_internal_details", + "result", + "createdAt" AS "created_at", + "updatedAt" AS "updated_at" + FROM "AuthDataMigrationJob" + ${whereSql} + `; +} + +function selectClaimedJobSql(whereSql: Prisma.Sql): Prisma.Sql { + return Prisma.sql` + SELECT + "id", + "tenancyId" AS "tenancy_id", + "projectId" AS "project_id", + "branchId" AS "branch_id", + "provider", + "status", + "createdByProjectUserId" AS "created_by_project_user_id", + "attemptCount" AS "attempt_count", + "maxAttempts" AS "max_attempts", + "nextAttemptAt" AS "next_attempt_at", + "startedAt" AS "started_at", + "finishedAt" AS "finished_at", + "lastErrorExternalMessage" AS "last_error_external_message", + "lastErrorInternalDetails" AS "last_error_internal_details", + "encryptedCredentials" AS "encrypted_credentials", + "result", + "createdAt" AS "created_at", + "updatedAt" AS "updated_at" + FROM "AuthDataMigrationJob" + ${whereSql} + `; +} + +function calculateRetryDelay(attemptCount: number): number { + return (Math.random() + 0.5) * BASE_RETRY_DELAY_MS * Math.pow(2, attemptCount - 1); +} + +export async function createAuthMigrationJob(options: { + tenancyId: string, + projectId: string, + branchId: string, + provider: AuthMigrationProvider, + credentials: AuthMigrationCredentials, + createdByProjectUserId: string | null, +}): Promise { + const encryptedCredentials = await encryptMigrationCredentials(options.credentials); + const rows = await globalPrismaClient.$queryRaw` + INSERT INTO "AuthDataMigrationJob" ( + "tenancyId", + "projectId", + "branchId", + "provider", + "encryptedCredentials", + "createdByProjectUserId", + "maxAttempts", + "nextAttemptAt" + ) + VALUES ( + ${options.tenancyId}::uuid, + ${options.projectId}, + ${options.branchId}, + ${options.provider}, + ${JSON.stringify(encryptedCredentials)}::jsonb, + ${options.createdByProjectUserId}::uuid, + ${MAX_ATTEMPTS}, + NOW() + ) + RETURNING + "id", + "tenancyId" AS "tenancy_id", + "projectId" AS "project_id", + "branchId" AS "branch_id", + "provider", + "status", + "createdByProjectUserId" AS "created_by_project_user_id", + "attemptCount" AS "attempt_count", + "maxAttempts" AS "max_attempts", + "nextAttemptAt" AS "next_attempt_at", + "startedAt" AS "started_at", + "finishedAt" AS "finished_at", + "lastErrorExternalMessage" AS "last_error_external_message", + "lastErrorInternalDetails" AS "last_error_internal_details", + "result", + "createdAt" AS "created_at", + "updatedAt" AS "updated_at" + `; + if (rows.length !== 1) throw new StackAssertionError("Auth migration job insert returned no rows"); + return rowToApi(rows[0]); +} + +export async function listAuthMigrationJobs(tenancyId: string): Promise { + const rows = await globalPrismaClient.$queryRaw(selectJobSql(Prisma.sql` + WHERE "tenancyId" = ${tenancyId}::uuid + ORDER BY "createdAt" DESC, "id" DESC + `)); + return rows.map(rowToApi); +} + +export async function getAuthMigrationJob(tenancyId: string, id: string): Promise { + const rows = await globalPrismaClient.$queryRaw(selectJobSql(Prisma.sql` + WHERE "tenancyId" = ${tenancyId}::uuid AND "id" = ${id}::uuid + `)); + if (rows.length !== 1) throw new StatusError(404, "Auth migration job not found"); + return rowToApi(rows[0]); +} + +export async function retryAuthMigrationJob(tenancyId: string, id: string): Promise { + const rows = await globalPrismaClient.$queryRaw` + UPDATE "AuthDataMigrationJob" + SET + "status" = 'PENDING', + "nextAttemptAt" = NOW(), + "finishedAt" = NULL, + "lastErrorExternalMessage" = NULL, + "lastErrorInternalDetails" = NULL, + "updatedAt" = NOW() + WHERE "tenancyId" = ${tenancyId}::uuid + AND "id" = ${id}::uuid + AND "status" IN ('FAILED', 'WAITING_RETRY') + RETURNING + "id", + "tenancyId" AS "tenancy_id", + "projectId" AS "project_id", + "branchId" AS "branch_id", + "provider", + "status", + "createdByProjectUserId" AS "created_by_project_user_id", + "attemptCount" AS "attempt_count", + "maxAttempts" AS "max_attempts", + "nextAttemptAt" AS "next_attempt_at", + "startedAt" AS "started_at", + "finishedAt" AS "finished_at", + "lastErrorExternalMessage" AS "last_error_external_message", + "lastErrorInternalDetails" AS "last_error_internal_details", + "result", + "createdAt" AS "created_at", + "updatedAt" AS "updated_at" + `; + if (rows.length !== 1) throw new StatusError(400, "Auth migration job is not retryable"); + return rowToApi(rows[0]); +} + +async function resetStuckRunningJobs(): Promise { + const rows = await globalPrismaClient.$queryRaw<{ id: string }[]>` + UPDATE "AuthDataMigrationJob" + SET + "status" = 'WAITING_RETRY', + "nextAttemptAt" = NOW(), + "lastErrorExternalMessage" = 'Migration worker timed out before finishing.', + "lastErrorInternalDetails" = ${JSON.stringify({ type: "stuck-running-reset", timeoutMs: STUCK_RUNNING_TIMEOUT_MS })}::jsonb, + "updatedAt" = NOW() + WHERE "status" = 'RUNNING' + AND "startedAt" <= NOW() - (${STUCK_RUNNING_TIMEOUT_MS} || ' milliseconds')::interval + RETURNING "id" + `; + return rows.length; +} + +async function claimDueJobs(limit: number): Promise { + return await globalPrismaClient.$queryRaw` + WITH picked AS ( + SELECT "tenancyId", "id" + FROM "AuthDataMigrationJob" + WHERE "status" IN ('PENDING', 'WAITING_RETRY') + AND ("nextAttemptAt" IS NULL OR "nextAttemptAt" <= NOW()) + ORDER BY "nextAttemptAt" ASC NULLS FIRST, "createdAt" ASC + LIMIT ${limit} + FOR UPDATE SKIP LOCKED + ) + UPDATE "AuthDataMigrationJob" AS job + SET + "status" = 'RUNNING', + "startedAt" = NOW(), + "finishedAt" = NULL, + "attemptCount" = job."attemptCount" + 1, + "updatedAt" = NOW() + FROM picked + WHERE job."tenancyId" = picked."tenancyId" AND job."id" = picked."id" + RETURNING + job."id", + job."tenancyId" AS "tenancy_id", + job."projectId" AS "project_id", + job."branchId" AS "branch_id", + job."provider", + job."status", + job."createdByProjectUserId" AS "created_by_project_user_id", + job."attemptCount" AS "attempt_count", + job."maxAttempts" AS "max_attempts", + job."nextAttemptAt" AS "next_attempt_at", + job."startedAt" AS "started_at", + job."finishedAt" AS "finished_at", + job."lastErrorExternalMessage" AS "last_error_external_message", + job."lastErrorInternalDetails" AS "last_error_internal_details", + job."encryptedCredentials" AS "encrypted_credentials", + job."result", + job."createdAt" AS "created_at", + job."updatedAt" AS "updated_at" + `; +} + +async function markJobSucceeded(job: ClaimedAuthMigrationJobRow, result: JsonObject): Promise { + await globalPrismaClient.$executeRaw` + UPDATE "AuthDataMigrationJob" + SET + "status" = 'SUCCEEDED', + "finishedAt" = NOW(), + "nextAttemptAt" = NULL, + "result" = ${JSON.stringify(result)}::jsonb, + "lastErrorExternalMessage" = NULL, + "lastErrorInternalDetails" = NULL, + "updatedAt" = NOW() + WHERE "tenancyId" = ${job.tenancy_id}::uuid AND "id" = ${job.id}::uuid + `; +} + +async function markJobFailed(job: ClaimedAuthMigrationJobRow, error: unknown): Promise { + const willRetry = job.attempt_count < job.max_attempts; + const nextAttemptAt = new Date(Date.now() + calculateRetryDelay(job.attempt_count)); + const internalDetails = { + type: "auth-migration-job-error", + attemptCount: job.attempt_count, + provider: job.provider, + error: errorToNiceString(error), + }; + await globalPrismaClient.$executeRaw` + UPDATE "AuthDataMigrationJob" + SET + "status" = ${willRetry ? "WAITING_RETRY" : "FAILED"}, + "finishedAt" = ${willRetry ? null : new Date()}, + "nextAttemptAt" = ${willRetry ? nextAttemptAt : null}, + "lastErrorExternalMessage" = ${error instanceof StatusError ? error.message : "Auth migration failed. Please check the migration details or try again."}, + "lastErrorInternalDetails" = ${JSON.stringify(internalDetails)}::jsonb, + "updatedAt" = NOW() + WHERE "tenancyId" = ${job.tenancy_id}::uuid AND "id" = ${job.id}::uuid + `; +} + +async function processJob(job: ClaimedAuthMigrationJobRow): Promise { + const credentials = await decryptMigrationCredentials(job.encrypted_credentials); + const prepared = await prepareAuthMigration(job.provider, credentials); + const tenancy = await getSoleTenancyFromProjectBranch(job.project_id, job.branch_id); + const importResult = await importPlanToStackAuth(tenancy, prepared.plan); + await markJobSucceeded(job, { + ...importResult, + provider: job.provider, + }); +} + +export async function runAuthMigrationQueueStep(limit = 5): Promise<{ claimed: number, resetStuck: number }> { + const resetStuck = await resetStuckRunningJobs(); + const jobs = await claimDueJobs(limit); + for (const job of jobs) { + try { + await processJob(job); + } catch (error) { + await markJobFailed(job, error); + } + } + return { + claimed: jobs.length, + resetStuck, + }; +} + +export const _authMigrationJobsForTesting = { + calculateRetryDelay, +}; diff --git a/apps/backend/src/lib/auth-migrations/providers.ts b/apps/backend/src/lib/auth-migrations/providers.ts new file mode 100644 index 0000000000..bc11477dc7 --- /dev/null +++ b/apps/backend/src/lib/auth-migrations/providers.ts @@ -0,0 +1,105 @@ +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { createBetterAuthStackPersistence } from "./better-auth-persistence"; +import type { AuthMigrationCredentials, AuthMigrationProvider, BetterAuthMigrationCredentials, StackImportOptions, StackMigrationPlan } from "./types"; + +export type PreparedMigration = { + plan: StackMigrationPlan, + options: StackImportOptions, +}; + +function getProviderIdMap(credentials: AuthMigrationCredentials): Map | undefined { + const raw = credentials.provider_id_map; + if (raw == null) return undefined; + if (typeof raw !== "object" || Array.isArray(raw)) { + throw new StatusError(400, "provider_id_map must be an object from source provider IDs to Stack Auth provider IDs."); + } + return new Map(Object.entries(raw).map(([key, value]) => { + if (typeof value !== "string") { + throw new StatusError(400, `provider_id_map.${key} must be a string.`); + } + return [key, value]; + })); +} + +function getImportOptions(credentials: AuthMigrationCredentials): StackImportOptions { + const unsupportedPasswordHashAction = credentials.unsupported_password_hash_action; + if (unsupportedPasswordHashAction != null && unsupportedPasswordHashAction !== "error" && unsupportedPasswordHashAction !== "omit") { + throw new StatusError(400, "unsupported_password_hash_action must be either error or omit."); + } + return { + providerIdMap: getProviderIdMap(credentials), + unsupportedPasswordHashAction: unsupportedPasswordHashAction ?? "error", + }; +} + +function isBetterAuthCreateRecord(value: unknown): value is NonNullable[number] { + return typeof value === "object" && value !== null && "model" in value && typeof value.model === "string" && "data" in value && typeof value.data === "object" && value.data !== null && !Array.isArray(value.data); +} + +function requireString(credentials: AuthMigrationCredentials, key: string, label: string): void { + if (typeof credentials[key] !== "string" || credentials[key].trim() === "") { + throw new StatusError(400, `${label} is required.`); + } +} + +export function validateAuthMigrationCredentials(provider: AuthMigrationProvider, credentials: AuthMigrationCredentials): void { + getImportOptions(credentials); + + switch (provider) { + case "workos": { + requireString(credentials, "api_key", "WorkOS API key"); + return; + } + case "clerk": { + requireString(credentials, "secret_key", "Clerk secret key"); + return; + } + case "authjs": { + requireString(credentials, "database_url", "Auth.js database URL"); + return; + } + case "auth0": { + requireString(credentials, "domain", "Auth0 domain"); + requireString(credentials, "client_id", "Auth0 Management API client ID"); + requireString(credentials, "client_secret", "Auth0 Management API client secret"); + return; + } + case "supabase": { + requireString(credentials, "project_url", "Supabase project URL"); + requireString(credentials, "service_role_key", "Supabase service role key"); + return; + } + case "better_auth": { + if (Array.isArray(credentials.records)) return; + requireString(credentials, "database_url", "Better Auth database URL"); + return; + } + } +} + +async function prepareBetterAuthMigration(credentials: BetterAuthMigrationCredentials): Promise { + const persistence = createBetterAuthStackPersistence(); + const records = credentials.records; + if (!Array.isArray(records)) { + throw new StatusError(501, "Better Auth database migrations are queued through this endpoint, but the Better Auth database runner is not implemented yet."); + } + for (const record of records) { + if (!isBetterAuthCreateRecord(record)) { + throw new StatusError(400, "Every Better Auth migration record must have string model and object data fields."); + } + await persistence.adapter.create(record); + } + const options = getImportOptions(credentials); + return { + plan: persistence.buildPlan(options), + options, + }; +} + +export async function prepareAuthMigration(provider: AuthMigrationProvider, credentials: AuthMigrationCredentials): Promise { + if (provider === "better_auth") { + return await prepareBetterAuthMigration(credentials as BetterAuthMigrationCredentials); + } + + throw new StatusError(501, `${provider} migrations are queued through this endpoint, but the provider runner is not implemented yet.`); +} diff --git a/apps/backend/src/lib/auth-migrations/stack-import.ts b/apps/backend/src/lib/auth-migrations/stack-import.ts new file mode 100644 index 0000000000..272af8ebff --- /dev/null +++ b/apps/backend/src/lib/auth-migrations/stack-import.ts @@ -0,0 +1,58 @@ +import { teamMembershipsCrudHandlers } from "@/app/api/latest/team-memberships/crud"; +import { teamsCrudHandlers } from "@/app/api/latest/teams/crud"; +import { usersCrudHandlers } from "@/app/api/latest/users/crud"; +import type { Tenancy } from "@/lib/tenancies"; +import type { StackMigrationPlan } from "./types"; + +export type StackImportResult = { + userIdMap: Record, + teamIdMap: Record, + importedUsers: number, + importedTeams: number, + importedMemberships: number, +}; + +export async function importPlanToStackAuth(tenancy: Tenancy, plan: StackMigrationPlan): Promise { + const userIdMap = new Map(); + for (const user of plan.users) { + const created = await usersCrudHandlers.serverCreate({ + tenancy, + data: user.body, + }); + userIdMap.set(user.externalUserId, created.id); + } + + const teamIdMap = new Map(); + for (const team of plan.teams) { + const created = await teamsCrudHandlers.serverCreate({ + tenancy, + data: team.body, + }); + teamIdMap.set(team.externalOrganizationId, created.id); + } + + for (const membership of plan.memberships) { + const stackUserId = userIdMap.get(membership.externalUserId); + if (stackUserId == null) { + throw new Error(`External membership ${membership.externalMembershipId} references missing user ${membership.externalUserId}`); + } + const stackTeamId = teamIdMap.get(membership.externalOrganizationId); + if (stackTeamId == null) { + throw new Error(`External membership ${membership.externalMembershipId} references missing organization ${membership.externalOrganizationId}`); + } + await teamMembershipsCrudHandlers.serverCreate({ + tenancy, + team_id: stackTeamId, + user_id: stackUserId, + data: {}, + }); + } + + return { + userIdMap: Object.fromEntries(userIdMap), + teamIdMap: Object.fromEntries(teamIdMap), + importedUsers: userIdMap.size, + importedTeams: teamIdMap.size, + importedMemberships: plan.memberships.length, + }; +} diff --git a/apps/backend/src/lib/auth-migrations/stack-plan.ts b/apps/backend/src/lib/auth-migrations/stack-plan.ts new file mode 100644 index 0000000000..bbba71b029 --- /dev/null +++ b/apps/backend/src/lib/auth-migrations/stack-plan.ts @@ -0,0 +1,100 @@ +import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; +import type { ExternalAuthSnapshot, ExternalUser, JsonObject, StackImportOptions, StackMigrationPlan, StackUserCreateBody } from "./types"; + +function isSupportedStackPasswordHash(hash: string): boolean { + return /^\$2[ayb]\$.{56}$/.test(hash); +} + +function getProviderId(providerId: string, providerIdMap: Map): string { + return providerIdMap.get(providerId) ?? providerId; +} + +function buildServerMetadata(source: string, externalIdField: string, externalId: string, metadata: JsonObject): JsonObject { + return { + ...metadata, + migration: { + source, + [externalIdField]: externalId, + }, + }; +} + +function mapUserToStackBody(source: string, user: ExternalUser, options: Required): StackMigrationPlan["users"][number] { + const body: StackUserCreateBody = { + server_metadata: buildServerMetadata(source, "user_id", user.externalId, user.metadata), + }; + + if (user.primaryEmail != null) { + body.primary_email = user.primaryEmail; + body.primary_email_verified = user.primaryEmailVerified; + body.primary_email_auth_enabled = true; + } + if (user.displayName != null) { + body.display_name = user.displayName; + } + if (user.profileImageUrl != null) { + body.profile_image_url = user.profileImageUrl; + } + if (user.passwordHash != null) { + if (isSupportedStackPasswordHash(user.passwordHash)) { + body.password_hash = user.passwordHash; + } else if (options.unsupportedPasswordHashAction === "error") { + throw new Error(`External user ${user.externalId} has an unsupported password hash. Stack Auth currently accepts bcrypt hashes for password_hash imports.`); + } + } + if (user.restricted != null) { + body.restricted_by_admin = true; + body.restricted_by_admin_reason = user.restricted.reason; + body.restricted_by_admin_private_details = user.restricted.privateDetails; + } + + const oauthProviders = user.oauthAccounts + .sort((a, b) => stringCompare(`${a.providerId}:${a.accountId}`, `${b.providerId}:${b.accountId}`)) + .map((account) => ({ + id: getProviderId(account.providerId, options.providerIdMap), + account_id: account.accountId, + email: account.email, + })); + if (oauthProviders.length > 0) { + body.oauth_providers = oauthProviders; + } + + return { + externalUserId: user.externalId, + body, + }; +} + +export function buildStackMigrationPlan(snapshot: ExternalAuthSnapshot, options: StackImportOptions = {}): StackMigrationPlan { + const resolvedOptions: Required = { + providerIdMap: options.providerIdMap ?? new Map(), + unsupportedPasswordHashAction: options.unsupportedPasswordHashAction ?? "error", + }; + + const users = [...snapshot.users] + .sort((a, b) => stringCompare(a.externalId, b.externalId)) + .map((user) => mapUserToStackBody(snapshot.source, user, resolvedOptions)); + + const teams = [...snapshot.organizations] + .sort((a, b) => stringCompare(a.externalId, b.externalId)) + .map((organization) => ({ + externalOrganizationId: organization.externalId, + body: { + display_name: organization.displayName, + ...(organization.profileImageUrl != null ? { profile_image_url: organization.profileImageUrl } : {}), + server_metadata: buildServerMetadata(snapshot.source, "organization_id", organization.externalId, organization.metadata), + }, + })); + + const memberships = [...snapshot.memberships] + .sort((a, b) => stringCompare(a.externalId, b.externalId)) + .map((membership) => ({ + externalMembershipId: membership.externalId, + externalUserId: membership.externalUserId, + externalOrganizationId: membership.externalOrganizationId, + role: membership.role, + metadata: membership.metadata, + })); + + return { users, teams, memberships }; +} diff --git a/apps/backend/src/lib/auth-migrations/types.ts b/apps/backend/src/lib/auth-migrations/types.ts new file mode 100644 index 0000000000..b2cf2ce4d9 --- /dev/null +++ b/apps/backend/src/lib/auth-migrations/types.ts @@ -0,0 +1,173 @@ +import type { Prisma } from "@/generated/prisma/client"; + +export const authMigrationProviders = ["workos", "clerk", "authjs", "auth0", "supabase", "better_auth"] as const; +export type AuthMigrationProvider = typeof authMigrationProviders[number]; + +export const authMigrationStatuses = ["PENDING", "RUNNING", "WAITING_RETRY", "SUCCEEDED", "FAILED"] as const; +export type AuthMigrationStatus = typeof authMigrationStatuses[number]; + +export type JsonObject = { [key: string]: JsonValue }; +export type JsonValue = JsonObject | JsonValue[] | string | number | boolean | null; + +export type BetterAuthPersistenceRecord = JsonObject & { + id: string, +}; + +export type BetterAuthWhere = { + field: string, + value: JsonValue, + operator?: "eq" | "ne" | "in" | "contains" | "starts_with" | "ends_with", +}[]; + +export type BetterAuthCreateInput = { + model: string, + data: JsonObject, +}; + +export type BetterAuthFindOneInput = { + model: string, + where?: BetterAuthWhere, +}; + +export type BetterAuthFindManyInput = BetterAuthFindOneInput & { + limit?: number, + offset?: number, +}; + +export type BetterAuthUpdateInput = BetterAuthFindOneInput & { + update: JsonObject, +}; + +export type BetterAuthPersistenceAdapter = { + create(input: BetterAuthCreateInput): Promise, + findOne(input: BetterAuthFindOneInput): Promise, + findMany(input: BetterAuthFindManyInput): Promise, + update(input: BetterAuthUpdateInput): Promise, + updateMany(input: BetterAuthUpdateInput): Promise, + delete(input: BetterAuthFindOneInput): Promise, + deleteMany(input: BetterAuthFindOneInput): Promise, + count(input: BetterAuthFindOneInput): Promise, +}; + +export type ExternalOAuthAccount = { + providerId: string, + accountId: string, + email: string | null, +}; + +export type ExternalRestriction = { + reason: string, + privateDetails: string | null, +}; + +export type ExternalUser = { + externalId: string, + primaryEmail: string | null, + primaryEmailVerified: boolean, + displayName: string | null, + profileImageUrl: string | null, + passwordHash: string | null, + oauthAccounts: ExternalOAuthAccount[], + restricted: ExternalRestriction | null, + metadata: JsonObject, +}; + +export type ExternalOrganization = { + externalId: string, + displayName: string, + profileImageUrl: string | null, + metadata: JsonObject, +}; + +export type ExternalMembership = { + externalId: string, + externalUserId: string, + externalOrganizationId: string, + role: string | null, + metadata: JsonObject, +}; + +export type ExternalAuthSnapshot = { + source: string, + users: ExternalUser[], + organizations: ExternalOrganization[], + memberships: ExternalMembership[], +}; + +export type StackImportOptions = { + providerIdMap?: Map, + unsupportedPasswordHashAction?: "error" | "omit", +}; + +export type StackUserCreateBody = { + primary_email?: string, + primary_email_verified?: boolean, + primary_email_auth_enabled?: boolean, + display_name?: string | null, + profile_image_url?: string | null, + password_hash?: string, + oauth_providers?: { + id: string, + account_id: string, + email: string | null, + }[], + restricted_by_admin?: boolean, + restricted_by_admin_reason?: string | null, + restricted_by_admin_private_details?: string | null, + server_metadata: JsonObject, +}; + +export type StackTeamCreateBody = { + display_name: string, + profile_image_url?: string | null, + server_metadata: JsonObject, +}; + +export type StackMigrationPlan = { + users: { + externalUserId: string, + body: StackUserCreateBody, + }[], + teams: { + externalOrganizationId: string, + body: StackTeamCreateBody, + }[], + memberships: { + externalMembershipId: string, + externalUserId: string, + externalOrganizationId: string, + role: string | null, + metadata: JsonObject, + }[], +}; + +export type EncryptedMigrationCredentials = { + ciphertext_base64: string, +}; + +export type BetterAuthMigrationCredentials = { + records?: BetterAuthCreateInput[], + database_url?: string, +}; + +export type AuthMigrationCredentials = JsonObject; + +export type AuthMigrationJobRow = { + id: string, + tenancy_id: string, + project_id: string, + branch_id: string, + provider: AuthMigrationProvider, + status: AuthMigrationStatus, + created_by_project_user_id: string | null, + attempt_count: number, + max_attempts: number, + next_attempt_at: Date | null, + started_at: Date | null, + finished_at: Date | null, + last_error_external_message: string | null, + last_error_internal_details: Prisma.JsonValue | null, + result: Prisma.JsonValue | null, + created_at: Date, + updated_at: Date, +}; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/migrations/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/migrations/page-client.tsx new file mode 100644 index 0000000000..34aa695fbc --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/migrations/page-client.tsx @@ -0,0 +1,629 @@ +"use client"; + +import { DesignAlert, DesignBadge, DesignButton, DesignCard, DesignInput, DesignSelectorDropdown } from "@/components/design-components"; +import { stackAppInternalsSymbol } from "@/lib/stack-app-internals"; +import { cn } from "@/lib/utils"; +import { ArrowsClockwiseIcon, CheckCircleIcon, ClockIcon, CloudArrowUpIcon, DatabaseIcon, KeyIcon, PlugsIcon, XCircleIcon } from "@phosphor-icons/react"; +import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; +import { Result } from "@stackframe/stack-shared/dist/utils/results"; +import { urlString } from "@stackframe/stack-shared/dist/utils/urls"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { PageLayout } from "../page-layout"; +import { useAdminApp } from "../use-admin-app"; + +const providerConfigs = [ + { id: "workos", label: "WorkOS", detail: "API key + org filters" }, + { id: "clerk", label: "Clerk", detail: "Backend secret key" }, + { id: "authjs", label: "Auth.js", detail: "Database connection" }, + { id: "auth0", label: "Auth0", detail: "Management API app" }, + { id: "supabase", label: "Supabase", detail: "Service role key" }, + { id: "better_auth", label: "Better Auth", detail: "Database connection" }, +] as const; + +type ProviderId = typeof providerConfigs[number]["id"]; +type JobStatus = "PENDING" | "RUNNING" | "WAITING_RETRY" | "SUCCEEDED" | "FAILED"; + +type AuthMigrationJob = { + id: string, + provider: ProviderId, + status: JobStatus, + attempt_count: number, + max_attempts: number, + next_attempt_at_millis: number | null, + started_at_millis: number | null, + finished_at_millis: number | null, + last_error_external_message: string | null, + result: unknown, + created_at_millis: number, + updated_at_millis: number, +}; + +type AdminAppInternals = { + sendRequest: (path: string, requestOptions: RequestInit, requestType?: "client" | "server" | "admin") => Promise, +}; + +const statusCopy = new Map([ + ["PENDING", { label: "Queued", color: "blue" }], + ["RUNNING", { label: "Running", color: "orange" }], + ["WAITING_RETRY", { label: "Retrying", color: "orange" }], + ["SUCCEEDED", { label: "Succeeded", color: "green" }], + ["FAILED", { label: "Failed", color: "red" }], +]); + +type CredentialField = { + key: string, + label: string, + placeholder: string, + type?: "text" | "password", + optional?: boolean, +}; + +const providerCredentialFields: Record = { + workos: [ + { key: "api_key", label: "WorkOS API key", placeholder: "sk_test_...", type: "password" }, + { key: "organization_id", label: "Organization ID", placeholder: "org_...", optional: true }, + { key: "directory_id", label: "Directory ID", placeholder: "directory_...", optional: true }, + ], + clerk: [ + { key: "secret_key", label: "Clerk secret key", placeholder: "sk_test_...", type: "password" }, + { key: "api_base_url", label: "API base URL", placeholder: "https://api.clerk.com/v1", optional: true }, + ], + authjs: [ + { key: "database_url", label: "Auth.js database URL", placeholder: "postgres://user:password@host:5432/db", type: "password" }, + { key: "users_table", label: "Users table", placeholder: "users", optional: true }, + { key: "accounts_table", label: "Accounts table", placeholder: "accounts", optional: true }, + ], + auth0: [ + { key: "domain", label: "Auth0 domain", placeholder: "your-tenant.us.auth0.com" }, + { key: "client_id", label: "Management API client ID", placeholder: "client id" }, + { key: "client_secret", label: "Management API client secret", placeholder: "client secret", type: "password" }, + { key: "connection_id", label: "Connection ID", placeholder: "con_...", optional: true }, + ], + supabase: [ + { key: "project_url", label: "Project URL", placeholder: "https://project-ref.supabase.co" }, + { key: "service_role_key", label: "Service role key", placeholder: "eyJ...", type: "password" }, + ], + better_auth: [ + { key: "database_url", label: "Better Auth database URL", placeholder: "postgres://user:password@host:5432/db", type: "password" }, + { key: "schema", label: "Schema", placeholder: "public", optional: true }, + ], +}; + +type ProviderMapRow = { + id: string, + sourceProviderId: string, + stackProviderId: string, +}; + +function isProviderId(value: string): value is ProviderId { + return providerConfigs.some((provider) => provider.id === value); +} + +function getAdminAppInternals(adminApp: ReturnType): AdminAppInternals { + const internals = Reflect.get(adminApp, stackAppInternalsSymbol); + if (typeof internals !== "object" || internals === null) { + throw new Error("Stack Admin App internals are unavailable."); + } + const sendRequest = Reflect.get(internals, "sendRequest"); + if (typeof sendRequest !== "function") { + throw new Error("Stack Admin App sendRequest internals are unavailable."); + } + return { + sendRequest: async (path, requestOptions, requestType) => { + const response: unknown = await sendRequest(path, requestOptions, requestType); + if (!(response instanceof Response)) { + throw new Error("Stack Admin App sendRequest returned an unexpected value."); + } + return response; + }, + }; +} + +function getProviderConfig(providerId: ProviderId) { + const provider = providerConfigs.find((config) => config.id === providerId); + if (provider == null) throw new Error(`Missing provider config for ${providerId}`); + return provider; +} + +function isJobStatus(value: unknown): value is JobStatus { + return value === "PENDING" || value === "RUNNING" || value === "WAITING_RETRY" || value === "SUCCEEDED" || value === "FAILED"; +} + +function isAuthMigrationJob(value: unknown): value is AuthMigrationJob { + if (typeof value !== "object" || value === null) return false; + const id = Reflect.get(value, "id"); + const provider = Reflect.get(value, "provider"); + const status = Reflect.get(value, "status"); + const attemptCount = Reflect.get(value, "attempt_count"); + const maxAttempts = Reflect.get(value, "max_attempts"); + const nextAttemptAtMillis = Reflect.get(value, "next_attempt_at_millis"); + const startedAtMillis = Reflect.get(value, "started_at_millis"); + const finishedAtMillis = Reflect.get(value, "finished_at_millis"); + const lastErrorExternalMessage = Reflect.get(value, "last_error_external_message"); + const createdAtMillis = Reflect.get(value, "created_at_millis"); + const updatedAtMillis = Reflect.get(value, "updated_at_millis"); + return typeof id === "string" + && typeof provider === "string" + && isProviderId(provider) + && isJobStatus(status) + && typeof attemptCount === "number" + && typeof maxAttempts === "number" + && (typeof nextAttemptAtMillis === "number" || nextAttemptAtMillis === null) + && (typeof startedAtMillis === "number" || startedAtMillis === null) + && (typeof finishedAtMillis === "number" || finishedAtMillis === null) + && (typeof lastErrorExternalMessage === "string" || lastErrorExternalMessage === null) + && typeof createdAtMillis === "number" + && typeof updatedAtMillis === "number"; +} + +function parseJobsResponse(value: unknown): AuthMigrationJob[] { + if (typeof value !== "object" || value === null || !("items" in value) || !Array.isArray(value.items)) { + throw new Error("Unexpected auth migration list response."); + } + if (!value.items.every(isAuthMigrationJob)) { + throw new Error("Auth migration list response contained an invalid job."); + } + return value.items; +} + +function buildProviderIdMap(rows: ProviderMapRow[]): Record | undefined { + const providerIdMap: Record = {}; + for (const row of rows) { + const sourceProviderId = row.sourceProviderId.trim(); + const stackProviderId = row.stackProviderId.trim(); + if (sourceProviderId === "" && stackProviderId === "") { + continue; + } + if (sourceProviderId === "" || stackProviderId === "") { + throw new Error("OAuth provider map rows need both a source provider ID and a Stack Auth provider ID."); + } + providerIdMap[sourceProviderId] = stackProviderId; + } + return Object.keys(providerIdMap).length > 0 ? providerIdMap : undefined; +} + +function buildCredentials(provider: ProviderId, credentialValues: Record): Record { + const credentials: Record = {}; + for (const field of providerCredentialFields[provider]) { + const fieldId = `${provider}.${field.key}`; + const value = (Object.hasOwn(credentialValues, fieldId) ? credentialValues[fieldId] : "").trim(); + if (value === "") { + if (field.optional === true) continue; + throw new Error(`${field.label} is required.`); + } + credentials[field.key] = value; + } + return credentials; +} + +function formatTimestamp(millis: number | null) { + if (millis == null) return "—"; + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(millis)); +} + +function getStatusMeta(status: JobStatus) { + const meta = statusCopy.get(status); + if (meta == null) throw new Error(`Missing migration status metadata for ${status}`); + return meta; +} + +function countStatus(jobs: AuthMigrationJob[], status: JobStatus) { + return jobs.filter((job) => job.status === status).length; +} + +function getImportedCount(jobs: AuthMigrationJob[]) { + return jobs.filter((job) => job.status === "SUCCEEDED").length; +} + +export default function PageClient() { + const adminApp = useAdminApp(); + const adminAppInternals = useMemo(() => getAdminAppInternals(adminApp), [adminApp]); + const [selectedProvider, setSelectedProvider] = useState("workos"); + const [unsupportedPasswordHashAction, setUnsupportedPasswordHashAction] = useState("error"); + const [credentialValues, setCredentialValues] = useState>({}); + const [providerMapRows, setProviderMapRows] = useState([ + { id: "default", sourceProviderId: "", stackProviderId: "" }, + ]); + const [jobs, setJobs] = useState([]); + const [selectedJobId, setSelectedJobId] = useState(null); + const [loadingJobs, setLoadingJobs] = useState(true); + const [queueing, setQueueing] = useState(false); + const [error, setError] = useState(null); + const inFlightRef = useRef(false); + + const selectedProviderConfig = getProviderConfig(selectedProvider); + const selectedJob: AuthMigrationJob | null = jobs.find((job) => job.id === selectedJobId) ?? (jobs.length > 0 ? jobs[0] : null); + + const loadJobs = useCallback(async () => { + if (inFlightRef.current) return; + inFlightRef.current = true; + setLoadingJobs(true); + + const result = await Result.fromPromise((async () => { + const response = await adminAppInternals.sendRequest( + urlString`/internal/auth-migrations`, + { method: "GET" }, + "admin", + ); + const body: unknown = await response.json(); + if (!response.ok) { + const message = typeof body === "object" && body !== null && "error" in body && typeof body.error === "string" ? body.error : "Failed to load migration jobs."; + throw new Error(message); + } + return parseJobsResponse(body); + })()); + + inFlightRef.current = false; + setLoadingJobs(false); + + if (result.status === "error") { + setError(result.error instanceof Error ? result.error.message : String(result.error)); + return; + } + + setJobs(result.data); + setSelectedJobId((current) => current ?? (result.data.length > 0 ? result.data[0].id : null)); + setError(null); + }, [adminAppInternals]); + + useEffect(() => { + runAsynchronously(loadJobs()); + }, [loadJobs]); + + const queueMigration = useCallback(async () => { + setQueueing(true); + const result = await Result.fromPromise((async () => { + const credentials = buildCredentials(selectedProvider, credentialValues); + const providerIdMap = buildProviderIdMap(providerMapRows); + const body = { + provider: selectedProvider, + credentials: { + ...credentials, + unsupported_password_hash_action: unsupportedPasswordHashAction, + ...(providerIdMap != null ? { provider_id_map: providerIdMap } : {}), + }, + }; + + const response = await adminAppInternals.sendRequest( + urlString`/internal/auth-migrations`, + { + method: "POST", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }, + "admin", + ); + const responseBody: unknown = await response.json(); + if (!response.ok) { + const message = typeof responseBody === "object" && responseBody !== null && "error" in responseBody && typeof responseBody.error === "string" ? responseBody.error : "Failed to queue migration job."; + throw new Error(message); + } + if (!isAuthMigrationJob(responseBody)) { + throw new Error("Unexpected auth migration create response."); + } + return responseBody; + })()); + setQueueing(false); + + if (result.status === "error") { + setError(result.error instanceof Error ? result.error.message : String(result.error)); + return; + } + + setJobs((current) => [result.data, ...current]); + setSelectedJobId(result.data.id); + setError(null); + }, [adminAppInternals, credentialValues, providerMapRows, selectedProvider, unsupportedPasswordHashAction]); + + const retrySelectedJob = useCallback(async () => { + if (selectedJob == null) return; + const result = await Result.fromPromise((async () => { + const response = await adminAppInternals.sendRequest( + urlString`/internal/auth-migrations/${selectedJob.id}/retry`, + { method: "POST" }, + "admin", + ); + const body: unknown = await response.json(); + if (!response.ok) { + const message = typeof body === "object" && body !== null && "error" in body && typeof body.error === "string" ? body.error : "Failed to retry migration job."; + throw new Error(message); + } + if (!isAuthMigrationJob(body)) { + throw new Error("Unexpected auth migration retry response."); + } + return body; + })()); + + if (result.status === "error") { + setError(result.error instanceof Error ? result.error.message : String(result.error)); + return; + } + + setJobs((current) => current.map((job) => job.id === result.data.id ? result.data : job)); + setError(null); + }, [adminAppInternals, selectedJob]); + + const stats = useMemo(() => [ + { label: "Queued", value: countStatus(jobs, "PENDING"), icon: ClockIcon, color: "blue" as const }, + { label: "Running", value: countStatus(jobs, "RUNNING"), icon: ArrowsClockwiseIcon, color: "orange" as const }, + { label: "Failed", value: countStatus(jobs, "FAILED"), icon: XCircleIcon, color: "red" as const }, + { label: "Imported jobs", value: getImportedCount(jobs), icon: CheckCircleIcon, color: "green" as const }, + ], [jobs]); + + return ( + + + Refresh + + } + > + {error != null && ( + + )} + +
+ {stats.map((stat) => ( + +
+
+
{stat.label}
+
{stat.value}
+
+
+ +
+
+
+ ))} +
+ +
+ +
+
+ {providerConfigs.map((provider) => { + const selected = provider.id === selectedProvider; + return ( + + ); + })} +
+ +
+
+
+
Source credentials
+
+ Stack Auth will use these credentials on the backend to fetch from {selectedProviderConfig.label} and convert the data into Stack Auth users and teams. +
+
+
+ {providerCredentialFields[selectedProvider].map((field) => { + const fieldId = `${selectedProvider}.${field.key}`; + return ( + + ); + })} +
+
+ +
+
+
+ OAuth provider map +
+
+ {providerMapRows.map((row) => ( +
+ setProviderMapRows((current) => current.map((item) => item.id === row.id ? { ...item, sourceProviderId: event.target.value } : item))} + /> + setProviderMapRows((current) => current.map((item) => item.id === row.id ? { ...item, stackProviderId: event.target.value } : item))} + /> +
+ ))} + setProviderMapRows((current) => [...current, { id: crypto.randomUUID(), sourceProviderId: "", stackProviderId: "" }])} + > + Add mapping + +
+
+ +
+
Password hashes
+ +
+ + +
+
+ +
+
+ Queue the job and the backend will pull from the source provider, normalize the data, and import it into this Stack Auth project branch. +
+ + + Queue migration + +
+
+
+ + + {selectedJob == null ? ( +
+ +
No jobs queued
+
Queue a migration to inspect attempts, failures, and import counts.
+
+ ) : ( +
+
+
+
{getProviderConfig(selectedJob.provider).label}
+
{selectedJob.id}
+
+ +
+ +
+ + + + +
+ + {selectedJob.last_error_external_message != null && ( + + )} + +
+
Worker timeline
+ {(["PENDING", "RUNNING", "WAITING_RETRY", "SUCCEEDED"] as const).map((status) => ( + + ))} +
+ + + + Retry job + +
+ )} +
+
+ + + {jobs.length === 0 ? ( +
+ {loadingJobs ? "Loading migration jobs..." : "No migration jobs yet."} +
+ ) : ( +
+
+
Provider
+
Status
+
Attempts
+
Created
+
Actions
+
+ {jobs.map((job) => { + const statusMeta = getStatusMeta(job.status); + return ( + + ); + })} +
+ )} +
+
+ ); +} + +function JobDetail(props: { label: string, value: string }) { + return ( +
+
{props.label}
+
{props.value}
+
+ ); +} + +function TimelineRow(props: { active: boolean, complete: boolean, label: string }) { + return ( +
+
+ {props.label} +
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/migrations/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/migrations/page.tsx new file mode 100644 index 0000000000..c78c9eb7e4 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/migrations/page.tsx @@ -0,0 +1,11 @@ +import PageClient from "./page-client"; + +export const metadata = { + title: "Migrations", +}; + +export default function Page() { + return ( + + ); +} diff --git a/apps/dashboard/src/lib/apps-frontend.tsx b/apps/dashboard/src/lib/apps-frontend.tsx index b4bb954985..aab1a21dea 100644 --- a/apps/dashboard/src/lib/apps-frontend.tsx +++ b/apps/dashboard/src/lib/apps-frontend.tsx @@ -1,5 +1,5 @@ import { Link } from "@/components/link"; -import { ChartLineIcon, ClipboardTextIcon, CreditCardIcon, EnvelopeSimpleIcon, FingerprintSimpleIcon, KeyIcon, MailboxIcon, RocketIcon, ShieldCheckIcon, SparkleIcon, TelevisionSimpleIcon, TriangleIcon, UserGearIcon, UsersIcon, VaultIcon, WebhooksLogoIcon } from "@phosphor-icons/react"; +import { ArrowsClockwiseIcon, ChartLineIcon, ClipboardTextIcon, CreditCardIcon, EnvelopeSimpleIcon, FingerprintSimpleIcon, KeyIcon, MailboxIcon, RocketIcon, ShieldCheckIcon, SparkleIcon, TelevisionSimpleIcon, TriangleIcon, UserGearIcon, UsersIcon, VaultIcon, WebhooksLogoIcon } from "@phosphor-icons/react"; import { StackAdminApp } from "@stackframe/stack"; import { ALL_APPS } from "@stackframe/stack-shared/dist/apps/apps-config"; import { getRelativePart, isChildUrl } from "@stackframe/stack-shared/dist/utils/urls"; @@ -177,6 +177,21 @@ export const ALL_APPS_FRONTEND = { ), }, + migrations: { + icon: ArrowsClockwiseIcon, + href: "migrations", + navigationItems: [ + { displayName: "Migrations", href: "." }, + ], + screenshots: [], + storeDescription: ( + <> +

Migrations imports users, teams, OAuth accounts, and password hashes from other auth providers into Stack Auth.

+

Queue provider jobs from the dashboard while credentials stay encrypted and backend workers handle retries.

+

Use Better Auth-shaped provider migrations as the normalization layer so the hard provider-specific parsing stays reusable.

+ + ), + }, payments: { icon: CreditCardIcon, href: "payments", @@ -445,4 +460,3 @@ async function getEmailDraftBreadcrumbItems(stackAdminApp: StackAdminApp, }, ]; } - diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth-migrations.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth-migrations.test.ts new file mode 100644 index 0000000000..64e29a5967 --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth-migrations.test.ts @@ -0,0 +1,62 @@ +import { it } from "../../../../helpers"; +import { Project, niceBackendFetch } from "../../../backend-helpers"; + +it("creates and reads queued auth migration jobs from the backend", async ({ expect }) => { + await Project.createAndSwitch(); + + const createResponse = await niceBackendFetch("/api/v1/internal/auth-migrations", { + method: "POST", + accessType: "admin", + body: { + provider: "clerk", + credentials: { + secret_key: "sk_test_dummy", + }, + }, + }); + + expect(createResponse).toMatchObject({ + status: 200, + body: { + id: expect.any(String), + provider: "clerk", + status: "PENDING", + attempt_count: 0, + max_attempts: 5, + next_attempt_at_millis: null, + started_at_millis: null, + finished_at_millis: null, + last_error_external_message: null, + }, + }); + + const jobId = createResponse.body.id as string; + + const readResponse = await niceBackendFetch(`/api/v1/internal/auth-migrations/${jobId}`, { + accessType: "admin", + }); + expect(readResponse).toMatchObject({ + status: 200, + body: { + id: jobId, + provider: "clerk", + status: "PENDING", + }, + }); + + const listResponse = await niceBackendFetch("/api/v1/internal/auth-migrations", { + accessType: "admin", + }); + expect(listResponse).toMatchObject({ + status: 200, + body: { + items: expect.arrayContaining([ + expect.objectContaining({ + id: jobId, + provider: "clerk", + status: "PENDING", + }), + ]), + }, + }); +}); diff --git a/claude/CLAUDE-KNOWLEDGE.md b/claude/CLAUDE-KNOWLEDGE.md index 402521357b..b2085d3f00 100644 --- a/claude/CLAUDE-KNOWLEDGE.md +++ b/claude/CLAUDE-KNOWLEDGE.md @@ -217,10 +217,16 @@ A: In `apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project Q: How can onboarding CTA buttons stay visible without leaving bottom-of-page actions on every step? A: In the current onboarding implementation, step actions are rendered by the shared `OnboardingPage` layout rather than a dedicated `OnboardingStickyTop` component in `apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx`. Keep the page body focused on step content and rely on that shared layout for visible `Continue` / `Do This Later` actions instead of adding duplicated footer CTAs. + Q: How should user signup time be exposed in JWT claims before production rollout? -A: The local dashboard's `DEV` overlay includes `Quick Sign In` and `Switch to email...` shortcuts, which are useful for browser smoke tests without going through the full external OAuth flow. A: Use `signed_up_at` (OIDC-style naming) in access tokens and encode it as Unix seconds in `apps/backend/src/lib/tokens.tsx` (`Math.floor(user.signed_up_at_millis / 1000)`). Since this is pre-prod, the payload schema can require `signed_up_at` directly without a backward-compat optional shim. +Q: What is the intended architecture for provider migrations through Better Auth into Stack Auth? +A: Treat Better Auth as the normalization/migration engine, not as an intermediate database. The backend should own provider credentials, queue/retry state, and final persistence into Stack Auth. Provider runners can reuse Better Auth migration logic by swapping its persistence adapter for a Stack Auth capture adapter that accepts Better Auth-shaped `user`, `account`, `organization`, and `member` writes, then imports the resulting plan into the current project branch. + +Q: How should the Stack Auth dashboard expose provider migrations? +A: Expose migrations as a project dashboard app with a compact provider wizard: provider selector, credentials/records JSON editor, OAuth provider ID map, unsupported password hash behavior, backend queue action, status summary cards, and selected-job retry details. Keep the UI backend-owned: credentials should be submitted to internal auth migration job endpoints and encrypted/persisted server-side rather than managed as long-lived browser state. + Q: Where should new globally searchable Cmd+K destinations be added in the dashboard? A: Add project-level shortcuts to `PROJECT_SHORTCUTS` in `apps/dashboard/src/components/cmdk-commands.tsx` (optionally gated with `requiredApps`), and for app subpages rely on the flattened `appFrontend.navigationItems` command generation in the same file so pages are directly searchable without nested preview navigation. diff --git a/docs-mintlify/docs.json b/docs-mintlify/docs.json index 37bea151cf..7d01d1451c 100644 --- a/docs-mintlify/docs.json +++ b/docs-mintlify/docs.json @@ -61,7 +61,8 @@ "guides/going-further/stack-app", "guides/going-further/backend-integration", "guides/going-further/local-development", - "guides/going-further/user-metadata" + "guides/going-further/user-metadata", + "guides/going-further/migrations" ] }, { diff --git a/docs-mintlify/guides/faq.mdx b/docs-mintlify/guides/faq.mdx index b3921e9bea..98186e286a 100644 --- a/docs-mintlify/guides/faq.mdx +++ b/docs-mintlify/guides/faq.mdx @@ -36,7 +36,7 @@ sidebarTitle: FAQ - Yes! You can [create users programmatically](/api/server/users/create-user) using our [REST API](/api/overview). + Yes! For provider migrations, use backend migration jobs from the project dashboard. You can also [create users programmatically](/api/server/users/create-user) using our [REST API](/api/overview). diff --git a/docs-mintlify/guides/going-further/migrations.mdx b/docs-mintlify/guides/going-further/migrations.mdx new file mode 100644 index 0000000000..bb3a7dd9a7 --- /dev/null +++ b/docs-mintlify/guides/going-further/migrations.mdx @@ -0,0 +1,53 @@ +--- +title: Migrations +description: Migrate users, organizations, passwords, and OAuth accounts from other auth providers to Stack Auth. +--- + +# Migrations + +Stack Auth migration jobs move users, organizations, passwords, and OAuth accounts from other providers into Stack Auth from the backend. + +The recommended strategy is to use the best migration logic that already exists for your source provider, then swap the persistence layer so the normalized records are imported into Stack Auth. For example, if your source provider has a Better Auth migration, Stack Auth can let Better Auth do the provider-specific transformation and use Stack Auth as the persistence target. + +## Better Auth as the migration engine + +Better Auth migration guides often transform provider data into Better Auth model writes such as `user`, `account`, `organization`, and `member`. Instead of writing those records to a Better Auth database, Stack Auth captures those Better Auth-shaped writes on the backend and imports the resulting plan into the current project branch. + +Dashboard-driven provider migrations are queued server-side. Credentials are encrypted before they are persisted, workers claim jobs with retry state, and failed jobs can be retried without resubmitting credentials. + +## What gets migrated + +The Better Auth persistence adapter currently maps: + +- `user` records to Stack Auth users +- credential `account` records to Stack Auth password hashes +- OAuth `account` records to Stack Auth OAuth providers +- `organization` records to Stack Auth teams +- `member` records to Stack Auth team memberships + +Stack Auth stores source ids in `server_metadata.migration` and Better Auth details in `server_metadata.better_auth`, so you can keep an audit trail and build id maps during cutover. + +## Provider jobs + +Migration jobs are created per project branch and can target these provider families: + +- WorkOS +- Clerk +- Auth.js +- Auth0 +- Supabase Auth +- Better Auth + +Provider-specific credentials are submitted to the backend, not stored in browser state. The backend normalizes source-provider data into the Better Auth-shaped migration model, then imports it into Stack Auth. + +## Password hashes + +Stack Auth accepts bcrypt hashes through `password_hash`. If the normalized Better Auth account contains another hash type, the migration fails by default so you do not silently import users who cannot sign in. + +If you choose to omit unsupported password hashes, users imported this way should go through password reset or another sign-in method. + +## OAuth provider ids + +If your Better Auth provider ids differ from the provider ids configured in Stack Auth, provide a provider id map when creating the migration job. + +Configure the matching OAuth providers in Stack Auth before importing users. diff --git a/docs/content/docs/(guides)/faq.mdx b/docs/content/docs/(guides)/faq.mdx index 8f43a94a52..ae6ea8a34e 100644 --- a/docs/content/docs/(guides)/faq.mdx +++ b/docs/content/docs/(guides)/faq.mdx @@ -28,7 +28,7 @@ description: Frequently asked questions about Stack If you answered "no" to any of these questions, then that's how Stack Auth is different from ``. - Yes! You can [create users programmatically](/rest-api/server/users/create-user) using our [REST API](/rest-api). + Yes! For provider migrations, use backend migration jobs from the project dashboard. You can also [create users programmatically](/rest-api/server/users/create-user) using our [REST API](/rest-api). diff --git a/packages/migrations/README.md b/packages/migrations/README.md new file mode 100644 index 0000000000..cc1a30dc6e --- /dev/null +++ b/packages/migrations/README.md @@ -0,0 +1,33 @@ +# Stack Auth migrations + +Utilities for migrating users, organizations, memberships, password hashes, and OAuth accounts from other auth providers to Stack Auth. + +## Better Auth + +Better Auth is supported as a migration engine. This is useful when another provider, such as WorkOS or Clerk, already has a Better Auth migration path. Let Better Auth normalize the source provider's data into Better Auth model writes, but point those writes at Stack Auth migration persistence instead of a Better Auth database. + +```ts +import { createBetterAuthStackPersistence } from "@stackframe/migrations"; + +const persistence = createBetterAuthStackPersistence(); + +// In your Better Auth migration script, replace ctx.adapter with +// persistence.adapter for all migration writes: +await persistence.adapter.create({ + model: "user", + data: { + id: "external-user-id", + email: "user@example.com", + emailVerified: true, + name: "User Name", + }, +}); + +await persistence.flushToStackAuth({ + apiUrl: "http://localhost:8102", + projectId: process.env.STACK_PROJECT_ID!, + secretServerKey: process.env.STACK_SECRET_SERVER_KEY!, +}); +``` + +The package captures Better Auth `user`, `account`, `organization`, and `member` writes, converts them into Stack Auth users, OAuth accounts, teams, and memberships, and imports them through Stack Auth's server REST API. diff --git a/packages/migrations/eslint.config.mjs b/packages/migrations/eslint.config.mjs new file mode 100644 index 0000000000..db0ece8629 --- /dev/null +++ b/packages/migrations/eslint.config.mjs @@ -0,0 +1,29 @@ +import tsPlugin from "@typescript-eslint/eslint-plugin"; +import tsParser from "@typescript-eslint/parser"; +import { fileURLToPath } from "node:url"; + +const tsconfigRootDir = fileURLToPath(new URL(".", import.meta.url)); + +export default [ + { + ignores: ["dist/**"], + }, + { + files: ["src/**/*.ts"], + languageOptions: { + parser: tsParser, + parserOptions: { + project: "./tsconfig.json", + sourceType: "module", + tsconfigRootDir, + }, + }, + plugins: { + "@typescript-eslint": tsPlugin, + }, + rules: { + ...tsPlugin.configs.recommended.rules, + "@typescript-eslint/no-explicit-any": "error", + }, + }, +]; diff --git a/packages/migrations/package.json b/packages/migrations/package.json new file mode 100644 index 0000000000..d58ad17764 --- /dev/null +++ b/packages/migrations/package.json @@ -0,0 +1,48 @@ +{ + "name": "@stackframe/migrations", + "version": "2.8.86", + "repository": "https://github.com/stack-auth/stack-auth", + "description": "Migration utilities for moving users, organizations, and auth data from other auth providers to Stack Auth.", + "main": "dist/index.js", + "type": "module", + "bin": { + "stack-migrate": "./dist/cli.js" + }, + "scripts": { + "build": "rimraf dist && tsdown", + "dev": "tsdown --watch", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "files": [ + "README.md", + "dist", + "CHANGELOG.md", + "LICENSE" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "require": { + "default": "./dist/index.js" + }, + "default": "./dist/esm/index.js" + }, + "./better-auth": { + "types": "./dist/adapters/better-auth.d.ts", + "require": { + "default": "./dist/adapters/better-auth.js" + }, + "default": "./dist/esm/adapters/better-auth.js" + } + }, + "devDependencies": { + "@types/node": "20.17.6", + "rimraf": "^6.0.1", + "tsdown": "^0.20.3", + "typescript": "5.9.3", + "vitest": "^1.6.0" + }, + "packageManager": "pnpm@10.23.0" +} diff --git a/packages/migrations/scripts/test-clerk-to-stack.ts b/packages/migrations/scripts/test-clerk-to-stack.ts new file mode 100644 index 0000000000..86fd1ce353 --- /dev/null +++ b/packages/migrations/scripts/test-clerk-to-stack.ts @@ -0,0 +1,189 @@ +import { createBetterAuthStackPersistence } from "../src"; + +const clerkApiBaseUrl = "https://api.clerk.com/v1"; +const userCount = 100; +const runId = `stack-migration-${new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)}`; +const importedPasswordHash = "$2a$10$TVyY/gpw9Db/w1fBeJkCgeNg2Rae2JfNqrPnSAKtj.ufAO5cVF13."; + +function getRequiredEnv(name: string): string { + const value = process.env[name]; + if (value == null || value === "") { + throw new Error(`${name} is required`); + } + return value; +} + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function clerkFetch(path: string, options: RequestInit = {}): Promise { + const secretKey = getRequiredEnv("CLERK_SECRET_KEY"); + const response = await fetch(`${clerkApiBaseUrl}${path}`, { + ...options, + headers: { + "authorization": `Bearer ${secretKey}`, + "content-type": "application/json", + ...options.headers, + }, + }); + + const text = await response.text(); + const body = text === "" ? null : JSON.parse(text); + if (!response.ok) { + throw new Error(`Clerk ${options.method ?? "GET"} ${path} failed with ${response.status}: ${text}`); + } + return body; +} + +function readObject(value: unknown, label: string): Record { + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + return value as Record; + } + throw new Error(`${label} must be an object`); +} + +function readString(value: unknown, label: string): string { + if (typeof value === "string") { + return value; + } + throw new Error(`${label} must be a string`); +} + +function readMaybeString(value: unknown, label: string): string | null { + if (value == null) { + return null; + } + return readString(value, label); +} + +function readBoolean(value: unknown, label: string): boolean { + if (typeof value === "boolean") { + return value; + } + throw new Error(`${label} must be a boolean`); +} + +function readDateIso(value: unknown, label: string): string { + if (typeof value !== "string" && typeof value !== "number") { + throw new Error(`${label} must be a string or number timestamp`); + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new Error(`${label} is not a valid timestamp`); + } + return date.toISOString(); +} + +function readPrimaryEmail(user: Record): { email: string, verified: boolean } { + const primaryEmailAddressId = readString(user.primary_email_address_id, "primary_email_address_id"); + if (!Array.isArray(user.email_addresses)) { + throw new Error("email_addresses must be an array"); + } + + const primaryEmail = user.email_addresses + .map((email) => readObject(email, "email_address")) + .find((email) => email.id === primaryEmailAddressId); + if (primaryEmail == null) { + throw new Error(`No primary email found for Clerk user ${String(user.id)}`); + } + + return { + email: readString(primaryEmail.email_address, "email_address.email_address"), + verified: readString(readObject(primaryEmail.verification, "email_address.verification").status, "email_address.verification.status") === "verified", + }; +} + +async function seedClerkUser(index: number): Promise> { + const paddedIndex = String(index).padStart(3, "0"); + const externalId = `${runId}-${paddedIndex}`; + return readObject(await clerkFetch("/users", { + method: "POST", + body: JSON.stringify({ + external_id: externalId, + email_address: [`${externalId}@stack-generated.example.com`], + first_name: "Stack", + last_name: `Migration ${paddedIndex}`, + password_digest: importedPasswordHash, + password_hasher: "bcrypt", + public_metadata: { + stackMigrationRunId: runId, + seedIndex: index, + }, + private_metadata: { + source: "clerk-test-seed", + }, + skip_password_checks: true, + }), + }), "Clerk create user response"); +} + +async function seedClerkUsers(): Promise[]> { + const users: Record[] = []; + for (let index = 0; index < userCount; index++) { + users.push(await seedClerkUser(index)); + if ((index + 1) % 10 === 0) { + console.log(`Seeded ${index + 1}/${userCount} Clerk users`); + await wait(1200); + } + } + return users; +} + +async function main(): Promise { + const stackApiUrl = process.env.STACK_API_URL ?? "http://localhost:8102"; + const stackProjectId = process.env.STACK_PROJECT_ID ?? "internal"; + const stackSecretServerKey = process.env.STACK_SECRET_SERVER_KEY ?? "this-secret-server-key-is-for-local-development-only"; + const stackPublishableClientKey = process.env.STACK_PUBLISHABLE_CLIENT_KEY ?? "this-publishable-client-key-is-for-local-development-only"; + + console.log(`Starting Clerk -> Better Auth persistence -> Stack Auth test run ${runId}`); + const clerkUsers = await seedClerkUsers(); + + const persistence = createBetterAuthStackPersistence(); + for (const clerkUser of clerkUsers) { + const clerkUserId = readString(clerkUser.id, "Clerk user id"); + const externalId = readMaybeString(clerkUser.external_id, "Clerk external id") ?? clerkUserId; + const primaryEmail = readPrimaryEmail(clerkUser); + const firstName = readMaybeString(clerkUser.first_name, "first_name"); + const lastName = readMaybeString(clerkUser.last_name, "last_name"); + const displayName = [firstName, lastName].filter((value) => value != null && value !== "").join(" ") || null; + + await persistence.adapter.create({ + model: "user", + data: { + id: externalId, + email: primaryEmail.email, + emailVerified: primaryEmail.verified, + name: displayName, + image: readMaybeString(clerkUser.image_url, "image_url"), + createdAt: readDateIso(clerkUser.created_at, "created_at"), + updatedAt: readDateIso(clerkUser.updated_at, "updated_at"), + banned: readBoolean(clerkUser.banned, "banned"), + }, + }); + + await persistence.adapter.create({ + model: "account", + data: { + id: `${externalId}-credential`, + userId: externalId, + accountId: externalId, + providerId: "credential", + password: importedPasswordHash, + }, + }); + } + + const plan = persistence.buildPlan(); + console.log(`Built Stack Auth import plan: ${plan.users.length} users, ${plan.teams.length} teams, ${plan.memberships.length} memberships`); + const result = await persistence.flushToStackAuth({ + apiUrl: stackApiUrl, + projectId: stackProjectId, + secretServerKey: stackSecretServerKey, + publishableClientKey: stackPublishableClientKey, + }); + console.log(`Imported ${result.userIdMap.size} users into Stack Auth internal project`); + console.log(`Run id: ${runId}`); +} + +await main(); diff --git a/packages/migrations/src/adapters/better-auth.test.ts b/packages/migrations/src/adapters/better-auth.test.ts new file mode 100644 index 0000000000..e94427444d --- /dev/null +++ b/packages/migrations/src/adapters/better-auth.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from "vitest"; +import { createBetterAuthStackPersistence } from "./better-auth"; + +describe("createBetterAuthStackPersistence", () => { + it("captures Better Auth model writes and builds a Stack Auth import plan", async () => { + const persistence = createBetterAuthStackPersistence(); + + await persistence.adapter.create({ + model: "user", + data: { + id: "workos-user-1", + email: "Ada@Example.COM", + emailVerified: true, + name: "Ada Lovelace", + image: "https://example.com/ada.png", + createdAt: "2024-01-02T03:04:05.000Z", + updatedAt: "2024-01-03T03:04:05.000Z", + }, + }); + await persistence.adapter.create({ + model: "account", + data: { + id: "credential-account-1", + userId: "workos-user-1", + accountId: "workos-user-1", + providerId: "credential", + password: "$2a$10$TVyY/gpw9Db/w1fBeJkCgeNg2Rae2JfNqrPnSAKtj.ufAO5cVF13.", + }, + }); + await persistence.adapter.create({ + model: "account", + data: { + id: "oauth-account-1", + userId: "workos-user-1", + accountId: "github-ada", + providerId: "github", + }, + }); + await persistence.adapter.create({ + model: "organization", + data: { + id: "workos-org-1", + name: "Analytical Engines", + slug: "analytical-engines", + logo: null, + metadata: { plan: "pro" }, + }, + }); + await persistence.adapter.create({ + model: "member", + data: { + id: "workos-member-1", + userId: "workos-user-1", + organizationId: "workos-org-1", + role: "admin", + }, + }); + + expect(persistence.buildPlan()).toMatchInlineSnapshot(` + { + "memberships": [ + { + "externalMembershipId": "workos-member-1", + "externalOrganizationId": "workos-org-1", + "externalUserId": "workos-user-1", + "metadata": { + "better_auth": { + "created_at": null, + "id": "workos-member-1", + "updated_at": null, + }, + }, + "role": "admin", + }, + ], + "teams": [ + { + "body": { + "display_name": "Analytical Engines", + "server_metadata": { + "better_auth": { + "created_at": null, + "id": "workos-org-1", + "metadata": { + "plan": "pro", + }, + "slug": "analytical-engines", + "updated_at": null, + }, + "migration": { + "organization_id": "workos-org-1", + "source": "better_auth", + }, + }, + }, + "externalOrganizationId": "workos-org-1", + }, + ], + "users": [ + { + "body": { + "display_name": "Ada Lovelace", + "oauth_providers": [ + { + "account_id": "github-ada", + "email": "Ada@Example.COM", + "id": "github", + }, + ], + "password_hash": "$2a$10$TVyY/gpw9Db/w1fBeJkCgeNg2Rae2JfNqrPnSAKtj.ufAO5cVF13.", + "primary_email": "Ada@Example.COM", + "primary_email_auth_enabled": true, + "primary_email_verified": true, + "profile_image_url": "https://example.com/ada.png", + "server_metadata": { + "better_auth": { + "created_at": "2024-01-02T03:04:05.000Z", + "id": "workos-user-1", + "updated_at": "2024-01-03T03:04:05.000Z", + }, + "migration": { + "source": "better_auth", + "user_id": "workos-user-1", + }, + }, + }, + "externalUserId": "workos-user-1", + }, + ], + } + `); + }); + + it("can omit unsupported hashes when Better Auth normalized a provider that Stack cannot verify", async () => { + const persistence = createBetterAuthStackPersistence(); + await persistence.adapter.create({ + model: "user", + data: { + id: "user-1", + email: "user@example.com", + }, + }); + await persistence.adapter.create({ + model: "account", + data: { + id: "account-1", + userId: "user-1", + accountId: "user-1", + providerId: "credential", + password: "scrypt:hash", + }, + }); + + expect(() => persistence.buildPlan()).toThrow("unsupported password hash"); + expect(persistence.buildPlan({ unsupportedPasswordHashAction: "omit" }).users[0].body.password_hash).toBeUndefined(); + }); + + it("supports find/update/delete operations migration scripts commonly use", async () => { + const persistence = createBetterAuthStackPersistence(); + await persistence.adapter.create({ model: "user", data: { id: "user-1", email: "first@example.com" } }); + await persistence.adapter.update({ + model: "user", + where: [{ field: "email", value: "first@example.com" }], + update: { email: "second@example.com" }, + }); + + expect(await persistence.adapter.findOne({ model: "user", where: [{ field: "email", value: "second@example.com" }] })).toMatchObject({ + id: "user-1", + email: "second@example.com", + }); + expect(await persistence.adapter.count({ model: "user", where: [{ field: "email", value: "second", operator: "contains" }] })).toBe(1); + + await persistence.adapter.delete({ model: "user", where: [{ field: "id", value: "user-1" }] }); + expect(await persistence.adapter.count({ model: "user" })).toBe(0); + }); +}); diff --git a/packages/migrations/src/adapters/better-auth.ts b/packages/migrations/src/adapters/better-auth.ts new file mode 100644 index 0000000000..e71057b757 --- /dev/null +++ b/packages/migrations/src/adapters/better-auth.ts @@ -0,0 +1,288 @@ +import { buildStackMigrationPlan } from "../core"; +import { importPlanToStackAuth, type StackApiConfig, type StackImportResult } from "../stack-api"; +import type { ExternalAuthSnapshot, ExternalMembership, ExternalOAuthAccount, ExternalOrganization, ExternalRestriction, ExternalUser, JsonObject, JsonValue, StackImportOptions, StackMigrationPlan } from "../types"; + +export type BetterAuthPersistenceRecord = JsonObject & { + id: string, +}; + +type BetterAuthWhere = { + field: string, + value: JsonValue, + operator?: "eq" | "ne" | "in" | "contains" | "starts_with" | "ends_with", +}[]; + +type BetterAuthCreateInput = { + model: string, + data: JsonObject, +}; + +type BetterAuthFindOneInput = { + model: string, + where?: BetterAuthWhere, +}; + +type BetterAuthFindManyInput = BetterAuthFindOneInput & { + limit?: number, + offset?: number, +}; + +type BetterAuthUpdateInput = BetterAuthFindOneInput & { + update: JsonObject, +}; + +export type BetterAuthPersistenceAdapter = { + create(input: BetterAuthCreateInput): Promise, + findOne(input: BetterAuthFindOneInput): Promise, + findMany(input: BetterAuthFindManyInput): Promise, + update(input: BetterAuthUpdateInput): Promise, + updateMany(input: BetterAuthUpdateInput): Promise, + delete(input: BetterAuthFindOneInput): Promise, + deleteMany(input: BetterAuthFindOneInput): Promise, + count(input: BetterAuthFindOneInput): Promise, +}; + +export type BetterAuthStackPersistence = { + adapter: BetterAuthPersistenceAdapter, + snapshot(): ExternalAuthSnapshot, + buildPlan(options?: StackImportOptions): StackMigrationPlan, + flushToStackAuth(config: StackApiConfig, options?: StackImportOptions): Promise, +}; + +function assertString(value: JsonValue | undefined, field: string, model: string): string { + if (typeof value !== "string") { + throw new Error(`Better Auth ${model}.${field} must be a string during Stack Auth migration`); + } + return value; +} + +function optionalString(value: JsonValue | undefined, field: string, model: string): string | null { + if (value == null) { + return null; + } + if (typeof value !== "string") { + throw new Error(`Better Auth ${model}.${field} must be a string during Stack Auth migration`); + } + return value; +} + +function optionalBoolean(value: JsonValue | undefined, field: string, model: string): boolean { + if (value == null) { + return false; + } + if (typeof value !== "boolean") { + throw new Error(`Better Auth ${model}.${field} must be a boolean during Stack Auth migration`); + } + return value; +} + +function toJsonObject(value: JsonValue | undefined, field: string, model: string): JsonObject { + if (value == null) { + return {}; + } + if (typeof value === "object" && !Array.isArray(value)) { + return value; + } + throw new Error(`Better Auth ${model}.${field} must be an object during Stack Auth migration`); +} + +function createdUpdatedMetadata(record: BetterAuthPersistenceRecord): JsonObject { + return { + better_auth: { + id: record.id, + created_at: optionalString(record.createdAt, "createdAt", "record"), + updated_at: optionalString(record.updatedAt, "updatedAt", "record"), + }, + }; +} + +function matchesWhere(record: BetterAuthPersistenceRecord, where: BetterAuthWhere | undefined): boolean { + if (where == null || where.length === 0) { + return true; + } + return where.every((clause) => { + const recordValue = record[clause.field]; + switch (clause.operator ?? "eq") { + case "eq": + return recordValue === clause.value; + case "ne": + return recordValue !== clause.value; + case "in": + return Array.isArray(clause.value) && clause.value.includes(recordValue); + case "contains": + return typeof recordValue === "string" && typeof clause.value === "string" && recordValue.includes(clause.value); + case "starts_with": + return typeof recordValue === "string" && typeof clause.value === "string" && recordValue.startsWith(clause.value); + case "ends_with": + return typeof recordValue === "string" && typeof clause.value === "string" && recordValue.endsWith(clause.value); + default: + throw new Error(`Unsupported Better Auth where operator ${String(clause.operator)}`); + } + }); +} + +function cloneRecord(record: BetterAuthPersistenceRecord): BetterAuthPersistenceRecord { + return JSON.parse(JSON.stringify(record)); +} + +function pushRecord(table: Map, model: string, data: JsonObject): BetterAuthPersistenceRecord { + const id = assertString(data.id, "id", model); + if (table.has(id)) { + throw new Error(`Better Auth ${model} record ${id} was created twice during Stack Auth migration`); + } + const record = { ...data, id }; + table.set(id, record); + return cloneRecord(record); +} + +function collectOAuthAccounts(accounts: BetterAuthPersistenceRecord[], userId: string, email: string | null): ExternalOAuthAccount[] { + return accounts + .filter((account) => account.userId === userId && account.providerId !== "credential") + .map((account) => ({ + providerId: assertString(account.providerId, "providerId", "account"), + accountId: assertString(account.accountId, "accountId", "account"), + email, + })); +} + +function collectPasswordHash(accounts: BetterAuthPersistenceRecord[], userId: string): string | null { + const credentialAccounts = accounts.filter((account) => account.userId === userId && account.providerId === "credential"); + if (credentialAccounts.length > 1) { + throw new Error(`Better Auth user ${userId} has multiple credential accounts`); + } + return optionalString(credentialAccounts[0]?.password, "password", "account"); +} + +function collectRestriction(record: BetterAuthPersistenceRecord): ExternalRestriction | null { + if (record.banned !== true) { + return null; + } + return { + reason: optionalString(record.banReason, "banReason", "user") ?? "Imported as banned", + privateDetails: `Better Auth user ${record.id} was banned during migration.`, + }; +} + +export function createBetterAuthStackPersistence(): BetterAuthStackPersistence { + const tables = new Map>(); + + function getTable(model: string): Map { + const existing = tables.get(model); + if (existing) { + return existing; + } + const created = new Map(); + tables.set(model, created); + return created; + } + + const adapter: BetterAuthPersistenceAdapter = { + async create(input) { + return pushRecord(getTable(input.model), input.model, input.data); + }, + async findOne(input) { + return [...getTable(input.model).values()].find((record) => matchesWhere(record, input.where)) ?? null; + }, + async findMany(input) { + const offset = input.offset ?? 0; + const limit = input.limit ?? Number.POSITIVE_INFINITY; + return [...getTable(input.model).values()] + .filter((record) => matchesWhere(record, input.where)) + .slice(offset, offset + limit) + .map(cloneRecord); + }, + async update(input) { + const match = [...getTable(input.model).values()].find((record) => matchesWhere(record, input.where)); + if (match == null) { + return null; + } + Object.assign(match, input.update); + return cloneRecord(match); + }, + async updateMany(input) { + const matches = [...getTable(input.model).values()].filter((record) => matchesWhere(record, input.where)); + for (const match of matches) { + Object.assign(match, input.update); + } + return matches.length; + }, + async delete(input) { + const table = getTable(input.model); + const match = [...table.values()].find((record) => matchesWhere(record, input.where)); + if (match != null) { + table.delete(match.id); + } + }, + async deleteMany(input) { + const table = getTable(input.model); + const matches = [...table.values()].filter((record) => matchesWhere(record, input.where)); + for (const match of matches) { + table.delete(match.id); + } + return matches.length; + }, + async count(input) { + return [...getTable(input.model).values()].filter((record) => matchesWhere(record, input.where)).length; + }, + }; + + function snapshot(): ExternalAuthSnapshot { + const usersTable = getTable("user"); + const accounts = [...getTable("account").values()]; + const organizationsTable = getTable("organization"); + const membersTable = getTable("member"); + + const users: ExternalUser[] = [...usersTable.values()].map((record) => { + const email = optionalString(record.email, "email", "user"); + return { + externalId: record.id, + primaryEmail: email, + primaryEmailVerified: optionalBoolean(record.emailVerified, "emailVerified", "user"), + displayName: optionalString(record.name, "name", "user"), + profileImageUrl: optionalString(record.image, "image", "user"), + passwordHash: collectPasswordHash(accounts, record.id), + oauthAccounts: collectOAuthAccounts(accounts, record.id, email), + restricted: collectRestriction(record), + metadata: createdUpdatedMetadata(record), + }; + }); + + const organizations: ExternalOrganization[] = [...organizationsTable.values()].map((record) => { + const baseMetadata = createdUpdatedMetadata(record); + return { + externalId: record.id, + displayName: assertString(record.name, "name", "organization"), + profileImageUrl: optionalString(record.logo, "logo", "organization"), + metadata: { + ...baseMetadata, + better_auth: { + ...toJsonObject(baseMetadata.better_auth, "better_auth", "organization"), + slug: optionalString(record.slug, "slug", "organization"), + metadata: record.metadata ?? null, + }, + }, + }; + }); + + const memberships: ExternalMembership[] = [...membersTable.values()].map((record) => ({ + externalId: record.id, + externalUserId: assertString(record.userId, "userId", "member"), + externalOrganizationId: assertString(record.organizationId, "organizationId", "member"), + role: optionalString(record.role, "role", "member"), + metadata: createdUpdatedMetadata(record), + })); + + return { source: "better_auth", users, organizations, memberships }; + } + + return { + adapter, + snapshot, + buildPlan(options) { + return buildStackMigrationPlan(snapshot(), options); + }, + async flushToStackAuth(config, options) { + return await importPlanToStackAuth(config, buildStackMigrationPlan(snapshot(), options)); + }, + }; +} diff --git a/packages/migrations/src/cli.ts b/packages/migrations/src/cli.ts new file mode 100644 index 0000000000..9ff70f0f9c --- /dev/null +++ b/packages/migrations/src/cli.ts @@ -0,0 +1,17 @@ +#!/usr/bin/env node + +function printHelp(): void { + console.log(`Stack Auth migration utilities + +This package is primarily intended to be used from migration scripts. + +Example: + import { createBetterAuthStackPersistence } from "@stackframe/migrations"; + + const persistence = createBetterAuthStackPersistence(); + // Point Better Auth migration writes at persistence.adapter, then: + await persistence.flushToStackAuth({ apiUrl, projectId, secretServerKey }); +`); +} + +printHelp(); diff --git a/packages/migrations/src/core.ts b/packages/migrations/src/core.ts new file mode 100644 index 0000000000..053ae06213 --- /dev/null +++ b/packages/migrations/src/core.ts @@ -0,0 +1,99 @@ +import type { ExternalAuthSnapshot, ExternalUser, JsonObject, StackImportOptions, StackMigrationPlan, StackUserCreateBody } from "./types"; + +function isSupportedStackPasswordHash(hash: string): boolean { + return /^\$2[ayb]\$.{56}$/.test(hash); +} + +function getProviderId(providerId: string, providerIdMap: Map): string { + return providerIdMap.get(providerId) ?? providerId; +} + +function buildServerMetadata(source: string, externalIdField: string, externalId: string, metadata: JsonObject): JsonObject { + return { + ...metadata, + migration: { + source, + [externalIdField]: externalId, + }, + }; +} + +function mapUserToStackBody(source: string, user: ExternalUser, options: Required): StackMigrationPlan["users"][number] { + const body: StackUserCreateBody = { + server_metadata: buildServerMetadata(source, "user_id", user.externalId, user.metadata), + }; + + if (user.primaryEmail != null) { + body.primary_email = user.primaryEmail; + body.primary_email_verified = user.primaryEmailVerified; + body.primary_email_auth_enabled = true; + } + if (user.displayName != null) { + body.display_name = user.displayName; + } + if (user.profileImageUrl != null) { + body.profile_image_url = user.profileImageUrl; + } + if (user.passwordHash != null) { + if (isSupportedStackPasswordHash(user.passwordHash)) { + body.password_hash = user.passwordHash; + } else if (options.unsupportedPasswordHashAction === "error") { + throw new Error(`External user ${user.externalId} has an unsupported password hash. Stack Auth currently accepts bcrypt hashes for password_hash imports.`); + } + } + if (user.restricted != null) { + body.restricted_by_admin = true; + body.restricted_by_admin_reason = user.restricted.reason; + body.restricted_by_admin_private_details = user.restricted.privateDetails; + } + + const oauthProviders = user.oauthAccounts + .sort((a, b) => `${a.providerId}:${a.accountId}`.localeCompare(`${b.providerId}:${b.accountId}`)) + .map((account) => ({ + id: getProviderId(account.providerId, options.providerIdMap), + account_id: account.accountId, + email: account.email, + })); + if (oauthProviders.length > 0) { + body.oauth_providers = oauthProviders; + } + + return { + externalUserId: user.externalId, + body, + }; +} + +export function buildStackMigrationPlan(snapshot: ExternalAuthSnapshot, options: StackImportOptions = {}): StackMigrationPlan { + const resolvedOptions: Required = { + providerIdMap: options.providerIdMap ?? new Map(), + unsupportedPasswordHashAction: options.unsupportedPasswordHashAction ?? "error", + }; + + const users = [...snapshot.users] + .sort((a, b) => a.externalId.localeCompare(b.externalId)) + .map((user) => mapUserToStackBody(snapshot.source, user, resolvedOptions)); + + const teams = [...snapshot.organizations] + .sort((a, b) => a.externalId.localeCompare(b.externalId)) + .map((organization) => ({ + externalOrganizationId: organization.externalId, + body: { + display_name: organization.displayName, + ...(organization.profileImageUrl != null ? { profile_image_url: organization.profileImageUrl } : {}), + server_metadata: buildServerMetadata(snapshot.source, "organization_id", organization.externalId, organization.metadata), + }, + })); + + const memberships = [...snapshot.memberships] + .sort((a, b) => a.externalId.localeCompare(b.externalId)) + .map((membership) => ({ + externalMembershipId: membership.externalId, + externalUserId: membership.externalUserId, + externalOrganizationId: membership.externalOrganizationId, + role: membership.role, + metadata: membership.metadata, + })); + + return { users, teams, memberships }; +} diff --git a/packages/migrations/src/index.ts b/packages/migrations/src/index.ts new file mode 100644 index 0000000000..ccf7023bcc --- /dev/null +++ b/packages/migrations/src/index.ts @@ -0,0 +1,19 @@ +export { buildStackMigrationPlan } from "./core"; +export { importPlanToStackAuth } from "./stack-api"; +export type { StackApiConfig, StackImportResult } from "./stack-api"; +export { createBetterAuthStackPersistence } from "./adapters/better-auth"; +export type { BetterAuthPersistenceAdapter, BetterAuthPersistenceRecord, BetterAuthStackPersistence } from "./adapters/better-auth"; +export type { + ExternalAuthSnapshot, + ExternalMembership, + ExternalOAuthAccount, + ExternalOrganization, + ExternalRestriction, + ExternalUser, + JsonObject, + JsonValue, + StackImportOptions, + StackMigrationPlan, + StackTeamCreateBody, + StackUserCreateBody, +} from "./types"; diff --git a/packages/migrations/src/stack-api.ts b/packages/migrations/src/stack-api.ts new file mode 100644 index 0000000000..6455fc1b6e --- /dev/null +++ b/packages/migrations/src/stack-api.ts @@ -0,0 +1,79 @@ +import type { JsonObject, StackMigrationPlan } from "./types"; + +export type StackApiConfig = { + apiUrl: string, + projectId: string, + secretServerKey: string, + publishableClientKey?: string, + branchId?: string, +}; + +export type StackImportResult = { + userIdMap: Map, + teamIdMap: Map, +}; + +async function stackFetch( + config: StackApiConfig, + path: string, + method: "POST", + body: TBody, +): Promise { + const response = await fetch(new URL(path, config.apiUrl), { + method, + headers: { + "content-type": "application/json", + "x-stack-access-type": "server", + "x-stack-project-id": config.projectId, + "x-stack-secret-server-key": config.secretServerKey, + ...(config.publishableClientKey != null ? { "x-stack-publishable-client-key": config.publishableClientKey } : {}), + ...(config.branchId != null ? { "x-stack-branch-id": config.branchId } : {}), + }, + body: JSON.stringify(body), + }); + const text = await response.text(); + const responseBody = text === "" ? null : JSON.parse(text); + if (!response.ok) { + throw new Error(`Stack Auth ${method} ${path} failed with ${response.status}: ${text}`); + } + return responseBody; +} + +function readIdFromStackResponse(value: unknown, path: string): string { + if (typeof value === "object" && value !== null && !Array.isArray(value) && "id" in value && typeof value.id === "string") { + return value.id; + } + throw new Error(`Stack Auth response for ${path} did not include an id`); +} + +function withMembershipMetadata(body: JsonObject): JsonObject { + return body; +} + +export async function importPlanToStackAuth(config: StackApiConfig, plan: StackMigrationPlan): Promise { + const userIdMap = new Map(); + for (const user of plan.users) { + const response = await stackFetch(config, "/api/v1/users", "POST", user.body); + userIdMap.set(user.externalUserId, readIdFromStackResponse(response, "/api/v1/users")); + } + + const teamIdMap = new Map(); + for (const team of plan.teams) { + const response = await stackFetch(config, "/api/v1/teams", "POST", team.body); + teamIdMap.set(team.externalOrganizationId, readIdFromStackResponse(response, "/api/v1/teams")); + } + + for (const membership of plan.memberships) { + const stackUserId = userIdMap.get(membership.externalUserId); + if (stackUserId == null) { + throw new Error(`External membership ${membership.externalMembershipId} references missing user ${membership.externalUserId}`); + } + const stackTeamId = teamIdMap.get(membership.externalOrganizationId); + if (stackTeamId == null) { + throw new Error(`External membership ${membership.externalMembershipId} references missing organization ${membership.externalOrganizationId}`); + } + await stackFetch(config, `/api/v1/team-memberships/${stackTeamId}/${stackUserId}`, "POST", withMembershipMetadata({})); + } + + return { userIdMap, teamIdMap }; +} diff --git a/packages/migrations/src/types.ts b/packages/migrations/src/types.ts new file mode 100644 index 0000000000..7d8ddef9f9 --- /dev/null +++ b/packages/migrations/src/types.ts @@ -0,0 +1,94 @@ +export type JsonObject = { [key: string]: JsonValue }; +export type JsonValue = JsonObject | JsonValue[] | string | number | boolean | null; + +export type ExternalUser = { + externalId: string, + primaryEmail: string | null, + primaryEmailVerified: boolean, + displayName: string | null, + profileImageUrl: string | null, + passwordHash: string | null, + oauthAccounts: ExternalOAuthAccount[], + restricted: ExternalRestriction | null, + metadata: JsonObject, +}; + +export type ExternalOAuthAccount = { + providerId: string, + accountId: string, + email: string | null, +}; + +export type ExternalRestriction = { + reason: string, + privateDetails: string | null, +}; + +export type ExternalOrganization = { + externalId: string, + displayName: string, + profileImageUrl: string | null, + metadata: JsonObject, +}; + +export type ExternalMembership = { + externalId: string, + externalUserId: string, + externalOrganizationId: string, + role: string | null, + metadata: JsonObject, +}; + +export type ExternalAuthSnapshot = { + source: string, + users: ExternalUser[], + organizations: ExternalOrganization[], + memberships: ExternalMembership[], +}; + +export type StackUserCreateBody = { + primary_email?: string, + primary_email_verified?: boolean, + primary_email_auth_enabled?: boolean, + display_name?: string | null, + profile_image_url?: string | null, + password_hash?: string, + oauth_providers?: { + id: string, + account_id: string, + email: string | null, + }[], + restricted_by_admin?: boolean, + restricted_by_admin_reason?: string | null, + restricted_by_admin_private_details?: string | null, + server_metadata: JsonObject, +}; + +export type StackTeamCreateBody = { + display_name: string, + profile_image_url?: string | null, + server_metadata: JsonObject, +}; + +export type StackMigrationPlan = { + users: { + externalUserId: string, + body: StackUserCreateBody, + }[], + teams: { + externalOrganizationId: string, + body: StackTeamCreateBody, + }[], + memberships: { + externalMembershipId: string, + externalUserId: string, + externalOrganizationId: string, + role: string | null, + metadata: JsonObject, + }[], +}; + +export type StackImportOptions = { + providerIdMap?: Map, + unsupportedPasswordHashAction?: "error" | "omit", +}; diff --git a/packages/migrations/tsconfig.json b/packages/migrations/tsconfig.json new file mode 100644 index 0000000000..6f8e344ec3 --- /dev/null +++ b/packages/migrations/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "target": "ES2021", + "lib": ["ES2021", "ES2022.Error", "DOM"], + "module": "ES2020", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "esModuleInterop": true, + "noErrorTruncation": true, + "skipLibCheck": true, + "strict": true, + "sourceMap": true, + "declarationMap": true, + "types": [ + "vitest/importMeta" + ] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/migrations/tsdown.config.ts b/packages/migrations/tsdown.config.ts new file mode 100644 index 0000000000..d112d04a09 --- /dev/null +++ b/packages/migrations/tsdown.config.ts @@ -0,0 +1,3 @@ +import createJsLibraryTsupConfig from '../../configs/tsdown/js-library.ts'; + +export default createJsLibraryTsupConfig({}); diff --git a/packages/stack-shared/src/apps/apps-config.ts b/packages/stack-shared/src/apps/apps-config.ts index 92a5cb9726..2213191a25 100644 --- a/packages/stack-shared/src/apps/apps-config.ts +++ b/packages/stack-shared/src/apps/apps-config.ts @@ -84,6 +84,12 @@ export const ALL_APPS = { tags: ["auth", "security", "developers"], stage: "stable", }, + "migrations": { + displayName: "Migrations", + subtitle: "Import users and teams from other auth providers", + tags: ["auth", "operations"], + stage: "alpha", + }, "payments": { displayName: "Payments", subtitle: "Payment processing and subscription management",