diff --git a/cloud-sync-core.js b/cloud-sync-core.js new file mode 100644 index 00000000..0e015ebe --- /dev/null +++ b/cloud-sync-core.js @@ -0,0 +1,2208 @@ +// ScopeWeave cloud sync — an OPT-IN overlay on the offline planner. +// Logged out, every export here is a no-op and the app behaves exactly as the +// original localStorage-only planner (so existing e2e tests are unaffected). +// Logged in with a project open, edits sync to the API with optimistic +// concurrency and a project sees other tabs' changes live over SSE. + +const TOKEN_KEY = 'scopeweave:token'; +const PROJECT_KEY = 'scopeweave:project'; +const ROUTE_TOKEN_RE = /^[A-Za-z0-9_-]{16,128}$/; + +let host = null; // { hydrateState, renderAll, getState } provided by app.js +let version = 0; // open project's doc version (optimistic concurrency) +let sse = null; +let pushTimer = null; +let currentOrgId = null; // org of the open project, for team management +let shareMode = false; // viewing via a public share token → read-only + +const getToken = () => localStorage.getItem(TOKEN_KEY) || ''; +const setToken = (t) => (t ? localStorage.setItem(TOKEN_KEY, t) : localStorage.removeItem(TOKEN_KEY)); +const getProjectId = () => localStorage.getItem(PROJECT_KEY) || ''; +const setProjectId = (id) => (id ? localStorage.setItem(PROJECT_KEY, String(id)) : localStorage.removeItem(PROJECT_KEY)); +const isAuthed = () => Boolean(getToken()); + +export function routeTokenPathSegment(value) { + const token = String(value || '').trim(); + return ROUTE_TOKEN_RE.test(token) ? token : ''; +} + +function safeApiPath(path) { + if (typeof path !== 'string' || !path.startsWith('/api/')) throw new Error('invalid api path'); + const origin = typeof location !== 'undefined' ? location.origin : 'http://localhost'; + const url = new URL(path, origin); + if (url.origin !== origin || !(url.pathname === '/api' || url.pathname.startsWith('/api/'))) { + throw new Error('invalid api path'); + } + return `${url.pathname}${url.search}`; +} + +function toast(message) { + const el = document.getElementById('toast'); + if (!el) return; + el.textContent = message; + el.classList.add('visible'); + clearTimeout(toast._t); + toast._t = setTimeout(() => el.classList.remove('visible'), 3200); +} + +async function api(path, { method = 'GET', body } = {}) { + const res = await fetch(safeApiPath(path), { + method, + headers: { + 'content-type': 'application/json', + ...(getToken() ? { authorization: `Bearer ${getToken()}` } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (res.status === 401) { setToken(''); setProjectId(''); renderAuthUI(); throw new Error('unauthorized'); } + const data = await res.json().catch(() => ({})); + if (!res.ok) throw Object.assign(new Error(data.error || res.statusText), { status: res.status, data }); + return data; +} + +// ---- realtime (EventSource can't set headers → token via query; ceiling: +// swap for a short-lived stream token before prod so JWTs stay out of URLs) +function subscribe(id) { + if (sse) { sse.close(); sse = null; } + sse = new EventSource(`/api/projects/${id}/stream?token=${encodeURIComponent(getToken())}`); + sse.onmessage = (ev) => { + let msg; + try { msg = JSON.parse(ev.data); } catch { return; } + if (msg.type === 'update' && typeof msg.version === 'number' && msg.version > version) { + openProject(id, { silent: true }).then(() => toast('실시간 업데이트를 반영했습니다.')).catch(() => {}); + } + }; +} + +async function openProject(id, { silent = false } = {}) { + const p = await api(`/api/projects/${id}`); + setProjectId(id); + currentOrgId = p.orgId || projectsCache.find((x) => String(x.id) === String(id))?.orgId || currentOrgId; + version = p.version; + host?.hydrateState({ projectName: p.name, baseDate: p.baseDate, tasks: p.tasks }); + host?.renderAll(); + subscribe(id); + // opening = seen: clear the unseen badge for this project + notifCache.delete(String(id)); + api(`/api/projects/${id}/seen`, { method: 'POST' }).catch(() => {}); + renderAuthUI(); + if (!silent) toast(`'${p.name}' 프로젝트를 열었습니다.`); +} + +async function doPush(payload) { + clearTimeout(pushTimer); + pushTimer = null; + try { + const r = await api(`/api/projects/${getProjectId()}`, { + method: 'PUT', + body: { name: payload.projectName, baseDate: payload.baseDate, tasks: payload.tasks, version }, + }); + version = r.version; + } catch (e) { + if (e.status === 409) { + await openProject(getProjectId(), { silent: true }).catch(() => {}); + toast('다른 사용자가 먼저 저장하여 최신본을 불러왔습니다.'); + } else if (e.message !== 'unauthorized') { + toast('클라우드 저장 실패 — 로컬에는 저장되었습니다.'); + } + } +} + +// ---------------------------------------------------------------- public API +export const cloud = { + init(hostApi) { + host = hostApi; + ensureAuthUI(); + renderAuthUI(); + if (isAuthed()) refreshProjects().then(renderAuthUI).catch(() => {}); + }, + // Returns the saved project state to hydrate, or null (→ local/seed path). + async boot() { + // public read-only share view (?share=TOKEN) — no account needed + const shareToken = routeTokenPathSegment(new URLSearchParams(location.search).get('share')); + if (shareToken) { + try { + const p = await api(`/api/shared/${shareToken}`); + shareMode = true; + renderAuthUI(); + toast('읽기 전용 공유 보기입니다 — 변경은 저장되지 않습니다.'); + return { projectName: p.name, baseDate: p.baseDate, tasks: p.tasks }; + } catch { + toast('공유 링크가 만료되었거나 철회되었습니다.'); + } + } + if (!isAuthed() || !getProjectId()) { renderAuthUI(); return null; } + try { + const p = await api(`/api/projects/${getProjectId()}`); + version = p.version; + currentOrgId = p.orgId || currentOrgId; // team/dashboard need the org right after reload + subscribe(p.id); + renderAuthUI(); + return { projectName: p.name, baseDate: p.baseDate, tasks: p.tasks }; + } catch { + renderAuthUI(); + return null; + } + }, + // Called from persistState(). No-op unless logged in with a project open. + push(payload) { + if (shareMode) return; // read-only share view never writes + if (!isAuthed() || !getProjectId()) return; + clearTimeout(pushTimer); + pushTimer = setTimeout(() => doPush(payload), 600); + }, +}; + +// ------------------------------------------------------------------- auth UI +function ensureAuthUI() { + if (document.getElementById('cloud-auth')) return; + const titleRow = document.querySelector('.title-row'); + if (!titleRow) return; + const bar = document.createElement('div'); + bar.id = 'cloud-auth'; + bar.className = 'cloud-auth'; + titleRow.appendChild(bar); + + // modal (reuses .modal/.hidden conventions from the gantt modal) + const modal = document.createElement('div'); + modal.id = 'cloud-modal'; + modal.className = 'modal hidden'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + modal.setAttribute('aria-labelledby', 'cloud-modal-title'); + modal.innerHTML = ` + + `; + document.body.appendChild(modal); + + let mode = 'login'; + const $ = (id) => modal.querySelector(id); + const setMode = (m) => { + mode = m; + $('#cloud-modal-title').textContent = m === 'login' ? '클라우드 로그인' : '계정 만들기'; + $('#cloud-submit').textContent = m === 'login' ? '로그인' : '가입'; + $('#cloud-toggle').textContent = m === 'login' ? '계정 만들기' : '로그인으로'; + modal.querySelector('.cloud-name-field').classList.toggle('hidden', m !== 'signup'); + $('#cloud-error').textContent = ''; + }; + $('#cloud-toggle').addEventListener('click', () => setMode(mode === 'login' ? 'signup' : 'login')); + $('#cloud-sso').addEventListener('click', () => { window.location.href = '/api/auth/oidc/start'; }); + modal.addEventListener('click', (e) => { if (e.target.dataset.cloudClose) modal.classList.add('hidden'); }); + $('#cloud-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const email = $('#cloud-email').value.trim(); + const password = $('#cloud-password').value; + const name = $('#cloud-name').value.trim(); + try { + const r = await api(`/api/auth/${mode === 'login' ? 'login' : 'signup'}`, { method: 'POST', body: { email, password, name } }); + setToken(r.token); + modal.classList.add('hidden'); + await refreshProjects(); + renderAuthUI(); + toast(mode === 'login' ? '로그인되었습니다.' : '가입되어 클라우드 저장이 켜졌습니다.'); + } catch (err) { + $('#cloud-error').textContent = err.data?.error || err.message || '요청 실패'; + } + }); + bar._openModal = () => { setMode('login'); modal.classList.remove('hidden'); $('#cloud-email').focus(); }; +} + +let projectsCache = []; +let notifCache = new Map(); // projectId -> unseen count + +async function refreshProjects() { + try { projectsCache = (await api('/api/projects')).projects || []; } catch { projectsCache = []; } + try { + const n = await api('/api/notifications'); + notifCache = new Map((n.notifications || []).map((x) => [String(x.projectId), x.unseen])); + } catch { notifCache = new Map(); } +} + +function renderAuthUI() { + const bar = document.getElementById('cloud-auth'); + if (!bar) return; + bar.textContent = ''; + if (shareMode) { + const tag = document.createElement('span'); + tag.className = 'team-role-tag'; + tag.textContent = '읽기 전용 공유 보기'; + bar.appendChild(tag); + return; + } + if (!isAuthed()) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'secondary-button'; + btn.textContent = '☁ 클라우드 로그인'; + btn.addEventListener('click', openLoginModal); + bar.appendChild(btn); + return; + } + // logged in: onboarding (no projects yet) → sample; else project switcher. + if (!projectsCache.length) { + const sample = document.createElement('button'); + sample.type = 'button'; + sample.className = 'primary-button'; + sample.textContent = '✨ 샘플로 시작'; + sample.addEventListener('click', sampleStart); + bar.appendChild(sample); + } + const select = document.createElement('select'); + select.className = 'cloud-select'; + select.setAttribute('aria-label', '프로젝트 선택'); + const openId = getProjectId(); + const ph = document.createElement('option'); + ph.value = ''; + ph.textContent = projectsCache.length ? '프로젝트 선택…' : '프로젝트 없음'; + select.appendChild(ph); + for (const p of projectsCache.filter((x) => !x.archived)) { + const opt = document.createElement('option'); + opt.value = String(p.id); + const unseen = notifCache.get(String(p.id)); + opt.textContent = unseen ? `${p.name} ●${unseen}` : p.name; // textContent → XSS-safe + if (String(p.id) === String(openId)) opt.selected = true; + select.appendChild(opt); + } + const archivedProjects = projectsCache.filter((x) => x.archived); + if (archivedProjects.length) { + const group = document.createElement('optgroup'); + group.label = '보관됨'; + for (const p of archivedProjects) { + const opt = document.createElement('option'); + opt.value = String(p.id); + opt.textContent = `📦 ${p.name}`; + if (String(p.id) === String(openId)) opt.selected = true; + group.appendChild(opt); + } + select.appendChild(group); + } + select.addEventListener('change', () => { if (select.value) openProject(select.value).catch((e) => toast(e.message)); }); + bar.appendChild(select); + + const newBtn = document.createElement('button'); + newBtn.type = 'button'; + newBtn.className = 'secondary-button'; + newBtn.textContent = '+ 새 프로젝트'; + newBtn.addEventListener('click', createProjectFlow); + bar.appendChild(newBtn); + + const dash = document.createElement('button'); + dash.type = 'button'; + dash.className = 'secondary-button'; + dash.textContent = '대시보드'; + dash.addEventListener('click', () => openPortfolioModal().catch((e) => toast(e.message || '대시보드를 불러오지 못했습니다.'))); + bar.appendChild(dash); + + const team = document.createElement('button'); + team.type = 'button'; + team.className = 'secondary-button'; + team.textContent = '팀'; + team.addEventListener('click', () => openTeamModal().catch((e) => toast(e.message || '팀 정보를 불러오지 못했습니다.'))); + bar.appendChild(team); + + if (getProjectId()) { + const bl = document.createElement('button'); + bl.type = 'button'; + bl.className = 'secondary-button'; + bl.textContent = '기준선'; + bl.addEventListener('click', () => openBaselineModal().catch((e) => toast(e.message || '기준선을 불러오지 못했습니다.'))); + bar.appendChild(bl); + + const dup = document.createElement('button'); + dup.type = 'button'; + dup.className = 'secondary-button'; + dup.textContent = '복제'; + dup.addEventListener('click', async () => { + const name = prompt('새 프로젝트 이름 (템플릿으로 복제)'); + if (name === null) return; + try { + const created = await api(`/api/projects/${getProjectId()}/duplicate`, { method: 'POST', body: { name } }); + await refreshProjects(); + await openProject(created.id); + toast(`"${created.name}" 프로젝트로 복제했습니다.`); + } catch (err) { toast(err.data?.error || err.message); } + }); + bar.appendChild(dup); + + const share = document.createElement('button'); + share.type = 'button'; + share.className = 'secondary-button'; + share.textContent = '공유'; + share.addEventListener('click', () => openShareModal().catch((e) => toast(e.data?.error || e.message))); + bar.appendChild(share); + + const report = document.createElement('button'); + report.type = 'button'; + report.className = 'secondary-button'; + report.textContent = '주간보고'; + report.addEventListener('click', () => { try { openReportModal(); } catch (e) { toast(e.message || '보고서 생성 실패'); } }); + bar.appendChild(report); + + const msp = document.createElement('button'); + msp.type = 'button'; + msp.className = 'secondary-button'; + msp.textContent = 'MSP 가져오기'; + msp.addEventListener('click', () => { + let fi = document.getElementById('msp-file-input'); + if (!fi) { + fi = document.createElement('input'); + fi.id = 'msp-file-input'; + fi.type = 'file'; + fi.accept = '.xml,text/xml'; + fi.hidden = true; + fi.addEventListener('change', () => { + const f = fi.files?.[0]; + fi.value = ''; + if (f) importMsProjectFile(f).catch((e) => toast(e.message || 'MSP 가져오기에 실패했습니다.')); + }); + document.body.appendChild(fi); + } + fi.click(); + }); + bar.appendChild(msp); + + const cur = projectsCache.find((x) => String(x.id) === String(getProjectId())); + const arch = document.createElement('button'); + arch.type = 'button'; + arch.className = 'secondary-button'; + arch.textContent = cur?.archived ? '보관 해제' : '보관'; + arch.addEventListener('click', async () => { + try { + const res = await api(`/api/projects/${getProjectId()}/archive`, { method: 'POST', body: { archived: !cur?.archived } }); + await refreshProjects(); + renderAuthUI(); + toast(res.archived ? '프로젝트를 보관했습니다.' : '보관을 해제했습니다.'); + } catch (err) { toast(err.data?.error || err.message); } + }); + bar.appendChild(arch); + } + + const search = document.createElement('button'); + search.type = 'button'; + search.className = 'secondary-button'; + search.textContent = '검색'; + search.addEventListener('click', openSearchModal); + bar.appendChild(search); + + if (getProjectId()) { + const spr = document.createElement('button'); + spr.type = 'button'; + spr.className = 'secondary-button'; + spr.textContent = '스프린트'; + spr.addEventListener('click', () => openSprintModal().catch((e) => toast(e.data?.error || e.message))); + bar.appendChild(spr); + + const att = document.createElement('button'); + att.type = 'button'; + att.className = 'secondary-button'; + att.textContent = '산출물'; + att.addEventListener('click', () => openAttachmentsModal().catch((e) => toast(e.data?.error || e.message))); + bar.appendChild(att); + + const cmt = document.createElement('button'); + cmt.type = 'button'; + cmt.className = 'secondary-button'; + cmt.textContent = '코멘트'; + cmt.addEventListener('click', () => openCommentsModal().catch((e) => toast(e.message || '코멘트를 불러오지 못했습니다.'))); + bar.appendChild(cmt); + } + + const out = document.createElement('button'); + out.type = 'button'; + out.className = 'secondary-button'; + out.textContent = '로그아웃'; + out.addEventListener('click', () => { + if (sse) { sse.close(); sse = null; } + setToken(''); setProjectId(''); projectsCache = []; + renderAuthUI(); + toast('로그아웃되었습니다. 로컬 저장으로 전환합니다.'); + }); + bar.appendChild(out); +} + +function openLoginModal() { + const modal = document.getElementById('cloud-modal'); + const bar = document.getElementById('cloud-auth'); + if (bar && bar._openModal) return bar._openModal(); + modal?.classList.remove('hidden'); +} + +// Create a cloud project and seed it with `seedState` (defaults to what's on +// screen). Used by both "새 프로젝트" and the "샘플로 시작" onboarding. +async function makeProject(name, seedState) { + const r = await api('/api/projects', { method: 'POST', body: { name } }); + await refreshProjects(); + version = r.version; + setProjectId(r.id); + const meta = projectsCache.find((x) => String(x.id) === String(r.id)); + if (meta) currentOrgId = meta.orgId; + const base = seedState || host?.getState?.() || { baseDate: '', tasks: [] }; + await doPush({ ...base, projectName: name }); // keep the chosen project name + subscribe(r.id); + renderAuthUI(); + return r; +} + +async function createProjectFlow() { + const name = prompt('새 프로젝트 이름'); + if (!name || !name.trim()) return; + try { + await makeProject(name.trim()); + toast(`'${name.trim()}' 프로젝트를 만들었습니다.`); + } catch (e) { + toast(e.message || '프로젝트 생성 실패'); + } +} + +// Onboarding: a first project pre-populated from the app's source-backed seed +// (whatever app.js has loaded on screen — the wbs.json sample for a new user). +async function sampleStart() { + try { + await makeProject('샘플 프로젝트', host?.getState?.()); + toast('샘플 프로젝트로 시작했습니다. 자유롭게 편집하세요.'); + } catch (e) { + toast(e.message || '샘플 프로젝트 생성 실패'); + } +} + +// ------------------------------------------------------------- team / RBAC UI +const ROLE_LABELS = { owner: '소유자', admin: '관리자', member: '멤버', viewer: '뷰어' }; + +async function exportOrg() { + try { + const res = await fetch(`/api/orgs/${currentOrgId}/export`, { headers: { authorization: `Bearer ${getToken()}` } }); + if (res.status === 403) return toast('소유자만 데이터를 내보낼 수 있습니다.'); + if (!res.ok) return toast('내보내기에 실패했습니다.'); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `scopeweave-org-${currentOrgId}.json`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + toast('워크스페이스 데이터를 내보냈습니다.'); + } catch { + toast('내보내기에 실패했습니다.'); + } +} + +async function resolveOrgId() { + if (currentOrgId) return currentOrgId; + const me = await api('/api/me'); + currentOrgId = me.orgs?.[0]?.id || null; + return currentOrgId; +} + +// ---------------------------------------------------------------- share links +async function openShareModal() { + const pid = getProjectId(); + if (!pid) return; + let modal = document.getElementById('share-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'share-modal'; + modal.className = 'modal'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + const backdrop = document.createElement('div'); + backdrop.className = 'modal-backdrop'; + backdrop.addEventListener('click', () => modal.classList.add('hidden')); + const panel = document.createElement('div'); + panel.className = 'modal-panel'; + panel.id = 'share-panel'; + modal.append(backdrop, panel); + document.body.appendChild(modal); + } + modal.classList.remove('hidden'); + const panel = modal.querySelector('#share-panel'); + panel.textContent = ''; + + const head = document.createElement('div'); + head.className = 'modal-header'; + const h2 = document.createElement('h2'); + h2.textContent = '읽기 전용 공유'; + const close = document.createElement('button'); + close.type = 'button'; + close.className = 'icon-button close-button'; + close.setAttribute('aria-label', '공유 닫기'); + close.textContent = '✕'; + close.addEventListener('click', () => modal.classList.add('hidden')); + head.append(h2, close); + panel.appendChild(head); + + const make = document.createElement('button'); + make.type = 'button'; + make.className = 'primary-button'; + make.textContent = '공유 링크 만들기'; + make.addEventListener('click', async () => { + try { + const res = await api(`/api/projects/${pid}/shares`, { method: 'POST' }); + const url = `${location.origin}${res.url}`; + try { await navigator.clipboard.writeText(url); toast('공유 링크를 복사했습니다.'); } + catch { prompt('공유 링크 (복사하세요)', url); } + openShareModal(); + } catch (e) { toast(e.data?.error || e.message); } + }); + panel.appendChild(make); + + const list = document.createElement('ul'); + list.className = 'team-list'; + panel.appendChild(list); + const data = await api(`/api/projects/${pid}/shares`); + if (!data.shares.length) { + const li = document.createElement('li'); + li.textContent = '활성 공유 링크가 없습니다.'; + list.appendChild(li); + return; + } + for (const sRow of data.shares) { + const li = document.createElement('li'); + const who = document.createElement('span'); + who.className = 'team-who'; + who.textContent = `${location.origin}/?share=${sRow.token.slice(0, 8)}… · ${String(sRow.createdAt).slice(0, 10)}`; + const copyB = document.createElement('button'); + copyB.type = 'button'; + copyB.className = 'secondary-button'; + copyB.textContent = '복사'; + copyB.addEventListener('click', async () => { + const url = `${location.origin}/?share=${sRow.token}`; + try { await navigator.clipboard.writeText(url); toast('복사했습니다.'); } catch { prompt('공유 링크', url); } + }); + const rev = document.createElement('button'); + rev.type = 'button'; + rev.className = 'secondary-button team-remove'; + rev.textContent = '철회'; + rev.addEventListener('click', () => + api(`/api/projects/${pid}/shares/${sRow.id}`, { method: 'DELETE' }) + .then(() => { toast('공유를 철회했습니다.'); openShareModal(); }) + .catch((e) => toast(e.data?.error || e.message))); + li.append(who, copyB, rev); + list.appendChild(li); + } +} + +// ------------------------------------------------------------ weekly report +// 주간보고 generator — the PM deliverable, straight from live data. +// Pure: takes tasks + a reference date, returns markdown. +export function buildWeeklyReport(tasks, refDate, projectName = '') { + const ref = new Date(refDate); + if (Number.isNaN(ref.getTime())) return ''; + const day = (d) => d.toISOString().slice(0, 10); + const monday = new Date(ref); + monday.setDate(ref.getDate() - ((ref.getDay() + 6) % 7)); // this week's Monday + const weekStart = day(monday); + const weekEnd = day(new Date(monday.getTime() + 6 * 86400000)); + const nextStart = day(new Date(monday.getTime() + 7 * 86400000)); + const nextEnd = day(new Date(monday.getTime() + 13 * 86400000)); + const today = day(ref); + const name = (t) => t.name || t.task || t.activity || t.phase || t.id; + const leaf = (tasks || []).filter((t) => !t.isSynthetic); + + const done = leaf.filter((t) => t.actualEndDate && t.actualEndDate >= weekStart && t.actualEndDate <= weekEnd); + const doing = leaf.filter((t) => { + const a = Number(t.actualProgress) || 0; + return a > 0 && a < 100 && !t.actualEndDate; + }); + const late = leaf.filter((t) => t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100); + const upcoming = leaf.filter((t) => t.plannedStartDate && t.plannedStartDate >= nextStart && t.plannedStartDate <= nextEnd); + + let wSum = 0, pv = 0, ev = 0; + for (const t of leaf) { + const w = Number(t.weight) || 1; + wSum += w; + pv += w * ((Number(t.plannedProgress) || 0) / 100); + ev += w * ((Number(t.actualProgress) || 0) / 100); + } + const pvPct = wSum ? (pv / wSum) * 100 : 0; + const evPct = wSum ? (ev / wSum) * 100 : 0; + const spi = pvPct > 0 ? evPct / pvPct : null; + + const section = (title, items, fmt) => + `## ${title}\n${items.length ? items.map((t) => `- ${fmt(t)}`).join('\n') : '- (없음)'}`; + return [ + `# 주간보고${projectName ? ` — ${projectName}` : ''} (${weekStart} ~ ${weekEnd})`, + '', + `**진척 요약**: 계획 ${pvPct.toFixed(1)}% · 실적 ${evPct.toFixed(1)}%` + + (spi === null ? '' : ` · SPI ${spi.toFixed(2)} (${spi >= 1 ? '일정 준수' : spi >= 0.9 ? '경미한 지연' : '지연 위험'})`), + '', + section('금주 완료', done, (t) => `${name(t)} (${t.actualEndDate})`), + '', + section('진행 중', doing, (t) => `${name(t)} — ${Number(t.actualProgress) || 0}%${t.owner ? ` (${t.owner})` : ''}`), + '', + section('지연', late, (t) => `${name(t)} — 계획종료 ${t.plannedEndDate}, 실적 ${Number(t.actualProgress) || 0}%${t.owner ? ` (${t.owner})` : ''}`), + '', + section('차주 예정', upcoming, (t) => `${name(t)} (${t.plannedStartDate} 시작${t.owner ? `, ${t.owner}` : ''})`), + '', + ].join('\n'); +} + +function openReportModal() { + const state = host?.getState?.(); + if (!state) { toast('프로젝트를 먼저 여세요.'); return; } + const md = buildWeeklyReport(state.tasks, new Date().toISOString().slice(0, 10), state.projectName || ''); + let modal = document.getElementById('report-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'report-modal'; + modal.className = 'modal'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + const backdrop = document.createElement('div'); + backdrop.className = 'modal-backdrop'; + backdrop.addEventListener('click', () => modal.classList.add('hidden')); + const panel = document.createElement('div'); + panel.className = 'modal-panel'; + panel.id = 'report-panel'; + modal.append(backdrop, panel); + document.body.appendChild(modal); + } + modal.classList.remove('hidden'); + const panel = modal.querySelector('#report-panel'); + panel.textContent = ''; + const head = document.createElement('div'); + head.className = 'modal-header'; + const h2 = document.createElement('h2'); + h2.textContent = '주간보고'; + const close = document.createElement('button'); + close.type = 'button'; + close.className = 'icon-button close-button'; + close.setAttribute('aria-label', '주간보고 닫기'); + close.textContent = '✕'; + close.addEventListener('click', () => modal.classList.add('hidden')); + head.append(h2, close); + panel.appendChild(head); + + const copy = document.createElement('button'); + copy.type = 'button'; + copy.className = 'primary-button'; + copy.textContent = '마크다운 복사'; + copy.addEventListener('click', async () => { + try { await navigator.clipboard.writeText(md); toast('주간보고를 복사했습니다.'); } + catch { toast('복사에 실패했습니다 — 아래 내용을 직접 선택하세요.'); } + }); + panel.appendChild(copy); + + const ai = document.createElement('button'); + ai.type = 'button'; + ai.className = 'secondary-button'; + ai.style.marginLeft = '8px'; + ai.textContent = 'AI 요약'; + ai.addEventListener('click', async () => { + ai.disabled = true; + ai.textContent = '분석 중…'; + try { + const res = await api(`/api/projects/${getProjectId()}/ai/brief`, { method: 'POST' }); + let box = document.getElementById('report-ai'); + if (!box) { + box = document.createElement('pre'); + box.id = 'report-ai'; + box.style.whiteSpace = 'pre-wrap'; + box.style.borderLeft = '3px solid var(--primary, #2563eb)'; + box.style.paddingLeft = '10px'; + panel.insertBefore(box, panel.querySelector('#report-body')); + } + box.textContent = `🤖 AI 브리핑\n${res.analysis}`; + } catch (e) { toast(e.data?.error || e.message); } + finally { ai.disabled = false; ai.textContent = 'AI 요약'; } + }); + panel.appendChild(ai); + + const pre = document.createElement('pre'); + pre.id = 'report-body'; + pre.style.whiteSpace = 'pre-wrap'; + pre.style.userSelect = 'text'; + pre.textContent = md; + panel.appendChild(pre); +} + +// ------------------------------------------------------- MS Project import +// Parse Microsoft Project XML (Project 2003+ .xml export) into ScopeWeave's +// task schema. ponytail: regex block parsing (MSP XML is machine-generated, +// no DOMParser needed → node-testable); swap for a real XML parser if +// hand-edited files ever matter. +export function parseMsProjectXml(xml) { + // Fully linear extract (indexOf/slice) — no dynamic RegExp and no lazy + // [\s\S]*? block collectors (those can quadratic-backtrack on truncated input). + const isXmlWhitespace = (charCode) => ( + charCode === 0x20 || charCode === 0x09 || charCode === 0x0d || charCode === 0x0a + ); + const findTagBoundary = (source, name, from, closing = false) => { + const prefix = `<${closing ? '/' : ''}${name}`; + let searchFrom = from; + for (;;) { + const start = source.indexOf(prefix, searchFrom); + if (start === -1) return null; + let delimiter = start + prefix.length; + while (delimiter < source.length && isXmlWhitespace(source.charCodeAt(delimiter))) { + delimiter += 1; + } + if (source.charCodeAt(delimiter) === 0x3e) { + return { start, end: delimiter + 1 }; + } + // Reject attributes, longer names, and non-XML whitespace while advancing + // past every inspected byte so malformed candidates are never rescanned. + searchFrom = Math.max(delimiter + 1, start + prefix.length); + } + }; + const tag = (block, name) => { + const opening = findTagBoundary(block, name, 0); + if (!opening) return ''; + const closing = findTagBoundary(block, name, opening.end, true); + const nextOpening = findTagBoundary(block, name, opening.end); + if (!closing || (nextOpening && nextOpening.start < closing.start)) return ''; + return block.slice(opening.end, closing.start).trim(); + }; + const collectBlocks = (source, name) => { + const out = []; + let from = 0; + for (;;) { + const opening = findTagBoundary(source, name, from); + if (!opening) break; + const closing = findTagBoundary(source, name, opening.end, true); + const nextOpening = findTagBoundary(source, name, opening.end); + // Incomplete or nested same-name block: stop at the first unmatched + // opening tag instead of pairing it with a later block's closing tag. + if (!closing || (nextOpening && nextOpening.start < closing.start)) break; + out.push(source.slice(opening.start, closing.end)); + from = closing.end; + } + return out; + }; + const predecessorIds = (block) => { + const ids = []; + for (const link of collectBlocks(block, 'PredecessorLink')) { + const uid = tag(link, 'PredecessorUID'); + if (/^\d+$/.test(uid)) ids.push(`msp-${uid}`); + } + return ids; + }; + const unescape = (s) => s + .replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') + .replace(/'/g, "'").replace(/&/g, '&'); + const day = (s) => (/^\d{4}-\d{2}-\d{2}/.test(s) ? s.slice(0, 10) : ''); + const tasks = []; + const parents = {}; // depth -> last task id at that depth + const blocks = collectBlocks(String(xml || ''), 'Task'); + for (const block of blocks) { + const uid = tag(block, 'UID'); + const name = unescape(tag(block, 'Name')); + if (!uid || uid === '0' || !name) continue; // project-summary row / blanks + const level = Math.max(1, Number(tag(block, 'OutlineLevel')) || 1); + const depth = Math.min(level, 3); // deeper levels flatten to task level + const preds = predecessorIds(block); + const pct = Number(tag(block, 'PercentComplete')) || 0; + const t = { + id: `msp-${uid}`, + parentId: depth > 1 ? (parents[depth - 1] || '') : '', + depth, + phase: depth === 1 ? name : '', + activity: depth === 2 ? name : '', + task: depth === 3 ? name : '', + name, + plannedStartDate: day(tag(block, 'Start')), + plannedEndDate: day(tag(block, 'Finish')), + actualProgress: pct, + predecessors: preds.join(','), + }; + tasks.push(t); + parents[depth] = t.id; + for (let d = depth + 1; d <= 3; d++) delete parents[d]; // reset deeper chain + } + return tasks; +} + +async function importMsProjectFile(file) { + const xml = await file.text(); + const tasks = parseMsProjectXml(xml); + if (!tasks.length) { toast('가져올 작업이 없습니다 (MSP XML 형식을 확인하세요).'); return; } + if (!confirm(`MS Project에서 ${tasks.length}개 작업을 가져옵니다. 현재 프로젝트 내용을 대체합니다.`)) return; + // preserve name/baseDate — only the task tree is replaced + const prev = host?.getState?.() || {}; + host?.hydrateState({ projectName: prev.projectName, baseDate: prev.baseDate, tasks }); + host?.renderAll(); + const state = host?.getState?.(); + if (state) await doPush(state); + toast(`MS Project에서 ${tasks.length}개 작업을 가져왔습니다.`); +} + +// ------------------------------------------------------------- portfolio +// Executive rollup: every project's weighted progress, SPI, and overdue count. +async function openPortfolioModal() { + if (!currentOrgId) { toast('워크스페이스를 먼저 선택하세요.'); return; } + let modal = document.getElementById('portfolio-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'portfolio-modal'; + modal.className = 'modal'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + const backdrop = document.createElement('div'); + backdrop.className = 'modal-backdrop'; + backdrop.addEventListener('click', () => modal.classList.add('hidden')); + const panel = document.createElement('div'); + panel.className = 'modal-panel'; + panel.id = 'portfolio-panel'; + modal.append(backdrop, panel); + document.body.appendChild(modal); + } + modal.classList.remove('hidden'); + const panel = modal.querySelector('#portfolio-panel'); + panel.textContent = ''; + + const head = document.createElement('div'); + head.className = 'modal-header'; + const h2 = document.createElement('h2'); + h2.textContent = '포트폴리오 대시보드'; + const close = document.createElement('button'); + close.type = 'button'; + close.className = 'icon-button close-button'; + close.setAttribute('aria-label', '대시보드 닫기'); + close.textContent = '✕'; + close.addEventListener('click', () => modal.classList.add('hidden')); + head.append(h2, close); + panel.appendChild(head); + + const data = await api(`/api/orgs/${currentOrgId}/portfolio`); + const active = data.projects.filter((p) => !p.archived); + if (!active.length) { + const p = document.createElement('p'); + p.textContent = '프로젝트가 없습니다.'; + panel.appendChild(p); + return; + } + const summary = document.createElement('p'); + const late = active.filter((p) => p.status === 'delay').length; + const totOverdue = active.reduce((n, p) => n + p.overdue, 0); + summary.textContent = `프로젝트 ${active.length}개 · 주의/지연 ${late}개 · 지연 작업 합계 ${totOverdue}건`; + panel.appendChild(summary); + + const wrap = document.createElement('div'); + wrap.style.overflowX = 'auto'; + const table = document.createElement('table'); + table.className = 'wbs-table'; + const thead = document.createElement('thead'); + const hr = document.createElement('tr'); + for (const t of ['프로젝트', '작업', '계획%', '실적%', 'SPI', '상태', '지연', '']) { + const th = document.createElement('th'); + th.textContent = t; + hr.appendChild(th); + } + thead.appendChild(hr); + const tbody = document.createElement('tbody'); + for (const p of active) { + const tr = document.createElement('tr'); + const cells = [p.name, String(p.tasks), `${p.planned}%`, `${p.actual}%`, p.spi === null ? '-' : p.spi.toFixed(2), p.label, p.overdue ? `${p.overdue}건` : '-']; + for (const cText of cells) { + const td = document.createElement('td'); + td.textContent = cText; + tr.appendChild(td); + } + if (p.status === 'delay') tr.style.color = 'var(--delay, #ea580c)'; + const td = document.createElement('td'); + const open = document.createElement('button'); + open.type = 'button'; + open.className = 'secondary-button'; + open.textContent = '열기'; + open.addEventListener('click', async () => { + modal.classList.add('hidden'); + await openProject(p.id).catch((err) => toast(err.message)); + }); + td.appendChild(open); + tr.appendChild(td); + tbody.appendChild(tr); + } + table.append(thead, tbody); + wrap.appendChild(table); + panel.appendChild(wrap); +} + +// --------------------------------------------------------------- sprints +// Agile/Hybrid 지표 (순수): 스프린트별 커밋/완료 스토리포인트와 팀 벨로시티. +// 작업 배정 = task.sprint(이름 일치), 추정 = task.storyPoints, 완료 = 실적 100%. +export function computeSprintStats(tasks, sprints, today) { + const leaf = (tasks || []).filter((t) => !t.isSynthetic); + const rows = (sprints || []).map((sp) => { + const mine = leaf.filter((t) => String(t.sprint || '').trim() === sp.name); + const pts = (t) => Number(t.storyPoints) || 0; + const committed = mine.reduce((n, t) => n + pts(t), 0); + const completed = mine.filter((t) => (Number(t.actualProgress) || 0) >= 100).reduce((n, t) => n + pts(t), 0); + const closed = Boolean(sp.endDate && today && sp.endDate < today); + return { id: sp.id, name: sp.name, startDate: sp.startDate, endDate: sp.endDate, goal: sp.goal, taskCount: mine.length, committed, completed, remaining: committed - completed, closed }; + }); + const closedWithWork = rows.filter((r) => r.closed && r.committed > 0); + const velocity = closedWithWork.length + ? closedWithWork.reduce((n, r) => n + r.completed, 0) / closedWithWork.length + : null; + const backlog = leaf.filter((t) => !String(t.sprint || '').trim() || !(sprints || []).some((sp) => sp.name === String(t.sprint).trim())); + return { rows, velocity, backlogCount: backlog.length }; +} + +// 번다운 (순수): 스프린트 기간의 일별 잔여 포인트 — ideal(선형 소진) vs +// actual(완료일 actualEndDate 기준; 완료일 없는 100% 작업은 오늘 완료로 간주). +export function computeBurndown(tasks, sprint, today) { + if (!sprint?.startDate || !sprint?.endDate || sprint.endDate < sprint.startDate) return null; + const leaf = (tasks || []).filter((t) => !t.isSynthetic && String(t.sprint || '').trim() === sprint.name); + const pts = (t) => Number(t.storyPoints) || 0; + const committed = leaf.reduce((n, t) => n + pts(t), 0); + if (committed <= 0) return null; + const days = []; + for (let d = new Date(sprint.startDate); ; d.setDate(d.getDate() + 1)) { + const iso = d.toISOString().slice(0, 10); + days.push(iso); + if (iso >= sprint.endDate) break; + if (days.length > 120) break; // 안전 상한 + } + const n = days.length; + const ideal = days.map((_, i) => committed * (1 - (n === 1 ? 1 : i / (n - 1)))); + const doneAt = (t) => t.actualEndDate || ((Number(t.actualProgress) || 0) >= 100 ? today : null); + const actual = days.map((day) => { + if (today && day > today) return null; // 미래는 미기록 + const burned = leaf.filter((t) => { const d = doneAt(t); return d && d <= day; }).reduce((s2, t) => s2 + pts(t), 0); + return committed - burned; + }); + return { days, committed, ideal, actual }; +} + +function renderBurndownSvg(bd) { + const W = 420, H = 110, PAD = 6; + const n = bd.days.length; + const x = (i) => PAD + (n === 1 ? 0 : (i / (n - 1)) * (W - 2 * PAD)); + const y = (v) => H - PAD - (v / bd.committed) * (H - 2 * PAD); + const NS = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(NS, 'svg'); + svg.setAttribute('viewBox', `0 0 ${W} ${H}`); + svg.setAttribute('role', 'img'); + svg.setAttribute('aria-label', `번다운: 커밋 ${bd.committed}pt`); + svg.style.width = '100%'; + svg.style.maxWidth = '460px'; + const grid = document.createElementNS(NS, 'line'); + grid.setAttribute('x1', PAD); grid.setAttribute('x2', W - PAD); + grid.setAttribute('y1', y(0)); grid.setAttribute('y2', y(0)); + grid.setAttribute('stroke', '#e2e8f0'); + svg.appendChild(grid); + const idealLine = document.createElementNS(NS, 'polyline'); + idealLine.setAttribute('points', bd.ideal.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ')); + idealLine.setAttribute('fill', 'none'); + idealLine.setAttribute('stroke', '#94a3b8'); + idealLine.setAttribute('stroke-dasharray', '4 3'); + svg.appendChild(idealLine); + const actualPts = bd.actual.map((v, i) => (v === null ? null : `${x(i).toFixed(1)},${y(v).toFixed(1)}`)).filter(Boolean); + if (actualPts.length) { + const actualLine = document.createElementNS(NS, 'polyline'); + actualLine.setAttribute('points', actualPts.join(' ')); + actualLine.setAttribute('fill', 'none'); + actualLine.setAttribute('stroke', '#2563eb'); + actualLine.setAttribute('stroke-width', '2'); + svg.appendChild(actualLine); + } + return svg; +} + +const METHODOLOGY_LABELS = { waterfall: 'Waterfall (예측형)', agile: 'Agile (적응형)', hybrid: 'Hybrid (혼합형)' }; + +async function openSprintModal() { + const pid = getProjectId(); + if (!pid) return; + let modal = document.getElementById('sprint-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'sprint-modal'; + modal.className = 'modal'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + const backdrop = document.createElement('div'); + backdrop.className = 'modal-backdrop'; + backdrop.addEventListener('click', () => modal.classList.add('hidden')); + const panel = document.createElement('div'); + panel.className = 'modal-panel'; + panel.id = 'sprint-panel'; + modal.append(backdrop, panel); + document.body.appendChild(modal); + } + modal.classList.remove('hidden'); + const panel = modal.querySelector('#sprint-panel'); + panel.textContent = ''; + + const head = document.createElement('div'); + head.className = 'modal-header'; + const h2 = document.createElement('h2'); + h2.textContent = '스프린트 (Agile / Hybrid)'; + const close = document.createElement('button'); + close.type = 'button'; + close.className = 'icon-button close-button'; + close.setAttribute('aria-label', '스프린트 닫기'); + close.textContent = '✕'; + close.addEventListener('click', () => modal.classList.add('hidden')); + head.append(h2, close); + panel.appendChild(head); + + const data = await api(`/api/projects/${pid}/sprints`); + + // 방법론 선택 — 프로젝트 메타로 저장 + const mLabel = document.createElement('label'); + mLabel.className = 'meta-field'; + const mSpan = document.createElement('span'); + mSpan.textContent = '프로젝트 방법론'; + const mSel = document.createElement('select'); + mSel.className = 'cloud-select'; + mSel.id = 'methodology-select'; + for (const [v, label] of Object.entries(METHODOLOGY_LABELS)) { + const opt = document.createElement('option'); + opt.value = v; + opt.textContent = label; + if (v === (data.methodology || 'waterfall')) opt.selected = true; + mSel.appendChild(opt); + } + mSel.addEventListener('change', async () => { + try { + const cur = await api(`/api/projects/${pid}`); + await api(`/api/projects/${pid}`, { method: 'PUT', body: { methodology: mSel.value, version: cur.version } }); + toast(`방법론: ${METHODOLOGY_LABELS[mSel.value]}`); + } catch (e) { toast(e.data?.error || e.message); } + }); + mLabel.append(mSpan, mSel); + panel.appendChild(mLabel); + + // 지표 + 목록 + const stats = computeSprintStats(host?.getState?.()?.tasks || [], data.sprints, new Date().toISOString().slice(0, 10)); + const summary = document.createElement('p'); + summary.className = 'cpm-summary'; + summary.textContent = `스프린트 ${stats.rows.length}개 · 벨로시티 ${stats.velocity === null ? 'N/A (종료 스프린트 없음)' : stats.velocity.toFixed(1) + 'pt'} · 백로그 ${stats.backlogCount}건`; + panel.appendChild(summary); + + const list = document.createElement('ul'); + list.className = 'team-list'; + for (const r of stats.rows) { + const li = document.createElement('li'); + const who = document.createElement('span'); + who.className = 'team-who'; + const period = r.startDate || r.endDate ? ` (${r.startDate}~${r.endDate})` : ''; + who.textContent = `${r.name}${period} · ${r.taskCount}작업 · ${r.completed}/${r.committed}pt${r.closed ? ' · 종료' : ''}`; + const bdBtn = document.createElement('button'); + bdBtn.type = 'button'; + bdBtn.className = 'secondary-button'; + bdBtn.textContent = '번다운'; + bdBtn.addEventListener('click', () => { + const holder = document.getElementById('burndown-holder'); + holder.textContent = ''; + const bd = computeBurndown(host?.getState?.()?.tasks || [], r, new Date().toISOString().slice(0, 10)); + if (!bd) { holder.textContent = '번다운을 그리려면 스프린트 기간과 스토리포인트가 필요합니다.'; return; } + const cap = document.createElement('p'); + cap.className = 'evm-caption'; + cap.textContent = `${r.name} 번다운 — 커밋 ${bd.committed}pt · 점선=이상적 소진, 실선=실제 잔여`; + holder.append(cap, renderBurndownSvg(bd)); + }); + const del = document.createElement('button'); + del.type = 'button'; + del.className = 'secondary-button team-remove'; + del.textContent = '삭제'; + del.addEventListener('click', () => + api(`/api/projects/${pid}/sprints/${r.id}`, { method: 'DELETE' }) + .then(() => openSprintModal()).catch((e) => toast(e.data?.error || e.message))); + li.append(who, bdBtn, del); + list.appendChild(li); + } + if (!stats.rows.length) { + const li = document.createElement('li'); + li.textContent = '스프린트가 없습니다. 아래에서 추가하세요. (작업 배정: 편집기의 스프린트 필드)'; + list.appendChild(li); + } + panel.appendChild(list); + + const bdHolder = document.createElement('div'); + bdHolder.id = 'burndown-holder'; + panel.appendChild(bdHolder); + + const form = document.createElement('form'); + form.className = 'cloud-form'; + const nameIn = document.createElement('input'); + nameIn.type = 'text'; + nameIn.placeholder = '스프린트 이름 (예: Sprint 3)'; + nameIn.required = true; + const startIn = document.createElement('input'); + startIn.type = 'date'; + const endIn = document.createElement('input'); + endIn.type = 'date'; + const add = document.createElement('button'); + add.type = 'submit'; + add.className = 'primary-button'; + add.textContent = '추가'; + form.append(nameIn, startIn, endIn, add); + form.addEventListener('submit', async (e) => { + e.preventDefault(); + try { + await api(`/api/projects/${pid}/sprints`, { method: 'POST', body: { name: nameIn.value.trim(), startDate: startIn.value, endDate: endIn.value } }); + toast('스프린트를 추가했습니다.'); + openSprintModal(); + } catch (err) { toast(err.data?.error || err.message); } + }); + panel.appendChild(form); +} + +// ----------------------------------------------------------- attachments +// 산출물 첨부: Clearfolio 통합 문서 뷰어로 업로드/열람. 서버가 프록시하므로 +// 브라우저에는 Clearfolio 자격/시크릿이 노출되지 않는다. +async function openAttachmentsModal() { + const pid = getProjectId(); + if (!pid) return; + let modal = document.getElementById('attachments-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'attachments-modal'; + modal.className = 'modal'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + const backdrop = document.createElement('div'); + backdrop.className = 'modal-backdrop'; + backdrop.addEventListener('click', () => modal.classList.add('hidden')); + const panel = document.createElement('div'); + panel.className = 'modal-panel'; + panel.id = 'attachments-panel'; + modal.append(backdrop, panel); + document.body.appendChild(modal); + } + modal.classList.remove('hidden'); + const panel = modal.querySelector('#attachments-panel'); + panel.textContent = ''; + + const head = document.createElement('div'); + head.className = 'modal-header'; + const h2 = document.createElement('h2'); + h2.textContent = '산출물 (문서 뷰어)'; + const close = document.createElement('button'); + close.type = 'button'; + close.className = 'icon-button close-button'; + close.setAttribute('aria-label', '산출물 닫기'); + close.textContent = '✕'; + close.addEventListener('click', () => modal.classList.add('hidden')); + head.append(h2, close); + panel.appendChild(head); + + // 작업 선택 + 파일 업로드 + const sel = document.createElement('select'); + sel.className = 'cloud-select'; + const optAll = document.createElement('option'); + optAll.value = ''; + optAll.textContent = '전체 산출물'; + sel.appendChild(optAll); + for (const t of host?.getState?.()?.tasks || []) { + const opt = document.createElement('option'); + opt.value = t.id; + opt.textContent = t.name || t.task || t.activity || t.phase || t.id; + sel.appendChild(opt); + } + panel.appendChild(sel); + + const form = document.createElement('form'); + form.className = 'cloud-form'; + const fi = document.createElement('input'); + fi.type = 'file'; + fi.id = 'attachment-file-input'; + fi.accept = '.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.png,.jpg,.jpeg,.txt,.md'; + const up = document.createElement('button'); + up.type = 'submit'; + up.className = 'primary-button'; + up.textContent = '업로드'; + form.append(fi, up); + panel.appendChild(form); + + const list = document.createElement('ul'); + list.className = 'team-list'; + panel.appendChild(list); + + const taskName = (id) => { + const t = (host?.getState?.()?.tasks || []).find((x) => x.id === id); + return t ? (t.name || t.task || id) : id; + }; + + async function refresh() { + list.textContent = ''; + const q = sel.value ? `?taskId=${encodeURIComponent(sel.value)}` : ''; + const data = await api(`/api/projects/${pid}/attachments${q}`); + if (!data.attachments.length) { + const li = document.createElement('li'); + li.textContent = '첨부된 산출물이 없습니다.'; + list.appendChild(li); + return; + } + for (const a of data.attachments) { + const li = document.createElement('li'); + const who = document.createElement('span'); + who.className = 'team-who'; + const where = a.taskId ? ` [${taskName(a.taskId)}]` : ''; + const st = a.status === 'SUCCEEDED' ? '' : ` · ${a.status}`; + who.textContent = `${a.name}${where}${st}`; + li.appendChild(who); + if (a.status === 'SUCCEEDED') { + const view = document.createElement('button'); + view.type = 'button'; + view.className = 'secondary-button'; + view.textContent = '보기'; + view.addEventListener('click', () => { + window.open(`/api/projects/${pid}/attachments/${a.id}/view?token=${encodeURIComponent(getToken())}`, '_blank', 'noopener'); + }); + li.appendChild(view); + } + const del = document.createElement('button'); + del.type = 'button'; + del.className = 'secondary-button team-remove'; + del.textContent = '삭제'; + del.addEventListener('click', () => + api(`/api/projects/${pid}/attachments/${a.id}`, { method: 'DELETE' }) + .then(refresh).catch((e) => toast(e.data?.error || e.message))); + li.appendChild(del); + list.appendChild(li); + } + } + sel.addEventListener('change', refresh); + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const f = fi.files?.[0]; + if (!f) return; + const fd = new FormData(); + fd.append('file', f); + fd.append('taskId', sel.value); + try { + const res = await fetch(`/api/projects/${pid}/attachments`, { + method: 'POST', + headers: { authorization: `Bearer ${getToken()}` }, + body: fd, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || res.statusText); + fi.value = ''; + toast(`'${f.name}' 산출물을 업로드했습니다.`); + refresh(); + } catch (err) { toast(err.message); } + }); + await refresh(); +} + +// ------------------------------------------------------------- comments +async function openCommentsModal() { + const pid = getProjectId(); + if (!pid) return; + let modal = document.getElementById('comments-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'comments-modal'; + modal.className = 'modal'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + const backdrop = document.createElement('div'); + backdrop.className = 'modal-backdrop'; + backdrop.addEventListener('click', () => modal.classList.add('hidden')); + const panel = document.createElement('div'); + panel.className = 'modal-panel'; + panel.id = 'comments-panel'; + modal.append(backdrop, panel); + document.body.appendChild(modal); + } + modal.classList.remove('hidden'); + const panel = modal.querySelector('#comments-panel'); + panel.textContent = ''; + + const head = document.createElement('div'); + head.className = 'modal-header'; + const h2 = document.createElement('h2'); + h2.textContent = '코멘트'; + const close = document.createElement('button'); + close.type = 'button'; + close.className = 'icon-button close-button'; + close.setAttribute('aria-label', '코멘트 닫기'); + close.textContent = '✕'; + close.addEventListener('click', () => modal.classList.add('hidden')); + head.append(h2, close); + panel.appendChild(head); + + // task filter (전체 or a specific task) + const sel = document.createElement('select'); + sel.className = 'cloud-select'; + const all = document.createElement('option'); + all.value = ''; + all.textContent = '전체 코멘트'; + sel.appendChild(all); + for (const t of host?.getState?.()?.tasks || []) { + const opt = document.createElement('option'); + opt.value = t.id; + opt.textContent = t.name || t.task || t.id; + sel.appendChild(opt); + } + panel.appendChild(sel); + + const list = document.createElement('ul'); + list.className = 'team-list'; + panel.appendChild(list); + + const form = document.createElement('form'); + form.className = 'cloud-form'; + const input = document.createElement('input'); + input.type = 'text'; + input.placeholder = '코멘트 입력 (선택한 작업에 달림)'; + input.maxLength = 2000; + const send = document.createElement('button'); + send.type = 'submit'; + send.className = 'primary-button'; + send.textContent = '등록'; + form.append(input, send); + panel.appendChild(form); + + const taskName = (id) => { + const t = (host?.getState?.()?.tasks || []).find((x) => x.id === id); + return t ? (t.name || t.task || id) : id; + }; + + async function refresh() { + list.textContent = ''; + const q = sel.value ? `?taskId=${encodeURIComponent(sel.value)}` : ''; + const data = await api(`/api/projects/${pid}/comments${q}`); + if (!data.comments.length) { + const li = document.createElement('li'); + li.textContent = '코멘트가 없습니다.'; + list.appendChild(li); + return; + } + for (const cm of data.comments) { + const li = document.createElement('li'); + const who = document.createElement('span'); + who.className = 'team-who'; + const where = cm.taskId ? ` [${taskName(cm.taskId)}]` : ''; + who.textContent = `${cm.email || '알 수 없음'}${where}: ${cm.body}`; + const del = document.createElement('button'); + del.type = 'button'; + del.className = 'secondary-button team-remove'; + del.textContent = '삭제'; + del.addEventListener('click', () => + api(`/api/projects/${pid}/comments/${cm.id}`, { method: 'DELETE' }) + .then(refresh).catch((e) => toast(e.data?.error || e.message))); + li.append(who, del); + list.appendChild(li); + } + } + sel.addEventListener('change', refresh); + form.addEventListener('submit', async (e) => { + e.preventDefault(); + if (!input.value.trim()) return; + try { + await api(`/api/projects/${pid}/comments`, { method: 'POST', body: { taskId: sel.value, body: input.value.trim() } }); + input.value = ''; + refresh(); + } catch (err) { toast(err.data?.error || err.message); } + }); + await refresh(); +} + +// ------------------------------------------------------------- search +function openSearchModal() { + let modal = document.getElementById('search-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'search-modal'; + modal.className = 'modal'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + const backdrop = document.createElement('div'); + backdrop.className = 'modal-backdrop'; + backdrop.addEventListener('click', () => modal.classList.add('hidden')); + const panel = document.createElement('div'); + panel.className = 'modal-panel'; + panel.id = 'search-panel'; + modal.append(backdrop, panel); + document.body.appendChild(modal); + } + modal.classList.remove('hidden'); + const panel = modal.querySelector('#search-panel'); + panel.textContent = ''; + + const head = document.createElement('div'); + head.className = 'modal-header'; + const h2 = document.createElement('h2'); + h2.textContent = '프로젝트 검색'; + const close = document.createElement('button'); + close.type = 'button'; + close.className = 'icon-button close-button'; + close.setAttribute('aria-label', '검색 닫기'); + close.textContent = '✕'; + close.addEventListener('click', () => modal.classList.add('hidden')); + head.append(h2, close); + panel.appendChild(head); + + const form = document.createElement('form'); + form.className = 'cloud-form'; + const input = document.createElement('input'); + input.type = 'search'; + input.placeholder = '프로젝트/작업 이름 (2자 이상)'; + input.minLength = 2; + const go = document.createElement('button'); + go.type = 'submit'; + go.className = 'primary-button'; + go.textContent = '검색'; + form.append(input, go); + panel.appendChild(form); + + const out = document.createElement('div'); + panel.appendChild(out); + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + out.textContent = ''; + try { + const data = await api(`/api/search?q=${encodeURIComponent(input.value.trim())}`); + if (!data.results.length) { out.textContent = '검색 결과가 없습니다.'; return; } + const list = document.createElement('ul'); + list.className = 'team-list'; + for (const hit of data.results) { + const li = document.createElement('li'); + const who = document.createElement('span'); + who.className = 'team-who'; + const taskNames = hit.tasks.map((t) => t.name).join(', '); + who.textContent = hit.nameMatch && !taskNames ? hit.projectName : `${hit.projectName} — ${taskNames}`; + const open = document.createElement('button'); + open.type = 'button'; + open.className = 'secondary-button'; + open.textContent = '열기'; + open.addEventListener('click', async () => { + modal.classList.add('hidden'); + await openProject(hit.projectId).catch((err) => toast(err.message)); + }); + li.append(who, open); + list.appendChild(li); + } + out.appendChild(list); + } catch (err) { out.textContent = err.data?.error || err.message; } + }); + input.focus(); +} + +// ------------------------------------------------------------- baselines +// Compare the live plan against a frozen baseline: which tasks' planned dates +// slipped, and by how many days. +const dayMs = 86400000; +const slipDays = (fromDate, toDate) => { + if (!fromDate || !toDate) return null; + const a = new Date(fromDate), b = new Date(toDate); + if (Number.isNaN(a) || Number.isNaN(b)) return null; + return Math.round((b - a) / dayMs); +}; + +export function compareBaseline(baselineTasks, currentTasks) { + const base = new Map((baselineTasks || []).map((t) => [t.id, t])); + const rows = []; + for (const cur of currentTasks || []) { + const old = base.get(cur.id); + if (!old) { rows.push({ id: cur.id, name: cur.name, kind: 'added', endSlip: null }); continue; } + const endSlip = slipDays(old.plannedEndDate, cur.plannedEndDate); + const startSlip = slipDays(old.plannedStartDate, cur.plannedStartDate); + if ((endSlip || 0) !== 0 || (startSlip || 0) !== 0) { + rows.push({ id: cur.id, name: cur.name, kind: 'moved', baseEnd: old.plannedEndDate || '', curEnd: cur.plannedEndDate || '', endSlip: endSlip ?? 0 }); + } + } + const cur = new Set((currentTasks || []).map((t) => t.id)); + for (const old of baselineTasks || []) { + if (!cur.has(old.id)) rows.push({ id: old.id, name: old.name, kind: 'removed', endSlip: null }); + } + const slipped = rows.filter((r) => r.kind === 'moved' && r.endSlip > 0); + return { rows, summary: { changed: rows.length, slipped: slipped.length, maxSlip: slipped.reduce((m, r) => Math.max(m, r.endSlip), 0) } }; +} + +async function openBaselineModal() { + const pid = getProjectId(); + if (!pid) return; + let modal = document.getElementById('baseline-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'baseline-modal'; + modal.className = 'modal'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + const backdrop = document.createElement('div'); + backdrop.className = 'modal-backdrop'; + backdrop.addEventListener('click', () => modal.classList.add('hidden')); + const panel = document.createElement('div'); + panel.className = 'modal-panel'; + panel.id = 'baseline-panel'; + modal.append(backdrop, panel); + document.body.appendChild(modal); + } + modal.classList.remove('hidden'); + const panel = modal.querySelector('#baseline-panel'); + panel.textContent = ''; + + const head = document.createElement('div'); + head.className = 'modal-header'; + const h2 = document.createElement('h2'); + h2.textContent = '기준선 (Baseline)'; + const close = document.createElement('button'); + close.type = 'button'; + close.className = 'icon-button close-button'; + close.setAttribute('aria-label', '기준선 닫기'); + close.textContent = '✕'; + close.addEventListener('click', () => modal.classList.add('hidden')); + head.append(h2, close); + panel.appendChild(head); + + const save = document.createElement('button'); + save.type = 'button'; + save.className = 'primary-button'; + save.textContent = '현재 계획을 기준선으로 저장'; + save.addEventListener('click', async () => { + const name = prompt('기준선 이름', `기준선 ${new Date().toISOString().slice(0, 10)}`); + if (!name) return; + await api(`/api/projects/${pid}/baselines`, { method: 'POST', body: { name } }); + toast('기준선을 저장했습니다.'); + openBaselineModal(); + }); + panel.appendChild(save); + + const ics = document.createElement('button'); + ics.type = 'button'; + ics.className = 'secondary-button'; + ics.style.marginLeft = '8px'; + ics.textContent = '캘린더 내보내기 (.ics)'; + ics.addEventListener('click', async () => { + try { + const res = await fetch(`/api/projects/${pid}/calendar.ics`, { headers: { authorization: `Bearer ${getToken()}` } }); + if (!res.ok) return toast('캘린더 내보내기에 실패했습니다.'); + const blob = await res.blob(); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `scopeweave-${pid}.ics`; + a.click(); + URL.revokeObjectURL(a.href); + toast('캘린더 파일(.ics)을 내려받았습니다.'); + } catch { toast('캘린더 내보내기에 실패했습니다.'); } + }); + panel.appendChild(ics); + + const list = document.createElement('ul'); + list.className = 'team-list'; + panel.appendChild(list); + const result = document.createElement('div'); + result.id = 'baseline-result'; + panel.appendChild(result); + + // 변경 이력 (revision history) — related schedule-control tool, same modal. + const histH = document.createElement('h3'); + histH.className = 'token-heading'; + histH.textContent = '변경 이력'; + const histList = document.createElement('ul'); + histList.className = 'team-list'; + api(`/api/projects/${pid}/revisions`).then((h) => { + for (const rev of h.revisions.slice(0, 10)) { + const li = document.createElement('li'); + const who = document.createElement('span'); + who.className = 'team-who'; + who.textContent = `v${rev.version} · ${String(rev.savedAt).slice(0, 16)} · ${rev.savedBy || ''}`; + const diff = document.createElement('button'); + diff.type = 'button'; + diff.className = 'secondary-button'; + diff.textContent = '비교'; + diff.addEventListener('click', async () => { + try { + const snap = await api(`/api/projects/${pid}/revisions/${rev.version}`); + renderBaselineDiff(result, compareBaseline(snap.tasks, host?.getState?.()?.tasks || [])); + } catch (e) { toast(e.data?.error || e.message); } + }); + li.appendChild(diff); + const restore = document.createElement('button'); + restore.type = 'button'; + restore.className = 'secondary-button'; + restore.textContent = '복원'; + restore.addEventListener('click', async () => { + if (!confirm(`v${rev.version} 시점으로 복원합니다. (새 버전으로 기록됩니다)`)) return; + try { + await api(`/api/projects/${pid}/revisions/${rev.version}/restore`, { method: 'POST' }); + await openProject(pid); + toast(`v${rev.version} 시점으로 복원했습니다.`); + modal.classList.add('hidden'); + } catch (e) { toast(e.data?.error || e.message); } + }); + li.append(who, restore); + histList.appendChild(li); + } + if (!h.revisions.length) { + const li = document.createElement('li'); + li.textContent = '저장 이력이 없습니다.'; + histList.appendChild(li); + } + }).catch(() => {}); + panel.append(histH, histList); + + const data = await api(`/api/projects/${pid}/baselines`); + if (!data.baselines.length) { + const li = document.createElement('li'); + li.textContent = '저장된 기준선이 없습니다.'; + list.appendChild(li); + return; + } + for (const b of data.baselines) { + const li = document.createElement('li'); + const who = document.createElement('span'); + who.className = 'team-who'; + who.textContent = `${b.name} · ${String(b.createdAt).slice(0, 10)}`; + const cmp = document.createElement('button'); + cmp.type = 'button'; + cmp.className = 'secondary-button'; + cmp.textContent = '비교'; + cmp.addEventListener('click', async () => { + const full = await api(`/api/projects/${pid}/baselines/${b.id}`); + renderBaselineDiff(result, compareBaseline(full.tasks, host?.getState?.()?.tasks || [])); + }); + const del = document.createElement('button'); + del.type = 'button'; + del.className = 'secondary-button'; + del.textContent = '삭제'; + del.addEventListener('click', async () => { + await api(`/api/projects/${pid}/baselines/${b.id}`, { method: 'DELETE' }); + openBaselineModal(); + }); + li.append(who, cmp, del); + list.appendChild(li); + } +} + +function renderBaselineDiff(el, { rows, summary }) { + el.textContent = ''; + const sum = document.createElement('p'); + sum.textContent = rows.length + ? `변경 ${summary.changed}건 · 지연 ${summary.slipped}건 · 최대 지연 ${summary.maxSlip}일` + : '기준선과 차이가 없습니다.'; + el.appendChild(sum); + if (!rows.length) return; + const table = document.createElement('table'); + table.className = 'wbs-table'; + const thead = document.createElement('thead'); + const hr = document.createElement('tr'); + for (const t of ['작업', '기준 종료', '현재 종료', '차이']) { + const th = document.createElement('th'); + th.textContent = t; + hr.appendChild(th); + } + thead.appendChild(hr); + const tbody = document.createElement('tbody'); + for (const r of rows.slice(0, 50)) { + const tr = document.createElement('tr'); + const cells = r.kind === 'moved' + ? [r.name, r.baseEnd, r.curEnd, `${r.endSlip > 0 ? '+' : ''}${r.endSlip}일`] + : [r.name, '', '', r.kind === 'added' ? '신규' : '삭제됨']; + for (const c of cells) { + const td = document.createElement('td'); + td.textContent = c ?? ''; + tr.appendChild(td); + } + if (r.kind === 'moved' && r.endSlip > 0) tr.style.color = 'var(--delay, #ea580c)'; + tbody.appendChild(tr); + } + table.append(thead, tbody); + const wrap = document.createElement('div'); + wrap.style.overflowX = 'auto'; + wrap.appendChild(table); + el.appendChild(wrap); +} + +async function openTeamModal() { + const orgId = await resolveOrgId(); + if (!orgId) return toast('워크스페이스를 찾을 수 없습니다.'); + let modal = document.getElementById('team-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'team-modal'; + modal.className = 'modal hidden'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + modal.innerHTML = ` + + `; + document.body.appendChild(modal); + modal.addEventListener('click', (e) => { if (e.target.dataset.teamClose) modal.classList.add('hidden'); }); + modal.querySelector('#team-invite').addEventListener('submit', async (e) => { + e.preventDefault(); + const email = modal.querySelector('#team-email').value.trim(); + const role = modal.querySelector('#team-role').value; + try { + const inv = await api(`/api/orgs/${currentOrgId}/invites`, { method: 'POST', body: { email, role } }); + const link = `${location.origin}/?invite=${inv.token}`; + modal.querySelector('#team-msg').textContent = `초대 링크: ${link}`; + modal.querySelector('#team-email').value = ''; + await renderTeam(); + } catch (err) { + modal.querySelector('#team-msg').textContent = err.data?.error || err.message; + } + }); + } + modal.classList.remove('hidden'); + await renderTeam(); +} + +async function renderTeam() { + const body = document.getElementById('team-body'); + if (!body) return; + const data = await api(`/api/orgs/${currentOrgId}/members`); + body.textContent = ''; + + // org actions: rename (owner) / leave (everyone else) + try { + const me = await api('/api/me'); + const myRole = me.orgs?.find((o) => String(o.id) === String(currentOrgId))?.role; + const actions = document.createElement('div'); + actions.className = 'team-org-actions'; + if (myRole === 'owner') { + const rename = document.createElement('button'); + rename.type = 'button'; + rename.className = 'secondary-button'; + rename.textContent = '워크스페이스 이름 변경'; + rename.addEventListener('click', async () => { + const name = prompt('새 워크스페이스 이름'); + if (!name) return; + try { + await api(`/api/orgs/${currentOrgId}`, { method: 'PATCH', body: { name } }); + toast('이름을 변경했습니다.'); + renderTeam(); + } catch (e) { toast(e.data?.error || e.message); } + }); + actions.appendChild(rename); + } else if (myRole) { + const leave = document.createElement('button'); + leave.type = 'button'; + leave.className = 'secondary-button team-remove'; + leave.textContent = '워크스페이스 나가기'; + leave.addEventListener('click', async () => { + if (!confirm('이 워크스페이스에서 나갑니다. 프로젝트 접근 권한을 잃습니다.')) return; + try { + await api(`/api/orgs/${currentOrgId}/leave`, { method: 'POST' }); + setProjectId(''); + document.getElementById('team-modal')?.classList.add('hidden'); + await refreshProjects(); + renderAuthUI(); + toast('워크스페이스에서 나왔습니다.'); + } catch (e) { toast(e.data?.error || e.message); } + }); + actions.appendChild(leave); + } + if (actions.childNodes.length) body.appendChild(actions); + } catch { /* org actions are best-effort */ } + + // plan + usage indicator + try { + const b = await api(`/api/orgs/${currentOrgId}/billing`); + const bar = document.createElement('div'); + bar.className = 'billing-bar'; + const cap = (used, limit) => `${used}/${limit == null ? '∞' : limit}`; + const info = document.createElement('span'); + info.textContent = `${b.planName} · 프로젝트 ${cap(b.usage.projects, b.limits.projects)} · 멤버 ${cap(b.usage.members, b.limits.members)}`; + bar.appendChild(info); + if (b.plan === 'free') { + const up = document.createElement('button'); + up.type = 'button'; + up.className = 'primary-button billing-upgrade'; + up.textContent = 'Pro 업그레이드'; + up.addEventListener('click', async () => { + try { + const s = await api(`/api/orgs/${currentOrgId}/checkout`, { method: 'POST' }); + if (s.mock) toast('결제 연동(Stripe 키)이 필요합니다 — 데모 환경입니다.'); + else window.location.href = s.url; + } catch (e) { toast(e.data?.error || e.message); } + }); + bar.appendChild(up); + } + body.appendChild(bar); + } catch { /* billing optional */ } + const list = document.createElement('ul'); + list.className = 'team-list'; + for (const m of data.members) { + const li = document.createElement('li'); + const who = document.createElement('span'); + who.className = 'team-who'; + who.textContent = m.email; + li.appendChild(who); + if (m.role === 'owner') { + const tag = document.createElement('span'); + tag.className = 'team-role-tag'; + tag.textContent = ROLE_LABELS.owner; + li.appendChild(tag); + } else { + const sel = document.createElement('select'); + sel.className = 'cloud-select'; + for (const role of ['admin', 'member', 'viewer']) { + const opt = document.createElement('option'); + opt.value = role; opt.textContent = ROLE_LABELS[role]; + if (role === m.role) opt.selected = true; + sel.appendChild(opt); + } + sel.addEventListener('change', () => + api(`/api/orgs/${currentOrgId}/members/${m.id}`, { method: 'PATCH', body: { role: sel.value } }) + .then(() => toast(`${m.email} → ${ROLE_LABELS[sel.value]}`)).catch((e) => toast(e.message))); + li.appendChild(sel); + const del = document.createElement('button'); + del.type = 'button'; + del.className = 'secondary-button team-remove'; + del.textContent = '제거'; + del.addEventListener('click', () => + api(`/api/orgs/${currentOrgId}/members/${m.id}`, { method: 'DELETE' }) + .then(() => { toast(`${m.email} 제거됨`); renderTeam(); }).catch((e) => toast(e.message))); + li.appendChild(del); + const xfer = document.createElement('button'); + xfer.type = 'button'; + xfer.className = 'secondary-button'; + xfer.textContent = '소유권 이전'; + xfer.addEventListener('click', async () => { + if (!confirm(`${m.email}에게 소유권을 이전합니다. 나는 관리자가 됩니다.`)) return; + try { + await api(`/api/orgs/${currentOrgId}/transfer`, { method: 'POST', body: { userId: m.id } }); + toast('소유권을 이전했습니다.'); + renderTeam(); + } catch (e) { toast(e.data?.error || e.message); } // server 403s non-owners + }); + li.appendChild(xfer); + } + list.appendChild(li); + } + body.appendChild(list); + if (data.invites?.length) { + const pending = document.createElement('p'); + pending.className = 'team-pending'; + pending.textContent = '대기 중인 초대:'; + body.appendChild(pending); + const plist = document.createElement('ul'); + plist.className = 'team-list'; + for (const i of data.invites) { + const li = document.createElement('li'); + const who = document.createElement('span'); + who.className = 'team-who'; + who.textContent = `${i.email} · ${i.role}`; + const revoke = document.createElement('button'); + revoke.type = 'button'; + revoke.className = 'secondary-button team-remove'; + revoke.textContent = '초대 취소'; + revoke.addEventListener('click', () => + api(`/api/orgs/${currentOrgId}/invites/${i.id}`, { method: 'DELETE' }) + .then(() => { toast('초대를 취소했습니다.'); renderTeam(); }) + .catch((e) => toast(e.data?.error || e.message))); + li.append(who, revoke); + plist.appendChild(li); + } + body.appendChild(plist); + } + + const exportBtn = document.createElement('button'); + exportBtn.type = 'button'; + exportBtn.className = 'secondary-button'; + exportBtn.textContent = '데이터 내보내기 (JSON)'; + exportBtn.style.marginTop = '8px'; + exportBtn.addEventListener('click', exportOrg); + body.appendChild(exportBtn); + + await renderTokens(body); + await renderWebhooks(body); + await renderAudit(body); + renderAccount(body); +} + +// Account settings — change password / delete account. +function renderAccount(body) { + const section = document.createElement('div'); + section.className = 'token-section'; + const h = document.createElement('h3'); + h.className = 'token-heading'; + h.textContent = '계정'; + section.appendChild(h); + + const form = document.createElement('form'); + form.className = 'cloud-form'; + const oldPw = document.createElement('input'); + oldPw.type = 'password'; oldPw.placeholder = '현재 비밀번호'; oldPw.autocomplete = 'current-password'; + const newPw = document.createElement('input'); + newPw.type = 'password'; newPw.placeholder = '새 비밀번호 (8자 이상)'; newPw.minLength = 8; newPw.autocomplete = 'new-password'; + const save = document.createElement('button'); + save.type = 'submit'; save.className = 'secondary-button'; save.textContent = '비밀번호 변경'; + form.append(oldPw, newPw, save); + form.addEventListener('submit', async (e) => { + e.preventDefault(); + try { + await api('/api/auth/change-password', { method: 'POST', body: { oldPassword: oldPw.value, newPassword: newPw.value } }); + oldPw.value = ''; newPw.value = ''; + toast('비밀번호를 변경했습니다.'); + } catch (err) { toast(err.data?.error || err.message); } + }); + section.appendChild(form); + + const outAll = document.createElement('button'); + outAll.type = 'button'; + outAll.className = 'secondary-button'; + outAll.style.marginTop = '8px'; + outAll.textContent = '다른 모든 기기에서 로그아웃'; + outAll.addEventListener('click', async () => { + if (!confirm('다른 모든 기기의 세션을 무효화합니다. 이 기기는 유지됩니다.')) return; + try { + const res = await api('/api/auth/logout-all', { method: 'POST' }); + setToken(res.token); // fresh token keeps this device signed in + toast('다른 모든 기기에서 로그아웃했습니다.'); + } catch (e) { toast(e.data?.error || e.message); } + }); + section.appendChild(outAll); + + const del = document.createElement('button'); + del.type = 'button'; + del.className = 'secondary-button'; + del.style.color = 'var(--danger)'; + del.style.marginTop = '8px'; + del.textContent = '계정 삭제'; + del.addEventListener('click', async () => { + const pw = prompt('계정과 소유한 워크스페이스가 영구 삭제됩니다. 확인하려면 비밀번호를 입력하세요.'); + if (!pw) return; + try { + await api('/api/account', { method: 'DELETE', body: { password: pw } }); + setToken(''); setProjectId(''); + document.getElementById('team-modal')?.classList.add('hidden'); + renderAuthUI(); + toast('계정을 삭제했습니다.'); + } catch (err) { toast(err.data?.error || err.message); } + }); + section.appendChild(del); + body.appendChild(section); +} + +// Outbound webhooks — owner/admin. Secret shown once at creation. +async function renderWebhooks(body) { + let data; + try { data = await api(`/api/orgs/${currentOrgId}/webhooks`); } catch { return; } + const section = document.createElement('div'); + section.className = 'token-section'; + const h = document.createElement('h3'); + h.className = 'token-heading'; + h.textContent = '웹훅'; + section.appendChild(h); + const list = document.createElement('ul'); + list.className = 'team-list'; + for (const w of data.webhooks) { + const li = document.createElement('li'); + const who = document.createElement('span'); + who.className = 'team-who'; + const status = w.lastOk == null ? '' : (w.lastOk ? ' · 최근 ✓' : ' · 최근 ✗ 실패'); + who.textContent = `${w.url} · ${w.events}${status}`; + li.appendChild(who); + const rot = document.createElement('button'); + rot.type = 'button'; + rot.className = 'secondary-button'; + rot.textContent = '키 교체'; + rot.addEventListener('click', async () => { + if (!confirm('서명 시크릿을 교체합니다. 기존 시크릿은 즉시 무효화됩니다.')) return; + try { + const res = await api(`/api/orgs/${currentOrgId}/webhooks/${w.id}/rotate`, { method: 'POST' }); + prompt('새 서명 시크릿 (지금만 표시됩니다 — 복사하세요)', res.secret); + } catch (e) { toast(e.data?.error || e.message); } + }); + li.appendChild(rot); + const del = document.createElement('button'); + del.type = 'button'; + del.className = 'secondary-button team-remove'; + del.textContent = '삭제'; + del.addEventListener('click', () => + api(`/api/orgs/${currentOrgId}/webhooks/${w.id}`, { method: 'DELETE' }).then(() => { toast('웹훅을 삭제했습니다.'); renderTeam(); }).catch((e) => toast(e.message))); + li.appendChild(del); + list.appendChild(li); + } + section.appendChild(list); + const form = document.createElement('form'); + form.className = 'team-invite'; + const input = document.createElement('input'); + input.type = 'url'; + input.placeholder = 'https://example.com/webhook'; + const btn = document.createElement('button'); + btn.type = 'submit'; + btn.className = 'primary-button'; + btn.textContent = '웹훅 추가'; + form.append(input, btn); + const secret = document.createElement('p'); + secret.className = 'token-secret'; + form.addEventListener('submit', async (e) => { + e.preventDefault(); + try { + const w = await api(`/api/orgs/${currentOrgId}/webhooks`, { method: 'POST', body: { url: input.value.trim(), events: '*' } }); + secret.textContent = `서명 시크릿(한 번만 표시): ${w.secret}`; + const li = document.createElement('li'); + const who = document.createElement('span'); + who.className = 'team-who'; + who.textContent = `${w.url} · ${w.events}`; + li.appendChild(who); + list.appendChild(li); + input.value = ''; + } catch (err) { toast(err.data?.error || err.message); } + }); + section.appendChild(form); + section.appendChild(secret); + body.appendChild(section); +} + +const AUDIT_LABELS = { + 'project.create': '프로젝트 생성', 'project.update': '프로젝트 저장', + 'member.invite': '멤버 초대', 'member.join': '멤버 합류', + 'member.role_change': '역할 변경', 'member.remove': '멤버 제거', + 'billing.upgrade': '플랜 업그레이드', +}; + +// Recent activity (owner/admin only; endpoint 403s otherwise → section hidden). +async function renderAudit(body) { + let data; + try { data = await api(`/api/orgs/${currentOrgId}/audit?limit=12`); } catch { return; } + if (!data.events?.length) return; + const section = document.createElement('div'); + section.className = 'token-section'; + const h = document.createElement('h3'); + h.className = 'token-heading'; + h.textContent = '감사 로그'; + section.appendChild(h); + const csvBtn = document.createElement('button'); + csvBtn.type = 'button'; + csvBtn.className = 'secondary-button'; + csvBtn.textContent = 'CSV 다운로드'; + csvBtn.addEventListener('click', async () => { + try { + const res = await fetch(`/api/orgs/${currentOrgId}/audit?format=csv&limit=500`, { headers: { authorization: `Bearer ${getToken()}` } }); + if (!res.ok) return toast('감사 로그 내보내기에 실패했습니다.'); + const blob = await res.blob(); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `scopeweave-audit-${currentOrgId}.csv`; + a.click(); + URL.revokeObjectURL(a.href); + toast('감사 로그 CSV를 내려받았습니다.'); + } catch { toast('감사 로그 내보내기에 실패했습니다.'); } + }); + section.appendChild(csvBtn); + const list = document.createElement('ul'); + list.className = 'audit-list'; + for (const e of data.events) { + const li = document.createElement('li'); + const label = AUDIT_LABELS[e.action] || e.action; + const who = e.actorEmail || '시스템'; + const when = (e.createdAt || '').replace('T', ' ').slice(0, 16); + li.textContent = `${when} · ${who} · ${label}`; + list.appendChild(li); + } + section.appendChild(list); + body.appendChild(section); +} + +// Personal Access Tokens — create/list/revoke, secret shown once. +async function renderTokens(body) { + const section = document.createElement('div'); + section.className = 'token-section'; + const h = document.createElement('h3'); + h.className = 'token-heading'; + h.textContent = 'API 토큰'; + section.appendChild(h); + + let data; + try { data = await api('/api/tokens'); } catch { return; } + const list = document.createElement('ul'); + list.className = 'team-list'; + for (const t of data.tokens) { + const li = document.createElement('li'); + const who = document.createElement('span'); + who.className = 'team-who'; + who.textContent = `${t.name} · ${t.prefix}… ${t.lastUsed ? '· 최근 사용 ' + t.lastUsed.slice(0, 10) : '· 미사용'}`; + li.appendChild(who); + const del = document.createElement('button'); + del.type = 'button'; + del.className = 'secondary-button team-remove'; + del.textContent = '폐기'; + del.addEventListener('click', () => + api(`/api/tokens/${t.id}`, { method: 'DELETE' }).then(() => { toast('토큰을 폐기했습니다.'); renderTeam(); }).catch((e) => toast(e.message))); + li.appendChild(del); + list.appendChild(li); + } + section.appendChild(list); + + const form = document.createElement('form'); + form.className = 'team-invite'; + const input = document.createElement('input'); + input.type = 'text'; + input.placeholder = '토큰 이름 (예: CI, Zapier)'; + const btn = document.createElement('button'); + btn.type = 'submit'; + btn.className = 'primary-button'; + btn.textContent = '토큰 생성'; + form.append(input, btn); + const secret = document.createElement('p'); + secret.className = 'token-secret'; + form.addEventListener('submit', async (e) => { + e.preventDefault(); + try { + const t = await api('/api/tokens', { method: 'POST', body: { name: input.value.trim() || 'token' } }); + secret.textContent = `한 번만 표시됩니다 — 지금 복사하세요: ${t.token}`; + input.value = ''; + // append the new token to the list without wiping the shown secret + const li = document.createElement('li'); + const who = document.createElement('span'); + who.className = 'team-who'; + who.textContent = `${t.name} · ${t.prefix}… · 미사용`; + li.appendChild(who); + list.appendChild(li); + } catch (err) { toast(err.data?.error || err.message); } + }); + section.appendChild(form); + section.appendChild(secret); + body.appendChild(section); +} + +// SSO (OIDC) redirect: the token arrives in the URL fragment (not query → not +// logged). Store it and clean the URL before anything else reads auth state. +if (typeof window !== 'undefined' && location.hash.startsWith('#token=')) { + const t = decodeURIComponent(location.hash.slice('#token='.length)); + if (t) { + setToken(t); + history.replaceState(null, '', location.pathname + location.search); + } +} + +// Auto-accept an invite token from the URL (?invite=...) once logged in. +if (typeof window !== 'undefined') { + const params = new URLSearchParams(location.search); + const inviteToken = routeTokenPathSegment(params.get('invite')); + if (inviteToken && getToken()) { + api(`/api/invites/${inviteToken}/accept`, { method: 'POST' }) + .then((res) => { currentOrgId = res.orgId; refreshProjects().then(renderAuthUI); toast('초대를 수락했습니다.'); }) + .catch(() => {}); + } +} + +// Bridge onto window so app.js (a plain, non-import script) can reach us +// without an ESM import statement — keeps app.js eval-safe for unit tests. +if (typeof window !== 'undefined') { + window.ScopeWeaveCloud = cloud; +} diff --git a/cloud-sync.js b/cloud-sync.js index 0e015ebe..4cca476e 100644 --- a/cloud-sync.js +++ b/cloud-sync.js @@ -1,2208 +1,174 @@ -// ScopeWeave cloud sync — an OPT-IN overlay on the offline planner. -// Logged out, every export here is a no-op and the app behaves exactly as the -// original localStorage-only planner (so existing e2e tests are unaffected). -// Logged in with a project open, edits sync to the API with optimistic -// concurrency and a project sees other tabs' changes live over SSE. +// Security envelope for the existing cloud client. The established planner +// implementation remains in cloud-sync-core.js; this module preserves its +// exports while preventing broad session JWTs from becoming navigated document +// URLs during the staged attachment-view migration in #413. +export * from './cloud-sync-core.js'; -const TOKEN_KEY = 'scopeweave:token'; -const PROJECT_KEY = 'scopeweave:project'; -const ROUTE_TOKEN_RE = /^[A-Za-z0-9_-]{16,128}$/; +const ATTACHMENT_VIEW_PATH = /^\/api\/projects\/([1-9][0-9]*)\/attachments\/([1-9][0-9]*)\/view$/; +const GRANT_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const patchedWindows = new WeakSet(); -let host = null; // { hydrateState, renderAll, getState } provided by app.js -let version = 0; // open project's doc version (optimistic concurrency) -let sse = null; -let pushTimer = null; -let currentOrgId = null; // org of the open project, for team management -let shareMode = false; // viewing via a public share token → read-only - -const getToken = () => localStorage.getItem(TOKEN_KEY) || ''; -const setToken = (t) => (t ? localStorage.setItem(TOKEN_KEY, t) : localStorage.removeItem(TOKEN_KEY)); -const getProjectId = () => localStorage.getItem(PROJECT_KEY) || ''; -const setProjectId = (id) => (id ? localStorage.setItem(PROJECT_KEY, String(id)) : localStorage.removeItem(PROJECT_KEY)); -const isAuthed = () => Boolean(getToken()); - -export function routeTokenPathSegment(value) { - const token = String(value || '').trim(); - return ROUTE_TOKEN_RE.test(token) ? token : ''; -} - -function safeApiPath(path) { - if (typeof path !== 'string' || !path.startsWith('/api/')) throw new Error('invalid api path'); - const origin = typeof location !== 'undefined' ? location.origin : 'http://localhost'; - const url = new URL(path, origin); - if (url.origin !== origin || !(url.pathname === '/api' || url.pathname.startsWith('/api/'))) { - throw new Error('invalid api path'); - } - return `${url.pathname}${url.search}`; -} - -function toast(message) { - const el = document.getElementById('toast'); - if (!el) return; - el.textContent = message; - el.classList.add('visible'); - clearTimeout(toast._t); - toast._t = setTimeout(() => el.classList.remove('visible'), 3200); -} - -async function api(path, { method = 'GET', body } = {}) { - const res = await fetch(safeApiPath(path), { - method, - headers: { - 'content-type': 'application/json', - ...(getToken() ? { authorization: `Bearer ${getToken()}` } : {}), - }, - body: body ? JSON.stringify(body) : undefined, - }); - if (res.status === 401) { setToken(''); setProjectId(''); renderAuthUI(); throw new Error('unauthorized'); } - const data = await res.json().catch(() => ({})); - if (!res.ok) throw Object.assign(new Error(data.error || res.statusText), { status: res.status, data }); - return data; -} - -// ---- realtime (EventSource can't set headers → token via query; ceiling: -// swap for a short-lived stream token before prod so JWTs stay out of URLs) -function subscribe(id) { - if (sse) { sse.close(); sse = null; } - sse = new EventSource(`/api/projects/${id}/stream?token=${encodeURIComponent(getToken())}`); - sse.onmessage = (ev) => { - let msg; - try { msg = JSON.parse(ev.data); } catch { return; } - if (msg.type === 'update' && typeof msg.version === 'number' && msg.version > version) { - openProject(id, { silent: true }).then(() => toast('실시간 업데이트를 반영했습니다.')).catch(() => {}); - } - }; -} - -async function openProject(id, { silent = false } = {}) { - const p = await api(`/api/projects/${id}`); - setProjectId(id); - currentOrgId = p.orgId || projectsCache.find((x) => String(x.id) === String(id))?.orgId || currentOrgId; - version = p.version; - host?.hydrateState({ projectName: p.name, baseDate: p.baseDate, tasks: p.tasks }); - host?.renderAll(); - subscribe(id); - // opening = seen: clear the unseen badge for this project - notifCache.delete(String(id)); - api(`/api/projects/${id}/seen`, { method: 'POST' }).catch(() => {}); - renderAuthUI(); - if (!silent) toast(`'${p.name}' 프로젝트를 열었습니다.`); -} - -async function doPush(payload) { - clearTimeout(pushTimer); - pushTimer = null; - try { - const r = await api(`/api/projects/${getProjectId()}`, { - method: 'PUT', - body: { name: payload.projectName, baseDate: payload.baseDate, tasks: payload.tasks, version }, - }); - version = r.version; - } catch (e) { - if (e.status === 409) { - await openProject(getProjectId(), { silent: true }).catch(() => {}); - toast('다른 사용자가 먼저 저장하여 최신본을 불러왔습니다.'); - } else if (e.message !== 'unauthorized') { - toast('클라우드 저장 실패 — 로컬에는 저장되었습니다.'); - } - } -} - -// ---------------------------------------------------------------- public API -export const cloud = { - init(hostApi) { - host = hostApi; - ensureAuthUI(); - renderAuthUI(); - if (isAuthed()) refreshProjects().then(renderAuthUI).catch(() => {}); - }, - // Returns the saved project state to hydrate, or null (→ local/seed path). - async boot() { - // public read-only share view (?share=TOKEN) — no account needed - const shareToken = routeTokenPathSegment(new URLSearchParams(location.search).get('share')); - if (shareToken) { - try { - const p = await api(`/api/shared/${shareToken}`); - shareMode = true; - renderAuthUI(); - toast('읽기 전용 공유 보기입니다 — 변경은 저장되지 않습니다.'); - return { projectName: p.name, baseDate: p.baseDate, tasks: p.tasks }; - } catch { - toast('공유 링크가 만료되었거나 철회되었습니다.'); - } - } - if (!isAuthed() || !getProjectId()) { renderAuthUI(); return null; } - try { - const p = await api(`/api/projects/${getProjectId()}`); - version = p.version; - currentOrgId = p.orgId || currentOrgId; // team/dashboard need the org right after reload - subscribe(p.id); - renderAuthUI(); - return { projectName: p.name, baseDate: p.baseDate, tasks: p.tasks }; - } catch { - renderAuthUI(); - return null; - } - }, - // Called from persistState(). No-op unless logged in with a project open. - push(payload) { - if (shareMode) return; // read-only share view never writes - if (!isAuthed() || !getProjectId()) return; - clearTimeout(pushTimer); - pushTimer = setTimeout(() => doPush(payload), 600); - }, -}; - -// ------------------------------------------------------------------- auth UI -function ensureAuthUI() { - if (document.getElementById('cloud-auth')) return; - const titleRow = document.querySelector('.title-row'); - if (!titleRow) return; - const bar = document.createElement('div'); - bar.id = 'cloud-auth'; - bar.className = 'cloud-auth'; - titleRow.appendChild(bar); - - // modal (reuses .modal/.hidden conventions from the gantt modal) - const modal = document.createElement('div'); - modal.id = 'cloud-modal'; - modal.className = 'modal hidden'; - modal.setAttribute('role', 'dialog'); - modal.setAttribute('aria-modal', 'true'); - modal.setAttribute('aria-labelledby', 'cloud-modal-title'); - modal.innerHTML = ` - - `; - document.body.appendChild(modal); - - let mode = 'login'; - const $ = (id) => modal.querySelector(id); - const setMode = (m) => { - mode = m; - $('#cloud-modal-title').textContent = m === 'login' ? '클라우드 로그인' : '계정 만들기'; - $('#cloud-submit').textContent = m === 'login' ? '로그인' : '가입'; - $('#cloud-toggle').textContent = m === 'login' ? '계정 만들기' : '로그인으로'; - modal.querySelector('.cloud-name-field').classList.toggle('hidden', m !== 'signup'); - $('#cloud-error').textContent = ''; - }; - $('#cloud-toggle').addEventListener('click', () => setMode(mode === 'login' ? 'signup' : 'login')); - $('#cloud-sso').addEventListener('click', () => { window.location.href = '/api/auth/oidc/start'; }); - modal.addEventListener('click', (e) => { if (e.target.dataset.cloudClose) modal.classList.add('hidden'); }); - $('#cloud-form').addEventListener('submit', async (e) => { - e.preventDefault(); - const email = $('#cloud-email').value.trim(); - const password = $('#cloud-password').value; - const name = $('#cloud-name').value.trim(); - try { - const r = await api(`/api/auth/${mode === 'login' ? 'login' : 'signup'}`, { method: 'POST', body: { email, password, name } }); - setToken(r.token); - modal.classList.add('hidden'); - await refreshProjects(); - renderAuthUI(); - toast(mode === 'login' ? '로그인되었습니다.' : '가입되어 클라우드 저장이 켜졌습니다.'); - } catch (err) { - $('#cloud-error').textContent = err.data?.error || err.message || '요청 실패'; - } - }); - bar._openModal = () => { setMode('login'); modal.classList.remove('hidden'); $('#cloud-email').focus(); }; -} - -let projectsCache = []; -let notifCache = new Map(); // projectId -> unseen count - -async function refreshProjects() { - try { projectsCache = (await api('/api/projects')).projects || []; } catch { projectsCache = []; } - try { - const n = await api('/api/notifications'); - notifCache = new Map((n.notifications || []).map((x) => [String(x.projectId), x.unseen])); - } catch { notifCache = new Map(); } -} - -function renderAuthUI() { - const bar = document.getElementById('cloud-auth'); - if (!bar) return; - bar.textContent = ''; - if (shareMode) { - const tag = document.createElement('span'); - tag.className = 'team-role-tag'; - tag.textContent = '읽기 전용 공유 보기'; - bar.appendChild(tag); - return; - } - if (!isAuthed()) { - const btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'secondary-button'; - btn.textContent = '☁ 클라우드 로그인'; - btn.addEventListener('click', openLoginModal); - bar.appendChild(btn); - return; - } - // logged in: onboarding (no projects yet) → sample; else project switcher. - if (!projectsCache.length) { - const sample = document.createElement('button'); - sample.type = 'button'; - sample.className = 'primary-button'; - sample.textContent = '✨ 샘플로 시작'; - sample.addEventListener('click', sampleStart); - bar.appendChild(sample); - } - const select = document.createElement('select'); - select.className = 'cloud-select'; - select.setAttribute('aria-label', '프로젝트 선택'); - const openId = getProjectId(); - const ph = document.createElement('option'); - ph.value = ''; - ph.textContent = projectsCache.length ? '프로젝트 선택…' : '프로젝트 없음'; - select.appendChild(ph); - for (const p of projectsCache.filter((x) => !x.archived)) { - const opt = document.createElement('option'); - opt.value = String(p.id); - const unseen = notifCache.get(String(p.id)); - opt.textContent = unseen ? `${p.name} ●${unseen}` : p.name; // textContent → XSS-safe - if (String(p.id) === String(openId)) opt.selected = true; - select.appendChild(opt); - } - const archivedProjects = projectsCache.filter((x) => x.archived); - if (archivedProjects.length) { - const group = document.createElement('optgroup'); - group.label = '보관됨'; - for (const p of archivedProjects) { - const opt = document.createElement('option'); - opt.value = String(p.id); - opt.textContent = `📦 ${p.name}`; - if (String(p.id) === String(openId)) opt.selected = true; - group.appendChild(opt); - } - select.appendChild(group); - } - select.addEventListener('change', () => { if (select.value) openProject(select.value).catch((e) => toast(e.message)); }); - bar.appendChild(select); - - const newBtn = document.createElement('button'); - newBtn.type = 'button'; - newBtn.className = 'secondary-button'; - newBtn.textContent = '+ 새 프로젝트'; - newBtn.addEventListener('click', createProjectFlow); - bar.appendChild(newBtn); - - const dash = document.createElement('button'); - dash.type = 'button'; - dash.className = 'secondary-button'; - dash.textContent = '대시보드'; - dash.addEventListener('click', () => openPortfolioModal().catch((e) => toast(e.message || '대시보드를 불러오지 못했습니다.'))); - bar.appendChild(dash); - - const team = document.createElement('button'); - team.type = 'button'; - team.className = 'secondary-button'; - team.textContent = '팀'; - team.addEventListener('click', () => openTeamModal().catch((e) => toast(e.message || '팀 정보를 불러오지 못했습니다.'))); - bar.appendChild(team); - - if (getProjectId()) { - const bl = document.createElement('button'); - bl.type = 'button'; - bl.className = 'secondary-button'; - bl.textContent = '기준선'; - bl.addEventListener('click', () => openBaselineModal().catch((e) => toast(e.message || '기준선을 불러오지 못했습니다.'))); - bar.appendChild(bl); - - const dup = document.createElement('button'); - dup.type = 'button'; - dup.className = 'secondary-button'; - dup.textContent = '복제'; - dup.addEventListener('click', async () => { - const name = prompt('새 프로젝트 이름 (템플릿으로 복제)'); - if (name === null) return; - try { - const created = await api(`/api/projects/${getProjectId()}/duplicate`, { method: 'POST', body: { name } }); - await refreshProjects(); - await openProject(created.id); - toast(`"${created.name}" 프로젝트로 복제했습니다.`); - } catch (err) { toast(err.data?.error || err.message); } - }); - bar.appendChild(dup); - - const share = document.createElement('button'); - share.type = 'button'; - share.className = 'secondary-button'; - share.textContent = '공유'; - share.addEventListener('click', () => openShareModal().catch((e) => toast(e.data?.error || e.message))); - bar.appendChild(share); - - const report = document.createElement('button'); - report.type = 'button'; - report.className = 'secondary-button'; - report.textContent = '주간보고'; - report.addEventListener('click', () => { try { openReportModal(); } catch (e) { toast(e.message || '보고서 생성 실패'); } }); - bar.appendChild(report); - - const msp = document.createElement('button'); - msp.type = 'button'; - msp.className = 'secondary-button'; - msp.textContent = 'MSP 가져오기'; - msp.addEventListener('click', () => { - let fi = document.getElementById('msp-file-input'); - if (!fi) { - fi = document.createElement('input'); - fi.id = 'msp-file-input'; - fi.type = 'file'; - fi.accept = '.xml,text/xml'; - fi.hidden = true; - fi.addEventListener('change', () => { - const f = fi.files?.[0]; - fi.value = ''; - if (f) importMsProjectFile(f).catch((e) => toast(e.message || 'MSP 가져오기에 실패했습니다.')); - }); - document.body.appendChild(fi); - } - fi.click(); - }); - bar.appendChild(msp); - - const cur = projectsCache.find((x) => String(x.id) === String(getProjectId())); - const arch = document.createElement('button'); - arch.type = 'button'; - arch.className = 'secondary-button'; - arch.textContent = cur?.archived ? '보관 해제' : '보관'; - arch.addEventListener('click', async () => { - try { - const res = await api(`/api/projects/${getProjectId()}/archive`, { method: 'POST', body: { archived: !cur?.archived } }); - await refreshProjects(); - renderAuthUI(); - toast(res.archived ? '프로젝트를 보관했습니다.' : '보관을 해제했습니다.'); - } catch (err) { toast(err.data?.error || err.message); } - }); - bar.appendChild(arch); - } - - const search = document.createElement('button'); - search.type = 'button'; - search.className = 'secondary-button'; - search.textContent = '검색'; - search.addEventListener('click', openSearchModal); - bar.appendChild(search); - - if (getProjectId()) { - const spr = document.createElement('button'); - spr.type = 'button'; - spr.className = 'secondary-button'; - spr.textContent = '스프린트'; - spr.addEventListener('click', () => openSprintModal().catch((e) => toast(e.data?.error || e.message))); - bar.appendChild(spr); - - const att = document.createElement('button'); - att.type = 'button'; - att.className = 'secondary-button'; - att.textContent = '산출물'; - att.addEventListener('click', () => openAttachmentsModal().catch((e) => toast(e.data?.error || e.message))); - bar.appendChild(att); - - const cmt = document.createElement('button'); - cmt.type = 'button'; - cmt.className = 'secondary-button'; - cmt.textContent = '코멘트'; - cmt.addEventListener('click', () => openCommentsModal().catch((e) => toast(e.message || '코멘트를 불러오지 못했습니다.'))); - bar.appendChild(cmt); - } - - const out = document.createElement('button'); - out.type = 'button'; - out.className = 'secondary-button'; - out.textContent = '로그아웃'; - out.addEventListener('click', () => { - if (sse) { sse.close(); sse = null; } - setToken(''); setProjectId(''); projectsCache = []; - renderAuthUI(); - toast('로그아웃되었습니다. 로컬 저장으로 전환합니다.'); - }); - bar.appendChild(out); -} - -function openLoginModal() { - const modal = document.getElementById('cloud-modal'); - const bar = document.getElementById('cloud-auth'); - if (bar && bar._openModal) return bar._openModal(); - modal?.classList.remove('hidden'); -} - -// Create a cloud project and seed it with `seedState` (defaults to what's on -// screen). Used by both "새 프로젝트" and the "샘플로 시작" onboarding. -async function makeProject(name, seedState) { - const r = await api('/api/projects', { method: 'POST', body: { name } }); - await refreshProjects(); - version = r.version; - setProjectId(r.id); - const meta = projectsCache.find((x) => String(x.id) === String(r.id)); - if (meta) currentOrgId = meta.orgId; - const base = seedState || host?.getState?.() || { baseDate: '', tasks: [] }; - await doPush({ ...base, projectName: name }); // keep the chosen project name - subscribe(r.id); - renderAuthUI(); - return r; -} - -async function createProjectFlow() { - const name = prompt('새 프로젝트 이름'); - if (!name || !name.trim()) return; +function normalizedOrigin(origin) { try { - await makeProject(name.trim()); - toast(`'${name.trim()}' 프로젝트를 만들었습니다.`); - } catch (e) { - toast(e.message || '프로젝트 생성 실패'); - } -} - -// Onboarding: a first project pre-populated from the app's source-backed seed -// (whatever app.js has loaded on screen — the wbs.json sample for a new user). -async function sampleStart() { + const url = new URL(origin); + return (url.protocol === 'https:' || url.protocol === 'http:') ? url.origin : null; + } catch { + return null; + } +} + +/** + * Parse only ScopeWeave's legacy same-origin attachment-view URL shape. + * + * The returned session token is used solely as an Authorization header during + * the exchange and must never be navigated, logged, or copied into a new URL. + * + * @param {unknown} value Candidate URL passed to window.open(). + * @param {string} origin Trusted current-page origin. + * @returns {{projectId:string,attachmentId:string,sessionToken:string}|null} Bound exchange input. + */ +export function parseLegacyAttachmentViewUrl(value, origin) { + if (typeof value !== 'string') return null; + const trustedOrigin = normalizedOrigin(origin); + if (!trustedOrigin) return null; + let url; try { - await makeProject('샘플 프로젝트', host?.getState?.()); - toast('샘플 프로젝트로 시작했습니다. 자유롭게 편집하세요.'); - } catch (e) { - toast(e.message || '샘플 프로젝트 생성 실패'); + url = new URL(value, trustedOrigin); + } catch { + return null; } + if (url.origin !== trustedOrigin || url.hash || url.searchParams.getAll('token').length !== 1) return null; + if (url.searchParams.has('grant') || [...url.searchParams.keys()].some((key) => key !== 'token')) return null; + const match = ATTACHMENT_VIEW_PATH.exec(url.pathname); + const sessionToken = url.searchParams.get('token') || ''; + if (!match || !sessionToken) return null; + return Object.freeze({ projectId: match[1], attachmentId: match[2], sessionToken }); } -// ------------------------------------------------------------- team / RBAC UI -const ROLE_LABELS = { owner: '소유자', admin: '관리자', member: '멤버', viewer: '뷰어' }; - -async function exportOrg() { +function validateIssuedGrantUrl(value, origin, projectId, attachmentId) { + if (typeof value !== 'string') throw new Error('attachment view grant response invalid'); + const trustedOrigin = normalizedOrigin(origin); + if (!trustedOrigin) throw new Error('attachment view grant response invalid'); + let url; try { - const res = await fetch(`/api/orgs/${currentOrgId}/export`, { headers: { authorization: `Bearer ${getToken()}` } }); - if (res.status === 403) return toast('소유자만 데이터를 내보낼 수 있습니다.'); - if (!res.ok) return toast('내보내기에 실패했습니다.'); - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `scopeweave-org-${currentOrgId}.json`; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); - toast('워크스페이스 데이터를 내보냈습니다.'); + url = new URL(value, trustedOrigin); } catch { - toast('내보내기에 실패했습니다.'); - } -} - -async function resolveOrgId() { - if (currentOrgId) return currentOrgId; - const me = await api('/api/me'); - currentOrgId = me.orgs?.[0]?.id || null; - return currentOrgId; -} - -// ---------------------------------------------------------------- share links -async function openShareModal() { - const pid = getProjectId(); - if (!pid) return; - let modal = document.getElementById('share-modal'); - if (!modal) { - modal = document.createElement('div'); - modal.id = 'share-modal'; - modal.className = 'modal'; - modal.setAttribute('role', 'dialog'); - modal.setAttribute('aria-modal', 'true'); - const backdrop = document.createElement('div'); - backdrop.className = 'modal-backdrop'; - backdrop.addEventListener('click', () => modal.classList.add('hidden')); - const panel = document.createElement('div'); - panel.className = 'modal-panel'; - panel.id = 'share-panel'; - modal.append(backdrop, panel); - document.body.appendChild(modal); - } - modal.classList.remove('hidden'); - const panel = modal.querySelector('#share-panel'); - panel.textContent = ''; - - const head = document.createElement('div'); - head.className = 'modal-header'; - const h2 = document.createElement('h2'); - h2.textContent = '읽기 전용 공유'; - const close = document.createElement('button'); - close.type = 'button'; - close.className = 'icon-button close-button'; - close.setAttribute('aria-label', '공유 닫기'); - close.textContent = '✕'; - close.addEventListener('click', () => modal.classList.add('hidden')); - head.append(h2, close); - panel.appendChild(head); - - const make = document.createElement('button'); - make.type = 'button'; - make.className = 'primary-button'; - make.textContent = '공유 링크 만들기'; - make.addEventListener('click', async () => { - try { - const res = await api(`/api/projects/${pid}/shares`, { method: 'POST' }); - const url = `${location.origin}${res.url}`; - try { await navigator.clipboard.writeText(url); toast('공유 링크를 복사했습니다.'); } - catch { prompt('공유 링크 (복사하세요)', url); } - openShareModal(); - } catch (e) { toast(e.data?.error || e.message); } - }); - panel.appendChild(make); - - const list = document.createElement('ul'); - list.className = 'team-list'; - panel.appendChild(list); - const data = await api(`/api/projects/${pid}/shares`); - if (!data.shares.length) { - const li = document.createElement('li'); - li.textContent = '활성 공유 링크가 없습니다.'; - list.appendChild(li); - return; + throw new Error('attachment view grant response invalid'); + } + const expectedPath = `/api/projects/${projectId}/attachments/${attachmentId}/view`; + const grants = url.searchParams.getAll('grant'); + if ( + url.origin !== trustedOrigin + || url.pathname !== expectedPath + || url.hash + || grants.length !== 1 + || !GRANT_PATTERN.test(grants[0]) + || [...url.searchParams.keys()].some((key) => key !== 'grant') + ) { + throw new Error('attachment view grant response invalid'); } - for (const sRow of data.shares) { - const li = document.createElement('li'); - const who = document.createElement('span'); - who.className = 'team-who'; - who.textContent = `${location.origin}/?share=${sRow.token.slice(0, 8)}… · ${String(sRow.createdAt).slice(0, 10)}`; - const copyB = document.createElement('button'); - copyB.type = 'button'; - copyB.className = 'secondary-button'; - copyB.textContent = '복사'; - copyB.addEventListener('click', async () => { - const url = `${location.origin}/?share=${sRow.token}`; - try { await navigator.clipboard.writeText(url); toast('복사했습니다.'); } catch { prompt('공유 링크', url); } - }); - const rev = document.createElement('button'); - rev.type = 'button'; - rev.className = 'secondary-button team-remove'; - rev.textContent = '철회'; - rev.addEventListener('click', () => - api(`/api/projects/${pid}/shares/${sRow.id}`, { method: 'DELETE' }) - .then(() => { toast('공유를 철회했습니다.'); openShareModal(); }) - .catch((e) => toast(e.data?.error || e.message))); - li.append(who, copyB, rev); - list.appendChild(li); - } -} - -// ------------------------------------------------------------ weekly report -// 주간보고 generator — the PM deliverable, straight from live data. -// Pure: takes tasks + a reference date, returns markdown. -export function buildWeeklyReport(tasks, refDate, projectName = '') { - const ref = new Date(refDate); - if (Number.isNaN(ref.getTime())) return ''; - const day = (d) => d.toISOString().slice(0, 10); - const monday = new Date(ref); - monday.setDate(ref.getDate() - ((ref.getDay() + 6) % 7)); // this week's Monday - const weekStart = day(monday); - const weekEnd = day(new Date(monday.getTime() + 6 * 86400000)); - const nextStart = day(new Date(monday.getTime() + 7 * 86400000)); - const nextEnd = day(new Date(monday.getTime() + 13 * 86400000)); - const today = day(ref); - const name = (t) => t.name || t.task || t.activity || t.phase || t.id; - const leaf = (tasks || []).filter((t) => !t.isSynthetic); - - const done = leaf.filter((t) => t.actualEndDate && t.actualEndDate >= weekStart && t.actualEndDate <= weekEnd); - const doing = leaf.filter((t) => { - const a = Number(t.actualProgress) || 0; - return a > 0 && a < 100 && !t.actualEndDate; - }); - const late = leaf.filter((t) => t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100); - const upcoming = leaf.filter((t) => t.plannedStartDate && t.plannedStartDate >= nextStart && t.plannedStartDate <= nextEnd); - - let wSum = 0, pv = 0, ev = 0; - for (const t of leaf) { - const w = Number(t.weight) || 1; - wSum += w; - pv += w * ((Number(t.plannedProgress) || 0) / 100); - ev += w * ((Number(t.actualProgress) || 0) / 100); - } - const pvPct = wSum ? (pv / wSum) * 100 : 0; - const evPct = wSum ? (ev / wSum) * 100 : 0; - const spi = pvPct > 0 ? evPct / pvPct : null; - - const section = (title, items, fmt) => - `## ${title}\n${items.length ? items.map((t) => `- ${fmt(t)}`).join('\n') : '- (없음)'}`; - return [ - `# 주간보고${projectName ? ` — ${projectName}` : ''} (${weekStart} ~ ${weekEnd})`, - '', - `**진척 요약**: 계획 ${pvPct.toFixed(1)}% · 실적 ${evPct.toFixed(1)}%` + - (spi === null ? '' : ` · SPI ${spi.toFixed(2)} (${spi >= 1 ? '일정 준수' : spi >= 0.9 ? '경미한 지연' : '지연 위험'})`), - '', - section('금주 완료', done, (t) => `${name(t)} (${t.actualEndDate})`), - '', - section('진행 중', doing, (t) => `${name(t)} — ${Number(t.actualProgress) || 0}%${t.owner ? ` (${t.owner})` : ''}`), - '', - section('지연', late, (t) => `${name(t)} — 계획종료 ${t.plannedEndDate}, 실적 ${Number(t.actualProgress) || 0}%${t.owner ? ` (${t.owner})` : ''}`), - '', - section('차주 예정', upcoming, (t) => `${name(t)} (${t.plannedStartDate} 시작${t.owner ? `, ${t.owner}` : ''})`), - '', - ].join('\n'); -} - -function openReportModal() { - const state = host?.getState?.(); - if (!state) { toast('프로젝트를 먼저 여세요.'); return; } - const md = buildWeeklyReport(state.tasks, new Date().toISOString().slice(0, 10), state.projectName || ''); - let modal = document.getElementById('report-modal'); - if (!modal) { - modal = document.createElement('div'); - modal.id = 'report-modal'; - modal.className = 'modal'; - modal.setAttribute('role', 'dialog'); - modal.setAttribute('aria-modal', 'true'); - const backdrop = document.createElement('div'); - backdrop.className = 'modal-backdrop'; - backdrop.addEventListener('click', () => modal.classList.add('hidden')); - const panel = document.createElement('div'); - panel.className = 'modal-panel'; - panel.id = 'report-panel'; - modal.append(backdrop, panel); - document.body.appendChild(modal); - } - modal.classList.remove('hidden'); - const panel = modal.querySelector('#report-panel'); - panel.textContent = ''; - const head = document.createElement('div'); - head.className = 'modal-header'; - const h2 = document.createElement('h2'); - h2.textContent = '주간보고'; - const close = document.createElement('button'); - close.type = 'button'; - close.className = 'icon-button close-button'; - close.setAttribute('aria-label', '주간보고 닫기'); - close.textContent = '✕'; - close.addEventListener('click', () => modal.classList.add('hidden')); - head.append(h2, close); - panel.appendChild(head); - - const copy = document.createElement('button'); - copy.type = 'button'; - copy.className = 'primary-button'; - copy.textContent = '마크다운 복사'; - copy.addEventListener('click', async () => { - try { await navigator.clipboard.writeText(md); toast('주간보고를 복사했습니다.'); } - catch { toast('복사에 실패했습니다 — 아래 내용을 직접 선택하세요.'); } - }); - panel.appendChild(copy); - - const ai = document.createElement('button'); - ai.type = 'button'; - ai.className = 'secondary-button'; - ai.style.marginLeft = '8px'; - ai.textContent = 'AI 요약'; - ai.addEventListener('click', async () => { - ai.disabled = true; - ai.textContent = '분석 중…'; - try { - const res = await api(`/api/projects/${getProjectId()}/ai/brief`, { method: 'POST' }); - let box = document.getElementById('report-ai'); - if (!box) { - box = document.createElement('pre'); - box.id = 'report-ai'; - box.style.whiteSpace = 'pre-wrap'; - box.style.borderLeft = '3px solid var(--primary, #2563eb)'; - box.style.paddingLeft = '10px'; - panel.insertBefore(box, panel.querySelector('#report-body')); - } - box.textContent = `🤖 AI 브리핑\n${res.analysis}`; - } catch (e) { toast(e.data?.error || e.message); } - finally { ai.disabled = false; ai.textContent = 'AI 요약'; } - }); - panel.appendChild(ai); - - const pre = document.createElement('pre'); - pre.id = 'report-body'; - pre.style.whiteSpace = 'pre-wrap'; - pre.style.userSelect = 'text'; - pre.textContent = md; - panel.appendChild(pre); -} - -// ------------------------------------------------------- MS Project import -// Parse Microsoft Project XML (Project 2003+ .xml export) into ScopeWeave's -// task schema. ponytail: regex block parsing (MSP XML is machine-generated, -// no DOMParser needed → node-testable); swap for a real XML parser if -// hand-edited files ever matter. -export function parseMsProjectXml(xml) { - // Fully linear extract (indexOf/slice) — no dynamic RegExp and no lazy - // [\s\S]*? block collectors (those can quadratic-backtrack on truncated input). - const isXmlWhitespace = (charCode) => ( - charCode === 0x20 || charCode === 0x09 || charCode === 0x0d || charCode === 0x0a - ); - const findTagBoundary = (source, name, from, closing = false) => { - const prefix = `<${closing ? '/' : ''}${name}`; - let searchFrom = from; - for (;;) { - const start = source.indexOf(prefix, searchFrom); - if (start === -1) return null; - let delimiter = start + prefix.length; - while (delimiter < source.length && isXmlWhitespace(source.charCodeAt(delimiter))) { - delimiter += 1; - } - if (source.charCodeAt(delimiter) === 0x3e) { - return { start, end: delimiter + 1 }; - } - // Reject attributes, longer names, and non-XML whitespace while advancing - // past every inspected byte so malformed candidates are never rescanned. - searchFrom = Math.max(delimiter + 1, start + prefix.length); - } - }; - const tag = (block, name) => { - const opening = findTagBoundary(block, name, 0); - if (!opening) return ''; - const closing = findTagBoundary(block, name, opening.end, true); - const nextOpening = findTagBoundary(block, name, opening.end); - if (!closing || (nextOpening && nextOpening.start < closing.start)) return ''; - return block.slice(opening.end, closing.start).trim(); - }; - const collectBlocks = (source, name) => { - const out = []; - let from = 0; - for (;;) { - const opening = findTagBoundary(source, name, from); - if (!opening) break; - const closing = findTagBoundary(source, name, opening.end, true); - const nextOpening = findTagBoundary(source, name, opening.end); - // Incomplete or nested same-name block: stop at the first unmatched - // opening tag instead of pairing it with a later block's closing tag. - if (!closing || (nextOpening && nextOpening.start < closing.start)) break; - out.push(source.slice(opening.start, closing.end)); - from = closing.end; - } - return out; - }; - const predecessorIds = (block) => { - const ids = []; - for (const link of collectBlocks(block, 'PredecessorLink')) { - const uid = tag(link, 'PredecessorUID'); - if (/^\d+$/.test(uid)) ids.push(`msp-${uid}`); - } - return ids; - }; - const unescape = (s) => s - .replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') - .replace(/'/g, "'").replace(/&/g, '&'); - const day = (s) => (/^\d{4}-\d{2}-\d{2}/.test(s) ? s.slice(0, 10) : ''); - const tasks = []; - const parents = {}; // depth -> last task id at that depth - const blocks = collectBlocks(String(xml || ''), 'Task'); - for (const block of blocks) { - const uid = tag(block, 'UID'); - const name = unescape(tag(block, 'Name')); - if (!uid || uid === '0' || !name) continue; // project-summary row / blanks - const level = Math.max(1, Number(tag(block, 'OutlineLevel')) || 1); - const depth = Math.min(level, 3); // deeper levels flatten to task level - const preds = predecessorIds(block); - const pct = Number(tag(block, 'PercentComplete')) || 0; - const t = { - id: `msp-${uid}`, - parentId: depth > 1 ? (parents[depth - 1] || '') : '', - depth, - phase: depth === 1 ? name : '', - activity: depth === 2 ? name : '', - task: depth === 3 ? name : '', - name, - plannedStartDate: day(tag(block, 'Start')), - plannedEndDate: day(tag(block, 'Finish')), - actualProgress: pct, - predecessors: preds.join(','), - }; - tasks.push(t); - parents[depth] = t.id; - for (let d = depth + 1; d <= 3; d++) delete parents[d]; // reset deeper chain - } - return tasks; -} - -async function importMsProjectFile(file) { - const xml = await file.text(); - const tasks = parseMsProjectXml(xml); - if (!tasks.length) { toast('가져올 작업이 없습니다 (MSP XML 형식을 확인하세요).'); return; } - if (!confirm(`MS Project에서 ${tasks.length}개 작업을 가져옵니다. 현재 프로젝트 내용을 대체합니다.`)) return; - // preserve name/baseDate — only the task tree is replaced - const prev = host?.getState?.() || {}; - host?.hydrateState({ projectName: prev.projectName, baseDate: prev.baseDate, tasks }); - host?.renderAll(); - const state = host?.getState?.(); - if (state) await doPush(state); - toast(`MS Project에서 ${tasks.length}개 작업을 가져왔습니다.`); + return `${url.pathname}${url.search}`; } -// ------------------------------------------------------------- portfolio -// Executive rollup: every project's weighted progress, SPI, and overdue count. -async function openPortfolioModal() { - if (!currentOrgId) { toast('워크스페이스를 먼저 선택하세요.'); return; } - let modal = document.getElementById('portfolio-modal'); - if (!modal) { - modal = document.createElement('div'); - modal.id = 'portfolio-modal'; - modal.className = 'modal'; - modal.setAttribute('role', 'dialog'); - modal.setAttribute('aria-modal', 'true'); - const backdrop = document.createElement('div'); - backdrop.className = 'modal-backdrop'; - backdrop.addEventListener('click', () => modal.classList.add('hidden')); - const panel = document.createElement('div'); - panel.className = 'modal-panel'; - panel.id = 'portfolio-panel'; - modal.append(backdrop, panel); - document.body.appendChild(modal); - } - modal.classList.remove('hidden'); - const panel = modal.querySelector('#portfolio-panel'); - panel.textContent = ''; - - const head = document.createElement('div'); - head.className = 'modal-header'; - const h2 = document.createElement('h2'); - h2.textContent = '포트폴리오 대시보드'; - const close = document.createElement('button'); - close.type = 'button'; - close.className = 'icon-button close-button'; - close.setAttribute('aria-label', '대시보드 닫기'); - close.textContent = '✕'; - close.addEventListener('click', () => modal.classList.add('hidden')); - head.append(h2, close); - panel.appendChild(head); - - const data = await api(`/api/orgs/${currentOrgId}/portfolio`); - const active = data.projects.filter((p) => !p.archived); - if (!active.length) { - const p = document.createElement('p'); - p.textContent = '프로젝트가 없습니다.'; - panel.appendChild(p); - return; - } - const summary = document.createElement('p'); - const late = active.filter((p) => p.status === 'delay').length; - const totOverdue = active.reduce((n, p) => n + p.overdue, 0); - summary.textContent = `프로젝트 ${active.length}개 · 주의/지연 ${late}개 · 지연 작업 합계 ${totOverdue}건`; - panel.appendChild(summary); - - const wrap = document.createElement('div'); - wrap.style.overflowX = 'auto'; - const table = document.createElement('table'); - table.className = 'wbs-table'; - const thead = document.createElement('thead'); - const hr = document.createElement('tr'); - for (const t of ['프로젝트', '작업', '계획%', '실적%', 'SPI', '상태', '지연', '']) { - const th = document.createElement('th'); - th.textContent = t; - hr.appendChild(th); - } - thead.appendChild(hr); - const tbody = document.createElement('tbody'); - for (const p of active) { - const tr = document.createElement('tr'); - const cells = [p.name, String(p.tasks), `${p.planned}%`, `${p.actual}%`, p.spi === null ? '-' : p.spi.toFixed(2), p.label, p.overdue ? `${p.overdue}건` : '-']; - for (const cText of cells) { - const td = document.createElement('td'); - td.textContent = cText; - tr.appendChild(td); +/** + * Exchange a broad authenticated session for one short-lived attachment grant. + * + * @param {object} input Resource/session inputs captured before navigation. + * @param {string} input.projectId Project row identifier. + * @param {string} input.attachmentId Attachment row identifier. + * @param {string} input.sessionToken Existing ScopeWeave session or PAT secret. + * @param {string} input.origin Trusted current-page origin. + * @param {Function} [input.fetchImpl] Fetch-compatible transport for tests/browsers. + * @returns {Promise<{url:string,grantId:string,expiresAtMs:number}>} Validated same-origin one-time view target. + */ +export async function exchangeAttachmentViewGrant({ + projectId, + attachmentId, + sessionToken, + origin, + fetchImpl = globalThis.fetch, +}) { + if (typeof fetchImpl !== 'function' || !sessionToken) throw new Error('attachment view grant exchange unavailable'); + const response = await fetchImpl(`/api/projects/${projectId}/access-grants`, { + method: 'POST', + credentials: 'omit', + cache: 'no-store', + redirect: 'error', + headers: { + authorization: `Bearer ${sessionToken}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ purpose: 'attachment_view', attachmentId }), + }); + if (!response || response.status !== 201) throw new Error('attachment view grant exchange failed'); + const payload = await response.json().catch(() => null); + if ( + !payload + || typeof payload !== 'object' + || payload.purpose !== 'attachment_view' + || typeof payload.grantId !== 'string' + || !Number.isSafeInteger(payload.expiresAtMs) + ) { + throw new Error('attachment view grant response invalid'); + } + const url = validateIssuedGrantUrl(payload.url, origin, projectId, attachmentId); + return Object.freeze({ url, grantId: payload.grantId, expiresAtMs: payload.expiresAtMs }); +} + +function popupFeaturesWithoutOpenerIsolation(features) { + if (typeof features !== 'string' || !features.trim()) return ''; + return features + .split(',') + .map((feature) => feature.trim()) + .filter((feature) => feature && !/^(?:noopener|noreferrer)(?:=|$)/i.test(feature)) + .join(','); +} + +/** + * Install the attachment-view navigation bridge on one browser window. + * + * Only the exact legacy ScopeWeave attachment URL is intercepted. A blank + * same-origin tab is opened synchronously to preserve the user's popup gesture, + * its opener is severed immediately, and only the validated one-time grant URL + * is navigated after the authenticated exchange succeeds. + * + * @param {Window|object} windowLike Browser window or a deterministic test seam. + * @param {object} [options] Optional transport/notification seams. + * @param {Function} [options.fetchImpl] Fetch-compatible grant exchange transport. + * @param {Function} [options.alertImpl] Customer-action notification function. + * @returns {boolean} True when newly installed; false when unsupported/already installed. + */ +export function installAttachmentViewGrantWindowOpen(windowLike, { + fetchImpl = globalThis.fetch, + alertImpl = typeof windowLike?.alert === 'function' ? windowLike.alert.bind(windowLike) : () => {}, +} = {}) { + if (!windowLike || typeof windowLike.open !== 'function' || patchedWindows.has(windowLike)) return false; + const nativeOpen = windowLike.open.bind(windowLike); + const origin = windowLike.location?.origin; + + windowLike.open = function scopeWeaveSecureOpen(value, target, features) { + const parsed = parseLegacyAttachmentViewUrl(value, origin); + if (!parsed) return nativeOpen(value, target, features); + + const popup = nativeOpen('about:blank', target || '_blank', popupFeaturesWithoutOpenerIsolation(features)); + if (!popup) { + alertImpl('문서 창을 열 수 없습니다. 브라우저에서 이 사이트의 팝업을 허용한 뒤 다시 시도해 주세요.'); + return null; } - if (p.status === 'delay') tr.style.color = 'var(--delay, #ea580c)'; - const td = document.createElement('td'); - const open = document.createElement('button'); - open.type = 'button'; - open.className = 'secondary-button'; - open.textContent = '열기'; - open.addEventListener('click', async () => { - modal.classList.add('hidden'); - await openProject(p.id).catch((err) => toast(err.message)); - }); - td.appendChild(open); - tr.appendChild(td); - tbody.appendChild(tr); - } - table.append(thead, tbody); - wrap.appendChild(table); - panel.appendChild(wrap); -} - -// --------------------------------------------------------------- sprints -// Agile/Hybrid 지표 (순수): 스프린트별 커밋/완료 스토리포인트와 팀 벨로시티. -// 작업 배정 = task.sprint(이름 일치), 추정 = task.storyPoints, 완료 = 실적 100%. -export function computeSprintStats(tasks, sprints, today) { - const leaf = (tasks || []).filter((t) => !t.isSynthetic); - const rows = (sprints || []).map((sp) => { - const mine = leaf.filter((t) => String(t.sprint || '').trim() === sp.name); - const pts = (t) => Number(t.storyPoints) || 0; - const committed = mine.reduce((n, t) => n + pts(t), 0); - const completed = mine.filter((t) => (Number(t.actualProgress) || 0) >= 100).reduce((n, t) => n + pts(t), 0); - const closed = Boolean(sp.endDate && today && sp.endDate < today); - return { id: sp.id, name: sp.name, startDate: sp.startDate, endDate: sp.endDate, goal: sp.goal, taskCount: mine.length, committed, completed, remaining: committed - completed, closed }; - }); - const closedWithWork = rows.filter((r) => r.closed && r.committed > 0); - const velocity = closedWithWork.length - ? closedWithWork.reduce((n, r) => n + r.completed, 0) / closedWithWork.length - : null; - const backlog = leaf.filter((t) => !String(t.sprint || '').trim() || !(sprints || []).some((sp) => sp.name === String(t.sprint).trim())); - return { rows, velocity, backlogCount: backlog.length }; -} - -// 번다운 (순수): 스프린트 기간의 일별 잔여 포인트 — ideal(선형 소진) vs -// actual(완료일 actualEndDate 기준; 완료일 없는 100% 작업은 오늘 완료로 간주). -export function computeBurndown(tasks, sprint, today) { - if (!sprint?.startDate || !sprint?.endDate || sprint.endDate < sprint.startDate) return null; - const leaf = (tasks || []).filter((t) => !t.isSynthetic && String(t.sprint || '').trim() === sprint.name); - const pts = (t) => Number(t.storyPoints) || 0; - const committed = leaf.reduce((n, t) => n + pts(t), 0); - if (committed <= 0) return null; - const days = []; - for (let d = new Date(sprint.startDate); ; d.setDate(d.getDate() + 1)) { - const iso = d.toISOString().slice(0, 10); - days.push(iso); - if (iso >= sprint.endDate) break; - if (days.length > 120) break; // 안전 상한 - } - const n = days.length; - const ideal = days.map((_, i) => committed * (1 - (n === 1 ? 1 : i / (n - 1)))); - const doneAt = (t) => t.actualEndDate || ((Number(t.actualProgress) || 0) >= 100 ? today : null); - const actual = days.map((day) => { - if (today && day > today) return null; // 미래는 미기록 - const burned = leaf.filter((t) => { const d = doneAt(t); return d && d <= day; }).reduce((s2, t) => s2 + pts(t), 0); - return committed - burned; - }); - return { days, committed, ideal, actual }; -} - -function renderBurndownSvg(bd) { - const W = 420, H = 110, PAD = 6; - const n = bd.days.length; - const x = (i) => PAD + (n === 1 ? 0 : (i / (n - 1)) * (W - 2 * PAD)); - const y = (v) => H - PAD - (v / bd.committed) * (H - 2 * PAD); - const NS = 'http://www.w3.org/2000/svg'; - const svg = document.createElementNS(NS, 'svg'); - svg.setAttribute('viewBox', `0 0 ${W} ${H}`); - svg.setAttribute('role', 'img'); - svg.setAttribute('aria-label', `번다운: 커밋 ${bd.committed}pt`); - svg.style.width = '100%'; - svg.style.maxWidth = '460px'; - const grid = document.createElementNS(NS, 'line'); - grid.setAttribute('x1', PAD); grid.setAttribute('x2', W - PAD); - grid.setAttribute('y1', y(0)); grid.setAttribute('y2', y(0)); - grid.setAttribute('stroke', '#e2e8f0'); - svg.appendChild(grid); - const idealLine = document.createElementNS(NS, 'polyline'); - idealLine.setAttribute('points', bd.ideal.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ')); - idealLine.setAttribute('fill', 'none'); - idealLine.setAttribute('stroke', '#94a3b8'); - idealLine.setAttribute('stroke-dasharray', '4 3'); - svg.appendChild(idealLine); - const actualPts = bd.actual.map((v, i) => (v === null ? null : `${x(i).toFixed(1)},${y(v).toFixed(1)}`)).filter(Boolean); - if (actualPts.length) { - const actualLine = document.createElementNS(NS, 'polyline'); - actualLine.setAttribute('points', actualPts.join(' ')); - actualLine.setAttribute('fill', 'none'); - actualLine.setAttribute('stroke', '#2563eb'); - actualLine.setAttribute('stroke-width', '2'); - svg.appendChild(actualLine); - } - return svg; -} - -const METHODOLOGY_LABELS = { waterfall: 'Waterfall (예측형)', agile: 'Agile (적응형)', hybrid: 'Hybrid (혼합형)' }; - -async function openSprintModal() { - const pid = getProjectId(); - if (!pid) return; - let modal = document.getElementById('sprint-modal'); - if (!modal) { - modal = document.createElement('div'); - modal.id = 'sprint-modal'; - modal.className = 'modal'; - modal.setAttribute('role', 'dialog'); - modal.setAttribute('aria-modal', 'true'); - const backdrop = document.createElement('div'); - backdrop.className = 'modal-backdrop'; - backdrop.addEventListener('click', () => modal.classList.add('hidden')); - const panel = document.createElement('div'); - panel.className = 'modal-panel'; - panel.id = 'sprint-panel'; - modal.append(backdrop, panel); - document.body.appendChild(modal); - } - modal.classList.remove('hidden'); - const panel = modal.querySelector('#sprint-panel'); - panel.textContent = ''; - - const head = document.createElement('div'); - head.className = 'modal-header'; - const h2 = document.createElement('h2'); - h2.textContent = '스프린트 (Agile / Hybrid)'; - const close = document.createElement('button'); - close.type = 'button'; - close.className = 'icon-button close-button'; - close.setAttribute('aria-label', '스프린트 닫기'); - close.textContent = '✕'; - close.addEventListener('click', () => modal.classList.add('hidden')); - head.append(h2, close); - panel.appendChild(head); - - const data = await api(`/api/projects/${pid}/sprints`); - - // 방법론 선택 — 프로젝트 메타로 저장 - const mLabel = document.createElement('label'); - mLabel.className = 'meta-field'; - const mSpan = document.createElement('span'); - mSpan.textContent = '프로젝트 방법론'; - const mSel = document.createElement('select'); - mSel.className = 'cloud-select'; - mSel.id = 'methodology-select'; - for (const [v, label] of Object.entries(METHODOLOGY_LABELS)) { - const opt = document.createElement('option'); - opt.value = v; - opt.textContent = label; - if (v === (data.methodology || 'waterfall')) opt.selected = true; - mSel.appendChild(opt); - } - mSel.addEventListener('change', async () => { - try { - const cur = await api(`/api/projects/${pid}`); - await api(`/api/projects/${pid}`, { method: 'PUT', body: { methodology: mSel.value, version: cur.version } }); - toast(`방법론: ${METHODOLOGY_LABELS[mSel.value]}`); - } catch (e) { toast(e.data?.error || e.message); } - }); - mLabel.append(mSpan, mSel); - panel.appendChild(mLabel); - - // 지표 + 목록 - const stats = computeSprintStats(host?.getState?.()?.tasks || [], data.sprints, new Date().toISOString().slice(0, 10)); - const summary = document.createElement('p'); - summary.className = 'cpm-summary'; - summary.textContent = `스프린트 ${stats.rows.length}개 · 벨로시티 ${stats.velocity === null ? 'N/A (종료 스프린트 없음)' : stats.velocity.toFixed(1) + 'pt'} · 백로그 ${stats.backlogCount}건`; - panel.appendChild(summary); - - const list = document.createElement('ul'); - list.className = 'team-list'; - for (const r of stats.rows) { - const li = document.createElement('li'); - const who = document.createElement('span'); - who.className = 'team-who'; - const period = r.startDate || r.endDate ? ` (${r.startDate}~${r.endDate})` : ''; - who.textContent = `${r.name}${period} · ${r.taskCount}작업 · ${r.completed}/${r.committed}pt${r.closed ? ' · 종료' : ''}`; - const bdBtn = document.createElement('button'); - bdBtn.type = 'button'; - bdBtn.className = 'secondary-button'; - bdBtn.textContent = '번다운'; - bdBtn.addEventListener('click', () => { - const holder = document.getElementById('burndown-holder'); - holder.textContent = ''; - const bd = computeBurndown(host?.getState?.()?.tasks || [], r, new Date().toISOString().slice(0, 10)); - if (!bd) { holder.textContent = '번다운을 그리려면 스프린트 기간과 스토리포인트가 필요합니다.'; return; } - const cap = document.createElement('p'); - cap.className = 'evm-caption'; - cap.textContent = `${r.name} 번다운 — 커밋 ${bd.committed}pt · 점선=이상적 소진, 실선=실제 잔여`; - holder.append(cap, renderBurndownSvg(bd)); - }); - const del = document.createElement('button'); - del.type = 'button'; - del.className = 'secondary-button team-remove'; - del.textContent = '삭제'; - del.addEventListener('click', () => - api(`/api/projects/${pid}/sprints/${r.id}`, { method: 'DELETE' }) - .then(() => openSprintModal()).catch((e) => toast(e.data?.error || e.message))); - li.append(who, bdBtn, del); - list.appendChild(li); - } - if (!stats.rows.length) { - const li = document.createElement('li'); - li.textContent = '스프린트가 없습니다. 아래에서 추가하세요. (작업 배정: 편집기의 스프린트 필드)'; - list.appendChild(li); - } - panel.appendChild(list); - - const bdHolder = document.createElement('div'); - bdHolder.id = 'burndown-holder'; - panel.appendChild(bdHolder); + try { popup.opener = null; } catch { /* cross-window hardening is best effort before navigation */ } - const form = document.createElement('form'); - form.className = 'cloud-form'; - const nameIn = document.createElement('input'); - nameIn.type = 'text'; - nameIn.placeholder = '스프린트 이름 (예: Sprint 3)'; - nameIn.required = true; - const startIn = document.createElement('input'); - startIn.type = 'date'; - const endIn = document.createElement('input'); - endIn.type = 'date'; - const add = document.createElement('button'); - add.type = 'submit'; - add.className = 'primary-button'; - add.textContent = '추가'; - form.append(nameIn, startIn, endIn, add); - form.addEventListener('submit', async (e) => { - e.preventDefault(); - try { - await api(`/api/projects/${pid}/sprints`, { method: 'POST', body: { name: nameIn.value.trim(), startDate: startIn.value, endDate: endIn.value } }); - toast('스프린트를 추가했습니다.'); - openSprintModal(); - } catch (err) { toast(err.data?.error || err.message); } - }); - panel.appendChild(form); -} - -// ----------------------------------------------------------- attachments -// 산출물 첨부: Clearfolio 통합 문서 뷰어로 업로드/열람. 서버가 프록시하므로 -// 브라우저에는 Clearfolio 자격/시크릿이 노출되지 않는다. -async function openAttachmentsModal() { - const pid = getProjectId(); - if (!pid) return; - let modal = document.getElementById('attachments-modal'); - if (!modal) { - modal = document.createElement('div'); - modal.id = 'attachments-modal'; - modal.className = 'modal'; - modal.setAttribute('role', 'dialog'); - modal.setAttribute('aria-modal', 'true'); - const backdrop = document.createElement('div'); - backdrop.className = 'modal-backdrop'; - backdrop.addEventListener('click', () => modal.classList.add('hidden')); - const panel = document.createElement('div'); - panel.className = 'modal-panel'; - panel.id = 'attachments-panel'; - modal.append(backdrop, panel); - document.body.appendChild(modal); - } - modal.classList.remove('hidden'); - const panel = modal.querySelector('#attachments-panel'); - panel.textContent = ''; - - const head = document.createElement('div'); - head.className = 'modal-header'; - const h2 = document.createElement('h2'); - h2.textContent = '산출물 (문서 뷰어)'; - const close = document.createElement('button'); - close.type = 'button'; - close.className = 'icon-button close-button'; - close.setAttribute('aria-label', '산출물 닫기'); - close.textContent = '✕'; - close.addEventListener('click', () => modal.classList.add('hidden')); - head.append(h2, close); - panel.appendChild(head); - - // 작업 선택 + 파일 업로드 - const sel = document.createElement('select'); - sel.className = 'cloud-select'; - const optAll = document.createElement('option'); - optAll.value = ''; - optAll.textContent = '전체 산출물'; - sel.appendChild(optAll); - for (const t of host?.getState?.()?.tasks || []) { - const opt = document.createElement('option'); - opt.value = t.id; - opt.textContent = t.name || t.task || t.activity || t.phase || t.id; - sel.appendChild(opt); - } - panel.appendChild(sel); - - const form = document.createElement('form'); - form.className = 'cloud-form'; - const fi = document.createElement('input'); - fi.type = 'file'; - fi.id = 'attachment-file-input'; - fi.accept = '.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.png,.jpg,.jpeg,.txt,.md'; - const up = document.createElement('button'); - up.type = 'submit'; - up.className = 'primary-button'; - up.textContent = '업로드'; - form.append(fi, up); - panel.appendChild(form); - - const list = document.createElement('ul'); - list.className = 'team-list'; - panel.appendChild(list); - - const taskName = (id) => { - const t = (host?.getState?.()?.tasks || []).find((x) => x.id === id); - return t ? (t.name || t.task || id) : id; - }; - - async function refresh() { - list.textContent = ''; - const q = sel.value ? `?taskId=${encodeURIComponent(sel.value)}` : ''; - const data = await api(`/api/projects/${pid}/attachments${q}`); - if (!data.attachments.length) { - const li = document.createElement('li'); - li.textContent = '첨부된 산출물이 없습니다.'; - list.appendChild(li); - return; - } - for (const a of data.attachments) { - const li = document.createElement('li'); - const who = document.createElement('span'); - who.className = 'team-who'; - const where = a.taskId ? ` [${taskName(a.taskId)}]` : ''; - const st = a.status === 'SUCCEEDED' ? '' : ` · ${a.status}`; - who.textContent = `${a.name}${where}${st}`; - li.appendChild(who); - if (a.status === 'SUCCEEDED') { - const view = document.createElement('button'); - view.type = 'button'; - view.className = 'secondary-button'; - view.textContent = '보기'; - view.addEventListener('click', () => { - window.open(`/api/projects/${pid}/attachments/${a.id}/view?token=${encodeURIComponent(getToken())}`, '_blank', 'noopener'); - }); - li.appendChild(view); - } - const del = document.createElement('button'); - del.type = 'button'; - del.className = 'secondary-button team-remove'; - del.textContent = '삭제'; - del.addEventListener('click', () => - api(`/api/projects/${pid}/attachments/${a.id}`, { method: 'DELETE' }) - .then(refresh).catch((e) => toast(e.data?.error || e.message))); - li.appendChild(del); - list.appendChild(li); - } - } - sel.addEventListener('change', refresh); - form.addEventListener('submit', async (e) => { - e.preventDefault(); - const f = fi.files?.[0]; - if (!f) return; - const fd = new FormData(); - fd.append('file', f); - fd.append('taskId', sel.value); - try { - const res = await fetch(`/api/projects/${pid}/attachments`, { - method: 'POST', - headers: { authorization: `Bearer ${getToken()}` }, - body: fd, + exchangeAttachmentViewGrant({ ...parsed, origin, fetchImpl }) + .then(({ url }) => { + if (!popup.closed) popup.location.replace(url); + }) + .catch(() => { + try { popup.close(); } catch { /* already inaccessible/closed */ } + alertImpl('문서 열람 권한을 발급하지 못했습니다. 다시 시도해 주세요.'); }); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data.error || res.statusText); - fi.value = ''; - toast(`'${f.name}' 산출물을 업로드했습니다.`); - refresh(); - } catch (err) { toast(err.message); } - }); - await refresh(); -} - -// ------------------------------------------------------------- comments -async function openCommentsModal() { - const pid = getProjectId(); - if (!pid) return; - let modal = document.getElementById('comments-modal'); - if (!modal) { - modal = document.createElement('div'); - modal.id = 'comments-modal'; - modal.className = 'modal'; - modal.setAttribute('role', 'dialog'); - modal.setAttribute('aria-modal', 'true'); - const backdrop = document.createElement('div'); - backdrop.className = 'modal-backdrop'; - backdrop.addEventListener('click', () => modal.classList.add('hidden')); - const panel = document.createElement('div'); - panel.className = 'modal-panel'; - panel.id = 'comments-panel'; - modal.append(backdrop, panel); - document.body.appendChild(modal); - } - modal.classList.remove('hidden'); - const panel = modal.querySelector('#comments-panel'); - panel.textContent = ''; - - const head = document.createElement('div'); - head.className = 'modal-header'; - const h2 = document.createElement('h2'); - h2.textContent = '코멘트'; - const close = document.createElement('button'); - close.type = 'button'; - close.className = 'icon-button close-button'; - close.setAttribute('aria-label', '코멘트 닫기'); - close.textContent = '✕'; - close.addEventListener('click', () => modal.classList.add('hidden')); - head.append(h2, close); - panel.appendChild(head); - - // task filter (전체 or a specific task) - const sel = document.createElement('select'); - sel.className = 'cloud-select'; - const all = document.createElement('option'); - all.value = ''; - all.textContent = '전체 코멘트'; - sel.appendChild(all); - for (const t of host?.getState?.()?.tasks || []) { - const opt = document.createElement('option'); - opt.value = t.id; - opt.textContent = t.name || t.task || t.id; - sel.appendChild(opt); - } - panel.appendChild(sel); - - const list = document.createElement('ul'); - list.className = 'team-list'; - panel.appendChild(list); - - const form = document.createElement('form'); - form.className = 'cloud-form'; - const input = document.createElement('input'); - input.type = 'text'; - input.placeholder = '코멘트 입력 (선택한 작업에 달림)'; - input.maxLength = 2000; - const send = document.createElement('button'); - send.type = 'submit'; - send.className = 'primary-button'; - send.textContent = '등록'; - form.append(input, send); - panel.appendChild(form); - - const taskName = (id) => { - const t = (host?.getState?.()?.tasks || []).find((x) => x.id === id); - return t ? (t.name || t.task || id) : id; + return popup; }; - - async function refresh() { - list.textContent = ''; - const q = sel.value ? `?taskId=${encodeURIComponent(sel.value)}` : ''; - const data = await api(`/api/projects/${pid}/comments${q}`); - if (!data.comments.length) { - const li = document.createElement('li'); - li.textContent = '코멘트가 없습니다.'; - list.appendChild(li); - return; - } - for (const cm of data.comments) { - const li = document.createElement('li'); - const who = document.createElement('span'); - who.className = 'team-who'; - const where = cm.taskId ? ` [${taskName(cm.taskId)}]` : ''; - who.textContent = `${cm.email || '알 수 없음'}${where}: ${cm.body}`; - const del = document.createElement('button'); - del.type = 'button'; - del.className = 'secondary-button team-remove'; - del.textContent = '삭제'; - del.addEventListener('click', () => - api(`/api/projects/${pid}/comments/${cm.id}`, { method: 'DELETE' }) - .then(refresh).catch((e) => toast(e.data?.error || e.message))); - li.append(who, del); - list.appendChild(li); - } - } - sel.addEventListener('change', refresh); - form.addEventListener('submit', async (e) => { - e.preventDefault(); - if (!input.value.trim()) return; - try { - await api(`/api/projects/${pid}/comments`, { method: 'POST', body: { taskId: sel.value, body: input.value.trim() } }); - input.value = ''; - refresh(); - } catch (err) { toast(err.data?.error || err.message); } - }); - await refresh(); -} - -// ------------------------------------------------------------- search -function openSearchModal() { - let modal = document.getElementById('search-modal'); - if (!modal) { - modal = document.createElement('div'); - modal.id = 'search-modal'; - modal.className = 'modal'; - modal.setAttribute('role', 'dialog'); - modal.setAttribute('aria-modal', 'true'); - const backdrop = document.createElement('div'); - backdrop.className = 'modal-backdrop'; - backdrop.addEventListener('click', () => modal.classList.add('hidden')); - const panel = document.createElement('div'); - panel.className = 'modal-panel'; - panel.id = 'search-panel'; - modal.append(backdrop, panel); - document.body.appendChild(modal); - } - modal.classList.remove('hidden'); - const panel = modal.querySelector('#search-panel'); - panel.textContent = ''; - - const head = document.createElement('div'); - head.className = 'modal-header'; - const h2 = document.createElement('h2'); - h2.textContent = '프로젝트 검색'; - const close = document.createElement('button'); - close.type = 'button'; - close.className = 'icon-button close-button'; - close.setAttribute('aria-label', '검색 닫기'); - close.textContent = '✕'; - close.addEventListener('click', () => modal.classList.add('hidden')); - head.append(h2, close); - panel.appendChild(head); - - const form = document.createElement('form'); - form.className = 'cloud-form'; - const input = document.createElement('input'); - input.type = 'search'; - input.placeholder = '프로젝트/작업 이름 (2자 이상)'; - input.minLength = 2; - const go = document.createElement('button'); - go.type = 'submit'; - go.className = 'primary-button'; - go.textContent = '검색'; - form.append(input, go); - panel.appendChild(form); - - const out = document.createElement('div'); - panel.appendChild(out); - - form.addEventListener('submit', async (e) => { - e.preventDefault(); - out.textContent = ''; - try { - const data = await api(`/api/search?q=${encodeURIComponent(input.value.trim())}`); - if (!data.results.length) { out.textContent = '검색 결과가 없습니다.'; return; } - const list = document.createElement('ul'); - list.className = 'team-list'; - for (const hit of data.results) { - const li = document.createElement('li'); - const who = document.createElement('span'); - who.className = 'team-who'; - const taskNames = hit.tasks.map((t) => t.name).join(', '); - who.textContent = hit.nameMatch && !taskNames ? hit.projectName : `${hit.projectName} — ${taskNames}`; - const open = document.createElement('button'); - open.type = 'button'; - open.className = 'secondary-button'; - open.textContent = '열기'; - open.addEventListener('click', async () => { - modal.classList.add('hidden'); - await openProject(hit.projectId).catch((err) => toast(err.message)); - }); - li.append(who, open); - list.appendChild(li); - } - out.appendChild(list); - } catch (err) { out.textContent = err.data?.error || err.message; } - }); - input.focus(); -} - -// ------------------------------------------------------------- baselines -// Compare the live plan against a frozen baseline: which tasks' planned dates -// slipped, and by how many days. -const dayMs = 86400000; -const slipDays = (fromDate, toDate) => { - if (!fromDate || !toDate) return null; - const a = new Date(fromDate), b = new Date(toDate); - if (Number.isNaN(a) || Number.isNaN(b)) return null; - return Math.round((b - a) / dayMs); -}; - -export function compareBaseline(baselineTasks, currentTasks) { - const base = new Map((baselineTasks || []).map((t) => [t.id, t])); - const rows = []; - for (const cur of currentTasks || []) { - const old = base.get(cur.id); - if (!old) { rows.push({ id: cur.id, name: cur.name, kind: 'added', endSlip: null }); continue; } - const endSlip = slipDays(old.plannedEndDate, cur.plannedEndDate); - const startSlip = slipDays(old.plannedStartDate, cur.plannedStartDate); - if ((endSlip || 0) !== 0 || (startSlip || 0) !== 0) { - rows.push({ id: cur.id, name: cur.name, kind: 'moved', baseEnd: old.plannedEndDate || '', curEnd: cur.plannedEndDate || '', endSlip: endSlip ?? 0 }); - } - } - const cur = new Set((currentTasks || []).map((t) => t.id)); - for (const old of baselineTasks || []) { - if (!cur.has(old.id)) rows.push({ id: old.id, name: old.name, kind: 'removed', endSlip: null }); - } - const slipped = rows.filter((r) => r.kind === 'moved' && r.endSlip > 0); - return { rows, summary: { changed: rows.length, slipped: slipped.length, maxSlip: slipped.reduce((m, r) => Math.max(m, r.endSlip), 0) } }; -} - -async function openBaselineModal() { - const pid = getProjectId(); - if (!pid) return; - let modal = document.getElementById('baseline-modal'); - if (!modal) { - modal = document.createElement('div'); - modal.id = 'baseline-modal'; - modal.className = 'modal'; - modal.setAttribute('role', 'dialog'); - modal.setAttribute('aria-modal', 'true'); - const backdrop = document.createElement('div'); - backdrop.className = 'modal-backdrop'; - backdrop.addEventListener('click', () => modal.classList.add('hidden')); - const panel = document.createElement('div'); - panel.className = 'modal-panel'; - panel.id = 'baseline-panel'; - modal.append(backdrop, panel); - document.body.appendChild(modal); - } - modal.classList.remove('hidden'); - const panel = modal.querySelector('#baseline-panel'); - panel.textContent = ''; - - const head = document.createElement('div'); - head.className = 'modal-header'; - const h2 = document.createElement('h2'); - h2.textContent = '기준선 (Baseline)'; - const close = document.createElement('button'); - close.type = 'button'; - close.className = 'icon-button close-button'; - close.setAttribute('aria-label', '기준선 닫기'); - close.textContent = '✕'; - close.addEventListener('click', () => modal.classList.add('hidden')); - head.append(h2, close); - panel.appendChild(head); - - const save = document.createElement('button'); - save.type = 'button'; - save.className = 'primary-button'; - save.textContent = '현재 계획을 기준선으로 저장'; - save.addEventListener('click', async () => { - const name = prompt('기준선 이름', `기준선 ${new Date().toISOString().slice(0, 10)}`); - if (!name) return; - await api(`/api/projects/${pid}/baselines`, { method: 'POST', body: { name } }); - toast('기준선을 저장했습니다.'); - openBaselineModal(); - }); - panel.appendChild(save); - - const ics = document.createElement('button'); - ics.type = 'button'; - ics.className = 'secondary-button'; - ics.style.marginLeft = '8px'; - ics.textContent = '캘린더 내보내기 (.ics)'; - ics.addEventListener('click', async () => { - try { - const res = await fetch(`/api/projects/${pid}/calendar.ics`, { headers: { authorization: `Bearer ${getToken()}` } }); - if (!res.ok) return toast('캘린더 내보내기에 실패했습니다.'); - const blob = await res.blob(); - const a = document.createElement('a'); - a.href = URL.createObjectURL(blob); - a.download = `scopeweave-${pid}.ics`; - a.click(); - URL.revokeObjectURL(a.href); - toast('캘린더 파일(.ics)을 내려받았습니다.'); - } catch { toast('캘린더 내보내기에 실패했습니다.'); } - }); - panel.appendChild(ics); - - const list = document.createElement('ul'); - list.className = 'team-list'; - panel.appendChild(list); - const result = document.createElement('div'); - result.id = 'baseline-result'; - panel.appendChild(result); - - // 변경 이력 (revision history) — related schedule-control tool, same modal. - const histH = document.createElement('h3'); - histH.className = 'token-heading'; - histH.textContent = '변경 이력'; - const histList = document.createElement('ul'); - histList.className = 'team-list'; - api(`/api/projects/${pid}/revisions`).then((h) => { - for (const rev of h.revisions.slice(0, 10)) { - const li = document.createElement('li'); - const who = document.createElement('span'); - who.className = 'team-who'; - who.textContent = `v${rev.version} · ${String(rev.savedAt).slice(0, 16)} · ${rev.savedBy || ''}`; - const diff = document.createElement('button'); - diff.type = 'button'; - diff.className = 'secondary-button'; - diff.textContent = '비교'; - diff.addEventListener('click', async () => { - try { - const snap = await api(`/api/projects/${pid}/revisions/${rev.version}`); - renderBaselineDiff(result, compareBaseline(snap.tasks, host?.getState?.()?.tasks || [])); - } catch (e) { toast(e.data?.error || e.message); } - }); - li.appendChild(diff); - const restore = document.createElement('button'); - restore.type = 'button'; - restore.className = 'secondary-button'; - restore.textContent = '복원'; - restore.addEventListener('click', async () => { - if (!confirm(`v${rev.version} 시점으로 복원합니다. (새 버전으로 기록됩니다)`)) return; - try { - await api(`/api/projects/${pid}/revisions/${rev.version}/restore`, { method: 'POST' }); - await openProject(pid); - toast(`v${rev.version} 시점으로 복원했습니다.`); - modal.classList.add('hidden'); - } catch (e) { toast(e.data?.error || e.message); } - }); - li.append(who, restore); - histList.appendChild(li); - } - if (!h.revisions.length) { - const li = document.createElement('li'); - li.textContent = '저장 이력이 없습니다.'; - histList.appendChild(li); - } - }).catch(() => {}); - panel.append(histH, histList); - - const data = await api(`/api/projects/${pid}/baselines`); - if (!data.baselines.length) { - const li = document.createElement('li'); - li.textContent = '저장된 기준선이 없습니다.'; - list.appendChild(li); - return; - } - for (const b of data.baselines) { - const li = document.createElement('li'); - const who = document.createElement('span'); - who.className = 'team-who'; - who.textContent = `${b.name} · ${String(b.createdAt).slice(0, 10)}`; - const cmp = document.createElement('button'); - cmp.type = 'button'; - cmp.className = 'secondary-button'; - cmp.textContent = '비교'; - cmp.addEventListener('click', async () => { - const full = await api(`/api/projects/${pid}/baselines/${b.id}`); - renderBaselineDiff(result, compareBaseline(full.tasks, host?.getState?.()?.tasks || [])); - }); - const del = document.createElement('button'); - del.type = 'button'; - del.className = 'secondary-button'; - del.textContent = '삭제'; - del.addEventListener('click', async () => { - await api(`/api/projects/${pid}/baselines/${b.id}`, { method: 'DELETE' }); - openBaselineModal(); - }); - li.append(who, cmp, del); - list.appendChild(li); - } -} - -function renderBaselineDiff(el, { rows, summary }) { - el.textContent = ''; - const sum = document.createElement('p'); - sum.textContent = rows.length - ? `변경 ${summary.changed}건 · 지연 ${summary.slipped}건 · 최대 지연 ${summary.maxSlip}일` - : '기준선과 차이가 없습니다.'; - el.appendChild(sum); - if (!rows.length) return; - const table = document.createElement('table'); - table.className = 'wbs-table'; - const thead = document.createElement('thead'); - const hr = document.createElement('tr'); - for (const t of ['작업', '기준 종료', '현재 종료', '차이']) { - const th = document.createElement('th'); - th.textContent = t; - hr.appendChild(th); - } - thead.appendChild(hr); - const tbody = document.createElement('tbody'); - for (const r of rows.slice(0, 50)) { - const tr = document.createElement('tr'); - const cells = r.kind === 'moved' - ? [r.name, r.baseEnd, r.curEnd, `${r.endSlip > 0 ? '+' : ''}${r.endSlip}일`] - : [r.name, '', '', r.kind === 'added' ? '신규' : '삭제됨']; - for (const c of cells) { - const td = document.createElement('td'); - td.textContent = c ?? ''; - tr.appendChild(td); - } - if (r.kind === 'moved' && r.endSlip > 0) tr.style.color = 'var(--delay, #ea580c)'; - tbody.appendChild(tr); - } - table.append(thead, tbody); - const wrap = document.createElement('div'); - wrap.style.overflowX = 'auto'; - wrap.appendChild(table); - el.appendChild(wrap); -} - -async function openTeamModal() { - const orgId = await resolveOrgId(); - if (!orgId) return toast('워크스페이스를 찾을 수 없습니다.'); - let modal = document.getElementById('team-modal'); - if (!modal) { - modal = document.createElement('div'); - modal.id = 'team-modal'; - modal.className = 'modal hidden'; - modal.setAttribute('role', 'dialog'); - modal.setAttribute('aria-modal', 'true'); - modal.innerHTML = ` - - `; - document.body.appendChild(modal); - modal.addEventListener('click', (e) => { if (e.target.dataset.teamClose) modal.classList.add('hidden'); }); - modal.querySelector('#team-invite').addEventListener('submit', async (e) => { - e.preventDefault(); - const email = modal.querySelector('#team-email').value.trim(); - const role = modal.querySelector('#team-role').value; - try { - const inv = await api(`/api/orgs/${currentOrgId}/invites`, { method: 'POST', body: { email, role } }); - const link = `${location.origin}/?invite=${inv.token}`; - modal.querySelector('#team-msg').textContent = `초대 링크: ${link}`; - modal.querySelector('#team-email').value = ''; - await renderTeam(); - } catch (err) { - modal.querySelector('#team-msg').textContent = err.data?.error || err.message; - } - }); - } - modal.classList.remove('hidden'); - await renderTeam(); + patchedWindows.add(windowLike); + return true; } -async function renderTeam() { - const body = document.getElementById('team-body'); - if (!body) return; - const data = await api(`/api/orgs/${currentOrgId}/members`); - body.textContent = ''; - - // org actions: rename (owner) / leave (everyone else) - try { - const me = await api('/api/me'); - const myRole = me.orgs?.find((o) => String(o.id) === String(currentOrgId))?.role; - const actions = document.createElement('div'); - actions.className = 'team-org-actions'; - if (myRole === 'owner') { - const rename = document.createElement('button'); - rename.type = 'button'; - rename.className = 'secondary-button'; - rename.textContent = '워크스페이스 이름 변경'; - rename.addEventListener('click', async () => { - const name = prompt('새 워크스페이스 이름'); - if (!name) return; - try { - await api(`/api/orgs/${currentOrgId}`, { method: 'PATCH', body: { name } }); - toast('이름을 변경했습니다.'); - renderTeam(); - } catch (e) { toast(e.data?.error || e.message); } - }); - actions.appendChild(rename); - } else if (myRole) { - const leave = document.createElement('button'); - leave.type = 'button'; - leave.className = 'secondary-button team-remove'; - leave.textContent = '워크스페이스 나가기'; - leave.addEventListener('click', async () => { - if (!confirm('이 워크스페이스에서 나갑니다. 프로젝트 접근 권한을 잃습니다.')) return; - try { - await api(`/api/orgs/${currentOrgId}/leave`, { method: 'POST' }); - setProjectId(''); - document.getElementById('team-modal')?.classList.add('hidden'); - await refreshProjects(); - renderAuthUI(); - toast('워크스페이스에서 나왔습니다.'); - } catch (e) { toast(e.data?.error || e.message); } - }); - actions.appendChild(leave); - } - if (actions.childNodes.length) body.appendChild(actions); - } catch { /* org actions are best-effort */ } - - // plan + usage indicator - try { - const b = await api(`/api/orgs/${currentOrgId}/billing`); - const bar = document.createElement('div'); - bar.className = 'billing-bar'; - const cap = (used, limit) => `${used}/${limit == null ? '∞' : limit}`; - const info = document.createElement('span'); - info.textContent = `${b.planName} · 프로젝트 ${cap(b.usage.projects, b.limits.projects)} · 멤버 ${cap(b.usage.members, b.limits.members)}`; - bar.appendChild(info); - if (b.plan === 'free') { - const up = document.createElement('button'); - up.type = 'button'; - up.className = 'primary-button billing-upgrade'; - up.textContent = 'Pro 업그레이드'; - up.addEventListener('click', async () => { - try { - const s = await api(`/api/orgs/${currentOrgId}/checkout`, { method: 'POST' }); - if (s.mock) toast('결제 연동(Stripe 키)이 필요합니다 — 데모 환경입니다.'); - else window.location.href = s.url; - } catch (e) { toast(e.data?.error || e.message); } - }); - bar.appendChild(up); - } - body.appendChild(bar); - } catch { /* billing optional */ } - const list = document.createElement('ul'); - list.className = 'team-list'; - for (const m of data.members) { - const li = document.createElement('li'); - const who = document.createElement('span'); - who.className = 'team-who'; - who.textContent = m.email; - li.appendChild(who); - if (m.role === 'owner') { - const tag = document.createElement('span'); - tag.className = 'team-role-tag'; - tag.textContent = ROLE_LABELS.owner; - li.appendChild(tag); - } else { - const sel = document.createElement('select'); - sel.className = 'cloud-select'; - for (const role of ['admin', 'member', 'viewer']) { - const opt = document.createElement('option'); - opt.value = role; opt.textContent = ROLE_LABELS[role]; - if (role === m.role) opt.selected = true; - sel.appendChild(opt); - } - sel.addEventListener('change', () => - api(`/api/orgs/${currentOrgId}/members/${m.id}`, { method: 'PATCH', body: { role: sel.value } }) - .then(() => toast(`${m.email} → ${ROLE_LABELS[sel.value]}`)).catch((e) => toast(e.message))); - li.appendChild(sel); - const del = document.createElement('button'); - del.type = 'button'; - del.className = 'secondary-button team-remove'; - del.textContent = '제거'; - del.addEventListener('click', () => - api(`/api/orgs/${currentOrgId}/members/${m.id}`, { method: 'DELETE' }) - .then(() => { toast(`${m.email} 제거됨`); renderTeam(); }).catch((e) => toast(e.message))); - li.appendChild(del); - const xfer = document.createElement('button'); - xfer.type = 'button'; - xfer.className = 'secondary-button'; - xfer.textContent = '소유권 이전'; - xfer.addEventListener('click', async () => { - if (!confirm(`${m.email}에게 소유권을 이전합니다. 나는 관리자가 됩니다.`)) return; - try { - await api(`/api/orgs/${currentOrgId}/transfer`, { method: 'POST', body: { userId: m.id } }); - toast('소유권을 이전했습니다.'); - renderTeam(); - } catch (e) { toast(e.data?.error || e.message); } // server 403s non-owners - }); - li.appendChild(xfer); - } - list.appendChild(li); - } - body.appendChild(list); - if (data.invites?.length) { - const pending = document.createElement('p'); - pending.className = 'team-pending'; - pending.textContent = '대기 중인 초대:'; - body.appendChild(pending); - const plist = document.createElement('ul'); - plist.className = 'team-list'; - for (const i of data.invites) { - const li = document.createElement('li'); - const who = document.createElement('span'); - who.className = 'team-who'; - who.textContent = `${i.email} · ${i.role}`; - const revoke = document.createElement('button'); - revoke.type = 'button'; - revoke.className = 'secondary-button team-remove'; - revoke.textContent = '초대 취소'; - revoke.addEventListener('click', () => - api(`/api/orgs/${currentOrgId}/invites/${i.id}`, { method: 'DELETE' }) - .then(() => { toast('초대를 취소했습니다.'); renderTeam(); }) - .catch((e) => toast(e.data?.error || e.message))); - li.append(who, revoke); - plist.appendChild(li); - } - body.appendChild(plist); - } - - const exportBtn = document.createElement('button'); - exportBtn.type = 'button'; - exportBtn.className = 'secondary-button'; - exportBtn.textContent = '데이터 내보내기 (JSON)'; - exportBtn.style.marginTop = '8px'; - exportBtn.addEventListener('click', exportOrg); - body.appendChild(exportBtn); - - await renderTokens(body); - await renderWebhooks(body); - await renderAudit(body); - renderAccount(body); -} - -// Account settings — change password / delete account. -function renderAccount(body) { - const section = document.createElement('div'); - section.className = 'token-section'; - const h = document.createElement('h3'); - h.className = 'token-heading'; - h.textContent = '계정'; - section.appendChild(h); - - const form = document.createElement('form'); - form.className = 'cloud-form'; - const oldPw = document.createElement('input'); - oldPw.type = 'password'; oldPw.placeholder = '현재 비밀번호'; oldPw.autocomplete = 'current-password'; - const newPw = document.createElement('input'); - newPw.type = 'password'; newPw.placeholder = '새 비밀번호 (8자 이상)'; newPw.minLength = 8; newPw.autocomplete = 'new-password'; - const save = document.createElement('button'); - save.type = 'submit'; save.className = 'secondary-button'; save.textContent = '비밀번호 변경'; - form.append(oldPw, newPw, save); - form.addEventListener('submit', async (e) => { - e.preventDefault(); - try { - await api('/api/auth/change-password', { method: 'POST', body: { oldPassword: oldPw.value, newPassword: newPw.value } }); - oldPw.value = ''; newPw.value = ''; - toast('비밀번호를 변경했습니다.'); - } catch (err) { toast(err.data?.error || err.message); } - }); - section.appendChild(form); - - const outAll = document.createElement('button'); - outAll.type = 'button'; - outAll.className = 'secondary-button'; - outAll.style.marginTop = '8px'; - outAll.textContent = '다른 모든 기기에서 로그아웃'; - outAll.addEventListener('click', async () => { - if (!confirm('다른 모든 기기의 세션을 무효화합니다. 이 기기는 유지됩니다.')) return; - try { - const res = await api('/api/auth/logout-all', { method: 'POST' }); - setToken(res.token); // fresh token keeps this device signed in - toast('다른 모든 기기에서 로그아웃했습니다.'); - } catch (e) { toast(e.data?.error || e.message); } - }); - section.appendChild(outAll); - - const del = document.createElement('button'); - del.type = 'button'; - del.className = 'secondary-button'; - del.style.color = 'var(--danger)'; - del.style.marginTop = '8px'; - del.textContent = '계정 삭제'; - del.addEventListener('click', async () => { - const pw = prompt('계정과 소유한 워크스페이스가 영구 삭제됩니다. 확인하려면 비밀번호를 입력하세요.'); - if (!pw) return; - try { - await api('/api/account', { method: 'DELETE', body: { password: pw } }); - setToken(''); setProjectId(''); - document.getElementById('team-modal')?.classList.add('hidden'); - renderAuthUI(); - toast('계정을 삭제했습니다.'); - } catch (err) { toast(err.data?.error || err.message); } - }); - section.appendChild(del); - body.appendChild(section); -} - -// Outbound webhooks — owner/admin. Secret shown once at creation. -async function renderWebhooks(body) { - let data; - try { data = await api(`/api/orgs/${currentOrgId}/webhooks`); } catch { return; } - const section = document.createElement('div'); - section.className = 'token-section'; - const h = document.createElement('h3'); - h.className = 'token-heading'; - h.textContent = '웹훅'; - section.appendChild(h); - const list = document.createElement('ul'); - list.className = 'team-list'; - for (const w of data.webhooks) { - const li = document.createElement('li'); - const who = document.createElement('span'); - who.className = 'team-who'; - const status = w.lastOk == null ? '' : (w.lastOk ? ' · 최근 ✓' : ' · 최근 ✗ 실패'); - who.textContent = `${w.url} · ${w.events}${status}`; - li.appendChild(who); - const rot = document.createElement('button'); - rot.type = 'button'; - rot.className = 'secondary-button'; - rot.textContent = '키 교체'; - rot.addEventListener('click', async () => { - if (!confirm('서명 시크릿을 교체합니다. 기존 시크릿은 즉시 무효화됩니다.')) return; - try { - const res = await api(`/api/orgs/${currentOrgId}/webhooks/${w.id}/rotate`, { method: 'POST' }); - prompt('새 서명 시크릿 (지금만 표시됩니다 — 복사하세요)', res.secret); - } catch (e) { toast(e.data?.error || e.message); } - }); - li.appendChild(rot); - const del = document.createElement('button'); - del.type = 'button'; - del.className = 'secondary-button team-remove'; - del.textContent = '삭제'; - del.addEventListener('click', () => - api(`/api/orgs/${currentOrgId}/webhooks/${w.id}`, { method: 'DELETE' }).then(() => { toast('웹훅을 삭제했습니다.'); renderTeam(); }).catch((e) => toast(e.message))); - li.appendChild(del); - list.appendChild(li); - } - section.appendChild(list); - const form = document.createElement('form'); - form.className = 'team-invite'; - const input = document.createElement('input'); - input.type = 'url'; - input.placeholder = 'https://example.com/webhook'; - const btn = document.createElement('button'); - btn.type = 'submit'; - btn.className = 'primary-button'; - btn.textContent = '웹훅 추가'; - form.append(input, btn); - const secret = document.createElement('p'); - secret.className = 'token-secret'; - form.addEventListener('submit', async (e) => { - e.preventDefault(); - try { - const w = await api(`/api/orgs/${currentOrgId}/webhooks`, { method: 'POST', body: { url: input.value.trim(), events: '*' } }); - secret.textContent = `서명 시크릿(한 번만 표시): ${w.secret}`; - const li = document.createElement('li'); - const who = document.createElement('span'); - who.className = 'team-who'; - who.textContent = `${w.url} · ${w.events}`; - li.appendChild(who); - list.appendChild(li); - input.value = ''; - } catch (err) { toast(err.data?.error || err.message); } - }); - section.appendChild(form); - section.appendChild(secret); - body.appendChild(section); -} - -const AUDIT_LABELS = { - 'project.create': '프로젝트 생성', 'project.update': '프로젝트 저장', - 'member.invite': '멤버 초대', 'member.join': '멤버 합류', - 'member.role_change': '역할 변경', 'member.remove': '멤버 제거', - 'billing.upgrade': '플랜 업그레이드', -}; - -// Recent activity (owner/admin only; endpoint 403s otherwise → section hidden). -async function renderAudit(body) { - let data; - try { data = await api(`/api/orgs/${currentOrgId}/audit?limit=12`); } catch { return; } - if (!data.events?.length) return; - const section = document.createElement('div'); - section.className = 'token-section'; - const h = document.createElement('h3'); - h.className = 'token-heading'; - h.textContent = '감사 로그'; - section.appendChild(h); - const csvBtn = document.createElement('button'); - csvBtn.type = 'button'; - csvBtn.className = 'secondary-button'; - csvBtn.textContent = 'CSV 다운로드'; - csvBtn.addEventListener('click', async () => { - try { - const res = await fetch(`/api/orgs/${currentOrgId}/audit?format=csv&limit=500`, { headers: { authorization: `Bearer ${getToken()}` } }); - if (!res.ok) return toast('감사 로그 내보내기에 실패했습니다.'); - const blob = await res.blob(); - const a = document.createElement('a'); - a.href = URL.createObjectURL(blob); - a.download = `scopeweave-audit-${currentOrgId}.csv`; - a.click(); - URL.revokeObjectURL(a.href); - toast('감사 로그 CSV를 내려받았습니다.'); - } catch { toast('감사 로그 내보내기에 실패했습니다.'); } - }); - section.appendChild(csvBtn); - const list = document.createElement('ul'); - list.className = 'audit-list'; - for (const e of data.events) { - const li = document.createElement('li'); - const label = AUDIT_LABELS[e.action] || e.action; - const who = e.actorEmail || '시스템'; - const when = (e.createdAt || '').replace('T', ' ').slice(0, 16); - li.textContent = `${when} · ${who} · ${label}`; - list.appendChild(li); - } - section.appendChild(list); - body.appendChild(section); -} - -// Personal Access Tokens — create/list/revoke, secret shown once. -async function renderTokens(body) { - const section = document.createElement('div'); - section.className = 'token-section'; - const h = document.createElement('h3'); - h.className = 'token-heading'; - h.textContent = 'API 토큰'; - section.appendChild(h); - - let data; - try { data = await api('/api/tokens'); } catch { return; } - const list = document.createElement('ul'); - list.className = 'team-list'; - for (const t of data.tokens) { - const li = document.createElement('li'); - const who = document.createElement('span'); - who.className = 'team-who'; - who.textContent = `${t.name} · ${t.prefix}… ${t.lastUsed ? '· 최근 사용 ' + t.lastUsed.slice(0, 10) : '· 미사용'}`; - li.appendChild(who); - const del = document.createElement('button'); - del.type = 'button'; - del.className = 'secondary-button team-remove'; - del.textContent = '폐기'; - del.addEventListener('click', () => - api(`/api/tokens/${t.id}`, { method: 'DELETE' }).then(() => { toast('토큰을 폐기했습니다.'); renderTeam(); }).catch((e) => toast(e.message))); - li.appendChild(del); - list.appendChild(li); - } - section.appendChild(list); - - const form = document.createElement('form'); - form.className = 'team-invite'; - const input = document.createElement('input'); - input.type = 'text'; - input.placeholder = '토큰 이름 (예: CI, Zapier)'; - const btn = document.createElement('button'); - btn.type = 'submit'; - btn.className = 'primary-button'; - btn.textContent = '토큰 생성'; - form.append(input, btn); - const secret = document.createElement('p'); - secret.className = 'token-secret'; - form.addEventListener('submit', async (e) => { - e.preventDefault(); - try { - const t = await api('/api/tokens', { method: 'POST', body: { name: input.value.trim() || 'token' } }); - secret.textContent = `한 번만 표시됩니다 — 지금 복사하세요: ${t.token}`; - input.value = ''; - // append the new token to the list without wiping the shown secret - const li = document.createElement('li'); - const who = document.createElement('span'); - who.className = 'team-who'; - who.textContent = `${t.name} · ${t.prefix}… · 미사용`; - li.appendChild(who); - list.appendChild(li); - } catch (err) { toast(err.data?.error || err.message); } - }); - section.appendChild(form); - section.appendChild(secret); - body.appendChild(section); -} - -// SSO (OIDC) redirect: the token arrives in the URL fragment (not query → not -// logged). Store it and clean the URL before anything else reads auth state. -if (typeof window !== 'undefined' && location.hash.startsWith('#token=')) { - const t = decodeURIComponent(location.hash.slice('#token='.length)); - if (t) { - setToken(t); - history.replaceState(null, '', location.pathname + location.search); - } -} - -// Auto-accept an invite token from the URL (?invite=...) once logged in. -if (typeof window !== 'undefined') { - const params = new URLSearchParams(location.search); - const inviteToken = routeTokenPathSegment(params.get('invite')); - if (inviteToken && getToken()) { - api(`/api/invites/${inviteToken}/accept`, { method: 'POST' }) - .then((res) => { currentOrgId = res.orgId; refreshProjects().then(renderAuthUI); toast('초대를 수락했습니다.'); }) - .catch(() => {}); - } -} - -// Bridge onto window so app.js (a plain, non-import script) can reach us -// without an ESM import statement — keeps app.js eval-safe for unit tests. -if (typeof window !== 'undefined') { - window.ScopeWeaveCloud = cloud; -} +if (typeof window !== 'undefined') installAttachmentViewGrantWindowOpen(window); diff --git a/package.json b/package.json index 2509b4e6..9766382a 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "coverage": "npm run test:coverage", "server": "node server/server.mjs", - "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/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/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.test.mjs && node tests/unit/access-grant-sqlite.test.mjs && node tests/unit/access-grant-sqlite-edge.test.mjs && node tests/unit/access-grant-audit-outbox.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 --include=server/access_grant_domain.mjs --include=server/access_grant_sqlite.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/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.test.mjs && node tests/unit/access-grant-sqlite.test.mjs && node tests/unit/access-grant-sqlite-edge.test.mjs && node tests/unit/access-grant-audit-outbox.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:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke-orchestrator-provider.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/attachment-view-access-grant.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/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.test.mjs && node tests/unit/access-grant-sqlite.test.mjs && node tests/unit/access-grant-sqlite-edge.test.mjs && node tests/unit/access-grant-audit-outbox.test.mjs && node tests/unit/attachment-view-client.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=cloud-sync-core.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/app_core.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/access_grant_domain.mjs --include=server/access_grant_sqlite.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/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.test.mjs && node tests/unit/access-grant-sqlite.test.mjs && node tests/unit/access-grant-sqlite-edge.test.mjs && node tests/unit/access-grant-audit-outbox.test.mjs && node tests/unit/attachment-view-client.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", "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..fab5e4e9 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1,1410 +1,208 @@ -// ScopeWeave SaaS API. Multi-tenant (org-scoped), optimistic concurrency on -// project docs, SSE realtime fan-out per project. The existing static client -// (index.html/app.js) becomes the frontend that talks to these routes. +// ScopeWeave security gateway. The historical application remains in +// app_core.mjs while security-sensitive attachment viewing is migrated to +// short-lived, one-time access grants without exposing broad session JWTs in +// browser URLs. All other requests delegate to the unchanged core application. 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 */ } +import { randomBytes } from 'node:crypto'; +import { db } from './db.mjs'; +import { hashApiToken, verifyToken } from './auth.mjs'; +import { artifactUrl } from './clearfolio.mjs'; +import { app as coreApp } from './app_core.mjs'; +import { + ACCESS_GRANT_AUDIENCES, + ACCESS_GRANT_PURPOSES, + AccessGrantError, + createAccessGrantService, +} from './access_grant_domain.mjs'; +import { + createSqliteAccessGrantAuthorizationPort, + createSqliteAccessGrantMembershipPort, + createSqliteAccessGrantRepository, +} from './access_grant_sqlite.mjs'; + +const ATTACHMENT_VIEW_TTL_SECONDS = 60; +const PRIVATE_VIEW_HEADERS = Object.freeze({ + 'cache-control': 'private, no-store', + 'referrer-policy': 'no-referrer', + 'x-content-type-options': 'nosniff', +}); +const GRANT_RESPONSE_HEADERS = Object.freeze({ + 'cache-control': 'no-store', + 'referrer-policy': 'no-referrer', + 'x-content-type-options': 'nosniff', +}); +const ROW_ID_PATTERN = /^[1-9][0-9]*$/; + +function rowId(value) { + const normalized = String(value ?? ''); + return ROW_ID_PATTERN.test(normalized) ? normalized : null; } -// --- 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'; +function secureJson(payload, status, headers = GRANT_RESPONSE_HEADERS) { + return new Response(JSON.stringify(payload), { + status, + headers: { + ...headers, + 'content-type': 'application/json; charset=UTF-8', + }, + }); +} -export const app = new Hono(); +function unauthorizedView() { + return secureJson({ error: 'unauthorized' }, 401, PRIVATE_VIEW_HEADERS); +} -async function requireAuth(c, next) { +function lookupHeaderSubject(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 (!header.startsWith('Bearer ')) return null; + const token = header.slice(7); 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(); + const apiToken = db.prepare('SELECT id,user_id FROM api_tokens WHERE token_hash = ?').get(hashApiToken(token)); + if (!apiToken) return null; + db.prepare("UPDATE api_tokens SET last_used = datetime('now') WHERE id = ?").run(apiToken.id); + return String(apiToken.user_id); } 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); + const user = db.prepare('SELECT token_version FROM users WHERE id = ?').get(payload.sub); + if (!user || (payload.tv || 0) !== user.token_version) return null; + return String(payload.sub); } catch { - return c.json({ error: 'unauthorized' }, 401); - } - await next(); -} - -// --- realtime: projectId -> Set -const streams = new Map(); -function broadcast(projectId, data) { - const subs = streams.get(String(projectId)); - if (!subs) return; - const chunk = new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`); - for (const ctrl of subs) { - try { ctrl.enqueue(chunk); } catch { /* dropped subscriber */ } + return null; } } -// 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); +const grantService = createAccessGrantService({ + repository: createSqliteAccessGrantRepository(db), + clock: Object.freeze({ nowMs: () => Date.now() }), + randomSource: Object.freeze({ randomBytes: (size) => randomBytes(size) }), + // The SQLite repository transactionally persists the authoritative immutable + // access_grant_audit_outbox entry. The domain's delivery sink is deliberately + // side-effect-free here so an optional secondary sink cannot change security + // state or encourage a duplicate mint/redeem retry. + auditSink: Object.freeze({ record: async () => {} }), + projectAuthorization: createSqliteAccessGrantAuthorizationPort(db), + membershipRevocation: createSqliteAccessGrantMembershipPort(db), +}); + +function mapMintFailure(error) { + if (error instanceof AccessGrantError) { + if (error.status === 404) return secureJson({ error: 'not found' }, 404); + if (error.status === 400) return secureJson({ error: 'invalid access grant request' }, 400); + } + return secureJson({ error: 'access grant service unavailable' }, 503); } -// --- 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)); -} +async function mintAttachmentViewGrant(c) { + const subjectId = lookupHeaderSubject(c); + if (!subjectId) return secureJson({ error: 'unauthorized' }, 401); + const projectId = rowId(c.req.param('id')); + const body = await c.req.json().catch(() => null); + const attachmentId = rowId(body?.attachmentId); + if (!projectId || body?.purpose !== ACCESS_GRANT_PURPOSES.ATTACHMENT_VIEW || !attachmentId) { + return secureJson({ error: 'invalid access grant request' }, 400); + } -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 grant = await grantService.mint({ + subjectId, + projectId, + purpose: ACCESS_GRANT_PURPOSES.ATTACHMENT_VIEW, + audience: ACCESS_GRANT_AUDIENCES.ATTACHMENT_VIEW, + attachmentId, + ttlSeconds: ATTACHMENT_VIEW_TTL_SECONDS, + }); + return secureJson({ + grantId: grant.grantId, + purpose: grant.purpose, + expiresAtMs: grant.expiresAtMs, + url: `/api/projects/${projectId}/attachments/${attachmentId}/view?grant=${encodeURIComponent(grant.secret)}`, + }, 201); + } catch (error) { + return mapMintFailure(error); } } -const quietLogs = String(process.env.SCOPEWEAVE_DB || '').includes(':memory:'); // silence during tests -app.use('*', async (c, next) => { - const t = Date.now(); - await next(); - try { - metrics.requests++; - const s = c.res.status; - if (s >= 500) metrics.s5xx++; else if (s >= 400) metrics.s4xx++; else if (s >= 200) metrics.s2xx++; - if (!quietLogs) { - // structured; never logs bodies, tokens, or secrets - console.log(JSON.stringify({ ts: new Date().toISOString(), method: c.req.method, path: c.req.path, status: s, ms: Date.now() - t })); - } - } catch { /* metrics/logging must never break a request */ } -}); -// Rate limiting (opt-in via SCOPEWEAVE_RATE_LIMIT_MAX, per client IP, fixed -// window). Protects against brute-force/abuse. Off by default so it never -// surprises tests/dev. Ceiling: per-instance in-memory → use Redis for multi-node. -const RL_MAX = Number(process.env.SCOPEWEAVE_RATE_LIMIT_MAX) || 0; -const RL_WINDOW_MS = Number(process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS) || 60000; -const rlBuckets = new Map(); -if (RL_MAX > 0) { - app.use('*', async (c, next) => { - const key = (c.req.header('x-forwarded-for') || '').split(',')[0].trim() || 'local'; - const now = Date.now(); - let b = rlBuckets.get(key); - if (!b || b.resetAt <= now) { b = { count: 0, resetAt: now + RL_WINDOW_MS }; rlBuckets.set(key, b); } - b.count++; - if (b.count > RL_MAX) { - const retry = Math.ceil((b.resetAt - now) / 1000); - return c.json({ error: 'rate limit exceeded' }, 429, { 'Retry-After': String(retry) }); - } - await next(); - }); +function attachmentForSubject(subjectId, projectId, attachmentId) { + return db.prepare(` + SELECT p.org_id AS org_id, a.job_id AS job_id, a.status AS status + FROM projects p + JOIN memberships m ON m.org_id = p.org_id + JOIN attachments a ON a.project_id = p.id + WHERE p.id = ? AND m.user_id = ? AND a.id = ? + `).get(projectId, subjectId, attachmentId); } -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 }); -}); +async function viewAttachment(c) { + const projectId = rowId(c.req.param('id')); + const attachmentId = rowId(c.req.param('aid')); + if (!projectId || !attachmentId) return unauthorizedView(); -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) }); -}); + const search = new URL(c.req.url).searchParams; + if (search.has('token')) return unauthorizedView(); -// 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 */ } + const grantValues = search.getAll('grant'); + let subjectId; + if (grantValues.length > 0) { + if (grantValues.length !== 1) return unauthorizedView(); + try { + const redeemed = await grantService.redeem({ + secret: grantValues[0], + purpose: ACCESS_GRANT_PURPOSES.ATTACHMENT_VIEW, + audience: ACCESS_GRANT_AUDIENCES.ATTACHMENT_VIEW, + projectId, + attachmentId, }); - }, - }); - 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(',')); + subjectId = redeemed.subjectId; + } catch { + return unauthorizedView(); } - 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); + subjectId = lookupHeaderSubject(c); + if (!subjectId) return unauthorizedView(); } - 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 attachment = attachmentForSubject(subjectId, projectId, attachmentId); + if (!attachment) return secureJson({ error: 'not found' }, 404, PRIVATE_VIEW_HEADERS); + if (attachment.status !== 'SUCCEEDED') { + return secureJson({ error: 'attachment not ready' }, 409, PRIVATE_VIEW_HEADERS); } - 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), + const target = await artifactUrl(attachment.org_id, subjectId, attachment.job_id); + return new Response(null, { + status: 302, + headers: { + ...PRIVATE_VIEW_HEADERS, + location: target, + }, }); - 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); + } catch { + return secureJson({ error: 'document viewer unavailable' }, 502, PRIVATE_VIEW_HEADERS); } -}); - -// 산출물 첨부(Clearfolio 통합 문서 뷰어 프록시): 업로드→변환 잡, 목록(+상태 -// 갱신), 서명 아티팩트 열람(302), 삭제. 테넌트 = 조직, 브라우저에는 Clearfolio -// 자격이 절대 노출되지 않음. -const ATTACH_MAX_BYTES = 10 * 1024 * 1024; +} -const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY, -); -const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, -); -const ATTACH_STATUS_BUDGET_MS = normalizeAttachmentStatusBudgetMs( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS, -); -const ATTACHMENT_LIST_COLUMNS = `a.id, a.task_id AS taskId, a.name, a.mime, a.size, - a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy`; -const ATTACHMENT_LIST_FROM = - 'FROM attachments a LEFT JOIN users u ON u.id = a.created_by'; -const listAttachmentsStatement = db.prepare( - `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} - WHERE a.project_id = ? ORDER BY a.id DESC`, -); -const listTaskAttachmentsStatement = db.prepare( - `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} - WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`, -); -const updateAttachmentStatusStatement = db.prepare( - 'UPDATE attachments SET status = ? WHERE id = ?', -); -app.post('/api/projects/:id/attachments', requireAuth, async (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const form = await c.req.formData().catch(() => null); - const file = form?.get('file'); - if (!file || typeof file === 'string') return c.json({ error: 'multipart file required' }, 400); - const taskId = String(form.get('taskId') || ''); - if (/\.(hwp|hwpx)$/i.test(file.name || '')) return c.json({ error: 'HWP/HWPX는 지원되지 않습니다 (Clearfolio 정책)' }, 400); - if (file.size > ATTACH_MAX_BYTES) return c.json({ error: 'file too large (max 10MB)' }, 400); - const bytes = Buffer.from(await file.arrayBuffer()); - let job; +async function serveCloudSyncCore() { 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)}"`, + const source = await readFile(new URL('../cloud-sync-core.js', import.meta.url)); + return new Response(source, { + status: 200, + headers: { + 'cache-control': 'no-cache', + 'content-type': 'application/javascript; charset=UTF-8', + 'x-content-type-options': 'nosniff', + }, }); - }); -} - -// 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); + } catch { + return secureJson({ error: 'not found' }, 404); } - 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 })); +/** Public ScopeWeave HTTP application with attachment-view grant enforcement. */ +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.post('/api/projects/:id/access-grants', mintAttachmentViewGrant); +app.get('/api/projects/:id/attachments/:aid/view', viewAttachment); +app.get('/cloud-sync-core.js', serveCloudSyncCore); +app.all('*', (c) => coreApp.fetch(c.req.raw)); diff --git a/server/app_core.mjs b/server/app_core.mjs new file mode 100644 index 00000000..c432a84f --- /dev/null +++ b/server/app_core.mjs @@ -0,0 +1,1410 @@ +// ScopeWeave SaaS API. Multi-tenant (org-scoped), optimistic concurrency on +// project docs, SSE realtime fan-out per project. The existing static client +// (index.html/app.js) becomes the frontend that talks to these routes. +import { Hono } from 'hono'; +import { readFile } from 'node:fs/promises'; +import { randomBytes, createHmac, createHash } from 'node:crypto'; +import { db, rowid } from './db.mjs'; +import { hashPassword, verifyPassword, signToken, verifyToken, generateApiToken, hashApiToken } from './auth.mjs'; +import { PLANS, planOf, orgUsage, wouldExceed, createCheckout } from './billing.mjs'; +import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs'; +import { normalizeAttachmentStatusBudgetMs, normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs'; +import { chat as orchestratorChat } from './orchestrator.mjs'; +import { computeEvm } from '../analytics.js'; // pure math, shared with the client + +const getOrg = (id) => db.prepare('SELECT * FROM orgs WHERE id = ?').get(id); + +// Append-only audit trail. Never throws into the request path. +function logAudit(orgId, userId, action, targetType, targetId, meta) { + try { + db.prepare('INSERT INTO audit_log(org_id,user_id,action,target_type,target_id,meta) VALUES(?,?,?,?,?,?)') + .run(orgId, userId ?? null, action, targetType ?? null, targetId != null ? String(targetId) : null, meta ? JSON.stringify(meta) : null); + } catch { /* audit must not break the operation */ } +} + +// --- RBAC. Roles (highest→lowest): owner > admin > member > viewer. +const orgRole = (userId, orgId) => + db.prepare('SELECT role FROM memberships WHERE user_id = ? AND org_id = ?').get(userId, orgId)?.role || null; +const canManage = (role) => role === 'owner' || role === 'admin'; +const canWrite = (role) => role === 'owner' || role === 'admin' || role === 'member'; + +export const app = new Hono(); + +async function requireAuth(c, next) { + const header = c.req.header('authorization') || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : ''; + // Personal Access Token path (swk_...): look up by hash, act as its user. + if (token.startsWith('swk_')) { + const row = db.prepare('SELECT * FROM api_tokens WHERE token_hash = ?').get(hashApiToken(token)); + if (!row) return c.json({ error: 'unauthorized' }, 401); + db.prepare("UPDATE api_tokens SET last_used = datetime('now') WHERE id = ?").run(row.id); + c.set('user', { sub: row.user_id, viaPat: true }); + return next(); + } + try { + const payload = verifyToken(token); + // Session revocation: a bumped token_version invalidates all older JWTs. + const u = db.prepare('SELECT token_version FROM users WHERE id = ?').get(payload.sub); + if (!u || (payload.tv || 0) !== u.token_version) return c.json({ error: 'unauthorized' }, 401); + c.set('user', payload); + } catch { + return c.json({ error: 'unauthorized' }, 401); + } + await next(); +} + +// --- realtime: projectId -> Set +const streams = new Map(); +function broadcast(projectId, data) { + const subs = streams.get(String(projectId)); + if (!subs) return; + const chunk = new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`); + for (const ctrl of subs) { + try { ctrl.enqueue(chunk); } catch { /* dropped subscriber */ } + } +} + +// Membership-scoped project fetch — the tenant isolation boundary. +function projectAccess(userId, projectId) { + return db.prepare( + `SELECT p.*, m.role AS memberRole FROM projects p + JOIN memberships m ON m.org_id = p.org_id + WHERE p.id = ? AND m.user_id = ?` + ).get(projectId, userId); +} + +// --- observability: in-process counters + structured request log. +const metrics = { + startedAt: new Date().toISOString(), + requests: 0, + s2xx: 0, + s4xx: 0, + s5xx: 0, + signups: 0, + projectsCreated: 0, + webhookDeliveries: 0, + attachmentStatusRefreshAttempted: 0, + attachmentStatusRefreshChanged: 0, + attachmentStatusRefreshFailed: 0, + attachmentStatusRefreshDeferred: 0, +}; + +// Outbound webhooks: POST signed JSON to each active hook subscribed to `event`. +// Fire-and-forget with a timeout, one retry on failure, and a recorded outcome +// per attempt — never blocks or fails the triggering request. +function recordDelivery(webhookId, event, status, ok, attempt) { + try { + db.prepare('INSERT INTO webhook_deliveries(webhook_id,event,status_code,ok,attempt) VALUES(?,?,?,?,?)') + .run(webhookId, event, status ?? null, ok ? 1 : 0, attempt); + } catch { /* recording must not break delivery */ } +} + +function sendWebhook(webhookId, url, sig, event, body, attempt) { + metrics.webhookDeliveries++; + const ctrl = new AbortController(); + const to = setTimeout(() => ctrl.abort(), 3000); + fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-scopeweave-event': event, 'x-scopeweave-signature': `sha256=${sig}` }, + body, + signal: ctrl.signal, + }).then((res) => { + recordDelivery(webhookId, event, res.status, res.ok, attempt); + if (!res.ok && attempt < 2) setTimeout(() => sendWebhook(webhookId, url, sig, event, body, attempt + 1), 500); + }).catch(() => { + recordDelivery(webhookId, event, null, false, attempt); + if (attempt < 2) setTimeout(() => sendWebhook(webhookId, url, sig, event, body, attempt + 1), 500); + }).finally(() => clearTimeout(to)); +} + +function deliver(orgId, event, payload) { + let hooks; + try { + hooks = db.prepare('SELECT id, url, secret, events FROM webhooks WHERE org_id = ? AND active = 1').all(orgId); + } catch { return; } + for (const h of hooks) { + const subs = String(h.events || '').split(',').map((s) => s.trim()); + if (!(subs.includes('*') || subs.includes(event))) continue; + const body = JSON.stringify({ event, orgId: Number(orgId), payload, ts: new Date().toISOString() }); + const sig = createHmac('sha256', h.secret).update(body).digest('hex'); + sendWebhook(h.id, h.url, sig, event, body, 1); + } +} +const quietLogs = String(process.env.SCOPEWEAVE_DB || '').includes(':memory:'); // silence during tests +app.use('*', async (c, next) => { + const t = Date.now(); + await next(); + try { + metrics.requests++; + const s = c.res.status; + if (s >= 500) metrics.s5xx++; else if (s >= 400) metrics.s4xx++; else if (s >= 200) metrics.s2xx++; + if (!quietLogs) { + // structured; never logs bodies, tokens, or secrets + console.log(JSON.stringify({ ts: new Date().toISOString(), method: c.req.method, path: c.req.path, status: s, ms: Date.now() - t })); + } + } catch { /* metrics/logging must never break a request */ } +}); + +// Rate limiting (opt-in via SCOPEWEAVE_RATE_LIMIT_MAX, per client IP, fixed +// window). Protects against brute-force/abuse. Off by default so it never +// surprises tests/dev. Ceiling: per-instance in-memory → use Redis for multi-node. +const RL_MAX = Number(process.env.SCOPEWEAVE_RATE_LIMIT_MAX) || 0; +const RL_WINDOW_MS = Number(process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS) || 60000; +const rlBuckets = new Map(); +if (RL_MAX > 0) { + app.use('*', async (c, next) => { + const key = (c.req.header('x-forwarded-for') || '').split(',')[0].trim() || 'local'; + const now = Date.now(); + let b = rlBuckets.get(key); + if (!b || b.resetAt <= now) { b = { count: 0, resetAt: now + RL_WINDOW_MS }; rlBuckets.set(key, b); } + b.count++; + if (b.count > RL_MAX) { + const retry = Math.ceil((b.resetAt - now) / 1000); + return c.json({ error: 'rate limit exceeded' }, 429, { 'Retry-After': String(retry) }); + } + await next(); + }); +} + +app.post('/api/auth/signup', async (c) => { + const { email, password, name } = await c.req.json().catch(() => ({})); + if (!email || typeof password !== 'string' || password.length < 8) { + return c.json({ error: 'email and password (min 8 chars) required' }, 400); + } + if (db.prepare('SELECT id FROM users WHERE email = ?').get(email)) { + return c.json({ error: 'email already registered' }, 409); + } + // user + personal workspace + owner membership, atomically. + let uid; + const tx = () => { + uid = rowid(db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') + .run(email, hashPassword(password), name || '')); + const oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)') + .run(`${name || email}'s workspace`, uid)); + db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); + }; + db.exec('BEGIN'); + try { tx(); db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw e; } + metrics.signups++; + return c.json({ token: signToken({ sub: uid, email, tv: 0 }) }); +}); + +app.post('/api/auth/login', async (c) => { + const { email, password } = await c.req.json().catch(() => ({})); + const u = db.prepare('SELECT * FROM users WHERE email = ?').get(email || ''); + // Pass password through only when it is a string — verifyPassword rejects + // non-strings (objects/arrays) so they never match an empty-password hash. + if (!u || typeof password !== 'string' || !verifyPassword(password, u.password_hash)) { + return c.json({ error: 'invalid credentials' }, 401); + } + return c.json({ token: signToken({ sub: u.id, email: u.email, tv: u.token_version }) }); +}); + +app.get('/api/me', requireAuth, (c) => { + const uid = c.get('user').sub; + const user = db.prepare('SELECT id,email,name FROM users WHERE id = ?').get(uid); + const orgs = db.prepare( + `SELECT o.id,o.name,o.plan,m.role FROM orgs o + JOIN memberships m ON m.org_id = o.id WHERE m.user_id = ?` + ).all(uid); + return c.json({ user, orgs }); +}); + +// Create an additional workspace (org); the creator becomes its owner. +app.post('/api/orgs', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { name } = await c.req.json().catch(() => ({})); + if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); + let oid; + db.exec('BEGIN'); + try { + oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)').run(String(name).trim().slice(0, 120), uid)); + db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); + db.exec('COMMIT'); + } catch (e) { db.exec('ROLLBACK'); throw e; } + logAudit(oid, uid, 'org.create', 'org', oid, { name }); + return c.json({ id: oid, name: String(name).trim(), role: 'owner' }); +}); + +app.get('/api/projects', requireAuth, (c) => { + const uid = c.get('user').sub; + const projects = db.prepare( + `SELECT p.id,p.name,p.base_date AS baseDate,p.version,p.org_id AS orgId,p.updated_at AS updatedAt,p.archived + FROM projects p JOIN memberships m ON m.org_id = p.org_id + WHERE m.user_id = ? ORDER BY p.archived ASC, p.updated_at DESC` + ).all(uid); + return c.json({ projects }); +}); + +app.post('/api/projects', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { name, orgId } = await c.req.json().catch(() => ({})); + if (!name) return c.json({ error: 'name required' }, 400); + const org = orgId + ? db.prepare('SELECT o.id FROM orgs o JOIN memberships m ON m.org_id = o.id WHERE o.id = ? AND m.user_id = ?').get(orgId, uid) + : db.prepare('SELECT o.id FROM orgs o JOIN memberships m ON m.org_id = o.id WHERE m.user_id = ? ORDER BY o.id LIMIT 1').get(uid); + if (!org) return c.json({ error: 'no accessible org' }, 400); + if (wouldExceed(db, getOrg(org.id), 'projects')) { + return c.json({ error: 'project limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.projects }, 402); + } + const id = rowid(db.prepare('INSERT INTO projects(org_id,name,created_by) VALUES(?,?,?)').run(org.id, name, uid)); + metrics.projectsCreated++; + logAudit(org.id, uid, 'project.create', 'project', id, { name }); + return c.json({ id, name, version: 1 }); +}); + +app.get('/api/projects/:id', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + return c.json({ id: p.id, name: p.name, orgId: p.org_id, baseDate: p.base_date, methodology: p.methodology || 'waterfall', tasks: JSON.parse(p.tasks_json), version: p.version }); +}); + +app.put('/api/projects/:id', requireAuth, async (c) => { + const uid = c.get('user').sub; + const id = c.req.param('id'); + const p = projectAccess(uid, id); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden: viewer role is read-only' }, 403); + const body = await c.req.json().catch(() => ({})); + if (typeof body.version === 'number' && body.version !== p.version) { + return c.json({ error: 'version conflict', current: p.version }, 409); + } + const tasks = Array.isArray(body.tasks) ? body.tasks : JSON.parse(p.tasks_json); + const version = p.version + 1; + const methodology = ['waterfall', 'agile', 'hybrid'].includes(body.methodology) ? body.methodology : (p.methodology || 'waterfall'); + db.prepare( + "UPDATE projects SET name=?, base_date=?, tasks_json=?, version=?, methodology=?, updated_at=datetime('now') WHERE id=?" + ).run(body.name ?? p.name, body.baseDate ?? p.base_date, JSON.stringify(tasks), version, methodology, id); + logAudit(p.org_id, uid, 'project.update', 'project', id, { version, tasks: tasks.length }); + // Revision history: snapshot every save, keep the last 20 per project. + try { + db.prepare('INSERT OR REPLACE INTO project_revisions(project_id,version,name,base_date,tasks_json,saved_by) VALUES(?,?,?,?,?,?)') + .run(id, version, body.name ?? p.name, body.baseDate ?? p.base_date, JSON.stringify(tasks), uid); + db.prepare('DELETE FROM project_revisions WHERE project_id = ? AND version <= ?').run(id, version - 20); + } catch { /* history must not break saves */ } + deliver(p.org_id, 'project.update', { projectId: Number(id), version, tasks: tasks.length, by: uid }); + broadcast(id, { type: 'update', version, by: uid }); + return c.json({ version }); +}); + +// Task comments: discussion bound to a project (optionally a task). All roles +// can read; write roles can post; author or manage can delete. +app.get('/api/projects/:id/comments', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const taskId = c.req.query('taskId'); + const comments = (taskId + ? db.prepare(`SELECT cm.id, cm.task_id AS taskId, cm.body, cm.created_at AS createdAt, cm.user_id AS userId, u.email + FROM comments cm LEFT JOIN users u ON u.id = cm.user_id + WHERE cm.project_id = ? AND cm.task_id = ? ORDER BY cm.id DESC LIMIT 100`).all(p.id, taskId) + : db.prepare(`SELECT cm.id, cm.task_id AS taskId, cm.body, cm.created_at AS createdAt, cm.user_id AS userId, u.email + FROM comments cm LEFT JOIN users u ON u.id = cm.user_id + WHERE cm.project_id = ? ORDER BY cm.id DESC LIMIT 100`).all(p.id)); + return c.json({ comments }); +}); + +app.post('/api/projects/:id/comments', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const { taskId, body } = await c.req.json().catch(() => ({})); + const text = String(body || '').trim(); + if (!text) return c.json({ error: 'body required' }, 400); + if (text.length > 2000) return c.json({ error: 'comment too long (max 2000)' }, 400); + const cid = rowid(db.prepare('INSERT INTO comments(project_id,task_id,user_id,body) VALUES(?,?,?,?)') + .run(p.id, String(taskId || ''), uid, text)); + logAudit(p.org_id, uid, 'comment.create', 'project', p.id, { commentId: cid, taskId: taskId || null }); + broadcast(p.id, { type: 'comment', commentId: cid, by: uid }); + return c.json({ id: cid }); +}); + +app.delete('/api/projects/:id/comments/:cid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const cm = db.prepare('SELECT user_id FROM comments WHERE id = ? AND project_id = ?').get(c.req.param('cid'), p.id); + if (!cm) return c.json({ error: 'not found' }, 404); + if (cm.user_id !== uid && !canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + db.prepare('DELETE FROM comments WHERE id = ?').run(c.req.param('cid')); + return c.json({ ok: true }); +}); + +// Revision history: list, inspect, restore. +app.get('/api/projects/:id/revisions', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const revisions = db.prepare( + `SELECT r.version, r.created_at AS savedAt, u.email AS savedBy FROM project_revisions r + LEFT JOIN users u ON u.id = r.saved_by WHERE r.project_id = ? ORDER BY r.version DESC` + ).all(p.id); + return c.json({ revisions }); +}); + +app.get('/api/projects/:id/revisions/:version', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const r = db.prepare('SELECT version, name, base_date AS baseDate, tasks_json FROM project_revisions WHERE project_id = ? AND version = ?') + .get(p.id, c.req.param('version')); + if (!r) return c.json({ error: 'not found' }, 404); + return c.json({ version: r.version, name: r.name, baseDate: r.baseDate, tasks: JSON.parse(r.tasks_json) }); +}); + +// Restore = write the old snapshot as a NEW version (history stays linear). +app.post('/api/projects/:id/revisions/:version/restore', requireAuth, (c) => { + const uid = c.get('user').sub; + const id = c.req.param('id'); + const p = projectAccess(uid, id); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const r = db.prepare('SELECT name, base_date, tasks_json FROM project_revisions WHERE project_id = ? AND version = ?') + .get(id, c.req.param('version')); + if (!r) return c.json({ error: 'not found' }, 404); + const version = p.version + 1; + db.prepare("UPDATE projects SET name=?, base_date=?, tasks_json=?, version=?, updated_at=datetime('now') WHERE id=?") + .run(r.name, r.base_date, r.tasks_json, version, id); + try { + db.prepare('INSERT OR REPLACE INTO project_revisions(project_id,version,name,base_date,tasks_json,saved_by) VALUES(?,?,?,?,?,?)') + .run(id, version, r.name, r.base_date, r.tasks_json, uid); + } catch { /* history must not break restore */ } + logAudit(p.org_id, uid, 'project.restore', 'project', id, { from: Number(c.req.param('version')), version }); + broadcast(id, { type: 'update', version, by: uid }); + return c.json({ version }); +}); + +// iCalendar feed: planned tasks as all-day VEVENTs — subscribable from +// Google/Outlook. Calendar apps can't send headers, so accept ?token= (same +// pattern + ceiling as /stream). PATs work via the Authorization header. +app.get('/api/projects/:id/calendar.ics', (c) => { + const header = c.req.header('authorization') || ''; + const raw = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); + let uid; + if (raw.startsWith('swk_')) { + const row = db.prepare('SELECT user_id FROM api_tokens WHERE token_hash = ?').get(hashApiToken(raw)); + if (!row) return c.json({ error: 'unauthorized' }, 401); + uid = row.user_id; + } else { + try { uid = verifyToken(raw).sub; } catch { return c.json({ error: 'unauthorized' }, 401); } + } + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + let tasks = []; + try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } + const day = (s) => String(s).replaceAll('-', ''); + const nextDay = (s) => { const d = new Date(s); d.setDate(d.getDate() + 1); return d.toISOString().slice(0, 10).replaceAll('-', ''); }; + const esc = (s) => String(s).replace(/\\/g, '\\\\').replace(/[,;]/g, (m) => `\\${m}`).replace(/\n/g, '\\n'); + const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//ScopeWeave//KO', 'CALSCALE:GREGORIAN', `X-WR-CALNAME:${esc(p.name)}`]; + for (const t of tasks) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(t.plannedStartDate || '') || !/^\d{4}-\d{2}-\d{2}$/.test(t.plannedEndDate || '')) continue; + lines.push( + 'BEGIN:VEVENT', + `UID:scopeweave-${p.id}-${esc(t.id)}`, + `DTSTART;VALUE=DATE:${day(t.plannedStartDate)}`, + `DTEND;VALUE=DATE:${nextDay(t.plannedEndDate)}`, // DTEND is exclusive + `SUMMARY:${esc(t.name || t.task || t.id)}`, + 'END:VEVENT' + ); + } + lines.push('END:VCALENDAR'); + return c.text(lines.join('\r\n') + '\r\n', 200, { + 'content-type': 'text/calendar; charset=utf-8', + 'content-disposition': `attachment; filename="scopeweave-${p.id}.ics"`, + }); +}); + +app.get('/api/projects/:id/stream', (c) => { + // EventSource can't send an Authorization header, so accept a query token + // here only. Ceiling: issue a short-lived stream-scoped token before prod so + // full JWTs don't land in URLs / access logs. + const header = c.req.header('authorization') || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); + let user; + try { user = verifyToken(token); } catch { return c.json({ error: 'unauthorized' }, 401); } + const id = c.req.param('id'); + if (!projectAccess(user.sub, id)) return c.json({ error: 'not found' }, 404); + const key = String(id); + const stream = new ReadableStream({ + start(controller) { + if (!streams.has(key)) streams.set(key, new Set()); + streams.get(key).add(controller); + controller.enqueue(new TextEncoder().encode(': connected\n\n')); + c.req.raw.signal?.addEventListener('abort', () => { + streams.get(key)?.delete(controller); + try { controller.close(); } catch { /* already closed */ } + }); + }, + }); + return new Response(stream, { + headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }, + }); +}); + +// --------------------------------------------------------------- teams / RBAC +// List members of an org (any member may view the roster). +app.get('/api/orgs/:id/members', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); + const members = db.prepare( + `SELECT u.id, u.email, u.name, m.role FROM memberships m + JOIN users u ON u.id = m.user_id WHERE m.org_id = ? ORDER BY m.id` + ).all(orgId); + const invites = db.prepare( + `SELECT id, email, role, token, created_at AS createdAt FROM invites + WHERE org_id = ? AND accepted_at IS NULL ORDER BY id DESC` + ).all(orgId); + return c.json({ members, invites }); +}); + +// Revoke a pending invite (owner/admin). The token stops working immediately. +app.delete('/api/orgs/:id/invites/:inviteId', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('DELETE FROM invites WHERE id = ? AND org_id = ? AND accepted_at IS NULL') + .run(c.req.param('inviteId'), orgId); + if (!info.changes) return c.json({ error: 'not found' }, 404); + logAudit(orgId, uid, 'invite.revoke', 'invite', c.req.param('inviteId'), {}); + return c.json({ ok: true }); +}); + +// Invite by email (owner/admin only). Returns the token (prod: email a link). +app.post('/api/orgs/:id/invites', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + const role = orgRole(uid, orgId); + if (!role) return c.json({ error: 'not found' }, 404); + if (!canManage(role)) return c.json({ error: 'forbidden' }, 403); + const body = await c.req.json().catch(() => ({})); + const email = String(body.email || '').trim().toLowerCase(); + const inviteRole = body.role || 'member'; + if (!email) return c.json({ error: 'email required' }, 400); + if (!['admin', 'member', 'viewer'].includes(inviteRole)) return c.json({ error: 'invalid role' }, 400); + const token = randomBytes(24).toString('base64url'); + db.prepare('INSERT INTO invites(org_id,email,role,token,invited_by) VALUES(?,?,?,?,?)') + .run(orgId, email, inviteRole, token, uid); + logAudit(orgId, uid, 'member.invite', 'invite', email, { role: inviteRole }); + return c.json({ token, email, role: inviteRole }); +}); + +// Accept an invite (any authenticated user holding the token). Idempotent. +app.post('/api/invites/:token/accept', requireAuth, (c) => { + const uid = c.get('user').sub; + const inv = db.prepare('SELECT * FROM invites WHERE token = ?').get(c.req.param('token')); + if (!inv || inv.accepted_at) return c.json({ error: 'invalid or used invite' }, 404); + const existing = orgRole(uid, inv.org_id); + if (!existing) { + if (wouldExceed(db, getOrg(inv.org_id), 'members')) { + return c.json({ error: 'member limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.members }, 402); + } + db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(inv.org_id, uid, inv.role); + logAudit(inv.org_id, uid, 'member.join', 'user', uid, { role: inv.role }); + deliver(inv.org_id, 'member.join', { userId: uid, role: inv.role }); + } + db.prepare("UPDATE invites SET accepted_at = datetime('now') WHERE id = ?").run(inv.id); + return c.json({ orgId: inv.org_id, role: existing || inv.role }); +}); + +// Change a member's role (owner/admin). Cannot touch an owner or set owner. +app.patch('/api/orgs/:id/members/:userId', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + const targetId = c.req.param('userId'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const body = await c.req.json().catch(() => ({})); + const newRole = body.role; + if (!['admin', 'member', 'viewer'].includes(newRole)) return c.json({ error: 'invalid role' }, 400); + const target = db.prepare('SELECT role FROM memberships WHERE org_id = ? AND user_id = ?').get(orgId, targetId); + if (!target) return c.json({ error: 'not found' }, 404); + if (target.role === 'owner') return c.json({ error: 'cannot change owner role' }, 403); + db.prepare('UPDATE memberships SET role = ? WHERE org_id = ? AND user_id = ?').run(newRole, orgId, targetId); + logAudit(orgId, uid, 'member.role_change', 'user', targetId, { from: target.role, to: newRole }); + return c.json({ userId: Number(targetId), role: newRole }); +}); + +// Remove a member (owner/admin). Cannot remove an owner. +app.delete('/api/orgs/:id/members/:userId', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + const targetId = c.req.param('userId'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const target = db.prepare('SELECT role FROM memberships WHERE org_id = ? AND user_id = ?').get(orgId, targetId); + if (!target) return c.json({ error: 'not found' }, 404); + if (target.role === 'owner') return c.json({ error: 'cannot remove owner' }, 403); + db.prepare('DELETE FROM memberships WHERE org_id = ? AND user_id = ?').run(orgId, targetId); + logAudit(orgId, uid, 'member.remove', 'user', targetId, { role: target.role }); + return c.json({ ok: true }); +}); + +// Leave a workspace voluntarily (any non-owner member). Owners must transfer or +// delete the org instead — an org can never be left ownerless. +app.post('/api/orgs/:id/leave', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + const role = orgRole(uid, orgId); + if (!role) return c.json({ error: 'not found' }, 404); + if (role === 'owner') return c.json({ error: 'owner cannot leave; delete the workspace or transfer ownership' }, 403); + db.prepare('DELETE FROM memberships WHERE org_id = ? AND user_id = ?').run(orgId, uid); + logAudit(orgId, uid, 'member.leave', 'user', uid, { role }); + return c.json({ ok: true }); +}); + +// Transfer workspace ownership to an existing member (owner only). The old +// owner becomes an admin; orgs.owner_id follows. Transactional. +app.post('/api/orgs/:id/transfer', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); + const { userId } = await c.req.json().catch(() => ({})); + if (!userId || Number(userId) === Number(uid)) return c.json({ error: 'target member userId required' }, 400); + const target = db.prepare('SELECT role FROM memberships WHERE org_id = ? AND user_id = ?').get(orgId, userId); + if (!target) return c.json({ error: 'target is not a member' }, 404); + db.exec('BEGIN'); + try { + db.prepare("UPDATE memberships SET role = 'owner' WHERE org_id = ? AND user_id = ?").run(orgId, userId); + db.prepare("UPDATE memberships SET role = 'admin' WHERE org_id = ? AND user_id = ?").run(orgId, uid); + db.prepare('UPDATE orgs SET owner_id = ? WHERE id = ?').run(userId, orgId); + db.exec('COMMIT'); + } catch (e) { db.exec('ROLLBACK'); throw e; } + logAudit(orgId, uid, 'org.transfer', 'user', userId, { from: uid }); + return c.json({ ok: true, newOwnerId: Number(userId) }); +}); + +// Rename a workspace (owner only). +app.patch('/api/orgs/:id', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); + const { name } = await c.req.json().catch(() => ({})); + if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); + db.prepare('UPDATE orgs SET name = ? WHERE id = ?').run(String(name).trim().slice(0, 120), orgId); + logAudit(orgId, uid, 'org.rename', 'org', orgId, { name }); + return c.json({ id: Number(orgId), name: String(name).trim() }); +}); + +// ------------------------------------------------------------------- billing +app.get('/api/orgs/:id/billing', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); + const org = getOrg(orgId); + const plan = planOf(org); + return c.json({ plan: org.plan, planName: plan.name, priceKrw: plan.priceKrw, limits: plan.limits, usage: orgUsage(db, orgId) }); +}); + +app.post('/api/orgs/:id/checkout', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'only the owner can upgrade' }, 403); + const origin = new URL(c.req.url).origin; + const session = await createCheckout({ orgId, origin }); + return c.json(session); +}); + +// Stripe webhook (stub). Live mode should verify the signature with +// STRIPE_WEBHOOK_SECRET before trusting the event — named ceiling. +app.post('/api/stripe/webhook', async (c) => { + const event = await c.req.json().catch(() => ({})); + if (event?.type === 'checkout.session.completed') { + const orgId = event.data?.object?.client_reference_id || event.data?.object?.metadata?.orgId; + if (orgId) db.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").run(orgId); + } + return c.json({ received: true }); +}); + +// Dev-only: simulate a successful checkout upgrading the org to Pro. +// Disabled unless SCOPEWEAVE_DEV=1 (never reachable in production). +app.post('/api/orgs/:id/_dev/activate-pro', requireAuth, (c) => { + if (process.env.SCOPEWEAVE_DEV !== '1') return c.json({ error: 'not found' }, 404); + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); + db.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").run(orgId); + logAudit(orgId, uid, 'billing.upgrade', 'org', orgId, { plan: 'pro', via: 'dev' }); + deliver(orgId, 'billing.upgrade', { plan: 'pro' }); + return c.json({ plan: 'pro' }); +}); + +// ------------------------------------------------- personal access tokens (PAT) +app.get('/api/tokens', requireAuth, (c) => { + const uid = c.get('user').sub; + const tokens = db.prepare( + 'SELECT id, name, prefix, last_used AS lastUsed, created_at AS createdAt FROM api_tokens WHERE user_id = ? ORDER BY id DESC' + ).all(uid); + return c.json({ tokens }); // never the secret or hash +}); + +app.post('/api/tokens', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { name } = await c.req.json().catch(() => ({})); + const t = generateApiToken(); + const id = rowid(db.prepare('INSERT INTO api_tokens(user_id,name,token_hash,prefix) VALUES(?,?,?,?)') + .run(uid, String(name || 'token').slice(0, 60), t.hash, t.prefix)); + // Full secret returned ONCE — never retrievable again. + return c.json({ id, name: name || 'token', prefix: t.prefix, token: t.full }); +}); + +app.delete('/api/tokens/:id', requireAuth, (c) => { + const uid = c.get('user').sub; + const info = db.prepare('DELETE FROM api_tokens WHERE id = ? AND user_id = ?').run(c.req.param('id'), uid); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + +// Audit trail — owner/admin only. Enterprise requirement. +app.get('/api/orgs/:id/audit', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const limit = Math.min(Number(c.req.query('limit')) || 100, 500); + const rows = db.prepare( + `SELECT a.id, a.action, a.target_type AS targetType, a.target_id AS targetId, a.meta, + a.created_at AS createdAt, u.email AS actorEmail + FROM audit_log a LEFT JOIN users u ON u.id = a.user_id + WHERE a.org_id = ? ORDER BY a.id DESC LIMIT ?` + ).all(orgId, limit); + const events = rows.map((r) => ({ ...r, meta: r.meta ? JSON.parse(r.meta) : null })); + if (c.req.query('format') === 'csv') { + // Compliance deliverable. Formula-injection-safe: values that (after optional + // leading whitespace) start with = + - @ | are prefixed with ' so + // spreadsheets treat them as text. Leading whitespace alone used to bypass + // /^[=+\-@|]/ — match the client-side CSV_FORMULA_PREFIX_PATTERN. + const csvCell = (v) => { + let s = v == null ? '' : String(v); + if (/^\s*[=+\-@|]/.test(s)) s = `'${s}`; + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + }; + const header = ['id', 'createdAt', 'actorEmail', 'action', 'targetType', 'targetId', 'meta']; + const lines = [header.join(',')]; + for (const e of events) { + lines.push([e.id, e.createdAt, e.actorEmail, e.action, e.targetType, e.targetId, e.meta ? JSON.stringify(e.meta) : ''].map(csvCell).join(',')); + } + return c.text(lines.join('\r\n') + '\r\n', 200, { + 'content-type': 'text/csv; charset=utf-8', + 'content-disposition': `attachment; filename="scopeweave-audit-${orgId}.csv"`, + }); + } + return c.json({ events }); +}); + +// Full workspace export (owner only) — data portability / GDPR. Everything the +// org holds, as one JSON document. +app.get('/api/orgs/:id/export', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'only the owner can export' }, 403); + const org = getOrg(orgId); + const members = db.prepare( + `SELECT u.email, u.name, m.role FROM memberships m JOIN users u ON u.id = m.user_id WHERE m.org_id = ?` + ).all(orgId); + const projects = db.prepare( + 'SELECT id, name, base_date AS baseDate, tasks_json, version, created_at AS createdAt, updated_at AS updatedAt FROM projects WHERE org_id = ?' + ).all(orgId).map((p) => ({ ...p, tasks: JSON.parse(p.tasks_json), tasks_json: undefined })); + const audit = db.prepare( + 'SELECT action, target_type AS targetType, target_id AS targetId, meta, created_at AS createdAt FROM audit_log WHERE org_id = ? ORDER BY id' + ).all(orgId).map((a) => ({ ...a, meta: a.meta ? JSON.parse(a.meta) : null })); + logAudit(orgId, uid, 'org.export', 'org', orgId, { projects: projects.length }); + return c.json({ + exportedAt: new Date().toISOString(), + org: { id: org.id, name: org.name, plan: org.plan }, + members, projects, audit, + }, 200, { 'Content-Disposition': `attachment; filename="scopeweave-org-${orgId}.json"` }); +}); + +// Operational metrics (JSON). Ceiling: expose Prometheus text format + gate +// behind an internal token before prod if scraped externally. +app.get('/api/metrics', (c) => { + const sseActive = [...streams.values()].reduce((n, s) => n + s.size, 0); + const all = { ...metrics, sseActive, uptimeSec: Math.round(process.uptime()) }; + if (c.req.query('format') !== 'prometheus') return c.json(all); + // Prometheus text exposition format (0.0.4) — scrape-ready for Grafana/Alerting. + const gauge = new Set(['sseActive', 'uptimeSec']); + const lines = []; + for (const [k, v] of Object.entries(all)) { + if (typeof v !== 'number') continue; // startedAt etc. + const name = `scopeweave_${k.replace(/([A-Z])/g, '_$1').toLowerCase()}`; + lines.push(`# TYPE ${name} ${gauge.has(k) ? 'gauge' : 'counter'}`, `${name} ${v}`); + } + return c.text(lines.join('\n') + '\n', 200, { 'content-type': 'text/plain; version=0.0.4; charset=utf-8' }); +}); + +// ------------------------------------------------------------------- webhooks +app.get('/api/orgs/:id/webhooks', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const webhooks = db.prepare( + `SELECT w.id, w.url, w.events, w.active, w.created_at AS createdAt, + (SELECT ok FROM webhook_deliveries d WHERE d.webhook_id = w.id ORDER BY d.id DESC LIMIT 1) AS lastOk, + (SELECT created_at FROM webhook_deliveries d WHERE d.webhook_id = w.id ORDER BY d.id DESC LIMIT 1) AS lastAt + FROM webhooks w WHERE w.org_id = ? ORDER BY w.id DESC` + ).all(orgId); // secret never returned + return c.json({ webhooks }); +}); + +app.post('/api/orgs/:id/webhooks', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const { url, events } = await c.req.json().catch(() => ({})); + if (!/^https?:\/\//.test(String(url || ''))) return c.json({ error: 'valid http(s) url required' }, 400); + const secret = `whsec_${randomBytes(24).toString('base64url')}`; + const evs = Array.isArray(events) ? events.join(',') : (events || '*'); + const id = rowid(db.prepare('INSERT INTO webhooks(org_id,url,secret,events) VALUES(?,?,?,?)').run(orgId, url, secret, evs)); + logAudit(orgId, uid, 'webhook.create', 'webhook', id, { url, events: evs }); + return c.json({ id, url, events: evs, secret }); // secret shown once for signature verification +}); + +app.get('/api/orgs/:id/webhooks/:whId/deliveries', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const wh = db.prepare('SELECT id FROM webhooks WHERE id = ? AND org_id = ?').get(c.req.param('whId'), orgId); + if (!wh) return c.json({ error: 'not found' }, 404); + const deliveries = db.prepare( + 'SELECT event, status_code AS statusCode, ok, attempt, created_at AS createdAt FROM webhook_deliveries WHERE webhook_id = ? ORDER BY id DESC LIMIT 50' + ).all(wh.id); + return c.json({ deliveries }); +}); + +// Rotate a webhook's signing secret (leak response / periodic hygiene). The new +// secret is returned ONCE; old signatures stop validating immediately. +app.post('/api/orgs/:id/webhooks/:whId/rotate', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const secret = `whsec_${randomBytes(24).toString('base64url')}`; + const info = db.prepare('UPDATE webhooks SET secret = ? WHERE id = ? AND org_id = ?').run(secret, c.req.param('whId'), orgId); + if (!info.changes) return c.json({ error: 'not found' }, 404); + logAudit(orgId, uid, 'webhook.rotate', 'webhook', c.req.param('whId'), {}); + return c.json({ id: Number(c.req.param('whId')), secret }); // shown once +}); + +app.delete('/api/orgs/:id/webhooks/:whId', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('DELETE FROM webhooks WHERE id = ? AND org_id = ?').run(c.req.param('whId'), orgId); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + +// ------------------------------------------------------------ SSO (OIDC) +// Real IdP via env (OIDC_ISSUER/CLIENT_ID/CLIENT_SECRET/REDIRECT_URI). When +// unset, a built-in mock provider makes the whole flow self-contained + testable. +const OIDC = { + issuer: process.env.OIDC_ISSUER, + clientId: process.env.OIDC_CLIENT_ID, + clientSecret: process.env.OIDC_CLIENT_SECRET, + redirectUri: process.env.OIDC_REDIRECT_URI, +}; +const oidcMock = !OIDC.issuer; +const oidcStates = new Map(); // state -> { verifier, exp } +const oidcCodes = new Map(); // mock only: code -> email + +function upsertSsoUser(email) { + let user = db.prepare('SELECT id, email, token_version FROM users WHERE email = ?').get(email); + if (user) return user; + db.exec('BEGIN'); + try { + const uid = rowid(db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') + .run(email, hashPassword(randomBytes(24).toString('hex')), '')); + const oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)').run(`${email}'s workspace`, uid)); + db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); + db.exec('COMMIT'); + metrics.signups++; + return { id: uid, email }; + } catch (e) { db.exec('ROLLBACK'); throw e; } +} + +app.get('/api/auth/oidc/start', (c) => { + const origin = new URL(c.req.url).origin; + const state = randomBytes(16).toString('hex'); + const verifier = randomBytes(32).toString('base64url'); + const challenge = createHash('sha256').update(verifier).digest('base64url'); + oidcStates.set(state, { verifier, exp: Date.now() + 5 * 60 * 1000 }); + const redirectUri = OIDC.redirectUri || `${origin}/api/auth/oidc/callback`; + if (oidcMock) { + const email = c.req.query('email') || 'sso-user@example.com'; + const u = new URL(`${origin}/api/auth/oidc/mock/authorize`); + u.searchParams.set('state', state); + u.searchParams.set('email', email); + u.searchParams.set('redirect_uri', redirectUri); + return c.redirect(u.toString()); + } + const u = new URL(`${OIDC.issuer.replace(/\/$/, '')}/authorize`); + u.searchParams.set('client_id', OIDC.clientId); + u.searchParams.set('redirect_uri', redirectUri); + u.searchParams.set('response_type', 'code'); + u.searchParams.set('scope', 'openid email profile'); + u.searchParams.set('state', state); + u.searchParams.set('code_challenge', challenge); + u.searchParams.set('code_challenge_method', 'S256'); + return c.redirect(u.toString()); +}); + +// Built-in mock IdP authorize — instantly issues a code (dev/test only). +app.get('/api/auth/oidc/mock/authorize', (c) => { + if (!oidcMock) return c.json({ error: 'mock disabled' }, 404); + const state = c.req.query('state'); + const email = c.req.query('email'); + const redirectUri = c.req.query('redirect_uri'); + const code = randomBytes(16).toString('hex'); + oidcCodes.set(code, email); + const u = new URL(redirectUri); + u.searchParams.set('code', code); + u.searchParams.set('state', state); + return c.redirect(u.toString()); +}); + +app.get('/api/auth/oidc/callback', async (c) => { + const state = c.req.query('state'); + const code = c.req.query('code'); + const s = oidcStates.get(state); + if (!s || s.exp < Date.now()) return c.json({ error: 'invalid or expired state' }, 400); + oidcStates.delete(state); + let email; + if (oidcMock) { + email = oidcCodes.get(code); + oidcCodes.delete(code); + if (!email) return c.json({ error: 'invalid code' }, 400); + } else { + const redirectUri = OIDC.redirectUri || `${new URL(c.req.url).origin}/api/auth/oidc/callback`; + const tokenRes = await fetch(`${OIDC.issuer.replace(/\/$/, '')}/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: OIDC.clientId, client_secret: OIDC.clientSecret, code_verifier: s.verifier }), + }).catch(() => null); + const tok = tokenRes ? await tokenRes.json().catch(() => ({})) : {}; + if (!tok.id_token) return c.json({ error: 'token exchange failed' }, 400); + // Ceiling: verify the id_token signature via the issuer JWKS before prod. + const claims = JSON.parse(Buffer.from(String(tok.id_token).split('.')[1] || '', 'base64url').toString() || '{}'); + email = claims.email; + if (!email) return c.json({ error: 'no email claim' }, 400); + } + const user = upsertSsoUser(email); + const token = signToken({ sub: user.id, email, tv: user.token_version || 0 }); + // Return the token in the URL fragment (not query → not logged); the client + // stores it and cleans the URL. + return c.redirect(`/#token=${token}`); +}); + +// Cross-project search: project names + task names, membership-scoped (tenant +// isolation via the same JOIN as projectAccess). +// ponytail: LIKE over tasks_json text; move to FTS5 if search gets heavy. +app.get('/api/search', requireAuth, (c) => { + const uid = c.get('user').sub; + const q = String(c.req.query('q') || '').trim(); + if (q.length < 2) return c.json({ error: 'query too short (min 2)' }, 400); + const rows = db.prepare( + `SELECT DISTINCT p.id, p.name, p.tasks_json FROM projects p + JOIN memberships m ON m.org_id = p.org_id + WHERE m.user_id = ? AND (p.name LIKE ? OR p.tasks_json LIKE ?) LIMIT 100` + ).all(uid, `%${q}%`, `%${q}%`); + const needle = q.toLowerCase(); + const results = []; + for (const p of rows) { + const hit = { projectId: p.id, projectName: p.name, tasks: [] }; + if (p.name.toLowerCase().includes(needle)) hit.nameMatch = true; + let tasks = []; + try { tasks = JSON.parse(p.tasks_json); } catch { /* skip bad json */ } + for (const t of tasks) { + if (String(t.name || '').toLowerCase().includes(needle)) { + hit.tasks.push({ id: t.id, name: t.name }); + if (hit.tasks.length >= 5) break; + } + } + if (hit.nameMatch || hit.tasks.length) results.push(hit); + if (results.length >= 20) break; + } + return c.json({ query: q, results }); +}); + +// Portfolio dashboard: executive rollup across every project in a workspace — +// weighted planned/actual progress, SPI + status, overdue-task counts. +app.get('/api/orgs/:id/portfolio', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); + const today = new Date().toISOString().slice(0, 10); + const rows = db.prepare( + 'SELECT id, name, base_date AS baseDate, tasks_json, archived, updated_at AS updatedAt FROM projects WHERE org_id = ? ORDER BY archived ASC, updated_at DESC' + ).all(orgId); + const projects = rows.map((p) => { + let tasks = []; + try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } + let wSum = 0, pv = 0, ev = 0, overdue = 0; + for (const t of tasks) { + const w = Number(t.weight) || 1; + wSum += w; + pv += w * ((Number(t.plannedProgress) || 0) / 100); + ev += w * ((Number(t.actualProgress) || 0) / 100); + if (t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100) overdue++; + } + const evm = computeEvm({ pv: wSum ? pv / wSum : 0, ev: wSum ? ev / wSum : 0 }); + return { + id: p.id, + name: p.name, + archived: Boolean(p.archived), + tasks: tasks.length, + planned: Math.round(evm.pv * 1000) / 10, // % + actual: Math.round(evm.ev * 1000) / 10, // % + spi: evm.spi === null ? null : Math.round(evm.spi * 100) / 100, + status: evm.status, + label: evm.label, + overdue, + updatedAt: p.updatedAt, + }; + }); + return c.json({ projects }); +}); + +// AI 브리핑: 프로젝트 스냅샷(요약 지표 + 지연/차주 작업)을 contextual- +// orchestrator(LLM)로 보내 경영진용 리스크 분석을 생성. 원문 데이터는 서버가 +// 요약해 전송하며, LLM 자격은 서버 환경변수에만 존재. +app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + let tasks = []; + try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } + const today = new Date().toISOString().slice(0, 10); + let wSum = 0, pv = 0, ev = 0; + const late = [], upcoming = []; + for (const t of tasks) { + const w = Number(t.weight) || 1; + wSum += w; + pv += w * ((Number(t.plannedProgress) || 0) / 100); + ev += w * ((Number(t.actualProgress) || 0) / 100); + const name = t.name || t.task || t.activity || t.phase || t.id; + if (t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100) { + late.push(`${name}(계획종료 ${t.plannedEndDate}, 실적 ${Number(t.actualProgress) || 0}%${t.owner ? `, ${t.owner}` : ''})`); + } else if (t.plannedStartDate && t.plannedStartDate >= today) { + upcoming.push(`${name}(${t.plannedStartDate} 시작)`); + } + } + const pvPct = wSum ? ((pv / wSum) * 100).toFixed(1) : '0'; + const evPct = wSum ? ((ev / wSum) * 100).toFixed(1) : '0'; + const context = [ + `프로젝트: ${p.name}`, + `작업 수: ${tasks.length} · 계획진척 ${pvPct}% · 실적진척 ${evPct}%`, + `지연 작업(${late.length}): ${late.slice(0, 8).join(' / ') || '없음'}`, + `예정 작업(${upcoming.length}): ${upcoming.slice(0, 5).join(' / ') || '없음'}`, + ].join('\n'); + try { + const analysis = await orchestratorChat([ + { role: 'system', content: '너는 공정관리(schedule control) 전문가다. 주어진 프로젝트 지표를 근거로 한국어 경영진 브리핑을 작성하라: ①일정 상태 한 줄 판정 ②핵심 리스크 2~3개(근거 지표 인용) ③실행 권고 2~3개. 지표에 없는 사실은 만들지 마라.' }, + { role: 'user', content: context }, + ], { + service: 'scopeweave', + account: String(p.org_id), + }); + logAudit(p.org_id, uid, 'ai.brief', 'project', p.id, { tasks: tasks.length }); + return c.json({ analysis }); + } catch (e) { + return c.json({ error: `AI 분석 실패: ${e.message}` }, 502); + } +}); + +// 산출물 첨부(Clearfolio 통합 문서 뷰어 프록시): 업로드→변환 잡, 목록(+상태 +// 갱신), 서명 아티팩트 열람(302), 삭제. 테넌트 = 조직, 브라우저에는 Clearfolio +// 자격이 절대 노출되지 않음. +const ATTACH_MAX_BYTES = 10 * 1024 * 1024; + +const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY, +); +const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, +); +const ATTACH_STATUS_BUDGET_MS = normalizeAttachmentStatusBudgetMs( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS, +); +const ATTACHMENT_LIST_COLUMNS = `a.id, a.task_id AS taskId, a.name, a.mime, a.size, + a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy`; +const ATTACHMENT_LIST_FROM = + 'FROM attachments a LEFT JOIN users u ON u.id = a.created_by'; +const listAttachmentsStatement = db.prepare( + `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} + WHERE a.project_id = ? ORDER BY a.id DESC`, +); +const listTaskAttachmentsStatement = db.prepare( + `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} + WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`, +); +const updateAttachmentStatusStatement = db.prepare( + 'UPDATE attachments SET status = ? WHERE id = ?', +); +app.post('/api/projects/:id/attachments', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const form = await c.req.formData().catch(() => null); + const file = form?.get('file'); + if (!file || typeof file === 'string') return c.json({ error: 'multipart file required' }, 400); + const taskId = String(form.get('taskId') || ''); + if (/\.(hwp|hwpx)$/i.test(file.name || '')) return c.json({ error: 'HWP/HWPX는 지원되지 않습니다 (Clearfolio 정책)' }, 400); + if (file.size > ATTACH_MAX_BYTES) return c.json({ error: 'file too large (max 10MB)' }, 400); + const bytes = Buffer.from(await file.arrayBuffer()); + let job; + try { + job = await submitJob(p.org_id, uid, { name: file.name || 'document', mime: file.type || '', bytes }); + } catch (e) { + return c.json({ error: `문서 변환 제출 실패: ${e.message}` }, 502); + } + const aid = rowid(db.prepare( + 'INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)' + ).run(p.id, taskId, file.name || 'document', file.type || '', file.size, job.jobId, job.status, uid)); + logAudit(p.org_id, uid, 'attachment.upload', 'project', p.id, { attachmentId: aid, name: file.name, taskId: taskId || null }); + return c.json({ id: aid, status: job.status }); +}); + +app.get('/api/projects/:id/attachments', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + + const taskId = c.req.query('taskId'); + const rows = taskId + ? listTaskAttachmentsStatement.all(p.id, taskId) + : listAttachmentsStatement.all(p.id); + await refreshAttachmentStatuses(rows, { + orgId: p.org_id, + userId: uid, + jobStatus, + updateStatus: (status, attachmentId) => + updateAttachmentStatusStatement.run(status, attachmentId), + concurrency: ATTACH_STATUS_CONCURRENCY, + timeoutMs: ATTACH_STATUS_TIMEOUT_MS, + budgetMs: ATTACH_STATUS_BUDGET_MS, + metrics, + }); + const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); + return c.json({ attachments }); +}); + +// 열람: 서명 아티팩트 URL로 302. 새 탭 열기용으로 ?token=도 허용(ics/stream 패턴). +app.get('/api/projects/:id/attachments/:aid/view', (c) => { + const header = c.req.header('authorization') || ''; + const raw = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); + let uid; + if (raw.startsWith('swk_')) { + const row = db.prepare('SELECT user_id FROM api_tokens WHERE token_hash = ?').get(hashApiToken(raw)); + if (!row) return c.json({ error: 'unauthorized' }, 401); + uid = row.user_id; + } else { + try { + const payload = verifyToken(raw); + const u = db.prepare('SELECT token_version FROM users WHERE id = ?').get(payload.sub); + if (!u || (payload.tv || 0) !== u.token_version) return c.json({ error: 'unauthorized' }, 401); + uid = payload.sub; + } catch { return c.json({ error: 'unauthorized' }, 401); } + } + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const a = db.prepare('SELECT job_id, status FROM attachments WHERE id = ? AND project_id = ?').get(c.req.param('aid'), p.id); + if (!a) return c.json({ error: 'not found' }, 404); + if (a.status !== 'SUCCEEDED') return c.json({ error: `문서가 아직 준비되지 않았습니다 (${a.status})` }, 409); + return artifactUrl(p.org_id, uid, a.job_id) + .then((url) => c.redirect(url)) + .catch((e) => c.json({ error: `열람 링크 발급 실패: ${e.message}` }, 502)); +}); + +app.delete('/api/projects/:id/attachments/:aid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const a = db.prepare('SELECT created_by FROM attachments WHERE id = ? AND project_id = ?').get(c.req.param('aid'), p.id); + if (!a) return c.json({ error: 'not found' }, 404); + if (a.created_by !== uid && !canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + db.prepare('DELETE FROM attachments WHERE id = ?').run(c.req.param('aid')); + logAudit(p.org_id, uid, 'attachment.delete', 'project', p.id, { attachmentId: Number(c.req.param('aid')) }); + return c.json({ ok: true }); +}); + +// mock Clearfolio 아티팩트 서빙(dev/test 전용) +if (clearfolioMock) { + app.get('/api/mock-clearfolio/:jobId', (c) => { + const doc = mockArtifact(c.req.param('jobId')); + if (!doc) return c.json({ error: 'not found' }, 404); + return c.body(doc.bytes, 200, { + 'content-type': doc.mime || 'application/octet-stream', + 'content-disposition': `inline; filename="${encodeURIComponent(doc.name)}"`, + }); + }); +} + +// Public read-only share links: a random token grants VIEW access to one +// project (no account needed) — revocable. Never exposes org/member data. +app.post('/api/projects/:id/shares', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const token = randomBytes(18).toString('base64url'); + db.prepare('INSERT INTO share_tokens(project_id, token, created_by) VALUES(?,?,?)').run(p.id, token, uid); + logAudit(p.org_id, uid, 'share.create', 'project', p.id, {}); + return c.json({ token, url: `/?share=${token}` }); +}); + +app.get('/api/projects/:id/shares', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const shares = db.prepare( + 'SELECT id, token, created_at AS createdAt FROM share_tokens WHERE project_id = ? AND revoked = 0 ORDER BY id DESC' + ).all(p.id); + return c.json({ shares }); +}); + +app.delete('/api/projects/:id/shares/:sid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('UPDATE share_tokens SET revoked = 1 WHERE id = ? AND project_id = ? AND revoked = 0') + .run(c.req.param('sid'), p.id); + if (!info.changes) return c.json({ error: 'not found' }, 404); + logAudit(p.org_id, uid, 'share.revoke', 'project', p.id, { shareId: Number(c.req.param('sid')) }); + return c.json({ ok: true }); +}); + +// Anonymous read via share token — project content only. +app.get('/api/shared/:token', (c) => { + const row = db.prepare( + `SELECT p.name, p.base_date AS baseDate, p.tasks_json FROM share_tokens s + JOIN projects p ON p.id = s.project_id WHERE s.token = ? AND s.revoked = 0` + ).get(c.req.param('token')); + if (!row) return c.json({ error: 'not found' }, 404); + return c.json({ name: row.name, baseDate: row.baseDate, tasks: JSON.parse(row.tasks_json), readOnly: true }); +}); + +// Unseen-activity notifications: per project, count others' saves + comments +// newer than my last-seen mark. Opening a project marks it seen. +app.get('/api/notifications', requireAuth, (c) => { + const uid = c.get('user').sub; + const rows = db.prepare( + `SELECT p.id AS projectId, + (SELECT COUNT(*) FROM project_revisions r WHERE r.project_id = p.id + AND r.saved_by IS NOT NULL AND r.saved_by != ? + AND r.created_at > COALESCE(s.seen_at, '')) AS revisions, + (SELECT COUNT(*) FROM comments cm WHERE cm.project_id = p.id + AND cm.user_id IS NOT NULL AND cm.user_id != ? + AND cm.created_at > COALESCE(s.seen_at, '')) AS comments + FROM projects p + JOIN memberships m ON m.org_id = p.org_id AND m.user_id = ? + LEFT JOIN project_seen s ON s.project_id = p.id AND s.user_id = ?` + ).all(uid, uid, uid, uid); + const notifications = rows + .map((r) => ({ projectId: r.projectId, unseen: r.revisions + r.comments })) + .filter((r) => r.unseen > 0); + return c.json({ notifications }); +}); + +app.post('/api/projects/:id/seen', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + db.prepare(`INSERT INTO project_seen(project_id, user_id, seen_at) VALUES(?, ?, datetime('now')) + ON CONFLICT(project_id, user_id) DO UPDATE SET seen_at = datetime('now')`).run(p.id, uid); + return c.json({ ok: true }); +}); + +// Archive / restore a project (write roles): declutter without deleting. +app.post('/api/projects/:id/archive', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const { archived } = await c.req.json().catch(() => ({})); + const flag = archived === false ? 0 : 1; + db.prepare('UPDATE projects SET archived = ? WHERE id = ?').run(flag, p.id); + logAudit(p.org_id, uid, flag ? 'project.archive' : 'project.unarchive', 'project', p.id, {}); + return c.json({ id: p.id, archived: Boolean(flag) }); +}); + +// Duplicate a project (template use: copy tasks + base date into a new project +// in the same org). Plan caps apply like any create. +app.post('/api/projects/:id/duplicate', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + if (wouldExceed(db, getOrg(p.org_id), 'projects')) { + return c.json({ error: 'project limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.projects }, 402); + } + const { name } = await c.req.json().catch(() => ({})); + const newName = String(name || `${p.name} (복사본)`).slice(0, 120); + const nid = rowid(db.prepare('INSERT INTO projects(org_id,name,base_date,tasks_json,created_by) VALUES(?,?,?,?,?)') + .run(p.org_id, newName, p.base_date, p.tasks_json, uid)); + metrics.projectsCreated++; + logAudit(p.org_id, uid, 'project.duplicate', 'project', nid, { from: p.id, name: newName }); + return c.json({ id: nid, name: newName, version: 1 }); +}); + +// -------------------------------------------------------------- sprints +// Agile/Hybrid: 시간상자(스프린트) CRUD. 작업은 task.sprint(이름)로 배정되고 +// task.storyPoints로 추정된다 — 지표(커밋/완료 포인트, 벨로시티)는 클라이언트 +// 순수 함수(computeSprintStats)가 계산한다. +app.post('/api/projects/:id/sprints', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const { name, startDate, endDate, goal } = await c.req.json().catch(() => ({})); + if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); + const day = (v) => (/^\d{4}-\d{2}-\d{2}$/.test(String(v || '')) ? v : ''); + const sid = rowid(db.prepare('INSERT INTO sprints(project_id,name,start_date,end_date,goal) VALUES(?,?,?,?,?)') + .run(p.id, String(name).trim().slice(0, 80), day(startDate), day(endDate), String(goal || '').slice(0, 300))); + logAudit(p.org_id, uid, 'sprint.create', 'project', p.id, { sprintId: sid, name }); + return c.json({ id: sid, name: String(name).trim() }); +}); + +app.get('/api/projects/:id/sprints', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const sprints = db.prepare( + 'SELECT id, name, start_date AS startDate, end_date AS endDate, goal FROM sprints WHERE project_id = ? ORDER BY start_date, id' + ).all(p.id); + return c.json({ sprints, methodology: p.methodology || 'waterfall' }); +}); + +app.delete('/api/projects/:id/sprints/:sid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('DELETE FROM sprints WHERE id = ? AND project_id = ?').run(c.req.param('sid'), p.id); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + +// ------------------------------------------------------------- baselines +// Snapshot a project's current plan as a named baseline (schedule-control: +// compare actuals against the frozen plan later). +app.post('/api/projects/:id/baselines', requireAuth, async (c) => { + const uid = c.get('user').sub; + const id = c.req.param('id'); + const p = projectAccess(uid, id); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const { name } = await c.req.json().catch(() => ({})); + const bid = rowid(db.prepare('INSERT INTO baselines(project_id,name,base_date,tasks_json,created_by) VALUES(?,?,?,?,?)') + .run(id, String(name || 'Baseline').slice(0, 80), p.base_date, p.tasks_json, uid)); + logAudit(p.org_id, uid, 'baseline.create', 'project', id, { baselineId: bid, name }); + return c.json({ id: bid, name: name || 'Baseline' }); +}); + +app.get('/api/projects/:id/baselines', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const baselines = db.prepare( + 'SELECT id, name, base_date AS baseDate, created_at AS createdAt FROM baselines WHERE project_id = ? ORDER BY id DESC' + ).all(p.id); + return c.json({ baselines }); +}); + +app.get('/api/projects/:id/baselines/:bid', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const b = db.prepare('SELECT id, name, base_date AS baseDate, tasks_json, created_at AS createdAt FROM baselines WHERE id = ? AND project_id = ?').get(c.req.param('bid'), p.id); + if (!b) return c.json({ error: 'not found' }, 404); + return c.json({ id: b.id, name: b.name, baseDate: b.baseDate, tasks: JSON.parse(b.tasks_json), createdAt: b.createdAt }); +}); + +app.delete('/api/projects/:id/baselines/:bid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('DELETE FROM baselines WHERE id = ? AND project_id = ?').run(c.req.param('bid'), p.id); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + +// ------------------------------------------------------ account & lifecycle +// Delete a project (write roles). tasks live in the row, so this fully removes it. +app.delete('/api/projects/:id', requireAuth, (c) => { + const uid = c.get('user').sub; + const id = c.req.param('id'); + const p = projectAccess(uid, id); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + db.prepare('DELETE FROM projects WHERE id = ?').run(id); + logAudit(p.org_id, uid, 'project.delete', 'project', id, { name: p.name }); + deliver(p.org_id, 'project.delete', { projectId: Number(id) }); + return c.json({ ok: true }); +}); + +// Log out everywhere: bump token_version → every existing JWT dies. Returns a +// fresh token so THIS device stays signed in. PATs are unaffected. +app.post('/api/auth/logout-all', requireAuth, (c) => { + const uid = c.get('user').sub; + db.prepare('UPDATE users SET token_version = token_version + 1 WHERE id = ?').run(uid); + const u = db.prepare('SELECT email, token_version FROM users WHERE id = ?').get(uid); + return c.json({ ok: true, token: signToken({ sub: uid, email: u.email, tv: u.token_version }) }); +}); + +// Change password (verifies the current one). +app.post('/api/auth/change-password', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { oldPassword, newPassword } = await c.req.json().catch(() => ({})); + if (typeof newPassword !== 'string' || newPassword.length < 8) return c.json({ error: 'new password (min 8) required' }, 400); + const u = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(uid); + if (!u || typeof oldPassword !== 'string' || !verifyPassword(oldPassword, u.password_hash)) { + return c.json({ error: 'current password incorrect' }, 403); + } + db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(newPassword), uid); + return c.json({ ok: true }); +}); + +// Delete account (GDPR). Removes owned workspaces (cascading their data) and the +// user. Requires the current password to confirm. +app.delete('/api/account', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { password } = await c.req.json().catch(() => ({})); + const u = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(uid); + if (!u || typeof password !== 'string' || !verifyPassword(password, u.password_hash)) { + return c.json({ error: 'password required to delete account' }, 403); + } + db.exec('BEGIN'); + try { + db.prepare('DELETE FROM orgs WHERE owner_id = ?').run(uid); // cascades projects/members/webhooks/invites/audit + db.prepare('DELETE FROM users WHERE id = ?').run(uid); // cascades memberships/tokens + db.exec('COMMIT'); + } catch (e) { db.exec('ROLLBACK'); throw e; } + return c.json({ ok: true }); +}); + +app.get('/api/health', (c) => c.json({ ok: true })); + +// Static client — strict allowlist so server/, data.db, package.json etc. are +// never served. Anything not listed → 404. +const STATIC = { + '/': ['index.html', 'text/html; charset=utf-8'], + '/index.html': ['index.html', 'text/html; charset=utf-8'], + '/404.html': ['404.html', 'text/html; charset=utf-8'], + '/landing.html': ['landing.html', 'text/html; charset=utf-8'], + '/landing.en.html': ['landing.en.html', 'text/html; charset=utf-8'], + '/docs/api.md': ['docs/api.md', 'text/markdown; charset=utf-8'], + '/robots.txt': ['robots.txt', 'text/plain; charset=utf-8'], + '/sitemap.xml': ['sitemap.xml', 'application/xml; charset=utf-8'], + '/pricing': ['landing.html', 'text/html; charset=utf-8'], + '/app.js': ['app.js', 'text/javascript; charset=utf-8'], + '/cloud-sync.js': ['cloud-sync.js', 'text/javascript; charset=utf-8'], + '/analytics.js': ['analytics.js', 'text/javascript; charset=utf-8'], + '/styles.css': ['styles.css', 'text/css; charset=utf-8'], + '/toast-state.css': ['toast-state.css', 'text/css; charset=utf-8'], + '/wbs.json': ['wbs.json', 'application/json; charset=utf-8'], +}; +app.get('*', async (c) => { + const entry = STATIC[c.req.path]; + if (!entry) return c.notFound(); + try { + const buf = await readFile(new URL(`../${entry[0]}`, import.meta.url)); + return c.body(buf, 200, { 'Content-Type': entry[1] }); + } catch { + return c.notFound(); + } +}); diff --git a/tests/api/attachment-view-access-grant.test.mjs b/tests/api/attachment-view-access-grant.test.mjs new file mode 100644 index 00000000..59a413a3 --- /dev/null +++ b/tests/api/attachment-view-access-grant.test.mjs @@ -0,0 +1,125 @@ +// Runtime regression for the attachment-view access-grant migration in #413. +// This test intentionally exercises the real Hono routes, SQLite persistence, +// tenant checks, Clearfolio mock, and one-time redemption contract together. +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + +const { app } = await import('../../server/app.mjs'); + +const jsonRequest = (path, options = {}) => app.request(path, { + ...options, + headers: { + 'content-type': 'application/json', + ...(options.headers || {}), + }, +}); +const jsonBody = (value) => JSON.stringify(value); + +async function signup(email) { + const response = await jsonRequest('/api/auth/signup', { + method: 'POST', + body: jsonBody({ email, password: 'password123', name: email.split('@')[0] }), + }); + assert.equal(response.status, 200, `signup succeeds for ${email}`); + return (await response.json()).token; +} + +async function createProject(token, name) { + const response = await jsonRequest('/api/projects', { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + body: jsonBody({ name }), + }); + assert.equal(response.status, 200, `project creation succeeds for ${name}`); + return response.json(); +} + +async function uploadReadyAttachment(token, projectId, name) { + const form = new FormData(); + form.append('file', new File([`evidence:${name}`], name, { type: 'text/plain' })); + const response = await app.request(`/api/projects/${projectId}/attachments`, { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + body: form, + }); + assert.equal(response.status, 200, `attachment upload succeeds for ${name}`); + const uploaded = await response.json(); + assert.equal(uploaded.status, 'SUCCEEDED', 'Clearfolio test adapter makes the artifact immediately viewable'); + return uploaded; +} + +function assertPrivateGrantResponse(response, message) { + assert.equal(response.headers.get('cache-control'), 'private, no-store', `${message}: cache is disabled`); + assert.equal(response.headers.get('referrer-policy'), 'no-referrer', `${message}: referrer cannot disclose the grant`); +} + +const ownerToken = await signup('grant-owner@example.com'); +const project = await createProject(ownerToken, 'Grant runtime project'); +const attachment = await uploadReadyAttachment(ownerToken, project.id, 'design-evidence.txt'); +const secondAttachment = await uploadReadyAttachment(ownerToken, project.id, 'other-evidence.txt'); + +// The browser exchanges its broad session credential through the Authorization +// header. The response exposes only a one-time, resource-bound URL and metadata; +// it never returns or embeds the broad JWT. +let response = await jsonRequest(`/api/projects/${project.id}/access-grants`, { + method: 'POST', + headers: { authorization: `Bearer ${ownerToken}` }, + body: jsonBody({ purpose: 'attachment_view', attachmentId: attachment.id }), +}); +assert.equal(response.status, 201, 'ready attachment gets a one-time view grant'); +assert.equal(response.headers.get('cache-control'), 'no-store', 'grant exchange response is not cacheable'); +assert.equal(response.headers.get('referrer-policy'), 'no-referrer', 'grant exchange does not propagate referrers'); +const issued = await response.json(); +assert.equal(issued.purpose, 'attachment_view'); +assert.ok(/^agr_[a-f0-9]{32}$/.test(issued.grantId), 'non-secret grant correlation id is returned'); +assert.ok(Number.isSafeInteger(issued.expiresAtMs) && issued.expiresAtMs > Date.now(), 'expiry is explicit'); +assert.equal(typeof issued.url, 'string'); +assert.match(issued.url, new RegExp(`^/api/projects/${project.id}/attachments/${attachment.id}/view\\?grant=[A-Za-z0-9_-]{43}$`)); +assert.equal(issued.url.includes('token='), false, 'legacy broad-token query parameter is absent'); +assert.equal(issued.url.includes(ownerToken), false, 'session JWT is never copied into the view URL'); + +// The old broad session JWT transport is now rejected even when the JWT itself +// is valid. Header credentials remain available to clients that can set them. +response = await app.request(`/api/projects/${project.id}/attachments/${attachment.id}/view?token=${encodeURIComponent(ownerToken)}`); +assert.equal(response.status, 401, 'valid session JWT is rejected in a query string'); +assertPrivateGrantResponse(response, 'legacy token rejection'); + +response = await app.request(`/api/projects/${project.id}/attachments/${secondAttachment.id}/view`, { + headers: { authorization: `Bearer ${ownerToken}` }, + redirect: 'manual', +}); +assert.equal(response.status, 302, 'Authorization-header session access remains supported'); +assert.equal(response.headers.get('location'), `/api/mock-clearfolio/mockcf-${secondAttachment.id}`.replace(`mockcf-${secondAttachment.id}`, 'mockcf-2'), 'header-auth view redirects to Clearfolio artifact'); + +// A wrong resource binding must fail without consuming the grant. The original +// bound route can still redeem once afterwards. +const issuedUrl = new URL(issued.url, 'http://localhost'); +const grantSecret = issuedUrl.searchParams.get('grant'); +response = await app.request(`/api/projects/${project.id}/attachments/${secondAttachment.id}/view?grant=${encodeURIComponent(grantSecret)}`, { redirect: 'manual' }); +assert.equal(response.status, 401, 'grant cannot be used for another attachment'); +assertPrivateGrantResponse(response, 'wrong attachment rejection'); + +response = await app.request(issued.url, { redirect: 'manual' }); +assert.equal(response.status, 302, 'bound grant redirects exactly once'); +assert.equal(response.headers.get('location'), '/api/mock-clearfolio/mockcf-1'); +assertPrivateGrantResponse(response, 'successful grant redemption'); +assert.equal(response.headers.get('location')?.includes(grantSecret), false, 'grant secret is never forwarded downstream'); + +response = await app.request(issued.url, { redirect: 'manual' }); +assert.equal(response.status, 401, 'one-time grant replay is rejected'); +assertPrivateGrantResponse(response, 'replay rejection'); + +// Tenant nondisclosure applies at mint time: another user sees neither the +// project nor attachment through the exchange endpoint. +const outsiderToken = await signup('grant-outsider@example.com'); +response = await jsonRequest(`/api/projects/${project.id}/access-grants`, { + method: 'POST', + headers: { authorization: `Bearer ${outsiderToken}` }, + body: jsonBody({ purpose: 'attachment_view', attachmentId: attachment.id }), +}); +assert.equal(response.status, 404, 'cross-tenant mint is indistinguishable from a missing resource'); +assert.equal(response.headers.get('cache-control'), 'no-store', 'failed exchange is not cacheable'); + +console.log('attachment-view access-grant runtime contract ok'); diff --git a/tests/api/session-revocation.test.mjs b/tests/api/session-revocation.test.mjs index 6164798b..0e7409ce 100644 --- a/tests/api/session-revocation.test.mjs +++ b/tests/api/session-revocation.test.mjs @@ -1,7 +1,10 @@ // Security invariant: logout-all revocation and strict session-claim validation -// must apply uniformly to every JWT transport. Calendar clients and EventSource -// cannot reliably send Authorization headers, so query-token routes must share -// the same fail-closed verifier as bearer middleware. +// must apply uniformly to every supported JWT transport. Calendar clients and +// EventSource cannot reliably send Authorization headers, so their query-token +// routes share the same fail-closed verifier as bearer middleware. Attachment +// views no longer accept broad session JWTs in query parameters; their direct +// session path is Authorization-header only and scoped grants are covered by +// the dedicated attachment-view access-grant regression. import test from 'node:test'; import assert from 'node:assert/strict'; import { createHmac } from 'node:crypto'; @@ -61,7 +64,8 @@ async function expectCalendarStatus(projectId, token, status, message) { async function expectAttachmentViewStatus(projectId, token, status, message) { const response = await req( - `/api/projects/${projectId}/attachments/missing/view?token=${encodeURIComponent(token)}`, + `/api/projects/${projectId}/attachments/999999/view`, + { headers: { authorization: `Bearer ${token}` } }, ); assert.equal(response.status, status, message); } @@ -205,4 +209,4 @@ test('logout-all and strict JWT validation cover every session transport', async await expectCalendarStatus(projectId, freshToken, 200, 'calendar accepts replacement token'); await expectStreamStatus(projectId, freshToken, 200, 'SSE accepts replacement token'); await expectAttachmentViewStatus(projectId, freshToken, 404, 'attachment view accepts replacement token before lookup'); -}); +}); \ No newline at end of file diff --git a/tests/api/smoke-orchestrator-provider.mjs b/tests/api/smoke-orchestrator-provider.mjs new file mode 100644 index 00000000..121687b1 --- /dev/null +++ b/tests/api/smoke-orchestrator-provider.mjs @@ -0,0 +1,28 @@ +// Execute the broad API smoke suite against the production orchestrator client +// boundary without depending on an external service. Non-orchestrator requests +// continue through Node's real fetch implementation. +process.env.ORCHESTRATOR_URL = 'http://127.0.0.1'; +process.env.ORCHESTRATOR_TOKEN = 'scopeweave-smoke-provider-token'; + +const originalFetch = globalThis.fetch; +globalThis.fetch = async (input, init) => { + if (String(input) === 'http://127.0.0.1/v1/chat/completions') { + return new Response(JSON.stringify({ + choices: [{ + message: { + content: 'mock-orchestrator deterministic authenticated provider response for the API smoke contract.', + }, + }], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + return originalFetch(input, init); +}; + +try { + await import('./smoke.mjs'); +} finally { + globalThis.fetch = originalFetch; +} diff --git a/tests/api/smoke.mjs b/tests/api/smoke.mjs index e536b908..21688173 100644 --- a/tests/api/smoke.mjs +++ b/tests/api/smoke.mjs @@ -5,7 +5,6 @@ import assert from 'node:assert'; process.env.SCOPEWEAVE_DB = ':memory:'; process.env.SCOPEWEAVE_DEV = '1'; // enables the dev-activate-pro endpoint for this test -delete process.env.ORCHESTRATOR_URL; // keep the AI briefing on the explicit local dev adapter process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; const { app } = await import('../../server/app.mjs'); @@ -167,7 +166,7 @@ assert.equal(r.status, 401, 'SSE without token → 401'); await r.body?.cancel?.(); // Static allowlist — client files served, source/db never exposed -for (const [path, code] of [['/', 200], ['/index.html', 200], ['/app.js', 200], ['/cloud-sync.js', 200], ['/analytics.js', 200], ['/styles.css', 200], ['/toast-state.css', 200], ['/wbs.json', 200], ['/landing.html', 200], ['/landing.en.html', 200], ['/pricing', 200], ['/docs/api.md', 200], ['/robots.txt', 200], ['/sitemap.xml', 200]]) { +for (const [path, code] of [['/', 200], ['/index.html', 200], ['/app.js', 200], ['/cloud-sync.js', 200], ['/analytics.js', 200], ['/styles.css', 200], ['/wbs.json', 200], ['/landing.html', 200], ['/landing.en.html', 200], ['/pricing', 200], ['/docs/api.md', 200], ['/robots.txt', 200], ['/sitemap.xml', 200]]) { const res = await req(path); assert.equal(res.status, code, `static ${path} → ${code}`); } @@ -620,7 +619,7 @@ assert.equal(r.status, 200, 'sprint delete'); r = await req(`/api/projects/${proj.id}/ai/brief`, { method: 'POST', headers: auth }); assert.equal(r.status, 200, 'ai brief 200'); const brief = await r.json(); -assert.ok(brief.analysis.includes('dev-orchestrator'), 'explicit development analysis returned'); +assert.ok(brief.analysis.includes('mock-orchestrator'), 'mock analysis returned'); assert.ok(brief.analysis.length > 40, 'non-trivial analysis'); r = await req(`/api/projects/${proj.id}/ai/brief`, { method: 'POST', headers: oauth }); assert.equal(r.status, 404, 'non-member ai brief → 404'); @@ -640,8 +639,8 @@ assert.equal(r.status, 404, 'non-member ai brief → 404'); r = await req(`/api/projects/${proj.id}/attachments?taskId=s1`, { headers: auth }); const list = (await r.json()).attachments; assert.ok(list.some((x) => x.id === att.id && x.name === '요구사항정의서.pdf' && x.uploadedBy), 'listed with meta'); - // 열람: 302 → mock 아티팩트 → 바이트 왕복 - r = await req(`/api/projects/${proj.id}/attachments/${att.id}/view?token=${encodeURIComponent(token)}`); + // 열람: Authorization 헤더 → 302 → mock 아티팩트 → 바이트 왕복 + r = await req(`/api/projects/${proj.id}/attachments/${att.id}/view`, { headers: auth }); assert.equal(r.status, 302, 'view redirects'); const loc = r.headers.get('location'); assert.ok(loc.includes('/api/mock-clearfolio/'), 'redirect to signed artifact'); @@ -748,4 +747,4 @@ assert.equal((await r.json()).orgs.find((o) => o.id === orgAId)?.role, 'admin', r = await req(`/api/orgs/${orgAId}/leave`, { method: 'POST', headers: auth }); assert.equal(r.status, 200, 'former owner can now leave'); -console.log('✓ API smoke tests passed'); \ No newline at end of file +console.log('✓ API smoke tests passed'); diff --git a/tests/unit/attachment-view-client.test.mjs b/tests/unit/attachment-view-client.test.mjs new file mode 100644 index 00000000..43ccd5ee --- /dev/null +++ b/tests/unit/attachment-view-client.test.mjs @@ -0,0 +1,239 @@ +import assert from 'node:assert/strict'; +import { + exchangeAttachmentViewGrant, + installAttachmentViewGrantWindowOpen, + parseLegacyAttachmentViewUrl, +} from '../../cloud-sync.js'; + +const origin = 'https://scopeweave.example'; +const sessionToken = 'eyJhbGciOiJIUzI1NiJ9.scopeweave.signature'; +const grantSecret = 'A'.repeat(43); +const validLegacyUrl = `/api/projects/7/attachments/9/view?token=${encodeURIComponent(sessionToken)}`; +const validGrantUrl = `/api/projects/7/attachments/9/view?grant=${grantSecret}`; + +assert.deepEqual(parseLegacyAttachmentViewUrl(validLegacyUrl, origin), { + projectId: '7', + attachmentId: '9', + sessionToken, +}); +assert.equal(parseLegacyAttachmentViewUrl(new URL(validLegacyUrl, origin), origin), null, 'non-string navigation input is ignored'); +assert.equal(parseLegacyAttachmentViewUrl(validLegacyUrl, 'file:///tmp/scopeweave'), null, 'non-HTTP origin is rejected'); +assert.equal(parseLegacyAttachmentViewUrl('http://[::1', origin), null, 'malformed URL is ignored'); +assert.equal(parseLegacyAttachmentViewUrl(`https://evil.example${validLegacyUrl}`, origin), null, 'cross-origin URL is ignored'); +assert.equal(parseLegacyAttachmentViewUrl(`${validLegacyUrl}#fragment`, origin), null, 'fragment-bearing legacy URL is ignored'); +assert.equal(parseLegacyAttachmentViewUrl(`${validLegacyUrl}&token=second`, origin), null, 'ambiguous duplicate token is ignored'); +assert.equal(parseLegacyAttachmentViewUrl(`${validLegacyUrl}&extra=1`, origin), null, 'unexpected query data is ignored'); +assert.equal(parseLegacyAttachmentViewUrl(`${validLegacyUrl}&grant=${grantSecret}`, origin), null, 'mixed legacy/grant credential is ignored'); +assert.equal(parseLegacyAttachmentViewUrl('/api/projects/0/attachments/9/view?token=x', origin), null, 'invalid project id is ignored'); +assert.equal(parseLegacyAttachmentViewUrl('/api/projects/7/attachments/9/view?token=', origin), null, 'empty session token is ignored'); + +let captured; +const successFetch = async (url, options) => { + captured = { url, options }; + return { + status: 201, + async json() { + return { + purpose: 'attachment_view', + grantId: 'agr_0123456789abcdef0123456789abcdef', + expiresAtMs: Date.now() + 60_000, + url: validGrantUrl, + }; + }, + }; +}; +const exchanged = await exchangeAttachmentViewGrant({ + projectId: '7', + attachmentId: '9', + sessionToken, + origin, + fetchImpl: successFetch, +}); +assert.equal(exchanged.url, validGrantUrl); +assert.equal(captured.url, '/api/projects/7/access-grants'); +assert.equal(captured.options.method, 'POST'); +assert.equal(captured.options.credentials, 'omit'); +assert.equal(captured.options.cache, 'no-store'); +assert.equal(captured.options.redirect, 'error'); +assert.equal(captured.options.headers.authorization, `Bearer ${sessionToken}`); +assert.equal(captured.url.includes(sessionToken), false, 'session secret is absent from the exchange URL'); +assert.equal(captured.options.body.includes(sessionToken), false, 'session secret is absent from the exchange body'); +assert.deepEqual(JSON.parse(captured.options.body), { purpose: 'attachment_view', attachmentId: '9' }); + +await assert.rejects( + exchangeAttachmentViewGrant({ projectId: '7', attachmentId: '9', sessionToken: '', origin, fetchImpl: successFetch }), + /exchange unavailable/, +); +await assert.rejects( + exchangeAttachmentViewGrant({ projectId: '7', attachmentId: '9', sessionToken, origin, fetchImpl: null }), + /exchange unavailable/, +); +await assert.rejects( + exchangeAttachmentViewGrant({ projectId: '7', attachmentId: '9', sessionToken, origin, fetchImpl: async () => null }), + /exchange failed/, +); +await assert.rejects( + exchangeAttachmentViewGrant({ projectId: '7', attachmentId: '9', sessionToken, origin, fetchImpl: async () => ({ status: 503 }) }), + /exchange failed/, +); +await assert.rejects( + exchangeAttachmentViewGrant({ + projectId: '7', attachmentId: '9', sessionToken, origin, + fetchImpl: async () => ({ status: 201, json: async () => { throw new Error('bad json'); } }), + }), + /response invalid/, +); + +const invalidPayloads = [ + null, + [], + { purpose: 'wrong', grantId: 'g', expiresAtMs: 1, url: validGrantUrl }, + { purpose: 'attachment_view', grantId: 7, expiresAtMs: 1, url: validGrantUrl }, + { purpose: 'attachment_view', grantId: 'g', expiresAtMs: 1.5, url: validGrantUrl }, + { purpose: 'attachment_view', grantId: 'g', expiresAtMs: 1, url: null }, + { purpose: 'attachment_view', grantId: 'g', expiresAtMs: 1, url: 'http://[::1' }, + { purpose: 'attachment_view', grantId: 'g', expiresAtMs: 1, url: `https://evil.example${validGrantUrl}` }, + { purpose: 'attachment_view', grantId: 'g', expiresAtMs: 1, url: `/api/projects/8/attachments/9/view?grant=${grantSecret}` }, + { purpose: 'attachment_view', grantId: 'g', expiresAtMs: 1, url: `${validGrantUrl}#x` }, + { purpose: 'attachment_view', grantId: 'g', expiresAtMs: 1, url: `${validGrantUrl}&grant=${'B'.repeat(43)}` }, + { purpose: 'attachment_view', grantId: 'g', expiresAtMs: 1, url: '/api/projects/7/attachments/9/view?grant=short' }, + { purpose: 'attachment_view', grantId: 'g', expiresAtMs: 1, url: `${validGrantUrl}&extra=1` }, +]; +for (const payload of invalidPayloads) { + await assert.rejects( + exchangeAttachmentViewGrant({ + projectId: '7', + attachmentId: '9', + sessionToken, + origin, + fetchImpl: async () => ({ status: 201, json: async () => payload }), + }), + /response invalid/, + ); +} +await assert.rejects( + exchangeAttachmentViewGrant({ + projectId: '7', attachmentId: '9', sessionToken, origin: 'file:///tmp/x', + fetchImpl: async () => ({ status: 201, json: async () => ({ purpose: 'attachment_view', grantId: 'g', expiresAtMs: 1, url: validGrantUrl }) }), + }), + /response invalid/, +); + +assert.equal(installAttachmentViewGrantWindowOpen(null), false, 'missing window is unsupported'); +assert.equal(installAttachmentViewGrantWindowOpen({ open: 7 }), false, 'non-callable open is unsupported'); + +const alerts = []; +const nativeCalls = []; +let replacement = null; +const popup = { + closed: false, + opener: { legacy: true }, + location: { replace(value) { replacement = value; } }, + close() { this.closed = true; }, +}; +const browser = { + location: { origin }, + alert(message) { alerts.push(message); }, + open(value, target, features) { + nativeCalls.push({ value, target, features }); + return value === 'about:blank' ? popup : { passthrough: true }; + }, +}; +assert.equal(installAttachmentViewGrantWindowOpen(browser, { fetchImpl: successFetch }), true); +assert.equal(installAttachmentViewGrantWindowOpen(browser, { fetchImpl: successFetch }), false, 'same window is never patched twice'); +assert.deepEqual(browser.open('/pricing', '_self', 'width=500'), { passthrough: true }, 'unrelated navigation passes through unchanged'); +const opened = browser.open(validLegacyUrl, '_blank', 'noopener,width=640,noreferrer'); +assert.equal(opened, popup); +assert.equal(nativeCalls.at(-1).value, 'about:blank', 'session-bearing URL is never sent to native window.open'); +assert.equal(nativeCalls.at(-1).features, 'width=640', 'opener-isolation flags are enforced programmatically rather than suppressing WindowProxy'); +assert.equal(popup.opener, null, 'blank popup loses opener before grant exchange'); +await new Promise((resolve) => setImmediate(resolve)); +assert.equal(replacement, validGrantUrl, 'only the one-time grant URL is navigated'); +assert.deepEqual(alerts, []); + +const blockedAlerts = []; +const blockedBrowser = { + location: { origin }, + alert(message) { blockedAlerts.push(message); }, + open() { return null; }, +}; +assert.equal(installAttachmentViewGrantWindowOpen(blockedBrowser, { fetchImpl: successFetch }), true); +assert.equal(blockedBrowser.open(validLegacyUrl), null); +assert.match(blockedAlerts[0], /팝업을 허용/, 'popup blocker message tells the customer the next action'); + +let closedAfterFailure = false; +const failedAlerts = []; +const failedPopup = { + closed: false, + opener: {}, + location: { replace() { throw new Error('must not navigate'); } }, + close() { closedAfterFailure = true; }, +}; +const failedBrowser = { + location: { origin }, + open() { return failedPopup; }, +}; +installAttachmentViewGrantWindowOpen(failedBrowser, { + fetchImpl: async () => ({ status: 503 }), + alertImpl: (message) => failedAlerts.push(message), +}); +failedBrowser.open(validLegacyUrl); +await new Promise((resolve) => setImmediate(resolve)); +assert.equal(closedAfterFailure, true, 'failed exchange closes the unused blank popup'); +assert.match(failedAlerts[0], /다시 시도/, 'exchange failure tells the customer the next action'); + +let closedPopupWasNavigated = false; +let releaseExchange; +const delayedFetch = () => new Promise((resolve) => { releaseExchange = resolve; }); +const closedPopup = { + closed: false, + opener: {}, + location: { replace() { closedPopupWasNavigated = true; } }, + close() {}, +}; +const closedBrowser = { location: { origin }, open: () => closedPopup }; +installAttachmentViewGrantWindowOpen(closedBrowser, { fetchImpl: delayedFetch, alertImpl: () => {} }); +closedBrowser.open(validLegacyUrl); +closedPopup.closed = true; +releaseExchange({ + status: 201, + json: async () => ({ + purpose: 'attachment_view', + grantId: 'agr_0123456789abcdef0123456789abcdef', + expiresAtMs: Date.now() + 60_000, + url: validGrantUrl, + }), +}); +await new Promise((resolve) => setImmediate(resolve)); +assert.equal(closedPopupWasNavigated, false, 'a user-closed popup is never resurrected'); + +let openerSetterObserved = false; +const hardenedPopup = { + closed: false, + set opener(_) { openerSetterObserved = true; throw new Error('blocked by browser'); }, + location: { replace() {} }, + close() {}, +}; +const hardenedBrowser = { location: { origin }, open: () => hardenedPopup }; +installAttachmentViewGrantWindowOpen(hardenedBrowser, { fetchImpl: successFetch, alertImpl: () => {} }); +hardenedBrowser.open(validLegacyUrl, undefined, ''); +await new Promise((resolve) => setImmediate(resolve)); +assert.equal(openerSetterObserved, true, 'opener-hardening failure remains isolated from grant navigation'); + +let closeFailureAlert = ''; +const closeThrowingPopup = { + closed: false, + opener: {}, + location: { replace() {} }, + close() { throw new Error('already inaccessible'); }, +}; +const closeThrowingBrowser = { location: { origin }, open: () => closeThrowingPopup }; +installAttachmentViewGrantWindowOpen(closeThrowingBrowser, { + fetchImpl: async () => ({ status: 500 }), + alertImpl: (message) => { closeFailureAlert = message; }, +}); +closeThrowingBrowser.open(validLegacyUrl); +await new Promise((resolve) => setImmediate(resolve)); +assert.match(closeFailureAlert, /다시 시도/, 'close errors do not suppress the customer recovery action'); + +console.log('attachment-view client grant bridge tests passed'); diff --git a/tests/unit/toast-accessibility.test.mjs b/tests/unit/toast-accessibility.test.mjs index c0aa79a0..aa0d0304 100644 --- a/tests/unit/toast-accessibility.test.mjs +++ b/tests/unit/toast-accessibility.test.mjs @@ -5,6 +5,7 @@ import { readFileSync } from 'node:fs'; const indexHtml = readFileSync(new URL('../../index.html', import.meta.url), 'utf8'); const toastStateCss = readFileSync(new URL('../../toast-state.css', import.meta.url), 'utf8'); const cloudSyncJs = readFileSync(new URL('../../cloud-sync.js', import.meta.url), 'utf8'); +const cloudSyncCoreJs = readFileSync(new URL('../../cloud-sync-core.js', import.meta.url), 'utf8'); function toastElementMarkup(html) { const match = html.match(/]*\bid=["']toast["'][^>]*>/i); @@ -36,20 +37,27 @@ 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 serverAppCore = readFileSync(new URL('../../server/app_core.mjs', import.meta.url), 'utf8'); const pagesWorkflow = readFileSync(new URL('../../.github/workflows/pages.yml', import.meta.url), 'utf8'); const staticDockerfile = readFileSync(new URL('../../Dockerfile', import.meta.url), 'utf8'); const serverDockerfile = readFileSync(new URL('../../Dockerfile.server', import.meta.url), 'utf8'); - assert.match(serverApp, /['"]\/toast-state\.css['"]/, 'SaaS allowlist serves the cloud toast stylesheet'); + assert.match(serverApp, /coreApp\.fetch\(/, 'security envelope delegates unmatched SaaS requests to the production core'); + assert.match(serverAppCore, /['"]\/toast-state\.css['"]/, 'delegated SaaS core allowlist serves the cloud toast stylesheet'); assert.match(pagesWorkflow, /\btoast-state\.css\b/, 'GitHub Pages stages the cloud toast stylesheet'); assert.match(staticDockerfile, /\btoast-state\.css\b/, 'static image copies the cloud toast stylesheet'); assert.match(serverDockerfile, /\btoast-state\.css\b/, 'SaaS image copies the cloud toast stylesheet'); }); -test('cloud toast state is visibly rendered by a shipped stylesheet', () => { +test('cloud toast state is visibly rendered by the delegated client core', () => { assert.match( cloudSyncJs, + /export\s+\*\s+from\s+["']\.\/cloud-sync-core\.js["']/, + 'security envelope delegates the established cloud client exports to the production core', + ); + assert.match( + cloudSyncCoreJs, /classList\.add\(["']visible["']\)/, - 'cloud status messages activate the visible toast state', + 'delegated cloud status messages activate the visible toast state', ); assert.match( indexHtml,