fix: close chat deletion and transport errors

This commit is contained in:
Jesse_Chen
2026-07-20 14:44:24 +08:00
parent f7b967f3d9
commit 4ffebdd5be
5 changed files with 78 additions and 63 deletions
@@ -15,9 +15,14 @@ function isAbort(error: unknown, signal?: AbortSignal): boolean {
|| (error instanceof DOMException && error.name === "AbortError");
}
function isJsonSyntaxError(error: unknown): boolean {
return error instanceof SyntaxError
|| (error instanceof DOMException && error.name === "SyntaxError");
}
function isLostResponse(error: unknown, signal?: AbortSignal): boolean {
return !isAbort(error, signal)
&& (error instanceof TypeError || error instanceof SyntaxError);
&& (error instanceof TypeError || isJsonSyntaxError(error));
}
async function postOnce(input: JsonPostInput): Promise<JsonPostResult> {
@@ -30,7 +35,7 @@ async function postOnce(input: JsonPostInput): Promise<JsonPostResult> {
try {
return { response, payload: await response.json() };
} catch (error) {
if (!response.ok && isLostResponse(error, input.signal)) {
if (!response.ok && isJsonSyntaxError(error)) {
return { response, payload: null };
}
throw error;
@@ -0,0 +1,7 @@
begin;
drop policy if exists chat_sessions_delete_own on public.chat_sessions;
create policy chat_sessions_delete_own
on public.chat_sessions for delete to authenticated
using ((select auth.uid()) = user_id);
grant delete on table public.chat_sessions to authenticated;
commit;
@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import test from "node:test";
import { postJson } from "../src/lib/birth-time-client-transport.ts";
test("non-json 502 returns a null payload instead of leaking WebKit syntax text", async () => {
const original = globalThis.fetch;
globalThis.fetch = async () => new Response("bad gateway", { status: 502 });
try {
const result = await postJson({ url: "/x", body: "{}", retryLostResponse: false });
assert.equal(result.response.status, 502);
assert.equal(result.payload, null);
} finally {
globalThis.fetch = original;
}
});
test("DOMException SyntaxError is classified as a lost response", async () => {
const original = globalThis.fetch;
let attempts = 0;
globalThis.fetch = async () => {
attempts += 1;
if (attempts === 1) throw new DOMException("pattern", "SyntaxError");
return Response.json({ ok: true });
};
try {
const result = await postJson({ url: "/x", body: "{}", retryLostResponse: true });
assert.deepEqual(result.payload, { ok: true });
} finally {
globalThis.fetch = original;
}
});
@@ -0,0 +1,13 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const sql = readFileSync(new URL(
"../supabase/migrations/20260720000000_chat_delete_and_dynamic_candidate_confirmation.sql",
import.meta.url,
), "utf8");
test("chat sessions expose owner-only delete", () => {
assert.match(sql, /create policy chat_sessions_delete_own[\s\S]*for delete[\s\S]*auth\.uid\(\).*user_id/i);
assert.match(sql, /grant delete on table public\.chat_sessions to authenticated/i);
});