Add Better Auth to Stack Auth migration package

This commit is contained in:
mantrakp04 2026-05-01 15:42:59 -07:00
parent d2f2fb0e42
commit f29ce377b1
18 changed files with 1204 additions and 4 deletions

View File

@ -217,10 +217,13 @@ 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. A public `@stackframe/migrations` package should expose Better Auth-shaped persistence (`user`, `account`, `organization`, `member` writes) that captures normalized records and then flushes them to Stack Auth via server REST APIs. This lets paths like WorkOS -> Better Auth format -> Stack Auth reuse Better Auth's provider-specific migration logic while keeping Stack Auth as the persistence target.
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.

View File

@ -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"
]
},
{

View File

@ -36,7 +36,7 @@ sidebarTitle: FAQ
</Accordion>
<Accordion title="Can I migrate my existing userbase to Stack Auth?">
Yes! You can [create users programmatically](/api/server/users/create-user) using our [REST API](/api/overview).
Yes! For provider migrations, use [`@stackframe/migrations`](/guides/going-further/migrations). You can also [create users programmatically](/api/server/users/create-user) using our [REST API](/api/overview).
</Accordion>
</AccordionGroup>

View File

@ -0,0 +1,99 @@
---
title: Migrations
description: Migrate users, organizations, passwords, and OAuth accounts from other auth providers to Stack Auth.
---
# Migrations
Stack Auth provides `@stackframe/migrations` for moving auth data from other providers into Stack Auth.
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, you can let Better Auth do the provider-specific transformation and use Stack Auth as the persistence target.
```bash
pnpm add @stackframe/migrations
```
## 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, create Stack Auth migration persistence and write to its adapter.
```ts
import { createBetterAuthStackPersistence } from "@stackframe/migrations";
const persistence = createBetterAuthStackPersistence();
// Example write shape. In a real migration, these calls come from
// the source-provider migration code after it normalizes the data.
await persistence.adapter.create({
model: "user",
data: {
id: "workos-user-id",
email: "user@example.com",
emailVerified: true,
name: "Ada Lovelace",
},
});
await persistence.adapter.create({
model: "account",
data: {
id: "credential-account-id",
userId: "workos-user-id",
accountId: "workos-user-id",
providerId: "credential",
password: "$2a$10$...",
},
});
await persistence.flushToStackAuth({
apiUrl: "https://api.stack-auth.com",
projectId: process.env.STACK_PROJECT_ID!,
secretServerKey: process.env.STACK_SECRET_SERVER_KEY!,
});
```
## 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.
## 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 want to import users without unsupported password hashes, pass:
```ts
persistence.buildPlan({ unsupportedPasswordHashAction: "omit" });
```
or:
```ts
await persistence.flushToStackAuth(stackConfig, {
unsupportedPasswordHashAction: "omit",
});
```
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, pass a provider id map:
```ts
await persistence.flushToStackAuth(stackConfig, {
providerIdMap: new Map([
["oauth_github", "github"],
]),
});
```
Configure the matching OAuth providers in Stack Auth before importing users.

View File

@ -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 `<X>`.
</Accordion>
<Accordion title="Can I migrate my existing userbase to Stack Auth?">
Yes! You can [create users programmatically](/rest-api/server/users/create-user) using our [REST API](/rest-api).
Yes! For provider migrations, use `@stackframe/migrations`. You can also [create users programmatically](/rest-api/server/users/create-user) using our [REST API](/rest-api).
</Accordion>
</AccordionGroup>

View File

@ -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.

View File

@ -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",
},
},
];

View File

@ -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"
}

View File

@ -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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function clerkFetch(path: string, options: RequestInit = {}): Promise<unknown> {
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<string, unknown> {
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
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<string, unknown>): { 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<Record<string, unknown>> {
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<Record<string, unknown>[]> {
const users: Record<string, unknown>[] = [];
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<void> {
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();

View File

@ -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);
});
});

View File

@ -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<BetterAuthPersistenceRecord>,
findOne(input: BetterAuthFindOneInput): Promise<BetterAuthPersistenceRecord | null>,
findMany(input: BetterAuthFindManyInput): Promise<BetterAuthPersistenceRecord[]>,
update(input: BetterAuthUpdateInput): Promise<BetterAuthPersistenceRecord | null>,
updateMany(input: BetterAuthUpdateInput): Promise<number>,
delete(input: BetterAuthFindOneInput): Promise<void>,
deleteMany(input: BetterAuthFindOneInput): Promise<number>,
count(input: BetterAuthFindOneInput): Promise<number>,
};
export type BetterAuthStackPersistence = {
adapter: BetterAuthPersistenceAdapter,
snapshot(): ExternalAuthSnapshot,
buildPlan(options?: StackImportOptions): StackMigrationPlan,
flushToStackAuth(config: StackApiConfig, options?: StackImportOptions): Promise<StackImportResult>,
};
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<string, BetterAuthPersistenceRecord>, 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<string, Map<string, BetterAuthPersistenceRecord>>();
function getTable(model: string): Map<string, BetterAuthPersistenceRecord> {
const existing = tables.get(model);
if (existing) {
return existing;
}
const created = new Map<string, BetterAuthPersistenceRecord>();
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));
},
};
}

View File

@ -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();

View File

@ -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, string>): 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<StackImportOptions>): 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<StackImportOptions> = {
providerIdMap: options.providerIdMap ?? new Map<string, string>(),
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 };
}

View File

@ -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";

View File

@ -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<string, string>,
teamIdMap: Map<string, string>,
};
async function stackFetch<TBody>(
config: StackApiConfig,
path: string,
method: "POST",
body: TBody,
): Promise<unknown> {
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<StackImportResult> {
const userIdMap = new Map<string, string>();
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<string, string>();
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 };
}

View File

@ -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<string, string>,
unsupportedPasswordHashAction?: "error" | "omit",
};

View File

@ -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"]
}

View File

@ -0,0 +1,3 @@
import createJsLibraryTsupConfig from '../../configs/tsdown/js-library.ts';
export default createJsLibraryTsupConfig({});