From d94eaac70de4f700df79b8111ce8aa07ba2ea853 Mon Sep 17 00:00:00 2001 From: "nekomimi.pet" Date: Tue, 28 Apr 2026 00:57:09 -0400 Subject: [PATCH] major webhook work --- apps/main-app/public/editor/editor.tsx | 17 + .../public/editor/hooks/useSecretData.ts | 83 ++++ .../public/editor/hooks/useWebhookData.ts | 1 + .../public/editor/tabs/WebhooksTab.tsx | 262 ++++++++++++- apps/main-app/src/index.ts | 2 + apps/main-app/src/lib/db.ts | 76 ++++ apps/main-app/src/routes/secret.ts | 97 +++++ apps/main-app/src/routes/webhook.ts | 3 +- apps/main-app/src/routes/xrpc.ts | 88 +++++ apps/webhook-service/bench/e2e.ts | 366 ++++++++++++++++++ apps/webhook-service/package.json | 3 +- apps/webhook-service/src/lib/db.ts | 19 + apps/webhook-service/src/lib/delivery.ts | 8 +- apps/webhook-service/src/lib/matcher.ts | 41 +- lexicons/secret-create-v2.json | 44 +++ lexicons/secret-delete-v2.json | 24 ++ lexicons/secret-list-v2.json | 35 ++ lexicons/secret-rotate-v2.json | 39 ++ lexicons/webhook-v1.json | 7 +- .../lexicons/src/atcute/lexicons/index.ts | 4 + .../types/place/wisp/v2/secret/create.ts | 43 ++ .../types/place/wisp/v2/secret/delete.ts | 29 ++ .../types/place/wisp/v2/secret/list.ts | 43 ++ .../types/place/wisp/v2/secret/rotate.ts | 40 ++ .../atcute/lexicons/types/place/wisp/v2/wh.ts | 6 +- packages/@wispplace/lexicons/src/index.ts | 62 +++ packages/@wispplace/lexicons/src/lexicons.ts | 203 +++++++++- .../src/types/place/wisp/v2/secret/create.ts | 48 +++ .../src/types/place/wisp/v2/secret/delete.ts | 34 ++ .../src/types/place/wisp/v2/secret/list.ts | 55 +++ .../src/types/place/wisp/v2/secret/rotate.ts | 47 +++ .../lexicons/src/types/place/wisp/v2/wh.ts | 4 +- 32 files changed, 1803 insertions(+), 30 deletions(-) create mode 100644 apps/main-app/public/editor/hooks/useSecretData.ts create mode 100644 apps/main-app/src/routes/secret.ts create mode 100644 apps/webhook-service/bench/e2e.ts create mode 100644 lexicons/secret-create-v2.json create mode 100644 lexicons/secret-delete-v2.json create mode 100644 lexicons/secret-list-v2.json create mode 100644 lexicons/secret-rotate-v2.json create mode 100644 packages/@wispplace/lexicons/src/atcute/lexicons/types/place/wisp/v2/secret/create.ts create mode 100644 packages/@wispplace/lexicons/src/atcute/lexicons/types/place/wisp/v2/secret/delete.ts create mode 100644 packages/@wispplace/lexicons/src/atcute/lexicons/types/place/wisp/v2/secret/list.ts create mode 100644 packages/@wispplace/lexicons/src/atcute/lexicons/types/place/wisp/v2/secret/rotate.ts create mode 100644 packages/@wispplace/lexicons/src/types/place/wisp/v2/secret/create.ts create mode 100644 packages/@wispplace/lexicons/src/types/place/wisp/v2/secret/delete.ts create mode 100644 packages/@wispplace/lexicons/src/types/place/wisp/v2/secret/list.ts create mode 100644 packages/@wispplace/lexicons/src/types/place/wisp/v2/secret/rotate.ts diff --git a/apps/main-app/public/editor/editor.tsx b/apps/main-app/public/editor/editor.tsx index 7c4ff91..a419b51 100644 --- a/apps/main-app/public/editor/editor.tsx +++ b/apps/main-app/public/editor/editor.tsx @@ -20,6 +20,7 @@ import { type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent, useCallback import { createRoot } from 'react-dom/client' import { BrowserRouter, Route, Routes } from 'react-router-dom' import { useDomainData } from './hooks/useDomainData' +import { useSecretData } from './hooks/useSecretData' import { type SiteWithDomains, useSiteData } from './hooks/useSiteData' import { useUserInfo } from './hooks/useUserInfo' import { useWebhookData } from './hooks/useWebhookData' @@ -51,6 +52,15 @@ function Dashboard() { createWebhook, deleteWebhook, } = useWebhookData() + const { + secrets, + secretsLoading, + isCreatingSecret, + fetchSecrets, + createSecret, + deleteSecret, + rotateSecret, + } = useSecretData() const { wispDomains, @@ -97,6 +107,7 @@ function Dashboard() { fetchDomains() fetchWebhooks() fetchEventLogs() + fetchSecrets() }, []) // Redirect to home if not authenticated @@ -534,9 +545,15 @@ function Dashboard() { eventLogsLoading={eventLogsLoading} isCreating={isCreating} userDid={userInfo?.did} + secrets={secrets} + secretsLoading={secretsLoading} + isCreatingSecret={isCreatingSecret} onCreateWebhook={createWebhook} onDeleteWebhook={deleteWebhook} onRefreshEvents={fetchEventLogs} + onCreateSecret={createSecret} + onDeleteSecret={deleteSecret} + onRotateSecret={rotateSecret} /> diff --git a/apps/main-app/public/editor/hooks/useSecretData.ts b/apps/main-app/public/editor/hooks/useSecretData.ts new file mode 100644 index 0000000..6dc7dfa --- /dev/null +++ b/apps/main-app/public/editor/hooks/useSecretData.ts @@ -0,0 +1,83 @@ +import { useCallback, useState } from 'react' + +export interface SecretMeta { + name: string + createdAt: string + lastRotatedAt?: string +} + +export function useSecretData() { + const [secrets, setSecrets] = useState([]) + const [secretsLoading, setSecretsLoading] = useState(false) + const [isCreatingSecret, setIsCreatingSecret] = useState(false) + + const fetchSecrets = useCallback(async () => { + setSecretsLoading(true) + try { + const res = await fetch('/api/secret', { credentials: 'include' }) + if (!res.ok) throw new Error('Failed to fetch secrets') + const data = await res.json() + setSecrets(data.secrets ?? []) + } catch (err) { + console.error('Failed to fetch secrets:', err) + } finally { + setSecretsLoading(false) + } + }, []) + + const createSecret = useCallback( + async (name: string): Promise<{ token: string }> => { + setIsCreatingSecret(true) + try { + const res = await fetch('/api/secret', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ name }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || 'Failed to create secret') + await fetchSecrets() + return { token: data.token } + } finally { + setIsCreatingSecret(false) + } + }, + [fetchSecrets], + ) + + const deleteSecret = useCallback(async (name: string): Promise => { + const res = await fetch(`/api/secret/${encodeURIComponent(name)}`, { + method: 'DELETE', + credentials: 'include', + }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + throw new Error(data.error || 'Failed to delete secret') + } + setSecrets((prev) => prev.filter((s) => s.name !== name)) + }, []) + + const rotateSecret = useCallback(async (name: string): Promise<{ token: string }> => { + const res = await fetch(`/api/secret/${encodeURIComponent(name)}/rotate`, { + method: 'POST', + credentials: 'include', + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || 'Failed to rotate secret') + setSecrets((prev) => + prev.map((s) => (s.name === name ? { ...s, lastRotatedAt: data.rotatedAt } : s)), + ) + return { token: data.token } + }, []) + + return { + secrets, + secretsLoading, + isCreatingSecret, + fetchSecrets, + createSecret, + deleteSecret, + rotateSecret, + } +} diff --git a/apps/main-app/public/editor/hooks/useWebhookData.ts b/apps/main-app/public/editor/hooks/useWebhookData.ts index b78b4db..e29e99d 100644 --- a/apps/main-app/public/editor/hooks/useWebhookData.ts +++ b/apps/main-app/public/editor/hooks/useWebhookData.ts @@ -77,6 +77,7 @@ export function useWebhookData() { backlinks: boolean events: string[] secret: string + secretId?: string enabled: boolean }) => { setIsCreating(true) diff --git a/apps/main-app/public/editor/tabs/WebhooksTab.tsx b/apps/main-app/public/editor/tabs/WebhooksTab.tsx index bbf0c74..19ac5de 100644 --- a/apps/main-app/public/editor/tabs/WebhooksTab.tsx +++ b/apps/main-app/public/editor/tabs/WebhooksTab.tsx @@ -10,15 +10,19 @@ import { ChevronDown, ChevronsUpDown, ChevronUp, + Copy, ExternalLink, + KeyRound, Loader2, Plus, RefreshCw, + RotateCcw, Trash2, Webhook, X, } from 'lucide-react' import { type ChangeEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { SecretMeta } from '../hooks/useSecretData' import type { WebhookEventLog, WebhookRecord } from '../hooks/useWebhookData' const APPS = [ @@ -39,34 +43,41 @@ interface WebhooksTabProps { eventLogsLoading: boolean isCreating: boolean userDid?: string + secrets: SecretMeta[] + secretsLoading: boolean + isCreatingSecret: boolean onCreateWebhook: (data: { scopeAturi: string url: string backlinks: boolean events: string[] secret: string + secretId?: string enabled: boolean }) => Promise onDeleteWebhook: (rkey: string) => Promise onRefreshEvents: () => Promise + onCreateSecret: (name: string) => Promise<{ token: string }> + onDeleteSecret: (name: string) => Promise + onRotateSecret: (name: string) => Promise<{ token: string }> } function buildScope( - userDid: string, + did: string, selectedApp: AppId | null, scopePath: string, otherMode: OtherMode, otherCollection: string, otherRkey: string, ): string { - if (!userDid) return '' + if (!did) return '' if (!selectedApp) return '' if (selectedApp === 'other') { - if (otherMode === 'all') return `at://${userDid}` - if (otherMode === 'collection') return otherCollection ? `at://${userDid}/${otherCollection}` : '' - return otherCollection && otherRkey ? `at://${userDid}/${otherCollection}/${otherRkey}` : '' + if (otherMode === 'all') return `at://${did}` + if (otherMode === 'collection') return otherCollection ? `at://${did}/${otherCollection}` : '' + return otherCollection && otherRkey ? `at://${did}/${otherCollection}/${otherRkey}` : '' } - return scopePath ? `at://${userDid}/${scopePath}` : `at://${userDid}` + return scopePath ? `at://${did}/${scopePath}` : `at://${did}` } function formatTimeAgo(dateStr: string): string { @@ -87,9 +98,15 @@ export const WebhooksTab = memo(function WebhooksTab({ eventLogsLoading, isCreating, userDid = '', + secrets, + secretsLoading, + isCreatingSecret, onCreateWebhook, onDeleteWebhook, onRefreshEvents, + onCreateSecret, + onDeleteSecret, + onRotateSecret, }: WebhooksTabProps) { const [url, setUrl] = useState('') const [selectedApp, setSelectedApp] = useState(null) @@ -97,6 +114,9 @@ export const WebhooksTab = memo(function WebhooksTab({ const [otherMode, setOtherMode] = useState('all') const [otherCollection, setOtherCollection] = useState('') const [otherRkey, setOtherRkey] = useState('') + const [customDid, setCustomDid] = useState('') + const [useCustomDid, setUseCustomDid] = useState(false) + const [selectedSecretId, setSelectedSecretId] = useState('') const [backlinks, setBacklinks] = useState(false) const [eventCreate, setEventCreate] = useState(true) const [eventUpdate, setEventUpdate] = useState(true) @@ -109,6 +129,15 @@ export const WebhooksTab = memo(function WebhooksTab({ const containerRef = useRef(null) const itemRefs = useRef<(HTMLDivElement | null)[]>([]) + // Secrets panel state + const [showSecretsPanel, setShowSecretsPanel] = useState(false) + const [newSecretName, setNewSecretName] = useState('') + const [secretError, setSecretError] = useState(null) + const [revealedToken, setRevealedToken] = useState<{ name: string; token: string } | null>(null) + const [deletingSecret, setDeletingSecret] = useState(null) + const [rotatingSecret, setRotatingSecret] = useState(null) + const [copiedToken, setCopiedToken] = useState(false) + useEffect(() => { const id = setInterval(onRefreshEvents, 60_000) return () => clearInterval(id) @@ -193,7 +222,8 @@ export const WebhooksTab = memo(function WebhooksTab({ setError(null) } - const scopeAturi = buildScope(userDid, selectedApp, scopePath, otherMode, otherCollection, otherRkey) + const effectiveDid = useCustomDid && customDid.trim() ? customDid.trim() : userDid + const scopeAturi = buildScope(effectiveDid, selectedApp, scopePath, otherMode, otherCollection, otherRkey) const handleCreate = async (e: React.FormEvent) => { e.preventDefault() @@ -221,6 +251,7 @@ export const WebhooksTab = memo(function WebhooksTab({ backlinks, events: events.length === 3 ? [] : events, secret: '', + secretId: selectedSecretId || undefined, enabled: true, }) setSuccess('Webhook created successfully') @@ -230,6 +261,9 @@ export const WebhooksTab = memo(function WebhooksTab({ setOtherMode('all') setOtherCollection('') setOtherRkey('') + setCustomDid('') + setUseCustomDid(false) + setSelectedSecretId('') setBacklinks(false) setEventCreate(true) setEventUpdate(true) @@ -442,7 +476,7 @@ export const WebhooksTab = memo(function WebhooksTab({
- at://{userDid ? `${userDid.slice(0, 12)}...` : 'did'}/ + at://{effectiveDid ? `${effectiveDid.slice(0, 16)}...` : 'did'}/ )} + {/* Custom DID override */} +
+
+ setUseCustomDid(!!v)} + /> + +
+ {useCustomDid && ( + ) => setCustomDid(e.target.value)} + placeholder="did:plc:..." + className="h-8 text-xs font-mono" + /> + )} +
+ {/* Options row */} {selectedApp && (
@@ -562,6 +618,28 @@ export const WebhooksTab = memo(function WebhooksTab({
)} + {/* Signing secret */} +
+ + + {secrets.length === 0 && ( +

+ No secrets yet — create one in the Secrets section below. +

+ )} +
+ {error && (

{error}

@@ -692,6 +770,174 @@ export const WebhooksTab = memo(function WebhooksTab({ )}
+ {/* Signing Secrets */} +
+
+
+ +

Signing Secrets

+
+ +
+ + {showSecretsPanel && ( +
+ {/* Create new secret */} +
+ ) => setNewSecretName(e.target.value)} + placeholder="secret name (e.g. my-server)" + className="h-8 text-xs font-mono flex-1" + /> + +
+ + {secretError && ( +

{secretError}

+ )} + + {/* Revealed token — show once */} + {revealedToken && ( +
+

+ New token for {revealedToken.name} — copy it now, it won't be shown again +

+
+ {revealedToken.token} + +
+
+ )} + + {/* Secret list */} + {secretsLoading ? ( + + ) : secrets.length === 0 ? ( +

No secrets yet

+ ) : ( +
+ {secrets.map((s) => ( +
+
+

{s.name}

+

+ Created {new Date(s.createdAt).toLocaleDateString()} + {s.lastRotatedAt && ` · rotated ${new Date(s.lastRotatedAt).toLocaleDateString()}`} +

+
+
+ + +
+
+ ))} +
+ )} +
+ )} +
+ {/* Event Logs — table */}
{/* Section header */} diff --git a/apps/main-app/src/index.ts b/apps/main-app/src/index.ts index b257209..9053ead 100644 --- a/apps/main-app/src/index.ts +++ b/apps/main-app/src/index.ts @@ -27,6 +27,7 @@ import { authRoutes } from './routes/auth' import { domainRoutes } from './routes/domain' import { siteRoutes } from './routes/site' import { userRoutes } from './routes/user' +import { secretRoutes } from './routes/secret' import { webhookRoutes } from './routes/webhook' import { wispRoutes } from './routes/wisp' import { xrpcRoutes } from './routes/xrpc' @@ -238,6 +239,7 @@ export const app = new Elysia({ .use(userRoutes(client, cookieSecret)) .use(siteRoutes(client, cookieSecret)) .use(webhookRoutes(client, cookieSecret)) + .use(secretRoutes(client, cookieSecret)) .use(adminRoutes(cookieSecret)) .use( await staticPlugin({ diff --git a/apps/main-app/src/lib/db.ts b/apps/main-app/src/lib/db.ts index 6766fb8..47ce59d 100644 --- a/apps/main-app/src/lib/db.ts +++ b/apps/main-app/src/lib/db.ts @@ -120,6 +120,18 @@ await db` ) ` +// Webhook signing secrets managed server-side; token never stored after creation +await db` + CREATE TABLE IF NOT EXISTS webhook_secrets ( + did TEXT NOT NULL, + name TEXT NOT NULL, + token TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_rotated_at TIMESTAMPTZ, + PRIMARY KEY (did, name) + ) +` + await runDatabaseMigrations(db) export const getDomainByDid = async (did: string): Promise => { @@ -478,6 +490,70 @@ export const getAllSupporters = async () => { return rows } +function generateSecretToken(): string { + const bytes = crypto.getRandomValues(new Uint8Array(24)) + return `wsk_${Buffer.from(bytes).toString('base64url')}` +} + +export const createWebhookSecret = async (did: string, name: string): Promise<{ token: string; createdAt: string }> => { + const token = generateSecretToken() + try { + const rows = await db` + INSERT INTO webhook_secrets (did, name, token, created_at) + VALUES (${did}, ${name}, ${token}, NOW()) + RETURNING created_at + ` + return { token, createdAt: new Date(rows[0].created_at).toISOString() } + } catch (_err) { + throw new Error('already_exists') + } +} + +export const listWebhookSecrets = async ( + did: string, +): Promise> => { + const rows = await db>` + SELECT name, created_at, last_rotated_at + FROM webhook_secrets + WHERE did = ${did} + ORDER BY created_at ASC + ` + return rows.map((r) => ({ + name: r.name, + createdAt: new Date(r.created_at).toISOString(), + lastRotatedAt: r.last_rotated_at ? new Date(r.last_rotated_at).toISOString() : undefined, + })) +} + +export const deleteWebhookSecret = async (did: string, name: string): Promise => { + const result = await db` + DELETE FROM webhook_secrets WHERE did = ${did} AND name = ${name} + ` + return (result as any).count > 0 +} + +export const rotateWebhookSecret = async ( + did: string, + name: string, +): Promise<{ token: string; rotatedAt: string } | null> => { + const token = generateSecretToken() + const rows = await db` + UPDATE webhook_secrets + SET token = ${token}, last_rotated_at = NOW() + WHERE did = ${did} AND name = ${name} + RETURNING last_rotated_at + ` + if (rows.length === 0) return null + return { token, rotatedAt: new Date(rows[0].last_rotated_at).toISOString() } +} + +export const getWebhookSecretToken = async (did: string, name: string): Promise => { + const rows = await db>` + SELECT token FROM webhook_secrets WHERE did = ${did} AND name = ${name} LIMIT 1 + ` + return rows[0]?.token ?? null +} + /** * Close database connection * Call this during graceful shutdown diff --git a/apps/main-app/src/routes/secret.ts b/apps/main-app/src/routes/secret.ts new file mode 100644 index 0000000..ef9d77b --- /dev/null +++ b/apps/main-app/src/routes/secret.ts @@ -0,0 +1,97 @@ +import type { NodeOAuthClient } from '@atproto/oauth-client-node' +import { createLogger } from '@wispplace/observability' +import { Elysia, t } from 'elysia' +import { createWebhookSecret, deleteWebhookSecret, listWebhookSecrets, rotateWebhookSecret } from '../lib/db' +import { requireAuth } from '../lib/wisp-auth' + +const logger = createLogger('main-app') + +export const secretRoutes = (client: NodeOAuthClient, cookieSecret: string) => + new Elysia({ + prefix: '/api/secret', + cookie: { secrets: cookieSecret, sign: ['did'] }, + }) + .derive(async ({ cookie }) => { + const auth = await requireAuth(client, cookie) + return { auth } + }) + /** + * GET /api/secret + * Lists signing secrets (names + metadata only, never tokens) for the authenticated user. + */ + .get('/', async ({ auth, set }) => { + try { + const secrets = await listWebhookSecrets(auth.did) + return { success: true, secrets } + } catch (err) { + logger.error('[Secret] List error', err) + set.status = 500 + return { success: false, error: 'Failed to list secrets' } + } + }) + /** + * POST /api/secret + * Creates a new signing secret. Returns the token once — it is not stored in plaintext. + */ + .post( + '/', + async ({ body, auth, set }) => { + try { + const { token, createdAt } = await createWebhookSecret(auth.did, body.name) + logger.info(`[Secret] Created secret "${body.name}" for ${auth.did}`) + return { success: true, name: body.name, token, createdAt } + } catch (err) { + const msg = err instanceof Error ? err.message : '' + if (msg === 'already_exists') { + set.status = 409 + return { success: false, error: 'A secret with that name already exists' } + } + logger.error('[Secret] Create error', err) + set.status = 500 + return { success: false, error: 'Failed to create secret' } + } + }, + { + body: t.Object({ + name: t.String({ minLength: 1 }), + }), + }, + ) + /** + * DELETE /api/secret/:name + * Deletes a signing secret by name. + */ + .delete('/:name', async ({ params, auth, set }) => { + try { + const deleted = await deleteWebhookSecret(auth.did, params.name) + if (!deleted) { + set.status = 404 + return { success: false, error: 'Secret not found' } + } + logger.info(`[Secret] Deleted secret "${params.name}" for ${auth.did}`) + return { success: true } + } catch (err) { + logger.error('[Secret] Delete error', err) + set.status = 500 + return { success: false, error: 'Failed to delete secret' } + } + }) + /** + * POST /api/secret/:name/rotate + * Rotates a signing secret, returning the new token once. + */ + .post('/:name/rotate', async ({ params, auth, set }) => { + try { + const result = await rotateWebhookSecret(auth.did, params.name) + if (!result) { + set.status = 404 + return { success: false, error: 'Secret not found' } + } + logger.info(`[Secret] Rotated secret "${params.name}" for ${auth.did}`) + return { success: true, name: params.name, token: result.token, rotatedAt: result.rotatedAt } + } catch (err) { + logger.error('[Secret] Rotate error', err) + set.status = 500 + return { success: false, error: 'Failed to rotate secret' } + } + }) diff --git a/apps/main-app/src/routes/webhook.ts b/apps/main-app/src/routes/webhook.ts index d8f683a..762bcb3 100644 --- a/apps/main-app/src/routes/webhook.ts +++ b/apps/main-app/src/routes/webhook.ts @@ -37,7 +37,7 @@ export const webhookRoutes = (client: NodeOAuthClient, cookieSecret: string) => }, url: body.url, ...(body.events && body.events.length > 0 ? { events: body.events } : {}), - ...(body.secret ? { secret: body.secret } : {}), + ...(body.secretId ? { secretId: body.secretId } : body.secret ? { secret: body.secret } : {}), enabled: body.enabled ?? true, createdAt: new Date().toISOString(), } @@ -65,6 +65,7 @@ export const webhookRoutes = (client: NodeOAuthClient, cookieSecret: string) => backlinks: t.Optional(t.Boolean()), events: t.Optional(t.Array(t.Union([t.Literal('create'), t.Literal('update'), t.Literal('delete')]))), secret: t.Optional(t.String()), + secretId: t.Optional(t.String()), enabled: t.Optional(t.Boolean()), }), }, diff --git a/apps/main-app/src/routes/xrpc.ts b/apps/main-app/src/routes/xrpc.ts index 081fc68..63f3ee8 100644 --- a/apps/main-app/src/routes/xrpc.ts +++ b/apps/main-app/src/routes/xrpc.ts @@ -10,6 +10,10 @@ import { PlaceWispV2DomainDelete, PlaceWispV2DomainGetList, PlaceWispV2DomainGetStatus, + PlaceWispV2SecretCreate, + PlaceWispV2SecretDelete, + PlaceWispV2SecretList, + PlaceWispV2SecretRotate, PlaceWispV2SiteDelete, PlaceWispV2SiteGetDomains, PlaceWispV2SiteGetList, @@ -20,7 +24,9 @@ import { Elysia } from 'elysia' import { claimCustomDomain, claimDomain, + createWebhookSecret, deleteCustomDomain, + deleteWebhookSecret, deleteWispDomain, getAllWispDomains, getCustomDomainInfo, @@ -28,6 +34,8 @@ import { getDomainsBySite, getSitesByDid, isDomainRegistered, + listWebhookSecrets, + rotateWebhookSecret, updateCustomDomainRkey, updateWispDomainSite, } from '../lib/db' @@ -80,6 +88,10 @@ const XRPC_NSIDS = { claim: 'place.wisp.v2.domain.claim', delete: 'place.wisp.v2.domain.delete', deleteSite: 'place.wisp.v2.site.delete', + secretCreate: 'place.wisp.v2.secret.create', + secretList: 'place.wisp.v2.secret.list', + secretDelete: 'place.wisp.v2.secret.delete', + secretRotate: 'place.wisp.v2.secret.rotate', } as const const toIsoFromEpoch = (epoch: unknown): string | undefined => { @@ -180,6 +192,14 @@ const invalidRequest = (description: string): never => { }) } +const alreadyExists = (description: string): never => { + throw new XRPCError({ + status: 409, + error: 'AlreadyExists', + description, + }) +} + const requireAuthenticated = (auth: XrpcAuthContext | undefined): XrpcAuthContext => { if (!auth) { authRequired() @@ -531,6 +551,10 @@ export const xrpcRoutes = () => { XRPC_NSIDS.claim, XRPC_NSIDS.delete, XRPC_NSIDS.deleteSite, + XRPC_NSIDS.secretCreate, + XRPC_NSIDS.secretList, + XRPC_NSIDS.secretDelete, + XRPC_NSIDS.secretRotate, ] addProcedureWithAliases( @@ -784,6 +808,70 @@ export const xrpcRoutes = () => { }, ) + addProcedureWithAliases( + router, + withNsid(PlaceWispV2SecretCreate.mainSchema as any, XRPC_NSIDS.secretCreate), + [], + { + async handler({ input, request }) { + const auth = requireAuthenticated(authByRequest.get(request)) + const name = input.name?.trim() + if (!name) invalidRequest('name is required') + try { + const { token, createdAt } = await createWebhookSecret(auth.did, name!) + return json({ name: name!, token, createdAt }) + } catch { + return alreadyExists('a secret with that name already exists') + } + }, + }, + ) + + addQueryWithAliases( + router, + withNsid(PlaceWispV2SecretList.mainSchema as any, XRPC_NSIDS.secretList), + [], + { + async handler({ request }) { + const auth = requireAuthenticated(authByRequest.get(request)) + const secrets = await listWebhookSecrets(auth.did) + return json({ secrets }) + }, + }, + ) + + addProcedureWithAliases( + router, + withNsid(PlaceWispV2SecretDelete.mainSchema as any, XRPC_NSIDS.secretDelete), + [], + { + async handler({ input, request }) { + const auth = requireAuthenticated(authByRequest.get(request)) + const name = input.name?.trim() + if (!name) invalidRequest('name is required') + const deleted = await deleteWebhookSecret(auth.did, name) + if (!deleted) notFound('secret not found') + return json({}) + }, + }, + ) + + addProcedureWithAliases( + router, + withNsid(PlaceWispV2SecretRotate.mainSchema as any, XRPC_NSIDS.secretRotate), + [], + { + async handler({ input, request }) { + const auth = requireAuthenticated(authByRequest.get(request)) + const name = input.name?.trim() + if (!name) invalidRequest('name is required') + const result = await rotateWebhookSecret(auth.did, name) + if (!result) notFound('secret not found') + return json({ name, token: result!.token, rotatedAt: result!.rotatedAt }) + }, + }, + ) + const schemaNsids = { addSite: (PlaceWispV2DomainAddSite.mainSchema as any).nsid, getStatus: (PlaceWispV2DomainGetStatus.mainSchema as any).nsid, diff --git a/apps/webhook-service/bench/e2e.ts b/apps/webhook-service/bench/e2e.ts new file mode 100644 index 0000000..a90cdfa --- /dev/null +++ b/apps/webhook-service/bench/e2e.ts @@ -0,0 +1,366 @@ +/** + * End-to-end integration test against a real PDS and Jetstream. + * + * Flow: + * 1. Spin up a local delivery server to receive webhook POSTs. + * 2. Connect JetstreamClient watching the test DID. + * 3. Create place.wisp.v2.wh on PDS → Jetstream delivers it → lands in local DB. + * 4. Create app.bsky.feed.post → direct match fires → delivery #1. + * 5. Create app.bsky.feed.like at the post → backlink fires → delivery #2. + * 6. Delete all created records. Print timing. + * + * Env vars (set in .env or prefix the command): + * TEST_PDS_HANDLE default: testacc.sharkgirl.pet + * TEST_PDS_PASSWORD required + * TEST_PDS_URL optional override for PDS base URL + * JETSTREAM_URL optional override + * TEST_DELIVERY_PORT default: 19876 + */ + +import { createHmac } from 'node:crypto' +import type { Main as WhRecord } from '@wispplace/lexicons/types/place/wisp/v2/wh' +import { JetstreamClient } from '../src/lib/jetstream' +import { db, deleteWebhookRecord, findBacklinkWebhooks, findWebhooksForDid, getWebhookSecretToken, upsertWebhookRecord } from '../src/lib/db' +import { matchWebhooks } from '../src/lib/matcher' +import { deliverWebhook } from '../src/lib/delivery' + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +const HANDLE = process.env.TEST_PDS_HANDLE ?? 'testacc.sharkgirl.pet' +const PASSWORD = process.env.TEST_PDS_PASSWORD +const JETSTREAM_URL = process.env.JETSTREAM_URL ?? 'wss://jetstream2.us-east.bsky.network/subscribe' +const DELIVERY_PORT = parseInt(process.env.TEST_DELIVERY_PORT ?? '19876', 10) +const EVENT_TIMEOUT_MS = 30_000 + +if (!PASSWORD) { + console.error('TEST_PDS_PASSWORD is required') + process.exit(1) +} + +// --------------------------------------------------------------------------- +// PDS helpers +// --------------------------------------------------------------------------- + +async function derivePdsUrl(handle: string): Promise { + if (process.env.TEST_PDS_URL) return process.env.TEST_PDS_URL.replace(/\/$/, '') + // For subdomain handles like testacc.sharkgirl.pet, try the parent domain + const parts = handle.split('.') + if (parts.length >= 2) { + const candidate = `https://${parts.slice(-2).join('.')}` + try { + const res = await fetch(`${candidate}/xrpc/com.atproto.server.describeServer`, { + signal: AbortSignal.timeout(5_000), + }) + if (res.ok) return candidate + } catch {} + } + throw new Error(`Could not derive PDS URL for ${handle}. Set TEST_PDS_URL explicitly.`) +} + +interface Session { + did: string + accessJwt: string +} + +async function createSession(pdsUrl: string, identifier: string, password: string): Promise { + const res = await fetch(`${pdsUrl}/xrpc/com.atproto.server.createSession`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identifier, password }), + signal: AbortSignal.timeout(10_000), + }) + if (!res.ok) throw new Error(`createSession failed: ${res.status} ${await res.text()}`) + return res.json() as Promise +} + +async function createRecord( + pdsUrl: string, + jwt: string, + repo: string, + collection: string, + record: Record, + rkey?: string, +): Promise<{ uri: string; cid: string }> { + const body: Record = { repo, collection, record } + if (rkey) body.rkey = rkey + const res = await fetch(`${pdsUrl}/xrpc/com.atproto.repo.createRecord`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${jwt}` }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10_000), + }) + if (!res.ok) throw new Error(`createRecord (${collection}) failed: ${res.status} ${await res.text()}`) + return res.json() as Promise<{ uri: string; cid: string }> +} + +async function deleteRecord(pdsUrl: string, jwt: string, repo: string, collection: string, rkey: string): Promise { + const res = await fetch(`${pdsUrl}/xrpc/com.atproto.repo.deleteRecord`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${jwt}` }, + body: JSON.stringify({ repo, collection, rkey }), + signal: AbortSignal.timeout(10_000), + }) + if (!res.ok) console.warn(` deleteRecord ${collection}/${rkey}: ${res.status}`) +} + +// --------------------------------------------------------------------------- +// Local delivery server +// --------------------------------------------------------------------------- + +const deliveries: Array<{ ts: number; body: unknown; signature?: string; rawBody: string }> = [] +let resolveDelivery: (() => void) | null = null +let expectedDeliveries = 0 + +const deliveryServer = Bun.serve({ + port: DELIVERY_PORT, + routes: { + '/': { + POST: async (req) => { + const rawBody = await req.text() + const signature = req.headers.get('x-webhook-signature') ?? undefined + const body = JSON.parse(rawBody) + deliveries.push({ ts: Date.now(), body, signature, rawBody }) + console.log(` [delivery #${deliveries.length}] sig=${signature ?? 'none'} ${JSON.stringify(body).slice(0, 80)}`) + if (deliveries.length >= expectedDeliveries) resolveDelivery?.() + return new Response('ok') + }, + }, + }, + fetch: () => new Response('Not Found', { status: 404 }), +}) + +function waitForDeliveries(n: number): Promise { + expectedDeliveries = n + if (deliveries.length >= n) return Promise.resolve() + return new Promise((resolve, reject) => { + resolveDelivery = resolve + setTimeout(() => reject(new Error(`Timed out waiting for ${n} deliveries (got ${deliveries.length})`)), EVENT_TIMEOUT_MS) + }) +} + +// --------------------------------------------------------------------------- +// Jetstream event handler +// --------------------------------------------------------------------------- + +const testDid: { value: string } = { value: '' } +let resolveWhRegistered: (() => void) | null = null +const whRegistered = new Promise((resolve) => { resolveWhRegistered = resolve }) + +async function handleEvent(event: { kind: string; did: string; commit?: { operation: string; collection: string; rkey: string; record?: unknown; cid?: string } }) { + if (event.kind !== 'commit' || !event.commit) return + const { did } = event + const { operation: op, collection, rkey, record, cid } = event.commit + if (op !== 'create' && op !== 'update' && op !== 'delete') return + + if (collection === 'place.wisp.v2.wh') { + if (op === 'delete') { + await deleteWebhookRecord(did, rkey) + } else if (record) { + const wh = record as WhRecord + if (wh.scope?.aturi && wh.url) { + await upsertWebhookRecord(did, rkey, wh) + console.log(` [wh registered] ${did}/${rkey} scope=${wh.scope.aturi} backlinks=${wh.scope.backlinks ?? false}`) + resolveWhRegistered?.() + } + } + return + } + + // Only process events from the test DID to avoid noise + if (did !== testDid.value) return + + const directCandidates = await findWebhooksForDid(did) + const backlinkCandidates = await findBacklinkWebhooks() + const seen = new Set(directCandidates.map((e) => `${e.ownerDid}/${e.rkey}`)) + const candidates = [...directCandidates] + for (const entry of backlinkCandidates) { + const k = `${entry.ownerDid}/${entry.rkey}` + if (!seen.has(k)) { seen.add(k); candidates.push(entry) } + } + + if (candidates.length === 0) return + + const matched = matchWebhooks(candidates, did, collection, rkey, op as any, record ?? null) + for (const entry of matched) { + await deliverWebhook(entry, did, collection, rkey, op as any, cid, record) + } +} + +// --------------------------------------------------------------------------- +// DB secret helpers (mirrors main-app logic without importing it) +// --------------------------------------------------------------------------- + +async function insertTestSecret(did: string, name: string): Promise { + const bytes = crypto.getRandomValues(new Uint8Array(24)) + const token = `wsk_${Buffer.from(bytes).toString('base64url')}` + await db` + INSERT INTO webhook_secrets (did, name, token, created_at) + VALUES (${did}, ${name}, ${token}, NOW()) + ON CONFLICT (did, name) DO UPDATE SET token = EXCLUDED.token, last_rotated_at = NOW() + ` + return token +} + +async function deleteTestSecret(did: string, name: string): Promise { + await db`DELETE FROM webhook_secrets WHERE did = ${did} AND name = ${name}` +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const createdRecords: Array<{ collection: string; rkey: string }> = [] + +async function run() { + console.log(`\n=== wisp webhook e2e ===`) + console.log(`PDS handle : ${HANDLE}`) + console.log(`Jetstream : ${JETSTREAM_URL}`) + console.log(`Delivery : http://localhost:${DELIVERY_PORT}/`) + console.log() + + // 1. Auth + const pdsUrl = await derivePdsUrl(HANDLE) + console.log(`PDS URL : ${pdsUrl}`) + const session = await createSession(pdsUrl, HANDLE, PASSWORD!) + testDid.value = session.did + console.log(`Test DID : ${session.did}\n`) + + // 2. Jetstream + const js = new JetstreamClient({ + url: JETSTREAM_URL, + wantedDids: [session.did], + onEvent: handleEvent as any, + onConnect: () => console.log('[jetstream] connected'), + onDisconnect: () => console.log('[jetstream] disconnected'), + onError: (err) => console.error('[jetstream] error:', err.message), + }) + js.start() + // Give the WS a moment to connect before creating records + await Bun.sleep(1_500) + + const deliveryUrl = `http://localhost:${DELIVERY_PORT}/` + const scopeAturi = `at://${session.did}/app.bsky.feed.post` + + try { + // 3. Create place.wisp.v2.wh record + console.log('--- step 1: create place.wisp.v2.wh ---') + const t0 = Date.now() + const whRkey = `bench-wh-${Date.now()}` + const { uri: whUri } = await createRecord(pdsUrl, session.accessJwt, session.did, 'place.wisp.v2.wh', { + $type: 'place.wisp.v2.wh', + scope: { $type: 'place.wisp.v2.wh#atUri', aturi: scopeAturi, backlinks: true }, + url: deliveryUrl, + events: ['create'], + enabled: true, + createdAt: new Date().toISOString(), + }, whRkey) + createdRecords.push({ collection: 'place.wisp.v2.wh', rkey: whRkey }) + console.log(` created ${whUri}`) + + await Promise.race([ + whRegistered, + new Promise((_, reject) => setTimeout(() => reject(new Error('Timed out waiting for wh to be registered in DB')), EVENT_TIMEOUT_MS)), + ]) + console.log(` registered in DB in ${Date.now() - t0}ms\n`) + + // 4. Create app.bsky.feed.post → direct match + console.log('--- step 2: create post (expect delivery #1 — direct match) ---') + const t1 = Date.now() + const { uri: postUri, cid: postCid } = await createRecord(pdsUrl, session.accessJwt, session.did, 'app.bsky.feed.post', { + $type: 'app.bsky.feed.post', + text: 'wisp webhook e2e test post', + createdAt: new Date().toISOString(), + }) + const postRkey = postUri.split('/').at(-1)! + createdRecords.push({ collection: 'app.bsky.feed.post', rkey: postRkey }) + console.log(` created ${postUri}`) + + await waitForDeliveries(1) + console.log(` delivery #1 received in ${Date.now() - t1}ms\n`) + + // 5. Create app.bsky.feed.like referencing the post → backlink match + console.log('--- step 3: like post (expect delivery #2 — backlink match) ---') + const t2 = Date.now() + const { uri: likeUri } = await createRecord(pdsUrl, session.accessJwt, session.did, 'app.bsky.feed.like', { + $type: 'app.bsky.feed.like', + subject: { uri: postUri, cid: postCid }, + createdAt: new Date().toISOString(), + }) + const likeRkey = likeUri.split('/').at(-1)! + createdRecords.push({ collection: 'app.bsky.feed.like', rkey: likeRkey }) + console.log(` created ${likeUri}`) + + await waitForDeliveries(2) + console.log(` delivery #2 received in ${Date.now() - t2}ms\n`) + + // 6. SecretId signing test + console.log('--- step 4: secretId signing ---') + const secretName = `e2e-test-${Date.now()}` + const secretToken = await insertTestSecret(session.did, secretName) + console.log(` inserted secret "${secretName}"`) + + // Verify getWebhookSecretToken can look it up + const lookedUp = await getWebhookSecretToken(session.did, secretName) + if (lookedUp !== secretToken) throw new Error(`getWebhookSecretToken mismatch: got ${lookedUp}`) + console.log(` getWebhookSecretToken ✓`) + + // Create a webhook with secretId, deliver manually, check signature + const signedWhRkey = `bench-wh-signed-${Date.now()}` + const signedWh: WhRecord = { + $type: 'place.wisp.v2.wh', + scope: { $type: 'place.wisp.v2.wh#atUri', aturi: `at://${session.did}/app.bsky.feed.post` }, + url: deliveryUrl, + secretId: secretName, + enabled: true, + createdAt: new Date().toISOString(), + } + await upsertWebhookRecord(session.did, signedWhRkey, signedWh) + + // Deliver a fake event + const beforeCount = deliveries.length + await deliverWebhook( + { ownerDid: session.did, rkey: signedWhRkey, record: signedWh }, + session.did, 'app.bsky.feed.post', 'test-rkey', 'create', undefined, { text: 'signed test' }, + ) + + // Wait for it + await new Promise((resolve, reject) => { + const check = () => { if (deliveries.length > beforeCount) resolve() } + check() + const iv = setInterval(check, 50) + setTimeout(() => { clearInterval(iv); reject(new Error('Timed out waiting for signed delivery')) }, EVENT_TIMEOUT_MS) + }) + + const signedDelivery = deliveries.at(-1)! + if (!signedDelivery.signature) throw new Error('Expected X-Webhook-Signature header but got none') + + const expected = `sha256=${createHmac('sha256', secretToken).update(signedDelivery.rawBody).digest('hex')}` + if (signedDelivery.signature !== expected) { + throw new Error(`Signature mismatch:\n got: ${signedDelivery.signature}\n expected: ${expected}`) + } + console.log(` X-Webhook-Signature ✓ ${signedDelivery.signature.slice(0, 32)}...`) + + // Cleanup secret + wh record + await deleteWebhookRecord(session.did, signedWhRkey) + await deleteTestSecret(session.did, secretName) + console.log(` cleaned up secret + webhook record\n`) + + console.log(`=== passed ✓ total time ${Date.now() - t0}ms ===`) + } finally { + // Cleanup + console.log('\n--- cleanup ---') + for (const { collection, rkey } of createdRecords.reverse()) { + await deleteRecord(pdsUrl, session.accessJwt, session.did, collection, rkey) + console.log(` deleted ${collection}/${rkey}`) + } + js.destroy() + deliveryServer.stop(true) + } +} + +run().catch((err) => { + console.error('\n=== failed ✗ ===\n', err.message) + process.exit(1) +}) diff --git a/apps/webhook-service/package.json b/apps/webhook-service/package.json index c1072c4..2cc426c 100644 --- a/apps/webhook-service/package.json +++ b/apps/webhook-service/package.json @@ -5,7 +5,8 @@ "scripts": { "dev": "bun --env-file=.env src/index.ts", "start": "bun src/index.ts", - "check": "tsc --noEmit" + "check": "tsc --noEmit", + "bench:e2e": "bun bench/e2e.ts" }, "dependencies": { "@atproto/identity": "^0.4.10", diff --git a/apps/webhook-service/src/lib/db.ts b/apps/webhook-service/src/lib/db.ts index d4251bd..1001446 100644 --- a/apps/webhook-service/src/lib/db.ts +++ b/apps/webhook-service/src/lib/db.ts @@ -72,6 +72,17 @@ await db` ON webhook_event_logs (owner_did, delivered_at DESC) ` +await db` + CREATE TABLE IF NOT EXISTS webhook_secrets ( + did TEXT NOT NULL, + name TEXT NOT NULL, + token TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_rotated_at TIMESTAMPTZ, + PRIMARY KEY (did, name) + ) +` + /** * Find all webhook records whose scope AT-URI targets the given DID. * Matches exact DID scope (`at://did`) and collection/rkey sub-scopes (`at://did/...`). @@ -311,6 +322,14 @@ export async function listAllKnownDids(): Promise { return [...dids].sort() } +/** Look up the token for a server-managed signing secret by owner DID + name. */ +export async function getWebhookSecretToken(ownerDid: string, name: string): Promise { + const rows = await db>` + SELECT token FROM webhook_secrets WHERE did = ${ownerDid} AND name = ${name} LIMIT 1 + ` + return rows[0]?.token ?? null +} + /** Close all database connections gracefully. */ export async function closeDatabase(): Promise { await db.close() diff --git a/apps/webhook-service/src/lib/delivery.ts b/apps/webhook-service/src/lib/delivery.ts index e0e3c20..667c6df 100644 --- a/apps/webhook-service/src/lib/delivery.ts +++ b/apps/webhook-service/src/lib/delivery.ts @@ -2,7 +2,7 @@ import { createHmac, randomUUID } from 'node:crypto' import { createLogger } from '@wispplace/observability' import { config } from '../config' import type { WebhookEntry } from './db' -import { insertEventLog } from './db' +import { getWebhookSecretToken, insertEventLog } from './db' import type { EventKind } from './matcher' import { publishWebhookEvent } from './redis' @@ -76,7 +76,11 @@ export async function deliverWebhook( } const body = JSON.stringify(payload) - const signature = record.secret ? sign(record.secret, body) : undefined + let signingSecret: string | undefined = record.secret ?? undefined + if (!signingSecret && record.secretId) { + signingSecret = (await getWebhookSecretToken(ownerDid, record.secretId)) ?? undefined + } + const signature = signingSecret ? sign(signingSecret, body) : undefined for (let attempt_n = 1; attempt_n <= config.deliveryMaxRetries; attempt_n++) { try { diff --git a/apps/webhook-service/src/lib/matcher.ts b/apps/webhook-service/src/lib/matcher.ts index 2ad9075..88b28c3 100644 --- a/apps/webhook-service/src/lib/matcher.ts +++ b/apps/webhook-service/src/lib/matcher.ts @@ -43,48 +43,61 @@ function matchesGlob(pattern: string, value: string): boolean { /** * Recursively walk a parsed record object checking whether any string value - * starts with `prefix` and has a collection segment matching `collectionRe`. + * starts with `prefix` and has a collection segment matching `collectionRe`, + * and optionally an rkey segment matching `rkey`. */ -function walkForReference(obj: unknown, prefix: string, collectionRe: RegExp | null, exact: string | null): boolean { +function walkForReference( + obj: unknown, + prefix: string, + collectionRe: RegExp | null, + exact: string | null, + rkey: string | undefined, +): boolean { if (typeof obj === 'string') { const idx = obj.indexOf(prefix) if (idx === -1) return false const rest = obj.slice(idx + prefix.length) if (collectionRe === null && exact === null) return true // at://did — any reference - const end = rest.search(/[/"\\]/) - const col = end === -1 ? rest : rest.slice(0, end) + const slashIdx = rest.search(/[/"\\]/) + const col = slashIdx === -1 ? rest : rest.slice(0, slashIdx) if (!col) return false - return exact !== null ? col === exact : collectionRe!.test(col) + const colMatches = exact !== null ? col === exact : collectionRe!.test(col) + if (!colMatches) return false + if (!rkey) return true + if (slashIdx === -1) return false + const afterSlash = rest.slice(slashIdx + 1) + const rkeyEnd = afterSlash.search(/[/"\\]/) + const rkeySegment = rkeyEnd === -1 ? afterSlash : afterSlash.slice(0, rkeyEnd) + return rkeySegment === rkey } if (Array.isArray(obj)) { for (const v of obj) { - if (walkForReference(v, prefix, collectionRe, exact)) return true + if (walkForReference(v, prefix, collectionRe, exact, rkey)) return true } return false } if (obj !== null && typeof obj === 'object') { for (const v of Object.values(obj)) { - if (walkForReference(v, prefix, collectionRe, exact)) return true + if (walkForReference(v, prefix, collectionRe, exact, rkey)) return true } } return false } /** - * Checks whether a record contains a reference to the given DID/collection. + * Checks whether a record contains a reference to the given DID/collection/rkey. * Uses a recursive walk and pre-compiled regex — no JSON.stringify. */ -function containsReference(record: unknown, did: string, collection?: string): boolean { +function containsReference(record: unknown, did: string, collection?: string, rkey?: string): boolean { const prefix = `at://${did}/` if (!collection) { - // Any reference to this DID at all - return walkForReference(record, `at://${did}`, null, null) + return walkForReference(record, `at://${did}`, null, null, undefined) } const collectionRe = collection.includes('*') ? compileGlob(collection) : null const exact = collectionRe ? null : collection - return walkForReference(record, prefix, collectionRe, exact) + return walkForReference(record, prefix, collectionRe, exact, rkey) } /** @@ -138,8 +151,8 @@ export function matchWebhooks( continue } - if (backlinks && eventDid !== scope.did && eventRecord != null) { - if (containsReference(eventRecord, scope.did, scope.collection)) { + if (backlinks && eventRecord != null) { + if (containsReference(eventRecord, scope.did, scope.collection, scope.rkey)) { matched.push(entry) } } diff --git a/lexicons/secret-create-v2.json b/lexicons/secret-create-v2.json new file mode 100644 index 0000000..c1b97bf --- /dev/null +++ b/lexicons/secret-create-v2.json @@ -0,0 +1,44 @@ +{ + "lexicon": 1, + "id": "place.wisp.v2.secret.create", + "defs": { + "main": { + "type": "procedure", + "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.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "format": "record-key", + "description": "Unique name for this secret, scoped to the caller DID." + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["name", "token", "createdAt"], + "properties": { + "name": { "type": "string" }, + "token": { + "type": "string", + "description": "The signing token. Only returned at creation time — store it now." + }, + "createdAt": { "type": "string", "format": "datetime" } + } + } + }, + "errors": [ + { "name": "AuthenticationRequired" }, + { "name": "InvalidRequest" }, + { "name": "AlreadyExists" } + ] + } + } +} diff --git a/lexicons/secret-delete-v2.json b/lexicons/secret-delete-v2.json new file mode 100644 index 0000000..f5382e1 --- /dev/null +++ b/lexicons/secret-delete-v2.json @@ -0,0 +1,24 @@ +{ + "lexicon": 1, + "id": "place.wisp.v2.secret.delete", + "defs": { + "main": { + "type": "procedure", + "description": "Delete a webhook signing secret by name.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string", "format": "record-key" } + } + } + }, + "errors": [ + { "name": "AuthenticationRequired" }, + { "name": "NotFound" } + ] + } + } +} diff --git a/lexicons/secret-list-v2.json b/lexicons/secret-list-v2.json new file mode 100644 index 0000000..bf3c668 --- /dev/null +++ b/lexicons/secret-list-v2.json @@ -0,0 +1,35 @@ +{ + "lexicon": 1, + "id": "place.wisp.v2.secret.list", + "defs": { + "main": { + "type": "query", + "description": "List webhook signing secrets for the caller DID. Token values are never returned.", + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["secrets"], + "properties": { + "secrets": { + "type": "array", + "items": { "type": "ref", "ref": "#secretMeta" } + } + } + } + }, + "errors": [ + { "name": "AuthenticationRequired" } + ] + }, + "secretMeta": { + "type": "object", + "required": ["name", "createdAt"], + "properties": { + "name": { "type": "string" }, + "createdAt": { "type": "string", "format": "datetime" }, + "lastRotatedAt": { "type": "string", "format": "datetime" } + } + } + } +} diff --git a/lexicons/secret-rotate-v2.json b/lexicons/secret-rotate-v2.json new file mode 100644 index 0000000..a307702 --- /dev/null +++ b/lexicons/secret-rotate-v2.json @@ -0,0 +1,39 @@ +{ + "lexicon": 1, + "id": "place.wisp.v2.secret.rotate", + "defs": { + "main": { + "type": "procedure", + "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.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string", "format": "record-key" } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["name", "token", "rotatedAt"], + "properties": { + "name": { "type": "string" }, + "token": { + "type": "string", + "description": "The new signing token. Only returned here — store it now." + }, + "rotatedAt": { "type": "string", "format": "datetime" } + } + } + }, + "errors": [ + { "name": "AuthenticationRequired" }, + { "name": "NotFound" } + ] + } + } +} diff --git a/lexicons/webhook-v1.json b/lexicons/webhook-v1.json index 178708d..26c4a2c 100644 --- a/lexicons/webhook-v1.json +++ b/lexicons/webhook-v1.json @@ -41,7 +41,12 @@ "secret": { "type": "string", "maxLength": 256, - "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." + "description": "Optional raw secret used to sign the webhook payload with HMAC-SHA256. Prefer secretId to avoid embedding plaintext values in PDS records." + }, + "secretId": { + "type": "string", + "format": "record-key", + "description": "Name of a server-managed signing secret created via place.wisp.v2.secret.create. Takes precedence over secret if both are present." }, "enabled": { "type": "boolean", diff --git a/packages/@wispplace/lexicons/src/atcute/lexicons/index.ts b/packages/@wispplace/lexicons/src/atcute/lexicons/index.ts index 5036e24..b061129 100644 --- a/packages/@wispplace/lexicons/src/atcute/lexicons/index.ts +++ b/packages/@wispplace/lexicons/src/atcute/lexicons/index.ts @@ -8,6 +8,10 @@ export * as PlaceWispV2DomainDelete from "./types/place/wisp/v2/domain/delete.js export * as PlaceWispV2DomainGetList from "./types/place/wisp/v2/domain/getList.js"; export * as PlaceWispV2DomainGetStatus from "./types/place/wisp/v2/domain/getStatus.js"; export * as PlaceWispV2Domains from "./types/place/wisp/v2/domains.js"; +export * as PlaceWispV2SecretCreate from "./types/place/wisp/v2/secret/create.js"; +export * as PlaceWispV2SecretDelete from "./types/place/wisp/v2/secret/delete.js"; +export * as PlaceWispV2SecretList from "./types/place/wisp/v2/secret/list.js"; +export * as PlaceWispV2SecretRotate from "./types/place/wisp/v2/secret/rotate.js"; export * as PlaceWispV2SiteDelete from "./types/place/wisp/v2/site/delete.js"; export * as PlaceWispV2SiteGetDomains from "./types/place/wisp/v2/site/getDomains.js"; export * as PlaceWispV2SiteGetList from "./types/place/wisp/v2/site/getList.js"; diff --git a/packages/@wispplace/lexicons/src/atcute/lexicons/types/place/wisp/v2/secret/create.ts b/packages/@wispplace/lexicons/src/atcute/lexicons/types/place/wisp/v2/secret/create.ts new file mode 100644 index 0000000..e9aaa48 --- /dev/null +++ b/packages/@wispplace/lexicons/src/atcute/lexicons/types/place/wisp/v2/secret/create.ts @@ -0,0 +1,43 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; + +const _mainSchema = /*#__PURE__*/ v.procedure("place.wisp.v2.secret.create", { + params: null, + input: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + /** + * Unique name for this secret, scoped to the caller DID. + */ + name: /*#__PURE__*/ v.recordKeyString(), + }), + }, + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + createdAt: /*#__PURE__*/ v.datetimeString(), + name: /*#__PURE__*/ v.string(), + /** + * The signing token. Only returned at creation time — store it now. + */ + token: /*#__PURE__*/ v.string(), + }), + }, +}); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface $params {} +export interface $input extends v.InferXRPCBodyInput {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCProcedures { + "place.wisp.v2.secret.create": mainSchema; + } +} diff --git a/packages/@wispplace/lexicons/src/atcute/lexicons/types/place/wisp/v2/secret/delete.ts b/packages/@wispplace/lexicons/src/atcute/lexicons/types/place/wisp/v2/secret/delete.ts new file mode 100644 index 0000000..f62377c --- /dev/null +++ b/packages/@wispplace/lexicons/src/atcute/lexicons/types/place/wisp/v2/secret/delete.ts @@ -0,0 +1,29 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; + +const _mainSchema = /*#__PURE__*/ v.procedure("place.wisp.v2.secret.delete", { + params: null, + input: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + name: /*#__PURE__*/ v.recordKeyString(), + }), + }, + output: null, +}); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface $params {} +export interface $input extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCProcedures { + "place.wisp.v2.secret.delete": mainSchema; + } +} diff --git a/packages/@wispplace/lexicons/src/atcute/lexicons/types/place/wisp/v2/secret/list.ts b/packages/@wispplace/lexicons/src/atcute/lexicons/types/place/wisp/v2/secret/list.ts new file mode 100644 index 0000000..3f37d0e --- /dev/null +++ b/packages/@wispplace/lexicons/src/atcute/lexicons/types/place/wisp/v2/secret/list.ts @@ -0,0 +1,43 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; + +const _mainSchema = /*#__PURE__*/ v.query("place.wisp.v2.secret.list", { + params: null, + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + get secrets() { + return /*#__PURE__*/ v.array(secretMetaSchema); + }, + }), + }, +}); +const _secretMetaSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("place.wisp.v2.secret.list#secretMeta"), + ), + createdAt: /*#__PURE__*/ v.datetimeString(), + lastRotatedAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), + name: /*#__PURE__*/ v.string(), +}); + +type main$schematype = typeof _mainSchema; +type secretMeta$schematype = typeof _secretMetaSchema; + +export interface mainSchema extends main$schematype {} +export interface secretMetaSchema extends secretMeta$schematype {} + +export const mainSchema = _mainSchema as mainSchema; +export const secretMetaSchema = _secretMetaSchema as secretMetaSchema; + +export interface SecretMeta extends v.InferInput {} + +export interface $params {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCQueries { + "place.wisp.v2.secret.list": mainSchema; + } +} diff --git a/packages/@wispplace/lexicons/src/atcute/lexicons/types/place/wisp/v2/secret/rotate.ts b/packages/@wispplace/lexicons/src/atcute/lexicons/types/place/wisp/v2/secret/rotate.ts new file mode 100644 index 0000000..a03888c --- /dev/null +++ b/packages/@wispplace/lexicons/src/atcute/lexicons/types/place/wisp/v2/secret/rotate.ts @@ -0,0 +1,40 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; + +const _mainSchema = /*#__PURE__*/ v.procedure("place.wisp.v2.secret.rotate", { + params: null, + input: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + name: /*#__PURE__*/ v.recordKeyString(), + }), + }, + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + name: /*#__PURE__*/ v.string(), + rotatedAt: /*#__PURE__*/ v.datetimeString(), + /** + * The new signing token. Only returned here — store it now. + */ + token: /*#__PURE__*/ v.string(), + }), + }, +}); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface $params {} +export interface $input extends v.InferXRPCBodyInput {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCProcedures { + "place.wisp.v2.secret.rotate": mainSchema; + } +} diff --git a/packages/@wispplace/lexicons/src/atcute/lexicons/types/place/wisp/v2/wh.ts b/packages/@wispplace/lexicons/src/atcute/lexicons/types/place/wisp/v2/wh.ts index 006a001..6480472 100644 --- a/packages/@wispplace/lexicons/src/atcute/lexicons/types/place/wisp/v2/wh.ts +++ b/packages/@wispplace/lexicons/src/atcute/lexicons/types/place/wisp/v2/wh.ts @@ -43,7 +43,7 @@ const _mainSchema = /*#__PURE__*/ v.record( return atUriSchema; }, /** - * 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. + * Optional raw secret used to sign the webhook payload with HMAC-SHA256. Prefer secretId to avoid embedding plaintext values in PDS records. * @maxLength 256 */ secret: /*#__PURE__*/ v.optional( @@ -51,6 +51,10 @@ const _mainSchema = /*#__PURE__*/ v.record( /*#__PURE__*/ v.stringLength(0, 256), ]), ), + /** + * Name of a server-managed signing secret created via place.wisp.v2.secret.create. Takes precedence over secret if both are present. + */ + secretId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.recordKeyString()), /** * HTTPS endpoint to POST the webhook payload to. * @maxLength 2048 diff --git a/packages/@wispplace/lexicons/src/index.ts b/packages/@wispplace/lexicons/src/index.ts index 16ebb56..de073e2 100644 --- a/packages/@wispplace/lexicons/src/index.ts +++ b/packages/@wispplace/lexicons/src/index.ts @@ -16,6 +16,10 @@ import * as PlaceWispV2DomainClaim from './types/place/wisp/v2/domain/claim.js' import * as PlaceWispV2DomainDelete from './types/place/wisp/v2/domain/delete.js' import * as PlaceWispV2DomainGetList from './types/place/wisp/v2/domain/getList.js' import * as PlaceWispV2DomainGetStatus from './types/place/wisp/v2/domain/getStatus.js' +import * as PlaceWispV2SecretCreate from './types/place/wisp/v2/secret/create.js' +import * as PlaceWispV2SecretDelete from './types/place/wisp/v2/secret/delete.js' +import * as PlaceWispV2SecretList from './types/place/wisp/v2/secret/list.js' +import * as PlaceWispV2SecretRotate from './types/place/wisp/v2/secret/rotate.js' import * as PlaceWispV2SiteDelete from './types/place/wisp/v2/site/delete.js' import * as PlaceWispV2SiteGetDomains from './types/place/wisp/v2/site/getDomains.js' import * as PlaceWispV2SiteGetList from './types/place/wisp/v2/site/getList.js' @@ -57,11 +61,13 @@ export class PlaceWispNS { export class PlaceWispV2NS { _server: Server domain: PlaceWispV2DomainNS + secret: PlaceWispV2SecretNS site: PlaceWispV2SiteNS constructor(server: Server) { this._server = server this.domain = new PlaceWispV2DomainNS(server) + this.secret = new PlaceWispV2SecretNS(server) this.site = new PlaceWispV2SiteNS(server) } } @@ -146,6 +152,62 @@ export class PlaceWispV2DomainNS { } } +export class PlaceWispV2SecretNS { + _server: Server + + constructor(server: Server) { + this._server = server + } + + create( + cfg: MethodConfigOrHandler< + A, + PlaceWispV2SecretCreate.QueryParams, + PlaceWispV2SecretCreate.HandlerInput, + PlaceWispV2SecretCreate.HandlerOutput + >, + ) { + const nsid = 'place.wisp.v2.secret.create' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + + delete( + cfg: MethodConfigOrHandler< + A, + PlaceWispV2SecretDelete.QueryParams, + PlaceWispV2SecretDelete.HandlerInput, + PlaceWispV2SecretDelete.HandlerOutput + >, + ) { + const nsid = 'place.wisp.v2.secret.delete' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + + list( + cfg: MethodConfigOrHandler< + A, + PlaceWispV2SecretList.QueryParams, + PlaceWispV2SecretList.HandlerInput, + PlaceWispV2SecretList.HandlerOutput + >, + ) { + const nsid = 'place.wisp.v2.secret.list' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + + rotate( + cfg: MethodConfigOrHandler< + A, + PlaceWispV2SecretRotate.QueryParams, + PlaceWispV2SecretRotate.HandlerInput, + PlaceWispV2SecretRotate.HandlerOutput + >, + ) { + const nsid = 'place.wisp.v2.secret.rotate' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } +} + export class PlaceWispV2SiteNS { _server: Server diff --git a/packages/@wispplace/lexicons/src/lexicons.ts b/packages/@wispplace/lexicons/src/lexicons.ts index 23cddbb..ce5119d 100644 --- a/packages/@wispplace/lexicons/src/lexicons.ts +++ b/packages/@wispplace/lexicons/src/lexicons.ts @@ -667,6 +667,197 @@ export const schemaDict = { }, }, }, + PlaceWispV2SecretCreate: { + lexicon: 1, + id: 'place.wisp.v2.secret.create', + defs: { + main: { + type: 'procedure', + 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.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['name'], + properties: { + name: { + type: 'string', + format: 'record-key', + description: + 'Unique name for this secret, scoped to the caller DID.', + }, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['name', 'token', 'createdAt'], + properties: { + name: { + type: 'string', + }, + token: { + type: 'string', + description: + 'The signing token. Only returned at creation time — store it now.', + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + }, + }, + }, + errors: [ + { + name: 'AuthenticationRequired', + }, + { + name: 'InvalidRequest', + }, + { + name: 'AlreadyExists', + }, + ], + }, + }, + }, + PlaceWispV2SecretDelete: { + lexicon: 1, + id: 'place.wisp.v2.secret.delete', + defs: { + main: { + type: 'procedure', + description: 'Delete a webhook signing secret by name.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['name'], + properties: { + name: { + type: 'string', + format: 'record-key', + }, + }, + }, + }, + errors: [ + { + name: 'AuthenticationRequired', + }, + { + name: 'NotFound', + }, + ], + }, + }, + }, + PlaceWispV2SecretList: { + lexicon: 1, + id: 'place.wisp.v2.secret.list', + defs: { + main: { + type: 'query', + description: + 'List webhook signing secrets for the caller DID. Token values are never returned.', + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['secrets'], + properties: { + secrets: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:place.wisp.v2.secret.list#secretMeta', + }, + }, + }, + }, + }, + errors: [ + { + name: 'AuthenticationRequired', + }, + ], + }, + secretMeta: { + type: 'object', + required: ['name', 'createdAt'], + properties: { + name: { + type: 'string', + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + lastRotatedAt: { + type: 'string', + format: 'datetime', + }, + }, + }, + }, + }, + PlaceWispV2SecretRotate: { + lexicon: 1, + id: 'place.wisp.v2.secret.rotate', + defs: { + main: { + type: 'procedure', + 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.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['name'], + properties: { + name: { + type: 'string', + format: 'record-key', + }, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['name', 'token', 'rotatedAt'], + properties: { + name: { + type: 'string', + }, + token: { + type: 'string', + description: + 'The new signing token. Only returned here — store it now.', + }, + rotatedAt: { + type: 'string', + format: 'datetime', + }, + }, + }, + }, + errors: [ + { + name: 'AuthenticationRequired', + }, + { + name: 'NotFound', + }, + ], + }, + }, + }, PlaceWispSettings: { lexicon: 1, id: 'place.wisp.settings', @@ -1129,7 +1320,13 @@ export const schemaDict = { type: 'string', maxLength: 256, 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.", + 'Optional raw secret used to sign the webhook payload with HMAC-SHA256. Prefer secretId to avoid embedding plaintext values in PDS records.', + }, + secretId: { + type: 'string', + format: 'record-key', + description: + 'Name of a server-managed signing secret created via place.wisp.v2.secret.create. Takes precedence over secret if both are present.', }, enabled: { type: 'boolean', @@ -1203,6 +1400,10 @@ export const ids = { PlaceWispV2DomainGetStatus: 'place.wisp.v2.domain.getStatus', PlaceWispV2Domains: 'place.wisp.v2.domains', PlaceWispFs: 'place.wisp.fs', + PlaceWispV2SecretCreate: 'place.wisp.v2.secret.create', + PlaceWispV2SecretDelete: 'place.wisp.v2.secret.delete', + PlaceWispV2SecretList: 'place.wisp.v2.secret.list', + PlaceWispV2SecretRotate: 'place.wisp.v2.secret.rotate', PlaceWispSettings: 'place.wisp.settings', PlaceWispV2SiteDelete: 'place.wisp.v2.site.delete', PlaceWispV2SiteGetDomains: 'place.wisp.v2.site.getDomains', diff --git a/packages/@wispplace/lexicons/src/types/place/wisp/v2/secret/create.ts b/packages/@wispplace/lexicons/src/types/place/wisp/v2/secret/create.ts new file mode 100644 index 0000000..6623974 --- /dev/null +++ b/packages/@wispplace/lexicons/src/types/place/wisp/v2/secret/create.ts @@ -0,0 +1,48 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../../../lexicons' +import { + type $Typed, + is$typed as _is$typed, + type OmitKey, +} from '../../../../../util' + +const is$typed = _is$typed, + validate = _validate +const id = 'place.wisp.v2.secret.create' + +export type QueryParams = {} + +export interface InputSchema { + /** Unique name for this secret, scoped to the caller DID. */ + name: string +} + +export interface OutputSchema { + name: string + /** The signing token. Only returned at creation time — store it now. */ + token: string + createdAt: string +} + +export interface HandlerInput { + encoding: 'application/json' + body: InputSchema +} + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string + error?: 'AuthenticationRequired' | 'InvalidRequest' | 'AlreadyExists' +} + +export type HandlerOutput = HandlerError | HandlerSuccess diff --git a/packages/@wispplace/lexicons/src/types/place/wisp/v2/secret/delete.ts b/packages/@wispplace/lexicons/src/types/place/wisp/v2/secret/delete.ts new file mode 100644 index 0000000..d23b35d --- /dev/null +++ b/packages/@wispplace/lexicons/src/types/place/wisp/v2/secret/delete.ts @@ -0,0 +1,34 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../../../lexicons' +import { + type $Typed, + is$typed as _is$typed, + type OmitKey, +} from '../../../../../util' + +const is$typed = _is$typed, + validate = _validate +const id = 'place.wisp.v2.secret.delete' + +export type QueryParams = {} + +export interface InputSchema { + name: string +} + +export interface HandlerInput { + encoding: 'application/json' + body: InputSchema +} + +export interface HandlerError { + status: number + message?: string + error?: 'AuthenticationRequired' | 'NotFound' +} + +export type HandlerOutput = HandlerError | void diff --git a/packages/@wispplace/lexicons/src/types/place/wisp/v2/secret/list.ts b/packages/@wispplace/lexicons/src/types/place/wisp/v2/secret/list.ts new file mode 100644 index 0000000..cbe9f89 --- /dev/null +++ b/packages/@wispplace/lexicons/src/types/place/wisp/v2/secret/list.ts @@ -0,0 +1,55 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../../../lexicons' +import { + type $Typed, + is$typed as _is$typed, + type OmitKey, +} from '../../../../../util' + +const is$typed = _is$typed, + validate = _validate +const id = 'place.wisp.v2.secret.list' + +export type QueryParams = {} +export type InputSchema = undefined + +export interface OutputSchema { + secrets: SecretMeta[] +} + +export type HandlerInput = void + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string + error?: 'AuthenticationRequired' +} + +export type HandlerOutput = HandlerError | HandlerSuccess + +export interface SecretMeta { + $type?: 'place.wisp.v2.secret.list#secretMeta' + name: string + createdAt: string + lastRotatedAt?: string +} + +const hashSecretMeta = 'secretMeta' + +export function isSecretMeta(v: V) { + return is$typed(v, id, hashSecretMeta) +} + +export function validateSecretMeta(v: V) { + return validate(v, id, hashSecretMeta) +} diff --git a/packages/@wispplace/lexicons/src/types/place/wisp/v2/secret/rotate.ts b/packages/@wispplace/lexicons/src/types/place/wisp/v2/secret/rotate.ts new file mode 100644 index 0000000..fc965c3 --- /dev/null +++ b/packages/@wispplace/lexicons/src/types/place/wisp/v2/secret/rotate.ts @@ -0,0 +1,47 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../../../lexicons' +import { + type $Typed, + is$typed as _is$typed, + type OmitKey, +} from '../../../../../util' + +const is$typed = _is$typed, + validate = _validate +const id = 'place.wisp.v2.secret.rotate' + +export type QueryParams = {} + +export interface InputSchema { + name: string +} + +export interface OutputSchema { + name: string + /** The new signing token. Only returned here — store it now. */ + token: string + rotatedAt: string +} + +export interface HandlerInput { + encoding: 'application/json' + body: InputSchema +} + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string + error?: 'AuthenticationRequired' | 'NotFound' +} + +export type HandlerOutput = HandlerError | HandlerSuccess diff --git a/packages/@wispplace/lexicons/src/types/place/wisp/v2/wh.ts b/packages/@wispplace/lexicons/src/types/place/wisp/v2/wh.ts index 5525ccd..84f394f 100644 --- a/packages/@wispplace/lexicons/src/types/place/wisp/v2/wh.ts +++ b/packages/@wispplace/lexicons/src/types/place/wisp/v2/wh.ts @@ -21,8 +21,10 @@ export interface Main { url: string /** Which record events to trigger on. Defaults to all events if omitted. */ events?: ('create' | 'update' | 'delete')[] - /** 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. */ + /** Optional raw secret used to sign the webhook payload with HMAC-SHA256. Prefer secretId to avoid embedding plaintext values in PDS records. */ secret?: string + /** Name of a server-managed signing secret created via place.wisp.v2.secret.create. Takes precedence over secret if both are present. */ + secretId?: string /** Whether the webhook is active. Defaults to true if omitted. */ enabled?: boolean /** Timestamp of when the webhook was created. */ -- 2.54.0