Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
1import { Elysia } from 'elysia' 2import type { Context } from 'elysia' 3import { cors } from '@elysiajs/cors' 4import { staticPlugin } from '@elysiajs/static' 5 6import type { Config } from './lib/types' 7import { BASE_HOST } from '@wisp/constants' 8import { 9 createClientMetadata, 10 getOAuthClient, 11 getCurrentKeys, 12 cleanupExpiredSessions, 13 rotateKeysIfNeeded 14} from './lib/oauth-client' 15import { getCookieSecret } from './lib/db' 16import { authRoutes } from './routes/auth' 17import { wispRoutes } from './routes/wisp' 18import { domainRoutes } from './routes/domain' 19import { userRoutes } from './routes/user' 20import { siteRoutes } from './routes/site' 21import { csrfProtection } from './lib/csrf' 22import { DNSVerificationWorker } from './lib/dns-verification-worker' 23import { createLogger, logCollector } from '@wisp/observability' 24import { observabilityMiddleware } from '@wisp/observability/middleware/elysia' 25import { promptAdminSetup } from './lib/admin-auth' 26import { adminRoutes } from './routes/admin' 27 28const logger = createLogger('main-app') 29 30const config: Config = { 31 domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as Config['domain'], 32 clientName: Bun.env.CLIENT_NAME ?? 'PDS-View' 33} 34 35// Initialize admin setup (prompt if no admin exists) 36await promptAdminSetup() 37 38// Get or generate cookie signing secret 39const cookieSecret = await getCookieSecret() 40 41const client = await getOAuthClient(config) 42 43// Periodic maintenance: cleanup expired sessions and rotate keys 44// Run every hour 45const runMaintenance = async () => { 46 console.log('[Maintenance] Running periodic maintenance...') 47 await cleanupExpiredSessions() 48 await rotateKeysIfNeeded() 49} 50 51// Run maintenance on startup 52runMaintenance() 53 54// Schedule maintenance to run every hour 55setInterval(runMaintenance, 60 * 60 * 1000) 56 57// Start DNS verification worker (runs every 10 minutes) 58const dnsVerifier = new DNSVerificationWorker( 59 10 * 60 * 1000, // 10 minutes 60 (msg, data) => { 61 logCollector.info(`[DNS Verifier] ${msg}`, 'main-app', data ? { data } : undefined) 62 } 63) 64 65dnsVerifier.start() 66logger.info('DNS Verifier Started - checking custom domains every 10 minutes') 67 68export const app = new Elysia({ 69 serve: { 70 maxRequestBodySize: 1024 * 1024 * 128 * 3, 71 development: Bun.env.NODE_ENV !== 'production' ? true : false, 72 id: Bun.env.NODE_ENV !== 'production' ? undefined : null, 73 }, 74 cookie: { 75 secrets: cookieSecret, 76 sign: ['did'] 77 } 78 }) 79 // Observability middleware 80 .onBeforeHandle(observabilityMiddleware('main-app').beforeHandle) 81 .onAfterHandle((ctx: Context) => { 82 observabilityMiddleware('main-app').afterHandle(ctx) 83 // Security headers middleware 84 const { set } = ctx 85 // Prevent clickjacking attacks 86 set.headers['X-Frame-Options'] = 'DENY' 87 // Prevent MIME type sniffing 88 set.headers['X-Content-Type-Options'] = 'nosniff' 89 // Strict Transport Security (HSTS) - enforce HTTPS 90 set.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' 91 // Referrer policy - limit referrer information 92 set.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' 93 // Content Security Policy 94 set.headers['Content-Security-Policy'] = 95 "default-src 'self'; " + 96 "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + 97 "style-src 'self' 'unsafe-inline'; " + 98 "img-src 'self' data: https:; " + 99 "font-src 'self' data:; " + 100 "connect-src 'self' https:; " + 101 "frame-ancestors 'none'; " + 102 "base-uri 'self'; " + 103 "form-action 'self'" 104 // Additional security headers 105 set.headers['X-XSS-Protection'] = '1; mode=block' 106 set.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()' 107 }) 108 .onError(observabilityMiddleware('main-app').onError) 109 .use(csrfProtection()) 110 .use(authRoutes(client, cookieSecret)) 111 .use(wispRoutes(client, cookieSecret)) 112 .use(domainRoutes(client, cookieSecret)) 113 .use(userRoutes(client, cookieSecret)) 114 .use(siteRoutes(client, cookieSecret)) 115 .use(adminRoutes(cookieSecret)) 116 .use( 117 await staticPlugin({ 118 assets: './apps/main-app/public', 119 prefix: '/', 120 }) 121 ) 122 .get('/client-metadata.json', () => { 123 return createClientMetadata(config) 124 }) 125 .get('/jwks.json', async ({ set }) => { 126 // Prevent caching to ensure clients always get fresh keys after rotation 127 set.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0' 128 set.headers['Pragma'] = 'no-cache' 129 set.headers['Expires'] = '0' 130 131 const keys = await getCurrentKeys() 132 if (!keys.length) return { keys: [] } 133 134 return { 135 keys: keys.map((k) => { 136 const jwk = k.publicJwk ?? k 137 const { ...pub } = jwk 138 return pub 139 }) 140 } 141 }) 142 .get('/api/health', () => { 143 const dnsVerifierHealth = dnsVerifier.getHealth() 144 return { 145 status: 'ok', 146 timestamp: new Date().toISOString(), 147 dnsVerifier: dnsVerifierHealth 148 } 149 }) 150 .get('/api/screenshots', async () => { 151 const fs = await import('fs/promises') 152 153 try { 154 const screenshotsDir = './apps/main-app/public/screenshots' 155 const files = await fs.readdir(screenshotsDir) 156 const screenshots = files.filter(file => file.endsWith('.png')) 157 return { screenshots } 158 } catch (error) { 159 return { screenshots: [] } 160 } 161 }) 162 .get('/api/admin/test', () => { 163 return { message: 'Admin routes test works!' } 164 }) 165 .post('/api/admin/verify-dns', async () => { 166 try { 167 await dnsVerifier.trigger() 168 return { 169 success: true, 170 message: 'DNS verification triggered' 171 } 172 } catch (error) { 173 return { 174 success: false, 175 error: error instanceof Error ? error.message : String(error) 176 } 177 } 178 }) 179 .get('/.well-known/atproto-did', ({ set }) => { 180 // Return plain text DID for AT Protocol domain verification 181 set.headers['Content-Type'] = 'text/plain' 182 return 'did:plc:7puq73yz2hkvbcpdhnsze2qw' 183 }) 184 .use(cors({ 185 origin: config.domain, 186 credentials: true, 187 methods: ['GET', 'POST', 'DELETE', 'PUT', 'PATCH', 'OPTIONS'], 188 allowedHeaders: ['Content-Type', 'Authorization', 'Origin', 'X-Forwarded-Host'], 189 exposeHeaders: ['Content-Type'], 190 maxAge: 86400 // 24 hours 191 })) 192 .listen(8000) 193 194console.log( 195 `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}` 196)