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

Configure Feed

Select the types of activity you want to include in your feed.

major webhook work

+1803 -30
+17
apps/main-app/public/editor/editor.tsx
··· 20 20 import { createRoot } from 'react-dom/client' 21 21 import { BrowserRouter, Route, Routes } from 'react-router-dom' 22 22 import { useDomainData } from './hooks/useDomainData' 23 + import { useSecretData } from './hooks/useSecretData' 23 24 import { type SiteWithDomains, useSiteData } from './hooks/useSiteData' 24 25 import { useUserInfo } from './hooks/useUserInfo' 25 26 import { useWebhookData } from './hooks/useWebhookData' ··· 51 52 createWebhook, 52 53 deleteWebhook, 53 54 } = useWebhookData() 55 + const { 56 + secrets, 57 + secretsLoading, 58 + isCreatingSecret, 59 + fetchSecrets, 60 + createSecret, 61 + deleteSecret, 62 + rotateSecret, 63 + } = useSecretData() 54 64 55 65 const { 56 66 wispDomains, ··· 97 107 fetchDomains() 98 108 fetchWebhooks() 99 109 fetchEventLogs() 110 + fetchSecrets() 100 111 }, []) 101 112 102 113 // Redirect to home if not authenticated ··· 534 545 eventLogsLoading={eventLogsLoading} 535 546 isCreating={isCreating} 536 547 userDid={userInfo?.did} 548 + secrets={secrets} 549 + secretsLoading={secretsLoading} 550 + isCreatingSecret={isCreatingSecret} 537 551 onCreateWebhook={createWebhook} 538 552 onDeleteWebhook={deleteWebhook} 539 553 onRefreshEvents={fetchEventLogs} 554 + onCreateSecret={createSecret} 555 + onDeleteSecret={deleteSecret} 556 + onRotateSecret={rotateSecret} 540 557 /> 541 558 </TabsContent> 542 559
+83
apps/main-app/public/editor/hooks/useSecretData.ts
··· 1 + import { useCallback, useState } from 'react' 2 + 3 + export interface SecretMeta { 4 + name: string 5 + createdAt: string 6 + lastRotatedAt?: string 7 + } 8 + 9 + export function useSecretData() { 10 + const [secrets, setSecrets] = useState<SecretMeta[]>([]) 11 + const [secretsLoading, setSecretsLoading] = useState(false) 12 + const [isCreatingSecret, setIsCreatingSecret] = useState(false) 13 + 14 + const fetchSecrets = useCallback(async () => { 15 + setSecretsLoading(true) 16 + try { 17 + const res = await fetch('/api/secret', { credentials: 'include' }) 18 + if (!res.ok) throw new Error('Failed to fetch secrets') 19 + const data = await res.json() 20 + setSecrets(data.secrets ?? []) 21 + } catch (err) { 22 + console.error('Failed to fetch secrets:', err) 23 + } finally { 24 + setSecretsLoading(false) 25 + } 26 + }, []) 27 + 28 + const createSecret = useCallback( 29 + async (name: string): Promise<{ token: string }> => { 30 + setIsCreatingSecret(true) 31 + try { 32 + const res = await fetch('/api/secret', { 33 + method: 'POST', 34 + headers: { 'Content-Type': 'application/json' }, 35 + credentials: 'include', 36 + body: JSON.stringify({ name }), 37 + }) 38 + const data = await res.json() 39 + if (!res.ok) throw new Error(data.error || 'Failed to create secret') 40 + await fetchSecrets() 41 + return { token: data.token } 42 + } finally { 43 + setIsCreatingSecret(false) 44 + } 45 + }, 46 + [fetchSecrets], 47 + ) 48 + 49 + const deleteSecret = useCallback(async (name: string): Promise<void> => { 50 + const res = await fetch(`/api/secret/${encodeURIComponent(name)}`, { 51 + method: 'DELETE', 52 + credentials: 'include', 53 + }) 54 + if (!res.ok) { 55 + const data = await res.json().catch(() => ({})) 56 + throw new Error(data.error || 'Failed to delete secret') 57 + } 58 + setSecrets((prev) => prev.filter((s) => s.name !== name)) 59 + }, []) 60 + 61 + const rotateSecret = useCallback(async (name: string): Promise<{ token: string }> => { 62 + const res = await fetch(`/api/secret/${encodeURIComponent(name)}/rotate`, { 63 + method: 'POST', 64 + credentials: 'include', 65 + }) 66 + const data = await res.json() 67 + if (!res.ok) throw new Error(data.error || 'Failed to rotate secret') 68 + setSecrets((prev) => 69 + prev.map((s) => (s.name === name ? { ...s, lastRotatedAt: data.rotatedAt } : s)), 70 + ) 71 + return { token: data.token } 72 + }, []) 73 + 74 + return { 75 + secrets, 76 + secretsLoading, 77 + isCreatingSecret, 78 + fetchSecrets, 79 + createSecret, 80 + deleteSecret, 81 + rotateSecret, 82 + } 83 + }
+1
apps/main-app/public/editor/hooks/useWebhookData.ts
··· 77 77 backlinks: boolean 78 78 events: string[] 79 79 secret: string 80 + secretId?: string 80 81 enabled: boolean 81 82 }) => { 82 83 setIsCreating(true)
+254 -8
apps/main-app/public/editor/tabs/WebhooksTab.tsx
··· 10 10 ChevronDown, 11 11 ChevronsUpDown, 12 12 ChevronUp, 13 + Copy, 13 14 ExternalLink, 15 + KeyRound, 14 16 Loader2, 15 17 Plus, 16 18 RefreshCw, 19 + RotateCcw, 17 20 Trash2, 18 21 Webhook, 19 22 X, 20 23 } from 'lucide-react' 21 24 import { type ChangeEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' 25 + import type { SecretMeta } from '../hooks/useSecretData' 22 26 import type { WebhookEventLog, WebhookRecord } from '../hooks/useWebhookData' 23 27 24 28 const APPS = [ ··· 39 43 eventLogsLoading: boolean 40 44 isCreating: boolean 41 45 userDid?: string 46 + secrets: SecretMeta[] 47 + secretsLoading: boolean 48 + isCreatingSecret: boolean 42 49 onCreateWebhook: (data: { 43 50 scopeAturi: string 44 51 url: string 45 52 backlinks: boolean 46 53 events: string[] 47 54 secret: string 55 + secretId?: string 48 56 enabled: boolean 49 57 }) => Promise<any> 50 58 onDeleteWebhook: (rkey: string) => Promise<void> 51 59 onRefreshEvents: () => Promise<void> 60 + onCreateSecret: (name: string) => Promise<{ token: string }> 61 + onDeleteSecret: (name: string) => Promise<void> 62 + onRotateSecret: (name: string) => Promise<{ token: string }> 52 63 } 53 64 54 65 function buildScope( 55 - userDid: string, 66 + did: string, 56 67 selectedApp: AppId | null, 57 68 scopePath: string, 58 69 otherMode: OtherMode, 59 70 otherCollection: string, 60 71 otherRkey: string, 61 72 ): string { 62 - if (!userDid) return '' 73 + if (!did) return '' 63 74 if (!selectedApp) return '' 64 75 if (selectedApp === 'other') { 65 - if (otherMode === 'all') return `at://${userDid}` 66 - if (otherMode === 'collection') return otherCollection ? `at://${userDid}/${otherCollection}` : '' 67 - return otherCollection && otherRkey ? `at://${userDid}/${otherCollection}/${otherRkey}` : '' 76 + if (otherMode === 'all') return `at://${did}` 77 + if (otherMode === 'collection') return otherCollection ? `at://${did}/${otherCollection}` : '' 78 + return otherCollection && otherRkey ? `at://${did}/${otherCollection}/${otherRkey}` : '' 68 79 } 69 - return scopePath ? `at://${userDid}/${scopePath}` : `at://${userDid}` 80 + return scopePath ? `at://${did}/${scopePath}` : `at://${did}` 70 81 } 71 82 72 83 function formatTimeAgo(dateStr: string): string { ··· 87 98 eventLogsLoading, 88 99 isCreating, 89 100 userDid = '', 101 + secrets, 102 + secretsLoading, 103 + isCreatingSecret, 90 104 onCreateWebhook, 91 105 onDeleteWebhook, 92 106 onRefreshEvents, 107 + onCreateSecret, 108 + onDeleteSecret, 109 + onRotateSecret, 93 110 }: WebhooksTabProps) { 94 111 const [url, setUrl] = useState('') 95 112 const [selectedApp, setSelectedApp] = useState<AppId | null>(null) ··· 97 114 const [otherMode, setOtherMode] = useState<OtherMode>('all') 98 115 const [otherCollection, setOtherCollection] = useState('') 99 116 const [otherRkey, setOtherRkey] = useState('') 117 + const [customDid, setCustomDid] = useState('') 118 + const [useCustomDid, setUseCustomDid] = useState(false) 119 + const [selectedSecretId, setSelectedSecretId] = useState('') 100 120 const [backlinks, setBacklinks] = useState(false) 101 121 const [eventCreate, setEventCreate] = useState(true) 102 122 const [eventUpdate, setEventUpdate] = useState(true) ··· 109 129 const containerRef = useRef<HTMLDivElement>(null) 110 130 const itemRefs = useRef<(HTMLDivElement | null)[]>([]) 111 131 132 + // Secrets panel state 133 + const [showSecretsPanel, setShowSecretsPanel] = useState(false) 134 + const [newSecretName, setNewSecretName] = useState('') 135 + const [secretError, setSecretError] = useState<string | null>(null) 136 + const [revealedToken, setRevealedToken] = useState<{ name: string; token: string } | null>(null) 137 + const [deletingSecret, setDeletingSecret] = useState<string | null>(null) 138 + const [rotatingSecret, setRotatingSecret] = useState<string | null>(null) 139 + const [copiedToken, setCopiedToken] = useState(false) 140 + 112 141 useEffect(() => { 113 142 const id = setInterval(onRefreshEvents, 60_000) 114 143 return () => clearInterval(id) ··· 193 222 setError(null) 194 223 } 195 224 196 - const scopeAturi = buildScope(userDid, selectedApp, scopePath, otherMode, otherCollection, otherRkey) 225 + const effectiveDid = useCustomDid && customDid.trim() ? customDid.trim() : userDid 226 + const scopeAturi = buildScope(effectiveDid, selectedApp, scopePath, otherMode, otherCollection, otherRkey) 197 227 198 228 const handleCreate = async (e: React.FormEvent) => { 199 229 e.preventDefault() ··· 221 251 backlinks, 222 252 events: events.length === 3 ? [] : events, 223 253 secret: '', 254 + secretId: selectedSecretId || undefined, 224 255 enabled: true, 225 256 }) 226 257 setSuccess('Webhook created successfully') ··· 230 261 setOtherMode('all') 231 262 setOtherCollection('') 232 263 setOtherRkey('') 264 + setCustomDid('') 265 + setUseCustomDid(false) 266 + setSelectedSecretId('') 233 267 setBacklinks(false) 234 268 setEventCreate(true) 235 269 setEventUpdate(true) ··· 442 476 </Label> 443 477 <div className="flex items-center gap-0 border border-border rounded-sm focus-within:border-accent transition-colors"> 444 478 <span className="px-2.5 py-1.5 text-xs text-muted-foreground bg-muted/40 border-r border-border whitespace-nowrap select-none"> 445 - at://{userDid ? `${userDid.slice(0, 12)}...` : 'did'}/ 479 + at://{effectiveDid ? `${effectiveDid.slice(0, 16)}...` : 'did'}/ 446 480 </span> 447 481 <input 448 482 id="wh-path" ··· 522 556 </p> 523 557 )} 524 558 559 + {/* Custom DID override */} 560 + <div className="space-y-1.5"> 561 + <div className="flex items-center gap-2"> 562 + <Checkbox 563 + id="wh-custom-did" 564 + checked={useCustomDid} 565 + onCheckedChange={(v: boolean | 'indeterminate') => setUseCustomDid(!!v)} 566 + /> 567 + <Label htmlFor="wh-custom-did" className="cursor-pointer text-xs"> 568 + Custom scope DID (default: your DID) 569 + </Label> 570 + </div> 571 + {useCustomDid && ( 572 + <Input 573 + value={customDid} 574 + onChange={(e: ChangeEvent<HTMLInputElement>) => setCustomDid(e.target.value)} 575 + placeholder="did:plc:..." 576 + className="h-8 text-xs font-mono" 577 + /> 578 + )} 579 + </div> 580 + 525 581 {/* Options row */} 526 582 {selectedApp && ( 527 583 <div className="flex flex-col sm:flex-row sm:items-start gap-4 pt-1"> ··· 561 617 </div> 562 618 </div> 563 619 )} 620 + 621 + {/* Signing secret */} 622 + <div className="space-y-1.5"> 623 + <Label className="text-xs text-muted-foreground">Signing Secret (optional)</Label> 624 + <select 625 + value={selectedSecretId} 626 + onChange={(e) => setSelectedSecretId(e.target.value)} 627 + className="h-8 w-full text-xs font-mono bg-background border border-border rounded-sm px-2.5 outline-none focus:border-accent transition-colors" 628 + > 629 + <option value="">— none —</option> 630 + {secrets.map((s) => ( 631 + <option key={s.name} value={s.name}> 632 + {s.name} 633 + </option> 634 + ))} 635 + </select> 636 + {secrets.length === 0 && ( 637 + <p className="text-[10px] text-muted-foreground"> 638 + No secrets yet — create one in the Secrets section below. 639 + </p> 640 + )} 641 + </div> 564 642 565 643 {error && ( 566 644 <div className="p-2.5 bg-destructive/10 border border-destructive/20 rounded-sm"> ··· 688 766 </div> 689 767 ) 690 768 })} 769 + </div> 770 + )} 771 + </div> 772 + 773 + {/* Signing Secrets */} 774 + <div className="p-4 border-t border-border/30 space-y-3"> 775 + <div className="flex items-center justify-between"> 776 + <div className="flex items-center gap-2"> 777 + <KeyRound className="w-3.5 h-3.5 text-muted-foreground" /> 778 + <p className="text-xs uppercase tracking-wider text-muted-foreground">Signing Secrets</p> 779 + </div> 780 + <Button 781 + variant="outline" 782 + size="sm" 783 + className="h-7 text-xs px-3" 784 + onClick={() => { 785 + setShowSecretsPanel((v) => !v) 786 + setSecretError(null) 787 + setRevealedToken(null) 788 + }} 789 + > 790 + {showSecretsPanel ? ( 791 + <> 792 + <ChevronUp className="w-3 h-3 mr-1.5" /> 793 + Hide 794 + </> 795 + ) : ( 796 + <> 797 + <ChevronDown className="w-3 h-3 mr-1.5" /> 798 + Manage 799 + </> 800 + )} 801 + </Button> 802 + </div> 803 + 804 + {showSecretsPanel && ( 805 + <div className="space-y-3"> 806 + {/* Create new secret */} 807 + <div className="flex gap-2"> 808 + <Input 809 + value={newSecretName} 810 + onChange={(e: ChangeEvent<HTMLInputElement>) => setNewSecretName(e.target.value)} 811 + placeholder="secret name (e.g. my-server)" 812 + className="h-8 text-xs font-mono flex-1" 813 + /> 814 + <Button 815 + size="sm" 816 + className="h-8 text-xs px-3 flex-shrink-0" 817 + disabled={isCreatingSecret || !newSecretName.trim()} 818 + onClick={async () => { 819 + setSecretError(null) 820 + setRevealedToken(null) 821 + try { 822 + const { token } = await onCreateSecret(newSecretName.trim()) 823 + setRevealedToken({ name: newSecretName.trim(), token }) 824 + setNewSecretName('') 825 + } catch (err) { 826 + setSecretError(err instanceof Error ? err.message : 'Failed to create secret') 827 + } 828 + }} 829 + > 830 + {isCreatingSecret ? <Loader2 className="w-3 h-3 animate-spin" /> : <Plus className="w-3 h-3 mr-1" />} 831 + Create 832 + </Button> 833 + </div> 834 + 835 + {secretError && ( 836 + <p className="text-xs text-destructive">{secretError}</p> 837 + )} 838 + 839 + {/* Revealed token — show once */} 840 + {revealedToken && ( 841 + <div className="p-3 bg-green-500/10 border border-green-500/20 rounded-sm space-y-2"> 842 + <p className="text-[10px] uppercase tracking-wider text-green-500"> 843 + New token for <strong>{revealedToken.name}</strong> — copy it now, it won't be shown again 844 + </p> 845 + <div className="flex items-center gap-2"> 846 + <code className="text-xs font-mono break-all flex-1 text-green-400">{revealedToken.token}</code> 847 + <button 848 + type="button" 849 + onClick={() => { 850 + navigator.clipboard.writeText(revealedToken.token) 851 + setCopiedToken(true) 852 + setTimeout(() => setCopiedToken(false), 2000) 853 + }} 854 + className="flex-shrink-0 text-green-400 hover:text-green-300" 855 + > 856 + {copiedToken ? <CheckCircle2 className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />} 857 + </button> 858 + </div> 859 + </div> 860 + )} 861 + 862 + {/* Secret list */} 863 + {secretsLoading ? ( 864 + <SkeletonShimmer className="h-8 w-full" /> 865 + ) : secrets.length === 0 ? ( 866 + <p className="text-xs text-muted-foreground py-2 text-center">No secrets yet</p> 867 + ) : ( 868 + <div className="space-y-1"> 869 + {secrets.map((s) => ( 870 + <div 871 + key={s.name} 872 + className="flex items-center justify-between p-2.5 border border-border/30 rounded-sm" 873 + > 874 + <div className="space-y-0.5 min-w-0 flex-1"> 875 + <p className="text-xs font-mono font-medium">{s.name}</p> 876 + <p className="text-[10px] text-muted-foreground"> 877 + Created {new Date(s.createdAt).toLocaleDateString()} 878 + {s.lastRotatedAt && ` · rotated ${new Date(s.lastRotatedAt).toLocaleDateString()}`} 879 + </p> 880 + </div> 881 + <div className="flex items-center gap-1 flex-shrink-0"> 882 + <Button 883 + variant="ghost" 884 + size="sm" 885 + className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground" 886 + disabled={rotatingSecret === s.name} 887 + title="Rotate" 888 + onClick={async () => { 889 + setSecretError(null) 890 + setRevealedToken(null) 891 + setRotatingSecret(s.name) 892 + try { 893 + const { token } = await onRotateSecret(s.name) 894 + setRevealedToken({ name: s.name, token }) 895 + } catch (err) { 896 + setSecretError(err instanceof Error ? err.message : 'Failed to rotate secret') 897 + } finally { 898 + setRotatingSecret(null) 899 + } 900 + }} 901 + > 902 + {rotatingSecret === s.name ? ( 903 + <Loader2 className="w-3 h-3 animate-spin" /> 904 + ) : ( 905 + <RotateCcw className="w-3 h-3" /> 906 + )} 907 + </Button> 908 + <Button 909 + variant="ghost" 910 + size="sm" 911 + className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive" 912 + disabled={deletingSecret === s.name} 913 + title="Delete" 914 + onClick={async () => { 915 + setSecretError(null) 916 + setDeletingSecret(s.name) 917 + try { 918 + await onDeleteSecret(s.name) 919 + } catch (err) { 920 + setSecretError(err instanceof Error ? err.message : 'Failed to delete secret') 921 + } finally { 922 + setDeletingSecret(null) 923 + } 924 + }} 925 + > 926 + {deletingSecret === s.name ? ( 927 + <Loader2 className="w-3 h-3 animate-spin" /> 928 + ) : ( 929 + <Trash2 className="w-3 h-3" /> 930 + )} 931 + </Button> 932 + </div> 933 + </div> 934 + ))} 935 + </div> 936 + )} 691 937 </div> 692 938 )} 693 939 </div>
+2
apps/main-app/src/index.ts
··· 27 27 import { domainRoutes } from './routes/domain' 28 28 import { siteRoutes } from './routes/site' 29 29 import { userRoutes } from './routes/user' 30 + import { secretRoutes } from './routes/secret' 30 31 import { webhookRoutes } from './routes/webhook' 31 32 import { wispRoutes } from './routes/wisp' 32 33 import { xrpcRoutes } from './routes/xrpc' ··· 238 239 .use(userRoutes(client, cookieSecret)) 239 240 .use(siteRoutes(client, cookieSecret)) 240 241 .use(webhookRoutes(client, cookieSecret)) 242 + .use(secretRoutes(client, cookieSecret)) 241 243 .use(adminRoutes(cookieSecret)) 242 244 .use( 243 245 await staticPlugin({
+76
apps/main-app/src/lib/db.ts
··· 120 120 ) 121 121 ` 122 122 123 + // Webhook signing secrets managed server-side; token never stored after creation 124 + await db` 125 + CREATE TABLE IF NOT EXISTS webhook_secrets ( 126 + did TEXT NOT NULL, 127 + name TEXT NOT NULL, 128 + token TEXT NOT NULL, 129 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 130 + last_rotated_at TIMESTAMPTZ, 131 + PRIMARY KEY (did, name) 132 + ) 133 + ` 134 + 123 135 await runDatabaseMigrations(db) 124 136 125 137 export const getDomainByDid = async (did: string): Promise<string | null> => { ··· 476 488 export const getAllSupporters = async () => { 477 489 const rows = await db`SELECT * FROM supporter ORDER BY created_at ASC` 478 490 return rows 491 + } 492 + 493 + function generateSecretToken(): string { 494 + const bytes = crypto.getRandomValues(new Uint8Array(24)) 495 + return `wsk_${Buffer.from(bytes).toString('base64url')}` 496 + } 497 + 498 + export const createWebhookSecret = async (did: string, name: string): Promise<{ token: string; createdAt: string }> => { 499 + const token = generateSecretToken() 500 + try { 501 + const rows = await db` 502 + INSERT INTO webhook_secrets (did, name, token, created_at) 503 + VALUES (${did}, ${name}, ${token}, NOW()) 504 + RETURNING created_at 505 + ` 506 + return { token, createdAt: new Date(rows[0].created_at).toISOString() } 507 + } catch (_err) { 508 + throw new Error('already_exists') 509 + } 510 + } 511 + 512 + export const listWebhookSecrets = async ( 513 + did: string, 514 + ): Promise<Array<{ name: string; createdAt: string; lastRotatedAt?: string }>> => { 515 + const rows = await db<Array<{ name: string; created_at: string; last_rotated_at: string | null }>>` 516 + SELECT name, created_at, last_rotated_at 517 + FROM webhook_secrets 518 + WHERE did = ${did} 519 + ORDER BY created_at ASC 520 + ` 521 + return rows.map((r) => ({ 522 + name: r.name, 523 + createdAt: new Date(r.created_at).toISOString(), 524 + lastRotatedAt: r.last_rotated_at ? new Date(r.last_rotated_at).toISOString() : undefined, 525 + })) 526 + } 527 + 528 + export const deleteWebhookSecret = async (did: string, name: string): Promise<boolean> => { 529 + const result = await db` 530 + DELETE FROM webhook_secrets WHERE did = ${did} AND name = ${name} 531 + ` 532 + return (result as any).count > 0 533 + } 534 + 535 + export const rotateWebhookSecret = async ( 536 + did: string, 537 + name: string, 538 + ): Promise<{ token: string; rotatedAt: string } | null> => { 539 + const token = generateSecretToken() 540 + const rows = await db` 541 + UPDATE webhook_secrets 542 + SET token = ${token}, last_rotated_at = NOW() 543 + WHERE did = ${did} AND name = ${name} 544 + RETURNING last_rotated_at 545 + ` 546 + if (rows.length === 0) return null 547 + return { token, rotatedAt: new Date(rows[0].last_rotated_at).toISOString() } 548 + } 549 + 550 + export const getWebhookSecretToken = async (did: string, name: string): Promise<string | null> => { 551 + const rows = await db<Array<{ token: string }>>` 552 + SELECT token FROM webhook_secrets WHERE did = ${did} AND name = ${name} LIMIT 1 553 + ` 554 + return rows[0]?.token ?? null 479 555 } 480 556 481 557 /**
+97
apps/main-app/src/routes/secret.ts
··· 1 + import type { NodeOAuthClient } from '@atproto/oauth-client-node' 2 + import { createLogger } from '@wispplace/observability' 3 + import { Elysia, t } from 'elysia' 4 + import { createWebhookSecret, deleteWebhookSecret, listWebhookSecrets, rotateWebhookSecret } from '../lib/db' 5 + import { requireAuth } from '../lib/wisp-auth' 6 + 7 + const logger = createLogger('main-app') 8 + 9 + export const secretRoutes = (client: NodeOAuthClient, cookieSecret: string) => 10 + new Elysia({ 11 + prefix: '/api/secret', 12 + cookie: { secrets: cookieSecret, sign: ['did'] }, 13 + }) 14 + .derive(async ({ cookie }) => { 15 + const auth = await requireAuth(client, cookie) 16 + return { auth } 17 + }) 18 + /** 19 + * GET /api/secret 20 + * Lists signing secrets (names + metadata only, never tokens) for the authenticated user. 21 + */ 22 + .get('/', async ({ auth, set }) => { 23 + try { 24 + const secrets = await listWebhookSecrets(auth.did) 25 + return { success: true, secrets } 26 + } catch (err) { 27 + logger.error('[Secret] List error', err) 28 + set.status = 500 29 + return { success: false, error: 'Failed to list secrets' } 30 + } 31 + }) 32 + /** 33 + * POST /api/secret 34 + * Creates a new signing secret. Returns the token once — it is not stored in plaintext. 35 + */ 36 + .post( 37 + '/', 38 + async ({ body, auth, set }) => { 39 + try { 40 + const { token, createdAt } = await createWebhookSecret(auth.did, body.name) 41 + logger.info(`[Secret] Created secret "${body.name}" for ${auth.did}`) 42 + return { success: true, name: body.name, token, createdAt } 43 + } catch (err) { 44 + const msg = err instanceof Error ? err.message : '' 45 + if (msg === 'already_exists') { 46 + set.status = 409 47 + return { success: false, error: 'A secret with that name already exists' } 48 + } 49 + logger.error('[Secret] Create error', err) 50 + set.status = 500 51 + return { success: false, error: 'Failed to create secret' } 52 + } 53 + }, 54 + { 55 + body: t.Object({ 56 + name: t.String({ minLength: 1 }), 57 + }), 58 + }, 59 + ) 60 + /** 61 + * DELETE /api/secret/:name 62 + * Deletes a signing secret by name. 63 + */ 64 + .delete('/:name', async ({ params, auth, set }) => { 65 + try { 66 + const deleted = await deleteWebhookSecret(auth.did, params.name) 67 + if (!deleted) { 68 + set.status = 404 69 + return { success: false, error: 'Secret not found' } 70 + } 71 + logger.info(`[Secret] Deleted secret "${params.name}" for ${auth.did}`) 72 + return { success: true } 73 + } catch (err) { 74 + logger.error('[Secret] Delete error', err) 75 + set.status = 500 76 + return { success: false, error: 'Failed to delete secret' } 77 + } 78 + }) 79 + /** 80 + * POST /api/secret/:name/rotate 81 + * Rotates a signing secret, returning the new token once. 82 + */ 83 + .post('/:name/rotate', async ({ params, auth, set }) => { 84 + try { 85 + const result = await rotateWebhookSecret(auth.did, params.name) 86 + if (!result) { 87 + set.status = 404 88 + return { success: false, error: 'Secret not found' } 89 + } 90 + logger.info(`[Secret] Rotated secret "${params.name}" for ${auth.did}`) 91 + return { success: true, name: params.name, token: result.token, rotatedAt: result.rotatedAt } 92 + } catch (err) { 93 + logger.error('[Secret] Rotate error', err) 94 + set.status = 500 95 + return { success: false, error: 'Failed to rotate secret' } 96 + } 97 + })
+2 -1
apps/main-app/src/routes/webhook.ts
··· 37 37 }, 38 38 url: body.url, 39 39 ...(body.events && body.events.length > 0 ? { events: body.events } : {}), 40 - ...(body.secret ? { secret: body.secret } : {}), 40 + ...(body.secretId ? { secretId: body.secretId } : body.secret ? { secret: body.secret } : {}), 41 41 enabled: body.enabled ?? true, 42 42 createdAt: new Date().toISOString(), 43 43 } ··· 65 65 backlinks: t.Optional(t.Boolean()), 66 66 events: t.Optional(t.Array(t.Union([t.Literal('create'), t.Literal('update'), t.Literal('delete')]))), 67 67 secret: t.Optional(t.String()), 68 + secretId: t.Optional(t.String()), 68 69 enabled: t.Optional(t.Boolean()), 69 70 }), 70 71 },
+88
apps/main-app/src/routes/xrpc.ts
··· 10 10 PlaceWispV2DomainDelete, 11 11 PlaceWispV2DomainGetList, 12 12 PlaceWispV2DomainGetStatus, 13 + PlaceWispV2SecretCreate, 14 + PlaceWispV2SecretDelete, 15 + PlaceWispV2SecretList, 16 + PlaceWispV2SecretRotate, 13 17 PlaceWispV2SiteDelete, 14 18 PlaceWispV2SiteGetDomains, 15 19 PlaceWispV2SiteGetList, ··· 20 24 import { 21 25 claimCustomDomain, 22 26 claimDomain, 27 + createWebhookSecret, 23 28 deleteCustomDomain, 29 + deleteWebhookSecret, 24 30 deleteWispDomain, 25 31 getAllWispDomains, 26 32 getCustomDomainInfo, ··· 28 34 getDomainsBySite, 29 35 getSitesByDid, 30 36 isDomainRegistered, 37 + listWebhookSecrets, 38 + rotateWebhookSecret, 31 39 updateCustomDomainRkey, 32 40 updateWispDomainSite, 33 41 } from '../lib/db' ··· 80 88 claim: 'place.wisp.v2.domain.claim', 81 89 delete: 'place.wisp.v2.domain.delete', 82 90 deleteSite: 'place.wisp.v2.site.delete', 91 + secretCreate: 'place.wisp.v2.secret.create', 92 + secretList: 'place.wisp.v2.secret.list', 93 + secretDelete: 'place.wisp.v2.secret.delete', 94 + secretRotate: 'place.wisp.v2.secret.rotate', 83 95 } as const 84 96 85 97 const toIsoFromEpoch = (epoch: unknown): string | undefined => { ··· 176 188 throw new XRPCError({ 177 189 status: 400, 178 190 error: 'InvalidRequest', 191 + description, 192 + }) 193 + } 194 + 195 + const alreadyExists = (description: string): never => { 196 + throw new XRPCError({ 197 + status: 409, 198 + error: 'AlreadyExists', 179 199 description, 180 200 }) 181 201 } ··· 531 551 XRPC_NSIDS.claim, 532 552 XRPC_NSIDS.delete, 533 553 XRPC_NSIDS.deleteSite, 554 + XRPC_NSIDS.secretCreate, 555 + XRPC_NSIDS.secretList, 556 + XRPC_NSIDS.secretDelete, 557 + XRPC_NSIDS.secretRotate, 534 558 ] 535 559 536 560 addProcedureWithAliases( ··· 780 804 return deleteSiteForDid(did, { 781 805 siteRkey: input.siteRkey, 782 806 }) 807 + }, 808 + }, 809 + ) 810 + 811 + addProcedureWithAliases( 812 + router, 813 + withNsid(PlaceWispV2SecretCreate.mainSchema as any, XRPC_NSIDS.secretCreate), 814 + [], 815 + { 816 + async handler({ input, request }) { 817 + const auth = requireAuthenticated(authByRequest.get(request)) 818 + const name = input.name?.trim() 819 + if (!name) invalidRequest('name is required') 820 + try { 821 + const { token, createdAt } = await createWebhookSecret(auth.did, name!) 822 + return json({ name: name!, token, createdAt }) 823 + } catch { 824 + return alreadyExists('a secret with that name already exists') 825 + } 826 + }, 827 + }, 828 + ) 829 + 830 + addQueryWithAliases( 831 + router, 832 + withNsid(PlaceWispV2SecretList.mainSchema as any, XRPC_NSIDS.secretList), 833 + [], 834 + { 835 + async handler({ request }) { 836 + const auth = requireAuthenticated(authByRequest.get(request)) 837 + const secrets = await listWebhookSecrets(auth.did) 838 + return json({ secrets }) 839 + }, 840 + }, 841 + ) 842 + 843 + addProcedureWithAliases( 844 + router, 845 + withNsid(PlaceWispV2SecretDelete.mainSchema as any, XRPC_NSIDS.secretDelete), 846 + [], 847 + { 848 + async handler({ input, request }) { 849 + const auth = requireAuthenticated(authByRequest.get(request)) 850 + const name = input.name?.trim() 851 + if (!name) invalidRequest('name is required') 852 + const deleted = await deleteWebhookSecret(auth.did, name) 853 + if (!deleted) notFound('secret not found') 854 + return json({}) 855 + }, 856 + }, 857 + ) 858 + 859 + addProcedureWithAliases( 860 + router, 861 + withNsid(PlaceWispV2SecretRotate.mainSchema as any, XRPC_NSIDS.secretRotate), 862 + [], 863 + { 864 + async handler({ input, request }) { 865 + const auth = requireAuthenticated(authByRequest.get(request)) 866 + const name = input.name?.trim() 867 + if (!name) invalidRequest('name is required') 868 + const result = await rotateWebhookSecret(auth.did, name) 869 + if (!result) notFound('secret not found') 870 + return json({ name, token: result!.token, rotatedAt: result!.rotatedAt }) 783 871 }, 784 872 }, 785 873 )
+366
apps/webhook-service/bench/e2e.ts
··· 1 + /** 2 + * End-to-end integration test against a real PDS and Jetstream. 3 + * 4 + * Flow: 5 + * 1. Spin up a local delivery server to receive webhook POSTs. 6 + * 2. Connect JetstreamClient watching the test DID. 7 + * 3. Create place.wisp.v2.wh on PDS → Jetstream delivers it → lands in local DB. 8 + * 4. Create app.bsky.feed.post → direct match fires → delivery #1. 9 + * 5. Create app.bsky.feed.like at the post → backlink fires → delivery #2. 10 + * 6. Delete all created records. Print timing. 11 + * 12 + * Env vars (set in .env or prefix the command): 13 + * TEST_PDS_HANDLE default: testacc.sharkgirl.pet 14 + * TEST_PDS_PASSWORD required 15 + * TEST_PDS_URL optional override for PDS base URL 16 + * JETSTREAM_URL optional override 17 + * TEST_DELIVERY_PORT default: 19876 18 + */ 19 + 20 + import { createHmac } from 'node:crypto' 21 + import type { Main as WhRecord } from '@wispplace/lexicons/types/place/wisp/v2/wh' 22 + import { JetstreamClient } from '../src/lib/jetstream' 23 + import { db, deleteWebhookRecord, findBacklinkWebhooks, findWebhooksForDid, getWebhookSecretToken, upsertWebhookRecord } from '../src/lib/db' 24 + import { matchWebhooks } from '../src/lib/matcher' 25 + import { deliverWebhook } from '../src/lib/delivery' 26 + 27 + // --------------------------------------------------------------------------- 28 + // Config 29 + // --------------------------------------------------------------------------- 30 + 31 + const HANDLE = process.env.TEST_PDS_HANDLE ?? 'testacc.sharkgirl.pet' 32 + const PASSWORD = process.env.TEST_PDS_PASSWORD 33 + const JETSTREAM_URL = process.env.JETSTREAM_URL ?? 'wss://jetstream2.us-east.bsky.network/subscribe' 34 + const DELIVERY_PORT = parseInt(process.env.TEST_DELIVERY_PORT ?? '19876', 10) 35 + const EVENT_TIMEOUT_MS = 30_000 36 + 37 + if (!PASSWORD) { 38 + console.error('TEST_PDS_PASSWORD is required') 39 + process.exit(1) 40 + } 41 + 42 + // --------------------------------------------------------------------------- 43 + // PDS helpers 44 + // --------------------------------------------------------------------------- 45 + 46 + async function derivePdsUrl(handle: string): Promise<string> { 47 + if (process.env.TEST_PDS_URL) return process.env.TEST_PDS_URL.replace(/\/$/, '') 48 + // For subdomain handles like testacc.sharkgirl.pet, try the parent domain 49 + const parts = handle.split('.') 50 + if (parts.length >= 2) { 51 + const candidate = `https://${parts.slice(-2).join('.')}` 52 + try { 53 + const res = await fetch(`${candidate}/xrpc/com.atproto.server.describeServer`, { 54 + signal: AbortSignal.timeout(5_000), 55 + }) 56 + if (res.ok) return candidate 57 + } catch {} 58 + } 59 + throw new Error(`Could not derive PDS URL for ${handle}. Set TEST_PDS_URL explicitly.`) 60 + } 61 + 62 + interface Session { 63 + did: string 64 + accessJwt: string 65 + } 66 + 67 + async function createSession(pdsUrl: string, identifier: string, password: string): Promise<Session> { 68 + const res = await fetch(`${pdsUrl}/xrpc/com.atproto.server.createSession`, { 69 + method: 'POST', 70 + headers: { 'Content-Type': 'application/json' }, 71 + body: JSON.stringify({ identifier, password }), 72 + signal: AbortSignal.timeout(10_000), 73 + }) 74 + if (!res.ok) throw new Error(`createSession failed: ${res.status} ${await res.text()}`) 75 + return res.json() as Promise<Session> 76 + } 77 + 78 + async function createRecord( 79 + pdsUrl: string, 80 + jwt: string, 81 + repo: string, 82 + collection: string, 83 + record: Record<string, unknown>, 84 + rkey?: string, 85 + ): Promise<{ uri: string; cid: string }> { 86 + const body: Record<string, unknown> = { repo, collection, record } 87 + if (rkey) body.rkey = rkey 88 + const res = await fetch(`${pdsUrl}/xrpc/com.atproto.repo.createRecord`, { 89 + method: 'POST', 90 + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${jwt}` }, 91 + body: JSON.stringify(body), 92 + signal: AbortSignal.timeout(10_000), 93 + }) 94 + if (!res.ok) throw new Error(`createRecord (${collection}) failed: ${res.status} ${await res.text()}`) 95 + return res.json() as Promise<{ uri: string; cid: string }> 96 + } 97 + 98 + async function deleteRecord(pdsUrl: string, jwt: string, repo: string, collection: string, rkey: string): Promise<void> { 99 + const res = await fetch(`${pdsUrl}/xrpc/com.atproto.repo.deleteRecord`, { 100 + method: 'POST', 101 + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${jwt}` }, 102 + body: JSON.stringify({ repo, collection, rkey }), 103 + signal: AbortSignal.timeout(10_000), 104 + }) 105 + if (!res.ok) console.warn(` deleteRecord ${collection}/${rkey}: ${res.status}`) 106 + } 107 + 108 + // --------------------------------------------------------------------------- 109 + // Local delivery server 110 + // --------------------------------------------------------------------------- 111 + 112 + const deliveries: Array<{ ts: number; body: unknown; signature?: string; rawBody: string }> = [] 113 + let resolveDelivery: (() => void) | null = null 114 + let expectedDeliveries = 0 115 + 116 + const deliveryServer = Bun.serve({ 117 + port: DELIVERY_PORT, 118 + routes: { 119 + '/': { 120 + POST: async (req) => { 121 + const rawBody = await req.text() 122 + const signature = req.headers.get('x-webhook-signature') ?? undefined 123 + const body = JSON.parse(rawBody) 124 + deliveries.push({ ts: Date.now(), body, signature, rawBody }) 125 + console.log(` [delivery #${deliveries.length}] sig=${signature ?? 'none'} ${JSON.stringify(body).slice(0, 80)}`) 126 + if (deliveries.length >= expectedDeliveries) resolveDelivery?.() 127 + return new Response('ok') 128 + }, 129 + }, 130 + }, 131 + fetch: () => new Response('Not Found', { status: 404 }), 132 + }) 133 + 134 + function waitForDeliveries(n: number): Promise<void> { 135 + expectedDeliveries = n 136 + if (deliveries.length >= n) return Promise.resolve() 137 + return new Promise<void>((resolve, reject) => { 138 + resolveDelivery = resolve 139 + setTimeout(() => reject(new Error(`Timed out waiting for ${n} deliveries (got ${deliveries.length})`)), EVENT_TIMEOUT_MS) 140 + }) 141 + } 142 + 143 + // --------------------------------------------------------------------------- 144 + // Jetstream event handler 145 + // --------------------------------------------------------------------------- 146 + 147 + const testDid: { value: string } = { value: '' } 148 + let resolveWhRegistered: (() => void) | null = null 149 + const whRegistered = new Promise<void>((resolve) => { resolveWhRegistered = resolve }) 150 + 151 + async function handleEvent(event: { kind: string; did: string; commit?: { operation: string; collection: string; rkey: string; record?: unknown; cid?: string } }) { 152 + if (event.kind !== 'commit' || !event.commit) return 153 + const { did } = event 154 + const { operation: op, collection, rkey, record, cid } = event.commit 155 + if (op !== 'create' && op !== 'update' && op !== 'delete') return 156 + 157 + if (collection === 'place.wisp.v2.wh') { 158 + if (op === 'delete') { 159 + await deleteWebhookRecord(did, rkey) 160 + } else if (record) { 161 + const wh = record as WhRecord 162 + if (wh.scope?.aturi && wh.url) { 163 + await upsertWebhookRecord(did, rkey, wh) 164 + console.log(` [wh registered] ${did}/${rkey} scope=${wh.scope.aturi} backlinks=${wh.scope.backlinks ?? false}`) 165 + resolveWhRegistered?.() 166 + } 167 + } 168 + return 169 + } 170 + 171 + // Only process events from the test DID to avoid noise 172 + if (did !== testDid.value) return 173 + 174 + const directCandidates = await findWebhooksForDid(did) 175 + const backlinkCandidates = await findBacklinkWebhooks() 176 + const seen = new Set(directCandidates.map((e) => `${e.ownerDid}/${e.rkey}`)) 177 + const candidates = [...directCandidates] 178 + for (const entry of backlinkCandidates) { 179 + const k = `${entry.ownerDid}/${entry.rkey}` 180 + if (!seen.has(k)) { seen.add(k); candidates.push(entry) } 181 + } 182 + 183 + if (candidates.length === 0) return 184 + 185 + const matched = matchWebhooks(candidates, did, collection, rkey, op as any, record ?? null) 186 + for (const entry of matched) { 187 + await deliverWebhook(entry, did, collection, rkey, op as any, cid, record) 188 + } 189 + } 190 + 191 + // --------------------------------------------------------------------------- 192 + // DB secret helpers (mirrors main-app logic without importing it) 193 + // --------------------------------------------------------------------------- 194 + 195 + async function insertTestSecret(did: string, name: string): Promise<string> { 196 + const bytes = crypto.getRandomValues(new Uint8Array(24)) 197 + const token = `wsk_${Buffer.from(bytes).toString('base64url')}` 198 + await db` 199 + INSERT INTO webhook_secrets (did, name, token, created_at) 200 + VALUES (${did}, ${name}, ${token}, NOW()) 201 + ON CONFLICT (did, name) DO UPDATE SET token = EXCLUDED.token, last_rotated_at = NOW() 202 + ` 203 + return token 204 + } 205 + 206 + async function deleteTestSecret(did: string, name: string): Promise<void> { 207 + await db`DELETE FROM webhook_secrets WHERE did = ${did} AND name = ${name}` 208 + } 209 + 210 + // --------------------------------------------------------------------------- 211 + // Main 212 + // --------------------------------------------------------------------------- 213 + 214 + const createdRecords: Array<{ collection: string; rkey: string }> = [] 215 + 216 + async function run() { 217 + console.log(`\n=== wisp webhook e2e ===`) 218 + console.log(`PDS handle : ${HANDLE}`) 219 + console.log(`Jetstream : ${JETSTREAM_URL}`) 220 + console.log(`Delivery : http://localhost:${DELIVERY_PORT}/`) 221 + console.log() 222 + 223 + // 1. Auth 224 + const pdsUrl = await derivePdsUrl(HANDLE) 225 + console.log(`PDS URL : ${pdsUrl}`) 226 + const session = await createSession(pdsUrl, HANDLE, PASSWORD!) 227 + testDid.value = session.did 228 + console.log(`Test DID : ${session.did}\n`) 229 + 230 + // 2. Jetstream 231 + const js = new JetstreamClient({ 232 + url: JETSTREAM_URL, 233 + wantedDids: [session.did], 234 + onEvent: handleEvent as any, 235 + onConnect: () => console.log('[jetstream] connected'), 236 + onDisconnect: () => console.log('[jetstream] disconnected'), 237 + onError: (err) => console.error('[jetstream] error:', err.message), 238 + }) 239 + js.start() 240 + // Give the WS a moment to connect before creating records 241 + await Bun.sleep(1_500) 242 + 243 + const deliveryUrl = `http://localhost:${DELIVERY_PORT}/` 244 + const scopeAturi = `at://${session.did}/app.bsky.feed.post` 245 + 246 + try { 247 + // 3. Create place.wisp.v2.wh record 248 + console.log('--- step 1: create place.wisp.v2.wh ---') 249 + const t0 = Date.now() 250 + const whRkey = `bench-wh-${Date.now()}` 251 + const { uri: whUri } = await createRecord(pdsUrl, session.accessJwt, session.did, 'place.wisp.v2.wh', { 252 + $type: 'place.wisp.v2.wh', 253 + scope: { $type: 'place.wisp.v2.wh#atUri', aturi: scopeAturi, backlinks: true }, 254 + url: deliveryUrl, 255 + events: ['create'], 256 + enabled: true, 257 + createdAt: new Date().toISOString(), 258 + }, whRkey) 259 + createdRecords.push({ collection: 'place.wisp.v2.wh', rkey: whRkey }) 260 + console.log(` created ${whUri}`) 261 + 262 + await Promise.race([ 263 + whRegistered, 264 + new Promise((_, reject) => setTimeout(() => reject(new Error('Timed out waiting for wh to be registered in DB')), EVENT_TIMEOUT_MS)), 265 + ]) 266 + console.log(` registered in DB in ${Date.now() - t0}ms\n`) 267 + 268 + // 4. Create app.bsky.feed.post → direct match 269 + console.log('--- step 2: create post (expect delivery #1 — direct match) ---') 270 + const t1 = Date.now() 271 + const { uri: postUri, cid: postCid } = await createRecord(pdsUrl, session.accessJwt, session.did, 'app.bsky.feed.post', { 272 + $type: 'app.bsky.feed.post', 273 + text: 'wisp webhook e2e test post', 274 + createdAt: new Date().toISOString(), 275 + }) 276 + const postRkey = postUri.split('/').at(-1)! 277 + createdRecords.push({ collection: 'app.bsky.feed.post', rkey: postRkey }) 278 + console.log(` created ${postUri}`) 279 + 280 + await waitForDeliveries(1) 281 + console.log(` delivery #1 received in ${Date.now() - t1}ms\n`) 282 + 283 + // 5. Create app.bsky.feed.like referencing the post → backlink match 284 + console.log('--- step 3: like post (expect delivery #2 — backlink match) ---') 285 + const t2 = Date.now() 286 + const { uri: likeUri } = await createRecord(pdsUrl, session.accessJwt, session.did, 'app.bsky.feed.like', { 287 + $type: 'app.bsky.feed.like', 288 + subject: { uri: postUri, cid: postCid }, 289 + createdAt: new Date().toISOString(), 290 + }) 291 + const likeRkey = likeUri.split('/').at(-1)! 292 + createdRecords.push({ collection: 'app.bsky.feed.like', rkey: likeRkey }) 293 + console.log(` created ${likeUri}`) 294 + 295 + await waitForDeliveries(2) 296 + console.log(` delivery #2 received in ${Date.now() - t2}ms\n`) 297 + 298 + // 6. SecretId signing test 299 + console.log('--- step 4: secretId signing ---') 300 + const secretName = `e2e-test-${Date.now()}` 301 + const secretToken = await insertTestSecret(session.did, secretName) 302 + console.log(` inserted secret "${secretName}"`) 303 + 304 + // Verify getWebhookSecretToken can look it up 305 + const lookedUp = await getWebhookSecretToken(session.did, secretName) 306 + if (lookedUp !== secretToken) throw new Error(`getWebhookSecretToken mismatch: got ${lookedUp}`) 307 + console.log(` getWebhookSecretToken ✓`) 308 + 309 + // Create a webhook with secretId, deliver manually, check signature 310 + const signedWhRkey = `bench-wh-signed-${Date.now()}` 311 + const signedWh: WhRecord = { 312 + $type: 'place.wisp.v2.wh', 313 + scope: { $type: 'place.wisp.v2.wh#atUri', aturi: `at://${session.did}/app.bsky.feed.post` }, 314 + url: deliveryUrl, 315 + secretId: secretName, 316 + enabled: true, 317 + createdAt: new Date().toISOString(), 318 + } 319 + await upsertWebhookRecord(session.did, signedWhRkey, signedWh) 320 + 321 + // Deliver a fake event 322 + const beforeCount = deliveries.length 323 + await deliverWebhook( 324 + { ownerDid: session.did, rkey: signedWhRkey, record: signedWh }, 325 + session.did, 'app.bsky.feed.post', 'test-rkey', 'create', undefined, { text: 'signed test' }, 326 + ) 327 + 328 + // Wait for it 329 + await new Promise<void>((resolve, reject) => { 330 + const check = () => { if (deliveries.length > beforeCount) resolve() } 331 + check() 332 + const iv = setInterval(check, 50) 333 + setTimeout(() => { clearInterval(iv); reject(new Error('Timed out waiting for signed delivery')) }, EVENT_TIMEOUT_MS) 334 + }) 335 + 336 + const signedDelivery = deliveries.at(-1)! 337 + if (!signedDelivery.signature) throw new Error('Expected X-Webhook-Signature header but got none') 338 + 339 + const expected = `sha256=${createHmac('sha256', secretToken).update(signedDelivery.rawBody).digest('hex')}` 340 + if (signedDelivery.signature !== expected) { 341 + throw new Error(`Signature mismatch:\n got: ${signedDelivery.signature}\n expected: ${expected}`) 342 + } 343 + console.log(` X-Webhook-Signature ✓ ${signedDelivery.signature.slice(0, 32)}...`) 344 + 345 + // Cleanup secret + wh record 346 + await deleteWebhookRecord(session.did, signedWhRkey) 347 + await deleteTestSecret(session.did, secretName) 348 + console.log(` cleaned up secret + webhook record\n`) 349 + 350 + console.log(`=== passed ✓ total time ${Date.now() - t0}ms ===`) 351 + } finally { 352 + // Cleanup 353 + console.log('\n--- cleanup ---') 354 + for (const { collection, rkey } of createdRecords.reverse()) { 355 + await deleteRecord(pdsUrl, session.accessJwt, session.did, collection, rkey) 356 + console.log(` deleted ${collection}/${rkey}`) 357 + } 358 + js.destroy() 359 + deliveryServer.stop(true) 360 + } 361 + } 362 + 363 + run().catch((err) => { 364 + console.error('\n=== failed ✗ ===\n', err.message) 365 + process.exit(1) 366 + })
+2 -1
apps/webhook-service/package.json
··· 5 5 "scripts": { 6 6 "dev": "bun --env-file=.env src/index.ts", 7 7 "start": "bun src/index.ts", 8 - "check": "tsc --noEmit" 8 + "check": "tsc --noEmit", 9 + "bench:e2e": "bun bench/e2e.ts" 9 10 }, 10 11 "dependencies": { 11 12 "@atproto/identity": "^0.4.10",
+19
apps/webhook-service/src/lib/db.ts
··· 72 72 ON webhook_event_logs (owner_did, delivered_at DESC) 73 73 ` 74 74 75 + await db` 76 + CREATE TABLE IF NOT EXISTS webhook_secrets ( 77 + did TEXT NOT NULL, 78 + name TEXT NOT NULL, 79 + token TEXT NOT NULL, 80 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 81 + last_rotated_at TIMESTAMPTZ, 82 + PRIMARY KEY (did, name) 83 + ) 84 + ` 85 + 75 86 /** 76 87 * Find all webhook records whose scope AT-URI targets the given DID. 77 88 * Matches exact DID scope (`at://did`) and collection/rkey sub-scopes (`at://did/...`). ··· 309 320 for (const r of webhookDids) dids.add(r.did) 310 321 311 322 return [...dids].sort() 323 + } 324 + 325 + /** Look up the token for a server-managed signing secret by owner DID + name. */ 326 + export async function getWebhookSecretToken(ownerDid: string, name: string): Promise<string | null> { 327 + const rows = await db<Array<{ token: string }>>` 328 + SELECT token FROM webhook_secrets WHERE did = ${ownerDid} AND name = ${name} LIMIT 1 329 + ` 330 + return rows[0]?.token ?? null 312 331 } 313 332 314 333 /** Close all database connections gracefully. */
+6 -2
apps/webhook-service/src/lib/delivery.ts
··· 2 2 import { createLogger } from '@wispplace/observability' 3 3 import { config } from '../config' 4 4 import type { WebhookEntry } from './db' 5 - import { insertEventLog } from './db' 5 + import { getWebhookSecretToken, insertEventLog } from './db' 6 6 import type { EventKind } from './matcher' 7 7 import { publishWebhookEvent } from './redis' 8 8 ··· 76 76 } 77 77 78 78 const body = JSON.stringify(payload) 79 - const signature = record.secret ? sign(record.secret, body) : undefined 79 + let signingSecret: string | undefined = record.secret ?? undefined 80 + if (!signingSecret && record.secretId) { 81 + signingSecret = (await getWebhookSecretToken(ownerDid, record.secretId)) ?? undefined 82 + } 83 + const signature = signingSecret ? sign(signingSecret, body) : undefined 80 84 81 85 for (let attempt_n = 1; attempt_n <= config.deliveryMaxRetries; attempt_n++) { 82 86 try {
+27 -14
apps/webhook-service/src/lib/matcher.ts
··· 43 43 44 44 /** 45 45 * Recursively walk a parsed record object checking whether any string value 46 - * starts with `prefix` and has a collection segment matching `collectionRe`. 46 + * starts with `prefix` and has a collection segment matching `collectionRe`, 47 + * and optionally an rkey segment matching `rkey`. 47 48 */ 48 - function walkForReference(obj: unknown, prefix: string, collectionRe: RegExp | null, exact: string | null): boolean { 49 + function walkForReference( 50 + obj: unknown, 51 + prefix: string, 52 + collectionRe: RegExp | null, 53 + exact: string | null, 54 + rkey: string | undefined, 55 + ): boolean { 49 56 if (typeof obj === 'string') { 50 57 const idx = obj.indexOf(prefix) 51 58 if (idx === -1) return false 52 59 const rest = obj.slice(idx + prefix.length) 53 60 if (collectionRe === null && exact === null) return true // at://did — any reference 54 - const end = rest.search(/[/"\\]/) 55 - const col = end === -1 ? rest : rest.slice(0, end) 61 + const slashIdx = rest.search(/[/"\\]/) 62 + const col = slashIdx === -1 ? rest : rest.slice(0, slashIdx) 56 63 if (!col) return false 57 - return exact !== null ? col === exact : collectionRe!.test(col) 64 + const colMatches = exact !== null ? col === exact : collectionRe!.test(col) 65 + if (!colMatches) return false 66 + if (!rkey) return true 67 + if (slashIdx === -1) return false 68 + const afterSlash = rest.slice(slashIdx + 1) 69 + const rkeyEnd = afterSlash.search(/[/"\\]/) 70 + const rkeySegment = rkeyEnd === -1 ? afterSlash : afterSlash.slice(0, rkeyEnd) 71 + return rkeySegment === rkey 58 72 } 59 73 if (Array.isArray(obj)) { 60 74 for (const v of obj) { 61 - if (walkForReference(v, prefix, collectionRe, exact)) return true 75 + if (walkForReference(v, prefix, collectionRe, exact, rkey)) return true 62 76 } 63 77 return false 64 78 } 65 79 if (obj !== null && typeof obj === 'object') { 66 80 for (const v of Object.values(obj)) { 67 - if (walkForReference(v, prefix, collectionRe, exact)) return true 81 + if (walkForReference(v, prefix, collectionRe, exact, rkey)) return true 68 82 } 69 83 } 70 84 return false 71 85 } 72 86 73 87 /** 74 - * Checks whether a record contains a reference to the given DID/collection. 88 + * Checks whether a record contains a reference to the given DID/collection/rkey. 75 89 * Uses a recursive walk and pre-compiled regex — no JSON.stringify. 76 90 */ 77 - function containsReference(record: unknown, did: string, collection?: string): boolean { 91 + function containsReference(record: unknown, did: string, collection?: string, rkey?: string): boolean { 78 92 const prefix = `at://${did}/` 79 93 80 94 if (!collection) { 81 - // Any reference to this DID at all 82 - return walkForReference(record, `at://${did}`, null, null) 95 + return walkForReference(record, `at://${did}`, null, null, undefined) 83 96 } 84 97 85 98 const collectionRe = collection.includes('*') ? compileGlob(collection) : null 86 99 const exact = collectionRe ? null : collection 87 - return walkForReference(record, prefix, collectionRe, exact) 100 + return walkForReference(record, prefix, collectionRe, exact, rkey) 88 101 } 89 102 90 103 /** ··· 138 151 continue 139 152 } 140 153 141 - if (backlinks && eventDid !== scope.did && eventRecord != null) { 142 - if (containsReference(eventRecord, scope.did, scope.collection)) { 154 + if (backlinks && eventRecord != null) { 155 + if (containsReference(eventRecord, scope.did, scope.collection, scope.rkey)) { 143 156 matched.push(entry) 144 157 } 145 158 }
+44
lexicons/secret-create-v2.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.wisp.v2.secret.create", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create a named webhook signing secret. The server generates a short random token returned once in the response. Reference the secret by name via secretId in place.wisp.v2.wh.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["name"], 13 + "properties": { 14 + "name": { 15 + "type": "string", 16 + "format": "record-key", 17 + "description": "Unique name for this secret, scoped to the caller DID." 18 + } 19 + } 20 + } 21 + }, 22 + "output": { 23 + "encoding": "application/json", 24 + "schema": { 25 + "type": "object", 26 + "required": ["name", "token", "createdAt"], 27 + "properties": { 28 + "name": { "type": "string" }, 29 + "token": { 30 + "type": "string", 31 + "description": "The signing token. Only returned at creation time — store it now." 32 + }, 33 + "createdAt": { "type": "string", "format": "datetime" } 34 + } 35 + } 36 + }, 37 + "errors": [ 38 + { "name": "AuthenticationRequired" }, 39 + { "name": "InvalidRequest" }, 40 + { "name": "AlreadyExists" } 41 + ] 42 + } 43 + } 44 + }
+24
lexicons/secret-delete-v2.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.wisp.v2.secret.delete", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Delete a webhook signing secret by name.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["name"], 13 + "properties": { 14 + "name": { "type": "string", "format": "record-key" } 15 + } 16 + } 17 + }, 18 + "errors": [ 19 + { "name": "AuthenticationRequired" }, 20 + { "name": "NotFound" } 21 + ] 22 + } 23 + } 24 + }
+35
lexicons/secret-list-v2.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.wisp.v2.secret.list", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "List webhook signing secrets for the caller DID. Token values are never returned.", 8 + "output": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["secrets"], 13 + "properties": { 14 + "secrets": { 15 + "type": "array", 16 + "items": { "type": "ref", "ref": "#secretMeta" } 17 + } 18 + } 19 + } 20 + }, 21 + "errors": [ 22 + { "name": "AuthenticationRequired" } 23 + ] 24 + }, 25 + "secretMeta": { 26 + "type": "object", 27 + "required": ["name", "createdAt"], 28 + "properties": { 29 + "name": { "type": "string" }, 30 + "createdAt": { "type": "string", "format": "datetime" }, 31 + "lastRotatedAt": { "type": "string", "format": "datetime" } 32 + } 33 + } 34 + } 35 + }
+39
lexicons/secret-rotate-v2.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.wisp.v2.secret.rotate", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Rotate a webhook signing secret, generating a new token. The old token stops working immediately. The new token is only returned in this response.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["name"], 13 + "properties": { 14 + "name": { "type": "string", "format": "record-key" } 15 + } 16 + } 17 + }, 18 + "output": { 19 + "encoding": "application/json", 20 + "schema": { 21 + "type": "object", 22 + "required": ["name", "token", "rotatedAt"], 23 + "properties": { 24 + "name": { "type": "string" }, 25 + "token": { 26 + "type": "string", 27 + "description": "The new signing token. Only returned here — store it now." 28 + }, 29 + "rotatedAt": { "type": "string", "format": "datetime" } 30 + } 31 + } 32 + }, 33 + "errors": [ 34 + { "name": "AuthenticationRequired" }, 35 + { "name": "NotFound" } 36 + ] 37 + } 38 + } 39 + }
+6 -1
lexicons/webhook-v1.json
··· 41 41 "secret": { 42 42 "type": "string", 43 43 "maxLength": 256, 44 - "description": "Optional secret used to sign the webhook payload with HMAC-SHA256. The signature is included in the 'X-Webhook-Signature' header of the webhook request." 44 + "description": "Optional raw secret used to sign the webhook payload with HMAC-SHA256. Prefer secretId to avoid embedding plaintext values in PDS records." 45 + }, 46 + "secretId": { 47 + "type": "string", 48 + "format": "record-key", 49 + "description": "Name of a server-managed signing secret created via place.wisp.v2.secret.create. Takes precedence over secret if both are present." 45 50 }, 46 51 "enabled": { 47 52 "type": "boolean",
+4
packages/@wispplace/lexicons/src/atcute/lexicons/index.ts
··· 8 8 export * as PlaceWispV2DomainGetList from "./types/place/wisp/v2/domain/getList.js"; 9 9 export * as PlaceWispV2DomainGetStatus from "./types/place/wisp/v2/domain/getStatus.js"; 10 10 export * as PlaceWispV2Domains from "./types/place/wisp/v2/domains.js"; 11 + export * as PlaceWispV2SecretCreate from "./types/place/wisp/v2/secret/create.js"; 12 + export * as PlaceWispV2SecretDelete from "./types/place/wisp/v2/secret/delete.js"; 13 + export * as PlaceWispV2SecretList from "./types/place/wisp/v2/secret/list.js"; 14 + export * as PlaceWispV2SecretRotate from "./types/place/wisp/v2/secret/rotate.js"; 11 15 export * as PlaceWispV2SiteDelete from "./types/place/wisp/v2/site/delete.js"; 12 16 export * as PlaceWispV2SiteGetDomains from "./types/place/wisp/v2/site/getDomains.js"; 13 17 export * as PlaceWispV2SiteGetList from "./types/place/wisp/v2/site/getList.js";
+43
packages/@wispplace/lexicons/src/atcute/lexicons/types/place/wisp/v2/secret/create.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.procedure("place.wisp.v2.secret.create", { 6 + params: null, 7 + input: { 8 + type: "lex", 9 + schema: /*#__PURE__*/ v.object({ 10 + /** 11 + * Unique name for this secret, scoped to the caller DID. 12 + */ 13 + name: /*#__PURE__*/ v.recordKeyString(), 14 + }), 15 + }, 16 + output: { 17 + type: "lex", 18 + schema: /*#__PURE__*/ v.object({ 19 + createdAt: /*#__PURE__*/ v.datetimeString(), 20 + name: /*#__PURE__*/ v.string(), 21 + /** 22 + * The signing token. Only returned at creation time — store it now. 23 + */ 24 + token: /*#__PURE__*/ v.string(), 25 + }), 26 + }, 27 + }); 28 + 29 + type main$schematype = typeof _mainSchema; 30 + 31 + export interface mainSchema extends main$schematype {} 32 + 33 + export const mainSchema = _mainSchema as mainSchema; 34 + 35 + export interface $params {} 36 + export interface $input extends v.InferXRPCBodyInput<mainSchema["input"]> {} 37 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 38 + 39 + declare module "@atcute/lexicons/ambient" { 40 + interface XRPCProcedures { 41 + "place.wisp.v2.secret.create": mainSchema; 42 + } 43 + }
+29
packages/@wispplace/lexicons/src/atcute/lexicons/types/place/wisp/v2/secret/delete.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.procedure("place.wisp.v2.secret.delete", { 6 + params: null, 7 + input: { 8 + type: "lex", 9 + schema: /*#__PURE__*/ v.object({ 10 + name: /*#__PURE__*/ v.recordKeyString(), 11 + }), 12 + }, 13 + output: null, 14 + }); 15 + 16 + type main$schematype = typeof _mainSchema; 17 + 18 + export interface mainSchema extends main$schematype {} 19 + 20 + export const mainSchema = _mainSchema as mainSchema; 21 + 22 + export interface $params {} 23 + export interface $input extends v.InferXRPCBodyInput<mainSchema["input"]> {} 24 + 25 + declare module "@atcute/lexicons/ambient" { 26 + interface XRPCProcedures { 27 + "place.wisp.v2.secret.delete": mainSchema; 28 + } 29 + }
+43
packages/@wispplace/lexicons/src/atcute/lexicons/types/place/wisp/v2/secret/list.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.query("place.wisp.v2.secret.list", { 6 + params: null, 7 + output: { 8 + type: "lex", 9 + schema: /*#__PURE__*/ v.object({ 10 + get secrets() { 11 + return /*#__PURE__*/ v.array(secretMetaSchema); 12 + }, 13 + }), 14 + }, 15 + }); 16 + const _secretMetaSchema = /*#__PURE__*/ v.object({ 17 + $type: /*#__PURE__*/ v.optional( 18 + /*#__PURE__*/ v.literal("place.wisp.v2.secret.list#secretMeta"), 19 + ), 20 + createdAt: /*#__PURE__*/ v.datetimeString(), 21 + lastRotatedAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), 22 + name: /*#__PURE__*/ v.string(), 23 + }); 24 + 25 + type main$schematype = typeof _mainSchema; 26 + type secretMeta$schematype = typeof _secretMetaSchema; 27 + 28 + export interface mainSchema extends main$schematype {} 29 + export interface secretMetaSchema extends secretMeta$schematype {} 30 + 31 + export const mainSchema = _mainSchema as mainSchema; 32 + export const secretMetaSchema = _secretMetaSchema as secretMetaSchema; 33 + 34 + export interface SecretMeta extends v.InferInput<typeof secretMetaSchema> {} 35 + 36 + export interface $params {} 37 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 38 + 39 + declare module "@atcute/lexicons/ambient" { 40 + interface XRPCQueries { 41 + "place.wisp.v2.secret.list": mainSchema; 42 + } 43 + }
+40
packages/@wispplace/lexicons/src/atcute/lexicons/types/place/wisp/v2/secret/rotate.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.procedure("place.wisp.v2.secret.rotate", { 6 + params: null, 7 + input: { 8 + type: "lex", 9 + schema: /*#__PURE__*/ v.object({ 10 + name: /*#__PURE__*/ v.recordKeyString(), 11 + }), 12 + }, 13 + output: { 14 + type: "lex", 15 + schema: /*#__PURE__*/ v.object({ 16 + name: /*#__PURE__*/ v.string(), 17 + rotatedAt: /*#__PURE__*/ v.datetimeString(), 18 + /** 19 + * The new signing token. Only returned here — store it now. 20 + */ 21 + token: /*#__PURE__*/ v.string(), 22 + }), 23 + }, 24 + }); 25 + 26 + type main$schematype = typeof _mainSchema; 27 + 28 + export interface mainSchema extends main$schematype {} 29 + 30 + export const mainSchema = _mainSchema as mainSchema; 31 + 32 + export interface $params {} 33 + export interface $input extends v.InferXRPCBodyInput<mainSchema["input"]> {} 34 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 35 + 36 + declare module "@atcute/lexicons/ambient" { 37 + interface XRPCProcedures { 38 + "place.wisp.v2.secret.rotate": mainSchema; 39 + } 40 + }
+5 -1
packages/@wispplace/lexicons/src/atcute/lexicons/types/place/wisp/v2/wh.ts
··· 43 43 return atUriSchema; 44 44 }, 45 45 /** 46 - * Optional secret used to sign the webhook payload with HMAC-SHA256. The signature is included in the 'X-Webhook-Signature' header of the webhook request. 46 + * Optional raw secret used to sign the webhook payload with HMAC-SHA256. Prefer secretId to avoid embedding plaintext values in PDS records. 47 47 * @maxLength 256 48 48 */ 49 49 secret: /*#__PURE__*/ v.optional( ··· 51 51 /*#__PURE__*/ v.stringLength(0, 256), 52 52 ]), 53 53 ), 54 + /** 55 + * Name of a server-managed signing secret created via place.wisp.v2.secret.create. Takes precedence over secret if both are present. 56 + */ 57 + secretId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.recordKeyString()), 54 58 /** 55 59 * HTTPS endpoint to POST the webhook payload to. 56 60 * @maxLength 2048
+62
packages/@wispplace/lexicons/src/index.ts
··· 16 16 import * as PlaceWispV2DomainDelete from './types/place/wisp/v2/domain/delete.js' 17 17 import * as PlaceWispV2DomainGetList from './types/place/wisp/v2/domain/getList.js' 18 18 import * as PlaceWispV2DomainGetStatus from './types/place/wisp/v2/domain/getStatus.js' 19 + import * as PlaceWispV2SecretCreate from './types/place/wisp/v2/secret/create.js' 20 + import * as PlaceWispV2SecretDelete from './types/place/wisp/v2/secret/delete.js' 21 + import * as PlaceWispV2SecretList from './types/place/wisp/v2/secret/list.js' 22 + import * as PlaceWispV2SecretRotate from './types/place/wisp/v2/secret/rotate.js' 19 23 import * as PlaceWispV2SiteDelete from './types/place/wisp/v2/site/delete.js' 20 24 import * as PlaceWispV2SiteGetDomains from './types/place/wisp/v2/site/getDomains.js' 21 25 import * as PlaceWispV2SiteGetList from './types/place/wisp/v2/site/getList.js' ··· 57 61 export class PlaceWispV2NS { 58 62 _server: Server 59 63 domain: PlaceWispV2DomainNS 64 + secret: PlaceWispV2SecretNS 60 65 site: PlaceWispV2SiteNS 61 66 62 67 constructor(server: Server) { 63 68 this._server = server 64 69 this.domain = new PlaceWispV2DomainNS(server) 70 + this.secret = new PlaceWispV2SecretNS(server) 65 71 this.site = new PlaceWispV2SiteNS(server) 66 72 } 67 73 } ··· 142 148 >, 143 149 ) { 144 150 const nsid = 'place.wisp.v2.domain.getStatus' // @ts-ignore 151 + return this._server.xrpc.method(nsid, cfg) 152 + } 153 + } 154 + 155 + export class PlaceWispV2SecretNS { 156 + _server: Server 157 + 158 + constructor(server: Server) { 159 + this._server = server 160 + } 161 + 162 + create<A extends Auth = void>( 163 + cfg: MethodConfigOrHandler< 164 + A, 165 + PlaceWispV2SecretCreate.QueryParams, 166 + PlaceWispV2SecretCreate.HandlerInput, 167 + PlaceWispV2SecretCreate.HandlerOutput 168 + >, 169 + ) { 170 + const nsid = 'place.wisp.v2.secret.create' // @ts-ignore 171 + return this._server.xrpc.method(nsid, cfg) 172 + } 173 + 174 + delete<A extends Auth = void>( 175 + cfg: MethodConfigOrHandler< 176 + A, 177 + PlaceWispV2SecretDelete.QueryParams, 178 + PlaceWispV2SecretDelete.HandlerInput, 179 + PlaceWispV2SecretDelete.HandlerOutput 180 + >, 181 + ) { 182 + const nsid = 'place.wisp.v2.secret.delete' // @ts-ignore 183 + return this._server.xrpc.method(nsid, cfg) 184 + } 185 + 186 + list<A extends Auth = void>( 187 + cfg: MethodConfigOrHandler< 188 + A, 189 + PlaceWispV2SecretList.QueryParams, 190 + PlaceWispV2SecretList.HandlerInput, 191 + PlaceWispV2SecretList.HandlerOutput 192 + >, 193 + ) { 194 + const nsid = 'place.wisp.v2.secret.list' // @ts-ignore 195 + return this._server.xrpc.method(nsid, cfg) 196 + } 197 + 198 + rotate<A extends Auth = void>( 199 + cfg: MethodConfigOrHandler< 200 + A, 201 + PlaceWispV2SecretRotate.QueryParams, 202 + PlaceWispV2SecretRotate.HandlerInput, 203 + PlaceWispV2SecretRotate.HandlerOutput 204 + >, 205 + ) { 206 + const nsid = 'place.wisp.v2.secret.rotate' // @ts-ignore 145 207 return this._server.xrpc.method(nsid, cfg) 146 208 } 147 209 }
+202 -1
packages/@wispplace/lexicons/src/lexicons.ts
··· 667 667 }, 668 668 }, 669 669 }, 670 + PlaceWispV2SecretCreate: { 671 + lexicon: 1, 672 + id: 'place.wisp.v2.secret.create', 673 + defs: { 674 + main: { 675 + type: 'procedure', 676 + description: 677 + 'Create a named webhook signing secret. The server generates a short random token returned once in the response. Reference the secret by name via secretId in place.wisp.v2.wh.', 678 + input: { 679 + encoding: 'application/json', 680 + schema: { 681 + type: 'object', 682 + required: ['name'], 683 + properties: { 684 + name: { 685 + type: 'string', 686 + format: 'record-key', 687 + description: 688 + 'Unique name for this secret, scoped to the caller DID.', 689 + }, 690 + }, 691 + }, 692 + }, 693 + output: { 694 + encoding: 'application/json', 695 + schema: { 696 + type: 'object', 697 + required: ['name', 'token', 'createdAt'], 698 + properties: { 699 + name: { 700 + type: 'string', 701 + }, 702 + token: { 703 + type: 'string', 704 + description: 705 + 'The signing token. Only returned at creation time — store it now.', 706 + }, 707 + createdAt: { 708 + type: 'string', 709 + format: 'datetime', 710 + }, 711 + }, 712 + }, 713 + }, 714 + errors: [ 715 + { 716 + name: 'AuthenticationRequired', 717 + }, 718 + { 719 + name: 'InvalidRequest', 720 + }, 721 + { 722 + name: 'AlreadyExists', 723 + }, 724 + ], 725 + }, 726 + }, 727 + }, 728 + PlaceWispV2SecretDelete: { 729 + lexicon: 1, 730 + id: 'place.wisp.v2.secret.delete', 731 + defs: { 732 + main: { 733 + type: 'procedure', 734 + description: 'Delete a webhook signing secret by name.', 735 + input: { 736 + encoding: 'application/json', 737 + schema: { 738 + type: 'object', 739 + required: ['name'], 740 + properties: { 741 + name: { 742 + type: 'string', 743 + format: 'record-key', 744 + }, 745 + }, 746 + }, 747 + }, 748 + errors: [ 749 + { 750 + name: 'AuthenticationRequired', 751 + }, 752 + { 753 + name: 'NotFound', 754 + }, 755 + ], 756 + }, 757 + }, 758 + }, 759 + PlaceWispV2SecretList: { 760 + lexicon: 1, 761 + id: 'place.wisp.v2.secret.list', 762 + defs: { 763 + main: { 764 + type: 'query', 765 + description: 766 + 'List webhook signing secrets for the caller DID. Token values are never returned.', 767 + output: { 768 + encoding: 'application/json', 769 + schema: { 770 + type: 'object', 771 + required: ['secrets'], 772 + properties: { 773 + secrets: { 774 + type: 'array', 775 + items: { 776 + type: 'ref', 777 + ref: 'lex:place.wisp.v2.secret.list#secretMeta', 778 + }, 779 + }, 780 + }, 781 + }, 782 + }, 783 + errors: [ 784 + { 785 + name: 'AuthenticationRequired', 786 + }, 787 + ], 788 + }, 789 + secretMeta: { 790 + type: 'object', 791 + required: ['name', 'createdAt'], 792 + properties: { 793 + name: { 794 + type: 'string', 795 + }, 796 + createdAt: { 797 + type: 'string', 798 + format: 'datetime', 799 + }, 800 + lastRotatedAt: { 801 + type: 'string', 802 + format: 'datetime', 803 + }, 804 + }, 805 + }, 806 + }, 807 + }, 808 + PlaceWispV2SecretRotate: { 809 + lexicon: 1, 810 + id: 'place.wisp.v2.secret.rotate', 811 + defs: { 812 + main: { 813 + type: 'procedure', 814 + description: 815 + 'Rotate a webhook signing secret, generating a new token. The old token stops working immediately. The new token is only returned in this response.', 816 + input: { 817 + encoding: 'application/json', 818 + schema: { 819 + type: 'object', 820 + required: ['name'], 821 + properties: { 822 + name: { 823 + type: 'string', 824 + format: 'record-key', 825 + }, 826 + }, 827 + }, 828 + }, 829 + output: { 830 + encoding: 'application/json', 831 + schema: { 832 + type: 'object', 833 + required: ['name', 'token', 'rotatedAt'], 834 + properties: { 835 + name: { 836 + type: 'string', 837 + }, 838 + token: { 839 + type: 'string', 840 + description: 841 + 'The new signing token. Only returned here — store it now.', 842 + }, 843 + rotatedAt: { 844 + type: 'string', 845 + format: 'datetime', 846 + }, 847 + }, 848 + }, 849 + }, 850 + errors: [ 851 + { 852 + name: 'AuthenticationRequired', 853 + }, 854 + { 855 + name: 'NotFound', 856 + }, 857 + ], 858 + }, 859 + }, 860 + }, 670 861 PlaceWispSettings: { 671 862 lexicon: 1, 672 863 id: 'place.wisp.settings', ··· 1129 1320 type: 'string', 1130 1321 maxLength: 256, 1131 1322 description: 1132 - "Optional secret used to sign the webhook payload with HMAC-SHA256. The signature is included in the 'X-Webhook-Signature' header of the webhook request.", 1323 + 'Optional raw secret used to sign the webhook payload with HMAC-SHA256. Prefer secretId to avoid embedding plaintext values in PDS records.', 1324 + }, 1325 + secretId: { 1326 + type: 'string', 1327 + format: 'record-key', 1328 + description: 1329 + 'Name of a server-managed signing secret created via place.wisp.v2.secret.create. Takes precedence over secret if both are present.', 1133 1330 }, 1134 1331 enabled: { 1135 1332 type: 'boolean', ··· 1203 1400 PlaceWispV2DomainGetStatus: 'place.wisp.v2.domain.getStatus', 1204 1401 PlaceWispV2Domains: 'place.wisp.v2.domains', 1205 1402 PlaceWispFs: 'place.wisp.fs', 1403 + PlaceWispV2SecretCreate: 'place.wisp.v2.secret.create', 1404 + PlaceWispV2SecretDelete: 'place.wisp.v2.secret.delete', 1405 + PlaceWispV2SecretList: 'place.wisp.v2.secret.list', 1406 + PlaceWispV2SecretRotate: 'place.wisp.v2.secret.rotate', 1206 1407 PlaceWispSettings: 'place.wisp.settings', 1207 1408 PlaceWispV2SiteDelete: 'place.wisp.v2.site.delete', 1208 1409 PlaceWispV2SiteGetDomains: 'place.wisp.v2.site.getDomains',
+48
packages/@wispplace/lexicons/src/types/place/wisp/v2/secret/create.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../../util' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'place.wisp.v2.secret.create' 16 + 17 + export type QueryParams = {} 18 + 19 + export interface InputSchema { 20 + /** Unique name for this secret, scoped to the caller DID. */ 21 + name: string 22 + } 23 + 24 + export interface OutputSchema { 25 + name: string 26 + /** The signing token. Only returned at creation time — store it now. */ 27 + token: string 28 + createdAt: string 29 + } 30 + 31 + export interface HandlerInput { 32 + encoding: 'application/json' 33 + body: InputSchema 34 + } 35 + 36 + export interface HandlerSuccess { 37 + encoding: 'application/json' 38 + body: OutputSchema 39 + headers?: { [key: string]: string } 40 + } 41 + 42 + export interface HandlerError { 43 + status: number 44 + message?: string 45 + error?: 'AuthenticationRequired' | 'InvalidRequest' | 'AlreadyExists' 46 + } 47 + 48 + export type HandlerOutput = HandlerError | HandlerSuccess
+34
packages/@wispplace/lexicons/src/types/place/wisp/v2/secret/delete.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../../util' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'place.wisp.v2.secret.delete' 16 + 17 + export type QueryParams = {} 18 + 19 + export interface InputSchema { 20 + name: string 21 + } 22 + 23 + export interface HandlerInput { 24 + encoding: 'application/json' 25 + body: InputSchema 26 + } 27 + 28 + export interface HandlerError { 29 + status: number 30 + message?: string 31 + error?: 'AuthenticationRequired' | 'NotFound' 32 + } 33 + 34 + export type HandlerOutput = HandlerError | void
+55
packages/@wispplace/lexicons/src/types/place/wisp/v2/secret/list.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../../util' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'place.wisp.v2.secret.list' 16 + 17 + export type QueryParams = {} 18 + export type InputSchema = undefined 19 + 20 + export interface OutputSchema { 21 + secrets: SecretMeta[] 22 + } 23 + 24 + export type HandlerInput = void 25 + 26 + export interface HandlerSuccess { 27 + encoding: 'application/json' 28 + body: OutputSchema 29 + headers?: { [key: string]: string } 30 + } 31 + 32 + export interface HandlerError { 33 + status: number 34 + message?: string 35 + error?: 'AuthenticationRequired' 36 + } 37 + 38 + export type HandlerOutput = HandlerError | HandlerSuccess 39 + 40 + export interface SecretMeta { 41 + $type?: 'place.wisp.v2.secret.list#secretMeta' 42 + name: string 43 + createdAt: string 44 + lastRotatedAt?: string 45 + } 46 + 47 + const hashSecretMeta = 'secretMeta' 48 + 49 + export function isSecretMeta<V>(v: V) { 50 + return is$typed(v, id, hashSecretMeta) 51 + } 52 + 53 + export function validateSecretMeta<V>(v: V) { 54 + return validate<SecretMeta & V>(v, id, hashSecretMeta) 55 + }
+47
packages/@wispplace/lexicons/src/types/place/wisp/v2/secret/rotate.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../../util' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'place.wisp.v2.secret.rotate' 16 + 17 + export type QueryParams = {} 18 + 19 + export interface InputSchema { 20 + name: string 21 + } 22 + 23 + export interface OutputSchema { 24 + name: string 25 + /** The new signing token. Only returned here — store it now. */ 26 + token: string 27 + rotatedAt: string 28 + } 29 + 30 + export interface HandlerInput { 31 + encoding: 'application/json' 32 + body: InputSchema 33 + } 34 + 35 + export interface HandlerSuccess { 36 + encoding: 'application/json' 37 + body: OutputSchema 38 + headers?: { [key: string]: string } 39 + } 40 + 41 + export interface HandlerError { 42 + status: number 43 + message?: string 44 + error?: 'AuthenticationRequired' | 'NotFound' 45 + } 46 + 47 + export type HandlerOutput = HandlerError | HandlerSuccess
+3 -1
packages/@wispplace/lexicons/src/types/place/wisp/v2/wh.ts
··· 21 21 url: string 22 22 /** Which record events to trigger on. Defaults to all events if omitted. */ 23 23 events?: ('create' | 'update' | 'delete')[] 24 - /** Optional secret used to sign the webhook payload with HMAC-SHA256. The signature is included in the 'X-Webhook-Signature' header of the webhook request. */ 24 + /** Optional raw secret used to sign the webhook payload with HMAC-SHA256. Prefer secretId to avoid embedding plaintext values in PDS records. */ 25 25 secret?: string 26 + /** Name of a server-managed signing secret created via place.wisp.v2.secret.create. Takes precedence over secret if both are present. */ 27 + secretId?: string 26 28 /** Whether the webhook is active. Defaults to true if omitted. */ 27 29 enabled?: boolean 28 30 /** Timestamp of when the webhook was created. */