@jhizzard/termdeck 0.3.2 → 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/README.md CHANGED
@@ -22,6 +22,16 @@ Enabling Flashback takes **one additional 15-minute setup step** — see Tier 2
22
22
 
23
23
  ---
24
24
 
25
+ ## Documentation hierarchy
26
+
27
+ - **This README** — quickstart, pitch, and links
28
+ - **[docs/GETTING-STARTED.md](docs/GETTING-STARTED.md)** — full 4-tier installation guide
29
+ - **[termdeck-docs.vercel.app](https://termdeck-docs.vercel.app)** — reference docs (Astro/Starlight)
30
+ - **docs/launch/** — launch collateral (Show HN, Twitter, etc.)
31
+ - **docs/sprint-N-*/** — historical sprint logs (append-only, not maintained post-sprint)
32
+
33
+ ---
34
+
25
35
  ## How Flashback works
26
36
 
27
37
  When a panel's status transitions to `errored`, the server's output analyzer fires an event. The mnestra bridge takes the session context (type, project, last command, error tail) and queries your Mnestra memory store for the top similar match. If it finds one above the relevance threshold, the result is pushed to the panel's WebSocket as a `proactive_memory` message. The client renders it as a toast anchored to the panel, showing the match's project tag, source type, similarity score, and content snippet. You click the toast to expand into the Memory tab of that panel's drawer.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck",
3
- "version": "0.3.2",
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,22 +34,47 @@
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 {
39
42
  display: flex;
40
- align-items: center;
41
- justify-content: space-between;
42
- padding: 0 16px;
43
- height: 42px;
43
+ flex-direction: column;
44
44
  background: var(--tg-surface);
45
45
  border-bottom: 1px solid var(--tg-border);
46
46
  flex-shrink: 0;
47
+ min-width: 0;
48
+ }
49
+
50
+ .topbar-row {
51
+ display: flex;
52
+ align-items: center;
53
+ gap: 10px;
54
+ padding: 0 12px;
55
+ flex-shrink: 0;
56
+ min-width: 0;
57
+ }
58
+
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;
47
69
  }
48
70
 
71
+ .topbar-row-2-spacer { flex: 1 1 auto; min-width: 0; }
72
+
49
73
  .topbar-left {
50
74
  display: flex;
51
75
  align-items: center;
52
76
  gap: 12px;
77
+ flex-shrink: 0;
53
78
  }
54
79
 
55
80
  .topbar-logo {
@@ -66,7 +91,7 @@
66
91
 
67
92
  .topbar-stats {
68
93
  display: flex;
69
- gap: 16px;
94
+ gap: 10px;
70
95
  font-size: 11px;
71
96
  color: var(--tg-text-dim);
72
97
  }
@@ -84,6 +109,7 @@
84
109
  background: var(--tg-bg);
85
110
  padding: 3px;
86
111
  border-radius: var(--tg-radius-sm);
112
+ flex-shrink: 0;
87
113
  }
88
114
 
89
115
  .layout-btn {
@@ -104,7 +130,8 @@
104
130
  .topbar-right {
105
131
  display: flex;
106
132
  align-items: center;
107
- gap: 8px;
133
+ gap: 4px;
134
+ flex-shrink: 0;
108
135
  }
109
136
 
110
137
  .topbar-right button {
@@ -112,7 +139,7 @@
112
139
  border: 1px solid var(--tg-border);
113
140
  color: var(--tg-text-dim);
114
141
  font-size: 11px;
115
- padding: 4px 12px;
142
+ padding: 4px 8px;
116
143
  border-radius: var(--tg-radius-sm);
117
144
  cursor: pointer;
118
145
  font-family: var(--tg-sans);
@@ -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
+ };
@@ -20,9 +20,17 @@ try { pg = require('pg'); } catch { pg = null; }
20
20
  // servers without DATABASE_URL never pay the connection cost.
21
21
  let _rumenPool = null;
22
22
  let _rumenPoolFailed = false;
23
+ let _rumenPoolFailedAt = 0;
24
+ const RUMEN_POOL_RETRY_MS = 30_000;
23
25
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
24
26
  function getRumenPool() {
25
- if (_rumenPool || _rumenPoolFailed) return _rumenPool;
27
+ if (_rumenPool) return _rumenPool;
28
+ if (_rumenPoolFailed) {
29
+ if (Date.now() - _rumenPoolFailedAt < RUMEN_POOL_RETRY_MS) return null;
30
+ console.warn('[rumen] retrying pool creation after 30s cooldown');
31
+ _rumenPoolFailed = false;
32
+ _rumenPoolFailedAt = 0;
33
+ }
26
34
  if (!pg || !process.env.DATABASE_URL) return null;
27
35
  try {
28
36
  _rumenPool = new pg.Pool({
@@ -38,6 +46,7 @@ function getRumenPool() {
38
46
  } catch (err) {
39
47
  console.warn('[rumen] failed to create pg pool:', err.message);
40
48
  _rumenPoolFailed = true;
49
+ _rumenPoolFailedAt = Date.now();
41
50
  return null;
42
51
  }
43
52
  }
@@ -51,6 +60,7 @@ const { TranscriptWriter } = require('./transcripts');
51
60
  const { createHealthHandler } = require('./preflight');
52
61
  const { themes, statusColors } = require('./themes');
53
62
  const { loadConfig, addProject } = require('./config');
63
+ const { createAuthMiddleware, verifyWebSocketUpgrade } = require('./auth');
54
64
 
55
65
  function createServer(config) {
56
66
  const app = express();
@@ -59,6 +69,15 @@ function createServer(config) {
59
69
 
60
70
  app.use(express.json());
61
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
+
62
81
  // Serve client files
63
82
  const clientDir = path.join(__dirname, '..', '..', 'client', 'public');
64
83
  app.use(express.static(clientDir));
@@ -731,6 +750,13 @@ function createServer(config) {
731
750
  // ==================== WebSocket ====================
732
751
 
733
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
+
734
760
  const url = new URL(req.url, `http://${req.headers.host}`);
735
761
  const sessionId = url.searchParams.get('session');
736
762
 
@@ -23,12 +23,12 @@ const CACHE_TTL_MS = 60_000;
23
23
  async function checkMnestra(config) {
24
24
  const rag = config.rag || {};
25
25
  const url = rag.mnestraWebhookUrl
26
- ? rag.mnestraWebhookUrl.replace(/\/mnestra\/?$/, '/health')
27
- : 'http://localhost:37778/health';
26
+ ? rag.mnestraWebhookUrl.replace(/\/mnestra\/?$/, '/healthz')
27
+ : 'http://localhost:37778/healthz';
28
28
 
29
29
  const body = await httpGet(url, 3000);
30
30
  const data = tryParseJSON(body);
31
- const total = data && (data.total || data.memories || data.count);
31
+ const total = data && (data.store?.rows ?? data.total ?? data.memories ?? data.count ?? null);
32
32
  if (total != null) {
33
33
  return { name: 'mnestra_reachable', passed: true, detail: `${Number(total).toLocaleString()} memories` };
34
34
  }
@@ -45,9 +45,9 @@ async function checkMnestraMemories(config) {
45
45
  ? rag.mnestraWebhookUrl.replace(/\/mnestra\/?$/, '')
46
46
  : 'http://localhost:37778';
47
47
 
48
- const body = await httpGet(`${baseUrl}/health`, 3000);
48
+ const body = await httpGet(`${baseUrl}/healthz`, 3000);
49
49
  const data = tryParseJSON(body);
50
- const total = data && (data.total || data.memories || data.count);
50
+ const total = data && (data.store?.rows ?? data.total ?? data.memories ?? data.count ?? null);
51
51
  if (total != null && Number(total) > 0) {
52
52
  return { name: 'mnestra_has_memories', passed: true, detail: `${Number(total).toLocaleString()} memories loaded` };
53
53
  }