From c761c526f038dab48e10ec049aaf22c9cb87d4f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 04:25:24 +0900 Subject: [PATCH 01/35] test(clearfolio): specify bounded provider transport --- .../clearfolio-provider-boundary.test.mjs | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 tests/unit/clearfolio-provider-boundary.test.mjs diff --git a/tests/unit/clearfolio-provider-boundary.test.mjs b/tests/unit/clearfolio-provider-boundary.test.mjs new file mode 100644 index 00000000..cea06c64 --- /dev/null +++ b/tests/unit/clearfolio-provider-boundary.test.mjs @@ -0,0 +1,144 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const HMAC_SECRET = 'clearfolio-shared-secret-32-bytes!!'; +process.env.CLEARFOLIO_URL = 'https://clearfolio.example'; +process.env.CLEARFOLIO_HMAC_SECRET = HMAC_SECRET; +delete process.env.SCOPEWEAVE_DEV; + +const originalFetch = globalThis.fetch; +const calls = []; +let responder; +globalThis.fetch = async (url, options = {}) => { + calls.push({ url: String(url), options }); + if (!responder) throw new Error('test responder is not configured'); + return responder(url, options); +}; + +const { + CLEARFOLIO_MAX_RESPONSE_BYTES, + CLEARFOLIO_REQUEST_TIMEOUT_MS, + artifactUrl, + jobStatus, + submitJob, +} = await import(`../../server/clearfolio.mjs?provider-boundary=${Date.now()}`); + +const jsonResponse = (value, init = {}) => new Response(JSON.stringify(value), { + status: init.status ?? 200, + headers: { + 'content-type': init.contentType ?? 'application/json; charset=utf-8', + ...(init.headers || {}), + }, +}); + +function useResponse(value, init = {}) { + responder = async () => jsonResponse(value, init); +} + +test.after(() => { + globalThis.fetch = originalFetch; + delete process.env.CLEARFOLIO_URL; + delete process.env.CLEARFOLIO_HMAC_SECRET; +}); + +test('provider requests disable redirects and carry a bounded total-request signal', async () => { + useResponse({ status: 'RUNNING' }); + const before = calls.length; + assert.equal(await jobStatus(1, 2, 'job-1'), 'RUNNING'); + assert.equal(calls.length, before + 1); + const { options } = calls.at(-1); + assert.equal(options.redirect, 'error'); + assert.ok(options.signal instanceof AbortSignal); + assert.equal(options.signal.aborted, false); + assert.equal(Number.isSafeInteger(CLEARFOLIO_REQUEST_TIMEOUT_MS), true); + assert.equal(CLEARFOLIO_REQUEST_TIMEOUT_MS > 0 && CLEARFOLIO_REQUEST_TIMEOUT_MS <= 30_000, true); +}); + +test('status response requires JSON media type before parsing', async () => { + useResponse({ status: 'RUNNING' }, { contentType: 'text/plain' }); + await assert.rejects( + () => jobStatus(1, 2, 'job-1'), + /clearfolio status response invalid/, + ); +}); + +test('declared and streamed provider response bodies are bounded', async () => { + assert.equal(Number.isSafeInteger(CLEARFOLIO_MAX_RESPONSE_BYTES), true); + assert.equal(CLEARFOLIO_MAX_RESPONSE_BYTES >= 1024 && CLEARFOLIO_MAX_RESPONSE_BYTES <= 1024 * 1024, true); + + useResponse({ status: 'RUNNING' }, { + headers: { 'content-length': String(CLEARFOLIO_MAX_RESPONSE_BYTES + 1) }, + }); + await assert.rejects( + () => jobStatus(1, 2, 'job-1'), + /clearfolio status response invalid/, + ); + + responder = async () => new Response( + new Uint8Array(CLEARFOLIO_MAX_RESPONSE_BYTES + 1), + { headers: { 'content-type': 'application/json' } }, + ); + await assert.rejects( + () => jobStatus(1, 2, 'job-1'), + /clearfolio status response invalid/, + ); +}); + +test('caller cancellation remains composed with the provider request budget', async () => { + const controller = new AbortController(); + controller.abort(new Error('caller cancelled with private detail')); + responder = async (_url, options) => { + assert.equal(options.signal.aborted, true); + throw options.signal.reason; + }; + await assert.rejects( + () => jobStatus(1, 2, 'job-1', { signal: controller.signal }), + (error) => { + assert.equal(error.message, 'clearfolio status unavailable'); + assert.doesNotMatch(error.message, /private detail/); + return true; + }, + ); +}); + +test('document validation fails before Blob, FormData, or provider transport', async () => { + const before = calls.length; + const invalidDocuments = [ + null, + { name: '', mime: 'text/plain', bytes: Buffer.from('x') }, + { name: 'x'.repeat(513), mime: 'text/plain', bytes: Buffer.from('x') }, + { name: 'x.txt', mime: 'x'.repeat(256), bytes: Buffer.from('x') }, + { name: 'x.txt', mime: 'text/plain', bytes: 'not-bytes' }, + { name: 'x.txt', mime: 'text/plain', bytes: new Uint8Array(10 * 1024 * 1024 + 1) }, + ]; + for (const document of invalidDocuments) { + await assert.rejects( + () => submitJob(1, 2, document), + /clearfolio document invalid/, + ); + } + assert.equal(calls.length, before, 'invalid documents never reach provider transport'); +}); + +test('provider job identifiers are bounded before URL construction', async () => { + const before = calls.length; + for (const operation of [ + () => jobStatus(1, 2, 'x'.repeat(257)), + () => artifactUrl(1, 2, 'x'.repeat(257)), + ]) { + await assert.rejects(operation, /clearfolio job id invalid/); + } + assert.equal(calls.length, before, 'oversized job identifiers never reach provider transport'); +}); + +test('valid submit response remains compatible with the bounded transport', async () => { + useResponse({ jobId: ' job-2 ', status: 'PENDING' }); + assert.deepEqual( + await submitJob(7, 9, { name: 'status.txt', mime: 'text/plain', bytes: Buffer.from('status') }), + { jobId: 'job-2', status: 'PENDING' }, + ); + const { options } = calls.at(-1); + assert.equal(options.redirect, 'error'); + assert.ok(options.signal instanceof AbortSignal); + assert.ok(options.body instanceof FormData); +}); From f38a3d0cfc5db6e7cb5e6b434a7b1071dff6d384 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 04:27:15 +0900 Subject: [PATCH 02/35] fix(clearfolio): bound provider transport and JSON responses --- server/clearfolio.mjs | 203 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 185 insertions(+), 18 deletions(-) diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index da17bbea..a11a4a97 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -9,6 +9,17 @@ const PERMISSIONS = 'job:create,job:read,viewer:read,artifact-link:create'; const CLEARFOLIO_JOB_STATUSES = new Set(['PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED']); const MIN_HMAC_SECRET_LENGTH = 32; const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']); +const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024; +const MAX_DOCUMENT_NAME_LENGTH = 512; +const MAX_MIME_LENGTH = 255; +const MAX_JOB_ID_LENGTH = 256; +const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/; + +/** Hard total-request budget for each Clearfolio provider call. */ +export const CLEARFOLIO_REQUEST_TIMEOUT_MS = 15_000; + +/** Maximum successful Clearfolio JSON response bytes read into memory. */ +export const CLEARFOLIO_MAX_RESPONSE_BYTES = 256 * 1024; /** Whether the process uses the explicit in-memory Clearfolio development adapter. */ export const clearfolioMock = process.env.SCOPEWEAVE_DEV === '1' && !CF_URL_INPUT; @@ -181,6 +192,148 @@ function isClearfolioJobStatus(value) { return typeof value === 'string' && CLEARFOLIO_JOB_STATUSES.has(value); } +/** + * Validate document metadata and bytes before allocating Blob/FormData objects. + * + * ScopeWeave's browser/API attachment ceiling is 10 MiB, so the provider adapter + * never accepts a larger in-process document than the caller can legitimately + * upload. Empty MIME is preserved as the existing application/octet-stream + * fallback. + * + * @param {unknown} document - Untrusted adapter input. + * @returns {{name:string,mime:string,bytes:Uint8Array}} Validated document input. + * @throws {Error} If metadata or bytes are malformed or outside the bounded contract. + */ +function validateDocument(document) { + if (!isJsonRecord(document)) throw new Error('clearfolio document invalid'); + const { name, mime, bytes } = document; + if ( + typeof name !== 'string' + || name.trim().length === 0 + || name.length > MAX_DOCUMENT_NAME_LENGTH + || CONTROL_CHARACTER_PATTERN.test(name) + || typeof mime !== 'string' + || mime.length > MAX_MIME_LENGTH + || CONTROL_CHARACTER_PATTERN.test(mime) + || !(bytes instanceof Uint8Array) + || bytes.byteLength > MAX_DOCUMENT_BYTES + ) { + throw new Error('clearfolio document invalid'); + } + return { name, mime, bytes }; +} + +/** + * Canonicalize and bound a provider job identifier before it reaches a URL. + * + * @param {unknown} jobId - Persisted or provider-returned job identifier. + * @returns {string} Trimmed non-empty identifier no longer than 256 characters. + * @throws {Error} If the identifier is unusable. + */ +function validateJobId(jobId) { + if (typeof jobId !== 'string') throw new Error('clearfolio job id invalid'); + const canonical = jobId.trim(); + if ( + canonical.length === 0 + || canonical.length > MAX_JOB_ID_LENGTH + || CONTROL_CHARACTER_PATTERN.test(canonical) + ) { + throw new Error('clearfolio job id invalid'); + } + return canonical; +} + +/** + * Compose an optional caller cancellation signal with the hard provider budget. + * + * @param {AbortSignal|undefined} callerSignal - Optional upstream cancellation signal. + * @returns {AbortSignal} Signal that aborts on caller cancellation or total timeout. + */ +function providerSignal(callerSignal) { + const timeoutSignal = AbortSignal.timeout(CLEARFOLIO_REQUEST_TIMEOUT_MS); + if (callerSignal === undefined) return timeoutSignal; + if (!(callerSignal instanceof AbortSignal)) throw new TypeError('signal must be an AbortSignal'); + return AbortSignal.any([callerSignal, timeoutSignal]); +} + +/** + * Parse one successful provider JSON response with media-type and byte bounds. + * + * Content-Length is treated only as an early rejection hint; the body stream is + * independently counted so omitted or dishonest length headers cannot bypass the + * memory ceiling. Invalid UTF-8, JSON, stream errors, and cancellation are + * collapsed to one operation-level message. + * + * @param {Response} response - Successful fetch response. + * @param {string} invalidMessage - Fixed operation-level validation error. + * @returns {Promise} Parsed JSON value. + * @throws {Error} If media type, declared/streamed size, UTF-8, or JSON is invalid. + */ +async function readBoundedJson(response, invalidMessage) { + const contentType = response?.headers?.get?.('content-type'); + if ( + typeof contentType !== 'string' + || contentType.split(';', 1)[0].trim().toLowerCase() !== 'application/json' + ) { + throw new Error(invalidMessage); + } + + const contentLength = response.headers.get('content-length'); + if (contentLength !== null) { + if (!/^\d+$/.test(contentLength)) throw new Error(invalidMessage); + const declaredBytes = Number(contentLength); + if (!Number.isSafeInteger(declaredBytes) || declaredBytes > CLEARFOLIO_MAX_RESPONSE_BYTES) { + throw new Error(invalidMessage); + } + } + + if (!response.body || typeof response.body.getReader !== 'function') { + throw new Error(invalidMessage); + } + + const reader = response.body.getReader(); + const chunks = []; + let totalBytes = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!(value instanceof Uint8Array)) throw new Error(invalidMessage); + totalBytes += value.byteLength; + if (totalBytes > CLEARFOLIO_MAX_RESPONSE_BYTES) { + try { await reader.cancel(); } catch { /* validation remains authoritative */ } + throw new Error(invalidMessage); + } + chunks.push(value); + } + } catch (error) { + if (error?.message === invalidMessage) throw error; + throw new Error(invalidMessage); + } finally { + try { reader.releaseLock(); } catch { /* no observable effect */ } + } + + if (totalBytes === 0) throw new Error(invalidMessage); + const bytes = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + + let text; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + throw new Error(invalidMessage); + } + try { + return JSON.parse(text); + } catch { + throw new Error(invalidMessage); + } +} + // ---- explicit development-only mock store (restart discards it) ---- const mockDocs = new Map(); // jobId -> { name, mime, bytes } let mockSeq = 0; @@ -205,37 +358,46 @@ export const mockArtifact = (jobId) => (clearfolioMock ? mockDocs.get(jobId) || * @returns {Promise<{jobId:string,status:string}>} Downstream job identity and initial status. * @throws {Error} If Clearfolio is unavailable, rejects the request, or returns a malformed response. */ -export async function submitJob(orgId, userId, { name, mime, bytes }) { +export async function submitJob(orgId, userId, document) { + const validatedDocument = validateDocument(document); const configuration = clearfolioConfiguration(); if (configuration.mock) { const jobId = `mockcf-${++mockSeq}`; - mockDocs.set(jobId, { name, mime, bytes }); + mockDocs.set(jobId, validatedDocument); return { jobId, status: 'SUCCEEDED' }; } const form = new FormData(); - form.append('file', new Blob([bytes], { type: mime || 'application/octet-stream' }), name); + form.append( + 'file', + new Blob([validatedDocument.bytes], { type: validatedDocument.mime || 'application/octet-stream' }), + validatedDocument.name, + ); let res; try { res = await fetch(`${configuration.baseUrl}/api/v1/convert/jobs`, { method: 'POST', headers: tenantHeaders(orgId, userId, configuration.secret), body: form, + redirect: 'error', + signal: providerSignal(), }); } catch { throw new Error('clearfolio submit unavailable'); } if (!res.ok) throw new Error(`clearfolio submit failed (${res.status})`); - const data = await res.json().catch(() => null); + const data = await readBoundedJson(res, 'clearfolio submit response invalid'); if (!isJsonRecord(data)) throw new Error('clearfolio submit response invalid'); const status = data.status === undefined ? 'PENDING' : data.status; - if ( - typeof data.jobId !== 'string' - || data.jobId.trim().length === 0 - || !isClearfolioJobStatus(status) - ) { + if (typeof data.jobId !== 'string' || !isClearfolioJobStatus(status)) { + throw new Error('clearfolio submit response invalid'); + } + let jobId; + try { + jobId = validateJobId(data.jobId); + } catch { throw new Error('clearfolio submit response invalid'); } - return { jobId: data.jobId.trim(), status }; + return { jobId, status }; } /** @@ -254,19 +416,21 @@ export async function submitJob(orgId, userId, { name, mime, bytes }) { * @throws {Error} If Clearfolio is unavailable, rejects the request, or returns a malformed status. */ export async function jobStatus(orgId, userId, jobId, { signal } = {}) { + const canonicalJobId = validateJobId(jobId); const configuration = clearfolioConfiguration(); - if (configuration.mock) return mockDocs.has(jobId) ? 'SUCCEEDED' : 'FAILED'; + if (configuration.mock) return mockDocs.has(canonicalJobId) ? 'SUCCEEDED' : 'FAILED'; let res; try { - res = await fetch(`${configuration.baseUrl}/api/v1/convert/jobs/${encodeURIComponent(jobId)}`, { + res = await fetch(`${configuration.baseUrl}/api/v1/convert/jobs/${encodeURIComponent(canonicalJobId)}`, { headers: tenantHeaders(orgId, userId, configuration.secret), - signal, + signal: providerSignal(signal), + redirect: 'error', }); } catch { throw new Error('clearfolio status unavailable'); } if (!res.ok) throw new Error(`clearfolio status failed (${res.status})`); - const data = await res.json().catch(() => null); + const data = await readBoundedJson(res, 'clearfolio status response invalid'); if (!isJsonRecord(data) || !isClearfolioJobStatus(data.status)) { throw new Error('clearfolio status response invalid'); } @@ -287,19 +451,22 @@ export async function jobStatus(orgId, userId, jobId, { signal } = {}) { * @throws {Error} If Clearfolio is unavailable, rejects the request, or returns an invalid link. */ export async function artifactUrl(orgId, userId, jobId) { + const canonicalJobId = validateJobId(jobId); const configuration = clearfolioConfiguration(); - if (configuration.mock) return `/api/mock-clearfolio/${encodeURIComponent(jobId)}`; + if (configuration.mock) return `/api/mock-clearfolio/${encodeURIComponent(canonicalJobId)}`; let res; try { - res = await fetch(`${configuration.baseUrl}/api/v1/viewer/${encodeURIComponent(jobId)}/artifact-links`, { + res = await fetch(`${configuration.baseUrl}/api/v1/viewer/${encodeURIComponent(canonicalJobId)}/artifact-links`, { method: 'POST', headers: tenantHeaders(orgId, userId, configuration.secret), + redirect: 'error', + signal: providerSignal(), }); } catch { throw new Error('clearfolio artifact-link unavailable'); } if (!res.ok) throw new Error(`clearfolio artifact-link failed (${res.status})`); - const data = await res.json().catch(() => null); + const data = await readBoundedJson(res, 'clearfolio artifact-link response invalid'); if (!isJsonRecord(data)) throw new Error('clearfolio artifact-link response invalid'); const link = data.artifactUrl || data.url || data.signedUrl; if (typeof link !== 'string' || link.length === 0) { @@ -321,7 +488,7 @@ export async function artifactUrl(orgId, userId, jobId) { const token = url.searchParams.get('artifactToken'); if (token && url.origin === clearfolioUrl.origin) { - return `${configuration.baseUrl}/viewer/${encodeURIComponent(jobId)}?artifactToken=${encodeURIComponent(token)}`; + return `${configuration.baseUrl}/viewer/${encodeURIComponent(canonicalJobId)}?artifactToken=${encodeURIComponent(token)}`; } return url.href; } From 71a8aafd3bf13bd28090bceaf2548a2d3b2288d5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 04:28:27 +0900 Subject: [PATCH 03/35] test(clearfolio): run adapter regressions through real response streams --- tests/unit/clearfolio-status-signal.test.mjs | 28 +++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/tests/unit/clearfolio-status-signal.test.mjs b/tests/unit/clearfolio-status-signal.test.mjs index fea68573..9ea164cb 100644 --- a/tests/unit/clearfolio-status-signal.test.mjs +++ b/tests/unit/clearfolio-status-signal.test.mjs @@ -14,16 +14,27 @@ globalThis.fetch = async (url, options = {}) => { observedUrl = String(url); observedOptions = options; if (downstreamError) throw downstreamError; - return downstreamResponse; + return downstreamResponse(); }; const { artifactUrl, jobStatus, submitJob } = await import( '../../server/clearfolio.mjs?downstream-contract-test=1' ); -function setResponse({ ok = true, status = 200, json }) { +function setResponse({ status = 200, json }) { downstreamError = undefined; - downstreamResponse = { ok, status, json }; + downstreamResponse = async () => { + let body; + try { + body = JSON.stringify(await json()); + } catch { + body = '{'; + } + return new Response(body, { + status, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }); + }; } function setNetworkError(error) { @@ -52,7 +63,9 @@ test('jobStatus enforces endpoint, signal, transport, HTTP, and status contracts const status = await jobStatus(1, 2, 'job-1', { signal: controller.signal }); assert.equal(status, 'RUNNING'); assert.equal(observedUrl, 'https://clearfolio.example/api/v1/convert/jobs/job-1'); - assert.equal(observedOptions.signal, controller.signal); + assert.ok(observedOptions.signal instanceof AbortSignal); + assert.notEqual(observedOptions.signal, controller.signal, 'caller signal is composed with provider timeout'); + assert.equal(observedOptions.redirect, 'error'); setNetworkError(new Error('connect ECONNREFUSED https://private-clearfolio.internal')); await expectSanitizedFailure( @@ -62,7 +75,6 @@ test('jobStatus enforces endpoint, signal, transport, HTTP, and status contracts ); setResponse({ - ok: false, status: 503, json: async () => ({ message: 'sensitive downstream text' }), }); @@ -113,7 +125,6 @@ test('submitJob rejects transport details and malformed successful responses', a ); setResponse({ - ok: false, status: 422, json: async () => ({ message: 'tenant-internal rejection detail' }), }); @@ -124,6 +135,8 @@ test('submitJob rejects transport details and malformed successful responses', a ); assert.equal(observedUrl, 'https://clearfolio.example/api/v1/convert/jobs'); assert.equal(observedOptions.method, 'POST'); + assert.equal(observedOptions.redirect, 'error'); + assert.ok(observedOptions.signal instanceof AbortSignal); assert.ok(observedOptions.body instanceof FormData); const malformedPayloads = [ @@ -175,7 +188,6 @@ test('artifactUrl validates links and never exposes transport or response text', ); setResponse({ - ok: false, status: 502, json: async () => ({ message: 'signed URL service secret detail' }), }); @@ -189,6 +201,8 @@ test('artifactUrl validates links and never exposes transport or response text', 'https://clearfolio.example/api/v1/viewer/job-1/artifact-links', ); assert.equal(observedOptions.method, 'POST'); + assert.equal(observedOptions.redirect, 'error'); + assert.ok(observedOptions.signal instanceof AbortSignal); const malformedPayloads = [ { From 312466d057345a444b2435f836bbbe2c9b995638 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 04:29:06 +0900 Subject: [PATCH 04/35] test(clearfolio): exercise configuration contracts with streamed responses --- .../clearfolio-adapter-mock-hmac.test.mjs | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/tests/unit/clearfolio-adapter-mock-hmac.test.mjs b/tests/unit/clearfolio-adapter-mock-hmac.test.mjs index 74d1417f..bda2463c 100644 --- a/tests/unit/clearfolio-adapter-mock-hmac.test.mjs +++ b/tests/unit/clearfolio-adapter-mock-hmac.test.mjs @@ -7,6 +7,11 @@ async function freshModule(label) { return import(`../../server/clearfolio.mjs?${label}-${Date.now()}-${Math.random()}`); } +const jsonResponse = (value) => new Response(JSON.stringify(value), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8' }, +}); + test('unconfigured production fails closed instead of creating fake conversions', async () => { delete process.env.SCOPEWEAVE_DEV; delete process.env.CLEARFOLIO_URL; @@ -89,11 +94,11 @@ test('production URL and HMAC configuration rejects ambiguous or unsafe input', process.env.CLEARFOLIO_HMAC_SECRET = HMAC_SECRET; const loopback = await freshModule('development-loopback-http'); const originalFetch = globalThis.fetch; - globalThis.fetch = async () => ({ - ok: true, - status: 200, - json: async () => ({ status: 'RUNNING' }), - }); + globalThis.fetch = async (_url, options) => { + assert.equal(options.redirect, 'error'); + assert.ok(options.signal instanceof AbortSignal); + return jsonResponse({ status: 'RUNNING' }); + }; try { assert.equal(await loopback.jobStatus(1, 2, 'job-1'), 'RUNNING'); } finally { @@ -116,11 +121,7 @@ test('Clearfolio tenant claim headers use the documented HMAC contract', async ( globalThis.fetch = async (url, options) => { observedUrl = String(url); observedOptions = options; - return { - ok: true, - status: 200, - json: async () => ({ status: 'RUNNING' }), - }; + return jsonResponse({ status: 'RUNNING' }); }; try { @@ -131,6 +132,8 @@ test('Clearfolio tenant claim headers use the documented HMAC contract', async ( observedUrl, 'https://clearfolio.example/api/v1/convert/jobs/signed-job', ); + assert.equal(observedOptions.redirect, 'error'); + assert.ok(observedOptions.signal instanceof AbortSignal); const issuedAt = '1750000000'; assert.equal(observedOptions.headers['X-Clearfolio-Tenant-Id'], 'sw-org-21'); From b9c18e66a1e98bebe3dca6b6b0e542c4631799f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 04:30:58 +0900 Subject: [PATCH 05/35] test(clearfolio): register bounded provider regressions --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 46d07bfb..4134834c 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs", - "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs", + "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/clearfolio-provider-boundary.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/clearfolio-provider-boundary.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js", From 099cf2f003ada95c1966b0015359d834a27f5c5d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 04:32:06 +0900 Subject: [PATCH 06/35] docs(clearfolio): record bounded provider response boundary --- .../clearfolio-provider-response-boundary.md | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/doctoring/clearfolio-provider-response-boundary.md diff --git a/docs/doctoring/clearfolio-provider-response-boundary.md b/docs/doctoring/clearfolio-provider-response-boundary.md new file mode 100644 index 00000000..52a2e97a --- /dev/null +++ b/docs/doctoring/clearfolio-provider-response-boundary.md @@ -0,0 +1,82 @@ +# Clearfolio provider response and request boundary + +## Decision + +ScopeWeave treats the Clearfolio service as an untrusted external API even after its root origin and tenant HMAC configuration have passed the production configuration boundary. Every hosted submit, status, and artifact-link call therefore uses the same fail-closed transport and response rules before provider data can affect ScopeWeave state or browser-visible behavior. + +This record is intentionally narrower than the full Clearfolio production-readiness issue. It extends the configuration boundary introduced by the preceding production-configuration slice and does not claim that arbitrary cross-origin artifact delivery, retry/idempotency policy, or the complete provider lifecycle is finished. + +## Request contract + +Hosted provider requests: + +1. use the configuration-validated provider origin and adapter-owned endpoint path; +2. send tenant claims only to that direct origin request; +3. use Fetch `redirect: "error"` so a redirect is a transport failure rather than a credential-forwarding opportunity; +4. carry a hard 15,000 ms total-request `AbortSignal`; +5. compose a caller cancellation signal with that hard budget for status refreshes; and +6. collapse network, redirect, timeout, and cancellation details into fixed operation-level errors before they can reach browser or diagnostic payloads. + +ScopeWeave does not retry provider calls in this slice. Retry eligibility, idempotency keys, backoff, cancellation recovery, and persisted operation lifecycle remain explicit follow-up work rather than being guessed at the transport layer. + +## Response contract + +Successful provider responses are accepted only when the media type essence is `application/json`. If `Content-Length` is present it must be an exact non-negative decimal integer no greater than 256 KiB. The body stream is independently counted to the same 256 KiB ceiling, so missing or dishonest length metadata cannot bypass the resource limit. Empty bodies, malformed streams, invalid UTF-8, malformed JSON, and incompatible JSON shapes fail closed with fixed operation-specific errors. + +The adapter never uses `response.json()` directly for successful hosted provider responses. This prevents an otherwise successful response from being buffered without an application-level byte ceiling before validation. + +Provider conversion states remain the exact `PENDING`, `RUNNING`, `SUCCEEDED`, and `FAILED` set. Provider job identifiers are trimmed and limited to 256 characters without control characters before persistence or URL construction. + +## Document boundary + +Document metadata and bytes are validated before `Blob` or `FormData` construction. The provider adapter accepts only: + +- a non-empty document name of at most 512 characters without control characters; +- a MIME string of at most 255 characters without control characters; an empty value retains the existing `application/octet-stream` fallback; +- `Uint8Array`-compatible bytes no larger than 10 MiB. + +The 10 MiB limit matches the current ScopeWeave attachment API ceiling, so the downstream adapter cannot accept a document larger than the application path that feeds it. + +## Artifact boundary and remaining work + +The preceding slice already prevents a cross-origin `artifactToken` from being transplanted into the trusted Clearfolio viewer origin. This slice bounds and media-validates the artifact-link response itself and disables redirects on the request. + +It **does not yet approve arbitrary cross-origin artifact URLs**. Issue #489 still owns the reviewed artifact-origin allowlist and the remaining URL rules for returned links, including credential and fragment rejection. Until that later slice integrates, cross-origin artifact URLs retain the narrower predecessor behavior and must not be represented as a fully qualified production CDN/object-storage policy. + +## Verification contract + +Regression evidence covers: + +- `redirect: "error"` on submit, status, and artifact-link calls; +- hard request-budget signals and caller-signal composition; +- non-JSON media rejection; +- declared and streamed response-size overflow; +- cancellation-detail sanitization; +- document metadata/byte rejection before provider transport; +- oversized provider job identifiers before URL construction; +- valid streamed JSON compatibility for submission, status, HMAC, loopback-development, and artifact-link behavior; +- the predecessor configuration, HMAC, artifact-token-origin, sanitized-error, status-enum, and attachment-refresh contracts under the same normal unit/coverage paths. + +`server/clearfolio.mjs` remains an owned c8 production target. The new provider-boundary regression is registered in both `test:unit` and `test:coverage:cases`; exact statement, branch, function, and line evidence remains a merge gate rather than a documentation claim. + +## Security rationale + +OWASP API10:2023 identifies unsafe consumption of third-party APIs when applications trust integrated-service data, blindly follow redirects, fail to validate returned data, omit timeouts, or fail to limit resources used to process third-party responses. OWASP API4:2023 separately highlights unbounded memory, bandwidth, and execution-time consumption. The transport, timeout, media-type, streaming-byte, identifier, and document limits in this slice apply those controls at the provider boundary rather than relying on Clearfolio to behave correctly. + +The WHATWG Fetch Standard explicitly supports `redirect: "error"` to reject redirect responses. ScopeWeave uses that mode because tenant HMAC claims are provider-origin credentials and there is no reviewed redirect allowlist in the current protocol. + +Node.js 22 provides `AbortSignal.timeout()` and `AbortSignal.any()`, allowing the adapter to impose its own total request budget while preserving upstream cancellation without maintaining a second timer/cancellation protocol. + +## Rollback + +Rollback reverts the provider-boundary implementation, the new and adapted unit regressions, test registrations, deployment guidance, this doctoring record, and the CHANGELOG entry together. The slice adds no database schema or migration. Existing persisted attachment state remains readable by the predecessor implementation. + +## References + +Node.js contributors. (2026). *Global objects: AbortSignal*. Node.js documentation. Retrieved August 15, 2026, from https://nodejs.org/download/release/v22.18.0/docs/api/globals.html + +OWASP Foundation. (2023a). *API4:2023 unrestricted resource consumption*. OWASP API Security Top 10. https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/ + +OWASP Foundation. (2023b). *API10:2023 unsafe consumption of APIs*. OWASP API Security Top 10. https://owasp.org/API-Security/editions/2023/en/0xaa-unsafe-consumption-of-apis/ + +WHATWG. (2026). *Fetch Standard* (Living Standard, updated May 8, 2026). https://fetch.spec.whatwg.org/ From 892484b7e8b6d15d223a3100e3cad8a7761d1d68 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 04:32:52 +0900 Subject: [PATCH 07/35] docs(deploy): document Clearfolio transport limits --- docs/deploy.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/deploy.md b/docs/deploy.md index fba01ca1..f8673e41 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -61,6 +61,21 @@ Keep credentials in the dedicated HMAC secret setting rather than URL userinfo, and do not configure a path, query string, or fragment. The adapter constructs its own versioned API paths from the validated origin. +Every hosted Clearfolio request is non-redirecting and has a hard 15-second +adapter budget; attachment status lookups compose that budget with the caller's +own cancellation signal. Successful provider responses must be +`application/json`, and both declared and streamed response bytes are capped at +256 KiB before JSON parsing. Provider job identifiers are limited to 256 +characters, and upload metadata/bytes are validated before Blob/FormData +allocation. The adapter's document ceiling is 10 MiB, matching the current +ScopeWeave attachment API limit. + +These limits are code constants rather than operator-tunable environment +settings. A deployment that needs larger provider responses, longer requests, or +larger documents requires a reviewed application change with corresponding +resource, latency, and security evidence; do not work around these bounds at the +proxy layer. + ## Attachment status refresh operations The attachment-list API reads `job_id` in its initial project-scoped query and From a733a2f3c78a43b1fed178524067b25cfde76ff7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 04:33:32 +0900 Subject: [PATCH 08/35] docs(changelog): record bounded Clearfolio provider transport --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f035f675..fce05d63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 required a canonical signed production origin, rejected ambiguous provider URL components, and prevented cross-origin artifact tokens from being transplanted into the trusted Clearfolio viewer URL. +- Bounded hosted Clearfolio calls to non-redirecting 15-second requests and + 256 KiB streamed JSON responses, composed caller cancellation with the + provider budget, and validated document metadata/bytes and provider job IDs + before allocation, persistence, or URL construction. - Made `SCOPEWEAVE_JWT_SECRET` mandatory at startup and rejected weak or unexpanded placeholder values so production deployments fail closed. - Neutralized audit-log CSV formulas even when executable prefixes are hidden From a70ac7de4166c47a045a705224401a67f1fa9ae7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 09:13:57 +0900 Subject: [PATCH 09/35] test(clearfolio): preserve IPv6 loopback parent regression --- tests/unit/clearfolio-adapter-mock-hmac.test.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/unit/clearfolio-adapter-mock-hmac.test.mjs b/tests/unit/clearfolio-adapter-mock-hmac.test.mjs index bda2463c..eba5783e 100644 --- a/tests/unit/clearfolio-adapter-mock-hmac.test.mjs +++ b/tests/unit/clearfolio-adapter-mock-hmac.test.mjs @@ -101,6 +101,14 @@ test('production URL and HMAC configuration rejects ambiguous or unsafe input', }; try { assert.equal(await loopback.jobStatus(1, 2, 'job-1'), 'RUNNING'); + + process.env.CLEARFOLIO_URL = 'http://[::1]:8080'; + const ipv6Loopback = await freshModule('development-ipv6-loopback-http'); + assert.equal( + await ipv6Loopback.jobStatus(1, 2, 'job-1'), + 'RUNNING', + 'explicit development mode accepts the WHATWG-serialized IPv6 loopback origin', + ); } finally { globalThis.fetch = originalFetch; delete process.env.SCOPEWEAVE_DEV; From ad6ca38ade8da269747335373c02608d675ceafc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 09:16:27 +0900 Subject: [PATCH 10/35] fix(clearfolio): carry forward IPv6 loopback parent fix --- server/clearfolio.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index a11a4a97..c93be182 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -8,7 +8,8 @@ const CF_SECRET = String(process.env.CLEARFOLIO_HMAC_SECRET || ''); const PERMISSIONS = 'job:create,job:read,viewer:read,artifact-link:create'; const CLEARFOLIO_JOB_STATUSES = new Set(['PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED']); const MIN_HMAC_SECRET_LENGTH = 32; -const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']); +// WHATWG URL serializes an IPv6 hostname with brackets (`[::1]`). +const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024; const MAX_DOCUMENT_NAME_LENGTH = 512; const MAX_MIME_LENGTH = 255; From 3adca5293eaca4fdcb9c4ca99c5b41746d1bb4e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 16:43:22 +0900 Subject: [PATCH 11/35] test(clearfolio): require cancellation of rejected provider bodies --- .../clearfolio-provider-boundary.test.mjs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/unit/clearfolio-provider-boundary.test.mjs b/tests/unit/clearfolio-provider-boundary.test.mjs index cea06c64..256a0450 100644 --- a/tests/unit/clearfolio-provider-boundary.test.mjs +++ b/tests/unit/clearfolio-provider-boundary.test.mjs @@ -142,3 +142,49 @@ test('valid submit response remains compatible with the bounded transport', asyn assert.ok(options.signal instanceof AbortSignal); assert.ok(options.body instanceof FormData); }); + +test('non-success provider responses cancel unread bodies without parsing downstream payloads', async () => { + let cancelledBodies = 0; + const privatePayload = 'private downstream payload that must remain unread'; + responder = async () => new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(privatePayload)); + }, + cancel() { + cancelledBodies += 1; + }, + }), + { + status: 503, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }, + ); + + const operations = [ + { + run: () => submitJob(1, 2, { name: 'x.txt', mime: 'text/plain', bytes: Buffer.from('x') }), + expected: 'clearfolio submit failed (503)', + }, + { + run: () => jobStatus(1, 2, 'job-1'), + expected: 'clearfolio status failed (503)', + }, + { + run: () => artifactUrl(1, 2, 'job-1'), + expected: 'clearfolio artifact-link failed (503)', + }, + ]; + + for (const [index, operation] of operations.entries()) { + await assert.rejects( + operation.run, + (error) => { + assert.equal(error.message, operation.expected); + assert.doesNotMatch(error.message, /private downstream payload/); + return true; + }, + ); + assert.equal(cancelledBodies, index + 1, 'each rejected response body is explicitly cancelled'); + } +}); From 88fa42e6aa7fde328c28271487a179bdaad7cdb5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 16:45:10 +0900 Subject: [PATCH 12/35] test(clearfolio): cover bodyless and failed cancellation paths --- .../clearfolio-provider-boundary.test.mjs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/unit/clearfolio-provider-boundary.test.mjs b/tests/unit/clearfolio-provider-boundary.test.mjs index 256a0450..f8628094 100644 --- a/tests/unit/clearfolio-provider-boundary.test.mjs +++ b/tests/unit/clearfolio-provider-boundary.test.mjs @@ -187,4 +187,28 @@ test('non-success provider responses cancel unread bodies without parsing downst ); assert.equal(cancelledBodies, index + 1, 'each rejected response body is explicitly cancelled'); } + + responder = async () => new Response(null, { status: 503 }); + await assert.rejects( + () => jobStatus(1, 2, 'job-1'), + /clearfolio status failed \(503\)/, + ); + assert.equal(cancelledBodies, 3, 'a response without a body needs no cancellation'); + + responder = async () => new Response( + new ReadableStream({ + cancel() { + throw new Error('private cancel failure'); + }, + }), + { status: 503 }, + ); + await assert.rejects( + () => artifactUrl(1, 2, 'job-1'), + (error) => { + assert.equal(error.message, 'clearfolio artifact-link failed (503)'); + assert.doesNotMatch(error.message, /private cancel failure/); + return true; + }, + ); }); From 4f017140c1ab375e4f302bf2094a64e7b290d14b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 16:45:51 +0900 Subject: [PATCH 13/35] fix(clearfolio): cancel rejected provider response bodies --- server/clearfolio.mjs | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index c93be182..163fc161 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -257,6 +257,29 @@ function providerSignal(callerSignal) { return AbortSignal.any([callerSignal, timeoutSignal]); } +/** + * Cancel an unread provider response body before returning a fixed rejection. + * + * Undici-backed fetch responses must be consumed or cancelled so rejected + * downstream bodies cannot strand connection-pool resources. Cancellation + * failures are deliberately hidden because the operation-level error remains + * the authoritative, non-secret client and operator signal. + * + * @param {Response} response - Provider response whose payload must remain unread. + * @param {string} errorMessage - Fixed non-secret error to throw after cancellation. + * @returns {Promise} Promise that always rejects with the fixed error. + */ +async function rejectProviderResponse(response, errorMessage) { + try { + if (response?.body && typeof response.body.cancel === 'function') { + await response.body.cancel(); + } + } catch { + // The fixed operation-level rejection remains authoritative. + } + throw new Error(errorMessage); +} + /** * Parse one successful provider JSON response with media-type and byte bounds. * @@ -276,20 +299,20 @@ async function readBoundedJson(response, invalidMessage) { typeof contentType !== 'string' || contentType.split(';', 1)[0].trim().toLowerCase() !== 'application/json' ) { - throw new Error(invalidMessage); + return rejectProviderResponse(response, invalidMessage); } const contentLength = response.headers.get('content-length'); if (contentLength !== null) { - if (!/^\d+$/.test(contentLength)) throw new Error(invalidMessage); + if (!/^\d+$/.test(contentLength)) return rejectProviderResponse(response, invalidMessage); const declaredBytes = Number(contentLength); if (!Number.isSafeInteger(declaredBytes) || declaredBytes > CLEARFOLIO_MAX_RESPONSE_BYTES) { - throw new Error(invalidMessage); + return rejectProviderResponse(response, invalidMessage); } } if (!response.body || typeof response.body.getReader !== 'function') { - throw new Error(invalidMessage); + return rejectProviderResponse(response, invalidMessage); } const reader = response.body.getReader(); @@ -385,7 +408,7 @@ export async function submitJob(orgId, userId, document) { } catch { throw new Error('clearfolio submit unavailable'); } - if (!res.ok) throw new Error(`clearfolio submit failed (${res.status})`); + if (!res.ok) return rejectProviderResponse(res, `clearfolio submit failed (${res.status})`); const data = await readBoundedJson(res, 'clearfolio submit response invalid'); if (!isJsonRecord(data)) throw new Error('clearfolio submit response invalid'); const status = data.status === undefined ? 'PENDING' : data.status; @@ -430,7 +453,7 @@ export async function jobStatus(orgId, userId, jobId, { signal } = {}) { } catch { throw new Error('clearfolio status unavailable'); } - if (!res.ok) throw new Error(`clearfolio status failed (${res.status})`); + if (!res.ok) return rejectProviderResponse(res, `clearfolio status failed (${res.status})`); const data = await readBoundedJson(res, 'clearfolio status response invalid'); if (!isJsonRecord(data) || !isClearfolioJobStatus(data.status)) { throw new Error('clearfolio status response invalid'); @@ -466,7 +489,7 @@ export async function artifactUrl(orgId, userId, jobId) { } catch { throw new Error('clearfolio artifact-link unavailable'); } - if (!res.ok) throw new Error(`clearfolio artifact-link failed (${res.status})`); + if (!res.ok) return rejectProviderResponse(res, `clearfolio artifact-link failed (${res.status})`); const data = await readBoundedJson(res, 'clearfolio artifact-link response invalid'); if (!isJsonRecord(data)) throw new Error('clearfolio artifact-link response invalid'); const link = data.artifactUrl || data.url || data.signedUrl; From b0e2df87a2d38ab75ad890906580c7d24802089d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 15:18:03 +0900 Subject: [PATCH 14/35] test(clearfolio): retain parent cross-origin token rejection --- tests/unit/clearfolio-status-signal.test.mjs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/unit/clearfolio-status-signal.test.mjs b/tests/unit/clearfolio-status-signal.test.mjs index 9ea164cb..b5203df9 100644 --- a/tests/unit/clearfolio-status-signal.test.mjs +++ b/tests/unit/clearfolio-status-signal.test.mjs @@ -255,10 +255,9 @@ test('artifactUrl validates links and never exposes transport or response text', signedUrl: 'https://cdn.example/file.pdf?artifactToken=token%20value', }), }); - assert.equal( - await artifactUrl(4, 5, 'job-1'), - 'https://cdn.example/file.pdf?artifactToken=token%20value', - 'a token from another origin is never transplanted into the trusted Clearfolio viewer', + await expectSanitizedFailure( + () => artifactUrl(4, 5, 'job-1'), + 'clearfolio artifact-link response invalid', ); }); From c7f0ddda356e46cac762cd23d686c84505b39a07 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 15:44:26 +0900 Subject: [PATCH 15/35] fix(clearfolio): reject cross-origin artifact tokens --- server/clearfolio.mjs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index 163fc161..2591b44c 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -464,9 +464,10 @@ export async function jobStatus(orgId, userId, jobId, { signal } = {}) { /** * Issue a viewable artifact URL for a completed Clearfolio job. * - * Same-origin `artifactToken` values may be translated into the local viewer - * route. A token returned on another origin remains bound to that origin and is - * never transplanted into the trusted Clearfolio viewer URL. + * Same-origin `artifactToken` values may be translated into the trusted viewer + * route. Token-bearing links from another origin are rejected until an explicit + * reviewed artifact-origin allowlist exists; tokens are never transplanted or + * returned to an unreviewed cross-origin host. * * @param {string|number} orgId - ScopeWeave organization identifier. * @param {string|number} userId - Requesting ScopeWeave user identifier. @@ -511,7 +512,10 @@ export async function artifactUrl(orgId, userId, jobId) { } const token = url.searchParams.get('artifactToken'); - if (token && url.origin === clearfolioUrl.origin) { + if (token) { + if (url.origin !== clearfolioUrl.origin) { + throw new Error('clearfolio artifact-link response invalid'); + } return `${configuration.baseUrl}/viewer/${encodeURIComponent(canonicalJobId)}?artifactToken=${encodeURIComponent(token)}`; } return url.href; From 6d7774dc61876822deb786687324bff143704106 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:48:11 +0900 Subject: [PATCH 16/35] docs(deploy): inherit orchestrator fail-closed contract --- docs/deploy.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/deploy.md b/docs/deploy.md index f8673e41..cd08ca9a 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -34,11 +34,11 @@ persists the database in the `scopeweave-data` volume. | `SCOPEWEAVE_JWT_SECRET` | **yes** | Signs session JWTs. Startup fails unless it contains at least 32 non-whitespace characters. | | `PORT` | no (default 8787) | Listen port | | `SCOPEWEAVE_DB` | no (default `/data/scopeweave.db`) | SQLite file path (on the volume) | -| `SCOPEWEAVE_DEV` | no | Must be `1` to enable development-only behavior, including `activate-pro`, loopback Clearfolio HTTP, and the in-memory Clearfolio adapter when no provider URL exists. **Never set in production.** | +| `SCOPEWEAVE_DEV` | no | Must be `1` to enable development-only behavior, including `activate-pro`, deterministic orchestrator responses, loopback Clearfolio HTTP, and the in-memory Clearfolio adapter when no provider URL exists. **Never set in production.** | | `STRIPE_SECRET_KEY`, `STRIPE_PRICE_ID`, `STRIPE_WEBHOOK_SECRET` | for live billing | Enables real Stripe Checkout (`npm i stripe` too). Without them, billing uses the mock path. | | `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI` | for real SSO | Points the OIDC login at your IdP. Unset → a built-in mock IdP (dev/test only). | -| `ORCHESTRATOR_URL` | for AI 브리핑 | contextual-orchestrator 주소. Unset → deterministic mock. | -| `ORCHESTRATOR_TOKEN` | with URL | orchestrator Bearer 토큰 (`CONTEXTUAL_ORCHESTRATOR_TOKEN`). | +| `ORCHESTRATOR_URL` | for production AI briefing | Root contextual-orchestrator service origin. Production briefing fails closed when it is absent; deterministic responses exist only with `SCOPEWEAVE_DEV=1`. | +| `ORCHESTRATOR_TOKEN` | with URL | Required bearer token for the configured contextual-orchestrator service (`CONTEXTUAL_ORCHESTRATOR_TOKEN`). | | `CLEARFOLIO_URL` | for production 산출물 viewer | Root Clearfolio service origin. Production requires HTTPS and rejects credentials, paths, query strings, and fragments. When absent in production, document conversion/viewing is unavailable rather than simulated. | | `CLEARFOLIO_HMAC_SECRET` | with URL | Required tenant-claim HMAC secret; must contain at least 32 non-whitespace characters and match Clearfolio's configured verifier secret. | | `SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY` | no (default 8, maximum 32) | Maximum concurrent Clearfolio status lookups during one attachment-list request. Invalid values fall back to 8; values above 32 are clamped. | From 036f916bc8a68f196c39576bed002aafeda5c400 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:49:50 +0900 Subject: [PATCH 17/35] fix(clearfolio): preserve current parent tree during stack reconciliation --- cloud-sync.js | 57 +++++++++++------ .../ms-project-xml-import-boundary.md | 63 +++++++++++++++++++ docs/security.md | 2 +- tests/unit/msproject.test.mjs | 49 +++++++++++++++ 4 files changed, 152 insertions(+), 19 deletions(-) create mode 100644 docs/doctoring/ms-project-xml-import-boundary.md diff --git a/cloud-sync.js b/cloud-sync.js index 9016cfbf..0e015ebe 100644 --- a/cloud-sync.js +++ b/cloud-sync.js @@ -741,33 +741,54 @@ function openReportModal() { export function parseMsProjectXml(xml) { // Fully linear extract (indexOf/slice) — no dynamic RegExp and no lazy // [\s\S]*? block collectors (those can quadratic-backtrack on truncated input). + const isXmlWhitespace = (charCode) => ( + charCode === 0x20 || charCode === 0x09 || charCode === 0x0d || charCode === 0x0a + ); + const findTagBoundary = (source, name, from, closing = false) => { + const prefix = `<${closing ? '/' : ''}${name}`; + let searchFrom = from; + for (;;) { + const start = source.indexOf(prefix, searchFrom); + if (start === -1) return null; + let delimiter = start + prefix.length; + while (delimiter < source.length && isXmlWhitespace(source.charCodeAt(delimiter))) { + delimiter += 1; + } + if (source.charCodeAt(delimiter) === 0x3e) { + return { start, end: delimiter + 1 }; + } + // Reject attributes, longer names, and non-XML whitespace while advancing + // past every inspected byte so malformed candidates are never rescanned. + searchFrom = Math.max(delimiter + 1, start + prefix.length); + } + }; const tag = (block, name) => { - const openingTag = `<${name}>`; - const closingTag = ``; - const valueStart = block.indexOf(openingTag); - if (valueStart === -1) return ''; - const contentStart = valueStart + openingTag.length; - const valueEnd = block.indexOf(closingTag, contentStart); - return valueEnd === -1 ? '' : block.slice(contentStart, valueEnd).trim(); + const opening = findTagBoundary(block, name, 0); + if (!opening) return ''; + const closing = findTagBoundary(block, name, opening.end, true); + const nextOpening = findTagBoundary(block, name, opening.end); + if (!closing || (nextOpening && nextOpening.start < closing.start)) return ''; + return block.slice(opening.end, closing.start).trim(); }; - const collectBlocks = (source, openTag, closeTag) => { + const collectBlocks = (source, name) => { const out = []; let from = 0; for (;;) { - const start = source.indexOf(openTag, from); - if (start === -1) break; - const contentStart = start + openTag.length; - const end = source.indexOf(closeTag, contentStart); - // Incomplete open tag: stop linearly (do not rescan the remainder). - if (end === -1) break; - out.push(source.slice(start, end + closeTag.length)); - from = end + closeTag.length; + const opening = findTagBoundary(source, name, from); + if (!opening) break; + const closing = findTagBoundary(source, name, opening.end, true); + const nextOpening = findTagBoundary(source, name, opening.end); + // Incomplete or nested same-name block: stop at the first unmatched + // opening tag instead of pairing it with a later block's closing tag. + if (!closing || (nextOpening && nextOpening.start < closing.start)) break; + out.push(source.slice(opening.start, closing.end)); + from = closing.end; } return out; }; const predecessorIds = (block) => { const ids = []; - for (const link of collectBlocks(block, '', '')) { + for (const link of collectBlocks(block, 'PredecessorLink')) { const uid = tag(link, 'PredecessorUID'); if (/^\d+$/.test(uid)) ids.push(`msp-${uid}`); } @@ -779,7 +800,7 @@ export function parseMsProjectXml(xml) { const day = (s) => (/^\d{4}-\d{2}-\d{2}/.test(s) ? s.slice(0, 10) : ''); const tasks = []; const parents = {}; // depth -> last task id at that depth - const blocks = collectBlocks(String(xml || ''), '', ''); + const blocks = collectBlocks(String(xml || ''), 'Task'); for (const block of blocks) { const uid = tag(block, 'UID'); const name = unescape(tag(block, 'Name')); diff --git a/docs/doctoring/ms-project-xml-import-boundary.md b/docs/doctoring/ms-project-xml-import-boundary.md new file mode 100644 index 00000000..0a8fa5aa --- /dev/null +++ b/docs/doctoring/ms-project-xml-import-boundary.md @@ -0,0 +1,63 @@ +# Microsoft Project XML delimiter boundary + +## Decision + +ScopeWeave's Microsoft Project import profile accepts XML whitespace between an +exact supported element name and the closing `>` delimiter. The accepted code +points are: + +- U+0020 SPACE; +- U+0009 CHARACTER TABULATION; +- U+000D CARRIAGE RETURN; and +- U+000A LINE FEED. + +The parser deliberately does not become a general XML processor. It recognizes +only the exact `Task`, `PredecessorLink`, and scalar element names already used +by the import adapter. Attributes, namespace prefixes, longer lookalike names, +non-XML whitespace, self-closing forms, nested same-name blocks, and truncated +blocks are rejected or yield no value under this narrow profile. + +## Security and complexity boundary + +The scanner remains monotonic and regex-free. It advances through every rejected +candidate and uses bounded `indexOf()` and `slice()` operations rather than +constructing dynamic regular expressions or lazy whole-document block matches. +This preserves the existing denial-of-service boundary for malformed or +adversarial uploads. + +An unmatched outer element cannot consume a later nested element's closing tag. +If another same-name opening appears before the candidate closing tag, block +collection stops at the unmatched outer element instead of silently producing a +mis-parented task. + +## Executable evidence + +`tests/unit/msproject.test.mjs` covers: + +- space, tab, carriage-return, and line-feed delimiters; +- scalar and predecessor-link elements using each allowed delimiter; +- an actual U+000B vertical tab, which is not XML whitespace; +- attributes and longer element names; +- truncated and repeated unclosed task blocks; +- nested same-name openings before a closing element; and +- the existing valid import and predecessor contracts. + +The test is already part of the full unit and coverage command paths. No package +or lockfile change is required. + +## Compatibility and rollback + +The change broadens acceptance only for documents that are conformant with the +XML whitespace production at the delimiter positions used by this adapter. +Existing byte-exact exports retain the same task identifiers, names, dates, +parents, progress, and predecessor values. + +Rollback must revert the scanner, focused tests, security documentation, +CHANGELOG entry, and this record together. Reintroducing byte-exact delimiters +would again reject standards-compliant Microsoft Project exports that contain +formatting whitespace before `>`. + +## Reference + +World Wide Web Consortium. (2008). *Extensible Markup Language (XML) 1.0 +(Fifth Edition)*. https://www.w3.org/TR/2008/REC-xml-20081126/ diff --git a/docs/security.md b/docs/security.md index 5b21a5e6..0ceee972 100644 --- a/docs/security.md +++ b/docs/security.md @@ -19,7 +19,7 @@ Every user-controlled CSV cell is neutralized when, after optional leading white ## XML imports -Microsoft Project XML extraction uses bounded `indexOf`/`slice` loops. Dynamic regular expressions and lazy whole-document block collectors are prohibited because truncated or adversarial input can cause catastrophic backtracking. +Microsoft Project XML extraction uses bounded `indexOf`/`slice` loops. Opening and closing `Task`, `PredecessorLink`, and scalar tags accept only XML whitespace (space, tab, carriage return, or line feed) between the exact element name and `>`. Attributes, longer names, and other whitespace code points are not accepted by this deliberately narrow import profile. Dynamic regular expressions and lazy whole-document block collectors are prohibited because truncated or adversarial input can cause catastrophic backtracking. ## Release verification diff --git a/tests/unit/msproject.test.mjs b/tests/unit/msproject.test.mjs index d829a32c..284cb51d 100644 --- a/tests/unit/msproject.test.mjs +++ b/tests/unit/msproject.test.mjs @@ -72,4 +72,53 @@ assert.deepEqual( const incompleteOpens = `${'9open'.repeat(5000)}`; assert.deepEqual(parseMsProjectXml(incompleteOpens), [], 'unclosed Task blocks yield no tasks'); +assert.deepEqual( + parseMsProjectXml( + '11unclosed outer12nested', + ), + [], + 'an unmatched outer Task cannot consume a nested Task closing tag', +); + +const whitespaceTags = parseMsProjectXml(` + + 8 + Whitespace-compatible task + 1 + 2026-08-11T09:00:00 + 2026-08-12T17:00:00 + + 2 + +`); +assert.equal(whitespaceTags.length, 1, 'XML whitespace before tag delimiters is accepted'); +assert.equal(whitespaceTags[0].id, 'msp-8'); +assert.equal(whitespaceTags[0].phase, 'Whitespace-compatible task'); +assert.equal(whitespaceTags[0].plannedStartDate, '2026-08-11'); +assert.equal(whitespaceTags[0].plannedEndDate, '2026-08-12'); +assert.equal(whitespaceTags[0].predecessors, 'msp-2', 'block and scalar tags share the scanner'); + +assert.deepEqual( + parseMsProjectXml('9wrong'), + [], + 'TaskX must not match Task', +); +assert.deepEqual( + parseMsProjectXml('9wrong whitespace'), + [], + 'non-XML whitespace before a delimiter is rejected', +); +assert.deepEqual( + parseMsProjectXml('10truncated'), + [], + 'truncated whitespace-delimited Task stops safely', +); +assert.deepEqual( + parseMsProjectXml( + '13outerinner1', + ), + [], + 'a nested scalar opening cannot consume the inner closing delimiter', +); + console.log('✓ MS Project import tests passed'); From d4b09cddb61f524efaa6d93e12a006f6a51afd3c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:50:39 +0900 Subject: [PATCH 18/35] fix(changelog): preserve parent XML import entry --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69805c45..ed87f4be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Accepted XML whitespace before exact Microsoft Project element delimiters + while preserving the linear, regex-free import scanner and rejecting + attributes, longer names, non-XML whitespace, nested unmatched blocks, and + truncated input. - Attachment-list status refresh now removes the per-row database lookup, uses a configurable bounded worker pool with per-item abortable timeouts and a request-wide latency budget, preserves stale status after downstream, From af0af30d3cb0c53dca3a69d8b49879796647ba9a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 07:25:16 +0900 Subject: [PATCH 19/35] test(clearfolio): lock artifact redirect authority --- .../clearfolio-provider-boundary.test.mjs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/unit/clearfolio-provider-boundary.test.mjs b/tests/unit/clearfolio-provider-boundary.test.mjs index f8628094..6797d53d 100644 --- a/tests/unit/clearfolio-provider-boundary.test.mjs +++ b/tests/unit/clearfolio-provider-boundary.test.mjs @@ -131,6 +131,27 @@ test('provider job identifiers are bounded before URL construction', async () => assert.equal(calls.length, before, 'oversized job identifiers never reach provider transport'); }); +test('artifact redirects remain bound to the configured provider origin', async () => { + for (const artifactUrlValue of [ + 'https://cdn.example/file.pdf', + 'https://user:pass@clearfolio.example/file.pdf', + 'https://clearfolio.example/file.pdf#private-fragment', + ]) { + useResponse({ artifactUrl: artifactUrlValue }); + await assert.rejects( + () => artifactUrl(1, 2, 'job-1'), + /clearfolio artifact-link response invalid/, + `${artifactUrlValue} must not become browser redirect authority`, + ); + } + + useResponse({ artifactUrl: 'https://clearfolio.example/file.pdf' }); + assert.equal( + await artifactUrl(1, 2, 'job-1'), + 'https://clearfolio.example/file.pdf', + ); +}); + test('valid submit response remains compatible with the bounded transport', async () => { useResponse({ jobId: ' job-2 ', status: 'PENDING' }); assert.deepEqual( From e6353be1019504de9b79be99013eba1befafd2c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 07:26:20 +0900 Subject: [PATCH 20/35] fix(clearfolio): preserve artifact redirect authority --- server/clearfolio.mjs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index 2591b44c..2f64c549 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -464,10 +464,12 @@ export async function jobStatus(orgId, userId, jobId, { signal } = {}) { /** * Issue a viewable artifact URL for a completed Clearfolio job. * + * All browser redirect authority remains bound to the configured Clearfolio + * origin in this transport slice. Cross-origin artifact hosts stay fail-closed + * until the separately reviewed artifact-origin allowlist lands. Credentials + * and fragments are rejected for both token-bearing and tokenless links. * Same-origin `artifactToken` values may be translated into the trusted viewer - * route. Token-bearing links from another origin are rejected until an explicit - * reviewed artifact-origin allowlist exists; tokens are never transplanted or - * returned to an unreviewed cross-origin host. + * route without exposing the token to an unreviewed host. * * @param {string|number} orgId - ScopeWeave organization identifier. * @param {string|number} userId - Requesting ScopeWeave user identifier. @@ -510,12 +512,17 @@ export async function artifactUrl(orgId, userId, jobId) { if (url.protocol !== 'https:' && !allowsHttp) { throw new Error('clearfolio artifact-link response invalid'); } + if ( + url.origin !== clearfolioUrl.origin + || url.username + || url.password + || url.hash + ) { + throw new Error('clearfolio artifact-link response invalid'); + } const token = url.searchParams.get('artifactToken'); if (token) { - if (url.origin !== clearfolioUrl.origin) { - throw new Error('clearfolio artifact-link response invalid'); - } return `${configuration.baseUrl}/viewer/${encodeURIComponent(canonicalJobId)}?artifactToken=${encodeURIComponent(token)}`; } return url.href; From 9bdfe5c8406c80018e154a1988402f7a8c760388 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 23:27:43 +0900 Subject: [PATCH 21/35] docs(clearfolio): preserve parent production authority --- .../clearfolio-production-configuration.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/doctoring/clearfolio-production-configuration.md b/docs/doctoring/clearfolio-production-configuration.md index 2f1b1501..764a9f7e 100644 --- a/docs/doctoring/clearfolio-production-configuration.md +++ b/docs/doctoring/clearfolio-production-configuration.md @@ -8,11 +8,13 @@ A configured production provider must be a root HTTPS origin. ScopeWeave parses This boundary prevents configuration text from becoming an arbitrary downstream request prefix and prevents a production deployment from persisting fake `SUCCEEDED` conversion state merely because an integration is absent. It also preserves independent ScopeWeave operation: planning functionality remains available while document conversion/viewing fails closed with an actionable configuration error. -## Artifact-token origin rule +## Provider redirect and artifact-origin rule -If Clearfolio returns an `artifactToken`, ScopeWeave rewrites it into the trusted Clearfolio viewer route only when the returned URL has the same origin as the configured Clearfolio service. A token-bearing link from another origin is rejected rather than transplanted into the trusted viewer or returned directly to an unreviewed host. This closes the token-confusion boundary without claiming that arbitrary cross-origin artifact hosts are approved. +Every tenant-signed submit, status, and artifact-link fetch uses `redirect: "error"`. A provider redirect therefore becomes the existing sanitized transport failure instead of allowing the runtime to replay tenant HMAC headers onto an untrusted `Location` target. -Issue #489 remains open after this slice. A subsequent bounded change must still implement the explicit reviewed artifact-origin allowlist, redirect policy, streaming response-size/media-type limits, provider-wide request budget, and the remaining resource/lifecycle acceptance criteria before the Clearfolio adapter can be described as fully production-complete. +Artifact links returned by this root configuration slice must resolve to the configured Clearfolio origin, must contain no URL credentials, and must contain no fragment. Protocol-relative or absolute foreign-host links fail closed, including token-free links that would otherwise become the browser's attachment-view redirect target. If a same-origin link contains an `artifactToken`, ScopeWeave rewrites that token into the trusted Clearfolio viewer route. A token is never transplanted into another origin. + +This root slice deliberately does not invent a cross-origin artifact-host allowlist. A later reviewed policy may admit explicitly configured canonical origins, but until that policy is present the secure default is same-origin only. Issue #489 remains open after this slice. Subsequent bounded work must still add the reviewed artifact-origin allowlist if cross-origin delivery is required, streaming response-size/media-type limits, a provider-wide request budget, and the remaining resource/lifecycle acceptance criteria before the Clearfolio adapter can be described as fully production-complete. ## Executable evidence @@ -24,7 +26,7 @@ Issue #489 remains open after this slice. A subsequent bounded change must still - loopback HTTP is accepted only under explicit development mode; and - signed tenant headers retain the documented canonical HMAC contract. -`tests/unit/clearfolio-status-signal.test.mjs` continues to exercise sanitized transport/HTTP/JSON/status/artifact failures and now proves that a cross-origin token-bearing artifact link fails closed rather than moving the token into the Clearfolio viewer or returning it to an unreviewed host. `tests/api/attachment-status.test.mjs` makes its test-only in-memory provider explicit instead of relying on an unset production URL. +`tests/unit/clearfolio-status-signal.test.mjs` exercises sanitized transport/HTTP/JSON/status/artifact failures and now requires all three tenant-signed fetch paths to disable redirects. It rejects token-free CDN links, protocol-relative foreign links, credential-bearing same-origin links, fragmented same-origin links, and cross-origin token-bearing links while retaining same-origin relative links and the trusted viewer rewrite. `tests/api/attachment-status.test.mjs` makes its test-only in-memory provider explicit instead of relying on an unset production URL. The shipped `server/clearfolio.mjs` remains in the canonical c8 production coverage target, so the new configuration branches execute under the repository coverage gate rather than a documentation-only path. @@ -32,13 +34,13 @@ The shipped `server/clearfolio.mjs` remains in the canonical c8 production cover The WHATWG URL Standard defines URL components, including credentials, queries, and fragments, and provides the common parsing model used by the JavaScript `URL` API. ScopeWeave parses first and then applies component-level policy instead of relying on string-prefix validation. -OWASP's SSRF Prevention guidance recommends strict allowlisting and warns that redirects and attacker-controlled complete URLs can bypass URL validation. This slice narrows operator configuration to a provider origin and keeps request paths adapter-owned. The remaining redirect and artifact-host controls stay explicitly tracked by issue #489 rather than being implied by this narrower change. +OWASP's SSRF Prevention guidance recommends strict allowlisting and warns that redirects and attacker-controlled complete URLs can bypass URL validation. This slice narrows operator configuration to a provider origin, disables redirect following for tenant-signed calls, and keeps browser redirect authority same-origin until an explicit reviewed allowlist exists. NIST SSDF 1.1 recommends identifying and maintaining software security requirements and producing well-secured software through repeatable verification. The fail-closed configuration contract, executable negative tests, and explicit remaining-gap statement provide acquisition-review evidence without claiming certification. ## Rollback -Rollback reverts the Clearfolio configuration parser, explicit development-mode tests, token-origin rule, deployment text, this doctoring record, and the corresponding CHANGELOG entry together. No database schema or persisted attachment representation changes in this slice. +Rollback reverts the Clearfolio configuration parser, explicit development-mode tests, redirect prohibition, same-origin artifact rule, deployment text, this doctoring record, and the corresponding CHANGELOG entry together. No database schema or persisted attachment representation changes in this slice. ## References @@ -46,4 +48,4 @@ National Institute of Standards and Technology. (2022). *Secure Software Develop OWASP Foundation. (n.d.). *Server Side Request Forgery Prevention Cheat Sheet*. OWASP Cheat Sheet Series. https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html -WHATWG. (2026). *URL Standard*. https://url.spec.whatwg.org/ +WHATWG. (2026). *URL Standard*. https://url.spec.whatwg.org/ \ No newline at end of file From ae0ffb53f10428f54369e563ed2a5984c1e821a4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 23:31:55 +0900 Subject: [PATCH 22/35] test(clearfolio): preserve parent artifact-origin contract --- tests/unit/clearfolio-status-signal.test.mjs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/unit/clearfolio-status-signal.test.mjs b/tests/unit/clearfolio-status-signal.test.mjs index b5203df9..13531d5c 100644 --- a/tests/unit/clearfolio-status-signal.test.mjs +++ b/tests/unit/clearfolio-status-signal.test.mjs @@ -218,6 +218,10 @@ test('artifactUrl validates links and never exposes transport or response text', { label: 'malformed URL', json: async () => ({ artifactUrl: 'http://[' }) }, { label: 'unsupported URL scheme', json: async () => ({ artifactUrl: 'javascript:alert(1)' }) }, { label: 'HTTPS downgrade', json: async () => ({ artifactUrl: 'http://cdn.example/file.pdf' }) }, + { label: 'foreign HTTPS origin', json: async () => ({ artifactUrl: 'https://cdn.example/file.pdf' }) }, + { label: 'protocol-relative foreign origin', json: async () => ({ artifactUrl: '//evil.example/file.pdf' }) }, + { label: 'credentialed same origin', json: async () => ({ artifactUrl: 'https://user@clearfolio.example/file.pdf' }) }, + { label: 'fragmented same origin', json: async () => ({ artifactUrl: 'https://clearfolio.example/file.pdf#viewer-state' }) }, ]; for (const malformed of malformedPayloads) { @@ -244,12 +248,6 @@ test('artifactUrl validates links and never exposes transport or response text', 'same-origin artifact tokens may be translated into the trusted viewer route', ); - setResponse({ json: async () => ({ url: 'https://cdn.example/file.pdf' }) }); - assert.equal( - await artifactUrl(4, 5, 'job-1'), - 'https://cdn.example/file.pdf', - ); - setResponse({ json: async () => ({ signedUrl: 'https://cdn.example/file.pdf?artifactToken=token%20value', From 5fa795210b0060f3034466c03f99fb013e838d13 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:54:48 +0900 Subject: [PATCH 23/35] fix(clearfolio): preserve current parent tree during stack reconciliation --- AGENTS.md | 6 +++ CHANGELOG.md | 5 ++- opencode.jsonc | 35 +++++++++--------- package.json | 2 +- tests/unit/changelog-release-notes.test.mjs | 12 ++++++ tests/unit/opencode-config.test.mjs | 41 +++++++++++++++++++++ 6 files changed, 81 insertions(+), 20 deletions(-) create mode 100644 tests/unit/changelog-release-notes.test.mjs create mode 100644 tests/unit/opencode-config.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 7a5b65ac..d1613bb0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,12 @@ - OpenCode Review, Strix Security Scan, and PR Review Merge Scheduler are organization-level required workflows from `ContextualWisdomLab/.github`. Do not copy them into this repository. +- ScopeWeave's repository-local `opencode.jsonc` is development configuration, + not a replacement for the organization review workflow. It uses NVIDIA NIM + only and reads the process-local `NVIDIA_API_KEY` binding. Organization CI + owns secret injection and maps the organization `NVIDIA_NIM_API_KEY` secret + into that process binding; do not add a repository-local OpenCode workflow or + restore GitHub Models/COPILOT credentials to this configuration. - Keep companion SCA workflows development-only; do not add runtime dependencies. - If GitHub CLI output emits Projects(classic) deprecation warnings, diff --git a/CHANGELOG.md b/CHANGELOG.md index bd6aec24..48e4c4e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Switched the repository-local OpenCode development configuration from GitHub + Models to an NVIDIA NIM-only candidate set while preserving organization-level + review-workflow ownership in `ContextualWisdomLab/.github`. - Accepted XML whitespace before exact Microsoft Project element delimiters while preserving the linear, regex-free import scanner and rejecting attributes, longer names, non-XML whitespace, nested unmatched blocks, and @@ -108,4 +111,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.1] - 2026-06-25 ### 성능 개선 (Performance) -- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. +- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하던 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. diff --git a/opencode.jsonc b/opencode.jsonc index a5ab3396..a3cb617b 100644 --- a/opencode.jsonc +++ b/opencode.jsonc @@ -1,8 +1,8 @@ { "$schema": "https://opencode.ai/config.json", - "model": "github-models/openai/gpt-5", - "small_model": "github-models/deepseek/deepseek-v3-0324", - "enabled_providers": ["github-models"], + "model": "nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5", + "small_model": "nvidia-nim/meta/llama-3.1-8b-instruct", + "enabled_providers": ["nvidia-nim"], "mcp": { "codegraph": { "type": "local", @@ -37,37 +37,36 @@ } }, "provider": { - "github-models": { + "nvidia-nim": { "npm": "@ai-sdk/openai-compatible", - "name": "GitHub Models", + "name": "NVIDIA NIM", "options": { - "baseURL": "https://models.github.ai/inference", - "apiKey": "{env:STRIX_GITHUB_MODELS_TOKEN}" + "baseURL": "https://integrate.api.nvidia.com/v1", + "apiKey": "{env:NVIDIA_API_KEY}" }, "models": { - "openai/gpt-5": { - "name": "OpenAI GPT-5", + "nvidia/llama-3.3-nemotron-super-49b-v1.5": { + "name": "NVIDIA Llama 3.3 Nemotron Super 49B v1.5", "tool_call": true, "reasoning": true, "limit": { - "context": 200000, - "output": 100000 + "context": 131072, + "output": 65536 } }, - "deepseek/deepseek-r1-0528": { - "name": "DeepSeek R1 0528", + "meta/llama-3.1-8b-instruct": { + "name": "Meta Llama 3.1 8B Instruct (NIM)", "tool_call": true, - "reasoning": true, "limit": { - "context": 128000, + "context": 131072, "output": 4096 } }, - "deepseek/deepseek-v3-0324": { - "name": "DeepSeek V3 0324", + "meta/llama-3.3-70b-instruct": { + "name": "Meta Llama 3.3 70B Instruct (NIM)", "tool_call": true, "limit": { - "context": 128000, + "context": 131072, "output": 4096 } } diff --git a/package.json b/package.json index 0dfe5609..26a68345 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs", - "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/clearfolio-provider-boundary.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/clearfolio-provider-boundary.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/clearfolio-provider-boundary.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", diff --git a/tests/unit/changelog-release-notes.test.mjs b/tests/unit/changelog-release-notes.test.mjs new file mode 100644 index 00000000..d886f3e1 --- /dev/null +++ b/tests/unit/changelog-release-notes.test.mjs @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const changelog = readFileSync(new URL('../../CHANGELOG.md', import.meta.url), 'utf8'); + +test('released changelog versions keep their published notes', () => { + assert.match(changelog, /## \[1\.0\.0\] - 2026-04-20/); + assert.match(changelog, /Initial ScopeWeave Planner release with tree-table editing/); + assert.match(changelog, /## \[1\.0\.1\] - 2026-06-25/); + assert.match(changelog, /O\(1\) 해시맵\(Map\) 기반의 캐싱 조회 로직/); +}); diff --git a/tests/unit/opencode-config.test.mjs b/tests/unit/opencode-config.test.mjs new file mode 100644 index 00000000..f95ff46f --- /dev/null +++ b/tests/unit/opencode-config.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const config = JSON.parse(readFileSync(new URL('../../opencode.jsonc', import.meta.url), 'utf8')); +const agents = readFileSync(new URL('../../AGENTS.md', import.meta.url), 'utf8'); + +test('OpenCode development config uses only currently hosted NVIDIA NIM candidates', () => { + assert.equal(config.model, 'nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5'); + assert.equal(config.small_model, 'nvidia-nim/meta/llama-3.1-8b-instruct'); + assert.deepEqual(config.enabled_providers, ['nvidia-nim']); + + const provider = config.provider?.['nvidia-nim']; + assert.ok(provider, 'NVIDIA NIM provider must be configured'); + assert.equal(provider.options?.baseURL, 'https://integrate.api.nvidia.com/v1'); + assert.equal(provider.options?.apiKey, '{env:NVIDIA_API_KEY}'); + assert.ok(provider.models?.['nvidia/llama-3.3-nemotron-super-49b-v1.5']); + assert.ok(provider.models?.['meta/llama-3.1-8b-instruct']); + assert.ok(provider.models?.['meta/llama-3.3-70b-instruct']); + assert.equal( + provider.models['meta/llama-3.3-70b-instruct'].limit?.output, + 4096, + '70B output must stay within the NVIDIA NIM max_tokens range of 1-4096', + ); + + const serialized = JSON.stringify(config); + assert.doesNotMatch(serialized, /github-models/i); + assert.doesNotMatch(serialized, /STRIX_GITHUB_MODELS_TOKEN/); + assert.doesNotMatch(serialized, /COPILOT_GITHUB_TOKEN/); +}); + +test('AGENTS.md keeps the local NVIDIA_API_KEY binding separate from the org NIM secret', () => { + assert.match(agents, /NVIDIA_API_KEY/); + assert.match(agents, /NVIDIA_NIM_API_KEY/); + assert.match( + agents, + /maps the organization `NVIDIA_NIM_API_KEY` secret/, + 'org CI must map NVIDIA_NIM_API_KEY into the process-local NVIDIA_API_KEY binding', + ); + assert.doesNotMatch(agents, /COPILOT_GITHUB_TOKEN/); +}); From 0e9da6e7291a70e4373778433cdd3219225e5dc9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 06:57:17 -0700 Subject: [PATCH 24/35] test(clearfolio): require provider timeout disposal --- .../clearfolio-provider-boundary.test.mjs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/unit/clearfolio-provider-boundary.test.mjs b/tests/unit/clearfolio-provider-boundary.test.mjs index 6797d53d..0f153815 100644 --- a/tests/unit/clearfolio-provider-boundary.test.mjs +++ b/tests/unit/clearfolio-provider-boundary.test.mjs @@ -54,6 +54,35 @@ test('provider requests disable redirects and carry a bounded total-request sign assert.equal(CLEARFOLIO_REQUEST_TIMEOUT_MS > 0 && CLEARFOLIO_REQUEST_TIMEOUT_MS <= 30_000, true); }); +test('completed provider requests dispose their timeout timers immediately', async () => { + useResponse({ status: 'RUNNING' }); + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + const timer = { unref() {} }; + let scheduled = 0; + let cleared = 0; + + globalThis.setTimeout = (callback, delay) => { + assert.equal(typeof callback, 'function'); + assert.equal(delay, CLEARFOLIO_REQUEST_TIMEOUT_MS); + scheduled += 1; + return timer; + }; + globalThis.clearTimeout = (value) => { + assert.equal(value, timer); + cleared += 1; + }; + + try { + assert.equal(await jobStatus(1, 2, 'job-1'), 'RUNNING'); + assert.equal(scheduled, 1, 'one bounded provider timer is created'); + assert.equal(cleared, 1, 'the completed request clears its provider timer'); + } finally { + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + } +}); + test('status response requires JSON media type before parsing', async () => { useResponse({ status: 'RUNNING' }, { contentType: 'text/plain' }); await assert.rejects( From c14ff6edbadc3b26c55f723176b634a5c4d414f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 07:04:11 -0700 Subject: [PATCH 25/35] fix(clearfolio): dispose provider request timers --- server/clearfolio.mjs | 211 ++++++++++++++++++++++++++---------------- 1 file changed, 129 insertions(+), 82 deletions(-) diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index 2f64c549..8bb8531c 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -245,16 +245,43 @@ function validateJobId(jobId) { } /** - * Compose an optional caller cancellation signal with the hard provider budget. + * Compose caller cancellation with a hard provider budget that can be disposed. + * + * The returned scope stays active until the provider response body has been + * fully validated or cancelled. Callers must dispose it in `finally` so fast + * requests do not retain a timeout or caller-signal listener for the full budget. * * @param {AbortSignal|undefined} callerSignal - Optional upstream cancellation signal. - * @returns {AbortSignal} Signal that aborts on caller cancellation or total timeout. + * @returns {{signal:AbortSignal,dispose:()=>void}} Scoped provider cancellation contract. */ function providerSignal(callerSignal) { - const timeoutSignal = AbortSignal.timeout(CLEARFOLIO_REQUEST_TIMEOUT_MS); - if (callerSignal === undefined) return timeoutSignal; - if (!(callerSignal instanceof AbortSignal)) throw new TypeError('signal must be an AbortSignal'); - return AbortSignal.any([callerSignal, timeoutSignal]); + if (callerSignal !== undefined && !(callerSignal instanceof AbortSignal)) { + throw new TypeError('signal must be an AbortSignal'); + } + + const controller = new AbortController(); + const timeoutError = new DOMException('Clearfolio provider request timed out', 'TimeoutError'); + const timeoutId = setTimeout( + controller.abort.bind(controller, timeoutError), + CLEARFOLIO_REQUEST_TIMEOUT_MS, + ); + timeoutId.unref(); + + const abortFromCaller = controller.abort.bind(controller); + if (callerSignal !== undefined) { + if (callerSignal.aborted) controller.abort(callerSignal.reason); + else callerSignal.addEventListener('abort', abortFromCaller, { once: true }); + } + + return { + signal: controller.signal, + dispose() { + clearTimeout(timeoutId); + if (callerSignal !== undefined) { + callerSignal.removeEventListener('abort', abortFromCaller); + } + }, + }; } /** @@ -396,32 +423,37 @@ export async function submitJob(orgId, userId, document) { new Blob([validatedDocument.bytes], { type: validatedDocument.mime || 'application/octet-stream' }), validatedDocument.name, ); - let res; + const request = providerSignal(); try { - res = await fetch(`${configuration.baseUrl}/api/v1/convert/jobs`, { - method: 'POST', - headers: tenantHeaders(orgId, userId, configuration.secret), - body: form, - redirect: 'error', - signal: providerSignal(), - }); - } catch { - throw new Error('clearfolio submit unavailable'); - } - if (!res.ok) return rejectProviderResponse(res, `clearfolio submit failed (${res.status})`); - const data = await readBoundedJson(res, 'clearfolio submit response invalid'); - if (!isJsonRecord(data)) throw new Error('clearfolio submit response invalid'); - const status = data.status === undefined ? 'PENDING' : data.status; - if (typeof data.jobId !== 'string' || !isClearfolioJobStatus(status)) { - throw new Error('clearfolio submit response invalid'); - } - let jobId; - try { - jobId = validateJobId(data.jobId); - } catch { - throw new Error('clearfolio submit response invalid'); + let res; + try { + res = await fetch(`${configuration.baseUrl}/api/v1/convert/jobs`, { + method: 'POST', + headers: tenantHeaders(orgId, userId, configuration.secret), + body: form, + redirect: 'error', + signal: request.signal, + }); + } catch { + throw new Error('clearfolio submit unavailable'); + } + if (!res.ok) return rejectProviderResponse(res, `clearfolio submit failed (${res.status})`); + const data = await readBoundedJson(res, 'clearfolio submit response invalid'); + if (!isJsonRecord(data)) throw new Error('clearfolio submit response invalid'); + const status = data.status === undefined ? 'PENDING' : data.status; + if (typeof data.jobId !== 'string' || !isClearfolioJobStatus(status)) { + throw new Error('clearfolio submit response invalid'); + } + let jobId; + try { + jobId = validateJobId(data.jobId); + } catch { + throw new Error('clearfolio submit response invalid'); + } + return { jobId, status }; + } finally { + request.dispose(); } - return { jobId, status }; } /** @@ -443,22 +475,32 @@ export async function jobStatus(orgId, userId, jobId, { signal } = {}) { const canonicalJobId = validateJobId(jobId); const configuration = clearfolioConfiguration(); if (configuration.mock) return mockDocs.has(canonicalJobId) ? 'SUCCEEDED' : 'FAILED'; - let res; + let request; try { - res = await fetch(`${configuration.baseUrl}/api/v1/convert/jobs/${encodeURIComponent(canonicalJobId)}`, { - headers: tenantHeaders(orgId, userId, configuration.secret), - signal: providerSignal(signal), - redirect: 'error', - }); + request = providerSignal(signal); } catch { throw new Error('clearfolio status unavailable'); } - if (!res.ok) return rejectProviderResponse(res, `clearfolio status failed (${res.status})`); - const data = await readBoundedJson(res, 'clearfolio status response invalid'); - if (!isJsonRecord(data) || !isClearfolioJobStatus(data.status)) { - throw new Error('clearfolio status response invalid'); + try { + let res; + try { + res = await fetch(`${configuration.baseUrl}/api/v1/convert/jobs/${encodeURIComponent(canonicalJobId)}`, { + headers: tenantHeaders(orgId, userId, configuration.secret), + signal: request.signal, + redirect: 'error', + }); + } catch { + throw new Error('clearfolio status unavailable'); + } + if (!res.ok) return rejectProviderResponse(res, `clearfolio status failed (${res.status})`); + const data = await readBoundedJson(res, 'clearfolio status response invalid'); + if (!isJsonRecord(data) || !isClearfolioJobStatus(data.status)) { + throw new Error('clearfolio status response invalid'); + } + return data.status; + } finally { + request.dispose(); } - return data.status; } /** @@ -481,49 +523,54 @@ export async function artifactUrl(orgId, userId, jobId) { const canonicalJobId = validateJobId(jobId); const configuration = clearfolioConfiguration(); if (configuration.mock) return `/api/mock-clearfolio/${encodeURIComponent(canonicalJobId)}`; - let res; + const request = providerSignal(); try { - res = await fetch(`${configuration.baseUrl}/api/v1/viewer/${encodeURIComponent(canonicalJobId)}/artifact-links`, { - method: 'POST', - headers: tenantHeaders(orgId, userId, configuration.secret), - redirect: 'error', - signal: providerSignal(), - }); - } catch { - throw new Error('clearfolio artifact-link unavailable'); - } - if (!res.ok) return rejectProviderResponse(res, `clearfolio artifact-link failed (${res.status})`); - const data = await readBoundedJson(res, 'clearfolio artifact-link response invalid'); - if (!isJsonRecord(data)) throw new Error('clearfolio artifact-link response invalid'); - const link = data.artifactUrl || data.url || data.signedUrl; - if (typeof link !== 'string' || link.length === 0) { - throw new Error('clearfolio artifact-link response invalid'); - } + let res; + try { + res = await fetch(`${configuration.baseUrl}/api/v1/viewer/${encodeURIComponent(canonicalJobId)}/artifact-links`, { + method: 'POST', + headers: tenantHeaders(orgId, userId, configuration.secret), + redirect: 'error', + signal: request.signal, + }); + } catch { + throw new Error('clearfolio artifact-link unavailable'); + } + if (!res.ok) return rejectProviderResponse(res, `clearfolio artifact-link failed (${res.status})`); + const data = await readBoundedJson(res, 'clearfolio artifact-link response invalid'); + if (!isJsonRecord(data)) throw new Error('clearfolio artifact-link response invalid'); + const link = data.artifactUrl || data.url || data.signedUrl; + if (typeof link !== 'string' || link.length === 0) { + throw new Error('clearfolio artifact-link response invalid'); + } - let url; - let clearfolioUrl; - try { - clearfolioUrl = new URL(configuration.baseUrl); - url = new URL(link, clearfolioUrl); - } catch { - throw new Error('clearfolio artifact-link response invalid'); - } - const allowsHttp = clearfolioUrl.protocol === 'http:' && url.protocol === 'http:'; - if (url.protocol !== 'https:' && !allowsHttp) { - throw new Error('clearfolio artifact-link response invalid'); - } - if ( - url.origin !== clearfolioUrl.origin - || url.username - || url.password - || url.hash - ) { - throw new Error('clearfolio artifact-link response invalid'); - } + let url; + let clearfolioUrl; + try { + clearfolioUrl = new URL(configuration.baseUrl); + url = new URL(link, clearfolioUrl); + } catch { + throw new Error('clearfolio artifact-link response invalid'); + } + const allowsHttp = clearfolioUrl.protocol === 'http:' && url.protocol === 'http:'; + if (url.protocol !== 'https:' && !allowsHttp) { + throw new Error('clearfolio artifact-link response invalid'); + } + if ( + url.origin !== clearfolioUrl.origin + || url.username + || url.password + || url.hash + ) { + throw new Error('clearfolio artifact-link response invalid'); + } - const token = url.searchParams.get('artifactToken'); - if (token) { - return `${configuration.baseUrl}/viewer/${encodeURIComponent(canonicalJobId)}?artifactToken=${encodeURIComponent(token)}`; + const token = url.searchParams.get('artifactToken'); + if (token) { + return `${configuration.baseUrl}/viewer/${encodeURIComponent(canonicalJobId)}?artifactToken=${encodeURIComponent(token)}`; + } + return url.href; + } finally { + request.dispose(); } - return url.href; } From 8faa7c279c71b5e591fe694a5152c338a2a2a5d4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 13:21:27 -0700 Subject: [PATCH 26/35] test(clearfolio): reproduce provider timeout metric classification --- .../unit/clearfolio-refresh-timeout.test.mjs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/unit/clearfolio-refresh-timeout.test.mjs diff --git a/tests/unit/clearfolio-refresh-timeout.test.mjs b/tests/unit/clearfolio-refresh-timeout.test.mjs new file mode 100644 index 00000000..5e5eacec --- /dev/null +++ b/tests/unit/clearfolio-refresh-timeout.test.mjs @@ -0,0 +1,62 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { refreshAttachmentStatuses } from '../../server/attachment_status.mjs'; + +const HMAC_SECRET = 'clearfolio-shared-secret-32-bytes!!'; +const originalFetch = globalThis.fetch; +process.env.CLEARFOLIO_URL = 'https://clearfolio.example'; +process.env.CLEARFOLIO_HMAC_SECRET = HMAC_SECRET; + +globalThis.fetch = async () => { + throw new DOMException('private provider timeout detail', 'TimeoutError'); +}; + +const { jobStatus } = await import( + '../../server/clearfolio.mjs?refresh-timeout-classification-test=1' +); + +test.after(() => { + globalThis.fetch = originalFetch; + delete process.env.CLEARFOLIO_URL; + delete process.env.CLEARFOLIO_HMAC_SECRET; +}); + +test('provider timeout stays sanitized and is counted as a refresh timeout', async () => { + await assert.rejects( + () => jobStatus(1, 2, 'job-timeout'), + (error) => { + assert.equal(error.name, 'TimeoutError'); + assert.equal(error.message, 'clearfolio status unavailable'); + assert.doesNotMatch(error.message, /private provider timeout detail/); + return true; + }, + ); + + const rows = [{ id: 1, jobId: 'job-timeout', status: 'PENDING' }]; + const categories = []; + const metrics = {}; + const counts = await refreshAttachmentStatuses(rows, { + orgId: 1, + userId: 2, + timeoutMs: 30_000, + budgetMs: 60_000, + metrics, + onError: ({ category }) => categories.push(category), + jobStatus, + updateStatus: () => { + throw new Error('timed-out status must not be persisted'); + }, + }); + + assert.deepEqual(counts, { + attempted: 1, + changed: 0, + failed: 1, + skipped: 0, + deferred: 0, + }); + assert.deepEqual(categories, ['timeout']); + assert.equal(metrics.attachmentStatusRefreshTimeoutFailures, 1); + assert.equal(metrics.attachmentStatusRefreshDownstreamLookupFailures, 0); + assert.equal(rows[0].status, 'PENDING'); +}); From 6b5854836dc9852d511a5f8019e28d7e67753b61 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 13:25:29 -0700 Subject: [PATCH 27/35] fix(clearfolio): preserve sanitized timeout identity --- server/clearfolio.mjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index 8bb8531c..d1688a3a 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -489,8 +489,10 @@ export async function jobStatus(orgId, userId, jobId, { signal } = {}) { signal: request.signal, redirect: 'error', }); - } catch { - throw new Error('clearfolio status unavailable'); + } catch (error) { + const unavailable = new Error('clearfolio status unavailable'); + if (error?.name === 'TimeoutError') unavailable.name = 'TimeoutError'; + throw unavailable; } if (!res.ok) return rejectProviderResponse(res, `clearfolio status failed (${res.status})`); const data = await readBoundedJson(res, 'clearfolio status response invalid'); @@ -573,4 +575,4 @@ export async function artifactUrl(orgId, userId, jobId) { } finally { request.dispose(); } -} +} \ No newline at end of file From 35c8157b499c4df2c7d08d6b336d75cf43d4ea24 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 13:26:25 -0700 Subject: [PATCH 28/35] fix(attachments): classify downstream timeouts accurately --- server/attachment_status.mjs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/attachment_status.mjs b/server/attachment_status.mjs index c6f9ae21..cc231d72 100644 --- a/server/attachment_status.mjs +++ b/server/attachment_status.mjs @@ -281,9 +281,10 @@ export async function refreshAttachmentStatuses(rows, options) { } } catch (error) { counts.failed += 1; - const category = error?.name === ATTACHMENT_STATUS_TIMEOUT_ERROR - ? 'timeout' - : failureCategory; + const category = ( + error?.name === ATTACHMENT_STATUS_TIMEOUT_ERROR + || error?.name === 'TimeoutError' + ) ? 'timeout' : failureCategory; failureCounts[category] += 1; reportRefreshFailure(options.onError, category); } From d60e281e7e2375207aa8b266f2938dfb51e65c2e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 13:27:09 -0700 Subject: [PATCH 29/35] test(clearfolio): execute refresh timeout regression --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 0968a71f..e55546ef 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/clearfolio-provider-boundary.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/clearfolio-provider-boundary.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-refresh-timeout.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/clearfolio-provider-boundary.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-refresh-timeout.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/clearfolio-provider-boundary.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", From b24db31ad91eff3ed52c862cd0ac88c733b8b10b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 17:17:43 +0900 Subject: [PATCH 30/35] fix(clearfolio): preserve status timeout classification --- CHANGELOG.md | 2 ++ .../clearfolio-provider-response-boundary.md | 3 +- server/clearfolio.mjs | 14 ++++++-- .../clearfolio-provider-boundary.test.mjs | 32 +++++++++++++++++++ 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8159e3f8..665009f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 256 KiB streamed JSON responses, composed caller cancellation with the provider budget, and validated document metadata/bytes and provider job IDs before allocation, persistence, or URL construction. +- Preserved Clearfolio provider timeouts that occur while streaming a status + response so attachment-refresh timeout metrics remain accurate. - Made `SCOPEWEAVE_JWT_SECRET` mandatory at startup and rejected weak or unexpanded placeholder values so production deployments fail closed. - Neutralized audit-log CSV formulas even when executable prefixes are hidden diff --git a/docs/doctoring/clearfolio-provider-response-boundary.md b/docs/doctoring/clearfolio-provider-response-boundary.md index 52a2e97a..d8928c46 100644 --- a/docs/doctoring/clearfolio-provider-response-boundary.md +++ b/docs/doctoring/clearfolio-provider-response-boundary.md @@ -15,7 +15,8 @@ Hosted provider requests: 3. use Fetch `redirect: "error"` so a redirect is a transport failure rather than a credential-forwarding opportunity; 4. carry a hard 15,000 ms total-request `AbortSignal`; 5. compose a caller cancellation signal with that hard budget for status refreshes; and -6. collapse network, redirect, timeout, and cancellation details into fixed operation-level errors before they can reach browser or diagnostic payloads. +6. collapse network, redirect, timeout, and cancellation details into fixed operation-level errors before they can reach browser or diagnostic payloads; and +7. preserve the timeout category when the hard budget aborts an in-progress status response body, so refresh metrics distinguish timeouts from malformed responses. ScopeWeave does not retry provider calls in this slice. Retry eligibility, idempotency keys, backoff, cancellation recovery, and persisted operation lifecycle remain explicit follow-up work rather than being guessed at the transport layer. diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index d1688a3a..686d8991 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -495,7 +495,17 @@ export async function jobStatus(orgId, userId, jobId, { signal } = {}) { throw unavailable; } if (!res.ok) return rejectProviderResponse(res, `clearfolio status failed (${res.status})`); - const data = await readBoundedJson(res, 'clearfolio status response invalid'); + let data; + try { + data = await readBoundedJson(res, 'clearfolio status response invalid'); + } catch (error) { + if (request.signal.aborted && request.signal.reason?.name === 'TimeoutError') { + const unavailable = new Error('clearfolio status unavailable'); + unavailable.name = 'TimeoutError'; + throw unavailable; + } + throw error; + } if (!isJsonRecord(data) || !isClearfolioJobStatus(data.status)) { throw new Error('clearfolio status response invalid'); } @@ -575,4 +585,4 @@ export async function artifactUrl(orgId, userId, jobId) { } finally { request.dispose(); } -} \ No newline at end of file +} diff --git a/tests/unit/clearfolio-provider-boundary.test.mjs b/tests/unit/clearfolio-provider-boundary.test.mjs index 0f153815..d90a2d43 100644 --- a/tests/unit/clearfolio-provider-boundary.test.mjs +++ b/tests/unit/clearfolio-provider-boundary.test.mjs @@ -130,6 +130,38 @@ test('caller cancellation remains composed with the provider request budget', as ); }); +test('status body timeout remains a timeout for refresh categorization', async () => { + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + globalThis.setTimeout = (callback) => { + callback(); + return { unref() {} }; + }; + globalThis.clearTimeout = () => {}; + responder = async () => new Response( + new ReadableStream({ + start(controller) { + controller.error(new DOMException('body aborted', 'AbortError')); + }, + }), + { headers: { 'content-type': 'application/json' } }, + ); + + try { + await assert.rejects( + () => jobStatus(1, 2, 'job-1'), + (error) => { + assert.equal(error.name, 'TimeoutError'); + assert.equal(error.message, 'clearfolio status unavailable'); + return true; + }, + ); + } finally { + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + } +}); + test('document validation fails before Blob, FormData, or provider transport', async () => { const before = calls.length; const invalidDocuments = [ From 10ee53c6e65779185b93c5f12929951c0ef367a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 07:58:24 -0700 Subject: [PATCH 31/35] test(clearfolio): preserve persistence failure category --- .../unit/clearfolio-refresh-timeout.test.mjs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/unit/clearfolio-refresh-timeout.test.mjs b/tests/unit/clearfolio-refresh-timeout.test.mjs index 5e5eacec..e61981aa 100644 --- a/tests/unit/clearfolio-refresh-timeout.test.mjs +++ b/tests/unit/clearfolio-refresh-timeout.test.mjs @@ -60,3 +60,33 @@ test('provider timeout stays sanitized and is counted as a refresh timeout', asy assert.equal(metrics.attachmentStatusRefreshDownstreamLookupFailures, 0); assert.equal(rows[0].status, 'PENDING'); }); + +test('a persistence TimeoutError remains a persistence failure', async () => { + const rows = [{ id: 1, jobId: 'job-ready', status: 'PENDING' }]; + const categories = []; + const metrics = {}; + const counts = await refreshAttachmentStatuses(rows, { + orgId: 1, + userId: 2, + timeoutMs: 30_000, + budgetMs: 60_000, + metrics, + onError: ({ category }) => categories.push(category), + jobStatus: async () => 'READY', + updateStatus: async () => { + throw new DOMException('storage deadline', 'TimeoutError'); + }, + }); + + assert.deepEqual(counts, { + attempted: 1, + changed: 0, + failed: 1, + skipped: 0, + deferred: 0, + }); + assert.deepEqual(categories, ['status_persistence']); + assert.equal(metrics.attachmentStatusRefreshTimeoutFailures, 0); + assert.equal(metrics.attachmentStatusRefreshPersistenceFailures, 1); + assert.equal(rows[0].status, 'PENDING'); +}); From 940858859b27060241864e633659f656907616b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 00:06:57 +0900 Subject: [PATCH 32/35] fix(attachments): preserve persistence timeout category --- server/attachment_status.mjs | 2 +- tests/unit/clearfolio-refresh-timeout.test.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/attachment_status.mjs b/server/attachment_status.mjs index cc231d72..3471ca3b 100644 --- a/server/attachment_status.mjs +++ b/server/attachment_status.mjs @@ -283,7 +283,7 @@ export async function refreshAttachmentStatuses(rows, options) { counts.failed += 1; const category = ( error?.name === ATTACHMENT_STATUS_TIMEOUT_ERROR - || error?.name === 'TimeoutError' + || (failureCategory === 'downstream_lookup' && error?.name === 'TimeoutError') ) ? 'timeout' : failureCategory; failureCounts[category] += 1; reportRefreshFailure(options.onError, category); diff --git a/tests/unit/clearfolio-refresh-timeout.test.mjs b/tests/unit/clearfolio-refresh-timeout.test.mjs index e61981aa..b3817cc2 100644 --- a/tests/unit/clearfolio-refresh-timeout.test.mjs +++ b/tests/unit/clearfolio-refresh-timeout.test.mjs @@ -72,7 +72,7 @@ test('a persistence TimeoutError remains a persistence failure', async () => { budgetMs: 60_000, metrics, onError: ({ category }) => categories.push(category), - jobStatus: async () => 'READY', + jobStatus: async () => 'SUCCEEDED', updateStatus: async () => { throw new DOMException('storage deadline', 'TimeoutError'); }, From 8d0ff9dcbc76ff8eebda6ac587c5e987272b7656 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 14:17:49 -0700 Subject: [PATCH 33/35] test(clearfolio): preserve late caller abort reason --- .../clearfolio-provider-boundary.test.mjs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/unit/clearfolio-provider-boundary.test.mjs b/tests/unit/clearfolio-provider-boundary.test.mjs index d90a2d43..20109f78 100644 --- a/tests/unit/clearfolio-provider-boundary.test.mjs +++ b/tests/unit/clearfolio-provider-boundary.test.mjs @@ -130,6 +130,38 @@ test('caller cancellation remains composed with the provider request budget', as ); }); +test('caller cancellation after provider start preserves the caller abort reason', async () => { + const controller = new AbortController(); + const callerReason = new Error('caller cancelled after provider start'); + let providerSignal; + let requestStarted; + const started = new Promise((resolve) => { requestStarted = resolve; }); + + responder = async (_url, options) => { + providerSignal = options.signal; + requestStarted(); + return new Promise((_resolve, reject) => { + const rejectOnAbort = () => reject(providerSignal.reason); + if (providerSignal.aborted) rejectOnAbort(); + else providerSignal.addEventListener('abort', rejectOnAbort, { once: true }); + }); + }; + + const pending = jobStatus(1, 2, 'job-1', { signal: controller.signal }); + await started; + controller.abort(callerReason); + + await assert.rejects( + () => pending, + (error) => { + assert.equal(error.message, 'clearfolio status unavailable'); + return true; + }, + ); + assert.equal(providerSignal.aborted, true); + assert.equal(providerSignal.reason, callerReason); +}); + test('status body timeout remains a timeout for refresh categorization', async () => { const originalSetTimeout = globalThis.setTimeout; const originalClearTimeout = globalThis.clearTimeout; From d4ca96a838d18c579a04fa94b2c6ad25fa4925b0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 14:20:32 -0700 Subject: [PATCH 34/35] fix(clearfolio): forward caller abort reason --- server/clearfolio.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index 686d8991..aee48084 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -267,7 +267,7 @@ function providerSignal(callerSignal) { ); timeoutId.unref(); - const abortFromCaller = controller.abort.bind(controller); + const abortFromCaller = () => controller.abort(callerSignal.reason); if (callerSignal !== undefined) { if (callerSignal.aborted) controller.abort(callerSignal.reason); else callerSignal.addEventListener('abort', abortFromCaller, { once: true }); From d7fae634706d4af8d94ae0b5201d0b539f81df84 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 14:28:15 -0700 Subject: [PATCH 35/35] test(clearfolio): exercise streamed response byte ceiling --- .../clearfolio-provider-boundary.test.mjs | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/unit/clearfolio-provider-boundary.test.mjs b/tests/unit/clearfolio-provider-boundary.test.mjs index 20109f78..169eecaf 100644 --- a/tests/unit/clearfolio-provider-boundary.test.mjs +++ b/tests/unit/clearfolio-provider-boundary.test.mjs @@ -103,10 +103,24 @@ test('declared and streamed provider response bodies are bounded', async () => { /clearfolio status response invalid/, ); - responder = async () => new Response( - new Uint8Array(CLEARFOLIO_MAX_RESPONSE_BYTES + 1), - { headers: { 'content-type': 'application/json' } }, - ); + responder = async () => { + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(CLEARFOLIO_MAX_RESPONSE_BYTES)); + controller.enqueue(new Uint8Array(1)); + controller.close(); + }, + }), + { headers: { 'content-type': 'application/json' } }, + ); + assert.equal( + response.headers.get('content-length'), + null, + 'streamed-overflow regression must reach the byte counter without a declared length', + ); + return response; + }; await assert.rejects( () => jobStatus(1, 2, 'job-1'), /clearfolio status response invalid/,