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, closeDatabase } 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)
208
209// Graceful shutdown
210process.on('SIGINT', async () => {
211 console.log('\n🛑 Shutting down...')
212 dnsVerifier.stop()
213 await closeDatabase()
214 process.exit(0)
215})
216
217process.on('SIGTERM', async () => {
218 console.log('\n🛑 Shutting down...')
219 dnsVerifier.stop()
220 await closeDatabase()
221 process.exit(0)
222})