From f8fb3d524dd1d202e31d3e155c28da33faf6bafc Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:57:48 -0300 Subject: [PATCH] fix(t3-chat-web): parse cookies + convexSessionId from stored credential (#3007) (#3162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The executor read credentials.cookies/convexSessionId, but the pipeline only stores the pasted string under apiKey → t3.chat always 400'd. Parse both values from apiKey (fallback accessToken), mirroring validation.ts. Co-authored-by: minhtran162 --- open-sse/executors/t3-chat-web.ts | 108 ++++++++++++++------- tests/unit/t3-chat-web-credentials.test.ts | 84 ++++++++++++++++ tests/unit/t3-chat-web.test.ts | 23 +++-- 3 files changed, 172 insertions(+), 43 deletions(-) create mode 100644 tests/unit/t3-chat-web-credentials.test.ts diff --git a/open-sse/executors/t3-chat-web.ts b/open-sse/executors/t3-chat-web.ts index 610d3cf647..027e5e75d5 100644 --- a/open-sse/executors/t3-chat-web.ts +++ b/open-sse/executors/t3-chat-web.ts @@ -32,6 +32,9 @@ const TSS_ACCEPT = "application/x-tss-framed, application/x-ndjson, application/ // ─── Types ─────────────────────────────────────────────────────────────────── export interface T3ChatCredentials { + /** Parsed Cookie header value, guaranteed to include convex-session-id when present. */ + cookieHeader: string; + /** Raw cookies portion (without the synthesized convex-session-id suffix). */ cookies: string; /** convex-session-id — stored as a cookie by t3.chat, sent in the Cookie header */ convexSessionId: string; @@ -39,13 +42,69 @@ export interface T3ChatCredentials { // ─── Helpers ───────────────────────────────────────────────────────────────── -function validateCredentials(creds: unknown): creds is T3ChatCredentials { - const raw = typeof creds === "object" && creds !== null ? (creds as Record) : {}; +/** + * Parse the single stored credential into a structured t3.chat cookie object. + * + * The credential pipeline (`src/sse/services/auth.ts`) stores the single pasted + * string as `credentials.apiKey` (fallback `accessToken`) — it never produces + * `cookies`/`convexSessionId` fields. So we parse the raw string here, mirroring + * the validator in `src/lib/providers/validation.ts` (#3007). + * + * Accepted forms: + * (a) "convex-session-id=abc; sessionToken=xyz" — plain Cookie header + * (b) full Cookie header already containing convex-session-id=... + * (c) "cookies=\nconvexSessionId=" — structured form + */ +export function parseT3Credentials(creds: unknown): T3ChatCredentials { + const rawCreds = + typeof creds === "object" && creds !== null ? (creds as Record) : {}; + const raw = String(rawCreds.apiKey ?? rawCreds.accessToken ?? "").trim(); + if (!raw) { + return { cookieHeader: "", cookies: "", convexSessionId: "" }; + } + + let cookieHeader = raw; + let convexSessionId = ""; + + if (raw.includes("convexSessionId") || raw.includes("convex-session-id")) { + // Structured / multi-part format: split on separators and pull out the id. + const parts = raw.split(/[,;\n]/).map((s) => s.trim()); + const cookieParts: string[] = []; + for (const part of parts) { + if (part.startsWith("convexSessionId=") || part.startsWith("convex-session-id=")) { + convexSessionId = part.split("=").slice(1).join("="); + } else if (part.startsWith("cookies=")) { + cookieParts.push(part.slice("cookies=".length)); + } else if (part.includes("=")) { + cookieParts.push(part); + } + } + if (cookieParts.length) cookieHeader = cookieParts.join("; "); + } + + // Synthesize the final Cookie header, appending convex-session-id only when it + // was provided separately and isn't already embedded in the header. + const finalCookie = + convexSessionId && !cookieHeader.includes("convex-session-id") + ? `${cookieHeader}; convex-session-id=${convexSessionId}` + : cookieHeader; + + // Derive convexSessionId from an embedded header form (b) for validation. + if (!convexSessionId) { + const m = finalCookie.match(/convex-session-id=([^;]+)/); + if (m) convexSessionId = m[1].trim(); + } + + return { cookieHeader: finalCookie, cookies: cookieHeader, convexSessionId }; +} + +export function validateT3Credentials(creds: T3ChatCredentials | null | undefined): boolean { + if (!creds) return false; return ( - typeof raw.cookies === "string" && - raw.cookies.length > 0 && - typeof raw.convexSessionId === "string" && - raw.convexSessionId.length > 0 + typeof creds.cookieHeader === "string" && + creds.cookieHeader.length > 0 && + typeof creds.convexSessionId === "string" && + creds.convexSessionId.length > 0 ); } @@ -62,17 +121,6 @@ function buildErrorResponse(status: number, message: string): Response { ); } -/** - * Build Cookie header value. Includes the convex-session-id as a cookie - * (confirmed from live capture: t3.chat sets convex-session-id as a cookie, - * not as a separate header). - */ -function buildCookieHeader(cookies: string, convexSessionId: string): string { - const base = cookies.trim(); - if (base.includes("convex-session-id")) return base; - return `${base}; convex-session-id=${convexSessionId}`; -} - /** * Build standard TanStack Start headers matching live captured traffic. * The x-deployment-id header is optional but helps CDN routing. @@ -275,7 +323,8 @@ export class T3ChatWebExecutor extends BaseExecutor { signal?: AbortSignal ): Promise { try { - if (!validateCredentials(credentials)) return false; + const parsed = parseT3Credentials(credentials); + if (!validateT3Credentials(parsed)) return false; // Probe: HEAD to t3.chat base — confirms site reachable and cookies accepted. // 200/302/404 all indicate reachability; 5xx = down. @@ -283,7 +332,7 @@ export class T3ChatWebExecutor extends BaseExecutor { method: "HEAD", headers: { "User-Agent": USER_AGENT, - Cookie: buildCookieHeader(credentials.cookies, credentials.convexSessionId), + Cookie: parsed.cookieHeader, }, signal, }); @@ -299,19 +348,15 @@ export class T3ChatWebExecutor extends BaseExecutor { role: string; content: string | unknown; }>; - const rawCreds = credentials as unknown as Record; - - // 1. Validate credentials - if (!validateCredentials(rawCreds)) { - const missing = !rawCreds.cookies - ? "cookies" - : !rawCreds.convexSessionId - ? "convexSessionId" - : "both fields"; + // 1. Parse + validate credentials. The credential pipeline stores the single + // pasted string as `apiKey` (fallback `accessToken`); parse out the Cookie + // header + convex-session-id (#3007) instead of expecting pre-structured fields. + const parsed = parseT3Credentials(credentials); + if (!validateT3Credentials(parsed)) { return { response: buildErrorResponse( 400, - `t3.chat credentials invalid: missing or empty ${missing}. Both 'cookies' and 'convexSessionId' are required.` + "t3.chat credentials invalid: paste your full Cookie header (including convex-session-id) from t3.chat." ), url: `${SERVER_FN_PREFIX}...`, headers: {}, @@ -319,10 +364,7 @@ export class T3ChatWebExecutor extends BaseExecutor { }; } - const cookieHeader = buildCookieHeader( - rawCreds.cookies as string, - rawCreds.convexSessionId as string - ); + const cookieHeader = parsed.cookieHeader; const headers = buildServerFnHeaders(cookieHeader); try { diff --git a/tests/unit/t3-chat-web-credentials.test.ts b/tests/unit/t3-chat-web-credentials.test.ts new file mode 100644 index 0000000000..2cb0754d68 --- /dev/null +++ b/tests/unit/t3-chat-web-credentials.test.ts @@ -0,0 +1,84 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + parseT3Credentials, + validateT3Credentials, + T3ChatWebExecutor, +} from "@omniroute/open-sse/executors/t3-chat-web.ts"; + +// Issue #3007: t3.chat web cookie providers not working. +// The credential pipeline stores the single pasted string as `credentials.apiKey` +// (fallback `accessToken`), but the executor used to read `credentials.cookies` +// and `credentials.convexSessionId` directly — fields nothing ever produces — so +// validation always failed with 400 "missing or empty cookies". +// These tests feed the three valid forms via `apiKey` and assert the parser/ +// validator accept them and produce a Cookie header carrying convex-session-id. + +test("parseT3Credentials: form (a) — convex-session-id=...; sessionToken=...", () => { + const creds = { apiKey: "convex-session-id=abc; sessionToken=xyz" }; + const parsed = parseT3Credentials(creds); + assert.ok(parsed, "parser should produce credentials"); + assert.ok(validateT3Credentials(parsed), "credentials should be valid"); + assert.match(parsed.cookieHeader, /convex-session-id=/); + assert.match(parsed.cookieHeader, /sessionToken=xyz/); +}); + +test("parseT3Credentials: form (b) — full Cookie header already containing convex-session-id", () => { + const creds = { + apiKey: + "__Secure-better-auth.session_token=foo; convex-session-id=session-123; theme=dark", + }; + const parsed = parseT3Credentials(creds); + assert.ok(parsed); + assert.ok(validateT3Credentials(parsed)); + assert.match(parsed.cookieHeader, /convex-session-id=session-123/); + // No duplication of convex-session-id + assert.equal( + parsed.cookieHeader.match(/convex-session-id=/g)?.length, + 1, + "convex-session-id must appear exactly once" + ); +}); + +test("parseT3Credentials: form (c) — structured cookies=...\\nconvexSessionId=...", () => { + const creds = { + apiKey: "cookies=__Secure-session=foo; theme=dark\nconvexSessionId=conv-789", + }; + const parsed = parseT3Credentials(creds); + assert.ok(parsed); + assert.ok(validateT3Credentials(parsed)); + assert.match(parsed.cookieHeader, /convex-session-id=conv-789/); + assert.match(parsed.cookieHeader, /__Secure-session=foo/); +}); + +test("parseT3Credentials: reads accessToken fallback when apiKey is absent", () => { + const creds = { accessToken: "convex-session-id=tok; sessionToken=zzz" }; + const parsed = parseT3Credentials(creds); + assert.ok(parsed); + assert.ok(validateT3Credentials(parsed)); + assert.match(parsed.cookieHeader, /convex-session-id=tok/); +}); + +test("validateT3Credentials: empty apiKey fails (negative case)", () => { + const parsed = parseT3Credentials({ apiKey: "" }); + assert.equal(validateT3Credentials(parsed), false); +}); + +test("validateT3Credentials: undefined credentials fail", () => { + const parsed = parseT3Credentials(undefined); + assert.equal(validateT3Credentials(parsed), false); +}); + +test("execute(): empty apiKey still returns a 400 error response", async () => { + const executor = new T3ChatWebExecutor(); + const result = await executor.execute({ + model: "gpt-4", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "" } as never, + } as never); + assert.equal(result.response.status, 400); + const body = await result.response.json(); + assert.equal(body.error.code, "HTTP_400"); +}); diff --git a/tests/unit/t3-chat-web.test.ts b/tests/unit/t3-chat-web.test.ts index a84ec71b50..c0d28ac49f 100644 --- a/tests/unit/t3-chat-web.test.ts +++ b/tests/unit/t3-chat-web.test.ts @@ -50,13 +50,16 @@ test("execute returns 400 with empty credentials", async () => { assert.ok(body.error?.message, "Should have error message"); }); -test("execute returns 400 with cookies present but convexSessionId missing", async () => { +// #3007: credentials arrive as the single pasted string under `apiKey` +// (fallback `accessToken`) — the old `cookies`/`convexSessionId` object shape +// was never produced by the credential pipeline, so it must be rejected. +test("execute returns 400 with legacy cookies/convexSessionId object shape (no apiKey)", async () => { const executor = new T3ChatWebExecutor(); const result = await executor.execute({ model: "gpt-4o", body: { messages: [{ role: "user", content: "hi" }] }, stream: true, - credentials: { cookies: "some-cookie=value" }, + credentials: { cookies: "some-cookie=value", convexSessionId: "abc" }, signal: AbortSignal.timeout(5000), }); assert.equal(result.response.status, 400); @@ -64,13 +67,13 @@ test("execute returns 400 with cookies present but convexSessionId missing", asy assert.ok(body.error?.message?.length > 0, "Should have error message"); }); -test("execute returns 400 with convexSessionId present but cookies missing", async () => { +test("execute returns 400 with apiKey that has no convex-session-id", async () => { const executor = new T3ChatWebExecutor(); const result = await executor.execute({ model: "gpt-4o", body: { messages: [{ role: "user", content: "hi" }] }, stream: true, - credentials: { convexSessionId: "session-abc-123" }, + credentials: { apiKey: "sessionToken=value; theme=dark" }, signal: AbortSignal.timeout(5000), }); assert.equal(result.response.status, 400); @@ -78,13 +81,13 @@ test("execute returns 400 with convexSessionId present but cookies missing", asy assert.ok(body.error?.message?.length > 0, "Should have error message"); }); -test("execute returns 400 with both fields as empty strings", async () => { +test("execute returns 400 with empty apiKey string", async () => { const executor = new T3ChatWebExecutor(); const result = await executor.execute({ model: "gpt-4o", body: { messages: [{ role: "user", content: "hi" }] }, stream: true, - credentials: { cookies: "", convexSessionId: "" }, + credentials: { apiKey: "" }, signal: AbortSignal.timeout(5000), }); assert.equal(result.response.status, 400); @@ -98,18 +101,18 @@ test("testConnection returns false with empty credentials", async () => { assert.equal(result, false); }); -test("testConnection returns false when convexSessionId is missing", async () => { +test("testConnection returns false when convex-session-id is missing from apiKey", async () => { const executor = new T3ChatWebExecutor(); - const result = await executor.testConnection({ cookies: "some-cookie=value" }); + const result = await executor.testConnection({ apiKey: "some-cookie=value" }); assert.equal(result, false); }); // ─── API flow helpers ───────────────────────────────────────────────────── function makeValidCreds() { + // The credential pipeline stores the single pasted string under `apiKey`. return { - cookies: "t3-auth=session-token-xyz; other=value", - convexSessionId: "convex-session-id-abc123", + apiKey: "t3-auth=session-token-xyz; other=value; convex-session-id=convex-abc123", }; }