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