From 1270be520168a1334555fdb7f9a5e25a939f537b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 20:44:05 -0700 Subject: [PATCH 001/157] test(security): reject unsafe outbound webhook destinations --- tests/api/webhook-destination-policy.test.mjs | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 tests/api/webhook-destination-policy.test.mjs diff --git a/tests/api/webhook-destination-policy.test.mjs b/tests/api/webhook-destination-policy.test.mjs new file mode 100644 index 00000000..9607ca8b --- /dev/null +++ b/tests/api/webhook-destination-policy.test.mjs @@ -0,0 +1,63 @@ +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; + +const deniedDestinations = [ + 'http://example.com/hook', + 'https://127.0.0.1/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://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}`); +} + +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'); + +console.log('webhook destination registration policy tests passed'); From 006cfabda2f9e1b36221215a481b9475a07164c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 20:44:32 -0700 Subject: [PATCH 002/157] test(security): register webhook destination regression --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index dc32b12b..853810c6 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/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.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/webhook-destination-policy.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/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/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", From 9ca1417db6cf017da27f3a58716dd80073e9cd61 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 21:05:20 -0700 Subject: [PATCH 003/157] fix(security): pin outbound webhook destinations --- package.json | 6 +- server/app.mjs | 1474 ++----------------------- server/app_core.mjs | 1407 +++++++++++++++++++++++ server/webhook_transport.mjs | 237 ++++ tests/unit/webhook-transport.test.mjs | 242 ++++ 5 files changed, 1979 insertions(+), 1387 deletions(-) create mode 100644 server/app_core.mjs create mode 100644 server/webhook_transport.mjs create mode 100644 tests/unit/webhook-transport.test.mjs diff --git a/package.json b/package.json index 853810c6..21c8880c 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/webhook-destination-policy.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/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/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/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/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/server/app.mjs b/server/app.mjs index 03908830..7051717e 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1,1407 +1,113 @@ -// 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(); - } +// ScopeWeave API security facade. +// +// The historical Hono route graph remains in app_core.mjs so this bounded +// security repair can interpose one explicit outbound-webhook policy boundary +// without rewriting unrelated tenant, auth, billing, or Clearfolio behavior. +import { app as coreApp } from './app_core.mjs'; +import { + WebhookDestinationError, + postWebhook, + validateWebhookRegistrationUrl, +} from './webhook_transport.mjs'; + +const nativeFetch = globalThis.fetch.bind(globalThis); +const webhookFetchBoundaryKey = Symbol.for('scopeweave.webhook-fetch-boundary'); +const WEBHOOK_REGISTRATION_PATH = /^\/api\/orgs\/[^/]+\/webhooks$/; + +function normalizedHeaders(headers) { 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); + return new Headers(headers || {}); } 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 */ } + return new Headers(); } } -// 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); +function isSignedWebhookRequest(init) { + if (String(init?.method || '').toUpperCase() !== 'POST') return false; + const headers = normalizedHeaders(init?.headers); + return Boolean( + headers.get('x-scopeweave-event') + && /^sha256=[0-9a-f]{64}$/i.test(headers.get('x-scopeweave-signature') || ''), + ); } -// --- 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(); +// Install exactly once per process. Only the server's own signed webhook POSTs +// are routed through the SSRF-safe transport; OIDC, Clearfolio, billing, and +// all other fetch users retain the native implementation. +if (!globalThis[webhookFetchBoundaryKey]) { + globalThis.fetch = (input, init = {}) => { + if (!isSignedWebhookRequest(init)) return nativeFetch(input, init); + const headers = normalizedHeaders(init.headers); + return postWebhook(input instanceof Request ? input.url : input, { + headers: Object.fromEntries(headers.entries()), + body: init.body ?? '', + signal: init.signal, + }); + }; + Object.defineProperty(globalThis, webhookFetchBoundaryKey, { + value: true, + configurable: false, + enumerable: false, + writable: false, }); } -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. +function isDevelopmentLoopbackHttp(value) { + if (process.env.SCOPEWEAVE_DEV !== '1') return false; 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' - ); + const url = new URL(String(value || '')); + const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase(); + return url.protocol === 'http:' + && !url.username + && !url.password + && !url.hash + && (host === 'localhost' || host === '127.0.0.1' || host === '::1'); + } catch { + return false; } - 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 }); +async function registrationPolicyResponse(request) { + const url = new URL(request.url); + if (request.method !== 'POST' || !WEBHOOK_REGISTRATION_PATH.test(url.pathname)) { + return null; } - 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'); + const payload = await request.clone().json().catch(() => ({})); 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(',')); + validateWebhookRegistrationUrl(payload.url); + return null; + } catch (error) { + // Preserve the existing dev-only localhost failure-path smoke fixture. The + // outbound transport still refuses HTTP, so this exception cannot create a + // server-side connection and production never inherits it. + if (error instanceof WebhookDestinationError && isDevelopmentLoopbackHttp(payload.url)) { + return null; } - 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 Response.json({ error: 'valid public https webhook URL required' }, { status: 400 }); } - 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 }, - ]); - 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)}"`, - }); - }); +async function secureFetch(request, ...rest) { + const denied = await registrationPolicyResponse(request); + if (denied) return denied; + return coreApp.fetch(request, ...rest); } -// 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 })); +async function secureRequest(input, init) { + const request = input instanceof Request + ? input + : new Request(new URL(String(input), 'http://localhost'), init); + return secureFetch(request); +} -// 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(); - } +// Proxying preserves Hono route/introspection properties for existing callers +// while forcing both server fetches and in-process app.request tests through the +// registration policy above. +export const app = new Proxy(coreApp, { + get(target, property) { + if (property === 'fetch') return secureFetch; + if (property === 'request') return secureRequest; + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, }); diff --git a/server/app_core.mjs b/server/app_core.mjs new file mode 100644 index 00000000..03908830 --- /dev/null +++ b/server/app_core.mjs @@ -0,0 +1,1407 @@ +// 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 }, + ]); + 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..dcdd2409 --- /dev/null +++ b/server/webhook_transport.mjs @@ -0,0 +1,237 @@ +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(); +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.88.99.0', 24, 'ipv4'], + ['192.168.0.0', 16, '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'], + ['2001:2::', 48, 'ipv6'], + ['2001:10::', 28, 'ipv6'], + ['2001:20::', 28, 'ipv6'], + ['2001:db8::', 32, 'ipv6'], + ['2002::', 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'; + +/** A stable, non-secret webhook destination policy failure. */ +export class WebhookDestinationError extends Error { + constructor() { + super(POLICY_ERROR); + this.name = 'WebhookDestinationError'; + } +} + +/** A 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 IP address is safe for an Internet-facing webhook target. + * Unknown strings and special/private address space fail closed. + */ +export function isPublicWebhookAddress(address) { + const family = isIP(address); + if (!family) return false; + return !(family === 4 + ? DENIED_IPV4_BLOCKS.check(address, 'ipv4') + : DENIED_IPV6_BLOCKS.check(address, 'ipv6')); +} + +/** + * Parse and canonicalize a webhook registration URL without performing DNS. + * DNS authorization happens again immediately before every 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 family = Number(answer?.family) || isIP(address); + if ((family !== 4 && family !== 6) || !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); + }; +} + +/** + * Build the outbound webhook transport around injectable DNS and HTTPS seams. + * Every post resolves afresh, rejects mixed/private answers, pins the socket to + * the validated address, preserves the original hostname for Host/TLS, and + * never follows redirects because Node's native HTTPS client does not do so. + */ +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 { address, family } = candidates[0]; + const tlsHost = hostAddress(destination.hostname); + + try { + return await withAbort(new Promise((resolve, reject) => { + let req; + try { + req = request(destination, { + method: 'POST', + headers, + signal, + agent: false, + lookup: pinnedLookup(address, family), + ...(isIP(tlsHost) ? {} : { servername: tlsHost }), + }, (response) => { + response.resume?.(); + const status = Number(response.statusCode) || 0; + resolve({ status, ok: status >= 200 && status < 300 }); + }); + } catch { + reject(new WebhookTransportError()); + return; + } + req.once?.('error', () => reject(new WebhookTransportError())); + req.end(body); + }), signal); + } catch (error) { + if (error instanceof WebhookDestinationError || error instanceof WebhookTransportError) throw error; + throw 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/unit/webhook-transport.test.mjs b/tests/unit/webhook-transport.test.mjs new file mode 100644 index 00000000..187087bf --- /dev/null +++ b/tests/unit/webhook-transport.test.mjs @@ -0,0 +1,242 @@ +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.168.1.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', '2001:2::1', '2001:db8::1', '2002::1', '3fff::1', '5f00::1', + 'fc00::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://[::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 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 835a22093df0cdf5342352cc565a89d1e0fe2d08 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 21:07:49 -0700 Subject: [PATCH 004/157] test(security): cover special-use webhook destinations --- server/webhook_transport.mjs | 5 ++--- tests/api/webhook-destination-policy.test.mjs | 10 ++++++++++ tests/unit/webhook-transport.test.mjs | 5 +++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/server/webhook_transport.mjs b/server/webhook_transport.mjs index dcdd2409..099bf05c 100644 --- a/server/webhook_transport.mjs +++ b/server/webhook_transport.mjs @@ -26,9 +26,8 @@ for (const [address, prefix, family] of [ ['64:ff9b::', 96, 'ipv6'], ['64:ff9b:1::', 48, 'ipv6'], ['100::', 64, 'ipv6'], - ['2001:2::', 48, 'ipv6'], - ['2001:10::', 28, 'ipv6'], - ['2001:20::', 28, 'ipv6'], + ['100:0:0:1::', 64, 'ipv6'], + ['2001::', 23, 'ipv6'], ['2001:db8::', 32, 'ipv6'], ['2002::', 16, 'ipv6'], ['3fff::', 20, 'ipv6'], diff --git a/tests/api/webhook-destination-policy.test.mjs b/tests/api/webhook-destination-policy.test.mjs index 9607ca8b..f0467de4 100644 --- a/tests/api/webhook-destination-policy.test.mjs +++ b/tests/api/webhook-destination-policy.test.mjs @@ -30,12 +30,17 @@ const organizationId = me.orgs[0].id; 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', ]; @@ -47,6 +52,11 @@ for (const url of deniedDestinations) { 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`, { diff --git a/tests/unit/webhook-transport.test.mjs b/tests/unit/webhook-transport.test.mjs index 187087bf..f40afb4b 100644 --- a/tests/unit/webhook-transport.test.mjs +++ b/tests/unit/webhook-transport.test.mjs @@ -15,8 +15,8 @@ for (const address of [ '169.254.169.254', '172.16.0.1', '192.0.2.10', '192.168.1.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', '2001:2::1', '2001:db8::1', '2002::1', '3fff::1', '5f00::1', - 'fc00::1', 'fe80::1', 'ff00::1', + '100::1', '100:0:0:1::1', '2001::1', '2001:2::1', '2001:db8::1', + '2002::1', '3fff::1', '5f00::1', 'fc00::1', 'fe80::1', 'ff00::1', ]) { assert.equal(isPublicWebhookAddress(address), false, `${address} is denied`); } @@ -35,6 +35,7 @@ for (const url of [ '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( From a40bc5ed2fa092ca1113d9103f9b95d1c16392db Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 21:09:33 -0700 Subject: [PATCH 005/157] docs(security): record webhook SSRF transport evidence --- docs/doctoring/outbound-webhook-ssrf.md | 153 ++++++++++++++++++++++++ 1 file changed, 153 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..8b56017e --- /dev/null +++ b/docs/doctoring/outbound-webhook-ssrf.md @@ -0,0 +1,153 @@ +# Outbound webhook SSRF boundary: evidence and design record + +## Status boundary + +As of this active repair, protected `develop@1fadec04195805722829b386475a09a15f8cd926` +still contains the historical webhook implementation that accepts arbitrary +`http(s)` destinations and later supplies the persisted URL to server-side +`fetch()`. That is protected-shipped truth until this pull request is integrated. + +The active `fix/webhook-ssrf-551` pull request introduces the candidate repair +described below. Nothing in this record represents a certification claim or a +statement that protected `develop` already contains the repair. + +## Buyer-visible risk + +A custom webhook is intentionally an outbound server-side request whose target +is supplied by a tenant administrator. Without a destination authority boundary, +that feature can become an SSRF primitive against the ScopeWeave runtime, +neighboring private services, link-local cloud metadata endpoints, or other +addresses that are not Internet-facing webhook authorities. Redirect following +and DNS rebinding can invalidate an otherwise correct one-time URL or DNS check. + +The commercial requirement is therefore stronger than syntactic URL validation: +ScopeWeave must prove that the address used by the actual socket is an allowed +public destination immediately before every attempt. + +## Candidate design + +The active repair applies the following fail-closed contract: + +1. webhook registration is parsed with the WHATWG `URL` implementation and + production accepts HTTPS only; URL credentials, fragments, localhost-like + names, and literal denied addresses are rejected; +2. every delivery attempt performs a fresh A/AAAA lookup and accepts the attempt + only when **every** returned address passes the same public-address policy; +3. private, loopback, link-local, shared, unspecified, mapped, multicast, + documentation, benchmark, reserved, ULA, and other non-public/special-use + ranges are rejected conservatively using the current IANA special-purpose + registries plus multicast boundaries; +4. the HTTPS socket receives a custom `lookup` callback containing the + just-validated address, while the original DNS hostname remains the HTTP/TLS + authority and SNI name. `agent: false` prevents an older pooled connection + from bypassing the fresh per-attempt authorization; +5. Node's native `https.request()` is used directly; redirects are never + followed, so a 3xx response is a failed delivery rather than authority + transplantation of the signed body and HMAC header; +6. the existing three-second abort budget and bounded retry behavior remain in + the application core. Each retry re-enters the transport and therefore + resolves, validates, and pins again; +7. policy, resolver, TLS, and transport failures expose stable non-secret error + classes rather than internal addresses or resolver/socket details; and +8. the legacy development-only loopback HTTP registration fixture remains + isolated behind `SCOPEWEAVE_DEV=1` solely so the existing failure/retry smoke + path remains deterministic. The outbound transport itself still rejects HTTP, + so that fixture cannot make a loopback connection and production never + inherits it. + +The existing Hono route graph is temporarily retained in `server/app_core.mjs`. +`server/app.mjs` is the sole exported server facade and interposes the registration +policy plus the signed-webhook egress transport while delegating unrelated OIDC, +Clearfolio, billing, tenant, and authentication fetches to native `fetch`. This +keeps the security slice bounded and reviewable rather than rewriting unrelated +application behavior inside the same repair. + +## Verification contract + +Deterministic regression evidence must cover at least: + +- production registration rejection for plaintext HTTP, localhost and + `.localhost`, IPv4 loopback in dotted/decimal/hex forms, RFC 1918, IPv4 + link-local/metadata-style destinations, IPv6 loopback, ULA, IPv4-mapped IPv6, + URL credentials, and fragments; +- direct public IPv4 and IPv6 literals plus a public-hostname-shaped success seam + without depending on the Internet; +- empty/malformed/private DNS responses and mixed public+private answer sets, + with zero connector calls after a denied resolution; +- a DNS-rebinding sequence where a first public answer can be used but a later + private answer is rejected before the retry socket is created; +- the custom connector lookup returning only the address validated for that + attempt while preserving the original hostname as TLS `servername`; +- redirects treated as failures without a second request; +- stable error text for resolver, synchronous request, asynchronous socket, and + pre-aborted-signal failures; and +- canonical `test:unit`, `test:api`, and c8 registration for every new production + module, while continuing to measure the moved application core rather than + creating a false coverage improvement through a filename split. + +A green successor head does not erase the deliberately preserved RED predecessor: +`006cfabda2f9e1b36221215a481b9475a07164c4` registered the real production API +regression and the hosted `unit-and-api` lane failed because protected behavior +returned HTTP 200 for the first denied plaintext-HTTP destination. Exact-current- +head gates must be regenerated after every production or evidence change. + +## Standards and primary-source rationale + +OWASP identifies custom webhooks as an SSRF use case, recommends validating both +A and AAAA answers when arbitrary external targets are allowed, calls out DNS +pinning/rebinding, and recommends disabling redirects. ScopeWeave therefore does +not rely on registration-time DNS or a second independent resolver decision at +connection time. + +IANA's IPv4 and IPv6 Special-Purpose Address Registries are the authoritative +machine-readable inventory for address blocks whose routing or protocol semantics +are exceptional. The registries were last updated October 9, 2025 when this +record was prepared. ScopeWeave uses a conservative deny policy for ranges not +suitable as ordinary public webhook authorities; this includes the IPv6 dummy +prefix `100:0:0:1::/64` added to the registry in 2025. + +RFC 6890 establishes the special-purpose address registries and their +`Globally Reachable` semantics. RFC 4291 defines IPv6 unspecified, loopback, +IPv4-mapped, link-local, and multicast semantics. RFC 1918, RFC 3927, and RFC +4193 define private IPv4, IPv4 link-local, and IPv6 unique-local space, +respectively. + +Node.js 22 documents that `https.request()` accepts HTTP request options plus TLS +options such as `servername`; its underlying connection options support a custom +DNS `lookup` function. The active repair uses that supported seam so address +validation and socket selection are one authorization decision while TLS still +authenticates the original webhook hostname. + +## References + +Cotton, M., Vegoda, L., Bonica, R., & Haberman, B. (2013). *Special-purpose IP +address registries* (BCP 153; RFC 6890). Internet Engineering Task Force. +https://doi.org/10.17487/RFC6890 + +Cheshire, S., Aboba, B., & Guttman, E. (2005). *Dynamic configuration of IPv4 +link-local addresses* (RFC 3927). Internet Engineering Task Force. +https://doi.org/10.17487/RFC3927 + +Hinden, R., & Deering, S. (2006). *IP version 6 addressing architecture* (RFC +4291). Internet Engineering Task Force. https://doi.org/10.17487/RFC4291 + +Hinden, R., & Haberman, B. (2005). *Unique local IPv6 unicast addresses* (RFC +4193). Internet Engineering Task Force. https://doi.org/10.17487/RFC4193 + +Internet Assigned Numbers Authority. (2025, October 9). *IPv4 special-purpose +address space*. https://www.iana.org/assignments/iana-ipv4-special-registry/ + +Internet Assigned Numbers Authority. (2025, October 9). *IPv6 special-purpose +address space*. https://www.iana.org/assignments/iana-ipv6-special-registry/ + +Open Worldwide Application Security Project Foundation. (n.d.). *Server side +request forgery prevention cheat sheet*. OWASP Cheat Sheet Series. Retrieved +August 18, 2026, from +https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html + +OpenJS Foundation. (2026). *HTTPS*. Node.js v22 documentation. +https://nodejs.org/docs/latest-v22.x/api/https.html + +Rekhter, Y., Moskowitz, B., Karrenberg, D., de Groot, G. J., & Lear, E. (1996). +*Address allocation for private Internets* (BCP 5; RFC 1918). Internet +Engineering Task Force. https://doi.org/10.17487/RFC1918 From 6c536735c40fb7a97413e3f32190b7964a0e95b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 21:13:23 -0700 Subject: [PATCH 006/157] test(security): preserve facade integration evidence --- tests/unit/coverage-script-contract.test.mjs | 20 ++++++++++++++++++++ tests/unit/toast-accessibility.test.mjs | 15 +++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 149440e5..325df689 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -34,11 +34,31 @@ assert.match( /--include=server\/clearfolio\.mjs/, 'the abortable Clearfolio adapter is instrumented', ); +assert.match( + scripts['test:coverage'], + /--include=server\/app_core\.mjs/, + 'the moved SaaS route graph remains instrumented after the security facade split', +); +assert.match( + scripts['test:coverage'], + /--include=server\/webhook_transport\.mjs/, + 'the outbound webhook SSRF transport is instrumented', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/, 'the Clearfolio signal and HTTP failure regression executes under c8', ); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/webhook-transport\.test\.mjs/, + 'the webhook DNS/pinning/redirect regression executes under c8', +); +assert.match( + scripts['test:api'], + /tests\/api\/webhook-destination-policy\.test\.mjs/, + 'the production webhook registration regression executes in the canonical API suite', +); assert.doesNotMatch( scripts['test:coverage:cases'], /npm run (?:coverage|test:coverage)(?:\s|$)/, diff --git a/tests/unit/toast-accessibility.test.mjs b/tests/unit/toast-accessibility.test.mjs index c0aa79a0..dd2aa28e 100644 --- a/tests/unit/toast-accessibility.test.mjs +++ b/tests/unit/toast-accessibility.test.mjs @@ -35,11 +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'); + 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 security facade delegates to the route graph that owns static assets', + ); + assert.match( + serverFacade, + /return\s+coreApp\.fetch\(request,\s*\.\.\.rest\)/, + 'SaaS security facade preserves the core static-asset request path', + ); + assert.match(serverCore, /['"]\/toast-state\.css['"]/, 'SaaS 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 9728722a0c3eb776faf0dd11852bcb694c5e0594 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 21:14:36 -0700 Subject: [PATCH 007/157] docs(changelog): record outbound webhook SSRF hardening --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53e6d29f..8d98caf4 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 +- Hardened tenant-configured outbound webhooks against SSRF and DNS rebinding by + requiring production HTTPS destinations, rejecting credentials, fragments, + local/private/link-local/special-use address space, revalidating every A/AAAA + answer before each delivery attempt, pinning the actual HTTPS socket to the + validated address while preserving hostname/TLS authority, refusing redirects, + and exposing stable non-secret resolver and transport failures. - 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 aa0a18be0b4c915619504161c53624eafca7a137 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 21:44:40 -0700 Subject: [PATCH 008/157] test(security): cover reserved webhook address space --- tests/unit/webhook-transport.test.mjs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/unit/webhook-transport.test.mjs b/tests/unit/webhook-transport.test.mjs index f40afb4b..f0805c52 100644 --- a/tests/unit/webhook-transport.test.mjs +++ b/tests/unit/webhook-transport.test.mjs @@ -12,11 +12,13 @@ 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.168.1.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', '3fff::1', '5f00::1', 'fc00::1', 'fe80::1', 'ff00::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`); } From d4f40742895a67f037f77fd6c11ef2ea465a26ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 21:45:25 -0700 Subject: [PATCH 009/157] fix(security): restrict webhooks to public unicast space --- server/webhook_transport.mjs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/server/webhook_transport.mjs b/server/webhook_transport.mjs index 099bf05c..e3663e59 100644 --- a/server/webhook_transport.mjs +++ b/server/webhook_transport.mjs @@ -4,6 +4,8 @@ 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'], @@ -13,8 +15,11 @@ for (const [address, prefix, family] of [ ['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'], @@ -30,6 +35,8 @@ for (const [address, prefix, family] of [ ['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'], @@ -76,14 +83,16 @@ function isLocalHostname(hostname) { /** * Return whether an IP address is safe for an Internet-facing webhook target. - * Unknown strings and special/private address space fail closed. + * IPv4 special-purpose space is denied. IPv6 must be in IANA's ordinary + * 2000::/3 global-unicast envelope and outside every denied special-use block. + * Unknown strings and exceptional/reserved address space fail closed. */ export function isPublicWebhookAddress(address) { const family = isIP(address); if (!family) return false; - return !(family === 4 - ? DENIED_IPV4_BLOCKS.check(address, 'ipv4') - : DENIED_IPV6_BLOCKS.check(address, 'ipv6')); + if (family === 4) return !DENIED_IPV4_BLOCKS.check(address, 'ipv4'); + return PUBLIC_IPV6_UNICAST.check(address, 'ipv6') + && !DENIED_IPV6_BLOCKS.check(address, 'ipv6'); } /** From 81093fbd56c9d4af7c840a0a12b43f9d4f88c58e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 21:47:05 -0700 Subject: [PATCH 010/157] test(security): preserve webhook authorization ordering --- tests/api/webhook-destination-policy.test.mjs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/api/webhook-destination-policy.test.mjs b/tests/api/webhook-destination-policy.test.mjs index f0467de4..c6d8ea68 100644 --- a/tests/api/webhook-destination-policy.test.mjs +++ b/tests/api/webhook-destination-policy.test.mjs @@ -28,6 +28,16 @@ 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', From f1a71591151fb10974a8cfad8e1dc245d48e9cd9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 21:48:04 -0700 Subject: [PATCH 011/157] fix(security): preserve webhook authorization before policy --- server/app.mjs | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 7051717e..b7fe4401 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -67,7 +67,26 @@ function isDevelopmentLoopbackHttp(value) { } } -async function registrationPolicyResponse(request) { +/** + * Ask the existing route graph to run its real authentication, tenant-role, + * rate-limit, and request middleware before this facade returns a policy error. + * The deliberately empty URL reaches the old route's own URL validation but can + * never be persisted or delivered, so denied destinations do not bypass or + * reorder the authoritative authorization boundary. + */ +async function deniedRegistrationAuthorization(request, rest) { + const headers = new Headers(request.headers); + headers.delete('content-length'); + const probe = new Request(request.url, { + method: 'POST', + headers, + body: JSON.stringify({ url: '' }), + }); + const response = await coreApp.fetch(probe, ...rest); + return response.status === 400 ? null : response; +} + +async function registrationPolicyResponse(request, rest) { const url = new URL(request.url); if (request.method !== 'POST' || !WEBHOOK_REGISTRATION_PATH.test(url.pathname)) { return null; @@ -83,12 +102,14 @@ async function registrationPolicyResponse(request) { if (error instanceof WebhookDestinationError && isDevelopmentLoopbackHttp(payload.url)) { return null; } + const authorization = await deniedRegistrationAuthorization(request, rest); + if (authorization) return authorization; return Response.json({ error: 'valid public https webhook URL required' }, { status: 400 }); } } async function secureFetch(request, ...rest) { - const denied = await registrationPolicyResponse(request); + const denied = await registrationPolicyResponse(request, rest); if (denied) return denied; return coreApp.fetch(request, ...rest); } From e7e18c607f85d55ee64bfc93a0bf094f502ee367 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 22:14:56 -0700 Subject: [PATCH 012/157] test(security): require canonical webhook persistence --- tests/api/webhook-destination-policy.test.mjs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/api/webhook-destination-policy.test.mjs b/tests/api/webhook-destination-policy.test.mjs index c6d8ea68..a959dddd 100644 --- a/tests/api/webhook-destination-policy.test.mjs +++ b/tests/api/webhook-destination-policy.test.mjs @@ -80,4 +80,31 @@ 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 be1fff9ddb77738fe2b4c253505f25150f2a8762 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 22:15:49 -0700 Subject: [PATCH 013/157] fix(security): persist canonical webhook destinations --- server/app.mjs | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index b7fe4401..6ff95f20 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -86,32 +86,49 @@ async function deniedRegistrationAuthorization(request, rest) { return response.status === 400 ? null : response; } -async function registrationPolicyResponse(request, rest) { +function canonicalRegistrationRequest(request, payload, canonicalUrl) { + const headers = new Headers(request.headers); + headers.delete('content-length'); + return new Request(request.url, { + method: request.method, + headers, + body: JSON.stringify({ ...payload, url: canonicalUrl }), + }); +} + +async function registrationPolicyResult(request, rest) { const url = new URL(request.url); if (request.method !== 'POST' || !WEBHOOK_REGISTRATION_PATH.test(url.pathname)) { return null; } const payload = await request.clone().json().catch(() => ({})); try { - validateWebhookRegistrationUrl(payload.url); - return null; + const canonicalUrl = validateWebhookRegistrationUrl(payload.url); + return canonicalUrl === payload.url + ? { request } + : { request: canonicalRegistrationRequest(request, payload, canonicalUrl) }; } catch (error) { // Preserve the existing dev-only localhost failure-path smoke fixture. The // outbound transport still refuses HTTP, so this exception cannot create a // server-side connection and production never inherits it. if (error instanceof WebhookDestinationError && isDevelopmentLoopbackHttp(payload.url)) { - return null; + return { request }; } const authorization = await deniedRegistrationAuthorization(request, rest); - if (authorization) return authorization; - return Response.json({ error: 'valid public https webhook URL required' }, { status: 400 }); + if (authorization) return { response: authorization }; + return { + response: Response.json( + { error: 'valid public https webhook URL required' }, + { status: 400 }, + ), + }; } } async function secureFetch(request, ...rest) { - const denied = await registrationPolicyResponse(request, rest); - if (denied) return denied; - return coreApp.fetch(request, ...rest); + const policy = await registrationPolicyResult(request, rest); + if (policy?.response) return policy.response; + return coreApp.fetch(policy?.request || request, ...rest); } async function secureRequest(input, init) { From ee3fe3922a34fc675dde51f7c1bfd43aa0f8e89d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 22:18:35 -0700 Subject: [PATCH 014/157] test(a11y): verify facade static asset behavior --- tests/unit/toast-accessibility.test.mjs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/unit/toast-accessibility.test.mjs b/tests/unit/toast-accessibility.test.mjs index dd2aa28e..5d33c333 100644 --- a/tests/unit/toast-accessibility.test.mjs +++ b/tests/unit/toast-accessibility.test.mjs @@ -2,6 +2,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; +process.env.SCOPEWEAVE_DB = ':memory:'; + const indexHtml = readFileSync(new URL('../../index.html', import.meta.url), 'utf8'); const toastStateCss = readFileSync(new URL('../../toast-state.css', import.meta.url), 'utf8'); const cloudSyncJs = readFileSync(new URL('../../cloud-sync.js', import.meta.url), 'utf8'); @@ -34,7 +36,7 @@ test('sync status uses the same explicit advisory status semantics', () => { assert.doesNotMatch(syncStatus, /\btabindex\s*=/i, 'sync feedback does not become a synthetic keyboard stop'); }); -test('cloud toast stylesheet is on every production serve path', () => { +test('cloud toast stylesheet is on every production serve path', async () => { 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'); @@ -45,10 +47,18 @@ test('cloud toast stylesheet is on every production serve path', () => { /import\s+\{\s*app\s+as\s+coreApp\s*\}\s+from\s+['"]\.\/app_core\.mjs['"]/, 'SaaS security facade delegates to the route graph that owns static assets', ); + const { app } = await import('../../server/app.mjs'); + const stylesheetResponse = await app.request('/toast-state.css'); + assert.equal(stylesheetResponse.status, 200, 'SaaS security facade serves the toast stylesheet'); assert.match( - serverFacade, - /return\s+coreApp\.fetch\(request,\s*\.\.\.rest\)/, - 'SaaS security facade preserves the core static-asset request path', + stylesheetResponse.headers.get('content-type') || '', + /^text\/css\b/i, + 'SaaS security facade preserves the stylesheet media type', + ); + assert.equal( + await stylesheetResponse.text(), + toastStateCss, + 'SaaS security facade preserves the exact core static-asset response', ); assert.match(serverCore, /['"]\/toast-state\.css['"]/, 'SaaS allowlist serves the cloud toast stylesheet'); assert.match(pagesWorkflow, /\btoast-state\.css\b/, 'GitHub Pages stages the cloud toast stylesheet'); From 287a07b3e4d82cc6fd93eb6c7de9a2905d74bce9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 22:21:09 -0700 Subject: [PATCH 015/157] test(a11y): configure facade fixture authentication --- tests/unit/toast-accessibility.test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/toast-accessibility.test.mjs b/tests/unit/toast-accessibility.test.mjs index 5d33c333..4daa0fbd 100644 --- a/tests/unit/toast-accessibility.test.mjs +++ b/tests/unit/toast-accessibility.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; const indexHtml = readFileSync(new URL('../../index.html', import.meta.url), 'utf8'); const toastStateCss = readFileSync(new URL('../../toast-state.css', import.meta.url), 'utf8'); From 2ceaa26650553bd01c8715d13c8220c276346618 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:15:56 -0700 Subject: [PATCH 016/157] test(security): specify current review regressions --- tests/api/review-regressions.test.mjs | 99 +++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 tests/api/review-regressions.test.mjs diff --git a/tests/api/review-regressions.test.mjs b/tests/api/review-regressions.test.mjs new file mode 100644 index 00000000..3d38ea76 --- /dev/null +++ b/tests/api/review-regressions.test.mjs @@ -0,0 +1,99 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + +const { app } = await import('../../server/app.mjs'); +const { app: coreApp } = await import('../../server/app_core.mjs'); +const { db } = await import('../../server/db.mjs'); + +const body = (value) => JSON.stringify(value); +const request = (target, options = {}) => app.request(target, { + ...options, + headers: { + 'content-type': 'application/json', + ...(options.headers || {}), + }, +}); + +async function createOwner(email) { + const response = await request('/api/auth/signup', { + method: 'POST', + body: body({ email, password: 'password123', name: 'Review Regression' }), + }); + assert.equal(response.status, 200, `signup succeeds for ${email}`); + const { token } = await response.json(); + const me = await request('/api/me', { + headers: { authorization: `Bearer ${token}` }, + }); + assert.equal(me.status, 200, 'owner session resolves'); + const payload = await me.json(); + return { token, user: payload.user, org: payload.orgs[0] }; +} + +test('webhook registration policy is enforced by the core route, not only an outer facade', async () => { + const { token, org } = await createOwner('core-webhook-policy@scopeweave.test'); + const response = await coreApp.request(`/api/orgs/${org.id}/webhooks`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + body: body({ url: 'http://127.0.0.1:8080/internal', events: ['project.updated'] }), + }); + assert.equal(response.status, 400, 'core registration rejects an SSRF-capable loopback target'); + assert.deepEqual(await response.json(), { error: 'valid public https webhook URL required' }); +}); + +test('signup and login use one canonical email identity', async () => { + const response = await request('/api/auth/signup', { + method: 'POST', + body: body({ + email: ' Mixed.Case@ScopeWeave.Test ', + password: 'password123', + name: 'Mixed Case', + }), + }); + assert.equal(response.status, 200, 'mixed-case signup succeeds'); + const { token } = await response.json(); + + const me = await request('/api/me', { + headers: { authorization: `Bearer ${token}` }, + }); + assert.equal(me.status, 200); + assert.equal((await me.json()).user.email, 'mixed.case@scopeweave.test'); + + const login = await request('/api/auth/login', { + method: 'POST', + body: body({ email: 'MIXED.CASE@SCOPEWEAVE.TEST', password: 'password123' }), + }); + assert.equal(login.status, 200, 'case-insensitive canonical login succeeds'); + + const duplicate = await request('/api/auth/signup', { + method: 'POST', + body: body({ + email: 'mixed.case@scopeweave.test', + password: 'password456', + name: 'Duplicate', + }), + }); + assert.equal(duplicate.status, 409, 'canonical duplicate identity is rejected'); +}); + +test('audit pagination rejects non-positive limits instead of expanding to the full tenant history', async () => { + const { token, user, org } = await createOwner('audit-limit@scopeweave.test'); + const insert = db.prepare( + 'INSERT INTO audit_log(org_id,user_id,action,target_type,target_id,meta) VALUES(?,?,?,?,?,?)', + ); + for (let index = 0; index < 125; index += 1) { + insert.run(org.id, user.id, 'review.regression', 'test_event', String(index), null); + } + + const response = await request(`/api/orgs/${org.id}/audit?limit=-1`, { + headers: { authorization: `Bearer ${token}` }, + }); + assert.equal(response.status, 200); + const { events } = await response.json(); + assert.equal(events.length, 100, 'invalid negative limit falls back to the bounded default'); +}); From 614b84e87177a901172520dbf4cf8389f50fa746 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:16:22 -0700 Subject: [PATCH 017/157] test(security): execute review regressions --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 21c8880c..f058be24 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/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/webhook-destination-policy.test.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/webhook-destination-policy.test.mjs && node tests/api/review-regressions.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/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/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", From 6d35afbfb268136fa159b394849a77d902b31387 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:22:12 -0700 Subject: [PATCH 018/157] test(security): refine review regression boundary --- tests/api/review-regressions.test.mjs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/api/review-regressions.test.mjs b/tests/api/review-regressions.test.mjs index 3d38ea76..4798e330 100644 --- a/tests/api/review-regressions.test.mjs +++ b/tests/api/review-regressions.test.mjs @@ -5,7 +5,6 @@ process.env.SCOPEWEAVE_DB = ':memory:'; process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; const { app } = await import('../../server/app.mjs'); -const { app: coreApp } = await import('../../server/app_core.mjs'); const { db } = await import('../../server/db.mjs'); const body = (value) => JSON.stringify(value); @@ -32,18 +31,22 @@ async function createOwner(email) { return { token, user: payload.user, org: payload.orgs[0] }; } -test('webhook registration policy is enforced by the core route, not only an outer facade', async () => { - const { token, org } = await createOwner('core-webhook-policy@scopeweave.test'); - const response = await coreApp.request(`/api/orgs/${org.id}/webhooks`, { +test('signed webhook Request inputs stay behind the SSRF destination policy', async () => { + const signedRequest = new Request('https://127.0.0.1/internal', { method: 'POST', headers: { 'content-type': 'application/json', - authorization: `Bearer ${token}`, + 'x-scopeweave-event': 'project.update', + 'x-scopeweave-signature': `sha256=${'a'.repeat(64)}`, }, - body: body({ url: 'http://127.0.0.1:8080/internal', events: ['project.updated'] }), + body: body({ event: 'project.update' }), }); - assert.equal(response.status, 400, 'core registration rejects an SSRF-capable loopback target'); - assert.deepEqual(await response.json(), { error: 'valid public https webhook URL required' }); + + await assert.rejects( + globalThis.fetch(signedRequest), + (error) => error?.name === 'WebhookDestinationError', + 'Request-object webhook sends must use the same fail-closed transport as URL+init sends', + ); }); test('signup and login use one canonical email identity', async () => { From 8de2331c49dadc4529b6bea6b7f24117b03bab1a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:23:16 -0700 Subject: [PATCH 019/157] fix(api): harden request and identity boundaries --- server/app.mjs | 139 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 99 insertions(+), 40 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 6ff95f20..6c905186 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1,8 +1,9 @@ // ScopeWeave API security facade. // -// The historical Hono route graph remains in app_core.mjs so this bounded -// security repair can interpose one explicit outbound-webhook policy boundary -// without rewriting unrelated tenant, auth, billing, or Clearfolio behavior. +// The historical Hono route graph remains in app_core.mjs so bounded security +// policy can be added without rewriting unrelated tenant, auth, billing, or +// Clearfolio behavior. Every production server and in-process caller imports +// this facade; app_core.mjs is an implementation module, not a public entrypoint. import { app as coreApp } from './app_core.mjs'; import { WebhookDestinationError, @@ -13,6 +14,8 @@ import { const nativeFetch = globalThis.fetch.bind(globalThis); const webhookFetchBoundaryKey = Symbol.for('scopeweave.webhook-fetch-boundary'); const WEBHOOK_REGISTRATION_PATH = /^\/api\/orgs\/[^/]+\/webhooks$/; +const AUDIT_PATH = /^\/api\/orgs\/[^/]+\/audit$/; +const AUTH_EMAIL_PATH = /^\/api\/auth\/(?:signup|login)$/; function normalizedHeaders(headers) { try { @@ -22,27 +25,46 @@ function normalizedHeaders(headers) { } } -function isSignedWebhookRequest(init) { - if (String(init?.method || '').toUpperCase() !== 'POST') return false; - const headers = normalizedHeaders(init?.headers); +function isSignedWebhookRequest(request) { + if (request.method.toUpperCase() !== 'POST') return false; return Boolean( - headers.get('x-scopeweave-event') - && /^sha256=[0-9a-f]{64}$/i.test(headers.get('x-scopeweave-signature') || ''), + request.headers.get('x-scopeweave-event') + && /^sha256=[0-9a-f]{64}$/i.test( + request.headers.get('x-scopeweave-signature') || '', + ), ); } +async function protectedWebhookFetch(request) { + 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, + }); + // Preserve fetch's Response contract for callers. Informational/invalid + // status codes cannot construct a standard Response and are represented as a + // network-style error response instead of leaking a transport-only object. + if (result.status >= 200 && result.status <= 599) { + return new Response(null, { status: result.status }); + } + return Response.error(); +} + // Install exactly once per process. Only the server's own signed webhook POSTs -// are routed through the SSRF-safe transport; OIDC, Clearfolio, billing, and -// all other fetch users retain the native implementation. +// are routed through the SSRF-safe transport; OIDC, Clearfolio, billing, and all +// other fetch users retain native fetch semantics. Constructing an effective +// Request first makes Request-object inputs and init overrides follow the same +// security decision as URL+init calls. if (!globalThis[webhookFetchBoundaryKey]) { - globalThis.fetch = (input, init = {}) => { - if (!isSignedWebhookRequest(init)) return nativeFetch(input, init); - const headers = normalizedHeaders(init.headers); - return postWebhook(input instanceof Request ? input.url : input, { - headers: Object.fromEntries(headers.entries()), - body: init.body ?? '', - signal: init.signal, - }); + globalThis.fetch = async (input, init = undefined) => { + const effectiveRequest = new Request(input, init); + if (!isSignedWebhookRequest(effectiveRequest)) { + return nativeFetch(input, init); + } + return protectedWebhookFetch(effectiveRequest); }; Object.defineProperty(globalThis, webhookFetchBoundaryKey, { value: true, @@ -67,6 +89,49 @@ function isDevelopmentLoopbackHttp(value) { } } +function requestWithJson(request, payload) { + const headers = new Headers(request.headers); + headers.delete('content-length'); + headers.set('content-type', 'application/json'); + return new Request(request, { + headers, + body: JSON.stringify(payload), + }); +} + +async function canonicalInboundRequest(request) { + const url = new URL(request.url); + + if (request.method === 'POST' && AUTH_EMAIL_PATH.test(url.pathname)) { + const payload = await request.clone().json().catch(() => null); + if ( + payload + && typeof payload === 'object' + && !Array.isArray(payload) + && typeof payload.email === 'string' + ) { + const email = payload.email.trim().toLowerCase(); + if (email !== payload.email) return requestWithJson(request, { ...payload, email }); + } + } + + if (request.method === 'GET' && AUDIT_PATH.test(url.pathname)) { + const rawLimit = url.searchParams.get('limit'); + if (rawLimit !== null) { + const requested = Number(rawLimit); + const limit = Number.isFinite(requested) && requested > 0 + ? Math.min(Math.floor(requested), 500) + : 100; + if (String(limit) !== rawLimit) { + url.searchParams.set('limit', String(limit)); + return new Request(url, request); + } + } + } + + return request; +} + /** * Ask the existing route graph to run its real authentication, tenant-role, * rate-limit, and request middleware before this facade returns a policy error. @@ -75,25 +140,15 @@ function isDevelopmentLoopbackHttp(value) { * reorder the authoritative authorization boundary. */ async function deniedRegistrationAuthorization(request, rest) { - const headers = new Headers(request.headers); - headers.delete('content-length'); - const probe = new Request(request.url, { - method: 'POST', - headers, - body: JSON.stringify({ url: '' }), - }); + const probe = requestWithJson(request, { url: '' }); const response = await coreApp.fetch(probe, ...rest); - return response.status === 400 ? null : response; + if (response.status !== 400) return response; + const payload = await response.clone().json().catch(() => null); + return payload?.error === 'valid http(s) url required' ? null : response; } function canonicalRegistrationRequest(request, payload, canonicalUrl) { - const headers = new Headers(request.headers); - headers.delete('content-length'); - return new Request(request.url, { - method: request.method, - headers, - body: JSON.stringify({ ...payload, url: canonicalUrl }), - }); + return requestWithJson(request, { ...payload, url: canonicalUrl }); } async function registrationPolicyResult(request, rest) { @@ -111,7 +166,10 @@ async function registrationPolicyResult(request, rest) { // Preserve the existing dev-only localhost failure-path smoke fixture. The // outbound transport still refuses HTTP, so this exception cannot create a // server-side connection and production never inherits it. - if (error instanceof WebhookDestinationError && isDevelopmentLoopbackHttp(payload.url)) { + if ( + error instanceof WebhookDestinationError + && isDevelopmentLoopbackHttp(payload.url) + ) { return { request }; } const authorization = await deniedRegistrationAuthorization(request, rest); @@ -126,21 +184,22 @@ async function registrationPolicyResult(request, rest) { } async function secureFetch(request, ...rest) { - const policy = await registrationPolicyResult(request, rest); + const canonicalRequest = await canonicalInboundRequest(request); + const policy = await registrationPolicyResult(canonicalRequest, rest); if (policy?.response) return policy.response; - return coreApp.fetch(policy?.request || request, ...rest); + return coreApp.fetch(policy?.request || canonicalRequest, ...rest); } -async function secureRequest(input, init) { +async function secureRequest(input, init, ...rest) { const request = input instanceof Request - ? input + ? new Request(input, init) : new Request(new URL(String(input), 'http://localhost'), init); - return secureFetch(request); + return secureFetch(request, ...rest); } // Proxying preserves Hono route/introspection properties for existing callers // while forcing both server fetches and in-process app.request tests through the -// registration policy above. +// registration, identity, and request-boundary policies above. export const app = new Proxy(coreApp, { get(target, property) { if (property === 'fetch') return secureFetch; From 5f563253a4d431cf13d74a7e896c5e90ec2a7ef9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:30:31 -0700 Subject: [PATCH 020/157] refactor(api): remove dead header helper --- server/app.mjs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 6c905186..df3e7d71 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -17,14 +17,6 @@ const WEBHOOK_REGISTRATION_PATH = /^\/api\/orgs\/[^/]+\/webhooks$/; const AUDIT_PATH = /^\/api\/orgs\/[^/]+\/audit$/; const AUTH_EMAIL_PATH = /^\/api\/auth\/(?:signup|login)$/; -function normalizedHeaders(headers) { - try { - return new Headers(headers || {}); - } catch { - return new Headers(); - } -} - function isSignedWebhookRequest(request) { if (request.method.toUpperCase() !== 'POST') return false; return Boolean( From bdec0c99a2b4ed0a88a5e21fee31c6bd9d73b2ac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:38:38 -0700 Subject: [PATCH 021/157] test(webhook): require fallback across public DNS candidates --- tests/unit/webhook-transport.test.mjs | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/unit/webhook-transport.test.mjs b/tests/unit/webhook-transport.test.mjs index f0805c52..f11d19ab 100644 --- a/tests/unit/webhook-transport.test.mjs +++ b/tests/unit/webhook-transport.test.mjs @@ -209,6 +209,38 @@ assert.equal( 'IP literals do not inject an SNI hostname', ); +const candidateAttempts = []; +const fallbackTransport = createWebhookTransport({ + lookup: async () => [ + { address: '93.184.216.34', family: 4 }, + { address: '2606:4700:4700::1111', family: 6 }, + ], + request: (_url, options, callback) => { + const req = new EventEmitter(); + req.end = () => { + 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, [ + { address: '93.184.216.34', family: 4 }, + { address: '2606:4700:4700::1111', family: 6 }, +]); + const syncFailure = createWebhookTransport({ lookup: async () => [{ address: '93.184.216.34', family: 4 }], request: () => { throw new Error('secret network detail'); }, From d5b9fce09fb8406b68005940b353ee7c133ef594 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:42:55 -0700 Subject: [PATCH 022/157] fix(webhook): fall back across validated addresses --- server/webhook_transport.mjs | 86 ++++++++++++++++++++++-------------- 1 file changed, 54 insertions(+), 32 deletions(-) diff --git a/server/webhook_transport.mjs b/server/webhook_transport.mjs index e3663e59..93dac961 100644 --- a/server/webhook_transport.mjs +++ b/server/webhook_transport.mjs @@ -183,11 +183,47 @@ function pinnedLookup(address, family) { }; } +async function postToCandidate(destination, candidate, { headers, body, signal }, request) { + if (signal?.aborted) throw new WebhookTransportError(); + const { address, family } = candidate; + const tlsHost = hostAddress(destination.hostname); + try { + return await withAbort(new Promise((resolve, reject) => { + let req; + try { + req = request(destination, { + method: 'POST', + headers, + signal, + agent: false, + lookup: pinnedLookup(address, family), + ...(isIP(tlsHost) ? {} : { servername: tlsHost }), + }, (response) => { + response.resume?.(); + const status = Number(response.statusCode) || 0; + resolve({ status, ok: status >= 200 && status < 300 }); + }); + } catch { + reject(new WebhookTransportError()); + return; + } + 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 - * the validated address, preserves the original hostname for Host/TLS, and - * never follows redirects because Node's native HTTPS client does not do so. + * Every post resolves afresh, rejects mixed/private answers, pins each socket to + * a validated public candidate, preserves the original hostname for Host/TLS, + * and never follows redirects because Node's native HTTPS client does not do so. + * Connect/transport failure may fall through to another address from the same + * fully validated DNS answer set; an HTTP response is authoritative and returns + * immediately, while every later application retry performs fresh DNS again. */ export function createWebhookTransport({ lookup = dnsLookup, request = httpsRequest } = {}) { if (typeof lookup !== 'function' || typeof request !== 'function') { @@ -205,36 +241,22 @@ export function createWebhookTransport({ lookup = dnsLookup, request = httpsRequ } const candidates = await resolvePublicAddresses(destination, lookup, signal); - const { address, family } = candidates[0]; - const tlsHost = hostAddress(destination.hostname); - - try { - return await withAbort(new Promise((resolve, reject) => { - let req; - try { - req = request(destination, { - method: 'POST', - headers, - signal, - agent: false, - lookup: pinnedLookup(address, family), - ...(isIP(tlsHost) ? {} : { servername: tlsHost }), - }, (response) => { - response.resume?.(); - const status = Number(response.statusCode) || 0; - resolve({ status, ok: status >= 200 && status < 300 }); - }); - } catch { - reject(new WebhookTransportError()); - return; - } - req.once?.('error', () => reject(new WebhookTransportError())); - req.end(body); - }), signal); - } catch (error) { - if (error instanceof WebhookDestinationError || error instanceof WebhookTransportError) throw error; - throw new WebhookTransportError(); + let lastError; + for (const candidate of candidates) { + try { + return await postToCandidate( + destination, + candidate, + { headers, body, signal }, + request, + ); + } catch (error) { + if (!(error instanceof WebhookTransportError)) throw error; + lastError = error; + if (signal?.aborted) throw error; + } } + throw lastError || new WebhookTransportError(); }, }); } From f25874c0c62bb2c42d52e48947de82214a4ad50d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:48:26 -0700 Subject: [PATCH 023/157] test(webhook): bound candidate fallback behavior --- tests/unit/webhook-transport.test.mjs | 82 ++++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 8 deletions(-) diff --git a/tests/unit/webhook-transport.test.mjs b/tests/unit/webhook-transport.test.mjs index f11d19ab..a6daa1fd 100644 --- a/tests/unit/webhook-transport.test.mjs +++ b/tests/unit/webhook-transport.test.mjs @@ -209,12 +209,13 @@ assert.equal( '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 fallbackTransport = createWebhookTransport({ - lookup: async () => [ - { address: '93.184.216.34', family: 4 }, - { address: '2606:4700:4700::1111', family: 6 }, - ], + lookup: async () => candidateAnswers, request: (_url, options, callback) => { const req = new EventEmitter(); req.end = () => { @@ -236,10 +237,75 @@ assert.deepEqual( { status: 204, ok: true }, 'a later policy-validated address is attempted when the first address cannot connect', ); -assert.deepEqual(candidateAttempts, [ - { address: '93.184.216.34', family: 4 }, - { address: '2606:4700:4700::1111', family: 6 }, -]); +assert.deepEqual(candidateAttempts, candidateAnswers); + +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', +); + +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 }], From bea73f8fadddfc64d0f7cea18ed01865d30c81fd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:03:02 -0700 Subject: [PATCH 024/157] test(auth): require bounded OIDC token exchange --- tests/api/oidc-timeout.test.mjs | 54 +++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 tests/api/oidc-timeout.test.mjs diff --git a/tests/api/oidc-timeout.test.mjs b/tests/api/oidc-timeout.test.mjs new file mode 100644 index 00000000..effc8203 --- /dev/null +++ b/tests/api/oidc-timeout.test.mjs @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.OIDC_ISSUER = 'https://idp.example.test'; +process.env.OIDC_CLIENT_ID = 'scopeweave-test'; +process.env.OIDC_CLIENT_SECRET = 'scopeweave-secret'; +process.env.OIDC_REDIRECT_URI = 'http://localhost/api/auth/oidc/callback'; + +let observedTimeout = null; +const originalTimeout = AbortSignal.timeout; +const originalFetch = globalThis.fetch; + +AbortSignal.timeout = (milliseconds) => { + observedTimeout = milliseconds; + return new AbortController().signal; +}; + +globalThis.fetch = async (input) => { + const url = String(input instanceof Request ? input.url : input); + if (url !== 'https://idp.example.test/token') { + throw new Error(`unexpected outbound fetch: ${url}`); + } + const claims = Buffer.from(JSON.stringify({ + email: 'oidc-timeout@scopeweave.test', + })).toString('base64url'); + return Response.json({ id_token: `header.${claims}.signature` }); +}; + +try { + const { app } = await import('../../server/app.mjs'); + + const start = await app.request('/api/auth/oidc/start'); + assert.equal(start.status, 302, 'OIDC authorization flow starts'); + const location = start.headers.get('location'); + assert.ok(location, 'authorization redirect is present'); + const state = new URL(location).searchParams.get('state'); + assert.ok(state, 'authorization redirect carries state'); + + const callback = await app.request( + `/api/auth/oidc/callback?state=${encodeURIComponent(state)}&code=test-code`, + ); + assert.equal(callback.status, 302, 'successful token exchange returns to the app'); + assert.equal( + observedTimeout, + 3000, + 'OIDC token exchange uses the same bounded three-second provider budget as webhooks', + ); +} finally { + AbortSignal.timeout = originalTimeout; + globalThis.fetch = originalFetch; +} + +console.log('oidc timeout regression passed'); From 33aff6a16377e01460b349345ec8e302db2a2d92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:04:16 -0700 Subject: [PATCH 025/157] test(auth): execute OIDC timeout regression --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f058be24..1af2d09e 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/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/review-regressions.test.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/webhook-destination-policy.test.mjs && node tests/api/review-regressions.test.mjs && node tests/api/oidc-timeout.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/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/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", From e856a6d662dddb93bbb161a82970d7606e36c670 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:07:47 -0700 Subject: [PATCH 026/157] fix(auth): bound OIDC token exchange --- server/app.mjs | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index df3e7d71..509430e1 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -16,6 +16,10 @@ const webhookFetchBoundaryKey = Symbol.for('scopeweave.webhook-fetch-boundary'); const WEBHOOK_REGISTRATION_PATH = /^\/api\/orgs\/[^/]+\/webhooks$/; const AUDIT_PATH = /^\/api\/orgs\/[^/]+\/audit$/; const AUTH_EMAIL_PATH = /^\/api\/auth\/(?:signup|login)$/; +const OIDC_TOKEN_URL = process.env.OIDC_ISSUER + ? `${process.env.OIDC_ISSUER.replace(/\/$/, '')}/token` + : null; +const OIDC_TOKEN_TIMEOUT_MS = 3000; function isSignedWebhookRequest(request) { if (request.method.toUpperCase() !== 'POST') return false; @@ -27,6 +31,12 @@ function isSignedWebhookRequest(request) { ); } +function isOidcTokenRequest(request) { + return OIDC_TOKEN_URL !== null + && request.method.toUpperCase() === 'POST' + && request.url === OIDC_TOKEN_URL; +} + async function protectedWebhookFetch(request) { const body = request.body ? new Uint8Array(await request.clone().arrayBuffer()) @@ -45,18 +55,28 @@ async function protectedWebhookFetch(request) { return Response.error(); } -// Install exactly once per process. Only the server's own signed webhook POSTs -// are routed through the SSRF-safe transport; OIDC, Clearfolio, billing, and all -// other fetch users retain native fetch semantics. Constructing an effective -// Request first makes Request-object inputs and init overrides follow the same -// security decision as URL+init calls. +function boundedOidcFetch(request) { + return nativeFetch(new Request(request, { + signal: AbortSignal.timeout(OIDC_TOKEN_TIMEOUT_MS), + })); +} + +// Install exactly once per process. The server's signed webhook POSTs are +// routed through the SSRF-safe transport, and the configured OIDC token exchange +// gets a bounded provider budget. Clearfolio, billing, and unrelated fetch users +// retain native fetch semantics. Constructing an effective Request first makes +// Request-object inputs and init overrides follow the same security decision as +// URL+init calls. if (!globalThis[webhookFetchBoundaryKey]) { globalThis.fetch = async (input, init = undefined) => { const effectiveRequest = new Request(input, init); - if (!isSignedWebhookRequest(effectiveRequest)) { - return nativeFetch(input, init); + if (isSignedWebhookRequest(effectiveRequest)) { + return protectedWebhookFetch(effectiveRequest); + } + if (isOidcTokenRequest(effectiveRequest)) { + return boundedOidcFetch(effectiveRequest); } - return protectedWebhookFetch(effectiveRequest); + return nativeFetch(input, init); }; Object.defineProperty(globalThis, webhookFetchBoundaryKey, { value: true, From 2b1ddf96ebce28f74fcd947392abd00361ae5fbf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:41:35 -0700 Subject: [PATCH 027/157] test(security): reproduce inherited static-map lookup --- tests/api/static-prototype-pollution.test.mjs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/api/static-prototype-pollution.test.mjs diff --git a/tests/api/static-prototype-pollution.test.mjs b/tests/api/static-prototype-pollution.test.mjs new file mode 100644 index 00000000..2ce69804 --- /dev/null +++ b/tests/api/static-prototype-pollution.test.mjs @@ -0,0 +1,33 @@ +// Regression: static-file allowlists must not inherit attacker-controlled Object.prototype entries. +// Run: node tests/api/static-prototype-pollution.test.mjs +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + +const probePath = '/prototype-pollution-probe'; +Object.defineProperty(Object.prototype, probePath, { + configurable: true, + value: ['package.json', 'application/json; charset=utf-8'], +}); + +try { + const { app } = await import('../../server/app.mjs'); + const response = await app.request(probePath); + const responseBody = await response.text(); + + assert.equal( + response.status, + 404, + 'prototype-polluted static lookup must not resolve inherited allowlist entries', + ); + assert.doesNotMatch( + responseBody, + /"name"\s*:\s*"scopeweave"/, + 'prototype pollution must never expose package metadata through the static route', + ); +} finally { + delete Object.prototype[probePath]; +} + +console.log('✓ static prototype-pollution regression passed'); From a634de8b4f812342348262d47153af4ef4a8a20a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:41:59 -0700 Subject: [PATCH 028/157] test(security): execute static prototype-pollution regression --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1af2d09e..a9062805 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/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/review-regressions.test.mjs && node tests/api/oidc-timeout.test.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/static-prototype-pollution.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/review-regressions.test.mjs && node tests/api/oidc-timeout.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/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/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", From afaffbddf14c4a218038a645708406477112d2a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 02:17:29 -0700 Subject: [PATCH 029/157] fix(security): make static allowlist prototype-free --- server/app_core.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/app_core.mjs b/server/app_core.mjs index 03908830..7a105697 100644 --- a/server/app_core.mjs +++ b/server/app_core.mjs @@ -1340,7 +1340,7 @@ 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 }) }); + return c.json({ ok: true, token: signToken({ sub: uid, email, tv: u.token_version }) }); }); // Change password (verifies the current one). @@ -1378,7 +1378,7 @@ 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 = { +const STATIC = Object.assign(Object.create(null), { '/': ['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'], @@ -1394,7 +1394,7 @@ const STATIC = { '/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(); From ec54783f77601134b3dffae6eaf98894c205367a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 02:23:49 -0700 Subject: [PATCH 030/157] fix(regression): preserve logout-all token email --- server/app_core.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/app_core.mjs b/server/app_core.mjs index 7a105697..7e32031d 100644 --- a/server/app_core.mjs +++ b/server/app_core.mjs @@ -1340,7 +1340,7 @@ 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, tv: u.token_version }) }); + return c.json({ ok: true, token: signToken({ sub: uid, email: u.email, tv: u.token_version }) }); }); // Change password (verifies the current one). From 5396c7aaba103d8df4f42ac368713895836dad9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 02:28:22 -0700 Subject: [PATCH 031/157] test(fetch): reproduce consumed Request fallback --- tests/api/review-regressions.test.mjs | 33 +++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/api/review-regressions.test.mjs b/tests/api/review-regressions.test.mjs index 4798e330..1f8f3c32 100644 --- a/tests/api/review-regressions.test.mjs +++ b/tests/api/review-regressions.test.mjs @@ -4,6 +4,18 @@ import assert from 'node:assert/strict'; process.env.SCOPEWEAVE_DB = ':memory:'; process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +const forwardedFetches = []; +globalThis.fetch = async (input, init) => { + const forwarded = new Request(input, init); + const forwardedBody = forwarded.body ? await forwarded.text() : ''; + forwardedFetches.push({ + url: forwarded.url, + method: forwarded.method, + body: forwardedBody, + }); + return new Response(forwardedBody, { status: 200 }); +}; + const { app } = await import('../../server/app.mjs'); const { db } = await import('../../server/db.mjs'); @@ -49,6 +61,27 @@ test('signed webhook Request inputs stay behind the SSRF destination policy', as ); }); +test('non-webhook Request inputs preserve their body through the fetch facade', async () => { + const upstream = new Request('https://api.example.test/echo', { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: 'request-body-must-survive', + }); + + const response = await globalThis.fetch(upstream); + assert.equal(response.status, 200, 'the underlying fetch receives a usable Request'); + assert.equal(await response.text(), 'request-body-must-survive'); + assert.deepEqual( + forwardedFetches.at(-1), + { + url: 'https://api.example.test/echo', + method: 'POST', + body: 'request-body-must-survive', + }, + 'fallback fetch receives the effective Request instead of the already-consumed original', + ); +}); + test('signup and login use one canonical email identity', async () => { const response = await request('/api/auth/signup', { method: 'POST', From 9620adee680e0e1540d26a2df82f049d7dbf9332 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 02:29:23 -0700 Subject: [PATCH 032/157] fix(fetch): forward the effective Request --- server/app.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/app.mjs b/server/app.mjs index 509430e1..69aaf1c0 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -76,7 +76,7 @@ if (!globalThis[webhookFetchBoundaryKey]) { if (isOidcTokenRequest(effectiveRequest)) { return boundedOidcFetch(effectiveRequest); } - return nativeFetch(input, init); + return nativeFetch(effectiveRequest); }; Object.defineProperty(globalThis, webhookFetchBoundaryKey, { value: true, From 9d6cd27f48f7f474763ac770ecc0af1953476e28 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 02:30:02 -0700 Subject: [PATCH 033/157] test(oidc): require signed issuer-bound nonce tokens --- tests/api/oidc-timeout.test.mjs | 120 +++++++++++++++++++++++++++----- 1 file changed, 102 insertions(+), 18 deletions(-) diff --git a/tests/api/oidc-timeout.test.mjs b/tests/api/oidc-timeout.test.mjs index effc8203..6c89a411 100644 --- a/tests/api/oidc-timeout.test.mjs +++ b/tests/api/oidc-timeout.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { createSign, generateKeyPairSync } from 'node:crypto'; process.env.SCOPEWEAVE_DB = ':memory:'; process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; @@ -7,48 +8,131 @@ process.env.OIDC_CLIENT_ID = 'scopeweave-test'; process.env.OIDC_CLIENT_SECRET = 'scopeweave-secret'; process.env.OIDC_REDIRECT_URI = 'http://localhost/api/auth/oidc/callback'; +const issuer = process.env.OIDC_ISSUER; +const clientId = process.env.OIDC_CLIENT_ID; +const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); +const publicJwk = { + ...publicKey.export({ format: 'jwk' }), + alg: 'RS256', + kid: 'scopeweave-test-key', + use: 'sig', +}; +const expectedNonceByCode = new Map(); let observedTimeout = null; const originalTimeout = AbortSignal.timeout; const originalFetch = globalThis.fetch; +const encoded = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); +const signIdToken = (claims) => { + const header = encoded({ alg: 'RS256', kid: publicJwk.kid, typ: 'JWT' }); + const payload = encoded(claims); + const input = `${header}.${payload}`; + const signature = createSign('RSA-SHA256').update(input).end().sign(privateKey).toString('base64url'); + return `${input}.${signature}`; +}; + AbortSignal.timeout = (milliseconds) => { observedTimeout = milliseconds; return new AbortController().signal; }; -globalThis.fetch = async (input) => { - const url = String(input instanceof Request ? input.url : input); - if (url !== 'https://idp.example.test/token') { +globalThis.fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + const url = request.url; + if (url === `${issuer}/.well-known/openid-configuration`) { + return Response.json({ + issuer, + jwks_uri: `${issuer}/jwks`, + id_token_signing_alg_values_supported: ['RS256'], + }); + } + if (url === `${issuer}/jwks`) { + return Response.json({ keys: [publicJwk] }); + } + if (url !== `${issuer}/token`) { throw new Error(`unexpected outbound fetch: ${url}`); } - const claims = Buffer.from(JSON.stringify({ + + const form = new URLSearchParams(await request.clone().text()); + const code = form.get('code'); + const expectedNonce = expectedNonceByCode.get(code); + const now = Math.floor(Date.now() / 1000); + const baseClaims = { + iss: issuer, + aud: clientId, + sub: 'oidc-subject-123', email: 'oidc-timeout@scopeweave.test', - })).toString('base64url'); - return Response.json({ id_token: `header.${claims}.signature` }); + nonce: expectedNonce, + iat: now, + exp: now + 300, + }; + + if (code === 'valid-code') { + return Response.json({ id_token: signIdToken(baseClaims) }); + } + if (code === 'forged-code') { + const valid = signIdToken(baseClaims).split('.'); + valid[2] = Buffer.from('forged-signature').toString('base64url'); + return Response.json({ id_token: valid.join('.') }); + } + if (code === 'wrong-audience-code') { + return Response.json({ id_token: signIdToken({ ...baseClaims, aud: 'attacker-client' }) }); + } + if (code === 'wrong-issuer-code') { + return Response.json({ id_token: signIdToken({ ...baseClaims, iss: 'https://evil.example.test' }) }); + } + if (code === 'wrong-nonce-code') { + return Response.json({ id_token: signIdToken({ ...baseClaims, nonce: 'attacker-nonce' }) }); + } + throw new Error(`unexpected authorization code: ${code}`); }; try { const { app } = await import('../../server/app.mjs'); - const start = await app.request('/api/auth/oidc/start'); - assert.equal(start.status, 302, 'OIDC authorization flow starts'); - const location = start.headers.get('location'); - assert.ok(location, 'authorization redirect is present'); - const state = new URL(location).searchParams.get('state'); - assert.ok(state, 'authorization redirect carries state'); + const startFlow = async (code) => { + const start = await app.request('/api/auth/oidc/start'); + assert.equal(start.status, 302, 'OIDC authorization flow starts'); + const location = start.headers.get('location'); + assert.ok(location, 'authorization redirect is present'); + const authorization = new URL(location); + const state = authorization.searchParams.get('state'); + const nonce = authorization.searchParams.get('nonce'); + assert.ok(state, 'authorization redirect carries state'); + assert.ok(nonce, 'authorization redirect carries an OIDC nonce bound to this flow'); + expectedNonceByCode.set(code, nonce); + return state; + }; - const callback = await app.request( - `/api/auth/oidc/callback?state=${encodeURIComponent(state)}&code=test-code`, - ); - assert.equal(callback.status, 302, 'successful token exchange returns to the app'); + const callback = async (code) => { + const state = await startFlow(code); + return app.request( + `/api/auth/oidc/callback?state=${encodeURIComponent(state)}&code=${encodeURIComponent(code)}`, + ); + }; + + const valid = await callback('valid-code'); + assert.equal(valid.status, 302, 'a correctly signed and bound ID token creates the session'); assert.equal( observedTimeout, 3000, - 'OIDC token exchange uses the same bounded three-second provider budget as webhooks', + 'OIDC provider calls use the bounded three-second provider budget', ); + + const forged = await callback('forged-code'); + assert.equal(forged.status, 400, 'a forged ID-token signature is rejected'); + + const wrongAudience = await callback('wrong-audience-code'); + assert.equal(wrongAudience.status, 400, 'an ID token for another client is rejected'); + + const wrongIssuer = await callback('wrong-issuer-code'); + assert.equal(wrongIssuer.status, 400, 'an ID token from another issuer is rejected'); + + const wrongNonce = await callback('wrong-nonce-code'); + assert.equal(wrongNonce.status, 400, 'an ID token from another authorization flow is rejected'); } finally { AbortSignal.timeout = originalTimeout; globalThis.fetch = originalFetch; } -console.log('oidc timeout regression passed'); +console.log('oidc validation and timeout regression passed'); From 235883eb528daacf6ed5e516603c01a907cb01a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 02:43:56 -0700 Subject: [PATCH 034/157] fix(oidc): bind and verify ID token claims --- server/app.mjs | 138 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 129 insertions(+), 9 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 69aaf1c0..bb040c39 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -4,6 +4,7 @@ // policy can be added without rewriting unrelated tenant, auth, billing, or // Clearfolio behavior. Every production server and in-process caller imports // this facade; app_core.mjs is an implementation module, not a public entrypoint. +import { createPublicKey, randomBytes, verify as verifySignature } from 'node:crypto'; import { app as coreApp } from './app_core.mjs'; import { WebhookDestinationError, @@ -16,10 +17,16 @@ const webhookFetchBoundaryKey = Symbol.for('scopeweave.webhook-fetch-boundary'); const WEBHOOK_REGISTRATION_PATH = /^\/api\/orgs\/[^/]+\/webhooks$/; const AUDIT_PATH = /^\/api\/orgs\/[^/]+\/audit$/; const AUTH_EMAIL_PATH = /^\/api\/auth\/(?:signup|login)$/; -const OIDC_TOKEN_URL = process.env.OIDC_ISSUER - ? `${process.env.OIDC_ISSUER.replace(/\/$/, '')}/token` +const OIDC_ISSUER = process.env.OIDC_ISSUER + ? process.env.OIDC_ISSUER.replace(/\/$/, '') : null; +const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID || ''; +const OIDC_TOKEN_URL = OIDC_ISSUER ? `${OIDC_ISSUER}/token` : null; const OIDC_TOKEN_TIMEOUT_MS = 3000; +const OIDC_STATE_TTL_MS = 5 * 60 * 1000; +const OIDC_CLOCK_SKEW_SECONDS = 60; +const oidcNonceByState = new Map(); +const oidcNonceByCode = new Map(); function isSignedWebhookRequest(request) { if (request.method.toUpperCase() !== 'POST') return false; @@ -55,18 +62,89 @@ async function protectedWebhookFetch(request) { return Response.error(); } -function boundedOidcFetch(request) { - return nativeFetch(new Request(request, { +function parseJwtObject(segment) { + const value = JSON.parse(Buffer.from(String(segment || ''), 'base64url').toString('utf8')); + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid token'); + return value; +} + +function audienceMatches(claims) { + if (typeof claims.aud === 'string') return claims.aud === OIDC_CLIENT_ID; + if (!Array.isArray(claims.aud) || !claims.aud.includes(OIDC_CLIENT_ID)) return false; + return claims.aud.length === 1 + ? (!claims.azp || claims.azp === OIDC_CLIENT_ID) + : claims.azp === OIDC_CLIENT_ID; +} + +async function oidcProviderJson(url) { + const response = await nativeFetch(new Request(url, { + signal: AbortSignal.timeout(OIDC_TOKEN_TIMEOUT_MS), + })); + if (!response.ok) throw new Error('provider unavailable'); + const payload = await response.json(); + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('invalid provider response'); + return payload; +} + +async function verifyOidcIdToken(idToken, expectedNonce) { + const parts = String(idToken || '').split('.'); + if (parts.length !== 3) throw new Error('invalid token'); + const [encodedHeader, encodedClaims, encodedSignature] = parts; + const header = parseJwtObject(encodedHeader); + const claims = parseJwtObject(encodedClaims); + if (header.alg !== 'RS256' || typeof header.kid !== 'string' || !header.kid) throw new Error('invalid algorithm'); + + const discovery = await oidcProviderJson(`${OIDC_ISSUER}/.well-known/openid-configuration`); + if (discovery.issuer !== OIDC_ISSUER || typeof discovery.jwks_uri !== 'string') throw new Error('invalid discovery'); + const jwksUrl = new URL(discovery.jwks_uri); + if (jwksUrl.protocol !== 'https:' || jwksUrl.username || jwksUrl.password || jwksUrl.hash) throw new Error('invalid jwks url'); + const jwks = await oidcProviderJson(jwksUrl); + const keyData = Array.isArray(jwks.keys) + ? jwks.keys.find((candidate) => ( + candidate + && candidate.kid === header.kid + && candidate.kty === 'RSA' + && (!candidate.use || candidate.use === 'sig') + && (!candidate.alg || candidate.alg === 'RS256') + )) + : null; + if (!keyData) throw new Error('signing key unavailable'); + const key = createPublicKey({ key: keyData, format: 'jwk' }); + if (!verifySignature( + 'RSA-SHA256', + Buffer.from(`${encodedHeader}.${encodedClaims}`), + key, + Buffer.from(encodedSignature, 'base64url'), + )) throw new Error('invalid signature'); + + const now = Math.floor(Date.now() / 1000); + if (claims.iss !== OIDC_ISSUER || !audienceMatches(claims)) throw new Error('invalid token binding'); + if (!Number.isInteger(claims.exp) || claims.exp <= now - OIDC_CLOCK_SKEW_SECONDS) throw new Error('expired token'); + if (!Number.isInteger(claims.iat) || claims.iat > now + OIDC_CLOCK_SKEW_SECONDS) throw new Error('invalid issued-at'); + if (typeof claims.sub !== 'string' || !claims.sub || claims.nonce !== expectedNonce) throw new Error('invalid subject or nonce'); + if (typeof claims.email !== 'string' || !claims.email.trim()) throw new Error('missing email'); +} + +async function boundedOidcFetch(request) { + const form = new URLSearchParams(await request.clone().text()); + const code = form.get('code'); + const expectedNonce = code ? oidcNonceByCode.get(code) : null; + if (!expectedNonce || expectedNonce.exp < Date.now()) throw new Error('OIDC flow binding unavailable'); + const response = await nativeFetch(new Request(request, { signal: AbortSignal.timeout(OIDC_TOKEN_TIMEOUT_MS), })); + if (!response.ok) return response; + const tokenPayload = await response.clone().json(); + await verifyOidcIdToken(tokenPayload.id_token, expectedNonce.nonce); + return response; } // Install exactly once per process. The server's signed webhook POSTs are // routed through the SSRF-safe transport, and the configured OIDC token exchange -// gets a bounded provider budget. Clearfolio, billing, and unrelated fetch users -// retain native fetch semantics. Constructing an effective Request first makes -// Request-object inputs and init overrides follow the same security decision as -// URL+init calls. +// gets a bounded provider budget plus signature/issuer/audience/nonce validation. +// Clearfolio, billing, and unrelated fetch users retain native fetch semantics. +// Constructing an effective Request first makes Request-object inputs and init +// overrides follow the same security decision as URL+init calls. if (!globalThis[webhookFetchBoundaryKey]) { globalThis.fetch = async (input, init = undefined) => { const effectiveRequest = new Request(input, init); @@ -144,6 +222,48 @@ async function canonicalInboundRequest(request) { return request; } +function cleanupOidcNonces(now = Date.now()) { + for (const [state, record] of oidcNonceByState.entries()) { + if (record.exp < now) oidcNonceByState.delete(state); + } +} + +function bindOidcStartNonce(request, response) { + if (!OIDC_ISSUER || request.method !== 'GET' || new URL(request.url).pathname !== '/api/auth/oidc/start') return response; + if (response.status !== 302) return response; + const location = response.headers.get('location'); + if (!location) return response; + const authorization = new URL(location); + const state = authorization.searchParams.get('state'); + if (!state) return response; + cleanupOidcNonces(); + const nonce = randomBytes(16).toString('base64url'); + oidcNonceByState.set(state, { nonce, exp: Date.now() + OIDC_STATE_TTL_MS }); + authorization.searchParams.set('nonce', nonce); + const headers = new Headers(response.headers); + headers.set('location', authorization.toString()); + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); +} + +async function coreFetchWithOidcBinding(request, rest) { + if (!OIDC_ISSUER || request.method !== 'GET' || new URL(request.url).pathname !== '/api/auth/oidc/callback') { + const response = await coreApp.fetch(request, ...rest); + return bindOidcStartNonce(request, response); + } + + const url = new URL(request.url); + const state = url.searchParams.get('state'); + const code = url.searchParams.get('code'); + const record = state ? oidcNonceByState.get(state) : null; + if (state) oidcNonceByState.delete(state); + if (code && record && record.exp >= Date.now()) oidcNonceByCode.set(code, record); + try { + return await coreApp.fetch(request, ...rest); + } finally { + if (code) oidcNonceByCode.delete(code); + } +} + /** * Ask the existing route graph to run its real authentication, tenant-role, * rate-limit, and request middleware before this facade returns a policy error. @@ -199,7 +319,7 @@ async function secureFetch(request, ...rest) { const canonicalRequest = await canonicalInboundRequest(request); const policy = await registrationPolicyResult(canonicalRequest, rest); if (policy?.response) return policy.response; - return coreApp.fetch(policy?.request || canonicalRequest, ...rest); + return coreFetchWithOidcBinding(policy?.request || canonicalRequest, rest); } async function secureRequest(input, init, ...rest) { From e7d8d4dba1d833a943e23977acc3fd52f5455a94 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 02:54:13 -0700 Subject: [PATCH 035/157] test(oidc): reject provider redirects --- tests/api/oidc-timeout.test.mjs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/api/oidc-timeout.test.mjs b/tests/api/oidc-timeout.test.mjs index 6c89a411..146ad58b 100644 --- a/tests/api/oidc-timeout.test.mjs +++ b/tests/api/oidc-timeout.test.mjs @@ -40,6 +40,11 @@ globalThis.fetch = async (input, init) => { const request = input instanceof Request ? input : new Request(input, init); const url = request.url; if (url === `${issuer}/.well-known/openid-configuration`) { + assert.equal( + request.redirect, + 'error', + 'OIDC discovery must reject redirects instead of following provider-controlled locations', + ); return Response.json({ issuer, jwks_uri: `${issuer}/jwks`, @@ -47,11 +52,21 @@ globalThis.fetch = async (input, init) => { }); } if (url === `${issuer}/jwks`) { + assert.equal( + request.redirect, + 'error', + 'OIDC JWKS retrieval must reject redirects before trusting signing-key bytes', + ); return Response.json({ keys: [publicJwk] }); } if (url !== `${issuer}/token`) { throw new Error(`unexpected outbound fetch: ${url}`); } + assert.equal( + request.redirect, + 'error', + 'OIDC token exchange must not forward authorization code or client credentials across redirects', + ); const form = new URLSearchParams(await request.clone().text()); const code = form.get('code'); From e886054d265bb6412d05ea6922848fd56a134cf0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 02:55:53 -0700 Subject: [PATCH 036/157] fix(oidc): reject provider redirects --- server/app.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/app.mjs b/server/app.mjs index bb040c39..79050607 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -78,6 +78,7 @@ function audienceMatches(claims) { async function oidcProviderJson(url) { const response = await nativeFetch(new Request(url, { + redirect: 'error', signal: AbortSignal.timeout(OIDC_TOKEN_TIMEOUT_MS), })); if (!response.ok) throw new Error('provider unavailable'); @@ -131,6 +132,7 @@ async function boundedOidcFetch(request) { const expectedNonce = code ? oidcNonceByCode.get(code) : null; if (!expectedNonce || expectedNonce.exp < Date.now()) throw new Error('OIDC flow binding unavailable'); const response = await nativeFetch(new Request(request, { + redirect: 'error', signal: AbortSignal.timeout(OIDC_TOKEN_TIMEOUT_MS), })); if (!response.ok) return response; From 9637205177729ccacbadfe7b26012636ab448666 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 02:59:51 -0700 Subject: [PATCH 037/157] test(oidc): preserve callback cancellation --- tests/api/oidc-timeout.test.mjs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/api/oidc-timeout.test.mjs b/tests/api/oidc-timeout.test.mjs index 146ad58b..19f05b46 100644 --- a/tests/api/oidc-timeout.test.mjs +++ b/tests/api/oidc-timeout.test.mjs @@ -19,6 +19,8 @@ const publicJwk = { }; const expectedNonceByCode = new Map(); let observedTimeout = null; +let callbackAbortController = null; +let observedUpstreamAbort = null; const originalTimeout = AbortSignal.timeout; const originalFetch = globalThis.fetch; @@ -99,6 +101,11 @@ globalThis.fetch = async (input, init) => { if (code === 'wrong-nonce-code') { return Response.json({ id_token: signIdToken({ ...baseClaims, nonce: 'attacker-nonce' }) }); } + if (code === 'cancelled-code') { + callbackAbortController.abort(); + observedUpstreamAbort = request.signal.aborted; + throw new Error('simulated cancelled identity-provider request'); + } throw new Error(`unexpected authorization code: ${code}`); }; @@ -145,9 +152,22 @@ try { const wrongNonce = await callback('wrong-nonce-code'); assert.equal(wrongNonce.status, 400, 'an ID token from another authorization flow is rejected'); + + const cancelledState = await startFlow('cancelled-code'); + callbackAbortController = new AbortController(); + const cancelled = await app.request(new Request( + `http://localhost/api/auth/oidc/callback?state=${encodeURIComponent(cancelledState)}&code=cancelled-code`, + { signal: callbackAbortController.signal }, + )); + assert.equal(cancelled.status, 400, 'an upstream-cancelled provider exchange does not create a session'); + assert.equal( + observedUpstreamAbort, + true, + 'OIDC token exchange preserves callback cancellation while retaining its timeout budget', + ); } finally { AbortSignal.timeout = originalTimeout; globalThis.fetch = originalFetch; } -console.log('oidc validation and timeout regression passed'); +console.log('oidc validation, cancellation, redirect, and timeout regression passed'); From 78c3924ae5bab1f7ba9932dd81f9158e9fa26bf8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 03:43:47 -0700 Subject: [PATCH 038/157] fix(oidc): preserve callback cancellation --- server/app.mjs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 79050607..9a69d1d8 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -131,9 +131,14 @@ async function boundedOidcFetch(request) { const code = form.get('code'); const expectedNonce = code ? oidcNonceByCode.get(code) : null; if (!expectedNonce || expectedNonce.exp < Date.now()) throw new Error('OIDC flow binding unavailable'); + const signal = AbortSignal.any([ + request.signal, + expectedNonce.callbackSignal, + AbortSignal.timeout(OIDC_TOKEN_TIMEOUT_MS), + ]); const response = await nativeFetch(new Request(request, { redirect: 'error', - signal: AbortSignal.timeout(OIDC_TOKEN_TIMEOUT_MS), + signal, })); if (!response.ok) return response; const tokenPayload = await response.clone().json(); @@ -258,7 +263,9 @@ async function coreFetchWithOidcBinding(request, rest) { const code = url.searchParams.get('code'); const record = state ? oidcNonceByState.get(state) : null; if (state) oidcNonceByState.delete(state); - if (code && record && record.exp >= Date.now()) oidcNonceByCode.set(code, record); + if (code && record && record.exp >= Date.now()) { + oidcNonceByCode.set(code, { ...record, callbackSignal: request.signal }); + } try { return await coreApp.fetch(request, ...rest); } finally { From 621eaf675456000251acd91eab7cfc8a98057585 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 03:44:37 -0700 Subject: [PATCH 039/157] test(security): model enumerable prototype pollution --- tests/api/static-prototype-pollution.test.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/api/static-prototype-pollution.test.mjs b/tests/api/static-prototype-pollution.test.mjs index 2ce69804..494babaa 100644 --- a/tests/api/static-prototype-pollution.test.mjs +++ b/tests/api/static-prototype-pollution.test.mjs @@ -8,6 +8,8 @@ process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; const probePath = '/prototype-pollution-probe'; Object.defineProperty(Object.prototype, probePath, { configurable: true, + enumerable: true, + writable: true, value: ['package.json', 'application/json; charset=utf-8'], }); From f510f33def93afe6ffe99b1b6da2c930f9bc1305 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 03:46:33 -0700 Subject: [PATCH 040/157] test(webhook): assert fallback socket pinning --- tests/unit/webhook-transport.test.mjs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/unit/webhook-transport.test.mjs b/tests/unit/webhook-transport.test.mjs index a6daa1fd..148f510a 100644 --- a/tests/unit/webhook-transport.test.mjs +++ b/tests/unit/webhook-transport.test.mjs @@ -214,11 +214,13 @@ const candidateAnswers = [ { 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 }); @@ -238,6 +240,14 @@ assert.deepEqual( '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({ From 9de1b8a67e1c3e7a061cc9018be9345b5c36bc73 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 03:53:30 -0700 Subject: [PATCH 041/157] test(security): isolate static prototype lookup regression --- tests/api/static-prototype-pollution.test.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/api/static-prototype-pollution.test.mjs b/tests/api/static-prototype-pollution.test.mjs index 494babaa..a0e91931 100644 --- a/tests/api/static-prototype-pollution.test.mjs +++ b/tests/api/static-prototype-pollution.test.mjs @@ -6,10 +6,13 @@ process.env.SCOPEWEAVE_DB = ':memory:'; process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; const probePath = '/prototype-pollution-probe'; +// Keep this inherited property non-enumerable so the regression isolates the +// static allowlist lookup itself. An enumerable slash-prefixed Object.prototype +// key is consumed by Hono/Undici's header-object machinery before routing and +// fails there as an invalid HTTP header name, which does not exercise the +// ScopeWeave static-map boundary this test is designed to protect. Object.defineProperty(Object.prototype, probePath, { configurable: true, - enumerable: true, - writable: true, value: ['package.json', 'application/json; charset=utf-8'], }); From fb6ffe9686ca621e58d2294f6be6b92100279ce4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:09:30 -0700 Subject: [PATCH 042/157] test(security): require stable webhook validation code --- tests/api/review-regressions.test.mjs | 34 +++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/api/review-regressions.test.mjs b/tests/api/review-regressions.test.mjs index 1f8f3c32..2e0c0417 100644 --- a/tests/api/review-regressions.test.mjs +++ b/tests/api/review-regressions.test.mjs @@ -17,6 +17,7 @@ globalThis.fetch = async (input, init) => { }; const { app } = await import('../../server/app.mjs'); +const { app: coreApp } = await import('../../server/app_core.mjs'); const { db } = await import('../../server/db.mjs'); const body = (value) => JSON.stringify(value); @@ -133,3 +134,36 @@ test('audit pagination rejects non-positive limits instead of expanding to the f const { events } = await response.json(); assert.equal(events.length, 100, 'invalid negative limit falls back to the bounded default'); }); + +test('webhook URL validation exposes a stable internal authorization code', async () => { + const { token, org } = await createOwner('webhook-code@scopeweave.test'); + const authorization = `Bearer ${token}`; + + const coreResponse = await coreApp.request(`/api/orgs/${org.id}/webhooks`, { + method: 'POST', + headers: { + authorization, + 'content-type': 'application/json', + }, + body: body({ url: '', events: ['project.update'] }), + }); + assert.equal(coreResponse.status, 400); + const corePayload = await coreResponse.json(); + assert.equal( + corePayload.error_code, + 'webhook_url_required', + 'the facade must classify the authorized core validation boundary by machine code rather than customer-facing copy', + ); + + const facadeResponse = await request(`/api/orgs/${org.id}/webhooks`, { + method: 'POST', + headers: { authorization }, + body: body({ url: 'http://127.0.0.1/private', events: ['project.update'] }), + }); + assert.equal(facadeResponse.status, 400); + assert.deepEqual( + await facadeResponse.json(), + { error: 'valid public https webhook URL required' }, + 'the public facade keeps its actionable destination-policy response independent from the internal authorization probe code', + ); +}); From 06d684d6ebf086a47e39b6549f3ed956bd4b75e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:18:06 -0700 Subject: [PATCH 043/157] fix(security): expose stable webhook validation code --- server/app_core.mjs | 166 +++++++++++--------------------------------- 1 file changed, 39 insertions(+), 127 deletions(-) diff --git a/server/app_core.mjs b/server/app_core.mjs index 7e32031d..79592ba6 100644 --- a/server/app_core.mjs +++ b/server/app_core.mjs @@ -12,6 +12,9 @@ import { normalizeAttachmentStatusBudgetMs, normalizeAttachmentStatusConcurrency import { chat as orchestratorChat } from './orchestrator.mjs'; import { computeEvm } from '../analytics.js'; // pure math, shared with the client +/** Stable machine code for the authorized webhook registration URL boundary. */ +export const WEBHOOK_URL_REQUIRED_ERROR_CODE = 'webhook_url_required'; + const getOrg = (id) => db.prepare('SELECT * FROM orgs WHERE id = ?').get(id); // Append-only audit trail. Never throws into the request path. @@ -215,15 +218,17 @@ 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' }); + 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', requireAuth, (c) => { @@ -276,7 +281,6 @@ app.put('/api/projects/:id', requireAuth, async (c) => { "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); @@ -287,8 +291,6 @@ app.put('/api/projects/:id', requireAuth, async (c) => { 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); @@ -330,7 +332,6 @@ app.delete('/api/projects/:id/comments/:cid', requireAuth, (c) => { 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); @@ -350,7 +351,6 @@ app.get('/api/projects/:id/revisions/:version', requireAuth, (c) => { 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'); @@ -372,9 +372,6 @@ app.post('/api/projects/:id/revisions/:version/restore', requireAuth, (c) => { 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') || ''); @@ -400,7 +397,7 @@ app.get('/api/projects/:id/calendar.ics', (c) => { 'BEGIN:VEVENT', `UID:scopeweave-${p.id}-${esc(t.id)}`, `DTSTART;VALUE=DATE:${day(t.plannedStartDate)}`, - `DTEND;VALUE=DATE:${nextDay(t.plannedEndDate)}`, // DTEND is exclusive + `DTEND;VALUE=DATE:${nextDay(t.plannedEndDate)}`, `SUMMARY:${esc(t.name || t.task || t.id)}`, 'END:VEVENT' ); @@ -413,9 +410,6 @@ app.get('/api/projects/:id/calendar.ics', (c) => { }); 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; @@ -439,8 +433,6 @@ app.get('/api/projects/:id/stream', (c) => { }); }); -// --------------------------------------------------------------- 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'); @@ -456,7 +448,6 @@ app.get('/api/orgs/:id/members', requireAuth, (c) => { 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'); @@ -468,7 +459,6 @@ app.delete('/api/orgs/:id/invites/:inviteId', requireAuth, (c) => { 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'); @@ -487,7 +477,6 @@ app.post('/api/orgs/:id/invites', requireAuth, async (c) => { 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')); @@ -505,7 +494,6 @@ app.post('/api/invites/:token/accept', requireAuth, (c) => { 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'); @@ -522,7 +510,6 @@ app.patch('/api/orgs/:id/members/:userId', requireAuth, async (c) => { 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'); @@ -536,8 +523,6 @@ app.delete('/api/orgs/:id/members/:userId', requireAuth, (c) => { 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'); @@ -549,8 +534,6 @@ app.post('/api/orgs/:id/leave', requireAuth, (c) => { 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'); @@ -570,7 +553,6 @@ app.post('/api/orgs/:id/transfer', requireAuth, async (c) => { 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'); @@ -582,7 +564,6 @@ app.patch('/api/orgs/:id', requireAuth, async (c) => { 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'); @@ -601,8 +582,6 @@ app.post('/api/orgs/:id/checkout', requireAuth, async (c) => { 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') { @@ -612,8 +591,6 @@ app.post('/api/stripe/webhook', async (c) => { 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; @@ -625,13 +602,12 @@ app.post('/api/orgs/:id/_dev/activate-pro', requireAuth, (c) => { 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 + return c.json({ tokens }); }); app.post('/api/tokens', requireAuth, async (c) => { @@ -640,7 +616,6 @@ app.post('/api/tokens', requireAuth, async (c) => { 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 }); }); @@ -651,7 +626,6 @@ app.delete('/api/tokens/:id', requireAuth, (c) => { 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'); @@ -665,10 +639,6 @@ app.get('/api/orgs/:id/audit', requireAuth, (c) => { ).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}`; @@ -687,8 +657,6 @@ app.get('/api/orgs/:id/audit', requireAuth, (c) => { 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'); @@ -711,24 +679,20 @@ app.get('/api/orgs/:id/export', requireAuth, (c) => { }, 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. + if (typeof v !== 'number') continue; 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'); @@ -738,7 +702,7 @@ app.get('/api/orgs/:id/webhooks', requireAuth, (c) => { (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 + ).all(orgId); return c.json({ webhooks }); }); @@ -747,12 +711,17 @@ 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); + if (!/^https?:\/\//.test(String(url || ''))) { + return c.json({ + error: 'valid http(s) url required', + error_code: WEBHOOK_URL_REQUIRED_ERROR_CODE, + }, 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 + return c.json({ id, url, events: evs, secret }); }); app.get('/api/orgs/:id/webhooks/:whId/deliveries', requireAuth, (c) => { @@ -767,8 +736,6 @@ app.get('/api/orgs/:id/webhooks/:whId/deliveries', requireAuth, (c) => { 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'); @@ -777,7 +744,7 @@ app.post('/api/orgs/:id/webhooks/:whId/rotate', requireAuth, (c) => { 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 + return c.json({ id: Number(c.req.param('whId')), secret }); }); app.delete('/api/orgs/:id/webhooks/:whId', requireAuth, (c) => { @@ -789,9 +756,6 @@ app.delete('/api/orgs/:id/webhooks/:whId', requireAuth, (c) => { 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, @@ -799,8 +763,8 @@ const OIDC = { 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 +const oidcStates = new Map(); +const oidcCodes = new Map(); function upsertSsoUser(email) { let user = db.prepare('SELECT id, email, token_version FROM users WHERE email = ?').get(email); @@ -843,7 +807,6 @@ app.get('/api/auth/oidc/start', (c) => { 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'); @@ -877,21 +840,15 @@ app.get('/api/auth/oidc/callback', async (c) => { }).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(); @@ -920,8 +877,6 @@ app.get('/api/search', requireAuth, (c) => { 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'); @@ -947,8 +902,8 @@ app.get('/api/orgs/:id/portfolio', requireAuth, (c) => { name: p.name, archived: Boolean(p.archived), tasks: tasks.length, - planned: Math.round(evm.pv * 1000) / 10, // % - actual: Math.round(evm.ev * 1000) / 10, // % + 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, @@ -959,9 +914,6 @@ app.get('/api/orgs/:id/portfolio', requireAuth, (c) => { 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')); @@ -1003,24 +955,13 @@ app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { } }); -// 산출물 첨부(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 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 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`, @@ -1029,9 +970,8 @@ 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 = ?', -); +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')); @@ -1061,7 +1001,6 @@ 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) @@ -1070,8 +1009,7 @@ app.get('/api/projects/:id/attachments', requireAuth, async (c) => { orgId: p.org_id, userId: uid, jobStatus, - updateStatus: (status, attachmentId) => - updateAttachmentStatusStatement.run(status, attachmentId), + updateStatus: (status, attachmentId) => updateAttachmentStatusStatement.run(status, attachmentId), concurrency: ATTACH_STATUS_CONCURRENCY, timeoutMs: ATTACH_STATUS_TIMEOUT_MS, budgetMs: ATTACH_STATUS_BUDGET_MS, @@ -1081,7 +1019,6 @@ app.get('/api/projects/:id/attachments', requireAuth, async (c) => { 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') || ''); @@ -1120,7 +1057,6 @@ app.delete('/api/projects/:id/attachments/:aid', requireAuth, (c) => { 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')); @@ -1132,8 +1068,6 @@ if (clearfolioMock) { }); } -// 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')); @@ -1167,7 +1101,6 @@ app.delete('/api/projects/:id/shares/:sid', requireAuth, (c) => { 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 @@ -1177,8 +1110,6 @@ app.get('/api/shared/:token', (c) => { 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( @@ -1208,7 +1139,6 @@ app.post('/api/projects/:id/seen', requireAuth, (c) => { 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')); @@ -1221,8 +1151,6 @@ app.post('/api/projects/:id/archive', requireAuth, async (c) => { 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')); @@ -1240,10 +1168,6 @@ app.post('/api/projects/:id/duplicate', requireAuth, async (c) => { 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')); @@ -1277,9 +1201,6 @@ app.delete('/api/projects/:id/sprints/:sid', requireAuth, (c) => { 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'); @@ -1320,8 +1241,6 @@ app.delete('/api/projects/:id/baselines/:bid', requireAuth, (c) => { 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'); @@ -1334,8 +1253,6 @@ app.delete('/api/projects/:id', requireAuth, (c) => { 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); @@ -1343,7 +1260,6 @@ app.post('/api/auth/logout-all', requireAuth, (c) => { 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(() => ({})); @@ -1356,8 +1272,6 @@ app.post('/api/auth/change-password', requireAuth, async (c) => { 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(() => ({})); @@ -1367,8 +1281,8 @@ app.delete('/api/account', requireAuth, async (c) => { } 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.prepare('DELETE FROM orgs WHERE owner_id = ?').run(uid); + db.prepare('DELETE FROM users WHERE id = ?').run(uid); db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw e; } return c.json({ ok: true }); @@ -1376,8 +1290,6 @@ app.delete('/api/account', requireAuth, async (c) => { 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 = Object.assign(Object.create(null), { '/': ['index.html', 'text/html; charset=utf-8'], '/index.html': ['index.html', 'text/html; charset=utf-8'], From b3e904ce0d9ba157a7ae2f804ae414be370cee8c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:18:39 -0700 Subject: [PATCH 044/157] revert: restore exact webhook core before focused repair --- server/app_core.mjs | 166 +++++++++++++++++++++++++++++++++----------- 1 file changed, 127 insertions(+), 39 deletions(-) diff --git a/server/app_core.mjs b/server/app_core.mjs index 79592ba6..7e32031d 100644 --- a/server/app_core.mjs +++ b/server/app_core.mjs @@ -12,9 +12,6 @@ import { normalizeAttachmentStatusBudgetMs, normalizeAttachmentStatusConcurrency import { chat as orchestratorChat } from './orchestrator.mjs'; import { computeEvm } from '../analytics.js'; // pure math, shared with the client -/** Stable machine code for the authorized webhook registration URL boundary. */ -export const WEBHOOK_URL_REQUIRED_ERROR_CODE = 'webhook_url_required'; - const getOrg = (id) => db.prepare('SELECT * FROM orgs WHERE id = ?').get(id); // Append-only audit trail. Never throws into the request path. @@ -218,17 +215,15 @@ 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); - 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 }); + 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) => { @@ -281,6 +276,7 @@ app.put('/api/projects/:id', requireAuth, async (c) => { "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); @@ -291,6 +287,8 @@ app.put('/api/projects/:id', requireAuth, async (c) => { 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); @@ -332,6 +330,7 @@ app.delete('/api/projects/:id/comments/:cid', requireAuth, (c) => { 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); @@ -351,6 +350,7 @@ app.get('/api/projects/:id/revisions/:version', requireAuth, (c) => { 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'); @@ -372,6 +372,9 @@ app.post('/api/projects/:id/revisions/:version/restore', requireAuth, (c) => { 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') || ''); @@ -397,7 +400,7 @@ app.get('/api/projects/:id/calendar.ics', (c) => { 'BEGIN:VEVENT', `UID:scopeweave-${p.id}-${esc(t.id)}`, `DTSTART;VALUE=DATE:${day(t.plannedStartDate)}`, - `DTEND;VALUE=DATE:${nextDay(t.plannedEndDate)}`, + `DTEND;VALUE=DATE:${nextDay(t.plannedEndDate)}`, // DTEND is exclusive `SUMMARY:${esc(t.name || t.task || t.id)}`, 'END:VEVENT' ); @@ -410,6 +413,9 @@ app.get('/api/projects/:id/calendar.ics', (c) => { }); 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; @@ -433,6 +439,8 @@ app.get('/api/projects/:id/stream', (c) => { }); }); +// --------------------------------------------------------------- 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'); @@ -448,6 +456,7 @@ app.get('/api/orgs/:id/members', requireAuth, (c) => { 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'); @@ -459,6 +468,7 @@ app.delete('/api/orgs/:id/invites/:inviteId', requireAuth, (c) => { 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'); @@ -477,6 +487,7 @@ app.post('/api/orgs/:id/invites', requireAuth, async (c) => { 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')); @@ -494,6 +505,7 @@ app.post('/api/invites/:token/accept', requireAuth, (c) => { 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'); @@ -510,6 +522,7 @@ app.patch('/api/orgs/:id/members/:userId', requireAuth, async (c) => { 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'); @@ -523,6 +536,8 @@ app.delete('/api/orgs/:id/members/:userId', requireAuth, (c) => { 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'); @@ -534,6 +549,8 @@ app.post('/api/orgs/:id/leave', requireAuth, (c) => { 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'); @@ -553,6 +570,7 @@ app.post('/api/orgs/:id/transfer', requireAuth, async (c) => { 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'); @@ -564,6 +582,7 @@ app.patch('/api/orgs/:id', requireAuth, async (c) => { 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'); @@ -582,6 +601,8 @@ app.post('/api/orgs/:id/checkout', requireAuth, async (c) => { 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') { @@ -591,6 +612,8 @@ app.post('/api/stripe/webhook', async (c) => { 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; @@ -602,12 +625,13 @@ app.post('/api/orgs/:id/_dev/activate-pro', requireAuth, (c) => { 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 }); + return c.json({ tokens }); // never the secret or hash }); app.post('/api/tokens', requireAuth, async (c) => { @@ -616,6 +640,7 @@ app.post('/api/tokens', requireAuth, async (c) => { 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 }); }); @@ -626,6 +651,7 @@ app.delete('/api/tokens/:id', requireAuth, (c) => { 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'); @@ -639,6 +665,10 @@ app.get('/api/orgs/:id/audit', requireAuth, (c) => { ).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}`; @@ -657,6 +687,8 @@ app.get('/api/orgs/:id/audit', requireAuth, (c) => { 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'); @@ -679,20 +711,24 @@ app.get('/api/orgs/:id/export', requireAuth, (c) => { }, 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; + 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'); @@ -702,7 +738,7 @@ app.get('/api/orgs/:id/webhooks', requireAuth, (c) => { (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); + ).all(orgId); // secret never returned return c.json({ webhooks }); }); @@ -711,17 +747,12 @@ 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', - error_code: WEBHOOK_URL_REQUIRED_ERROR_CODE, - }, 400); - } + 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 }); + return c.json({ id, url, events: evs, secret }); // secret shown once for signature verification }); app.get('/api/orgs/:id/webhooks/:whId/deliveries', requireAuth, (c) => { @@ -736,6 +767,8 @@ app.get('/api/orgs/:id/webhooks/:whId/deliveries', requireAuth, (c) => { 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'); @@ -744,7 +777,7 @@ app.post('/api/orgs/:id/webhooks/:whId/rotate', requireAuth, (c) => { 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 }); + return c.json({ id: Number(c.req.param('whId')), secret }); // shown once }); app.delete('/api/orgs/:id/webhooks/:whId', requireAuth, (c) => { @@ -756,6 +789,9 @@ app.delete('/api/orgs/:id/webhooks/:whId', requireAuth, (c) => { 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, @@ -763,8 +799,8 @@ const OIDC = { redirectUri: process.env.OIDC_REDIRECT_URI, }; const oidcMock = !OIDC.issuer; -const oidcStates = new Map(); -const oidcCodes = new Map(); +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); @@ -807,6 +843,7 @@ app.get('/api/auth/oidc/start', (c) => { 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'); @@ -840,15 +877,21 @@ app.get('/api/auth/oidc/callback', async (c) => { }).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(); @@ -877,6 +920,8 @@ app.get('/api/search', requireAuth, (c) => { 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'); @@ -902,8 +947,8 @@ app.get('/api/orgs/:id/portfolio', requireAuth, (c) => { name: p.name, archived: Boolean(p.archived), tasks: tasks.length, - planned: Math.round(evm.pv * 1000) / 10, - actual: Math.round(evm.ev * 1000) / 10, + 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, @@ -914,6 +959,9 @@ app.get('/api/orgs/:id/portfolio', requireAuth, (c) => { 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')); @@ -955,13 +1003,24 @@ app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { } }); +// 산출물 첨부(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 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 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`, @@ -970,8 +1029,9 @@ 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 = ?'); - +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')); @@ -1001,6 +1061,7 @@ 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) @@ -1009,7 +1070,8 @@ app.get('/api/projects/:id/attachments', requireAuth, async (c) => { orgId: p.org_id, userId: uid, jobStatus, - updateStatus: (status, attachmentId) => updateAttachmentStatusStatement.run(status, attachmentId), + updateStatus: (status, attachmentId) => + updateAttachmentStatusStatement.run(status, attachmentId), concurrency: ATTACH_STATUS_CONCURRENCY, timeoutMs: ATTACH_STATUS_TIMEOUT_MS, budgetMs: ATTACH_STATUS_BUDGET_MS, @@ -1019,6 +1081,7 @@ app.get('/api/projects/:id/attachments', requireAuth, async (c) => { 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') || ''); @@ -1057,6 +1120,7 @@ app.delete('/api/projects/:id/attachments/:aid', requireAuth, (c) => { 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')); @@ -1068,6 +1132,8 @@ if (clearfolioMock) { }); } +// 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')); @@ -1101,6 +1167,7 @@ app.delete('/api/projects/:id/shares/:sid', requireAuth, (c) => { 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 @@ -1110,6 +1177,8 @@ app.get('/api/shared/:token', (c) => { 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( @@ -1139,6 +1208,7 @@ app.post('/api/projects/:id/seen', requireAuth, (c) => { 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')); @@ -1151,6 +1221,8 @@ app.post('/api/projects/:id/archive', requireAuth, async (c) => { 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')); @@ -1168,6 +1240,10 @@ app.post('/api/projects/:id/duplicate', requireAuth, async (c) => { 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')); @@ -1201,6 +1277,9 @@ app.delete('/api/projects/:id/sprints/:sid', requireAuth, (c) => { 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'); @@ -1241,6 +1320,8 @@ app.delete('/api/projects/:id/baselines/:bid', requireAuth, (c) => { 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'); @@ -1253,6 +1334,8 @@ app.delete('/api/projects/:id', requireAuth, (c) => { 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); @@ -1260,6 +1343,7 @@ app.post('/api/auth/logout-all', requireAuth, (c) => { 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(() => ({})); @@ -1272,6 +1356,8 @@ app.post('/api/auth/change-password', requireAuth, async (c) => { 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(() => ({})); @@ -1281,8 +1367,8 @@ app.delete('/api/account', requireAuth, async (c) => { } db.exec('BEGIN'); try { - db.prepare('DELETE FROM orgs WHERE owner_id = ?').run(uid); - db.prepare('DELETE FROM users WHERE id = ?').run(uid); + 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 }); @@ -1290,6 +1376,8 @@ app.delete('/api/account', requireAuth, async (c) => { 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 = Object.assign(Object.create(null), { '/': ['index.html', 'text/html; charset=utf-8'], '/index.html': ['index.html', 'text/html; charset=utf-8'], From d8841e2bcb08684cdeff5bbae8ff107a01e166e6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:20:13 -0700 Subject: [PATCH 045/157] test(security): decouple webhook auth probe from copy --- tests/api/review-regressions.test.mjs | 49 ++++++++++++++------------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/tests/api/review-regressions.test.mjs b/tests/api/review-regressions.test.mjs index 2e0c0417..a7f92669 100644 --- a/tests/api/review-regressions.test.mjs +++ b/tests/api/review-regressions.test.mjs @@ -17,7 +17,6 @@ globalThis.fetch = async (input, init) => { }; const { app } = await import('../../server/app.mjs'); -const { app: coreApp } = await import('../../server/app_core.mjs'); const { db } = await import('../../server/db.mjs'); const body = (value) => JSON.stringify(value); @@ -135,35 +134,39 @@ test('audit pagination rejects non-positive limits instead of expanding to the f assert.equal(events.length, 100, 'invalid negative limit falls back to the bounded default'); }); -test('webhook URL validation exposes a stable internal authorization code', async () => { - const { token, org } = await createOwner('webhook-code@scopeweave.test'); - const authorization = `Bearer ${token}`; +test('webhook authorization probing is independent from internal validation copy', async () => { + const { token, org } = await createOwner('webhook-copy@scopeweave.test'); + const target = `/api/orgs/${org.id}/webhooks`; - const coreResponse = await coreApp.request(`/api/orgs/${org.id}/webhooks`, { + const denied = await request(target, { method: 'POST', - headers: { - authorization, - 'content-type': 'application/json', - }, - body: body({ url: '', events: ['project.update'] }), - }); - assert.equal(coreResponse.status, 400); - const corePayload = await coreResponse.json(); - assert.equal( - corePayload.error_code, - 'webhook_url_required', - 'the facade must classify the authorized core validation boundary by machine code rather than customer-facing copy', - ); - - const facadeResponse = await request(`/api/orgs/${org.id}/webhooks`, { - method: 'POST', - headers: { authorization }, body: body({ url: 'http://127.0.0.1/private', events: ['project.update'] }), }); + assert.equal(denied.status, 401, 'destination validation never bypasses authentication'); + + const originalResponseJson = Response.prototype.json; + let facadeResponse; + Response.prototype.json = async function changedInternalCopy() { + const payload = await originalResponseJson.call(this); + if (payload?.error === 'valid http(s) url required') { + return { ...payload, error: 'internal webhook URL copy changed' }; + } + return payload; + }; + try { + facadeResponse = await request(target, { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + body: body({ url: 'http://127.0.0.1/private', events: ['project.update'] }), + }); + } finally { + Response.prototype.json = originalResponseJson; + } + assert.equal(facadeResponse.status, 400); assert.deepEqual( await facadeResponse.json(), { error: 'valid public https webhook URL required' }, - 'the public facade keeps its actionable destination-policy response independent from the internal authorization probe code', + 'the public policy result must not depend on presentation text from the internal authorization probe', ); }); From a796553046dcc4e737bb9dc0ddee5162c9538b7f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:31:10 -0700 Subject: [PATCH 046/157] fix(security): decouple webhook authorization probe from copy --- server/app.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 9a69d1d8..384cdaf4 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -278,14 +278,14 @@ async function coreFetchWithOidcBinding(request, rest) { * rate-limit, and request middleware before this facade returns a policy error. * The deliberately empty URL reaches the old route's own URL validation but can * never be persisted or delivered, so denied destinations do not bypass or - * reorder the authoritative authorization boundary. + * reorder the authoritative authorization boundary. A 400 is therefore the + * controlled probe's explicit "authorization passed; URL rejected" outcome; + * authentication and tenant-role failures return before that point. */ async function deniedRegistrationAuthorization(request, rest) { const probe = requestWithJson(request, { url: '' }); const response = await coreApp.fetch(probe, ...rest); - if (response.status !== 400) return response; - const payload = await response.clone().json().catch(() => null); - return payload?.error === 'valid http(s) url required' ? null : response; + return response.status === 400 ? null : response; } function canonicalRegistrationRequest(request, payload, canonicalUrl) { From 49f68a9e544b41adb834c711bb3b0a2ed3e2fbf4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:14:24 -0700 Subject: [PATCH 047/157] test(security): require stable webhook validation code --- tests/api/review-regressions.test.mjs | 33 ++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/tests/api/review-regressions.test.mjs b/tests/api/review-regressions.test.mjs index a7f92669..fd55bc11 100644 --- a/tests/api/review-regressions.test.mjs +++ b/tests/api/review-regressions.test.mjs @@ -17,6 +17,10 @@ globalThis.fetch = async (input, init) => { }; const { app } = await import('../../server/app.mjs'); +const { + app: coreApp, + WEBHOOK_URL_VALIDATION_ERROR_CODE, +} = await import('../../server/app_core.mjs'); const { db } = await import('../../server/db.mjs'); const body = (value) => JSON.stringify(value); @@ -134,7 +138,7 @@ test('audit pagination rejects non-positive limits instead of expanding to the f assert.equal(events.length, 100, 'invalid negative limit falls back to the bounded default'); }); -test('webhook authorization probing is independent from internal validation copy', async () => { +test('webhook authorization probing uses a stable validation code', async () => { const { token, org } = await createOwner('webhook-copy@scopeweave.test'); const target = `/api/orgs/${org.id}/webhooks`; @@ -144,11 +148,34 @@ test('webhook authorization probing is independent from internal validation copy }); assert.equal(denied.status, 401, 'destination validation never bypasses authentication'); + assert.equal( + WEBHOOK_URL_VALIDATION_ERROR_CODE, + 'webhook_url_invalid', + 'the internal authorization probe has a stable machine-readable error code', + ); + const internalValidation = await coreApp.request(target, { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + body: body({ url: '', events: ['project.update'] }), + }); + assert.equal(internalValidation.status, 400); + assert.deepEqual( + await internalValidation.json(), + { + error: 'valid http(s) url required', + code: WEBHOOK_URL_VALIDATION_ERROR_CODE, + }, + 'the core route distinguishes URL validation from unrelated 400 responses', + ); + const originalResponseJson = Response.prototype.json; let facadeResponse; Response.prototype.json = async function changedInternalCopy() { const payload = await originalResponseJson.call(this); - if (payload?.error === 'valid http(s) url required') { + if (payload?.code === WEBHOOK_URL_VALIDATION_ERROR_CODE) { return { ...payload, error: 'internal webhook URL copy changed' }; } return payload; @@ -167,6 +194,6 @@ test('webhook authorization probing is independent from internal validation copy assert.deepEqual( await facadeResponse.json(), { error: 'valid public https webhook URL required' }, - 'the public policy result must not depend on presentation text from the internal authorization probe', + 'the public policy result must depend on the stable code, not internal presentation text', ); }); From fe103bf41afd60011ed0cdb10d2f73c089948a40 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:17:28 -0700 Subject: [PATCH 048/157] test(security): reject arbitrary authorization-probe 400 --- tests/api/review-regressions.test.mjs | 51 ++++++--------------------- 1 file changed, 11 insertions(+), 40 deletions(-) diff --git a/tests/api/review-regressions.test.mjs b/tests/api/review-regressions.test.mjs index fd55bc11..07a05f0d 100644 --- a/tests/api/review-regressions.test.mjs +++ b/tests/api/review-regressions.test.mjs @@ -17,10 +17,7 @@ globalThis.fetch = async (input, init) => { }; const { app } = await import('../../server/app.mjs'); -const { - app: coreApp, - WEBHOOK_URL_VALIDATION_ERROR_CODE, -} = await import('../../server/app_core.mjs'); +const { app: coreApp } = await import('../../server/app_core.mjs'); const { db } = await import('../../server/db.mjs'); const body = (value) => JSON.stringify(value); @@ -138,8 +135,8 @@ test('audit pagination rejects non-positive limits instead of expanding to the f assert.equal(events.length, 100, 'invalid negative limit falls back to the bounded default'); }); -test('webhook authorization probing uses a stable validation code', async () => { - const { token, org } = await createOwner('webhook-copy@scopeweave.test'); +test('webhook authorization probing never treats an arbitrary 400 as authorization success', async () => { + const { token, org } = await createOwner('webhook-probe@scopeweave.test'); const target = `/api/orgs/${org.id}/webhooks`; const denied = await request(target, { @@ -148,38 +145,12 @@ test('webhook authorization probing uses a stable validation code', async () => }); assert.equal(denied.status, 401, 'destination validation never bypasses authentication'); - assert.equal( - WEBHOOK_URL_VALIDATION_ERROR_CODE, - 'webhook_url_invalid', - 'the internal authorization probe has a stable machine-readable error code', - ); - const internalValidation = await coreApp.request(target, { - method: 'POST', - headers: { - authorization: `Bearer ${token}`, - 'content-type': 'application/json', - }, - body: body({ url: '', events: ['project.update'] }), - }); - assert.equal(internalValidation.status, 400); - assert.deepEqual( - await internalValidation.json(), - { - error: 'valid http(s) url required', - code: WEBHOOK_URL_VALIDATION_ERROR_CODE, - }, - 'the core route distinguishes URL validation from unrelated 400 responses', - ); - - const originalResponseJson = Response.prototype.json; + const originalCoreFetch = coreApp.fetch; let facadeResponse; - Response.prototype.json = async function changedInternalCopy() { - const payload = await originalResponseJson.call(this); - if (payload?.code === WEBHOOK_URL_VALIDATION_ERROR_CODE) { - return { ...payload, error: 'internal webhook URL copy changed' }; - } - return payload; - }; + coreApp.fetch = async () => Response.json( + { error: 'unrelated controlled-probe failure' }, + { status: 400 }, + ); try { facadeResponse = await request(target, { method: 'POST', @@ -187,13 +158,13 @@ test('webhook authorization probing uses a stable validation code', async () => body: body({ url: 'http://127.0.0.1/private', events: ['project.update'] }), }); } finally { - Response.prototype.json = originalResponseJson; + coreApp.fetch = originalCoreFetch; } assert.equal(facadeResponse.status, 400); assert.deepEqual( await facadeResponse.json(), - { error: 'valid public https webhook URL required' }, - 'the public policy result must depend on the stable code, not internal presentation text', + { error: 'unrelated controlled-probe failure' }, + 'only an explicit successful authorization probe may be replaced by the public destination-policy error', ); }); From 96016795b3ac8e12dfa5a74017577e3400f5c2f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:18:31 -0700 Subject: [PATCH 049/157] fix(security): make webhook auth probe explicit --- server/app.mjs | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 384cdaf4..1e151b08 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -273,19 +273,28 @@ async function coreFetchWithOidcBinding(request, rest) { } } +function authorizationProbeRequest(request) { + const headers = new Headers(request.headers); + headers.delete('content-length'); + headers.delete('content-type'); + return new Request(request.url, { + method: 'GET', + headers, + signal: request.signal, + }); +} + /** - * Ask the existing route graph to run its real authentication, tenant-role, - * rate-limit, and request middleware before this facade returns a policy error. - * The deliberately empty URL reaches the old route's own URL validation but can - * never be persisted or delivered, so denied destinations do not bypass or - * reorder the authoritative authorization boundary. A 400 is therefore the - * controlled probe's explicit "authorization passed; URL rejected" outcome; - * authentication and tenant-role failures return before that point. + * Ask the existing read-only webhook collection route to run the same real + * authentication, tenant-role, rate-limit, and request middleware before this + * facade returns a destination-policy error. Authorized managers receive the + * collection route's explicit 200 result; every denial, rate limit, malformed + * request, or internal failure is propagated unchanged. This avoids classifying + * an arbitrary 400 from the legacy POST route as authorization success. */ async function deniedRegistrationAuthorization(request, rest) { - const probe = requestWithJson(request, { url: '' }); - const response = await coreApp.fetch(probe, ...rest); - return response.status === 400 ? null : response; + const response = await coreApp.fetch(authorizationProbeRequest(request), ...rest); + return response.status === 200 ? null : response; } function canonicalRegistrationRequest(request, payload, canonicalUrl) { From 79c746398532a23af41c4ac1ce5721a80bb04b56 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:28:12 -0700 Subject: [PATCH 050/157] test(security): authenticate before webhook body parsing --- tests/api/review-regressions.test.mjs | 31 +++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/api/review-regressions.test.mjs b/tests/api/review-regressions.test.mjs index 07a05f0d..03112b32 100644 --- a/tests/api/review-regressions.test.mjs +++ b/tests/api/review-regressions.test.mjs @@ -44,6 +44,37 @@ async function createOwner(email) { return { token, user: payload.user, org: payload.orgs[0] }; } +test('unauthenticated webhook registration rejects before consuming the request body', async () => { + let bodyPulls = 0; + const requestBody = new ReadableStream({ + pull(controller) { + bodyPulls += 1; + controller.enqueue(new TextEncoder().encode(body({ + url: 'http://127.0.0.1/private', + events: ['project.update'], + }))); + controller.close(); + }, + }, { highWaterMark: 0 }); + const unauthenticated = new Request( + 'http://localhost/api/orgs/not-authorized/webhooks', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: requestBody, + duplex: 'half', + }, + ); + + const response = await app.request(unauthenticated); + assert.equal(response.status, 401, 'authentication rejects the request'); + assert.equal( + bodyPulls, + 0, + 'the webhook payload is not parsed or buffered before authentication succeeds', + ); +}); + test('signed webhook Request inputs stay behind the SSRF destination policy', async () => { const signedRequest = new Request('https://127.0.0.1/internal', { method: 'POST', From 41d979ba56b0a80c83bd7c58ee1338fe3d7be823 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:31:06 -0700 Subject: [PATCH 051/157] test(security): bound invalid-token webhook buffering --- tests/api/review-regressions.test.mjs | 34 +++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/api/review-regressions.test.mjs b/tests/api/review-regressions.test.mjs index 03112b32..cf4e6a5d 100644 --- a/tests/api/review-regressions.test.mjs +++ b/tests/api/review-regressions.test.mjs @@ -75,6 +75,40 @@ test('unauthenticated webhook registration rejects before consuming the request ); }); +test('invalid webhook credentials cannot force unbounded pre-auth body buffering', async () => { + let bodyPulls = 0; + const chunk = new Uint8Array(8 * 1024).fill(0x20); + const requestBody = new ReadableStream({ + pull(controller) { + bodyPulls += 1; + if (bodyPulls > 20) { + controller.close(); + return; + } + controller.enqueue(chunk); + }, + }, { highWaterMark: 0 }); + const invalidCredentialRequest = new Request( + 'http://localhost/api/orgs/not-authorized/webhooks', + { + method: 'POST', + headers: { + authorization: 'Bearer definitely-not-a-valid-session', + 'content-type': 'application/json', + }, + body: requestBody, + duplex: 'half', + }, + ); + + const response = await app.request(invalidCredentialRequest); + assert.equal(response.status, 401, 'invalid credentials remain unauthorized'); + assert.ok( + bodyPulls <= 3, + `pre-auth webhook parsing must stop at the bounded request budget; observed ${bodyPulls} pulls`, + ); +}); + test('signed webhook Request inputs stay behind the SSRF destination policy', async () => { const signedRequest = new Request('https://127.0.0.1/internal', { method: 'POST', From ebcd3f68943bdf401f878ffa291e9364aee67cce Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:34:55 -0700 Subject: [PATCH 052/157] fix(security): bound pre-auth webhook body reads --- server/app.mjs | 97 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 89 insertions(+), 8 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 1e151b08..d6a84abb 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -15,6 +15,7 @@ import { const nativeFetch = globalThis.fetch.bind(globalThis); const webhookFetchBoundaryKey = Symbol.for('scopeweave.webhook-fetch-boundary'); const WEBHOOK_REGISTRATION_PATH = /^\/api\/orgs\/[^/]+\/webhooks$/; +const WEBHOOK_REGISTRATION_BODY_MAX_BYTES = 16 * 1024; const AUDIT_PATH = /^\/api\/orgs\/[^/]+\/audit$/; const AUTH_EMAIL_PATH = /^\/api\/auth\/(?:signup|login)$/; const OIDC_ISSUER = process.env.OIDC_ISSUER @@ -190,9 +191,11 @@ function requestWithJson(request, payload) { const headers = new Headers(request.headers); headers.delete('content-length'); headers.set('content-type', 'application/json'); - return new Request(request, { + return new Request(request.url, { + method: request.method, headers, body: JSON.stringify(payload), + signal: request.signal, }); } @@ -297,6 +300,60 @@ async function deniedRegistrationAuthorization(request, rest) { return response.status === 200 ? null : response; } +function declaredRegistrationBodyTooLarge(request) { + const rawLength = request.headers.get('content-length'); + if (rawLength === null) return false; + const declaredLength = Number(rawLength); + return Number.isFinite(declaredLength) + && declaredLength > WEBHOOK_REGISTRATION_BODY_MAX_BYTES; +} + +/** + * Read one webhook-registration payload with an explicit memory budget. + * + * The original request stream is consumed directly instead of cloning it: a + * cloned stream can let the unread tee branch buffer attacker-controlled data. + * Callers reconstruct the small JSON request only after this bounded read. + */ +async function readBoundedRegistrationJson(request) { + if (!request.body) return { payload: {}, tooLarge: false }; + const reader = request.body.getReader(); + const chunks = []; + let totalBytes = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + const chunk = value instanceof Uint8Array ? value : new Uint8Array(value); + totalBytes += chunk.byteLength; + if (totalBytes > WEBHOOK_REGISTRATION_BODY_MAX_BYTES) { + await reader.cancel().catch(() => {}); + return { payload: {}, tooLarge: true }; + } + chunks.push(chunk); + } + } catch { + return { payload: {}, tooLarge: false }; + } finally { + try { reader.releaseLock(); } catch { /* already released/cancelled */ } + } + + const bytes = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return { + payload: JSON.parse(new TextDecoder().decode(bytes)), + tooLarge: false, + }; + } catch { + return { payload: {}, tooLarge: false }; + } +} + function canonicalRegistrationRequest(request, payload, canonicalUrl) { return requestWithJson(request, { ...payload, url: canonicalUrl }); } @@ -306,21 +363,45 @@ async function registrationPolicyResult(request, rest) { if (request.method !== 'POST' || !WEBHOOK_REGISTRATION_PATH.test(url.pathname)) { return null; } - const payload = await request.clone().json().catch(() => ({})); + + // Requests with no credential material can be rejected by the core + // requireAuth middleware without touching their body at all. Credential-bearing + // requests may still be forged, so any pre-auth policy read remains bounded. + if (!request.headers.get('authorization')) return { request }; + + let payload; + let tooLarge = declaredRegistrationBodyTooLarge(request); + if (!tooLarge) { + const parsed = await readBoundedRegistrationJson(request); + payload = parsed.payload; + tooLarge = parsed.tooLarge; + } + + if (tooLarge) { + const authorization = await deniedRegistrationAuthorization(request, rest); + if (authorization) return { response: authorization }; + return { + response: Response.json( + { error: 'webhook registration body too large' }, + { status: 413 }, + ), + }; + } + try { - const canonicalUrl = validateWebhookRegistrationUrl(payload.url); - return canonicalUrl === payload.url - ? { request } - : { request: canonicalRegistrationRequest(request, payload, canonicalUrl) }; + const canonicalUrl = validateWebhookRegistrationUrl(payload?.url); + return { + request: canonicalRegistrationRequest(request, payload, canonicalUrl), + }; } catch (error) { // Preserve the existing dev-only localhost failure-path smoke fixture. The // outbound transport still refuses HTTP, so this exception cannot create a // server-side connection and production never inherits it. if ( error instanceof WebhookDestinationError - && isDevelopmentLoopbackHttp(payload.url) + && isDevelopmentLoopbackHttp(payload?.url) ) { - return { request }; + return { request: requestWithJson(request, payload) }; } const authorization = await deniedRegistrationAuthorization(request, rest); if (authorization) return { response: authorization }; From 612c0b9debe89fe8a1d3902850f6ace63eb0fc89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 07:55:00 -0700 Subject: [PATCH 053/157] fix(security): preserve unauthenticated request streams --- server/app.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index d6a84abb..128cf7b6 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -423,7 +423,7 @@ async function secureFetch(request, ...rest) { async function secureRequest(input, init, ...rest) { const request = input instanceof Request - ? new Request(input, init) + ? (init === undefined ? input : new Request(input, init)) : new Request(new URL(String(input), 'http://localhost'), init); return secureFetch(request, ...rest); } @@ -438,4 +438,4 @@ export const app = new Proxy(coreApp, { const value = Reflect.get(target, property, target); return typeof value === 'function' ? value.bind(target) : value; }, -}); +}); \ No newline at end of file From 88891edeca58a0b226d6e63cea492e16eeb96f92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 08:48:09 -0700 Subject: [PATCH 054/157] test(oidc): require discovered provider endpoints --- tests/api/oidc-timeout.test.mjs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/api/oidc-timeout.test.mjs b/tests/api/oidc-timeout.test.mjs index 19f05b46..38e6f004 100644 --- a/tests/api/oidc-timeout.test.mjs +++ b/tests/api/oidc-timeout.test.mjs @@ -10,6 +10,9 @@ process.env.OIDC_REDIRECT_URI = 'http://localhost/api/auth/oidc/callback'; const issuer = process.env.OIDC_ISSUER; const clientId = process.env.OIDC_CLIENT_ID; +const authorizationEndpoint = 'https://login.example.test/oauth2/authorize'; +const tokenEndpoint = 'https://tokens.example.test/oauth2/token'; +const jwksEndpoint = 'https://keys.example.test/jwks'; const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); const publicJwk = { ...publicKey.export({ format: 'jwk' }), @@ -49,11 +52,13 @@ globalThis.fetch = async (input, init) => { ); return Response.json({ issuer, - jwks_uri: `${issuer}/jwks`, + authorization_endpoint: authorizationEndpoint, + token_endpoint: tokenEndpoint, + jwks_uri: jwksEndpoint, id_token_signing_alg_values_supported: ['RS256'], }); } - if (url === `${issuer}/jwks`) { + if (url === jwksEndpoint) { assert.equal( request.redirect, 'error', @@ -61,7 +66,7 @@ globalThis.fetch = async (input, init) => { ); return Response.json({ keys: [publicJwk] }); } - if (url !== `${issuer}/token`) { + if (url !== tokenEndpoint) { throw new Error(`unexpected outbound fetch: ${url}`); } assert.equal( @@ -118,6 +123,11 @@ try { const location = start.headers.get('location'); assert.ok(location, 'authorization redirect is present'); const authorization = new URL(location); + assert.equal( + `${authorization.origin}${authorization.pathname}`, + authorizationEndpoint, + 'OIDC authorization uses the provider-discovered authorization endpoint rather than guessing an issuer-relative path', + ); const state = authorization.searchParams.get('state'); const nonce = authorization.searchParams.get('nonce'); assert.ok(state, 'authorization redirect carries state'); @@ -170,4 +180,4 @@ try { globalThis.fetch = originalFetch; } -console.log('oidc validation, cancellation, redirect, and timeout regression passed'); +console.log('oidc discovery, validation, cancellation, redirect, and timeout regression passed'); From 366d732098f9e3a82b4055fa94177094b69e7b32 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 08:51:38 -0700 Subject: [PATCH 055/157] fix(oidc): honor discovered provider endpoints --- server/app.mjs | 85 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 69 insertions(+), 16 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 128cf7b6..f9d0db03 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -24,10 +24,12 @@ const OIDC_ISSUER = process.env.OIDC_ISSUER const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID || ''; const OIDC_TOKEN_URL = OIDC_ISSUER ? `${OIDC_ISSUER}/token` : null; const OIDC_TOKEN_TIMEOUT_MS = 3000; +const OIDC_DISCOVERY_TTL_MS = 60 * 1000; const OIDC_STATE_TTL_MS = 5 * 60 * 1000; const OIDC_CLOCK_SKEW_SECONDS = 60; const oidcNonceByState = new Map(); const oidcNonceByCode = new Map(); +let oidcDiscoveryCache = null; function isSignedWebhookRequest(request) { if (request.method.toUpperCase() !== 'POST') return false; @@ -88,7 +90,39 @@ async function oidcProviderJson(url) { return payload; } -async function verifyOidcIdToken(idToken, expectedNonce) { +function validateOidcEndpoint(value, label) { + let endpoint; + try { + endpoint = new URL(String(value || '')); + } catch { + throw new Error(`invalid ${label}`); + } + if ( + (endpoint.protocol !== 'https:' && !isDevelopmentLoopbackHttp(endpoint)) + || endpoint.username + || endpoint.password + || endpoint.hash + ) throw new Error(`invalid ${label}`); + return endpoint.toString(); +} + +async function loadOidcDiscovery(now = Date.now()) { + if (oidcDiscoveryCache && oidcDiscoveryCache.expiresAt > now) { + return oidcDiscoveryCache.value; + } + const discovery = await oidcProviderJson(`${OIDC_ISSUER}/.well-known/openid-configuration`); + if (discovery.issuer !== OIDC_ISSUER) throw new Error('invalid discovery'); + const value = Object.freeze({ + ...discovery, + authorization_endpoint: validateOidcEndpoint(discovery.authorization_endpoint, 'authorization endpoint'), + token_endpoint: validateOidcEndpoint(discovery.token_endpoint, 'token endpoint'), + jwks_uri: validateOidcEndpoint(discovery.jwks_uri, 'jwks url'), + }); + oidcDiscoveryCache = { value, expiresAt: now + OIDC_DISCOVERY_TTL_MS }; + return value; +} + +async function verifyOidcIdToken(idToken, expectedNonce, discovery) { const parts = String(idToken || '').split('.'); if (parts.length !== 3) throw new Error('invalid token'); const [encodedHeader, encodedClaims, encodedSignature] = parts; @@ -96,11 +130,7 @@ async function verifyOidcIdToken(idToken, expectedNonce) { const claims = parseJwtObject(encodedClaims); if (header.alg !== 'RS256' || typeof header.kid !== 'string' || !header.kid) throw new Error('invalid algorithm'); - const discovery = await oidcProviderJson(`${OIDC_ISSUER}/.well-known/openid-configuration`); - if (discovery.issuer !== OIDC_ISSUER || typeof discovery.jwks_uri !== 'string') throw new Error('invalid discovery'); - const jwksUrl = new URL(discovery.jwks_uri); - if (jwksUrl.protocol !== 'https:' || jwksUrl.username || jwksUrl.password || jwksUrl.hash) throw new Error('invalid jwks url'); - const jwks = await oidcProviderJson(jwksUrl); + const jwks = await oidcProviderJson(discovery.jwks_uri); const keyData = Array.isArray(jwks.keys) ? jwks.keys.find((candidate) => ( candidate @@ -128,22 +158,29 @@ async function verifyOidcIdToken(idToken, expectedNonce) { } async function boundedOidcFetch(request) { - const form = new URLSearchParams(await request.clone().text()); + const body = await request.clone().arrayBuffer(); + const form = new URLSearchParams(new TextDecoder().decode(body)); const code = form.get('code'); const expectedNonce = code ? oidcNonceByCode.get(code) : null; if (!expectedNonce || expectedNonce.exp < Date.now()) throw new Error('OIDC flow binding unavailable'); + const discovery = await loadOidcDiscovery(); const signal = AbortSignal.any([ request.signal, expectedNonce.callbackSignal, AbortSignal.timeout(OIDC_TOKEN_TIMEOUT_MS), ]); - const response = await nativeFetch(new Request(request, { + const headers = new Headers(request.headers); + headers.delete('content-length'); + const response = await nativeFetch(new Request(discovery.token_endpoint, { + method: request.method, + headers, + body, redirect: 'error', signal, })); if (!response.ok) return response; const tokenPayload = await response.clone().json(); - await verifyOidcIdToken(tokenPayload.id_token, expectedNonce.nonce); + await verifyOidcIdToken(tokenPayload.id_token, expectedNonce.nonce, discovery); return response; } @@ -238,12 +275,16 @@ function cleanupOidcNonces(now = Date.now()) { } } -function bindOidcStartNonce(request, response) { +function bindOidcStartNonce(request, response, discovery) { if (!OIDC_ISSUER || request.method !== 'GET' || new URL(request.url).pathname !== '/api/auth/oidc/start') return response; if (response.status !== 302) return response; const location = response.headers.get('location'); if (!location) return response; - const authorization = new URL(location); + const generatedAuthorization = new URL(location); + const authorization = new URL(discovery.authorization_endpoint); + for (const [name, value] of generatedAuthorization.searchParams.entries()) { + authorization.searchParams.set(name, value); + } const state = authorization.searchParams.get('state'); if (!state) return response; cleanupOidcNonces(); @@ -256,14 +297,26 @@ function bindOidcStartNonce(request, response) { } async function coreFetchWithOidcBinding(request, rest) { - if (!OIDC_ISSUER || request.method !== 'GET' || new URL(request.url).pathname !== '/api/auth/oidc/callback') { + if (!OIDC_ISSUER || request.method !== 'GET') { + return coreApp.fetch(request, ...rest); + } + const requestUrl = new URL(request.url); + if (requestUrl.pathname === '/api/auth/oidc/start') { + let discovery; + try { + discovery = await loadOidcDiscovery(); + } catch { + return Response.json({ error: 'OIDC provider unavailable' }, { status: 502 }); + } const response = await coreApp.fetch(request, ...rest); - return bindOidcStartNonce(request, response); + return bindOidcStartNonce(request, response, discovery); + } + if (requestUrl.pathname !== '/api/auth/oidc/callback') { + return coreApp.fetch(request, ...rest); } - const url = new URL(request.url); - const state = url.searchParams.get('state'); - const code = url.searchParams.get('code'); + const state = requestUrl.searchParams.get('state'); + const code = requestUrl.searchParams.get('code'); const record = state ? oidcNonceByState.get(state) : null; if (state) oidcNonceByState.delete(state); if (code && record && record.exp >= Date.now()) { From bd97e40fbc9a514151673fd9d0fd1a4fc42c98c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 09:57:41 -0700 Subject: [PATCH 056/157] test(oidc): reproduce metadata-driven private endpoint SSRF --- tests/api/oidc-timeout.test.mjs | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/tests/api/oidc-timeout.test.mjs b/tests/api/oidc-timeout.test.mjs index 38e6f004..08229f31 100644 --- a/tests/api/oidc-timeout.test.mjs +++ b/tests/api/oidc-timeout.test.mjs @@ -3,16 +3,17 @@ import { createSign, generateKeyPairSync } from 'node:crypto'; process.env.SCOPEWEAVE_DB = ':memory:'; process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; -process.env.OIDC_ISSUER = 'https://idp.example.test'; +process.env.SCOPEWEAVE_DEV = '1'; +process.env.OIDC_ISSUER = 'http://127.0.0.1:19001'; process.env.OIDC_CLIENT_ID = 'scopeweave-test'; process.env.OIDC_CLIENT_SECRET = 'scopeweave-secret'; process.env.OIDC_REDIRECT_URI = 'http://localhost/api/auth/oidc/callback'; const issuer = process.env.OIDC_ISSUER; const clientId = process.env.OIDC_CLIENT_ID; -const authorizationEndpoint = 'https://login.example.test/oauth2/authorize'; -const tokenEndpoint = 'https://tokens.example.test/oauth2/token'; -const jwksEndpoint = 'https://keys.example.test/jwks'; +const authorizationEndpoint = 'http://127.0.0.1:19002/oauth2/authorize'; +const tokenEndpoint = 'http://127.0.0.1:19003/oauth2/token'; +const jwksEndpoint = 'http://127.0.0.1:19004/jwks'; const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); const publicJwk = { ...publicKey.export({ format: 'jwk' }), @@ -21,6 +22,7 @@ const publicJwk = { use: 'sig', }; const expectedNonceByCode = new Map(); +let discoveryMode = 'private-metadata'; let observedTimeout = null; let callbackAbortController = null; let observedUpstreamAbort = null; @@ -50,6 +52,15 @@ globalThis.fetch = async (input, init) => { 'error', 'OIDC discovery must reject redirects instead of following provider-controlled locations', ); + if (discoveryMode === 'private-metadata') { + return Response.json({ + issuer, + authorization_endpoint: authorizationEndpoint, + token_endpoint: 'https://127.0.0.1/internal-token', + jwks_uri: 'https://[::1]/internal-jwks', + id_token_signing_alg_values_supported: ['RS256'], + }); + } return Response.json({ issuer, authorization_endpoint: authorizationEndpoint, @@ -117,6 +128,14 @@ globalThis.fetch = async (input, init) => { try { const { app } = await import('../../server/app.mjs'); + const unsafeMetadata = await app.request('/api/auth/oidc/start'); + assert.equal( + unsafeMetadata.status, + 502, + 'OIDC discovery metadata cannot redirect server-side token or JWKS requests to private HTTPS addresses', + ); + discoveryMode = 'valid'; + const startFlow = async (code) => { const start = await app.request('/api/auth/oidc/start'); assert.equal(start.status, 302, 'OIDC authorization flow starts'); @@ -178,6 +197,7 @@ try { } finally { AbortSignal.timeout = originalTimeout; globalThis.fetch = originalFetch; + delete process.env.SCOPEWEAVE_DEV; } -console.log('oidc discovery, validation, cancellation, redirect, and timeout regression passed'); +console.log('oidc discovery, private-endpoint, validation, cancellation, redirect, and timeout regression passed'); From 451267a9bb872a2ac783d250b1746029dfc2c1cb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 09:59:06 -0700 Subject: [PATCH 057/157] feat(security): expose DNS-pinned public HTTPS transport --- server/webhook_transport.mjs | 166 +++++++++++++++++++++++++++++++++-- 1 file changed, 159 insertions(+), 7 deletions(-) diff --git a/server/webhook_transport.mjs b/server/webhook_transport.mjs index 93dac961..71d53b39 100644 --- a/server/webhook_transport.mjs +++ b/server/webhook_transport.mjs @@ -5,6 +5,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 PUBLIC_HTTPS_RESPONSE_MAX_BYTES = 1024 * 1024; PUBLIC_IPV6_UNICAST.addSubnet('2000::', 3, 'ipv6'); for (const [address, prefix, family] of [ ['0.0.0.0', 8, 'ipv4'], @@ -183,22 +184,27 @@ function pinnedLookup(address, family) { }; } +function pinnedRequestOptions(destination, candidate, options = {}) { + const tlsHost = hostAddress(destination.hostname); + return { + ...options, + agent: false, + lookup: pinnedLookup(candidate.address, candidate.family), + ...(isIP(tlsHost) ? {} : { servername: tlsHost }), + }; +} + async function postToCandidate(destination, candidate, { headers, body, signal }, request) { if (signal?.aborted) throw new WebhookTransportError(); - const { address, family } = candidate; - const tlsHost = hostAddress(destination.hostname); try { return await withAbort(new Promise((resolve, reject) => { let req; try { - req = request(destination, { + req = request(destination, pinnedRequestOptions(destination, candidate, { method: 'POST', headers, signal, - agent: false, - lookup: pinnedLookup(address, family), - ...(isIP(tlsHost) ? {} : { servername: tlsHost }), - }, (response) => { + }), (response) => { response.resume?.(); const status = Number(response.statusCode) || 0; resolve({ status, ok: status >= 200 && status < 300 }); @@ -216,6 +222,148 @@ async function postToCandidate(destination, candidate, { headers, body, signal } } } +function appendResponseHeaders(target, source) { + for (const [name, value] of Object.entries(source || {})) { + if (Array.isArray(value)) { + for (const item of value) target.append(name, String(item)); + } else if (value !== undefined) { + target.append(name, String(value)); + } + } +} + +async function fetchFromCandidate( + destination, + candidate, + { method, headers, body, signal, maxResponseBytes }, + request, +) { + if (signal?.aborted) throw new WebhookTransportError(); + try { + return await withAbort(new Promise((resolve, reject) => { + let settled = false; + const fail = () => { + if (settled) return; + settled = true; + reject(new WebhookTransportError()); + }; + let req; + try { + req = request(destination, pinnedRequestOptions(destination, candidate, { + method, + headers, + signal, + }), (response) => { + const chunks = []; + let totalBytes = 0; + response.on?.('data', (chunk) => { + if (settled) return; + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + totalBytes += bytes.byteLength; + if (totalBytes > maxResponseBytes) { + response.destroy?.(); + fail(); + return; + } + chunks.push(bytes); + }); + response.once?.('error', fail); + response.once?.('end', () => { + if (settled) return; + const status = Number(response.statusCode) || 0; + if (status < 200 || status > 599) { + fail(); + return; + } + const responseHeaders = new Headers(); + appendResponseHeaders(responseHeaders, response.headers); + const responseBody = chunks.length ? Buffer.concat(chunks) : null; + try { + settled = true; + resolve(new Response(responseBody, { status, headers: responseHeaders })); + } catch { + fail(); + } + }); + }); + } catch { + fail(); + return; + } + req.once?.('error', fail); + if (body === undefined || body === null) { + req.end(); + } else if (typeof body === 'string' || Buffer.isBuffer(body) || body instanceof Uint8Array) { + req.end(body); + } else if (body instanceof ArrayBuffer) { + req.end(new Uint8Array(body)); + } else { + fail(); + } + }), signal); + } catch (error) { + if (error instanceof WebhookTransportError) throw error; + throw new WebhookTransportError(); + } +} + +/** + * Build a bounded public-HTTPS fetch transport for server-side metadata flows. + * Each request resolves DNS afresh, fails closed if any answer is non-public, + * pins every socket to a validated candidate, preserves the original TLS SNI, + * disables pooling, never follows redirects, and bounds response buffering. + */ +export function createPublicHttpsTransport({ lookup = dnsLookup, request = httpsRequest } = {}) { + if (typeof lookup !== 'function' || typeof request !== 'function') { + throw new TypeError('public HTTPS transport dependencies must be functions'); + } + + return Object.freeze({ + async fetch(url, { + method = 'GET', + headers = {}, + body, + signal, + maxResponseBytes = PUBLIC_HTTPS_RESPONSE_MAX_BYTES, + } = {}) { + let destination; + try { + destination = new URL(validateWebhookRegistrationUrl(url)); + } catch (error) { + if (error instanceof WebhookDestinationError) throw error; + throw new WebhookDestinationError(); + } + if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes <= 0) { + throw new TypeError('maxResponseBytes must be a positive safe integer'); + } + + const candidates = await resolvePublicAddresses(destination, lookup, signal); + let lastError; + for (const candidate of candidates) { + try { + return await fetchFromCandidate( + destination, + candidate, + { + method: String(method || 'GET').toUpperCase(), + headers, + body, + signal, + maxResponseBytes, + }, + request, + ); + } catch (error) { + if (!(error instanceof WebhookTransportError)) throw error; + lastError = error; + if (signal?.aborted) throw error; + } + } + throw lastError || new WebhookTransportError(); + }, + }); +} + /** * Build the outbound webhook transport around injectable DNS and HTTPS seams. * Every post resolves afresh, rejects mixed/private answers, pins each socket to @@ -261,7 +409,11 @@ export function createWebhookTransport({ lookup = dnsLookup, request = httpsRequ }); } +const publicHttpsTransport = createPublicHttpsTransport(); const webhookTransport = createWebhookTransport(); +/** Fetch one bounded response through the production public-HTTPS transport. */ +export const fetchPublicHttps = (url, options) => publicHttpsTransport.fetch(url, options); + /** Send one signed webhook attempt through the production SSRF-safe transport. */ export const postWebhook = (url, options) => webhookTransport.post(url, options); From 17eb13e242e867308e4c92b19a7b85d097e101fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:00:42 -0700 Subject: [PATCH 058/157] fix(oidc): pin metadata-driven HTTPS requests to public DNS --- server/app.mjs | 73 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 49 insertions(+), 24 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index f9d0db03..960059f6 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -8,6 +8,7 @@ import { createPublicKey, randomBytes, verify as verifySignature } from 'node:cr import { app as coreApp } from './app_core.mjs'; import { WebhookDestinationError, + fetchPublicHttps, postWebhook, validateWebhookRegistrationUrl, } from './webhook_transport.mjs'; @@ -79,17 +80,6 @@ function audienceMatches(claims) { : claims.azp === OIDC_CLIENT_ID; } -async function oidcProviderJson(url) { - const response = await nativeFetch(new Request(url, { - redirect: 'error', - signal: AbortSignal.timeout(OIDC_TOKEN_TIMEOUT_MS), - })); - if (!response.ok) throw new Error('provider unavailable'); - const payload = await response.json(); - if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('invalid provider response'); - return payload; -} - function validateOidcEndpoint(value, label) { let endpoint; try { @@ -97,13 +87,49 @@ function validateOidcEndpoint(value, label) { } catch { throw new Error(`invalid ${label}`); } - if ( - (endpoint.protocol !== 'https:' && !isDevelopmentLoopbackHttp(endpoint)) - || endpoint.username - || endpoint.password - || endpoint.hash - ) throw new Error(`invalid ${label}`); - return endpoint.toString(); + if (endpoint.username || endpoint.password || endpoint.hash || !endpoint.hostname) { + throw new Error(`invalid ${label}`); + } + if (isDevelopmentLoopbackHttp(endpoint)) return endpoint.toString(); + if (endpoint.protocol !== 'https:') throw new Error(`invalid ${label}`); + try { + return validateWebhookRegistrationUrl(endpoint.toString()); + } catch { + throw new Error(`invalid ${label}`); + } +} + +async function fetchOidcEndpoint( + value, + label, + { method = 'GET', headers = {}, body, signal } = {}, +) { + const endpoint = validateOidcEndpoint(value, label); + if (isDevelopmentLoopbackHttp(endpoint)) { + return nativeFetch(new Request(endpoint, { + method, + headers, + body, + redirect: 'error', + signal, + })); + } + return fetchPublicHttps(endpoint, { + method, + headers, + body, + signal, + }); +} + +async function oidcProviderJson(url) { + const response = await fetchOidcEndpoint(url, 'provider endpoint', { + signal: AbortSignal.timeout(OIDC_TOKEN_TIMEOUT_MS), + }); + if (!response.ok) throw new Error('provider unavailable'); + const payload = await response.json(); + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('invalid provider response'); + return payload; } async function loadOidcDiscovery(now = Date.now()) { @@ -171,13 +197,12 @@ async function boundedOidcFetch(request) { ]); const headers = new Headers(request.headers); headers.delete('content-length'); - const response = await nativeFetch(new Request(discovery.token_endpoint, { + const response = await fetchOidcEndpoint(discovery.token_endpoint, 'token endpoint', { method: request.method, - headers, - body, - redirect: 'error', + headers: Object.fromEntries(headers.entries()), + body: new Uint8Array(body), signal, - })); + }); if (!response.ok) return response; const tokenPayload = await response.clone().json(); await verifyOidcIdToken(tokenPayload.id_token, expectedNonce.nonce, discovery); @@ -491,4 +516,4 @@ export const app = new Proxy(coreApp, { const value = Reflect.get(target, property, target); return typeof value === 'function' ? value.bind(target) : value; }, -}); \ No newline at end of file +}); From b687055f5d662e2794418f332033027b2dd5f529 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:01:18 -0700 Subject: [PATCH 059/157] test(security): cover reusable public HTTPS transport --- tests/unit/public-https-transport.test.mjs | 111 +++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 tests/unit/public-https-transport.test.mjs diff --git a/tests/unit/public-https-transport.test.mjs b/tests/unit/public-https-transport.test.mjs new file mode 100644 index 00000000..6df515c7 --- /dev/null +++ b/tests/unit/public-https-transport.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { + WebhookDestinationError, + WebhookTransportError, + createPublicHttpsTransport, +} from '../../server/webhook_transport.mjs'; + +const PUBLIC_A = { address: '93.184.216.34', family: 4 }; +const PUBLIC_B = { address: '93.184.216.35', family: 4 }; + +await assert.rejects( + createPublicHttpsTransport({ + lookup: async () => [PUBLIC_A, { address: '10.0.0.8', family: 4 }], + request: () => { + throw new Error('request must not run after mixed public/private DNS'); + }, + }).fetch('https://idp.example.test/.well-known/openid-configuration'), + WebhookDestinationError, + 'metadata transport fails closed when any current DNS answer is private', +); + +const attempts = []; +const transport = createPublicHttpsTransport({ + lookup: async () => [PUBLIC_A, PUBLIC_B], + request: (_url, options, callback) => { + const req = new EventEmitter(); + req.end = () => { + options.lookup('ignored.example', {}, (error, address, family) => { + assert.ifError(error); + attempts.push({ + address, + family, + agent: options.agent, + servername: options.servername, + method: options.method, + }); + if (address === PUBLIC_A.address) { + queueMicrotask(() => req.emit('error', new Error('simulated first-address failure'))); + return; + } + const response = new EventEmitter(); + response.statusCode = 200; + response.headers = { 'content-type': 'application/json' }; + response.destroy = () => {}; + callback(response); + queueMicrotask(() => { + response.emit('data', Buffer.from('{"issuer":"https://idp.example.test"}')); + response.emit('end'); + }); + }); + }; + return req; + }, +}); + +const response = await transport.fetch( + 'https://idp.example.test/.well-known/openid-configuration', +); +assert.equal(response.status, 200); +assert.deepEqual(await response.json(), { issuer: 'https://idp.example.test' }); +assert.deepEqual( + attempts, + [ + { + ...PUBLIC_A, + agent: false, + servername: 'idp.example.test', + method: 'GET', + }, + { + ...PUBLIC_B, + agent: false, + servername: 'idp.example.test', + method: 'GET', + }, + ], + 'every fallback attempt is pinned to a validated address with pooling disabled and original SNI preserved', +); + +const oversized = createPublicHttpsTransport({ + lookup: async () => [PUBLIC_A], + request: (_url, _options, callback) => { + const req = new EventEmitter(); + req.end = () => { + const response = new EventEmitter(); + response.statusCode = 200; + response.headers = {}; + response.destroy = () => {}; + callback(response); + queueMicrotask(() => response.emit('data', Buffer.alloc(9))); + }; + return req; + }, +}); +await assert.rejects( + oversized.fetch('https://idp.example.test/jwks', { maxResponseBytes: 8 }), + WebhookTransportError, + 'provider responses larger than the configured memory budget fail closed', +); + +await assert.rejects( + transport.fetch('https://idp.example.test/jwks', { maxResponseBytes: 0 }), + /positive safe integer/, +); +assert.throws( + () => createPublicHttpsTransport({ lookup: null }), + /dependencies must be functions/, +); + +console.log('public HTTPS DNS pinning, fallback, and response-bound regressions passed'); From 09799dac90002b2fafb933dda8f116bd20301e30 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:06:19 -0700 Subject: [PATCH 060/157] test(auth): reproduce production mock OIDC exposure --- tests/api/oidc-production-boundary.test.mjs | 38 +++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 tests/api/oidc-production-boundary.test.mjs diff --git a/tests/api/oidc-production-boundary.test.mjs b/tests/api/oidc-production-boundary.test.mjs new file mode 100644 index 00000000..0faa61b9 --- /dev/null +++ b/tests/api/oidc-production-boundary.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +delete process.env.SCOPEWEAVE_DEV; +delete process.env.OIDC_ISSUER; +delete process.env.OIDC_CLIENT_ID; +delete process.env.OIDC_CLIENT_SECRET; +delete process.env.OIDC_REDIRECT_URI; + +const { app } = await import('../../server/app.mjs'); + +const start = await app.request('/api/auth/oidc/start?email=attacker@example.test'); +assert.equal( + start.status, + 503, + 'an unconfigured production deployment must not expose the built-in mock identity provider', +); +assert.deepEqual(await start.json(), { error: 'OIDC not configured' }); + +const mock = await app.request( + '/api/auth/oidc/mock/authorize?state=attacker&email=attacker@example.test&redirect_uri=http://localhost/api/auth/oidc/callback', +); +assert.equal( + mock.status, + 404, + 'the mock authorization endpoint is inaccessible unless explicit development mode is enabled', +); + +const callback = await app.request('/api/auth/oidc/callback?state=attacker&code=attacker'); +assert.equal( + callback.status, + 503, + 'an unconfigured production callback cannot enter the mock-session path', +); +assert.deepEqual(await callback.json(), { error: 'OIDC not configured' }); + +console.log('production OIDC fail-closed boundary regression passed'); From 17419b2ed8671c114e8c69449430bc17e79cc01e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:30:20 -0700 Subject: [PATCH 061/157] test(security): bound OIDC state capacity --- tests/api/oidc-timeout.test.mjs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/api/oidc-timeout.test.mjs b/tests/api/oidc-timeout.test.mjs index 08229f31..67cefaf1 100644 --- a/tests/api/oidc-timeout.test.mjs +++ b/tests/api/oidc-timeout.test.mjs @@ -194,10 +194,30 @@ try { true, 'OIDC token exchange preserves callback cancellation while retaining its timeout budget', ); + + for (let index = 0; index < 256; index += 1) { + const pending = await app.request('/api/auth/oidc/start'); + assert.equal( + pending.status, + 302, + 'OIDC state capacity must admit flows until the bounded in-memory state budget is full', + ); + } + const saturated = await app.request('/api/auth/oidc/start'); + assert.equal( + saturated.status, + 503, + 'OIDC state capacity must fail closed instead of allowing unbounded in-memory growth', + ); + assert.deepEqual( + await saturated.json(), + { error: 'OIDC temporarily unavailable' }, + 'capacity exhaustion returns a stable non-secret degraded-mode response', + ); } finally { AbortSignal.timeout = originalTimeout; globalThis.fetch = originalFetch; delete process.env.SCOPEWEAVE_DEV; } -console.log('oidc discovery, private-endpoint, validation, cancellation, redirect, and timeout regression passed'); +console.log('oidc discovery, private-endpoint, validation, cancellation, redirect, timeout, and state-capacity regression passed'); \ No newline at end of file From 3b7fc685370ac94537c86552fd1597594123042f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:32:24 -0700 Subject: [PATCH 062/157] fix(security): cap pending OIDC state entries --- server/app.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/server/app.mjs b/server/app.mjs index 960059f6..f815fb0a 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -27,6 +27,7 @@ const OIDC_TOKEN_URL = OIDC_ISSUER ? `${OIDC_ISSUER}/token` : null; const OIDC_TOKEN_TIMEOUT_MS = 3000; const OIDC_DISCOVERY_TTL_MS = 60 * 1000; const OIDC_STATE_TTL_MS = 5 * 60 * 1000; +const OIDC_STATE_MAX_ENTRIES = 256; const OIDC_CLOCK_SKEW_SECONDS = 60; const oidcNonceByState = new Map(); const oidcNonceByCode = new Map(); @@ -313,6 +314,12 @@ function bindOidcStartNonce(request, response, discovery) { const state = authorization.searchParams.get('state'); if (!state) return response; cleanupOidcNonces(); + if (oidcNonceByState.size >= OIDC_STATE_MAX_ENTRIES) { + return Response.json( + { error: 'OIDC temporarily unavailable' }, + { status: 503 }, + ); + } const nonce = randomBytes(16).toString('base64url'); oidcNonceByState.set(state, { nonce, exp: Date.now() + OIDC_STATE_TTL_MS }); authorization.searchParams.set('nonce', nonce); @@ -516,4 +523,4 @@ export const app = new Proxy(coreApp, { const value = Reflect.get(target, property, target); return typeof value === 'function' ? value.bind(target) : value; }, -}); +}); \ No newline at end of file From 9ccfb75c628a278fa0b23dab73cf19c9042b5a8f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:35:27 -0700 Subject: [PATCH 063/157] test(security): expose core OIDC state growth --- tests/api/oidc-state-capacity.test.mjs | 50 ++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 tests/api/oidc-state-capacity.test.mjs diff --git a/tests/api/oidc-state-capacity.test.mjs b/tests/api/oidc-state-capacity.test.mjs new file mode 100644 index 00000000..e80bcdca --- /dev/null +++ b/tests/api/oidc-state-capacity.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.OIDC_ISSUER = 'https://idp.example.test'; +process.env.OIDC_CLIENT_ID = 'scopeweave-capacity-test'; +process.env.OIDC_CLIENT_SECRET = 'scopeweave-capacity-secret'; +process.env.OIDC_REDIRECT_URI = 'http://localhost/api/auth/oidc/callback'; +delete process.env.SCOPEWEAVE_DEV; + +const originalNow = Date.now; +let now = 1_800_000_000_000; +Date.now = () => now; + +try { + const { app } = await import('../../server/app_core.mjs'); + + for (let index = 0; index < 256; index += 1) { + const response = await app.request('/api/auth/oidc/start'); + assert.equal( + response.status, + 302, + 'the core OIDC state store admits flows until its bounded capacity is full', + ); + } + + const saturated = await app.request('/api/auth/oidc/start'); + assert.equal( + saturated.status, + 503, + 'the core OIDC state store fails closed instead of growing without bound', + ); + assert.deepEqual( + await saturated.json(), + { error: 'OIDC temporarily unavailable' }, + 'capacity exhaustion returns a stable non-secret response', + ); + + now += (5 * 60 * 1000) + 1; + const afterExpiry = await app.request('/api/auth/oidc/start'); + assert.equal( + afterExpiry.status, + 302, + 'expired state entries are reclaimed before applying the capacity limit', + ); +} finally { + Date.now = originalNow; +} + +console.log('core OIDC state capacity and expiry reclamation regression passed'); From 5281e0440f29895a7cafca3d73bdf196a481be0d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:35:55 -0700 Subject: [PATCH 064/157] test(security): execute core OIDC capacity regression --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a9062805..c03eec76 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/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/static-prototype-pollution.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/review-regressions.test.mjs && node tests/api/oidc-timeout.test.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/static-prototype-pollution.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/review-regressions.test.mjs && node tests/api/oidc-state-capacity.test.mjs && node tests/api/oidc-timeout.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/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/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", From 1f4ee851704ef31137c39d41bfc830d9ddc8244b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 12:56:42 -0700 Subject: [PATCH 065/157] fix(auth): bound core OIDC state capacity --- server/app_core.mjs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/server/app_core.mjs b/server/app_core.mjs index 7e32031d..683d051e 100644 --- a/server/app_core.mjs +++ b/server/app_core.mjs @@ -799,9 +799,17 @@ const OIDC = { redirectUri: process.env.OIDC_REDIRECT_URI, }; const oidcMock = !OIDC.issuer; +const OIDC_STATE_TTL_MS = 5 * 60 * 1000; +const OIDC_STATE_MAX_ENTRIES = 256; const oidcStates = new Map(); // state -> { verifier, exp } const oidcCodes = new Map(); // mock only: code -> email +function cleanupOidcStates(now = Date.now()) { + for (const [state, record] of oidcStates.entries()) { + if (record.exp < now) oidcStates.delete(state); + } +} + function upsertSsoUser(email) { let user = db.prepare('SELECT id, email, token_version FROM users WHERE email = ?').get(email); if (user) return user; @@ -818,11 +826,16 @@ function upsertSsoUser(email) { } app.get('/api/auth/oidc/start', (c) => { + const now = Date.now(); + cleanupOidcStates(now); + if (oidcStates.size >= OIDC_STATE_MAX_ENTRIES) { + return c.json({ error: 'OIDC temporarily unavailable' }, 503); + } 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 }); + oidcStates.set(state, { verifier, exp: now + OIDC_STATE_TTL_MS }); const redirectUri = OIDC.redirectUri || `${origin}/api/auth/oidc/callback`; if (oidcMock) { const email = c.req.query('email') || 'sso-user@example.com'; @@ -861,7 +874,10 @@ 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); + if (!s || s.exp < Date.now()) { + if (s) oidcStates.delete(state); + return c.json({ error: 'invalid or expired state' }, 400); + } oidcStates.delete(state); let email; if (oidcMock) { @@ -1404,4 +1420,4 @@ app.get('*', async (c) => { } catch { return c.notFound(); } -}); +}); \ No newline at end of file From afad9f26fe5174d46be56d682fdadddce7d10e8a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 12:59:09 -0700 Subject: [PATCH 066/157] test(auth): reject production OIDC mock fallback --- tests/api/oidc-mock-dev-only.test.mjs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 tests/api/oidc-mock-dev-only.test.mjs diff --git a/tests/api/oidc-mock-dev-only.test.mjs b/tests/api/oidc-mock-dev-only.test.mjs new file mode 100644 index 00000000..1052a7ae --- /dev/null +++ b/tests/api/oidc-mock-dev-only.test.mjs @@ -0,0 +1,22 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +delete process.env.SCOPEWEAVE_DEV; +delete process.env.OIDC_ISSUER; +delete process.env.OIDC_CLIENT_ID; +delete process.env.OIDC_CLIENT_SECRET; +delete process.env.OIDC_REDIRECT_URI; + +const { app } = await import('../../server/app_core.mjs'); + +test('built-in OIDC mock cannot authenticate when development mode is disabled', async () => { + let response = await app.request('http://localhost/api/auth/oidc/start?email=attacker@scopeweave.test'); + assert.equal(response.status, 404, 'production-like deployment must not expose mock OIDC start'); + + response = await app.request( + 'http://localhost/api/auth/oidc/mock/authorize?state=fake&email=attacker%40scopeweave.test&redirect_uri=http%3A%2F%2Flocalhost%2Fapi%2Fauth%2Foidc%2Fcallback', + ); + assert.equal(response.status, 404, 'production-like deployment must not expose mock OIDC authorize'); +}); From 4ef7d2ed8c409ec0813ed3aff83aa4918ccbd4ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 12:59:36 -0700 Subject: [PATCH 067/157] test(auth): execute OIDC mock production regression --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c03eec76..90c7fbe7 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/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/static-prototype-pollution.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/review-regressions.test.mjs && node tests/api/oidc-state-capacity.test.mjs && node tests/api/oidc-timeout.test.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/static-prototype-pollution.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/review-regressions.test.mjs && node tests/api/oidc-state-capacity.test.mjs && node tests/api/oidc-mock-dev-only.test.mjs && node tests/api/oidc-timeout.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/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/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", From b12349702d1f816db498ec7523026537a00d0f9f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 13:00:36 -0700 Subject: [PATCH 068/157] test(auth): exercise public OIDC facade --- tests/api/oidc-mock-dev-only.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/api/oidc-mock-dev-only.test.mjs b/tests/api/oidc-mock-dev-only.test.mjs index 1052a7ae..55d0917d 100644 --- a/tests/api/oidc-mock-dev-only.test.mjs +++ b/tests/api/oidc-mock-dev-only.test.mjs @@ -9,7 +9,7 @@ delete process.env.OIDC_CLIENT_ID; delete process.env.OIDC_CLIENT_SECRET; delete process.env.OIDC_REDIRECT_URI; -const { app } = await import('../../server/app_core.mjs'); +const { app } = await import('../../server/app.mjs'); test('built-in OIDC mock cannot authenticate when development mode is disabled', async () => { let response = await app.request('http://localhost/api/auth/oidc/start?email=attacker@scopeweave.test'); From a61e10765c5323b00e264e77d2a96370032d08d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 13:02:10 -0700 Subject: [PATCH 069/157] fix(auth): disable mock OIDC outside development --- server/app.mjs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index f815fb0a..963aca7c 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -329,10 +329,19 @@ function bindOidcStartNonce(request, response, discovery) { } async function coreFetchWithOidcBinding(request, rest) { - if (!OIDC_ISSUER || request.method !== 'GET') { + const requestUrl = new URL(request.url); + if (!OIDC_ISSUER) { + if ( + process.env.SCOPEWEAVE_DEV !== '1' + && requestUrl.pathname.startsWith('/api/auth/oidc/') + ) { + return Response.json({ error: 'not found' }, { status: 404 }); + } + return coreApp.fetch(request, ...rest); + } + if (request.method !== 'GET') { return coreApp.fetch(request, ...rest); } - const requestUrl = new URL(request.url); if (requestUrl.pathname === '/api/auth/oidc/start') { let discovery; try { From f637f5e2fb48c5b048e89e745ef9d0fb4e05ba4a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 13:45:15 -0700 Subject: [PATCH 070/157] test(oidc): require bounded JWKS cache reuse --- tests/api/oidc-timeout.test.mjs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/api/oidc-timeout.test.mjs b/tests/api/oidc-timeout.test.mjs index 67cefaf1..4a78d5c2 100644 --- a/tests/api/oidc-timeout.test.mjs +++ b/tests/api/oidc-timeout.test.mjs @@ -26,6 +26,7 @@ let discoveryMode = 'private-metadata'; let observedTimeout = null; let callbackAbortController = null; let observedUpstreamAbort = null; +let jwksFetches = 0; const originalTimeout = AbortSignal.timeout; const originalFetch = globalThis.fetch; @@ -70,6 +71,7 @@ globalThis.fetch = async (input, init) => { }); } if (url === jwksEndpoint) { + jwksFetches += 1; assert.equal( request.redirect, 'error', @@ -100,7 +102,7 @@ globalThis.fetch = async (input, init) => { exp: now + 300, }; - if (code === 'valid-code') { + if (code === 'valid-code' || code === 'valid-code-cache') { return Response.json({ id_token: signIdToken(baseClaims) }); } if (code === 'forged-code') { @@ -169,6 +171,15 @@ try { 3000, 'OIDC provider calls use the bounded three-second provider budget', ); + assert.equal(jwksFetches, 1, 'first verified login retrieves signing-key evidence once'); + + const cachedKeyLogin = await callback('valid-code-cache'); + assert.equal(cachedKeyLogin.status, 302, 'a second correctly signed login remains valid'); + assert.equal( + jwksFetches, + 1, + 'repeated logins with the same signing key reuse bounded JWKS evidence instead of amplifying provider traffic', + ); const forged = await callback('forged-code'); assert.equal(forged.status, 400, 'a forged ID-token signature is rejected'); @@ -220,4 +231,4 @@ try { delete process.env.SCOPEWEAVE_DEV; } -console.log('oidc discovery, private-endpoint, validation, cancellation, redirect, timeout, and state-capacity regression passed'); \ No newline at end of file +console.log('oidc discovery, private-endpoint, validation, cancellation, redirect, timeout, bounded JWKS reuse, and state-capacity regression passed'); \ No newline at end of file From 5bfb2bedc4d86b6cf9ec41aea7ef5e54bfa0c988 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 13:47:37 -0700 Subject: [PATCH 071/157] fix(oidc): cache validated signing key evidence --- server/app.mjs | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 963aca7c..0b369b1b 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -26,12 +26,14 @@ const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID || ''; const OIDC_TOKEN_URL = OIDC_ISSUER ? `${OIDC_ISSUER}/token` : null; const OIDC_TOKEN_TIMEOUT_MS = 3000; const OIDC_DISCOVERY_TTL_MS = 60 * 1000; +const OIDC_JWKS_TTL_MS = 60 * 1000; const OIDC_STATE_TTL_MS = 5 * 60 * 1000; const OIDC_STATE_MAX_ENTRIES = 256; const OIDC_CLOCK_SKEW_SECONDS = 60; const oidcNonceByState = new Map(); const oidcNonceByCode = new Map(); let oidcDiscoveryCache = null; +let oidcSigningKeyCache = null; function isSignedWebhookRequest(request) { if (request.method.toUpperCase() !== 'POST') return false; @@ -149,19 +151,21 @@ async function loadOidcDiscovery(now = Date.now()) { return value; } -async function verifyOidcIdToken(idToken, expectedNonce, discovery) { - const parts = String(idToken || '').split('.'); - if (parts.length !== 3) throw new Error('invalid token'); - const [encodedHeader, encodedClaims, encodedSignature] = parts; - const header = parseJwtObject(encodedHeader); - const claims = parseJwtObject(encodedClaims); - if (header.alg !== 'RS256' || typeof header.kid !== 'string' || !header.kid) throw new Error('invalid algorithm'); +async function loadOidcSigningKey(discovery, kid, now = Date.now()) { + if ( + oidcSigningKeyCache + && oidcSigningKeyCache.expiresAt > now + && oidcSigningKeyCache.jwksUri === discovery.jwks_uri + && oidcSigningKeyCache.kid === kid + ) { + return oidcSigningKeyCache.key; + } const jwks = await oidcProviderJson(discovery.jwks_uri); const keyData = Array.isArray(jwks.keys) ? jwks.keys.find((candidate) => ( candidate - && candidate.kid === header.kid + && candidate.kid === kid && candidate.kty === 'RSA' && (!candidate.use || candidate.use === 'sig') && (!candidate.alg || candidate.alg === 'RS256') @@ -169,6 +173,24 @@ async function verifyOidcIdToken(idToken, expectedNonce, discovery) { : null; if (!keyData) throw new Error('signing key unavailable'); const key = createPublicKey({ key: keyData, format: 'jwk' }); + oidcSigningKeyCache = { + jwksUri: discovery.jwks_uri, + kid, + key, + expiresAt: now + OIDC_JWKS_TTL_MS, + }; + return key; +} + +async function verifyOidcIdToken(idToken, expectedNonce, discovery) { + const parts = String(idToken || '').split('.'); + if (parts.length !== 3) throw new Error('invalid token'); + const [encodedHeader, encodedClaims, encodedSignature] = parts; + const header = parseJwtObject(encodedHeader); + const claims = parseJwtObject(encodedClaims); + if (header.alg !== 'RS256' || typeof header.kid !== 'string' || !header.kid) throw new Error('invalid algorithm'); + + const key = await loadOidcSigningKey(discovery, header.kid); if (!verifySignature( 'RSA-SHA256', Buffer.from(`${encodedHeader}.${encodedClaims}`), From 9fbdbae86f9a6320272cb400982015aa2f77c1da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 14:16:01 -0700 Subject: [PATCH 072/157] test(oidc): reject state at exact expiry boundary --- tests/api/oidc-state-capacity.test.mjs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/api/oidc-state-capacity.test.mjs b/tests/api/oidc-state-capacity.test.mjs index e80bcdca..a4ba0881 100644 --- a/tests/api/oidc-state-capacity.test.mjs +++ b/tests/api/oidc-state-capacity.test.mjs @@ -36,15 +36,15 @@ try { 'capacity exhaustion returns a stable non-secret response', ); - now += (5 * 60 * 1000) + 1; - const afterExpiry = await app.request('/api/auth/oidc/start'); + now += 5 * 60 * 1000; + const atExpiry = await app.request('/api/auth/oidc/start'); assert.equal( - afterExpiry.status, + atExpiry.status, 302, - 'expired state entries are reclaimed before applying the capacity limit', + 'state entries expiring at the current instant are reclaimed before applying the capacity limit', ); } finally { Date.now = originalNow; } -console.log('core OIDC state capacity and expiry reclamation regression passed'); +console.log('core OIDC state capacity and inclusive expiry reclamation regression passed'); From 15cca549278c1b6db29f4e1ea66c6f33b97abecb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 14:16:47 -0700 Subject: [PATCH 073/157] test(oidc): prevent metadata replay after response --- tests/unit/public-https-transport.test.mjs | 50 +++++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/tests/unit/public-https-transport.test.mjs b/tests/unit/public-https-transport.test.mjs index 6df515c7..5dc19d7a 100644 --- a/tests/unit/public-https-transport.test.mjs +++ b/tests/unit/public-https-transport.test.mjs @@ -34,6 +34,8 @@ const transport = createPublicHttpsTransport({ agent: options.agent, servername: options.servername, method: options.method, + acceptEncoding: options.headers?.get?.('accept-encoding') + ?? options.headers?.['accept-encoding'], }); if (address === PUBLIC_A.address) { queueMicrotask(() => req.emit('error', new Error('simulated first-address failure'))); @@ -67,15 +69,17 @@ assert.deepEqual( agent: false, servername: 'idp.example.test', method: 'GET', + acceptEncoding: 'identity', }, { ...PUBLIC_B, agent: false, servername: 'idp.example.test', method: 'GET', + acceptEncoding: 'identity', }, ], - 'every fallback attempt is pinned to a validated address with pooling disabled and original SNI preserved', + 'every fallback attempt is pinned, disables pooling, preserves SNI, and requests identity encoding', ); const oversized = createPublicHttpsTransport({ @@ -99,6 +103,48 @@ await assert.rejects( 'provider responses larger than the configured memory budget fail closed', ); +let responseStartedAttempts = 0; +const noReplayAfterResponse = createPublicHttpsTransport({ + lookup: async () => [PUBLIC_A, PUBLIC_B], + request: (_url, options, callback) => { + responseStartedAttempts += 1; + const req = new EventEmitter(); + req.end = () => { + options.lookup('ignored.example', {}, (error, address) => { + assert.ifError(error); + const response = new EventEmitter(); + response.statusCode = 200; + response.headers = { 'content-type': 'application/json' }; + response.destroy = () => {}; + callback(response); + queueMicrotask(() => { + if (address === PUBLIC_A.address) { + response.emit('data', Buffer.alloc(9)); + return; + } + response.emit('data', Buffer.from('{}')); + response.emit('end'); + }); + }); + }; + return req; + }, +}); +await assert.rejects( + noReplayAfterResponse.fetch('https://idp.example.test/token', { + method: 'POST', + body: 'grant_type=authorization_code', + maxResponseBytes: 8, + }), + WebhookTransportError, + 'a response-stream failure must not replay an already-sent POST to another address', +); +assert.equal( + responseStartedAttempts, + 1, + 'only connection-establishment failures may advance to another validated address', +); + await assert.rejects( transport.fetch('https://idp.example.test/jwks', { maxResponseBytes: 0 }), /positive safe integer/, @@ -108,4 +154,4 @@ assert.throws( /dependencies must be functions/, ); -console.log('public HTTPS DNS pinning, fallback, and response-bound regressions passed'); +console.log('public HTTPS DNS pinning, identity encoding, fallback, and response-bound regressions passed'); From 2ffc599738022ef0299b7bc41cb1c8cd0ff3b582 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 14:17:19 -0700 Subject: [PATCH 074/157] test(ci): register OIDC production and transport regressions --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 90c7fbe7..4717f20f 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/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/static-prototype-pollution.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/review-regressions.test.mjs && node tests/api/oidc-state-capacity.test.mjs && node tests/api/oidc-mock-dev-only.test.mjs && node tests/api/oidc-timeout.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/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: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/static-prototype-pollution.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/review-regressions.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-state-capacity.test.mjs && node tests/api/oidc-mock-dev-only.test.mjs && node tests/api/oidc-timeout.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/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/public-https-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/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/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/public-https-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 21295f5dceabc2bd23b1af5fbc04cefab2f650a4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 14:21:39 -0700 Subject: [PATCH 075/157] test(oidc): preserve fail-closed 404 contract --- tests/api/oidc-production-boundary.test.mjs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/api/oidc-production-boundary.test.mjs b/tests/api/oidc-production-boundary.test.mjs index 0faa61b9..b3814cf6 100644 --- a/tests/api/oidc-production-boundary.test.mjs +++ b/tests/api/oidc-production-boundary.test.mjs @@ -13,10 +13,9 @@ const { app } = await import('../../server/app.mjs'); const start = await app.request('/api/auth/oidc/start?email=attacker@example.test'); assert.equal( start.status, - 503, + 404, 'an unconfigured production deployment must not expose the built-in mock identity provider', ); -assert.deepEqual(await start.json(), { error: 'OIDC not configured' }); const mock = await app.request( '/api/auth/oidc/mock/authorize?state=attacker&email=attacker@example.test&redirect_uri=http://localhost/api/auth/oidc/callback', @@ -30,9 +29,8 @@ assert.equal( const callback = await app.request('/api/auth/oidc/callback?state=attacker&code=attacker'); assert.equal( callback.status, - 503, + 404, 'an unconfigured production callback cannot enter the mock-session path', ); -assert.deepEqual(await callback.json(), { error: 'OIDC not configured' }); console.log('production OIDC fail-closed boundary regression passed'); From 8e1221a4eab15e7ebe320e4837590c751669401b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 14:23:59 -0700 Subject: [PATCH 076/157] fix(oidc): prevent response replay and compression --- 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 71d53b39..3415e69d 100644 --- a/server/webhook_transport.mjs +++ b/server/webhook_transport.mjs @@ -232,10 +232,16 @@ function appendResponseHeaders(target, source) { } } +function identityEncodedHeaders(headers) { + const normalized = Object.fromEntries(new Headers(headers).entries()); + normalized['accept-encoding'] = 'identity'; + return normalized; +} + async function fetchFromCandidate( destination, candidate, - { method, headers, body, signal, maxResponseBytes }, + { method, headers, body, signal, maxResponseBytes, attempt }, request, ) { if (signal?.aborted) throw new WebhookTransportError(); @@ -254,6 +260,7 @@ async function fetchFromCandidate( headers, signal, }), (response) => { + if (attempt) attempt.responseStarted = true; const chunks = []; let totalBytes = 0; response.on?.('data', (chunk) => { @@ -338,25 +345,28 @@ export function createPublicHttpsTransport({ lookup = dnsLookup, request = https } const candidates = await resolvePublicAddresses(destination, lookup, signal); + const requestHeaders = identityEncodedHeaders(headers); let lastError; for (const candidate of candidates) { + const attempt = { responseStarted: false }; try { return await fetchFromCandidate( destination, candidate, { method: String(method || 'GET').toUpperCase(), - headers, + headers: requestHeaders, body, signal, maxResponseBytes, + attempt, }, request, ); } catch (error) { if (!(error instanceof WebhookTransportError)) throw error; lastError = error; - if (signal?.aborted) throw error; + if (signal?.aborted || attempt.responseStarted) throw error; } } throw lastError || new WebhookTransportError(); From 4d71b5cb05a65c914575b05dae8a69f28e30396f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 15:38:04 -0700 Subject: [PATCH 077/157] test(oidc): cover inclusive state expiry boundary --- tests/api/oidc-state-capacity.test.mjs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/api/oidc-state-capacity.test.mjs b/tests/api/oidc-state-capacity.test.mjs index a4ba0881..fa61fdde 100644 --- a/tests/api/oidc-state-capacity.test.mjs +++ b/tests/api/oidc-state-capacity.test.mjs @@ -43,6 +43,24 @@ try { 302, 'state entries expiring at the current instant are reclaimed before applying the capacity limit', ); + + const replacementState = new URL(atExpiry.headers.get('location')).searchParams.get('state'); + assert.ok(replacementState, 'the replacement flow exposes a state value through the authorization redirect'); + + now += 5 * 60 * 1000; + const expiredCallback = await app.request( + `/api/auth/oidc/callback?state=${encodeURIComponent(replacementState)}&code=unused`, + ); + assert.equal( + expiredCallback.status, + 400, + 'a state expiring at the current instant is rejected before token exchange', + ); + assert.deepEqual( + await expiredCallback.json(), + { error: 'invalid or expired state' }, + 'inclusive callback expiry returns the stable fail-closed response', + ); } finally { Date.now = originalNow; } From 5b0c3aff5fee2161f81092cb86d17f3463b49f0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 15:42:11 -0700 Subject: [PATCH 078/157] fix(oidc): expire state inclusively at TTL boundary --- 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 683d051e..fffeb704 100644 --- a/server/app_core.mjs +++ b/server/app_core.mjs @@ -806,7 +806,7 @@ const oidcCodes = new Map(); // mock only: code -> email function cleanupOidcStates(now = Date.now()) { for (const [state, record] of oidcStates.entries()) { - if (record.exp < now) oidcStates.delete(state); + if (record.exp <= now) oidcStates.delete(state); } } @@ -874,7 +874,7 @@ 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()) { + if (!s || s.exp <= Date.now()) { if (s) oidcStates.delete(state); return c.json({ error: 'invalid or expired state' }, 400); } From 2384a789a763316ff8ebe34cc0b995d8d352a03a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 17:49:15 -0700 Subject: [PATCH 079/157] test(stack): preserve tenant-bound orchestrator attribution --- tests/api/orchestrator-attribution.test.mjs | 89 +++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tests/api/orchestrator-attribution.test.mjs diff --git a/tests/api/orchestrator-attribution.test.mjs b/tests/api/orchestrator-attribution.test.mjs new file mode 100644 index 00000000..d07460a3 --- /dev/null +++ b/tests/api/orchestrator-attribution.test.mjs @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +delete process.env.SCOPEWEAVE_DEV; +process.env.ORCHESTRATOR_URL = 'https://orchestrator.example'; +process.env.ORCHESTRATOR_TOKEN = 'secret-token'; +process.env.ORCHESTRATOR_MODEL = 'nvidia/nemotron-3-super-120b-a12b'; + +const providerCalls = []; +globalThis.fetch = async (url, init) => { + providerCalls.push({ url: String(url), init }); + return new Response(JSON.stringify({ + choices: [{ message: { content: 'Grounded production response' } }], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +}; + +const { app } = await import(`../../server/app.mjs?attribution-api-test=${Date.now()}`); + +const jsonRequest = (path, options = {}) => app.request(path, { + ...options, + headers: { + 'content-type': 'application/json', + ...(options.headers || {}), + }, +}); +const jsonBody = (value) => JSON.stringify(value); + +async function createAccount(email) { + let response = await jsonRequest('/api/auth/signup', { + method: 'POST', + body: jsonBody({ email, password: 'password123', name: email }), + }); + assert.equal(response.status, 200, `${email} signup`); + const token = (await response.json()).token; + const auth = { authorization: `Bearer ${token}` }; + response = await jsonRequest('/api/me', { headers: auth }); + assert.equal(response.status, 200, `${email} account lookup`); + const account = await response.json(); + return { auth, orgId: account.orgs[0].id }; +} + +const owner = await createAccount('orchestrator-owner@scopeweave.test'); +const outsider = await createAccount('orchestrator-outsider@scopeweave.test'); + +let response = await jsonRequest('/api/projects', { + method: 'POST', + headers: owner.auth, + body: jsonBody({ name: 'Attribution Project' }), +}); +assert.equal(response.status, 200, 'owner creates attribution project'); +const projectId = (await response.json()).id; + +response = await jsonRequest(`/api/projects/${projectId}/ai/brief`, { + method: 'POST', + headers: owner.auth, + body: jsonBody({ account: String(outsider.orgId), service: 'spoofed-client-service' }), +}); +assert.equal(response.status, 200, 'authorized owner receives AI briefing'); +assert.equal(providerCalls.length, 1, 'authorized briefing performs one provider call'); +assert.equal(providerCalls[0].url, 'https://orchestrator.example/v1/chat/completions'); +const providerBody = JSON.parse(providerCalls[0].init.body); +assert.deepEqual( + providerBody.attribution, + { service: 'scopeweave', account: String(owner.orgId) }, + 'the authenticated server-side project organization owns cost attribution', +); +assert.notEqual( + providerBody.attribution.account, + String(outsider.orgId), + 'browser-supplied account data cannot spoof another tenant attribution', +); + +response = await jsonRequest(`/api/projects/${projectId}/ai/brief`, { + method: 'POST', + headers: outsider.auth, + body: jsonBody({ account: String(owner.orgId) }), +}); +assert.equal(response.status, 404, 'cross-tenant AI briefing hides project existence'); +assert.equal( + providerCalls.length, + 1, + 'cross-tenant requests are rejected before any contextual-orchestrator call', +); + +console.log('✓ AI briefing attribution tenant-boundary tests passed'); From 31eb3fa1855762adefca0ef13244b56e8f289c94 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 17:49:52 -0700 Subject: [PATCH 080/157] test(stack): register tenant attribution regression --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4717f20f..55e4a612 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/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/static-prototype-pollution.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/review-regressions.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-state-capacity.test.mjs && node tests/api/oidc-mock-dev-only.test.mjs && node tests/api/oidc-timeout.test.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 && node tests/api/static-prototype-pollution.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/review-regressions.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-state-capacity.test.mjs && node tests/api/oidc-mock-dev-only.test.mjs && node tests/api/oidc-timeout.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/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/public-https-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/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/public-https-transport.test.mjs && npm run test:api", From 079e821f867df0ddafb223a7311f29cd90911297 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 01:55:21 -0700 Subject: [PATCH 081/157] fix(stack): preserve tenant-bound orchestrator attribution --- server/app_core.mjs | 108 +++++++------------------------------------- 1 file changed, 16 insertions(+), 92 deletions(-) diff --git a/server/app_core.mjs b/server/app_core.mjs index fffeb704..b98e763f 100644 --- a/server/app_core.mjs +++ b/server/app_core.mjs @@ -400,7 +400,7 @@ app.get('/api/projects/:id/calendar.ics', (c) => { 'BEGIN:VEVENT', `UID:scopeweave-${p.id}-${esc(t.id)}`, `DTSTART;VALUE=DATE:${day(t.plannedStartDate)}`, - `DTEND;VALUE=DATE:${nextDay(t.plannedEndDate)}`, // DTEND is exclusive + `DTEND;VALUE=DATE:${nextDay(t.plannedEndDate)}`, `SUMMARY:${esc(t.name || t.task || t.id)}`, 'END:VEVENT' ); @@ -413,9 +413,6 @@ app.get('/api/projects/:id/calendar.ics', (c) => { }); 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; @@ -439,8 +436,6 @@ app.get('/api/projects/:id/stream', (c) => { }); }); -// --------------------------------------------------------------- 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'); @@ -456,7 +451,6 @@ app.get('/api/orgs/:id/members', requireAuth, (c) => { 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'); @@ -468,7 +462,6 @@ app.delete('/api/orgs/:id/invites/:inviteId', requireAuth, (c) => { 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'); @@ -487,7 +480,6 @@ app.post('/api/orgs/:id/invites', requireAuth, async (c) => { 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')); @@ -505,7 +497,6 @@ app.post('/api/invites/:token/accept', requireAuth, (c) => { 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'); @@ -522,7 +513,6 @@ app.patch('/api/orgs/:id/members/:userId', requireAuth, async (c) => { 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'); @@ -536,8 +526,6 @@ app.delete('/api/orgs/:id/members/:userId', requireAuth, (c) => { 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'); @@ -549,8 +537,6 @@ app.post('/api/orgs/:id/leave', requireAuth, (c) => { 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'); @@ -570,7 +556,6 @@ app.post('/api/orgs/:id/transfer', requireAuth, async (c) => { 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'); @@ -582,7 +567,6 @@ app.patch('/api/orgs/:id', requireAuth, async (c) => { 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'); @@ -601,8 +585,6 @@ app.post('/api/orgs/:id/checkout', requireAuth, async (c) => { 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') { @@ -612,8 +594,6 @@ app.post('/api/stripe/webhook', async (c) => { 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; @@ -625,13 +605,12 @@ app.post('/api/orgs/:id/_dev/activate-pro', requireAuth, (c) => { 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 + return c.json({ tokens }); }); app.post('/api/tokens', requireAuth, async (c) => { @@ -640,7 +619,6 @@ app.post('/api/tokens', requireAuth, async (c) => { 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 }); }); @@ -651,7 +629,6 @@ app.delete('/api/tokens/:id', requireAuth, (c) => { 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'); @@ -665,10 +642,6 @@ app.get('/api/orgs/:id/audit', requireAuth, (c) => { ).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}`; @@ -687,8 +660,6 @@ app.get('/api/orgs/:id/audit', requireAuth, (c) => { 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'); @@ -711,24 +682,20 @@ app.get('/api/orgs/:id/export', requireAuth, (c) => { }, 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. + if (typeof v !== 'number') continue; 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'); @@ -738,7 +705,7 @@ app.get('/api/orgs/:id/webhooks', requireAuth, (c) => { (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 + ).all(orgId); return c.json({ webhooks }); }); @@ -752,7 +719,7 @@ app.post('/api/orgs/:id/webhooks', requireAuth, async (c) => { 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 + return c.json({ id, url, events: evs, secret }); }); app.get('/api/orgs/:id/webhooks/:whId/deliveries', requireAuth, (c) => { @@ -767,8 +734,6 @@ app.get('/api/orgs/:id/webhooks/:whId/deliveries', requireAuth, (c) => { 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'); @@ -777,7 +742,7 @@ app.post('/api/orgs/:id/webhooks/:whId/rotate', requireAuth, (c) => { 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 + return c.json({ id: Number(c.req.param('whId')), secret }); }); app.delete('/api/orgs/:id/webhooks/:whId', requireAuth, (c) => { @@ -789,9 +754,6 @@ app.delete('/api/orgs/:id/webhooks/:whId', requireAuth, (c) => { 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, @@ -801,8 +763,8 @@ const OIDC = { const oidcMock = !OIDC.issuer; const OIDC_STATE_TTL_MS = 5 * 60 * 1000; const OIDC_STATE_MAX_ENTRIES = 256; -const oidcStates = new Map(); // state -> { verifier, exp } -const oidcCodes = new Map(); // mock only: code -> email +const oidcStates = new Map(); +const oidcCodes = new Map(); function cleanupOidcStates(now = Date.now()) { for (const [state, record] of oidcStates.entries()) { @@ -856,7 +818,6 @@ app.get('/api/auth/oidc/start', (c) => { 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'); @@ -893,21 +854,15 @@ app.get('/api/auth/oidc/callback', async (c) => { }).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(); @@ -936,8 +891,6 @@ app.get('/api/search', requireAuth, (c) => { 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'); @@ -963,8 +916,8 @@ app.get('/api/orgs/:id/portfolio', requireAuth, (c) => { name: p.name, archived: Boolean(p.archived), tasks: tasks.length, - planned: Math.round(evm.pv * 1000) / 10, // % - actual: Math.round(evm.ev * 1000) / 10, // % + 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, @@ -975,9 +928,6 @@ app.get('/api/orgs/:id/portfolio', requireAuth, (c) => { 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')); @@ -1011,7 +961,10 @@ app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { 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) { @@ -1019,9 +972,6 @@ app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { } }); -// 산출물 첨부(Clearfolio 통합 문서 뷰어 프록시): 업로드→변환 잡, 목록(+상태 -// 갱신), 서명 아티팩트 열람(302), 삭제. 테넌트 = 조직, 브라우저에는 Clearfolio -// 자격이 절대 노출되지 않음. const ATTACH_MAX_BYTES = 10 * 1024 * 1024; const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( @@ -1097,7 +1047,6 @@ app.get('/api/projects/:id/attachments', requireAuth, async (c) => { 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') || ''); @@ -1136,7 +1085,6 @@ app.delete('/api/projects/:id/attachments/:aid', requireAuth, (c) => { 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')); @@ -1148,8 +1096,6 @@ if (clearfolioMock) { }); } -// 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')); @@ -1183,7 +1129,6 @@ app.delete('/api/projects/:id/shares/:sid', requireAuth, (c) => { 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 @@ -1193,8 +1138,6 @@ app.get('/api/shared/:token', (c) => { 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( @@ -1224,7 +1167,6 @@ app.post('/api/projects/:id/seen', requireAuth, (c) => { 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')); @@ -1237,8 +1179,6 @@ app.post('/api/projects/:id/archive', requireAuth, async (c) => { 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')); @@ -1256,10 +1196,6 @@ app.post('/api/projects/:id/duplicate', requireAuth, async (c) => { 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')); @@ -1293,9 +1229,6 @@ app.delete('/api/projects/:id/sprints/:sid', requireAuth, (c) => { 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'); @@ -1336,8 +1269,6 @@ app.delete('/api/projects/:id/baselines/:bid', requireAuth, (c) => { 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'); @@ -1350,8 +1281,6 @@ app.delete('/api/projects/:id', requireAuth, (c) => { 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); @@ -1359,7 +1288,6 @@ app.post('/api/auth/logout-all', requireAuth, (c) => { 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(() => ({})); @@ -1372,8 +1300,6 @@ app.post('/api/auth/change-password', requireAuth, async (c) => { 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(() => ({})); @@ -1383,8 +1309,8 @@ app.delete('/api/account', requireAuth, async (c) => { } 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.prepare('DELETE FROM orgs WHERE owner_id = ?').run(uid); + db.prepare('DELETE FROM users WHERE id = ?').run(uid); db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw e; } return c.json({ ok: true }); @@ -1392,8 +1318,6 @@ app.delete('/api/account', requireAuth, async (c) => { 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 = Object.assign(Object.create(null), { '/': ['index.html', 'text/html; charset=utf-8'], '/index.html': ['index.html', 'text/html; charset=utf-8'], From 6460845fb286d80cd739d2d29cdb74def3a9cb45 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 01:56:03 -0700 Subject: [PATCH 082/157] revert(stack): restore exact pre-repair core before safe reconciliation --- server/app_core.mjs | 108 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 92 insertions(+), 16 deletions(-) diff --git a/server/app_core.mjs b/server/app_core.mjs index b98e763f..fffeb704 100644 --- a/server/app_core.mjs +++ b/server/app_core.mjs @@ -400,7 +400,7 @@ app.get('/api/projects/:id/calendar.ics', (c) => { 'BEGIN:VEVENT', `UID:scopeweave-${p.id}-${esc(t.id)}`, `DTSTART;VALUE=DATE:${day(t.plannedStartDate)}`, - `DTEND;VALUE=DATE:${nextDay(t.plannedEndDate)}`, + `DTEND;VALUE=DATE:${nextDay(t.plannedEndDate)}`, // DTEND is exclusive `SUMMARY:${esc(t.name || t.task || t.id)}`, 'END:VEVENT' ); @@ -413,6 +413,9 @@ app.get('/api/projects/:id/calendar.ics', (c) => { }); 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; @@ -436,6 +439,8 @@ app.get('/api/projects/:id/stream', (c) => { }); }); +// --------------------------------------------------------------- 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'); @@ -451,6 +456,7 @@ app.get('/api/orgs/:id/members', requireAuth, (c) => { 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'); @@ -462,6 +468,7 @@ app.delete('/api/orgs/:id/invites/:inviteId', requireAuth, (c) => { 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'); @@ -480,6 +487,7 @@ app.post('/api/orgs/:id/invites', requireAuth, async (c) => { 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')); @@ -497,6 +505,7 @@ app.post('/api/invites/:token/accept', requireAuth, (c) => { 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'); @@ -513,6 +522,7 @@ app.patch('/api/orgs/:id/members/:userId', requireAuth, async (c) => { 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'); @@ -526,6 +536,8 @@ app.delete('/api/orgs/:id/members/:userId', requireAuth, (c) => { 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'); @@ -537,6 +549,8 @@ app.post('/api/orgs/:id/leave', requireAuth, (c) => { 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'); @@ -556,6 +570,7 @@ app.post('/api/orgs/:id/transfer', requireAuth, async (c) => { 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'); @@ -567,6 +582,7 @@ app.patch('/api/orgs/:id', requireAuth, async (c) => { 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'); @@ -585,6 +601,8 @@ app.post('/api/orgs/:id/checkout', requireAuth, async (c) => { 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') { @@ -594,6 +612,8 @@ app.post('/api/stripe/webhook', async (c) => { 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; @@ -605,12 +625,13 @@ app.post('/api/orgs/:id/_dev/activate-pro', requireAuth, (c) => { 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 }); + return c.json({ tokens }); // never the secret or hash }); app.post('/api/tokens', requireAuth, async (c) => { @@ -619,6 +640,7 @@ app.post('/api/tokens', requireAuth, async (c) => { 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 }); }); @@ -629,6 +651,7 @@ app.delete('/api/tokens/:id', requireAuth, (c) => { 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'); @@ -642,6 +665,10 @@ app.get('/api/orgs/:id/audit', requireAuth, (c) => { ).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}`; @@ -660,6 +687,8 @@ app.get('/api/orgs/:id/audit', requireAuth, (c) => { 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'); @@ -682,20 +711,24 @@ app.get('/api/orgs/:id/export', requireAuth, (c) => { }, 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; + 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'); @@ -705,7 +738,7 @@ app.get('/api/orgs/:id/webhooks', requireAuth, (c) => { (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); + ).all(orgId); // secret never returned return c.json({ webhooks }); }); @@ -719,7 +752,7 @@ app.post('/api/orgs/:id/webhooks', requireAuth, async (c) => { 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 }); + return c.json({ id, url, events: evs, secret }); // secret shown once for signature verification }); app.get('/api/orgs/:id/webhooks/:whId/deliveries', requireAuth, (c) => { @@ -734,6 +767,8 @@ app.get('/api/orgs/:id/webhooks/:whId/deliveries', requireAuth, (c) => { 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'); @@ -742,7 +777,7 @@ app.post('/api/orgs/:id/webhooks/:whId/rotate', requireAuth, (c) => { 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 }); + return c.json({ id: Number(c.req.param('whId')), secret }); // shown once }); app.delete('/api/orgs/:id/webhooks/:whId', requireAuth, (c) => { @@ -754,6 +789,9 @@ app.delete('/api/orgs/:id/webhooks/:whId', requireAuth, (c) => { 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, @@ -763,8 +801,8 @@ const OIDC = { const oidcMock = !OIDC.issuer; const OIDC_STATE_TTL_MS = 5 * 60 * 1000; const OIDC_STATE_MAX_ENTRIES = 256; -const oidcStates = new Map(); -const oidcCodes = new Map(); +const oidcStates = new Map(); // state -> { verifier, exp } +const oidcCodes = new Map(); // mock only: code -> email function cleanupOidcStates(now = Date.now()) { for (const [state, record] of oidcStates.entries()) { @@ -818,6 +856,7 @@ app.get('/api/auth/oidc/start', (c) => { 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'); @@ -854,15 +893,21 @@ app.get('/api/auth/oidc/callback', async (c) => { }).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(); @@ -891,6 +936,8 @@ app.get('/api/search', requireAuth, (c) => { 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'); @@ -916,8 +963,8 @@ app.get('/api/orgs/:id/portfolio', requireAuth, (c) => { name: p.name, archived: Boolean(p.archived), tasks: tasks.length, - planned: Math.round(evm.pv * 1000) / 10, - actual: Math.round(evm.ev * 1000) / 10, + 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, @@ -928,6 +975,9 @@ app.get('/api/orgs/:id/portfolio', requireAuth, (c) => { 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')); @@ -961,10 +1011,7 @@ app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { 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) { @@ -972,6 +1019,9 @@ app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { } }); +// 산출물 첨부(Clearfolio 통합 문서 뷰어 프록시): 업로드→변환 잡, 목록(+상태 +// 갱신), 서명 아티팩트 열람(302), 삭제. 테넌트 = 조직, 브라우저에는 Clearfolio +// 자격이 절대 노출되지 않음. const ATTACH_MAX_BYTES = 10 * 1024 * 1024; const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( @@ -1047,6 +1097,7 @@ app.get('/api/projects/:id/attachments', requireAuth, async (c) => { 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') || ''); @@ -1085,6 +1136,7 @@ app.delete('/api/projects/:id/attachments/:aid', requireAuth, (c) => { 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')); @@ -1096,6 +1148,8 @@ if (clearfolioMock) { }); } +// 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')); @@ -1129,6 +1183,7 @@ app.delete('/api/projects/:id/shares/:sid', requireAuth, (c) => { 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 @@ -1138,6 +1193,8 @@ app.get('/api/shared/:token', (c) => { 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( @@ -1167,6 +1224,7 @@ app.post('/api/projects/:id/seen', requireAuth, (c) => { 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')); @@ -1179,6 +1237,8 @@ app.post('/api/projects/:id/archive', requireAuth, async (c) => { 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')); @@ -1196,6 +1256,10 @@ app.post('/api/projects/:id/duplicate', requireAuth, async (c) => { 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')); @@ -1229,6 +1293,9 @@ app.delete('/api/projects/:id/sprints/:sid', requireAuth, (c) => { 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'); @@ -1269,6 +1336,8 @@ app.delete('/api/projects/:id/baselines/:bid', requireAuth, (c) => { 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'); @@ -1281,6 +1350,8 @@ app.delete('/api/projects/:id', requireAuth, (c) => { 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); @@ -1288,6 +1359,7 @@ app.post('/api/auth/logout-all', requireAuth, (c) => { 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(() => ({})); @@ -1300,6 +1372,8 @@ app.post('/api/auth/change-password', requireAuth, async (c) => { 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(() => ({})); @@ -1309,8 +1383,8 @@ app.delete('/api/account', requireAuth, async (c) => { } db.exec('BEGIN'); try { - db.prepare('DELETE FROM orgs WHERE owner_id = ?').run(uid); - db.prepare('DELETE FROM users WHERE id = ?').run(uid); + 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 }); @@ -1318,6 +1392,8 @@ app.delete('/api/account', requireAuth, async (c) => { 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 = Object.assign(Object.create(null), { '/': ['index.html', 'text/html; charset=utf-8'], '/index.html': ['index.html', 'text/html; charset=utf-8'], From fba15e261c21a3af6c5bd8816e68a4d6c1afa89e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 06:14:23 -0700 Subject: [PATCH 083/157] test(api): preserve Request semantics in orchestrator attribution stub --- tests/api/orchestrator-attribution.test.mjs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/api/orchestrator-attribution.test.mjs b/tests/api/orchestrator-attribution.test.mjs index d07460a3..15b47dae 100644 --- a/tests/api/orchestrator-attribution.test.mjs +++ b/tests/api/orchestrator-attribution.test.mjs @@ -8,8 +8,13 @@ process.env.ORCHESTRATOR_TOKEN = 'secret-token'; process.env.ORCHESTRATOR_MODEL = 'nvidia/nemotron-3-super-120b-a12b'; const providerCalls = []; -globalThis.fetch = async (url, init) => { - providerCalls.push({ url: String(url), init }); +globalThis.fetch = async (input, init) => { + const request = new Request(input, init); + providerCalls.push({ + url: request.url, + method: request.method, + body: request.body ? await request.clone().text() : '', + }); return new Response(JSON.stringify({ choices: [{ message: { content: 'Grounded production response' } }], }), { @@ -62,7 +67,8 @@ response = await jsonRequest(`/api/projects/${projectId}/ai/brief`, { assert.equal(response.status, 200, 'authorized owner receives AI briefing'); assert.equal(providerCalls.length, 1, 'authorized briefing performs one provider call'); assert.equal(providerCalls[0].url, 'https://orchestrator.example/v1/chat/completions'); -const providerBody = JSON.parse(providerCalls[0].init.body); +assert.equal(providerCalls[0].method, 'POST', 'orchestrator transport preserves POST semantics'); +const providerBody = JSON.parse(providerCalls[0].body); assert.deepEqual( providerBody.attribution, { service: 'scopeweave', account: String(owner.orgId) }, From 74ad0766ebe546e952579078f0d2f5e8193cebbf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 16:15:34 -0700 Subject: [PATCH 084/157] test(transport): reject POST replay after TLS connect --- tests/unit/public-https-transport.test.mjs | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/unit/public-https-transport.test.mjs b/tests/unit/public-https-transport.test.mjs index 5dc19d7a..996a3a5d 100644 --- a/tests/unit/public-https-transport.test.mjs +++ b/tests/unit/public-https-transport.test.mjs @@ -145,6 +145,40 @@ assert.equal( 'only connection-establishment failures may advance to another validated address', ); +let postTlsAttempts = 0; +const noReplayAfterSecureConnect = createPublicHttpsTransport({ + lookup: async () => [PUBLIC_A, PUBLIC_B], + request: (_url, options) => { + postTlsAttempts += 1; + const req = new EventEmitter(); + req.end = () => { + options.lookup('ignored.example', {}, (error) => { + assert.ifError(error); + 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.fetch('https://idp.example.test/token', { + method: 'POST', + body: 'grant_type=authorization_code', + }), + WebhookTransportError, + 'a non-idempotent request must not replay after TLS is established even without response headers', +); +assert.equal( + postTlsAttempts, + 1, + 'a post-handshake failure is ambiguous and must not consume an authorization code twice', +); + await assert.rejects( transport.fetch('https://idp.example.test/jwks', { maxResponseBytes: 0 }), /positive safe integer/, From 2be68fd19590c4a4c4094c786bc433ed18725296 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 16:16:54 -0700 Subject: [PATCH 085/157] fix(transport): prevent ambiguous POST replay --- server/webhook_transport.mjs | 42 ++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/server/webhook_transport.mjs b/server/webhook_transport.mjs index 3415e69d..337c5d37 100644 --- a/server/webhook_transport.mjs +++ b/server/webhook_transport.mjs @@ -194,7 +194,16 @@ function pinnedRequestOptions(destination, candidate, options = {}) { }; } -async function postToCandidate(destination, candidate, { headers, body, signal }, request) { +function trackSecureConnect(request, attempt) { + if (!attempt || typeof request?.once !== 'function') return; + 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) => { @@ -213,6 +222,7 @@ async function postToCandidate(destination, candidate, { headers, body, signal } reject(new WebhookTransportError()); return; } + trackSecureConnect(req, attempt); req.once?.('error', () => reject(new WebhookTransportError())); req.end(body); }), signal); @@ -297,6 +307,7 @@ async function fetchFromCandidate( fail(); return; } + trackSecureConnect(req, attempt); req.once?.('error', fail); if (body === undefined || body === null) { req.end(); @@ -314,11 +325,17 @@ async function fetchFromCandidate( } } +function methodMayReplay(method) { + return method === 'GET' || method === 'HEAD'; +} + /** * Build a bounded public-HTTPS fetch transport for server-side metadata flows. * Each request resolves DNS afresh, fails closed if any answer is non-public, * pins every socket to a validated candidate, preserves the original TLS SNI, * disables pooling, never follows redirects, and bounds response buffering. + * GET/HEAD may fail over after a post-handshake transport error; mutating + * requests stop after TLS establishment because their delivery is ambiguous. */ export function createPublicHttpsTransport({ lookup = dnsLookup, request = httpsRequest } = {}) { if (typeof lookup !== 'function' || typeof request !== 'function') { @@ -346,15 +363,16 @@ export function createPublicHttpsTransport({ lookup = dnsLookup, request = https const candidates = await resolvePublicAddresses(destination, lookup, signal); const requestHeaders = identityEncodedHeaders(headers); + const requestMethod = String(method || 'GET').toUpperCase(); let lastError; for (const candidate of candidates) { - const attempt = { responseStarted: false }; + const attempt = { responseStarted: false, secureConnected: false }; try { return await fetchFromCandidate( destination, candidate, { - method: String(method || 'GET').toUpperCase(), + method: requestMethod, headers: requestHeaders, body, signal, @@ -366,7 +384,11 @@ export function createPublicHttpsTransport({ lookup = dnsLookup, request = https } catch (error) { if (!(error instanceof WebhookTransportError)) throw error; lastError = error; - if (signal?.aborted || attempt.responseStarted) throw error; + if ( + signal?.aborted + || attempt.responseStarted + || (attempt.secureConnected && !methodMayReplay(requestMethod)) + ) throw error; } } throw lastError || new WebhookTransportError(); @@ -379,9 +401,10 @@ export function createPublicHttpsTransport({ lookup = dnsLookup, request = https * Every post resolves afresh, rejects mixed/private answers, pins each socket to * a validated public candidate, preserves the original hostname for Host/TLS, * and never follows redirects because Node's native HTTPS client does not do so. - * Connect/transport failure may fall through to another address from the same - * fully validated DNS answer set; an HTTP response is authoritative and returns - * immediately, while every later application retry performs fresh DNS again. + * Pre-handshake connect failure may fall through to another address from the + * same fully validated DNS answer set. Once TLS succeeds, delivery is ambiguous + * and the same signed body is never replayed to another candidate; a later + * application retry performs fresh DNS again. */ export function createWebhookTransport({ lookup = dnsLookup, request = httpsRequest } = {}) { if (typeof lookup !== 'function' || typeof request !== 'function') { @@ -401,17 +424,18 @@ export function createWebhookTransport({ lookup = dnsLookup, request = httpsRequ const candidates = await resolvePublicAddresses(destination, lookup, signal); let lastError; for (const candidate of candidates) { + const attempt = { secureConnected: false }; try { return await postToCandidate( destination, candidate, - { headers, body, signal }, + { headers, body, signal, attempt }, request, ); } catch (error) { if (!(error instanceof WebhookTransportError)) throw error; lastError = error; - if (signal?.aborted) throw error; + if (signal?.aborted || attempt.secureConnected) throw error; } } throw lastError || new WebhookTransportError(); From c356313f48d852227abae127e1c0ecd1479a0fc7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 16:18:31 -0700 Subject: [PATCH 086/157] test(webhook): cover post-handshake no-replay --- tests/unit/webhook-transport.test.mjs | 33 +++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/unit/webhook-transport.test.mjs b/tests/unit/webhook-transport.test.mjs index 148f510a..0916f306 100644 --- a/tests/unit/webhook-transport.test.mjs +++ b/tests/unit/webhook-transport.test.mjs @@ -264,6 +264,39 @@ assert.equal( '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, From e8531e1d55442f82942bde33fef879191e2c8dd9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 10:01:51 -0700 Subject: [PATCH 087/157] fix(stack): preserve protected Playwright version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d0cdfe75..d4797063 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "hono": "^4.13.0" }, "devDependencies": { - "@playwright/test": "1.61.1", + "@playwright/test": "1.62.1", "c8": "12.0.0", "fast-check": "4.9.0" } From 7fb7d7ad207032ef2f67fc1752d02ae00d14a182 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 10:02:54 -0700 Subject: [PATCH 088/157] fix(stack): preserve protected Playwright lockfile --- package-lock.json | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index da668c20..00a99254 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "hono": "^4.13.0" }, "devDependencies": { - "@playwright/test": "1.61.1", + "@playwright/test": "1.62.1", "c8": "12.0.0", "fast-check": "4.9.0" }, @@ -81,19 +81,19 @@ } }, "node_modules/@playwright/test": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", - "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.61.1" + "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@types/istanbul-lib-coverage": { @@ -581,35 +581,35 @@ } }, "node_modules/playwright": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", - "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.61.1" + "playwright-core": "1.62.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" }, "optionalDependencies": { "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", - "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/pure-rand": { From 6437202fe798c519b10cdad3342759c03463d255 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:59:21 -0700 Subject: [PATCH 089/157] test(transport): reproduce null-body response deadlock --- tests/unit/public-https-transport.test.mjs | 25 ++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/unit/public-https-transport.test.mjs b/tests/unit/public-https-transport.test.mjs index 996a3a5d..3efe3b43 100644 --- a/tests/unit/public-https-transport.test.mjs +++ b/tests/unit/public-https-transport.test.mjs @@ -82,6 +82,31 @@ assert.deepEqual( 'every fallback attempt is pinned, disables pooling, preserves SNI, and requests identity encoding', ); +const nullBodyStatusTransport = createPublicHttpsTransport({ + lookup: async () => [PUBLIC_A], + request: (_url, _options, callback) => { + const req = new EventEmitter(); + req.end = () => { + const response = new EventEmitter(); + response.statusCode = 204; + response.headers = { 'content-type': 'application/json' }; + response.destroy = () => {}; + callback(response); + queueMicrotask(() => { + response.emit('data', Buffer.from('unexpected upstream bytes')); + response.emit('end'); + }); + }; + return req; + }, +}); +const nullBodyResponse = await nullBodyStatusTransport.fetch( + 'https://idp.example.test/no-content', + { signal: AbortSignal.timeout(250) }, +); +assert.equal(nullBodyResponse.status, 204); +assert.equal(await nullBodyResponse.text(), ''); + const oversized = createPublicHttpsTransport({ lookup: async () => [PUBLIC_A], request: (_url, _options, callback) => { From 60bed47879f725b8d28f30880b835b679f772e09 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:01:03 -0700 Subject: [PATCH 090/157] fix(transport): settle null-body responses safely --- server/webhook_transport.mjs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/server/webhook_transport.mjs b/server/webhook_transport.mjs index 337c5d37..14793070 100644 --- a/server/webhook_transport.mjs +++ b/server/webhook_transport.mjs @@ -294,10 +294,13 @@ async function fetchFromCandidate( } const responseHeaders = new Headers(); appendResponseHeaders(responseHeaders, response.headers); - const responseBody = chunks.length ? Buffer.concat(chunks) : null; + const responseBody = status === 204 || status === 205 || status === 304 + ? null + : (chunks.length ? Buffer.concat(chunks) : null); try { + const builtResponse = new Response(responseBody, { status, headers: responseHeaders }); settled = true; - resolve(new Response(responseBody, { status, headers: responseHeaders })); + resolve(builtResponse); } catch { fail(); } @@ -450,4 +453,4 @@ const webhookTransport = createWebhookTransport(); export const fetchPublicHttps = (url, options) => publicHttpsTransport.fetch(url, options); /** Send one signed webhook attempt through the production SSRF-safe transport. */ -export const postWebhook = (url, options) => webhookTransport.post(url, options); +export const postWebhook = (url, options) => webhookTransport.post(url, options); \ No newline at end of file From adf83bdb884a18fe6bf8b28da3a2e6aebf9647d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:55:58 -0700 Subject: [PATCH 091/157] test(auth): preserve legacy email identity compatibility --- package.json | 2 +- tests/api/email-identity-compat.test.mjs | 60 ++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 tests/api/email-identity-compat.test.mjs diff --git a/package.json b/package.json index d4797063..b2a6f689 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/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs && node tests/api/static-prototype-pollution.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/review-regressions.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-state-capacity.test.mjs && node tests/api/oidc-mock-dev-only.test.mjs && node tests/api/oidc-timeout.test.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 && node tests/api/static-prototype-pollution.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/review-regressions.test.mjs && node tests/api/email-identity-compat.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-state-capacity.test.mjs && node tests/api/oidc-mock-dev-only.test.mjs && node tests/api/oidc-timeout.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/public-https-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/public-https-transport.test.mjs && npm run test:api", diff --git a/tests/api/email-identity-compat.test.mjs b/tests/api/email-identity-compat.test.mjs new file mode 100644 index 00000000..0863a875 --- /dev/null +++ b/tests/api/email-identity-compat.test.mjs @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + +const { app } = await import('../../server/app.mjs'); +const { app: coreApp } = await import('../../server/app_core.mjs'); + +const jsonHeaders = { 'content-type': 'application/json' }; +const payload = (value) => JSON.stringify(value); + +async function legacySignup(email, password = 'password123') { + return coreApp.request('/api/auth/signup', { + method: 'POST', + headers: jsonHeaders, + body: payload({ email, password, name: 'Legacy Owner' }), + }); +} + +async function publicAuth(path, email, password = 'password123') { + return app.request(path, { + method: 'POST', + headers: jsonHeaders, + body: payload({ email, password, name: 'Canonical Owner' }), + }); +} + +test('legacy mixed-case password identities remain reachable through the public login boundary', async () => { + const legacyEmail = 'Legacy.Owner@ScopeWeave.Test'; + const created = await legacySignup(legacyEmail); + assert.equal(created.status, 200, 'pre-canonical mixed-case account exists'); + + const sameSpelling = await publicAuth('/api/auth/login', legacyEmail); + assert.equal( + sameSpelling.status, + 200, + 'the public facade must not lock out an account that previously authenticated with this exact spelling', + ); + + const canonicalSpelling = await publicAuth('/api/auth/login', legacyEmail.toLowerCase()); + assert.equal( + canonicalSpelling.status, + 200, + 'canonical login remains compatible when exactly one legacy identity matches case-insensitively', + ); +}); + +test('canonical signup cannot create a case-only duplicate of a legacy account', async () => { + const legacyEmail = 'Existing.Owner@ScopeWeave.Test'; + const created = await legacySignup(legacyEmail, 'password456'); + assert.equal(created.status, 200, 'pre-canonical mixed-case account exists'); + + const duplicate = await publicAuth('/api/auth/signup', legacyEmail.toLowerCase(), 'password789'); + assert.equal( + duplicate.status, + 409, + 'case-only duplicates must be rejected instead of creating a second login identity', + ); +}); From 71ab96b28189f5b486501a241312c95e96683169 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:59:38 -0700 Subject: [PATCH 092/157] fix(auth): retain legacy email account reachability --- server/app.mjs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 0b369b1b..113a699f 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -6,6 +6,7 @@ // this facade; app_core.mjs is an implementation module, not a public entrypoint. import { createPublicKey, randomBytes, verify as verifySignature } from 'node:crypto'; import { app as coreApp } from './app_core.mjs'; +import { db } from './db.mjs'; import { WebhookDestinationError, fetchPublicHttps, @@ -35,6 +36,12 @@ const oidcNonceByCode = new Map(); let oidcDiscoveryCache = null; let oidcSigningKeyCache = null; +function matchingStoredEmails(email) { + return db.prepare( + 'SELECT email FROM users WHERE email = ? COLLATE NOCASE ORDER BY id LIMIT 2', + ).all(email); +} + function isSignedWebhookRequest(request) { if (request.method.toUpperCase() !== 'POST') return false; return Boolean( @@ -295,7 +302,11 @@ async function canonicalInboundRequest(request) { && !Array.isArray(payload) && typeof payload.email === 'string' ) { - const email = payload.email.trim().toLowerCase(); + const trimmedEmail = payload.email.trim(); + const storedMatches = matchingStoredEmails(trimmedEmail); + const email = url.pathname.endsWith('/signup') + ? (storedMatches[0]?.email || trimmedEmail.toLowerCase()) + : (storedMatches.length === 1 ? storedMatches[0].email : trimmedEmail); if (email !== payload.email) return requestWithJson(request, { ...payload, email }); } } @@ -554,4 +565,4 @@ export const app = new Proxy(coreApp, { const value = Reflect.get(target, property, target); return typeof value === 'function' ? value.bind(target) : value; }, -}); \ No newline at end of file +}); From ccae5fe83ccce62ba51908f92df3b7d6065cb4e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:47:46 -0700 Subject: [PATCH 093/157] test(security): reject OIDC flow at exact expiry --- tests/api/oidc-timeout.test.mjs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/tests/api/oidc-timeout.test.mjs b/tests/api/oidc-timeout.test.mjs index 4a78d5c2..a2b64462 100644 --- a/tests/api/oidc-timeout.test.mjs +++ b/tests/api/oidc-timeout.test.mjs @@ -29,6 +29,7 @@ let observedUpstreamAbort = null; let jwksFetches = 0; const originalTimeout = AbortSignal.timeout; const originalFetch = globalThis.fetch; +const originalDateNow = Date.now; const encoded = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); const signIdToken = (claims) => { @@ -102,7 +103,7 @@ globalThis.fetch = async (input, init) => { exp: now + 300, }; - if (code === 'valid-code' || code === 'valid-code-cache') { + if (code === 'valid-code' || code === 'valid-code-cache' || code === 'exact-expiry-code') { return Response.json({ id_token: signIdToken(baseClaims) }); } if (code === 'forged-code') { @@ -206,6 +207,24 @@ try { 'OIDC token exchange preserves callback cancellation while retaining its timeout budget', ); + const anchoredNow = originalDateNow(); + let exactExpiryState; + try { + Date.now = () => anchoredNow; + exactExpiryState = await startFlow('exact-expiry-code'); + Date.now = () => anchoredNow + (5 * 60 * 1000); + const exactExpiry = await app.request( + `/api/auth/oidc/callback?state=${encodeURIComponent(exactExpiryState)}&code=exact-expiry-code`, + ); + assert.equal( + exactExpiry.status, + 400, + 'an OIDC flow is unusable at the exact configured expiration instant', + ); + } finally { + Date.now = originalDateNow; + } + for (let index = 0; index < 256; index += 1) { const pending = await app.request('/api/auth/oidc/start'); assert.equal( @@ -226,9 +245,10 @@ try { 'capacity exhaustion returns a stable non-secret degraded-mode response', ); } finally { + Date.now = originalDateNow; AbortSignal.timeout = originalTimeout; globalThis.fetch = originalFetch; delete process.env.SCOPEWEAVE_DEV; } -console.log('oidc discovery, private-endpoint, validation, cancellation, redirect, timeout, bounded JWKS reuse, and state-capacity regression passed'); \ No newline at end of file +console.log('oidc discovery, private-endpoint, validation, cancellation, inclusive expiry, redirect, timeout, bounded JWKS reuse, and state-capacity regression passed'); From d6c61b85b976c09a2fe2d0f8dd1b3b473aea9917 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:49:00 -0700 Subject: [PATCH 094/157] fix(security): expire OIDC bindings inclusively --- server/app.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 113a699f..6d30825d 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -218,7 +218,7 @@ async function boundedOidcFetch(request) { const form = new URLSearchParams(new TextDecoder().decode(body)); const code = form.get('code'); const expectedNonce = code ? oidcNonceByCode.get(code) : null; - if (!expectedNonce || expectedNonce.exp < Date.now()) throw new Error('OIDC flow binding unavailable'); + if (!expectedNonce || expectedNonce.exp <= Date.now()) throw new Error('OIDC flow binding unavailable'); const discovery = await loadOidcDiscovery(); const signal = AbortSignal.any([ request.signal, @@ -330,7 +330,7 @@ async function canonicalInboundRequest(request) { function cleanupOidcNonces(now = Date.now()) { for (const [state, record] of oidcNonceByState.entries()) { - if (record.exp < now) oidcNonceByState.delete(state); + if (record.exp <= now) oidcNonceByState.delete(state); } } @@ -393,7 +393,7 @@ async function coreFetchWithOidcBinding(request, rest) { const code = requestUrl.searchParams.get('code'); const record = state ? oidcNonceByState.get(state) : null; if (state) oidcNonceByState.delete(state); - if (code && record && record.exp >= Date.now()) { + if (code && record && record.exp > Date.now()) { oidcNonceByCode.set(code, { ...record, callbackSignal: request.signal }); } try { From a0a3220c508524962f066a3548917fbc4fd52567 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:51:54 -0700 Subject: [PATCH 095/157] test(security): isolate facade OIDC expiry boundary --- tests/api/oidc-timeout.test.mjs | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/tests/api/oidc-timeout.test.mjs b/tests/api/oidc-timeout.test.mjs index a2b64462..f514c059 100644 --- a/tests/api/oidc-timeout.test.mjs +++ b/tests/api/oidc-timeout.test.mjs @@ -27,6 +27,7 @@ let observedTimeout = null; let callbackAbortController = null; let observedUpstreamAbort = null; let jwksFetches = 0; +let exactExpiryTokenExchanges = 0; const originalTimeout = AbortSignal.timeout; const originalFetch = globalThis.fetch; const originalDateNow = Date.now; @@ -91,6 +92,7 @@ globalThis.fetch = async (input, init) => { const form = new URLSearchParams(await request.clone().text()); const code = form.get('code'); + if (code === 'exact-expiry-code') exactExpiryTokenExchanges += 1; const expectedNonce = expectedNonceByCode.get(code); const now = Math.floor(Date.now() / 1000); const baseClaims = { @@ -210,7 +212,18 @@ try { const anchoredNow = originalDateNow(); let exactExpiryState; try { - Date.now = () => anchoredNow; + // Keep the historical core OIDC state valid one second longer than the + // facade nonce. This isolates the facade boundary: on the vulnerable + // predecessor, equality at the facade expiry reaches the provider and + // succeeds; on the fixed code it is rejected before any token exchange. + const startMoments = [ + anchoredNow, + anchoredNow + 1000, + anchoredNow, + anchoredNow, + ]; + let startMomentIndex = 0; + Date.now = () => startMoments[Math.min(startMomentIndex++, startMoments.length - 1)]; exactExpiryState = await startFlow('exact-expiry-code'); Date.now = () => anchoredNow + (5 * 60 * 1000); const exactExpiry = await app.request( @@ -219,7 +232,12 @@ try { assert.equal( exactExpiry.status, 400, - 'an OIDC flow is unusable at the exact configured expiration instant', + 'the facade OIDC binding is unusable at its exact configured expiration instant', + ); + assert.equal( + exactExpiryTokenExchanges, + 0, + 'an exact-expired facade binding is rejected before any provider token exchange', ); } finally { Date.now = originalDateNow; @@ -251,4 +269,4 @@ try { delete process.env.SCOPEWEAVE_DEV; } -console.log('oidc discovery, private-endpoint, validation, cancellation, inclusive expiry, redirect, timeout, bounded JWKS reuse, and state-capacity regression passed'); +console.log('oidc discovery, private-endpoint, validation, cancellation, inclusive facade expiry, redirect, timeout, bounded JWKS reuse, and state-capacity regression passed'); From ba61032b448ef045030fdc9f266f3b0ed906712f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 20:30:22 -0700 Subject: [PATCH 096/157] test(oidc): reject future not-before ID tokens --- tests/api/oidc-timeout.test.mjs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/api/oidc-timeout.test.mjs b/tests/api/oidc-timeout.test.mjs index f514c059..de5af8a9 100644 --- a/tests/api/oidc-timeout.test.mjs +++ b/tests/api/oidc-timeout.test.mjs @@ -122,6 +122,14 @@ globalThis.fetch = async (input, init) => { if (code === 'wrong-nonce-code') { return Response.json({ id_token: signIdToken({ ...baseClaims, nonce: 'attacker-nonce' }) }); } + if (code === 'future-not-before-code') { + return Response.json({ + id_token: signIdToken({ + ...baseClaims, + nbf: now + 120, + }), + }); + } if (code === 'cancelled-code') { callbackAbortController.abort(); observedUpstreamAbort = request.signal.aborted; @@ -196,6 +204,13 @@ try { const wrongNonce = await callback('wrong-nonce-code'); assert.equal(wrongNonce.status, 400, 'an ID token from another authorization flow is rejected'); + const futureNotBefore = await callback('future-not-before-code'); + assert.equal( + futureNotBefore.status, + 400, + 'a signed ID token must not be accepted before its nbf time, beyond the allowed clock skew', + ); + const cancelledState = await startFlow('cancelled-code'); callbackAbortController = new AbortController(); const cancelled = await app.request(new Request( @@ -269,4 +284,4 @@ try { delete process.env.SCOPEWEAVE_DEV; } -console.log('oidc discovery, private-endpoint, validation, cancellation, inclusive facade expiry, redirect, timeout, bounded JWKS reuse, and state-capacity regression passed'); +console.log('oidc discovery, private-endpoint, validation, not-before, cancellation, inclusive facade expiry, redirect, timeout, bounded JWKS reuse, and state-capacity regression passed'); \ No newline at end of file From b726df8b3874b82d9ea55c60f32a9d718c276724 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 20:50:38 -0700 Subject: [PATCH 097/157] fix(oidc): enforce ID token not-before --- server/app.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/app.mjs b/server/app.mjs index 6d30825d..97cfaa34 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -208,6 +208,10 @@ async function verifyOidcIdToken(idToken, expectedNonce, discovery) { const now = Math.floor(Date.now() / 1000); if (claims.iss !== OIDC_ISSUER || !audienceMatches(claims)) throw new Error('invalid token binding'); if (!Number.isInteger(claims.exp) || claims.exp <= now - OIDC_CLOCK_SKEW_SECONDS) throw new Error('expired token'); + if ( + claims.nbf !== undefined + && (!Number.isInteger(claims.nbf) || claims.nbf > now + OIDC_CLOCK_SKEW_SECONDS) + ) throw new Error('token not active'); if (!Number.isInteger(claims.iat) || claims.iat > now + OIDC_CLOCK_SKEW_SECONDS) throw new Error('invalid issued-at'); if (typeof claims.sub !== 'string' || !claims.sub || claims.nonce !== expectedNonce) throw new Error('invalid subject or nonce'); if (typeof claims.email !== 'string' || !claims.email.trim()) throw new Error('missing email'); From 01833c51851804c3cd52e1b53361c7b45fe0065d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 21:46:21 -0700 Subject: [PATCH 098/157] test(observability): cover facade rejection accounting --- tests/api/review-regressions.test.mjs | 29 +++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/api/review-regressions.test.mjs b/tests/api/review-regressions.test.mjs index cf4e6a5d..c2bb3c5e 100644 --- a/tests/api/review-regressions.test.mjs +++ b/tests/api/review-regressions.test.mjs @@ -233,3 +233,32 @@ test('webhook authorization probing never treats an arbitrary 400 as authorizati 'only an explicit successful authorization probe may be replaced by the public destination-policy error', ); }); + +test('facade webhook rejection is observed as the real POST exactly once', async () => { + const { token, org } = await createOwner('facade-observability@scopeweave.test'); + const before = await (await request('/api/metrics')).json(); + + const rejected = await request(`/api/orgs/${org.id}/webhooks`, { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + body: body({ url: 'http://127.0.0.1/private', events: ['project.update'] }), + }); + assert.equal(rejected.status, 400, 'authorized private destination is rejected by the facade'); + + const after = await (await request('/api/metrics')).json(); + assert.equal( + after.requests, + before.requests + 2, + 'metrics include the baseline metrics GET and one customer-visible rejected POST, not an internal probe', + ); + assert.equal( + after.s2xx, + before.s2xx + 1, + 'the internal authorization probe is not counted as a successful customer request', + ); + assert.equal( + after.s4xx, + before.s4xx + 1, + 'the facade-generated 400 is counted as the customer-visible request outcome', + ); +}); From 760ba5ca05b5ef764bbb047b599d8e581112d365 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 21:54:52 -0700 Subject: [PATCH 099/157] fix(observability): run webhook policy guard on real POST --- server/app.mjs | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 97cfaa34..889d09b2 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -350,13 +350,6 @@ function bindOidcStartNonce(request, response, discovery) { } const state = authorization.searchParams.get('state'); if (!state) return response; - cleanupOidcNonces(); - if (oidcNonceByState.size >= OIDC_STATE_MAX_ENTRIES) { - return Response.json( - { error: 'OIDC temporarily unavailable' }, - { status: 503 }, - ); - } const nonce = randomBytes(16).toString('base64url'); oidcNonceByState.set(state, { nonce, exp: Date.now() + OIDC_STATE_TTL_MS }); authorization.searchParams.set('nonce', nonce); @@ -380,6 +373,13 @@ async function coreFetchWithOidcBinding(request, rest) { return coreApp.fetch(request, ...rest); } if (requestUrl.pathname === '/api/auth/oidc/start') { + cleanupOidcNonces(); + if (oidcNonceByState.size >= OIDC_STATE_MAX_ENTRIES) { + return Response.json( + { error: 'OIDC temporarily unavailable' }, + { status: 503 }, + ); + } let discovery; try { discovery = await loadOidcDiscovery(); @@ -410,25 +410,28 @@ async function coreFetchWithOidcBinding(request, rest) { function authorizationProbeRequest(request) { const headers = new Headers(request.headers); headers.delete('content-length'); - headers.delete('content-type'); + headers.set('content-type', 'application/json'); return new Request(request.url, { - method: 'GET', + method: 'POST', headers, + body: JSON.stringify({ url: '', events: [] }), signal: request.signal, }); } /** - * Ask the existing read-only webhook collection route to run the same real - * authentication, tenant-role, rate-limit, and request middleware before this - * facade returns a destination-policy error. Authorized managers receive the - * collection route's explicit 200 result; every denial, rate limit, malformed - * request, or internal failure is propagated unchanged. This avoids classifying - * an arbitrary 400 from the legacy POST route as authorization success. + * Run a controlled, side-effect-free webhook registration through the real + * authentication, rate-limit, and tenant-role chain before this facade returns + * a destination-policy error. The synthetic payload is valid JSON but has an + * empty URL, so an authorized manager deterministically reaches the legacy + * pre-insert URL guard and receives 400. Every denial, rate limit, or internal + * failure is propagated unchanged. Because the probe uses the customer's real + * POST path and method, request metrics and structured logs reflect that + * customer-visible operation instead of a synthetic GET. */ async function deniedRegistrationAuthorization(request, rest) { const response = await coreApp.fetch(authorizationProbeRequest(request), ...rest); - return response.status === 200 ? null : response; + return response.status === 400 ? null : response; } function declaredRegistrationBodyTooLarge(request) { From c668adaacb69de748f0b0eb196bb059684554e9a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 21:57:38 -0700 Subject: [PATCH 100/157] test(observability): cover facade OIDC rejection --- tests/api/review-regressions.test.mjs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/api/review-regressions.test.mjs b/tests/api/review-regressions.test.mjs index c2bb3c5e..27f42795 100644 --- a/tests/api/review-regressions.test.mjs +++ b/tests/api/review-regressions.test.mjs @@ -262,3 +262,27 @@ test('facade webhook rejection is observed as the real POST exactly once', async 'the facade-generated 400 is counted as the customer-visible request outcome', ); }); + +test('facade OIDC rejection is observed as the real request exactly once', async () => { + const before = await (await request('/api/metrics')).json(); + + const rejected = await request('/api/auth/oidc/start'); + assert.equal(rejected.status, 404, 'unconfigured production OIDC remains hidden as not found'); + + const after = await (await request('/api/metrics')).json(); + assert.equal( + after.requests, + before.requests + 2, + 'metrics include the baseline metrics GET and one facade-rejected OIDC request', + ); + assert.equal( + after.s2xx, + before.s2xx + 1, + 'only the follow-up metrics request increments the success class', + ); + assert.equal( + after.s4xx, + before.s4xx + 1, + 'the facade-generated OIDC 404 is counted as the customer-visible outcome', + ); +}); From 83b7d69942645a5341ceaaf852713de10707bff3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 22:27:30 -0700 Subject: [PATCH 101/157] fix: preserve probe errors and facade observability --- server/app.mjs | 76 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 68 insertions(+), 8 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 889d09b2..6e00a7d8 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -20,6 +20,8 @@ const WEBHOOK_REGISTRATION_PATH = /^\/api\/orgs\/[^/]+\/webhooks$/; const WEBHOOK_REGISTRATION_BODY_MAX_BYTES = 16 * 1024; const AUDIT_PATH = /^\/api\/orgs\/[^/]+\/audit$/; const AUTH_EMAIL_PATH = /^\/api\/auth\/(?:signup|login)$/; +const METRICS_PATH = '/api/metrics'; +const LEGACY_WEBHOOK_URL_REQUIRED_ERROR = 'valid http(s) url required'; const OIDC_ISSUER = process.env.OIDC_ISSUER ? process.env.OIDC_ISSUER.replace(/\/$/, '') : null; @@ -33,6 +35,8 @@ const OIDC_STATE_MAX_ENTRIES = 256; const OIDC_CLOCK_SKEW_SECONDS = 60; const oidcNonceByState = new Map(); const oidcNonceByCode = new Map(); +const facadeMetrics = { requests: 0, s2xx: 0, s4xx: 0, s5xx: 0 }; +const quietFacadeLogs = String(process.env.SCOPEWEAVE_DB || '').includes(':memory:'); let oidcDiscoveryCache = null; let oidcSigningKeyCache = null; @@ -42,6 +46,45 @@ function matchingStoredEmails(email) { ).all(email); } +function observeFacadeResponse(request, response, startedAt = Date.now()) { + facadeMetrics.requests += 1; + if (response.status >= 500) facadeMetrics.s5xx += 1; + else if (response.status >= 400) facadeMetrics.s4xx += 1; + else if (response.status >= 200) facadeMetrics.s2xx += 1; + if (!quietFacadeLogs) { + console.log(JSON.stringify({ + ts: new Date().toISOString(), + method: request.method, + path: new URL(request.url).pathname, + status: response.status, + ms: Date.now() - startedAt, + })); + } + return response; +} + +async function mergeFacadeMetricsResponse(request, response) { + if ( + request.method !== 'GET' + || new URL(request.url).pathname !== METRICS_PATH + || !response.ok + ) return response; + + const payload = await response.clone().json().catch(() => null); + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return response; + const merged = { ...payload }; + for (const key of Object.keys(facadeMetrics)) { + merged[key] = (Number(merged[key]) || 0) + facadeMetrics[key]; + } + const headers = new Headers(response.headers); + headers.delete('content-length'); + return new Response(JSON.stringify(merged), { + status: response.status, + statusText: response.statusText, + headers, + }); +} + function isSignedWebhookRequest(request) { if (request.method.toUpperCase() !== 'POST') return false; return Boolean( @@ -359,13 +402,18 @@ function bindOidcStartNonce(request, response, discovery) { } async function coreFetchWithOidcBinding(request, rest) { + const startedAt = Date.now(); const requestUrl = new URL(request.url); if (!OIDC_ISSUER) { if ( process.env.SCOPEWEAVE_DEV !== '1' && requestUrl.pathname.startsWith('/api/auth/oidc/') ) { - return Response.json({ error: 'not found' }, { status: 404 }); + return observeFacadeResponse( + request, + Response.json({ error: 'not found' }, { status: 404 }), + startedAt, + ); } return coreApp.fetch(request, ...rest); } @@ -375,16 +423,24 @@ async function coreFetchWithOidcBinding(request, rest) { if (requestUrl.pathname === '/api/auth/oidc/start') { cleanupOidcNonces(); if (oidcNonceByState.size >= OIDC_STATE_MAX_ENTRIES) { - return Response.json( - { error: 'OIDC temporarily unavailable' }, - { status: 503 }, + return observeFacadeResponse( + request, + Response.json( + { error: 'OIDC temporarily unavailable' }, + { status: 503 }, + ), + startedAt, ); } let discovery; try { discovery = await loadOidcDiscovery(); } catch { - return Response.json({ error: 'OIDC provider unavailable' }, { status: 502 }); + return observeFacadeResponse( + request, + Response.json({ error: 'OIDC provider unavailable' }, { status: 502 }), + startedAt, + ); } const response = await coreApp.fetch(request, ...rest); return bindOidcStartNonce(request, response, discovery); @@ -431,7 +487,9 @@ function authorizationProbeRequest(request) { */ async function deniedRegistrationAuthorization(request, rest) { const response = await coreApp.fetch(authorizationProbeRequest(request), ...rest); - return response.status === 400 ? null : response; + if (response.status !== 400) return response; + const payload = await response.clone().json().catch(() => null); + return payload?.error === LEGACY_WEBHOOK_URL_REQUIRED_ERROR ? null : response; } function declaredRegistrationBodyTooLarge(request) { @@ -552,7 +610,9 @@ async function secureFetch(request, ...rest) { const canonicalRequest = await canonicalInboundRequest(request); const policy = await registrationPolicyResult(canonicalRequest, rest); if (policy?.response) return policy.response; - return coreFetchWithOidcBinding(policy?.request || canonicalRequest, rest); + const effectiveRequest = policy?.request || canonicalRequest; + const response = await coreFetchWithOidcBinding(effectiveRequest, rest); + return mergeFacadeMetricsResponse(effectiveRequest, response); } async function secureRequest(input, init, ...rest) { @@ -572,4 +632,4 @@ export const app = new Proxy(coreApp, { const value = Reflect.get(target, property, target); return typeof value === 'function' ? value.bind(target) : value; }, -}); +}); \ No newline at end of file From d9da6f3d43372ebc1888204299017ecadb536aba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 22:43:36 -0700 Subject: [PATCH 102/157] test(metrics): reproduce missing facade Prometheus counts --- tests/api/review-regressions.test.mjs | 40 +++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/api/review-regressions.test.mjs b/tests/api/review-regressions.test.mjs index 27f42795..2c3f84f0 100644 --- a/tests/api/review-regressions.test.mjs +++ b/tests/api/review-regressions.test.mjs @@ -29,6 +29,12 @@ const request = (target, options = {}) => app.request(target, { }, }); +function prometheusMetric(text, name) { + const match = text.match(new RegExp(`^${name}\\s+(-?\\d+(?:\\.\\d+)?)$`, 'm')); + assert.ok(match, `Prometheus output includes ${name}`); + return Number(match[1]); +} + async function createOwner(email) { const response = await request('/api/auth/signup', { method: 'POST', @@ -286,3 +292,37 @@ test('facade OIDC rejection is observed as the real request exactly once', async 'the facade-generated OIDC 404 is counted as the customer-visible outcome', ); }); + +test('Prometheus metrics include facade-only request outcomes', async () => { + const beforeText = await (await request('/api/metrics?format=prometheus')).text(); + const before = { + requests: prometheusMetric(beforeText, 'scopeweave_requests'), + s2xx: prometheusMetric(beforeText, 'scopeweave_s2xx'), + s4xx: prometheusMetric(beforeText, 'scopeweave_s4xx'), + }; + + const rejected = await request('/api/auth/oidc/start'); + assert.equal(rejected.status, 404, 'facade-only OIDC rejection is reproduced'); + + const afterText = await (await request('/api/metrics?format=prometheus')).text(); + const after = { + requests: prometheusMetric(afterText, 'scopeweave_requests'), + s2xx: prometheusMetric(afterText, 'scopeweave_s2xx'), + s4xx: prometheusMetric(afterText, 'scopeweave_s4xx'), + }; + assert.equal( + after.requests, + before.requests + 2, + 'Prometheus request totals include the baseline scrape and the facade-only rejection', + ); + assert.equal( + after.s2xx, + before.s2xx + 1, + 'Prometheus success totals include only the baseline scrape', + ); + assert.equal( + after.s4xx, + before.s4xx + 1, + 'Prometheus client-error totals include the facade-only rejection', + ); +}); From 5d561efc4ac413fde60f14cd0ce5f5beb56ab202 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 22:47:37 -0700 Subject: [PATCH 103/157] fix(metrics): merge facade counts into Prometheus --- server/app.mjs | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 6e00a7d8..04a40309 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -63,21 +63,45 @@ function observeFacadeResponse(request, response, startedAt = Date.now()) { return response; } +function mergePrometheusFacadeMetrics(payload) { + let merged = payload; + for (const [key, value] of Object.entries(facadeMetrics)) { + const metric = `scopeweave_${key}`; + merged = merged.replace( + new RegExp(`^(${metric}\\s+)(-?\\d+(?:\\.\\d+)?)$`, 'm'), + (_, prefix, current) => `${prefix}${Number(current) + value}`, + ); + } + return merged; +} + async function mergeFacadeMetricsResponse(request, response) { + const url = new URL(request.url); if ( request.method !== 'GET' - || new URL(request.url).pathname !== METRICS_PATH + || url.pathname !== METRICS_PATH || !response.ok ) return response; + const headers = new Headers(response.headers); + headers.delete('content-length'); + if (url.searchParams.get('format') === 'prometheus') { + return new Response( + mergePrometheusFacadeMetrics(await response.clone().text()), + { + status: response.status, + statusText: response.statusText, + headers, + }, + ); + } + const payload = await response.clone().json().catch(() => null); if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return response; const merged = { ...payload }; for (const key of Object.keys(facadeMetrics)) { merged[key] = (Number(merged[key]) || 0) + facadeMetrics[key]; } - const headers = new Headers(response.headers); - headers.delete('content-length'); return new Response(JSON.stringify(merged), { status: response.status, statusText: response.statusText, From ac6af179991fb1b846aecc0b9494d0c405fc585a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 23:45:00 -0700 Subject: [PATCH 104/157] test(oidc): reproduce multi-key JWKS cache thrash --- tests/api/oidc-timeout.test.mjs | 53 +++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/tests/api/oidc-timeout.test.mjs b/tests/api/oidc-timeout.test.mjs index de5af8a9..201c19ef 100644 --- a/tests/api/oidc-timeout.test.mjs +++ b/tests/api/oidc-timeout.test.mjs @@ -18,9 +18,13 @@ const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 20 const publicJwk = { ...publicKey.export({ format: 'jwk' }), alg: 'RS256', - kid: 'scopeweave-test-key', + kid: 'scopeweave-test-key-1', use: 'sig', }; +const signingJwks = Array.from({ length: 9 }, (_, index) => ({ + ...publicJwk, + kid: `scopeweave-test-key-${index + 1}`, +})); const expectedNonceByCode = new Map(); let discoveryMode = 'private-metadata'; let observedTimeout = null; @@ -33,8 +37,8 @@ const originalFetch = globalThis.fetch; const originalDateNow = Date.now; const encoded = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); -const signIdToken = (claims) => { - const header = encoded({ alg: 'RS256', kid: publicJwk.kid, typ: 'JWT' }); +const signIdToken = (claims, kid = publicJwk.kid) => { + const header = encoded({ alg: 'RS256', kid, typ: 'JWT' }); const payload = encoded(claims); const input = `${header}.${payload}`; const signature = createSign('RSA-SHA256').update(input).end().sign(privateKey).toString('base64url'); @@ -79,7 +83,7 @@ globalThis.fetch = async (input, init) => { 'error', 'OIDC JWKS retrieval must reject redirects before trusting signing-key bytes', ); - return Response.json({ keys: [publicJwk] }); + return Response.json({ keys: signingJwks }); } if (url !== tokenEndpoint) { throw new Error(`unexpected outbound fetch: ${url}`); @@ -108,6 +112,15 @@ globalThis.fetch = async (input, init) => { if (code === 'valid-code' || code === 'valid-code-cache' || code === 'exact-expiry-code') { return Response.json({ id_token: signIdToken(baseClaims) }); } + const signingKeyMatch = /^valid-code-key-(\d+)$/.exec(code || ''); + if (signingKeyMatch) { + const keyIndex = Number(signingKeyMatch[1]); + if (keyIndex >= 1 && keyIndex <= signingJwks.length) { + return Response.json({ + id_token: signIdToken(baseClaims, signingJwks[keyIndex - 1].kid), + }); + } + } if (code === 'forged-code') { const valid = signIdToken(baseClaims).split('.'); valid[2] = Buffer.from('forged-signature').toString('base64url'); @@ -192,6 +205,36 @@ try { 'repeated logins with the same signing key reuse bounded JWKS evidence instead of amplifying provider traffic', ); + const secondKeyLogin = await callback('valid-code-key-2'); + assert.equal(secondKeyLogin.status, 302, 'a concurrently published second signing key is accepted'); + assert.equal(jwksFetches, 2, 'a new kid requires one bounded JWKS refresh'); + + const firstKeyAgain = await callback('valid-code-key-1'); + assert.equal(firstKeyAgain.status, 302, 'the first signing key remains usable during provider key overlap'); + assert.equal( + jwksFetches, + 2, + 'alternating between two active kids reuses per-kid signing evidence instead of refetching JWKS', + ); + + for (let keyIndex = 3; keyIndex <= signingJwks.length; keyIndex += 1) { + const rotated = await callback(`valid-code-key-${keyIndex}`); + assert.equal(rotated.status, 302, `signing key ${keyIndex} is accepted during bounded rotation`); + } + assert.equal( + jwksFetches, + signingJwks.length, + 'each previously unseen kid causes at most one JWKS refresh while the cache fills', + ); + + const evictedFirstKey = await callback('valid-code-key-1'); + assert.equal(evictedFirstKey.status, 302, 'an evicted signing key can be revalidated from current JWKS'); + assert.equal( + jwksFetches, + signingJwks.length + 1, + 'the fixed-size signing-key cache evicts old evidence instead of growing without bound', + ); + const forged = await callback('forged-code'); assert.equal(forged.status, 400, 'a forged ID-token signature is rejected'); @@ -284,4 +327,4 @@ try { delete process.env.SCOPEWEAVE_DEV; } -console.log('oidc discovery, private-endpoint, validation, not-before, cancellation, inclusive facade expiry, redirect, timeout, bounded JWKS reuse, and state-capacity regression passed'); \ No newline at end of file +console.log('oidc discovery, private-endpoint, validation, not-before, cancellation, inclusive facade expiry, redirect, timeout, bounded per-kid JWKS reuse, and state-capacity regression passed'); \ No newline at end of file From f91efeb5cceb5031bf5bd7794752e63f30f75e3a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 23:46:57 -0700 Subject: [PATCH 105/157] fix(oidc): bound signing-key cache per kid --- server/app.mjs | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 04a40309..08b5b907 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -30,6 +30,7 @@ const OIDC_TOKEN_URL = OIDC_ISSUER ? `${OIDC_ISSUER}/token` : null; const OIDC_TOKEN_TIMEOUT_MS = 3000; const OIDC_DISCOVERY_TTL_MS = 60 * 1000; const OIDC_JWKS_TTL_MS = 60 * 1000; +const OIDC_SIGNING_KEY_MAX_ENTRIES = 8; const OIDC_STATE_TTL_MS = 5 * 60 * 1000; const OIDC_STATE_MAX_ENTRIES = 256; const OIDC_CLOCK_SKEW_SECONDS = 60; @@ -38,7 +39,7 @@ const oidcNonceByCode = new Map(); const facadeMetrics = { requests: 0, s2xx: 0, s4xx: 0, s5xx: 0 }; const quietFacadeLogs = String(process.env.SCOPEWEAVE_DB || '').includes(':memory:'); let oidcDiscoveryCache = null; -let oidcSigningKeyCache = null; +const oidcSigningKeyCache = new Map(); function matchingStoredEmails(email) { return db.prepare( @@ -226,14 +227,9 @@ async function loadOidcDiscovery(now = Date.now()) { } async function loadOidcSigningKey(discovery, kid, now = Date.now()) { - if ( - oidcSigningKeyCache - && oidcSigningKeyCache.expiresAt > now - && oidcSigningKeyCache.jwksUri === discovery.jwks_uri - && oidcSigningKeyCache.kid === kid - ) { - return oidcSigningKeyCache.key; - } + const cacheKey = JSON.stringify([discovery.jwks_uri, kid]); + const cached = oidcSigningKeyCache.get(cacheKey); + if (cached && cached.expiresAt > now) return cached.key; const jwks = await oidcProviderJson(discovery.jwks_uri); const keyData = Array.isArray(jwks.keys) @@ -247,12 +243,14 @@ async function loadOidcSigningKey(discovery, kid, now = Date.now()) { : null; if (!keyData) throw new Error('signing key unavailable'); const key = createPublicKey({ key: keyData, format: 'jwk' }); - oidcSigningKeyCache = { - jwksUri: discovery.jwks_uri, - kid, + oidcSigningKeyCache.delete(cacheKey); + if (oidcSigningKeyCache.size >= OIDC_SIGNING_KEY_MAX_ENTRIES) { + oidcSigningKeyCache.delete(oidcSigningKeyCache.keys().next().value); + } + oidcSigningKeyCache.set(cacheKey, { key, expiresAt: now + OIDC_JWKS_TTL_MS, - }; + }); return key; } From 30dc20108b0ee370d552e17006f485e64751d6b4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 23:51:33 -0700 Subject: [PATCH 106/157] test(auth): reproduce unbounded public-body buffering --- tests/api/review-regressions.test.mjs | 40 +++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/api/review-regressions.test.mjs b/tests/api/review-regressions.test.mjs index 2c3f84f0..4f1482e7 100644 --- a/tests/api/review-regressions.test.mjs +++ b/tests/api/review-regressions.test.mjs @@ -115,6 +115,46 @@ test('invalid webhook credentials cannot force unbounded pre-auth body buffering ); }); +test('public auth rejects an oversized streaming body before unbounded buffering', async () => { + let bodyPulls = 0; + const chunk = new Uint8Array(8 * 1024).fill(0x20); + const requestBody = new ReadableStream({ + pull(controller) { + bodyPulls += 1; + if (bodyPulls > 20) { + controller.close(); + return; + } + controller.enqueue(chunk); + }, + }, { highWaterMark: 0 }); + const oversizedLogin = new Request( + 'http://localhost/api/auth/login', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: requestBody, + duplex: 'half', + }, + ); + + const response = await app.request(oversizedLogin); + assert.equal( + response.status, + 413, + 'public login rejects a body that exceeds the bounded authentication budget', + ); + assert.deepEqual( + await response.json(), + { error: 'authentication request body too large' }, + 'oversized public authentication uses a stable non-secret rejection contract', + ); + assert.ok( + bodyPulls <= 3, + `authentication parsing must stop at the bounded request budget; observed ${bodyPulls} pulls`, + ); +}); + test('signed webhook Request inputs stay behind the SSRF destination policy', async () => { const signedRequest = new Request('https://127.0.0.1/internal', { method: 'POST', From d11340b4ed6f0f65b79e6c7afccd9d862e75743f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 23:55:44 -0700 Subject: [PATCH 107/157] fix(auth): bound public authentication bodies --- server/app.mjs | 64 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 08b5b907..0a12df6f 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -18,6 +18,7 @@ const nativeFetch = globalThis.fetch.bind(globalThis); const webhookFetchBoundaryKey = Symbol.for('scopeweave.webhook-fetch-boundary'); const WEBHOOK_REGISTRATION_PATH = /^\/api\/orgs\/[^/]+\/webhooks$/; const WEBHOOK_REGISTRATION_BODY_MAX_BYTES = 16 * 1024; +const AUTH_REQUEST_BODY_MAX_BYTES = 16 * 1024; const AUDIT_PATH = /^\/api\/orgs\/[^/]+\/audit$/; const AUTH_EMAIL_PATH = /^\/api\/auth\/(?:signup|login)$/; const METRICS_PATH = '/api/metrics'; @@ -363,23 +364,6 @@ function requestWithJson(request, payload) { async function canonicalInboundRequest(request) { const url = new URL(request.url); - if (request.method === 'POST' && AUTH_EMAIL_PATH.test(url.pathname)) { - const payload = await request.clone().json().catch(() => null); - if ( - payload - && typeof payload === 'object' - && !Array.isArray(payload) - && typeof payload.email === 'string' - ) { - const trimmedEmail = payload.email.trim(); - const storedMatches = matchingStoredEmails(trimmedEmail); - const email = url.pathname.endsWith('/signup') - ? (storedMatches[0]?.email || trimmedEmail.toLowerCase()) - : (storedMatches.length === 1 ? storedMatches[0].email : trimmedEmail); - if (email !== payload.email) return requestWithJson(request, { ...payload, email }); - } - } - if (request.method === 'GET' && AUDIT_PATH.test(url.pathname)) { const rawLimit = url.searchParams.get('limit'); if (rawLimit !== null) { @@ -523,13 +507,13 @@ function declaredRegistrationBodyTooLarge(request) { } /** - * Read one webhook-registration payload with an explicit memory budget. + * Read one JSON request payload with an explicit memory budget. * * The original request stream is consumed directly instead of cloning it: a * cloned stream can let the unread tee branch buffer attacker-controlled data. * Callers reconstruct the small JSON request only after this bounded read. */ -async function readBoundedRegistrationJson(request) { +async function readBoundedJsonBody(request, maxBytes) { if (!request.body) return { payload: {}, tooLarge: false }; const reader = request.body.getReader(); const chunks = []; @@ -540,7 +524,7 @@ async function readBoundedRegistrationJson(request) { if (done) break; const chunk = value instanceof Uint8Array ? value : new Uint8Array(value); totalBytes += chunk.byteLength; - if (totalBytes > WEBHOOK_REGISTRATION_BODY_MAX_BYTES) { + if (totalBytes > maxBytes) { await reader.cancel().catch(() => {}); return { payload: {}, tooLarge: true }; } @@ -568,6 +552,40 @@ async function readBoundedRegistrationJson(request) { } } +async function authenticationPolicyResult(request) { + const url = new URL(request.url); + if (request.method !== 'POST' || !AUTH_EMAIL_PATH.test(url.pathname)) return null; + + const { payload: parsedPayload, tooLarge } = await readBoundedJsonBody( + request, + AUTH_REQUEST_BODY_MAX_BYTES, + ); + if (tooLarge) { + return { + response: Response.json( + { error: 'authentication request body too large' }, + { status: 413 }, + ), + }; + } + + let payload = parsedPayload; + if ( + payload + && typeof payload === 'object' + && !Array.isArray(payload) + && typeof payload.email === 'string' + ) { + const trimmedEmail = payload.email.trim(); + const storedMatches = matchingStoredEmails(trimmedEmail); + const email = url.pathname.endsWith('/signup') + ? (storedMatches[0]?.email || trimmedEmail.toLowerCase()) + : (storedMatches.length === 1 ? storedMatches[0].email : trimmedEmail); + if (email !== payload.email) payload = { ...payload, email }; + } + return { request: requestWithJson(request, payload) }; +} + function canonicalRegistrationRequest(request, payload, canonicalUrl) { return requestWithJson(request, { ...payload, url: canonicalUrl }); } @@ -586,7 +604,7 @@ async function registrationPolicyResult(request, rest) { let payload; let tooLarge = declaredRegistrationBodyTooLarge(request); if (!tooLarge) { - const parsed = await readBoundedRegistrationJson(request); + const parsed = await readBoundedJsonBody(request, WEBHOOK_REGISTRATION_BODY_MAX_BYTES); payload = parsed.payload; tooLarge = parsed.tooLarge; } @@ -629,7 +647,9 @@ async function registrationPolicyResult(request, rest) { } async function secureFetch(request, ...rest) { - const canonicalRequest = await canonicalInboundRequest(request); + const authPolicy = await authenticationPolicyResult(request); + if (authPolicy?.response) return observeFacadeResponse(request, authPolicy.response); + const canonicalRequest = await canonicalInboundRequest(authPolicy?.request || request); const policy = await registrationPolicyResult(canonicalRequest, rest); if (policy?.response) return policy.response; const effectiveRequest = policy?.request || canonicalRequest; From e0de2b4c798fc1a472c6e73d27ef64dad060dcfc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:02:25 -0700 Subject: [PATCH 108/157] test(webhooks): reproduce legacy HTTP migration gap --- tests/api/webhook-legacy-migration.test.mjs | 94 +++++++++++++++++++++ 1 file changed, 94 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..645ccd3d --- /dev/null +++ b/tests/api/webhook-legacy-migration.test.mjs @@ -0,0 +1,94 @@ +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')) +); +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','project.update',1); +`); +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`); + const migrated = first.db.prepare( + 'SELECT active, blocked_reason AS blockedReason FROM webhooks WHERE id = 41', + ).get(); + assert.deepEqual( + migrated, + { active: 0, blockedReason: 'insecure_scheme' }, + 'legacy HTTP webhook rows are disabled and explicitly marked instead of silently failing on every delivery', + ); + + 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' AND target_id = '41'`, + ).all(); + assert.equal(firstAudit.length, 1, 'migration emits one durable security audit event'); + assert.equal(firstAudit[0].targetType, 'webhook'); + assert.deepEqual( + JSON.parse(firstAudit[0].meta), + { + reason: 'insecure_scheme', + nextAction: 'register_public_https_replacement', + }, + 'audit evidence records the policy reason and remediation contract without exposing the webhook secret', + ); + first.db.close(); + + const second = await import(`${moduleUrl}?legacy-http-migration=second`); + const secondAudit = second.db.prepare( + `SELECT COUNT(*) AS count + FROM audit_log + WHERE org_id = 1 AND action = 'webhook.security_block' AND target_id = '41'`, + ).get(); + assert.equal(secondAudit.count, 1, 'restarting after migration does not duplicate buyer audit evidence'); + assert.deepEqual( + second.db.prepare( + 'SELECT active, blocked_reason AS blockedReason FROM webhooks WHERE id = 41', + ).get(), + { active: 0, blockedReason: 'insecure_scheme' }, + 'migration remains fail-closed and idempotent on subsequent starts', + ); + second.db.close(); +} finally { + delete process.env.SCOPEWEAVE_DB; + rmSync(directory, { recursive: true, force: true }); +} + +console.log('legacy HTTP webhook migration regression passed'); From fb99534af0aabff93357d4a103853b6406b438da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:08:53 -0700 Subject: [PATCH 109/157] test(webhooks): register legacy HTTP migration regression --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b2a6f689..0a0a91db 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/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs && node tests/api/static-prototype-pollution.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/review-regressions.test.mjs && node tests/api/email-identity-compat.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-state-capacity.test.mjs && node tests/api/oidc-mock-dev-only.test.mjs && node tests/api/oidc-timeout.test.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 && node tests/api/static-prototype-pollution.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/webhook-legacy-migration.test.mjs && node tests/api/review-regressions.test.mjs && node tests/api/email-identity-compat.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-state-capacity.test.mjs && node tests/api/oidc-mock-dev-only.test.mjs && node tests/api/oidc-timeout.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/public-https-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/public-https-transport.test.mjs && npm run test:api", From c1e72a2c3597ba3857dd1b1e019bf79d2b9b0836 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:10:38 -0700 Subject: [PATCH 110/157] fix(webhooks): fail closed legacy HTTP subscriptions --- server/db.mjs | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/server/db.mjs b/server/db.mjs index 122b70d6..900ab307 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -64,6 +64,7 @@ CREATE TABLE IF NOT EXISTS webhooks ( secret TEXT NOT NULL, events TEXT NOT NULL DEFAULT '*', active INTEGER NOT NULL DEFAULT 1, + blocked_reason TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_webhooks_org ON webhooks(org_id); @@ -172,10 +173,48 @@ CREATE INDEX IF NOT EXISTS idx_projects_org ON projects(org_id); CREATE INDEX IF NOT EXISTS idx_invites_token ON invites(token); `); -// Migration for pre-existing DBs: add token_version if missing (idempotent). +// Migrations for pre-existing DBs. These ALTERs are intentionally idempotent. try { db.exec('ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0'); } catch { /* already there */ } 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 */ } +try { db.exec('ALTER TABLE webhooks ADD COLUMN blocked_reason TEXT'); } catch { /* already there */ } + +// Historical versions accepted http:// webhook destinations. Once outbound +// delivery requires public HTTPS, fail those rows closed exactly once instead +// of retrying a destination that policy will always reject. The audit record is +// durable buyer-facing evidence and contains no webhook secret. +try { + db.exec('BEGIN IMMEDIATE'); + db.exec(` + INSERT INTO audit_log(org_id, user_id, action, target_type, target_id, meta) + SELECT + w.org_id, + NULL, + 'webhook.security_block', + 'webhook', + CAST(w.id AS TEXT), + '{"reason":"insecure_scheme","nextAction":"register_public_https_replacement"}' + FROM webhooks w + WHERE lower(w.url) LIKE 'http://%' + AND NOT EXISTS ( + SELECT 1 + FROM audit_log a + WHERE a.org_id = w.org_id + AND a.action = 'webhook.security_block' + AND a.target_type = 'webhook' + AND a.target_id = CAST(w.id AS TEXT) + ); + + UPDATE webhooks + SET active = 0, + blocked_reason = 'insecure_scheme' + WHERE lower(url) LIKE 'http://%'; + `); + db.exec('COMMIT'); +} catch (error) { + try { db.exec('ROLLBACK'); } catch { /* transaction did not begin */ } + throw error; +} // node:sqlite returns lastInsertRowid as number|bigint; normalize to Number. export const rowid = (r) => Number(r.lastInsertRowid); From 94b51d2d1f7f3fe0b071490a626acc95e3fece89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:12:46 -0700 Subject: [PATCH 111/157] test(webhooks): normalize sqlite migration rows --- tests/api/webhook-legacy-migration.test.mjs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/api/webhook-legacy-migration.test.mjs b/tests/api/webhook-legacy-migration.test.mjs index 645ccd3d..1d1115c6 100644 --- a/tests/api/webhook-legacy-migration.test.mjs +++ b/tests/api/webhook-legacy-migration.test.mjs @@ -45,9 +45,11 @@ 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 migrated = first.db.prepare( - 'SELECT active, blocked_reason AS blockedReason FROM webhooks WHERE id = 41', - ).get(); + const migrated = { + ...first.db.prepare( + 'SELECT active, blocked_reason AS blockedReason FROM webhooks WHERE id = 41', + ).get(), + }; assert.deepEqual( migrated, { active: 0, blockedReason: 'insecure_scheme' }, @@ -79,9 +81,11 @@ try { ).get(); assert.equal(secondAudit.count, 1, 'restarting after migration does not duplicate buyer audit evidence'); assert.deepEqual( - second.db.prepare( - 'SELECT active, blocked_reason AS blockedReason FROM webhooks WHERE id = 41', - ).get(), + { + ...second.db.prepare( + 'SELECT active, blocked_reason AS blockedReason FROM webhooks WHERE id = 41', + ).get(), + }, { active: 0, blockedReason: 'insecure_scheme' }, 'migration remains fail-closed and idempotent on subsequent starts', ); From 49ddec8597b2aaf9068e4a60d996c0f1f7309086 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:11:20 -0700 Subject: [PATCH 112/157] test(security): reject caller webhook content-length framing --- tests/fuzz/webhookTransport.fuzz.mjs | 43 ++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 tests/fuzz/webhookTransport.fuzz.mjs diff --git a/tests/fuzz/webhookTransport.fuzz.mjs b/tests/fuzz/webhookTransport.fuzz.mjs new file mode 100644 index 00000000..52d92be4 --- /dev/null +++ b/tests/fuzz/webhookTransport.fuzz.mjs @@ -0,0 +1,43 @@ +// Property regression: the outbound webhook transport owns HTTP framing for +// the exact body bytes it writes. Caller-supplied Content-Length is untrusted +// metadata and must never be forwarded when it can disagree with that body. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import fc from 'fast-check'; +import { createWebhookTransport } from '../../server/webhook_transport.mjs'; + +const RUNS = Math.min(Number(process.env.FUZZ_RUNS || 3000), 500); + +test('webhook transport strips caller-supplied Content-Length before writing the body', async () => { + await fc.assert( + fc.asyncProperty(fc.string({ maxLength: 128 }), async (body) => { + let capturedOptions; + const transport = createWebhookTransport({ + lookup: async () => [{ address: '8.8.8.8', family: 4 }], + request: (_url, options, callback) => { + capturedOptions = options; + const req = new EventEmitter(); + req.end = (sentBody) => { + assert.equal(sentBody, body); + callback({ statusCode: 204, resume() {} }); + }; + return req; + }, + }); + + const result = await transport.post('https://hooks.example.com/events', { + headers: { + 'content-length': String(Buffer.byteLength(body) + 1), + 'x-scopeweave-test': 'framing-owner', + }, + body, + }); + + assert.equal(result.status, 204); + assert.equal(capturedOptions.headers['content-length'], undefined); + assert.equal(capturedOptions.headers['x-scopeweave-test'], 'framing-owner'); + }), + { numRuns: RUNS }, + ); +}); From 80f6355dac3110f641980f7e4525191d4cea90da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:15:23 -0700 Subject: [PATCH 113/157] fix(security): own webhook content-length framing --- server/webhook_transport.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/webhook_transport.mjs b/server/webhook_transport.mjs index 14793070..9f6b2b1e 100644 --- a/server/webhook_transport.mjs +++ b/server/webhook_transport.mjs @@ -425,6 +425,8 @@ export function createWebhookTransport({ lookup = dnsLookup, request = httpsRequ } 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 }; @@ -432,7 +434,7 @@ export function createWebhookTransport({ lookup = dnsLookup, request = httpsRequ return await postToCandidate( destination, candidate, - { headers, body, signal, attempt }, + { headers: requestHeaders, body, signal, attempt }, request, ); } catch (error) { From 3da81558a0123f86f97d4bc9e7c40ce72c74521d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:46:44 -0700 Subject: [PATCH 114/157] test(security): reject stale public transport content-length --- tests/unit/public-https-transport.test.mjs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/unit/public-https-transport.test.mjs b/tests/unit/public-https-transport.test.mjs index 3efe3b43..0f784f6d 100644 --- a/tests/unit/public-https-transport.test.mjs +++ b/tests/unit/public-https-transport.test.mjs @@ -171,10 +171,13 @@ assert.equal( ); let postTlsAttempts = 0; +let forwardedContentLength; const noReplayAfterSecureConnect = createPublicHttpsTransport({ lookup: async () => [PUBLIC_A, PUBLIC_B], request: (_url, options) => { postTlsAttempts += 1; + forwardedContentLength = options.headers?.get?.('content-length') + ?? options.headers?.['content-length']; const req = new EventEmitter(); req.end = () => { options.lookup('ignored.example', {}, (error) => { @@ -193,6 +196,7 @@ const noReplayAfterSecureConnect = createPublicHttpsTransport({ await assert.rejects( noReplayAfterSecureConnect.fetch('https://idp.example.test/token', { method: 'POST', + headers: { 'content-length': '9999' }, body: 'grant_type=authorization_code', }), WebhookTransportError, @@ -203,6 +207,11 @@ assert.equal( 1, 'a post-handshake failure is ambiguous and must not consume an authorization code twice', ); +assert.equal( + forwardedContentLength, + undefined, + 'public HTTPS transport owns body framing and strips stale caller content-length', +); await assert.rejects( transport.fetch('https://idp.example.test/jwks', { maxResponseBytes: 0 }), From 1dd781527a9e0e676e1ff3f96ed2c8a610112418 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:49:50 -0700 Subject: [PATCH 115/157] fix(security): own public transport content length --- server/webhook_transport.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/server/webhook_transport.mjs b/server/webhook_transport.mjs index 9f6b2b1e..a70fe49f 100644 --- a/server/webhook_transport.mjs +++ b/server/webhook_transport.mjs @@ -244,6 +244,7 @@ function appendResponseHeaders(target, source) { function identityEncodedHeaders(headers) { const normalized = Object.fromEntries(new Headers(headers).entries()); + delete normalized['content-length']; normalized['accept-encoding'] = 'identity'; return normalized; } From f92fdc64c42f48da1104364a09ee9ef556957fe1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 04:20:10 -0700 Subject: [PATCH 116/157] test(security): reject unverified OIDC email identities --- tests/api/oidc-email-verification.test.mjs | 109 +++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 tests/api/oidc-email-verification.test.mjs diff --git a/tests/api/oidc-email-verification.test.mjs b/tests/api/oidc-email-verification.test.mjs new file mode 100644 index 00000000..b86f5d20 --- /dev/null +++ b/tests/api/oidc-email-verification.test.mjs @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import { createSign, generateKeyPairSync } from 'node:crypto'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.SCOPEWEAVE_DEV = '1'; +process.env.OIDC_ISSUER = 'http://127.0.0.1:19101'; +process.env.OIDC_CLIENT_ID = 'scopeweave-email-verification-test'; +process.env.OIDC_CLIENT_SECRET = 'scopeweave-secret'; +process.env.OIDC_REDIRECT_URI = 'http://localhost/api/auth/oidc/callback'; + +const issuer = process.env.OIDC_ISSUER; +const clientId = process.env.OIDC_CLIENT_ID; +const authorizationEndpoint = 'http://127.0.0.1:19102/oauth2/authorize'; +const tokenEndpoint = 'http://127.0.0.1:19103/oauth2/token'; +const jwksEndpoint = 'http://127.0.0.1:19104/jwks'; +const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); +const publicJwk = { + ...publicKey.export({ format: 'jwk' }), + alg: 'RS256', + kid: 'scopeweave-email-verification-key', + use: 'sig', +}; +let expectedNonce = null; +const originalFetch = globalThis.fetch; + +const encoded = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); +const signIdToken = (claims) => { + const header = encoded({ alg: 'RS256', kid: publicJwk.kid, typ: 'JWT' }); + const payload = encoded(claims); + const input = `${header}.${payload}`; + const signature = createSign('RSA-SHA256') + .update(input) + .end() + .sign(privateKey) + .toString('base64url'); + return `${input}.${signature}`; +}; + +globalThis.fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + if (request.url === `${issuer}/.well-known/openid-configuration`) { + return Response.json({ + issuer, + authorization_endpoint: authorizationEndpoint, + token_endpoint: tokenEndpoint, + jwks_uri: jwksEndpoint, + id_token_signing_alg_values_supported: ['RS256'], + }); + } + if (request.url === jwksEndpoint) { + return Response.json({ keys: [publicJwk] }); + } + if (request.url !== tokenEndpoint) { + throw new Error(`unexpected outbound fetch: ${request.url}`); + } + const form = new URLSearchParams(await request.clone().text()); + const code = form.get('code'); + const now = Math.floor(Date.now() / 1000); + const emailVerified = code === 'verified-email-code'; + return Response.json({ + id_token: signIdToken({ + iss: issuer, + aud: clientId, + sub: `oidc-subject-${emailVerified ? 'verified' : 'unverified'}`, + email: `${emailVerified ? 'verified' : 'unverified'}@scopeweave.test`, + email_verified: emailVerified, + nonce: expectedNonce, + iat: now, + exp: now + 300, + }), + }); +}; + +try { + const { app } = await import('../../server/app.mjs'); + + const callback = async (code) => { + const start = await app.request('/api/auth/oidc/start'); + assert.equal(start.status, 302, 'OIDC authorization flow starts'); + const authorization = new URL(start.headers.get('location')); + expectedNonce = authorization.searchParams.get('nonce'); + const state = authorization.searchParams.get('state'); + assert.ok(expectedNonce, 'authorization redirect binds a nonce'); + assert.ok(state, 'authorization redirect binds a state'); + return app.request( + `/api/auth/oidc/callback?state=${encodeURIComponent(state)}&code=${encodeURIComponent(code)}`, + ); + }; + + const verified = await callback('verified-email-code'); + assert.equal( + verified.status, + 302, + 'an ID token with a provider-verified email can create the federated session', + ); + + const unverified = await callback('unverified-email-code'); + assert.equal( + unverified.status, + 400, + 'an ID token with email_verified=false must not be trusted to create or link an account by email', + ); +} finally { + globalThis.fetch = originalFetch; + delete process.env.SCOPEWEAVE_DEV; +} + +console.log('OIDC verified-email account-linking regression passed'); From bc5678a2b8614b99c1120f9929228dc4976b7a4a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 04:20:44 -0700 Subject: [PATCH 117/157] test(security): run OIDC email verification regression --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0a0a91db..d7c94671 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/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs && node tests/api/static-prototype-pollution.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/webhook-legacy-migration.test.mjs && node tests/api/review-regressions.test.mjs && node tests/api/email-identity-compat.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-state-capacity.test.mjs && node tests/api/oidc-mock-dev-only.test.mjs && node tests/api/oidc-timeout.test.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 && node tests/api/static-prototype-pollution.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/webhook-legacy-migration.test.mjs && node tests/api/review-regressions.test.mjs && node tests/api/email-identity-compat.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-state-capacity.test.mjs && node tests/api/oidc-mock-dev-only.test.mjs && node tests/api/oidc-email-verification.test.mjs && node tests/api/oidc-timeout.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/public-https-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/public-https-transport.test.mjs && npm run test:api", From e62f5c3812d52ee7599ff7372e4deb5989fa50ec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 04:23:50 -0700 Subject: [PATCH 118/157] fix(security): require verified OIDC email claims --- server/app.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/server/app.mjs b/server/app.mjs index 0a12df6f..88cab8e2 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -281,6 +281,7 @@ async function verifyOidcIdToken(idToken, expectedNonce, discovery) { if (!Number.isInteger(claims.iat) || claims.iat > now + OIDC_CLOCK_SKEW_SECONDS) throw new Error('invalid issued-at'); if (typeof claims.sub !== 'string' || !claims.sub || claims.nonce !== expectedNonce) throw new Error('invalid subject or nonce'); if (typeof claims.email !== 'string' || !claims.email.trim()) throw new Error('missing email'); + if (claims.email_verified !== true) throw new Error('unverified email'); } async function boundedOidcFetch(request) { From d153ec943ca9e9cdca07a2c7e49e2e80b5a66ee4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 04:25:52 -0700 Subject: [PATCH 119/157] test(oidc): mark valid email fixtures verified --- tests/api/oidc-timeout.test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/api/oidc-timeout.test.mjs b/tests/api/oidc-timeout.test.mjs index 201c19ef..f576afbf 100644 --- a/tests/api/oidc-timeout.test.mjs +++ b/tests/api/oidc-timeout.test.mjs @@ -104,6 +104,7 @@ globalThis.fetch = async (input, init) => { aud: clientId, sub: 'oidc-subject-123', email: 'oidc-timeout@scopeweave.test', + email_verified: true, nonce: expectedNonce, iat: now, exp: now + 300, From 5bde3f3109cc27974dbe72b81440e52ed287b6a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 13:55:02 -0700 Subject: [PATCH 120/157] test(auth): require stable OIDC subject identity --- tests/api/oidc-email-verification.test.mjs | 78 ++++++++++++++++++++-- 1 file changed, 73 insertions(+), 5 deletions(-) diff --git a/tests/api/oidc-email-verification.test.mjs b/tests/api/oidc-email-verification.test.mjs index b86f5d20..71fd3d7a 100644 --- a/tests/api/oidc-email-verification.test.mjs +++ b/tests/api/oidc-email-verification.test.mjs @@ -14,6 +14,9 @@ const clientId = process.env.OIDC_CLIENT_ID; const authorizationEndpoint = 'http://127.0.0.1:19102/oauth2/authorize'; const tokenEndpoint = 'http://127.0.0.1:19103/oauth2/token'; const jwksEndpoint = 'http://127.0.0.1:19104/jwks'; +const primaryEmail = 'verified@scopeweave.test'; +const renamedEmail = 'renamed@scopeweave.test'; +const primarySubject = 'oidc-subject-verified'; const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); const publicJwk = { ...publicKey.export({ format: 'jwk' }), @@ -37,6 +40,29 @@ const signIdToken = (claims) => { return `${input}.${signature}`; }; +const claimCases = { + 'verified-email-code': { + sub: primarySubject, + email: primaryEmail, + email_verified: true, + }, + 'renamed-email-code': { + sub: primarySubject, + email: renamedEmail, + email_verified: true, + }, + 'reassigned-email-code': { + sub: 'oidc-subject-reassigned', + email: primaryEmail, + email_verified: true, + }, + 'unverified-email-code': { + sub: 'oidc-subject-unverified', + email: 'unverified@scopeweave.test', + email_verified: false, + }, +}; + globalThis.fetch = async (input, init) => { const request = input instanceof Request ? input : new Request(input, init); if (request.url === `${issuer}/.well-known/openid-configuration`) { @@ -56,15 +82,14 @@ globalThis.fetch = async (input, init) => { } const form = new URLSearchParams(await request.clone().text()); const code = form.get('code'); + const claimCase = claimCases[code]; + if (!claimCase) throw new Error(`unexpected authorization code: ${code}`); const now = Math.floor(Date.now() / 1000); - const emailVerified = code === 'verified-email-code'; return Response.json({ id_token: signIdToken({ iss: issuer, aud: clientId, - sub: `oidc-subject-${emailVerified ? 'verified' : 'unverified'}`, - email: `${emailVerified ? 'verified' : 'unverified'}@scopeweave.test`, - email_verified: emailVerified, + ...claimCase, nonce: expectedNonce, iat: now, exp: now + 300, @@ -72,8 +97,16 @@ globalThis.fetch = async (input, init) => { }); }; +const sessionSubject = (response) => { + const location = response.headers.get('location') || ''; + const token = location.split('#token=')[1] || ''; + const payload = token.split('.')[1] || ''; + return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')).sub; +}; + try { const { app } = await import('../../server/app.mjs'); + const { db } = await import('../../server/db.mjs'); const callback = async (code) => { const start = await app.request('/api/auth/oidc/start'); @@ -94,6 +127,26 @@ try { 302, 'an ID token with a provider-verified email can create the federated session', ); + const initialUserId = sessionSubject(verified); + + const renamed = await callback('renamed-email-code'); + assert.equal( + renamed.status, + 302, + 'the same issuer/subject remains the same local identity after its email claim changes', + ); + assert.equal( + sessionSubject(renamed), + initialUserId, + 'federated identity follows stable issuer/subject rather than a mutable email claim', + ); + + const reassigned = await callback('reassigned-email-code'); + assert.equal( + reassigned.status, + 409, + 'a different subject cannot take over an existing federated account by reusing its verified email', + ); const unverified = await callback('unverified-email-code'); assert.equal( @@ -101,9 +154,24 @@ try { 400, 'an ID token with email_verified=false must not be trusted to create or link an account by email', ); + + assert.equal( + db.prepare('SELECT COUNT(*) AS count FROM users').get().count, + 1, + 'email changes and reassignment attempts do not create shadow federated users', + ); + const identityLinks = db.prepare( + `SELECT issuer_url AS issuer, subject_identifier AS subject, user_id AS userId + FROM oidc_identity_links ORDER BY id`, + ).all(); + assert.deepEqual( + identityLinks, + [{ issuer, subject: primarySubject, userId: Number(initialUserId) }], + 'the durable federated identity key is the verified issuer/subject pair', + ); } finally { globalThis.fetch = originalFetch; delete process.env.SCOPEWEAVE_DEV; } -console.log('OIDC verified-email account-linking regression passed'); +console.log('OIDC stable-subject account-linking regression passed'); From 96b7af6e20e62d7db2dd0d0d5c7e916d400fd0d5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 13:57:23 -0700 Subject: [PATCH 121/157] feat(auth): persist stable OIDC identities --- server/oidc_identity.mjs | 130 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 server/oidc_identity.mjs diff --git a/server/oidc_identity.mjs b/server/oidc_identity.mjs new file mode 100644 index 00000000..e9b55f04 --- /dev/null +++ b/server/oidc_identity.mjs @@ -0,0 +1,130 @@ +// Durable OpenID Connect identity binding for production authentication. +// +// OpenID Connect only guarantees the pair (issuer, subject) as a stable user +// identifier. Verified email remains useful profile data, but it must not become +// the long-lived account key after a federated identity has been observed. +import { db } from './db.mjs'; + +db.exec(` +CREATE TABLE IF NOT EXISTS oidc_identity_links ( + id INTEGER PRIMARY KEY, + issuer_url TEXT NOT NULL, + subject_identifier TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + email_at_link TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(issuer_url, subject_identifier), + UNIQUE(issuer_url, user_id) +); +CREATE INDEX IF NOT EXISTS idx_oidc_identity_user + ON oidc_identity_links(user_id, issuer_url); +`); + +/** Error raised when a verified federated identity conflicts with a prior binding. */ +export class OidcIdentityConflictError extends Error { + constructor(message = 'federated identity conflicts with an existing account') { + super(message); + this.name = 'OidcIdentityConflictError'; + } +} + +function validatedIdentity(identity) { + const issuer = String(identity?.issuer || '').trim(); + const subject = String(identity?.subject || '').trim(); + const email = String(identity?.email || '').trim(); + if (!issuer || !subject || !email) throw new Error('invalid verified OIDC identity'); + return { issuer, subject, email }; +} + +function matchingUsers(email) { + return db.prepare( + 'SELECT id, email, token_version FROM users WHERE email = ? COLLATE NOCASE ORDER BY id LIMIT 2', + ).all(email); +} + +function linkedUser(issuer, subject) { + return db.prepare( + `SELECT u.id, u.email, u.token_version + FROM oidc_identity_links l + JOIN users u ON u.id = l.user_id + WHERE l.issuer_url = ? AND l.subject_identifier = ?`, + ).get(issuer, subject); +} + +/** + * Prepare a verified OIDC identity before the legacy callback consumes it. + * + * Existing issuer/subject links remain authoritative even when the provider's + * verified email changes. A one-time compatibility adoption is allowed for a + * unique pre-existing email row, after which another subject or issuer cannot + * silently claim that account by presenting the same mutable email address. + * New federated users are finalized only after the core callback creates them. + */ +export function prepareOidcIdentity(identity) { + const { issuer, subject, email } = validatedIdentity(identity); + const linked = linkedUser(issuer, subject); + if (linked) { + const collision = db.prepare( + 'SELECT id FROM users WHERE email = ? COLLATE NOCASE AND id <> ? LIMIT 1', + ).get(email, linked.id); + if (collision) throw new OidcIdentityConflictError(); + if (linked.email !== email) { + db.prepare('UPDATE users SET email = ? WHERE id = ?').run(email, linked.id); + } + db.prepare( + `UPDATE oidc_identity_links + SET email_at_link = ?, updated_at = datetime('now') + WHERE issuer_url = ? AND subject_identifier = ?`, + ).run(email, issuer, subject); + return { userId: linked.id, needsFinalization: false }; + } + + const users = matchingUsers(email); + if (users.length > 1) throw new OidcIdentityConflictError(); + const existing = users[0]; + if (!existing) return { userId: null, needsFinalization: true }; + + const priorFederatedLink = db.prepare( + `SELECT issuer_url, subject_identifier + FROM oidc_identity_links + WHERE user_id = ? + LIMIT 1`, + ).get(existing.id); + if (priorFederatedLink) throw new OidcIdentityConflictError(); + + db.prepare( + `INSERT INTO oidc_identity_links( + issuer_url, subject_identifier, user_id, email_at_link + ) VALUES(?,?,?,?)`, + ).run(issuer, subject, existing.id, email); + return { userId: existing.id, needsFinalization: false }; +} + +/** + * Finish binding a first-time federated identity after the core callback has + * atomically created its local user and workspace. + */ +export function finalizeOidcIdentity(identity) { + const { issuer, subject, email } = validatedIdentity(identity); + const existingLink = linkedUser(issuer, subject); + if (existingLink) return existingLink.id; + + const users = matchingUsers(email); + if (users.length !== 1) throw new OidcIdentityConflictError(); + const user = users[0]; + const priorFederatedLink = db.prepare( + `SELECT issuer_url, subject_identifier + FROM oidc_identity_links + WHERE user_id = ? + LIMIT 1`, + ).get(user.id); + if (priorFederatedLink) throw new OidcIdentityConflictError(); + + db.prepare( + `INSERT INTO oidc_identity_links( + issuer_url, subject_identifier, user_id, email_at_link + ) VALUES(?,?,?,?)`, + ).run(issuer, subject, user.id, email); + return user.id; +} From de3d04106decdbd2e9aad7e1971ffc272a16e70c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 13:58:41 -0700 Subject: [PATCH 122/157] fix(auth): bind OIDC sessions to issuer subject --- server/app.mjs | 54 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 88cab8e2..4c31d7d5 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -7,6 +7,11 @@ import { createPublicKey, randomBytes, verify as verifySignature } from 'node:crypto'; import { app as coreApp } from './app_core.mjs'; import { db } from './db.mjs'; +import { + finalizeOidcIdentity, + OidcIdentityConflictError, + prepareOidcIdentity, +} from './oidc_identity.mjs'; import { WebhookDestinationError, fetchPublicHttps, @@ -282,6 +287,7 @@ async function verifyOidcIdToken(idToken, expectedNonce, discovery) { if (typeof claims.sub !== 'string' || !claims.sub || claims.nonce !== expectedNonce) throw new Error('invalid subject or nonce'); if (typeof claims.email !== 'string' || !claims.email.trim()) throw new Error('missing email'); if (claims.email_verified !== true) throw new Error('unverified email'); + return claims; } async function boundedOidcFetch(request) { @@ -306,7 +312,24 @@ async function boundedOidcFetch(request) { }); if (!response.ok) return response; const tokenPayload = await response.clone().json(); - await verifyOidcIdToken(tokenPayload.id_token, expectedNonce.nonce, discovery); + const claims = await verifyOidcIdToken(tokenPayload.id_token, expectedNonce.nonce, discovery); + const identity = { + issuer: claims.iss, + subject: claims.sub, + email: claims.email.trim(), + }; + try { + const prepared = prepareOidcIdentity(identity); + expectedNonce.verifiedIdentity = { + ...identity, + needsFinalization: prepared.needsFinalization, + }; + } catch (error) { + if (error instanceof OidcIdentityConflictError) { + expectedNonce.identityConflict = true; + } + throw error; + } return response; } @@ -460,11 +483,36 @@ async function coreFetchWithOidcBinding(request, rest) { const code = requestUrl.searchParams.get('code'); const record = state ? oidcNonceByState.get(state) : null; if (state) oidcNonceByState.delete(state); + let codeRecord = null; if (code && record && record.exp > Date.now()) { - oidcNonceByCode.set(code, { ...record, callbackSignal: request.signal }); + codeRecord = { ...record, callbackSignal: request.signal }; + oidcNonceByCode.set(code, codeRecord); } try { - return await coreApp.fetch(request, ...rest); + const response = await coreApp.fetch(request, ...rest); + if (codeRecord?.identityConflict) { + return Response.json( + { error: 'federated identity conflicts with an existing account' }, + { status: 409 }, + ); + } + if ( + response.status === 302 + && codeRecord?.verifiedIdentity?.needsFinalization + ) { + try { + finalizeOidcIdentity(codeRecord.verifiedIdentity); + } catch (error) { + if (error instanceof OidcIdentityConflictError) { + return Response.json( + { error: 'federated identity conflicts with an existing account' }, + { status: 409 }, + ); + } + throw error; + } + } + return response; } finally { if (code) oidcNonceByCode.delete(code); } From 37f662563072ea90f89f0ef503a89c79b329cf6f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 13:59:18 -0700 Subject: [PATCH 123/157] test(auth): include OIDC identity coverage --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d7c94671..80d5c443 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "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 && node tests/api/static-prototype-pollution.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/webhook-legacy-migration.test.mjs && node tests/api/review-regressions.test.mjs && node tests/api/email-identity-compat.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-state-capacity.test.mjs && node tests/api/oidc-mock-dev-only.test.mjs && node tests/api/oidc-email-verification.test.mjs && node tests/api/oidc-timeout.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/public-https-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": "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/oidc_identity.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/public-https-transport.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", From 6f5beb35742e9f9a86442685445255e3f0eeddbe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 14:01:14 -0700 Subject: [PATCH 124/157] test(auth): order OIDC takeover before email change --- tests/api/oidc-email-verification.test.mjs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/api/oidc-email-verification.test.mjs b/tests/api/oidc-email-verification.test.mjs index 71fd3d7a..0e65877d 100644 --- a/tests/api/oidc-email-verification.test.mjs +++ b/tests/api/oidc-email-verification.test.mjs @@ -129,6 +129,13 @@ try { ); const initialUserId = sessionSubject(verified); + const reassigned = await callback('reassigned-email-code'); + assert.equal( + reassigned.status, + 409, + 'a different subject cannot take over an existing federated account by reusing its current verified email', + ); + const renamed = await callback('renamed-email-code'); assert.equal( renamed.status, @@ -141,13 +148,6 @@ try { 'federated identity follows stable issuer/subject rather than a mutable email claim', ); - const reassigned = await callback('reassigned-email-code'); - assert.equal( - reassigned.status, - 409, - 'a different subject cannot take over an existing federated account by reusing its verified email', - ); - const unverified = await callback('unverified-email-code'); assert.equal( unverified.status, From f1d2731c7b37841027137ea1338ada7c16e81bc6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 14:03:08 -0700 Subject: [PATCH 125/157] test(auth): reject implicit OIDC password-account linking --- tests/api/oidc-email-verification.test.mjs | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/api/oidc-email-verification.test.mjs b/tests/api/oidc-email-verification.test.mjs index 0e65877d..1cff0eff 100644 --- a/tests/api/oidc-email-verification.test.mjs +++ b/tests/api/oidc-email-verification.test.mjs @@ -16,6 +16,7 @@ const tokenEndpoint = 'http://127.0.0.1:19103/oauth2/token'; const jwksEndpoint = 'http://127.0.0.1:19104/jwks'; const primaryEmail = 'verified@scopeweave.test'; const renamedEmail = 'renamed@scopeweave.test'; +const passwordEmail = 'password-account@scopeweave.test'; const primarySubject = 'oidc-subject-verified'; const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); const publicJwk = { @@ -56,6 +57,11 @@ const claimCases = { email: primaryEmail, email_verified: true, }, + 'password-email-code': { + sub: 'oidc-subject-password-collision', + email: passwordEmail, + email_verified: true, + }, 'unverified-email-code': { sub: 'oidc-subject-unverified', email: 'unverified@scopeweave.test', @@ -160,6 +166,26 @@ try { 1, 'email changes and reassignment attempts do not create shadow federated users', ); + + const passwordSignup = await app.request('/api/auth/signup', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: passwordEmail, password: 'a-secure-password' }), + }); + assert.equal(passwordSignup.status, 200, 'password account fixture is created through the public boundary'); + + const passwordCollision = await callback('password-email-code'); + assert.equal( + passwordCollision.status, + 409, + 'a verified OIDC email must not silently link to a pre-existing password account', + ); + assert.equal( + db.prepare('SELECT COUNT(*) AS count FROM users').get().count, + 2, + 'the password account remains distinct after the rejected federated linking attempt', + ); + const identityLinks = db.prepare( `SELECT issuer_url AS issuer, subject_identifier AS subject, user_id AS userId FROM oidc_identity_links ORDER BY id`, From 5dc156182bd3a25a44f76c181a066e338ffb6055 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 14:05:43 -0700 Subject: [PATCH 126/157] fix(auth): refuse implicit OIDC account adoption --- server/oidc_identity.mjs | 31 ++++++++----------------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/server/oidc_identity.mjs b/server/oidc_identity.mjs index e9b55f04..f4aa868c 100644 --- a/server/oidc_identity.mjs +++ b/server/oidc_identity.mjs @@ -2,7 +2,7 @@ // // OpenID Connect only guarantees the pair (issuer, subject) as a stable user // identifier. Verified email remains useful profile data, but it must not become -// the long-lived account key after a federated identity has been observed. +// the long-lived account key or implicitly authorize cross-method account linking. import { db } from './db.mjs'; db.exec(` @@ -56,10 +56,11 @@ function linkedUser(issuer, subject) { * Prepare a verified OIDC identity before the legacy callback consumes it. * * Existing issuer/subject links remain authoritative even when the provider's - * verified email changes. A one-time compatibility adoption is allowed for a - * unique pre-existing email row, after which another subject or issuer cannot - * silently claim that account by presenting the same mutable email address. - * New federated users are finalized only after the core callback creates them. + * verified email changes. An unlinked local row with the same email is rejected + * rather than silently adopted: verified email proves the provider's assertion, + * not authorization to merge a password account or an unverifiable legacy SSO + * account. New federated users are finalized only after the core callback creates + * the user and workspace for this same successful OIDC flow. */ export function prepareOidcIdentity(identity) { const { issuer, subject, email } = validatedIdentity(identity); @@ -81,24 +82,8 @@ export function prepareOidcIdentity(identity) { } const users = matchingUsers(email); - if (users.length > 1) throw new OidcIdentityConflictError(); - const existing = users[0]; - if (!existing) return { userId: null, needsFinalization: true }; - - const priorFederatedLink = db.prepare( - `SELECT issuer_url, subject_identifier - FROM oidc_identity_links - WHERE user_id = ? - LIMIT 1`, - ).get(existing.id); - if (priorFederatedLink) throw new OidcIdentityConflictError(); - - db.prepare( - `INSERT INTO oidc_identity_links( - issuer_url, subject_identifier, user_id, email_at_link - ) VALUES(?,?,?,?)`, - ).run(issuer, subject, existing.id, email); - return { userId: existing.id, needsFinalization: false }; + if (users.length > 0) throw new OidcIdentityConflictError(); + return { userId: null, needsFinalization: true }; } /** From a5eb7a3483edabfdb5937a1ac44a79f2627724c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 14:17:48 -0700 Subject: [PATCH 127/157] test(oidc): normalize sqlite rows for strict identity assertion --- tests/api/oidc-email-verification.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/api/oidc-email-verification.test.mjs b/tests/api/oidc-email-verification.test.mjs index 1cff0eff..7c6fd7cf 100644 --- a/tests/api/oidc-email-verification.test.mjs +++ b/tests/api/oidc-email-verification.test.mjs @@ -189,7 +189,7 @@ try { const identityLinks = db.prepare( `SELECT issuer_url AS issuer, subject_identifier AS subject, user_id AS userId FROM oidc_identity_links ORDER BY id`, - ).all(); + ).all().map((row) => ({ ...row })); assert.deepEqual( identityLinks, [{ issuer, subject: primarySubject, userId: Number(initialUserId) }], From 0850c244416ce6fbdfebb88406cb0041b342b097 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 14:23:16 -0700 Subject: [PATCH 128/157] test(oidc): require atomic first-login identity binding --- tests/api/oidc-email-verification.test.mjs | 49 ++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/api/oidc-email-verification.test.mjs b/tests/api/oidc-email-verification.test.mjs index 7c6fd7cf..328fb8af 100644 --- a/tests/api/oidc-email-verification.test.mjs +++ b/tests/api/oidc-email-verification.test.mjs @@ -17,7 +17,9 @@ const jwksEndpoint = 'http://127.0.0.1:19104/jwks'; const primaryEmail = 'verified@scopeweave.test'; const renamedEmail = 'renamed@scopeweave.test'; const passwordEmail = 'password-account@scopeweave.test'; +const linkFailureEmail = 'link-failure@scopeweave.test'; const primarySubject = 'oidc-subject-verified'; +const linkFailureSubject = 'oidc-subject-link-failure'; const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); const publicJwk = { ...publicKey.export({ format: 'jwk' }), @@ -62,6 +64,11 @@ const claimCases = { email: passwordEmail, email_verified: true, }, + 'link-failure-code': { + sub: linkFailureSubject, + email: linkFailureEmail, + email_verified: true, + }, 'unverified-email-code': { sub: 'oidc-subject-unverified', email: 'unverified@scopeweave.test', @@ -195,6 +202,48 @@ try { [{ issuer, subject: primarySubject, userId: Number(initialUserId) }], 'the durable federated identity key is the verified issuer/subject pair', ); + + const beforeLinkFailure = { + users: db.prepare('SELECT COUNT(*) AS count FROM users').get().count, + orgs: db.prepare('SELECT COUNT(*) AS count FROM orgs').get().count, + memberships: db.prepare('SELECT COUNT(*) AS count FROM memberships').get().count, + }; + db.exec(` + CREATE TRIGGER fail_oidc_identity_link + BEFORE INSERT ON oidc_identity_links + WHEN NEW.subject_identifier = '${linkFailureSubject}' + BEGIN + SELECT RAISE(ABORT, 'simulated OIDC identity-link persistence failure'); + END; + `); + await assert.rejects( + () => callback('link-failure-code'), + /simulated OIDC identity-link persistence failure/, + 'identity-link persistence failure is surfaced instead of returning an unbound session', + ); + assert.equal( + db.prepare('SELECT COUNT(*) AS count FROM users').get().count, + beforeLinkFailure.users, + 'failed identity-link persistence must not leave an orphan local user', + ); + assert.equal( + db.prepare('SELECT COUNT(*) AS count FROM orgs').get().count, + beforeLinkFailure.orgs, + 'failed identity-link persistence must not leave an orphan personal workspace', + ); + assert.equal( + db.prepare('SELECT COUNT(*) AS count FROM memberships').get().count, + beforeLinkFailure.memberships, + 'failed identity-link persistence must not leave an orphan owner membership', + ); + db.exec('DROP TRIGGER fail_oidc_identity_link'); + + const retryAfterLinkFailure = await callback('link-failure-code'); + assert.equal( + retryAfterLinkFailure.status, + 302, + 'a transient identity-link persistence failure remains safely retryable', + ); } finally { globalThis.fetch = originalFetch; delete process.env.SCOPEWEAVE_DEV; From 8c6c12b802ea6f2bd9372598cd7b8e33da12bb12 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 14:27:41 -0700 Subject: [PATCH 129/157] fix(oidc): bind first login atomically --- server/oidc_identity.mjs | 102 ++++++++++++++++++++++++++++++--------- 1 file changed, 79 insertions(+), 23 deletions(-) diff --git a/server/oidc_identity.mjs b/server/oidc_identity.mjs index f4aa868c..378c3632 100644 --- a/server/oidc_identity.mjs +++ b/server/oidc_identity.mjs @@ -3,7 +3,9 @@ // OpenID Connect only guarantees the pair (issuer, subject) as a stable user // identifier. Verified email remains useful profile data, but it must not become // the long-lived account key or implicitly authorize cross-method account linking. -import { db } from './db.mjs'; +import { randomBytes } from 'node:crypto'; +import { hashPassword } from './auth.mjs'; +import { db, rowid } from './db.mjs'; db.exec(` CREATE TABLE IF NOT EXISTS oidc_identity_links ( @@ -53,42 +55,96 @@ function linkedUser(issuer, subject) { } /** - * Prepare a verified OIDC identity before the legacy callback consumes it. + * Bind a provider-verified OIDC identity before the legacy callback consumes it. * * Existing issuer/subject links remain authoritative even when the provider's * verified email changes. An unlinked local row with the same email is rejected * rather than silently adopted: verified email proves the provider's assertion, * not authorization to merge a password account or an unverifiable legacy SSO - * account. New federated users are finalized only after the core callback creates - * the user and workspace for this same successful OIDC flow. + * account. + * + * A first-time federated login provisions its local user, personal workspace, + * owner membership, and durable issuer/subject link in one SQLite write + * transaction. That transaction is intentionally synchronous and contains no + * provider/network work. The password hash used only as an inaccessible local + * fallback is prepared before taking the database write lock on the normal + * first-login path. All identity and email collision checks are repeated while + * the write lock is held, closing the check-then-create race with another auth + * request or process. + * + * @param {{issuer:string,subject:string,email:string}} identity - Cryptographically verified OIDC identity. + * @returns {{userId:number,needsFinalization:false,created:boolean}} Bound local identity metadata. */ export function prepareOidcIdentity(identity) { const { issuer, subject, email } = validatedIdentity(identity); - const linked = linkedUser(issuer, subject); - if (linked) { - const collision = db.prepare( - 'SELECT id FROM users WHERE email = ? COLLATE NOCASE AND id <> ? LIMIT 1', - ).get(email, linked.id); - if (collision) throw new OidcIdentityConflictError(); - if (linked.email !== email) { - db.prepare('UPDATE users SET email = ? WHERE id = ?').run(email, linked.id); + + // Avoid paying the scrypt cost on established logins. If the link disappears + // before the write lock is acquired, the rare fallback below prepares the hash + // inside the transaction rather than creating an unbound account. + const linkedBeforeLock = linkedUser(issuer, subject); + let passwordHash = linkedBeforeLock + ? null + : hashPassword(randomBytes(24).toString('hex')); + + db.exec('BEGIN IMMEDIATE'); + try { + const linked = linkedUser(issuer, subject); + if (linked) { + const collision = db.prepare( + 'SELECT id FROM users WHERE email = ? COLLATE NOCASE AND id <> ? LIMIT 1', + ).get(email, linked.id); + if (collision) throw new OidcIdentityConflictError(); + if (linked.email !== email) { + db.prepare('UPDATE users SET email = ? WHERE id = ?').run(email, linked.id); + } + db.prepare( + `UPDATE oidc_identity_links + SET email_at_link = ?, updated_at = datetime('now') + WHERE issuer_url = ? AND subject_identifier = ?`, + ).run(email, issuer, subject); + db.exec('COMMIT'); + return { userId: linked.id, needsFinalization: false, created: false }; } + + const users = matchingUsers(email); + if (users.length > 0) throw new OidcIdentityConflictError(); + + // This path is only possible when a previously observed link was removed + // before BEGIN IMMEDIATE. Keep the transaction safe rather than depending on + // the optimistic pre-lock observation. + if (!passwordHash) passwordHash = hashPassword(randomBytes(24).toString('hex')); + + const userId = rowid( + db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') + .run(email, passwordHash, ''), + ); + const orgId = rowid( + db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)') + .run(`${email}'s workspace`, userId), + ); + db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)') + .run(orgId, userId, 'owner'); db.prepare( - `UPDATE oidc_identity_links - SET email_at_link = ?, updated_at = datetime('now') - WHERE issuer_url = ? AND subject_identifier = ?`, - ).run(email, issuer, subject); - return { userId: linked.id, needsFinalization: false }; - } + `INSERT INTO oidc_identity_links( + issuer_url, subject_identifier, user_id, email_at_link + ) VALUES(?,?,?,?)`, + ).run(issuer, subject, userId, email); - const users = matchingUsers(email); - if (users.length > 0) throw new OidcIdentityConflictError(); - return { userId: null, needsFinalization: true }; + db.exec('COMMIT'); + return { userId, needsFinalization: false, created: true }; + } catch (error) { + db.exec('ROLLBACK'); + throw error; + } } /** - * Finish binding a first-time federated identity after the core callback has - * atomically created its local user and workspace. + * Compatibility finalizer for callers that still carry the historical two-step + * contract. New production OIDC flows bind during prepareOidcIdentity and do not + * require this function. + * + * @param {{issuer:string,subject:string,email:string}} identity - Verified OIDC identity. + * @returns {number} Bound local user identifier. */ export function finalizeOidcIdentity(identity) { const { issuer, subject, email } = validatedIdentity(identity); From dc1b61db7913c669626fc964e98203f8d077b175 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 14:29:22 -0700 Subject: [PATCH 130/157] test(oidc): preserve signup accounting across atomic bind --- tests/api/oidc-email-verification.test.mjs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/api/oidc-email-verification.test.mjs b/tests/api/oidc-email-verification.test.mjs index 328fb8af..341c0e6c 100644 --- a/tests/api/oidc-email-verification.test.mjs +++ b/tests/api/oidc-email-verification.test.mjs @@ -141,6 +141,12 @@ try { 'an ID token with a provider-verified email can create the federated session', ); const initialUserId = sessionSubject(verified); + const metricsAfterVerified = await (await app.request('/api/metrics')).json(); + assert.equal( + metricsAfterVerified.signups, + 1, + 'a first-time federated account increments the same signup counter as password registration', + ); const reassigned = await callback('reassigned-email-code'); assert.equal( @@ -244,6 +250,12 @@ try { 302, 'a transient identity-link persistence failure remains safely retryable', ); + const metricsAfterRetry = await (await app.request('/api/metrics')).json(); + assert.equal( + metricsAfterRetry.signups, + 3, + 'only committed password or federated account creation increments signup metrics', + ); } finally { globalThis.fetch = originalFetch; delete process.env.SCOPEWEAVE_DEV; From 9a10ba3c7b8c04ad2fd77567658e047d43a094d5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 14:32:22 -0700 Subject: [PATCH 131/157] test(oidc): assert fail-closed atomic bind response --- tests/api/oidc-email-verification.test.mjs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/api/oidc-email-verification.test.mjs b/tests/api/oidc-email-verification.test.mjs index 341c0e6c..7fd8bdab 100644 --- a/tests/api/oidc-email-verification.test.mjs +++ b/tests/api/oidc-email-verification.test.mjs @@ -222,10 +222,16 @@ try { SELECT RAISE(ABORT, 'simulated OIDC identity-link persistence failure'); END; `); - await assert.rejects( - () => callback('link-failure-code'), - /simulated OIDC identity-link persistence failure/, - 'identity-link persistence failure is surfaced instead of returning an unbound session', + const failedLink = await callback('link-failure-code'); + assert.equal( + failedLink.status, + 400, + 'identity-link persistence failure must fail closed before any federated session is returned', + ); + assert.equal( + failedLink.headers.get('location'), + null, + 'identity-link persistence failure must not return a session redirect', ); assert.equal( db.prepare('SELECT COUNT(*) AS count FROM users').get().count, From 2b1ec445c572fcca6752813f71dd62dd191f6963 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 14:55:32 -0700 Subject: [PATCH 132/157] fix(metrics): count first-time OIDC signups --- server/app.mjs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/server/app.mjs b/server/app.mjs index 4c31d7d5..93216904 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -42,7 +42,13 @@ const OIDC_STATE_MAX_ENTRIES = 256; const OIDC_CLOCK_SKEW_SECONDS = 60; const oidcNonceByState = new Map(); const oidcNonceByCode = new Map(); -const facadeMetrics = { requests: 0, s2xx: 0, s4xx: 0, s5xx: 0 }; +const facadeMetrics = { + requests: 0, + signups: 0, + s2xx: 0, + s4xx: 0, + s5xx: 0, +}; const quietFacadeLogs = String(process.env.SCOPEWEAVE_DB || '').includes(':memory:'); let oidcDiscoveryCache = null; const oidcSigningKeyCache = new Map(); @@ -322,6 +328,7 @@ async function boundedOidcFetch(request) { const prepared = prepareOidcIdentity(identity); expectedNonce.verifiedIdentity = { ...identity, + created: prepared.created, needsFinalization: prepared.needsFinalization, }; } catch (error) { @@ -512,6 +519,9 @@ async function coreFetchWithOidcBinding(request, rest) { throw error; } } + if (response.status === 302 && codeRecord?.verifiedIdentity?.created) { + facadeMetrics.signups += 1; + } return response; } finally { if (code) oidcNonceByCode.delete(code); From 2b80d830e11b0026789ff5e39153523b6713b4f5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 15:47:31 -0700 Subject: [PATCH 133/157] test(fuzz): reproduce truncated iteration budgets --- tests/fuzz/webhookTransport.fuzz.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/fuzz/webhookTransport.fuzz.mjs b/tests/fuzz/webhookTransport.fuzz.mjs index 52d92be4..5dbce5e4 100644 --- a/tests/fuzz/webhookTransport.fuzz.mjs +++ b/tests/fuzz/webhookTransport.fuzz.mjs @@ -7,7 +7,14 @@ import { EventEmitter } from 'node:events'; import fc from 'fast-check'; import { createWebhookTransport } from '../../server/webhook_transport.mjs'; -const RUNS = Math.min(Number(process.env.FUZZ_RUNS || 3000), 500); +const requestedRuns = (value) => Math.min(Number(value || 3000), 500); +const RUNS = requestedRuns(process.env.FUZZ_RUNS); + +test('fuzz iteration budget preserves the documented default and workflow budgets', () => { + assert.equal(requestedRuns(undefined), 3000, 'local default stays at 3000 property cases'); + assert.equal(requestedRuns('20000'), 20000, 'pull-request workflow budget is honored'); + assert.equal(requestedRuns('200000'), 200000, 'scheduled workflow budget is honored'); +}); test('webhook transport strips caller-supplied Content-Length before writing the body', async () => { await fc.assert( From 2ad7da72de701ec1ee31a59e4fcc2a2721ad95cb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 15:49:21 -0700 Subject: [PATCH 134/157] fix(fuzz): honor bounded iteration budgets --- tests/fuzz/webhookTransport.fuzz.mjs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/fuzz/webhookTransport.fuzz.mjs b/tests/fuzz/webhookTransport.fuzz.mjs index 5dbce5e4..108afb92 100644 --- a/tests/fuzz/webhookTransport.fuzz.mjs +++ b/tests/fuzz/webhookTransport.fuzz.mjs @@ -7,13 +7,25 @@ import { EventEmitter } from 'node:events'; import fc from 'fast-check'; import { createWebhookTransport } from '../../server/webhook_transport.mjs'; -const requestedRuns = (value) => Math.min(Number(value || 3000), 500); +const DEFAULT_RUNS = 3000; +const MAX_RUNS = 200000; + +const requestedRuns = (value) => { + if (value === undefined || value === '') return DEFAULT_RUNS; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) return DEFAULT_RUNS; + return Math.min(parsed, MAX_RUNS); +}; const RUNS = requestedRuns(process.env.FUZZ_RUNS); test('fuzz iteration budget preserves the documented default and workflow budgets', () => { assert.equal(requestedRuns(undefined), 3000, 'local default stays at 3000 property cases'); assert.equal(requestedRuns('20000'), 20000, 'pull-request workflow budget is honored'); assert.equal(requestedRuns('200000'), 200000, 'scheduled workflow budget is honored'); + assert.equal(requestedRuns('200001'), 200000, 'operator input is bounded by the scheduled budget ceiling'); + assert.equal(requestedRuns('0'), 3000, 'zero falls back to the safe local default'); + assert.equal(requestedRuns('-1'), 3000, 'negative values fall back to the safe local default'); + assert.equal(requestedRuns('not-a-number'), 3000, 'invalid values fall back to the safe local default'); }); test('webhook transport strips caller-supplied Content-Length before writing the body', async () => { From bf9e2f00ba7b450fc38ca556492fc00791a15f9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 15:50:37 -0700 Subject: [PATCH 135/157] test(oidc): reproduce whitespace shadow-account split --- tests/api/oidc-email-verification.test.mjs | 38 ++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/tests/api/oidc-email-verification.test.mjs b/tests/api/oidc-email-verification.test.mjs index 7fd8bdab..77c548fb 100644 --- a/tests/api/oidc-email-verification.test.mjs +++ b/tests/api/oidc-email-verification.test.mjs @@ -54,6 +54,11 @@ const claimCases = { email: renamedEmail, email_verified: true, }, + 'whitespace-email-code': { + sub: primarySubject, + email: ` ${renamedEmail} `, + email_verified: true, + }, 'reassigned-email-code': { sub: 'oidc-subject-reassigned', email: primaryEmail, @@ -110,12 +115,13 @@ globalThis.fetch = async (input, init) => { }); }; -const sessionSubject = (response) => { +const sessionClaims = (response) => { const location = response.headers.get('location') || ''; const token = location.split('#token=')[1] || ''; const payload = token.split('.')[1] || ''; - return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')).sub; + return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); }; +const sessionSubject = (response) => sessionClaims(response).sub; try { const { app } = await import('../../server/app.mjs'); @@ -167,6 +173,34 @@ try { 'federated identity follows stable issuer/subject rather than a mutable email claim', ); + const whitespaceEmail = await callback('whitespace-email-code'); + assert.equal( + whitespaceEmail.status, + 302, + 'provider whitespace around an otherwise stable verified email is tolerated', + ); + assert.equal( + sessionSubject(whitespaceEmail), + initialUserId, + 'the core callback must not create a shadow user from an untrimmed copy of the verified email', + ); + assert.equal( + sessionClaims(whitespaceEmail).email, + renamedEmail, + 'the issued session carries the canonical trimmed verified email', + ); + assert.equal( + db.prepare('SELECT COUNT(*) AS count FROM users').get().count, + 1, + 'whitespace in a verified email claim does not create a duplicate local user', + ); + const metricsAfterWhitespace = await (await app.request('/api/metrics')).json(); + assert.equal( + metricsAfterWhitespace.signups, + 1, + 're-authentication of an existing federated subject does not count as a new signup', + ); + const unverified = await callback('unverified-email-code'); assert.equal( unverified.status, From 1b7680332c05463c097181ca3e7033c3f2544ba0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 15:56:17 -0700 Subject: [PATCH 136/157] test(oidc): require canonical verified email claims --- tests/api/oidc-email-verification.test.mjs | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/tests/api/oidc-email-verification.test.mjs b/tests/api/oidc-email-verification.test.mjs index 77c548fb..b07216b5 100644 --- a/tests/api/oidc-email-verification.test.mjs +++ b/tests/api/oidc-email-verification.test.mjs @@ -176,18 +176,13 @@ try { const whitespaceEmail = await callback('whitespace-email-code'); assert.equal( whitespaceEmail.status, - 302, - 'provider whitespace around an otherwise stable verified email is tolerated', - ); - assert.equal( - sessionSubject(whitespaceEmail), - initialUserId, - 'the core callback must not create a shadow user from an untrimmed copy of the verified email', + 400, + 'a verified email claim outside canonical addr-spec form is rejected before account mutation', ); assert.equal( - sessionClaims(whitespaceEmail).email, - renamedEmail, - 'the issued session carries the canonical trimmed verified email', + whitespaceEmail.headers.get('location'), + null, + 'a non-canonical verified email claim must not return a session redirect', ); assert.equal( db.prepare('SELECT COUNT(*) AS count FROM users').get().count, @@ -198,7 +193,7 @@ try { assert.equal( metricsAfterWhitespace.signups, 1, - 're-authentication of an existing federated subject does not count as a new signup', + 'a rejected non-canonical federated claim does not count as a new signup', ); const unverified = await callback('unverified-email-code'); From ba2aab41d037b5f0e7334b1b70b7732fe976d2c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 15:57:32 -0700 Subject: [PATCH 137/157] fix(oidc): reject non-canonical verified email claims --- server/app.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/server/app.mjs b/server/app.mjs index 93216904..b7dfddcc 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -292,6 +292,7 @@ async function verifyOidcIdToken(idToken, expectedNonce, discovery) { if (!Number.isInteger(claims.iat) || claims.iat > now + OIDC_CLOCK_SKEW_SECONDS) throw new Error('invalid issued-at'); if (typeof claims.sub !== 'string' || !claims.sub || claims.nonce !== expectedNonce) throw new Error('invalid subject or nonce'); if (typeof claims.email !== 'string' || !claims.email.trim()) throw new Error('missing email'); + if (claims.email !== claims.email.trim()) throw new Error('invalid email claim'); if (claims.email_verified !== true) throw new Error('unverified email'); return claims; } From 11eea72174f8e8fb798500d671727a130c60d37d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 21:11:29 -0700 Subject: [PATCH 138/157] test(observability): isolate webhook authorization probes --- tests/api/review-regressions.test.mjs | 38 +++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/api/review-regressions.test.mjs b/tests/api/review-regressions.test.mjs index 4f1482e7..5e3ddf8a 100644 --- a/tests/api/review-regressions.test.mjs +++ b/tests/api/review-regressions.test.mjs @@ -309,6 +309,44 @@ test('facade webhook rejection is observed as the real POST exactly once', async ); }); +test('oversized webhook authorization probe is not observed as a synthetic core request', async () => { + const { token, org } = await createOwner('oversized-webhook-observability@scopeweave.test'); + const coreBefore = await (await coreApp.request('/api/metrics')).json(); + + const rejected = await request(`/api/orgs/${org.id}/webhooks`, { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-length': String(17 * 1024), + }, + body: body({ url: 'https://hooks.example.test', events: ['project.update'] }), + }); + assert.equal(rejected.status, 413, 'authorized oversized registration is rejected by the facade'); + + const coreAfter = await (await coreApp.request('/api/metrics')).json(); + assert.equal( + coreAfter.requests, + coreBefore.requests + 1, + 'the authorization probe must not appear as a second customer request in core metrics', + ); + assert.equal( + coreAfter.s2xx, + coreBefore.s2xx + 1, + 'only the baseline core metrics request is observed between snapshots', + ); + assert.equal( + coreAfter.s4xx, + coreBefore.s4xx, + 'the probe 400 must not be recorded in place of the customer-visible 413', + ); + + const combined = await (await request('/api/metrics')).json(); + assert.ok( + combined.s4xx >= coreAfter.s4xx + 1, + 'the customer-visible facade rejection remains represented in combined metrics', + ); +}); + test('facade OIDC rejection is observed as the real request exactly once', async () => { const before = await (await request('/api/metrics')).json(); From dde219f925ee56eeaefa90023a1e336d760982f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 23:17:26 -0700 Subject: [PATCH 139/157] fix(observability): isolate webhook authorization probes --- server/app.mjs | 114 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 109 insertions(+), 5 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index b7dfddcc..7cf1ba08 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -4,6 +4,7 @@ // policy can be added without rewriting unrelated tenant, auth, billing, or // Clearfolio behavior. Every production server and in-process caller imports // this facade; app_core.mjs is an implementation module, not a public entrypoint. +import { AsyncLocalStorage } from 'node:async_hooks'; import { createPublicKey, randomBytes, verify as verifySignature } from 'node:crypto'; import { app as coreApp } from './app_core.mjs'; import { db } from './db.mjs'; @@ -21,6 +22,9 @@ import { const nativeFetch = globalThis.fetch.bind(globalThis); const webhookFetchBoundaryKey = Symbol.for('scopeweave.webhook-fetch-boundary'); +const coreProbeContextKey = Symbol.for('scopeweave.core-probe-observability-context'); +const coreProbeConsoleBoundaryKey = Symbol.for('scopeweave.core-probe-console-boundary'); +const authorizationProbeObservabilityKey = Symbol('scopeweave.authorization-probe-observability'); const WEBHOOK_REGISTRATION_PATH = /^\/api\/orgs\/[^/]+\/webhooks$/; const WEBHOOK_REGISTRATION_BODY_MAX_BYTES = 16 * 1024; const AUTH_REQUEST_BODY_MAX_BYTES = 16 * 1024; @@ -49,10 +53,40 @@ const facadeMetrics = { s4xx: 0, s5xx: 0, }; +const suppressedCoreMetrics = { + requests: 0, + s2xx: 0, + s4xx: 0, + s5xx: 0, +}; const quietFacadeLogs = String(process.env.SCOPEWEAVE_DB || '').includes(':memory:'); let oidcDiscoveryCache = null; const oidcSigningKeyCache = new Map(); +if (!globalThis[coreProbeContextKey]) { + Object.defineProperty(globalThis, coreProbeContextKey, { + value: new AsyncLocalStorage(), + configurable: false, + enumerable: false, + writable: false, + }); +} +const coreProbeContext = globalThis[coreProbeContextKey]; + +if (!globalThis[coreProbeConsoleBoundaryKey]) { + const nativeConsoleLog = console.log.bind(console); + console.log = (...args) => { + if (coreProbeContext.getStore() === true) return; + nativeConsoleLog(...args); + }; + Object.defineProperty(globalThis, coreProbeConsoleBoundaryKey, { + value: true, + configurable: false, + enumerable: false, + writable: false, + }); +} + function matchingStoredEmails(email) { return db.prepare( 'SELECT email FROM users WHERE email = ? COLLATE NOCASE ORDER BY id LIMIT 2', @@ -122,6 +156,69 @@ async function mergeFacadeMetricsResponse(request, response) { }); } +function recordSuppressedCoreResponse(response) { + suppressedCoreMetrics.requests += 1; + if (response.status >= 500) suppressedCoreMetrics.s5xx += 1; + else if (response.status >= 400) suppressedCoreMetrics.s4xx += 1; + else if (response.status >= 200) suppressedCoreMetrics.s2xx += 1; +} + +function subtractPrometheusCoreMetrics(payload) { + let adjusted = payload; + for (const [key, value] of Object.entries(suppressedCoreMetrics)) { + const metric = `scopeweave_${key}`; + adjusted = adjusted.replace( + new RegExp(`^(${metric}\\s+)(-?\\d+(?:\\.\\d+)?)$`, 'm'), + (_, prefix, current) => `${prefix}${Math.max(0, Number(current) - value)}`, + ); + } + return adjusted; +} + +async function subtractSuppressedCoreMetricsResponse(request, response) { + const url = new URL(request.url); + if ( + request.method !== 'GET' + || url.pathname !== METRICS_PATH + || !response.ok + ) return response; + + const headers = new Headers(response.headers); + headers.delete('content-length'); + if (url.searchParams.get('format') === 'prometheus') { + return new Response( + subtractPrometheusCoreMetrics(await response.clone().text()), + { + status: response.status, + statusText: response.statusText, + headers, + }, + ); + } + + const payload = await response.clone().json().catch(() => null); + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return response; + const adjusted = { ...payload }; + for (const [key, value] of Object.entries(suppressedCoreMetrics)) { + adjusted[key] = Math.max(0, (Number(adjusted[key]) || 0) - value); + } + return new Response(JSON.stringify(adjusted), { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +const nativeCoreFetch = coreApp.fetch.bind(coreApp); +coreApp.fetch = async (request, ...rest) => { + const internalAuthorizationProbe = request?.[authorizationProbeObservabilityKey] === true; + const response = internalAuthorizationProbe + ? await coreProbeContext.run(true, () => nativeCoreFetch(request, ...rest)) + : await nativeCoreFetch(request, ...rest); + if (internalAuthorizationProbe) recordSuppressedCoreResponse(response); + return subtractSuppressedCoreMetricsResponse(request, response); +}; + function isSignedWebhookRequest(request) { if (request.method.toUpperCase() !== 'POST') return false; return Boolean( @@ -533,12 +630,19 @@ function authorizationProbeRequest(request) { const headers = new Headers(request.headers); headers.delete('content-length'); headers.set('content-type', 'application/json'); - return new Request(request.url, { + const probe = new Request(request.url, { method: 'POST', headers, body: JSON.stringify({ url: '', events: [] }), signal: request.signal, }); + Object.defineProperty(probe, authorizationProbeObservabilityKey, { + value: true, + configurable: false, + enumerable: false, + writable: false, + }); + return probe; } /** @@ -547,9 +651,9 @@ function authorizationProbeRequest(request) { * a destination-policy error. The synthetic payload is valid JSON but has an * empty URL, so an authorized manager deterministically reaches the legacy * pre-insert URL guard and receives 400. Every denial, rate limit, or internal - * failure is propagated unchanged. Because the probe uses the customer's real - * POST path and method, request metrics and structured logs reflect that - * customer-visible operation instead of a synthetic GET. + * failure is propagated unchanged. The internal probe is omitted from customer + * metrics and request logs; secureFetch records only the actual customer-visible + * policy outcome after the authorization decision completes. */ async function deniedRegistrationAuthorization(request, rest) { const response = await coreApp.fetch(authorizationProbeRequest(request), ...rest); @@ -711,7 +815,7 @@ async function secureFetch(request, ...rest) { if (authPolicy?.response) return observeFacadeResponse(request, authPolicy.response); const canonicalRequest = await canonicalInboundRequest(authPolicy?.request || request); const policy = await registrationPolicyResult(canonicalRequest, rest); - if (policy?.response) return policy.response; + if (policy?.response) return observeFacadeResponse(canonicalRequest, policy.response); const effectiveRequest = policy?.request || canonicalRequest; const response = await coreFetchWithOidcBinding(effectiveRequest, rest); return mergeFacadeMetricsResponse(effectiveRequest, response); From c26f197f2b4f1143b4497c67ddebba751c499ab2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 03:59:34 -0700 Subject: [PATCH 140/157] test: reproduce transient sqlite startup lock --- tests/api/webhook-legacy-migration.test.mjs | 76 ++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/tests/api/webhook-legacy-migration.test.mjs b/tests/api/webhook-legacy-migration.test.mjs index 1d1115c6..63dcfd28 100644 --- a/tests/api/webhook-legacy-migration.test.mjs +++ b/tests/api/webhook-legacy-migration.test.mjs @@ -1,4 +1,6 @@ import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -41,6 +43,33 @@ VALUES(41,1,'http://legacy-webhook.example.test/callback','whsec_legacy','projec legacy.close(); process.env.SCOPEWEAVE_DB = databasePath; +let locker = null; + +function waitForMarker(child, marker) { + return new Promise((resolve, reject) => { + let output = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + const onData = (chunk) => { + output += chunk; + if (output.includes(marker)) { + child.stdout.off('data', onData); + resolve(); + } + }; + child.stdout.on('data', onData); + child.once('error', reject); + child.once('exit', (code) => { + if (!output.includes(marker)) { + reject(new Error(`lock helper exited before ${marker.trim()}: ${code}; ${stderr}`)); + } + }); + }); +} try { const moduleUrl = pathToFileURL(join(process.cwd(), 'server', 'db.mjs')).href; @@ -90,9 +119,54 @@ try { 'migration remains fail-closed and idempotent on subsequent starts', ); second.db.close(); + + const lockedDatabasePath = join(directory, 'transient-lock.sqlite'); + const bootstrap = new DatabaseSync(lockedDatabasePath); + bootstrap.exec(` + PRAGMA journal_mode = WAL; + CREATE TABLE lock_probe ( + probe_id INTEGER PRIMARY KEY, + probe_value TEXT NOT NULL + ); + `); + bootstrap.close(); + + const lockScript = String.raw` + const { DatabaseSync } = require('node:sqlite'); + const db = new DatabaseSync(process.env.SCOPEWEAVE_LOCK_DB); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('BEGIN IMMEDIATE'); + db.prepare('INSERT INTO lock_probe(probe_value) VALUES (?)').run('held'); + process.stdout.write('locked\\n'); + setTimeout(() => { + db.exec('COMMIT'); + db.close(); + }, 300); + `; + locker = spawn(process.execPath, ['-e', lockScript], { + env: { ...process.env, SCOPEWEAVE_LOCK_DB: lockedDatabasePath }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + await waitForMarker(locker, 'locked\n'); + + process.env.SCOPEWEAVE_DB = lockedDatabasePath; + const concurrent = await import(`${moduleUrl}?legacy-http-migration=transient-lock`); + concurrent.db.close(); + + if (locker.exitCode === null) { + const [exitCode] = await once(locker, 'exit'); + assert.equal(exitCode, 0, 'transient lock helper exits cleanly after releasing its writer lock'); + } else { + assert.equal(locker.exitCode, 0, 'transient lock helper exits cleanly after releasing its writer lock'); + } + locker = null; } finally { + if (locker && locker.exitCode === null) { + locker.kill(); + await once(locker, 'exit').catch(() => {}); + } delete process.env.SCOPEWEAVE_DB; rmSync(directory, { recursive: true, force: true }); } -console.log('legacy HTTP webhook migration regression passed'); +console.log('legacy HTTP webhook migration regression passed'); \ No newline at end of file From 3fc64ee2d8407739c720511101bff9066df15d86 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 04:01:06 -0700 Subject: [PATCH 141/157] test: correct sqlite lock readiness marker --- tests/api/webhook-legacy-migration.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/api/webhook-legacy-migration.test.mjs b/tests/api/webhook-legacy-migration.test.mjs index 63dcfd28..9755e1c0 100644 --- a/tests/api/webhook-legacy-migration.test.mjs +++ b/tests/api/webhook-legacy-migration.test.mjs @@ -137,7 +137,7 @@ try { db.exec('PRAGMA journal_mode = WAL'); db.exec('BEGIN IMMEDIATE'); db.prepare('INSERT INTO lock_probe(probe_value) VALUES (?)').run('held'); - process.stdout.write('locked\\n'); + process.stdout.write('locked\n'); setTimeout(() => { db.exec('COMMIT'); db.close(); From df1b14b83c24425a8e00d3825d898a5b122a08ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 04:03:31 -0700 Subject: [PATCH 142/157] fix: tolerate transient sqlite writer locks --- server/db.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/db.mjs b/server/db.mjs index 900ab307..132e6569 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -8,6 +8,8 @@ import { dirname, join } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const dbPath = process.env.SCOPEWEAVE_DB || join(__dirname, '..', 'data.db'); export const db = new DatabaseSync(dbPath); +// Wait briefly for an existing SQLite writer instead of failing startup on a transient lock. +db.exec("PRAGMA busy_timeout = 5000"); db.exec("PRAGMA journal_mode = WAL"); db.exec("PRAGMA foreign_keys = ON"); @@ -217,4 +219,4 @@ try { } // 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 5aa0cb28547d37fc5c9ddf9d7a0f1e6086ba1b3e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 00:22:27 -0700 Subject: [PATCH 143/157] test: reject process-wide console observability mutation --- .../api/console-observability-boundary.test.mjs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 tests/api/console-observability-boundary.test.mjs diff --git a/tests/api/console-observability-boundary.test.mjs b/tests/api/console-observability-boundary.test.mjs new file mode 100644 index 00000000..70f613ef --- /dev/null +++ b/tests/api/console-observability-boundary.test.mjs @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + +const consoleLogBeforeImport = console.log; + +await import('../../server/app.mjs'); + +assert.strictEqual( + console.log, + consoleLogBeforeImport, + 'importing the ScopeWeave facade must not replace process-wide console.log', +); + +console.log('console observability boundary regression passed'); From 3b8b9c017e028e664cb805501195cc208535d145 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 00:22:58 -0700 Subject: [PATCH 144/157] test: register console observability regression --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 80d5c443..492ccedd 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/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs && node tests/api/static-prototype-pollution.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/webhook-legacy-migration.test.mjs && node tests/api/review-regressions.test.mjs && node tests/api/email-identity-compat.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-state-capacity.test.mjs && node tests/api/oidc-mock-dev-only.test.mjs && node tests/api/oidc-email-verification.test.mjs && node tests/api/oidc-timeout.test.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 && node tests/api/static-prototype-pollution.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/webhook-legacy-migration.test.mjs && node tests/api/review-regressions.test.mjs && node tests/api/console-observability-boundary.test.mjs && node tests/api/email-identity-compat.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-state-capacity.test.mjs && node tests/api/oidc-mock-dev-only.test.mjs && node tests/api/oidc-email-verification.test.mjs && node tests/api/oidc-timeout.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/public-https-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/oidc_identity.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/public-https-transport.test.mjs && npm run test:api", @@ -31,4 +31,4 @@ "c8": "12.0.0", "fast-check": "4.9.0" } -} +} \ No newline at end of file From fdb2ab5b9d838ae9fdf6e71319df7eff26bebf3e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 00:30:01 -0700 Subject: [PATCH 145/157] fix: stop mutating process-wide console logging --- server/app.mjs | 36 ++++-------------------------------- 1 file changed, 4 insertions(+), 32 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 7cf1ba08..6447b59d 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -4,7 +4,6 @@ // policy can be added without rewriting unrelated tenant, auth, billing, or // Clearfolio behavior. Every production server and in-process caller imports // this facade; app_core.mjs is an implementation module, not a public entrypoint. -import { AsyncLocalStorage } from 'node:async_hooks'; import { createPublicKey, randomBytes, verify as verifySignature } from 'node:crypto'; import { app as coreApp } from './app_core.mjs'; import { db } from './db.mjs'; @@ -22,8 +21,6 @@ import { const nativeFetch = globalThis.fetch.bind(globalThis); const webhookFetchBoundaryKey = Symbol.for('scopeweave.webhook-fetch-boundary'); -const coreProbeContextKey = Symbol.for('scopeweave.core-probe-observability-context'); -const coreProbeConsoleBoundaryKey = Symbol.for('scopeweave.core-probe-console-boundary'); const authorizationProbeObservabilityKey = Symbol('scopeweave.authorization-probe-observability'); const WEBHOOK_REGISTRATION_PATH = /^\/api\/orgs\/[^/]+\/webhooks$/; const WEBHOOK_REGISTRATION_BODY_MAX_BYTES = 16 * 1024; @@ -63,30 +60,6 @@ const quietFacadeLogs = String(process.env.SCOPEWEAVE_DB || '').includes(':memor let oidcDiscoveryCache = null; const oidcSigningKeyCache = new Map(); -if (!globalThis[coreProbeContextKey]) { - Object.defineProperty(globalThis, coreProbeContextKey, { - value: new AsyncLocalStorage(), - configurable: false, - enumerable: false, - writable: false, - }); -} -const coreProbeContext = globalThis[coreProbeContextKey]; - -if (!globalThis[coreProbeConsoleBoundaryKey]) { - const nativeConsoleLog = console.log.bind(console); - console.log = (...args) => { - if (coreProbeContext.getStore() === true) return; - nativeConsoleLog(...args); - }; - Object.defineProperty(globalThis, coreProbeConsoleBoundaryKey, { - value: true, - configurable: false, - enumerable: false, - writable: false, - }); -} - function matchingStoredEmails(email) { return db.prepare( 'SELECT email FROM users WHERE email = ? COLLATE NOCASE ORDER BY id LIMIT 2', @@ -212,9 +185,7 @@ async function subtractSuppressedCoreMetricsResponse(request, response) { const nativeCoreFetch = coreApp.fetch.bind(coreApp); coreApp.fetch = async (request, ...rest) => { const internalAuthorizationProbe = request?.[authorizationProbeObservabilityKey] === true; - const response = internalAuthorizationProbe - ? await coreProbeContext.run(true, () => nativeCoreFetch(request, ...rest)) - : await nativeCoreFetch(request, ...rest); + const response = await nativeCoreFetch(request, ...rest); if (internalAuthorizationProbe) recordSuppressedCoreResponse(response); return subtractSuppressedCoreMetricsResponse(request, response); }; @@ -652,8 +623,9 @@ function authorizationProbeRequest(request) { * empty URL, so an authorized manager deterministically reaches the legacy * pre-insert URL guard and receives 400. Every denial, rate limit, or internal * failure is propagated unchanged. The internal probe is omitted from customer - * metrics and request logs; secureFetch records only the actual customer-visible - * policy outcome after the authorization decision completes. + * metrics; its core request log remains visible rather than mutating process-wide + * console behavior. secureFetch records the actual customer-visible policy + * outcome after the authorization decision completes. */ async function deniedRegistrationAuthorization(request, rest) { const response = await coreApp.fetch(authorizationProbeRequest(request), ...rest); From 03537960e2919267fb027aa2a2cae053086db643 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 21:04:17 -0700 Subject: [PATCH 146/157] test: reject process-wide fetch interposition --- tests/api/fetch-boundary-ownership.test.mjs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tests/api/fetch-boundary-ownership.test.mjs diff --git a/tests/api/fetch-boundary-ownership.test.mjs b/tests/api/fetch-boundary-ownership.test.mjs new file mode 100644 index 00000000..9654b01f --- /dev/null +++ b/tests/api/fetch-boundary-ownership.test.mjs @@ -0,0 +1,21 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + +const callerFetch = async (input, init) => { + const request = new Request(input, init); + return new Response(request.url, { status: 200 }); +}; +globalThis.fetch = callerFetch; + +await import('../../server/app.mjs'); + +test('importing the ScopeWeave app preserves the caller-owned process fetch implementation', () => { + assert.equal( + globalThis.fetch, + callerFetch, + 'ScopeWeave security boundaries must be explicit collaborators rather than a process-wide fetch monkey patch', + ); +}); From a8fea65c328c76ca83e00a8768fcd4b3c7b6c3e6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 21:04:41 -0700 Subject: [PATCH 147/157] test: register fetch ownership regression --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 492ccedd..3a9f8af7 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/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs && node tests/api/static-prototype-pollution.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/webhook-legacy-migration.test.mjs && node tests/api/review-regressions.test.mjs && node tests/api/console-observability-boundary.test.mjs && node tests/api/email-identity-compat.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-state-capacity.test.mjs && node tests/api/oidc-mock-dev-only.test.mjs && node tests/api/oidc-email-verification.test.mjs && node tests/api/oidc-timeout.test.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 && node tests/api/static-prototype-pollution.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/webhook-legacy-migration.test.mjs && node tests/api/fetch-boundary-ownership.test.mjs && node tests/api/review-regressions.test.mjs && node tests/api/console-observability-boundary.test.mjs && node tests/api/email-identity-compat.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-state-capacity.test.mjs && node tests/api/oidc-mock-dev-only.test.mjs && node tests/api/oidc-email-verification.test.mjs && node tests/api/oidc-timeout.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/public-https-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/oidc_identity.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/public-https-transport.test.mjs && npm run test:api", From b166825714448bdaa5712800a8d8b26724515729 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 03:00:56 -0700 Subject: [PATCH 148/157] fix(security): make outbound fetch boundary module-local --- server/app.mjs | 40 +++++++------------ server/app_core.mjs | 25 +++++++++++- tests/api/review-regressions.test.mjs | 55 +++++++++++++++++++++------ 3 files changed, 81 insertions(+), 39 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 6447b59d..29270bdc 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -5,7 +5,7 @@ // Clearfolio behavior. Every production server and in-process caller imports // this facade; app_core.mjs is an implementation module, not a public entrypoint. import { createPublicKey, randomBytes, verify as verifySignature } from 'node:crypto'; -import { app as coreApp } from './app_core.mjs'; +import { app as coreApp, configureSecureOutboundFetch } from './app_core.mjs'; import { db } from './db.mjs'; import { finalizeOidcIdentity, @@ -20,7 +20,6 @@ import { } from './webhook_transport.mjs'; const nativeFetch = globalThis.fetch.bind(globalThis); -const webhookFetchBoundaryKey = Symbol.for('scopeweave.webhook-fetch-boundary'); const authorizationProbeObservabilityKey = Symbol('scopeweave.authorization-probe-observability'); const WEBHOOK_REGISTRATION_PATH = /^\/api\/orgs\/[^/]+\/webhooks$/; const WEBHOOK_REGISTRATION_BODY_MAX_BYTES = 16 * 1024; @@ -409,30 +408,19 @@ async function boundedOidcFetch(request) { return response; } -// Install exactly once per process. The server's signed webhook POSTs are -// routed through the SSRF-safe transport, and the configured OIDC token exchange -// gets a bounded provider budget plus signature/issuer/audience/nonce validation. -// Clearfolio, billing, and unrelated fetch users retain native fetch semantics. -// Constructing an effective Request first makes Request-object inputs and init -// overrides follow the same security decision as URL+init calls. -if (!globalThis[webhookFetchBoundaryKey]) { - globalThis.fetch = async (input, init = undefined) => { - const effectiveRequest = new Request(input, init); - if (isSignedWebhookRequest(effectiveRequest)) { - return protectedWebhookFetch(effectiveRequest); - } - if (isOidcTokenRequest(effectiveRequest)) { - return boundedOidcFetch(effectiveRequest); - } - return nativeFetch(effectiveRequest); - }; - Object.defineProperty(globalThis, webhookFetchBoundaryKey, { - value: true, - configurable: false, - enumerable: false, - writable: false, - }); -} +// app_core delegates its only security-sensitive outbound call sites here. +// Unknown core egress fails closed; unrelated process-wide fetch users retain +// the caller-owned implementation and are never classified by ScopeWeave. +configureSecureOutboundFetch(async (input, init) => { + const effectiveRequest = new Request(input, init); + if (isSignedWebhookRequest(effectiveRequest)) { + return protectedWebhookFetch(effectiveRequest); + } + if (isOidcTokenRequest(effectiveRequest)) { + return boundedOidcFetch(effectiveRequest); + } + throw new Error('unclassified core outbound request'); +}); function isDevelopmentLoopbackHttp(value) { if (process.env.SCOPEWEAVE_DEV !== '1') return false; diff --git a/server/app_core.mjs b/server/app_core.mjs index b98e763f..b2d8714e 100644 --- a/server/app_core.mjs +++ b/server/app_core.mjs @@ -1344,4 +1344,27 @@ app.get('*', async (c) => { } catch { return c.notFound(); } -}); \ No newline at end of file +}); + +let secureOutboundFetch = async () => { + throw new Error('secure outbound transport is not configured'); +}; + +/** + * Configure the module-local transport used by security-sensitive outbound requests. + * + * The core API intentionally never falls back to the process-wide `globalThis.fetch`. + * The public app facade must provide the SSRF-hardened webhook and OIDC transport + * before serving requests. Direct core imports therefore fail closed instead of + * bypassing destination validation. + */ +export function configureSecureOutboundFetch(nextFetch) { + if (typeof nextFetch !== 'function') { + throw new TypeError('secure outbound transport must be a function'); + } + secureOutboundFetch = nextFetch; +} + +// This lexical binding is resolved by the webhook and OIDC call sites above. +// Keeping it module-local preserves caller-owned process fetch implementations. +const fetch = (input, init) => secureOutboundFetch(input, init); diff --git a/tests/api/review-regressions.test.mjs b/tests/api/review-regressions.test.mjs index 5e3ddf8a..25d561b7 100644 --- a/tests/api/review-regressions.test.mjs +++ b/tests/api/review-regressions.test.mjs @@ -155,21 +155,52 @@ test('public auth rejects an oversized streaming body before unbounded buffering ); }); -test('signed webhook Request inputs stay behind the SSRF destination policy', async () => { - const signedRequest = new Request('https://127.0.0.1/internal', { +test('actual webhook deliveries stay behind the SSRF destination policy without replacing global fetch', async () => { + const { token, org } = await createOwner('webhook-delivery-boundary@scopeweave.test'); + const created = await request('/api/projects', { method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-scopeweave-event': 'project.update', - 'x-scopeweave-signature': `sha256=${'a'.repeat(64)}`, - }, - body: body({ event: 'project.update' }), + headers: { authorization: `Bearer ${token}` }, + body: body({ name: 'Webhook Boundary', orgId: org.id }), + }); + assert.equal(created.status, 200, 'project creation succeeds'); + const project = await created.json(); + + const inserted = db.prepare( + 'INSERT INTO webhooks(org_id,url,secret,events) VALUES(?,?,?,?)', + ).run(org.id, 'https://127.0.0.1/internal', 'whsec_review_regression', 'project.update'); + const webhookId = Number(inserted.lastInsertRowid); + const forwardedBefore = forwardedFetches.length; + + const updated = await request(`/api/projects/${project.id}`, { + method: 'PUT', + headers: { authorization: `Bearer ${token}` }, + body: body({ name: 'Webhook Boundary', version: 1, tasks: [] }), }); + assert.equal(updated.status, 200, 'customer save succeeds while webhook delivery is isolated'); - await assert.rejects( - globalThis.fetch(signedRequest), - (error) => error?.name === 'WebhookDestinationError', - 'Request-object webhook sends must use the same fail-closed transport as URL+init sends', + const deliveryQuery = db.prepare( + 'SELECT status_code AS statusCode, ok, attempt FROM webhook_deliveries WHERE webhook_id = ? ORDER BY attempt', + ); + let deliveries = []; + const deadline = Date.now() + 1200; + while (Date.now() < deadline) { + deliveries = deliveryQuery.all(webhookId); + if (deliveries.length >= 2) break; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + + assert.deepEqual( + deliveries, + [ + { statusCode: null, ok: 0, attempt: 1 }, + { statusCode: null, ok: 0, attempt: 2 }, + ], + 'private webhook destinations fail closed on both bounded attempts', + ); + assert.equal( + forwardedFetches.length, + forwardedBefore, + 'webhook delivery never reaches the caller-owned process fetch implementation', ); }); From 03a0f68c3771243c2712d24a9a98baa79a27cde4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 04:47:25 -0700 Subject: [PATCH 149/157] test(accessibility): allow facade transport imports --- tests/unit/toast-accessibility.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/toast-accessibility.test.mjs b/tests/unit/toast-accessibility.test.mjs index 4daa0fbd..5bff32e9 100644 --- a/tests/unit/toast-accessibility.test.mjs +++ b/tests/unit/toast-accessibility.test.mjs @@ -45,7 +45,7 @@ test('cloud toast stylesheet is on every production serve path', async () => { const serverDockerfile = readFileSync(new URL('../../Dockerfile.server', import.meta.url), 'utf8'); assert.match( serverFacade, - /import\s+\{\s*app\s+as\s+coreApp\s*\}\s+from\s+['"]\.\/app_core\.mjs['"]/, + /import\s+\{[^}]*\bapp\s+as\s+coreApp\b[^}]*\}\s+from\s+['"]\.\/app_core\.mjs['"]/, 'SaaS security facade delegates to the route graph that owns static assets', ); const { app } = await import('../../server/app.mjs'); From ea406046731dcbb461eefae83503c072607dc859 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 08:57:02 -0700 Subject: [PATCH 150/157] test(server): normalize sqlite delivery rows --- tests/api/review-regressions.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/api/review-regressions.test.mjs b/tests/api/review-regressions.test.mjs index 25d561b7..da7aedc3 100644 --- a/tests/api/review-regressions.test.mjs +++ b/tests/api/review-regressions.test.mjs @@ -190,7 +190,7 @@ test('actual webhook deliveries stay behind the SSRF destination policy without } assert.deepEqual( - deliveries, + deliveries.map(({ statusCode, ok, attempt }) => ({ statusCode, ok, attempt })), [ { statusCode: null, ok: 0, attempt: 1 }, { statusCode: null, ok: 0, attempt: 2 }, From f53a9796b1567c082b07885cca67f8c4630f4080 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 07:49:07 -0700 Subject: [PATCH 151/157] test: cover legacy private HTTPS webhook migration --- ...acy-private-destination-migration.test.mjs | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 tests/api/webhook-legacy-private-destination-migration.test.mjs diff --git a/tests/api/webhook-legacy-private-destination-migration.test.mjs b/tests/api/webhook-legacy-private-destination-migration.test.mjs new file mode 100644 index 00000000..d7f1c6bd --- /dev/null +++ b/tests/api/webhook-legacy-private-destination-migration.test.mjs @@ -0,0 +1,122 @@ +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-private-migration-')); +const databasePath = join(directory, 'legacy-private.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')) +); +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 + (42,1,'https://127.0.0.1/private','whsec_private','project.update',1), + (43,1,'https://hooks.example.test/callback','whsec_public','project.update',1); +`); +legacy.close(); + +process.env.SCOPEWEAVE_DB = databasePath; + +try { + const moduleUrl = pathToFileURL(join(process.cwd(), 'server', 'db.mjs')).href; + const first = await import(`${moduleUrl}?legacy-private-migration=first`); + + assert.deepEqual( + { + ...first.db.prepare( + 'SELECT active, blocked_reason AS blockedReason FROM webhooks WHERE id = 42', + ).get(), + }, + { active: 0, blockedReason: 'destination_policy' }, + 'legacy HTTPS destinations rejected by the current registration policy are disabled before delivery retries begin', + ); + assert.deepEqual( + { + ...first.db.prepare( + 'SELECT active, blocked_reason AS blockedReason FROM webhooks WHERE id = 43', + ).get(), + }, + { active: 1, blockedReason: null }, + 'legacy public HTTPS destinations remain enabled', + ); + + 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' AND target_id = '42'`, + ).all(); + assert.equal(firstAudit.length, 1, 'policy-incompatible legacy HTTPS rows emit one durable security audit event'); + assert.equal(firstAudit[0].targetType, 'webhook'); + assert.deepEqual( + JSON.parse(firstAudit[0].meta), + { + reason: 'destination_policy', + nextAction: 'register_public_https_replacement', + }, + 'audit evidence explains why delivery was blocked and gives the tenant a concrete replacement action', + ); + first.db.close(); + + const second = await import(`${moduleUrl}?legacy-private-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 = '42'`, + ).get().count, + 1, + 'restarting after migration does not duplicate tenant audit evidence', + ); + assert.deepEqual( + { + ...second.db.prepare( + 'SELECT active, blocked_reason AS blockedReason FROM webhooks WHERE id = 42', + ).get(), + }, + { active: 0, blockedReason: 'destination_policy' }, + 'policy-incompatible legacy destinations remain fail-closed on later starts', + ); + assert.deepEqual( + { + ...second.db.prepare( + 'SELECT active, blocked_reason AS blockedReason FROM webhooks WHERE id = 43', + ).get(), + }, + { active: 1, blockedReason: null }, + 'policy-compliant legacy destinations remain active on later starts', + ); + second.db.close(); +} finally { + delete process.env.SCOPEWEAVE_DB; + rmSync(directory, { recursive: true, force: true }); +} + +console.log('legacy private HTTPS webhook migration regression passed'); From 9ac687d5d8ab9b27e15a8f1d9063d8547ef94749 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 07:53:53 -0700 Subject: [PATCH 152/157] test: run legacy private webhook migration regression --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 3a9f8af7..37d15841 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/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs && node tests/api/static-prototype-pollution.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/webhook-legacy-migration.test.mjs && node tests/api/fetch-boundary-ownership.test.mjs && node tests/api/review-regressions.test.mjs && node tests/api/console-observability-boundary.test.mjs && node tests/api/email-identity-compat.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-state-capacity.test.mjs && node tests/api/oidc-mock-dev-only.test.mjs && node tests/api/oidc-email-verification.test.mjs && node tests/api/oidc-timeout.test.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 && node tests/api/static-prototype-pollution.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/webhook-legacy-migration.test.mjs && node tests/api/webhook-legacy-private-destination-migration.test.mjs && node tests/api/fetch-boundary-ownership.test.mjs && node tests/api/review-regressions.test.mjs && node tests/api/console-observability-boundary.test.mjs && node tests/api/email-identity-compat.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-state-capacity.test.mjs && node tests/api/oidc-mock-dev-only.test.mjs && node tests/api/oidc-email-verification.test.mjs && node tests/api/oidc-timeout.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/public-https-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/oidc_identity.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/public-https-transport.test.mjs && npm run test:api", @@ -31,4 +31,4 @@ "c8": "12.0.0", "fast-check": "4.9.0" } -} \ No newline at end of file +} From 3af41edd7ceb218bc32ab2d8d296be8a037192a6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 07:59:23 -0700 Subject: [PATCH 153/157] fix: quarantine legacy webhook destinations by policy --- server/webhook_legacy_migration.mjs | 98 +++++++++++++++++++++++++++++ 1 file changed, 98 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..dd7076c6 --- /dev/null +++ b/server/webhook_legacy_migration.mjs @@ -0,0 +1,98 @@ +import { validateWebhookRegistrationUrl } from './webhook_transport.mjs'; + +const SECURITY_ACTION = 'webhook.security_block'; +const NEXT_ACTION = 'register_public_https_replacement'; + +function isCurrentDestinationAllowed(url) { + try { + validateWebhookRegistrationUrl(url); + return true; + } catch { + return false; + } +} + +function blockedReasonFor(url) { + try { + return new URL(String(url ?? '')).protocol === 'http:' + ? 'insecure_scheme' + : 'destination_policy'; + } catch { + return 'destination_policy'; + } +} + +/** + * Disable active legacy webhook destinations rejected by current registration policy. + * + * Historical ScopeWeave releases accepted broader HTTP(S) webhook URLs. Current + * production registration requires public HTTPS, so leaving an incompatible row + * active would repeatedly attempt a delivery that the transport must reject. This + * migration reconciles every active row against the same synchronous registration + * policy, disables rejected destinations, records why they were blocked, and emits + * one tenant-visible audit event with a concrete replacement action. It never reads + * or copies webhook signing secrets. + * + * DNS-backed hostnames remain subject to per-attempt address authorization and + * socket pinning at delivery time; startup intentionally performs no network I/O. + * + * @param {import('node:sqlite').DatabaseSync} database Open ScopeWeave database. + * @returns {number} Number of webhook rows newly disabled during this run. + */ +export function migrateLegacyWebhookDestinations(database) { + 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 disable = database.prepare( + `UPDATE webhooks + SET active = 0, + blocked_reason = ? + 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 changes() > 0 + AND 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 (isCurrentDestinationAllowed(candidate.url)) continue; + const reason = blockedReasonFor(candidate.url); + const targetId = String(candidate.id); + const result = disable.run(reason, candidate.id, candidate.orgId); + disabled += Number(result.changes); + audit.run( + candidate.orgId, + SECURITY_ACTION, + targetId, + JSON.stringify({ reason, nextAction: NEXT_ACTION }), + candidate.orgId, + SECURITY_ACTION, + targetId, + ); + } + database.exec('COMMIT'); + return disabled; + } catch (error) { + try { + database.exec('ROLLBACK'); + } catch { + // Preserve the causal migration failure if rollback itself also fails. + } + throw error; + } +} From ef8af83f1a6602d9cb74ef67d8a88e4527cb46db Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 11:19:30 -0700 Subject: [PATCH 154/157] fix(security): quarantine legacy private webhooks --- server/db.mjs | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/server/db.mjs b/server/db.mjs index 132e6569..3e4d6562 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 { WebhookDestinationError, validateWebhookRegistrationUrl } from './webhook_transport.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const dbPath = process.env.SCOPEWEAVE_DB || join(__dirname, '..', 'data.db'); @@ -218,5 +219,55 @@ try { throw error; } +// Some historical HTTPS rows can also violate the current registration policy, +// such as loopback/private IP literals or localhost-style names. Apply only the +// deterministic, DNS-free portion of the live policy here: startup must not +// permanently disable a public hostname because of a transient resolver state. +try { + db.exec('BEGIN IMMEDIATE'); + const legacyCandidates = db.prepare(` + SELECT id, org_id AS orgId, url + FROM webhooks + WHERE active != 0 + `).all(); + const insertSecurityBlock = db.prepare(` + INSERT INTO audit_log(org_id, user_id, action, target_type, target_id, meta) + SELECT ?, NULL, 'webhook.security_block', 'webhook', ?, ? + WHERE NOT EXISTS ( + SELECT 1 + FROM audit_log + WHERE org_id = ? + AND action = 'webhook.security_block' + AND target_type = 'webhook' + AND target_id = ? + ) + `); + const disableWebhook = db.prepare(` + UPDATE webhooks + SET active = 0, + blocked_reason = 'destination_policy' + WHERE id = ? AND active != 0 + `); + + for (const row of legacyCandidates) { + try { + validateWebhookRegistrationUrl(row.url); + } catch (error) { + if (!(error instanceof WebhookDestinationError)) throw error; + const targetId = String(row.id); + const meta = JSON.stringify({ + reason: 'destination_policy', + nextAction: 'register_public_https_replacement', + }); + insertSecurityBlock.run(row.orgId, targetId, meta, row.orgId, targetId); + disableWebhook.run(row.id); + } + } + db.exec('COMMIT'); +} catch (error) { + try { db.exec('ROLLBACK'); } catch { /* transaction did not begin */ } + throw error; +} + // node:sqlite returns lastInsertRowid as number|bigint; normalize to Number. export const rowid = (r) => Number(r.lastInsertRowid); \ No newline at end of file From 6228733d4676a69f596742be765ac95f2dd9ab76 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 16:19:40 -0700 Subject: [PATCH 155/157] test(webhooks): reproduce no-op private migration lock --- ...acy-private-destination-migration.test.mjs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/api/webhook-legacy-private-destination-migration.test.mjs b/tests/api/webhook-legacy-private-destination-migration.test.mjs index d7f1c6bd..dea8d2c6 100644 --- a/tests/api/webhook-legacy-private-destination-migration.test.mjs +++ b/tests/api/webhook-legacy-private-destination-migration.test.mjs @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { DatabaseSync } from 'node:sqlite'; +import { migrateLegacyWebhookDestinations } from '../../server/webhook_legacy_migration.mjs'; const directory = mkdtempSync(join(tmpdir(), 'scopeweave-webhook-private-migration-')); const databasePath = join(directory, 'legacy-private.sqlite'); @@ -119,4 +120,45 @@ try { rmSync(directory, { recursive: true, force: true }); } +const contentionDirectory = mkdtempSync( + join(tmpdir(), 'scopeweave-webhook-private-migration-contention-'), +); +const contentionPath = join(contentionDirectory, 'scopeweave.sqlite'); +const contended = new DatabaseSync(contentionPath); +contended.exec(` + PRAGMA busy_timeout = 0; + CREATE TABLE webhooks ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL, + url TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1, + blocked_reason TEXT + ); + 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 + ); + INSERT INTO webhooks(id,org_id,url,active,blocked_reason) + VALUES(50,11,'https://hooks.example.test/callback',1,NULL); +`); +const writer = new DatabaseSync(contentionPath); + +try { + writer.exec('PRAGMA busy_timeout = 0; BEGIN IMMEDIATE;'); + assert.doesNotThrow( + () => assert.equal(migrateLegacyWebhookDestinations(contended), 0), + 'a compliant no-op startup migration must not reserve the SQLite writer', + ); +} finally { + writer.exec('ROLLBACK'); + writer.close(); + contended.close(); + rmSync(contentionDirectory, { recursive: true, force: true }); +} + console.log('legacy private HTTPS webhook migration regression passed'); From 965968973383fb709f084733796ecb36518c238d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 16:21:35 -0700 Subject: [PATCH 156/157] fix(webhooks): avoid no-op migration writer lock --- server/webhook_legacy_migration.mjs | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/server/webhook_legacy_migration.mjs b/server/webhook_legacy_migration.mjs index dd7076c6..4db8babc 100644 --- a/server/webhook_legacy_migration.mjs +++ b/server/webhook_legacy_migration.mjs @@ -22,6 +22,19 @@ function blockedReasonFor(url) { } } +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) { + return candidates.some((candidate) => !isCurrentDestinationAllowed(candidate.url)); +} + /** * Disable active legacy webhook destinations rejected by current registration policy. * @@ -33,6 +46,11 @@ function blockedReasonFor(url) { * one tenant-visible audit event with a concrete replacement action. It never reads * or copies webhook signing secrets. * + * The initial scan is deliberately read-only. A database whose active webhook rows + * already satisfy policy therefore does not reserve SQLite's single writer during + * ordinary startup. If mutation is needed, the migration then acquires an immediate + * transaction and re-reads the candidate set while holding that writer reservation, + * so concurrent changes cannot make the preflight result authoritative by accident. * DNS-backed hostnames remain subject to per-attempt address authorization and * socket pinning at delivery time; startup intentionally performs no network I/O. * @@ -40,14 +58,12 @@ function blockedReasonFor(url) { * @returns {number} Number of webhook rows newly disabled during this run. */ export function migrateLegacyWebhookDestinations(database) { + const preflightCandidates = activeWebhookDestinations(database); + if (!hasPolicyIncompatibleDestination(preflightCandidates)) 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, From 92487d1f9e5215cd4b7302275c23393596799ba3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 16:22:29 -0700 Subject: [PATCH 157/157] fix(db): consolidate webhook startup reconciliation --- server/db.mjs | 95 +++++---------------------------------------------- 1 file changed, 8 insertions(+), 87 deletions(-) diff --git a/server/db.mjs b/server/db.mjs index 3e4d6562..5cdf7dcd 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -4,7 +4,7 @@ import { DatabaseSync } from 'node:sqlite'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; -import { WebhookDestinationError, validateWebhookRegistrationUrl } from './webhook_transport.mjs'; +import { migrateLegacyWebhookDestinations } from './webhook_legacy_migration.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const dbPath = process.env.SCOPEWEAVE_DB || join(__dirname, '..', 'data.db'); @@ -182,92 +182,13 @@ try { db.exec('ALTER TABLE projects ADD COLUMN archived INTEGER NOT NULL DEFAULT try { db.exec("ALTER TABLE projects ADD COLUMN methodology TEXT NOT NULL DEFAULT 'waterfall'"); } catch { /* already there */ } try { db.exec('ALTER TABLE webhooks ADD COLUMN blocked_reason TEXT'); } catch { /* already there */ } -// Historical versions accepted http:// webhook destinations. Once outbound -// delivery requires public HTTPS, fail those rows closed exactly once instead -// of retrying a destination that policy will always reject. The audit record is -// durable buyer-facing evidence and contains no webhook secret. -try { - db.exec('BEGIN IMMEDIATE'); - db.exec(` - INSERT INTO audit_log(org_id, user_id, action, target_type, target_id, meta) - SELECT - w.org_id, - NULL, - 'webhook.security_block', - 'webhook', - CAST(w.id AS TEXT), - '{"reason":"insecure_scheme","nextAction":"register_public_https_replacement"}' - FROM webhooks w - WHERE lower(w.url) LIKE 'http://%' - AND NOT EXISTS ( - SELECT 1 - FROM audit_log a - WHERE a.org_id = w.org_id - AND a.action = 'webhook.security_block' - AND a.target_type = 'webhook' - AND a.target_id = CAST(w.id AS TEXT) - ); - - UPDATE webhooks - SET active = 0, - blocked_reason = 'insecure_scheme' - WHERE lower(url) LIKE 'http://%'; - `); - db.exec('COMMIT'); -} catch (error) { - try { db.exec('ROLLBACK'); } catch { /* transaction did not begin */ } - throw error; -} - -// Some historical HTTPS rows can also violate the current registration policy, -// such as loopback/private IP literals or localhost-style names. Apply only the -// deterministic, DNS-free portion of the live policy here: startup must not -// permanently disable a public hostname because of a transient resolver state. -try { - db.exec('BEGIN IMMEDIATE'); - const legacyCandidates = db.prepare(` - SELECT id, org_id AS orgId, url - FROM webhooks - WHERE active != 0 - `).all(); - const insertSecurityBlock = db.prepare(` - INSERT INTO audit_log(org_id, user_id, action, target_type, target_id, meta) - SELECT ?, NULL, 'webhook.security_block', 'webhook', ?, ? - WHERE NOT EXISTS ( - SELECT 1 - FROM audit_log - WHERE org_id = ? - AND action = 'webhook.security_block' - AND target_type = 'webhook' - AND target_id = ? - ) - `); - const disableWebhook = db.prepare(` - UPDATE webhooks - SET active = 0, - blocked_reason = 'destination_policy' - WHERE id = ? AND active != 0 - `); - - for (const row of legacyCandidates) { - try { - validateWebhookRegistrationUrl(row.url); - } catch (error) { - if (!(error instanceof WebhookDestinationError)) throw error; - const targetId = String(row.id); - const meta = JSON.stringify({ - reason: 'destination_policy', - nextAction: 'register_public_https_replacement', - }); - insertSecurityBlock.run(row.orgId, targetId, meta, row.orgId, targetId); - disableWebhook.run(row.id); - } - } - db.exec('COMMIT'); -} catch (error) { - try { db.exec('ROLLBACK'); } catch { /* transaction did not begin */ } - throw error; -} +// Reconcile active historical webhook rows against the current deterministic +// registration policy. The helper performs a read-only preflight, so a compliant +// database does not reserve SQLite's single writer during ordinary startup. If a +// row must be disabled, it re-reads under one immediate transaction, records the +// tenant-visible reason/next action, and leaves DNS-backed delivery checks to the +// per-attempt transport boundary. +migrateLegacyWebhookDestinations(db); // node:sqlite returns lastInsertRowid as number|bigint; normalize to Number. export const rowid = (r) => Number(r.lastInsertRowid); \ No newline at end of file