Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions PROJECT_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
60 changes: 60 additions & 0 deletions app/api/admin/test-subscription-webhooks/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
75 changes: 75 additions & 0 deletions app/api/admin/webhook-subscriptions/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
94 changes: 94 additions & 0 deletions app/api/admin/webhook-subscriptions/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
7 changes: 6 additions & 1 deletion lib/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -102,7 +104,8 @@ export type PhaseFeatureFlag =
| "phase-142"
| "phase-143"
| "phase-144"
| "phase-145";
| "phase-145"
| "phase-154";

const FLAG_ENV_MAP: Record<PhaseFeatureFlag, string[]> = {
"phase-66": ["NEXT_PUBLIC_FEATURE_PHASE_66", "FEATURE_PHASE_66"],
Expand Down Expand Up @@ -163,6 +166,7 @@ const FLAG_ENV_MAP: Record<PhaseFeatureFlag, string[]> = {
"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 {
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions lib/server-data-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading