summaryrefslogtreecommitdiff
path: root/apps/web/app/api/v1/keys
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/v1/keys
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/v1/keys')
-rw-r--r--apps/web/app/api/v1/keys/[keyIdentifier]/route.ts36
-rw-r--r--apps/web/app/api/v1/keys/route.ts116
2 files changed, 152 insertions, 0 deletions
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,
+ })
+}