From 2e179bf895067e6dc3cfdb1a1ef4e64738280904 Mon Sep 17 00:00:00 2001 From: jackrescuer-gif Date: Tue, 12 May 2026 11:39:34 +0300 Subject: [PATCH 1/3] feat: project launcher with New/Last session + GitHub repo onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Project launcher" experience on the Projects view: - "▶ New" button on each project group opens a fresh agent session in the project directory (no --resume). Uses the most recently used tool for that project, falling back to Claude. - "⟳ Last" button resumes the project's latest session. - "+ Add Project" toolbar opens a modal with three tabs: * Local path — register an arbitrary folder by absolute path * My GitHub repos — list owned repos via /user/repos, one-click clone & add * Contributing — repos where the user is a collaborator/org member - Cloned repos land in ~/code/ and get registered automatically. - Manual + cloned projects are persisted in ~/.codedash/projects.json and merged into the Projects view alongside session-derived entries. Server changes: - New module src/projects.js with the registry, GitHub API helper, and git-clone helper. saveProjects is atomic (write-then-rename) and gated by a promise-chain mutex so concurrent registrations don't lose entries. - New routes: GET/POST /api/projects/manual, DELETE /api/projects/manual/:id, POST /api/projects/clone, GET /api/github/repos?type=owned|contributing. - POST /api/launch extended with mode:'fresh' for new sessions without a session id. The project path is validated against shell metacharacters and must resolve to a real directory on disk, defanging the `cd "..." && claude` shell concatenation in terminals.js. - DNS rebinding guard: requests whose Host header is not localhost/127.0.0.1 are rejected with 403 when the server is bound to a loopback address. Skipped when bound to a LAN address so cross-device access still works. - /api/projects/clone cross-validates cloneUrl owner/repo against the caller-supplied fullName so a crafted request cannot display a trusted name while cloning attacker-controlled code. Terminals: - openInTerminal(sessionId, tool, flags, projectDir, terminalId, mode) gets a new `mode` argument; 'fresh' builds the command without --resume and generates a synthetic WSL window tag. OAuth scope is intentionally kept at `read:user`. /user/repos still returns public owned repos under that scope; private/collaborator listings will be limited until we add a separately-scoped flow that does not share its token with the leaderboard sync. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/frontend/app.js | 312 ++++++++++++++++++++++++++++++++++++++-- src/frontend/index.html | 32 +++++ src/frontend/styles.css | 84 +++++++++++ src/projects.js | 264 ++++++++++++++++++++++++++++++++++ src/server.js | 146 ++++++++++++++++++- src/terminals.js | 32 ++++- 6 files changed, 848 insertions(+), 22 deletions(-) create mode 100644 src/projects.js diff --git a/src/frontend/app.js b/src/frontend/app.js index 84b3e42..553b60c 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -1246,47 +1246,97 @@ function renderQACard(s, idx) { } function renderProjects(container, sessions) { - var byGit = {}; // key → { name, list } + var byGit = {}; // key → { name, list, path, source, manualId } sessions.forEach(function(s) { var info = getRepoInfo(s.project, s.git_root); - if (!byGit[info.key]) byGit[info.key] = { name: info.name, list: [] }; + if (!byGit[info.key]) byGit[info.key] = { name: info.name, list: [], path: info.key !== 'unknown' ? info.key : '', source: 'session', manualId: '' }; byGit[info.key].list.push(s); }); + // Merge manually-registered projects (local-only or cloned from GitHub) so + // they appear as project cards even before any session exists for them. + (window.manualProjects || []).forEach(function(p) { + if (!byGit[p.path]) { + byGit[p.path] = { name: p.name, list: [], path: p.path, source: p.source || 'manual', manualId: p.id, _git: p.git, _lastAdded: p.addedAt }; + } else { + // Existing key from sessions — annotate with manual id so the launcher + // buttons can still show source and offer "remove from registry". + byGit[p.path].manualId = p.id; + if (!byGit[p.path].source || byGit[p.path].source === 'session') byGit[p.path].source = p.source || 'manual'; + } + }); + var sorted = Object.entries(byGit).sort(function(a, b) { - return b[1].list[0].last_ts - a[1].list[0].last_ts; + var aTs = a[1].list[0] ? a[1].list[0].last_ts : (a[1]._lastAdded ? Date.parse(a[1]._lastAdded) : 0); + var bTs = b[1].list[0] ? b[1].list[0].last_ts : (b[1]._lastAdded ? Date.parse(b[1]._lastAdded) : 0); + return bTs - aTs; }); + var html = '
'; + html += ''; + html += ''; + html += ''; + html += '
'; + if (sorted.length === 0) { - container.innerHTML = '
No projects found.
'; + container.innerHTML = html + '
No projects yet. Click + Add Project to register a local folder or clone one from GitHub.
'; return; } var globalIdx = 0; - var html = '
'; - html += ''; - html += ''; - html += '
'; html += '
'; sorted.forEach(function(entry) { var projKey = entry[0]; - var projName = entry[1].name; - var list = entry[1].list.slice().sort(function(a, b) { return b.last_ts - a.last_ts; }); + var projInfo = entry[1]; + var projName = projInfo.name; + var projPath = projInfo.path; + var list = projInfo.list.slice().sort(function(a, b) { return b.last_ts - a.last_ts; }); var color = getProjectColor(projName); var totalMsgs = list.reduce(function(s, e) { return s + (e.messages || 0); }, 0); var totalCost = list.reduce(function(s, e) { return s + getEstimatedSessionCost(e); }, 0); var costLabel = totalCost > 0 ? ' · ~$' + totalCost.toFixed(2) : ''; + var lastSession = list[0]; // most recent + + var sourceTag = ''; + if (projInfo.source === 'github-clone') sourceTag = 'github'; + else if (projInfo.source === 'manual') sourceTag = 'added'; + + var statsLine = list.length === 0 + ? 'no sessions yet' + : (list.length + ' sessions · ' + totalMsgs + ' msgs' + costLabel); - html += '
'; + html += '
'; html += '
'; html += ''; - html += '' + escHtml(projName) + ''; - html += '' + list.length + ' sessions · ' + totalMsgs + ' msgs' + escHtml(costLabel) + ''; + html += '' + escHtml(projName) + sourceTag + ''; + html += '' + escHtml(statsLine) + ''; + + // Launch buttons — fresh + resume last. Only render if we know the local path. + if (projPath) { + // Prefer the tool the user actually worked with in this project; default + // to claude for brand-new entries with no sessions yet. + var preferredTool = lastSession && lastSession.tool ? lastSession.tool : 'claude'; + html += ''; + if (lastSession) { + var lastId = lastSession.id || ''; + var lastTool = lastSession.tool || 'claude'; + html += ''; + } + } + + if (projInfo.manualId) { + html += ''; + } + html += ''; html += ''; html += '
'; html += '
'; - list.forEach(function(s) { html += renderQACard(s, globalIdx++); }); + if (list.length === 0) { + html += '
No sessions yet. Click ▶ New above to start one.
'; + } else { + list.forEach(function(s) { html += renderQACard(s, globalIdx++); }); + } html += '
'; html += '
'; }); @@ -2106,12 +2156,246 @@ function dismissUpdate() { if (banner) banner.style.display = 'none'; } +// ── Project launcher: manual registry, New/Last session, Add Project modal ── + +window.manualProjects = window.manualProjects || []; +var _githubReposCache = { owned: null, contributing: null }; + +async function loadManualProjects() { + try { + var resp = await fetch('/api/projects/manual'); + var data = await resp.json(); + window.manualProjects = Array.isArray(data) ? data : []; + if (currentView === 'projects') render(); + } catch (e) { + console.warn('[codbash] loadManualProjects failed:', e && e.message); + } +} + +function _currentTerminalId() { + return localStorage.getItem('codedash-terminal') || ''; +} + +async function launchNewProjectSession(projectPath, tool) { + if (!projectPath) { showToast('No project path'); return; } + var t = tool || 'claude'; + try { + var resp = await fetch('/api/launch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mode: 'fresh', + tool: t, + flags: [], + project: projectPath, + terminal: _currentTerminalId(), + }), + }); + var data = await resp.json(); + if (data.ok) showToast('Starting new ' + t + ' session in ' + projectPath.split('/').pop()); + else showToast('Launch failed: ' + (data.error || 'unknown')); + } catch (e) { + showToast('Launch failed: ' + e.message); + } +} + +async function resumeLastProjectSession(sessionId, tool, projectPath) { + if (!sessionId) { showToast('No previous session to resume'); return; } + try { + var resp = await fetch('/api/launch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sessionId: sessionId, + tool: tool || 'claude', + flags: [], + project: projectPath || '', + terminal: _currentTerminalId(), + }), + }); + var data = await resp.json(); + if (data.ok) showToast('Resuming ' + sessionId.slice(0, 8) + '…'); + else showToast('Resume failed: ' + (data.error || 'unknown')); + } catch (e) { + showToast('Resume failed: ' + e.message); + } +} + +async function unregisterProject(id, name) { + if (!id) return; + if (!confirm('Remove “' + name + '” from project registry? Files on disk are untouched.')) return; + try { + var resp = await fetch('/api/projects/manual/' + encodeURIComponent(id), { method: 'DELETE' }); + var data = await resp.json(); + if (data.ok) { + showToast('Removed ' + name); + await loadManualProjects(); + } else { + showToast('Remove failed'); + } + } catch (e) { + showToast('Remove failed: ' + e.message); + } +} + +// ── Add Project modal ───────────────────────────────────────── + +function openAddProject() { + var overlay = document.getElementById('addProjectOverlay'); + if (!overlay) return; + overlay.classList.add('open'); + addProjectSwitchTab('local'); + var input = document.getElementById('apLocalPath'); + if (input) { input.value = ''; setTimeout(function() { input.focus(); }, 50); } + var err = document.getElementById('apLocalError'); if (err) err.textContent = ''; +} + +function closeAddProject() { + var overlay = document.getElementById('addProjectOverlay'); + if (overlay) overlay.classList.remove('open'); +} + +function addProjectSwitchTab(tab) { + ['local', 'owned', 'contributing'].forEach(function(t) { + var btn = document.querySelector('.ap-tab[data-tab="' + t + '"]'); + if (btn) btn.classList.toggle('active', t === tab); + }); + document.getElementById('apPaneLocal').style.display = tab === 'local' ? '' : 'none'; + document.getElementById('apPaneOwned').style.display = tab === 'owned' ? '' : 'none'; + document.getElementById('apPaneContrib').style.display = tab === 'contributing' ? '' : 'none'; + var addBtn = document.getElementById('apLocalAddBtn'); + if (addBtn) addBtn.style.display = tab === 'local' ? '' : 'none'; + + if (tab === 'owned' || tab === 'contributing') { + var apiType = tab === 'owned' ? 'owned' : 'contributing'; + if (!_githubReposCache[apiType]) loadGithubRepos(apiType); + else renderRepoList(apiType); + } +} + +async function submitAddLocalProject() { + var input = document.getElementById('apLocalPath'); + var err = document.getElementById('apLocalError'); + if (err) err.textContent = ''; + var p = input ? input.value.trim() : ''; + if (!p) { if (err) err.textContent = 'Path required'; return; } + try { + var resp = await fetch('/api/projects/manual', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: p, source: 'manual' }), + }); + var data = await resp.json(); + if (data.ok) { + showToast('Added ' + (data.project.name || p)); + closeAddProject(); + await loadManualProjects(); + } else { + if (err) err.textContent = data.error || 'Failed to add'; + } + } catch (e) { + if (err) err.textContent = e.message; + } +} + +async function loadGithubRepos(apiType) { + var containerId = apiType === 'owned' ? 'apOwnedRepos' : 'apContribRepos'; + var container = document.getElementById(containerId); + if (!container) return; + container.innerHTML = '
Loading repos…
'; + try { + var resp = await fetch('/api/github/repos?type=' + apiType); + if (resp.status === 401) { + container.innerHTML = '
GitHub not connected. Open Cloud view → connect GitHub, then return here.
'; + return; + } + var data = await resp.json(); + if (!Array.isArray(data)) { + container.innerHTML = '
' + escHtml(data.error || 'Failed to load') + '
'; + return; + } + _githubReposCache[apiType] = data; + renderRepoList(apiType); + } catch (e) { + container.innerHTML = '
Failed to load: ' + escHtml(e.message) + '
'; + } +} + +function renderRepoList(apiType) { + var containerId = apiType === 'owned' ? 'apOwnedRepos' : 'apContribRepos'; + var filterId = apiType === 'owned' ? 'apOwnedFilter' : 'apContribFilter'; + var container = document.getElementById(containerId); + var filter = document.getElementById(filterId); + if (!container) return; + var repos = _githubReposCache[apiType] || []; + var q = filter ? filter.value.trim().toLowerCase() : ''; + var filtered = q + ? repos.filter(function(r) { + return (r.fullName || '').toLowerCase().indexOf(q) >= 0 + || (r.description || '').toLowerCase().indexOf(q) >= 0; + }) + : repos; + + if (filtered.length === 0) { + container.innerHTML = '
' + (q ? 'No repos match "' + escHtml(q) + '"' : 'No repos found') + '
'; + return; + } + var registeredPaths = (window.manualProjects || []).map(function(p) { return p.remoteUrl; }).filter(Boolean); + var html = ''; + // No client-side truncation: the backend already caps at 300 repos and the + // filter has narrowed the list. Truncating here would hide matching repos + // when the user has many. + filtered.forEach(function(r) { + var already = registeredPaths.indexOf(r.cloneUrl) >= 0; + var meta = (r.private ? 'private · ' : '') + (r.description ? r.description : (r.htmlUrl || '')); + html += '
'; + html += '
'; + html += '
' + escHtml(r.fullName) + '
'; + html += '
' + escHtml(meta) + '
'; + html += '
'; + html += ''; + html += '
'; + }); + container.innerHTML = html; +} + +async function cloneRepoAndAdd(btn) { + var fullName = btn.getAttribute('data-full-name'); + var cloneUrl = btn.getAttribute('data-clone-url'); + var sshUrl = btn.getAttribute('data-ssh-url'); + var defaultBranch = btn.getAttribute('data-default-branch'); + btn.disabled = true; + btn.textContent = 'Cloning…'; + try { + var resp = await fetch('/api/projects/clone', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fullName: fullName, cloneUrl: cloneUrl, sshUrl: sshUrl, defaultBranch: defaultBranch }), + }); + var data = await resp.json(); + if (data.ok) { + showToast(data.alreadyExisted ? 'Linked existing clone of ' + fullName : 'Cloned ' + fullName); + btn.textContent = 'Added'; + await loadManualProjects(); + } else { + btn.disabled = false; + btn.textContent = 'Retry'; + showToast('Clone failed: ' + (data.error || 'unknown')); + } + } catch (e) { + btn.disabled = false; + btn.textContent = 'Retry'; + showToast('Clone failed: ' + e.message); + } +} + // ── Initialization ───────────────────────────────────────────── (function init() { // Load data loadSessions(); loadTerminals(); + loadManualProjects(); checkForUpdates(); setInterval(checkForUpdates, 10000); // check every 10s setInterval(loadSessions, 60000); // refresh sessions + invalidate analytics cache every 60s diff --git a/src/frontend/index.html b/src/frontend/index.html index 8748325..8a64ddf 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -191,6 +191,38 @@

Delete Session?

+ +
+
+

Add project

+
+ + + +
+
+
+ + +
Must be an existing directory. ~ is expanded.
+
+
+ + +
+
+ + +
+
+
+ '; } -async function startRepoScopeConnect(apiType) { +async function startRepoScopeConnect(apiType, btn) { + if (btn) btn.disabled = true; // prevent double-clicks racing two device flows var status = document.getElementById('apConnectStatus'); if (status) status.textContent = 'Requesting device code…'; var publicOnly = !!(document.getElementById('apPublicOnlyToggle') && document.getElementById('apPublicOnlyToggle').checked); @@ -2364,78 +2385,123 @@ async function startRepoScopeConnect(apiType) { var data = await resp.json(); if (data.error) { if (status) status.textContent = 'Failed: ' + data.error; + if (btn) btn.disabled = false; return; } showDeviceCodePanel(data, apiType); } catch (e) { if (status) status.textContent = 'Failed: ' + e.message; + showToast('Connect failed: ' + e.message); + if (btn) btn.disabled = false; } } function showDeviceCodePanel(deviceData, apiType) { var status = document.getElementById('apConnectStatus'); if (!status) return; + // user_code goes into innerHTML via escHtml AND into a data-attribute on the + // Copy button — the click handler reads from `dataset.code` instead of the + // attribute being interpolated into an inline JS string, so any quote + // characters in a future user_code value cannot break out of the handler. status.innerHTML = '' + '
' + '
1. Open ' + escHtml(deviceData.verification_uri) + '
' - + '
2. Enter code: ' + escHtml(deviceData.user_code) + '
' + + '
2. Enter code: ' + escHtml(deviceData.user_code) + ' ' + + '
' + '
Waiting for authorization…
' + '
'; - if (_repoScopePolling) { clearInterval(_repoScopePolling); _repoScopePolling = null; } - var deadline = Date.now() + (deviceData.expires_in || 900) * 1000; - var intervalMs = (deviceData.interval || 5) * 1000; - _repoScopePolling = setInterval(function() { - if (Date.now() > deadline) { - clearInterval(_repoScopePolling); _repoScopePolling = null; - var st = document.getElementById('apPollStatus'); - if (st) st.textContent = 'Code expired. Click Connect to try again.'; - return; - } - pollRepoScopeOnce(deviceData.device_code, deviceData.scope, apiType); - }, intervalMs); + _stopRepoScopePolling(); + _repoScopeDeadline = Date.now() + (deviceData.expires_in || 900) * 1000; + _repoScopeInterval = (deviceData.interval || 5) * 1000; + _repoScopeDeviceCode = deviceData.device_code; + _repoScopeApiType = apiType; + _repoScopeErrorStreak = 0; + _repoScopePolling = setInterval(_repoScopeTick, _repoScopeInterval); +} + +function _repoScopeTick() { + if (Date.now() > _repoScopeDeadline) { + _stopRepoScopePolling(); + var st = document.getElementById('apPollStatus'); + if (st) st.textContent = 'Code expired. Click Connect to try again.'; + return; + } + pollRepoScopeOnce(); } -async function pollRepoScopeOnce(deviceCode, scope, apiType) { +async function pollRepoScopeOnce() { + var deviceCode = _repoScopeDeviceCode; + var apiType = _repoScopeApiType; try { var resp = await fetch('/api/github/repo-scope/poll-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ device_code: deviceCode, scope: scope }), + body: JSON.stringify({ device_code: deviceCode }), }); var data = await resp.json(); + // Bail out if a different flow took over while this poll was in flight. + if (deviceCode !== _repoScopeDeviceCode) return; if (data.error) { var st = document.getElementById('apPollStatus'); if (st) st.textContent = 'Error: ' + data.error; - clearInterval(_repoScopePolling); _repoScopePolling = null; + _stopRepoScopePolling(); + return; + } + if (data.status === 'pending') { + _repoScopeErrorStreak = 0; + return; + } + if (data.status === 'slow_down') { + // RFC 8628 §3.5: add at least 5s on slow_down and wait the new interval. + _stopRepoScopePolling(); + _repoScopeInterval = _repoScopeInterval + 5000; + _repoScopePolling = setInterval(_repoScopeTick, _repoScopeInterval); return; } - if (data.status === 'pending' || data.status === 'slow_down') return; if (data.status === 'expired') { var st2 = document.getElementById('apPollStatus'); if (st2) st2.textContent = 'Code expired. Click Connect to try again.'; - clearInterval(_repoScopePolling); _repoScopePolling = null; + _stopRepoScopePolling(); return; } if (data.status === 'ok') { - clearInterval(_repoScopePolling); _repoScopePolling = null; + _stopRepoScopePolling(); showToast('GitHub repo access granted'); - // Invalidate cache and reload the relevant tab _githubReposCache.owned = null; _githubReposCache.contributing = null; loadGithubRepos(apiType); } } catch (e) { - // transient — keep polling + // Transient network failures shouldn't kill the flow. Persistent failures + // (3 in a row) surface to the user instead of polling silently forever. + _repoScopeErrorStreak += 1; + if (_repoScopeErrorStreak >= 3) { + _stopRepoScopePolling(); + var st3 = document.getElementById('apPollStatus'); + if (st3) st3.textContent = 'Connection error. Please retry.'; + } } } async function disconnectRepoScope() { - if (!confirm('Disconnect repo-scope GitHub access? You can re-connect any time.')) return; + if (!confirm('Disconnect repo-scope GitHub access locally? You can re-connect any time. To fully revoke the OAuth authorization you must also visit github.com → Settings → Applications.')) return; + // Cancel any in-flight polling first — otherwise a poll could race the + // disconnect and silently restore the token if GitHub returns ok at the + // same moment. + _stopRepoScopePolling(); + _repoScopeDeviceCode = ''; try { - await fetch('/api/github/repo-scope/disconnect', { method: 'POST' }); + var resp = await fetch('/api/github/repo-scope/disconnect', { method: 'POST' }); + var data = await resp.json().catch(function() { return {}; }); _githubReposCache.owned = null; _githubReposCache.contributing = null; - showToast('Repo access disconnected'); + showToast('Repo access disconnected locally'); + if (data.revokeUrl) { + // Nudge the user toward fully revoking on github.com, since clearing + // locally does not invalidate the token at GitHub. + var go = confirm('Also fully revoke the GitHub OAuth authorization?\n\nThis opens github.com so you can disconnect the app there too.'); + if (go) window.open(data.revokeUrl, '_blank', 'noopener'); + } var tab = document.querySelector('.ap-tab.active'); if (tab) addProjectSwitchTab(tab.getAttribute('data-tab')); } catch (e) { diff --git a/src/server.js b/src/server.js index 12ddc6e..ef95201 100644 --- a/src/server.js +++ b/src/server.js @@ -420,7 +420,11 @@ function startServer(host, port, openBrowser = true) { else if (req.method === 'GET' && pathname === '/api/github/profile') { const profile = loadGitHubProfile(); - json(res, profile || { authenticated: false }); + if (!profile) return json(res, { authenticated: false }); + // Never vend raw tokens to the browser. The frontend only needs to know + // *whether* the user is connected and what the display fields are. + const { token: _t, repoToken: _rt, ...safe } = profile; + json(res, safe); } else if (req.method === 'POST' && pathname === '/api/github/logout') { @@ -442,9 +446,9 @@ function startServer(host, port, openBrowser = true) { else if (req.method === 'POST' && pathname === '/api/github/repo-scope/poll-token') { readBody(req, body => { try { - const { device_code, scope } = JSON.parse(body); + const { device_code } = JSON.parse(body); if (!device_code) throw new Error('device_code required'); - githubRepoScopePollToken(device_code, scope) + githubRepoScopePollToken(device_code) .then(data => json(res, data)) .catch(e => json(res, { error: e.message }, 400)); } catch (e) { @@ -466,9 +470,19 @@ function startServer(host, port, openBrowser = true) { } else if (req.method === 'POST' && pathname === '/api/github/repo-scope/disconnect') { - updateGitHubProfile({ repoToken: null, repoTokenScope: null, repoTokenConnectedAt: null }); - log('AUTH', 'Repo-scope GitHub token cleared'); - json(res, { ok: true }); + try { + updateGitHubProfile({ repoToken: null, repoTokenScope: null, repoTokenConnectedAt: null }); + log('AUTH', 'Repo-scope GitHub token cleared locally (GitHub authorization must be revoked manually)'); + // Provide the deep link so the UI can nudge the user to also revoke + // the OAuth authorization on github.com — clearing locally does not + // invalidate the token at GitHub. + json(res, { + ok: true, + revokeUrl: 'https://github.com/settings/connections/applications/' + GITHUB_CLIENT_ID, + }); + } catch (e) { + json(res, { ok: false, error: e.message }, 500); + } } // ── GitHub repos (for project launcher) ──────────────────── @@ -1118,12 +1132,29 @@ async function githubPollToken(deviceCode) { // the resulting token in `repoToken` — kept strictly away from leaderboard // sync. The user must explicitly opt in via the Add Project modal. +// In-memory map of pending device codes → requested scope. Bounded by GitHub's +// 15 min device-code TTL plus a small slack. Survives the lifetime of the +// process only — a restart invalidates all in-flight codes, which is fine. +const _pendingRepoScopeCodes = new Map(); +const PENDING_CODE_TTL = 16 * 60 * 1000; + +function _rememberPendingCode(deviceCode, scope) { + _pendingRepoScopeCodes.set(deviceCode, { scope, exp: Date.now() + PENDING_CODE_TTL }); + // Cheap GC: prune expired entries opportunistically. + for (const [code, meta] of _pendingRepoScopeCodes) { + if (meta.exp < Date.now()) _pendingRepoScopeCodes.delete(code); + } +} + async function githubRepoScopeDeviceCode(publicOnly) { const scope = publicOnly ? 'read:user public_repo' : 'read:user repo'; const data = await githubRequest('github.com', '/login/device/code', 'POST', JSON.stringify({ client_id: GITHUB_CLIENT_ID, scope })); if (data.error) throw new Error(data.error_description || data.error); - log('AUTH', `Repo-scope device code: ${data.user_code} → ${data.verification_uri} (scope=${scope})`); + // Don't log the user_code itself — anyone with log access could authorize + // the device flow against the user's account before they enter it. + log('AUTH', `Repo-scope device code issued (scope=${scope})`); + _rememberPendingCode(data.device_code, scope); return { user_code: data.user_code, verification_uri: data.verification_uri, @@ -1134,22 +1165,34 @@ async function githubRepoScopeDeviceCode(publicOnly) { }; } -async function githubRepoScopePollToken(deviceCode, scope) { +async function githubRepoScopePollToken(deviceCode) { const data = await githubRequest('github.com', '/login/oauth/access_token', 'POST', JSON.stringify({ client_id: GITHUB_CLIENT_ID, device_code: deviceCode, grant_type: 'urn:ietf:params:oauth:grant-type:device_code' })); if (data.error === 'authorization_pending') return { status: 'pending' }; if (data.error === 'slow_down') return { status: 'slow_down' }; - if (data.error === 'expired_token') return { status: 'expired' }; + if (data.error === 'expired_token') { + _pendingRepoScopeCodes.delete(deviceCode); + return { status: 'expired' }; + } if (data.error) throw new Error(data.error_description || data.error); if (!data.access_token) throw new Error('No access token received'); + // Prefer the scope returned by GitHub on the token itself; fall back to the + // scope we recorded when we issued the device code. We deliberately do not + // accept a client-supplied scope to keep the stored label trustworthy. + const requestedScope = (_pendingRepoScopeCodes.get(deviceCode) || {}).scope; + const grantedScope = data.scope + ? String(data.scope).split(',').map(s => s.trim()).join(' ') + : (requestedScope || 'read:user repo'); + _pendingRepoScopeCodes.delete(deviceCode); + updateGitHubProfile({ repoToken: data.access_token, - repoTokenScope: scope || 'read:user repo', + repoTokenScope: grantedScope, repoTokenConnectedAt: new Date().toISOString(), }); - log('AUTH', `Repo-scope GitHub token saved (scope=${scope || 'read:user repo'})`); - return { status: 'ok', scope: scope || 'read:user repo' }; + log('AUTH', `Repo-scope GitHub token saved (scope=${grantedScope})`); + return { status: 'ok', scope: grantedScope }; } function loadGitHubProfile() { @@ -1178,7 +1221,11 @@ function saveGitHubProfile(profile) { const dir = path.dirname(GITHUB_PROFILE_FILE); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); if (profile) { - fs.writeFileSync(GITHUB_PROFILE_FILE, JSON.stringify(profile, null, 2)); + // Atomic write + restrictive perms (owner read/write only). The token file + // would otherwise be world-readable on shared boxes/CI runners. + const tmp = GITHUB_PROFILE_FILE + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(profile, null, 2), { mode: 0o600 }); + fs.renameSync(tmp, GITHUB_PROFILE_FILE); } else { try { fs.unlinkSync(GITHUB_PROFILE_FILE); } catch {} } @@ -1186,10 +1233,24 @@ function saveGitHubProfile(profile) { // Merge partial fields into the existing profile so endpoints that touch // just one slice (e.g. repoToken) don't have to rebuild the whole object. +// Bails on parse error so a corrupt file can't silently erase the user's +// existing leaderboard token. Setting a field to null *removes* it from the +// stored JSON instead of persisting an explicit null. function updateGitHubProfile(partial) { - let current = {}; - try { current = JSON.parse(fs.readFileSync(GITHUB_PROFILE_FILE, 'utf8')) || {}; } catch {} + let current; + if (fs.existsSync(GITHUB_PROFILE_FILE)) { + try { + current = JSON.parse(fs.readFileSync(GITHUB_PROFILE_FILE, 'utf8')) || {}; + } catch (e) { + throw new Error('github profile file is corrupt; refusing to overwrite'); + } + } else { + current = {}; + } const next = { ...current, ...partial }; + for (const key of Object.keys(partial)) { + if (partial[key] === null) delete next[key]; + } saveGitHubProfile(next); return next; }