Graphical PDS migrator for AT Protocol

one day i will die

-112
auth/creds/sessions.ts
···
-
import { Agent } from "npm:@atproto/api";
-
import { getIronSession, SessionOptions } from "npm:iron-session";
-
import { CredentialSession, createSessionOptions } from "../types.ts";
-
-
const migrationSessionOptions = createSessionOptions("migration_sid");
-
const credentialSessionOptions = createSessionOptions("cred_sid");
-
-
export function getCredentialSession(
-
req: Request,
-
res: Response = new Response(),
-
options: SessionOptions
-
) {
-
return getIronSession<CredentialSession>(
-
req,
-
res,
-
options,
-
);
-
}
-
-
export async function getCredentialAgent(
-
req: Request,
-
res: Response = new Response(),
-
isMigration: boolean = false,
-
) {
-
const session = await getCredentialSession(
-
req,
-
res,
-
isMigration ?
-
migrationSessionOptions : credentialSessionOptions
-
);
-
if (!session.did || !session.service) {
-
return null;
-
}
-
-
try {
-
return new Agent({
-
service: session.service,
-
});
-
} catch (err) {
-
console.warn("Failed to create migration agent:", err);
-
session.destroy();
-
return null;
-
}
-
}
-
-
export async function setCredentialSession(
-
req: Request,
-
res: Response,
-
data: CredentialSession,
-
isMigration: boolean = false,
-
) {
-
const session = await getCredentialSession(
-
req,
-
res,
-
isMigration ?
-
migrationSessionOptions : credentialSessionOptions
-
);
-
session.did = data.did;
-
session.handle = data.handle;
-
session.service = data.service;
-
session.password = data.password;
-
await session.save();
-
return session;
-
}
-
-
export async function getCredentialSessionAgent(
-
req: Request,
-
res: Response = new Response(),
-
isMigration: boolean = false,
-
) {
-
const session = await getCredentialSession(
-
req,
-
res,
-
isMigration ?
-
migrationSessionOptions : credentialSessionOptions
-
);
-
-
if (
-
!session.did || !session.service || !session.handle || !session.password
-
) {
-
return null;
-
}
-
-
try {
-
console.log("Creating agent with service:", session.service);
-
const agent = new Agent({ service: session.service });
-
-
// Attempt to restore session by creating a new one
-
try {
-
console.log("Attempting to create session for:", session.handle);
-
const sessionRes = await agent.com.atproto.server.createSession({
-
identifier: session.handle,
-
password: session.password,
-
});
-
console.log("Session created successfully:", !!sessionRes);
-
-
// Set the auth tokens in the agent
-
agent.setHeader("Authorization", `Bearer ${sessionRes.data.accessJwt}`);
-
-
return agent;
-
} catch (err) {
-
// Session creation failed, clear the session
-
console.error("Failed to create session:", err);
-
session.destroy();
-
return null;
-
}
-
} catch (err) {
-
console.warn("Failed to create migration agent:", err);
-
session.destroy();
-
return null;
-
}
-
}
auth/oauth/client.ts lib/oauth/client.ts
auth/oauth/sessions.ts lib/oauth/sessions.ts
-51
auth/sessions.ts
···
-
import { Agent } from "npm:@atproto/api";
-
import { OauthSession, CredentialSession, createSessionOptions } from "./types.ts";
-
import { getCredentialSession, getCredentialSessionAgent } from "./creds/sessions.ts";
-
import { getOauthSession, getOauthSessionAgent } from "./oauth/sessions.ts";
-
import { IronSession } from "npm:iron-session";
-
-
const migrationSessionOptions = createSessionOptions("migration_sid");
-
const credentialSessionOptions = createSessionOptions("cred_sid");
-
-
export async function getSession(
-
req: Request,
-
res: Response = new Response(),
-
isMigration: boolean = false
-
): Promise<IronSession<OauthSession | CredentialSession>> {
-
if (isMigration) {
-
return await getCredentialSession(req, res, migrationSessionOptions);
-
}
-
const oauthSession = await getOauthSession(req);
-
const credentialSession = await getCredentialSession(req, res, credentialSessionOptions);
-
-
if (oauthSession) {
-
console.log("Oauth session found")
-
return oauthSession;
-
}
-
if (credentialSession) {
-
return credentialSession;
-
}
-
-
throw new Error("No session found");
-
}
-
-
export async function getSessionAgent(
-
req: Request,
-
res: Response = new Response(),
-
isMigration: boolean = false
-
): Promise<Agent | null> {
-
if (isMigration) {
-
return await getCredentialSessionAgent(req, res, isMigration);
-
}
-
-
const oauthAgent = await getOauthSessionAgent(req);
-
if (oauthAgent) {
-
return oauthAgent;
-
}
-
const credentialAgent = await getCredentialSessionAgent(req, res, isMigration);
-
if (credentialAgent) {
-
return credentialAgent;
-
}
-
-
return null;
-
}
auth/storage.ts lib/storage.ts
+8 -12
auth/types.ts lib/types.ts
···
import { SessionOptions } from "npm:iron-session";
+
import { AtpSessionData } from "@atproto/api";
export interface OauthSession {
did: string
}
-
export interface CredentialSession {
-
did: string;
-
handle: string;
-
service: string;
-
password: string;
-
recoveryKey?: string;
-
recoveryKeyDid?: string;
-
credentials?: {
-
rotationKeys: string[];
-
[key: string]: unknown;
-
};
+
export type Credentials = {
+
service: string,
+
did: string,
+
password: string
}
+
+
export type CredSession = AtpSessionData & { service: string }
export const createSessionOptions = (cookieName: string): SessionOptions => {
const cookieSecret = Deno.env.get("COOKIE_SECRET");
···
domain: undefined,
},
}
-
};
+
};
+124
islands/CredLogin.tsx
···
+
import { useState } from 'preact/hooks'
+
import { JSX } from 'preact'
+
+
export default function CredLogin() {
+
const [handle, setHandle] = useState('')
+
const [password, setPassword] = useState('')
+
const [error, setError] = useState<string | null>(null)
+
const [isPending, setIsPending] = useState(false)
+
+
const handleSubmit = async (e: JSX.TargetedEvent<HTMLFormElement>) => {
+
e.preventDefault()
+
if (!handle.trim() || !password.trim()) return
+
+
setError(null)
+
setIsPending(true)
+
+
try {
+
const response = await fetch('/api/cred/login', {
+
method: 'POST',
+
headers: {
+
'Content-Type': 'application/json',
+
},
+
body: JSON.stringify({ handle, password }),
+
})
+
+
if (!response.ok) {
+
const errorText = await response.text()
+
throw new Error(errorText || 'Login failed')
+
}
+
+
// Add a small delay before redirecting for better UX
+
await new Promise((resolve) => setTimeout(resolve, 500))
+
+
// Redirect to home page after successful login
+
globalThis.location.href = '/'
+
} catch (err) {
+
const message = err instanceof Error ? err.message : 'Login failed'
+
setError(message)
+
} finally {
+
setIsPending(false)
+
}
+
}
+
+
return (
+
<form onSubmit={handleSubmit}>
+
{error && (
+
<div className="text-red-500 mb-4 p-2 bg-red-50 dark:bg-red-950 dark:bg-opacity-30 rounded-md">
+
{error}
+
</div>
+
)}
+
+
<div className="mb-4">
+
<label
+
htmlFor="handle"
+
className="block mb-2 text-gray-700 dark:text-gray-300"
+
>
+
Enter your Bluesky handle:
+
</label>
+
<input
+
id="handle"
+
type="text"
+
value={handle}
+
onInput={(e) => setHandle((e.target as HTMLInputElement).value)}
+
placeholder="example.bsky.social"
+
disabled={isPending}
+
className="w-full p-3 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-500 transition-colors"
+
/>
+
</div>
+
+
<div className="mb-4">
+
<label
+
htmlFor="password"
+
className="block mb-2 text-gray-700 dark:text-gray-300"
+
>
+
Password:
+
</label>
+
<input
+
id="password"
+
type="password"
+
value={password}
+
onInput={(e) => setPassword((e.target as HTMLInputElement).value)}
+
placeholder="Enter your password"
+
disabled={isPending}
+
className="w-full p-3 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-500 transition-colors"
+
/>
+
</div>
+
+
<button
+
type="submit"
+
disabled={isPending}
+
className={`w-full px-4 py-2 rounded-md bg-blue-500 dark:bg-blue-600 text-white font-medium hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-500 relative ${
+
isPending ? 'opacity-90 cursor-not-allowed' : ''
+
}`}
+
>
+
<span className={isPending ? 'invisible' : ''}>Login with Password</span>
+
{isPending && (
+
<span className="absolute inset-0 flex items-center justify-center">
+
<svg
+
className="animate-spin -ml-1 mr-2 h-5 w-5 text-white"
+
xmlns="http://www.w3.org/2000/svg"
+
fill="none"
+
viewBox="0 0 24 24"
+
>
+
<circle
+
className="opacity-25"
+
cx="12"
+
cy="12"
+
r="10"
+
stroke="currentColor"
+
strokeWidth="4"
+
></circle>
+
<path
+
className="opacity-75"
+
fill="currentColor"
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+
></path>
+
</svg>
+
<span>Logging in...</span>
+
</span>
+
)}
+
</button>
+
</form>
+
)
+
}
+51
islands/LoginSelector.tsx
···
+
import { useState } from "preact/hooks"
+
import HandleInput from "./HandleInput.tsx"
+
import CredLogin from "./CredLogin.tsx"
+
+
export default function LoginMethodSelector() {
+
const [loginMethod, setLoginMethod] = useState<'oauth' | 'password'>('password')
+
+
return (
+
<div className="flex flex-col gap-8">
+
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm max-w-md mx-auto w-full">
+
<h2 className="text-xl font-semibold mb-4">Login with ATProto</h2>
+
+
<div className="flex gap-4 mb-6">
+
<button
+
type="button"
+
onClick={() => setLoginMethod('oauth')}
+
className={`flex-1 px-4 py-2 rounded-md transition-colors ${
+
loginMethod === 'oauth'
+
? 'bg-blue-500 text-white'
+
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
+
}`}
+
>
+
OAuth
+
</button>
+
<button
+
type="button"
+
onClick={() => setLoginMethod('password')}
+
className={`flex-1 px-4 py-2 rounded-md transition-colors ${
+
loginMethod === 'password'
+
? 'bg-blue-500 text-white'
+
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
+
}`}
+
>
+
Credential
+
</button>
+
</div>
+
+
{loginMethod === 'oauth' ? <HandleInput /> : <CredLogin />}
+
+
<div className="mt-4 text-center">
+
<a
+
href="/"
+
className="text-blue-500 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
+
>
+
Cancel
+
</a>
+
</div>
+
</div>
+
</div>
+
)
+
}
+71
lib/cred/sessions.ts
···
+
import { CredentialSession } from "npm:@atproto/api";
+
import { getIronSession } from "npm:iron-session";
+
import { CredSession, Credentials, createSessionOptions } from "../types.ts";
+
import { AtpAgent } from "@atproto/api";
+
+
const credentialSessionOptions = createSessionOptions("cred_sid");
+
+
export function getCredentialSession(
+
req: Request,
+
res: Response = new Response()
+
) {
+
return getIronSession<CredSession>(
+
req,
+
res,
+
credentialSessionOptions,
+
);
+
}
+
+
export async function setCredentialSession(
+
req: Request,
+
res: Response,
+
data: Credentials
+
) {
+
const session = await getCredentialSession(
+
req,
+
res
+
);
+
+
const credSession = new CredentialSession(new URL(data.service))
+
const loginResponse = await credSession.login({
+
identifier: data.did,
+
password: data.password
+
})
+
+
session.did = data.did;
+
session.service = data.service;
+
session.refreshJwt = loginResponse.data.refreshJwt;
+
session.accessJwt = loginResponse.data.accessJwt;
+
session.active = loginResponse.data.active ?? true;
+
session.handle = loginResponse.data.handle;
+
+
await session.save();
+
return session;
+
}
+
+
export async function getCredentialSessionAgent(
+
req: Request,
+
res: Response = new Response()
+
) {
+
const session = await getCredentialSession(
+
req,
+
res
+
);
+
+
if (
+
!session.did || !session.service
+
) {
+
return null;
+
}
+
+
try {
+
console.log("Creating agent with service:", session.service);
+
const agent = new AtpAgent(session);
+
agent.resumeSession(session)
+
return agent;
+
} catch (err) {
+
console.warn("Failed to create migration agent:", err);
+
session.destroy();
+
return null;
+
}
+
}
+39
lib/sessions.ts
···
+
import { Agent } from "npm:@atproto/api";
+
import { OauthSession, CredSession } 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(),
+
): Promise<IronSession<OauthSession | CredSession>> {
+
const oauthSession = await getOauthSession(req);
+
const credentialSession = await getCredentialSession(req, res);
+
+
if (oauthSession) {
+
console.log("Oauth session found")
+
return oauthSession;
+
}
+
if (credentialSession) {
+
return credentialSession;
+
}
+
+
throw new Error("No session found");
+
}
+
+
export async function getSessionAgent(
+
req: Request,
+
res: Response = new Response(),
+
): Promise<Agent | null> {
+
const oauthAgent = await getOauthSessionAgent(req);
+
if (oauthAgent) {
+
return oauthAgent;
+
}
+
const credentialAgent = await getCredentialSessionAgent(req, res);
+
if (credentialAgent) {
+
return credentialAgent;
+
}
+
+
return null;
+
}
+69
routes/api/cred/login.ts
···
+
import { setCredentialSession } from "../../../lib/cred/sessions.ts";
+
import { resolver } from "../../../lib/id-resolver.ts";
+
import { define } from "../../../utils.ts";
+
+
export const handler = define.handlers({
+
async POST(ctx) {
+
try {
+
const body = await ctx.req.json();
+
const { handle, password } = body;
+
+
if (!handle || !password) {
+
return new Response(JSON.stringify({
+
success: false,
+
message: "Handle and password are required"
+
}), {
+
status: 400,
+
headers: { "Content-Type": "application/json" }
+
});
+
}
+
const did = await resolver.resolveHandleToDid(handle)
+
const service = await resolver.resolveDidToPdsUrl(did)
+
+
if (!service) {
+
return new Response(JSON.stringify({
+
success: false,
+
message: "Invalid handle"
+
}), {
+
status: 400,
+
})
+
}
+
+
try {
+
// Create response for setting cookies
+
const response = new Response(JSON.stringify({
+
success: true,
+
did,
+
handle
+
}), {
+
status: 200,
+
headers: { "Content-Type": "application/json" }
+
});
+
// Create and save our client session with tokens
+
await setCredentialSession(ctx.req, response, {
+
did,
+
service,
+
password
+
});
+
+
return response;
+
} catch {
+
return new Response(JSON.stringify({
+
success: false,
+
message: "Invalid credentials"
+
}), {
+
status: 401,
+
headers: { "Content-Type": "application/json" }
+
});
+
}
+
} catch (error) {
+
return new Response(JSON.stringify({
+
success: false,
+
message: error instanceof Error ? error.message : "An error occurred"
+
}), {
+
status: 500,
+
headers: { "Content-Type": "application/json" }
+
});
+
}
+
}
+
});
+12 -3
routes/api/me.ts
···
-
import { getSessionAgent } from "../../auth/sessions.ts";
+
import { getSessionAgent } from "../../lib/sessions.ts";
+
import { resolver } from "../../lib/id-resolver.ts";
import { define } from "../../utils.ts";
-
import { resolver } from "../../tools/id-resolver.ts";
export const handler = define.handlers({
async GET(ctx) {
···
}
try {
-
const did = agent.assertDid;
+
if (agent.assertDid) {
+
const did = agent.assertDid;
+
const handle = await resolver.resolveDidToHandle(did);
+
+
return Response.json({ did, handle });
+
}
+
+
const session = await agent.com.atproto.server.getSession();
+
const did = session.data.did
const handle = await resolver.resolveDidToHandle(did);
+
return Response.json({ did, handle });
} catch (err) {
+3 -9
routes/api/migrate/create.ts
···
-
import { getSessionAgent } from "../../../auth/sessions.ts";
-
import { setCredentialSession } from "../../../auth/creds/sessions.ts";
+
import { getSessionAgent } from "../../../lib/sessions.ts";
+
import { setCredentialSession } from "../../../lib/cred/sessions.ts";
import { Agent } from "@atproto/api";
import { define } from "../../../utils.ts";
···
},
);
-
// Create session and store it
-
const sessionRes = await newAgent.com.atproto.server.createSession({
-
identifier: newHandle,
-
password: newPassword,
-
});
-
// Store the migration session
await setCredentialSession(ctx.req, res, {
-
did: sessionRes.data.did,
+
did: accountDid,
handle: newHandle,
service: serviceUrl,
password: newPassword,
+4 -2
routes/api/migrate/data.ts
···
import { define } from "../../../utils.ts";
import {
getSessionAgent,
-
} from "../../../auth/sessions.ts";
+
} from "../../../lib/sessions.ts";
export const handler = define.handlers({
async POST(ctx) {
···
console.log("Data migration: Cookies present:", !!cookies);
console.log("Data migration: Cookie header:", cookies);
-
const newAgent = await getSessionAgent(ctx.req, res, true);
+
const newAgent = await getSessionAgent(ctx.req, res, );
console.log("Data migration: Got new agent:", !!newAgent);
if (!oldAgent) {
···
const repoRes = await oldAgent.com.atproto.sync.getRepo({
did: accountDid,
});
+
const session = await newAgent.com.atproto.server.getSession()
+
console.log("Data migration: session:", session)
await newAgent.com.atproto.repo.importRepo(repoRes.data, {
encoding: "application/vnd.ipld.car",
});
+1 -1
routes/api/migrate/finalize.ts
···
-
import { getSessionAgent } from "../../../auth/sessions.ts";
+
import { getSessionAgent } from "../../../lib/sessions.ts";
import { define } from "../../../utils.ts";
export const handler = define.handlers({
+1 -1
routes/api/migrate/identity/request.ts
···
import {
getSessionAgent,
-
} from "../../../../auth/sessions.ts";
+
} from "../../../../lib/sessions.ts";
import { define } from "../../../../utils.ts";
export const handler = define.handlers({
+1 -1
routes/api/migrate/identity/sign.ts
···
import {
getSessionAgent,
-
} from "../../../../auth/sessions.ts";
+
} from "../../../../lib/sessions.ts";
import { Secp256k1Keypair } from "npm:@atproto/crypto";
import * as ui8 from "npm:uint8arrays";
import { define } from "../../../../utils.ts";
+2 -2
routes/api/oauth/callback.ts
···
-
import { oauthClient } from "../../../auth/oauth/client.ts";
-
import { getOauthSession } from "../../../auth/oauth/sessions.ts";
+
import { oauthClient } from "../../../lib/oauth/client.ts";
+
import { getOauthSession } from "../../../lib/oauth/sessions.ts";
import { define } from "../../../utils.ts";
export const handler = define.handlers({
+1 -1
routes/api/oauth/initiate.ts
···
import { isValidHandle } from 'npm:@atproto/syntax'
-
import { oauthClient } from "../../../auth/oauth/client.ts";
+
import { oauthClient } from "../../../lib/oauth/client.ts";
import { define } from "../../../utils.ts";
function isValidUrl(url: string): boolean {
+2 -2
routes/api/oauth/logout.ts
···
-
import { getSession } from "../../../auth/sessions.ts";
-
import { oauthClient } from "../../../auth/oauth/client.ts";
+
import { getSession } from "../../../lib/sessions.ts";
+
import { oauthClient } from "../../../lib/oauth/client.ts";
import { define } from "../../../utils.ts";
export const handler = define.handlers({
+1 -1
routes/api/server/describe.ts
···
import { Agent } from "@atproto/api";
-
import { getSessionAgent } from "../../../auth/sessions.ts";
+
import { getSessionAgent } from "../../../lib/sessions.ts";
import { define } from "../../../utils.ts";
/**
* Describe the server configuration and capabilities
+2 -16
routes/login/index.tsx
···
import { PageProps } from "fresh";
-
-
import HandleInput from "../../islands/HandleInput.tsx";
+
import LoginSelector from "../../islands/LoginSelector.tsx"
export async function submitHandle(handle: string) {
const response = await fetch("/api/oauth/initiate", {
···
return (
<>
<div className="flex flex-col gap-8">
-
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm max-w-md mx-auto w-full">
-
<h2 className="text-xl font-semibold mb-4">Login with ATProto</h2>
-
-
<HandleInput />
-
-
<div className="mt-4 text-center">
-
<a
-
href="/"
-
className="text-blue-500 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
-
>
-
Cancel
-
</a>
-
</div>
-
</div>
+
<LoginSelector />
</div>
</>
);
+5
tools/id-resolver.ts lib/id-resolver.ts
···
import { IdResolver } from "npm:@atproto/identity";
+
import { Did } from "npm:@atproto/api";
interface AtprotoData {
did: string;
···
return didDoc.handle;
}
return did;
+
},
+
+
async resolveHandleToDid(handle: string) {
+
return await resolver.handle.resolve(handle) as Did
},
async resolveDidToPdsUrl(did: string): Promise<string | undefined> {