fix(notion-web): add browser fingerprint headers to reduce Cloudflare challenges (#7864)

* fix(notion-web): add browser fingerprint headers to reduce Cloudflare challenges

Adds sec-ch-ua, sec-fetch-*, cache-control, pragma, and priority headers
that real Chromium browsers send. Without these, Cloudflare may challenge
or block requests that look like non-browser clients.

Applied to:
- buildNotionExecuteHeaders (inference requests)
- buildNotionBrowserHeaders (workspace discovery)
- buildNotionModelsDiscoveryHeaders (model discovery)

Headers match the real browser capture from Chrome 149 on Linux.

Addresses gemini-code-assist review:
- Fixed platform mismatch: sec-ch-ua-platform now matches USER_AGENT (Windows)
- Deduplicated headers via shared BROWSER_HEADERS constant in notionWebModels.ts
- Both executor and model discovery use the same constant

* fix(notion-web): align Chrome version to 149 and add browser header tests

Addresses maintainer review feedback on #7864:
- Align User-Agent and NOTION_USER_AGENT to Chrome/149 (was 145 and 150)
  matching sec-ch-ua already declaring v="149"
- Add test assertions that browser fingerprint headers (sec-ch-ua,
  sec-fetch-mode, cache-control, pragma) are sent on both executor
  and models-discovery requests

* refactor(providers): extract notion-web fallback catalog to its own module

notionWebModels.ts crossed the 800-line new-file cap (875) once the browser
header tests landed; move the NOTION_WEB_FALLBACK_MODELS catalog + its type to
notionWebFallbackModels.ts (pure data, re-exported for existing consumers).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Hassiy
2026-07-20 21:57:15 +03:00
committed by GitHub
parent 3fe61cdea7
commit fd6a583a95
5 changed files with 241 additions and 105 deletions

View File

@@ -25,6 +25,7 @@ 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,
@@ -36,7 +37,7 @@ import {
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/145.0.0.0 Safari/537.36";
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
const NOTION_CLIENT_VERSION = "23.13.20260719.1125";
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -451,7 +452,12 @@ export function parseNotionInferenceStream(raw: string): string {
applyNotionStreamLine(rawLine, state);
}
const candidates = [state.lastRecordMap, state.lastPatchFinal, state.lastIncremental, state.lastLegacy]
const candidates = [
state.lastRecordMap,
state.lastPatchFinal,
state.lastIncremental,
state.lastLegacy,
]
.map(sanitizeNotionAssistantText)
.filter(Boolean);
// Prefer the longest non-empty candidate; record-map usually wins.
@@ -472,12 +478,8 @@ export function estimateNotionUsage(
.map((m) => (typeof m?.content === "string" ? 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;
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,
@@ -486,20 +488,14 @@ export function estimateNotionUsage(
};
}
function chatCompletionResponse(
content: string,
model: string,
messages?: NotionMessage[]
) {
function chatCompletionResponse(content: string, model: string, messages?: NotionMessage[]) {
return new Response(
JSON.stringify({
id: `chatcmpl-notion-${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model,
choices: [
{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" },
],
choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
usage: estimateNotionUsage(messages, content),
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
@@ -611,6 +607,7 @@ function buildNotionExecuteHeaders(opts: {
"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;
@@ -659,7 +656,12 @@ async function sendNotionInferenceRequest(opts: {
if (!upstream.ok) {
const errText = await upstream.text().catch(() => "");
return {
errorResult: makeErrorResult(upstream.status, `Notion error: ${errText}`, reqBody, NOTION_URL),
errorResult: makeErrorResult(
upstream.status,
`Notion error: ${errText}`,
reqBody,
NOTION_URL
),
};
}
@@ -720,7 +722,11 @@ export class NotionWebExecutor extends BaseExecutor {
const reqBody = buildNotionCreateThreadRequestBody({ spaceId, userId, threadId, transcript });
const reqHeaders = buildNotionExecuteHeaders({ cookie, spaceId, userId });
const { rawText, errorResult } = await sendNotionInferenceRequest({ reqBody, reqHeaders, signal });
const { rawText, errorResult } = await sendNotionInferenceRequest({
reqBody,
reqHeaders,
signal,
});
if (errorResult) return errorResult;
const finalText = parseNotionInferenceStream(rawText || "");

View File

@@ -0,0 +1,134 @@
/**
* Notion Web fallback model catalog (seeded from the live AI picker).
* Extracted from notionWebModels.ts to keep that module under the 800-line
* file-size cap; notionWebModels.ts re-exports both symbols for consumers.
*/
export type NotionDiscoveredModel = {
/**
* Catalog / OpenAI-compatible model id shown to clients.
* Prefer the web picker label slug (e.g. `fable-5`, `gpt-5.6-sol`) so users
* never have to choose Notion's internal food codenames.
*/
id: string;
/** Human label from Notion's AI picker (`modelMessage`), e.g. "Fable 5". */
name: string;
owned_by: string;
supportsReasoning?: boolean;
disabled?: boolean;
/**
* Internal Notion `model` codename for `runInferenceTranscript`
* (e.g. `acai-budino-high`). When omitted, `id` is the codename itself
* (rare; only when no display label was available).
*/
notionCodename?: string;
};
/**
* Offline fallback when getAvailableModels is unreachable (seeded from live picker).
* Catalog ids use real web-picker labels; `notionCodename` is what the API accepts.
*/
export const NOTION_WEB_FALLBACK_MODELS: NotionDiscoveredModel[] = [
{ id: "notion-ai", name: "Notion AI (default)", owned_by: "notion" },
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol", owned_by: "openai", notionCodename: "orange-mousse" },
{
id: "gpt-5.6-terra",
name: "GPT-5.6 Terra",
owned_by: "openai",
notionCodename: "orchid-muffin",
},
{
id: "gpt-5.6-luna",
name: "GPT-5.6 Luna",
owned_by: "openai",
notionCodename: "olive-jellyroll",
},
{ id: "gpt-5.2", name: "GPT-5.2", owned_by: "openai", notionCodename: "oatmeal-cookie" },
{ id: "gpt-5.4", name: "GPT-5.4", owned_by: "openai", notionCodename: "oval-kumquat-medium" },
{ id: "gpt-5.5", name: "GPT-5.5", owned_by: "openai", notionCodename: "opal-quince-medium" },
{
id: "gpt-5.4-mini",
name: "GPT-5.4 Mini",
owned_by: "openai",
notionCodename: "oregon-grape-medium",
},
{
id: "gpt-5.4-nano",
name: "GPT-5.4 Nano",
owned_by: "openai",
notionCodename: "otaheite-apple-medium",
},
{
id: "gemini-3.5-flash",
name: "Gemini 3.5 Flash",
owned_by: "gemini",
notionCodename: "vertex-gemini-3.5-flash",
},
{
id: "gemini-3-flash",
name: "Gemini 3 Flash",
owned_by: "gemini",
notionCodename: "gingerbread",
},
{
id: "gemini-3.1-pro",
name: "Gemini 3.1 Pro",
owned_by: "gemini",
notionCodename: "galette-medium-thinking",
},
{
id: "sonnet-4.6",
name: "Sonnet 4.6",
owned_by: "anthropic",
notionCodename: "almond-croissant-low",
},
{ id: "sonnet-5", name: "Sonnet 5", owned_by: "anthropic", notionCodename: "angel-cake-high" },
{
id: "opus-4.6",
name: "Opus 4.6",
owned_by: "anthropic",
notionCodename: "avocado-froyo-medium",
},
{
id: "opus-4.7",
name: "Opus 4.7",
owned_by: "anthropic",
notionCodename: "apricot-sorbet-high",
},
{ id: "opus-4.8", name: "Opus 4.8", owned_by: "anthropic", notionCodename: "ambrosia-tart-high" },
{
id: "haiku-4.5",
name: "Haiku 4.5",
owned_by: "anthropic",
notionCodename: "anthropic-haiku-4.5",
},
{ id: "fable-5", name: "Fable 5", owned_by: "anthropic", notionCodename: "acai-budino-high" },
{
id: "kimi-k2.6",
name: "Kimi K2.6",
owned_by: "mystery",
notionCodename: "fireworks-kimi-k2.6",
},
{
id: "kimi-k2.7-code",
name: "Kimi K2.7 Code",
owned_by: "mystery",
notionCodename: "fireworks-kimi-k2.7",
},
{
id: "deepseek-v4-pro",
name: "DeepSeek V4 Pro",
owned_by: "mystery",
notionCodename: "baseten-deepseek-v4-pro",
},
{ id: "glm-5.2", name: "GLM 5.2", owned_by: "mystery", notionCodename: "baseten-glm-5.2" },
{ id: "grok-4.3", name: "Grok 4.3", owned_by: "xai", notionCodename: "xigua-mochi-medium" },
{ id: "grok-4.5", name: "Grok 4.5", owned_by: "xai", notionCodename: "strawberry-whoopiepie" },
{
id: "grok-build-0.1",
name: "Grok Build 0.1",
owned_by: "xai",
notionCodename: "xinomavro-cake",
},
];

View File

@@ -7,6 +7,14 @@
* build the cookie/headers/body the models-discovery route needs.
*/
import {
NOTION_WEB_FALLBACK_MODELS,
type NotionDiscoveredModel,
} from "./notionWebFallbackModels.ts";
export { NOTION_WEB_FALLBACK_MODELS };
export type { NotionDiscoveredModel };
// Browser AI surface uses app.notion.com (live capture 2026-07-19). www.notion.so
// still works for many paths but can return a different space default / cookie
// domain behavior — prefer the same host the web picker uses.
@@ -15,73 +23,37 @@ const NOTION_LEGACY_ORIGIN = "https://www.notion.so";
const NOTION_MODELS_URL = `${NOTION_APP_ORIGIN}/api/v3/getAvailableModels`;
const NOTION_SPACES_URL = `${NOTION_APP_ORIGIN}/api/v3/getSpaces`;
const NOTION_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36";
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
/** Recent Notion web client version — accepted loosely but required by some paths. */
const NOTION_CLIENT_VERSION = "23.13.20260719.1125";
/** Cap how many workspaces we probe for AI models when space_id is omitted. */
const NOTION_MAX_SPACE_PROBE = 8;
/** Cache auto-selected workspace per token so chat/inference reuses discovery. */
const NOTION_SPACE_CACHE = new Map<string, { spaceId: string; userId: string; expiresAt: number }>();
const NOTION_SPACE_CACHE = new Map<
string,
{ spaceId: string; userId: string; expiresAt: number }
>();
const NOTION_SPACE_CACHE_TTL_MS = 30 * 60 * 1000;
// Browser fingerprint headers — make requests look like real Chromium
// to reduce Cloudflare bot-detection challenges.
export const BROWSER_HEADERS: Record<string, string> = {
"sec-ch-ua": '"Chromium";v="149", "Not)A;Brand";v="24"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
priority: "u=1, i",
"cache-control": "no-cache",
pragma: "no-cache",
};
function notionTokenCacheKey(cookie: string): string {
// Prefer the token_v2 value only — ignore optional space/user parts.
return readCookieValue(cookie, "token_v2") || normalizeNotionWebCookie(cookie);
}
export type NotionDiscoveredModel = {
/**
* Catalog / OpenAI-compatible model id shown to clients.
* Prefer the web picker label slug (e.g. `fable-5`, `gpt-5.6-sol`) so users
* never have to choose Notion's internal food codenames.
*/
id: string;
/** Human label from Notion's AI picker (`modelMessage`), e.g. "Fable 5". */
name: string;
owned_by: string;
supportsReasoning?: boolean;
disabled?: boolean;
/**
* Internal Notion `model` codename for `runInferenceTranscript`
* (e.g. `acai-budino-high`). When omitted, `id` is the codename itself
* (rare; only when no display label was available).
*/
notionCodename?: string;
};
/**
* Offline fallback when getAvailableModels is unreachable (seeded from live picker).
* Catalog ids use real web-picker labels; `notionCodename` is what the API accepts.
*/
export const NOTION_WEB_FALLBACK_MODELS: NotionDiscoveredModel[] = [
{ id: "notion-ai", name: "Notion AI (default)", owned_by: "notion" },
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol", owned_by: "openai", notionCodename: "orange-mousse" },
{ id: "gpt-5.6-terra", name: "GPT-5.6 Terra", owned_by: "openai", notionCodename: "orchid-muffin" },
{ id: "gpt-5.6-luna", name: "GPT-5.6 Luna", owned_by: "openai", notionCodename: "olive-jellyroll" },
{ id: "gpt-5.2", name: "GPT-5.2", owned_by: "openai", notionCodename: "oatmeal-cookie" },
{ id: "gpt-5.4", name: "GPT-5.4", owned_by: "openai", notionCodename: "oval-kumquat-medium" },
{ id: "gpt-5.5", name: "GPT-5.5", owned_by: "openai", notionCodename: "opal-quince-medium" },
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini", owned_by: "openai", notionCodename: "oregon-grape-medium" },
{ id: "gpt-5.4-nano", name: "GPT-5.4 Nano", owned_by: "openai", notionCodename: "otaheite-apple-medium" },
{ id: "gemini-3.5-flash", name: "Gemini 3.5 Flash", owned_by: "gemini", notionCodename: "vertex-gemini-3.5-flash" },
{ id: "gemini-3-flash", name: "Gemini 3 Flash", owned_by: "gemini", notionCodename: "gingerbread" },
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", owned_by: "gemini", notionCodename: "galette-medium-thinking" },
{ id: "sonnet-4.6", name: "Sonnet 4.6", owned_by: "anthropic", notionCodename: "almond-croissant-low" },
{ id: "sonnet-5", name: "Sonnet 5", owned_by: "anthropic", notionCodename: "angel-cake-high" },
{ id: "opus-4.6", name: "Opus 4.6", owned_by: "anthropic", notionCodename: "avocado-froyo-medium" },
{ id: "opus-4.7", name: "Opus 4.7", owned_by: "anthropic", notionCodename: "apricot-sorbet-high" },
{ id: "opus-4.8", name: "Opus 4.8", owned_by: "anthropic", notionCodename: "ambrosia-tart-high" },
{ id: "haiku-4.5", name: "Haiku 4.5", owned_by: "anthropic", notionCodename: "anthropic-haiku-4.5" },
{ id: "fable-5", name: "Fable 5", owned_by: "anthropic", notionCodename: "acai-budino-high" },
{ id: "kimi-k2.6", name: "Kimi K2.6", owned_by: "mystery", notionCodename: "fireworks-kimi-k2.6" },
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code", owned_by: "mystery", notionCodename: "fireworks-kimi-k2.7" },
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", owned_by: "mystery", notionCodename: "baseten-deepseek-v4-pro" },
{ id: "glm-5.2", name: "GLM 5.2", owned_by: "mystery", notionCodename: "baseten-glm-5.2" },
{ id: "grok-4.3", name: "Grok 4.3", owned_by: "xai", notionCodename: "xigua-mochi-medium" },
{ id: "grok-4.5", name: "Grok 4.5", owned_by: "xai", notionCodename: "strawberry-whoopiepie" },
{ id: "grok-build-0.1", name: "Grok Build 0.1", owned_by: "xai", notionCodename: "xinomavro-cake" },
];
/** Normalize a pasted credential to a Cookie header string. */
export function normalizeNotionWebCookie(raw: string): string {
const trimmed = String(raw || "").trim();
@@ -105,11 +77,7 @@ export function readCookieValue(cookie: string, name: string): string {
}
export function extractSpaceIdFromNotionCookie(cookie: string): string {
return (
readCookieValue(cookie, "space_id") ||
readCookieValue(cookie, "spaceId") ||
""
);
return readCookieValue(cookie, "space_id") || readCookieValue(cookie, "spaceId") || "";
}
export function extractNotionUserIdFromCookie(cookie: string): string {
@@ -229,10 +197,7 @@ export function formatNotionDisabledModelsWarning(
* Catalog `id` is the real picker label slug; `notionCodename` is what
* runInferenceTranscript requires.
*/
function parseNotionModelEntry(
entry: unknown,
seen: Set<string>
): NotionDiscoveredModel | null {
function parseNotionModelEntry(entry: unknown, seen: Set<string>): NotionDiscoveredModel | null {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
const row = entry as Record<string, unknown>;
// Notion still returns plan-locked models (Fable 5) with isDisabled=true.
@@ -314,6 +279,7 @@ export function buildNotionModelsDiscoveryHeaders(token: string): Record<string,
"notion-client-version": NOTION_CLIENT_VERSION,
"notion-audit-log-platform": "web",
...(cookie ? { cookie } : {}),
...BROWSER_HEADERS,
};
if (spaceId) headers["x-notion-space-id"] = spaceId;
if (userId) headers["x-notion-active-user-header"] = userId;
@@ -418,6 +384,7 @@ function buildNotionBrowserHeaders(cookie: string, userId?: string): Record<stri
"notion-client-version": NOTION_CLIENT_VERSION,
"notion-audit-log-platform": "web",
cookie,
...BROWSER_HEADERS,
};
if (userId) headers["x-notion-active-user-header"] = userId;
return headers;
@@ -482,8 +449,12 @@ export async function selectBestNotionSpaceId(opts: {
const cookie = normalizeNotionWebCookie(opts.cookie);
if (!cookie || opts.spaceIds.length === 0) return null;
let best: { spaceId: string; models: NotionDiscoveredModel[]; raw: unknown; score: number } | null =
null;
let best: {
spaceId: string;
models: NotionDiscoveredModel[];
raw: unknown;
score: number;
} | null = null;
for (const spaceId of opts.spaceIds.slice(0, NOTION_MAX_SPACE_PROBE)) {
if (!spaceId) continue;
@@ -521,9 +492,7 @@ export async function selectBestNotionSpaceId(opts: {
}
}
return best
? { spaceId: best.spaceId, models: best.models, raw: best.raw }
: null;
return best ? { spaceId: best.spaceId, models: best.models, raw: best.raw } : null;
}
/**
@@ -558,7 +527,11 @@ export async function resolveNotionRuntimeWorkspace(opts: {
signal: opts.signal,
});
if (!best?.spaceId) {
return { spaceId: candidates.spaceIds[0] || "", userId: userId || candidates.userId, fromCache: false };
return {
spaceId: candidates.spaceIds[0] || "",
userId: userId || candidates.userId,
fromCache: false,
};
}
const resolvedUser = userId || candidates.userId;
@@ -766,10 +739,7 @@ export function resolveNotionCodename(
else if (m.startsWith("nw/")) m = m.slice(3);
if (!m || m === "notion-ai") return "";
const map = buildNotionFriendlyToCodenameMap([
...NOTION_WEB_FALLBACK_MODELS,
...extraModels,
]);
const map = buildNotionFriendlyToCodenameMap([...NOTION_WEB_FALLBACK_MODELS, ...extraModels]);
// Unknown ids pass through as-is so a freshly discovered codename still works
// before the fallback table is updated.
return map.get(m) || map.get(m.toLowerCase()) || map.get(slugifyNotionDisplayName(m)) || m;

View File

@@ -26,9 +26,14 @@ describe("NotionWebExecutor — registry consistency", () => {
assert.ok(models.length >= 1);
assert.ok(models.some((m) => m.id === "notion-ai"));
// Seed catalog uses real web-picker labels (fable-5 / gpt-5.6-sol), not food codenames.
assert.ok(models.some((m) => m.id === "fable-5" || m.id === "gpt-5.6-sol" || m.id === "opus-4.8"));
assert.ok(
models.some((m) => m.id === "fable-5" || m.id === "gpt-5.6-sol" || m.id === "opus-4.8")
);
assert.equal(
models.some((m) => m.id === "ambrosia-tart-high" || m.id === "orange-mousse" || m.id === "acai-budino-high"),
models.some(
(m) =>
m.id === "ambrosia-tart-high" || m.id === "orange-mousse" || m.id === "acai-budino-high"
),
false
);
});
@@ -124,6 +129,14 @@ describe("NotionWebExecutor — upstream translation (mocked fetch)", () => {
assert.equal(capturedHeaders.Cookie, COOKIE_WITH_SPACE);
assert.equal(capturedHeaders["x-notion-space-id"], "space-1");
assert.equal(capturedHeaders["x-notion-active-user-header"], "user-1");
// Browser fingerprint headers to reduce Cloudflare challenges.
assert.ok(capturedHeaders["sec-ch-ua"], "sec-ch-ua should be present");
assert.ok(capturedHeaders["sec-fetch-dest"], "sec-fetch-dest should be present");
assert.ok(capturedHeaders["sec-fetch-mode"], "sec-fetch-mode should be present");
assert.equal(capturedHeaders["sec-fetch-mode"], "cors");
assert.ok(capturedHeaders["sec-ch-ua-platform"], "sec-ch-ua-platform should be present");
assert.equal(capturedHeaders["cache-control"], "no-cache");
assert.equal(capturedHeaders["pragma"], "no-cache");
assert.ok(capturedBody);
assert.equal(capturedBody.createThread, true);
assert.ok(typeof capturedBody.threadId === "string" && capturedBody.threadId.length > 0);
@@ -295,7 +308,8 @@ describe("NotionWebExecutor — upstream translation (mocked fetch)", () => {
const executor = new mod.NotionWebExecutor();
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = (async () => new Response("not-json\n{}", { status: 200 })) as typeof fetch;
globalThis.fetch = (async () =>
new Response("not-json\n{}", { status: 200 })) as typeof fetch;
const result = await executor.execute({
model: "notion-ai",
@@ -314,8 +328,7 @@ describe("NotionWebExecutor — upstream translation (mocked fetch)", () => {
const executor = new mod.NotionWebExecutor();
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = (async () =>
new Response("Forbidden", { status: 403 })) as typeof fetch;
globalThis.fetch = (async () => new Response("Forbidden", { status: 403 })) as typeof fetch;
const result = await executor.execute({
model: "notion-ai",
@@ -325,7 +338,9 @@ describe("NotionWebExecutor — upstream translation (mocked fetch)", () => {
signal: null,
} as never);
assert.equal(result.response.status, 403);
const errBody = (await result.response.json()) as { error: { message: string; code: string } };
const errBody = (await result.response.json()) as {
error: { message: string; code: string };
};
assert.match(errBody.error.message, /session expired|invalid/i);
assert.equal(errBody.error.code, "HTTP_403");
// No stack trace / file path leakage (Hard Rule #12).
@@ -459,10 +474,7 @@ describe("estimateNotionUsage", () => {
it("scales with prompt and completion length (not a constant 2000)", () => {
const short = estimateNotionUsage([{ role: "user", content: "hi" }], "PONG");
const long = estimateNotionUsage(
[{ role: "user", content: "a".repeat(400) }],
"b".repeat(400)
);
const long = estimateNotionUsage([{ role: "user", content: "a".repeat(400) }], "b".repeat(400));
assert.equal(short.estimated, true);
assert.ok(short.prompt_tokens >= 1);
assert.ok(short.completion_tokens >= 1);

View File

@@ -58,8 +58,14 @@ test("parseNotionAvailableModels maps enabled models and skips disabled", () =>
assert.ok(models.some((m) => m.id === "opus-4.8" && m.name === "Opus 4.8"));
assert.ok(models.some((m) => m.id === "notion-ai"));
// Food codenames must not be primary catalog ids.
assert.equal(models.some((m) => m.id === "orange-mousse"), false);
assert.equal(models.some((m) => m.id === "ambrosia-tart-high"), false);
assert.equal(
models.some((m) => m.id === "orange-mousse"),
false
);
assert.equal(
models.some((m) => m.id === "ambrosia-tart-high"),
false
);
const sol = models.find((m) => m.id === "gpt-5.6-sol");
assert.equal(sol?.notionCodename, "orange-mousse");
assert.equal(sol?.supportsReasoning, true);
@@ -108,7 +114,10 @@ test("listNotionDisabledModels surfaces plan-locked Fable 5 without listing it a
assert.equal(disabled[0].reason, "business_or_enterprise_plan_required");
const enabled = notionModels.parseNotionAvailableModels(payload);
assert.equal(enabled.some((m) => m.id === "fable-5" || m.id === "acai-budino-high"), false);
assert.equal(
enabled.some((m) => m.id === "fable-5" || m.id === "acai-budino-high"),
false
);
assert.ok(enabled.some((m) => m.id === "gpt-5.6-sol"));
const warning = notionModels.formatNotionDisabledModelsWarning(disabled);
@@ -311,6 +320,11 @@ test("notion-web models route returns live getAvailableModels catalog", async ()
assert.equal(body.spaceId, "space-live-1");
const headers = init?.headers as Record<string, string>;
assert.match(String(headers.cookie || headers.Cookie || ""), /token_v2=sess/);
// Browser fingerprint headers present on models-discovery requests.
assert.ok(headers["sec-ch-ua"], "sec-ch-ua should be present");
assert.ok(headers["sec-fetch-mode"], "sec-fetch-mode should be present");
assert.equal(headers["sec-fetch-mode"], "cors");
assert.equal(headers["cache-control"], "no-cache");
return Response.json(SAMPLE_RESPONSE);
}
return new Response("unexpected", { status: 500 });