diff options
| author | Fuwn <[email protected]> | 2026-02-07 01:42:57 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-07 01:42:57 -0800 |
| commit | 5c5b1993edd890a80870ee05607ac5f088191d4e (patch) | |
| tree | a721b76bcd49ba10826c53efc87302c7a689512f /apps/web/app/api/v1/keys | |
| download | asa.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.ts | 36 | ||||
| -rw-r--r-- | apps/web/app/api/v1/keys/route.ts | 116 |
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, + }) +} |