mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Add Better Auth to Stack Auth migration package
This commit is contained in:
parent
d2f2fb0e42
commit
f29ce377b1
@ -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.
|
||||
|
||||
|
||||
@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
99
docs-mintlify/guides/going-further/migrations.mdx
Normal file
99
docs-mintlify/guides/going-further/migrations.mdx
Normal 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.
|
||||
@ -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>
|
||||
|
||||
|
||||
33
packages/migrations/README.md
Normal file
33
packages/migrations/README.md
Normal 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.
|
||||
29
packages/migrations/eslint.config.mjs
Normal file
29
packages/migrations/eslint.config.mjs
Normal 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",
|
||||
},
|
||||
},
|
||||
];
|
||||
48
packages/migrations/package.json
Normal file
48
packages/migrations/package.json
Normal 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"
|
||||
}
|
||||
189
packages/migrations/scripts/test-clerk-to-stack.ts
Normal file
189
packages/migrations/scripts/test-clerk-to-stack.ts
Normal 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();
|
||||
176
packages/migrations/src/adapters/better-auth.test.ts
Normal file
176
packages/migrations/src/adapters/better-auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
288
packages/migrations/src/adapters/better-auth.ts
Normal file
288
packages/migrations/src/adapters/better-auth.ts
Normal 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));
|
||||
},
|
||||
};
|
||||
}
|
||||
17
packages/migrations/src/cli.ts
Normal file
17
packages/migrations/src/cli.ts
Normal 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();
|
||||
99
packages/migrations/src/core.ts
Normal file
99
packages/migrations/src/core.ts
Normal 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 };
|
||||
}
|
||||
19
packages/migrations/src/index.ts
Normal file
19
packages/migrations/src/index.ts
Normal 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";
|
||||
79
packages/migrations/src/stack-api.ts
Normal file
79
packages/migrations/src/stack-api.ts
Normal 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 };
|
||||
}
|
||||
94
packages/migrations/src/types.ts
Normal file
94
packages/migrations/src/types.ts
Normal 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",
|
||||
};
|
||||
23
packages/migrations/tsconfig.json
Normal file
23
packages/migrations/tsconfig.json
Normal 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"]
|
||||
}
|
||||
3
packages/migrations/tsdown.config.ts
Normal file
3
packages/migrations/tsdown.config.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import createJsLibraryTsupConfig from '../../configs/tsdown/js-library.ts';
|
||||
|
||||
export default createJsLibraryTsupConfig({});
|
||||
Loading…
Reference in New Issue
Block a user