From 0846f9f04c0aeaf314e3a126c70a6e4170ff2625 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 15:04:37 +0900 Subject: [PATCH 01/14] test(clearfolio): define capability readiness contract --- .../clearfolio-capability-readiness.test.mjs | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 tests/unit/clearfolio-capability-readiness.test.mjs diff --git a/tests/unit/clearfolio-capability-readiness.test.mjs b/tests/unit/clearfolio-capability-readiness.test.mjs new file mode 100644 index 00000000..5094e8ac --- /dev/null +++ b/tests/unit/clearfolio-capability-readiness.test.mjs @@ -0,0 +1,117 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; + +const JWT_SECRET = '0123456789abcdef0123456789abcdef'; +const HMAC_SECRET = 'clearfolio-shared-secret-32-bytes!!'; + +/** + * Ask a fresh ScopeWeave process for `/api/health` under one Clearfolio configuration. + * + * Each probe uses a new Node process because Clearfolio configuration is bound at + * module import time. The child replaces `fetch` with a throwing function so the + * health contract proves capability readiness from local configuration only and + * never turns a liveness request into provider traffic. + * + * @param {Record} overrides - Environment values for the child. + * @returns {{status:number,body:Record}} Parsed health response. + */ +function healthProbe(overrides = {}) { + const env = { ...process.env }; + delete env.SCOPEWEAVE_DEV; + delete env.CLEARFOLIO_URL; + delete env.CLEARFOLIO_HMAC_SECRET; + delete env.CLEARFOLIO_ARTIFACT_ORIGINS; + Object.assign(env, overrides, { + SCOPEWEAVE_DB: ':memory:', + SCOPEWEAVE_JWT_SECRET: JWT_SECRET, + }); + + const script = ` + globalThis.fetch = async () => { throw new Error('health must not call a provider'); }; + const { app } = await import('./server/app.mjs?capability-health=' + Date.now()); + const response = await app.request('/api/health'); + process.stdout.write(JSON.stringify({ status: response.status, body: await response.json() })); + `; + const child = spawnSync(process.execPath, ['--input-type=module', '-e', script], { + cwd: process.cwd(), + env, + encoding: 'utf8', + }); + assert.equal(child.status, 0, child.stderr || child.stdout); + return JSON.parse(child.stdout); +} + +test('health stays live while unconfigured production reports Clearfolio unavailable', () => { + assert.deepEqual(healthProbe(), { + status: 200, + body: { + ok: true, + capabilities: { + clearfolio: { + ready: false, + mode: 'unavailable', + reason: 'clearfolio_not_configured', + action: 'Set CLEARFOLIO_URL and CLEARFOLIO_HMAC_SECRET, or use SCOPEWEAVE_DEV=1 only for local development.', + }, + }, + }, + }); +}); + +test('explicit development mock is visible without masquerading as production provider readiness', () => { + assert.deepEqual(healthProbe({ SCOPEWEAVE_DEV: '1' }), { + status: 200, + body: { + ok: true, + capabilities: { + clearfolio: { + ready: true, + mode: 'development_mock', + reason: null, + action: 'Configure a Clearfolio provider before using this deployment for production document conversion.', + }, + }, + }, + }); +}); + +test('valid production configuration reports provider readiness without provider traffic', () => { + assert.deepEqual(healthProbe({ + CLEARFOLIO_URL: 'https://clearfolio.example', + CLEARFOLIO_HMAC_SECRET: HMAC_SECRET, + }), { + status: 200, + body: { + ok: true, + capabilities: { + clearfolio: { + ready: true, + mode: 'provider', + reason: null, + action: null, + }, + }, + }, + }); +}); + +test('invalid production configuration degrades only Clearfolio capability and gives a safe next action', () => { + assert.deepEqual(healthProbe({ + CLEARFOLIO_URL: 'http://clearfolio.example', + CLEARFOLIO_HMAC_SECRET: HMAC_SECRET, + }), { + status: 200, + body: { + ok: true, + capabilities: { + clearfolio: { + ready: false, + mode: 'unavailable', + reason: 'clearfolio_transport_insecure', + action: 'Set CLEARFOLIO_URL to a root HTTPS origin without credentials, path, query, or fragment.', + }, + }, + }, + }); +}); From 9a2da6af268d66bb05fcfa3e0d7fcbece1cc1538 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 15:04:58 +0900 Subject: [PATCH 02/14] test(clearfolio): register capability readiness regression --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index ce17f8ce..078a94b2 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/clearfolio-provider-boundary.test.mjs && node tests/unit/clearfolio-artifact-origin.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/clearfolio-artifact-origin.test.mjs && node tests/unit/clearfolio-capability-readiness.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/clearfolio-provider-boundary.test.mjs && node tests/unit/clearfolio-artifact-origin.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/clearfolio-artifact-origin.test.mjs && node tests/unit/clearfolio-capability-readiness.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 e19744b308a9edb882123ab08987a11d3739033d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 15:08:54 +0900 Subject: [PATCH 03/14] feat(clearfolio): expose safe capability readiness --- server/clearfolio.mjs | 53 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index fd716524..66a1894e 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -174,6 +174,59 @@ function clearfolioArtifactOrigins(baseUrl) { return trustedOrigins; } +/** + * Return a non-secret operator action for one Clearfolio configuration failure. + * + * @param {string} code - Stable `ClearfolioConfigurationError` code. + * @returns {string} A concrete remediation instruction containing no secret values. + */ +function clearfolioConfigurationAction(code) { + if (code === 'clearfolio_not_configured') { + return 'Set CLEARFOLIO_URL and CLEARFOLIO_HMAC_SECRET, or use SCOPEWEAVE_DEV=1 only for local development.'; + } + if (code === 'clearfolio_hmac_secret_invalid') { + return `Set CLEARFOLIO_HMAC_SECRET to at least ${MIN_HMAC_SECRET_LENGTH} non-whitespace characters.`; + } + if (code === 'clearfolio_artifact_origins_invalid') { + return 'Set CLEARFOLIO_ARTIFACT_ORIGINS to comma-separated HTTPS origins without credentials, path, query, or fragment, or unset it.'; + } + return 'Set CLEARFOLIO_URL to a root HTTPS origin without credentials, path, query, or fragment.'; +} + +/** + * Describe whether the optional Clearfolio capability is locally ready to serve. + * + * This is configuration readiness only: it intentionally performs no DNS, HTTP, + * authentication, or provider-health request, so ScopeWeave liveness cannot be + * coupled to an optional downstream dependency. The development mock is reported + * explicitly and never masquerades as production-provider readiness. + * + * @returns {{ready:boolean,mode:'provider'|'development_mock'|'unavailable',reason:string|null,action:string|null}} Safe capability state. + */ +export function clearfolioCapabilityStatus() { + if (clearfolioMock) { + return { + ready: true, + mode: 'development_mock', + reason: null, + action: 'Configure a Clearfolio provider before using this deployment for production document conversion.', + }; + } + try { + const configuration = clearfolioConfiguration(); + clearfolioArtifactOrigins(configuration.baseUrl); + return { ready: true, mode: 'provider', reason: null, action: null }; + } catch (error) { + if (!(error instanceof ClearfolioConfigurationError)) throw error; + return { + ready: false, + mode: 'unavailable', + reason: error.code, + action: clearfolioConfigurationAction(error.code), + }; + } +} + /** * Sign tenant claims using the Clearfolio HMAC interoperability contract. * From fa12c4f2d5d1874c2add9399512de4342f5eed04 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 15:10:16 +0900 Subject: [PATCH 04/14] test(clearfolio): verify liveness separation and readiness status --- .../clearfolio-capability-readiness.test.mjs | 125 +++++++++--------- 1 file changed, 62 insertions(+), 63 deletions(-) diff --git a/tests/unit/clearfolio-capability-readiness.test.mjs b/tests/unit/clearfolio-capability-readiness.test.mjs index 5094e8ac..4b5e96fb 100644 --- a/tests/unit/clearfolio-capability-readiness.test.mjs +++ b/tests/unit/clearfolio-capability-readiness.test.mjs @@ -6,17 +6,17 @@ const JWT_SECRET = '0123456789abcdef0123456789abcdef'; const HMAC_SECRET = 'clearfolio-shared-secret-32-bytes!!'; /** - * Ask a fresh ScopeWeave process for `/api/health` under one Clearfolio configuration. + * Read liveness and Clearfolio capability state from a fresh ScopeWeave process. * * Each probe uses a new Node process because Clearfolio configuration is bound at - * module import time. The child replaces `fetch` with a throwing function so the - * health contract proves capability readiness from local configuration only and - * never turns a liveness request into provider traffic. + * module import time. The child replaces `fetch` with a throwing function so + * readiness is proven from local configuration only and never turns liveness or + * startup diagnostics into provider traffic. * * @param {Record} overrides - Environment values for the child. - * @returns {{status:number,body:Record}} Parsed health response. + * @returns {{health:{status:number,body:Record},capability:Record}} Probe result. */ -function healthProbe(overrides = {}) { +function capabilityProbe(overrides = {}) { const env = { ...process.env }; delete env.SCOPEWEAVE_DEV; delete env.CLEARFOLIO_URL; @@ -28,10 +28,14 @@ function healthProbe(overrides = {}) { }); const script = ` - globalThis.fetch = async () => { throw new Error('health must not call a provider'); }; + globalThis.fetch = async () => { throw new Error('readiness must not call a provider'); }; + const { clearfolioCapabilityStatus } = await import('./server/clearfolio.mjs?capability=' + Date.now()); const { app } = await import('./server/app.mjs?capability-health=' + Date.now()); const response = await app.request('/api/health'); - process.stdout.write(JSON.stringify({ status: response.status, body: await response.json() })); + process.stdout.write(JSON.stringify({ + health: { status: response.status, body: await response.json() }, + capability: clearfolioCapabilityStatus(), + })); `; const child = spawnSync(process.execPath, ['--input-type=module', '-e', script], { cwd: process.cwd(), @@ -42,76 +46,71 @@ function healthProbe(overrides = {}) { return JSON.parse(child.stdout); } -test('health stays live while unconfigured production reports Clearfolio unavailable', () => { - assert.deepEqual(healthProbe(), { - status: 200, - body: { - ok: true, - capabilities: { - clearfolio: { - ready: false, - mode: 'unavailable', - reason: 'clearfolio_not_configured', - action: 'Set CLEARFOLIO_URL and CLEARFOLIO_HMAC_SECRET, or use SCOPEWEAVE_DEV=1 only for local development.', - }, - }, - }, +function expectLiveHealth(probe) { + assert.deepEqual(probe.health, { status: 200, body: { ok: true } }); +} + +test('liveness stays healthy while unconfigured production reports Clearfolio unavailable', () => { + const probe = capabilityProbe(); + expectLiveHealth(probe); + assert.deepEqual(probe.capability, { + ready: false, + mode: 'unavailable', + reason: 'clearfolio_not_configured', + action: 'Set CLEARFOLIO_URL and CLEARFOLIO_HMAC_SECRET, or use SCOPEWEAVE_DEV=1 only for local development.', }); }); test('explicit development mock is visible without masquerading as production provider readiness', () => { - assert.deepEqual(healthProbe({ SCOPEWEAVE_DEV: '1' }), { - status: 200, - body: { - ok: true, - capabilities: { - clearfolio: { - ready: true, - mode: 'development_mock', - reason: null, - action: 'Configure a Clearfolio provider before using this deployment for production document conversion.', - }, - }, - }, + const probe = capabilityProbe({ SCOPEWEAVE_DEV: '1' }); + expectLiveHealth(probe); + assert.deepEqual(probe.capability, { + ready: true, + mode: 'development_mock', + reason: null, + action: 'Configure a Clearfolio provider before using this deployment for production document conversion.', }); }); test('valid production configuration reports provider readiness without provider traffic', () => { - assert.deepEqual(healthProbe({ + const probe = capabilityProbe({ CLEARFOLIO_URL: 'https://clearfolio.example', CLEARFOLIO_HMAC_SECRET: HMAC_SECRET, - }), { - status: 200, - body: { - ok: true, - capabilities: { - clearfolio: { - ready: true, - mode: 'provider', - reason: null, - action: null, - }, - }, - }, + }); + expectLiveHealth(probe); + assert.deepEqual(probe.capability, { + ready: true, + mode: 'provider', + reason: null, + action: null, }); }); test('invalid production configuration degrades only Clearfolio capability and gives a safe next action', () => { - assert.deepEqual(healthProbe({ + const probe = capabilityProbe({ CLEARFOLIO_URL: 'http://clearfolio.example', CLEARFOLIO_HMAC_SECRET: HMAC_SECRET, - }), { - status: 200, - body: { - ok: true, - capabilities: { - clearfolio: { - ready: false, - mode: 'unavailable', - reason: 'clearfolio_transport_insecure', - action: 'Set CLEARFOLIO_URL to a root HTTPS origin without credentials, path, query, or fragment.', - }, - }, - }, + }); + expectLiveHealth(probe); + assert.deepEqual(probe.capability, { + ready: false, + mode: 'unavailable', + reason: 'clearfolio_transport_insecure', + action: 'Set CLEARFOLIO_URL to a root HTTPS origin without credentials, path, query, or fragment.', + }); +}); + +test('invalid artifact-origin policy is readiness-visible before provider transport', () => { + const probe = capabilityProbe({ + CLEARFOLIO_URL: 'https://clearfolio.example', + CLEARFOLIO_HMAC_SECRET: HMAC_SECRET, + CLEARFOLIO_ARTIFACT_ORIGINS: 'https://cdn.example/files', + }); + expectLiveHealth(probe); + assert.deepEqual(probe.capability, { + ready: false, + mode: 'unavailable', + reason: 'clearfolio_artifact_origins_invalid', + action: 'Set CLEARFOLIO_ARTIFACT_ORIGINS to comma-separated HTTPS origins without credentials, path, query, or fragment, or unset it.', }); }); From 6261a3c820e0016e8723ff48072b7c44094c2dfa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 15:10:31 +0900 Subject: [PATCH 05/14] feat(ops): publish Clearfolio readiness at startup --- server/server.mjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/server/server.mjs b/server/server.mjs index c84c2e25..5ff61ad9 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -1,7 +1,14 @@ import { serve } from '@hono/node-server'; import { app } from './app.mjs'; +import { clearfolioCapabilityStatus } from './clearfolio.mjs'; const port = Number(process.env.PORT) || 8787; +const clearfolioCapability = clearfolioCapabilityStatus(); +console.log(JSON.stringify({ + event: 'capability.readiness', + capability: 'clearfolio', + ...clearfolioCapability, +})); serve({ fetch: app.fetch, port }, (info) => { console.log(`ScopeWeave API listening on http://localhost:${info.port}`); }); From 1e6b3870b7624c358c00697d1d2453c20ecfdf0f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 15:11:54 +0900 Subject: [PATCH 06/14] docs(clearfolio): document readiness and operator actions --- docs/deploy.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/deploy.md b/docs/deploy.md index f8673e41..7e1d9bcb 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -41,6 +41,7 @@ persists the database in the `scopeweave-data` volume. | `ORCHESTRATOR_TOKEN` | with URL | orchestrator Bearer 토큰 (`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. | +| `CLEARFOLIO_ARTIFACT_ORIGINS` | only for reviewed cross-origin artifact hosts | Optional comma-separated HTTPS origins for approved CDN/object-storage artifact redirects. Do not include credentials, paths, query strings, fragments, or empty entries. Unset keeps artifact trust same-origin only. | | `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. | | `SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS` | no (default 3000, maximum 30000) | Hard caller-side timeout for each Clearfolio status lookup. The AbortSignal is also forwarded downstream. | | `SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS` | no (default 5000, maximum 60000) | Wall-clock budget for the entire best-effort refresh pass. Work not started before the deadline is deferred to a later list request. | @@ -56,6 +57,28 @@ ScopeWeave planning capabilities remain available. For local integration work, permits HTTP only for `localhost`, `127.0.0.1`, or `::1`; remote HTTP endpoints are rejected. +At process startup ScopeWeave emits one structured, non-secret readiness record: + +```json +{"event":"capability.readiness","capability":"clearfolio","ready":false,"mode":"unavailable","reason":"clearfolio_not_configured","action":"Set CLEARFOLIO_URL and CLEARFOLIO_HMAC_SECRET, or use SCOPEWEAVE_DEV=1 only for local development."} +``` + +Use `ready`, `mode`, `reason`, and `action` to decide the next operator step. The +record validates local provider and artifact-origin configuration only; it does +**not** make a DNS or HTTP call and therefore does not claim that Clearfolio is +reachable. `mode=development_mock` is deliberately distinct from +`mode=provider`. Invalid provider transport, weak HMAC configuration, and an +invalid artifact-origin allowlist report `ready=false` with a stable reason and +a safe remediation instruction. + +`GET /api/health` remains liveness-only and returns `{"ok":true}` even when the +optional Clearfolio capability is unavailable. This separation prevents an +optional document-viewer dependency from causing the planner process to be +restarted or removed from service. Kubernetes documents liveness as the signal +for restarting unhealthy containers and readiness as the signal for whether a +container should receive traffic; ScopeWeave keeps the whole application live +while reporting the optional capability independently. + Provider URLs are treated as service origins, not arbitrary request prefixes. 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 @@ -175,4 +198,6 @@ stays focused on the container + compose path.) ## Health `GET /api/health` → `{"ok":true}`. Wired as the container `HEALTHCHECK` and the -compose healthcheck. +compose healthcheck. Optional dependency readiness is reported separately in the +startup `capability.readiness` record so Clearfolio configuration never makes +whole-process liveness fail. From 723aa179f45bde181456c1a5abb2c611a1cc5101 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 15:12:37 +0900 Subject: [PATCH 07/14] docs(changelog): record Clearfolio capability readiness --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3501968e..33af405f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added a non-secret Clearfolio capability-readiness record at server startup so + operators can distinguish configured provider, explicit development mock, and + unavailable/invalid configuration without coupling optional document-viewer + readiness to whole-process `/api/health` liveness. - Added deterministic PM analysis for requirements/RFI/RFP readiness, WBS estimation coverage, dependency risk, and procurement package section checks. - Preserved PM-analysis research papers, NASA WBS handbook, BCP 14, and JSON From 423b3d6ec39d3577f8e951da9a2dcaffd00ba624 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 15:13:08 +0900 Subject: [PATCH 08/14] docs(clearfolio): record capability readiness boundary --- .../clearfolio-capability-readiness.md | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 docs/doctoring/clearfolio-capability-readiness.md diff --git a/docs/doctoring/clearfolio-capability-readiness.md b/docs/doctoring/clearfolio-capability-readiness.md new file mode 100644 index 00000000..9253ddf7 --- /dev/null +++ b/docs/doctoring/clearfolio-capability-readiness.md @@ -0,0 +1,83 @@ +# Clearfolio capability readiness and liveness separation + +## Decision + +Clearfolio is an optional ScopeWeave MSA capability. Its local configuration state must be visible to an operator without turning the whole planner process unhealthy and without making a provider network request merely to answer a health question. + +ScopeWeave therefore keeps `GET /api/health` as whole-process liveness and publishes one non-secret structured `capability.readiness` record for Clearfolio at server startup. The readiness record is produced by the same configuration validator used by production Clearfolio operations and returns only four bounded fields: `ready`, `mode`, `reason`, and `action`. + +This is a bounded follow-up slice of issue #489. It does not claim remote Clearfolio reachability, latency, authentication success, artifact availability, or end-to-end readiness. Those require operational evidence from real provider calls and the attachment status path; the startup record proves configuration readiness only. + +## States + +### Provider + +A valid root Clearfolio origin, HMAC secret, and optional artifact-origin policy returns: + +```json +{"ready":true,"mode":"provider","reason":null,"action":null} +``` + +No DNS lookup or HTTP request occurs while deriving this state. + +### Explicit development mock + +`SCOPEWEAVE_DEV=1` with no provider URL returns: + +```json +{"ready":true,"mode":"development_mock","reason":null,"action":"Configure a Clearfolio provider before using this deployment for production document conversion."} +``` + +The mode name deliberately prevents the mock from being presented as production-provider readiness. + +### Unavailable or invalid production configuration + +Missing or invalid production configuration returns `ready=false`, `mode=unavailable`, a stable configuration reason, and an action that tells the operator what to change without echoing a URL, shared secret, provider body, network address, or tenant claim. + +Examples include: + +- `clearfolio_not_configured` -> configure the provider URL and HMAC secret, or use the development flag only for local work; +- `clearfolio_hmac_secret_invalid` -> provide at least 32 non-whitespace characters; +- URL component or transport failures -> use a root HTTPS origin without credentials, path, query, or fragment; +- `clearfolio_artifact_origins_invalid` -> provide only comma-separated HTTPS origins or remove the optional setting. + +## Why liveness remains independent + +Kubernetes distinguishes liveness from readiness: a failed liveness probe can trigger container restart, while readiness controls whether a workload should receive service traffic. Clearfolio is not required for planning, authentication, project CRUD, or the static client, so treating its configuration as whole-process liveness would turn an optional dependency failure into an unnecessary planner outage. + +The existing `/api/health` response remains `{"ok":true}` while the Clearfolio capability is unavailable. Operators inspect the startup readiness record for the optional integration and continue to use attachment failure/status evidence for remote operational diagnosis. + +RFC 9110 defines a successful GET response as a representation of the target resource state. ScopeWeave keeps the `/api/health` resource narrowly defined as process liveness rather than silently changing its semantics to aggregate every optional dependency. + +## Security and privacy boundary + +The readiness function calls only local configuration validators. It never: + +- calls `fetch`, resolves DNS, follows redirects, or contacts Clearfolio; +- includes `CLEARFOLIO_HMAC_SECRET`, tenant claims, provider response text, job IDs, artifact tokens, or configured URLs in output; +- changes a capability from unavailable to a successful mock outside explicit development mode; +- weakens the provider URL or artifact-origin allowlist checks established by the parent stack. + +Unknown non-configuration exceptions are rethrown instead of being silently misclassified as a configuration state. + +## Verification contract + +`tests/unit/clearfolio-capability-readiness.test.mjs` launches fresh processes so module-import configuration cannot leak between cases. It replaces global `fetch` with a throwing function and proves that readiness evaluation performs no provider transport. The cases cover: + +- unconfigured production with live `/api/health` and unavailable Clearfolio; +- explicit development mock with a production-configuration action; +- valid production provider configuration; +- insecure production HTTP configuration; +- malformed artifact-origin policy detected before provider transport. + +The regression executes in both `test:unit` and `test:coverage:cases`; `server/clearfolio.mjs` remains in the canonical owned-production c8 target set. + +## Rollback + +Rollback removes the startup capability record and exported readiness function together. It must not restore implicit production mocks or make `/api/health` fail because Clearfolio is optional. If operators require a remote dependency probe later, add it as a separately named operational signal with bounded timeout and explicit failure semantics rather than expanding liveness implicitly. + +## References + +Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110; STD 97). Internet Engineering Task Force. https://doi.org/10.17487/RFC9110 + +The Kubernetes Authors. (2026). *Liveness, readiness, and startup probes*. Kubernetes Documentation. https://kubernetes.io/docs/concepts/workloads/pods/probes/ From e45a2f366598dc47c5aa105ed2fd2595e3c5e946 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 22:01:09 +0900 Subject: [PATCH 09/14] test(clearfolio): preserve provider rejection cleanup on readiness stack --- .../clearfolio-provider-boundary.test.mjs | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/unit/clearfolio-provider-boundary.test.mjs b/tests/unit/clearfolio-provider-boundary.test.mjs index cea06c64..f8628094 100644 --- a/tests/unit/clearfolio-provider-boundary.test.mjs +++ b/tests/unit/clearfolio-provider-boundary.test.mjs @@ -142,3 +142,73 @@ 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'); + } + + 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 fd24a6c19c699e3546e9589cd2fc69bbbbe4db9e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 00:18:17 +0900 Subject: [PATCH 10/14] fix(clearfolio): preserve provider response cancellation --- server/clearfolio.mjs | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index 66a1894e..28f289e2 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -362,6 +362,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. * @@ -381,20 +404,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(); @@ -490,7 +513,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; @@ -535,7 +558,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'); @@ -572,7 +595,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 4ac599fbd8714f0ee73d3321002d82d4011b401e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 02:46:52 +0900 Subject: [PATCH 11/14] fix(clearfolio): preserve canonical artifact origins in readiness stack --- server/clearfolio.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index 28f289e2..60c18ff2 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -148,9 +148,10 @@ function clearfolioArtifactOrigins(baseUrl) { } for (const entry of entries) { + const canonical = entry.trim(); let url; try { - url = new URL(entry.trim()); + url = new URL(canonical); } catch { throw new ClearfolioConfigurationError( 'clearfolio_artifact_origins_invalid', @@ -163,6 +164,7 @@ function clearfolioArtifactOrigins(baseUrl) { || url.pathname !== '/' || Boolean(url.search) || Boolean(url.hash) + || (canonical !== url.origin && canonical !== `${url.origin}/`) ) { throw new ClearfolioConfigurationError( 'clearfolio_artifact_origins_invalid', From 8455fb1e61faf1c90ae347c5cbc353d1c201b4e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 02:50:22 +0900 Subject: [PATCH 12/14] fix(clearfolio): restore canonical artifact-origin evidence after stack merge --- ARCHITECTURE.md | 4 +++ README.md | 5 +++- docs/api.md | 6 +++-- .../clearfolio-artifact-origin-trust.md | 9 ++++--- .../clearfolio-production-configuration.md | 4 +-- .../clearfolio-provider-response-boundary.md | 2 +- .../unit/clearfolio-artifact-origin.test.mjs | 27 +++++++++++++++++++ 7 files changed, 47 insertions(+), 10 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3006c74b..4fa04b7f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -43,3 +43,7 @@ contain. - Kubernetes/IaC security coverage remains a follow-up design lane for any future `infra/` or container packaging surface. +- Clearfolio artifact redirects trust only the configured provider origin + unless `CLEARFOLIO_ARTIFACT_ORIGINS` lists additional exact HTTPS origins. + Cross-origin `artifactToken` values stay on the returned origin and are + never transplanted into the Clearfolio viewer. diff --git a/README.md b/README.md index ea8bb77a..c0da0685 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,10 @@ Docker: set a **persistent** `SCOPEWEAVE_JWT_SECRET` first, then run `docker com | `OIDC_ISSUER/CLIENT_ID/CLIENT_SECRET/REDIRECT_URI` | Real SSO IdP (mock when unset) | | `STRIPE_SECRET_KEY` | Real checkout (mock URL when unset) | | `SCOPEWEAVE_RATE_LIMIT_MAX` (+`_WINDOW_MS`) | Opt-in per-IP rate limiting | -| `SCOPEWEAVE_DEV=1` | Dev-only endpoints (activate-pro) | +| `SCOPEWEAVE_DEV=1` | Dev-only endpoints (activate-pro, loopback Clearfolio HTTP, in-memory Clearfolio adapter when no provider URL exists). Never set in production. | +| `CLEARFOLIO_URL` | Production 산출물 viewer origin (HTTPS). Unset fails closed unless `SCOPEWEAVE_DEV=1`. | +| `CLEARFOLIO_HMAC_SECRET` | Tenant-claim HMAC secret (at least 32 non-whitespace characters) when a provider URL is set. | +| `CLEARFOLIO_ARTIFACT_ORIGINS` | Optional comma-separated exact HTTPS artifact origins. Unset trusts only `CLEARFOLIO_URL`. Empty or malformed values fail closed. | ## Verification diff --git a/docs/api.md b/docs/api.md index 1c668425..b3d51d31 100644 --- a/docs/api.md +++ b/docs/api.md @@ -90,8 +90,10 @@ credentials never reach the browser. HWP/HWPX are rejected (Clearfolio policy). | `GET` | `/api/projects/:id/attachments/:aid/view` | 302 → signed artifact URL (`?token=` for new-tab opens) | | `DELETE` | `/api/projects/:id/attachments/:aid` | Uploader or manage | -Env: `CLEARFOLIO_URL` (+ optional `CLEARFOLIO_HMAC_SECRET` for gateway-signed -tenant claims). Unset → a built-in mock converter (dev/test only). +Env: `CLEARFOLIO_URL` plus `CLEARFOLIO_HMAC_SECRET` for production conversion. +Optional `CLEARFOLIO_ARTIFACT_ORIGINS` adds reviewed HTTPS CDN/object-store +origins; unset trusts only the Clearfolio origin. An unset URL is not a +successful converter: the in-memory mock exists only with `SCOPEWEAVE_DEV=1`. ## Comments (코멘트) diff --git a/docs/doctoring/clearfolio-artifact-origin-trust.md b/docs/doctoring/clearfolio-artifact-origin-trust.md index 38507d3d..a48bf8cc 100644 --- a/docs/doctoring/clearfolio-artifact-origin-trust.md +++ b/docs/doctoring/clearfolio-artifact-origin-trust.md @@ -15,9 +15,9 @@ The adapter uses a positive allowlist rather than accepting any syntactically va ## Runtime contract 1. `CLEARFOLIO_ARTIFACT_ORIGINS` is optional. When absent, only the validated Clearfolio provider origin is trusted. -2. When present, the value is a comma-separated list of canonicalizable HTTPS origins. Whitespace around entries is ignored; empty entries are rejected. -3. Any malformed entry, HTTP entry, URL credential, path, query, or fragment produces `ClearfolioConfigurationError` with stable code `clearfolio_artifact_origins_invalid` before the artifact-link provider request is sent. -4. Provider-returned artifact URLs must still satisfy the existing HTTP/HTTPS and downgrade rules, must contain no credentials or fragment, and must resolve to the provider origin or an explicitly configured artifact origin. +2. When present, the value is a comma-separated list of canonical HTTPS origins. Whitespace around entries is ignored. Empty values, whitespace-only values, and empty comma-separated entries are rejected. After parsing, each trimmed entry must equal `URL.origin` or that origin plus a single trailing `/`, so default-port `:443`, empty fragments, empty userinfo, and path-normalized lookalikes cannot sneak in. +3. Any malformed entry, HTTP entry, URL credential, path, query, fragment, or non-canonical origin produces `ClearfolioConfigurationError` with stable code `clearfolio_artifact_origins_invalid` before the artifact-link provider request is sent. +4. Provider-returned artifact URLs must still satisfy the existing HTTP/HTTPS and downgrade rules, must contain no credentials or fragment, and must resolve to the provider origin or an explicitly configured artifact origin. Protocol-relative links, backslash-normalized protocol-relative paths, and userinfo-as-host URLs inherit a foreign origin and are rejected unless that origin is explicitly allowlisted. 5. A same-origin `artifactToken` may be translated into the trusted Clearfolio viewer route. A token on an approved cross-origin artifact URL remains on that returned URL; ScopeWeave never transplants it into the provider-origin viewer. 6. Exact origin comparison includes the effective port. Approving `https://cdn.example:8443` does not approve `https://cdn.example`. @@ -36,11 +36,12 @@ Do not place signed paths, object keys, tokens, credentials, query strings, or f `tests/unit/clearfolio-artifact-origin.test.mjs` exercises the production adapter with real `URL` parsing and a bounded mocked provider response. It proves: - cross-origin HTTPS artifacts fail by default; +- protocol-relative, backslash-normalized, and userinfo-as-host links do not inherit the provider origin; - same-origin relative artifacts continue to resolve against the provider; - an explicitly approved origin succeeds only for the same scheme/host/port identity; - approved cross-origin `artifactToken` values remain on the approved origin; - credentials and fragments are rejected on provider and approved origins; -- malformed, HTTP, credentialed, path-, query-, fragment-bearing, and empty-entry configuration fails before any provider transport. +- malformed, HTTP, credentialed, path-, query-, fragment-bearing, non-canonical, empty, and whitespace-only configuration fails before any provider transport. The test is registered in both the normal unit suite and the production coverage cases so changes to this boundary cannot silently bypass repository coverage evidence. diff --git a/docs/doctoring/clearfolio-production-configuration.md b/docs/doctoring/clearfolio-production-configuration.md index 2f1b1501..a4b79ca5 100644 --- a/docs/doctoring/clearfolio-production-configuration.md +++ b/docs/doctoring/clearfolio-production-configuration.md @@ -10,9 +10,9 @@ This boundary prevents configuration text from becoming an arbitrary downstream ## Artifact-token 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. +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 never transplanted into the trusted viewer. Reviewed CDN or object-storage hosts are added only through the later `CLEARFOLIO_ARTIFACT_ORIGINS` allowlist recorded in `docs/doctoring/clearfolio-artifact-origin-trust.md`; without that setting, a cross-origin token remains rejected. -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. +Issue #489 remains open after this slice. Streaming response-size/media-type limits and the provider-wide request budget are owned by the provider-response-boundary record. Remaining work is capability readiness, persistence/lifecycle controls, incident and recovery evidence, and destination DNS/IP authorization so an allowlisted origin cannot become an arbitrary in-origin redirector. ## Executable evidence diff --git a/docs/doctoring/clearfolio-provider-response-boundary.md b/docs/doctoring/clearfolio-provider-response-boundary.md index 52a2e97a..58f8ed18 100644 --- a/docs/doctoring/clearfolio-provider-response-boundary.md +++ b/docs/doctoring/clearfolio-provider-response-boundary.md @@ -41,7 +41,7 @@ The 10 MiB limit matches the current ScopeWeave attachment API ceiling, so the d 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. +Reviewed cross-origin artifact hosts are not implied here. They are owned by the later `CLEARFOLIO_ARTIFACT_ORIGINS` allowlist in `docs/doctoring/clearfolio-artifact-origin-trust.md`, which also rejects credentials and fragments on returned links. Until that allowlist is configured, only the provider origin is trusted. Issue #489 still owns destination DNS/IP authorization, signed-artifact URL shape, and the remaining lifecycle/acceptance work. ## Verification contract diff --git a/tests/unit/clearfolio-artifact-origin.test.mjs b/tests/unit/clearfolio-artifact-origin.test.mjs index 2094c39f..3dcf7559 100644 --- a/tests/unit/clearfolio-artifact-origin.test.mjs +++ b/tests/unit/clearfolio-artifact-origin.test.mjs @@ -44,6 +44,22 @@ test('artifact URLs default to the configured Clearfolio origin', async () => { () => resolveArtifact('https://cdn.example/file.pdf', undefined), /clearfolio artifact-link response invalid/, ); + await assert.rejects( + () => resolveArtifact('https://evil.com/file.pdf?artifactToken=stolen', undefined), + /clearfolio artifact-link response invalid/, + 'an unallowlisted token stays rejected instead of moving into the viewer', + ); + for (const link of [ + '//evil.com/file.pdf', + '/\\evil.com/file.pdf', + 'https://clearfolio.example@evil.com/x?artifactToken=t', + ]) { + await assert.rejects( + () => resolveArtifact(link, undefined), + /clearfolio artifact-link response invalid/, + `${link} must not inherit the provider origin`, + ); + } assert.equal( await resolveArtifact('/signed/file.pdf', undefined), 'https://clearfolio.example/signed/file.pdf', @@ -55,6 +71,11 @@ test('explicit HTTPS artifact origins are exact scheme-host-port allowlist entri await resolveArtifact('https://cdn.example/file.pdf', ' https://cdn.example '), 'https://cdn.example/file.pdf', ); + assert.equal( + await resolveArtifact('https://cdn.example/file.pdf', 'https://cdn.example/'), + 'https://cdn.example/file.pdf', + 'a trailing slash is still an origin-only allowlist entry', + ); assert.equal( await resolveArtifact( 'https://cdn.example/file.pdf?artifactToken=remote%20token', @@ -91,8 +112,14 @@ test('artifact origin configuration fails closed before provider transport', asy 'https://cdn.example/path', 'https://cdn.example?query=1', 'https://cdn.example#fragment', + 'https://cdn.example#', + 'https://:@cdn.example', + 'https://cdn.example/foo/..', + 'https://cdn.example:443', 'not a URL', 'https://cdn.example,', + '', + ' ', ]; for (const artifactOrigins of invalidConfigurations) { From 3ffc13bd9205c37d0615b86d511d2989fddd46ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 02:51:47 +0900 Subject: [PATCH 13/14] docs(clearfolio): preserve parent artifact-origin operations with readiness --- CHANGELOG.md | 5 +++-- docs/deploy.md | 10 +++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0f0e98a..21cf98cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 transplanted into the trusted Clearfolio viewer URL. - Restricted hosted Clearfolio artifact redirects to the provider origin by default; optional `CLEARFOLIO_ARTIFACT_ORIGINS` entries must be exact HTTPS - origins, so scheme/host/port changes, credentials, fragments, and unapproved + origins (`URL.origin` or that origin plus `/`), so scheme/host/port changes, + credentials, fragments, protocol-relative lookalikes, and unapproved cross-origin links fail closed while approved cross-origin tokens remain bound to the origin that issued them. - Bounded hosted Clearfolio calls to non-redirecting 15-second requests and @@ -107,4 +108,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/docs/deploy.md b/docs/deploy.md index 45645332..74f653d4 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -41,7 +41,7 @@ persists the database in the `scopeweave-data` volume. | `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. | -| `CLEARFOLIO_ARTIFACT_ORIGINS` | only for reviewed cross-origin artifact hosts | Optional comma-separated HTTPS origins for approved CDN/object-storage artifact redirects. Do not include credentials, paths, query strings, fragments, or empty entries. Unset keeps artifact trust same-origin only. | +| `CLEARFOLIO_ARTIFACT_ORIGINS` | optional with URL | Comma-separated exact HTTPS artifact origins (scheme, host, optional non-default port). Leave unset to trust only `CLEARFOLIO_URL`. Empty, whitespace-only, HTTP, credentialed, path, query, fragment, or non-canonical entries fail closed before any provider call. | | `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. | | `SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS` | no (default 3000, maximum 30000) | Hard caller-side timeout for each Clearfolio status lookup. The AbortSignal is also forwarded downstream. | | `SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS` | no (default 5000, maximum 60000) | Wall-clock budget for the entire best-effort refresh pass. Work not started before the deadline is deferred to a later list request. | @@ -84,6 +84,14 @@ 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. +Artifact links returned by Clearfolio are untrusted until they match the +provider origin or an origin listed in `CLEARFOLIO_ARTIFACT_ORIGINS`. If +Clearfolio serves files from a reviewed CDN or object store, add only that +origin, for example `https://artifacts.example.com`. Do not put signed paths, +object keys, or tokens in the setting. Unset keeps the least-privilege default: +only the Clearfolio origin is trusted, and a cross-origin `artifactToken` is +never copied into the viewer URL. + 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 From f1f0cee6da9dd6ced09b624ae75376dd38d4d670 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 02:53:38 +0900 Subject: [PATCH 14/14] docs(changelog): preserve parent wording in readiness stack --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21cf98cd..33e75517 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,4 +108,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 리스트에서의 버벅임 현상을 해결했습니다.