Compare commits

..

1 Commits

Author SHA1 Message Date
diegosouzapw
c3bf4053e4 fix(providers): scope TinyCMS DOM shims to the call, not the process (#12072)
initTinyCmsWasm()/generateSecurePayload() installed global.window/document
DOM shims via setupDomMocks() but never called the returned restore
callback. On an npm-global install the Next.js dashboard SSR runs in the
same Node process, so after the first TinyCMS request every later SSR
render observed the leaked fake document, whose createElement() returns
null for anything but 'canvas' — turning the next render into a 500.

Wrap both call sites in install -> use -> restore (try/finally) so the
shims are scoped to just the call instead of the process lifetime.

Also fixes a second, smaller bug found while tracing the client-visible
symptom: fetchInterceptionToggles() called res.json() without checking
res.ok first, so a non-JSON error body (e.g. the plain-text 500 above)
surfaced as a raw SyntaxError in the interceptionLoadError toast instead
of a clean HTTP <status> message.
2026-09-10 14:49:29 -03:00
8 changed files with 208 additions and 119 deletions

View File

@@ -0,0 +1 @@
- fix(providers): scope TinyCMS Web signer's DOM shims to each call instead of leaking them for the process lifetime, and surface a clean HTTP status on a non-JSON interception-toggles error (#12072)

View File

@@ -1 +0,0 @@
- fix(sse): parse Ollama Cloud's current usage markup (`$X of $Y used` aria-label, nested width style) (#12749)

View File

@@ -467,13 +467,21 @@ let wasmInitialized = false;
export async function initTinyCmsWasm() {
if (wasmInitialized) return;
// Install the DOM shims the wasm-bindgen glue expects before instantiating
// the module (see setupDomMocks() above). Left installed for the process
// lifetime — generateSecurePayload() keeps calling into the same canvas
// shims on every invocation, not just at init.
setupDomMocks();
const wasmBuffer = Buffer.from(WASM_BASE64, 'base64');
await __wbg_init(wasmBuffer);
wasmInitialized = true;
// the module (see setupDomMocks() above), and restore them right after —
// scoped to just this init call instead of the process lifetime. This
// process runs the Next.js dashboard SSR too (npm-global install), so
// leaving global.window/document installed here would poison every later
// SSR render (#12072). generateSecurePayload() below re-installs its own
// shims around each call, since the wasm-bindgen glue reaches back into
// document.createElement/getContext on every invocation, not just at init.
const restore = setupDomMocks();
try {
const wasmBuffer = Buffer.from(WASM_BASE64, 'base64');
await __wbg_init(wasmBuffer);
wasmInitialized = true;
} finally {
restore();
}
}
// Add type bindings
@@ -501,5 +509,15 @@ export function generateSecurePayload(
client_ip: string,
difficulty: number
): SecurePayload {
return generate_secure_payload(username, timestamp, nonce_js, challenge, client_ip, difficulty) as SecurePayload;
// Scope the DOM shims to just this synchronous call (install -> use ->
// restore) instead of relying on whatever initTinyCmsWasm() left behind
// — that call now restores its own shims immediately, and this is fully
// synchronous (no await between install and restore), so nothing else on
// Node's single-threaded event loop can observe the shim in between.
const restore = setupDomMocks();
try {
return generate_secure_payload(username, timestamp, nonce_js, challenge, client_ip, difficulty) as SecurePayload;
} finally {
restore();
}
}

View File

@@ -77,36 +77,16 @@ function normalizeOllamaCloudCookie(value: string): string {
: trimmed;
}
function clampPercent(pct: number): number | null {
return Number.isFinite(pct) && pct >= 0 && pct <= 100 ? pct : null;
}
function extractAriaLabelPercent(tagHeader: string): number | null {
const directMatch = tagHeader.match(/(\d+(?:\.\d+)?)%\s*used/);
if (directMatch) return clampPercent(toNumber(directMatch[1], Number.NaN));
const ratioMatch = tagHeader.match(/\$\s*([0-9.]+)\s*of\s*\$\s*([0-9.]+)\s*used/i);
if (!ratioMatch) return null;
const used = toNumber(ratioMatch[1], Number.NaN);
const total = toNumber(ratioMatch[2], Number.NaN);
if (!Number.isFinite(used) || !Number.isFinite(total) || total <= 0) return null;
return clampPercent((used / total) * 100);
}
function extractWidthStylePercent(html: string): number | null {
const styleMatches = html.matchAll(/style="([^"]*)"/g);
for (const match of styleMatches) {
const pct = toNumber(match[1].match(/(?:^|;)\s*width\s*:\s*([0-9.]+)%/)?.[1], Number.NaN);
const clamped = clampPercent(pct);
if (clamped !== null) return clamped;
}
return null;
}
function extractOllamaUsagePercent(trackHtml: string): number | null {
const tagHeader = trackHtml.match(/^[^>]*/)?.[0] ?? "";
const ariaPercent = extractAriaLabelPercent(tagHeader);
if (ariaPercent !== null) return ariaPercent;
return extractWidthStylePercent(trackHtml);
const ariaMatch = tagHeader.match(/(\d+(?:\.\d+)?)%\s*used/);
if (ariaMatch) {
const pct = toNumber(ariaMatch[1], Number.NaN);
if (Number.isFinite(pct) && pct >= 0 && pct <= 100) return pct;
}
const style = tagHeader.match(/style="([^"]*)"/)?.[1] ?? "";
const pct = toNumber(style.match(/(?:^|;)\s*width\s*:\s*([0-9.]+)%/)?.[1], Number.NaN);
return Number.isFinite(pct) && pct >= 0 && pct <= 100 ? pct : null;
}
function parseOllamaCloudSettingsHtml(html: string): OllamaCloudUsage | null {

View File

@@ -32,8 +32,15 @@ type Translate = (key: string, values?: Record<string, string>) => string;
const DEFAULT_TOGGLES: InterceptionToggles = { interceptSearch: false, interceptFetch: false };
async function throwOnErrorResponse(res: Response): Promise<void> {
if (res.ok) return;
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || `HTTP ${res.status}`);
}
async function fetchInterceptionToggles(providerId: string): Promise<InterceptionToggles> {
const res = await fetch(`/api/providers/${providerId}/interception-rules`);
await throwOnErrorResponse(res);
const data = await res.json();
return {
interceptSearch: data?.interceptSearch === true,
@@ -41,12 +48,6 @@ async function fetchInterceptionToggles(providerId: string): Promise<Interceptio
};
}
async function throwOnErrorResponse(res: Response): Promise<void> {
if (res.ok) return;
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || `HTTP ${res.status}`);
}
async function putInterceptionToggles(
providerId: string,
toggles: InterceptionToggles

View File

@@ -0,0 +1,103 @@
// @vitest-environment jsdom
//
// Regression test for issue #12072 (second, smaller bug found while fixing
// the TinyCMS DOM-shim leak): fetchInterceptionToggles() used to call
// `await res.json()` without checking `res.ok` first, so a non-JSON error
// body (e.g. a plain-text 500 from the poisoned-SSR bug) surfaced as a raw
// `SyntaxError` inside the `interceptionLoadError` toast instead of a clean
// `HTTP <status>` message.
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ProviderInterceptionSection from "../ProviderInterceptionSection";
// Stable references: the component's load effect depends on `t` and `notify`,
// so a mock returning a fresh closure/object on every render would re-fire the
// effect after every setState (an infinite loop) instead of running once.
const stableTranslate = (key: string, values?: Record<string, string>) =>
values ? `${key}:${JSON.stringify(values)}` : key;
vi.mock("next-intl", () => ({
useTranslations: () => stableTranslate,
}));
const notifyError = vi.fn();
const stableNotify = { error: notifyError, success: vi.fn() };
vi.mock("@/store/notificationStore", () => ({
useNotificationStore: () => stableNotify,
}));
const cleanups: Array<() => void> = [];
function renderComponent(node: React.ReactElement) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => root.render(node));
cleanups.push(() => {
act(() => root.unmount());
container.remove();
});
return container;
}
async function flush() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe("ProviderInterceptionSection (#12072)", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
notifyError.mockClear();
});
afterEach(() => {
while (cleanups.length) cleanups.pop()?.();
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
it("surfaces a clean HTTP status message when GET returns a non-JSON 500 body", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve({
ok: false,
status: 500,
json: () => Promise.reject(new SyntaxError('Unexpected token \'I\', "Internal S"...')),
} as unknown as Response)
)
);
renderComponent(<ProviderInterceptionSection providerId="openai" />);
await flush();
expect(notifyError).toHaveBeenCalledTimes(1);
const [message] = notifyError.mock.calls[0] as [string];
expect(message).toContain("HTTP 500");
expect(message).not.toContain("Unexpected token");
expect(message).not.toContain("SyntaxError");
});
it("loads toggles normally when GET returns a valid JSON body", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ interceptSearch: true, interceptFetch: false }),
} as unknown as Response)
)
);
renderComponent(<ProviderInterceptionSection providerId="openai" />);
await flush();
expect(notifyError).not.toHaveBeenCalled();
});
});

View File

@@ -189,79 +189,3 @@ test("getUsageForProvider reports expired Ollama Cloud cookies on redirect", asy
else process.env.OLLAMA_USAGE_COOKIE = originalCookie;
}
});
test("getUsageForProvider parses the current $X-of-$Y aria-label with nested width style (#12749)", async () => {
const originalFetch = globalThis.fetch;
const originalCookie = process.env.OLLAMA_USAGE_COOKIE;
const originalOmniCookie = process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE;
delete process.env.OLLAMA_USAGE_COOKIE;
process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE = "__Secure-session=test-cookie";
globalThis.fetch = async () =>
new Response(
[
'<span class="capitalize">pro</span>',
'<div class="relative h-3 overflow-hidden rounded-full bg-neutral-200" data-usage-track aria-label="Monthly usage $60.01 of $60 used">',
'<div class="flex h-full overflow-hidden bg-neutral-950" style="width: 100%; background: #ef4444;"></div>',
'<span class="local-time" data-time="2026-06-22T15:00:00.000Z"></span>',
"</div>",
].join(""),
{ status: 200, headers: { "content-type": "text/html" } }
);
try {
const result = (await usage.getUsageForProvider({
id: "ollama-cloud-new-markup",
provider: "ollama-cloud",
apiKey: "ollama-chat-key",
})) as { message?: string; quotas?: Record<string, { used: number }> };
assert.ok(
result.quotas && Object.keys(result.quotas).length > 0,
`expected quotas, got message: ${result.message}`
);
assert.equal(result.quotas!.session.used, 100);
} finally {
globalThis.fetch = originalFetch;
if (originalCookie === undefined) delete process.env.OLLAMA_USAGE_COOKIE;
else process.env.OLLAMA_USAGE_COOKIE = originalCookie;
if (originalOmniCookie === undefined) delete process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE;
else process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE = originalOmniCookie;
}
});
test("getUsageForProvider still finds width style on a nested child when no aria-label percent exists (#12749)", async () => {
const originalFetch = globalThis.fetch;
const originalCookie = process.env.OLLAMA_USAGE_COOKIE;
const originalOmniCookie = process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE;
delete process.env.OLLAMA_USAGE_COOKIE;
process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE = "__Secure-session=test-cookie";
globalThis.fetch = async () =>
new Response(
[
'<div class="relative h-3" data-usage-track aria-label="Weekly usage $12 of $60 used">',
'<div class="flex h-full" style="width: 20%;"></div>',
'<span class="local-time" data-time="2026-06-29T15:00:00.000Z"></span>',
"</div>",
].join(""),
{ status: 200, headers: { "content-type": "text/html" } }
);
try {
const result = (await usage.getUsageForProvider({
id: "ollama-cloud-nested-width",
provider: "ollama-cloud",
apiKey: "ollama-chat-key",
})) as { quotas?: Record<string, { used: number }> };
// The aria-label ratio ($12 of $60 = 20%) is used, matching the nested style width fallback.
assert.equal(result.quotas!.session.used, 20);
} finally {
globalThis.fetch = originalFetch;
if (originalCookie === undefined) delete process.env.OLLAMA_USAGE_COOKIE;
else process.env.OLLAMA_USAGE_COOKIE = originalCookie;
if (originalOmniCookie === undefined) delete process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE;
else process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE = originalOmniCookie;
}
});

View File

@@ -0,0 +1,63 @@
/**
* Regression test for issue #12072.
*
* initTinyCmsWasm() / generateSecurePayload() used to call setupDomMocks()
* and never invoke the restore callback it returns, so global.window /
* global.document / HTMLCanvasElement remained installed on the Node
* process for its entire lifetime. On an npm-global install the Next.js
* dashboard SSR runs in that same process, so after the first TinyCMS
* request every SSR render observed a fake `document` whose
* createElement() returns null for anything but 'canvas' — which turned
* the following SSR render into a plain-text 500.
*
* This test proves the shims are scoped to the call (installed, used,
* restored) instead of leaking past it, directly against
* tinycmsSigner.ts, without needing a live TinyCMS network call or a
* running Next.js server.
*/
import test from "node:test";
import assert from "node:assert/strict";
test("initTinyCmsWasm does not leave global.window/document installed after it resolves", async () => {
const g = global as Record<string, unknown>;
// Sanity: nothing must be present before we start, otherwise the
// assertions below prove nothing.
assert.equal("window" in g, false, "test process must not already have global.window");
assert.equal("document" in g, false, "test process must not already have global.document");
const { initTinyCmsWasm } = await import("../../open-sse/executors/tinycmsSigner.ts");
await initTinyCmsWasm();
assert.equal(
typeof g.window,
"undefined",
"REGRESSION (#12072): global.window leaked past initTinyCmsWasm() — this is what makes " +
"`typeof window !== \"undefined\"` true for every subsequent SSR render in the same process"
);
assert.equal(
typeof g.document,
"undefined",
"REGRESSION (#12072): global.document leaked past initTinyCmsWasm()"
);
});
test("generateSecurePayload does not leave global.window/document installed after it returns", async () => {
const g = global as Record<string, unknown>;
const { generateSecurePayload } = await import("../../open-sse/executors/tinycmsSigner.ts");
generateSecurePayload("user", String(Date.now()), "nonce", "challenge", "127.0.0.1", 1);
assert.equal(
typeof g.window,
"undefined",
"REGRESSION (#12072): global.window leaked past generateSecurePayload()"
);
assert.equal(
typeof g.document,
"undefined",
"REGRESSION (#12072): global.document leaked past generateSecurePayload()"
);
});