Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
1import { Elysia, t } from 'elysia'
2import { NodeOAuthClient } from '@atproto/oauth-client-node'
3import { getSitesByDid, getDomainByDid, getCookieSecret } from '../lib/db'
4import { syncSitesFromPDS } from '../lib/sync-sites'
5import { authenticateRequest } from '../lib/wisp-auth'
6import { createLogger } from '@wisp/observability'
7
8const logger = createLogger('main-app')
9
10export const authRoutes = (client: NodeOAuthClient, cookieSecret: string) => new Elysia({
11 cookie: {
12 secrets: cookieSecret,
13 sign: ['did']
14 }
15 })
16 .post('/api/auth/signin', async (c) => {
17 let handle = 'unknown'
18 try {
19 const body = c.body as { handle: string }
20 handle = body.handle
21 logger.info('Sign-in attempt', { handle })
22 const state = crypto.randomUUID()
23 const url = await client.authorize(handle, { state })
24 logger.info('Authorization URL generated', { handle })
25 return { url: url.toString() }
26 } catch (err) {
27 logger.error('Signin error', err, { handle })
28 console.error('[Auth] Full error:', err)
29 return { error: 'Authentication failed', details: err instanceof Error ? err.message : String(err) }
30 }
31 })
32 .get('/api/auth/callback', async (c) => {
33 try {
34 const params = new URLSearchParams(c.query)
35
36 // client.callback() validates the state parameter internally
37 // It will throw an error if state validation fails (CSRF protection)
38 const { session } = await client.callback(params)
39
40 if (!session) {
41 logger.error('[Auth] OAuth callback failed: no session returned')
42 c.cookie.did.remove()
43 return c.redirect('/?error=auth_failed')
44 }
45
46 const cookieSession = c.cookie
47 cookieSession.did.set({
48 value: session.did,
49 httpOnly: true,
50 secure: process.env.NODE_ENV === 'production',
51 sameSite: 'lax',
52 maxAge: 30 * 24 * 60 * 60 // 30 days
53 })
54
55 // Sync sites from PDS to database cache
56 logger.debug('[Auth] Syncing sites from PDS for', session.did as any)
57 try {
58 const syncResult = await syncSitesFromPDS(session.did, session)
59 logger.debug(`[Auth] Sync complete: ${syncResult.synced} sites synced`)
60 if (syncResult.errors.length > 0) {
61 logger.debug('[Auth] Sync errors:', syncResult.errors)
62 }
63 } catch (err) {
64 logger.error('[Auth] Failed to sync sites', err)
65 // Don't fail auth if sync fails, just log it
66 }
67
68 // Check if user has any sites or domain
69 const sites = await getSitesByDid(session.did)
70 const domain = await getDomainByDid(session.did)
71
72 // If no sites and no domain, redirect to onboarding
73 if (sites.length === 0 && !domain) {
74 return c.redirect('/onboarding')
75 }
76
77 return c.redirect('/editor')
78 } catch (err) {
79 // This catches state validation failures and other OAuth errors
80 logger.error('[Auth] OAuth callback error', err)
81 c.cookie.did.remove()
82 return c.redirect('/?error=auth_failed')
83 }
84 })
85 .post('/api/auth/logout', async (c) => {
86 try {
87 const cookieSession = c.cookie
88 const did = cookieSession.did?.value
89
90 // Clear the session cookie
91 cookieSession.did.remove()
92
93 // If we have a DID, try to revoke the OAuth session
94 if (did && typeof did === 'string') {
95 try {
96 await client.revoke(did)
97 logger.debug('[Auth] Revoked OAuth session for', did as any)
98 } catch (err) {
99 logger.error('[Auth] Failed to revoke session', err)
100 // Continue with logout even if revoke fails
101 }
102 }
103
104 return { success: true }
105 } catch (err) {
106 logger.error('[Auth] Logout error', err)
107 return { error: 'Logout failed' }
108 }
109 })
110 .get('/api/auth/status', async (c) => {
111 try {
112 const auth = await authenticateRequest(client, c.cookie)
113
114 if (!auth) {
115 c.cookie.did.remove()
116 return { authenticated: false }
117 }
118
119 return {
120 authenticated: true,
121 did: auth.did
122 }
123 } catch (err) {
124 logger.error('[Auth] Status check error', err)
125 c.cookie.did.remove()
126 return { authenticated: false }
127 }
128 })