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 { Glob } = await import('bun') 152 const glob = new Glob('*.png') 153 const screenshots: string[] = [] 154 155 for await (const file of glob.scan('./apps/main-app/public/screenshots')) { 156 screenshots.push(file) 157 } 158 159 return { screenshots } 160 }) 161 .get('/api/admin/test', () => { 162 return { message: 'Admin routes test works!' } 163 }) 164 .post('/api/admin/verify-dns', async () => { 165 try { 166 await dnsVerifier.trigger() 167 return { 168 success: true, 169 message: 'DNS verification triggered' 170 } 171 } catch (error) { 172 return { 173 success: false, 174 error: error instanceof Error ? error.message : String(error) 175 } 176 } 177 }) 178 .get('/.well-known/atproto-did', ({ set }) => { 179 // Return plain text DID for AT Protocol domain verification 180 set.headers['Content-Type'] = 'text/plain' 181 return 'did:plc:7puq73yz2hkvbcpdhnsze2qw' 182 }) 183 .use(cors({ 184 origin: config.domain, 185 credentials: true, 186 methods: ['GET', 'POST', 'DELETE', 'PUT', 'PATCH', 'OPTIONS'], 187 allowedHeaders: ['Content-Type', 'Authorization', 'Origin', 'X-Forwarded-Host'], 188 exposeHeaders: ['Content-Type'], 189 maxAge: 86400 // 24 hours 190 })) 191 .listen(8000) 192 193console.log( 194 `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}` 195)