Graphical PDS migrator for AT Protocol

first part of oauth working!

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