Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
1import { Elysia } from 'elysia' 2import { requireAuth, type AuthenticatedContext } from '../lib/wisp-auth' 3import { NodeOAuthClient } from '@atproto/oauth-client-node' 4import { Agent } from '@atproto/api' 5import { 6 claimDomain, 7 getDomainByDid, 8 isDomainAvailable, 9 isDomainRegistered, 10 isValidHandle, 11 toDomain, 12 updateDomain, 13 countWispDomains, 14 deleteWispDomain, 15 getCustomDomainInfo, 16 getCustomDomainById, 17 claimCustomDomain, 18 deleteCustomDomain, 19 updateCustomDomainVerification, 20 updateWispDomainSite, 21 updateCustomDomainRkey 22} from '../lib/db' 23import { createHash } from 'crypto' 24import { verifyCustomDomain } from '../lib/dns-verify' 25import { logger } from '../lib/logger' 26 27export const domainRoutes = (client: NodeOAuthClient, cookieSecret: string) => 28 new Elysia({ 29 prefix: '/api/domain', 30 cookie: { 31 secrets: cookieSecret, 32 sign: ['did'] 33 } 34 }) 35 // Public endpoints (no auth required) 36 .get('/check', async ({ query }) => { 37 try { 38 const handle = (query.handle || "") 39 .trim() 40 .toLowerCase(); 41 42 if (!isValidHandle(handle)) { 43 return { 44 available: false, 45 reason: "invalid" 46 }; 47 } 48 49 const available = await isDomainAvailable(handle); 50 return { 51 available, 52 domain: toDomain(handle) 53 }; 54 } catch (err) { 55 logger.error('[Domain] Check error', err); 56 return { 57 available: false 58 }; 59 } 60 }) 61 .get('/registered', async ({ query, set }) => { 62 try { 63 const domain = (query.domain || "").trim().toLowerCase(); 64 65 if (!domain) { 66 set.status = 400; 67 return { error: 'Domain parameter required' }; 68 } 69 70 const result = await isDomainRegistered(domain); 71 72 // For Caddy on-demand TLS: 200 = allow, 404 = deny 73 if (result.registered) { 74 set.status = 200; 75 return result; 76 } else { 77 set.status = 404; 78 return { registered: false }; 79 } 80 } catch (err) { 81 logger.error('[Domain] Registered check error', err); 82 set.status = 500; 83 return { error: 'Failed to check domain' }; 84 } 85 }) 86 // Authenticated endpoints (require auth) 87 .derive(async ({ cookie }) => { 88 const auth = await requireAuth(client, cookie) 89 return { auth } 90 }) 91 .post('/claim', async ({ body, auth }) => { 92 try { 93 const { handle } = body as { handle?: string }; 94 const normalizedHandle = (handle || "").trim().toLowerCase(); 95 96 if (!isValidHandle(normalizedHandle)) { 97 throw new Error("Invalid handle"); 98 } 99 100 // Check if user already has 3 domains (handled in claimDomain) 101 // claim in DB 102 let domain: string; 103 try { 104 domain = await claimDomain(auth.did, normalizedHandle); 105 } catch (err) { 106 const message = err instanceof Error ? err.message : 'Unknown error'; 107 if (message === 'domain_limit_reached') { 108 throw new Error("Domain limit reached: You can only claim up to 3 wisp.place domains"); 109 } 110 throw new Error("Handle taken or error claiming domain"); 111 } 112 113 // write place.wisp.domain record with unique rkey 114 const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)); 115 const rkey = normalizedHandle; // Use handle as rkey for uniqueness 116 await agent.com.atproto.repo.putRecord({ 117 repo: auth.did, 118 collection: "place.wisp.domain", 119 rkey, 120 record: { 121 $type: "place.wisp.domain", 122 domain, 123 createdAt: new Date().toISOString(), 124 } as any, 125 validate: false, 126 }); 127 128 return { success: true, domain }; 129 } catch (err) { 130 logger.error('[Domain] Claim error', err); 131 throw new Error(`Failed to claim: ${err instanceof Error ? err.message : 'Unknown error'}`); 132 } 133 }) 134 .post('/update', async ({ body, auth }) => { 135 try { 136 const { handle } = body as { handle?: string }; 137 const normalizedHandle = (handle || "").trim().toLowerCase(); 138 139 if (!isValidHandle(normalizedHandle)) { 140 throw new Error("Invalid handle"); 141 } 142 143 const desiredDomain = toDomain(normalizedHandle); 144 const current = await getDomainByDid(auth.did); 145 146 if (current === desiredDomain) { 147 return { success: true, domain: current }; 148 } 149 150 let domain: string; 151 try { 152 domain = await updateDomain(auth.did, normalizedHandle); 153 } catch (err) { 154 throw new Error("Handle taken"); 155 } 156 157 const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)); 158 await agent.com.atproto.repo.putRecord({ 159 repo: auth.did, 160 collection: "place.wisp.domain", 161 rkey: "self", 162 record: { 163 $type: "place.wisp.domain", 164 domain, 165 createdAt: new Date().toISOString(), 166 } as any, 167 validate: false, 168 }); 169 170 return { success: true, domain }; 171 } catch (err) { 172 logger.error('[Domain] Update error', err); 173 throw new Error(`Failed to update: ${err instanceof Error ? err.message : 'Unknown error'}`); 174 } 175 }) 176 .post('/custom/add', async ({ body, auth }) => { 177 try { 178 const { domain } = body as { domain: string }; 179 const domainLower = domain.toLowerCase().trim(); 180 181 // Enhanced domain validation 182 // 1. Length check (RFC 1035: labels 1-63 chars, total max 253) 183 if (!domainLower || domainLower.length < 3 || domainLower.length > 253) { 184 throw new Error('Invalid domain: must be 3-253 characters'); 185 } 186 187 // 2. Basic format validation 188 // - Must contain at least one dot (require TLD) 189 // - Valid characters: a-z, 0-9, hyphen, dot 190 // - No consecutive dots, no leading/trailing dots or hyphens 191 const domainPattern = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/; 192 if (!domainPattern.test(domainLower)) { 193 throw new Error('Invalid domain format'); 194 } 195 196 // 3. Validate each label (part between dots) 197 const labels = domainLower.split('.'); 198 for (const label of labels) { 199 if (label.length === 0 || label.length > 63) { 200 throw new Error('Invalid domain: label length must be 1-63 characters'); 201 } 202 if (label.startsWith('-') || label.endsWith('-')) { 203 throw new Error('Invalid domain: labels cannot start or end with hyphen'); 204 } 205 } 206 207 // 4. TLD validation (require valid TLD, block single-char TLDs and numeric TLDs) 208 const tld = labels[labels.length - 1]; 209 if (tld.length < 2 || /^\d+$/.test(tld)) { 210 throw new Error('Invalid domain: TLD must be at least 2 characters and not all numeric'); 211 } 212 213 // 5. Homograph attack protection - block domains with mixed scripts or confusables 214 // Block non-ASCII characters (Punycode domains should be pre-converted) 215 if (!/^[a-z0-9.-]+$/.test(domainLower)) { 216 throw new Error('Invalid domain: only ASCII alphanumeric, dots, and hyphens allowed'); 217 } 218 219 // 6. Block localhost, internal IPs, and reserved domains 220 const blockedDomains = [ 221 'localhost', 222 'example.com', 223 'example.org', 224 'example.net', 225 'test', 226 'invalid', 227 'local' 228 ]; 229 const blockedPatterns = [ 230 /^(?:10|127|172\.(?:1[6-9]|2[0-9]|3[01])|192\.168)\./, // Private IPs 231 /^(?:\d{1,3}\.){3}\d{1,3}$/, // Any IP address 232 ]; 233 234 if (blockedDomains.includes(domainLower)) { 235 throw new Error('Invalid domain: reserved or blocked domain'); 236 } 237 238 for (const pattern of blockedPatterns) { 239 if (pattern.test(domainLower)) { 240 throw new Error('Invalid domain: IP addresses not allowed'); 241 } 242 } 243 244 // Check if already exists and is verified 245 const existing = await getCustomDomainInfo(domainLower); 246 if (existing && existing.verified) { 247 throw new Error('Domain already verified and claimed'); 248 } 249 250 // Create hash for ID 251 const hash = createHash('sha256').update(`${auth.did}:${domainLower}`).digest('hex').substring(0, 16); 252 253 // Store in database only 254 await claimCustomDomain(auth.did, domainLower, hash); 255 256 return { 257 success: true, 258 id: hash, 259 domain: domainLower, 260 verified: false 261 }; 262 } catch (err) { 263 logger.error('[Domain] Custom domain add error', err); 264 throw new Error(`Failed to add domain: ${err instanceof Error ? err.message : 'Unknown error'}`); 265 } 266 }) 267 .post('/custom/verify', async ({ body, auth }) => { 268 try { 269 const { id } = body as { id: string }; 270 271 // Get domain from database 272 const domainInfo = await getCustomDomainById(id); 273 if (!domainInfo) { 274 throw new Error('Domain not found'); 275 } 276 277 // Verify DNS records (TXT + CNAME) 278 logger.debug(`[Domain] Verifying custom domain: ${domainInfo.domain}`); 279 const result = await verifyCustomDomain(domainInfo.domain, auth.did, id); 280 281 // Update verification status in database 282 await updateCustomDomainVerification(id, result.verified); 283 284 return { 285 success: true, 286 verified: result.verified, 287 error: result.error, 288 found: result.found 289 }; 290 } catch (err) { 291 logger.error('[Domain] Custom domain verify error', err); 292 throw new Error(`Failed to verify domain: ${err instanceof Error ? err.message : 'Unknown error'}`); 293 } 294 }) 295 .delete('/custom/:id', async ({ params, auth }) => { 296 try { 297 const { id } = params; 298 299 // Verify ownership before deleting 300 const domainInfo = await getCustomDomainById(id); 301 if (!domainInfo) { 302 throw new Error('Domain not found'); 303 } 304 305 if (domainInfo.did !== auth.did) { 306 throw new Error('Unauthorized: You do not own this domain'); 307 } 308 309 // Delete from database 310 await deleteCustomDomain(id); 311 312 return { success: true }; 313 } catch (err) { 314 logger.error('[Domain] Custom domain delete error', err); 315 throw new Error(`Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}`); 316 } 317 }) 318 .post('/wisp/map-site', async ({ body, auth }) => { 319 try { 320 const { domain, siteRkey } = body as { domain: string; siteRkey: string | null }; 321 322 if (!domain) { 323 throw new Error('Domain parameter required'); 324 } 325 326 // Update wisp.place domain to point to this site 327 await updateWispDomainSite(domain, siteRkey); 328 329 return { success: true }; 330 } catch (err) { 331 logger.error('[Domain] Wisp domain map error', err); 332 throw new Error(`Failed to map site: ${err instanceof Error ? err.message : 'Unknown error'}`); 333 } 334 }) 335 .delete('/wisp/:domain', async ({ params, auth }) => { 336 try { 337 const { domain } = params; 338 339 // Verify domain belongs to user 340 const domainLower = domain.toLowerCase().trim(); 341 const info = await isDomainRegistered(domainLower); 342 343 if (!info.registered || info.type !== 'wisp') { 344 throw new Error('Domain not found'); 345 } 346 347 if (info.did !== auth.did) { 348 throw new Error('Unauthorized: You do not own this domain'); 349 } 350 351 // Delete from database 352 await deleteWispDomain(domainLower); 353 354 // Delete from PDS 355 const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)); 356 const handle = domainLower.replace(`.${process.env.BASE_DOMAIN || 'wisp.place'}`, ''); 357 try { 358 await agent.com.atproto.repo.deleteRecord({ 359 repo: auth.did, 360 collection: "place.wisp.domain", 361 rkey: handle, 362 }); 363 } catch (err) { 364 // Record might not exist in PDS, continue anyway 365 logger.warn('[Domain] Could not delete wisp domain from PDS', err); 366 } 367 368 return { success: true }; 369 } catch (err) { 370 logger.error('[Domain] Wisp domain delete error', err); 371 throw new Error(`Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}`); 372 } 373 }) 374 .post('/custom/:id/map-site', async ({ params, body, auth }) => { 375 try { 376 const { id } = params; 377 const { siteRkey } = body as { siteRkey: string | null }; 378 379 // Verify ownership before updating 380 const domainInfo = await getCustomDomainById(id); 381 if (!domainInfo) { 382 throw new Error('Domain not found'); 383 } 384 385 if (domainInfo.did !== auth.did) { 386 throw new Error('Unauthorized: You do not own this domain'); 387 } 388 389 // Update custom domain to point to this site 390 await updateCustomDomainRkey(id, siteRkey); 391 392 return { success: true }; 393 } catch (err) { 394 logger.error('[Domain] Custom domain map error', err); 395 throw new Error(`Failed to map site: ${err instanceof Error ? err.message : 'Unknown error'}`); 396 } 397 });