summaryrefslogtreecommitdiff
path: root/apps/web/app/api
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
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')
-rw-r--r--apps/web/app/api/account/data/route.ts96
-rw-r--r--apps/web/app/api/account/route.ts27
-rw-r--r--apps/web/app/api/billing/create-checkout-session/route.ts153
-rw-r--r--apps/web/app/api/billing/create-portal-session/route.ts51
-rw-r--r--apps/web/app/api/billing/webhook/route.ts181
-rw-r--r--apps/web/app/api/export/route.ts67
-rw-r--r--apps/web/app/api/share/[token]/route.ts85
-rw-r--r--apps/web/app/api/share/route.ts132
-rw-r--r--apps/web/app/api/v1/entries/[entryIdentifier]/route.ts72
-rw-r--r--apps/web/app/api/v1/entries/route.ts114
-rw-r--r--apps/web/app/api/v1/feeds/route.ts55
-rw-r--r--apps/web/app/api/v1/folders/route.ts36
-rw-r--r--apps/web/app/api/v1/keys/[keyIdentifier]/route.ts36
-rw-r--r--apps/web/app/api/v1/keys/route.ts116
-rw-r--r--apps/web/app/api/v1/profile/route.ts49
-rw-r--r--apps/web/app/api/webhook-config/route.ts117
-rw-r--r--apps/web/app/api/webhook-config/test/route.ts101
17 files changed, 1488 insertions, 0 deletions
diff --git a/apps/web/app/api/account/data/route.ts b/apps/web/app/api/account/data/route.ts
new file mode 100644
index 0000000..dbee725
--- /dev/null
+++ b/apps/web/app/api/account/data/route.ts
@@ -0,0 +1,96 @@
+import { NextResponse } from "next/server"
+import { createSupabaseServerClient } from "@/lib/supabase/server"
+
+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 [
+ profileResult,
+ subscriptionsResult,
+ foldersResult,
+ mutedKeywordsResult,
+ customFeedsResult,
+ entryStatesResult,
+ highlightsResult,
+ sharedEntriesResult,
+ savedEntriesResult,
+ ] = await Promise.all([
+ supabaseClient
+ .from("user_profiles")
+ .select("id, display_name, tier, created_at")
+ .eq("id", user.id)
+ .single(),
+ supabaseClient
+ .from("subscriptions")
+ .select("id, feed_id, folder_id, custom_title, created_at, feeds(title, url)")
+ .eq("user_id", user.id),
+ supabaseClient
+ .from("folders")
+ .select("id, name, position, created_at")
+ .eq("user_id", user.id),
+ supabaseClient
+ .from("muted_keywords")
+ .select("id, keyword, created_at")
+ .eq("user_id", user.id),
+ supabaseClient
+ .from("custom_feeds")
+ .select("id, name, query, position, created_at")
+ .eq("user_id", user.id),
+ supabaseClient
+ .from("user_entry_states")
+ .select("entry_id, read, saved, updated_at")
+ .eq("user_id", user.id),
+ supabaseClient
+ .from("user_highlights")
+ .select(
+ "id, entry_id, highlighted_text, note, color, text_offset, text_length, created_at, entries(title, url)"
+ )
+ .eq("user_id", user.id),
+ supabaseClient
+ .from("shared_entries")
+ .select("id, entry_id, share_token, created_at, entries(title, url)")
+ .eq("user_id", user.id),
+ supabaseClient
+ .from("user_entry_states")
+ .select(
+ "entries(id, title, url, author, summary, published_at, feeds(title, url))"
+ )
+ .eq("user_id", user.id)
+ .eq("saved", true),
+ ])
+
+ const exportData = {
+ exportedAt: new Date().toISOString(),
+ account: {
+ emailAddress: user.email,
+ ...profileResult.data,
+ },
+ subscriptions: subscriptionsResult.data ?? [],
+ folders: foldersResult.data ?? [],
+ mutedKeywords: mutedKeywordsResult.data ?? [],
+ customFeeds: customFeedsResult.data ?? [],
+ entryStates: entryStatesResult.data ?? [],
+ highlights: highlightsResult.data ?? [],
+ sharedEntries: sharedEntriesResult.data ?? [],
+ savedEntries:
+ (savedEntriesResult.data ?? []).map(
+ (row) => (row as Record<string, unknown>).entries
+ ) ?? [],
+ }
+
+ const jsonString = JSON.stringify(exportData, null, 2)
+
+ return new Response(jsonString, {
+ headers: {
+ "Content-Type": "application/json",
+ "Content-Disposition": `attachment; filename="asa-news-gdpr-export-${new Date().toISOString().slice(0, 10)}.json"`,
+ },
+ })
+}
diff --git a/apps/web/app/api/account/route.ts b/apps/web/app/api/account/route.ts
new file mode 100644
index 0000000..6b1bc2d
--- /dev/null
+++ b/apps/web/app/api/account/route.ts
@@ -0,0 +1,27 @@
+import { NextResponse } from "next/server"
+import { createSupabaseServerClient } from "@/lib/supabase/server"
+import { createSupabaseAdminClient } from "@/lib/supabase/admin"
+
+export async function DELETE() {
+ 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 { error } = await adminClient.auth.admin.deleteUser(user.id)
+
+ if (error) {
+ return NextResponse.json(
+ { error: "Failed to delete account" },
+ { status: 500 }
+ )
+ }
+
+ return new Response(null, { status: 204 })
+}
diff --git a/apps/web/app/api/billing/create-checkout-session/route.ts b/apps/web/app/api/billing/create-checkout-session/route.ts
new file mode 100644
index 0000000..cfbb388
--- /dev/null
+++ b/apps/web/app/api/billing/create-checkout-session/route.ts
@@ -0,0 +1,153 @@
+import { NextResponse } from "next/server"
+import { headers } from "next/headers"
+import { createSupabaseServerClient } from "@/lib/supabase/server"
+import { createSupabaseAdminClient } from "@/lib/supabase/admin"
+import { getStripe } from "@/lib/stripe"
+import { rateLimit } from "@/lib/rate-limit"
+
+export async function POST(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(`checkout:${user.id}`, 10, 60_000)
+ if (!rateLimitResult.success) {
+ return NextResponse.json({ error: "Too many requests" }, { status: 429 })
+ }
+
+ const body = await request.json().catch(() => ({}))
+ const billingInterval =
+ body.billingInterval === "yearly" ? "yearly" : "monthly"
+ const targetTier =
+ body.targetTier === "developer" ? "developer" : "pro"
+
+ const priceIdentifierMap: Record<string, string | undefined> = {
+ "pro:monthly": process.env.STRIPE_PRO_MONTHLY_PRICE_IDENTIFIER,
+ "pro:yearly": process.env.STRIPE_PRO_YEARLY_PRICE_IDENTIFIER,
+ "developer:monthly": process.env.STRIPE_DEVELOPER_MONTHLY_PRICE_IDENTIFIER,
+ "developer:yearly": process.env.STRIPE_DEVELOPER_YEARLY_PRICE_IDENTIFIER,
+ }
+
+ const stripePriceIdentifier =
+ priceIdentifierMap[`${targetTier}:${billingInterval}`]
+
+ if (!stripePriceIdentifier) {
+ return NextResponse.json(
+ { error: "Invalid plan configuration" },
+ { status: 500 }
+ )
+ }
+
+ const { data: profile, error: profileError } = await supabaseClient
+ .from("user_profiles")
+ .select("tier, stripe_customer_identifier, stripe_subscription_identifier")
+ .eq("id", user.id)
+ .single()
+
+ if (profileError || !profile) {
+ return NextResponse.json(
+ { error: "Failed to load profile" },
+ { status: 500 }
+ )
+ }
+
+ const tierRank: Record<string, number> = { free: 0, pro: 1, developer: 2 }
+ const currentRank = tierRank[profile.tier] ?? 0
+ const targetRank = tierRank[targetTier] ?? 0
+
+ if (currentRank >= targetRank) {
+ return NextResponse.json(
+ { error: `Already on ${profile.tier} plan` },
+ { status: 400 }
+ )
+ }
+
+ if (profile.stripe_subscription_identifier && currentRank > 0) {
+ const subscription = await getStripe().subscriptions.retrieve(
+ profile.stripe_subscription_identifier
+ )
+
+ const existingItemIdentifier = subscription.items.data[0]?.id
+
+ if (!existingItemIdentifier) {
+ return NextResponse.json(
+ { error: "Could not find existing subscription item" },
+ { status: 500 }
+ )
+ }
+
+ await getStripe().subscriptions.update(
+ profile.stripe_subscription_identifier,
+ {
+ items: [
+ {
+ id: existingItemIdentifier,
+ price: stripePriceIdentifier,
+ },
+ ],
+ proration_behavior: "always_invoice",
+ metadata: { supabase_user_identifier: user.id },
+ }
+ )
+
+ const adminClient = createSupabaseAdminClient()
+ await adminClient
+ .from("user_profiles")
+ .update({ tier: targetTier })
+ .eq("id", user.id)
+
+ return NextResponse.json({ upgraded: true })
+ }
+
+ let stripeCustomerIdentifier = profile.stripe_customer_identifier
+
+ if (!stripeCustomerIdentifier) {
+ const customer = await getStripe().customers.create({
+ email: user.email,
+ metadata: { supabase_user_identifier: user.id },
+ })
+
+ stripeCustomerIdentifier = customer.id
+
+ const adminClient = createSupabaseAdminClient()
+ const { error: updateError } = await adminClient
+ .from("user_profiles")
+ .update({ stripe_customer_identifier: stripeCustomerIdentifier })
+ .eq("id", user.id)
+
+ if (updateError) {
+ console.error("Admin client update error:", updateError)
+ return NextResponse.json(
+ { error: "Failed to save customer: " + updateError.message },
+ { status: 500 }
+ )
+ }
+ }
+
+ const headersList = await headers()
+ const origin = headersList.get("origin") || "http://localhost:3000"
+
+ const checkoutSession = await getStripe().checkout.sessions.create({
+ customer: stripeCustomerIdentifier,
+ mode: "subscription",
+ line_items: [
+ {
+ price: stripePriceIdentifier,
+ quantity: 1,
+ },
+ ],
+ success_url: `${origin}/reader/settings?billing=success`,
+ cancel_url: `${origin}/reader/settings?billing=cancelled`,
+ subscription_data: {
+ metadata: { supabase_user_identifier: user.id },
+ },
+ client_reference_id: user.id,
+ })
+
+ return NextResponse.json({ url: checkoutSession.url })
+}
diff --git a/apps/web/app/api/billing/create-portal-session/route.ts b/apps/web/app/api/billing/create-portal-session/route.ts
new file mode 100644
index 0000000..3832c0d
--- /dev/null
+++ b/apps/web/app/api/billing/create-portal-session/route.ts
@@ -0,0 +1,51 @@
+import { NextResponse } from "next/server"
+import { headers } from "next/headers"
+import { createSupabaseServerClient } from "@/lib/supabase/server"
+import { getStripe } from "@/lib/stripe"
+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(`portal:${user.id}`, 10, 60_000)
+ if (!rateLimitResult.success) {
+ return NextResponse.json({ error: "Too many requests" }, { status: 429 })
+ }
+
+ const { data: profile, error: profileError } = await supabaseClient
+ .from("user_profiles")
+ .select("stripe_customer_identifier")
+ .eq("id", user.id)
+ .single()
+
+ if (profileError || !profile) {
+ return NextResponse.json(
+ { error: "Failed to load profile" },
+ { status: 500 }
+ )
+ }
+
+ if (!profile.stripe_customer_identifier) {
+ return NextResponse.json(
+ { error: "No billing account found" },
+ { status: 400 }
+ )
+ }
+
+ const headersList = await headers()
+ const origin = headersList.get("origin") || "http://localhost:3000"
+
+ const portalSession = await getStripe().billingPortal.sessions.create({
+ customer: profile.stripe_customer_identifier,
+ return_url: `${origin}/reader/settings`,
+ })
+
+ return NextResponse.json({ url: portalSession.url })
+}
diff --git a/apps/web/app/api/billing/webhook/route.ts b/apps/web/app/api/billing/webhook/route.ts
new file mode 100644
index 0000000..8aed7d0
--- /dev/null
+++ b/apps/web/app/api/billing/webhook/route.ts
@@ -0,0 +1,181 @@
+import { NextResponse } from "next/server"
+import type Stripe from "stripe"
+import { getStripe } from "@/lib/stripe"
+import { createSupabaseAdminClient } from "@/lib/supabase/admin"
+import { rateLimit } from "@/lib/rate-limit"
+
+function determineTierFromSubscription(
+ subscription: Stripe.Subscription
+): "pro" | "developer" {
+ const priceIdentifier = subscription.items?.data?.[0]?.price?.id
+ const developerPriceIdentifiers = [
+ process.env.STRIPE_DEVELOPER_MONTHLY_PRICE_IDENTIFIER,
+ process.env.STRIPE_DEVELOPER_YEARLY_PRICE_IDENTIFIER,
+ ]
+
+ if (priceIdentifier && developerPriceIdentifiers.includes(priceIdentifier)) {
+ return "developer"
+ }
+
+ return "pro"
+}
+
+function extractPeriodEnd(subscription: Stripe.Subscription): string | null {
+ const firstItem = subscription.items?.data?.[0]
+ if (firstItem?.current_period_end) {
+ return new Date(firstItem.current_period_end * 1000).toISOString()
+ }
+
+ if (subscription.cancel_at) {
+ return new Date(subscription.cancel_at * 1000).toISOString()
+ }
+
+ return null
+}
+
+async function updateBillingState(
+ stripeCustomerIdentifier: string,
+ updates: Record<string, unknown>
+) {
+ const adminClient = createSupabaseAdminClient()
+ const { error } = await adminClient
+ .from("user_profiles")
+ .update(updates)
+ .eq("stripe_customer_identifier", stripeCustomerIdentifier)
+
+ if (error) {
+ console.error("Failed to update billing state:", error)
+ }
+}
+
+async function handleCheckoutSessionCompleted(
+ session: Stripe.Checkout.Session
+) {
+ if (session.mode !== "subscription" || !session.subscription) return
+
+ const userIdentifier = session.client_reference_id
+ if (!userIdentifier) return
+
+ const subscription = await getStripe().subscriptions.retrieve(
+ session.subscription as string,
+ { expand: ["items.data"] }
+ )
+
+ const adminClient = createSupabaseAdminClient()
+ await adminClient
+ .from("user_profiles")
+ .update({
+ tier: determineTierFromSubscription(subscription),
+ stripe_customer_identifier: session.customer as string,
+ stripe_subscription_identifier: subscription.id,
+ stripe_subscription_status: subscription.status,
+ stripe_current_period_end: extractPeriodEnd(subscription),
+ })
+ .eq("id", userIdentifier)
+}
+
+async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
+ const stripeCustomerIdentifier = subscription.customer as string
+
+ const updates: Record<string, unknown> = {
+ stripe_subscription_status: subscription.status,
+ stripe_current_period_end: extractPeriodEnd(subscription),
+ }
+
+ if (subscription.status === "active") {
+ updates.tier = determineTierFromSubscription(subscription)
+ }
+
+ await updateBillingState(stripeCustomerIdentifier, updates)
+}
+
+async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
+ const stripeCustomerIdentifier = subscription.customer as string
+
+ await updateBillingState(stripeCustomerIdentifier, {
+ tier: "free",
+ stripe_subscription_identifier: null,
+ stripe_subscription_status: "canceled",
+ stripe_current_period_end: null,
+ })
+}
+
+async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) {
+ const stripeCustomerIdentifier = invoice.customer as string
+
+ await updateBillingState(stripeCustomerIdentifier, {
+ stripe_subscription_status: "past_due",
+ })
+}
+
+async function handleInvoicePaid(invoice: Stripe.Invoice) {
+ const stripeCustomerIdentifier = invoice.customer as string
+ const lineItem = invoice.lines?.data?.[0]
+ const priceIdentifier = (lineItem as unknown as { price?: { id?: string } } | undefined)?.price?.id
+ const developerPriceIdentifiers = [
+ process.env.STRIPE_DEVELOPER_MONTHLY_PRICE_IDENTIFIER,
+ process.env.STRIPE_DEVELOPER_YEARLY_PRICE_IDENTIFIER,
+ ]
+ const tier =
+ priceIdentifier && developerPriceIdentifiers.includes(priceIdentifier)
+ ? "developer"
+ : "pro"
+
+ await updateBillingState(stripeCustomerIdentifier, {
+ tier,
+ stripe_subscription_status: "active",
+ })
+}
+
+export async function POST(request: Request) {
+ const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"
+ const rateLimitResult = rateLimit(`webhook:${clientIp}`, 60, 60_000)
+ if (!rateLimitResult.success) {
+ return NextResponse.json({ error: "Too many requests" }, { status: 429 })
+ }
+
+ const body = await request.text()
+ const signature = request.headers.get("stripe-signature")
+
+ if (!signature) {
+ return NextResponse.json({ error: "Missing signature" }, { status: 400 })
+ }
+
+ let event: Stripe.Event
+
+ try {
+ event = getStripe().webhooks.constructEvent(
+ body,
+ signature,
+ process.env.STRIPE_WEBHOOK_SECRET!
+ )
+ } catch {
+ return NextResponse.json({ error: "Invalid signature" }, { status: 400 })
+ }
+
+ switch (event.type) {
+ case "checkout.session.completed":
+ await handleCheckoutSessionCompleted(
+ event.data.object as Stripe.Checkout.Session
+ )
+ break
+ case "customer.subscription.updated":
+ await handleSubscriptionUpdated(
+ event.data.object as Stripe.Subscription
+ )
+ break
+ case "customer.subscription.deleted":
+ await handleSubscriptionDeleted(
+ event.data.object as Stripe.Subscription
+ )
+ break
+ case "invoice.payment_failed":
+ await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice)
+ break
+ case "invoice.paid":
+ await handleInvoicePaid(event.data.object as Stripe.Invoice)
+ break
+ }
+
+ return NextResponse.json({ received: true })
+}
diff --git a/apps/web/app/api/export/route.ts b/apps/web/app/api/export/route.ts
new file mode 100644
index 0000000..4842f83
--- /dev/null
+++ b/apps/web/app/api/export/route.ts
@@ -0,0 +1,67 @@
+import { NextResponse } from "next/server"
+import { createSupabaseServerClient } from "@/lib/supabase/server"
+
+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 { data: profile } = await supabaseClient
+ .from("user_profiles")
+ .select("tier, display_name")
+ .eq("id", user.id)
+ .single()
+
+ const tier = profile?.tier ?? "free"
+
+ const { data: savedEntries } = await supabaseClient
+ .from("user_entry_states")
+ .select(
+ "entries(id, title, url, author, summary, published_at, feeds(title, url))"
+ )
+ .eq("user_id", user.id)
+ .eq("saved", true)
+
+ const exportData: Record<string, unknown> = {
+ exportedAt: new Date().toISOString(),
+ tier,
+ savedEntries:
+ (savedEntries ?? []).map((row) => (row as Record<string, unknown>).entries) ?? [],
+ }
+
+ if (tier === "pro" || tier === "developer") {
+ const [subscriptionsResult, foldersResult, mutedKeywordsResult] =
+ await Promise.all([
+ supabaseClient
+ .from("subscriptions")
+ .select("id, feed_id, folder_id, custom_title, feeds(title, url)")
+ .eq("user_id", user.id),
+ supabaseClient
+ .from("folders")
+ .select("id, name, position")
+ .eq("user_id", user.id),
+ supabaseClient
+ .from("muted_keywords")
+ .select("id, keyword")
+ .eq("user_id", user.id),
+ ])
+
+ exportData.subscriptions = subscriptionsResult.data ?? []
+ exportData.folders = foldersResult.data ?? []
+ exportData.mutedKeywords = mutedKeywordsResult.data ?? []
+ }
+
+ const jsonString = JSON.stringify(exportData, null, 2)
+
+ return new Response(jsonString, {
+ headers: {
+ "Content-Type": "application/json",
+ "Content-Disposition": `attachment; filename="asa-news-export-${new Date().toISOString().slice(0, 10)}.json"`,
+ },
+ })
+}
diff --git a/apps/web/app/api/share/[token]/route.ts b/apps/web/app/api/share/[token]/route.ts
new file mode 100644
index 0000000..45224aa
--- /dev/null
+++ b/apps/web/app/api/share/[token]/route.ts
@@ -0,0 +1,85 @@
+import { NextResponse } from "next/server"
+import { createSupabaseServerClient } from "@/lib/supabase/server"
+
+const MAX_NOTE_LENGTH = 1000
+
+export async function DELETE(
+ _request: Request,
+ { params }: { params: Promise<{ token: string }> }
+) {
+ const supabaseClient = await createSupabaseServerClient()
+ const {
+ data: { user },
+ } = await supabaseClient.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
+ }
+
+ const { token } = await params
+
+ const { error } = await supabaseClient
+ .from("shared_entries")
+ .delete()
+ .eq("share_token", token)
+ .eq("user_id", user.id)
+
+ if (error) {
+ return NextResponse.json(
+ { error: "Failed to delete share" },
+ { status: 500 }
+ )
+ }
+
+ return new Response(null, { status: 204 })
+}
+
+export async function PATCH(
+ request: Request,
+ { params }: { params: Promise<{ token: string }> }
+) {
+ const supabaseClient = await createSupabaseServerClient()
+ const {
+ data: { user },
+ } = await supabaseClient.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
+ }
+
+ const { token } = await params
+ const body = await request.json()
+ const rawNote = body.note
+
+ let note: string | null = null
+ if (rawNote !== undefined && rawNote !== null) {
+ if (typeof rawNote !== "string") {
+ return NextResponse.json(
+ { error: "note must be a string" },
+ { status: 400 }
+ )
+ }
+ if (rawNote.length > MAX_NOTE_LENGTH) {
+ return NextResponse.json(
+ { error: `note must be ${MAX_NOTE_LENGTH} characters or fewer` },
+ { status: 400 }
+ )
+ }
+ note = rawNote.trim() || null
+ }
+
+ const { error } = await supabaseClient
+ .from("shared_entries")
+ .update({ note })
+ .eq("share_token", token)
+ .eq("user_id", user.id)
+
+ if (error) {
+ return NextResponse.json(
+ { error: "Failed to update share" },
+ { status: 500 }
+ )
+ }
+
+ return NextResponse.json({ note })
+}
diff --git a/apps/web/app/api/share/route.ts b/apps/web/app/api/share/route.ts
new file mode 100644
index 0000000..2558560
--- /dev/null
+++ b/apps/web/app/api/share/route.ts
@@ -0,0 +1,132 @@
+import { NextResponse } from "next/server"
+import { randomBytes } from "crypto"
+import { createSupabaseServerClient } from "@/lib/supabase/server"
+
+const MAX_NOTE_LENGTH = 1000
+
+function buildOrigin(request: Request): string {
+ if (process.env.NEXT_PUBLIC_APP_URL) {
+ return process.env.NEXT_PUBLIC_APP_URL.replace(/\/$/, "")
+ }
+
+ return (
+ request.headers.get("origin") ??
+ `https://${request.headers.get("host")}`
+ )
+}
+
+export async function POST(request: Request) {
+ const supabaseClient = await createSupabaseServerClient()
+ const {
+ data: { user },
+ } = await supabaseClient.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
+ }
+
+ const { data: userProfile } = await supabaseClient
+ .from("user_profiles")
+ .select("tier")
+ .eq("id", user.id)
+ .single()
+
+ const tier = userProfile?.tier ?? "free"
+ const expiryDays = tier === "pro" || tier === "developer" ? 30 : 7
+ const expiresAt = new Date(
+ Date.now() + expiryDays * 24 * 60 * 60 * 1000
+ ).toISOString()
+
+ const body = await request.json()
+ const entryIdentifier = body.entryIdentifier as string
+ const rawNote = body.note
+
+ if (!entryIdentifier || typeof entryIdentifier !== "string") {
+ return NextResponse.json(
+ { error: "entryIdentifier is required" },
+ { status: 400 }
+ )
+ }
+
+ let note: string | null = null
+ if (rawNote !== undefined && rawNote !== null) {
+ if (typeof rawNote !== "string") {
+ return NextResponse.json(
+ { error: "note must be a string" },
+ { status: 400 }
+ )
+ }
+ if (rawNote.length > MAX_NOTE_LENGTH) {
+ return NextResponse.json(
+ { error: `note must be ${MAX_NOTE_LENGTH} characters or fewer` },
+ { status: 400 }
+ )
+ }
+ note = rawNote.trim() || null
+ }
+
+ const { data: entryAccess } = await supabaseClient
+ .from("entries")
+ .select("id, feed_id")
+ .eq("id", entryIdentifier)
+ .maybeSingle()
+
+ if (!entryAccess) {
+ return NextResponse.json(
+ { error: "Entry not found or not accessible" },
+ { status: 404 }
+ )
+ }
+
+ const { data: subscriptionAccess } = await supabaseClient
+ .from("subscriptions")
+ .select("id")
+ .eq("feed_id", entryAccess.feed_id)
+ .eq("user_id", user.id)
+ .maybeSingle()
+
+ if (!subscriptionAccess) {
+ return NextResponse.json(
+ { error: "You do not have access to this entry" },
+ { status: 403 }
+ )
+ }
+
+ const origin = buildOrigin(request)
+
+ const { data: existingShare } = await supabaseClient
+ .from("shared_entries")
+ .select("share_token")
+ .eq("entry_id", entryIdentifier)
+ .eq("user_id", user.id)
+ .maybeSingle()
+
+ if (existingShare) {
+ const shareUrl = `${origin}/shared/${existingShare.share_token}`
+ return NextResponse.json({
+ shareToken: existingShare.share_token,
+ shareUrl,
+ })
+ }
+
+ const shareToken = randomBytes(16).toString("base64url")
+
+ const { error } = await supabaseClient.from("shared_entries").insert({
+ user_id: user.id,
+ entry_id: entryIdentifier,
+ share_token: shareToken,
+ expires_at: expiresAt,
+ note,
+ })
+
+ if (error) {
+ return NextResponse.json(
+ { error: "Failed to create share" },
+ { status: 500 }
+ )
+ }
+
+ const shareUrl = `${origin}/shared/${shareToken}`
+
+ return NextResponse.json({ shareToken, shareUrl })
+}
diff --git a/apps/web/app/api/v1/entries/[entryIdentifier]/route.ts b/apps/web/app/api/v1/entries/[entryIdentifier]/route.ts
new file mode 100644
index 0000000..157366b
--- /dev/null
+++ b/apps/web/app/api/v1/entries/[entryIdentifier]/route.ts
@@ -0,0 +1,72 @@
+import { NextResponse } from "next/server"
+import { createSupabaseAdminClient } from "@/lib/supabase/admin"
+import { authenticateApiRequest } from "@/lib/api-auth"
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ entryIdentifier: string }> }
+) {
+ const authResult = await authenticateApiRequest(request)
+
+ if (!authResult.authenticated) {
+ return NextResponse.json(
+ { error: authResult.error },
+ { status: authResult.status }
+ )
+ }
+
+ const { entryIdentifier } = await params
+ const adminClient = createSupabaseAdminClient()
+
+ const { data: entry, error } = await adminClient
+ .from("entries")
+ .select(
+ "id, feed_id, guid, title, url, author, summary, content_html, image_url, published_at, enclosure_url, enclosure_type, enclosure_length, word_count"
+ )
+ .eq("id", entryIdentifier)
+ .is("owner_id", null)
+ .single()
+
+ if (error || !entry) {
+ return NextResponse.json({ error: "Entry not found" }, { status: 404 })
+ }
+
+ const { data: subscription } = await adminClient
+ .from("subscriptions")
+ .select("id")
+ .eq("user_id", authResult.user.userIdentifier)
+ .eq("feed_id", entry.feed_id)
+ .single()
+
+ if (!subscription) {
+ return NextResponse.json({ error: "Entry not found" }, { status: 404 })
+ }
+
+ const { data: stateRow } = await adminClient
+ .from("user_entry_states")
+ .select("read, saved")
+ .eq("user_id", authResult.user.userIdentifier)
+ .eq("entry_id", entryIdentifier)
+ .single()
+
+ return NextResponse.json({
+ entry: {
+ entryIdentifier: entry.id,
+ feedIdentifier: entry.feed_id,
+ guid: entry.guid,
+ title: entry.title,
+ url: entry.url,
+ author: entry.author,
+ summary: entry.summary,
+ contentHtml: entry.content_html,
+ imageUrl: entry.image_url,
+ publishedAt: entry.published_at,
+ enclosureUrl: entry.enclosure_url,
+ enclosureType: entry.enclosure_type,
+ enclosureLength: entry.enclosure_length,
+ wordCount: entry.word_count,
+ isRead: stateRow?.read ?? false,
+ isSaved: stateRow?.saved ?? false,
+ },
+ })
+}
diff --git a/apps/web/app/api/v1/entries/route.ts b/apps/web/app/api/v1/entries/route.ts
new file mode 100644
index 0000000..653c79b
--- /dev/null
+++ b/apps/web/app/api/v1/entries/route.ts
@@ -0,0 +1,114 @@
+import { NextResponse } from "next/server"
+import { createSupabaseAdminClient } from "@/lib/supabase/admin"
+import { authenticateApiRequest } from "@/lib/api-auth"
+
+export async function GET(request: Request) {
+ const authResult = await authenticateApiRequest(request)
+
+ if (!authResult.authenticated) {
+ return NextResponse.json(
+ { error: authResult.error },
+ { status: authResult.status }
+ )
+ }
+
+ const { searchParams } = new URL(request.url)
+ const feedIdentifier = searchParams.get("feedIdentifier")
+ const isRead = searchParams.get("isRead")
+ const isSaved = searchParams.get("isSaved")
+ const cursor = searchParams.get("cursor")
+ const limitParameter = searchParams.get("limit")
+ const limit = Math.min(Math.max(Number(limitParameter) || 50, 1), 100)
+
+ const adminClient = createSupabaseAdminClient()
+
+ const { data: subscriptionRows } = await adminClient
+ .from("subscriptions")
+ .select("feed_id")
+ .eq("user_id", authResult.user.userIdentifier)
+
+ const subscribedFeedIdentifiers = (subscriptionRows ?? []).map(
+ (row) => row.feed_id
+ )
+
+ if (subscribedFeedIdentifiers.length === 0) {
+ return NextResponse.json({ entries: [], nextCursor: null })
+ }
+
+ let query = adminClient
+ .from("entries")
+ .select(
+ "id, feed_id, guid, title, url, author, summary, image_url, published_at, enclosure_url, enclosure_type, user_entry_states!left(read, saved)"
+ )
+ .in("feed_id", subscribedFeedIdentifiers)
+ .is("owner_id", null)
+ .order("published_at", { ascending: false })
+ .limit(limit + 1)
+
+ if (feedIdentifier) {
+ query = query.eq("feed_id", feedIdentifier)
+ }
+
+ if (cursor) {
+ query = query.lt("published_at", cursor)
+ }
+
+ const { data, error } = await query
+
+ if (error) {
+ return NextResponse.json(
+ { error: "Failed to load entries" },
+ { status: 500 }
+ )
+ }
+
+ interface EntryRow {
+ id: string
+ feed_id: string
+ guid: string | null
+ title: string | null
+ url: string | null
+ author: string | null
+ summary: string | null
+ image_url: string | null
+ published_at: string | null
+ enclosure_url: string | null
+ enclosure_type: string | null
+ user_entry_states: Array<{ read: boolean; saved: boolean }> | null
+ }
+
+ let entries = (data as unknown as EntryRow[]).map((row) => {
+ const state = row.user_entry_states?.[0]
+
+ return {
+ entryIdentifier: row.id,
+ feedIdentifier: row.feed_id,
+ guid: row.guid,
+ title: row.title,
+ url: row.url,
+ author: row.author,
+ summary: row.summary,
+ imageUrl: row.image_url,
+ publishedAt: row.published_at,
+ enclosureUrl: row.enclosure_url,
+ enclosureType: row.enclosure_type,
+ isRead: state?.read ?? false,
+ isSaved: state?.saved ?? false,
+ }
+ })
+
+ if (isRead === "true") entries = entries.filter((entry) => entry.isRead)
+ if (isRead === "false") entries = entries.filter((entry) => !entry.isRead)
+ if (isSaved === "true") entries = entries.filter((entry) => entry.isSaved)
+ if (isSaved === "false") entries = entries.filter((entry) => !entry.isSaved)
+
+ const hasMore = entries.length > limit
+ if (hasMore) entries = entries.slice(0, limit)
+
+ const nextCursor =
+ hasMore && entries.length > 0
+ ? entries[entries.length - 1].publishedAt
+ : null
+
+ return NextResponse.json({ entries, nextCursor })
+}
diff --git a/apps/web/app/api/v1/feeds/route.ts b/apps/web/app/api/v1/feeds/route.ts
new file mode 100644
index 0000000..adf5422
--- /dev/null
+++ b/apps/web/app/api/v1/feeds/route.ts
@@ -0,0 +1,55 @@
+import { NextResponse } from "next/server"
+import { createSupabaseAdminClient } from "@/lib/supabase/admin"
+import { authenticateApiRequest } from "@/lib/api-auth"
+
+export async function GET(request: Request) {
+ const authResult = await authenticateApiRequest(request)
+
+ if (!authResult.authenticated) {
+ return NextResponse.json(
+ { error: authResult.error },
+ { status: authResult.status }
+ )
+ }
+
+ const adminClient = createSupabaseAdminClient()
+ const { data, error } = await adminClient
+ .from("subscriptions")
+ .select(
+ "id, custom_title, folder_id, feeds!inner(id, url, title, feed_type, site_url)"
+ )
+ .eq("user_id", authResult.user.userIdentifier)
+
+ if (error) {
+ return NextResponse.json(
+ { error: "Failed to load feeds" },
+ { status: 500 }
+ )
+ }
+
+ interface FeedRow {
+ id: string
+ custom_title: string | null
+ folder_id: string | null
+ feeds: {
+ id: string
+ url: string
+ title: string | null
+ feed_type: string | null
+ site_url: string | null
+ }
+ }
+
+ return NextResponse.json({
+ feeds: (data as unknown as FeedRow[]).map((row) => ({
+ subscriptionIdentifier: row.id,
+ feedIdentifier: row.feeds.id,
+ feedUrl: row.feeds.url,
+ feedTitle: row.feeds.title,
+ customTitle: row.custom_title,
+ feedType: row.feeds.feed_type,
+ siteUrl: row.feeds.site_url,
+ folderIdentifier: row.folder_id,
+ })),
+ })
+}
diff --git a/apps/web/app/api/v1/folders/route.ts b/apps/web/app/api/v1/folders/route.ts
new file mode 100644
index 0000000..5fb006d
--- /dev/null
+++ b/apps/web/app/api/v1/folders/route.ts
@@ -0,0 +1,36 @@
+import { NextResponse } from "next/server"
+import { createSupabaseAdminClient } from "@/lib/supabase/admin"
+import { authenticateApiRequest } from "@/lib/api-auth"
+
+export async function GET(request: Request) {
+ const authResult = await authenticateApiRequest(request)
+
+ if (!authResult.authenticated) {
+ return NextResponse.json(
+ { error: authResult.error },
+ { status: authResult.status }
+ )
+ }
+
+ const adminClient = createSupabaseAdminClient()
+ const { data, error } = await adminClient
+ .from("folders")
+ .select("id, name, position")
+ .eq("user_id", authResult.user.userIdentifier)
+ .order("position", { ascending: true })
+
+ if (error) {
+ return NextResponse.json(
+ { error: "Failed to load folders" },
+ { status: 500 }
+ )
+ }
+
+ return NextResponse.json({
+ folders: (data ?? []).map((row) => ({
+ identifier: row.id,
+ name: row.name,
+ position: row.position,
+ })),
+ })
+}
diff --git a/apps/web/app/api/v1/keys/[keyIdentifier]/route.ts b/apps/web/app/api/v1/keys/[keyIdentifier]/route.ts
new file mode 100644
index 0000000..8026f27
--- /dev/null
+++ b/apps/web/app/api/v1/keys/[keyIdentifier]/route.ts
@@ -0,0 +1,36 @@
+import { NextResponse } from "next/server"
+import { createSupabaseServerClient } from "@/lib/supabase/server"
+import { createSupabaseAdminClient } from "@/lib/supabase/admin"
+
+export async function DELETE(
+ _request: Request,
+ { params }: { params: Promise<{ keyIdentifier: string }> }
+) {
+ const supabaseClient = await createSupabaseServerClient()
+ const {
+ data: { user },
+ } = await supabaseClient.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
+ }
+
+ const { keyIdentifier } = await params
+
+ const adminClient = createSupabaseAdminClient()
+ const { error } = await adminClient
+ .from("api_keys")
+ .update({ revoked_at: new Date().toISOString() })
+ .eq("id", keyIdentifier)
+ .eq("user_id", user.id)
+ .is("revoked_at", null)
+
+ if (error) {
+ return NextResponse.json(
+ { error: "Failed to revoke API key" },
+ { status: 500 }
+ )
+ }
+
+ return NextResponse.json({ revoked: true })
+}
diff --git a/apps/web/app/api/v1/keys/route.ts b/apps/web/app/api/v1/keys/route.ts
new file mode 100644
index 0000000..7ac7144
--- /dev/null
+++ b/apps/web/app/api/v1/keys/route.ts
@@ -0,0 +1,116 @@
+import { NextResponse } from "next/server"
+import { createSupabaseServerClient } from "@/lib/supabase/server"
+import { createSupabaseAdminClient } from "@/lib/supabase/admin"
+import { generateApiKey } from "@/lib/api-key"
+import { TIER_LIMITS, type SubscriptionTier } from "@asa-news/shared"
+import { rateLimit } from "@/lib/rate-limit"
+
+const MAXIMUM_ACTIVE_KEYS = 5
+
+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: keys, error } = await adminClient
+ .from("api_keys")
+ .select("id, key_prefix, label, created_at, last_used_at, revoked_at")
+ .eq("user_id", user.id)
+ .order("created_at", { ascending: false })
+
+ if (error) {
+ return NextResponse.json(
+ { error: "Failed to load API keys" },
+ { status: 500 }
+ )
+ }
+
+ return NextResponse.json({
+ keys: keys.map((key) => ({
+ identifier: key.id,
+ keyPrefix: key.key_prefix,
+ label: key.label,
+ createdAt: key.created_at,
+ lastUsedAt: key.last_used_at,
+ isRevoked: key.revoked_at !== null,
+ })),
+ })
+}
+
+export async function POST(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(`api-keys:${user.id}`, 10, 60_000)
+ if (!rateLimitResult.success) {
+ return NextResponse.json({ error: "Too many requests" }, { status: 429 })
+ }
+
+ const adminClient = createSupabaseAdminClient()
+
+ const { data: userProfile } = await adminClient
+ .from("user_profiles")
+ .select("tier")
+ .eq("id", user.id)
+ .single()
+
+ if (
+ !userProfile ||
+ !TIER_LIMITS[userProfile.tier as SubscriptionTier]?.allowsApiAccess
+ ) {
+ return NextResponse.json(
+ { error: "API access requires the developer plan" },
+ { status: 403 }
+ )
+ }
+
+ const { count: activeKeyCount } = await adminClient
+ .from("api_keys")
+ .select("id", { count: "exact", head: true })
+ .eq("user_id", user.id)
+ .is("revoked_at", null)
+
+ if ((activeKeyCount ?? 0) >= MAXIMUM_ACTIVE_KEYS) {
+ return NextResponse.json(
+ { error: `Maximum of ${MAXIMUM_ACTIVE_KEYS} active keys allowed` },
+ { status: 400 }
+ )
+ }
+
+ const body = await request.json().catch(() => ({}))
+ const label = typeof body.label === "string" ? body.label.trim() || null : null
+
+ const { fullKey, keyHash, keyPrefix } = generateApiKey()
+
+ const { error: insertError } = await adminClient.from("api_keys").insert({
+ user_id: user.id,
+ key_hash: keyHash,
+ key_prefix: keyPrefix,
+ label,
+ })
+
+ if (insertError) {
+ return NextResponse.json(
+ { error: "Failed to create API key" },
+ { status: 500 }
+ )
+ }
+
+ return NextResponse.json({
+ key: fullKey,
+ keyPrefix,
+ label,
+ })
+}
diff --git a/apps/web/app/api/v1/profile/route.ts b/apps/web/app/api/v1/profile/route.ts
new file mode 100644
index 0000000..f7ec308
--- /dev/null
+++ b/apps/web/app/api/v1/profile/route.ts
@@ -0,0 +1,49 @@
+import { NextResponse } from "next/server"
+import { createSupabaseAdminClient } from "@/lib/supabase/admin"
+import { authenticateApiRequest } from "@/lib/api-auth"
+import { TIER_LIMITS, type SubscriptionTier } from "@asa-news/shared"
+
+export async function GET(request: Request) {
+ const authResult = await authenticateApiRequest(request)
+
+ if (!authResult.authenticated) {
+ return NextResponse.json(
+ { error: authResult.error },
+ { status: authResult.status }
+ )
+ }
+
+ const adminClient = createSupabaseAdminClient()
+ const { data: profile, error } = await adminClient
+ .from("user_profiles")
+ .select(
+ "tier, feed_count, folder_count, muted_keyword_count, custom_feed_count"
+ )
+ .eq("id", authResult.user.userIdentifier)
+ .single()
+
+ if (error || !profile) {
+ return NextResponse.json(
+ { error: "Failed to load profile" },
+ { status: 500 }
+ )
+ }
+
+ const tierLimits = TIER_LIMITS[profile.tier as SubscriptionTier]
+
+ return NextResponse.json({
+ profile: {
+ tier: profile.tier,
+ feedCount: profile.feed_count,
+ folderCount: profile.folder_count,
+ mutedKeywordCount: profile.muted_keyword_count,
+ customFeedCount: profile.custom_feed_count,
+ limits: {
+ maximumFeeds: tierLimits.maximumFeeds,
+ maximumFolders: tierLimits.maximumFolders,
+ maximumMutedKeywords: tierLimits.maximumMutedKeywords,
+ maximumCustomFeeds: tierLimits.maximumCustomFeeds,
+ },
+ },
+ })
+}
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,
+ })
+ }
+}