diff --git a/PROJECT_ARCHITECTURE.md b/PROJECT_ARCHITECTURE.md index 70ebac73..f821095f 100644 --- a/PROJECT_ARCHITECTURE.md +++ b/PROJECT_ARCHITECTURE.md @@ -198,6 +198,7 @@ is a fixed-size digest of the canonical payload, keeping it under wallet | `phase-143` | `NEXT_PUBLIC_FEATURE_PHASE_143` / `FEATURE_PHASE_143` | AI-driven churn and winback with dormant detection, tailored reactivation hooks, and experiment cohorts | off | Unset var, restart — winback route returns 404, experiment history in sidecar stays inert | | `phase-144` | `NEXT_PUBLIC_FEATURE_PHASE_144` / `FEATURE_PHASE_144` | ve-PHASELQ time-locked governance escrow with pro-rata treasury fee-share distribution | off | Unset var, restart — governance ve-lock route returns 404, SQLite ve rows remain inert | | `phase-145` | `NEXT_PUBLIC_FEATURE_PHASE_145` / `FEATURE_PHASE_145` | Gauge voting for quest emissions with ve-weight directing collection quest budgets | off | Unset var, restart — gauge route returns 404, SQLite vote rows remain inert | +| `phase-154` | `NEXT_PUBLIC_FEATURE_PHASE_154` / `FEATURE_PHASE_154` | Outbound webhook subscriptions: admin-managed subscriber endpoints with per-endpoint HMAC secrets and signed, retried event delivery (`settle.verified`, `market.offer_accepted`, `faucet.claim_completed`). Partial slice of spike 664 (issue #527) — no production event wiring, SSRF fencing, or redelivery UI yet | off | Unset var, restart — admin routes return 404; `webhook-subscriptions.json` rows remain inert, no migration to undo | Flags are read via `lib/feature-flags.ts:isFeatureEnabled`. Client flags use `NEXT_PUBLIC_*`, server also accepts `FEATURE_*`. Zero regression when off. diff --git a/app/api/admin/test-subscription-webhooks/route.ts b/app/api/admin/test-subscription-webhooks/route.ts new file mode 100644 index 00000000..e196f495 --- /dev/null +++ b/app/api/admin/test-subscription-webhooks/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from "next/server" +import { isFeatureEnabled } from "@/lib/feature-flags" +import { dispatchWebhookEvent } from "@/lib/webhook-delivery" +import { WEBHOOK_EVENT_TYPES, type WebhookEventType } from "@/lib/webhook-subscriptions" + +/** + * Sends a synthetic event to every active subscriber of that event type, so + * an operator can verify signing + delivery end-to-end without waiting for a + * real settle/offer/faucet event. Mirrors the shape of + * app/api/admin/test-webhooks/route.ts (the internal-alerts test hook), but + * for per-subscriber delivery. + * + * POST /api/admin/test-subscription-webhooks { "event": "faucet.claim_completed" } + * + * Flag: phase-154. + */ + +export const dynamic = "force-dynamic" + +function validateAdminAuth(req: NextRequest): boolean { + const authHeader = req.headers.get("authorization") + const adminToken = process.env.ADMIN_API_TOKEN?.trim() + if (!adminToken) return false + if (!authHeader || !authHeader.startsWith("Bearer ")) return false + return authHeader.substring(7) === adminToken +} + +export async function POST(req: NextRequest) { + if (!isFeatureEnabled("phase-154")) { + return NextResponse.json({ error: "Not found" }, { status: 404 }) + } + if (!validateAdminAuth(req)) { + return NextResponse.json( + { error: "Unauthorized - valid admin token required" }, + { status: 401 }, + ) + } + + let body: { event?: unknown } + try { + body = await req.json() + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }) + } + + const event = body.event + if (typeof event !== "string" || !WEBHOOK_EVENT_TYPES.includes(event as WebhookEventType)) { + return NextResponse.json( + { error: `event must be one of: ${WEBHOOK_EVENT_TYPES.join(", ")}` }, + { status: 400 }, + ) + } + + const result = await dispatchWebhookEvent(event as WebhookEventType, { + test: true, + note: "Synthetic event sent from /api/admin/test-subscription-webhooks", + }) + + return NextResponse.json({ ok: true, ...result }) +} diff --git a/app/api/admin/webhook-subscriptions/[id]/route.ts b/app/api/admin/webhook-subscriptions/[id]/route.ts new file mode 100644 index 00000000..6eacd0fc --- /dev/null +++ b/app/api/admin/webhook-subscriptions/[id]/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from "next/server" +import { isFeatureEnabled } from "@/lib/feature-flags" +import { + deleteSubscription, + getSubscription, + rotateSubscriptionSecret, +} from "@/lib/webhook-subscriptions" + +/** + * DELETE /api/admin/webhook-subscriptions/[id] - remove a subscription + * POST /api/admin/webhook-subscriptions/[id]?rotate=1 - rotate its secret + * + * Flag: phase-154. See ../route.ts for the shared admin-auth convention. + */ + +export const dynamic = "force-dynamic" + +function validateAdminAuth(req: NextRequest): boolean { + const authHeader = req.headers.get("authorization") + const adminToken = process.env.ADMIN_API_TOKEN?.trim() + if (!adminToken) return false + if (!authHeader || !authHeader.startsWith("Bearer ")) return false + return authHeader.substring(7) === adminToken +} + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + if (!isFeatureEnabled("phase-154")) { + return NextResponse.json({ error: "Not found" }, { status: 404 }) + } + if (!validateAdminAuth(req)) { + return NextResponse.json( + { error: "Unauthorized - valid admin token required" }, + { status: 401 }, + ) + } + + const { id } = await params + const removed = await deleteSubscription(id) + if (!removed) { + return NextResponse.json({ error: "Subscription not found" }, { status: 404 }) + } + return NextResponse.json({ ok: true }) +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + if (!isFeatureEnabled("phase-154")) { + return NextResponse.json({ error: "Not found" }, { status: 404 }) + } + if (!validateAdminAuth(req)) { + return NextResponse.json( + { error: "Unauthorized - valid admin token required" }, + { status: 401 }, + ) + } + + const { id } = await params + const rotate = req.nextUrl.searchParams.get("rotate") + if (!rotate) { + return NextResponse.json({ error: "Specify ?rotate=1 to rotate the secret" }, { status: 400 }) + } + + const existing = await getSubscription(id) + if (!existing) { + return NextResponse.json({ error: "Subscription not found" }, { status: 404 }) + } + + const secret = await rotateSubscriptionSecret(id) + return NextResponse.json({ ok: true, secret }) +} diff --git a/app/api/admin/webhook-subscriptions/route.ts b/app/api/admin/webhook-subscriptions/route.ts new file mode 100644 index 00000000..acad54c9 --- /dev/null +++ b/app/api/admin/webhook-subscriptions/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from "next/server" +import { isFeatureEnabled } from "@/lib/feature-flags" +import { + createSubscription, + listSubscriptions, + maskSubscription, + WEBHOOK_EVENT_TYPES, + type WebhookEventType, +} from "@/lib/webhook-subscriptions" + +/** + * Admin API for outbound webhook subscriptions (partial slice of issue #527). + * + * GET /api/admin/webhook-subscriptions - List subscriptions (secrets masked) + * POST /api/admin/webhook-subscriptions - Create a subscription + * + * Flag: phase-154. Disabled routes return 404, matching the convention used + * by every other flag-gated route in this repo (see PROJECT_ARCHITECTURE.md §10). + */ + +export const dynamic = "force-dynamic" + +function validateAdminAuth(req: NextRequest): boolean { + const authHeader = req.headers.get("authorization") + const adminToken = process.env.ADMIN_API_TOKEN?.trim() + + if (!adminToken) { + console.warn("[admin/webhook-subscriptions] ADMIN_API_TOKEN not configured") + return false + } + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return false + } + const token = authHeader.substring(7) + return token === adminToken +} + +export async function GET(req: NextRequest) { + if (!isFeatureEnabled("phase-154")) { + return NextResponse.json({ error: "Not found" }, { status: 404 }) + } + if (!validateAdminAuth(req)) { + return NextResponse.json( + { error: "Unauthorized - valid admin token required" }, + { status: 401 }, + ) + } + + const subscriptions = await listSubscriptions() + return NextResponse.json({ + ok: true, + subscriptions: subscriptions.map(maskSubscription), + }) +} + +export async function POST(req: NextRequest) { + if (!isFeatureEnabled("phase-154")) { + return NextResponse.json({ error: "Not found" }, { status: 404 }) + } + if (!validateAdminAuth(req)) { + return NextResponse.json( + { error: "Unauthorized - valid admin token required" }, + { status: 401 }, + ) + } + + let body: { url?: unknown; events?: unknown } + try { + body = await req.json() + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }) + } + + if (typeof body.url !== "string" || !Array.isArray(body.events)) { + return NextResponse.json( + { error: `url (string) and events (array of ${WEBHOOK_EVENT_TYPES.join(" | ")}) are required` }, + { status: 400 }, + ) + } + + try { + const subscription = await createSubscription({ + url: body.url, + events: body.events.filter( + (e): e is WebhookEventType => typeof e === "string", + ) as WebhookEventType[], + }) + // The secret is returned once, at creation time, and never again. + return NextResponse.json({ ok: true, subscription }, { status: 201 }) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + return NextResponse.json({ error: msg }, { status: 400 }) + } +} diff --git a/lib/feature-flags.ts b/lib/feature-flags.ts index 8050f619..c53e3df3 100644 --- a/lib/feature-flags.ts +++ b/lib/feature-flags.ts @@ -41,6 +41,8 @@ * - phase-143: AI-driven churn and winback with dormant detection plus experiments * - phase-144: ve-PHASELQ locking with time-locked weight plus fee share * - phase-145: gauge voting for emissions with ve-weight directing quest budgets + * - phase-154: outbound webhook subscriptions — signed event delivery to + * third-party endpoints (partial slice of spike 664 / issue #527) */ export type PhaseFeatureFlag = @@ -102,7 +104,8 @@ export type PhaseFeatureFlag = | "phase-142" | "phase-143" | "phase-144" - | "phase-145"; + | "phase-145" + | "phase-154"; const FLAG_ENV_MAP: Record = { "phase-66": ["NEXT_PUBLIC_FEATURE_PHASE_66", "FEATURE_PHASE_66"], @@ -163,6 +166,7 @@ const FLAG_ENV_MAP: Record = { "phase-143": ["NEXT_PUBLIC_FEATURE_PHASE_143", "FEATURE_PHASE_143"], "phase-144": ["NEXT_PUBLIC_FEATURE_PHASE_144", "FEATURE_PHASE_144"], "phase-145": ["NEXT_PUBLIC_FEATURE_PHASE_145", "FEATURE_PHASE_145"], + "phase-154": ["NEXT_PUBLIC_FEATURE_PHASE_154", "FEATURE_PHASE_154"], }; function isTruthy(v: string | undefined): boolean { @@ -256,6 +260,7 @@ export function getEnabledFeatureFlags(): PhaseFeatureFlag[] { "phase-143", "phase-144", "phase-145", + "phase-154", ]; // Filtering is evaluated at call time, allowing runtime test overrides. return all.filter(isFeatureEnabled) diff --git a/lib/server-data-paths.ts b/lib/server-data-paths.ts index 27cc7c80..b3f07d23 100644 --- a/lib/server-data-paths.ts +++ b/lib/server-data-paths.ts @@ -39,6 +39,7 @@ const FILES = { generationJobs: "generation-jobs.json", generationDlq: "generation-dlq.json", winbackExperiments: "winback-experiments.json", + webhookSubscriptions: "webhook-subscriptions.json", } as const export type ServerDataFile = keyof typeof FILES diff --git a/lib/webhook-delivery.ts b/lib/webhook-delivery.ts new file mode 100644 index 00000000..9c0da4ff --- /dev/null +++ b/lib/webhook-delivery.ts @@ -0,0 +1,134 @@ +/** + * Outbound webhook delivery — partial slice of Spike 664 (issue #527). + * + * Mirrors `app/api/webhooks/nanobanana/route.ts`'s inbound HMAC verifier in + * reverse: that route takes `x-nanobanana-signature: sha256=` and + * recomputes `HMAC-SHA256(secret, rawBody)` to verify a request PHASE + * receives. Here PHASE is the sender, so the same primitive is used to + * *produce* a signature a subscriber can verify: `HMAC-SHA256(secret, + * "..")`, sent as `x-phase-signature: sha256=` + * alongside `x-phase-timestamp` and `x-phase-nonce` so the receiver can + * reject replayed deliveries (the inbound route has no timestamp/nonce + * because NanoBanana does not send one — subscribers here get the stronger + * check since PHASE controls both ends of this contract). + * + * Flag: phase-154. Rollback: unset the flag; see lib/webhook-subscriptions.ts. + * + * SCOPE NOTE: delivery here is at-least-once, best-effort, fire-and-forget + * per subscriber with a small fixed retry/backoff schedule. It is NOT the + * durable, cross-instance-ordered, circuit-broken-with-redelivery-UI system + * the full issue asks for — see PR description "Scope and residual risk". + */ + +import { createHmac, randomUUID } from "node:crypto" +import { + listActiveSubscriptionsForEvent, + recordDeliveryOutcome, + type WebhookEventType, + type WebhookSubscription, +} from "@/lib/webhook-subscriptions" + +const DELIVERY_TIMEOUT_MS = 5_000 +/** Fixed backoff schedule: first attempt immediate, then two retries. */ +const RETRY_DELAYS_MS = [0, 1_000, 4_000] + +export function signWebhookDelivery( + secret: string, + timestamp: number, + nonce: string, + rawBody: string, +): string { + return createHmac("sha256", secret) + .update(`${timestamp}.${nonce}.${rawBody}`, "utf8") + .digest("hex") +} + +async function sleep(ms: number): Promise { + if (ms <= 0) return + await new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function postOnce( + url: string, + headers: Record, + body: string, +): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), DELIVERY_TIMEOUT_MS) + try { + const res = await fetch(url, { + method: "POST", + headers, + body, + signal: controller.signal, + }) + return res.ok + } catch { + return false + } finally { + clearTimeout(timeout) + } +} + +/** + * Delivers one event payload to one subscription with retry/backoff. + * Always resolves (never throws) so callers can fan out with + * `Promise.allSettled` semantics without a rejection derailing siblings. + */ +export async function deliverToSubscription( + subscription: WebhookSubscription, + eventType: WebhookEventType, + payload: unknown, +): Promise { + const rawBody = JSON.stringify({ + event: eventType, + deliveredAt: Date.now(), + data: payload, + }) + const nonce = randomUUID() + + let delivered = false + for (const delay of RETRY_DELAYS_MS) { + await sleep(delay) + const timestamp = Date.now() + const signature = signWebhookDelivery(subscription.secret, timestamp, nonce, rawBody) + delivered = await postOnce( + subscription.url, + { + "content-type": "application/json", + "x-phase-signature": `sha256=${signature}`, + "x-phase-timestamp": String(timestamp), + "x-phase-nonce": nonce, + "x-phase-event": eventType, + }, + rawBody, + ) + if (delivered) break + } + + await recordDeliveryOutcome(subscription.id, delivered).catch(() => {}) + return delivered +} + +/** + * Fans an event out to every active subscription registered for it. + * Fire-and-forget per subscriber; one subscriber's failure never blocks or + * fails another's delivery. + */ +export async function dispatchWebhookEvent( + eventType: WebhookEventType, + payload: unknown, +): Promise<{ attempted: number; delivered: number }> { + const subscriptions = await listActiveSubscriptionsForEvent(eventType) + if (subscriptions.length === 0) return { attempted: 0, delivered: 0 } + + const results = await Promise.allSettled( + subscriptions.map((s) => deliverToSubscription(s, eventType, payload)), + ) + + const delivered = results.filter( + (r) => r.status === "fulfilled" && r.value === true, + ).length + + return { attempted: subscriptions.length, delivered } +} diff --git a/lib/webhook-subscriptions.ts b/lib/webhook-subscriptions.ts new file mode 100644 index 00000000..04d59952 --- /dev/null +++ b/lib/webhook-subscriptions.ts @@ -0,0 +1,189 @@ +/** + * Outbound webhook subscriptions — partial slice of Spike 664 (issue #527). + * + * Stores third-party subscriber endpoints and their per-endpoint signing + * secrets, keyed by id. CRUD goes through `lib/json-store.ts`'s + * `updateStore` / `readStore`, the same per-process file-lock + atomic + * tmp+rename pattern every other JSON sidecar in this repo uses — see + * `lib/json-store.ts` for the single-instance-safety scope limit. + * + * Flag: phase-154 (NEXT_PUBLIC_FEATURE_PHASE_154 / FEATURE_PHASE_154). + * Rollback: unset the flag and restart. Subscription rows stay on disk, + * inert — no migration to undo. + * + * SCOPE NOTE (see PR description "Scope and residual risk"): this is a + * partial slice of issue #527. Endpoint verification handshake, SSRF + * fencing, durable cross-instance delivery ordering, and a redelivery UI + * are explicitly NOT implemented here. + */ + +import { randomBytes, randomUUID } from "node:crypto" +import { readStore, updateStore } from "@/lib/json-store" + +export type WebhookEventType = + | "settle.verified" + | "market.offer_accepted" + | "faucet.claim_completed" + +export const WEBHOOK_EVENT_TYPES: WebhookEventType[] = [ + "settle.verified", + "market.offer_accepted", + "faucet.claim_completed", +] + +export type WebhookSubscription = { + id: string + url: string + /** Hex-encoded signing secret, shown once on creation; never returned by GET. */ + secret: string + events: WebhookEventType[] + active: boolean + /** Circuit breaker: disabled after too many consecutive delivery failures. */ + consecutiveFailures: number + lastDeliveryAt?: number + lastDeliveryStatus?: "ok" | "failed" + createdAt: number + updatedAt: number +} + +export type WebhookSubscriptionStore = Record + +/** Disable a subscription after this many consecutive delivery failures. */ +export const MAX_CONSECUTIVE_FAILURES = 10 + +function generateSecret(): string { + return randomBytes(32).toString("hex") +} + +export type CreateSubscriptionInput = { + url: string + events: WebhookEventType[] +} + +function assertValidUrl(url: string): void { + let parsed: URL + try { + parsed = new URL(url) + } catch { + throw new Error("Invalid subscription url") + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new Error("Subscription url must be http(s)") + } +} + +/** + * Creates a subscription with a freshly generated secret. The secret is + * returned only on this call — callers must store it client-side. + */ +export async function createSubscription( + input: CreateSubscriptionInput, +): Promise { + assertValidUrl(input.url) + const events = input.events.filter((e) => WEBHOOK_EVENT_TYPES.includes(e)) + if (events.length === 0) { + throw new Error("At least one valid event type is required") + } + + const now = Date.now() + const subscription: WebhookSubscription = { + id: randomUUID(), + url: input.url, + secret: generateSecret(), + events, + active: true, + consecutiveFailures: 0, + createdAt: now, + updatedAt: now, + } + + await updateStore("webhookSubscriptions", (store) => { + store[subscription.id] = subscription + }) + + return subscription +} + +export async function listSubscriptions(): Promise { + const store = await readStore("webhookSubscriptions", () => ({})) + return Object.values(store).sort((a, b) => b.createdAt - a.createdAt) +} + +export async function getSubscription(id: string): Promise { + const store = await readStore("webhookSubscriptions", () => ({})) + return store[id] ?? null +} + +/** Subscriptions still eligible for delivery of a given event type. */ +export async function listActiveSubscriptionsForEvent( + eventType: WebhookEventType, +): Promise { + const all = await listSubscriptions() + return all.filter((s) => s.active && s.events.includes(eventType)) +} + +export async function deleteSubscription(id: string): Promise { + return updateStore("webhookSubscriptions", (store) => { + if (!store[id]) return false + delete store[id] + return true + }) +} + +/** + * Rotates the signing secret for a subscription. Returns the new secret, or + * null if the subscription does not exist. + */ +export async function rotateSubscriptionSecret(id: string): Promise { + return updateStore( + "webhookSubscriptions", + (store) => { + const existing = store[id] + if (!existing) return null + const secret = generateSecret() + store[id] = { ...existing, secret, updatedAt: Date.now() } + return secret + }, + ) +} + +/** + * Records the outcome of a delivery attempt and applies the circuit + * breaker: a subscription is deactivated once it has failed + * `MAX_CONSECUTIVE_FAILURES` deliveries in a row. A success resets the + * counter. + */ +export async function recordDeliveryOutcome( + id: string, + ok: boolean, +): Promise { + return updateStore( + "webhookSubscriptions", + (store) => { + const existing = store[id] + if (!existing) return null + const consecutiveFailures = ok ? 0 : existing.consecutiveFailures + 1 + const updated: WebhookSubscription = { + ...existing, + consecutiveFailures, + lastDeliveryAt: Date.now(), + lastDeliveryStatus: ok ? "ok" : "failed", + active: existing.active && consecutiveFailures < MAX_CONSECUTIVE_FAILURES, + updatedAt: Date.now(), + } + store[id] = updated + return updated + }, + ) +} + +/** Returns a subscription with its secret masked, for API responses. */ +export function maskSubscription( + subscription: WebhookSubscription, +): Omit & { secretPreview: string } { + const { secret, ...rest } = subscription + return { + ...rest, + secretPreview: `${secret.slice(0, 4)}…${secret.slice(-4)}`, + } +}