Graphical PDS migrator for AT Protocol

Compare changes

Choose any two refs to compare.

.DS_Store

This is a binary file and will not be displayed.

+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
+2
.gitignore
···
.env.production.local
.env.local
+
.DS_Store
+
# Fresh build directory
_fresh/
# npm dependencies
+35 -9
.zed/settings.json
···
+
// Folder-specific settings
+
//
+
// For a full list of overridable settings, and general information on folder-specific settings,
+
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
{
+
"lsp": {
+
"deno": {
+
"settings": {
+
"deno": {
+
"enable": true,
+
"cacheOnSave": true,
+
"suggest": {
+
"imports": {
+
"autoDiscover": true
+
}
+
}
+
}
+
}
+
}
+
},
"languages": {
+
"JavaScript": {
+
"language_servers": [
+
"deno",
+
"!vtsls",
+
"!eslint",
+
"..."
+
]
+
},
"TypeScript": {
"language_servers": [
-
"wakatime",
"deno",
"!typescript-language-server",
"!vtsls",
-
"!eslint"
-
],
-
"formatter": "language_server"
+
"!eslint",
+
"..."
+
]
},
"TSX": {
"language_servers": [
-
"wakatime",
"deno",
"!typescript-language-server",
"!vtsls",
-
"!eslint"
-
],
-
"formatter": "language_server"
+
"!eslint",
+
"..."
+
]
}
-
}
+
},
+
"formatter": "language_server"
}
+6 -15
README.md
···
Airport is a web application built with Fresh and Deno that helps users safely migrate and backup their Bluesky PDS data. It provides a user-friendly interface for managing your AT Protocol data.
-
โš ๏ธ **Alpha Status**: Airport is currently in alpha. Please use migration tools at your own risk and avoid using with main accounts during this phase.
-
## Features
- PDS migration between servers
···
- User-friendly interface
- Coming soon: PLC Key retrieval, data backup
-
## Technology Stack
+
## Tech Stack
-
- [Fresh](https://fresh.deno.dev/) - The next-gen web framework
-
- [Deno](https://deno.com/) - A modern runtime for JavaScript and TypeScript
-
- [Tailwind CSS](https://tailwindcss.com/) - For styling
-
- AT Protocol Integration
+
- [Fresh](https://fresh.deno.dev/) - Web Framework
+
- [Deno](https://deno.com/) - Runtime
+
- [Tailwind](https://tailwindcss.com/) - Styling
-
## Getting Started
+
## Development
-
### Prerequisites
-
-
Make sure to install Deno:
+
Make sure you have Deno installed:
https://docs.deno.com/runtime/getting_started/installation
-
-
### Development
Start the project in development mode:
```shell
deno task dev
```
-
-
This will watch the project directory and restart as necessary.
## About
+35
islands/LoginButton.tsx
···
+
import { useEffect, useState } from "preact/hooks";
+
import { Button } from "../components/Button.tsx";
+
+
export default function LoginButton() {
+
const [isMobile, setIsMobile] = useState(true); // Default to mobile for SSR
+
+
useEffect(() => {
+
const checkMobile = () => {
+
setIsMobile(globalThis.innerWidth < 640);
+
};
+
+
// Check on mount
+
checkMobile();
+
+
// Listen for resize events
+
globalThis.addEventListener('resize', checkMobile);
+
return () => globalThis.removeEventListener('resize', checkMobile);
+
}, []);
+
+
return (
+
<div class="mt-6 sm:mt-8 text-center w-fit mx-auto">
+
<Button
+
href={isMobile ? undefined : "/login"}
+
color="blue"
+
label={isMobile ? "MOBILE NOT SUPPORTED" : "GET STARTED"}
+
className={isMobile ? "opacity-50 cursor-not-allowed" : "opacity-100 cursor-pointer"}
+
onClick={(e: MouseEvent) => {
+
if (isMobile) {
+
e.preventDefault();
+
}
+
}}
+
/>
+
</div>
+
);
+
}
+536 -264
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}
*/
···
name: string;
status: "pending" | "in-progress" | "verifying" | "completed" | "error";
error?: string;
+
isVerificationError?: boolean;
}
/**
···
*/
export default function MigrationProgress(props: MigrationProgressProps) {
const [token, setToken] = useState("");
+
const [migrationState, setMigrationState] = useState<
+
MigrationStateInfo | null
+
>(null);
+
const [retryAttempts, setRetryAttempts] = useState<Record<number, number>>(
+
{},
+
);
+
const [showContinueAnyway, setShowContinueAnyway] = useState<
+
Record<number, boolean>
+
>({});
const [steps, setSteps] = useState<MigrationStep[]>([
{ name: "Create Account", status: "pending" },
···
index: number,
status: MigrationStep["status"],
error?: string,
+
isVerificationError?: boolean,
) => {
console.log(
`Updating step ${index} to ${status}${
···
setSteps((prevSteps) =>
prevSteps.map((step, i) =>
i === index
-
? { ...step, status, error }
+
? { ...step, status, error, isVerificationError }
: i > index
-
? { ...step, status: "pending", error: undefined }
+
? {
+
...step,
+
status: "pending",
+
error: undefined,
+
isVerificationError: undefined,
+
}
: step
)
);
···
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) => {
if (step.status === "completed") {
switch (index) {
-
case 0: return "Account Created";
-
case 1: return "Data Migrated";
-
case 2: return "Identity Migrated";
-
case 3: return "Migration Finalized";
+
case 0:
+
return "Account Created";
+
case 1:
+
return "Data Migrated";
+
case 2:
+
return "Identity Migrated";
+
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
-
: "Migrating your identity...";
-
case 3: return "Finalizing migration...";
+
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
+
: "Migrating your identity...";
+
case 3:
+
return "Finalizing migration...";
}
}
if (step.status === "verifying") {
switch (index) {
-
case 0: return "Verifying account creation...";
-
case 1: return "Verifying data migration...";
-
case 2: return "Verifying identity migration...";
-
case 3: return "Verifying migration completion...";
+
case 0:
+
return "Verifying account creation...";
+
case 1:
+
return "Verifying data migration...";
+
case 2:
+
return "Verifying identity migration...";
+
case 3:
+
return "Verifying migration completion...";
}
}
-
+
return step.name;
};
···
updateStepStatus(0, "verifying");
const verified = await verifyStep(0);
if (!verified) {
-
throw new Error("Account creation verification failed");
-
}
-
} catch (error) {
-
updateStepStatus(
-
0,
-
"error",
-
error instanceof Error ? error.message : String(error),
-
);
-
throw error;
-
}
-
-
// Step 2: Migrate Data
-
updateStepStatus(1, "in-progress");
-
console.log("Starting data migration...");
-
-
try {
-
// Step 2.1: Migrate Repo
-
console.log("Data migration: Starting repo migration");
-
const repoRes = await fetch("/api/migrate/data/repo", {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
});
-
-
console.log("Repo migration: Response status:", repoRes.status);
-
const repoText = await repoRes.text();
-
console.log("Repo migration: Raw response:", repoText);
-
-
if (!repoRes.ok) {
-
try {
-
const json = JSON.parse(repoText);
-
console.error("Repo migration: Error response:", json);
-
throw new Error(json.message || "Failed to migrate repo");
-
} catch {
-
console.error("Repo migration: Non-JSON error response:", repoText);
-
throw new Error(repoText || "Failed to migrate repo");
-
}
-
}
-
-
// Step 2.2: Migrate Blobs
-
console.log("Data migration: Starting blob migration");
-
const blobsRes = await fetch("/api/migrate/data/blobs", {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
});
-
-
console.log("Blob migration: Response status:", blobsRes.status);
-
const blobsText = await blobsRes.text();
-
console.log("Blob migration: Raw response:", blobsText);
-
-
if (!blobsRes.ok) {
-
try {
-
const json = JSON.parse(blobsText);
-
console.error("Blob migration: Error response:", json);
-
throw new Error(json.message || "Failed to migrate blobs");
-
} catch {
-
console.error("Blob migration: Non-JSON error response:", blobsText);
-
throw new Error(blobsText || "Failed to migrate blobs");
-
}
-
}
-
-
// Step 2.3: Migrate Preferences
-
console.log("Data migration: Starting preferences migration");
-
const prefsRes = await fetch("/api/migrate/data/prefs", {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
});
-
-
console.log("Preferences migration: Response status:", prefsRes.status);
-
const prefsText = await prefsRes.text();
-
console.log("Preferences migration: Raw response:", prefsText);
-
-
if (!prefsRes.ok) {
-
try {
-
const json = JSON.parse(prefsText);
-
console.error("Preferences migration: Error response:", json);
-
throw new Error(json.message || "Failed to migrate preferences");
-
} catch {
-
console.error("Preferences migration: Non-JSON error response:", prefsText);
-
throw new Error(prefsText || "Failed to migrate preferences");
-
}
-
}
-
-
console.log("Data migration: Starting verification");
-
updateStepStatus(1, "verifying");
-
const verified = await verifyStep(1);
-
console.log("Data migration: Verification result:", verified);
-
if (!verified) {
-
throw new Error("Data migration verification failed");
-
}
-
} catch (error) {
-
console.error("Data migration: Error caught:", error);
-
updateStepStatus(
-
1,
-
"error",
-
error instanceof Error ? error.message : String(error),
-
);
-
throw error;
-
}
-
-
// Step 3: Request Identity Migration
-
updateStepStatus(2, "in-progress");
-
console.log("Requesting identity migration...");
-
-
try {
-
const requestRes = await fetch("/api/migrate/identity/request", {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
});
-
-
console.log("Identity request response status:", requestRes.status);
-
const requestText = await requestRes.text();
-
console.log("Identity request response:", requestText);
-
-
if (!requestRes.ok) {
-
try {
-
const json = JSON.parse(requestText);
-
throw new Error(json.message || "Failed to request identity migration");
-
} catch {
-
throw new Error(requestText || "Failed to request identity migration");
-
}
-
}
-
-
try {
-
const jsonData = JSON.parse(requestText);
-
if (!jsonData.success) {
-
throw new Error(
-
jsonData.message || "Identity migration request failed",
-
);
-
}
-
console.log("Identity migration requested successfully");
-
-
// Update step name to prompt for token
-
setSteps(prevSteps =>
-
prevSteps.map((step, i) =>
-
i === 2
-
? { ...step, name: "Enter the token sent to your email to complete identity migration" }
-
: step
-
)
+
console.log(
+
"Account creation: Verification failed, waiting for user action",
);
-
// Don't continue with migration - wait for token input
return;
-
} catch (e) {
-
console.error("Failed to parse identity request response:", e);
-
throw new Error(
-
"Invalid response from server during identity request",
-
);
}
+
+
// If verification succeeds, continue to data migration
+
await startDataMigration();
} catch (error) {
updateStepStatus(
-
2,
+
0,
"error",
error instanceof Error ? error.message : String(error),
);
···
if (!identityRes.ok) {
try {
const json = JSON.parse(identityData);
-
throw new Error(json.message || "Failed to complete identity migration");
+
throw new Error(
+
json.message || "Failed to complete identity migration",
+
);
} catch {
-
throw new Error(identityData || "Failed to complete identity migration");
+
throw new Error(
+
identityData || "Failed to complete identity migration",
+
);
}
}
···
throw new Error("Invalid response from server");
}
-
updateStepStatus(2, "verifying");
const verified = await verifyStep(2);
if (!verified) {
-
throw new Error("Identity migration verification failed");
-
}
-
-
// Step 4: Finalize Migration
-
updateStepStatus(3, "in-progress");
-
try {
-
const finalizeRes = await fetch("/api/migrate/finalize", {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
});
-
-
const finalizeData = await finalizeRes.text();
-
if (!finalizeRes.ok) {
-
try {
-
const json = JSON.parse(finalizeData);
-
throw new Error(json.message || "Failed to finalize migration");
-
} catch {
-
throw new Error(finalizeData || "Failed to finalize migration");
-
}
-
}
-
-
try {
-
const jsonData = JSON.parse(finalizeData);
-
if (!jsonData.success) {
-
throw new Error(jsonData.message || "Finalization failed");
-
}
-
} catch {
-
throw new Error("Invalid response from server during finalization");
-
}
-
-
updateStepStatus(3, "verifying");
-
const verified = await verifyStep(3);
-
if (!verified) {
-
throw new Error("Migration finalization verification failed");
-
}
-
} catch (error) {
-
updateStepStatus(
-
3,
-
"error",
-
error instanceof Error ? error.message : String(error),
+
console.log(
+
"Identity migration: Verification failed, waiting for user action",
);
-
throw error;
+
return;
}
+
+
// If verification succeeds, continue to finalization
+
await startFinalization();
} catch (error) {
console.error("Identity migration error:", error);
updateStepStatus(
···
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");
+
// Reset retry state on success
+
setRetryAttempts((prev) => ({ ...prev, [stepNum]: 0 }));
+
setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: false }));
+
+
// Continue to next step if not the last one
+
if (stepNum < 3) {
+
setTimeout(() => continueToNextStep(stepNum + 1), 500);
+
}
+
return true;
} else {
-
console.log(`Verification: Step ${stepNum + 1} is not ready:`, data.reason);
+
console.log(
+
`Verification: Step ${stepNum + 1} is not ready:`,
+
data.reason,
+
);
const statusDetails = {
activated: data.activated,
validDid: data.validDid,
···
indexedRecords: data.indexedRecords,
privateStateValues: data.privateStateValues,
expectedBlobs: data.expectedBlobs,
-
importedBlobs: data.importedBlobs
+
importedBlobs: data.importedBlobs,
};
-
console.log(`Verification: Step ${stepNum + 1} status details:`, statusDetails);
-
const errorMessage = `${data.reason || "Verification failed"}\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`;
-
updateStepStatus(stepNum, "error", errorMessage);
+
console.log(
+
`Verification: Step ${stepNum + 1} status details:`,
+
statusDetails,
+
);
+
const errorMessage = `${
+
data.reason || "Verification failed"
+
}\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`;
+
+
// Track retry attempts
+
const currentAttempts = retryAttempts[stepNum] || 0;
+
setRetryAttempts((prev) => ({
+
...prev,
+
[stepNum]: currentAttempts + 1,
+
}));
+
+
// Show continue anyway option if this is the second failure
+
if (currentAttempts >= 1) {
+
setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: true }));
+
}
+
+
updateStepStatus(stepNum, "error", errorMessage, true);
return false;
}
} catch (e) {
console.error(`Verification: Error in step ${stepNum + 1}:`, e);
-
updateStepStatus(stepNum, "error", e instanceof Error ? e.message : String(e));
+
const currentAttempts = retryAttempts[stepNum] || 0;
+
setRetryAttempts((prev) => ({ ...prev, [stepNum]: currentAttempts + 1 }));
+
+
// Show continue anyway option if this is the second failure
+
if (currentAttempts >= 1) {
+
setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: true }));
+
}
+
+
updateStepStatus(
+
stepNum,
+
"error",
+
e instanceof Error ? e.message : String(e),
+
true,
+
);
return false;
}
};
+
const retryVerification = async (stepNum: number) => {
+
console.log(`Retrying verification for step ${stepNum + 1}`);
+
await verifyStep(stepNum);
+
};
+
+
const continueAnyway = (stepNum: number) => {
+
console.log(`Continuing anyway for step ${stepNum + 1}`);
+
updateStepStatus(stepNum, "completed");
+
setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: false }));
+
+
// Continue with next step if not the last one
+
if (stepNum < 3) {
+
continueToNextStep(stepNum + 1);
+
}
+
};
+
+
const continueToNextStep = async (stepNum: number) => {
+
switch (stepNum) {
+
case 1:
+
// Continue to data migration
+
await startDataMigration();
+
break;
+
case 2:
+
// Continue to identity migration
+
await startIdentityMigration();
+
break;
+
case 3:
+
// Continue to finalization
+
await startFinalization();
+
break;
+
}
+
};
+
+
const startDataMigration = async () => {
+
// Step 2: Migrate Data
+
updateStepStatus(1, "in-progress");
+
console.log("Starting data migration...");
+
+
try {
+
// Step 2.1: Migrate Repo
+
console.log("Data migration: Starting repo migration");
+
const repoRes = await fetch("/api/migrate/data/repo", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
});
+
+
console.log("Repo migration: Response status:", repoRes.status);
+
const repoText = await repoRes.text();
+
console.log("Repo migration: Raw response:", repoText);
+
+
if (!repoRes.ok) {
+
try {
+
const json = JSON.parse(repoText);
+
console.error("Repo migration: Error response:", json);
+
throw new Error(json.message || "Failed to migrate repo");
+
} catch {
+
console.error("Repo migration: Non-JSON error response:", repoText);
+
throw new Error(repoText || "Failed to migrate repo");
+
}
+
}
+
+
// Step 2.2: Migrate Blobs
+
console.log("Data migration: Starting blob migration");
+
const blobsRes = await fetch("/api/migrate/data/blobs", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
});
+
+
console.log("Blob migration: Response status:", blobsRes.status);
+
const blobsText = await blobsRes.text();
+
console.log("Blob migration: Raw response:", blobsText);
+
+
if (!blobsRes.ok) {
+
try {
+
const json = JSON.parse(blobsText);
+
console.error("Blob migration: Error response:", json);
+
throw new Error(json.message || "Failed to migrate blobs");
+
} catch {
+
console.error(
+
"Blob migration: Non-JSON error response:",
+
blobsText,
+
);
+
throw new Error(blobsText || "Failed to migrate blobs");
+
}
+
}
+
+
// Step 2.3: Migrate Preferences
+
console.log("Data migration: Starting preferences migration");
+
const prefsRes = await fetch("/api/migrate/data/prefs", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
});
+
+
console.log("Preferences migration: Response status:", prefsRes.status);
+
const prefsText = await prefsRes.text();
+
console.log("Preferences migration: Raw response:", prefsText);
+
+
if (!prefsRes.ok) {
+
try {
+
const json = JSON.parse(prefsText);
+
console.error("Preferences migration: Error response:", json);
+
throw new Error(json.message || "Failed to migrate preferences");
+
} catch {
+
console.error(
+
"Preferences migration: Non-JSON error response:",
+
prefsText,
+
);
+
throw new Error(prefsText || "Failed to migrate preferences");
+
}
+
}
+
+
console.log("Data migration: Starting verification");
+
updateStepStatus(1, "verifying");
+
const verified = await verifyStep(1);
+
console.log("Data migration: Verification result:", verified);
+
if (!verified) {
+
console.log(
+
"Data migration: Verification failed, waiting for user action",
+
);
+
return;
+
}
+
+
// If verification succeeds, continue to next step
+
await startIdentityMigration();
+
} catch (error) {
+
console.error("Data migration: Error caught:", error);
+
updateStepStatus(
+
1,
+
"error",
+
error instanceof Error ? error.message : String(error),
+
);
+
throw error;
+
}
+
};
+
+
const startIdentityMigration = async () => {
+
// Step 3: Request Identity Migration
+
updateStepStatus(2, "in-progress");
+
console.log("Requesting identity migration...");
+
+
try {
+
const requestRes = await fetch("/api/migrate/identity/request", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
});
+
+
console.log("Identity request response status:", requestRes.status);
+
const requestText = await requestRes.text();
+
console.log("Identity request response:", requestText);
+
+
if (!requestRes.ok) {
+
try {
+
const json = JSON.parse(requestText);
+
throw new Error(
+
json.message || "Failed to request identity migration",
+
);
+
} catch {
+
throw new Error(
+
requestText || "Failed to request identity migration",
+
);
+
}
+
}
+
+
try {
+
const jsonData = JSON.parse(requestText);
+
if (!jsonData.success) {
+
throw new Error(
+
jsonData.message || "Identity migration request failed",
+
);
+
}
+
console.log("Identity migration requested successfully");
+
+
// Update step name to prompt for token
+
setSteps((prevSteps) =>
+
prevSteps.map((step, i) =>
+
i === 2
+
? {
+
...step,
+
name:
+
"Enter the token sent to your email to complete identity migration",
+
}
+
: step
+
)
+
);
+
// Don't continue with migration - wait for token input
+
return;
+
} catch (e) {
+
console.error("Failed to parse identity request response:", e);
+
throw new Error(
+
"Invalid response from server during identity request",
+
);
+
}
+
} catch (error) {
+
updateStepStatus(
+
2,
+
"error",
+
error instanceof Error ? error.message : String(error),
+
);
+
throw error;
+
}
+
};
+
+
const startFinalization = async () => {
+
// Step 4: Finalize Migration
+
updateStepStatus(3, "in-progress");
+
try {
+
const finalizeRes = await fetch("/api/migrate/finalize", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
});
+
+
const finalizeData = await finalizeRes.text();
+
if (!finalizeRes.ok) {
+
try {
+
const json = JSON.parse(finalizeData);
+
throw new Error(json.message || "Failed to finalize migration");
+
} catch {
+
throw new Error(finalizeData || "Failed to finalize migration");
+
}
+
}
+
+
try {
+
const jsonData = JSON.parse(finalizeData);
+
if (!jsonData.success) {
+
throw new Error(jsonData.message || "Finalization failed");
+
}
+
} catch {
+
throw new Error("Invalid response from server during finalization");
+
}
+
+
updateStepStatus(3, "verifying");
+
const verified = await verifyStep(3);
+
if (!verified) {
+
console.log(
+
"Finalization: Verification failed, waiting for user action",
+
);
+
return;
+
}
+
} catch (error) {
+
updateStepStatus(
+
3,
+
"error",
+
error instanceof Error ? error.message : String(error),
+
);
+
throw error;
+
}
+
};
+
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)}>
···
{getStepDisplayName(step, index)}
</p>
{step.error && (
-
<p class="text-sm text-red-600 dark:text-red-400 mt-1">
-
{(() => {
-
try {
-
const err = JSON.parse(step.error);
-
return err.message || step.error;
-
} catch {
-
return step.error;
-
}
-
})()}
-
</p>
+
<div class="mt-1">
+
<p class="text-sm text-red-600 dark:text-red-400">
+
{(() => {
+
try {
+
const err = JSON.parse(step.error);
+
return err.message || step.error;
+
} catch {
+
return step.error;
+
}
+
})()}
+
</p>
+
{step.isVerificationError && (
+
<div class="flex space-x-2 mt-2">
+
<button
+
type="button"
+
onClick={() => retryVerification(index)}
+
class="px-3 py-1 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors duration-200 dark:bg-blue-500 dark:hover:bg-blue-400"
+
>
+
Retry Verification
+
</button>
+
{showContinueAnyway[index] && (
+
<button
+
type="button"
+
onClick={() => continueAnyway(index)}
+
class="px-3 py-1 text-xs bg-white border border-gray-300 text-gray-700 hover:bg-gray-100 rounded transition-colors duration-200
+
dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
+
>
+
Continue Anyway
+
</button>
+
)}
+
</div>
+
)}
+
</div>
)}
{index === 2 && step.status === "in-progress" &&
-
step.name === "Enter the token sent to your email to complete identity migration" && (
+
step.name ===
+
"Enter the token sent to your email to complete identity migration" &&
+
(
<div class="mt-4 space-y-4">
<p class="text-sm text-blue-800 dark:text-blue-200">
-
Please check your email for the migration token and enter it below:
+
Please check your email for the migration token and enter
+
it below:
</p>
<div class="flex space-x-2">
<input
···
</button>
</div>
</div>
-
)
-
}
+
)}
</div>
</div>
))}
···
{steps[3].status === "completed" && (
<div class="p-4 bg-green-50 dark:bg-green-900 rounded-lg border-2 border-green-200 dark:border-green-800">
-
<p class="text-sm text-green-800 dark:text-green-200">
-
Migration completed successfully! You can now close this page.
+
<p class="text-sm text-green-800 dark:text-green-200 pb-2">
+
Migration completed successfully! Sign out to finish the process and
+
return home.<br />
+
Please consider donating to Airport to support server and
+
development costs.
</p>
-
<button
-
type="button"
-
onClick={async () => {
-
try {
-
const response = await fetch("/api/logout", {
-
method: "POST",
-
credentials: "include",
-
});
-
if (!response.ok) {
-
throw new Error("Logout failed");
+
<div class="flex space-x-4">
+
<button
+
type="button"
+
onClick={async () => {
+
try {
+
const response = await fetch("/api/logout", {
+
method: "POST",
+
credentials: "include",
+
});
+
if (!response.ok) {
+
throw new Error("Logout failed");
+
}
+
globalThis.location.href = "/";
+
} catch (error) {
+
console.error("Failed to logout:", error);
}
-
globalThis.location.href = "/";
-
} catch (error) {
-
console.error("Failed to logout:", error);
-
}
-
}}
-
class="mt-4 mr-4 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200"
-
>
-
Sign Out
-
</button>
-
<a href="https://ko-fi.com/knotbin" target="_blank" class="mt-4 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200">
-
Donate
-
</a>
+
}}
+
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200 flex items-center space-x-2"
+
>
+
<svg
+
class="w-5 h-5"
+
fill="none"
+
stroke="currentColor"
+
viewBox="0 0 24 24"
+
>
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2"
+
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
+
/>
+
</svg>
+
<span>Sign Out</span>
+
</button>
+
<a
+
href="https://ko-fi.com/knotbin"
+
target="_blank"
+
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200 flex items-center space-x-2"
+
>
+
<svg
+
class="w-5 h-5"
+
fill="none"
+
stroke="currentColor"
+
viewBox="0 0 24 24"
+
>
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2"
+
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
+
/>
+
</svg>
+
<span>Support Us</span>
+
</a>
+
</div>
</div>
)}
</div>
+61 -7
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",
});
···
// Get PDS URL from the current service
const pdsResponse = await fetch(`/api/resolve-pds?did=${userData.did}`);
const pdsData = await pdsResponse.json();
-
+
setPassport({
did: userData.did,
handle: userData.handle,
···
});
}
} 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">
···
<div class="text-center mb-4 mt-6">
<h3 class="text-2xl font-bold text-red-600 mb-2 tracking-wide">Final Boarding Call</h3>
<p class="text-gray-700 dark:text-gray-300 mb-2 text-base">
-
<span class="font-semibold text-red-500">Warning:</span> This migration process can be <strong>irreversible</strong>.<br />Airport is in <strong>alpha</strong> currently, and we don't recommend it for main accounts. Migrate at your own risk. We reccomend backing up your data before proceeding.
+
<span class="font-semibold text-red-500">Warning:</span> This migration is <strong>irreversible</strong> if coming from Bluesky servers.<br />Bluesky does not recommend it for main accounts. Migrate at your own risk. We reccomend backing up your data before proceeding.
</p>
<p class="text-gray-700 dark:text-gray-300 mb-4 text-base">
Please type <span class="font-mono font-bold text-blue-600">MIGRATE</span> below to confirm and proceed.
+7
lib/check-dids.ts
···
+
import { getSession } from "./sessions.ts";
+
+
export async function checkDidsMatch(req: Request): Promise<boolean> {
+
const oldSession = await getSession(req, undefined, false);
+
const newSession = await getSession(req, undefined, true);
+
return oldSession.did === newSession.did;
+
}
+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 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);
+
}
+
}
+31 -10
lib/sessions.ts
···
import { Agent } from "npm:@atproto/api";
-
import { OauthSession, CredentialSession } from "./types.ts";
-
import { getCredentialSession, getCredentialSessionAgent } from "./cred/sessions.ts";
+
import { CredentialSession, OauthSession } from "./types.ts";
+
import {
+
getCredentialSession,
+
getCredentialSessionAgent,
+
} from "./cred/sessions.ts";
import { getOauthSession, getOauthSessionAgent } from "./oauth/sessions.ts";
import { IronSession } from "npm:iron-session";
···
export async function getSession(
req: Request,
res: Response = new Response(),
-
isMigration: boolean = false
+
isMigration: boolean = false,
): Promise<IronSession<OauthSession | CredentialSession>> {
if (isMigration) {
return await getCredentialSession(req, res, true);
···
const credentialSession = await getCredentialSession(req, res);
if (oauthSession.did) {
-
console.log("Oauth session found")
+
console.log("Oauth session found");
return oauthSession;
}
if (credentialSession.did) {
···
export async function getSessionAgent(
req: Request,
res: Response = new Response(),
-
isMigration: boolean = false
+
isMigration: boolean = false,
): Promise<Agent | null> {
if (isMigration) {
return await getCredentialSessionAgent(req, res, isMigration);
}
const oauthAgent = await getOauthSessionAgent(req);
-
const credentialAgent = await getCredentialSessionAgent(req, res, isMigration);
+
const credentialAgent = await getCredentialSessionAgent(
+
req,
+
res,
+
isMigration,
+
);
if (oauthAgent) {
return oauthAgent;
···
/**
* Destroy all sessions for the given request.
* @param req - The request object
+
* @param res - The response object
*/
-
export async function destroyAllSessions(req: Request) {
-
const oauthSession = await getOauthSession(req);
-
const credentialSession = await getCredentialSession(req);
-
const migrationSession = await getCredentialSession(req, new Response(), true);
+
export async function destroyAllSessions(
+
req: Request,
+
res?: Response,
+
): Promise<Response> {
+
const response = res || new Response();
+
const oauthSession = await getOauthSession(req, response);
+
const credentialSession = await getCredentialSession(req, res);
+
const migrationSession = await getCredentialSession(
+
req,
+
res,
+
true,
+
);
if (oauthSession.did) {
oauthSession.destroy();
···
credentialSession.destroy();
}
if (migrationSession.did) {
+
console.log("DESTROYING MIGRATION SESSION", migrationSession);
migrationSession.destroy();
+
} else {
+
console.log("MIGRATION SESSION NOT FOUND", migrationSession);
}
+
+
return response;
}
+1 -1
lib/storage.ts
···
NodeSavedSessionStore,
NodeSavedState,
NodeSavedStateStore,
-
} from "jsr:@bigmoves/atproto-oauth-client";
+
} from "@bigmoves/atproto-oauth-client";
/**
* The state store for sessions.
routes/.DS_Store

This is a binary file and will not be displayed.

routes/api/.DS_Store

This is a binary file and will not be displayed.

+4 -4
routes/api/logout.ts
···
-
import { getSession, destroyAllSessions } from "../../lib/sessions.ts";
+
import { destroyAllSessions, getSession } from "../../lib/sessions.ts";
import { oauthClient } from "../../lib/oauth/client.ts";
import { define } from "../../utils.ts";
···
if (session.did) {
// Try to revoke both types of sessions - the one that doesn't exist will just no-op
await Promise.all([
-
oauthClient.revoke(session.did).catch(console.error)
+
oauthClient.revoke(session.did).catch(console.error),
]);
// Then destroy the iron session
session.destroy();
}
// Destroy all sessions including migration session
-
await destroyAllSessions(req);
+
const result = await destroyAllSessions(req, response);
-
return response;
+
return result;
} catch (error: unknown) {
const err = error instanceof Error ? error : new Error(String(error));
console.error("Logout failed:", err.message);
+6 -2
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;
···
return new Response("Could not create new agent", { status: 400 });
}
-
console.log("getting did")
+
console.log("getting did");
const session = await oldAgent.com.atproto.server.getSession();
const accountDid = session.data.did;
-
console.log("got did")
+
console.log("got did");
const describeRes = await newAgent.com.atproto.server.describeServer();
const newServerDid = describeRes.data.did;
const inviteRequired = describeRes.data.inviteCodeRequired ?? false;
+187 -49
routes/api/migrate/data/blobs.ts
···
import { getSessionAgent } from "../../../../lib/sessions.ts";
+
import { checkDidsMatch } from "../../../../lib/check-dids.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);
···
);
}
+
// Verify DIDs match between sessions
+
const didsMatch = await checkDidsMatch(ctx.req);
+
if (!didsMatch) {
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "Invalid state, original and target DIDs do not match",
+
}),
+
{
+
status: 400,
+
headers: { "Content-Type": "application/json" },
+
},
+
);
+
}
+
// Migrate blobs
const migrationLogs: string[] = [];
const migratedBlobs: string[] = [];
···
const startTime = Date.now();
console.log(`[${new Date().toISOString()}] Starting blob migration...`);
-
migrationLogs.push(`[${new Date().toISOString()}] Starting blob migration...`);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Starting blob migration...`,
+
);
// First count total blobs
console.log(`[${new Date().toISOString()}] Starting blob count...`);
-
migrationLogs.push(`[${new Date().toISOString()}] Starting blob count...`);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Starting blob count...`,
+
);
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}...`);
-
migrationLogs.push(`[${new Date().toISOString()}] Counting blobs on page ${pageCount + 1}...`);
+
console.log(
+
`[${new Date().toISOString()}] Counting blobs on page ${
+
pageCount + 1
+
}...`,
+
);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Counting blobs on page ${
+
pageCount + 1
+
}...`,
+
);
const listedBlobs = await oldAgent.com.atproto.sync.listBlobs({
did: accountDid,
cursor: blobCursor,
···
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}`);
-
+
+
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);
-
console.log(`[${new Date().toISOString()}] Total blobs to migrate: ${totalBlobs}`);
-
migrationLogs.push(`[${new Date().toISOString()}] Total blobs to migrate: ${totalBlobs}`);
+
console.log(
+
`[${new Date().toISOString()}] Total blobs to migrate: ${totalBlobs}`,
+
);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Total blobs to migrate: ${totalBlobs}`,
+
);
// Reset cursor for actual migration
blobCursor = undefined;
···
do {
const pageStartTime = Date.now();
-
console.log(`[${new Date().toISOString()}] Fetching blob list page ${pageCount + 1}...`);
-
migrationLogs.push(`[${new Date().toISOString()}] Fetching blob list page ${pageCount + 1}...`);
+
console.log(
+
`[${new Date().toISOString()}] Fetching blob list page ${
+
pageCount + 1
+
}...`,
+
);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Fetching blob list page ${
+
pageCount + 1
+
}...`,
+
);
const listedBlobs = await oldAgent.com.atproto.sync.listBlobs({
did: accountDid,
···
});
const pageTime = Date.now() - pageStartTime;
-
console.log(`[${new Date().toISOString()}] Found ${listedBlobs.data.cids.length} blobs on page ${pageCount + 1} in ${pageTime/1000} seconds`);
-
migrationLogs.push(`[${new Date().toISOString()}] Found ${listedBlobs.data.cids.length} blobs on page ${pageCount + 1} in ${pageTime/1000} seconds`);
+
console.log(
+
`[${
+
new Date().toISOString()
+
}] Found ${listedBlobs.data.cids.length} blobs on page ${
+
pageCount + 1
+
} in ${pageTime / 1000} seconds`,
+
);
+
migrationLogs.push(
+
`[${
+
new Date().toISOString()
+
}] Found ${listedBlobs.data.cids.length} blobs on page ${
+
pageCount + 1
+
} in ${pageTime / 1000} seconds`,
+
);
blobCursor = listedBlobs.data.cursor;
for (const cid of listedBlobs.data.cids) {
try {
const blobStartTime = Date.now();
-
console.log(`[${new Date().toISOString()}] Starting migration for blob ${cid} (${processedBlobs + 1} of ${totalBlobs})...`);
-
migrationLogs.push(`[${new Date().toISOString()}] Starting migration for blob ${cid} (${processedBlobs + 1} of ${totalBlobs})...`);
+
console.log(
+
`[${
+
new Date().toISOString()
+
}] Starting migration for blob ${cid} (${
+
processedBlobs + 1
+
} of ${totalBlobs})...`,
+
);
+
migrationLogs.push(
+
`[${
+
new Date().toISOString()
+
}] Starting migration for blob ${cid} (${
+
processedBlobs + 1
+
} of ${totalBlobs})...`,
+
);
const blobRes = await oldAgent.com.atproto.sync.getBlob({
did: accountDid,
···
const size = parseInt(contentLength, 10);
if (isNaN(size)) {
-
throw new Error(`Blob ${cid} has invalid content length: ${contentLength}`);
+
throw new Error(
+
`Blob ${cid} has invalid content length: ${contentLength}`,
+
);
}
const MAX_SIZE = 200 * 1024 * 1024; // 200MB
if (size > MAX_SIZE) {
-
throw new Error(`Blob ${cid} exceeds maximum size limit (${size} bytes)`);
+
throw new Error(
+
`Blob ${cid} exceeds maximum size limit (${size} bytes)`,
+
);
}
-
console.log(`[${new Date().toISOString()}] Downloading blob ${cid} (${size} bytes)...`);
-
migrationLogs.push(`[${new Date().toISOString()}] Downloading blob ${cid} (${size} bytes)...`);
+
console.log(
+
`[${
+
new Date().toISOString()
+
}] Downloading blob ${cid} (${size} bytes)...`,
+
);
+
migrationLogs.push(
+
`[${
+
new Date().toISOString()
+
}] Downloading blob ${cid} (${size} bytes)...`,
+
);
if (!blobRes.data) {
-
throw new Error(`Failed to download blob ${cid}: No data received`);
+
throw new Error(
+
`Failed to download blob ${cid}: No data received`,
+
);
}
-
console.log(`[${new Date().toISOString()}] Uploading blob ${cid} to new account...`);
-
migrationLogs.push(`[${new Date().toISOString()}] Uploading blob ${cid} to new account...`);
+
console.log(
+
`[${
+
new Date().toISOString()
+
}] Uploading blob ${cid} to new account...`,
+
);
+
migrationLogs.push(
+
`[${
+
new Date().toISOString()
+
}] Uploading blob ${cid} to new account...`,
+
);
try {
await newAgent.com.atproto.repo.uploadBlob(blobRes.data);
const blobTime = Date.now() - blobStartTime;
-
console.log(`[${new Date().toISOString()}] Successfully migrated blob ${cid} in ${blobTime/1000} seconds`);
-
migrationLogs.push(`[${new Date().toISOString()}] Successfully migrated blob ${cid} in ${blobTime/1000} seconds`);
+
console.log(
+
`[${
+
new Date().toISOString()
+
}] Successfully migrated blob ${cid} in ${
+
blobTime / 1000
+
} seconds`,
+
);
+
migrationLogs.push(
+
`[${
+
new Date().toISOString()
+
}] Successfully migrated blob ${cid} in ${
+
blobTime / 1000
+
} seconds`,
+
);
migratedBlobs.push(cid);
} catch (uploadError) {
-
console.error(`[${new Date().toISOString()}] Failed to upload blob ${cid}:`, uploadError);
-
throw new Error(`Upload failed: ${uploadError instanceof Error ? uploadError.message : String(uploadError)}`);
+
console.error(
+
`[${new Date().toISOString()}] Failed to upload blob ${cid}:`,
+
uploadError,
+
);
+
throw new Error(
+
`Upload failed: ${
+
uploadError instanceof Error
+
? uploadError.message
+
: String(uploadError)
+
}`,
+
);
}
} catch (error) {
-
const errorMessage = error instanceof Error ? error.message : String(error);
-
const detailedError = `[${new Date().toISOString()}] Failed to migrate blob ${cid}: ${errorMessage}`;
+
const errorMessage = error instanceof Error
+
? error.message
+
: String(error);
+
const detailedError = `[${
+
new Date().toISOString()
+
}] Failed to migrate blob ${cid}: ${errorMessage}`;
console.error(detailedError);
-
console.error('Full error details:', error);
+
console.error("Full error details:", error);
migrationLogs.push(detailedError);
failedBlobs.push(cid);
}
processedBlobs++;
-
const progressLog = `[${new Date().toISOString()}] Progress: ${processedBlobs}/${totalBlobs} blobs processed (${Math.round((processedBlobs/totalBlobs)*100)}%)`;
+
const progressLog = `[${
+
new Date().toISOString()
+
}] Progress: ${processedBlobs}/${totalBlobs} blobs processed (${
+
Math.round((processedBlobs / totalBlobs) * 100)
+
}%)`;
console.log(progressLog);
migrationLogs.push(progressLog);
}
···
} while (blobCursor);
const totalTime = Date.now() - startTime;
-
const completionMessage = `[${new Date().toISOString()}] Blob migration completed in ${totalTime/1000} seconds: ${migratedBlobs.length} blobs migrated${failedBlobs.length > 0 ? `, ${failedBlobs.length} failed` : ''} (${pageCount} pages processed)`;
+
const completionMessage = `[${
+
new Date().toISOString()
+
}] Blob migration completed in ${
+
totalTime / 1000
+
} seconds: ${migratedBlobs.length} blobs migrated${
+
failedBlobs.length > 0 ? `, ${failedBlobs.length} failed` : ""
+
} (${pageCount} pages processed)`;
console.log(completionMessage);
migrationLogs.push(completionMessage);
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,
···
totalBlobs,
logs: migrationLogs,
timing: {
-
totalTime: totalTime/1000
-
}
+
totalTime: totalTime / 1000,
+
},
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
...Object.fromEntries(res.headers),
-
}
-
}
+
},
+
},
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
-
console.error(`[${new Date().toISOString()}] Blob migration error:`, message);
-
console.error('Full error details:', error);
+
console.error(
+
`[${new Date().toISOString()}] Blob migration error:`,
+
message,
+
);
+
console.error("Full error details:", error);
return new Response(
JSON.stringify({
success: false,
message: `Blob migration failed: ${message}`,
-
error: error instanceof Error ? {
-
name: error.name,
-
message: error.message,
-
stack: error.stack,
-
} : String(error)
+
error: error instanceof Error
+
? {
+
name: error.name,
+
message: error.message,
+
stack: error.stack,
+
}
+
: String(error),
}),
{
status: 500,
headers: {
"Content-Type": "application/json",
...Object.fromEntries(res.headers),
-
}
-
}
+
},
+
},
);
}
-
}
-
});
+
},
+
});
+99 -37
routes/api/migrate/data/prefs.ts
···
import { getSessionAgent } from "../../../../lib/sessions.ts";
+
import { checkDidsMatch } from "../../../../lib/check-dids.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);
···
console.log("Preferences migration: Got new agent:", !!newAgent);
if (!oldAgent || !newAgent) {
-
return new Response(JSON.stringify({
-
success: false,
-
message: "Not authenticated"
-
}), {
-
status: 401,
-
headers: { "Content-Type": "application/json" }
-
});
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "Not authenticated",
+
}),
+
{
+
status: 401,
+
headers: { "Content-Type": "application/json" },
+
},
+
);
+
}
+
+
// Verify DIDs match between sessions
+
const didsMatch = await checkDidsMatch(ctx.req);
+
if (!didsMatch) {
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "Invalid state, original and target DIDs do not match",
+
}),
+
{
+
status: 400,
+
headers: { "Content-Type": "application/json" },
+
},
+
);
}
// Migrate preferences
const migrationLogs: string[] = [];
const startTime = Date.now();
-
console.log(`[${new Date().toISOString()}] Starting preferences migration...`);
-
migrationLogs.push(`[${new Date().toISOString()}] Starting preferences migration...`);
+
console.log(
+
`[${new Date().toISOString()}] Starting preferences migration...`,
+
);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Starting preferences migration...`,
+
);
// Fetch preferences
-
console.log(`[${new Date().toISOString()}] Fetching preferences from old account...`);
-
migrationLogs.push(`[${new Date().toISOString()}] Fetching preferences from old account...`);
-
+
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`);
+
+
console.log(
+
`[${new Date().toISOString()}] Preferences fetched in ${
+
fetchTime / 1000
+
} seconds`,
+
);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Preferences fetched in ${
+
fetchTime / 1000
+
} seconds`,
+
);
// Update preferences
-
console.log(`[${new Date().toISOString()}] Updating preferences on new account...`);
-
migrationLogs.push(`[${new Date().toISOString()}] Updating preferences on new account...`);
+
console.log(
+
`[${new Date().toISOString()}] Updating preferences on new account...`,
+
);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Updating preferences on new account...`,
+
);
const updateStartTime = Date.now();
await newAgent.app.bsky.actor.putPreferences(prefs.data);
const updateTime = Date.now() - updateStartTime;
-
console.log(`[${new Date().toISOString()}] Preferences updated in ${updateTime/1000} seconds`);
-
migrationLogs.push(`[${new Date().toISOString()}] Preferences updated in ${updateTime/1000} seconds`);
+
console.log(
+
`[${new Date().toISOString()}] Preferences updated in ${
+
updateTime / 1000
+
} seconds`,
+
);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Preferences updated in ${
+
updateTime / 1000
+
} seconds`,
+
);
const totalTime = Date.now() - startTime;
-
const completionMessage = `[${new Date().toISOString()}] Preferences migration completed in ${totalTime/1000} seconds total`;
+
const completionMessage = `[${
+
new Date().toISOString()
+
}] Preferences migration completed in ${totalTime / 1000} seconds total`;
console.log(completionMessage);
migrationLogs.push(completionMessage);
···
message: "Preferences migration completed successfully",
logs: migrationLogs,
timing: {
-
fetchTime: fetchTime/1000,
-
updateTime: updateTime/1000,
-
totalTime: totalTime/1000
-
}
+
fetchTime: fetchTime / 1000,
+
updateTime: updateTime / 1000,
+
totalTime: totalTime / 1000,
+
},
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
...Object.fromEntries(res.headers),
-
}
-
}
+
},
+
},
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
-
console.error(`[${new Date().toISOString()}] Preferences migration error:`, message);
-
console.error('Full error details:', error);
+
console.error(
+
`[${new Date().toISOString()}] Preferences migration error:`,
+
message,
+
);
+
console.error("Full error details:", error);
return new Response(
JSON.stringify({
success: false,
message: `Preferences migration failed: ${message}`,
-
error: error instanceof Error ? {
-
name: error.name,
-
message: error.message,
-
stack: error.stack,
-
} : String(error)
+
error: error instanceof Error
+
? {
+
name: error.name,
+
message: error.message,
+
stack: error.stack,
+
}
+
: String(error),
}),
{
status: 500,
headers: {
"Content-Type": "application/json",
...Object.fromEntries(res.headers),
-
}
-
}
+
},
+
},
);
}
-
}
-
});
+
},
+
});
+93 -38
routes/api/migrate/data/repo.ts
···
import { getSessionAgent } from "../../../../lib/sessions.ts";
+
import { checkDidsMatch } from "../../../../lib/check-dids.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);
-
const newAgent = await getSessionAgent(ctx.req, res, true);
console.log("Repo migration: Got new agent:", !!newAgent);
if (!oldAgent || !newAgent) {
-
return new Response(JSON.stringify({
-
success: false,
-
message: "Not authenticated"
-
}), {
-
status: 401,
-
headers: { "Content-Type": "application/json" }
-
});
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "Not authenticated",
+
}),
+
{
+
status: 401,
+
headers: { "Content-Type": "application/json" },
+
},
+
);
+
}
+
+
// Verify DIDs match between sessions
+
const didsMatch = await checkDidsMatch(ctx.req);
+
if (!didsMatch) {
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "Invalid state, original and target DIDs do not match",
+
}),
+
{
+
status: 400,
+
headers: { "Content-Type": "application/json" },
+
},
+
);
}
const session = await oldAgent.com.atproto.server.getSession();
···
const migrationLogs: string[] = [];
const startTime = Date.now();
console.log(`[${new Date().toISOString()}] Starting repo migration...`);
-
migrationLogs.push(`[${new Date().toISOString()}] Starting repo migration...`);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Starting repo migration...`,
+
);
// 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...`);
-
+
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`);
+
+
console.log(
+
`[${new Date().toISOString()}] Repo data fetched in ${
+
fetchTime / 1000
+
} seconds`,
+
);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Repo data fetched in ${
+
fetchTime / 1000
+
} seconds`,
+
);
-
console.log(`[${new Date().toISOString()}] Importing repo data to new account...`);
-
migrationLogs.push(`[${new Date().toISOString()}] Importing repo data to new account...`);
+
console.log(
+
`[${new Date().toISOString()}] Importing repo data to new account...`,
+
);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Importing repo data to new account...`,
+
);
// Import repo data to new account
const importStartTime = Date.now();
await newAgent.com.atproto.repo.importRepo(repoData.data, {
-
encoding: "application/vnd.ipld.car"
+
encoding: "application/vnd.ipld.car",
});
const importTime = Date.now() - importStartTime;
-
console.log(`[${new Date().toISOString()}] Repo data imported in ${importTime/1000} seconds`);
-
migrationLogs.push(`[${new Date().toISOString()}] Repo data imported in ${importTime/1000} seconds`);
+
console.log(
+
`[${new Date().toISOString()}] Repo data imported in ${
+
importTime / 1000
+
} seconds`,
+
);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Repo data imported in ${
+
importTime / 1000
+
} seconds`,
+
);
const totalTime = Date.now() - startTime;
-
const completionMessage = `[${new Date().toISOString()}] Repo migration completed in ${totalTime/1000} seconds total`;
+
const completionMessage = `[${
+
new Date().toISOString()
+
}] Repo migration completed in ${totalTime / 1000} seconds total`;
console.log(completionMessage);
migrationLogs.push(completionMessage);
···
message: "Repo migration completed successfully",
logs: migrationLogs,
timing: {
-
fetchTime: fetchTime/1000,
-
importTime: importTime/1000,
-
totalTime: totalTime/1000
-
}
+
fetchTime: fetchTime / 1000,
+
importTime: importTime / 1000,
+
totalTime: totalTime / 1000,
+
},
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
...Object.fromEntries(res.headers),
-
}
-
}
+
},
+
},
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
-
console.error(`[${new Date().toISOString()}] Repo migration error:`, message);
-
console.error('Full error details:', error);
+
console.error(
+
`[${new Date().toISOString()}] Repo migration error:`,
+
message,
+
);
+
console.error("Full error details:", error);
return new Response(
JSON.stringify({
success: false,
message: `Repo migration failed: ${message}`,
-
error: error instanceof Error ? {
-
name: error.name,
-
message: error.message,
-
stack: error.stack,
-
} : String(error)
+
error: error instanceof Error
+
? {
+
name: error.name,
+
message: error.message,
+
stack: error.stack,
+
}
+
: String(error),
}),
{
status: 500,
headers: {
"Content-Type": "application/json",
...Object.fromEntries(res.headers),
-
}
-
}
+
},
+
},
);
}
-
}
-
});
+
},
+
});
+17
routes/api/migrate/finalize.ts
···
import { getSessionAgent } from "../../../lib/sessions.ts";
+
import { checkDidsMatch } from "../../../lib/check-dids.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);
···
return new Response("Migration session not found or invalid", {
status: 400,
});
+
}
+
+
// Verify DIDs match between sessions
+
const didsMatch = await checkDidsMatch(ctx.req);
+
if (!didsMatch) {
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "Invalid state, original and target DIDs do not match",
+
}),
+
{ status: 400, headers: { "Content-Type": "application/json" } },
+
);
}
// Activate new account and deactivate old account
+22 -4
routes/api/migrate/identity/request.ts
···
-
import {
-
getSessionAgent,
-
} from "../../../../lib/sessions.ts";
+
import { getSessionAgent } from "../../../../lib/sessions.ts";
+
import { checkDidsMatch } from "../../../../lib/check-dids.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:", {
···
);
}
+
// Verify DIDs match between sessions
+
const didsMatch = await checkDidsMatch(ctx.req);
+
if (!didsMatch) {
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "Invalid state, original and target DIDs do not match",
+
}),
+
{
+
status: 400,
+
headers: { "Content-Type": "application/json" },
+
},
+
);
+
}
+
// Request the signature
console.log("Requesting PLC operation signature...");
try {
···
console.error("Error requesting PLC operation signature:", {
name: error instanceof Error ? error.name : "Unknown",
message: error instanceof Error ? error.message : String(error),
-
status: 400
+
status: 400,
});
throw error;
}
+20 -3
routes/api/migrate/identity/sign.ts
···
-
import {
-
getSessionAgent,
-
} from "../../../../lib/sessions.ts";
+
import { getSessionAgent } from "../../../../lib/sessions.ts";
+
import { checkDidsMatch } from "../../../../lib/check-dids.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");
···
JSON.stringify({
success: false,
message: "Migration session not found or invalid",
+
}),
+
{
+
status: 400,
+
headers: { "Content-Type": "application/json" },
+
},
+
);
+
}
+
+
// Verify DIDs match between sessions
+
const didsMatch = await checkDidsMatch(ctx.req);
+
if (!didsMatch) {
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "Invalid state, original and target DIDs do not match",
}),
{
status: 400,
+2 -2
routes/api/migrate/next-step.ts
···
// Check conditions in sequence to determine the next step
if (!newStatus.data) {
nextStep = 1;
-
} else if (!(newStatus.data.repoCommit &&
+
} else if (!(newStatus.data.repoCommit &&
newStatus.data.indexedRecords === oldStatus.data.indexedRecords &&
newStatus.data.privateStateValues === oldStatus.data.privateStateValues &&
newStatus.data.expectedBlobs === newStatus.data.importedBlobs &&
···
}
});
}
-
})
+
})
+130 -104
routes/api/migrate/status.ts
···
+
import { checkDidsMatch } from "../../../lib/check-dids.ts";
import { getSessionAgent } from "../../../lib/sessions.ts";
import { define } from "../../../utils.ts";
export const handler = define.handlers({
-
async GET(ctx) {
-
console.log("Status check: Starting");
-
const url = new URL(ctx.req.url);
-
const params = new URLSearchParams(url.search);
-
const step = params.get("step");
-
console.log("Status check: Step", step);
+
async GET(ctx) {
+
console.log("Status check: Starting");
+
const url = new URL(ctx.req.url);
+
const params = new URLSearchParams(url.search);
+
const step = params.get("step");
+
console.log("Status check: Step", step);
-
console.log("Status check: Getting agents");
-
const oldAgent = await getSessionAgent(ctx.req);
-
const newAgent = await getSessionAgent(ctx.req, new Response(), true);
-
-
if (!oldAgent || !newAgent) {
-
console.log("Status check: Unauthorized - missing agents", {
-
hasOldAgent: !!oldAgent,
-
hasNewAgent: !!newAgent
-
});
-
return new Response("Unauthorized", { status: 401 });
-
}
+
console.log("Status check: Getting agents");
+
const oldAgent = await getSessionAgent(ctx.req);
+
const newAgent = await getSessionAgent(ctx.req, new Response(), true);
-
console.log("Status check: Fetching account statuses");
-
const oldStatus = await oldAgent.com.atproto.server.checkAccountStatus();
-
const newStatus = await newAgent.com.atproto.server.checkAccountStatus();
-
-
if (!oldStatus.data || !newStatus.data) {
-
console.error("Status check: Failed to verify status", {
-
hasOldStatus: !!oldStatus.data,
-
hasNewStatus: !!newStatus.data
-
});
-
return new Response("Could not verify status", { status: 500 });
-
}
+
if (!oldAgent || !newAgent) {
+
console.log("Status check: Unauthorized - missing agents", {
+
hasOldAgent: !!oldAgent,
+
hasNewAgent: !!newAgent,
+
});
+
return new Response("Unauthorized", { status: 401 });
+
}
-
console.log("Status check: Account statuses", {
-
old: oldStatus.data,
-
new: newStatus.data
-
});
+
const didsMatch = await checkDidsMatch(ctx.req);
-
const readyToContinue = () => {
-
if (step) {
-
console.log("Status check: Evaluating step", step);
-
switch (step) {
-
case "1": {
-
if (newStatus.data) {
-
console.log("Status check: Step 1 ready");
-
return { ready: true };
-
}
-
console.log("Status check: Step 1 not ready - new account status not available");
-
return { ready: false, reason: "New account status not available" };
-
}
-
case "2": {
-
const isReady = newStatus.data.repoCommit &&
-
newStatus.data.indexedRecords === oldStatus.data.indexedRecords &&
-
newStatus.data.privateStateValues === oldStatus.data.privateStateValues &&
-
newStatus.data.expectedBlobs === newStatus.data.importedBlobs &&
-
newStatus.data.importedBlobs === oldStatus.data.importedBlobs;
+
console.log("Status check: Fetching account statuses");
+
const oldStatus = await oldAgent.com.atproto.server.checkAccountStatus();
+
const newStatus = await newAgent.com.atproto.server.checkAccountStatus();
-
if (isReady) {
-
console.log("Status check: Step 2 ready");
-
return { ready: true };
-
}
+
if (!oldStatus.data || !newStatus.data) {
+
console.error("Status check: Failed to verify status", {
+
hasOldStatus: !!oldStatus.data,
+
hasNewStatus: !!newStatus.data,
+
});
+
return new Response("Could not verify status", { status: 500 });
+
}
-
const reasons = [];
-
if (!newStatus.data.repoCommit) reasons.push("Repository not imported.");
-
if (newStatus.data.indexedRecords < oldStatus.data.indexedRecords)
-
reasons.push("Not all records imported.");
-
if (newStatus.data.privateStateValues < oldStatus.data.privateStateValues)
-
reasons.push("Not all private state values imported.");
-
if (newStatus.data.expectedBlobs !== newStatus.data.importedBlobs)
-
reasons.push("Expected blobs not fully imported.");
-
if (newStatus.data.importedBlobs < oldStatus.data.importedBlobs)
-
reasons.push("Not all blobs imported.");
+
console.log("Status check: Account statuses", {
+
old: oldStatus.data,
+
new: newStatus.data,
+
});
-
console.log("Status check: Step 2 not ready", { reasons });
-
return { ready: false, reason: reasons.join(", ") };
-
}
-
case "3": {
-
if (newStatus.data.validDid) {
-
console.log("Status check: Step 3 ready");
-
return { ready: true };
-
}
-
console.log("Status check: Step 3 not ready - DID not valid");
-
return { ready: false, reason: "DID not valid" };
-
}
-
case "4": {
-
if (newStatus.data.activated === true && oldStatus.data.activated === false) {
-
console.log("Status check: Step 4 ready");
-
return { ready: true };
-
}
-
console.log("Status check: Step 4 not ready - Account not activated");
-
return { ready: false, reason: "Account not activated" };
-
}
-
}
-
} else {
-
console.log("Status check: No step specified, returning ready");
-
return { ready: true };
+
const readyToContinue = () => {
+
if (!didsMatch) {
+
return {
+
ready: false,
+
reason: "Invalid state, original and target DIDs do not match",
+
};
+
}
+
if (step) {
+
console.log("Status check: Evaluating step", step);
+
switch (step) {
+
case "1": {
+
if (newStatus.data) {
+
console.log("Status check: Step 1 ready");
+
return { ready: true };
+
}
+
console.log(
+
"Status check: Step 1 not ready - new account status not available",
+
);
+
return { ready: false, reason: "New account status not available" };
+
}
+
case "2": {
+
const isReady = newStatus.data.repoCommit &&
+
newStatus.data.indexedRecords === oldStatus.data.indexedRecords &&
+
newStatus.data.privateStateValues ===
+
oldStatus.data.privateStateValues &&
+
newStatus.data.expectedBlobs === newStatus.data.importedBlobs &&
+
newStatus.data.importedBlobs === oldStatus.data.importedBlobs;
+
+
if (isReady) {
+
console.log("Status check: Step 2 ready");
+
return { ready: true };
+
}
+
+
const reasons = [];
+
if (!newStatus.data.repoCommit) {
+
reasons.push("Repository not imported.");
+
}
+
if (newStatus.data.indexedRecords < oldStatus.data.indexedRecords) {
+
reasons.push("Not all records imported.");
+
}
+
if (
+
newStatus.data.privateStateValues <
+
oldStatus.data.privateStateValues
+
) {
+
reasons.push("Not all private state values imported.");
+
}
+
if (newStatus.data.expectedBlobs !== newStatus.data.importedBlobs) {
+
reasons.push("Expected blobs not fully imported.");
+
}
+
if (newStatus.data.importedBlobs < oldStatus.data.importedBlobs) {
+
reasons.push("Not all blobs imported.");
+
}
+
+
console.log("Status check: Step 2 not ready", { reasons });
+
return { ready: false, reason: reasons.join(", ") };
+
}
+
case "3": {
+
if (newStatus.data.validDid) {
+
console.log("Status check: Step 3 ready");
+
return { ready: true };
+
}
+
console.log("Status check: Step 3 not ready - DID not valid");
+
return { ready: false, reason: "DID not valid" };
+
}
+
case "4": {
+
if (
+
newStatus.data.activated === true &&
+
oldStatus.data.activated === false
+
) {
+
console.log("Status check: Step 4 ready");
+
return { ready: true };
}
+
console.log(
+
"Status check: Step 4 not ready - Account not activated",
+
);
+
return { ready: false, reason: "Account not activated" };
+
}
}
+
} else {
+
console.log("Status check: No step specified, returning ready");
+
return { ready: true };
+
}
+
};
-
const status = {
-
activated: newStatus.data.activated,
-
validDid: newStatus.data.validDid,
-
repoCommit: newStatus.data.repoCommit,
-
repoRev: newStatus.data.repoRev,
-
repoBlocks: newStatus.data.repoBlocks,
-
expectedRecords: oldStatus.data.indexedRecords,
-
indexedRecords: newStatus.data.indexedRecords,
-
privateStateValues: newStatus.data.privateStateValues,
-
expectedBlobs: newStatus.data.expectedBlobs,
-
importedBlobs: newStatus.data.importedBlobs,
-
...readyToContinue()
-
}
+
const status = {
+
activated: newStatus.data.activated,
+
validDid: newStatus.data.validDid,
+
repoCommit: newStatus.data.repoCommit,
+
repoRev: newStatus.data.repoRev,
+
repoBlocks: newStatus.data.repoBlocks,
+
expectedRecords: oldStatus.data.indexedRecords,
+
indexedRecords: newStatus.data.indexedRecords,
+
privateStateValues: newStatus.data.privateStateValues,
+
expectedBlobs: newStatus.data.expectedBlobs,
+
importedBlobs: newStatus.data.importedBlobs,
+
...readyToContinue(),
+
};
-
console.log("Status check: Complete", status);
-
return Response.json(status);
-
}
-
})
+
console.log("Status check: Complete", status);
+
return Response.json(status);
+
},
+
});
+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",
+
},
+
}
+
);
+
}
+
},
+
});
+2 -17
routes/index.tsx
···
import Ticket from "../islands/Ticket.tsx";
import AirportSign from "../components/AirportSign.tsx";
import SocialLinks from "../islands/SocialLinks.tsx";
-
import { Button } from "../components/Button.tsx";
+
import LoginButton from "../islands/LoginButton.tsx";
export default function Home() {
return (
···
<p class="font-mono text-lg sm:text-xl font-bold mb-4 sm:mb-6 mt-0 text-center text-gray-600 dark:text-gray-300">
Your terminal for seamless AT Protocol PDS migration and backup.
</p>
-
<p class="font-mono mb-4 sm:mb-6 mt-0 text-center text-gray-600 dark:text-gray-300">
-
Airport is in <strong>alpha</strong> currently, and we don't recommend it for main accounts. <br/> Please use its migration tools at your own risk.
-
</p>
<Ticket />
-
<div class="mt-6 sm:mt-8 text-center w-fit mx-auto">
-
<Button
-
href="/login"
-
color="blue"
-
label="MOBILE NOT SUPPORTED"
-
className="opacity-50 cursor-not-allowed sm:opacity-100 sm:cursor-pointer"
-
onClick={(e: MouseEvent) => {
-
if (globalThis.innerWidth < 640) {
-
e.preventDefault();
-
}
-
}}
-
/>
-
</div>
+
<LoginButton />
<p class="font-mono text-lg sm:text-xl mb-4 mt-4 sm:mb-6 text-center text-gray-600 dark:text-gray-300">
Airport is made with love by <a class="text-blue-500 hover:underline" href="https://bsky.app/profile/knotbin.com">Roscoe</a> for <a class="text-blue-500 hover:underline" href="https://sprk.so">Spark</a>, a new short-video platform for AT Protocol.
</p>
+2 -2
routes/migrate/progress.tsx
···
if (!service || !handle || !email || !password) {
return (
-
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 p-4">
+
<div class="bg-gray-50 dark:bg-gray-900 p-4">
<div class="max-w-2xl mx-auto">
<div class="bg-red-50 dark:bg-red-900 p-4 rounded-lg">
<p class="text-red-800 dark:text-red-200">
···
}
return (
-
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 p-4">
+
<div class="bg-gray-50 dark:bg-gray-900 p-4">
<div class="max-w-2xl mx-auto">
<h1 class="font-mono text-3xl font-bold text-gray-900 dark:text-white mb-8">
Migration Progress