summaryrefslogtreecommitdiff
path: root/apps/web/lib/api-auth.ts
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/lib/api-auth.ts
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/lib/api-auth.ts')
-rw-r--r--apps/web/lib/api-auth.ts80
1 files changed, 80 insertions, 0 deletions
diff --git a/apps/web/lib/api-auth.ts b/apps/web/lib/api-auth.ts
new file mode 100644
index 0000000..309fbe9
--- /dev/null
+++ b/apps/web/lib/api-auth.ts
@@ -0,0 +1,80 @@
+import { createSupabaseAdminClient } from "@/lib/supabase/admin"
+import { hashApiKey } from "@/lib/api-key"
+import { rateLimit } from "@/lib/rate-limit"
+
+interface AuthenticatedApiUser {
+ userIdentifier: string
+ tier: string
+}
+
+export async function authenticateApiRequest(
+ request: Request
+): Promise<
+ | { authenticated: true; user: AuthenticatedApiUser }
+ | { authenticated: false; status: number; error: string }
+> {
+ const authorizationHeader = request.headers.get("authorization")
+
+ if (!authorizationHeader?.startsWith("Bearer ")) {
+ return {
+ authenticated: false,
+ status: 401,
+ error: "Missing or invalid Authorization header",
+ }
+ }
+
+ const apiKey = authorizationHeader.slice(7)
+
+ if (!apiKey.startsWith("asn_")) {
+ return { authenticated: false, status: 401, error: "Invalid API key format" }
+ }
+
+ const keyHash = hashApiKey(apiKey)
+ const adminClient = createSupabaseAdminClient()
+
+ const { data: keyRow } = await adminClient
+ .from("api_keys")
+ .select("user_id")
+ .eq("key_hash", keyHash)
+ .is("revoked_at", null)
+ .single()
+
+ if (!keyRow) {
+ return { authenticated: false, status: 401, error: "Invalid or revoked API key" }
+ }
+
+ const { data: userProfile } = await adminClient
+ .from("user_profiles")
+ .select("tier")
+ .eq("id", keyRow.user_id)
+ .single()
+
+ if (!userProfile || userProfile.tier !== "developer") {
+ return {
+ authenticated: false,
+ status: 403,
+ error: "API access requires the developer plan",
+ }
+ }
+
+ const rateLimitResult = rateLimit(`api:${keyRow.user_id}`, 100, 60_000)
+
+ if (!rateLimitResult.success) {
+ return {
+ authenticated: false,
+ status: 429,
+ error: `Rate limit exceeded. ${rateLimitResult.remaining} requests remaining.`,
+ }
+ }
+
+ adminClient
+ .from("api_keys")
+ .update({ last_used_at: new Date().toISOString() })
+ .eq("key_hash", keyHash)
+ .then(() => {})
+
+ return {
+ authenticated: true,
+ user: { userIdentifier: keyRow.user_id, tier: userProfile.tier },
+ }
+}