From 0a266bb3d1052881da50f1145ff261ad3af070cd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 23:48:00 -0700 Subject: [PATCH 01/71] test(security): reproduce outbound webhook SSRF --- package.json | 6 +- tests/unit/webhook-transport.test.mjs | 386 ++++++++++++++++++++++++++ 2 files changed, 389 insertions(+), 3 deletions(-) create mode 100644 tests/unit/webhook-transport.test.mjs diff --git a/package.json b/package.json index 8cefdc74..d9215cae 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", - "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs && node tests/unit/webhook-transport.test.mjs", + "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/webhook_transport.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/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/webhook-transport.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", diff --git a/tests/unit/webhook-transport.test.mjs b/tests/unit/webhook-transport.test.mjs new file mode 100644 index 00000000..0916f306 --- /dev/null +++ b/tests/unit/webhook-transport.test.mjs @@ -0,0 +1,386 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { + WebhookDestinationError, + WebhookTransportError, + createWebhookTransport, + isPublicWebhookAddress, + validateWebhookRegistrationUrl, +} from '../../server/webhook_transport.mjs'; + +assert.equal(isPublicWebhookAddress('8.8.8.8'), true); +assert.equal(isPublicWebhookAddress('2606:4700:4700::1111'), true); +for (const address of [ + 'not-an-ip', '0.0.0.0', '10.0.0.1', '100.64.0.1', '127.0.0.1', + '169.254.169.254', '172.16.0.1', '192.0.2.10', '192.31.196.1', + '192.52.193.1', '192.168.1.1', '192.175.48.1', '198.18.0.1', + '198.51.100.2', '203.0.113.9', '224.0.0.1', '255.255.255.255', + '::', '::1', '::ffff:127.0.0.1', '64:ff9b::1', '100::1', + '100:0:0:1::1', '2001::1', '2001:2::1', '2001:db8::1', '2002::1', + '2620:4f:8000::1', '3ffe::1', '3fff::1', '400::1', '4000::1', + '5f00::1', 'fc00::1', 'fec0::1', 'fe80::1', 'ff00::1', +]) { + assert.equal(isPublicWebhookAddress(address), false, `${address} is denied`); +} + +assert.equal( + validateWebhookRegistrationUrl('https://hooks.example.com/scopeweave?tenant=buyer'), + 'https://hooks.example.com/scopeweave?tenant=buyer', +); +assert.equal(validateWebhookRegistrationUrl('https://8.8.8.8/hook'), 'https://8.8.8.8/hook'); +assert.equal( + validateWebhookRegistrationUrl('https://[2606:4700:4700::1111]/hook'), + 'https://[2606:4700:4700::1111]/hook', +); +for (const url of [ + '', 'not a url', 'http://example.com/hook', + 'https://user:pass@example.com/hook', 'https://example.com/hook#fragment', + 'https://localhost/hook', 'https://api.localhost/hook', 'https://printer.local/hook', + 'https://home.arpa/hook', 'https://svc.home.arpa/hook', 'https://127.0.0.1/hook', + 'https://2130706433/hook', 'https://0x7f000001/hook', + 'https://[::1]/hook', 'https://[::ffff:127.0.0.1]/hook', +]) { + assert.throws( + () => validateWebhookRegistrationUrl(url), + WebhookDestinationError, + `${url} is rejected`, + ); +} +assert.throws(() => createWebhookTransport({ lookup: null }), TypeError); +assert.throws(() => createWebhookTransport({ request: null }), TypeError); + +function responseRequest(statusCode, capture = {}) { + return (url, options, callback) => { + capture.url = url; + capture.options = options; + capture.calls = (capture.calls || 0) + 1; + const req = new EventEmitter(); + req.end = (body) => { + capture.body = body; + queueMicrotask(() => callback({ + statusCode, + resume() { capture.resumed = true; }, + })); + }; + return req; + }; +} + +const capture = {}; +const publicTransport = createWebhookTransport({ + lookup: async (hostname, options) => { + assert.equal(hostname, 'hooks.example.com'); + assert.deepEqual(options, { all: true, verbatim: true }); + return [ + { address: '93.184.216.34', family: 4 }, + { address: '93.184.216.34', family: 4 }, + { address: '2606:4700:4700::1111', family: 6 }, + ]; + }, + request: responseRequest(204, capture), +}); +const sent = await publicTransport.post('https://hooks.example.com/a?x=1', { + headers: { 'x-test': 'yes' }, + body: '{"ok":true}', +}); +assert.deepEqual(sent, { status: 204, ok: true }); +assert.equal(capture.url.hostname, 'hooks.example.com'); +assert.equal(capture.options.method, 'POST'); +assert.equal(capture.options.agent, false); +assert.equal(capture.options.servername, 'hooks.example.com'); +assert.deepEqual(capture.options.headers, { 'x-test': 'yes' }); +assert.equal(capture.body, '{"ok":true}'); +assert.equal(capture.resumed, true); +await new Promise((resolve, reject) => { + capture.options.lookup('ignored.example', {}, (error, address, family) => { + try { + assert.equal(error, null); + assert.equal(address, '93.184.216.34'); + assert.equal(family, 4); + resolve(); + } catch (e) { reject(e); } + }); +}); +await new Promise((resolve, reject) => { + capture.options.lookup('ignored.example', { all: true }, (error, addresses) => { + try { + assert.equal(error, null); + assert.deepEqual(addresses, [{ address: '93.184.216.34', family: 4 }]); + resolve(); + } catch (e) { reject(e); } + }); +}); + +const redirectCapture = {}; +const redirectTransport = createWebhookTransport({ + lookup: async () => [{ address: '93.184.216.34', family: 4 }], + request: responseRequest(302, redirectCapture), +}); +assert.deepEqual( + await redirectTransport.post('https://hooks.example.com/redirect'), + { status: 302, ok: false }, +); +assert.equal(redirectCapture.calls, 1, 'native HTTPS does not follow redirects'); + +for (const answers of [ + [{ address: '127.0.0.1', family: 4 }], + [{ address: '93.184.216.34', family: 4 }, { address: '10.0.0.4', family: 4 }], + [{ address: 'bad-address', family: 4 }], + [{ address: '93.184.216.34', family: 7 }], +]) { + let requestCalls = 0; + const transport = createWebhookTransport({ + lookup: async () => answers, + request: (...args) => { + requestCalls++; + return responseRequest(200)(...args); + }, + }); + await assert.rejects( + () => transport.post('https://hooks.example.com/hook'), + WebhookDestinationError, + ); + assert.equal(requestCalls, 0, 'denied DNS answers never reach the connector'); +} + +for (const answers of [[], null]) { + const transport = createWebhookTransport({ + lookup: async () => answers, + request: responseRequest(200), + }); + await assert.rejects( + () => transport.post('https://hooks.example.com/hook'), + WebhookTransportError, + ); +} +const dnsFailure = createWebhookTransport({ + lookup: async () => { throw new Error('lookup 10.0.0.1 failed'); }, + request: responseRequest(200), +}); +await assert.rejects( + () => dnsFailure.post('https://hooks.example.com/hook'), + (error) => error instanceof WebhookTransportError + && error.message === 'webhook destination unavailable' + && !error.message.includes('10.0.0.1'), +); + +let generation = 0; +let reboundRequests = 0; +const rebindingTransport = createWebhookTransport({ + lookup: async () => (++generation === 1 + ? [{ address: '93.184.216.34', family: 4 }] + : [{ address: '127.0.0.1', family: 4 }]), + request: (...args) => { + reboundRequests++; + return responseRequest(503)(...args); + }, +}); +assert.deepEqual( + await rebindingTransport.post('https://hooks.example.com/hook'), + { status: 503, ok: false }, +); +await assert.rejects( + () => rebindingTransport.post('https://hooks.example.com/hook'), + WebhookDestinationError, +); +assert.equal( + reboundRequests, + 1, + 'a later private DNS answer is rejected before a retry connection', +); + +let literalLookupCalls = 0; +const literalCapture = {}; +const literalTransport = createWebhookTransport({ + lookup: async () => { + literalLookupCalls++; + return []; + }, + request: responseRequest(200, literalCapture), +}); +assert.deepEqual( + await literalTransport.post('https://8.8.8.8/hook'), + { status: 200, ok: true }, +); +assert.equal(literalLookupCalls, 0); +assert.equal( + 'servername' in literalCapture.options, + false, + 'IP literals do not inject an SNI hostname', +); + +const candidateAnswers = [ + { address: '93.184.216.34', family: 4 }, + { address: '2606:4700:4700::1111', family: 6 }, +]; +const candidateAttempts = []; +const candidateOptions = []; +const fallbackTransport = createWebhookTransport({ + lookup: async () => candidateAnswers, + request: (_url, options, callback) => { + const req = new EventEmitter(); + req.end = () => { + candidateOptions.push({ agent: options.agent, servername: options.servername }); + options.lookup('ignored.example', {}, (error, address, family) => { + assert.equal(error, null); + candidateAttempts.push({ address, family }); + if (candidateAttempts.length === 1) { + queueMicrotask(() => req.emit('error', new Error('first public address unreachable'))); + return; + } + queueMicrotask(() => callback({ statusCode: 204, resume() {} })); + }); + }; + return req; + }, +}); +assert.deepEqual( + await fallbackTransport.post('https://hooks.example.com/hook'), + { status: 204, ok: true }, + 'a later policy-validated address is attempted when the first address cannot connect', +); +assert.deepEqual(candidateAttempts, candidateAnswers); +assert.deepEqual( + candidateOptions, + [ + { agent: false, servername: 'hooks.example.com' }, + { agent: false, servername: 'hooks.example.com' }, + ], + 'every fallback attempt disables pooling and preserves the original TLS authority', +); + +const protocolCapture = {}; +const protocolFailureTransport = createWebhookTransport({ + lookup: async () => candidateAnswers, + request: responseRequest(503, protocolCapture), +}); +assert.deepEqual( + await protocolFailureTransport.post('https://hooks.example.com/hook'), + { status: 503, ok: false }, +); +assert.equal( + protocolCapture.calls, + 1, + 'an HTTP response is authoritative and must not replay the signed body to another address', +); + +let postHandshakeAttempts = 0; +const noReplayAfterSecureConnect = createWebhookTransport({ + lookup: async () => candidateAnswers, + request: (_url, options) => { + postHandshakeAttempts += 1; + const req = new EventEmitter(); + req.end = () => { + options.lookup('ignored.example', {}, (error) => { + assert.equal(error, null); + const socket = new EventEmitter(); + req.emit('socket', socket); + queueMicrotask(() => { + socket.emit('secureConnect'); + queueMicrotask(() => req.emit('error', new Error('peer closed after TLS handshake'))); + }); + }); + }; + return req; + }, +}); +await assert.rejects( + () => noReplayAfterSecureConnect.post('https://hooks.example.com/hook', { + body: '{"signed":"payload"}', + }), + WebhookTransportError, + 'a signed webhook must not replay after TLS is established even without response headers', +); +assert.equal( + postHandshakeAttempts, + 1, + 'post-handshake delivery is ambiguous and must stop within the current webhook attempt', +); + +const exhaustedAttempts = []; +const exhaustedTransport = createWebhookTransport({ + lookup: async () => candidateAnswers, + request: (_url, options) => { + const req = new EventEmitter(); + req.end = () => { + options.lookup('ignored.example', {}, (error, address, family) => { + assert.equal(error, null); + exhaustedAttempts.push({ address, family }); + queueMicrotask(() => req.emit('error', new Error('candidate unavailable'))); + }); + }; + return req; + }, +}); +await assert.rejects( + () => exhaustedTransport.post('https://hooks.example.com/hook'), + WebhookTransportError, +); +assert.deepEqual( + exhaustedAttempts, + candidateAnswers, + 'all already-validated candidates are exhausted before the attempt fails', +); + +const fallbackAbort = new AbortController(); +const abortAttempts = []; +const abortingFallbackTransport = createWebhookTransport({ + lookup: async () => candidateAnswers, + request: (_url, options) => { + const req = new EventEmitter(); + req.end = () => { + options.lookup('ignored.example', {}, (error, address, family) => { + assert.equal(error, null); + abortAttempts.push({ address, family }); + queueMicrotask(() => fallbackAbort.abort()); + }); + }; + return req; + }, +}); +await assert.rejects( + () => abortingFallbackTransport.post('https://hooks.example.com/hook', { + signal: fallbackAbort.signal, + }), + WebhookTransportError, +); +assert.deepEqual( + abortAttempts, + [candidateAnswers[0]], + 'an aborted delivery never falls through to another validated address', +); + +const syncFailure = createWebhookTransport({ + lookup: async () => [{ address: '93.184.216.34', family: 4 }], + request: () => { throw new Error('secret network detail'); }, +}); +await assert.rejects( + () => syncFailure.post('https://hooks.example.com/hook'), + WebhookTransportError, +); + +const emittedFailure = createWebhookTransport({ + lookup: async () => [{ address: '93.184.216.34', family: 4 }], + request: () => { + const req = new EventEmitter(); + req.end = () => queueMicrotask(() => req.emit('error', new Error('socket 10.0.0.1'))); + return req; + }, +}); +await assert.rejects( + () => emittedFailure.post('https://hooks.example.com/hook'), + WebhookTransportError, +); + +const controller = new AbortController(); +controller.abort(); +const aborted = createWebhookTransport({ + lookup: async () => [{ address: '93.184.216.34', family: 4 }], + request: responseRequest(200), +}); +await assert.rejects( + () => aborted.post('https://hooks.example.com/hook', { signal: controller.signal }), + WebhookTransportError, +); + +console.log('webhook transport policy tests passed'); From c1353bd3416b0ac29b125d7f46931a0461566980 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 23:54:43 -0700 Subject: [PATCH 02/71] fix(security): pin outbound webhook destinations --- package.json | 4 +- server/app.mjs | 1499 ++--------------- server/app_core.mjs | 1410 ++++++++++++++++ server/webhook_transport.mjs | 288 ++++ tests/api/webhook-destination-policy.test.mjs | 110 ++ 5 files changed, 1923 insertions(+), 1388 deletions(-) create mode 100644 server/app_core.mjs create mode 100644 server/webhook_transport.mjs create mode 100644 tests/api/webhook-destination-policy.test.mjs diff --git a/package.json b/package.json index d9215cae..0ad3854f 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,9 @@ "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "coverage": "npm run test:coverage", "server": "node server/server.mjs", - "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs && node tests/unit/webhook-transport.test.mjs", - "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/webhook_transport.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", + "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/app_core.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/webhook_transport.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/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/webhook-transport.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", diff --git a/server/app.mjs b/server/app.mjs index c432a84f..62db65db 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1,1410 +1,137 @@ -// ScopeWeave SaaS API. Multi-tenant (org-scoped), optimistic concurrency on -// project docs, SSE realtime fan-out per project. The existing static client -// (index.html/app.js) becomes the frontend that talks to these routes. +// ScopeWeave API security facade for outbound webhook registration and delivery. +// The protected-develop route graph lives in app_core.mjs unchanged; this module +// adds one bounded fail-closed destination policy without rewriting tenant/auth, +// billing, attachment, Clearfolio, or project-planning behavior. import { Hono } from 'hono'; -import { readFile } from 'node:fs/promises'; -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 { 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 - -const getOrg = (id) => db.prepare('SELECT * FROM orgs WHERE id = ?').get(id); - -// Append-only audit trail. Never throws into the request path. -function logAudit(orgId, userId, action, targetType, targetId, meta) { - try { - db.prepare('INSERT INTO audit_log(org_id,user_id,action,target_type,target_id,meta) VALUES(?,?,?,?,?,?)') - .run(orgId, userId ?? null, action, targetType ?? null, targetId != null ? String(targetId) : null, meta ? JSON.stringify(meta) : null); - } catch { /* audit must not break the operation */ } -} - -// --- RBAC. Roles (highest→lowest): owner > admin > member > viewer. -const orgRole = (userId, orgId) => - db.prepare('SELECT role FROM memberships WHERE user_id = ? AND org_id = ?').get(userId, orgId)?.role || null; -const canManage = (role) => role === 'owner' || role === 'admin'; -const canWrite = (role) => role === 'owner' || role === 'admin' || role === 'member'; - -export const app = new Hono(); - -async function requireAuth(c, next) { - const header = c.req.header('authorization') || ''; - const token = header.startsWith('Bearer ') ? header.slice(7) : ''; - // Personal Access Token path (swk_...): look up by hash, act as its user. - if (token.startsWith('swk_')) { - const row = db.prepare('SELECT * FROM api_tokens WHERE token_hash = ?').get(hashApiToken(token)); - if (!row) return c.json({ error: 'unauthorized' }, 401); - db.prepare("UPDATE api_tokens SET last_used = datetime('now') WHERE id = ?").run(row.id); - c.set('user', { sub: row.user_id, viaPat: true }); - return next(); - } - try { - const payload = verifyToken(token); - // Session revocation: a bumped token_version invalidates all older JWTs. - const u = db.prepare('SELECT token_version FROM users WHERE id = ?').get(payload.sub); - if (!u || (payload.tv || 0) !== u.token_version) return c.json({ error: 'unauthorized' }, 401); - c.set('user', payload); - } catch { - return c.json({ error: 'unauthorized' }, 401); - } - await next(); +import { app as coreApp } from './app_core.mjs'; +import { + postWebhook, + validateWebhookRegistrationUrl, +} from './webhook_transport.mjs'; + +const WEBHOOK_REGISTRATION_PATH = '/api/orgs/:id/webhooks'; +const webhookFetchBoundaryKey = Symbol.for('scopeweave.webhook-fetch-boundary'); +const nativeFetch = globalThis.fetch.bind(globalThis); + +function isSignedWebhookRequest(request) { + return request.method.toUpperCase() === 'POST' + && Boolean(request.headers.get('x-scopeweave-event')) + && /^sha256=[0-9a-f]{64}$/i.test( + request.headers.get('x-scopeweave-signature') || '', + ); } -// --- realtime: projectId -> Set -const streams = new Map(); -function broadcast(projectId, data) { - const subs = streams.get(String(projectId)); - if (!subs) return; - const chunk = new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`); - for (const ctrl of subs) { - try { ctrl.enqueue(chunk); } catch { /* dropped subscriber */ } +async function protectedWebhookFetch(input, init) { + const request = input instanceof Request + ? new Request(input, init) + : new Request(input, init); + if (!isSignedWebhookRequest(request)) return nativeFetch(input, init); + + const body = request.body + ? new Uint8Array(await request.clone().arrayBuffer()) + : ''; + const result = await postWebhook(request.url, { + headers: Object.fromEntries(request.headers.entries()), + body, + signal: request.signal, + }); + if (result.status >= 200 && result.status <= 599) { + return new Response(null, { status: result.status }); } + return Response.error(); } -// Membership-scoped project fetch — the tenant isolation boundary. -function projectAccess(userId, projectId) { - return db.prepare( - `SELECT p.*, m.role AS memberRole FROM projects p - JOIN memberships m ON m.org_id = p.org_id - WHERE p.id = ? AND m.user_id = ?` - ).get(projectId, userId); +if (!globalThis[webhookFetchBoundaryKey]) { + globalThis.fetch = protectedWebhookFetch; + Object.defineProperty(globalThis, webhookFetchBoundaryKey, { + value: true, + configurable: false, + enumerable: false, + writable: false, + }); } -// --- observability: in-process counters + structured request log. -const metrics = { - startedAt: new Date().toISOString(), - requests: 0, - s2xx: 0, - s4xx: 0, - s5xx: 0, - signups: 0, - projectsCreated: 0, - webhookDeliveries: 0, - attachmentStatusRefreshAttempted: 0, - attachmentStatusRefreshChanged: 0, - attachmentStatusRefreshFailed: 0, - attachmentStatusRefreshDeferred: 0, -}; - -// Outbound webhooks: POST signed JSON to each active hook subscribed to `event`. -// Fire-and-forget with a timeout, one retry on failure, and a recorded outcome -// per attempt — never blocks or fails the triggering request. -function recordDelivery(webhookId, event, status, ok, attempt) { +function canonicalDevelopmentLoopback(value) { + if (process.env.SCOPEWEAVE_DEV !== '1') return null; + let destination; try { - db.prepare('INSERT INTO webhook_deliveries(webhook_id,event,status_code,ok,attempt) VALUES(?,?,?,?,?)') - .run(webhookId, event, status ?? null, ok ? 1 : 0, attempt); - } catch { /* recording must not break delivery */ } -} - -function sendWebhook(webhookId, url, sig, event, body, attempt) { - metrics.webhookDeliveries++; - const ctrl = new AbortController(); - const to = setTimeout(() => ctrl.abort(), 3000); - fetch(url, { - method: 'POST', - headers: { 'content-type': 'application/json', 'x-scopeweave-event': event, 'x-scopeweave-signature': `sha256=${sig}` }, - body, - signal: ctrl.signal, - }).then((res) => { - recordDelivery(webhookId, event, res.status, res.ok, attempt); - if (!res.ok && attempt < 2) setTimeout(() => sendWebhook(webhookId, url, sig, event, body, attempt + 1), 500); - }).catch(() => { - recordDelivery(webhookId, event, null, false, attempt); - if (attempt < 2) setTimeout(() => sendWebhook(webhookId, url, sig, event, body, attempt + 1), 500); - }).finally(() => clearTimeout(to)); + destination = new URL(String(value ?? '')); + } catch { + return null; + } + if (destination.protocol !== 'http:' + || destination.username + || destination.password + || destination.hash) return null; + const host = destination.hostname.toLowerCase(); + const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host); + const loopbackV4 = ipv4 + && Number(ipv4[1]) === 127 + && ipv4.slice(1).every((part) => Number(part) >= 0 && Number(part) <= 255); + if (!(host === 'localhost' || host === '[::1]' || loopbackV4)) return null; + return destination.href; } -function deliver(orgId, event, payload) { - let hooks; - try { - hooks = db.prepare('SELECT id, url, secret, events FROM webhooks WHERE org_id = ? AND active = 1').all(orgId); - } catch { return; } - for (const h of hooks) { - const subs = String(h.events || '').split(',').map((s) => s.trim()); - if (!(subs.includes('*') || subs.includes(event))) continue; - const body = JSON.stringify({ event, orgId: Number(orgId), payload, ts: new Date().toISOString() }); - const sig = createHmac('sha256', h.secret).update(body).digest('hex'); - sendWebhook(h.id, h.url, sig, event, body, 1); - } +function canonicalRegistrationUrl(value) { + const developmentLoopback = canonicalDevelopmentLoopback(value); + return developmentLoopback || validateWebhookRegistrationUrl(value); } -const quietLogs = String(process.env.SCOPEWEAVE_DB || '').includes(':memory:'); // silence during tests -app.use('*', async (c, next) => { - const t = Date.now(); - await next(); - try { - metrics.requests++; - const s = c.res.status; - if (s >= 500) metrics.s5xx++; else if (s >= 400) metrics.s4xx++; else if (s >= 200) metrics.s2xx++; - if (!quietLogs) { - // structured; never logs bodies, tokens, or secrets - console.log(JSON.stringify({ ts: new Date().toISOString(), method: c.req.method, path: c.req.path, status: s, ms: Date.now() - t })); - } - } catch { /* metrics/logging must never break a request */ } -}); -// Rate limiting (opt-in via SCOPEWEAVE_RATE_LIMIT_MAX, per client IP, fixed -// window). Protects against brute-force/abuse. Off by default so it never -// surprises tests/dev. Ceiling: per-instance in-memory → use Redis for multi-node. -const RL_MAX = Number(process.env.SCOPEWEAVE_RATE_LIMIT_MAX) || 0; -const RL_WINDOW_MS = Number(process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS) || 60000; -const rlBuckets = new Map(); -if (RL_MAX > 0) { - app.use('*', async (c, next) => { - const key = (c.req.header('x-forwarded-for') || '').split(',')[0].trim() || 'local'; - const now = Date.now(); - let b = rlBuckets.get(key); - if (!b || b.resetAt <= now) { b = { count: 0, resetAt: now + RL_WINDOW_MS }; rlBuckets.set(key, b); } - b.count++; - if (b.count > RL_MAX) { - const retry = Math.ceil((b.resetAt - now) / 1000); - return c.json({ error: 'rate limit exceeded' }, 429, { 'Retry-After': String(retry) }); - } - await next(); +function requestWithJson(original, payload) { + const headers = new Headers(original.headers); + headers.delete('content-length'); + headers.set('content-type', 'application/json'); + return new Request(original.url, { + method: original.method, + headers, + body: JSON.stringify(payload), + signal: original.signal, }); } -app.post('/api/auth/signup', async (c) => { - const { email, password, name } = await c.req.json().catch(() => ({})); - if (!email || typeof password !== 'string' || password.length < 8) { - return c.json({ error: 'email and password (min 8 chars) required' }, 400); - } - if (db.prepare('SELECT id FROM users WHERE email = ?').get(email)) { - return c.json({ error: 'email already registered' }, 409); - } - // user + personal workspace + owner membership, atomically. - let uid; - const tx = () => { - uid = rowid(db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') - .run(email, hashPassword(password), name || '')); - const oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)') - .run(`${name || email}'s workspace`, uid)); - db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); - }; - db.exec('BEGIN'); - try { tx(); db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw e; } - metrics.signups++; - return c.json({ token: signToken({ sub: uid, email, tv: 0 }) }); -}); - -app.post('/api/auth/login', async (c) => { - const { email, password } = await c.req.json().catch(() => ({})); - const u = db.prepare('SELECT * FROM users WHERE email = ?').get(email || ''); - // Pass password through only when it is a string — verifyPassword rejects - // non-strings (objects/arrays) so they never match an empty-password hash. - if (!u || typeof password !== 'string' || !verifyPassword(password, u.password_hash)) { - return c.json({ error: 'invalid credentials' }, 401); - } - return c.json({ token: signToken({ sub: u.id, email: u.email, tv: u.token_version }) }); -}); - -app.get('/api/me', requireAuth, (c) => { - const uid = c.get('user').sub; - const user = db.prepare('SELECT id,email,name FROM users WHERE id = ?').get(uid); - const orgs = db.prepare( - `SELECT o.id,o.name,o.plan,m.role FROM orgs o - JOIN memberships m ON m.org_id = o.id WHERE m.user_id = ?` - ).all(uid); - return c.json({ user, orgs }); -}); - -// Create an additional workspace (org); the creator becomes its owner. -app.post('/api/orgs', requireAuth, async (c) => { - const uid = c.get('user').sub; - const { name } = await c.req.json().catch(() => ({})); - if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); - let oid; - db.exec('BEGIN'); - try { - oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)').run(String(name).trim().slice(0, 120), uid)); - db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); - db.exec('COMMIT'); - } catch (e) { db.exec('ROLLBACK'); throw e; } - logAudit(oid, uid, 'org.create', 'org', oid, { name }); - return c.json({ id: oid, name: String(name).trim(), role: 'owner' }); -}); - -app.get('/api/projects', requireAuth, (c) => { - const uid = c.get('user').sub; - const projects = db.prepare( - `SELECT p.id,p.name,p.base_date AS baseDate,p.version,p.org_id AS orgId,p.updated_at AS updatedAt,p.archived - FROM projects p JOIN memberships m ON m.org_id = p.org_id - WHERE m.user_id = ? ORDER BY p.archived ASC, p.updated_at DESC` - ).all(uid); - return c.json({ projects }); -}); - -app.post('/api/projects', requireAuth, async (c) => { - const uid = c.get('user').sub; - const { name, orgId } = await c.req.json().catch(() => ({})); - if (!name) return c.json({ error: 'name required' }, 400); - const org = orgId - ? db.prepare('SELECT o.id FROM orgs o JOIN memberships m ON m.org_id = o.id WHERE o.id = ? AND m.user_id = ?').get(orgId, uid) - : db.prepare('SELECT o.id FROM orgs o JOIN memberships m ON m.org_id = o.id WHERE m.user_id = ? ORDER BY o.id LIMIT 1').get(uid); - if (!org) return c.json({ error: 'no accessible org' }, 400); - if (wouldExceed(db, getOrg(org.id), 'projects')) { - return c.json({ error: 'project limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.projects }, 402); - } - const id = rowid(db.prepare('INSERT INTO projects(org_id,name,created_by) VALUES(?,?,?)').run(org.id, name, uid)); - metrics.projectsCreated++; - logAudit(org.id, uid, 'project.create', 'project', id, { name }); - return c.json({ id, name, version: 1 }); -}); - -app.get('/api/projects/:id', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - return c.json({ id: p.id, name: p.name, orgId: p.org_id, baseDate: p.base_date, methodology: p.methodology || 'waterfall', tasks: JSON.parse(p.tasks_json), version: p.version }); -}); - -app.put('/api/projects/:id', requireAuth, async (c) => { - const uid = c.get('user').sub; - const id = c.req.param('id'); - const p = projectAccess(uid, id); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden: viewer role is read-only' }, 403); - const body = await c.req.json().catch(() => ({})); - if (typeof body.version === 'number' && body.version !== p.version) { - return c.json({ error: 'version conflict', current: p.version }, 409); - } - const tasks = Array.isArray(body.tasks) ? body.tasks : JSON.parse(p.tasks_json); - const version = p.version + 1; - const methodology = ['waterfall', 'agile', 'hybrid'].includes(body.methodology) ? body.methodology : (p.methodology || 'waterfall'); - db.prepare( - "UPDATE projects SET name=?, base_date=?, tasks_json=?, version=?, methodology=?, updated_at=datetime('now') WHERE id=?" - ).run(body.name ?? p.name, body.baseDate ?? p.base_date, JSON.stringify(tasks), version, methodology, id); - logAudit(p.org_id, uid, 'project.update', 'project', id, { version, tasks: tasks.length }); - // Revision history: snapshot every save, keep the last 20 per project. +async function registrationPayload(request) { try { - db.prepare('INSERT OR REPLACE INTO project_revisions(project_id,version,name,base_date,tasks_json,saved_by) VALUES(?,?,?,?,?,?)') - .run(id, version, body.name ?? p.name, body.baseDate ?? p.base_date, JSON.stringify(tasks), uid); - db.prepare('DELETE FROM project_revisions WHERE project_id = ? AND version <= ?').run(id, version - 20); - } catch { /* history must not break saves */ } - deliver(p.org_id, 'project.update', { projectId: Number(id), version, tasks: tasks.length, by: uid }); - broadcast(id, { type: 'update', version, by: uid }); - return c.json({ version }); -}); - -// Task comments: discussion bound to a project (optionally a task). All roles -// can read; write roles can post; author or manage can delete. -app.get('/api/projects/:id/comments', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const taskId = c.req.query('taskId'); - const comments = (taskId - ? db.prepare(`SELECT cm.id, cm.task_id AS taskId, cm.body, cm.created_at AS createdAt, cm.user_id AS userId, u.email - FROM comments cm LEFT JOIN users u ON u.id = cm.user_id - WHERE cm.project_id = ? AND cm.task_id = ? ORDER BY cm.id DESC LIMIT 100`).all(p.id, taskId) - : db.prepare(`SELECT cm.id, cm.task_id AS taskId, cm.body, cm.created_at AS createdAt, cm.user_id AS userId, u.email - FROM comments cm LEFT JOIN users u ON u.id = cm.user_id - WHERE cm.project_id = ? ORDER BY cm.id DESC LIMIT 100`).all(p.id)); - return c.json({ comments }); -}); - -app.post('/api/projects/:id/comments', requireAuth, async (c) => { - const uid = c.get('user').sub; - 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 { taskId, body } = await c.req.json().catch(() => ({})); - const text = String(body || '').trim(); - if (!text) return c.json({ error: 'body required' }, 400); - if (text.length > 2000) return c.json({ error: 'comment too long (max 2000)' }, 400); - const cid = rowid(db.prepare('INSERT INTO comments(project_id,task_id,user_id,body) VALUES(?,?,?,?)') - .run(p.id, String(taskId || ''), uid, text)); - logAudit(p.org_id, uid, 'comment.create', 'project', p.id, { commentId: cid, taskId: taskId || null }); - broadcast(p.id, { type: 'comment', commentId: cid, by: uid }); - return c.json({ id: cid }); -}); - -app.delete('/api/projects/:id/comments/:cid', requireAuth, (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const cm = db.prepare('SELECT user_id FROM comments WHERE id = ? AND project_id = ?').get(c.req.param('cid'), p.id); - if (!cm) return c.json({ error: 'not found' }, 404); - if (cm.user_id !== uid && !canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - db.prepare('DELETE FROM comments WHERE id = ?').run(c.req.param('cid')); - return c.json({ ok: true }); -}); - -// Revision history: list, inspect, restore. -app.get('/api/projects/:id/revisions', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const revisions = db.prepare( - `SELECT r.version, r.created_at AS savedAt, u.email AS savedBy FROM project_revisions r - LEFT JOIN users u ON u.id = r.saved_by WHERE r.project_id = ? ORDER BY r.version DESC` - ).all(p.id); - return c.json({ revisions }); -}); - -app.get('/api/projects/:id/revisions/:version', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const r = db.prepare('SELECT version, name, base_date AS baseDate, tasks_json FROM project_revisions WHERE project_id = ? AND version = ?') - .get(p.id, c.req.param('version')); - if (!r) return c.json({ error: 'not found' }, 404); - return c.json({ version: r.version, name: r.name, baseDate: r.baseDate, tasks: JSON.parse(r.tasks_json) }); -}); - -// Restore = write the old snapshot as a NEW version (history stays linear). -app.post('/api/projects/:id/revisions/:version/restore', requireAuth, (c) => { - const uid = c.get('user').sub; - const id = c.req.param('id'); - const p = projectAccess(uid, id); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const r = db.prepare('SELECT name, base_date, tasks_json FROM project_revisions WHERE project_id = ? AND version = ?') - .get(id, c.req.param('version')); - if (!r) return c.json({ error: 'not found' }, 404); - const version = p.version + 1; - db.prepare("UPDATE projects SET name=?, base_date=?, tasks_json=?, version=?, updated_at=datetime('now') WHERE id=?") - .run(r.name, r.base_date, r.tasks_json, version, id); - try { - db.prepare('INSERT OR REPLACE INTO project_revisions(project_id,version,name,base_date,tasks_json,saved_by) VALUES(?,?,?,?,?,?)') - .run(id, version, r.name, r.base_date, r.tasks_json, uid); - } catch { /* history must not break restore */ } - logAudit(p.org_id, uid, 'project.restore', 'project', id, { from: Number(c.req.param('version')), version }); - broadcast(id, { type: 'update', version, by: uid }); - return c.json({ version }); -}); - -// iCalendar feed: planned tasks as all-day VEVENTs — subscribable from -// Google/Outlook. Calendar apps can't send headers, so accept ?token= (same -// pattern + ceiling as /stream). PATs work via the Authorization header. -app.get('/api/projects/:id/calendar.ics', (c) => { - const header = c.req.header('authorization') || ''; - const raw = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); - let uid; - if (raw.startsWith('swk_')) { - const row = db.prepare('SELECT user_id FROM api_tokens WHERE token_hash = ?').get(hashApiToken(raw)); - if (!row) return c.json({ error: 'unauthorized' }, 401); - uid = row.user_id; - } else { - try { uid = verifyToken(raw).sub; } catch { return c.json({ error: 'unauthorized' }, 401); } - } - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - let tasks = []; - try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } - const day = (s) => String(s).replaceAll('-', ''); - const nextDay = (s) => { const d = new Date(s); d.setDate(d.getDate() + 1); return d.toISOString().slice(0, 10).replaceAll('-', ''); }; - const esc = (s) => String(s).replace(/\\/g, '\\\\').replace(/[,;]/g, (m) => `\\${m}`).replace(/\n/g, '\\n'); - const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//ScopeWeave//KO', 'CALSCALE:GREGORIAN', `X-WR-CALNAME:${esc(p.name)}`]; - for (const t of tasks) { - if (!/^\d{4}-\d{2}-\d{2}$/.test(t.plannedStartDate || '') || !/^\d{4}-\d{2}-\d{2}$/.test(t.plannedEndDate || '')) continue; - lines.push( - 'BEGIN:VEVENT', - `UID:scopeweave-${p.id}-${esc(t.id)}`, - `DTSTART;VALUE=DATE:${day(t.plannedStartDate)}`, - `DTEND;VALUE=DATE:${nextDay(t.plannedEndDate)}`, // DTEND is exclusive - `SUMMARY:${esc(t.name || t.task || t.id)}`, - 'END:VEVENT' - ); - } - lines.push('END:VCALENDAR'); - return c.text(lines.join('\r\n') + '\r\n', 200, { - 'content-type': 'text/calendar; charset=utf-8', - 'content-disposition': `attachment; filename="scopeweave-${p.id}.ics"`, - }); -}); - -app.get('/api/projects/:id/stream', (c) => { - // EventSource can't send an Authorization header, so accept a query token - // here only. Ceiling: issue a short-lived stream-scoped token before prod so - // full JWTs don't land in URLs / access logs. - const header = c.req.header('authorization') || ''; - const token = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); - let user; - try { user = verifyToken(token); } catch { return c.json({ error: 'unauthorized' }, 401); } - const id = c.req.param('id'); - if (!projectAccess(user.sub, id)) return c.json({ error: 'not found' }, 404); - const key = String(id); - const stream = new ReadableStream({ - start(controller) { - if (!streams.has(key)) streams.set(key, new Set()); - streams.get(key).add(controller); - controller.enqueue(new TextEncoder().encode(': connected\n\n')); - c.req.raw.signal?.addEventListener('abort', () => { - streams.get(key)?.delete(controller); - try { controller.close(); } catch { /* already closed */ } - }); - }, - }); - return new Response(stream, { - headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }, - }); -}); - -// --------------------------------------------------------------- teams / RBAC -// List members of an org (any member may view the roster). -app.get('/api/orgs/:id/members', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); - const members = db.prepare( - `SELECT u.id, u.email, u.name, m.role FROM memberships m - JOIN users u ON u.id = m.user_id WHERE m.org_id = ? ORDER BY m.id` - ).all(orgId); - const invites = db.prepare( - `SELECT id, email, role, token, created_at AS createdAt FROM invites - WHERE org_id = ? AND accepted_at IS NULL ORDER BY id DESC` - ).all(orgId); - return c.json({ members, invites }); -}); - -// Revoke a pending invite (owner/admin). The token stops working immediately. -app.delete('/api/orgs/:id/invites/:inviteId', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const info = db.prepare('DELETE FROM invites WHERE id = ? AND org_id = ? AND accepted_at IS NULL') - .run(c.req.param('inviteId'), orgId); - if (!info.changes) return c.json({ error: 'not found' }, 404); - logAudit(orgId, uid, 'invite.revoke', 'invite', c.req.param('inviteId'), {}); - return c.json({ ok: true }); -}); - -// Invite by email (owner/admin only). Returns the token (prod: email a link). -app.post('/api/orgs/:id/invites', requireAuth, async (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - const role = orgRole(uid, orgId); - if (!role) return c.json({ error: 'not found' }, 404); - if (!canManage(role)) return c.json({ error: 'forbidden' }, 403); - const body = await c.req.json().catch(() => ({})); - const email = String(body.email || '').trim().toLowerCase(); - const inviteRole = body.role || 'member'; - if (!email) return c.json({ error: 'email required' }, 400); - if (!['admin', 'member', 'viewer'].includes(inviteRole)) return c.json({ error: 'invalid role' }, 400); - const token = randomBytes(24).toString('base64url'); - db.prepare('INSERT INTO invites(org_id,email,role,token,invited_by) VALUES(?,?,?,?,?)') - .run(orgId, email, inviteRole, token, uid); - logAudit(orgId, uid, 'member.invite', 'invite', email, { role: inviteRole }); - return c.json({ token, email, role: inviteRole }); -}); - -// Accept an invite (any authenticated user holding the token). Idempotent. -app.post('/api/invites/:token/accept', requireAuth, (c) => { - const uid = c.get('user').sub; - const inv = db.prepare('SELECT * FROM invites WHERE token = ?').get(c.req.param('token')); - if (!inv || inv.accepted_at) return c.json({ error: 'invalid or used invite' }, 404); - const existing = orgRole(uid, inv.org_id); - if (!existing) { - if (wouldExceed(db, getOrg(inv.org_id), 'members')) { - return c.json({ error: 'member limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.members }, 402); - } - db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(inv.org_id, uid, inv.role); - logAudit(inv.org_id, uid, 'member.join', 'user', uid, { role: inv.role }); - deliver(inv.org_id, 'member.join', { userId: uid, role: inv.role }); - } - db.prepare("UPDATE invites SET accepted_at = datetime('now') WHERE id = ?").run(inv.id); - return c.json({ orgId: inv.org_id, role: existing || inv.role }); -}); - -// Change a member's role (owner/admin). Cannot touch an owner or set owner. -app.patch('/api/orgs/:id/members/:userId', requireAuth, async (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - const targetId = c.req.param('userId'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const body = await c.req.json().catch(() => ({})); - const newRole = body.role; - if (!['admin', 'member', 'viewer'].includes(newRole)) return c.json({ error: 'invalid role' }, 400); - const target = db.prepare('SELECT role FROM memberships WHERE org_id = ? AND user_id = ?').get(orgId, targetId); - if (!target) return c.json({ error: 'not found' }, 404); - if (target.role === 'owner') return c.json({ error: 'cannot change owner role' }, 403); - db.prepare('UPDATE memberships SET role = ? WHERE org_id = ? AND user_id = ?').run(newRole, orgId, targetId); - logAudit(orgId, uid, 'member.role_change', 'user', targetId, { from: target.role, to: newRole }); - return c.json({ userId: Number(targetId), role: newRole }); -}); - -// Remove a member (owner/admin). Cannot remove an owner. -app.delete('/api/orgs/:id/members/:userId', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - const targetId = c.req.param('userId'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const target = db.prepare('SELECT role FROM memberships WHERE org_id = ? AND user_id = ?').get(orgId, targetId); - if (!target) return c.json({ error: 'not found' }, 404); - if (target.role === 'owner') return c.json({ error: 'cannot remove owner' }, 403); - db.prepare('DELETE FROM memberships WHERE org_id = ? AND user_id = ?').run(orgId, targetId); - logAudit(orgId, uid, 'member.remove', 'user', targetId, { role: target.role }); - return c.json({ ok: true }); -}); - -// Leave a workspace voluntarily (any non-owner member). Owners must transfer or -// delete the org instead — an org can never be left ownerless. -app.post('/api/orgs/:id/leave', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - const role = orgRole(uid, orgId); - if (!role) return c.json({ error: 'not found' }, 404); - if (role === 'owner') return c.json({ error: 'owner cannot leave; delete the workspace or transfer ownership' }, 403); - db.prepare('DELETE FROM memberships WHERE org_id = ? AND user_id = ?').run(orgId, uid); - logAudit(orgId, uid, 'member.leave', 'user', uid, { role }); - return c.json({ ok: true }); -}); - -// Transfer workspace ownership to an existing member (owner only). The old -// owner becomes an admin; orgs.owner_id follows. Transactional. -app.post('/api/orgs/:id/transfer', requireAuth, async (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); - const { userId } = await c.req.json().catch(() => ({})); - if (!userId || Number(userId) === Number(uid)) return c.json({ error: 'target member userId required' }, 400); - const target = db.prepare('SELECT role FROM memberships WHERE org_id = ? AND user_id = ?').get(orgId, userId); - if (!target) return c.json({ error: 'target is not a member' }, 404); - db.exec('BEGIN'); - try { - db.prepare("UPDATE memberships SET role = 'owner' WHERE org_id = ? AND user_id = ?").run(orgId, userId); - db.prepare("UPDATE memberships SET role = 'admin' WHERE org_id = ? AND user_id = ?").run(orgId, uid); - db.prepare('UPDATE orgs SET owner_id = ? WHERE id = ?').run(userId, orgId); - db.exec('COMMIT'); - } catch (e) { db.exec('ROLLBACK'); throw e; } - logAudit(orgId, uid, 'org.transfer', 'user', userId, { from: uid }); - return c.json({ ok: true, newOwnerId: Number(userId) }); -}); - -// Rename a workspace (owner only). -app.patch('/api/orgs/:id', requireAuth, async (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); - const { name } = await c.req.json().catch(() => ({})); - if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); - db.prepare('UPDATE orgs SET name = ? WHERE id = ?').run(String(name).trim().slice(0, 120), orgId); - logAudit(orgId, uid, 'org.rename', 'org', orgId, { name }); - return c.json({ id: Number(orgId), name: String(name).trim() }); -}); - -// ------------------------------------------------------------------- billing -app.get('/api/orgs/:id/billing', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); - const org = getOrg(orgId); - const plan = planOf(org); - return c.json({ plan: org.plan, planName: plan.name, priceKrw: plan.priceKrw, limits: plan.limits, usage: orgUsage(db, orgId) }); -}); - -app.post('/api/orgs/:id/checkout', requireAuth, async (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'only the owner can upgrade' }, 403); - const origin = new URL(c.req.url).origin; - const session = await createCheckout({ orgId, origin }); - return c.json(session); -}); - -// Stripe webhook (stub). Live mode should verify the signature with -// STRIPE_WEBHOOK_SECRET before trusting the event — named ceiling. -app.post('/api/stripe/webhook', async (c) => { - const event = await c.req.json().catch(() => ({})); - if (event?.type === 'checkout.session.completed') { - const orgId = event.data?.object?.client_reference_id || event.data?.object?.metadata?.orgId; - if (orgId) db.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").run(orgId); - } - return c.json({ received: true }); -}); - -// Dev-only: simulate a successful checkout upgrading the org to Pro. -// Disabled unless SCOPEWEAVE_DEV=1 (never reachable in production). -app.post('/api/orgs/:id/_dev/activate-pro', requireAuth, (c) => { - if (process.env.SCOPEWEAVE_DEV !== '1') return c.json({ error: 'not found' }, 404); - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); - db.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").run(orgId); - logAudit(orgId, uid, 'billing.upgrade', 'org', orgId, { plan: 'pro', via: 'dev' }); - deliver(orgId, 'billing.upgrade', { plan: 'pro' }); - return c.json({ plan: 'pro' }); -}); - -// ------------------------------------------------- personal access tokens (PAT) -app.get('/api/tokens', requireAuth, (c) => { - const uid = c.get('user').sub; - const tokens = db.prepare( - 'SELECT id, name, prefix, last_used AS lastUsed, created_at AS createdAt FROM api_tokens WHERE user_id = ? ORDER BY id DESC' - ).all(uid); - return c.json({ tokens }); // never the secret or hash -}); - -app.post('/api/tokens', requireAuth, async (c) => { - const uid = c.get('user').sub; - const { name } = await c.req.json().catch(() => ({})); - const t = generateApiToken(); - const id = rowid(db.prepare('INSERT INTO api_tokens(user_id,name,token_hash,prefix) VALUES(?,?,?,?)') - .run(uid, String(name || 'token').slice(0, 60), t.hash, t.prefix)); - // Full secret returned ONCE — never retrievable again. - return c.json({ id, name: name || 'token', prefix: t.prefix, token: t.full }); -}); - -app.delete('/api/tokens/:id', requireAuth, (c) => { - const uid = c.get('user').sub; - const info = db.prepare('DELETE FROM api_tokens WHERE id = ? AND user_id = ?').run(c.req.param('id'), uid); - if (!info.changes) return c.json({ error: 'not found' }, 404); - return c.json({ ok: true }); -}); - -// Audit trail — owner/admin only. Enterprise requirement. -app.get('/api/orgs/:id/audit', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const limit = Math.min(Number(c.req.query('limit')) || 100, 500); - const rows = db.prepare( - `SELECT a.id, a.action, a.target_type AS targetType, a.target_id AS targetId, a.meta, - a.created_at AS createdAt, u.email AS actorEmail - FROM audit_log a LEFT JOIN users u ON u.id = a.user_id - WHERE a.org_id = ? ORDER BY a.id DESC LIMIT ?` - ).all(orgId, limit); - const events = rows.map((r) => ({ ...r, meta: r.meta ? JSON.parse(r.meta) : null })); - if (c.req.query('format') === 'csv') { - // Compliance deliverable. Formula-injection-safe: values that (after optional - // leading whitespace) start with = + - @ | are prefixed with ' so - // spreadsheets treat them as text. Leading whitespace alone used to bypass - // /^[=+\-@|]/ — match the client-side CSV_FORMULA_PREFIX_PATTERN. - const csvCell = (v) => { - let s = v == null ? '' : String(v); - if (/^\s*[=+\-@|]/.test(s)) s = `'${s}`; - return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; - }; - const header = ['id', 'createdAt', 'actorEmail', 'action', 'targetType', 'targetId', 'meta']; - const lines = [header.join(',')]; - for (const e of events) { - lines.push([e.id, e.createdAt, e.actorEmail, e.action, e.targetType, e.targetId, e.meta ? JSON.stringify(e.meta) : ''].map(csvCell).join(',')); - } - return c.text(lines.join('\r\n') + '\r\n', 200, { - 'content-type': 'text/csv; charset=utf-8', - 'content-disposition': `attachment; filename="scopeweave-audit-${orgId}.csv"`, - }); - } - return c.json({ events }); -}); - -// Full workspace export (owner only) — data portability / GDPR. Everything the -// org holds, as one JSON document. -app.get('/api/orgs/:id/export', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'only the owner can export' }, 403); - const org = getOrg(orgId); - const members = db.prepare( - `SELECT u.email, u.name, m.role FROM memberships m JOIN users u ON u.id = m.user_id WHERE m.org_id = ?` - ).all(orgId); - const projects = db.prepare( - 'SELECT id, name, base_date AS baseDate, tasks_json, version, created_at AS createdAt, updated_at AS updatedAt FROM projects WHERE org_id = ?' - ).all(orgId).map((p) => ({ ...p, tasks: JSON.parse(p.tasks_json), tasks_json: undefined })); - const audit = db.prepare( - 'SELECT action, target_type AS targetType, target_id AS targetId, meta, created_at AS createdAt FROM audit_log WHERE org_id = ? ORDER BY id' - ).all(orgId).map((a) => ({ ...a, meta: a.meta ? JSON.parse(a.meta) : null })); - logAudit(orgId, uid, 'org.export', 'org', orgId, { projects: projects.length }); - return c.json({ - exportedAt: new Date().toISOString(), - org: { id: org.id, name: org.name, plan: org.plan }, - members, projects, audit, - }, 200, { 'Content-Disposition': `attachment; filename="scopeweave-org-${orgId}.json"` }); -}); - -// Operational metrics (JSON). Ceiling: expose Prometheus text format + gate -// behind an internal token before prod if scraped externally. -app.get('/api/metrics', (c) => { - const sseActive = [...streams.values()].reduce((n, s) => n + s.size, 0); - const all = { ...metrics, sseActive, uptimeSec: Math.round(process.uptime()) }; - if (c.req.query('format') !== 'prometheus') return c.json(all); - // Prometheus text exposition format (0.0.4) — scrape-ready for Grafana/Alerting. - const gauge = new Set(['sseActive', 'uptimeSec']); - const lines = []; - for (const [k, v] of Object.entries(all)) { - if (typeof v !== 'number') continue; // startedAt etc. - const name = `scopeweave_${k.replace(/([A-Z])/g, '_$1').toLowerCase()}`; - lines.push(`# TYPE ${name} ${gauge.has(k) ? 'gauge' : 'counter'}`, `${name} ${v}`); + const value = JSON.parse(await request.text()); + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + } catch { + return {}; } - return c.text(lines.join('\n') + '\n', 200, { 'content-type': 'text/plain; version=0.0.4; charset=utf-8' }); -}); - -// ------------------------------------------------------------------- webhooks -app.get('/api/orgs/:id/webhooks', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const webhooks = db.prepare( - `SELECT w.id, w.url, w.events, w.active, w.created_at AS createdAt, - (SELECT ok FROM webhook_deliveries d WHERE d.webhook_id = w.id ORDER BY d.id DESC LIMIT 1) AS lastOk, - (SELECT created_at FROM webhook_deliveries d WHERE d.webhook_id = w.id ORDER BY d.id DESC LIMIT 1) AS lastAt - FROM webhooks w WHERE w.org_id = ? ORDER BY w.id DESC` - ).all(orgId); // secret never returned - return c.json({ webhooks }); -}); - -app.post('/api/orgs/:id/webhooks', requireAuth, async (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const { url, events } = await c.req.json().catch(() => ({})); - if (!/^https?:\/\//.test(String(url || ''))) return c.json({ error: 'valid http(s) url required' }, 400); - const secret = `whsec_${randomBytes(24).toString('base64url')}`; - const evs = Array.isArray(events) ? events.join(',') : (events || '*'); - const id = rowid(db.prepare('INSERT INTO webhooks(org_id,url,secret,events) VALUES(?,?,?,?)').run(orgId, url, secret, evs)); - logAudit(orgId, uid, 'webhook.create', 'webhook', id, { url, events: evs }); - return c.json({ id, url, events: evs, secret }); // secret shown once for signature verification -}); - -app.get('/api/orgs/:id/webhooks/:whId/deliveries', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const wh = db.prepare('SELECT id FROM webhooks WHERE id = ? AND org_id = ?').get(c.req.param('whId'), orgId); - if (!wh) return c.json({ error: 'not found' }, 404); - const deliveries = db.prepare( - 'SELECT event, status_code AS statusCode, ok, attempt, created_at AS createdAt FROM webhook_deliveries WHERE webhook_id = ? ORDER BY id DESC LIMIT 50' - ).all(wh.id); - return c.json({ deliveries }); -}); - -// Rotate a webhook's signing secret (leak response / periodic hygiene). The new -// secret is returned ONCE; old signatures stop validating immediately. -app.post('/api/orgs/:id/webhooks/:whId/rotate', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const secret = `whsec_${randomBytes(24).toString('base64url')}`; - const info = db.prepare('UPDATE webhooks SET secret = ? WHERE id = ? AND org_id = ?').run(secret, c.req.param('whId'), orgId); - if (!info.changes) return c.json({ error: 'not found' }, 404); - logAudit(orgId, uid, 'webhook.rotate', 'webhook', c.req.param('whId'), {}); - return c.json({ id: Number(c.req.param('whId')), secret }); // shown once -}); - -app.delete('/api/orgs/:id/webhooks/:whId', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const info = db.prepare('DELETE FROM webhooks WHERE id = ? AND org_id = ?').run(c.req.param('whId'), orgId); - if (!info.changes) return c.json({ error: 'not found' }, 404); - return c.json({ ok: true }); -}); - -// ------------------------------------------------------------ SSO (OIDC) -// Real IdP via env (OIDC_ISSUER/CLIENT_ID/CLIENT_SECRET/REDIRECT_URI). When -// unset, a built-in mock provider makes the whole flow self-contained + testable. -const OIDC = { - issuer: process.env.OIDC_ISSUER, - clientId: process.env.OIDC_CLIENT_ID, - clientSecret: process.env.OIDC_CLIENT_SECRET, - redirectUri: process.env.OIDC_REDIRECT_URI, -}; -const oidcMock = !OIDC.issuer; -const oidcStates = new Map(); // state -> { verifier, exp } -const oidcCodes = new Map(); // mock only: code -> email - -function upsertSsoUser(email) { - let user = db.prepare('SELECT id, email, token_version FROM users WHERE email = ?').get(email); - if (user) return user; - db.exec('BEGIN'); - try { - const uid = rowid(db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') - .run(email, hashPassword(randomBytes(24).toString('hex')), '')); - const oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)').run(`${email}'s workspace`, uid)); - db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); - db.exec('COMMIT'); - metrics.signups++; - return { id: uid, email }; - } catch (e) { db.exec('ROLLBACK'); throw e; } } -app.get('/api/auth/oidc/start', (c) => { - const origin = new URL(c.req.url).origin; - const state = randomBytes(16).toString('hex'); - const verifier = randomBytes(32).toString('base64url'); - const challenge = createHash('sha256').update(verifier).digest('base64url'); - oidcStates.set(state, { verifier, exp: Date.now() + 5 * 60 * 1000 }); - const redirectUri = OIDC.redirectUri || `${origin}/api/auth/oidc/callback`; - if (oidcMock) { - const email = c.req.query('email') || 'sso-user@example.com'; - const u = new URL(`${origin}/api/auth/oidc/mock/authorize`); - u.searchParams.set('state', state); - u.searchParams.set('email', email); - u.searchParams.set('redirect_uri', redirectUri); - return c.redirect(u.toString()); - } - const u = new URL(`${OIDC.issuer.replace(/\/$/, '')}/authorize`); - u.searchParams.set('client_id', OIDC.clientId); - u.searchParams.set('redirect_uri', redirectUri); - u.searchParams.set('response_type', 'code'); - u.searchParams.set('scope', 'openid email profile'); - u.searchParams.set('state', state); - u.searchParams.set('code_challenge', challenge); - u.searchParams.set('code_challenge_method', 'S256'); - return c.redirect(u.toString()); -}); - -// Built-in mock IdP authorize — instantly issues a code (dev/test only). -app.get('/api/auth/oidc/mock/authorize', (c) => { - if (!oidcMock) return c.json({ error: 'mock disabled' }, 404); - const state = c.req.query('state'); - const email = c.req.query('email'); - const redirectUri = c.req.query('redirect_uri'); - const code = randomBytes(16).toString('hex'); - oidcCodes.set(code, email); - const u = new URL(redirectUri); - u.searchParams.set('code', code); - u.searchParams.set('state', state); - return c.redirect(u.toString()); -}); - -app.get('/api/auth/oidc/callback', async (c) => { - const state = c.req.query('state'); - const code = c.req.query('code'); - const s = oidcStates.get(state); - if (!s || s.exp < Date.now()) return c.json({ error: 'invalid or expired state' }, 400); - oidcStates.delete(state); - let email; - if (oidcMock) { - email = oidcCodes.get(code); - oidcCodes.delete(code); - if (!email) return c.json({ error: 'invalid code' }, 400); - } else { - const redirectUri = OIDC.redirectUri || `${new URL(c.req.url).origin}/api/auth/oidc/callback`; - const tokenRes = await fetch(`${OIDC.issuer.replace(/\/$/, '')}/token`, { - method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: OIDC.clientId, client_secret: OIDC.clientSecret, code_verifier: s.verifier }), - }).catch(() => null); - const tok = tokenRes ? await tokenRes.json().catch(() => ({})) : {}; - if (!tok.id_token) return c.json({ error: 'token exchange failed' }, 400); - // Ceiling: verify the id_token signature via the issuer JWKS before prod. - const claims = JSON.parse(Buffer.from(String(tok.id_token).split('.')[1] || '', 'base64url').toString() || '{}'); - email = claims.email; - if (!email) return c.json({ error: 'no email claim' }, 400); - } - const user = upsertSsoUser(email); - const token = signToken({ sub: user.id, email, tv: user.token_version || 0 }); - // Return the token in the URL fragment (not query → not logged); the client - // stores it and cleans the URL. - return c.redirect(`/#token=${token}`); -}); - -// Cross-project search: project names + task names, membership-scoped (tenant -// isolation via the same JOIN as projectAccess). -// ponytail: LIKE over tasks_json text; move to FTS5 if search gets heavy. -app.get('/api/search', requireAuth, (c) => { - const uid = c.get('user').sub; - const q = String(c.req.query('q') || '').trim(); - if (q.length < 2) return c.json({ error: 'query too short (min 2)' }, 400); - const rows = db.prepare( - `SELECT DISTINCT p.id, p.name, p.tasks_json FROM projects p - JOIN memberships m ON m.org_id = p.org_id - WHERE m.user_id = ? AND (p.name LIKE ? OR p.tasks_json LIKE ?) LIMIT 100` - ).all(uid, `%${q}%`, `%${q}%`); - const needle = q.toLowerCase(); - const results = []; - for (const p of rows) { - const hit = { projectId: p.id, projectName: p.name, tasks: [] }; - if (p.name.toLowerCase().includes(needle)) hit.nameMatch = true; - let tasks = []; - try { tasks = JSON.parse(p.tasks_json); } catch { /* skip bad json */ } - for (const t of tasks) { - if (String(t.name || '').toLowerCase().includes(needle)) { - hit.tasks.push({ id: t.id, name: t.name }); - if (hit.tasks.length >= 5) break; - } - } - if (hit.nameMatch || hit.tasks.length) results.push(hit); - if (results.length >= 20) break; - } - return c.json({ query: q, results }); -}); - -// Portfolio dashboard: executive rollup across every project in a workspace — -// weighted planned/actual progress, SPI + status, overdue-task counts. -app.get('/api/orgs/:id/portfolio', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); - const today = new Date().toISOString().slice(0, 10); - const rows = db.prepare( - 'SELECT id, name, base_date AS baseDate, tasks_json, archived, updated_at AS updatedAt FROM projects WHERE org_id = ? ORDER BY archived ASC, updated_at DESC' - ).all(orgId); - const projects = rows.map((p) => { - let tasks = []; - try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } - let wSum = 0, pv = 0, ev = 0, overdue = 0; - for (const t of tasks) { - const w = Number(t.weight) || 1; - wSum += w; - pv += w * ((Number(t.plannedProgress) || 0) / 100); - ev += w * ((Number(t.actualProgress) || 0) / 100); - if (t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100) overdue++; - } - const evm = computeEvm({ pv: wSum ? pv / wSum : 0, ev: wSum ? ev / wSum : 0 }); - return { - id: p.id, - name: p.name, - archived: Boolean(p.archived), - tasks: tasks.length, - planned: Math.round(evm.pv * 1000) / 10, // % - actual: Math.round(evm.ev * 1000) / 10, // % - spi: evm.spi === null ? null : Math.round(evm.spi * 100) / 100, - status: evm.status, - label: evm.label, - overdue, - updatedAt: p.updatedAt, - }; - }); - return c.json({ projects }); -}); - -// AI 브리핑: 프로젝트 스냅샷(요약 지표 + 지연/차주 작업)을 contextual- -// orchestrator(LLM)로 보내 경영진용 리스크 분석을 생성. 원문 데이터는 서버가 -// 요약해 전송하며, LLM 자격은 서버 환경변수에만 존재. -app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - let tasks = []; - try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } - const today = new Date().toISOString().slice(0, 10); - let wSum = 0, pv = 0, ev = 0; - const late = [], upcoming = []; - for (const t of tasks) { - const w = Number(t.weight) || 1; - wSum += w; - pv += w * ((Number(t.plannedProgress) || 0) / 100); - ev += w * ((Number(t.actualProgress) || 0) / 100); - const name = t.name || t.task || t.activity || t.phase || t.id; - if (t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100) { - late.push(`${name}(계획종료 ${t.plannedEndDate}, 실적 ${Number(t.actualProgress) || 0}%${t.owner ? `, ${t.owner}` : ''})`); - } else if (t.plannedStartDate && t.plannedStartDate >= today) { - upcoming.push(`${name}(${t.plannedStartDate} 시작)`); - } - } - const pvPct = wSum ? ((pv / wSum) * 100).toFixed(1) : '0'; - const evPct = wSum ? ((ev / wSum) * 100).toFixed(1) : '0'; - const context = [ - `프로젝트: ${p.name}`, - `작업 수: ${tasks.length} · 계획진척 ${pvPct}% · 실적진척 ${evPct}%`, - `지연 작업(${late.length}): ${late.slice(0, 8).join(' / ') || '없음'}`, - `예정 작업(${upcoming.length}): ${upcoming.slice(0, 5).join(' / ') || '없음'}`, - ].join('\n'); - try { - const analysis = await orchestratorChat([ - { role: 'system', content: '너는 공정관리(schedule control) 전문가다. 주어진 프로젝트 지표를 근거로 한국어 경영진 브리핑을 작성하라: ①일정 상태 한 줄 판정 ②핵심 리스크 2~3개(근거 지표 인용) ③실행 권고 2~3개. 지표에 없는 사실은 만들지 마라.' }, - { role: 'user', content: context }, - ], { - service: 'scopeweave', - account: String(p.org_id), - }); - logAudit(p.org_id, uid, 'ai.brief', 'project', p.id, { tasks: tasks.length }); - return c.json({ analysis }); - } catch (e) { - return c.json({ error: `AI 분석 실패: ${e.message}` }, 502); - } -}); - -// 산출물 첨부(Clearfolio 통합 문서 뷰어 프록시): 업로드→변환 잡, 목록(+상태 -// 갱신), 서명 아티팩트 열람(302), 삭제. 테넌트 = 조직, 브라우저에는 Clearfolio -// 자격이 절대 노출되지 않음. -const ATTACH_MAX_BYTES = 10 * 1024 * 1024; - -const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY, -); -const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, -); -const ATTACH_STATUS_BUDGET_MS = normalizeAttachmentStatusBudgetMs( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS, -); -const ATTACHMENT_LIST_COLUMNS = `a.id, a.task_id AS taskId, a.name, a.mime, a.size, - a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy`; -const ATTACHMENT_LIST_FROM = - 'FROM attachments a LEFT JOIN users u ON u.id = a.created_by'; -const listAttachmentsStatement = db.prepare( - `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} - WHERE a.project_id = ? ORDER BY a.id DESC`, -); -const listTaskAttachmentsStatement = db.prepare( - `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} - WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`, -); -const updateAttachmentStatusStatement = db.prepare( - 'UPDATE attachments SET status = ? WHERE id = ?', -); -app.post('/api/projects/:id/attachments', requireAuth, async (c) => { - const uid = c.get('user').sub; - 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 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); - const taskId = String(form.get('taskId') || ''); - if (/\.(hwp|hwpx)$/i.test(file.name || '')) return c.json({ error: 'HWP/HWPX는 지원되지 않습니다 (Clearfolio 정책)' }, 400); - if (file.size > ATTACH_MAX_BYTES) return c.json({ error: 'file too large (max 10MB)' }, 400); - const bytes = Buffer.from(await file.arrayBuffer()); - let job; +async function registrationPolicyResponse(c) { + const payload = await registrationPayload(c.req.raw); + let canonicalUrl; try { - job = await submitJob(p.org_id, uid, { name: file.name || 'document', mime: file.type || '', bytes }); - } catch (e) { - return c.json({ error: `문서 변환 제출 실패: ${e.message}` }, 502); - } - const aid = rowid(db.prepare( - 'INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)' - ).run(p.id, taskId, file.name || 'document', file.type || '', file.size, job.jobId, job.status, uid)); - logAudit(p.org_id, uid, 'attachment.upload', 'project', p.id, { attachmentId: aid, name: file.name, taskId: taskId || null }); - return c.json({ id: aid, status: job.status }); -}); - -app.get('/api/projects/:id/attachments', requireAuth, async (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - - const taskId = c.req.query('taskId'); - const rows = taskId - ? listTaskAttachmentsStatement.all(p.id, taskId) - : listAttachmentsStatement.all(p.id); - await refreshAttachmentStatuses(rows, { - orgId: p.org_id, - userId: uid, - jobStatus, - updateStatus: (status, attachmentId) => - updateAttachmentStatusStatement.run(status, attachmentId), - concurrency: ATTACH_STATUS_CONCURRENCY, - timeoutMs: ATTACH_STATUS_TIMEOUT_MS, - budgetMs: ATTACH_STATUS_BUDGET_MS, - metrics, - }); - const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); - return c.json({ attachments }); -}); - -// 열람: 서명 아티팩트 URL로 302. 새 탭 열기용으로 ?token=도 허용(ics/stream 패턴). -app.get('/api/projects/:id/attachments/:aid/view', (c) => { - const header = c.req.header('authorization') || ''; - const raw = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); - let uid; - if (raw.startsWith('swk_')) { - const row = db.prepare('SELECT user_id FROM api_tokens WHERE token_hash = ?').get(hashApiToken(raw)); - if (!row) return c.json({ error: 'unauthorized' }, 401); - uid = row.user_id; - } else { - try { - const payload = verifyToken(raw); - const u = db.prepare('SELECT token_version FROM users WHERE id = ?').get(payload.sub); - if (!u || (payload.tv || 0) !== u.token_version) return c.json({ error: 'unauthorized' }, 401); - uid = payload.sub; - } catch { return c.json({ error: 'unauthorized' }, 401); } - } - const p = projectAccess(uid, c.req.param('id')); - 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); - return artifactUrl(p.org_id, uid, a.job_id) - .then((url) => c.redirect(url)) - .catch((e) => c.json({ error: `열람 링크 발급 실패: ${e.message}` }, 502)); -}); - -app.delete('/api/projects/:id/attachments/:aid', requireAuth, (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const a = db.prepare('SELECT created_by 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.created_by !== uid && !canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - db.prepare('DELETE FROM attachments WHERE id = ?').run(c.req.param('aid')); - logAudit(p.org_id, uid, 'attachment.delete', 'project', p.id, { attachmentId: Number(c.req.param('aid')) }); - return c.json({ ok: true }); -}); - -// mock Clearfolio 아티팩트 서빙(dev/test 전용) -if (clearfolioMock) { - app.get('/api/mock-clearfolio/:jobId', (c) => { - const doc = mockArtifact(c.req.param('jobId')); - if (!doc) return c.json({ error: 'not found' }, 404); - return c.body(doc.bytes, 200, { - 'content-type': doc.mime || 'application/octet-stream', - 'content-disposition': `inline; filename="${encodeURIComponent(doc.name)}"`, - }); - }); + canonicalUrl = canonicalRegistrationUrl(payload.url); + } catch { + // Preserve the core route's authentication/rate-limit/tenant precedence by + // executing exactly one side-effect-free invalid-registration request. An + // authorized manager deterministically reaches the legacy URL guard (400); + // all earlier 401/403/429 outcomes are returned unchanged. + const probe = await coreApp.fetch(requestWithJson(c.req.raw, { + url: '', + events: payload.events, + })); + if (probe.status !== 400) return probe; + const probeBody = await probe.clone().json().catch(() => null); + if (probeBody?.error !== 'valid http(s) url required') return probe; + return c.json({ error: 'valid public https webhook URL required' }, 400); + } + + return coreApp.fetch(requestWithJson(c.req.raw, { + ...payload, + url: canonicalUrl, + })); } -// Public read-only share links: a random token grants VIEW access to one -// project (no account needed) — revocable. Never exposes org/member data. -app.post('/api/projects/:id/shares', requireAuth, (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const token = randomBytes(18).toString('base64url'); - db.prepare('INSERT INTO share_tokens(project_id, token, created_by) VALUES(?,?,?)').run(p.id, token, uid); - logAudit(p.org_id, uid, 'share.create', 'project', p.id, {}); - return c.json({ token, url: `/?share=${token}` }); -}); - -app.get('/api/projects/:id/shares', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const shares = db.prepare( - 'SELECT id, token, created_at AS createdAt FROM share_tokens WHERE project_id = ? AND revoked = 0 ORDER BY id DESC' - ).all(p.id); - return c.json({ shares }); -}); - -app.delete('/api/projects/:id/shares/:sid', requireAuth, (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const info = db.prepare('UPDATE share_tokens SET revoked = 1 WHERE id = ? AND project_id = ? AND revoked = 0') - .run(c.req.param('sid'), p.id); - if (!info.changes) return c.json({ error: 'not found' }, 404); - logAudit(p.org_id, uid, 'share.revoke', 'project', p.id, { shareId: Number(c.req.param('sid')) }); - return c.json({ ok: true }); -}); - -// Anonymous read via share token — project content only. -app.get('/api/shared/:token', (c) => { - const row = db.prepare( - `SELECT p.name, p.base_date AS baseDate, p.tasks_json FROM share_tokens s - JOIN projects p ON p.id = s.project_id WHERE s.token = ? AND s.revoked = 0` - ).get(c.req.param('token')); - if (!row) return c.json({ error: 'not found' }, 404); - return c.json({ name: row.name, baseDate: row.baseDate, tasks: JSON.parse(row.tasks_json), readOnly: true }); -}); - -// Unseen-activity notifications: per project, count others' saves + comments -// newer than my last-seen mark. Opening a project marks it seen. -app.get('/api/notifications', requireAuth, (c) => { - const uid = c.get('user').sub; - const rows = db.prepare( - `SELECT p.id AS projectId, - (SELECT COUNT(*) FROM project_revisions r WHERE r.project_id = p.id - AND r.saved_by IS NOT NULL AND r.saved_by != ? - AND r.created_at > COALESCE(s.seen_at, '')) AS revisions, - (SELECT COUNT(*) FROM comments cm WHERE cm.project_id = p.id - AND cm.user_id IS NOT NULL AND cm.user_id != ? - AND cm.created_at > COALESCE(s.seen_at, '')) AS comments - FROM projects p - JOIN memberships m ON m.org_id = p.org_id AND m.user_id = ? - LEFT JOIN project_seen s ON s.project_id = p.id AND s.user_id = ?` - ).all(uid, uid, uid, uid); - const notifications = rows - .map((r) => ({ projectId: r.projectId, unseen: r.revisions + r.comments })) - .filter((r) => r.unseen > 0); - return c.json({ notifications }); -}); - -app.post('/api/projects/:id/seen', requireAuth, (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - db.prepare(`INSERT INTO project_seen(project_id, user_id, seen_at) VALUES(?, ?, datetime('now')) - ON CONFLICT(project_id, user_id) DO UPDATE SET seen_at = datetime('now')`).run(p.id, uid); - return c.json({ ok: true }); -}); - -// Archive / restore a project (write roles): declutter without deleting. -app.post('/api/projects/:id/archive', requireAuth, async (c) => { - const uid = c.get('user').sub; - 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 { archived } = await c.req.json().catch(() => ({})); - const flag = archived === false ? 0 : 1; - db.prepare('UPDATE projects SET archived = ? WHERE id = ?').run(flag, p.id); - logAudit(p.org_id, uid, flag ? 'project.archive' : 'project.unarchive', 'project', p.id, {}); - return c.json({ id: p.id, archived: Boolean(flag) }); -}); - -// Duplicate a project (template use: copy tasks + base date into a new project -// in the same org). Plan caps apply like any create. -app.post('/api/projects/:id/duplicate', requireAuth, async (c) => { - const uid = c.get('user').sub; - 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); - if (wouldExceed(db, getOrg(p.org_id), 'projects')) { - return c.json({ error: 'project limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.projects }, 402); - } - const { name } = await c.req.json().catch(() => ({})); - const newName = String(name || `${p.name} (복사본)`).slice(0, 120); - const nid = rowid(db.prepare('INSERT INTO projects(org_id,name,base_date,tasks_json,created_by) VALUES(?,?,?,?,?)') - .run(p.org_id, newName, p.base_date, p.tasks_json, uid)); - metrics.projectsCreated++; - logAudit(p.org_id, uid, 'project.duplicate', 'project', nid, { from: p.id, name: newName }); - return c.json({ id: nid, name: newName, version: 1 }); -}); - -// -------------------------------------------------------------- sprints -// Agile/Hybrid: 시간상자(스프린트) CRUD. 작업은 task.sprint(이름)로 배정되고 -// task.storyPoints로 추정된다 — 지표(커밋/완료 포인트, 벨로시티)는 클라이언트 -// 순수 함수(computeSprintStats)가 계산한다. -app.post('/api/projects/:id/sprints', requireAuth, async (c) => { - const uid = c.get('user').sub; - 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 { name, startDate, endDate, goal } = await c.req.json().catch(() => ({})); - if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); - const day = (v) => (/^\d{4}-\d{2}-\d{2}$/.test(String(v || '')) ? v : ''); - const sid = rowid(db.prepare('INSERT INTO sprints(project_id,name,start_date,end_date,goal) VALUES(?,?,?,?,?)') - .run(p.id, String(name).trim().slice(0, 80), day(startDate), day(endDate), String(goal || '').slice(0, 300))); - logAudit(p.org_id, uid, 'sprint.create', 'project', p.id, { sprintId: sid, name }); - return c.json({ id: sid, name: String(name).trim() }); -}); - -app.get('/api/projects/:id/sprints', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const sprints = db.prepare( - 'SELECT id, name, start_date AS startDate, end_date AS endDate, goal FROM sprints WHERE project_id = ? ORDER BY start_date, id' - ).all(p.id); - return c.json({ sprints, methodology: p.methodology || 'waterfall' }); -}); - -app.delete('/api/projects/:id/sprints/:sid', requireAuth, (c) => { - const uid = c.get('user').sub; - 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 info = db.prepare('DELETE FROM sprints WHERE id = ? AND project_id = ?').run(c.req.param('sid'), p.id); - if (!info.changes) return c.json({ error: 'not found' }, 404); - return c.json({ ok: true }); -}); - -// ------------------------------------------------------------- baselines -// Snapshot a project's current plan as a named baseline (schedule-control: -// compare actuals against the frozen plan later). -app.post('/api/projects/:id/baselines', requireAuth, async (c) => { - const uid = c.get('user').sub; - const id = c.req.param('id'); - const p = projectAccess(uid, id); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const { name } = await c.req.json().catch(() => ({})); - const bid = rowid(db.prepare('INSERT INTO baselines(project_id,name,base_date,tasks_json,created_by) VALUES(?,?,?,?,?)') - .run(id, String(name || 'Baseline').slice(0, 80), p.base_date, p.tasks_json, uid)); - logAudit(p.org_id, uid, 'baseline.create', 'project', id, { baselineId: bid, name }); - return c.json({ id: bid, name: name || 'Baseline' }); -}); - -app.get('/api/projects/:id/baselines', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const baselines = db.prepare( - 'SELECT id, name, base_date AS baseDate, created_at AS createdAt FROM baselines WHERE project_id = ? ORDER BY id DESC' - ).all(p.id); - return c.json({ baselines }); -}); - -app.get('/api/projects/:id/baselines/:bid', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const b = db.prepare('SELECT id, name, base_date AS baseDate, tasks_json, created_at AS createdAt FROM baselines WHERE id = ? AND project_id = ?').get(c.req.param('bid'), p.id); - if (!b) return c.json({ error: 'not found' }, 404); - return c.json({ id: b.id, name: b.name, baseDate: b.baseDate, tasks: JSON.parse(b.tasks_json), createdAt: b.createdAt }); -}); - -app.delete('/api/projects/:id/baselines/:bid', requireAuth, (c) => { - const uid = c.get('user').sub; - 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 info = db.prepare('DELETE FROM baselines WHERE id = ? AND project_id = ?').run(c.req.param('bid'), p.id); - if (!info.changes) return c.json({ error: 'not found' }, 404); - return c.json({ ok: true }); -}); - -// ------------------------------------------------------ account & lifecycle -// Delete a project (write roles). tasks live in the row, so this fully removes it. -app.delete('/api/projects/:id', requireAuth, (c) => { - const uid = c.get('user').sub; - const id = c.req.param('id'); - const p = projectAccess(uid, id); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - db.prepare('DELETE FROM projects WHERE id = ?').run(id); - logAudit(p.org_id, uid, 'project.delete', 'project', id, { name: p.name }); - deliver(p.org_id, 'project.delete', { projectId: Number(id) }); - return c.json({ ok: true }); -}); - -// Log out everywhere: bump token_version → every existing JWT dies. Returns a -// fresh token so THIS device stays signed in. PATs are unaffected. -app.post('/api/auth/logout-all', requireAuth, (c) => { - const uid = c.get('user').sub; - db.prepare('UPDATE users SET token_version = token_version + 1 WHERE id = ?').run(uid); - const u = db.prepare('SELECT email, token_version FROM users WHERE id = ?').get(uid); - return c.json({ ok: true, token: signToken({ sub: uid, email: u.email, tv: u.token_version }) }); -}); - -// Change password (verifies the current one). -app.post('/api/auth/change-password', requireAuth, async (c) => { - const uid = c.get('user').sub; - const { oldPassword, newPassword } = await c.req.json().catch(() => ({})); - if (typeof newPassword !== 'string' || newPassword.length < 8) return c.json({ error: 'new password (min 8) required' }, 400); - const u = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(uid); - if (!u || typeof oldPassword !== 'string' || !verifyPassword(oldPassword, u.password_hash)) { - return c.json({ error: 'current password incorrect' }, 403); - } - db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(newPassword), uid); - return c.json({ ok: true }); -}); - -// Delete account (GDPR). Removes owned workspaces (cascading their data) and the -// user. Requires the current password to confirm. -app.delete('/api/account', requireAuth, async (c) => { - const uid = c.get('user').sub; - const { password } = await c.req.json().catch(() => ({})); - const u = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(uid); - if (!u || typeof password !== 'string' || !verifyPassword(password, u.password_hash)) { - return c.json({ error: 'password required to delete account' }, 403); - } - db.exec('BEGIN'); - try { - db.prepare('DELETE FROM orgs WHERE owner_id = ?').run(uid); // cascades projects/members/webhooks/invites/audit - db.prepare('DELETE FROM users WHERE id = ?').run(uid); // cascades memberships/tokens - db.exec('COMMIT'); - } catch (e) { db.exec('ROLLBACK'); throw e; } - return c.json({ ok: true }); -}); - -app.get('/api/health', (c) => c.json({ ok: true })); - -// Static client — strict allowlist so server/, data.db, package.json etc. are -// never served. Anything not listed → 404. -const STATIC = { - '/': ['index.html', 'text/html; charset=utf-8'], - '/index.html': ['index.html', 'text/html; charset=utf-8'], - '/404.html': ['404.html', 'text/html; charset=utf-8'], - '/landing.html': ['landing.html', 'text/html; charset=utf-8'], - '/landing.en.html': ['landing.en.html', 'text/html; charset=utf-8'], - '/docs/api.md': ['docs/api.md', 'text/markdown; charset=utf-8'], - '/robots.txt': ['robots.txt', 'text/plain; charset=utf-8'], - '/sitemap.xml': ['sitemap.xml', 'application/xml; charset=utf-8'], - '/pricing': ['landing.html', 'text/html; charset=utf-8'], - '/app.js': ['app.js', 'text/javascript; charset=utf-8'], - '/cloud-sync.js': ['cloud-sync.js', 'text/javascript; charset=utf-8'], - '/analytics.js': ['analytics.js', 'text/javascript; charset=utf-8'], - '/styles.css': ['styles.css', 'text/css; charset=utf-8'], - '/toast-state.css': ['toast-state.css', 'text/css; charset=utf-8'], - '/wbs.json': ['wbs.json', 'application/json; charset=utf-8'], -}; -app.get('*', async (c) => { - const entry = STATIC[c.req.path]; - if (!entry) return c.notFound(); - try { - const buf = await readFile(new URL(`../${entry[0]}`, import.meta.url)); - return c.body(buf, 200, { 'Content-Type': entry[1] }); - } catch { - return c.notFound(); - } +/** + * Public ScopeWeave HTTP application with fail-closed outbound webhook policy. + * All non-registration routes are delegated unchanged to the protected-develop + * core application; webhook POST registration is canonicalized before storage. + */ +export const app = new Hono(); +app.use(WEBHOOK_REGISTRATION_PATH, async (c, next) => { + if (c.req.method !== 'POST') return next(); + return registrationPolicyResponse(c); }); +app.route('/', coreApp); diff --git a/server/app_core.mjs b/server/app_core.mjs new file mode 100644 index 00000000..c432a84f --- /dev/null +++ b/server/app_core.mjs @@ -0,0 +1,1410 @@ +// ScopeWeave SaaS API. Multi-tenant (org-scoped), optimistic concurrency on +// project docs, SSE realtime fan-out per project. The existing static client +// (index.html/app.js) becomes the frontend that talks to these routes. +import { Hono } from 'hono'; +import { readFile } from 'node:fs/promises'; +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 { 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 + +const getOrg = (id) => db.prepare('SELECT * FROM orgs WHERE id = ?').get(id); + +// Append-only audit trail. Never throws into the request path. +function logAudit(orgId, userId, action, targetType, targetId, meta) { + try { + db.prepare('INSERT INTO audit_log(org_id,user_id,action,target_type,target_id,meta) VALUES(?,?,?,?,?,?)') + .run(orgId, userId ?? null, action, targetType ?? null, targetId != null ? String(targetId) : null, meta ? JSON.stringify(meta) : null); + } catch { /* audit must not break the operation */ } +} + +// --- RBAC. Roles (highest→lowest): owner > admin > member > viewer. +const orgRole = (userId, orgId) => + db.prepare('SELECT role FROM memberships WHERE user_id = ? AND org_id = ?').get(userId, orgId)?.role || null; +const canManage = (role) => role === 'owner' || role === 'admin'; +const canWrite = (role) => role === 'owner' || role === 'admin' || role === 'member'; + +export const app = new Hono(); + +async function requireAuth(c, next) { + const header = c.req.header('authorization') || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : ''; + // Personal Access Token path (swk_...): look up by hash, act as its user. + if (token.startsWith('swk_')) { + const row = db.prepare('SELECT * FROM api_tokens WHERE token_hash = ?').get(hashApiToken(token)); + if (!row) return c.json({ error: 'unauthorized' }, 401); + db.prepare("UPDATE api_tokens SET last_used = datetime('now') WHERE id = ?").run(row.id); + c.set('user', { sub: row.user_id, viaPat: true }); + return next(); + } + try { + const payload = verifyToken(token); + // Session revocation: a bumped token_version invalidates all older JWTs. + const u = db.prepare('SELECT token_version FROM users WHERE id = ?').get(payload.sub); + if (!u || (payload.tv || 0) !== u.token_version) return c.json({ error: 'unauthorized' }, 401); + c.set('user', payload); + } catch { + return c.json({ error: 'unauthorized' }, 401); + } + await next(); +} + +// --- realtime: projectId -> Set +const streams = new Map(); +function broadcast(projectId, data) { + const subs = streams.get(String(projectId)); + if (!subs) return; + const chunk = new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`); + for (const ctrl of subs) { + try { ctrl.enqueue(chunk); } catch { /* dropped subscriber */ } + } +} + +// Membership-scoped project fetch — the tenant isolation boundary. +function projectAccess(userId, projectId) { + return db.prepare( + `SELECT p.*, m.role AS memberRole FROM projects p + JOIN memberships m ON m.org_id = p.org_id + WHERE p.id = ? AND m.user_id = ?` + ).get(projectId, userId); +} + +// --- observability: in-process counters + structured request log. +const metrics = { + startedAt: new Date().toISOString(), + requests: 0, + s2xx: 0, + s4xx: 0, + s5xx: 0, + signups: 0, + projectsCreated: 0, + webhookDeliveries: 0, + attachmentStatusRefreshAttempted: 0, + attachmentStatusRefreshChanged: 0, + attachmentStatusRefreshFailed: 0, + attachmentStatusRefreshDeferred: 0, +}; + +// Outbound webhooks: POST signed JSON to each active hook subscribed to `event`. +// Fire-and-forget with a timeout, one retry on failure, and a recorded outcome +// per attempt — never blocks or fails the triggering request. +function recordDelivery(webhookId, event, status, ok, attempt) { + try { + db.prepare('INSERT INTO webhook_deliveries(webhook_id,event,status_code,ok,attempt) VALUES(?,?,?,?,?)') + .run(webhookId, event, status ?? null, ok ? 1 : 0, attempt); + } catch { /* recording must not break delivery */ } +} + +function sendWebhook(webhookId, url, sig, event, body, attempt) { + metrics.webhookDeliveries++; + const ctrl = new AbortController(); + const to = setTimeout(() => ctrl.abort(), 3000); + fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-scopeweave-event': event, 'x-scopeweave-signature': `sha256=${sig}` }, + body, + signal: ctrl.signal, + }).then((res) => { + recordDelivery(webhookId, event, res.status, res.ok, attempt); + if (!res.ok && attempt < 2) setTimeout(() => sendWebhook(webhookId, url, sig, event, body, attempt + 1), 500); + }).catch(() => { + recordDelivery(webhookId, event, null, false, attempt); + if (attempt < 2) setTimeout(() => sendWebhook(webhookId, url, sig, event, body, attempt + 1), 500); + }).finally(() => clearTimeout(to)); +} + +function deliver(orgId, event, payload) { + let hooks; + try { + hooks = db.prepare('SELECT id, url, secret, events FROM webhooks WHERE org_id = ? AND active = 1').all(orgId); + } catch { return; } + for (const h of hooks) { + const subs = String(h.events || '').split(',').map((s) => s.trim()); + if (!(subs.includes('*') || subs.includes(event))) continue; + const body = JSON.stringify({ event, orgId: Number(orgId), payload, ts: new Date().toISOString() }); + const sig = createHmac('sha256', h.secret).update(body).digest('hex'); + sendWebhook(h.id, h.url, sig, event, body, 1); + } +} +const quietLogs = String(process.env.SCOPEWEAVE_DB || '').includes(':memory:'); // silence during tests +app.use('*', async (c, next) => { + const t = Date.now(); + await next(); + try { + metrics.requests++; + const s = c.res.status; + if (s >= 500) metrics.s5xx++; else if (s >= 400) metrics.s4xx++; else if (s >= 200) metrics.s2xx++; + if (!quietLogs) { + // structured; never logs bodies, tokens, or secrets + console.log(JSON.stringify({ ts: new Date().toISOString(), method: c.req.method, path: c.req.path, status: s, ms: Date.now() - t })); + } + } catch { /* metrics/logging must never break a request */ } +}); + +// Rate limiting (opt-in via SCOPEWEAVE_RATE_LIMIT_MAX, per client IP, fixed +// window). Protects against brute-force/abuse. Off by default so it never +// surprises tests/dev. Ceiling: per-instance in-memory → use Redis for multi-node. +const RL_MAX = Number(process.env.SCOPEWEAVE_RATE_LIMIT_MAX) || 0; +const RL_WINDOW_MS = Number(process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS) || 60000; +const rlBuckets = new Map(); +if (RL_MAX > 0) { + app.use('*', async (c, next) => { + const key = (c.req.header('x-forwarded-for') || '').split(',')[0].trim() || 'local'; + const now = Date.now(); + let b = rlBuckets.get(key); + if (!b || b.resetAt <= now) { b = { count: 0, resetAt: now + RL_WINDOW_MS }; rlBuckets.set(key, b); } + b.count++; + if (b.count > RL_MAX) { + const retry = Math.ceil((b.resetAt - now) / 1000); + return c.json({ error: 'rate limit exceeded' }, 429, { 'Retry-After': String(retry) }); + } + await next(); + }); +} + +app.post('/api/auth/signup', async (c) => { + const { email, password, name } = await c.req.json().catch(() => ({})); + if (!email || typeof password !== 'string' || password.length < 8) { + return c.json({ error: 'email and password (min 8 chars) required' }, 400); + } + if (db.prepare('SELECT id FROM users WHERE email = ?').get(email)) { + return c.json({ error: 'email already registered' }, 409); + } + // user + personal workspace + owner membership, atomically. + let uid; + const tx = () => { + uid = rowid(db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') + .run(email, hashPassword(password), name || '')); + const oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)') + .run(`${name || email}'s workspace`, uid)); + db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); + }; + db.exec('BEGIN'); + try { tx(); db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw e; } + metrics.signups++; + return c.json({ token: signToken({ sub: uid, email, tv: 0 }) }); +}); + +app.post('/api/auth/login', async (c) => { + const { email, password } = await c.req.json().catch(() => ({})); + const u = db.prepare('SELECT * FROM users WHERE email = ?').get(email || ''); + // Pass password through only when it is a string — verifyPassword rejects + // non-strings (objects/arrays) so they never match an empty-password hash. + if (!u || typeof password !== 'string' || !verifyPassword(password, u.password_hash)) { + return c.json({ error: 'invalid credentials' }, 401); + } + return c.json({ token: signToken({ sub: u.id, email: u.email, tv: u.token_version }) }); +}); + +app.get('/api/me', requireAuth, (c) => { + const uid = c.get('user').sub; + const user = db.prepare('SELECT id,email,name FROM users WHERE id = ?').get(uid); + const orgs = db.prepare( + `SELECT o.id,o.name,o.plan,m.role FROM orgs o + JOIN memberships m ON m.org_id = o.id WHERE m.user_id = ?` + ).all(uid); + return c.json({ user, orgs }); +}); + +// Create an additional workspace (org); the creator becomes its owner. +app.post('/api/orgs', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { name } = await c.req.json().catch(() => ({})); + if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); + let oid; + db.exec('BEGIN'); + try { + oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)').run(String(name).trim().slice(0, 120), uid)); + db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); + db.exec('COMMIT'); + } catch (e) { db.exec('ROLLBACK'); throw e; } + logAudit(oid, uid, 'org.create', 'org', oid, { name }); + return c.json({ id: oid, name: String(name).trim(), role: 'owner' }); +}); + +app.get('/api/projects', requireAuth, (c) => { + const uid = c.get('user').sub; + const projects = db.prepare( + `SELECT p.id,p.name,p.base_date AS baseDate,p.version,p.org_id AS orgId,p.updated_at AS updatedAt,p.archived + FROM projects p JOIN memberships m ON m.org_id = p.org_id + WHERE m.user_id = ? ORDER BY p.archived ASC, p.updated_at DESC` + ).all(uid); + return c.json({ projects }); +}); + +app.post('/api/projects', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { name, orgId } = await c.req.json().catch(() => ({})); + if (!name) return c.json({ error: 'name required' }, 400); + const org = orgId + ? db.prepare('SELECT o.id FROM orgs o JOIN memberships m ON m.org_id = o.id WHERE o.id = ? AND m.user_id = ?').get(orgId, uid) + : db.prepare('SELECT o.id FROM orgs o JOIN memberships m ON m.org_id = o.id WHERE m.user_id = ? ORDER BY o.id LIMIT 1').get(uid); + if (!org) return c.json({ error: 'no accessible org' }, 400); + if (wouldExceed(db, getOrg(org.id), 'projects')) { + return c.json({ error: 'project limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.projects }, 402); + } + const id = rowid(db.prepare('INSERT INTO projects(org_id,name,created_by) VALUES(?,?,?)').run(org.id, name, uid)); + metrics.projectsCreated++; + logAudit(org.id, uid, 'project.create', 'project', id, { name }); + return c.json({ id, name, version: 1 }); +}); + +app.get('/api/projects/:id', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + return c.json({ id: p.id, name: p.name, orgId: p.org_id, baseDate: p.base_date, methodology: p.methodology || 'waterfall', tasks: JSON.parse(p.tasks_json), version: p.version }); +}); + +app.put('/api/projects/:id', requireAuth, async (c) => { + const uid = c.get('user').sub; + const id = c.req.param('id'); + const p = projectAccess(uid, id); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden: viewer role is read-only' }, 403); + const body = await c.req.json().catch(() => ({})); + if (typeof body.version === 'number' && body.version !== p.version) { + return c.json({ error: 'version conflict', current: p.version }, 409); + } + const tasks = Array.isArray(body.tasks) ? body.tasks : JSON.parse(p.tasks_json); + const version = p.version + 1; + const methodology = ['waterfall', 'agile', 'hybrid'].includes(body.methodology) ? body.methodology : (p.methodology || 'waterfall'); + db.prepare( + "UPDATE projects SET name=?, base_date=?, tasks_json=?, version=?, methodology=?, updated_at=datetime('now') WHERE id=?" + ).run(body.name ?? p.name, body.baseDate ?? p.base_date, JSON.stringify(tasks), version, methodology, id); + logAudit(p.org_id, uid, 'project.update', 'project', id, { version, tasks: tasks.length }); + // Revision history: snapshot every save, keep the last 20 per project. + try { + db.prepare('INSERT OR REPLACE INTO project_revisions(project_id,version,name,base_date,tasks_json,saved_by) VALUES(?,?,?,?,?,?)') + .run(id, version, body.name ?? p.name, body.baseDate ?? p.base_date, JSON.stringify(tasks), uid); + db.prepare('DELETE FROM project_revisions WHERE project_id = ? AND version <= ?').run(id, version - 20); + } catch { /* history must not break saves */ } + deliver(p.org_id, 'project.update', { projectId: Number(id), version, tasks: tasks.length, by: uid }); + broadcast(id, { type: 'update', version, by: uid }); + return c.json({ version }); +}); + +// Task comments: discussion bound to a project (optionally a task). All roles +// can read; write roles can post; author or manage can delete. +app.get('/api/projects/:id/comments', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const taskId = c.req.query('taskId'); + const comments = (taskId + ? db.prepare(`SELECT cm.id, cm.task_id AS taskId, cm.body, cm.created_at AS createdAt, cm.user_id AS userId, u.email + FROM comments cm LEFT JOIN users u ON u.id = cm.user_id + WHERE cm.project_id = ? AND cm.task_id = ? ORDER BY cm.id DESC LIMIT 100`).all(p.id, taskId) + : db.prepare(`SELECT cm.id, cm.task_id AS taskId, cm.body, cm.created_at AS createdAt, cm.user_id AS userId, u.email + FROM comments cm LEFT JOIN users u ON u.id = cm.user_id + WHERE cm.project_id = ? ORDER BY cm.id DESC LIMIT 100`).all(p.id)); + return c.json({ comments }); +}); + +app.post('/api/projects/:id/comments', requireAuth, async (c) => { + const uid = c.get('user').sub; + 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 { taskId, body } = await c.req.json().catch(() => ({})); + const text = String(body || '').trim(); + if (!text) return c.json({ error: 'body required' }, 400); + if (text.length > 2000) return c.json({ error: 'comment too long (max 2000)' }, 400); + const cid = rowid(db.prepare('INSERT INTO comments(project_id,task_id,user_id,body) VALUES(?,?,?,?)') + .run(p.id, String(taskId || ''), uid, text)); + logAudit(p.org_id, uid, 'comment.create', 'project', p.id, { commentId: cid, taskId: taskId || null }); + broadcast(p.id, { type: 'comment', commentId: cid, by: uid }); + return c.json({ id: cid }); +}); + +app.delete('/api/projects/:id/comments/:cid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const cm = db.prepare('SELECT user_id FROM comments WHERE id = ? AND project_id = ?').get(c.req.param('cid'), p.id); + if (!cm) return c.json({ error: 'not found' }, 404); + if (cm.user_id !== uid && !canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + db.prepare('DELETE FROM comments WHERE id = ?').run(c.req.param('cid')); + return c.json({ ok: true }); +}); + +// Revision history: list, inspect, restore. +app.get('/api/projects/:id/revisions', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const revisions = db.prepare( + `SELECT r.version, r.created_at AS savedAt, u.email AS savedBy FROM project_revisions r + LEFT JOIN users u ON u.id = r.saved_by WHERE r.project_id = ? ORDER BY r.version DESC` + ).all(p.id); + return c.json({ revisions }); +}); + +app.get('/api/projects/:id/revisions/:version', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const r = db.prepare('SELECT version, name, base_date AS baseDate, tasks_json FROM project_revisions WHERE project_id = ? AND version = ?') + .get(p.id, c.req.param('version')); + if (!r) return c.json({ error: 'not found' }, 404); + return c.json({ version: r.version, name: r.name, baseDate: r.baseDate, tasks: JSON.parse(r.tasks_json) }); +}); + +// Restore = write the old snapshot as a NEW version (history stays linear). +app.post('/api/projects/:id/revisions/:version/restore', requireAuth, (c) => { + const uid = c.get('user').sub; + const id = c.req.param('id'); + const p = projectAccess(uid, id); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const r = db.prepare('SELECT name, base_date, tasks_json FROM project_revisions WHERE project_id = ? AND version = ?') + .get(id, c.req.param('version')); + if (!r) return c.json({ error: 'not found' }, 404); + const version = p.version + 1; + db.prepare("UPDATE projects SET name=?, base_date=?, tasks_json=?, version=?, updated_at=datetime('now') WHERE id=?") + .run(r.name, r.base_date, r.tasks_json, version, id); + try { + db.prepare('INSERT OR REPLACE INTO project_revisions(project_id,version,name,base_date,tasks_json,saved_by) VALUES(?,?,?,?,?,?)') + .run(id, version, r.name, r.base_date, r.tasks_json, uid); + } catch { /* history must not break restore */ } + logAudit(p.org_id, uid, 'project.restore', 'project', id, { from: Number(c.req.param('version')), version }); + broadcast(id, { type: 'update', version, by: uid }); + return c.json({ version }); +}); + +// iCalendar feed: planned tasks as all-day VEVENTs — subscribable from +// Google/Outlook. Calendar apps can't send headers, so accept ?token= (same +// pattern + ceiling as /stream). PATs work via the Authorization header. +app.get('/api/projects/:id/calendar.ics', (c) => { + const header = c.req.header('authorization') || ''; + const raw = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); + let uid; + if (raw.startsWith('swk_')) { + const row = db.prepare('SELECT user_id FROM api_tokens WHERE token_hash = ?').get(hashApiToken(raw)); + if (!row) return c.json({ error: 'unauthorized' }, 401); + uid = row.user_id; + } else { + try { uid = verifyToken(raw).sub; } catch { return c.json({ error: 'unauthorized' }, 401); } + } + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + let tasks = []; + try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } + const day = (s) => String(s).replaceAll('-', ''); + const nextDay = (s) => { const d = new Date(s); d.setDate(d.getDate() + 1); return d.toISOString().slice(0, 10).replaceAll('-', ''); }; + const esc = (s) => String(s).replace(/\\/g, '\\\\').replace(/[,;]/g, (m) => `\\${m}`).replace(/\n/g, '\\n'); + const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//ScopeWeave//KO', 'CALSCALE:GREGORIAN', `X-WR-CALNAME:${esc(p.name)}`]; + for (const t of tasks) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(t.plannedStartDate || '') || !/^\d{4}-\d{2}-\d{2}$/.test(t.plannedEndDate || '')) continue; + lines.push( + 'BEGIN:VEVENT', + `UID:scopeweave-${p.id}-${esc(t.id)}`, + `DTSTART;VALUE=DATE:${day(t.plannedStartDate)}`, + `DTEND;VALUE=DATE:${nextDay(t.plannedEndDate)}`, // DTEND is exclusive + `SUMMARY:${esc(t.name || t.task || t.id)}`, + 'END:VEVENT' + ); + } + lines.push('END:VCALENDAR'); + return c.text(lines.join('\r\n') + '\r\n', 200, { + 'content-type': 'text/calendar; charset=utf-8', + 'content-disposition': `attachment; filename="scopeweave-${p.id}.ics"`, + }); +}); + +app.get('/api/projects/:id/stream', (c) => { + // EventSource can't send an Authorization header, so accept a query token + // here only. Ceiling: issue a short-lived stream-scoped token before prod so + // full JWTs don't land in URLs / access logs. + const header = c.req.header('authorization') || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); + let user; + try { user = verifyToken(token); } catch { return c.json({ error: 'unauthorized' }, 401); } + const id = c.req.param('id'); + if (!projectAccess(user.sub, id)) return c.json({ error: 'not found' }, 404); + const key = String(id); + const stream = new ReadableStream({ + start(controller) { + if (!streams.has(key)) streams.set(key, new Set()); + streams.get(key).add(controller); + controller.enqueue(new TextEncoder().encode(': connected\n\n')); + c.req.raw.signal?.addEventListener('abort', () => { + streams.get(key)?.delete(controller); + try { controller.close(); } catch { /* already closed */ } + }); + }, + }); + return new Response(stream, { + headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }, + }); +}); + +// --------------------------------------------------------------- teams / RBAC +// List members of an org (any member may view the roster). +app.get('/api/orgs/:id/members', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); + const members = db.prepare( + `SELECT u.id, u.email, u.name, m.role FROM memberships m + JOIN users u ON u.id = m.user_id WHERE m.org_id = ? ORDER BY m.id` + ).all(orgId); + const invites = db.prepare( + `SELECT id, email, role, token, created_at AS createdAt FROM invites + WHERE org_id = ? AND accepted_at IS NULL ORDER BY id DESC` + ).all(orgId); + return c.json({ members, invites }); +}); + +// Revoke a pending invite (owner/admin). The token stops working immediately. +app.delete('/api/orgs/:id/invites/:inviteId', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('DELETE FROM invites WHERE id = ? AND org_id = ? AND accepted_at IS NULL') + .run(c.req.param('inviteId'), orgId); + if (!info.changes) return c.json({ error: 'not found' }, 404); + logAudit(orgId, uid, 'invite.revoke', 'invite', c.req.param('inviteId'), {}); + return c.json({ ok: true }); +}); + +// Invite by email (owner/admin only). Returns the token (prod: email a link). +app.post('/api/orgs/:id/invites', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + const role = orgRole(uid, orgId); + if (!role) return c.json({ error: 'not found' }, 404); + if (!canManage(role)) return c.json({ error: 'forbidden' }, 403); + const body = await c.req.json().catch(() => ({})); + const email = String(body.email || '').trim().toLowerCase(); + const inviteRole = body.role || 'member'; + if (!email) return c.json({ error: 'email required' }, 400); + if (!['admin', 'member', 'viewer'].includes(inviteRole)) return c.json({ error: 'invalid role' }, 400); + const token = randomBytes(24).toString('base64url'); + db.prepare('INSERT INTO invites(org_id,email,role,token,invited_by) VALUES(?,?,?,?,?)') + .run(orgId, email, inviteRole, token, uid); + logAudit(orgId, uid, 'member.invite', 'invite', email, { role: inviteRole }); + return c.json({ token, email, role: inviteRole }); +}); + +// Accept an invite (any authenticated user holding the token). Idempotent. +app.post('/api/invites/:token/accept', requireAuth, (c) => { + const uid = c.get('user').sub; + const inv = db.prepare('SELECT * FROM invites WHERE token = ?').get(c.req.param('token')); + if (!inv || inv.accepted_at) return c.json({ error: 'invalid or used invite' }, 404); + const existing = orgRole(uid, inv.org_id); + if (!existing) { + if (wouldExceed(db, getOrg(inv.org_id), 'members')) { + return c.json({ error: 'member limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.members }, 402); + } + db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(inv.org_id, uid, inv.role); + logAudit(inv.org_id, uid, 'member.join', 'user', uid, { role: inv.role }); + deliver(inv.org_id, 'member.join', { userId: uid, role: inv.role }); + } + db.prepare("UPDATE invites SET accepted_at = datetime('now') WHERE id = ?").run(inv.id); + return c.json({ orgId: inv.org_id, role: existing || inv.role }); +}); + +// Change a member's role (owner/admin). Cannot touch an owner or set owner. +app.patch('/api/orgs/:id/members/:userId', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + const targetId = c.req.param('userId'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const body = await c.req.json().catch(() => ({})); + const newRole = body.role; + if (!['admin', 'member', 'viewer'].includes(newRole)) return c.json({ error: 'invalid role' }, 400); + const target = db.prepare('SELECT role FROM memberships WHERE org_id = ? AND user_id = ?').get(orgId, targetId); + if (!target) return c.json({ error: 'not found' }, 404); + if (target.role === 'owner') return c.json({ error: 'cannot change owner role' }, 403); + db.prepare('UPDATE memberships SET role = ? WHERE org_id = ? AND user_id = ?').run(newRole, orgId, targetId); + logAudit(orgId, uid, 'member.role_change', 'user', targetId, { from: target.role, to: newRole }); + return c.json({ userId: Number(targetId), role: newRole }); +}); + +// Remove a member (owner/admin). Cannot remove an owner. +app.delete('/api/orgs/:id/members/:userId', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + const targetId = c.req.param('userId'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const target = db.prepare('SELECT role FROM memberships WHERE org_id = ? AND user_id = ?').get(orgId, targetId); + if (!target) return c.json({ error: 'not found' }, 404); + if (target.role === 'owner') return c.json({ error: 'cannot remove owner' }, 403); + db.prepare('DELETE FROM memberships WHERE org_id = ? AND user_id = ?').run(orgId, targetId); + logAudit(orgId, uid, 'member.remove', 'user', targetId, { role: target.role }); + return c.json({ ok: true }); +}); + +// Leave a workspace voluntarily (any non-owner member). Owners must transfer or +// delete the org instead — an org can never be left ownerless. +app.post('/api/orgs/:id/leave', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + const role = orgRole(uid, orgId); + if (!role) return c.json({ error: 'not found' }, 404); + if (role === 'owner') return c.json({ error: 'owner cannot leave; delete the workspace or transfer ownership' }, 403); + db.prepare('DELETE FROM memberships WHERE org_id = ? AND user_id = ?').run(orgId, uid); + logAudit(orgId, uid, 'member.leave', 'user', uid, { role }); + return c.json({ ok: true }); +}); + +// Transfer workspace ownership to an existing member (owner only). The old +// owner becomes an admin; orgs.owner_id follows. Transactional. +app.post('/api/orgs/:id/transfer', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); + const { userId } = await c.req.json().catch(() => ({})); + if (!userId || Number(userId) === Number(uid)) return c.json({ error: 'target member userId required' }, 400); + const target = db.prepare('SELECT role FROM memberships WHERE org_id = ? AND user_id = ?').get(orgId, userId); + if (!target) return c.json({ error: 'target is not a member' }, 404); + db.exec('BEGIN'); + try { + db.prepare("UPDATE memberships SET role = 'owner' WHERE org_id = ? AND user_id = ?").run(orgId, userId); + db.prepare("UPDATE memberships SET role = 'admin' WHERE org_id = ? AND user_id = ?").run(orgId, uid); + db.prepare('UPDATE orgs SET owner_id = ? WHERE id = ?').run(userId, orgId); + db.exec('COMMIT'); + } catch (e) { db.exec('ROLLBACK'); throw e; } + logAudit(orgId, uid, 'org.transfer', 'user', userId, { from: uid }); + return c.json({ ok: true, newOwnerId: Number(userId) }); +}); + +// Rename a workspace (owner only). +app.patch('/api/orgs/:id', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); + const { name } = await c.req.json().catch(() => ({})); + if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); + db.prepare('UPDATE orgs SET name = ? WHERE id = ?').run(String(name).trim().slice(0, 120), orgId); + logAudit(orgId, uid, 'org.rename', 'org', orgId, { name }); + return c.json({ id: Number(orgId), name: String(name).trim() }); +}); + +// ------------------------------------------------------------------- billing +app.get('/api/orgs/:id/billing', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); + const org = getOrg(orgId); + const plan = planOf(org); + return c.json({ plan: org.plan, planName: plan.name, priceKrw: plan.priceKrw, limits: plan.limits, usage: orgUsage(db, orgId) }); +}); + +app.post('/api/orgs/:id/checkout', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'only the owner can upgrade' }, 403); + const origin = new URL(c.req.url).origin; + const session = await createCheckout({ orgId, origin }); + return c.json(session); +}); + +// Stripe webhook (stub). Live mode should verify the signature with +// STRIPE_WEBHOOK_SECRET before trusting the event — named ceiling. +app.post('/api/stripe/webhook', async (c) => { + const event = await c.req.json().catch(() => ({})); + if (event?.type === 'checkout.session.completed') { + const orgId = event.data?.object?.client_reference_id || event.data?.object?.metadata?.orgId; + if (orgId) db.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").run(orgId); + } + return c.json({ received: true }); +}); + +// Dev-only: simulate a successful checkout upgrading the org to Pro. +// Disabled unless SCOPEWEAVE_DEV=1 (never reachable in production). +app.post('/api/orgs/:id/_dev/activate-pro', requireAuth, (c) => { + if (process.env.SCOPEWEAVE_DEV !== '1') return c.json({ error: 'not found' }, 404); + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); + db.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").run(orgId); + logAudit(orgId, uid, 'billing.upgrade', 'org', orgId, { plan: 'pro', via: 'dev' }); + deliver(orgId, 'billing.upgrade', { plan: 'pro' }); + return c.json({ plan: 'pro' }); +}); + +// ------------------------------------------------- personal access tokens (PAT) +app.get('/api/tokens', requireAuth, (c) => { + const uid = c.get('user').sub; + const tokens = db.prepare( + 'SELECT id, name, prefix, last_used AS lastUsed, created_at AS createdAt FROM api_tokens WHERE user_id = ? ORDER BY id DESC' + ).all(uid); + return c.json({ tokens }); // never the secret or hash +}); + +app.post('/api/tokens', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { name } = await c.req.json().catch(() => ({})); + const t = generateApiToken(); + const id = rowid(db.prepare('INSERT INTO api_tokens(user_id,name,token_hash,prefix) VALUES(?,?,?,?)') + .run(uid, String(name || 'token').slice(0, 60), t.hash, t.prefix)); + // Full secret returned ONCE — never retrievable again. + return c.json({ id, name: name || 'token', prefix: t.prefix, token: t.full }); +}); + +app.delete('/api/tokens/:id', requireAuth, (c) => { + const uid = c.get('user').sub; + const info = db.prepare('DELETE FROM api_tokens WHERE id = ? AND user_id = ?').run(c.req.param('id'), uid); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + +// Audit trail — owner/admin only. Enterprise requirement. +app.get('/api/orgs/:id/audit', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const limit = Math.min(Number(c.req.query('limit')) || 100, 500); + const rows = db.prepare( + `SELECT a.id, a.action, a.target_type AS targetType, a.target_id AS targetId, a.meta, + a.created_at AS createdAt, u.email AS actorEmail + FROM audit_log a LEFT JOIN users u ON u.id = a.user_id + WHERE a.org_id = ? ORDER BY a.id DESC LIMIT ?` + ).all(orgId, limit); + const events = rows.map((r) => ({ ...r, meta: r.meta ? JSON.parse(r.meta) : null })); + if (c.req.query('format') === 'csv') { + // Compliance deliverable. Formula-injection-safe: values that (after optional + // leading whitespace) start with = + - @ | are prefixed with ' so + // spreadsheets treat them as text. Leading whitespace alone used to bypass + // /^[=+\-@|]/ — match the client-side CSV_FORMULA_PREFIX_PATTERN. + const csvCell = (v) => { + let s = v == null ? '' : String(v); + if (/^\s*[=+\-@|]/.test(s)) s = `'${s}`; + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + }; + const header = ['id', 'createdAt', 'actorEmail', 'action', 'targetType', 'targetId', 'meta']; + const lines = [header.join(',')]; + for (const e of events) { + lines.push([e.id, e.createdAt, e.actorEmail, e.action, e.targetType, e.targetId, e.meta ? JSON.stringify(e.meta) : ''].map(csvCell).join(',')); + } + return c.text(lines.join('\r\n') + '\r\n', 200, { + 'content-type': 'text/csv; charset=utf-8', + 'content-disposition': `attachment; filename="scopeweave-audit-${orgId}.csv"`, + }); + } + return c.json({ events }); +}); + +// Full workspace export (owner only) — data portability / GDPR. Everything the +// org holds, as one JSON document. +app.get('/api/orgs/:id/export', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'only the owner can export' }, 403); + const org = getOrg(orgId); + const members = db.prepare( + `SELECT u.email, u.name, m.role FROM memberships m JOIN users u ON u.id = m.user_id WHERE m.org_id = ?` + ).all(orgId); + const projects = db.prepare( + 'SELECT id, name, base_date AS baseDate, tasks_json, version, created_at AS createdAt, updated_at AS updatedAt FROM projects WHERE org_id = ?' + ).all(orgId).map((p) => ({ ...p, tasks: JSON.parse(p.tasks_json), tasks_json: undefined })); + const audit = db.prepare( + 'SELECT action, target_type AS targetType, target_id AS targetId, meta, created_at AS createdAt FROM audit_log WHERE org_id = ? ORDER BY id' + ).all(orgId).map((a) => ({ ...a, meta: a.meta ? JSON.parse(a.meta) : null })); + logAudit(orgId, uid, 'org.export', 'org', orgId, { projects: projects.length }); + return c.json({ + exportedAt: new Date().toISOString(), + org: { id: org.id, name: org.name, plan: org.plan }, + members, projects, audit, + }, 200, { 'Content-Disposition': `attachment; filename="scopeweave-org-${orgId}.json"` }); +}); + +// Operational metrics (JSON). Ceiling: expose Prometheus text format + gate +// behind an internal token before prod if scraped externally. +app.get('/api/metrics', (c) => { + const sseActive = [...streams.values()].reduce((n, s) => n + s.size, 0); + const all = { ...metrics, sseActive, uptimeSec: Math.round(process.uptime()) }; + if (c.req.query('format') !== 'prometheus') return c.json(all); + // Prometheus text exposition format (0.0.4) — scrape-ready for Grafana/Alerting. + const gauge = new Set(['sseActive', 'uptimeSec']); + const lines = []; + for (const [k, v] of Object.entries(all)) { + if (typeof v !== 'number') continue; // startedAt etc. + const name = `scopeweave_${k.replace(/([A-Z])/g, '_$1').toLowerCase()}`; + lines.push(`# TYPE ${name} ${gauge.has(k) ? 'gauge' : 'counter'}`, `${name} ${v}`); + } + return c.text(lines.join('\n') + '\n', 200, { 'content-type': 'text/plain; version=0.0.4; charset=utf-8' }); +}); + +// ------------------------------------------------------------------- webhooks +app.get('/api/orgs/:id/webhooks', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const webhooks = db.prepare( + `SELECT w.id, w.url, w.events, w.active, w.created_at AS createdAt, + (SELECT ok FROM webhook_deliveries d WHERE d.webhook_id = w.id ORDER BY d.id DESC LIMIT 1) AS lastOk, + (SELECT created_at FROM webhook_deliveries d WHERE d.webhook_id = w.id ORDER BY d.id DESC LIMIT 1) AS lastAt + FROM webhooks w WHERE w.org_id = ? ORDER BY w.id DESC` + ).all(orgId); // secret never returned + return c.json({ webhooks }); +}); + +app.post('/api/orgs/:id/webhooks', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const { url, events } = await c.req.json().catch(() => ({})); + if (!/^https?:\/\//.test(String(url || ''))) return c.json({ error: 'valid http(s) url required' }, 400); + const secret = `whsec_${randomBytes(24).toString('base64url')}`; + const evs = Array.isArray(events) ? events.join(',') : (events || '*'); + const id = rowid(db.prepare('INSERT INTO webhooks(org_id,url,secret,events) VALUES(?,?,?,?)').run(orgId, url, secret, evs)); + logAudit(orgId, uid, 'webhook.create', 'webhook', id, { url, events: evs }); + return c.json({ id, url, events: evs, secret }); // secret shown once for signature verification +}); + +app.get('/api/orgs/:id/webhooks/:whId/deliveries', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const wh = db.prepare('SELECT id FROM webhooks WHERE id = ? AND org_id = ?').get(c.req.param('whId'), orgId); + if (!wh) return c.json({ error: 'not found' }, 404); + const deliveries = db.prepare( + 'SELECT event, status_code AS statusCode, ok, attempt, created_at AS createdAt FROM webhook_deliveries WHERE webhook_id = ? ORDER BY id DESC LIMIT 50' + ).all(wh.id); + return c.json({ deliveries }); +}); + +// Rotate a webhook's signing secret (leak response / periodic hygiene). The new +// secret is returned ONCE; old signatures stop validating immediately. +app.post('/api/orgs/:id/webhooks/:whId/rotate', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const secret = `whsec_${randomBytes(24).toString('base64url')}`; + const info = db.prepare('UPDATE webhooks SET secret = ? WHERE id = ? AND org_id = ?').run(secret, c.req.param('whId'), orgId); + if (!info.changes) return c.json({ error: 'not found' }, 404); + logAudit(orgId, uid, 'webhook.rotate', 'webhook', c.req.param('whId'), {}); + return c.json({ id: Number(c.req.param('whId')), secret }); // shown once +}); + +app.delete('/api/orgs/:id/webhooks/:whId', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('DELETE FROM webhooks WHERE id = ? AND org_id = ?').run(c.req.param('whId'), orgId); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + +// ------------------------------------------------------------ SSO (OIDC) +// Real IdP via env (OIDC_ISSUER/CLIENT_ID/CLIENT_SECRET/REDIRECT_URI). When +// unset, a built-in mock provider makes the whole flow self-contained + testable. +const OIDC = { + issuer: process.env.OIDC_ISSUER, + clientId: process.env.OIDC_CLIENT_ID, + clientSecret: process.env.OIDC_CLIENT_SECRET, + redirectUri: process.env.OIDC_REDIRECT_URI, +}; +const oidcMock = !OIDC.issuer; +const oidcStates = new Map(); // state -> { verifier, exp } +const oidcCodes = new Map(); // mock only: code -> email + +function upsertSsoUser(email) { + let user = db.prepare('SELECT id, email, token_version FROM users WHERE email = ?').get(email); + if (user) return user; + db.exec('BEGIN'); + try { + const uid = rowid(db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') + .run(email, hashPassword(randomBytes(24).toString('hex')), '')); + const oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)').run(`${email}'s workspace`, uid)); + db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); + db.exec('COMMIT'); + metrics.signups++; + return { id: uid, email }; + } catch (e) { db.exec('ROLLBACK'); throw e; } +} + +app.get('/api/auth/oidc/start', (c) => { + const origin = new URL(c.req.url).origin; + const state = randomBytes(16).toString('hex'); + const verifier = randomBytes(32).toString('base64url'); + const challenge = createHash('sha256').update(verifier).digest('base64url'); + oidcStates.set(state, { verifier, exp: Date.now() + 5 * 60 * 1000 }); + const redirectUri = OIDC.redirectUri || `${origin}/api/auth/oidc/callback`; + if (oidcMock) { + const email = c.req.query('email') || 'sso-user@example.com'; + const u = new URL(`${origin}/api/auth/oidc/mock/authorize`); + u.searchParams.set('state', state); + u.searchParams.set('email', email); + u.searchParams.set('redirect_uri', redirectUri); + return c.redirect(u.toString()); + } + const u = new URL(`${OIDC.issuer.replace(/\/$/, '')}/authorize`); + u.searchParams.set('client_id', OIDC.clientId); + u.searchParams.set('redirect_uri', redirectUri); + u.searchParams.set('response_type', 'code'); + u.searchParams.set('scope', 'openid email profile'); + u.searchParams.set('state', state); + u.searchParams.set('code_challenge', challenge); + u.searchParams.set('code_challenge_method', 'S256'); + return c.redirect(u.toString()); +}); + +// Built-in mock IdP authorize — instantly issues a code (dev/test only). +app.get('/api/auth/oidc/mock/authorize', (c) => { + if (!oidcMock) return c.json({ error: 'mock disabled' }, 404); + const state = c.req.query('state'); + const email = c.req.query('email'); + const redirectUri = c.req.query('redirect_uri'); + const code = randomBytes(16).toString('hex'); + oidcCodes.set(code, email); + const u = new URL(redirectUri); + u.searchParams.set('code', code); + u.searchParams.set('state', state); + return c.redirect(u.toString()); +}); + +app.get('/api/auth/oidc/callback', async (c) => { + const state = c.req.query('state'); + const code = c.req.query('code'); + const s = oidcStates.get(state); + if (!s || s.exp < Date.now()) return c.json({ error: 'invalid or expired state' }, 400); + oidcStates.delete(state); + let email; + if (oidcMock) { + email = oidcCodes.get(code); + oidcCodes.delete(code); + if (!email) return c.json({ error: 'invalid code' }, 400); + } else { + const redirectUri = OIDC.redirectUri || `${new URL(c.req.url).origin}/api/auth/oidc/callback`; + const tokenRes = await fetch(`${OIDC.issuer.replace(/\/$/, '')}/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: OIDC.clientId, client_secret: OIDC.clientSecret, code_verifier: s.verifier }), + }).catch(() => null); + const tok = tokenRes ? await tokenRes.json().catch(() => ({})) : {}; + if (!tok.id_token) return c.json({ error: 'token exchange failed' }, 400); + // Ceiling: verify the id_token signature via the issuer JWKS before prod. + const claims = JSON.parse(Buffer.from(String(tok.id_token).split('.')[1] || '', 'base64url').toString() || '{}'); + email = claims.email; + if (!email) return c.json({ error: 'no email claim' }, 400); + } + const user = upsertSsoUser(email); + const token = signToken({ sub: user.id, email, tv: user.token_version || 0 }); + // Return the token in the URL fragment (not query → not logged); the client + // stores it and cleans the URL. + return c.redirect(`/#token=${token}`); +}); + +// Cross-project search: project names + task names, membership-scoped (tenant +// isolation via the same JOIN as projectAccess). +// ponytail: LIKE over tasks_json text; move to FTS5 if search gets heavy. +app.get('/api/search', requireAuth, (c) => { + const uid = c.get('user').sub; + const q = String(c.req.query('q') || '').trim(); + if (q.length < 2) return c.json({ error: 'query too short (min 2)' }, 400); + const rows = db.prepare( + `SELECT DISTINCT p.id, p.name, p.tasks_json FROM projects p + JOIN memberships m ON m.org_id = p.org_id + WHERE m.user_id = ? AND (p.name LIKE ? OR p.tasks_json LIKE ?) LIMIT 100` + ).all(uid, `%${q}%`, `%${q}%`); + const needle = q.toLowerCase(); + const results = []; + for (const p of rows) { + const hit = { projectId: p.id, projectName: p.name, tasks: [] }; + if (p.name.toLowerCase().includes(needle)) hit.nameMatch = true; + let tasks = []; + try { tasks = JSON.parse(p.tasks_json); } catch { /* skip bad json */ } + for (const t of tasks) { + if (String(t.name || '').toLowerCase().includes(needle)) { + hit.tasks.push({ id: t.id, name: t.name }); + if (hit.tasks.length >= 5) break; + } + } + if (hit.nameMatch || hit.tasks.length) results.push(hit); + if (results.length >= 20) break; + } + return c.json({ query: q, results }); +}); + +// Portfolio dashboard: executive rollup across every project in a workspace — +// weighted planned/actual progress, SPI + status, overdue-task counts. +app.get('/api/orgs/:id/portfolio', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); + const today = new Date().toISOString().slice(0, 10); + const rows = db.prepare( + 'SELECT id, name, base_date AS baseDate, tasks_json, archived, updated_at AS updatedAt FROM projects WHERE org_id = ? ORDER BY archived ASC, updated_at DESC' + ).all(orgId); + const projects = rows.map((p) => { + let tasks = []; + try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } + let wSum = 0, pv = 0, ev = 0, overdue = 0; + for (const t of tasks) { + const w = Number(t.weight) || 1; + wSum += w; + pv += w * ((Number(t.plannedProgress) || 0) / 100); + ev += w * ((Number(t.actualProgress) || 0) / 100); + if (t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100) overdue++; + } + const evm = computeEvm({ pv: wSum ? pv / wSum : 0, ev: wSum ? ev / wSum : 0 }); + return { + id: p.id, + name: p.name, + archived: Boolean(p.archived), + tasks: tasks.length, + planned: Math.round(evm.pv * 1000) / 10, // % + actual: Math.round(evm.ev * 1000) / 10, // % + spi: evm.spi === null ? null : Math.round(evm.spi * 100) / 100, + status: evm.status, + label: evm.label, + overdue, + updatedAt: p.updatedAt, + }; + }); + return c.json({ projects }); +}); + +// AI 브리핑: 프로젝트 스냅샷(요약 지표 + 지연/차주 작업)을 contextual- +// orchestrator(LLM)로 보내 경영진용 리스크 분석을 생성. 원문 데이터는 서버가 +// 요약해 전송하며, LLM 자격은 서버 환경변수에만 존재. +app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + let tasks = []; + try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } + const today = new Date().toISOString().slice(0, 10); + let wSum = 0, pv = 0, ev = 0; + const late = [], upcoming = []; + for (const t of tasks) { + const w = Number(t.weight) || 1; + wSum += w; + pv += w * ((Number(t.plannedProgress) || 0) / 100); + ev += w * ((Number(t.actualProgress) || 0) / 100); + const name = t.name || t.task || t.activity || t.phase || t.id; + if (t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100) { + late.push(`${name}(계획종료 ${t.plannedEndDate}, 실적 ${Number(t.actualProgress) || 0}%${t.owner ? `, ${t.owner}` : ''})`); + } else if (t.plannedStartDate && t.plannedStartDate >= today) { + upcoming.push(`${name}(${t.plannedStartDate} 시작)`); + } + } + const pvPct = wSum ? ((pv / wSum) * 100).toFixed(1) : '0'; + const evPct = wSum ? ((ev / wSum) * 100).toFixed(1) : '0'; + const context = [ + `프로젝트: ${p.name}`, + `작업 수: ${tasks.length} · 계획진척 ${pvPct}% · 실적진척 ${evPct}%`, + `지연 작업(${late.length}): ${late.slice(0, 8).join(' / ') || '없음'}`, + `예정 작업(${upcoming.length}): ${upcoming.slice(0, 5).join(' / ') || '없음'}`, + ].join('\n'); + try { + const analysis = await orchestratorChat([ + { role: 'system', content: '너는 공정관리(schedule control) 전문가다. 주어진 프로젝트 지표를 근거로 한국어 경영진 브리핑을 작성하라: ①일정 상태 한 줄 판정 ②핵심 리스크 2~3개(근거 지표 인용) ③실행 권고 2~3개. 지표에 없는 사실은 만들지 마라.' }, + { role: 'user', content: context }, + ], { + service: 'scopeweave', + account: String(p.org_id), + }); + logAudit(p.org_id, uid, 'ai.brief', 'project', p.id, { tasks: tasks.length }); + return c.json({ analysis }); + } catch (e) { + return c.json({ error: `AI 분석 실패: ${e.message}` }, 502); + } +}); + +// 산출물 첨부(Clearfolio 통합 문서 뷰어 프록시): 업로드→변환 잡, 목록(+상태 +// 갱신), 서명 아티팩트 열람(302), 삭제. 테넌트 = 조직, 브라우저에는 Clearfolio +// 자격이 절대 노출되지 않음. +const ATTACH_MAX_BYTES = 10 * 1024 * 1024; + +const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY, +); +const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, +); +const ATTACH_STATUS_BUDGET_MS = normalizeAttachmentStatusBudgetMs( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS, +); +const ATTACHMENT_LIST_COLUMNS = `a.id, a.task_id AS taskId, a.name, a.mime, a.size, + a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy`; +const ATTACHMENT_LIST_FROM = + 'FROM attachments a LEFT JOIN users u ON u.id = a.created_by'; +const listAttachmentsStatement = db.prepare( + `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} + WHERE a.project_id = ? ORDER BY a.id DESC`, +); +const listTaskAttachmentsStatement = db.prepare( + `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} + WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`, +); +const updateAttachmentStatusStatement = db.prepare( + 'UPDATE attachments SET status = ? WHERE id = ?', +); +app.post('/api/projects/:id/attachments', requireAuth, async (c) => { + const uid = c.get('user').sub; + 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 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); + const taskId = String(form.get('taskId') || ''); + if (/\.(hwp|hwpx)$/i.test(file.name || '')) return c.json({ error: 'HWP/HWPX는 지원되지 않습니다 (Clearfolio 정책)' }, 400); + if (file.size > ATTACH_MAX_BYTES) return c.json({ error: 'file too large (max 10MB)' }, 400); + const bytes = Buffer.from(await file.arrayBuffer()); + let job; + try { + job = await submitJob(p.org_id, uid, { name: file.name || 'document', mime: file.type || '', bytes }); + } catch (e) { + return c.json({ error: `문서 변환 제출 실패: ${e.message}` }, 502); + } + const aid = rowid(db.prepare( + 'INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)' + ).run(p.id, taskId, file.name || 'document', file.type || '', file.size, job.jobId, job.status, uid)); + logAudit(p.org_id, uid, 'attachment.upload', 'project', p.id, { attachmentId: aid, name: file.name, taskId: taskId || null }); + return c.json({ id: aid, status: job.status }); +}); + +app.get('/api/projects/:id/attachments', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + + const taskId = c.req.query('taskId'); + const rows = taskId + ? listTaskAttachmentsStatement.all(p.id, taskId) + : listAttachmentsStatement.all(p.id); + await refreshAttachmentStatuses(rows, { + orgId: p.org_id, + userId: uid, + jobStatus, + updateStatus: (status, attachmentId) => + updateAttachmentStatusStatement.run(status, attachmentId), + concurrency: ATTACH_STATUS_CONCURRENCY, + timeoutMs: ATTACH_STATUS_TIMEOUT_MS, + budgetMs: ATTACH_STATUS_BUDGET_MS, + metrics, + }); + const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); + return c.json({ attachments }); +}); + +// 열람: 서명 아티팩트 URL로 302. 새 탭 열기용으로 ?token=도 허용(ics/stream 패턴). +app.get('/api/projects/:id/attachments/:aid/view', (c) => { + const header = c.req.header('authorization') || ''; + const raw = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); + let uid; + if (raw.startsWith('swk_')) { + const row = db.prepare('SELECT user_id FROM api_tokens WHERE token_hash = ?').get(hashApiToken(raw)); + if (!row) return c.json({ error: 'unauthorized' }, 401); + uid = row.user_id; + } else { + try { + const payload = verifyToken(raw); + const u = db.prepare('SELECT token_version FROM users WHERE id = ?').get(payload.sub); + if (!u || (payload.tv || 0) !== u.token_version) return c.json({ error: 'unauthorized' }, 401); + uid = payload.sub; + } catch { return c.json({ error: 'unauthorized' }, 401); } + } + const p = projectAccess(uid, c.req.param('id')); + 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); + return artifactUrl(p.org_id, uid, a.job_id) + .then((url) => c.redirect(url)) + .catch((e) => c.json({ error: `열람 링크 발급 실패: ${e.message}` }, 502)); +}); + +app.delete('/api/projects/:id/attachments/:aid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const a = db.prepare('SELECT created_by 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.created_by !== uid && !canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + db.prepare('DELETE FROM attachments WHERE id = ?').run(c.req.param('aid')); + logAudit(p.org_id, uid, 'attachment.delete', 'project', p.id, { attachmentId: Number(c.req.param('aid')) }); + return c.json({ ok: true }); +}); + +// mock Clearfolio 아티팩트 서빙(dev/test 전용) +if (clearfolioMock) { + app.get('/api/mock-clearfolio/:jobId', (c) => { + const doc = mockArtifact(c.req.param('jobId')); + if (!doc) return c.json({ error: 'not found' }, 404); + return c.body(doc.bytes, 200, { + 'content-type': doc.mime || 'application/octet-stream', + 'content-disposition': `inline; filename="${encodeURIComponent(doc.name)}"`, + }); + }); +} + +// Public read-only share links: a random token grants VIEW access to one +// project (no account needed) — revocable. Never exposes org/member data. +app.post('/api/projects/:id/shares', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const token = randomBytes(18).toString('base64url'); + db.prepare('INSERT INTO share_tokens(project_id, token, created_by) VALUES(?,?,?)').run(p.id, token, uid); + logAudit(p.org_id, uid, 'share.create', 'project', p.id, {}); + return c.json({ token, url: `/?share=${token}` }); +}); + +app.get('/api/projects/:id/shares', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const shares = db.prepare( + 'SELECT id, token, created_at AS createdAt FROM share_tokens WHERE project_id = ? AND revoked = 0 ORDER BY id DESC' + ).all(p.id); + return c.json({ shares }); +}); + +app.delete('/api/projects/:id/shares/:sid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('UPDATE share_tokens SET revoked = 1 WHERE id = ? AND project_id = ? AND revoked = 0') + .run(c.req.param('sid'), p.id); + if (!info.changes) return c.json({ error: 'not found' }, 404); + logAudit(p.org_id, uid, 'share.revoke', 'project', p.id, { shareId: Number(c.req.param('sid')) }); + return c.json({ ok: true }); +}); + +// Anonymous read via share token — project content only. +app.get('/api/shared/:token', (c) => { + const row = db.prepare( + `SELECT p.name, p.base_date AS baseDate, p.tasks_json FROM share_tokens s + JOIN projects p ON p.id = s.project_id WHERE s.token = ? AND s.revoked = 0` + ).get(c.req.param('token')); + if (!row) return c.json({ error: 'not found' }, 404); + return c.json({ name: row.name, baseDate: row.baseDate, tasks: JSON.parse(row.tasks_json), readOnly: true }); +}); + +// Unseen-activity notifications: per project, count others' saves + comments +// newer than my last-seen mark. Opening a project marks it seen. +app.get('/api/notifications', requireAuth, (c) => { + const uid = c.get('user').sub; + const rows = db.prepare( + `SELECT p.id AS projectId, + (SELECT COUNT(*) FROM project_revisions r WHERE r.project_id = p.id + AND r.saved_by IS NOT NULL AND r.saved_by != ? + AND r.created_at > COALESCE(s.seen_at, '')) AS revisions, + (SELECT COUNT(*) FROM comments cm WHERE cm.project_id = p.id + AND cm.user_id IS NOT NULL AND cm.user_id != ? + AND cm.created_at > COALESCE(s.seen_at, '')) AS comments + FROM projects p + JOIN memberships m ON m.org_id = p.org_id AND m.user_id = ? + LEFT JOIN project_seen s ON s.project_id = p.id AND s.user_id = ?` + ).all(uid, uid, uid, uid); + const notifications = rows + .map((r) => ({ projectId: r.projectId, unseen: r.revisions + r.comments })) + .filter((r) => r.unseen > 0); + return c.json({ notifications }); +}); + +app.post('/api/projects/:id/seen', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + db.prepare(`INSERT INTO project_seen(project_id, user_id, seen_at) VALUES(?, ?, datetime('now')) + ON CONFLICT(project_id, user_id) DO UPDATE SET seen_at = datetime('now')`).run(p.id, uid); + return c.json({ ok: true }); +}); + +// Archive / restore a project (write roles): declutter without deleting. +app.post('/api/projects/:id/archive', requireAuth, async (c) => { + const uid = c.get('user').sub; + 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 { archived } = await c.req.json().catch(() => ({})); + const flag = archived === false ? 0 : 1; + db.prepare('UPDATE projects SET archived = ? WHERE id = ?').run(flag, p.id); + logAudit(p.org_id, uid, flag ? 'project.archive' : 'project.unarchive', 'project', p.id, {}); + return c.json({ id: p.id, archived: Boolean(flag) }); +}); + +// Duplicate a project (template use: copy tasks + base date into a new project +// in the same org). Plan caps apply like any create. +app.post('/api/projects/:id/duplicate', requireAuth, async (c) => { + const uid = c.get('user').sub; + 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); + if (wouldExceed(db, getOrg(p.org_id), 'projects')) { + return c.json({ error: 'project limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.projects }, 402); + } + const { name } = await c.req.json().catch(() => ({})); + const newName = String(name || `${p.name} (복사본)`).slice(0, 120); + const nid = rowid(db.prepare('INSERT INTO projects(org_id,name,base_date,tasks_json,created_by) VALUES(?,?,?,?,?)') + .run(p.org_id, newName, p.base_date, p.tasks_json, uid)); + metrics.projectsCreated++; + logAudit(p.org_id, uid, 'project.duplicate', 'project', nid, { from: p.id, name: newName }); + return c.json({ id: nid, name: newName, version: 1 }); +}); + +// -------------------------------------------------------------- sprints +// Agile/Hybrid: 시간상자(스프린트) CRUD. 작업은 task.sprint(이름)로 배정되고 +// task.storyPoints로 추정된다 — 지표(커밋/완료 포인트, 벨로시티)는 클라이언트 +// 순수 함수(computeSprintStats)가 계산한다. +app.post('/api/projects/:id/sprints', requireAuth, async (c) => { + const uid = c.get('user').sub; + 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 { name, startDate, endDate, goal } = await c.req.json().catch(() => ({})); + if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); + const day = (v) => (/^\d{4}-\d{2}-\d{2}$/.test(String(v || '')) ? v : ''); + const sid = rowid(db.prepare('INSERT INTO sprints(project_id,name,start_date,end_date,goal) VALUES(?,?,?,?,?)') + .run(p.id, String(name).trim().slice(0, 80), day(startDate), day(endDate), String(goal || '').slice(0, 300))); + logAudit(p.org_id, uid, 'sprint.create', 'project', p.id, { sprintId: sid, name }); + return c.json({ id: sid, name: String(name).trim() }); +}); + +app.get('/api/projects/:id/sprints', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const sprints = db.prepare( + 'SELECT id, name, start_date AS startDate, end_date AS endDate, goal FROM sprints WHERE project_id = ? ORDER BY start_date, id' + ).all(p.id); + return c.json({ sprints, methodology: p.methodology || 'waterfall' }); +}); + +app.delete('/api/projects/:id/sprints/:sid', requireAuth, (c) => { + const uid = c.get('user').sub; + 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 info = db.prepare('DELETE FROM sprints WHERE id = ? AND project_id = ?').run(c.req.param('sid'), p.id); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + +// ------------------------------------------------------------- baselines +// Snapshot a project's current plan as a named baseline (schedule-control: +// compare actuals against the frozen plan later). +app.post('/api/projects/:id/baselines', requireAuth, async (c) => { + const uid = c.get('user').sub; + const id = c.req.param('id'); + const p = projectAccess(uid, id); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const { name } = await c.req.json().catch(() => ({})); + const bid = rowid(db.prepare('INSERT INTO baselines(project_id,name,base_date,tasks_json,created_by) VALUES(?,?,?,?,?)') + .run(id, String(name || 'Baseline').slice(0, 80), p.base_date, p.tasks_json, uid)); + logAudit(p.org_id, uid, 'baseline.create', 'project', id, { baselineId: bid, name }); + return c.json({ id: bid, name: name || 'Baseline' }); +}); + +app.get('/api/projects/:id/baselines', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const baselines = db.prepare( + 'SELECT id, name, base_date AS baseDate, created_at AS createdAt FROM baselines WHERE project_id = ? ORDER BY id DESC' + ).all(p.id); + return c.json({ baselines }); +}); + +app.get('/api/projects/:id/baselines/:bid', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const b = db.prepare('SELECT id, name, base_date AS baseDate, tasks_json, created_at AS createdAt FROM baselines WHERE id = ? AND project_id = ?').get(c.req.param('bid'), p.id); + if (!b) return c.json({ error: 'not found' }, 404); + return c.json({ id: b.id, name: b.name, baseDate: b.baseDate, tasks: JSON.parse(b.tasks_json), createdAt: b.createdAt }); +}); + +app.delete('/api/projects/:id/baselines/:bid', requireAuth, (c) => { + const uid = c.get('user').sub; + 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 info = db.prepare('DELETE FROM baselines WHERE id = ? AND project_id = ?').run(c.req.param('bid'), p.id); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + +// ------------------------------------------------------ account & lifecycle +// Delete a project (write roles). tasks live in the row, so this fully removes it. +app.delete('/api/projects/:id', requireAuth, (c) => { + const uid = c.get('user').sub; + const id = c.req.param('id'); + const p = projectAccess(uid, id); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + db.prepare('DELETE FROM projects WHERE id = ?').run(id); + logAudit(p.org_id, uid, 'project.delete', 'project', id, { name: p.name }); + deliver(p.org_id, 'project.delete', { projectId: Number(id) }); + return c.json({ ok: true }); +}); + +// Log out everywhere: bump token_version → every existing JWT dies. Returns a +// fresh token so THIS device stays signed in. PATs are unaffected. +app.post('/api/auth/logout-all', requireAuth, (c) => { + const uid = c.get('user').sub; + db.prepare('UPDATE users SET token_version = token_version + 1 WHERE id = ?').run(uid); + const u = db.prepare('SELECT email, token_version FROM users WHERE id = ?').get(uid); + return c.json({ ok: true, token: signToken({ sub: uid, email: u.email, tv: u.token_version }) }); +}); + +// Change password (verifies the current one). +app.post('/api/auth/change-password', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { oldPassword, newPassword } = await c.req.json().catch(() => ({})); + if (typeof newPassword !== 'string' || newPassword.length < 8) return c.json({ error: 'new password (min 8) required' }, 400); + const u = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(uid); + if (!u || typeof oldPassword !== 'string' || !verifyPassword(oldPassword, u.password_hash)) { + return c.json({ error: 'current password incorrect' }, 403); + } + db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(newPassword), uid); + return c.json({ ok: true }); +}); + +// Delete account (GDPR). Removes owned workspaces (cascading their data) and the +// user. Requires the current password to confirm. +app.delete('/api/account', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { password } = await c.req.json().catch(() => ({})); + const u = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(uid); + if (!u || typeof password !== 'string' || !verifyPassword(password, u.password_hash)) { + return c.json({ error: 'password required to delete account' }, 403); + } + db.exec('BEGIN'); + try { + db.prepare('DELETE FROM orgs WHERE owner_id = ?').run(uid); // cascades projects/members/webhooks/invites/audit + db.prepare('DELETE FROM users WHERE id = ?').run(uid); // cascades memberships/tokens + db.exec('COMMIT'); + } catch (e) { db.exec('ROLLBACK'); throw e; } + return c.json({ ok: true }); +}); + +app.get('/api/health', (c) => c.json({ ok: true })); + +// Static client — strict allowlist so server/, data.db, package.json etc. are +// never served. Anything not listed → 404. +const STATIC = { + '/': ['index.html', 'text/html; charset=utf-8'], + '/index.html': ['index.html', 'text/html; charset=utf-8'], + '/404.html': ['404.html', 'text/html; charset=utf-8'], + '/landing.html': ['landing.html', 'text/html; charset=utf-8'], + '/landing.en.html': ['landing.en.html', 'text/html; charset=utf-8'], + '/docs/api.md': ['docs/api.md', 'text/markdown; charset=utf-8'], + '/robots.txt': ['robots.txt', 'text/plain; charset=utf-8'], + '/sitemap.xml': ['sitemap.xml', 'application/xml; charset=utf-8'], + '/pricing': ['landing.html', 'text/html; charset=utf-8'], + '/app.js': ['app.js', 'text/javascript; charset=utf-8'], + '/cloud-sync.js': ['cloud-sync.js', 'text/javascript; charset=utf-8'], + '/analytics.js': ['analytics.js', 'text/javascript; charset=utf-8'], + '/styles.css': ['styles.css', 'text/css; charset=utf-8'], + '/toast-state.css': ['toast-state.css', 'text/css; charset=utf-8'], + '/wbs.json': ['wbs.json', 'application/json; charset=utf-8'], +}; +app.get('*', async (c) => { + const entry = STATIC[c.req.path]; + if (!entry) return c.notFound(); + try { + const buf = await readFile(new URL(`../${entry[0]}`, import.meta.url)); + return c.body(buf, 200, { 'Content-Type': entry[1] }); + } catch { + return c.notFound(); + } +}); diff --git a/server/webhook_transport.mjs b/server/webhook_transport.mjs new file mode 100644 index 00000000..3076d627 --- /dev/null +++ b/server/webhook_transport.mjs @@ -0,0 +1,288 @@ +import { lookup as dnsLookup } from 'node:dns/promises'; +import { request as httpsRequest } from 'node:https'; +import { BlockList, isIP } from 'node:net'; + +const DENIED_IPV4_BLOCKS = new BlockList(); +const DENIED_IPV6_BLOCKS = new BlockList(); +const PUBLIC_IPV6_UNICAST = new BlockList(); +PUBLIC_IPV6_UNICAST.addSubnet('2000::', 3, 'ipv6'); + +for (const [address, prefix, family] of [ + ['0.0.0.0', 8, 'ipv4'], + ['10.0.0.0', 8, 'ipv4'], + ['100.64.0.0', 10, 'ipv4'], + ['127.0.0.0', 8, 'ipv4'], + ['169.254.0.0', 16, 'ipv4'], + ['172.16.0.0', 12, 'ipv4'], + ['192.0.0.0', 24, 'ipv4'], + ['192.0.2.0', 24, 'ipv4'], + ['192.31.196.0', 24, 'ipv4'], + ['192.52.193.0', 24, 'ipv4'], + ['192.88.99.0', 24, 'ipv4'], + ['192.168.0.0', 16, 'ipv4'], + ['192.175.48.0', 24, 'ipv4'], + ['198.18.0.0', 15, 'ipv4'], + ['198.51.100.0', 24, 'ipv4'], + ['203.0.113.0', 24, 'ipv4'], + ['224.0.0.0', 4, 'ipv4'], + ['240.0.0.0', 4, 'ipv4'], + ['::', 128, 'ipv6'], + ['::1', 128, 'ipv6'], + ['::ffff:0:0', 96, 'ipv6'], + ['64:ff9b::', 96, 'ipv6'], + ['64:ff9b:1::', 48, 'ipv6'], + ['100::', 64, 'ipv6'], + ['100:0:0:1::', 64, 'ipv6'], + ['2001::', 23, 'ipv6'], + ['2001:db8::', 32, 'ipv6'], + ['2002::', 16, 'ipv6'], + ['2620:4f:8000::', 48, 'ipv6'], + ['3ffe::', 16, 'ipv6'], + ['3fff::', 20, 'ipv6'], + ['5f00::', 16, 'ipv6'], + ['fc00::', 7, 'ipv6'], + ['fe80::', 10, 'ipv6'], + ['ff00::', 8, 'ipv6'], +]) { + (family === 'ipv4' ? DENIED_IPV4_BLOCKS : DENIED_IPV6_BLOCKS) + .addSubnet(address, prefix, family); +} + +const SAFE_ERROR = 'webhook destination unavailable'; +const POLICY_ERROR = 'webhook destination is not permitted'; + +/** Stable, non-secret webhook destination policy failure. */ +export class WebhookDestinationError extends Error { + constructor() { + super(POLICY_ERROR); + this.name = 'WebhookDestinationError'; + } +} + +/** Stable, non-secret resolver/TLS/transport failure. */ +export class WebhookTransportError extends Error { + constructor() { + super(SAFE_ERROR); + this.name = 'WebhookTransportError'; + } +} + +function hostAddress(hostname) { + return hostname.startsWith('[') && hostname.endsWith(']') + ? hostname.slice(1, -1) + : hostname; +} + +function isLocalHostname(hostname) { + const host = hostname.toLowerCase().replace(/\.$/, ''); + return host === 'localhost' + || host.endsWith('.localhost') + || host.endsWith('.local') + || host === 'home.arpa' + || host.endsWith('.home.arpa'); +} + +/** + * Return whether an address is an ordinary Internet-routable webhook target. + * IPv4 special-purpose ranges are denied. IPv6 must be within the ordinary + * 2000::/3 global-unicast envelope and outside denied special-use blocks. + */ +export function isPublicWebhookAddress(address) { + const family = isIP(address); + if (!family) return false; + if (family === 4) return !DENIED_IPV4_BLOCKS.check(address, 'ipv4'); + return PUBLIC_IPV6_UNICAST.check(address, 'ipv6') + && !DENIED_IPV6_BLOCKS.check(address, 'ipv6'); +} + +/** + * Parse and canonicalize a production webhook URL without performing DNS. + * DNS authorization is repeated immediately before each network attempt. + */ +export function validateWebhookRegistrationUrl(value) { + let destination; + try { + destination = new URL(String(value ?? '')); + } catch { + throw new WebhookDestinationError(); + } + if (destination.protocol !== 'https:' + || destination.username + || destination.password + || destination.hash + || !destination.hostname + || isLocalHostname(destination.hostname)) { + throw new WebhookDestinationError(); + } + const literal = hostAddress(destination.hostname); + if (isIP(literal) && !isPublicWebhookAddress(literal)) { + throw new WebhookDestinationError(); + } + return destination.href; +} + +async function withAbort(promise, signal) { + if (!signal) return promise; + if (signal.aborted) throw new WebhookTransportError(); + let onAbort; + const aborted = new Promise((_, reject) => { + onAbort = () => reject(new WebhookTransportError()); + signal.addEventListener('abort', onAbort, { once: true }); + }); + try { + return await Promise.race([promise, aborted]); + } finally { + signal.removeEventListener('abort', onAbort); + } +} + +async function resolvePublicAddresses(destination, lookup, signal) { + const literal = hostAddress(destination.hostname); + if (isIP(literal)) { + if (!isPublicWebhookAddress(literal)) throw new WebhookDestinationError(); + return [{ address: literal, family: isIP(literal) }]; + } + + let answers; + try { + answers = await withAbort( + Promise.resolve(lookup(destination.hostname, { all: true, verbatim: true })), + signal, + ); + } catch (error) { + if (error instanceof WebhookDestinationError || error instanceof WebhookTransportError) throw error; + throw new WebhookTransportError(); + } + if (!Array.isArray(answers) || answers.length === 0) throw new WebhookTransportError(); + + const normalized = []; + const seen = new Set(); + for (const answer of answers) { + const address = String(answer?.address || ''); + const actualFamily = isIP(address); + const family = Number(answer?.family) || actualFamily; + if ((family !== 4 && family !== 6) + || actualFamily !== family + || !isPublicWebhookAddress(address)) { + throw new WebhookDestinationError(); + } + const key = `${family}:${address}`; + if (!seen.has(key)) { + seen.add(key); + normalized.push({ address, family }); + } + } + if (!normalized.length) throw new WebhookTransportError(); + return normalized; +} + +function pinnedLookup(address, family) { + return (_hostname, options, callback) => { + if (options?.all) { + callback(null, [{ address, family }]); + return; + } + callback(null, address, family); + }; +} + +function requestOptions(destination, candidate, headers, signal) { + const tlsHost = hostAddress(destination.hostname); + return { + method: 'POST', + headers, + signal, + agent: false, + lookup: pinnedLookup(candidate.address, candidate.family), + ...(isIP(tlsHost) ? {} : { servername: tlsHost }), + }; +} + +function trackSecureConnect(request, attempt) { + request.once?.('socket', (socket) => { + socket?.once?.('secureConnect', () => { + attempt.secureConnected = true; + }); + }); +} + +async function postToCandidate(destination, candidate, { headers, body, signal, attempt }, request) { + if (signal?.aborted) throw new WebhookTransportError(); + try { + return await withAbort(new Promise((resolve, reject) => { + let req; + try { + req = request( + destination, + requestOptions(destination, candidate, headers, signal), + (response) => { + response.resume?.(); + const status = Number(response.statusCode) || 0; + resolve({ status, ok: status >= 200 && status < 300 }); + }, + ); + } catch { + reject(new WebhookTransportError()); + return; + } + trackSecureConnect(req, attempt); + req.once?.('error', () => reject(new WebhookTransportError())); + req.end(body); + }), signal); + } catch (error) { + if (error instanceof WebhookTransportError) throw error; + throw new WebhookTransportError(); + } +} + +/** + * Build the outbound webhook transport around injectable DNS and HTTPS seams. + * Every POST resolves afresh, rejects mixed/private answers, pins the socket to + * a validated candidate, preserves Host/TLS authority, disables pooling, and + * never follows redirects. A pre-handshake connect failure may fall through to + * another already-validated candidate; after TLS succeeds delivery is ambiguous + * and the signed body is never replayed within the same attempt. + */ +export function createWebhookTransport({ lookup = dnsLookup, request = httpsRequest } = {}) { + if (typeof lookup !== 'function' || typeof request !== 'function') { + throw new TypeError('webhook transport dependencies must be functions'); + } + + return Object.freeze({ + async post(url, { headers = {}, body = '', signal } = {}) { + let destination; + try { + destination = new URL(validateWebhookRegistrationUrl(url)); + } catch (error) { + if (error instanceof WebhookDestinationError) throw error; + throw new WebhookDestinationError(); + } + + const candidates = await resolvePublicAddresses(destination, lookup, signal); + const requestHeaders = Object.fromEntries(new Headers(headers).entries()); + delete requestHeaders['content-length']; + let lastError; + for (const candidate of candidates) { + const attempt = { secureConnected: false }; + try { + return await postToCandidate( + destination, + candidate, + { headers: requestHeaders, body, signal, attempt }, + request, + ); + } catch (error) { + if (!(error instanceof WebhookTransportError)) throw error; + lastError = error; + if (signal?.aborted || attempt.secureConnected) throw error; + } + } + throw lastError || new WebhookTransportError(); + }, + }); +} + +const webhookTransport = createWebhookTransport(); + +/** Send one signed webhook attempt through the production SSRF-safe transport. */ +export const postWebhook = (url, options) => webhookTransport.post(url, options); diff --git a/tests/api/webhook-destination-policy.test.mjs b/tests/api/webhook-destination-policy.test.mjs new file mode 100644 index 00000000..a959dddd --- /dev/null +++ b/tests/api/webhook-destination-policy.test.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +delete process.env.SCOPEWEAVE_DEV; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + +const { app } = await import('../../server/app.mjs'); + +const request = (path, options = {}) => app.request(path, { + ...options, + headers: { + 'content-type': 'application/json', + ...(options.headers || {}), + }, +}); +const json = (value) => JSON.stringify(value); + +let response = await request('/api/auth/signup', { + method: 'POST', + body: json({ email: 'webhook-owner@example.test', password: 'password123', name: 'Webhook Owner' }), +}); +assert.equal(response.status, 200, 'fixture owner signup succeeds'); +const signup = await response.json(); +const authorization = { authorization: `Bearer ${signup.token}` }; + +response = await request('/api/me', { headers: authorization }); +assert.equal(response.status, 200, 'fixture owner can resolve organization'); +const me = await response.json(); +const organizationId = me.orgs[0].id; + +for (const headers of [{}, { authorization: 'Bearer invalid-token' }]) { + response = await request(`/api/orgs/${organizationId}/webhooks`, { + method: 'POST', + headers, + body: json({ url: 'http://127.0.0.1/private', events: ['project.updated'] }), + }); + assert.equal(response.status, 401, 'destination policy never preempts authentication'); + assert.deepEqual(await response.json(), { error: 'unauthorized' }); +} + +const deniedDestinations = [ + 'http://example.com/hook', + 'https://localhost/hook', + 'https://api.localhost/hook', + 'https://127.0.0.1/hook', + 'https://2130706433/hook', + 'https://0x7f000001/hook', + 'https://169.254.169.254/latest/meta-data', + 'https://10.0.0.8/hook', + 'https://192.168.50.12/hook', + 'https://[::1]/hook', + 'https://[fc00::1]/hook', + 'https://[::ffff:127.0.0.1]/hook', + 'https://user:password@example.com/hook', + 'https://example.com/hook#fragment', +]; + +for (const url of deniedDestinations) { + response = await request(`/api/orgs/${organizationId}/webhooks`, { + method: 'POST', + headers: authorization, + body: json({ url, events: ['project.updated'] }), + }); + assert.equal(response.status, 400, `production webhook registration rejects unsafe destination ${url}`); + assert.deepEqual( + await response.json(), + { error: 'valid public https webhook URL required' }, + 'registration failure stays stable and does not disclose resolver or address details', + ); +} + +response = await request(`/api/orgs/${organizationId}/webhooks`, { + method: 'POST', + headers: authorization, + body: json({ url: 'https://hooks.example.com/scopeweave?tenant=buyer', events: ['project.updated'] }), +}); +assert.equal(response.status, 200, 'canonical public HTTPS webhook registration remains supported'); +const created = await response.json(); +assert.equal(created.url, 'https://hooks.example.com/scopeweave?tenant=buyer'); +assert.equal(created.events, 'project.updated'); +assert.match(created.secret, /^whsec_[A-Za-z0-9_-]+$/, 'secret is returned only at creation'); + +response = await request(`/api/orgs/${organizationId}/webhooks`, { + method: 'POST', + headers: authorization, + body: json({ + url: 'HTTPS://HOOKS.EXAMPLE.COM:443/staging/../scopeweave?tenant=buyer', + events: ['project.updated'], + }), +}); +assert.equal(response.status, 200, 'equivalent public HTTPS spelling remains accepted'); +const canonicalized = await response.json(); +assert.equal( + canonicalized.url, + 'https://hooks.example.com/scopeweave?tenant=buyer', + 'registration persists and returns the canonical authority/path rather than attacker-controlled spelling', +); + +response = await request(`/api/orgs/${organizationId}/webhooks`, { + headers: authorization, +}); +assert.equal(response.status, 200, 'owner can inspect registered webhook destinations'); +const listing = await response.json(); +assert.equal( + listing.webhooks.find((webhook) => webhook.id === canonicalized.id)?.url, + 'https://hooks.example.com/scopeweave?tenant=buyer', + 'canonical destination is durable in storage and therefore reused by later delivery attempts', +); + +console.log('webhook destination registration policy tests passed'); From ab6dd51df7c502daca14a67a69e61c79cbd07f2f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 23:57:08 -0700 Subject: [PATCH 03/71] test(server): preserve facade static-path contract --- tests/unit/toast-accessibility.test.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/unit/toast-accessibility.test.mjs b/tests/unit/toast-accessibility.test.mjs index c0aa79a0..1db8ddd6 100644 --- a/tests/unit/toast-accessibility.test.mjs +++ b/tests/unit/toast-accessibility.test.mjs @@ -35,7 +35,10 @@ test('sync status uses the same explicit advisory status semantics', () => { }); test('cloud toast stylesheet is on every production serve path', () => { - const serverApp = readFileSync(new URL('../../server/app.mjs', import.meta.url), 'utf8'); + const serverApp = [ + readFileSync(new URL('../../server/app.mjs', import.meta.url), 'utf8'), + readFileSync(new URL('../../server/app_core.mjs', import.meta.url), 'utf8'), + ].join('\n'); const pagesWorkflow = readFileSync(new URL('../../.github/workflows/pages.yml', import.meta.url), 'utf8'); const staticDockerfile = readFileSync(new URL('../../Dockerfile', import.meta.url), 'utf8'); const serverDockerfile = readFileSync(new URL('../../Dockerfile.server', import.meta.url), 'utf8'); @@ -61,4 +64,4 @@ test('cloud toast state is visibly rendered by a shipped stylesheet', () => { /\.toast\.visible\s*\{[^}]*\bopacity\s*:\s*1\s*;[^}]*\btransform\s*:\s*translateY\(0\)\s*;/s, 'the shipped cloud toast state becomes visually observable', ); -}); +}); \ No newline at end of file From 4b039270c2053c874832d22dc4ed122d2ca0d237 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:02:29 -0700 Subject: [PATCH 04/71] test(server): reproduce unrelated fetch body consumption --- package.json | 2 +- tests/api/webhook-fetch-contract.test.mjs | 32 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 tests/api/webhook-fetch-contract.test.mjs diff --git a/package.json b/package.json index 0ad3854f..c88beacf 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "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/webhook-destination-policy.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/webhook-fetch-contract.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs && node tests/unit/webhook-transport.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/app_core.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/webhook_transport.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/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/webhook-transport.test.mjs && npm run test:api", diff --git a/tests/api/webhook-fetch-contract.test.mjs b/tests/api/webhook-fetch-contract.test.mjs new file mode 100644 index 00000000..9d332458 --- /dev/null +++ b/tests/api/webhook-fetch-contract.test.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +delete process.env.SCOPEWEAVE_DEV; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + +const nativeCalls = []; +globalThis.fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + const body = request.body ? await request.text() : ''; + nativeCalls.push({ method: request.method, url: request.url, body }); + return new Response(body, { status: 200, headers: { 'content-type': 'text/plain' } }); +}; + +await import('../../server/app.mjs'); + +const unrelated = new Request('https://unrelated.example.test/echo', { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: 'preserve-this-body', +}); +const response = await globalThis.fetch(unrelated); + +assert.equal(response.status, 200, 'unrelated native fetch result is preserved'); +assert.equal(await response.text(), 'preserve-this-body'); +assert.deepEqual(nativeCalls, [{ + method: 'POST', + url: 'https://unrelated.example.test/echo', + body: 'preserve-this-body', +}], 'the facade must not consume a non-webhook Request before native fetch receives it'); + +console.log('webhook fetch boundary preserves unrelated Request bodies'); From 1a0389f4b17c5b3f828c06194decc6a55d0871ee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:04:13 -0700 Subject: [PATCH 05/71] fix(server): preserve unrelated fetch request bodies --- server/app.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/app.mjs b/server/app.mjs index 62db65db..383a64de 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -25,7 +25,7 @@ async function protectedWebhookFetch(input, init) { const request = input instanceof Request ? new Request(input, init) : new Request(input, init); - if (!isSignedWebhookRequest(request)) return nativeFetch(input, init); + if (!isSignedWebhookRequest(request)) return nativeFetch(request); const body = request.body ? new Uint8Array(await request.clone().arrayBuffer()) From 3ddb73bc84a14c59ca5f945521fefa1730b32573 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:12:08 -0700 Subject: [PATCH 06/71] fix(webhook): preserve unrelated fetch call semantics --- server/app.mjs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 383a64de..50619d5f 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -22,10 +22,8 @@ function isSignedWebhookRequest(request) { } async function protectedWebhookFetch(input, init) { - const request = input instanceof Request - ? new Request(input, init) - : new Request(input, init); - if (!isSignedWebhookRequest(request)) return nativeFetch(request); + const request = new Request(input, init); + if (!isSignedWebhookRequest(request)) return nativeFetch(input, init); const body = request.body ? new Uint8Array(await request.clone().arrayBuffer()) From e4766272b3d5ae47e187431dd60cef7251d2086b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:12:58 -0700 Subject: [PATCH 07/71] fix(webhook): classify signed fetches without disturbing requests --- server/app.mjs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 50619d5f..c5a23b0f 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -13,17 +13,20 @@ const WEBHOOK_REGISTRATION_PATH = '/api/orgs/:id/webhooks'; const webhookFetchBoundaryKey = Symbol.for('scopeweave.webhook-fetch-boundary'); const nativeFetch = globalThis.fetch.bind(globalThis); -function isSignedWebhookRequest(request) { - return request.method.toUpperCase() === 'POST' - && Boolean(request.headers.get('x-scopeweave-event')) +function isSignedWebhookInput(input, init) { + const requestInput = input instanceof Request ? input : null; + const method = String(init?.method ?? requestInput?.method ?? 'GET').toUpperCase(); + const headers = new Headers(init?.headers ?? requestInput?.headers); + return method === 'POST' + && Boolean(headers.get('x-scopeweave-event')) && /^sha256=[0-9a-f]{64}$/i.test( - request.headers.get('x-scopeweave-signature') || '', + headers.get('x-scopeweave-signature') || '', ); } async function protectedWebhookFetch(input, init) { + if (!isSignedWebhookInput(input, init)) return nativeFetch(input, init); const request = new Request(input, init); - if (!isSignedWebhookRequest(request)) return nativeFetch(input, init); const body = request.body ? new Uint8Array(await request.clone().arrayBuffer()) From e3801b4f1ce278a3d19e5e5e3125db990a79cc3d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:20:52 -0700 Subject: [PATCH 08/71] docs(security): trace outbound webhook SSRF boundary --- docs/doctoring/outbound-webhook-ssrf.md | 53 +++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 docs/doctoring/outbound-webhook-ssrf.md diff --git a/docs/doctoring/outbound-webhook-ssrf.md b/docs/doctoring/outbound-webhook-ssrf.md new file mode 100644 index 00000000..df34dbd2 --- /dev/null +++ b/docs/doctoring/outbound-webhook-ssrf.md @@ -0,0 +1,53 @@ +# Outbound webhook SSRF and DNS-rebinding boundary + +Status: **active pull request only** (`#588`). This document does not claim that the repair is shipped on protected `develop` or released. Protected `develop` remains the source of shipped truth until the reviewed exact contributor head is integrated through live repository and organization gates. + +## Customer decision this control supports + +A ScopeWeave organization administrator may configure a webhook destination that causes ScopeWeave to send signed event data from the server. Because the administrator controls the destination URL, the product must distinguish an ordinary public webhook endpoint from a destination that could reach the ScopeWeave host, cloud metadata, a private network, or another special-use address. + +The active repair therefore makes the network destination an authorization boundary rather than trusting a syntactically valid URL. A customer can use public HTTPS webhook endpoints; production ScopeWeave will reject destinations that are local, private, special-use, ambiguous after DNS resolution, or otherwise outside the public-unicast authority admitted by the transport. + +## Threat and control traceability + +| Threat / requirement | Active-PR control | Regression evidence | +| --- | --- | --- | +| Direct loopback, private, link-local, reserved, documentation, multicast, or other special-use IP destination | `server/webhook_transport.mjs` parses the URL and rejects non-public destination authorities before network I/O. IPv4-mapped IPv6 forms are normalized into the same decision. | `tests/unit/webhook-transport.test.mjs` exercises representative denied IPv4/IPv6 and mapped forms. | +| Hostname resolves to one denied answer or a mixed public+denied answer set | Every A/AAAA result must pass the public-destination policy; a mixed answer set fails closed. | DNS policy cases in `tests/unit/webhook-transport.test.mjs`. | +| DNS validation and connection use different resolver answers (rebinding/TOCTOU) | Resolution is performed before connection; the HTTPS request receives a custom `lookup` result pinned to an address from the just-validated answer set while the original hostname remains the TLS authority/SNI identity. | Rebinding and pinned-lookup cases in `tests/unit/webhook-transport.test.mjs`. | +| Redirect moves a signed body/secret to a second authority | The bounded transport uses Node HTTPS directly and does not implement redirect following. A redirect response is an application response, not a new destination request. | Redirect/non-replay cases in `tests/unit/webhook-transport.test.mjs`. | +| Retry reuses stale DNS authority | The existing application retry calls the protected transport again, so the outer delivery retry performs a new resolution/validation/pinning decision. Pre-connect failure may try another address only from the same already-validated answer set; once TLS has connected, no candidate replay occurs for that attempt. | Pre-connect fallback, post-connect replay, and rebinding-across-attempts tests. | +| Credential or fragment-bearing registration URL | Production registration accepts canonical public `https:` destinations only and rejects credentials/fragments. | `tests/api/webhook-destination-policy.test.mjs`. | +| Development compatibility accidentally weakens production | HTTP is admitted only when `SCOPEWEAVE_DEV=1` and only for explicit loopback development destinations. | Development/production registration policy tests. | +| Transport or resolver details expose internal information | Customer-visible transport errors are stable and do not include resolver answers, credentials, or lower-layer exception text. | Sanitized-failure regressions in `tests/unit/webhook-transport.test.mjs`. | +| Security wrapper changes unrelated outbound integrations | The fetch facade classifies a signed ScopeWeave webhook from method and signature/event headers without constructing or consuming an unrelated `Request`; all unrelated calls retain their original native-fetch input/init semantics. | Existing `tests/api/orchestrator-attribution.test.mjs` plus `tests/api/webhook-fetch-contract.test.mjs`. | + +## Design boundary + +`server/app_core.mjs` is the protected-develop application moved without behavioral editing for this slice. `server/app.mjs` is a bounded facade for webhook registration and signed webhook delivery. `server/webhook_transport.mjs` owns destination policy, resolution, address authorization, HTTPS connection pinning, and transport-level replay safety. + +This structure is intentional: tenant/auth, billing, attachment, Clearfolio, project-planning, event filtering, webhook signing, attempt accounting, and the existing three-second per-attempt abort budget remain in their prior owning code. The security slice does not make those concerns subordinate to model judgment and does not alter central `.github` policy. + +## Evidence state and merge boundary + +The preserved RED history is followed by production implementation and two additional compatibility repairs. On contributor head `e4766272b3d5ae47e187431dd60cef7251d2086b`, the repository's existing unit/API/cloud suites are green, including the webhook transport and unrelated orchestrator attribution regressions. That hosted Server Tests run checked out GitHub's synthetic pull-request merge revision, however, so it is useful behavioral evidence but is not accepted here as immutable contributor-head merge authority. + +Exact-head repository CI is being repaired independently in ScopeWeave PR `#523`; the centrally owned reusable SAST/Security exact-head defect is tracked through `ContextualWisdomLab/.github#1222`. Before `#588` can integrate, the unchanged final contributor head must receive authoritative exact-head owned coverage, required security/dependency/supply-chain evidence, zero valid unresolved findings, and qualifying independent current-head approval. Pending, synthetic-only, stale, predecessor, status-only, or model-only evidence is non-passing. + +## Standards and primary technical basis + +OWASP's SSRF guidance explicitly treats custom webhooks as an SSRF risk, recommends disabling redirect following, and for arbitrary external destinations recommends resolving A and AAAA records and applying the same public-address validation to every result. The implementation additionally binds that validation result to the actual socket lookup so the network destination cannot silently diverge from the authorization decision. Node's `https.request()` supports the HTTP request options needed for a custom `lookup` seam while retaining TLS hostname handling. RFC and IANA registries provide the authority for private, link-local, unique-local, and other special-purpose address classifications. + +## References (APA 7) + +Cheshire, S., Aboba, B., & Guttman, E. (2005). *Dynamic configuration of IPv4 link-local addresses* (RFC 3927). RFC Editor. https://doi.org/10.17487/RFC3927 + +Hinden, R., & Haberman, B. (2005). *Unique local IPv6 unicast addresses* (RFC 4193). RFC Editor. https://doi.org/10.17487/RFC4193 + +Internet Assigned Numbers Authority. (n.d.). *Number-related registries*. Retrieved August 23, 2026, from https://www.iana.org/numbers/registries + +Node.js contributors. (n.d.). *HTTPS*. Node.js documentation. Retrieved August 23, 2026, from https://nodejs.org/api/https.html + +OWASP Foundation. (n.d.). *Server-side request forgery prevention cheat sheet*. OWASP Cheat Sheet Series. Retrieved August 23, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html + +Rekhter, Y., Moskowitz, B., Karrenberg, D., de Groot, G. J., & Lear, E. (1996). *Address allocation for private internets* (RFC 1918). RFC Editor. https://doi.org/10.17487/RFC1918 From 2a1c12fca96eb27f90999bf1e82b0b8d41cd2f58 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:41:58 -0700 Subject: [PATCH 09/71] test(webhook): preserve dev loopback delivery --- .../webhook-development-transport.test.mjs | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 tests/unit/webhook-development-transport.test.mjs diff --git a/tests/unit/webhook-development-transport.test.mjs b/tests/unit/webhook-development-transport.test.mjs new file mode 100644 index 00000000..48fef972 --- /dev/null +++ b/tests/unit/webhook-development-transport.test.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { + WebhookDestinationError, + createWebhookTransport, +} from '../../server/webhook_transport.mjs'; + +function responseRequest(statusCode, capture = {}) { + return (url, options, callback) => { + capture.url = url; + capture.options = options; + capture.calls = (capture.calls || 0) + 1; + const req = new EventEmitter(); + req.end = (body) => { + capture.body = body; + queueMicrotask(() => callback({ + statusCode, + resume() { capture.resumed = true; }, + })); + }; + return req; + }; +} + +const devCapture = {}; +let httpsCalls = 0; +const devTransport = createWebhookTransport({ + allowDevelopmentLoopback: true, + lookup: async () => [{ address: '127.0.0.1', family: 4 }], + request: () => { + httpsCalls += 1; + throw new Error('HTTPS connector must not receive a development HTTP loopback'); + }, + httpRequest: responseRequest(204, devCapture), +}); + +assert.deepEqual( + await devTransport.post('http://127.0.0.1:8788/hook', { + headers: { 'x-scopeweave-event': 'project.update' }, + body: '{"ok":true}', + }), + { status: 204, ok: true }, + 'a loopback URL admitted in development mode is also deliverable', +); +assert.equal(httpsCalls, 0, 'development HTTP loopback never uses the HTTPS connector'); +assert.equal(devCapture.url.protocol, 'http:'); +assert.equal(devCapture.url.hostname, '127.0.0.1'); +assert.equal(devCapture.options.method, 'POST'); +assert.equal(devCapture.options.agent, false); +assert.equal('servername' in devCapture.options, false, 'development HTTP does not configure TLS SNI'); +assert.equal(devCapture.body, '{"ok":true}'); + +const productionTransport = createWebhookTransport({ + allowDevelopmentLoopback: false, + lookup: async () => [{ address: '127.0.0.1', family: 4 }], + request: responseRequest(204), + httpRequest: responseRequest(204), +}); +await assert.rejects( + () => productionTransport.post('http://127.0.0.1:8788/hook'), + WebhookDestinationError, + 'the loopback exception remains unavailable outside explicit development mode', +); + +let privateConnectorCalls = 0; +const privateHostnameTransport = createWebhookTransport({ + allowDevelopmentLoopback: true, + lookup: async () => [{ address: '10.0.0.5', family: 4 }], + request: responseRequest(204), + httpRequest: (...args) => { + privateConnectorCalls += 1; + return responseRequest(204)(...args); + }, +}); +await assert.rejects( + () => privateHostnameTransport.post('http://localhost:8788/hook'), + WebhookDestinationError, + 'development localhost may resolve only to loopback addresses', +); +assert.equal(privateConnectorCalls, 0, 'a non-loopback localhost answer never reaches a connector'); + +console.log('webhook development loopback transport tests passed'); From dd1893ea870bec9ddbd03fbe2c24f084641f72de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:42:43 -0700 Subject: [PATCH 10/71] test(webhook): run dev loopback regression --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index c88beacf..beee62e1 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/webhook-destination-policy.test.mjs && node tests/api/webhook-fetch-contract.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs && node tests/unit/webhook-transport.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs && node tests/unit/webhook-transport.test.mjs && node tests/unit/webhook-development-transport.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/app_core.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/webhook_transport.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/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/webhook-transport.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/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/webhook-transport.test.mjs && node tests/unit/webhook-development-transport.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", From b0c4d45b212fe135ae40fc2607770ebd6ad6ddb0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:45:23 -0700 Subject: [PATCH 11/71] fix(webhook): deliver bounded dev loopback hooks --- server/webhook_transport.mjs | 111 +++++++++++++++++++++++++---------- 1 file changed, 80 insertions(+), 31 deletions(-) diff --git a/server/webhook_transport.mjs b/server/webhook_transport.mjs index 3076d627..5832426c 100644 --- a/server/webhook_transport.mjs +++ b/server/webhook_transport.mjs @@ -1,4 +1,5 @@ import { lookup as dnsLookup } from 'node:dns/promises'; +import { request as httpRequest } from 'node:http'; import { request as httpsRequest } from 'node:https'; import { BlockList, isIP } from 'node:net'; @@ -82,6 +83,26 @@ function isLocalHostname(hostname) { || host.endsWith('.home.arpa'); } +function isLoopbackAddress(address) { + const family = isIP(address); + if (family === 6) return address === '::1'; + if (family !== 4) return false; + const [first] = address.split('.').map(Number); + return first === 127; +} + +function isDevelopmentLoopbackUrl(destination) { + if (destination.protocol !== 'http:' + || destination.username + || destination.password + || destination.hash + || !destination.hostname) return false; + const host = destination.hostname.toLowerCase().replace(/\.$/, ''); + if (host === 'localhost') return true; + const literal = hostAddress(host); + return isLoopbackAddress(literal); +} + /** * Return whether an address is an ordinary Internet-routable webhook target. * IPv4 special-purpose ranges are denied. IPv6 must be within the ordinary @@ -96,16 +117,21 @@ export function isPublicWebhookAddress(address) { } /** - * Parse and canonicalize a production webhook URL without performing DNS. - * DNS authorization is repeated immediately before each network attempt. + * Parse and canonicalize a webhook URL without performing DNS. Production + * destinations are public HTTPS only. Explicit development mode may admit + * HTTP only for localhost or literal loopback addresses; the transport still + * revalidates every resolved address immediately before each connection. */ -export function validateWebhookRegistrationUrl(value) { +export function validateWebhookRegistrationUrl(value, { allowDevelopmentLoopback = false } = {}) { let destination; try { destination = new URL(String(value ?? '')); } catch { throw new WebhookDestinationError(); } + if (allowDevelopmentLoopback && isDevelopmentLoopbackUrl(destination)) { + return destination.href; + } if (destination.protocol !== 'https:' || destination.username || destination.password @@ -136,12 +162,9 @@ async function withAbort(promise, signal) { } } -async function resolvePublicAddresses(destination, lookup, signal) { +async function lookupAddresses(destination, lookup, signal) { const literal = hostAddress(destination.hostname); - if (isIP(literal)) { - if (!isPublicWebhookAddress(literal)) throw new WebhookDestinationError(); - return [{ address: literal, family: isIP(literal) }]; - } + if (isIP(literal)) return [{ address: literal, family: isIP(literal) }]; let answers; try { @@ -161,9 +184,7 @@ async function resolvePublicAddresses(destination, lookup, signal) { const address = String(answer?.address || ''); const actualFamily = isIP(address); const family = Number(answer?.family) || actualFamily; - if ((family !== 4 && family !== 6) - || actualFamily !== family - || !isPublicWebhookAddress(address)) { + if ((family !== 4 && family !== 6) || actualFamily !== family) { throw new WebhookDestinationError(); } const key = `${family}:${address}`; @@ -176,6 +197,18 @@ async function resolvePublicAddresses(destination, lookup, signal) { return normalized; } +async function resolveAuthorizedAddresses(destination, lookup, signal, allowDevelopmentLoopback) { + const candidates = await lookupAddresses(destination, lookup, signal); + const developmentLoopback = allowDevelopmentLoopback && isDevelopmentLoopbackUrl(destination); + for (const candidate of candidates) { + const allowed = developmentLoopback + ? isLoopbackAddress(candidate.address) + : isPublicWebhookAddress(candidate.address); + if (!allowed) throw new WebhookDestinationError(); + } + return candidates; +} + function pinnedLookup(address, family) { return (_hostname, options, callback) => { if (options?.all) { @@ -194,14 +227,15 @@ function requestOptions(destination, candidate, headers, signal) { signal, agent: false, lookup: pinnedLookup(candidate.address, candidate.family), - ...(isIP(tlsHost) ? {} : { servername: tlsHost }), + ...(destination.protocol === 'https:' && !isIP(tlsHost) ? { servername: tlsHost } : {}), }; } -function trackSecureConnect(request, attempt) { +function trackConnection(request, attempt, secure) { request.once?.('socket', (socket) => { - socket?.once?.('secureConnect', () => { - attempt.secureConnected = true; + const event = secure ? 'secureConnect' : 'connect'; + socket?.once?.(event, () => { + attempt.connected = true; }); }); } @@ -225,7 +259,7 @@ async function postToCandidate(destination, candidate, { headers, body, signal, reject(new WebhookTransportError()); return; } - trackSecureConnect(req, attempt); + trackConnection(req, attempt, destination.protocol === 'https:'); req.once?.('error', () => reject(new WebhookTransportError())); req.end(body); }), signal); @@ -236,15 +270,22 @@ async function postToCandidate(destination, candidate, { headers, body, signal, } /** - * Build the outbound webhook transport around injectable DNS and HTTPS seams. - * Every POST resolves afresh, rejects mixed/private answers, pins the socket to - * a validated candidate, preserves Host/TLS authority, disables pooling, and - * never follows redirects. A pre-handshake connect failure may fall through to - * another already-validated candidate; after TLS succeeds delivery is ambiguous - * and the signed body is never replayed within the same attempt. + * Build the outbound webhook transport around injectable DNS and network seams. + * Every POST resolves afresh, rejects unauthorized mixed answers, pins the socket + * to a validated candidate, preserves HTTPS Host/TLS authority, disables pooling, + * and never follows redirects. A pre-connect failure may fall through to another + * already-validated candidate; after a connection is established delivery is + * ambiguous and the signed body is never replayed within the same attempt. */ -export function createWebhookTransport({ lookup = dnsLookup, request = httpsRequest } = {}) { - if (typeof lookup !== 'function' || typeof request !== 'function') { +export function createWebhookTransport({ + lookup = dnsLookup, + request = httpsRequest, + httpRequest: developmentHttpRequest = httpRequest, + allowDevelopmentLoopback = false, +} = {}) { + if (typeof lookup !== 'function' + || typeof request !== 'function' + || typeof developmentHttpRequest !== 'function') { throw new TypeError('webhook transport dependencies must be functions'); } @@ -252,29 +293,35 @@ export function createWebhookTransport({ lookup = dnsLookup, request = httpsRequ async post(url, { headers = {}, body = '', signal } = {}) { let destination; try { - destination = new URL(validateWebhookRegistrationUrl(url)); + destination = new URL(validateWebhookRegistrationUrl(url, { allowDevelopmentLoopback })); } catch (error) { if (error instanceof WebhookDestinationError) throw error; throw new WebhookDestinationError(); } - const candidates = await resolvePublicAddresses(destination, lookup, signal); + const candidates = await resolveAuthorizedAddresses( + destination, + lookup, + signal, + allowDevelopmentLoopback, + ); const requestHeaders = Object.fromEntries(new Headers(headers).entries()); delete requestHeaders['content-length']; + const connector = destination.protocol === 'http:' ? developmentHttpRequest : request; let lastError; for (const candidate of candidates) { - const attempt = { secureConnected: false }; + const attempt = { connected: false }; try { return await postToCandidate( destination, candidate, { headers: requestHeaders, body, signal, attempt }, - request, + connector, ); } catch (error) { if (!(error instanceof WebhookTransportError)) throw error; lastError = error; - if (signal?.aborted || attempt.secureConnected) throw error; + if (signal?.aborted || attempt.connected) throw error; } } throw lastError || new WebhookTransportError(); @@ -282,7 +329,9 @@ export function createWebhookTransport({ lookup = dnsLookup, request = httpsRequ }); } -const webhookTransport = createWebhookTransport(); +const webhookTransport = createWebhookTransport({ + allowDevelopmentLoopback: process.env.SCOPEWEAVE_DEV === '1', +}); -/** Send one signed webhook attempt through the production SSRF-safe transport. */ +/** Send one signed webhook attempt through the SSRF-safe transport policy. */ export const postWebhook = (url, options) => webhookTransport.post(url, options); From ed44d32289241f9f73e99e8119001e4e187d93be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:46:06 -0700 Subject: [PATCH 12/71] fix(webhook): share registration and delivery policy --- server/app.mjs | 26 +++----------------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index c5a23b0f..32c138d2 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -52,30 +52,10 @@ if (!globalThis[webhookFetchBoundaryKey]) { }); } -function canonicalDevelopmentLoopback(value) { - if (process.env.SCOPEWEAVE_DEV !== '1') return null; - let destination; - try { - destination = new URL(String(value ?? '')); - } catch { - return null; - } - if (destination.protocol !== 'http:' - || destination.username - || destination.password - || destination.hash) return null; - const host = destination.hostname.toLowerCase(); - const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host); - const loopbackV4 = ipv4 - && Number(ipv4[1]) === 127 - && ipv4.slice(1).every((part) => Number(part) >= 0 && Number(part) <= 255); - if (!(host === 'localhost' || host === '[::1]' || loopbackV4)) return null; - return destination.href; -} - function canonicalRegistrationUrl(value) { - const developmentLoopback = canonicalDevelopmentLoopback(value); - return developmentLoopback || validateWebhookRegistrationUrl(value); + return validateWebhookRegistrationUrl(value, { + allowDevelopmentLoopback: process.env.SCOPEWEAVE_DEV === '1', + }); } function requestWithJson(original, payload) { From 553d5ed722b5362b01777d2261dc2fd4156ccc39 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:49:10 -0700 Subject: [PATCH 13/71] docs(security): trace webhook destination boundary --- .../doctoring/webhook-destination-security.md | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 docs/doctoring/webhook-destination-security.md diff --git a/docs/doctoring/webhook-destination-security.md b/docs/doctoring/webhook-destination-security.md new file mode 100644 index 00000000..4bf82540 --- /dev/null +++ b/docs/doctoring/webhook-destination-security.md @@ -0,0 +1,64 @@ +# Webhook destination security — active PR trace + +> **Lifecycle:** active-PR evidence only. This document describes the repair lane in PR #588 and must not be read as protected-`develop` shipment or certification evidence until that PR is integrated from an exact gated head. + +## Buyer and operator outcome + +ScopeWeave accepts buyer-configured webhook destinations, so the outbound HTTP client is an SSRF trust boundary. The active repair makes registration and delivery use one destination policy instead of validating a URL once and later allowing the platform resolver/network stack to choose a different address. + +Production webhook destinations are limited to canonical public HTTPS URLs. Immediately before each delivery attempt, ScopeWeave resolves all returned A/AAAA candidates, rejects the entire result if any candidate is malformed or special-use/non-public, and pins the connection to a validated address while preserving the original HTTPS hostname for TLS authority. Redirects are not followed. This closes the common validation-versus-connect gap used by DNS-rebinding/pinning attacks and avoids redirect-based policy escape. + +`SCOPEWEAVE_DEV=1` is an explicit non-production exception. It may admit only HTTP `localhost`, IPv4 `127.0.0.0/8`, or IPv6 `::1` destinations. Delivery reuses that same transport policy; `localhost` DNS answers must all be loopback addresses before any connector is called. The exception does not admit arbitrary RFC 1918, link-local, metadata-service, `.local`, `.localhost` subdomain, or other special-use destinations. + +## Control design + +| Boundary | Active-PR behavior | Acceptance evidence | +| --- | --- | --- | +| URL parsing | WHATWG `URL`; credentials and fragments rejected | `tests/api/webhook-destination-policy.test.mjs`, `tests/unit/webhook-transport.test.mjs` | +| Production scheme | HTTPS only | destination-policy and transport unit tests | +| Special-use IPs | IPv4/IPv6 special-purpose ranges denied; IPv6 public acceptance is limited to the ordinary `2000::/3` global-unicast envelope and excludes registered special-purpose blocks | transport policy tests; IANA registry trace below | +| DNS authorization | Every returned A/AAAA address must pass policy; mixed public/private answers fail closed | transport unit tests | +| DNS rebinding | Resolution occurs per outbound attempt and the socket lookup is pinned to the validated candidate | transport unit tests | +| TLS authority | Original hostname remains TLS `servername` for HTTPS hostnames even while address selection is pinned | transport unit tests | +| Redirects | Transport returns 3xx without following it; delivery is recorded unsuccessful by existing webhook logic | transport/API regression coverage | +| Replay safety | Another validated address may be tried only before a connection becomes established; after connect/TLS secure-connect, the signed body is not replayed within the same attempt | transport unit tests | +| Development loopback | Registration and delivery share the same explicit `SCOPEWEAVE_DEV=1` loopback exception; `localhost` must resolve exclusively to loopback | `tests/unit/webhook-development-transport.test.mjs`, API smoke test | +| Error disclosure | Destination-policy and transport failures expose stable non-secret errors rather than resolver/socket details | destination-policy and transport tests | + +## Standards and primary-source rationale + +OWASP identifies custom webhooks as a direct SSRF use case and recommends resolving all A/AAAA results, applying the same IP policy to every result, and disabling redirect following for outbound requests. The ScopeWeave boundary implements those deterministic controls rather than delegating the decision to model judgment. + +IANA's live IPv4 and IPv6 Special-Purpose Address Registries are the source of truth for ranges that have special semantics and are not ordinary globally reachable destinations. RFC 6890 defines those registries; RFC 8190 updates their registry metadata model. The code uses explicit denied ranges so addresses such as loopback, private-use, link-local, documentation, multicast, IPv4-mapped IPv6, and other special-purpose space cannot become production webhook targets. + +RFC 6761 defines `localhost.` names as special-use and states that address queries for localhost names are expected to yield loopback addresses. ScopeWeave therefore treats bare `localhost` as a development-only spelling and still validates its actual resolver answers as loopback before connection. Literal `127.0.0.0/8` and `::1` follow their IANA/RFC loopback semantics. + +Node.js `https.request()` accepts the HTTP request options plus TLS options including `servername`; the active transport uses an injected `lookup` function to pin the validated address while retaining the URL hostname as TLS authority. `agent: false` prevents connection pooling from silently reusing a socket whose address was authorized under a prior resolution. + +## TDD and current verification trace + +The review finding that exposed registration/delivery drift is preserved by `tests/unit/webhook-development-transport.test.mjs`. On contributor head `dd1893ea870bec9ddbd03fbe2c24f084641f72de`, hosted Server Tests run `32626294458` failed at the new development-loopback delivery assertion with `WebhookDestinationError`; that is the realistic RED reproduction. + +The root-cause repair is carried by `server/webhook_transport.mjs` plus the narrow registration facade in `server/app.mjs`: one validator now defines both registration and delivery admission. On contributor head `ed44d32289241f9f73e99e8119001e4e187d93be`, Server Tests run `32626453099` passed both `unit-and-api` and `cloud-e2e`; its log explicitly includes `webhook development loopback transport tests passed` and `API smoke tests passed`. Fuzz, Dependency Review, OSV Scanner, Security Scan, and SAST Semgrep also reported terminal success for that contributor revision at the workflow level. + +Those hosted results are **behavioral regression evidence, not merge authority**. The repository's current PR Server Tests workflow checks out GitHub's synthetic merge result (`75aa3377b632c9e954c05658e377e8306ac0f5ab` for that run) rather than the unchanged contributor head. The repo-owned exact-head workflow repair remains tracked in #523, and centrally reusable SAST/Security exact-head repair remains owned by `ContextualWisdomLab/.github#1222`. Integration still requires fresh exact-head evidence and a qualifying independent current-head approval under live branch protection/rulesets. + +## Rollback and residual risk + +Rollback is code-only; this repair adds no database migration. Reverting the transport would re-open the verified registration/delivery consistency defect and should therefore require an explicit security exception rather than an operational shortcut. + +Residual limits are intentional and visible: this is an outbound destination authorization layer, not a general egress firewall. Production environments should still apply network egress controls and metadata-service protections as defense in depth. A public service that intentionally redirects or resolves through special-purpose/private addresses is incompatible with the production webhook policy and must expose a stable public HTTPS endpoint instead of requesting an allowlist bypass. + +## References (APA 7) + +Cheshire, S., & Krochmal, M. (2013). *Special-use domain names* (RFC 6761). Internet Engineering Task Force. https://doi.org/10.17487/RFC6761 + +Cotton, M., Vegoda, L., Bonica, R., & Haberman, B. (2013). *Special-purpose IP address registries* (RFC 6890). Internet Engineering Task Force. https://doi.org/10.17487/RFC6890 + +Internet Assigned Numbers Authority. (2025, October 9). *IPv6 special-purpose address space*. https://www.iana.org/assignments/iana-ipv6-special-registry/ + +Internet Assigned Numbers Authority. (n.d.). *IPv4 special-purpose address space*. Retrieved August 23, 2026, from https://www.iana.org/assignments/iana-ipv4-special-registry/ + +Node.js contributors. (2025). *HTTPS: Node.js v22 documentation*. Node.js. https://nodejs.org/docs/v22.13.0/api/https.html + +OWASP Foundation. (n.d.). *Server side request forgery prevention cheat sheet*. Retrieved August 23, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html From b65fa41d30a024f6a37466344c73f701c1d0bf81 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 01:07:33 -0700 Subject: [PATCH 14/71] test(security): reproduce legacy HTTP webhook failure --- tests/api/webhook-legacy-migration.test.mjs | 117 ++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 tests/api/webhook-legacy-migration.test.mjs diff --git a/tests/api/webhook-legacy-migration.test.mjs b/tests/api/webhook-legacy-migration.test.mjs new file mode 100644 index 00000000..5d575f83 --- /dev/null +++ b/tests/api/webhook-legacy-migration.test.mjs @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { DatabaseSync } from 'node:sqlite'; + +const directory = mkdtempSync(join(tmpdir(), 'scopeweave-webhook-migration-')); +const databasePath = join(directory, 'legacy.sqlite'); +const legacy = new DatabaseSync(databasePath); +legacy.exec(` +CREATE TABLE users ( + id INTEGER PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + token_version INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE orgs ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + owner_id INTEGER NOT NULL REFERENCES users(id), + plan TEXT NOT NULL DEFAULT 'free', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE webhooks ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, + url TEXT NOT NULL, + secret TEXT NOT NULL, + events TEXT NOT NULL DEFAULT '*', + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + target_type TEXT, + target_id TEXT, + meta TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +INSERT INTO users(id,email,password_hash,name) +VALUES(1,'legacy-owner@example.test','unused','Legacy Owner'); +INSERT INTO orgs(id,name,owner_id) VALUES(1,'Legacy Buyer',1); +INSERT INTO webhooks(id,org_id,url,secret,events,active) VALUES + (41,1,'http://legacy-webhook.example.test/callback','whsec_legacy_active','project.update',1), + (42,1,'https://webhook.example.test/callback','whsec_public_https','project.update',1), + (43,1,'http://retired-webhook.example.test/callback','whsec_legacy_inactive','project.update',0); +`); +legacy.close(); + +process.env.SCOPEWEAVE_DB = databasePath; + +try { + const moduleUrl = pathToFileURL(join(process.cwd(), 'server', 'db.mjs')).href; + const first = await import(`${moduleUrl}?legacy-http-migration=first`); + + assert.deepEqual( + first.db.prepare('SELECT id, active FROM webhooks ORDER BY id').all(), + [ + { id: 41, active: 0 }, + { id: 42, active: 1 }, + { id: 43, active: 0 }, + ], + 'startup disables only previously active insecure HTTP webhooks and preserves HTTPS/inactive rows', + ); + + const firstAudit = first.db.prepare( + `SELECT action, target_type AS targetType, target_id AS targetId, meta + FROM audit_log + WHERE org_id = 1 AND action = 'webhook.security_block' + ORDER BY id`, + ).all(); + assert.equal(firstAudit.length, 1, 'migration emits one durable buyer-visible security audit event'); + assert.equal(firstAudit[0].targetType, 'webhook'); + assert.equal(firstAudit[0].targetId, '41'); + assert.deepEqual( + JSON.parse(firstAudit[0].meta), + { + reason: 'insecure_scheme', + nextAction: 'register_public_https_replacement', + }, + 'audit evidence gives the operator a concrete remediation action', + ); + assert.equal( + firstAudit[0].meta.includes('whsec_'), + false, + 'buyer-visible audit evidence never includes the webhook signing secret', + ); + first.db.close(); + + const second = await import(`${moduleUrl}?legacy-http-migration=second`); + assert.equal( + second.db.prepare( + `SELECT COUNT(*) AS count + FROM audit_log + WHERE org_id = 1 AND action = 'webhook.security_block' AND target_id = '41'`, + ).get().count, + 1, + 'restart is idempotent and does not duplicate the security audit event', + ); + assert.equal( + second.db.prepare('SELECT active FROM webhooks WHERE id = 41').get().active, + 0, + 'restart remains fail-closed for the migrated insecure destination', + ); + second.db.close(); +} finally { + delete process.env.SCOPEWEAVE_DB; + rmSync(directory, { recursive: true, force: true }); +} + +console.log('legacy HTTP webhook migration regression passed'); From 0e28697724c3671cdb5569324962d12b4cf6db05 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 01:07:58 -0700 Subject: [PATCH 15/71] test(security): run legacy webhook migration regression --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index beee62e1..59901353 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "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/webhook-destination-policy.test.mjs && node tests/api/webhook-fetch-contract.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/webhook-fetch-contract.test.mjs && node tests/api/webhook-legacy-migration.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs && node tests/unit/webhook-transport.test.mjs && node tests/unit/webhook-development-transport.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/app_core.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/webhook_transport.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/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/webhook-transport.test.mjs && node tests/unit/webhook-development-transport.test.mjs && npm run test:api", From 710319b956cedf9c5c9e3693c94abe400b131f61 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 01:11:17 -0700 Subject: [PATCH 16/71] fix(security): add legacy webhook migration boundary --- server/webhook_legacy_migration.mjs | 92 +++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 server/webhook_legacy_migration.mjs diff --git a/server/webhook_legacy_migration.mjs b/server/webhook_legacy_migration.mjs new file mode 100644 index 00000000..298c0743 --- /dev/null +++ b/server/webhook_legacy_migration.mjs @@ -0,0 +1,92 @@ +import { validateWebhookRegistrationUrl } from './webhook_transport.mjs'; + +const SECURITY_ACTION = 'webhook.security_block'; +const SECURITY_META = JSON.stringify({ + reason: 'insecure_scheme', + nextAction: 'register_public_https_replacement', +}); + +function isPreservedDevelopmentLoopback(url, allowDevelopmentLoopback) { + if (!allowDevelopmentLoopback) return false; + try { + const canonical = validateWebhookRegistrationUrl(url, { + allowDevelopmentLoopback: true, + }); + return new URL(canonical).protocol === 'http:'; + } catch { + return false; + } +} + +/** + * Disable previously accepted HTTP webhook destinations before requests serve. + * + * Historical ScopeWeave releases accepted arbitrary `http://` webhook URLs. + * Production delivery now requires public HTTPS, so leaving those rows active + * would create an endless silent retry loop. This migration disables only + * active legacy HTTP rows, writes one tenant-visible audit event with a concrete + * replacement action, and never reads or copies the webhook signing secret. + * Explicit development mode preserves only loopback HTTP URLs that the current + * destination policy still permits. + * + * @param {import('node:sqlite').DatabaseSync} database Open ScopeWeave database. + * @param {{allowDevelopmentLoopback?: boolean}} [options] Migration policy. + * @returns {number} Number of webhook rows newly disabled during this run. + */ +export function migrateLegacyWebhookDestinations( + database, + { allowDevelopmentLoopback = false } = {}, +) { + database.exec('BEGIN IMMEDIATE'); + try { + const candidates = database.prepare( + `SELECT id, org_id AS orgId, url + FROM webhooks + WHERE active = 1 AND lower(url) LIKE 'http://%' + ORDER BY id`, + ).all(); + const disable = database.prepare( + 'UPDATE webhooks SET active = 0 WHERE id = ? AND org_id = ? AND active = 1', + ); + const audit = database.prepare( + `INSERT INTO audit_log(org_id, user_id, action, target_type, target_id, meta) + SELECT ?, NULL, ?, 'webhook', ?, ? + WHERE NOT EXISTS ( + SELECT 1 + FROM audit_log + WHERE org_id = ? + AND action = ? + AND target_type = 'webhook' + AND target_id = ? + )`, + ); + + let disabled = 0; + for (const candidate of candidates) { + if (isPreservedDevelopmentLoopback(candidate.url, allowDevelopmentLoopback)) { + continue; + } + const targetId = String(candidate.id); + const result = disable.run(candidate.id, candidate.orgId); + if (!result.changes) continue; + disabled += Number(result.changes); + audit.run( + candidate.orgId, + SECURITY_ACTION, + targetId, + SECURITY_META, + candidate.orgId, + SECURITY_ACTION, + targetId, + ); + } + database.exec('COMMIT'); + return disabled; + } catch (error) { + try { + database.exec('ROLLBACK'); + } finally { + throw error; + } + } +} From a8005a509acd0915e94bebffd6cf8a3a255fa2aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 01:11:55 -0700 Subject: [PATCH 17/71] fix(security): disable legacy insecure webhooks at startup --- server/db.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/db.mjs b/server/db.mjs index 122b70d6..d61b53d6 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -4,6 +4,7 @@ import { DatabaseSync } from 'node:sqlite'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; +import { migrateLegacyWebhookDestinations } from './webhook_legacy_migration.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const dbPath = process.env.SCOPEWEAVE_DB || join(__dirname, '..', 'data.db'); @@ -177,5 +178,9 @@ try { db.exec('ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAU try { db.exec('ALTER TABLE projects ADD COLUMN archived INTEGER NOT NULL DEFAULT 0'); } catch { /* already there */ } try { db.exec("ALTER TABLE projects ADD COLUMN methodology TEXT NOT NULL DEFAULT 'waterfall'"); } catch { /* already there */ } +migrateLegacyWebhookDestinations(db, { + allowDevelopmentLoopback: process.env.SCOPEWEAVE_DEV === '1', +}); + // node:sqlite returns lastInsertRowid as number|bigint; normalize to Number. -export const rowid = (r) => Number(r.lastInsertRowid); +export const rowid = (r) => Number(r.lastInsertRowid); \ No newline at end of file From 00cc8aed3b95160fab6b9bfa27a2ad36f03c2ece Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 01:12:41 -0700 Subject: [PATCH 18/71] test(security): cover webhook migration policy and rollback --- tests/unit/webhook-legacy-migration.test.mjs | 123 +++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 tests/unit/webhook-legacy-migration.test.mjs diff --git a/tests/unit/webhook-legacy-migration.test.mjs b/tests/unit/webhook-legacy-migration.test.mjs new file mode 100644 index 00000000..e66b3dd3 --- /dev/null +++ b/tests/unit/webhook-legacy-migration.test.mjs @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +import { migrateLegacyWebhookDestinations } from '../../server/webhook_legacy_migration.mjs'; + +function createDatabase() { + const database = new DatabaseSync(':memory:'); + database.exec(` + CREATE TABLE webhooks ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL, + url TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1 + ); + CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL, + user_id INTEGER, + action TEXT NOT NULL, + target_type TEXT, + target_id TEXT, + meta TEXT + ); + `); + return database; +} + +const production = createDatabase(); +production.exec(` + INSERT INTO webhooks(id,org_id,url,active) VALUES + (1,7,'http://legacy.example.test/hook',1), + (2,7,'http://localhost:8080/hook',1), + (3,7,'https://public.example.test/hook',1), + (4,7,'http://retired.example.test/hook',0); +`); +assert.equal( + migrateLegacyWebhookDestinations(production), + 2, + 'production disables every active historical HTTP destination', +); +assert.deepEqual( + production.prepare('SELECT id, active FROM webhooks ORDER BY id').all(), + [ + { id: 1, active: 0 }, + { id: 2, active: 0 }, + { id: 3, active: 1 }, + { id: 4, active: 0 }, + ], +); +assert.equal( + production.prepare("SELECT COUNT(*) AS count FROM audit_log WHERE action = 'webhook.security_block'").get().count, + 2, + 'each newly disabled production row gets one tenant-visible security audit event', +); +assert.equal( + migrateLegacyWebhookDestinations(production), + 0, + 'rerunning the migration is idempotent once insecure rows are inactive', +); +assert.equal( + production.prepare("SELECT COUNT(*) AS count FROM audit_log WHERE action = 'webhook.security_block'").get().count, + 2, + 'idempotent restart does not duplicate audit evidence', +); +production.close(); + +const development = createDatabase(); +development.exec(` + INSERT INTO webhooks(id,org_id,url,active) VALUES + (10,8,'http://localhost:8080/hook',1), + (11,8,'http://127.0.0.8:8080/hook',1), + (12,8,'http://[::1]:8080/hook',1), + (13,8,'http://public.example.test/hook',1), + (14,8,'http://localhost.evil.example/hook',1); +`); +assert.equal( + migrateLegacyWebhookDestinations(development, { allowDevelopmentLoopback: true }), + 2, + 'explicit development mode preserves only loopback HTTP rows and still blocks other HTTP destinations', +); +assert.deepEqual( + development.prepare('SELECT id, active FROM webhooks ORDER BY id').all(), + [ + { id: 10, active: 1 }, + { id: 11, active: 1 }, + { id: 12, active: 1 }, + { id: 13, active: 0 }, + { id: 14, active: 0 }, + ], +); +development.close(); + +const rollback = createDatabase(); +rollback.exec(` + INSERT INTO webhooks(id,org_id,url,active) + VALUES(20,9,'http://legacy.example.test/hook',1); + CREATE TRIGGER reject_security_audit + BEFORE INSERT ON audit_log + BEGIN + SELECT RAISE(ABORT, 'audit write rejected'); + END; +`); +assert.throws( + () => migrateLegacyWebhookDestinations(rollback), + /audit write rejected/, + 'migration fails closed when durable audit evidence cannot be written', +); +assert.equal( + rollback.prepare('SELECT active FROM webhooks WHERE id = 20').get().active, + 1, + 'failed audit persistence rolls back the webhook mutation atomically', +); +assert.equal( + rollback.prepare('SELECT COUNT(*) AS count FROM audit_log').get().count, + 0, + 'failed migration leaves no partial audit record', +); +assert.doesNotThrow( + () => rollback.exec('BEGIN IMMEDIATE; COMMIT;'), + 'rollback releases the write transaction for subsequent startup work', +); +rollback.close(); + +console.log('legacy webhook migration unit tests passed'); From 85b9c7255475272d45519a5a779f66e6efe4f712 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 01:13:22 -0700 Subject: [PATCH 19/71] test(security): enforce legacy migration coverage --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 59901353..b30b1551 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/webhook-destination-policy.test.mjs && node tests/api/webhook-fetch-contract.test.mjs && node tests/api/webhook-legacy-migration.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs && node tests/unit/webhook-transport.test.mjs && node tests/unit/webhook-development-transport.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/app_core.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/webhook_transport.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/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/webhook-transport.test.mjs && node tests/unit/webhook-development-transport.test.mjs && npm run test:api", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs && node tests/unit/webhook-transport.test.mjs && node tests/unit/webhook-development-transport.test.mjs && node tests/unit/webhook-legacy-migration.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/app_core.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/webhook_transport.mjs --include=server/webhook_legacy_migration.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/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/webhook-transport.test.mjs && node tests/unit/webhook-development-transport.test.mjs && node tests/unit/webhook-legacy-migration.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", From 11540883a8c02a98717514236666ae02b1dfbc68 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 01:16:04 -0700 Subject: [PATCH 20/71] docs(security): trace legacy webhook remediation --- .../doctoring/webhook-destination-security.md | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/doctoring/webhook-destination-security.md b/docs/doctoring/webhook-destination-security.md index 4bf82540..14195e23 100644 --- a/docs/doctoring/webhook-destination-security.md +++ b/docs/doctoring/webhook-destination-security.md @@ -8,7 +8,9 @@ ScopeWeave accepts buyer-configured webhook destinations, so the outbound HTTP c Production webhook destinations are limited to canonical public HTTPS URLs. Immediately before each delivery attempt, ScopeWeave resolves all returned A/AAAA candidates, rejects the entire result if any candidate is malformed or special-use/non-public, and pins the connection to a validated address while preserving the original HTTPS hostname for TLS authority. Redirects are not followed. This closes the common validation-versus-connect gap used by DNS-rebinding/pinning attacks and avoids redirect-based policy escape. -`SCOPEWEAVE_DEV=1` is an explicit non-production exception. It may admit only HTTP `localhost`, IPv4 `127.0.0.0/8`, or IPv6 `::1` destinations. Delivery reuses that same transport policy; `localhost` DNS answers must all be loopback addresses before any connector is called. The exception does not admit arbitrary RFC 1918, link-local, metadata-service, `.local`, `.localhost` subdomain, or other special-use destinations. +Historical ScopeWeave releases accepted `http://` webhook destinations. Leaving those rows active after tightening the transport would make an existing customer integration fail silently on every delivery and retry. The active repair therefore performs a transactional, idempotent startup state migration: previously active HTTP destinations that the current production policy cannot deliver are disabled, and one tenant-visible `webhook.security_block` audit event tells the operator to `register_public_https_replacement`. The audit record never reads or copies the signing secret. HTTPS rows and already inactive rows are left unchanged. + +`SCOPEWEAVE_DEV=1` is an explicit non-production exception. It may admit only HTTP `localhost`, IPv4 `127.0.0.0/8`, or IPv6 `::1` destinations. Registration, delivery, and legacy-row migration reuse that same policy: a stored development loopback HTTP webhook remains active, while arbitrary HTTP destinations are still disabled. `localhost` DNS answers must all be loopback addresses before any connector is called. The exception does not admit arbitrary RFC 1918, link-local, metadata-service, `.local`, `.localhost` subdomain, or other special-use destinations. ## Control design @@ -22,7 +24,10 @@ Production webhook destinations are limited to canonical public HTTPS URLs. Imme | TLS authority | Original hostname remains TLS `servername` for HTTPS hostnames even while address selection is pinned | transport unit tests | | Redirects | Transport returns 3xx without following it; delivery is recorded unsuccessful by existing webhook logic | transport/API regression coverage | | Replay safety | Another validated address may be tried only before a connection becomes established; after connect/TLS secure-connect, the signed body is not replayed within the same attempt | transport unit tests | -| Development loopback | Registration and delivery share the same explicit `SCOPEWEAVE_DEV=1` loopback exception; `localhost` must resolve exclusively to loopback | `tests/unit/webhook-development-transport.test.mjs`, API smoke test | +| Development loopback | Registration, delivery, and startup migration share the same explicit `SCOPEWEAVE_DEV=1` loopback exception; `localhost` must resolve exclusively to loopback | `tests/unit/webhook-development-transport.test.mjs`, `tests/unit/webhook-legacy-migration.test.mjs`, API smoke test | +| Legacy HTTP state | Active historical HTTP rows are disabled atomically before serving; HTTPS and already inactive rows are preserved; one idempotent audit event gives the replacement action | `tests/api/webhook-legacy-migration.test.mjs`, `tests/unit/webhook-legacy-migration.test.mjs` | +| Migration failure | Mutation and audit persistence share one `BEGIN IMMEDIATE` transaction; failure to write durable audit evidence rolls the row mutation back | `tests/unit/webhook-legacy-migration.test.mjs` | +| Secret handling | Migration queries only webhook id, tenant id, URL, and active state; audit metadata is fixed non-secret remediation data | migration unit/API regressions | | Error disclosure | Destination-policy and transport failures expose stable non-secret errors rather than resolver/socket details | destination-policy and transport tests | ## Standards and primary-source rationale @@ -35,17 +40,25 @@ RFC 6761 defines `localhost.` names as special-use and states that address queri Node.js `https.request()` accepts the HTTP request options plus TLS options including `servername`; the active transport uses an injected `lookup` function to pin the validated address while retaining the URL hostname as TLS authority. `agent: false` prevents connection pooling from silently reusing a socket whose address was authorized under a prior resolution. +The legacy-row transition is a product compatibility control rather than a new network policy: once the production transport legitimately refuses HTTP, continuing to mark an impossible destination active would create misleading operability state. The migration therefore makes the persisted state match the enforceable transport policy and records the customer next action in the existing tenant audit trail. + ## TDD and current verification trace The review finding that exposed registration/delivery drift is preserved by `tests/unit/webhook-development-transport.test.mjs`. On contributor head `dd1893ea870bec9ddbd03fbe2c24f084641f72de`, hosted Server Tests run `32626294458` failed at the new development-loopback delivery assertion with `WebhookDestinationError`; that is the realistic RED reproduction. The root-cause repair is carried by `server/webhook_transport.mjs` plus the narrow registration facade in `server/app.mjs`: one validator now defines both registration and delivery admission. On contributor head `ed44d32289241f9f73e99e8119001e4e187d93be`, Server Tests run `32626453099` passed both `unit-and-api` and `cloud-e2e`; its log explicitly includes `webhook development loopback transport tests passed` and `API smoke tests passed`. Fuzz, Dependency Review, OSV Scanner, Security Scan, and SAST Semgrep also reported terminal success for that contributor revision at the workflow level. -Those hosted results are **behavioral regression evidence, not merge authority**. The repository's current PR Server Tests workflow checks out GitHub's synthetic merge result (`75aa3377b632c9e954c05658e377e8306ac0f5ab` for that run) rather than the unchanged contributor head. The repo-owned exact-head workflow repair remains tracked in #523, and centrally reusable SAST/Security exact-head repair remains owned by `ContextualWisdomLab/.github#1222`. Integration still requires fresh exact-head evidence and a qualifying independent current-head approval under live branch protection/rulesets. +A later current-source review found a separate compatibility defect: pre-existing HTTP rows accepted by historical releases would remain active even though the new production transport could never deliver them. The realistic database regression was committed first at `b65fa41d30a024f6a37466344c73f701c1d0bf81` and registered in the canonical API lane at `0e28697724c3671cdb5569324962d12b4cf6db05`. That exact source had no startup migration, so its seeded active legacy HTTP row necessarily remained active. Hosted Server Tests for that transient RED revision were cancelled after the repair branch advanced; they are not claimed as RED evidence. + +The smallest root-cause repair introduces `server/webhook_legacy_migration.mjs` and invokes it from `server/db.mjs` after schema initialization. The migration uses the same current destination-policy validator for the development exception, selects no signing secret, changes only active historical HTTP rows, records one fixed tenant audit remediation event, and is atomic and idempotent. `tests/unit/webhook-legacy-migration.test.mjs` additionally proves production behavior, development-loopback preservation, restart idempotence, and transaction rollback when audit persistence fails. `tests/api/webhook-legacy-migration.test.mjs` exercises the real startup import against a legacy on-disk SQLite schema. Both regressions are part of the canonical unit/API and coverage commands. + +Hosted results for this PR remain **behavioral regression evidence, not merge authority**, until the exact unchanged contributor head has been regenerated under corrected checkout controls. The repository's current PR Server Tests workflow still materializes GitHub's synthetic merge result rather than the contributor head. The repo-owned exact-head workflow repair remains tracked in #523, and centrally reusable SAST/Security exact-head repair remains owned by `ContextualWisdomLab/.github#1222`. Integration still requires fresh exact-head evidence and a qualifying independent current-head approval under live branch protection/rulesets. ## Rollback and residual risk -Rollback is code-only; this repair adds no database migration. Reverting the transport would re-open the verified registration/delivery consistency defect and should therefore require an explicit security exception rather than an operational shortcut. +The startup transition adds no schema object, but it is a real persisted-data state migration: rows that could no longer be delivered under production policy become inactive. The mutation and its audit evidence are committed together or rolled back together. Re-running startup is idempotent. + +Reverting the code does not safely reactivate migrated rows and must not be used as an implicit downgrade path. An operator who needs to restore delivery should register a new public-HTTPS webhook through the normal authenticated API. Reactivating an old HTTP destination would require an explicit security exception and is outside the supported production recovery path. The existing audit record remains durable evidence of why the row was disabled and what action the tenant should take. Residual limits are intentional and visible: this is an outbound destination authorization layer, not a general egress firewall. Production environments should still apply network egress controls and metadata-service protections as defense in depth. A public service that intentionally redirects or resolves through special-purpose/private addresses is incompatible with the production webhook policy and must expose a stable public HTTPS endpoint instead of requesting an allowlist bypass. @@ -61,4 +74,4 @@ Internet Assigned Numbers Authority. (n.d.). *IPv4 special-purpose address space Node.js contributors. (2025). *HTTPS: Node.js v22 documentation*. Node.js. https://nodejs.org/docs/v22.13.0/api/https.html -OWASP Foundation. (n.d.). *Server side request forgery prevention cheat sheet*. Retrieved August 23, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html +OWASP Foundation. (n.d.). *Server side request forgery prevention cheat sheet*. Retrieved August 23, 2026, from https://cheatsheetseries.owasp.org/cheatsheetsheetseries/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html \ No newline at end of file From 95e7bd7bfc109abde7eae7e6ee76cf01ebbf77d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 01:16:53 -0700 Subject: [PATCH 21/71] docs(security): correct SSRF reference link --- docs/doctoring/webhook-destination-security.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/doctoring/webhook-destination-security.md b/docs/doctoring/webhook-destination-security.md index 14195e23..96254c2f 100644 --- a/docs/doctoring/webhook-destination-security.md +++ b/docs/doctoring/webhook-destination-security.md @@ -74,4 +74,4 @@ Internet Assigned Numbers Authority. (n.d.). *IPv4 special-purpose address space Node.js contributors. (2025). *HTTPS: Node.js v22 documentation*. Node.js. https://nodejs.org/docs/v22.13.0/api/https.html -OWASP Foundation. (n.d.). *Server side request forgery prevention cheat sheet*. Retrieved August 23, 2026, from https://cheatsheetseries.owasp.org/cheatsheetsheetseries/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html \ No newline at end of file +OWASP Foundation. (n.d.). *Server side request forgery prevention cheat sheet*. Retrieved August 23, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html \ No newline at end of file From c7f299a480dc89873fc08807f460c7d248134a83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 01:17:31 -0700 Subject: [PATCH 22/71] docs(security): record webhook destination hardening --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e434fa01..325287c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Constrained outbound webhook registration and delivery to public HTTPS with + per-attempt DNS/IP authorization, validated-address pinning, redirect + non-following, and replay-safe fallback; the explicit development exception + is limited to loopback HTTP. Active legacy HTTP webhook rows that production + can no longer deliver are transactionally disabled on startup with a + tenant-visible audit next action instead of silently retrying forever. - Made contextual-orchestrator briefing requests fail closed unless an authenticated endpoint is configured. Deterministic generated text is restricted to explicit `SCOPEWEAVE_DEV=1`, message/provider responses are bounded and validated, and non-loopback HTTP transport is rejected. - Made `SCOPEWEAVE_JWT_SECRET` mandatory at startup and rejected weak or unexpanded placeholder values so production deployments fail closed. @@ -106,4 +112,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 리스트에서의 버벅임 현상을 해결했습니다. \ No newline at end of file From da062b9b9b82f84ae805a5ed31365e15e63d43a5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 01:43:11 -0700 Subject: [PATCH 23/71] test(security): cover legacy private webhook migration --- tests/unit/webhook-legacy-migration.test.mjs | 43 ++++++++++++++------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/tests/unit/webhook-legacy-migration.test.mjs b/tests/unit/webhook-legacy-migration.test.mjs index e66b3dd3..a6880bcb 100644 --- a/tests/unit/webhook-legacy-migration.test.mjs +++ b/tests/unit/webhook-legacy-migration.test.mjs @@ -24,41 +24,56 @@ function createDatabase() { return database; } +function plainRows(rows) { + return rows.map((row) => ({ ...row })); +} + const production = createDatabase(); production.exec(` INSERT INTO webhooks(id,org_id,url,active) VALUES (1,7,'http://legacy.example.test/hook',1), (2,7,'http://localhost:8080/hook',1), (3,7,'https://public.example.test/hook',1), - (4,7,'http://retired.example.test/hook',0); + (4,7,'http://retired.example.test/hook',0), + (5,7,'https://localhost/hook',1), + (6,7,'https://127.0.0.1/hook',1), + (7,7,'https://10.0.0.8/hook',1); `); assert.equal( migrateLegacyWebhookDestinations(production), - 2, - 'production disables every active historical HTTP destination', + 5, + 'production disables every active historical destination rejected by the current registration policy', ); assert.deepEqual( - production.prepare('SELECT id, active FROM webhooks ORDER BY id').all(), + plainRows(production.prepare('SELECT id, active FROM webhooks ORDER BY id').all()), [ { id: 1, active: 0 }, { id: 2, active: 0 }, { id: 3, active: 1 }, { id: 4, active: 0 }, + { id: 5, active: 0 }, + { id: 6, active: 0 }, + { id: 7, active: 0 }, ], ); assert.equal( production.prepare("SELECT COUNT(*) AS count FROM audit_log WHERE action = 'webhook.security_block'").get().count, - 2, + 5, 'each newly disabled production row gets one tenant-visible security audit event', ); +assert.deepEqual( + plainRows(production.prepare("SELECT DISTINCT meta FROM audit_log WHERE action = 'webhook.security_block'").all()), + [{ meta: JSON.stringify({ reason: 'destination_policy', nextAction: 'register_public_https_replacement' }) }], + 'audit evidence explains the current destination-policy incompatibility rather than assuming every row used HTTP', +); assert.equal( migrateLegacyWebhookDestinations(production), 0, - 'rerunning the migration is idempotent once insecure rows are inactive', + 'rerunning the migration is idempotent once incompatible rows are inactive', ); assert.equal( production.prepare("SELECT COUNT(*) AS count FROM audit_log WHERE action = 'webhook.security_block'").get().count, - 2, + 5, 'idempotent restart does not duplicate audit evidence', ); production.close(); @@ -70,21 +85,25 @@ development.exec(` (11,8,'http://127.0.0.8:8080/hook',1), (12,8,'http://[::1]:8080/hook',1), (13,8,'http://public.example.test/hook',1), - (14,8,'http://localhost.evil.example/hook',1); + (14,8,'http://localhost.evil.example/hook',1), + (15,8,'https://localhost/hook',1), + (16,8,'https://public.example.test/hook',1); `); assert.equal( migrateLegacyWebhookDestinations(development, { allowDevelopmentLoopback: true }), - 2, - 'explicit development mode preserves only loopback HTTP rows and still blocks other HTTP destinations', + 3, + 'explicit development mode preserves only admitted loopback HTTP and public HTTPS destinations', ); assert.deepEqual( - development.prepare('SELECT id, active FROM webhooks ORDER BY id').all(), + plainRows(development.prepare('SELECT id, active FROM webhooks ORDER BY id').all()), [ { id: 10, active: 1 }, { id: 11, active: 1 }, { id: 12, active: 1 }, { id: 13, active: 0 }, { id: 14, active: 0 }, + { id: 15, active: 0 }, + { id: 16, active: 1 }, ], ); development.close(); @@ -92,7 +111,7 @@ development.close(); const rollback = createDatabase(); rollback.exec(` INSERT INTO webhooks(id,org_id,url,active) - VALUES(20,9,'http://legacy.example.test/hook',1); + VALUES(20,9,'https://127.0.0.1/hook',1); CREATE TRIGGER reject_security_audit BEFORE INSERT ON audit_log BEGIN From 7dbc5a8a1d43ebe6de6326d017af5b210cda25e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 01:43:44 -0700 Subject: [PATCH 24/71] fix(security): migrate all legacy webhook policy violations --- server/webhook_legacy_migration.mjs | 36 +++++++++++++++-------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/server/webhook_legacy_migration.mjs b/server/webhook_legacy_migration.mjs index 298c0743..f967f7bb 100644 --- a/server/webhook_legacy_migration.mjs +++ b/server/webhook_legacy_migration.mjs @@ -2,32 +2,34 @@ import { validateWebhookRegistrationUrl } from './webhook_transport.mjs'; const SECURITY_ACTION = 'webhook.security_block'; const SECURITY_META = JSON.stringify({ - reason: 'insecure_scheme', + reason: 'destination_policy', nextAction: 'register_public_https_replacement', }); -function isPreservedDevelopmentLoopback(url, allowDevelopmentLoopback) { - if (!allowDevelopmentLoopback) return false; +function isCurrentDestinationAllowed(url, allowDevelopmentLoopback) { try { - const canonical = validateWebhookRegistrationUrl(url, { - allowDevelopmentLoopback: true, - }); - return new URL(canonical).protocol === 'http:'; + validateWebhookRegistrationUrl(url, { allowDevelopmentLoopback }); + return true; } catch { return false; } } /** - * Disable previously accepted HTTP webhook destinations before requests serve. + * Disable active legacy webhook destinations rejected by current registration policy. * - * Historical ScopeWeave releases accepted arbitrary `http://` webhook URLs. - * Production delivery now requires public HTTPS, so leaving those rows active - * would create an endless silent retry loop. This migration disables only - * active legacy HTTP rows, writes one tenant-visible audit event with a concrete - * replacement action, and never reads or copies the webhook signing secret. - * Explicit development mode preserves only loopback HTTP URLs that the current - * destination policy still permits. + * Historical ScopeWeave releases accepted arbitrary HTTP(S) webhook URLs, + * including local/private HTTPS literals and names. Current production + * registration requires public HTTPS, so leaving policy-incompatible rows active + * would create an endless silent retry loop. This migration examines every active + * row, disables only destinations rejected by the current synchronous registration + * policy, writes one tenant-visible audit event with a concrete replacement action, + * and never reads or copies the webhook signing secret. Explicit development mode + * preserves only destinations that the same current development registration + * policy still permits, including loopback HTTP. + * + * DNS-backed hostnames remain subject to per-attempt address authorization at + * delivery time; this startup migration deliberately does not perform network I/O. * * @param {import('node:sqlite').DatabaseSync} database Open ScopeWeave database. * @param {{allowDevelopmentLoopback?: boolean}} [options] Migration policy. @@ -42,7 +44,7 @@ export function migrateLegacyWebhookDestinations( const candidates = database.prepare( `SELECT id, org_id AS orgId, url FROM webhooks - WHERE active = 1 AND lower(url) LIKE 'http://%' + WHERE active = 1 ORDER BY id`, ).all(); const disable = database.prepare( @@ -63,7 +65,7 @@ export function migrateLegacyWebhookDestinations( let disabled = 0; for (const candidate of candidates) { - if (isPreservedDevelopmentLoopback(candidate.url, allowDevelopmentLoopback)) { + if (isCurrentDestinationAllowed(candidate.url, allowDevelopmentLoopback)) { continue; } const targetId = String(candidate.id); From 584d7b0527f93165b5b6a97f12eaf6dfcf7a96d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 01:44:14 -0700 Subject: [PATCH 25/71] test(security): verify startup migration for private HTTPS webhooks --- tests/api/webhook-legacy-migration.test.mjs | 69 +++++++++++++-------- 1 file changed, 42 insertions(+), 27 deletions(-) diff --git a/tests/api/webhook-legacy-migration.test.mjs b/tests/api/webhook-legacy-migration.test.mjs index 5d575f83..a233feda 100644 --- a/tests/api/webhook-legacy-migration.test.mjs +++ b/tests/api/webhook-legacy-migration.test.mjs @@ -5,6 +5,10 @@ import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { DatabaseSync } from 'node:sqlite'; +function plainRows(rows) { + return rows.map((row) => ({ ...row })); +} + const directory = mkdtempSync(join(tmpdir(), 'scopeweave-webhook-migration-')); const databasePath = join(directory, 'legacy.sqlite'); const legacy = new DatabaseSync(databasePath); @@ -49,7 +53,8 @@ INSERT INTO orgs(id,name,owner_id) VALUES(1,'Legacy Buyer',1); INSERT INTO webhooks(id,org_id,url,secret,events,active) VALUES (41,1,'http://legacy-webhook.example.test/callback','whsec_legacy_active','project.update',1), (42,1,'https://webhook.example.test/callback','whsec_public_https','project.update',1), - (43,1,'http://retired-webhook.example.test/callback','whsec_legacy_inactive','project.update',0); + (43,1,'http://retired-webhook.example.test/callback','whsec_legacy_inactive','project.update',0), + (44,1,'https://127.0.0.1/callback','whsec_legacy_private_https','project.update',1); `); legacy.close(); @@ -57,16 +62,17 @@ process.env.SCOPEWEAVE_DB = databasePath; try { const moduleUrl = pathToFileURL(join(process.cwd(), 'server', 'db.mjs')).href; - const first = await import(`${moduleUrl}?legacy-http-migration=first`); + const first = await import(`${moduleUrl}?legacy-destination-migration=first`); assert.deepEqual( - first.db.prepare('SELECT id, active FROM webhooks ORDER BY id').all(), + plainRows(first.db.prepare('SELECT id, active FROM webhooks ORDER BY id').all()), [ { id: 41, active: 0 }, { id: 42, active: 1 }, { id: 43, active: 0 }, + { id: 44, active: 0 }, ], - 'startup disables only previously active insecure HTTP webhooks and preserves HTTPS/inactive rows', + 'startup disables active legacy destinations rejected by current policy and preserves public HTTPS/inactive rows', ); const firstAudit = first.db.prepare( @@ -75,38 +81,47 @@ try { WHERE org_id = 1 AND action = 'webhook.security_block' ORDER BY id`, ).all(); - assert.equal(firstAudit.length, 1, 'migration emits one durable buyer-visible security audit event'); - assert.equal(firstAudit[0].targetType, 'webhook'); - assert.equal(firstAudit[0].targetId, '41'); + assert.equal(firstAudit.length, 2, 'migration emits one durable buyer-visible security audit event per disabled destination'); assert.deepEqual( - JSON.parse(firstAudit[0].meta), - { - reason: 'insecure_scheme', - nextAction: 'register_public_https_replacement', - }, - 'audit evidence gives the operator a concrete remediation action', - ); - assert.equal( - firstAudit[0].meta.includes('whsec_'), - false, - 'buyer-visible audit evidence never includes the webhook signing secret', + firstAudit.map((row) => row.targetId), + ['41', '44'], + 'audit events identify both legacy HTTP and private-HTTPS rows', ); + for (const audit of firstAudit) { + assert.equal(audit.targetType, 'webhook'); + assert.deepEqual( + JSON.parse(audit.meta), + { + reason: 'destination_policy', + nextAction: 'register_public_https_replacement', + }, + 'audit evidence gives the operator a concrete remediation action without misclassifying the scheme', + ); + assert.equal( + audit.meta.includes('whsec_'), + false, + 'buyer-visible audit evidence never includes the webhook signing secret', + ); + } first.db.close(); - const second = await import(`${moduleUrl}?legacy-http-migration=second`); + const second = await import(`${moduleUrl}?legacy-destination-migration=second`); assert.equal( second.db.prepare( `SELECT COUNT(*) AS count FROM audit_log - WHERE org_id = 1 AND action = 'webhook.security_block' AND target_id = '41'`, + WHERE org_id = 1 AND action = 'webhook.security_block'`, ).get().count, - 1, - 'restart is idempotent and does not duplicate the security audit event', + 2, + 'restart is idempotent and does not duplicate security audit events', ); - assert.equal( - second.db.prepare('SELECT active FROM webhooks WHERE id = 41').get().active, - 0, - 'restart remains fail-closed for the migrated insecure destination', + assert.deepEqual( + plainRows(second.db.prepare('SELECT id, active FROM webhooks WHERE id IN (41, 44) ORDER BY id').all()), + [ + { id: 41, active: 0 }, + { id: 44, active: 0 }, + ], + 'restart remains fail-closed for every migrated policy-incompatible destination', ); second.db.close(); } finally { @@ -114,4 +129,4 @@ try { rmSync(directory, { recursive: true, force: true }); } -console.log('legacy HTTP webhook migration regression passed'); +console.log('legacy webhook destination migration regression passed'); From fc9160b63745c0f16b93341a5e47c49ff947cb0a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 01:45:44 -0700 Subject: [PATCH 26/71] docs(security): align legacy webhook migration trace --- .../doctoring/webhook-destination-security.md | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/docs/doctoring/webhook-destination-security.md b/docs/doctoring/webhook-destination-security.md index 96254c2f..8bd92a9b 100644 --- a/docs/doctoring/webhook-destination-security.md +++ b/docs/doctoring/webhook-destination-security.md @@ -6,11 +6,13 @@ ScopeWeave accepts buyer-configured webhook destinations, so the outbound HTTP client is an SSRF trust boundary. The active repair makes registration and delivery use one destination policy instead of validating a URL once and later allowing the platform resolver/network stack to choose a different address. -Production webhook destinations are limited to canonical public HTTPS URLs. Immediately before each delivery attempt, ScopeWeave resolves all returned A/AAAA candidates, rejects the entire result if any candidate is malformed or special-use/non-public, and pins the connection to a validated address while preserving the original HTTPS hostname for TLS authority. Redirects are not followed. This closes the common validation-versus-connect gap used by DNS-rebinding/pinning attacks and avoids redirect-based policy escape. +Production webhook destinations are limited to canonical public HTTPS URLs. Immediately before each delivery attempt, ScopeWeave resolves all returned A/AAAA candidates, rejects the entire result if any candidate is malformed or special-use/non-public, and pins the connection to a validated address while preserving the original HTTPS hostname for TLS authority. Redirects are not followed. This closes the validation-versus-connect gap used by DNS-rebinding/pinning attacks and avoids redirect-based policy escape. -Historical ScopeWeave releases accepted `http://` webhook destinations. Leaving those rows active after tightening the transport would make an existing customer integration fail silently on every delivery and retry. The active repair therefore performs a transactional, idempotent startup state migration: previously active HTTP destinations that the current production policy cannot deliver are disabled, and one tenant-visible `webhook.security_block` audit event tells the operator to `register_public_https_replacement`. The audit record never reads or copies the signing secret. HTTPS rows and already inactive rows are left unchanged. +Historical ScopeWeave releases accepted arbitrary HTTP(S) webhook URLs, including HTTP endpoints and HTTPS local/private literals that the current registration policy rejects. Leaving those rows active after tightening the transport would make an existing customer integration fail silently on every delivery and retry. The active repair therefore performs a transactional, idempotent startup state migration: every active stored destination is checked against the current synchronous registration policy, policy-incompatible rows are disabled, and one tenant-visible `webhook.security_block` audit event tells the operator to `register_public_https_replacement`. The fixed non-secret audit metadata uses `reason: "destination_policy"`; the migration never reads or copies the signing secret. Public HTTPS rows and already inactive rows remain unchanged. -`SCOPEWEAVE_DEV=1` is an explicit non-production exception. It may admit only HTTP `localhost`, IPv4 `127.0.0.0/8`, or IPv6 `::1` destinations. Registration, delivery, and legacy-row migration reuse that same policy: a stored development loopback HTTP webhook remains active, while arbitrary HTTP destinations are still disabled. `localhost` DNS answers must all be loopback addresses before any connector is called. The exception does not admit arbitrary RFC 1918, link-local, metadata-service, `.local`, `.localhost` subdomain, or other special-use destinations. +The migration deliberately performs no DNS/network I/O at startup. A syntactically admissible public hostname that later resolves to a private or special-use address remains subject to the delivery-time A/AAAA authorization boundary and fails closed there. This avoids making database startup availability depend on external DNS while preserving per-attempt rebinding protection. + +`SCOPEWEAVE_DEV=1` is an explicit non-production exception. It may admit only HTTP `localhost`, IPv4 `127.0.0.0/8`, or IPv6 `::1` destinations. Registration, delivery, and legacy-row migration reuse that same policy: a stored development loopback HTTP webhook remains active, while public HTTP and HTTPS-local/private destinations are disabled. `localhost` DNS answers must all be loopback addresses before any connector is called. The exception does not admit arbitrary RFC 1918, link-local, metadata-service, `.local`, `.localhost` subdomain, or other special-use destinations. ## Control design @@ -25,7 +27,8 @@ Historical ScopeWeave releases accepted `http://` webhook destinations. Leaving | Redirects | Transport returns 3xx without following it; delivery is recorded unsuccessful by existing webhook logic | transport/API regression coverage | | Replay safety | Another validated address may be tried only before a connection becomes established; after connect/TLS secure-connect, the signed body is not replayed within the same attempt | transport unit tests | | Development loopback | Registration, delivery, and startup migration share the same explicit `SCOPEWEAVE_DEV=1` loopback exception; `localhost` must resolve exclusively to loopback | `tests/unit/webhook-development-transport.test.mjs`, `tests/unit/webhook-legacy-migration.test.mjs`, API smoke test | -| Legacy HTTP state | Active historical HTTP rows are disabled atomically before serving; HTTPS and already inactive rows are preserved; one idempotent audit event gives the replacement action | `tests/api/webhook-legacy-migration.test.mjs`, `tests/unit/webhook-legacy-migration.test.mjs` | +| Legacy destination state | Every active historical row is checked against the current synchronous registration policy; HTTP, local-name, and private/special-use literal destinations are disabled atomically; public HTTPS and already inactive rows are preserved | `tests/api/webhook-legacy-migration.test.mjs`, `tests/unit/webhook-legacy-migration.test.mjs` | +| DNS-backed legacy hostname | Startup does not resolve external names; delivery still resolves afresh and rejects any non-public A/AAAA result | migration docstring plus transport unit tests | | Migration failure | Mutation and audit persistence share one `BEGIN IMMEDIATE` transaction; failure to write durable audit evidence rolls the row mutation back | `tests/unit/webhook-legacy-migration.test.mjs` | | Secret handling | Migration queries only webhook id, tenant id, URL, and active state; audit metadata is fixed non-secret remediation data | migration unit/API regressions | | Error disclosure | Destination-policy and transport failures expose stable non-secret errors rather than resolver/socket details | destination-policy and transport tests | @@ -40,25 +43,23 @@ RFC 6761 defines `localhost.` names as special-use and states that address queri Node.js `https.request()` accepts the HTTP request options plus TLS options including `servername`; the active transport uses an injected `lookup` function to pin the validated address while retaining the URL hostname as TLS authority. `agent: false` prevents connection pooling from silently reusing a socket whose address was authorized under a prior resolution. -The legacy-row transition is a product compatibility control rather than a new network policy: once the production transport legitimately refuses HTTP, continuing to mark an impossible destination active would create misleading operability state. The migration therefore makes the persisted state match the enforceable transport policy and records the customer next action in the existing tenant audit trail. +The legacy-row transition is a product compatibility control rather than a new network policy: once the production registration/transport boundary legitimately refuses a stored destination, continuing to mark that destination active would create misleading operability state. The migration therefore makes persisted state match the enforceable synchronous registration policy and records the customer next action in the existing tenant audit trail. ## TDD and current verification trace -The review finding that exposed registration/delivery drift is preserved by `tests/unit/webhook-development-transport.test.mjs`. On contributor head `dd1893ea870bec9ddbd03fbe2c24f084641f72de`, hosted Server Tests run `32626294458` failed at the new development-loopback delivery assertion with `WebhookDestinationError`; that is the realistic RED reproduction. - -The root-cause repair is carried by `server/webhook_transport.mjs` plus the narrow registration facade in `server/app.mjs`: one validator now defines both registration and delivery admission. On contributor head `ed44d32289241f9f73e99e8119001e4e187d93be`, Server Tests run `32626453099` passed both `unit-and-api` and `cloud-e2e`; its log explicitly includes `webhook development loopback transport tests passed` and `API smoke tests passed`. Fuzz, Dependency Review, OSV Scanner, Security Scan, and SAST Semgrep also reported terminal success for that contributor revision at the workflow level. +The review finding that exposed registration/delivery drift is preserved by `tests/unit/webhook-development-transport.test.mjs`. On contributor head `dd1893ea870bec9ddbd03fbe2c24f084641f72de`, hosted Server Tests run `32626294458` failed at the new development-loopback delivery assertion with `WebhookDestinationError`; that is the realistic RED reproduction. The root-cause transport repair then made explicit development loopback registration and delivery use the same policy. -A later current-source review found a separate compatibility defect: pre-existing HTTP rows accepted by historical releases would remain active even though the new production transport could never deliver them. The realistic database regression was committed first at `b65fa41d30a024f6a37466344c73f701c1d0bf81` and registered in the canonical API lane at `0e28697724c3671cdb5569324962d12b4cf6db05`. That exact source had no startup migration, so its seeded active legacy HTTP row necessarily remained active. Hosted Server Tests for that transient RED revision were cancelled after the repair branch advanced; they are not claimed as RED evidence. +A later review identified the legacy-state compatibility problem. The first migration repaired active HTTP rows, but exact-current review of contributor `c7f299a480dc89873fc08807f460c7d248134a83` found that historical HTTPS-local/private rows such as `https://localhost`, `https://127.0.0.1`, and `https://10.0.0.x` would remain active even though the current policy rejects them. The same head's Server Tests run `32627872784` also exposed a separate test-harness defect: Node 22.13 SQLite row objects have a null prototype, so strict deep equality against plain object literals failed before the migration assertions could provide reliable evidence. -The smallest root-cause repair introduces `server/webhook_legacy_migration.mjs` and invokes it from `server/db.mjs` after schema initialization. The migration uses the same current destination-policy validator for the development exception, selects no signing secret, changes only active historical HTTP rows, records one fixed tenant audit remediation event, and is atomic and idempotent. `tests/unit/webhook-legacy-migration.test.mjs` additionally proves production behavior, development-loopback preservation, restart idempotence, and transaction rollback when audit persistence fails. `tests/api/webhook-legacy-migration.test.mjs` exercises the real startup import against a legacy on-disk SQLite schema. Both regressions are part of the canonical unit/API and coverage commands. +The regression-first successor `da062b9b9b82f84ae805a5ed31365e15e63d43a5` normalizes SQLite result rows only at the assertion boundary and adds explicit legacy HTTPS-local/private cases plus correct operator audit semantics. The root-cause implementation `7dbc5a8a1d43ebe6de6326d017af5b210cda25e9` changes the startup migration from an HTTP-prefix query to evaluating every active row with the same synchronous current registration validator; it preserves admitted development loopback destinations, never performs startup DNS, and records `reason: "destination_policy"`. The realistic on-disk API regression was then aligned at `584d7b0527f93165b5b6a97f12eaf6dfcf7a96d0`, including both an active legacy HTTP row and an active private-HTTPS row, public-HTTPS preservation, durable audit evidence, secret non-disclosure, and restart idempotence. -Hosted results for this PR remain **behavioral regression evidence, not merge authority**, until the exact unchanged contributor head has been regenerated under corrected checkout controls. The repository's current PR Server Tests workflow still materializes GitHub's synthetic merge result rather than the contributor head. The repo-owned exact-head workflow repair remains tracked in #523, and centrally reusable SAST/Security exact-head repair remains owned by `ContextualWisdomLab/.github#1222`. Integration still requires fresh exact-head evidence and a qualifying independent current-head approval under live branch protection/rulesets. +The workflows associated with these rapidly advancing repair heads are revision-sensitive and must not be transferred between heads. The latest exact contributor head after this documentation commit must obtain fresh terminal evidence before any finding is considered closed. Hosted results on this PR remain **behavioral regression evidence, not merge authority** until the exact unchanged contributor head has been regenerated under corrected checkout controls. The repository's protected Server Tests control remains owned by #523, and centrally reusable SAST/Security exact-head repair remains owned by `ContextualWisdomLab/.github#1222`. ## Rollback and residual risk -The startup transition adds no schema object, but it is a real persisted-data state migration: rows that could no longer be delivered under production policy become inactive. The mutation and its audit evidence are committed together or rolled back together. Re-running startup is idempotent. +The startup transition adds no schema object, but it is a real persisted-data state migration: rows rejected by the current synchronous registration policy become inactive. The mutation and its audit evidence are committed together or rolled back together. Re-running startup is idempotent. -Reverting the code does not safely reactivate migrated rows and must not be used as an implicit downgrade path. An operator who needs to restore delivery should register a new public-HTTPS webhook through the normal authenticated API. Reactivating an old HTTP destination would require an explicit security exception and is outside the supported production recovery path. The existing audit record remains durable evidence of why the row was disabled and what action the tenant should take. +Reverting the code does not safely reactivate migrated rows and must not be used as an implicit downgrade path. An operator who needs to restore delivery should register a new public-HTTPS webhook through the normal authenticated API. Reactivating an old policy-incompatible destination would require an explicit security exception and is outside the supported production recovery path. The existing audit record remains durable evidence of why the row was disabled and what action the tenant should take. Residual limits are intentional and visible: this is an outbound destination authorization layer, not a general egress firewall. Production environments should still apply network egress controls and metadata-service protections as defense in depth. A public service that intentionally redirects or resolves through special-purpose/private addresses is incompatible with the production webhook policy and must expose a stable public HTTPS endpoint instead of requesting an allowlist bypass. @@ -74,4 +75,4 @@ Internet Assigned Numbers Authority. (n.d.). *IPv4 special-purpose address space Node.js contributors. (2025). *HTTPS: Node.js v22 documentation*. Node.js. https://nodejs.org/docs/v22.13.0/api/https.html -OWASP Foundation. (n.d.). *Server side request forgery prevention cheat sheet*. Retrieved August 23, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html \ No newline at end of file +OWASP Foundation. (n.d.). *Server side request forgery prevention cheat sheet*. Retrieved August 23, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html From d7d62784c109d161900e56484c2919f91f2a3bb6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 01:46:18 -0700 Subject: [PATCH 27/71] docs(security): describe legacy webhook policy migration --- CHANGELOG.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 325287c2..69bb399a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,9 +25,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Constrained outbound webhook registration and delivery to public HTTPS with per-attempt DNS/IP authorization, validated-address pinning, redirect non-following, and replay-safe fallback; the explicit development exception - is limited to loopback HTTP. Active legacy HTTP webhook rows that production - can no longer deliver are transactionally disabled on startup with a - tenant-visible audit next action instead of silently retrying forever. + is limited to loopback HTTP. Active legacy webhook rows rejected by the + current synchronous destination policy, including HTTP and local/private + HTTPS literals, are transactionally disabled on startup with a tenant-visible + replacement action instead of silently retrying forever; DNS-backed names + remain re-authorized immediately before each delivery attempt. - Made contextual-orchestrator briefing requests fail closed unless an authenticated endpoint is configured. Deterministic generated text is restricted to explicit `SCOPEWEAVE_DEV=1`, message/provider responses are bounded and validated, and non-loopback HTTP transport is rejected. - Made `SCOPEWEAVE_JWT_SECRET` mandatory at startup and rejected weak or unexpanded placeholder values so production deployments fail closed. From 988b74bff4acb6db3c5c6851bf54dc481c1569d9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 02:15:05 -0700 Subject: [PATCH 28/71] test(webhook): authenticate before reading registration body --- tests/api/webhook-destination-policy.test.mjs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/api/webhook-destination-policy.test.mjs b/tests/api/webhook-destination-policy.test.mjs index a959dddd..8d0884b1 100644 --- a/tests/api/webhook-destination-policy.test.mjs +++ b/tests/api/webhook-destination-policy.test.mjs @@ -28,6 +28,25 @@ assert.equal(response.status, 200, 'fixture owner can resolve organization'); const me = await response.json(); const organizationId = me.orgs[0].id; +let unauthenticatedBodyPulls = 0; +const unauthenticatedBody = new ReadableStream({ + pull(controller) { + unauthenticatedBodyPulls += 1; + controller.enqueue(new TextEncoder().encode('x'.repeat(8192))); + if (unauthenticatedBodyPulls >= 8) controller.close(); + }, +}); +response = await request(`/api/orgs/${organizationId}/webhooks`, { + method: 'POST', + body: unauthenticatedBody, + duplex: 'half', +}); +assert.equal(response.status, 401, 'webhook registration authenticates before reading an untrusted request body'); +assert.ok( + unauthenticatedBodyPulls <= 1, + `unauthenticated webhook body must not be drained before auth; observed ${unauthenticatedBodyPulls} stream pulls`, +); + for (const headers of [{}, { authorization: 'Bearer invalid-token' }]) { response = await request(`/api/orgs/${organizationId}/webhooks`, { method: 'POST', From 59d32e264b32b07fa6d35952852810c09c02a7c8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 02:27:10 -0700 Subject: [PATCH 29/71] fix(security): authenticate before webhook body buffering --- server/app.mjs | 77 ++++++++++++++++++++++++++++---------------------- 1 file changed, 43 insertions(+), 34 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 32c138d2..9f8c0102 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -58,51 +58,60 @@ function canonicalRegistrationUrl(value) { }); } -function requestWithJson(original, payload) { +function canonicalRegistrationBody(original) { + if (!original.body) return JSON.stringify({ url: '' }); + + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let text = ''; + return original.body.pipeThrough(new TransformStream({ + transform(chunk) { + text += decoder.decode(chunk, { stream: true }); + }, + flush(controller) { + text += decoder.decode(); + let payload = {}; + try { + const value = JSON.parse(text); + if (value && typeof value === 'object' && !Array.isArray(value)) payload = value; + } catch { /* malformed JSON follows the core route's stable 400 path */ } + + let canonicalUrl = ''; + try { + canonicalUrl = canonicalRegistrationUrl(payload.url); + } catch { /* the core legacy URL guard is translated after auth/authorization */ } + + controller.enqueue(encoder.encode(JSON.stringify({ + ...payload, + url: canonicalUrl, + }))); + }, + })); +} + +function requestWithCanonicalRegistration(original) { const headers = new Headers(original.headers); headers.delete('content-length'); headers.set('content-type', 'application/json'); + const body = canonicalRegistrationBody(original); return new Request(original.url, { method: original.method, headers, - body: JSON.stringify(payload), + body, signal: original.signal, + ...(body instanceof ReadableStream ? { duplex: 'half' } : {}), }); } -async function registrationPayload(request) { - try { - const value = JSON.parse(await request.text()); - return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; - } catch { - return {}; - } -} - async function registrationPolicyResponse(c) { - const payload = await registrationPayload(c.req.raw); - let canonicalUrl; - try { - canonicalUrl = canonicalRegistrationUrl(payload.url); - } catch { - // Preserve the core route's authentication/rate-limit/tenant precedence by - // executing exactly one side-effect-free invalid-registration request. An - // authorized manager deterministically reaches the legacy URL guard (400); - // all earlier 401/403/429 outcomes are returned unchanged. - const probe = await coreApp.fetch(requestWithJson(c.req.raw, { - url: '', - events: payload.events, - })); - if (probe.status !== 400) return probe; - const probeBody = await probe.clone().json().catch(() => null); - if (probeBody?.error !== 'valid http(s) url required') return probe; - return c.json({ error: 'valid public https webhook URL required' }, 400); - } - - return coreApp.fetch(requestWithJson(c.req.raw, { - ...payload, - url: canonicalUrl, - })); + // Route the request through the core exactly once. The transformed body is a + // backpressured stream, so core rate-limit/auth/RBAC middleware can reject a + // request without the facade draining an attacker-controlled body first. + const response = await coreApp.fetch(requestWithCanonicalRegistration(c.req.raw)); + if (response.status !== 400) return response; + const responseBody = await response.clone().json().catch(() => null); + if (responseBody?.error !== 'valid http(s) url required') return response; + return c.json({ error: 'valid public https webhook URL required' }, 400); } /** From 2501a38aed9085c8e231891e630d592297112ae2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 02:32:47 -0700 Subject: [PATCH 30/71] fix(security): defer webhook body reads until authorization --- server/app.mjs | 80 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 57 insertions(+), 23 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 9f8c0102..db14191a 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -58,35 +58,69 @@ function canonicalRegistrationUrl(value) { }); } +function canonicalRegistrationPayload(text) { + let payload = {}; + try { + const value = JSON.parse(text); + if (value && typeof value === 'object' && !Array.isArray(value)) payload = value; + } catch { /* malformed JSON follows the core route's stable 400 path */ } + + let canonicalUrl = ''; + try { + canonicalUrl = canonicalRegistrationUrl(payload.url); + } catch { /* the core legacy URL guard is translated after auth/authorization */ } + + return JSON.stringify({ + ...payload, + url: canonicalUrl, + }); +} + function canonicalRegistrationBody(original) { if (!original.body) return JSON.stringify({ url: '' }); + const source = original.body.getReader(); const decoder = new TextDecoder(); const encoder = new TextEncoder(); let text = ''; - return original.body.pipeThrough(new TransformStream({ - transform(chunk) { - text += decoder.decode(chunk, { stream: true }); - }, - flush(controller) { - text += decoder.decode(); - let payload = {}; - try { - const value = JSON.parse(text); - if (value && typeof value === 'object' && !Array.isArray(value)) payload = value; - } catch { /* malformed JSON follows the core route's stable 400 path */ } + let finished = false; - let canonicalUrl = ''; + // A zero-sized queue is deliberate: creating the forwarding Request must not + // pull attacker-controlled bytes. The core rate-limit/auth/RBAC middleware + // therefore runs first; only its route-level c.req.json() read starts source + // consumption for an already-authorized registration request. + return new ReadableStream({ + async pull(controller) { + if (finished) return; try { - canonicalUrl = canonicalRegistrationUrl(payload.url); - } catch { /* the core legacy URL guard is translated after auth/authorization */ } - - controller.enqueue(encoder.encode(JSON.stringify({ - ...payload, - url: canonicalUrl, - }))); + while (true) { + const { done, value } = await source.read(); + if (done) { + text += decoder.decode(); + controller.enqueue(encoder.encode(canonicalRegistrationPayload(text))); + controller.close(); + finished = true; + source.releaseLock(); + return; + } + text += decoder.decode(value, { stream: true }); + } + } catch (error) { + finished = true; + try { source.releaseLock(); } catch { /* already released/cancelled */ } + controller.error(error); + } + }, + async cancel(reason) { + if (finished) return; + finished = true; + try { + await source.cancel(reason); + } finally { + try { source.releaseLock(); } catch { /* cancellation can release it */ } + } }, - })); + }, { highWaterMark: 0 }); } function requestWithCanonicalRegistration(original) { @@ -104,9 +138,9 @@ function requestWithCanonicalRegistration(original) { } async function registrationPolicyResponse(c) { - // Route the request through the core exactly once. The transformed body is a - // backpressured stream, so core rate-limit/auth/RBAC middleware can reject a - // request without the facade draining an attacker-controlled body first. + // Route the request through the core exactly once. The forwarding stream has + // no eager queue, so core rate-limit/auth/RBAC middleware can reject a request + // without the facade draining an attacker-controlled body first. const response = await coreApp.fetch(requestWithCanonicalRegistration(c.req.raw)); if (response.status !== 400) return response; const responseBody = await response.clone().json().catch(() => null); From e45cd679652c21e07a7751d65c366d5197de28fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 03:09:29 -0700 Subject: [PATCH 31/71] test(security): require webhook transport to avoid global fetch mutation --- tests/api/webhook-fetch-contract.test.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/api/webhook-fetch-contract.test.mjs b/tests/api/webhook-fetch-contract.test.mjs index 9d332458..e887229b 100644 --- a/tests/api/webhook-fetch-contract.test.mjs +++ b/tests/api/webhook-fetch-contract.test.mjs @@ -11,9 +11,16 @@ globalThis.fetch = async (input, init) => { nativeCalls.push({ method: request.method, url: request.url, body }); return new Response(body, { status: 200, headers: { 'content-type': 'text/plain' } }); }; +const configuredFetch = globalThis.fetch; await import('../../server/app.mjs'); +assert.strictEqual( + globalThis.fetch, + configuredFetch, + 'importing the ScopeWeave app must not replace the process-wide fetch implementation', +); + const unrelated = new Request('https://unrelated.example.test/echo', { method: 'POST', headers: { 'content-type': 'text/plain' }, @@ -29,4 +36,4 @@ assert.deepEqual(nativeCalls, [{ body: 'preserve-this-body', }], 'the facade must not consume a non-webhook Request before native fetch receives it'); -console.log('webhook fetch boundary preserves unrelated Request bodies'); +console.log('webhook transport leaves the process-wide fetch boundary untouched'); From cb327671bbfc3ab45284ce335cf6e8078f8e05e6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 03:18:03 -0700 Subject: [PATCH 32/71] fix(security): bind webhook delivery to SSRF-safe transport --- server/app_core.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/app_core.mjs b/server/app_core.mjs index c432a84f..856c4946 100644 --- a/server/app_core.mjs +++ b/server/app_core.mjs @@ -10,6 +10,7 @@ import { PLANS, planOf, orgUsage, wouldExceed, createCheckout } from './billing. import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs'; import { normalizeAttachmentStatusBudgetMs, normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs'; import { chat as orchestratorChat } from './orchestrator.mjs'; +import { postWebhook } from './webhook_transport.mjs'; import { computeEvm } from '../analytics.js'; // pure math, shared with the client const getOrg = (id) => db.prepare('SELECT * FROM orgs WHERE id = ?').get(id); @@ -103,8 +104,7 @@ function sendWebhook(webhookId, url, sig, event, body, attempt) { metrics.webhookDeliveries++; const ctrl = new AbortController(); const to = setTimeout(() => ctrl.abort(), 3000); - fetch(url, { - method: 'POST', + postWebhook(url, { headers: { 'content-type': 'application/json', 'x-scopeweave-event': event, 'x-scopeweave-signature': `sha256=${sig}` }, body, signal: ctrl.signal, From 04c9b4afe750f205337e355e7122569677799f78 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 03:43:17 -0700 Subject: [PATCH 33/71] fix(security): stop mutating global fetch on app import --- server/app.mjs | 56 ++++++-------------------------------------------- 1 file changed, 6 insertions(+), 50 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index db14191a..f4552794 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1,56 +1,12 @@ -// ScopeWeave API security facade for outbound webhook registration and delivery. -// The protected-develop route graph lives in app_core.mjs unchanged; this module -// adds one bounded fail-closed destination policy without rewriting tenant/auth, -// billing, attachment, Clearfolio, or project-planning behavior. +// ScopeWeave API security facade for outbound webhook registration. +// The protected-develop route graph lives in app_core.mjs; this module adds one +// bounded fail-closed destination-registration policy without rewriting tenant, +// auth, billing, attachment, Clearfolio, or project-planning behavior. import { Hono } from 'hono'; import { app as coreApp } from './app_core.mjs'; -import { - postWebhook, - validateWebhookRegistrationUrl, -} from './webhook_transport.mjs'; +import { validateWebhookRegistrationUrl } from './webhook_transport.mjs'; const WEBHOOK_REGISTRATION_PATH = '/api/orgs/:id/webhooks'; -const webhookFetchBoundaryKey = Symbol.for('scopeweave.webhook-fetch-boundary'); -const nativeFetch = globalThis.fetch.bind(globalThis); - -function isSignedWebhookInput(input, init) { - const requestInput = input instanceof Request ? input : null; - const method = String(init?.method ?? requestInput?.method ?? 'GET').toUpperCase(); - const headers = new Headers(init?.headers ?? requestInput?.headers); - return method === 'POST' - && Boolean(headers.get('x-scopeweave-event')) - && /^sha256=[0-9a-f]{64}$/i.test( - headers.get('x-scopeweave-signature') || '', - ); -} - -async function protectedWebhookFetch(input, init) { - if (!isSignedWebhookInput(input, init)) return nativeFetch(input, init); - const request = new Request(input, init); - - const body = request.body - ? new Uint8Array(await request.clone().arrayBuffer()) - : ''; - const result = await postWebhook(request.url, { - headers: Object.fromEntries(request.headers.entries()), - body, - signal: request.signal, - }); - if (result.status >= 200 && result.status <= 599) { - return new Response(null, { status: result.status }); - } - return Response.error(); -} - -if (!globalThis[webhookFetchBoundaryKey]) { - globalThis.fetch = protectedWebhookFetch; - Object.defineProperty(globalThis, webhookFetchBoundaryKey, { - value: true, - configurable: false, - enumerable: false, - writable: false, - }); -} function canonicalRegistrationUrl(value) { return validateWebhookRegistrationUrl(value, { @@ -149,7 +105,7 @@ async function registrationPolicyResponse(c) { } /** - * Public ScopeWeave HTTP application with fail-closed outbound webhook policy. + * Public ScopeWeave HTTP application with fail-closed webhook registration. * All non-registration routes are delegated unchanged to the protected-develop * core application; webhook POST registration is canonicalized before storage. */ From b24255d42827e3a69e77643b1623f63dffb82b83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 03:45:10 -0700 Subject: [PATCH 34/71] test(security): prove core webhook registration enforces SSRF policy --- tests/api/webhook-destination-policy.test.mjs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/api/webhook-destination-policy.test.mjs b/tests/api/webhook-destination-policy.test.mjs index 8d0884b1..d21cb957 100644 --- a/tests/api/webhook-destination-policy.test.mjs +++ b/tests/api/webhook-destination-policy.test.mjs @@ -5,6 +5,7 @@ delete process.env.SCOPEWEAVE_DEV; process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; const { app } = await import('../../server/app.mjs'); +const { app: coreApp } = await import('../../server/app_core.mjs'); const request = (path, options = {}) => app.request(path, { ...options, @@ -28,6 +29,21 @@ assert.equal(response.status, 200, 'fixture owner can resolve organization'); const me = await response.json(); const organizationId = me.orgs[0].id; +response = await coreApp.request(`/api/orgs/${organizationId}/webhooks`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...authorization, + }, + body: json({ url: 'https://127.0.0.1/private', events: ['project.updated'] }), +}); +assert.equal(response.status, 400, 'core webhook registration cannot bypass the destination policy'); +assert.deepEqual( + await response.json(), + { error: 'valid public https webhook URL required' }, + 'core registration rejects private destinations without resolver details', +); + let unauthenticatedBodyPulls = 0; const unauthenticatedBody = new ReadableStream({ pull(controller) { From 4ae63911b368bb21f1bd816ecab47e01789e988a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 04:13:23 -0700 Subject: [PATCH 35/71] fix: enforce webhook destination policy in core route --- server/app_core.mjs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/server/app_core.mjs b/server/app_core.mjs index 856c4946..49b5ce03 100644 --- a/server/app_core.mjs +++ b/server/app_core.mjs @@ -10,7 +10,7 @@ import { PLANS, planOf, orgUsage, wouldExceed, createCheckout } from './billing. import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs'; import { normalizeAttachmentStatusBudgetMs, normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs'; import { chat as orchestratorChat } from './orchestrator.mjs'; -import { postWebhook } from './webhook_transport.mjs'; +import { postWebhook, validateWebhookRegistrationUrl, WebhookDestinationError } from './webhook_transport.mjs'; import { computeEvm } from '../analytics.js'; // pure math, shared with the client const getOrg = (id) => db.prepare('SELECT * FROM orgs WHERE id = ?').get(id); @@ -747,12 +747,22 @@ app.post('/api/orgs/:id/webhooks', requireAuth, async (c) => { const orgId = c.req.param('id'); if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); const { url, events } = await c.req.json().catch(() => ({})); - if (!/^https?:\/\//.test(String(url || ''))) return c.json({ error: 'valid http(s) url required' }, 400); + let canonicalUrl; + try { + canonicalUrl = validateWebhookRegistrationUrl(url, { + allowDevelopmentLoopback: process.env.SCOPEWEAVE_DEV === '1', + }); + } catch (error) { + if (error instanceof WebhookDestinationError) { + return c.json({ error: 'valid public https webhook URL required' }, 400); + } + throw error; + } const secret = `whsec_${randomBytes(24).toString('base64url')}`; const evs = Array.isArray(events) ? events.join(',') : (events || '*'); - const id = rowid(db.prepare('INSERT INTO webhooks(org_id,url,secret,events) VALUES(?,?,?,?)').run(orgId, url, secret, evs)); - logAudit(orgId, uid, 'webhook.create', 'webhook', id, { url, events: evs }); - return c.json({ id, url, events: evs, secret }); // secret shown once for signature verification + const id = rowid(db.prepare('INSERT INTO webhooks(org_id,url,secret,events) VALUES(?,?,?,?)').run(orgId, canonicalUrl, secret, evs)); + logAudit(orgId, uid, 'webhook.create', 'webhook', id, { url: canonicalUrl, events: evs }); + return c.json({ id, url: canonicalUrl, events: evs, secret }); // secret shown once for signature verification }); app.get('/api/orgs/:id/webhooks/:whId/deliveries', requireAuth, (c) => { From ec730ab7c38557d53beab47b2b419576e53e7459 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 04:29:00 -0700 Subject: [PATCH 36/71] fix: preserve webhook migration failure outside finally --- server/webhook_legacy_migration.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/webhook_legacy_migration.mjs b/server/webhook_legacy_migration.mjs index f967f7bb..3512c18f 100644 --- a/server/webhook_legacy_migration.mjs +++ b/server/webhook_legacy_migration.mjs @@ -87,8 +87,9 @@ export function migrateLegacyWebhookDestinations( } catch (error) { try { database.exec('ROLLBACK'); - } finally { - throw error; + } catch { + // Preserve the causal migration failure if rollback itself also fails. } + throw error; } } From 92d03f4b5523fc8721be95a0d58768763b00e818 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 04:29:36 -0700 Subject: [PATCH 37/71] test: bind toast asset to facade delegation --- tests/unit/toast-accessibility.test.mjs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/unit/toast-accessibility.test.mjs b/tests/unit/toast-accessibility.test.mjs index 1db8ddd6..a815264b 100644 --- a/tests/unit/toast-accessibility.test.mjs +++ b/tests/unit/toast-accessibility.test.mjs @@ -35,14 +35,22 @@ test('sync status uses the same explicit advisory status semantics', () => { }); test('cloud toast stylesheet is on every production serve path', () => { - const serverApp = [ - readFileSync(new URL('../../server/app.mjs', import.meta.url), 'utf8'), - readFileSync(new URL('../../server/app_core.mjs', import.meta.url), 'utf8'), - ].join('\n'); + const serverFacade = readFileSync(new URL('../../server/app.mjs', import.meta.url), 'utf8'); + const serverCore = readFileSync(new URL('../../server/app_core.mjs', import.meta.url), 'utf8'); const pagesWorkflow = readFileSync(new URL('../../.github/workflows/pages.yml', import.meta.url), 'utf8'); const staticDockerfile = readFileSync(new URL('../../Dockerfile', import.meta.url), 'utf8'); const serverDockerfile = readFileSync(new URL('../../Dockerfile.server', import.meta.url), 'utf8'); - assert.match(serverApp, /['"]\/toast-state\.css['"]/, 'SaaS allowlist serves the cloud toast stylesheet'); + assert.match( + serverFacade, + /import\s+\{\s*app\s+as\s+coreApp\s*\}\s+from\s+['"]\.\/app_core\.mjs['"]/, + 'SaaS facade imports the core application that owns static asset routes', + ); + assert.match( + serverFacade, + /app\.route\(\s*['"]\/['"]\s*,\s*coreApp\s*\)/, + 'SaaS facade delegates the root route graph to the core application', + ); + assert.match(serverCore, /['"]\/toast-state\.css['"]/, 'SaaS core allowlist serves the cloud toast stylesheet'); assert.match(pagesWorkflow, /\btoast-state\.css\b/, 'GitHub Pages stages the cloud toast stylesheet'); assert.match(staticDockerfile, /\btoast-state\.css\b/, 'static image copies the cloud toast stylesheet'); assert.match(serverDockerfile, /\btoast-state\.css\b/, 'SaaS image copies the cloud toast stylesheet'); From 91ffa41b95edf17e368fee44124dc6048ba01115 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 04:39:44 -0700 Subject: [PATCH 38/71] test: reject stale webhook registration error contract --- tests/api/webhook-destination-policy.test.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/api/webhook-destination-policy.test.mjs b/tests/api/webhook-destination-policy.test.mjs index d21cb957..548fb970 100644 --- a/tests/api/webhook-destination-policy.test.mjs +++ b/tests/api/webhook-destination-policy.test.mjs @@ -1,9 +1,17 @@ import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; process.env.SCOPEWEAVE_DB = ':memory:'; delete process.env.SCOPEWEAVE_DEV; process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +const facadeSource = readFileSync(new URL('../../server/app.mjs', import.meta.url), 'utf8'); +assert.doesNotMatch( + facadeSource, + /valid http\(s\) url required/, + 'registration facade does not retain the superseded core error contract', +); + const { app } = await import('../../server/app.mjs'); const { app: coreApp } = await import('../../server/app_core.mjs'); From fb6190235e4519b8f6d3eaade9e72ca7d0a9127f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 04:41:25 -0700 Subject: [PATCH 39/71] fix: remove stale webhook error translation --- server/app.mjs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index f4552794..a85ad923 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -24,7 +24,7 @@ function canonicalRegistrationPayload(text) { let canonicalUrl = ''; try { canonicalUrl = canonicalRegistrationUrl(payload.url); - } catch { /* the core legacy URL guard is translated after auth/authorization */ } + } catch { /* the core registration route owns the stable destination error */ } return JSON.stringify({ ...payload, @@ -97,11 +97,7 @@ async function registrationPolicyResponse(c) { // Route the request through the core exactly once. The forwarding stream has // no eager queue, so core rate-limit/auth/RBAC middleware can reject a request // without the facade draining an attacker-controlled body first. - const response = await coreApp.fetch(requestWithCanonicalRegistration(c.req.raw)); - if (response.status !== 400) return response; - const responseBody = await response.clone().json().catch(() => null); - if (responseBody?.error !== 'valid http(s) url required') return response; - return c.json({ error: 'valid public https webhook URL required' }, 400); + return coreApp.fetch(requestWithCanonicalRegistration(c.req.raw)); } /** From 81be5dba98198a2f4393f1c4ce87b5c56d05708d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 04:44:12 -0700 Subject: [PATCH 40/71] test: bound authorized webhook registration bodies --- tests/api/webhook-destination-policy.test.mjs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/api/webhook-destination-policy.test.mjs b/tests/api/webhook-destination-policy.test.mjs index 548fb970..eed1e2fd 100644 --- a/tests/api/webhook-destination-policy.test.mjs +++ b/tests/api/webhook-destination-policy.test.mjs @@ -71,6 +71,31 @@ assert.ok( `unauthenticated webhook body must not be drained before auth; observed ${unauthenticatedBodyPulls} stream pulls`, ); +let authorizedBodyPulls = 0; +const oversizedAuthorizedBody = new ReadableStream({ + pull(controller) { + authorizedBodyPulls += 1; + controller.enqueue(new TextEncoder().encode('x'.repeat(8192))); + if (authorizedBodyPulls >= 8) controller.close(); + }, +}); +response = await request(`/api/orgs/${organizationId}/webhooks`, { + method: 'POST', + headers: authorization, + body: oversizedAuthorizedBody, + duplex: 'half', +}); +assert.equal(response.status, 413, 'authorized webhook registration bodies have a bounded memory budget'); +assert.deepEqual( + await response.json(), + { error: 'webhook registration body too large' }, + 'oversized registration returns a stable buyer-actionable error', +); +assert.ok( + authorizedBodyPulls <= 3, + `oversized webhook body must stop near the 16 KiB budget; observed ${authorizedBodyPulls} stream pulls`, +); + for (const headers of [{}, { authorization: 'Bearer invalid-token' }]) { response = await request(`/api/orgs/${organizationId}/webhooks`, { method: 'POST', From 5a01014afa809c4be1af372bb26a0ea6c6f185a6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 04:46:52 -0700 Subject: [PATCH 41/71] fix: bound authorized webhook registration bodies --- server/app.mjs | 53 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index a85ad923..2d17e1ea 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -7,6 +7,7 @@ import { app as coreApp } from './app_core.mjs'; import { validateWebhookRegistrationUrl } from './webhook_transport.mjs'; const WEBHOOK_REGISTRATION_PATH = '/api/orgs/:id/webhooks'; +const WEBHOOK_REGISTRATION_BODY_MAX_BYTES = 16 * 1024; function canonicalRegistrationUrl(value) { return validateWebhookRegistrationUrl(value, { @@ -33,19 +34,22 @@ function canonicalRegistrationPayload(text) { } function canonicalRegistrationBody(original) { - if (!original.body) return JSON.stringify({ url: '' }); + const state = { tooLarge: false }; + if (!original.body) return { body: JSON.stringify({ url: '' }), state }; const source = original.body.getReader(); const decoder = new TextDecoder(); const encoder = new TextEncoder(); let text = ''; + let totalBytes = 0; let finished = false; // A zero-sized queue is deliberate: creating the forwarding Request must not // pull attacker-controlled bytes. The core rate-limit/auth/RBAC middleware // therefore runs first; only its route-level c.req.json() read starts source - // consumption for an already-authorized registration request. - return new ReadableStream({ + // consumption for an already-authorized registration request. Once that read + // begins, the facade enforces a small explicit memory budget before decoding. + const body = new ReadableStream({ async pull(controller) { if (finished) return; try { @@ -59,7 +63,22 @@ function canonicalRegistrationBody(original) { source.releaseLock(); return; } - text += decoder.decode(value, { stream: true }); + + const chunk = value instanceof Uint8Array ? value : new Uint8Array(value); + totalBytes += chunk.byteLength; + if (totalBytes > WEBHOOK_REGISTRATION_BODY_MAX_BYTES) { + state.tooLarge = true; + finished = true; + try { await source.cancel('webhook registration body too large'); } catch { /* best effort */ } + try { source.releaseLock(); } catch { /* cancellation may release it */ } + // Feed the core route a side-effect-free invalid registration after + // auth/RBAC has already admitted this request. The facade converts + // that route response into the stable 413 below. + controller.enqueue(encoder.encode(JSON.stringify({ url: '' }))); + controller.close(); + return; + } + text += decoder.decode(chunk, { stream: true }); } } catch (error) { finished = true; @@ -77,27 +96,35 @@ function canonicalRegistrationBody(original) { } }, }, { highWaterMark: 0 }); + + return { body, state }; } function requestWithCanonicalRegistration(original) { const headers = new Headers(original.headers); headers.delete('content-length'); headers.set('content-type', 'application/json'); - const body = canonicalRegistrationBody(original); - return new Request(original.url, { - method: original.method, - headers, - body, - signal: original.signal, - ...(body instanceof ReadableStream ? { duplex: 'half' } : {}), - }); + const { body, state } = canonicalRegistrationBody(original); + return { + request: new Request(original.url, { + method: original.method, + headers, + body, + signal: original.signal, + ...(body instanceof ReadableStream ? { duplex: 'half' } : {}), + }), + state, + }; } async function registrationPolicyResponse(c) { // Route the request through the core exactly once. The forwarding stream has // no eager queue, so core rate-limit/auth/RBAC middleware can reject a request // without the facade draining an attacker-controlled body first. - return coreApp.fetch(requestWithCanonicalRegistration(c.req.raw)); + const { request, state } = requestWithCanonicalRegistration(c.req.raw); + const response = await coreApp.fetch(request); + if (!state.tooLarge) return response; + return c.json({ error: 'webhook registration body too large' }, 413); } /** From b67700f69cb6d145a792280227a0042793e733f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 05:55:59 -0700 Subject: [PATCH 42/71] test: model Request body prefetch in webhook limit regression --- tests/api/webhook-destination-policy.test.mjs | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/api/webhook-destination-policy.test.mjs b/tests/api/webhook-destination-policy.test.mjs index eed1e2fd..b6f341bf 100644 --- a/tests/api/webhook-destination-policy.test.mjs +++ b/tests/api/webhook-destination-policy.test.mjs @@ -72,12 +72,18 @@ assert.ok( ); let authorizedBodyPulls = 0; +let authorizedBodyCancels = 0; +let authorizedBodyCancelReason = ''; const oversizedAuthorizedBody = new ReadableStream({ pull(controller) { authorizedBodyPulls += 1; controller.enqueue(new TextEncoder().encode('x'.repeat(8192))); if (authorizedBodyPulls >= 8) controller.close(); }, + cancel(reason) { + authorizedBodyCancels += 1; + authorizedBodyCancelReason = String(reason); + }, }); response = await request(`/api/orgs/${organizationId}/webhooks`, { method: 'POST', @@ -91,9 +97,19 @@ assert.deepEqual( { error: 'webhook registration body too large' }, 'oversized registration returns a stable buyer-actionable error', ); +// Constructing a Fetch Request may prefetch one upstream chunk before the +// facade acquires the body reader. Detecting an unknown-length body above the +// exact 16 KiB ceiling then requires reading the first over-budget chunk; the +// facade must cancel immediately instead of draining the remaining stream. assert.ok( - authorizedBodyPulls <= 3, - `oversized webhook body must stop near the 16 KiB budget; observed ${authorizedBodyPulls} stream pulls`, + authorizedBodyPulls <= 4, + `oversized webhook body must stop at the Request prefetch plus first over-budget chunk; observed ${authorizedBodyPulls} stream pulls`, +); +assert.equal(authorizedBodyCancels, 1, 'oversized webhook registration cancels its upstream body exactly once'); +assert.equal( + authorizedBodyCancelReason, + 'webhook registration body too large', + 'oversized webhook cancellation records the bounded-body reason', ); for (const headers of [{}, { authorization: 'Bearer invalid-token' }]) { From 208e1b9bcff7f9c8d818c01438adc82ab64ee220 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 06:47:35 -0700 Subject: [PATCH 43/71] test(billing): reproduce unsigned Stripe entitlement escalation --- tests/api/stripe-webhook-security.test.mjs | 65 ++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 tests/api/stripe-webhook-security.test.mjs diff --git a/tests/api/stripe-webhook-security.test.mjs b/tests/api/stripe-webhook-security.test.mjs new file mode 100644 index 00000000..9bd3eef7 --- /dev/null +++ b/tests/api/stripe-webhook-security.test.mjs @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +delete process.env.SCOPEWEAVE_DEV; +process.env.SCOPEWEAVE_PUBLIC_ORIGIN = 'https://scopeweave.example'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.STRIPE_SECRET_KEY = 'sk_test_scopeweave_webhook'; +process.env.STRIPE_PRICE_ID = 'price_scopeweave_webhook'; +process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_api_webhook_secret'; + +const { app } = await import('../../server/app.mjs?stripe-webhook-security-regression=1'); + +const jsonHeaders = { 'content-type': 'application/json' }; + +async function signupAndOrg() { + const signup = await app.request('https://scopeweave.example/api/auth/signup', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ + email: `stripe-security-${Date.now()}-${Math.random()}@example.test`, + password: 'password123', + name: 'Stripe Security Owner', + }), + }); + assert.equal(signup.status, 200); + const { token } = await signup.json(); + const me = await app.request('https://scopeweave.example/api/me', { + headers: { authorization: `Bearer ${token}` }, + }); + assert.equal(me.status, 200); + return { token, orgId: (await me.json()).orgs[0].id }; +} + +async function currentPlan(token) { + const response = await app.request('https://scopeweave.example/api/me', { + headers: { authorization: `Bearer ${token}` }, + }); + assert.equal(response.status, 200); + return (await response.json()).orgs[0].plan; +} + +test('unsigned Stripe provider-shaped JSON cannot upgrade an organization', async () => { + const { token, orgId } = await signupAndOrg(); + assert.equal(await currentPlan(token), 'free'); + + const response = await app.request('https://scopeweave.example/api/stripe/webhook', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ + id: `evt_unsigned_${orgId}`, + type: 'checkout.session.completed', + data: { + object: { + client_reference_id: String(orgId), + metadata: { orgId: String(orgId) }, + }, + }, + }), + }); + + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: 'stripe_webhook_signature_invalid' }); + assert.equal(await currentPlan(token), 'free'); +}); From 5d9b23ad06bfe42de544c657d65ed1cd9cf1cf00 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 06:47:56 -0700 Subject: [PATCH 44/71] test(billing): execute Stripe trust-boundary regression --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b30b1551..73cfe18d 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "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/webhook-destination-policy.test.mjs && node tests/api/webhook-fetch-contract.test.mjs && node tests/api/webhook-legacy-migration.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/stripe-webhook-security.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/webhook-fetch-contract.test.mjs && node tests/api/webhook-legacy-migration.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs && node tests/unit/webhook-transport.test.mjs && node tests/unit/webhook-development-transport.test.mjs && node tests/unit/webhook-legacy-migration.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/app_core.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/webhook_transport.mjs --include=server/webhook_legacy_migration.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/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/webhook-transport.test.mjs && node tests/unit/webhook-development-transport.test.mjs && node tests/unit/webhook-legacy-migration.test.mjs && npm run test:api", From 4935661309bc0f316b45df9a13bcaf9dfa1e0db1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 06:52:29 -0700 Subject: [PATCH 45/71] fix(billing): add bounded raw-body Stripe verifier --- server/stripe_webhook.mjs | 224 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 server/stripe_webhook.mjs diff --git a/server/stripe_webhook.mjs b/server/stripe_webhook.mjs new file mode 100644 index 00000000..1647fd67 --- /dev/null +++ b/server/stripe_webhook.mjs @@ -0,0 +1,224 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +const STRIPE_WEBHOOK_MAX_BYTES = 256 * 1024; +const STRIPE_SIGNATURE_HEADER_MAX_LENGTH = 4096; +const STRIPE_SIGNATURE_TOLERANCE_SECONDS = 5 * 60; +const STRIPE_EVENT_FIELD_MAX_LENGTH = 255; +const HEX_SHA256_PATTERN = /^[0-9a-f]{64}$/i; +const DECIMAL_INTEGER_PATTERN = /^\d+$/; + +/** + * Stable, browser-safe Stripe webhook boundary failure. + * + * The error contains only a machine-readable classification and HTTP status; + * signatures, webhook secrets, raw provider payloads, and parser details never + * cross this boundary. + */ +export class StripeWebhookError extends Error { + /** + * Create one sanitized webhook verification failure. + * @param {string} code stable machine-readable error code + * @param {number} status HTTP response status for the adapter + */ + constructor(code, status) { + super(code); + this.name = 'StripeWebhookError'; + this.code = code; + this.status = status; + } +} + +function webhookError(code, status = 400) { + return new StripeWebhookError(code, status); +} + +function requireVerifierConfiguration(secret, nowSeconds) { + if (typeof secret !== 'string' || secret.trim().length === 0) { + throw webhookError('stripe_webhook_not_configured', 503); + } + if (!Number.isSafeInteger(nowSeconds) || nowSeconds < 0) { + throw webhookError('stripe_webhook_request_invalid'); + } +} + +async function readBoundedRawBody(request) { + if (!request || typeof request !== 'object' || !request.headers) { + throw webhookError('stripe_webhook_request_invalid'); + } + + const declaredLength = request.headers.get('content-length'); + if (declaredLength !== null) { + const normalizedLength = declaredLength.trim(); + if (!DECIMAL_INTEGER_PATTERN.test(normalizedLength)) { + throw webhookError('stripe_webhook_request_invalid'); + } + const length = Number(normalizedLength); + if (!Number.isSafeInteger(length)) { + throw webhookError('stripe_webhook_request_invalid'); + } + if (length > STRIPE_WEBHOOK_MAX_BYTES) { + throw webhookError('stripe_webhook_body_too_large', 413); + } + } + + const reader = request.body?.getReader?.(); + if (!reader || typeof reader.read !== 'function') { + throw webhookError('stripe_webhook_request_invalid'); + } + + const chunks = []; + let totalBytes = 0; + try { + for (;;) { + let result; + try { + result = await reader.read(); + } catch { + throw webhookError('stripe_webhook_request_invalid'); + } + if (result.done) break; + if (!(result.value instanceof Uint8Array)) { + throw webhookError('stripe_webhook_request_invalid'); + } + totalBytes += result.value.byteLength; + if (totalBytes > STRIPE_WEBHOOK_MAX_BYTES) { + try { + await reader.cancel(); + } catch { + // Cancellation is best effort after the byte budget has failed closed. + } + throw webhookError('stripe_webhook_body_too_large', 413); + } + chunks.push(result.value); + } + } finally { + try { + reader.releaseLock?.(); + } catch { + // Reader cleanup cannot change the verification result. + } + } + + const body = Buffer.allocUnsafe(totalBytes); + let offset = 0; + for (const chunk of chunks) { + Buffer.from(chunk).copy(body, offset); + offset += chunk.byteLength; + } + return body; +} + +function parseStripeSignatureHeader(header) { + if ( + typeof header !== 'string' + || header.length === 0 + || header.length > STRIPE_SIGNATURE_HEADER_MAX_LENGTH + ) { + throw webhookError('stripe_webhook_signature_invalid'); + } + + const timestamps = []; + const signatures = []; + for (const component of header.split(',')) { + const separator = component.indexOf('='); + if (separator <= 0) continue; + const key = component.slice(0, separator).trim(); + const value = component.slice(separator + 1).trim(); + if (key === 't') timestamps.push(value); + if (key === 'v1') signatures.push(value); + } + + if (timestamps.length !== 1 || !DECIMAL_INTEGER_PATTERN.test(timestamps[0])) { + throw webhookError('stripe_webhook_signature_invalid'); + } + const timestamp = Number(timestamps[0]); + if (!Number.isSafeInteger(timestamp) || timestamp < 0 || signatures.length === 0) { + throw webhookError('stripe_webhook_signature_invalid'); + } + + const validSignatures = signatures.filter((signature) => HEX_SHA256_PATTERN.test(signature)); + if (validSignatures.length === 0) { + throw webhookError('stripe_webhook_signature_invalid'); + } + return { timestamp, signatures: validSignatures }; +} + +function signatureMatches(body, signatureHeader, secret, nowSeconds) { + const { timestamp, signatures } = parseStripeSignatureHeader(signatureHeader); + if (Math.abs(nowSeconds - timestamp) > STRIPE_SIGNATURE_TOLERANCE_SECONDS) { + return false; + } + + const expected = createHmac('sha256', secret) + .update(String(timestamp)) + .update('.') + .update(body) + .digest(); + + let matched = false; + for (const signature of signatures) { + const candidate = Buffer.from(signature, 'hex'); + if (candidate.length === expected.length && timingSafeEqual(candidate, expected)) { + matched = true; + } + } + return matched; +} + +function parseVerifiedEvent(body) { + let event; + try { + const text = new TextDecoder('utf-8', { fatal: true }).decode(body); + event = JSON.parse(text); + } catch { + throw webhookError('stripe_webhook_payload_invalid'); + } + + if (!event || typeof event !== 'object' || Array.isArray(event)) { + throw webhookError('stripe_webhook_payload_invalid'); + } + if ( + typeof event.id !== 'string' + || event.id.length === 0 + || event.id.length > STRIPE_EVENT_FIELD_MAX_LENGTH + || typeof event.type !== 'string' + || event.type.length === 0 + || event.type.length > STRIPE_EVENT_FIELD_MAX_LENGTH + ) { + throw webhookError('stripe_webhook_payload_invalid'); + } + return event; +} + +/** + * Verify and parse one Stripe webhook without mutating its signed request body. + * + * Stripe signs `timestamp + "." + raw request body`; JSON parsing therefore + * happens only after constant-time HMAC verification over the exact streamed + * bytes. The request body is capped at 256 KiB before buffering, the signature + * header is bounded, and the signed timestamp must be within five minutes of the + * server clock. Multiple `v1` values are accepted for endpoint-secret rotation. + * + * This function establishes transport authenticity only. It intentionally does + * not deduplicate event IDs, assume delivery ordering, or grant billing + * entitlements; those operations require durable provider-state reconciliation. + * + * @param {Request} request Fetch-compatible request containing the raw webhook body + * @param {object} options verifier configuration + * @param {string} options.secret Stripe endpoint signing secret + * @param {number} [options.nowSeconds] integer epoch seconds used for replay checks + * @returns {Promise>} verified bounded Stripe event object + * @throws {StripeWebhookError} for unconfigured, oversized, malformed, or unauthenticated requests + */ +export async function verifyStripeWebhookRequest(request, { + secret, + nowSeconds = Math.floor(Date.now() / 1000), +} = {}) { + requireVerifierConfiguration(secret, nowSeconds); + const body = await readBoundedRawBody(request); + const signatureHeader = request.headers.get('stripe-signature'); + if (!signatureMatches(body, signatureHeader, secret, nowSeconds)) { + throw webhookError('stripe_webhook_signature_invalid'); + } + return parseVerifiedEvent(body); +} From 5e8441d399674b5c128740dea9514d85288b1d08 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 06:52:52 -0700 Subject: [PATCH 46/71] test(billing): cover Stripe raw-body verifier --- tests/unit/stripe-webhook-boundary.test.mjs | 193 ++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 tests/unit/stripe-webhook-boundary.test.mjs diff --git a/tests/unit/stripe-webhook-boundary.test.mjs b/tests/unit/stripe-webhook-boundary.test.mjs new file mode 100644 index 00000000..20ec2df3 --- /dev/null +++ b/tests/unit/stripe-webhook-boundary.test.mjs @@ -0,0 +1,193 @@ +import assert from 'node:assert/strict'; +import { createHmac } from 'node:crypto'; +import test from 'node:test'; + +const SECRET = 'whsec_scopeweave_webhook_test_secret'; +const NOW_SECONDS = 1_800_000_000; + +const { StripeWebhookError, verifyStripeWebhookRequest } = await import( + '../../server/stripe_webhook.mjs' +); + +function signatureHeader(bodyBytes, timestamp = NOW_SECONDS, secret = SECRET, extra = '') { + const digest = createHmac('sha256', secret) + .update(String(timestamp)) + .update('.') + .update(bodyBytes) + .digest('hex'); + return `t=${timestamp},v1=${digest}${extra}`; +} + +function webhookRequest(bodyBytes, { + signature = signatureHeader(bodyBytes), + contentLength, +} = {}) { + const headers = new Headers({ + 'content-type': 'application/json', + 'stripe-signature': signature, + }); + if (contentLength !== undefined) headers.set('content-length', String(contentLength)); + return new Request('https://scopeweave.example/api/stripe/webhook', { + method: 'POST', + headers, + body: bodyBytes, + duplex: 'half', + }); +} + +function encoded(value) { + return new TextEncoder().encode(value); +} + +async function expectWebhookError(operation, code, status) { + await assert.rejects(operation, (error) => { + assert.ok(error instanceof StripeWebhookError); + assert.equal(error.code, code); + assert.equal(error.status, status); + return true; + }); +} + +test('verified webhook preserves the exact signed raw body and returns bounded event identity', async () => { + const bytes = encoded('{\n "id":"evt_scopeweave_1",\n "type":"checkout.session.completed",\n "data":{"object":{"client_reference_id":"7"}}\n}\n'); + const event = await verifyStripeWebhookRequest(webhookRequest(bytes), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }); + + assert.equal(event.id, 'evt_scopeweave_1'); + assert.equal(event.type, 'checkout.session.completed'); + assert.equal(event.data.object.client_reference_id, '7'); +}); + +test('signature verification fails when JSON-equivalent bytes differ from the signed body', async () => { + const signedBytes = encoded('{"id":"evt_raw","type":"checkout.session.completed"}'); + const mutatedBytes = encoded('{ "id": "evt_raw", "type": "checkout.session.completed" }'); + + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(mutatedBytes, { + signature: signatureHeader(signedBytes), + }), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_signature_invalid', + 400, + ); +}); + +test('signature parser accepts one matching v1 value and rejects malformed, missing, stale, or future signatures', async () => { + const bytes = encoded('{"id":"evt_sig","type":"invoice.paid"}'); + const valid = signatureHeader(bytes); + const validDigest = valid.split('v1=')[1]; + + const multiple = webhookRequest(bytes, { + signature: `t=${NOW_SECONDS},v1=${'0'.repeat(64)},v1=${validDigest}`, + }); + assert.equal((await verifyStripeWebhookRequest(multiple, { + secret: SECRET, + nowSeconds: NOW_SECONDS, + })).id, 'evt_sig'); + + for (const signature of [ + '', + `t=${NOW_SECONDS}`, + `v1=${validDigest}`, + `t=not-a-number,v1=${validDigest}`, + `t=${NOW_SECONDS},v1=xyz`, + signatureHeader(bytes, NOW_SECONDS - 301), + signatureHeader(bytes, NOW_SECONDS + 301), + ]) { + const request = webhookRequest(bytes, { signature }); + await expectWebhookError( + () => verifyStripeWebhookRequest(request, { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_signature_invalid', + 400, + ); + } +}); + +test('body byte ceiling rejects declared and streamed oversize requests before JSON parsing', async () => { + const small = encoded('{"id":"evt_size","type":"invoice.paid"}'); + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(small, { + contentLength: 262_145, + }), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_body_too_large', + 413, + ); + + const large = encoded(JSON.stringify({ + id: 'evt_stream_size', + type: 'invoice.paid', + data: 'x'.repeat(262_144), + })); + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(large), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_body_too_large', + 413, + ); +}); + +test('invalid content length, payload JSON, event identity, and verifier configuration fail closed', async () => { + const validBytes = encoded('{"id":"evt_valid","type":"invoice.paid"}'); + + for (const contentLength of ['-1', 'NaN', '1.5', '999999999999999999999999']) { + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(validBytes, { contentLength }), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_request_invalid', + 400, + ); + } + + const malformed = encoded('{"id":'); + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(malformed), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_payload_invalid', + 400, + ); + + for (const value of [ + null, + [], + {}, + { id: '', type: 'invoice.paid' }, + { id: 'evt_ok', type: '' }, + { id: 'x'.repeat(256), type: 'invoice.paid' }, + { id: 'evt_ok', type: 'x'.repeat(256) }, + ]) { + const bytes = encoded(JSON.stringify(value)); + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(bytes), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_payload_invalid', + 400, + ); + } + + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(validBytes), { + secret: ' ', + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_not_configured', + 503, + ); +}); From 799eda04622e314e2c186456921965417d6392a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 06:54:57 -0700 Subject: [PATCH 47/71] fix(billing): remove unsigned Stripe route from public graph --- server/app.mjs | 41 ++++++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 2d17e1ea..2477e26f 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1,13 +1,15 @@ // ScopeWeave API security facade for outbound webhook registration. -// The protected-develop route graph lives in app_core.mjs; this module adds one -// bounded fail-closed destination-registration policy without rewriting tenant, -// auth, billing, attachment, Clearfolio, or project-planning behavior. +// The protected-develop route graph lives in app_core.mjs; this module adds +// bounded fail-closed policies without rewriting tenant, auth, billing, +// attachment, Clearfolio, or project-planning behavior. import { Hono } from 'hono'; import { app as coreApp } from './app_core.mjs'; +import { StripeWebhookError, verifyStripeWebhookRequest } from './stripe_webhook.mjs'; import { validateWebhookRegistrationUrl } from './webhook_transport.mjs'; const WEBHOOK_REGISTRATION_PATH = '/api/orgs/:id/webhooks'; const WEBHOOK_REGISTRATION_BODY_MAX_BYTES = 16 * 1024; +const STRIPE_WEBHOOK_PATH = '/api/stripe/webhook'; function canonicalRegistrationUrl(value) { return validateWebhookRegistrationUrl(value, { @@ -127,14 +129,39 @@ async function registrationPolicyResponse(c) { return c.json({ error: 'webhook registration body too large' }, 413); } +async function stripeWebhookResponse(c) { + try { + await verifyStripeWebhookRequest(c.req.raw, { + secret: process.env.STRIPE_WEBHOOK_SECRET, + }); + // Authentication of a provider delivery is not entitlement authority. A + // later durable event ledger/provider-state reconciliation owns plan writes. + return c.json({ received: true }, 200, { 'Cache-Control': 'no-store' }); + } catch (error) { + if (error instanceof StripeWebhookError) { + return c.json({ error: error.code }, error.status, { 'Cache-Control': 'no-store' }); + } + return c.json({ error: 'stripe_webhook_unavailable' }, 500, { 'Cache-Control': 'no-store' }); + } +} + /** - * Public ScopeWeave HTTP application with fail-closed webhook registration. - * All non-registration routes are delegated unchanged to the protected-develop - * core application; webhook POST registration is canonicalized before storage. + * Public ScopeWeave HTTP application with fail-closed webhook boundaries. + * + * The core route and middleware graph is preserved in registration order, but + * the historical unsigned Stripe route is deliberately omitted from the public + * graph and replaced after the inherited request logging/rate-limit middleware. + * This makes unsigned plan escalation unreachable from the shipped server while + * retaining the existing destination-registration facade and all other routes. */ export const app = new Hono(); app.use(WEBHOOK_REGISTRATION_PATH, async (c, next) => { if (c.req.method !== 'POST') return next(); return registrationPolicyResponse(c); }); -app.route('/', coreApp); +for (const route of coreApp.routes.filter( + ({ method, path }) => !(method === 'POST' && path === STRIPE_WEBHOOK_PATH), +)) { + app.on(route.method, route.path, route.handler); +} +app.post(STRIPE_WEBHOOK_PATH, stripeWebhookResponse); From 2a177e651c031dee7f23540c7d470cc997d8a250 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 06:55:19 -0700 Subject: [PATCH 48/71] test(billing): include Stripe verifier in owned coverage --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 73cfe18d..bd5c62ef 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/stripe-webhook-security.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/webhook-fetch-contract.test.mjs && node tests/api/webhook-legacy-migration.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs && node tests/unit/webhook-transport.test.mjs && node tests/unit/webhook-development-transport.test.mjs && node tests/unit/webhook-legacy-migration.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/app_core.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/webhook_transport.mjs --include=server/webhook_legacy_migration.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/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/webhook-transport.test.mjs && node tests/unit/webhook-development-transport.test.mjs && node tests/unit/webhook-legacy-migration.test.mjs && npm run test:api", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/toast-accessibility.test.mjs && node tests/unit/webhook-transport.test.mjs && node tests/unit/webhook-development-transport.test.mjs && node tests/unit/webhook-legacy-migration.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/app_core.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --include=server/webhook_transport.mjs --include=server/webhook_legacy_migration.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/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/webhook-transport.test.mjs && node tests/unit/webhook-development-transport.test.mjs && node tests/unit/webhook-legacy-migration.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", From 8087838249a64442172fe5ce4378c7f28b6d48c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 06:55:47 -0700 Subject: [PATCH 49/71] test(billing): verify signed Stripe delivery stays non-authoritative --- tests/api/stripe-webhook-security.test.mjs | 87 +++++++++++++++++++--- 1 file changed, 77 insertions(+), 10 deletions(-) diff --git a/tests/api/stripe-webhook-security.test.mjs b/tests/api/stripe-webhook-security.test.mjs index 9bd3eef7..b6b7b831 100644 --- a/tests/api/stripe-webhook-security.test.mjs +++ b/tests/api/stripe-webhook-security.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { createHmac } from 'node:crypto'; import test from 'node:test'; process.env.SCOPEWEAVE_DB = ':memory:'; @@ -11,8 +12,18 @@ process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_api_webhook_secret'; const { app } = await import('../../server/app.mjs?stripe-webhook-security-regression=1'); +const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET; const jsonHeaders = { 'content-type': 'application/json' }; +function signatureHeader(body, timestamp = Math.floor(Date.now() / 1000)) { + const digest = createHmac('sha256', WEBHOOK_SECRET) + .update(String(timestamp)) + .update('.') + .update(body) + .digest('hex'); + return `t=${timestamp},v1=${digest}`; +} + async function signupAndOrg() { const signup = await app.request('https://scopeweave.example/api/auth/signup', { method: 'POST', @@ -40,6 +51,19 @@ async function currentPlan(token) { return (await response.json()).orgs[0].plan; } +function checkoutCompletedBody(orgId, idPrefix = 'evt_checkout') { + return JSON.stringify({ + id: `${idPrefix}_${orgId}`, + type: 'checkout.session.completed', + data: { + object: { + client_reference_id: String(orgId), + metadata: { orgId: String(orgId) }, + }, + }, + }); +} + test('unsigned Stripe provider-shaped JSON cannot upgrade an organization', async () => { const { token, orgId } = await signupAndOrg(); assert.equal(await currentPlan(token), 'free'); @@ -47,18 +71,61 @@ test('unsigned Stripe provider-shaped JSON cannot upgrade an organization', asyn const response = await app.request('https://scopeweave.example/api/stripe/webhook', { method: 'POST', headers: jsonHeaders, - body: JSON.stringify({ - id: `evt_unsigned_${orgId}`, - type: 'checkout.session.completed', - data: { - object: { - client_reference_id: String(orgId), - metadata: { orgId: String(orgId) }, - }, - }, - }), + body: checkoutCompletedBody(orgId, 'evt_unsigned'), }); + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: 'stripe_webhook_signature_invalid' }); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal(await currentPlan(token), 'free'); +}); + +test('authenticated Stripe delivery is acknowledged without granting entitlement', async () => { + const { token, orgId } = await signupAndOrg(); + const body = checkoutCompletedBody(orgId); + const response = await app.request('https://scopeweave.example/api/stripe/webhook', { + method: 'POST', + headers: { + ...jsonHeaders, + 'stripe-signature': signatureHeader(body), + }, + body, + }); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { received: true }); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal( + await currentPlan(token), + 'free', + 'signature authentication alone cannot bypass durable provider-state reconciliation', + ); +}); + +test('stale signatures and raw-body mutation fail closed without changing plan state', async () => { + const { token, orgId } = await signupAndOrg(); + const body = checkoutCompletedBody(orgId, 'evt_replay'); + const staleTimestamp = Math.floor(Date.now() / 1000) - 301; + + let response = await app.request('https://scopeweave.example/api/stripe/webhook', { + method: 'POST', + headers: { + ...jsonHeaders, + 'stripe-signature': signatureHeader(body, staleTimestamp), + }, + body, + }); + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: 'stripe_webhook_signature_invalid' }); + + response = await app.request('https://scopeweave.example/api/stripe/webhook', { + method: 'POST', + headers: { + ...jsonHeaders, + 'stripe-signature': signatureHeader(body), + }, + body: `${body}\n`, + }); assert.equal(response.status, 400); assert.deepEqual(await response.json(), { error: 'stripe_webhook_signature_invalid' }); assert.equal(await currentPlan(token), 'free'); From 9a56e66ba1017f4c97ee5bcbca9f7df5000f19d9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 07:53:32 -0700 Subject: [PATCH 50/71] test(a11y): match the shipped core route replay contract --- tests/unit/toast-accessibility.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/toast-accessibility.test.mjs b/tests/unit/toast-accessibility.test.mjs index a815264b..c970e2a6 100644 --- a/tests/unit/toast-accessibility.test.mjs +++ b/tests/unit/toast-accessibility.test.mjs @@ -47,8 +47,8 @@ test('cloud toast stylesheet is on every production serve path', () => { ); assert.match( serverFacade, - /app\.route\(\s*['"]\/['"]\s*,\s*coreApp\s*\)/, - 'SaaS facade delegates the root route graph to the core application', + /for\s*\(const\s+route\s+of\s+coreApp\.routes\.filter\([\s\S]*?\)\)\s*\{\s*app\.on\(route\.method,\s*route\.path,\s*route\.handler\);\s*\}/, + 'SaaS facade replays the inherited core route graph after applying its bounded route exclusions', ); assert.match(serverCore, /['"]\/toast-state\.css['"]/, 'SaaS core allowlist serves the cloud toast stylesheet'); assert.match(pagesWorkflow, /\btoast-state\.css\b/, 'GitHub Pages stages the cloud toast stylesheet'); From 237b9cfaec993df530086d5a454a7f38ce342a89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 08:25:19 -0700 Subject: [PATCH 51/71] test(webhooks): require transport-owned timeout Add a no-AbortSignal regression showing that the SSRF-safe webhook transport must bound a stalled peer itself, surface the existing sanitized transport error, and actively destroy the timed-out request. --- package.json | 4 +- tests/unit/webhook-transport-timeout.test.mjs | 42 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 tests/unit/webhook-transport-timeout.test.mjs diff --git a/package.json b/package.json index bd5c62ef..e31accb1 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/stripe-webhook-security.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/webhook-fetch-contract.test.mjs && node tests/api/webhook-legacy-migration.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/toast-accessibility.test.mjs && node tests/unit/webhook-transport.test.mjs && node tests/unit/webhook-development-transport.test.mjs && node tests/unit/webhook-legacy-migration.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/toast-accessibility.test.mjs && node tests/unit/webhook-transport.test.mjs && node tests/unit/webhook-transport-timeout.test.mjs && node tests/unit/webhook-development-transport.test.mjs && node tests/unit/webhook-legacy-migration.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/app_core.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --include=server/webhook_transport.mjs --include=server/webhook_legacy_migration.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/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/webhook-transport.test.mjs && node tests/unit/webhook-development-transport.test.mjs && node tests/unit/webhook-legacy-migration.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/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/webhook-transport.test.mjs && node tests/unit/webhook-transport-timeout.test.mjs && node tests/unit/webhook-development-transport.test.mjs && node tests/unit/webhook-legacy-migration.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", diff --git a/tests/unit/webhook-transport-timeout.test.mjs b/tests/unit/webhook-transport-timeout.test.mjs new file mode 100644 index 00000000..cf1c88c0 --- /dev/null +++ b/tests/unit/webhook-transport-timeout.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { + WebhookTransportError, + createWebhookTransport, +} from '../../server/webhook_transport.mjs'; + +let capturedTimeout; +let destroyCalls = 0; +const transport = createWebhookTransport({ + lookup: async () => [{ address: '93.184.216.34', family: 4 }], + request: (_url, options) => { + capturedTimeout = options.timeout; + const request = new EventEmitter(); + request.destroy = () => { + destroyCalls += 1; + queueMicrotask(() => request.emit('error', new Error('simulated stalled peer'))); + }; + request.end = () => { + queueMicrotask(() => { + if (options.timeout === undefined) { + request.emit('error', new Error('transport omitted its default timeout')); + return; + } + request.emit('timeout'); + }); + }; + return request; + }, +}); + +await assert.rejects( + () => transport.post('https://hooks.example.com/stalled', { + body: '{"event":"project.update"}', + }), + WebhookTransportError, + 'a stalled destination fails closed even when the caller supplies no AbortSignal', +); +assert.equal(capturedTimeout, 3000, 'transport owns a three-second default request timeout'); +assert.equal(destroyCalls, 1, 'the timeout actively destroys the stalled request'); + +console.log('webhook default timeout regression passed'); From a67c89f433db47e08e428c1edb7f278534e75423 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 08:26:41 -0700 Subject: [PATCH 52/71] fix(webhooks): bound stalled transport requests Give every SSRF-safe webhook connector a three-second transport-owned timeout and destroy a stalled request before surfacing the existing sanitized WebhookTransportError. This keeps the transport bounded even when a direct caller omits AbortSignal. --- server/webhook_transport.mjs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/server/webhook_transport.mjs b/server/webhook_transport.mjs index 5832426c..ef01b7ad 100644 --- a/server/webhook_transport.mjs +++ b/server/webhook_transport.mjs @@ -6,6 +6,7 @@ import { BlockList, isIP } from 'node:net'; const DENIED_IPV4_BLOCKS = new BlockList(); const DENIED_IPV6_BLOCKS = new BlockList(); const PUBLIC_IPV6_UNICAST = new BlockList(); +const DEFAULT_WEBHOOK_TRANSPORT_TIMEOUT_MS = 3000; PUBLIC_IPV6_UNICAST.addSubnet('2000::', 3, 'ipv6'); for (const [address, prefix, family] of [ @@ -225,6 +226,7 @@ function requestOptions(destination, candidate, headers, signal) { method: 'POST', headers, signal, + timeout: DEFAULT_WEBHOOK_TRANSPORT_TIMEOUT_MS, agent: false, lookup: pinnedLookup(candidate.address, candidate.family), ...(destination.protocol === 'https:' && !isIP(tlsHost) ? { servername: tlsHost } : {}), @@ -261,6 +263,13 @@ async function postToCandidate(destination, candidate, { headers, body, signal, } trackConnection(req, attempt, destination.protocol === 'https:'); req.once?.('error', () => reject(new WebhookTransportError())); + req.once?.('timeout', () => { + try { + req.destroy?.(); + } finally { + reject(new WebhookTransportError()); + } + }); req.end(body); }), signal); } catch (error) { @@ -273,9 +282,10 @@ async function postToCandidate(destination, candidate, { headers, body, signal, * Build the outbound webhook transport around injectable DNS and network seams. * Every POST resolves afresh, rejects unauthorized mixed answers, pins the socket * to a validated candidate, preserves HTTPS Host/TLS authority, disables pooling, - * and never follows redirects. A pre-connect failure may fall through to another - * already-validated candidate; after a connection is established delivery is - * ambiguous and the signed body is never replayed within the same attempt. + * and bounds stalled peers with a transport-owned timeout. A pre-connect failure + * may fall through to another already-validated candidate; after a connection is + * established delivery is ambiguous and the signed body is never replayed within + * the same attempt. */ export function createWebhookTransport({ lookup = dnsLookup, From 4dcd8d84fb3053fa60d26f834a8663173f7c1906 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 09:43:42 -0700 Subject: [PATCH 53/71] test(security): require early webhook body cancellation --- tests/api/webhook-destination-policy.test.mjs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/api/webhook-destination-policy.test.mjs b/tests/api/webhook-destination-policy.test.mjs index b6f341bf..ab58e372 100644 --- a/tests/api/webhook-destination-policy.test.mjs +++ b/tests/api/webhook-destination-policy.test.mjs @@ -53,12 +53,18 @@ assert.deepEqual( ); let unauthenticatedBodyPulls = 0; +let unauthenticatedBodyCancels = 0; +let unauthenticatedBodyCancelReason = ''; const unauthenticatedBody = new ReadableStream({ pull(controller) { unauthenticatedBodyPulls += 1; controller.enqueue(new TextEncoder().encode('x'.repeat(8192))); if (unauthenticatedBodyPulls >= 8) controller.close(); }, + cancel(reason) { + unauthenticatedBodyCancels += 1; + unauthenticatedBodyCancelReason = String(reason); + }, }); response = await request(`/api/orgs/${organizationId}/webhooks`, { method: 'POST', @@ -70,6 +76,16 @@ assert.ok( unauthenticatedBodyPulls <= 1, `unauthenticated webhook body must not be drained before auth; observed ${unauthenticatedBodyPulls} stream pulls`, ); +assert.equal( + unauthenticatedBodyCancels, + 1, + 'early authentication rejection cancels the unread upstream registration body exactly once', +); +assert.equal( + unauthenticatedBodyCancelReason, + 'webhook registration request completed', + 'early rejection releases the upstream body with a stable non-secret cancellation reason', +); let authorizedBodyPulls = 0; let authorizedBodyCancels = 0; From 5af895e20f4534253bb06b00d6897ec299365598 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 09:45:14 -0700 Subject: [PATCH 54/71] fix(security): release rejected webhook request bodies --- server/app.mjs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/server/app.mjs b/server/app.mjs index 2477e26f..e0a5bc5f 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -124,7 +124,17 @@ async function registrationPolicyResponse(c) { // no eager queue, so core rate-limit/auth/RBAC middleware can reject a request // without the facade draining an attacker-controlled body first. const { request, state } = requestWithCanonicalRegistration(c.req.raw); - const response = await coreApp.fetch(request); + let response; + try { + response = await coreApp.fetch(request); + } finally { + // Authentication/RBAC/rate-limit rejection can return without consuming the + // forwarding body. Cancel that unread stream so its reader releases the + // original network body instead of keeping the connection resource locked. + if (request.body && !request.bodyUsed && !request.body.locked) { + try { await request.body.cancel('webhook registration request completed'); } catch { /* best effort */ } + } + } if (!state.tooLarge) return response; return c.json({ error: 'webhook registration body too large' }, 413); } From c321333a3249d884454443b69d65012f08e3e372 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 15:59:09 -0700 Subject: [PATCH 55/71] test(security): align webhook 413 access evidence --- tests/api/webhook-destination-policy.test.mjs | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/tests/api/webhook-destination-policy.test.mjs b/tests/api/webhook-destination-policy.test.mjs index ab58e372..f6f7b3e8 100644 --- a/tests/api/webhook-destination-policy.test.mjs +++ b/tests/api/webhook-destination-policy.test.mjs @@ -1,10 +1,28 @@ import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { readFileSync } from 'node:fs'; -process.env.SCOPEWEAVE_DB = ':memory:'; +const tempDirectory = mkdtempSync(join(tmpdir(), 'scopeweave-webhook-policy-')); +process.env.SCOPEWEAVE_DB = join(tempDirectory, 'webhook-policy.sqlite'); delete process.env.SCOPEWEAVE_DEV; process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +const requestLogs = []; +const originalConsoleLog = console.log; +console.log = (...args) => { + if (args.length !== 1 || typeof args[0] !== 'string') return; + try { + const record = JSON.parse(args[0]); + if (record && typeof record === 'object' && typeof record.path === 'string') { + requestLogs.push(record); + } + } catch { + // Test progress output is intentionally ignored while structured request logs are captured. + } +}; + const facadeSource = readFileSync(new URL('../../server/app.mjs', import.meta.url), 'utf8'); assert.doesNotMatch( facadeSource, @@ -14,6 +32,7 @@ assert.doesNotMatch( const { app } = await import('../../server/app.mjs'); const { app: coreApp } = await import('../../server/app_core.mjs'); +const { db } = await import('../../server/db.mjs'); const request = (path, options = {}) => app.request(path, { ...options, @@ -127,6 +146,17 @@ assert.equal( 'webhook registration body too large', 'oversized webhook cancellation records the bounded-body reason', ); +const oversizedAccessLog = requestLogs + .filter((record) => ( + record.method === 'POST' + && record.path === `/api/orgs/${organizationId}/webhooks` + )) + .at(-1); +assert.equal( + oversizedAccessLog?.status, + 413, + 'structured access evidence must record the same 413 status returned to the customer', +); for (const headers of [{}, { authorization: 'Bearer invalid-token' }]) { response = await request(`/api/orgs/${organizationId}/webhooks`, { @@ -207,4 +237,7 @@ assert.equal( 'canonical destination is durable in storage and therefore reused by later delivery attempts', ); +db.close(); +console.log = originalConsoleLog; +rmSync(tempDirectory, { recursive: true, force: true }); console.log('webhook destination registration policy tests passed'); From a7ec6e80f7a5a88ff83bdef9ceb60ce4fc964ae6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 16:20:29 -0700 Subject: [PATCH 56/71] fix(observability): align webhook 413 access evidence --- server/app.mjs | 42 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index e0a5bc5f..4bd4f58c 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -9,8 +9,35 @@ import { validateWebhookRegistrationUrl } from './webhook_transport.mjs'; const WEBHOOK_REGISTRATION_PATH = '/api/orgs/:id/webhooks'; const WEBHOOK_REGISTRATION_BODY_MAX_BYTES = 16 * 1024; +const WEBHOOK_REGISTRATION_EVIDENCE = Symbol.for('scopeweave.webhookRegistrationEvidence'); const STRIPE_WEBHOOK_PATH = '/api/stripe/webhook'; +// Registration requests need the core logger/rate-limit/auth/RBAC stack to run +// exactly once, but an oversized authorized body must become 413 *inside* that +// stack so the structured access record and counters match the public response. +// Replay only global middleware plus this route, and insert the translation after +// global middleware but before route-local auth/handler entries. Hono preserves +// registration order in routes; the public facade below relies on the same +// contract when replaying the protected core graph. +const registrationCoreApp = new Hono(); +let registrationEvidenceBoundaryInstalled = false; +for (const route of coreApp.routes) { + if (route.path !== '*' && route.path !== WEBHOOK_REGISTRATION_PATH) continue; + if (!registrationEvidenceBoundaryInstalled && route.path === WEBHOOK_REGISTRATION_PATH) { + registrationCoreApp.use(WEBHOOK_REGISTRATION_PATH, async (c, next) => { + await next(); + if (c.env?.[WEBHOOK_REGISTRATION_EVIDENCE]?.tooLarge === true) { + c.res = c.json({ error: 'webhook registration body too large' }, 413); + } + }); + registrationEvidenceBoundaryInstalled = true; + } + registrationCoreApp.on(route.method, route.path, route.handler); +} +if (!registrationEvidenceBoundaryInstalled) { + throw new Error('ScopeWeave webhook registration core route is unavailable'); +} + function canonicalRegistrationUrl(value) { return validateWebhookRegistrationUrl(value, { allowDevelopmentLoopback: process.env.SCOPEWEAVE_DEV === '1', @@ -74,8 +101,9 @@ function canonicalRegistrationBody(original) { try { await source.cancel('webhook registration body too large'); } catch { /* best effort */ } try { source.releaseLock(); } catch { /* cancellation may release it */ } // Feed the core route a side-effect-free invalid registration after - // auth/RBAC has already admitted this request. The facade converts - // that route response into the stable 413 below. + // auth/RBAC has already admitted this request. The evidence boundary + // above replaces that route response with the stable 413 before the + // inherited request logger resumes. controller.enqueue(encoder.encode(JSON.stringify({ url: '' }))); controller.close(); return; @@ -122,11 +150,14 @@ function requestWithCanonicalRegistration(original) { async function registrationPolicyResponse(c) { // Route the request through the core exactly once. The forwarding stream has // no eager queue, so core rate-limit/auth/RBAC middleware can reject a request - // without the facade draining an attacker-controlled body first. + // without the facade draining an attacker-controlled body first. The mutable + // evidence object is process-local Hono env state, not a spoofable HTTP header. const { request, state } = requestWithCanonicalRegistration(c.req.raw); let response; try { - response = await coreApp.fetch(request); + response = await registrationCoreApp.fetch(request, { + [WEBHOOK_REGISTRATION_EVIDENCE]: state, + }); } finally { // Authentication/RBAC/rate-limit rejection can return without consuming the // forwarding body. Cancel that unread stream so its reader releases the @@ -135,8 +166,7 @@ async function registrationPolicyResponse(c) { try { await request.body.cancel('webhook registration request completed'); } catch { /* best effort */ } } } - if (!state.tooLarge) return response; - return c.json({ error: 'webhook registration body too large' }, 413); + return response; } async function stripeWebhookResponse(c) { From 2781be8913180417a84672f18cf881fe89eab7c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 16:29:57 -0700 Subject: [PATCH 57/71] fix(observability): translate bounded webhook status in route chain --- server/app.mjs | 44 +++++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 4bd4f58c..0b459088 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -9,32 +9,34 @@ import { validateWebhookRegistrationUrl } from './webhook_transport.mjs'; const WEBHOOK_REGISTRATION_PATH = '/api/orgs/:id/webhooks'; const WEBHOOK_REGISTRATION_BODY_MAX_BYTES = 16 * 1024; -const WEBHOOK_REGISTRATION_EVIDENCE = Symbol.for('scopeweave.webhookRegistrationEvidence'); +const webhookRegistrationEvidence = new WeakMap(); const STRIPE_WEBHOOK_PATH = '/api/stripe/webhook'; // Registration requests need the core logger/rate-limit/auth/RBAC stack to run // exactly once, but an oversized authorized body must become 413 *inside* that // stack so the structured access record and counters match the public response. -// Replay only global middleware plus this route, and insert the translation after -// global middleware but before route-local auth/handler entries. Hono preserves -// registration order in routes; the public facade below relies on the same -// contract when replaying the protected core graph. +// Replay only global middleware plus this route. Registration handlers are +// wrapped at their existing position so the bounded-body result is translated +// before the inherited request logger resumes. The evidence stays process-local +// in a WeakMap keyed by the forwarded Request, so clients cannot spoof it. const registrationCoreApp = new Hono(); -let registrationEvidenceBoundaryInstalled = false; +let registrationRouteInstalled = false; for (const route of coreApp.routes) { if (route.path !== '*' && route.path !== WEBHOOK_REGISTRATION_PATH) continue; - if (!registrationEvidenceBoundaryInstalled && route.path === WEBHOOK_REGISTRATION_PATH) { - registrationCoreApp.use(WEBHOOK_REGISTRATION_PATH, async (c, next) => { - await next(); - if (c.env?.[WEBHOOK_REGISTRATION_EVIDENCE]?.tooLarge === true) { - c.res = c.json({ error: 'webhook registration body too large' }, 413); + if (route.path === WEBHOOK_REGISTRATION_PATH) { + registrationRouteInstalled = true; + registrationCoreApp.on(route.method, route.path, async (c, next) => { + const response = await route.handler(c, next); + if (webhookRegistrationEvidence.get(c.req.raw)?.tooLarge === true) { + return c.json({ error: 'webhook registration body too large' }, 413); } + return response; }); - registrationEvidenceBoundaryInstalled = true; + continue; } registrationCoreApp.on(route.method, route.path, route.handler); } -if (!registrationEvidenceBoundaryInstalled) { +if (!registrationRouteInstalled) { throw new Error('ScopeWeave webhook registration core route is unavailable'); } @@ -101,9 +103,9 @@ function canonicalRegistrationBody(original) { try { await source.cancel('webhook registration body too large'); } catch { /* best effort */ } try { source.releaseLock(); } catch { /* cancellation may release it */ } // Feed the core route a side-effect-free invalid registration after - // auth/RBAC has already admitted this request. The evidence boundary - // above replaces that route response with the stable 413 before the - // inherited request logger resumes. + // auth/RBAC has already admitted this request. The wrapped core route + // replaces that invalid-registration response with the stable 413 + // before inherited request observability resumes. controller.enqueue(encoder.encode(JSON.stringify({ url: '' }))); controller.close(); return; @@ -150,15 +152,15 @@ function requestWithCanonicalRegistration(original) { async function registrationPolicyResponse(c) { // Route the request through the core exactly once. The forwarding stream has // no eager queue, so core rate-limit/auth/RBAC middleware can reject a request - // without the facade draining an attacker-controlled body first. The mutable - // evidence object is process-local Hono env state, not a spoofable HTTP header. + // without the facade draining an attacker-controlled body first. Bounded-body + // evidence is keyed to this Request in process memory, not in an HTTP header. const { request, state } = requestWithCanonicalRegistration(c.req.raw); + webhookRegistrationEvidence.set(request, state); let response; try { - response = await registrationCoreApp.fetch(request, { - [WEBHOOK_REGISTRATION_EVIDENCE]: state, - }); + response = await registrationCoreApp.fetch(request); } finally { + webhookRegistrationEvidence.delete(request); // Authentication/RBAC/rate-limit rejection can return without consuming the // forwarding body. Cancel that unread stream so its reader releases the // original network body instead of keeping the connection resource locked. From 10c98b091552b355e34f3a22b0f7aa7f83ecdb38 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 16:44:41 -0700 Subject: [PATCH 58/71] fix(webhooks): align oversized access status --- server/app.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/app.mjs b/server/app.mjs index 0b459088..cc75441b 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -28,7 +28,12 @@ for (const route of coreApp.routes) { registrationCoreApp.on(route.method, route.path, async (c, next) => { const response = await route.handler(c, next); if (webhookRegistrationEvidence.get(c.req.raw)?.tooLarge === true) { - return c.json({ error: 'webhook registration body too large' }, 413); + const oversizedResponse = c.json({ error: 'webhook registration body too large' }, 413); + // Hono's outer logger observes c.res after next() returns. Assign the + // replacement here, before control unwinds, so access evidence records + // the same 413 that the customer receives instead of the core's probe 400. + c.res = oversizedResponse; + return oversizedResponse; } return response; }); From 3a3977e6c53721e94c681877cd45676cfe5addb8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 16:50:07 -0700 Subject: [PATCH 59/71] fix(webhooks): wrap registration with global middleware --- server/app.mjs | 99 +++++++++++++++++++++++++++----------------------- 1 file changed, 53 insertions(+), 46 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index cc75441b..957e4daa 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -12,34 +12,27 @@ const WEBHOOK_REGISTRATION_BODY_MAX_BYTES = 16 * 1024; const webhookRegistrationEvidence = new WeakMap(); const STRIPE_WEBHOOK_PATH = '/api/stripe/webhook'; -// Registration requests need the core logger/rate-limit/auth/RBAC stack to run -// exactly once, but an oversized authorized body must become 413 *inside* that -// stack so the structured access record and counters match the public response. -// Replay only global middleware plus this route. Registration handlers are -// wrapped at their existing position so the bounded-body result is translated -// before the inherited request logger resumes. The evidence stays process-local -// in a WeakMap keyed by the forwarded Request, so clients cannot spoof it. +// The public app below owns the core's global logger/rate-limit middleware. The +// private replay app therefore contains only the route-specific registration +// chain (auth/RBAC + handler). That makes global accounting run exactly once and +// lets the public logger observe the facade's final response status instead of +// the side-effect-free probe used after an authorized body exceeds its budget. +// Route handlers are wrapped at their existing position so the bounded-body +// result is translated before the private route chain unwinds. Evidence remains +// process-local in a WeakMap keyed by the forwarded Request, so clients cannot +// spoof it. const registrationCoreApp = new Hono(); let registrationRouteInstalled = false; for (const route of coreApp.routes) { - if (route.path !== '*' && route.path !== WEBHOOK_REGISTRATION_PATH) continue; - if (route.path === WEBHOOK_REGISTRATION_PATH) { - registrationRouteInstalled = true; - registrationCoreApp.on(route.method, route.path, async (c, next) => { - const response = await route.handler(c, next); - if (webhookRegistrationEvidence.get(c.req.raw)?.tooLarge === true) { - const oversizedResponse = c.json({ error: 'webhook registration body too large' }, 413); - // Hono's outer logger observes c.res after next() returns. Assign the - // replacement here, before control unwinds, so access evidence records - // the same 413 that the customer receives instead of the core's probe 400. - c.res = oversizedResponse; - return oversizedResponse; - } - return response; - }); - continue; - } - registrationCoreApp.on(route.method, route.path, route.handler); + if (route.path !== WEBHOOK_REGISTRATION_PATH) continue; + registrationRouteInstalled = true; + registrationCoreApp.on(route.method, route.path, async (c, next) => { + const response = await route.handler(c, next); + if (webhookRegistrationEvidence.get(c.req.raw)?.tooLarge === true) { + return c.json({ error: 'webhook registration body too large' }, 413); + } + return response; + }); } if (!registrationRouteInstalled) { throw new Error('ScopeWeave webhook registration core route is unavailable'); @@ -81,10 +74,11 @@ function canonicalRegistrationBody(original) { let finished = false; // A zero-sized queue is deliberate: creating the forwarding Request must not - // pull attacker-controlled bytes. The core rate-limit/auth/RBAC middleware - // therefore runs first; only its route-level c.req.json() read starts source - // consumption for an already-authorized registration request. Once that read - // begins, the facade enforces a small explicit memory budget before decoding. + // pull attacker-controlled bytes. Public global middleware has already run; + // only the private route's auth/RBAC chain can reach c.req.json(), so body + // consumption starts only for an authorized registration request. Once that + // read begins, the facade enforces a small explicit memory budget before + // decoding. const body = new ReadableStream({ async pull(controller) { if (finished) return; @@ -107,10 +101,10 @@ function canonicalRegistrationBody(original) { finished = true; try { await source.cancel('webhook registration body too large'); } catch { /* best effort */ } try { source.releaseLock(); } catch { /* cancellation may release it */ } - // Feed the core route a side-effect-free invalid registration after - // auth/RBAC has already admitted this request. The wrapped core route - // replaces that invalid-registration response with the stable 413 - // before inherited request observability resumes. + // Feed the private core route a side-effect-free invalid registration + // after auth/RBAC has already admitted this request. The wrapped route + // translates that probe into the stable 413; the public logger then + // records the same final status returned to the customer. controller.enqueue(encoder.encode(JSON.stringify({ url: '' }))); controller.close(); return; @@ -155,10 +149,11 @@ function requestWithCanonicalRegistration(original) { } async function registrationPolicyResponse(c) { - // Route the request through the core exactly once. The forwarding stream has - // no eager queue, so core rate-limit/auth/RBAC middleware can reject a request - // without the facade draining an attacker-controlled body first. Bounded-body - // evidence is keyed to this Request in process memory, not in an HTTP header. + // Public global middleware has already run exactly once. The zero-queue + // forwarding stream then enters only the route-specific core chain, where + // auth/RBAC can reject the request without draining attacker-controlled body + // bytes. Bounded-body evidence is keyed to this Request in process memory, not + // in an HTTP header. const { request, state } = requestWithCanonicalRegistration(c.req.raw); webhookRegistrationEvidence.set(request, state); let response; @@ -166,9 +161,9 @@ async function registrationPolicyResponse(c) { response = await registrationCoreApp.fetch(request); } finally { webhookRegistrationEvidence.delete(request); - // Authentication/RBAC/rate-limit rejection can return without consuming the - // forwarding body. Cancel that unread stream so its reader releases the - // original network body instead of keeping the connection resource locked. + // Authentication/RBAC rejection can return without consuming the forwarding + // body. Cancel that unread stream so its reader releases the original network + // body instead of keeping the connection resource locked. if (request.body && !request.bodyUsed && !request.body.locked) { try { await request.body.cancel('webhook registration request completed'); } catch { /* best effort */ } } @@ -195,19 +190,31 @@ async function stripeWebhookResponse(c) { /** * Public ScopeWeave HTTP application with fail-closed webhook boundaries. * - * The core route and middleware graph is preserved in registration order, but - * the historical unsigned Stripe route is deliberately omitted from the public - * graph and replaced after the inherited request logging/rate-limit middleware. - * This makes unsigned plan escalation unreachable from the shipped server while - * retaining the existing destination-registration facade and all other routes. + * The core route and middleware graph is preserved in registration order. Core + * global middleware is mounted first so every public request, including the + * registration facade and verified Stripe route, retains the same logging and + * rate-limit envelope. The historical unsigned Stripe route is deliberately + * omitted and replaced after those inherited global controls. */ export const app = new Hono(); + +// Global core middleware must wrap the registration facade as well as ordinary +// routes. Keeping it here (and out of registrationCoreApp) makes final response +// status, metrics, and limiter state line up with the public request exactly once. +for (const route of coreApp.routes.filter(({ path }) => path === '*')) { + app.on(route.method, route.path, route.handler); +} + app.use(WEBHOOK_REGISTRATION_PATH, async (c, next) => { if (c.req.method !== 'POST') return next(); return registrationPolicyResponse(c); }); + for (const route of coreApp.routes.filter( - ({ method, path }) => !(method === 'POST' && path === STRIPE_WEBHOOK_PATH), + ({ method, path }) => ( + path !== '*' + && !(method === 'POST' && path === STRIPE_WEBHOOK_PATH) + ), )) { app.on(route.method, route.path, route.handler); } From 4db0cf52b440453c2203c9b829135d3db36fd0c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 16:54:23 -0700 Subject: [PATCH 60/71] fix(webhooks): expose facade status to access logger --- server/app.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/app.mjs b/server/app.mjs index 957e4daa..5e0281ea 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -207,7 +207,12 @@ for (const route of coreApp.routes.filter(({ path }) => path === '*')) { app.use(WEBHOOK_REGISTRATION_PATH, async (c, next) => { if (c.req.method !== 'POST') return next(); - return registrationPolicyResponse(c); + const response = await registrationPolicyResponse(c); + // Assign the facade's final response before outer global middleware resumes. + // Hono's access logger reads c.res after await next(), so merely returning the + // replacement Response can leave it observing the private probe's 400 status. + c.res = response; + return response; }); for (const route of coreApp.routes.filter( From ad84b560a5328c229f616ad6d59ba51ba4874673 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 17:00:27 -0700 Subject: [PATCH 61/71] test(webhooks): distinguish missing access evidence --- tests/api/webhook-destination-policy.test.mjs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/api/webhook-destination-policy.test.mjs b/tests/api/webhook-destination-policy.test.mjs index f6f7b3e8..5a28f41e 100644 --- a/tests/api/webhook-destination-policy.test.mjs +++ b/tests/api/webhook-destination-policy.test.mjs @@ -42,6 +42,10 @@ const request = (path, options = {}) => app.request(path, { }, }); const json = (value) => JSON.stringify(value); +const registrationAccessLogs = (organizationId) => requestLogs.filter((record) => ( + record.method === 'POST' + && record.path === `/api/orgs/${organizationId}/webhooks` +)); let response = await request('/api/auth/signup', { method: 'POST', @@ -120,6 +124,7 @@ const oversizedAuthorizedBody = new ReadableStream({ authorizedBodyCancelReason = String(reason); }, }); +const accessLogCountBeforeOversized = registrationAccessLogs(organizationId).length; response = await request(`/api/orgs/${organizationId}/webhooks`, { method: 'POST', headers: authorization, @@ -146,12 +151,13 @@ assert.equal( 'webhook registration body too large', 'oversized webhook cancellation records the bounded-body reason', ); -const oversizedAccessLog = requestLogs - .filter((record) => ( - record.method === 'POST' - && record.path === `/api/orgs/${organizationId}/webhooks` - )) - .at(-1); +const oversizedAccessLogs = registrationAccessLogs(organizationId); +assert.equal( + oversizedAccessLogs.length, + accessLogCountBeforeOversized + 1, + 'every public webhook registration must append exactly one structured access record', +); +const oversizedAccessLog = oversizedAccessLogs.at(-1); assert.equal( oversizedAccessLog?.status, 413, From 3e6fc6c142a350ea0f4b0d15b95ae7dbbbae6bba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 17:48:42 -0700 Subject: [PATCH 62/71] fix(webhooks): preserve global middleware envelope --- server/app.mjs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 5e0281ea..be046b46 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -198,11 +198,15 @@ async function stripeWebhookResponse(c) { */ export const app = new Hono(); -// Global core middleware must wrap the registration facade as well as ordinary -// routes. Keeping it here (and out of registrationCoreApp) makes final response -// status, metrics, and limiter state line up with the public request exactly once. -for (const route of coreApp.routes.filter(({ path }) => path === '*')) { - app.on(route.method, route.path, route.handler); +// Hono normalizes root `*` registrations to `/*` in `routes`. Identify only the +// method-ALL records produced by core `app.use('*', ...)`; the final GET `/*` +// static fallback is a route, not middleware, and must keep its original tail +// position. Re-registering these records before the facade makes the core logger +// and optional rate limiter wrap registration POSTs instead of being replayed +// after the short-circuiting facade. +const isGlobalCoreMiddleware = ({ method, path }) => method === 'ALL' && path === '/*'; +for (const route of coreApp.routes.filter(isGlobalCoreMiddleware)) { + app.use(route.path, route.handler); } app.use(WEBHOOK_REGISTRATION_PATH, async (c, next) => { @@ -216,9 +220,9 @@ app.use(WEBHOOK_REGISTRATION_PATH, async (c, next) => { }); for (const route of coreApp.routes.filter( - ({ method, path }) => ( - path !== '*' - && !(method === 'POST' && path === STRIPE_WEBHOOK_PATH) + (route) => ( + !isGlobalCoreMiddleware(route) + && !(route.method === 'POST' && route.path === STRIPE_WEBHOOK_PATH) ), )) { app.on(route.method, route.path, route.handler); From 33b5aa6e78a8f663286b1b264373765a9a3ad770 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 22:50:36 -0700 Subject: [PATCH 63/71] test(billing): require verified Stripe entitlement reconciliation --- tests/api/stripe-webhook-security.test.mjs | 24 ++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/tests/api/stripe-webhook-security.test.mjs b/tests/api/stripe-webhook-security.test.mjs index b6b7b831..0d649a2a 100644 --- a/tests/api/stripe-webhook-security.test.mjs +++ b/tests/api/stripe-webhook-security.test.mjs @@ -11,6 +11,7 @@ process.env.STRIPE_PRICE_ID = 'price_scopeweave_webhook'; process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_api_webhook_secret'; const { app } = await import('../../server/app.mjs?stripe-webhook-security-regression=1'); +const { db } = await import('../../server/db.mjs'); const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET; const jsonHeaders = { 'content-type': 'application/json' }; @@ -57,6 +58,7 @@ function checkoutCompletedBody(orgId, idPrefix = 'evt_checkout') { type: 'checkout.session.completed', data: { object: { + mode: 'subscription', client_reference_id: String(orgId), metadata: { orgId: String(orgId) }, }, @@ -80,10 +82,10 @@ test('unsigned Stripe provider-shaped JSON cannot upgrade an organization', asyn assert.equal(await currentPlan(token), 'free'); }); -test('authenticated Stripe delivery is acknowledged without granting entitlement', async () => { +test('verified subscription checkout activates the matching organization idempotently', async () => { const { token, orgId } = await signupAndOrg(); const body = checkoutCompletedBody(orgId); - const response = await app.request('https://scopeweave.example/api/stripe/webhook', { + const send = () => app.request('https://scopeweave.example/api/stripe/webhook', { method: 'POST', headers: { ...jsonHeaders, @@ -92,14 +94,28 @@ test('authenticated Stripe delivery is acknowledged without granting entitlement body, }); + let response = await send(); assert.equal(response.status, 200); assert.deepEqual(await response.json(), { received: true }); assert.equal(response.headers.get('cache-control'), 'no-store'); assert.equal( await currentPlan(token), - 'free', - 'signature authentication alone cannot bypass durable provider-state reconciliation', + 'pro', + 'a verified checkout created for this organization must activate the purchased plan', ); + + response = await send(); + assert.equal(response.status, 200, 'provider retries are acknowledged'); + assert.equal(await currentPlan(token), 'pro'); + const audit = db.prepare(` + SELECT COUNT(*) AS count + FROM audit_log + WHERE org_id = ? + AND action = 'billing.checkout_completed' + AND target_type = 'stripe_event' + AND target_id = ? + `).get(orgId, `evt_checkout_${orgId}`); + assert.equal(audit.count, 1, 'the same verified Stripe event is reconciled exactly once'); }); test('stale signatures and raw-body mutation fail closed without changing plan state', async () => { From 2bbcd924aea038cea566f2e75f513f3596499cf9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 22:52:16 -0700 Subject: [PATCH 64/71] fix(billing): reconcile verified Stripe checkout entitlement --- server/app.mjs | 83 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 3 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index be046b46..e96f21ae 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -4,6 +4,7 @@ // attachment, Clearfolio, or project-planning behavior. import { Hono } from 'hono'; import { app as coreApp } from './app_core.mjs'; +import { db } from './db.mjs'; import { StripeWebhookError, verifyStripeWebhookRequest } from './stripe_webhook.mjs'; import { validateWebhookRegistrationUrl } from './webhook_transport.mjs'; @@ -11,6 +12,9 @@ const WEBHOOK_REGISTRATION_PATH = '/api/orgs/:id/webhooks'; const WEBHOOK_REGISTRATION_BODY_MAX_BYTES = 16 * 1024; const webhookRegistrationEvidence = new WeakMap(); const STRIPE_WEBHOOK_PATH = '/api/stripe/webhook'; +const STRIPE_CHECKOUT_AUDIT_ACTION = 'billing.checkout_completed'; +const STRIPE_EVENT_TARGET_TYPE = 'stripe_event'; +const POSITIVE_DECIMAL_ID = /^[1-9]\d*$/; // The public app below owns the core's global logger/rate-limit middleware. The // private replay app therefore contains only the route-specific registration @@ -171,13 +175,86 @@ async function registrationPolicyResponse(c) { return response; } +/** + * Resolve the ScopeWeave organization bound to a verified subscription checkout. + * + * ScopeWeave creates Stripe Checkout sessions with both client_reference_id and + * metadata.orgId set to the same server-selected organization identifier. Both + * fields must therefore be present, canonical positive integers, and equal before + * a provider event can become entitlement authority. Provider-shaped but + * inconsistent events are acknowledged without mutating tenant state. + */ +function checkoutOrganizationId(event) { + if (event?.type !== 'checkout.session.completed') return null; + const checkout = event?.data?.object; + if (!checkout || typeof checkout !== 'object' || checkout.mode !== 'subscription') return null; + + const referenceId = checkout.client_reference_id; + const metadataId = checkout.metadata?.orgId; + if ( + typeof referenceId !== 'string' + || typeof metadataId !== 'string' + || !POSITIVE_DECIMAL_ID.test(referenceId) + || !POSITIVE_DECIMAL_ID.test(metadataId) + || referenceId !== metadataId + ) return null; + + const orgId = Number(referenceId); + return Number.isSafeInteger(orgId) ? orgId : null; +} + +/** + * Project one verified Stripe checkout into ScopeWeave entitlement state once. + * + * The existing append-only audit_log is also the durable provider-event ledger: + * a BEGIN IMMEDIATE transaction serializes duplicate deliveries, checks the + * globally unique Stripe event id before any plan write, updates only an existing + * organization, and records the event id without secrets or provider payloads. + * Replayed deliveries are acknowledged but cannot create a second entitlement + * transition or audit record. + */ +function reconcileStripeCheckout(event) { + const orgId = checkoutOrganizationId(event); + if (orgId === null) return; + + db.exec('BEGIN IMMEDIATE'); + try { + const alreadyProcessed = db.prepare(` + SELECT id + FROM audit_log + WHERE action = ? AND target_type = ? AND target_id = ? + LIMIT 1 + `).get(STRIPE_CHECKOUT_AUDIT_ACTION, STRIPE_EVENT_TARGET_TYPE, event.id); + + if (!alreadyProcessed) { + const org = db.prepare('SELECT id FROM orgs WHERE id = ?').get(orgId); + if (org) { + db.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").run(orgId); + db.prepare(` + INSERT INTO audit_log(org_id,user_id,action,target_type,target_id,meta) + VALUES(?,NULL,?,?,?,?) + `).run( + orgId, + STRIPE_CHECKOUT_AUDIT_ACTION, + STRIPE_EVENT_TARGET_TYPE, + event.id, + JSON.stringify({ provider: 'stripe', eventType: event.type, plan: 'pro' }), + ); + } + } + db.exec('COMMIT'); + } catch (error) { + try { db.exec('ROLLBACK'); } catch { /* preserve the reconciliation failure */ } + throw error; + } +} + async function stripeWebhookResponse(c) { try { - await verifyStripeWebhookRequest(c.req.raw, { + const event = await verifyStripeWebhookRequest(c.req.raw, { secret: process.env.STRIPE_WEBHOOK_SECRET, }); - // Authentication of a provider delivery is not entitlement authority. A - // later durable event ledger/provider-state reconciliation owns plan writes. + reconcileStripeCheckout(event); return c.json({ received: true }, 200, { 'Cache-Control': 'no-store' }); } catch (error) { if (error instanceof StripeWebhookError) { From e1db2fb5c83b3c587fae12a3e4becf53e06abff0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 22:53:07 -0700 Subject: [PATCH 65/71] test(billing): reject unpaid Stripe checkout entitlement --- tests/api/stripe-webhook-security.test.mjs | 32 ++++++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/tests/api/stripe-webhook-security.test.mjs b/tests/api/stripe-webhook-security.test.mjs index 0d649a2a..0e3d6dc3 100644 --- a/tests/api/stripe-webhook-security.test.mjs +++ b/tests/api/stripe-webhook-security.test.mjs @@ -52,13 +52,14 @@ async function currentPlan(token) { return (await response.json()).orgs[0].plan; } -function checkoutCompletedBody(orgId, idPrefix = 'evt_checkout') { +function checkoutCompletedBody(orgId, idPrefix = 'evt_checkout', paymentStatus = 'paid') { return JSON.stringify({ id: `${idPrefix}_${orgId}`, type: 'checkout.session.completed', data: { object: { mode: 'subscription', + payment_status: paymentStatus, client_reference_id: String(orgId), metadata: { orgId: String(orgId) }, }, @@ -82,7 +83,7 @@ test('unsigned Stripe provider-shaped JSON cannot upgrade an organization', asyn assert.equal(await currentPlan(token), 'free'); }); -test('verified subscription checkout activates the matching organization idempotently', async () => { +test('verified paid subscription checkout activates the matching organization idempotently', async () => { const { token, orgId } = await signupAndOrg(); const body = checkoutCompletedBody(orgId); const send = () => app.request('https://scopeweave.example/api/stripe/webhook', { @@ -101,7 +102,7 @@ test('verified subscription checkout activates the matching organization idempot assert.equal( await currentPlan(token), 'pro', - 'a verified checkout created for this organization must activate the purchased plan', + 'a verified paid checkout created for this organization must activate the purchased plan', ); response = await send(); @@ -118,6 +119,31 @@ test('verified subscription checkout activates the matching organization idempot assert.equal(audit.count, 1, 'the same verified Stripe event is reconciled exactly once'); }); +test('verified unpaid checkout is acknowledged without granting entitlement', async () => { + const { token, orgId } = await signupAndOrg(); + const body = checkoutCompletedBody(orgId, 'evt_unpaid', 'unpaid'); + const response = await app.request('https://scopeweave.example/api/stripe/webhook', { + method: 'POST', + headers: { + ...jsonHeaders, + 'stripe-signature': signatureHeader(body), + }, + body, + }); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { received: true }); + assert.equal(await currentPlan(token), 'free'); + const audit = db.prepare(` + SELECT COUNT(*) AS count + FROM audit_log + WHERE action = 'billing.checkout_completed' + AND target_type = 'stripe_event' + AND target_id = ? + `).get(`evt_unpaid_${orgId}`); + assert.equal(audit.count, 0, 'an unpaid checkout cannot enter the entitlement ledger'); +}); + test('stale signatures and raw-body mutation fail closed without changing plan state', async () => { const { token, orgId } = await signupAndOrg(); const body = checkoutCompletedBody(orgId, 'evt_replay'); From 5eecf10f45743a77d6f253bc2e05e44c5a8f8ba8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 22:54:33 -0700 Subject: [PATCH 66/71] fix(billing): require paid Stripe checkout before entitlement --- server/app.mjs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index e96f21ae..b17b0bc3 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -176,18 +176,24 @@ async function registrationPolicyResponse(c) { } /** - * Resolve the ScopeWeave organization bound to a verified subscription checkout. + * Resolve the ScopeWeave organization bound to a verified paid subscription checkout. * * ScopeWeave creates Stripe Checkout sessions with both client_reference_id and * metadata.orgId set to the same server-selected organization identifier. Both - * fields must therefore be present, canonical positive integers, and equal before - * a provider event can become entitlement authority. Provider-shaped but - * inconsistent events are acknowledged without mutating tenant state. + * fields must therefore be present, canonical positive integers, and equal. The + * checkout must also report subscription mode and a paid status before a provider + * event can become entitlement authority. Provider-shaped but inconsistent or + * unpaid events are acknowledged without mutating tenant state. */ function checkoutOrganizationId(event) { if (event?.type !== 'checkout.session.completed') return null; const checkout = event?.data?.object; - if (!checkout || typeof checkout !== 'object' || checkout.mode !== 'subscription') return null; + if ( + !checkout + || typeof checkout !== 'object' + || checkout.mode !== 'subscription' + || checkout.payment_status !== 'paid' + ) return null; const referenceId = checkout.client_reference_id; const metadataId = checkout.metadata?.orgId; From 4131b81ed79c6d152f184d1cb2b1f5981712662a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 22:58:52 -0700 Subject: [PATCH 67/71] test(billing): cover delayed Stripe payment success --- tests/api/stripe-webhook-security.test.mjs | 52 +++++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/tests/api/stripe-webhook-security.test.mjs b/tests/api/stripe-webhook-security.test.mjs index 0e3d6dc3..bacb0747 100644 --- a/tests/api/stripe-webhook-security.test.mjs +++ b/tests/api/stripe-webhook-security.test.mjs @@ -52,10 +52,15 @@ async function currentPlan(token) { return (await response.json()).orgs[0].plan; } -function checkoutCompletedBody(orgId, idPrefix = 'evt_checkout', paymentStatus = 'paid') { +function checkoutBody( + orgId, + idPrefix = 'evt_checkout', + paymentStatus = 'paid', + type = 'checkout.session.completed', +) { return JSON.stringify({ id: `${idPrefix}_${orgId}`, - type: 'checkout.session.completed', + type, data: { object: { mode: 'subscription', @@ -74,7 +79,7 @@ test('unsigned Stripe provider-shaped JSON cannot upgrade an organization', asyn const response = await app.request('https://scopeweave.example/api/stripe/webhook', { method: 'POST', headers: jsonHeaders, - body: checkoutCompletedBody(orgId, 'evt_unsigned'), + body: checkoutBody(orgId, 'evt_unsigned'), }); assert.equal(response.status, 400); @@ -85,7 +90,7 @@ test('unsigned Stripe provider-shaped JSON cannot upgrade an organization', asyn test('verified paid subscription checkout activates the matching organization idempotently', async () => { const { token, orgId } = await signupAndOrg(); - const body = checkoutCompletedBody(orgId); + const body = checkoutBody(orgId); const send = () => app.request('https://scopeweave.example/api/stripe/webhook', { method: 'POST', headers: { @@ -121,7 +126,7 @@ test('verified paid subscription checkout activates the matching organization id test('verified unpaid checkout is acknowledged without granting entitlement', async () => { const { token, orgId } = await signupAndOrg(); - const body = checkoutCompletedBody(orgId, 'evt_unpaid', 'unpaid'); + const body = checkoutBody(orgId, 'evt_unpaid', 'unpaid'); const response = await app.request('https://scopeweave.example/api/stripe/webhook', { method: 'POST', headers: { @@ -144,9 +149,44 @@ test('verified unpaid checkout is acknowledged without granting entitlement', as assert.equal(audit.count, 0, 'an unpaid checkout cannot enter the entitlement ledger'); }); +test('verified delayed-payment success activates the matching subscription organization', async () => { + const { token, orgId } = await signupAndOrg(); + const body = checkoutBody( + orgId, + 'evt_async_paid', + 'paid', + 'checkout.session.async_payment_succeeded', + ); + const response = await app.request('https://scopeweave.example/api/stripe/webhook', { + method: 'POST', + headers: { + ...jsonHeaders, + 'stripe-signature': signatureHeader(body), + }, + body, + }); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { received: true }); + assert.equal( + await currentPlan(token), + 'pro', + 'a verified delayed-payment success must activate the purchased subscription after funds become available', + ); + const audit = db.prepare(` + SELECT COUNT(*) AS count + FROM audit_log + WHERE org_id = ? + AND action = 'billing.checkout_completed' + AND target_type = 'stripe_event' + AND target_id = ? + `).get(orgId, `evt_async_paid_${orgId}`); + assert.equal(audit.count, 1, 'the delayed-payment success enters the entitlement ledger exactly once'); +}); + test('stale signatures and raw-body mutation fail closed without changing plan state', async () => { const { token, orgId } = await signupAndOrg(); - const body = checkoutCompletedBody(orgId, 'evt_replay'); + const body = checkoutBody(orgId, 'evt_replay'); const staleTimestamp = Math.floor(Date.now() / 1000) - 301; let response = await app.request('https://scopeweave.example/api/stripe/webhook', { From 5c42fbe5f361466afdf33644fd902674fb7b6221 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 23:00:47 -0700 Subject: [PATCH 68/71] fix(billing): reconcile delayed Stripe payment success --- server/app.mjs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index b17b0bc3..32f8a0c6 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -14,6 +14,10 @@ const webhookRegistrationEvidence = new WeakMap(); const STRIPE_WEBHOOK_PATH = '/api/stripe/webhook'; const STRIPE_CHECKOUT_AUDIT_ACTION = 'billing.checkout_completed'; const STRIPE_EVENT_TARGET_TYPE = 'stripe_event'; +const STRIPE_ENTITLEMENT_EVENTS = new Set([ + 'checkout.session.completed', + 'checkout.session.async_payment_succeeded', +]); const POSITIVE_DECIMAL_ID = /^[1-9]\d*$/; // The public app below owns the core's global logger/rate-limit middleware. The @@ -181,12 +185,13 @@ async function registrationPolicyResponse(c) { * ScopeWeave creates Stripe Checkout sessions with both client_reference_id and * metadata.orgId set to the same server-selected organization identifier. Both * fields must therefore be present, canonical positive integers, and equal. The - * checkout must also report subscription mode and a paid status before a provider - * event can become entitlement authority. Provider-shaped but inconsistent or - * unpaid events are acknowledged without mutating tenant state. + * checkout must also report subscription mode and a paid status before either + * the immediate completion event or Stripe's delayed-payment success event can + * become entitlement authority. Provider-shaped but inconsistent or unpaid + * events are acknowledged without mutating tenant state. */ function checkoutOrganizationId(event) { - if (event?.type !== 'checkout.session.completed') return null; + if (!STRIPE_ENTITLEMENT_EVENTS.has(event?.type)) return null; const checkout = event?.data?.object; if ( !checkout From 6d18ecc6e46fba63fe21623a4a5608493ef305fd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 00:57:02 -0700 Subject: [PATCH 69/71] fix(security): keep SSRF lane scoped to outbound webhooks --- package.json | 8 +- server/app.mjs | 125 +---------- server/stripe_webhook.mjs | 224 -------------------- tests/api/stripe-webhook-security.test.mjs | 214 ------------------- tests/unit/stripe-webhook-boundary.test.mjs | 193 ----------------- 5 files changed, 10 insertions(+), 754 deletions(-) delete mode 100644 server/stripe_webhook.mjs delete mode 100644 tests/api/stripe-webhook-security.test.mjs delete mode 100644 tests/unit/stripe-webhook-boundary.test.mjs diff --git a/package.json b/package.json index e31accb1..a0337b7f 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "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/stripe-webhook-security.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/webhook-fetch-contract.test.mjs && node tests/api/webhook-legacy-migration.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/toast-accessibility.test.mjs && node tests/unit/webhook-transport.test.mjs && node tests/unit/webhook-transport-timeout.test.mjs && node tests/unit/webhook-development-transport.test.mjs && node tests/unit/webhook-legacy-migration.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/app_core.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --include=server/webhook_transport.mjs --include=server/webhook_legacy_migration.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/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/webhook-transport.test.mjs && node tests/unit/webhook-transport-timeout.test.mjs && node tests/unit/webhook-development-transport.test.mjs && node tests/unit/webhook-legacy-migration.test.mjs && npm run test:api", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/webhook-fetch-contract.test.mjs && node tests/api/webhook-legacy-migration.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs && node tests/unit/webhook-transport.test.mjs && node tests/unit/webhook-transport-timeout.test.mjs && node tests/unit/webhook-development-transport.test.mjs && node tests/unit/webhook-legacy-migration.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/app_core.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/webhook_transport.mjs --include=server/webhook_legacy_migration.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/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/webhook-transport.test.mjs && node tests/unit/webhook-transport-timeout.test.mjs && node tests/unit/webhook-development-transport.test.mjs && node tests/unit/webhook-legacy-migration.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", diff --git a/server/app.mjs b/server/app.mjs index 32f8a0c6..a3c779a7 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -4,21 +4,11 @@ // attachment, Clearfolio, or project-planning behavior. import { Hono } from 'hono'; import { app as coreApp } from './app_core.mjs'; -import { db } from './db.mjs'; -import { StripeWebhookError, verifyStripeWebhookRequest } from './stripe_webhook.mjs'; import { validateWebhookRegistrationUrl } from './webhook_transport.mjs'; const WEBHOOK_REGISTRATION_PATH = '/api/orgs/:id/webhooks'; const WEBHOOK_REGISTRATION_BODY_MAX_BYTES = 16 * 1024; const webhookRegistrationEvidence = new WeakMap(); -const STRIPE_WEBHOOK_PATH = '/api/stripe/webhook'; -const STRIPE_CHECKOUT_AUDIT_ACTION = 'billing.checkout_completed'; -const STRIPE_EVENT_TARGET_TYPE = 'stripe_event'; -const STRIPE_ENTITLEMENT_EVENTS = new Set([ - 'checkout.session.completed', - 'checkout.session.async_payment_succeeded', -]); -const POSITIVE_DECIMAL_ID = /^[1-9]\d*$/; // The public app below owns the core's global logger/rate-limit middleware. The // private replay app therefore contains only the route-specific registration @@ -180,109 +170,12 @@ async function registrationPolicyResponse(c) { } /** - * Resolve the ScopeWeave organization bound to a verified paid subscription checkout. + * Public ScopeWeave HTTP application with a fail-closed outbound-webhook facade. * - * ScopeWeave creates Stripe Checkout sessions with both client_reference_id and - * metadata.orgId set to the same server-selected organization identifier. Both - * fields must therefore be present, canonical positive integers, and equal. The - * checkout must also report subscription mode and a paid status before either - * the immediate completion event or Stripe's delayed-payment success event can - * become entitlement authority. Provider-shaped but inconsistent or unpaid - * events are acknowledged without mutating tenant state. - */ -function checkoutOrganizationId(event) { - if (!STRIPE_ENTITLEMENT_EVENTS.has(event?.type)) return null; - const checkout = event?.data?.object; - if ( - !checkout - || typeof checkout !== 'object' - || checkout.mode !== 'subscription' - || checkout.payment_status !== 'paid' - ) return null; - - const referenceId = checkout.client_reference_id; - const metadataId = checkout.metadata?.orgId; - if ( - typeof referenceId !== 'string' - || typeof metadataId !== 'string' - || !POSITIVE_DECIMAL_ID.test(referenceId) - || !POSITIVE_DECIMAL_ID.test(metadataId) - || referenceId !== metadataId - ) return null; - - const orgId = Number(referenceId); - return Number.isSafeInteger(orgId) ? orgId : null; -} - -/** - * Project one verified Stripe checkout into ScopeWeave entitlement state once. - * - * The existing append-only audit_log is also the durable provider-event ledger: - * a BEGIN IMMEDIATE transaction serializes duplicate deliveries, checks the - * globally unique Stripe event id before any plan write, updates only an existing - * organization, and records the event id without secrets or provider payloads. - * Replayed deliveries are acknowledged but cannot create a second entitlement - * transition or audit record. - */ -function reconcileStripeCheckout(event) { - const orgId = checkoutOrganizationId(event); - if (orgId === null) return; - - db.exec('BEGIN IMMEDIATE'); - try { - const alreadyProcessed = db.prepare(` - SELECT id - FROM audit_log - WHERE action = ? AND target_type = ? AND target_id = ? - LIMIT 1 - `).get(STRIPE_CHECKOUT_AUDIT_ACTION, STRIPE_EVENT_TARGET_TYPE, event.id); - - if (!alreadyProcessed) { - const org = db.prepare('SELECT id FROM orgs WHERE id = ?').get(orgId); - if (org) { - db.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").run(orgId); - db.prepare(` - INSERT INTO audit_log(org_id,user_id,action,target_type,target_id,meta) - VALUES(?,NULL,?,?,?,?) - `).run( - orgId, - STRIPE_CHECKOUT_AUDIT_ACTION, - STRIPE_EVENT_TARGET_TYPE, - event.id, - JSON.stringify({ provider: 'stripe', eventType: event.type, plan: 'pro' }), - ); - } - } - db.exec('COMMIT'); - } catch (error) { - try { db.exec('ROLLBACK'); } catch { /* preserve the reconciliation failure */ } - throw error; - } -} - -async function stripeWebhookResponse(c) { - try { - const event = await verifyStripeWebhookRequest(c.req.raw, { - secret: process.env.STRIPE_WEBHOOK_SECRET, - }); - reconcileStripeCheckout(event); - return c.json({ received: true }, 200, { 'Cache-Control': 'no-store' }); - } catch (error) { - if (error instanceof StripeWebhookError) { - return c.json({ error: error.code }, error.status, { 'Cache-Control': 'no-store' }); - } - return c.json({ error: 'stripe_webhook_unavailable' }, 500, { 'Cache-Control': 'no-store' }); - } -} - -/** - * Public ScopeWeave HTTP application with fail-closed webhook boundaries. - * - * The core route and middleware graph is preserved in registration order. Core - * global middleware is mounted first so every public request, including the - * registration facade and verified Stripe route, retains the same logging and - * rate-limit envelope. The historical unsigned Stripe route is deliberately - * omitted and replaced after those inherited global controls. + * The protected core route graph remains authoritative for every unrelated + * product surface. Only outbound webhook registration is intercepted here. + * Core global middleware is mounted first so logging and rate limiting still + * wrap the facade exactly once. */ export const app = new Hono(); @@ -307,12 +200,6 @@ app.use(WEBHOOK_REGISTRATION_PATH, async (c, next) => { return response; }); -for (const route of coreApp.routes.filter( - (route) => ( - !isGlobalCoreMiddleware(route) - && !(route.method === 'POST' && route.path === STRIPE_WEBHOOK_PATH) - ), -)) { +for (const route of coreApp.routes.filter((route) => !isGlobalCoreMiddleware(route))) { app.on(route.method, route.path, route.handler); } -app.post(STRIPE_WEBHOOK_PATH, stripeWebhookResponse); diff --git a/server/stripe_webhook.mjs b/server/stripe_webhook.mjs deleted file mode 100644 index 1647fd67..00000000 --- a/server/stripe_webhook.mjs +++ /dev/null @@ -1,224 +0,0 @@ -import { createHmac, timingSafeEqual } from 'node:crypto'; - -const STRIPE_WEBHOOK_MAX_BYTES = 256 * 1024; -const STRIPE_SIGNATURE_HEADER_MAX_LENGTH = 4096; -const STRIPE_SIGNATURE_TOLERANCE_SECONDS = 5 * 60; -const STRIPE_EVENT_FIELD_MAX_LENGTH = 255; -const HEX_SHA256_PATTERN = /^[0-9a-f]{64}$/i; -const DECIMAL_INTEGER_PATTERN = /^\d+$/; - -/** - * Stable, browser-safe Stripe webhook boundary failure. - * - * The error contains only a machine-readable classification and HTTP status; - * signatures, webhook secrets, raw provider payloads, and parser details never - * cross this boundary. - */ -export class StripeWebhookError extends Error { - /** - * Create one sanitized webhook verification failure. - * @param {string} code stable machine-readable error code - * @param {number} status HTTP response status for the adapter - */ - constructor(code, status) { - super(code); - this.name = 'StripeWebhookError'; - this.code = code; - this.status = status; - } -} - -function webhookError(code, status = 400) { - return new StripeWebhookError(code, status); -} - -function requireVerifierConfiguration(secret, nowSeconds) { - if (typeof secret !== 'string' || secret.trim().length === 0) { - throw webhookError('stripe_webhook_not_configured', 503); - } - if (!Number.isSafeInteger(nowSeconds) || nowSeconds < 0) { - throw webhookError('stripe_webhook_request_invalid'); - } -} - -async function readBoundedRawBody(request) { - if (!request || typeof request !== 'object' || !request.headers) { - throw webhookError('stripe_webhook_request_invalid'); - } - - const declaredLength = request.headers.get('content-length'); - if (declaredLength !== null) { - const normalizedLength = declaredLength.trim(); - if (!DECIMAL_INTEGER_PATTERN.test(normalizedLength)) { - throw webhookError('stripe_webhook_request_invalid'); - } - const length = Number(normalizedLength); - if (!Number.isSafeInteger(length)) { - throw webhookError('stripe_webhook_request_invalid'); - } - if (length > STRIPE_WEBHOOK_MAX_BYTES) { - throw webhookError('stripe_webhook_body_too_large', 413); - } - } - - const reader = request.body?.getReader?.(); - if (!reader || typeof reader.read !== 'function') { - throw webhookError('stripe_webhook_request_invalid'); - } - - const chunks = []; - let totalBytes = 0; - try { - for (;;) { - let result; - try { - result = await reader.read(); - } catch { - throw webhookError('stripe_webhook_request_invalid'); - } - if (result.done) break; - if (!(result.value instanceof Uint8Array)) { - throw webhookError('stripe_webhook_request_invalid'); - } - totalBytes += result.value.byteLength; - if (totalBytes > STRIPE_WEBHOOK_MAX_BYTES) { - try { - await reader.cancel(); - } catch { - // Cancellation is best effort after the byte budget has failed closed. - } - throw webhookError('stripe_webhook_body_too_large', 413); - } - chunks.push(result.value); - } - } finally { - try { - reader.releaseLock?.(); - } catch { - // Reader cleanup cannot change the verification result. - } - } - - const body = Buffer.allocUnsafe(totalBytes); - let offset = 0; - for (const chunk of chunks) { - Buffer.from(chunk).copy(body, offset); - offset += chunk.byteLength; - } - return body; -} - -function parseStripeSignatureHeader(header) { - if ( - typeof header !== 'string' - || header.length === 0 - || header.length > STRIPE_SIGNATURE_HEADER_MAX_LENGTH - ) { - throw webhookError('stripe_webhook_signature_invalid'); - } - - const timestamps = []; - const signatures = []; - for (const component of header.split(',')) { - const separator = component.indexOf('='); - if (separator <= 0) continue; - const key = component.slice(0, separator).trim(); - const value = component.slice(separator + 1).trim(); - if (key === 't') timestamps.push(value); - if (key === 'v1') signatures.push(value); - } - - if (timestamps.length !== 1 || !DECIMAL_INTEGER_PATTERN.test(timestamps[0])) { - throw webhookError('stripe_webhook_signature_invalid'); - } - const timestamp = Number(timestamps[0]); - if (!Number.isSafeInteger(timestamp) || timestamp < 0 || signatures.length === 0) { - throw webhookError('stripe_webhook_signature_invalid'); - } - - const validSignatures = signatures.filter((signature) => HEX_SHA256_PATTERN.test(signature)); - if (validSignatures.length === 0) { - throw webhookError('stripe_webhook_signature_invalid'); - } - return { timestamp, signatures: validSignatures }; -} - -function signatureMatches(body, signatureHeader, secret, nowSeconds) { - const { timestamp, signatures } = parseStripeSignatureHeader(signatureHeader); - if (Math.abs(nowSeconds - timestamp) > STRIPE_SIGNATURE_TOLERANCE_SECONDS) { - return false; - } - - const expected = createHmac('sha256', secret) - .update(String(timestamp)) - .update('.') - .update(body) - .digest(); - - let matched = false; - for (const signature of signatures) { - const candidate = Buffer.from(signature, 'hex'); - if (candidate.length === expected.length && timingSafeEqual(candidate, expected)) { - matched = true; - } - } - return matched; -} - -function parseVerifiedEvent(body) { - let event; - try { - const text = new TextDecoder('utf-8', { fatal: true }).decode(body); - event = JSON.parse(text); - } catch { - throw webhookError('stripe_webhook_payload_invalid'); - } - - if (!event || typeof event !== 'object' || Array.isArray(event)) { - throw webhookError('stripe_webhook_payload_invalid'); - } - if ( - typeof event.id !== 'string' - || event.id.length === 0 - || event.id.length > STRIPE_EVENT_FIELD_MAX_LENGTH - || typeof event.type !== 'string' - || event.type.length === 0 - || event.type.length > STRIPE_EVENT_FIELD_MAX_LENGTH - ) { - throw webhookError('stripe_webhook_payload_invalid'); - } - return event; -} - -/** - * Verify and parse one Stripe webhook without mutating its signed request body. - * - * Stripe signs `timestamp + "." + raw request body`; JSON parsing therefore - * happens only after constant-time HMAC verification over the exact streamed - * bytes. The request body is capped at 256 KiB before buffering, the signature - * header is bounded, and the signed timestamp must be within five minutes of the - * server clock. Multiple `v1` values are accepted for endpoint-secret rotation. - * - * This function establishes transport authenticity only. It intentionally does - * not deduplicate event IDs, assume delivery ordering, or grant billing - * entitlements; those operations require durable provider-state reconciliation. - * - * @param {Request} request Fetch-compatible request containing the raw webhook body - * @param {object} options verifier configuration - * @param {string} options.secret Stripe endpoint signing secret - * @param {number} [options.nowSeconds] integer epoch seconds used for replay checks - * @returns {Promise>} verified bounded Stripe event object - * @throws {StripeWebhookError} for unconfigured, oversized, malformed, or unauthenticated requests - */ -export async function verifyStripeWebhookRequest(request, { - secret, - nowSeconds = Math.floor(Date.now() / 1000), -} = {}) { - requireVerifierConfiguration(secret, nowSeconds); - const body = await readBoundedRawBody(request); - const signatureHeader = request.headers.get('stripe-signature'); - if (!signatureMatches(body, signatureHeader, secret, nowSeconds)) { - throw webhookError('stripe_webhook_signature_invalid'); - } - return parseVerifiedEvent(body); -} diff --git a/tests/api/stripe-webhook-security.test.mjs b/tests/api/stripe-webhook-security.test.mjs deleted file mode 100644 index bacb0747..00000000 --- a/tests/api/stripe-webhook-security.test.mjs +++ /dev/null @@ -1,214 +0,0 @@ -import assert from 'node:assert/strict'; -import { createHmac } from 'node:crypto'; -import test from 'node:test'; - -process.env.SCOPEWEAVE_DB = ':memory:'; -delete process.env.SCOPEWEAVE_DEV; -process.env.SCOPEWEAVE_PUBLIC_ORIGIN = 'https://scopeweave.example'; -process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; -process.env.STRIPE_SECRET_KEY = 'sk_test_scopeweave_webhook'; -process.env.STRIPE_PRICE_ID = 'price_scopeweave_webhook'; -process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_api_webhook_secret'; - -const { app } = await import('../../server/app.mjs?stripe-webhook-security-regression=1'); -const { db } = await import('../../server/db.mjs'); - -const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET; -const jsonHeaders = { 'content-type': 'application/json' }; - -function signatureHeader(body, timestamp = Math.floor(Date.now() / 1000)) { - const digest = createHmac('sha256', WEBHOOK_SECRET) - .update(String(timestamp)) - .update('.') - .update(body) - .digest('hex'); - return `t=${timestamp},v1=${digest}`; -} - -async function signupAndOrg() { - const signup = await app.request('https://scopeweave.example/api/auth/signup', { - method: 'POST', - headers: jsonHeaders, - body: JSON.stringify({ - email: `stripe-security-${Date.now()}-${Math.random()}@example.test`, - password: 'password123', - name: 'Stripe Security Owner', - }), - }); - assert.equal(signup.status, 200); - const { token } = await signup.json(); - const me = await app.request('https://scopeweave.example/api/me', { - headers: { authorization: `Bearer ${token}` }, - }); - assert.equal(me.status, 200); - return { token, orgId: (await me.json()).orgs[0].id }; -} - -async function currentPlan(token) { - const response = await app.request('https://scopeweave.example/api/me', { - headers: { authorization: `Bearer ${token}` }, - }); - assert.equal(response.status, 200); - return (await response.json()).orgs[0].plan; -} - -function checkoutBody( - orgId, - idPrefix = 'evt_checkout', - paymentStatus = 'paid', - type = 'checkout.session.completed', -) { - return JSON.stringify({ - id: `${idPrefix}_${orgId}`, - type, - data: { - object: { - mode: 'subscription', - payment_status: paymentStatus, - client_reference_id: String(orgId), - metadata: { orgId: String(orgId) }, - }, - }, - }); -} - -test('unsigned Stripe provider-shaped JSON cannot upgrade an organization', async () => { - const { token, orgId } = await signupAndOrg(); - assert.equal(await currentPlan(token), 'free'); - - const response = await app.request('https://scopeweave.example/api/stripe/webhook', { - method: 'POST', - headers: jsonHeaders, - body: checkoutBody(orgId, 'evt_unsigned'), - }); - - assert.equal(response.status, 400); - assert.deepEqual(await response.json(), { error: 'stripe_webhook_signature_invalid' }); - assert.equal(response.headers.get('cache-control'), 'no-store'); - assert.equal(await currentPlan(token), 'free'); -}); - -test('verified paid subscription checkout activates the matching organization idempotently', async () => { - const { token, orgId } = await signupAndOrg(); - const body = checkoutBody(orgId); - const send = () => app.request('https://scopeweave.example/api/stripe/webhook', { - method: 'POST', - headers: { - ...jsonHeaders, - 'stripe-signature': signatureHeader(body), - }, - body, - }); - - let response = await send(); - assert.equal(response.status, 200); - assert.deepEqual(await response.json(), { received: true }); - assert.equal(response.headers.get('cache-control'), 'no-store'); - assert.equal( - await currentPlan(token), - 'pro', - 'a verified paid checkout created for this organization must activate the purchased plan', - ); - - response = await send(); - assert.equal(response.status, 200, 'provider retries are acknowledged'); - assert.equal(await currentPlan(token), 'pro'); - const audit = db.prepare(` - SELECT COUNT(*) AS count - FROM audit_log - WHERE org_id = ? - AND action = 'billing.checkout_completed' - AND target_type = 'stripe_event' - AND target_id = ? - `).get(orgId, `evt_checkout_${orgId}`); - assert.equal(audit.count, 1, 'the same verified Stripe event is reconciled exactly once'); -}); - -test('verified unpaid checkout is acknowledged without granting entitlement', async () => { - const { token, orgId } = await signupAndOrg(); - const body = checkoutBody(orgId, 'evt_unpaid', 'unpaid'); - const response = await app.request('https://scopeweave.example/api/stripe/webhook', { - method: 'POST', - headers: { - ...jsonHeaders, - 'stripe-signature': signatureHeader(body), - }, - body, - }); - - assert.equal(response.status, 200); - assert.deepEqual(await response.json(), { received: true }); - assert.equal(await currentPlan(token), 'free'); - const audit = db.prepare(` - SELECT COUNT(*) AS count - FROM audit_log - WHERE action = 'billing.checkout_completed' - AND target_type = 'stripe_event' - AND target_id = ? - `).get(`evt_unpaid_${orgId}`); - assert.equal(audit.count, 0, 'an unpaid checkout cannot enter the entitlement ledger'); -}); - -test('verified delayed-payment success activates the matching subscription organization', async () => { - const { token, orgId } = await signupAndOrg(); - const body = checkoutBody( - orgId, - 'evt_async_paid', - 'paid', - 'checkout.session.async_payment_succeeded', - ); - const response = await app.request('https://scopeweave.example/api/stripe/webhook', { - method: 'POST', - headers: { - ...jsonHeaders, - 'stripe-signature': signatureHeader(body), - }, - body, - }); - - assert.equal(response.status, 200); - assert.deepEqual(await response.json(), { received: true }); - assert.equal( - await currentPlan(token), - 'pro', - 'a verified delayed-payment success must activate the purchased subscription after funds become available', - ); - const audit = db.prepare(` - SELECT COUNT(*) AS count - FROM audit_log - WHERE org_id = ? - AND action = 'billing.checkout_completed' - AND target_type = 'stripe_event' - AND target_id = ? - `).get(orgId, `evt_async_paid_${orgId}`); - assert.equal(audit.count, 1, 'the delayed-payment success enters the entitlement ledger exactly once'); -}); - -test('stale signatures and raw-body mutation fail closed without changing plan state', async () => { - const { token, orgId } = await signupAndOrg(); - const body = checkoutBody(orgId, 'evt_replay'); - const staleTimestamp = Math.floor(Date.now() / 1000) - 301; - - let response = await app.request('https://scopeweave.example/api/stripe/webhook', { - method: 'POST', - headers: { - ...jsonHeaders, - 'stripe-signature': signatureHeader(body, staleTimestamp), - }, - body, - }); - assert.equal(response.status, 400); - assert.deepEqual(await response.json(), { error: 'stripe_webhook_signature_invalid' }); - - response = await app.request('https://scopeweave.example/api/stripe/webhook', { - method: 'POST', - headers: { - ...jsonHeaders, - 'stripe-signature': signatureHeader(body), - }, - body: `${body}\n`, - }); - assert.equal(response.status, 400); - assert.deepEqual(await response.json(), { error: 'stripe_webhook_signature_invalid' }); - assert.equal(await currentPlan(token), 'free'); -}); diff --git a/tests/unit/stripe-webhook-boundary.test.mjs b/tests/unit/stripe-webhook-boundary.test.mjs deleted file mode 100644 index 20ec2df3..00000000 --- a/tests/unit/stripe-webhook-boundary.test.mjs +++ /dev/null @@ -1,193 +0,0 @@ -import assert from 'node:assert/strict'; -import { createHmac } from 'node:crypto'; -import test from 'node:test'; - -const SECRET = 'whsec_scopeweave_webhook_test_secret'; -const NOW_SECONDS = 1_800_000_000; - -const { StripeWebhookError, verifyStripeWebhookRequest } = await import( - '../../server/stripe_webhook.mjs' -); - -function signatureHeader(bodyBytes, timestamp = NOW_SECONDS, secret = SECRET, extra = '') { - const digest = createHmac('sha256', secret) - .update(String(timestamp)) - .update('.') - .update(bodyBytes) - .digest('hex'); - return `t=${timestamp},v1=${digest}${extra}`; -} - -function webhookRequest(bodyBytes, { - signature = signatureHeader(bodyBytes), - contentLength, -} = {}) { - const headers = new Headers({ - 'content-type': 'application/json', - 'stripe-signature': signature, - }); - if (contentLength !== undefined) headers.set('content-length', String(contentLength)); - return new Request('https://scopeweave.example/api/stripe/webhook', { - method: 'POST', - headers, - body: bodyBytes, - duplex: 'half', - }); -} - -function encoded(value) { - return new TextEncoder().encode(value); -} - -async function expectWebhookError(operation, code, status) { - await assert.rejects(operation, (error) => { - assert.ok(error instanceof StripeWebhookError); - assert.equal(error.code, code); - assert.equal(error.status, status); - return true; - }); -} - -test('verified webhook preserves the exact signed raw body and returns bounded event identity', async () => { - const bytes = encoded('{\n "id":"evt_scopeweave_1",\n "type":"checkout.session.completed",\n "data":{"object":{"client_reference_id":"7"}}\n}\n'); - const event = await verifyStripeWebhookRequest(webhookRequest(bytes), { - secret: SECRET, - nowSeconds: NOW_SECONDS, - }); - - assert.equal(event.id, 'evt_scopeweave_1'); - assert.equal(event.type, 'checkout.session.completed'); - assert.equal(event.data.object.client_reference_id, '7'); -}); - -test('signature verification fails when JSON-equivalent bytes differ from the signed body', async () => { - const signedBytes = encoded('{"id":"evt_raw","type":"checkout.session.completed"}'); - const mutatedBytes = encoded('{ "id": "evt_raw", "type": "checkout.session.completed" }'); - - await expectWebhookError( - () => verifyStripeWebhookRequest(webhookRequest(mutatedBytes, { - signature: signatureHeader(signedBytes), - }), { - secret: SECRET, - nowSeconds: NOW_SECONDS, - }), - 'stripe_webhook_signature_invalid', - 400, - ); -}); - -test('signature parser accepts one matching v1 value and rejects malformed, missing, stale, or future signatures', async () => { - const bytes = encoded('{"id":"evt_sig","type":"invoice.paid"}'); - const valid = signatureHeader(bytes); - const validDigest = valid.split('v1=')[1]; - - const multiple = webhookRequest(bytes, { - signature: `t=${NOW_SECONDS},v1=${'0'.repeat(64)},v1=${validDigest}`, - }); - assert.equal((await verifyStripeWebhookRequest(multiple, { - secret: SECRET, - nowSeconds: NOW_SECONDS, - })).id, 'evt_sig'); - - for (const signature of [ - '', - `t=${NOW_SECONDS}`, - `v1=${validDigest}`, - `t=not-a-number,v1=${validDigest}`, - `t=${NOW_SECONDS},v1=xyz`, - signatureHeader(bytes, NOW_SECONDS - 301), - signatureHeader(bytes, NOW_SECONDS + 301), - ]) { - const request = webhookRequest(bytes, { signature }); - await expectWebhookError( - () => verifyStripeWebhookRequest(request, { - secret: SECRET, - nowSeconds: NOW_SECONDS, - }), - 'stripe_webhook_signature_invalid', - 400, - ); - } -}); - -test('body byte ceiling rejects declared and streamed oversize requests before JSON parsing', async () => { - const small = encoded('{"id":"evt_size","type":"invoice.paid"}'); - await expectWebhookError( - () => verifyStripeWebhookRequest(webhookRequest(small, { - contentLength: 262_145, - }), { - secret: SECRET, - nowSeconds: NOW_SECONDS, - }), - 'stripe_webhook_body_too_large', - 413, - ); - - const large = encoded(JSON.stringify({ - id: 'evt_stream_size', - type: 'invoice.paid', - data: 'x'.repeat(262_144), - })); - await expectWebhookError( - () => verifyStripeWebhookRequest(webhookRequest(large), { - secret: SECRET, - nowSeconds: NOW_SECONDS, - }), - 'stripe_webhook_body_too_large', - 413, - ); -}); - -test('invalid content length, payload JSON, event identity, and verifier configuration fail closed', async () => { - const validBytes = encoded('{"id":"evt_valid","type":"invoice.paid"}'); - - for (const contentLength of ['-1', 'NaN', '1.5', '999999999999999999999999']) { - await expectWebhookError( - () => verifyStripeWebhookRequest(webhookRequest(validBytes, { contentLength }), { - secret: SECRET, - nowSeconds: NOW_SECONDS, - }), - 'stripe_webhook_request_invalid', - 400, - ); - } - - const malformed = encoded('{"id":'); - await expectWebhookError( - () => verifyStripeWebhookRequest(webhookRequest(malformed), { - secret: SECRET, - nowSeconds: NOW_SECONDS, - }), - 'stripe_webhook_payload_invalid', - 400, - ); - - for (const value of [ - null, - [], - {}, - { id: '', type: 'invoice.paid' }, - { id: 'evt_ok', type: '' }, - { id: 'x'.repeat(256), type: 'invoice.paid' }, - { id: 'evt_ok', type: 'x'.repeat(256) }, - ]) { - const bytes = encoded(JSON.stringify(value)); - await expectWebhookError( - () => verifyStripeWebhookRequest(webhookRequest(bytes), { - secret: SECRET, - nowSeconds: NOW_SECONDS, - }), - 'stripe_webhook_payload_invalid', - 400, - ); - } - - await expectWebhookError( - () => verifyStripeWebhookRequest(webhookRequest(validBytes), { - secret: ' ', - nowSeconds: NOW_SECONDS, - }), - 'stripe_webhook_not_configured', - 503, - ); -}); From bdfb48d34eda73d839f5d46a81a3b6259b840a34 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 13:55:01 -0700 Subject: [PATCH 70/71] test(webhooks): reproduce no-op migration write-lock contention --- tests/unit/webhook-legacy-migration.test.mjs | 29 ++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/tests/unit/webhook-legacy-migration.test.mjs b/tests/unit/webhook-legacy-migration.test.mjs index a6880bcb..f4b19277 100644 --- a/tests/unit/webhook-legacy-migration.test.mjs +++ b/tests/unit/webhook-legacy-migration.test.mjs @@ -1,9 +1,12 @@ import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { migrateLegacyWebhookDestinations } from '../../server/webhook_legacy_migration.mjs'; -function createDatabase() { - const database = new DatabaseSync(':memory:'); +function createDatabase(path = ':memory:') { + const database = new DatabaseSync(path); database.exec(` CREATE TABLE webhooks ( id INTEGER PRIMARY KEY, @@ -139,4 +142,26 @@ assert.doesNotThrow( ); rollback.close(); +const contentionDirectory = mkdtempSync(join(tmpdir(), 'scopeweave-webhook-migration-')); +const contentionPath = join(contentionDirectory, 'scopeweave.sqlite'); +const contended = createDatabase(contentionPath); +const writer = new DatabaseSync(contentionPath); +try { + contended.exec(` + PRAGMA busy_timeout = 0; + INSERT INTO webhooks(id,org_id,url,active) + VALUES(30,10,'https://public.example.test/hook',1); + `); + writer.exec('PRAGMA busy_timeout = 0; BEGIN IMMEDIATE;'); + assert.doesNotThrow( + () => assert.equal(migrateLegacyWebhookDestinations(contended), 0), + 'a compliant no-op startup migration must not require the database write reservation', + ); +} finally { + writer.exec('ROLLBACK'); + writer.close(); + contended.close(); + rmSync(contentionDirectory, { recursive: true, force: true }); +} + console.log('legacy webhook migration unit tests passed'); From bc60476829ec98f37d83977a9432b79ba0b3c0d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 13:56:43 -0700 Subject: [PATCH 71/71] fix(webhooks): avoid no-op startup write reservation --- server/webhook_legacy_migration.mjs | 32 +++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/server/webhook_legacy_migration.mjs b/server/webhook_legacy_migration.mjs index 3512c18f..d9ba94a9 100644 --- a/server/webhook_legacy_migration.mjs +++ b/server/webhook_legacy_migration.mjs @@ -15,6 +15,21 @@ function isCurrentDestinationAllowed(url, allowDevelopmentLoopback) { } } +function activeWebhookDestinations(database) { + return database.prepare( + `SELECT id, org_id AS orgId, url + FROM webhooks + WHERE active = 1 + ORDER BY id`, + ).all(); +} + +function hasPolicyIncompatibleDestination(candidates, allowDevelopmentLoopback) { + return candidates.some( + (candidate) => !isCurrentDestinationAllowed(candidate.url, allowDevelopmentLoopback), + ); +} + /** * Disable active legacy webhook destinations rejected by current registration policy. * @@ -28,6 +43,11 @@ function isCurrentDestinationAllowed(url, allowDevelopmentLoopback) { * preserves only destinations that the same current development registration * policy still permits, including loopback HTTP. * + * A read-only preflight avoids reserving the SQLite writer when every active row + * already satisfies current policy. If a write is required, the migration acquires + * `BEGIN IMMEDIATE` and re-reads the active rows inside that transaction before any + * mutation, preserving the existing atomic fail-closed migration boundary. + * * DNS-backed hostnames remain subject to per-attempt address authorization at * delivery time; this startup migration deliberately does not perform network I/O. * @@ -39,14 +59,14 @@ export function migrateLegacyWebhookDestinations( database, { allowDevelopmentLoopback = false } = {}, ) { + const preflightCandidates = activeWebhookDestinations(database); + if (!hasPolicyIncompatibleDestination(preflightCandidates, allowDevelopmentLoopback)) { + return 0; + } + database.exec('BEGIN IMMEDIATE'); try { - const candidates = database.prepare( - `SELECT id, org_id AS orgId, url - FROM webhooks - WHERE active = 1 - ORDER BY id`, - ).all(); + const candidates = activeWebhookDestinations(database); const disable = database.prepare( 'UPDATE webhooks SET active = 0 WHERE id = ? AND org_id = ? AND active = 1', );