feat(chat-worker): add JWT auth and PostgREST helpers

This commit is contained in:
Arthur Belleville 2026-04-11 12:00:25 +02:00
parent d3f4287200
commit f6a56fdbdd
No known key found for this signature in database
2 changed files with 116 additions and 0 deletions

View file

@ -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<AuthResult> {
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);
}

View file

@ -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<string, string> {
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<T>(table: string, data: Record<string, unknown>): Promise<T[]> {
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<T[]>;
}
/** Upsert a row (requires Prefer: resolution=merge-duplicates). */
async upsert<T>(table: string, data: Record<string, unknown>, onConflict: string): Promise<T[]> {
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<T[]>;
}
/** Select rows with PostgREST query string. */
async select<T>(table: string, query: string): Promise<T[]> {
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<T[]>;
}
/** Select with exact count header for unread queries. */
async count(table: string, query: string): Promise<number> {
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);
}
}