tangled
alpha
login
or
join now
nekomimi.pet
/
wisp.place-monorepo
88
fork
atom
Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
88
fork
atom
overview
issues
9
pulls
pipelines
claudeslop up a frontend
nekomimi.pet
2 weeks ago
f1f32112
5ac0bf25
1/2
deploy-wisp.yml
success
38s
test.yml
failed
40s
+771
-4
11 changed files
expand all
collapse all
unified
split
apps
main-app
public
editor
editor.tsx
hooks
useWebhookData.ts
tabs
WebhooksTab.tsx
src
index.ts
lib
redis.ts
routes
webhook.ts
webhook-service
src
config.ts
index.ts
lib
db.ts
delivery.ts
redis.ts
+34
-4
apps/main-app/public/editor/editor.tsx
···
31
31
import { useUserInfo } from './hooks/useUserInfo'
32
32
import { useSiteData, type SiteWithDomains } from './hooks/useSiteData'
33
33
import { useDomainData } from './hooks/useDomainData'
34
34
+
import { useWebhookData } from './hooks/useWebhookData'
34
35
import { SitesTab } from './tabs/SitesTab'
35
36
import { DomainsTab } from './tabs/DomainsTab'
36
37
import { UploadTab } from './tabs/UploadTab'
37
38
import { CLITab } from './tabs/CLITab'
39
39
+
import { WebhooksTab } from './tabs/WebhooksTab'
38
40
39
41
function Dashboard() {
40
42
// Use custom hooks
41
43
const { userInfo, loading, isAuthenticated, fetchUserInfo } = useUserInfo()
42
44
const { sites, sitesLoading, isSyncing, fetchSites, syncSites, deleteSite } = useSiteData()
43
45
const {
46
46
+
webhooks, webhooksLoading, fetchWebhooks,
47
47
+
eventLogs, eventLogsLoading, fetchEventLogs,
48
48
+
isCreating, createWebhook, deleteWebhook,
49
49
+
} = useWebhookData()
50
50
+
51
51
+
const {
44
52
wispDomains,
45
53
customDomains,
46
54
domainsLoading,
···
82
90
fetchUserInfo()
83
91
fetchSites()
84
92
fetchDomains()
93
93
+
fetchWebhooks()
94
94
+
fetchEventLogs()
85
95
}, [])
86
96
87
97
// Redirect to home if not authenticated
···
107
117
108
118
// Handle tab navigation with arrow keys (Left/Right only)
109
119
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
110
110
-
const tabs = ['sites', 'domains', 'upload', 'cli']
120
120
+
const tabs = ['sites', 'domains', 'upload', 'webhooks', 'cli']
111
121
const currentIndex = tabs.indexOf(activeTab)
112
122
113
123
if (e.key === 'ArrowLeft' && currentIndex > 0) {
···
370
380
371
381
{/* Tabs Skeleton */}
372
382
<div className="space-y-6 w-full">
373
373
-
<div className="grid w-full grid-cols-4 border-b border-border/50">
374
374
-
{[...Array(4)].map((_, i) => (
383
383
+
<div className="grid w-full grid-cols-5 border-b border-border/50">
384
384
+
{[...Array(5)].map((_, i) => (
375
385
<SkeletonShimmer key={i} className="h-10 w-full" />
376
386
))}
377
387
</div>
···
444
454
</div>
445
455
446
456
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col flex-1 overflow-hidden">
447
447
-
<TabsList className="grid w-full grid-cols-4 bg-card border-b border-border/50 rounded-none h-auto p-0 flex-shrink-0">
457
457
+
<TabsList className="grid w-full grid-cols-5 bg-card border-b border-border/50 rounded-none h-auto p-0 flex-shrink-0">
448
458
<TabsTrigger
449
459
value="sites"
450
460
className="rounded-none border-b-2 border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50 data-[state=active]:border-accent data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none py-3"
···
462
472
className="rounded-none border-b-2 border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50 data-[state=active]:border-accent data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none py-3"
463
473
>
464
474
Upload
475
475
+
</TabsTrigger>
476
476
+
<TabsTrigger
477
477
+
value="webhooks"
478
478
+
className="rounded-none border-b-2 border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50 data-[state=active]:border-accent data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none py-3"
479
479
+
>
480
480
+
Webhooks
465
481
</TabsTrigger>
466
482
<TabsTrigger
467
483
value="cli"
···
505
521
sites={sites}
506
522
sitesLoading={sitesLoading}
507
523
onUploadComplete={handleUploadComplete}
524
524
+
/>
525
525
+
</TabsContent>
526
526
+
527
527
+
{/* Webhooks Tab */}
528
528
+
<TabsContent value="webhooks" className="flex-1 m-0 mt-4 overflow-hidden data-[state=inactive]:hidden">
529
529
+
<WebhooksTab
530
530
+
webhooks={webhooks}
531
531
+
webhooksLoading={webhooksLoading}
532
532
+
eventLogs={eventLogs}
533
533
+
eventLogsLoading={eventLogsLoading}
534
534
+
isCreating={isCreating}
535
535
+
onCreateWebhook={createWebhook}
536
536
+
onDeleteWebhook={deleteWebhook}
537
537
+
onRefreshEvents={fetchEventLogs}
508
538
/>
509
539
</TabsContent>
510
540
+112
apps/main-app/public/editor/hooks/useWebhookData.ts
···
1
1
+
import { useState } from 'react'
2
2
+
3
3
+
export interface WebhookRecord {
4
4
+
rkey: string
5
5
+
scopeAturi: string
6
6
+
url: string
7
7
+
backlinks: boolean
8
8
+
events: string[]
9
9
+
enabled: boolean
10
10
+
createdAt: string
11
11
+
}
12
12
+
13
13
+
export interface WebhookEventLog {
14
14
+
ownerDid: string
15
15
+
rkey: string
16
16
+
url: string
17
17
+
eventKind: string
18
18
+
eventDid: string
19
19
+
eventCollection: string
20
20
+
eventRkey: string
21
21
+
cid?: string
22
22
+
deliveredAt: string
23
23
+
status: 'ok' | 'failed'
24
24
+
}
25
25
+
26
26
+
export function useWebhookData() {
27
27
+
const [webhooks, setWebhooks] = useState<WebhookRecord[]>([])
28
28
+
const [webhooksLoading, setWebhooksLoading] = useState(false)
29
29
+
const [eventLogs, setEventLogs] = useState<WebhookEventLog[]>([])
30
30
+
const [eventLogsLoading, setEventLogsLoading] = useState(false)
31
31
+
const [isCreating, setIsCreating] = useState(false)
32
32
+
33
33
+
const fetchWebhooks = async () => {
34
34
+
setWebhooksLoading(true)
35
35
+
try {
36
36
+
const res = await fetch('/api/webhook', { credentials: 'include' })
37
37
+
if (!res.ok) throw new Error('Failed to fetch webhooks')
38
38
+
const data = await res.json()
39
39
+
if (data.success && data.records) {
40
40
+
setWebhooks(data.records.map((r: any) => ({
41
41
+
rkey: r.uri.split('/').pop(),
42
42
+
scopeAturi: r.value?.scope?.aturi ?? '',
43
43
+
url: r.value?.url ?? '',
44
44
+
backlinks: r.value?.scope?.backlinks ?? false,
45
45
+
events: r.value?.events ?? [],
46
46
+
enabled: r.value?.enabled ?? true,
47
47
+
createdAt: r.value?.createdAt ?? '',
48
48
+
})))
49
49
+
}
50
50
+
} catch (err) {
51
51
+
console.error('Failed to fetch webhooks:', err)
52
52
+
} finally {
53
53
+
setWebhooksLoading(false)
54
54
+
}
55
55
+
}
56
56
+
57
57
+
const createWebhook = async (data: {
58
58
+
scopeAturi: string
59
59
+
url: string
60
60
+
backlinks: boolean
61
61
+
events: string[]
62
62
+
secret: string
63
63
+
enabled: boolean
64
64
+
}) => {
65
65
+
setIsCreating(true)
66
66
+
try {
67
67
+
const res = await fetch('/api/webhook', {
68
68
+
method: 'POST',
69
69
+
headers: { 'Content-Type': 'application/json' },
70
70
+
credentials: 'include',
71
71
+
body: JSON.stringify(data),
72
72
+
})
73
73
+
const result = await res.json()
74
74
+
if (!res.ok || !result.success) throw new Error(result.error || 'Failed to create webhook')
75
75
+
await fetchWebhooks()
76
76
+
return result
77
77
+
} finally {
78
78
+
setIsCreating(false)
79
79
+
}
80
80
+
}
81
81
+
82
82
+
const deleteWebhook = async (rkey: string) => {
83
83
+
const res = await fetch(`/api/webhook/${rkey}`, {
84
84
+
method: 'DELETE',
85
85
+
credentials: 'include',
86
86
+
})
87
87
+
const result = await res.json()
88
88
+
if (!res.ok || !result.success) throw new Error(result.error || 'Failed to delete webhook')
89
89
+
setWebhooks(prev => prev.filter(w => w.rkey !== rkey))
90
90
+
}
91
91
+
92
92
+
/** Fetch the last 100 webhook delivery events for this user from Redis. */
93
93
+
const fetchEventLogs = async () => {
94
94
+
setEventLogsLoading(true)
95
95
+
try {
96
96
+
const res = await fetch('/api/webhook/events', { credentials: 'include' })
97
97
+
if (!res.ok) throw new Error('Failed to fetch events')
98
98
+
const data = await res.json()
99
99
+
if (data.success && data.events) setEventLogs(data.events)
100
100
+
} catch (err) {
101
101
+
console.error('Failed to fetch event logs:', err)
102
102
+
} finally {
103
103
+
setEventLogsLoading(false)
104
104
+
}
105
105
+
}
106
106
+
107
107
+
return {
108
108
+
webhooks, webhooksLoading, fetchWebhooks,
109
109
+
eventLogs, eventLogsLoading, fetchEventLogs,
110
110
+
isCreating, createWebhook, deleteWebhook,
111
111
+
}
112
112
+
}
+285
apps/main-app/public/editor/tabs/WebhooksTab.tsx
···
1
1
+
import { useState, useEffect } from 'react'
2
2
+
import { Button } from '@public/components/ui/button'
3
3
+
import { Input } from '@public/components/ui/input'
4
4
+
import { Checkbox } from '@public/components/ui/checkbox'
5
5
+
import { Label } from '@public/components/ui/label'
6
6
+
import { Badge } from '@public/components/ui/badge'
7
7
+
import { SkeletonShimmer } from '@public/components/ui/skeleton'
8
8
+
import { Loader2, RefreshCw, Trash2 } from 'lucide-react'
9
9
+
import type { WebhookRecord, WebhookEventLog } from '../hooks/useWebhookData'
10
10
+
11
11
+
interface WebhooksTabProps {
12
12
+
webhooks: WebhookRecord[]
13
13
+
webhooksLoading: boolean
14
14
+
eventLogs: WebhookEventLog[]
15
15
+
eventLogsLoading: boolean
16
16
+
isCreating: boolean
17
17
+
onCreateWebhook: (data: {
18
18
+
scopeAturi: string
19
19
+
url: string
20
20
+
backlinks: boolean
21
21
+
events: string[]
22
22
+
secret: string
23
23
+
enabled: boolean
24
24
+
}) => Promise<any>
25
25
+
onDeleteWebhook: (rkey: string) => Promise<void>
26
26
+
onRefreshEvents: () => Promise<void>
27
27
+
}
28
28
+
29
29
+
export function WebhooksTab({
30
30
+
webhooks,
31
31
+
webhooksLoading,
32
32
+
eventLogs,
33
33
+
eventLogsLoading,
34
34
+
isCreating,
35
35
+
onCreateWebhook,
36
36
+
onDeleteWebhook,
37
37
+
onRefreshEvents,
38
38
+
}: WebhooksTabProps) {
39
39
+
const [url, setUrl] = useState('')
40
40
+
const [scopeAturi, setScopeAturi] = useState('')
41
41
+
const [backlinks, setBacklinks] = useState(false)
42
42
+
const [eventCreate, setEventCreate] = useState(true)
43
43
+
const [eventUpdate, setEventUpdate] = useState(true)
44
44
+
const [eventDelete, setEventDelete] = useState(true)
45
45
+
const [secret, setSecret] = useState('')
46
46
+
const [enabled, setEnabled] = useState(true)
47
47
+
const [error, setError] = useState<string | null>(null)
48
48
+
const [success, setSuccess] = useState<string | null>(null)
49
49
+
const [deletingRkey, setDeletingRkey] = useState<string | null>(null)
50
50
+
51
51
+
// Auto-refresh event logs every 60 seconds
52
52
+
useEffect(() => {
53
53
+
const id = setInterval(onRefreshEvents, 60_000)
54
54
+
return () => clearInterval(id)
55
55
+
}, [onRefreshEvents])
56
56
+
57
57
+
const handleCreate = async (e: React.FormEvent) => {
58
58
+
e.preventDefault()
59
59
+
setError(null)
60
60
+
setSuccess(null)
61
61
+
62
62
+
if (!url.startsWith('http://') && !url.startsWith('https://')) {
63
63
+
setError('URL must start with http:// or https://')
64
64
+
return
65
65
+
}
66
66
+
if (!scopeAturi.startsWith('at://')) {
67
67
+
setError('Scope must be a valid AT-URI (at://...)')
68
68
+
return
69
69
+
}
70
70
+
71
71
+
const events: string[] = []
72
72
+
if (eventCreate) events.push('create')
73
73
+
if (eventUpdate) events.push('update')
74
74
+
if (eventDelete) events.push('delete')
75
75
+
76
76
+
try {
77
77
+
await onCreateWebhook({ url, scopeAturi, backlinks, events: events.length === 3 ? [] : events, secret, enabled })
78
78
+
setSuccess('Webhook created. It will become active once the service picks it up from the firehose.')
79
79
+
setUrl('')
80
80
+
setScopeAturi('')
81
81
+
setBacklinks(false)
82
82
+
setEventCreate(true)
83
83
+
setEventUpdate(true)
84
84
+
setEventDelete(true)
85
85
+
setSecret('')
86
86
+
setEnabled(true)
87
87
+
} catch (err) {
88
88
+
setError(err instanceof Error ? err.message : 'Failed to create webhook')
89
89
+
}
90
90
+
}
91
91
+
92
92
+
const handleDelete = async (rkey: string) => {
93
93
+
setDeletingRkey(rkey)
94
94
+
try {
95
95
+
await onDeleteWebhook(rkey)
96
96
+
} catch (err) {
97
97
+
alert(err instanceof Error ? err.message : 'Failed to delete webhook')
98
98
+
} finally {
99
99
+
setDeletingRkey(null)
100
100
+
}
101
101
+
}
102
102
+
103
103
+
return (
104
104
+
<div className="h-full flex flex-col border border-border/30 bg-card/50 font-mono">
105
105
+
{/* Header */}
106
106
+
<div className="p-4 pb-3 border-b border-border/30 flex-shrink-0">
107
107
+
<p className="text-sm font-semibold">Webhooks</p>
108
108
+
<p className="text-xs text-muted-foreground mt-0.5">Receive HTTP callbacks when AT Protocol records change</p>
109
109
+
</div>
110
110
+
111
111
+
{/* Content */}
112
112
+
<div className="flex-1 min-h-0 overflow-y-auto p-4 space-y-6">
113
113
+
114
114
+
{/* Create webhook form */}
115
115
+
<div className="space-y-3">
116
116
+
<p className="text-xs uppercase tracking-wider text-muted-foreground">Create Webhook</p>
117
117
+
<form onSubmit={handleCreate} className="space-y-3 p-3 border border-border/30">
118
118
+
<div className="space-y-1">
119
119
+
<Label htmlFor="wh-url" className="text-xs">URL <span className="text-muted-foreground">(required)</span></Label>
120
120
+
<Input
121
121
+
id="wh-url"
122
122
+
value={url}
123
123
+
onChange={e => setUrl(e.target.value)}
124
124
+
placeholder="https://example.com/webhook"
125
125
+
required
126
126
+
className="h-8 text-sm"
127
127
+
/>
128
128
+
</div>
129
129
+
130
130
+
<div className="space-y-1">
131
131
+
<Label htmlFor="wh-scope" className="text-xs">Scope <span className="text-muted-foreground">(required)</span></Label>
132
132
+
<Input
133
133
+
id="wh-scope"
134
134
+
value={scopeAturi}
135
135
+
onChange={e => setScopeAturi(e.target.value)}
136
136
+
placeholder="at://did:plc:... or at://did:plc:.../app.bsky.*"
137
137
+
required
138
138
+
className="h-8 text-sm"
139
139
+
/>
140
140
+
<p className="text-xs text-muted-foreground">
141
141
+
<code className="bg-muted px-1">at://did</code> watches all records,{' '}
142
142
+
<code className="bg-muted px-1">at://did/collection</code> for a specific one,{' '}
143
143
+
<code className="bg-muted px-1">at://did/app.bsky.*</code> for a glob.
144
144
+
</p>
145
145
+
</div>
146
146
+
147
147
+
<div className="flex items-center gap-2">
148
148
+
<Checkbox id="wh-backlinks" checked={backlinks} onCheckedChange={v => setBacklinks(!!v)} />
149
149
+
<Label htmlFor="wh-backlinks" className="cursor-pointer text-xs">
150
150
+
Backlinks <span className="text-muted-foreground">— also fire when other records reference this scope</span>
151
151
+
</Label>
152
152
+
</div>
153
153
+
154
154
+
<div className="space-y-1.5">
155
155
+
<Label className="text-xs">Events</Label>
156
156
+
<div className="flex gap-4">
157
157
+
{([['create', eventCreate, setEventCreate], ['update', eventUpdate, setEventUpdate], ['delete', eventDelete, setEventDelete]] as const).map(([name, val, set]) => (
158
158
+
<div key={name} className="flex items-center gap-1.5">
159
159
+
<Checkbox id={`wh-event-${name}`} checked={val} onCheckedChange={v => set(!!v)} />
160
160
+
<Label htmlFor={`wh-event-${name}`} className="cursor-pointer text-xs capitalize">{name}</Label>
161
161
+
</div>
162
162
+
))}
163
163
+
</div>
164
164
+
<p className="text-xs text-muted-foreground">All checked = no filter (fires on all events)</p>
165
165
+
</div>
166
166
+
167
167
+
<div className="space-y-1">
168
168
+
<Label htmlFor="wh-secret" className="text-xs">Secret <span className="text-muted-foreground">(optional)</span></Label>
169
169
+
<Input
170
170
+
id="wh-secret"
171
171
+
type="password"
172
172
+
value={secret}
173
173
+
onChange={e => setSecret(e.target.value)}
174
174
+
placeholder="HMAC-SHA256 signing secret"
175
175
+
className="h-8 text-sm"
176
176
+
/>
177
177
+
<p className="text-xs text-muted-foreground">Stored publicly in your PDS record — transport integrity, not authentication.</p>
178
178
+
</div>
179
179
+
180
180
+
<div className="flex items-center gap-2">
181
181
+
<Checkbox id="wh-enabled" checked={enabled} onCheckedChange={v => setEnabled(!!v)} />
182
182
+
<Label htmlFor="wh-enabled" className="cursor-pointer text-xs">Enabled</Label>
183
183
+
</div>
184
184
+
185
185
+
{error && <p className="text-xs text-destructive">{error}</p>}
186
186
+
{success && <p className="text-xs text-green-500">{success}</p>}
187
187
+
188
188
+
<Button type="submit" disabled={isCreating} size="sm" className="w-full sm:w-auto">
189
189
+
{isCreating ? <><Loader2 className="w-3 h-3 mr-2 animate-spin" />Creating...</> : 'Create Webhook'}
190
190
+
</Button>
191
191
+
</form>
192
192
+
</div>
193
193
+
194
194
+
{/* Existing webhooks */}
195
195
+
<div className="space-y-2">
196
196
+
<p className="text-xs uppercase tracking-wider text-muted-foreground">Your Webhooks</p>
197
197
+
{webhooksLoading ? (
198
198
+
<div className="space-y-2">
199
199
+
{[...Array(2)].map((_, i) => <SkeletonShimmer key={i} className="h-14 w-full" />)}
200
200
+
</div>
201
201
+
) : webhooks.length === 0 ? (
202
202
+
<p className="text-xs text-muted-foreground py-2">No webhooks configured.</p>
203
203
+
) : (
204
204
+
<div className="space-y-2">
205
205
+
{webhooks.map(wh => (
206
206
+
<div key={wh.rkey} className="flex items-start justify-between p-3 border border-border/30 gap-4">
207
207
+
<div className="space-y-1 min-w-0">
208
208
+
<p className="text-sm truncate">{wh.url}</p>
209
209
+
<p className="text-xs text-muted-foreground truncate">{wh.scopeAturi}</p>
210
210
+
<div className="flex gap-1.5 flex-wrap">
211
211
+
{!wh.enabled && <Badge variant="secondary" className="text-[10px]">disabled</Badge>}
212
212
+
{wh.backlinks && <Badge variant="outline" className="text-[10px]">backlinks</Badge>}
213
213
+
{wh.events.length > 0 && wh.events.map(e => <Badge key={e} variant="outline" className="text-[10px]">{e}</Badge>)}
214
214
+
</div>
215
215
+
</div>
216
216
+
<Button
217
217
+
variant="ghost"
218
218
+
size="sm"
219
219
+
onClick={() => handleDelete(wh.rkey)}
220
220
+
disabled={deletingRkey === wh.rkey}
221
221
+
className="flex-shrink-0 h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
222
222
+
>
223
223
+
{deletingRkey === wh.rkey ? <Loader2 className="w-3 h-3 animate-spin" /> : <Trash2 className="w-3 h-3" />}
224
224
+
</Button>
225
225
+
</div>
226
226
+
))}
227
227
+
</div>
228
228
+
)}
229
229
+
</div>
230
230
+
231
231
+
{/* Event logs */}
232
232
+
<div className="space-y-2">
233
233
+
<div className="flex items-center justify-between">
234
234
+
<p className="text-xs uppercase tracking-wider text-muted-foreground">
235
235
+
Event Logs <span className="font-normal normal-case">(auto-refreshes every 60s)</span>
236
236
+
</p>
237
237
+
<Button variant="outline" size="sm" onClick={onRefreshEvents} disabled={eventLogsLoading} className="h-7 px-2 gap-1.5 text-xs">
238
238
+
<RefreshCw className={`w-3 h-3 ${eventLogsLoading ? 'animate-spin' : ''}`} />
239
239
+
Refresh
240
240
+
</Button>
241
241
+
</div>
242
242
+
243
243
+
{eventLogsLoading ? (
244
244
+
<div className="space-y-1.5">
245
245
+
{[...Array(3)].map((_, i) => <SkeletonShimmer key={i} className="h-7 w-full" />)}
246
246
+
</div>
247
247
+
) : eventLogs.length === 0 ? (
248
248
+
<p className="text-xs text-muted-foreground py-2">No events yet.</p>
249
249
+
) : (
250
250
+
<div className="overflow-x-auto border border-border/30">
251
251
+
<table className="w-full text-xs font-mono border-collapse">
252
252
+
<thead>
253
253
+
<tr className="border-b border-border/30 text-muted-foreground bg-muted/20">
254
254
+
<th className="text-left py-2 px-3">Time</th>
255
255
+
<th className="text-left py-2 px-3">Status</th>
256
256
+
<th className="text-left py-2 px-3">Event</th>
257
257
+
<th className="text-left py-2 px-3">Source DID</th>
258
258
+
<th className="text-left py-2 px-3">Collection</th>
259
259
+
<th className="text-left py-2 px-3">Rkey</th>
260
260
+
<th className="text-left py-2 px-3">Delivered To</th>
261
261
+
</tr>
262
262
+
</thead>
263
263
+
<tbody>
264
264
+
{eventLogs.map((log, i) => (
265
265
+
<tr key={i} className="border-b border-border/20 hover:bg-muted/20">
266
266
+
<td className="py-1.5 px-3 text-muted-foreground whitespace-nowrap">{new Date(log.deliveredAt).toLocaleTimeString()}</td>
267
267
+
<td className="py-1.5 px-3">
268
268
+
<Badge variant={log.status === 'ok' ? 'default' : 'destructive'} className="text-[10px]">{log.status}</Badge>
269
269
+
</td>
270
270
+
<td className="py-1.5 px-3">{log.eventKind}</td>
271
271
+
<td className="py-1.5 px-3 truncate max-w-[10rem]">{log.eventDid}</td>
272
272
+
<td className="py-1.5 px-3">{log.eventCollection}</td>
273
273
+
<td className="py-1.5 px-3">{log.eventRkey}</td>
274
274
+
<td className="py-1.5 px-3 truncate max-w-[10rem]">{log.url}</td>
275
275
+
</tr>
276
276
+
))}
277
277
+
</tbody>
278
278
+
</table>
279
279
+
</div>
280
280
+
)}
281
281
+
</div>
282
282
+
</div>
283
283
+
</div>
284
284
+
)
285
285
+
}
+8
apps/main-app/src/index.ts
···
16
16
rotateKeysIfNeeded
17
17
} from './lib/oauth-client'
18
18
import { getCookieSecret, closeDatabase } from './lib/db'
19
19
+
import { getRedisClient, closeRedisClient } from './lib/redis'
19
20
import { ensureServiceIdentityKeypair } from './lib/service-identity'
20
21
import { authRoutes } from './routes/auth'
21
22
import { wispRoutes } from './routes/wisp'
22
23
import { domainRoutes } from './routes/domain'
23
24
import { userRoutes } from './routes/user'
24
25
import { siteRoutes } from './routes/site'
26
26
+
import { webhookRoutes } from './routes/webhook'
25
27
import { xrpcRoutes } from './routes/xrpc'
26
28
import { csrfProtection } from './lib/csrf'
27
29
import { DNSVerificationWorker } from './lib/dns-verification-worker'
···
62
64
63
65
// Initialize admin setup (prompt if no admin exists)
64
66
await promptAdminSetup()
67
67
+
68
68
+
// Establish Redis connection (used for webhook event logs)
69
69
+
getRedisClient()
65
70
66
71
// Get or generate cookie signing secret
67
72
const cookieSecret = await getCookieSecret()
···
238
243
.use(domainRoutes(client, cookieSecret))
239
244
.use(userRoutes(client, cookieSecret))
240
245
.use(siteRoutes(client, cookieSecret))
246
246
+
.use(webhookRoutes(client, cookieSecret))
241
247
.use(adminRoutes(cookieSecret))
242
248
.use(
243
249
await staticPlugin({
···
478
484
process.on('SIGINT', async () => {
479
485
console.log('\n🛑 Shutting down...')
480
486
dnsVerifier.stop()
487
487
+
closeRedisClient()
481
488
await closeDatabase()
482
489
process.exit(0)
483
490
})
···
485
492
process.on('SIGTERM', async () => {
486
493
console.log('\n🛑 Shutting down...')
487
494
dnsVerifier.stop()
495
495
+
closeRedisClient()
488
496
await closeDatabase()
489
497
process.exit(0)
490
498
})
+28
apps/main-app/src/lib/redis.ts
···
1
1
+
import { RedisClient } from 'bun';
2
2
+
import { createLogger } from '@wispplace/observability';
3
3
+
4
4
+
const logger = createLogger('main-app:redis');
5
5
+
6
6
+
let client: RedisClient | null = null;
7
7
+
8
8
+
/** Returns the shared Redis client, creating it lazily. Returns null if REDIS_URL is not set. */
9
9
+
export function getRedisClient(): RedisClient | null {
10
10
+
const redisUrl = Bun.env.REDIS_URL;
11
11
+
if (!redisUrl) return null;
12
12
+
13
13
+
if (!client) {
14
14
+
logger.info(`[Redis] Connecting to ${redisUrl}`);
15
15
+
client = new RedisClient(redisUrl);
16
16
+
client.onconnect = () => logger.info('[Redis] Connected');
17
17
+
client.onclose = (err) => {
18
18
+
if (err) logger.error('[Redis] Disconnected with error', err);
19
19
+
};
20
20
+
}
21
21
+
22
22
+
return client;
23
23
+
}
24
24
+
25
25
+
export function closeRedisClient(): void {
26
26
+
client?.close();
27
27
+
client = null;
28
28
+
}
+146
apps/main-app/src/routes/webhook.ts
···
1
1
+
import { Elysia, t } from 'elysia'
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
5
+
import { TID } from '@atproto/common-web'
6
6
+
import { createLogger } from '@wispplace/observability'
7
7
+
import { db } from '../lib/db'
8
8
+
9
9
+
const logger = createLogger('main-app')
10
10
+
11
11
+
export const webhookRoutes = (client: NodeOAuthClient, cookieSecret: string) =>
12
12
+
new Elysia({
13
13
+
prefix: '/api/webhook',
14
14
+
cookie: { secrets: cookieSecret, sign: ['did'] }
15
15
+
})
16
16
+
.derive(async ({ cookie }) => {
17
17
+
const auth = await requireAuth(client, cookie)
18
18
+
return { auth }
19
19
+
})
20
20
+
/**
21
21
+
* POST /api/webhook
22
22
+
* Creates a place.wisp.v2.wh record in the user's PDS.
23
23
+
* The webhook service will pick it up from the firehose.
24
24
+
* Success: { success: true, rkey, uri }
25
25
+
*/
26
26
+
.post('/', async ({ body, auth, set }) => {
27
27
+
try {
28
28
+
const agent = new Agent((url, init) => auth.session.fetchHandler(url, init))
29
29
+
const rkey = TID.nextStr()
30
30
+
const record = {
31
31
+
$type: 'place.wisp.v2.wh',
32
32
+
scope: {
33
33
+
aturi: body.scopeAturi,
34
34
+
...(body.backlinks ? { backlinks: true } : {}),
35
35
+
},
36
36
+
url: body.url,
37
37
+
...(body.events && body.events.length > 0 ? { events: body.events } : {}),
38
38
+
...(body.secret ? { secret: body.secret } : {}),
39
39
+
enabled: body.enabled ?? true,
40
40
+
createdAt: new Date().toISOString(),
41
41
+
}
42
42
+
43
43
+
const result = await agent.com.atproto.repo.putRecord({
44
44
+
repo: auth.did,
45
45
+
collection: 'place.wisp.v2.wh',
46
46
+
rkey,
47
47
+
record,
48
48
+
})
49
49
+
50
50
+
logger.info(`[Webhook] Created webhook ${rkey} for ${auth.did} → ${body.url}`)
51
51
+
52
52
+
return { success: true, rkey, uri: result.data.uri }
53
53
+
} catch (err) {
54
54
+
logger.error('[Webhook] Create error', err)
55
55
+
set.status = 500
56
56
+
return { success: false, error: err instanceof Error ? err.message : 'Failed to create webhook' }
57
57
+
}
58
58
+
}, {
59
59
+
body: t.Object({
60
60
+
scopeAturi: t.String(),
61
61
+
url: t.String(),
62
62
+
backlinks: t.Optional(t.Boolean()),
63
63
+
events: t.Optional(t.Array(t.Union([
64
64
+
t.Literal('create'),
65
65
+
t.Literal('update'),
66
66
+
t.Literal('delete'),
67
67
+
]))),
68
68
+
secret: t.Optional(t.String()),
69
69
+
enabled: t.Optional(t.Boolean()),
70
70
+
})
71
71
+
})
72
72
+
/**
73
73
+
* DELETE /api/webhook/:rkey
74
74
+
* Deletes a place.wisp.v2.wh record from the user's PDS.
75
75
+
*/
76
76
+
.delete('/:rkey', async ({ params, auth, set }) => {
77
77
+
try {
78
78
+
const agent = new Agent((url, init) => auth.session.fetchHandler(url, init))
79
79
+
await agent.com.atproto.repo.deleteRecord({
80
80
+
repo: auth.did,
81
81
+
collection: 'place.wisp.v2.wh',
82
82
+
rkey: params.rkey,
83
83
+
})
84
84
+
logger.info(`[Webhook] Deleted webhook ${params.rkey} for ${auth.did}`)
85
85
+
return { success: true }
86
86
+
} catch (err) {
87
87
+
logger.error('[Webhook] Delete error', err)
88
88
+
set.status = 500
89
89
+
return { success: false, error: err instanceof Error ? err.message : 'Failed to delete webhook' }
90
90
+
}
91
91
+
})
92
92
+
/**
93
93
+
* GET /api/webhook
94
94
+
* Lists the user's place.wisp.v2.wh records from their PDS.
95
95
+
*/
96
96
+
.get('/', async ({ auth, set }) => {
97
97
+
try {
98
98
+
const agent = new Agent((url, init) => auth.session.fetchHandler(url, init))
99
99
+
const result = await agent.com.atproto.repo.listRecords({
100
100
+
repo: auth.did,
101
101
+
collection: 'place.wisp.v2.wh',
102
102
+
limit: 100,
103
103
+
})
104
104
+
return { success: true, records: result.data.records }
105
105
+
} catch (err) {
106
106
+
logger.error('[Webhook] List error', err)
107
107
+
set.status = 500
108
108
+
return { success: false, error: err instanceof Error ? err.message : 'Failed to list webhooks' }
109
109
+
}
110
110
+
})
111
111
+
/**
112
112
+
* GET /api/webhook/events
113
113
+
* Returns the 100 most recent delivery events for the authenticated user from the shared DB.
114
114
+
*/
115
115
+
.get('/events', async ({ auth, set }) => {
116
116
+
try {
117
117
+
const rows = await db<Array<{
118
118
+
rkey: string; url: string; event_kind: string; event_did: string;
119
119
+
event_collection: string; event_rkey: string; cid: string | null;
120
120
+
status: string; delivered_at: string;
121
121
+
}>>`
122
122
+
SELECT rkey, url, event_kind, event_did, event_collection, event_rkey, cid, status, delivered_at
123
123
+
FROM webhook_event_logs
124
124
+
WHERE owner_did = ${auth.did}
125
125
+
ORDER BY delivered_at DESC
126
126
+
LIMIT 100
127
127
+
`
128
128
+
const events = rows.map(r => ({
129
129
+
ownerDid: auth.did,
130
130
+
rkey: r.rkey,
131
131
+
url: r.url,
132
132
+
eventKind: r.event_kind,
133
133
+
eventDid: r.event_did,
134
134
+
eventCollection: r.event_collection,
135
135
+
eventRkey: r.event_rkey,
136
136
+
cid: r.cid ?? undefined,
137
137
+
status: r.status,
138
138
+
deliveredAt: r.delivered_at,
139
139
+
}))
140
140
+
return { success: true, events }
141
141
+
} catch (err) {
142
142
+
logger.error('[Webhook] Events list error', err)
143
143
+
set.status = 500
144
144
+
return { success: false, error: 'Failed to fetch events' }
145
145
+
}
146
146
+
})
+2
apps/webhook-service/src/config.ts
···
3
3
healthPort: parseInt(process.env.HEALTH_PORT || '3003', 10),
4
4
deliveryTimeoutMs: parseInt(process.env.DELIVERY_TIMEOUT_MS || '10000', 10),
5
5
deliveryMaxRetries: parseInt(process.env.DELIVERY_MAX_RETRIES || '3', 10),
6
6
+
redisUrl: process.env.REDIS_URL,
7
7
+
webhookEventsChannel: process.env.WEBHOOK_EVENTS_CHANNEL || 'webhook:events',
6
8
} as const;
+2
apps/webhook-service/src/index.ts
···
2
2
import { config } from './config';
3
3
import { startFirehose, stopFirehose, getFirehoseHealth } from './lib/firehose';
4
4
import { closeDatabase, db } from './lib/db';
5
5
+
import { closeRedisPublisher } from './lib/redis';
5
6
6
7
const logger = createLogger('webhook-service');
7
8
···
93
94
isShuttingDown = true;
94
95
logger.info(`Received ${signal}, shutting down...`);
95
96
stopFirehose();
97
97
+
closeRedisPublisher();
96
98
await closeDatabase();
97
99
process.exit(0);
98
100
}
+88
apps/webhook-service/src/lib/db.ts
···
40
40
)
41
41
`;
42
42
43
43
+
await db`
44
44
+
CREATE TABLE IF NOT EXISTS webhook_event_logs (
45
45
+
id BIGSERIAL PRIMARY KEY,
46
46
+
owner_did TEXT NOT NULL,
47
47
+
rkey TEXT NOT NULL,
48
48
+
url TEXT NOT NULL,
49
49
+
event_kind TEXT NOT NULL,
50
50
+
event_did TEXT NOT NULL,
51
51
+
event_collection TEXT NOT NULL,
52
52
+
event_rkey TEXT NOT NULL,
53
53
+
cid TEXT,
54
54
+
status TEXT NOT NULL,
55
55
+
delivered_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
56
56
+
)
57
57
+
`;
58
58
+
59
59
+
await db`
60
60
+
CREATE INDEX IF NOT EXISTS webhook_event_logs_owner_did_idx
61
61
+
ON webhook_event_logs (owner_did, delivered_at DESC)
62
62
+
`;
63
63
+
43
64
/**
44
65
* Find all webhook records whose scope AT-URI targets the given DID.
45
66
* Matches exact DID scope (`at://did`) and collection/rkey sub-scopes (`at://did/...`).
···
135
156
logger.error(`[DB] deleteWebhookRecord error for ${k}`, err);
136
157
throw err;
137
158
}
159
159
+
}
160
160
+
161
161
+
export interface EventLogEntry {
162
162
+
ownerDid: string;
163
163
+
rkey: string;
164
164
+
url: string;
165
165
+
eventKind: string;
166
166
+
eventDid: string;
167
167
+
eventCollection: string;
168
168
+
eventRkey: string;
169
169
+
cid?: string;
170
170
+
status: 'ok' | 'failed';
171
171
+
deliveredAt: string;
172
172
+
}
173
173
+
174
174
+
/** Insert a webhook delivery event into the persistent log. Keeps the last 500 rows per owner. */
175
175
+
export async function insertEventLog(entry: EventLogEntry): Promise<void> {
176
176
+
try {
177
177
+
await db`
178
178
+
INSERT INTO webhook_event_logs
179
179
+
(owner_did, rkey, url, event_kind, event_did, event_collection, event_rkey, cid, status, delivered_at)
180
180
+
VALUES
181
181
+
(${entry.ownerDid}, ${entry.rkey}, ${entry.url}, ${entry.eventKind},
182
182
+
${entry.eventDid}, ${entry.eventCollection}, ${entry.eventRkey},
183
183
+
${entry.cid ?? null}, ${entry.status}, ${entry.deliveredAt}::timestamptz)
184
184
+
`;
185
185
+
// Prune to last 500 per owner
186
186
+
await db`
187
187
+
DELETE FROM webhook_event_logs
188
188
+
WHERE owner_did = ${entry.ownerDid}
189
189
+
AND id NOT IN (
190
190
+
SELECT id FROM webhook_event_logs
191
191
+
WHERE owner_did = ${entry.ownerDid}
192
192
+
ORDER BY delivered_at DESC
193
193
+
LIMIT 500
194
194
+
)
195
195
+
`;
196
196
+
} catch (err) {
197
197
+
logger.error('[DB] insertEventLog error', err);
198
198
+
}
199
199
+
}
200
200
+
201
201
+
/** Return up to `limit` most-recent delivery events for an owner DID. */
202
202
+
export async function listEventLogs(ownerDid: string, limit = 100): Promise<EventLogEntry[]> {
203
203
+
const rows = await db<Array<{
204
204
+
rkey: string; url: string; event_kind: string; event_did: string;
205
205
+
event_collection: string; event_rkey: string; cid: string | null;
206
206
+
status: string; delivered_at: string;
207
207
+
}>>`
208
208
+
SELECT rkey, url, event_kind, event_did, event_collection, event_rkey, cid, status, delivered_at
209
209
+
FROM webhook_event_logs
210
210
+
WHERE owner_did = ${ownerDid}
211
211
+
ORDER BY delivered_at DESC
212
212
+
LIMIT ${limit}
213
213
+
`;
214
214
+
return rows.map(r => ({
215
215
+
ownerDid,
216
216
+
rkey: r.rkey,
217
217
+
url: r.url,
218
218
+
eventKind: r.event_kind,
219
219
+
eventDid: r.event_did,
220
220
+
eventCollection: r.event_collection,
221
221
+
eventRkey: r.event_rkey,
222
222
+
cid: r.cid ?? undefined,
223
223
+
status: r.status as 'ok' | 'failed',
224
224
+
deliveredAt: r.delivered_at,
225
225
+
}));
138
226
}
139
227
140
228
/** Close all database connections gracefully. */
+8
apps/webhook-service/src/lib/delivery.ts
···
1
1
import { createHmac, randomUUID } from 'node:crypto';
2
2
import type { WebhookEntry } from './db';
3
3
+
import { insertEventLog } from './db';
3
4
import type { EventKind } from './matcher';
4
5
import { config } from '../config';
5
6
import { createLogger } from '@wispplace/observability';
7
7
+
import { publishWebhookEvent } from './redis';
6
8
7
9
const logger = createLogger('webhook-service:delivery');
8
10
···
80
82
try {
81
83
await attempt(record.url, body, signature);
82
84
logger.info(`[delivery] ok ${ownerDid}/${rkey} → ${record.url}`);
85
85
+
const okEvent = { ownerDid, rkey, url: record.url, eventKind, eventDid, eventCollection, eventRkey, cid: eventCid, deliveredAt: payload.timestamp, status: 'ok' as const };
86
86
+
publishWebhookEvent(okEvent).catch(() => {});
87
87
+
insertEventLog(okEvent).catch(() => {});
83
88
return;
84
89
} catch (err) {
85
90
const isLast = attempt_n === config.deliveryMaxRetries;
86
91
if (isLast) {
87
92
logger.warn(`Failed to deliver webhook ${ownerDid}/${rkey} → ${record.url} after ${attempt_n} attempts`, { err });
93
93
+
const failEvent = { ownerDid, rkey, url: record.url, eventKind, eventDid, eventCollection, eventRkey, cid: eventCid, deliveredAt: new Date().toISOString(), status: 'failed' as const };
94
94
+
publishWebhookEvent(failEvent).catch(() => {});
95
95
+
insertEventLog(failEvent).catch(() => {});
88
96
} else {
89
97
const delay = 1000 * 2 ** (attempt_n - 1);
90
98
await new Promise(r => setTimeout(r, delay));
+58
apps/webhook-service/src/lib/redis.ts
···
1
1
+
import { RedisClient } from 'bun';
2
2
+
import { createLogger } from '@wispplace/observability';
3
3
+
import { config } from '../config';
4
4
+
5
5
+
const logger = createLogger('webhook-service:redis');
6
6
+
7
7
+
export interface WebhookEvent {
8
8
+
ownerDid: string;
9
9
+
rkey: string;
10
10
+
url: string;
11
11
+
eventKind: string;
12
12
+
eventDid: string;
13
13
+
eventCollection: string;
14
14
+
eventRkey: string;
15
15
+
cid?: string;
16
16
+
deliveredAt: string;
17
17
+
status: 'ok' | 'failed';
18
18
+
}
19
19
+
20
20
+
let publisher: RedisClient | null = null;
21
21
+
let loggedMissingRedis = false;
22
22
+
23
23
+
function getPublisher(): RedisClient | null {
24
24
+
if (!config.redisUrl) {
25
25
+
if (!loggedMissingRedis) {
26
26
+
logger.warn('[Redis] REDIS_URL not set — webhook event publishing disabled');
27
27
+
loggedMissingRedis = true;
28
28
+
}
29
29
+
return null;
30
30
+
}
31
31
+
32
32
+
if (!publisher) {
33
33
+
logger.info(`[Redis] Connecting to ${config.redisUrl}`);
34
34
+
publisher = new RedisClient(config.redisUrl);
35
35
+
publisher.onconnect = () => logger.info('[Redis] Publisher connected');
36
36
+
publisher.onclose = (err) => {
37
37
+
if (err) logger.error('[Redis] Publisher disconnected', err);
38
38
+
};
39
39
+
}
40
40
+
41
41
+
return publisher;
42
42
+
}
43
43
+
44
44
+
/** Publish a webhook delivery event to Redis. Fire-and-forget; never throws. */
45
45
+
export async function publishWebhookEvent(event: WebhookEvent): Promise<void> {
46
46
+
const client = getPublisher();
47
47
+
if (!client) return;
48
48
+
try {
49
49
+
await client.publish(config.webhookEventsChannel, JSON.stringify(event));
50
50
+
} catch (err) {
51
51
+
logger.error('[Redis] Failed to publish webhook event', err);
52
52
+
}
53
53
+
}
54
54
+
55
55
+
export function closeRedisPublisher(): void {
56
56
+
publisher?.close();
57
57
+
publisher = null;
58
58
+
}