@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 +10 -0
- package/package.json +1 -1
- package/packages/client/public/app.js +160 -4
- package/packages/client/public/index.html +33 -30
- package/packages/client/public/style.css +35 -8
- package/packages/server/src/auth.js +164 -0
- package/packages/server/src/index.js +27 -1
- package/packages/server/src/preflight.js +5 -5
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.
|
|
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
|
-
|
|
53
|
-
|
|
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-
|
|
15
|
-
<div class="topbar-
|
|
16
|
-
<
|
|
17
|
-
<
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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\/?$/, '/
|
|
27
|
-
: 'http://localhost:37778/
|
|
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
|
|
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}/
|
|
48
|
+
const body = await httpGet(`${baseUrl}/healthz`, 3000);
|
|
49
49
|
const data = tryParseJSON(body);
|
|
50
|
-
const total = data && (data.total
|
|
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
|
}
|