Atproto AMA app
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix local auth callback with funky ipv6

+487 -403
+18 -6
.env.example
··· 1 1 # Database 2 2 DATABASE_URL="postgresql://user:password@localhost:5432/askimut" 3 3 4 - # OAuth 5 - PUBLIC_URL="http://localhost:3000" 4 + # OAuth — local dev 5 + # The app must be served on a different loopback host than the PDS so the 6 + # browser reports Sec-Fetch-Site: cross-site on /oauth/authorize (the atproto 7 + # oauth-provider rejects same-site authorize requests). We use IPv6 loopback 8 + # [::1] for the app and IPv4 loopback (localhost) for the PDS. 9 + APP_URL=http://[::1]:3000 10 + HOST=:: 11 + PORT=3000 12 + NITRO_HOST=:: 13 + NITRO_PORT=3000 14 + 15 + # In production, set PUBLIC_URL to your real HTTPS origin instead of APP_URL: 16 + # PUBLIC_URL="https://askimut.example.com" 6 17 7 18 # AT Protocol Configuration 8 19 # Enable/disable AT Protocol publishing (default: false for development) ··· 20 31 AT_PROTOCOL_PDS=https://bsky.social 21 32 AT_PROTOCOL_APPVIEW=https://api.bsky.app 22 33 23 - # Local dev network (run `pnpm run dev:network` to start) 24 - # Ports are assigned dynamically — copy the printed values here. 25 - # DEV_PDS_URL=http://127.0.0.1:XXXX 26 - # DEV_PLC_URL=http://127.0.0.1:XXXX 34 + # Local dev network (run `pnpm run dev:network` to start). 35 + # Fixed ports 2583 (PLC) and 2584 (PDS). PDS stays on `localhost` so it is 36 + # cross-site vs the app served on `[::1]`. 37 + DEV_PDS_URL=http://localhost:2584 38 + DEV_PLC_URL=http://localhost:2583 27 39 28 40 # Lexicon Validation Configuration 29 41 # Enable/disable lexicon validation (default: true)
+1 -1
app.config.ts
··· 7 7 }, 8 8 vite: { 9 9 server: { 10 - host: "127.0.0.1", 10 + host: "::", 11 11 port: 3000, 12 12 }, 13 13 },
+3 -2
package.json
··· 11 11 "db:studio": "drizzle-kit studio", 12 12 "db:seed": "dotenv -e .env -- tsx scripts/seed-test-questions.ts", 13 13 "lex:build": "lex build --lexicons ./lexicons --out ./src/lexicons --override", 14 - "dev:network": "tsx scripts/dev-network.ts" 14 + "dev:network": "tsx scripts/dev-network.ts", 15 + "dev:network:auto": "tsx scripts/dev-network.ts | tsx scripts/update-env.ts" 15 16 }, 16 17 "dependencies": { 17 18 "@atproto/api": "^0.13.0", ··· 28 29 "vinxi": "^0.5.3" 29 30 }, 30 31 "devDependencies": { 31 - "@atproto/dev-env": "^0.4.0", 32 + "@atproto/dev-env": "^0.4.4", 32 33 "@types/node": "^25.5.0", 33 34 "dotenv-cli": "^7.4.2", 34 35 "drizzle-kit": "^0.30.0",
+15 -7
scripts/dev-network.ts
··· 9 9 * 10 10 * Usage: 11 11 * pnpm run dev:network 12 - * 13 - * Then start the app with the printed env vars, or copy them into .env 14 12 */ 15 13 16 14 import { TestNetworkNoAppView } from "@atproto/dev-env"; 15 + 16 + const APP_URL = process.env.APP_URL || "http://[::1]:3000"; 17 17 18 18 const ACCOUNTS = [ 19 19 { ··· 35 35 async function main() { 36 36 console.log("Starting local AT Protocol network...\n"); 37 37 38 - const network = await TestNetworkNoAppView.create({}); 38 + const network = await TestNetworkNoAppView.create({ 39 + plc: { port: 2583 }, 40 + pds: { port: 2584 }, 41 + }); 39 42 40 43 const pdsUrl = network.pds.url; 41 44 const plcUrl = network.plc.url; ··· 55 58 56 59 await network.processAll(); 57 60 58 - console.log("\n--- Environment variables for .env ---\n"); 59 - console.log(`DEV_PDS_URL=${pdsUrl}`); 60 - console.log(`DEV_PLC_URL=${plcUrl}`); 61 - console.log(""); 61 + const bar = "═".repeat(60); 62 + console.log(`\n${bar}`); 63 + console.log(` Open the app at: ${APP_URL}`); 64 + console.log(`${bar}`); 65 + console.log( 66 + " IMPORTANT: use exactly this URL (IPv6 loopback). Opening\n" + 67 + " http://localhost:3000 or http://127.0.0.1:3000 will break the\n" + 68 + " OAuth flow — the PDS rejects same-site authorize requests.\n", 69 + ); 62 70 63 71 console.log("--- Test accounts ---\n"); 64 72 for (const account of ACCOUNTS) {
+68
scripts/update-env.ts
··· 1 + #!/usr/bin/env tsx 2 + 3 + /** 4 + * Auto-update .env with dev network URLs by parsing dev-network output. 5 + * 6 + * Usage: 7 + * pnpm run dev:network | pnpm exec tsx scripts/update-env.ts 8 + * 9 + * Or use the combined script: 10 + * pnpm run dev:network:auto 11 + */ 12 + 13 + import { readFileSync, writeFileSync } from 'fs'; 14 + import { join } from 'path'; 15 + 16 + const ENV_FILE = join(process.cwd(), '.env'); 17 + 18 + let envContent = ''; 19 + try { 20 + envContent = readFileSync(ENV_FILE, 'utf8'); 21 + } catch (err) { 22 + console.error('Failed to read .env file:', err); 23 + process.exit(1); 24 + } 25 + 26 + // Listen for stdin to parse dev-network output 27 + let buffer = ''; 28 + let updated = false; 29 + process.stdin.setEncoding('utf8'); 30 + 31 + process.stdin.on('data', (chunk) => { 32 + buffer += chunk; 33 + process.stdout.write(chunk); // Pass through output 34 + 35 + if (updated) return; 36 + 37 + // Look for the environment variables section 38 + const pdsMatch = buffer.match(/DEV_PDS_URL=(http:\/\/(?:localhost|127\.0\.0\.1):\d+)/); 39 + const plcMatch = buffer.match(/DEV_PLC_URL=(http:\/\/(?:localhost|127\.0\.0\.1):\d+)/); 40 + 41 + if (pdsMatch && plcMatch) { 42 + updated = true; 43 + const newPdsUrl = pdsMatch[1]; 44 + const newPlcUrl = plcMatch[1]; 45 + 46 + const alreadySet = 47 + envContent.includes(`DEV_PDS_URL=${newPdsUrl}`) && 48 + envContent.includes(`DEV_PLC_URL=${newPlcUrl}`); 49 + 50 + if (alreadySet) { 51 + console.log(`\n✅ .env already has correct URLs (no restart needed):\n DEV_PDS_URL=${newPdsUrl}\n DEV_PLC_URL=${newPlcUrl}\n`); 52 + return; 53 + } 54 + 55 + let updatedContent = envContent; 56 + updatedContent = updatedContent.replace(/DEV_PDS_URL=.*/, `DEV_PDS_URL=${newPdsUrl}`); 57 + updatedContent = updatedContent.replace(/DEV_PLC_URL=.*/, `DEV_PLC_URL=${newPlcUrl}`); 58 + 59 + try { 60 + writeFileSync(ENV_FILE, updatedContent); 61 + console.log(`\n🔄 Updated .env with new URLs:`); 62 + console.log(` DEV_PDS_URL=${newPdsUrl}`); 63 + console.log(` DEV_PLC_URL=${newPlcUrl}\n`); 64 + } catch (err) { 65 + console.error('Failed to update .env:', err); 66 + } 67 + } 68 + });
+12 -49
src/components/Header.tsx
··· 1 1 import { A, createAsync } from "@solidjs/router"; 2 - import { Show, createEffect, createSignal, onMount } from "solid-js"; 2 + import { Show, createSignal, onMount } from "solid-js"; 3 3 4 4 import { getCurrentUser } from "~/lib/queries"; 5 5 6 6 import styles from "./Header.module.css"; 7 7 8 8 export function Header() { 9 - console.log("[Header] Component rendering - START:", { 10 - isServer: typeof window === "undefined", 11 - timestamp: new Date().toISOString() 12 - }); 13 - 14 - const user = createAsync(() => { 15 - console.log("[Header] createAsync getCurrentUser called:", { 16 - isServer: typeof window === "undefined", 17 - timestamp: new Date().toISOString() 18 - }); 19 - return getCurrentUser(); 20 - }); 9 + const user = createAsync(() => getCurrentUser()); 21 10 22 - // Track hydration state to prevent mismatches 23 11 const [isHydrated, setIsHydrated] = createSignal(false); 24 - 25 - onMount(() => { 26 - console.log("[Header] Component mounted (client-side)"); 27 - setIsHydrated(true); 28 - }); 29 - 30 - // Log the immediate value of user() during render 31 - const currentUserValue = user(); 32 - console.log("[Header] Initial user value during render:", { 33 - user: currentUserValue, 34 - isServer: typeof window === "undefined", 35 - isHydrated: isHydrated(), 36 - timestamp: new Date().toISOString(), 37 - userHandle: currentUserValue?.handle, 38 - userDid: currentUserValue?.did, 39 - isLoading: user.loading, 40 - hasError: !!user.error 41 - }); 42 - 43 - // Debug logging for hydration issues 44 - createEffect(() => { 45 - const userValue = user(); 46 - console.log("[Header] User state changed in effect:", { 47 - user: userValue, 48 - isServer: typeof window === "undefined", 49 - isHydrated: isHydrated(), 50 - timestamp: new Date().toISOString(), 51 - userHandle: userValue?.handle, 52 - userDid: userValue?.did, 53 - isLoading: user.loading, 54 - hasError: !!user.error 55 - }); 56 - }); 12 + onMount(() => setIsHydrated(true)); 57 13 58 14 return ( 59 15 <header class={styles.bar}> 60 16 <A class={styles.brand} href="/"> 61 17 Askimut 62 18 </A> 63 - <Show when={user() && (isHydrated() || typeof window === "undefined")} keyed> 19 + <Show when={(isHydrated() || typeof window === "undefined") && user()} keyed> 64 20 {(u) => ( 65 21 <div class={styles.right}> 66 22 <span class={styles.handle}>@{u.handle}</span> 67 - <a class={styles.signOut} href="/oauth/logout"> 23 + <a 24 + class={styles.signOut} 25 + href="/oauth/logout" 26 + onClick={(e) => { 27 + e.preventDefault(); 28 + window.location.href = "/oauth/logout"; 29 + }} 30 + > 68 31 Sign out 69 32 </a> 70 33 </div>
+19
src/lib/log.ts
··· 1 + import fs from "node:fs"; 2 + 3 + const LOG_FILE = "/tmp/askimut-app.log"; 4 + 5 + if (typeof process !== "undefined") { 6 + try { 7 + fs.appendFileSync(LOG_FILE, `\n\n=== App started ${new Date().toISOString()} ===\n`); 8 + } catch {} 9 + } 10 + 11 + export function log(...args: unknown[]) { 12 + const line = args 13 + .map((a) => (typeof a === "string" ? a : JSON.stringify(a, null, 2))) 14 + .join(" "); 15 + console.log(line); 16 + try { 17 + fs.appendFileSync(LOG_FILE, line + "\n"); 18 + } catch {} 19 + }
+17 -1
src/lib/oauth.ts
··· 12 12 function resolveUrl(): string { 13 13 const publicUrl = process.env.PUBLIC_URL; 14 14 if (publicUrl) return publicUrl.replace(/\/$/, ""); 15 + // APP_URL is used in dev to set a loopback-compatible origin (e.g. http://[::1]:3000) 16 + // so the app and the PDS are cross-site in the browser (required by oauth-provider). 17 + const appUrl = process.env.APP_URL; 18 + if (appUrl) return appUrl.replace(/\/$/, ""); 15 19 const port = process.env.PORT || 3000; 16 20 return `http://127.0.0.1:${port}`; 17 21 } ··· 87 91 }; 88 92 } 89 93 94 + function oauthAllowHttp(): boolean { 95 + // Without PUBLIC_URL we already use http://127.0.0.1 for the app → allow http PDS. 96 + if (!process.env.PUBLIC_URL) return true; 97 + // .env often sets PUBLIC_URL for redirects while DEV_PDS_URL is still http (dev-network). 98 + const pds = process.env.DEV_PDS_URL; 99 + try { 100 + return Boolean(pds && new URL(pds).protocol === "http:"); 101 + } catch { 102 + return false; 103 + } 104 + } 105 + 90 106 export async function createOAuthClient(): Promise<NodeOAuthClient> { 91 107 const devPdsUrl = process.env.DEV_PDS_URL; 92 108 const devPlcUrl = process.env.DEV_PLC_URL; ··· 95 111 clientMetadata: getOAuthClientMetadata(), 96 112 stateStore: drizzleStateStore(), 97 113 sessionStore: drizzleSessionStore(), 98 - allowHttp: !process.env.PUBLIC_URL, 114 + allowHttp: oauthAllowHttp(), 99 115 ...(devPdsUrl && { handleResolver: devPdsUrl }), 100 116 ...(devPlcUrl && { plcDirectoryUrl: devPlcUrl }), 101 117 });
+18 -36
src/lib/queries.ts
··· 1 - import { action, query, revalidate } from "@solidjs/router"; 1 + import { action, query, redirect, revalidate } from "@solidjs/router"; 2 2 import { eq } from "drizzle-orm"; 3 3 import { getCookie } from "vinxi/http"; 4 4 ··· 22 22 export const getCurrentUser = query(async () => { 23 23 "use server"; 24 24 const sessionId = getCookie(SESSION_COOKIE); 25 - console.log("[getCurrentUser] Session lookup:", { 26 - sessionId: sessionId ? `${sessionId.slice(0, 8)}...` : null, 27 - hasSessionId: !!sessionId, 28 - timestamp: new Date().toISOString() 29 - }); 30 - 31 - if (!sessionId) { 32 - console.log("[getCurrentUser] No session ID found in cookie"); 33 - return null; 34 - } 35 - 25 + if (!sessionId) return null; 36 26 const session = await getSession(sessionId); 37 - console.log("[getCurrentUser] Session resolved:", { 38 - hasSession: !!session, 39 - hasUser: !!session?.user, 40 - userHandle: session?.user?.handle, 41 - userDid: session?.user?.did, 42 - sessionExpiry: session?.expiresAt, 43 - timestamp: new Date().toISOString() 44 - }); 45 - 46 27 return session?.user ?? null; 47 28 }, "currentUser"); 48 29 ··· 182 163 .where(eq(users.did, user.did)); 183 164 } 184 165 185 - await revalidate([ 186 - getQuestions.keyFor(user.did), 187 - getUserByHandle.keyFor(user.handle), 188 - getCurrentUser.keyFor(), 189 - ]); 166 + throw redirect(`/${user.handle}`, { 167 + revalidate: [ 168 + getUserByHandle.keyFor(user.handle), 169 + getQuestions.keyFor(user.did), 170 + getCurrentUser.keyFor(), 171 + ], 172 + }); 190 173 }, "toggleQuestionsOpen"); 191 174 192 175 export const submitQuestion = action( ··· 271 254 const target = await db.query.users.findFirst({ 272 255 where: eq(users.did, targetDid), 273 256 }); 274 - await revalidate([ 275 - getQuestions.keyFor(targetDid), 276 - getPendingQuestions.keyFor(targetDid), 277 - ...(target ? [getUserByHandle.keyFor(target.handle)] : []), 278 - ]); 257 + throw redirect(`/${target!.handle}`, { 258 + revalidate: getQuestions.keyFor(targetDid), 259 + }); 279 260 }, 280 261 "submitQuestion", 281 262 ); ··· 363 344 insertedAnswer = inserted; 364 345 } 365 346 366 - await revalidate([ 367 - getQuestions.keyFor(q.targetDid), 368 - getPendingQuestions.keyFor(q.targetDid), 369 - getQuestionById.keyFor(q.id), 370 - ]); 347 + const target = await db.query.users.findFirst({ 348 + where: eq(users.did, q.targetDid), 349 + }); 350 + throw redirect(`/${target!.handle}/pending`, { 351 + revalidate: getPendingQuestions.keyFor(q.targetDid), 352 + }); 371 353 }, 372 354 "submitAnswer", 373 355 );
+13
src/middleware.ts
··· 2 2 3 3 import { getSession, parseSessionCookie } from "~/lib/session"; 4 4 5 + if (!(globalThis as { __ASKIMUT_BANNER_PRINTED?: boolean }).__ASKIMUT_BANNER_PRINTED) { 6 + (globalThis as { __ASKIMUT_BANNER_PRINTED?: boolean }).__ASKIMUT_BANNER_PRINTED = true; 7 + const appUrl = process.env.APP_URL || "http://[::1]:3000"; 8 + const bar = "═".repeat(60); 9 + console.log(`\n${bar}`); 10 + console.log(` Open Askimut at: ${appUrl}`); 11 + console.log(`${bar}`); 12 + console.log( 13 + " Use this exact URL (IPv6 loopback). Opening via localhost or\n" + 14 + " 127.0.0.1 will break the OAuth login (same-site rejection).\n", 15 + ); 16 + } 17 + 5 18 export default createMiddleware({ 6 19 onRequest: [ 7 20 async (event) => {
+3
src/routes/[handle].module.css
··· 72 72 } 73 73 74 74 .questionCard { 75 + display: block; 75 76 padding: 1.1rem 1.2rem; 76 77 background: var(--color-surface); 77 78 border: 1px solid var(--color-border); 78 79 border-radius: var(--radius); 80 + text-decoration: none; 81 + color: inherit; 79 82 } 80 83 81 84 .questionContent {
+27 -29
src/routes/[handle]/index.tsx
··· 1 1 import { 2 + A, 2 3 createAsync, 3 - useNavigate, 4 4 useParams, 5 + useSubmission, 5 6 } from "@solidjs/router"; 6 7 import { For, Show } from "solid-js"; 7 8 ··· 10 11 getQuestions, 11 12 getPendingQuestions, 12 13 getUserByHandle, 13 - submitAnswer, 14 - submitQuestion, 15 14 toggleQuestionsOpen, 16 15 } from "~/lib/queries"; 17 16 import SourceAttribution from "~/components/SourceAttribution"; 18 17 19 18 import styles from "../[handle].module.css"; 20 19 20 + export const route = { 21 + preload({ params }: { params: { handle: string } }) { 22 + getUserByHandle(params.handle); 23 + getCurrentUser(); 24 + }, 25 + }; 26 + 21 27 function formatWhen(d: Date | string) { 22 28 const date = typeof d === "string" ? new Date(d) : d; 23 29 return date.toLocaleString(undefined, { ··· 28 34 29 35 export default function UserProfile() { 30 36 const params = useParams(); 31 - const navigate = useNavigate(); 37 + const toggling = useSubmission(toggleQuestionsOpen); 32 38 const currentUser = createAsync(() => getCurrentUser()); 33 39 const profileUser = createAsync(() => getUserByHandle(params.handle)); 34 40 const questions = createAsync(async () => { ··· 38 44 }); 39 45 40 46 const pendingQuestions = createAsync(async () => { 41 - const p = await profileUser(); 42 - const c = await currentUser(); 47 + const p = await getUserByHandle(params.handle); 48 + const c = await getCurrentUser(); 43 49 if (!p || !c || c.did !== p.did) return []; 44 50 try { 45 51 return await getPendingQuestions(p.did); ··· 96 102 </div> 97 103 </header> 98 104 99 - {/* Navigation buttons */} 100 105 <Show when={currentUser()}> 101 106 <div class={styles.pageActions}> 102 - <Show when={!isOwn() && profile.questionsOpen}> 103 - <button 107 + {/* Owner: answer pending — shown first, most prominent */} 108 + <Show when={isOwn() && profile.questionsOpen && pendingQuestions() && pendingQuestions()!.length > 0}> 109 + <A 110 + href={`/${params.handle}/pending`} 104 111 class={styles.button} 105 - onClick={() => navigate(`/${params.handle}/new`)} 106 112 > 107 - Ask a Question 108 - </button> 113 + Answer Questions ({pendingQuestions()?.length}) 114 + </A> 109 115 </Show> 110 - <Show when={isOwn() && profile.questionsOpen && pendingQuestions() && pendingQuestions()!.length > 0}> 111 - <button 116 + {/* Visitor: ask a question */} 117 + <Show when={!isOwn() && profile.questionsOpen}> 118 + <A 119 + href={`/${params.handle}/new`} 112 120 class={styles.button} 113 - onClick={() => navigate(`/${params.handle}/pending`)} 114 121 > 115 - Answer Questions ({pendingQuestions()?.length}) 116 - </button> 122 + Ask a Question 123 + </A> 117 124 </Show> 118 125 </div> 119 126 </Show> ··· 129 136 action={toggleQuestionsOpen} 130 137 method="post" 131 138 > 132 - <button class={styles.button} type="submit"> 133 - Open your questions 139 + <button class={styles.button} type="submit" disabled={toggling.pending}> 140 + {toggling.pending ? "Updating…" : "Open your questions"} 134 141 </button> 135 142 </form> 136 143 </Show> ··· 233 240 234 241 <Show when={!currentUser()}> 235 242 <p class={styles.status}> 236 - <a 237 - href="/" 238 - onClick={(e) => { 239 - e.preventDefault(); 240 - navigate("/"); 241 - }} 242 - > 243 - Sign in 244 - </a>{" "} 245 - to ask a question. 243 + <A href="/">Sign in</A>{" "}to ask a question. 246 244 </p> 247 245 </Show> 248 246 </Show>
+26 -28
src/routes/[handle]/new.tsx
··· 1 1 import { 2 + A, 2 3 createAsync, 3 - useNavigate, 4 + useAction, 4 5 useParams, 6 + useSubmission, 5 7 } from "@solidjs/router"; 6 8 import { Show, ErrorBoundary } from "solid-js"; 7 9 ··· 15 17 16 18 import styles from "../[handle].module.css"; 17 19 20 + export const route = { 21 + preload({ params }: { params: { handle: string } }) { 22 + getUserByHandle(params.handle); 23 + getCurrentUser(); 24 + }, 25 + }; 26 + 18 27 export default function AskQuestion() { 19 28 const params = useParams(); 20 - const navigate = useNavigate(); 21 - 22 - // Verify authentication and ensure user is not the profile owner 29 + const doSubmit = useAction(submitQuestion); 30 + const submitting = useSubmission(submitQuestion); 31 + 23 32 const currentUser = createAsync(async () => { 24 33 try { 25 34 return await requireNotOwner(params.handle); 26 - } catch (error) { 27 - // Handle redirects gracefully 35 + } catch { 28 36 return null; 29 37 } 30 38 }); 31 - 39 + 32 40 const profileUser = createAsync(async () => { 33 41 const user = await getUserByHandle(params.handle); 34 42 if (!user) { ··· 40 48 return user; 41 49 }); 42 50 43 - const handleSuccess = () => { 44 - navigate(`/${params.handle}`, { 45 - replace: true, 46 - state: { message: "Question sent successfully!" } 47 - }); 48 - }; 49 - 50 51 return ( 51 52 <ErrorBoundary fallback={ErrorBoundaryComponent}> 52 53 <div class={styles.page}> 53 54 <Show when={profileUser() && currentUser()} keyed> 54 - {([profile, user]) => ( 55 + {([profile]) => ( 55 56 <> 56 57 <header class={styles.header}> 57 58 <Show ··· 89 90 <div class={styles.questionForm}> 90 91 <form 91 92 class={styles.form} 92 - action={submitQuestion.with(profile.did)} 93 - method="post" 94 - onSubmit={() => { 95 - // Add a small delay to allow form submission to complete 96 - setTimeout(handleSuccess, 100); 93 + onSubmit={async (e) => { 94 + e.preventDefault(); 95 + await doSubmit(profile.did, new FormData(e.currentTarget)); 97 96 }} 98 97 > 99 98 <textarea ··· 117 116 /> 118 117 <label class={styles.toggleRow}> 119 118 <input type="checkbox" name="anonymous" /> 120 - Ask anonymously 119 + {" "}Ask anonymously 121 120 </label> 122 121 <div class={styles.formActions}> 123 - <button 124 - type="button" 122 + <A 123 + href={`/${params.handle}`} 125 124 class={`${styles.button} ${styles.buttonSecondary}`} 126 - onClick={() => navigate(`/${params.handle}`)} 127 125 > 128 126 Cancel 129 - </button> 130 - <button class={styles.button} type="submit"> 131 - Send question 127 + </A> 128 + <button class={styles.button} type="submit" disabled={submitting.pending}> 129 + {submitting.pending ? "Sending…" : "Send question"} 132 130 </button> 133 131 </div> 134 132 </form> ··· 139 137 </div> 140 138 </ErrorBoundary> 141 139 ); 142 - } 140 + }
+129 -128
src/routes/[handle]/pending/[id].tsx
··· 1 1 import { 2 + A, 2 3 createAsync, 3 - useNavigate, 4 + useAction, 4 5 useParams, 6 + useSubmission, 5 7 } from "@solidjs/router"; 6 8 import { Show, ErrorBoundary } from "solid-js"; 7 9 ··· 11 13 getQuestionById, 12 14 submitAnswer, 13 15 } from "~/lib/queries"; 14 - import { requireOwner, NotFoundError } from "~/lib/route-guards"; 16 + import { NotFoundError } from "~/lib/route-guards"; 15 17 import SourceAttribution from "~/components/SourceAttribution"; 16 18 import ErrorBoundaryComponent from "~/components/ErrorBoundary"; 17 19 18 20 import styles from "../../[handle].module.css"; 19 21 22 + export const route = { 23 + preload({ params }: { params: { handle: string; id: string } }) { 24 + getUserByHandle(params.handle); 25 + getCurrentUser(); 26 + getQuestionById(params.id); 27 + }, 28 + }; 29 + 20 30 function formatWhen(d: Date | string) { 21 31 const date = typeof d === "string" ? new Date(d) : d; 22 32 return date.toLocaleString(undefined, { ··· 27 37 28 38 export default function AnswerQuestion() { 29 39 const params = useParams(); 30 - const navigate = useNavigate(); 31 - 32 - // Verify authentication and ensure user owns the profile 33 - const currentUser = createAsync(async () => { 34 - try { 35 - return await requireOwner(params.handle); 36 - } catch (error) { 37 - // Handle redirects gracefully 38 - return null; 39 - } 40 - }); 41 - 40 + const doSubmit = useAction(submitAnswer); 41 + const submitting = useSubmission(submitAnswer); 42 + 42 43 const profileUser = createAsync(async () => { 43 44 const user = await getUserByHandle(params.handle); 44 45 if (!user) { ··· 47 48 return user; 48 49 }); 49 50 51 + const currentUser = createAsync(() => getCurrentUser()); 52 + 50 53 const question = createAsync(async () => { 51 - if (!currentUser()) return null; 54 + const user = await getCurrentUser(); 55 + if (!user) return null; 52 56 try { 53 57 return await getQuestionById(params.id); 54 58 } catch (error) { ··· 59 63 } 60 64 }); 61 65 62 - const handleSuccess = () => { 63 - navigate(`/${params.handle}/pending`, { 64 - replace: true, 65 - state: { message: "Answer posted successfully!" } 66 - }); 66 + const ownerContext = () => { 67 + const profile = profileUser(); 68 + const user = currentUser(); 69 + const q = question(); 70 + if (!profile || !user || !q) return undefined; 71 + return [profile, user, q] as const; 67 72 }; 68 73 69 74 return ( 70 75 <ErrorBoundary fallback={ErrorBoundaryComponent}> 71 76 <div class={styles.page}> 72 - <Show when={profileUser() && currentUser() && question()} keyed> 73 - {([profile, user, q]) => ( 74 - <> 75 - <header class={styles.header}> 76 - <Show 77 - when={profile.avatarUrl} 78 - keyed 79 - fallback={ 80 - <div 81 - class={`${styles.avatar} ${styles.avatarPlaceholder}`} 82 - aria-hidden 83 - > 84 - {(profile.displayName || profile.handle) 85 - .slice(0, 1) 86 - .toUpperCase()} 77 + <Show when={ownerContext()} keyed> 78 + {([profile, , q]) => ( 79 + <> 80 + <header class={styles.header}> 81 + <Show 82 + when={profile.avatarUrl} 83 + keyed 84 + fallback={ 85 + <div 86 + class={`${styles.avatar} ${styles.avatarPlaceholder}`} 87 + aria-hidden 88 + > 89 + {(profile.displayName || profile.handle) 90 + .slice(0, 1) 91 + .toUpperCase()} 92 + </div> 93 + } 94 + > 95 + {(src) => ( 96 + <img 97 + class={styles.avatar} 98 + src={src} 99 + alt="" 100 + width={64} 101 + height={64} 102 + /> 103 + )} 104 + </Show> 105 + <div class={styles.profileText}> 106 + <div class={styles.displayName}> 107 + Answer Question 87 108 </div> 88 - } 89 - > 90 - {(src) => ( 91 - <img 92 - class={styles.avatar} 93 - src={src} 94 - alt="" 95 - width={64} 96 - height={64} 97 - /> 98 - )} 99 - </Show> 100 - <div class={styles.profileText}> 101 - <div class={styles.displayName}> 102 - Answer Question 109 + <div class={styles.handle}>@{profile.handle}</div> 103 110 </div> 104 - <div class={styles.handle}>@{profile.handle}</div> 111 + </header> 112 + 113 + <div class={styles.pageActions}> 114 + <A 115 + href={`/${params.handle}/pending`} 116 + class={`${styles.button} ${styles.buttonSecondary}`} 117 + > 118 + ← Back to Pending 119 + </A> 105 120 </div> 106 - </header> 107 121 108 - <div class={styles.pageActions}> 109 - <button 110 - type="button" 111 - class={`${styles.button} ${styles.buttonSecondary}`} 112 - onClick={() => navigate(`/${params.handle}/pending`)} 113 - > 114 - ← Back to Pending 115 - </button> 116 - </div> 117 - 118 - <div class={styles.questionDetail}> 119 - <article class={styles.questionCard}> 120 - <div class={styles.questionLabel}>Question</div> 121 - <div class={styles.questionContent}>{q.content}</div> 122 - <div class={styles.questionMeta}> 123 - {q.anonymous 124 - ? "Anonymous" 125 - : q.author?.displayName || 126 - q.author?.handle || 127 - "Unknown"}{" "} 128 - · {formatWhen(q.createdAt)} 129 - <SourceAttribution 130 - sourceType={q.sourceType || 'askimut'} 131 - sourceUri={q.sourceUri} 132 - sourceData={q.sourceData} 133 - /> 134 - </div> 135 - </article> 122 + <div class={styles.questionDetail}> 123 + <article class={styles.questionCard}> 124 + <div class={styles.questionLabel}>Question</div> 125 + <div class={styles.questionContent}>{q.content}</div> 126 + <div class={styles.questionMeta}> 127 + {q.anonymous 128 + ? "Anonymous" 129 + : q.author?.displayName || 130 + q.author?.handle || 131 + "Unknown"}{" "} 132 + · {formatWhen(q.createdAt)} 133 + <SourceAttribution 134 + sourceType={q.sourceType || 'askimut'} 135 + sourceUri={q.sourceUri} 136 + sourceData={q.sourceData} 137 + /> 138 + </div> 139 + </article> 136 140 137 - <div class={styles.answerForm}> 138 - <form 139 - class={styles.form} 140 - action={submitAnswer.with(q.id)} 141 - method="post" 142 - onSubmit={() => { 143 - // Add a small delay to allow form submission to complete 144 - setTimeout(handleSuccess, 100); 145 - }} 146 - > 147 - <label class={styles.formLabel} for="answer-content"> 148 - Your Answer 149 - </label> 150 - <textarea 151 - id="answer-content" 152 - class={styles.textarea} 153 - name="content" 154 - required 155 - placeholder="Write your answer…" 156 - rows={4} 157 - maxLength={1000} 158 - minLength={5} 159 - onInput={(e) => { 160 - const target = e.target as HTMLTextAreaElement; 161 - if (target.value.length < 5) { 162 - target.setCustomValidity("Answer must be at least 5 characters long"); 163 - } else if (target.value.length > 1000) { 164 - target.setCustomValidity("Answer must be less than 1000 characters"); 165 - } else { 166 - target.setCustomValidity(""); 167 - } 141 + <div class={styles.answerForm}> 142 + <form 143 + class={styles.form} 144 + onSubmit={async (e) => { 145 + e.preventDefault(); 146 + await doSubmit(q.id, new FormData(e.currentTarget)); 168 147 }} 169 - /> 170 - <div class={styles.formActions}> 171 - <button 172 - type="button" 173 - class={`${styles.button} ${styles.buttonSecondary}`} 174 - onClick={() => navigate(`/${params.handle}/pending`)} 175 - > 176 - Cancel 177 - </button> 178 - <button class={styles.button} type="submit"> 179 - Post answer 180 - </button> 181 - </div> 182 - </form> 148 + > 149 + <label class={styles.formLabel} for="answer-content"> 150 + Your Answer 151 + </label> 152 + <textarea 153 + id="answer-content" 154 + class={styles.textarea} 155 + name="content" 156 + required 157 + placeholder="Write your answer…" 158 + rows={4} 159 + maxLength={1000} 160 + minLength={5} 161 + onInput={(e) => { 162 + const target = e.target as HTMLTextAreaElement; 163 + if (target.value.length < 5) { 164 + target.setCustomValidity("Answer must be at least 5 characters long"); 165 + } else if (target.value.length > 1000) { 166 + target.setCustomValidity("Answer must be less than 1000 characters"); 167 + } else { 168 + target.setCustomValidity(""); 169 + } 170 + }} 171 + /> 172 + <div class={styles.formActions}> 173 + <A 174 + href={`/${params.handle}/pending`} 175 + class={`${styles.button} ${styles.buttonSecondary}`} 176 + > 177 + Cancel 178 + </A> 179 + <button class={styles.button} type="submit" disabled={submitting.pending}> 180 + {submitting.pending ? "Posting…" : "Post answer"} 181 + </button> 182 + </div> 183 + </form> 184 + </div> 183 185 </div> 184 - </div> 185 - </> 186 + </> 186 187 )} 187 188 </Show> 188 189 </div> 189 190 </ErrorBoundary> 190 191 ); 191 - } 192 + }
+103 -110
src/routes/[handle]/pending/index.tsx
··· 1 1 import { 2 + A, 2 3 createAsync, 3 - useNavigate, 4 4 useParams, 5 5 } from "@solidjs/router"; 6 6 import { For, Show, ErrorBoundary } from "solid-js"; ··· 10 10 getUserByHandle, 11 11 getPendingQuestions, 12 12 } from "~/lib/queries"; 13 - import { requireOwner, NotFoundError } from "~/lib/route-guards"; 13 + import { NotFoundError } from "~/lib/route-guards"; 14 14 import SourceAttribution from "~/components/SourceAttribution"; 15 15 import ErrorBoundaryComponent from "~/components/ErrorBoundary"; 16 16 17 17 import styles from "../../[handle].module.css"; 18 18 19 + export const route = { 20 + preload({ params }: { params: { handle: string } }) { 21 + getUserByHandle(params.handle); 22 + getCurrentUser(); 23 + }, 24 + }; 25 + 19 26 function formatWhen(d: Date | string) { 20 27 const date = typeof d === "string" ? new Date(d) : d; 21 28 return date.toLocaleString(undefined, { ··· 26 33 27 34 export default function PendingQuestions() { 28 35 const params = useParams(); 29 - const navigate = useNavigate(); 30 - 31 - // Verify authentication and ensure user owns the profile 32 - const currentUser = createAsync(async () => { 33 - try { 34 - return await requireOwner(params.handle); 35 - } catch (error) { 36 - // Handle redirects gracefully 37 - return null; 38 - } 39 - }); 40 - 36 + 41 37 const profileUser = createAsync(async () => { 42 38 const user = await getUserByHandle(params.handle); 43 39 if (!user) { ··· 46 42 return user; 47 43 }); 48 44 45 + const currentUser = createAsync(() => getCurrentUser()); 46 + 49 47 const pendingQuestions = createAsync(async () => { 50 - const profile = await profileUser(); 51 - if (!profile || !currentUser()) return []; 48 + const profile = await getUserByHandle(params.handle); 49 + const user = await getCurrentUser(); 50 + if (!profile || !user || user.did !== profile.did) return []; 52 51 return getPendingQuestions(profile.did); 53 52 }); 54 53 55 - const handleQuestionClick = (questionId: string) => { 56 - navigate(`/${params.handle}/pending/${questionId}`); 54 + const ownerContext = () => { 55 + const profile = profileUser(); 56 + const user = currentUser(); 57 + if (!profile || !user || user.did !== profile.did) return undefined; 58 + return [profile, user] as const; 57 59 }; 58 60 59 61 return ( 60 62 <ErrorBoundary fallback={ErrorBoundaryComponent}> 61 63 <div class={styles.page}> 62 - <Show when={profileUser() && currentUser()} keyed> 63 - {([profile, user]) => ( 64 - <> 65 - <header class={styles.header}> 64 + <Show when={ownerContext()} keyed> 65 + {([profile]) => ( 66 + <> 67 + <header class={styles.header}> 68 + <Show 69 + when={profile.avatarUrl} 70 + keyed 71 + fallback={ 72 + <div 73 + class={`${styles.avatar} ${styles.avatarPlaceholder}`} 74 + aria-hidden 75 + > 76 + {(profile.displayName || profile.handle) 77 + .slice(0, 1) 78 + .toUpperCase()} 79 + </div> 80 + } 81 + > 82 + {(src) => ( 83 + <img 84 + class={styles.avatar} 85 + src={src} 86 + alt="" 87 + width={64} 88 + height={64} 89 + /> 90 + )} 91 + </Show> 92 + <div class={styles.profileText}> 93 + <div class={styles.displayName}> 94 + Pending Questions 95 + </div> 96 + <div class={styles.handle}>@{profile.handle}</div> 97 + </div> 98 + </header> 99 + 100 + <div class={styles.pageActions}> 101 + <A 102 + href={`/${params.handle}`} 103 + class={`${styles.button} ${styles.buttonSecondary}`} 104 + > 105 + ← Back to Profile 106 + </A> 107 + </div> 108 + 66 109 <Show 67 - when={profile.avatarUrl} 68 - keyed 110 + when={pendingQuestions() && pendingQuestions()!.length > 0} 69 111 fallback={ 70 - <div 71 - class={`${styles.avatar} ${styles.avatarPlaceholder}`} 72 - aria-hidden 73 - > 74 - {(profile.displayName || profile.handle) 75 - .slice(0, 1) 76 - .toUpperCase()} 112 + <div class={styles.emptyState}> 113 + <p class={styles.status}> 114 + No pending questions. All caught up! 115 + </p> 77 116 </div> 78 117 } 79 118 > 80 - {(src) => ( 81 - <img 82 - class={styles.avatar} 83 - src={src} 84 - alt="" 85 - width={64} 86 - height={64} 87 - /> 88 - )} 89 - </Show> 90 - <div class={styles.profileText}> 91 - <div class={styles.displayName}> 92 - Pending Questions 93 - </div> 94 - <div class={styles.handle}>@{profile.handle}</div> 95 - </div> 96 - </header> 97 - 98 - <div class={styles.pageActions}> 99 - <button 100 - type="button" 101 - class={`${styles.button} ${styles.buttonSecondary}`} 102 - onClick={() => navigate(`/${params.handle}`)} 103 - > 104 - ← Back to Profile 105 - </button> 106 - </div> 107 - 108 - <Show 109 - when={pendingQuestions() && pendingQuestions()!.length > 0} 110 - fallback={ 111 - <div class={styles.emptyState}> 112 - <p class={styles.status}> 113 - No pending questions. All caught up! 114 - </p> 119 + <p class={styles.sectionTitle}> 120 + Questions waiting for your answer ({pendingQuestions()?.length ?? 0}) 121 + </p> 122 + <div class={styles.questionList}> 123 + <For each={pendingQuestions()}> 124 + {(q) => ( 125 + <A 126 + href={`/${params.handle}/pending/${q.id}`} 127 + class={`${styles.questionCard} ${styles.clickable}`} 128 + > 129 + <div class={styles.questionContent}>{q.content}</div> 130 + <div class={styles.questionMeta}> 131 + {q.anonymous 132 + ? "Anonymous" 133 + : q.author?.displayName || 134 + q.author?.handle || 135 + "Unknown"}{" "} 136 + · {formatWhen(q.createdAt)} 137 + <SourceAttribution 138 + sourceType={q.sourceType || 'askimut'} 139 + sourceUri={q.sourceUri} 140 + sourceData={q.sourceData} 141 + /> 142 + </div> 143 + <div class={styles.questionActions}> 144 + <span class={styles.actionHint}>Click to answer →</span> 145 + </div> 146 + </A> 147 + )} 148 + </For> 115 149 </div> 116 - } 117 - > 118 - <p class={styles.sectionTitle}> 119 - Questions waiting for your answer ({pendingQuestions()?.length || 0}) 120 - </p> 121 - <div class={styles.questionList}> 122 - <For each={pendingQuestions()}> 123 - {(q) => ( 124 - <article 125 - class={`${styles.questionCard} ${styles.clickable}`} 126 - onClick={() => handleQuestionClick(q.id)} 127 - role="button" 128 - tabIndex={0} 129 - onKeyDown={(e) => { 130 - if (e.key === 'Enter' || e.key === ' ') { 131 - e.preventDefault(); 132 - handleQuestionClick(q.id); 133 - } 134 - }} 135 - > 136 - <div class={styles.questionContent}>{q.content}</div> 137 - <div class={styles.questionMeta}> 138 - {q.anonymous 139 - ? "Anonymous" 140 - : q.author?.displayName || 141 - q.author?.handle || 142 - "Unknown"}{" "} 143 - · {formatWhen(q.createdAt)} 144 - <SourceAttribution 145 - sourceType={q.sourceType || 'askimut'} 146 - sourceUri={q.sourceUri} 147 - sourceData={q.sourceData} 148 - /> 149 - </div> 150 - <div class={styles.questionActions}> 151 - <span class={styles.actionHint}>Click to answer →</span> 152 - </div> 153 - </article> 154 - )} 155 - </For> 156 - </div> 157 - </Show> 158 - </> 150 + </Show> 151 + </> 159 152 )} 160 153 </Show> 161 154 </div> 162 155 </ErrorBoundary> 163 156 ); 164 - } 157 + }
+3 -1
src/routes/client-metadata.json.ts
··· 1 + import { log } from "~/lib/log"; 1 2 import { getOAuthClientMetadata } from "~/lib/oauth"; 2 3 3 - export function GET() { 4 + export function GET(event: { request: Request }) { 5 + log("[client-metadata] fetched by:", event.request.headers.get("user-agent") ?? "unknown"); 4 6 const metadata = getOAuthClientMetadata(); 5 7 6 8 return new Response(JSON.stringify(metadata), {
+11 -1
src/routes/index.tsx
··· 1 1 import { Navigate, createAsync, useSubmission } from "@solidjs/router"; 2 - import { Show } from "solid-js"; 2 + import { Show, createEffect } from "solid-js"; 3 3 4 4 import { getCurrentUser, initiateLogin } from "~/lib/queries"; 5 5 ··· 8 8 export default function Home() { 9 9 const user = createAsync(() => getCurrentUser()); 10 10 const loggingIn = useSubmission(initiateLogin); 11 + 12 + // The app must be served from http://[::1]:3000 (IPv6 loopback) so that the 13 + // browser treats it as cross-site versus the PDS on http://localhost:2584. 14 + // The atproto oauth-provider rejects same-site authorize requests with a 15 + // 400 "Invalid request" page. 16 + createEffect(() => { 17 + const url = loggingIn.result?.redirectUrl; 18 + if (!url) return; 19 + window.location.assign(url); 20 + }); 11 21 12 22 return ( 13 23 <>
+1 -4
src/routes/oauth/login.ts
··· 1 1 "use server"; 2 2 3 - import { redirect } from "@solidjs/router"; 4 3 import { getOAuthClient } from "~/lib/oauth"; 5 4 6 5 export async function initiateLogin(formData: FormData) { ··· 15 14 const url = await oauth.authorize(handle, { 16 15 scope: "atproto transition:generic", 17 16 }); 18 - throw redirect(url.toString()); 17 + return { redirectUrl: url.toString() }; 19 18 } catch (err) { 20 - if (err instanceof Response) throw err; 21 - console.error("OAuth authorize failed:", err); 22 19 return { 23 20 error: `Could not initiate login: ${err instanceof Error ? err.message : String(err)}`, 24 21 };