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 as any);
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 });