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