Highly ambitious ATProtocol AppView service and sdks
at main 15 kB view raw
1/// <reference lib="deno.ns" /> 2 3import type { Route } from "@std/http/unstable-route"; 4import { 5 ADMIN_DIDS, 6 createOAuthClient, 7 oauthConfig, 8 oauthSessions, 9 oauthStorage, 10 sessionStore, 11 SLICE_URI, 12} from "./config.ts"; 13import { OAuthClient } from "@slices/oauth"; 14import { handleDocsDetail, handleDocsIndex } from "./docs.ts"; 15import { initializeUserProfile } from "./profile-init.ts"; 16import { checkUserAccess } from "./invite-checker.ts"; 17import { handleLandingOGImage } from "./og-images.tsx"; 18 19// ============================================================================ 20// AUTH ROUTES 21// ============================================================================ 22 23async function handleOAuthAuthorize(req: Request): Promise<Response> { 24 try { 25 const formData = await req.formData(); 26 const loginHint = formData.get("loginHint") as string; 27 28 if (!loginHint) { 29 return new Response("Missing login hint", { status: 400 }); 30 } 31 32 const tempOAuthClient = new OAuthClient( 33 oauthConfig, 34 oauthStorage, 35 loginHint, 36 ); 37 38 const authResult = await tempOAuthClient.authorize({ 39 loginHint, 40 }); 41 42 return Response.redirect(authResult.authorizationUrl, 302); 43 } catch (error) { 44 console.error("OAuth authorize error:", error); 45 46 return Response.redirect( 47 "/login?error=" + 48 encodeURIComponent("Please check your handle and try again."), 49 302, 50 ); 51 } 52} 53 54async function handleOAuthCallback(req: Request): Promise<Response> { 55 try { 56 const url = new URL(req.url); 57 const code = url.searchParams.get("code"); 58 const state = url.searchParams.get("state"); 59 60 // Check if state contains waitlist data 61 let isWaitlistFlow = false; 62 if (state) { 63 try { 64 const decodedState = JSON.parse(atob(state)); 65 isWaitlistFlow = decodedState.isWaitlistFlow === true; 66 } catch { 67 // State is not our custom waitlist state, continue with normal flow 68 } 69 } 70 71 // If this is a waitlist flow, handle it separately 72 if (isWaitlistFlow) { 73 return handleWaitlistCallback(req); 74 } 75 76 if (!code || !state) { 77 return Response.redirect( 78 "/login?error=" + encodeURIComponent("Invalid OAuth callback"), 79 302, 80 ); 81 } 82 83 const tempOAuthClient = new OAuthClient(oauthConfig, oauthStorage, "temp"); 84 const tokens = await tempOAuthClient.handleCallback({ code, state }); 85 const sessionId = await oauthSessions.createOAuthSession(tokens); 86 87 if (!sessionId) { 88 return Response.redirect( 89 "/login?error=" + encodeURIComponent("Failed to create session"), 90 302, 91 ); 92 } 93 94 // Initialize user profile before redirecting 95 const oauthClient = createOAuthClient(sessionId); 96 const userInfo = await oauthClient.getUserInfo(); 97 98 // Check waitlist access if user info is available 99 if (userInfo?.sub) { 100 if (!SLICE_URI || !ADMIN_DIDS) { 101 console.error("Missing SLICE_URI or ADMIN_DIDS configuration"); 102 } else { 103 const { hasAccess, isOnWaitlist } = await checkUserAccess( 104 userInfo.sub, 105 SLICE_URI, 106 ); 107 if (!hasAccess) { 108 // Clear OAuth session and redirect to waitlist page 109 await oauthClient.logout(); 110 111 const errorCode = isOnWaitlist 112 ? "already_on_waitlist" 113 : "invite_required"; 114 return Response.redirect( 115 `/waitlist?error=${errorCode}`, 116 302, 117 ); 118 } 119 } 120 } 121 122 if (userInfo?.sub && userInfo?.name) { 123 await initializeUserProfile(userInfo.sub, userInfo.name, { 124 accessToken: tokens.accessToken, 125 tokenType: tokens.tokenType, 126 }); 127 } 128 129 const sessionCookie = sessionStore.createSessionCookie(sessionId); 130 131 return new Response(null, { 132 status: 302, 133 headers: { 134 Location: "/", 135 "Set-Cookie": sessionCookie, 136 }, 137 }); 138 } catch (error) { 139 console.error("OAuth callback error:", error); 140 return Response.redirect( 141 "/login?error=" + encodeURIComponent("Authentication failed"), 142 302, 143 ); 144 } 145} 146 147async function handleLogout(req: Request): Promise<Response> { 148 const session = await sessionStore.getSessionFromRequest(req); 149 150 if (session) { 151 await oauthSessions.logout(session.sessionId); 152 } 153 154 const clearCookie = sessionStore.createLogoutCookie(); 155 156 return new Response(null, { 157 status: 302, 158 headers: { 159 Location: "/login", 160 "Set-Cookie": clearCookie, 161 }, 162 }); 163} 164 165// ============================================================================ 166// WAITLIST HANDLERS 167// ============================================================================ 168 169async function handleWaitlistInitiate(req: Request): Promise<Response> { 170 try { 171 const formData = await req.formData(); 172 const handle = formData.get("handle") as string; 173 174 if (!handle) { 175 return new Response("Missing handle", { status: 400 }); 176 } 177 178 // Create temporary OAuth client for waitlist flow 179 const tempOAuthClient = new OAuthClient(oauthConfig, oauthStorage, handle); 180 181 // Store waitlist flag in state parameter 182 const waitlistState = btoa( 183 JSON.stringify({ 184 isWaitlistFlow: true, 185 handle, 186 redirectUri: "/auth/waitlist/callback", 187 }), 188 ); 189 190 // Initiate OAuth with minimal scope for waitlist, passing state directly 191 const authResult = await tempOAuthClient.authorize({ 192 loginHint: handle, 193 scope: "atproto repo:network.slices.waitlist.request", 194 state: waitlistState, 195 }); 196 197 return Response.redirect(authResult.authorizationUrl, 302); 198 } catch (error) { 199 console.error("Waitlist initiate error:", error); 200 return Response.redirect( 201 "/waitlist?error=authorization_failed", 202 302, 203 ); 204 } 205} 206 207async function handleWaitlistCallback(req: Request): Promise<Response> { 208 try { 209 const url = new URL(req.url); 210 const code = url.searchParams.get("code"); 211 const state = url.searchParams.get("state"); 212 213 if (!code || !state) { 214 return Response.redirect( 215 "/waitlist?error=invalid_callback", 216 302, 217 ); 218 } 219 220 // Decode waitlist state from state parameter 221 let waitlistData: { 222 isWaitlistFlow?: boolean; 223 handle?: string; 224 redirectUri?: string; 225 } = {}; 226 try { 227 waitlistData = JSON.parse(atob(state)); 228 } catch { 229 console.error("Failed to decode waitlist state"); 230 } 231 232 // Create temp session-scoped client for waitlist (not creating actual session) 233 const tempSessionId = "waitlist_" + Date.now(); 234 const tempOAuthClient = new OAuthClient( 235 oauthConfig, 236 oauthStorage, 237 tempSessionId, 238 ); 239 240 // Exchange code for tokens 241 const tokens = await tempOAuthClient.handleCallback({ code, state }); 242 243 // Store tokens so we can fetch user info 244 await oauthStorage.setTokens(tokens, tempSessionId); 245 246 // Get user info 247 const userInfo = await tempOAuthClient.getUserInfo(); 248 249 if (!userInfo) { 250 return Response.redirect( 251 "/waitlist?error=no_user_info", 252 302, 253 ); 254 } 255 256 // Get slice URI from environment 257 const sliceUri = Deno.env.get("VITE_SLICE_URI"); 258 if (!sliceUri) { 259 console.error("Missing VITE_SLICE_URI environment variable"); 260 return Response.redirect( 261 "/waitlist?error=waitlist_failed", 262 302, 263 ); 264 } 265 266 // Create waitlist record via GraphQL mutation 267 try { 268 const mutation = ` 269 mutation CreateWaitlistRequest($slice: String!, $createdAt: String!, $rkey: String!) { 270 createNetworkSlicesWaitlistRequest( 271 input: { 272 slice: $slice 273 createdAt: $createdAt 274 } 275 rkey: $rkey 276 ) { 277 uri 278 } 279 } 280 `; 281 282 const graphqlUrl = `${API_URL}/graphql?slice=${ 283 encodeURIComponent(sliceUri) 284 }`; 285 const response = await fetch(graphqlUrl, { 286 method: "POST", 287 headers: { 288 "Content-Type": "application/json", 289 "Authorization": `${tokens.tokenType} ${tokens.accessToken}`, 290 }, 291 body: JSON.stringify({ 292 query: mutation, 293 variables: { 294 slice: sliceUri, 295 createdAt: new Date().toISOString(), 296 rkey: "self", 297 }, 298 }), 299 }); 300 301 if (!response.ok) { 302 throw new Error(`GraphQL request failed: ${response.statusText}`); 303 } 304 305 const result = await response.json(); 306 if (result.errors) { 307 console.error("GraphQL mutation errors:", result.errors); 308 throw new Error("Failed to create waitlist record"); 309 } 310 311 // Sync user collections to populate their Bluesky profile data 312 try { 313 const syncMutation = ` 314 mutation SyncUserCollections($slice: String!) { 315 syncNetworkSlicesSliceUserCollections(slice: $slice) { 316 success 317 } 318 } 319 `; 320 321 await fetch(graphqlUrl, { 322 method: "POST", 323 headers: { 324 "Content-Type": "application/json", 325 "Authorization": `${tokens.tokenType} ${tokens.accessToken}`, 326 }, 327 body: JSON.stringify({ 328 query: syncMutation, 329 variables: { slice: sliceUri }, 330 }), 331 }); 332 } catch (syncError) { 333 console.error( 334 "Failed to sync user collections for waitlist user:", 335 syncError, 336 ); 337 // Don't fail the waitlist process if sync fails 338 } 339 } catch (error) { 340 console.error("Failed to create waitlist record:", error); 341 // Continue anyway - we don't want to block the user 342 } 343 344 // Clear temp OAuth session since this is just for waitlist 345 await tempOAuthClient.logout(); 346 347 // Redirect back to waitlist page with success parameter 348 const handle = userInfo.name || waitlistData.handle || "user"; 349 const params = new URLSearchParams({ 350 waitlist: "success", 351 handle, 352 }); 353 return Response.redirect(`/waitlist?${params.toString()}`, 302); 354 } catch (error) { 355 console.error("Waitlist callback error:", error); 356 return Response.redirect( 357 "/waitlist?error=waitlist_failed", 358 302, 359 ); 360 } 361} 362 363// ============================================================================ 364// SESSION API 365// ============================================================================ 366 367async function handleGetSession(req: Request): Promise<Response> { 368 try { 369 const session = await sessionStore.getSessionFromRequest(req); 370 371 if (!session) { 372 return Response.json({ authenticated: false }, { status: 401 }); 373 } 374 375 // Get user info from OAuth client 376 const oauthClient = createOAuthClient(session.sessionId); 377 const userInfo = await oauthClient.getUserInfo(); 378 379 if (!userInfo) { 380 return Response.json({ authenticated: false }, { status: 401 }); 381 } 382 383 // Get access token for API documentation 384 let accessToken: string | undefined; 385 try { 386 const tokens = await oauthClient.getTokens(); 387 accessToken = tokens?.accessToken; 388 } catch (error) { 389 console.error("Could not get access token:", error); 390 } 391 392 return Response.json({ 393 authenticated: true, 394 user: { 395 did: userInfo.sub, 396 handle: userInfo.name, 397 }, 398 accessToken, 399 }); 400 } catch (error) { 401 console.error("Session check error:", error); 402 return Response.json({ authenticated: false }, { status: 401 }); 403 } 404} 405 406// ============================================================================ 407// GRAPHQL PROXY 408// ============================================================================ 409 410const API_URL = Deno.env.get("API_URL"); 411 412if (!API_URL) { 413 throw new Error( 414 "Missing API_URL configuration. Please ensure .env file contains API_URL", 415 ); 416} 417 418async function handleGraphQLProxy(req: Request): Promise<Response> { 419 try { 420 const url = new URL(req.url); 421 const session = await sessionStore.getSessionFromRequest(req); 422 423 // Build request headers 424 const headers: Record<string, string> = { 425 "Content-Type": "application/json", 426 }; 427 428 // If authenticated, add authorization header 429 if (session) { 430 const oauthClient = createOAuthClient(session.sessionId); 431 const tokens = await oauthClient.getTokens(); 432 433 if (tokens) { 434 headers["Authorization"] = `${tokens.tokenType} ${tokens.accessToken}`; 435 } 436 } 437 438 // Forward the request to the GraphQL API 439 const graphqlUrl = `${API_URL}/graphql${url.search}`; 440 const response = await fetch(graphqlUrl, { 441 method: req.method, 442 headers, 443 body: req.method !== "GET" ? await req.text() : undefined, 444 }); 445 446 // Return the GraphQL response 447 const data = await response.json(); 448 return Response.json(data, { 449 status: response.status, 450 headers: { 451 "Content-Type": "application/json", 452 }, 453 }); 454 } catch (error) { 455 console.error("GraphQL proxy error:", error); 456 return Response.json( 457 { error: "GraphQL request failed" }, 458 { status: 500 }, 459 ); 460 } 461} 462 463// ============================================================================ 464// ROUTE EXPORTS 465// ============================================================================ 466 467export const allRoutes: Route[] = [ 468 // OAuth flow 469 { 470 method: "POST", 471 pattern: new URLPattern({ pathname: "/oauth/authorize" }), 472 handler: handleOAuthAuthorize, 473 }, 474 { 475 method: "GET", 476 pattern: new URLPattern({ pathname: "/oauth/callback" }), 477 handler: handleOAuthCallback, 478 }, 479 // Waitlist flow 480 { 481 method: "POST", 482 pattern: new URLPattern({ pathname: "/auth/waitlist/initiate" }), 483 handler: handleWaitlistInitiate, 484 }, 485 // Logout 486 { 487 method: "POST", 488 pattern: new URLPattern({ pathname: "/logout" }), 489 handler: handleLogout, 490 }, 491 // Session API 492 { 493 method: "GET", 494 pattern: new URLPattern({ pathname: "/api/session" }), 495 handler: handleGetSession, 496 }, 497 // Docs API 498 { 499 method: "GET", 500 pattern: new URLPattern({ pathname: "/api/docs" }), 501 handler: handleDocsIndex, 502 }, 503 { 504 method: "GET", 505 pattern: new URLPattern({ pathname: "/api/docs/:slug" }), 506 handler: handleDocsDetail, 507 }, 508 // GraphQL Proxy 509 { 510 method: "POST", 511 pattern: new URLPattern({ pathname: "/graphql" }), 512 handler: handleGraphQLProxy, 513 }, 514 { 515 method: "GET", 516 pattern: new URLPattern({ pathname: "/graphql" }), 517 handler: handleGraphQLProxy, 518 }, 519 // OG Images 520 { 521 method: "GET", 522 pattern: new URLPattern({ pathname: "/og-image" }), 523 handler: handleLandingOGImage, 524 }, 525];