Graphical PDS migrator for AT Protocol

allow disabling migration

+2 -1
.env.example
···
# generate with `openssl ecparam --name secp256k1 --genkey --noout --outform DER | tail --bytes=+8 | head --bytes=32 | xxd --plain --cols 32`
-
COOKIE_SECRET=my_secret
+
COOKIE_SECRET=my_secret
+
MIGRATION_STATE=up
+74 -18
islands/MigrationProgress.tsx
···
import { useEffect, useState } from "preact/hooks";
/**
+
* The migration state info.
+
* @type {MigrationStateInfo}
+
*/
+
interface MigrationStateInfo {
+
state: "up" | "issue" | "maintenance";
+
message: string;
+
allowMigration: boolean;
+
}
+
+
/**
* The migration progress props.
* @type {MigrationProgressProps}
*/
···
*/
export default function MigrationProgress(props: MigrationProgressProps) {
const [token, setToken] = useState("");
+
const [migrationState, setMigrationState] = useState<MigrationStateInfo | null>(null);
const [steps, setSteps] = useState<MigrationStep[]>([
{ name: "Create Account", status: "pending" },
···
invite: props.invite,
});
-
if (!validateParams()) {
-
console.log("Parameter validation failed");
-
return;
-
}
+
// Check migration state first
+
const checkMigrationState = async () => {
+
try {
+
const migrationResponse = await fetch("/api/migration-state");
+
if (migrationResponse.ok) {
+
const migrationData = await migrationResponse.json();
+
setMigrationState(migrationData);
-
startMigration().catch((error) => {
-
console.error("Unhandled migration error:", error);
-
updateStepStatus(
-
0,
-
"error",
-
error instanceof Error ? error.message : String(error),
-
);
-
});
+
if (!migrationData.allowMigration) {
+
updateStepStatus(0, "error", migrationData.message);
+
return;
+
}
+
}
+
} catch (error) {
+
console.error("Failed to check migration state:", error);
+
updateStepStatus(0, "error", "Unable to verify migration availability");
+
return;
+
}
+
+
if (!validateParams()) {
+
console.log("Parameter validation failed");
+
return;
+
}
+
+
startMigration().catch((error) => {
+
console.error("Unhandled migration error:", error);
+
updateStepStatus(
+
0,
+
"error",
+
error.message || "Unknown error occurred",
+
);
+
});
+
};
+
+
checkMigrationState();
}, []);
const getStepDisplayName = (step: MigrationStep, index: number) => {
···
case 3: return "Migration Finalized";
}
}
-
+
if (step.status === "in-progress") {
switch (index) {
case 0: return "Creating your new account...";
case 1: return "Migrating your data...";
-
case 2: return step.name === "Enter the token sent to your email to complete identity migration"
-
? step.name
+
case 2: return step.name === "Enter the token sent to your email to complete identity migration"
+
? step.name
: "Migrating your identity...";
case 3: return "Finalizing migration...";
}
···
case 3: return "Verifying migration completion...";
}
}
-
+
return step.name;
};
···
);
}
console.log("Identity migration requested successfully");
-
+
// Update step name to prompt for token
setSteps(prevSteps =>
prevSteps.map((step, i) =>
···
console.log(`Verification: Status response status:`, res.status);
const data = await res.json();
console.log(`Verification: Status data for step ${stepNum + 1}:`, data);
-
+
if (data.ready) {
console.log(`Verification: Step ${stepNum + 1} is ready`);
updateStepStatus(stepNum, "completed");
···
return (
<div class="space-y-8">
+
{/* Migration state alert */}
+
{migrationState && !migrationState.allowMigration && (
+
<div class={`p-4 rounded-lg border ${
+
migrationState.state === "maintenance"
+
? "bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-200"
+
: "bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200"
+
}`}>
+
<div class="flex items-center">
+
<div class={`mr-3 ${
+
migrationState.state === "maintenance" ? "text-yellow-600 dark:text-yellow-400" : "text-red-600 dark:text-red-400"
+
}`}>
+
{migrationState.state === "maintenance" ? "⚠️" : "🚫"}
+
</div>
+
<div>
+
<h3 class="font-semibold mb-1">
+
{migrationState.state === "maintenance" ? "Maintenance Mode" : "Service Unavailable"}
+
</h3>
+
<p class="text-sm">{migrationState.message}</p>
+
</div>
+
</div>
+
</div>
+
)}
+
<div class="space-y-4">
{steps.map((step, index) => (
<div key={step.name} class={getStepClasses(step.status)}>
+59 -5
islands/MigrationSetup.tsx
···
}
/**
+
* The migration state info.
+
* @type {MigrationStateInfo}
+
*/
+
interface MigrationStateInfo {
+
state: "up" | "issue" | "maintenance";
+
message: string;
+
allowMigration: boolean;
+
}
+
+
/**
* The migration setup component.
* @param props - The migration setup props
* @returns The migration setup component
···
const [showConfirmation, setShowConfirmation] = useState(false);
const [confirmationText, setConfirmationText] = useState("");
const [passport, setPassport] = useState<UserPassport | null>(null);
+
const [migrationState, setMigrationState] = useState<MigrationStateInfo | null>(null);
const ensureServiceUrl = (url: string): string => {
if (!url) return url;
···
useEffect(() => {
if (!IS_BROWSER) return;
-
const fetchPassport = async () => {
+
const fetchInitialData = async () => {
try {
+
// Check migration state first
+
const migrationResponse = await fetch("/api/migration-state");
+
if (migrationResponse.ok) {
+
const migrationData = await migrationResponse.json();
+
setMigrationState(migrationData);
+
}
+
+
// Fetch user passport
const response = await fetch("/api/me", {
credentials: "include",
});
···
});
}
} catch (error) {
-
console.error("Failed to fetch passport:", error);
+
console.error("Failed to fetch initial data:", error);
}
};
-
fetchPassport();
+
fetchInitialData();
}, []);
const checkServerDescription = async (serviceUrl: string) => {
···
const handleSubmit = (e: Event) => {
e.preventDefault();
+
// Check migration state first
+
if (migrationState && !migrationState.allowMigration) {
+
setError(migrationState.message);
+
return;
+
}
+
if (!service || !handlePrefix || !email || !password) {
setError("Please fill in all required fields");
return;
···
};
const handleConfirmation = () => {
+
// Double-check migration state before proceeding
+
if (migrationState && !migrationState.allowMigration) {
+
setError(migrationState.message);
+
return;
+
}
+
if (confirmationText !== "MIGRATE") {
setError("Please type 'MIGRATE' to confirm");
return;
···
<div class="absolute top-2 left-4 text-blue-500 text-sm font-mono">TERMINAL 1</div>
<div class="absolute top-2 right-4 text-blue-500 text-sm font-mono">GATE M1</div>
+
{/* Migration state alert */}
+
{migrationState && !migrationState.allowMigration && (
+
<div class={`mb-6 mt-4 p-4 rounded-lg border ${
+
migrationState.state === "maintenance"
+
? "bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-200"
+
: "bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200"
+
}`}>
+
<div class="flex items-center">
+
<div class={`mr-3 ${
+
migrationState.state === "maintenance" ? "text-yellow-600 dark:text-yellow-400" : "text-red-600 dark:text-red-400"
+
}`}>
+
{migrationState.state === "maintenance" ? "⚠️" : "🚫"}
+
</div>
+
<div>
+
<h3 class="font-semibold mb-1">
+
{migrationState.state === "maintenance" ? "Maintenance Mode" : "Service Unavailable"}
+
</h3>
+
<p class="text-sm">{migrationState.message}</p>
+
</div>
+
</div>
+
</div>
+
)}
+
<div class="text-center mb-8 relative">
<p class="text-gray-600 dark:text-gray-400 mt-4">Please complete your migration check-in</p>
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400 font-mono">FLIGHT: MIG-2024</div>
···
<form onSubmit={handleSubmit} class="space-y-6">
{error && (
-
<div class="bg-red-50 dark:bg-red-900 rounded-lg">
+
<div class="bg-red-50 dark:bg-red-900 rounded-lg ">
<p class="text-red-800 dark:text-red-200 flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
···
<button
type="submit"
-
disabled={isLoading}
+
disabled={isLoading || Boolean(migrationState && !migrationState.allowMigration)}
class="w-full flex justify-center items-center py-3 px-4 rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+70
lib/migration-state.ts
···
+
/**
+
* Migration state types and utilities for controlling migration availability.
+
*/
+
+
export type MigrationState = "up" | "issue" | "maintenance";
+
+
export interface MigrationStateInfo {
+
state: MigrationState;
+
message: string;
+
allowMigration: boolean;
+
}
+
+
/**
+
* Get the current migration state from environment variables.
+
* @returns The migration state information
+
*/
+
export function getMigrationState(): MigrationStateInfo {
+
const state = (Deno.env.get("MIGRATION_STATE") || "up").toLowerCase() as MigrationState;
+
+
switch (state) {
+
case "issue":
+
return {
+
state: "issue",
+
message: "Migration services are temporarily unavailable as we investigate an issue. Please try again later.",
+
allowMigration: false,
+
};
+
+
case "maintenance":
+
return {
+
state: "maintenance",
+
message: "Migration services are temporarily unavailable for scheduled maintenance. Please try again later.",
+
allowMigration: false,
+
};
+
+
case "up":
+
default:
+
return {
+
state: "up",
+
message: "Migration services are operational.",
+
allowMigration: true,
+
};
+
}
+
}
+
+
/**
+
* Check if migrations are currently allowed.
+
* @returns True if migrations are allowed, false otherwise
+
*/
+
export function isMigrationAllowed(): boolean {
+
return getMigrationState().allowMigration;
+
}
+
+
/**
+
* Get a user-friendly message for the current migration state.
+
* @returns The message to display to users
+
*/
+
export function getMigrationStateMessage(): string {
+
return getMigrationState().message;
+
}
+
+
/**
+
* Throw an error if migrations are not allowed.
+
* Used in API endpoints to prevent migration operations when disabled.
+
*/
+
export function assertMigrationAllowed(): void {
+
const stateInfo = getMigrationState();
+
if (!stateInfo.allowMigration) {
+
throw new Error(stateInfo.message);
+
}
+
}
+4
routes/api/migrate/create.ts
···
import { setCredentialSession } from "../../../lib/cred/sessions.ts";
import { Agent } from "@atproto/api";
import { define } from "../../../utils.ts";
+
import { assertMigrationAllowed } from "../../../lib/migration-state.ts";
/**
* Handle account creation
···
async POST(ctx) {
const res = new Response();
try {
+
// Check if migrations are currently allowed
+
assertMigrationAllowed();
+
const body = await ctx.req.json();
const serviceUrl = body.service;
const newHandle = body.handle;
+9 -5
routes/api/migrate/data/blobs.ts
···
import { getSessionAgent } from "../../../../lib/sessions.ts";
import { define } from "../../../../utils.ts";
+
import { assertMigrationAllowed } from "../../../../lib/migration-state.ts";
export const handler = define.handlers({
async POST(ctx) {
const res = new Response();
try {
+
// Check if migrations are currently allowed
+
assertMigrationAllowed();
+
console.log("Blob migration: Starting session retrieval");
const oldAgent = await getSessionAgent(ctx.req);
console.log("Blob migration: Got old agent:", !!oldAgent);
···
const session = await oldAgent.com.atproto.server.getSession();
const accountDid = session.data.did;
-
+
do {
const pageStartTime = Date.now();
console.log(`[${new Date().toISOString()}] Counting blobs on page ${pageCount + 1}...`);
···
const newBlobs = listedBlobs.data.cids.length;
totalBlobs += newBlobs;
const pageTime = Date.now() - pageStartTime;
-
+
console.log(`[${new Date().toISOString()}] Found ${newBlobs} blobs on page ${pageCount + 1} in ${pageTime/1000} seconds, total so far: ${totalBlobs}`);
migrationLogs.push(`[${new Date().toISOString()}] Found ${newBlobs} blobs on page ${pageCount + 1} in ${pageTime/1000} seconds, total so far: ${totalBlobs}`);
-
+
pageCount++;
blobCursor = listedBlobs.data.cursor;
} while (blobCursor);
···
return new Response(
JSON.stringify({
success: true,
-
message: failedBlobs.length > 0
+
message: failedBlobs.length > 0
? `Blob migration completed with ${failedBlobs.length} failed blobs`
: "Blob migration completed successfully",
migratedBlobs,
···
);
}
}
-
});
+
});
+7 -3
routes/api/migrate/data/prefs.ts
···
import { getSessionAgent } from "../../../../lib/sessions.ts";
import { define } from "../../../../utils.ts";
+
import { assertMigrationAllowed } from "../../../../lib/migration-state.ts";
export const handler = define.handlers({
async POST(ctx) {
const res = new Response();
try {
+
// Check if migrations are currently allowed
+
assertMigrationAllowed();
+
console.log("Preferences migration: Starting session retrieval");
const oldAgent = await getSessionAgent(ctx.req);
console.log("Preferences migration: Got old agent:", !!oldAgent);
···
// Fetch preferences
console.log(`[${new Date().toISOString()}] Fetching preferences from old account...`);
migrationLogs.push(`[${new Date().toISOString()}] Fetching preferences from old account...`);
-
+
const fetchStartTime = Date.now();
const prefs = await oldAgent.app.bsky.actor.getPreferences();
const fetchTime = Date.now() - fetchStartTime;
-
+
console.log(`[${new Date().toISOString()}] Preferences fetched in ${fetchTime/1000} seconds`);
migrationLogs.push(`[${new Date().toISOString()}] Preferences fetched in ${fetchTime/1000} seconds`);
···
);
}
}
-
});
+
});
+7 -3
routes/api/migrate/data/repo.ts
···
import { getSessionAgent } from "../../../../lib/sessions.ts";
import { define } from "../../../../utils.ts";
+
import { assertMigrationAllowed } from "../../../../lib/migration-state.ts";
export const handler = define.handlers({
async POST(ctx) {
const res = new Response();
try {
+
// Check if migrations are currently allowed
+
assertMigrationAllowed();
+
console.log("Repo migration: Starting session retrieval");
const oldAgent = await getSessionAgent(ctx.req);
console.log("Repo migration: Got old agent:", !!oldAgent);
···
// Get repo data from old account
console.log(`[${new Date().toISOString()}] Fetching repo data from old account...`);
migrationLogs.push(`[${new Date().toISOString()}] Fetching repo data from old account...`);
-
+
const fetchStartTime = Date.now();
const repoData = await oldAgent.com.atproto.sync.getRepo({
did: accountDid,
});
const fetchTime = Date.now() - fetchStartTime;
-
+
console.log(`[${new Date().toISOString()}] Repo data fetched in ${fetchTime/1000} seconds`);
migrationLogs.push(`[${new Date().toISOString()}] Repo data fetched in ${fetchTime/1000} seconds`);
···
);
}
}
-
});
+
});
+4
routes/api/migrate/finalize.ts
···
import { getSessionAgent } from "../../../lib/sessions.ts";
import { define } from "../../../utils.ts";
+
import { assertMigrationAllowed } from "../../../lib/migration-state.ts";
export const handler = define.handlers({
async POST(ctx) {
const res = new Response();
try {
+
// Check if migrations are currently allowed
+
assertMigrationAllowed();
+
const oldAgent = await getSessionAgent(ctx.req);
const newAgent = await getSessionAgent(ctx.req, res, true);
+4
routes/api/migrate/identity/request.ts
···
getSessionAgent,
} from "../../../../lib/sessions.ts";
import { define } from "../../../../utils.ts";
+
import { assertMigrationAllowed } from "../../../../lib/migration-state.ts";
/**
* Handle identity migration request
···
async POST(ctx) {
const res = new Response();
try {
+
// Check if migrations are currently allowed
+
assertMigrationAllowed();
+
console.log("Starting identity migration request...");
const oldAgent = await getSessionAgent(ctx.req);
console.log("Got old agent:", {
+3
routes/api/migrate/identity/sign.ts
···
import { Secp256k1Keypair } from "npm:@atproto/crypto";
import * as ui8 from "npm:uint8arrays";
import { define } from "../../../../utils.ts";
+
import { assertMigrationAllowed } from "../../../../lib/migration-state.ts";
/**
* Handle identity migration sign
···
async POST(ctx) {
const res = new Response();
try {
+
// Check if migrations are currently allowed
+
assertMigrationAllowed();
const url = new URL(ctx.req.url);
const token = url.searchParams.get("token");
+44
routes/api/migration-state.ts
···
+
import { getMigrationState } from "../../lib/migration-state.ts";
+
import { define } from "../../utils.ts";
+
+
/**
+
* API endpoint to check the current migration state.
+
* Returns the migration state information including whether migrations are allowed.
+
*/
+
export const handler = define.handlers({
+
GET(_ctx) {
+
try {
+
const stateInfo = getMigrationState();
+
+
return new Response(
+
JSON.stringify({
+
state: stateInfo.state,
+
message: stateInfo.message,
+
allowMigration: stateInfo.allowMigration,
+
}),
+
{
+
status: 200,
+
headers: {
+
"Content-Type": "application/json",
+
},
+
}
+
);
+
} catch (error) {
+
console.error("Error checking migration state:", error);
+
+
return new Response(
+
JSON.stringify({
+
state: "issue",
+
message: "Unable to determine migration state. Please try again later.",
+
allowMigration: false,
+
}),
+
{
+
status: 500,
+
headers: {
+
"Content-Type": "application/json",
+
},
+
}
+
);
+
}
+
},
+
});