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