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
+65
components/Link.tsx
···
+
import { JSX } from "preact";
+
+
/**
+
* Props for the Link component
+
*/
+
type Props = Omit<JSX.HTMLAttributes<HTMLAnchorElement>, "href"> & {
+
/** URL for the link */
+
href: string;
+
/** Whether this is an external link that should show an outbound icon */
+
isExternal?: boolean;
+
/** Link text content */
+
children: JSX.Element | string;
+
};
+
+
/**
+
* A link component that handles external links with appropriate styling and accessibility.
+
* Automatically adds external link icon and proper attributes for external links.
+
*/
+
export function Link(props: Props) {
+
const {
+
isExternal = false,
+
class: className = "",
+
children,
+
href,
+
...rest
+
} = props;
+
+
// SVG for external link icon
+
const externalLinkIcon = (
+
<svg
+
xmlns="http://www.w3.org/2000/svg"
+
viewBox="0 0 20 20"
+
fill="currentColor"
+
className="w-4 h-4 inline-block ml-1"
+
aria-hidden="true"
+
>
+
<path
+
fillRule="evenodd"
+
d="M4.25 5.5a.75.75 0 00-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 00.75-.75v-4a.75.75 0 011.5 0v4A2.25 2.25 0 0112.75 17h-8.5A2.25 2.25 0 012 14.75v-8.5A2.25 2.25 0 014.25 4h5a.75.75 0 010 1.5h-5z"
+
/>
+
<path
+
fillRule="evenodd"
+
d="M6.194 12.753a.75.75 0 001.06.053L16.5 4.44v2.81a.75.75 0 001.5 0v-4.5a.75.75 0 00-.75-.75h-4.5a.75.75 0 000 1.5h2.553l-9.056 8.194a.75.75 0 00-.053 1.06z"
+
/>
+
</svg>
+
);
+
+
return (
+
<a
+
href={href}
+
{...rest}
+
className={`inline-flex items-center hover:underline ${className}`}
+
{...(isExternal && {
+
target: "_blank",
+
rel: "noopener noreferrer",
+
"aria-label": `${
+
typeof children === "string" ? children : ""
+
} (opens in new tab)`,
+
})}
+
>
+
{children}
+
{isExternal && externalLinkIcon}
+
</a>
+
);
+
}
+267 -80
islands/DidPlcProgress.tsx
···
-
import { useState } from "preact/hooks";
+
import { useState, useEffect } from "preact/hooks";
+
import { Link } from "../components/Link.tsx";
interface PlcUpdateStep {
name: string;
···
error?: string;
}
+
// Content chunks for the description
+
const contentChunks = [
+
{
+
title: "Welcome to Key Management",
+
subtitle: "BOARDING PASS - SECTION A",
+
content: (
+
<>
+
<div class="passenger-info text-slate-600 dark:text-slate-300 font-mono text-sm mb-4">
+
GATE: KEY-01 โ€ข SEAT: DID-1A
+
</div>
+
<p class="text-slate-700 dark:text-slate-300 mb-4">
+
This tool helps you add a new rotation key to your{" "}
+
<Link
+
href="https://web.plc.directory/"
+
isExternal
+
class="text-blue-600 dark:text-blue-400"
+
>
+
PLC (Public Ledger of Credentials)
+
</Link>
+
. Having control of a rotation key gives you sovereignty over your DID
+
(Decentralized Identifier).
+
</p>
+
</>
+
),
+
},
+
{
+
title: "Key Benefits",
+
subtitle: "BOARDING PASS - SECTION B",
+
content: (
+
<>
+
<div class="passenger-info text-slate-600 dark:text-slate-300 font-mono text-sm mb-4">
+
GATE: KEY-02 โ€ข SEAT: DID-1B
+
</div>
+
<div class="space-y-4">
+
<div class="p-3 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
+
<h4 class="font-mono font-bold text-amber-500 dark:text-amber-400 mb-2">
+
PROVIDER MOBILITY โœˆ๏ธ
+
</h4>
+
<p class="text-slate-700 dark:text-slate-300">
+
Change your PDS without losing your identity, protecting you if
+
your provider becomes hostile.
+
</p>
+
</div>
+
<div class="p-3 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
+
<h4 class="font-mono font-bold text-amber-500 dark:text-amber-400 mb-2">
+
IDENTITY CONTROL โœจ
+
</h4>
+
<p class="text-slate-700 dark:text-slate-300">
+
Modify your DID document independently of your provider.
+
</p>
+
</div>
+
<div class="p-3 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
+
<p class="text-slate-700 dark:text-slate-300">
+
๐Ÿ’ก It's good practice to have a rotation key so you can move to a
+
different provider if you need to.
+
</p>
+
</div>
+
</div>
+
</>
+
),
+
},
+
{
+
title: "โš ๏ธ CRITICAL SECURITY WARNING",
+
subtitle: "BOARDING PASS - SECTION C",
+
content: (
+
<>
+
<div class="passenger-info text-slate-600 dark:text-slate-300 font-mono text-sm mb-4">
+
GATE: KEY-03 โ€ข SEAT: DID-1C
+
</div>
+
<div class="p-4 bg-red-50 dark:bg-red-900 rounded-lg border-2 border-red-500 dark:border-red-700 mb-4">
+
<div class="flex items-center mb-3">
+
<span class="text-2xl mr-2">โš ๏ธ</span>
+
<h4 class="font-mono font-bold text-red-700 dark:text-red-400 text-lg">
+
NON-REVOCABLE KEY WARNING
+
</h4>
+
</div>
+
<div class="space-y-3 text-red-700 dark:text-red-300">
+
<p class="font-bold">
+
This rotation key CANNOT BE DISABLED OR DELETED once added:
+
</p>
+
<ul class="list-disc pl-5 space-y-2">
+
<li>
+
If compromised, the attacker can take complete control of your
+
account and identity
+
</li>
+
<li>
+
Malicious actors with this key have COMPLETE CONTROL of your
+
account and identity
+
</li>
+
<li>
+
Store securely, like a password (e.g. <strong>DO NOT</strong>{" "}
+
keep it in Notes or any easily accessible app on an unlocked
+
device).
+
</li>
+
</ul>
+
</div>
+
</div>
+
<div class="p-3 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
+
<p class="text-slate-700 dark:text-slate-300">
+
๐Ÿ’ก We recommend adding a custom rotation key but recommend{" "}
+
<strong class="italic">against</strong> having more than one custom
+
rotation key, as more than one increases risk.
+
</p>
+
</div>
+
</>
+
),
+
},
+
{
+
title: "Technical Overview",
+
subtitle: "BOARDING PASS - SECTION C",
+
content: (
+
<>
+
<div class="passenger-info text-slate-600 dark:text-slate-300 font-mono text-sm mb-4">
+
GATE: KEY-03 โ€ข SEAT: DID-1C
+
</div>
+
<div class="p-4 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
+
<div class="flex items-center mb-3">
+
<span class="text-lg mr-2">๐Ÿ“</span>
+
<h4 class="font-mono font-bold text-amber-500 dark:text-amber-400">
+
TECHNICAL DETAILS
+
</h4>
+
</div>
+
<p class="text-slate-700 dark:text-slate-300">
+
The rotation key is a did:key that will be added to your PLC
+
document's rotationKeys array. This process uses the AT Protocol's
+
PLC operations to update your DID document.
+
<Link
+
href="https://web.plc.directory/"
+
class="block ml-1 text-blue-600 dark:text-blue-400"
+
isExternal
+
>
+
Learn more about did:plc
+
</Link>
+
</p>
+
</div>
+
</>
+
),
+
},
+
];
+
export default function PlcUpdateProgress() {
const [hasStarted, setHasStarted] = useState(false);
+
const [currentChunkIndex, setCurrentChunkIndex] = useState(0);
const [steps, setSteps] = useState<PlcUpdateStep[]>([
{ name: "Generate Rotation Key", status: "pending" },
{ name: "Start PLC update", status: "pending" },
···
return;
}
-
updateStepStatus(1, "in-progress");
+
updateStepStatus(1, "completed");
try {
+
updateStepStatus(2, "in-progress");
console.log("Submitting update request with token...");
// Send the update request with both key and token
const res = await fetch("/api/plc/update", {
···
setUpdateResult("PLC update completed successfully!");
// Add a delay before marking steps as completed for better UX
-
updateStepStatus(1, "verifying");
-
updateStepStatus(2, "in-progress");
+
updateStepStatus(2, "verifying");
+
+
const verifyRes = await fetch("/api/plc/verify", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({
+
key: keyJson.publicKeyDid,
+
}),
+
});
+
+
const verifyText = await verifyRes.text();
+
console.log("Verification response:", verifyText);
+
+
let verifyData;
+
try {
+
verifyData = JSON.parse(verifyText);
+
} catch {
+
throw new Error("Invalid verification response from server");
+
}
-
await new Promise((resolve) => setTimeout(resolve, 1500));
-
updateStepStatus(1, "completed");
+
if (!verifyRes.ok || !verifyData.success) {
+
const errorMessage =
+
verifyData.message || "Failed to verify PLC update";
+
console.error("Verification failed:", errorMessage);
+
throw new Error(errorMessage);
+
}
-
await new Promise((resolve) => setTimeout(resolve, 1000));
+
console.log("Verification successful, marking steps as completed");
updateStepStatus(2, "completed");
} catch (error) {
console.error("Update failed:", error);
···
if (!hasStarted) {
return (
<div class="space-y-6">
-
<div class="bg-blue-50 dark:bg-blue-900 p-6 rounded-lg border border-blue-200 dark:border-blue-800">
-
<h3 class="text-lg font-semibold text-blue-900 dark:text-blue-100 mb-4">
-
DID Rotation Key Management
-
</h3>
+
<div class="ticket bg-white dark:bg-slate-800 p-6 relative">
+
<div class="boarding-label text-amber-500 dark:text-amber-400 font-mono font-bold tracking-wider text-sm mb-2">
+
{contentChunks[currentChunkIndex].subtitle}
+
</div>
+
+
<div class="flex justify-between items-start mb-4">
+
<h3 class="text-2xl font-mono text-slate-800 dark:text-slate-200">
+
{contentChunks[currentChunkIndex].title}
+
</h3>
+
</div>
{/* Main Description */}
-
<div class="prose dark:prose-invert max-w-none mb-6">
-
<p class="text-blue-800 dark:text-blue-200 mb-4">
-
This tool helps you add a new rotation key to your PLC (Public
-
Ledger of Credentials). Having control of a rotation key gives you
-
sovereignty over your DID (Decentralized Identifier).
-
</p>
+
<div class="mb-6">{contentChunks[currentChunkIndex].content}</div>
-
<h4 class="text-blue-900 dark:text-blue-100 font-medium mt-4 mb-2">
-
What you can do with a rotation key:
-
</h4>
-
<ul class="space-y-2 text-sm text-blue-700 dark:text-blue-300 list-disc pl-5">
-
<li>
-
<span class="font-medium">Move to a different provider:</span>
-
<br />
-
Change your PDS without losing your identity, protecting you if
-
your provider becomes hostile.
-
</li>
-
<li>
-
<span class="font-medium">Direct DID control:</span>
-
<br />
-
Modify your DID document independently of your provider.
-
</li>
-
</ul>
+
{/* Navigation */}
+
<div class="mt-8 border-t border-dashed border-slate-200 dark:border-slate-700 pt-4">
+
<div class="flex justify-between items-center">
+
<button
+
onClick={() =>
+
setCurrentChunkIndex((prev) => Math.max(0, prev - 1))
+
}
+
class={`px-4 py-2 font-mono text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 transition-colors duration-200 flex items-center space-x-2 ${
+
currentChunkIndex === 0 ? "invisible" : ""
+
}`}
+
>
+
<svg
+
class="w-5 h-5 rotate-180"
+
fill="none"
+
stroke="currentColor"
+
viewBox="0 0 24 24"
+
>
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2"
+
d="M9 5l7 7-7 7"
+
/>
+
</svg>
+
<span>Previous Gate</span>
+
</button>
-
<h4 class="text-blue-900 dark:text-blue-100 font-medium mt-6 mb-2">
-
Process Overview:
-
</h4>
-
<ol class="space-y-2 text-sm text-blue-700 dark:text-blue-300 list-decimal pl-5">
-
<li>Generate a secure rotation key</li>
-
<li>Download and safely store the key</li>
-
<li>Verify your identity via email</li>
-
<li>Add the key to your PLC record</li>
-
</ol>
-
</div>
+
{currentChunkIndex === contentChunks.length - 1 ? (
+
<button
+
onClick={handleStart}
+
class="px-6 py-2 bg-amber-500 hover:bg-amber-600 text-white font-mono rounded-md transition-colors duration-200 flex items-center space-x-2"
+
>
+
<span>Begin Key Generation</span>
+
<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="M9 5l7 7-7 7"
+
/>
+
</svg>
+
</button>
+
) : (
+
<button
+
onClick={() =>
+
setCurrentChunkIndex((prev) =>
+
Math.min(contentChunks.length - 1, prev + 1)
+
)
+
}
+
class="px-4 py-2 font-mono text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 transition-colors duration-200 flex items-center space-x-2"
+
>
+
<span>Next Gate</span>
+
<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="M9 5l7 7-7 7"
+
/>
+
</svg>
+
</button>
+
)}
+
</div>
-
{/* Technical Note for Developers */}
-
<div class="mt-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
-
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">
-
๐Ÿ“ Technical Note
-
</h4>
-
<p class="text-sm text-gray-600 dark:text-gray-400">
-
The rotation key is a did:key that will be added to your PLC
-
record's rotationKeys array. This process uses the ATP PLC
-
operations to update your DID document.
-
<a
-
href="https://atproto.com/specs/did-plc"
-
target="_blank"
-
class="text-blue-600 dark:text-blue-400 hover:underline ml-1"
-
>
-
Learn more about PLC DIDs โ†’
-
</a>
-
</p>
+
{/* Progress Dots */}
+
<div class="flex justify-center space-x-3 mt-4">
+
{contentChunks.map((_, index) => (
+
<div
+
key={index}
+
class={`h-1.5 w-8 rounded-full transition-colors duration-200 ${
+
index === currentChunkIndex
+
? "bg-amber-500"
+
: "bg-slate-200 dark:bg-slate-700"
+
}`}
+
/>
+
))}
+
</div>
</div>
-
-
<button
-
onClick={handleStart}
-
class="mt-6 px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors duration-200 flex items-center space-x-2"
-
>
-
<span>Start Key Generation</span>
-
<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="M9 5l7 7-7 7"
-
/>
-
</svg>
-
</button>
</div>
</div>
);
+15 -6
islands/Header.tsx
···
setUser(
userData
? {
-
did: userData.did,
-
handle: userData.handle,
-
}
-
: null,
+
did: userData.did,
+
handle: userData.handle,
+
}
+
: null
);
} catch (error) {
console.error("Failed to fetch user:", error);
···
/>
<div className="flex items-center gap-3">
+
{/* Ticket booth (did:plc update) */}
+
<Button
+
href="/ticket-booth"
+
color="amber"
+
icon="/icons/ticket_bold.svg"
+
iconAlt="Ticket"
+
label="TICKET BOOTH"
+
/>
+
{/* Departures (Migration) */}
<Button
href="/migrate"
···
<div className="relative">
<Button
color="amber"
-
icon="/icons/ticket_bold.svg"
+
icon="/icons/account.svg"
iconAlt="Check-in"
label="CHECKED IN"
onClick={() => setShowDropdown(!showDropdown)}
···
<Button
href="/login"
color="amber"
-
icon="/icons/ticket_bold.svg"
+
icon="/icons/account.svg"
iconAlt="Check-in"
label="CHECK-IN"
/>
+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.
+15 -7
islands/Ticket.tsx
···
import { useEffect, useState } from "preact/hooks";
import { IS_BROWSER } from "fresh/runtime";
+
import { Link } from "../components/Link.tsx";
/**
* The user interface for the ticket component.
···
setUser(
userData
? {
-
did: userData.did,
-
handle: userData.handle,
-
}
-
: null,
+
did: userData.did,
+
handle: userData.handle,
+
}
+
: null
);
} catch (error) {
console.error("Failed to fetch user:", error);
···
</p>
<p>
Think you might need to migrate in the future but your PDS might be
-
hostile or offline? No worries! Soon you'll be able to go to the
-
ticket booth and get a PLC key to use for account recovery in the
-
future. You can also go to baggage claim (take the air shuttle to
+
hostile or offline? No worries! You can go to the{" "}
+
<Link
+
href="/ticket-booth"
+
isExternal
+
class="text-blue-600 dark:text-blue-400"
+
>
+
ticket booth
+
</Link>{" "}
+
and get a PLC key to use for account recovery in the future. Soon
+
you'll also be able to go to baggage claim (take the air shuttle to
terminal four) and get a downloadable backup of all your current PDS
data in case that were to happen.
</p>
+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",
+
},
+
}
+
);
+
}
+
},
+
});
+36 -10
routes/api/plc/update.ts
···
import { getSessionAgent } from "../../../lib/sessions.ts";
-
import { setCredentialSession } from "../../../lib/cred/sessions.ts";
-
import { Agent } from "@atproto/api";
import { define } from "../../../utils.ts";
+
import * as plc from "@did-plc/lib";
/**
* Handle PLC update operation
···
return new Response("Unauthorized", { status: 401 });
}
+
const session = await agent.com.atproto.server.getSession();
+
const did = session.data.did;
+
if (!did) {
+
console.log("No DID found in session");
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "No DID found in your session",
+
}),
+
{
+
status: 400,
+
headers: { "Content-Type": "application/json" },
+
}
+
);
+
}
+
console.log("Using agent DID:", did);
+
// Get recommended credentials first
-
console.log("Getting recommended credentials...");
-
const getDidCredentials =
-
await agent.com.atproto.identity.getRecommendedDidCredentials();
-
console.log("Got recommended credentials:", {
-
rotationKeys: getDidCredentials.data.rotationKeys,
-
hasRotationKeys: !!getDidCredentials.data.rotationKeys?.length,
-
});
+
console.log("Getting did:plc document...");
+
const plcClient = new plc.Client("https://plc.directory");
+
const didDoc = await plcClient.getDocumentData(did);
+
if (!didDoc) {
+
console.log("No DID document found for agent DID");
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "No DID document found for your account",
+
}),
+
{
+
status: 400,
+
headers: { "Content-Type": "application/json" },
+
}
+
);
+
}
+
console.log("Got DID document:", didDoc);
-
const rotationKeys = getDidCredentials.data.rotationKeys ?? [];
+
const rotationKeys = didDoc.rotationKeys ?? [];
if (!rotationKeys.length) {
console.log("No existing rotation keys found");
throw new Error("No rotation keys provided in recommended credentials");
+131
routes/api/plc/verify.ts
···
+
import { getSessionAgent } from "../../../lib/sessions.ts";
+
import { define } from "../../../utils.ts";
+
import * as plc from "@did-plc/lib";
+
+
/**
+
* Verify if a rotation key exists in the PLC document
+
* Body must contain:
+
* - key: The rotation key to verify
+
* @param ctx - The context object containing the request and response
+
* @returns A response object with the verification result
+
*/
+
export const handler = define.handlers({
+
async POST(ctx) {
+
const res = new Response();
+
try {
+
const body = await ctx.req.json();
+
const { key: newKey } = body;
+
console.log("Request body:", { newKey });
+
+
if (!newKey) {
+
console.log("Missing key in request");
+
return new Response("Missing param key in request body", {
+
status: 400,
+
});
+
}
+
+
const agent = await getSessionAgent(ctx.req, res);
+
if (!agent) {
+
console.log("No agent found");
+
return new Response("Unauthorized", { status: 401 });
+
}
+
+
const session = await agent.com.atproto.server.getSession();
+
const did = session.data.did;
+
if (!did) {
+
console.log("No DID found in session");
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "No DID found in your session",
+
}),
+
{
+
status: 400,
+
headers: { "Content-Type": "application/json" },
+
}
+
);
+
}
+
console.log("Using agent DID:", did);
+
+
// Fetch the PLC document to check rotation keys
+
console.log("Getting did:plc document...");
+
const plcClient = new plc.Client("https://plc.directory");
+
const didDoc = await plcClient.getDocumentData(did);
+
if (!didDoc) {
+
console.log("No DID document found for agent DID");
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "No DID document found for your account",
+
}),
+
{
+
status: 400,
+
headers: { "Content-Type": "application/json" },
+
}
+
);
+
}
+
console.log("Got DID document:", didDoc);
+
+
const rotationKeys = didDoc.rotationKeys ?? [];
+
if (!rotationKeys.length) {
+
console.log("No existing rotation keys found");
+
throw new Error("No rotation keys found in did:plc document");
+
}
+
+
// Check if the key exists in rotation keys
+
if (rotationKeys.includes(newKey)) {
+
return new Response(
+
JSON.stringify({
+
success: true,
+
message: "Rotation key exists in PLC document",
+
}),
+
{
+
status: 200,
+
headers: {
+
"Content-Type": "application/json",
+
...Object.fromEntries(res.headers), // Include session cookie headers
+
},
+
}
+
);
+
}
+
+
// If we get here, the key was not found
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "Rotation key not found in PLC document",
+
}),
+
{
+
status: 404,
+
headers: { "Content-Type": "application/json" },
+
}
+
);
+
} catch (error) {
+
console.error("PLC verification error:", error);
+
const errorMessage =
+
error instanceof Error
+
? error.message
+
: "Failed to verify rotation key";
+
console.log("Sending error response:", errorMessage);
+
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: errorMessage,
+
error:
+
error instanceof Error
+
? {
+
name: error.name,
+
message: error.message,
+
stack: error.stack,
+
}
+
: String(error),
+
}),
+
{
+
status: 400,
+
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
+4
static/icons/account.svg
···
+
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#e3e3e3">
+
<path d="M0 0h24v24H0z" fill="none"/>
+
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
+
</svg>