Highly ambitious ATProtocol AppView service and sdks
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];