tangled
alpha
login
or
join now
flo-bit.dev
/
blento
21
fork
atom
your personal website on atproto - mirror
blento.app
21
fork
atom
overview
issues
pulls
pipelines
improve custom domain stuff
Florian
4 weeks ago
f3815344
573b3c21
+493
-81
5 changed files
expand all
collapse all
unified
split
src
lib
dns.ts
website
Account.svelte
CustomDomainModal.svelte
routes
api
activate-domain
+server.ts
verify-domain
+server.ts
+294
src/lib/dns.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
// Authoritative DNS verification
2
+
// Queries authoritative nameservers directly instead of relying on cached resolvers.
3
+
// See: https://jacob.gold/posts/stop-telling-users-their-dns-is-wrong/
4
+
5
+
const TYPE_A = 1;
6
+
const TYPE_NS = 2;
7
+
const TYPE_CNAME = 5;
8
+
9
+
interface DnsRecord {
10
+
type: number;
11
+
data: string;
12
+
}
13
+
14
+
// --- Wire format encoding ---
15
+
16
+
function encodeName(name: string): Uint8Array {
17
+
const n = name.endsWith('.') ? name.slice(0, -1) : name;
18
+
const parts = n.split('.');
19
+
const bytes: number[] = [];
20
+
for (const part of parts) {
21
+
bytes.push(part.length);
22
+
for (let i = 0; i < part.length; i++) {
23
+
bytes.push(part.charCodeAt(i));
24
+
}
25
+
}
26
+
bytes.push(0);
27
+
return new Uint8Array(bytes);
28
+
}
29
+
30
+
function buildQuery(name: string, type: number): Uint8Array {
31
+
const qname = encodeName(name);
32
+
const msg = new Uint8Array(12 + qname.length + 4);
33
+
const id = (Math.random() * 0xffff) | 0;
34
+
msg[0] = id >> 8;
35
+
msg[1] = id & 0xff;
36
+
msg[2] = 0x01; // RD=1
37
+
msg[5] = 0x01; // QDCOUNT=1
38
+
msg.set(qname, 12);
39
+
const off = 12 + qname.length;
40
+
msg[off] = type >> 8;
41
+
msg[off + 1] = type & 0xff;
42
+
msg[off + 3] = 0x01; // CLASS IN
43
+
return msg;
44
+
}
45
+
46
+
// --- Wire format decoding ---
47
+
48
+
function decodeName(data: Uint8Array, offset: number): [string, number] {
49
+
const labels: string[] = [];
50
+
let pos = offset;
51
+
let jumped = false;
52
+
let savedPos = 0;
53
+
for (let safety = 0; safety < 128 && pos < data.length; safety++) {
54
+
const len = data[pos];
55
+
if (len === 0) {
56
+
pos++;
57
+
break;
58
+
}
59
+
if ((len & 0xc0) === 0xc0) {
60
+
if (!jumped) savedPos = pos + 2;
61
+
pos = ((len & 0x3f) << 8) | data[pos + 1];
62
+
jumped = true;
63
+
continue;
64
+
}
65
+
pos++;
66
+
let label = '';
67
+
for (let i = 0; i < len && pos + i < data.length; i++) {
68
+
label += String.fromCharCode(data[pos + i]);
69
+
}
70
+
labels.push(label);
71
+
pos += len;
72
+
}
73
+
return [labels.join('.'), jumped ? savedPos : pos];
74
+
}
75
+
76
+
function parseResponse(data: Uint8Array): DnsRecord[] {
77
+
if (data.length < 12) return [];
78
+
const qdcount = (data[4] << 8) | data[5];
79
+
const ancount = (data[6] << 8) | data[7];
80
+
let offset = 12;
81
+
82
+
// Skip questions
83
+
for (let i = 0; i < qdcount && offset < data.length; i++) {
84
+
const [, off] = decodeName(data, offset);
85
+
offset = off + 4;
86
+
}
87
+
88
+
const records: DnsRecord[] = [];
89
+
for (let i = 0; i < ancount && offset + 10 < data.length; i++) {
90
+
const [, nameEnd] = decodeName(data, offset);
91
+
offset = nameEnd;
92
+
const type = (data[offset] << 8) | data[offset + 1];
93
+
const rdlength = (data[offset + 8] << 8) | data[offset + 9];
94
+
offset += 10;
95
+
if (offset + rdlength > data.length) break;
96
+
97
+
let value = '';
98
+
if (type === TYPE_A && rdlength === 4) {
99
+
value = `${data[offset]}.${data[offset + 1]}.${data[offset + 2]}.${data[offset + 3]}`;
100
+
} else if (type === TYPE_CNAME || type === TYPE_NS) {
101
+
[value] = decodeName(data, offset);
102
+
}
103
+
if (value) records.push({ type, data: value });
104
+
offset += rdlength;
105
+
}
106
+
return records;
107
+
}
108
+
109
+
// --- DNS-over-HTTPS (for finding NS records and as fallback) ---
110
+
111
+
interface DohAnswer {
112
+
type: number;
113
+
data: string;
114
+
}
115
+
116
+
async function dohQuery(name: string, type: string): Promise<DohAnswer[]> {
117
+
const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(name)}&type=${type}`;
118
+
const res = await fetch(url, { headers: { Accept: 'application/dns-json' } });
119
+
const json: { Answer?: DohAnswer[] } = await res.json();
120
+
return json.Answer ?? [];
121
+
}
122
+
123
+
// --- Find authoritative nameservers (walking up the domain tree) ---
124
+
125
+
async function findNameservers(domain: string): Promise<string[]> {
126
+
let current = domain;
127
+
while (current.includes('.')) {
128
+
const answers = await dohQuery(current, 'NS');
129
+
const ns = answers.filter((a) => a.type === TYPE_NS).map((a) => a.data.replace(/\.$/, ''));
130
+
if (ns.length > 0) return ns;
131
+
current = current.substring(current.indexOf('.') + 1);
132
+
}
133
+
return [];
134
+
}
135
+
136
+
// --- Resolve hostname to IPs via DoH ---
137
+
138
+
async function resolveToIps(hostname: string): Promise<string[]> {
139
+
const answers = await dohQuery(hostname, 'A');
140
+
return answers.filter((a) => a.type === TYPE_A).map((a) => a.data);
141
+
}
142
+
143
+
// --- Query nameserver directly via TCP (cloudflare:sockets) ---
144
+
145
+
async function queryViaTcp(serverIp: string, name: string, type: number): Promise<DnsRecord[]> {
146
+
// @ts-expect-error: cloudflare:sockets is only available in Workers runtime
147
+
const { connect } = await import('cloudflare:sockets');
148
+
149
+
const query = buildQuery(name, type);
150
+
const tcpMsg = new Uint8Array(query.length + 2);
151
+
tcpMsg[0] = (query.length >> 8) & 0xff;
152
+
tcpMsg[1] = query.length & 0xff;
153
+
tcpMsg.set(query, 2);
154
+
155
+
const socket = connect({ hostname: serverIp, port: 53 });
156
+
const writer = socket.writable.getWriter();
157
+
await writer.write(tcpMsg);
158
+
writer.releaseLock();
159
+
160
+
const reader = socket.readable.getReader();
161
+
let buffer = new Uint8Array(0);
162
+
let responseLen = -1;
163
+
164
+
const timeout = setTimeout(() => socket.close(), 5000);
165
+
try {
166
+
while (true) {
167
+
const { value, done } = await reader.read();
168
+
if (done) break;
169
+
const chunk = new Uint8Array(value as ArrayBuffer);
170
+
const newBuf = new Uint8Array(buffer.length + chunk.length);
171
+
newBuf.set(buffer);
172
+
newBuf.set(chunk, buffer.length);
173
+
buffer = newBuf;
174
+
175
+
if (responseLen === -1 && buffer.length >= 2) {
176
+
responseLen = (buffer[0] << 8) | buffer[1];
177
+
}
178
+
if (responseLen >= 0 && buffer.length >= responseLen + 2) break;
179
+
}
180
+
} finally {
181
+
clearTimeout(timeout);
182
+
reader.releaseLock();
183
+
socket.close();
184
+
}
185
+
186
+
if (buffer.length < 2) return [];
187
+
return parseResponse(buffer.slice(2, 2 + responseLen));
188
+
}
189
+
190
+
// --- Query with TCP, fall back to DoH for local dev ---
191
+
192
+
async function queryAuthoritative(
193
+
serverIp: string,
194
+
name: string,
195
+
type: number
196
+
): Promise<DnsRecord[]> {
197
+
try {
198
+
return await queryViaTcp(serverIp, name, type);
199
+
} catch {
200
+
// cloudflare:sockets not available (local dev) — fall back to DoH
201
+
const typeStr = type === TYPE_A ? 'A' : type === TYPE_CNAME ? 'CNAME' : 'NS';
202
+
return (await dohQuery(name, typeStr)).map((a) => ({ type: a.type, data: a.data }));
203
+
}
204
+
}
205
+
206
+
// --- Main verification ---
207
+
208
+
export interface DnsVerifyResult {
209
+
ok: boolean;
210
+
error?: string;
211
+
hint?: string;
212
+
}
213
+
214
+
function normalize(s: string): string {
215
+
return s.replace(/\.$/, '').toLowerCase();
216
+
}
217
+
218
+
export async function verifyDomainDns(
219
+
domain: string,
220
+
expectedTarget: string
221
+
): Promise<DnsVerifyResult> {
222
+
// Find authoritative nameservers
223
+
const nameservers = await findNameservers(domain);
224
+
if (nameservers.length === 0) {
225
+
return { ok: false, error: `Could not find nameservers for "${domain}".` };
226
+
}
227
+
228
+
// Try each nameserver until one responds
229
+
for (const ns of nameservers) {
230
+
const ips = await resolveToIps(ns);
231
+
if (ips.length === 0) continue;
232
+
233
+
const nsIp = ips[0];
234
+
235
+
// Check CNAME records
236
+
const cnameRecords = await queryAuthoritative(nsIp, domain, TYPE_CNAME);
237
+
const cnames = cnameRecords.filter((r) => r.type === TYPE_CNAME);
238
+
239
+
if (cnames.length > 0) {
240
+
if (cnames.length > 1) {
241
+
return {
242
+
ok: false,
243
+
error: `Multiple CNAME records found for "${domain}": ${cnames.map((r) => r.data).join(', ')}. Remove duplicate records so only one remains.`
244
+
};
245
+
}
246
+
const got = normalize(cnames[0].data);
247
+
const want = normalize(expectedTarget);
248
+
if (got !== want) {
249
+
return {
250
+
ok: false,
251
+
error: `CNAME for "${domain}" points to "${got}" instead of "${want}".`,
252
+
hint: `If you're using Cloudflare, make sure the proxy (orange cloud) is turned off for this record.`
253
+
};
254
+
}
255
+
return { ok: true };
256
+
}
257
+
258
+
// No CNAME — check A records (root/apex domains with CNAME flattening or A records)
259
+
const aRecords = await queryAuthoritative(nsIp, domain, TYPE_A);
260
+
const aValues = aRecords.filter((r) => r.type === TYPE_A);
261
+
262
+
if (aValues.length > 0) {
263
+
// Resolve the expected target to get its IPs for comparison
264
+
const expectedIps = await resolveToIps(expectedTarget);
265
+
if (expectedIps.length === 0) {
266
+
return {
267
+
ok: false,
268
+
error: `Could not resolve "${expectedTarget}" to verify A records.`
269
+
};
270
+
}
271
+
const expectedSet = new Set(expectedIps);
272
+
const unexpected = aValues.filter((r) => !expectedSet.has(r.data));
273
+
if (unexpected.length > 0) {
274
+
return {
275
+
ok: false,
276
+
error: `A record(s) for "${domain}" include unexpected IPs: ${unexpected.map((r) => r.data).join(', ')}. Expected only IPs matching "${expectedTarget}" (${expectedIps.join(', ')}).`,
277
+
hint: `If you're using Cloudflare, make sure the proxy (orange cloud) is turned off for this record.`
278
+
};
279
+
}
280
+
return { ok: true };
281
+
}
282
+
283
+
// Neither CNAME nor A found
284
+
return {
285
+
ok: false,
286
+
error: `No CNAME or A record found for "${domain}". Please add a CNAME record pointing to "${expectedTarget}".`
287
+
};
288
+
}
289
+
290
+
return {
291
+
ok: false,
292
+
error: `Could not reach any nameserver for "${domain}".`
293
+
};
294
+
}
+1
-1
src/lib/website/Account.svelte
···
47
</Popover>
48
</div>
49
50
-
<CustomDomainModal />
51
{/if}
···
47
</Popover>
48
</div>
49
50
+
<CustomDomainModal publicationUrl={data.publication?.url} />
51
{/if}
+116
-11
src/lib/website/CustomDomainModal.svelte
···
7
</script>
8
9
<script lang="ts">
10
-
import { putRecord, getRecord } from '$lib/atproto/methods';
11
import { user } from '$lib/atproto';
12
import { Button, Input } from '@foxui/core';
13
import Modal from '$lib/components/modal/Modal.svelte';
14
import { launchConfetti } from '@foxui/visual';
15
16
-
let step: 'input' | 'instructions' | 'verifying' | 'success' | 'error' = $state('input');
0
0
0
0
0
0
0
0
0
17
let domain = $state('');
18
let errorMessage = $state('');
0
19
20
$effect(() => {
21
-
if (!customDomainModalState.visible) {
0
0
22
step = 'input';
23
domain = '';
24
errorMessage = '';
0
25
}
26
});
27
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
28
function goToInstructions() {
29
if (!domain.trim()) return;
30
step = 'instructions';
···
33
async function verify() {
34
step = 'verifying';
35
try {
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
36
const existing = await getRecord({
37
collection: 'site.standard.publication',
38
rkey: 'blento.self'
···
47
}
48
});
49
50
-
const res = await fetch('/api/verify-domain', {
0
51
method: 'POST',
52
headers: { 'Content-Type': 'application/json' },
53
body: JSON.stringify({ did: user.did, domain })
54
});
55
56
-
const data = await res.json();
57
58
-
if (data.success) {
59
-
launchConfetti();
60
-
step = 'success';
61
-
} else if (data.error) {
62
-
errorMessage = data.error;
63
step = 'error';
0
64
}
0
0
0
0
0
0
0
0
65
} catch (err: unknown) {
66
errorMessage = err instanceof Error ? err.message : String(err);
67
step = 'error';
···
74
</script>
75
76
<Modal bind:open={customDomainModalState.visible}>
77
-
{#if step === 'input'}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
78
<h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title">
79
Custom Domain
80
</h3>
···
150
</svg>
151
<span class="text-base-600 dark:text-base-400 text-sm">Verifying...</span>
152
</div>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
153
{:else if step === 'success'}
154
<h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title">
155
Domain verified!
···
170
<p class="mt-2 text-sm text-red-500 dark:text-red-400">
171
{errorMessage}
172
</p>
0
0
0
0
0
173
174
<div class="mt-4 flex gap-2">
175
<Button variant="ghost" onclick={() => customDomainModalState.hide()}>Close</Button>
···
7
</script>
8
9
<script lang="ts">
10
+
import { putRecord, getRecord, getHandleOrDid } from '$lib/atproto/methods';
11
import { user } from '$lib/atproto';
12
import { Button, Input } from '@foxui/core';
13
import Modal from '$lib/components/modal/Modal.svelte';
14
import { launchConfetti } from '@foxui/visual';
15
16
+
let { publicationUrl }: { publicationUrl?: string } = $props();
17
+
18
+
let currentDomain = $derived(
19
+
publicationUrl?.startsWith('https://') && !publicationUrl.includes('blento.app')
20
+
? publicationUrl.replace('https://', '')
21
+
: ''
22
+
);
23
+
24
+
let step: 'current' | 'input' | 'instructions' | 'verifying' | 'removing' | 'success' | 'error' =
25
+
$state('input');
26
let domain = $state('');
27
let errorMessage = $state('');
28
+
let errorHint = $state('');
29
30
$effect(() => {
31
+
if (customDomainModalState.visible) {
32
+
step = currentDomain ? 'current' : 'input';
33
+
} else {
34
step = 'input';
35
domain = '';
36
errorMessage = '';
37
+
errorHint = '';
38
}
39
});
40
41
+
async function removeDomain() {
42
+
step = 'removing';
43
+
try {
44
+
const existing = await getRecord({
45
+
collection: 'site.standard.publication',
46
+
rkey: 'blento.self'
47
+
});
48
+
49
+
if (existing?.value) {
50
+
const { url: _url, ...rest } = existing.value as Record<string, unknown>;
51
+
await putRecord({
52
+
collection: 'site.standard.publication',
53
+
rkey: 'blento.self',
54
+
record: rest
55
+
});
56
+
}
57
+
58
+
step = 'input';
59
+
} catch (err: unknown) {
60
+
errorMessage = err instanceof Error ? err.message : String(err);
61
+
step = 'error';
62
+
}
63
+
}
64
+
65
function goToInstructions() {
66
if (!domain.trim()) return;
67
step = 'instructions';
···
70
async function verify() {
71
step = 'verifying';
72
try {
73
+
// Step 1: Verify DNS records
74
+
const dnsRes = await fetch('/api/verify-domain', {
75
+
method: 'POST',
76
+
headers: { 'Content-Type': 'application/json' },
77
+
body: JSON.stringify({ domain })
78
+
});
79
+
80
+
const dnsData = await dnsRes.json();
81
+
82
+
if (!dnsRes.ok || dnsData.error) {
83
+
errorMessage = dnsData.error;
84
+
errorHint = dnsData.hint || '';
85
+
step = 'error';
86
+
return;
87
+
}
88
+
89
+
// Step 2: Write URL to ATProto profile
90
const existing = await getRecord({
91
collection: 'site.standard.publication',
92
rkey: 'blento.self'
···
101
}
102
});
103
104
+
// Step 3: Activate domain in KV (server verifies profile has the URL)
105
+
const activateRes = await fetch('/api/activate-domain', {
106
method: 'POST',
107
headers: { 'Content-Type': 'application/json' },
108
body: JSON.stringify({ did: user.did, domain })
109
});
110
111
+
const activateData = await activateRes.json();
112
113
+
if (!activateRes.ok || activateData.error) {
114
+
errorMessage = activateData.error;
115
+
errorHint = '';
0
0
116
step = 'error';
117
+
return;
118
}
119
+
120
+
// Refresh cached profile
121
+
if (user.profile) {
122
+
fetch(`/${getHandleOrDid(user.profile)}/api/refresh`).catch(() => {});
123
+
}
124
+
125
+
launchConfetti();
126
+
step = 'success';
127
} catch (err: unknown) {
128
errorMessage = err instanceof Error ? err.message : String(err);
129
step = 'error';
···
136
</script>
137
138
<Modal bind:open={customDomainModalState.visible}>
139
+
{#if step === 'current'}
140
+
<h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title">
141
+
Custom Domain
142
+
</h3>
143
+
144
+
<div
145
+
class="bg-base-200 dark:bg-base-700 mt-2 flex items-center justify-between rounded-2xl px-3 py-2 font-mono text-sm"
146
+
>
147
+
<span>{currentDomain}</span>
148
+
</div>
149
+
150
+
<div class="mt-4 flex gap-2">
151
+
<Button variant="ghost" onclick={removeDomain}>Remove</Button>
152
+
<Button variant="ghost" onclick={() => (step = 'input')}>Change</Button>
153
+
<Button onclick={() => customDomainModalState.hide()}>Close</Button>
154
+
</div>
155
+
{:else if step === 'input'}
156
<h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title">
157
Custom Domain
158
</h3>
···
228
</svg>
229
<span class="text-base-600 dark:text-base-400 text-sm">Verifying...</span>
230
</div>
231
+
{:else if step === 'removing'}
232
+
<h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title">
233
+
Removing...
234
+
</h3>
235
+
236
+
<div class="mt-4 flex items-center gap-2">
237
+
<svg
238
+
class="text-base-500 size-5 animate-spin"
239
+
xmlns="http://www.w3.org/2000/svg"
240
+
fill="none"
241
+
viewBox="0 0 24 24"
242
+
>
243
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
244
+
></circle>
245
+
<path
246
+
class="opacity-75"
247
+
fill="currentColor"
248
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
249
+
></path>
250
+
</svg>
251
+
<span class="text-base-600 dark:text-base-400 text-sm">Removing domain...</span>
252
+
</div>
253
{:else if step === 'success'}
254
<h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title">
255
Domain verified!
···
270
<p class="mt-2 text-sm text-red-500 dark:text-red-400">
271
{errorMessage}
272
</p>
273
+
{#if errorHint}
274
+
<p class="mt-1 text-sm font-bold text-red-500 dark:text-red-400">
275
+
{errorHint}
276
+
</p>
277
+
{/if}
278
279
<div class="mt-4 flex gap-2">
280
<Button variant="ghost" onclick={() => customDomainModalState.hide()}>Close</Button>
+67
src/routes/api/activate-domain/+server.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import { json } from '@sveltejs/kit';
2
+
import { isDid } from '@atcute/lexicons/syntax';
3
+
import { getRecord } from '$lib/atproto/methods';
4
+
import type { Did } from '@atcute/lexicons';
5
+
6
+
export async function POST({ request, platform }) {
7
+
let body: { did: string; domain: string };
8
+
try {
9
+
body = await request.json();
10
+
} catch {
11
+
return json({ error: 'Invalid JSON body' }, { status: 400 });
12
+
}
13
+
14
+
const { did, domain } = body;
15
+
16
+
if (!did || !domain) {
17
+
return json({ error: 'Missing required fields: did, domain' }, { status: 400 });
18
+
}
19
+
20
+
if (!isDid(did)) {
21
+
return json({ error: 'Invalid DID format' }, { status: 400 });
22
+
}
23
+
24
+
// Validate domain format
25
+
if (
26
+
!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/.test(
27
+
domain
28
+
)
29
+
) {
30
+
return json({ error: 'Invalid domain format' }, { status: 400 });
31
+
}
32
+
33
+
// Verify the user's ATProto profile has this domain set
34
+
try {
35
+
const record = await getRecord({
36
+
did: did as Did,
37
+
collection: 'site.standard.publication',
38
+
rkey: 'blento.self'
39
+
});
40
+
41
+
const url = (record?.value as Record<string, unknown>)?.url;
42
+
if (url !== `https://${domain}`) {
43
+
return json(
44
+
{
45
+
error: `Profile does not have this domain set. Expected "https://${domain}" but got "${url || '(none)'}".`
46
+
},
47
+
{ status: 403 }
48
+
);
49
+
}
50
+
} catch {
51
+
return json({ error: 'Failed to verify profile record.' }, { status: 500 });
52
+
}
53
+
54
+
// Write to CUSTOM_DOMAINS KV
55
+
const kv = platform?.env?.CUSTOM_DOMAINS;
56
+
if (!kv) {
57
+
return json({ error: 'KV storage not available.' }, { status: 500 });
58
+
}
59
+
60
+
try {
61
+
await kv.put(domain.toLowerCase(), did);
62
+
} catch {
63
+
return json({ error: 'Failed to register domain.' }, { status: 500 });
64
+
}
65
+
66
+
return json({ success: true });
67
+
}
+15
-69
src/routes/api/verify-domain/+server.ts
···
1
import { json } from '@sveltejs/kit';
2
-
import type { Did } from '@atcute/lexicons';
3
-
import { getClient, getRecord } from '$lib/atproto/methods';
4
5
-
export async function POST({ request, platform }) {
6
-
let body: { did: string; domain: string };
0
0
7
try {
8
body = await request.json();
9
} catch {
10
return json({ error: 'Invalid JSON body' }, { status: 400 });
11
}
12
13
-
const { did, domain } = body;
14
15
-
if (!did || !domain) {
16
-
return json({ error: 'Missing required fields: did and domain' }, { status: 400 });
17
}
18
19
// Validate domain format
···
25
return json({ error: 'Invalid domain format' }, { status: 400 });
26
}
27
28
-
// Check the user's site.standard.publication record
29
-
try {
30
-
const client = await getClient({ did: did as Did });
31
-
const record = await getRecord({
32
-
did: did as Did,
33
-
collection: 'site.standard.publication',
34
-
rkey: 'blento.self',
35
-
client
36
-
});
37
-
38
-
const recordUrl = record?.value?.url;
39
-
const expectedUrl = `https://${domain}`;
40
-
41
-
if (recordUrl !== expectedUrl) {
42
-
return json(
43
-
{
44
-
error: `Publication record URL does not match. Expected "${expectedUrl}", got "${recordUrl || '(not set)'}".`
45
-
},
46
-
{ status: 400 }
47
-
);
48
-
}
49
-
} catch {
50
-
return json(
51
-
{ error: 'Could not read site.standard.publication record. Make sure it exists.' },
52
-
{ status: 400 }
53
-
);
54
-
}
55
-
56
-
// Verify CNAME via DNS-over-HTTPS
57
try {
58
-
const dohUrl = `https://mozilla.cloudflare-dns.com/dns-query?name=${encodeURIComponent(domain)}&type=CNAME`;
59
-
const dnsRes = await fetch(dohUrl, {
60
-
headers: { Accept: 'application/dns-json' }
61
-
});
62
-
const dnsData = await dnsRes.json();
63
-
64
-
const cnameTarget = 'blento-proxy.fly.dev.';
65
-
const cnameTargetNoDot = 'blento-proxy.fly.dev';
66
-
67
-
const hasCname = dnsData.Answer?.some(
68
-
(answer: { type: number; data: string }) =>
69
-
answer.type === 5 && (answer.data === cnameTarget || answer.data === cnameTargetNoDot)
70
-
);
71
-
72
-
if (!hasCname) {
73
-
return json(
74
-
{
75
-
error: `CNAME record not found. Please set a CNAME for "${domain}" pointing to "blento-proxy.fly.dev".`
76
-
},
77
-
{ status: 400 }
78
-
);
79
}
80
} catch {
81
return json({ error: 'Failed to verify DNS records.' }, { status: 500 });
82
-
}
83
-
84
-
// Write to CUSTOM_DOMAINS KV
85
-
const kv = platform?.env?.CUSTOM_DOMAINS;
86
-
if (!kv) {
87
-
return json({ error: 'Domain storage is not available.' }, { status: 500 });
88
-
}
89
-
90
-
try {
91
-
await kv.put(domain, did);
92
-
} catch {
93
-
return json({ error: 'Failed to save custom domain.' }, { status: 500 });
94
}
95
96
return json({ success: true });
···
1
import { json } from '@sveltejs/kit';
2
+
import { verifyDomainDns } from '$lib/dns';
0
3
4
+
const EXPECTED_TARGET = 'blento-proxy.fly.dev';
5
+
6
+
export async function POST({ request }) {
7
+
let body: { domain: string };
8
try {
9
body = await request.json();
10
} catch {
11
return json({ error: 'Invalid JSON body' }, { status: 400 });
12
}
13
14
+
const { domain } = body;
15
16
+
if (!domain) {
17
+
return json({ error: 'Missing required field: domain' }, { status: 400 });
18
}
19
20
// Validate domain format
···
26
return json({ error: 'Invalid domain format' }, { status: 400 });
27
}
28
29
+
// Verify DNS by querying authoritative nameservers directly.
30
+
// This gives instant, accurate results instead of relying on cached resolvers.
31
+
// Checks CNAME for subdomains and A records for root/apex domains.
32
+
// See: https://jacob.gold/posts/stop-telling-users-their-dns-is-wrong/
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
33
try {
34
+
const result = await verifyDomainDns(domain, EXPECTED_TARGET);
35
+
if (!result.ok) {
36
+
return json({ error: result.error, hint: result.hint }, { status: 400 });
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
37
}
38
} catch {
39
return json({ error: 'Failed to verify DNS records.' }, { status: 500 });
0
0
0
0
0
0
0
0
0
0
0
0
40
}
41
42
return json({ success: true });