From e6a1bde8e5ddfcffb4902d48c1257492ed1ec159 Mon Sep 17 00:00:00 2001 From: Roddy GitHub Date: Fri, 24 Jul 2026 16:44:17 +0200 Subject: [PATCH 01/14] feat(web): extract shared formatDateTime in lib/format + refactor DLQ grid Signed-off-by: Roddy GitHub --- web/src/components/WebhookDlqGrid.tsx | 11 ++--------- web/src/lib/format.ts | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/web/src/components/WebhookDlqGrid.tsx b/web/src/components/WebhookDlqGrid.tsx index bb821a73..7c5eb4a6 100644 --- a/web/src/components/WebhookDlqGrid.tsx +++ b/web/src/components/WebhookDlqGrid.tsx @@ -7,6 +7,7 @@ import { AgGridReact } from "ag-grid-react"; import { type ColDef, type ICellRendererParams } from "ag-grid-community"; import type { WebhookDlqRow } from "@/lib/api"; import { replayDlq } from "@/lib/api"; +import { formatDateTime } from "@/lib/format"; import { appGridTheme } from "./ag-grid-setup"; import styles from "./WebhookDlqGrid.module.css"; @@ -16,14 +17,6 @@ const GRID_CONTAINER_STYLE: React.CSSProperties = { width: "100%", }; -function formatDate(iso: string | null): string { - if (!iso) { - return "—"; - } - const d = new Date(iso); - return Number.isNaN(d.getTime()) ? iso : d.toLocaleString(); -} - export function WebhookDlqGrid({ rows }: { rows: WebhookDlqRow[] }) { const router = useRouter(); const [replaying, setReplaying] = useState>(new Set()); @@ -65,7 +58,7 @@ export function WebhookDlqGrid({ rows }: { rows: WebhookDlqRow[] }) { sortable: true, filter: true, minWidth: 180, - valueFormatter: (p) => formatDate(p.value), + valueFormatter: (p) => formatDateTime(p.value), }, { headerName: "Actions", diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 0c1f24df..2c4bf72a 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -25,3 +25,23 @@ export function formatSecondsLabel(ms: number): string { const rem = s % 60; return `${m}:${rem.toString().padStart(2, "0")}`; } + +/** + * Format an ISO-8601 datetime string for table rows. Returns + * ``"-"`` for null/undefined values so AG Grid columns render + * a stable empty marker. Falls back to the raw string when + * ``new Date()`` returns ``NaN`` (the wire layer sometimes + * emits truncated strings during schema migration windows) so + * a corrupt value never blanks an entire column. + * + * Shared by :class:\`WebhookDlqGrid\` + :class:\`WebhookSubscriptionsGrid\` + * (PR2 wire-shape mirror) so the table-time formatting stays + * in lockstep across the two grids. + */ +export function formatDateTime(iso: string | null | undefined): string { + if (!iso) { + return "—"; + } + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? iso : d.toLocaleString(); +} From cd34a18e176f9fb5bf81a8164318edd9eacfb7ab Mon Sep 17 00:00:00 2001 From: Roddy GitHub Date: Fri, 24 Jul 2026 16:44:18 +0200 Subject: [PATCH 02/14] feat(web): add WebhookSubscriptionRow/Out types + fetch/create/revoke API Signed-off-by: Roddy GitHub --- web/src/lib/api/index.ts | 15 +++++- web/src/lib/api/webhooks.ts | 105 ++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 2 deletions(-) diff --git a/web/src/lib/api/index.ts b/web/src/lib/api/index.ts index 2784c636..7e6e807e 100644 --- a/web/src/lib/api/index.ts +++ b/web/src/lib/api/index.ts @@ -69,5 +69,16 @@ export { fetchPlayerCompareTimeline, } from "./players"; -export type { WebhookDlqRow } from "./webhooks"; -export { fetchWebhookDeliveries, replayDlq } from "./webhooks"; +export type { + WebhookDlqRow, + WebhookSubscriptionRow, + WebhookSubscriptionCreatedRow, + CreateWebhookPayload, +} from "./webhooks"; +export { + fetchWebhookDeliveries, + fetchWebhookSubscriptions, + createWebhook, + revokeWebhook, + replayDlq, +} from "./webhooks"; diff --git a/web/src/lib/api/webhooks.ts b/web/src/lib/api/webhooks.ts index 12b6ebce..a6be1034 100644 --- a/web/src/lib/api/webhooks.ts +++ b/web/src/lib/api/webhooks.ts @@ -9,6 +9,44 @@ export interface WebhookDlqRow { moved_to_dlq_at: string; } +/** + * PR2 wire-shape mirror of the OpenAPI-generated + * ``WebhookSubscriptionOut`` schema. ``created_at`` is a + * date-time string (FastAPI serialises ``datetime`` as ISO-8601); + * the frontend renders it via ``Date.toLocaleString``. ``filter`` + * is a free-form object (the Python ORM column name is ``filter`` + * which shadows the builtin; the wire schema reuses the + * ``filter_payload`` Python attr name as the field key). + * ``description`` is optional and may be ``null`` (the Pydantic + * model defaults to ``None``). + */ +export interface WebhookSubscriptionRow { + id: string; + url: string; + filter: Record | null; + description: string | null; + created_at: string; +} + +/** + * One-shot create response: identical to + * :class:`WebhookSubscriptionRow` PLUS the plaintext ``secret`` + * which the backend emits ONLY on the 201 response (Fernet + * envelope encryption-at-rest for everything past the create + * call per plan 031). Callers MUST surface the ``secret`` in a + * dedicated acknowledgement UI before discarding the response + * to avoid the one-shot-loss bug. + */ +export interface WebhookSubscriptionCreatedRow extends WebhookSubscriptionRow { + secret: string; +} + +export interface CreateWebhookPayload { + url: string; + description?: string | null; + filter?: Record | null; +} + export async function fetchWebhookDeliveries( opts: { subscriptionId?: string; limit?: number; offset?: number } = {}, ): Promise { @@ -35,6 +73,73 @@ export async function fetchWebhookDeliveries( return rows as WebhookDlqRow[]; } +export async function fetchWebhookSubscriptions( + opts: { limit?: number; offset?: number } = {}, +): Promise { + const params = new URLSearchParams(); + if (opts.limit !== undefined) { + params.set("limit", String(opts.limit)); + } + if (opts.offset !== undefined) { + params.set("offset", String(opts.offset)); + } + const qs = params.toString(); + const url = `${API_BASE_URL}/api/v1/webhooks${qs ? `?${qs}` : ""}`; + const resp = await fetch(url, { cache: "no-store" }); + if (!resp.ok) { + throw new ApiError(resp.status, await resp.text()); + } + const rows: unknown = await resp.json(); + if (!Array.isArray(rows)) { + throw new ApiError(500, "upstream returned non-array"); + } + return rows as WebhookSubscriptionRow[]; +} + +/** + * Register a new webhook subscription. The backend returns + * ``201 Created`` with the plaintext ``secret`` which is + * Fernet-encrypted-at-rest for any subsequent fetch (one-shot + * plaintext surface). The caller is responsible for surfacing + * the secret in an acknowledgement UI before discarding the + * response. + */ +export async function createWebhook( + payload: CreateWebhookPayload, +): Promise { + const url = `${API_BASE_URL}/api/v1/webhooks`; + const resp = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: payload.url, + description: payload.description ?? null, + filter: payload.filter ?? {}, + }), + cache: "no-store", + }); + if (!resp.ok) { + throw new ApiError(resp.status, await resp.text()); + } + return (await resp.json()) as WebhookSubscriptionCreatedRow; +} + +/** + * Idempotent soft-delete: the backend responds 204 on success AND + * on a previously-revoked subscription. A genuine unknown-id + * 404 propagates as a real ``ApiError(404, ...)`` so the UI can + * surface the orphaned-row case instead of silently no-op-ing. + */ +export async function revokeWebhook(subscriptionId: string): Promise { + const url = `${API_BASE_URL}/api/v1/webhooks/${encodeURIComponent( + subscriptionId, + )}`; + const resp = await fetch(url, { method: "DELETE", cache: "no-store" }); + if (!resp.ok) { + throw new ApiError(resp.status, await resp.text()); + } +} + export async function replayDlq(deliveryId: string): Promise { const url = `${API_BASE_URL}/api/v1/webhooks/dlq/${encodeURIComponent( deliveryId, From 2f2bfa61be579ebbb9b6c0becbde5f8eecad52b3 Mon Sep 17 00:00:00 2001 From: Roddy GitHub Date: Fri, 24 Jul 2026 16:44:18 +0200 Subject: [PATCH 03/14] feat(web): CreateWebhookPanel -- 3-phase state machine + one-shot secret reveal Signed-off-by: Roddy GitHub --- .../components/CreateWebhookPanel.module.css | 251 +++++++++ web/src/components/CreateWebhookPanel.tsx | 500 ++++++++++++++++++ 2 files changed, 751 insertions(+) create mode 100644 web/src/components/CreateWebhookPanel.module.css create mode 100644 web/src/components/CreateWebhookPanel.tsx diff --git a/web/src/components/CreateWebhookPanel.module.css b/web/src/components/CreateWebhookPanel.module.css new file mode 100644 index 00000000..59b0d855 --- /dev/null +++ b/web/src/components/CreateWebhookPanel.module.css @@ -0,0 +1,251 @@ +.container { + margin-bottom: 16px; +} + +.openButton { + padding: 10px 16px; + border-radius: 8px; + border: 1px solid var(--accent); + background: transparent; + color: var(--accent); + cursor: pointer; + font-size: 14px; + font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif; + transition: opacity 0.15s ease-in-out; +} + +.openButton:hover { + opacity: 0.85; +} + +.form { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface); +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.labelText { + font-size: 13px; + font-weight: 600; + color: var(--foreground); +} + +.input { + padding: 8px 12px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--background); + color: var(--foreground); + font-family: var(--font-geist-mono), ui-monospace, monospace; + font-size: 13px; +} + +.input:focus { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.textarea { + padding: 8px 12px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--background); + color: var(--foreground); + font-family: var(--font-geist-mono), ui-monospace, monospace; + font-size: 13px; + resize: vertical; + min-height: 60px; +} + +.textarea:focus { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.helpText { + font-size: 12px; + color: var(--foreground-muted, var(--foreground)); + opacity: 0.65; +} + +.error { + padding: 8px 12px; + border-radius: 6px; + background: color-mix(in srgb, var(--error, #ff6e6e) 12%, transparent); + border: 1px solid var(--error, #ff6e6e); + color: var(--error, #ff6e6e); + font-size: 13px; +} + +.buttonRow { + display: flex; + gap: 8px; + margin-top: 4px; +} + +.submit { + padding: 10px 16px; + border-radius: 6px; + border: 1px solid var(--accent); + background: var(--accent); + color: var(--accent-foreground, #ffffff); + cursor: pointer; + font-size: 14px; + font-weight: 600; + font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif; + transition: opacity 0.15s ease-in-out; +} + +.submit:hover:not(:disabled) { + opacity: 0.9; +} + +.submit:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.muted { + padding: 10px 16px; + border-radius: 6px; + border: 1px solid var(--border); + background: transparent; + color: var(--foreground); + cursor: pointer; + font-size: 14px; + font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif; + transition: opacity 0.15s ease-in-out; +} + +.muted:hover:not(:disabled) { + opacity: 0.85; +} + +.muted:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.reveal { + padding: 16px; + border-radius: 8px; + border: 1px solid var(--accent); + background: color-mix(in srgb, var(--accent) 10%, var(--surface)); + display: flex; + flex-direction: column; + gap: 12px; + outline: none; +} + +.revealTitle { + font-size: 16px; + font-weight: 700; + color: var(--foreground); + margin: 0; +} + +.revealLede { + font-size: 13px; + line-height: 1.5; + color: var(--foreground); + margin: 0; +} + +.revealLede code { + font-family: var(--font-geist-mono), ui-monospace, monospace; + color: var(--accent); +} + +/* + ``.copyBlock`` is the focus-target wrapper for the + :focus-within hint rule below. The :focus-within selector + matches when ANY descendant (the secret ````, + the Copy button) has keyboard focus, so the cursor + selection fallback surfaces for keyboard-only users. +*/ +.copyBlock { + display: flex; + flex-direction: column; + gap: 6px; +} + +.copyBlock:focus-within .copyStatusHint { + display: block; +} + +.secretRow { + display: flex; + align-items: stretch; + gap: 8px; +} + +.secret { + flex: 1; + padding: 10px 12px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--background); + color: var(--foreground); + font-family: var(--font-geist-mono), ui-monospace, monospace; + font-size: 13px; + word-break: break-all; + user-select: all; +} + +.copyButton { + padding: 0 16px; + border-radius: 6px; + border: 1px solid var(--accent); + background: transparent; + color: var(--accent); + cursor: pointer; + font-size: 13px; + font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif; + transition: opacity 0.15s ease-in-out; + white-space: nowrap; +} + +.copyButton:hover { + opacity: 0.85; +} + +.copyStatus { + font-size: 12px; + color: var(--accent); + margin: 0; +} + +.copyStatusHint { + font-size: 12px; + color: var(--foreground-muted, var(--foreground)); + opacity: 0.65; + margin: 0; + display: none; +} + +.copyStatusHint kbd { + font-family: var(--font-geist-mono), ui-monospace, monospace; + background: var(--background); + border: 1px solid var(--border); + border-radius: 4px; + padding: 1px 6px; + font-size: 11px; +} + +.ackRow { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--foreground); +} diff --git a/web/src/components/CreateWebhookPanel.tsx b/web/src/components/CreateWebhookPanel.tsx new file mode 100644 index 00000000..f635005f --- /dev/null +++ b/web/src/components/CreateWebhookPanel.tsx @@ -0,0 +1,500 @@ +/** + * CreateWebhookPanel -- Client Component that drives a 3-phase + * state machine for registering a new webhook subscription: + * + * 1. ``closed`` -- only the ``New subscription`` trigger + * button is visible. Keeps the subscriptions table compact + * for operators who are just here to inspect / revoke / + * replay. + * 2. ``form`` -- URL (required) + description (optional) + + * filter (optional JSON object, advanced). Submit + * transitions to either ``reveal`` (on 201) OR renders an + * inline error card (on 4xx -- typically the + * `_validate_webhook_url` SSRF guard returning 422). + * 3. ``reveal`` -- the one-shot plaintext ``secret`` callout + * with a Copy-to-clipboard button + a mandatory + * ``I have securely stored this secret.`` acknowledgement + * checkbox. The ``Done`` button is disabled until the + * checkbox is checked. Closing the panel triggers + * ``router.refresh()`` so the new subscription surfaces in + * the WebhookSubscriptionsGrid table. + * + * Why a 3-phase state machine (not a single form with the + * secret revealed inline) + * ============================================================ + * The backend returns the plaintext secret ONCE on the 201 + * response (Fernet envelope encryption-at-rest for every later + * fetch). If the form simply closed after a successful POST, + * the secret would be silently lost and the operator would + * have to re-register. The forced acknowledgement flow mirrors + * industry standards (Stripe, GitHub webhooks, Slack apps) so + * the analyst is forced to interact with the secret value + * BEFORE it disappears, not after. + * + * Why a single Client Component (not a multi-page wizard) + * ========================================================== + * The whole flow is short (< 30 s typical) and has zero + * cross-page state. A multi-page wizard would require + * server-side storage of the in-flight secret (because the + * one-shot contract means the secret does NOT survive a + * refresh). The 3-phase Client Component flow keeps the + * plaintext secret in React state for the lifetime of the + * wizard and only crosses the network once. + * + * Why useReducer + discriminated union + * ------------------------------------- + * The 3 phases have explicit forbidden transitions (cannot + * submit from `reveal`, cannot edit from `closed`, etc.). + * 3 separate `useState`s would invite incoherent + * combinations (e.g. `mode === "reveal"` but + * `revealedSecret === null`). The discriminated union + * narrows the legal states to one-of-N and makes each + * transition a named action. + */ + +"use client"; + +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useReducer, useRef } from "react"; + +import { + createWebhook, + formatApiError, + type CreateWebhookPayload, + type WebhookSubscriptionCreatedRow, +} from "@/lib/api"; + +import styles from "./CreateWebhookPanel.module.css"; + +type Phase = "closed" | "form" | "reveal"; + +type State = + | { phase: "closed" } + | { + phase: "form"; + url: string; + description: string; + filter: string; + submitting: boolean; + error: string | null; + } + | { + phase: "reveal"; + created: WebhookSubscriptionCreatedRow; + acknowledged: boolean; + copied: boolean; + }; + +type Action = + | { type: "open" } + | { type: "cancel" } + | { type: "update"; field: "url" | "description" | "filter"; value: string } + | { type: "submit-start" } + | { type: "submit-success"; created: WebhookSubscriptionCreatedRow } + | { type: "submit-failure"; message: string } + | { type: "acknowledge"; value: boolean } + | { type: "copied" } + | { type: "done" }; + +const INITIAL_STATE: State = { phase: "closed" }; + +function emptyForm(): Extract { + return { + phase: "form", + url: "", + description: "", + filter: "", + submitting: false, + error: null, + }; +} + +function reducer(state: State, action: Action): State { + switch (action.type) { + case "open": + // From any closed or revealed state, re-entering the form + // resets every field. We do NOT reuse the prior values for + // two reasons: (1) the operator could accidentally re-POST + // the same URL twice; (2) starting fresh matches the + // expect-after-error flow (the analyst fixes the URL and + // re-submits -- starting from a stale URL adds confusion). + return emptyForm(); + case "cancel": + return { phase: "closed" }; + case "update": + if (state.phase !== "form") { + return state; + } + return { ...state, [action.field]: action.value, error: null }; + case "submit-start": + if (state.phase !== "form") { + return state; + } + return { ...state, submitting: true, error: null }; + case "submit-success": + // Symmetric with submit-start / submit-failure: only + // transitions from `form`. Guards against the dispatcher + // ever firing this from a non-form phase (e.g. an + // accidental post from the reveal panel's async code). + if (state.phase !== "form") { + return state; + } + return { + phase: "reveal", + created: action.created, + acknowledged: false, + copied: false, + }; + case "submit-failure": + if (state.phase !== "form") { + return state; + } + return { ...state, submitting: false, error: action.message }; + case "acknowledge": + if (state.phase !== "reveal") { + return state; + } + return { ...state, acknowledged: action.value }; + case "copied": + if (state.phase !== "reveal") { + return state; + } + return { ...state, copied: true }; + case "done": + if (state.phase !== "reveal") { + return state; + } + return { phase: "closed" }; + } +} + +export function CreateWebhookPanel() { + const router = useRouter(); + const [state, dispatch] = useReducer(reducer, INITIAL_STATE); + + // When the panel moves INTO the `reveal` phase, focus the + // secret callout so a keyboard-only operator can navigate the + // acknowledge / done controls immediately. We skip the call + // on the FIRST render so re-mounts mid-task don't yank + // focus (parallels the `upload/page.tsx` focus effect). + const hasMountedRef = useRef(false); + const revealRef = useRef(null); + useEffect(() => { + if (!hasMountedRef.current) { + hasMountedRef.current = true; + return; + } + if (state.phase === "reveal") { + revealRef.current?.focus(); + } + }, [state.phase]); + + const handleSubmit = useCallback( + async (event: React.FormEvent) => { + event.preventDefault(); + if (state.phase !== "form" || state.submitting) { + return; + } + const url = state.url.trim(); + if (!url) { + dispatch({ type: "submit-failure", message: "URL is required" }); + return; + } + // Parse the optional filter field as JSON. An empty / + // whitespace string is treated as "no filter" (the + // backend defaults to `{}`). A parse failure surfaces as + // an inline error so the analyst can fix the JSON before + // submitting; we do NOT silently send malformed JSON. + let filterPayload: Record | null = null; + const filterTrimmed = state.filter.trim(); + if (filterTrimmed !== "") { + try { + const parsed: unknown = JSON.parse(filterTrimmed); + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) + ) { + throw new Error("filter must be a JSON object"); + } + filterPayload = parsed as Record; + } catch (err) { + dispatch({ + type: "submit-failure", + message: `filter: ${ + err instanceof Error ? err.message : String(err) + }`, + }); + return; + } + } + dispatch({ type: "submit-start" }); + const payload: CreateWebhookPayload = { + url, + description: + state.description.trim() === "" ? null : state.description, + filter: filterPayload, + }; + try { + const created = await createWebhook(payload); + dispatch({ type: "submit-success", created }); + } catch (err) { + dispatch({ type: "submit-failure", message: formatApiError(err) }); + } + }, + // Deps: TypeScript cannot narrow a discriminated-union + // type at the useCallback deps-array level (the early + // return inside the closure narrows ``state`` after the + // guard, but the deps array is evaluated at the + // useCallback call site where ``state`` is the whole + // union -- ``state.url`` etc. don't statically exist on + // the union, so a per-field deps list fails with TS2339). + // + // The canonical workaround is ``[state]`` -- wider than + // ideal but safe: form-field edits always dispatch a + // reducer action that constructs a new ``state`` object, + // so the wider deps doesn't introduce a stale-closure + // bug. The early-return guard inside the closure + // (``state.phase !== "form" || state.submitting``) + // rejects every non-form case so the body never + // dereferences a missing field. + [state], + ); + + const handleCopy = useCallback(async (secret: string) => { + try { + if (typeof navigator !== "undefined" && navigator.clipboard) { + await navigator.clipboard.writeText(secret); + } else { + // Fallback for exotic browsers without the Clipboard + // API: create a throwaway textarea, select, and use + // the legacy `document.execCommand("copy")` shim. + const ta = document.createElement("textarea"); + ta.value = secret; + ta.style.position = "fixed"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.select(); + document.execCommand("copy"); + document.body.removeChild(ta); + } + dispatch({ type: "copied" }); + } catch { + // Copy failure is non-fatal: the analyst can still + // visually read the secret and copy it manually. We do + // not surface an error card because the secret is + // already revealed -- surfacing would only distract. + } + }, []); + + const handleDone = useCallback(() => { + dispatch({ type: "done" }); + // `router.refresh()` re-runs the server component which + // re-fetches both the subscriptions list + the DLQ (the + // latter is unaffected, the former picks up the newly + // registered row). + router.refresh(); + }, [router]); + + if (state.phase === "closed") { + return ( +
+ +
+ ); + } + + if (state.phase === "form") { + const canSubmit = state.url.trim() !== "" && !state.submitting; + return ( +
+
+ + + + +