Graphical PDS migrator for AT Protocol

jsdoc

+6
components/AirportSign.tsx
···
+
/**
+
* The airport sign component, used on the landing page.
+
* Looks like a physical airport sign with a screen.
+
* @returns The airport sign component
+
* @component
+
*/
export default function AirportSign() {
return (
<div class="relative inline-block mb-8 sm:mb-12">
+10
components/Button.tsx
···
type ButtonProps = ButtonBaseProps & Omit<JSX.HTMLAttributes<HTMLButtonElement>, keyof ButtonBaseProps>;
type AnchorProps = ButtonBaseProps & Omit<JSX.HTMLAttributes<HTMLAnchorElement>, keyof ButtonBaseProps> & { href: string };
+
/**
+
* The button props or anchor props for a button or link.
+
* @type {Props}
+
*/
type Props = ButtonProps | AnchorProps;
+
/**
+
* Styled button component.
+
* @param props - The button props
+
* @returns The button component
+
* @component
+
*/
export function Button(props: Props) {
const { color = "blue", icon, iconAlt, label, className = "", condensed = false, ...rest } = props;
const isAnchor = 'href' in props;
+5
islands/CredLogin.tsx
···
import { useState } from "preact/hooks";
import { JSX } from "preact";
+
/**
+
* The credential login form.
+
* @returns The credential login form
+
* @component
+
*/
export default function CredLogin() {
const [handle, setHandle] = useState("");
const [password, setPassword] = useState("");
+5
islands/HandleInput.tsx
···
import { useState } from "preact/hooks";
import { JSX } from "preact";
+
/**
+
* The OAuth handle input form.
+
* @returns The handle input form
+
* @component
+
*/
export default function HandleInput() {
const [handle, setHandle] = useState("");
const [error, setError] = useState<string | null>(null);
+15
islands/Header.tsx
···
import { IS_BROWSER } from "fresh/runtime";
import { Button } from "../components/Button.tsx";
+
/**
+
* The user interface.
+
* @type {User}
+
*/
interface User {
did: string;
handle?: string;
}
+
/**
+
* Truncate text to a maximum length.
+
* @param text - The text to truncate
+
* @param maxLength - The maximum length
+
* @returns The truncated text
+
*/
function truncateText(text: string, maxLength: number) {
if (text.length <= maxLength) return text;
let truncated = text.slice(0, maxLength);
···
return truncated + "...";
}
+
/**
+
* The header component.
+
* @returns The header component
+
* @component
+
*/
export default function Header() {
const [user, setUser] = useState<User | null>(null);
const [showDropdown, setShowDropdown] = useState(false);
+5
islands/LoginSelector.tsx
···
import HandleInput from "./HandleInput.tsx"
import CredLogin from "./CredLogin.tsx"
+
/**
+
* The login method selector for OAuth or Credential.
+
* @returns The login method selector
+
* @component
+
*/
export default function LoginMethodSelector() {
const [loginMethod, setLoginMethod] = useState<'oauth' | 'password'>('password')
+15
islands/MigrationProgress.tsx
···
import { useEffect, useState } from "preact/hooks";
+
/**
+
* The migration progress props.
+
* @type {MigrationProgressProps}
+
*/
interface MigrationProgressProps {
service: string;
handle: string;
···
invite?: string;
}
+
/**
+
* The migration step.
+
* @type {MigrationStep}
+
*/
interface MigrationStep {
name: string;
status: "pending" | "in-progress" | "verifying" | "completed" | "error";
error?: string;
}
+
/**
+
* The migration progress component.
+
* @param props - The migration progress props
+
* @returns The migration progress component
+
* @component
+
*/
export default function MigrationProgress(props: MigrationProgressProps) {
const [token, setToken] = useState("");
···
Migration completed successfully! You can now close this page.
</p>
<button
+
type="button"
onClick={async () => {
try {
const response = await fetch("/api/logout", {
+18
islands/MigrationSetup.tsx
···
import { useState, useEffect } from "preact/hooks";
import { IS_BROWSER } from "fresh/runtime";
+
/**
+
* The migration setup props.
+
* @type {MigrationSetupProps}
+
*/
interface MigrationSetupProps {
service?: string | null;
handle?: string | null;
···
invite?: string | null;
}
+
/**
+
* The server description.
+
* @type {ServerDescription}
+
*/
interface ServerDescription {
inviteCodeRequired: boolean;
availableUserDomains: string[];
}
+
/**
+
* The user passport.
+
* @type {UserPassport}
+
*/
interface UserPassport {
did: string;
handle: string;
···
createdAt?: string;
}
+
/**
+
* The migration setup component.
+
* @param props - The migration setup props
+
* @returns The migration setup component
+
* @component
+
*/
export default function MigrationSetup(props: MigrationSetupProps) {
const [service, setService] = useState(props.service || "");
const [handlePrefix, setHandlePrefix] = useState(
+10
islands/OAuthCallback.tsx
···
import { useEffect, useState } from "preact/hooks";
import { IS_BROWSER } from "fresh/runtime";
+
/**
+
* The OAuth callback props.
+
* @type {OAuthCallbackProps}
+
*/
interface OAuthCallbackProps {
error?: string;
}
+
/**
+
* The OAuth callback component.
+
* @param props - The OAuth callback props
+
* @returns The OAuth callback component
+
* @component
+
*/
export default function OAuthCallback(
{ error: initialError }: OAuthCallbackProps,
) {
+9
islands/SocialLinks.tsx
···
import { useEffect, useState } from "preact/hooks";
import * as Icon from 'npm:preact-feather';
+
/**
+
* The GitHub repository.
+
* @type {GitHubRepo}
+
*/
interface GitHubRepo {
stargazers_count: number;
}
+
/**
+
* The social links component.
+
* @returns The social links component
+
* @component
+
*/
export default function SocialLinks() {
const [starCount, setStarCount] = useState<number | null>(null);
+9
islands/Ticket.tsx
···
import { useEffect, useState } from "preact/hooks";
import { IS_BROWSER } from "fresh/runtime";
+
/**
+
* The user interface for the ticket component.
+
* @type {User}
+
*/
interface User {
did: string;
handle?: string;
}
+
/**
+
* The ticket component for the landing page.
+
* @returns The ticket component
+
* @component
+
*/
export default function Ticket() {
const [user, setUser] = useState<User | null>(null);
+34
lib/cred/sessions.ts
···
let migrationSessionOptions: SessionOptions;
let credentialSessionOptions: SessionOptions;
+
/**
+
* Get the session options for the given request.
+
* @param isMigration - Whether to get the migration session options
+
* @returns The session options
+
*/
async function getOptions(isMigration: boolean) {
if (isMigration) {
if (!migrationSessionOptions) {
···
return credentialSessionOptions;
}
+
/**
+
* Get the credential session for the given request.
+
* @param req - The request object
+
* @param res - The response object
+
* @param isMigration - Whether to get the migration session
+
* @returns The credential session
+
*/
export async function getCredentialSession(
req: Request,
res: Response = new Response(),
···
);
}
+
/**
+
* Get the credential agent for the given request.
+
* @param req - The request object
+
* @param res - The response object
+
* @param isMigration - Whether to get the migration session
+
* @returns The credential agent
+
*/
export async function getCredentialAgent(
req: Request,
res: Response = new Response(),
···
}
}
+
/**
+
* Set the credential session for the given request.
+
* @param req - The request object
+
* @param res - The response object
+
* @param data - The credential session data
+
* @param isMigration - Whether to set the migration session
+
* @returns The credential session
+
*/
export async function setCredentialSession(
req: Request,
res: Response,
···
return session;
}
+
/**
+
* Get the credential session agent for the given request.
+
* @param req - The request object
+
* @param res - The response object
+
* @param isMigration - Whether to get the migration session
+
* @returns The credential session agent
+
*/
export async function getCredentialSessionAgent(
req: Request,
res: Response = new Response(),
+16
lib/id-resolver.ts
···
pds: string;
}
+
/**
+
* ID resolver instance.
+
*/
const idResolver = createIdResolver();
export const resolver = createBidirectionalResolver(idResolver);
+
/**
+
* Create the ID resolver.
+
* @returns The ID resolver
+
*/
export function createIdResolver() {
return new IdResolver();
}
+
/**
+
* The bidirectional resolver.
+
* @interface
+
*/
export interface BidirectionalResolver {
resolveDidToHandle(did: string): Promise<string>;
resolveDidsToHandles(dids: string[]): Promise<Record<string, string>>;
resolveDidToPdsUrl(did: string): Promise<string | undefined>;
}
+
/**
+
* Create the bidirectional resolver.
+
* @param resolver - The ID resolver
+
* @returns The bidirectional resolver
+
*/
export function createBidirectionalResolver(resolver: IdResolver) {
return {
async resolveDidToHandle(did: string): Promise<string> {
+5
lib/oauth/client.ts
···
import { AtprotoOAuthClient } from "@bigmoves/atproto-oauth-client";
import { SessionStore, StateStore } from "../storage.ts";
+
/**
+
* Create the OAuth client.
+
* @param db - The Deno KV instance for the database
+
* @returns The OAuth client
+
*/
export const createClient = (db: Deno.Kv) => {
if (Deno.env.get("NODE_ENV") == "production" && !Deno.env.get("PUBLIC_URL")) {
throw new Error("PUBLIC_URL is not set");
+15
lib/oauth/sessions.ts
···
let oauthSessionOptions: SessionOptions;
+
/**
+
* Get the OAuth session options.
+
* @returns The OAuth session options
+
*/
async function getOptions() {
if (!oauthSessionOptions) {
oauthSessionOptions = await createSessionOptions("oauth_sid");
···
return oauthSessionOptions;
}
+
/**
+
* Get the OAuth session agent for the given request.
+
* @param req - The request object
+
* @returns The OAuth session agent
+
*/
export async function getOauthSessionAgent(
req: Request
) {
···
}
}
+
/**
+
* Get the OAuth session for the given request.
+
* @param req - The request object
+
* @param res - The response object
+
* @returns The OAuth session
+
*/
export async function getOauthSession(
req: Request,
res: Response = new Response(),
+18
lib/sessions.ts
···
import { getOauthSession, getOauthSessionAgent } from "./oauth/sessions.ts";
import { IronSession } from "npm:iron-session";
+
/**
+
* Get the session for the given request.
+
* @param req - The request object
+
* @param res - The response object
+
* @param isMigration - Whether to get the migration session
+
* @returns The session
+
*/
export async function getSession(
req: Request,
res: Response = new Response(),
···
throw new Error("No session found");
}
+
/**
+
* Get the session agent for the given request.
+
* @param req - The request object
+
* @param res - The response object
+
* @param isMigration - Whether to get the migration session
+
* @returns The session agent
+
*/
export async function getSessionAgent(
req: Request,
res: Response = new Response(),
···
return null;
}
+
/**
+
* Destroy all sessions for the given request.
+
* @param req - The request object
+
*/
export async function destroyAllSessions(req: Request) {
const oauthSession = await getOauthSession(req);
const credentialSession = await getCredentialSession(req);
+8
lib/storage.ts
···
NodeSavedStateStore,
} from "jsr:@bigmoves/atproto-oauth-client";
+
/**
+
* The state store for sessions.
+
* @implements {NodeSavedStateStore}
+
*/
export class StateStore implements NodeSavedStateStore {
constructor(private db: Deno.Kv) {}
async get(key: string): Promise<NodeSavedState | undefined> {
···
}
}
+
/**
+
* The session store for sessions.
+
* @implements {NodeSavedSessionStore}
+
*/
export class SessionStore implements NodeSavedSessionStore {
constructor(private db: Deno.Kv) {}
async get(key: string): Promise<NodeSavedSession | undefined> {
+24 -1
lib/types.ts
···
import { SessionOptions as BaseSessionOptions } from "npm:iron-session";
+
/**
+
* The session options.
+
* @type {SessionOptions}
+
* @implements {BaseSessionOptions}
+
*/
interface SessionOptions extends BaseSessionOptions {
lockFn?: (key: string) => Promise<() => Promise<void>>;
}
-
// Helper function to create a lock using Deno KV
+
/**
+
* Create a lock using Deno KV.
+
* @param key - The key to lock
+
* @param db - The Deno KV instance for the database
+
* @returns The unlock function
+
*/
async function createLock(key: string, db: Deno.Kv): Promise<() => Promise<void>> {
const lockKey = ["session_lock", key];
const lockValue = Date.now();
···
};
}
+
/**
+
* The OAuth session.
+
* @type {OauthSession}
+
*/
export interface OauthSession {
did: string
}
+
/**
+
* The credential session.
+
* @type {CredentialSession}
+
*/
export interface CredentialSession {
did: string;
handle: string;
···
let db: Deno.Kv;
+
/**
+
* Create the session options.
+
* @param cookieName - The name of the iron session cookie
+
* @returns The session options for iron session
+
*/
export const createSessionOptions = async (cookieName: string): Promise<SessionOptions> => {
const cookieSecret = Deno.env.get("COOKIE_SECRET");
if (!cookieSecret) {
+7
routes/api/cred/login.ts
···
import { define } from "../../../utils.ts";
import { Agent } from "npm:@atproto/api";
+
/**
+
* Handle credential login
+
* Save iron session to cookies
+
* Save credential session state to database
+
* @param ctx - The context object containing the request and response
+
* @returns A response object with the login result
+
*/
export const handler = define.handlers({
async POST(ctx) {
try {
+12
routes/api/migrate/create.ts
···
import { Agent } from "@atproto/api";
import { define } from "../../../utils.ts";
+
/**
+
* Handle account creation
+
* First step of the migration process
+
* Body must contain:
+
* - service: The service URL of the new account
+
* - handle: The handle of the new account
+
* - password: The password of the new account
+
* - email: The email of the new account
+
* - invite: The invite code of the new account (optional depending on the PDS)
+
* @param ctx - The context object containing the request and response
+
* @returns A response object with the creation result
+
*/
export const handler = define.handlers({
async POST(ctx) {
const res = new Response();
+24
routes/api/migrate/data.ts
···
const MAX_RETRIES = 3;
const INITIAL_RETRY_DELAY = 1000; // 1 second
+
/**
+
* Retry options
+
* @param maxRetries - The maximum number of retries
+
* @param initialDelay - The initial delay between retries
+
* @param onRetry - The function to call on retry
+
*/
interface RetryOptions {
maxRetries?: number;
initialDelay?: number;
onRetry?: (attempt: number, error: Error) => void;
}
+
/**
+
* Retry function with exponential backoff
+
* @param operation - The operation to retry
+
* @param options - The retry options
+
* @returns The result of the operation
+
*/
async function withRetry<T>(
operation: () => Promise<T>,
options: RetryOptions = {},
···
throw lastError ?? new Error("Operation failed after retries");
}
+
/**
+
* Handle blob upload to new PDS
+
* Retries on errors
+
* @param newAgent - The new agent
+
* @param blobRes - The blob response
+
* @param cid - The CID of the blob
+
*/
async function handleBlobUpload(
newAgent: Agent,
blobRes: ComAtprotoSyncGetBlob.Response,
···
}
}
+
/**
+
* Handle data migration
+
* @param ctx - The context object containing the request and response
+
* @returns A response object with the migration result
+
*/
export const handler = define.handlers({
async POST(ctx) {
const res = new Response();
+7
routes/api/migrate/identity/request.ts
···
} from "../../../../lib/sessions.ts";
import { define } from "../../../../utils.ts";
+
/**
+
* Handle identity migration request
+
* Sends a PLC operation signature request to the old account's email
+
* Should be called after all data is migrated to the new account
+
* @param ctx - The context object containing the request and response
+
* @returns A response object with the migration result
+
*/
export const handler = define.handlers({
async POST(ctx) {
const res = new Response();
+7
routes/api/migrate/identity/sign.ts
···
import * as ui8 from "npm:uint8arrays";
import { define } from "../../../../utils.ts";
+
/**
+
* Handle identity migration sign
+
* Should be called after user receives the migration token via email
+
* URL params must contain the token
+
* @param ctx - The context object containing the request with the token in the URL params
+
* @returns A response object with the migration result
+
*/
export const handler = define.handlers({
async POST(ctx) {
const res = new Response();