+3
bun.lock
+3
bun.lock
···
13
13
"@elysiajs/openapi": "^1.4.11",
14
14
"@elysiajs/opentelemetry": "^1.4.6",
15
15
"@elysiajs/static": "^1.4.2",
16
+
"@radix-ui/react-checkbox": "^1.3.3",
16
17
"@radix-ui/react-dialog": "^1.1.15",
17
18
"@radix-ui/react-label": "^2.1.7",
18
19
"@radix-ui/react-radio-group": "^1.3.8",
···
231
232
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
232
233
233
234
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
235
+
236
+
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
234
237
235
238
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
236
239
+1
package.json
+1
package.json
+30
public/components/ui/checkbox.tsx
+30
public/components/ui/checkbox.tsx
···
1
+
import * as React from "react"
2
+
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
3
+
import { CheckIcon } from "lucide-react"
4
+
5
+
import { cn } from "@public/lib/utils"
6
+
7
+
function Checkbox({
8
+
className,
9
+
...props
10
+
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
11
+
return (
12
+
<CheckboxPrimitive.Root
13
+
data-slot="checkbox"
14
+
className={cn(
15
+
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
16
+
className
17
+
)}
18
+
{...props}
19
+
>
20
+
<CheckboxPrimitive.Indicator
21
+
data-slot="checkbox-indicator"
22
+
className="grid place-content-center text-current transition-none"
23
+
>
24
+
<CheckIcon className="size-3.5" />
25
+
</CheckboxPrimitive.Indicator>
26
+
</CheckboxPrimitive.Root>
27
+
)
28
+
}
29
+
30
+
export { Checkbox }
+204
-98
public/editor/editor.tsx
+204
-98
public/editor/editor.tsx
···
38
38
Settings
39
39
} from 'lucide-react'
40
40
import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group'
41
+
import { Checkbox } from '@public/components/ui/checkbox'
41
42
import { CodeBlock } from '@public/components/ui/code-block'
42
43
43
44
import Layout from '@public/layouts'
···
55
56
updated_at: number
56
57
}
57
58
59
+
interface DomainInfo {
60
+
type: 'wisp' | 'custom'
61
+
domain: string
62
+
verified?: boolean
63
+
id?: string
64
+
}
65
+
66
+
interface SiteWithDomains extends Site {
67
+
domains?: DomainInfo[]
68
+
}
69
+
58
70
interface CustomDomain {
59
71
id: string
60
72
domain: string
···
76
88
const [loading, setLoading] = useState(true)
77
89
78
90
// Sites state
79
-
const [sites, setSites] = useState<Site[]>([])
91
+
const [sites, setSites] = useState<SiteWithDomains[]>([])
80
92
const [sitesLoading, setSitesLoading] = useState(true)
81
93
const [isSyncing, setIsSyncing] = useState(false)
82
94
···
86
98
const [domainsLoading, setDomainsLoading] = useState(true)
87
99
88
100
// Site configuration state
89
-
const [configuringSite, setConfiguringSite] = useState<Site | null>(null)
90
-
const [selectedDomain, setSelectedDomain] = useState<string>('')
101
+
const [configuringSite, setConfiguringSite] = useState<SiteWithDomains | null>(null)
102
+
const [selectedDomains, setSelectedDomains] = useState<Set<string>>(new Set())
91
103
const [isSavingConfig, setIsSavingConfig] = useState(false)
92
104
const [isDeletingSite, setIsDeletingSite] = useState(false)
93
105
···
148
160
try {
149
161
const response = await fetch('/api/user/sites')
150
162
const data = await response.json()
151
-
setSites(data.sites || [])
163
+
const sitesData: Site[] = data.sites || []
164
+
165
+
// Fetch domain info for each site
166
+
const sitesWithDomains = await Promise.all(
167
+
sitesData.map(async (site) => {
168
+
try {
169
+
const domainsResponse = await fetch(`/api/user/site/${site.rkey}/domains`)
170
+
const domainsData = await domainsResponse.json()
171
+
return {
172
+
...site,
173
+
domains: domainsData.domains || []
174
+
}
175
+
} catch (err) {
176
+
console.error(`Failed to fetch domains for site ${site.rkey}:`, err)
177
+
return {
178
+
...site,
179
+
domains: []
180
+
}
181
+
}
182
+
})
183
+
)
184
+
185
+
setSites(sitesWithDomains)
152
186
} catch (err) {
153
187
console.error('Failed to fetch sites:', err)
154
188
} finally {
···
189
223
}
190
224
}
191
225
192
-
const getSiteUrl = (site: Site) => {
193
-
// Check if this site is mapped to the wisp.place domain
194
-
if (wispDomain && wispDomain.rkey === site.rkey) {
195
-
return `https://${wispDomain.domain}`
226
+
const getSiteUrl = (site: SiteWithDomains) => {
227
+
// Use the first mapped domain if available
228
+
if (site.domains && site.domains.length > 0) {
229
+
return `https://${site.domains[0].domain}`
196
230
}
197
231
198
-
// Check if this site is mapped to any custom domain
199
-
const customDomain = customDomains.find((d) => d.rkey === site.rkey)
200
-
if (customDomain) {
201
-
return `https://${customDomain.domain}`
202
-
}
203
-
204
-
// Default fallback URL
232
+
// Default fallback URL - use handle instead of DID
205
233
if (!userInfo) return '#'
206
-
return `https://sites.wisp.place/${site.did}/${site.rkey}`
234
+
return `https://sites.wisp.place/${userInfo.handle}/${site.rkey}`
207
235
}
208
236
209
-
const getSiteDomainName = (site: Site) => {
210
-
if (wispDomain && wispDomain.rkey === site.rkey) {
211
-
return wispDomain.domain
237
+
const getSiteDomainName = (site: SiteWithDomains) => {
238
+
// Return the first domain if available
239
+
if (site.domains && site.domains.length > 0) {
240
+
return site.domains[0].domain
212
241
}
213
242
214
-
const customDomain = customDomains.find((d) => d.rkey === site.rkey)
215
-
if (customDomain) {
216
-
return customDomain.domain
217
-
}
218
-
219
-
return `sites.wisp.place/${site.did}/${site.rkey}`
243
+
// Use handle instead of DID for display
244
+
if (!userInfo) return `sites.wisp.place/.../${site.rkey}`
245
+
return `sites.wisp.place/${userInfo.handle}/${site.rkey}`
220
246
}
221
247
222
248
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
···
373
399
}
374
400
}
375
401
376
-
const handleConfigureSite = (site: Site) => {
402
+
const handleConfigureSite = (site: SiteWithDomains) => {
377
403
setConfiguringSite(site)
378
404
379
-
// Determine current domain mapping
380
-
if (wispDomain && wispDomain.rkey === site.rkey) {
381
-
setSelectedDomain('wisp')
382
-
} else {
383
-
const customDomain = customDomains.find((d) => d.rkey === site.rkey)
384
-
if (customDomain) {
385
-
setSelectedDomain(customDomain.id)
386
-
} else {
387
-
setSelectedDomain('none')
388
-
}
405
+
// Build set of currently mapped domains
406
+
const mappedDomains = new Set<string>()
407
+
408
+
if (site.domains) {
409
+
site.domains.forEach(domainInfo => {
410
+
if (domainInfo.type === 'wisp') {
411
+
mappedDomains.add('wisp')
412
+
} else if (domainInfo.id) {
413
+
mappedDomains.add(domainInfo.id)
414
+
}
415
+
})
389
416
}
417
+
418
+
setSelectedDomains(mappedDomains)
390
419
}
391
420
392
421
const handleSaveSiteConfig = async () => {
···
394
423
395
424
setIsSavingConfig(true)
396
425
try {
397
-
if (selectedDomain === 'wisp') {
398
-
// Map to wisp.place domain
426
+
// Determine which domains should be mapped/unmapped
427
+
const shouldMapWisp = selectedDomains.has('wisp')
428
+
const isCurrentlyMappedToWisp = wispDomain && wispDomain.rkey === configuringSite.rkey
429
+
430
+
// Handle wisp domain mapping
431
+
if (shouldMapWisp && !isCurrentlyMappedToWisp) {
432
+
// Map to wisp domain
399
433
const response = await fetch('/api/domain/wisp/map-site', {
400
434
method: 'POST',
401
435
headers: { 'Content-Type': 'application/json' },
402
436
body: JSON.stringify({ siteRkey: configuringSite.rkey })
403
437
})
404
438
const data = await response.json()
405
-
if (!data.success) throw new Error('Failed to map site')
406
-
} else if (selectedDomain === 'none') {
407
-
// Unmap from all domains
408
-
// Unmap wisp domain if this site was mapped to it
409
-
if (wispDomain && wispDomain.rkey === configuringSite.rkey) {
410
-
await fetch('/api/domain/wisp/map-site', {
439
+
if (!data.success) throw new Error('Failed to map wisp domain')
440
+
} else if (!shouldMapWisp && isCurrentlyMappedToWisp) {
441
+
// Unmap from wisp domain
442
+
await fetch('/api/domain/wisp/map-site', {
443
+
method: 'POST',
444
+
headers: { 'Content-Type': 'application/json' },
445
+
body: JSON.stringify({ siteRkey: null })
446
+
})
447
+
}
448
+
449
+
// Handle custom domain mappings
450
+
const selectedCustomDomainIds = Array.from(selectedDomains).filter(id => id !== 'wisp')
451
+
const currentlyMappedCustomDomains = customDomains.filter(
452
+
d => d.rkey === configuringSite.rkey
453
+
)
454
+
455
+
// Unmap domains that are no longer selected
456
+
for (const domain of currentlyMappedCustomDomains) {
457
+
if (!selectedCustomDomainIds.includes(domain.id)) {
458
+
await fetch(`/api/domain/custom/${domain.id}/map-site`, {
411
459
method: 'POST',
412
460
headers: { 'Content-Type': 'application/json' },
413
461
body: JSON.stringify({ siteRkey: null })
414
462
})
415
463
}
464
+
}
416
465
417
-
// Unmap from custom domains
418
-
const mappedCustom = customDomains.find(
419
-
(d) => d.rkey === configuringSite.rkey
420
-
)
421
-
if (mappedCustom) {
422
-
await fetch(`/api/domain/custom/${mappedCustom.id}/map-site`, {
466
+
// Map newly selected domains
467
+
for (const domainId of selectedCustomDomainIds) {
468
+
const isAlreadyMapped = currentlyMappedCustomDomains.some(d => d.id === domainId)
469
+
if (!isAlreadyMapped) {
470
+
const response = await fetch(`/api/domain/custom/${domainId}/map-site`, {
423
471
method: 'POST',
424
472
headers: { 'Content-Type': 'application/json' },
425
-
body: JSON.stringify({ siteRkey: null })
473
+
body: JSON.stringify({ siteRkey: configuringSite.rkey })
426
474
})
475
+
const data = await response.json()
476
+
if (!data.success) throw new Error(`Failed to map custom domain ${domainId}`)
427
477
}
428
-
} else {
429
-
// Map to a custom domain
430
-
const response = await fetch(
431
-
`/api/domain/custom/${selectedDomain}/map-site`,
432
-
{
433
-
method: 'POST',
434
-
headers: { 'Content-Type': 'application/json' },
435
-
body: JSON.stringify({ siteRkey: configuringSite.rkey })
436
-
}
437
-
)
438
-
const data = await response.json()
439
-
if (!data.success) throw new Error('Failed to map site')
440
478
}
441
479
442
-
// Refresh domains to get updated mappings
480
+
// Refresh both domains and sites to get updated mappings
443
481
await fetchDomains()
482
+
await fetchSites()
444
483
setConfiguringSite(null)
445
484
} catch (err) {
446
485
console.error('Save config error:', err)
···
638
677
active
639
678
</Badge>
640
679
</div>
641
-
<a
642
-
href={getSiteUrl(site)}
643
-
target="_blank"
644
-
rel="noopener noreferrer"
645
-
className="text-sm text-accent hover:text-accent/80 flex items-center gap-1"
646
-
>
647
-
{getSiteDomainName(site)}
648
-
<ExternalLink className="w-3 h-3" />
649
-
</a>
680
+
681
+
{/* Display all mapped domains */}
682
+
{site.domains && site.domains.length > 0 ? (
683
+
<div className="space-y-1">
684
+
{site.domains.map((domainInfo, idx) => (
685
+
<div key={`${domainInfo.domain}-${idx}`} className="flex items-center gap-2">
686
+
<a
687
+
href={`https://${domainInfo.domain}`}
688
+
target="_blank"
689
+
rel="noopener noreferrer"
690
+
className="text-sm text-accent hover:text-accent/80 flex items-center gap-1"
691
+
>
692
+
<Globe className="w-3 h-3" />
693
+
{domainInfo.domain}
694
+
<ExternalLink className="w-3 h-3" />
695
+
</a>
696
+
<Badge
697
+
variant={domainInfo.type === 'wisp' ? 'default' : 'outline'}
698
+
className="text-xs"
699
+
>
700
+
{domainInfo.type}
701
+
</Badge>
702
+
{domainInfo.type === 'custom' && (
703
+
<Badge
704
+
variant={domainInfo.verified ? 'default' : 'secondary'}
705
+
className="text-xs"
706
+
>
707
+
{domainInfo.verified ? (
708
+
<>
709
+
<CheckCircle2 className="w-3 h-3 mr-1" />
710
+
verified
711
+
</>
712
+
) : (
713
+
<>
714
+
<AlertCircle className="w-3 h-3 mr-1" />
715
+
pending
716
+
</>
717
+
)}
718
+
</Badge>
719
+
)}
720
+
</div>
721
+
))}
722
+
</div>
723
+
) : (
724
+
<a
725
+
href={getSiteUrl(site)}
726
+
target="_blank"
727
+
rel="noopener noreferrer"
728
+
className="text-sm text-muted-foreground hover:text-accent flex items-center gap-1"
729
+
>
730
+
{getSiteDomainName(site)}
731
+
<ExternalLink className="w-3 h-3" />
732
+
</a>
733
+
)}
650
734
</div>
651
735
<Button
652
736
variant="outline"
···
1405
1489
>
1406
1490
<DialogContent className="sm:max-w-lg">
1407
1491
<DialogHeader>
1408
-
<DialogTitle>Configure Site Domain</DialogTitle>
1492
+
<DialogTitle>Configure Site Domains</DialogTitle>
1409
1493
<DialogDescription>
1410
-
Choose which domain this site should use
1494
+
Select which domains should be mapped to this site. You can select multiple domains.
1411
1495
</DialogDescription>
1412
1496
</DialogHeader>
1413
1497
{configuringSite && (
···
1420
1504
</p>
1421
1505
</div>
1422
1506
1423
-
<RadioGroup
1424
-
value={selectedDomain}
1425
-
onValueChange={setSelectedDomain}
1426
-
>
1507
+
<div className="space-y-3">
1508
+
<p className="text-sm font-medium">Available Domains:</p>
1509
+
1427
1510
{wispDomain && (
1428
-
<div className="flex items-center space-x-2">
1429
-
<RadioGroupItem value="wisp" id="wisp" />
1511
+
<div className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/30">
1512
+
<Checkbox
1513
+
id="wisp"
1514
+
checked={selectedDomains.has('wisp')}
1515
+
onCheckedChange={(checked) => {
1516
+
const newSelected = new Set(selectedDomains)
1517
+
if (checked) {
1518
+
newSelected.add('wisp')
1519
+
} else {
1520
+
newSelected.delete('wisp')
1521
+
}
1522
+
setSelectedDomains(newSelected)
1523
+
}}
1524
+
/>
1430
1525
<Label
1431
1526
htmlFor="wisp"
1432
1527
className="flex-1 cursor-pointer"
···
1436
1531
{wispDomain.domain}
1437
1532
</span>
1438
1533
<Badge variant="secondary" className="text-xs ml-2">
1439
-
Free
1534
+
Wisp
1440
1535
</Badge>
1441
1536
</div>
1442
1537
</Label>
···
1448
1543
.map((domain) => (
1449
1544
<div
1450
1545
key={domain.id}
1451
-
className="flex items-center space-x-2"
1546
+
className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/30"
1452
1547
>
1453
-
<RadioGroupItem
1454
-
value={domain.id}
1548
+
<Checkbox
1455
1549
id={domain.id}
1550
+
checked={selectedDomains.has(domain.id)}
1551
+
onCheckedChange={(checked) => {
1552
+
const newSelected = new Set(selectedDomains)
1553
+
if (checked) {
1554
+
newSelected.add(domain.id)
1555
+
} else {
1556
+
newSelected.delete(domain.id)
1557
+
}
1558
+
setSelectedDomains(newSelected)
1559
+
}}
1456
1560
/>
1457
1561
<Label
1458
1562
htmlFor={domain.id}
···
1473
1577
</div>
1474
1578
))}
1475
1579
1476
-
<div className="flex items-center space-x-2">
1477
-
<RadioGroupItem value="none" id="none" />
1478
-
<Label htmlFor="none" className="flex-1 cursor-pointer">
1479
-
<div className="flex flex-col">
1480
-
<span className="text-sm">Default URL</span>
1481
-
<span className="text-xs text-muted-foreground font-mono break-all">
1482
-
sites.wisp.place/{configuringSite.did}/
1483
-
{configuringSite.rkey}
1484
-
</span>
1485
-
</div>
1486
-
</Label>
1487
-
</div>
1488
-
</RadioGroup>
1580
+
{customDomains.filter(d => d.verified).length === 0 && !wispDomain && (
1581
+
<p className="text-sm text-muted-foreground py-4 text-center">
1582
+
No domains available. Add a custom domain or claim your wisp.place subdomain.
1583
+
</p>
1584
+
)}
1585
+
</div>
1586
+
1587
+
<div className="p-3 bg-muted/20 rounded-lg border-l-4 border-blue-500/50">
1588
+
<p className="text-xs text-muted-foreground">
1589
+
<strong>Note:</strong> If no domains are selected, the site will be accessible at:{' '}
1590
+
<span className="font-mono">
1591
+
sites.wisp.place/{userInfo?.handle || '...'}/{configuringSite.rkey}
1592
+
</span>
1593
+
</p>
1594
+
</div>
1489
1595
</div>
1490
1596
)}
1491
1597
<DialogFooter className="flex flex-col sm:flex-row sm:justify-between gap-2">
+58
src/lib/db.ts
+58
src/lib/db.ts
···
578
578
return { success: false, error: err };
579
579
}
580
580
};
581
+
582
+
// Get all domains (wisp + custom) mapped to a specific site
583
+
export const getDomainsBySite = async (did: string, rkey: string) => {
584
+
const domains: Array<{
585
+
type: 'wisp' | 'custom';
586
+
domain: string;
587
+
verified?: boolean;
588
+
id?: string;
589
+
}> = [];
590
+
591
+
// Check wisp domain
592
+
const wispDomain = await db`
593
+
SELECT domain, rkey FROM domains
594
+
WHERE did = ${did} AND rkey = ${rkey}
595
+
`;
596
+
if (wispDomain.length > 0) {
597
+
domains.push({
598
+
type: 'wisp',
599
+
domain: wispDomain[0].domain,
600
+
});
601
+
}
602
+
603
+
// Check custom domains
604
+
const customDomains = await db`
605
+
SELECT id, domain, verified FROM custom_domains
606
+
WHERE did = ${did} AND rkey = ${rkey}
607
+
ORDER BY created_at DESC
608
+
`;
609
+
for (const cd of customDomains) {
610
+
domains.push({
611
+
type: 'custom',
612
+
domain: cd.domain,
613
+
verified: cd.verified,
614
+
id: cd.id,
615
+
});
616
+
}
617
+
618
+
return domains;
619
+
};
620
+
621
+
// Get count of domains mapped to a specific site
622
+
export const getDomainCountBySite = async (did: string, rkey: string) => {
623
+
const wispCount = await db`
624
+
SELECT COUNT(*) as count FROM domains
625
+
WHERE did = ${did} AND rkey = ${rkey}
626
+
`;
627
+
628
+
const customCount = await db`
629
+
SELECT COUNT(*) as count FROM custom_domains
630
+
WHERE did = ${did} AND rkey = ${rkey}
631
+
`;
632
+
633
+
return {
634
+
wisp: Number(wispCount[0]?.count || 0),
635
+
custom: Number(customCount[0]?.count || 0),
636
+
total: Number(wispCount[0]?.count || 0) + Number(customCount[0]?.count || 0),
637
+
};
638
+
};
+15
-1
src/routes/user.ts
+15
-1
src/routes/user.ts
···
2
2
import { requireAuth } from '../lib/wisp-auth'
3
3
import { NodeOAuthClient } from '@atproto/oauth-client-node'
4
4
import { Agent } from '@atproto/api'
5
-
import { getSitesByDid, getDomainByDid, getCustomDomainsByDid, getWispDomainInfo } from '../lib/db'
5
+
import { getSitesByDid, getDomainByDid, getCustomDomainsByDid, getWispDomainInfo, getDomainsBySite } from '../lib/db'
6
6
import { syncSitesFromPDS } from '../lib/sync-sites'
7
7
import { logger } from '../lib/logger'
8
8
···
98
98
throw new Error('Failed to sync sites')
99
99
}
100
100
})
101
+
.get('/site/:rkey/domains', async ({ auth, params }) => {
102
+
try {
103
+
const { rkey } = params
104
+
const domains = await getDomainsBySite(auth.did, rkey)
105
+
106
+
return {
107
+
rkey,
108
+
domains
109
+
}
110
+
} catch (err) {
111
+
logger.error('[User] Site domains error', err)
112
+
throw new Error('Failed to get domains for site')
113
+
}
114
+
})