From f1285c6ad2d05bc46368aebfc5b161cbe48487fa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:02:24 +0000 Subject: [PATCH 1/5] feat(clearfolio): expose queryable capability readiness to planners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authenticated GET /api/capabilities and attachment 503 reuse the configuration-only evaluator so operators and the 산출물 dialog can act without coupling optional conversion to /api/health liveness. Co-authored-by: Seongho Bae --- AGENTS.md | 4 + ARCHITECTURE.md | 5 + CHANGELOG.md | 3 + CLAUDE.md | 2 +- cloud-sync.js | 39 +++++++- docs/api.md | 14 ++- docs/deploy.md | 17 ++-- .../clearfolio-capability-operator-surface.md | 67 +++++++++++++ .../clearfolio-capability-readiness.md | 9 +- server/app.mjs | 53 ++++++++++- styles.css | 9 ++ .../clearfolio-capability-readiness.test.mjs | 95 ++++++++++++++++++- tests/unit/cloud-sync-security.test.mjs | 16 +++- 13 files changed, 310 insertions(+), 23 deletions(-) create mode 100644 docs/doctoring/clearfolio-capability-operator-surface.md diff --git a/AGENTS.md b/AGENTS.md index 7a5b65ac..6b731810 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,10 @@ - Preserve the single global `tasks` array as the source of truth. - Use a single `renderAll()` integration path for user-visible rerenders. - Prefer browser-native APIs only. +- Keep `GET /api/health` as process liveness. Report optional Clearfolio + readiness separately (`capability.readiness` logs, `GET /api/capabilities`, + and attachment 503). Do not turn an unconfigured document viewer into a + planner outage. ## Verification diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3006c74b..fed59e19 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -43,3 +43,8 @@ contain. - Kubernetes/IaC security coverage remains a follow-up design lane for any future `infra/` or container packaging surface. +- Optional Clearfolio conversion is a replaceable MSA capability. Process + liveness stays on `GET /api/health`. Configuration readiness is emitted at + startup, queried from authenticated `GET /api/capabilities`, and used to + fail attachment upload/view closed with HTTP 503. The in-memory adapter + exists only behind `SCOPEWEAVE_DEV=1`. diff --git a/CHANGELOG.md b/CHANGELOG.md index b0f0e98a..a8afbf43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 authenticated `GET /api/capabilities` and HTTP 503 attachment + short-circuit so planners see a concrete next action before upload and + operators can query the same record without reading container logs. - 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 diff --git a/CLAUDE.md b/CLAUDE.md index b1f11c4d..7deae503 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,7 @@ deploy guide is `docs/deploy.md`. - `server.mjs` — `@hono/node-server` entry (PORT, default 8787), serves the API and the static client via a strict allowlist. - `app.mjs` — Hono routes (auth/SSO, projects, teams, billing, webhooks, baselines, - revisions, comments, search…); `auth.mjs` — scrypt + pinned-HS256 JWT + PAT hashing; + revisions, comments, search, authenticated capability readiness…); `auth.mjs` — scrypt + pinned-HS256 JWT + PAT hashing; `billing.mjs` — plans/caps, Stripe via dynamic import; `db.mjs` — `node:sqlite`. - Only two runtime dependencies (`hono`, `@hono/node-server`); everything else is Node built-ins. Do not add runtime dependencies (repository contract in diff --git a/cloud-sync.js b/cloud-sync.js index 0e015ebe..22ab24dc 100644 --- a/cloud-sync.js +++ b/cloud-sync.js @@ -26,6 +26,24 @@ export function routeTokenPathSegment(value) { return ROUTE_TOKEN_RE.test(token) ? token : ''; } +/** + * Return the next action a planner should take when Clearfolio is locally unavailable. + * + * The server remains the authority: this helper only formats an already-safe + * capability record so the attachments dialog can tell the user what to do + * before they pick a file. Empty string means conversion may proceed. + * + * @param {{ready?:boolean,action?:string|null}|null|undefined} capability - Authenticated Clearfolio capability record. + * @returns {string} Concrete next action, or an empty string when upload may continue. + */ +export function clearfolioCapabilityNotice(capability) { + if (!capability || capability.ready) return ''; + const action = typeof capability.action === 'string' ? capability.action.trim() : ''; + return action + ? `문서 변환을 사용할 수 없습니다. ${action}` + : '문서 변환을 사용할 수 없습니다. 운영자에게 Clearfolio 설정을 요청하십시오.'; +} + function safeApiPath(path) { if (typeof path !== 'string' || !path.startsWith('/api/')) throw new Error('invalid api path'); const origin = typeof location !== 'undefined' ? location.origin : 'http://localhost'; @@ -1196,6 +1214,21 @@ async function openAttachmentsModal() { head.append(h2, close); panel.appendChild(head); + let capability = null; + try { + capability = (await api('/api/capabilities'))?.capabilities?.clearfolio || null; + } catch { + capability = null; + } + const noticeText = clearfolioCapabilityNotice(capability); + if (noticeText) { + const notice = document.createElement('p'); + notice.className = 'capability-notice'; + notice.setAttribute('role', 'status'); + notice.textContent = noticeText; + panel.appendChild(notice); + } + // 작업 선택 + 파일 업로드 const sel = document.createElement('select'); sel.className = 'cloud-select'; @@ -1220,7 +1253,11 @@ async function openAttachmentsModal() { const up = document.createElement('button'); up.type = 'submit'; up.className = 'primary-button'; - up.textContent = '업로드'; + up.textContent = noticeText ? '변환 설정 필요' : '업로드'; + if (noticeText) { + fi.disabled = true; + up.disabled = true; + } form.append(fi, up); panel.appendChild(form); diff --git a/docs/api.md b/docs/api.md index 1c668425..4334c74a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -85,13 +85,16 @@ credentials never reach the browser. HWP/HWPX are rejected (Clearfolio policy). | Method | Path | Purpose | | --- | --- | --- | -| `POST` | `/api/projects/:id/attachments` | multipart `file` (+`taskId?`, ≤10MB) → conversion job (write roles) | +| `GET` | `/api/capabilities` | Authenticated optional-capability readiness (`clearfolio.ready/mode/reason/action`). Configuration-only; no provider I/O. | +| `POST` | `/api/projects/:id/attachments` | multipart `file` (+`taskId?`, ≤10MB) → conversion job (write roles). Unconfigured/invalid Clearfolio returns `503` with the same capability record. | | `GET` | `/api/projects/:id/attachments?taskId=` | List (+ refreshes pending statuses) | -| `GET` | `/api/projects/:id/attachments/:aid/view` | 302 → signed artifact URL (`?token=` for new-tab opens) | +| `GET` | `/api/projects/:id/attachments/:aid/view` | 302 → signed artifact URL (`?token=` for new-tab opens). Locally unready Clearfolio returns `503`. | | `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` and `CLEARFOLIO_HMAC_SECRET` for production conversion. +Unset in production makes the capability unavailable (`ready=false`) rather +than simulating success. `SCOPEWEAVE_DEV=1` without a URL enables the +in-memory adapter for local work only. ## Comments (코멘트) @@ -180,7 +183,8 @@ const ok = req.headers['x-scopeweave-signature'] === | `GET` | `/api/orgs/:id/audit` | Audit log (manage; `?format=csv` for a compliance CSV) | | `GET` | `/api/orgs/:id/export` | Full workspace export JSON (owner) | | `GET` | `/api/metrics` | Ops counters (JSON; add `?format=prometheus` for scrape-ready text) | -| `GET` | `/api/health` | Liveness | +| `GET` | `/api/capabilities` | Authenticated optional-capability readiness (Clearfolio configuration only) | +| `GET` | `/api/health` | Liveness (`{"ok":true}` even when Clearfolio is unavailable) | ## Example diff --git a/docs/deploy.md b/docs/deploy.md index 45645332..25c1c941 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -54,7 +54,7 @@ Outside explicit `SCOPEWEAVE_DEV=1`, Clearfolio operations fail closed with a stable configuration error and the mock artifact route is not registered. Other ScopeWeave planning capabilities remain available. For local integration work, `SCOPEWEAVE_DEV=1` permits the in-memory adapter when the URL is absent and also -permits HTTP only for `localhost`, `127.0.0.1`, or `::1`; remote HTTP endpoints +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: @@ -72,12 +72,15 @@ 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. +optional Clearfolio capability is unavailable. Authenticated +`GET /api/capabilities` returns the same non-secret record so operators do not +need container logs. Attachment upload and view return HTTP 503 with that +record when the capability is locally unready, before any provider call. 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, diff --git a/docs/doctoring/clearfolio-capability-operator-surface.md b/docs/doctoring/clearfolio-capability-operator-surface.md new file mode 100644 index 00000000..79f94b1e --- /dev/null +++ b/docs/doctoring/clearfolio-capability-operator-surface.md @@ -0,0 +1,67 @@ +# Clearfolio capability operator and planner surface + +## Decision + +Startup logs are not a buyer-usable control surface. A planner who opens 산출물 +must learn that document conversion is unavailable before selecting a file, and +an operator who cannot read container stdout must still retrieve the same +non-secret readiness record. + +ScopeWeave therefore exposes authenticated `GET /api/capabilities` and fails +attachment upload/view with HTTP 503 when Clearfolio is locally unready. Both +surfaces reuse `clearfolioCapabilityStatus()` and never call the provider. +`GET /api/health` remains liveness-only. + +This slice does not claim remote Clearfolio reachability. It completes the +operator/planner half of issue #489 configuration readiness. + +## Planner next action + +The attachments dialog reads `/api/capabilities` after login. When +`ready=false`, it shows the server `action` as a status notice, disables the +file input, and changes the submit label to `변환 설정 필요`. If the advisory +query fails, the dialog stays open and the server remains authoritative: an +unconfigured upload still returns 503 with the same reason and action. + +## Why 503 instead of 502 + +RFC 9110 distinguishes a gateway/proxy error (502) from a service that is +temporarily or locally unable to handle the request (503). An unconfigured or +unsafe Clearfolio deployment is not a failed downstream hop; it is a local +capability that the process has already decided it cannot serve. Returning 503 +with a stable reason prevents operators from paging a remote provider that was +never contacted. + +## Security and privacy boundary + +- Anonymous callers receive `401` and learn only that the route is + authenticated. Deployment mode (`development_mock` vs `unavailable`) is not + published on unauthenticated surfaces, including `/api/metrics`. +- The JSON body contains only capability name, readiness, mode, stable reason, + and fixed remediation text. HMAC material, URLs, tenant claims, job IDs, and + provider bodies are omitted. +- The UI helper only formats an already-safe record; it does not invent a + second readiness evaluator. + +## Verification contract + +`tests/unit/clearfolio-capability-readiness.test.mjs` continues to launch a +fresh process per configuration and replaces `fetch` with a throwing function. +Added cases prove HMAC-invalid readiness, authenticated capability query, and +503 upload rejection without provider traffic. +`tests/unit/cloud-sync-security.test.mjs` locks the planner notice copy. + +## Rollback + +Remove `GET /api/capabilities`, the 503 attachment short-circuit, and the +attachments-dialog notice together. Do not restore implicit production mocks or +make `/api/health` fail. + +## 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/ diff --git a/docs/doctoring/clearfolio-capability-readiness.md b/docs/doctoring/clearfolio-capability-readiness.md index 9253ddf7..3c8f68a0 100644 --- a/docs/doctoring/clearfolio-capability-readiness.md +++ b/docs/doctoring/clearfolio-capability-readiness.md @@ -4,7 +4,7 @@ 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`. +ScopeWeave therefore keeps `GET /api/health` as whole-process liveness and publishes the same non-secret Clearfolio capability record in three operator/user surfaces: one structured `capability.readiness` log at server startup, authenticated `GET /api/capabilities`, and a 503 attachment response that repeats `ready`, `mode`, `reason`, and `action`. The record is produced by the same configuration validator used by production Clearfolio operations. 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. @@ -45,7 +45,7 @@ Examples include: 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. +The existing `/api/health` response remains `{"ok":true}` while the Clearfolio capability is unavailable. Operators inspect the startup readiness record or `GET /api/capabilities` for the optional integration. Planners see the same next action in the attachments dialog before they pick a file, and attachment upload/view fail closed with HTTP 503 instead of attempting provider traffic. Remote operational diagnosis still uses attachment failure/status evidence after a valid provider is configured. 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. @@ -68,7 +68,10 @@ Unknown non-configuration exceptions are rethrown instead of being silently misc - explicit development mock with a production-configuration action; - valid production provider configuration; - insecure production HTTP configuration; -- malformed artifact-origin policy detected before provider transport. +- malformed artifact-origin policy detected before provider transport; +- weak HMAC configuration with a secret-free next action; +- anonymous `GET /api/capabilities` rejected while authenticated callers receive the same local record; +- unconfigured production attachment upload returning HTTP 503 before any provider call. The regression executes in both `test:unit` and `test:coverage:cases`; `server/clearfolio.mjs` remains in the canonical owned-production c8 target set. diff --git a/server/app.mjs b/server/app.mjs index 450be878..6f2efa8b 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -7,7 +7,7 @@ import { randomBytes, createHmac, createHash } from 'node:crypto'; import { db, rowid } from './db.mjs'; import { hashPassword, verifyPassword, signToken, verifyToken, generateApiToken, hashApiToken } from './auth.mjs'; import { PLANS, planOf, orgUsage, wouldExceed, createCheckout } from './billing.mjs'; -import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs'; +import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl, clearfolioCapabilityStatus } from './clearfolio.mjs'; import { normalizeAttachmentStatusBudgetMs, normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs'; import { chat as orchestratorChat } from './orchestrator.mjs'; import { computeEvm } from '../analytics.js'; // pure math, shared with the client @@ -28,6 +28,42 @@ const orgRole = (userId, orgId) => const canManage = (role) => role === 'owner' || role === 'admin'; const canWrite = (role) => role === 'owner' || role === 'admin' || role === 'member'; +/** + * Build a non-secret Clearfolio capability record for authenticated surfaces. + * + * The payload reuses the configuration-only readiness evaluator so clients and + * operators receive the same `ready`, `mode`, `reason`, and `action` fields that + * startup logs already emit. Secret values, URLs, and provider diagnostics are + * never copied into this object. + * + * @returns {{capability:'clearfolio',ready:boolean,mode:string,reason:string|null,action:string|null}} Safe capability state. + */ +function clearfolioCapabilityPayload() { + return { + capability: 'clearfolio', + ...clearfolioCapabilityStatus(), + }; +} + +/** + * Return HTTP 503 when optional Clearfolio conversion is not locally ready. + * + * Callers use this before reading upload bytes or asking the provider for an + * artifact link so an unconfigured or unsafe deployment fails closed with a + * concrete next action instead of a generic 502 after wasted work. + * + * @param {object} context - Current Hono request context. + * @returns {Response|null} Service-unavailable response, or null when conversion may proceed. + */ +function clearfolioUnavailableResponse(context) { + const capability = clearfolioCapabilityStatus(); + if (capability.ready) return null; + return context.json({ + error: capability.action, + ...clearfolioCapabilityPayload(), + }, 503); +} + export const app = new Hono(); async function requireAuth(c, next) { @@ -1037,6 +1073,8 @@ app.post('/api/projects/:id/attachments', requireAuth, async (c) => { const p = projectAccess(uid, c.req.param('id')); if (!p) return c.json({ error: 'not found' }, 404); if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const unavailable = clearfolioUnavailableResponse(c); + if (unavailable) return unavailable; const form = await c.req.formData().catch(() => null); const file = form?.get('file'); if (!file || typeof file === 'string') return c.json({ error: 'multipart file required' }, 400); @@ -1103,6 +1141,8 @@ app.get('/api/projects/:id/attachments/:aid/view', (c) => { const a = db.prepare('SELECT job_id, status FROM attachments WHERE id = ? AND project_id = ?').get(c.req.param('aid'), p.id); if (!a) return c.json({ error: 'not found' }, 404); if (a.status !== 'SUCCEEDED') return c.json({ error: `문서가 아직 준비되지 않았습니다 (${a.status})` }, 409); + const unavailable = clearfolioUnavailableResponse(c); + if (unavailable) return unavailable; return artifactUrl(p.org_id, uid, a.job_id) .then((url) => c.redirect(url)) .catch((e) => c.json({ error: `열람 링크 발급 실패: ${e.message}` }, 502)); @@ -1376,6 +1416,17 @@ app.delete('/api/account', requireAuth, async (c) => { app.get('/api/health', (c) => c.json({ ok: true })); +/** + * Authenticated optional-capability readiness. This is not a liveness probe: + * `/api/health` stays `{ok:true}` while Clearfolio is unconfigured. The record + * is configuration-only and never performs provider I/O. + */ +app.get('/api/capabilities', requireAuth, (c) => c.json({ + capabilities: { + clearfolio: clearfolioCapabilityStatus(), + }, +})); + // Static client — strict allowlist so server/, data.db, package.json etc. are // never served. Anything not listed → 404. const STATIC = { diff --git a/styles.css b/styles.css index 9d715f00..050a045c 100644 --- a/styles.css +++ b/styles.css @@ -944,6 +944,15 @@ select[data-inline-progress]:focus { min-height: 18px; margin: 0; } +.capability-notice { + margin: 0 0 12px; + padding: 12px 14px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface-muted); + color: var(--text); + font-size: 0.9375rem; +} .cloud-name-field.hidden { display: none; } diff --git a/tests/unit/clearfolio-capability-readiness.test.mjs b/tests/unit/clearfolio-capability-readiness.test.mjs index 4b5e96fb..c8e00482 100644 --- a/tests/unit/clearfolio-capability-readiness.test.mjs +++ b/tests/unit/clearfolio-capability-readiness.test.mjs @@ -14,9 +14,10 @@ const HMAC_SECRET = 'clearfolio-shared-secret-32-bytes!!'; * startup diagnostics into provider traffic. * * @param {Record} overrides - Environment values for the child. - * @returns {{health:{status:number,body:Record},capability:Record}} Probe result. + * @param {{authenticate?:boolean,upload?:boolean}} [options] - Optional authenticated surfaces to exercise. + * @returns {{health:{status:number,body:Record},capability:Record,anonymousCapabilities:{status:number,body:Record},capabilities:{status:number,body:Record}|null,upload:{status:number,body:Record}|null}} Probe result. */ -function capabilityProbe(overrides = {}) { +function capabilityProbe(overrides = {}, options = {}) { const env = { ...process.env }; delete env.SCOPEWEAVE_DEV; delete env.CLEARFOLIO_URL; @@ -26,15 +27,55 @@ function capabilityProbe(overrides = {}) { SCOPEWEAVE_DB: ':memory:', SCOPEWEAVE_JWT_SECRET: JWT_SECRET, }); + const authenticate = options.authenticate === true; + const upload = options.upload === true; const script = ` 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'); + const healthResponse = await app.request('/api/health'); + const anonymousResponse = await app.request('/api/capabilities'); + let capabilities = null; + let uploadResult = null; + if (${authenticate}) { + const signup = await app.request('/api/auth/signup', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + email: 'capability-' + Date.now() + '@scopeweave.test', + password: 'password123', + name: 'Capability', + }), + }); + const token = (await signup.json()).token; + const capabilityResponse = await app.request('/api/capabilities', { + headers: { authorization: 'Bearer ' + token }, + }); + capabilities = { status: capabilityResponse.status, body: await capabilityResponse.json() }; + if (${upload}) { + const projectResponse = await app.request('/api/projects', { + method: 'POST', + headers: { authorization: 'Bearer ' + token, 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Capability Project' }), + }); + const projectId = (await projectResponse.json()).id; + const form = new FormData(); + form.append('file', new Blob(['hello'], { type: 'text/plain' }), 'hello.txt'); + const uploadResponse = await app.request('/api/projects/' + projectId + '/attachments', { + method: 'POST', + headers: { authorization: 'Bearer ' + token }, + body: form, + }); + uploadResult = { status: uploadResponse.status, body: await uploadResponse.json() }; + } + } process.stdout.write(JSON.stringify({ - health: { status: response.status, body: await response.json() }, + health: { status: healthResponse.status, body: await healthResponse.json() }, capability: clearfolioCapabilityStatus(), + anonymousCapabilities: { status: anonymousResponse.status, body: await anonymousResponse.json() }, + capabilities, + upload: uploadResult, })); `; const child = spawnSync(process.execPath, ['--input-type=module', '-e', script], { @@ -114,3 +155,49 @@ test('invalid artifact-origin policy is readiness-visible before provider transp action: 'Set CLEARFOLIO_ARTIFACT_ORIGINS to comma-separated HTTPS origins without credentials, path, query, or fragment, or unset it.', }); }); + +test('weak HMAC is readiness-visible with a secret-free next action', () => { + const probe = capabilityProbe({ + CLEARFOLIO_URL: 'https://clearfolio.example', + CLEARFOLIO_HMAC_SECRET: 'too-short', + }); + expectLiveHealth(probe); + assert.deepEqual(probe.capability, { + ready: false, + mode: 'unavailable', + reason: 'clearfolio_hmac_secret_invalid', + action: 'Set CLEARFOLIO_HMAC_SECRET to at least 32 non-whitespace characters.', + }); +}); + +test('authenticated capability query stays local and does not change liveness', () => { + const probe = capabilityProbe({}, { authenticate: true }); + expectLiveHealth(probe); + assert.equal(probe.anonymousCapabilities.status, 401); + assert.deepEqual(probe.anonymousCapabilities.body, { error: 'unauthorized' }); + assert.equal(probe.capabilities.status, 200); + assert.deepEqual(probe.capabilities.body, { + 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('unconfigured production rejects attachment upload before provider traffic', () => { + const probe = capabilityProbe({}, { authenticate: true, upload: true }); + expectLiveHealth(probe); + assert.equal(probe.upload.status, 503); + assert.deepEqual(probe.upload.body, { + error: 'Set CLEARFOLIO_URL and CLEARFOLIO_HMAC_SECRET, or use SCOPEWEAVE_DEV=1 only for local development.', + 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.', + }); +}); diff --git a/tests/unit/cloud-sync-security.test.mjs b/tests/unit/cloud-sync-security.test.mjs index 76c92ff4..ce599d62 100644 --- a/tests/unit/cloud-sync-security.test.mjs +++ b/tests/unit/cloud-sync-security.test.mjs @@ -1,9 +1,23 @@ import assert from 'node:assert/strict'; -import { routeTokenPathSegment } from '../../cloud-sync.js'; +import { clearfolioCapabilityNotice, routeTokenPathSegment } from '../../cloud-sync.js'; assert.equal(routeTokenPathSegment('abc_DEF-1234567890'), 'abc_DEF-1234567890'); assert.equal(routeTokenPathSegment(' abc_DEF-1234567890 '), 'abc_DEF-1234567890'); assert.equal(routeTokenPathSegment('../admin?force=true'), ''); assert.equal(routeTokenPathSegment('https://example.test/api'), ''); assert.equal(routeTokenPathSegment('short'), ''); + +assert.equal(clearfolioCapabilityNotice(null), ''); +assert.equal(clearfolioCapabilityNotice({ ready: true, action: null }), ''); +assert.equal( + clearfolioCapabilityNotice({ + ready: false, + action: 'Set CLEARFOLIO_URL and CLEARFOLIO_HMAC_SECRET, or use SCOPEWEAVE_DEV=1 only for local development.', + }), + '문서 변환을 사용할 수 없습니다. Set CLEARFOLIO_URL and CLEARFOLIO_HMAC_SECRET, or use SCOPEWEAVE_DEV=1 only for local development.', +); +assert.equal( + clearfolioCapabilityNotice({ ready: false, action: ' ' }), + '문서 변환을 사용할 수 없습니다. 운영자에게 Clearfolio 설정을 요청하십시오.', +); From 7a2ece469c6bc971e990282890cdf0645bb40f80 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 09:42:49 -0700 Subject: [PATCH 2/5] test(clearfolio): surface unknown readiness to planners --- tests/unit/cloud-sync-security.test.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/unit/cloud-sync-security.test.mjs b/tests/unit/cloud-sync-security.test.mjs index ce599d62..92c2997c 100644 --- a/tests/unit/cloud-sync-security.test.mjs +++ b/tests/unit/cloud-sync-security.test.mjs @@ -8,7 +8,10 @@ assert.equal(routeTokenPathSegment('../admin?force=true'), ''); assert.equal(routeTokenPathSegment('https://example.test/api'), ''); assert.equal(routeTokenPathSegment('short'), ''); -assert.equal(clearfolioCapabilityNotice(null), ''); +assert.equal( + clearfolioCapabilityNotice(null), + '문서 변환 상태를 확인하지 못했습니다. 연결 또는 로그인 상태를 확인한 뒤 다시 시도하십시오. 업로드 시 서버가 최종 사용 가능 여부를 확인합니다.', +); assert.equal(clearfolioCapabilityNotice({ ready: true, action: null }), ''); assert.equal( clearfolioCapabilityNotice({ From 71033674b1f87e663f7c6ba037ddebdd562831d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 11:56:58 -0700 Subject: [PATCH 3/5] test(clearfolio): preserve advisory fail-open contract --- tests/unit/cloud-sync-security.test.mjs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/unit/cloud-sync-security.test.mjs b/tests/unit/cloud-sync-security.test.mjs index 92c2997c..ce599d62 100644 --- a/tests/unit/cloud-sync-security.test.mjs +++ b/tests/unit/cloud-sync-security.test.mjs @@ -8,10 +8,7 @@ assert.equal(routeTokenPathSegment('../admin?force=true'), ''); assert.equal(routeTokenPathSegment('https://example.test/api'), ''); assert.equal(routeTokenPathSegment('short'), ''); -assert.equal( - clearfolioCapabilityNotice(null), - '문서 변환 상태를 확인하지 못했습니다. 연결 또는 로그인 상태를 확인한 뒤 다시 시도하십시오. 업로드 시 서버가 최종 사용 가능 여부를 확인합니다.', -); +assert.equal(clearfolioCapabilityNotice(null), ''); assert.equal(clearfolioCapabilityNotice({ ready: true, action: null }), ''); assert.equal( clearfolioCapabilityNotice({ From 96af8abfa0d9c73671cfad1d0d21eedd06e7adc0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 20:14:33 -0700 Subject: [PATCH 4/5] test(clearfolio): require unready view capability response --- .../clearfolio-capability-readiness.test.mjs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/tests/unit/clearfolio-capability-readiness.test.mjs b/tests/unit/clearfolio-capability-readiness.test.mjs index c8e00482..cb9ee564 100644 --- a/tests/unit/clearfolio-capability-readiness.test.mjs +++ b/tests/unit/clearfolio-capability-readiness.test.mjs @@ -87,6 +87,64 @@ function capabilityProbe(overrides = {}, options = {}) { return JSON.parse(child.stdout); } +/** + * Exercise an already-persisted pending attachment while Clearfolio is unready. + * + * The child creates the attachment directly in the same in-memory database used + * by the app so the view route is tested without ever submitting provider work. + * Provider fetch is replaced with a throwing function to prove the readiness + * short-circuit happens before artifact transport. + * + * @returns {{status:number,body:Record}} View response. + */ +function pendingAttachmentViewProbe() { + 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, { + SCOPEWEAVE_DB: ':memory:', + SCOPEWEAVE_JWT_SECRET: JWT_SECRET, + }); + + const script = ` + globalThis.fetch = async () => { throw new Error('unready view must not call a provider'); }; + const { app } = await import('./server/app.mjs?pending-view=' + Date.now()); + const { db } = await import('./server/db.mjs'); + const email = 'pending-view-' + Date.now() + '@scopeweave.test'; + const signup = await app.request('/api/auth/signup', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password: 'password123', name: 'Pending View' }), + }); + const token = (await signup.json()).token; + const projectResponse = await app.request('/api/projects', { + method: 'POST', + headers: { authorization: 'Bearer ' + token, 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Pending View Project' }), + }); + const projectId = (await projectResponse.json()).id; + const userId = db.prepare('SELECT id FROM users WHERE email = ?').get(email).id; + const inserted = db.prepare( + "INSERT INTO attachments(project_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?)", + ).run(projectId, 'pending.txt', 'text/plain', 7, 'pending-job', 'PENDING', userId); + const attachmentId = Number(inserted.lastInsertRowid); + const response = await app.request( + '/api/projects/' + projectId + '/attachments/' + attachmentId + '/view', + { headers: { authorization: 'Bearer ' + token } }, + ); + 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); +} + function expectLiveHealth(probe) { assert.deepEqual(probe.health, { status: 200, body: { ok: true } }); } @@ -201,3 +259,16 @@ test('unconfigured production rejects attachment upload before provider traffic' action: 'Set CLEARFOLIO_URL and CLEARFOLIO_HMAC_SECRET, or use SCOPEWEAVE_DEV=1 only for local development.', }); }); + +test('unconfigured production rejects pending attachment view with capability remediation', () => { + const view = pendingAttachmentViewProbe(); + assert.equal(view.status, 503); + assert.deepEqual(view.body, { + error: 'Set CLEARFOLIO_URL and CLEARFOLIO_HMAC_SECRET, or use SCOPEWEAVE_DEV=1 only for local development.', + 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.', + }); +}); From abcd68c20ef073818d1869f2a5e891fb99248a05 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 20:24:41 -0700 Subject: [PATCH 5/5] fix(clearfolio): prioritize unready view remediation --- server/app.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/app.mjs b/server/app.mjs index 015e2bbf..7717ba41 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1143,9 +1143,9 @@ app.get('/api/projects/:id/attachments/:aid/view', (c) => { if (!p) return c.json({ error: 'not found' }, 404); const a = db.prepare('SELECT job_id, status FROM attachments WHERE id = ? AND project_id = ?').get(c.req.param('aid'), p.id); if (!a) return c.json({ error: 'not found' }, 404); - if (a.status !== 'SUCCEEDED') return c.json({ error: `문서가 아직 준비되지 않았습니다 (${a.status})` }, 409); const unavailable = clearfolioUnavailableResponse(c); if (unavailable) return unavailable; + if (a.status !== 'SUCCEEDED') return c.json({ error: `문서가 아직 준비되지 않았습니다 (${a.status})` }, 409); return artifactUrl(p.org_id, uid, a.job_id) .then((url) => c.redirect(url)) .catch((e) => c.json({ error: `열람 링크 발급 실패: ${e.message}` }, 502));