Graphical PDS migrator for AT Protocol

first part of oauth working!

+24
.zed/settings.json
···
+
{
+
"languages": {
+
"TypeScript": {
+
"language_servers": [
+
"wakatime",
+
"deno",
+
"!typescript-language-server",
+
"!vtsls",
+
"!eslint"
+
],
+
"formatter": "language_server"
+
},
+
"TSX": {
+
"language_servers": [
+
"wakatime",
+
"deno",
+
"!typescript-language-server",
+
"!vtsls",
+
"!eslint"
+
],
+
"formatter": "language_server"
+
}
+
}
+
}
+4 -8
deno.json
···
},
"lint": {
"rules": {
-
"tags": [
-
"fresh",
-
"recommended"
-
]
+
"tags": ["fresh", "recommended"]
}
},
-
"exclude": [
-
"**/_fresh/*"
-
],
+
"exclude": ["**/_fresh/*"],
"imports": {
"$fresh/": "https://deno.land/x/fresh@1.7.3/",
"preact": "https://esm.sh/preact@10.22.0",
···
"jsx": "react-jsx",
"jsxImportSource": "preact"
},
-
"nodeModulesDir": true
+
"nodeModulesDir": "auto",
+
"unstable": ["kv"]
}
+3 -1
fresh.config.ts
···
import { defineConfig } from "$fresh/server.ts";
import tailwind from "$fresh/plugins/tailwind.ts";
+
import session from "./plugins/session.ts";
+
import didJson from "./plugins/did.ts";
export default defineConfig({
-
plugins: [tailwind()],
+
plugins: [tailwind(), session, didJson],
});
+8 -4
fresh.gen.ts
···
import * as $_404 from "./routes/_404.tsx";
import * as $_app from "./routes/_app.tsx";
-
import * as $api_joke from "./routes/api/joke.ts";
-
import * as $greet_name_ from "./routes/greet/[name].tsx";
+
import * as $api_oauth_callback from "./routes/api/oauth/callback.ts";
+
import * as $api_oauth_initiate from "./routes/api/oauth/initiate.ts";
import * as $index from "./routes/index.tsx";
+
import * as $login_index from "./routes/login/index.tsx";
import * as $Counter from "./islands/Counter.tsx";
+
import * as $HandleInput from "./islands/HandleInput.tsx";
import type { Manifest } from "$fresh/server.ts";
const manifest = {
routes: {
"./routes/_404.tsx": $_404,
"./routes/_app.tsx": $_app,
-
"./routes/api/joke.ts": $api_joke,
-
"./routes/greet/[name].tsx": $greet_name_,
+
"./routes/api/oauth/callback.ts": $api_oauth_callback,
+
"./routes/api/oauth/initiate.ts": $api_oauth_initiate,
"./routes/index.tsx": $index,
+
"./routes/login/index.tsx": $login_index,
},
islands: {
"./islands/Counter.tsx": $Counter,
+
"./islands/HandleInput.tsx": $HandleInput,
},
baseUrl: import.meta.url,
} satisfies Manifest;
+111
islands/HandleInput.tsx
···
+
import { useState } from 'preact/hooks'
+
import { JSX } from 'preact'
+
+
export default function HandleInput() {
+
const [handle, setHandle] = 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()) return
+
+
setError(null)
+
setIsPending(true)
+
+
try {
+
const response = await fetch('/api/oauth/initiate', {
+
method: 'POST',
+
headers: {
+
'Content-Type': 'application/json',
+
},
+
body: JSON.stringify({ handle }),
+
})
+
+
if (!response.ok) {
+
const errorText = await response.text()
+
throw new Error(errorText || 'Login failed')
+
}
+
+
const data = await response.json()
+
+
// Add a small delay before redirecting for better UX
+
await new Promise((resolve) => setTimeout(resolve, 500))
+
+
// Redirect to ATProto OAuth flow
+
globalThis.location.href = data.redirectUrl
+
} 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"
+
/>
+
<p className="text-gray-400 dark:text-gray-500 text-sm mt-2">
+
You can also enter an AT Protocol PDS URL, i.e.{' '}
+
<span className="whitespace-nowrap">https://bsky.social</span>
+
</p>
+
</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</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>Connecting...</span>
+
</span>
+
)}
+
</button>
+
</form>
+
)
+
}
+1
main.ts
···
/// <reference lib="dom.iterable" />
/// <reference lib="dom.asynciterable" />
/// <reference lib="deno.ns" />
+
/// <reference lib="deno.unstable" />
import "$std/dotenv/load.ts";
+58
oauth/client.ts
···
+
import { AtprotoOAuthClient } from 'jsr:@bigmoves/atproto-oauth-client'
+
import { SignJWT, jwtVerify } from "npm:jose@5.9.6";
+
import { SessionStore, StateStore } from "./storage.ts";
+
+
// Create a secure key for JWT signing
+
const jwtKey = new TextEncoder().encode(
+
Deno.env.get("JWT_SECRET") || "secure-jwt-secret-for-oauth-dpop-tokens"
+
);
+
+
class CustomJoseKey {
+
async createJwt(payload: Record<string, unknown>) {
+
const jwt = await new SignJWT(payload)
+
.setProtectedHeader({ alg: "HS256" })
+
.setIssuedAt()
+
.setExpirationTime("1h")
+
.sign(jwtKey);
+
return jwt;
+
}
+
+
async verifyJwt(jwt: string) {
+
const { payload } = await jwtVerify(jwt, jwtKey);
+
return payload;
+
}
+
}
+
+
export const createClient = async (db: Deno.Kv) => {
+
if (Deno.env.get("NODE_ENV") == "production" && !Deno.env.get("PUBLIC_URL")) {
+
throw new Error("PUBLIC_URL is not set");
+
}
+
+
const publicUrl = Deno.env.get("PUBLIC_URL");
+
const url = publicUrl || `http://127.0.0.1:${Deno.env.get("VITE_PORT")}`;
+
const enc = encodeURIComponent;
+
+
return new AtprotoOAuthClient({
+
clientMetadata: {
+
client_name: "Statusphere React App",
+
client_id: publicUrl
+
? `${url}/oauth-client-metadata.json`
+
: `http://localhost?redirect_uri=${
+
enc(`${url}/api/oauth/callback`)
+
}&scope=${enc("atproto transition:generic")}`,
+
client_uri: url,
+
redirect_uris: [`${url}/api/oauth/callback`],
+
scope: "atproto transition:generic",
+
grant_types: ["authorization_code", "refresh_token"],
+
response_types: ["code"],
+
application_type: "web",
+
token_endpoint_auth_method: "none",
+
dpop_bound_access_tokens: true,
+
},
+
stateStore: new StateStore(db),
+
sessionStore: new SessionStore(db)
+
});
+
};
+
+
const kv = await Deno.openKv()
+
export const oauthClient = await createClient(kv)
+59
oauth/session.ts
···
+
import { Agent } from "npm:@atproto/api";
+
import { getIronSession, SessionOptions } from "npm:iron-session";
+
import { FreshContext } from "$fresh/server.ts";
+
import { oauthClient } from "./client.ts";
+
+
export interface Session {
+
did: string;
+
}
+
+
export interface State {
+
session?: Session;
+
sessionUser?: Agent;
+
}
+
+
const cookieSecret = Deno.env.get("COOKIE_SECRET")
+
+
const sessionOptions: SessionOptions = {
+
cookieName: "sid",
+
password: cookieSecret!,
+
cookieOptions: {
+
secure: Deno.env.get("NODE_ENV") === "production",
+
httpOnly: true,
+
sameSite: true,
+
path: "/",
+
// Don't set domain explicitly - let browser determine it
+
domain: undefined,
+
},
+
};
+
+
export async function getSessionAgent(
+
req: Request,
+
ctx: FreshContext,
+
) {
+
const session = await getIronSession<Session>(
+
req,
+
new Response(),
+
sessionOptions,
+
);
+
+
if (!session.did) {
+
return null;
+
}
+
+
try {
+
const oauthSession = await oauthClient.restore(session.did);
+
return oauthSession ? new Agent(oauthSession) : null;
+
} catch (err) {
+
const logger = ctx.state.logger as {
+
warn: (obj: Record<string, unknown>, msg: string) => void;
+
};
+
logger.warn({ err }, "oauth restore failed");
+
session.destroy();
+
return null;
+
}
+
}
+
+
export function getSession(req: Request) {
+
return getIronSession<Session>(req, new Response(), sessionOptions);
+
}
+34
oauth/storage.ts
···
+
import type {
+
NodeSavedSession,
+
NodeSavedSessionStore,
+
NodeSavedState,
+
NodeSavedStateStore,
+
} from "jsr:@bigmoves/atproto-oauth-client";
+
+
export class StateStore implements NodeSavedStateStore {
+
constructor(private db: Deno.Kv) {}
+
async get(key: string): Promise<NodeSavedState | undefined> {
+
const result = await this.db.get<NodeSavedState>(["auth_state", key]);
+
return result.value ?? undefined;
+
}
+
async set(key: string, val: NodeSavedState) {
+
await this.db.set(["auth_state", key], val);
+
}
+
async del(key: string) {
+
await this.db.delete(["auth_state", key]);
+
}
+
}
+
+
export class SessionStore implements NodeSavedSessionStore {
+
constructor(private db: Deno.Kv) {}
+
async get(key: string): Promise<NodeSavedSession | undefined> {
+
const result = await this.db.get<NodeSavedSession>(["auth_session", key]);
+
return result.value ?? undefined;
+
}
+
async set(key: string, val: NodeSavedSession) {
+
await this.db.set(["auth_session", key], val);
+
}
+
async del(key: string) {
+
await this.db.delete(["auth_session", key]);
+
}
+
}
+45
plugins/did.ts
···
+
import { FreshContext, Plugin } from "$fresh/server.ts";
+
import { Secp256k1Keypair, formatMultikey } from 'npm:@atproto/crypto'
+
+
export default {
+
name: 'did-json',
+
routes: [
+
{
+
path: '/.well-known/did.json',
+
handler: async () => {
+
const domain = Deno.env.get("PUBLIC_URL")?.split('://')[1] || 'localhost'
+
const privateKey = Deno.env.get("APPVIEW_K256_PRIVATE_KEY_HEX")
+
if (!privateKey) {
+
throw new Error("APPVIEW_K256_PRIVATE_KEY_HEX environment variable is required")
+
}
+
const keypair = await Secp256k1Keypair.import(privateKey)
+
const multikey = formatMultikey(keypair.jwtAlg, keypair.publicKeyBytes())
+
+
return Response.json({
+
'@context': ['https://www.w3.org/ns/did/v1'],
+
id: `did:web:${domain}`,
+
verificationMethod: [
+
{
+
id: `did:web:${domain}#atproto`,
+
type: 'Multikey',
+
controller: `did:web:${domain}`,
+
publicKeyMultibase: multikey,
+
},
+
],
+
service: [
+
{
+
id: '#swsh_appview',
+
type: 'SwshAppView',
+
serviceEndpoint: `https://${domain}`,
+
},
+
{
+
id: '#atproto_pds',
+
type: 'AtprotoPersonalDataServer',
+
serviceEndpoint: `https://${domain}`,
+
},
+
],
+
})
+
}
+
}
+
]
+
} as Plugin;
+24
plugins/session.ts
···
+
import { getSessionAgent } from "../oauth/session.ts"
+
import { FreshContext, Plugin } from "$fresh/server.ts";
+
import { oauthClient } from "../oauth/client.ts";
+
+
const plugin: Plugin = {
+
name: "session",
+
routes: [],
+
middlewares: [{
+
path: "/",
+
middleware: {
+
handler: async (req: Request, ctx: FreshContext) => {
+
const res = await ctx.next();
+
if (!oauthClient) {
+
console.warn("Missing required oauthClient in state");
+
return res;
+
}
+
const agent = await getSessionAgent(req, ctx);
+
return res;
+
},
+
},
+
}],
+
};
+
+
export default plugin;
+1 -1
routes/_404.tsx
···
<Head>
<title>404 - Page not found</title>
</Head>
-
<div class="px-4 py-8 mx-auto bg-[#86efac]">
+
<div class="px-4 py-8 mx-auto">
<div class="max-w-screen-md mx-auto flex flex-col items-center justify-center">
<img
class="my-6"
-21
routes/api/joke.ts
···
-
import { FreshContext } from "$fresh/server.ts";
-
-
// Jokes courtesy of https://punsandoneliners.com/randomness/programmer-jokes/
-
const JOKES = [
-
"Why do Java developers often wear glasses? They can't C#.",
-
"A SQL query walks into a bar, goes up to two tables and says “can I join you?”",
-
"Wasn't hard to crack Forrest Gump's password. 1forrest1.",
-
"I love pressing the F5 key. It's refreshing.",
-
"Called IT support and a chap from Australia came to fix my network connection. I asked “Do you come from a LAN down under?”",
-
"There are 10 types of people in the world. Those who understand binary and those who don't.",
-
"Why are assembly programmers often wet? They work below C level.",
-
"My favourite computer based band is the Black IPs.",
-
"What programme do you use to predict the music tastes of former US presidential candidates? An Al Gore Rhythm.",
-
"An SEO expert walked into a bar, pub, inn, tavern, hostelry, public house.",
-
];
-
-
export const handler = (_req: Request, _ctx: FreshContext): Response => {
-
const randomIndex = Math.floor(Math.random() * JOKES.length);
-
const body = JOKES[randomIndex];
-
return new Response(body);
-
};
+37
routes/api/oauth/callback.ts
···
+
import { Handlers } from "$fresh/server.ts"
+
import { oauthClient } from "../../../oauth/client.ts";
+
import { getSession } from "../../../oauth/session.ts"
+
+
export const handler: Handlers = {
+
async GET(_req) {
+
const params = new URLSearchParams(_req.url.split("?")[1]);
+
const url = new URL(_req.url);
+
+
try {
+
const { session } = await oauthClient.callback(params);
+
// Use the common session options
+
const clientSession = await getSession(_req);
+
+
// Set the DID on the session
+
clientSession.did = session.did;
+
await clientSession.save();
+
+
// Get the origin and determine appropriate redirect
+
const host = params.get("host");
+
const protocol = url.protocol || "http";
+
const baseUrl = `${protocol}://${host}`;
+
+
console.info(
+
`OAuth callback successful, redirecting to ${baseUrl}/oauth-callback`,
+
);
+
+
// Redirect to the frontend oauth-callback page
+
+
return Response.redirect("/login/callback");
+
} catch (err) {
+
console.error({ err }, "oauth callback failed");
+
+
return Response.redirect("/oauth-callback?error=auth");
+
}
+
}
+
}
+37
routes/api/oauth/initiate.ts
···
+
import type { Handlers } from "$fresh/server.ts";
+
import { isValidHandle } from 'npm:@atproto/syntax'
+
import { oauthClient } from "../../../oauth/client.ts";
+
+
function isValidUrl(url: string): boolean {
+
try {
+
const urlp = new URL(url)
+
// http or https
+
return urlp.protocol === 'http:' || urlp.protocol === 'https:'
+
} catch {
+
return false
+
}
+
}
+
+
export const handler: Handlers = {
+
async POST(_req) {
+
const data = await _req.json()
+
const handle = data.handle
+
if (
+
typeof handle !== 'string' ||
+
!(isValidHandle(handle) || isValidUrl(handle))
+
) {
+
return new Response("Invalid Handle", {status: 400})
+
}
+
+
// Initiate the OAuth flow
+
try {
+
const url = await oauthClient.authorize(handle, {
+
scope: 'atproto transition:generic',
+
})
+
return Response.json({ redirectUrl: url.toString() })
+
} catch (err) {
+
console.error({ err }, 'oauth authorize failed')
+
return new Response("Couldn't initiate login", {status: 500})
+
}
+
},
+
};
-5
routes/greet/[name].tsx
···
-
import { PageProps } from "$fresh/server.ts";
-
-
export default function Greet(props: PageProps) {
-
return <div>Hello {props.params.name}</div>;
-
}
+1 -1
routes/index.tsx
···
export default function Home() {
const count = useSignal(3);
return (
-
<div class="px-4 py-8 mx-auto bg-[#86efac]">
+
<div class="px-4 py-8 mx-auto">
<div class="max-w-screen-md mx-auto flex flex-col items-center justify-center">
<img
class="my-6"
+53
routes/login/index.tsx
···
+
import { PageProps } from "$fresh/server.ts"
+
import { Head } from "$fresh/runtime.ts"
+
+
import HandleInput from "../../islands/HandleInput.tsx"
+
+
export async function submitHandle(handle: string) {
+
const response = await fetch('/api/oauth/initiate', {
+
method: 'POST',
+
headers: {
+
'Content-Type': 'application/json',
+
},
+
body: JSON.stringify({ handle }),
+
})
+
+
if (!response.ok) {
+
const errorText = await response.text()
+
throw new Error(errorText || 'Login failed')
+
}
+
+
const data = await response.json()
+
+
// Add a small delay before redirecting for better UX
+
await new Promise((resolve) => setTimeout(resolve, 500))
+
+
// Redirect to ATProto OAuth flow
+
globalThis.location.href = data.redirectUrl
+
}
+
+
export default function Login(_props: PageProps) {
+
return (
+
<>
+
<Head>
+
<title>Login - Airport</title>
+
</Head>
+
<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>
+
</div>
+
</>
+
)
+
}
+63 -1
static/styles.css
···
+
@import url("https://fonts.googleapis.com/css2?family=Spectral:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;1,200;1,300;1,400;1,500;1,600;1,700;1,800&display=swap");
+
@tailwind base;
@tailwind components;
-
@tailwind utilities;
+
@tailwind utilities;
+
+
@keyframes fadeOut {
+
0% {
+
opacity: 1;
+
}
+
75% {
+
opacity: 1;
+
} /* Hold full opacity for most of the animation */
+
100% {
+
opacity: 0;
+
}
+
}
+
+
.status-message-fade {
+
animation: fadeOut 2s forwards;
+
}
+
+
.font-spectral {
+
font-family: "Spectral", serif;
+
}
+
+
.grow-wrap {
+
/* easy way to plop the elements on top of each other and have them both sized based on the tallest one's height */
+
display: grid;
+
}
+
.grow-wrap::after {
+
/* Note the weird space! Needed to preventy jumpy behavior */
+
content: attr(data-replicated-value) " ";
+
+
/* This is how textarea text behaves */
+
white-space: pre-wrap;
+
+
/* Hidden from view, clicks, and screen readers */
+
visibility: hidden;
+
}
+
.grow-wrap > textarea {
+
/* You could leave this, but after a user resizes, then it ruins the auto sizing */
+
resize: none;
+
+
/* Firefox shows scrollbar on growth, you can hide like this. */
+
overflow: hidden;
+
}
+
.grow-wrap > textarea,
+
.grow-wrap::after {
+
/* Identical styling required!! */
+
font: inherit;
+
+
/* Place on top of each other */
+
grid-area: 1 / 1 / 2 / 2;
+
}
+
+
/* Base styling */
+
@layer base {
+
body {
+
@apply bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100;
+
}
+
button {
+
@apply rounded-xl;
+
}
+
}
+29
types.ts
···
+
import { OAuthClient } from 'jsr:@bigmoves/atproto-oauth-client'
+
import { Agent } from 'npm:@atproto/api'
+
+
export interface AppContext {
+
oauthClient: OAuthClient
+
logger: {
+
warn: (obj: Record<string, unknown>, msg: string) => void
+
}
+
}
+
+
export interface Env {
+
COOKIE_SECRET: string
+
NODE_ENV: string
+
}
+
+
declare global {
+
const env: Env
+
}
+
+
// Extend Fresh's State interface
+
declare module "$fresh/server.ts" {
+
interface State {
+
oauthClient: OAuthClient
+
logger: {
+
warn: (obj: Record<string, unknown>, msg: string) => void
+
}
+
agent?: Agent | null
+
}
+
}