mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-17 04:12:17 +03:00
Migrates the Claude, Grok, LMArena, Notion and Perplexity web-cookie transports from the tls-client-node/Koffi sidecar to the exactly pinned wreq-js 3.2.0 runtime, keeping the per-provider browser/OS profiles, making request cookies ephemeral, bounding and generation-protecting the shared native transport pool, removing the legacy downloader and repair path, and carrying the native binding and license evidence through the npm, standalone, Electron, Docker and Bun packaging surfaces. This is the consolidation of the two competing migrations, and the consolidation was decided by evidence rather than by preference. #11753's six suites were installed over this implementation and run as an independent specification: 31 of 36 passed. All five failures are artefacts of #11753 being the older design, not coverage gaps — - two hardcode the 3.0.0 pin in their assertions (this branch pins 3.2.0, which is what the release tip already resolves; #11753's 3.0.0 would have conflicted); - one reads open-sse/services/chatgptTlsClient.ts, deleted when #11754 retired ChatGPT Web, so the test is stale against the current tip; - two import WREQ_JS_NATIVE_BINARY_NAMES / resolveWreqJsNativeBinaryName, which this branch redesigned into WREQ_JS_NATIVE_BINDINGS / resolveWreqJsNativeBinding plus WREQ_JS_VERSION — a rename from modelling natives as file names to modelling them as package bindings, verified as an API difference rather than a lost capability (the linux-x64-gnu .node is present and serviceable). This branch is also the strict superset by scope: 7 files exclusive to it, including the wreq-js Rust license inventory and notices, .trivyignore, open-sse/utils/tlsClient.ts and assembleStandalone.mjs. #11753 had one exclusive file, its changelog fragment. Nothing needed porting, so #11753 is superseded rather than merged, and the changelog entry credits both. Reconciled on merge: clean against the tip. The new migration suite (tests/unit/tls-client-wreq-migration.test.ts, 1374 lines, 31 cases) is frozen at its exact LOC with the rationale — it shares one native-transport harness, so splitting it mid-merge would duplicate that harness for no coverage gain. Verified that no existing cap moves. Verified: 182/182 across the eight TLS, native-manifest, postinstall, standalone-bundle, pack-artifact and provider-validation suites, typecheck:core clean, check:cycles OK, check-changelog-integrity OK, check-file-size OK, and every changed TypeScript file parses.
733 lines
26 KiB
TypeScript
733 lines
26 KiB
TypeScript
/**
|
|
* NotionWebExecutor — Notion AI Web Session Provider (Unofficial/Experimental)
|
|
*
|
|
* Notion AI has no public, documented inference API (see issue #3272, closed
|
|
* by the owner for that reason). This executor reverse-engineers the same
|
|
* cookie-authenticated internal endpoint used by open-source bridges
|
|
* (notion2api / Notion2API-go, cited in issue #6758): a `token_v2` session
|
|
* cookie posted to `POST /api/v3/runInferenceTranscript`.
|
|
*
|
|
* Live capture (2026-07-19 / 2026-07-20) against a Business workspace confirmed:
|
|
* - First turn: createThread: true + a fresh threadId
|
|
* (createThread:false without a known threadId → ValidationError 400)
|
|
* - Follow-ups: createThread: false + the SAME threadId + full transcript
|
|
* (OpenAI multi-turn messages[] maps to one Notion AI chat; a new UUID
|
|
* every request forces a new chat and breaks agent/tool continuity)
|
|
* - transcript starts with config + context, then user/assistant steps
|
|
* - x-notion-space-id + x-notion-active-user-header required
|
|
* - response is NDJSON patch-start / patch / record-map (not legacy rich-text
|
|
* tuples alone). Text is extracted from agent-inference / markdown-chat.
|
|
*
|
|
* Streaming is still pseudo-streaming: read full body, parse, emit one SSE
|
|
* chunk — safer than assuming unverified incremental-delta semantics.
|
|
*
|
|
* Auth: Cookie-based (token_v2 [+ optional space_id, notion_browser_id, user_id])
|
|
* Method: Browser-TLS impersonation via pinned wreq-js (Chrome JA3/JA4). Plain
|
|
* Node/undici fetch is rejected by Notion's edge with in-band
|
|
* `temporarily-unavailable` (HTTP 200, empty assistant text) — curl/Schannel
|
|
* and Chrome work with the same cookie + body. See services/notionTlsClient.ts.
|
|
*/
|
|
import { randomUUID } from "node:crypto";
|
|
import { BaseExecutor, type ExecuteInput } from "./base.ts";
|
|
import { makeExecutorErrorResult as makeErrorResult } from "../utils/error.ts";
|
|
import {
|
|
BROWSER_HEADERS,
|
|
extractNotionUserIdFromCookie,
|
|
resolveNotionCodename,
|
|
resolveNotionRuntimeWorkspace,
|
|
} from "../services/notionWebModels.ts";
|
|
import {
|
|
__resetNotionThreadSessionsForTests,
|
|
conversationPrefixBeforeLastUser,
|
|
extractNotionMessageText,
|
|
hashNotionConversation,
|
|
notionThreadMarkConfirmed,
|
|
notionThreadMarkCreateAttempted,
|
|
notionThreadSessionLookup,
|
|
notionThreadSessionStore,
|
|
readClientThreadId,
|
|
hashNotionCallerCookie,
|
|
resolveNotionThreadBinding,
|
|
type NotionMessage,
|
|
} from "../services/notionThreadSessions.ts";
|
|
import {
|
|
extractNotionUpstreamError,
|
|
parseNotionInferenceStream,
|
|
sanitizeNotionAssistantText,
|
|
} from "../services/notionStreamParser.ts";
|
|
import {
|
|
buildNotionTranscript,
|
|
messagesForNotionTranscript,
|
|
type NotionAgentOptions,
|
|
} from "../services/notionTranscriptBuilder.ts";
|
|
import { tlsFetchNotion } from "../services/notionTlsClient.ts";
|
|
|
|
// Re-exported for unit tests that destructure `mod.<name>` on this module.
|
|
export {
|
|
__resetNotionThreadSessionsForTests,
|
|
buildNotionTranscript,
|
|
conversationPrefixBeforeLastUser,
|
|
extractNotionUpstreamError,
|
|
hashNotionConversation,
|
|
notionThreadSessionLookup,
|
|
notionThreadSessionStore,
|
|
parseNotionInferenceStream,
|
|
resolveNotionThreadBinding,
|
|
notionThreadMarkCreateAttempted,
|
|
notionThreadMarkConfirmed,
|
|
sanitizeNotionAssistantText,
|
|
};
|
|
|
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
|
|
// Both app.notion.com and www.notion.so work; prefer the AI surface host.
|
|
const BASE_URL = "https://app.notion.com";
|
|
const NOTION_URL = `${BASE_URL}/api/v3/runInferenceTranscript`;
|
|
const USER_AGENT =
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36";
|
|
// Match a recent live browser capture (web_providers/notion.txt, 2026-07-20).
|
|
const NOTION_CLIENT_VERSION = "23.13.20260720.1949";
|
|
|
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
|
|
interface NotionRequestBody {
|
|
messages?: NotionMessage[];
|
|
model?: string;
|
|
/** OpenAI-compatible structured output request. Notion Web has no native param,
|
|
* so the executor folds it into transcript instructions. */
|
|
response_format?: unknown;
|
|
/** Optional client-supplied Notion thread continuity (also via X-Notion-Thread-Id). */
|
|
notion_thread_id?: string;
|
|
thread_id?: string;
|
|
}
|
|
|
|
// ─── Helpers — credential resolution ───────────────────────────────────────
|
|
|
|
function readCredentialString(value: unknown): string {
|
|
if (typeof value !== "string") return "";
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : "";
|
|
}
|
|
|
|
function readProviderSpecificString(
|
|
providerSpecificData: unknown,
|
|
keys: readonly string[]
|
|
): string {
|
|
if (
|
|
!providerSpecificData ||
|
|
typeof providerSpecificData !== "object" ||
|
|
Array.isArray(providerSpecificData)
|
|
) {
|
|
return "";
|
|
}
|
|
const data = providerSpecificData as Record<string, unknown>;
|
|
for (const key of keys) {
|
|
const value = readCredentialString(data[key]);
|
|
if (value) return value;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function buildStructuredOutputInstruction(responseFormat: unknown): string {
|
|
if (!responseFormat || typeof responseFormat !== "object" || Array.isArray(responseFormat)) {
|
|
return "";
|
|
}
|
|
const format = responseFormat as Record<string, unknown>;
|
|
const type = typeof format.type === "string" ? format.type : "";
|
|
if (type !== "json_object" && type !== "json_schema") return "";
|
|
|
|
const lines = [
|
|
"Structured output requirement:",
|
|
"- Return only valid JSON.",
|
|
"- Do not wrap the JSON in markdown fences.",
|
|
"- Do not add prose before or after the JSON.",
|
|
];
|
|
|
|
if (type === "json_schema" && format.json_schema && typeof format.json_schema === "object") {
|
|
try {
|
|
lines.push(`- Match this JSON schema: ${JSON.stringify(format.json_schema)}`);
|
|
} catch {
|
|
lines.push("- Match the requested JSON schema.");
|
|
}
|
|
}
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function appendStructuredOutputInstruction(
|
|
messages: NotionMessage[],
|
|
responseFormat: unknown
|
|
): NotionMessage[] {
|
|
const instruction = buildStructuredOutputInstruction(responseFormat);
|
|
if (!instruction) return messages;
|
|
return [{ role: "system", content: instruction }, ...messages];
|
|
}
|
|
|
|
/** Normalize a pasted credential to a `name=value` cookie pair. Accepts a bare
|
|
* token or an already-prefixed `token_v2=...` value. */
|
|
export function normalizeNotionCookieInput(raw: string, cookieName = "token_v2"): string {
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) return "";
|
|
return trimmed.includes("=") ? trimmed : `${cookieName}=${trimmed}`;
|
|
}
|
|
|
|
/**
|
|
* Resolve the Cookie header to send upstream. Accepts, in priority order:
|
|
* 1. A full cookie header pasted as `apiKey` or `credentials.cookie`.
|
|
* 2. `providerSpecificData.cookie` (full header).
|
|
* 3. Structured `providerSpecificData.token_v2` (+ optional `space_id`,
|
|
* `notion_browser_id`), assembled into a cookie header.
|
|
*/
|
|
export function resolveNotionWebCookie(credentials: ExecuteInput["credentials"]): string {
|
|
const directCookie =
|
|
readCredentialString(credentials?.apiKey) ||
|
|
readCredentialString((credentials as Record<string, unknown> | undefined)?.cookie);
|
|
if (directCookie) return normalizeNotionCookieInput(directCookie);
|
|
|
|
const providerSpecificData = credentials?.providerSpecificData;
|
|
const cookie = readProviderSpecificString(providerSpecificData, ["cookie"]);
|
|
if (cookie) return normalizeNotionCookieInput(cookie);
|
|
|
|
const tokenV2 = readProviderSpecificString(providerSpecificData, ["token_v2", "tokenV2"]);
|
|
const spaceId = readProviderSpecificString(providerSpecificData, ["space_id", "spaceId"]);
|
|
const userId = readProviderSpecificString(providerSpecificData, [
|
|
"notion_user_id",
|
|
"notionUserId",
|
|
"user_id",
|
|
"userId",
|
|
]);
|
|
const browserId = readProviderSpecificString(providerSpecificData, [
|
|
"notion_browser_id",
|
|
"notionBrowserId",
|
|
]);
|
|
return [
|
|
tokenV2 ? normalizeNotionCookieInput(tokenV2) : "",
|
|
spaceId ? `space_id=${spaceId}` : "",
|
|
userId ? `notion_user_id=${userId}` : "",
|
|
browserId ? `notion_browser_id=${browserId}` : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join("; ");
|
|
}
|
|
|
|
/** Pull `space_id` out of an assembled cookie header, if present. */
|
|
export function extractSpaceIdFromCookie(cookie: string): string {
|
|
const match = cookie.match(/(?:^|;\s*)space_id=([^;]+)/i);
|
|
if (match) return match[1].trim();
|
|
const camel = cookie.match(/(?:^|;\s*)spaceId=([^;]+)/);
|
|
return camel ? camel[1].trim() : "";
|
|
}
|
|
|
|
function extractUserIdFromCookie(cookie: string): string {
|
|
return extractNotionUserIdFromCookie(cookie);
|
|
}
|
|
|
|
/**
|
|
* Notion's undocumented inference API does not return token usage.
|
|
* Emit a cheap char-based estimate so clients don't see a constant
|
|
* `USAGE_TOKEN_BUFFER` (default 2000) from buffering an all-zero stub.
|
|
* chatCore may still add the safety buffer on top of real estimates.
|
|
*/
|
|
export function estimateNotionUsage(
|
|
messages: NotionMessage[] | undefined,
|
|
content: string
|
|
): { prompt_tokens: number; completion_tokens: number; total_tokens: number; estimated: true } {
|
|
const promptText = (messages || []).map((m) => extractNotionMessageText(m?.content)).join("\n");
|
|
// ~4 chars/token (English-ish); at least 1 when there is any text.
|
|
const prompt_tokens = promptText ? Math.max(1, Math.ceil(promptText.length / 4)) : 0;
|
|
const completion_tokens = content ? Math.max(1, Math.ceil(content.length / 4)) : 0;
|
|
return {
|
|
prompt_tokens,
|
|
completion_tokens,
|
|
total_tokens: prompt_tokens + completion_tokens,
|
|
estimated: true,
|
|
};
|
|
}
|
|
|
|
function chatCompletionResponse(
|
|
content: string,
|
|
model: string,
|
|
messages?: NotionMessage[],
|
|
threadId?: string
|
|
) {
|
|
const id = threadId ? `chatcmpl-notion-${threadId}` : `chatcmpl-notion-${Date.now()}`;
|
|
return new Response(
|
|
JSON.stringify({
|
|
id,
|
|
object: "chat.completion",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model,
|
|
choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
|
|
usage: estimateNotionUsage(messages, content),
|
|
// Non-standard but useful for clients that want to pin continuity explicitly
|
|
notion_thread_id: threadId || undefined,
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(threadId ? { "X-Notion-Thread-Id": threadId } : {}),
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
function pseudoStreamResponse(content: string, model: string, threadId?: string) {
|
|
const encoder = new TextEncoder();
|
|
const id = threadId ? `chatcmpl-notion-${threadId}` : `chatcmpl-notion-${Date.now()}`;
|
|
const chunk = (delta: string, finishReason: string | null) => ({
|
|
id,
|
|
object: "chat.completion.chunk",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model,
|
|
choices: [{ index: 0, delta: delta ? { content: delta } : {}, finish_reason: finishReason }],
|
|
});
|
|
const readable = new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk(content, null))}\n\n`));
|
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk("", "stop"))}\n\n`));
|
|
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
controller.close();
|
|
},
|
|
});
|
|
return new Response(readable, {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
Connection: "keep-alive",
|
|
...(threadId ? { "X-Notion-Thread-Id": threadId } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
function clientFacingModelId(model: unknown): string {
|
|
let clientFacingModel = typeof model === "string" ? model.trim() : "";
|
|
if (clientFacingModel.startsWith("notion-web/")) {
|
|
clientFacingModel = clientFacingModel.slice("notion-web/".length);
|
|
} else if (clientFacingModel.startsWith("nw/")) {
|
|
clientFacingModel = clientFacingModel.slice(3);
|
|
}
|
|
return clientFacingModel;
|
|
}
|
|
|
|
/** Resolves workspace + user (cached). Required for createThread payloads. */
|
|
async function resolveExecuteWorkspace(
|
|
cookie: string,
|
|
signal: ExecuteInput["signal"]
|
|
): Promise<{ spaceId: string; userId: string }> {
|
|
let spaceId = extractSpaceIdFromCookie(cookie);
|
|
let userId = extractUserIdFromCookie(cookie);
|
|
try {
|
|
const resolved = await resolveNotionRuntimeWorkspace({ cookie, signal });
|
|
if (!spaceId) spaceId = resolved.spaceId;
|
|
if (!userId) userId = resolved.userId;
|
|
} catch {
|
|
// keep cookie-derived values
|
|
}
|
|
return { spaceId, userId };
|
|
}
|
|
|
|
/**
|
|
* Live-verified shape:
|
|
* - First turn: createThread true + new threadId
|
|
* - Follow-up: createThread false + same threadId (false without threadId → 400)
|
|
*/
|
|
function buildNotionInferenceRequestBody(opts: {
|
|
spaceId: string;
|
|
userId: string;
|
|
threadId: string;
|
|
transcript: unknown;
|
|
createThread: boolean;
|
|
agent?: NotionAgentOptions;
|
|
}): Record<string, unknown> {
|
|
const { spaceId, threadId, transcript, createThread, agent } = opts;
|
|
const isCustom = Boolean(agent?.workflowId);
|
|
const workflowId = agent?.workflowId || "";
|
|
// Follow-ups: isPartialTranscript true matches open-source Notion bridges and
|
|
// avoids re-validating the entire prior transcript (a source of transient errors).
|
|
const isFollowUp = !createThread;
|
|
return {
|
|
traceId: randomUUID(),
|
|
spaceId,
|
|
threadId,
|
|
createThread,
|
|
// Only generate a title when starting a new Notion AI chat
|
|
generateTitle: createThread,
|
|
asPatchResponse: true,
|
|
patchResponseVersion: 2,
|
|
isPartialTranscript: isFollowUp,
|
|
saveAllThreadOperations: true,
|
|
setUnreadState: createThread,
|
|
createdSource: isCustom ? "custom_agent" : "ai_module",
|
|
threadType: "workflow",
|
|
supportsCustomAgentNudgeTranscriptStep: true,
|
|
isUserInAnySalesAssistedSpace: false,
|
|
isSpaceSalesAssisted: false,
|
|
transcript,
|
|
// Default AI is parented by the workspace; custom agents by the workflow id.
|
|
threadParentPointer: isCustom
|
|
? { table: "workflow", id: workflowId, spaceId }
|
|
: { table: "space", id: spaceId, spaceId },
|
|
debugOverrides: {
|
|
annotationInferences: {},
|
|
cachedInferences: {},
|
|
emitAgentSearchExtractedResults: true,
|
|
emitInferences: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildNotionExecuteHeaders(opts: {
|
|
cookie: string;
|
|
spaceId: string;
|
|
userId: string;
|
|
agent?: NotionAgentOptions;
|
|
}): Record<string, string> {
|
|
const isCustom = Boolean(opts.agent?.workflowId);
|
|
// Browser uses /agent/<workflowId without dashes>?wfv=chat for custom agents.
|
|
const agentPathId = (opts.agent?.workflowId || "").replace(/-/g, "");
|
|
const referer =
|
|
isCustom && agentPathId ? `${BASE_URL}/agent/${agentPathId}?wfv=chat` : `${BASE_URL}/ai`;
|
|
const reqHeaders: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
"User-Agent": USER_AGENT,
|
|
Accept: "application/x-ndjson",
|
|
Cookie: opts.cookie,
|
|
Origin: BASE_URL,
|
|
Referer: referer,
|
|
"notion-client-version": NOTION_CLIENT_VERSION,
|
|
"notion-audit-log-platform": "web",
|
|
"x-notion-space-id": opts.spaceId,
|
|
"Accept-Language": "en-US,en;q=0.9",
|
|
...BROWSER_HEADERS,
|
|
};
|
|
if (opts.userId) reqHeaders["x-notion-active-user-header"] = opts.userId;
|
|
return reqHeaders;
|
|
}
|
|
|
|
/** Normalize a pasted workflow/agent id (with or without dashes). */
|
|
export function normalizeNotionWorkflowId(raw: string | undefined | null): string {
|
|
const s = String(raw || "").trim();
|
|
if (!s) return "";
|
|
// URL path segment …/agent/<id>?… or bare hex
|
|
const fromUrl = s.match(/\/agent\/([a-f0-9-]{20,})/i);
|
|
let id = fromUrl ? fromUrl[1]! : s;
|
|
id = id.replace(/[^a-f0-9-]/gi, "");
|
|
// Insert dashes if 32 hex chars (no dashes)
|
|
const hex = id.replace(/-/g, "");
|
|
if (/^[a-f0-9]{32}$/i.test(hex)) {
|
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`.toLowerCase();
|
|
}
|
|
// Already UUID-like
|
|
if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(id)) {
|
|
return id.toLowerCase();
|
|
}
|
|
return id;
|
|
}
|
|
|
|
/**
|
|
* Read custom-agent workflow id + optional context page from credentials.
|
|
* Sources (priority): providerSpecificData → cookie pairs on apiKey
|
|
* (`workflow_id=…`, `notion_workflow_id=…`, `context_page_id=…`).
|
|
*/
|
|
export function resolveNotionAgentOptions(
|
|
credentials: ExecuteInput["credentials"],
|
|
cookie: string
|
|
): NotionAgentOptions {
|
|
const ps = credentials?.providerSpecificData;
|
|
const workflowFromPs =
|
|
readProviderSpecificString(ps, [
|
|
"workflowId",
|
|
"workflow_id",
|
|
"notionWorkflowId",
|
|
"notion_workflow_id",
|
|
"agentId",
|
|
"agent_id",
|
|
]) || "";
|
|
const pageFromPs =
|
|
readProviderSpecificString(ps, ["contextPageId", "context_page_id", "notionContextPageId"]) ||
|
|
"";
|
|
|
|
const readCookie = (name: string): string => {
|
|
const m = cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`, "i"));
|
|
if (!m) return "";
|
|
const raw = m[1]!.trim();
|
|
try {
|
|
return decodeURIComponent(raw);
|
|
} catch {
|
|
return raw;
|
|
}
|
|
};
|
|
|
|
const workflowId = normalizeNotionWorkflowId(
|
|
workflowFromPs ||
|
|
readCookie("workflow_id") ||
|
|
readCookie("notion_workflow_id") ||
|
|
readCookie("agent_id")
|
|
);
|
|
const contextPageId =
|
|
pageFromPs || readCookie("context_page_id") || readCookie("notion_context_page_id") || "";
|
|
|
|
return {
|
|
workflowId: workflowId || undefined,
|
|
contextPageId: contextPageId ? contextPageId.trim() : undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Sends the createThread request to Notion and returns either the raw
|
|
* inference text or an error result — callers just check `.errorResult`.
|
|
*/
|
|
async function sendNotionInferenceRequest(opts: {
|
|
reqBody: Record<string, unknown>;
|
|
reqHeaders: Record<string, string>;
|
|
signal: ExecuteInput["signal"];
|
|
}): Promise<{ rawText?: string; errorResult?: ReturnType<typeof makeErrorResult> }> {
|
|
const { reqBody, reqHeaders, signal } = opts;
|
|
// Notion's edge rejects Node/undici TLS fingerprints with in-band
|
|
// temporarily-unavailable (HTTP 200, no assistant text). Always use the
|
|
// Chrome-JA3 tls-client path for runInferenceTranscript.
|
|
let status = 0;
|
|
let rawText = "";
|
|
try {
|
|
const tlsRes = await tlsFetchNotion(NOTION_URL, {
|
|
method: "POST",
|
|
headers: reqHeaders,
|
|
body: JSON.stringify(reqBody),
|
|
signal: signal ?? undefined,
|
|
// Inference can take a while (tool-autoload + LLM first token).
|
|
timeoutMs: Number.parseInt(process.env.OMNIROUTE_NOTION_TLS_TIMEOUT_MS || "", 10) || 180_000,
|
|
});
|
|
status = tlsRes.status;
|
|
rawText = tlsRes.text ?? "";
|
|
} catch (err) {
|
|
// Fail closed: plain fetch would bypass the resolved proxy and Notion rejects
|
|
// undici's fingerprint anyway. A missing native binding is a packaging error,
|
|
// not permission to leak a direct request.
|
|
return {
|
|
errorResult: makeErrorResult(
|
|
502,
|
|
`Notion fetch failed: ${err instanceof Error ? err.message : "unknown error"}`,
|
|
reqBody,
|
|
NOTION_URL
|
|
),
|
|
};
|
|
}
|
|
|
|
if (status === 401 || status === 403) {
|
|
return {
|
|
errorResult: makeErrorResult(
|
|
status,
|
|
"Notion session expired or invalid — re-paste token_v2 from notion.so",
|
|
reqBody,
|
|
NOTION_URL
|
|
),
|
|
};
|
|
}
|
|
|
|
if (status < 200 || status >= 300) {
|
|
return {
|
|
errorResult: makeErrorResult(
|
|
status || 502,
|
|
`Notion error: ${rawText.slice(0, 500)}`,
|
|
reqBody,
|
|
NOTION_URL
|
|
),
|
|
};
|
|
}
|
|
|
|
return { rawText };
|
|
}
|
|
|
|
// ─── Executor ───────────────────────────────────────────────────────────────
|
|
|
|
export class NotionWebExecutor extends BaseExecutor {
|
|
constructor() {
|
|
super("notion-web", { id: "notion-web", baseUrl: NOTION_URL });
|
|
}
|
|
|
|
async execute(input: ExecuteInput) {
|
|
const { model, body, stream: wantStream, credentials, signal } = input;
|
|
const requestBody = (body || {}) as NotionRequestBody;
|
|
|
|
const cookie = resolveNotionWebCookie(credentials);
|
|
if (!cookie) {
|
|
return makeErrorResult(
|
|
401,
|
|
"Missing Notion token_v2 cookie — paste it from notion.so DevTools → Application → Cookies",
|
|
body,
|
|
NOTION_URL
|
|
);
|
|
}
|
|
|
|
// Optional custom agent (workflowId). Empty → default Notion AI (not agentic-specific).
|
|
const agent = resolveNotionAgentOptions(credentials, cookie);
|
|
|
|
const messages = appendStructuredOutputInstruction(
|
|
requestBody.messages || [],
|
|
requestBody.response_format
|
|
);
|
|
if (!messages.some((m) => m.role === "user")) {
|
|
return makeErrorResult(400, "No user message found", body, NOTION_URL);
|
|
}
|
|
|
|
const { spaceId, userId } = await resolveExecuteWorkspace(cookie, signal);
|
|
|
|
if (!spaceId) {
|
|
return makeErrorResult(
|
|
400,
|
|
"Could not resolve Notion spaceId — paste space_id from cookies or ensure token_v2 can call getSpaces",
|
|
body,
|
|
NOTION_URL
|
|
);
|
|
}
|
|
|
|
// Client may send notion-web/fable-5, nw/fable-5, fable-5, "Fable 5", or the
|
|
// legacy food codename (acai-budino-high). Notion only accepts the food codename
|
|
// on the wire; we echo the client-facing id in the OpenAI response.
|
|
const notionCodename = resolveNotionCodename(model);
|
|
const clientFacing = clientFacingModelId(model);
|
|
const modelId = clientFacing || notionCodename || "notion-ai";
|
|
|
|
// Thread continuity (sticky) — see resolveNotionThreadBinding:
|
|
// - Prefer X-Notion-Thread-Id / body pin from the client
|
|
// - Else exact conversation-prefix hash (multi-turn OpenAI history)
|
|
// - Else sticky root (first user text) for UREW + failed-first-request retries
|
|
// - First-turn + confirmed sticky (new Claude Code session with same “hi”) → mint fresh
|
|
// - Bind threadId *before* the upstream call so error retries never mint a second chat
|
|
const inboundHeaders =
|
|
(input.clientHeaders as Record<string, string> | null | undefined) ??
|
|
((input as { headers?: Record<string, string> }).headers as
|
|
Record<string, string> | undefined);
|
|
const clientThreadId = readClientThreadId(requestBody, inboundHeaders ?? undefined);
|
|
// Namespace the thread cache PER CALLER (hash of the caller's cookie) AND by custom
|
|
// agent, so (a) two users of the same Notion space never share a cached thread
|
|
// (cross-tenant IDOR, #7900 review) and (b) default AI and agents never share threads.
|
|
const callerScope = hashNotionCallerCookie(cookie);
|
|
const threadSpaceKey = agent.workflowId
|
|
? `caller:${callerScope}|${spaceId}|wf:${agent.workflowId}`
|
|
: `caller:${callerScope}|${spaceId}`;
|
|
const binding = resolveNotionThreadBinding(threadSpaceKey, messages, clientThreadId);
|
|
let { threadId, createThread, rootKey } = binding;
|
|
|
|
const reqHeaders = buildNotionExecuteHeaders({ cookie, spaceId, userId, agent });
|
|
|
|
type NotionAttempt =
|
|
| { ok: true; finalText: string; reqBody: Record<string, unknown> }
|
|
| {
|
|
ok: false;
|
|
errorResult: ReturnType<typeof makeErrorResult>;
|
|
retryable: boolean;
|
|
reqBody: Record<string, unknown>;
|
|
};
|
|
|
|
// `strictNullChecks: false` narrows a boolean-literal discriminant on the positive
|
|
// branch only, so `!attempt.ok` leaves the full union and the failure-only fields are
|
|
// unreachable to the checker. An explicit predicate narrows under those settings.
|
|
const isFailedAttempt = (
|
|
attempt: NotionAttempt
|
|
): attempt is Extract<NotionAttempt, { ok: false }> => !attempt.ok;
|
|
|
|
const runOnce = async (opts: {
|
|
createThread: boolean;
|
|
threadId: string;
|
|
}): Promise<NotionAttempt> => {
|
|
const transcript = buildNotionTranscript(messages, {
|
|
notionModel: notionCodename || undefined,
|
|
spaceId,
|
|
userId: userId || undefined,
|
|
agent,
|
|
isFollowUp: !opts.createThread,
|
|
});
|
|
const reqBody = buildNotionInferenceRequestBody({
|
|
spaceId,
|
|
userId,
|
|
threadId: opts.threadId,
|
|
transcript,
|
|
createThread: opts.createThread,
|
|
agent,
|
|
});
|
|
|
|
if (opts.createThread) {
|
|
notionThreadMarkCreateAttempted(rootKey, opts.threadId);
|
|
}
|
|
|
|
const { rawText, errorResult } = await sendNotionInferenceRequest({
|
|
reqBody,
|
|
reqHeaders,
|
|
signal,
|
|
});
|
|
|
|
if (errorResult) {
|
|
// HTTP-level failure — keep sticky binding so the next turn reuses threadId
|
|
const status = errorResult.response?.status ?? 502;
|
|
const retryable = status === 429 || status === 503 || status >= 500;
|
|
return { ok: false, errorResult, retryable, reqBody };
|
|
}
|
|
|
|
const raw = rawText || "";
|
|
const upstreamErr = extractNotionUpstreamError(raw);
|
|
if (upstreamErr) {
|
|
// In-band Notion error (often HTTP 200 NDJSON). Sticky thread stays bound.
|
|
const status = upstreamErr.isRetryable ? 503 : 502;
|
|
return {
|
|
ok: false,
|
|
retryable: upstreamErr.isRetryable,
|
|
reqBody,
|
|
errorResult: makeErrorResult(
|
|
status,
|
|
`Notion ${upstreamErr.subType || "error"}: ${upstreamErr.message}`,
|
|
reqBody,
|
|
NOTION_URL
|
|
),
|
|
};
|
|
}
|
|
|
|
const finalText = parseNotionInferenceStream(raw);
|
|
if (!finalText) {
|
|
return {
|
|
ok: false,
|
|
retryable: true,
|
|
reqBody,
|
|
errorResult: makeErrorResult(502, "No response from Notion AI", reqBody, NOTION_URL),
|
|
};
|
|
}
|
|
|
|
return { ok: true, finalText, reqBody };
|
|
};
|
|
|
|
// First attempt
|
|
let attempt = await runOnce({ createThread, threadId });
|
|
|
|
// One automatic retry for transient Notion faults — same threadId, never create again
|
|
if (isFailedAttempt(attempt) && attempt.retryable) {
|
|
const delayMs =
|
|
process.env.NODE_ENV === "test" || process.env.VITEST
|
|
? 20
|
|
: 700 + Math.floor(Math.random() * 400);
|
|
await new Promise((r) => setTimeout(r, delayMs));
|
|
attempt = await runOnce({ createThread: false, threadId });
|
|
}
|
|
|
|
if (isFailedAttempt(attempt)) {
|
|
return attempt.errorResult;
|
|
}
|
|
|
|
// Confirm sticky binding + prefix keys for multi-turn continuity
|
|
notionThreadMarkConfirmed(rootKey, threadId);
|
|
notionThreadSessionStore(threadSpaceKey, messages, attempt.finalText, threadId);
|
|
|
|
const response = wantStream
|
|
? pseudoStreamResponse(attempt.finalText, modelId, threadId)
|
|
: chatCompletionResponse(attempt.finalText, modelId, messages, threadId);
|
|
|
|
return {
|
|
response,
|
|
url: NOTION_URL,
|
|
headers: reqHeaders,
|
|
transformedBody: attempt.reqBody,
|
|
};
|
|
}
|
|
}
|