@lifeaitools/clauth 0.3.12 → 0.4.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.
@@ -1,1213 +1,1653 @@
1
- // cli/commands/serve.js
2
- // Localhost-only credential daemon with daemon lifecycle management
3
- // Binds 127.0.0.1 ONLY — unreachable from outside the machine
4
- // 3 failed requests of any kind → process exits, requires manual restart
5
- // Supports: start (background daemon), stop, restart, ping, foreground
6
-
7
- import http from "http";
8
- import fs from "fs";
9
- import os from "os";
10
- import path from "path";
11
- import { fileURLToPath } from "url";
12
- import { getMachineHash, deriveToken, deriveSeedHash } from "../fingerprint.js";
13
- import * as api from "../api.js";
14
- import chalk from "chalk";
15
-
16
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
- const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../package.json"), "utf8"));
18
- const VERSION = pkg.version;
19
-
20
- const PID_FILE = path.join(os.tmpdir(), "clauth-serve.pid");
21
- const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
22
-
23
- // ── PID helpers ──────────────────────────────────────────────
24
- function readPid() {
25
- try {
26
- const raw = fs.readFileSync(PID_FILE, "utf8").trim();
27
- const [pid, port] = raw.split(":");
28
- return { pid: parseInt(pid, 10), port: parseInt(port, 10) };
29
- } catch { return null; }
30
- }
31
-
32
- function writePid(pid, port) {
33
- fs.writeFileSync(PID_FILE, `${pid}:${port}`, "utf8");
34
- }
35
-
36
- function removePid() {
37
- try { fs.unlinkSync(PID_FILE); } catch {}
38
- }
39
-
40
- function isProcessAlive(pid) {
41
- try { process.kill(pid, 0); return true; } catch { return false; }
42
- }
43
-
44
- // ── Dashboard HTML ───────────────────────────────────────────
45
- function dashboardHtml(port, whitelist) {
46
- return `<!DOCTYPE html>
47
- <html lang="en">
48
- <head>
49
- <meta charset="utf-8">
50
- <meta name="viewport" content="width=device-width,initial-scale=1">
51
- <title>clauth vault v${VERSION}</title>
52
- <style>
53
- *{margin:0;padding:0;box-sizing:border-box}
54
- body{background:#0a0f1a;color:#e2e8f0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;min-height:100vh}
55
- /* ── Lock screen ── */
56
- #lock-screen{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;padding:2rem}
57
- .lock-card{background:#1e293b;border:1px solid #334155;border-radius:12px;padding:2.5rem 2rem;width:100%;max-width:380px;text-align:center}
58
- .lock-icon{font-size:2.5rem;margin-bottom:1rem}
59
- .lock-title{font-size:1.25rem;font-weight:600;color:#f8fafc;margin-bottom:.4rem}
60
- .lock-sub{font-size:.85rem;color:#64748b;margin-bottom:1.75rem}
61
- .lock-input{width:100%;background:#0f172a;border:1px solid #334155;border-radius:8px;color:#e2e8f0;font-family:'Courier New',monospace;font-size:1rem;padding:10px 14px;outline:none;text-align:center;letter-spacing:.1em;transition:border-color .2s;margin-bottom:1rem}
62
- .lock-input:focus{border-color:#3b82f6}
63
- .lock-input.error{border-color:#ef4444;animation:shake .3s}
64
- @keyframes shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-6px)}75%{transform:translateX(6px)}}
65
- .btn-unlock{width:100%;background:#3b82f6;color:#fff;border:none;border-radius:8px;padding:10px;font-size:.95rem;font-weight:600;cursor:pointer;transition:background .15s}
66
- .btn-unlock:hover{background:#2563eb}
67
- .btn-unlock:disabled{background:#1e3a5f;color:#4a6fa5;cursor:not-allowed}
68
- .lock-err{color:#f87171;font-size:.82rem;margin-top:.75rem;min-height:1.2em}
69
- /* ── Main view ── */
70
- #main-view{display:none;padding:2rem}
71
- .header{display:flex;align-items:center;gap:10px;margin-bottom:1.5rem;flex-wrap:wrap}
72
- .header h1{font-size:1.4rem;font-weight:600;flex:1}
73
- .dot{width:10px;height:10px;border-radius:50%;background:#22c55e;animation:pulse 2s infinite;flex-shrink:0}
74
- @keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
75
- .status-bar{display:flex;gap:1.5rem;margin-bottom:1.5rem;font-size:.82rem;color:#94a3b8;flex-wrap:wrap}
76
- .status-bar span{color:#e2e8f0;font-weight:500}
77
- .toolbar{display:flex;gap:8px;margin-bottom:1rem;flex-wrap:wrap;align-items:center}
78
- .chpw-panel{display:none;background:#1a1f2e;border:1px solid #334155;border-radius:8px;padding:1.25rem;margin-bottom:1.5rem}
79
- .chpw-panel h3{font-size:.9rem;font-weight:600;color:#f8fafc;margin-bottom:1rem}
80
- .chpw-row{display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;margin-bottom:.75rem}
81
- .chpw-field{display:flex;flex-direction:column;gap:4px}
82
- .chpw-field label{font-size:.75rem;color:#64748b}
83
- .chpw-input{background:#0f172a;border:1px solid #334155;border-radius:6px;color:#e2e8f0;font-family:'Courier New',monospace;font-size:.88rem;padding:7px 12px;outline:none;width:200px;transition:border-color .2s}
84
- .chpw-input:focus{border-color:#3b82f6}
85
- .chpw-foot{display:flex;gap:8px;align-items:center}
86
- .btn-chpw-save{background:#1e3a5f;color:#60a5fa;padding:7px 18px;font-size:.85rem;border-radius:6px;border:none;cursor:pointer;font-weight:500;transition:background .15s}
87
- .btn-chpw-save:hover{background:#1e4a7f}
88
- .chpw-msg{font-size:.82rem}
89
- .chpw-msg.ok{color:#4ade80} .chpw-msg.fail{color:#f87171}
90
- .btn-refresh{background:#3b82f6;color:#fff;padding:7px 18px;font-size:.85rem;border-radius:7px;border:none;cursor:pointer;font-weight:500}
91
- .btn-refresh:hover{background:#2563eb}
92
- .btn-lock{background:#1e293b;color:#f87171;border:1px solid #334155;padding:7px 16px;font-size:.85rem;border-radius:7px;cursor:pointer;font-weight:500}
93
- .btn-lock:hover{background:#2d1f1f;border-color:#f87171}
94
- .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:1rem}
95
- .card{background:#1e293b;border:1px solid #334155;border-radius:8px;padding:1.25rem;transition:border-color .2s}
96
- .card:hover{border-color:#3b82f6}
97
- .card-name{font-size:1rem;font-weight:600;color:#f8fafc;margin-bottom:3px}
98
- .card-type{font-size:.78rem;color:#64748b;text-transform:uppercase;letter-spacing:.5px}
99
- .card-getkey{font-size:.75rem;color:#3b82f6;text-decoration:none;opacity:.7;transition:opacity .15s}
100
- .card-getkey:hover{opacity:1;text-decoration:underline}
101
- .card-value{font-family:'Courier New',monospace;font-size:.82rem;color:#22c55e;background:#0f172a;border-radius:4px;padding:8px 10px;margin-top:10px;word-break:break-all;max-height:80px;overflow:auto;display:none}
102
- .card-actions{margin-top:10px;display:flex;gap:7px;flex-wrap:wrap}
103
- .set-panel{display:none;margin-top:10px;background:#0f172a;border-radius:6px;padding:10px;border:1px solid #1e3a5f}
104
- .set-panel label{font-size:.75rem;color:#64748b;display:block;margin-bottom:6px}
105
- .set-input{width:100%;background:#0a0f1a;border:1px solid #1e3a5f;border-radius:4px;color:#e2e8f0;font-family:'Courier New',monospace;font-size:.85rem;padding:7px 10px;outline:none;resize:vertical;min-height:58px;transition:border-color .2s}
106
- .set-input:focus{border-color:#3b82f6}
107
- .set-foot{margin-top:8px;display:flex;gap:8px;align-items:center}
108
- .set-msg{font-size:.8rem}
109
- .set-msg.ok{color:#4ade80} .set-msg.fail{color:#f87171}
110
- .btn{padding:6px 13px;border-radius:6px;border:none;cursor:pointer;font-size:.8rem;font-weight:500;transition:all .15s}
111
- .btn-reveal{background:#1e3a5f;color:#60a5fa}.btn-reveal:hover{background:#1e4a7f}
112
- .btn-copy{background:#1a3328;color:#4ade80}.btn-copy:hover{background:#1a4338}
113
- .btn-set{background:#2d1f4a;color:#a78bfa}.btn-set:hover{background:#3d2f5a}
114
- .btn-save{background:#1e3a5f;color:#60a5fa;padding:6px 16px}.btn-save:hover{background:#1e4a7f}
115
- .btn-cancel{background:transparent;color:#64748b;padding:6px 10px}.btn-cancel:hover{color:#94a3b8}
116
- .btn-check{background:#0f2d2d;color:#34d399;border:1px solid #064e3b;padding:7px 16px;font-size:.85rem;border-radius:7px;cursor:pointer;font-weight:500;transition:all .15s}
117
- .btn-check:hover{background:#134e4a;border-color:#34d399}.btn-check:disabled{opacity:.5;cursor:not-allowed}
118
- .btn-enable{background:#14291a;color:#4ade80;border:1px solid #166534}.btn-enable:hover{background:#1a3d22;border-color:#4ade80}
119
- .btn-disable{background:#2d1f1f;color:#f87171;border:1px solid #7f1d1d}.btn-disable:hover{background:#3d2525;border-color:#f87171}
120
- .svc-badge{font-size:.7rem;font-weight:600;padding:2px 7px;border-radius:4px;letter-spacing:.4px;text-transform:uppercase}
121
- .svc-badge.on{background:rgba(74,222,128,.12);color:#4ade80;border:1px solid rgba(74,222,128,.25)}
122
- .svc-badge.off{background:rgba(248,113,113,.1);color:#f87171;border:1px solid rgba(248,113,113,.2)}
123
- .status-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;opacity:0;transition:opacity .3s;margin-top:4px;cursor:default}
124
- .status-dot.checking{background:#f59e0b;opacity:1;animation:pulse 1s infinite}
125
- .status-dot.ok{background:#22c55e;opacity:1}
126
- .status-dot.fail{background:#ef4444;opacity:1}
127
- @keyframes sdot-fade{to{opacity:0}} .status-dot.fading{animation:sdot-fade 1.5s forwards}
128
- .error-bar{background:#7f1d1d;color:#fca5a5;border:1px solid #991b1b;padding:10px 14px;border-radius:8px;margin-bottom:1rem;display:none;font-size:.85rem}
129
- .loading{color:#64748b;font-style:italic}
130
- .footer{margin-top:2rem;font-size:.75rem;color:#475569;text-align:center}
131
- .oauth-fields{display:flex;flex-direction:column;gap:8px;margin-bottom:8px}
132
- .oauth-field{display:flex;flex-direction:column;gap:3px}
133
- .oauth-label{font-size:.75rem;color:#94a3b8;font-weight:500}
134
- .oauth-hint{font-size:.71rem;color:#475569;font-style:italic}
135
- .oauth-input{width:100%;background:#0a0f1a;border:1px solid #1e3a5f;border-radius:4px;color:#e2e8f0;font-family:'Courier New',monospace;font-size:.85rem;padding:7px 10px;outline:none;transition:border-color .2s}
136
- .oauth-input:focus{border-color:#3b82f6}
137
- </style>
138
- </head>
139
- <body>
140
-
141
- <!-- ── Lock screen ──────────────────────────── -->
142
- <div id="lock-screen">
143
- <div class="lock-card">
144
- <div class="lock-icon">🔒</div>
145
- <div class="lock-title">clauth vault</div>
146
- <div class="lock-sub">Paste your password to unlock</div>
147
- <input class="lock-input" id="lock-input" type="password" placeholder="••••••••••••" autocomplete="off">
148
- <button class="btn-unlock" id="unlock-btn" onclick="unlock()">Unlock</button>
149
- <div class="lock-err" id="lock-err"></div>
150
- </div>
151
- </div>
152
-
153
- <!-- ── Main view (shown after unlock) ──────── -->
154
- <div id="main-view">
155
- <div class="header">
156
- <div class="dot" id="dot"></div>
157
- <h1>🔐 clauth vault <span style="font-size:0.55em;opacity:0.45;font-weight:400">v${VERSION}</span></h1>
158
- </div>
159
- <div id="error-bar" class="error-bar"></div>
160
- <div class="status-bar">
161
- <div>PID: <span id="s-pid">—</span></div>
162
- <div>Port: <span id="s-port">${port}</span></div>
163
- <div>Services: <span id="s-services">${whitelist ? whitelist.join(", ") : "all"}</span></div>
164
- <div>Failures: <span id="s-fails">—</span></div>
165
- </div>
166
- <div class="toolbar">
167
- <button class="btn-refresh" onclick="loadServices()">↻ Refresh</button>
168
- <button class="btn-check" id="check-btn" onclick="checkAll()">⬤ Check All</button>
169
- <button class="btn-lock" onclick="lockVault()">🔒 Lock</button>
170
- <button class="btn-cancel" style="margin-left:auto" onclick="toggleChangePw()">Change Password</button>
171
- </div>
172
-
173
- <div class="chpw-panel" id="chpw-panel">
174
- <h3>Change Master Password</h3>
175
- <div class="chpw-row">
176
- <div class="chpw-field">
177
- <label>New password</label>
178
- <input class="chpw-input" id="chpw-new" type="password" placeholder="min 8 chars" autocomplete="new-password">
179
- </div>
180
- <div class="chpw-field">
181
- <label>Confirm</label>
182
- <input class="chpw-input" id="chpw-confirm" type="password" placeholder="repeat" autocomplete="new-password">
183
- </div>
184
- </div>
185
- <div class="chpw-foot">
186
- <button class="btn-chpw-save" onclick="changePassword()">Update Password</button>
187
- <button class="btn-cancel" onclick="toggleChangePw()">Cancel</button>
188
- <span class="chpw-msg" id="chpw-msg"></span>
189
- </div>
190
- </div>
191
-
192
- <div id="grid" class="grid"><p class="loading">Loading services…</p></div>
193
- <div class="footer">localhost:${port} · 127.0.0.1 only · 3-strike lockout</div>
194
- </div>
195
-
196
- <script>
197
- const BASE = "http://127.0.0.1:${port}";
198
-
199
- const SERVICE_HINTS = {
200
- "neo4j": "neo4j+s://username:password@instance.databases.neo4j.io",
201
- "supabase-db": "postgresql://postgres:password@db.ref.supabase.co:5432/postgres",
202
- "r2": "accountId:accessKeyId:secretAccessKey",
203
- "namecheap": "apiUser:apiKey",
204
- };
205
-
206
- const KEY_URLS = {
207
- "github": "https://github.com/settings/tokens",
208
- "vercel": "https://vercel.com/account/tokens",
209
- "supabase-anon": "https://supabase.com/dashboard/project/uvojezuorjgqzmhhgluu/settings/api",
210
- "supabase-service":"https://supabase.com/dashboard/project/uvojezuorjgqzmhhgluu/settings/api",
211
- "supabase-db": "https://supabase.com/dashboard/project/uvojezuorjgqzmhhgluu/settings/database",
212
- "anthropic": "https://console.anthropic.com/settings/keys",
213
- "cloudflare": "https://dash.cloudflare.com/profile/api-tokens",
214
- "r2": "https://dash.cloudflare.com/profile/api-tokens",
215
- "r2-bucket": "https://dash.cloudflare.com/profile/api-tokens",
216
- "rocketreach": "https://rocketreach.co/account?section=security",
217
- "namecheap": "https://ap.www.namecheap.com/settings/tools/apiaccess/",
218
- "neo4j": "https://console.neo4j.io/",
219
- "npm": "https://www.npmjs.com/settings/~/tokens",
220
- "gmail": "https://console.cloud.google.com/apis/credentials",
221
- };
222
-
223
- // ── OAuth import config ─────────────────────
224
- // OAuth services with atomic fields each field saved as clauth.<service>.<key>
225
- // No JSON blob parsing. Each field is its own vault secret.
226
- const OAUTH_FIELDS = {
227
- "gmail": [
228
- { key: "client_id", label: "Client ID", hint: "From Google Cloud Console → APIs & Services → Credentials → OAuth client", required: true },
229
- { key: "client_secret", label: "Client Secret", hint: "From Google Cloud Console OAuth client", required: true },
230
- { key: "refresh_token", label: "Refresh Token", hint: "From Google OAuth Playground or your app's auth callback", required: true },
231
- { key: "from_address", label: "From Address", hint: "Gmail address to send from (e.g. dave@life.ai)", required: false },
232
- ]
233
- };
234
-
235
- function renderSetPanel(name) {
236
- const fields = OAUTH_FIELDS[name];
237
- if (fields) {
238
- const fieldsHtml = fields.map(f => \`
239
- <div class="oauth-field">
240
- <label class="oauth-label">\${f.label}\${f.required ? "" : " <span style='color:#475569'>(optional)</span>"}</label>
241
- \${f.hint ? \`<div class="oauth-hint">\${f.hint}</div>\` : ""}
242
- <input type="text" class="oauth-input" id="ofield-\${name}-\${f.key}" placeholder="Paste \${f.label}…" spellcheck="false" autocomplete="off">
243
- </div>
244
- \`).join("");
245
- return \`
246
- <div class="set-panel" id="set-panel-\${name}">
247
- <label>Set <strong>\${name}</strong> credentials — paste directly from source, never in chat</label>
248
- <div class="oauth-fields">
249
- \${fieldsHtml}
250
- </div>
251
- <div class="set-foot">
252
- <button class="btn btn-save" onclick="saveKey('\${name}')">Save</button>
253
- <button class="btn btn-cancel" onclick="toggleSet('\${name}')">Cancel</button>
254
- <span class="set-msg" id="set-msg-\${name}"></span>
255
- </div>
256
- </div>
257
- \`;
258
- }
259
- return \`
260
- <div class="set-panel" id="set-panel-\${name}">
261
- <label>New value for <strong>\${name}</strong> — paste here, never in chat</label>
262
- <textarea class="set-input" id="set-input-\${name}" placeholder="\${SERVICE_HINTS[name] || "Paste credential…"}" spellcheck="false"></textarea>
263
- \${SERVICE_HINTS[name] ? \`<div style="font-size:.72rem;color:#475569;margin-top:4px;font-family:'Courier New',monospace">\${SERVICE_HINTS[name]}</div>\` : ""}
264
- <div class="set-foot">
265
- <button class="btn btn-save" onclick="saveKey('\${name}')">Save</button>
266
- <button class="btn btn-cancel" onclick="toggleSet('\${name}')">Cancel</button>
267
- <span class="set-msg" id="set-msg-\${name}"></span>
268
- </div>
269
- </div>
270
- \`;
271
- }
272
-
273
- // ── Boot: check lock state ──────────────────
274
- async function boot() {
275
- try {
276
- const ping = await fetch(BASE + "/ping").then(r => r.json());
277
- if (ping.locked) {
278
- showLockScreen();
279
- } else {
280
- showMain(ping);
281
- loadServices();
282
- }
283
- } catch {
284
- showLockScreen();
285
- }
286
- }
287
-
288
- function showLockScreen() {
289
- document.getElementById("lock-screen").style.display = "flex";
290
- document.getElementById("main-view").style.display = "none";
291
- setTimeout(() => document.getElementById("lock-input").focus(), 50);
292
- }
293
-
294
- function showMain(ping) {
295
- document.getElementById("lock-screen").style.display = "none";
296
- document.getElementById("main-view").style.display = "block";
297
- if (ping) {
298
- document.getElementById("s-pid").textContent = ping.pid || "—";
299
- document.getElementById("s-fails").textContent =
300
- ping.failures + "/" + (ping.failures + ping.failures_remaining);
301
- }
302
- }
303
-
304
- // ── Unlock ──────────────────────────────────
305
- async function unlock() {
306
- const input = document.getElementById("lock-input");
307
- const btn = document.getElementById("unlock-btn");
308
- const err = document.getElementById("lock-err");
309
- const pw = input.value;
310
-
311
- if (!pw) { err.textContent = "Password is required."; return; }
312
-
313
- btn.disabled = true; btn.textContent = "Verifying…"; err.textContent = "";
314
-
315
- try {
316
- const r = await fetch(BASE + "/auth", {
317
- method: "POST",
318
- headers: { "Content-Type": "application/json" },
319
- body: JSON.stringify({ password: pw })
320
- }).then(r => r.json());
321
-
322
- if (r.error) throw new Error(r.error);
323
-
324
- input.value = "";
325
- const ping = await fetch(BASE + "/ping").then(r => r.json());
326
- showMain(ping);
327
- loadServices();
328
- } catch (e) {
329
- input.value = "";
330
- input.className = "lock-input error";
331
- err.textContent = "✗ " + (e.message || "Invalid password");
332
- setTimeout(() => input.className = "lock-input", 600);
333
- } finally {
334
- btn.disabled = false; btn.textContent = "Unlock";
335
- }
336
- }
337
-
338
- // ── Lock ────────────────────────────────────
339
- async function lockVault() {
340
- await fetch(BASE + "/lock", { method: "POST" }).catch(() => {});
341
- showLockScreen();
342
- }
343
-
344
- // ── Load services ───────────────────────────
345
- async function loadServices() {
346
- const grid = document.getElementById("grid");
347
- const err = document.getElementById("error-bar");
348
- err.style.display = "none";
349
- grid.innerHTML = '<p class="loading">Loading…</p>';
350
-
351
- try {
352
- const status = await fetch(BASE + "/status").then(r => r.json());
353
- if (status.locked) { showLockScreen(); return; }
354
- if (status.error) throw new Error(status.error);
355
-
356
- const services = status.services || [];
357
- if (!services.length) { grid.innerHTML = '<p class="loading">No services registered.</p>'; return; }
358
-
359
- grid.innerHTML = services.map(s => \`
360
- <div class="card">
361
- <div style="display:flex;align-items:flex-start;justify-content:space-between">
362
- <div>
363
- <div class="card-name">\${s.name}</div>
364
- <div style="display:flex;align-items:center;gap:6px;margin-top:2px">
365
- <div class="card-type">\${s.key_type || "secret"}</div>
366
- <span class="svc-badge \${s.enabled === false ? "off" : "on"}" id="badge-\${s.name}">\${s.enabled === false ? "disabled" : "enabled"}</span>
367
- </div>
368
- \${KEY_URLS[s.name] ? \`<a class="card-getkey" href="\${KEY_URLS[s.name]}" target="_blank" rel="noopener">↗ Get / rotate key</a>\` : ""}
369
- </div>
370
- <div class="status-dot" id="sdot-\${s.name}" title=""></div>
371
- </div>
372
- <div class="card-value" id="val-\${s.name}"></div>
373
- <div class="card-actions">
374
- <button class="btn btn-reveal" onclick="reveal('\${s.name}', this)">Reveal</button>
375
- <button class="btn btn-copy" id="copybtn-\${s.name}" style="display:none" onclick="copyKey('\${s.name}')">Copy</button>
376
- <button class="btn btn-set" onclick="toggleSet('\${s.name}')">Set</button>
377
- <button class="btn \${s.enabled === false ? "btn-enable" : "btn-disable"}" id="togbtn-\${s.name}" onclick="toggleService('\${s.name}')">\${s.enabled === false ? "Enable" : "Disable"}</button>
378
- </div>
379
- \${renderSetPanel(s.name)}
380
- </div>
381
- \`).join("");
382
- } catch (e) {
383
- err.textContent = "⚠ " + e.message;
384
- err.style.display = "block";
385
- document.getElementById("dot").style.background = "#ef4444";
386
- grid.innerHTML = "";
387
- }
388
- }
389
-
390
- // ── Reveal ──────────────────────────────────
391
- async function reveal(name, btn) {
392
- const valEl = document.getElementById("val-" + name);
393
- const copyBtn = document.getElementById("copybtn-" + name);
394
- if (valEl.style.display === "block") {
395
- valEl.style.display = "none"; copyBtn.style.display = "none";
396
- btn.textContent = "Reveal"; return;
397
- }
398
- valEl.textContent = "fetching…"; valEl.style.display = "block"; btn.textContent = "Hide";
399
- try {
400
- const r = await fetch(BASE + "/get/" + name).then(r => r.json());
401
- if (r.locked) { showLockScreen(); return; }
402
- if (r.error) throw new Error(r.error);
403
- const imp = OAUTH_IMPORT[name];
404
- if (imp) {
405
- try {
406
- const parsed = JSON.parse(r.value);
407
- const allKeys = [...imp.jsonFields, ...imp.extra.map(f => f.key)];
408
- valEl.innerHTML = allKeys.map(k =>
409
- '<div style="margin-bottom:6px"><span style="color:#64748b;font-size:.72rem;text-transform:uppercase;letter-spacing:.4px">' + k.replace(/_/g," ") + '</span><br>' + (parsed[k] || "—") + '</div>'
410
- ).join("");
411
- } catch { valEl.textContent = r.value; }
412
- } else {
413
- valEl.textContent = r.value;
414
- }
415
- copyBtn.style.display = "inline-block";
416
- } catch (e) {
417
- valEl.textContent = "Error: " + e.message; valEl.style.color = "#ef4444";
418
- }
419
- }
420
-
421
- async function copyKey(name) {
422
- const val = document.getElementById("val-" + name).textContent;
423
- try {
424
- await navigator.clipboard.writeText(val);
425
- const btn = document.getElementById("copybtn-" + name);
426
- btn.textContent = "Copied!"; setTimeout(() => btn.textContent = "Copy", 1500);
427
- } catch {}
428
- }
429
-
430
- // ── Set key ─────────────────────────────────
431
- function toggleSet(name) {
432
- const panel = document.getElementById("set-panel-" + name);
433
- const msg = document.getElementById("set-msg-" + name);
434
- const open = panel.style.display === "block";
435
- panel.style.display = open ? "none" : "block";
436
- if (!open) {
437
- if (msg) msg.textContent = "";
438
- const imp = OAUTH_IMPORT[name];
439
- if (imp) {
440
- const jsonEl = document.getElementById("ofield-" + name + "-json");
441
- if (jsonEl) { jsonEl.value = ""; jsonEl.focus(); }
442
- imp.extra.forEach(f => { const el = document.getElementById("ofield-" + name + "-" + f.key); if (el) el.value = ""; });
443
- } else {
444
- const input = document.getElementById("set-input-" + name);
445
- if (input) { input.value = ""; input.focus(); }
446
- }
447
- }
448
- }
449
-
450
- async function saveKey(name) {
451
- const msg = document.getElementById("set-msg-" + name);
452
- const fields = OAUTH_FIELDS[name];
453
-
454
- if (fields) {
455
- // Validate required fields first
456
- for (const f of fields) {
457
- if (!f.required) continue;
458
- const el = document.getElementById("ofield-" + name + "-" + f.key);
459
- if (!el || !el.value.trim()) {
460
- msg.className = "set-msg fail"; msg.textContent = f.label + " is required."; return;
461
- }
462
- }
463
- // Save each field as an atomic vault key: clauth.<service>.<key>
464
- msg.className = "set-msg"; msg.textContent = "Saving…";
465
- try {
466
- for (const f of fields) {
467
- const el = document.getElementById("ofield-" + name + "-" + f.key);
468
- const v = el ? el.value.trim() : "";
469
- if (!v) continue; // skip optional empty fields
470
- const r = await fetch(BASE + "/set/" + name + "." + f.key, {
471
- method: "POST",
472
- headers: { "Content-Type": "application/json" },
473
- body: JSON.stringify({ value: v })
474
- }).then(r => r.json());
475
- if (r.locked) { showLockScreen(); return; }
476
- if (r.error) throw new Error(r.error);
477
- }
478
- msg.className = "set-msg ok"; msg.textContent = "✓ Saved";
479
- fields.forEach(f => { const el = document.getElementById("ofield-" + name + "-" + f.key); if (el) el.value = ""; });
480
- } catch (e) {
481
- msg.className = "set-msg fail"; msg.textContent = e.message || "Save failed";
482
- return;
483
- }
484
- } else {
485
- const input = document.getElementById("set-input-" + name);
486
- const value = input ? input.value.trim() : "";
487
- if (!value) { msg.className = "set-msg fail"; msg.textContent = "Value is empty."; return; }
488
-
489
- msg.className = "set-msg"; msg.textContent = "Saving…";
490
- try {
491
- const r = await fetch(BASE + "/set/" + name, {
492
- method: "POST",
493
- headers: { "Content-Type": "application/json" },
494
- body: JSON.stringify({ value })
495
- }).then(r => r.json());
496
-
497
- if (r.locked) { showLockScreen(); return; }
498
- if (r.error) throw new Error(r.error);
499
- msg.className = "set-msg ok"; msg.textContent = "✓ Saved";
500
- const inp = document.getElementById("set-input-" + name);
501
- if (inp) inp.value = "";
502
- } catch (e) {
503
- msg.className = "set-msg fail"; msg.textContent = e.message || "Save failed";
504
- return;
505
- }
506
- }
507
-
508
- const dot = document.getElementById("sdot-" + name);
509
- if (dot) { dot.className = "status-dot"; dot.title = ""; }
510
- setTimeout(() => {
511
- document.getElementById("set-panel-" + name).style.display = "none";
512
- msg.textContent = "";
513
- }, 1800);
514
- }
515
-
516
- // ── Enable / Disable service ────────────────
517
- async function toggleService(name) {
518
- const badge = document.getElementById("badge-" + name);
519
- const btn = document.getElementById("togbtn-" + name);
520
- const currently = badge.classList.contains("on");
521
- const newState = !currently;
522
-
523
- btn.disabled = true; btn.textContent = "…";
524
-
525
- try {
526
- const r = await fetch(BASE + "/toggle/" + name, {
527
- method: "POST",
528
- headers: { "Content-Type": "application/json" },
529
- body: JSON.stringify({ enabled: newState })
530
- }).then(r => r.json());
531
-
532
- if (r.locked) { showLockScreen(); return; }
533
- if (r.error) throw new Error(r.error);
534
-
535
- badge.className = "svc-badge " + (newState ? "on" : "off");
536
- badge.textContent = newState ? "enabled" : "disabled";
537
- btn.className = "btn " + (newState ? "btn-disable" : "btn-enable");
538
- btn.textContent = newState ? "Disable" : "Enable";
539
- } catch (e) {
540
- btn.textContent = "Error";
541
- setTimeout(() => {
542
- btn.textContent = currently ? "Disable" : "Enable";
543
- }, 2000);
544
- } finally {
545
- btn.disabled = false;
546
- }
547
- }
548
-
549
- // ── Check all credentials ───────────────────
550
- async function checkAll() {
551
- const btn = document.getElementById("check-btn");
552
- btn.disabled = true; btn.textContent = "Checking…";
553
-
554
- // Show checking state on every visible dot
555
- document.querySelectorAll(".status-dot").forEach(d => {
556
- d.className = "status-dot checking"; d.title = "Checking…";
557
- });
558
-
559
- try {
560
- const r = await fetch(BASE + "/check-all").then(r => r.json());
561
- if (r.locked) { showLockScreen(); return; }
562
- if (r.error) throw new Error(r.error);
563
-
564
- const results = r.results || {};
565
- for (const [name, result] of Object.entries(results)) {
566
- const dot = document.getElementById("sdot-" + name);
567
- if (!dot) continue;
568
- if (result.ok) {
569
- dot.className = "status-dot ok"; dot.title = "OK";
570
- } else {
571
- dot.className = "status-dot fail";
572
- dot.title = result.reason || "No key stored";
573
- }
574
- }
575
-
576
- // Fade green dots after 3 s — red dots stay until next check
577
- setTimeout(() => {
578
- document.querySelectorAll(".status-dot.ok").forEach(d => d.classList.add("fading"));
579
- }, 3000);
580
-
581
- } catch (e) {
582
- document.querySelectorAll(".status-dot").forEach(d => { d.className = "status-dot"; d.title = ""; });
583
- const errBar = document.getElementById("error-bar");
584
- errBar.textContent = "⚠ Check failed: " + e.message;
585
- errBar.style.display = "block";
586
- } finally {
587
- btn.disabled = false; btn.textContent = "⬤ Check All";
588
- }
589
- }
590
-
591
- // ── Change password ─────────────────────────
592
- function toggleChangePw() {
593
- const panel = document.getElementById("chpw-panel");
594
- const open = panel.style.display === "block";
595
- panel.style.display = open ? "none" : "block";
596
- if (!open) {
597
- document.getElementById("chpw-new").value = "";
598
- document.getElementById("chpw-confirm").value = "";
599
- document.getElementById("chpw-msg").textContent = "";
600
- document.getElementById("chpw-new").focus();
601
- }
602
- }
603
-
604
- async function changePassword() {
605
- const newPw = document.getElementById("chpw-new").value;
606
- const confPw = document.getElementById("chpw-confirm").value;
607
- const msg = document.getElementById("chpw-msg");
608
-
609
- if (!newPw) { msg.className = "chpw-msg fail"; msg.textContent = "Enter a new password."; return; }
610
- if (newPw.length < 8) { msg.className = "chpw-msg fail"; msg.textContent = "Minimum 8 characters."; return; }
611
- if (newPw !== confPw) { msg.className = "chpw-msg fail"; msg.textContent = "Passwords don't match."; return; }
612
-
613
- msg.className = "chpw-msg"; msg.textContent = "Updating…";
614
- try {
615
- const r = await fetch(BASE + "/change-pw", {
616
- method: "POST",
617
- headers: { "Content-Type": "application/json" },
618
- body: JSON.stringify({ newPassword: newPw })
619
- }).then(r => r.json());
620
-
621
- if (r.locked) { showLockScreen(); return; }
622
- if (r.error) throw new Error(r.error);
623
-
624
- msg.className = "chpw-msg ok"; msg.textContent = "✓ Password updated";
625
- document.getElementById("chpw-new").value = "";
626
- document.getElementById("chpw-confirm").value = "";
627
- setTimeout(() => {
628
- document.getElementById("chpw-panel").style.display = "none";
629
- msg.textContent = "";
630
- }, 2000);
631
- } catch (e) {
632
- msg.className = "chpw-msg fail"; msg.textContent = "✗ " + e.message;
633
- }
634
- }
635
-
636
- // Enter key on lock screen
637
- document.addEventListener("DOMContentLoaded", () => {
638
- document.getElementById("lock-input").addEventListener("keydown", e => {
639
- if (e.key === "Enter") unlock();
640
- });
641
- });
642
-
643
- boot();
644
- </script>
645
- </body>
646
- </html>`;
647
- }
648
-
649
- // ── Body parser helper ────────────────────────────────────────
650
- function readBody(req) {
651
- return new Promise((resolve, reject) => {
652
- let data = "";
653
- req.on("data", chunk => { data += chunk; if (data.length > 65536) reject(new Error("Body too large")); });
654
- req.on("end", () => { try { resolve(JSON.parse(data)); } catch { reject(new Error("Invalid JSON")); } });
655
- req.on("error", reject);
656
- });
657
- }
658
-
659
- // ── Server logic (shared by foreground + daemon) ─────────────
660
- function createServer(initPassword, whitelist, port) {
661
- const MAX_FAILS = 3;
662
- let failCount = 0;
663
- let password = initPassword || null; // null = locked; set via POST /auth
664
- const machineHash = getMachineHash();
665
-
666
- const CORS = {
667
- "Access-Control-Allow-Origin": "*",
668
- "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
669
- "Access-Control-Allow-Headers": "Content-Type",
670
- };
671
-
672
- function strike(res, code, message) {
673
- failCount++;
674
- const remaining = MAX_FAILS - failCount;
675
- const logLine = `[${new Date().toISOString()}] [FAIL ${failCount}/${MAX_FAILS}] ${message}\n`;
676
- try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
677
-
678
- const body = JSON.stringify({
679
- error: message,
680
- failures: failCount,
681
- failures_remaining: remaining,
682
- ...(failCount >= MAX_FAILS ? { shutdown: true } : {})
683
- });
684
-
685
- res.writeHead(code, { "Content-Type": "application/json", ...CORS });
686
- res.end(body);
687
-
688
- if (failCount >= MAX_FAILS) {
689
- const msg = `[${new Date().toISOString()}] Failure limit reached — shutting down\n`;
690
- try { fs.appendFileSync(LOG_FILE, msg); } catch {}
691
- removePid();
692
- setTimeout(() => process.exit(1), 100);
693
- }
694
- }
695
-
696
- function ok(res, data) {
697
- res.writeHead(200, { "Content-Type": "application/json", ...CORS });
698
- res.end(JSON.stringify(data));
699
- }
700
-
701
- const server = http.createServer(async (req, res) => {
702
- // Hard reject anything not from loopback
703
- const remote = req.socket.remoteAddress;
704
- const isLocal = remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
705
- if (!isLocal) {
706
- return strike(res, 403, `Rejected non-local address: ${remote}`);
707
- }
708
-
709
- // CORS preflight
710
- if (req.method === "OPTIONS") {
711
- res.writeHead(204, CORS);
712
- return res.end();
713
- }
714
-
715
- const url = new URL(req.url, `http://127.0.0.1:${port}`);
716
- const reqPath = url.pathname;
717
- const method = req.method;
718
-
719
- // GET / built-in web dashboard
720
- if (method === "GET" && reqPath === "/") {
721
- res.writeHead(200, { "Content-Type": "text/html", ...CORS });
722
- return res.end(dashboardHtml(port, whitelist));
723
- }
724
-
725
- // GET /ping
726
- if (method === "GET" && reqPath === "/ping") {
727
- return ok(res, {
728
- status: "ok",
729
- pid: process.pid,
730
- locked: !password,
731
- failures: failCount,
732
- failures_remaining: MAX_FAILS - failCount,
733
- services: whitelist || "all",
734
- port
735
- });
736
- }
737
-
738
- // GET /shutdown (for daemon stop)
739
- if (method === "GET" && reqPath === "/shutdown") {
740
- ok(res, { ok: true, message: "shutting down" });
741
- removePid();
742
- setTimeout(() => process.exit(0), 100);
743
- return;
744
- }
745
-
746
- // Locked guard — returns true if locked and already responded
747
- function lockedGuard(res) {
748
- if (!password) {
749
- res.writeHead(401, { "Content-Type": "application/json", ...CORS });
750
- res.end(JSON.stringify({ error: "Vault is locked", locked: true }));
751
- return true;
752
- }
753
- return false;
754
- }
755
-
756
- // GET /status
757
- if (method === "GET" && reqPath === "/status") {
758
- if (lockedGuard(res)) return;
759
- try {
760
- const { token, timestamp } = deriveToken(password, machineHash);
761
- const result = await api.status(password, machineHash, token, timestamp);
762
- if (result.error) return strike(res, 502, result.error);
763
- if (whitelist) {
764
- result.services = (result.services || []).filter(
765
- s => whitelist.includes(s.name.toLowerCase())
766
- );
767
- }
768
- return ok(res, result);
769
- } catch (err) {
770
- return strike(res, 502, err.message);
771
- }
772
- }
773
-
774
- // GET /get/:service
775
- const getMatch = reqPath.match(/^\/get\/([a-zA-Z0-9_.-]+)$/);
776
- if (method === "GET" && getMatch) {
777
- if (lockedGuard(res)) return;
778
- const service = getMatch[1].toLowerCase();
779
-
780
- if (whitelist && !whitelist.includes(service)) {
781
- return strike(res, 403, `Service '${service}' not in whitelist`);
782
- }
783
-
784
- try {
785
- const { token, timestamp } = deriveToken(password, machineHash);
786
- const result = await api.retrieve(password, machineHash, token, timestamp, service);
787
- if (result.error) return strike(res, 502, result.error);
788
- return ok(res, { service, value: result.value, key_type: result.key_type });
789
- } catch (err) {
790
- return strike(res, 502, err.message);
791
- }
792
- }
793
-
794
- // POST /auth unlock the vault with a password (verifies against Edge Function)
795
- if (method === "POST" && reqPath === "/auth") {
796
- let body;
797
- try { body = await readBody(req); } catch {
798
- res.writeHead(400, { "Content-Type": "application/json", ...CORS });
799
- return res.end(JSON.stringify({ error: "Invalid JSON body" }));
800
- }
801
-
802
- const pw = body.password;
803
- if (!pw || typeof pw !== "string") {
804
- res.writeHead(400, { "Content-Type": "application/json", ...CORS });
805
- return res.end(JSON.stringify({ error: "password is required" }));
806
- }
807
-
808
- try {
809
- const { token, timestamp } = deriveToken(pw, machineHash);
810
- const result = await api.test(pw, machineHash, token, timestamp);
811
- if (result.error) throw new Error(result.error);
812
- password = pw; // unlock store in process memory only
813
- const logLine = `[${new Date().toISOString()}] Vault unlocked\n`;
814
- try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
815
- return ok(res, { ok: true, locked: false });
816
- } catch {
817
- // Wrong password — not a lockout strike, just a UI auth attempt
818
- res.writeHead(401, { "Content-Type": "application/json", ...CORS });
819
- return res.end(JSON.stringify({ error: "Invalid password" }));
820
- }
821
- }
822
-
823
- // POST /lock clear password from memory
824
- if (method === "POST" && reqPath === "/lock") {
825
- password = null;
826
- const logLine = `[${new Date().toISOString()}] Vault locked\n`;
827
- try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
828
- return ok(res, { ok: true, locked: true });
829
- }
830
-
831
- // POST /toggle/:service — enable or disable a service
832
- const toggleMatch = reqPath.match(/^\/toggle\/([a-zA-Z0-9_-]+)$/);
833
- if (method === "POST" && toggleMatch) {
834
- if (lockedGuard(res)) return;
835
- const service = toggleMatch[1].toLowerCase();
836
-
837
- let body;
838
- try { body = await readBody(req); } catch {
839
- res.writeHead(400, { "Content-Type": "application/json", ...CORS });
840
- return res.end(JSON.stringify({ error: "Invalid JSON body" }));
841
- }
842
-
843
- const { enabled } = body;
844
- if (typeof enabled !== "boolean") {
845
- res.writeHead(400, { "Content-Type": "application/json", ...CORS });
846
- return res.end(JSON.stringify({ error: "enabled must be boolean" }));
847
- }
848
-
849
- try {
850
- const { token, timestamp } = deriveToken(password, machineHash);
851
- const result = await api.enable(password, machineHash, token, timestamp, service, enabled);
852
- if (result.error) return strike(res, 502, result.error);
853
- return ok(res, { ok: true, service, enabled });
854
- } catch (err) {
855
- return strike(res, 502, err.message);
856
- }
857
- }
858
-
859
- // GET /check-all soft health check, no strikes for missing/disabled keys
860
- if (method === "GET" && reqPath === "/check-all") {
861
- if (lockedGuard(res)) return;
862
- try {
863
- const { token: st, timestamp: sts } = deriveToken(password, machineHash);
864
- const statusResult = await api.status(password, machineHash, st, sts);
865
- if (statusResult.error) return strike(res, 502, statusResult.error);
866
-
867
- const services = (statusResult.services || []).filter(
868
- s => !whitelist || whitelist.includes(s.name.toLowerCase())
869
- );
870
-
871
- const results = {};
872
- for (const svc of services) {
873
- try {
874
- const { token, timestamp } = deriveToken(password, machineHash);
875
- const r = await api.retrieve(password, machineHash, token, timestamp, svc.name);
876
- results[svc.name] = r.error
877
- ? { ok: false, reason: r.error }
878
- : { ok: true };
879
- } catch (e) {
880
- results[svc.name] = { ok: false, reason: e.message };
881
- }
882
- }
883
- return ok(res, { results });
884
- } catch (err) {
885
- return strike(res, 502, err.message);
886
- }
887
- }
888
-
889
- // POST /change-pw — change master password (must be unlocked)
890
- if (method === "POST" && reqPath === "/change-pw") {
891
- if (lockedGuard(res)) return;
892
-
893
- let body;
894
- try { body = await readBody(req); } catch {
895
- res.writeHead(400, { "Content-Type": "application/json", ...CORS });
896
- return res.end(JSON.stringify({ error: "Invalid JSON body" }));
897
- }
898
-
899
- const { newPassword } = body;
900
- if (!newPassword || typeof newPassword !== "string" || newPassword.length < 8) {
901
- res.writeHead(400, { "Content-Type": "application/json", ...CORS });
902
- return res.end(JSON.stringify({ error: "newPassword must be at least 8 characters" }));
903
- }
904
-
905
- try {
906
- const { token, timestamp } = deriveToken(password, machineHash);
907
- const newSeedHash = deriveSeedHash(machineHash, newPassword);
908
- const result = await api.changePassword(password, machineHash, token, timestamp, newSeedHash);
909
- if (result.error) throw new Error(result.error);
910
- password = newPassword; // update in-memory password to new one
911
- const logLine = `[${new Date().toISOString()}] Password changed\n`;
912
- try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
913
- return ok(res, { ok: true });
914
- } catch (err) {
915
- res.writeHead(502, { "Content-Type": "application/json", ...CORS });
916
- return res.end(JSON.stringify({ error: err.message }));
917
- }
918
- }
919
-
920
- // POST /set/:service — write a new key value into vault
921
- const setMatch = reqPath.match(/^\/set\/([a-zA-Z0-9_.-]+)$/);
922
- if (method === "POST" && setMatch) {
923
- if (lockedGuard(res)) return;
924
- const service = setMatch[1].toLowerCase();
925
-
926
- if (whitelist && !whitelist.includes(service)) {
927
- return strike(res, 403, `Service '${service}' not in whitelist`);
928
- }
929
-
930
- let body;
931
- try { body = await readBody(req); } catch {
932
- return strike(res, 400, "Invalid JSON body");
933
- }
934
-
935
- const value = body.value;
936
- if (!value || typeof value !== "string" || !value.trim()) {
937
- res.writeHead(400, { "Content-Type": "application/json", ...CORS });
938
- return res.end(JSON.stringify({ error: "value is required" }));
939
- }
940
-
941
- try {
942
- const { token, timestamp } = deriveToken(password, machineHash);
943
- const result = await api.write(password, machineHash, token, timestamp, service, value.trim());
944
- if (result.error) return strike(res, 502, result.error);
945
- return ok(res, { ok: true, service });
946
- } catch (err) {
947
- return strike(res, 502, err.message);
948
- }
949
- }
950
-
951
- // Unknown route
952
- return strike(res, 404, `Unknown endpoint: ${reqPath}`);
953
- });
954
-
955
- return server;
956
- }
957
-
958
- // ── Actions ──────────────────────────────────────────────────
959
-
960
- async function verifyAuth(password) {
961
- const machineHash = getMachineHash();
962
- const { token, timestamp } = deriveToken(password, machineHash);
963
- const result = await api.test(password, machineHash, token, timestamp);
964
- if (result.error) throw new Error(result.error);
965
- }
966
-
967
- async function actionStart(opts) {
968
- const port = parseInt(opts.port || "52437", 10);
969
- const password = opts.pw;
970
- const whitelist = opts.services
971
- ? opts.services.split(",").map(s => s.trim().toLowerCase())
972
- : null;
973
-
974
- // Check for existing instance
975
- const existing = readPid();
976
- if (existing && isProcessAlive(existing.pid)) {
977
- console.log(chalk.yellow(`\n clauth serve already running (PID ${existing.pid}, port ${existing.port})`));
978
- console.log(chalk.gray(` Stop it first: clauth serve stop\n`));
979
- process.exit(1);
980
- }
981
-
982
- // If we're the daemon child, run the server directly
983
- if (process.env.__CLAUTH_DAEMON === "1") {
984
- // Verify password only if one was provided at start (optional — browser can unlock later)
985
- if (password) {
986
- try {
987
- await verifyAuth(password);
988
- } catch (err) {
989
- const msg = `[${new Date().toISOString()}] Auth failed: ${err.message}\n`;
990
- fs.appendFileSync(LOG_FILE, msg);
991
- process.exit(1);
992
- }
993
- }
994
-
995
- const server = createServer(password, whitelist, port);
996
- server.listen(port, "127.0.0.1", () => {
997
- writePid(process.pid, port);
998
- const msg = `[${new Date().toISOString()}] clauth serve started — PID ${process.pid}, port ${port}, services: ${whitelist ? whitelist.join(",") : "all"}\n`;
999
- fs.appendFileSync(LOG_FILE, msg);
1000
- });
1001
-
1002
- server.on("error", err => {
1003
- const msg = `[${new Date().toISOString()}] Server error: ${err.message}\n`;
1004
- fs.appendFileSync(LOG_FILE, msg);
1005
- process.exit(1);
1006
- });
1007
-
1008
- const shutdown = () => { removePid(); process.exit(0); };
1009
- process.on("SIGTERM", shutdown);
1010
- process.on("SIGINT", shutdown);
1011
- return;
1012
- }
1013
-
1014
- // If --pw provided, verify it before spawning (fast-fail on wrong password)
1015
- if (password) {
1016
- console.log(chalk.gray("\n Verifying vault credentials..."));
1017
- try {
1018
- await verifyAuth(password);
1019
- } catch (err) {
1020
- console.log(chalk.red(`\n Auth failed: ${err.message}\n`));
1021
- process.exit(1);
1022
- }
1023
- console.log(chalk.green(" Vault auth verified"));
1024
- } else {
1025
- console.log(chalk.yellow("\n Starting in locked state — open browser to unlock"));
1026
- }
1027
-
1028
- // Spawn detached daemon child via the clauth CLI entry point
1029
- const { spawn } = await import("child_process");
1030
- const { fileURLToPath } = await import("url");
1031
- const { dirname, join } = await import("path");
1032
- const __filename = fileURLToPath(import.meta.url);
1033
- const cliEntry = join(dirname(__filename), "..", "index.js");
1034
-
1035
- // Build args: node index.js serve start --port N [--pw PW] [--services S]
1036
- const childArgs = [cliEntry, "serve", "start", "--port", String(port)];
1037
- if (password) childArgs.push("--pw", password);
1038
- if (opts.services) childArgs.push("--services", opts.services);
1039
-
1040
- const out = fs.openSync(LOG_FILE, "a");
1041
- const child = spawn(process.execPath, childArgs, {
1042
- detached: true,
1043
- stdio: ["ignore", out, out],
1044
- env: { ...process.env, __CLAUTH_DAEMON: "1" },
1045
- });
1046
- child.unref();
1047
-
1048
- // Give it time to bind and write PID file (Windows spawn is slower)
1049
- // Verify via HTTP ping (process.kill(pid,0) fails on Windows for detached processes)
1050
- let started = false;
1051
- for (let attempt = 0; attempt < 5; attempt++) {
1052
- await new Promise(r => setTimeout(r, 1000));
1053
- try {
1054
- const resp = await fetch(`http://127.0.0.1:${port}/ping`);
1055
- if (resp.ok) { started = true; break; }
1056
- } catch {}
1057
- }
1058
-
1059
- const info = readPid();
1060
- if (started && info) {
1061
- console.log(chalk.green(`\n 🔐 clauth serve started`));
1062
- console.log(chalk.gray(` PID: ${info.pid}`));
1063
- console.log(chalk.gray(` Port: 127.0.0.1:${info.port}`));
1064
- console.log(chalk.gray(` Services: ${whitelist ? whitelist.join(", ") : "all"}`));
1065
- console.log(chalk.gray(` Log: ${LOG_FILE}`));
1066
- if (!password) {
1067
- console.log(chalk.cyan(`\n 👉 Open http://127.0.0.1:${info.port} to unlock the vault`));
1068
- }
1069
- console.log(chalk.gray(` Stop: clauth serve stop\n`));
1070
- } else {
1071
- console.log(chalk.red(`\n ❌ Failed to start daemon — check ${LOG_FILE}\n`));
1072
- process.exit(1);
1073
- }
1074
- }
1075
-
1076
- async function actionStop() {
1077
- const info = readPid();
1078
- if (!info) {
1079
- console.log(chalk.yellow("\n No clauth serve PID file found — not running.\n"));
1080
- return;
1081
- }
1082
-
1083
- if (!isProcessAlive(info.pid)) {
1084
- console.log(chalk.yellow(`\n PID ${info.pid} is not running (stale PID file). Cleaning up.\n`));
1085
- removePid();
1086
- return;
1087
- }
1088
-
1089
- // Try HTTP shutdown first (clean)
1090
- try {
1091
- const resp = await fetch(`http://127.0.0.1:${info.port}/shutdown`);
1092
- if (resp.ok) {
1093
- await new Promise(r => setTimeout(r, 300));
1094
- console.log(chalk.green(`\n 🛑 clauth serve stopped (was PID ${info.pid}, port ${info.port})\n`));
1095
- removePid();
1096
- return;
1097
- }
1098
- } catch {}
1099
-
1100
- // Fallback: kill the process
1101
- try {
1102
- process.kill(info.pid, "SIGTERM");
1103
- await new Promise(r => setTimeout(r, 300));
1104
- console.log(chalk.green(`\n 🛑 clauth serve stopped via SIGTERM (PID ${info.pid})\n`));
1105
- } catch (err) {
1106
- console.log(chalk.yellow(`\n Could not kill PID ${info.pid}: ${err.message}\n`));
1107
- }
1108
- removePid();
1109
- }
1110
-
1111
- async function actionPing() {
1112
- const info = readPid();
1113
- if (!info) {
1114
- console.log(chalk.red("\n clauth serve is not running (no PID file)\n"));
1115
- process.exit(1);
1116
- }
1117
-
1118
- if (!isProcessAlive(info.pid)) {
1119
- console.log(chalk.red(`\n PID ${info.pid} is not alive (stale PID file)\n`));
1120
- removePid();
1121
- process.exit(1);
1122
- }
1123
-
1124
- try {
1125
- const resp = await fetch(`http://127.0.0.1:${info.port}/ping`);
1126
- const data = await resp.json();
1127
- if (data.status === "ok") {
1128
- console.log(chalk.green(`\n ✅ clauth serve running`));
1129
- console.log(chalk.gray(` PID: ${info.pid}`));
1130
- console.log(chalk.gray(` Port: ${info.port}`));
1131
- console.log(chalk.gray(` Fails: ${data.failures}/${data.failures + data.failures_remaining}`));
1132
- console.log(chalk.gray(` Services: ${Array.isArray(data.services) ? data.services.join(", ") : data.services}\n`));
1133
- } else {
1134
- console.log(chalk.yellow(`\n PID alive but /ping returned unexpected response\n`));
1135
- }
1136
- } catch (err) {
1137
- console.log(chalk.yellow(`\n PID ${info.pid} alive but HTTP failed: ${err.message}\n`));
1138
- }
1139
- }
1140
-
1141
- async function actionRestart(opts) {
1142
- const info = readPid();
1143
- if (info && isProcessAlive(info.pid)) {
1144
- await actionStop();
1145
- await new Promise(r => setTimeout(r, 500));
1146
- }
1147
- await actionStart(opts);
1148
- }
1149
-
1150
- async function actionForeground(opts) {
1151
- const port = parseInt(opts.port || "52437", 10);
1152
- const password = opts.pw || null;
1153
- const whitelist = opts.services
1154
- ? opts.services.split(",").map(s => s.trim().toLowerCase())
1155
- : null;
1156
-
1157
- if (password) {
1158
- console.log(chalk.gray("\n Verifying vault credentials..."));
1159
- try {
1160
- await verifyAuth(password);
1161
- } catch (err) {
1162
- console.log(chalk.red(`\n Auth failed: ${err.message}\n`));
1163
- process.exit(1);
1164
- }
1165
- console.log(chalk.green(" Vault auth verified"));
1166
- } else {
1167
- console.log(chalk.yellow("\n Starting in locked state — open browser to unlock"));
1168
- }
1169
-
1170
- console.log(chalk.gray(` Port: 127.0.0.1:${port}`));
1171
- console.log(chalk.gray(` Services: ${whitelist ? whitelist.join(", ") : "all"}`));
1172
- console.log(chalk.gray(` Lockout: 3 failures → exit\n`));
1173
-
1174
- const server = createServer(password, whitelist, port);
1175
- server.listen(port, "127.0.0.1", () => {
1176
- writePid(process.pid, port);
1177
- console.log(chalk.green(` clauth serve http://127.0.0.1:${port}`));
1178
- if (!password) console.log(chalk.cyan(` 👉 Open http://127.0.0.1:${port} to unlock`));
1179
- console.log(chalk.gray(" Ctrl+C to stop\n"));
1180
- });
1181
-
1182
- server.on("error", err => {
1183
- if (err.code === "EADDRINUSE") {
1184
- console.log(chalk.red(`\n Port ${port} already in use. Use --port to choose another.\n`));
1185
- } else {
1186
- console.log(chalk.red(`\n Server error: ${err.message}\n`));
1187
- }
1188
- process.exit(1);
1189
- });
1190
-
1191
- process.on("SIGINT", () => {
1192
- console.log(chalk.yellow("\n Stopping clauth serve...\n"));
1193
- removePid();
1194
- server.close(() => process.exit(0));
1195
- });
1196
- }
1197
-
1198
- // ── Export ────────────────────────────────────────────────────
1199
- export async function runServe(opts) {
1200
- const action = opts.action || "foreground";
1201
-
1202
- switch (action) {
1203
- case "start": return actionStart(opts);
1204
- case "stop": return actionStop();
1205
- case "restart": return actionRestart(opts);
1206
- case "ping": return actionPing();
1207
- case "foreground": return actionForeground(opts);
1208
- default:
1209
- console.log(chalk.red(`\n Unknown serve action: ${action}`));
1210
- console.log(chalk.gray(" Actions: start | stop | restart | ping | foreground\n"));
1211
- process.exit(1);
1212
- }
1213
- }
1
+ // cli/commands/serve.js
2
+ // Localhost-only credential daemon with daemon lifecycle management
3
+ // Binds 127.0.0.1 ONLY — unreachable from outside the machine
4
+ // 3 failed requests of any kind → process exits, requires manual restart
5
+ // Supports: start (background daemon), stop, restart, ping, foreground
6
+
7
+ import http from "http";
8
+ import fs from "fs";
9
+ import os from "os";
10
+ import path from "path";
11
+ import { fileURLToPath } from "url";
12
+ import { getMachineHash, deriveToken, deriveSeedHash } from "../fingerprint.js";
13
+ import * as api from "../api.js";
14
+ import chalk from "chalk";
15
+
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../package.json"), "utf8"));
18
+ const VERSION = pkg.version;
19
+
20
+ const PID_FILE = path.join(os.tmpdir(), "clauth-serve.pid");
21
+ const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
22
+
23
+ // ── PID helpers ──────────────────────────────────────────────
24
+ function readPid() {
25
+ try {
26
+ const raw = fs.readFileSync(PID_FILE, "utf8").trim();
27
+ const [pid, port] = raw.split(":");
28
+ return { pid: parseInt(pid, 10), port: parseInt(port, 10) };
29
+ } catch { return null; }
30
+ }
31
+
32
+ function writePid(pid, port) {
33
+ fs.writeFileSync(PID_FILE, `${pid}:${port}`, "utf8");
34
+ }
35
+
36
+ function removePid() {
37
+ try { fs.unlinkSync(PID_FILE); } catch {}
38
+ }
39
+
40
+ function isProcessAlive(pid) {
41
+ try { process.kill(pid, 0); return true; } catch { return false; }
42
+ }
43
+
44
+ // ── Dashboard HTML ───────────────────────────────────────────
45
+ function dashboardHtml(port, whitelist) {
46
+ return `<!DOCTYPE html>
47
+ <html lang="en">
48
+ <head>
49
+ <meta charset="utf-8">
50
+ <meta name="viewport" content="width=device-width,initial-scale=1">
51
+ <title>clauth vault v${VERSION}</title>
52
+ <style>
53
+ *{margin:0;padding:0;box-sizing:border-box}
54
+ body{background:#0a0f1a;color:#e2e8f0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;min-height:100vh}
55
+ /* ── Lock screen ── */
56
+ #lock-screen{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;padding:2rem}
57
+ .lock-card{background:#1e293b;border:1px solid #334155;border-radius:12px;padding:2.5rem 2rem;width:100%;max-width:380px;text-align:center}
58
+ .lock-icon{font-size:2.5rem;margin-bottom:1rem}
59
+ .lock-title{font-size:1.25rem;font-weight:600;color:#f8fafc;margin-bottom:.4rem}
60
+ .lock-sub{font-size:.85rem;color:#64748b;margin-bottom:1.75rem}
61
+ .lock-input{width:100%;background:#0f172a;border:1px solid #334155;border-radius:8px;color:#e2e8f0;font-family:'Courier New',monospace;font-size:1rem;padding:10px 14px;outline:none;text-align:center;letter-spacing:.1em;transition:border-color .2s;margin-bottom:1rem}
62
+ .lock-input:focus{border-color:#3b82f6}
63
+ .lock-input.error{border-color:#ef4444;animation:shake .3s}
64
+ @keyframes shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-6px)}75%{transform:translateX(6px)}}
65
+ .btn-unlock{width:100%;background:#3b82f6;color:#fff;border:none;border-radius:8px;padding:10px;font-size:.95rem;font-weight:600;cursor:pointer;transition:background .15s}
66
+ .btn-unlock:hover{background:#2563eb}
67
+ .btn-unlock:disabled{background:#1e3a5f;color:#4a6fa5;cursor:not-allowed}
68
+ .lock-err{color:#f87171;font-size:.82rem;margin-top:.75rem;min-height:1.2em}
69
+ /* ── Main view ── */
70
+ #main-view{display:none;padding:2rem}
71
+ .header{display:flex;align-items:center;gap:10px;margin-bottom:1.5rem;flex-wrap:wrap}
72
+ .header h1{font-size:1.4rem;font-weight:600;flex:1}
73
+ .dot{width:10px;height:10px;border-radius:50%;background:#22c55e;animation:pulse 2s infinite;flex-shrink:0}
74
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
75
+ .status-bar{display:flex;gap:1.5rem;margin-bottom:1.5rem;font-size:.82rem;color:#94a3b8;flex-wrap:wrap}
76
+ .status-bar span{color:#e2e8f0;font-weight:500}
77
+ .toolbar{display:flex;gap:8px;margin-bottom:1rem;flex-wrap:wrap;align-items:center}
78
+ .chpw-panel{display:none;background:#1a1f2e;border:1px solid #334155;border-radius:8px;padding:1.25rem;margin-bottom:1.5rem}
79
+ .chpw-panel h3{font-size:.9rem;font-weight:600;color:#f8fafc;margin-bottom:1rem}
80
+ .chpw-row{display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;margin-bottom:.75rem}
81
+ .chpw-field{display:flex;flex-direction:column;gap:4px}
82
+ .chpw-field label{font-size:.75rem;color:#64748b}
83
+ .chpw-input{background:#0f172a;border:1px solid #334155;border-radius:6px;color:#e2e8f0;font-family:'Courier New',monospace;font-size:.88rem;padding:7px 12px;outline:none;width:200px;transition:border-color .2s}
84
+ .chpw-input:focus{border-color:#3b82f6}
85
+ .chpw-foot{display:flex;gap:8px;align-items:center}
86
+ .btn-chpw-save{background:#1e3a5f;color:#60a5fa;padding:7px 18px;font-size:.85rem;border-radius:6px;border:none;cursor:pointer;font-weight:500;transition:background .15s}
87
+ .btn-chpw-save:hover{background:#1e4a7f}
88
+ .chpw-msg{font-size:.82rem}
89
+ .chpw-msg.ok{color:#4ade80} .chpw-msg.fail{color:#f87171}
90
+ .btn-refresh{background:#3b82f6;color:#fff;padding:7px 18px;font-size:.85rem;border-radius:7px;border:none;cursor:pointer;font-weight:500}
91
+ .btn-refresh:hover{background:#2563eb}
92
+ .btn-lock{background:#1e293b;color:#f87171;border:1px solid #334155;padding:7px 16px;font-size:.85rem;border-radius:7px;cursor:pointer;font-weight:500}
93
+ .btn-lock:hover{background:#2d1f1f;border-color:#f87171}
94
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:1rem}
95
+ .card{background:#1e293b;border:1px solid #334155;border-radius:8px;padding:1.25rem;transition:border-color .2s}
96
+ .card:hover{border-color:#3b82f6}
97
+ .card-name{font-size:1rem;font-weight:600;color:#f8fafc;margin-bottom:3px}
98
+ .card-type{font-size:.78rem;color:#64748b;text-transform:uppercase;letter-spacing:.5px}
99
+ .card-getkey{font-size:.75rem;color:#3b82f6;text-decoration:none;opacity:.7;transition:opacity .15s}
100
+ .card-getkey:hover{opacity:1;text-decoration:underline}
101
+ .card-value{font-family:'Courier New',monospace;font-size:.82rem;color:#22c55e;background:#0f172a;border-radius:4px;padding:8px 10px;margin-top:10px;word-break:break-all;max-height:80px;overflow:auto;display:none}
102
+ .card-actions{margin-top:10px;display:flex;gap:7px;flex-wrap:wrap}
103
+ .set-panel{display:none;margin-top:10px;background:#0f172a;border-radius:6px;padding:10px;border:1px solid #1e3a5f}
104
+ .set-panel label{font-size:.75rem;color:#64748b;display:block;margin-bottom:6px}
105
+ .set-input{width:100%;background:#0a0f1a;border:1px solid #1e3a5f;border-radius:4px;color:#e2e8f0;font-family:'Courier New',monospace;font-size:.85rem;padding:7px 10px;outline:none;resize:vertical;min-height:58px;transition:border-color .2s}
106
+ .set-input:focus{border-color:#3b82f6}
107
+ .set-foot{margin-top:8px;display:flex;gap:8px;align-items:center}
108
+ .set-msg{font-size:.8rem}
109
+ .set-msg.ok{color:#4ade80} .set-msg.fail{color:#f87171}
110
+ .btn{padding:6px 13px;border-radius:6px;border:none;cursor:pointer;font-size:.8rem;font-weight:500;transition:all .15s}
111
+ .btn-reveal{background:#1e3a5f;color:#60a5fa}.btn-reveal:hover{background:#1e4a7f}
112
+ .btn-copy{background:#1a3328;color:#4ade80}.btn-copy:hover{background:#1a4338}
113
+ .btn-set{background:#2d1f4a;color:#a78bfa}.btn-set:hover{background:#3d2f5a}
114
+ .btn-save{background:#1e3a5f;color:#60a5fa;padding:6px 16px}.btn-save:hover{background:#1e4a7f}
115
+ .btn-cancel{background:transparent;color:#64748b;padding:6px 10px}.btn-cancel:hover{color:#94a3b8}
116
+ .btn-check{background:#0f2d2d;color:#34d399;border:1px solid #064e3b;padding:7px 16px;font-size:.85rem;border-radius:7px;cursor:pointer;font-weight:500;transition:all .15s}
117
+ .btn-check:hover{background:#134e4a;border-color:#34d399}.btn-check:disabled{opacity:.5;cursor:not-allowed}
118
+ .btn-enable{background:#14291a;color:#4ade80;border:1px solid #166534}.btn-enable:hover{background:#1a3d22;border-color:#4ade80}
119
+ .btn-disable{background:#2d1f1f;color:#f87171;border:1px solid #7f1d1d}.btn-disable:hover{background:#3d2525;border-color:#f87171}
120
+ .svc-badge{font-size:.7rem;font-weight:600;padding:2px 7px;border-radius:4px;letter-spacing:.4px;text-transform:uppercase}
121
+ .svc-badge.on{background:rgba(74,222,128,.12);color:#4ade80;border:1px solid rgba(74,222,128,.25)}
122
+ .svc-badge.off{background:rgba(248,113,113,.1);color:#f87171;border:1px solid rgba(248,113,113,.2)}
123
+ .status-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;opacity:0;transition:opacity .3s;margin-top:4px;cursor:default}
124
+ .status-dot.checking{background:#f59e0b;opacity:1;animation:pulse 1s infinite}
125
+ .status-dot.ok{background:#22c55e;opacity:1}
126
+ .status-dot.fail{background:#ef4444;opacity:1}
127
+ @keyframes sdot-fade{to{opacity:0}} .status-dot.fading{animation:sdot-fade 1.5s forwards}
128
+ .error-bar{background:#7f1d1d;color:#fca5a5;border:1px solid #991b1b;padding:10px 14px;border-radius:8px;margin-bottom:1rem;display:none;font-size:.85rem}
129
+ .loading{color:#64748b;font-style:italic}
130
+ .footer{margin-top:2rem;font-size:.75rem;color:#475569;text-align:center}
131
+ .oauth-fields{display:flex;flex-direction:column;gap:8px;margin-bottom:8px}
132
+ .oauth-field{display:flex;flex-direction:column;gap:3px}
133
+ .oauth-label{font-size:.75rem;color:#94a3b8;font-weight:500}
134
+ .oauth-hint{font-size:.71rem;color:#475569;font-style:italic}
135
+ .oauth-input{width:100%;background:#0a0f1a;border:1px solid #1e3a5f;border-radius:4px;color:#e2e8f0;font-family:'Courier New',monospace;font-size:.85rem;padding:7px 10px;outline:none;transition:border-color .2s}
136
+ .oauth-input:focus{border-color:#3b82f6}
137
+ </style>
138
+ </head>
139
+ <body>
140
+
141
+ <!-- ── Lock screen ──────────────────────────── -->
142
+ <div id="lock-screen">
143
+ <div class="lock-card">
144
+ <div class="lock-icon">🔒</div>
145
+ <div class="lock-title">clauth vault</div>
146
+ <div class="lock-sub">Paste your password to unlock</div>
147
+ <input class="lock-input" id="lock-input" type="password" placeholder="••••••••••••" autocomplete="off">
148
+ <button class="btn-unlock" id="unlock-btn" onclick="unlock()">Unlock</button>
149
+ <div class="lock-err" id="lock-err"></div>
150
+ </div>
151
+ </div>
152
+
153
+ <!-- ── Main view (shown after unlock) ──────── -->
154
+ <div id="main-view">
155
+ <div class="header">
156
+ <div class="dot" id="dot"></div>
157
+ <h1>🔐 clauth vault <span style="font-size:0.55em;opacity:0.45;font-weight:400">v${VERSION}</span></h1>
158
+ </div>
159
+ <div id="error-bar" class="error-bar"></div>
160
+ <div class="status-bar">
161
+ <div>PID: <span id="s-pid">—</span></div>
162
+ <div>Port: <span id="s-port">${port}</span></div>
163
+ <div>Services: <span id="s-services">${whitelist ? whitelist.join(", ") : "all"}</span></div>
164
+ <div>Failures: <span id="s-fails">—</span></div>
165
+ </div>
166
+ <div class="toolbar">
167
+ <button class="btn-refresh" onclick="loadServices()">↻ Refresh</button>
168
+ <button class="btn-check" id="check-btn" onclick="checkAll()">⬤ Check All</button>
169
+ <button class="btn-lock" onclick="lockVault()">🔒 Lock</button>
170
+ <button class="btn-cancel" style="margin-left:auto" onclick="toggleChangePw()">Change Password</button>
171
+ </div>
172
+
173
+ <div class="chpw-panel" id="chpw-panel">
174
+ <h3>Change Master Password</h3>
175
+ <div class="chpw-row">
176
+ <div class="chpw-field">
177
+ <label>New password</label>
178
+ <input class="chpw-input" id="chpw-new" type="password" placeholder="min 8 chars" autocomplete="new-password">
179
+ </div>
180
+ <div class="chpw-field">
181
+ <label>Confirm</label>
182
+ <input class="chpw-input" id="chpw-confirm" type="password" placeholder="repeat" autocomplete="new-password">
183
+ </div>
184
+ </div>
185
+ <div class="chpw-foot">
186
+ <button class="btn-chpw-save" onclick="changePassword()">Update Password</button>
187
+ <button class="btn-cancel" onclick="toggleChangePw()">Cancel</button>
188
+ <span class="chpw-msg" id="chpw-msg"></span>
189
+ </div>
190
+ </div>
191
+
192
+ <div id="grid" class="grid"><p class="loading">Loading services…</p></div>
193
+ <div class="footer">localhost:${port} · 127.0.0.1 only · 3-strike lockout</div>
194
+ </div>
195
+
196
+ <script>
197
+ const BASE = "http://127.0.0.1:${port}";
198
+
199
+ const SERVICE_HINTS = {
200
+ "neo4j": "neo4j+s://username:password@instance.databases.neo4j.io",
201
+ "supabase-db": "postgresql://postgres:password@db.ref.supabase.co:5432/postgres",
202
+ "r2": "accountId:accessKeyId:secretAccessKey",
203
+ "namecheap": "apiUser:apiKey",
204
+ };
205
+
206
+ const KEY_URLS = {
207
+ "github": "https://github.com/settings/tokens",
208
+ "vercel": "https://vercel.com/account/tokens",
209
+ "supabase-anon": "https://supabase.com/dashboard/project/uvojezuorjgqzmhhgluu/settings/api",
210
+ "supabase-service":"https://supabase.com/dashboard/project/uvojezuorjgqzmhhgluu/settings/api",
211
+ "supabase-db": "https://supabase.com/dashboard/project/uvojezuorjgqzmhhgluu/settings/database",
212
+ "anthropic": "https://console.anthropic.com/settings/keys",
213
+ "cloudflare": "https://dash.cloudflare.com/profile/api-tokens",
214
+ "r2": "https://dash.cloudflare.com/profile/api-tokens",
215
+ "r2-bucket": "https://dash.cloudflare.com/profile/api-tokens",
216
+ "rocketreach": "https://rocketreach.co/account?section=security",
217
+ "namecheap": "https://ap.www.namecheap.com/settings/tools/apiaccess/",
218
+ "neo4j": "https://console.neo4j.io/",
219
+ "npm": "https://www.npmjs.com/settings/~/tokens",
220
+ "gmail": "https://console.cloud.google.com/apis/credentials",
221
+ };
222
+
223
+ // ── OAuth import config ─────────────────────
224
+ // Services where Google downloads a JSON file. jsonFields = keys to extract
225
+ // from the downloaded JSON (top-level or under an "installed"/"web" wrapper).
226
+ // extra = additional fields the user must provide separately.
227
+ const OAUTH_IMPORT = {
228
+ "gmail": {
229
+ jsonFields: ["client_id", "client_secret"],
230
+ extra: [{ key: "refresh_token", label: "Refresh Token", hint: "From Google OAuth Playground or your app's auth callback" }]
231
+ }
232
+ };
233
+
234
+ function renderSetPanel(name) {
235
+ const imp = OAUTH_IMPORT[name];
236
+ if (imp) {
237
+ const extraHtml = imp.extra.map(f => \`
238
+ <div class="oauth-field">
239
+ <label class="oauth-label">\${f.label}</label>
240
+ \${f.hint ? \`<div class="oauth-hint">\${f.hint}</div>\` : ""}
241
+ <input type="text" class="oauth-input" id="ofield-\${name}-\${f.key}" placeholder="Paste \${f.label}…" spellcheck="false" autocomplete="off">
242
+ </div>
243
+ \`).join("");
244
+ return \`
245
+ <div class="set-panel" id="set-panel-\${name}">
246
+ <label>Set <strong>\${name}</strong> credentials — paste directly from Google, never in chat</label>
247
+ <div class="oauth-fields">
248
+ <div class="oauth-field">
249
+ <label class="oauth-label">OAuth JSON from Google Cloud Console</label>
250
+ <div class="oauth-hint">Download from APIs & Services → Credentials → your OAuth client → ↓ Download JSON</div>
251
+ <textarea class="set-input" id="ofield-\${name}-json" placeholder='{"installed":{"client_id":"…","client_secret":"…",...}}' spellcheck="false" rows="3"></textarea>
252
+ </div>
253
+ \${extraHtml}
254
+ </div>
255
+ <div class="set-foot">
256
+ <button class="btn btn-save" onclick="saveKey('\${name}')">Save</button>
257
+ <button class="btn btn-cancel" onclick="toggleSet('\${name}')">Cancel</button>
258
+ <span class="set-msg" id="set-msg-\${name}"></span>
259
+ </div>
260
+ </div>
261
+ \`;
262
+ }
263
+ return \`
264
+ <div class="set-panel" id="set-panel-\${name}">
265
+ <label>New value for <strong>\${name}</strong> — paste here, never in chat</label>
266
+ <textarea class="set-input" id="set-input-\${name}" placeholder="\${SERVICE_HINTS[name] || "Paste credential…"}" spellcheck="false"></textarea>
267
+ \${SERVICE_HINTS[name] ? \`<div style="font-size:.72rem;color:#475569;margin-top:4px;font-family:'Courier New',monospace">\${SERVICE_HINTS[name]}</div>\` : ""}
268
+ <div class="set-foot">
269
+ <button class="btn btn-save" onclick="saveKey('\${name}')">Save</button>
270
+ <button class="btn btn-cancel" onclick="toggleSet('\${name}')">Cancel</button>
271
+ <span class="set-msg" id="set-msg-\${name}"></span>
272
+ </div>
273
+ </div>
274
+ \`;
275
+ }
276
+
277
+ // ── Boot: check lock state ──────────────────
278
+ async function boot() {
279
+ try {
280
+ const ping = await fetch(BASE + "/ping").then(r => r.json());
281
+ if (ping.locked) {
282
+ showLockScreen();
283
+ } else {
284
+ showMain(ping);
285
+ loadServices();
286
+ }
287
+ } catch {
288
+ showLockScreen();
289
+ }
290
+ }
291
+
292
+ function showLockScreen() {
293
+ document.getElementById("lock-screen").style.display = "flex";
294
+ document.getElementById("main-view").style.display = "none";
295
+ setTimeout(() => document.getElementById("lock-input").focus(), 50);
296
+ }
297
+
298
+ function showMain(ping) {
299
+ document.getElementById("lock-screen").style.display = "none";
300
+ document.getElementById("main-view").style.display = "block";
301
+ if (ping) {
302
+ document.getElementById("s-pid").textContent = ping.pid || "—";
303
+ document.getElementById("s-fails").textContent =
304
+ ping.failures + "/" + (ping.failures + ping.failures_remaining);
305
+ }
306
+ }
307
+
308
+ // ── Unlock ──────────────────────────────────
309
+ async function unlock() {
310
+ const input = document.getElementById("lock-input");
311
+ const btn = document.getElementById("unlock-btn");
312
+ const err = document.getElementById("lock-err");
313
+ const pw = input.value;
314
+
315
+ if (!pw) { err.textContent = "Password is required."; return; }
316
+
317
+ btn.disabled = true; btn.textContent = "Verifying…"; err.textContent = "";
318
+
319
+ try {
320
+ const r = await fetch(BASE + "/auth", {
321
+ method: "POST",
322
+ headers: { "Content-Type": "application/json" },
323
+ body: JSON.stringify({ password: pw })
324
+ }).then(r => r.json());
325
+
326
+ if (r.error) throw new Error(r.error);
327
+
328
+ input.value = "";
329
+ const ping = await fetch(BASE + "/ping").then(r => r.json());
330
+ showMain(ping);
331
+ loadServices();
332
+ } catch (e) {
333
+ input.value = "";
334
+ input.className = "lock-input error";
335
+ err.textContent = "✗ " + (e.message || "Invalid password");
336
+ setTimeout(() => input.className = "lock-input", 600);
337
+ } finally {
338
+ btn.disabled = false; btn.textContent = "Unlock";
339
+ }
340
+ }
341
+
342
+ // ── Lock ────────────────────────────────────
343
+ async function lockVault() {
344
+ await fetch(BASE + "/lock", { method: "POST" }).catch(() => {});
345
+ showLockScreen();
346
+ }
347
+
348
+ // ── Load services ───────────────────────────
349
+ async function loadServices() {
350
+ const grid = document.getElementById("grid");
351
+ const err = document.getElementById("error-bar");
352
+ err.style.display = "none";
353
+ grid.innerHTML = '<p class="loading">Loading…</p>';
354
+
355
+ try {
356
+ const status = await fetch(BASE + "/status").then(r => r.json());
357
+ if (status.locked) { showLockScreen(); return; }
358
+ if (status.error) throw new Error(status.error);
359
+
360
+ const services = status.services || [];
361
+ if (!services.length) { grid.innerHTML = '<p class="loading">No services registered.</p>'; return; }
362
+
363
+ grid.innerHTML = services.map(s => \`
364
+ <div class="card">
365
+ <div style="display:flex;align-items:flex-start;justify-content:space-between">
366
+ <div>
367
+ <div class="card-name">\${s.name}</div>
368
+ <div style="display:flex;align-items:center;gap:6px;margin-top:2px">
369
+ <div class="card-type">\${s.key_type || "secret"}</div>
370
+ <span class="svc-badge \${s.enabled === false ? "off" : "on"}" id="badge-\${s.name}">\${s.enabled === false ? "disabled" : "enabled"}</span>
371
+ </div>
372
+ \${KEY_URLS[s.name] ? \`<a class="card-getkey" href="\${KEY_URLS[s.name]}" target="_blank" rel="noopener">↗ Get / rotate key</a>\` : ""}
373
+ </div>
374
+ <div class="status-dot" id="sdot-\${s.name}" title=""></div>
375
+ </div>
376
+ <div class="card-value" id="val-\${s.name}"></div>
377
+ <div class="card-actions">
378
+ <button class="btn btn-reveal" onclick="reveal('\${s.name}', this)">Reveal</button>
379
+ <button class="btn btn-copy" id="copybtn-\${s.name}" style="display:none" onclick="copyKey('\${s.name}')">Copy</button>
380
+ <button class="btn btn-set" onclick="toggleSet('\${s.name}')">Set</button>
381
+ <button class="btn \${s.enabled === false ? "btn-enable" : "btn-disable"}" id="togbtn-\${s.name}" onclick="toggleService('\${s.name}')">\${s.enabled === false ? "Enable" : "Disable"}</button>
382
+ </div>
383
+ \${renderSetPanel(s.name)}
384
+ </div>
385
+ \`).join("");
386
+ } catch (e) {
387
+ err.textContent = "⚠ " + e.message;
388
+ err.style.display = "block";
389
+ document.getElementById("dot").style.background = "#ef4444";
390
+ grid.innerHTML = "";
391
+ }
392
+ }
393
+
394
+ // ── Reveal ──────────────────────────────────
395
+ async function reveal(name, btn) {
396
+ const valEl = document.getElementById("val-" + name);
397
+ const copyBtn = document.getElementById("copybtn-" + name);
398
+ if (valEl.style.display === "block") {
399
+ valEl.style.display = "none"; copyBtn.style.display = "none";
400
+ btn.textContent = "Reveal"; return;
401
+ }
402
+ valEl.textContent = "fetching…"; valEl.style.display = "block"; btn.textContent = "Hide";
403
+ try {
404
+ const r = await fetch(BASE + "/get/" + name).then(r => r.json());
405
+ if (r.locked) { showLockScreen(); return; }
406
+ if (r.error) throw new Error(r.error);
407
+ const imp = OAUTH_IMPORT[name];
408
+ if (imp) {
409
+ try {
410
+ const parsed = JSON.parse(r.value);
411
+ const allKeys = [...imp.jsonFields, ...imp.extra.map(f => f.key)];
412
+ valEl.innerHTML = allKeys.map(k =>
413
+ '<div style="margin-bottom:6px"><span style="color:#64748b;font-size:.72rem;text-transform:uppercase;letter-spacing:.4px">' + k.replace(/_/g," ") + '</span><br>' + (parsed[k] || "—") + '</div>'
414
+ ).join("");
415
+ } catch { valEl.textContent = r.value; }
416
+ } else {
417
+ valEl.textContent = r.value;
418
+ }
419
+ copyBtn.style.display = "inline-block";
420
+ } catch (e) {
421
+ valEl.textContent = "Error: " + e.message; valEl.style.color = "#ef4444";
422
+ }
423
+ }
424
+
425
+ async function copyKey(name) {
426
+ const val = document.getElementById("val-" + name).textContent;
427
+ try {
428
+ await navigator.clipboard.writeText(val);
429
+ const btn = document.getElementById("copybtn-" + name);
430
+ btn.textContent = "Copied!"; setTimeout(() => btn.textContent = "Copy", 1500);
431
+ } catch {}
432
+ }
433
+
434
+ // ── Set key ─────────────────────────────────
435
+ function toggleSet(name) {
436
+ const panel = document.getElementById("set-panel-" + name);
437
+ const msg = document.getElementById("set-msg-" + name);
438
+ const open = panel.style.display === "block";
439
+ panel.style.display = open ? "none" : "block";
440
+ if (!open) {
441
+ if (msg) msg.textContent = "";
442
+ const imp = OAUTH_IMPORT[name];
443
+ if (imp) {
444
+ const jsonEl = document.getElementById("ofield-" + name + "-json");
445
+ if (jsonEl) { jsonEl.value = ""; jsonEl.focus(); }
446
+ imp.extra.forEach(f => { const el = document.getElementById("ofield-" + name + "-" + f.key); if (el) el.value = ""; });
447
+ } else {
448
+ const input = document.getElementById("set-input-" + name);
449
+ if (input) { input.value = ""; input.focus(); }
450
+ }
451
+ }
452
+ }
453
+
454
+ async function saveKey(name) {
455
+ const msg = document.getElementById("set-msg-" + name);
456
+ const imp = OAUTH_IMPORT[name];
457
+ let value;
458
+
459
+ if (imp) {
460
+ const jsonEl = document.getElementById("ofield-" + name + "-json");
461
+ const raw = jsonEl ? jsonEl.value.trim() : "";
462
+ if (!raw) { msg.className = "set-msg fail"; msg.textContent = "Paste the OAuth JSON first."; return; }
463
+ let parsed;
464
+ try { parsed = JSON.parse(raw); } catch { msg.className = "set-msg fail"; msg.textContent = "Invalid JSON — copy the full file content."; return; }
465
+ // Google wraps fields under "installed" or "web"
466
+ const src = parsed.installed || parsed.web || parsed;
467
+ const obj = {};
468
+ for (const k of imp.jsonFields) {
469
+ if (!src[k]) { msg.className = "set-msg fail"; msg.textContent = k + " not found in JSON."; return; }
470
+ obj[k] = src[k];
471
+ }
472
+ for (const f of imp.extra) {
473
+ const el = document.getElementById("ofield-" + name + "-" + f.key);
474
+ const v = el ? el.value.trim() : "";
475
+ if (!v) { msg.className = "set-msg fail"; msg.textContent = f.label + " is required."; return; }
476
+ obj[f.key] = v;
477
+ }
478
+ value = JSON.stringify(obj);
479
+ } else {
480
+ const input = document.getElementById("set-input-" + name);
481
+ value = input ? input.value.trim() : "";
482
+ if (!value) { msg.className = "set-msg fail"; msg.textContent = "Value is empty."; return; }
483
+ }
484
+
485
+ msg.className = "set-msg"; msg.textContent = "Saving…";
486
+ try {
487
+ const r = await fetch(BASE + "/set/" + name, {
488
+ method: "POST",
489
+ headers: { "Content-Type": "application/json" },
490
+ body: JSON.stringify({ value })
491
+ }).then(r => r.json());
492
+
493
+ if (r.locked) { showLockScreen(); return; }
494
+ if (r.error) throw new Error(r.error);
495
+ msg.className = "set-msg ok"; msg.textContent = "✓ Saved";
496
+ if (imp) {
497
+ const jsonEl = document.getElementById("ofield-" + name + "-json");
498
+ if (jsonEl) jsonEl.value = "";
499
+ imp.extra.forEach(f => { const el = document.getElementById("ofield-" + name + "-" + f.key); if (el) el.value = ""; });
500
+ } else {
501
+ const inp = document.getElementById("set-input-" + name);
502
+ if (inp) inp.value = "";
503
+ }
504
+ const dot = document.getElementById("sdot-" + name);
505
+ if (dot) { dot.className = "status-dot"; dot.title = ""; }
506
+ setTimeout(() => {
507
+ document.getElementById("set-panel-" + name).style.display = "none";
508
+ msg.textContent = "";
509
+ }, 1800);
510
+ } catch (e) {
511
+ msg.className = "set-msg fail"; msg.textContent = "" + e.message;
512
+ }
513
+ }
514
+
515
+ // ── Enable / Disable service ────────────────
516
+ async function toggleService(name) {
517
+ const badge = document.getElementById("badge-" + name);
518
+ const btn = document.getElementById("togbtn-" + name);
519
+ const currently = badge.classList.contains("on");
520
+ const newState = !currently;
521
+
522
+ btn.disabled = true; btn.textContent = "…";
523
+
524
+ try {
525
+ const r = await fetch(BASE + "/toggle/" + name, {
526
+ method: "POST",
527
+ headers: { "Content-Type": "application/json" },
528
+ body: JSON.stringify({ enabled: newState })
529
+ }).then(r => r.json());
530
+
531
+ if (r.locked) { showLockScreen(); return; }
532
+ if (r.error) throw new Error(r.error);
533
+
534
+ badge.className = "svc-badge " + (newState ? "on" : "off");
535
+ badge.textContent = newState ? "enabled" : "disabled";
536
+ btn.className = "btn " + (newState ? "btn-disable" : "btn-enable");
537
+ btn.textContent = newState ? "Disable" : "Enable";
538
+ } catch (e) {
539
+ btn.textContent = "Error";
540
+ setTimeout(() => {
541
+ btn.textContent = currently ? "Disable" : "Enable";
542
+ }, 2000);
543
+ } finally {
544
+ btn.disabled = false;
545
+ }
546
+ }
547
+
548
+ // ── Check all credentials ───────────────────
549
+ async function checkAll() {
550
+ const btn = document.getElementById("check-btn");
551
+ btn.disabled = true; btn.textContent = "Checking…";
552
+
553
+ // Show checking state on every visible dot
554
+ document.querySelectorAll(".status-dot").forEach(d => {
555
+ d.className = "status-dot checking"; d.title = "Checking…";
556
+ });
557
+
558
+ try {
559
+ const r = await fetch(BASE + "/check-all").then(r => r.json());
560
+ if (r.locked) { showLockScreen(); return; }
561
+ if (r.error) throw new Error(r.error);
562
+
563
+ const results = r.results || {};
564
+ for (const [name, result] of Object.entries(results)) {
565
+ const dot = document.getElementById("sdot-" + name);
566
+ if (!dot) continue;
567
+ if (result.ok) {
568
+ dot.className = "status-dot ok"; dot.title = "OK";
569
+ } else {
570
+ dot.className = "status-dot fail";
571
+ dot.title = result.reason || "No key stored";
572
+ }
573
+ }
574
+
575
+ // Fade green dots after 3 s — red dots stay until next check
576
+ setTimeout(() => {
577
+ document.querySelectorAll(".status-dot.ok").forEach(d => d.classList.add("fading"));
578
+ }, 3000);
579
+
580
+ } catch (e) {
581
+ document.querySelectorAll(".status-dot").forEach(d => { d.className = "status-dot"; d.title = ""; });
582
+ const errBar = document.getElementById("error-bar");
583
+ errBar.textContent = "⚠ Check failed: " + e.message;
584
+ errBar.style.display = "block";
585
+ } finally {
586
+ btn.disabled = false; btn.textContent = "⬤ Check All";
587
+ }
588
+ }
589
+
590
+ // ── Change password ─────────────────────────
591
+ function toggleChangePw() {
592
+ const panel = document.getElementById("chpw-panel");
593
+ const open = panel.style.display === "block";
594
+ panel.style.display = open ? "none" : "block";
595
+ if (!open) {
596
+ document.getElementById("chpw-new").value = "";
597
+ document.getElementById("chpw-confirm").value = "";
598
+ document.getElementById("chpw-msg").textContent = "";
599
+ document.getElementById("chpw-new").focus();
600
+ }
601
+ }
602
+
603
+ async function changePassword() {
604
+ const newPw = document.getElementById("chpw-new").value;
605
+ const confPw = document.getElementById("chpw-confirm").value;
606
+ const msg = document.getElementById("chpw-msg");
607
+
608
+ if (!newPw) { msg.className = "chpw-msg fail"; msg.textContent = "Enter a new password."; return; }
609
+ if (newPw.length < 8) { msg.className = "chpw-msg fail"; msg.textContent = "Minimum 8 characters."; return; }
610
+ if (newPw !== confPw) { msg.className = "chpw-msg fail"; msg.textContent = "Passwords don't match."; return; }
611
+
612
+ msg.className = "chpw-msg"; msg.textContent = "Updating…";
613
+ try {
614
+ const r = await fetch(BASE + "/change-pw", {
615
+ method: "POST",
616
+ headers: { "Content-Type": "application/json" },
617
+ body: JSON.stringify({ newPassword: newPw })
618
+ }).then(r => r.json());
619
+
620
+ if (r.locked) { showLockScreen(); return; }
621
+ if (r.error) throw new Error(r.error);
622
+
623
+ msg.className = "chpw-msg ok"; msg.textContent = "✓ Password updated";
624
+ document.getElementById("chpw-new").value = "";
625
+ document.getElementById("chpw-confirm").value = "";
626
+ setTimeout(() => {
627
+ document.getElementById("chpw-panel").style.display = "none";
628
+ msg.textContent = "";
629
+ }, 2000);
630
+ } catch (e) {
631
+ msg.className = "chpw-msg fail"; msg.textContent = "✗ " + e.message;
632
+ }
633
+ }
634
+
635
+ // Enter key on lock screen
636
+ document.addEventListener("DOMContentLoaded", () => {
637
+ document.getElementById("lock-input").addEventListener("keydown", e => {
638
+ if (e.key === "Enter") unlock();
639
+ });
640
+ });
641
+
642
+ boot();
643
+ </script>
644
+ </body>
645
+ </html>`;
646
+ }
647
+
648
+ // ── Body parser helper ────────────────────────────────────────
649
+ function readBody(req) {
650
+ return new Promise((resolve, reject) => {
651
+ let data = "";
652
+ req.on("data", chunk => { data += chunk; if (data.length > 65536) reject(new Error("Body too large")); });
653
+ req.on("end", () => { try { resolve(JSON.parse(data)); } catch { reject(new Error("Invalid JSON")); } });
654
+ req.on("error", reject);
655
+ });
656
+ }
657
+
658
+ // ── Server logic (shared by foreground + daemon) ─────────────
659
+ function createServer(initPassword, whitelist, port) {
660
+ const MAX_FAILS = 3;
661
+ let failCount = 0;
662
+ let password = initPassword || null; // null = locked; set via POST /auth
663
+ const machineHash = getMachineHash();
664
+
665
+ const CORS = {
666
+ "Access-Control-Allow-Origin": "*",
667
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
668
+ "Access-Control-Allow-Headers": "Content-Type",
669
+ };
670
+
671
+ function strike(res, code, message) {
672
+ failCount++;
673
+ const remaining = MAX_FAILS - failCount;
674
+ const logLine = `[${new Date().toISOString()}] [FAIL ${failCount}/${MAX_FAILS}] ${message}\n`;
675
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
676
+
677
+ const body = JSON.stringify({
678
+ error: message,
679
+ failures: failCount,
680
+ failures_remaining: remaining,
681
+ ...(failCount >= MAX_FAILS ? { shutdown: true } : {})
682
+ });
683
+
684
+ res.writeHead(code, { "Content-Type": "application/json", ...CORS });
685
+ res.end(body);
686
+
687
+ if (failCount >= MAX_FAILS) {
688
+ const msg = `[${new Date().toISOString()}] Failure limit reached — shutting down\n`;
689
+ try { fs.appendFileSync(LOG_FILE, msg); } catch {}
690
+ removePid();
691
+ setTimeout(() => process.exit(1), 100);
692
+ }
693
+ }
694
+
695
+ function ok(res, data) {
696
+ res.writeHead(200, { "Content-Type": "application/json", ...CORS });
697
+ res.end(JSON.stringify(data));
698
+ }
699
+
700
+ const server = http.createServer(async (req, res) => {
701
+ // Hard reject anything not from loopback
702
+ const remote = req.socket.remoteAddress;
703
+ const isLocal = remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
704
+ if (!isLocal) {
705
+ return strike(res, 403, `Rejected non-local address: ${remote}`);
706
+ }
707
+
708
+ // CORS preflight
709
+ if (req.method === "OPTIONS") {
710
+ res.writeHead(204, CORS);
711
+ return res.end();
712
+ }
713
+
714
+ const url = new URL(req.url, `http://127.0.0.1:${port}`);
715
+ const reqPath = url.pathname;
716
+ const method = req.method;
717
+
718
+ // GET / — built-in web dashboard
719
+ if (method === "GET" && reqPath === "/") {
720
+ res.writeHead(200, { "Content-Type": "text/html", ...CORS });
721
+ return res.end(dashboardHtml(port, whitelist));
722
+ }
723
+
724
+ // GET /ping
725
+ if (method === "GET" && reqPath === "/ping") {
726
+ return ok(res, {
727
+ status: "ok",
728
+ pid: process.pid,
729
+ locked: !password,
730
+ failures: failCount,
731
+ failures_remaining: MAX_FAILS - failCount,
732
+ services: whitelist || "all",
733
+ port
734
+ });
735
+ }
736
+
737
+ // GET /shutdown (for daemon stop)
738
+ if (method === "GET" && reqPath === "/shutdown") {
739
+ ok(res, { ok: true, message: "shutting down" });
740
+ removePid();
741
+ setTimeout(() => process.exit(0), 100);
742
+ return;
743
+ }
744
+
745
+ // Locked guard — returns true if locked and already responded
746
+ function lockedGuard(res) {
747
+ if (!password) {
748
+ res.writeHead(401, { "Content-Type": "application/json", ...CORS });
749
+ res.end(JSON.stringify({ error: "Vault is locked", locked: true }));
750
+ return true;
751
+ }
752
+ return false;
753
+ }
754
+
755
+ // GET /status
756
+ if (method === "GET" && reqPath === "/status") {
757
+ if (lockedGuard(res)) return;
758
+ try {
759
+ const { token, timestamp } = deriveToken(password, machineHash);
760
+ const result = await api.status(password, machineHash, token, timestamp);
761
+ if (result.error) return strike(res, 502, result.error);
762
+ if (whitelist) {
763
+ result.services = (result.services || []).filter(
764
+ s => whitelist.includes(s.name.toLowerCase())
765
+ );
766
+ }
767
+ return ok(res, result);
768
+ } catch (err) {
769
+ return strike(res, 502, err.message);
770
+ }
771
+ }
772
+
773
+ // GET /get/:service
774
+ const getMatch = reqPath.match(/^\/get\/([a-zA-Z0-9_-]+)$/);
775
+ if (method === "GET" && getMatch) {
776
+ if (lockedGuard(res)) return;
777
+ const service = getMatch[1].toLowerCase();
778
+
779
+ if (whitelist && !whitelist.includes(service)) {
780
+ return strike(res, 403, `Service '${service}' not in whitelist`);
781
+ }
782
+
783
+ try {
784
+ const { token, timestamp } = deriveToken(password, machineHash);
785
+ const result = await api.retrieve(password, machineHash, token, timestamp, service);
786
+ if (result.error) return strike(res, 502, result.error);
787
+ return ok(res, { service, value: result.value, key_type: result.key_type });
788
+ } catch (err) {
789
+ return strike(res, 502, err.message);
790
+ }
791
+ }
792
+
793
+ // POST /auth — unlock the vault with a password (verifies against Edge Function)
794
+ if (method === "POST" && reqPath === "/auth") {
795
+ let body;
796
+ try { body = await readBody(req); } catch {
797
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
798
+ return res.end(JSON.stringify({ error: "Invalid JSON body" }));
799
+ }
800
+
801
+ const pw = body.password;
802
+ if (!pw || typeof pw !== "string") {
803
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
804
+ return res.end(JSON.stringify({ error: "password is required" }));
805
+ }
806
+
807
+ try {
808
+ const { token, timestamp } = deriveToken(pw, machineHash);
809
+ const result = await api.test(pw, machineHash, token, timestamp);
810
+ if (result.error) throw new Error(result.error);
811
+ password = pw; // unlock — store in process memory only
812
+ const logLine = `[${new Date().toISOString()}] Vault unlocked\n`;
813
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
814
+ return ok(res, { ok: true, locked: false });
815
+ } catch {
816
+ // Wrong password — not a lockout strike, just a UI auth attempt
817
+ res.writeHead(401, { "Content-Type": "application/json", ...CORS });
818
+ return res.end(JSON.stringify({ error: "Invalid password" }));
819
+ }
820
+ }
821
+
822
+ // POST /lock — clear password from memory
823
+ if (method === "POST" && reqPath === "/lock") {
824
+ password = null;
825
+ const logLine = `[${new Date().toISOString()}] Vault locked\n`;
826
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
827
+ return ok(res, { ok: true, locked: true });
828
+ }
829
+
830
+ // POST /toggle/:service — enable or disable a service
831
+ const toggleMatch = reqPath.match(/^\/toggle\/([a-zA-Z0-9_-]+)$/);
832
+ if (method === "POST" && toggleMatch) {
833
+ if (lockedGuard(res)) return;
834
+ const service = toggleMatch[1].toLowerCase();
835
+
836
+ let body;
837
+ try { body = await readBody(req); } catch {
838
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
839
+ return res.end(JSON.stringify({ error: "Invalid JSON body" }));
840
+ }
841
+
842
+ const { enabled } = body;
843
+ if (typeof enabled !== "boolean") {
844
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
845
+ return res.end(JSON.stringify({ error: "enabled must be boolean" }));
846
+ }
847
+
848
+ try {
849
+ const { token, timestamp } = deriveToken(password, machineHash);
850
+ const result = await api.enable(password, machineHash, token, timestamp, service, enabled);
851
+ if (result.error) return strike(res, 502, result.error);
852
+ return ok(res, { ok: true, service, enabled });
853
+ } catch (err) {
854
+ return strike(res, 502, err.message);
855
+ }
856
+ }
857
+
858
+ // GET /check-all — soft health check, no strikes for missing/disabled keys
859
+ if (method === "GET" && reqPath === "/check-all") {
860
+ if (lockedGuard(res)) return;
861
+ try {
862
+ const { token: st, timestamp: sts } = deriveToken(password, machineHash);
863
+ const statusResult = await api.status(password, machineHash, st, sts);
864
+ if (statusResult.error) return strike(res, 502, statusResult.error);
865
+
866
+ const services = (statusResult.services || []).filter(
867
+ s => !whitelist || whitelist.includes(s.name.toLowerCase())
868
+ );
869
+
870
+ const results = {};
871
+ for (const svc of services) {
872
+ try {
873
+ const { token, timestamp } = deriveToken(password, machineHash);
874
+ const r = await api.retrieve(password, machineHash, token, timestamp, svc.name);
875
+ results[svc.name] = r.error
876
+ ? { ok: false, reason: r.error }
877
+ : { ok: true };
878
+ } catch (e) {
879
+ results[svc.name] = { ok: false, reason: e.message };
880
+ }
881
+ }
882
+ return ok(res, { results });
883
+ } catch (err) {
884
+ return strike(res, 502, err.message);
885
+ }
886
+ }
887
+
888
+ // POST /change-pw — change master password (must be unlocked)
889
+ if (method === "POST" && reqPath === "/change-pw") {
890
+ if (lockedGuard(res)) return;
891
+
892
+ let body;
893
+ try { body = await readBody(req); } catch {
894
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
895
+ return res.end(JSON.stringify({ error: "Invalid JSON body" }));
896
+ }
897
+
898
+ const { newPassword } = body;
899
+ if (!newPassword || typeof newPassword !== "string" || newPassword.length < 8) {
900
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
901
+ return res.end(JSON.stringify({ error: "newPassword must be at least 8 characters" }));
902
+ }
903
+
904
+ try {
905
+ const { token, timestamp } = deriveToken(password, machineHash);
906
+ const newSeedHash = deriveSeedHash(machineHash, newPassword);
907
+ const result = await api.changePassword(password, machineHash, token, timestamp, newSeedHash);
908
+ if (result.error) throw new Error(result.error);
909
+ password = newPassword; // update in-memory password to new one
910
+ const logLine = `[${new Date().toISOString()}] Password changed\n`;
911
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
912
+ return ok(res, { ok: true });
913
+ } catch (err) {
914
+ res.writeHead(502, { "Content-Type": "application/json", ...CORS });
915
+ return res.end(JSON.stringify({ error: err.message }));
916
+ }
917
+ }
918
+
919
+ // POST /set/:service — write a new key value into vault
920
+ const setMatch = reqPath.match(/^\/set\/([a-zA-Z0-9_-]+)$/);
921
+ if (method === "POST" && setMatch) {
922
+ if (lockedGuard(res)) return;
923
+ const service = setMatch[1].toLowerCase();
924
+
925
+ if (whitelist && !whitelist.includes(service)) {
926
+ return strike(res, 403, `Service '${service}' not in whitelist`);
927
+ }
928
+
929
+ let body;
930
+ try { body = await readBody(req); } catch {
931
+ return strike(res, 400, "Invalid JSON body");
932
+ }
933
+
934
+ const value = body.value;
935
+ if (!value || typeof value !== "string" || !value.trim()) {
936
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
937
+ return res.end(JSON.stringify({ error: "value is required" }));
938
+ }
939
+
940
+ try {
941
+ const { token, timestamp } = deriveToken(password, machineHash);
942
+ const result = await api.write(password, machineHash, token, timestamp, service, value.trim());
943
+ if (result.error) return strike(res, 502, result.error);
944
+ return ok(res, { ok: true, service });
945
+ } catch (err) {
946
+ return strike(res, 502, err.message);
947
+ }
948
+ }
949
+
950
+ // Unknown route
951
+ return strike(res, 404, `Unknown endpoint: ${reqPath}`);
952
+ });
953
+
954
+ return server;
955
+ }
956
+
957
+ // ── Actions ──────────────────────────────────────────────────
958
+
959
+ async function verifyAuth(password) {
960
+ const machineHash = getMachineHash();
961
+ const { token, timestamp } = deriveToken(password, machineHash);
962
+ const result = await api.test(password, machineHash, token, timestamp);
963
+ if (result.error) throw new Error(result.error);
964
+ }
965
+
966
+ async function actionStart(opts) {
967
+ const port = parseInt(opts.port || "52437", 10);
968
+ const password = opts.pw;
969
+ const whitelist = opts.services
970
+ ? opts.services.split(",").map(s => s.trim().toLowerCase())
971
+ : null;
972
+
973
+ // Check for existing instance
974
+ const existing = readPid();
975
+ if (existing && isProcessAlive(existing.pid)) {
976
+ console.log(chalk.yellow(`\n clauth serve already running (PID ${existing.pid}, port ${existing.port})`));
977
+ console.log(chalk.gray(` Stop it first: clauth serve stop\n`));
978
+ process.exit(1);
979
+ }
980
+
981
+ // If we're the daemon child, run the server directly
982
+ if (process.env.__CLAUTH_DAEMON === "1") {
983
+ // Verify password only if one was provided at start (optional browser can unlock later)
984
+ if (password) {
985
+ try {
986
+ await verifyAuth(password);
987
+ } catch (err) {
988
+ const msg = `[${new Date().toISOString()}] Auth failed: ${err.message}\n`;
989
+ fs.appendFileSync(LOG_FILE, msg);
990
+ process.exit(1);
991
+ }
992
+ }
993
+
994
+ const server = createServer(password, whitelist, port);
995
+ server.listen(port, "127.0.0.1", () => {
996
+ writePid(process.pid, port);
997
+ const msg = `[${new Date().toISOString()}] clauth serve started — PID ${process.pid}, port ${port}, services: ${whitelist ? whitelist.join(",") : "all"}\n`;
998
+ fs.appendFileSync(LOG_FILE, msg);
999
+ });
1000
+
1001
+ server.on("error", err => {
1002
+ const msg = `[${new Date().toISOString()}] Server error: ${err.message}\n`;
1003
+ fs.appendFileSync(LOG_FILE, msg);
1004
+ process.exit(1);
1005
+ });
1006
+
1007
+ const shutdown = () => { removePid(); process.exit(0); };
1008
+ process.on("SIGTERM", shutdown);
1009
+ process.on("SIGINT", shutdown);
1010
+ return;
1011
+ }
1012
+
1013
+ // If --pw provided, verify it before spawning (fast-fail on wrong password)
1014
+ if (password) {
1015
+ console.log(chalk.gray("\n Verifying vault credentials..."));
1016
+ try {
1017
+ await verifyAuth(password);
1018
+ } catch (err) {
1019
+ console.log(chalk.red(`\n Auth failed: ${err.message}\n`));
1020
+ process.exit(1);
1021
+ }
1022
+ console.log(chalk.green(" ✓ Vault auth verified"));
1023
+ } else {
1024
+ console.log(chalk.yellow("\n Starting in locked state — open browser to unlock"));
1025
+ }
1026
+
1027
+ // Spawn detached daemon child via the clauth CLI entry point
1028
+ const { spawn } = await import("child_process");
1029
+ const { fileURLToPath } = await import("url");
1030
+ const { dirname, join } = await import("path");
1031
+ const __filename = fileURLToPath(import.meta.url);
1032
+ const cliEntry = join(dirname(__filename), "..", "index.js");
1033
+
1034
+ // Build args: node index.js serve start --port N [--pw PW] [--services S]
1035
+ const childArgs = [cliEntry, "serve", "start", "--port", String(port)];
1036
+ if (password) childArgs.push("--pw", password);
1037
+ if (opts.services) childArgs.push("--services", opts.services);
1038
+
1039
+ const out = fs.openSync(LOG_FILE, "a");
1040
+ const child = spawn(process.execPath, childArgs, {
1041
+ detached: true,
1042
+ stdio: ["ignore", out, out],
1043
+ env: { ...process.env, __CLAUTH_DAEMON: "1" },
1044
+ });
1045
+ child.unref();
1046
+
1047
+ // Give it time to bind and write PID file (Windows spawn is slower)
1048
+ // Verify via HTTP ping (process.kill(pid,0) fails on Windows for detached processes)
1049
+ let started = false;
1050
+ for (let attempt = 0; attempt < 5; attempt++) {
1051
+ await new Promise(r => setTimeout(r, 1000));
1052
+ try {
1053
+ const resp = await fetch(`http://127.0.0.1:${port}/ping`);
1054
+ if (resp.ok) { started = true; break; }
1055
+ } catch {}
1056
+ }
1057
+
1058
+ const info = readPid();
1059
+ if (started && info) {
1060
+ console.log(chalk.green(`\n 🔐 clauth serve started`));
1061
+ console.log(chalk.gray(` PID: ${info.pid}`));
1062
+ console.log(chalk.gray(` Port: 127.0.0.1:${info.port}`));
1063
+ console.log(chalk.gray(` Services: ${whitelist ? whitelist.join(", ") : "all"}`));
1064
+ console.log(chalk.gray(` Log: ${LOG_FILE}`));
1065
+ if (!password) {
1066
+ console.log(chalk.cyan(`\n 👉 Open http://127.0.0.1:${info.port} to unlock the vault`));
1067
+ }
1068
+ console.log(chalk.gray(` Stop: clauth serve stop\n`));
1069
+ } else {
1070
+ console.log(chalk.red(`\n Failed to start daemon — check ${LOG_FILE}\n`));
1071
+ process.exit(1);
1072
+ }
1073
+ }
1074
+
1075
+ async function actionStop() {
1076
+ const info = readPid();
1077
+ if (!info) {
1078
+ console.log(chalk.yellow("\n No clauth serve PID file found — not running.\n"));
1079
+ return;
1080
+ }
1081
+
1082
+ if (!isProcessAlive(info.pid)) {
1083
+ console.log(chalk.yellow(`\n PID ${info.pid} is not running (stale PID file). Cleaning up.\n`));
1084
+ removePid();
1085
+ return;
1086
+ }
1087
+
1088
+ // Try HTTP shutdown first (clean)
1089
+ try {
1090
+ const resp = await fetch(`http://127.0.0.1:${info.port}/shutdown`);
1091
+ if (resp.ok) {
1092
+ await new Promise(r => setTimeout(r, 300));
1093
+ console.log(chalk.green(`\n 🛑 clauth serve stopped (was PID ${info.pid}, port ${info.port})\n`));
1094
+ removePid();
1095
+ return;
1096
+ }
1097
+ } catch {}
1098
+
1099
+ // Fallback: kill the process
1100
+ try {
1101
+ process.kill(info.pid, "SIGTERM");
1102
+ await new Promise(r => setTimeout(r, 300));
1103
+ console.log(chalk.green(`\n 🛑 clauth serve stopped via SIGTERM (PID ${info.pid})\n`));
1104
+ } catch (err) {
1105
+ console.log(chalk.yellow(`\n Could not kill PID ${info.pid}: ${err.message}\n`));
1106
+ }
1107
+ removePid();
1108
+ }
1109
+
1110
+ async function actionPing() {
1111
+ const info = readPid();
1112
+ if (!info) {
1113
+ console.log(chalk.red("\n clauth serve is not running (no PID file)\n"));
1114
+ process.exit(1);
1115
+ }
1116
+
1117
+ if (!isProcessAlive(info.pid)) {
1118
+ console.log(chalk.red(`\n PID ${info.pid} is not alive (stale PID file)\n`));
1119
+ removePid();
1120
+ process.exit(1);
1121
+ }
1122
+
1123
+ try {
1124
+ const resp = await fetch(`http://127.0.0.1:${info.port}/ping`);
1125
+ const data = await resp.json();
1126
+ if (data.status === "ok") {
1127
+ console.log(chalk.green(`\n ✅ clauth serve running`));
1128
+ console.log(chalk.gray(` PID: ${info.pid}`));
1129
+ console.log(chalk.gray(` Port: ${info.port}`));
1130
+ console.log(chalk.gray(` Fails: ${data.failures}/${data.failures + data.failures_remaining}`));
1131
+ console.log(chalk.gray(` Services: ${Array.isArray(data.services) ? data.services.join(", ") : data.services}\n`));
1132
+ } else {
1133
+ console.log(chalk.yellow(`\n PID alive but /ping returned unexpected response\n`));
1134
+ }
1135
+ } catch (err) {
1136
+ console.log(chalk.yellow(`\n PID ${info.pid} alive but HTTP failed: ${err.message}\n`));
1137
+ }
1138
+ }
1139
+
1140
+ async function actionRestart(opts) {
1141
+ const info = readPid();
1142
+ if (info && isProcessAlive(info.pid)) {
1143
+ await actionStop();
1144
+ await new Promise(r => setTimeout(r, 500));
1145
+ }
1146
+ await actionStart(opts);
1147
+ }
1148
+
1149
+ async function actionForeground(opts) {
1150
+ const port = parseInt(opts.port || "52437", 10);
1151
+ const password = opts.pw || null;
1152
+ const whitelist = opts.services
1153
+ ? opts.services.split(",").map(s => s.trim().toLowerCase())
1154
+ : null;
1155
+
1156
+ if (password) {
1157
+ console.log(chalk.gray("\n Verifying vault credentials..."));
1158
+ try {
1159
+ await verifyAuth(password);
1160
+ } catch (err) {
1161
+ console.log(chalk.red(`\n Auth failed: ${err.message}\n`));
1162
+ process.exit(1);
1163
+ }
1164
+ console.log(chalk.green(" ✓ Vault auth verified"));
1165
+ } else {
1166
+ console.log(chalk.yellow("\n Starting in locked state — open browser to unlock"));
1167
+ }
1168
+
1169
+ console.log(chalk.gray(` Port: 127.0.0.1:${port}`));
1170
+ console.log(chalk.gray(` Services: ${whitelist ? whitelist.join(", ") : "all"}`));
1171
+ console.log(chalk.gray(` Lockout: 3 failures exit\n`));
1172
+
1173
+ const server = createServer(password, whitelist, port);
1174
+ server.listen(port, "127.0.0.1", () => {
1175
+ writePid(process.pid, port);
1176
+ console.log(chalk.green(` clauth serve → http://127.0.0.1:${port}`));
1177
+ if (!password) console.log(chalk.cyan(` 👉 Open http://127.0.0.1:${port} to unlock`));
1178
+ console.log(chalk.gray(" Ctrl+C to stop\n"));
1179
+ });
1180
+
1181
+ server.on("error", err => {
1182
+ if (err.code === "EADDRINUSE") {
1183
+ console.log(chalk.red(`\n Port ${port} already in use. Use --port to choose another.\n`));
1184
+ } else {
1185
+ console.log(chalk.red(`\n Server error: ${err.message}\n`));
1186
+ }
1187
+ process.exit(1);
1188
+ });
1189
+
1190
+ process.on("SIGINT", () => {
1191
+ console.log(chalk.yellow("\n Stopping clauth serve...\n"));
1192
+ removePid();
1193
+ server.close(() => process.exit(0));
1194
+ });
1195
+ }
1196
+
1197
+ // ── MCP stdio server ──────────────────────────────────────────
1198
+ // JSON-RPC 2.0 over stdin/stdout for Claude Code integration.
1199
+ // Reuses the same auth model as the HTTP daemon.
1200
+ // Secrets are delivered via temp files — never in the MCP response.
1201
+
1202
+ import { createInterface } from "readline";
1203
+ import { execSync } from "child_process";
1204
+
1205
+ const ENV_MAP = {
1206
+ "github": "GITHUB_TOKEN",
1207
+ "supabase-anon": "NEXT_PUBLIC_SUPABASE_ANON_KEY",
1208
+ "supabase-service": "SUPABASE_SERVICE_ROLE_KEY",
1209
+ "supabase-db": "SUPABASE_DB_URL",
1210
+ "vercel": "VERCEL_TOKEN",
1211
+ "anthropic": "ANTHROPIC_API_KEY",
1212
+ "cloudflare": "CLOUDFLARE_API_TOKEN",
1213
+ "r2": "R2_ACCESS_KEY_ID",
1214
+ "r2-bucket": "R2_BUCKET_NAME",
1215
+ "neo4j": "NEO4J_URI",
1216
+ "rocketreach": "ROCKETREACH_API_KEY",
1217
+ "npm": "NPM_TOKEN",
1218
+ "namecheap": "NAMECHEAP_API_KEY",
1219
+ "gmail": "GMAIL_CREDENTIALS",
1220
+ };
1221
+
1222
+ const MCP_TOOLS = [
1223
+ {
1224
+ name: "clauth_ping",
1225
+ description: "Check if the vault is locked or unlocked, show failure count",
1226
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
1227
+ },
1228
+ {
1229
+ name: "clauth_unlock",
1230
+ description: "Unlock the vault with the master password (password stays in MCP server memory only)",
1231
+ inputSchema: { type: "object", properties: { password: { type: "string", description: "clauth master password" } }, required: ["password"], additionalProperties: false }
1232
+ },
1233
+ {
1234
+ name: "clauth_lock",
1235
+ description: "Lock the vault — clears password from memory",
1236
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
1237
+ },
1238
+ {
1239
+ name: "clauth_status",
1240
+ description: "List all services with type, enabled state, key presence, and last retrieval time",
1241
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
1242
+ },
1243
+ {
1244
+ name: "clauth_list",
1245
+ description: "List registered service names",
1246
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
1247
+ },
1248
+ {
1249
+ name: "clauth_get",
1250
+ description: "Retrieve a secret and deliver to a temp file (default), clipboard, or stdout. Temp files auto-delete after 30 seconds.",
1251
+ inputSchema: {
1252
+ type: "object",
1253
+ properties: {
1254
+ service: { type: "string", description: "Service name (e.g. github, anthropic, vercel)" },
1255
+ target: { type: "string", enum: ["file", "clipboard", "stdout"], default: "file", description: "Where to deliver the secret. 'file' writes a temp file, 'clipboard' copies to clipboard, 'stdout' returns the value in the response (DANGEROUS — enters transcript)" }
1256
+ },
1257
+ required: ["service"],
1258
+ additionalProperties: false
1259
+ }
1260
+ },
1261
+ {
1262
+ name: "clauth_inject",
1263
+ description: "Bulk retrieve multiple services into a single sourceable env file. Usage: source the returned file path before running commands.",
1264
+ inputSchema: {
1265
+ type: "object",
1266
+ properties: {
1267
+ services: { type: "array", items: { type: "string" }, description: "Service names to inject (e.g. ['github', 'vercel', 'anthropic'])" }
1268
+ },
1269
+ required: ["services"],
1270
+ additionalProperties: false
1271
+ }
1272
+ },
1273
+ {
1274
+ name: "clauth_enable",
1275
+ description: "Enable a service in the vault",
1276
+ inputSchema: { type: "object", properties: { service: { type: "string" } }, required: ["service"], additionalProperties: false }
1277
+ },
1278
+ {
1279
+ name: "clauth_disable",
1280
+ description: "Disable a service in the vault",
1281
+ inputSchema: { type: "object", properties: { service: { type: "string" } }, required: ["service"], additionalProperties: false }
1282
+ },
1283
+ {
1284
+ name: "clauth_test",
1285
+ description: "Test the HMAC handshake without retrieving any keys",
1286
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
1287
+ },
1288
+ {
1289
+ name: "clauth_scrub",
1290
+ description: "Scrub secrets from Claude Code transcript .jsonl files",
1291
+ inputSchema: {
1292
+ type: "object",
1293
+ properties: {
1294
+ target: { type: "string", enum: ["latest", "all"], default: "latest", description: "Scrub the most recent transcript or all transcripts" }
1295
+ },
1296
+ additionalProperties: false
1297
+ }
1298
+ },
1299
+ ];
1300
+
1301
+ function writeTempSecret(service, value) {
1302
+ const filePath = path.join(os.tmpdir(), `.clauth-${service}`);
1303
+ fs.writeFileSync(filePath, value, { mode: 0o600 });
1304
+ setTimeout(() => { try { fs.unlinkSync(filePath); } catch {} }, 30_000);
1305
+ return filePath;
1306
+ }
1307
+
1308
+ function copyToClipboard(value) {
1309
+ const platform = os.platform();
1310
+ if (platform === "win32") {
1311
+ execSync("clip", { input: value, stdio: ["pipe", "pipe", "pipe"] });
1312
+ } else if (platform === "darwin") {
1313
+ execSync("pbcopy", { input: value, stdio: ["pipe", "pipe", "pipe"] });
1314
+ } else {
1315
+ execSync("xclip -selection clipboard", { input: value, stdio: ["pipe", "pipe", "pipe"] });
1316
+ }
1317
+ }
1318
+
1319
+ function mcpResult(text) {
1320
+ return { content: [{ type: "text", text }] };
1321
+ }
1322
+
1323
+ function mcpError(text) {
1324
+ return { content: [{ type: "text", text }], isError: true };
1325
+ }
1326
+
1327
+ async function handleMcpTool(vault, name, args) {
1328
+ switch (name) {
1329
+ case "clauth_ping": {
1330
+ return mcpResult(
1331
+ vault.password
1332
+ ? `unlocked | pid: ${process.pid} | failures: ${vault.failCount}/${vault.MAX_FAILS}`
1333
+ : `locked | pid: ${process.pid} | failures: ${vault.failCount}/${vault.MAX_FAILS}`
1334
+ );
1335
+ }
1336
+
1337
+ case "clauth_unlock": {
1338
+ const pw = args.password;
1339
+ if (!pw) return mcpError("password is required");
1340
+ try {
1341
+ const { token, timestamp } = deriveToken(pw, vault.machineHash);
1342
+ const result = await api.test(pw, vault.machineHash, token, timestamp);
1343
+ if (result.error) return mcpError(`Unlock failed: ${result.error}`);
1344
+ vault.password = pw;
1345
+ return mcpResult("Vault unlocked");
1346
+ } catch (err) {
1347
+ return mcpError(`Unlock failed: ${err.message}`);
1348
+ }
1349
+ }
1350
+
1351
+ case "clauth_lock": {
1352
+ vault.password = null;
1353
+ return mcpResult("Vault locked — password cleared from memory");
1354
+ }
1355
+
1356
+ case "clauth_status": {
1357
+ if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
1358
+ try {
1359
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
1360
+ const result = await api.status(vault.password, vault.machineHash, token, timestamp);
1361
+ if (result.error) return mcpError(result.error);
1362
+ let services = result.services || [];
1363
+ if (vault.whitelist) {
1364
+ services = services.filter(s => vault.whitelist.includes(s.name.toLowerCase()));
1365
+ }
1366
+ const lines = ["SERVICE TYPE STATUS KEY LAST RETRIEVED",
1367
+ "--- ---- ------ --- --------------"];
1368
+ for (const s of services) {
1369
+ const status = s.enabled ? "ACTIVE" : (s.vault_key ? "SUSPENDED" : "NO KEY");
1370
+ const hasKey = s.vault_key ? "yes" : "—";
1371
+ const lastGet = s.last_retrieved ? new Date(s.last_retrieved).toLocaleDateString() : "never";
1372
+ lines.push(`${s.name.padEnd(20)} ${(s.key_type || "").padEnd(12)} ${status.padEnd(12)} ${hasKey.padEnd(6)} ${lastGet}`);
1373
+ }
1374
+ return mcpResult(lines.join("\n"));
1375
+ } catch (err) {
1376
+ return mcpError(err.message);
1377
+ }
1378
+ }
1379
+
1380
+ case "clauth_list": {
1381
+ if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
1382
+ try {
1383
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
1384
+ const result = await api.status(vault.password, vault.machineHash, token, timestamp);
1385
+ if (result.error) return mcpError(result.error);
1386
+ let services = result.services || [];
1387
+ if (vault.whitelist) {
1388
+ services = services.filter(s => vault.whitelist.includes(s.name.toLowerCase()));
1389
+ }
1390
+ return mcpResult(services.map(s => s.name).join(", "));
1391
+ } catch (err) {
1392
+ return mcpError(err.message);
1393
+ }
1394
+ }
1395
+
1396
+ case "clauth_get": {
1397
+ if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
1398
+ const service = (args.service || "").toLowerCase();
1399
+ const target = args.target || "file";
1400
+ if (!service) return mcpError("service is required");
1401
+ if (vault.whitelist && !vault.whitelist.includes(service)) {
1402
+ return mcpError(`Service '${service}' not in whitelist`);
1403
+ }
1404
+ try {
1405
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
1406
+ const result = await api.retrieve(vault.password, vault.machineHash, token, timestamp, service);
1407
+ if (result.error) return mcpError(result.error);
1408
+ const value = typeof result.value === "string" ? result.value : JSON.stringify(result.value);
1409
+
1410
+ if (target === "clipboard") {
1411
+ try {
1412
+ copyToClipboard(value);
1413
+ return mcpResult(`${service} copied to clipboard`);
1414
+ } catch (err) {
1415
+ return mcpError(`Clipboard failed: ${err.message}. Use target 'file' instead.`);
1416
+ }
1417
+ }
1418
+
1419
+ if (target === "stdout") {
1420
+ return {
1421
+ content: [{ type: "text", text: `WARNING: secret in response — will appear in transcript. Use 'file' or 'clipboard' mode instead.\n\n${value}` }],
1422
+ isError: true
1423
+ };
1424
+ }
1425
+
1426
+ // Default: file
1427
+ const envVar = ENV_MAP[service] || service.toUpperCase().replace(/-/g, "_");
1428
+ const filePath = writeTempSecret(service, value);
1429
+ return mcpResult(`${service} → ${filePath} (auto-deletes in 30s)\nEnv var: ${envVar}\nUsage: export ${envVar}=$(cat ${filePath.replace(/\\/g, "/")})`);
1430
+ } catch (err) {
1431
+ return mcpError(err.message);
1432
+ }
1433
+ }
1434
+
1435
+ case "clauth_inject": {
1436
+ if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
1437
+ const services = args.services || [];
1438
+ if (!services.length) return mcpError("services array is required");
1439
+
1440
+ const lines = [];
1441
+ const errors = [];
1442
+ for (const svc of services) {
1443
+ const service = svc.toLowerCase();
1444
+ if (vault.whitelist && !vault.whitelist.includes(service)) {
1445
+ errors.push(`${service}: not in whitelist`);
1446
+ continue;
1447
+ }
1448
+ try {
1449
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
1450
+ const result = await api.retrieve(vault.password, vault.machineHash, token, timestamp, service);
1451
+ if (result.error) { errors.push(`${service}: ${result.error}`); continue; }
1452
+ const value = typeof result.value === "string" ? result.value : JSON.stringify(result.value);
1453
+ const envVar = ENV_MAP[service] || service.toUpperCase().replace(/-/g, "_");
1454
+ lines.push(`export ${envVar}="${value.replace(/"/g, '\\"')}"`);
1455
+ } catch (err) {
1456
+ errors.push(`${service}: ${err.message}`);
1457
+ }
1458
+ }
1459
+
1460
+ if (!lines.length) return mcpError(`No services retrieved:\n${errors.join("\n")}`);
1461
+
1462
+ const envFilePath = path.join(os.tmpdir(), ".clauth-env");
1463
+ fs.writeFileSync(envFilePath, lines.join("\n") + "\n", { mode: 0o600 });
1464
+ setTimeout(() => { try { fs.unlinkSync(envFilePath); } catch {} }, 30_000);
1465
+
1466
+ let msg = `${lines.length} service(s) → ${envFilePath.replace(/\\/g, "/")} (auto-deletes in 30s)\nUsage: source ${envFilePath.replace(/\\/g, "/")}`;
1467
+ if (errors.length) msg += `\n\nErrors:\n${errors.join("\n")}`;
1468
+ return mcpResult(msg);
1469
+ }
1470
+
1471
+ case "clauth_enable": {
1472
+ if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
1473
+ const service = (args.service || "").toLowerCase();
1474
+ if (!service) return mcpError("service is required");
1475
+ try {
1476
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
1477
+ const result = await api.enable(vault.password, vault.machineHash, token, timestamp, service, true);
1478
+ if (result.error) return mcpError(result.error);
1479
+ return mcpResult(`${service} enabled`);
1480
+ } catch (err) {
1481
+ return mcpError(err.message);
1482
+ }
1483
+ }
1484
+
1485
+ case "clauth_disable": {
1486
+ if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
1487
+ const service = (args.service || "").toLowerCase();
1488
+ if (!service) return mcpError("service is required");
1489
+ try {
1490
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
1491
+ const result = await api.enable(vault.password, vault.machineHash, token, timestamp, service, false);
1492
+ if (result.error) return mcpError(result.error);
1493
+ return mcpResult(`${service} disabled`);
1494
+ } catch (err) {
1495
+ return mcpError(err.message);
1496
+ }
1497
+ }
1498
+
1499
+ case "clauth_test": {
1500
+ if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
1501
+ try {
1502
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
1503
+ const result = await api.test(vault.password, vault.machineHash, token, timestamp);
1504
+ if (result.error) return mcpError(`FAIL: ${result.error}`);
1505
+ return mcpResult(`PASS — machine: ${vault.machineHash.slice(0, 16)}... | window: ${new Date(result.timestamp).toISOString()}`);
1506
+ } catch (err) {
1507
+ return mcpError(`FAIL: ${err.message}`);
1508
+ }
1509
+ }
1510
+
1511
+ case "clauth_scrub": {
1512
+ const target = args.target || "latest";
1513
+ try {
1514
+ const { runScrub: doScrub } = await import("./scrub.js");
1515
+ // runScrub writes to stdout via chalk — capture isn't clean in MCP mode.
1516
+ // Call it and trust it works; report success.
1517
+ await doScrub(target === "all" ? "all" : undefined, {});
1518
+ return mcpResult(`Scrub complete (target: ${target})`);
1519
+ } catch (err) {
1520
+ return mcpError(`Scrub failed: ${err.message}`);
1521
+ }
1522
+ }
1523
+
1524
+ default:
1525
+ return mcpError(`Unknown tool: ${name}`);
1526
+ }
1527
+ }
1528
+
1529
+ function createMcpServer(initPassword, whitelist) {
1530
+ // Ensure wmic is reachable — bash shells on Windows may not have
1531
+ // C:\Windows\System32\Wbem on PATH, which getMachineHash() needs.
1532
+ if (os.platform() === "win32" && !process.env.PATH?.includes("Wbem")) {
1533
+ process.env.PATH = (process.env.PATH || "") + ";C:\\Windows\\System32\\Wbem";
1534
+ }
1535
+
1536
+ // Lazy-init machineHash — defer to first tool call that needs auth.
1537
+ let _machineHash = null;
1538
+ function ensureMachineHash() {
1539
+ if (!_machineHash) _machineHash = getMachineHash();
1540
+ return _machineHash;
1541
+ }
1542
+
1543
+ const vault = {
1544
+ password: initPassword || null,
1545
+ get machineHash() { return ensureMachineHash(); },
1546
+ whitelist,
1547
+ failCount: 0,
1548
+ MAX_FAILS: 3,
1549
+ };
1550
+
1551
+ const rl = createInterface({ input: process.stdin, terminal: false });
1552
+
1553
+ function send(msg) {
1554
+ process.stdout.write(JSON.stringify(msg) + "\n");
1555
+ }
1556
+
1557
+ rl.on("line", async (line) => {
1558
+ let msg;
1559
+ try {
1560
+ msg = JSON.parse(line);
1561
+ } catch {
1562
+ send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
1563
+ return;
1564
+ }
1565
+
1566
+ const id = msg.id;
1567
+
1568
+ // notifications (no id) — handle initialized, etc.
1569
+ if (msg.method === "notifications/initialized" || msg.method === "initialized") {
1570
+ return; // no response needed for notifications
1571
+ }
1572
+
1573
+ if (msg.method === "initialize") {
1574
+ return send({
1575
+ jsonrpc: "2.0", id,
1576
+ result: {
1577
+ protocolVersion: "2024-11-05",
1578
+ serverInfo: { name: "clauth", version: VERSION },
1579
+ capabilities: { tools: {} }
1580
+ }
1581
+ });
1582
+ }
1583
+
1584
+ if (msg.method === "tools/list") {
1585
+ return send({
1586
+ jsonrpc: "2.0", id,
1587
+ result: { tools: MCP_TOOLS }
1588
+ });
1589
+ }
1590
+
1591
+ if (msg.method === "tools/call") {
1592
+ const { name, arguments: args } = msg.params || {};
1593
+ try {
1594
+ const result = await handleMcpTool(vault, name, args || {});
1595
+ return send({ jsonrpc: "2.0", id, result });
1596
+ } catch (err) {
1597
+ return send({ jsonrpc: "2.0", id, result: mcpError(`Internal error: ${err.message}`) });
1598
+ }
1599
+ }
1600
+
1601
+ // Unknown method
1602
+ send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Unknown method: ${msg.method}` } });
1603
+ });
1604
+
1605
+ rl.on("close", () => {
1606
+ process.exit(0);
1607
+ });
1608
+
1609
+ // Prevent unhandled errors from crashing the MCP server
1610
+ process.on("uncaughtException", (err) => {
1611
+ const msg = `[${new Date().toISOString()}] MCP uncaught: ${err.message}\n`;
1612
+ try { fs.appendFileSync(LOG_FILE, msg); } catch {}
1613
+ });
1614
+ }
1615
+
1616
+ async function actionMcp(opts) {
1617
+ const password = opts.pw || null;
1618
+ const whitelist = opts.services
1619
+ ? opts.services.split(",").map(s => s.trim().toLowerCase())
1620
+ : null;
1621
+
1622
+ if (password) {
1623
+ try {
1624
+ await verifyAuth(password);
1625
+ } catch (err) {
1626
+ // Can't use stderr for errors in MCP mode without careful framing
1627
+ // Write to log file instead
1628
+ const msg = `[${new Date().toISOString()}] MCP auth failed: ${err.message}\n`;
1629
+ try { fs.appendFileSync(LOG_FILE, msg); } catch {}
1630
+ process.exit(1);
1631
+ }
1632
+ }
1633
+
1634
+ createMcpServer(password, whitelist);
1635
+ }
1636
+
1637
+ // ── Export ────────────────────────────────────────────────────
1638
+ export async function runServe(opts) {
1639
+ const action = opts.action || "foreground";
1640
+
1641
+ switch (action) {
1642
+ case "start": return actionStart(opts);
1643
+ case "stop": return actionStop();
1644
+ case "restart": return actionRestart(opts);
1645
+ case "ping": return actionPing();
1646
+ case "foreground": return actionForeground(opts);
1647
+ case "mcp": return actionMcp(opts);
1648
+ default:
1649
+ console.log(chalk.red(`\n Unknown serve action: ${action}`));
1650
+ console.log(chalk.gray(" Actions: start | stop | restart | ping | foreground | mcp\n"));
1651
+ process.exit(1);
1652
+ }
1653
+ }