Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

move dns worker to main app, better JWK handling.

Changed files
+172 -98
hosting-service
src
public
editor
onboarding
src
-32
hosting-service/src/index.ts
··· 1 1 import { serve } from 'bun'; 2 2 import app from './server'; 3 3 import { FirehoseWorker } from './lib/firehose'; 4 - import { DNSVerificationWorker } from './lib/dns-verification-worker'; 5 4 import { mkdirSync, existsSync } from 'fs'; 6 5 7 6 const PORT = process.env.PORT || 3001; ··· 20 19 21 20 firehose.start(); 22 21 23 - // Start DNS verification worker (runs every hour) 24 - const dnsVerifier = new DNSVerificationWorker( 25 - 60 * 60 * 1000, // 1 hour 26 - (msg, data) => { 27 - console.log('[DNS Verifier]', msg, data || ''); 28 - } 29 - ); 30 - 31 - dnsVerifier.start(); 32 - 33 22 // Add health check endpoint 34 23 app.get('/health', (c) => { 35 24 const firehoseHealth = firehose.getHealth(); 36 - const dnsVerifierHealth = dnsVerifier.getHealth(); 37 25 return c.json({ 38 26 status: 'ok', 39 27 firehose: firehoseHealth, 40 - dnsVerifier: dnsVerifierHealth, 41 28 }); 42 29 }); 43 30 44 - // Add manual DNS verification trigger (for testing/admin) 45 - app.post('/admin/verify-dns', async (c) => { 46 - try { 47 - await dnsVerifier.trigger(); 48 - return c.json({ 49 - success: true, 50 - message: 'DNS verification triggered', 51 - }); 52 - } catch (error) { 53 - return c.json({ 54 - success: false, 55 - error: error instanceof Error ? error.message : String(error), 56 - }, 500); 57 - } 58 - }); 59 - 60 31 // Start HTTP server 61 32 const server = serve({ 62 33 port: PORT, ··· 70 41 Health: http://localhost:${PORT}/health 71 42 Cache: ${CACHE_DIR} 72 43 Firehose: Connected to Jetstream 73 - DNS Verifier: Checking every hour 74 44 `); 75 45 76 46 // Graceful shutdown 77 47 process.on('SIGINT', () => { 78 48 console.log('\n🛑 Shutting down...'); 79 49 firehose.stop(); 80 - dnsVerifier.stop(); 81 50 server.stop(); 82 51 process.exit(0); 83 52 }); ··· 85 54 process.on('SIGTERM', () => { 86 55 console.log('\n🛑 Shutting down...'); 87 56 firehose.stop(); 88 - dnsVerifier.stop(); 89 57 server.stop(); 90 58 process.exit(0); 91 59 });
+2 -2
hosting-service/src/lib/dns-verification-worker.ts src/lib/dns-verification-worker.ts
··· 1 - import { verifyCustomDomain } from '../../../src/lib/dns-verify'; 2 - import { db } from '../../../src/lib/db'; 1 + import { verifyCustomDomain } from './dns-verify'; 2 + import { db } from './db'; 3 3 4 4 interface VerificationStats { 5 5 totalChecked: number;
+3 -19
hosting-service/src/lib/utils.ts
··· 4 4 import { writeFile, readFile } from 'fs/promises'; 5 5 import { safeFetchJson, safeFetchBlob } from './safe-fetch'; 6 6 import { CID } from 'multiformats/cid'; 7 - import { createHash } from 'crypto'; 8 7 9 8 const CACHE_DIR = './cache/sites'; 10 9 const CACHE_TTL = 14 * 24 * 60 * 60 * 1000; // 14 days cache TTL ··· 16 15 rkey: string; 17 16 } 18 17 19 - // Type guards for different blob reference formats 20 18 interface IpldLink { 21 19 $link: string; 22 20 } ··· 63 61 let doc; 64 62 65 63 if (did.startsWith('did:plc:')) { 66 - // Resolve did:plc from plc.directory 67 64 doc = await safeFetchJson(`https://plc.directory/${encodeURIComponent(did)}`); 68 65 } else if (did.startsWith('did:web:')) { 69 - // Resolve did:web from the domain 70 66 const didUrl = didWebToHttps(did); 71 67 doc = await safeFetchJson(didUrl); 72 68 } else { ··· 85 81 } 86 82 87 83 function didWebToHttps(did: string): string { 88 - // did:web:example.com -> https://example.com/.well-known/did.json 89 - // did:web:example.com:path:to:did -> https://example.com/path/to/did/did.json 90 - 91 84 const didParts = did.split(':'); 92 85 if (didParts.length < 3 || didParts[0] !== 'did' || didParts[1] !== 'web') { 93 86 throw new Error('Invalid did:web format'); ··· 97 90 const pathParts = didParts.slice(3); 98 91 99 92 if (pathParts.length === 0) { 100 - // No path, use .well-known 101 93 return `https://${domain}/.well-known/did.json`; 102 94 } else { 103 - // Has path 104 95 const path = pathParts.join('/'); 105 96 return `https://${domain}/${path}/did.json`; 106 97 } ··· 114 105 const url = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(rkey)}`; 115 106 const data = await safeFetchJson(url); 116 107 117 - // Return both the record and its CID for verification 118 108 return { 119 109 record: data.value as WispFsRecord, 120 110 cid: data.cid || '' ··· 126 116 } 127 117 128 118 export function extractBlobCid(blobRef: unknown): string | null { 129 - // Check if it's a direct IPLD link 130 119 if (isIpldLink(blobRef)) { 131 120 return blobRef.$link; 132 121 } 133 122 134 - // Check if it's a typed blob ref with a ref property 135 123 if (isTypedBlobRef(blobRef)) { 136 124 const ref = blobRef.ref; 137 125 138 - // Check if ref is a CID object 139 - if (CID.isCID(ref)) { 140 - return ref.toString(); 126 + const cid = CID.asCID(ref); 127 + if (cid) { 128 + return cid.toString(); 141 129 } 142 130 143 - // Check if ref is an IPLD link object 144 131 if (isIpldLink(ref)) { 145 132 return ref.$link; 146 133 } 147 134 } 148 135 149 - // Check if it's an untyped blob ref with a cid string 150 136 if (isUntypedBlobRef(blobRef)) { 151 137 return blobRef.cid; 152 138 } ··· 157 143 export async function downloadAndCacheSite(did: string, rkey: string, record: WispFsRecord, pdsEndpoint: string, recordCid: string): Promise<void> { 158 144 console.log('Caching site', did, rkey); 159 145 160 - // Validate record structure 161 146 if (!record.root) { 162 147 console.error('Record missing root directory:', JSON.stringify(record, null, 2)); 163 148 throw new Error('Invalid record structure: missing root directory'); ··· 170 155 171 156 await cacheFiles(did, rkey, record.root.entries, pdsEndpoint, ''); 172 157 173 - // Save cache metadata with CID for verification 174 158 await saveCacheMetadata(did, rkey, recordCid); 175 159 } 176 160
+49 -6
public/editor/editor.tsx
··· 94 94 const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null) 95 95 const [isUploading, setIsUploading] = useState(false) 96 96 const [uploadProgress, setUploadProgress] = useState('') 97 + const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([]) 98 + const [uploadedCount, setUploadedCount] = useState(0) 97 99 98 100 // Custom domain modal state 99 101 const [addDomainModalOpen, setAddDomainModalOpen] = useState(false) ··· 232 234 const data = await response.json() 233 235 if (data.success) { 234 236 setUploadProgress('Upload complete!') 237 + setSkippedFiles(data.skippedFiles || []) 238 + setUploadedCount(data.uploadedCount || data.fileCount || 0) 235 239 setSiteName('') 236 240 setSelectedFiles(null) 237 241 238 242 // Refresh sites list 239 243 await fetchSites() 240 244 241 - // Reset form 245 + // Reset form - give more time if there are skipped files 246 + const resetDelay = data.skippedFiles && data.skippedFiles.length > 0 ? 4000 : 1500 242 247 setTimeout(() => { 243 248 setUploadProgress('') 249 + setSkippedFiles([]) 250 + setUploadedCount(0) 244 251 setIsUploading(false) 245 - }, 1500) 252 + }, resetDelay) 246 253 } else { 247 254 throw new Error(data.error || 'Upload failed') 248 255 } ··· 714 721 onChange={(e) => setSiteName(e.target.value)} 715 722 disabled={isUploading} 716 723 /> 724 + <p className="text-xs text-muted-foreground"> 725 + File limits: 100MB per file, 300MB total 726 + </p> 717 727 </div> 718 728 719 729 <div className="grid md:grid-cols-2 gap-4"> ··· 774 784 </div> 775 785 776 786 {uploadProgress && ( 777 - <div className="p-4 bg-muted rounded-lg"> 778 - <div className="flex items-center gap-2"> 779 - <Loader2 className="w-4 h-4 animate-spin" /> 780 - <span className="text-sm">{uploadProgress}</span> 787 + <div className="space-y-3"> 788 + <div className="p-4 bg-muted rounded-lg"> 789 + <div className="flex items-center gap-2"> 790 + <Loader2 className="w-4 h-4 animate-spin" /> 791 + <span className="text-sm">{uploadProgress}</span> 792 + </div> 781 793 </div> 794 + 795 + {skippedFiles.length > 0 && ( 796 + <div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"> 797 + <div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2"> 798 + <AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" /> 799 + <div className="flex-1"> 800 + <span className="font-medium"> 801 + {skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped 802 + </span> 803 + {uploadedCount > 0 && ( 804 + <span className="text-sm ml-2"> 805 + ({uploadedCount} uploaded successfully) 806 + </span> 807 + )} 808 + </div> 809 + </div> 810 + <div className="ml-6 space-y-1 max-h-32 overflow-y-auto"> 811 + {skippedFiles.slice(0, 5).map((file, idx) => ( 812 + <div key={idx} className="text-xs"> 813 + <span className="font-mono">{file.name}</span> 814 + <span className="text-muted-foreground"> - {file.reason}</span> 815 + </div> 816 + ))} 817 + {skippedFiles.length > 5 && ( 818 + <div className="text-xs text-muted-foreground"> 819 + ...and {skippedFiles.length - 5} more 820 + </div> 821 + )} 822 + </div> 823 + </div> 824 + )} 782 825 </div> 783 826 )} 784 827
+58 -11
public/onboarding/onboarding.tsx
··· 10 10 } from '@public/components/ui/card' 11 11 import { Input } from '@public/components/ui/input' 12 12 import { Label } from '@public/components/ui/label' 13 - import { Globe, Upload, CheckCircle2, Loader2 } from 'lucide-react' 13 + import { Globe, Upload, CheckCircle2, Loader2, AlertCircle } from 'lucide-react' 14 14 import Layout from '@public/layouts' 15 15 16 16 type OnboardingStep = 'domain' | 'upload' | 'complete' ··· 28 28 const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null) 29 29 const [isUploading, setIsUploading] = useState(false) 30 30 const [uploadProgress, setUploadProgress] = useState('') 31 + const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([]) 32 + const [uploadedCount, setUploadedCount] = useState(0) 31 33 32 34 // Check domain availability as user types 33 35 useEffect(() => { ··· 117 119 const data = await response.json() 118 120 if (data.success) { 119 121 setUploadProgress('Upload complete!') 120 - // Redirect to the claimed domain 121 - setTimeout(() => { 122 - window.location.href = `https://${claimedDomain}` 123 - }, 1500) 122 + setSkippedFiles(data.skippedFiles || []) 123 + setUploadedCount(data.uploadedCount || data.fileCount || 0) 124 + 125 + // If there are skipped files, show them briefly before redirecting 126 + if (data.skippedFiles && data.skippedFiles.length > 0) { 127 + setTimeout(() => { 128 + window.location.href = `https://${claimedDomain}` 129 + }, 3000) // Give more time to see skipped files 130 + } else { 131 + setTimeout(() => { 132 + window.location.href = `https://${claimedDomain}` 133 + }, 1500) 134 + } 124 135 } else { 125 136 throw new Error(data.error || 'Upload failed') 126 137 } ··· 355 366 <p className="text-xs text-muted-foreground"> 356 367 Supported: HTML, CSS, JS, images, fonts, and more 357 368 </p> 369 + <p className="text-xs text-muted-foreground"> 370 + Limits: 100MB per file, 300MB total 371 + </p> 358 372 </div> 359 373 360 374 {uploadProgress && ( 361 - <div className="p-4 bg-muted rounded-lg"> 362 - <div className="flex items-center gap-2"> 363 - <Loader2 className="w-4 h-4 animate-spin" /> 364 - <span className="text-sm"> 365 - {uploadProgress} 366 - </span> 375 + <div className="space-y-3"> 376 + <div className="p-4 bg-muted rounded-lg"> 377 + <div className="flex items-center gap-2"> 378 + <Loader2 className="w-4 h-4 animate-spin" /> 379 + <span className="text-sm"> 380 + {uploadProgress} 381 + </span> 382 + </div> 367 383 </div> 384 + 385 + {skippedFiles.length > 0 && ( 386 + <div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"> 387 + <div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2"> 388 + <AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" /> 389 + <div className="flex-1"> 390 + <span className="font-medium"> 391 + {skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped 392 + </span> 393 + {uploadedCount > 0 && ( 394 + <span className="text-sm ml-2"> 395 + ({uploadedCount} uploaded successfully) 396 + </span> 397 + )} 398 + </div> 399 + </div> 400 + <div className="ml-6 space-y-1 max-h-32 overflow-y-auto"> 401 + {skippedFiles.slice(0, 5).map((file, idx) => ( 402 + <div key={idx} className="text-xs"> 403 + <span className="font-mono">{file.name}</span> 404 + <span className="text-muted-foreground"> - {file.reason}</span> 405 + </div> 406 + ))} 407 + {skippedFiles.length > 5 && ( 408 + <div className="text-xs text-muted-foreground"> 409 + ...and {skippedFiles.length - 5} more 410 + </div> 411 + )} 412 + </div> 413 + </div> 414 + )} 368 415 </div> 369 416 )} 370 417
+37 -7
src/index.ts
··· 17 17 import { domainRoutes } from './routes/domain' 18 18 import { userRoutes } from './routes/user' 19 19 import { csrfProtection } from './lib/csrf' 20 + import { DNSVerificationWorker } from './lib/dns-verification-worker' 21 + import { logger } from './lib/logger' 20 22 21 23 const config: Config = { 22 24 domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as `https://${string}`, ··· 39 41 // Schedule maintenance to run every hour 40 42 setInterval(runMaintenance, 60 * 60 * 1000) 41 43 44 + // Start DNS verification worker (runs every hour) 45 + const dnsVerifier = new DNSVerificationWorker( 46 + 60 * 60 * 1000, // 1 hour 47 + (msg, data) => { 48 + logger.info('[DNS Verifier]', msg, data || '') 49 + } 50 + ) 51 + 52 + dnsVerifier.start() 53 + logger.info('[DNS Verifier] Started - checking custom domains every hour') 54 + 42 55 export const app = new Elysia() 43 56 // Security headers middleware 44 57 .onAfterHandle(({ set }) => { ··· 66 79 set.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()' 67 80 }) 68 81 .use( 69 - openapi({ 70 - references: fromTypes() 71 - }) 72 - ) 73 - .use( 74 82 await staticPlugin({ 75 83 prefix: '/' 76 84 }) ··· 83 91 .get('/client-metadata.json', (c) => { 84 92 return createClientMetadata(config) 85 93 }) 86 - .get('/jwks.json', (c) => { 87 - const keys = getCurrentKeys() 94 + .get('/jwks.json', async (c) => { 95 + const keys = await getCurrentKeys() 88 96 if (!keys.length) return { keys: [] } 89 97 90 98 return { ··· 93 101 const { ...pub } = jwk 94 102 return pub 95 103 }) 104 + } 105 + }) 106 + .get('/api/health', () => { 107 + const dnsVerifierHealth = dnsVerifier.getHealth() 108 + return { 109 + status: 'ok', 110 + timestamp: new Date().toISOString(), 111 + dnsVerifier: dnsVerifierHealth 112 + } 113 + }) 114 + .post('/api/admin/verify-dns', async () => { 115 + try { 116 + await dnsVerifier.trigger() 117 + return { 118 + success: true, 119 + message: 'DNS verification triggered' 120 + } 121 + } catch (error) { 122 + return { 123 + success: false, 124 + error: error instanceof Error ? error.message : String(error) 125 + } 96 126 } 97 127 }) 98 128 .use(cors({
+6 -10
src/lib/db.ts
··· 387 387 return keys; 388 388 }; 389 389 390 - let currentKeys: JoseKey[] = []; 391 - 392 - export const getCurrentKeys = () => currentKeys; 390 + // Load keys from database every time (stateless - safe for horizontal scaling) 391 + export const getCurrentKeys = async (): Promise<JoseKey[]> => { 392 + return await loadPersistedKeys(); 393 + }; 393 394 394 395 // Key rotation - rotate keys older than 30 days (monthly rotation) 395 396 const KEY_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds ··· 423 424 424 425 console.log(`[KeyRotation] Rotated key ${oldKid}`); 425 426 426 - // Reload keys into memory 427 - currentKeys = await ensureKeys(); 428 - 429 427 return true; 430 428 } catch (err) { 431 429 console.error('[KeyRotation] Failed to rotate keys:', err); ··· 434 432 }; 435 433 436 434 export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => { 437 - if (currentKeys.length === 0) { 438 - currentKeys = await ensureKeys(); 439 - } 435 + const keys = await ensureKeys(); 440 436 441 437 return new NodeOAuthClient({ 442 438 clientMetadata: createClientMetadata(config), 443 - keyset: currentKeys, 439 + keyset: keys, 444 440 stateStore, 445 441 sessionStore 446 442 });
+6 -10
src/lib/oauth-client.ts
··· 168 168 return keys; 169 169 }; 170 170 171 - let currentKeys: JoseKey[] = []; 172 - 173 - export const getCurrentKeys = () => currentKeys; 171 + // Load keys from database every time (stateless - safe for horizontal scaling) 172 + export const getCurrentKeys = async (): Promise<JoseKey[]> => { 173 + return await loadPersistedKeys(); 174 + }; 174 175 175 176 // Key rotation - rotate keys older than 30 days (monthly rotation) 176 177 const KEY_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds ··· 204 205 205 206 logger.info(`[KeyRotation] Rotated key ${oldKid}`); 206 207 207 - // Reload keys into memory 208 - currentKeys = await ensureKeys(); 209 - 210 208 return true; 211 209 } catch (err) { 212 210 logger.error('[KeyRotation] Failed to rotate keys', err); ··· 215 213 }; 216 214 217 215 export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => { 218 - if (currentKeys.length === 0) { 219 - currentKeys = await ensureKeys(); 220 - } 216 + const keys = await ensureKeys(); 221 217 222 218 return new NodeOAuthClient({ 223 219 clientMetadata: createClientMetadata(config), 224 - keyset: currentKeys, 220 + keyset: keys, 225 221 stateStore, 226 222 sessionStore 227 223 });
+11 -1
src/routes/wisp.ts
··· 101 101 // Elysia gives us File objects directly, handle both single file and array 102 102 const fileArray = Array.isArray(files) ? files : [files]; 103 103 const uploadedFiles: UploadedFile[] = []; 104 + const skippedFiles: Array<{ name: string; reason: string }> = []; 104 105 105 106 // Define allowed file extensions for static site hosting 106 107 const allowedExtensions = new Set([ ··· 135 136 136 137 // Skip excluded files 137 138 if (excludedFiles.has(fileExtension)) { 139 + skippedFiles.push({ name: file.name, reason: 'excluded file type' }); 138 140 continue; 139 141 } 140 142 141 143 // Skip files that aren't in allowed extensions 142 144 if (!allowedExtensions.has(fileExtension)) { 145 + skippedFiles.push({ name: file.name, reason: 'unsupported file type' }); 143 146 continue; 144 147 } 145 148 146 149 // Skip files that are too large (limit to 100MB per file) 147 150 const maxSize = 100 * 1024 * 1024; // 100MB 148 151 if (file.size > maxSize) { 152 + skippedFiles.push({ 153 + name: file.name, 154 + reason: `file too large (${(file.size / 1024 / 1024).toFixed(2)}MB, max 100MB)` 155 + }); 149 156 continue; 150 157 } 151 158 ··· 198 205 cid: record.data.cid, 199 206 fileCount: 0, 200 207 siteName, 208 + skippedFiles, 201 209 message: 'Site created but no valid web files were found to upload' 202 210 }; 203 211 } ··· 273 281 uri: record.data.uri, 274 282 cid: record.data.cid, 275 283 fileCount, 276 - siteName 284 + siteName, 285 + skippedFiles, 286 + uploadedCount: uploadedFiles.length 277 287 }; 278 288 279 289 return result;