fix(t3-chat-web): parse cookies + convexSessionId from stored credential (#3007) (#3162)

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 <minhtran162@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-04 16:57:48 -03:00
committed by GitHub
parent a2fd56c313
commit f8fb3d524d
3 changed files with 172 additions and 43 deletions

View File

@@ -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<string, unknown>) : {};
/**
* 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=<Cookie header>\nconvexSessionId=<id>" — structured form
*/
export function parseT3Credentials(creds: unknown): T3ChatCredentials {
const rawCreds =
typeof creds === "object" && creds !== null ? (creds as Record<string, unknown>) : {};
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<boolean> {
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<string, unknown>;
// 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 {

View File

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

View File

@@ -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",
};
}