speclock 3.0.0 → 3.5.1

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.
@@ -0,0 +1,338 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>SpecLock Dashboard</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0f172a; --surface: #1e293b; --border: #334155;
10
+ --text: #e2e8f0; --muted: #94a3b8; --accent: #3b82f6;
11
+ --green: #22c55e; --red: #ef4444; --yellow: #eab308; --purple: #a855f7;
12
+ }
13
+ * { box-sizing: border-box; margin: 0; padding: 0; }
14
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
15
+
16
+ /* Header */
17
+ .header { display: flex; align-items: center; justify-content: space-between; padding: 16px 24px; background: var(--surface); border-bottom: 1px solid var(--border); }
18
+ .header h1 { font-size: 20px; font-weight: 700; }
19
+ .header h1 span { color: var(--accent); }
20
+ .header .meta { font-size: 13px; color: var(--muted); }
21
+ .status-badge { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
22
+ .status-badge.healthy { background: rgba(34,197,94,0.15); color: var(--green); }
23
+ .status-badge.warning { background: rgba(234,179,8,0.15); color: var(--yellow); }
24
+
25
+ /* Grid */
26
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; padding: 24px; }
27
+ .card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 20px; }
28
+ .card h2 { font-size: 14px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; }
29
+ .card .big-number { font-size: 36px; font-weight: 700; line-height: 1; }
30
+ .card .sub { font-size: 13px; color: var(--muted); margin-top: 4px; }
31
+
32
+ /* Stats row */
33
+ .stats-row { display: flex; gap: 12px; margin-top: 12px; }
34
+ .stat { flex: 1; text-align: center; padding: 8px; background: rgba(255,255,255,0.03); border-radius: 8px; }
35
+ .stat .val { font-size: 20px; font-weight: 700; }
36
+ .stat .label { font-size: 11px; color: var(--muted); margin-top: 2px; }
37
+
38
+ /* Table */
39
+ .table-card { grid-column: span 2; }
40
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
41
+ th { text-align: left; padding: 8px 12px; color: var(--muted); font-weight: 600; border-bottom: 1px solid var(--border); }
42
+ td { padding: 8px 12px; border-bottom: 1px solid rgba(51,65,85,0.5); }
43
+ tr:hover td { background: rgba(255,255,255,0.02); }
44
+
45
+ /* Badge */
46
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
47
+ .badge.high { background: rgba(239,68,68,0.2); color: var(--red); }
48
+ .badge.medium { background: rgba(234,179,8,0.2); color: var(--yellow); }
49
+ .badge.low { background: rgba(34,197,94,0.2); color: var(--green); }
50
+ .badge.active { background: rgba(59,130,246,0.2); color: var(--accent); }
51
+ .badge.inactive { background: rgba(148,163,184,0.2); color: var(--muted); }
52
+
53
+ /* Chart bar */
54
+ .bar-chart { display: flex; align-items: flex-end; gap: 4px; height: 80px; margin-top: 12px; }
55
+ .bar { flex: 1; background: var(--accent); border-radius: 3px 3px 0 0; min-height: 2px; position: relative; transition: height 0.3s; }
56
+ .bar:hover { opacity: 0.8; }
57
+ .bar .tip { position: absolute; top: -20px; left: 50%; transform: translateX(-50%); font-size: 10px; color: var(--muted); white-space: nowrap; display: none; }
58
+ .bar:hover .tip { display: block; }
59
+ .bar-labels { display: flex; gap: 4px; margin-top: 4px; }
60
+ .bar-labels span { flex: 1; text-align: center; font-size: 10px; color: var(--muted); }
61
+
62
+ /* Sections */
63
+ .full-width { grid-column: 1 / -1; }
64
+ .section-title { padding: 24px 24px 0; font-size: 16px; font-weight: 700; color: var(--muted); }
65
+
66
+ /* Loading */
67
+ .loading { text-align: center; padding: 40px; color: var(--muted); }
68
+ .error { color: var(--red); text-align: center; padding: 20px; }
69
+
70
+ /* Refresh button */
71
+ .refresh-btn { background: var(--accent); color: white; border: none; padding: 6px 14px; border-radius: 6px; font-size: 12px; cursor: pointer; }
72
+ .refresh-btn:hover { opacity: 0.9; }
73
+
74
+ /* Timeline */
75
+ .timeline { list-style: none; }
76
+ .timeline li { padding: 8px 0; border-bottom: 1px solid rgba(51,65,85,0.3); display: flex; gap: 12px; align-items: flex-start; }
77
+ .timeline .time { font-size: 11px; color: var(--muted); min-width: 130px; }
78
+ .timeline .event-type { font-size: 11px; font-weight: 600; min-width: 100px; }
79
+ .timeline .event-summary { font-size: 13px; }
80
+
81
+ @media (max-width: 768px) {
82
+ .grid { grid-template-columns: 1fr; }
83
+ .table-card { grid-column: span 1; }
84
+ }
85
+ </style>
86
+ </head>
87
+ <body>
88
+
89
+ <div class="header">
90
+ <div>
91
+ <h1><span>SpecLock</span> Dashboard</h1>
92
+ <div class="meta">v3.5.0 &mdash; AI Constraint Engine</div>
93
+ </div>
94
+ <div style="display:flex;align-items:center;gap:12px;">
95
+ <span id="health-badge" class="status-badge healthy">Loading...</span>
96
+ <button class="refresh-btn" onclick="loadAll()">Refresh</button>
97
+ </div>
98
+ </div>
99
+
100
+ <!-- Overview Cards -->
101
+ <div class="grid">
102
+ <div class="card">
103
+ <h2>Active Locks</h2>
104
+ <div class="big-number" id="lock-count">-</div>
105
+ <div class="sub" id="lock-sub"></div>
106
+ </div>
107
+ <div class="card">
108
+ <h2>Decisions</h2>
109
+ <div class="big-number" id="decision-count">-</div>
110
+ </div>
111
+ <div class="card">
112
+ <h2>Events</h2>
113
+ <div class="big-number" id="event-count">-</div>
114
+ <div class="sub" id="event-sub"></div>
115
+ </div>
116
+ <div class="card">
117
+ <h2>Violations Blocked</h2>
118
+ <div class="big-number" id="violation-count">-</div>
119
+ <div class="stats-row">
120
+ <div class="stat"><div class="val" id="v-blocked" style="color:var(--red)">-</div><div class="label">Blocked</div></div>
121
+ <div class="stat"><div class="val" id="v-advisory" style="color:var(--yellow)">-</div><div class="label">Advisory</div></div>
122
+ <div class="stat"><div class="val" id="v-overrides" style="color:var(--purple)">-</div><div class="label">Overrides</div></div>
123
+ </div>
124
+ </div>
125
+ </div>
126
+
127
+ <!-- Enforcement & Security -->
128
+ <div class="section-title">Enforcement & Security</div>
129
+ <div class="grid">
130
+ <div class="card">
131
+ <h2>Enforcement Mode</h2>
132
+ <div class="big-number" id="enforce-mode" style="text-transform:uppercase">-</div>
133
+ <div class="sub" id="enforce-threshold"></div>
134
+ </div>
135
+ <div class="card">
136
+ <h2>Authentication</h2>
137
+ <div class="big-number" id="auth-status">-</div>
138
+ <div class="sub" id="auth-keys"></div>
139
+ </div>
140
+ <div class="card">
141
+ <h2>Encryption</h2>
142
+ <div class="big-number" id="encrypt-status">-</div>
143
+ <div class="sub" id="encrypt-algo"></div>
144
+ </div>
145
+ <div class="card">
146
+ <h2>Audit Chain</h2>
147
+ <div class="big-number" id="audit-status">-</div>
148
+ <div class="sub" id="audit-events"></div>
149
+ </div>
150
+ </div>
151
+
152
+ <!-- Active Locks Table -->
153
+ <div class="section-title">Active Locks</div>
154
+ <div class="grid">
155
+ <div class="card table-card">
156
+ <table>
157
+ <thead><tr><th>ID</th><th>Constraint</th><th>Source</th><th>Tags</th><th>Created</th></tr></thead>
158
+ <tbody id="locks-table"><tr><td colspan="5" class="loading">Loading...</td></tr></tbody>
159
+ </table>
160
+ </div>
161
+ </div>
162
+
163
+ <!-- Recent Events Timeline -->
164
+ <div class="section-title">Recent Events</div>
165
+ <div class="grid">
166
+ <div class="card full-width">
167
+ <ul class="timeline" id="events-timeline">
168
+ <li class="loading">Loading...</li>
169
+ </ul>
170
+ </div>
171
+ </div>
172
+
173
+ <!-- Sessions -->
174
+ <div class="section-title">Session History</div>
175
+ <div class="grid">
176
+ <div class="card table-card">
177
+ <table>
178
+ <thead><tr><th>Tool</th><th>Started</th><th>Duration</th><th>Events</th><th>Summary</th></tr></thead>
179
+ <tbody id="sessions-table"><tr><td colspan="5" class="loading">Loading...</td></tr></tbody>
180
+ </table>
181
+ </div>
182
+ </div>
183
+
184
+ <div style="text-align:center;padding:24px;color:var(--muted);font-size:12px;">
185
+ SpecLock v3.5.0 &mdash; Developed by Sandeep Roy &mdash; <a href="https://github.com/sgroy10/speclock" style="color:var(--accent)">GitHub</a>
186
+ </div>
187
+
188
+ <script>
189
+ const API_BASE = window.location.origin;
190
+
191
+ async function apiFetch(path) {
192
+ try {
193
+ const res = await fetch(`${API_BASE}${path}`);
194
+ if (!res.ok) throw new Error(`${res.status}`);
195
+ return await res.json();
196
+ } catch (e) {
197
+ console.error(`API error ${path}:`, e);
198
+ return null;
199
+ }
200
+ }
201
+
202
+ async function loadAll() {
203
+ loadHealth();
204
+ loadContext();
205
+ loadEvents();
206
+ }
207
+
208
+ async function loadHealth() {
209
+ const h = await apiFetch('/health');
210
+ const badge = document.getElementById('health-badge');
211
+ if (h) {
212
+ badge.textContent = h.status === 'healthy' ? 'Healthy' : 'Warning';
213
+ badge.className = `status-badge ${h.status === 'healthy' ? 'healthy' : 'warning'}`;
214
+ document.getElementById('audit-status').textContent = h.auditChain === 'valid' ? 'Valid' : h.auditChain;
215
+ document.getElementById('audit-status').style.color = h.auditChain === 'valid' ? 'var(--green)' : 'var(--red)';
216
+ document.getElementById('auth-status').textContent = h.authEnabled ? 'Enabled' : 'Disabled';
217
+ document.getElementById('auth-status').style.color = h.authEnabled ? 'var(--green)' : 'var(--muted)';
218
+ } else {
219
+ badge.textContent = 'Error';
220
+ badge.className = 'status-badge warning';
221
+ }
222
+ }
223
+
224
+ async function loadContext() {
225
+ // Use a direct brain fetch via the dashboard API
226
+ const data = await apiFetch('/dashboard/api/brain');
227
+ if (!data) return;
228
+
229
+ const brain = data;
230
+ const activeLocks = (brain.specLock?.items || []).filter(l => l.active !== false);
231
+
232
+ // Overview
233
+ document.getElementById('lock-count').textContent = activeLocks.length;
234
+ document.getElementById('lock-sub').textContent = `${brain.specLock?.items?.length || 0} total (${activeLocks.length} active)`;
235
+ document.getElementById('decision-count').textContent = brain.decisions?.length || 0;
236
+ document.getElementById('event-count').textContent = brain.events?.count || 0;
237
+ document.getElementById('event-sub').textContent = `Last: ${brain.events?.lastEventId || 'none'}`;
238
+
239
+ // Violations
240
+ const violations = brain.state?.violations || [];
241
+ document.getElementById('violation-count').textContent = violations.length;
242
+ document.getElementById('v-blocked').textContent = violations.filter(v => v.blocked).length;
243
+ document.getElementById('v-advisory').textContent = violations.filter(v => !v.blocked).length;
244
+
245
+ // Overrides count
246
+ let overrideCount = 0;
247
+ for (const lock of (brain.specLock?.items || [])) {
248
+ overrideCount += (lock.overrides || []).length;
249
+ }
250
+ document.getElementById('v-overrides').textContent = overrideCount;
251
+
252
+ // Enforcement
253
+ const enforcement = brain.enforcement || { mode: 'advisory', blockThreshold: 70 };
254
+ document.getElementById('enforce-mode').textContent = enforcement.mode;
255
+ document.getElementById('enforce-mode').style.color = enforcement.mode === 'hard' ? 'var(--red)' : 'var(--yellow)';
256
+ document.getElementById('enforce-threshold').textContent = `Block threshold: ${enforcement.blockThreshold}%`;
257
+
258
+ // Encryption
259
+ document.getElementById('encrypt-status').textContent = data._encryption ? 'Enabled' : 'Disabled';
260
+ document.getElementById('encrypt-status').style.color = data._encryption ? 'var(--green)' : 'var(--muted)';
261
+ document.getElementById('encrypt-algo').textContent = data._encryption ? 'AES-256-GCM' : 'Set SPECLOCK_ENCRYPTION_KEY to enable';
262
+
263
+ // Auth keys
264
+ document.getElementById('auth-keys').textContent = data._authKeys ? `${data._authKeys} active key(s)` : '';
265
+
266
+ // Locks table
267
+ const locksBody = document.getElementById('locks-table');
268
+ if (activeLocks.length === 0) {
269
+ locksBody.innerHTML = '<tr><td colspan="5" style="color:var(--muted)">No active locks</td></tr>';
270
+ } else {
271
+ locksBody.innerHTML = activeLocks.map(l => `
272
+ <tr>
273
+ <td><code>${l.id}</code></td>
274
+ <td>${escHtml(l.text)}</td>
275
+ <td><span class="badge active">${l.source || 'agent'}</span></td>
276
+ <td>${(l.tags || []).map(t => `<span class="badge low">${t}</span>`).join(' ') || '-'}</td>
277
+ <td>${(l.createdAt || '').substring(0, 16)}</td>
278
+ </tr>
279
+ `).join('');
280
+ }
281
+
282
+ // Sessions table
283
+ const sessions = brain.sessions?.history || [];
284
+ const sessBody = document.getElementById('sessions-table');
285
+ if (sessions.length === 0) {
286
+ sessBody.innerHTML = '<tr><td colspan="5" style="color:var(--muted)">No sessions yet</td></tr>';
287
+ } else {
288
+ sessBody.innerHTML = sessions.slice(0, 20).map(s => {
289
+ const dur = s.startedAt && s.endedAt ? Math.round((new Date(s.endedAt) - new Date(s.startedAt)) / 60000) + ' min' : '-';
290
+ return `
291
+ <tr>
292
+ <td><span class="badge active">${s.toolUsed || 'unknown'}</span></td>
293
+ <td>${(s.startedAt || '').substring(0, 16)}</td>
294
+ <td>${dur}</td>
295
+ <td>${s.eventsInSession || 0}</td>
296
+ <td>${escHtml((s.summary || '').substring(0, 80))}</td>
297
+ </tr>
298
+ `;
299
+ }).join('');
300
+ }
301
+ }
302
+
303
+ async function loadEvents() {
304
+ const data = await apiFetch('/dashboard/api/events');
305
+ const timeline = document.getElementById('events-timeline');
306
+ if (!data || !data.events || data.events.length === 0) {
307
+ timeline.innerHTML = '<li style="color:var(--muted)">No events yet</li>';
308
+ return;
309
+ }
310
+
311
+ timeline.innerHTML = data.events.slice(0, 30).map(e => `
312
+ <li>
313
+ <span class="time">${(e.at || '').substring(0, 19)}</span>
314
+ <span class="event-type badge ${getEventColor(e.type)}">${e.type}</span>
315
+ <span class="event-summary">${escHtml(e.summary || e.eventId || '')}</span>
316
+ </li>
317
+ `).join('');
318
+ }
319
+
320
+ function getEventColor(type) {
321
+ if (type?.includes('lock') || type?.includes('violation')) return 'high';
322
+ if (type?.includes('session') || type?.includes('decision')) return 'medium';
323
+ return 'low';
324
+ }
325
+
326
+ function escHtml(s) {
327
+ const d = document.createElement('div');
328
+ d.textContent = s || '';
329
+ return d.innerHTML;
330
+ }
331
+
332
+ // Auto-refresh every 30 seconds
333
+ loadAll();
334
+ setInterval(loadAll, 30000);
335
+ </script>
336
+
337
+ </body>
338
+ </html>
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import os from "os";
8
+ import fs from "fs";
8
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
10
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
10
11
  import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
@@ -63,9 +64,34 @@ import {
63
64
  disableAuth,
64
65
  TOOL_PERMISSIONS,
65
66
  } from "../core/auth.js";
67
+ import { isEncryptionEnabled } from "../core/crypto.js";
68
+ import {
69
+ evaluatePolicy,
70
+ listPolicyRules,
71
+ addPolicyRule,
72
+ removePolicyRule,
73
+ initPolicy,
74
+ exportPolicy,
75
+ importPolicy,
76
+ } from "../core/policy.js";
77
+ import {
78
+ isTelemetryEnabled,
79
+ trackToolUsage,
80
+ getTelemetrySummary,
81
+ } from "../core/telemetry.js";
82
+ import {
83
+ isSSOEnabled,
84
+ getAuthorizationUrl,
85
+ handleCallback as ssoHandleCallback,
86
+ validateSession,
87
+ revokeSession,
88
+ listSessions,
89
+ } from "../core/sso.js";
90
+ import { fileURLToPath } from "url";
91
+ import _path from "path";
66
92
 
67
93
  const PROJECT_ROOT = process.env.SPECLOCK_PROJECT_ROOT || process.cwd();
68
- const VERSION = "3.0.0";
94
+ const VERSION = "3.5.0";
69
95
  const AUTHOR = "Sandeep Roy";
70
96
  const START_TIME = Date.now();
71
97
 
@@ -410,7 +436,7 @@ function createSpecLockServer() {
410
436
  const app = createMcpExpressApp({ host: "0.0.0.0" });
411
437
 
412
438
  // CORS preflight handler
413
- app.options("*", (req, res) => {
439
+ app.options("/{*path}", (req, res) => {
414
440
  setCorsHeaders(res);
415
441
  res.writeHead(204).end();
416
442
  });
@@ -580,7 +606,136 @@ app.get("/", (req, res) => {
580
606
  });
581
607
  });
582
608
 
609
+ // ========================================
610
+ // DASHBOARD (v3.5)
611
+ // ========================================
612
+
613
+ // Serve dashboard HTML
614
+ app.get("/dashboard", (req, res) => {
615
+ setCorsHeaders(res);
616
+ const __filename = fileURLToPath(import.meta.url);
617
+ const __dirname = _path.dirname(__filename);
618
+ const htmlPath = _path.join(__dirname, "..", "dashboard", "index.html");
619
+ try {
620
+ const html = fs.readFileSync(htmlPath, "utf-8");
621
+ res.setHeader("Content-Type", "text/html");
622
+ res.end(html);
623
+ } catch {
624
+ res.status(404).end("Dashboard not found.");
625
+ }
626
+ });
627
+
628
+ // Dashboard API: brain data
629
+ app.get("/dashboard/api/brain", (req, res) => {
630
+ setCorsHeaders(res);
631
+ try {
632
+ ensureInit(PROJECT_ROOT);
633
+ const brain = readBrain(PROJECT_ROOT);
634
+ if (!brain) return res.json({});
635
+ // Add metadata for dashboard
636
+ brain._encryption = isEncryptionEnabled();
637
+ brain._authEnabled = isAuthEnabled(PROJECT_ROOT);
638
+ try {
639
+ const keys = listApiKeys(PROJECT_ROOT);
640
+ brain._authKeys = keys.keys.filter(k => k.active).length;
641
+ } catch { brain._authKeys = 0; }
642
+ res.json(brain);
643
+ } catch (e) {
644
+ res.status(500).json({ error: e.message });
645
+ }
646
+ });
647
+
648
+ // Dashboard API: recent events
649
+ app.get("/dashboard/api/events", (req, res) => {
650
+ setCorsHeaders(res);
651
+ try {
652
+ const events = readEvents(PROJECT_ROOT, { limit: 50 });
653
+ res.json({ events });
654
+ } catch {
655
+ res.json({ events: [] });
656
+ }
657
+ });
658
+
659
+ // Dashboard API: telemetry summary
660
+ app.get("/dashboard/api/telemetry", (req, res) => {
661
+ setCorsHeaders(res);
662
+ res.json(getTelemetrySummary(PROJECT_ROOT));
663
+ });
664
+
665
+ // ========================================
666
+ // POLICY-AS-CODE ENDPOINTS (v3.5)
667
+ // ========================================
668
+
669
+ app.get("/policy", (req, res) => {
670
+ setCorsHeaders(res);
671
+ res.json(listPolicyRules(PROJECT_ROOT));
672
+ });
673
+
674
+ app.post("/policy", async (req, res) => {
675
+ setCorsHeaders(res);
676
+ const auth = authenticateRequest(req);
677
+ if (auth.authEnabled && (!auth.valid || !checkPermission(auth.role, "speclock_add_lock"))) {
678
+ return res.status(auth.valid ? 403 : 401).json({ error: "Write permission required." });
679
+ }
680
+
681
+ const { action } = req.body || {};
682
+ switch (action) {
683
+ case "init":
684
+ return res.json(initPolicy(PROJECT_ROOT));
685
+ case "add-rule":
686
+ return res.json(addPolicyRule(PROJECT_ROOT, req.body.rule || {}));
687
+ case "remove-rule":
688
+ return res.json(removePolicyRule(PROJECT_ROOT, req.body.ruleId));
689
+ case "evaluate":
690
+ return res.json(evaluatePolicy(PROJECT_ROOT, req.body.action || {}));
691
+ case "export":
692
+ return res.json(exportPolicy(PROJECT_ROOT));
693
+ case "import":
694
+ return res.json(importPolicy(PROJECT_ROOT, req.body.yaml || "", req.body.mode || "merge"));
695
+ default:
696
+ return res.status(400).json({ error: `Unknown policy action. Valid: init, add-rule, remove-rule, evaluate, export, import` });
697
+ }
698
+ });
699
+
700
+ // ========================================
701
+ // SSO ENDPOINTS (v3.5)
702
+ // ========================================
703
+
704
+ app.get("/auth/sso/login", (req, res) => {
705
+ setCorsHeaders(res);
706
+ if (!isSSOEnabled(PROJECT_ROOT)) {
707
+ return res.status(400).json({ error: "SSO not configured." });
708
+ }
709
+ const result = getAuthorizationUrl(PROJECT_ROOT);
710
+ if (!result.success) return res.status(400).json(result);
711
+ res.redirect(result.url);
712
+ });
713
+
714
+ app.get("/auth/callback", async (req, res) => {
715
+ setCorsHeaders(res);
716
+ const { code, state, error } = req.query || {};
717
+ if (error) return res.status(400).json({ error });
718
+ if (!code || !state) return res.status(400).json({ error: "Missing code or state." });
719
+ const result = await ssoHandleCallback(PROJECT_ROOT, code, state);
720
+ if (!result.success) return res.status(401).json(result);
721
+ res.json({ message: "SSO login successful", ...result });
722
+ });
723
+
724
+ app.get("/auth/sso/sessions", (req, res) => {
725
+ setCorsHeaders(res);
726
+ res.json(listSessions(PROJECT_ROOT));
727
+ });
728
+
729
+ app.post("/auth/sso/logout", (req, res) => {
730
+ setCorsHeaders(res);
731
+ const { sessionId } = req.body || {};
732
+ res.json(revokeSession(PROJECT_ROOT, sessionId));
733
+ });
734
+
735
+ // ========================================
736
+
583
737
  const PORT = parseInt(process.env.PORT || "3000", 10);
584
738
  app.listen(PORT, "0.0.0.0", () => {
585
739
  console.log(`SpecLock MCP HTTP Server v${VERSION} running on port ${PORT} — Developed by ${AUTHOR}`);
740
+ console.log(` Dashboard: http://localhost:${PORT}/dashboard`);
586
741
  });