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