summaryrefslogtreecommitdiff
path: root/apps/web/app/api/webhook-config
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-07 01:42:57 -0800
committerFuwn <[email protected]>2026-02-07 01:42:57 -0800
commit5c5b1993edd890a80870ee05607ac5f088191d4e (patch)
treea721b76bcd49ba10826c53efc87302c7a689512f /apps/web/app/api/webhook-config
downloadasa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.tar.xz
asa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.zip
feat: asa.news RSS reader with developer tier, REST API, and webhooks
Full-stack RSS reader SaaS: Supabase + Next.js + Go worker. Includes three subscription tiers (free/pro/developer), API key auth, read-only REST API, webhook push notifications, Stripe billing with proration, and PWA support.
Diffstat (limited to 'apps/web/app/api/webhook-config')
-rw-r--r--apps/web/app/api/webhook-config/route.ts117
-rw-r--r--apps/web/app/api/webhook-config/test/route.ts101
2 files changed, 218 insertions, 0 deletions
diff --git a/apps/web/app/api/webhook-config/route.ts b/apps/web/app/api/webhook-config/route.ts
new file mode 100644
index 0000000..1ce9a30
--- /dev/null
+++ b/apps/web/app/api/webhook-config/route.ts
@@ -0,0 +1,117 @@
+import { NextResponse } from "next/server"
+import { createSupabaseServerClient } from "@/lib/supabase/server"
+import { createSupabaseAdminClient } from "@/lib/supabase/admin"
+import { TIER_LIMITS, type SubscriptionTier } from "@asa-news/shared"
+import { rateLimit } from "@/lib/rate-limit"
+
+export async function GET() {
+ const supabaseClient = await createSupabaseServerClient()
+ const {
+ data: { user },
+ } = await supabaseClient.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
+ }
+
+ const adminClient = createSupabaseAdminClient()
+ const { data: profile, error } = await adminClient
+ .from("user_profiles")
+ .select(
+ "tier, webhook_url, webhook_secret, webhook_enabled, webhook_consecutive_failures"
+ )
+ .eq("id", user.id)
+ .single()
+
+ if (error || !profile) {
+ return NextResponse.json(
+ { error: "Failed to load webhook config" },
+ { status: 500 }
+ )
+ }
+
+ return NextResponse.json({
+ webhookUrl: profile.webhook_url,
+ webhookSecret: profile.webhook_secret,
+ webhookEnabled: profile.webhook_enabled,
+ consecutiveFailures: profile.webhook_consecutive_failures,
+ })
+}
+
+export async function PUT(request: Request) {
+ const supabaseClient = await createSupabaseServerClient()
+ const {
+ data: { user },
+ } = await supabaseClient.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
+ }
+
+ const rateLimitResult = rateLimit(`webhook-config:${user.id}`, 10, 60_000)
+ if (!rateLimitResult.success) {
+ return NextResponse.json({ error: "Too many requests" }, { status: 429 })
+ }
+
+ const adminClient = createSupabaseAdminClient()
+
+ const { data: profile } = await adminClient
+ .from("user_profiles")
+ .select("tier")
+ .eq("id", user.id)
+ .single()
+
+ if (
+ !profile ||
+ !TIER_LIMITS[profile.tier as SubscriptionTier]?.allowsWebhooks
+ ) {
+ return NextResponse.json(
+ { error: "Webhooks require the developer plan" },
+ { status: 403 }
+ )
+ }
+
+ const body = await request.json().catch(() => ({}))
+
+ const updates: Record<string, unknown> = {}
+
+ if (typeof body.webhookUrl === "string") {
+ const trimmedUrl = body.webhookUrl.trim()
+ if (trimmedUrl && !trimmedUrl.startsWith("https://")) {
+ return NextResponse.json(
+ { error: "Webhook URL must use HTTPS" },
+ { status: 400 }
+ )
+ }
+ updates.webhook_url = trimmedUrl || null
+ }
+
+ if (typeof body.webhookSecret === "string") {
+ updates.webhook_secret = body.webhookSecret.trim() || null
+ }
+
+ if (typeof body.webhookEnabled === "boolean") {
+ updates.webhook_enabled = body.webhookEnabled
+ if (body.webhookEnabled) {
+ updates.webhook_consecutive_failures = 0
+ }
+ }
+
+ if (Object.keys(updates).length === 0) {
+ return NextResponse.json({ error: "No updates provided" }, { status: 400 })
+ }
+
+ const { error } = await adminClient
+ .from("user_profiles")
+ .update(updates)
+ .eq("id", user.id)
+
+ if (error) {
+ return NextResponse.json(
+ { error: "Failed to update webhook config" },
+ { status: 500 }
+ )
+ }
+
+ return NextResponse.json({ updated: true })
+}
diff --git a/apps/web/app/api/webhook-config/test/route.ts b/apps/web/app/api/webhook-config/test/route.ts
new file mode 100644
index 0000000..684ec0c
--- /dev/null
+++ b/apps/web/app/api/webhook-config/test/route.ts
@@ -0,0 +1,101 @@
+import { NextResponse } from "next/server"
+import { createHmac } from "crypto"
+import { createSupabaseServerClient } from "@/lib/supabase/server"
+import { createSupabaseAdminClient } from "@/lib/supabase/admin"
+import { TIER_LIMITS, type SubscriptionTier } from "@asa-news/shared"
+import { rateLimit } from "@/lib/rate-limit"
+
+export async function POST() {
+ const supabaseClient = await createSupabaseServerClient()
+ const {
+ data: { user },
+ } = await supabaseClient.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
+ }
+
+ const rateLimitResult = rateLimit(`webhook-test:${user.id}`, 5, 60_000)
+ if (!rateLimitResult.success) {
+ return NextResponse.json({ error: "Too many requests" }, { status: 429 })
+ }
+
+ const adminClient = createSupabaseAdminClient()
+ const { data: profile } = await adminClient
+ .from("user_profiles")
+ .select(
+ "tier, webhook_url, webhook_secret, webhook_enabled"
+ )
+ .eq("id", user.id)
+ .single()
+
+ if (
+ !profile ||
+ !TIER_LIMITS[profile.tier as SubscriptionTier]?.allowsWebhooks
+ ) {
+ return NextResponse.json(
+ { error: "Webhooks require the developer plan" },
+ { status: 403 }
+ )
+ }
+
+ if (!profile.webhook_url) {
+ return NextResponse.json(
+ { error: "No webhook URL configured" },
+ { status: 400 }
+ )
+ }
+
+ const testPayload = {
+ event: "test",
+ timestamp: new Date().toISOString(),
+ entries: [
+ {
+ entryIdentifier: "test-entry-000",
+ feedIdentifier: "test-feed-000",
+ title: "Test webhook delivery",
+ url: "https://asa.news",
+ author: "asa.news",
+ summary: "This is a test webhook payload to verify your endpoint.",
+ publishedAt: new Date().toISOString(),
+ },
+ ],
+ }
+
+ const payloadString = JSON.stringify(testPayload)
+ const headers: Record<string, string> = {
+ "Content-Type": "application/json",
+ "User-Agent": "asa.news Webhook/1.0",
+ }
+
+ if (profile.webhook_secret) {
+ const signature = createHmac("sha256", profile.webhook_secret)
+ .update(payloadString)
+ .digest("hex")
+ headers["X-Asa-Signature-256"] = `sha256=${signature}`
+ }
+
+ try {
+ const response = await fetch(profile.webhook_url, {
+ method: "POST",
+ headers,
+ body: payloadString,
+ signal: AbortSignal.timeout(10_000),
+ })
+
+ return NextResponse.json({
+ delivered: true,
+ statusCode: response.status,
+ })
+ } catch (deliveryError) {
+ const errorMessage =
+ deliveryError instanceof Error
+ ? deliveryError.message
+ : "Unknown error"
+
+ return NextResponse.json({
+ delivered: false,
+ error: errorMessage,
+ })
+ }
+}