@jhizzard/termdeck 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
5
5
  "bin": {
6
6
  "termdeck": "./packages/cli/src/index.js"
@@ -48,10 +48,10 @@
48
48
  }
49
49
  }
50
50
 
51
- // RAG indicator
52
- if (state.config.ragEnabled) {
53
- document.getElementById('stat-rag').style.display = '';
54
- }
51
+ // RAG indicator removed (Sprint 9 T2): redundant with health badge which
52
+ // already surfaces mnestra_reachable / mnestra_has_memories per-check.
53
+ // The #stat-rag HTML stub is hidden by default; T1 can strip it from
54
+ // index.html.
55
55
 
56
56
  // Disable AI input bars if Supabase/OpenAI not configured
57
57
  if (!state.config.aiQueryAvailable) {
@@ -2168,6 +2168,145 @@
2168
2168
  try { localStorage.setItem('termdeck:tour:seen', '1'); } catch {}
2169
2169
  }
2170
2170
 
2171
+ // ===== Status / Config dropdowns (Sprint 9 T2) =====
2172
+ // Generic toolbar-button → dropdown factory. Opens below the button,
2173
+ // click-outside closes, re-fetches every open so the data isn't stale.
2174
+ function setupInfoDropdown({ btnId, dropdownId, fetch, render }) {
2175
+ const btn = document.getElementById(btnId);
2176
+ if (!btn) return;
2177
+
2178
+ const dropdown = document.createElement('div');
2179
+ dropdown.className = 'health-dropdown info-dropdown';
2180
+ dropdown.id = dropdownId;
2181
+ dropdown.innerHTML = '<div class="hd-loading">Loading…</div>';
2182
+ document.body.appendChild(dropdown);
2183
+
2184
+ let open = false;
2185
+
2186
+ const close = () => {
2187
+ dropdown.classList.remove('open');
2188
+ open = false;
2189
+ };
2190
+
2191
+ const openDropdown = async () => {
2192
+ const rect = btn.getBoundingClientRect();
2193
+ dropdown.style.top = `${rect.bottom + 4}px`;
2194
+ // Right-align under the button; clamp to viewport
2195
+ const desiredLeft = Math.min(
2196
+ window.innerWidth - 320,
2197
+ Math.max(8, rect.right - 300)
2198
+ );
2199
+ dropdown.style.left = `${desiredLeft}px`;
2200
+ dropdown.innerHTML = '<div class="hd-loading">Loading…</div>';
2201
+ dropdown.classList.add('open');
2202
+ open = true;
2203
+ try {
2204
+ const data = await fetch();
2205
+ // If user closed it while we were fetching, abort
2206
+ if (!open) return;
2207
+ dropdown.innerHTML = render(data);
2208
+ } catch (err) {
2209
+ dropdown.innerHTML = `<div class="hd-empty">Failed to load: ${escapeHtml(err.message || String(err))}</div>`;
2210
+ }
2211
+ };
2212
+
2213
+ btn.addEventListener('click', (e) => {
2214
+ e.stopPropagation();
2215
+ if (open) close(); else openDropdown();
2216
+ });
2217
+ document.addEventListener('click', (e) => {
2218
+ if (open && !dropdown.contains(e.target) && e.target !== btn) close();
2219
+ });
2220
+ document.addEventListener('keydown', (e) => {
2221
+ if (open && e.key === 'Escape') close();
2222
+ });
2223
+ }
2224
+
2225
+ function renderStatusDropdown(data) {
2226
+ const total = data.totalSessions || 0;
2227
+ const byStatus = data.byStatus || {};
2228
+ const byProject = data.byProject || {};
2229
+ const byType = data.byType || {};
2230
+ const uptime = fmtUptime(data.uptime || 0);
2231
+ const heapMB = data.memory && data.memory.heapUsed
2232
+ ? (data.memory.heapUsed / 1024 / 1024).toFixed(1) + ' MB'
2233
+ : '—';
2234
+ const rag = data.ragEnabled ? 'on' : 'off';
2235
+
2236
+ const row = (label, value) => `<div class="hd-check">
2237
+ <span class="hd-icon">·</span>
2238
+ <span class="hd-name">${escapeHtml(label)}</span>
2239
+ <span class="hd-dots"></span>
2240
+ <span class="hd-status">${escapeHtml(String(value))}</span>
2241
+ </div>`;
2242
+
2243
+ const kvBlock = (title, obj) => {
2244
+ const keys = Object.keys(obj);
2245
+ if (keys.length === 0) return '';
2246
+ const lines = keys.map(k => row(k, obj[k])).join('');
2247
+ return `<div class="hd-detail" style="grid-column:1/-1;margin-top:6px;color:var(--tg-text-dim);font-size:10px">${escapeHtml(title)}</div>${lines}`;
2248
+ };
2249
+
2250
+ return row('sessions', total)
2251
+ + row('uptime', uptime)
2252
+ + row('heap', heapMB)
2253
+ + row('rag sync', rag)
2254
+ + kvBlock('by status', byStatus)
2255
+ + kvBlock('by project', byProject)
2256
+ + kvBlock('by type', byType);
2257
+ }
2258
+
2259
+ function renderConfigDropdown(data) {
2260
+ const projects = data.projects || {};
2261
+ const projectCount = Object.keys(projects).length;
2262
+ const defaultTheme = data.defaultTheme || '—';
2263
+ const rag = data.ragEnabled ? 'enabled' : 'disabled';
2264
+ const aiQuery = data.aiQueryAvailable ? 'yes' : 'no';
2265
+
2266
+ const row = (label, value, ok) => {
2267
+ const icon = ok == null ? '·' : (ok ? '✓' : '✗');
2268
+ const cls = ok == null ? '' : (ok ? 'hd-ok' : 'hd-fail');
2269
+ return `<div class="hd-check ${cls}">
2270
+ <span class="hd-icon">${icon}</span>
2271
+ <span class="hd-name">${escapeHtml(label)}</span>
2272
+ <span class="hd-dots"></span>
2273
+ <span class="hd-status">${escapeHtml(String(value))}</span>
2274
+ </div>`;
2275
+ };
2276
+
2277
+ let html = ''
2278
+ + row('projects', projectCount)
2279
+ + row('default theme', defaultTheme)
2280
+ + row('RAG sync', rag, data.ragEnabled)
2281
+ + row('AI query', aiQuery, data.aiQueryAvailable);
2282
+
2283
+ if (projectCount > 0) {
2284
+ html += `<div class="hd-detail" style="grid-column:1/-1;margin-top:6px;color:var(--tg-text-dim);font-size:10px">projects</div>`;
2285
+ for (const [name, cfg] of Object.entries(projects)) {
2286
+ const path = (cfg && cfg.path) || '';
2287
+ html += `<div class="hd-check">
2288
+ <span class="hd-icon">·</span>
2289
+ <span class="hd-name">${escapeHtml(name)}</span>
2290
+ <span class="hd-dots"></span>
2291
+ <span class="hd-status" style="font-size:10px;color:var(--tg-text-dim)">${escapeHtml(path)}</span>
2292
+ </div>`;
2293
+ }
2294
+ }
2295
+
2296
+ html += `<div class="hd-detail" style="grid-column:1/-1;margin-top:8px;color:var(--tg-text-dim);font-size:10px">edit <code>~/.termdeck/config.yaml</code> and restart to apply</div>`;
2297
+ return html;
2298
+ }
2299
+
2300
+ function fmtUptime(sec) {
2301
+ const s = Math.floor(sec);
2302
+ const h = Math.floor(s / 3600);
2303
+ const m = Math.floor((s % 3600) / 60);
2304
+ const rs = s % 60;
2305
+ if (h > 0) return `${h}h ${m}m`;
2306
+ if (m > 0) return `${m}m ${rs}s`;
2307
+ return `${rs}s`;
2308
+ }
2309
+
2171
2310
  // ===== Event Listeners =====
2172
2311
  document.querySelectorAll('.layout-btn').forEach(btn => {
2173
2312
  btn.addEventListener('click', () => setLayout(btn.dataset.layout));
@@ -2189,6 +2328,23 @@
2189
2328
  if (e.key === 'Escape') { e.preventDefault(); closeAddProjectModal(); }
2190
2329
  });
2191
2330
 
2331
+ // Status + config dropdowns (Sprint 9 T2): btn-status/btn-config were
2332
+ // stubs with no listeners. Each now opens a dropdown with live data
2333
+ // fetched from /api/status and /api/config. Reuses .health-dropdown
2334
+ // styling (from T1's style.css) for visual consistency.
2335
+ setupInfoDropdown({
2336
+ btnId: 'btn-status',
2337
+ dropdownId: 'statusDropdown',
2338
+ fetch: () => api('GET', '/api/status'),
2339
+ render: renderStatusDropdown
2340
+ });
2341
+ setupInfoDropdown({
2342
+ btnId: 'btn-config',
2343
+ dropdownId: 'configDropdown',
2344
+ fetch: () => api('GET', '/api/config'),
2345
+ render: renderConfigDropdown
2346
+ });
2347
+
2192
2348
  // Onboarding tour wiring
2193
2349
  document.getElementById('btn-how').addEventListener('click', startTour);
2194
2350
  document.getElementById('tourNextBtn').addEventListener('click', nextTourStep);
@@ -9,41 +9,43 @@
9
9
  </head>
10
10
  <body>
11
11
 
12
- <!-- TOP BAR -->
12
+ <!-- TOP BAR (Sprint 9 T1: two-row layout) -->
13
13
  <div class="topbar">
14
- <div class="topbar-left">
15
- <div class="topbar-logo">
16
- <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
17
- <rect x="1" y="1" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.2"/>
18
- <rect x="10" y="1" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.2"/>
19
- <rect x="1" y="10" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.2"/>
20
- <rect x="10" y="10" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.2"/>
21
- </svg>
22
- TermDeck
14
+ <div class="topbar-row topbar-row-1">
15
+ <div class="topbar-left">
16
+ <div class="topbar-logo">
17
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
18
+ <rect x="1" y="1" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.2"/>
19
+ <rect x="10" y="1" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.2"/>
20
+ <rect x="1" y="10" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.2"/>
21
+ <rect x="10" y="10" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.2"/>
22
+ </svg>
23
+ TermDeck
24
+ </div>
25
+ <div class="topbar-stats" id="globalStats">
26
+ <span class="topbar-stat"><span class="dot" style="background:var(--tg-green)"></span> <span id="stat-active">0</span> active</span>
27
+ <span class="topbar-stat"><span class="dot" style="background:var(--tg-purple)"></span> <span id="stat-thinking">0</span> thinking</span>
28
+ <span class="topbar-stat"><span class="dot" style="background:var(--tg-amber)"></span> <span id="stat-idle">0</span> idle</span>
29
+ <span class="topbar-stat" id="stat-rag" style="display:none">RAG</span>
30
+ <button type="button" class="rumen-badge" id="rumenBadge" title="Rumen insights" aria-haspopup="dialog" aria-controls="rumenModal" aria-label="Open Rumen insights briefing">
31
+ <span class="rb-icon" aria-hidden="true">💡</span>
32
+ <span id="rumenBadgeLabel">0 insights</span>
33
+ </button>
34
+ </div>
23
35
  </div>
24
- <div class="topbar-stats" id="globalStats">
25
- <span class="topbar-stat"><span class="dot" style="background:var(--tg-green)"></span> <span id="stat-active">0</span> active</span>
26
- <span class="topbar-stat"><span class="dot" style="background:var(--tg-purple)"></span> <span id="stat-thinking">0</span> thinking</span>
27
- <span class="topbar-stat"><span class="dot" style="background:var(--tg-amber)"></span> <span id="stat-idle">0</span> idle</span>
28
- <span class="topbar-stat" id="stat-rag" style="display:none">RAG</span>
29
- <button type="button" class="rumen-badge" id="rumenBadge" title="Rumen insights" aria-haspopup="dialog" aria-controls="rumenModal" aria-label="Open Rumen insights briefing">
30
- <span class="rb-icon" aria-hidden="true">💡</span>
31
- <span id="rumenBadgeLabel">0 insights</span>
32
- </button>
33
- </div>
34
- </div>
35
36
 
36
- <div class="topbar-center">
37
- <button class="layout-btn" data-layout="1x1">1x1</button>
38
- <button class="layout-btn active" data-layout="2x1">2x1</button>
39
- <button class="layout-btn" data-layout="2x2">2x2</button>
40
- <button class="layout-btn" data-layout="3x2">3x2</button>
41
- <button class="layout-btn" data-layout="2x4">2x4</button>
42
- <button class="layout-btn" data-layout="4x2">4x2</button>
43
- <button class="layout-btn control-btn" data-layout="control" title="Aggregate activity feed">control</button>
37
+ <div class="topbar-center">
38
+ <button class="layout-btn" data-layout="1x1">1x1</button>
39
+ <button class="layout-btn active" data-layout="2x1">2x1</button>
40
+ <button class="layout-btn" data-layout="2x2">2x2</button>
41
+ <button class="layout-btn" data-layout="3x2">3x2</button>
42
+ <button class="layout-btn" data-layout="2x4">2x4</button>
43
+ <button class="layout-btn" data-layout="4x2">4x2</button>
44
+ <button class="layout-btn control-btn" data-layout="control" title="Aggregate activity feed">control</button>
45
+ </div>
44
46
  </div>
45
47
 
46
- <div class="topbar-right">
48
+ <div class="topbar-row topbar-row-2 topbar-right">
47
49
  <!-- TERMINAL SWITCHER (T1.2 / F1.2): lives in chrome, not over PTY content -->
48
50
  <div class="term-switcher" id="termSwitcher" aria-label="Terminal switcher">
49
51
  <div class="term-switcher-label">Alt+1…9</div>
@@ -54,6 +56,7 @@
54
56
  <button class="topbar-ql-btn" onclick="quickLaunch('claude')" title="Open Claude Code">claude</button>
55
57
  <button class="topbar-ql-btn" onclick="quickLaunch('python3 -m http.server 8080')" title="Open a Python HTTP server on :8080">python</button>
56
58
  </div>
59
+ <div class="topbar-row-2-spacer"></div>
57
60
  <button id="btn-status">status</button>
58
61
  <button id="btn-config">config</button>
59
62
  <button id="btn-how" title="Walkthrough of every TermDeck feature">how this works</button>
@@ -34,26 +34,41 @@
34
34
  flex-direction: column;
35
35
  }
36
36
 
37
- /* ===== TOP BAR ===== */
37
+ /* ===== TOP BAR (Sprint 9 T1: two-row layout) =====
38
+ Row 1 = primary (logo, stats, badges, layout buttons) at 42px.
39
+ Row 2 = controls (quick-launch + chrome buttons) at 32px, dimmer.
40
+ Total ~74px. No horizontal scroll at 1440px. */
38
41
  .topbar {
42
+ display: flex;
43
+ flex-direction: column;
44
+ background: var(--tg-surface);
45
+ border-bottom: 1px solid var(--tg-border);
46
+ flex-shrink: 0;
47
+ min-width: 0;
48
+ }
49
+
50
+ .topbar-row {
39
51
  display: flex;
40
52
  align-items: center;
41
- justify-content: space-between;
42
53
  gap: 10px;
43
54
  padding: 0 12px;
44
- height: 42px;
45
- background: var(--tg-surface);
46
- border-bottom: 1px solid var(--tg-border);
47
55
  flex-shrink: 0;
48
- overflow-x: auto;
49
- overflow-y: hidden;
50
56
  min-width: 0;
51
- scrollbar-width: thin;
52
- flex-wrap: nowrap;
53
57
  }
54
58
 
55
- .topbar::-webkit-scrollbar { height: 4px; }
56
- .topbar::-webkit-scrollbar-thumb { background: var(--tg-border); border-radius: 2px; }
59
+ .topbar-row-1 {
60
+ height: 42px;
61
+ justify-content: space-between;
62
+ }
63
+
64
+ .topbar-row-2 {
65
+ height: 32px;
66
+ background: var(--tg-bg);
67
+ border-top: 1px solid var(--tg-border);
68
+ gap: 6px;
69
+ }
70
+
71
+ .topbar-row-2-spacer { flex: 1 1 auto; min-width: 0; }
57
72
 
58
73
  .topbar-left {
59
74
  display: flex;
@@ -0,0 +1,164 @@
1
+ // Optional token authentication for TermDeck (Sprint 9 T3).
2
+ //
3
+ // When no token is configured, auth is a no-op — `createAuthMiddleware()` returns
4
+ // null and callers skip wiring. When a token is configured (config.auth.token
5
+ // OR the TERMDECK_AUTH_TOKEN env var), every request except /api/health must
6
+ // present the token via one of:
7
+ // - Authorization: Bearer <token>
8
+ // - ?token=<token> query parameter
9
+ // - termdeck_token=<token> cookie
10
+ //
11
+ // Browser requests without a valid token receive a minimal HTML login page that
12
+ // stores the token in a cookie client-side and retries. API requests get a
13
+ // JSON 401.
14
+
15
+ function getConfiguredToken(config) {
16
+ const fromConfig = config && config.auth && config.auth.token;
17
+ if (typeof fromConfig === 'string' && fromConfig.trim()) return fromConfig.trim();
18
+ const fromEnv = process.env.TERMDECK_AUTH_TOKEN;
19
+ if (typeof fromEnv === 'string' && fromEnv.trim()) return fromEnv.trim();
20
+ return null;
21
+ }
22
+
23
+ function readCookieToken(cookieHeader) {
24
+ if (!cookieHeader || typeof cookieHeader !== 'string') return null;
25
+ const parts = cookieHeader.split(';');
26
+ for (const part of parts) {
27
+ const eq = part.indexOf('=');
28
+ if (eq === -1) continue;
29
+ const name = part.slice(0, eq).trim();
30
+ if (name !== 'termdeck_token') continue;
31
+ try {
32
+ return decodeURIComponent(part.slice(eq + 1).trim());
33
+ } catch (err) {
34
+ return null;
35
+ }
36
+ }
37
+ return null;
38
+ }
39
+
40
+ function extractToken(req) {
41
+ const header = req.headers && req.headers['authorization'];
42
+ if (typeof header === 'string') {
43
+ const stripped = header.replace(/^Bearer\s+/i, '').trim();
44
+ if (stripped) return stripped;
45
+ }
46
+ if (req.query && typeof req.query.token === 'string' && req.query.token) {
47
+ return req.query.token;
48
+ }
49
+ // Fallback for callers that did not parse query (e.g. WS upgrade path).
50
+ if (!req.query && req.url) {
51
+ try {
52
+ const host = (req.headers && req.headers.host) || 'localhost';
53
+ const parsed = new URL(req.url, `http://${host}`);
54
+ const q = parsed.searchParams.get('token');
55
+ if (q) return q;
56
+ } catch (err) {
57
+ // malformed URL — fall through
58
+ }
59
+ }
60
+ const cookie = req.headers && req.headers.cookie;
61
+ const fromCookie = readCookieToken(cookie);
62
+ if (fromCookie) return fromCookie;
63
+ return null;
64
+ }
65
+
66
+ function loginPage() {
67
+ return `<!doctype html>
68
+ <html lang="en">
69
+ <head>
70
+ <meta charset="utf-8">
71
+ <meta name="viewport" content="width=device-width, initial-scale=1">
72
+ <title>TermDeck — Sign in</title>
73
+ <style>
74
+ html, body { height: 100%; }
75
+ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
76
+ background: #0d1117; color: #c9d1d9; display: flex; align-items: center; justify-content: center; }
77
+ form { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 28px 32px;
78
+ width: 320px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); }
79
+ h1 { margin: 0 0 6px; font-size: 18px; }
80
+ p { margin: 0 0 18px; color: #8b949e; font-size: 13px; }
81
+ label { display: block; font-size: 12px; margin-bottom: 6px; color: #8b949e; }
82
+ input { width: 100%; padding: 8px 10px; border: 1px solid #30363d; border-radius: 6px;
83
+ background: #0d1117; color: #c9d1d9; font: 13px ui-monospace, monospace; box-sizing: border-box; }
84
+ input:focus { outline: 1px solid #1f6feb; }
85
+ button { margin-top: 16px; width: 100%; padding: 8px; background: #238636; color: #fff;
86
+ border: 0; border-radius: 6px; font-weight: 600; cursor: pointer; font-size: 13px; }
87
+ button:hover { background: #2ea043; }
88
+ .err { color: #f85149; font-size: 12px; margin-top: 10px; min-height: 15px; }
89
+ </style>
90
+ </head>
91
+ <body>
92
+ <form onsubmit="return submitToken(event)">
93
+ <h1>TermDeck</h1>
94
+ <p>Enter the access token to continue.</p>
95
+ <label for="t">Access token</label>
96
+ <input id="t" type="password" autocomplete="current-password" autofocus required>
97
+ <button type="submit">Sign in</button>
98
+ <div class="err" id="err"></div>
99
+ </form>
100
+ <script>
101
+ function submitToken(e) {
102
+ e.preventDefault();
103
+ var err = document.getElementById('err');
104
+ err.textContent = '';
105
+ var t = document.getElementById('t').value.trim();
106
+ if (!t) return false;
107
+ document.cookie = 'termdeck_token=' + encodeURIComponent(t) +
108
+ '; path=/; SameSite=Strict; Max-Age=2592000';
109
+ var next = new URLSearchParams(location.search).get('next') || '/';
110
+ fetch('/api/config', { credentials: 'same-origin' }).then(function(r) {
111
+ if (r.ok) { location.href = next; return; }
112
+ document.cookie = 'termdeck_token=; path=/; Max-Age=0';
113
+ err.textContent = 'Invalid token.';
114
+ }).catch(function() {
115
+ err.textContent = 'Network error.';
116
+ });
117
+ return false;
118
+ }
119
+ </script>
120
+ </body>
121
+ </html>`;
122
+ }
123
+
124
+ function createAuthMiddleware(config) {
125
+ const token = getConfiguredToken(config);
126
+ if (!token) return null;
127
+
128
+ return function authMiddleware(req, res, next) {
129
+ // Health check stays open so external monitors can verify liveness
130
+ // without being handed a secret.
131
+ if (req.path === '/api/health') return next();
132
+
133
+ const provided = extractToken(req);
134
+ if (provided && provided === token) return next();
135
+
136
+ // API clients always get JSON; browsers get the login page.
137
+ if (req.path.startsWith('/api/')) {
138
+ return res.status(401).json({ error: 'unauthorized' });
139
+ }
140
+ if (req.accepts && req.accepts('html')) {
141
+ res.status(401);
142
+ res.set('Content-Type', 'text/html; charset=utf-8');
143
+ return res.send(loginPage());
144
+ }
145
+ return res.status(401).json({ error: 'unauthorized' });
146
+ };
147
+ }
148
+
149
+ // Verify a WebSocket upgrade request. Used by the WS connection handler
150
+ // (Express middleware does not run on upgrades). Returns true if no token is
151
+ // configured OR the request presents a matching token.
152
+ function verifyWebSocketUpgrade(config, req) {
153
+ const token = getConfiguredToken(config);
154
+ if (!token) return true;
155
+ const provided = extractToken(req);
156
+ return !!provided && provided === token;
157
+ }
158
+
159
+ module.exports = {
160
+ createAuthMiddleware,
161
+ verifyWebSocketUpgrade,
162
+ getConfiguredToken,
163
+ loginPage
164
+ };
@@ -60,6 +60,7 @@ const { TranscriptWriter } = require('./transcripts');
60
60
  const { createHealthHandler } = require('./preflight');
61
61
  const { themes, statusColors } = require('./themes');
62
62
  const { loadConfig, addProject } = require('./config');
63
+ const { createAuthMiddleware, verifyWebSocketUpgrade } = require('./auth');
63
64
 
64
65
  function createServer(config) {
65
66
  const app = express();
@@ -68,6 +69,15 @@ function createServer(config) {
68
69
 
69
70
  app.use(express.json());
70
71
 
72
+ // Optional token auth (Sprint 9 T3). Zero-op when no token is configured,
73
+ // so local users see no behavior change. Mounted before static + routes so
74
+ // unauthenticated requests never touch app.js / index.html.
75
+ const authMiddleware = createAuthMiddleware(config);
76
+ if (authMiddleware) {
77
+ app.use(authMiddleware);
78
+ console.log('[auth] Token authentication enabled');
79
+ }
80
+
71
81
  // Serve client files
72
82
  const clientDir = path.join(__dirname, '..', '..', 'client', 'public');
73
83
  app.use(express.static(clientDir));
@@ -740,6 +750,13 @@ function createServer(config) {
740
750
  // ==================== WebSocket ====================
741
751
 
742
752
  wss.on('connection', (ws, req) => {
753
+ // Optional token auth for WS upgrades (Sprint 9 T3). Express middleware
754
+ // does not run on the upgrade path, so the check has to live here.
755
+ if (!verifyWebSocketUpgrade(config, req)) {
756
+ ws.close(4003, 'Unauthorized');
757
+ return;
758
+ }
759
+
743
760
  const url = new URL(req.url, `http://${req.headers.host}`);
744
761
  const sessionId = url.searchParams.get('session');
745
762