mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
committed by
GitHub
parent
a2fd56c313
commit
f8fb3d524d
@@ -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 {
|
||||
|
||||
84
tests/unit/t3-chat-web-credentials.test.ts
Normal file
84
tests/unit/t3-chat-web-credentials.test.ts
Normal 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");
|
||||
});
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user