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,