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

claudeslop up a frontend

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