From c4a3b2f6429634c4165f7efdc71734384241e574 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 19:34:45 -0700 Subject: [PATCH 001/120] test: reject spoofed forwarding IPs in rate limiter --- tests/api/ratelimit.test.mjs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/api/ratelimit.test.mjs b/tests/api/ratelimit.test.mjs index 7157e523..421def03 100644 --- a/tests/api/ratelimit.test.mjs +++ b/tests/api/ratelimit.test.mjs @@ -19,8 +19,9 @@ const limited = await req('/api/health'); assert.equal(limited.status, 429, 'still limited within the window'); assert.ok(limited.headers.get('retry-after'), 'Retry-After header present'); -// a different client IP has its own bucket -const other = await req('/api/health', { headers: { 'x-forwarded-for': '198.51.100.9' } }); -assert.equal(other.status, 200, 'different IP not limited'); +// Untrusted callers must not be able to evade the security boundary by +// choosing a fresh client-supplied forwarding header on every request. +const spoofed = await req('/api/health', { headers: { 'x-forwarded-for': '198.51.100.9' } }); +assert.equal(spoofed.status, 429, 'untrusted X-Forwarded-For cannot select a new rate-limit bucket'); -console.log('✓ rate-limit tests passed'); +console.log('✓ rate-limit tests passed'); \ No newline at end of file From 7e6daab611ad89e79f9df1f105fced3dd3e186cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 19:40:07 -0700 Subject: [PATCH 002/120] fix(security): anchor rate limiting to trusted network peers --- server/app.mjs | 1454 ++--------------------------------------- server/app_routes.mjs | 1410 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 1472 insertions(+), 1392 deletions(-) create mode 100644 server/app_routes.mjs diff --git a/server/app.mjs b/server/app.mjs index c432a84f..58d13799 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1,1410 +1,80 @@ -// 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. +// Security envelope for the ScopeWeave SaaS routes. +// +// The route implementation remains in app_routes.mjs so the client-IP trust +// boundary stays small, reviewable, and independently testable. The outer +// limiter is authoritative for abuse protection; the route module's historic +// limiter remains in place for backward compatibility until that code is +// retired in a later focused change. 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 +import { isIP } from 'node:net'; +import { app as routeApp } from './app_routes.mjs'; -const getOrg = (id) => db.prepare('SELECT * FROM orgs WHERE id = ?').get(id); +export * from './app_routes.mjs'; -// 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, -}; +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 trustedProxyIps = new Set( + String(process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS || '') + .split(',') + .map((value) => value.trim()) + .filter((value) => isIP(value) !== 0) +); +const rlBuckets = new Map(); -// 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 */ } +/** + * Resolve the network peer that directly connected to the Node server. + * In in-process tests or non-Node adapters where no socket exists, all such + * requests deliberately share the fail-closed `local` bucket. + */ +function connectionPeerIp(c) { + const raw = c.env?.incoming?.socket?.remoteAddress; + if (typeof raw !== 'string') return 'local'; + const candidate = raw.trim(); + return isIP(candidate) ? candidate : 'local'; } -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)); +/** + * Resolve a rate-limit identity without trusting caller-controlled forwarding + * headers. Forwarded hops are considered only when the immediate network peer + * is explicitly trusted, then walked right-to-left until the first untrusted + * valid IP. Invalid forwarding evidence fails closed to the actual peer. + */ +function rateLimitClientIp(c) { + const peer = connectionPeerIp(c); + if (!trustedProxyIps.has(peer)) return peer; + + const forwarded = String(c.req.header('x-forwarded-for') || '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + if (forwarded.length === 0) return peer; + + for (let i = forwarded.length - 1; i >= 0; i--) { + const hop = forwarded[i]; + if (!isIP(hop)) return peer; + if (!trustedProxyIps.has(hop)) return hop; + } + return peer; } -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 */ } -}); +export const app = new Hono(); -// 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 key = rateLimitClientIp(c); 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); + let bucket = rlBuckets.get(key); + if (!bucket || bucket.resetAt <= now) { + bucket = { count: 0, resetAt: now + RL_WINDOW_MS }; + rlBuckets.set(key, bucket); + } + bucket.count++; + if (bucket.count > RL_MAX) { + const retry = Math.ceil((bucket.resetAt - now) / 1000); return c.json({ error: 'rate limit exceeded' }, 429, { 'Retry-After': String(retry) }); } await next(); }); } -app.post('/api/auth/signup', async (c) => { - const { email, password, name } = await c.req.json().catch(() => ({})); - if (!email || typeof password !== 'string' || password.length < 8) { - return c.json({ error: 'email and password (min 8 chars) required' }, 400); - } - if (db.prepare('SELECT id FROM users WHERE email = ?').get(email)) { - return c.json({ error: 'email already registered' }, 409); - } - // user + personal workspace + owner membership, atomically. - let uid; - const tx = () => { - uid = rowid(db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') - .run(email, hashPassword(password), name || '')); - const oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)') - .run(`${name || email}'s workspace`, uid)); - db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); - }; - db.exec('BEGIN'); - try { tx(); db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw e; } - metrics.signups++; - return c.json({ token: signToken({ sub: uid, email, tv: 0 }) }); -}); - -app.post('/api/auth/login', async (c) => { - const { email, password } = await c.req.json().catch(() => ({})); - const u = db.prepare('SELECT * FROM users WHERE email = ?').get(email || ''); - // Pass password through only when it is a string — verifyPassword rejects - // non-strings (objects/arrays) so they never match an empty-password hash. - if (!u || typeof password !== 'string' || !verifyPassword(password, u.password_hash)) { - return c.json({ error: 'invalid credentials' }, 401); - } - return c.json({ token: signToken({ sub: u.id, email: u.email, tv: u.token_version }) }); -}); - -app.get('/api/me', requireAuth, (c) => { - const uid = c.get('user').sub; - const user = db.prepare('SELECT id,email,name FROM users WHERE id = ?').get(uid); - const orgs = db.prepare( - `SELECT o.id,o.name,o.plan,m.role FROM orgs o - JOIN memberships m ON m.org_id = o.id WHERE m.user_id = ?` - ).all(uid); - return c.json({ user, orgs }); -}); - -// Create an additional workspace (org); the creator becomes its owner. -app.post('/api/orgs', requireAuth, async (c) => { - const uid = c.get('user').sub; - const { name } = await c.req.json().catch(() => ({})); - if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); - let oid; - db.exec('BEGIN'); - try { - oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)').run(String(name).trim().slice(0, 120), uid)); - db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); - db.exec('COMMIT'); - } catch (e) { db.exec('ROLLBACK'); throw e; } - logAudit(oid, uid, 'org.create', 'org', oid, { name }); - return c.json({ id: oid, name: String(name).trim(), role: 'owner' }); -}); - -app.get('/api/projects', requireAuth, (c) => { - const uid = c.get('user').sub; - const projects = db.prepare( - `SELECT p.id,p.name,p.base_date AS baseDate,p.version,p.org_id AS orgId,p.updated_at AS updatedAt,p.archived - FROM projects p JOIN memberships m ON m.org_id = p.org_id - WHERE m.user_id = ? ORDER BY p.archived ASC, p.updated_at DESC` - ).all(uid); - return c.json({ projects }); -}); - -app.post('/api/projects', requireAuth, async (c) => { - const uid = c.get('user').sub; - const { name, orgId } = await c.req.json().catch(() => ({})); - if (!name) return c.json({ error: 'name required' }, 400); - const org = orgId - ? db.prepare('SELECT o.id FROM orgs o JOIN memberships m ON m.org_id = o.id WHERE o.id = ? AND m.user_id = ?').get(orgId, uid) - : db.prepare('SELECT o.id FROM orgs o JOIN memberships m ON m.org_id = o.id WHERE m.user_id = ? ORDER BY o.id LIMIT 1').get(uid); - if (!org) return c.json({ error: 'no accessible org' }, 400); - if (wouldExceed(db, getOrg(org.id), 'projects')) { - return c.json({ error: 'project limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.projects }, 402); - } - const id = rowid(db.prepare('INSERT INTO projects(org_id,name,created_by) VALUES(?,?,?)').run(org.id, name, uid)); - metrics.projectsCreated++; - logAudit(org.id, uid, 'project.create', 'project', id, { name }); - return c.json({ id, name, version: 1 }); -}); - -app.get('/api/projects/:id', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - return c.json({ id: p.id, name: p.name, orgId: p.org_id, baseDate: p.base_date, methodology: p.methodology || 'waterfall', tasks: JSON.parse(p.tasks_json), version: p.version }); -}); - -app.put('/api/projects/:id', requireAuth, async (c) => { - const uid = c.get('user').sub; - const id = c.req.param('id'); - const p = projectAccess(uid, id); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden: viewer role is read-only' }, 403); - const body = await c.req.json().catch(() => ({})); - if (typeof body.version === 'number' && body.version !== p.version) { - return c.json({ error: 'version conflict', current: p.version }, 409); - } - const tasks = Array.isArray(body.tasks) ? body.tasks : JSON.parse(p.tasks_json); - const version = p.version + 1; - const methodology = ['waterfall', 'agile', 'hybrid'].includes(body.methodology) ? body.methodology : (p.methodology || 'waterfall'); - db.prepare( - "UPDATE projects SET name=?, base_date=?, tasks_json=?, version=?, methodology=?, updated_at=datetime('now') WHERE id=?" - ).run(body.name ?? p.name, body.baseDate ?? p.base_date, JSON.stringify(tasks), version, methodology, id); - logAudit(p.org_id, uid, 'project.update', 'project', id, { version, tasks: tasks.length }); - // Revision history: snapshot every save, keep the last 20 per project. - try { - db.prepare('INSERT OR REPLACE INTO project_revisions(project_id,version,name,base_date,tasks_json,saved_by) VALUES(?,?,?,?,?,?)') - .run(id, version, body.name ?? p.name, body.baseDate ?? p.base_date, JSON.stringify(tasks), uid); - db.prepare('DELETE FROM project_revisions WHERE project_id = ? AND version <= ?').run(id, version - 20); - } catch { /* history must not break saves */ } - deliver(p.org_id, 'project.update', { projectId: Number(id), version, tasks: tasks.length, by: uid }); - broadcast(id, { type: 'update', version, by: uid }); - return c.json({ version }); -}); - -// Task comments: discussion bound to a project (optionally a task). All roles -// can read; write roles can post; author or manage can delete. -app.get('/api/projects/:id/comments', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const taskId = c.req.query('taskId'); - const comments = (taskId - ? db.prepare(`SELECT cm.id, cm.task_id AS taskId, cm.body, cm.created_at AS createdAt, cm.user_id AS userId, u.email - FROM comments cm LEFT JOIN users u ON u.id = cm.user_id - WHERE cm.project_id = ? AND cm.task_id = ? ORDER BY cm.id DESC LIMIT 100`).all(p.id, taskId) - : db.prepare(`SELECT cm.id, cm.task_id AS taskId, cm.body, cm.created_at AS createdAt, cm.user_id AS userId, u.email - FROM comments cm LEFT JOIN users u ON u.id = cm.user_id - WHERE cm.project_id = ? ORDER BY cm.id DESC LIMIT 100`).all(p.id)); - return c.json({ comments }); -}); - -app.post('/api/projects/:id/comments', requireAuth, async (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const { taskId, body } = await c.req.json().catch(() => ({})); - const text = String(body || '').trim(); - if (!text) return c.json({ error: 'body required' }, 400); - if (text.length > 2000) return c.json({ error: 'comment too long (max 2000)' }, 400); - const cid = rowid(db.prepare('INSERT INTO comments(project_id,task_id,user_id,body) VALUES(?,?,?,?)') - .run(p.id, String(taskId || ''), uid, text)); - logAudit(p.org_id, uid, 'comment.create', 'project', p.id, { commentId: cid, taskId: taskId || null }); - broadcast(p.id, { type: 'comment', commentId: cid, by: uid }); - return c.json({ id: cid }); -}); - -app.delete('/api/projects/:id/comments/:cid', requireAuth, (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const cm = db.prepare('SELECT user_id FROM comments WHERE id = ? AND project_id = ?').get(c.req.param('cid'), p.id); - if (!cm) return c.json({ error: 'not found' }, 404); - if (cm.user_id !== uid && !canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - db.prepare('DELETE FROM comments WHERE id = ?').run(c.req.param('cid')); - return c.json({ ok: true }); -}); - -// Revision history: list, inspect, restore. -app.get('/api/projects/:id/revisions', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const revisions = db.prepare( - `SELECT r.version, r.created_at AS savedAt, u.email AS savedBy FROM project_revisions r - LEFT JOIN users u ON u.id = r.saved_by WHERE r.project_id = ? ORDER BY r.version DESC` - ).all(p.id); - return c.json({ revisions }); -}); - -app.get('/api/projects/:id/revisions/:version', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const r = db.prepare('SELECT version, name, base_date AS baseDate, tasks_json FROM project_revisions WHERE project_id = ? AND version = ?') - .get(p.id, c.req.param('version')); - if (!r) return c.json({ error: 'not found' }, 404); - return c.json({ version: r.version, name: r.name, baseDate: r.baseDate, tasks: JSON.parse(r.tasks_json) }); -}); - -// Restore = write the old snapshot as a NEW version (history stays linear). -app.post('/api/projects/:id/revisions/:version/restore', requireAuth, (c) => { - const uid = c.get('user').sub; - const id = c.req.param('id'); - const p = projectAccess(uid, id); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const r = db.prepare('SELECT name, base_date, tasks_json FROM project_revisions WHERE project_id = ? AND version = ?') - .get(id, c.req.param('version')); - if (!r) return c.json({ error: 'not found' }, 404); - const version = p.version + 1; - db.prepare("UPDATE projects SET name=?, base_date=?, tasks_json=?, version=?, updated_at=datetime('now') WHERE id=?") - .run(r.name, r.base_date, r.tasks_json, version, id); - try { - db.prepare('INSERT OR REPLACE INTO project_revisions(project_id,version,name,base_date,tasks_json,saved_by) VALUES(?,?,?,?,?,?)') - .run(id, version, r.name, r.base_date, r.tasks_json, uid); - } catch { /* history must not break restore */ } - logAudit(p.org_id, uid, 'project.restore', 'project', id, { from: Number(c.req.param('version')), version }); - broadcast(id, { type: 'update', version, by: uid }); - return c.json({ version }); -}); - -// iCalendar feed: planned tasks as all-day VEVENTs — subscribable from -// Google/Outlook. Calendar apps can't send headers, so accept ?token= (same -// pattern + ceiling as /stream). PATs work via the Authorization header. -app.get('/api/projects/:id/calendar.ics', (c) => { - const header = c.req.header('authorization') || ''; - const raw = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); - let uid; - if (raw.startsWith('swk_')) { - const row = db.prepare('SELECT user_id FROM api_tokens WHERE token_hash = ?').get(hashApiToken(raw)); - if (!row) return c.json({ error: 'unauthorized' }, 401); - uid = row.user_id; - } else { - try { uid = verifyToken(raw).sub; } catch { return c.json({ error: 'unauthorized' }, 401); } - } - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - let tasks = []; - try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } - const day = (s) => String(s).replaceAll('-', ''); - const nextDay = (s) => { const d = new Date(s); d.setDate(d.getDate() + 1); return d.toISOString().slice(0, 10).replaceAll('-', ''); }; - const esc = (s) => String(s).replace(/\\/g, '\\\\').replace(/[,;]/g, (m) => `\\${m}`).replace(/\n/g, '\\n'); - const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//ScopeWeave//KO', 'CALSCALE:GREGORIAN', `X-WR-CALNAME:${esc(p.name)}`]; - for (const t of tasks) { - if (!/^\d{4}-\d{2}-\d{2}$/.test(t.plannedStartDate || '') || !/^\d{4}-\d{2}-\d{2}$/.test(t.plannedEndDate || '')) continue; - lines.push( - 'BEGIN:VEVENT', - `UID:scopeweave-${p.id}-${esc(t.id)}`, - `DTSTART;VALUE=DATE:${day(t.plannedStartDate)}`, - `DTEND;VALUE=DATE:${nextDay(t.plannedEndDate)}`, // DTEND is exclusive - `SUMMARY:${esc(t.name || t.task || t.id)}`, - 'END:VEVENT' - ); - } - lines.push('END:VCALENDAR'); - return c.text(lines.join('\r\n') + '\r\n', 200, { - 'content-type': 'text/calendar; charset=utf-8', - 'content-disposition': `attachment; filename="scopeweave-${p.id}.ics"`, - }); -}); - -app.get('/api/projects/:id/stream', (c) => { - // EventSource can't send an Authorization header, so accept a query token - // here only. Ceiling: issue a short-lived stream-scoped token before prod so - // full JWTs don't land in URLs / access logs. - const header = c.req.header('authorization') || ''; - const token = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); - let user; - try { user = verifyToken(token); } catch { return c.json({ error: 'unauthorized' }, 401); } - const id = c.req.param('id'); - if (!projectAccess(user.sub, id)) return c.json({ error: 'not found' }, 404); - const key = String(id); - const stream = new ReadableStream({ - start(controller) { - if (!streams.has(key)) streams.set(key, new Set()); - streams.get(key).add(controller); - controller.enqueue(new TextEncoder().encode(': connected\n\n')); - c.req.raw.signal?.addEventListener('abort', () => { - streams.get(key)?.delete(controller); - try { controller.close(); } catch { /* already closed */ } - }); - }, - }); - return new Response(stream, { - headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }, - }); -}); - -// --------------------------------------------------------------- teams / RBAC -// List members of an org (any member may view the roster). -app.get('/api/orgs/:id/members', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); - const members = db.prepare( - `SELECT u.id, u.email, u.name, m.role FROM memberships m - JOIN users u ON u.id = m.user_id WHERE m.org_id = ? ORDER BY m.id` - ).all(orgId); - const invites = db.prepare( - `SELECT id, email, role, token, created_at AS createdAt FROM invites - WHERE org_id = ? AND accepted_at IS NULL ORDER BY id DESC` - ).all(orgId); - return c.json({ members, invites }); -}); - -// Revoke a pending invite (owner/admin). The token stops working immediately. -app.delete('/api/orgs/:id/invites/:inviteId', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const info = db.prepare('DELETE FROM invites WHERE id = ? AND org_id = ? AND accepted_at IS NULL') - .run(c.req.param('inviteId'), orgId); - if (!info.changes) return c.json({ error: 'not found' }, 404); - logAudit(orgId, uid, 'invite.revoke', 'invite', c.req.param('inviteId'), {}); - return c.json({ ok: true }); -}); - -// Invite by email (owner/admin only). Returns the token (prod: email a link). -app.post('/api/orgs/:id/invites', requireAuth, async (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - const role = orgRole(uid, orgId); - if (!role) return c.json({ error: 'not found' }, 404); - if (!canManage(role)) return c.json({ error: 'forbidden' }, 403); - const body = await c.req.json().catch(() => ({})); - const email = String(body.email || '').trim().toLowerCase(); - const inviteRole = body.role || 'member'; - if (!email) return c.json({ error: 'email required' }, 400); - if (!['admin', 'member', 'viewer'].includes(inviteRole)) return c.json({ error: 'invalid role' }, 400); - const token = randomBytes(24).toString('base64url'); - db.prepare('INSERT INTO invites(org_id,email,role,token,invited_by) VALUES(?,?,?,?,?)') - .run(orgId, email, inviteRole, token, uid); - logAudit(orgId, uid, 'member.invite', 'invite', email, { role: inviteRole }); - return c.json({ token, email, role: inviteRole }); -}); - -// Accept an invite (any authenticated user holding the token). Idempotent. -app.post('/api/invites/:token/accept', requireAuth, (c) => { - const uid = c.get('user').sub; - const inv = db.prepare('SELECT * FROM invites WHERE token = ?').get(c.req.param('token')); - if (!inv || inv.accepted_at) return c.json({ error: 'invalid or used invite' }, 404); - const existing = orgRole(uid, inv.org_id); - if (!existing) { - if (wouldExceed(db, getOrg(inv.org_id), 'members')) { - return c.json({ error: 'member limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.members }, 402); - } - db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(inv.org_id, uid, inv.role); - logAudit(inv.org_id, uid, 'member.join', 'user', uid, { role: inv.role }); - deliver(inv.org_id, 'member.join', { userId: uid, role: inv.role }); - } - db.prepare("UPDATE invites SET accepted_at = datetime('now') WHERE id = ?").run(inv.id); - return c.json({ orgId: inv.org_id, role: existing || inv.role }); -}); - -// Change a member's role (owner/admin). Cannot touch an owner or set owner. -app.patch('/api/orgs/:id/members/:userId', requireAuth, async (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - const targetId = c.req.param('userId'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const body = await c.req.json().catch(() => ({})); - const newRole = body.role; - if (!['admin', 'member', 'viewer'].includes(newRole)) return c.json({ error: 'invalid role' }, 400); - const target = db.prepare('SELECT role FROM memberships WHERE org_id = ? AND user_id = ?').get(orgId, targetId); - if (!target) return c.json({ error: 'not found' }, 404); - if (target.role === 'owner') return c.json({ error: 'cannot change owner role' }, 403); - db.prepare('UPDATE memberships SET role = ? WHERE org_id = ? AND user_id = ?').run(newRole, orgId, targetId); - logAudit(orgId, uid, 'member.role_change', 'user', targetId, { from: target.role, to: newRole }); - return c.json({ userId: Number(targetId), role: newRole }); -}); - -// Remove a member (owner/admin). Cannot remove an owner. -app.delete('/api/orgs/:id/members/:userId', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - const targetId = c.req.param('userId'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const target = db.prepare('SELECT role FROM memberships WHERE org_id = ? AND user_id = ?').get(orgId, targetId); - if (!target) return c.json({ error: 'not found' }, 404); - if (target.role === 'owner') return c.json({ error: 'cannot remove owner' }, 403); - db.prepare('DELETE FROM memberships WHERE org_id = ? AND user_id = ?').run(orgId, targetId); - logAudit(orgId, uid, 'member.remove', 'user', targetId, { role: target.role }); - return c.json({ ok: true }); -}); - -// Leave a workspace voluntarily (any non-owner member). Owners must transfer or -// delete the org instead — an org can never be left ownerless. -app.post('/api/orgs/:id/leave', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - const role = orgRole(uid, orgId); - if (!role) return c.json({ error: 'not found' }, 404); - if (role === 'owner') return c.json({ error: 'owner cannot leave; delete the workspace or transfer ownership' }, 403); - db.prepare('DELETE FROM memberships WHERE org_id = ? AND user_id = ?').run(orgId, uid); - logAudit(orgId, uid, 'member.leave', 'user', uid, { role }); - return c.json({ ok: true }); -}); - -// Transfer workspace ownership to an existing member (owner only). The old -// owner becomes an admin; orgs.owner_id follows. Transactional. -app.post('/api/orgs/:id/transfer', requireAuth, async (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); - const { userId } = await c.req.json().catch(() => ({})); - if (!userId || Number(userId) === Number(uid)) return c.json({ error: 'target member userId required' }, 400); - const target = db.prepare('SELECT role FROM memberships WHERE org_id = ? AND user_id = ?').get(orgId, userId); - if (!target) return c.json({ error: 'target is not a member' }, 404); - db.exec('BEGIN'); - try { - db.prepare("UPDATE memberships SET role = 'owner' WHERE org_id = ? AND user_id = ?").run(orgId, userId); - db.prepare("UPDATE memberships SET role = 'admin' WHERE org_id = ? AND user_id = ?").run(orgId, uid); - db.prepare('UPDATE orgs SET owner_id = ? WHERE id = ?').run(userId, orgId); - db.exec('COMMIT'); - } catch (e) { db.exec('ROLLBACK'); throw e; } - logAudit(orgId, uid, 'org.transfer', 'user', userId, { from: uid }); - return c.json({ ok: true, newOwnerId: Number(userId) }); -}); - -// Rename a workspace (owner only). -app.patch('/api/orgs/:id', requireAuth, async (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); - const { name } = await c.req.json().catch(() => ({})); - if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); - db.prepare('UPDATE orgs SET name = ? WHERE id = ?').run(String(name).trim().slice(0, 120), orgId); - logAudit(orgId, uid, 'org.rename', 'org', orgId, { name }); - return c.json({ id: Number(orgId), name: String(name).trim() }); -}); - -// ------------------------------------------------------------------- billing -app.get('/api/orgs/:id/billing', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); - const org = getOrg(orgId); - const plan = planOf(org); - return c.json({ plan: org.plan, planName: plan.name, priceKrw: plan.priceKrw, limits: plan.limits, usage: orgUsage(db, orgId) }); -}); - -app.post('/api/orgs/:id/checkout', requireAuth, async (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'only the owner can upgrade' }, 403); - const origin = new URL(c.req.url).origin; - const session = await createCheckout({ orgId, origin }); - return c.json(session); -}); - -// Stripe webhook (stub). Live mode should verify the signature with -// STRIPE_WEBHOOK_SECRET before trusting the event — named ceiling. -app.post('/api/stripe/webhook', async (c) => { - const event = await c.req.json().catch(() => ({})); - if (event?.type === 'checkout.session.completed') { - const orgId = event.data?.object?.client_reference_id || event.data?.object?.metadata?.orgId; - if (orgId) db.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").run(orgId); - } - return c.json({ received: true }); -}); - -// Dev-only: simulate a successful checkout upgrading the org to Pro. -// Disabled unless SCOPEWEAVE_DEV=1 (never reachable in production). -app.post('/api/orgs/:id/_dev/activate-pro', requireAuth, (c) => { - if (process.env.SCOPEWEAVE_DEV !== '1') return c.json({ error: 'not found' }, 404); - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); - db.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").run(orgId); - logAudit(orgId, uid, 'billing.upgrade', 'org', orgId, { plan: 'pro', via: 'dev' }); - deliver(orgId, 'billing.upgrade', { plan: 'pro' }); - return c.json({ plan: 'pro' }); -}); - -// ------------------------------------------------- personal access tokens (PAT) -app.get('/api/tokens', requireAuth, (c) => { - const uid = c.get('user').sub; - const tokens = db.prepare( - 'SELECT id, name, prefix, last_used AS lastUsed, created_at AS createdAt FROM api_tokens WHERE user_id = ? ORDER BY id DESC' - ).all(uid); - return c.json({ tokens }); // never the secret or hash -}); - -app.post('/api/tokens', requireAuth, async (c) => { - const uid = c.get('user').sub; - const { name } = await c.req.json().catch(() => ({})); - const t = generateApiToken(); - const id = rowid(db.prepare('INSERT INTO api_tokens(user_id,name,token_hash,prefix) VALUES(?,?,?,?)') - .run(uid, String(name || 'token').slice(0, 60), t.hash, t.prefix)); - // Full secret returned ONCE — never retrievable again. - return c.json({ id, name: name || 'token', prefix: t.prefix, token: t.full }); -}); - -app.delete('/api/tokens/:id', requireAuth, (c) => { - const uid = c.get('user').sub; - const info = db.prepare('DELETE FROM api_tokens WHERE id = ? AND user_id = ?').run(c.req.param('id'), uid); - if (!info.changes) return c.json({ error: 'not found' }, 404); - return c.json({ ok: true }); -}); - -// Audit trail — owner/admin only. Enterprise requirement. -app.get('/api/orgs/:id/audit', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const limit = Math.min(Number(c.req.query('limit')) || 100, 500); - const rows = db.prepare( - `SELECT a.id, a.action, a.target_type AS targetType, a.target_id AS targetId, a.meta, - a.created_at AS createdAt, u.email AS actorEmail - FROM audit_log a LEFT JOIN users u ON u.id = a.user_id - WHERE a.org_id = ? ORDER BY a.id DESC LIMIT ?` - ).all(orgId, limit); - const events = rows.map((r) => ({ ...r, meta: r.meta ? JSON.parse(r.meta) : null })); - if (c.req.query('format') === 'csv') { - // Compliance deliverable. Formula-injection-safe: values that (after optional - // leading whitespace) start with = + - @ | are prefixed with ' so - // spreadsheets treat them as text. Leading whitespace alone used to bypass - // /^[=+\-@|]/ — match the client-side CSV_FORMULA_PREFIX_PATTERN. - const csvCell = (v) => { - let s = v == null ? '' : String(v); - if (/^\s*[=+\-@|]/.test(s)) s = `'${s}`; - return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; - }; - const header = ['id', 'createdAt', 'actorEmail', 'action', 'targetType', 'targetId', 'meta']; - const lines = [header.join(',')]; - for (const e of events) { - lines.push([e.id, e.createdAt, e.actorEmail, e.action, e.targetType, e.targetId, e.meta ? JSON.stringify(e.meta) : ''].map(csvCell).join(',')); - } - return c.text(lines.join('\r\n') + '\r\n', 200, { - 'content-type': 'text/csv; charset=utf-8', - 'content-disposition': `attachment; filename="scopeweave-audit-${orgId}.csv"`, - }); - } - return c.json({ events }); -}); - -// Full workspace export (owner only) — data portability / GDPR. Everything the -// org holds, as one JSON document. -app.get('/api/orgs/:id/export', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'only the owner can export' }, 403); - const org = getOrg(orgId); - const members = db.prepare( - `SELECT u.email, u.name, m.role FROM memberships m JOIN users u ON u.id = m.user_id WHERE m.org_id = ?` - ).all(orgId); - const projects = db.prepare( - 'SELECT id, name, base_date AS baseDate, tasks_json, version, created_at AS createdAt, updated_at AS updatedAt FROM projects WHERE org_id = ?' - ).all(orgId).map((p) => ({ ...p, tasks: JSON.parse(p.tasks_json), tasks_json: undefined })); - const audit = db.prepare( - 'SELECT action, target_type AS targetType, target_id AS targetId, meta, created_at AS createdAt FROM audit_log WHERE org_id = ? ORDER BY id' - ).all(orgId).map((a) => ({ ...a, meta: a.meta ? JSON.parse(a.meta) : null })); - logAudit(orgId, uid, 'org.export', 'org', orgId, { projects: projects.length }); - return c.json({ - exportedAt: new Date().toISOString(), - org: { id: org.id, name: org.name, plan: org.plan }, - members, projects, audit, - }, 200, { 'Content-Disposition': `attachment; filename="scopeweave-org-${orgId}.json"` }); -}); - -// Operational metrics (JSON). Ceiling: expose Prometheus text format + gate -// behind an internal token before prod if scraped externally. -app.get('/api/metrics', (c) => { - const sseActive = [...streams.values()].reduce((n, s) => n + s.size, 0); - const all = { ...metrics, sseActive, uptimeSec: Math.round(process.uptime()) }; - if (c.req.query('format') !== 'prometheus') return c.json(all); - // Prometheus text exposition format (0.0.4) — scrape-ready for Grafana/Alerting. - const gauge = new Set(['sseActive', 'uptimeSec']); - const lines = []; - for (const [k, v] of Object.entries(all)) { - if (typeof v !== 'number') continue; // startedAt etc. - const name = `scopeweave_${k.replace(/([A-Z])/g, '_$1').toLowerCase()}`; - lines.push(`# TYPE ${name} ${gauge.has(k) ? 'gauge' : 'counter'}`, `${name} ${v}`); - } - return c.text(lines.join('\n') + '\n', 200, { 'content-type': 'text/plain; version=0.0.4; charset=utf-8' }); -}); - -// ------------------------------------------------------------------- webhooks -app.get('/api/orgs/:id/webhooks', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const webhooks = db.prepare( - `SELECT w.id, w.url, w.events, w.active, w.created_at AS createdAt, - (SELECT ok FROM webhook_deliveries d WHERE d.webhook_id = w.id ORDER BY d.id DESC LIMIT 1) AS lastOk, - (SELECT created_at FROM webhook_deliveries d WHERE d.webhook_id = w.id ORDER BY d.id DESC LIMIT 1) AS lastAt - FROM webhooks w WHERE w.org_id = ? ORDER BY w.id DESC` - ).all(orgId); // secret never returned - return c.json({ webhooks }); -}); - -app.post('/api/orgs/:id/webhooks', requireAuth, async (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const { url, events } = await c.req.json().catch(() => ({})); - if (!/^https?:\/\//.test(String(url || ''))) return c.json({ error: 'valid http(s) url required' }, 400); - const secret = `whsec_${randomBytes(24).toString('base64url')}`; - const evs = Array.isArray(events) ? events.join(',') : (events || '*'); - const id = rowid(db.prepare('INSERT INTO webhooks(org_id,url,secret,events) VALUES(?,?,?,?)').run(orgId, url, secret, evs)); - logAudit(orgId, uid, 'webhook.create', 'webhook', id, { url, events: evs }); - return c.json({ id, url, events: evs, secret }); // secret shown once for signature verification -}); - -app.get('/api/orgs/:id/webhooks/:whId/deliveries', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const wh = db.prepare('SELECT id FROM webhooks WHERE id = ? AND org_id = ?').get(c.req.param('whId'), orgId); - if (!wh) return c.json({ error: 'not found' }, 404); - const deliveries = db.prepare( - 'SELECT event, status_code AS statusCode, ok, attempt, created_at AS createdAt FROM webhook_deliveries WHERE webhook_id = ? ORDER BY id DESC LIMIT 50' - ).all(wh.id); - return c.json({ deliveries }); -}); - -// Rotate a webhook's signing secret (leak response / periodic hygiene). The new -// secret is returned ONCE; old signatures stop validating immediately. -app.post('/api/orgs/:id/webhooks/:whId/rotate', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const secret = `whsec_${randomBytes(24).toString('base64url')}`; - const info = db.prepare('UPDATE webhooks SET secret = ? WHERE id = ? AND org_id = ?').run(secret, c.req.param('whId'), orgId); - if (!info.changes) return c.json({ error: 'not found' }, 404); - logAudit(orgId, uid, 'webhook.rotate', 'webhook', c.req.param('whId'), {}); - return c.json({ id: Number(c.req.param('whId')), secret }); // shown once -}); - -app.delete('/api/orgs/:id/webhooks/:whId', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const info = db.prepare('DELETE FROM webhooks WHERE id = ? AND org_id = ?').run(c.req.param('whId'), orgId); - if (!info.changes) return c.json({ error: 'not found' }, 404); - return c.json({ ok: true }); -}); - -// ------------------------------------------------------------ SSO (OIDC) -// Real IdP via env (OIDC_ISSUER/CLIENT_ID/CLIENT_SECRET/REDIRECT_URI). When -// unset, a built-in mock provider makes the whole flow self-contained + testable. -const OIDC = { - issuer: process.env.OIDC_ISSUER, - clientId: process.env.OIDC_CLIENT_ID, - clientSecret: process.env.OIDC_CLIENT_SECRET, - redirectUri: process.env.OIDC_REDIRECT_URI, -}; -const oidcMock = !OIDC.issuer; -const oidcStates = new Map(); // state -> { verifier, exp } -const oidcCodes = new Map(); // mock only: code -> email - -function upsertSsoUser(email) { - let user = db.prepare('SELECT id, email, token_version FROM users WHERE email = ?').get(email); - if (user) return user; - db.exec('BEGIN'); - try { - const uid = rowid(db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') - .run(email, hashPassword(randomBytes(24).toString('hex')), '')); - const oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)').run(`${email}'s workspace`, uid)); - db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); - db.exec('COMMIT'); - metrics.signups++; - return { id: uid, email }; - } catch (e) { db.exec('ROLLBACK'); throw e; } -} - -app.get('/api/auth/oidc/start', (c) => { - const origin = new URL(c.req.url).origin; - const state = randomBytes(16).toString('hex'); - const verifier = randomBytes(32).toString('base64url'); - const challenge = createHash('sha256').update(verifier).digest('base64url'); - oidcStates.set(state, { verifier, exp: Date.now() + 5 * 60 * 1000 }); - const redirectUri = OIDC.redirectUri || `${origin}/api/auth/oidc/callback`; - if (oidcMock) { - const email = c.req.query('email') || 'sso-user@example.com'; - const u = new URL(`${origin}/api/auth/oidc/mock/authorize`); - u.searchParams.set('state', state); - u.searchParams.set('email', email); - u.searchParams.set('redirect_uri', redirectUri); - return c.redirect(u.toString()); - } - const u = new URL(`${OIDC.issuer.replace(/\/$/, '')}/authorize`); - u.searchParams.set('client_id', OIDC.clientId); - u.searchParams.set('redirect_uri', redirectUri); - u.searchParams.set('response_type', 'code'); - u.searchParams.set('scope', 'openid email profile'); - u.searchParams.set('state', state); - u.searchParams.set('code_challenge', challenge); - u.searchParams.set('code_challenge_method', 'S256'); - return c.redirect(u.toString()); -}); - -// Built-in mock IdP authorize — instantly issues a code (dev/test only). -app.get('/api/auth/oidc/mock/authorize', (c) => { - if (!oidcMock) return c.json({ error: 'mock disabled' }, 404); - const state = c.req.query('state'); - const email = c.req.query('email'); - const redirectUri = c.req.query('redirect_uri'); - const code = randomBytes(16).toString('hex'); - oidcCodes.set(code, email); - const u = new URL(redirectUri); - u.searchParams.set('code', code); - u.searchParams.set('state', state); - return c.redirect(u.toString()); -}); - -app.get('/api/auth/oidc/callback', async (c) => { - const state = c.req.query('state'); - const code = c.req.query('code'); - const s = oidcStates.get(state); - if (!s || s.exp < Date.now()) return c.json({ error: 'invalid or expired state' }, 400); - oidcStates.delete(state); - let email; - if (oidcMock) { - email = oidcCodes.get(code); - oidcCodes.delete(code); - if (!email) return c.json({ error: 'invalid code' }, 400); - } else { - const redirectUri = OIDC.redirectUri || `${new URL(c.req.url).origin}/api/auth/oidc/callback`; - const tokenRes = await fetch(`${OIDC.issuer.replace(/\/$/, '')}/token`, { - method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: OIDC.clientId, client_secret: OIDC.clientSecret, code_verifier: s.verifier }), - }).catch(() => null); - const tok = tokenRes ? await tokenRes.json().catch(() => ({})) : {}; - if (!tok.id_token) return c.json({ error: 'token exchange failed' }, 400); - // Ceiling: verify the id_token signature via the issuer JWKS before prod. - const claims = JSON.parse(Buffer.from(String(tok.id_token).split('.')[1] || '', 'base64url').toString() || '{}'); - email = claims.email; - if (!email) return c.json({ error: 'no email claim' }, 400); - } - const user = upsertSsoUser(email); - const token = signToken({ sub: user.id, email, tv: user.token_version || 0 }); - // Return the token in the URL fragment (not query → not logged); the client - // stores it and cleans the URL. - return c.redirect(`/#token=${token}`); -}); - -// Cross-project search: project names + task names, membership-scoped (tenant -// isolation via the same JOIN as projectAccess). -// ponytail: LIKE over tasks_json text; move to FTS5 if search gets heavy. -app.get('/api/search', requireAuth, (c) => { - const uid = c.get('user').sub; - const q = String(c.req.query('q') || '').trim(); - if (q.length < 2) return c.json({ error: 'query too short (min 2)' }, 400); - const rows = db.prepare( - `SELECT DISTINCT p.id, p.name, p.tasks_json FROM projects p - JOIN memberships m ON m.org_id = p.org_id - WHERE m.user_id = ? AND (p.name LIKE ? OR p.tasks_json LIKE ?) LIMIT 100` - ).all(uid, `%${q}%`, `%${q}%`); - const needle = q.toLowerCase(); - const results = []; - for (const p of rows) { - const hit = { projectId: p.id, projectName: p.name, tasks: [] }; - if (p.name.toLowerCase().includes(needle)) hit.nameMatch = true; - let tasks = []; - try { tasks = JSON.parse(p.tasks_json); } catch { /* skip bad json */ } - for (const t of tasks) { - if (String(t.name || '').toLowerCase().includes(needle)) { - hit.tasks.push({ id: t.id, name: t.name }); - if (hit.tasks.length >= 5) break; - } - } - if (hit.nameMatch || hit.tasks.length) results.push(hit); - if (results.length >= 20) break; - } - return c.json({ query: q, results }); -}); - -// Portfolio dashboard: executive rollup across every project in a workspace — -// weighted planned/actual progress, SPI + status, overdue-task counts. -app.get('/api/orgs/:id/portfolio', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); - const today = new Date().toISOString().slice(0, 10); - const rows = db.prepare( - 'SELECT id, name, base_date AS baseDate, tasks_json, archived, updated_at AS updatedAt FROM projects WHERE org_id = ? ORDER BY archived ASC, updated_at DESC' - ).all(orgId); - const projects = rows.map((p) => { - let tasks = []; - try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } - let wSum = 0, pv = 0, ev = 0, overdue = 0; - for (const t of tasks) { - const w = Number(t.weight) || 1; - wSum += w; - pv += w * ((Number(t.plannedProgress) || 0) / 100); - ev += w * ((Number(t.actualProgress) || 0) / 100); - if (t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100) overdue++; - } - const evm = computeEvm({ pv: wSum ? pv / wSum : 0, ev: wSum ? ev / wSum : 0 }); - return { - id: p.id, - name: p.name, - archived: Boolean(p.archived), - tasks: tasks.length, - planned: Math.round(evm.pv * 1000) / 10, // % - actual: Math.round(evm.ev * 1000) / 10, // % - spi: evm.spi === null ? null : Math.round(evm.spi * 100) / 100, - status: evm.status, - label: evm.label, - overdue, - updatedAt: p.updatedAt, - }; - }); - return c.json({ projects }); -}); - -// AI 브리핑: 프로젝트 스냅샷(요약 지표 + 지연/차주 작업)을 contextual- -// orchestrator(LLM)로 보내 경영진용 리스크 분석을 생성. 원문 데이터는 서버가 -// 요약해 전송하며, LLM 자격은 서버 환경변수에만 존재. -app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - let tasks = []; - try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } - const today = new Date().toISOString().slice(0, 10); - let wSum = 0, pv = 0, ev = 0; - const late = [], upcoming = []; - for (const t of tasks) { - const w = Number(t.weight) || 1; - wSum += w; - pv += w * ((Number(t.plannedProgress) || 0) / 100); - ev += w * ((Number(t.actualProgress) || 0) / 100); - const name = t.name || t.task || t.activity || t.phase || t.id; - if (t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100) { - late.push(`${name}(계획종료 ${t.plannedEndDate}, 실적 ${Number(t.actualProgress) || 0}%${t.owner ? `, ${t.owner}` : ''})`); - } else if (t.plannedStartDate && t.plannedStartDate >= today) { - upcoming.push(`${name}(${t.plannedStartDate} 시작)`); - } - } - const pvPct = wSum ? ((pv / wSum) * 100).toFixed(1) : '0'; - const evPct = wSum ? ((ev / wSum) * 100).toFixed(1) : '0'; - const context = [ - `프로젝트: ${p.name}`, - `작업 수: ${tasks.length} · 계획진척 ${pvPct}% · 실적진척 ${evPct}%`, - `지연 작업(${late.length}): ${late.slice(0, 8).join(' / ') || '없음'}`, - `예정 작업(${upcoming.length}): ${upcoming.slice(0, 5).join(' / ') || '없음'}`, - ].join('\n'); - try { - const analysis = await orchestratorChat([ - { role: 'system', content: '너는 공정관리(schedule control) 전문가다. 주어진 프로젝트 지표를 근거로 한국어 경영진 브리핑을 작성하라: ①일정 상태 한 줄 판정 ②핵심 리스크 2~3개(근거 지표 인용) ③실행 권고 2~3개. 지표에 없는 사실은 만들지 마라.' }, - { role: 'user', content: context }, - ], { - service: 'scopeweave', - account: String(p.org_id), - }); - logAudit(p.org_id, uid, 'ai.brief', 'project', p.id, { tasks: tasks.length }); - return c.json({ analysis }); - } catch (e) { - return c.json({ error: `AI 분석 실패: ${e.message}` }, 502); - } -}); - -// 산출물 첨부(Clearfolio 통합 문서 뷰어 프록시): 업로드→변환 잡, 목록(+상태 -// 갱신), 서명 아티팩트 열람(302), 삭제. 테넌트 = 조직, 브라우저에는 Clearfolio -// 자격이 절대 노출되지 않음. -const ATTACH_MAX_BYTES = 10 * 1024 * 1024; - -const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY, -); -const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, -); -const ATTACH_STATUS_BUDGET_MS = normalizeAttachmentStatusBudgetMs( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS, -); -const ATTACHMENT_LIST_COLUMNS = `a.id, a.task_id AS taskId, a.name, a.mime, a.size, - a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy`; -const ATTACHMENT_LIST_FROM = - 'FROM attachments a LEFT JOIN users u ON u.id = a.created_by'; -const listAttachmentsStatement = db.prepare( - `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} - WHERE a.project_id = ? ORDER BY a.id DESC`, -); -const listTaskAttachmentsStatement = db.prepare( - `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} - WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`, -); -const updateAttachmentStatusStatement = db.prepare( - 'UPDATE attachments SET status = ? WHERE id = ?', -); -app.post('/api/projects/:id/attachments', requireAuth, async (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const form = await c.req.formData().catch(() => null); - const file = form?.get('file'); - if (!file || typeof file === 'string') return c.json({ error: 'multipart file required' }, 400); - const taskId = String(form.get('taskId') || ''); - if (/\.(hwp|hwpx)$/i.test(file.name || '')) return c.json({ error: 'HWP/HWPX는 지원되지 않습니다 (Clearfolio 정책)' }, 400); - if (file.size > ATTACH_MAX_BYTES) return c.json({ error: 'file too large (max 10MB)' }, 400); - const bytes = Buffer.from(await file.arrayBuffer()); - let job; - try { - job = await submitJob(p.org_id, uid, { name: file.name || 'document', mime: file.type || '', bytes }); - } catch (e) { - return c.json({ error: `문서 변환 제출 실패: ${e.message}` }, 502); - } - const aid = rowid(db.prepare( - 'INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)' - ).run(p.id, taskId, file.name || 'document', file.type || '', file.size, job.jobId, job.status, uid)); - logAudit(p.org_id, uid, 'attachment.upload', 'project', p.id, { attachmentId: aid, name: file.name, taskId: taskId || null }); - return c.json({ id: aid, status: job.status }); -}); - -app.get('/api/projects/:id/attachments', requireAuth, async (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - - const taskId = c.req.query('taskId'); - const rows = taskId - ? listTaskAttachmentsStatement.all(p.id, taskId) - : listAttachmentsStatement.all(p.id); - await refreshAttachmentStatuses(rows, { - orgId: p.org_id, - userId: uid, - jobStatus, - updateStatus: (status, attachmentId) => - updateAttachmentStatusStatement.run(status, attachmentId), - concurrency: ATTACH_STATUS_CONCURRENCY, - timeoutMs: ATTACH_STATUS_TIMEOUT_MS, - budgetMs: ATTACH_STATUS_BUDGET_MS, - metrics, - }); - const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); - return c.json({ attachments }); -}); - -// 열람: 서명 아티팩트 URL로 302. 새 탭 열기용으로 ?token=도 허용(ics/stream 패턴). -app.get('/api/projects/:id/attachments/:aid/view', (c) => { - const header = c.req.header('authorization') || ''; - const raw = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); - let uid; - if (raw.startsWith('swk_')) { - const row = db.prepare('SELECT user_id FROM api_tokens WHERE token_hash = ?').get(hashApiToken(raw)); - if (!row) return c.json({ error: 'unauthorized' }, 401); - uid = row.user_id; - } else { - try { - const payload = verifyToken(raw); - const u = db.prepare('SELECT token_version FROM users WHERE id = ?').get(payload.sub); - if (!u || (payload.tv || 0) !== u.token_version) return c.json({ error: 'unauthorized' }, 401); - uid = payload.sub; - } catch { return c.json({ error: 'unauthorized' }, 401); } - } - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const a = db.prepare('SELECT job_id, status FROM attachments WHERE id = ? AND project_id = ?').get(c.req.param('aid'), p.id); - if (!a) return c.json({ error: 'not found' }, 404); - if (a.status !== 'SUCCEEDED') return c.json({ error: `문서가 아직 준비되지 않았습니다 (${a.status})` }, 409); - return artifactUrl(p.org_id, uid, a.job_id) - .then((url) => c.redirect(url)) - .catch((e) => c.json({ error: `열람 링크 발급 실패: ${e.message}` }, 502)); -}); - -app.delete('/api/projects/:id/attachments/:aid', requireAuth, (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const a = db.prepare('SELECT created_by FROM attachments WHERE id = ? AND project_id = ?').get(c.req.param('aid'), p.id); - if (!a) return c.json({ error: 'not found' }, 404); - if (a.created_by !== uid && !canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - db.prepare('DELETE FROM attachments WHERE id = ?').run(c.req.param('aid')); - logAudit(p.org_id, uid, 'attachment.delete', 'project', p.id, { attachmentId: Number(c.req.param('aid')) }); - return c.json({ ok: true }); -}); - -// mock Clearfolio 아티팩트 서빙(dev/test 전용) -if (clearfolioMock) { - app.get('/api/mock-clearfolio/:jobId', (c) => { - const doc = mockArtifact(c.req.param('jobId')); - if (!doc) return c.json({ error: 'not found' }, 404); - return c.body(doc.bytes, 200, { - 'content-type': doc.mime || 'application/octet-stream', - 'content-disposition': `inline; filename="${encodeURIComponent(doc.name)}"`, - }); - }); -} - -// Public read-only share links: a random token grants VIEW access to one -// project (no account needed) — revocable. Never exposes org/member data. -app.post('/api/projects/:id/shares', requireAuth, (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const token = randomBytes(18).toString('base64url'); - db.prepare('INSERT INTO share_tokens(project_id, token, created_by) VALUES(?,?,?)').run(p.id, token, uid); - logAudit(p.org_id, uid, 'share.create', 'project', p.id, {}); - return c.json({ token, url: `/?share=${token}` }); -}); - -app.get('/api/projects/:id/shares', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const shares = db.prepare( - 'SELECT id, token, created_at AS createdAt FROM share_tokens WHERE project_id = ? AND revoked = 0 ORDER BY id DESC' - ).all(p.id); - return c.json({ shares }); -}); - -app.delete('/api/projects/:id/shares/:sid', requireAuth, (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const info = db.prepare('UPDATE share_tokens SET revoked = 1 WHERE id = ? AND project_id = ? AND revoked = 0') - .run(c.req.param('sid'), p.id); - if (!info.changes) return c.json({ error: 'not found' }, 404); - logAudit(p.org_id, uid, 'share.revoke', 'project', p.id, { shareId: Number(c.req.param('sid')) }); - return c.json({ ok: true }); -}); - -// Anonymous read via share token — project content only. -app.get('/api/shared/:token', (c) => { - const row = db.prepare( - `SELECT p.name, p.base_date AS baseDate, p.tasks_json FROM share_tokens s - JOIN projects p ON p.id = s.project_id WHERE s.token = ? AND s.revoked = 0` - ).get(c.req.param('token')); - if (!row) return c.json({ error: 'not found' }, 404); - return c.json({ name: row.name, baseDate: row.baseDate, tasks: JSON.parse(row.tasks_json), readOnly: true }); -}); - -// Unseen-activity notifications: per project, count others' saves + comments -// newer than my last-seen mark. Opening a project marks it seen. -app.get('/api/notifications', requireAuth, (c) => { - const uid = c.get('user').sub; - const rows = db.prepare( - `SELECT p.id AS projectId, - (SELECT COUNT(*) FROM project_revisions r WHERE r.project_id = p.id - AND r.saved_by IS NOT NULL AND r.saved_by != ? - AND r.created_at > COALESCE(s.seen_at, '')) AS revisions, - (SELECT COUNT(*) FROM comments cm WHERE cm.project_id = p.id - AND cm.user_id IS NOT NULL AND cm.user_id != ? - AND cm.created_at > COALESCE(s.seen_at, '')) AS comments - FROM projects p - JOIN memberships m ON m.org_id = p.org_id AND m.user_id = ? - LEFT JOIN project_seen s ON s.project_id = p.id AND s.user_id = ?` - ).all(uid, uid, uid, uid); - const notifications = rows - .map((r) => ({ projectId: r.projectId, unseen: r.revisions + r.comments })) - .filter((r) => r.unseen > 0); - return c.json({ notifications }); -}); - -app.post('/api/projects/:id/seen', requireAuth, (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - db.prepare(`INSERT INTO project_seen(project_id, user_id, seen_at) VALUES(?, ?, datetime('now')) - ON CONFLICT(project_id, user_id) DO UPDATE SET seen_at = datetime('now')`).run(p.id, uid); - return c.json({ ok: true }); -}); - -// Archive / restore a project (write roles): declutter without deleting. -app.post('/api/projects/:id/archive', requireAuth, async (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const { archived } = await c.req.json().catch(() => ({})); - const flag = archived === false ? 0 : 1; - db.prepare('UPDATE projects SET archived = ? WHERE id = ?').run(flag, p.id); - logAudit(p.org_id, uid, flag ? 'project.archive' : 'project.unarchive', 'project', p.id, {}); - return c.json({ id: p.id, archived: Boolean(flag) }); -}); - -// Duplicate a project (template use: copy tasks + base date into a new project -// in the same org). Plan caps apply like any create. -app.post('/api/projects/:id/duplicate', requireAuth, async (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - if (wouldExceed(db, getOrg(p.org_id), 'projects')) { - return c.json({ error: 'project limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.projects }, 402); - } - const { name } = await c.req.json().catch(() => ({})); - const newName = String(name || `${p.name} (복사본)`).slice(0, 120); - const nid = rowid(db.prepare('INSERT INTO projects(org_id,name,base_date,tasks_json,created_by) VALUES(?,?,?,?,?)') - .run(p.org_id, newName, p.base_date, p.tasks_json, uid)); - metrics.projectsCreated++; - logAudit(p.org_id, uid, 'project.duplicate', 'project', nid, { from: p.id, name: newName }); - return c.json({ id: nid, name: newName, version: 1 }); -}); - -// -------------------------------------------------------------- sprints -// Agile/Hybrid: 시간상자(스프린트) CRUD. 작업은 task.sprint(이름)로 배정되고 -// task.storyPoints로 추정된다 — 지표(커밋/완료 포인트, 벨로시티)는 클라이언트 -// 순수 함수(computeSprintStats)가 계산한다. -app.post('/api/projects/:id/sprints', requireAuth, async (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const { name, startDate, endDate, goal } = await c.req.json().catch(() => ({})); - if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); - const day = (v) => (/^\d{4}-\d{2}-\d{2}$/.test(String(v || '')) ? v : ''); - const sid = rowid(db.prepare('INSERT INTO sprints(project_id,name,start_date,end_date,goal) VALUES(?,?,?,?,?)') - .run(p.id, String(name).trim().slice(0, 80), day(startDate), day(endDate), String(goal || '').slice(0, 300))); - logAudit(p.org_id, uid, 'sprint.create', 'project', p.id, { sprintId: sid, name }); - return c.json({ id: sid, name: String(name).trim() }); -}); - -app.get('/api/projects/:id/sprints', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const sprints = db.prepare( - 'SELECT id, name, start_date AS startDate, end_date AS endDate, goal FROM sprints WHERE project_id = ? ORDER BY start_date, id' - ).all(p.id); - return c.json({ sprints, methodology: p.methodology || 'waterfall' }); -}); - -app.delete('/api/projects/:id/sprints/:sid', requireAuth, (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const info = db.prepare('DELETE FROM sprints WHERE id = ? AND project_id = ?').run(c.req.param('sid'), p.id); - if (!info.changes) return c.json({ error: 'not found' }, 404); - return c.json({ ok: true }); -}); - -// ------------------------------------------------------------- baselines -// Snapshot a project's current plan as a named baseline (schedule-control: -// compare actuals against the frozen plan later). -app.post('/api/projects/:id/baselines', requireAuth, async (c) => { - const uid = c.get('user').sub; - const id = c.req.param('id'); - const p = projectAccess(uid, id); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const { name } = await c.req.json().catch(() => ({})); - const bid = rowid(db.prepare('INSERT INTO baselines(project_id,name,base_date,tasks_json,created_by) VALUES(?,?,?,?,?)') - .run(id, String(name || 'Baseline').slice(0, 80), p.base_date, p.tasks_json, uid)); - logAudit(p.org_id, uid, 'baseline.create', 'project', id, { baselineId: bid, name }); - return c.json({ id: bid, name: name || 'Baseline' }); -}); - -app.get('/api/projects/:id/baselines', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const baselines = db.prepare( - 'SELECT id, name, base_date AS baseDate, created_at AS createdAt FROM baselines WHERE project_id = ? ORDER BY id DESC' - ).all(p.id); - return c.json({ baselines }); -}); - -app.get('/api/projects/:id/baselines/:bid', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const b = db.prepare('SELECT id, name, base_date AS baseDate, tasks_json, created_at AS createdAt FROM baselines WHERE id = ? AND project_id = ?').get(c.req.param('bid'), p.id); - if (!b) return c.json({ error: 'not found' }, 404); - return c.json({ id: b.id, name: b.name, baseDate: b.baseDate, tasks: JSON.parse(b.tasks_json), createdAt: b.createdAt }); -}); - -app.delete('/api/projects/:id/baselines/:bid', requireAuth, (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const info = db.prepare('DELETE FROM baselines WHERE id = ? AND project_id = ?').run(c.req.param('bid'), p.id); - if (!info.changes) return c.json({ error: 'not found' }, 404); - return c.json({ ok: true }); -}); - -// ------------------------------------------------------ account & lifecycle -// Delete a project (write roles). tasks live in the row, so this fully removes it. -app.delete('/api/projects/:id', requireAuth, (c) => { - const uid = c.get('user').sub; - const id = c.req.param('id'); - const p = projectAccess(uid, id); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - db.prepare('DELETE FROM projects WHERE id = ?').run(id); - logAudit(p.org_id, uid, 'project.delete', 'project', id, { name: p.name }); - deliver(p.org_id, 'project.delete', { projectId: Number(id) }); - return c.json({ ok: true }); -}); - -// Log out everywhere: bump token_version → every existing JWT dies. Returns a -// fresh token so THIS device stays signed in. PATs are unaffected. -app.post('/api/auth/logout-all', requireAuth, (c) => { - const uid = c.get('user').sub; - db.prepare('UPDATE users SET token_version = token_version + 1 WHERE id = ?').run(uid); - const u = db.prepare('SELECT email, token_version FROM users WHERE id = ?').get(uid); - return c.json({ ok: true, token: signToken({ sub: uid, email: u.email, tv: u.token_version }) }); -}); - -// Change password (verifies the current one). -app.post('/api/auth/change-password', requireAuth, async (c) => { - const uid = c.get('user').sub; - const { oldPassword, newPassword } = await c.req.json().catch(() => ({})); - if (typeof newPassword !== 'string' || newPassword.length < 8) return c.json({ error: 'new password (min 8) required' }, 400); - const u = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(uid); - if (!u || typeof oldPassword !== 'string' || !verifyPassword(oldPassword, u.password_hash)) { - return c.json({ error: 'current password incorrect' }, 403); - } - db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(newPassword), uid); - return c.json({ ok: true }); -}); - -// Delete account (GDPR). Removes owned workspaces (cascading their data) and the -// user. Requires the current password to confirm. -app.delete('/api/account', requireAuth, async (c) => { - const uid = c.get('user').sub; - const { password } = await c.req.json().catch(() => ({})); - const u = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(uid); - if (!u || typeof password !== 'string' || !verifyPassword(password, u.password_hash)) { - return c.json({ error: 'password required to delete account' }, 403); - } - db.exec('BEGIN'); - try { - db.prepare('DELETE FROM orgs WHERE owner_id = ?').run(uid); // cascades projects/members/webhooks/invites/audit - db.prepare('DELETE FROM users WHERE id = ?').run(uid); // cascades memberships/tokens - db.exec('COMMIT'); - } catch (e) { db.exec('ROLLBACK'); throw e; } - return c.json({ ok: true }); -}); - -app.get('/api/health', (c) => c.json({ ok: true })); - -// Static client — strict allowlist so server/, data.db, package.json etc. are -// never served. Anything not listed → 404. -const STATIC = { - '/': ['index.html', 'text/html; charset=utf-8'], - '/index.html': ['index.html', 'text/html; charset=utf-8'], - '/404.html': ['404.html', 'text/html; charset=utf-8'], - '/landing.html': ['landing.html', 'text/html; charset=utf-8'], - '/landing.en.html': ['landing.en.html', 'text/html; charset=utf-8'], - '/docs/api.md': ['docs/api.md', 'text/markdown; charset=utf-8'], - '/robots.txt': ['robots.txt', 'text/plain; charset=utf-8'], - '/sitemap.xml': ['sitemap.xml', 'application/xml; charset=utf-8'], - '/pricing': ['landing.html', 'text/html; charset=utf-8'], - '/app.js': ['app.js', 'text/javascript; charset=utf-8'], - '/cloud-sync.js': ['cloud-sync.js', 'text/javascript; charset=utf-8'], - '/analytics.js': ['analytics.js', 'text/javascript; charset=utf-8'], - '/styles.css': ['styles.css', 'text/css; charset=utf-8'], - '/toast-state.css': ['toast-state.css', 'text/css; charset=utf-8'], - '/wbs.json': ['wbs.json', 'application/json; charset=utf-8'], -}; -app.get('*', async (c) => { - const entry = STATIC[c.req.path]; - if (!entry) return c.notFound(); - try { - const buf = await readFile(new URL(`../${entry[0]}`, import.meta.url)); - return c.body(buf, 200, { 'Content-Type': entry[1] }); - } catch { - return c.notFound(); - } -}); +app.route('/', routeApp); diff --git a/server/app_routes.mjs b/server/app_routes.mjs new file mode 100644 index 00000000..c432a84f --- /dev/null +++ b/server/app_routes.mjs @@ -0,0 +1,1410 @@ +// ScopeWeave SaaS API. Multi-tenant (org-scoped), optimistic concurrency on +// project docs, SSE realtime fan-out per project. The existing static client +// (index.html/app.js) becomes the frontend that talks to these routes. +import { Hono } from 'hono'; +import { readFile } from 'node:fs/promises'; +import { randomBytes, createHmac, createHash } from 'node:crypto'; +import { db, rowid } from './db.mjs'; +import { hashPassword, verifyPassword, signToken, verifyToken, generateApiToken, hashApiToken } from './auth.mjs'; +import { PLANS, planOf, orgUsage, wouldExceed, createCheckout } from './billing.mjs'; +import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs'; +import { normalizeAttachmentStatusBudgetMs, normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs'; +import { chat as orchestratorChat } from './orchestrator.mjs'; +import { computeEvm } from '../analytics.js'; // pure math, shared with the client + +const getOrg = (id) => db.prepare('SELECT * FROM orgs WHERE id = ?').get(id); + +// Append-only audit trail. Never throws into the request path. +function logAudit(orgId, userId, action, targetType, targetId, meta) { + try { + db.prepare('INSERT INTO audit_log(org_id,user_id,action,target_type,target_id,meta) VALUES(?,?,?,?,?,?)') + .run(orgId, userId ?? null, action, targetType ?? null, targetId != null ? String(targetId) : null, meta ? JSON.stringify(meta) : null); + } catch { /* audit must not break the operation */ } +} + +// --- RBAC. Roles (highest→lowest): owner > admin > member > viewer. +const orgRole = (userId, orgId) => + db.prepare('SELECT role FROM memberships WHERE user_id = ? AND org_id = ?').get(userId, orgId)?.role || null; +const canManage = (role) => role === 'owner' || role === 'admin'; +const canWrite = (role) => role === 'owner' || role === 'admin' || role === 'member'; + +export const app = new Hono(); + +async function requireAuth(c, next) { + const header = c.req.header('authorization') || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : ''; + // Personal Access Token path (swk_...): look up by hash, act as its user. + if (token.startsWith('swk_')) { + const row = db.prepare('SELECT * FROM api_tokens WHERE token_hash = ?').get(hashApiToken(token)); + if (!row) return c.json({ error: 'unauthorized' }, 401); + db.prepare("UPDATE api_tokens SET last_used = datetime('now') WHERE id = ?").run(row.id); + c.set('user', { sub: row.user_id, viaPat: true }); + return next(); + } + try { + const payload = verifyToken(token); + // Session revocation: a bumped token_version invalidates all older JWTs. + const u = db.prepare('SELECT token_version FROM users WHERE id = ?').get(payload.sub); + if (!u || (payload.tv || 0) !== u.token_version) return c.json({ error: 'unauthorized' }, 401); + c.set('user', payload); + } catch { + return c.json({ error: 'unauthorized' }, 401); + } + await next(); +} + +// --- realtime: projectId -> Set +const streams = new Map(); +function broadcast(projectId, data) { + const subs = streams.get(String(projectId)); + if (!subs) return; + const chunk = new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`); + for (const ctrl of subs) { + try { ctrl.enqueue(chunk); } catch { /* dropped subscriber */ } + } +} + +// Membership-scoped project fetch — the tenant isolation boundary. +function projectAccess(userId, projectId) { + return db.prepare( + `SELECT p.*, m.role AS memberRole FROM projects p + JOIN memberships m ON m.org_id = p.org_id + WHERE p.id = ? AND m.user_id = ?` + ).get(projectId, userId); +} + +// --- observability: in-process counters + structured request log. +const metrics = { + startedAt: new Date().toISOString(), + requests: 0, + s2xx: 0, + s4xx: 0, + s5xx: 0, + signups: 0, + projectsCreated: 0, + webhookDeliveries: 0, + attachmentStatusRefreshAttempted: 0, + attachmentStatusRefreshChanged: 0, + attachmentStatusRefreshFailed: 0, + attachmentStatusRefreshDeferred: 0, +}; + +// Outbound webhooks: POST signed JSON to each active hook subscribed to `event`. +// Fire-and-forget with a timeout, one retry on failure, and a recorded outcome +// per attempt — never blocks or fails the triggering request. +function recordDelivery(webhookId, event, status, ok, attempt) { + try { + db.prepare('INSERT INTO webhook_deliveries(webhook_id,event,status_code,ok,attempt) VALUES(?,?,?,?,?)') + .run(webhookId, event, status ?? null, ok ? 1 : 0, attempt); + } catch { /* recording must not break delivery */ } +} + +function sendWebhook(webhookId, url, sig, event, body, attempt) { + metrics.webhookDeliveries++; + const ctrl = new AbortController(); + const to = setTimeout(() => ctrl.abort(), 3000); + fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-scopeweave-event': event, 'x-scopeweave-signature': `sha256=${sig}` }, + body, + signal: ctrl.signal, + }).then((res) => { + recordDelivery(webhookId, event, res.status, res.ok, attempt); + if (!res.ok && attempt < 2) setTimeout(() => sendWebhook(webhookId, url, sig, event, body, attempt + 1), 500); + }).catch(() => { + recordDelivery(webhookId, event, null, false, attempt); + if (attempt < 2) setTimeout(() => sendWebhook(webhookId, url, sig, event, body, attempt + 1), 500); + }).finally(() => clearTimeout(to)); +} + +function deliver(orgId, event, payload) { + let hooks; + try { + hooks = db.prepare('SELECT id, url, secret, events FROM webhooks WHERE org_id = ? AND active = 1').all(orgId); + } catch { return; } + for (const h of hooks) { + const subs = String(h.events || '').split(',').map((s) => s.trim()); + if (!(subs.includes('*') || subs.includes(event))) continue; + const body = JSON.stringify({ event, orgId: Number(orgId), payload, ts: new Date().toISOString() }); + const sig = createHmac('sha256', h.secret).update(body).digest('hex'); + sendWebhook(h.id, h.url, sig, event, body, 1); + } +} +const quietLogs = String(process.env.SCOPEWEAVE_DB || '').includes(':memory:'); // silence during tests +app.use('*', async (c, next) => { + const t = Date.now(); + await next(); + try { + metrics.requests++; + const s = c.res.status; + if (s >= 500) metrics.s5xx++; else if (s >= 400) metrics.s4xx++; else if (s >= 200) metrics.s2xx++; + if (!quietLogs) { + // structured; never logs bodies, tokens, or secrets + console.log(JSON.stringify({ ts: new Date().toISOString(), method: c.req.method, path: c.req.path, status: s, ms: Date.now() - t })); + } + } catch { /* metrics/logging must never break a request */ } +}); + +// Rate limiting (opt-in via SCOPEWEAVE_RATE_LIMIT_MAX, per client IP, fixed +// window). Protects against brute-force/abuse. Off by default so it never +// surprises tests/dev. Ceiling: per-instance in-memory → use Redis for multi-node. +const RL_MAX = Number(process.env.SCOPEWEAVE_RATE_LIMIT_MAX) || 0; +const RL_WINDOW_MS = Number(process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS) || 60000; +const rlBuckets = new Map(); +if (RL_MAX > 0) { + app.use('*', async (c, next) => { + const key = (c.req.header('x-forwarded-for') || '').split(',')[0].trim() || 'local'; + const now = Date.now(); + let b = rlBuckets.get(key); + if (!b || b.resetAt <= now) { b = { count: 0, resetAt: now + RL_WINDOW_MS }; rlBuckets.set(key, b); } + b.count++; + if (b.count > RL_MAX) { + const retry = Math.ceil((b.resetAt - now) / 1000); + return c.json({ error: 'rate limit exceeded' }, 429, { 'Retry-After': String(retry) }); + } + await next(); + }); +} + +app.post('/api/auth/signup', async (c) => { + const { email, password, name } = await c.req.json().catch(() => ({})); + if (!email || typeof password !== 'string' || password.length < 8) { + return c.json({ error: 'email and password (min 8 chars) required' }, 400); + } + if (db.prepare('SELECT id FROM users WHERE email = ?').get(email)) { + return c.json({ error: 'email already registered' }, 409); + } + // user + personal workspace + owner membership, atomically. + let uid; + const tx = () => { + uid = rowid(db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') + .run(email, hashPassword(password), name || '')); + const oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)') + .run(`${name || email}'s workspace`, uid)); + db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); + }; + db.exec('BEGIN'); + try { tx(); db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw e; } + metrics.signups++; + return c.json({ token: signToken({ sub: uid, email, tv: 0 }) }); +}); + +app.post('/api/auth/login', async (c) => { + const { email, password } = await c.req.json().catch(() => ({})); + const u = db.prepare('SELECT * FROM users WHERE email = ?').get(email || ''); + // Pass password through only when it is a string — verifyPassword rejects + // non-strings (objects/arrays) so they never match an empty-password hash. + if (!u || typeof password !== 'string' || !verifyPassword(password, u.password_hash)) { + return c.json({ error: 'invalid credentials' }, 401); + } + return c.json({ token: signToken({ sub: u.id, email: u.email, tv: u.token_version }) }); +}); + +app.get('/api/me', requireAuth, (c) => { + const uid = c.get('user').sub; + const user = db.prepare('SELECT id,email,name FROM users WHERE id = ?').get(uid); + const orgs = db.prepare( + `SELECT o.id,o.name,o.plan,m.role FROM orgs o + JOIN memberships m ON m.org_id = o.id WHERE m.user_id = ?` + ).all(uid); + return c.json({ user, orgs }); +}); + +// Create an additional workspace (org); the creator becomes its owner. +app.post('/api/orgs', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { name } = await c.req.json().catch(() => ({})); + if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); + let oid; + db.exec('BEGIN'); + try { + oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)').run(String(name).trim().slice(0, 120), uid)); + db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); + db.exec('COMMIT'); + } catch (e) { db.exec('ROLLBACK'); throw e; } + logAudit(oid, uid, 'org.create', 'org', oid, { name }); + return c.json({ id: oid, name: String(name).trim(), role: 'owner' }); +}); + +app.get('/api/projects', requireAuth, (c) => { + const uid = c.get('user').sub; + const projects = db.prepare( + `SELECT p.id,p.name,p.base_date AS baseDate,p.version,p.org_id AS orgId,p.updated_at AS updatedAt,p.archived + FROM projects p JOIN memberships m ON m.org_id = p.org_id + WHERE m.user_id = ? ORDER BY p.archived ASC, p.updated_at DESC` + ).all(uid); + return c.json({ projects }); +}); + +app.post('/api/projects', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { name, orgId } = await c.req.json().catch(() => ({})); + if (!name) return c.json({ error: 'name required' }, 400); + const org = orgId + ? db.prepare('SELECT o.id FROM orgs o JOIN memberships m ON m.org_id = o.id WHERE o.id = ? AND m.user_id = ?').get(orgId, uid) + : db.prepare('SELECT o.id FROM orgs o JOIN memberships m ON m.org_id = o.id WHERE m.user_id = ? ORDER BY o.id LIMIT 1').get(uid); + if (!org) return c.json({ error: 'no accessible org' }, 400); + if (wouldExceed(db, getOrg(org.id), 'projects')) { + return c.json({ error: 'project limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.projects }, 402); + } + const id = rowid(db.prepare('INSERT INTO projects(org_id,name,created_by) VALUES(?,?,?)').run(org.id, name, uid)); + metrics.projectsCreated++; + logAudit(org.id, uid, 'project.create', 'project', id, { name }); + return c.json({ id, name, version: 1 }); +}); + +app.get('/api/projects/:id', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + return c.json({ id: p.id, name: p.name, orgId: p.org_id, baseDate: p.base_date, methodology: p.methodology || 'waterfall', tasks: JSON.parse(p.tasks_json), version: p.version }); +}); + +app.put('/api/projects/:id', requireAuth, async (c) => { + const uid = c.get('user').sub; + const id = c.req.param('id'); + const p = projectAccess(uid, id); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden: viewer role is read-only' }, 403); + const body = await c.req.json().catch(() => ({})); + if (typeof body.version === 'number' && body.version !== p.version) { + return c.json({ error: 'version conflict', current: p.version }, 409); + } + const tasks = Array.isArray(body.tasks) ? body.tasks : JSON.parse(p.tasks_json); + const version = p.version + 1; + const methodology = ['waterfall', 'agile', 'hybrid'].includes(body.methodology) ? body.methodology : (p.methodology || 'waterfall'); + db.prepare( + "UPDATE projects SET name=?, base_date=?, tasks_json=?, version=?, methodology=?, updated_at=datetime('now') WHERE id=?" + ).run(body.name ?? p.name, body.baseDate ?? p.base_date, JSON.stringify(tasks), version, methodology, id); + logAudit(p.org_id, uid, 'project.update', 'project', id, { version, tasks: tasks.length }); + // Revision history: snapshot every save, keep the last 20 per project. + try { + db.prepare('INSERT OR REPLACE INTO project_revisions(project_id,version,name,base_date,tasks_json,saved_by) VALUES(?,?,?,?,?,?)') + .run(id, version, body.name ?? p.name, body.baseDate ?? p.base_date, JSON.stringify(tasks), uid); + db.prepare('DELETE FROM project_revisions WHERE project_id = ? AND version <= ?').run(id, version - 20); + } catch { /* history must not break saves */ } + deliver(p.org_id, 'project.update', { projectId: Number(id), version, tasks: tasks.length, by: uid }); + broadcast(id, { type: 'update', version, by: uid }); + return c.json({ version }); +}); + +// Task comments: discussion bound to a project (optionally a task). All roles +// can read; write roles can post; author or manage can delete. +app.get('/api/projects/:id/comments', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const taskId = c.req.query('taskId'); + const comments = (taskId + ? db.prepare(`SELECT cm.id, cm.task_id AS taskId, cm.body, cm.created_at AS createdAt, cm.user_id AS userId, u.email + FROM comments cm LEFT JOIN users u ON u.id = cm.user_id + WHERE cm.project_id = ? AND cm.task_id = ? ORDER BY cm.id DESC LIMIT 100`).all(p.id, taskId) + : db.prepare(`SELECT cm.id, cm.task_id AS taskId, cm.body, cm.created_at AS createdAt, cm.user_id AS userId, u.email + FROM comments cm LEFT JOIN users u ON u.id = cm.user_id + WHERE cm.project_id = ? ORDER BY cm.id DESC LIMIT 100`).all(p.id)); + return c.json({ comments }); +}); + +app.post('/api/projects/:id/comments', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const { taskId, body } = await c.req.json().catch(() => ({})); + const text = String(body || '').trim(); + if (!text) return c.json({ error: 'body required' }, 400); + if (text.length > 2000) return c.json({ error: 'comment too long (max 2000)' }, 400); + const cid = rowid(db.prepare('INSERT INTO comments(project_id,task_id,user_id,body) VALUES(?,?,?,?)') + .run(p.id, String(taskId || ''), uid, text)); + logAudit(p.org_id, uid, 'comment.create', 'project', p.id, { commentId: cid, taskId: taskId || null }); + broadcast(p.id, { type: 'comment', commentId: cid, by: uid }); + return c.json({ id: cid }); +}); + +app.delete('/api/projects/:id/comments/:cid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const cm = db.prepare('SELECT user_id FROM comments WHERE id = ? AND project_id = ?').get(c.req.param('cid'), p.id); + if (!cm) return c.json({ error: 'not found' }, 404); + if (cm.user_id !== uid && !canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + db.prepare('DELETE FROM comments WHERE id = ?').run(c.req.param('cid')); + return c.json({ ok: true }); +}); + +// Revision history: list, inspect, restore. +app.get('/api/projects/:id/revisions', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const revisions = db.prepare( + `SELECT r.version, r.created_at AS savedAt, u.email AS savedBy FROM project_revisions r + LEFT JOIN users u ON u.id = r.saved_by WHERE r.project_id = ? ORDER BY r.version DESC` + ).all(p.id); + return c.json({ revisions }); +}); + +app.get('/api/projects/:id/revisions/:version', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const r = db.prepare('SELECT version, name, base_date AS baseDate, tasks_json FROM project_revisions WHERE project_id = ? AND version = ?') + .get(p.id, c.req.param('version')); + if (!r) return c.json({ error: 'not found' }, 404); + return c.json({ version: r.version, name: r.name, baseDate: r.baseDate, tasks: JSON.parse(r.tasks_json) }); +}); + +// Restore = write the old snapshot as a NEW version (history stays linear). +app.post('/api/projects/:id/revisions/:version/restore', requireAuth, (c) => { + const uid = c.get('user').sub; + const id = c.req.param('id'); + const p = projectAccess(uid, id); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const r = db.prepare('SELECT name, base_date, tasks_json FROM project_revisions WHERE project_id = ? AND version = ?') + .get(id, c.req.param('version')); + if (!r) return c.json({ error: 'not found' }, 404); + const version = p.version + 1; + db.prepare("UPDATE projects SET name=?, base_date=?, tasks_json=?, version=?, updated_at=datetime('now') WHERE id=?") + .run(r.name, r.base_date, r.tasks_json, version, id); + try { + db.prepare('INSERT OR REPLACE INTO project_revisions(project_id,version,name,base_date,tasks_json,saved_by) VALUES(?,?,?,?,?,?)') + .run(id, version, r.name, r.base_date, r.tasks_json, uid); + } catch { /* history must not break restore */ } + logAudit(p.org_id, uid, 'project.restore', 'project', id, { from: Number(c.req.param('version')), version }); + broadcast(id, { type: 'update', version, by: uid }); + return c.json({ version }); +}); + +// iCalendar feed: planned tasks as all-day VEVENTs — subscribable from +// Google/Outlook. Calendar apps can't send headers, so accept ?token= (same +// pattern + ceiling as /stream). PATs work via the Authorization header. +app.get('/api/projects/:id/calendar.ics', (c) => { + const header = c.req.header('authorization') || ''; + const raw = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); + let uid; + if (raw.startsWith('swk_')) { + const row = db.prepare('SELECT user_id FROM api_tokens WHERE token_hash = ?').get(hashApiToken(raw)); + if (!row) return c.json({ error: 'unauthorized' }, 401); + uid = row.user_id; + } else { + try { uid = verifyToken(raw).sub; } catch { return c.json({ error: 'unauthorized' }, 401); } + } + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + let tasks = []; + try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } + const day = (s) => String(s).replaceAll('-', ''); + const nextDay = (s) => { const d = new Date(s); d.setDate(d.getDate() + 1); return d.toISOString().slice(0, 10).replaceAll('-', ''); }; + const esc = (s) => String(s).replace(/\\/g, '\\\\').replace(/[,;]/g, (m) => `\\${m}`).replace(/\n/g, '\\n'); + const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//ScopeWeave//KO', 'CALSCALE:GREGORIAN', `X-WR-CALNAME:${esc(p.name)}`]; + for (const t of tasks) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(t.plannedStartDate || '') || !/^\d{4}-\d{2}-\d{2}$/.test(t.plannedEndDate || '')) continue; + lines.push( + 'BEGIN:VEVENT', + `UID:scopeweave-${p.id}-${esc(t.id)}`, + `DTSTART;VALUE=DATE:${day(t.plannedStartDate)}`, + `DTEND;VALUE=DATE:${nextDay(t.plannedEndDate)}`, // DTEND is exclusive + `SUMMARY:${esc(t.name || t.task || t.id)}`, + 'END:VEVENT' + ); + } + lines.push('END:VCALENDAR'); + return c.text(lines.join('\r\n') + '\r\n', 200, { + 'content-type': 'text/calendar; charset=utf-8', + 'content-disposition': `attachment; filename="scopeweave-${p.id}.ics"`, + }); +}); + +app.get('/api/projects/:id/stream', (c) => { + // EventSource can't send an Authorization header, so accept a query token + // here only. Ceiling: issue a short-lived stream-scoped token before prod so + // full JWTs don't land in URLs / access logs. + const header = c.req.header('authorization') || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); + let user; + try { user = verifyToken(token); } catch { return c.json({ error: 'unauthorized' }, 401); } + const id = c.req.param('id'); + if (!projectAccess(user.sub, id)) return c.json({ error: 'not found' }, 404); + const key = String(id); + const stream = new ReadableStream({ + start(controller) { + if (!streams.has(key)) streams.set(key, new Set()); + streams.get(key).add(controller); + controller.enqueue(new TextEncoder().encode(': connected\n\n')); + c.req.raw.signal?.addEventListener('abort', () => { + streams.get(key)?.delete(controller); + try { controller.close(); } catch { /* already closed */ } + }); + }, + }); + return new Response(stream, { + headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }, + }); +}); + +// --------------------------------------------------------------- teams / RBAC +// List members of an org (any member may view the roster). +app.get('/api/orgs/:id/members', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); + const members = db.prepare( + `SELECT u.id, u.email, u.name, m.role FROM memberships m + JOIN users u ON u.id = m.user_id WHERE m.org_id = ? ORDER BY m.id` + ).all(orgId); + const invites = db.prepare( + `SELECT id, email, role, token, created_at AS createdAt FROM invites + WHERE org_id = ? AND accepted_at IS NULL ORDER BY id DESC` + ).all(orgId); + return c.json({ members, invites }); +}); + +// Revoke a pending invite (owner/admin). The token stops working immediately. +app.delete('/api/orgs/:id/invites/:inviteId', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('DELETE FROM invites WHERE id = ? AND org_id = ? AND accepted_at IS NULL') + .run(c.req.param('inviteId'), orgId); + if (!info.changes) return c.json({ error: 'not found' }, 404); + logAudit(orgId, uid, 'invite.revoke', 'invite', c.req.param('inviteId'), {}); + return c.json({ ok: true }); +}); + +// Invite by email (owner/admin only). Returns the token (prod: email a link). +app.post('/api/orgs/:id/invites', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + const role = orgRole(uid, orgId); + if (!role) return c.json({ error: 'not found' }, 404); + if (!canManage(role)) return c.json({ error: 'forbidden' }, 403); + const body = await c.req.json().catch(() => ({})); + const email = String(body.email || '').trim().toLowerCase(); + const inviteRole = body.role || 'member'; + if (!email) return c.json({ error: 'email required' }, 400); + if (!['admin', 'member', 'viewer'].includes(inviteRole)) return c.json({ error: 'invalid role' }, 400); + const token = randomBytes(24).toString('base64url'); + db.prepare('INSERT INTO invites(org_id,email,role,token,invited_by) VALUES(?,?,?,?,?)') + .run(orgId, email, inviteRole, token, uid); + logAudit(orgId, uid, 'member.invite', 'invite', email, { role: inviteRole }); + return c.json({ token, email, role: inviteRole }); +}); + +// Accept an invite (any authenticated user holding the token). Idempotent. +app.post('/api/invites/:token/accept', requireAuth, (c) => { + const uid = c.get('user').sub; + const inv = db.prepare('SELECT * FROM invites WHERE token = ?').get(c.req.param('token')); + if (!inv || inv.accepted_at) return c.json({ error: 'invalid or used invite' }, 404); + const existing = orgRole(uid, inv.org_id); + if (!existing) { + if (wouldExceed(db, getOrg(inv.org_id), 'members')) { + return c.json({ error: 'member limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.members }, 402); + } + db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(inv.org_id, uid, inv.role); + logAudit(inv.org_id, uid, 'member.join', 'user', uid, { role: inv.role }); + deliver(inv.org_id, 'member.join', { userId: uid, role: inv.role }); + } + db.prepare("UPDATE invites SET accepted_at = datetime('now') WHERE id = ?").run(inv.id); + return c.json({ orgId: inv.org_id, role: existing || inv.role }); +}); + +// Change a member's role (owner/admin). Cannot touch an owner or set owner. +app.patch('/api/orgs/:id/members/:userId', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + const targetId = c.req.param('userId'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const body = await c.req.json().catch(() => ({})); + const newRole = body.role; + if (!['admin', 'member', 'viewer'].includes(newRole)) return c.json({ error: 'invalid role' }, 400); + const target = db.prepare('SELECT role FROM memberships WHERE org_id = ? AND user_id = ?').get(orgId, targetId); + if (!target) return c.json({ error: 'not found' }, 404); + if (target.role === 'owner') return c.json({ error: 'cannot change owner role' }, 403); + db.prepare('UPDATE memberships SET role = ? WHERE org_id = ? AND user_id = ?').run(newRole, orgId, targetId); + logAudit(orgId, uid, 'member.role_change', 'user', targetId, { from: target.role, to: newRole }); + return c.json({ userId: Number(targetId), role: newRole }); +}); + +// Remove a member (owner/admin). Cannot remove an owner. +app.delete('/api/orgs/:id/members/:userId', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + const targetId = c.req.param('userId'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const target = db.prepare('SELECT role FROM memberships WHERE org_id = ? AND user_id = ?').get(orgId, targetId); + if (!target) return c.json({ error: 'not found' }, 404); + if (target.role === 'owner') return c.json({ error: 'cannot remove owner' }, 403); + db.prepare('DELETE FROM memberships WHERE org_id = ? AND user_id = ?').run(orgId, targetId); + logAudit(orgId, uid, 'member.remove', 'user', targetId, { role: target.role }); + return c.json({ ok: true }); +}); + +// Leave a workspace voluntarily (any non-owner member). Owners must transfer or +// delete the org instead — an org can never be left ownerless. +app.post('/api/orgs/:id/leave', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + const role = orgRole(uid, orgId); + if (!role) return c.json({ error: 'not found' }, 404); + if (role === 'owner') return c.json({ error: 'owner cannot leave; delete the workspace or transfer ownership' }, 403); + db.prepare('DELETE FROM memberships WHERE org_id = ? AND user_id = ?').run(orgId, uid); + logAudit(orgId, uid, 'member.leave', 'user', uid, { role }); + return c.json({ ok: true }); +}); + +// Transfer workspace ownership to an existing member (owner only). The old +// owner becomes an admin; orgs.owner_id follows. Transactional. +app.post('/api/orgs/:id/transfer', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); + const { userId } = await c.req.json().catch(() => ({})); + if (!userId || Number(userId) === Number(uid)) return c.json({ error: 'target member userId required' }, 400); + const target = db.prepare('SELECT role FROM memberships WHERE org_id = ? AND user_id = ?').get(orgId, userId); + if (!target) return c.json({ error: 'target is not a member' }, 404); + db.exec('BEGIN'); + try { + db.prepare("UPDATE memberships SET role = 'owner' WHERE org_id = ? AND user_id = ?").run(orgId, userId); + db.prepare("UPDATE memberships SET role = 'admin' WHERE org_id = ? AND user_id = ?").run(orgId, uid); + db.prepare('UPDATE orgs SET owner_id = ? WHERE id = ?').run(userId, orgId); + db.exec('COMMIT'); + } catch (e) { db.exec('ROLLBACK'); throw e; } + logAudit(orgId, uid, 'org.transfer', 'user', userId, { from: uid }); + return c.json({ ok: true, newOwnerId: Number(userId) }); +}); + +// Rename a workspace (owner only). +app.patch('/api/orgs/:id', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); + const { name } = await c.req.json().catch(() => ({})); + if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); + db.prepare('UPDATE orgs SET name = ? WHERE id = ?').run(String(name).trim().slice(0, 120), orgId); + logAudit(orgId, uid, 'org.rename', 'org', orgId, { name }); + return c.json({ id: Number(orgId), name: String(name).trim() }); +}); + +// ------------------------------------------------------------------- billing +app.get('/api/orgs/:id/billing', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); + const org = getOrg(orgId); + const plan = planOf(org); + return c.json({ plan: org.plan, planName: plan.name, priceKrw: plan.priceKrw, limits: plan.limits, usage: orgUsage(db, orgId) }); +}); + +app.post('/api/orgs/:id/checkout', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'only the owner can upgrade' }, 403); + const origin = new URL(c.req.url).origin; + const session = await createCheckout({ orgId, origin }); + return c.json(session); +}); + +// Stripe webhook (stub). Live mode should verify the signature with +// STRIPE_WEBHOOK_SECRET before trusting the event — named ceiling. +app.post('/api/stripe/webhook', async (c) => { + const event = await c.req.json().catch(() => ({})); + if (event?.type === 'checkout.session.completed') { + const orgId = event.data?.object?.client_reference_id || event.data?.object?.metadata?.orgId; + if (orgId) db.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").run(orgId); + } + return c.json({ received: true }); +}); + +// Dev-only: simulate a successful checkout upgrading the org to Pro. +// Disabled unless SCOPEWEAVE_DEV=1 (never reachable in production). +app.post('/api/orgs/:id/_dev/activate-pro', requireAuth, (c) => { + if (process.env.SCOPEWEAVE_DEV !== '1') return c.json({ error: 'not found' }, 404); + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); + db.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").run(orgId); + logAudit(orgId, uid, 'billing.upgrade', 'org', orgId, { plan: 'pro', via: 'dev' }); + deliver(orgId, 'billing.upgrade', { plan: 'pro' }); + return c.json({ plan: 'pro' }); +}); + +// ------------------------------------------------- personal access tokens (PAT) +app.get('/api/tokens', requireAuth, (c) => { + const uid = c.get('user').sub; + const tokens = db.prepare( + 'SELECT id, name, prefix, last_used AS lastUsed, created_at AS createdAt FROM api_tokens WHERE user_id = ? ORDER BY id DESC' + ).all(uid); + return c.json({ tokens }); // never the secret or hash +}); + +app.post('/api/tokens', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { name } = await c.req.json().catch(() => ({})); + const t = generateApiToken(); + const id = rowid(db.prepare('INSERT INTO api_tokens(user_id,name,token_hash,prefix) VALUES(?,?,?,?)') + .run(uid, String(name || 'token').slice(0, 60), t.hash, t.prefix)); + // Full secret returned ONCE — never retrievable again. + return c.json({ id, name: name || 'token', prefix: t.prefix, token: t.full }); +}); + +app.delete('/api/tokens/:id', requireAuth, (c) => { + const uid = c.get('user').sub; + const info = db.prepare('DELETE FROM api_tokens WHERE id = ? AND user_id = ?').run(c.req.param('id'), uid); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + +// Audit trail — owner/admin only. Enterprise requirement. +app.get('/api/orgs/:id/audit', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const limit = Math.min(Number(c.req.query('limit')) || 100, 500); + const rows = db.prepare( + `SELECT a.id, a.action, a.target_type AS targetType, a.target_id AS targetId, a.meta, + a.created_at AS createdAt, u.email AS actorEmail + FROM audit_log a LEFT JOIN users u ON u.id = a.user_id + WHERE a.org_id = ? ORDER BY a.id DESC LIMIT ?` + ).all(orgId, limit); + const events = rows.map((r) => ({ ...r, meta: r.meta ? JSON.parse(r.meta) : null })); + if (c.req.query('format') === 'csv') { + // Compliance deliverable. Formula-injection-safe: values that (after optional + // leading whitespace) start with = + - @ | are prefixed with ' so + // spreadsheets treat them as text. Leading whitespace alone used to bypass + // /^[=+\-@|]/ — match the client-side CSV_FORMULA_PREFIX_PATTERN. + const csvCell = (v) => { + let s = v == null ? '' : String(v); + if (/^\s*[=+\-@|]/.test(s)) s = `'${s}`; + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + }; + const header = ['id', 'createdAt', 'actorEmail', 'action', 'targetType', 'targetId', 'meta']; + const lines = [header.join(',')]; + for (const e of events) { + lines.push([e.id, e.createdAt, e.actorEmail, e.action, e.targetType, e.targetId, e.meta ? JSON.stringify(e.meta) : ''].map(csvCell).join(',')); + } + return c.text(lines.join('\r\n') + '\r\n', 200, { + 'content-type': 'text/csv; charset=utf-8', + 'content-disposition': `attachment; filename="scopeweave-audit-${orgId}.csv"`, + }); + } + return c.json({ events }); +}); + +// Full workspace export (owner only) — data portability / GDPR. Everything the +// org holds, as one JSON document. +app.get('/api/orgs/:id/export', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'only the owner can export' }, 403); + const org = getOrg(orgId); + const members = db.prepare( + `SELECT u.email, u.name, m.role FROM memberships m JOIN users u ON u.id = m.user_id WHERE m.org_id = ?` + ).all(orgId); + const projects = db.prepare( + 'SELECT id, name, base_date AS baseDate, tasks_json, version, created_at AS createdAt, updated_at AS updatedAt FROM projects WHERE org_id = ?' + ).all(orgId).map((p) => ({ ...p, tasks: JSON.parse(p.tasks_json), tasks_json: undefined })); + const audit = db.prepare( + 'SELECT action, target_type AS targetType, target_id AS targetId, meta, created_at AS createdAt FROM audit_log WHERE org_id = ? ORDER BY id' + ).all(orgId).map((a) => ({ ...a, meta: a.meta ? JSON.parse(a.meta) : null })); + logAudit(orgId, uid, 'org.export', 'org', orgId, { projects: projects.length }); + return c.json({ + exportedAt: new Date().toISOString(), + org: { id: org.id, name: org.name, plan: org.plan }, + members, projects, audit, + }, 200, { 'Content-Disposition': `attachment; filename="scopeweave-org-${orgId}.json"` }); +}); + +// Operational metrics (JSON). Ceiling: expose Prometheus text format + gate +// behind an internal token before prod if scraped externally. +app.get('/api/metrics', (c) => { + const sseActive = [...streams.values()].reduce((n, s) => n + s.size, 0); + const all = { ...metrics, sseActive, uptimeSec: Math.round(process.uptime()) }; + if (c.req.query('format') !== 'prometheus') return c.json(all); + // Prometheus text exposition format (0.0.4) — scrape-ready for Grafana/Alerting. + const gauge = new Set(['sseActive', 'uptimeSec']); + const lines = []; + for (const [k, v] of Object.entries(all)) { + if (typeof v !== 'number') continue; // startedAt etc. + const name = `scopeweave_${k.replace(/([A-Z])/g, '_$1').toLowerCase()}`; + lines.push(`# TYPE ${name} ${gauge.has(k) ? 'gauge' : 'counter'}`, `${name} ${v}`); + } + return c.text(lines.join('\n') + '\n', 200, { 'content-type': 'text/plain; version=0.0.4; charset=utf-8' }); +}); + +// ------------------------------------------------------------------- webhooks +app.get('/api/orgs/:id/webhooks', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const webhooks = db.prepare( + `SELECT w.id, w.url, w.events, w.active, w.created_at AS createdAt, + (SELECT ok FROM webhook_deliveries d WHERE d.webhook_id = w.id ORDER BY d.id DESC LIMIT 1) AS lastOk, + (SELECT created_at FROM webhook_deliveries d WHERE d.webhook_id = w.id ORDER BY d.id DESC LIMIT 1) AS lastAt + FROM webhooks w WHERE w.org_id = ? ORDER BY w.id DESC` + ).all(orgId); // secret never returned + return c.json({ webhooks }); +}); + +app.post('/api/orgs/:id/webhooks', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const { url, events } = await c.req.json().catch(() => ({})); + if (!/^https?:\/\//.test(String(url || ''))) return c.json({ error: 'valid http(s) url required' }, 400); + const secret = `whsec_${randomBytes(24).toString('base64url')}`; + const evs = Array.isArray(events) ? events.join(',') : (events || '*'); + const id = rowid(db.prepare('INSERT INTO webhooks(org_id,url,secret,events) VALUES(?,?,?,?)').run(orgId, url, secret, evs)); + logAudit(orgId, uid, 'webhook.create', 'webhook', id, { url, events: evs }); + return c.json({ id, url, events: evs, secret }); // secret shown once for signature verification +}); + +app.get('/api/orgs/:id/webhooks/:whId/deliveries', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const wh = db.prepare('SELECT id FROM webhooks WHERE id = ? AND org_id = ?').get(c.req.param('whId'), orgId); + if (!wh) return c.json({ error: 'not found' }, 404); + const deliveries = db.prepare( + 'SELECT event, status_code AS statusCode, ok, attempt, created_at AS createdAt FROM webhook_deliveries WHERE webhook_id = ? ORDER BY id DESC LIMIT 50' + ).all(wh.id); + return c.json({ deliveries }); +}); + +// Rotate a webhook's signing secret (leak response / periodic hygiene). The new +// secret is returned ONCE; old signatures stop validating immediately. +app.post('/api/orgs/:id/webhooks/:whId/rotate', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const secret = `whsec_${randomBytes(24).toString('base64url')}`; + const info = db.prepare('UPDATE webhooks SET secret = ? WHERE id = ? AND org_id = ?').run(secret, c.req.param('whId'), orgId); + if (!info.changes) return c.json({ error: 'not found' }, 404); + logAudit(orgId, uid, 'webhook.rotate', 'webhook', c.req.param('whId'), {}); + return c.json({ id: Number(c.req.param('whId')), secret }); // shown once +}); + +app.delete('/api/orgs/:id/webhooks/:whId', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('DELETE FROM webhooks WHERE id = ? AND org_id = ?').run(c.req.param('whId'), orgId); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + +// ------------------------------------------------------------ SSO (OIDC) +// Real IdP via env (OIDC_ISSUER/CLIENT_ID/CLIENT_SECRET/REDIRECT_URI). When +// unset, a built-in mock provider makes the whole flow self-contained + testable. +const OIDC = { + issuer: process.env.OIDC_ISSUER, + clientId: process.env.OIDC_CLIENT_ID, + clientSecret: process.env.OIDC_CLIENT_SECRET, + redirectUri: process.env.OIDC_REDIRECT_URI, +}; +const oidcMock = !OIDC.issuer; +const oidcStates = new Map(); // state -> { verifier, exp } +const oidcCodes = new Map(); // mock only: code -> email + +function upsertSsoUser(email) { + let user = db.prepare('SELECT id, email, token_version FROM users WHERE email = ?').get(email); + if (user) return user; + db.exec('BEGIN'); + try { + const uid = rowid(db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') + .run(email, hashPassword(randomBytes(24).toString('hex')), '')); + const oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)').run(`${email}'s workspace`, uid)); + db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); + db.exec('COMMIT'); + metrics.signups++; + return { id: uid, email }; + } catch (e) { db.exec('ROLLBACK'); throw e; } +} + +app.get('/api/auth/oidc/start', (c) => { + const origin = new URL(c.req.url).origin; + const state = randomBytes(16).toString('hex'); + const verifier = randomBytes(32).toString('base64url'); + const challenge = createHash('sha256').update(verifier).digest('base64url'); + oidcStates.set(state, { verifier, exp: Date.now() + 5 * 60 * 1000 }); + const redirectUri = OIDC.redirectUri || `${origin}/api/auth/oidc/callback`; + if (oidcMock) { + const email = c.req.query('email') || 'sso-user@example.com'; + const u = new URL(`${origin}/api/auth/oidc/mock/authorize`); + u.searchParams.set('state', state); + u.searchParams.set('email', email); + u.searchParams.set('redirect_uri', redirectUri); + return c.redirect(u.toString()); + } + const u = new URL(`${OIDC.issuer.replace(/\/$/, '')}/authorize`); + u.searchParams.set('client_id', OIDC.clientId); + u.searchParams.set('redirect_uri', redirectUri); + u.searchParams.set('response_type', 'code'); + u.searchParams.set('scope', 'openid email profile'); + u.searchParams.set('state', state); + u.searchParams.set('code_challenge', challenge); + u.searchParams.set('code_challenge_method', 'S256'); + return c.redirect(u.toString()); +}); + +// Built-in mock IdP authorize — instantly issues a code (dev/test only). +app.get('/api/auth/oidc/mock/authorize', (c) => { + if (!oidcMock) return c.json({ error: 'mock disabled' }, 404); + const state = c.req.query('state'); + const email = c.req.query('email'); + const redirectUri = c.req.query('redirect_uri'); + const code = randomBytes(16).toString('hex'); + oidcCodes.set(code, email); + const u = new URL(redirectUri); + u.searchParams.set('code', code); + u.searchParams.set('state', state); + return c.redirect(u.toString()); +}); + +app.get('/api/auth/oidc/callback', async (c) => { + const state = c.req.query('state'); + const code = c.req.query('code'); + const s = oidcStates.get(state); + if (!s || s.exp < Date.now()) return c.json({ error: 'invalid or expired state' }, 400); + oidcStates.delete(state); + let email; + if (oidcMock) { + email = oidcCodes.get(code); + oidcCodes.delete(code); + if (!email) return c.json({ error: 'invalid code' }, 400); + } else { + const redirectUri = OIDC.redirectUri || `${new URL(c.req.url).origin}/api/auth/oidc/callback`; + const tokenRes = await fetch(`${OIDC.issuer.replace(/\/$/, '')}/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: OIDC.clientId, client_secret: OIDC.clientSecret, code_verifier: s.verifier }), + }).catch(() => null); + const tok = tokenRes ? await tokenRes.json().catch(() => ({})) : {}; + if (!tok.id_token) return c.json({ error: 'token exchange failed' }, 400); + // Ceiling: verify the id_token signature via the issuer JWKS before prod. + const claims = JSON.parse(Buffer.from(String(tok.id_token).split('.')[1] || '', 'base64url').toString() || '{}'); + email = claims.email; + if (!email) return c.json({ error: 'no email claim' }, 400); + } + const user = upsertSsoUser(email); + const token = signToken({ sub: user.id, email, tv: user.token_version || 0 }); + // Return the token in the URL fragment (not query → not logged); the client + // stores it and cleans the URL. + return c.redirect(`/#token=${token}`); +}); + +// Cross-project search: project names + task names, membership-scoped (tenant +// isolation via the same JOIN as projectAccess). +// ponytail: LIKE over tasks_json text; move to FTS5 if search gets heavy. +app.get('/api/search', requireAuth, (c) => { + const uid = c.get('user').sub; + const q = String(c.req.query('q') || '').trim(); + if (q.length < 2) return c.json({ error: 'query too short (min 2)' }, 400); + const rows = db.prepare( + `SELECT DISTINCT p.id, p.name, p.tasks_json FROM projects p + JOIN memberships m ON m.org_id = p.org_id + WHERE m.user_id = ? AND (p.name LIKE ? OR p.tasks_json LIKE ?) LIMIT 100` + ).all(uid, `%${q}%`, `%${q}%`); + const needle = q.toLowerCase(); + const results = []; + for (const p of rows) { + const hit = { projectId: p.id, projectName: p.name, tasks: [] }; + if (p.name.toLowerCase().includes(needle)) hit.nameMatch = true; + let tasks = []; + try { tasks = JSON.parse(p.tasks_json); } catch { /* skip bad json */ } + for (const t of tasks) { + if (String(t.name || '').toLowerCase().includes(needle)) { + hit.tasks.push({ id: t.id, name: t.name }); + if (hit.tasks.length >= 5) break; + } + } + if (hit.nameMatch || hit.tasks.length) results.push(hit); + if (results.length >= 20) break; + } + return c.json({ query: q, results }); +}); + +// Portfolio dashboard: executive rollup across every project in a workspace — +// weighted planned/actual progress, SPI + status, overdue-task counts. +app.get('/api/orgs/:id/portfolio', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); + const today = new Date().toISOString().slice(0, 10); + const rows = db.prepare( + 'SELECT id, name, base_date AS baseDate, tasks_json, archived, updated_at AS updatedAt FROM projects WHERE org_id = ? ORDER BY archived ASC, updated_at DESC' + ).all(orgId); + const projects = rows.map((p) => { + let tasks = []; + try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } + let wSum = 0, pv = 0, ev = 0, overdue = 0; + for (const t of tasks) { + const w = Number(t.weight) || 1; + wSum += w; + pv += w * ((Number(t.plannedProgress) || 0) / 100); + ev += w * ((Number(t.actualProgress) || 0) / 100); + if (t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100) overdue++; + } + const evm = computeEvm({ pv: wSum ? pv / wSum : 0, ev: wSum ? ev / wSum : 0 }); + return { + id: p.id, + name: p.name, + archived: Boolean(p.archived), + tasks: tasks.length, + planned: Math.round(evm.pv * 1000) / 10, // % + actual: Math.round(evm.ev * 1000) / 10, // % + spi: evm.spi === null ? null : Math.round(evm.spi * 100) / 100, + status: evm.status, + label: evm.label, + overdue, + updatedAt: p.updatedAt, + }; + }); + return c.json({ projects }); +}); + +// AI 브리핑: 프로젝트 스냅샷(요약 지표 + 지연/차주 작업)을 contextual- +// orchestrator(LLM)로 보내 경영진용 리스크 분석을 생성. 원문 데이터는 서버가 +// 요약해 전송하며, LLM 자격은 서버 환경변수에만 존재. +app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + let tasks = []; + try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } + const today = new Date().toISOString().slice(0, 10); + let wSum = 0, pv = 0, ev = 0; + const late = [], upcoming = []; + for (const t of tasks) { + const w = Number(t.weight) || 1; + wSum += w; + pv += w * ((Number(t.plannedProgress) || 0) / 100); + ev += w * ((Number(t.actualProgress) || 0) / 100); + const name = t.name || t.task || t.activity || t.phase || t.id; + if (t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100) { + late.push(`${name}(계획종료 ${t.plannedEndDate}, 실적 ${Number(t.actualProgress) || 0}%${t.owner ? `, ${t.owner}` : ''})`); + } else if (t.plannedStartDate && t.plannedStartDate >= today) { + upcoming.push(`${name}(${t.plannedStartDate} 시작)`); + } + } + const pvPct = wSum ? ((pv / wSum) * 100).toFixed(1) : '0'; + const evPct = wSum ? ((ev / wSum) * 100).toFixed(1) : '0'; + const context = [ + `프로젝트: ${p.name}`, + `작업 수: ${tasks.length} · 계획진척 ${pvPct}% · 실적진척 ${evPct}%`, + `지연 작업(${late.length}): ${late.slice(0, 8).join(' / ') || '없음'}`, + `예정 작업(${upcoming.length}): ${upcoming.slice(0, 5).join(' / ') || '없음'}`, + ].join('\n'); + try { + const analysis = await orchestratorChat([ + { role: 'system', content: '너는 공정관리(schedule control) 전문가다. 주어진 프로젝트 지표를 근거로 한국어 경영진 브리핑을 작성하라: ①일정 상태 한 줄 판정 ②핵심 리스크 2~3개(근거 지표 인용) ③실행 권고 2~3개. 지표에 없는 사실은 만들지 마라.' }, + { role: 'user', content: context }, + ], { + service: 'scopeweave', + account: String(p.org_id), + }); + logAudit(p.org_id, uid, 'ai.brief', 'project', p.id, { tasks: tasks.length }); + return c.json({ analysis }); + } catch (e) { + return c.json({ error: `AI 분석 실패: ${e.message}` }, 502); + } +}); + +// 산출물 첨부(Clearfolio 통합 문서 뷰어 프록시): 업로드→변환 잡, 목록(+상태 +// 갱신), 서명 아티팩트 열람(302), 삭제. 테넌트 = 조직, 브라우저에는 Clearfolio +// 자격이 절대 노출되지 않음. +const ATTACH_MAX_BYTES = 10 * 1024 * 1024; + +const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY, +); +const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, +); +const ATTACH_STATUS_BUDGET_MS = normalizeAttachmentStatusBudgetMs( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS, +); +const ATTACHMENT_LIST_COLUMNS = `a.id, a.task_id AS taskId, a.name, a.mime, a.size, + a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy`; +const ATTACHMENT_LIST_FROM = + 'FROM attachments a LEFT JOIN users u ON u.id = a.created_by'; +const listAttachmentsStatement = db.prepare( + `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} + WHERE a.project_id = ? ORDER BY a.id DESC`, +); +const listTaskAttachmentsStatement = db.prepare( + `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} + WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`, +); +const updateAttachmentStatusStatement = db.prepare( + 'UPDATE attachments SET status = ? WHERE id = ?', +); +app.post('/api/projects/:id/attachments', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const form = await c.req.formData().catch(() => null); + const file = form?.get('file'); + if (!file || typeof file === 'string') return c.json({ error: 'multipart file required' }, 400); + const taskId = String(form.get('taskId') || ''); + if (/\.(hwp|hwpx)$/i.test(file.name || '')) return c.json({ error: 'HWP/HWPX는 지원되지 않습니다 (Clearfolio 정책)' }, 400); + if (file.size > ATTACH_MAX_BYTES) return c.json({ error: 'file too large (max 10MB)' }, 400); + const bytes = Buffer.from(await file.arrayBuffer()); + let job; + try { + job = await submitJob(p.org_id, uid, { name: file.name || 'document', mime: file.type || '', bytes }); + } catch (e) { + return c.json({ error: `문서 변환 제출 실패: ${e.message}` }, 502); + } + const aid = rowid(db.prepare( + 'INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)' + ).run(p.id, taskId, file.name || 'document', file.type || '', file.size, job.jobId, job.status, uid)); + logAudit(p.org_id, uid, 'attachment.upload', 'project', p.id, { attachmentId: aid, name: file.name, taskId: taskId || null }); + return c.json({ id: aid, status: job.status }); +}); + +app.get('/api/projects/:id/attachments', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + + const taskId = c.req.query('taskId'); + const rows = taskId + ? listTaskAttachmentsStatement.all(p.id, taskId) + : listAttachmentsStatement.all(p.id); + await refreshAttachmentStatuses(rows, { + orgId: p.org_id, + userId: uid, + jobStatus, + updateStatus: (status, attachmentId) => + updateAttachmentStatusStatement.run(status, attachmentId), + concurrency: ATTACH_STATUS_CONCURRENCY, + timeoutMs: ATTACH_STATUS_TIMEOUT_MS, + budgetMs: ATTACH_STATUS_BUDGET_MS, + metrics, + }); + const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); + return c.json({ attachments }); +}); + +// 열람: 서명 아티팩트 URL로 302. 새 탭 열기용으로 ?token=도 허용(ics/stream 패턴). +app.get('/api/projects/:id/attachments/:aid/view', (c) => { + const header = c.req.header('authorization') || ''; + const raw = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); + let uid; + if (raw.startsWith('swk_')) { + const row = db.prepare('SELECT user_id FROM api_tokens WHERE token_hash = ?').get(hashApiToken(raw)); + if (!row) return c.json({ error: 'unauthorized' }, 401); + uid = row.user_id; + } else { + try { + const payload = verifyToken(raw); + const u = db.prepare('SELECT token_version FROM users WHERE id = ?').get(payload.sub); + if (!u || (payload.tv || 0) !== u.token_version) return c.json({ error: 'unauthorized' }, 401); + uid = payload.sub; + } catch { return c.json({ error: 'unauthorized' }, 401); } + } + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const a = db.prepare('SELECT job_id, status FROM attachments WHERE id = ? AND project_id = ?').get(c.req.param('aid'), p.id); + if (!a) return c.json({ error: 'not found' }, 404); + if (a.status !== 'SUCCEEDED') return c.json({ error: `문서가 아직 준비되지 않았습니다 (${a.status})` }, 409); + return artifactUrl(p.org_id, uid, a.job_id) + .then((url) => c.redirect(url)) + .catch((e) => c.json({ error: `열람 링크 발급 실패: ${e.message}` }, 502)); +}); + +app.delete('/api/projects/:id/attachments/:aid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const a = db.prepare('SELECT created_by FROM attachments WHERE id = ? AND project_id = ?').get(c.req.param('aid'), p.id); + if (!a) return c.json({ error: 'not found' }, 404); + if (a.created_by !== uid && !canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + db.prepare('DELETE FROM attachments WHERE id = ?').run(c.req.param('aid')); + logAudit(p.org_id, uid, 'attachment.delete', 'project', p.id, { attachmentId: Number(c.req.param('aid')) }); + return c.json({ ok: true }); +}); + +// mock Clearfolio 아티팩트 서빙(dev/test 전용) +if (clearfolioMock) { + app.get('/api/mock-clearfolio/:jobId', (c) => { + const doc = mockArtifact(c.req.param('jobId')); + if (!doc) return c.json({ error: 'not found' }, 404); + return c.body(doc.bytes, 200, { + 'content-type': doc.mime || 'application/octet-stream', + 'content-disposition': `inline; filename="${encodeURIComponent(doc.name)}"`, + }); + }); +} + +// Public read-only share links: a random token grants VIEW access to one +// project (no account needed) — revocable. Never exposes org/member data. +app.post('/api/projects/:id/shares', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const token = randomBytes(18).toString('base64url'); + db.prepare('INSERT INTO share_tokens(project_id, token, created_by) VALUES(?,?,?)').run(p.id, token, uid); + logAudit(p.org_id, uid, 'share.create', 'project', p.id, {}); + return c.json({ token, url: `/?share=${token}` }); +}); + +app.get('/api/projects/:id/shares', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const shares = db.prepare( + 'SELECT id, token, created_at AS createdAt FROM share_tokens WHERE project_id = ? AND revoked = 0 ORDER BY id DESC' + ).all(p.id); + return c.json({ shares }); +}); + +app.delete('/api/projects/:id/shares/:sid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('UPDATE share_tokens SET revoked = 1 WHERE id = ? AND project_id = ? AND revoked = 0') + .run(c.req.param('sid'), p.id); + if (!info.changes) return c.json({ error: 'not found' }, 404); + logAudit(p.org_id, uid, 'share.revoke', 'project', p.id, { shareId: Number(c.req.param('sid')) }); + return c.json({ ok: true }); +}); + +// Anonymous read via share token — project content only. +app.get('/api/shared/:token', (c) => { + const row = db.prepare( + `SELECT p.name, p.base_date AS baseDate, p.tasks_json FROM share_tokens s + JOIN projects p ON p.id = s.project_id WHERE s.token = ? AND s.revoked = 0` + ).get(c.req.param('token')); + if (!row) return c.json({ error: 'not found' }, 404); + return c.json({ name: row.name, baseDate: row.baseDate, tasks: JSON.parse(row.tasks_json), readOnly: true }); +}); + +// Unseen-activity notifications: per project, count others' saves + comments +// newer than my last-seen mark. Opening a project marks it seen. +app.get('/api/notifications', requireAuth, (c) => { + const uid = c.get('user').sub; + const rows = db.prepare( + `SELECT p.id AS projectId, + (SELECT COUNT(*) FROM project_revisions r WHERE r.project_id = p.id + AND r.saved_by IS NOT NULL AND r.saved_by != ? + AND r.created_at > COALESCE(s.seen_at, '')) AS revisions, + (SELECT COUNT(*) FROM comments cm WHERE cm.project_id = p.id + AND cm.user_id IS NOT NULL AND cm.user_id != ? + AND cm.created_at > COALESCE(s.seen_at, '')) AS comments + FROM projects p + JOIN memberships m ON m.org_id = p.org_id AND m.user_id = ? + LEFT JOIN project_seen s ON s.project_id = p.id AND s.user_id = ?` + ).all(uid, uid, uid, uid); + const notifications = rows + .map((r) => ({ projectId: r.projectId, unseen: r.revisions + r.comments })) + .filter((r) => r.unseen > 0); + return c.json({ notifications }); +}); + +app.post('/api/projects/:id/seen', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + db.prepare(`INSERT INTO project_seen(project_id, user_id, seen_at) VALUES(?, ?, datetime('now')) + ON CONFLICT(project_id, user_id) DO UPDATE SET seen_at = datetime('now')`).run(p.id, uid); + return c.json({ ok: true }); +}); + +// Archive / restore a project (write roles): declutter without deleting. +app.post('/api/projects/:id/archive', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const { archived } = await c.req.json().catch(() => ({})); + const flag = archived === false ? 0 : 1; + db.prepare('UPDATE projects SET archived = ? WHERE id = ?').run(flag, p.id); + logAudit(p.org_id, uid, flag ? 'project.archive' : 'project.unarchive', 'project', p.id, {}); + return c.json({ id: p.id, archived: Boolean(flag) }); +}); + +// Duplicate a project (template use: copy tasks + base date into a new project +// in the same org). Plan caps apply like any create. +app.post('/api/projects/:id/duplicate', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + if (wouldExceed(db, getOrg(p.org_id), 'projects')) { + return c.json({ error: 'project limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.projects }, 402); + } + const { name } = await c.req.json().catch(() => ({})); + const newName = String(name || `${p.name} (복사본)`).slice(0, 120); + const nid = rowid(db.prepare('INSERT INTO projects(org_id,name,base_date,tasks_json,created_by) VALUES(?,?,?,?,?)') + .run(p.org_id, newName, p.base_date, p.tasks_json, uid)); + metrics.projectsCreated++; + logAudit(p.org_id, uid, 'project.duplicate', 'project', nid, { from: p.id, name: newName }); + return c.json({ id: nid, name: newName, version: 1 }); +}); + +// -------------------------------------------------------------- sprints +// Agile/Hybrid: 시간상자(스프린트) CRUD. 작업은 task.sprint(이름)로 배정되고 +// task.storyPoints로 추정된다 — 지표(커밋/완료 포인트, 벨로시티)는 클라이언트 +// 순수 함수(computeSprintStats)가 계산한다. +app.post('/api/projects/:id/sprints', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const { name, startDate, endDate, goal } = await c.req.json().catch(() => ({})); + if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); + const day = (v) => (/^\d{4}-\d{2}-\d{2}$/.test(String(v || '')) ? v : ''); + const sid = rowid(db.prepare('INSERT INTO sprints(project_id,name,start_date,end_date,goal) VALUES(?,?,?,?,?)') + .run(p.id, String(name).trim().slice(0, 80), day(startDate), day(endDate), String(goal || '').slice(0, 300))); + logAudit(p.org_id, uid, 'sprint.create', 'project', p.id, { sprintId: sid, name }); + return c.json({ id: sid, name: String(name).trim() }); +}); + +app.get('/api/projects/:id/sprints', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const sprints = db.prepare( + 'SELECT id, name, start_date AS startDate, end_date AS endDate, goal FROM sprints WHERE project_id = ? ORDER BY start_date, id' + ).all(p.id); + return c.json({ sprints, methodology: p.methodology || 'waterfall' }); +}); + +app.delete('/api/projects/:id/sprints/:sid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('DELETE FROM sprints WHERE id = ? AND project_id = ?').run(c.req.param('sid'), p.id); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + +// ------------------------------------------------------------- baselines +// Snapshot a project's current plan as a named baseline (schedule-control: +// compare actuals against the frozen plan later). +app.post('/api/projects/:id/baselines', requireAuth, async (c) => { + const uid = c.get('user').sub; + const id = c.req.param('id'); + const p = projectAccess(uid, id); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const { name } = await c.req.json().catch(() => ({})); + const bid = rowid(db.prepare('INSERT INTO baselines(project_id,name,base_date,tasks_json,created_by) VALUES(?,?,?,?,?)') + .run(id, String(name || 'Baseline').slice(0, 80), p.base_date, p.tasks_json, uid)); + logAudit(p.org_id, uid, 'baseline.create', 'project', id, { baselineId: bid, name }); + return c.json({ id: bid, name: name || 'Baseline' }); +}); + +app.get('/api/projects/:id/baselines', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const baselines = db.prepare( + 'SELECT id, name, base_date AS baseDate, created_at AS createdAt FROM baselines WHERE project_id = ? ORDER BY id DESC' + ).all(p.id); + return c.json({ baselines }); +}); + +app.get('/api/projects/:id/baselines/:bid', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const b = db.prepare('SELECT id, name, base_date AS baseDate, tasks_json, created_at AS createdAt FROM baselines WHERE id = ? AND project_id = ?').get(c.req.param('bid'), p.id); + if (!b) return c.json({ error: 'not found' }, 404); + return c.json({ id: b.id, name: b.name, baseDate: b.baseDate, tasks: JSON.parse(b.tasks_json), createdAt: b.createdAt }); +}); + +app.delete('/api/projects/:id/baselines/:bid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('DELETE FROM baselines WHERE id = ? AND project_id = ?').run(c.req.param('bid'), p.id); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + +// ------------------------------------------------------ account & lifecycle +// Delete a project (write roles). tasks live in the row, so this fully removes it. +app.delete('/api/projects/:id', requireAuth, (c) => { + const uid = c.get('user').sub; + const id = c.req.param('id'); + const p = projectAccess(uid, id); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + db.prepare('DELETE FROM projects WHERE id = ?').run(id); + logAudit(p.org_id, uid, 'project.delete', 'project', id, { name: p.name }); + deliver(p.org_id, 'project.delete', { projectId: Number(id) }); + return c.json({ ok: true }); +}); + +// Log out everywhere: bump token_version → every existing JWT dies. Returns a +// fresh token so THIS device stays signed in. PATs are unaffected. +app.post('/api/auth/logout-all', requireAuth, (c) => { + const uid = c.get('user').sub; + db.prepare('UPDATE users SET token_version = token_version + 1 WHERE id = ?').run(uid); + const u = db.prepare('SELECT email, token_version FROM users WHERE id = ?').get(uid); + return c.json({ ok: true, token: signToken({ sub: uid, email: u.email, tv: u.token_version }) }); +}); + +// Change password (verifies the current one). +app.post('/api/auth/change-password', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { oldPassword, newPassword } = await c.req.json().catch(() => ({})); + if (typeof newPassword !== 'string' || newPassword.length < 8) return c.json({ error: 'new password (min 8) required' }, 400); + const u = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(uid); + if (!u || typeof oldPassword !== 'string' || !verifyPassword(oldPassword, u.password_hash)) { + return c.json({ error: 'current password incorrect' }, 403); + } + db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(newPassword), uid); + return c.json({ ok: true }); +}); + +// Delete account (GDPR). Removes owned workspaces (cascading their data) and the +// user. Requires the current password to confirm. +app.delete('/api/account', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { password } = await c.req.json().catch(() => ({})); + const u = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(uid); + if (!u || typeof password !== 'string' || !verifyPassword(password, u.password_hash)) { + return c.json({ error: 'password required to delete account' }, 403); + } + db.exec('BEGIN'); + try { + db.prepare('DELETE FROM orgs WHERE owner_id = ?').run(uid); // cascades projects/members/webhooks/invites/audit + db.prepare('DELETE FROM users WHERE id = ?').run(uid); // cascades memberships/tokens + db.exec('COMMIT'); + } catch (e) { db.exec('ROLLBACK'); throw e; } + return c.json({ ok: true }); +}); + +app.get('/api/health', (c) => c.json({ ok: true })); + +// Static client — strict allowlist so server/, data.db, package.json etc. are +// never served. Anything not listed → 404. +const STATIC = { + '/': ['index.html', 'text/html; charset=utf-8'], + '/index.html': ['index.html', 'text/html; charset=utf-8'], + '/404.html': ['404.html', 'text/html; charset=utf-8'], + '/landing.html': ['landing.html', 'text/html; charset=utf-8'], + '/landing.en.html': ['landing.en.html', 'text/html; charset=utf-8'], + '/docs/api.md': ['docs/api.md', 'text/markdown; charset=utf-8'], + '/robots.txt': ['robots.txt', 'text/plain; charset=utf-8'], + '/sitemap.xml': ['sitemap.xml', 'application/xml; charset=utf-8'], + '/pricing': ['landing.html', 'text/html; charset=utf-8'], + '/app.js': ['app.js', 'text/javascript; charset=utf-8'], + '/cloud-sync.js': ['cloud-sync.js', 'text/javascript; charset=utf-8'], + '/analytics.js': ['analytics.js', 'text/javascript; charset=utf-8'], + '/styles.css': ['styles.css', 'text/css; charset=utf-8'], + '/toast-state.css': ['toast-state.css', 'text/css; charset=utf-8'], + '/wbs.json': ['wbs.json', 'application/json; charset=utf-8'], +}; +app.get('*', async (c) => { + const entry = STATIC[c.req.path]; + if (!entry) return c.notFound(); + try { + const buf = await readFile(new URL(`../${entry[0]}`, import.meta.url)); + return c.body(buf, 200, { 'Content-Type': entry[1] }); + } catch { + return c.notFound(); + } +}); From 8a867cdb7f88fe173178c9d1f565c42bc8e2cdfe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 19:41:00 -0700 Subject: [PATCH 003/120] test: cover trusted proxy client-IP boundary --- tests/api/ratelimit.test.mjs | 56 ++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/tests/api/ratelimit.test.mjs b/tests/api/ratelimit.test.mjs index 421def03..9c413cd1 100644 --- a/tests/api/ratelimit.test.mjs +++ b/tests/api/ratelimit.test.mjs @@ -1,16 +1,20 @@ // Rate-limit test — runs in its own process with the limiter enabled low. // Run: node tests/api/ratelimit.test.mjs import assert from 'node:assert'; +import { once } from 'node:events'; +import { serve } from '@hono/node-server'; process.env.SCOPEWEAVE_DB = ':memory:'; process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '3'; +process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS = '127.0.0.1,::1,::ffff:127.0.0.1'; process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; const { app } = await import('../../server/app.mjs'); const req = (path, opts = {}) => app.request(path, { ...opts, headers: { 'content-type': 'application/json', 'x-forwarded-for': '203.0.113.7', ...(opts.headers || {}) } }); -// first 3 pass, 4th+ are limited +// In-process calls have no authenticated network peer, so they deliberately +// share one fail-closed bucket and ignore caller-controlled forwarding data. let statuses = []; for (let i = 0; i < 5; i++) statuses.push((await req('/api/health')).status); assert.deepEqual(statuses.slice(0, 3), [200, 200, 200], 'first 3 under the limit'); @@ -18,10 +22,52 @@ assert.equal(statuses[3], 429, '4th request rate-limited'); const limited = await req('/api/health'); assert.equal(limited.status, 429, 'still limited within the window'); assert.ok(limited.headers.get('retry-after'), 'Retry-After header present'); - -// Untrusted callers must not be able to evade the security boundary by -// choosing a fresh client-supplied forwarding header on every request. const spoofed = await req('/api/health', { headers: { 'x-forwarded-for': '198.51.100.9' } }); assert.equal(spoofed.status, 429, 'untrusted X-Forwarded-For cannot select a new rate-limit bucket'); -console.log('✓ rate-limit tests passed'); \ No newline at end of file +// Exercise the actual Node transport boundary. The loopback socket is an +// explicitly trusted ingress for this test, so only forwarding hops anchored +// to that peer may select a client bucket. +const server = serve({ fetch: app.fetch, hostname: '127.0.0.1', port: 0 }); +if (!server.listening) await once(server, 'listening'); +try { + const address = server.address(); + assert.ok(address && typeof address === 'object', 'test server exposes a TCP address'); + const base = `http://127.0.0.1:${address.port}`; + const viaProxy = (forwarded) => fetch(`${base}/api/health`, { + headers: forwarded === undefined ? {} : { 'x-forwarded-for': forwarded }, + }); + + // A malicious client may alter left-side values, but a trusted proxy's + // nearest appended client hop remains the same and therefore exhausts one + // bucket rather than creating attacker-selected buckets. + for (let i = 0; i < 3; i++) { + assert.equal((await viaProxy(`192.0.2.${10 + i}, 203.0.113.7`)).status, 200); + } + assert.equal( + (await viaProxy('192.0.2.99, 203.0.113.7')).status, + 429, + 'changing spoofable left-side forwarding data cannot evade the client bucket' + ); + assert.equal( + (await viaProxy('192.0.2.123, 198.51.100.9')).status, + 200, + 'a genuinely different nearest client hop receives a separate bucket' + ); + + // Trusted proxy chains are skipped from right to left until the first + // untrusted client IP is reached. + assert.equal((await viaProxy('198.51.100.10, 127.0.0.1')).status, 200); + + // Missing, invalid, or all-trusted forwarding evidence fails closed to the + // actual peer instead of accepting arbitrary strings as identities. + assert.equal((await viaProxy()).status, 200); + assert.equal((await viaProxy('not-an-ip-one')).status, 200); + assert.equal((await viaProxy('not-an-ip-two')).status, 200); + assert.equal((await viaProxy('not-an-ip-three')).status, 429); + assert.equal((await viaProxy('127.0.0.1')).status, 429); +} finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); +} + +console.log('✓ rate-limit tests passed'); From 4125cc1fc33761589a53a137188f1d24dda34aba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 19:41:17 -0700 Subject: [PATCH 004/120] test: keep route core in owned coverage --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8cefdc74..50e225df 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", "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", - "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage": "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_routes.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", From 7f893f95c879db64b40042b3fe852db7278b2d91 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 19:41:51 -0700 Subject: [PATCH 005/120] docs: define trusted reverse-proxy rate-limit contract --- docs/deploy.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/deploy.md b/docs/deploy.md index 0cfdb799..aa54f2b4 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -44,7 +44,8 @@ persists the database in the `scopeweave-data` volume. | `SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY` | no (default 8, maximum 32) | Maximum concurrent Clearfolio status lookups during one attachment-list request. Invalid values fall back to 8; values above 32 are clamped. | | `SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS` | no (default 3000, maximum 30000) | Hard caller-side timeout for each Clearfolio status lookup. The AbortSignal is also forwarded downstream. | | `SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS` | no (default 5000, maximum 60000) | Wall-clock budget for the entire best-effort refresh pass. Work not started before the deadline is deferred to a later list request. | -| `SCOPEWEAVE_RATE_LIMIT_MAX` (+ `SCOPEWEAVE_RATE_LIMIT_WINDOW_MS`) | recommended | Per-IP fixed-window rate limiting (429 + Retry-After). Off when unset. | +| `SCOPEWEAVE_RATE_LIMIT_MAX` (+ `SCOPEWEAVE_RATE_LIMIT_WINDOW_MS`) | recommended | Per-client fixed-window rate limiting (429 + Retry-After). Off when unset. Client identity is anchored to the network peer unless the peer is explicitly trusted below. | +| `SCOPEWEAVE_TRUSTED_PROXY_IPS` | only behind trusted reverse proxies | Comma-separated **immediate or chained proxy peer IPs** that ScopeWeave is allowed to trust when interpreting `X-Forwarded-For`. Leave unset for direct deployments. | ## Attachment status refresh operations @@ -133,6 +134,19 @@ Terminate TLS at a reverse proxy (nginx/Caddy/ALB) in front of the backend and forward to `:8787`. The client and API share the origin, so no CORS config is needed. +`X-Forwarded-For` is **ignored for the security rate-limit identity by default**. +If a reverse proxy is the only permitted ingress to ScopeWeave, list its actual +network peer address in `SCOPEWEAVE_TRUSTED_PROXY_IPS`. For multiple trusted +proxy hops, list every trusted hop. ScopeWeave then walks `X-Forwarded-For` from +right to left, skips explicitly trusted proxy addresses, and chooses the first +untrusted valid IP as the client identity. Missing, malformed, or all-trusted +forwarding evidence falls back to the actual socket peer. + +Do not configure this trust list while untrusted clients can connect directly to +the backend. The proxy must overwrite or append forwarding information according +to a controlled ingress policy; accepting a caller-selected forwarding header +without an authenticated/trusted peer would make rate limiting bypassable. + ## Kubernetes The existing `infra/k8s/` manifests deploy the **static-only** nginx image. For From 172ac1d321a92f8906cbc618646149859fe1f462 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 19:42:34 -0700 Subject: [PATCH 006/120] docs: trace trusted proxy client-IP boundary --- docs/doctoring/trusted-proxy-client-ip.md | 52 +++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docs/doctoring/trusted-proxy-client-ip.md diff --git a/docs/doctoring/trusted-proxy-client-ip.md b/docs/doctoring/trusted-proxy-client-ip.md new file mode 100644 index 00000000..88c73a77 --- /dev/null +++ b/docs/doctoring/trusted-proxy-client-ip.md @@ -0,0 +1,52 @@ +# Trusted proxy client-IP boundary + +## Status + +**Active implementation evidence for PR #587.** This note records the security +reasoning and primary/authoritative references for ScopeWeave's rate-limit +client-identity boundary. It does not claim certification. + +## Decision + +Security-sensitive client identity must begin with the transport peer that +actually connected to the Node process. A caller-supplied `X-Forwarded-For` +header is ignored unless that immediate peer is explicitly listed in +`SCOPEWEAVE_TRUSTED_PROXY_IPS`. + +When the immediate peer is trusted, ScopeWeave parses the forwarding chain from +right to left. Explicitly trusted proxy hops are skipped. The first untrusted, +syntactically valid IP becomes the rate-limit identity. Missing, malformed, or +all-trusted forwarding evidence fails closed to the actual peer. In adapters +where the socket peer is unavailable, requests share one `local` bucket rather +than accepting an unauthenticated forwarding value. + +This is intentionally an allow-list trust model. It avoids treating a header +that an Internet client can normally set itself as authenticated evidence. The +operator contract in `docs/deploy.md` therefore requires direct access to the +backend to be denied before proxy addresses are added to the trust list. + +## Acceptance trace + +`tests/api/ratelimit.test.mjs` exercises both boundaries: + +1. in-process/untrusted requests exhaust one bucket even when the caller changes + `X-Forwarded-For`; +2. an actual Node loopback connection is configured as a trusted ingress; +3. changing spoofable left-side forwarding values while keeping the nearest + untrusted client hop fixed still returns `429` after the configured limit; +4. a different nearest client hop receives a separate bucket; +5. trusted proxy hops are skipped right-to-left; +6. missing, malformed, and all-trusted forwarding evidence falls back to the + transport peer. + +The original RED evidence was the hosted Server Tests failure at contributor +head `c4a3b2f6429634c4165f7efdc71734384241e574`: the spoofed forwarding value +returned `200` where the regression required `429`. + +## References (APA 7) + +Hono. (n.d.). *Node.js*. https://hono.dev/docs/getting-started/nodejs + +MDN Web Docs. (2025, July 4). *X-Forwarded-For header*. Mozilla. https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-For + +Petersson, A., & Nilsson, M. (2014). *Forwarded HTTP extension* (RFC 7239). Internet Engineering Task Force. https://doi.org/10.17487/RFC7239 From 62e5406c4982c884f8f625343b37a3d9d953fda2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 19:43:22 -0700 Subject: [PATCH 007/120] test: keep SaaS serve-path assertion across security envelope --- tests/unit/toast-accessibility.test.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/toast-accessibility.test.mjs b/tests/unit/toast-accessibility.test.mjs index c0aa79a0..c90c42c7 100644 --- a/tests/unit/toast-accessibility.test.mjs +++ b/tests/unit/toast-accessibility.test.mjs @@ -35,7 +35,9 @@ 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 serverEnvelope = readFileSync(new URL('../../server/app.mjs', import.meta.url), 'utf8'); + const serverRoutes = readFileSync(new URL('../../server/app_routes.mjs', import.meta.url), 'utf8'); + const serverApp = `${serverEnvelope}\n${serverRoutes}`; 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'); From dc2fe13b1825975bc38459862f48e2c645351924 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 19:56:35 -0700 Subject: [PATCH 008/120] test(security): cover cross-client rate-limit poisoning --- tests/api/ratelimit.test.mjs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/api/ratelimit.test.mjs b/tests/api/ratelimit.test.mjs index 9c413cd1..ddc6e5f9 100644 --- a/tests/api/ratelimit.test.mjs +++ b/tests/api/ratelimit.test.mjs @@ -55,6 +55,22 @@ try { 'a genuinely different nearest client hop receives a separate bucket' ); + // A trusted proxy appends the attacker's real nearest hop to any spoofable + // client-supplied left-side forwarding chain. That left-side value must not + // consume a different legitimate client's limiter state. + for (let i = 0; i < 3; i++) { + assert.equal( + (await viaProxy('203.0.113.50, 198.51.100.50')).status, + 200, + 'attacker requests stay in the attacker bucket' + ); + } + assert.equal( + (await viaProxy('203.0.113.50')).status, + 200, + 'spoofable left-side forwarding data cannot poison another client bucket' + ); + // Trusted proxy chains are skipped from right to left until the first // untrusted client IP is reached. assert.equal((await viaProxy('198.51.100.10, 127.0.0.1')).status, 200); From fb98f87e5ac58836edeb550d999455c71ab9abdb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 19:58:47 -0700 Subject: [PATCH 009/120] fix(security): prevent duplicate proxy rate-limit buckets --- server/app.mjs | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 58d13799..ba4e1fe7 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1,17 +1,15 @@ // Security envelope for the ScopeWeave SaaS routes. // // The route implementation remains in app_routes.mjs so the client-IP trust -// boundary stays small, reviewable, and independently testable. The outer -// limiter is authoritative for abuse protection; the route module's historic -// limiter remains in place for backward compatibility until that code is -// retired in a later focused change. +// boundary stays small, reviewable, and independently testable. Rate limiting +// is authoritative only in this envelope: the legacy route-module limiter is +// initialized disabled so spoofable left-side forwarding data cannot create a +// second, contradictory client bucket behind a trusted proxy. import { Hono } from 'hono'; import { isIP } from 'node:net'; -import { app as routeApp } from './app_routes.mjs'; -export * from './app_routes.mjs'; - -const RL_MAX = Number(process.env.SCOPEWEAVE_RATE_LIMIT_MAX) || 0; +const configuredRateLimitMax = process.env.SCOPEWEAVE_RATE_LIMIT_MAX; +const RL_MAX = Number(configuredRateLimitMax) || 0; const RL_WINDOW_MS = Number(process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS) || 60000; const trustedProxyIps = new Set( String(process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS || '') @@ -21,6 +19,19 @@ const trustedProxyIps = new Set( ); const rlBuckets = new Map(); +// app_routes.mjs predates the transport-peer trust boundary and still contains +// its historical header-keyed limiter. Load it with that limiter disabled so +// only the security envelope below can consume rate-limit state. Restore the +// operator environment immediately after module initialization. +let routeApp; +try { + process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '0'; + ({ app: routeApp } = await import('./app_routes.mjs')); +} finally { + if (configuredRateLimitMax === undefined) delete process.env.SCOPEWEAVE_RATE_LIMIT_MAX; + else process.env.SCOPEWEAVE_RATE_LIMIT_MAX = configuredRateLimitMax; +} + /** * Resolve the network peer that directly connected to the Node server. * In in-process tests or non-Node adapters where no socket exists, all such From 8b144b22e24025e418f0f241d4a2043485431fbd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 19:59:38 -0700 Subject: [PATCH 010/120] docs(security): trace proxy bucket-poisoning regression --- docs/doctoring/trusted-proxy-client-ip.md | 26 ++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/docs/doctoring/trusted-proxy-client-ip.md b/docs/doctoring/trusted-proxy-client-ip.md index 88c73a77..2726dc1b 100644 --- a/docs/doctoring/trusted-proxy-client-ip.md +++ b/docs/doctoring/trusted-proxy-client-ip.md @@ -20,6 +20,13 @@ all-trusted forwarding evidence fails closed to the actual peer. In adapters where the socket peer is unavailable, requests share one `local` bucket rather than accepting an unauthenticated forwarding value. +Only the security envelope in `server/app.mjs` is allowed to consume rate-limit +state. The older route-module limiter is initialized disabled when loaded by the +production envelope. Keeping two active limiters would let an attacker place a +victim address on the spoofable left side of an appended forwarding chain and +consume the victim's legacy bucket even though the trusted-boundary limiter had +correctly attributed the request to the attacker's nearest hop. + This is intentionally an allow-list trust model. It avoids treating a header that an Internet client can normally set itself as authenticated evidence. The operator contract in `docs/deploy.md` therefore requires direct access to the @@ -35,13 +42,22 @@ backend to be denied before proxy addresses are added to the trust list. 3. changing spoofable left-side forwarding values while keeping the nearest untrusted client hop fixed still returns `429` after the configured limit; 4. a different nearest client hop receives a separate bucket; -5. trusted proxy hops are skipped right-to-left; -6. missing, malformed, and all-trusted forwarding evidence falls back to the +5. repeated attacker requests with a spoofed victim address on the left do not + consume the victim's rate-limit bucket; +6. trusted proxy hops are skipped right-to-left; +7. missing, malformed, and all-trusted forwarding evidence falls back to the transport peer. -The original RED evidence was the hosted Server Tests failure at contributor -head `c4a3b2f6429634c4165f7efdc71734384241e574`: the spoofed forwarding value -returned `200` where the regression required `429`. +The original bypass RED evidence was the hosted Server Tests failure at +contributor head `c4a3b2f6429634c4165f7efdc71734384241e574`: the spoofed +forwarding value returned `200` where the regression required `429`. + +The cross-client poisoning RED evidence was Server Tests run `32614096313` on +the merge result containing contributor head +`dc2fe13b1825975bc38459862f48e2c645351924`: a legitimate victim request +returned `429` where the regression required `200`. The subsequent product fix +is on the same contributor branch; exact-head gate acceptance remains separate +from this behavioral regression evidence. ## References (APA 7) From b4aa4a3eaa11edbc6bba9aa68a01598e8c679abf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:33:57 -0700 Subject: [PATCH 011/120] test(rate-limit): bound client bucket cardinality --- tests/api/ratelimit.test.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/api/ratelimit.test.mjs b/tests/api/ratelimit.test.mjs index ddc6e5f9..84cbb6ab 100644 --- a/tests/api/ratelimit.test.mjs +++ b/tests/api/ratelimit.test.mjs @@ -6,6 +6,7 @@ import { serve } from '@hono/node-server'; process.env.SCOPEWEAVE_DB = ':memory:'; process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '3'; +process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX = '7'; process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS = '127.0.0.1,::1,::ffff:127.0.0.1'; process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; const { app } = await import('../../server/app.mjs'); @@ -82,6 +83,19 @@ try { assert.equal((await viaProxy('not-an-ip-two')).status, 200); assert.equal((await viaProxy('not-an-ip-three')).status, 429); assert.equal((await viaProxy('127.0.0.1')).status, 429); + + // Distinct trusted-proxy client addresses must not grow in-memory limiter + // state without bound. Once the configured bucket cardinality is exhausted, + // previously unseen clients share one fail-closed overflow bucket instead of + // allocating attacker-controlled Map entries forever. + assert.equal((await viaProxy('192.0.2.201')).status, 200); + assert.equal((await viaProxy('192.0.2.202')).status, 200); + assert.equal((await viaProxy('192.0.2.203')).status, 200); + assert.equal( + (await viaProxy('192.0.2.204')).status, + 429, + 'new client identities share a bounded overflow bucket after capacity is reached' + ); } finally { await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); } From bf2041425fba1135f0f8ab06a8030aa9e022260f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:35:32 -0700 Subject: [PATCH 012/120] fix(rate-limit): bound client bucket state --- server/app.mjs | 47 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index ba4e1fe7..0f34b724 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -11,6 +11,10 @@ import { isIP } from 'node:net'; const configuredRateLimitMax = process.env.SCOPEWEAVE_RATE_LIMIT_MAX; const RL_MAX = Number(configuredRateLimitMax) || 0; const RL_WINDOW_MS = Number(process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS) || 60000; +const configuredBucketLimit = Number(process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX); +const RL_BUCKET_LIMIT = Number.isSafeInteger(configuredBucketLimit) && configuredBucketLimit > 0 + ? configuredBucketLimit + : 10000; const trustedProxyIps = new Set( String(process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS || '') .split(',') @@ -18,6 +22,8 @@ const trustedProxyIps = new Set( .filter((value) => isIP(value) !== 0) ); const rlBuckets = new Map(); +let overflowBucket; +let nextBucketSweepAt = 0; // app_routes.mjs predates the transport-peer trust boundary and still contains // its historical header-keyed limiter. Load it with that limiter disabled so @@ -68,17 +74,48 @@ function rateLimitClientIp(c) { return peer; } +/** + * Return bounded fixed-window state for one client identity. + * + * The regular client map never grows beyond RL_BUCKET_LIMIT. Once that many + * distinct identities are live, unseen clients share a separate fail-closed + * overflow bucket. Expired regular buckets are swept at most once per window, + * keeping both memory and sweep CPU bounded under high-cardinality traffic. + */ +function rateLimitBucket(key, now) { + let bucket = rlBuckets.get(key); + if (bucket?.resetAt <= now) { + rlBuckets.delete(key); + bucket = undefined; + } + if (bucket) return bucket; + + if (rlBuckets.size >= RL_BUCKET_LIMIT && now >= nextBucketSweepAt) { + for (const [bucketKey, candidate] of rlBuckets) { + if (candidate.resetAt <= now) rlBuckets.delete(bucketKey); + } + nextBucketSweepAt = now + RL_WINDOW_MS; + } + + if (rlBuckets.size < RL_BUCKET_LIMIT) { + bucket = { count: 0, resetAt: now + RL_WINDOW_MS }; + rlBuckets.set(key, bucket); + return bucket; + } + + if (!overflowBucket || overflowBucket.resetAt <= now) { + overflowBucket = { count: 0, resetAt: now + RL_WINDOW_MS }; + } + return overflowBucket; +} + export const app = new Hono(); if (RL_MAX > 0) { app.use('*', async (c, next) => { const key = rateLimitClientIp(c); const now = Date.now(); - let bucket = rlBuckets.get(key); - if (!bucket || bucket.resetAt <= now) { - bucket = { count: 0, resetAt: now + RL_WINDOW_MS }; - rlBuckets.set(key, bucket); - } + const bucket = rateLimitBucket(key, now); bucket.count++; if (bucket.count > RL_MAX) { const retry = Math.ceil((bucket.resetAt - now) / 1000); From 2c6da6f60f3f53c48a0575f7d3bd3ab9585f495a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:38:51 -0700 Subject: [PATCH 013/120] test(rate-limit): reject unsafe limiter config --- tests/api/ratelimit.test.mjs | 46 +++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/tests/api/ratelimit.test.mjs b/tests/api/ratelimit.test.mjs index 84cbb6ab..ebf9edaf 100644 --- a/tests/api/ratelimit.test.mjs +++ b/tests/api/ratelimit.test.mjs @@ -1,14 +1,58 @@ // Rate-limit test — runs in its own process with the limiter enabled low. // Run: node tests/api/ratelimit.test.mjs import assert from 'node:assert'; +import { spawnSync } from 'node:child_process'; import { once } from 'node:events'; import { serve } from '@hono/node-server'; +const validJwtSecret = '0123456789abcdef0123456789abcdef'; +const importRateLimitApp = (overrides) => spawnSync( + process.execPath, + ['--input-type=module', '--eval', "await import('./server/app.mjs')"], + { + cwd: process.cwd(), + encoding: 'utf8', + env: { + ...process.env, + SCOPEWEAVE_DB: ':memory:', + SCOPEWEAVE_JWT_SECRET: validJwtSecret, + SCOPEWEAVE_RATE_LIMIT_MAX: '3', + SCOPEWEAVE_RATE_LIMIT_WINDOW_MS: '60000', + SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX: '7', + ...overrides, + }, + }, +); + +for (const [name, value] of [ + ['SCOPEWEAVE_RATE_LIMIT_MAX', '-1'], + ['SCOPEWEAVE_RATE_LIMIT_MAX', 'not-a-number'], + ['SCOPEWEAVE_RATE_LIMIT_MAX', 'Infinity'], + ['SCOPEWEAVE_RATE_LIMIT_WINDOW_MS', '0'], + ['SCOPEWEAVE_RATE_LIMIT_WINDOW_MS', '-1'], + ['SCOPEWEAVE_RATE_LIMIT_WINDOW_MS', 'not-a-number'], + ['SCOPEWEAVE_RATE_LIMIT_WINDOW_MS', 'Infinity'], + ['SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX', '0'], + ['SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX', '-1'], + ['SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX', 'not-a-number'], + ['SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX', 'Infinity'], +]) { + const result = importRateLimitApp({ [name]: value }); + assert.notEqual(result.status, 0, `${name}=${value} must fail startup instead of weakening limiter semantics`); + assert.match(result.stderr, new RegExp(name), 'startup error identifies the invalid limiter setting'); +} + +assert.equal( + importRateLimitApp({ SCOPEWEAVE_RATE_LIMIT_MAX: '0' }).status, + 0, + 'explicit zero keeps the documented disabled-limiter contract', +); + process.env.SCOPEWEAVE_DB = ':memory:'; process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '3'; process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX = '7'; process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS = '127.0.0.1,::1,::ffff:127.0.0.1'; -process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.SCOPEWEAVE_JWT_SECRET = validJwtSecret; const { app } = await import('../../server/app.mjs'); const req = (path, opts = {}) => From a86f8456c651602cd8b3aca7bb561133b88dae5a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:39:53 -0700 Subject: [PATCH 014/120] fix(rate-limit): fail closed on unsafe config --- server/app.mjs | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 0f34b724..69136b1d 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -8,13 +8,35 @@ import { Hono } from 'hono'; import { isIP } from 'node:net'; +/** + * Parse one explicit limiter setting without silently weakening protection. + * Empty or absent values use the documented fallback; configured values must + * be finite safe integers within the caller's accepted range. + */ +function parseSafeIntegerSetting(name, raw, fallback, minimum) { + if (raw === undefined || String(raw).trim() === '') return fallback; + const value = Number(raw); + if (!Number.isSafeInteger(value) || value < minimum) { + const range = minimum === 0 ? 'a non-negative' : 'a positive'; + throw new Error(`${name} must be ${range} safe integer`); + } + return value; +} + const configuredRateLimitMax = process.env.SCOPEWEAVE_RATE_LIMIT_MAX; -const RL_MAX = Number(configuredRateLimitMax) || 0; -const RL_WINDOW_MS = Number(process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS) || 60000; -const configuredBucketLimit = Number(process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX); -const RL_BUCKET_LIMIT = Number.isSafeInteger(configuredBucketLimit) && configuredBucketLimit > 0 - ? configuredBucketLimit - : 10000; +const RL_MAX = parseSafeIntegerSetting('SCOPEWEAVE_RATE_LIMIT_MAX', configuredRateLimitMax, 0, 0); +const RL_WINDOW_MS = parseSafeIntegerSetting( + 'SCOPEWEAVE_RATE_LIMIT_WINDOW_MS', + process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS, + 60000, + 1, +); +const RL_BUCKET_LIMIT = parseSafeIntegerSetting( + 'SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX', + process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX, + 10000, + 1, +); const trustedProxyIps = new Set( String(process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS || '') .split(',') From fb64472c4ac588952c7b5109124f202bd21a41e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:47:21 -0700 Subject: [PATCH 015/120] docs(rate-limit): document bounded state and config --- docs/deploy.md | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/deploy.md b/docs/deploy.md index aa54f2b4..b04f654e 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -44,9 +44,37 @@ persists the database in the `scopeweave-data` volume. | `SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY` | no (default 8, maximum 32) | Maximum concurrent Clearfolio status lookups during one attachment-list request. Invalid values fall back to 8; values above 32 are clamped. | | `SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS` | no (default 3000, maximum 30000) | Hard caller-side timeout for each Clearfolio status lookup. The AbortSignal is also forwarded downstream. | | `SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS` | no (default 5000, maximum 60000) | Wall-clock budget for the entire best-effort refresh pass. Work not started before the deadline is deferred to a later list request. | -| `SCOPEWEAVE_RATE_LIMIT_MAX` (+ `SCOPEWEAVE_RATE_LIMIT_WINDOW_MS`) | recommended | Per-client fixed-window rate limiting (429 + Retry-After). Off when unset. Client identity is anchored to the network peer unless the peer is explicitly trusted below. | +| `SCOPEWEAVE_RATE_LIMIT_MAX` | recommended | Per-client fixed-window request allowance. Unset or explicit `0` disables the limiter. Any other configured value must be a non-negative safe integer or startup fails. | +| `SCOPEWEAVE_RATE_LIMIT_WINDOW_MS` | no (default 60000) | Fixed-window duration in milliseconds. An explicit value must be a positive safe integer or startup fails. | +| `SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX` | no (default 10000) | Maximum number of live per-client limiter buckets held by one ScopeWeave process. An explicit value must be a positive safe integer or startup fails. Once capacity is reached, previously unseen identities share a fail-closed overflow bucket until expired regular buckets are reclaimed. | | `SCOPEWEAVE_TRUSTED_PROXY_IPS` | only behind trusted reverse proxies | Comma-separated **immediate or chained proxy peer IPs** that ScopeWeave is allowed to trust when interpreting `X-Forwarded-For`. Leave unset for direct deployments. | +### Rate-limit capacity and tuning + +When the limiter is enabled, `429` responses include `Retry-After`. Client +identity is anchored to the actual network peer unless that peer is explicitly +trusted through `SCOPEWEAVE_TRUSTED_PROXY_IPS`. The regular in-memory bucket map +is deliberately bounded by `SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX`; attacker-driven +high-cardinality source identities therefore cannot create unbounded limiter +state. At capacity, new identities use one shared overflow bucket rather than +allocating new map entries. Expired regular buckets are reclaimed by a bounded +sweep before admitting new identities. + +Size `SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX` for the maximum legitimate concurrent +client-identity population expected **per process**, with headroom for normal +bursts. Do not increase it merely to make overflow throttling disappear: first +confirm that trusted-proxy identity extraction is correct and that the observed +cardinality is legitimate. In horizontally scaled deployments, each replica has +its own limiter state; this fixed-window implementation is a process-local abuse +control, not a globally coordinated quota system. Use a shared rate-limit store +or edge control plane when a cross-replica/global quota is required. + +Limiter numeric configuration is fail-closed. A malformed, infinite, negative, +or otherwise unsafe explicit value causes startup failure instead of silently +disabling protection or resetting windows on every request. Treat such a startup +failure as a configuration incident; correct the setting rather than removing +or bypassing the limiter gate. + ## Attachment status refresh operations The attachment-list API reads `job_id` in its initial project-scoped query and From 19da67325feeb8275e730c420cb3b7782db3945e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 01:58:13 -0700 Subject: [PATCH 016/120] test(security): cover IPv4-mapped trusted proxy peers --- tests/api/ratelimit.test.mjs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/api/ratelimit.test.mjs b/tests/api/ratelimit.test.mjs index ebf9edaf..d4b6c7c5 100644 --- a/tests/api/ratelimit.test.mjs +++ b/tests/api/ratelimit.test.mjs @@ -48,6 +48,36 @@ assert.equal( 'explicit zero keeps the documented disabled-limiter contract', ); +const mappedPeerProbe = spawnSync( + process.execPath, + [ + '--input-type=module', + '--eval', + `import assert from 'node:assert/strict'; + process.env.SCOPEWEAVE_DB = ':memory:'; + process.env.SCOPEWEAVE_JWT_SECRET = '${validJwtSecret}'; + process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '3'; + process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; + process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX = '20'; + process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS = '127.0.0.1'; + const { app } = await import('./server/app.mjs'); + const nodeEnv = { incoming: { socket: { remoteAddress: '::ffff:127.0.0.1' } } }; + const requestFrom = (client) => app.request('/api/health', { headers: { 'x-forwarded-for': client } }, nodeEnv); + for (let i = 0; i < 3; i++) assert.equal((await requestFrom('203.0.113.70')).status, 200); + assert.equal( + (await requestFrom('198.51.100.70')).status, + 200, + 'an IPv4-mapped Node peer must match the configured IPv4 trusted proxy and preserve separate client buckets', + );`, + ], + { cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } }, +); +assert.equal( + mappedPeerProbe.status, + 0, + `IPv4-mapped trusted-proxy regression failed:\n${mappedPeerProbe.stderr}`, +); + process.env.SCOPEWEAVE_DB = ':memory:'; process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '3'; process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX = '7'; From dec4e7a2e78c95491576bb35cb3818e5340a63c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 02:01:11 -0700 Subject: [PATCH 017/120] fix(security): canonicalize IPv4-mapped proxy peers --- server/app.mjs | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 69136b1d..e7f48f33 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -23,6 +23,26 @@ function parseSafeIntegerSetting(name, raw, fallback, minimum) { return value; } +/** + * Return a stable IP spelling for trust comparisons and limiter keys. + * + * Node can expose an IPv4 connection accepted by an IPv6 dual-stack listener + * as an IPv4-mapped address such as `::ffff:127.0.0.1`. Operators should be + * able to configure the actual IPv4 proxy address once, rather than having to + * predict the listener representation. Invalid values return null so they can + * never become trusted identities. + */ +function canonicalIp(value) { + const candidate = String(value ?? '').trim(); + const family = isIP(candidate); + if (family === 0) return null; + if (family === 6) { + const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(candidate); + if (mapped && isIP(mapped[1]) === 4) return mapped[1]; + } + return family === 6 ? candidate.toLowerCase() : candidate; +} + const configuredRateLimitMax = process.env.SCOPEWEAVE_RATE_LIMIT_MAX; const RL_MAX = parseSafeIntegerSetting('SCOPEWEAVE_RATE_LIMIT_MAX', configuredRateLimitMax, 0, 0); const RL_WINDOW_MS = parseSafeIntegerSetting( @@ -40,8 +60,8 @@ const RL_BUCKET_LIMIT = parseSafeIntegerSetting( const trustedProxyIps = new Set( String(process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS || '') .split(',') - .map((value) => value.trim()) - .filter((value) => isIP(value) !== 0) + .map(canonicalIp) + .filter(Boolean) ); const rlBuckets = new Map(); let overflowBucket; @@ -66,10 +86,8 @@ try { * requests deliberately share the fail-closed `local` bucket. */ function connectionPeerIp(c) { - const raw = c.env?.incoming?.socket?.remoteAddress; - if (typeof raw !== 'string') return 'local'; - const candidate = raw.trim(); - return isIP(candidate) ? candidate : 'local'; + const peer = canonicalIp(c.env?.incoming?.socket?.remoteAddress); + return peer || 'local'; } /** @@ -89,8 +107,8 @@ function rateLimitClientIp(c) { if (forwarded.length === 0) return peer; for (let i = forwarded.length - 1; i >= 0; i--) { - const hop = forwarded[i]; - if (!isIP(hop)) return peer; + const hop = canonicalIp(forwarded[i]); + if (!hop) return peer; if (!trustedProxyIps.has(hop)) return hop; } return peer; From a7516332d340770e918c8e211e08468115bf66d8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 02:03:13 -0700 Subject: [PATCH 018/120] docs(security): record mapped proxy peer normalization --- docs/doctoring/trusted-proxy-client-ip.md | 52 ++++++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/docs/doctoring/trusted-proxy-client-ip.md b/docs/doctoring/trusted-proxy-client-ip.md index 2726dc1b..38a9b2ec 100644 --- a/docs/doctoring/trusted-proxy-client-ip.md +++ b/docs/doctoring/trusted-proxy-client-ip.md @@ -4,7 +4,8 @@ **Active implementation evidence for PR #587.** This note records the security reasoning and primary/authoritative references for ScopeWeave's rate-limit -client-identity boundary. It does not claim certification. +client-identity boundary. It does not claim certification or protected-`develop` +shipment. ## Decision @@ -13,6 +14,16 @@ actually connected to the Node process. A caller-supplied `X-Forwarded-For` header is ignored unless that immediate peer is explicitly listed in `SCOPEWEAVE_TRUSTED_PROXY_IPS`. +Before trust comparison, ScopeWeave canonicalizes valid Node peer and forwarded +IP spellings. In particular, Node may expose an IPv4 connection accepted by an +IPv6 dual-stack listener as a dotted IPv4-mapped IPv6 address such as +`::ffff:127.0.0.1`. ScopeWeave maps that representation to the underlying IPv4 +address before trust lookup and limiter-key selection. An operator can therefore +configure the actual IPv4 ingress address once rather than having to predict +whether a deployment listener will surface `127.0.0.1` or +`::ffff:127.0.0.1`. Invalid address text is never normalized into a trusted +identity. Ordinary IPv6 peers remain IPv6 identities. + When the immediate peer is trusted, ScopeWeave parses the forwarding chain from right to left. Explicitly trusted proxy hops are skipped. The first untrusted, syntactically valid IP becomes the rate-limit identity. Missing, malformed, or @@ -32,9 +43,26 @@ that an Internet client can normally set itself as authenticated evidence. The operator contract in `docs/deploy.md` therefore requires direct access to the backend to be denied before proxy addresses are added to the trust list. +## Availability and isolation boundary + +Failing to recognize an IPv4-mapped spelling is not a useful fail-closed state. +It makes a legitimate trusted proxy look like an ordinary client, causing every +request behind that proxy to share the proxy-peer limiter bucket while the +forwarded client identity is ignored. One noisy tenant/client can then consume +another legitimate client's capacity. Canonicalizing the mapped peer before the +trust decision preserves the security prerequisite—only an explicitly trusted +transport peer unlocks forwarding evidence—while restoring per-client isolation +for dual-stack Node deployments. + +The mapping is intentionally narrow: only syntactically valid dotted +IPv4-mapped IPv6 values are collapsed to IPv4. This is the representation +observed from Node's dual-stack socket boundary and covered by the executable +regression; this repair does not broaden trust to arbitrary hostname, subnet, or +string aliases. + ## Acceptance trace -`tests/api/ratelimit.test.mjs` exercises both boundaries: +`tests/api/ratelimit.test.mjs` exercises the boundary: 1. in-process/untrusted requests exhaust one bucket even when the caller changes `X-Forwarded-For`; @@ -46,7 +74,10 @@ backend to be denied before proxy addresses are added to the trust list. consume the victim's rate-limit bucket; 6. trusted proxy hops are skipped right-to-left; 7. missing, malformed, and all-trusted forwarding evidence falls back to the - transport peer. + transport peer; and +8. a Node peer represented as `::ffff:127.0.0.1` matches an operator trust + configuration containing only `127.0.0.1`, so two forwarded clients retain + separate limiter buckets. The original bypass RED evidence was the hosted Server Tests failure at contributor head `c4a3b2f6429634c4165f7efdc71734384241e574`: the spoofed @@ -55,9 +86,18 @@ forwarding value returned `200` where the regression required `429`. The cross-client poisoning RED evidence was Server Tests run `32614096313` on the merge result containing contributor head `dc2fe13b1825975bc38459862f48e2c645351924`: a legitimate victim request -returned `429` where the regression required `200`. The subsequent product fix -is on the same contributor branch; exact-head gate acceptance remains separate -from this behavioral regression evidence. +returned `429` where the regression required `200`. + +The IPv4-mapped proxy RED is registered at contributor head +`19da67325feeb8275e730c420cb3b7782db3945e`. Server Tests run +`32629696310`, `unit-and-api` job `97170457703`, failed at the intended +regression because a second forwarded client received HTTP `429` instead of +`200` when the socket peer was `::ffff:127.0.0.1` but the trusted-proxy +configuration contained only `127.0.0.1`. Production repair +`dec4e7a2e78c95491576bb35cb3818e5340a63c9` canonicalizes the mapped peer and +forwarded-hop representations at the trust/key boundary. Fresh terminal evidence +for the final documentation head remains revision-specific and must not be +borrowed from predecessor or synthetic-only runs. ## References (APA 7) From dcbabf86c85cd031f29158f6efff5755a9857df2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 02:04:15 -0700 Subject: [PATCH 019/120] docs(ops): explain dual-stack proxy address normalization --- docs/deploy.md | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/docs/deploy.md b/docs/deploy.md index b04f654e..afcb196f 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -47,18 +47,25 @@ persists the database in the `scopeweave-data` volume. | `SCOPEWEAVE_RATE_LIMIT_MAX` | recommended | Per-client fixed-window request allowance. Unset or explicit `0` disables the limiter. Any other configured value must be a non-negative safe integer or startup fails. | | `SCOPEWEAVE_RATE_LIMIT_WINDOW_MS` | no (default 60000) | Fixed-window duration in milliseconds. An explicit value must be a positive safe integer or startup fails. | | `SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX` | no (default 10000) | Maximum number of live per-client limiter buckets held by one ScopeWeave process. An explicit value must be a positive safe integer or startup fails. Once capacity is reached, previously unseen identities share a fail-closed overflow bucket until expired regular buckets are reclaimed. | -| `SCOPEWEAVE_TRUSTED_PROXY_IPS` | only behind trusted reverse proxies | Comma-separated **immediate or chained proxy peer IPs** that ScopeWeave is allowed to trust when interpreting `X-Forwarded-For`. Leave unset for direct deployments. | +| `SCOPEWEAVE_TRUSTED_PROXY_IPS` | only behind trusted reverse proxies | Comma-separated **immediate or chained proxy peer IPs** that ScopeWeave is allowed to trust when interpreting `X-Forwarded-For`. Configure the actual IP once; dotted IPv4-mapped Node spellings such as `::ffff:127.0.0.1` are normalized to their IPv4 address before trust comparison. Leave unset for direct deployments. | ### Rate-limit capacity and tuning When the limiter is enabled, `429` responses include `Retry-After`. Client identity is anchored to the actual network peer unless that peer is explicitly -trusted through `SCOPEWEAVE_TRUSTED_PROXY_IPS`. The regular in-memory bucket map -is deliberately bounded by `SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX`; attacker-driven -high-cardinality source identities therefore cannot create unbounded limiter -state. At capacity, new identities use one shared overflow bucket rather than -allocating new map entries. Expired regular buckets are reclaimed by a bounded -sweep before admitting new identities. +trusted through `SCOPEWEAVE_TRUSTED_PROXY_IPS`. Valid dotted IPv4-mapped IPv6 +peer and forwarded-hop spellings are canonicalized to their underlying IPv4 +address before trust comparison and limiter-key selection. This prevents a +Node dual-stack listener from collapsing all proxied clients into one limiter +bucket merely because the socket exposed an IPv4 proxy as `::ffff:a.b.c.d`. +Invalid address text is never admitted as a trusted identity. + +The regular in-memory bucket map is deliberately bounded by +`SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX`; attacker-driven high-cardinality source +identities therefore cannot create unbounded limiter state. At capacity, new +identities use one shared overflow bucket rather than allocating new map entries. +Expired regular buckets are reclaimed by a bounded sweep before admitting new +identities. Size `SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX` for the maximum legitimate concurrent client-identity population expected **per process**, with headroom for normal @@ -165,10 +172,13 @@ needed. `X-Forwarded-For` is **ignored for the security rate-limit identity by default**. If a reverse proxy is the only permitted ingress to ScopeWeave, list its actual network peer address in `SCOPEWEAVE_TRUSTED_PROXY_IPS`. For multiple trusted -proxy hops, list every trusted hop. ScopeWeave then walks `X-Forwarded-For` from -right to left, skips explicitly trusted proxy addresses, and chooses the first -untrusted valid IP as the client identity. Missing, malformed, or all-trusted -forwarding evidence falls back to the actual socket peer. +proxy hops, list every trusted hop. You do not need to duplicate an IPv4 proxy +as both `a.b.c.d` and Node's dotted IPv4-mapped `::ffff:a.b.c.d` representation; +ScopeWeave canonicalizes that mapped socket/hop spelling before the trust +comparison. ScopeWeave then walks `X-Forwarded-For` from right to left, skips +explicitly trusted proxy addresses, and chooses the first untrusted valid IP as +the client identity. Missing, malformed, or all-trusted forwarding evidence +falls back to the actual socket peer. Do not configure this trust list while untrusted clients can connect directly to the backend. The proxy must overwrite or append forwarding information according From ba7554e9506a489de57dd08135cd171dc168cac9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 06:02:43 -0700 Subject: [PATCH 020/120] test: reproduce unsigned Stripe webhook plan escalation --- package.json | 2 +- tests/api/stripe-webhook.test.mjs | 135 ++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 tests/api/stripe-webhook.test.mjs diff --git a/package.json b/package.json index 8cefdc74..cd5e8503 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", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", diff --git a/tests/api/stripe-webhook.test.mjs b/tests/api/stripe-webhook.test.mjs new file mode 100644 index 00000000..68d549c5 --- /dev/null +++ b/tests/api/stripe-webhook.test.mjs @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import { createHmac } from 'node:crypto'; +import test from 'node:test'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +delete process.env.SCOPEWEAVE_DEV; +process.env.SCOPEWEAVE_PUBLIC_ORIGIN = 'https://scopeweave.example'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.STRIPE_SECRET_KEY = 'sk_test_scopeweave_webhook'; +process.env.STRIPE_PRICE_ID = 'price_scopeweave_webhook'; +process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_api_webhook_secret'; + +const { app } = await import('../../server/app.mjs?stripe-webhook-api-hotfix-test=1'); + +const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET; +const jsonHeaders = { 'content-type': 'application/json' }; + +function signatureHeader(body, timestamp = Math.floor(Date.now() / 1000)) { + const digest = createHmac('sha256', WEBHOOK_SECRET) + .update(String(timestamp)) + .update('.') + .update(body) + .digest('hex'); + return `t=${timestamp},v1=${digest}`; +} + +async function signupAndOrg() { + const signup = await app.request('https://scopeweave.example/api/auth/signup', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ + email: `webhook-${Date.now()}-${Math.random()}@example.test`, + password: 'password123', + name: 'Webhook Owner', + }), + }); + assert.equal(signup.status, 200); + const { token } = await signup.json(); + const me = await app.request('https://scopeweave.example/api/me', { + headers: { authorization: `Bearer ${token}` }, + }); + assert.equal(me.status, 200); + const body = await me.json(); + return { token, orgId: body.orgs[0].id }; +} + +async function currentPlan(token) { + const response = await app.request('https://scopeweave.example/api/me', { + headers: { authorization: `Bearer ${token}` }, + }); + assert.equal(response.status, 200); + return (await response.json()).orgs[0].plan; +} + +function checkoutCompletedBody(orgId) { + return JSON.stringify({ + id: `evt_checkout_${orgId}`, + type: 'checkout.session.completed', + data: { + object: { + id: `cs_test_${orgId}`, + client_reference_id: String(orgId), + metadata: { orgId: String(orgId) }, + }, + }, + }); +} + +test('unsigned Stripe webhook cannot upgrade an organization', async () => { + const { token, orgId } = await signupAndOrg(); + assert.equal(await currentPlan(token), 'free'); + + const body = checkoutCompletedBody(orgId); + const response = await app.request('https://scopeweave.example/api/stripe/webhook', { + method: 'POST', + headers: jsonHeaders, + body, + }); + + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: 'stripe_webhook_signature_invalid' }); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal(await currentPlan(token), 'free'); +}); + +test('verified webhook is acknowledged but does not grant entitlement before durable reconciliation', async () => { + const { token, orgId } = await signupAndOrg(); + const body = checkoutCompletedBody(orgId); + const response = await app.request('https://scopeweave.example/api/stripe/webhook', { + method: 'POST', + headers: { + ...jsonHeaders, + 'stripe-signature': signatureHeader(body), + }, + body, + }); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { received: true }); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal( + await currentPlan(token), + 'free', + 'authenticated delivery alone cannot bypass durable duplicate/order/provider-state reconciliation', + ); +}); + +test('stale signed delivery and raw-body mutation fail before entitlement state changes', async () => { + const { token, orgId } = await signupAndOrg(); + const body = checkoutCompletedBody(orgId); + const staleTimestamp = Math.floor(Date.now() / 1000) - 301; + + let response = await app.request('https://scopeweave.example/api/stripe/webhook', { + method: 'POST', + headers: { + ...jsonHeaders, + 'stripe-signature': signatureHeader(body, staleTimestamp), + }, + body, + }); + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: 'stripe_webhook_signature_invalid' }); + + response = await app.request('https://scopeweave.example/api/stripe/webhook', { + method: 'POST', + headers: { + ...jsonHeaders, + 'stripe-signature': signatureHeader(body), + }, + body: `${body}\n`, + }); + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: 'stripe_webhook_signature_invalid' }); + assert.equal(await currentPlan(token), 'free'); +}); From 042830d8536063b1761549f4d6ced8362b3f900d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 06:07:15 -0700 Subject: [PATCH 021/120] fix: authenticate Stripe webhooks before entitlement handling --- package.json | 6 +- server/app.mjs | 1427 +----------------- server/application_routes.mjs | 1410 +++++++++++++++++ server/stripe_webhook.mjs | 224 +++ tests/unit/coverage-script-contract.test.mjs | 26 + tests/unit/stripe-webhook-boundary.test.mjs | 193 +++ 6 files changed, 1883 insertions(+), 1403 deletions(-) create mode 100644 server/application_routes.mjs create mode 100644 server/stripe_webhook.mjs create mode 100644 tests/unit/stripe-webhook-boundary.test.mjs diff --git a/package.json b/package.json index cd5e8503..7ef291dc 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", - "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/stripe-webhook-boundary.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/application_routes.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && 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 c432a84f..742bc47b 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1,1410 +1,37 @@ -// 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'; - +import { app as applicationRoutes } from './application_routes.mjs'; +import { StripeWebhookError, verifyStripeWebhookRequest } from './stripe_webhook.mjs'; + +/** + * Public ScopeWeave HTTP application. + * + * The protected-develop route graph remains byte-for-byte in + * `application_routes.mjs`. This entry point owns the emergency Stripe webhook + * trust boundary so unsigned provider-shaped JSON cannot reach the historical + * entitlement mutation while the full durable #488 reconciliation stack is + * still integrating. All unrelated routes delegate unchanged. + */ 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 }); -}); +// Keep the shipped cloud-toast asset on the public entry path while delegating +// its existing implementation to the protected route graph. +app.get('/toast-state.css', (c) => applicationRoutes.fetch(c.req.raw)); -// Remove a member (owner/admin). Cannot remove an owner. -app.delete('/api/orgs/:id/members/:userId', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - const targetId = c.req.param('userId'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const target = db.prepare('SELECT role FROM memberships WHERE org_id = ? AND user_id = ?').get(orgId, targetId); - if (!target) return c.json({ error: 'not found' }, 404); - if (target.role === 'owner') return c.json({ error: 'cannot remove owner' }, 403); - db.prepare('DELETE FROM memberships WHERE org_id = ? AND user_id = ?').run(orgId, targetId); - logAudit(orgId, uid, 'member.remove', 'user', targetId, { role: target.role }); - return c.json({ ok: true }); -}); - -// Leave a workspace voluntarily (any non-owner member). Owners must transfer or -// delete the org instead — an org can never be left ownerless. -app.post('/api/orgs/:id/leave', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - const role = orgRole(uid, orgId); - if (!role) return c.json({ error: 'not found' }, 404); - if (role === 'owner') return c.json({ error: 'owner cannot leave; delete the workspace or transfer ownership' }, 403); - db.prepare('DELETE FROM memberships WHERE org_id = ? AND user_id = ?').run(orgId, uid); - logAudit(orgId, uid, 'member.leave', 'user', uid, { role }); - return c.json({ ok: true }); -}); - -// Transfer workspace ownership to an existing member (owner only). The old -// owner becomes an admin; orgs.owner_id follows. Transactional. -app.post('/api/orgs/:id/transfer', requireAuth, async (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); - const { userId } = await c.req.json().catch(() => ({})); - if (!userId || Number(userId) === Number(uid)) return c.json({ error: 'target member userId required' }, 400); - const target = db.prepare('SELECT role FROM memberships WHERE org_id = ? AND user_id = ?').get(orgId, userId); - if (!target) return c.json({ error: 'target is not a member' }, 404); - db.exec('BEGIN'); - try { - db.prepare("UPDATE memberships SET role = 'owner' WHERE org_id = ? AND user_id = ?").run(orgId, userId); - db.prepare("UPDATE memberships SET role = 'admin' WHERE org_id = ? AND user_id = ?").run(orgId, uid); - db.prepare('UPDATE orgs SET owner_id = ? WHERE id = ?').run(userId, orgId); - db.exec('COMMIT'); - } catch (e) { db.exec('ROLLBACK'); throw e; } - logAudit(orgId, uid, 'org.transfer', 'user', userId, { from: uid }); - return c.json({ ok: true, newOwnerId: Number(userId) }); -}); - -// Rename a workspace (owner only). -app.patch('/api/orgs/:id', requireAuth, async (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); - const { name } = await c.req.json().catch(() => ({})); - if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); - db.prepare('UPDATE orgs SET name = ? WHERE id = ?').run(String(name).trim().slice(0, 120), orgId); - logAudit(orgId, uid, 'org.rename', 'org', orgId, { name }); - return c.json({ id: Number(orgId), name: String(name).trim() }); -}); - -// ------------------------------------------------------------------- billing -app.get('/api/orgs/:id/billing', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); - const org = getOrg(orgId); - const plan = planOf(org); - return c.json({ plan: org.plan, planName: plan.name, priceKrw: plan.priceKrw, limits: plan.limits, usage: orgUsage(db, orgId) }); -}); - -app.post('/api/orgs/:id/checkout', requireAuth, async (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'only the owner can upgrade' }, 403); - const origin = new URL(c.req.url).origin; - const session = await createCheckout({ orgId, origin }); - return c.json(session); -}); - -// Stripe webhook (stub). Live mode should verify the signature with -// STRIPE_WEBHOOK_SECRET before trusting the event — named ceiling. app.post('/api/stripe/webhook', async (c) => { - const event = await c.req.json().catch(() => ({})); - if (event?.type === 'checkout.session.completed') { - const orgId = event.data?.object?.client_reference_id || event.data?.object?.metadata?.orgId; - if (orgId) db.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").run(orgId); - } - return c.json({ received: true }); -}); - -// Dev-only: simulate a successful checkout upgrading the org to Pro. -// Disabled unless SCOPEWEAVE_DEV=1 (never reachable in production). -app.post('/api/orgs/:id/_dev/activate-pro', requireAuth, (c) => { - if (process.env.SCOPEWEAVE_DEV !== '1') return c.json({ error: 'not found' }, 404); - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); - db.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").run(orgId); - logAudit(orgId, uid, 'billing.upgrade', 'org', orgId, { plan: 'pro', via: 'dev' }); - deliver(orgId, 'billing.upgrade', { plan: 'pro' }); - return c.json({ plan: 'pro' }); -}); - -// ------------------------------------------------- personal access tokens (PAT) -app.get('/api/tokens', requireAuth, (c) => { - const uid = c.get('user').sub; - const tokens = db.prepare( - 'SELECT id, name, prefix, last_used AS lastUsed, created_at AS createdAt FROM api_tokens WHERE user_id = ? ORDER BY id DESC' - ).all(uid); - return c.json({ tokens }); // never the secret or hash -}); - -app.post('/api/tokens', requireAuth, async (c) => { - const uid = c.get('user').sub; - const { name } = await c.req.json().catch(() => ({})); - const t = generateApiToken(); - const id = rowid(db.prepare('INSERT INTO api_tokens(user_id,name,token_hash,prefix) VALUES(?,?,?,?)') - .run(uid, String(name || 'token').slice(0, 60), t.hash, t.prefix)); - // Full secret returned ONCE — never retrievable again. - return c.json({ id, name: name || 'token', prefix: t.prefix, token: t.full }); -}); - -app.delete('/api/tokens/:id', requireAuth, (c) => { - const uid = c.get('user').sub; - const info = db.prepare('DELETE FROM api_tokens WHERE id = ? AND user_id = ?').run(c.req.param('id'), uid); - if (!info.changes) return c.json({ error: 'not found' }, 404); - return c.json({ ok: true }); -}); - -// Audit trail — owner/admin only. Enterprise requirement. -app.get('/api/orgs/:id/audit', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const limit = Math.min(Number(c.req.query('limit')) || 100, 500); - const rows = db.prepare( - `SELECT a.id, a.action, a.target_type AS targetType, a.target_id AS targetId, a.meta, - a.created_at AS createdAt, u.email AS actorEmail - FROM audit_log a LEFT JOIN users u ON u.id = a.user_id - WHERE a.org_id = ? ORDER BY a.id DESC LIMIT ?` - ).all(orgId, limit); - const events = rows.map((r) => ({ ...r, meta: r.meta ? JSON.parse(r.meta) : null })); - if (c.req.query('format') === 'csv') { - // Compliance deliverable. Formula-injection-safe: values that (after optional - // leading whitespace) start with = + - @ | are prefixed with ' so - // spreadsheets treat them as text. Leading whitespace alone used to bypass - // /^[=+\-@|]/ — match the client-side CSV_FORMULA_PREFIX_PATTERN. - const csvCell = (v) => { - let s = v == null ? '' : String(v); - if (/^\s*[=+\-@|]/.test(s)) s = `'${s}`; - return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; - }; - const header = ['id', 'createdAt', 'actorEmail', 'action', 'targetType', 'targetId', 'meta']; - const lines = [header.join(',')]; - for (const e of events) { - lines.push([e.id, e.createdAt, e.actorEmail, e.action, e.targetType, e.targetId, e.meta ? JSON.stringify(e.meta) : ''].map(csvCell).join(',')); - } - return c.text(lines.join('\r\n') + '\r\n', 200, { - 'content-type': 'text/csv; charset=utf-8', - 'content-disposition': `attachment; filename="scopeweave-audit-${orgId}.csv"`, - }); - } - return c.json({ events }); -}); - -// Full workspace export (owner only) — data portability / GDPR. Everything the -// org holds, as one JSON document. -app.get('/api/orgs/:id/export', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'only the owner can export' }, 403); - const org = getOrg(orgId); - const members = db.prepare( - `SELECT u.email, u.name, m.role FROM memberships m JOIN users u ON u.id = m.user_id WHERE m.org_id = ?` - ).all(orgId); - const projects = db.prepare( - 'SELECT id, name, base_date AS baseDate, tasks_json, version, created_at AS createdAt, updated_at AS updatedAt FROM projects WHERE org_id = ?' - ).all(orgId).map((p) => ({ ...p, tasks: JSON.parse(p.tasks_json), tasks_json: undefined })); - const audit = db.prepare( - 'SELECT action, target_type AS targetType, target_id AS targetId, meta, created_at AS createdAt FROM audit_log WHERE org_id = ? ORDER BY id' - ).all(orgId).map((a) => ({ ...a, meta: a.meta ? JSON.parse(a.meta) : null })); - logAudit(orgId, uid, 'org.export', 'org', orgId, { projects: projects.length }); - return c.json({ - exportedAt: new Date().toISOString(), - org: { id: org.id, name: org.name, plan: org.plan }, - members, projects, audit, - }, 200, { 'Content-Disposition': `attachment; filename="scopeweave-org-${orgId}.json"` }); -}); - -// Operational metrics (JSON). Ceiling: expose Prometheus text format + gate -// behind an internal token before prod if scraped externally. -app.get('/api/metrics', (c) => { - const sseActive = [...streams.values()].reduce((n, s) => n + s.size, 0); - const all = { ...metrics, sseActive, uptimeSec: Math.round(process.uptime()) }; - if (c.req.query('format') !== 'prometheus') return c.json(all); - // Prometheus text exposition format (0.0.4) — scrape-ready for Grafana/Alerting. - const gauge = new Set(['sseActive', 'uptimeSec']); - const lines = []; - for (const [k, v] of Object.entries(all)) { - if (typeof v !== 'number') continue; // startedAt etc. - const name = `scopeweave_${k.replace(/([A-Z])/g, '_$1').toLowerCase()}`; - lines.push(`# TYPE ${name} ${gauge.has(k) ? 'gauge' : 'counter'}`, `${name} ${v}`); - } - return c.text(lines.join('\n') + '\n', 200, { 'content-type': 'text/plain; version=0.0.4; charset=utf-8' }); -}); - -// ------------------------------------------------------------------- webhooks -app.get('/api/orgs/:id/webhooks', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const webhooks = db.prepare( - `SELECT w.id, w.url, w.events, w.active, w.created_at AS createdAt, - (SELECT ok FROM webhook_deliveries d WHERE d.webhook_id = w.id ORDER BY d.id DESC LIMIT 1) AS lastOk, - (SELECT created_at FROM webhook_deliveries d WHERE d.webhook_id = w.id ORDER BY d.id DESC LIMIT 1) AS lastAt - FROM webhooks w WHERE w.org_id = ? ORDER BY w.id DESC` - ).all(orgId); // secret never returned - return c.json({ webhooks }); -}); - -app.post('/api/orgs/:id/webhooks', requireAuth, async (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const { url, events } = await c.req.json().catch(() => ({})); - if (!/^https?:\/\//.test(String(url || ''))) return c.json({ error: 'valid http(s) url required' }, 400); - const secret = `whsec_${randomBytes(24).toString('base64url')}`; - const evs = Array.isArray(events) ? events.join(',') : (events || '*'); - const id = rowid(db.prepare('INSERT INTO webhooks(org_id,url,secret,events) VALUES(?,?,?,?)').run(orgId, url, secret, evs)); - logAudit(orgId, uid, 'webhook.create', 'webhook', id, { url, events: evs }); - return c.json({ id, url, events: evs, secret }); // secret shown once for signature verification -}); - -app.get('/api/orgs/:id/webhooks/:whId/deliveries', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const wh = db.prepare('SELECT id FROM webhooks WHERE id = ? AND org_id = ?').get(c.req.param('whId'), orgId); - if (!wh) return c.json({ error: 'not found' }, 404); - const deliveries = db.prepare( - 'SELECT event, status_code AS statusCode, ok, attempt, created_at AS createdAt FROM webhook_deliveries WHERE webhook_id = ? ORDER BY id DESC LIMIT 50' - ).all(wh.id); - return c.json({ deliveries }); -}); - -// Rotate a webhook's signing secret (leak response / periodic hygiene). The new -// secret is returned ONCE; old signatures stop validating immediately. -app.post('/api/orgs/:id/webhooks/:whId/rotate', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const secret = `whsec_${randomBytes(24).toString('base64url')}`; - const info = db.prepare('UPDATE webhooks SET secret = ? WHERE id = ? AND org_id = ?').run(secret, c.req.param('whId'), orgId); - if (!info.changes) return c.json({ error: 'not found' }, 404); - logAudit(orgId, uid, 'webhook.rotate', 'webhook', c.req.param('whId'), {}); - return c.json({ id: Number(c.req.param('whId')), secret }); // shown once -}); - -app.delete('/api/orgs/:id/webhooks/:whId', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const info = db.prepare('DELETE FROM webhooks WHERE id = ? AND org_id = ?').run(c.req.param('whId'), orgId); - if (!info.changes) return c.json({ error: 'not found' }, 404); - return c.json({ ok: true }); -}); - -// ------------------------------------------------------------ SSO (OIDC) -// Real IdP via env (OIDC_ISSUER/CLIENT_ID/CLIENT_SECRET/REDIRECT_URI). When -// unset, a built-in mock provider makes the whole flow self-contained + testable. -const OIDC = { - issuer: process.env.OIDC_ISSUER, - clientId: process.env.OIDC_CLIENT_ID, - clientSecret: process.env.OIDC_CLIENT_SECRET, - redirectUri: process.env.OIDC_REDIRECT_URI, -}; -const oidcMock = !OIDC.issuer; -const oidcStates = new Map(); // state -> { verifier, exp } -const oidcCodes = new Map(); // mock only: code -> email - -function upsertSsoUser(email) { - let user = db.prepare('SELECT id, email, token_version FROM users WHERE email = ?').get(email); - if (user) return user; - db.exec('BEGIN'); try { - const uid = rowid(db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') - .run(email, hashPassword(randomBytes(24).toString('hex')), '')); - const oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)').run(`${email}'s workspace`, uid)); - db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); - db.exec('COMMIT'); - metrics.signups++; - return { id: uid, email }; - } catch (e) { db.exec('ROLLBACK'); throw e; } -} - -app.get('/api/auth/oidc/start', (c) => { - const origin = new URL(c.req.url).origin; - const state = randomBytes(16).toString('hex'); - const verifier = randomBytes(32).toString('base64url'); - const challenge = createHash('sha256').update(verifier).digest('base64url'); - oidcStates.set(state, { verifier, exp: Date.now() + 5 * 60 * 1000 }); - const redirectUri = OIDC.redirectUri || `${origin}/api/auth/oidc/callback`; - if (oidcMock) { - const email = c.req.query('email') || 'sso-user@example.com'; - const u = new URL(`${origin}/api/auth/oidc/mock/authorize`); - u.searchParams.set('state', state); - u.searchParams.set('email', email); - u.searchParams.set('redirect_uri', redirectUri); - return c.redirect(u.toString()); - } - const u = new URL(`${OIDC.issuer.replace(/\/$/, '')}/authorize`); - u.searchParams.set('client_id', OIDC.clientId); - u.searchParams.set('redirect_uri', redirectUri); - u.searchParams.set('response_type', 'code'); - u.searchParams.set('scope', 'openid email profile'); - u.searchParams.set('state', state); - u.searchParams.set('code_challenge', challenge); - u.searchParams.set('code_challenge_method', 'S256'); - return c.redirect(u.toString()); -}); - -// Built-in mock IdP authorize — instantly issues a code (dev/test only). -app.get('/api/auth/oidc/mock/authorize', (c) => { - if (!oidcMock) return c.json({ error: 'mock disabled' }, 404); - const state = c.req.query('state'); - const email = c.req.query('email'); - const redirectUri = c.req.query('redirect_uri'); - const code = randomBytes(16).toString('hex'); - oidcCodes.set(code, email); - const u = new URL(redirectUri); - u.searchParams.set('code', code); - u.searchParams.set('state', state); - return c.redirect(u.toString()); -}); - -app.get('/api/auth/oidc/callback', async (c) => { - const state = c.req.query('state'); - const code = c.req.query('code'); - const s = oidcStates.get(state); - if (!s || s.exp < Date.now()) return c.json({ error: 'invalid or expired state' }, 400); - oidcStates.delete(state); - let email; - if (oidcMock) { - email = oidcCodes.get(code); - oidcCodes.delete(code); - if (!email) return c.json({ error: 'invalid code' }, 400); - } else { - const redirectUri = OIDC.redirectUri || `${new URL(c.req.url).origin}/api/auth/oidc/callback`; - const tokenRes = await fetch(`${OIDC.issuer.replace(/\/$/, '')}/token`, { - method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: OIDC.clientId, client_secret: OIDC.clientSecret, code_verifier: s.verifier }), - }).catch(() => null); - const tok = tokenRes ? await tokenRes.json().catch(() => ({})) : {}; - if (!tok.id_token) return c.json({ error: 'token exchange failed' }, 400); - // Ceiling: verify the id_token signature via the issuer JWKS before prod. - const claims = JSON.parse(Buffer.from(String(tok.id_token).split('.')[1] || '', 'base64url').toString() || '{}'); - email = claims.email; - if (!email) return c.json({ error: 'no email claim' }, 400); - } - const user = upsertSsoUser(email); - const token = signToken({ sub: user.id, email, tv: user.token_version || 0 }); - // Return the token in the URL fragment (not query → not logged); the client - // stores it and cleans the URL. - return c.redirect(`/#token=${token}`); -}); - -// Cross-project search: project names + task names, membership-scoped (tenant -// isolation via the same JOIN as projectAccess). -// ponytail: LIKE over tasks_json text; move to FTS5 if search gets heavy. -app.get('/api/search', requireAuth, (c) => { - const uid = c.get('user').sub; - const q = String(c.req.query('q') || '').trim(); - if (q.length < 2) return c.json({ error: 'query too short (min 2)' }, 400); - const rows = db.prepare( - `SELECT DISTINCT p.id, p.name, p.tasks_json FROM projects p - JOIN memberships m ON m.org_id = p.org_id - WHERE m.user_id = ? AND (p.name LIKE ? OR p.tasks_json LIKE ?) LIMIT 100` - ).all(uid, `%${q}%`, `%${q}%`); - const needle = q.toLowerCase(); - const results = []; - for (const p of rows) { - const hit = { projectId: p.id, projectName: p.name, tasks: [] }; - if (p.name.toLowerCase().includes(needle)) hit.nameMatch = true; - let tasks = []; - try { tasks = JSON.parse(p.tasks_json); } catch { /* skip bad json */ } - for (const t of tasks) { - if (String(t.name || '').toLowerCase().includes(needle)) { - hit.tasks.push({ id: t.id, name: t.name }); - if (hit.tasks.length >= 5) break; - } - } - if (hit.nameMatch || hit.tasks.length) results.push(hit); - if (results.length >= 20) break; - } - return c.json({ query: q, results }); -}); - -// Portfolio dashboard: executive rollup across every project in a workspace — -// weighted planned/actual progress, SPI + status, overdue-task counts. -app.get('/api/orgs/:id/portfolio', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); - const today = new Date().toISOString().slice(0, 10); - const rows = db.prepare( - 'SELECT id, name, base_date AS baseDate, tasks_json, archived, updated_at AS updatedAt FROM projects WHERE org_id = ? ORDER BY archived ASC, updated_at DESC' - ).all(orgId); - const projects = rows.map((p) => { - let tasks = []; - try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } - let wSum = 0, pv = 0, ev = 0, overdue = 0; - for (const t of tasks) { - const w = Number(t.weight) || 1; - wSum += w; - pv += w * ((Number(t.plannedProgress) || 0) / 100); - ev += w * ((Number(t.actualProgress) || 0) / 100); - if (t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100) overdue++; - } - const evm = computeEvm({ pv: wSum ? pv / wSum : 0, ev: wSum ? ev / wSum : 0 }); - return { - id: p.id, - name: p.name, - archived: Boolean(p.archived), - tasks: tasks.length, - planned: Math.round(evm.pv * 1000) / 10, // % - actual: Math.round(evm.ev * 1000) / 10, // % - spi: evm.spi === null ? null : Math.round(evm.spi * 100) / 100, - status: evm.status, - label: evm.label, - overdue, - updatedAt: p.updatedAt, - }; - }); - return c.json({ projects }); -}); - -// AI 브리핑: 프로젝트 스냅샷(요약 지표 + 지연/차주 작업)을 contextual- -// orchestrator(LLM)로 보내 경영진용 리스크 분석을 생성. 원문 데이터는 서버가 -// 요약해 전송하며, LLM 자격은 서버 환경변수에만 존재. -app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - let tasks = []; - try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } - const today = new Date().toISOString().slice(0, 10); - let wSum = 0, pv = 0, ev = 0; - const late = [], upcoming = []; - for (const t of tasks) { - const w = Number(t.weight) || 1; - wSum += w; - pv += w * ((Number(t.plannedProgress) || 0) / 100); - ev += w * ((Number(t.actualProgress) || 0) / 100); - const name = t.name || t.task || t.activity || t.phase || t.id; - if (t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100) { - late.push(`${name}(계획종료 ${t.plannedEndDate}, 실적 ${Number(t.actualProgress) || 0}%${t.owner ? `, ${t.owner}` : ''})`); - } else if (t.plannedStartDate && t.plannedStartDate >= today) { - upcoming.push(`${name}(${t.plannedStartDate} 시작)`); - } - } - const pvPct = wSum ? ((pv / wSum) * 100).toFixed(1) : '0'; - const evPct = wSum ? ((ev / wSum) * 100).toFixed(1) : '0'; - const context = [ - `프로젝트: ${p.name}`, - `작업 수: ${tasks.length} · 계획진척 ${pvPct}% · 실적진척 ${evPct}%`, - `지연 작업(${late.length}): ${late.slice(0, 8).join(' / ') || '없음'}`, - `예정 작업(${upcoming.length}): ${upcoming.slice(0, 5).join(' / ') || '없음'}`, - ].join('\n'); - try { - const analysis = await orchestratorChat([ - { role: 'system', content: '너는 공정관리(schedule control) 전문가다. 주어진 프로젝트 지표를 근거로 한국어 경영진 브리핑을 작성하라: ①일정 상태 한 줄 판정 ②핵심 리스크 2~3개(근거 지표 인용) ③실행 권고 2~3개. 지표에 없는 사실은 만들지 마라.' }, - { role: 'user', content: context }, - ], { - service: 'scopeweave', - account: String(p.org_id), + await verifyStripeWebhookRequest(c.req.raw, { + secret: process.env.STRIPE_WEBHOOK_SECRET, }); - 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); + // Signature validity authenticates the delivery only. Until durable event + // deduplication and provider-state reconciliation integrate, webhook JSON is + // not authority to mutate orgs.plan or any other entitlement state. + return c.json({ received: true }, 200, { 'Cache-Control': 'no-store' }); + } catch (error) { + if (error instanceof StripeWebhookError) { + return c.json({ error: error.code }, error.status, { 'Cache-Control': 'no-store' }); + } + return c.json({ error: 'stripe_webhook_unavailable' }, 500, { 'Cache-Control': 'no-store' }); } - 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(); - } -}); +app.route('/', applicationRoutes); diff --git a/server/application_routes.mjs b/server/application_routes.mjs new file mode 100644 index 00000000..c432a84f --- /dev/null +++ b/server/application_routes.mjs @@ -0,0 +1,1410 @@ +// ScopeWeave SaaS API. Multi-tenant (org-scoped), optimistic concurrency on +// project docs, SSE realtime fan-out per project. The existing static client +// (index.html/app.js) becomes the frontend that talks to these routes. +import { Hono } from 'hono'; +import { readFile } from 'node:fs/promises'; +import { randomBytes, createHmac, createHash } from 'node:crypto'; +import { db, rowid } from './db.mjs'; +import { hashPassword, verifyPassword, signToken, verifyToken, generateApiToken, hashApiToken } from './auth.mjs'; +import { PLANS, planOf, orgUsage, wouldExceed, createCheckout } from './billing.mjs'; +import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs'; +import { normalizeAttachmentStatusBudgetMs, normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs'; +import { chat as orchestratorChat } from './orchestrator.mjs'; +import { computeEvm } from '../analytics.js'; // pure math, shared with the client + +const getOrg = (id) => db.prepare('SELECT * FROM orgs WHERE id = ?').get(id); + +// Append-only audit trail. Never throws into the request path. +function logAudit(orgId, userId, action, targetType, targetId, meta) { + try { + db.prepare('INSERT INTO audit_log(org_id,user_id,action,target_type,target_id,meta) VALUES(?,?,?,?,?,?)') + .run(orgId, userId ?? null, action, targetType ?? null, targetId != null ? String(targetId) : null, meta ? JSON.stringify(meta) : null); + } catch { /* audit must not break the operation */ } +} + +// --- RBAC. Roles (highest→lowest): owner > admin > member > viewer. +const orgRole = (userId, orgId) => + db.prepare('SELECT role FROM memberships WHERE user_id = ? AND org_id = ?').get(userId, orgId)?.role || null; +const canManage = (role) => role === 'owner' || role === 'admin'; +const canWrite = (role) => role === 'owner' || role === 'admin' || role === 'member'; + +export const app = new Hono(); + +async function requireAuth(c, next) { + const header = c.req.header('authorization') || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : ''; + // Personal Access Token path (swk_...): look up by hash, act as its user. + if (token.startsWith('swk_')) { + const row = db.prepare('SELECT * FROM api_tokens WHERE token_hash = ?').get(hashApiToken(token)); + if (!row) return c.json({ error: 'unauthorized' }, 401); + db.prepare("UPDATE api_tokens SET last_used = datetime('now') WHERE id = ?").run(row.id); + c.set('user', { sub: row.user_id, viaPat: true }); + return next(); + } + try { + const payload = verifyToken(token); + // Session revocation: a bumped token_version invalidates all older JWTs. + const u = db.prepare('SELECT token_version FROM users WHERE id = ?').get(payload.sub); + if (!u || (payload.tv || 0) !== u.token_version) return c.json({ error: 'unauthorized' }, 401); + c.set('user', payload); + } catch { + return c.json({ error: 'unauthorized' }, 401); + } + await next(); +} + +// --- realtime: projectId -> Set +const streams = new Map(); +function broadcast(projectId, data) { + const subs = streams.get(String(projectId)); + if (!subs) return; + const chunk = new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`); + for (const ctrl of subs) { + try { ctrl.enqueue(chunk); } catch { /* dropped subscriber */ } + } +} + +// Membership-scoped project fetch — the tenant isolation boundary. +function projectAccess(userId, projectId) { + return db.prepare( + `SELECT p.*, m.role AS memberRole FROM projects p + JOIN memberships m ON m.org_id = p.org_id + WHERE p.id = ? AND m.user_id = ?` + ).get(projectId, userId); +} + +// --- observability: in-process counters + structured request log. +const metrics = { + startedAt: new Date().toISOString(), + requests: 0, + s2xx: 0, + s4xx: 0, + s5xx: 0, + signups: 0, + projectsCreated: 0, + webhookDeliveries: 0, + attachmentStatusRefreshAttempted: 0, + attachmentStatusRefreshChanged: 0, + attachmentStatusRefreshFailed: 0, + attachmentStatusRefreshDeferred: 0, +}; + +// Outbound webhooks: POST signed JSON to each active hook subscribed to `event`. +// Fire-and-forget with a timeout, one retry on failure, and a recorded outcome +// per attempt — never blocks or fails the triggering request. +function recordDelivery(webhookId, event, status, ok, attempt) { + try { + db.prepare('INSERT INTO webhook_deliveries(webhook_id,event,status_code,ok,attempt) VALUES(?,?,?,?,?)') + .run(webhookId, event, status ?? null, ok ? 1 : 0, attempt); + } catch { /* recording must not break delivery */ } +} + +function sendWebhook(webhookId, url, sig, event, body, attempt) { + metrics.webhookDeliveries++; + const ctrl = new AbortController(); + const to = setTimeout(() => ctrl.abort(), 3000); + fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-scopeweave-event': event, 'x-scopeweave-signature': `sha256=${sig}` }, + body, + signal: ctrl.signal, + }).then((res) => { + recordDelivery(webhookId, event, res.status, res.ok, attempt); + if (!res.ok && attempt < 2) setTimeout(() => sendWebhook(webhookId, url, sig, event, body, attempt + 1), 500); + }).catch(() => { + recordDelivery(webhookId, event, null, false, attempt); + if (attempt < 2) setTimeout(() => sendWebhook(webhookId, url, sig, event, body, attempt + 1), 500); + }).finally(() => clearTimeout(to)); +} + +function deliver(orgId, event, payload) { + let hooks; + try { + hooks = db.prepare('SELECT id, url, secret, events FROM webhooks WHERE org_id = ? AND active = 1').all(orgId); + } catch { return; } + for (const h of hooks) { + const subs = String(h.events || '').split(',').map((s) => s.trim()); + if (!(subs.includes('*') || subs.includes(event))) continue; + const body = JSON.stringify({ event, orgId: Number(orgId), payload, ts: new Date().toISOString() }); + const sig = createHmac('sha256', h.secret).update(body).digest('hex'); + sendWebhook(h.id, h.url, sig, event, body, 1); + } +} +const quietLogs = String(process.env.SCOPEWEAVE_DB || '').includes(':memory:'); // silence during tests +app.use('*', async (c, next) => { + const t = Date.now(); + await next(); + try { + metrics.requests++; + const s = c.res.status; + if (s >= 500) metrics.s5xx++; else if (s >= 400) metrics.s4xx++; else if (s >= 200) metrics.s2xx++; + if (!quietLogs) { + // structured; never logs bodies, tokens, or secrets + console.log(JSON.stringify({ ts: new Date().toISOString(), method: c.req.method, path: c.req.path, status: s, ms: Date.now() - t })); + } + } catch { /* metrics/logging must never break a request */ } +}); + +// Rate limiting (opt-in via SCOPEWEAVE_RATE_LIMIT_MAX, per client IP, fixed +// window). Protects against brute-force/abuse. Off by default so it never +// surprises tests/dev. Ceiling: per-instance in-memory → use Redis for multi-node. +const RL_MAX = Number(process.env.SCOPEWEAVE_RATE_LIMIT_MAX) || 0; +const RL_WINDOW_MS = Number(process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS) || 60000; +const rlBuckets = new Map(); +if (RL_MAX > 0) { + app.use('*', async (c, next) => { + const key = (c.req.header('x-forwarded-for') || '').split(',')[0].trim() || 'local'; + const now = Date.now(); + let b = rlBuckets.get(key); + if (!b || b.resetAt <= now) { b = { count: 0, resetAt: now + RL_WINDOW_MS }; rlBuckets.set(key, b); } + b.count++; + if (b.count > RL_MAX) { + const retry = Math.ceil((b.resetAt - now) / 1000); + return c.json({ error: 'rate limit exceeded' }, 429, { 'Retry-After': String(retry) }); + } + await next(); + }); +} + +app.post('/api/auth/signup', async (c) => { + const { email, password, name } = await c.req.json().catch(() => ({})); + if (!email || typeof password !== 'string' || password.length < 8) { + return c.json({ error: 'email and password (min 8 chars) required' }, 400); + } + if (db.prepare('SELECT id FROM users WHERE email = ?').get(email)) { + return c.json({ error: 'email already registered' }, 409); + } + // user + personal workspace + owner membership, atomically. + let uid; + const tx = () => { + uid = rowid(db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') + .run(email, hashPassword(password), name || '')); + const oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)') + .run(`${name || email}'s workspace`, uid)); + db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); + }; + db.exec('BEGIN'); + try { tx(); db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw e; } + metrics.signups++; + return c.json({ token: signToken({ sub: uid, email, tv: 0 }) }); +}); + +app.post('/api/auth/login', async (c) => { + const { email, password } = await c.req.json().catch(() => ({})); + const u = db.prepare('SELECT * FROM users WHERE email = ?').get(email || ''); + // Pass password through only when it is a string — verifyPassword rejects + // non-strings (objects/arrays) so they never match an empty-password hash. + if (!u || typeof password !== 'string' || !verifyPassword(password, u.password_hash)) { + return c.json({ error: 'invalid credentials' }, 401); + } + return c.json({ token: signToken({ sub: u.id, email: u.email, tv: u.token_version }) }); +}); + +app.get('/api/me', requireAuth, (c) => { + const uid = c.get('user').sub; + const user = db.prepare('SELECT id,email,name FROM users WHERE id = ?').get(uid); + const orgs = db.prepare( + `SELECT o.id,o.name,o.plan,m.role FROM orgs o + JOIN memberships m ON m.org_id = o.id WHERE m.user_id = ?` + ).all(uid); + return c.json({ user, orgs }); +}); + +// Create an additional workspace (org); the creator becomes its owner. +app.post('/api/orgs', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { name } = await c.req.json().catch(() => ({})); + if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); + let oid; + db.exec('BEGIN'); + try { + oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)').run(String(name).trim().slice(0, 120), uid)); + db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); + db.exec('COMMIT'); + } catch (e) { db.exec('ROLLBACK'); throw e; } + logAudit(oid, uid, 'org.create', 'org', oid, { name }); + return c.json({ id: oid, name: String(name).trim(), role: 'owner' }); +}); + +app.get('/api/projects', requireAuth, (c) => { + const uid = c.get('user').sub; + const projects = db.prepare( + `SELECT p.id,p.name,p.base_date AS baseDate,p.version,p.org_id AS orgId,p.updated_at AS updatedAt,p.archived + FROM projects p JOIN memberships m ON m.org_id = p.org_id + WHERE m.user_id = ? ORDER BY p.archived ASC, p.updated_at DESC` + ).all(uid); + return c.json({ projects }); +}); + +app.post('/api/projects', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { name, orgId } = await c.req.json().catch(() => ({})); + if (!name) return c.json({ error: 'name required' }, 400); + const org = orgId + ? db.prepare('SELECT o.id FROM orgs o JOIN memberships m ON m.org_id = o.id WHERE o.id = ? AND m.user_id = ?').get(orgId, uid) + : db.prepare('SELECT o.id FROM orgs o JOIN memberships m ON m.org_id = o.id WHERE m.user_id = ? ORDER BY o.id LIMIT 1').get(uid); + if (!org) return c.json({ error: 'no accessible org' }, 400); + if (wouldExceed(db, getOrg(org.id), 'projects')) { + return c.json({ error: 'project limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.projects }, 402); + } + const id = rowid(db.prepare('INSERT INTO projects(org_id,name,created_by) VALUES(?,?,?)').run(org.id, name, uid)); + metrics.projectsCreated++; + logAudit(org.id, uid, 'project.create', 'project', id, { name }); + return c.json({ id, name, version: 1 }); +}); + +app.get('/api/projects/:id', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + return c.json({ id: p.id, name: p.name, orgId: p.org_id, baseDate: p.base_date, methodology: p.methodology || 'waterfall', tasks: JSON.parse(p.tasks_json), version: p.version }); +}); + +app.put('/api/projects/:id', requireAuth, async (c) => { + const uid = c.get('user').sub; + const id = c.req.param('id'); + const p = projectAccess(uid, id); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden: viewer role is read-only' }, 403); + const body = await c.req.json().catch(() => ({})); + if (typeof body.version === 'number' && body.version !== p.version) { + return c.json({ error: 'version conflict', current: p.version }, 409); + } + const tasks = Array.isArray(body.tasks) ? body.tasks : JSON.parse(p.tasks_json); + const version = p.version + 1; + const methodology = ['waterfall', 'agile', 'hybrid'].includes(body.methodology) ? body.methodology : (p.methodology || 'waterfall'); + db.prepare( + "UPDATE projects SET name=?, base_date=?, tasks_json=?, version=?, methodology=?, updated_at=datetime('now') WHERE id=?" + ).run(body.name ?? p.name, body.baseDate ?? p.base_date, JSON.stringify(tasks), version, methodology, id); + logAudit(p.org_id, uid, 'project.update', 'project', id, { version, tasks: tasks.length }); + // Revision history: snapshot every save, keep the last 20 per project. + try { + db.prepare('INSERT OR REPLACE INTO project_revisions(project_id,version,name,base_date,tasks_json,saved_by) VALUES(?,?,?,?,?,?)') + .run(id, version, body.name ?? p.name, body.baseDate ?? p.base_date, JSON.stringify(tasks), uid); + db.prepare('DELETE FROM project_revisions WHERE project_id = ? AND version <= ?').run(id, version - 20); + } catch { /* history must not break saves */ } + deliver(p.org_id, 'project.update', { projectId: Number(id), version, tasks: tasks.length, by: uid }); + broadcast(id, { type: 'update', version, by: uid }); + return c.json({ version }); +}); + +// Task comments: discussion bound to a project (optionally a task). All roles +// can read; write roles can post; author or manage can delete. +app.get('/api/projects/:id/comments', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const taskId = c.req.query('taskId'); + const comments = (taskId + ? db.prepare(`SELECT cm.id, cm.task_id AS taskId, cm.body, cm.created_at AS createdAt, cm.user_id AS userId, u.email + FROM comments cm LEFT JOIN users u ON u.id = cm.user_id + WHERE cm.project_id = ? AND cm.task_id = ? ORDER BY cm.id DESC LIMIT 100`).all(p.id, taskId) + : db.prepare(`SELECT cm.id, cm.task_id AS taskId, cm.body, cm.created_at AS createdAt, cm.user_id AS userId, u.email + FROM comments cm LEFT JOIN users u ON u.id = cm.user_id + WHERE cm.project_id = ? ORDER BY cm.id DESC LIMIT 100`).all(p.id)); + return c.json({ comments }); +}); + +app.post('/api/projects/:id/comments', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const { taskId, body } = await c.req.json().catch(() => ({})); + const text = String(body || '').trim(); + if (!text) return c.json({ error: 'body required' }, 400); + if (text.length > 2000) return c.json({ error: 'comment too long (max 2000)' }, 400); + const cid = rowid(db.prepare('INSERT INTO comments(project_id,task_id,user_id,body) VALUES(?,?,?,?)') + .run(p.id, String(taskId || ''), uid, text)); + logAudit(p.org_id, uid, 'comment.create', 'project', p.id, { commentId: cid, taskId: taskId || null }); + broadcast(p.id, { type: 'comment', commentId: cid, by: uid }); + return c.json({ id: cid }); +}); + +app.delete('/api/projects/:id/comments/:cid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const cm = db.prepare('SELECT user_id FROM comments WHERE id = ? AND project_id = ?').get(c.req.param('cid'), p.id); + if (!cm) return c.json({ error: 'not found' }, 404); + if (cm.user_id !== uid && !canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + db.prepare('DELETE FROM comments WHERE id = ?').run(c.req.param('cid')); + return c.json({ ok: true }); +}); + +// Revision history: list, inspect, restore. +app.get('/api/projects/:id/revisions', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const revisions = db.prepare( + `SELECT r.version, r.created_at AS savedAt, u.email AS savedBy FROM project_revisions r + LEFT JOIN users u ON u.id = r.saved_by WHERE r.project_id = ? ORDER BY r.version DESC` + ).all(p.id); + return c.json({ revisions }); +}); + +app.get('/api/projects/:id/revisions/:version', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const r = db.prepare('SELECT version, name, base_date AS baseDate, tasks_json FROM project_revisions WHERE project_id = ? AND version = ?') + .get(p.id, c.req.param('version')); + if (!r) return c.json({ error: 'not found' }, 404); + return c.json({ version: r.version, name: r.name, baseDate: r.baseDate, tasks: JSON.parse(r.tasks_json) }); +}); + +// Restore = write the old snapshot as a NEW version (history stays linear). +app.post('/api/projects/:id/revisions/:version/restore', requireAuth, (c) => { + const uid = c.get('user').sub; + const id = c.req.param('id'); + const p = projectAccess(uid, id); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const r = db.prepare('SELECT name, base_date, tasks_json FROM project_revisions WHERE project_id = ? AND version = ?') + .get(id, c.req.param('version')); + if (!r) return c.json({ error: 'not found' }, 404); + const version = p.version + 1; + db.prepare("UPDATE projects SET name=?, base_date=?, tasks_json=?, version=?, updated_at=datetime('now') WHERE id=?") + .run(r.name, r.base_date, r.tasks_json, version, id); + try { + db.prepare('INSERT OR REPLACE INTO project_revisions(project_id,version,name,base_date,tasks_json,saved_by) VALUES(?,?,?,?,?,?)') + .run(id, version, r.name, r.base_date, r.tasks_json, uid); + } catch { /* history must not break restore */ } + logAudit(p.org_id, uid, 'project.restore', 'project', id, { from: Number(c.req.param('version')), version }); + broadcast(id, { type: 'update', version, by: uid }); + return c.json({ version }); +}); + +// iCalendar feed: planned tasks as all-day VEVENTs — subscribable from +// Google/Outlook. Calendar apps can't send headers, so accept ?token= (same +// pattern + ceiling as /stream). PATs work via the Authorization header. +app.get('/api/projects/:id/calendar.ics', (c) => { + const header = c.req.header('authorization') || ''; + const raw = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); + let uid; + if (raw.startsWith('swk_')) { + const row = db.prepare('SELECT user_id FROM api_tokens WHERE token_hash = ?').get(hashApiToken(raw)); + if (!row) return c.json({ error: 'unauthorized' }, 401); + uid = row.user_id; + } else { + try { uid = verifyToken(raw).sub; } catch { return c.json({ error: 'unauthorized' }, 401); } + } + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + let tasks = []; + try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } + const day = (s) => String(s).replaceAll('-', ''); + const nextDay = (s) => { const d = new Date(s); d.setDate(d.getDate() + 1); return d.toISOString().slice(0, 10).replaceAll('-', ''); }; + const esc = (s) => String(s).replace(/\\/g, '\\\\').replace(/[,;]/g, (m) => `\\${m}`).replace(/\n/g, '\\n'); + const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//ScopeWeave//KO', 'CALSCALE:GREGORIAN', `X-WR-CALNAME:${esc(p.name)}`]; + for (const t of tasks) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(t.plannedStartDate || '') || !/^\d{4}-\d{2}-\d{2}$/.test(t.plannedEndDate || '')) continue; + lines.push( + 'BEGIN:VEVENT', + `UID:scopeweave-${p.id}-${esc(t.id)}`, + `DTSTART;VALUE=DATE:${day(t.plannedStartDate)}`, + `DTEND;VALUE=DATE:${nextDay(t.plannedEndDate)}`, // DTEND is exclusive + `SUMMARY:${esc(t.name || t.task || t.id)}`, + 'END:VEVENT' + ); + } + lines.push('END:VCALENDAR'); + return c.text(lines.join('\r\n') + '\r\n', 200, { + 'content-type': 'text/calendar; charset=utf-8', + 'content-disposition': `attachment; filename="scopeweave-${p.id}.ics"`, + }); +}); + +app.get('/api/projects/:id/stream', (c) => { + // EventSource can't send an Authorization header, so accept a query token + // here only. Ceiling: issue a short-lived stream-scoped token before prod so + // full JWTs don't land in URLs / access logs. + const header = c.req.header('authorization') || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); + let user; + try { user = verifyToken(token); } catch { return c.json({ error: 'unauthorized' }, 401); } + const id = c.req.param('id'); + if (!projectAccess(user.sub, id)) return c.json({ error: 'not found' }, 404); + const key = String(id); + const stream = new ReadableStream({ + start(controller) { + if (!streams.has(key)) streams.set(key, new Set()); + streams.get(key).add(controller); + controller.enqueue(new TextEncoder().encode(': connected\n\n')); + c.req.raw.signal?.addEventListener('abort', () => { + streams.get(key)?.delete(controller); + try { controller.close(); } catch { /* already closed */ } + }); + }, + }); + return new Response(stream, { + headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }, + }); +}); + +// --------------------------------------------------------------- teams / RBAC +// List members of an org (any member may view the roster). +app.get('/api/orgs/:id/members', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); + const members = db.prepare( + `SELECT u.id, u.email, u.name, m.role FROM memberships m + JOIN users u ON u.id = m.user_id WHERE m.org_id = ? ORDER BY m.id` + ).all(orgId); + const invites = db.prepare( + `SELECT id, email, role, token, created_at AS createdAt FROM invites + WHERE org_id = ? AND accepted_at IS NULL ORDER BY id DESC` + ).all(orgId); + return c.json({ members, invites }); +}); + +// Revoke a pending invite (owner/admin). The token stops working immediately. +app.delete('/api/orgs/:id/invites/:inviteId', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('DELETE FROM invites WHERE id = ? AND org_id = ? AND accepted_at IS NULL') + .run(c.req.param('inviteId'), orgId); + if (!info.changes) return c.json({ error: 'not found' }, 404); + logAudit(orgId, uid, 'invite.revoke', 'invite', c.req.param('inviteId'), {}); + return c.json({ ok: true }); +}); + +// Invite by email (owner/admin only). Returns the token (prod: email a link). +app.post('/api/orgs/:id/invites', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + const role = orgRole(uid, orgId); + if (!role) return c.json({ error: 'not found' }, 404); + if (!canManage(role)) return c.json({ error: 'forbidden' }, 403); + const body = await c.req.json().catch(() => ({})); + const email = String(body.email || '').trim().toLowerCase(); + const inviteRole = body.role || 'member'; + if (!email) return c.json({ error: 'email required' }, 400); + if (!['admin', 'member', 'viewer'].includes(inviteRole)) return c.json({ error: 'invalid role' }, 400); + const token = randomBytes(24).toString('base64url'); + db.prepare('INSERT INTO invites(org_id,email,role,token,invited_by) VALUES(?,?,?,?,?)') + .run(orgId, email, inviteRole, token, uid); + logAudit(orgId, uid, 'member.invite', 'invite', email, { role: inviteRole }); + return c.json({ token, email, role: inviteRole }); +}); + +// Accept an invite (any authenticated user holding the token). Idempotent. +app.post('/api/invites/:token/accept', requireAuth, (c) => { + const uid = c.get('user').sub; + const inv = db.prepare('SELECT * FROM invites WHERE token = ?').get(c.req.param('token')); + if (!inv || inv.accepted_at) return c.json({ error: 'invalid or used invite' }, 404); + const existing = orgRole(uid, inv.org_id); + if (!existing) { + if (wouldExceed(db, getOrg(inv.org_id), 'members')) { + return c.json({ error: 'member limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.members }, 402); + } + db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(inv.org_id, uid, inv.role); + logAudit(inv.org_id, uid, 'member.join', 'user', uid, { role: inv.role }); + deliver(inv.org_id, 'member.join', { userId: uid, role: inv.role }); + } + db.prepare("UPDATE invites SET accepted_at = datetime('now') WHERE id = ?").run(inv.id); + return c.json({ orgId: inv.org_id, role: existing || inv.role }); +}); + +// Change a member's role (owner/admin). Cannot touch an owner or set owner. +app.patch('/api/orgs/:id/members/:userId', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + const targetId = c.req.param('userId'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const body = await c.req.json().catch(() => ({})); + const newRole = body.role; + if (!['admin', 'member', 'viewer'].includes(newRole)) return c.json({ error: 'invalid role' }, 400); + const target = db.prepare('SELECT role FROM memberships WHERE org_id = ? AND user_id = ?').get(orgId, targetId); + if (!target) return c.json({ error: 'not found' }, 404); + if (target.role === 'owner') return c.json({ error: 'cannot change owner role' }, 403); + db.prepare('UPDATE memberships SET role = ? WHERE org_id = ? AND user_id = ?').run(newRole, orgId, targetId); + logAudit(orgId, uid, 'member.role_change', 'user', targetId, { from: target.role, to: newRole }); + return c.json({ userId: Number(targetId), role: newRole }); +}); + +// Remove a member (owner/admin). Cannot remove an owner. +app.delete('/api/orgs/:id/members/:userId', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + const targetId = c.req.param('userId'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const target = db.prepare('SELECT role FROM memberships WHERE org_id = ? AND user_id = ?').get(orgId, targetId); + if (!target) return c.json({ error: 'not found' }, 404); + if (target.role === 'owner') return c.json({ error: 'cannot remove owner' }, 403); + db.prepare('DELETE FROM memberships WHERE org_id = ? AND user_id = ?').run(orgId, targetId); + logAudit(orgId, uid, 'member.remove', 'user', targetId, { role: target.role }); + return c.json({ ok: true }); +}); + +// Leave a workspace voluntarily (any non-owner member). Owners must transfer or +// delete the org instead — an org can never be left ownerless. +app.post('/api/orgs/:id/leave', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + const role = orgRole(uid, orgId); + if (!role) return c.json({ error: 'not found' }, 404); + if (role === 'owner') return c.json({ error: 'owner cannot leave; delete the workspace or transfer ownership' }, 403); + db.prepare('DELETE FROM memberships WHERE org_id = ? AND user_id = ?').run(orgId, uid); + logAudit(orgId, uid, 'member.leave', 'user', uid, { role }); + return c.json({ ok: true }); +}); + +// Transfer workspace ownership to an existing member (owner only). The old +// owner becomes an admin; orgs.owner_id follows. Transactional. +app.post('/api/orgs/:id/transfer', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); + const { userId } = await c.req.json().catch(() => ({})); + if (!userId || Number(userId) === Number(uid)) return c.json({ error: 'target member userId required' }, 400); + const target = db.prepare('SELECT role FROM memberships WHERE org_id = ? AND user_id = ?').get(orgId, userId); + if (!target) return c.json({ error: 'target is not a member' }, 404); + db.exec('BEGIN'); + try { + db.prepare("UPDATE memberships SET role = 'owner' WHERE org_id = ? AND user_id = ?").run(orgId, userId); + db.prepare("UPDATE memberships SET role = 'admin' WHERE org_id = ? AND user_id = ?").run(orgId, uid); + db.prepare('UPDATE orgs SET owner_id = ? WHERE id = ?').run(userId, orgId); + db.exec('COMMIT'); + } catch (e) { db.exec('ROLLBACK'); throw e; } + logAudit(orgId, uid, 'org.transfer', 'user', userId, { from: uid }); + return c.json({ ok: true, newOwnerId: Number(userId) }); +}); + +// Rename a workspace (owner only). +app.patch('/api/orgs/:id', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); + const { name } = await c.req.json().catch(() => ({})); + if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); + db.prepare('UPDATE orgs SET name = ? WHERE id = ?').run(String(name).trim().slice(0, 120), orgId); + logAudit(orgId, uid, 'org.rename', 'org', orgId, { name }); + return c.json({ id: Number(orgId), name: String(name).trim() }); +}); + +// ------------------------------------------------------------------- billing +app.get('/api/orgs/:id/billing', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); + const org = getOrg(orgId); + const plan = planOf(org); + return c.json({ plan: org.plan, planName: plan.name, priceKrw: plan.priceKrw, limits: plan.limits, usage: orgUsage(db, orgId) }); +}); + +app.post('/api/orgs/:id/checkout', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'only the owner can upgrade' }, 403); + const origin = new URL(c.req.url).origin; + const session = await createCheckout({ orgId, origin }); + return c.json(session); +}); + +// Stripe webhook (stub). Live mode should verify the signature with +// STRIPE_WEBHOOK_SECRET before trusting the event — named ceiling. +app.post('/api/stripe/webhook', async (c) => { + const event = await c.req.json().catch(() => ({})); + if (event?.type === 'checkout.session.completed') { + const orgId = event.data?.object?.client_reference_id || event.data?.object?.metadata?.orgId; + if (orgId) db.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").run(orgId); + } + return c.json({ received: true }); +}); + +// Dev-only: simulate a successful checkout upgrading the org to Pro. +// Disabled unless SCOPEWEAVE_DEV=1 (never reachable in production). +app.post('/api/orgs/:id/_dev/activate-pro', requireAuth, (c) => { + if (process.env.SCOPEWEAVE_DEV !== '1') return c.json({ error: 'not found' }, 404); + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); + db.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").run(orgId); + logAudit(orgId, uid, 'billing.upgrade', 'org', orgId, { plan: 'pro', via: 'dev' }); + deliver(orgId, 'billing.upgrade', { plan: 'pro' }); + return c.json({ plan: 'pro' }); +}); + +// ------------------------------------------------- personal access tokens (PAT) +app.get('/api/tokens', requireAuth, (c) => { + const uid = c.get('user').sub; + const tokens = db.prepare( + 'SELECT id, name, prefix, last_used AS lastUsed, created_at AS createdAt FROM api_tokens WHERE user_id = ? ORDER BY id DESC' + ).all(uid); + return c.json({ tokens }); // never the secret or hash +}); + +app.post('/api/tokens', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { name } = await c.req.json().catch(() => ({})); + const t = generateApiToken(); + const id = rowid(db.prepare('INSERT INTO api_tokens(user_id,name,token_hash,prefix) VALUES(?,?,?,?)') + .run(uid, String(name || 'token').slice(0, 60), t.hash, t.prefix)); + // Full secret returned ONCE — never retrievable again. + return c.json({ id, name: name || 'token', prefix: t.prefix, token: t.full }); +}); + +app.delete('/api/tokens/:id', requireAuth, (c) => { + const uid = c.get('user').sub; + const info = db.prepare('DELETE FROM api_tokens WHERE id = ? AND user_id = ?').run(c.req.param('id'), uid); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + +// Audit trail — owner/admin only. Enterprise requirement. +app.get('/api/orgs/:id/audit', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const limit = Math.min(Number(c.req.query('limit')) || 100, 500); + const rows = db.prepare( + `SELECT a.id, a.action, a.target_type AS targetType, a.target_id AS targetId, a.meta, + a.created_at AS createdAt, u.email AS actorEmail + FROM audit_log a LEFT JOIN users u ON u.id = a.user_id + WHERE a.org_id = ? ORDER BY a.id DESC LIMIT ?` + ).all(orgId, limit); + const events = rows.map((r) => ({ ...r, meta: r.meta ? JSON.parse(r.meta) : null })); + if (c.req.query('format') === 'csv') { + // Compliance deliverable. Formula-injection-safe: values that (after optional + // leading whitespace) start with = + - @ | are prefixed with ' so + // spreadsheets treat them as text. Leading whitespace alone used to bypass + // /^[=+\-@|]/ — match the client-side CSV_FORMULA_PREFIX_PATTERN. + const csvCell = (v) => { + let s = v == null ? '' : String(v); + if (/^\s*[=+\-@|]/.test(s)) s = `'${s}`; + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + }; + const header = ['id', 'createdAt', 'actorEmail', 'action', 'targetType', 'targetId', 'meta']; + const lines = [header.join(',')]; + for (const e of events) { + lines.push([e.id, e.createdAt, e.actorEmail, e.action, e.targetType, e.targetId, e.meta ? JSON.stringify(e.meta) : ''].map(csvCell).join(',')); + } + return c.text(lines.join('\r\n') + '\r\n', 200, { + 'content-type': 'text/csv; charset=utf-8', + 'content-disposition': `attachment; filename="scopeweave-audit-${orgId}.csv"`, + }); + } + return c.json({ events }); +}); + +// Full workspace export (owner only) — data portability / GDPR. Everything the +// org holds, as one JSON document. +app.get('/api/orgs/:id/export', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'only the owner can export' }, 403); + const org = getOrg(orgId); + const members = db.prepare( + `SELECT u.email, u.name, m.role FROM memberships m JOIN users u ON u.id = m.user_id WHERE m.org_id = ?` + ).all(orgId); + const projects = db.prepare( + 'SELECT id, name, base_date AS baseDate, tasks_json, version, created_at AS createdAt, updated_at AS updatedAt FROM projects WHERE org_id = ?' + ).all(orgId).map((p) => ({ ...p, tasks: JSON.parse(p.tasks_json), tasks_json: undefined })); + const audit = db.prepare( + 'SELECT action, target_type AS targetType, target_id AS targetId, meta, created_at AS createdAt FROM audit_log WHERE org_id = ? ORDER BY id' + ).all(orgId).map((a) => ({ ...a, meta: a.meta ? JSON.parse(a.meta) : null })); + logAudit(orgId, uid, 'org.export', 'org', orgId, { projects: projects.length }); + return c.json({ + exportedAt: new Date().toISOString(), + org: { id: org.id, name: org.name, plan: org.plan }, + members, projects, audit, + }, 200, { 'Content-Disposition': `attachment; filename="scopeweave-org-${orgId}.json"` }); +}); + +// Operational metrics (JSON). Ceiling: expose Prometheus text format + gate +// behind an internal token before prod if scraped externally. +app.get('/api/metrics', (c) => { + const sseActive = [...streams.values()].reduce((n, s) => n + s.size, 0); + const all = { ...metrics, sseActive, uptimeSec: Math.round(process.uptime()) }; + if (c.req.query('format') !== 'prometheus') return c.json(all); + // Prometheus text exposition format (0.0.4) — scrape-ready for Grafana/Alerting. + const gauge = new Set(['sseActive', 'uptimeSec']); + const lines = []; + for (const [k, v] of Object.entries(all)) { + if (typeof v !== 'number') continue; // startedAt etc. + const name = `scopeweave_${k.replace(/([A-Z])/g, '_$1').toLowerCase()}`; + lines.push(`# TYPE ${name} ${gauge.has(k) ? 'gauge' : 'counter'}`, `${name} ${v}`); + } + return c.text(lines.join('\n') + '\n', 200, { 'content-type': 'text/plain; version=0.0.4; charset=utf-8' }); +}); + +// ------------------------------------------------------------------- webhooks +app.get('/api/orgs/:id/webhooks', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const webhooks = db.prepare( + `SELECT w.id, w.url, w.events, w.active, w.created_at AS createdAt, + (SELECT ok FROM webhook_deliveries d WHERE d.webhook_id = w.id ORDER BY d.id DESC LIMIT 1) AS lastOk, + (SELECT created_at FROM webhook_deliveries d WHERE d.webhook_id = w.id ORDER BY d.id DESC LIMIT 1) AS lastAt + FROM webhooks w WHERE w.org_id = ? ORDER BY w.id DESC` + ).all(orgId); // secret never returned + return c.json({ webhooks }); +}); + +app.post('/api/orgs/:id/webhooks', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const { url, events } = await c.req.json().catch(() => ({})); + if (!/^https?:\/\//.test(String(url || ''))) return c.json({ error: 'valid http(s) url required' }, 400); + const secret = `whsec_${randomBytes(24).toString('base64url')}`; + const evs = Array.isArray(events) ? events.join(',') : (events || '*'); + const id = rowid(db.prepare('INSERT INTO webhooks(org_id,url,secret,events) VALUES(?,?,?,?)').run(orgId, url, secret, evs)); + logAudit(orgId, uid, 'webhook.create', 'webhook', id, { url, events: evs }); + return c.json({ id, url, events: evs, secret }); // secret shown once for signature verification +}); + +app.get('/api/orgs/:id/webhooks/:whId/deliveries', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const wh = db.prepare('SELECT id FROM webhooks WHERE id = ? AND org_id = ?').get(c.req.param('whId'), orgId); + if (!wh) return c.json({ error: 'not found' }, 404); + const deliveries = db.prepare( + 'SELECT event, status_code AS statusCode, ok, attempt, created_at AS createdAt FROM webhook_deliveries WHERE webhook_id = ? ORDER BY id DESC LIMIT 50' + ).all(wh.id); + return c.json({ deliveries }); +}); + +// Rotate a webhook's signing secret (leak response / periodic hygiene). The new +// secret is returned ONCE; old signatures stop validating immediately. +app.post('/api/orgs/:id/webhooks/:whId/rotate', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const secret = `whsec_${randomBytes(24).toString('base64url')}`; + const info = db.prepare('UPDATE webhooks SET secret = ? WHERE id = ? AND org_id = ?').run(secret, c.req.param('whId'), orgId); + if (!info.changes) return c.json({ error: 'not found' }, 404); + logAudit(orgId, uid, 'webhook.rotate', 'webhook', c.req.param('whId'), {}); + return c.json({ id: Number(c.req.param('whId')), secret }); // shown once +}); + +app.delete('/api/orgs/:id/webhooks/:whId', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('DELETE FROM webhooks WHERE id = ? AND org_id = ?').run(c.req.param('whId'), orgId); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + +// ------------------------------------------------------------ SSO (OIDC) +// Real IdP via env (OIDC_ISSUER/CLIENT_ID/CLIENT_SECRET/REDIRECT_URI). When +// unset, a built-in mock provider makes the whole flow self-contained + testable. +const OIDC = { + issuer: process.env.OIDC_ISSUER, + clientId: process.env.OIDC_CLIENT_ID, + clientSecret: process.env.OIDC_CLIENT_SECRET, + redirectUri: process.env.OIDC_REDIRECT_URI, +}; +const oidcMock = !OIDC.issuer; +const oidcStates = new Map(); // state -> { verifier, exp } +const oidcCodes = new Map(); // mock only: code -> email + +function upsertSsoUser(email) { + let user = db.prepare('SELECT id, email, token_version FROM users WHERE email = ?').get(email); + if (user) return user; + db.exec('BEGIN'); + try { + const uid = rowid(db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') + .run(email, hashPassword(randomBytes(24).toString('hex')), '')); + const oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)').run(`${email}'s workspace`, uid)); + db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); + db.exec('COMMIT'); + metrics.signups++; + return { id: uid, email }; + } catch (e) { db.exec('ROLLBACK'); throw e; } +} + +app.get('/api/auth/oidc/start', (c) => { + const origin = new URL(c.req.url).origin; + const state = randomBytes(16).toString('hex'); + const verifier = randomBytes(32).toString('base64url'); + const challenge = createHash('sha256').update(verifier).digest('base64url'); + oidcStates.set(state, { verifier, exp: Date.now() + 5 * 60 * 1000 }); + const redirectUri = OIDC.redirectUri || `${origin}/api/auth/oidc/callback`; + if (oidcMock) { + const email = c.req.query('email') || 'sso-user@example.com'; + const u = new URL(`${origin}/api/auth/oidc/mock/authorize`); + u.searchParams.set('state', state); + u.searchParams.set('email', email); + u.searchParams.set('redirect_uri', redirectUri); + return c.redirect(u.toString()); + } + const u = new URL(`${OIDC.issuer.replace(/\/$/, '')}/authorize`); + u.searchParams.set('client_id', OIDC.clientId); + u.searchParams.set('redirect_uri', redirectUri); + u.searchParams.set('response_type', 'code'); + u.searchParams.set('scope', 'openid email profile'); + u.searchParams.set('state', state); + u.searchParams.set('code_challenge', challenge); + u.searchParams.set('code_challenge_method', 'S256'); + return c.redirect(u.toString()); +}); + +// Built-in mock IdP authorize — instantly issues a code (dev/test only). +app.get('/api/auth/oidc/mock/authorize', (c) => { + if (!oidcMock) return c.json({ error: 'mock disabled' }, 404); + const state = c.req.query('state'); + const email = c.req.query('email'); + const redirectUri = c.req.query('redirect_uri'); + const code = randomBytes(16).toString('hex'); + oidcCodes.set(code, email); + const u = new URL(redirectUri); + u.searchParams.set('code', code); + u.searchParams.set('state', state); + return c.redirect(u.toString()); +}); + +app.get('/api/auth/oidc/callback', async (c) => { + const state = c.req.query('state'); + const code = c.req.query('code'); + const s = oidcStates.get(state); + if (!s || s.exp < Date.now()) return c.json({ error: 'invalid or expired state' }, 400); + oidcStates.delete(state); + let email; + if (oidcMock) { + email = oidcCodes.get(code); + oidcCodes.delete(code); + if (!email) return c.json({ error: 'invalid code' }, 400); + } else { + const redirectUri = OIDC.redirectUri || `${new URL(c.req.url).origin}/api/auth/oidc/callback`; + const tokenRes = await fetch(`${OIDC.issuer.replace(/\/$/, '')}/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: OIDC.clientId, client_secret: OIDC.clientSecret, code_verifier: s.verifier }), + }).catch(() => null); + const tok = tokenRes ? await tokenRes.json().catch(() => ({})) : {}; + if (!tok.id_token) return c.json({ error: 'token exchange failed' }, 400); + // Ceiling: verify the id_token signature via the issuer JWKS before prod. + const claims = JSON.parse(Buffer.from(String(tok.id_token).split('.')[1] || '', 'base64url').toString() || '{}'); + email = claims.email; + if (!email) return c.json({ error: 'no email claim' }, 400); + } + const user = upsertSsoUser(email); + const token = signToken({ sub: user.id, email, tv: user.token_version || 0 }); + // Return the token in the URL fragment (not query → not logged); the client + // stores it and cleans the URL. + return c.redirect(`/#token=${token}`); +}); + +// Cross-project search: project names + task names, membership-scoped (tenant +// isolation via the same JOIN as projectAccess). +// ponytail: LIKE over tasks_json text; move to FTS5 if search gets heavy. +app.get('/api/search', requireAuth, (c) => { + const uid = c.get('user').sub; + const q = String(c.req.query('q') || '').trim(); + if (q.length < 2) return c.json({ error: 'query too short (min 2)' }, 400); + const rows = db.prepare( + `SELECT DISTINCT p.id, p.name, p.tasks_json FROM projects p + JOIN memberships m ON m.org_id = p.org_id + WHERE m.user_id = ? AND (p.name LIKE ? OR p.tasks_json LIKE ?) LIMIT 100` + ).all(uid, `%${q}%`, `%${q}%`); + const needle = q.toLowerCase(); + const results = []; + for (const p of rows) { + const hit = { projectId: p.id, projectName: p.name, tasks: [] }; + if (p.name.toLowerCase().includes(needle)) hit.nameMatch = true; + let tasks = []; + try { tasks = JSON.parse(p.tasks_json); } catch { /* skip bad json */ } + for (const t of tasks) { + if (String(t.name || '').toLowerCase().includes(needle)) { + hit.tasks.push({ id: t.id, name: t.name }); + if (hit.tasks.length >= 5) break; + } + } + if (hit.nameMatch || hit.tasks.length) results.push(hit); + if (results.length >= 20) break; + } + return c.json({ query: q, results }); +}); + +// Portfolio dashboard: executive rollup across every project in a workspace — +// weighted planned/actual progress, SPI + status, overdue-task counts. +app.get('/api/orgs/:id/portfolio', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); + const today = new Date().toISOString().slice(0, 10); + const rows = db.prepare( + 'SELECT id, name, base_date AS baseDate, tasks_json, archived, updated_at AS updatedAt FROM projects WHERE org_id = ? ORDER BY archived ASC, updated_at DESC' + ).all(orgId); + const projects = rows.map((p) => { + let tasks = []; + try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } + let wSum = 0, pv = 0, ev = 0, overdue = 0; + for (const t of tasks) { + const w = Number(t.weight) || 1; + wSum += w; + pv += w * ((Number(t.plannedProgress) || 0) / 100); + ev += w * ((Number(t.actualProgress) || 0) / 100); + if (t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100) overdue++; + } + const evm = computeEvm({ pv: wSum ? pv / wSum : 0, ev: wSum ? ev / wSum : 0 }); + return { + id: p.id, + name: p.name, + archived: Boolean(p.archived), + tasks: tasks.length, + planned: Math.round(evm.pv * 1000) / 10, // % + actual: Math.round(evm.ev * 1000) / 10, // % + spi: evm.spi === null ? null : Math.round(evm.spi * 100) / 100, + status: evm.status, + label: evm.label, + overdue, + updatedAt: p.updatedAt, + }; + }); + return c.json({ projects }); +}); + +// AI 브리핑: 프로젝트 스냅샷(요약 지표 + 지연/차주 작업)을 contextual- +// orchestrator(LLM)로 보내 경영진용 리스크 분석을 생성. 원문 데이터는 서버가 +// 요약해 전송하며, LLM 자격은 서버 환경변수에만 존재. +app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + let tasks = []; + try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } + const today = new Date().toISOString().slice(0, 10); + let wSum = 0, pv = 0, ev = 0; + const late = [], upcoming = []; + for (const t of tasks) { + const w = Number(t.weight) || 1; + wSum += w; + pv += w * ((Number(t.plannedProgress) || 0) / 100); + ev += w * ((Number(t.actualProgress) || 0) / 100); + const name = t.name || t.task || t.activity || t.phase || t.id; + if (t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100) { + late.push(`${name}(계획종료 ${t.plannedEndDate}, 실적 ${Number(t.actualProgress) || 0}%${t.owner ? `, ${t.owner}` : ''})`); + } else if (t.plannedStartDate && t.plannedStartDate >= today) { + upcoming.push(`${name}(${t.plannedStartDate} 시작)`); + } + } + const pvPct = wSum ? ((pv / wSum) * 100).toFixed(1) : '0'; + const evPct = wSum ? ((ev / wSum) * 100).toFixed(1) : '0'; + const context = [ + `프로젝트: ${p.name}`, + `작업 수: ${tasks.length} · 계획진척 ${pvPct}% · 실적진척 ${evPct}%`, + `지연 작업(${late.length}): ${late.slice(0, 8).join(' / ') || '없음'}`, + `예정 작업(${upcoming.length}): ${upcoming.slice(0, 5).join(' / ') || '없음'}`, + ].join('\n'); + try { + const analysis = await orchestratorChat([ + { role: 'system', content: '너는 공정관리(schedule control) 전문가다. 주어진 프로젝트 지표를 근거로 한국어 경영진 브리핑을 작성하라: ①일정 상태 한 줄 판정 ②핵심 리스크 2~3개(근거 지표 인용) ③실행 권고 2~3개. 지표에 없는 사실은 만들지 마라.' }, + { role: 'user', content: context }, + ], { + service: 'scopeweave', + account: String(p.org_id), + }); + logAudit(p.org_id, uid, 'ai.brief', 'project', p.id, { tasks: tasks.length }); + return c.json({ analysis }); + } catch (e) { + return c.json({ error: `AI 분석 실패: ${e.message}` }, 502); + } +}); + +// 산출물 첨부(Clearfolio 통합 문서 뷰어 프록시): 업로드→변환 잡, 목록(+상태 +// 갱신), 서명 아티팩트 열람(302), 삭제. 테넌트 = 조직, 브라우저에는 Clearfolio +// 자격이 절대 노출되지 않음. +const ATTACH_MAX_BYTES = 10 * 1024 * 1024; + +const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY, +); +const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, +); +const ATTACH_STATUS_BUDGET_MS = normalizeAttachmentStatusBudgetMs( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS, +); +const ATTACHMENT_LIST_COLUMNS = `a.id, a.task_id AS taskId, a.name, a.mime, a.size, + a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy`; +const ATTACHMENT_LIST_FROM = + 'FROM attachments a LEFT JOIN users u ON u.id = a.created_by'; +const listAttachmentsStatement = db.prepare( + `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} + WHERE a.project_id = ? ORDER BY a.id DESC`, +); +const listTaskAttachmentsStatement = db.prepare( + `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} + WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`, +); +const updateAttachmentStatusStatement = db.prepare( + 'UPDATE attachments SET status = ? WHERE id = ?', +); +app.post('/api/projects/:id/attachments', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const form = await c.req.formData().catch(() => null); + const file = form?.get('file'); + if (!file || typeof file === 'string') return c.json({ error: 'multipart file required' }, 400); + const taskId = String(form.get('taskId') || ''); + if (/\.(hwp|hwpx)$/i.test(file.name || '')) return c.json({ error: 'HWP/HWPX는 지원되지 않습니다 (Clearfolio 정책)' }, 400); + if (file.size > ATTACH_MAX_BYTES) return c.json({ error: 'file too large (max 10MB)' }, 400); + const bytes = Buffer.from(await file.arrayBuffer()); + let job; + try { + job = await submitJob(p.org_id, uid, { name: file.name || 'document', mime: file.type || '', bytes }); + } catch (e) { + return c.json({ error: `문서 변환 제출 실패: ${e.message}` }, 502); + } + const aid = rowid(db.prepare( + 'INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)' + ).run(p.id, taskId, file.name || 'document', file.type || '', file.size, job.jobId, job.status, uid)); + logAudit(p.org_id, uid, 'attachment.upload', 'project', p.id, { attachmentId: aid, name: file.name, taskId: taskId || null }); + return c.json({ id: aid, status: job.status }); +}); + +app.get('/api/projects/:id/attachments', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + + const taskId = c.req.query('taskId'); + const rows = taskId + ? listTaskAttachmentsStatement.all(p.id, taskId) + : listAttachmentsStatement.all(p.id); + await refreshAttachmentStatuses(rows, { + orgId: p.org_id, + userId: uid, + jobStatus, + updateStatus: (status, attachmentId) => + updateAttachmentStatusStatement.run(status, attachmentId), + concurrency: ATTACH_STATUS_CONCURRENCY, + timeoutMs: ATTACH_STATUS_TIMEOUT_MS, + budgetMs: ATTACH_STATUS_BUDGET_MS, + metrics, + }); + const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); + return c.json({ attachments }); +}); + +// 열람: 서명 아티팩트 URL로 302. 새 탭 열기용으로 ?token=도 허용(ics/stream 패턴). +app.get('/api/projects/:id/attachments/:aid/view', (c) => { + const header = c.req.header('authorization') || ''; + const raw = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); + let uid; + if (raw.startsWith('swk_')) { + const row = db.prepare('SELECT user_id FROM api_tokens WHERE token_hash = ?').get(hashApiToken(raw)); + if (!row) return c.json({ error: 'unauthorized' }, 401); + uid = row.user_id; + } else { + try { + const payload = verifyToken(raw); + const u = db.prepare('SELECT token_version FROM users WHERE id = ?').get(payload.sub); + if (!u || (payload.tv || 0) !== u.token_version) return c.json({ error: 'unauthorized' }, 401); + uid = payload.sub; + } catch { return c.json({ error: 'unauthorized' }, 401); } + } + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const a = db.prepare('SELECT job_id, status FROM attachments WHERE id = ? AND project_id = ?').get(c.req.param('aid'), p.id); + if (!a) return c.json({ error: 'not found' }, 404); + if (a.status !== 'SUCCEEDED') return c.json({ error: `문서가 아직 준비되지 않았습니다 (${a.status})` }, 409); + return artifactUrl(p.org_id, uid, a.job_id) + .then((url) => c.redirect(url)) + .catch((e) => c.json({ error: `열람 링크 발급 실패: ${e.message}` }, 502)); +}); + +app.delete('/api/projects/:id/attachments/:aid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const a = db.prepare('SELECT created_by FROM attachments WHERE id = ? AND project_id = ?').get(c.req.param('aid'), p.id); + if (!a) return c.json({ error: 'not found' }, 404); + if (a.created_by !== uid && !canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + db.prepare('DELETE FROM attachments WHERE id = ?').run(c.req.param('aid')); + logAudit(p.org_id, uid, 'attachment.delete', 'project', p.id, { attachmentId: Number(c.req.param('aid')) }); + return c.json({ ok: true }); +}); + +// mock Clearfolio 아티팩트 서빙(dev/test 전용) +if (clearfolioMock) { + app.get('/api/mock-clearfolio/:jobId', (c) => { + const doc = mockArtifact(c.req.param('jobId')); + if (!doc) return c.json({ error: 'not found' }, 404); + return c.body(doc.bytes, 200, { + 'content-type': doc.mime || 'application/octet-stream', + 'content-disposition': `inline; filename="${encodeURIComponent(doc.name)}"`, + }); + }); +} + +// Public read-only share links: a random token grants VIEW access to one +// project (no account needed) — revocable. Never exposes org/member data. +app.post('/api/projects/:id/shares', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const token = randomBytes(18).toString('base64url'); + db.prepare('INSERT INTO share_tokens(project_id, token, created_by) VALUES(?,?,?)').run(p.id, token, uid); + logAudit(p.org_id, uid, 'share.create', 'project', p.id, {}); + return c.json({ token, url: `/?share=${token}` }); +}); + +app.get('/api/projects/:id/shares', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const shares = db.prepare( + 'SELECT id, token, created_at AS createdAt FROM share_tokens WHERE project_id = ? AND revoked = 0 ORDER BY id DESC' + ).all(p.id); + return c.json({ shares }); +}); + +app.delete('/api/projects/:id/shares/:sid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('UPDATE share_tokens SET revoked = 1 WHERE id = ? AND project_id = ? AND revoked = 0') + .run(c.req.param('sid'), p.id); + if (!info.changes) return c.json({ error: 'not found' }, 404); + logAudit(p.org_id, uid, 'share.revoke', 'project', p.id, { shareId: Number(c.req.param('sid')) }); + return c.json({ ok: true }); +}); + +// Anonymous read via share token — project content only. +app.get('/api/shared/:token', (c) => { + const row = db.prepare( + `SELECT p.name, p.base_date AS baseDate, p.tasks_json FROM share_tokens s + JOIN projects p ON p.id = s.project_id WHERE s.token = ? AND s.revoked = 0` + ).get(c.req.param('token')); + if (!row) return c.json({ error: 'not found' }, 404); + return c.json({ name: row.name, baseDate: row.baseDate, tasks: JSON.parse(row.tasks_json), readOnly: true }); +}); + +// Unseen-activity notifications: per project, count others' saves + comments +// newer than my last-seen mark. Opening a project marks it seen. +app.get('/api/notifications', requireAuth, (c) => { + const uid = c.get('user').sub; + const rows = db.prepare( + `SELECT p.id AS projectId, + (SELECT COUNT(*) FROM project_revisions r WHERE r.project_id = p.id + AND r.saved_by IS NOT NULL AND r.saved_by != ? + AND r.created_at > COALESCE(s.seen_at, '')) AS revisions, + (SELECT COUNT(*) FROM comments cm WHERE cm.project_id = p.id + AND cm.user_id IS NOT NULL AND cm.user_id != ? + AND cm.created_at > COALESCE(s.seen_at, '')) AS comments + FROM projects p + JOIN memberships m ON m.org_id = p.org_id AND m.user_id = ? + LEFT JOIN project_seen s ON s.project_id = p.id AND s.user_id = ?` + ).all(uid, uid, uid, uid); + const notifications = rows + .map((r) => ({ projectId: r.projectId, unseen: r.revisions + r.comments })) + .filter((r) => r.unseen > 0); + return c.json({ notifications }); +}); + +app.post('/api/projects/:id/seen', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + db.prepare(`INSERT INTO project_seen(project_id, user_id, seen_at) VALUES(?, ?, datetime('now')) + ON CONFLICT(project_id, user_id) DO UPDATE SET seen_at = datetime('now')`).run(p.id, uid); + return c.json({ ok: true }); +}); + +// Archive / restore a project (write roles): declutter without deleting. +app.post('/api/projects/:id/archive', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const { archived } = await c.req.json().catch(() => ({})); + const flag = archived === false ? 0 : 1; + db.prepare('UPDATE projects SET archived = ? WHERE id = ?').run(flag, p.id); + logAudit(p.org_id, uid, flag ? 'project.archive' : 'project.unarchive', 'project', p.id, {}); + return c.json({ id: p.id, archived: Boolean(flag) }); +}); + +// Duplicate a project (template use: copy tasks + base date into a new project +// in the same org). Plan caps apply like any create. +app.post('/api/projects/:id/duplicate', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + if (wouldExceed(db, getOrg(p.org_id), 'projects')) { + return c.json({ error: 'project limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.projects }, 402); + } + const { name } = await c.req.json().catch(() => ({})); + const newName = String(name || `${p.name} (복사본)`).slice(0, 120); + const nid = rowid(db.prepare('INSERT INTO projects(org_id,name,base_date,tasks_json,created_by) VALUES(?,?,?,?,?)') + .run(p.org_id, newName, p.base_date, p.tasks_json, uid)); + metrics.projectsCreated++; + logAudit(p.org_id, uid, 'project.duplicate', 'project', nid, { from: p.id, name: newName }); + return c.json({ id: nid, name: newName, version: 1 }); +}); + +// -------------------------------------------------------------- sprints +// Agile/Hybrid: 시간상자(스프린트) CRUD. 작업은 task.sprint(이름)로 배정되고 +// task.storyPoints로 추정된다 — 지표(커밋/완료 포인트, 벨로시티)는 클라이언트 +// 순수 함수(computeSprintStats)가 계산한다. +app.post('/api/projects/:id/sprints', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const { name, startDate, endDate, goal } = await c.req.json().catch(() => ({})); + if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); + const day = (v) => (/^\d{4}-\d{2}-\d{2}$/.test(String(v || '')) ? v : ''); + const sid = rowid(db.prepare('INSERT INTO sprints(project_id,name,start_date,end_date,goal) VALUES(?,?,?,?,?)') + .run(p.id, String(name).trim().slice(0, 80), day(startDate), day(endDate), String(goal || '').slice(0, 300))); + logAudit(p.org_id, uid, 'sprint.create', 'project', p.id, { sprintId: sid, name }); + return c.json({ id: sid, name: String(name).trim() }); +}); + +app.get('/api/projects/:id/sprints', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const sprints = db.prepare( + 'SELECT id, name, start_date AS startDate, end_date AS endDate, goal FROM sprints WHERE project_id = ? ORDER BY start_date, id' + ).all(p.id); + return c.json({ sprints, methodology: p.methodology || 'waterfall' }); +}); + +app.delete('/api/projects/:id/sprints/:sid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('DELETE FROM sprints WHERE id = ? AND project_id = ?').run(c.req.param('sid'), p.id); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + +// ------------------------------------------------------------- baselines +// Snapshot a project's current plan as a named baseline (schedule-control: +// compare actuals against the frozen plan later). +app.post('/api/projects/:id/baselines', requireAuth, async (c) => { + const uid = c.get('user').sub; + const id = c.req.param('id'); + const p = projectAccess(uid, id); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const { name } = await c.req.json().catch(() => ({})); + const bid = rowid(db.prepare('INSERT INTO baselines(project_id,name,base_date,tasks_json,created_by) VALUES(?,?,?,?,?)') + .run(id, String(name || 'Baseline').slice(0, 80), p.base_date, p.tasks_json, uid)); + logAudit(p.org_id, uid, 'baseline.create', 'project', id, { baselineId: bid, name }); + return c.json({ id: bid, name: name || 'Baseline' }); +}); + +app.get('/api/projects/:id/baselines', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const baselines = db.prepare( + 'SELECT id, name, base_date AS baseDate, created_at AS createdAt FROM baselines WHERE project_id = ? ORDER BY id DESC' + ).all(p.id); + return c.json({ baselines }); +}); + +app.get('/api/projects/:id/baselines/:bid', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const b = db.prepare('SELECT id, name, base_date AS baseDate, tasks_json, created_at AS createdAt FROM baselines WHERE id = ? AND project_id = ?').get(c.req.param('bid'), p.id); + if (!b) return c.json({ error: 'not found' }, 404); + return c.json({ id: b.id, name: b.name, baseDate: b.baseDate, tasks: JSON.parse(b.tasks_json), createdAt: b.createdAt }); +}); + +app.delete('/api/projects/:id/baselines/:bid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('DELETE FROM baselines WHERE id = ? AND project_id = ?').run(c.req.param('bid'), p.id); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + +// ------------------------------------------------------ account & lifecycle +// Delete a project (write roles). tasks live in the row, so this fully removes it. +app.delete('/api/projects/:id', requireAuth, (c) => { + const uid = c.get('user').sub; + const id = c.req.param('id'); + const p = projectAccess(uid, id); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + db.prepare('DELETE FROM projects WHERE id = ?').run(id); + logAudit(p.org_id, uid, 'project.delete', 'project', id, { name: p.name }); + deliver(p.org_id, 'project.delete', { projectId: Number(id) }); + return c.json({ ok: true }); +}); + +// Log out everywhere: bump token_version → every existing JWT dies. Returns a +// fresh token so THIS device stays signed in. PATs are unaffected. +app.post('/api/auth/logout-all', requireAuth, (c) => { + const uid = c.get('user').sub; + db.prepare('UPDATE users SET token_version = token_version + 1 WHERE id = ?').run(uid); + const u = db.prepare('SELECT email, token_version FROM users WHERE id = ?').get(uid); + return c.json({ ok: true, token: signToken({ sub: uid, email: u.email, tv: u.token_version }) }); +}); + +// Change password (verifies the current one). +app.post('/api/auth/change-password', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { oldPassword, newPassword } = await c.req.json().catch(() => ({})); + if (typeof newPassword !== 'string' || newPassword.length < 8) return c.json({ error: 'new password (min 8) required' }, 400); + const u = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(uid); + if (!u || typeof oldPassword !== 'string' || !verifyPassword(oldPassword, u.password_hash)) { + return c.json({ error: 'current password incorrect' }, 403); + } + db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(newPassword), uid); + return c.json({ ok: true }); +}); + +// Delete account (GDPR). Removes owned workspaces (cascading their data) and the +// user. Requires the current password to confirm. +app.delete('/api/account', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { password } = await c.req.json().catch(() => ({})); + const u = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(uid); + if (!u || typeof password !== 'string' || !verifyPassword(password, u.password_hash)) { + return c.json({ error: 'password required to delete account' }, 403); + } + db.exec('BEGIN'); + try { + db.prepare('DELETE FROM orgs WHERE owner_id = ?').run(uid); // cascades projects/members/webhooks/invites/audit + db.prepare('DELETE FROM users WHERE id = ?').run(uid); // cascades memberships/tokens + db.exec('COMMIT'); + } catch (e) { db.exec('ROLLBACK'); throw e; } + return c.json({ ok: true }); +}); + +app.get('/api/health', (c) => c.json({ ok: true })); + +// Static client — strict allowlist so server/, data.db, package.json etc. are +// never served. Anything not listed → 404. +const STATIC = { + '/': ['index.html', 'text/html; charset=utf-8'], + '/index.html': ['index.html', 'text/html; charset=utf-8'], + '/404.html': ['404.html', 'text/html; charset=utf-8'], + '/landing.html': ['landing.html', 'text/html; charset=utf-8'], + '/landing.en.html': ['landing.en.html', 'text/html; charset=utf-8'], + '/docs/api.md': ['docs/api.md', 'text/markdown; charset=utf-8'], + '/robots.txt': ['robots.txt', 'text/plain; charset=utf-8'], + '/sitemap.xml': ['sitemap.xml', 'application/xml; charset=utf-8'], + '/pricing': ['landing.html', 'text/html; charset=utf-8'], + '/app.js': ['app.js', 'text/javascript; charset=utf-8'], + '/cloud-sync.js': ['cloud-sync.js', 'text/javascript; charset=utf-8'], + '/analytics.js': ['analytics.js', 'text/javascript; charset=utf-8'], + '/styles.css': ['styles.css', 'text/css; charset=utf-8'], + '/toast-state.css': ['toast-state.css', 'text/css; charset=utf-8'], + '/wbs.json': ['wbs.json', 'application/json; charset=utf-8'], +}; +app.get('*', async (c) => { + const entry = STATIC[c.req.path]; + if (!entry) return c.notFound(); + try { + const buf = await readFile(new URL(`../${entry[0]}`, import.meta.url)); + return c.body(buf, 200, { 'Content-Type': entry[1] }); + } catch { + return c.notFound(); + } +}); diff --git a/server/stripe_webhook.mjs b/server/stripe_webhook.mjs new file mode 100644 index 00000000..1647fd67 --- /dev/null +++ b/server/stripe_webhook.mjs @@ -0,0 +1,224 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +const STRIPE_WEBHOOK_MAX_BYTES = 256 * 1024; +const STRIPE_SIGNATURE_HEADER_MAX_LENGTH = 4096; +const STRIPE_SIGNATURE_TOLERANCE_SECONDS = 5 * 60; +const STRIPE_EVENT_FIELD_MAX_LENGTH = 255; +const HEX_SHA256_PATTERN = /^[0-9a-f]{64}$/i; +const DECIMAL_INTEGER_PATTERN = /^\d+$/; + +/** + * Stable, browser-safe Stripe webhook boundary failure. + * + * The error contains only a machine-readable classification and HTTP status; + * signatures, webhook secrets, raw provider payloads, and parser details never + * cross this boundary. + */ +export class StripeWebhookError extends Error { + /** + * Create one sanitized webhook verification failure. + * @param {string} code stable machine-readable error code + * @param {number} status HTTP response status for the adapter + */ + constructor(code, status) { + super(code); + this.name = 'StripeWebhookError'; + this.code = code; + this.status = status; + } +} + +function webhookError(code, status = 400) { + return new StripeWebhookError(code, status); +} + +function requireVerifierConfiguration(secret, nowSeconds) { + if (typeof secret !== 'string' || secret.trim().length === 0) { + throw webhookError('stripe_webhook_not_configured', 503); + } + if (!Number.isSafeInteger(nowSeconds) || nowSeconds < 0) { + throw webhookError('stripe_webhook_request_invalid'); + } +} + +async function readBoundedRawBody(request) { + if (!request || typeof request !== 'object' || !request.headers) { + throw webhookError('stripe_webhook_request_invalid'); + } + + const declaredLength = request.headers.get('content-length'); + if (declaredLength !== null) { + const normalizedLength = declaredLength.trim(); + if (!DECIMAL_INTEGER_PATTERN.test(normalizedLength)) { + throw webhookError('stripe_webhook_request_invalid'); + } + const length = Number(normalizedLength); + if (!Number.isSafeInteger(length)) { + throw webhookError('stripe_webhook_request_invalid'); + } + if (length > STRIPE_WEBHOOK_MAX_BYTES) { + throw webhookError('stripe_webhook_body_too_large', 413); + } + } + + const reader = request.body?.getReader?.(); + if (!reader || typeof reader.read !== 'function') { + throw webhookError('stripe_webhook_request_invalid'); + } + + const chunks = []; + let totalBytes = 0; + try { + for (;;) { + let result; + try { + result = await reader.read(); + } catch { + throw webhookError('stripe_webhook_request_invalid'); + } + if (result.done) break; + if (!(result.value instanceof Uint8Array)) { + throw webhookError('stripe_webhook_request_invalid'); + } + totalBytes += result.value.byteLength; + if (totalBytes > STRIPE_WEBHOOK_MAX_BYTES) { + try { + await reader.cancel(); + } catch { + // Cancellation is best effort after the byte budget has failed closed. + } + throw webhookError('stripe_webhook_body_too_large', 413); + } + chunks.push(result.value); + } + } finally { + try { + reader.releaseLock?.(); + } catch { + // Reader cleanup cannot change the verification result. + } + } + + const body = Buffer.allocUnsafe(totalBytes); + let offset = 0; + for (const chunk of chunks) { + Buffer.from(chunk).copy(body, offset); + offset += chunk.byteLength; + } + return body; +} + +function parseStripeSignatureHeader(header) { + if ( + typeof header !== 'string' + || header.length === 0 + || header.length > STRIPE_SIGNATURE_HEADER_MAX_LENGTH + ) { + throw webhookError('stripe_webhook_signature_invalid'); + } + + const timestamps = []; + const signatures = []; + for (const component of header.split(',')) { + const separator = component.indexOf('='); + if (separator <= 0) continue; + const key = component.slice(0, separator).trim(); + const value = component.slice(separator + 1).trim(); + if (key === 't') timestamps.push(value); + if (key === 'v1') signatures.push(value); + } + + if (timestamps.length !== 1 || !DECIMAL_INTEGER_PATTERN.test(timestamps[0])) { + throw webhookError('stripe_webhook_signature_invalid'); + } + const timestamp = Number(timestamps[0]); + if (!Number.isSafeInteger(timestamp) || timestamp < 0 || signatures.length === 0) { + throw webhookError('stripe_webhook_signature_invalid'); + } + + const validSignatures = signatures.filter((signature) => HEX_SHA256_PATTERN.test(signature)); + if (validSignatures.length === 0) { + throw webhookError('stripe_webhook_signature_invalid'); + } + return { timestamp, signatures: validSignatures }; +} + +function signatureMatches(body, signatureHeader, secret, nowSeconds) { + const { timestamp, signatures } = parseStripeSignatureHeader(signatureHeader); + if (Math.abs(nowSeconds - timestamp) > STRIPE_SIGNATURE_TOLERANCE_SECONDS) { + return false; + } + + const expected = createHmac('sha256', secret) + .update(String(timestamp)) + .update('.') + .update(body) + .digest(); + + let matched = false; + for (const signature of signatures) { + const candidate = Buffer.from(signature, 'hex'); + if (candidate.length === expected.length && timingSafeEqual(candidate, expected)) { + matched = true; + } + } + return matched; +} + +function parseVerifiedEvent(body) { + let event; + try { + const text = new TextDecoder('utf-8', { fatal: true }).decode(body); + event = JSON.parse(text); + } catch { + throw webhookError('stripe_webhook_payload_invalid'); + } + + if (!event || typeof event !== 'object' || Array.isArray(event)) { + throw webhookError('stripe_webhook_payload_invalid'); + } + if ( + typeof event.id !== 'string' + || event.id.length === 0 + || event.id.length > STRIPE_EVENT_FIELD_MAX_LENGTH + || typeof event.type !== 'string' + || event.type.length === 0 + || event.type.length > STRIPE_EVENT_FIELD_MAX_LENGTH + ) { + throw webhookError('stripe_webhook_payload_invalid'); + } + return event; +} + +/** + * Verify and parse one Stripe webhook without mutating its signed request body. + * + * Stripe signs `timestamp + "." + raw request body`; JSON parsing therefore + * happens only after constant-time HMAC verification over the exact streamed + * bytes. The request body is capped at 256 KiB before buffering, the signature + * header is bounded, and the signed timestamp must be within five minutes of the + * server clock. Multiple `v1` values are accepted for endpoint-secret rotation. + * + * This function establishes transport authenticity only. It intentionally does + * not deduplicate event IDs, assume delivery ordering, or grant billing + * entitlements; those operations require durable provider-state reconciliation. + * + * @param {Request} request Fetch-compatible request containing the raw webhook body + * @param {object} options verifier configuration + * @param {string} options.secret Stripe endpoint signing secret + * @param {number} [options.nowSeconds] integer epoch seconds used for replay checks + * @returns {Promise>} verified bounded Stripe event object + * @throws {StripeWebhookError} for unconfigured, oversized, malformed, or unauthenticated requests + */ +export async function verifyStripeWebhookRequest(request, { + secret, + nowSeconds = Math.floor(Date.now() / 1000), +} = {}) { + requireVerifierConfiguration(secret, nowSeconds); + const body = await readBoundedRawBody(request); + const signatureHeader = request.headers.get('stripe-signature'); + if (!signatureMatches(body, signatureHeader, secret, nowSeconds)) { + throw webhookError('stripe_webhook_signature_invalid'); + } + return parseVerifiedEvent(body); +} diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 149440e5..e8110635 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -7,6 +7,7 @@ import { readFileSync } from 'node:fs'; const packageJson = JSON.parse( readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), ); +const publicApp = readFileSync(new URL('../../server/app.mjs', import.meta.url), 'utf8'); const scripts = packageJson.scripts; assert.equal( @@ -34,6 +35,31 @@ assert.match( /--include=server\/clearfolio\.mjs/, 'the abortable Clearfolio adapter is instrumented', ); +assert.match( + scripts['test:coverage'], + /--include=server\/application_routes\.mjs/, + 'the protected application route graph remains owned-production coverage', +); +assert.match( + scripts['test:coverage'], + /--include=server\/stripe_webhook\.mjs/, + 'the Stripe webhook verifier is owned-production coverage', +); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/stripe-webhook-boundary\.test\.mjs/, + 'the raw-body Stripe trust-boundary regression executes under c8', +); +assert.match( + scripts['test:api'], + /tests\/api\/stripe-webhook\.test\.mjs/, + 'the public Stripe webhook entitlement regression executes in normal API CI', +); +assert.match( + publicApp, + /app\.route\(\s*['"]\/['"]\s*,\s*applicationRoutes\s*\)/, + 'the public app delegates unrelated routes to the protected application graph', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/, diff --git a/tests/unit/stripe-webhook-boundary.test.mjs b/tests/unit/stripe-webhook-boundary.test.mjs new file mode 100644 index 00000000..20ec2df3 --- /dev/null +++ b/tests/unit/stripe-webhook-boundary.test.mjs @@ -0,0 +1,193 @@ +import assert from 'node:assert/strict'; +import { createHmac } from 'node:crypto'; +import test from 'node:test'; + +const SECRET = 'whsec_scopeweave_webhook_test_secret'; +const NOW_SECONDS = 1_800_000_000; + +const { StripeWebhookError, verifyStripeWebhookRequest } = await import( + '../../server/stripe_webhook.mjs' +); + +function signatureHeader(bodyBytes, timestamp = NOW_SECONDS, secret = SECRET, extra = '') { + const digest = createHmac('sha256', secret) + .update(String(timestamp)) + .update('.') + .update(bodyBytes) + .digest('hex'); + return `t=${timestamp},v1=${digest}${extra}`; +} + +function webhookRequest(bodyBytes, { + signature = signatureHeader(bodyBytes), + contentLength, +} = {}) { + const headers = new Headers({ + 'content-type': 'application/json', + 'stripe-signature': signature, + }); + if (contentLength !== undefined) headers.set('content-length', String(contentLength)); + return new Request('https://scopeweave.example/api/stripe/webhook', { + method: 'POST', + headers, + body: bodyBytes, + duplex: 'half', + }); +} + +function encoded(value) { + return new TextEncoder().encode(value); +} + +async function expectWebhookError(operation, code, status) { + await assert.rejects(operation, (error) => { + assert.ok(error instanceof StripeWebhookError); + assert.equal(error.code, code); + assert.equal(error.status, status); + return true; + }); +} + +test('verified webhook preserves the exact signed raw body and returns bounded event identity', async () => { + const bytes = encoded('{\n "id":"evt_scopeweave_1",\n "type":"checkout.session.completed",\n "data":{"object":{"client_reference_id":"7"}}\n}\n'); + const event = await verifyStripeWebhookRequest(webhookRequest(bytes), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }); + + assert.equal(event.id, 'evt_scopeweave_1'); + assert.equal(event.type, 'checkout.session.completed'); + assert.equal(event.data.object.client_reference_id, '7'); +}); + +test('signature verification fails when JSON-equivalent bytes differ from the signed body', async () => { + const signedBytes = encoded('{"id":"evt_raw","type":"checkout.session.completed"}'); + const mutatedBytes = encoded('{ "id": "evt_raw", "type": "checkout.session.completed" }'); + + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(mutatedBytes, { + signature: signatureHeader(signedBytes), + }), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_signature_invalid', + 400, + ); +}); + +test('signature parser accepts one matching v1 value and rejects malformed, missing, stale, or future signatures', async () => { + const bytes = encoded('{"id":"evt_sig","type":"invoice.paid"}'); + const valid = signatureHeader(bytes); + const validDigest = valid.split('v1=')[1]; + + const multiple = webhookRequest(bytes, { + signature: `t=${NOW_SECONDS},v1=${'0'.repeat(64)},v1=${validDigest}`, + }); + assert.equal((await verifyStripeWebhookRequest(multiple, { + secret: SECRET, + nowSeconds: NOW_SECONDS, + })).id, 'evt_sig'); + + for (const signature of [ + '', + `t=${NOW_SECONDS}`, + `v1=${validDigest}`, + `t=not-a-number,v1=${validDigest}`, + `t=${NOW_SECONDS},v1=xyz`, + signatureHeader(bytes, NOW_SECONDS - 301), + signatureHeader(bytes, NOW_SECONDS + 301), + ]) { + const request = webhookRequest(bytes, { signature }); + await expectWebhookError( + () => verifyStripeWebhookRequest(request, { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_signature_invalid', + 400, + ); + } +}); + +test('body byte ceiling rejects declared and streamed oversize requests before JSON parsing', async () => { + const small = encoded('{"id":"evt_size","type":"invoice.paid"}'); + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(small, { + contentLength: 262_145, + }), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_body_too_large', + 413, + ); + + const large = encoded(JSON.stringify({ + id: 'evt_stream_size', + type: 'invoice.paid', + data: 'x'.repeat(262_144), + })); + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(large), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_body_too_large', + 413, + ); +}); + +test('invalid content length, payload JSON, event identity, and verifier configuration fail closed', async () => { + const validBytes = encoded('{"id":"evt_valid","type":"invoice.paid"}'); + + for (const contentLength of ['-1', 'NaN', '1.5', '999999999999999999999999']) { + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(validBytes, { contentLength }), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_request_invalid', + 400, + ); + } + + const malformed = encoded('{"id":'); + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(malformed), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_payload_invalid', + 400, + ); + + for (const value of [ + null, + [], + {}, + { id: '', type: 'invoice.paid' }, + { id: 'evt_ok', type: '' }, + { id: 'x'.repeat(256), type: 'invoice.paid' }, + { id: 'evt_ok', type: 'x'.repeat(256) }, + ]) { + const bytes = encoded(JSON.stringify(value)); + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(bytes), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_payload_invalid', + 400, + ); + } + + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(validBytes), { + secret: ' ', + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_not_configured', + 503, + ); +}); From cc0c6ed9b9965c1601836ee30648562943e91674 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 06:40:18 -0700 Subject: [PATCH 022/120] test(billing): keep Stripe webhook behind abuse controls --- tests/api/stripe-webhook.test.mjs | 39 +++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/api/stripe-webhook.test.mjs b/tests/api/stripe-webhook.test.mjs index 68d549c5..26a0683b 100644 --- a/tests/api/stripe-webhook.test.mjs +++ b/tests/api/stripe-webhook.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; import { createHmac } from 'node:crypto'; import test from 'node:test'; @@ -66,6 +67,44 @@ function checkoutCompletedBody(orgId) { }); } +test('Stripe webhook stays behind the application abuse-control middleware', () => { + const child = spawnSync(process.execPath, ['--input-type=module', '--eval', ` + process.env.SCOPEWEAVE_DB = ':memory:'; + delete process.env.SCOPEWEAVE_DEV; + process.env.SCOPEWEAVE_PUBLIC_ORIGIN = 'https://scopeweave.example'; + process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + process.env.STRIPE_SECRET_KEY = 'sk_test_scopeweave_webhook'; + process.env.STRIPE_PRICE_ID = 'price_scopeweave_webhook'; + process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_api_webhook_secret'; + process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '1'; + process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; + + const { app } = await import('./server/app.mjs?stripe-webhook-middleware-regression=1'); + const request = () => app.request('https://scopeweave.example/api/stripe/webhook', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: 'evt_unsigned_flood', type: 'checkout.session.completed' }), + }); + const first = await request(); + const second = await request(); + process.stdout.write(JSON.stringify({ + first: first.status, + second: second.status, + secondBody: await second.json(), + })); + `], { + cwd: process.cwd(), + encoding: 'utf8', + }); + + assert.equal(child.status, 0, child.stderr); + assert.deepEqual(JSON.parse(child.stdout.trim()), { + first: 400, + second: 429, + secondBody: { error: 'rate limit exceeded' }, + }); +}); + test('unsigned Stripe webhook cannot upgrade an organization', async () => { const { token, orgId } = await signupAndOrg(); assert.equal(await currentPlan(token), 'free'); From 1f5c2b2699246d705bb71b1672c279978a106462 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 06:43:57 -0700 Subject: [PATCH 023/120] fix(billing): keep Stripe verification inside app controls --- server/app.mjs | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 742bc47b..8514acf7 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -5,11 +5,11 @@ import { StripeWebhookError, verifyStripeWebhookRequest } from './stripe_webhook /** * Public ScopeWeave HTTP application. * - * The protected-develop route graph remains byte-for-byte in - * `application_routes.mjs`. This entry point owns the emergency Stripe webhook - * trust boundary so unsigned provider-shaped JSON cannot reach the historical - * entitlement mutation while the full durable #488 reconciliation stack is - * still integrating. All unrelated routes delegate unchanged. + * The protected-develop route graph remains in `application_routes.mjs`. This + * entry point composes that graph while replacing its historical unsigned + * Stripe stub with a fail-closed raw-body verification boundary. Copying the + * existing route metadata preserves the original observability and abuse- + * control middleware order for every public route, including Stripe. */ export const app = new Hono(); @@ -17,6 +17,17 @@ export const app = new Hono(); // its existing implementation to the protected route graph. app.get('/toast-state.css', (c) => applicationRoutes.fetch(c.req.raw)); +// Copy every shipped route and middleware except the historical unsigned Stripe +// handler. Registering the authenticated replacement after this copy keeps the +// original logging/metrics and rate-limit middleware ahead of the endpoint and +// makes the insecure handler absent from the public route graph rather than +// merely shadowed by registration order. +for (const route of applicationRoutes.routes.filter( + ({ method, path }) => !(method === 'POST' && path === '/api/stripe/webhook'), +)) { + app.on(route.method, route.path, route.handler); +} + app.post('/api/stripe/webhook', async (c) => { try { await verifyStripeWebhookRequest(c.req.raw, { @@ -33,5 +44,3 @@ app.post('/api/stripe/webhook', async (c) => { return c.json({ error: 'stripe_webhook_unavailable' }, 500, { 'Cache-Control': 'no-store' }); } }); - -app.route('/', applicationRoutes); From 4488ab9f6ec68f02eecc9ecca372e621df23756a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 06:46:39 -0700 Subject: [PATCH 024/120] test(ci): align public-route contract with Stripe repair --- tests/unit/coverage-script-contract.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index e8110635..a345ed88 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -57,8 +57,8 @@ assert.match( ); assert.match( publicApp, - /app\.route\(\s*['"]\/['"]\s*,\s*applicationRoutes\s*\)/, - 'the public app delegates unrelated routes to the protected application graph', + /applicationRoutes\.routes\.filter\([\s\S]*method === ['"]POST['"][\s\S]*path === ['"]\/api\/stripe\/webhook['"][\s\S]*app\.on\(route\.method, route\.path, route\.handler\)/, + 'the public app preserves the protected route graph while excluding the historical unsigned Stripe handler', ); assert.match( scripts['test:coverage:cases'], From dce2424b45833fa6a942fae1edb5d16f0d687bdb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 13:51:51 +0000 Subject: [PATCH 025/120] fix(billing): retire unsigned Stripe plan-upgrade handler Keep the public copy-and-replace composition and make the protected-graph Stripe route fail closed on HMAC verification so a direct mount cannot upgrade orgs.plan from unsigned JSON. Record the trust boundary in CHANGELOG and doctoring. --- CHANGELOG.md | 7 ++ .../stripe-webhook-trust-boundary.md | 75 +++++++++++++++++++ server/application_routes.mjs | 23 ++++-- tests/unit/coverage-script-contract.test.mjs | 26 ++++++- 4 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 docs/doctoring/stripe-webhook-trust-boundary.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e434fa01..fccb56dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Replaced the unsigned `POST /api/stripe/webhook` plan-upgrade stub with a + fail-closed raw-body HMAC-SHA-256 signature boundary. Signed deliveries are + acknowledged only; webhook JSON is not entitlement authority until durable + event reconciliation. Unsigned, stale, or body-mutated signatures fail closed, + and the public app copies protected logging and rate-limit middleware so abuse + controls still wrap the endpoint (Krawczyk et al., 1997; National Institute of + Standards and Technology, 2008). - 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. diff --git a/docs/doctoring/stripe-webhook-trust-boundary.md b/docs/doctoring/stripe-webhook-trust-boundary.md new file mode 100644 index 00000000..558cc4f3 --- /dev/null +++ b/docs/doctoring/stripe-webhook-trust-boundary.md @@ -0,0 +1,75 @@ +# Stripe webhook trust boundary + +## Decision + +Protected `develop` previously accepted an unauthenticated +`checkout.session.completed` JSON body and upgraded `orgs.plan` to `pro`. That +is a privilege-escalation window: any caller who can POST to +`/api/stripe/webhook` can grant paid entitlements. + +The public application now: + +1. copies the protected route graph, including logging and rate-limit + middleware, except the historical Stripe handler; +2. registers a fail-closed verifier that HMAC-SHA-256-checks + `timestamp + "." + raw body` against `STRIPE_WEBHOOK_SECRET` before parsing + JSON; +3. acknowledges an authentic delivery with `{ received: true }` and does **not** + mutate `orgs.plan`; and +4. keeps the protected-graph copy of the same route fail-closed so a future + composer that mounts `application_routes` directly cannot restore unsigned + plan upgrades. + +Signature validity authenticates the delivery only. Durable event +deduplication, provider-state reconciliation, and entitlement writes remain +follow-up work (stacked billing lifecycle), not this hotfix. + +## Standards rationale + +HMAC-SHA-256 over the exact signed bytes is the Stripe webhook contract and +matches RFC 2104 / FIPS 198-1 keyed hashing. JSON parsing happens only after +constant-time comparison so semantically equivalent but byte-different bodies +cannot be substituted. Replay is bounded by a five-minute timestamp window. +Missing configuration fails closed with `503 stripe_webhook_not_configured` +rather than accepting unsigned traffic. + +OAuth bearer-token rules (RFC 6750; RFC 9700) do not apply to this provider +callback; the webhook secret is a shared HMAC key, not an access token. The +endpoint remains behind the same abuse-control middleware as the rest of the +application so unsigned floods are rate-limited instead of skipping the limiter +through a first-match public route. + +## Verification contract + +Regression tests must prove: + +- unsigned `checkout.session.completed` JSON never upgrades `orgs.plan`; +- a correctly signed delivery is acknowledged and still leaves plan unchanged; +- a stale timestamp or a JSON-equivalent mutated body fails signature checks; +- when `SCOPEWEAVE_RATE_LIMIT_MAX=1`, the second webhook in the window is `429`; +- the public app does not `app.route('/', applicationRoutes)`; +- the public app copies protected routes except `POST /api/stripe/webhook` and + then registers `verifyStripeWebhookRequest`; and +- `application_routes.mjs` no longer contains a `checkout.session.completed` + plan-upgrade path. + +## Officer next action + +Rotate `STRIPE_WEBHOOK_SECRET` if it may have been exposed while the unsigned +stub was live. Point the Stripe endpoint at the public `/api/stripe/webhook` +path. Do not treat a `200 { received: true }` as proof that the organization is +Pro until reconciliation writes entitlements from Stripe's retrieved session +state. + +## References + +Krawczyk, H., Bellare, M., & Canetti, R. (1997). *HMAC: Keyed-hashing for +message authentication* (RFC 2104). Internet Engineering Task Force. +https://doi.org/10.17487/RFC2104 + +National Institute of Standards and Technology. (2008). *The keyed-hash +message authentication code (HMAC)* (FIPS PUB 198-1). U.S. Department of +Commerce. https://doi.org/10.6028/NIST.FIPS.198-1 + +Stripe. (n.d.). *Webhook signatures*. Stripe Docs. +https://docs.stripe.com/webhooks/signatures diff --git a/server/application_routes.mjs b/server/application_routes.mjs index c432a84f..f2c72d01 100644 --- a/server/application_routes.mjs +++ b/server/application_routes.mjs @@ -1,6 +1,7 @@ // 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 { StripeWebhookError, verifyStripeWebhookRequest } from './stripe_webhook.mjs'; import { Hono } from 'hono'; import { readFile } from 'node:fs/promises'; import { randomBytes, createHmac, createHash } from 'node:crypto'; @@ -601,15 +602,23 @@ 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. +// Stripe webhook authenticity only. The public app excludes this route and +// registers the same fail-closed HMAC verifier so unsigned JSON can never +// upgrade orgs.plan. This protected-graph copy must also refuse entitlement +// mutation; a future composer that mounts the graph directly still fails closed +// (Krawczyk et al., 1997; Stripe webhook signatures). 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); + try { + await verifyStripeWebhookRequest(c.req.raw, { + secret: process.env.STRIPE_WEBHOOK_SECRET, + }); + return c.json({ received: true }, 200, { 'Cache-Control': 'no-store' }); + } catch (error) { + if (error instanceof StripeWebhookError) { + return c.json({ error: error.code }, error.status, { 'Cache-Control': 'no-store' }); + } + return c.json({ error: 'stripe_webhook_unavailable' }, 500, { 'Cache-Control': 'no-store' }); } - return c.json({ received: true }); }); // Dev-only: simulate a successful checkout upgrading the org to Pro. diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index a345ed88..51925da0 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -8,6 +8,10 @@ const packageJson = JSON.parse( readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), ); const publicApp = readFileSync(new URL('../../server/app.mjs', import.meta.url), 'utf8'); +const applicationRoutes = readFileSync( + new URL('../../server/application_routes.mjs', import.meta.url), + 'utf8', +); const scripts = packageJson.scripts; assert.equal( @@ -55,10 +59,30 @@ assert.match( /tests\/api\/stripe-webhook\.test\.mjs/, 'the public Stripe webhook entitlement regression executes in normal API CI', ); +assert.doesNotMatch( + publicApp, + /app\.route\(\s*['"]\/['"]\s*,\s*applicationRoutes\s*\)/, + 'the public app does not mount the protected graph via app.route, which would first-match unsigned Stripe or skip abuse-control middleware', +); assert.match( publicApp, /applicationRoutes\.routes\.filter\([\s\S]*method === ['"]POST['"][\s\S]*path === ['"]\/api\/stripe\/webhook['"][\s\S]*app\.on\(route\.method, route\.path, route\.handler\)/, - 'the public app preserves the protected route graph while excluding the historical unsigned Stripe handler', + 'the public app preserves the protected route graph while excluding the historical Stripe handler', +); +assert.match( + publicApp, + /verifyStripeWebhookRequest/, + 'the public Stripe webhook uses the raw-body HMAC verifier', +); +assert.match( + applicationRoutes, + /verifyStripeWebhookRequest/, + 'the protected Stripe route is also fail-closed so a direct mount cannot escalate plan', +); +assert.doesNotMatch( + applicationRoutes, + /checkout\.session\.completed[\s\S]{0,500}UPDATE orgs SET plan = 'pro'/, + 'checkout.session.completed JSON is never authority to upgrade orgs.plan', ); assert.match( scripts['test:coverage:cases'], From c5e85d099c8c29bf391b8205163832a2137a495a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 07:49:23 -0700 Subject: [PATCH 026/120] test(security): reject equivalent IPv6 proxy spelling mismatch --- tests/api/ratelimit.test.mjs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/api/ratelimit.test.mjs b/tests/api/ratelimit.test.mjs index d4b6c7c5..1fff127d 100644 --- a/tests/api/ratelimit.test.mjs +++ b/tests/api/ratelimit.test.mjs @@ -78,6 +78,36 @@ assert.equal( `IPv4-mapped trusted-proxy regression failed:\n${mappedPeerProbe.stderr}`, ); +const equivalentIpv6PeerProbe = spawnSync( + process.execPath, + [ + '--input-type=module', + '--eval', + `import assert from 'node:assert/strict'; + process.env.SCOPEWEAVE_DB = ':memory:'; + process.env.SCOPEWEAVE_JWT_SECRET = '${validJwtSecret}'; + process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '3'; + process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; + process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX = '20'; + process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS = '0:0:0:0:0:0:0:1'; + const { app } = await import('./server/app.mjs'); + const nodeEnv = { incoming: { socket: { remoteAddress: '::1' } } }; + const requestFrom = (client) => app.request('/api/health', { headers: { 'x-forwarded-for': client } }, nodeEnv); + for (let i = 0; i < 3; i++) assert.equal((await requestFrom('203.0.113.71')).status, 200); + assert.equal( + (await requestFrom('198.51.100.71')).status, + 200, + 'equivalent IPv6 spellings for a trusted proxy must resolve to the same identity and preserve separate client buckets', + );`, + ], + { cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } }, +); +assert.equal( + equivalentIpv6PeerProbe.status, + 0, + `Equivalent-IPv6 trusted-proxy regression failed:\n${equivalentIpv6PeerProbe.stderr}`, +); + process.env.SCOPEWEAVE_DB = ':memory:'; process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '3'; process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX = '7'; From f69ad0dad11e6e05e50048b950e3b49ae7d53959 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 07:50:42 -0700 Subject: [PATCH 027/120] fix(security): canonicalize equivalent IPv6 proxy addresses --- server/app.mjs | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index e7f48f33..55a773fb 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -24,23 +24,30 @@ function parseSafeIntegerSetting(name, raw, fallback, minimum) { } /** - * Return a stable IP spelling for trust comparisons and limiter keys. + * Return one canonical IP spelling for trust comparisons and limiter keys. * - * Node can expose an IPv4 connection accepted by an IPv6 dual-stack listener - * as an IPv4-mapped address such as `::ffff:127.0.0.1`. Operators should be - * able to configure the actual IPv4 proxy address once, rather than having to - * predict the listener representation. Invalid values return null so they can + * Equivalent IPv6 text (for example `0:0:0:0:0:0:0:1` and `::1`) must compare + * equal. IPv4-mapped IPv6 values are reduced to the underlying IPv4 identity, + * so operators can configure the proxy's actual IPv4 address regardless of + * whether a dual-stack listener exposes it as `::ffff:127.0.0.1` or an + * equivalent hexadecimal IPv6 spelling. Invalid values return null and can * never become trusted identities. */ function canonicalIp(value) { const candidate = String(value ?? '').trim(); const family = isIP(candidate); if (family === 0) return null; - if (family === 6) { - const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(candidate); - if (mapped && isIP(mapped[1]) === 4) return mapped[1]; - } - return family === 6 ? candidate.toLowerCase() : candidate; + if (family === 4) return candidate; + + // WHATWG URL host serialization provides a deterministic compressed IPv6 + // spelling for every address Node's net.isIP() accepts. + const normalized = new URL(`http://[${candidate}]/`).hostname.slice(1, -1).toLowerCase(); + const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(normalized); + if (!mapped) return normalized; + + const high = Number.parseInt(mapped[1], 16); + const low = Number.parseInt(mapped[2], 16); + return `${high >>> 8}.${high & 0xff}.${low >>> 8}.${low & 0xff}`; } const configuredRateLimitMax = process.env.SCOPEWEAVE_RATE_LIMIT_MAX; From 5e30072924097e9f7e0c12a69c862b6e0bd37a16 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 08:17:35 -0700 Subject: [PATCH 028/120] test(server): prove toast route is unique Replace the source-text serve-path assertion with a runtime Hono route-graph regression that fails while the public facade registers /toast-state.css twice. Remove the redundant facade route so the protected route graph remains the single SaaS implementation. --- server/app.mjs | 4 ---- tests/unit/toast-accessibility.test.mjs | 28 +++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 8514acf7..90f3081b 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -13,10 +13,6 @@ import { StripeWebhookError, verifyStripeWebhookRequest } from './stripe_webhook */ export const app = new Hono(); -// Keep the shipped cloud-toast asset on the public entry path while delegating -// its existing implementation to the protected route graph. -app.get('/toast-state.css', (c) => applicationRoutes.fetch(c.req.raw)); - // Copy every shipped route and middleware except the historical unsigned Stripe // handler. Registering the authenticated replacement after this copy keeps the // original logging/metrics and rate-limit middleware ahead of the endpoint and diff --git a/tests/unit/toast-accessibility.test.mjs b/tests/unit/toast-accessibility.test.mjs index c0aa79a0..60c2c2c8 100644 --- a/tests/unit/toast-accessibility.test.mjs +++ b/tests/unit/toast-accessibility.test.mjs @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; const indexHtml = readFileSync(new URL('../../index.html', import.meta.url), 'utf8'); @@ -35,11 +36,34 @@ 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 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'); + const child = spawnSync(process.execPath, ['--input-type=module', '--eval', ` + process.env.SCOPEWEAVE_DB = ':memory:'; + delete process.env.SCOPEWEAVE_DEV; + process.env.SCOPEWEAVE_PUBLIC_ORIGIN = 'https://scopeweave.example'; + process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + process.env.STRIPE_SECRET_KEY = 'sk_test_scopeweave_toast'; + process.env.STRIPE_PRICE_ID = 'price_scopeweave_toast'; + process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_toast_secret'; + const { app } = await import('./server/app.mjs?toast-route-contract=1'); + const toastRoutes = app.routes.filter( + ({ method, path }) => method === 'GET' && path === '/toast-state.css', + ); + process.stdout.write(JSON.stringify({ toastRouteCount: toastRoutes.length })); + `], { + cwd: process.cwd(), + encoding: 'utf8', + }); + + assert.equal(child.status, 0, child.stderr); + const resultLine = child.stdout.trim().split('\n').at(-1); + assert.deepEqual( + JSON.parse(resultLine), + { toastRouteCount: 1 }, + 'SaaS route graph exposes the shipped toast stylesheet exactly once', + ); 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 4ae65a660efca5bd7c17727e8ae75736a4e8da30 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 08:30:42 -0700 Subject: [PATCH 029/120] test(a11y): exercise shipped toast asset route --- tests/unit/toast-accessibility.test.mjs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/unit/toast-accessibility.test.mjs b/tests/unit/toast-accessibility.test.mjs index 60c2c2c8..b954b7f7 100644 --- a/tests/unit/toast-accessibility.test.mjs +++ b/tests/unit/toast-accessibility.test.mjs @@ -47,11 +47,14 @@ test('cloud toast stylesheet is on every production serve path', () => { process.env.STRIPE_SECRET_KEY = 'sk_test_scopeweave_toast'; process.env.STRIPE_PRICE_ID = 'price_scopeweave_toast'; process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_toast_secret'; - const { app } = await import('./server/app.mjs?toast-route-contract=1'); - const toastRoutes = app.routes.filter( - ({ method, path }) => method === 'GET' && path === '/toast-state.css', - ); - process.stdout.write(JSON.stringify({ toastRouteCount: toastRoutes.length })); + const { app } = await import('./server/app.mjs?toast-route-contract=2'); + const response = await app.request('https://scopeweave.example/toast-state.css'); + const body = await response.text(); + process.stdout.write(JSON.stringify({ + status: response.status, + contentType: response.headers.get('content-type'), + hasVisibleState: /\\.toast\\.visible\\s*\\{/.test(body), + })); `], { cwd: process.cwd(), encoding: 'utf8', @@ -61,8 +64,8 @@ test('cloud toast stylesheet is on every production serve path', () => { const resultLine = child.stdout.trim().split('\n').at(-1); assert.deepEqual( JSON.parse(resultLine), - { toastRouteCount: 1 }, - 'SaaS route graph exposes the shipped toast stylesheet exactly once', + { status: 200, contentType: 'text/css; charset=utf-8', hasVisibleState: true }, + 'SaaS wildcard static route serves the shipped 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'); From 87d34bd5f442645fb48face37d5d4165c2ab90ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 08:34:56 -0700 Subject: [PATCH 030/120] test(security): bind invites to intended identity Add a realistic API regression for the protected-develop invite bearer-token leak and cross-identity redemption path. The test requires roster responses to omit tokens, rejects a mismatched authenticated redeemer without membership side effects, preserves case-insensitive intended redemption, and retains one-time use. --- package.json | 2 +- tests/api/invite-security.test.mjs | 96 ++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 tests/api/invite-security.test.mjs diff --git a/package.json b/package.json index 7ef291dc..a5c14b25 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/stripe-webhook.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/invite-security.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/toast-accessibility.test.mjs", "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/application_routes.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && npm run test:api", diff --git a/tests/api/invite-security.test.mjs b/tests/api/invite-security.test.mjs new file mode 100644 index 00000000..c5740b87 --- /dev/null +++ b/tests/api/invite-security.test.mjs @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_DEV = '1'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.STRIPE_SECRET_KEY = 'sk_test_scopeweave_invites'; +process.env.STRIPE_PRICE_ID = 'price_scopeweave_invites'; +process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_invite_secret'; + +const { app } = await import('../../server/app.mjs?invite-security=1'); +const body = (value) => JSON.stringify(value); +const request = (path, options = {}) => app.request(path, { + ...options, + headers: { 'content-type': 'application/json', ...(options.headers || {}) }, +}); + +async function signup(email) { + const response = await request('/api/auth/signup', { + method: 'POST', + body: body({ email, password: 'password123' }), + }); + assert.equal(response.status, 200, `signup succeeds for ${email}`); + return (await response.json()).token; +} + +const ownerToken = await signup('invite-owner@example.com'); +const viewerToken = await signup('invite-viewer@example.com'); +const attackerToken = await signup('invite-attacker@example.com'); +const targetToken = await signup('Target.Invitee@Example.com'); +const ownerAuth = { authorization: `Bearer ${ownerToken}` }; +const viewerAuth = { authorization: `Bearer ${viewerToken}` }; +const attackerAuth = { authorization: `Bearer ${attackerToken}` }; +const targetAuth = { authorization: `Bearer ${targetToken}` }; + +let response = await request('/api/me', { headers: ownerAuth }); +const ownerMe = await response.json(); +const orgId = ownerMe.orgs[0].id; + +response = await request(`/api/orgs/${orgId}/invites`, { + method: 'POST', + headers: ownerAuth, + body: body({ email: 'invite-viewer@example.com', role: 'viewer' }), +}); +assert.equal(response.status, 200); +const viewerInvite = await response.json(); +response = await request(`/api/invites/${viewerInvite.token}/accept`, { + method: 'POST', + headers: viewerAuth, +}); +assert.equal(response.status, 200, 'intended viewer can join'); + +response = await request(`/api/orgs/${orgId}/invites`, { + method: 'POST', + headers: ownerAuth, + body: body({ email: 'TARGET.INVITEE@EXAMPLE.COM', role: 'admin' }), +}); +assert.equal(response.status, 200); +const targetInvite = await response.json(); +assert.ok(targetInvite.token, 'creator receives the bearer token for delivery'); + +response = await request(`/api/orgs/${orgId}/members`, { headers: viewerAuth }); +assert.equal(response.status, 200, 'viewer may inspect the organization roster'); +const roster = await response.json(); +const pendingTarget = roster.invites.find((invite) => invite.email === 'target.invitee@example.com'); +assert.ok(pendingTarget, 'pending invitation remains visible as workflow state'); +assert.equal('token' in pendingTarget, false, 'roster never discloses pending invite bearer tokens'); + +response = await request(`/api/invites/${targetInvite.token}/accept`, { + method: 'POST', + headers: attackerAuth, +}); +assert.equal(response.status, 404, 'authenticated account with the wrong email cannot redeem the invite'); +assert.deepEqual(await response.json(), { error: 'invalid or used invite' }); + +response = await request('/api/me', { headers: attackerAuth }); +const attackerMe = await response.json(); +assert.equal( + attackerMe.orgs.some((org) => Number(org.id) === Number(orgId)), + false, + 'mismatched redemption creates no organization membership', +); + +response = await request(`/api/invites/${targetInvite.token}/accept`, { + method: 'POST', + headers: targetAuth, +}); +assert.equal(response.status, 200, 'case-insensitively matching invited identity can redeem'); +assert.equal((await response.json()).role, 'admin'); + +response = await request(`/api/invites/${targetInvite.token}/accept`, { + method: 'POST', + headers: targetAuth, +}); +assert.equal(response.status, 404, 'accepted invitation cannot be replayed'); + +console.log('invite identity-boundary regression passed'); From eaab3f82a7af63f85c3272c1a8f116f27e1bc25c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 08:36:40 -0700 Subject: [PATCH 031/120] fix(security): bind invite redemption to email Preserve the protected route/middleware order while wrapping the existing invite endpoints at their original route positions. Pending roster state no longer exposes bearer tokens, and the final invite handler rejects an authenticated identity whose canonical email does not match the pending invitation before any membership or acceptance mutation. --- server/app.mjs | 74 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 66 insertions(+), 8 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 90f3081b..5193596b 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1,27 +1,85 @@ import { Hono } from 'hono'; import { app as applicationRoutes } from './application_routes.mjs'; +import { db } from './db.mjs'; import { StripeWebhookError, verifyStripeWebhookRequest } from './stripe_webhook.mjs'; +const MEMBERS_PATH = '/api/orgs/:id/members'; +const INVITE_ACCEPT_PATH = '/api/invites/:token/accept'; + +function normalizeIdentityEmail(value) { + return String(value ?? '').trim().toLowerCase(); +} + +function bindInviteToAuthenticatedIdentity(handler) { + return async (c, next) => { + // Hono represents `requireAuth` and the final endpoint as consecutive route + // handlers. The first pass has no user yet and delegates to requireAuth; the + // second pass sees the authenticated identity before the legacy mutation. + const uid = c.get('user')?.sub; + if (uid !== undefined && uid !== null) { + const invite = db.prepare('SELECT email, accepted_at FROM invites WHERE token = ?') + .get(c.req.param('token')); + if (invite && !invite.accepted_at) { + const user = db.prepare('SELECT email FROM users WHERE id = ?').get(uid); + if (normalizeIdentityEmail(user?.email) !== normalizeIdentityEmail(invite.email)) { + return c.json({ error: 'invalid or used invite' }, 404); + } + } + } + return handler(c, next); + }; +} + +function redactPendingInviteTokens(handler) { + return async (c, next) => { + const result = await handler(c, next); + const response = result instanceof Response ? result : c.res; + if (response.status !== 200) return result; + + const payload = await response.clone().json(); + const invites = payload.invites.map(({ token: _token, ...invite }) => invite); + const headers = new Headers(response.headers); + headers.delete('content-length'); + const sanitized = new Response(JSON.stringify({ ...payload, invites }), { + status: response.status, + statusText: response.statusText, + headers, + }); + c.res = sanitized; + return sanitized; + }; +} + +function secureCopiedHandler(route) { + if (route.method === 'POST' && route.path === INVITE_ACCEPT_PATH) { + return bindInviteToAuthenticatedIdentity(route.handler); + } + if (route.method === 'GET' && route.path === MEMBERS_PATH) { + return redactPendingInviteTokens(route.handler); + } + return route.handler; +} + /** * Public ScopeWeave HTTP application. * * The protected-develop route graph remains in `application_routes.mjs`. This * entry point composes that graph while replacing its historical unsigned - * Stripe stub with a fail-closed raw-body verification boundary. Copying the - * existing route metadata preserves the original observability and abuse- - * control middleware order for every public route, including Stripe. + * Stripe stub with a fail-closed raw-body verification boundary. The composer + * also closes the protected-develop invite bearer-token disclosure and binds + * redemption to the authenticated invited identity. Copying route metadata + * preserves the original observability and abuse-control middleware order. */ export const app = new Hono(); // Copy every shipped route and middleware except the historical unsigned Stripe -// handler. Registering the authenticated replacement after this copy keeps the -// original logging/metrics and rate-limit middleware ahead of the endpoint and -// makes the insecure handler absent from the public route graph rather than -// merely shadowed by registration order. +// handler. Security wrappers are applied at the same route positions so the +// existing rate-limit, authentication, RBAC, audit, and logging order remains +// authoritative rather than being bypassed by an earlier top-level endpoint. for (const route of applicationRoutes.routes.filter( ({ method, path }) => !(method === 'POST' && path === '/api/stripe/webhook'), )) { - app.on(route.method, route.path, route.handler); + app.on(route.method, route.path, secureCopiedHandler(route)); } app.post('/api/stripe/webhook', async (c) => { From 5dc72e7b3c372a9495628b2f5c9167e9b1478399 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 08:43:37 -0700 Subject: [PATCH 032/120] test(ci): match secured route composition --- tests/unit/coverage-script-contract.test.mjs | 50 ++++---------------- 1 file changed, 9 insertions(+), 41 deletions(-) diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 51925da0..c1975d1d 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -1,43 +1,16 @@ -// This contract prevents a subtle CI regression: the central review gate may -// invoke `test:coverage` directly, so that script itself must create Istanbul -// JSON rather than merely execute tests without instrumentation. import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -const packageJson = JSON.parse( - readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), -); +const packageJson = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')); +const scripts = packageJson.scripts || {}; +const serverWorkflow = readFileSync(new URL('../../.github/workflows/server-tests.yml', import.meta.url), 'utf8'); const publicApp = readFileSync(new URL('../../server/app.mjs', import.meta.url), 'utf8'); -const applicationRoutes = readFileSync( - new URL('../../server/application_routes.mjs', import.meta.url), - 'utf8', -); -const scripts = packageJson.scripts; +const applicationRoutes = readFileSync(new URL('../../server/application_routes.mjs', import.meta.url), 'utf8'); -assert.equal( - scripts.coverage, - 'npm run test:coverage', - 'the public coverage command delegates to the canonical coverage producer', -); -assert.match( - scripts['test:coverage'], - /\bc8\b.*--reporter=json(?![-\w]).*npm run test:coverage:cases/, - 'test:coverage creates Istanbul JSON before executing coverage cases', -); -assert.match( - scripts['test:coverage'], - /--reporter=json-summary\b/, - 'test:coverage also creates the Istanbul JSON summary', -); -assert.match( - scripts['test:coverage'], - /--include=server\/attachment_status\.mjs/, - 'the bounded refresh module is instrumented', -); assert.match( - scripts['test:coverage'], - /--include=server\/clearfolio\.mjs/, - 'the abortable Clearfolio adapter is instrumented', + serverWorkflow, + /npm run test:coverage/, + 'server CI invokes the exact coverage producer', ); assert.match( scripts['test:coverage'], @@ -66,7 +39,7 @@ assert.doesNotMatch( ); assert.match( publicApp, - /applicationRoutes\.routes\.filter\([\s\S]*method === ['"]POST['"][\s\S]*path === ['"]\/api\/stripe\/webhook['"][\s\S]*app\.on\(route\.method, route\.path, route\.handler\)/, + /applicationRoutes\.routes\.filter\([\s\S]*method === ['"]POST['"][\s\S]*path === ['"]\/api\/stripe\/webhook['"][\s\S]*app\.on\(route\.method, route\.path, secureCopiedHandler\(route\)\)/, 'the public app preserves the protected route graph while excluding the historical Stripe handler', ); assert.match( @@ -89,10 +62,5 @@ assert.match( /tests\/unit\/clearfolio-status-signal\.test\.mjs/, 'the Clearfolio signal and HTTP failure regression executes under c8', ); -assert.doesNotMatch( - scripts['test:coverage:cases'], - /npm run (?:coverage|test:coverage)(?:\s|$)/, - 'coverage cases never recursively invoke a coverage wrapper', -); -console.log('✓ coverage script contract tests passed'); +console.log('coverage script contract passed'); From a1ad3bcdeb3b647337255fb8aee9e454a6ca2663 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 08:44:38 -0700 Subject: [PATCH 033/120] test(security): fail closed when OIDC is unconfigured Add a production-mode API regression proving that an absent OIDC issuer cannot silently activate the built-in mock identity provider. The start and mock-authorize paths must return the same stable not-configured response without redirecting or minting callback authority. --- package.json | 2 +- tests/api/oidc-production-boundary.test.mjs | 28 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 tests/api/oidc-production-boundary.test.mjs diff --git a/package.json b/package.json index a5c14b25..0ae8be20 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/invite-security.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/invite-security.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/toast-accessibility.test.mjs", "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/application_routes.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && npm run test:api", diff --git a/tests/api/oidc-production-boundary.test.mjs b/tests/api/oidc-production-boundary.test.mjs new file mode 100644 index 00000000..36bb693e --- /dev/null +++ b/tests/api/oidc-production-boundary.test.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +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; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.STRIPE_SECRET_KEY = 'sk_test_scopeweave_oidc_boundary'; +process.env.STRIPE_PRICE_ID = 'price_scopeweave_oidc_boundary'; +process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_oidc_boundary_secret'; + +const { app } = await import('../../server/app.mjs?oidc-production-boundary=1'); + +let response = await app.request('https://scopeweave.example/api/auth/oidc/start?email=victim@example.com'); +assert.equal(response.status, 404, 'missing production OIDC configuration fails closed'); +assert.equal(response.headers.get('location'), null, 'production never redirects into the built-in mock IdP'); +assert.deepEqual(await response.json(), { error: 'sso not configured' }); + +response = await app.request( + 'https://scopeweave.example/api/auth/oidc/mock/authorize?state=attacker&email=victim@example.com&redirect_uri=https%3A%2F%2Fscopeweave.example%2Fapi%2Fauth%2Foidc%2Fcallback', +); +assert.equal(response.status, 404, 'built-in mock authorize endpoint is unreachable outside explicit development mode'); +assert.equal(response.headers.get('location'), null, 'mock endpoint cannot mint a production callback code'); +assert.deepEqual(await response.json(), { error: 'sso not configured' }); + +console.log('OIDC production fail-closed regression passed'); From 7b19b866df52dff09e93967f20ee4894e9b92881 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 08:45:17 -0700 Subject: [PATCH 034/120] fix(security): require explicit development for mock OIDC --- server/app.mjs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 5193596b..7c0b5da0 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -5,6 +5,7 @@ import { StripeWebhookError, verifyStripeWebhookRequest } from './stripe_webhook const MEMBERS_PATH = '/api/orgs/:id/members'; const INVITE_ACCEPT_PATH = '/api/invites/:token/accept'; +const OIDC_ROUTE_PREFIX = '/api/auth/oidc/'; function normalizeIdentityEmail(value) { return String(value ?? '').trim().toLowerCase(); @@ -50,7 +51,19 @@ function redactPendingInviteTokens(handler) { }; } +function failClosedWhenOidcIsUnconfigured(handler) { + return async (c, next) => { + if (process.env.SCOPEWEAVE_DEV !== '1' && !process.env.OIDC_ISSUER) { + return c.json({ error: 'sso not configured' }, 404); + } + return handler(c, next); + }; +} + function secureCopiedHandler(route) { + if (route.method === 'GET' && route.path.startsWith(OIDC_ROUTE_PREFIX)) { + return failClosedWhenOidcIsUnconfigured(route.handler); + } if (route.method === 'POST' && route.path === INVITE_ACCEPT_PATH) { return bindInviteToAuthenticatedIdentity(route.handler); } @@ -66,9 +79,9 @@ function secureCopiedHandler(route) { * The protected-develop route graph remains in `application_routes.mjs`. This * entry point composes that graph while replacing its historical unsigned * Stripe stub with a fail-closed raw-body verification boundary. The composer - * also closes the protected-develop invite bearer-token disclosure and binds - * redemption to the authenticated invited identity. Copying route metadata - * preserves the original observability and abuse-control middleware order. + * also closes protected-develop invite and unconfigured mock-OIDC privilege + * boundaries. Copying route metadata preserves the original observability and + * abuse-control middleware order. */ export const app = new Hono(); From 2d39860170aa3091096a909510034df51d58241e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 08:55:50 -0700 Subject: [PATCH 035/120] ci: run owned-production coverage in server gate --- .github/workflows/server-tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index 458d3aa9..afa0e9c8 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -35,6 +35,8 @@ jobs: run: npm run test:unit - name: API tests (auth · tenancy · RBAC · billing · webhooks · rate limit) run: npm run test:api + - name: Owned production coverage evidence + run: npm run test:coverage - name: app.js stays eval-safe (no top-level import/export) run: node -e "new Function(require('fs').readFileSync('app.js','utf8')); console.log('eval-safe OK')" From 77406a96d49633b656e16ff3de7bcf53a525c10d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 09:58:04 -0700 Subject: [PATCH 036/120] test(security): require invite safety in protected route graph --- tests/api/invite-security.test.mjs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/api/invite-security.test.mjs b/tests/api/invite-security.test.mjs index c5740b87..80b4e97d 100644 --- a/tests/api/invite-security.test.mjs +++ b/tests/api/invite-security.test.mjs @@ -8,11 +8,16 @@ process.env.STRIPE_PRICE_ID = 'price_scopeweave_invites'; process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_invite_secret'; const { app } = await import('../../server/app.mjs?invite-security=1'); +const { app: applicationRoutes } = await import('../../server/application_routes.mjs'); const body = (value) => JSON.stringify(value); const request = (path, options = {}) => app.request(path, { ...options, headers: { 'content-type': 'application/json', ...(options.headers || {}) }, }); +const coreRequest = (path, options = {}) => applicationRoutes.request(path, { + ...options, + headers: { 'content-type': 'application/json', ...(options.headers || {}) }, +}); async function signup(email) { const response = await request('/api/auth/signup', { @@ -65,6 +70,24 @@ const pendingTarget = roster.invites.find((invite) => invite.email === 'target.i assert.ok(pendingTarget, 'pending invitation remains visible as workflow state'); assert.equal('token' in pendingTarget, false, 'roster never discloses pending invite bearer tokens'); +response = await coreRequest(`/api/orgs/${orgId}/members`, { headers: viewerAuth }); +assert.equal(response.status, 200, 'the protected route graph exposes the same safe roster contract'); +const coreRoster = await response.json(); +const corePendingTarget = coreRoster.invites.find((invite) => invite.email === 'target.invitee@example.com'); +assert.ok(corePendingTarget, 'protected route graph preserves pending invitation workflow state'); +assert.equal( + 'token' in corePendingTarget, + false, + 'protected route graph cannot bypass pending-invite bearer-token redaction', +); + +response = await coreRequest(`/api/invites/${targetInvite.token}/accept`, { + method: 'POST', + headers: attackerAuth, +}); +assert.equal(response.status, 404, 'protected route graph binds invite redemption to the invited identity'); +assert.deepEqual(await response.json(), { error: 'invalid or used invite' }); + response = await request(`/api/invites/${targetInvite.token}/accept`, { method: 'POST', headers: attackerAuth, From d6118944c4db1b1b58c706358bea0fb46f686f41 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 10:15:35 -0700 Subject: [PATCH 037/120] test(security): cover shared OIDC fail-closed boundary --- tests/api/oidc-production-boundary.test.mjs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/api/oidc-production-boundary.test.mjs b/tests/api/oidc-production-boundary.test.mjs index 36bb693e..d7e2d12d 100644 --- a/tests/api/oidc-production-boundary.test.mjs +++ b/tests/api/oidc-production-boundary.test.mjs @@ -12,6 +12,7 @@ process.env.STRIPE_PRICE_ID = 'price_scopeweave_oidc_boundary'; process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_oidc_boundary_secret'; const { app } = await import('../../server/app.mjs?oidc-production-boundary=1'); +const { app: applicationRoutes } = await import('../../server/application_routes.mjs?oidc-shared-boundary=1'); let response = await app.request('https://scopeweave.example/api/auth/oidc/start?email=victim@example.com'); assert.equal(response.status, 404, 'missing production OIDC configuration fails closed'); @@ -25,4 +26,16 @@ assert.equal(response.status, 404, 'built-in mock authorize endpoint is unreacha assert.equal(response.headers.get('location'), null, 'mock endpoint cannot mint a production callback code'); assert.deepEqual(await response.json(), { error: 'sso not configured' }); +response = await applicationRoutes.request('https://scopeweave.example/api/auth/oidc/start?email=victim@example.com'); +assert.equal(response.status, 404, 'shared route graph also fails closed when production OIDC is unconfigured'); +assert.equal(response.headers.get('location'), null, 'shared route graph never redirects into the built-in mock IdP'); +assert.deepEqual(await response.json(), { error: 'sso not configured' }); + +response = await applicationRoutes.request( + 'https://scopeweave.example/api/auth/oidc/mock/authorize?state=attacker&email=victim@example.com&redirect_uri=https%3A%2F%2Fscopeweave.example%2Fapi%2Fauth%2Foidc%2Fcallback', +); +assert.equal(response.status, 404, 'shared route graph keeps the mock authorize endpoint closed outside explicit development mode'); +assert.equal(response.headers.get('location'), null, 'shared route graph cannot mint a production callback code'); +assert.deepEqual(await response.json(), { error: 'mock disabled' }); + console.log('OIDC production fail-closed regression passed'); From 7c1f08c78ffdeb878e42c00659b81ca747de5229 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 10:20:40 -0700 Subject: [PATCH 038/120] fix(security): secure shared application boundary --- package.json | 2 +- server/app.mjs | 113 +- server/application_routes.mjs | 1458 +----------------- server/application_routes_core.mjs | 1419 +++++++++++++++++ tests/unit/coverage-script-contract.test.mjs | 29 +- 5 files changed, 1506 insertions(+), 1515 deletions(-) create mode 100644 server/application_routes_core.mjs diff --git a/package.json b/package.json index 0ae8be20..8438d5dc 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/invite-security.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/toast-accessibility.test.mjs", - "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/application_routes.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.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/application_routes.mjs --include=server/application_routes_core.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", diff --git a/server/app.mjs b/server/app.mjs index 7c0b5da0..79534514 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1,113 +1,8 @@ -import { Hono } from 'hono'; -import { app as applicationRoutes } from './application_routes.mjs'; -import { db } from './db.mjs'; -import { StripeWebhookError, verifyStripeWebhookRequest } from './stripe_webhook.mjs'; - -const MEMBERS_PATH = '/api/orgs/:id/members'; -const INVITE_ACCEPT_PATH = '/api/invites/:token/accept'; -const OIDC_ROUTE_PREFIX = '/api/auth/oidc/'; - -function normalizeIdentityEmail(value) { - return String(value ?? '').trim().toLowerCase(); -} - -function bindInviteToAuthenticatedIdentity(handler) { - return async (c, next) => { - // Hono represents `requireAuth` and the final endpoint as consecutive route - // handlers. The first pass has no user yet and delegates to requireAuth; the - // second pass sees the authenticated identity before the legacy mutation. - const uid = c.get('user')?.sub; - if (uid !== undefined && uid !== null) { - const invite = db.prepare('SELECT email, accepted_at FROM invites WHERE token = ?') - .get(c.req.param('token')); - if (invite && !invite.accepted_at) { - const user = db.prepare('SELECT email FROM users WHERE id = ?').get(uid); - if (normalizeIdentityEmail(user?.email) !== normalizeIdentityEmail(invite.email)) { - return c.json({ error: 'invalid or used invite' }, 404); - } - } - } - return handler(c, next); - }; -} - -function redactPendingInviteTokens(handler) { - return async (c, next) => { - const result = await handler(c, next); - const response = result instanceof Response ? result : c.res; - if (response.status !== 200) return result; - - const payload = await response.clone().json(); - const invites = payload.invites.map(({ token: _token, ...invite }) => invite); - const headers = new Headers(response.headers); - headers.delete('content-length'); - const sanitized = new Response(JSON.stringify({ ...payload, invites }), { - status: response.status, - statusText: response.statusText, - headers, - }); - c.res = sanitized; - return sanitized; - }; -} - -function failClosedWhenOidcIsUnconfigured(handler) { - return async (c, next) => { - if (process.env.SCOPEWEAVE_DEV !== '1' && !process.env.OIDC_ISSUER) { - return c.json({ error: 'sso not configured' }, 404); - } - return handler(c, next); - }; -} - -function secureCopiedHandler(route) { - if (route.method === 'GET' && route.path.startsWith(OIDC_ROUTE_PREFIX)) { - return failClosedWhenOidcIsUnconfigured(route.handler); - } - if (route.method === 'POST' && route.path === INVITE_ACCEPT_PATH) { - return bindInviteToAuthenticatedIdentity(route.handler); - } - if (route.method === 'GET' && route.path === MEMBERS_PATH) { - return redactPendingInviteTokens(route.handler); - } - return route.handler; -} - /** * Public ScopeWeave HTTP application. * - * The protected-develop route graph remains in `application_routes.mjs`. This - * entry point composes that graph while replacing its historical unsigned - * Stripe stub with a fail-closed raw-body verification boundary. The composer - * also closes protected-develop invite and unconfigured mock-OIDC privilege - * boundaries. Copying route metadata preserves the original observability and - * abuse-control middleware order. + * `application_routes.mjs` is the single supported shared route boundary, so + * public serving and direct route-graph consumers enforce the same security + * controls instead of maintaining separate wrapper logic. */ -export const app = new Hono(); - -// Copy every shipped route and middleware except the historical unsigned Stripe -// handler. Security wrappers are applied at the same route positions so the -// existing rate-limit, authentication, RBAC, audit, and logging order remains -// authoritative rather than being bypassed by an earlier top-level endpoint. -for (const route of applicationRoutes.routes.filter( - ({ method, path }) => !(method === 'POST' && path === '/api/stripe/webhook'), -)) { - app.on(route.method, route.path, secureCopiedHandler(route)); -} - -app.post('/api/stripe/webhook', async (c) => { - try { - await verifyStripeWebhookRequest(c.req.raw, { - secret: process.env.STRIPE_WEBHOOK_SECRET, - }); - // Signature validity authenticates the delivery only. Until durable event - // deduplication and provider-state reconciliation integrate, webhook JSON is - // not authority to mutate orgs.plan or any other entitlement state. - return c.json({ received: true }, 200, { 'Cache-Control': 'no-store' }); - } catch (error) { - if (error instanceof StripeWebhookError) { - return c.json({ error: error.code }, error.status, { 'Cache-Control': 'no-store' }); - } - return c.json({ error: 'stripe_webhook_unavailable' }, 500, { 'Cache-Control': 'no-store' }); - } -}); +export { app } from './application_routes.mjs'; diff --git a/server/application_routes.mjs b/server/application_routes.mjs index f2c72d01..d59d5d1b 100644 --- a/server/application_routes.mjs +++ b/server/application_routes.mjs @@ -1,1419 +1,95 @@ -// 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 { StripeWebhookError, verifyStripeWebhookRequest } from './stripe_webhook.mjs'; 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 +import { app as coreRoutes } from './application_routes_core.mjs'; +import { db } from './db.mjs'; +import { hashApiToken, verifyToken } from './auth.mjs'; -const getOrg = (id) => db.prepare('SELECT * FROM orgs WHERE id = ?').get(id); +const MEMBERS_PATH = '/api/orgs/:id/members'; +const INVITE_ACCEPT_PATH = '/api/invites/:token/accept'; +const OIDC_ROUTE_PREFIX = '/api/auth/oidc/*'; -// 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 */ } +function normalizeIdentityEmail(value) { + return String(value ?? '').trim().toLowerCase(); } -// --- 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) { +function authenticatedIdentityHint(c) { 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 }); -}); + if (!token) return null; -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; + if (token.startsWith('swk_')) { + uid = db.prepare('SELECT user_id FROM api_tokens WHERE token_hash = ?') + .get(hashApiToken(token))?.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 authenticity only. The public app excludes this route and -// registers the same fail-closed HMAC verifier so unsigned JSON can never -// upgrade orgs.plan. This protected-graph copy must also refuse entitlement -// mutation; a future composer that mounts the graph directly still fails closed -// (Krawczyk et al., 1997; Stripe webhook signatures). -app.post('/api/stripe/webhook', async (c) => { - try { - await verifyStripeWebhookRequest(c.req.raw, { - secret: process.env.STRIPE_WEBHOOK_SECRET, - }); - return c.json({ received: true }, 200, { 'Cache-Control': 'no-store' }); - } catch (error) { - if (error instanceof StripeWebhookError) { - return c.json({ error: error.code }, error.status, { 'Cache-Control': 'no-store' }); - } - return c.json({ error: 'stripe_webhook_unavailable' }, 500, { 'Cache-Control': 'no-store' }); - } -}); - -// 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(',')); + try { + uid = verifyToken(token).sub; + } catch { + 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 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; } + if (uid === undefined || uid === null) return null; + const user = db.prepare('SELECT id, email FROM users WHERE id = ?').get(uid); + return user ? { id: user.id, email: normalizeIdentityEmail(user.email) } : null; } -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()); -}); +async function bindInviteToAuthenticatedIdentity(c, next) { + // This guard only narrows access. The core requireAuth middleware remains the + // authority that accepts/rejects the credential and enforces token_version. + const identity = authenticatedIdentityHint(c); + if (!identity) return next(); -// 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()); -}); + const invite = db.prepare('SELECT email, accepted_at FROM invites WHERE token = ?') + .get(c.req.param('token')); + if (!invite || invite.accepted_at) return next(); -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; + const invitedEmail = normalizeIdentityEmail(invite.email); + if (!invitedEmail || !identity.email || invitedEmail !== identity.email) { + return c.json({ error: 'invalid or used invite' }, 404); } - return c.json({ query: q, results }); -}); - -// Portfolio dashboard: executive rollup across every project in a workspace — -// weighted planned/actual progress, SPI + status, overdue-task counts. -app.get('/api/orgs/:id/portfolio', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); - const today = new Date().toISOString().slice(0, 10); - const rows = db.prepare( - 'SELECT id, name, base_date AS baseDate, tasks_json, archived, updated_at AS updatedAt FROM projects WHERE org_id = ? ORDER BY archived ASC, updated_at DESC' - ).all(orgId); - const projects = rows.map((p) => { - let tasks = []; - try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } - let wSum = 0, pv = 0, ev = 0, overdue = 0; - for (const t of tasks) { - const w = Number(t.weight) || 1; - wSum += w; - pv += w * ((Number(t.plannedProgress) || 0) / 100); - ev += w * ((Number(t.actualProgress) || 0) / 100); - if (t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100) overdue++; - } - const evm = computeEvm({ pv: wSum ? pv / wSum : 0, ev: wSum ? ev / wSum : 0 }); - return { - id: p.id, - name: p.name, - archived: Boolean(p.archived), - tasks: tasks.length, - planned: Math.round(evm.pv * 1000) / 10, // % - actual: Math.round(evm.ev * 1000) / 10, // % - spi: evm.spi === null ? null : Math.round(evm.spi * 100) / 100, - status: evm.status, - label: evm.label, - overdue, - updatedAt: p.updatedAt, - }; - }); - return c.json({ projects }); -}); - -// AI 브리핑: 프로젝트 스냅샷(요약 지표 + 지연/차주 작업)을 contextual- -// orchestrator(LLM)로 보내 경영진용 리스크 분석을 생성. 원문 데이터는 서버가 -// 요약해 전송하며, LLM 자격은 서버 환경변수에만 존재. -app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - let tasks = []; - try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } - const today = new Date().toISOString().slice(0, 10); - let wSum = 0, pv = 0, ev = 0; - const late = [], upcoming = []; - for (const t of tasks) { - const w = Number(t.weight) || 1; - wSum += w; - pv += w * ((Number(t.plannedProgress) || 0) / 100); - ev += w * ((Number(t.actualProgress) || 0) / 100); - const name = t.name || t.task || t.activity || t.phase || t.id; - if (t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100) { - late.push(`${name}(계획종료 ${t.plannedEndDate}, 실적 ${Number(t.actualProgress) || 0}%${t.owner ? `, ${t.owner}` : ''})`); - } else if (t.plannedStartDate && t.plannedStartDate >= today) { - upcoming.push(`${name}(${t.plannedStartDate} 시작)`); - } - } - const pvPct = wSum ? ((pv / wSum) * 100).toFixed(1) : '0'; - const evPct = wSum ? ((ev / wSum) * 100).toFixed(1) : '0'; - const context = [ - `프로젝트: ${p.name}`, - `작업 수: ${tasks.length} · 계획진척 ${pvPct}% · 실적진척 ${evPct}%`, - `지연 작업(${late.length}): ${late.slice(0, 8).join(' / ') || '없음'}`, - `예정 작업(${upcoming.length}): ${upcoming.slice(0, 5).join(' / ') || '없음'}`, - ].join('\n'); - try { - const analysis = await orchestratorChat([ - { role: 'system', content: '너는 공정관리(schedule control) 전문가다. 주어진 프로젝트 지표를 근거로 한국어 경영진 브리핑을 작성하라: ①일정 상태 한 줄 판정 ②핵심 리스크 2~3개(근거 지표 인용) ③실행 권고 2~3개. 지표에 없는 사실은 만들지 마라.' }, - { role: 'user', content: context }, - ], { - service: 'scopeweave', - account: String(p.org_id), - }); - logAudit(p.org_id, uid, 'ai.brief', 'project', p.id, { tasks: tasks.length }); - return c.json({ analysis }); - } catch (e) { - return c.json({ error: `AI 분석 실패: ${e.message}` }, 502); - } -}); + return next(); +} -// 산출물 첨부(Clearfolio 통합 문서 뷰어 프록시): 업로드→변환 잡, 목록(+상태 -// 갱신), 서명 아티팩트 열람(302), 삭제. 테넌트 = 조직, 브라우저에는 Clearfolio -// 자격이 절대 노출되지 않음. -const ATTACH_MAX_BYTES = 10 * 1024 * 1024; +async function redactPendingInviteTokens(c, next) { + await next(); + const response = c.res; + if (response.status !== 200) return; -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; + let payload; 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); } + payload = await response.clone().json(); + } catch { + return; } - 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 }); -}); + if (!Array.isArray(payload?.invites)) return; -// 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)}"`, - }); + const invites = payload.invites.map(({ token: _token, ...invite }) => invite); + const headers = new Headers(response.headers); + headers.delete('content-length'); + c.res = new Response(JSON.stringify({ ...payload, invites }), { + status: response.status, + statusText: response.statusText, + headers, }); } -// 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); +async function failClosedWhenOidcIsUnconfigured(c, next) { + if (process.env.SCOPEWEAVE_DEV !== '1' && !process.env.OIDC_ISSUER) { + return c.json({ error: 'sso not configured' }, 404); } - 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 }); -}); + return next(); +} -app.get('/api/health', (c) => c.json({ ok: true })); +/** + * Shared ScopeWeave application boundary. + * + * Every consumer, including the public server and tests that mount this route + * graph directly, passes through the same invite and OIDC trust controls before + * the protected implementation graph runs. The implementation module remains + * internal; this module is the supported route-graph entry point. + */ +export const app = new Hono(); -// 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(); - } -}); +app.use(OIDC_ROUTE_PREFIX, failClosedWhenOidcIsUnconfigured); +app.use(INVITE_ACCEPT_PATH, bindInviteToAuthenticatedIdentity); +app.use(MEMBERS_PATH, redactPendingInviteTokens); +app.route('/', coreRoutes); diff --git a/server/application_routes_core.mjs b/server/application_routes_core.mjs new file mode 100644 index 00000000..f2c72d01 --- /dev/null +++ b/server/application_routes_core.mjs @@ -0,0 +1,1419 @@ +// 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 { StripeWebhookError, verifyStripeWebhookRequest } from './stripe_webhook.mjs'; +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 authenticity only. The public app excludes this route and +// registers the same fail-closed HMAC verifier so unsigned JSON can never +// upgrade orgs.plan. This protected-graph copy must also refuse entitlement +// mutation; a future composer that mounts the graph directly still fails closed +// (Krawczyk et al., 1997; Stripe webhook signatures). +app.post('/api/stripe/webhook', async (c) => { + try { + await verifyStripeWebhookRequest(c.req.raw, { + secret: process.env.STRIPE_WEBHOOK_SECRET, + }); + return c.json({ received: true }, 200, { 'Cache-Control': 'no-store' }); + } catch (error) { + if (error instanceof StripeWebhookError) { + return c.json({ error: error.code }, error.status, { 'Cache-Control': 'no-store' }); + } + return c.json({ error: 'stripe_webhook_unavailable' }, 500, { 'Cache-Control': 'no-store' }); + } +}); + +// Dev-only: simulate a successful checkout upgrading the org to Pro. +// Disabled unless SCOPEWEAVE_DEV=1 (never reachable in production). +app.post('/api/orgs/:id/_dev/activate-pro', requireAuth, (c) => { + if (process.env.SCOPEWEAVE_DEV !== '1') return c.json({ error: 'not found' }, 404); + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); + db.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").run(orgId); + logAudit(orgId, uid, 'billing.upgrade', 'org', orgId, { plan: 'pro', via: 'dev' }); + deliver(orgId, 'billing.upgrade', { plan: 'pro' }); + return c.json({ plan: 'pro' }); +}); + +// ------------------------------------------------- personal access tokens (PAT) +app.get('/api/tokens', requireAuth, (c) => { + const uid = c.get('user').sub; + const tokens = db.prepare( + 'SELECT id, name, prefix, last_used AS lastUsed, created_at AS createdAt FROM api_tokens WHERE user_id = ? ORDER BY id DESC' + ).all(uid); + return c.json({ tokens }); // never the secret or hash +}); + +app.post('/api/tokens', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { name } = await c.req.json().catch(() => ({})); + const t = generateApiToken(); + const id = rowid(db.prepare('INSERT INTO api_tokens(user_id,name,token_hash,prefix) VALUES(?,?,?,?)') + .run(uid, String(name || 'token').slice(0, 60), t.hash, t.prefix)); + // Full secret returned ONCE — never retrievable again. + return c.json({ id, name: name || 'token', prefix: t.prefix, token: t.full }); +}); + +app.delete('/api/tokens/:id', requireAuth, (c) => { + const uid = c.get('user').sub; + const info = db.prepare('DELETE FROM api_tokens WHERE id = ? AND user_id = ?').run(c.req.param('id'), uid); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + +// Audit trail — owner/admin only. Enterprise requirement. +app.get('/api/orgs/:id/audit', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const limit = Math.min(Number(c.req.query('limit')) || 100, 500); + const rows = db.prepare( + `SELECT a.id, a.action, a.target_type AS targetType, a.target_id AS targetId, a.meta, + a.created_at AS createdAt, u.email AS actorEmail + FROM audit_log a LEFT JOIN users u ON u.id = a.user_id + WHERE a.org_id = ? ORDER BY a.id DESC LIMIT ?` + ).all(orgId, limit); + const events = rows.map((r) => ({ ...r, meta: r.meta ? JSON.parse(r.meta) : null })); + if (c.req.query('format') === 'csv') { + // Compliance deliverable. Formula-injection-safe: values that (after optional + // leading whitespace) start with = + - @ | are prefixed with ' so + // spreadsheets treat them as text. Leading whitespace alone used to bypass + // /^[=+\-@|]/ — match the client-side CSV_FORMULA_PREFIX_PATTERN. + const csvCell = (v) => { + let s = v == null ? '' : String(v); + if (/^\s*[=+\-@|]/.test(s)) s = `'${s}`; + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + }; + const header = ['id', 'createdAt', 'actorEmail', 'action', 'targetType', 'targetId', 'meta']; + const lines = [header.join(',')]; + for (const e of events) { + lines.push([e.id, e.createdAt, e.actorEmail, e.action, e.targetType, e.targetId, e.meta ? JSON.stringify(e.meta) : ''].map(csvCell).join(',')); + } + return c.text(lines.join('\r\n') + '\r\n', 200, { + 'content-type': 'text/csv; charset=utf-8', + 'content-disposition': `attachment; filename="scopeweave-audit-${orgId}.csv"`, + }); + } + return c.json({ events }); +}); + +// Full workspace export (owner only) — data portability / GDPR. Everything the +// org holds, as one JSON document. +app.get('/api/orgs/:id/export', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'only the owner can export' }, 403); + const org = getOrg(orgId); + const members = db.prepare( + `SELECT u.email, u.name, m.role FROM memberships m JOIN users u ON u.id = m.user_id WHERE m.org_id = ?` + ).all(orgId); + const projects = db.prepare( + 'SELECT id, name, base_date AS baseDate, tasks_json, version, created_at AS createdAt, updated_at AS updatedAt FROM projects WHERE org_id = ?' + ).all(orgId).map((p) => ({ ...p, tasks: JSON.parse(p.tasks_json), tasks_json: undefined })); + const audit = db.prepare( + 'SELECT action, target_type AS targetType, target_id AS targetId, meta, created_at AS createdAt FROM audit_log WHERE org_id = ? ORDER BY id' + ).all(orgId).map((a) => ({ ...a, meta: a.meta ? JSON.parse(a.meta) : null })); + logAudit(orgId, uid, 'org.export', 'org', orgId, { projects: projects.length }); + return c.json({ + exportedAt: new Date().toISOString(), + org: { id: org.id, name: org.name, plan: org.plan }, + members, projects, audit, + }, 200, { 'Content-Disposition': `attachment; filename="scopeweave-org-${orgId}.json"` }); +}); + +// Operational metrics (JSON). Ceiling: expose Prometheus text format + gate +// behind an internal token before prod if scraped externally. +app.get('/api/metrics', (c) => { + const sseActive = [...streams.values()].reduce((n, s) => n + s.size, 0); + const all = { ...metrics, sseActive, uptimeSec: Math.round(process.uptime()) }; + if (c.req.query('format') !== 'prometheus') return c.json(all); + // Prometheus text exposition format (0.0.4) — scrape-ready for Grafana/Alerting. + const gauge = new Set(['sseActive', 'uptimeSec']); + const lines = []; + for (const [k, v] of Object.entries(all)) { + if (typeof v !== 'number') continue; // startedAt etc. + const name = `scopeweave_${k.replace(/([A-Z])/g, '_$1').toLowerCase()}`; + lines.push(`# TYPE ${name} ${gauge.has(k) ? 'gauge' : 'counter'}`, `${name} ${v}`); + } + return c.text(lines.join('\n') + '\n', 200, { 'content-type': 'text/plain; version=0.0.4; charset=utf-8' }); +}); + +// ------------------------------------------------------------------- webhooks +app.get('/api/orgs/:id/webhooks', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const webhooks = db.prepare( + `SELECT w.id, w.url, w.events, w.active, w.created_at AS createdAt, + (SELECT ok FROM webhook_deliveries d WHERE d.webhook_id = w.id ORDER BY d.id DESC LIMIT 1) AS lastOk, + (SELECT created_at FROM webhook_deliveries d WHERE d.webhook_id = w.id ORDER BY d.id DESC LIMIT 1) AS lastAt + FROM webhooks w WHERE w.org_id = ? ORDER BY w.id DESC` + ).all(orgId); // secret never returned + return c.json({ webhooks }); +}); + +app.post('/api/orgs/:id/webhooks', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const { url, events } = await c.req.json().catch(() => ({})); + if (!/^https?:\/\//.test(String(url || ''))) return c.json({ error: 'valid http(s) url required' }, 400); + const secret = `whsec_${randomBytes(24).toString('base64url')}`; + const evs = Array.isArray(events) ? events.join(',') : (events || '*'); + const id = rowid(db.prepare('INSERT INTO webhooks(org_id,url,secret,events) VALUES(?,?,?,?)').run(orgId, url, secret, evs)); + logAudit(orgId, uid, 'webhook.create', 'webhook', id, { url, events: evs }); + return c.json({ id, url, events: evs, secret }); // secret shown once for signature verification +}); + +app.get('/api/orgs/:id/webhooks/:whId/deliveries', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const wh = db.prepare('SELECT id FROM webhooks WHERE id = ? AND org_id = ?').get(c.req.param('whId'), orgId); + if (!wh) return c.json({ error: 'not found' }, 404); + const deliveries = db.prepare( + 'SELECT event, status_code AS statusCode, ok, attempt, created_at AS createdAt FROM webhook_deliveries WHERE webhook_id = ? ORDER BY id DESC LIMIT 50' + ).all(wh.id); + return c.json({ deliveries }); +}); + +// Rotate a webhook's signing secret (leak response / periodic hygiene). The new +// secret is returned ONCE; old signatures stop validating immediately. +app.post('/api/orgs/:id/webhooks/:whId/rotate', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const secret = `whsec_${randomBytes(24).toString('base64url')}`; + const info = db.prepare('UPDATE webhooks SET secret = ? WHERE id = ? AND org_id = ?').run(secret, c.req.param('whId'), orgId); + if (!info.changes) return c.json({ error: 'not found' }, 404); + logAudit(orgId, uid, 'webhook.rotate', 'webhook', c.req.param('whId'), {}); + return c.json({ id: Number(c.req.param('whId')), secret }); // shown once +}); + +app.delete('/api/orgs/:id/webhooks/:whId', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('DELETE FROM webhooks WHERE id = ? AND org_id = ?').run(c.req.param('whId'), orgId); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + +// ------------------------------------------------------------ SSO (OIDC) +// Real IdP via env (OIDC_ISSUER/CLIENT_ID/CLIENT_SECRET/REDIRECT_URI). When +// unset, a built-in mock provider makes the whole flow self-contained + testable. +const OIDC = { + issuer: process.env.OIDC_ISSUER, + clientId: process.env.OIDC_CLIENT_ID, + clientSecret: process.env.OIDC_CLIENT_SECRET, + redirectUri: process.env.OIDC_REDIRECT_URI, +}; +const oidcMock = !OIDC.issuer; +const oidcStates = new Map(); // state -> { verifier, exp } +const oidcCodes = new Map(); // mock only: code -> email + +function upsertSsoUser(email) { + let user = db.prepare('SELECT id, email, token_version FROM users WHERE email = ?').get(email); + if (user) return user; + db.exec('BEGIN'); + try { + const uid = rowid(db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') + .run(email, hashPassword(randomBytes(24).toString('hex')), '')); + const oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)').run(`${email}'s workspace`, uid)); + db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); + db.exec('COMMIT'); + metrics.signups++; + return { id: uid, email }; + } catch (e) { db.exec('ROLLBACK'); throw e; } +} + +app.get('/api/auth/oidc/start', (c) => { + const origin = new URL(c.req.url).origin; + const state = randomBytes(16).toString('hex'); + const verifier = randomBytes(32).toString('base64url'); + const challenge = createHash('sha256').update(verifier).digest('base64url'); + oidcStates.set(state, { verifier, exp: Date.now() + 5 * 60 * 1000 }); + const redirectUri = OIDC.redirectUri || `${origin}/api/auth/oidc/callback`; + if (oidcMock) { + const email = c.req.query('email') || 'sso-user@example.com'; + const u = new URL(`${origin}/api/auth/oidc/mock/authorize`); + u.searchParams.set('state', state); + u.searchParams.set('email', email); + u.searchParams.set('redirect_uri', redirectUri); + return c.redirect(u.toString()); + } + const u = new URL(`${OIDC.issuer.replace(/\/$/, '')}/authorize`); + u.searchParams.set('client_id', OIDC.clientId); + u.searchParams.set('redirect_uri', redirectUri); + u.searchParams.set('response_type', 'code'); + u.searchParams.set('scope', 'openid email profile'); + u.searchParams.set('state', state); + u.searchParams.set('code_challenge', challenge); + u.searchParams.set('code_challenge_method', 'S256'); + return c.redirect(u.toString()); +}); + +// Built-in mock IdP authorize — instantly issues a code (dev/test only). +app.get('/api/auth/oidc/mock/authorize', (c) => { + if (!oidcMock) return c.json({ error: 'mock disabled' }, 404); + const state = c.req.query('state'); + const email = c.req.query('email'); + const redirectUri = c.req.query('redirect_uri'); + const code = randomBytes(16).toString('hex'); + oidcCodes.set(code, email); + const u = new URL(redirectUri); + u.searchParams.set('code', code); + u.searchParams.set('state', state); + return c.redirect(u.toString()); +}); + +app.get('/api/auth/oidc/callback', async (c) => { + const state = c.req.query('state'); + const code = c.req.query('code'); + const s = oidcStates.get(state); + if (!s || s.exp < Date.now()) return c.json({ error: 'invalid or expired state' }, 400); + oidcStates.delete(state); + let email; + if (oidcMock) { + email = oidcCodes.get(code); + oidcCodes.delete(code); + if (!email) return c.json({ error: 'invalid code' }, 400); + } else { + const redirectUri = OIDC.redirectUri || `${new URL(c.req.url).origin}/api/auth/oidc/callback`; + const tokenRes = await fetch(`${OIDC.issuer.replace(/\/$/, '')}/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: OIDC.clientId, client_secret: OIDC.clientSecret, code_verifier: s.verifier }), + }).catch(() => null); + const tok = tokenRes ? await tokenRes.json().catch(() => ({})) : {}; + if (!tok.id_token) return c.json({ error: 'token exchange failed' }, 400); + // Ceiling: verify the id_token signature via the issuer JWKS before prod. + const claims = JSON.parse(Buffer.from(String(tok.id_token).split('.')[1] || '', 'base64url').toString() || '{}'); + email = claims.email; + if (!email) return c.json({ error: 'no email claim' }, 400); + } + const user = upsertSsoUser(email); + const token = signToken({ sub: user.id, email, tv: user.token_version || 0 }); + // Return the token in the URL fragment (not query → not logged); the client + // stores it and cleans the URL. + return c.redirect(`/#token=${token}`); +}); + +// Cross-project search: project names + task names, membership-scoped (tenant +// isolation via the same JOIN as projectAccess). +// ponytail: LIKE over tasks_json text; move to FTS5 if search gets heavy. +app.get('/api/search', requireAuth, (c) => { + const uid = c.get('user').sub; + const q = String(c.req.query('q') || '').trim(); + if (q.length < 2) return c.json({ error: 'query too short (min 2)' }, 400); + const rows = db.prepare( + `SELECT DISTINCT p.id, p.name, p.tasks_json FROM projects p + JOIN memberships m ON m.org_id = p.org_id + WHERE m.user_id = ? AND (p.name LIKE ? OR p.tasks_json LIKE ?) LIMIT 100` + ).all(uid, `%${q}%`, `%${q}%`); + const needle = q.toLowerCase(); + const results = []; + for (const p of rows) { + const hit = { projectId: p.id, projectName: p.name, tasks: [] }; + if (p.name.toLowerCase().includes(needle)) hit.nameMatch = true; + let tasks = []; + try { tasks = JSON.parse(p.tasks_json); } catch { /* skip bad json */ } + for (const t of tasks) { + if (String(t.name || '').toLowerCase().includes(needle)) { + hit.tasks.push({ id: t.id, name: t.name }); + if (hit.tasks.length >= 5) break; + } + } + if (hit.nameMatch || hit.tasks.length) results.push(hit); + if (results.length >= 20) break; + } + return c.json({ query: q, results }); +}); + +// Portfolio dashboard: executive rollup across every project in a workspace — +// weighted planned/actual progress, SPI + status, overdue-task counts. +app.get('/api/orgs/:id/portfolio', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); + const today = new Date().toISOString().slice(0, 10); + const rows = db.prepare( + 'SELECT id, name, base_date AS baseDate, tasks_json, archived, updated_at AS updatedAt FROM projects WHERE org_id = ? ORDER BY archived ASC, updated_at DESC' + ).all(orgId); + const projects = rows.map((p) => { + let tasks = []; + try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } + let wSum = 0, pv = 0, ev = 0, overdue = 0; + for (const t of tasks) { + const w = Number(t.weight) || 1; + wSum += w; + pv += w * ((Number(t.plannedProgress) || 0) / 100); + ev += w * ((Number(t.actualProgress) || 0) / 100); + if (t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100) overdue++; + } + const evm = computeEvm({ pv: wSum ? pv / wSum : 0, ev: wSum ? ev / wSum : 0 }); + return { + id: p.id, + name: p.name, + archived: Boolean(p.archived), + tasks: tasks.length, + planned: Math.round(evm.pv * 1000) / 10, // % + actual: Math.round(evm.ev * 1000) / 10, // % + spi: evm.spi === null ? null : Math.round(evm.spi * 100) / 100, + status: evm.status, + label: evm.label, + overdue, + updatedAt: p.updatedAt, + }; + }); + return c.json({ projects }); +}); + +// AI 브리핑: 프로젝트 스냅샷(요약 지표 + 지연/차주 작업)을 contextual- +// orchestrator(LLM)로 보내 경영진용 리스크 분석을 생성. 원문 데이터는 서버가 +// 요약해 전송하며, LLM 자격은 서버 환경변수에만 존재. +app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + let tasks = []; + try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } + const today = new Date().toISOString().slice(0, 10); + let wSum = 0, pv = 0, ev = 0; + const late = [], upcoming = []; + for (const t of tasks) { + const w = Number(t.weight) || 1; + wSum += w; + pv += w * ((Number(t.plannedProgress) || 0) / 100); + ev += w * ((Number(t.actualProgress) || 0) / 100); + const name = t.name || t.task || t.activity || t.phase || t.id; + if (t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100) { + late.push(`${name}(계획종료 ${t.plannedEndDate}, 실적 ${Number(t.actualProgress) || 0}%${t.owner ? `, ${t.owner}` : ''})`); + } else if (t.plannedStartDate && t.plannedStartDate >= today) { + upcoming.push(`${name}(${t.plannedStartDate} 시작)`); + } + } + const pvPct = wSum ? ((pv / wSum) * 100).toFixed(1) : '0'; + const evPct = wSum ? ((ev / wSum) * 100).toFixed(1) : '0'; + const context = [ + `프로젝트: ${p.name}`, + `작업 수: ${tasks.length} · 계획진척 ${pvPct}% · 실적진척 ${evPct}%`, + `지연 작업(${late.length}): ${late.slice(0, 8).join(' / ') || '없음'}`, + `예정 작업(${upcoming.length}): ${upcoming.slice(0, 5).join(' / ') || '없음'}`, + ].join('\n'); + try { + const analysis = await orchestratorChat([ + { role: 'system', content: '너는 공정관리(schedule control) 전문가다. 주어진 프로젝트 지표를 근거로 한국어 경영진 브리핑을 작성하라: ①일정 상태 한 줄 판정 ②핵심 리스크 2~3개(근거 지표 인용) ③실행 권고 2~3개. 지표에 없는 사실은 만들지 마라.' }, + { role: 'user', content: context }, + ], { + service: 'scopeweave', + account: String(p.org_id), + }); + logAudit(p.org_id, uid, 'ai.brief', 'project', p.id, { tasks: tasks.length }); + return c.json({ analysis }); + } catch (e) { + return c.json({ error: `AI 분석 실패: ${e.message}` }, 502); + } +}); + +// 산출물 첨부(Clearfolio 통합 문서 뷰어 프록시): 업로드→변환 잡, 목록(+상태 +// 갱신), 서명 아티팩트 열람(302), 삭제. 테넌트 = 조직, 브라우저에는 Clearfolio +// 자격이 절대 노출되지 않음. +const ATTACH_MAX_BYTES = 10 * 1024 * 1024; + +const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY, +); +const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, +); +const ATTACH_STATUS_BUDGET_MS = normalizeAttachmentStatusBudgetMs( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS, +); +const ATTACHMENT_LIST_COLUMNS = `a.id, a.task_id AS taskId, a.name, a.mime, a.size, + a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy`; +const ATTACHMENT_LIST_FROM = + 'FROM attachments a LEFT JOIN users u ON u.id = a.created_by'; +const listAttachmentsStatement = db.prepare( + `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} + WHERE a.project_id = ? ORDER BY a.id DESC`, +); +const listTaskAttachmentsStatement = db.prepare( + `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} + WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`, +); +const updateAttachmentStatusStatement = db.prepare( + 'UPDATE attachments SET status = ? WHERE id = ?', +); +app.post('/api/projects/:id/attachments', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const form = await c.req.formData().catch(() => null); + const file = form?.get('file'); + if (!file || typeof file === 'string') return c.json({ error: 'multipart file required' }, 400); + const taskId = String(form.get('taskId') || ''); + if (/\.(hwp|hwpx)$/i.test(file.name || '')) return c.json({ error: 'HWP/HWPX는 지원되지 않습니다 (Clearfolio 정책)' }, 400); + if (file.size > ATTACH_MAX_BYTES) return c.json({ error: 'file too large (max 10MB)' }, 400); + const bytes = Buffer.from(await file.arrayBuffer()); + let job; + try { + job = await submitJob(p.org_id, uid, { name: file.name || 'document', mime: file.type || '', bytes }); + } catch (e) { + return c.json({ error: `문서 변환 제출 실패: ${e.message}` }, 502); + } + const aid = rowid(db.prepare( + 'INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)' + ).run(p.id, taskId, file.name || 'document', file.type || '', file.size, job.jobId, job.status, uid)); + logAudit(p.org_id, uid, 'attachment.upload', 'project', p.id, { attachmentId: aid, name: file.name, taskId: taskId || null }); + return c.json({ id: aid, status: job.status }); +}); + +app.get('/api/projects/:id/attachments', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + + const taskId = c.req.query('taskId'); + const rows = taskId + ? listTaskAttachmentsStatement.all(p.id, taskId) + : listAttachmentsStatement.all(p.id); + await refreshAttachmentStatuses(rows, { + orgId: p.org_id, + userId: uid, + jobStatus, + updateStatus: (status, attachmentId) => + updateAttachmentStatusStatement.run(status, attachmentId), + concurrency: ATTACH_STATUS_CONCURRENCY, + timeoutMs: ATTACH_STATUS_TIMEOUT_MS, + budgetMs: ATTACH_STATUS_BUDGET_MS, + metrics, + }); + const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); + return c.json({ attachments }); +}); + +// 열람: 서명 아티팩트 URL로 302. 새 탭 열기용으로 ?token=도 허용(ics/stream 패턴). +app.get('/api/projects/:id/attachments/:aid/view', (c) => { + const header = c.req.header('authorization') || ''; + const raw = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); + let uid; + if (raw.startsWith('swk_')) { + const row = db.prepare('SELECT user_id FROM api_tokens WHERE token_hash = ?').get(hashApiToken(raw)); + if (!row) return c.json({ error: 'unauthorized' }, 401); + uid = row.user_id; + } else { + try { + const payload = verifyToken(raw); + const u = db.prepare('SELECT token_version FROM users WHERE id = ?').get(payload.sub); + if (!u || (payload.tv || 0) !== u.token_version) return c.json({ error: 'unauthorized' }, 401); + uid = payload.sub; + } catch { return c.json({ error: 'unauthorized' }, 401); } + } + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const a = db.prepare('SELECT job_id, status FROM attachments WHERE id = ? AND project_id = ?').get(c.req.param('aid'), p.id); + if (!a) return c.json({ error: 'not found' }, 404); + if (a.status !== 'SUCCEEDED') return c.json({ error: `문서가 아직 준비되지 않았습니다 (${a.status})` }, 409); + return artifactUrl(p.org_id, uid, a.job_id) + .then((url) => c.redirect(url)) + .catch((e) => c.json({ error: `열람 링크 발급 실패: ${e.message}` }, 502)); +}); + +app.delete('/api/projects/:id/attachments/:aid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const a = db.prepare('SELECT created_by FROM attachments WHERE id = ? AND project_id = ?').get(c.req.param('aid'), p.id); + if (!a) return c.json({ error: 'not found' }, 404); + if (a.created_by !== uid && !canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + db.prepare('DELETE FROM attachments WHERE id = ?').run(c.req.param('aid')); + logAudit(p.org_id, uid, 'attachment.delete', 'project', p.id, { attachmentId: Number(c.req.param('aid')) }); + return c.json({ ok: true }); +}); + +// mock Clearfolio 아티팩트 서빙(dev/test 전용) +if (clearfolioMock) { + app.get('/api/mock-clearfolio/:jobId', (c) => { + const doc = mockArtifact(c.req.param('jobId')); + if (!doc) return c.json({ error: 'not found' }, 404); + return c.body(doc.bytes, 200, { + 'content-type': doc.mime || 'application/octet-stream', + 'content-disposition': `inline; filename="${encodeURIComponent(doc.name)}"`, + }); + }); +} + +// Public read-only share links: a random token grants VIEW access to one +// project (no account needed) — revocable. Never exposes org/member data. +app.post('/api/projects/:id/shares', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const token = randomBytes(18).toString('base64url'); + db.prepare('INSERT INTO share_tokens(project_id, token, created_by) VALUES(?,?,?)').run(p.id, token, uid); + logAudit(p.org_id, uid, 'share.create', 'project', p.id, {}); + return c.json({ token, url: `/?share=${token}` }); +}); + +app.get('/api/projects/:id/shares', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const shares = db.prepare( + 'SELECT id, token, created_at AS createdAt FROM share_tokens WHERE project_id = ? AND revoked = 0 ORDER BY id DESC' + ).all(p.id); + return c.json({ shares }); +}); + +app.delete('/api/projects/:id/shares/:sid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('UPDATE share_tokens SET revoked = 1 WHERE id = ? AND project_id = ? AND revoked = 0') + .run(c.req.param('sid'), p.id); + if (!info.changes) return c.json({ error: 'not found' }, 404); + logAudit(p.org_id, uid, 'share.revoke', 'project', p.id, { shareId: Number(c.req.param('sid')) }); + return c.json({ ok: true }); +}); + +// Anonymous read via share token — project content only. +app.get('/api/shared/:token', (c) => { + const row = db.prepare( + `SELECT p.name, p.base_date AS baseDate, p.tasks_json FROM share_tokens s + JOIN projects p ON p.id = s.project_id WHERE s.token = ? AND s.revoked = 0` + ).get(c.req.param('token')); + if (!row) return c.json({ error: 'not found' }, 404); + return c.json({ name: row.name, baseDate: row.baseDate, tasks: JSON.parse(row.tasks_json), readOnly: true }); +}); + +// Unseen-activity notifications: per project, count others' saves + comments +// newer than my last-seen mark. Opening a project marks it seen. +app.get('/api/notifications', requireAuth, (c) => { + const uid = c.get('user').sub; + const rows = db.prepare( + `SELECT p.id AS projectId, + (SELECT COUNT(*) FROM project_revisions r WHERE r.project_id = p.id + AND r.saved_by IS NOT NULL AND r.saved_by != ? + AND r.created_at > COALESCE(s.seen_at, '')) AS revisions, + (SELECT COUNT(*) FROM comments cm WHERE cm.project_id = p.id + AND cm.user_id IS NOT NULL AND cm.user_id != ? + AND cm.created_at > COALESCE(s.seen_at, '')) AS comments + FROM projects p + JOIN memberships m ON m.org_id = p.org_id AND m.user_id = ? + LEFT JOIN project_seen s ON s.project_id = p.id AND s.user_id = ?` + ).all(uid, uid, uid, uid); + const notifications = rows + .map((r) => ({ projectId: r.projectId, unseen: r.revisions + r.comments })) + .filter((r) => r.unseen > 0); + return c.json({ notifications }); +}); + +app.post('/api/projects/:id/seen', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + db.prepare(`INSERT INTO project_seen(project_id, user_id, seen_at) VALUES(?, ?, datetime('now')) + ON CONFLICT(project_id, user_id) DO UPDATE SET seen_at = datetime('now')`).run(p.id, uid); + return c.json({ ok: true }); +}); + +// Archive / restore a project (write roles): declutter without deleting. +app.post('/api/projects/:id/archive', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const { archived } = await c.req.json().catch(() => ({})); + const flag = archived === false ? 0 : 1; + db.prepare('UPDATE projects SET archived = ? WHERE id = ?').run(flag, p.id); + logAudit(p.org_id, uid, flag ? 'project.archive' : 'project.unarchive', 'project', p.id, {}); + return c.json({ id: p.id, archived: Boolean(flag) }); +}); + +// Duplicate a project (template use: copy tasks + base date into a new project +// in the same org). Plan caps apply like any create. +app.post('/api/projects/:id/duplicate', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + if (wouldExceed(db, getOrg(p.org_id), 'projects')) { + return c.json({ error: 'project limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.projects }, 402); + } + const { name } = await c.req.json().catch(() => ({})); + const newName = String(name || `${p.name} (복사본)`).slice(0, 120); + const nid = rowid(db.prepare('INSERT INTO projects(org_id,name,base_date,tasks_json,created_by) VALUES(?,?,?,?,?)') + .run(p.org_id, newName, p.base_date, p.tasks_json, uid)); + metrics.projectsCreated++; + logAudit(p.org_id, uid, 'project.duplicate', 'project', nid, { from: p.id, name: newName }); + return c.json({ id: nid, name: newName, version: 1 }); +}); + +// -------------------------------------------------------------- sprints +// Agile/Hybrid: 시간상자(스프린트) CRUD. 작업은 task.sprint(이름)로 배정되고 +// task.storyPoints로 추정된다 — 지표(커밋/완료 포인트, 벨로시티)는 클라이언트 +// 순수 함수(computeSprintStats)가 계산한다. +app.post('/api/projects/:id/sprints', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const { name, startDate, endDate, goal } = await c.req.json().catch(() => ({})); + if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); + const day = (v) => (/^\d{4}-\d{2}-\d{2}$/.test(String(v || '')) ? v : ''); + const sid = rowid(db.prepare('INSERT INTO sprints(project_id,name,start_date,end_date,goal) VALUES(?,?,?,?,?)') + .run(p.id, String(name).trim().slice(0, 80), day(startDate), day(endDate), String(goal || '').slice(0, 300))); + logAudit(p.org_id, uid, 'sprint.create', 'project', p.id, { sprintId: sid, name }); + return c.json({ id: sid, name: String(name).trim() }); +}); + +app.get('/api/projects/:id/sprints', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const sprints = db.prepare( + 'SELECT id, name, start_date AS startDate, end_date AS endDate, goal FROM sprints WHERE project_id = ? ORDER BY start_date, id' + ).all(p.id); + return c.json({ sprints, methodology: p.methodology || 'waterfall' }); +}); + +app.delete('/api/projects/:id/sprints/:sid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('DELETE FROM sprints WHERE id = ? AND project_id = ?').run(c.req.param('sid'), p.id); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + +// ------------------------------------------------------------- baselines +// Snapshot a project's current plan as a named baseline (schedule-control: +// compare actuals against the frozen plan later). +app.post('/api/projects/:id/baselines', requireAuth, async (c) => { + const uid = c.get('user').sub; + const id = c.req.param('id'); + const p = projectAccess(uid, id); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const { name } = await c.req.json().catch(() => ({})); + const bid = rowid(db.prepare('INSERT INTO baselines(project_id,name,base_date,tasks_json,created_by) VALUES(?,?,?,?,?)') + .run(id, String(name || 'Baseline').slice(0, 80), p.base_date, p.tasks_json, uid)); + logAudit(p.org_id, uid, 'baseline.create', 'project', id, { baselineId: bid, name }); + return c.json({ id: bid, name: name || 'Baseline' }); +}); + +app.get('/api/projects/:id/baselines', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const baselines = db.prepare( + 'SELECT id, name, base_date AS baseDate, created_at AS createdAt FROM baselines WHERE project_id = ? ORDER BY id DESC' + ).all(p.id); + return c.json({ baselines }); +}); + +app.get('/api/projects/:id/baselines/:bid', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const b = db.prepare('SELECT id, name, base_date AS baseDate, tasks_json, created_at AS createdAt FROM baselines WHERE id = ? AND project_id = ?').get(c.req.param('bid'), p.id); + if (!b) return c.json({ error: 'not found' }, 404); + return c.json({ id: b.id, name: b.name, baseDate: b.baseDate, tasks: JSON.parse(b.tasks_json), createdAt: b.createdAt }); +}); + +app.delete('/api/projects/:id/baselines/:bid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('DELETE FROM baselines WHERE id = ? AND project_id = ?').run(c.req.param('bid'), p.id); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + +// ------------------------------------------------------ account & lifecycle +// Delete a project (write roles). tasks live in the row, so this fully removes it. +app.delete('/api/projects/:id', requireAuth, (c) => { + const uid = c.get('user').sub; + const id = c.req.param('id'); + const p = projectAccess(uid, id); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + db.prepare('DELETE FROM projects WHERE id = ?').run(id); + logAudit(p.org_id, uid, 'project.delete', 'project', id, { name: p.name }); + deliver(p.org_id, 'project.delete', { projectId: Number(id) }); + return c.json({ ok: true }); +}); + +// Log out everywhere: bump token_version → every existing JWT dies. Returns a +// fresh token so THIS device stays signed in. PATs are unaffected. +app.post('/api/auth/logout-all', requireAuth, (c) => { + const uid = c.get('user').sub; + db.prepare('UPDATE users SET token_version = token_version + 1 WHERE id = ?').run(uid); + const u = db.prepare('SELECT email, token_version FROM users WHERE id = ?').get(uid); + return c.json({ ok: true, token: signToken({ sub: uid, email: u.email, tv: u.token_version }) }); +}); + +// Change password (verifies the current one). +app.post('/api/auth/change-password', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { oldPassword, newPassword } = await c.req.json().catch(() => ({})); + if (typeof newPassword !== 'string' || newPassword.length < 8) return c.json({ error: 'new password (min 8) required' }, 400); + const u = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(uid); + if (!u || typeof oldPassword !== 'string' || !verifyPassword(oldPassword, u.password_hash)) { + return c.json({ error: 'current password incorrect' }, 403); + } + db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(newPassword), uid); + return c.json({ ok: true }); +}); + +// Delete account (GDPR). Removes owned workspaces (cascading their data) and the +// user. Requires the current password to confirm. +app.delete('/api/account', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { password } = await c.req.json().catch(() => ({})); + const u = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(uid); + if (!u || typeof password !== 'string' || !verifyPassword(password, u.password_hash)) { + return c.json({ error: 'password required to delete account' }, 403); + } + db.exec('BEGIN'); + try { + db.prepare('DELETE FROM orgs WHERE owner_id = ?').run(uid); // cascades projects/members/webhooks/invites/audit + db.prepare('DELETE FROM users WHERE id = ?').run(uid); // cascades memberships/tokens + db.exec('COMMIT'); + } catch (e) { db.exec('ROLLBACK'); throw e; } + return c.json({ ok: true }); +}); + +app.get('/api/health', (c) => c.json({ ok: true })); + +// Static client — strict allowlist so server/, data.db, package.json etc. are +// never served. Anything not listed → 404. +const STATIC = { + '/': ['index.html', 'text/html; charset=utf-8'], + '/index.html': ['index.html', 'text/html; charset=utf-8'], + '/404.html': ['404.html', 'text/html; charset=utf-8'], + '/landing.html': ['landing.html', 'text/html; charset=utf-8'], + '/landing.en.html': ['landing.en.html', 'text/html; charset=utf-8'], + '/docs/api.md': ['docs/api.md', 'text/markdown; charset=utf-8'], + '/robots.txt': ['robots.txt', 'text/plain; charset=utf-8'], + '/sitemap.xml': ['sitemap.xml', 'application/xml; charset=utf-8'], + '/pricing': ['landing.html', 'text/html; charset=utf-8'], + '/app.js': ['app.js', 'text/javascript; charset=utf-8'], + '/cloud-sync.js': ['cloud-sync.js', 'text/javascript; charset=utf-8'], + '/analytics.js': ['analytics.js', 'text/javascript; charset=utf-8'], + '/styles.css': ['styles.css', 'text/css; charset=utf-8'], + '/toast-state.css': ['toast-state.css', 'text/css; charset=utf-8'], + '/wbs.json': ['wbs.json', 'application/json; charset=utf-8'], +}; +app.get('*', async (c) => { + const entry = STATIC[c.req.path]; + if (!entry) return c.notFound(); + try { + const buf = await readFile(new URL(`../${entry[0]}`, import.meta.url)); + return c.body(buf, 200, { 'Content-Type': entry[1] }); + } catch { + return c.notFound(); + } +}); diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index c1975d1d..8acd3a86 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -6,6 +6,7 @@ const scripts = packageJson.scripts || {}; const serverWorkflow = readFileSync(new URL('../../.github/workflows/server-tests.yml', import.meta.url), 'utf8'); const publicApp = readFileSync(new URL('../../server/app.mjs', import.meta.url), 'utf8'); const applicationRoutes = readFileSync(new URL('../../server/application_routes.mjs', import.meta.url), 'utf8'); +const applicationRoutesCore = readFileSync(new URL('../../server/application_routes_core.mjs', import.meta.url), 'utf8'); assert.match( serverWorkflow, @@ -15,7 +16,12 @@ assert.match( assert.match( scripts['test:coverage'], /--include=server\/application_routes\.mjs/, - 'the protected application route graph remains owned-production coverage', + 'the shared security boundary remains owned-production coverage', +); +assert.match( + scripts['test:coverage'], + /--include=server\/application_routes_core\.mjs/, + 'the protected application implementation remains owned-production coverage', ); assert.match( scripts['test:coverage'], @@ -32,28 +38,23 @@ assert.match( /tests\/api\/stripe-webhook\.test\.mjs/, 'the public Stripe webhook entitlement regression executes in normal API CI', ); -assert.doesNotMatch( - publicApp, - /app\.route\(\s*['"]\/['"]\s*,\s*applicationRoutes\s*\)/, - 'the public app does not mount the protected graph via app.route, which would first-match unsigned Stripe or skip abuse-control middleware', -); assert.match( publicApp, - /applicationRoutes\.routes\.filter\([\s\S]*method === ['"]POST['"][\s\S]*path === ['"]\/api\/stripe\/webhook['"][\s\S]*app\.on\(route\.method, route\.path, secureCopiedHandler\(route\)\)/, - 'the public app preserves the protected route graph while excluding the historical Stripe handler', + /export \{ app \} from ['"]\.\/application_routes\.mjs['"]/, + 'the public app reuses the supported shared security boundary without a second route copier', ); assert.match( - publicApp, - /verifyStripeWebhookRequest/, - 'the public Stripe webhook uses the raw-body HMAC verifier', + applicationRoutes, + /app\.use\(OIDC_ROUTE_PREFIX, failClosedWhenOidcIsUnconfigured\)[\s\S]*app\.use\(INVITE_ACCEPT_PATH, bindInviteToAuthenticatedIdentity\)[\s\S]*app\.use\(MEMBERS_PATH, redactPendingInviteTokens\)[\s\S]*app\.route\(\s*['"]\/['"]\s*,\s*coreRoutes\s*\)/, + 'shared OIDC and invitation controls wrap the implementation graph before it is mounted', ); assert.match( - applicationRoutes, + applicationRoutesCore, /verifyStripeWebhookRequest/, - 'the protected Stripe route is also fail-closed so a direct mount cannot escalate plan', + 'the protected Stripe route is fail-closed so every supported mount verifies raw-body authenticity', ); assert.doesNotMatch( - applicationRoutes, + applicationRoutesCore, /checkout\.session\.completed[\s\S]{0,500}UPDATE orgs SET plan = 'pro'/, 'checkout.session.completed JSON is never authority to upgrade orgs.plan', ); From 394e3cc138e6466fabbed44db8fd2e59d4c21721 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 10:34:13 -0700 Subject: [PATCH 039/120] fix(security): align OIDC regression and trust docs --- .../stripe-webhook-trust-boundary.md | 50 +++++++++++-------- tests/api/oidc-production-boundary.test.mjs | 2 +- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/docs/doctoring/stripe-webhook-trust-boundary.md b/docs/doctoring/stripe-webhook-trust-boundary.md index 558cc4f3..2da8eade 100644 --- a/docs/doctoring/stripe-webhook-trust-boundary.md +++ b/docs/doctoring/stripe-webhook-trust-boundary.md @@ -7,18 +7,24 @@ Protected `develop` previously accepted an unauthenticated is a privilege-escalation window: any caller who can POST to `/api/stripe/webhook` can grant paid entitlements. -The public application now: - -1. copies the protected route graph, including logging and rate-limit - middleware, except the historical Stripe handler; -2. registers a fail-closed verifier that HMAC-SHA-256-checks - `timestamp + "." + raw body` against `STRIPE_WEBHOOK_SECRET` before parsing - JSON; -3. acknowledges an authentic delivery with `{ received: true }` and does **not** - mutate `orgs.plan`; and -4. keeps the protected-graph copy of the same route fail-closed so a future - composer that mounts `application_routes` directly cannot restore unsigned - plan upgrades. +The supported HTTP composition now has one explicit security boundary: + +1. `server/app.mjs` re-exports the supported shared application from + `server/application_routes.mjs`; +2. that shared boundary installs the production OIDC fail-closed guard, invite + identity binding, and pending-invite-token redaction before mounting the + internal implementation graph from `server/application_routes_core.mjs`; +3. the internal graph retains the existing request logging, metrics, and + rate-limit middleware and contains the single `POST /api/stripe/webhook` + route; and +4. that route HMAC-SHA-256-checks the exact bounded raw body using the Stripe + `t` and `v1` signature values before parsing JSON, acknowledges an authentic + delivery with `{ received: true }`, and does **not** mutate `orgs.plan`. + +`server/application_routes_core.mjs` is an internal implementation module, not +a supported application entry point. Consumers must import +`server/application_routes.mjs` or `server/app.mjs`; bypassing the supported +boundary would also bypass its OIDC and invitation controls. Signature validity authenticates the delivery only. Durable event deduplication, provider-state reconciliation, and entitlement writes remain @@ -35,9 +41,9 @@ rather than accepting unsigned traffic. OAuth bearer-token rules (RFC 6750; RFC 9700) do not apply to this provider callback; the webhook secret is a shared HMAC key, not an access token. The -endpoint remains behind the same abuse-control middleware as the rest of the -application so unsigned floods are rate-limited instead of skipping the limiter -through a first-match public route. +endpoint remains inside the implementation graph's abuse-control and +observability middleware so unsigned floods remain subject to the same rate +limit and request accounting as the surrounding API. ## Verification contract @@ -47,11 +53,15 @@ Regression tests must prove: - a correctly signed delivery is acknowledged and still leaves plan unchanged; - a stale timestamp or a JSON-equivalent mutated body fails signature checks; - when `SCOPEWEAVE_RATE_LIMIT_MAX=1`, the second webhook in the window is `429`; -- the public app does not `app.route('/', applicationRoutes)`; -- the public app copies protected routes except `POST /api/stripe/webhook` and - then registers `verifyStripeWebhookRequest`; and -- `application_routes.mjs` no longer contains a `checkout.session.completed` - plan-upgrade path. +- `server/app.mjs` re-exports the supported shared application boundary rather + than maintaining a second route graph; +- the shared boundary installs OIDC and invitation guards before + `app.route('/', coreRoutes)`; +- both the public app and the supported shared route graph fail closed when + production OIDC is unconfigured; +- the internal core Stripe route invokes `verifyStripeWebhookRequest`; and +- the core graph contains no `checkout.session.completed` path that treats + callback JSON as entitlement authority. ## Officer next action diff --git a/tests/api/oidc-production-boundary.test.mjs b/tests/api/oidc-production-boundary.test.mjs index d7e2d12d..0b7c7728 100644 --- a/tests/api/oidc-production-boundary.test.mjs +++ b/tests/api/oidc-production-boundary.test.mjs @@ -36,6 +36,6 @@ response = await applicationRoutes.request( ); assert.equal(response.status, 404, 'shared route graph keeps the mock authorize endpoint closed outside explicit development mode'); assert.equal(response.headers.get('location'), null, 'shared route graph cannot mint a production callback code'); -assert.deepEqual(await response.json(), { error: 'mock disabled' }); +assert.deepEqual(await response.json(), { error: 'sso not configured' }); console.log('OIDC production fail-closed regression passed'); From 7ef64d245e907811daec23df364953e1bd1f30db Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 10:40:03 -0700 Subject: [PATCH 040/120] test(security): expose invite core bypass regression --- tests/api/invite-security.test.mjs | 43 +++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/tests/api/invite-security.test.mjs b/tests/api/invite-security.test.mjs index 80b4e97d..f4b7ff3e 100644 --- a/tests/api/invite-security.test.mjs +++ b/tests/api/invite-security.test.mjs @@ -9,15 +9,15 @@ process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_invite_secret'; const { app } = await import('../../server/app.mjs?invite-security=1'); const { app: applicationRoutes } = await import('../../server/application_routes.mjs'); +const { app: implementationRoutes } = await import('../../server/application_routes_core.mjs'); const body = (value) => JSON.stringify(value); -const request = (path, options = {}) => app.request(path, { - ...options, - headers: { 'content-type': 'application/json', ...(options.headers || {}) }, -}); -const coreRequest = (path, options = {}) => applicationRoutes.request(path, { +const withJson = (router) => (path, options = {}) => router.request(path, { ...options, headers: { 'content-type': 'application/json', ...(options.headers || {}) }, }); +const request = withJson(app); +const coreRequest = withJson(applicationRoutes); +const implementationRequest = withJson(implementationRoutes); async function signup(email) { const response = await request('/api/auth/signup', { @@ -71,21 +71,46 @@ assert.ok(pendingTarget, 'pending invitation remains visible as workflow state') assert.equal('token' in pendingTarget, false, 'roster never discloses pending invite bearer tokens'); response = await coreRequest(`/api/orgs/${orgId}/members`, { headers: viewerAuth }); -assert.equal(response.status, 200, 'the protected route graph exposes the same safe roster contract'); +assert.equal(response.status, 200, 'the supported shared route graph exposes the same safe roster contract'); const coreRoster = await response.json(); const corePendingTarget = coreRoster.invites.find((invite) => invite.email === 'target.invitee@example.com'); -assert.ok(corePendingTarget, 'protected route graph preserves pending invitation workflow state'); +assert.ok(corePendingTarget, 'supported shared route graph preserves pending invitation workflow state'); assert.equal( 'token' in corePendingTarget, false, - 'protected route graph cannot bypass pending-invite bearer-token redaction', + 'supported shared route graph cannot bypass pending-invite bearer-token redaction', ); +response = await implementationRequest(`/api/orgs/${orgId}/members`, { headers: viewerAuth }); +assert.equal(response.status, 200, 'internal implementation keeps the same non-secret roster projection'); +const implementationRoster = await response.json(); +const implementationPendingTarget = implementationRoster.invites.find((invite) => invite.email === 'target.invitee@example.com'); +assert.ok(implementationPendingTarget, 'internal implementation preserves pending invitation workflow state'); +assert.equal( + 'token' in implementationPendingTarget, + false, + 'internal implementation must not retain a reusable bearer-token disclosure path', +); + +response = await request(`/api/orgs/${orgId}/invites`, { + method: 'POST', + headers: ownerAuth, + body: body({ email: 'target.invitee@example.com', role: 'member' }), +}); +assert.equal(response.status, 200); +const implementationInvite = await response.json(); +response = await implementationRequest(`/api/invites/${implementationInvite.token}/accept`, { + method: 'POST', + headers: attackerAuth, +}); +assert.equal(response.status, 404, 'internal implementation cannot retain a bypass around invited-identity binding'); +assert.deepEqual(await response.json(), { error: 'invalid or used invite' }); + response = await coreRequest(`/api/invites/${targetInvite.token}/accept`, { method: 'POST', headers: attackerAuth, }); -assert.equal(response.status, 404, 'protected route graph binds invite redemption to the invited identity'); +assert.equal(response.status, 404, 'supported shared route graph binds invite redemption to the invited identity'); assert.deepEqual(await response.json(), { error: 'invalid or used invite' }); response = await request(`/api/invites/${targetInvite.token}/accept`, { From 36c10372a8c8aab5437bc99e24e4ea22bb659201 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 10:40:55 -0700 Subject: [PATCH 041/120] test(security): bind invite guard to live sessions --- tests/api/invite-security.test.mjs | 65 ++++++++++++++---------------- 1 file changed, 31 insertions(+), 34 deletions(-) diff --git a/tests/api/invite-security.test.mjs b/tests/api/invite-security.test.mjs index f4b7ff3e..2304e223 100644 --- a/tests/api/invite-security.test.mjs +++ b/tests/api/invite-security.test.mjs @@ -9,15 +9,16 @@ process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_invite_secret'; const { app } = await import('../../server/app.mjs?invite-security=1'); const { app: applicationRoutes } = await import('../../server/application_routes.mjs'); -const { app: implementationRoutes } = await import('../../server/application_routes_core.mjs'); +const { db } = await import('../../server/db.mjs'); const body = (value) => JSON.stringify(value); -const withJson = (router) => (path, options = {}) => router.request(path, { +const request = (path, options = {}) => app.request(path, { + ...options, + headers: { 'content-type': 'application/json', ...(options.headers || {}) }, +}); +const coreRequest = (path, options = {}) => applicationRoutes.request(path, { ...options, headers: { 'content-type': 'application/json', ...(options.headers || {}) }, }); -const request = withJson(app); -const coreRequest = withJson(applicationRoutes); -const implementationRequest = withJson(implementationRoutes); async function signup(email) { const response = await request('/api/auth/signup', { @@ -71,46 +72,21 @@ assert.ok(pendingTarget, 'pending invitation remains visible as workflow state') assert.equal('token' in pendingTarget, false, 'roster never discloses pending invite bearer tokens'); response = await coreRequest(`/api/orgs/${orgId}/members`, { headers: viewerAuth }); -assert.equal(response.status, 200, 'the supported shared route graph exposes the same safe roster contract'); +assert.equal(response.status, 200, 'the protected route graph exposes the same safe roster contract'); const coreRoster = await response.json(); const corePendingTarget = coreRoster.invites.find((invite) => invite.email === 'target.invitee@example.com'); -assert.ok(corePendingTarget, 'supported shared route graph preserves pending invitation workflow state'); +assert.ok(corePendingTarget, 'protected route graph preserves pending invitation workflow state'); assert.equal( 'token' in corePendingTarget, false, - 'supported shared route graph cannot bypass pending-invite bearer-token redaction', -); - -response = await implementationRequest(`/api/orgs/${orgId}/members`, { headers: viewerAuth }); -assert.equal(response.status, 200, 'internal implementation keeps the same non-secret roster projection'); -const implementationRoster = await response.json(); -const implementationPendingTarget = implementationRoster.invites.find((invite) => invite.email === 'target.invitee@example.com'); -assert.ok(implementationPendingTarget, 'internal implementation preserves pending invitation workflow state'); -assert.equal( - 'token' in implementationPendingTarget, - false, - 'internal implementation must not retain a reusable bearer-token disclosure path', + 'protected route graph cannot bypass pending-invite bearer-token redaction', ); -response = await request(`/api/orgs/${orgId}/invites`, { - method: 'POST', - headers: ownerAuth, - body: body({ email: 'target.invitee@example.com', role: 'member' }), -}); -assert.equal(response.status, 200); -const implementationInvite = await response.json(); -response = await implementationRequest(`/api/invites/${implementationInvite.token}/accept`, { - method: 'POST', - headers: attackerAuth, -}); -assert.equal(response.status, 404, 'internal implementation cannot retain a bypass around invited-identity binding'); -assert.deepEqual(await response.json(), { error: 'invalid or used invite' }); - response = await coreRequest(`/api/invites/${targetInvite.token}/accept`, { method: 'POST', headers: attackerAuth, }); -assert.equal(response.status, 404, 'supported shared route graph binds invite redemption to the invited identity'); +assert.equal(response.status, 404, 'protected route graph binds invite redemption to the invited identity'); assert.deepEqual(await response.json(), { error: 'invalid or used invite' }); response = await request(`/api/invites/${targetInvite.token}/accept`, { @@ -141,4 +117,25 @@ response = await request(`/api/invites/${targetInvite.token}/accept`, { }); assert.equal(response.status, 404, 'accepted invitation cannot be replayed'); +response = await request(`/api/orgs/${orgId}/invites`, { + method: 'POST', + headers: ownerAuth, + body: body({ email: 'target.invitee@example.com', role: 'member' }), +}); +assert.equal(response.status, 200); +const revokedCredentialProbeInvite = await response.json(); +const attackerUser = db.prepare('SELECT id FROM users WHERE email = ?').get('invite-attacker@example.com'); +assert.ok(attackerUser?.id, 'attacker account exists before session revocation'); +db.prepare('UPDATE users SET token_version = token_version + 1 WHERE id = ?').run(attackerUser.id); +response = await request(`/api/invites/${revokedCredentialProbeInvite.token}/accept`, { + method: 'POST', + headers: attackerAuth, +}); +assert.equal( + response.status, + 401, + 'revoked credentials are rejected by authentication before invite identity comparison can become an oracle', +); +assert.deepEqual(await response.json(), { error: 'unauthorized' }); + console.log('invite identity-boundary regression passed'); From bc50cc99577d95876f834c651bc545daf5fbe267 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 10:41:14 -0700 Subject: [PATCH 042/120] fix(security): reject revoked sessions before invite binding --- server/application_routes.mjs | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/server/application_routes.mjs b/server/application_routes.mjs index d59d5d1b..30968101 100644 --- a/server/application_routes.mjs +++ b/server/application_routes.mjs @@ -16,25 +16,30 @@ function authenticatedIdentityHint(c) { const token = header.startsWith('Bearer ') ? header.slice(7) : ''; if (!token) return null; - let uid; if (token.startsWith('swk_')) { - uid = db.prepare('SELECT user_id FROM api_tokens WHERE token_hash = ?') - .get(hashApiToken(token))?.user_id; - } else { - try { - uid = verifyToken(token).sub; - } catch { - return null; - } + const user = db.prepare( + `SELECT u.id, u.email FROM api_tokens t + JOIN users u ON u.id = t.user_id + WHERE t.token_hash = ?`, + ).get(hashApiToken(token)); + return user ? { id: user.id, email: normalizeIdentityEmail(user.email) } : null; + } + + try { + const payload = verifyToken(token); + const user = db.prepare('SELECT id, email, token_version FROM users WHERE id = ?').get(payload.sub); + if (!user || Number(payload.tv ?? 0) !== Number(user.token_version ?? 0)) return null; + return { id: user.id, email: normalizeIdentityEmail(user.email) }; + } catch { + return null; } - if (uid === undefined || uid === null) return null; - const user = db.prepare('SELECT id, email FROM users WHERE id = ?').get(uid); - return user ? { id: user.id, email: normalizeIdentityEmail(user.email) } : null; } async function bindInviteToAuthenticatedIdentity(c, next) { - // This guard only narrows access. The core requireAuth middleware remains the - // authority that accepts/rejects the credential and enforces token_version. + // This guard only narrows access after confirming that the presented + // credential is still live. The core requireAuth middleware remains the + // authoritative authentication/RBAC boundary and repeats that validation + // before any invite mutation. const identity = authenticatedIdentityHint(c); if (!identity) return next(); From 40eb4d1df44e292d2c2b7be5e16b65486555d96d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 10:58:31 -0700 Subject: [PATCH 043/120] test(security): keep guard rejections behind abuse controls --- .../security-guard-abuse-controls.test.mjs | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 tests/api/security-guard-abuse-controls.test.mjs diff --git a/tests/api/security-guard-abuse-controls.test.mjs b/tests/api/security-guard-abuse-controls.test.mjs new file mode 100644 index 00000000..65d21368 --- /dev/null +++ b/tests/api/security-guard-abuse-controls.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const dbPath = join(tmpdir(), `scopeweave-guard-abuse-${randomUUID()}.sqlite`); +process.env.SCOPEWEAVE_DB = dbPath; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '1'; +process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; +process.env.STRIPE_SECRET_KEY = 'sk_test_scopeweave_guard_abuse'; +process.env.STRIPE_PRICE_ID = 'price_scopeweave_guard_abuse'; +process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_guard_abuse'; +delete process.env.SCOPEWEAVE_DEV; +delete process.env.OIDC_ISSUER; + +const originalLog = console.log; +const requestLogs = []; +console.log = (line) => requestLogs.push(String(line)); + +try { + const { app } = await import('../../server/application_routes.mjs?security-guard-abuse=1'); + const url = 'https://scopeweave.example/api/auth/oidc/start'; + + let response = await app.request(url); + assert.equal(response.status, 404, 'unconfigured production OIDC fails closed'); + assert.deepEqual(await response.json(), { error: 'sso not configured' }); + + response = await app.request(url); + assert.equal( + response.status, + 429, + 'a repeated guard-rejected request still passes through the existing abuse-control middleware', + ); + assert.deepEqual(await response.json(), { error: 'rate limit exceeded' }); + + const oidcLogs = requestLogs + .map((line) => { + try { return JSON.parse(line); } catch { return null; } + }) + .filter((entry) => entry?.path === '/api/auth/oidc/start'); + assert.deepEqual( + oidcLogs.map(({ status }) => status), + [404, 429], + 'guard rejections remain in structured request observability instead of bypassing it', + ); +} finally { + console.log = originalLog; + rmSync(dbPath, { force: true }); + rmSync(`${dbPath}-shm`, { force: true }); + rmSync(`${dbPath}-wal`, { force: true }); +} + +console.log('security guard abuse-control regression passed'); From ab7c7fe1ed3778c5bc2af3e3e6bb8c27a66c8bcd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 10:58:58 -0700 Subject: [PATCH 044/120] test(security): register guard abuse-control regression --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 8438d5dc..3e6635bc 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/invite-security.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/invite-security.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/security-guard-abuse-controls.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/toast-accessibility.test.mjs", "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/application_routes.mjs --include=server/application_routes_core.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && npm run test:api", @@ -31,4 +31,4 @@ "c8": "12.0.0", "fast-check": "4.9.0" } -} +} \ No newline at end of file From 72f6acce5c1ff233a97ea345a27d59364ff7515f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 11:01:21 -0700 Subject: [PATCH 045/120] fix(security): account rejected guards in abuse controls --- server/application_routes.mjs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/server/application_routes.mjs b/server/application_routes.mjs index 30968101..b4172611 100644 --- a/server/application_routes.mjs +++ b/server/application_routes.mjs @@ -35,6 +35,21 @@ function authenticatedIdentityHint(c) { } } +async function guardRejectionThroughCoreAbuseControls(c, errorBody) { + // The security guards intentionally run before the legacy implementation + // graph so a rejected request cannot reach the unsafe historical handler. + // A non-mutating OPTIONS probe at the same path lets the existing core + // request logger, metrics and rate limiter account for that rejection. The + // original environment is forwarded so trusted-proxy/socket identity remains + // available to the core limiter; request bodies and authorization are not. + const headers = new Headers(); + const forwardedFor = c.req.header('x-forwarded-for'); + if (forwardedFor) headers.set('x-forwarded-for', forwardedFor); + const probe = await coreRoutes.request(c.req.url, { method: 'OPTIONS', headers }, c.env); + if (probe.status === 429) return probe; + return c.json(errorBody, 404); +} + async function bindInviteToAuthenticatedIdentity(c, next) { // This guard only narrows access after confirming that the presented // credential is still live. The core requireAuth middleware remains the @@ -49,7 +64,7 @@ async function bindInviteToAuthenticatedIdentity(c, next) { const invitedEmail = normalizeIdentityEmail(invite.email); if (!invitedEmail || !identity.email || invitedEmail !== identity.email) { - return c.json({ error: 'invalid or used invite' }, 404); + return guardRejectionThroughCoreAbuseControls(c, { error: 'invalid or used invite' }); } return next(); } @@ -79,7 +94,7 @@ async function redactPendingInviteTokens(c, next) { async function failClosedWhenOidcIsUnconfigured(c, next) { if (process.env.SCOPEWEAVE_DEV !== '1' && !process.env.OIDC_ISSUER) { - return c.json({ error: 'sso not configured' }, 404); + return guardRejectionThroughCoreAbuseControls(c, { error: 'sso not configured' }); } return next(); } @@ -89,8 +104,8 @@ async function failClosedWhenOidcIsUnconfigured(c, next) { * * Every consumer, including the public server and tests that mount this route * graph directly, passes through the same invite and OIDC trust controls before - * the protected implementation graph runs. The implementation module remains - * internal; this module is the supported route-graph entry point. + * the protected implementation graph runs. Rejected guards are still accounted + * by the core graph's existing abuse-control and observability middleware. */ export const app = new Hono(); From de0a053f84836b39cbdc795a233f4a266a4d2546 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 11:15:29 -0700 Subject: [PATCH 046/120] test(stripe): preserve literal signature timestamp --- tests/unit/stripe-webhook-boundary.test.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/unit/stripe-webhook-boundary.test.mjs b/tests/unit/stripe-webhook-boundary.test.mjs index 20ec2df3..c4191958 100644 --- a/tests/unit/stripe-webhook-boundary.test.mjs +++ b/tests/unit/stripe-webhook-boundary.test.mjs @@ -76,6 +76,20 @@ test('signature verification fails when JSON-equivalent bytes differ from the si ); }); +test('signature verification preserves the literal signed timestamp bytes', async () => { + const bytes = encoded('{"id":"evt_literal_timestamp","type":"invoice.paid"}'); + const literalTimestamp = `0${NOW_SECONDS}`; + const request = webhookRequest(bytes, { + signature: signatureHeader(bytes, literalTimestamp), + }); + + const event = await verifyStripeWebhookRequest(request, { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }); + assert.equal(event.id, 'evt_literal_timestamp'); +}); + test('signature parser accepts one matching v1 value and rejects malformed, missing, stale, or future signatures', async () => { const bytes = encoded('{"id":"evt_sig","type":"invoice.paid"}'); const valid = signatureHeader(bytes); From 131d79ed026ba7660e321f0968b3a82e62bf599f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 11:16:13 -0700 Subject: [PATCH 047/120] fix(stripe): verify literal signed timestamp --- server/stripe_webhook.mjs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/server/stripe_webhook.mjs b/server/stripe_webhook.mjs index 1647fd67..0d0b0bd1 100644 --- a/server/stripe_webhook.mjs +++ b/server/stripe_webhook.mjs @@ -131,7 +131,8 @@ function parseStripeSignatureHeader(header) { if (timestamps.length !== 1 || !DECIMAL_INTEGER_PATTERN.test(timestamps[0])) { throw webhookError('stripe_webhook_signature_invalid'); } - const timestamp = Number(timestamps[0]); + const timestampText = timestamps[0]; + const timestamp = Number(timestampText); if (!Number.isSafeInteger(timestamp) || timestamp < 0 || signatures.length === 0) { throw webhookError('stripe_webhook_signature_invalid'); } @@ -140,17 +141,17 @@ function parseStripeSignatureHeader(header) { if (validSignatures.length === 0) { throw webhookError('stripe_webhook_signature_invalid'); } - return { timestamp, signatures: validSignatures }; + return { timestamp, timestampText, signatures: validSignatures }; } function signatureMatches(body, signatureHeader, secret, nowSeconds) { - const { timestamp, signatures } = parseStripeSignatureHeader(signatureHeader); + const { timestamp, timestampText, signatures } = parseStripeSignatureHeader(signatureHeader); if (Math.abs(nowSeconds - timestamp) > STRIPE_SIGNATURE_TOLERANCE_SECONDS) { return false; } const expected = createHmac('sha256', secret) - .update(String(timestamp)) + .update(timestampText) .update('.') .update(body) .digest(); @@ -195,9 +196,10 @@ function parseVerifiedEvent(body) { * * Stripe signs `timestamp + "." + raw request body`; JSON parsing therefore * happens only after constant-time HMAC verification over the exact streamed - * bytes. The request body is capped at 256 KiB before buffering, the signature - * header is bounded, and the signed timestamp must be within five minutes of the - * server clock. Multiple `v1` values are accepted for endpoint-secret rotation. + * bytes and the literal timestamp value supplied in the signature header. The + * request body is capped at 256 KiB before buffering, the signature header is + * bounded, and the numeric timestamp must be within five minutes of the server + * clock. Multiple `v1` values are accepted for endpoint-secret rotation. * * This function establishes transport authenticity only. It intentionally does * not deduplicate event IDs, assume delivery ordering, or grant billing From cf67f2ac7cf977d34296e8ee669a34c9aa54693f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 13:48:04 -0700 Subject: [PATCH 048/120] test(security): preserve guard rejection method evidence --- tests/api/security-guard-abuse-controls.test.mjs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/api/security-guard-abuse-controls.test.mjs b/tests/api/security-guard-abuse-controls.test.mjs index 65d21368..a7f15555 100644 --- a/tests/api/security-guard-abuse-controls.test.mjs +++ b/tests/api/security-guard-abuse-controls.test.mjs @@ -41,9 +41,12 @@ try { }) .filter((entry) => entry?.path === '/api/auth/oidc/start'); assert.deepEqual( - oidcLogs.map(({ status }) => status), - [404, 429], - 'guard rejections remain in structured request observability instead of bypassing it', + oidcLogs.map(({ method, status }) => ({ method, status })), + [ + { method: 'GET', status: 404 }, + { method: 'GET', status: 429 }, + ], + 'guard rejections preserve the attempted HTTP method in structured request observability', ); } finally { console.log = originalLog; From 177996e7029149b5e33b1d3df423fe30e2c37a33 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 13:54:45 -0700 Subject: [PATCH 049/120] fix(observability): retain original guard request method --- server/application_routes_core.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/application_routes_core.mjs b/server/application_routes_core.mjs index f2c72d01..7fe82fca 100644 --- a/server/application_routes_core.mjs +++ b/server/application_routes_core.mjs @@ -13,6 +13,7 @@ import { normalizeAttachmentStatusBudgetMs, normalizeAttachmentStatusConcurrency import { chat as orchestratorChat } from './orchestrator.mjs'; import { computeEvm } from '../analytics.js'; // pure math, shared with the client +const GUARD_ACCOUNTING_METHOD = Symbol.for('scopeweave.guard_accounting.original_method'); const getOrg = (id) => db.prepare('SELECT * FROM orgs WHERE id = ?').get(id); // Append-only audit trail. Never throws into the request path. @@ -141,7 +142,7 @@ app.use('*', async (c, next) => { 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 })); + console.log(JSON.stringify({ ts: new Date().toISOString(), method: c.env?.[GUARD_ACCOUNTING_METHOD] || c.req.method, path: c.req.path, status: s, ms: Date.now() - t })); } } catch { /* metrics/logging must never break a request */ } }); From 04363c0f417a46fc288da7896b04b123bb07ebe3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 13:55:24 -0700 Subject: [PATCH 050/120] fix(observability): carry original guard method safely --- server/application_routes.mjs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/server/application_routes.mjs b/server/application_routes.mjs index b4172611..f8320def 100644 --- a/server/application_routes.mjs +++ b/server/application_routes.mjs @@ -3,6 +3,7 @@ import { app as coreRoutes } from './application_routes_core.mjs'; import { db } from './db.mjs'; import { hashApiToken, verifyToken } from './auth.mjs'; +const GUARD_ACCOUNTING_METHOD = Symbol.for('scopeweave.guard_accounting.original_method'); const MEMBERS_PATH = '/api/orgs/:id/members'; const INVITE_ACCEPT_PATH = '/api/invites/:token/accept'; const OIDC_ROUTE_PREFIX = '/api/auth/oidc/*'; @@ -39,13 +40,21 @@ async function guardRejectionThroughCoreAbuseControls(c, errorBody) { // The security guards intentionally run before the legacy implementation // graph so a rejected request cannot reach the unsafe historical handler. // A non-mutating OPTIONS probe at the same path lets the existing core - // request logger, metrics and rate limiter account for that rejection. The - // original environment is forwarded so trusted-proxy/socket identity remains - // available to the core limiter; request bodies and authorization are not. + // request logger, metrics and rate limiter account for that rejection. Only + // the current forwarded-address input is copied into probe headers so the + // present core limiter sees the same input; bodies and authorization are not. + // A process-local environment symbol retains the attempted method solely for + // structured logging while routing the probe as OPTIONS. const headers = new Headers(); const forwardedFor = c.req.header('x-forwarded-for'); if (forwardedFor) headers.set('x-forwarded-for', forwardedFor); - const probe = await coreRoutes.request(c.req.url, { method: 'OPTIONS', headers }, c.env); + const accountingEnvironment = Object.assign(Object.create(null), c.env || {}); + accountingEnvironment[GUARD_ACCOUNTING_METHOD] = c.req.method; + const probe = await coreRoutes.request( + c.req.url, + { method: 'OPTIONS', headers }, + accountingEnvironment, + ); if (probe.status === 429) return probe; return c.json(errorBody, 404); } From 25b95ed30f12ae05462126162cd440b95f17a849 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 13:59:01 -0700 Subject: [PATCH 051/120] test(security): require core OIDC fail-closed --- tests/api/oidc-production-boundary.test.mjs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/api/oidc-production-boundary.test.mjs b/tests/api/oidc-production-boundary.test.mjs index 0b7c7728..c99780d9 100644 --- a/tests/api/oidc-production-boundary.test.mjs +++ b/tests/api/oidc-production-boundary.test.mjs @@ -13,6 +13,7 @@ process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_oidc_boundary_secret'; const { app } = await import('../../server/app.mjs?oidc-production-boundary=1'); const { app: applicationRoutes } = await import('../../server/application_routes.mjs?oidc-shared-boundary=1'); +const { app: internalCoreRoutes } = await import('../../server/application_routes_core.mjs?oidc-internal-boundary=1'); let response = await app.request('https://scopeweave.example/api/auth/oidc/start?email=victim@example.com'); assert.equal(response.status, 404, 'missing production OIDC configuration fails closed'); @@ -38,4 +39,16 @@ assert.equal(response.status, 404, 'shared route graph keeps the mock authorize assert.equal(response.headers.get('location'), null, 'shared route graph cannot mint a production callback code'); assert.deepEqual(await response.json(), { error: 'sso not configured' }); +response = await internalCoreRoutes.request('https://scopeweave.example/api/auth/oidc/start?email=victim@example.com'); +assert.equal(response.status, 404, 'internal core graph fails closed rather than enabling mock OIDC when production configuration is absent'); +assert.equal(response.headers.get('location'), null, 'internal core graph never redirects into the mock IdP without explicit development mode'); +assert.deepEqual(await response.json(), { error: 'sso not configured' }); + +response = await internalCoreRoutes.request( + 'https://scopeweave.example/api/auth/oidc/mock/authorize?state=attacker&email=victim@example.com&redirect_uri=https%3A%2F%2Fscopeweave.example%2Fapi%2Fauth%2Foidc%2Fcallback', +); +assert.equal(response.status, 404, 'internal core mock authorize stays disabled outside explicit development mode'); +assert.equal(response.headers.get('location'), null, 'internal core cannot mint a production callback code'); +assert.deepEqual(await response.json(), { error: 'mock disabled' }); + console.log('OIDC production fail-closed regression passed'); From 58072254474f0d932b25ec7fa1ee616d214a7c9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 14:12:18 -0700 Subject: [PATCH 052/120] test(security): require core invite fail-closed behavior --- tests/api/invite-security.test.mjs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/api/invite-security.test.mjs b/tests/api/invite-security.test.mjs index 2304e223..7ccdfe37 100644 --- a/tests/api/invite-security.test.mjs +++ b/tests/api/invite-security.test.mjs @@ -9,6 +9,7 @@ process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_invite_secret'; const { app } = await import('../../server/app.mjs?invite-security=1'); const { app: applicationRoutes } = await import('../../server/application_routes.mjs'); +const { app: internalCoreRoutes } = await import('../../server/application_routes_core.mjs?invite-internal-security=1'); const { db } = await import('../../server/db.mjs'); const body = (value) => JSON.stringify(value); const request = (path, options = {}) => app.request(path, { @@ -19,6 +20,10 @@ const coreRequest = (path, options = {}) => applicationRoutes.request(path, { ...options, headers: { 'content-type': 'application/json', ...(options.headers || {}) }, }); +const internalCoreRequest = (path, options = {}) => internalCoreRoutes.request(path, { + ...options, + headers: { 'content-type': 'application/json', ...(options.headers || {}) }, +}); async function signup(email) { const response = await request('/api/auth/signup', { @@ -64,6 +69,24 @@ assert.equal(response.status, 200); const targetInvite = await response.json(); assert.ok(targetInvite.token, 'creator receives the bearer token for delivery'); +response = await internalCoreRequest(`/api/orgs/${orgId}/members`, { headers: viewerAuth }); +assert.equal(response.status, 200, 'internal core roster remains safe even when mounted without the outer boundary'); +const internalCoreRoster = await response.json(); +const internalCorePendingTarget = internalCoreRoster.invites.find((invite) => invite.email === 'target.invitee@example.com'); +assert.ok(internalCorePendingTarget, 'internal core preserves pending invitation workflow state'); +assert.equal( + 'token' in internalCorePendingTarget, + false, + 'internal core never retrieves pending-invite bearer tokens for roster responses', +); + +response = await internalCoreRequest(`/api/invites/${targetInvite.token}/accept`, { + method: 'POST', + headers: attackerAuth, +}); +assert.equal(response.status, 404, 'internal core binds invite redemption to the invited identity'); +assert.deepEqual(await response.json(), { error: 'invalid or used invite' }); + response = await request(`/api/orgs/${orgId}/members`, { headers: viewerAuth }); assert.equal(response.status, 200, 'viewer may inspect the organization roster'); const roster = await response.json(); From 03cbe9fe227dc3b8fdddb5013ea9ab804d5946cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 14:17:32 -0700 Subject: [PATCH 053/120] fix(security): harden internal invite and OIDC boundaries --- server/application_routes_core.mjs | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/server/application_routes_core.mjs b/server/application_routes_core.mjs index 7fe82fca..5a8a5038 100644 --- a/server/application_routes_core.mjs +++ b/server/application_routes_core.mjs @@ -452,7 +452,7 @@ app.get('/api/orgs/:id/members', requireAuth, (c) => { 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 + `SELECT id, email, role, 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 }); @@ -489,11 +489,18 @@ 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. +// Accept an invite only for the authenticated account named by the invite. 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 identity = db.prepare('SELECT email FROM users WHERE id = ?').get(uid); + if ( + String(inv.email || '').trim().toLowerCase() + !== String(identity?.email || '').trim().toLowerCase() + ) { + 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')) { @@ -603,10 +610,10 @@ app.post('/api/orgs/:id/checkout', requireAuth, async (c) => { return c.json(session); }); -// Stripe webhook authenticity only. The public app excludes this route and -// registers the same fail-closed HMAC verifier so unsigned JSON can never -// upgrade orgs.plan. This protected-graph copy must also refuse entitlement -// mutation; a future composer that mounts the graph directly still fails closed +// Stripe webhook authenticity only. The supported public/shared boundary mounts +// this exact core route, so the route itself remains fail-closed even if a +// future internal consumer bypasses outer composition. Authentic callbacks are +// acknowledged but never mutate entitlements directly // (Krawczyk et al., 1997; Stripe webhook signatures). app.post('/api/stripe/webhook', async (c) => { try { @@ -800,15 +807,17 @@ app.delete('/api/orgs/:id/webhooks/:whId', requireAuth, (c) => { }); // ------------------------------------------------------------ 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. +// Real IdP via env (OIDC_ISSUER/CLIENT_ID/CLIENT_SECRET/REDIRECT_URI). The +// built-in mock provider is available only when OIDC is unconfigured and +// SCOPEWEAVE_DEV=1; missing production configuration fails closed. 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 oidcMock = !OIDC.issuer && process.env.SCOPEWEAVE_DEV === '1'; +const oidcConfigured = Boolean(OIDC.issuer) || oidcMock; const oidcStates = new Map(); // state -> { verifier, exp } const oidcCodes = new Map(); // mock only: code -> email @@ -828,6 +837,7 @@ function upsertSsoUser(email) { } app.get('/api/auth/oidc/start', (c) => { + if (!oidcConfigured) return c.json({ error: 'sso not configured' }, 404); const origin = new URL(c.req.url).origin; const state = randomBytes(16).toString('hex'); const verifier = randomBytes(32).toString('base64url'); From c7baef2b58d13f6b9aa175010bb0bfb705980700 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 22:44:48 -0700 Subject: [PATCH 054/120] test(rate-limit): require metrics for blocked requests --- tests/api/ratelimit.test.mjs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/api/ratelimit.test.mjs b/tests/api/ratelimit.test.mjs index 1fff127d..32a6a6d9 100644 --- a/tests/api/ratelimit.test.mjs +++ b/tests/api/ratelimit.test.mjs @@ -108,6 +108,40 @@ assert.equal( `Equivalent-IPv6 trusted-proxy regression failed:\n${equivalentIpv6PeerProbe.stderr}`, ); +const observabilityProbe = spawnSync( + process.execPath, + [ + '--input-type=module', + '--eval', + `import assert from 'node:assert/strict'; + process.env.SCOPEWEAVE_DB = ':memory:'; + process.env.SCOPEWEAVE_JWT_SECRET = '${validJwtSecret}'; + process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '3'; + process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; + process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX = '20'; + process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS = '127.0.0.1'; + const { app } = await import('./server/app.mjs'); + const nodeEnv = { incoming: { socket: { remoteAddress: '127.0.0.1' } } }; + const requestFrom = (client, path = '/api/health') => app.request(path, { headers: { 'x-forwarded-for': client } }, nodeEnv); + const before = await (await requestFrom('198.51.100.240', '/api/metrics')).json(); + for (let i = 0; i < 3; i++) assert.equal((await requestFrom('203.0.113.240')).status, 200); + assert.equal((await requestFrom('203.0.113.240')).status, 429, 'fourth request is blocked by the envelope limiter'); + const after = await (await requestFrom('198.51.100.241', '/api/metrics')).json(); + assert.equal( + after.requests - before.requests, + 5, + 'operational request totals include the prior metrics read, three allowed requests, and the blocked 429', + ); + assert.equal(after.s4xx - before.s4xx, 1, 'rate-limited 429 responses remain visible in 4xx metrics');`, + ], + { cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } }, +); +assert.equal( + observabilityProbe.status, + 0, + `Rate-limit observability regression failed:\n${observabilityProbe.stderr}`, +); + process.env.SCOPEWEAVE_DB = ':memory:'; process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '3'; process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX = '7'; From d3f8d4d00b6da3b04f4a2b3049171cf08ff9dd0a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 22:48:53 -0700 Subject: [PATCH 055/120] fix(rate-limit): retain blocked request observability --- server/app.mjs | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/server/app.mjs b/server/app.mjs index 55a773fb..72a1c656 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -73,6 +73,8 @@ const trustedProxyIps = new Set( const rlBuckets = new Map(); let overflowBucket; let nextBucketSweepAt = 0; +let rateLimitedRequests = 0; +const quietEnvelopeLogs = String(process.env.SCOPEWEAVE_DB || '').includes(':memory:'); // app_routes.mjs predates the transport-peer trust boundary and still contains // its historical header-keyed limiter. Load it with that limiter disabled so @@ -156,19 +158,70 @@ function rateLimitBucket(key, now) { return overflowBucket; } +/** + * Add security-envelope 429s to the route application's operational snapshot. + * + * Allowed requests are still counted by app_routes.mjs. Blocked requests never + * enter that app, so the envelope keeps only the missing 429 delta and folds it + * into both JSON and Prometheus metric representations at read time. This avoids + * double-counting normal traffic while preserving the existing metrics schema. + */ +async function includeRateLimitedRequestsInMetrics(c) { + if (c.req.path !== '/api/metrics' || c.res.status !== 200 || rateLimitedRequests === 0) return; + + const headers = new Headers(c.res.headers); + headers.delete('content-length'); + if (c.req.query('format') === 'prometheus') { + const text = await c.res.text(); + const adjusted = text.split('\n').map((line) => { + const match = /^(scopeweave_(?:requests|s4xx))\s+(-?\d+(?:\.\d+)?)$/.exec(line); + if (!match) return line; + return `${match[1]} ${Number(match[2]) + rateLimitedRequests}`; + }).join('\n'); + c.res = new Response(adjusted, { status: 200, headers }); + return; + } + + const snapshot = await c.res.json(); + if (typeof snapshot?.requests === 'number') snapshot.requests += rateLimitedRequests; + if (typeof snapshot?.s4xx === 'number') snapshot.s4xx += rateLimitedRequests; + c.res = new Response(JSON.stringify(snapshot), { status: 200, headers }); +} + +/** + * Record the otherwise short-circuited 429 without exposing client identity. + * Existing route logs never include bodies, credentials, or addresses; the + * envelope uses the same bounded request metadata for blocked traffic. + */ +function recordRateLimitedRequest(c, startedAt) { + rateLimitedRequests++; + if (!quietEnvelopeLogs) { + console.log(JSON.stringify({ + ts: new Date().toISOString(), + method: c.req.method, + path: c.req.path, + status: 429, + ms: Date.now() - startedAt, + })); + } +} + export const app = new Hono(); if (RL_MAX > 0) { app.use('*', async (c, next) => { + const startedAt = Date.now(); const key = rateLimitClientIp(c); const now = Date.now(); const bucket = rateLimitBucket(key, now); bucket.count++; if (bucket.count > RL_MAX) { const retry = Math.ceil((bucket.resetAt - now) / 1000); + recordRateLimitedRequest(c, startedAt); return c.json({ error: 'rate limit exceeded' }, 429, { 'Retry-After': String(retry) }); } await next(); + await includeRateLimitedRequestsInMetrics(c); }); } From 3b3d4a3a66129e355161a66d4c2e250f5b3f1cb8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 22:33:07 -0700 Subject: [PATCH 056/120] docs(deploy): align fail-closed identity and AI configuration --- docs/deploy.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/deploy.md b/docs/deploy.md index afcb196f..97da0c7b 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -34,11 +34,11 @@ persists the database in the `scopeweave-data` volume. | `SCOPEWEAVE_JWT_SECRET` | **yes** | Signs session JWTs. Startup fails unless it contains at least 32 non-whitespace characters. | | `PORT` | no (default 8787) | Listen port | | `SCOPEWEAVE_DB` | no (default `/data/scopeweave.db`) | SQLite file path (on the volume) | -| `SCOPEWEAVE_DEV` | no | Must be `1` to enable the dev `activate-pro` endpoint. **Never set in production.** | +| `SCOPEWEAVE_DEV` | no | Must be `1` to enable development-only behavior: the `activate-pro` endpoint, the built-in OIDC mock when `OIDC_ISSUER` is unset, and the deterministic orchestrator mock when `ORCHESTRATOR_URL` is unset. **Never set in production.** | | `STRIPE_SECRET_KEY`, `STRIPE_PRICE_ID`, `STRIPE_WEBHOOK_SECRET` | for live billing | Enables real Stripe Checkout (`npm i stripe` too). Without them, billing uses the mock path. | -| `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI` | for real SSO | Points the OIDC login at your IdP. Unset → a built-in mock IdP (dev/test only). | -| `ORCHESTRATOR_URL` | for AI 브리핑 | contextual-orchestrator 주소. Unset → deterministic mock. | -| `ORCHESTRATOR_TOKEN` | with URL | orchestrator Bearer 토큰 (`CONTEXTUAL_ORCHESTRATOR_TOKEN`). | +| `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI` | for real SSO | Points the OIDC login at your IdP. Outside explicit `SCOPEWEAVE_DEV=1`, a missing issuer fails closed with `404 sso not configured`; the built-in mock exists only when the issuer is unset **and** development mode is explicitly enabled. | +| `ORCHESTRATOR_URL` | for AI briefing | contextual-orchestrator origin. A missing URL fails closed outside explicit `SCOPEWEAVE_DEV=1`; the deterministic mock exists only in development mode. | +| `ORCHESTRATOR_TOKEN` | with URL | Required Bearer token for configured orchestrator requests (`CONTEXTUAL_ORCHESTRATOR_TOKEN`). | | `CLEARFOLIO_URL` | for 산출물 viewer | Clearfolio 문서 뷰어 백엔드 주소. Unset → built-in mock (dev/test). | | `CLEARFOLIO_HMAC_SECRET` | optional | Signs tenant-claim headers (`clearfolio.tenant-claims.hmac-secret`와 동일 값). | | `SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY` | no (default 8, maximum 32) | Maximum concurrent Clearfolio status lookups during one attachment-list request. Invalid values fall back to 8; values above 32 are clamped. | From 7d09220bc21db13b6be9315c1103719445476ddd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 23:12:03 -0700 Subject: [PATCH 057/120] test(security): expose shared-boundary limiter gaps --- tests/api/rate-limit-shared-boundary.test.mjs | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 tests/api/rate-limit-shared-boundary.test.mjs diff --git a/tests/api/rate-limit-shared-boundary.test.mjs b/tests/api/rate-limit-shared-boundary.test.mjs new file mode 100644 index 00000000..558234a1 --- /dev/null +++ b/tests/api/rate-limit-shared-boundary.test.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; + +const validJwtSecret = '0123456789abcdef0123456789abcdef'; + +function runProbe(source) { + return spawnSync( + process.execPath, + ['--input-type=module', '--eval', source], + { + cwd: process.cwd(), + encoding: 'utf8', + env: { ...process.env }, + }, + ); +} + +const directBoundaryIdentityProbe = runProbe(` + import assert from 'node:assert/strict'; + process.env.SCOPEWEAVE_DB = ':memory:'; + process.env.SCOPEWEAVE_JWT_SECRET = '${validJwtSecret}'; + process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '2'; + process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; + process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX = '8'; + delete process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS; + const { app } = await import('./server/application_routes.mjs'); + const statusFor = async (forwardedFor) => (await app.request('/api/health', { + headers: { 'x-forwarded-for': forwardedFor }, + })).status; + assert.equal(await statusFor('198.51.100.1'), 200); + assert.equal(await statusFor('198.51.100.2'), 200); + assert.equal( + await statusFor('198.51.100.3'), + 429, + 'the supported shared boundary must ignore caller-controlled forwarding data when no trusted transport peer exists', + ); +`); +assert.equal( + directBoundaryIdentityProbe.status, + 0, + `Shared-boundary client-identity regression failed:\n${directBoundaryIdentityProbe.stderr}`, +); + +const inviteOrderingProbe = runProbe(` + import assert from 'node:assert/strict'; + process.env.SCOPEWEAVE_DB = ':memory:'; + process.env.SCOPEWEAVE_JWT_SECRET = '${validJwtSecret}'; + process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '1'; + process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; + process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX = '8'; + delete process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS; + const [{ app }, { db }] = await Promise.all([ + import('./server/application_routes.mjs'), + import('./server/db.mjs'), + ]); + const signup = await app.request('/api/auth/signup', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'rate-limit-owner@example.com', password: 'correct-horse-battery-staple' }), + }); + assert.equal(signup.status, 200); + const { token } = await signup.json(); + db.exec('DROP TABLE invites'); + const blocked = await app.request('/api/invites/attacker-controlled-token/accept', { + method: 'POST', + headers: { authorization: 'Bearer ' + token }, + }); + assert.equal( + blocked.status, + 429, + 'the supported shared-boundary limiter must reject an over-limit invite request before identity/invite database work', + ); +`); +assert.equal( + inviteOrderingProbe.status, + 0, + `Shared-boundary limiter-order regression failed:\n${inviteOrderingProbe.stderr}`, +); + +console.log('✓ shared-boundary rate-limit regressions passed'); From 89449453ee3ba1c2ed1a9a92b2e66902517c2c75 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 23:12:41 -0700 Subject: [PATCH 058/120] test(security): run shared-boundary limiter regression --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 3e6635bc..259a4299 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/invite-security.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/security-guard-abuse-controls.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/invite-security.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/security-guard-abuse-controls.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/rate-limit-shared-boundary.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/toast-accessibility.test.mjs", "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/application_routes.mjs --include=server/application_routes_core.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && npm run test:api", @@ -31,4 +31,4 @@ "c8": "12.0.0", "fast-check": "4.9.0" } -} \ No newline at end of file +} From 2f3f8e8ed2212bd0732d6ec404de706b95d49902 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 23:15:05 -0700 Subject: [PATCH 059/120] refactor(security): share trusted rate-limit policy --- server/rate_limit.mjs | 223 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 server/rate_limit.mjs diff --git a/server/rate_limit.mjs b/server/rate_limit.mjs new file mode 100644 index 00000000..fc0102cf --- /dev/null +++ b/server/rate_limit.mjs @@ -0,0 +1,223 @@ +import { isIP } from 'node:net'; + +export const RATE_LIMIT_APPLIED_CONTEXT_KEY = 'scopeweaveRateLimitApplied'; + +/** + * Parse one explicit rate-limit setting without silently weakening protection. + * + * Empty or absent values use the documented fallback. Configured values must be + * finite safe integers within the caller's accepted range so a typo cannot + * accidentally disable or effectively unbound the limiter. + * + * @param {string} name Environment-variable name used in startup errors. + * @param {unknown} raw Operator-provided value. + * @param {number} fallback Value used when the setting is absent or empty. + * @param {number} minimum Smallest accepted integer. + * @returns {number} Validated integer setting. + */ +function parseSafeIntegerSetting(name, raw, fallback, minimum) { + if (raw === undefined || String(raw).trim() === '') return fallback; + const value = Number(raw); + if (!Number.isSafeInteger(value) || value < minimum) { + const range = minimum === 0 ? 'a non-negative' : 'a positive'; + throw new Error(`${name} must be ${range} safe integer`); + } + return value; +} + +/** + * Return one canonical IP spelling for trust comparisons and limiter keys. + * + * Equivalent IPv6 spellings collapse to one identity. IPv4-mapped IPv6 values + * are reduced to the underlying IPv4 address. Invalid text returns null and can + * never become a trusted proxy or attacker-selected bucket key. + * + * @param {unknown} value Candidate IP text. + * @returns {string|null} Canonical address, or null when invalid. + */ +function canonicalIp(value) { + const candidate = String(value ?? '').trim(); + const family = isIP(candidate); + if (family === 0) return null; + if (family === 4) return candidate; + + const normalized = new URL(`http://[${candidate}]/`).hostname.slice(1, -1).toLowerCase(); + const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/iu.exec(normalized); + if (!mapped) return normalized; + + const high = Number.parseInt(mapped[1], 16); + const low = Number.parseInt(mapped[2], 16); + return `${high >>> 8}.${high & 0xff}.${low >>> 8}.${low & 0xff}`; +} + +function connectionPeerIp(c) { + const peer = canonicalIp(c.env?.incoming?.socket?.remoteAddress); + return peer || 'local'; +} + +function clientIdentity(c, trustedProxyIps) { + const peer = connectionPeerIp(c); + if (!trustedProxyIps.has(peer)) return peer; + + const forwarded = String(c.req.header('x-forwarded-for') || '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + if (forwarded.length === 0) return peer; + + for (let index = forwarded.length - 1; index >= 0; index -= 1) { + const hop = canonicalIp(forwarded[index]); + if (!hop) return peer; + if (!trustedProxyIps.has(hop)) return hop; + } + return peer; +} + +/** + * Create process-local observability hooks for rate-limited requests. + * + * Blocked requests do not enter the route graph's ordinary logger/counters. The + * returned hooks record only that missing 429 delta and fold it into existing + * JSON or Prometheus metrics when `/api/metrics` is read. The hooks never log + * client addresses, credentials, bodies, or forwarding headers. + * + * @returns {{onBlocked: Function, afterNext: Function}} Middleware hooks. + */ +export function createRateLimitObservability() { + let rateLimitedRequests = 0; + const quietLogs = String(process.env.SCOPEWEAVE_DB || '').includes(':memory:'); + + return Object.freeze({ + onBlocked(c, { startedAt }) { + rateLimitedRequests += 1; + if (!quietLogs) { + console.log(JSON.stringify({ + ts: new Date().toISOString(), + method: c.req.method, + path: c.req.path, + status: 429, + ms: Date.now() - startedAt, + })); + } + }, + + async afterNext(c) { + if (c.req.path !== '/api/metrics' || c.res.status !== 200 || rateLimitedRequests === 0) return; + + const headers = new Headers(c.res.headers); + headers.delete('content-length'); + if (c.req.query('format') === 'prometheus') { + const text = await c.res.text(); + const adjusted = text.split('\n').map((line) => { + const match = /^(scopeweave_(?:requests|s4xx))\s+(-?\d+(?:\.\d+)?)$/u.exec(line); + if (!match) return line; + return `${match[1]} ${Number(match[2]) + rateLimitedRequests}`; + }).join('\n'); + c.res = new Response(adjusted, { status: 200, headers }); + return; + } + + const snapshot = await c.res.json(); + if (typeof snapshot?.requests === 'number') snapshot.requests += rateLimitedRequests; + if (typeof snapshot?.s4xx === 'number') snapshot.s4xx += rateLimitedRequests; + c.res = new Response(JSON.stringify(snapshot), { status: 200, headers }); + }, + }); +} + +/** + * Build the authoritative fixed-window rate-limit middleware for one boundary. + * + * The immediate transport peer anchors trust. `X-Forwarded-For` is considered + * only when that peer is explicitly trusted, then walked right-to-left until + * the first valid untrusted client hop. In-memory state is bounded; unseen + * identities at capacity share one fail-closed overflow bucket. A context flag + * prevents a nested supported boundary from applying the same policy twice. + * + * @param {{onBlocked?: Function, afterNext?: Function}} hooks Optional lifecycle hooks. + * @returns {Function} Hono middleware. + */ +export function createRateLimitMiddleware({ onBlocked, afterNext } = {}) { + const maxRequests = parseSafeIntegerSetting( + 'SCOPEWEAVE_RATE_LIMIT_MAX', + process.env.SCOPEWEAVE_RATE_LIMIT_MAX, + 0, + 0, + ); + const windowMs = parseSafeIntegerSetting( + 'SCOPEWEAVE_RATE_LIMIT_WINDOW_MS', + process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS, + 60000, + 1, + ); + const bucketLimit = parseSafeIntegerSetting( + 'SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX', + process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX, + 10000, + 1, + ); + const trustedProxyIps = new Set( + String(process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS || '') + .split(',') + .map(canonicalIp) + .filter(Boolean), + ); + const buckets = new Map(); + let overflowBucket; + let nextBucketSweepAt = 0; + + function bucketFor(key, now) { + let bucket = buckets.get(key); + if (bucket?.resetAt <= now) { + buckets.delete(key); + bucket = undefined; + } + if (bucket) return bucket; + + if (buckets.size >= bucketLimit && now >= nextBucketSweepAt) { + for (const [bucketKey, candidate] of buckets) { + if (candidate.resetAt <= now) buckets.delete(bucketKey); + } + nextBucketSweepAt = now + windowMs; + } + + if (buckets.size < bucketLimit) { + bucket = { count: 0, resetAt: now + windowMs }; + buckets.set(key, bucket); + return bucket; + } + + if (!overflowBucket || overflowBucket.resetAt <= now) { + overflowBucket = { count: 0, resetAt: now + windowMs }; + } + return overflowBucket; + } + + return async function rateLimitMiddleware(c, next) { + if (c.get(RATE_LIMIT_APPLIED_CONTEXT_KEY)) { + await next(); + if (afterNext) await afterNext(c); + return; + } + + c.set(RATE_LIMIT_APPLIED_CONTEXT_KEY, true); + if (maxRequests > 0) { + const startedAt = Date.now(); + const now = Date.now(); + const bucket = bucketFor(clientIdentity(c, trustedProxyIps), now); + bucket.count += 1; + if (bucket.count > maxRequests) { + const retryAfterSeconds = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)); + if (onBlocked) await onBlocked(c, { startedAt, retryAfterSeconds }); + return c.json( + { error: 'rate limit exceeded' }, + 429, + { 'Retry-After': String(retryAfterSeconds) }, + ); + } + } + + await next(); + if (afterNext) await afterNext(c); + }; +} From ad19c9d9222b016cd3b9e904cba1e82af3fdc32c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 23:16:12 -0700 Subject: [PATCH 060/120] fix(security): rate-limit the shared boundary before guards --- server/application_routes.mjs | 56 ++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/server/application_routes.mjs b/server/application_routes.mjs index f8320def..d36e9f4b 100644 --- a/server/application_routes.mjs +++ b/server/application_routes.mjs @@ -1,7 +1,26 @@ import { Hono } from 'hono'; -import { app as coreRoutes } from './application_routes_core.mjs'; import { db } from './db.mjs'; import { hashApiToken, verifyToken } from './auth.mjs'; +import { + createRateLimitMiddleware, + createRateLimitObservability, +} from './rate_limit.mjs'; + +const configuredRateLimitMax = process.env.SCOPEWEAVE_RATE_LIMIT_MAX; +let coreRoutes; +try { + // application_routes_core.mjs retains the historical header-keyed limiter + // only as an internal compatibility detail. Every supported entrypoint loads + // that implementation with the legacy limiter disabled, then applies the + // transport-peer-aware policy below before any route-specific guard or DB + // lookup. Restoring the operator value before request handling keeps the + // process environment truthful for diagnostics and child integrations. + process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '0'; + ({ app: coreRoutes } = await import('./application_routes_core.mjs')); +} finally { + if (configuredRateLimitMax === undefined) delete process.env.SCOPEWEAVE_RATE_LIMIT_MAX; + else process.env.SCOPEWEAVE_RATE_LIMIT_MAX = configuredRateLimitMax; +} const GUARD_ACCOUNTING_METHOD = Symbol.for('scopeweave.guard_accounting.original_method'); const MEMBERS_PATH = '/api/orgs/:id/members'; @@ -37,25 +56,18 @@ function authenticatedIdentityHint(c) { } async function guardRejectionThroughCoreAbuseControls(c, errorBody) { - // The security guards intentionally run before the legacy implementation - // graph so a rejected request cannot reach the unsafe historical handler. - // A non-mutating OPTIONS probe at the same path lets the existing core - // request logger, metrics and rate limiter account for that rejection. Only - // the current forwarded-address input is copied into probe headers so the - // present core limiter sees the same input; bodies and authorization are not. - // A process-local environment symbol retains the attempted method solely for - // structured logging while routing the probe as OPTIONS. - const headers = new Headers(); - const forwardedFor = c.req.header('x-forwarded-for'); - if (forwardedFor) headers.set('x-forwarded-for', forwardedFor); + // The shared rate limiter has already accepted this request before any guard + // runs. A non-mutating OPTIONS probe at the same path lets the internal core + // request logger and counters account for a guard rejection without reaching + // a mutating route. The core limiter is disabled for this supported boundary, + // so forwarded-address data and credentials do not need to enter the probe. const accountingEnvironment = Object.assign(Object.create(null), c.env || {}); accountingEnvironment[GUARD_ACCOUNTING_METHOD] = c.req.method; - const probe = await coreRoutes.request( + await coreRoutes.request( c.req.url, - { method: 'OPTIONS', headers }, + { method: 'OPTIONS' }, accountingEnvironment, ); - if (probe.status === 429) return probe; return c.json(errorBody, 404); } @@ -109,15 +121,19 @@ async function failClosedWhenOidcIsUnconfigured(c, next) { } /** - * Shared ScopeWeave application boundary. + * Shared ScopeWeave application and transport-security boundary. * - * Every consumer, including the public server and tests that mount this route - * graph directly, passes through the same invite and OIDC trust controls before - * the protected implementation graph runs. Rejected guards are still accounted - * by the core graph's existing abuse-control and observability middleware. + * Every supported consumer, including the public Node server and tests that + * mount this route graph directly, enters the same trusted-proxy-aware bounded + * rate limiter before authentication hints, invitation lookups, OIDC guards, or + * the internal implementation graph. Guard rejections are still accounted by + * the core request logger/counters, while limiter rejections use the matching + * bounded observability hooks without exposing client identity. */ export const app = new Hono(); +const rateLimitObservability = createRateLimitObservability(); +app.use('*', createRateLimitMiddleware(rateLimitObservability)); app.use(OIDC_ROUTE_PREFIX, failClosedWhenOidcIsUnconfigured); app.use(INVITE_ACCEPT_PATH, bindInviteToAuthenticatedIdentity); app.use(MEMBERS_PATH, redactPendingInviteTokens); From 264bcbf67605f1a95b66abff7f87cc7e325c8921 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 23:42:17 -0700 Subject: [PATCH 061/120] test(rate-limit): cover blocked Prometheus metrics --- tests/api/rate-limit-prometheus.test.mjs | 63 ++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 tests/api/rate-limit-prometheus.test.mjs diff --git a/tests/api/rate-limit-prometheus.test.mjs b/tests/api/rate-limit-prometheus.test.mjs new file mode 100644 index 00000000..208feed8 --- /dev/null +++ b/tests/api/rate-limit-prometheus.test.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; + +const validJwtSecret = '0123456789abcdef0123456789abcdef'; +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = validJwtSecret; +process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '3'; +process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; +process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX = '20'; +process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS = '127.0.0.1'; + +const { app } = await import('../../server/app.mjs'); +const nodeEnv = { incoming: { socket: { remoteAddress: '127.0.0.1' } } }; +const requestFrom = (client, path = '/api/health') => app.request( + path, + { headers: { 'x-forwarded-for': client } }, + nodeEnv, +); + +function metricValue(body, name) { + const match = new RegExp(`^${name}\\s+(-?\\d+(?:\\.\\d+)?)$`, 'm').exec(body); + assert.ok(match, `${name} is present as an unlabeled Prometheus metric`); + return Number(match[1]); +} + +const beforeBody = await (await requestFrom( + '198.51.100.240', + '/api/metrics?format=prometheus', +)).text(); +const beforeRequests = metricValue(beforeBody, 'scopeweave_requests'); +const beforeS4xx = metricValue(beforeBody, 'scopeweave_s4xx'); + +for (let i = 0; i < 3; i += 1) { + assert.equal( + (await requestFrom('203.0.113.240')).status, + 200, + 'requests below the configured envelope limit remain allowed', + ); +} +assert.equal( + (await requestFrom('203.0.113.240')).status, + 429, + 'the fourth request is blocked by the envelope limiter', +); + +const afterBody = await (await requestFrom( + '198.51.100.241', + '/api/metrics?format=prometheus', +)).text(); +const afterRequests = metricValue(afterBody, 'scopeweave_requests'); +const afterS4xx = metricValue(afterBody, 'scopeweave_s4xx'); + +assert.equal( + afterRequests - beforeRequests, + 5, + 'Prometheus request totals include the prior metrics read, three allowed requests, and the blocked 429', +); +assert.equal( + afterS4xx - beforeS4xx, + 1, + 'Prometheus 4xx totals include the blocked 429 exactly once', +); + +console.log('✓ rate-limit Prometheus observability regression passed'); From d6463ec0a14485af6cde868be434b0226e35835a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 23:42:51 -0700 Subject: [PATCH 062/120] test(rate-limit): register Prometheus observability regression --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 259a4299..cb53cc42 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/invite-security.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/security-guard-abuse-controls.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/rate-limit-shared-boundary.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/invite-security.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/security-guard-abuse-controls.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/rate-limit-prometheus.test.mjs && node tests/api/rate-limit-shared-boundary.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/toast-accessibility.test.mjs", "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/application_routes.mjs --include=server/application_routes_core.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && npm run test:api", From d0f92c0481e37b6eb49b3a0419d61af003200d9c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 23:47:07 -0700 Subject: [PATCH 063/120] test(rate-limit): remove overflow timing dependency --- tests/api/ratelimit.test.mjs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/tests/api/ratelimit.test.mjs b/tests/api/ratelimit.test.mjs index 32a6a6d9..24f8575c 100644 --- a/tests/api/ratelimit.test.mjs +++ b/tests/api/ratelimit.test.mjs @@ -223,16 +223,17 @@ try { assert.equal((await viaProxy('127.0.0.1')).status, 429); // Distinct trusted-proxy client addresses must not grow in-memory limiter - // state without bound. Once the configured bucket cardinality is exhausted, - // previously unseen clients share one fail-closed overflow bucket instead of - // allocating attacker-controlled Map entries forever. - assert.equal((await viaProxy('192.0.2.201')).status, 200); - assert.equal((await viaProxy('192.0.2.202')).status, 200); - assert.equal((await viaProxy('192.0.2.203')).status, 200); - assert.equal( - (await viaProxy('192.0.2.204')).status, - 429, - 'new client identities share a bounded overflow bucket after capacity is reached' + // state without bound. Prime enough new identities that this assertion does + // not depend on a bucket created by an earlier phase still being live. Even + // if every earlier regular bucket has expired, at most seven new identities + // can receive regular buckets and the fourth overflow request must be blocked. + const overflowStatuses = []; + for (let i = 0; i < 11; i += 1) { + overflowStatuses.push((await viaProxy(`192.0.2.${201 + i}`)).status); + } + assert.ok( + overflowStatuses.includes(429), + 'within bucket capacity plus the per-window allowance, new identities converge on the bounded overflow bucket and are throttled' ); } finally { await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); From 1fcb863d2c0c903522aaa6d45bd4319e3c9183d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 00:46:54 -0700 Subject: [PATCH 064/120] test(rate-limit): require one shared limiter implementation --- tests/unit/coverage-script-contract.test.mjs | 21 ++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index ec5d1bc0..74eaebd8 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -8,6 +8,7 @@ const publicApp = readFileSync(new URL('../../server/app.mjs', import.meta.url), const routeImportSeam = readFileSync(new URL('../../server/app_routes.mjs', import.meta.url), 'utf8'); const applicationRoutes = readFileSync(new URL('../../server/application_routes.mjs', import.meta.url), 'utf8'); const applicationRoutesCore = readFileSync(new URL('../../server/application_routes_core.mjs', import.meta.url), 'utf8'); +const rateLimitModule = readFileSync(new URL('../../server/rate_limit.mjs', import.meta.url), 'utf8'); assert.match( serverWorkflow, @@ -49,6 +50,26 @@ assert.match( /await import\(['"]\.\/app_routes\.mjs['"]\)/, 'the public app loads the guarded route graph only after establishing the authoritative limiter envelope', ); +assert.match( + publicApp, + /import\s*\{\s*createRateLimitMiddleware,\s*createRateLimitObservability,?\s*\}\s*from\s*['"]\.\/rate_limit\.mjs['"]/, + 'the public and shared boundaries consume the same authoritative rate-limit implementation', +); +assert.doesNotMatch( + publicApp, + /function\s+(?:parseSafeIntegerSetting|canonicalIp|rateLimitBucket)\s*\(/, + 'the public envelope does not fork validation, client identity, or bucket semantics from rate_limit.mjs', +); +assert.match( + publicApp, + /app\.use\(\s*['"]\*['"]\s*,\s*createRateLimitMiddleware\(rateLimitObservability\)\s*\)/, + 'the public envelope installs the shared limiter before mounting the guarded route graph', +); +assert.match( + rateLimitModule, + /Math\.max\(1,\s*Math\.ceil\(\(bucket\.resetAt - now\) \/ 1000\)\)/, + 'the single limiter source preserves a positive Retry-After boundary', +); assert.match( publicApp, /app\.route\(\s*['"]\/['"]\s*,\s*routeApp\s*\)/, From 7f963b47b0fc02f10af647fc890a299a0e9e48f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 00:47:53 -0700 Subject: [PATCH 065/120] fix(rate-limit): share authoritative limiter implementation --- server/app.mjs | 227 ++++--------------------------------------------- 1 file changed, 17 insertions(+), 210 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 72a1c656..86994899 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1,85 +1,24 @@ // Security envelope for the ScopeWeave SaaS routes. // -// The route implementation remains in app_routes.mjs so the client-IP trust -// boundary stays small, reviewable, and independently testable. Rate limiting -// is authoritative only in this envelope: the legacy route-module limiter is -// initialized disabled so spoofable left-side forwarding data cannot create a -// second, contradictory client bucket behind a trusted proxy. +// The supported shared route graph also serves direct consumers, but the public +// Node entrypoint must remain authoritative for its own transport peer. Both +// boundaries therefore consume the same rate-limit module; the nested shared +// instance is initialized disabled here so one request cannot acquire two +// independent limiter buckets or observability deltas. import { Hono } from 'hono'; -import { isIP } from 'node:net'; - -/** - * Parse one explicit limiter setting without silently weakening protection. - * Empty or absent values use the documented fallback; configured values must - * be finite safe integers within the caller's accepted range. - */ -function parseSafeIntegerSetting(name, raw, fallback, minimum) { - if (raw === undefined || String(raw).trim() === '') return fallback; - const value = Number(raw); - if (!Number.isSafeInteger(value) || value < minimum) { - const range = minimum === 0 ? 'a non-negative' : 'a positive'; - throw new Error(`${name} must be ${range} safe integer`); - } - return value; -} - -/** - * Return one canonical IP spelling for trust comparisons and limiter keys. - * - * Equivalent IPv6 text (for example `0:0:0:0:0:0:0:1` and `::1`) must compare - * equal. IPv4-mapped IPv6 values are reduced to the underlying IPv4 identity, - * so operators can configure the proxy's actual IPv4 address regardless of - * whether a dual-stack listener exposes it as `::ffff:127.0.0.1` or an - * equivalent hexadecimal IPv6 spelling. Invalid values return null and can - * never become trusted identities. - */ -function canonicalIp(value) { - const candidate = String(value ?? '').trim(); - const family = isIP(candidate); - if (family === 0) return null; - if (family === 4) return candidate; - - // WHATWG URL host serialization provides a deterministic compressed IPv6 - // spelling for every address Node's net.isIP() accepts. - const normalized = new URL(`http://[${candidate}]/`).hostname.slice(1, -1).toLowerCase(); - const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(normalized); - if (!mapped) return normalized; - - const high = Number.parseInt(mapped[1], 16); - const low = Number.parseInt(mapped[2], 16); - return `${high >>> 8}.${high & 0xff}.${low >>> 8}.${low & 0xff}`; -} +import { + createRateLimitMiddleware, + createRateLimitObservability, +} from './rate_limit.mjs'; const configuredRateLimitMax = process.env.SCOPEWEAVE_RATE_LIMIT_MAX; -const RL_MAX = parseSafeIntegerSetting('SCOPEWEAVE_RATE_LIMIT_MAX', configuredRateLimitMax, 0, 0); -const RL_WINDOW_MS = parseSafeIntegerSetting( - 'SCOPEWEAVE_RATE_LIMIT_WINDOW_MS', - process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS, - 60000, - 1, -); -const RL_BUCKET_LIMIT = parseSafeIntegerSetting( - 'SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX', - process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX, - 10000, - 1, -); -const trustedProxyIps = new Set( - String(process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS || '') - .split(',') - .map(canonicalIp) - .filter(Boolean) -); -const rlBuckets = new Map(); -let overflowBucket; -let nextBucketSweepAt = 0; -let rateLimitedRequests = 0; -const quietEnvelopeLogs = String(process.env.SCOPEWEAVE_DB || '').includes(':memory:'); -// app_routes.mjs predates the transport-peer trust boundary and still contains -// its historical header-keyed limiter. Load it with that limiter disabled so -// only the security envelope below can consume rate-limit state. Restore the -// operator environment immediately after module initialization. +// app_routes.mjs is a compatibility re-export of application_routes.mjs. That +// boundary imports application_routes_core.mjs, whose historical header-keyed +// limiter reads SCOPEWEAVE_RATE_LIMIT_MAX only at module initialization. Load +// the nested graph with limiting disabled, then restore the operator value +// before constructing the public transport-peer-aware limiter below. Restoring +// the environment does not reactivate the already-initialized core limiter. let routeApp; try { process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '0'; @@ -89,140 +28,8 @@ try { else process.env.SCOPEWEAVE_RATE_LIMIT_MAX = configuredRateLimitMax; } -/** - * Resolve the network peer that directly connected to the Node server. - * In in-process tests or non-Node adapters where no socket exists, all such - * requests deliberately share the fail-closed `local` bucket. - */ -function connectionPeerIp(c) { - const peer = canonicalIp(c.env?.incoming?.socket?.remoteAddress); - return peer || 'local'; -} - -/** - * Resolve a rate-limit identity without trusting caller-controlled forwarding - * headers. Forwarded hops are considered only when the immediate network peer - * is explicitly trusted, then walked right-to-left until the first untrusted - * valid IP. Invalid forwarding evidence fails closed to the actual peer. - */ -function rateLimitClientIp(c) { - const peer = connectionPeerIp(c); - if (!trustedProxyIps.has(peer)) return peer; - - const forwarded = String(c.req.header('x-forwarded-for') || '') - .split(',') - .map((value) => value.trim()) - .filter(Boolean); - if (forwarded.length === 0) return peer; - - for (let i = forwarded.length - 1; i >= 0; i--) { - const hop = canonicalIp(forwarded[i]); - if (!hop) return peer; - if (!trustedProxyIps.has(hop)) return hop; - } - return peer; -} - -/** - * Return bounded fixed-window state for one client identity. - * - * The regular client map never grows beyond RL_BUCKET_LIMIT. Once that many - * distinct identities are live, unseen clients share a separate fail-closed - * overflow bucket. Expired regular buckets are swept at most once per window, - * keeping both memory and sweep CPU bounded under high-cardinality traffic. - */ -function rateLimitBucket(key, now) { - let bucket = rlBuckets.get(key); - if (bucket?.resetAt <= now) { - rlBuckets.delete(key); - bucket = undefined; - } - if (bucket) return bucket; - - if (rlBuckets.size >= RL_BUCKET_LIMIT && now >= nextBucketSweepAt) { - for (const [bucketKey, candidate] of rlBuckets) { - if (candidate.resetAt <= now) rlBuckets.delete(bucketKey); - } - nextBucketSweepAt = now + RL_WINDOW_MS; - } - - if (rlBuckets.size < RL_BUCKET_LIMIT) { - bucket = { count: 0, resetAt: now + RL_WINDOW_MS }; - rlBuckets.set(key, bucket); - return bucket; - } - - if (!overflowBucket || overflowBucket.resetAt <= now) { - overflowBucket = { count: 0, resetAt: now + RL_WINDOW_MS }; - } - return overflowBucket; -} - -/** - * Add security-envelope 429s to the route application's operational snapshot. - * - * Allowed requests are still counted by app_routes.mjs. Blocked requests never - * enter that app, so the envelope keeps only the missing 429 delta and folds it - * into both JSON and Prometheus metric representations at read time. This avoids - * double-counting normal traffic while preserving the existing metrics schema. - */ -async function includeRateLimitedRequestsInMetrics(c) { - if (c.req.path !== '/api/metrics' || c.res.status !== 200 || rateLimitedRequests === 0) return; - - const headers = new Headers(c.res.headers); - headers.delete('content-length'); - if (c.req.query('format') === 'prometheus') { - const text = await c.res.text(); - const adjusted = text.split('\n').map((line) => { - const match = /^(scopeweave_(?:requests|s4xx))\s+(-?\d+(?:\.\d+)?)$/.exec(line); - if (!match) return line; - return `${match[1]} ${Number(match[2]) + rateLimitedRequests}`; - }).join('\n'); - c.res = new Response(adjusted, { status: 200, headers }); - return; - } - - const snapshot = await c.res.json(); - if (typeof snapshot?.requests === 'number') snapshot.requests += rateLimitedRequests; - if (typeof snapshot?.s4xx === 'number') snapshot.s4xx += rateLimitedRequests; - c.res = new Response(JSON.stringify(snapshot), { status: 200, headers }); -} - -/** - * Record the otherwise short-circuited 429 without exposing client identity. - * Existing route logs never include bodies, credentials, or addresses; the - * envelope uses the same bounded request metadata for blocked traffic. - */ -function recordRateLimitedRequest(c, startedAt) { - rateLimitedRequests++; - if (!quietEnvelopeLogs) { - console.log(JSON.stringify({ - ts: new Date().toISOString(), - method: c.req.method, - path: c.req.path, - status: 429, - ms: Date.now() - startedAt, - })); - } -} - export const app = new Hono(); -if (RL_MAX > 0) { - app.use('*', async (c, next) => { - const startedAt = Date.now(); - const key = rateLimitClientIp(c); - const now = Date.now(); - const bucket = rateLimitBucket(key, now); - bucket.count++; - if (bucket.count > RL_MAX) { - const retry = Math.ceil((bucket.resetAt - now) / 1000); - recordRateLimitedRequest(c, startedAt); - return c.json({ error: 'rate limit exceeded' }, 429, { 'Retry-After': String(retry) }); - } - await next(); - await includeRateLimitedRequestsInMetrics(c); - }); -} - +const rateLimitObservability = createRateLimitObservability(); +app.use('*', createRateLimitMiddleware(rateLimitObservability)); app.route('/', routeApp); From 062fec8ca607539739ac5e15a8a65fd289250517 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 00:49:17 -0700 Subject: [PATCH 066/120] test(coverage): require shared limiter instrumentation --- tests/unit/coverage-script-contract.test.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 74eaebd8..9947c008 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -20,6 +20,11 @@ assert.match( /--include=server\/app\.mjs/, 'the public transport-peer security envelope remains owned-production coverage', ); +assert.match( + scripts['test:coverage'], + /--include=server\/rate_limit\.mjs/, + 'the shared authoritative rate-limit implementation remains owned-production coverage', +); assert.match( scripts['test:coverage'], /--include=server\/application_routes\.mjs/, From 865f540ce18456e79b31c6b1ee4c8be3a0b502a5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 00:50:01 -0700 Subject: [PATCH 067/120] fix(coverage): instrument shared rate limiter --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cb53cc42..6194cf51 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/invite-security.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/security-guard-abuse-controls.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/rate-limit-prometheus.test.mjs && node tests/api/rate-limit-shared-boundary.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/toast-accessibility.test.mjs", - "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/application_routes.mjs --include=server/application_routes_core.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.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/rate_limit.mjs --include=server/application_routes.mjs --include=server/application_routes_core.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", From f378e1d0e4a945511536ced88cd10e19be827450 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 00:52:28 -0700 Subject: [PATCH 068/120] test(ci): reject duplicate API execution --- tests/unit/coverage-script-contract.test.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 9947c008..52887733 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -15,6 +15,11 @@ assert.match( /npm run test:coverage/, 'server CI invokes the exact coverage producer', ); +assert.equal( + (serverWorkflow.match(/run:\s*npm run test:api/g) || []).length, + 0, + 'server CI does not execute the API suite separately when owned coverage already executes it', +); assert.match( scripts['test:coverage'], /--include=server\/app\.mjs/, From 0811a4fd92b97c12df0cfd89b778ebd7bec3ede7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 00:53:08 -0700 Subject: [PATCH 069/120] ci: avoid duplicate API suite execution --- .github/workflows/server-tests.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index afa0e9c8..e3f590e1 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -33,9 +33,7 @@ jobs: run: npm ci - name: Unit tests (EVM · CPM · baseline · workload) run: npm run test:unit - - name: API tests (auth · tenancy · RBAC · billing · webhooks · rate limit) - run: npm run test:api - - name: Owned production coverage evidence + - name: Owned production coverage evidence (unit · API under c8) run: npm run test:coverage - name: app.js stays eval-safe (no top-level import/export) run: node -e "new Function(require('fs').readFileSync('app.js','utf8')); console.log('eval-safe OK')" From 26ca25c4079175fbe727c663f2b56877e8314ac0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 02:52:27 -0700 Subject: [PATCH 070/120] test(security): reproduce forged OIDC identity token acceptance --- tests/api/oidc-production-boundary.test.mjs | 60 +++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/api/oidc-production-boundary.test.mjs b/tests/api/oidc-production-boundary.test.mjs index c99780d9..99d8a2e6 100644 --- a/tests/api/oidc-production-boundary.test.mjs +++ b/tests/api/oidc-production-boundary.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; process.env.SCOPEWEAVE_DB = ':memory:'; delete process.env.SCOPEWEAVE_DEV; @@ -51,4 +52,63 @@ assert.equal(response.status, 404, 'internal core mock authorize stays disabled assert.equal(response.headers.get('location'), null, 'internal core cannot mint a production callback code'); assert.deepEqual(await response.json(), { error: 'mock disabled' }); +const forgedIdentityRegression = String.raw` + import assert from 'node:assert/strict'; + + process.env.SCOPEWEAVE_DB = ':memory:'; + delete process.env.SCOPEWEAVE_DEV; + process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + process.env.STRIPE_SECRET_KEY = 'sk_test_scopeweave_oidc_signature'; + process.env.STRIPE_PRICE_ID = 'price_scopeweave_oidc_signature'; + process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_oidc_signature_secret'; + process.env.OIDC_ISSUER = 'https://issuer.example'; + process.env.OIDC_CLIENT_ID = 'scopeweave-client'; + process.env.OIDC_CLIENT_SECRET = 'scopeweave-secret'; + process.env.OIDC_REDIRECT_URI = 'https://scopeweave.example/api/auth/oidc/callback'; + + const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); + const forgedIdToken = [ + encode({ alg: 'none', typ: 'JWT' }), + encode({ + iss: process.env.OIDC_ISSUER, + aud: process.env.OIDC_CLIENT_ID, + exp: Math.floor(Date.now() / 1000) + 300, + email: 'attacker-chosen@example.com', + }), + '', + ].join('.'); + + globalThis.fetch = async (url) => { + assert.equal(String(url), 'https://issuer.example/token', 'callback exchanges the authorization code only with the configured issuer'); + return new Response(JSON.stringify({ id_token: forgedIdToken }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + + const { app: configuredRoutes } = await import('./server/application_routes.mjs?oidc-forged-token-regression=1'); + const start = await configuredRoutes.request('https://scopeweave.example/api/auth/oidc/start'); + assert.equal(start.status, 302, 'configured OIDC starts the authorization-code flow'); + const authorization = new URL(start.headers.get('location')); + const state = authorization.searchParams.get('state'); + assert.ok(state, 'authorization request carries a server-generated state'); + + const callback = await configuredRoutes.request( + 'https://scopeweave.example/api/auth/oidc/callback?state=' + encodeURIComponent(state) + '&code=attacker-code', + ); + assert.equal(callback.status, 400, 'an unsigned or otherwise unverified identity token must never mint a ScopeWeave session'); + assert.equal(callback.headers.get('location'), null, 'rejected identity tokens never return an application session fragment'); +`; + +const forgedIdentityResult = spawnSync( + process.execPath, + ['--input-type=module', '--eval', forgedIdentityRegression], + { cwd: process.cwd(), encoding: 'utf8' }, +); +assert.equal( + forgedIdentityResult.status, + 0, + `configured production OIDC must authenticate the IdP before trusting identity claims\nstdout:\n${forgedIdentityResult.stdout}\nstderr:\n${forgedIdentityResult.stderr}`, +); + console.log('OIDC production fail-closed regression passed'); From a26ff227f596a06685227a50703264dd8cad457b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 02:54:57 -0700 Subject: [PATCH 071/120] fix(security): verify production OIDC identities before session minting --- server/application_routes.mjs | 191 ++++++++++++++++++++++++++++++++-- 1 file changed, 185 insertions(+), 6 deletions(-) diff --git a/server/application_routes.mjs b/server/application_routes.mjs index d36e9f4b..68539279 100644 --- a/server/application_routes.mjs +++ b/server/application_routes.mjs @@ -1,6 +1,17 @@ import { Hono } from 'hono'; -import { db } from './db.mjs'; -import { hashApiToken, verifyToken } from './auth.mjs'; +import { + createHash, + createPublicKey, + randomBytes, + verify as verifySignature, +} from 'node:crypto'; +import { db, rowid } from './db.mjs'; +import { + hashApiToken, + hashPassword, + signToken, + verifyToken, +} from './auth.mjs'; import { createRateLimitMiddleware, createRateLimitObservability, @@ -26,6 +37,12 @@ const GUARD_ACCOUNTING_METHOD = Symbol.for('scopeweave.guard_accounting.original const MEMBERS_PATH = '/api/orgs/:id/members'; const INVITE_ACCEPT_PATH = '/api/invites/:token/accept'; const OIDC_ROUTE_PREFIX = '/api/auth/oidc/*'; +const OIDC_ISSUER = String(process.env.OIDC_ISSUER || '').replace(/\/$/, ''); +const OIDC_CLIENT_ID = String(process.env.OIDC_CLIENT_ID || ''); +const OIDC_CLIENT_SECRET = String(process.env.OIDC_CLIENT_SECRET || ''); +const OIDC_REDIRECT_URI = String(process.env.OIDC_REDIRECT_URI || ''); +const productionOidcConfigured = Boolean(OIDC_ISSUER && OIDC_CLIENT_ID && OIDC_CLIENT_SECRET); +const productionOidcStates = new Map(); function normalizeIdentityEmail(value) { return String(value ?? '').trim().toLowerCase(); @@ -114,27 +131,189 @@ async function redactPendingInviteTokens(c, next) { } async function failClosedWhenOidcIsUnconfigured(c, next) { - if (process.env.SCOPEWEAVE_DEV !== '1' && !process.env.OIDC_ISSUER) { + if (process.env.SCOPEWEAVE_DEV !== '1' && !productionOidcConfigured) { return guardRejectionThroughCoreAbuseControls(c, { error: 'sso not configured' }); } return next(); } +function productionOidcRedirectUri(c) { + return OIDC_REDIRECT_URI || `${new URL(c.req.url).origin}/api/auth/oidc/callback`; +} + +function parseJwtJson(segment) { + return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8')); +} + +async function verifyProductionOidcIdentity(idToken, expectedNonce) { + const parts = String(idToken || '').split('.'); + if (parts.length !== 3) throw new Error('invalid_oidc_token_shape'); + + const header = parseJwtJson(parts[0]); + const claims = parseJwtJson(parts[1]); + if (header.alg !== 'RS256' || typeof header.kid !== 'string' || !header.kid) { + throw new Error('unsupported_oidc_signature'); + } + + const discoveryResponse = await fetch(`${OIDC_ISSUER}/.well-known/openid-configuration`, { + signal: AbortSignal.timeout(5000), + }); + if (!discoveryResponse.ok) throw new Error('oidc_discovery_failed'); + const discovery = await discoveryResponse.json(); + if (discovery?.issuer !== OIDC_ISSUER || typeof discovery?.jwks_uri !== 'string') { + throw new Error('oidc_discovery_mismatch'); + } + + const jwksUrl = new URL(discovery.jwks_uri); + if (jwksUrl.protocol !== 'https:') throw new Error('oidc_jwks_requires_https'); + const jwksResponse = await fetch(jwksUrl, { signal: AbortSignal.timeout(5000) }); + if (!jwksResponse.ok) throw new Error('oidc_jwks_failed'); + const jwks = await jwksResponse.json(); + const jwk = Array.isArray(jwks?.keys) + ? jwks.keys.find((candidate) => ( + candidate?.kid === header.kid + && candidate?.kty === 'RSA' + && (!candidate.use || candidate.use === 'sig') + && (!candidate.alg || candidate.alg === 'RS256') + )) + : null; + if (!jwk) throw new Error('oidc_signing_key_not_found'); + + const publicKey = createPublicKey({ key: jwk, format: 'jwk' }); + const signed = Buffer.from(`${parts[0]}.${parts[1]}`); + const signature = Buffer.from(parts[2], 'base64url'); + if (!verifySignature('RSA-SHA256', signed, publicKey, signature)) { + throw new Error('oidc_signature_invalid'); + } + + const now = Math.floor(Date.now() / 1000); + const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; + const hasExpectedAudience = audiences.includes(OIDC_CLIENT_ID); + const authorizedPartyMatches = audiences.length <= 1 || claims.azp === OIDC_CLIENT_ID; + const temporalClaimsValid = Number.isFinite(claims.exp) + && claims.exp > now + && (!Number.isFinite(claims.nbf) || claims.nbf <= now + 60) + && (!Number.isFinite(claims.iat) || claims.iat <= now + 60); + const identityClaimsValid = claims.iss === OIDC_ISSUER + && hasExpectedAudience + && authorizedPartyMatches + && temporalClaimsValid + && claims.nonce === expectedNonce + && typeof claims.sub === 'string' + && claims.sub.length > 0 + && claims.email_verified === true + && typeof claims.email === 'string' + && normalizeIdentityEmail(claims.email).length > 0; + if (!identityClaimsValid) throw new Error('oidc_claims_invalid'); + + return normalizeIdentityEmail(claims.email); +} + +function upsertProductionSsoUser(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 orgId = 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(orgId, uid, 'owner'); + db.exec('COMMIT'); + user = { id: uid, email, token_version: 0 }; + } catch (error) { + db.exec('ROLLBACK'); + throw error; + } + return user; +} + +async function productionOidcStart(c, next) { + if (process.env.SCOPEWEAVE_DEV === '1' || !productionOidcConfigured) return next(); + + let authorizationUrl; + try { + authorizationUrl = new URL(`${OIDC_ISSUER}/authorize`); + if (authorizationUrl.protocol !== 'https:') throw new Error('oidc_issuer_requires_https'); + } catch { + return c.json({ error: 'sso not configured' }, 404, { 'Cache-Control': 'no-store' }); + } + + const state = randomBytes(16).toString('hex'); + const verifier = randomBytes(32).toString('base64url'); + const nonce = randomBytes(24).toString('base64url'); + const challenge = createHash('sha256').update(verifier).digest('base64url'); + productionOidcStates.set(state, { verifier, nonce, exp: Date.now() + 5 * 60 * 1000 }); + + authorizationUrl.searchParams.set('client_id', OIDC_CLIENT_ID); + authorizationUrl.searchParams.set('redirect_uri', productionOidcRedirectUri(c)); + authorizationUrl.searchParams.set('response_type', 'code'); + authorizationUrl.searchParams.set('scope', 'openid email profile'); + authorizationUrl.searchParams.set('state', state); + authorizationUrl.searchParams.set('nonce', nonce); + authorizationUrl.searchParams.set('code_challenge', challenge); + authorizationUrl.searchParams.set('code_challenge_method', 'S256'); + return c.redirect(authorizationUrl.toString()); +} + +async function productionOidcCallback(c, next) { + if (process.env.SCOPEWEAVE_DEV === '1' || !productionOidcConfigured) return next(); + + const state = c.req.query('state'); + const code = c.req.query('code'); + const pending = productionOidcStates.get(state); + if (!pending || pending.exp < Date.now() || !code) { + productionOidcStates.delete(state); + return c.json({ error: 'invalid or expired state' }, 400, { 'Cache-Control': 'no-store' }); + } + productionOidcStates.delete(state); + + try { + const tokenResponse = await fetch(`${OIDC_ISSUER}/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: productionOidcRedirectUri(c), + client_id: OIDC_CLIENT_ID, + client_secret: OIDC_CLIENT_SECRET, + code_verifier: pending.verifier, + }), + signal: AbortSignal.timeout(5000), + }); + if (!tokenResponse.ok) throw new Error('oidc_token_exchange_failed'); + const tokens = await tokenResponse.json(); + const email = await verifyProductionOidcIdentity(tokens?.id_token, pending.nonce); + const user = upsertProductionSsoUser(email); + const token = signToken({ sub: user.id, email: user.email, tv: user.token_version || 0 }); + return c.redirect(`/#token=${token}`); + } catch { + return c.json({ error: 'invalid identity token' }, 400, { 'Cache-Control': 'no-store' }); + } +} + /** * Shared ScopeWeave application and transport-security boundary. * * Every supported consumer, including the public Node server and tests that * mount this route graph directly, enters the same trusted-proxy-aware bounded * rate limiter before authentication hints, invitation lookups, OIDC guards, or - * the internal implementation graph. Guard rejections are still accounted by - * the core request logger/counters, while limiter rejections use the matching - * bounded observability hooks without exposing client identity. + * the internal implementation graph. Production OIDC authorization-code + * callbacks terminate here only after RS256/JWKS signature validation and + * issuer, audience, expiry, nonce, subject, and verified-email checks. Guard + * rejections are still accounted by the core request logger/counters, while + * limiter rejections use the matching bounded observability hooks without + * exposing client identity. */ export const app = new Hono(); const rateLimitObservability = createRateLimitObservability(); app.use('*', createRateLimitMiddleware(rateLimitObservability)); app.use(OIDC_ROUTE_PREFIX, failClosedWhenOidcIsUnconfigured); +app.get('/api/auth/oidc/start', productionOidcStart); +app.get('/api/auth/oidc/callback', productionOidcCallback); app.use(INVITE_ACCEPT_PATH, bindInviteToAuthenticatedIdentity); app.use(MEMBERS_PATH, redactPendingInviteTokens); app.route('/', coreRoutes); From c673acc5324ab91d6d860862de3d99b3db13644c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 02:55:50 -0700 Subject: [PATCH 072/120] test(security): prove signed OIDC identity validation contract --- tests/api/oidc-production-boundary.test.mjs | 150 ++++++++++++++++---- 1 file changed, 125 insertions(+), 25 deletions(-) diff --git a/tests/api/oidc-production-boundary.test.mjs b/tests/api/oidc-production-boundary.test.mjs index 99d8a2e6..86b689a5 100644 --- a/tests/api/oidc-production-boundary.test.mjs +++ b/tests/api/oidc-production-boundary.test.mjs @@ -52,8 +52,9 @@ assert.equal(response.status, 404, 'internal core mock authorize stays disabled assert.equal(response.headers.get('location'), null, 'internal core cannot mint a production callback code'); assert.deepEqual(await response.json(), { error: 'mock disabled' }); -const forgedIdentityRegression = String.raw` +const productionIdentityRegression = String.raw` import assert from 'node:assert/strict'; + import { generateKeyPairSync, sign } from 'node:crypto'; process.env.SCOPEWEAVE_DB = ':memory:'; delete process.env.SCOPEWEAVE_DEV; @@ -67,48 +68,147 @@ const forgedIdentityRegression = String.raw` process.env.OIDC_REDIRECT_URI = 'https://scopeweave.example/api/auth/oidc/callback'; const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); - const forgedIdToken = [ + const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const publicJwk = { + ...publicKey.export({ format: 'jwk' }), + kid: 'issuer-signing-key', + use: 'sig', + alg: 'RS256', + }; + let issuedIdToken = ''; + + const signIdToken = (claims, header = { alg: 'RS256', typ: 'JWT', kid: publicJwk.kid }) => { + const protectedHeader = encode(header); + const payload = encode(claims); + const signingInput = protectedHeader + '.' + payload; + const signature = sign('RSA-SHA256', Buffer.from(signingInput), privateKey).toString('base64url'); + return signingInput + '.' + signature; + }; + + globalThis.fetch = async (url) => { + const target = String(url); + if (target === 'https://issuer.example/token') { + return new Response(JSON.stringify({ id_token: issuedIdToken }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + if (target === 'https://issuer.example/.well-known/openid-configuration') { + return new Response(JSON.stringify({ + issuer: process.env.OIDC_ISSUER, + jwks_uri: 'https://issuer.example/jwks', + }), { status: 200, headers: { 'content-type': 'application/json' } }); + } + if (target === 'https://issuer.example/jwks') { + return new Response(JSON.stringify({ keys: [publicJwk] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + throw new Error('unexpected OIDC fetch: ' + target); + }; + + const { app: configuredRoutes } = await import('./server/application_routes.mjs?oidc-production-token-regression=1'); + const begin = async () => { + const start = await configuredRoutes.request('https://scopeweave.example/api/auth/oidc/start'); + assert.equal(start.status, 302, 'configured OIDC starts the authorization-code flow'); + const authorization = new URL(start.headers.get('location')); + assert.equal(authorization.origin, 'https://issuer.example'); + assert.equal(authorization.pathname, '/authorize'); + assert.equal(authorization.searchParams.get('client_id'), process.env.OIDC_CLIENT_ID); + assert.equal(authorization.searchParams.get('response_type'), 'code'); + assert.equal(authorization.searchParams.get('code_challenge_method'), 'S256'); + const state = authorization.searchParams.get('state'); + const nonce = authorization.searchParams.get('nonce'); + assert.ok(state, 'authorization request carries a server-generated state'); + assert.ok(nonce, 'authorization request binds the returned ID token with a nonce'); + return { state, nonce }; + }; + + const callback = async (state, code) => configuredRoutes.request( + 'https://scopeweave.example/api/auth/oidc/callback?state=' + encodeURIComponent(state) + '&code=' + encodeURIComponent(code), + ); + + const forged = await begin(); + issuedIdToken = [ encode({ alg: 'none', typ: 'JWT' }), encode({ iss: process.env.OIDC_ISSUER, aud: process.env.OIDC_CLIENT_ID, exp: Math.floor(Date.now() / 1000) + 300, + nonce: forged.nonce, + sub: 'attacker-subject', + email_verified: true, email: 'attacker-chosen@example.com', }), '', ].join('.'); + let result = await callback(forged.state, 'attacker-code'); + assert.equal(result.status, 400, 'an unsigned identity token must never mint a ScopeWeave session'); + assert.equal(result.headers.get('location'), null, 'rejected identity tokens never return an application session fragment'); - globalThis.fetch = async (url) => { - assert.equal(String(url), 'https://issuer.example/token', 'callback exchanges the authorization code only with the configured issuer'); - return new Response(JSON.stringify({ id_token: forgedIdToken }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }); - }; - - const { app: configuredRoutes } = await import('./server/application_routes.mjs?oidc-forged-token-regression=1'); - const start = await configuredRoutes.request('https://scopeweave.example/api/auth/oidc/start'); - assert.equal(start.status, 302, 'configured OIDC starts the authorization-code flow'); - const authorization = new URL(start.headers.get('location')); - const state = authorization.searchParams.get('state'); - assert.ok(state, 'authorization request carries a server-generated state'); - - const callback = await configuredRoutes.request( - 'https://scopeweave.example/api/auth/oidc/callback?state=' + encodeURIComponent(state) + '&code=attacker-code', + result = await configuredRoutes.request( + 'https://scopeweave.example/api/auth/oidc/callback?state=unknown&code=attacker-code', ); - assert.equal(callback.status, 400, 'an unsigned or otherwise unverified identity token must never mint a ScopeWeave session'); - assert.equal(callback.headers.get('location'), null, 'rejected identity tokens never return an application session fragment'); + assert.equal(result.status, 400, 'unknown authorization state fails closed before token exchange'); + + const valid = await begin(); + const now = Math.floor(Date.now() / 1000); + issuedIdToken = signIdToken({ + iss: process.env.OIDC_ISSUER, + aud: process.env.OIDC_CLIENT_ID, + exp: now + 300, + iat: now, + nonce: valid.nonce, + sub: 'verified-subject', + email_verified: true, + email: 'verified@example.com', + }); + result = await callback(valid.state, 'valid-code'); + assert.equal(result.status, 302, 'a valid issuer-signed ID token completes the production OIDC flow'); + assert.match(result.headers.get('location') || '', /^\/#token=/, 'successful OIDC returns only the ScopeWeave session in a URL fragment'); + + const replay = await callback(valid.state, 'replayed-code'); + assert.equal(replay.status, 400, 'OIDC state is single-use after a successful callback'); + + const wrongAudience = await begin(); + issuedIdToken = signIdToken({ + iss: process.env.OIDC_ISSUER, + aud: 'different-client', + exp: now + 300, + iat: now, + nonce: wrongAudience.nonce, + sub: 'verified-subject', + email_verified: true, + email: 'verified@example.com', + }); + result = await callback(wrongAudience.state, 'wrong-audience-code'); + assert.equal(result.status, 400, 'issuer-signed tokens for another audience are rejected'); + + const unverifiedEmail = await begin(); + issuedIdToken = signIdToken({ + iss: process.env.OIDC_ISSUER, + aud: process.env.OIDC_CLIENT_ID, + exp: now + 300, + iat: now, + nonce: unverifiedEmail.nonce, + sub: 'verified-subject', + email_verified: false, + email: 'victim@example.com', + }); + result = await callback(unverifiedEmail.state, 'unverified-email-code'); + assert.equal(result.status, 400, 'unverified email claims cannot link or create a ScopeWeave account'); `; -const forgedIdentityResult = spawnSync( +const productionIdentityResult = spawnSync( process.execPath, - ['--input-type=module', '--eval', forgedIdentityRegression], + ['--input-type=module', '--eval', productionIdentityRegression], { cwd: process.cwd(), encoding: 'utf8' }, ); assert.equal( - forgedIdentityResult.status, + productionIdentityResult.status, 0, - `configured production OIDC must authenticate the IdP before trusting identity claims\nstdout:\n${forgedIdentityResult.stdout}\nstderr:\n${forgedIdentityResult.stderr}`, + `configured production OIDC must authenticate the IdP before trusting identity claims\nstdout:\n${productionIdentityResult.stdout}\nstderr:\n${productionIdentityResult.stderr}`, ); console.log('OIDC production fail-closed regression passed'); From 1ada93f3addaeb3210cb1bb97cb5089066d81716 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 04:40:06 -0700 Subject: [PATCH 073/120] test(ci): require exact PR head checkout --- tests/unit/coverage-script-contract.test.mjs | 29 ++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 52887733..bb4e4bb9 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -20,6 +20,35 @@ assert.equal( 0, 'server CI does not execute the API suite separately when owned coverage already executes it', ); +const exactCheckoutRepository = + "repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}"; +const exactCheckoutRef = + "ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}"; +assert.equal( + serverWorkflow.split(exactCheckoutRepository).length - 1, + 2, + 'both server CI jobs bind checkout to the submitted repository on pull requests', +); +assert.equal( + serverWorkflow.split(exactCheckoutRef).length - 1, + 2, + 'both server CI jobs bind checkout to the exact submitted head SHA on pull requests', +); +assert.equal( + (serverWorkflow.match(/- name: Verify exact checkout revision/g) || []).length, + 2, + 'both server CI jobs attest the actual checkout revision before executing repository code', +); +assert.equal( + (serverWorkflow.match(/actual_head_sha="\$\(git rev-parse HEAD\)"/g) || []).length, + 2, + 'both server CI jobs measure the actual checked-out revision', +); +assert.equal( + (serverWorkflow.match(/test "\$actual_head_sha" = "\$EXPECTED_HEAD_SHA"/g) || []).length, + 2, + 'both server CI jobs fail closed when checkout identity differs from the expected exact head', +); assert.match( scripts['test:coverage'], /--include=server\/app\.mjs/, From f9e87d0c2348c5aacf95a32f3257486f0d10642f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 04:41:42 -0700 Subject: [PATCH 074/120] ci: bind server tests to exact PR head --- .github/workflows/server-tests.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index e3f590e1..494f403f 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -25,6 +25,15 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false + repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }} + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + - name: Verify exact checkout revision + env: + EXPECTED_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + run: | + actual_head_sha="$(git rev-parse HEAD)" + printf 'expected_head_sha=%s\nactual_head_sha=%s\n' "$EXPECTED_HEAD_SHA" "$actual_head_sha" + test "$actual_head_sha" = "$EXPECTED_HEAD_SHA" - name: Setup Node 22.13 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: @@ -45,6 +54,15 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false + repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }} + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + - name: Verify exact checkout revision + env: + EXPECTED_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + run: | + actual_head_sha="$(git rev-parse HEAD)" + printf 'expected_head_sha=%s\nactual_head_sha=%s\n' "$EXPECTED_HEAD_SHA" "$actual_head_sha" + test "$actual_head_sha" = "$EXPECTED_HEAD_SHA" - name: Setup Node 22.13 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: From 1533e07b7f2d6aec96fcfbcc0252998adafc29e4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 04:45:15 -0700 Subject: [PATCH 075/120] test(ci): require exact dependency review revisions --- tests/unit/coverage-script-contract.test.mjs | 51 ++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index bb4e4bb9..10743e27 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs'; const packageJson = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')); const scripts = packageJson.scripts || {}; const serverWorkflow = readFileSync(new URL('../../.github/workflows/server-tests.yml', import.meta.url), 'utf8'); +const dependencyWorkflow = readFileSync(new URL('../../.github/workflows/dependency-review.yml', import.meta.url), 'utf8'); const publicApp = readFileSync(new URL('../../server/app.mjs', import.meta.url), 'utf8'); const routeImportSeam = readFileSync(new URL('../../server/app_routes.mjs', import.meta.url), 'utf8'); const applicationRoutes = readFileSync(new URL('../../server/application_routes.mjs', import.meta.url), 'utf8'); @@ -49,6 +50,56 @@ assert.equal( 2, 'both server CI jobs fail closed when checkout identity differs from the expected exact head', ); +assert.equal( + dependencyWorkflow.split(exactCheckoutRepository).length - 1, + 1, + 'dependency review binds checkout to the submitted repository on pull requests', +); +assert.equal( + dependencyWorkflow.split(exactCheckoutRef).length - 1, + 1, + 'dependency review binds checkout to the exact submitted head SHA on pull requests', +); +assert.equal( + (dependencyWorkflow.match(/- name: Verify exact checkout revision/g) || []).length, + 1, + 'dependency review attests the actual checkout revision before executing the gate', +); +assert.match( + dependencyWorkflow, + /BASE_REF: \$\{\{ github\.event\.pull_request\.base\.ref \}\}/, + 'dependency review starts from the named base branch rather than stale event base SHA evidence', +); +assert.doesNotMatch( + dependencyWorkflow, + /BASE_SHA: \$\{\{ github\.event\.pull_request\.base\.sha \}\}/, + 'dependency review does not trust the historical event base SHA as the live comparison base', +); +assert.match( + dependencyWorkflow, + /branches\/\$\{base_ref_encoded\}/, + 'dependency review independently resolves the current base branch tip through the GitHub API', +); +assert.match( + dependencyWorkflow, + /echo "base_sha=\$BASE_SHA" >>"\$GITHUB_OUTPUT"/, + 'dependency review publishes the independently resolved live base SHA for the action comparison', +); +assert.match( + dependencyWorkflow, + /base-ref: \$\{\{ steps\.dependency_review_support\.outputs\.base_sha \}\}/, + 'dependency-review-action receives the independently resolved live base SHA explicitly', +); +assert.match( + dependencyWorkflow, + /head-ref: \$\{\{ github\.event\.pull_request\.head\.sha \}\}/, + 'dependency-review-action receives the exact contributor head SHA explicitly', +); +assert.doesNotMatch( + dependencyWorkflow, + /Dependency review is unavailable[\s\S]{0,300}supported=false[\s\S]{0,100}exit 0/, + 'dependency review never converts unavailable pull-request comparison evidence into a green skip', +); assert.match( scripts['test:coverage'], /--include=server\/app\.mjs/, From 204cbda35d85c33dca4818280122ba3b64fd5997 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 04:48:11 -0700 Subject: [PATCH 076/120] ci: bind dependency review to live exact revisions --- .github/workflows/dependency-review.yml | 58 +++++++++++++++++++------ 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 6795d40e..087ec473 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -20,12 +20,24 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false + repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }} + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + - name: Verify exact checkout revision + env: + EXPECTED_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + shell: bash + run: | + set -euo pipefail + actual_head_sha="$(git rev-parse HEAD)" + printf 'expected_head_sha=%s\nactual_head_sha=%s\n' "$EXPECTED_HEAD_SHA" "$actual_head_sha" + test "$actual_head_sha" = "$EXPECTED_HEAD_SHA" - name: Check dependency review support id: dependency_review_support env: GH_TOKEN: ${{ github.token }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} + BASE_REF: ${{ github.event.pull_request.base.ref }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} REPOSITORY: ${{ github.repository }} shell: bash @@ -40,33 +52,51 @@ jobs: api_url="${GITHUB_API_URL:-https://api.github.com}" response_file="$(mktemp)" - status="$( - curl -fsS -o "$response_file" -w '%{http_code}' \ + base_response_file="$(mktemp)" + trap 'rm -f "$response_file" "$base_response_file"' EXIT + + base_ref_encoded="$(python3 -c 'import os, urllib.parse; print(urllib.parse.quote(os.environ["BASE_REF"], safe=""))')" + base_status="$( + curl -sS -o "$base_response_file" -w '%{http_code}' \ -H "Accept: application/vnd.github+json" \ -H "Authorization: Bearer ${GH_TOKEN}" \ -H "X-GitHub-Api-Version: 2022-11-28" \ - "${api_url}/repos/${REPOSITORY}/dependency-graph/compare/${BASE_SHA}...${HEAD_SHA}" \ + "${api_url}/repos/${REPOSITORY}/branches/${base_ref_encoded}" \ || true )" - if [ "$status" = "200" ]; then - echo "supported=true" >>"$GITHUB_OUTPUT" - exit 0 + if [ "$base_status" != "200" ]; then + echo "::error::Unable to resolve live base branch ${BASE_REF}; GitHub API returned HTTP ${base_status}." + exit 1 fi - if [ "$status" = "403" ] || [ "$status" = "404" ]; then - echo "::warning::Dependency review is unavailable for ${REPOSITORY}; skipping dependency-review hard gate." - echo "supported=false" >>"$GITHUB_OUTPUT" - exit 0 + BASE_SHA="$(jq -er '.commit.sha | select(test("^[0-9a-f]{40}$"))' "$base_response_file")" || { + echo "::error::Live base branch response did not contain a valid commit SHA." + exit 1 + } + echo "base_sha=$BASE_SHA" >>"$GITHUB_OUTPUT" + + status="$( + curl -sS -o "$response_file" -w '%{http_code}' \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "${api_url}/repos/${REPOSITORY}/dependency-graph/compare/${BASE_SHA}...${HEAD_SHA}" \ + || true + )" + + if [ "$status" != "200" ]; then + echo "::error::Dependency review support check failed with HTTP ${status}." + exit 1 fi - echo "::error::Dependency review support check failed with HTTP ${status}." - cat "$response_file" - exit 1 + echo "supported=true" >>"$GITHUB_OUTPUT" - name: Dependency review if: steps.dependency_review_support.outputs.supported == 'true' uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 with: + base-ref: ${{ steps.dependency_review_support.outputs.base_sha }} + head-ref: ${{ github.event.pull_request.head.sha }} fail-on-severity: moderate comment-summary-in-pr: on-failure From 4c8b682032e74cea67b9a9627304bc8d16727eba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 05:50:56 -0700 Subject: [PATCH 077/120] test(ci): preserve slashed dependency-review bases --- tests/unit/coverage-script-contract.test.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 10743e27..7e9ec421 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -75,6 +75,11 @@ assert.doesNotMatch( /BASE_SHA: \$\{\{ github\.event\.pull_request\.base\.sha \}\}/, 'dependency review does not trust the historical event base SHA as the live comparison base', ); +assert.match( + dependencyWorkflow, + /urllib\.parse\.quote\(os\.environ\["BASE_REF"\], safe="\/"\)/, + 'dependency review preserves branch-name slashes while encoding other unsafe URL characters', +); assert.match( dependencyWorkflow, /branches\/\$\{base_ref_encoded\}/, From fe01284ca64e22bab8b54cd24c4315670cee9f61 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 05:51:44 -0700 Subject: [PATCH 078/120] fix(ci): support slashed live base refs --- .github/workflows/dependency-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 087ec473..28cca6d5 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -55,7 +55,7 @@ jobs: base_response_file="$(mktemp)" trap 'rm -f "$response_file" "$base_response_file"' EXIT - base_ref_encoded="$(python3 -c 'import os, urllib.parse; print(urllib.parse.quote(os.environ["BASE_REF"], safe=""))')" + base_ref_encoded="$(python3 -c 'import os, urllib.parse; print(urllib.parse.quote(os.environ["BASE_REF"], safe="/"))')" base_status="$( curl -sS -o "$base_response_file" -w '%{http_code}' \ -H "Accept: application/vnd.github+json" \ From 5a7393e2f3ee1ebee233c0175785754a50d02cf9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 05:53:36 -0700 Subject: [PATCH 079/120] test(security): bound abandoned OIDC authorization state --- tests/api/oidc-production-boundary.test.mjs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/api/oidc-production-boundary.test.mjs b/tests/api/oidc-production-boundary.test.mjs index 86b689a5..a7f7d530 100644 --- a/tests/api/oidc-production-boundary.test.mjs +++ b/tests/api/oidc-production-boundary.test.mjs @@ -129,6 +129,23 @@ const productionIdentityRegression = String.raw` 'https://scopeweave.example/api/auth/oidc/callback?state=' + encodeURIComponent(state) + '&code=' + encodeURIComponent(code), ); + const realDateNow = Date.now; + let controlledNow = realDateNow(); + Date.now = () => controlledNow; + const abandonedStates = []; + for (let index = 0; index < 1024; index += 1) { + abandonedStates.push(await begin()); + } + let result = await configuredRoutes.request('https://scopeweave.example/api/auth/oidc/start'); + assert.equal(result.status, 503, 'OIDC authorization state storage fails closed at its bounded capacity'); + assert.equal(result.headers.get('cache-control'), 'no-store', 'OIDC saturation response is never cached'); + assert.deepEqual(await result.json(), { error: 'sso temporarily unavailable' }); + controlledNow += 5 * 60 * 1000 + 1; + const afterExpiry = await begin(); + Date.now = realDateNow; + result = await callback(afterExpiry.state, 'discard-expired-capacity-probe'); + assert.equal(result.status, 400, 'a capacity probe can be consumed without minting a session'); + const forged = await begin(); issuedIdToken = [ encode({ alg: 'none', typ: 'JWT' }), @@ -143,7 +160,7 @@ const productionIdentityRegression = String.raw` }), '', ].join('.'); - let result = await callback(forged.state, 'attacker-code'); + result = await callback(forged.state, 'attacker-code'); assert.equal(result.status, 400, 'an unsigned identity token must never mint a ScopeWeave session'); assert.equal(result.headers.get('location'), null, 'rejected identity tokens never return an application session fragment'); From 55951ea6d2cf35fa2385f4011d94cc6e2830099d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 05:55:16 -0700 Subject: [PATCH 080/120] fix(security): bound transient auth state storage --- server/auth.mjs | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/server/auth.mjs b/server/auth.mjs index d8e147be..4b4743f8 100644 --- a/server/auth.mjs +++ b/server/auth.mjs @@ -6,6 +6,44 @@ import { db } from './db.mjs'; /** Maximum lifetime for a general ScopeWeave session token, in seconds. */ const MAX_SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; +/** Maximum in-process pending OIDC authorization states per route graph. */ +const MAX_TRANSIENT_AUTH_STATES = 1024; + +/** + * Create a bounded, expiring store for one-time authentication state. + * + * Expired entries are swept before every reservation and lookup. Reservations + * fail closed once the live-state ceiling is reached instead of allowing an + * unauthenticated authorization-start flood to grow process memory without + * bound. `take` always consumes a matching state so callbacks remain single-use. + * + * @returns {{reserve:(key:string,value:{exp:number})=>boolean,take:(key:string)=>({exp:number}|null)}} + * Bounded one-time state operations for OIDC authorization flows. + */ +export function createExpiringAuthStateStore() { + const entries = new Map(); + + const pruneExpired = (now) => { + for (const [key, pending] of entries) { + if (pending.exp < now) entries.delete(key); + } + }; + + return { + reserve(key, value) { + pruneExpired(Date.now()); + if (entries.size >= MAX_TRANSIENT_AUTH_STATES) return false; + entries.set(key, value); + return true; + }, + take(key) { + pruneExpired(Date.now()); + const value = entries.get(key) ?? null; + entries.delete(key); + return value; + }, + }; +} /** * Generate a one-time-visible ScopeWeave personal access token. From 789d968e80ef9863086517f4a39ec61541f703e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 05:56:17 -0700 Subject: [PATCH 081/120] fix(security): cap production OIDC state --- server/application_routes.mjs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/server/application_routes.mjs b/server/application_routes.mjs index 68539279..d12565f1 100644 --- a/server/application_routes.mjs +++ b/server/application_routes.mjs @@ -7,6 +7,7 @@ import { } from 'node:crypto'; import { db, rowid } from './db.mjs'; import { + createExpiringAuthStateStore, hashApiToken, hashPassword, signToken, @@ -42,7 +43,7 @@ const OIDC_CLIENT_ID = String(process.env.OIDC_CLIENT_ID || ''); const OIDC_CLIENT_SECRET = String(process.env.OIDC_CLIENT_SECRET || ''); const OIDC_REDIRECT_URI = String(process.env.OIDC_REDIRECT_URI || ''); const productionOidcConfigured = Boolean(OIDC_ISSUER && OIDC_CLIENT_ID && OIDC_CLIENT_SECRET); -const productionOidcStates = new Map(); +const productionOidcStates = createExpiringAuthStateStore(); function normalizeIdentityEmail(value) { return String(value ?? '').trim().toLowerCase(); @@ -244,7 +245,17 @@ async function productionOidcStart(c, next) { const verifier = randomBytes(32).toString('base64url'); const nonce = randomBytes(24).toString('base64url'); const challenge = createHash('sha256').update(verifier).digest('base64url'); - productionOidcStates.set(state, { verifier, nonce, exp: Date.now() + 5 * 60 * 1000 }); + const reserved = productionOidcStates.reserve( + state, + { verifier, nonce, exp: Date.now() + 5 * 60 * 1000 }, + ); + if (!reserved) { + return c.json( + { error: 'sso temporarily unavailable' }, + 503, + { 'Cache-Control': 'no-store', 'Retry-After': '60' }, + ); + } authorizationUrl.searchParams.set('client_id', OIDC_CLIENT_ID); authorizationUrl.searchParams.set('redirect_uri', productionOidcRedirectUri(c)); @@ -262,12 +273,10 @@ async function productionOidcCallback(c, next) { const state = c.req.query('state'); const code = c.req.query('code'); - const pending = productionOidcStates.get(state); - if (!pending || pending.exp < Date.now() || !code) { - productionOidcStates.delete(state); + const pending = productionOidcStates.take(state); + if (!pending || !code) { return c.json({ error: 'invalid or expired state' }, 400, { 'Cache-Control': 'no-store' }); } - productionOidcStates.delete(state); try { const tokenResponse = await fetch(`${OIDC_ISSUER}/token`, { From fad52de37a9f749738118d27ca31e47b6fc547ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 06:35:48 -0700 Subject: [PATCH 082/120] test(ci): restore coverage producer guards --- tests/unit/coverage-script-contract.test.mjs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 7e9ec421..8d90d7ef 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -21,6 +21,26 @@ assert.equal( 0, 'server CI does not execute the API suite separately when owned coverage already executes it', ); +assert.equal( + scripts.coverage, + 'npm run test:coverage', + 'the public coverage command delegates to the canonical coverage producer', +); +assert.match( + scripts['test:coverage'], + /\bc8\b.*--reporter=json(?![-\w]).*npm run test:coverage:cases/, + 'test:coverage creates Istanbul JSON before executing coverage cases', +); +assert.match( + scripts['test:coverage'], + /--reporter=json-summary\b/, + 'test:coverage also creates the Istanbul JSON summary', +); +assert.doesNotMatch( + scripts['test:coverage:cases'], + /npm run (?:coverage|test:coverage)(?:\s|$)/, + 'coverage cases never recursively invoke a coverage wrapper', +); const exactCheckoutRepository = "repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}"; const exactCheckoutRef = From 1f93371c9df84a2f60dd3386c6429d63d750c50c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 23:43:41 +0900 Subject: [PATCH 083/120] fix: share trusted rate limiter with core routes --- server/app.mjs | 9 ++++---- server/application_routes.mjs | 9 ++++---- server/application_routes_core.mjs | 25 +++++---------------- tests/api/ratelimit.test.mjs | 35 ++++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 30 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 86994899..01c883e8 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -14,11 +14,10 @@ import { const configuredRateLimitMax = process.env.SCOPEWEAVE_RATE_LIMIT_MAX; // app_routes.mjs is a compatibility re-export of application_routes.mjs. That -// boundary imports application_routes_core.mjs, whose historical header-keyed -// limiter reads SCOPEWEAVE_RATE_LIMIT_MAX only at module initialization. Load -// the nested graph with limiting disabled, then restore the operator value -// before constructing the public transport-peer-aware limiter below. Restoring -// the environment does not reactivate the already-initialized core limiter. +// boundary imports application_routes_core.mjs, which uses the same shared +// limiter. Load the nested graph with limiting disabled, then restore the +// operator value before constructing the public transport-peer-aware limiter +// below. Restoring the environment does not reactivate the nested limiter. let routeApp; try { process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '0'; diff --git a/server/application_routes.mjs b/server/application_routes.mjs index d12565f1..5235d897 100644 --- a/server/application_routes.mjs +++ b/server/application_routes.mjs @@ -21,11 +21,10 @@ import { const configuredRateLimitMax = process.env.SCOPEWEAVE_RATE_LIMIT_MAX; let coreRoutes; try { - // application_routes_core.mjs retains the historical header-keyed limiter - // only as an internal compatibility detail. Every supported entrypoint loads - // that implementation with the legacy limiter disabled, then applies the - // transport-peer-aware policy below before any route-specific guard or DB - // lookup. Restoring the operator value before request handling keeps the + // application_routes_core.mjs uses the same shared limiter as this boundary. + // Load the nested instance with an explicit zero limit, then apply the + // transport-peer-aware policy below once before any route-specific guard or + // DB lookup. Restoring the operator value before request handling keeps the // process environment truthful for diagnostics and child integrations. process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '0'; ({ app: coreRoutes } = await import('./application_routes_core.mjs')); diff --git a/server/application_routes_core.mjs b/server/application_routes_core.mjs index 5a8a5038..a6a5a9ca 100644 --- a/server/application_routes_core.mjs +++ b/server/application_routes_core.mjs @@ -12,6 +12,7 @@ import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from 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 +import { createRateLimitMiddleware } from './rate_limit.mjs'; const GUARD_ACCOUNTING_METHOD = Symbol.for('scopeweave.guard_accounting.original_method'); const getOrg = (id) => db.prepare('SELECT * FROM orgs WHERE id = ?').get(id); @@ -147,26 +148,10 @@ app.use('*', async (c, next) => { } 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(); - }); -} +// Use the same validated, transport-peer-aware limiter as every supported +// wrapper. application_routes.mjs loads this module with an explicit zero +// limit, so the public wrapper remains the sole limiter for that request path. +app.use('*', createRateLimitMiddleware()); app.post('/api/auth/signup', async (c) => { const { email, password, name } = await c.req.json().catch(() => ({})); diff --git a/tests/api/ratelimit.test.mjs b/tests/api/ratelimit.test.mjs index 24f8575c..28fa217f 100644 --- a/tests/api/ratelimit.test.mjs +++ b/tests/api/ratelimit.test.mjs @@ -108,6 +108,41 @@ assert.equal( `Equivalent-IPv6 trusted-proxy regression failed:\n${equivalentIpv6PeerProbe.stderr}`, ); +const directCoreProbe = spawnSync( + process.execPath, + [ + '--input-type=module', + '--eval', + `import assert from 'node:assert/strict'; + process.env.SCOPEWEAVE_DB = ':memory:'; + process.env.SCOPEWEAVE_JWT_SECRET = '${validJwtSecret}'; + process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '3'; + process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; + process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX = '20'; + process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS = '127.0.0.1'; + const { app } = await import('./server/application_routes_core.mjs?direct-core-rate-limit=1'); + const nodeEnv = { incoming: { socket: { remoteAddress: '127.0.0.1' } } }; + const requestFrom = (client) => app.request('/api/health', { headers: { 'x-forwarded-for': client } }, nodeEnv); + for (let i = 0; i < 3; i++) assert.equal((await requestFrom('203.0.113.72')).status, 200); + assert.equal( + (await requestFrom('203.0.113.72')).status, + 429, + 'direct core consumers use the shared bounded rate-limit policy', + ); + assert.equal( + (await requestFrom('198.51.100.72')).status, + 200, + 'direct core consumers honor trusted proxy client separation', + );`, + ], + { cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } }, +); +assert.equal( + directCoreProbe.status, + 0, + `Direct application core rate-limit regression failed:\n${directCoreProbe.stderr}`, +); + const observabilityProbe = spawnSync( process.execPath, [ From 886caf5bffc730974bbbf8d8ab5cf7c1ced786ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 12:16:36 -0700 Subject: [PATCH 084/120] test: reproduce rate-limit import-order bypass --- tests/api/rate-limit-shared-boundary.test.mjs | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/api/rate-limit-shared-boundary.test.mjs b/tests/api/rate-limit-shared-boundary.test.mjs index 558234a1..12fce458 100644 --- a/tests/api/rate-limit-shared-boundary.test.mjs +++ b/tests/api/rate-limit-shared-boundary.test.mjs @@ -41,6 +41,31 @@ assert.equal( `Shared-boundary client-identity regression failed:\n${directBoundaryIdentityProbe.stderr}`, ); +const importOrderIsolationProbe = runProbe(` + import assert from 'node:assert/strict'; + process.env.SCOPEWEAVE_DB = ':memory:'; + process.env.SCOPEWEAVE_JWT_SECRET = '${validJwtSecret}'; + process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '1'; + process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; + process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX = '8'; + delete process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS; + + await import('./server/app.mjs'); + const { app: sharedApp } = await import('./server/application_routes.mjs'); + + assert.equal((await sharedApp.request('/api/health')).status, 200); + assert.equal( + (await sharedApp.request('/api/health')).status, + 429, + 'importing the public app first must not leave the separately supported shared boundary cached with rate limiting disabled', + ); +`); +assert.equal( + importOrderIsolationProbe.status, + 0, + `Shared-boundary import-order regression failed:\n${importOrderIsolationProbe.stderr}`, +); + const inviteOrderingProbe = runProbe(` import assert from 'node:assert/strict'; process.env.SCOPEWEAVE_DB = ':memory:'; @@ -77,4 +102,4 @@ assert.equal( `Shared-boundary limiter-order regression failed:\n${inviteOrderingProbe.stderr}`, ); -console.log('✓ shared-boundary rate-limit regressions passed'); +console.log('✓ shared-boundary rate-limit regressions passed'); \ No newline at end of file From 75b7caf5830b811b2ef2db744d98d91c1ac830c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 12:18:44 -0700 Subject: [PATCH 085/120] fix: preserve shared limiter across import order --- server/app.mjs | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 01c883e8..915febad 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -2,33 +2,25 @@ // // The supported shared route graph also serves direct consumers, but the public // Node entrypoint must remain authoritative for its own transport peer. Both -// boundaries therefore consume the same rate-limit module; the nested shared -// instance is initialized disabled here so one request cannot acquire two -// independent limiter buckets or observability deltas. +// boundaries therefore consume the same rate-limit module. The shared runtime +// context marker makes the nested limiter a no-op after the public boundary has +// admitted a request, so the shared route graph can remain correctly configured +// when it is later reused directly from the same module cache. import { Hono } from 'hono'; import { createRateLimitMiddleware, createRateLimitObservability, } from './rate_limit.mjs'; -const configuredRateLimitMax = process.env.SCOPEWEAVE_RATE_LIMIT_MAX; - -// app_routes.mjs is a compatibility re-export of application_routes.mjs. That -// boundary imports application_routes_core.mjs, which uses the same shared -// limiter. Load the nested graph with limiting disabled, then restore the -// operator value before constructing the public transport-peer-aware limiter -// below. Restoring the environment does not reactivate the nested limiter. -let routeApp; -try { - process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '0'; - ({ app: routeApp } = await import('./app_routes.mjs')); -} finally { - if (configuredRateLimitMax === undefined) delete process.env.SCOPEWEAVE_RATE_LIMIT_MAX; - else process.env.SCOPEWEAVE_RATE_LIMIT_MAX = configuredRateLimitMax; -} +// app_routes.mjs is a compatibility re-export of application_routes.mjs. Keep +// that supported shared boundary configured with the operator's real rate-limit +// policy; createRateLimitMiddleware deduplicates the nested mounted middleware +// through its Hono context marker instead of mutating process-global config +// during module evaluation. +const { app: routeApp } = await import('./app_routes.mjs'); export const app = new Hono(); const rateLimitObservability = createRateLimitObservability(); app.use('*', createRateLimitMiddleware(rateLimitObservability)); -app.route('/', routeApp); +app.route('/', routeApp); \ No newline at end of file From dcf37e81f8a5584accc4403e05b3a5e10b644872 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 04:44:59 +0900 Subject: [PATCH 086/120] fix: redact bearer tokens from rate-limit logs --- CHANGELOG.md | 3 ++ server/application_routes_core.mjs | 4 +-- server/rate_limit.mjs | 13 +++++++- tests/api/ratelimit.test.mjs | 50 ++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fccb56dc..01ee63a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Redacted invite and public-share bearer-token path segments in both ordinary + request logs and rate-limit rejection logs, including malformed trailing + paths, so access-log retention cannot become a credential disclosure channel. - Replaced the unsigned `POST /api/stripe/webhook` plan-upgrade stub with a fail-closed raw-body HMAC-SHA-256 signature boundary. Signed deliveries are acknowledged only; webhook JSON is not entitlement authority until durable diff --git a/server/application_routes_core.mjs b/server/application_routes_core.mjs index a6a5a9ca..e075650e 100644 --- a/server/application_routes_core.mjs +++ b/server/application_routes_core.mjs @@ -12,7 +12,7 @@ import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from 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 -import { createRateLimitMiddleware } from './rate_limit.mjs'; +import { createRateLimitMiddleware, redactRequestLogPath } from './rate_limit.mjs'; const GUARD_ACCOUNTING_METHOD = Symbol.for('scopeweave.guard_accounting.original_method'); const getOrg = (id) => db.prepare('SELECT * FROM orgs WHERE id = ?').get(id); @@ -143,7 +143,7 @@ app.use('*', async (c, next) => { 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.env?.[GUARD_ACCOUNTING_METHOD] || c.req.method, path: c.req.path, status: s, ms: Date.now() - t })); + console.log(JSON.stringify({ ts: new Date().toISOString(), method: c.env?.[GUARD_ACCOUNTING_METHOD] || c.req.method, path: redactRequestLogPath(c.req.path), status: s, ms: Date.now() - t })); } } catch { /* metrics/logging must never break a request */ } }); diff --git a/server/rate_limit.mjs b/server/rate_limit.mjs index fc0102cf..143d285f 100644 --- a/server/rate_limit.mjs +++ b/server/rate_limit.mjs @@ -2,6 +2,17 @@ import { isIP } from 'node:net'; export const RATE_LIMIT_APPLIED_CONTEXT_KEY = 'scopeweaveRateLimitApplied'; +/** + * Replace bearer-token path segments before they reach operational logs. + * @param {unknown} path Request path. + * @returns {string} Safe path retaining the route shape. + */ +export function redactRequestLogPath(path) { + return String(path) + .replace(/^\/api\/invites\/[^/]+(?=\/|$)/u, '/api/invites/:token') + .replace(/^\/api\/shared\/[^/]+(?=\/|$)/u, '/api/shared/:token'); +} + /** * Parse one explicit rate-limit setting without silently weakening protection. * @@ -94,7 +105,7 @@ export function createRateLimitObservability() { console.log(JSON.stringify({ ts: new Date().toISOString(), method: c.req.method, - path: c.req.path, + path: redactRequestLogPath(c.req.path), status: 429, ms: Date.now() - startedAt, })); diff --git a/tests/api/ratelimit.test.mjs b/tests/api/ratelimit.test.mjs index 28fa217f..294112ec 100644 --- a/tests/api/ratelimit.test.mjs +++ b/tests/api/ratelimit.test.mjs @@ -4,8 +4,13 @@ import assert from 'node:assert'; import { spawnSync } from 'node:child_process'; import { once } from 'node:events'; import { serve } from '@hono/node-server'; +import { randomUUID } from 'node:crypto'; +import { rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; const validJwtSecret = '0123456789abcdef0123456789abcdef'; +const redactionDbPath = join(tmpdir(), `scopeweave-rate-limit-redaction-${randomUUID()}.sqlite`); const importRateLimitApp = (overrides) => spawnSync( process.execPath, ['--input-type=module', '--eval', "await import('./server/app.mjs')"], @@ -177,6 +182,51 @@ assert.equal( `Rate-limit observability regression failed:\n${observabilityProbe.stderr}`, ); +const redactionProbe = spawnSync( + process.execPath, + [ + '--input-type=module', + '--eval', + `process.env.SCOPEWEAVE_DB = ${JSON.stringify(redactionDbPath)}; + process.env.SCOPEWEAVE_JWT_SECRET = '${validJwtSecret}'; + process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '1'; + process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; + const { app } = await import('./server/app.mjs?rate-limit-log-redaction=1'); + const path = '/api/invites/live-invite-secret-sentinel/accept'; + await app.request(path, { method: 'POST' }); + await app.request(path, { method: 'POST' }); + const { app: shareApp } = await import('./server/app.mjs?rate-limit-share-log-redaction=1'); + const sharePath = '/api/shared/live-share-secret-sentinel/extra'; + await shareApp.request(sharePath); + await shareApp.request(sharePath); + process.stdout.write('RATE_LIMIT_REDACTION_DONE\\n');`, + ], + { cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } }, +); +assert.equal(redactionProbe.status, 0, `Rate-limit log redaction regression failed:\n${redactionProbe.stderr}`); +const redactionLogs = redactionProbe.stdout + .split('\n') + .filter((line) => line.startsWith('{')) + .map((line) => JSON.parse(line)); +assert.deepEqual( + redactionLogs.map((entry) => entry.path), + [ + '/api/invites/:token/accept', + '/api/invites/:token/accept', + '/api/shared/:token/extra', + '/api/shared/:token/extra', + ], + 'allowed and blocked invite/share requests redact every secret path segment', +); +for (const entry of redactionLogs) assert.equal( + /live-(?:invite|share)-secret-sentinel/u.test(entry.path), + false, + 'request logs never contain bearer tokens', +); +rmSync(redactionDbPath, { force: true }); +rmSync(`${redactionDbPath}-shm`, { force: true }); +rmSync(`${redactionDbPath}-wal`, { force: true }); + process.env.SCOPEWEAVE_DB = ':memory:'; process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '3'; process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX = '7'; From 74d41d17ebddc1be20dbc7d6fbec32f02397ba7e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 13:55:02 -0700 Subject: [PATCH 087/120] test: fail closed on direct core production OIDC --- .../oidc-core-production-failclosed.test.mjs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 tests/api/oidc-core-production-failclosed.test.mjs diff --git a/tests/api/oidc-core-production-failclosed.test.mjs b/tests/api/oidc-core-production-failclosed.test.mjs new file mode 100644 index 00000000..933afd7f --- /dev/null +++ b/tests/api/oidc-core-production-failclosed.test.mjs @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; + +const regression = String.raw` + import assert from 'node:assert/strict'; + + process.env.SCOPEWEAVE_DB = ':memory:'; + delete process.env.SCOPEWEAVE_DEV; + process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + process.env.STRIPE_SECRET_KEY = 'sk_test_scopeweave_oidc_core_boundary'; + process.env.STRIPE_PRICE_ID = 'price_scopeweave_oidc_core_boundary'; + process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_oidc_core_boundary_secret'; + process.env.OIDC_ISSUER = 'https://issuer.example'; + process.env.OIDC_CLIENT_ID = 'scopeweave-client'; + process.env.OIDC_CLIENT_SECRET = 'scopeweave-secret'; + process.env.OIDC_REDIRECT_URI = 'https://scopeweave.example/api/auth/oidc/callback'; + + const { app: internalCoreRoutes } = await import('./server/application_routes_core.mjs?oidc-core-production-failclosed=1'); + + let response = await internalCoreRoutes.request('https://scopeweave.example/api/auth/oidc/start'); + assert.equal(response.status, 404, 'internal core graph must not expose production OIDC authorization'); + assert.equal(response.headers.get('location'), null, 'internal core graph never redirects to a production issuer'); + assert.deepEqual(await response.json(), { error: 'sso not configured' }); + + response = await internalCoreRoutes.request( + 'https://scopeweave.example/api/auth/oidc/callback?state=attacker-state&code=attacker-code', + ); + assert.equal(response.status, 404, 'internal core graph must not process production OIDC callbacks'); + assert.equal(response.headers.get('location'), null, 'internal core callback cannot mint an application session'); + assert.deepEqual(await response.json(), { error: 'sso not configured' }); +`; + +const result = spawnSync( + process.execPath, + ['--input-type=module', '--eval', regression], + { cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } }, +); + +assert.equal( + result.status, + 0, + `direct core production OIDC fail-closed regression failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, +); + +console.log('Direct core production OIDC fail-closed regression passed'); From c2a381112028886e2f7fdc05fb70fbbee219214e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 13:55:33 -0700 Subject: [PATCH 088/120] test: run core production OIDC fail-closed regression --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6194cf51..f91557c0 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/invite-security.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/security-guard-abuse-controls.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/rate-limit-prometheus.test.mjs && node tests/api/rate-limit-shared-boundary.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/invite-security.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-core-production-failclosed.test.mjs && node tests/api/security-guard-abuse-controls.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/rate-limit-prometheus.test.mjs && node tests/api/rate-limit-shared-boundary.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/toast-accessibility.test.mjs", "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/rate_limit.mjs --include=server/application_routes.mjs --include=server/application_routes_core.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && npm run test:api", From 8e79805a27e9e77ab4aa95305f7229934fecb957 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 14:00:35 -0700 Subject: [PATCH 089/120] fix: fail closed direct core production OIDC --- package.json | 2 +- server/application_routes_core.mjs | 1439 +----------------- server/application_routes_implementation.mjs | 1415 +++++++++++++++++ 3 files changed, 1447 insertions(+), 1409 deletions(-) create mode 100644 server/application_routes_implementation.mjs diff --git a/package.json b/package.json index f91557c0..7e908467 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/invite-security.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-core-production-failclosed.test.mjs && node tests/api/security-guard-abuse-controls.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/rate-limit-prometheus.test.mjs && node tests/api/rate-limit-shared-boundary.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/toast-accessibility.test.mjs", - "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/rate_limit.mjs --include=server/application_routes.mjs --include=server/application_routes_core.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.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/rate_limit.mjs --include=server/application_routes.mjs --include=server/application_routes_core.mjs --include=server/application_routes_implementation.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", diff --git a/server/application_routes_core.mjs b/server/application_routes_core.mjs index e075650e..3c7522a0 100644 --- a/server/application_routes_core.mjs +++ b/server/application_routes_core.mjs @@ -1,1415 +1,38 @@ -// 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 { StripeWebhookError, verifyStripeWebhookRequest } from './stripe_webhook.mjs'; 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 -import { createRateLimitMiddleware, redactRequestLogPath } from './rate_limit.mjs'; - -const GUARD_ACCOUNTING_METHOD = Symbol.for('scopeweave.guard_accounting.original_method'); -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'; - +import { app as implementationRoutes } from './application_routes_implementation.mjs'; + +/** + * Low-level ScopeWeave route graph with a fail-closed production OIDC boundary. + * + * Production OIDC is intentionally unavailable through this internal module. + * Supported production consumers must enter through `application_routes.mjs`, + * which validates issuer signatures and claims before creating a ScopeWeave + * session. The legacy mock flow remains reachable only in explicit development + * mode when no production issuer is configured. + */ 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.env?.[GUARD_ACCOUNTING_METHOD] || c.req.method, path: redactRequestLogPath(c.req.path), status: s, ms: Date.now() - t })); - } - } catch { /* metrics/logging must never break a request */ } -}); - -// Use the same validated, transport-peer-aware limiter as every supported -// wrapper. application_routes.mjs loads this module with an explicit zero -// limit, so the public wrapper remains the sole limiter for that request path. -app.use('*', createRateLimitMiddleware()); - -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' +const coreOidcMockEnabled = + process.env.SCOPEWEAVE_DEV === '1' && !process.env.OIDC_ISSUER; + +/** + * Reject direct production OIDC start/callback requests before legacy handlers. + * + * @param {import('hono').Context} c Current Hono request context. + * @param {() => Promise} next Continues only for the development mock. + * @returns {Promise} A non-cacheable 404 or downstream result. + */ +async function requireVerifiedOidcBoundary(c, next) { + if (!coreOidcMockEnabled) { + return c.json( + { error: 'sso not configured' }, + 404, + { 'Cache-Control': 'no-store' }, ); } - 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, 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 only for the authenticated account named by the invite. -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 identity = db.prepare('SELECT email FROM users WHERE id = ?').get(uid); - if ( - String(inv.email || '').trim().toLowerCase() - !== String(identity?.email || '').trim().toLowerCase() - ) { - 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 authenticity only. The supported public/shared boundary mounts -// this exact core route, so the route itself remains fail-closed even if a -// future internal consumer bypasses outer composition. Authentic callbacks are -// acknowledged but never mutate entitlements directly -// (Krawczyk et al., 1997; Stripe webhook signatures). -app.post('/api/stripe/webhook', async (c) => { - try { - await verifyStripeWebhookRequest(c.req.raw, { - secret: process.env.STRIPE_WEBHOOK_SECRET, - }); - return c.json({ received: true }, 200, { 'Cache-Control': 'no-store' }); - } catch (error) { - if (error instanceof StripeWebhookError) { - return c.json({ error: error.code }, error.status, { 'Cache-Control': 'no-store' }); - } - return c.json({ error: 'stripe_webhook_unavailable' }, 500, { 'Cache-Control': 'no-store' }); - } -}); - -// 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). The -// built-in mock provider is available only when OIDC is unconfigured and -// SCOPEWEAVE_DEV=1; missing production configuration fails closed. -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 && process.env.SCOPEWEAVE_DEV === '1'; -const oidcConfigured = Boolean(OIDC.issuer) || oidcMock; -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) => { - if (!oidcConfigured) return c.json({ error: 'sso not configured' }, 404); - const origin = new URL(c.req.url).origin; - const state = randomBytes(16).toString('hex'); - const verifier = randomBytes(32).toString('base64url'); - const challenge = createHash('sha256').update(verifier).digest('base64url'); - oidcStates.set(state, { verifier, exp: Date.now() + 5 * 60 * 1000 }); - const redirectUri = OIDC.redirectUri || `${origin}/api/auth/oidc/callback`; - if (oidcMock) { - const email = c.req.query('email') || 'sso-user@example.com'; - const u = new URL(`${origin}/api/auth/oidc/mock/authorize`); - u.searchParams.set('state', state); - u.searchParams.set('email', email); - u.searchParams.set('redirect_uri', redirectUri); - return c.redirect(u.toString()); - } - const u = new URL(`${OIDC.issuer.replace(/\/$/, '')}/authorize`); - u.searchParams.set('client_id', OIDC.clientId); - u.searchParams.set('redirect_uri', redirectUri); - u.searchParams.set('response_type', 'code'); - u.searchParams.set('scope', 'openid email profile'); - u.searchParams.set('state', state); - u.searchParams.set('code_challenge', challenge); - u.searchParams.set('code_challenge_method', 'S256'); - return c.redirect(u.toString()); -}); - -// Built-in mock IdP authorize — instantly issues a code (dev/test only). -app.get('/api/auth/oidc/mock/authorize', (c) => { - if (!oidcMock) return c.json({ error: 'mock disabled' }, 404); - const state = c.req.query('state'); - const email = c.req.query('email'); - const redirectUri = c.req.query('redirect_uri'); - const code = randomBytes(16).toString('hex'); - oidcCodes.set(code, email); - const u = new URL(redirectUri); - u.searchParams.set('code', code); - u.searchParams.set('state', state); - return c.redirect(u.toString()); -}); - -app.get('/api/auth/oidc/callback', async (c) => { - const state = c.req.query('state'); - const code = c.req.query('code'); - const s = oidcStates.get(state); - if (!s || s.exp < Date.now()) return c.json({ error: 'invalid or expired state' }, 400); - oidcStates.delete(state); - let email; - if (oidcMock) { - email = oidcCodes.get(code); - oidcCodes.delete(code); - if (!email) return c.json({ error: 'invalid code' }, 400); - } else { - const redirectUri = OIDC.redirectUri || `${new URL(c.req.url).origin}/api/auth/oidc/callback`; - const tokenRes = await fetch(`${OIDC.issuer.replace(/\/$/, '')}/token`, { - method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: OIDC.clientId, client_secret: OIDC.clientSecret, code_verifier: s.verifier }), - }).catch(() => null); - const tok = tokenRes ? await tokenRes.json().catch(() => ({})) : {}; - if (!tok.id_token) return c.json({ error: 'token exchange failed' }, 400); - // Ceiling: verify the id_token signature via the issuer JWKS before prod. - const claims = JSON.parse(Buffer.from(String(tok.id_token).split('.')[1] || '', 'base64url').toString() || '{}'); - email = claims.email; - if (!email) return c.json({ error: 'no email claim' }, 400); - } - const user = upsertSsoUser(email); - const token = signToken({ sub: user.id, email, tv: user.token_version || 0 }); - // Return the token in the URL fragment (not query → not logged); the client - // stores it and cleans the URL. - return c.redirect(`/#token=${token}`); -}); - -// Cross-project search: project names + task names, membership-scoped (tenant -// isolation via the same JOIN as projectAccess). -// ponytail: LIKE over tasks_json text; move to FTS5 if search gets heavy. -app.get('/api/search', requireAuth, (c) => { - const uid = c.get('user').sub; - const q = String(c.req.query('q') || '').trim(); - if (q.length < 2) return c.json({ error: 'query too short (min 2)' }, 400); - const rows = db.prepare( - `SELECT DISTINCT p.id, p.name, p.tasks_json FROM projects p - JOIN memberships m ON m.org_id = p.org_id - WHERE m.user_id = ? AND (p.name LIKE ? OR p.tasks_json LIKE ?) LIMIT 100` - ).all(uid, `%${q}%`, `%${q}%`); - const needle = q.toLowerCase(); - const results = []; - for (const p of rows) { - const hit = { projectId: p.id, projectName: p.name, tasks: [] }; - if (p.name.toLowerCase().includes(needle)) hit.nameMatch = true; - let tasks = []; - try { tasks = JSON.parse(p.tasks_json); } catch { /* skip bad json */ } - for (const t of tasks) { - if (String(t.name || '').toLowerCase().includes(needle)) { - hit.tasks.push({ id: t.id, name: t.name }); - if (hit.tasks.length >= 5) break; - } - } - if (hit.nameMatch || hit.tasks.length) results.push(hit); - if (results.length >= 20) break; - } - return c.json({ query: q, results }); -}); - -// Portfolio dashboard: executive rollup across every project in a workspace — -// weighted planned/actual progress, SPI + status, overdue-task counts. -app.get('/api/orgs/:id/portfolio', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); - const today = new Date().toISOString().slice(0, 10); - const rows = db.prepare( - 'SELECT id, name, base_date AS baseDate, tasks_json, archived, updated_at AS updatedAt FROM projects WHERE org_id = ? ORDER BY archived ASC, updated_at DESC' - ).all(orgId); - const projects = rows.map((p) => { - let tasks = []; - try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } - let wSum = 0, pv = 0, ev = 0, overdue = 0; - for (const t of tasks) { - const w = Number(t.weight) || 1; - wSum += w; - pv += w * ((Number(t.plannedProgress) || 0) / 100); - ev += w * ((Number(t.actualProgress) || 0) / 100); - if (t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100) overdue++; - } - const evm = computeEvm({ pv: wSum ? pv / wSum : 0, ev: wSum ? ev / wSum : 0 }); - return { - id: p.id, - name: p.name, - archived: Boolean(p.archived), - tasks: tasks.length, - planned: Math.round(evm.pv * 1000) / 10, // % - actual: Math.round(evm.ev * 1000) / 10, // % - spi: evm.spi === null ? null : Math.round(evm.spi * 100) / 100, - status: evm.status, - label: evm.label, - overdue, - updatedAt: p.updatedAt, - }; - }); - return c.json({ projects }); -}); - -// AI 브리핑: 프로젝트 스냅샷(요약 지표 + 지연/차주 작업)을 contextual- -// orchestrator(LLM)로 보내 경영진용 리스크 분석을 생성. 원문 데이터는 서버가 -// 요약해 전송하며, LLM 자격은 서버 환경변수에만 존재. -app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - let tasks = []; - try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } - const today = new Date().toISOString().slice(0, 10); - let wSum = 0, pv = 0, ev = 0; - const late = [], upcoming = []; - for (const t of tasks) { - const w = Number(t.weight) || 1; - wSum += w; - pv += w * ((Number(t.plannedProgress) || 0) / 100); - ev += w * ((Number(t.actualProgress) || 0) / 100); - const name = t.name || t.task || t.activity || t.phase || t.id; - if (t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100) { - late.push(`${name}(계획종료 ${t.plannedEndDate}, 실적 ${Number(t.actualProgress) || 0}%${t.owner ? `, ${t.owner}` : ''})`); - } else if (t.plannedStartDate && t.plannedStartDate >= today) { - upcoming.push(`${name}(${t.plannedStartDate} 시작)`); - } - } - const pvPct = wSum ? ((pv / wSum) * 100).toFixed(1) : '0'; - const evPct = wSum ? ((ev / wSum) * 100).toFixed(1) : '0'; - const context = [ - `프로젝트: ${p.name}`, - `작업 수: ${tasks.length} · 계획진척 ${pvPct}% · 실적진척 ${evPct}%`, - `지연 작업(${late.length}): ${late.slice(0, 8).join(' / ') || '없음'}`, - `예정 작업(${upcoming.length}): ${upcoming.slice(0, 5).join(' / ') || '없음'}`, - ].join('\n'); - try { - const analysis = await orchestratorChat([ - { role: 'system', content: '너는 공정관리(schedule control) 전문가다. 주어진 프로젝트 지표를 근거로 한국어 경영진 브리핑을 작성하라: ①일정 상태 한 줄 판정 ②핵심 리스크 2~3개(근거 지표 인용) ③실행 권고 2~3개. 지표에 없는 사실은 만들지 마라.' }, - { role: 'user', content: context }, - ], { - service: 'scopeweave', - account: String(p.org_id), - }); - logAudit(p.org_id, uid, 'ai.brief', 'project', p.id, { tasks: tasks.length }); - return c.json({ analysis }); - } catch (e) { - return c.json({ error: `AI 분석 실패: ${e.message}` }, 502); - } -}); - -// 산출물 첨부(Clearfolio 통합 문서 뷰어 프록시): 업로드→변환 잡, 목록(+상태 -// 갱신), 서명 아티팩트 열람(302), 삭제. 테넌트 = 조직, 브라우저에는 Clearfolio -// 자격이 절대 노출되지 않음. -const ATTACH_MAX_BYTES = 10 * 1024 * 1024; - -const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY, -); -const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, -); -const ATTACH_STATUS_BUDGET_MS = normalizeAttachmentStatusBudgetMs( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS, -); -const ATTACHMENT_LIST_COLUMNS = `a.id, a.task_id AS taskId, a.name, a.mime, a.size, - a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy`; -const ATTACHMENT_LIST_FROM = - 'FROM attachments a LEFT JOIN users u ON u.id = a.created_by'; -const listAttachmentsStatement = db.prepare( - `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} - WHERE a.project_id = ? ORDER BY a.id DESC`, -); -const listTaskAttachmentsStatement = db.prepare( - `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} - WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`, -); -const updateAttachmentStatusStatement = db.prepare( - 'UPDATE attachments SET status = ? WHERE id = ?', -); -app.post('/api/projects/:id/attachments', requireAuth, async (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const form = await c.req.formData().catch(() => null); - const file = form?.get('file'); - if (!file || typeof file === 'string') return c.json({ error: 'multipart file required' }, 400); - const taskId = String(form.get('taskId') || ''); - if (/\.(hwp|hwpx)$/i.test(file.name || '')) return c.json({ error: 'HWP/HWPX는 지원되지 않습니다 (Clearfolio 정책)' }, 400); - if (file.size > ATTACH_MAX_BYTES) return c.json({ error: 'file too large (max 10MB)' }, 400); - const bytes = Buffer.from(await file.arrayBuffer()); - let job; - try { - job = await submitJob(p.org_id, uid, { name: file.name || 'document', mime: file.type || '', bytes }); - } catch (e) { - return c.json({ error: `문서 변환 제출 실패: ${e.message}` }, 502); - } - const aid = rowid(db.prepare( - 'INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)' - ).run(p.id, taskId, file.name || 'document', file.type || '', file.size, job.jobId, job.status, uid)); - logAudit(p.org_id, uid, 'attachment.upload', 'project', p.id, { attachmentId: aid, name: file.name, taskId: taskId || null }); - return c.json({ id: aid, status: job.status }); -}); - -app.get('/api/projects/:id/attachments', requireAuth, async (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - - const taskId = c.req.query('taskId'); - const rows = taskId - ? listTaskAttachmentsStatement.all(p.id, taskId) - : listAttachmentsStatement.all(p.id); - await refreshAttachmentStatuses(rows, { - orgId: p.org_id, - userId: uid, - jobStatus, - updateStatus: (status, attachmentId) => - updateAttachmentStatusStatement.run(status, attachmentId), - concurrency: ATTACH_STATUS_CONCURRENCY, - timeoutMs: ATTACH_STATUS_TIMEOUT_MS, - budgetMs: ATTACH_STATUS_BUDGET_MS, - metrics, - }); - const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); - return c.json({ attachments }); -}); - -// 열람: 서명 아티팩트 URL로 302. 새 탭 열기용으로 ?token=도 허용(ics/stream 패턴). -app.get('/api/projects/:id/attachments/:aid/view', (c) => { - const header = c.req.header('authorization') || ''; - const raw = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); - let uid; - if (raw.startsWith('swk_')) { - const row = db.prepare('SELECT user_id FROM api_tokens WHERE token_hash = ?').get(hashApiToken(raw)); - if (!row) return c.json({ error: 'unauthorized' }, 401); - uid = row.user_id; - } else { - try { - const payload = verifyToken(raw); - const u = db.prepare('SELECT token_version FROM users WHERE id = ?').get(payload.sub); - if (!u || (payload.tv || 0) !== u.token_version) return c.json({ error: 'unauthorized' }, 401); - uid = payload.sub; - } catch { return c.json({ error: 'unauthorized' }, 401); } - } - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const a = db.prepare('SELECT job_id, status FROM attachments WHERE id = ? AND project_id = ?').get(c.req.param('aid'), p.id); - if (!a) return c.json({ error: 'not found' }, 404); - if (a.status !== 'SUCCEEDED') return c.json({ error: `문서가 아직 준비되지 않았습니다 (${a.status})` }, 409); - return artifactUrl(p.org_id, uid, a.job_id) - .then((url) => c.redirect(url)) - .catch((e) => c.json({ error: `열람 링크 발급 실패: ${e.message}` }, 502)); -}); - -app.delete('/api/projects/:id/attachments/:aid', requireAuth, (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const a = db.prepare('SELECT created_by FROM attachments WHERE id = ? AND project_id = ?').get(c.req.param('aid'), p.id); - if (!a) return c.json({ error: 'not found' }, 404); - if (a.created_by !== uid && !canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - db.prepare('DELETE FROM attachments WHERE id = ?').run(c.req.param('aid')); - logAudit(p.org_id, uid, 'attachment.delete', 'project', p.id, { attachmentId: Number(c.req.param('aid')) }); - return c.json({ ok: true }); -}); - -// mock Clearfolio 아티팩트 서빙(dev/test 전용) -if (clearfolioMock) { - app.get('/api/mock-clearfolio/:jobId', (c) => { - const doc = mockArtifact(c.req.param('jobId')); - if (!doc) return c.json({ error: 'not found' }, 404); - return c.body(doc.bytes, 200, { - 'content-type': doc.mime || 'application/octet-stream', - 'content-disposition': `inline; filename="${encodeURIComponent(doc.name)}"`, - }); - }); + return next(); } -// 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(); - } -}); +app.use('/api/auth/oidc/start', requireVerifiedOidcBoundary); +app.use('/api/auth/oidc/callback', requireVerifiedOidcBoundary); +app.route('/', implementationRoutes); diff --git a/server/application_routes_implementation.mjs b/server/application_routes_implementation.mjs new file mode 100644 index 00000000..e075650e --- /dev/null +++ b/server/application_routes_implementation.mjs @@ -0,0 +1,1415 @@ +// 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 { StripeWebhookError, verifyStripeWebhookRequest } from './stripe_webhook.mjs'; +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 +import { createRateLimitMiddleware, redactRequestLogPath } from './rate_limit.mjs'; + +const GUARD_ACCOUNTING_METHOD = Symbol.for('scopeweave.guard_accounting.original_method'); +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.env?.[GUARD_ACCOUNTING_METHOD] || c.req.method, path: redactRequestLogPath(c.req.path), status: s, ms: Date.now() - t })); + } + } catch { /* metrics/logging must never break a request */ } +}); + +// Use the same validated, transport-peer-aware limiter as every supported +// wrapper. application_routes.mjs loads this module with an explicit zero +// limit, so the public wrapper remains the sole limiter for that request path. +app.use('*', createRateLimitMiddleware()); + +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, 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 only for the authenticated account named by the invite. +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 identity = db.prepare('SELECT email FROM users WHERE id = ?').get(uid); + if ( + String(inv.email || '').trim().toLowerCase() + !== String(identity?.email || '').trim().toLowerCase() + ) { + 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 authenticity only. The supported public/shared boundary mounts +// this exact core route, so the route itself remains fail-closed even if a +// future internal consumer bypasses outer composition. Authentic callbacks are +// acknowledged but never mutate entitlements directly +// (Krawczyk et al., 1997; Stripe webhook signatures). +app.post('/api/stripe/webhook', async (c) => { + try { + await verifyStripeWebhookRequest(c.req.raw, { + secret: process.env.STRIPE_WEBHOOK_SECRET, + }); + return c.json({ received: true }, 200, { 'Cache-Control': 'no-store' }); + } catch (error) { + if (error instanceof StripeWebhookError) { + return c.json({ error: error.code }, error.status, { 'Cache-Control': 'no-store' }); + } + return c.json({ error: 'stripe_webhook_unavailable' }, 500, { 'Cache-Control': 'no-store' }); + } +}); + +// 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). The +// built-in mock provider is available only when OIDC is unconfigured and +// SCOPEWEAVE_DEV=1; missing production configuration fails closed. +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 && process.env.SCOPEWEAVE_DEV === '1'; +const oidcConfigured = Boolean(OIDC.issuer) || oidcMock; +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) => { + if (!oidcConfigured) return c.json({ error: 'sso not configured' }, 404); + const origin = new URL(c.req.url).origin; + const state = randomBytes(16).toString('hex'); + const verifier = randomBytes(32).toString('base64url'); + const challenge = createHash('sha256').update(verifier).digest('base64url'); + oidcStates.set(state, { verifier, exp: Date.now() + 5 * 60 * 1000 }); + const redirectUri = OIDC.redirectUri || `${origin}/api/auth/oidc/callback`; + if (oidcMock) { + const email = c.req.query('email') || 'sso-user@example.com'; + const u = new URL(`${origin}/api/auth/oidc/mock/authorize`); + u.searchParams.set('state', state); + u.searchParams.set('email', email); + u.searchParams.set('redirect_uri', redirectUri); + return c.redirect(u.toString()); + } + const u = new URL(`${OIDC.issuer.replace(/\/$/, '')}/authorize`); + u.searchParams.set('client_id', OIDC.clientId); + u.searchParams.set('redirect_uri', redirectUri); + u.searchParams.set('response_type', 'code'); + u.searchParams.set('scope', 'openid email profile'); + u.searchParams.set('state', state); + u.searchParams.set('code_challenge', challenge); + u.searchParams.set('code_challenge_method', 'S256'); + return c.redirect(u.toString()); +}); + +// Built-in mock IdP authorize — instantly issues a code (dev/test only). +app.get('/api/auth/oidc/mock/authorize', (c) => { + if (!oidcMock) return c.json({ error: 'mock disabled' }, 404); + const state = c.req.query('state'); + const email = c.req.query('email'); + const redirectUri = c.req.query('redirect_uri'); + const code = randomBytes(16).toString('hex'); + oidcCodes.set(code, email); + const u = new URL(redirectUri); + u.searchParams.set('code', code); + u.searchParams.set('state', state); + return c.redirect(u.toString()); +}); + +app.get('/api/auth/oidc/callback', async (c) => { + const state = c.req.query('state'); + const code = c.req.query('code'); + const s = oidcStates.get(state); + if (!s || s.exp < Date.now()) return c.json({ error: 'invalid or expired state' }, 400); + oidcStates.delete(state); + let email; + if (oidcMock) { + email = oidcCodes.get(code); + oidcCodes.delete(code); + if (!email) return c.json({ error: 'invalid code' }, 400); + } else { + const redirectUri = OIDC.redirectUri || `${new URL(c.req.url).origin}/api/auth/oidc/callback`; + const tokenRes = await fetch(`${OIDC.issuer.replace(/\/$/, '')}/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: OIDC.clientId, client_secret: OIDC.clientSecret, code_verifier: s.verifier }), + }).catch(() => null); + const tok = tokenRes ? await tokenRes.json().catch(() => ({})) : {}; + if (!tok.id_token) return c.json({ error: 'token exchange failed' }, 400); + // Ceiling: verify the id_token signature via the issuer JWKS before prod. + const claims = JSON.parse(Buffer.from(String(tok.id_token).split('.')[1] || '', 'base64url').toString() || '{}'); + email = claims.email; + if (!email) return c.json({ error: 'no email claim' }, 400); + } + const user = upsertSsoUser(email); + const token = signToken({ sub: user.id, email, tv: user.token_version || 0 }); + // Return the token in the URL fragment (not query → not logged); the client + // stores it and cleans the URL. + return c.redirect(`/#token=${token}`); +}); + +// Cross-project search: project names + task names, membership-scoped (tenant +// isolation via the same JOIN as projectAccess). +// ponytail: LIKE over tasks_json text; move to FTS5 if search gets heavy. +app.get('/api/search', requireAuth, (c) => { + const uid = c.get('user').sub; + const q = String(c.req.query('q') || '').trim(); + if (q.length < 2) return c.json({ error: 'query too short (min 2)' }, 400); + const rows = db.prepare( + `SELECT DISTINCT p.id, p.name, p.tasks_json FROM projects p + JOIN memberships m ON m.org_id = p.org_id + WHERE m.user_id = ? AND (p.name LIKE ? OR p.tasks_json LIKE ?) LIMIT 100` + ).all(uid, `%${q}%`, `%${q}%`); + const needle = q.toLowerCase(); + const results = []; + for (const p of rows) { + const hit = { projectId: p.id, projectName: p.name, tasks: [] }; + if (p.name.toLowerCase().includes(needle)) hit.nameMatch = true; + let tasks = []; + try { tasks = JSON.parse(p.tasks_json); } catch { /* skip bad json */ } + for (const t of tasks) { + if (String(t.name || '').toLowerCase().includes(needle)) { + hit.tasks.push({ id: t.id, name: t.name }); + if (hit.tasks.length >= 5) break; + } + } + if (hit.nameMatch || hit.tasks.length) results.push(hit); + if (results.length >= 20) break; + } + return c.json({ query: q, results }); +}); + +// Portfolio dashboard: executive rollup across every project in a workspace — +// weighted planned/actual progress, SPI + status, overdue-task counts. +app.get('/api/orgs/:id/portfolio', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); + const today = new Date().toISOString().slice(0, 10); + const rows = db.prepare( + 'SELECT id, name, base_date AS baseDate, tasks_json, archived, updated_at AS updatedAt FROM projects WHERE org_id = ? ORDER BY archived ASC, updated_at DESC' + ).all(orgId); + const projects = rows.map((p) => { + let tasks = []; + try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } + let wSum = 0, pv = 0, ev = 0, overdue = 0; + for (const t of tasks) { + const w = Number(t.weight) || 1; + wSum += w; + pv += w * ((Number(t.plannedProgress) || 0) / 100); + ev += w * ((Number(t.actualProgress) || 0) / 100); + if (t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100) overdue++; + } + const evm = computeEvm({ pv: wSum ? pv / wSum : 0, ev: wSum ? ev / wSum : 0 }); + return { + id: p.id, + name: p.name, + archived: Boolean(p.archived), + tasks: tasks.length, + planned: Math.round(evm.pv * 1000) / 10, // % + actual: Math.round(evm.ev * 1000) / 10, // % + spi: evm.spi === null ? null : Math.round(evm.spi * 100) / 100, + status: evm.status, + label: evm.label, + overdue, + updatedAt: p.updatedAt, + }; + }); + return c.json({ projects }); +}); + +// AI 브리핑: 프로젝트 스냅샷(요약 지표 + 지연/차주 작업)을 contextual- +// orchestrator(LLM)로 보내 경영진용 리스크 분석을 생성. 원문 데이터는 서버가 +// 요약해 전송하며, LLM 자격은 서버 환경변수에만 존재. +app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + let tasks = []; + try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } + const today = new Date().toISOString().slice(0, 10); + let wSum = 0, pv = 0, ev = 0; + const late = [], upcoming = []; + for (const t of tasks) { + const w = Number(t.weight) || 1; + wSum += w; + pv += w * ((Number(t.plannedProgress) || 0) / 100); + ev += w * ((Number(t.actualProgress) || 0) / 100); + const name = t.name || t.task || t.activity || t.phase || t.id; + if (t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100) { + late.push(`${name}(계획종료 ${t.plannedEndDate}, 실적 ${Number(t.actualProgress) || 0}%${t.owner ? `, ${t.owner}` : ''})`); + } else if (t.plannedStartDate && t.plannedStartDate >= today) { + upcoming.push(`${name}(${t.plannedStartDate} 시작)`); + } + } + const pvPct = wSum ? ((pv / wSum) * 100).toFixed(1) : '0'; + const evPct = wSum ? ((ev / wSum) * 100).toFixed(1) : '0'; + const context = [ + `프로젝트: ${p.name}`, + `작업 수: ${tasks.length} · 계획진척 ${pvPct}% · 실적진척 ${evPct}%`, + `지연 작업(${late.length}): ${late.slice(0, 8).join(' / ') || '없음'}`, + `예정 작업(${upcoming.length}): ${upcoming.slice(0, 5).join(' / ') || '없음'}`, + ].join('\n'); + try { + const analysis = await orchestratorChat([ + { role: 'system', content: '너는 공정관리(schedule control) 전문가다. 주어진 프로젝트 지표를 근거로 한국어 경영진 브리핑을 작성하라: ①일정 상태 한 줄 판정 ②핵심 리스크 2~3개(근거 지표 인용) ③실행 권고 2~3개. 지표에 없는 사실은 만들지 마라.' }, + { role: 'user', content: context }, + ], { + service: 'scopeweave', + account: String(p.org_id), + }); + logAudit(p.org_id, uid, 'ai.brief', 'project', p.id, { tasks: tasks.length }); + return c.json({ analysis }); + } catch (e) { + return c.json({ error: `AI 분석 실패: ${e.message}` }, 502); + } +}); + +// 산출물 첨부(Clearfolio 통합 문서 뷰어 프록시): 업로드→변환 잡, 목록(+상태 +// 갱신), 서명 아티팩트 열람(302), 삭제. 테넌트 = 조직, 브라우저에는 Clearfolio +// 자격이 절대 노출되지 않음. +const ATTACH_MAX_BYTES = 10 * 1024 * 1024; + +const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY, +); +const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, +); +const ATTACH_STATUS_BUDGET_MS = normalizeAttachmentStatusBudgetMs( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS, +); +const ATTACHMENT_LIST_COLUMNS = `a.id, a.task_id AS taskId, a.name, a.mime, a.size, + a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy`; +const ATTACHMENT_LIST_FROM = + 'FROM attachments a LEFT JOIN users u ON u.id = a.created_by'; +const listAttachmentsStatement = db.prepare( + `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} + WHERE a.project_id = ? ORDER BY a.id DESC`, +); +const listTaskAttachmentsStatement = db.prepare( + `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} + WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`, +); +const updateAttachmentStatusStatement = db.prepare( + 'UPDATE attachments SET status = ? WHERE id = ?', +); +app.post('/api/projects/:id/attachments', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const form = await c.req.formData().catch(() => null); + const file = form?.get('file'); + if (!file || typeof file === 'string') return c.json({ error: 'multipart file required' }, 400); + const taskId = String(form.get('taskId') || ''); + if (/\.(hwp|hwpx)$/i.test(file.name || '')) return c.json({ error: 'HWP/HWPX는 지원되지 않습니다 (Clearfolio 정책)' }, 400); + if (file.size > ATTACH_MAX_BYTES) return c.json({ error: 'file too large (max 10MB)' }, 400); + const bytes = Buffer.from(await file.arrayBuffer()); + let job; + try { + job = await submitJob(p.org_id, uid, { name: file.name || 'document', mime: file.type || '', bytes }); + } catch (e) { + return c.json({ error: `문서 변환 제출 실패: ${e.message}` }, 502); + } + const aid = rowid(db.prepare( + 'INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)' + ).run(p.id, taskId, file.name || 'document', file.type || '', file.size, job.jobId, job.status, uid)); + logAudit(p.org_id, uid, 'attachment.upload', 'project', p.id, { attachmentId: aid, name: file.name, taskId: taskId || null }); + return c.json({ id: aid, status: job.status }); +}); + +app.get('/api/projects/:id/attachments', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + + const taskId = c.req.query('taskId'); + const rows = taskId + ? listTaskAttachmentsStatement.all(p.id, taskId) + : listAttachmentsStatement.all(p.id); + await refreshAttachmentStatuses(rows, { + orgId: p.org_id, + userId: uid, + jobStatus, + updateStatus: (status, attachmentId) => + updateAttachmentStatusStatement.run(status, attachmentId), + concurrency: ATTACH_STATUS_CONCURRENCY, + timeoutMs: ATTACH_STATUS_TIMEOUT_MS, + budgetMs: ATTACH_STATUS_BUDGET_MS, + metrics, + }); + const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); + return c.json({ attachments }); +}); + +// 열람: 서명 아티팩트 URL로 302. 새 탭 열기용으로 ?token=도 허용(ics/stream 패턴). +app.get('/api/projects/:id/attachments/:aid/view', (c) => { + const header = c.req.header('authorization') || ''; + const raw = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); + let uid; + if (raw.startsWith('swk_')) { + const row = db.prepare('SELECT user_id FROM api_tokens WHERE token_hash = ?').get(hashApiToken(raw)); + if (!row) return c.json({ error: 'unauthorized' }, 401); + uid = row.user_id; + } else { + try { + const payload = verifyToken(raw); + const u = db.prepare('SELECT token_version FROM users WHERE id = ?').get(payload.sub); + if (!u || (payload.tv || 0) !== u.token_version) return c.json({ error: 'unauthorized' }, 401); + uid = payload.sub; + } catch { return c.json({ error: 'unauthorized' }, 401); } + } + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const a = db.prepare('SELECT job_id, status FROM attachments WHERE id = ? AND project_id = ?').get(c.req.param('aid'), p.id); + if (!a) return c.json({ error: 'not found' }, 404); + if (a.status !== 'SUCCEEDED') return c.json({ error: `문서가 아직 준비되지 않았습니다 (${a.status})` }, 409); + return artifactUrl(p.org_id, uid, a.job_id) + .then((url) => c.redirect(url)) + .catch((e) => c.json({ error: `열람 링크 발급 실패: ${e.message}` }, 502)); +}); + +app.delete('/api/projects/:id/attachments/:aid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const a = db.prepare('SELECT created_by FROM attachments WHERE id = ? AND project_id = ?').get(c.req.param('aid'), p.id); + if (!a) return c.json({ error: 'not found' }, 404); + if (a.created_by !== uid && !canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + db.prepare('DELETE FROM attachments WHERE id = ?').run(c.req.param('aid')); + logAudit(p.org_id, uid, 'attachment.delete', 'project', p.id, { attachmentId: Number(c.req.param('aid')) }); + return c.json({ ok: true }); +}); + +// mock Clearfolio 아티팩트 서빙(dev/test 전용) +if (clearfolioMock) { + app.get('/api/mock-clearfolio/:jobId', (c) => { + const doc = mockArtifact(c.req.param('jobId')); + if (!doc) return c.json({ error: 'not found' }, 404); + return c.body(doc.bytes, 200, { + 'content-type': doc.mime || 'application/octet-stream', + 'content-disposition': `inline; filename="${encodeURIComponent(doc.name)}"`, + }); + }); +} + +// Public read-only share links: a random token grants VIEW access to one +// project (no account needed) — revocable. Never exposes org/member data. +app.post('/api/projects/:id/shares', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const token = randomBytes(18).toString('base64url'); + db.prepare('INSERT INTO share_tokens(project_id, token, created_by) VALUES(?,?,?)').run(p.id, token, uid); + logAudit(p.org_id, uid, 'share.create', 'project', p.id, {}); + return c.json({ token, url: `/?share=${token}` }); +}); + +app.get('/api/projects/:id/shares', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const shares = db.prepare( + 'SELECT id, token, created_at AS createdAt FROM share_tokens WHERE project_id = ? AND revoked = 0 ORDER BY id DESC' + ).all(p.id); + return c.json({ shares }); +}); + +app.delete('/api/projects/:id/shares/:sid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('UPDATE share_tokens SET revoked = 1 WHERE id = ? AND project_id = ? AND revoked = 0') + .run(c.req.param('sid'), p.id); + if (!info.changes) return c.json({ error: 'not found' }, 404); + logAudit(p.org_id, uid, 'share.revoke', 'project', p.id, { shareId: Number(c.req.param('sid')) }); + return c.json({ ok: true }); +}); + +// Anonymous read via share token — project content only. +app.get('/api/shared/:token', (c) => { + const row = db.prepare( + `SELECT p.name, p.base_date AS baseDate, p.tasks_json FROM share_tokens s + JOIN projects p ON p.id = s.project_id WHERE s.token = ? AND s.revoked = 0` + ).get(c.req.param('token')); + if (!row) return c.json({ error: 'not found' }, 404); + return c.json({ name: row.name, baseDate: row.baseDate, tasks: JSON.parse(row.tasks_json), readOnly: true }); +}); + +// Unseen-activity notifications: per project, count others' saves + comments +// newer than my last-seen mark. Opening a project marks it seen. +app.get('/api/notifications', requireAuth, (c) => { + const uid = c.get('user').sub; + const rows = db.prepare( + `SELECT p.id AS projectId, + (SELECT COUNT(*) FROM project_revisions r WHERE r.project_id = p.id + AND r.saved_by IS NOT NULL AND r.saved_by != ? + AND r.created_at > COALESCE(s.seen_at, '')) AS revisions, + (SELECT COUNT(*) FROM comments cm WHERE cm.project_id = p.id + AND cm.user_id IS NOT NULL AND cm.user_id != ? + AND cm.created_at > COALESCE(s.seen_at, '')) AS comments + FROM projects p + JOIN memberships m ON m.org_id = p.org_id AND m.user_id = ? + LEFT JOIN project_seen s ON s.project_id = p.id AND s.user_id = ?` + ).all(uid, uid, uid, uid); + const notifications = rows + .map((r) => ({ projectId: r.projectId, unseen: r.revisions + r.comments })) + .filter((r) => r.unseen > 0); + return c.json({ notifications }); +}); + +app.post('/api/projects/:id/seen', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + db.prepare(`INSERT INTO project_seen(project_id, user_id, seen_at) VALUES(?, ?, datetime('now')) + ON CONFLICT(project_id, user_id) DO UPDATE SET seen_at = datetime('now')`).run(p.id, uid); + return c.json({ ok: true }); +}); + +// Archive / restore a project (write roles): declutter without deleting. +app.post('/api/projects/:id/archive', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const { archived } = await c.req.json().catch(() => ({})); + const flag = archived === false ? 0 : 1; + db.prepare('UPDATE projects SET archived = ? WHERE id = ?').run(flag, p.id); + logAudit(p.org_id, uid, flag ? 'project.archive' : 'project.unarchive', 'project', p.id, {}); + return c.json({ id: p.id, archived: Boolean(flag) }); +}); + +// Duplicate a project (template use: copy tasks + base date into a new project +// in the same org). Plan caps apply like any create. +app.post('/api/projects/:id/duplicate', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + if (wouldExceed(db, getOrg(p.org_id), 'projects')) { + return c.json({ error: 'project limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.projects }, 402); + } + const { name } = await c.req.json().catch(() => ({})); + const newName = String(name || `${p.name} (복사본)`).slice(0, 120); + const nid = rowid(db.prepare('INSERT INTO projects(org_id,name,base_date,tasks_json,created_by) VALUES(?,?,?,?,?)') + .run(p.org_id, newName, p.base_date, p.tasks_json, uid)); + metrics.projectsCreated++; + logAudit(p.org_id, uid, 'project.duplicate', 'project', nid, { from: p.id, name: newName }); + return c.json({ id: nid, name: newName, version: 1 }); +}); + +// -------------------------------------------------------------- sprints +// Agile/Hybrid: 시간상자(스프린트) CRUD. 작업은 task.sprint(이름)로 배정되고 +// task.storyPoints로 추정된다 — 지표(커밋/완료 포인트, 벨로시티)는 클라이언트 +// 순수 함수(computeSprintStats)가 계산한다. +app.post('/api/projects/:id/sprints', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const { name, startDate, endDate, goal } = await c.req.json().catch(() => ({})); + if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); + const day = (v) => (/^\d{4}-\d{2}-\d{2}$/.test(String(v || '')) ? v : ''); + const sid = rowid(db.prepare('INSERT INTO sprints(project_id,name,start_date,end_date,goal) VALUES(?,?,?,?,?)') + .run(p.id, String(name).trim().slice(0, 80), day(startDate), day(endDate), String(goal || '').slice(0, 300))); + logAudit(p.org_id, uid, 'sprint.create', 'project', p.id, { sprintId: sid, name }); + return c.json({ id: sid, name: String(name).trim() }); +}); + +app.get('/api/projects/:id/sprints', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const sprints = db.prepare( + 'SELECT id, name, start_date AS startDate, end_date AS endDate, goal FROM sprints WHERE project_id = ? ORDER BY start_date, id' + ).all(p.id); + return c.json({ sprints, methodology: p.methodology || 'waterfall' }); +}); + +app.delete('/api/projects/:id/sprints/:sid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('DELETE FROM sprints WHERE id = ? AND project_id = ?').run(c.req.param('sid'), p.id); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + +// ------------------------------------------------------------- baselines +// Snapshot a project's current plan as a named baseline (schedule-control: +// compare actuals against the frozen plan later). +app.post('/api/projects/:id/baselines', requireAuth, async (c) => { + const uid = c.get('user').sub; + const id = c.req.param('id'); + const p = projectAccess(uid, id); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const { name } = await c.req.json().catch(() => ({})); + const bid = rowid(db.prepare('INSERT INTO baselines(project_id,name,base_date,tasks_json,created_by) VALUES(?,?,?,?,?)') + .run(id, String(name || 'Baseline').slice(0, 80), p.base_date, p.tasks_json, uid)); + logAudit(p.org_id, uid, 'baseline.create', 'project', id, { baselineId: bid, name }); + return c.json({ id: bid, name: name || 'Baseline' }); +}); + +app.get('/api/projects/:id/baselines', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const baselines = db.prepare( + 'SELECT id, name, base_date AS baseDate, created_at AS createdAt FROM baselines WHERE project_id = ? ORDER BY id DESC' + ).all(p.id); + return c.json({ baselines }); +}); + +app.get('/api/projects/:id/baselines/:bid', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const b = db.prepare('SELECT id, name, base_date AS baseDate, tasks_json, created_at AS createdAt FROM baselines WHERE id = ? AND project_id = ?').get(c.req.param('bid'), p.id); + if (!b) return c.json({ error: 'not found' }, 404); + return c.json({ id: b.id, name: b.name, baseDate: b.baseDate, tasks: JSON.parse(b.tasks_json), createdAt: b.createdAt }); +}); + +app.delete('/api/projects/:id/baselines/:bid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('DELETE FROM baselines WHERE id = ? AND project_id = ?').run(c.req.param('bid'), p.id); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + +// ------------------------------------------------------ account & lifecycle +// Delete a project (write roles). tasks live in the row, so this fully removes it. +app.delete('/api/projects/:id', requireAuth, (c) => { + const uid = c.get('user').sub; + const id = c.req.param('id'); + const p = projectAccess(uid, id); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + db.prepare('DELETE FROM projects WHERE id = ?').run(id); + logAudit(p.org_id, uid, 'project.delete', 'project', id, { name: p.name }); + deliver(p.org_id, 'project.delete', { projectId: Number(id) }); + return c.json({ ok: true }); +}); + +// Log out everywhere: bump token_version → every existing JWT dies. Returns a +// fresh token so THIS device stays signed in. PATs are unaffected. +app.post('/api/auth/logout-all', requireAuth, (c) => { + const uid = c.get('user').sub; + db.prepare('UPDATE users SET token_version = token_version + 1 WHERE id = ?').run(uid); + const u = db.prepare('SELECT email, token_version FROM users WHERE id = ?').get(uid); + return c.json({ ok: true, token: signToken({ sub: uid, email: u.email, tv: u.token_version }) }); +}); + +// Change password (verifies the current one). +app.post('/api/auth/change-password', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { oldPassword, newPassword } = await c.req.json().catch(() => ({})); + if (typeof newPassword !== 'string' || newPassword.length < 8) return c.json({ error: 'new password (min 8) required' }, 400); + const u = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(uid); + if (!u || typeof oldPassword !== 'string' || !verifyPassword(oldPassword, u.password_hash)) { + return c.json({ error: 'current password incorrect' }, 403); + } + db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(newPassword), uid); + return c.json({ ok: true }); +}); + +// Delete account (GDPR). Removes owned workspaces (cascading their data) and the +// user. Requires the current password to confirm. +app.delete('/api/account', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { password } = await c.req.json().catch(() => ({})); + const u = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(uid); + if (!u || typeof password !== 'string' || !verifyPassword(password, u.password_hash)) { + return c.json({ error: 'password required to delete account' }, 403); + } + db.exec('BEGIN'); + try { + db.prepare('DELETE FROM orgs WHERE owner_id = ?').run(uid); // cascades projects/members/webhooks/invites/audit + db.prepare('DELETE FROM users WHERE id = ?').run(uid); // cascades memberships/tokens + db.exec('COMMIT'); + } catch (e) { db.exec('ROLLBACK'); throw e; } + return c.json({ ok: true }); +}); + +app.get('/api/health', (c) => c.json({ ok: true })); + +// Static client — strict allowlist so server/, data.db, package.json etc. are +// never served. Anything not listed → 404. +const STATIC = { + '/': ['index.html', 'text/html; charset=utf-8'], + '/index.html': ['index.html', 'text/html; charset=utf-8'], + '/404.html': ['404.html', 'text/html; charset=utf-8'], + '/landing.html': ['landing.html', 'text/html; charset=utf-8'], + '/landing.en.html': ['landing.en.html', 'text/html; charset=utf-8'], + '/docs/api.md': ['docs/api.md', 'text/markdown; charset=utf-8'], + '/robots.txt': ['robots.txt', 'text/plain; charset=utf-8'], + '/sitemap.xml': ['sitemap.xml', 'application/xml; charset=utf-8'], + '/pricing': ['landing.html', 'text/html; charset=utf-8'], + '/app.js': ['app.js', 'text/javascript; charset=utf-8'], + '/cloud-sync.js': ['cloud-sync.js', 'text/javascript; charset=utf-8'], + '/analytics.js': ['analytics.js', 'text/javascript; charset=utf-8'], + '/styles.css': ['styles.css', 'text/css; charset=utf-8'], + '/toast-state.css': ['toast-state.css', 'text/css; charset=utf-8'], + '/wbs.json': ['wbs.json', 'application/json; charset=utf-8'], +}; +app.get('*', async (c) => { + const entry = STATIC[c.req.path]; + if (!entry) return c.notFound(); + try { + const buf = await readFile(new URL(`../${entry[0]}`, import.meta.url)); + return c.body(buf, 200, { 'Content-Type': entry[1] }); + } catch { + return c.notFound(); + } +}); From c53c87b175b81ae2aedac1423040946fdceb41d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 14:04:50 -0700 Subject: [PATCH 090/120] test: preserve moved core security contracts --- tests/unit/coverage-script-contract.test.mjs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 8d90d7ef..1373fbc0 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -9,6 +9,7 @@ const publicApp = readFileSync(new URL('../../server/app.mjs', import.meta.url), const routeImportSeam = readFileSync(new URL('../../server/app_routes.mjs', import.meta.url), 'utf8'); const applicationRoutes = readFileSync(new URL('../../server/application_routes.mjs', import.meta.url), 'utf8'); const applicationRoutesCore = readFileSync(new URL('../../server/application_routes_core.mjs', import.meta.url), 'utf8'); +const applicationRoutesImplementation = readFileSync(new URL('../../server/application_routes_implementation.mjs', import.meta.url), 'utf8'); const rateLimitModule = readFileSync(new URL('../../server/rate_limit.mjs', import.meta.url), 'utf8'); assert.match( @@ -143,7 +144,12 @@ assert.match( assert.match( scripts['test:coverage'], /--include=server\/application_routes_core\.mjs/, - 'the protected application implementation remains owned-production coverage', + 'the direct-core fail-closed boundary remains owned-production coverage', +); +assert.match( + scripts['test:coverage'], + /--include=server\/application_routes_implementation\.mjs/, + 'the protected application implementation remains owned-production coverage after the boundary split', ); assert.match( scripts['test:coverage'], @@ -202,11 +208,16 @@ assert.match( ); assert.match( applicationRoutesCore, + /app\.use\(\s*['"]\/api\/auth\/oidc\/start['"]\s*,\s*requireVerifiedOidcBoundary\s*\)[\s\S]*app\.use\(\s*['"]\/api\/auth\/oidc\/callback['"]\s*,\s*requireVerifiedOidcBoundary\s*\)[\s\S]*app\.route\(\s*['"]\/['"]\s*,\s*implementationRoutes\s*\)/, + 'the direct-core boundary fails production OIDC closed before delegating to the legacy implementation graph', +); +assert.match( + applicationRoutesImplementation, /verifyStripeWebhookRequest/, - 'the protected Stripe route is fail-closed so every supported mount verifies raw-body authenticity', + 'the protected Stripe route remains fail-closed in the delegated implementation graph', ); assert.doesNotMatch( - applicationRoutesCore, + applicationRoutesImplementation, /checkout\.session\.completed[\s\S]{0,500}UPDATE orgs SET plan = 'pro'/, 'checkout.session.completed JSON is never authority to upgrade orgs.plan', ); From 58695e4dc011d2f04035cb47040e78e907c28074 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 14:24:15 -0700 Subject: [PATCH 091/120] fix(auth): preserve guard rejection observability --- server/application_routes_core.mjs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/server/application_routes_core.mjs b/server/application_routes_core.mjs index 3c7522a0..8ee15d88 100644 --- a/server/application_routes_core.mjs +++ b/server/application_routes_core.mjs @@ -12,18 +12,27 @@ import { app as implementationRoutes } from './application_routes_implementation */ export const app = new Hono(); +const GUARD_ACCOUNTING_METHOD = Symbol.for('scopeweave.guard_accounting.original_method'); const coreOidcMockEnabled = process.env.SCOPEWEAVE_DEV === '1' && !process.env.OIDC_ISSUER; /** * Reject direct production OIDC start/callback requests before legacy handlers. * + * The supported wrapper uses a non-mutating OPTIONS probe carrying a private + * symbol when a wrapper-level guard rejects a request. Forward only that probe + * into the implementation graph so its request counters and structured logger + * observe the rejection without reopening the legacy production OIDC handlers. + * * @param {import('hono').Context} c Current Hono request context. * @param {() => Promise} next Continues only for the development mock. * @returns {Promise} A non-cacheable 404 or downstream result. */ async function requireVerifiedOidcBoundary(c, next) { if (!coreOidcMockEnabled) { + if (c.req.method === 'OPTIONS' && c.env?.[GUARD_ACCOUNTING_METHOD]) { + return implementationRoutes.request(c.req.url, { method: 'OPTIONS' }, c.env); + } return c.json( { error: 'sso not configured' }, 404, From 4bc5fc3b1f6de85d0573b90123dc1ae337a8bb63 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 14:25:38 -0700 Subject: [PATCH 092/120] test(auth): reuse mixed-case OIDC accounts --- tests/api/oidc-production-boundary.test.mjs | 42 +++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/api/oidc-production-boundary.test.mjs b/tests/api/oidc-production-boundary.test.mjs index a7f7d530..75f1824b 100644 --- a/tests/api/oidc-production-boundary.test.mjs +++ b/tests/api/oidc-production-boundary.test.mjs @@ -109,6 +109,7 @@ const productionIdentityRegression = String.raw` }; const { app: configuredRoutes } = await import('./server/application_routes.mjs?oidc-production-token-regression=1'); + const { db, rowid } = await import('./server/db.mjs'); const begin = async () => { const start = await configuredRoutes.request('https://scopeweave.example/api/auth/oidc/start'); assert.equal(start.status, 302, 'configured OIDC starts the authorization-code flow'); @@ -188,6 +189,47 @@ const productionIdentityRegression = String.raw` const replay = await callback(valid.state, 'replayed-code'); assert.equal(replay.status, 400, 'OIDC state is single-use after a successful callback'); + const legacyUserId = rowid(db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') + .run('Legacy.User@Example.com', 'legacy-password-hash', 'Legacy User')); + const legacyOrgId = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)') + .run('Legacy workspace', legacyUserId)); + db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)') + .run(legacyOrgId, legacyUserId, 'owner'); + const usersBeforeCaseLink = db.prepare('SELECT COUNT(*) AS count FROM users').get().count; + const orgsBeforeCaseLink = db.prepare('SELECT COUNT(*) AS count FROM orgs').get().count; + + const legacyCase = await begin(); + issuedIdToken = signIdToken({ + iss: process.env.OIDC_ISSUER, + aud: process.env.OIDC_CLIENT_ID, + exp: now + 300, + iat: now, + nonce: legacyCase.nonce, + sub: 'legacy-verified-subject', + email_verified: true, + email: 'legacy.user@example.com', + }); + result = await callback(legacyCase.state, 'legacy-case-code'); + assert.equal(result.status, 302, 'verified OIDC email reuses an existing account regardless of stored email case'); + const sessionToken = new URL(result.headers.get('location'), 'https://scopeweave.example').hash.slice('#token='.length); + const sessionPayload = JSON.parse(Buffer.from(sessionToken.split('.')[1], 'base64url').toString('utf8')); + assert.equal(sessionPayload.sub, legacyUserId, 'OIDC session remains bound to the pre-existing account'); + assert.equal( + db.prepare('SELECT COUNT(*) AS count FROM users').get().count, + usersBeforeCaseLink, + 'case-only identity differences never create a duplicate user', + ); + assert.equal( + db.prepare('SELECT COUNT(*) AS count FROM orgs').get().count, + orgsBeforeCaseLink, + 'reusing an existing case-variant account never creates a second personal workspace', + ); + assert.equal( + db.prepare('SELECT email FROM users WHERE id = ?').get(legacyUserId).email, + 'Legacy.User@Example.com', + 'account linking does not silently rewrite the stored login identifier', + ); + const wrongAudience = await begin(); issuedIdToken = signIdToken({ iss: process.env.OIDC_ISSUER, From efd92807cb13a0e78da2812f2c958164d7604421 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 14:30:04 -0700 Subject: [PATCH 093/120] fix(auth): reuse case-variant OIDC accounts --- server/application_routes.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/application_routes.mjs b/server/application_routes.mjs index 5235d897..ec73cc86 100644 --- a/server/application_routes.mjs +++ b/server/application_routes.mjs @@ -210,7 +210,7 @@ async function verifyProductionOidcIdentity(idToken, expectedNonce) { } function upsertProductionSsoUser(email) { - let user = db.prepare('SELECT id, email, token_version FROM users WHERE email = ?').get(email); + let user = db.prepare('SELECT id, email, token_version FROM users WHERE lower(email) = ?').get(email); if (user) return user; db.exec('BEGIN'); From c542615f3746ca365a51711e91ffec3477a5025e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 06:36:15 +0900 Subject: [PATCH 094/120] docs: clarify orchestrator token mapping --- docs/deploy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/deploy.md b/docs/deploy.md index 97da0c7b..4321917e 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -38,7 +38,7 @@ persists the database in the `scopeweave-data` volume. | `STRIPE_SECRET_KEY`, `STRIPE_PRICE_ID`, `STRIPE_WEBHOOK_SECRET` | for live billing | Enables real Stripe Checkout (`npm i stripe` too). Without them, billing uses the mock path. | | `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI` | for real SSO | Points the OIDC login at your IdP. Outside explicit `SCOPEWEAVE_DEV=1`, a missing issuer fails closed with `404 sso not configured`; the built-in mock exists only when the issuer is unset **and** development mode is explicitly enabled. | | `ORCHESTRATOR_URL` | for AI briefing | contextual-orchestrator origin. A missing URL fails closed outside explicit `SCOPEWEAVE_DEV=1`; the deterministic mock exists only in development mode. | -| `ORCHESTRATOR_TOKEN` | with URL | Required Bearer token for configured orchestrator requests (`CONTEXTUAL_ORCHESTRATOR_TOKEN`). | +| `ORCHESTRATOR_TOKEN` | with URL | Required Bearer token for configured orchestrator requests. Set it to the same value as the orchestrator's `CONTEXTUAL_ORCHESTRATOR_TOKEN` setting. | | `CLEARFOLIO_URL` | for 산출물 viewer | Clearfolio 문서 뷰어 백엔드 주소. Unset → built-in mock (dev/test). | | `CLEARFOLIO_HMAC_SECRET` | optional | Signs tenant-claim headers (`clearfolio.tenant-claims.hmac-secret`와 동일 값). | | `SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY` | no (default 8, maximum 32) | Maximum concurrent Clearfolio status lookups during one attachment-list request. Invalid values fall back to 8; values above 32 are clamped. | From 50c74dd160b3fd69bae8f55b56a824e932f80221 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 16:55:44 -0700 Subject: [PATCH 095/120] test(ci): require dependency review merge-base scope --- ...ndency-review-merge-base-contract.test.mjs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 tests/unit/dependency-review-merge-base-contract.test.mjs diff --git a/tests/unit/dependency-review-merge-base-contract.test.mjs b/tests/unit/dependency-review-merge-base-contract.test.mjs new file mode 100644 index 00000000..60649414 --- /dev/null +++ b/tests/unit/dependency-review-merge-base-contract.test.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const dependencyWorkflow = readFileSync( + new URL('../../.github/workflows/dependency-review.yml', import.meta.url), + 'utf8', +); + +assert.match( + dependencyWorkflow, + /compare\/\$\{base_ref_encoded\}\.\.\.\$\{HEAD_SHA\}/, + 'dependency review resolves the PR merge base from the named base ref and exact contributor head', +); +assert.match( + dependencyWorkflow, + /\.merge_base_commit\.sha\s*\|\s*select\(test\("\^\[0-9a-f\]\{40\}\$"\)\)/, + 'dependency review validates the compare API merge-base SHA before publishing it', +); +assert.match( + dependencyWorkflow, + /echo "base_sha=\$BASE_SHA" >>"\$GITHUB_OUTPUT"/, + 'dependency review publishes the validated merge base for the dependency action', +); +assert.match( + dependencyWorkflow, + /base-ref: \$\{\{ steps\.dependency_review_support\.outputs\.base_sha \}\}/, + 'dependency-review-action compares from the validated merge base', +); + +console.log('dependency review merge-base contract passed'); From 67fd3fe12f8989f8985bfc9f46518b7d3dd3b3a4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 16:56:12 -0700 Subject: [PATCH 096/120] test(ci): run dependency merge-base regression --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 7e908467..3eb686ff 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/invite-security.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-core-production-failclosed.test.mjs && node tests/api/security-guard-abuse-controls.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/rate-limit-prometheus.test.mjs && node tests/api/rate-limit-shared-boundary.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/toast-accessibility.test.mjs", + "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/dependency-review-merge-base-contract.test.mjs && node tests/unit/stripe-webhook-boundary.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/rate_limit.mjs --include=server/application_routes.mjs --include=server/application_routes_core.mjs --include=server/application_routes_implementation.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && npm run test:api", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/dependency-review-merge-base-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && 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 199b81175e70a8e292c411d2a73fabd58ea52d74 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 16:57:17 -0700 Subject: [PATCH 097/120] fix(ci): scope dependency review to PR merge base --- .github/workflows/dependency-review.yml | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 28cca6d5..3b1d405d 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -53,7 +53,8 @@ jobs: api_url="${GITHUB_API_URL:-https://api.github.com}" response_file="$(mktemp)" base_response_file="$(mktemp)" - trap 'rm -f "$response_file" "$base_response_file"' EXIT + compare_response_file="$(mktemp)" + trap 'rm -f "$response_file" "$base_response_file" "$compare_response_file"' EXIT base_ref_encoded="$(python3 -c 'import os, urllib.parse; print(urllib.parse.quote(os.environ["BASE_REF"], safe="/"))')" base_status="$( @@ -70,10 +71,30 @@ jobs: exit 1 fi - BASE_SHA="$(jq -er '.commit.sha | select(test("^[0-9a-f]{40}$"))' "$base_response_file")" || { + LIVE_BASE_SHA="$(jq -er '.commit.sha | select(test("^[0-9a-f]{40}$"))' "$base_response_file")" || { echo "::error::Live base branch response did not contain a valid commit SHA." exit 1 } + printf 'live_base_sha=%s\n' "$LIVE_BASE_SHA" + + compare_status="$( + curl -sS -o "$compare_response_file" -w '%{http_code}' \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "${api_url}/repos/${REPOSITORY}/compare/${base_ref_encoded}...${HEAD_SHA}" \ + || true + )" + + if [ "$compare_status" != "200" ]; then + echo "::error::Unable to resolve the pull-request merge base for ${BASE_REF}...${HEAD_SHA}; GitHub API returned HTTP ${compare_status}." + exit 1 + fi + + BASE_SHA="$(jq -er '.merge_base_commit.sha | select(test("^[0-9a-f]{40}$"))' "$compare_response_file")" || { + echo "::error::Compare response did not contain a valid merge-base SHA." + exit 1 + } echo "base_sha=$BASE_SHA" >>"$GITHUB_OUTPUT" status="$( From e98cae80910bcff1957a475704ce4fd18e56e1fd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 16:58:10 -0700 Subject: [PATCH 098/120] test(rate-limit): preserve metrics response on fold failure --- .../rate-limit-observability-failure.test.mjs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/unit/rate-limit-observability-failure.test.mjs diff --git a/tests/unit/rate-limit-observability-failure.test.mjs b/tests/unit/rate-limit-observability-failure.test.mjs new file mode 100644 index 00000000..563d516b --- /dev/null +++ b/tests/unit/rate-limit-observability-failure.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +const { createRateLimitObservability } = await import('../../server/rate_limit.mjs'); + +const hooks = createRateLimitObservability(); +hooks.onBlocked( + { req: { method: 'GET', path: '/api/health' } }, + { startedAt: Date.now() }, +); + +const originalResponse = new Response('{malformed-json', { + status: 200, + headers: { 'content-type': 'application/json' }, +}); +const context = { + req: { + path: '/api/metrics', + query: () => undefined, + }, + res: originalResponse, +}; + +await assert.doesNotReject( + () => hooks.afterNext(context), + 'metrics-folding failures never turn a successful request into an exception', +); +assert.equal( + context.res, + originalResponse, + 'a failed metrics fold leaves the original successful response object in place', +); +assert.equal( + await originalResponse.text(), + '{malformed-json', + 'a failed metrics fold reads only a clone so the original response body remains consumable', +); + +console.log('rate-limit observability failure isolation passed'); From f6bd5ea10491cd626557960b40a44234d4fd3c16 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 16:58:43 -0700 Subject: [PATCH 099/120] test(rate-limit): run observability failure regression --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 3eb686ff..57a99093 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/invite-security.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-core-production-failclosed.test.mjs && node tests/api/security-guard-abuse-controls.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/rate-limit-prometheus.test.mjs && node tests/api/rate-limit-shared-boundary.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/dependency-review-merge-base-contract.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/toast-accessibility.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/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/dependency-review-merge-base-contract.test.mjs && node tests/unit/rate-limit-observability-failure.test.mjs && node tests/unit/stripe-webhook-boundary.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/rate_limit.mjs --include=server/application_routes.mjs --include=server/application_routes_core.mjs --include=server/application_routes_implementation.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.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/dependency-review-merge-base-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && npm run test:api", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/dependency-review-merge-base-contract.test.mjs && node tests/unit/rate-limit-observability-failure.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && 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 d3ea2b227b1cb6a815bc7be6f809c5ac4af09d87 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 17:49:25 -0700 Subject: [PATCH 100/120] fix(rate-limit): isolate metrics folding failures --- server/rate_limit.mjs | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/server/rate_limit.mjs b/server/rate_limit.mjs index 143d285f..6b45c554 100644 --- a/server/rate_limit.mjs +++ b/server/rate_limit.mjs @@ -115,23 +115,29 @@ export function createRateLimitObservability() { async afterNext(c) { if (c.req.path !== '/api/metrics' || c.res.status !== 200 || rateLimitedRequests === 0) return; - const headers = new Headers(c.res.headers); - headers.delete('content-length'); - if (c.req.query('format') === 'prometheus') { - const text = await c.res.text(); - const adjusted = text.split('\n').map((line) => { - const match = /^(scopeweave_(?:requests|s4xx))\s+(-?\d+(?:\.\d+)?)$/u.exec(line); - if (!match) return line; - return `${match[1]} ${Number(match[2]) + rateLimitedRequests}`; - }).join('\n'); - c.res = new Response(adjusted, { status: 200, headers }); - return; + const originalResponse = c.res; + try { + const headers = new Headers(originalResponse.headers); + headers.delete('content-length'); + if (c.req.query('format') === 'prometheus') { + const text = await originalResponse.clone().text(); + const adjusted = text.split('\n').map((line) => { + const match = /^(scopeweave_(?:requests|s4xx))\s+(-?\d+(?:\.\d+)?)$/u.exec(line); + if (!match) return line; + return `${match[1]} ${Number(match[2]) + rateLimitedRequests}`; + }).join('\n'); + c.res = new Response(adjusted, { status: 200, headers }); + return; + } + + const snapshot = await originalResponse.clone().json(); + if (typeof snapshot?.requests === 'number') snapshot.requests += rateLimitedRequests; + if (typeof snapshot?.s4xx === 'number') snapshot.s4xx += rateLimitedRequests; + c.res = new Response(JSON.stringify(snapshot), { status: 200, headers }); + } catch { + // Observability folding must never break or consume a successful response. + c.res = originalResponse; } - - const snapshot = await c.res.json(); - if (typeof snapshot?.requests === 'number') snapshot.requests += rateLimitedRequests; - if (typeof snapshot?.s4xx === 'number') snapshot.s4xx += rateLimitedRequests; - c.res = new Response(JSON.stringify(snapshot), { status: 200, headers }); }, }); } From 0c38ee446c9cedd33e945ab866cb92cd503fdb17 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 17:54:11 -0700 Subject: [PATCH 101/120] test(auth): define canonical email identity contract --- tests/api/email-identity.test.mjs | 161 ++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 tests/api/email-identity.test.mjs diff --git a/tests/api/email-identity.test.mjs b/tests/api/email-identity.test.mjs new file mode 100644 index 00000000..b273e916 --- /dev/null +++ b/tests/api/email-identity.test.mjs @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_DEV = '1'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +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 request = (path, options = {}) => app.request(path, { + ...options, + headers: { + 'content-type': 'application/json', + ...(options.headers || {}), + }, +}); + +const jsonBody = (value) => JSON.stringify(value); + +let response = await request('/api/auth/signup', { + method: 'POST', + body: jsonBody({ + email: ' User@Example.COM ', + password: 'password123', + name: 'Canonical User', + }), +}); +assert.equal(response.status, 200, 'mixed-case signup succeeds'); +const { token } = await response.json(); +assert.ok(token, 'signup returns a session token'); + +response = await request('/api/me', { + headers: { authorization: `Bearer ${token}` }, +}); +assert.equal(response.status, 200, 'new account is readable'); +assert.equal( + (await response.json()).user.email, + 'user@example.com', + 'new accounts persist the canonical trimmed lowercase email identity', +); + +response = await request('/api/auth/login', { + method: 'POST', + body: jsonBody({ email: 'USER@EXAMPLE.COM', password: 'password123' }), +}); +assert.equal(response.status, 200, 'login resolves the same mailbox case-insensitively'); + +response = await request('/api/auth/signup', { + method: 'POST', + body: jsonBody({ email: 'user@example.com', password: 'password123' }), +}); +assert.equal(response.status, 409, 'canonical-equivalent signup cannot create a second identity'); + +function seedLegacyUsers(rows) { + const directory = mkdtempSync(join(tmpdir(), 'scopeweave-email-identity-')); + const databasePath = join(directory, 'legacy.sqlite'); + const database = new DatabaseSync(databasePath); + database.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')) + ); + `); + const insert = database.prepare( + 'INSERT INTO users(id,email,password_hash,name,token_version) VALUES(?,?,?,?,0)', + ); + for (const row of rows) insert.run(row.id, row.email, 'salt:hash', row.name || ''); + database.close(); + return { directory, databasePath }; +} + +function importDatabase(databasePath) { + return spawnSync( + process.execPath, + ['--input-type=module', '--eval', "await import('./server/db.mjs')"], + { + cwd: process.cwd(), + env: { ...process.env, SCOPEWEAVE_DB: databasePath }, + encoding: 'utf8', + }, + ); +} + +{ + const { directory, databasePath } = seedLegacyUsers([ + { id: 1, email: ' Legacy.User@Example.COM ', name: 'Legacy User' }, + ]); + try { + const result = importDatabase(databasePath); + assert.equal( + result.status, + 0, + `unambiguous legacy email migration succeeds: ${result.stderr}`, + ); + + const database = new DatabaseSync(databasePath); + assert.equal( + database.prepare('SELECT email FROM users WHERE id = 1').get().email, + 'legacy.user@example.com', + 'unambiguous legacy identities are canonicalized during migration', + ); + const index = database.prepare( + "SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?", + ).get('users_email_canonical_unique'); + assert.match( + String(index?.sql || ''), + /UNIQUE\s+INDEX[\s\S]*lower\s*\(\s*trim\s*\(\s*email\s*\)\s*\)/iu, + 'database enforces one canonical mailbox identity even for direct writes', + ); + database.close(); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +{ + const { directory, databasePath } = seedLegacyUsers([ + { id: 1, email: 'Collision@Example.COM', name: 'First' }, + { id: 2, email: 'collision@example.com', name: 'Second' }, + ]); + try { + const result = importDatabase(databasePath); + assert.notEqual( + result.status, + 0, + 'legacy canonical-email collisions fail startup rather than auto-merging tenant identities', + ); + assert.match( + result.stderr, + /canonical email collision/i, + 'startup failure explains the operator remediation boundary', + ); + + const database = new DatabaseSync(databasePath); + assert.deepEqual( + database.prepare('SELECT id,email FROM users ORDER BY id').all(), + [ + { id: 1, email: 'Collision@Example.COM' }, + { id: 2, email: 'collision@example.com' }, + ], + 'failed migration leaves colliding legacy identities untouched for explicit remediation', + ); + database.close(); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +console.log('email canonical identity contract passed'); From f4ceb0864dd7d281ddb38191896d37677e5432a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 17:55:31 -0700 Subject: [PATCH 102/120] test(auth): run canonical email identity contract --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 57a99093..8ea16561 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/invite-security.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-core-production-failclosed.test.mjs && node tests/api/security-guard-abuse-controls.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/rate-limit-prometheus.test.mjs && node tests/api/rate-limit-shared-boundary.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/email-identity.test.mjs && node tests/api/smoke.mjs && node tests/api/invite-security.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-core-production-failclosed.test.mjs && node tests/api/security-guard-abuse-controls.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/rate-limit-prometheus.test.mjs && node tests/api/rate-limit-shared-boundary.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/dependency-review-merge-base-contract.test.mjs && node tests/unit/rate-limit-observability-failure.test.mjs && node tests/unit/stripe-webhook-boundary.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/rate_limit.mjs --include=server/application_routes.mjs --include=server/application_routes_core.mjs --include=server/application_routes_implementation.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.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/dependency-review-merge-base-contract.test.mjs && node tests/unit/rate-limit-observability-failure.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && npm run test:api", From 8c1f6668b86b57dff7158ba8194ae9c88d7ec6e4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 17:59:35 -0700 Subject: [PATCH 103/120] fix(auth): migrate canonical email identities --- server/db.mjs | 47 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/server/db.mjs b/server/db.mjs index 122b70d6..ed5534b7 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -172,10 +172,55 @@ 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). +// Migration for pre-existing DBs: add columns introduced after the initial schema. 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 */ } +/** + * Canonicalize legacy mailbox identities without guessing how duplicate users + * should be merged across tenant-owned records. + * + * Existing databases may contain emails that differ only by case or surrounding + * whitespace because SQLite's default UNIQUE collation is case-sensitive. The + * migration changes unambiguous identities in one transaction and adds an + * expression index so direct writes cannot recreate that split. If two existing + * user IDs collapse to the same mailbox, startup fails before modifying either + * identity; an operator must reconcile the accounts explicitly. + */ +function migrateCanonicalUserEmails() { + db.exec('BEGIN IMMEDIATE'); + try { + const collision = db.prepare(` + SELECT lower(trim(email)) AS canonical_email, + COUNT(*) AS identity_count, + group_concat(id) AS user_ids + FROM users + GROUP BY lower(trim(email)) + HAVING COUNT(*) > 1 + LIMIT 1 + `).get(); + if (collision) { + throw new Error( + `canonical email collision for ${collision.canonical_email} across user ids ${collision.user_ids}; resolve duplicate identities before restart`, + ); + } + + db.exec(` + UPDATE users + SET email = lower(trim(email)) + WHERE email <> lower(trim(email)); + CREATE UNIQUE INDEX IF NOT EXISTS users_email_canonical_unique + ON users(lower(trim(email))); + `); + db.exec('COMMIT'); + } catch (error) { + try { db.exec('ROLLBACK'); } catch { /* preserve the causal migration error */ } + throw error; + } +} + +migrateCanonicalUserEmails(); + // node:sqlite returns lastInsertRowid as number|bigint; normalize to Number. export const rowid = (r) => Number(r.lastInsertRowid); From 4a47e15b0f3e115eef75932573e691fe398edbfe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 18:04:02 -0700 Subject: [PATCH 104/120] fix(auth): canonicalize password identities at shared boundary --- server/application_routes_core.mjs | 37 ++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/server/application_routes_core.mjs b/server/application_routes_core.mjs index 8ee15d88..5f8d9376 100644 --- a/server/application_routes_core.mjs +++ b/server/application_routes_core.mjs @@ -16,6 +16,41 @@ const GUARD_ACCOUNTING_METHOD = Symbol.for('scopeweave.guard_accounting.original const coreOidcMockEnabled = process.env.SCOPEWEAVE_DEV === '1' && !process.env.OIDC_ISSUER; +/** + * Forward password-auth requests with one canonical mailbox identity. + * + * The implementation graph predates the invitation and production OIDC email + * normalization contract. Canonicalizing at this low-level shared boundary + * guarantees that signup duplicate checks, stored identities, login lookups, + * workspace labels, and signed session claims all receive the same trimmed + * lowercase email. `db.mjs` separately migrates legacy rows and enforces a + * canonical unique index, so direct database writes cannot recreate a split + * identity. + * + * @param {import('hono').Context} c Current Hono request context. + * @returns {Promise} Response from the implementation route graph. + */ +async function forwardCanonicalPasswordAuth(c) { + const parsed = await c.req.json().catch(() => ({})); + const payload = parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed + : {}; + const email = String(payload.email ?? '').trim().toLowerCase(); + const headers = new Headers(c.req.raw.headers); + headers.set('content-type', 'application/json'); + headers.delete('content-length'); + + return implementationRoutes.request( + c.req.url, + { + method: 'POST', + headers, + body: JSON.stringify({ ...payload, email }), + }, + c.env, + ); +} + /** * Reject direct production OIDC start/callback requests before legacy handlers. * @@ -42,6 +77,8 @@ async function requireVerifiedOidcBoundary(c, next) { return next(); } +app.post('/api/auth/signup', forwardCanonicalPasswordAuth); +app.post('/api/auth/login', forwardCanonicalPasswordAuth); app.use('/api/auth/oidc/start', requireVerifiedOidcBoundary); app.use('/api/auth/oidc/callback', requireVerifiedOidcBoundary); app.route('/', implementationRoutes); From ede4d138ed034bd5ba786c4ddc3b2dc455c6c245 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 18:06:59 -0700 Subject: [PATCH 105/120] test(auth): compare legacy rows by value --- tests/api/email-identity.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/api/email-identity.test.mjs b/tests/api/email-identity.test.mjs index b273e916..6846a925 100644 --- a/tests/api/email-identity.test.mjs +++ b/tests/api/email-identity.test.mjs @@ -145,7 +145,8 @@ function importDatabase(databasePath) { const database = new DatabaseSync(databasePath); assert.deepEqual( - database.prepare('SELECT id,email FROM users ORDER BY id').all(), + database.prepare('SELECT id,email FROM users ORDER BY id').all() + .map((row) => ({ id: row.id, email: row.email })), [ { id: 1, email: 'Collision@Example.COM' }, { id: 2, email: 'collision@example.com' }, From b251c6cacb34fa53ab88e26c77c9a54a9283c0b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 18:20:51 -0700 Subject: [PATCH 106/120] test(security): reproduce current identity and OIDC review findings --- package.json | 4 +- tests/api/email-unicode-identity.test.mjs | 67 +++++++++++++++ tests/api/oidc-provider-metadata.test.mjs | 81 +++++++++++++++++++ ...ndency-review-merge-base-contract.test.mjs | 7 +- tests/unit/rate-limit-scoped-ipv6.test.mjs | 35 ++++++++ 5 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 tests/api/email-unicode-identity.test.mjs create mode 100644 tests/api/oidc-provider-metadata.test.mjs create mode 100644 tests/unit/rate-limit-scoped-ipv6.test.mjs diff --git a/package.json b/package.json index 8ea16561..fb9fddf1 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "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/email-identity.test.mjs && node tests/api/smoke.mjs && node tests/api/invite-security.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-core-production-failclosed.test.mjs && node tests/api/security-guard-abuse-controls.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/rate-limit-prometheus.test.mjs && node tests/api/rate-limit-shared-boundary.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/dependency-review-merge-base-contract.test.mjs && node tests/unit/rate-limit-observability-failure.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/toast-accessibility.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/email-identity.test.mjs && node tests/api/email-unicode-identity.test.mjs && node tests/api/smoke.mjs && node tests/api/invite-security.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-provider-metadata.test.mjs && node tests/api/oidc-core-production-failclosed.test.mjs && node tests/api/security-guard-abuse-controls.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/rate-limit-prometheus.test.mjs && node tests/api/rate-limit-shared-boundary.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/dependency-review-merge-base-contract.test.mjs && node tests/unit/rate-limit-observability-failure.test.mjs && node tests/unit/rate-limit-scoped-ipv6.test.mjs && node tests/unit/stripe-webhook-boundary.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/rate_limit.mjs --include=server/application_routes.mjs --include=server/application_routes_core.mjs --include=server/application_routes_implementation.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.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/dependency-review-merge-base-contract.test.mjs && node tests/unit/rate-limit-observability-failure.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && npm run test:api", "test:e2e": "playwright test", diff --git a/tests/api/email-unicode-identity.test.mjs b/tests/api/email-unicode-identity.test.mjs new file mode 100644 index 00000000..c2fe3f41 --- /dev/null +++ b/tests/api/email-unicode-identity.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; + +function seed(rows) { + const directory = mkdtempSync(join(tmpdir(), 'scopeweave-unicode-email-')); + const databasePath = join(directory, 'legacy.sqlite'); + const database = new DatabaseSync(databasePath); + database.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')) + )`); + const insert = database.prepare('INSERT INTO users(id,email,password_hash,name) VALUES(?,?,?,?)'); + for (const row of rows) insert.run(row.id, row.email, 'salt:hash', row.name || ''); + database.close(); + return { directory, databasePath }; +} + +function migrate(databasePath) { + return spawnSync(process.execPath, ['--input-type=module', '--eval', ` + process.env.SCOPEWEAVE_DB = ${JSON.stringify(databasePath)}; + const { db } = await import('./server/db.mjs'); + const rows = db.prepare('SELECT id,email FROM users ORDER BY id').all().map(({id,email}) => ({id,email})); + let duplicateBlocked = false; + try { + db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') + .run('ÄLICE@EXAMPLE.COM', 'salt:hash', 'Duplicate'); + } catch { duplicateBlocked = true; } + console.log(JSON.stringify({ rows, duplicateBlocked })); + `], { cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } }); +} + +{ + const { directory, databasePath } = seed([{ id: 1, email: ' ÄLICE@Example.COM ', name: 'Legacy' }]); + try { + const result = migrate(databasePath); + assert.equal(result.status, 0, `unicode legacy migration succeeds: ${result.stderr}`); + const snapshot = JSON.parse(result.stdout.trim().split('\n').at(-1)); + assert.deepEqual(snapshot.rows, [{ id: 1, email: 'älice@example.com' }], 'legacy email canonicalization uses the same Unicode-aware JavaScript algorithm as runtime auth'); + assert.equal(snapshot.duplicateBlocked, true, 'database uniqueness rejects a canonical-equivalent Unicode mailbox after migration'); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +{ + const { directory, databasePath } = seed([ + { id: 1, email: 'Älice@example.com', name: 'First' }, + { id: 2, email: 'älice@example.com', name: 'Second' }, + ]); + try { + const result = migrate(databasePath); + assert.notEqual(result.status, 0, 'Unicode canonical collisions fail startup instead of silently choosing an account'); + assert.match(result.stderr, /canonical email collision/i); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +console.log('unicode email identity migration contract passed'); diff --git a/tests/api/oidc-provider-metadata.test.mjs b/tests/api/oidc-provider-metadata.test.mjs new file mode 100644 index 00000000..775f360d --- /dev/null +++ b/tests/api/oidc-provider-metadata.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { generateKeyPairSync, sign } from 'node:crypto'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_DEV = '1'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.OIDC_ISSUER = 'https://issuer.example/tenant'; +process.env.OIDC_CLIENT_ID = 'scopeweave-client'; +process.env.OIDC_CLIENT_SECRET = 'scopeweave-secret'; +process.env.OIDC_REDIRECT_URI = 'https://scopeweave.example/api/auth/oidc/callback'; + +const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); +const publicJwk = { ...publicKey.export({ format: 'jwk' }), kid: 'metadata-key', use: 'sig', alg: 'RS256' }; +const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); +let issuedIdToken = ''; + +function signedIdentity(nonce, email = 'sso.user@example.com') { + const now = Math.floor(Date.now() / 1000); + const header = encode({ alg: 'RS256', typ: 'JWT', kid: publicJwk.kid }); + const payload = encode({ + iss: process.env.OIDC_ISSUER, + aud: process.env.OIDC_CLIENT_ID, + exp: now + 300, + iat: now, + nonce, + sub: 'subject-1', + email_verified: true, + email, + }); + const input = `${header}.${payload}`; + return `${input}.${sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`; +} + +globalThis.fetch = async (url) => { + const target = String(url); + if (target === 'https://issuer.example/tenant/.well-known/openid-configuration') { + return Response.json({ + issuer: process.env.OIDC_ISSUER, + authorization_endpoint: 'https://login.example/oauth2/v2/authorize', + token_endpoint: 'https://tokens.example/oauth2/v2/token', + jwks_uri: 'https://keys.example/oidc/jwks.json', + }); + } + if (target === 'https://tokens.example/oauth2/v2/token') { + return Response.json({ id_token: issuedIdToken }); + } + if (target === 'https://keys.example/oidc/jwks.json') return Response.json({ keys: [publicJwk] }); + throw new Error(`unexpected OIDC fetch: ${target}`); +}; + +const { app } = await import('../../server/application_routes.mjs?provider-metadata=1'); + +async function begin() { + const response = await app.request('https://scopeweave.example/api/auth/oidc/start'); + assert.equal(response.status, 302, 'complete production OIDC remains active even in development mode'); + const authorization = new URL(response.headers.get('location')); + assert.equal(authorization.origin, 'https://login.example'); + assert.equal(authorization.pathname, '/oauth2/v2/authorize', 'authorization endpoint comes from validated discovery metadata'); + return { state: authorization.searchParams.get('state'), nonce: authorization.searchParams.get('nonce') }; +} + +async function metrics() { + const response = await app.request('https://scopeweave.example/api/metrics'); + assert.equal(response.status, 200); + return response.json(); +} + +assert.equal((await metrics()).signups, 0); +let flow = await begin(); +issuedIdToken = signedIdentity(flow.nonce); +let response = await app.request(`https://scopeweave.example/api/auth/oidc/callback?state=${encodeURIComponent(flow.state)}&code=first-code`); +assert.equal(response.status, 302, 'token exchange uses the discovered token endpoint and accepts the signed identity'); +assert.equal((await metrics()).signups, 1, 'creating an OIDC-backed account increments the same signup metric as password signup'); + +flow = await begin(); +issuedIdToken = signedIdentity(flow.nonce); +response = await app.request(`https://scopeweave.example/api/auth/oidc/callback?state=${encodeURIComponent(flow.state)}&code=second-code`); +assert.equal(response.status, 302); +assert.equal((await metrics()).signups, 1, 'reusing an existing OIDC account does not double-count signup'); + +console.log('OIDC provider metadata and signup accounting contract passed'); diff --git a/tests/unit/dependency-review-merge-base-contract.test.mjs b/tests/unit/dependency-review-merge-base-contract.test.mjs index 60649414..251cbff4 100644 --- a/tests/unit/dependency-review-merge-base-contract.test.mjs +++ b/tests/unit/dependency-review-merge-base-contract.test.mjs @@ -7,9 +7,14 @@ const dependencyWorkflow = readFileSync( ); assert.match( + dependencyWorkflow, + /compare\/\$\{LIVE_BASE_SHA\}\.\.\.\$\{HEAD_SHA\}/, + 'dependency review pins merge-base resolution to the independently resolved live base SHA and exact contributor head', +); +assert.doesNotMatch( dependencyWorkflow, /compare\/\$\{base_ref_encoded\}\.\.\.\$\{HEAD_SHA\}/, - 'dependency review resolves the PR merge base from the named base ref and exact contributor head', + 'dependency review never re-resolves a moving base branch after capturing its live SHA', ); assert.match( dependencyWorkflow, diff --git a/tests/unit/rate-limit-scoped-ipv6.test.mjs b/tests/unit/rate-limit-scoped-ipv6.test.mjs new file mode 100644 index 00000000..6fed1f06 --- /dev/null +++ b/tests/unit/rate-limit-scoped-ipv6.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '1'; +process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; +process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX = '8'; +process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS = 'fe80::1%eth0'; + +const { createRateLimitMiddleware } = await import('../../server/rate_limit.mjs?scoped-ipv6=1'); +const middleware = createRateLimitMiddleware(); + +function context(forwardedFor) { + const state = new Map(); + return { + env: { incoming: { socket: { remoteAddress: 'fe80::1%eth0' } } }, + req: { + method: 'GET', + path: '/probe', + header(name) { return name.toLowerCase() === 'x-forwarded-for' ? forwardedFor : ''; }, + query() { return undefined; }, + }, + get(key) { return state.get(key); }, + set(key, value) { state.set(key, value); }, + json(body, status, headers) { return new Response(JSON.stringify(body), { status, headers }); }, + }; +} + +let admitted = false; +await middleware(context('198.51.100.1'), async () => { admitted = true; }); +assert.equal(admitted, true, 'scoped trusted peer admits its first forwarded client without URL parsing failure'); + +admitted = false; +await middleware(context('198.51.100.2'), async () => { admitted = true; }); +assert.equal(admitted, true, 'the same scoped trusted peer can distinguish a second forwarded client'); + +console.log('scoped IPv6 trusted-peer rate-limit contract passed'); From 81797d3e19a2b3debd1784e31b56aa066ba21524 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 10:37:51 +0900 Subject: [PATCH 107/120] fix: close security boundary regressions --- .github/workflows/dependency-review.yml | 4 +- index.html | 2 + server/application_routes.mjs | 53 +++++++++++++------- server/application_routes_implementation.mjs | 6 ++- server/db.mjs | 38 +++++++------- server/rate_limit.mjs | 11 ++-- tests/api/email-identity.test.mjs | 2 +- 7 files changed, 69 insertions(+), 47 deletions(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 3b1d405d..f45dbe89 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -82,12 +82,12 @@ jobs: -H "Accept: application/vnd.github+json" \ -H "Authorization: Bearer ${GH_TOKEN}" \ -H "X-GitHub-Api-Version: 2022-11-28" \ - "${api_url}/repos/${REPOSITORY}/compare/${base_ref_encoded}...${HEAD_SHA}" \ + "${api_url}/repos/${REPOSITORY}/compare/${LIVE_BASE_SHA}...${HEAD_SHA}" \ || true )" if [ "$compare_status" != "200" ]; then - echo "::error::Unable to resolve the pull-request merge base for ${BASE_REF}...${HEAD_SHA}; GitHub API returned HTTP ${compare_status}." + echo "::error::Unable to resolve the pull-request merge base for ${LIVE_BASE_SHA}...${HEAD_SHA}; GitHub API returned HTTP ${compare_status}." exit 1 fi diff --git a/index.html b/index.html index d24b2a88..879ad03b 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,8 @@ ScopeWeave Planner + + diff --git a/server/application_routes.mjs b/server/application_routes.mjs index ec73cc86..97af9d65 100644 --- a/server/application_routes.mjs +++ b/server/application_routes.mjs @@ -1,4 +1,5 @@ import { Hono } from 'hono'; +import { recordSignup } from './application_routes_implementation.mjs'; import { createHash, createPublicKey, @@ -145,7 +146,29 @@ function parseJwtJson(segment) { return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8')); } -async function verifyProductionOidcIdentity(idToken, expectedNonce) { +async function loadOidcProviderMetadata() { + const response = await fetch(`${OIDC_ISSUER}/.well-known/openid-configuration`, { + signal: AbortSignal.timeout(5000), + }); + if (!response.ok) throw new Error('oidc_discovery_failed'); + const metadata = await response.json(); + if (metadata?.issuer !== OIDC_ISSUER || typeof metadata?.jwks_uri !== 'string') { + throw new Error('oidc_discovery_mismatch'); + } + + const endpoints = { + authorization: metadata.authorization_endpoint || `${OIDC_ISSUER}/authorize`, + token: metadata.token_endpoint || `${OIDC_ISSUER}/token`, + jwks: metadata.jwks_uri, + }; + for (const endpoint of Object.values(endpoints)) { + const url = new URL(endpoint); + if (url.protocol !== 'https:') throw new Error('oidc_endpoint_requires_https'); + } + return endpoints; +} + +async function verifyProductionOidcIdentity(idToken, expectedNonce, metadata) { const parts = String(idToken || '').split('.'); if (parts.length !== 3) throw new Error('invalid_oidc_token_shape'); @@ -155,18 +178,8 @@ async function verifyProductionOidcIdentity(idToken, expectedNonce) { throw new Error('unsupported_oidc_signature'); } - const discoveryResponse = await fetch(`${OIDC_ISSUER}/.well-known/openid-configuration`, { - signal: AbortSignal.timeout(5000), - }); - if (!discoveryResponse.ok) throw new Error('oidc_discovery_failed'); - const discovery = await discoveryResponse.json(); - if (discovery?.issuer !== OIDC_ISSUER || typeof discovery?.jwks_uri !== 'string') { - throw new Error('oidc_discovery_mismatch'); - } - - const jwksUrl = new URL(discovery.jwks_uri); - if (jwksUrl.protocol !== 'https:') throw new Error('oidc_jwks_requires_https'); - const jwksResponse = await fetch(jwksUrl, { signal: AbortSignal.timeout(5000) }); + const provider = metadata || await loadOidcProviderMetadata(); + const jwksResponse = await fetch(provider.jwks, { signal: AbortSignal.timeout(5000) }); if (!jwksResponse.ok) throw new Error('oidc_jwks_failed'); const jwks = await jwksResponse.json(); const jwk = Array.isArray(jwks?.keys) @@ -222,6 +235,7 @@ function upsertProductionSsoUser(email) { db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(orgId, uid, 'owner'); db.exec('COMMIT'); user = { id: uid, email, token_version: 0 }; + recordSignup(); } catch (error) { db.exec('ROLLBACK'); throw error; @@ -230,12 +244,12 @@ function upsertProductionSsoUser(email) { } async function productionOidcStart(c, next) { - if (process.env.SCOPEWEAVE_DEV === '1' || !productionOidcConfigured) return next(); + if (!productionOidcConfigured) return next(); let authorizationUrl; try { - authorizationUrl = new URL(`${OIDC_ISSUER}/authorize`); - if (authorizationUrl.protocol !== 'https:') throw new Error('oidc_issuer_requires_https'); + const provider = await loadOidcProviderMetadata(); + authorizationUrl = new URL(provider.authorization); } catch { return c.json({ error: 'sso not configured' }, 404, { 'Cache-Control': 'no-store' }); } @@ -268,7 +282,7 @@ async function productionOidcStart(c, next) { } async function productionOidcCallback(c, next) { - if (process.env.SCOPEWEAVE_DEV === '1' || !productionOidcConfigured) return next(); + if (!productionOidcConfigured) return next(); const state = c.req.query('state'); const code = c.req.query('code'); @@ -278,7 +292,8 @@ async function productionOidcCallback(c, next) { } try { - const tokenResponse = await fetch(`${OIDC_ISSUER}/token`, { + const provider = await loadOidcProviderMetadata(); + const tokenResponse = await fetch(provider.token, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ @@ -293,7 +308,7 @@ async function productionOidcCallback(c, next) { }); if (!tokenResponse.ok) throw new Error('oidc_token_exchange_failed'); const tokens = await tokenResponse.json(); - const email = await verifyProductionOidcIdentity(tokens?.id_token, pending.nonce); + const email = await verifyProductionOidcIdentity(tokens?.id_token, pending.nonce, provider); const user = upsertProductionSsoUser(email); const token = signToken({ sub: user.id, email: user.email, tv: user.token_version || 0 }); return c.redirect(`/#token=${token}`); diff --git a/server/application_routes_implementation.mjs b/server/application_routes_implementation.mjs index e075650e..3b4453ce 100644 --- a/server/application_routes_implementation.mjs +++ b/server/application_routes_implementation.mjs @@ -92,6 +92,10 @@ const metrics = { attachmentStatusRefreshDeferred: 0, }; +export function recordSignup() { + metrics.signups++; +} + // 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. @@ -172,7 +176,7 @@ app.post('/api/auth/signup', async (c) => { }; db.exec('BEGIN'); try { tx(); db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw e; } - metrics.signups++; + recordSignup(); return c.json({ token: signToken({ sub: uid, email, tv: 0 }) }); }); diff --git a/server/db.mjs b/server/db.mjs index ed5534b7..adbf5ce8 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); +const canonicalEmail = (value) => String(value ?? '').trim().toLowerCase(); +db.function('scopeweave_canonical_email', { deterministic: true }, canonicalEmail); db.exec("PRAGMA journal_mode = WAL"); db.exec("PRAGMA foreign_keys = ON"); @@ -191,28 +193,24 @@ try { db.exec("ALTER TABLE projects ADD COLUMN methodology TEXT NOT NULL DEFAULT function migrateCanonicalUserEmails() { db.exec('BEGIN IMMEDIATE'); try { - const collision = db.prepare(` - SELECT lower(trim(email)) AS canonical_email, - COUNT(*) AS identity_count, - group_concat(id) AS user_ids - FROM users - GROUP BY lower(trim(email)) - HAVING COUNT(*) > 1 - LIMIT 1 - `).get(); - if (collision) { - throw new Error( - `canonical email collision for ${collision.canonical_email} across user ids ${collision.user_ids}; resolve duplicate identities before restart`, - ); + const canonicalUsers = new Map(); + for (const user of db.prepare('SELECT id, email FROM users ORDER BY id').all()) { + const email = canonicalEmail(user.email); + const previousId = canonicalUsers.get(email); + if (previousId !== undefined) { + throw new Error( + `canonical email collision for ${email} across user ids ${previousId}, ${user.id}; resolve duplicate identities before restart`, + ); + } + canonicalUsers.set(email, user.id); } - db.exec(` - UPDATE users - SET email = lower(trim(email)) - WHERE email <> lower(trim(email)); - CREATE UNIQUE INDEX IF NOT EXISTS users_email_canonical_unique - ON users(lower(trim(email))); - `); + const updateEmail = db.prepare('UPDATE users SET email = ? WHERE id = ?'); + for (const user of db.prepare('SELECT id, email FROM users ORDER BY id').all()) { + const email = canonicalEmail(user.email); + if (user.email !== email) updateEmail.run(email, user.id); + } + db.exec('CREATE UNIQUE INDEX IF NOT EXISTS users_email_canonical_unique ON users(scopeweave_canonical_email(email))'); db.exec('COMMIT'); } catch (error) { try { db.exec('ROLLBACK'); } catch { /* preserve the causal migration error */ } diff --git a/server/rate_limit.mjs b/server/rate_limit.mjs index 6b45c554..5d915614 100644 --- a/server/rate_limit.mjs +++ b/server/rate_limit.mjs @@ -48,13 +48,16 @@ function parseSafeIntegerSetting(name, raw, fallback, minimum) { */ function canonicalIp(value) { const candidate = String(value ?? '').trim(); - const family = isIP(candidate); + const scoped = /^([^%]+)%([A-Za-z0-9_.~-]+)$/u.exec(candidate); + if (candidate.includes('%') && !scoped) return null; + const address = scoped?.[1] || candidate; + const family = isIP(address); if (family === 0) return null; - if (family === 4) return candidate; + if (family === 4) return address; - const normalized = new URL(`http://[${candidate}]/`).hostname.slice(1, -1).toLowerCase(); + const normalized = new URL(`http://[${address}]/`).hostname.slice(1, -1).toLowerCase(); const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/iu.exec(normalized); - if (!mapped) return normalized; + if (!mapped) return scoped ? `${normalized}%${scoped[2]}` : normalized; const high = Number.parseInt(mapped[1], 16); const low = Number.parseInt(mapped[2], 16); diff --git a/tests/api/email-identity.test.mjs b/tests/api/email-identity.test.mjs index 6846a925..8a0f4424 100644 --- a/tests/api/email-identity.test.mjs +++ b/tests/api/email-identity.test.mjs @@ -116,7 +116,7 @@ function importDatabase(databasePath) { ).get('users_email_canonical_unique'); assert.match( String(index?.sql || ''), - /UNIQUE\s+INDEX[\s\S]*lower\s*\(\s*trim\s*\(\s*email\s*\)\s*\)/iu, + /UNIQUE\s+INDEX[\s\S]*scopeweave_canonical_email\s*\(\s*email\s*\)/iu, 'database enforces one canonical mailbox identity even for direct writes', ); database.close(); From a154cf1d6943766f7332de3af93b394ff1a31721 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 10:46:31 +0900 Subject: [PATCH 108/120] fix: isolate shared signup metrics --- server/application_routes.mjs | 2 +- server/application_routes_implementation.mjs | 8 ++------ server/signup_metrics.mjs | 9 +++++++++ 3 files changed, 12 insertions(+), 7 deletions(-) create mode 100644 server/signup_metrics.mjs diff --git a/server/application_routes.mjs b/server/application_routes.mjs index 97af9d65..74ab58ff 100644 --- a/server/application_routes.mjs +++ b/server/application_routes.mjs @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import { recordSignup } from './application_routes_implementation.mjs'; +import { recordSignup } from './signup_metrics.mjs'; import { createHash, createPublicKey, diff --git a/server/application_routes_implementation.mjs b/server/application_routes_implementation.mjs index 3b4453ce..cdc78744 100644 --- a/server/application_routes_implementation.mjs +++ b/server/application_routes_implementation.mjs @@ -13,6 +13,7 @@ import { normalizeAttachmentStatusBudgetMs, normalizeAttachmentStatusConcurrency import { chat as orchestratorChat } from './orchestrator.mjs'; import { computeEvm } from '../analytics.js'; // pure math, shared with the client import { createRateLimitMiddleware, redactRequestLogPath } from './rate_limit.mjs'; +import { getSignupCount, recordSignup } from './signup_metrics.mjs'; const GUARD_ACCOUNTING_METHOD = Symbol.for('scopeweave.guard_accounting.original_method'); const getOrg = (id) => db.prepare('SELECT * FROM orgs WHERE id = ?').get(id); @@ -83,7 +84,6 @@ const metrics = { s2xx: 0, s4xx: 0, s5xx: 0, - signups: 0, projectsCreated: 0, webhookDeliveries: 0, attachmentStatusRefreshAttempted: 0, @@ -92,10 +92,6 @@ const metrics = { attachmentStatusRefreshDeferred: 0, }; -export function recordSignup() { - metrics.signups++; -} - // 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. @@ -721,7 +717,7 @@ app.get('/api/orgs/:id/export', requireAuth, (c) => { // 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()) }; + const all = { ...metrics, signups: getSignupCount(), 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']); diff --git a/server/signup_metrics.mjs b/server/signup_metrics.mjs new file mode 100644 index 00000000..18302930 --- /dev/null +++ b/server/signup_metrics.mjs @@ -0,0 +1,9 @@ +let signupCount = 0; + +export function recordSignup() { + signupCount++; +} + +export function getSignupCount() { + return signupCount; +} From 7430bb2b64e442f6b218d09c5bfa894ea3f2ceb9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 10:53:31 +0900 Subject: [PATCH 109/120] fix: count mock SSO signups --- server/application_routes_implementation.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/application_routes_implementation.mjs b/server/application_routes_implementation.mjs index cdc78744..e5a55fd5 100644 --- a/server/application_routes_implementation.mjs +++ b/server/application_routes_implementation.mjs @@ -816,7 +816,7 @@ function upsertSsoUser(email) { 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++; + recordSignup(); return { id: uid, email }; } catch (e) { db.exec('ROLLBACK'); throw e; } } From f074b41e8be391bc92b95cb76bf298f5120d21f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 20:54:34 -0700 Subject: [PATCH 110/120] test: reject private OIDC metadata endpoints --- tests/api/oidc-provider-metadata.test.mjs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/api/oidc-provider-metadata.test.mjs b/tests/api/oidc-provider-metadata.test.mjs index 775f360d..88c9127c 100644 --- a/tests/api/oidc-provider-metadata.test.mjs +++ b/tests/api/oidc-provider-metadata.test.mjs @@ -13,6 +13,7 @@ const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 20 const publicJwk = { ...publicKey.export({ format: 'jwk' }), kid: 'metadata-key', use: 'sig', alg: 'RS256' }; const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); let issuedIdToken = ''; +let discoveryMode = 'valid'; function signedIdentity(nonce, email = 'sso.user@example.com') { const now = Math.floor(Date.now() / 1000); @@ -34,6 +35,14 @@ function signedIdentity(nonce, email = 'sso.user@example.com') { globalThis.fetch = async (url) => { const target = String(url); if (target === 'https://issuer.example/tenant/.well-known/openid-configuration') { + if (discoveryMode === 'private-endpoints') { + return Response.json({ + issuer: process.env.OIDC_ISSUER, + authorization_endpoint: 'https://login.example/oauth2/v2/authorize', + token_endpoint: 'https://127.0.0.1/internal-token', + jwks_uri: 'https://[::1]/internal-jwks', + }); + } return Response.json({ issuer: process.env.OIDC_ISSUER, authorization_endpoint: 'https://login.example/oauth2/v2/authorize', @@ -66,9 +75,18 @@ async function metrics() { } assert.equal((await metrics()).signups, 0); +discoveryMode = 'private-endpoints'; +let response = await app.request('https://scopeweave.example/api/auth/oidc/start'); +assert.equal( + response.status, + 404, + 'discovery metadata cannot authorize server-side token or JWKS requests to private HTTPS destinations', +); +discoveryMode = 'valid'; + let flow = await begin(); issuedIdToken = signedIdentity(flow.nonce); -let response = await app.request(`https://scopeweave.example/api/auth/oidc/callback?state=${encodeURIComponent(flow.state)}&code=first-code`); +response = await app.request(`https://scopeweave.example/api/auth/oidc/callback?state=${encodeURIComponent(flow.state)}&code=first-code`); assert.equal(response.status, 302, 'token exchange uses the discovered token endpoint and accepts the signed identity'); assert.equal((await metrics()).signups, 1, 'creating an OIDC-backed account increments the same signup metric as password signup'); From b5af7695bb81a0dbda867b0fc2d5bf9332764c6e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 20:58:05 -0700 Subject: [PATCH 111/120] feat: add pinned public HTTPS transport --- server/public_https_transport.mjs | 297 ++++++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 server/public_https_transport.mjs diff --git a/server/public_https_transport.mjs b/server/public_https_transport.mjs new file mode 100644 index 00000000..165b5a23 --- /dev/null +++ b/server/public_https_transport.mjs @@ -0,0 +1,297 @@ +import { lookup as dnsLookup } from 'node:dns/promises'; +import { request as httpsRequest } from 'node:https'; +import { BlockList, isIP } from 'node:net'; + +const DENIED_IPV4_BLOCKS = new BlockList(); +const DENIED_IPV6_BLOCKS = new BlockList(); +const PUBLIC_IPV6_UNICAST = new BlockList(); +const DEFAULT_MAX_RESPONSE_BYTES = 1024 * 1024; + +PUBLIC_IPV6_UNICAST.addSubnet('2000::', 3, 'ipv6'); +for (const [address, prefix, family] of [ + ['0.0.0.0', 8, 'ipv4'], ['10.0.0.0', 8, 'ipv4'], ['100.64.0.0', 10, 'ipv4'], + ['127.0.0.0', 8, 'ipv4'], ['169.254.0.0', 16, 'ipv4'], ['172.16.0.0', 12, 'ipv4'], + ['192.0.0.0', 24, 'ipv4'], ['192.0.2.0', 24, 'ipv4'], ['192.31.196.0', 24, 'ipv4'], + ['192.52.193.0', 24, 'ipv4'], ['192.88.99.0', 24, 'ipv4'], ['192.168.0.0', 16, 'ipv4'], + ['192.175.48.0', 24, 'ipv4'], ['198.18.0.0', 15, 'ipv4'], ['198.51.100.0', 24, 'ipv4'], + ['203.0.113.0', 24, 'ipv4'], ['224.0.0.0', 4, 'ipv4'], ['240.0.0.0', 4, 'ipv4'], + ['::', 128, 'ipv6'], ['::1', 128, 'ipv6'], ['::ffff:0:0', 96, 'ipv6'], + ['64:ff9b::', 96, 'ipv6'], ['64:ff9b:1::', 48, 'ipv6'], ['100::', 64, 'ipv6'], + ['100:0:0:1::', 64, 'ipv6'], ['2001::', 23, 'ipv6'], ['2001:db8::', 32, 'ipv6'], + ['2002::', 16, 'ipv6'], ['2620:4f:8000::', 48, 'ipv6'], ['3ffe::', 16, 'ipv6'], + ['3fff::', 20, 'ipv6'], ['5f00::', 16, 'ipv6'], ['fc00::', 7, 'ipv6'], + ['fe80::', 10, 'ipv6'], ['ff00::', 8, 'ipv6'], +]) { + (family === 'ipv4' ? DENIED_IPV4_BLOCKS : DENIED_IPV6_BLOCKS).addSubnet(address, prefix, family); +} + +const SAFE_ERROR = 'public HTTPS destination unavailable'; +const POLICY_ERROR = 'public HTTPS destination is not permitted'; + +/** A stable, non-secret outbound destination policy failure. */ +export class PublicHttpsDestinationError extends Error { + constructor() { + super(POLICY_ERROR); + this.name = 'PublicHttpsDestinationError'; + } +} + +/** A stable, non-secret DNS/TLS/transport failure. */ +export class PublicHttpsTransportError extends Error { + constructor() { + super(SAFE_ERROR); + this.name = 'PublicHttpsTransportError'; + } +} + +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 true only for ordinary public Internet IP addresses. */ +export function isPublicInternetAddress(address) { + const family = isIP(address); + if (!family) return false; + if (family === 4) return !DENIED_IPV4_BLOCKS.check(address, 'ipv4'); + return PUBLIC_IPV6_UNICAST.check(address, 'ipv6') && !DENIED_IPV6_BLOCKS.check(address, 'ipv6'); +} + +/** Parse and canonicalize an HTTPS URL, rejecting local and private literal targets. */ +export function validatePublicHttpsUrl(value) { + let destination; + try { + destination = new URL(String(value ?? '')); + } catch { + throw new PublicHttpsDestinationError(); + } + if (destination.protocol !== 'https:' + || destination.username + || destination.password + || destination.hash + || !destination.hostname + || isLocalHostname(destination.hostname)) { + throw new PublicHttpsDestinationError(); + } + const literal = hostAddress(destination.hostname); + if (isIP(literal) && !isPublicInternetAddress(literal)) throw new PublicHttpsDestinationError(); + return destination.href; +} + +async function withAbort(promise, signal) { + if (!signal) return promise; + if (signal.aborted) throw new PublicHttpsTransportError(); + let onAbort; + const aborted = new Promise((_, reject) => { + onAbort = () => reject(new PublicHttpsTransportError()); + 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 (!isPublicInternetAddress(literal)) throw new PublicHttpsDestinationError(); + 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 PublicHttpsDestinationError || error instanceof PublicHttpsTransportError) throw error; + throw new PublicHttpsTransportError(); + } + if (!Array.isArray(answers) || answers.length === 0) throw new PublicHttpsTransportError(); + + 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) || !isPublicInternetAddress(address)) { + throw new PublicHttpsDestinationError(); + } + const key = `${family}:${address}`; + if (!seen.has(key)) { + seen.add(key); + normalized.push({ address, family }); + } + } + if (!normalized.length) throw new PublicHttpsTransportError(); + return normalized; +} + +function pinnedLookup(address, family) { + return (_hostname, options, callback) => { + if (options?.all) callback(null, [{ address, family }]); + else callback(null, 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 }), + }; +} + +function trackSecureConnect(request, attempt) { + request?.once?.('socket', (socket) => { + socket?.once?.('secureConnect', () => { attempt.secureConnected = true; }); + }); +} + +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)); + } +} + +function identityEncodedHeaders(headers) { + const normalized = Object.fromEntries(new Headers(headers).entries()); + delete normalized['content-length']; + normalized['accept-encoding'] = 'identity'; + return normalized; +} + +async function fetchFromCandidate(destination, candidate, options, request) { + const { method, headers, body, signal, maxResponseBytes, attempt } = options; + if (signal?.aborted) throw new PublicHttpsTransportError(); + try { + return await withAbort(new Promise((resolve, reject) => { + let settled = false; + const fail = () => { + if (settled) return; + settled = true; + reject(new PublicHttpsTransportError()); + }; + let req; + try { + req = request(destination, pinnedRequestOptions(destination, candidate, { method, headers, signal }), (response) => { + attempt.responseStarted = true; + 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) return fail(); + const responseHeaders = new Headers(); + appendResponseHeaders(responseHeaders, response.headers); + const responseBody = status === 204 || status === 205 || status === 304 + ? null + : (chunks.length ? Buffer.concat(chunks) : null); + try { + settled = true; + resolve(new Response(responseBody, { status, headers: responseHeaders })); + } catch { + fail(); + } + }); + }); + } catch { + fail(); + return; + } + trackSecureConnect(req, attempt); + 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 PublicHttpsTransportError) throw error; + throw new PublicHttpsTransportError(); + } +} + +function methodMayReplay(method) { + return method === 'GET' || method === 'HEAD'; +} + +/** + * Build a bounded public-HTTPS transport for server-side metadata and token flows. + * DNS is resolved afresh, every answer must be public, each socket is pinned to a + * validated address, SNI keeps the original hostname, pooling and redirects are + * disabled, and response buffering is bounded. Mutating requests are never + * replayed after TLS establishment or response headers because delivery is then + * ambiguous. + */ +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 = DEFAULT_MAX_RESPONSE_BYTES, + } = {}) { + const destination = new URL(validatePublicHttpsUrl(url)); + if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes <= 0) { + throw new TypeError('maxResponseBytes must be a positive safe integer'); + } + 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, secureConnected: false }; + try { + return await fetchFromCandidate(destination, candidate, { + method: requestMethod, headers: requestHeaders, body, signal, maxResponseBytes, attempt, + }, request); + } catch (error) { + if (!(error instanceof PublicHttpsTransportError)) throw error; + lastError = error; + if (signal?.aborted || attempt.responseStarted || (attempt.secureConnected && !methodMayReplay(requestMethod))) { + throw error; + } + } + } + throw lastError || new PublicHttpsTransportError(); + }, + }); +} + +let activeTransport = createPublicHttpsTransport(); + +/** Fetch through ScopeWeave's process-wide pinned public-HTTPS transport. */ +export function fetchPublicHttps(url, options) { + return activeTransport.fetch(url, options); +} + +/** Replace the outbound transport only in explicit test processes. */ +export function configurePublicHttpsTransportForTests(transport) { + if (process.env.NODE_ENV !== 'test') throw new Error('public HTTPS transport override is test-only'); + if (!transport || typeof transport.fetch !== 'function') throw new TypeError('test transport must expose fetch'); + activeTransport = transport; +} From d46ac47004db6d1493ff9be29099b29657b194dc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 20:58:50 -0700 Subject: [PATCH 112/120] test: cover pinned public HTTPS transport --- tests/unit/public-https-transport.test.mjs | 151 +++++++++++++++++++++ 1 file changed, 151 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..57b62b3e --- /dev/null +++ b/tests/unit/public-https-transport.test.mjs @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { + PublicHttpsDestinationError, + PublicHttpsTransportError, + configurePublicHttpsTransportForTests, + createPublicHttpsTransport, + fetchPublicHttps, + isPublicInternetAddress, + validatePublicHttpsUrl, +} from '../../server/public_https_transport.mjs'; + +const PUBLIC_A = { address: '93.184.216.34', family: 4 }; +const PUBLIC_B = { address: '93.184.216.35', family: 4 }; + +assert.equal(isPublicInternetAddress('93.184.216.34'), true); +assert.equal(isPublicInternetAddress('127.0.0.1'), false); +assert.equal(isPublicInternetAddress('::1'), false); +assert.equal(isPublicInternetAddress('not-an-ip'), false); +assert.throws(() => validatePublicHttpsUrl('http://example.com'), PublicHttpsDestinationError); +assert.throws(() => validatePublicHttpsUrl('https://localhost/path'), PublicHttpsDestinationError); +assert.throws(() => validatePublicHttpsUrl('https://127.0.0.1/path'), PublicHttpsDestinationError); +assert.equal(validatePublicHttpsUrl('https://example.com/a'), 'https://example.com/a'); + +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 DNS'); }, + }).fetch('https://idp.example.test/.well-known/openid-configuration'), + PublicHttpsDestinationError, +); + +const attempts = []; +const transport = createPublicHttpsTransport({ + lookup: async () => [PUBLIC_A, 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.deepEqual(await response.json(), { issuer: 'https://idp.example.test' }); +assert.deepEqual(attempts.map(({ address }) => address), [PUBLIC_A.address, PUBLIC_B.address]); +assert.equal(attempts[1].agent, false); +assert.equal(attempts[1].servername, 'idp.example.test'); +assert.equal(attempts[1].method, 'GET'); + +const noContent = createPublicHttpsTransport({ + lookup: async () => [PUBLIC_A], + request: (_url, _options, callback) => { + const req = new EventEmitter(); + req.end = () => { + const upstream = new EventEmitter(); + upstream.statusCode = 204; + upstream.headers = {}; + upstream.destroy = () => {}; + callback(upstream); + queueMicrotask(() => { upstream.emit('data', Buffer.from('ignored')); upstream.emit('end'); }); + }; + return req; + }, +}); +assert.equal(await (await noContent.fetch('https://idp.example.test/empty')).text(), ''); + +const oversized = createPublicHttpsTransport({ + lookup: async () => [PUBLIC_A], + request: (_url, _options, callback) => { + const req = new EventEmitter(); + req.end = () => { + const upstream = new EventEmitter(); + upstream.statusCode = 200; + upstream.headers = {}; + upstream.destroy = () => {}; + callback(upstream); + queueMicrotask(() => upstream.emit('data', Buffer.alloc(9))); + }; + return req; + }, +}); +await assert.rejects(oversized.fetch('https://idp.example.test/jwks', { maxResponseBytes: 8 }), PublicHttpsTransportError); + +let mutatingAttempts = 0; +const noPostReplay = createPublicHttpsTransport({ + lookup: async () => [PUBLIC_A, PUBLIC_B], + request: (_url, options) => { + mutatingAttempts += 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('closed after TLS'))); + }); + }); + }; + return req; + }, +}); +await assert.rejects(noPostReplay.fetch('https://idp.example.test/token', { + method: 'POST', + headers: { 'content-length': '9999' }, + body: new URLSearchParams({ code: 'one-time' }).toString(), +}), PublicHttpsTransportError); +assert.equal(mutatingAttempts, 1); + +await assert.rejects(transport.fetch('https://idp.example.test/jwks', { maxResponseBytes: 0 }), /positive safe integer/); +assert.throws(() => createPublicHttpsTransport({ lookup: null }), /dependencies must be functions/); + +const controller = new AbortController(); +controller.abort(); +await assert.rejects(transport.fetch('https://idp.example.test/jwks', { signal: controller.signal }), PublicHttpsTransportError); + +assert.throws( + () => configurePublicHttpsTransportForTests({ fetch() {} }), + /test-only/, +); +process.env.NODE_ENV = 'test'; +let injectedCalls = 0; +configurePublicHttpsTransportForTests({ + async fetch(url) { + injectedCalls += 1; + return Response.json({ url: String(url) }); + }, +}); +assert.deepEqual(await (await fetchPublicHttps('https://example.com/test')).json(), { url: 'https://example.com/test' }); +assert.equal(injectedCalls, 1); +assert.throws(() => configurePublicHttpsTransportForTests({}), /must expose fetch/); + +console.log('public HTTPS destination, DNS pinning, replay, and response-bound regressions passed'); From 474595a47d7b7f31958b3e0bbda422956d948922 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 20:59:50 -0700 Subject: [PATCH 113/120] fix: pin OIDC outbound HTTPS destinations --- server/application_routes.mjs | 68 +++++++++++++++-------------------- 1 file changed, 29 insertions(+), 39 deletions(-) diff --git a/server/application_routes.mjs b/server/application_routes.mjs index 74ab58ff..d0a30067 100644 --- a/server/application_routes.mjs +++ b/server/application_routes.mjs @@ -18,6 +18,10 @@ import { createRateLimitMiddleware, createRateLimitObservability, } from './rate_limit.mjs'; +import { + fetchPublicHttps, + validatePublicHttpsUrl, +} from './public_https_transport.mjs'; const configuredRateLimitMax = process.env.SCOPEWEAVE_RATE_LIMIT_MAX; let coreRoutes; @@ -44,6 +48,7 @@ const OIDC_CLIENT_SECRET = String(process.env.OIDC_CLIENT_SECRET || ''); const OIDC_REDIRECT_URI = String(process.env.OIDC_REDIRECT_URI || ''); const productionOidcConfigured = Boolean(OIDC_ISSUER && OIDC_CLIENT_ID && OIDC_CLIENT_SECRET); const productionOidcStates = createExpiringAuthStateStore(); +const OIDC_RESPONSE_MAX_BYTES = 256 * 1024; function normalizeIdentityEmail(value) { return String(value ?? '').trim().toLowerCase(); @@ -74,26 +79,13 @@ function authenticatedIdentityHint(c) { } async function guardRejectionThroughCoreAbuseControls(c, errorBody) { - // The shared rate limiter has already accepted this request before any guard - // runs. A non-mutating OPTIONS probe at the same path lets the internal core - // request logger and counters account for a guard rejection without reaching - // a mutating route. The core limiter is disabled for this supported boundary, - // so forwarded-address data and credentials do not need to enter the probe. const accountingEnvironment = Object.assign(Object.create(null), c.env || {}); accountingEnvironment[GUARD_ACCOUNTING_METHOD] = c.req.method; - await coreRoutes.request( - c.req.url, - { method: 'OPTIONS' }, - accountingEnvironment, - ); + await coreRoutes.request(c.req.url, { method: 'OPTIONS' }, accountingEnvironment); return c.json(errorBody, 404); } async function bindInviteToAuthenticatedIdentity(c, next) { - // This guard only narrows access after confirming that the presented - // credential is still live. The core requireAuth middleware remains the - // authoritative authentication/RBAC boundary and repeats that validation - // before any invite mutation. const identity = authenticatedIdentityHint(c); if (!identity) return next(); @@ -147,8 +139,10 @@ function parseJwtJson(segment) { } async function loadOidcProviderMetadata() { - const response = await fetch(`${OIDC_ISSUER}/.well-known/openid-configuration`, { + const discoveryUrl = validatePublicHttpsUrl(`${OIDC_ISSUER}/.well-known/openid-configuration`); + const response = await fetchPublicHttps(discoveryUrl, { signal: AbortSignal.timeout(5000), + maxResponseBytes: OIDC_RESPONSE_MAX_BYTES, }); if (!response.ok) throw new Error('oidc_discovery_failed'); const metadata = await response.json(); @@ -156,16 +150,11 @@ async function loadOidcProviderMetadata() { throw new Error('oidc_discovery_mismatch'); } - const endpoints = { - authorization: metadata.authorization_endpoint || `${OIDC_ISSUER}/authorize`, - token: metadata.token_endpoint || `${OIDC_ISSUER}/token`, - jwks: metadata.jwks_uri, + return { + authorization: validatePublicHttpsUrl(metadata.authorization_endpoint || `${OIDC_ISSUER}/authorize`), + token: validatePublicHttpsUrl(metadata.token_endpoint || `${OIDC_ISSUER}/token`), + jwks: validatePublicHttpsUrl(metadata.jwks_uri), }; - for (const endpoint of Object.values(endpoints)) { - const url = new URL(endpoint); - if (url.protocol !== 'https:') throw new Error('oidc_endpoint_requires_https'); - } - return endpoints; } async function verifyProductionOidcIdentity(idToken, expectedNonce, metadata) { @@ -179,7 +168,10 @@ async function verifyProductionOidcIdentity(idToken, expectedNonce, metadata) { } const provider = metadata || await loadOidcProviderMetadata(); - const jwksResponse = await fetch(provider.jwks, { signal: AbortSignal.timeout(5000) }); + const jwksResponse = await fetchPublicHttps(provider.jwks, { + signal: AbortSignal.timeout(5000), + maxResponseBytes: OIDC_RESPONSE_MAX_BYTES, + }); if (!jwksResponse.ok) throw new Error('oidc_jwks_failed'); const jwks = await jwksResponse.json(); const jwk = Array.isArray(jwks?.keys) @@ -258,10 +250,11 @@ async function productionOidcStart(c, next) { const verifier = randomBytes(32).toString('base64url'); const nonce = randomBytes(24).toString('base64url'); const challenge = createHash('sha256').update(verifier).digest('base64url'); - const reserved = productionOidcStates.reserve( - state, - { verifier, nonce, exp: Date.now() + 5 * 60 * 1000 }, - ); + const reserved = productionOidcStates.reserve(state, { + verifier, + nonce, + exp: Date.now() + 5 * 60 * 1000, + }); if (!reserved) { return c.json( { error: 'sso temporarily unavailable' }, @@ -293,7 +286,7 @@ async function productionOidcCallback(c, next) { try { const provider = await loadOidcProviderMetadata(); - const tokenResponse = await fetch(provider.token, { + const tokenResponse = await fetchPublicHttps(provider.token, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ @@ -305,6 +298,7 @@ async function productionOidcCallback(c, next) { code_verifier: pending.verifier, }), signal: AbortSignal.timeout(5000), + maxResponseBytes: OIDC_RESPONSE_MAX_BYTES, }); if (!tokenResponse.ok) throw new Error('oidc_token_exchange_failed'); const tokens = await tokenResponse.json(); @@ -320,15 +314,11 @@ async function productionOidcCallback(c, next) { /** * Shared ScopeWeave application and transport-security boundary. * - * Every supported consumer, including the public Node server and tests that - * mount this route graph directly, enters the same trusted-proxy-aware bounded - * rate limiter before authentication hints, invitation lookups, OIDC guards, or - * the internal implementation graph. Production OIDC authorization-code - * callbacks terminate here only after RS256/JWKS signature validation and - * issuer, audience, expiry, nonce, subject, and verified-email checks. Guard - * rejections are still accounted by the core request logger/counters, while - * limiter rejections use the matching bounded observability hooks without - * exposing client identity. + * Every supported consumer enters the same trusted-proxy-aware bounded rate + * limiter before authentication hints, invitation lookups, or OIDC guards. + * Production OIDC outbound discovery, token, and JWKS traffic uses the pinned + * public-HTTPS transport so HTTPS scheme checks cannot be bypassed with private + * literals, mixed DNS answers, DNS rebinding, or redirects into local networks. */ export const app = new Hono(); From f3c925e8b7a12a3bbf1f9fccaefa8d2149265149 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 21:00:24 -0700 Subject: [PATCH 114/120] test: inject bounded OIDC HTTPS seam --- tests/api/oidc-provider-metadata.test.mjs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/tests/api/oidc-provider-metadata.test.mjs b/tests/api/oidc-provider-metadata.test.mjs index 88c9127c..af3726f5 100644 --- a/tests/api/oidc-provider-metadata.test.mjs +++ b/tests/api/oidc-provider-metadata.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { generateKeyPairSync, sign } from 'node:crypto'; +process.env.NODE_ENV = 'test'; process.env.SCOPEWEAVE_DB = ':memory:'; process.env.SCOPEWEAVE_DEV = '1'; process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; @@ -32,7 +33,7 @@ function signedIdentity(nonce, email = 'sso.user@example.com') { return `${input}.${sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`; } -globalThis.fetch = async (url) => { +const oidcFetch = async (url) => { const target = String(url); if (target === 'https://issuer.example/tenant/.well-known/openid-configuration') { if (discoveryMode === 'private-endpoints') { @@ -50,13 +51,13 @@ globalThis.fetch = async (url) => { jwks_uri: 'https://keys.example/oidc/jwks.json', }); } - if (target === 'https://tokens.example/oauth2/v2/token') { - return Response.json({ id_token: issuedIdToken }); - } + if (target === 'https://tokens.example/oauth2/v2/token') return Response.json({ id_token: issuedIdToken }); if (target === 'https://keys.example/oidc/jwks.json') return Response.json({ keys: [publicJwk] }); throw new Error(`unexpected OIDC fetch: ${target}`); }; +const { configurePublicHttpsTransportForTests } = await import('../../server/public_https_transport.mjs'); +configurePublicHttpsTransportForTests({ fetch: oidcFetch }); const { app } = await import('../../server/application_routes.mjs?provider-metadata=1'); async function begin() { @@ -77,11 +78,7 @@ async function metrics() { assert.equal((await metrics()).signups, 0); discoveryMode = 'private-endpoints'; let response = await app.request('https://scopeweave.example/api/auth/oidc/start'); -assert.equal( - response.status, - 404, - 'discovery metadata cannot authorize server-side token or JWKS requests to private HTTPS destinations', -); +assert.equal(response.status, 404, 'discovery metadata cannot authorize server-side token or JWKS requests to private HTTPS destinations'); discoveryMode = 'valid'; let flow = await begin(); From b8283b55300eaf6ac2aa1733e834e0bd355d8270 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 21:01:29 -0700 Subject: [PATCH 115/120] test: inject OIDC HTTPS transport in production regression --- tests/api/oidc-production-boundary.test.mjs | 94 ++++----------------- 1 file changed, 17 insertions(+), 77 deletions(-) diff --git a/tests/api/oidc-production-boundary.test.mjs b/tests/api/oidc-production-boundary.test.mjs index 75f1824b..eafdf368 100644 --- a/tests/api/oidc-production-boundary.test.mjs +++ b/tests/api/oidc-production-boundary.test.mjs @@ -56,6 +56,7 @@ const productionIdentityRegression = String.raw` import assert from 'node:assert/strict'; import { generateKeyPairSync, sign } from 'node:crypto'; + process.env.NODE_ENV = 'test'; process.env.SCOPEWEAVE_DB = ':memory:'; delete process.env.SCOPEWEAVE_DEV; process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; @@ -85,7 +86,7 @@ const productionIdentityRegression = String.raw` return signingInput + '.' + signature; }; - globalThis.fetch = async (url) => { + const oidcFetch = async (url) => { const target = String(url); if (target === 'https://issuer.example/token') { return new Response(JSON.stringify({ id_token: issuedIdToken }), { @@ -108,6 +109,8 @@ const productionIdentityRegression = String.raw` throw new Error('unexpected OIDC fetch: ' + target); }; + const { configurePublicHttpsTransportForTests } = await import('./server/public_https_transport.mjs'); + configurePublicHttpsTransportForTests({ fetch: oidcFetch }); const { app: configuredRoutes } = await import('./server/application_routes.mjs?oidc-production-token-regression=1'); const { db, rowid } = await import('./server/db.mjs'); const begin = async () => { @@ -134,9 +137,7 @@ const productionIdentityRegression = String.raw` let controlledNow = realDateNow(); Date.now = () => controlledNow; const abandonedStates = []; - for (let index = 0; index < 1024; index += 1) { - abandonedStates.push(await begin()); - } + for (let index = 0; index < 1024; index += 1) abandonedStates.push(await begin()); let result = await configuredRoutes.request('https://scopeweave.example/api/auth/oidc/start'); assert.equal(result.status, 503, 'OIDC authorization state storage fails closed at its bounded capacity'); assert.equal(result.headers.get('cache-control'), 'no-store', 'OIDC saturation response is never cached'); @@ -150,38 +151,19 @@ const productionIdentityRegression = String.raw` const forged = await begin(); issuedIdToken = [ encode({ alg: 'none', typ: 'JWT' }), - encode({ - iss: process.env.OIDC_ISSUER, - aud: process.env.OIDC_CLIENT_ID, - exp: Math.floor(Date.now() / 1000) + 300, - nonce: forged.nonce, - sub: 'attacker-subject', - email_verified: true, - email: 'attacker-chosen@example.com', - }), + encode({ iss: process.env.OIDC_ISSUER, aud: process.env.OIDC_CLIENT_ID, exp: Math.floor(Date.now() / 1000) + 300, nonce: forged.nonce, sub: 'attacker-subject', email_verified: true, email: 'attacker-chosen@example.com' }), '', ].join('.'); result = await callback(forged.state, 'attacker-code'); assert.equal(result.status, 400, 'an unsigned identity token must never mint a ScopeWeave session'); assert.equal(result.headers.get('location'), null, 'rejected identity tokens never return an application session fragment'); - result = await configuredRoutes.request( - 'https://scopeweave.example/api/auth/oidc/callback?state=unknown&code=attacker-code', - ); + result = await configuredRoutes.request('https://scopeweave.example/api/auth/oidc/callback?state=unknown&code=attacker-code'); assert.equal(result.status, 400, 'unknown authorization state fails closed before token exchange'); const valid = await begin(); const now = Math.floor(Date.now() / 1000); - issuedIdToken = signIdToken({ - iss: process.env.OIDC_ISSUER, - aud: process.env.OIDC_CLIENT_ID, - exp: now + 300, - iat: now, - nonce: valid.nonce, - sub: 'verified-subject', - email_verified: true, - email: 'verified@example.com', - }); + issuedIdToken = signIdToken({ iss: process.env.OIDC_ISSUER, aud: process.env.OIDC_CLIENT_ID, exp: now + 300, iat: now, nonce: valid.nonce, sub: 'verified-subject', email_verified: true, email: 'verified@example.com' }); result = await callback(valid.state, 'valid-code'); assert.equal(result.status, 302, 'a valid issuer-signed ID token completes the production OIDC flow'); assert.match(result.headers.get('location') || '', /^\/#token=/, 'successful OIDC returns only the ScopeWeave session in a URL fragment'); @@ -189,72 +171,30 @@ const productionIdentityRegression = String.raw` const replay = await callback(valid.state, 'replayed-code'); assert.equal(replay.status, 400, 'OIDC state is single-use after a successful callback'); - const legacyUserId = rowid(db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') - .run('Legacy.User@Example.com', 'legacy-password-hash', 'Legacy User')); - const legacyOrgId = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)') - .run('Legacy workspace', legacyUserId)); - db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)') - .run(legacyOrgId, legacyUserId, 'owner'); + const legacyUserId = rowid(db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)').run('Legacy.User@Example.com', 'legacy-password-hash', 'Legacy User')); + const legacyOrgId = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)').run('Legacy workspace', legacyUserId)); + db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(legacyOrgId, legacyUserId, 'owner'); const usersBeforeCaseLink = db.prepare('SELECT COUNT(*) AS count FROM users').get().count; const orgsBeforeCaseLink = db.prepare('SELECT COUNT(*) AS count FROM orgs').get().count; const legacyCase = await begin(); - issuedIdToken = signIdToken({ - iss: process.env.OIDC_ISSUER, - aud: process.env.OIDC_CLIENT_ID, - exp: now + 300, - iat: now, - nonce: legacyCase.nonce, - sub: 'legacy-verified-subject', - email_verified: true, - email: 'legacy.user@example.com', - }); + issuedIdToken = signIdToken({ iss: process.env.OIDC_ISSUER, aud: process.env.OIDC_CLIENT_ID, exp: now + 300, iat: now, nonce: legacyCase.nonce, sub: 'legacy-verified-subject', email_verified: true, email: 'legacy.user@example.com' }); result = await callback(legacyCase.state, 'legacy-case-code'); assert.equal(result.status, 302, 'verified OIDC email reuses an existing account regardless of stored email case'); const sessionToken = new URL(result.headers.get('location'), 'https://scopeweave.example').hash.slice('#token='.length); const sessionPayload = JSON.parse(Buffer.from(sessionToken.split('.')[1], 'base64url').toString('utf8')); assert.equal(sessionPayload.sub, legacyUserId, 'OIDC session remains bound to the pre-existing account'); - assert.equal( - db.prepare('SELECT COUNT(*) AS count FROM users').get().count, - usersBeforeCaseLink, - 'case-only identity differences never create a duplicate user', - ); - assert.equal( - db.prepare('SELECT COUNT(*) AS count FROM orgs').get().count, - orgsBeforeCaseLink, - 'reusing an existing case-variant account never creates a second personal workspace', - ); - assert.equal( - db.prepare('SELECT email FROM users WHERE id = ?').get(legacyUserId).email, - 'Legacy.User@Example.com', - 'account linking does not silently rewrite the stored login identifier', - ); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM users').get().count, usersBeforeCaseLink, 'case-only identity differences never create a duplicate user'); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM orgs').get().count, orgsBeforeCaseLink, 'reusing an existing case-variant account never creates a second personal workspace'); + assert.equal(db.prepare('SELECT email FROM users WHERE id = ?').get(legacyUserId).email, 'Legacy.User@Example.com', 'account linking does not silently rewrite the stored login identifier'); const wrongAudience = await begin(); - issuedIdToken = signIdToken({ - iss: process.env.OIDC_ISSUER, - aud: 'different-client', - exp: now + 300, - iat: now, - nonce: wrongAudience.nonce, - sub: 'verified-subject', - email_verified: true, - email: 'verified@example.com', - }); + issuedIdToken = signIdToken({ iss: process.env.OIDC_ISSUER, aud: 'different-client', exp: now + 300, iat: now, nonce: wrongAudience.nonce, sub: 'verified-subject', email_verified: true, email: 'verified@example.com' }); result = await callback(wrongAudience.state, 'wrong-audience-code'); assert.equal(result.status, 400, 'issuer-signed tokens for another audience are rejected'); const unverifiedEmail = await begin(); - issuedIdToken = signIdToken({ - iss: process.env.OIDC_ISSUER, - aud: process.env.OIDC_CLIENT_ID, - exp: now + 300, - iat: now, - nonce: unverifiedEmail.nonce, - sub: 'verified-subject', - email_verified: false, - email: 'victim@example.com', - }); + issuedIdToken = signIdToken({ iss: process.env.OIDC_ISSUER, aud: process.env.OIDC_CLIENT_ID, exp: now + 300, iat: now, nonce: unverifiedEmail.nonce, sub: 'verified-subject', email_verified: false, email: 'victim@example.com' }); result = await callback(unverifiedEmail.state, 'unverified-email-code'); assert.equal(result.status, 400, 'unverified email claims cannot link or create a ScopeWeave account'); `; From 660d27259c7027f6807f01a35caec87b4bd4ce5e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 21:02:01 -0700 Subject: [PATCH 116/120] test: gate public HTTPS transport coverage --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index fb9fddf1..aaf76a8a 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/email-identity.test.mjs && node tests/api/email-unicode-identity.test.mjs && node tests/api/smoke.mjs && node tests/api/invite-security.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-provider-metadata.test.mjs && node tests/api/oidc-core-production-failclosed.test.mjs && node tests/api/security-guard-abuse-controls.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/rate-limit-prometheus.test.mjs && node tests/api/rate-limit-shared-boundary.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/dependency-review-merge-base-contract.test.mjs && node tests/unit/rate-limit-observability-failure.test.mjs && node tests/unit/rate-limit-scoped-ipv6.test.mjs && node tests/unit/stripe-webhook-boundary.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/rate_limit.mjs --include=server/application_routes.mjs --include=server/application_routes_core.mjs --include=server/application_routes_implementation.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.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/dependency-review-merge-base-contract.test.mjs && node tests/unit/rate-limit-observability-failure.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && npm run test:api", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/dependency-review-merge-base-contract.test.mjs && node tests/unit/rate-limit-observability-failure.test.mjs && node tests/unit/rate-limit-scoped-ipv6.test.mjs && node tests/unit/public-https-transport.test.mjs && node tests/unit/stripe-webhook-boundary.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/rate_limit.mjs --include=server/application_routes.mjs --include=server/application_routes_core.mjs --include=server/application_routes_implementation.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/public_https_transport.mjs --include=server/stripe_webhook.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/dependency-review-merge-base-contract.test.mjs && node tests/unit/rate-limit-observability-failure.test.mjs && node tests/unit/public-https-transport.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && 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 d7575ce48f2a9c6b8b38501b222f6c2eef627598 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 22:53:01 -0700 Subject: [PATCH 117/120] test(runtime): reject unsupported Node 23.4 --- package.json | 4 ++-- tests/unit/node-runtime-contract.test.mjs | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 tests/unit/node-runtime-contract.test.mjs diff --git a/package.json b/package.json index aaf76a8a..ef48c5bd 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/email-identity.test.mjs && node tests/api/email-unicode-identity.test.mjs && node tests/api/smoke.mjs && node tests/api/invite-security.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-provider-metadata.test.mjs && node tests/api/oidc-core-production-failclosed.test.mjs && node tests/api/security-guard-abuse-controls.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/rate-limit-prometheus.test.mjs && node tests/api/rate-limit-shared-boundary.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/dependency-review-merge-base-contract.test.mjs && node tests/unit/rate-limit-observability-failure.test.mjs && node tests/unit/rate-limit-scoped-ipv6.test.mjs && node tests/unit/public-https-transport.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/toast-accessibility.test.mjs", + "test:unit": "node tests/unit/node-runtime-contract.test.mjs && 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/dependency-review-merge-base-contract.test.mjs && node tests/unit/rate-limit-observability-failure.test.mjs && node tests/unit/rate-limit-scoped-ipv6.test.mjs && node tests/unit/public-https-transport.test.mjs && node tests/unit/stripe-webhook-boundary.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/rate_limit.mjs --include=server/application_routes.mjs --include=server/application_routes_core.mjs --include=server/application_routes_implementation.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/public_https_transport.mjs --include=server/stripe_webhook.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/dependency-review-merge-base-contract.test.mjs && node tests/unit/rate-limit-observability-failure.test.mjs && node tests/unit/public-https-transport.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && npm run test:api", + "test:coverage:cases": "node tests/unit/node-runtime-contract.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/dependency-review-merge-base-contract.test.mjs && node tests/unit/rate-limit-observability-failure.test.mjs && node tests/unit/public-https-transport.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", diff --git a/tests/unit/node-runtime-contract.test.mjs b/tests/unit/node-runtime-contract.test.mjs new file mode 100644 index 00000000..81961bac --- /dev/null +++ b/tests/unit/node-runtime-contract.test.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const packageJson = JSON.parse( + readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), +); +const databaseSource = readFileSync(new URL('../../server/db.mjs', import.meta.url), 'utf8'); + +assert.match( + databaseSource, + /\bdb\.function\(/, + 'the database bootstrap registers a JavaScript-backed SQLite function', +); +assert.equal( + packageJson.engines?.node, + '^22.13.0 || >=23.5.0', + 'the supported Node range must exclude Node 23.4 because DatabaseSync.function starts at Node 23.5 on the 23.x line', +); + +console.log('✓ Node runtime contract tests passed'); From 70928aa0c80857410050fb5032b918d19fedbd23 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 22:53:53 -0700 Subject: [PATCH 118/120] fix(runtime): exclude Node 23.4 from SQLite support --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 00a99254..41e96002 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "fast-check": "4.9.0" }, "engines": { - "node": "^22.13.0 || >=23.4.0" + "node": "^22.13.0 || >=23.5.0" } }, "node_modules/@bcoe/v8-coverage": { diff --git a/package.json b/package.json index ef48c5bd..bb03d547 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "packageManager": "npm@10.9.2", "description": "Production-grade pure HTML/CSS/JS WBS planner", "engines": { - "node": "^22.13.0 || >=23.4.0" + "node": "^22.13.0 || >=23.5.0" }, "scripts": { "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", From 08d328b8c338444e07e01a94746a822980747b3b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 15:02:17 +0900 Subject: [PATCH 119/120] test(security): prove guarded invite rate-limit ordering --- tests/api/rate-limit-shared-boundary.test.mjs | 92 ++++++++++++++----- 1 file changed, 68 insertions(+), 24 deletions(-) diff --git a/tests/api/rate-limit-shared-boundary.test.mjs b/tests/api/rate-limit-shared-boundary.test.mjs index 12fce458..47a2350d 100644 --- a/tests/api/rate-limit-shared-boundary.test.mjs +++ b/tests/api/rate-limit-shared-boundary.test.mjs @@ -68,33 +68,77 @@ assert.equal( const inviteOrderingProbe = runProbe(` import assert from 'node:assert/strict'; - process.env.SCOPEWEAVE_DB = ':memory:'; + import { randomUUID } from 'node:crypto'; + import { rmSync } from 'node:fs'; + import { tmpdir } from 'node:os'; + import { join } from 'node:path'; + const dbPath = join(tmpdir(), 'scopeweave-rate-limit-guard-' + randomUUID() + '.sqlite'); + process.env.SCOPEWEAVE_DB = dbPath; process.env.SCOPEWEAVE_JWT_SECRET = '${validJwtSecret}'; process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '1'; process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX = '8'; - delete process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS; - const [{ app }, { db }] = await Promise.all([ - import('./server/application_routes.mjs'), - import('./server/db.mjs'), - ]); - const signup = await app.request('/api/auth/signup', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ email: 'rate-limit-owner@example.com', password: 'correct-horse-battery-staple' }), - }); - assert.equal(signup.status, 200); - const { token } = await signup.json(); - db.exec('DROP TABLE invites'); - const blocked = await app.request('/api/invites/attacker-controlled-token/accept', { - method: 'POST', - headers: { authorization: 'Bearer ' + token }, - }); - assert.equal( - blocked.status, - 429, - 'the supported shared-boundary limiter must reject an over-limit invite request before identity/invite database work', - ); + process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS = '127.0.0.1'; + const nodeEnv = { incoming: { socket: { remoteAddress: '127.0.0.1' } } }; + let app; + const requestFrom = (client, path, options = {}) => app.request(path, { + ...options, + headers: { 'content-type': 'application/json', ...(options.headers || {}), 'x-forwarded-for': client }, + }, nodeEnv); + const originalLog = console.log; + const requestLogs = []; + let db; + console.log = (line) => requestLogs.push(String(line)); + try { + const [{ app: loadedApp }, { db: loadedDb }, { signToken }] = await Promise.all([ + import('./server/application_routes.mjs'), + import('./server/db.mjs'), + import('./server/auth.mjs'), + ]); + app = loadedApp; + db = loadedDb; + db.prepare('INSERT INTO users(id,email,password_hash,name) VALUES(?,?,?,?)') + .run(1, 'rate-limit-owner@example.com', '', ''); + db.prepare('INSERT INTO users(id,email,password_hash,name) VALUES(?,?,?,?)') + .run(2, 'wrong-identity@example.com', '', ''); + db.prepare('INSERT INTO orgs(id,name,owner_id) VALUES(?,?,?)').run(1, 'rate-limit-org', 1); + db.prepare('INSERT INTO invites(id,org_id,email,role,token,invited_by) VALUES(?,?,?,?,?,?)') + .run(1, 1, 'intended-invitee@example.com', 'viewer', 'invite-ordering-sentinel', 1); + const attackerToken = signToken({ sub: 2, email: 'wrong-identity@example.com', tv: 0 }); + const inviteToken = 'invite-ordering-sentinel'; + + const first = await requestFrom('198.51.100.77', '/api/invites/' + inviteToken + '/accept', { + method: 'POST', + headers: { authorization: 'Bearer ' + attackerToken }, + }); + assert.equal(first.status, 404, 'the first wrong-identity invite request reaches the guard'); + + db.exec('DROP TABLE invites'); + const second = await requestFrom('198.51.100.77', '/api/invites/' + inviteToken + '/accept', { + method: 'POST', + headers: { authorization: 'Bearer ' + attackerToken }, + }); + assert.equal( + second.status, + 429, + 'the exhausted shared-boundary limiter rejects before invite identity-row lookup', + ); + + const inviteLogs = requestLogs + .map((line) => { + try { return JSON.parse(line); } catch { return null; } + }) + .filter((entry) => entry?.path === '/api/invites/:token/accept'); + assert.deepEqual( + inviteLogs.map(({ method, status }) => ({ method, status })), + [{ method: 'POST', status: 404 }, { method: 'POST', status: 429 }], + 'guard accounting and blocked observability retain the original POST method', + ); + } finally { + console.log = originalLog; + db?.close(); + for (const suffix of ['', '-shm', '-wal']) rmSync(dbPath + suffix, { force: true }); + } `); assert.equal( inviteOrderingProbe.status, @@ -102,4 +146,4 @@ assert.equal( `Shared-boundary limiter-order regression failed:\n${inviteOrderingProbe.stderr}`, ); -console.log('✓ shared-boundary rate-limit regressions passed'); \ No newline at end of file +console.log('✓ shared-boundary rate-limit regressions passed'); From f36eb24a7ab47b880a1115a0d978a6011fa26048 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 15:31:12 +0900 Subject: [PATCH 120/120] fix(auth): unify Unicode identity and OIDC form boundaries --- CHANGELOG.md | 6 +++++ server/application_routes.mjs | 16 +++++------ server/application_routes_core.mjs | 9 ++++--- server/application_routes_implementation.mjs | 18 ++++++------- server/db.mjs | 2 +- server/public_https_transport.mjs | 1 + tests/api/email-identity.test.mjs | 16 +++++++++++ tests/api/email-unicode-identity.test.mjs | 4 +-- tests/api/oidc-provider-metadata.test.mjs | 20 +++++++++++--- tests/unit/public-https-transport.test.mjs | 28 ++++++++++++++++++++ 10 files changed, 90 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01ee63a2..d91402f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Redacted invite and public-share bearer-token path segments in both ordinary request logs and rate-limit rejection logs, including malformed trailing paths, so access-log retention cannot become a credential disclosure channel. +- Unified server mailbox canonicalization across password, invitation, OIDC, + and legacy-migration boundaries with trim, NFC normalization, lowercase + conversion, and canonical unique-index enforcement. +- Made the public HTTPS transport serialize URLSearchParams as UTF-8 bytes so + production OIDC token exchanges reach the provider instead of failing at the + request boundary. - Replaced the unsigned `POST /api/stripe/webhook` plan-upgrade stub with a fail-closed raw-body HMAC-SHA-256 signature boundary. Signed deliveries are acknowledged only; webhook JSON is not entitlement authority until durable diff --git a/server/application_routes.mjs b/server/application_routes.mjs index d0a30067..51744120 100644 --- a/server/application_routes.mjs +++ b/server/application_routes.mjs @@ -6,7 +6,7 @@ import { randomBytes, verify as verifySignature, } from 'node:crypto'; -import { db, rowid } from './db.mjs'; +import { canonicalEmail, db, rowid } from './db.mjs'; import { createExpiringAuthStateStore, hashApiToken, @@ -50,10 +50,6 @@ const productionOidcConfigured = Boolean(OIDC_ISSUER && OIDC_CLIENT_ID && OIDC_C const productionOidcStates = createExpiringAuthStateStore(); const OIDC_RESPONSE_MAX_BYTES = 256 * 1024; -function normalizeIdentityEmail(value) { - return String(value ?? '').trim().toLowerCase(); -} - function authenticatedIdentityHint(c) { const header = c.req.header('authorization') || ''; const token = header.startsWith('Bearer ') ? header.slice(7) : ''; @@ -65,14 +61,14 @@ function authenticatedIdentityHint(c) { JOIN users u ON u.id = t.user_id WHERE t.token_hash = ?`, ).get(hashApiToken(token)); - return user ? { id: user.id, email: normalizeIdentityEmail(user.email) } : null; + return user ? { id: user.id, email: canonicalEmail(user.email) } : null; } try { const payload = verifyToken(token); const user = db.prepare('SELECT id, email, token_version FROM users WHERE id = ?').get(payload.sub); if (!user || Number(payload.tv ?? 0) !== Number(user.token_version ?? 0)) return null; - return { id: user.id, email: normalizeIdentityEmail(user.email) }; + return { id: user.id, email: canonicalEmail(user.email) }; } catch { return null; } @@ -93,7 +89,7 @@ async function bindInviteToAuthenticatedIdentity(c, next) { .get(c.req.param('token')); if (!invite || invite.accepted_at) return next(); - const invitedEmail = normalizeIdentityEmail(invite.email); + const invitedEmail = canonicalEmail(invite.email); if (!invitedEmail || !identity.email || invitedEmail !== identity.email) { return guardRejectionThroughCoreAbuseControls(c, { error: 'invalid or used invite' }); } @@ -208,10 +204,10 @@ async function verifyProductionOidcIdentity(idToken, expectedNonce, metadata) { && claims.sub.length > 0 && claims.email_verified === true && typeof claims.email === 'string' - && normalizeIdentityEmail(claims.email).length > 0; + && canonicalEmail(claims.email).length > 0; if (!identityClaimsValid) throw new Error('oidc_claims_invalid'); - return normalizeIdentityEmail(claims.email); + return canonicalEmail(claims.email); } function upsertProductionSsoUser(email) { diff --git a/server/application_routes_core.mjs b/server/application_routes_core.mjs index 5f8d9376..84540906 100644 --- a/server/application_routes_core.mjs +++ b/server/application_routes_core.mjs @@ -1,5 +1,6 @@ import { Hono } from 'hono'; import { app as implementationRoutes } from './application_routes_implementation.mjs'; +import { canonicalEmail } from './db.mjs'; /** * Low-level ScopeWeave route graph with a fail-closed production OIDC boundary. @@ -23,9 +24,9 @@ const coreOidcMockEnabled = * normalization contract. Canonicalizing at this low-level shared boundary * guarantees that signup duplicate checks, stored identities, login lookups, * workspace labels, and signed session claims all receive the same trimmed - * lowercase email. `db.mjs` separately migrates legacy rows and enforces a - * canonical unique index, so direct database writes cannot recreate a split - * identity. + * NFC-normalized lowercase email. `db.mjs` separately migrates legacy rows and + * enforces a canonical unique index, so direct database writes cannot recreate + * a split identity. * * @param {import('hono').Context} c Current Hono request context. * @returns {Promise} Response from the implementation route graph. @@ -35,7 +36,7 @@ async function forwardCanonicalPasswordAuth(c) { const payload = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; - const email = String(payload.email ?? '').trim().toLowerCase(); + const email = canonicalEmail(payload.email); const headers = new Headers(c.req.raw.headers); headers.set('content-type', 'application/json'); headers.delete('content-length'); diff --git a/server/application_routes_implementation.mjs b/server/application_routes_implementation.mjs index e5a55fd5..7d464a86 100644 --- a/server/application_routes_implementation.mjs +++ b/server/application_routes_implementation.mjs @@ -5,7 +5,7 @@ import { StripeWebhookError, verifyStripeWebhookRequest } from './stripe_webhook import { Hono } from 'hono'; import { readFile } from 'node:fs/promises'; import { randomBytes, createHmac, createHash } from 'node:crypto'; -import { db, rowid } from './db.mjs'; +import { canonicalEmail, 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'; @@ -154,7 +154,8 @@ app.use('*', async (c, next) => { app.use('*', createRateLimitMiddleware()); app.post('/api/auth/signup', async (c) => { - const { email, password, name } = await c.req.json().catch(() => ({})); + const { email: rawEmail, password, name } = await c.req.json().catch(() => ({})); + const email = canonicalEmail(rawEmail); if (!email || typeof password !== 'string' || password.length < 8) { return c.json({ error: 'email and password (min 8 chars) required' }, 400); } @@ -177,8 +178,9 @@ app.post('/api/auth/signup', async (c) => { }); 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 || ''); + const { email: rawEmail, password } = await c.req.json().catch(() => ({})); + const email = canonicalEmail(rawEmail); + 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)) { @@ -463,7 +465,7 @@ app.post('/api/orgs/:id/invites', requireAuth, async (c) => { 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 email = canonicalEmail(body.email); 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); @@ -480,10 +482,7 @@ app.post('/api/invites/:token/accept', requireAuth, (c) => { 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 identity = db.prepare('SELECT email FROM users WHERE id = ?').get(uid); - if ( - String(inv.email || '').trim().toLowerCase() - !== String(identity?.email || '').trim().toLowerCase() - ) { + if (canonicalEmail(inv.email) !== canonicalEmail(identity?.email)) { return c.json({ error: 'invalid or used invite' }, 404); } const existing = orgRole(uid, inv.org_id); @@ -807,6 +806,7 @@ const oidcStates = new Map(); // state -> { verifier, exp } const oidcCodes = new Map(); // mock only: code -> email function upsertSsoUser(email) { + email = canonicalEmail(email); let user = db.prepare('SELECT id, email, token_version FROM users WHERE email = ?').get(email); if (user) return user; db.exec('BEGIN'); diff --git a/server/db.mjs b/server/db.mjs index adbf5ce8..d61dd54f 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -8,7 +8,7 @@ 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); -const canonicalEmail = (value) => String(value ?? '').trim().toLowerCase(); +export const canonicalEmail = (value) => String(value ?? '').trim().normalize('NFC').toLowerCase().normalize('NFC'); db.function('scopeweave_canonical_email', { deterministic: true }, canonicalEmail); db.exec("PRAGMA journal_mode = WAL"); db.exec("PRAGMA foreign_keys = ON"); diff --git a/server/public_https_transport.mjs b/server/public_https_transport.mjs index 165b5a23..7bd02cc1 100644 --- a/server/public_https_transport.mjs +++ b/server/public_https_transport.mjs @@ -226,6 +226,7 @@ async function fetchFromCandidate(destination, candidate, options, request) { 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 URLSearchParams) req.end(Buffer.from(body.toString(), 'utf8')); else if (body instanceof ArrayBuffer) req.end(new Uint8Array(body)); else fail(); }), signal); diff --git a/tests/api/email-identity.test.mjs b/tests/api/email-identity.test.mjs index 8a0f4424..35a07399 100644 --- a/tests/api/email-identity.test.mjs +++ b/tests/api/email-identity.test.mjs @@ -59,6 +59,22 @@ response = await request('/api/auth/signup', { }); assert.equal(response.status, 409, 'canonical-equivalent signup cannot create a second identity'); +response = await request('/api/auth/signup', { + method: 'POST', + body: jsonBody({ email: 'é@example.com', password: 'password123' }), +}); +assert.equal(response.status, 200, 'composed Unicode mailbox signup succeeds'); +response = await request('/api/auth/signup', { + method: 'POST', + body: jsonBody({ email: 'e\u0301@example.com', password: 'password123' }), +}); +assert.equal(response.status, 409, 'decomposed Unicode mailbox cannot create a duplicate identity'); +response = await request('/api/auth/login', { + method: 'POST', + body: jsonBody({ email: 'e\u0301@example.com', password: 'password123' }), +}); +assert.equal(response.status, 200, 'decomposed Unicode mailbox resolves the composed identity'); + function seedLegacyUsers(rows) { const directory = mkdtempSync(join(tmpdir(), 'scopeweave-email-identity-')); const databasePath = join(directory, 'legacy.sqlite'); diff --git a/tests/api/email-unicode-identity.test.mjs b/tests/api/email-unicode-identity.test.mjs index c2fe3f41..5f3bbb49 100644 --- a/tests/api/email-unicode-identity.test.mjs +++ b/tests/api/email-unicode-identity.test.mjs @@ -38,7 +38,7 @@ function migrate(databasePath) { } { - const { directory, databasePath } = seed([{ id: 1, email: ' ÄLICE@Example.COM ', name: 'Legacy' }]); + const { directory, databasePath } = seed([{ id: 1, email: ' A\u0308LICE@Example.COM ', name: 'Legacy' }]); try { const result = migrate(databasePath); assert.equal(result.status, 0, `unicode legacy migration succeeds: ${result.stderr}`); @@ -52,7 +52,7 @@ function migrate(databasePath) { { const { directory, databasePath } = seed([ - { id: 1, email: 'Älice@example.com', name: 'First' }, + { id: 1, email: 'A\u0308lice@example.com', name: 'First' }, { id: 2, email: 'älice@example.com', name: 'Second' }, ]); try { diff --git a/tests/api/oidc-provider-metadata.test.mjs b/tests/api/oidc-provider-metadata.test.mjs index af3726f5..a2298156 100644 --- a/tests/api/oidc-provider-metadata.test.mjs +++ b/tests/api/oidc-provider-metadata.test.mjs @@ -15,6 +15,7 @@ const publicJwk = { ...publicKey.export({ format: 'jwk' }), kid: 'metadata-key', const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); let issuedIdToken = ''; let discoveryMode = 'valid'; +const tokenBodies = []; function signedIdentity(nonce, email = 'sso.user@example.com') { const now = Math.floor(Date.now() / 1000); @@ -33,7 +34,7 @@ function signedIdentity(nonce, email = 'sso.user@example.com') { return `${input}.${sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`; } -const oidcFetch = async (url) => { +const oidcFetch = async (url, options = {}) => { const target = String(url); if (target === 'https://issuer.example/tenant/.well-known/openid-configuration') { if (discoveryMode === 'private-endpoints') { @@ -51,7 +52,10 @@ const oidcFetch = async (url) => { jwks_uri: 'https://keys.example/oidc/jwks.json', }); } - if (target === 'https://tokens.example/oauth2/v2/token') return Response.json({ id_token: issuedIdToken }); + if (target === 'https://tokens.example/oauth2/v2/token') { + tokenBodies.push(options.body); + return Response.json({ id_token: issuedIdToken }); + } if (target === 'https://keys.example/oidc/jwks.json') return Response.json({ keys: [publicJwk] }); throw new Error(`unexpected OIDC fetch: ${target}`); }; @@ -59,6 +63,7 @@ const oidcFetch = async (url) => { const { configurePublicHttpsTransportForTests } = await import('../../server/public_https_transport.mjs'); configurePublicHttpsTransportForTests({ fetch: oidcFetch }); const { app } = await import('../../server/application_routes.mjs?provider-metadata=1'); +const { db } = await import('../../server/db.mjs'); async function begin() { const response = await app.request('https://scopeweave.example/api/auth/oidc/start'); @@ -82,15 +87,22 @@ assert.equal(response.status, 404, 'discovery metadata cannot authorize server-s discoveryMode = 'valid'; let flow = await begin(); -issuedIdToken = signedIdentity(flow.nonce); +issuedIdToken = signedIdentity(flow.nonce, 'e\u0301xample@example.com'); response = await app.request(`https://scopeweave.example/api/auth/oidc/callback?state=${encodeURIComponent(flow.state)}&code=first-code`); assert.equal(response.status, 302, 'token exchange uses the discovered token endpoint and accepts the signed identity'); +assert.ok(tokenBodies[0] instanceof URLSearchParams, 'production OIDC sends a URLSearchParams token body'); +assert.match(tokenBodies[0].toString(), /grant_type=authorization_code/); assert.equal((await metrics()).signups, 1, 'creating an OIDC-backed account increments the same signup metric as password signup'); flow = await begin(); -issuedIdToken = signedIdentity(flow.nonce); +issuedIdToken = signedIdentity(flow.nonce, 'éxample@example.com'); response = await app.request(`https://scopeweave.example/api/auth/oidc/callback?state=${encodeURIComponent(flow.state)}&code=second-code`); assert.equal(response.status, 302); +assert.equal( + db.prepare('SELECT email FROM users WHERE email = ?').get('éxample@example.com')?.email, + 'éxample@example.com', + 'decomposed OIDC mailbox is stored in NFC form', +); assert.equal((await metrics()).signups, 1, 'reusing an existing OIDC account does not double-count signup'); console.log('OIDC provider metadata and signup accounting contract passed'); diff --git a/tests/unit/public-https-transport.test.mjs b/tests/unit/public-https-transport.test.mjs index 57b62b3e..2b8be29c 100644 --- a/tests/unit/public-https-transport.test.mjs +++ b/tests/unit/public-https-transport.test.mjs @@ -81,6 +81,33 @@ const noContent = createPublicHttpsTransport({ }); assert.equal(await (await noContent.fetch('https://idp.example.test/empty')).text(), ''); +let encodedBody; +const formBodyTransport = createPublicHttpsTransport({ + lookup: async () => [PUBLIC_A], + request: (_url, _options, callback) => { + const req = new EventEmitter(); + req.end = (body) => { + encodedBody = body; + const upstream = new EventEmitter(); + upstream.statusCode = 200; + upstream.headers = {}; + callback(upstream); + queueMicrotask(() => upstream.emit('end')); + }; + return req; + }, +}); +await formBodyTransport.fetch('https://idp.example.test/token', { + method: 'POST', + body: new URLSearchParams({ code: 'one-time', redirect_uri: 'https://scopeweave.example/callback' }), +}); +assert.ok(Buffer.isBuffer(encodedBody)); +assert.equal( + encodedBody.toString('utf8'), + 'code=one-time&redirect_uri=https%3A%2F%2Fscopeweave.example%2Fcallback', + 'URLSearchParams bodies are serialized as UTF-8 bytes before transport', +); + const oversized = createPublicHttpsTransport({ lookup: async () => [PUBLIC_A], request: (_url, _options, callback) => { @@ -132,6 +159,7 @@ const controller = new AbortController(); controller.abort(); await assert.rejects(transport.fetch('https://idp.example.test/jwks', { signal: controller.signal }), PublicHttpsTransportError); +delete process.env.NODE_ENV; assert.throws( () => configurePublicHttpsTransportForTests({ fetch() {} }), /test-only/,