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

prevent domain squatting by claiming domains you dont even own

Changed files
+135 -4
src
+116
src/lib/db.test.ts
··· 1 + import { describe, test, expect, beforeAll, afterAll } from 'bun:test' 2 + import { 3 + claimCustomDomain, 4 + getCustomDomainInfo, 5 + deleteCustomDomain, 6 + updateCustomDomainVerification, 7 + db 8 + } from './db' 9 + 10 + describe('custom domain claiming', () => { 11 + const testDid1 = 'did:plc:testuser1' 12 + const testDid2 = 'did:plc:testuser2' 13 + const testDomain = 'example-test-domain.com' 14 + const hash1 = 'testhash12345678' 15 + const hash2 = 'testhash87654321' 16 + const hash3 = 'testhash11111111' 17 + 18 + beforeAll(async () => { 19 + // Clean up any existing test data 20 + try { 21 + await db`DELETE FROM custom_domains WHERE domain = ${testDomain}` 22 + } catch (err) { 23 + // Ignore errors if table doesn't exist or other issues 24 + } 25 + }) 26 + 27 + afterAll(async () => { 28 + // Clean up test data 29 + try { 30 + await db`DELETE FROM custom_domains WHERE domain = ${testDomain}` 31 + } catch (err) { 32 + // Ignore cleanup errors 33 + } 34 + }) 35 + 36 + test('should allow first user to claim a domain', async () => { 37 + const result = await claimCustomDomain(testDid1, testDomain, hash1) 38 + expect(result.success).toBe(true) 39 + expect(result.hash).toBe(hash1) 40 + 41 + const domainInfo = await getCustomDomainInfo(testDomain) 42 + expect(domainInfo).toBeTruthy() 43 + expect(domainInfo!.domain).toBe(testDomain) 44 + expect(domainInfo!.did).toBe(testDid1) 45 + expect(domainInfo!.verified).toBe(false) 46 + expect(domainInfo!.id).toBe(hash1) 47 + }) 48 + 49 + test('should allow second user to claim an unverified domain', async () => { 50 + const result = await claimCustomDomain(testDid2, testDomain, hash2) 51 + expect(result.success).toBe(true) 52 + expect(result.hash).toBe(hash2) 53 + 54 + const domainInfo = await getCustomDomainInfo(testDomain) 55 + expect(domainInfo).toBeTruthy() 56 + expect(domainInfo!.domain).toBe(testDomain) 57 + expect(domainInfo!.did).toBe(testDid2) // Should have changed 58 + expect(domainInfo!.verified).toBe(false) 59 + expect(domainInfo!.id).toBe(hash2) // Should have changed 60 + }) 61 + 62 + test('should prevent claiming a verified domain', async () => { 63 + // First verify the domain for testDid2 64 + await updateCustomDomainVerification(hash2, true) 65 + 66 + // Now try to claim it with testDid1 - should fail 67 + try { 68 + await claimCustomDomain(testDid1, testDomain, hash3) 69 + expect.fail('Should have thrown an error when trying to claim a verified domain') 70 + } catch (err) { 71 + expect(err.message).toBe('conflict') 72 + } 73 + 74 + // Verify the domain is still owned by testDid2 and verified 75 + const domainInfo = await getCustomDomainInfo(testDomain) 76 + expect(domainInfo).toBeTruthy() 77 + expect(domainInfo!.did).toBe(testDid2) 78 + expect(domainInfo!.verified).toBe(true) 79 + expect(domainInfo!.id).toBe(hash2) 80 + }) 81 + 82 + test('should allow claiming after unverification', async () => { 83 + // Unverify the domain 84 + await updateCustomDomainVerification(hash2, false) 85 + 86 + // Now should be claimable again 87 + const result = await claimCustomDomain(testDid1, testDomain, hash3) 88 + expect(result.success).toBe(true) 89 + expect(result.hash).toBe(hash3) 90 + 91 + const domainInfo = await getCustomDomainInfo(testDomain) 92 + expect(domainInfo).toBeTruthy() 93 + expect(domainInfo!.did).toBe(testDid1) // Should have changed back 94 + expect(domainInfo!.verified).toBe(false) 95 + expect(domainInfo!.id).toBe(hash3) 96 + }) 97 + 98 + test('should handle concurrent claims gracefully', async () => { 99 + // Both users try to claim at the same time - one should win 100 + const promise1 = claimCustomDomain(testDid1, testDomain, hash1) 101 + const promise2 = claimCustomDomain(testDid2, testDomain, hash2) 102 + 103 + const [result1, result2] = await Promise.allSettled([promise1, promise2]) 104 + 105 + // At least one should succeed 106 + const successCount = [result1, result2].filter(r => r.status === 'fulfilled').length 107 + expect(successCount).toBeGreaterThan(0) 108 + expect(successCount).toBeLessThanOrEqual(2) 109 + 110 + // Final state should be consistent 111 + const domainInfo = await getCustomDomainInfo(testDomain) 112 + expect(domainInfo).toBeTruthy() 113 + expect(domainInfo!.verified).toBe(false) 114 + expect([hash1, hash2]).toContain(domainInfo!.id) 115 + }) 116 + })
+16 -1
src/lib/db.ts
··· 595 595 export const claimCustomDomain = async (did: string, domain: string, hash: string, rkey: string | null = null) => { 596 596 const domainLower = domain.toLowerCase(); 597 597 try { 598 - await db` 598 + // Use UPSERT with ON CONFLICT to handle existing pending domains 599 + const result = await db` 599 600 INSERT INTO custom_domains (id, domain, did, rkey, verified, created_at) 600 601 VALUES (${hash}, ${domainLower}, ${did}, ${rkey}, false, EXTRACT(EPOCH FROM NOW())) 602 + ON CONFLICT (domain) DO UPDATE SET 603 + id = EXCLUDED.id, 604 + did = EXCLUDED.did, 605 + rkey = EXCLUDED.rkey, 606 + verified = EXCLUDED.verified, 607 + created_at = EXCLUDED.created_at 608 + WHERE custom_domains.verified = false 609 + RETURNING * 601 610 `; 611 + 612 + if (result.length === 0) { 613 + // No rows were updated, meaning the domain exists and is verified 614 + throw new Error('conflict'); 615 + } 616 + 602 617 return { success: true, hash }; 603 618 } catch (err) { 604 619 console.error('Failed to claim custom domain', err);
+3 -3
src/routes/domain.ts
··· 241 241 } 242 242 } 243 243 244 - // Check if already exists 244 + // Check if already exists and is verified 245 245 const existing = await getCustomDomainInfo(domainLower); 246 - if (existing) { 247 - throw new Error('Domain already claimed'); 246 + if (existing && existing.verified) { 247 + throw new Error('Domain already verified and claimed'); 248 248 } 249 249 250 250 // Create hash for ID