From f6a56fdbddcd84d5fb23b15be9504d5ffaab3e3d Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Apr 2026 12:00:25 +0200 Subject: [PATCH] feat(chat-worker): add JWT auth and PostgREST helpers --- apps/chat-worker/src/lib/auth.ts | 34 ++++++++++++ apps/chat-worker/src/lib/supabase.ts | 82 ++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 apps/chat-worker/src/lib/auth.ts create mode 100644 apps/chat-worker/src/lib/supabase.ts diff --git a/apps/chat-worker/src/lib/auth.ts b/apps/chat-worker/src/lib/auth.ts new file mode 100644 index 0000000..62ef91e --- /dev/null +++ b/apps/chat-worker/src/lib/auth.ts @@ -0,0 +1,34 @@ +import { jwtVerify } from "jose"; + +interface AuthResult { + userId: string; + email: string | null; +} + +/** + * Verify a Supabase JWT and extract the user ID. + * Supabase JWTs are signed with the JWT secret and contain the user ID in the `sub` claim. + */ +export async function verifyJwt(token: string, jwtSecret: string): Promise { + const secret = new TextEncoder().encode(jwtSecret); + const { payload } = await jwtVerify(token, secret, { + issuer: "https://mhcafqvzbrrwvahpvvzd.supabase.co/auth/v1", + }); + + if (!payload.sub) { + throw new Error("Missing sub claim in JWT"); + } + + return { + userId: payload.sub, + email: (payload.email as string) ?? null, + }; +} + +/** + * Extract Bearer token from Authorization header. + */ +export function extractToken(authHeader: string | undefined): string | null { + if (!authHeader?.startsWith("Bearer ")) return null; + return authHeader.slice(7); +} diff --git a/apps/chat-worker/src/lib/supabase.ts b/apps/chat-worker/src/lib/supabase.ts new file mode 100644 index 0000000..f572bd0 --- /dev/null +++ b/apps/chat-worker/src/lib/supabase.ts @@ -0,0 +1,82 @@ +/** + * Thin PostgREST client using fetch — no Supabase SDK dependency. + * Used by both the Worker (history queries) and the Durable Object (message persistence). + */ +export class PostgREST { + private baseUrl: string; + private serviceRoleKey: string; + + constructor(supabaseUrl: string, serviceRoleKey: string) { + this.baseUrl = `${supabaseUrl}/rest/v1`; + this.serviceRoleKey = serviceRoleKey; + } + + private headers(): Record { + return { + "apikey": this.serviceRoleKey, + "Authorization": `Bearer ${this.serviceRoleKey}`, + "Content-Type": "application/json", + "Prefer": "return=representation", + }; + } + + /** Insert a row and return the inserted data. */ + async insert(table: string, data: Record): Promise { + const res = await fetch(`${this.baseUrl}/${table}`, { + method: "POST", + headers: this.headers(), + body: JSON.stringify(data), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`PostgREST insert failed (${res.status}): ${body}`); + } + return res.json() as Promise; + } + + /** Upsert a row (requires Prefer: resolution=merge-duplicates). */ + async upsert(table: string, data: Record, onConflict: string): Promise { + const headers = this.headers(); + headers["Prefer"] = "return=representation,resolution=merge-duplicates"; + const res = await fetch(`${this.baseUrl}/${table}?on_conflict=${onConflict}`, { + method: "POST", + headers, + body: JSON.stringify(data), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`PostgREST upsert failed (${res.status}): ${body}`); + } + return res.json() as Promise; + } + + /** Select rows with PostgREST query string. */ + async select(table: string, query: string): Promise { + const res = await fetch(`${this.baseUrl}/${table}?${query}`, { + method: "GET", + headers: this.headers(), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`PostgREST select failed (${res.status}): ${body}`); + } + return res.json() as Promise; + } + + /** Select with exact count header for unread queries. */ + async count(table: string, query: string): Promise { + const headers = this.headers(); + headers["Prefer"] = "count=exact"; + headers["Range-Unit"] = "items"; + headers["Range"] = "0-0"; + const res = await fetch(`${this.baseUrl}/${table}?${query}`, { + method: "HEAD", + headers, + }); + const contentRange = res.headers.get("Content-Range"); + if (!contentRange) return 0; + // Content-Range format: "0-0/42" or "*/0" + const total = contentRange.split("/")[1]; + return total === "*" ? 0 : parseInt(total, 10); + } +}