feat(chat-worker): add JWT auth and PostgREST helpers
This commit is contained in:
parent
d3f4287200
commit
f6a56fdbdd
2 changed files with 116 additions and 0 deletions
34
apps/chat-worker/src/lib/auth.ts
Normal file
34
apps/chat-worker/src/lib/auth.ts
Normal 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);
|
||||
}
|
||||
82
apps/chat-worker/src/lib/supabase.ts
Normal file
82
apps/chat-worker/src/lib/supabase.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue