@lifeaitools/clauth 0.3.1 → 0.3.6

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.
@@ -8,7 +8,7 @@ import http from "http";
8
8
  import fs from "fs";
9
9
  import os from "os";
10
10
  import path from "path";
11
- import { getMachineHash, deriveToken } from "../fingerprint.js";
11
+ import { getMachineHash, deriveToken, deriveSeedHash } from "../fingerprint.js";
12
12
  import * as api from "../api.js";
13
13
  import chalk from "chalk";
14
14
 
@@ -36,12 +36,485 @@ function isProcessAlive(pid) {
36
36
  try { process.kill(pid, 0); return true; } catch { return false; }
37
37
  }
38
38
 
39
+ // ── Dashboard HTML ───────────────────────────────────────────
40
+ function dashboardHtml(port, whitelist) {
41
+ return `<!DOCTYPE html>
42
+ <html lang="en">
43
+ <head>
44
+ <meta charset="utf-8">
45
+ <meta name="viewport" content="width=device-width,initial-scale=1">
46
+ <title>clauth vault</title>
47
+ <style>
48
+ *{margin:0;padding:0;box-sizing:border-box}
49
+ body{background:#0a0f1a;color:#e2e8f0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;min-height:100vh}
50
+ /* ── Lock screen ── */
51
+ #lock-screen{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;padding:2rem}
52
+ .lock-card{background:#1e293b;border:1px solid #334155;border-radius:12px;padding:2.5rem 2rem;width:100%;max-width:380px;text-align:center}
53
+ .lock-icon{font-size:2.5rem;margin-bottom:1rem}
54
+ .lock-title{font-size:1.25rem;font-weight:600;color:#f8fafc;margin-bottom:.4rem}
55
+ .lock-sub{font-size:.85rem;color:#64748b;margin-bottom:1.75rem}
56
+ .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}
57
+ .lock-input:focus{border-color:#3b82f6}
58
+ .lock-input.error{border-color:#ef4444;animation:shake .3s}
59
+ @keyframes shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-6px)}75%{transform:translateX(6px)}}
60
+ .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}
61
+ .btn-unlock:hover{background:#2563eb}
62
+ .btn-unlock:disabled{background:#1e3a5f;color:#4a6fa5;cursor:not-allowed}
63
+ .lock-err{color:#f87171;font-size:.82rem;margin-top:.75rem;min-height:1.2em}
64
+ /* ── Main view ── */
65
+ #main-view{display:none;padding:2rem}
66
+ .header{display:flex;align-items:center;gap:10px;margin-bottom:1.5rem;flex-wrap:wrap}
67
+ .header h1{font-size:1.4rem;font-weight:600;flex:1}
68
+ .dot{width:10px;height:10px;border-radius:50%;background:#22c55e;animation:pulse 2s infinite;flex-shrink:0}
69
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
70
+ .status-bar{display:flex;gap:1.5rem;margin-bottom:1.5rem;font-size:.82rem;color:#94a3b8;flex-wrap:wrap}
71
+ .status-bar span{color:#e2e8f0;font-weight:500}
72
+ .toolbar{display:flex;gap:8px;margin-bottom:1rem;flex-wrap:wrap;align-items:center}
73
+ .chpw-panel{display:none;background:#1a1f2e;border:1px solid #334155;border-radius:8px;padding:1.25rem;margin-bottom:1.5rem}
74
+ .chpw-panel h3{font-size:.9rem;font-weight:600;color:#f8fafc;margin-bottom:1rem}
75
+ .chpw-row{display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;margin-bottom:.75rem}
76
+ .chpw-field{display:flex;flex-direction:column;gap:4px}
77
+ .chpw-field label{font-size:.75rem;color:#64748b}
78
+ .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}
79
+ .chpw-input:focus{border-color:#3b82f6}
80
+ .chpw-foot{display:flex;gap:8px;align-items:center}
81
+ .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}
82
+ .btn-chpw-save:hover{background:#1e4a7f}
83
+ .chpw-msg{font-size:.82rem}
84
+ .chpw-msg.ok{color:#4ade80} .chpw-msg.fail{color:#f87171}
85
+ .btn-refresh{background:#3b82f6;color:#fff;padding:7px 18px;font-size:.85rem;border-radius:7px;border:none;cursor:pointer;font-weight:500}
86
+ .btn-refresh:hover{background:#2563eb}
87
+ .btn-lock{background:#1e293b;color:#f87171;border:1px solid #334155;padding:7px 16px;font-size:.85rem;border-radius:7px;cursor:pointer;font-weight:500}
88
+ .btn-lock:hover{background:#2d1f1f;border-color:#f87171}
89
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:1rem}
90
+ .card{background:#1e293b;border:1px solid #334155;border-radius:8px;padding:1.25rem;transition:border-color .2s}
91
+ .card:hover{border-color:#3b82f6}
92
+ .card-name{font-size:1rem;font-weight:600;color:#f8fafc;margin-bottom:3px}
93
+ .card-type{font-size:.78rem;color:#64748b;text-transform:uppercase;letter-spacing:.5px}
94
+ .card-getkey{font-size:.75rem;color:#3b82f6;text-decoration:none;opacity:.7;transition:opacity .15s}
95
+ .card-getkey:hover{opacity:1;text-decoration:underline}
96
+ .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}
97
+ .card-actions{margin-top:10px;display:flex;gap:7px;flex-wrap:wrap}
98
+ .set-panel{display:none;margin-top:10px;background:#0f172a;border-radius:6px;padding:10px;border:1px solid #1e3a5f}
99
+ .set-panel label{font-size:.75rem;color:#64748b;display:block;margin-bottom:6px}
100
+ .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}
101
+ .set-input:focus{border-color:#3b82f6}
102
+ .set-foot{margin-top:8px;display:flex;gap:8px;align-items:center}
103
+ .set-msg{font-size:.8rem}
104
+ .set-msg.ok{color:#4ade80} .set-msg.fail{color:#f87171}
105
+ .btn{padding:6px 13px;border-radius:6px;border:none;cursor:pointer;font-size:.8rem;font-weight:500;transition:all .15s}
106
+ .btn-reveal{background:#1e3a5f;color:#60a5fa}.btn-reveal:hover{background:#1e4a7f}
107
+ .btn-copy{background:#1a3328;color:#4ade80}.btn-copy:hover{background:#1a4338}
108
+ .btn-set{background:#2d1f4a;color:#a78bfa}.btn-set:hover{background:#3d2f5a}
109
+ .btn-save{background:#1e3a5f;color:#60a5fa;padding:6px 16px}.btn-save:hover{background:#1e4a7f}
110
+ .btn-cancel{background:transparent;color:#64748b;padding:6px 10px}.btn-cancel:hover{color:#94a3b8}
111
+ .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}
112
+ .btn-check:hover{background:#134e4a;border-color:#34d399}.btn-check:disabled{opacity:.5;cursor:not-allowed}
113
+ .status-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;opacity:0;transition:opacity .3s;margin-top:4px;cursor:default}
114
+ .status-dot.checking{background:#f59e0b;opacity:1;animation:pulse 1s infinite}
115
+ .status-dot.ok{background:#22c55e;opacity:1}
116
+ .status-dot.fail{background:#ef4444;opacity:1}
117
+ @keyframes sdot-fade{to{opacity:0}} .status-dot.fading{animation:sdot-fade 1.5s forwards}
118
+ .error-bar{background:#7f1d1d;color:#fca5a5;border:1px solid #991b1b;padding:10px 14px;border-radius:8px;margin-bottom:1rem;display:none;font-size:.85rem}
119
+ .loading{color:#64748b;font-style:italic}
120
+ .footer{margin-top:2rem;font-size:.75rem;color:#475569;text-align:center}
121
+ </style>
122
+ </head>
123
+ <body>
124
+
125
+ <!-- ── Lock screen ──────────────────────────── -->
126
+ <div id="lock-screen">
127
+ <div class="lock-card">
128
+ <div class="lock-icon">🔒</div>
129
+ <div class="lock-title">clauth vault</div>
130
+ <div class="lock-sub">Paste your password to unlock</div>
131
+ <input class="lock-input" id="lock-input" type="password" placeholder="••••••••••••" autocomplete="off">
132
+ <button class="btn-unlock" id="unlock-btn" onclick="unlock()">Unlock</button>
133
+ <div class="lock-err" id="lock-err"></div>
134
+ </div>
135
+ </div>
136
+
137
+ <!-- ── Main view (shown after unlock) ──────── -->
138
+ <div id="main-view">
139
+ <div class="header">
140
+ <div class="dot" id="dot"></div>
141
+ <h1>🔐 clauth vault</h1>
142
+ </div>
143
+ <div id="error-bar" class="error-bar"></div>
144
+ <div class="status-bar">
145
+ <div>PID: <span id="s-pid">—</span></div>
146
+ <div>Port: <span id="s-port">${port}</span></div>
147
+ <div>Services: <span id="s-services">${whitelist ? whitelist.join(", ") : "all"}</span></div>
148
+ <div>Failures: <span id="s-fails">—</span></div>
149
+ </div>
150
+ <div class="toolbar">
151
+ <button class="btn-refresh" onclick="loadServices()">↻ Refresh</button>
152
+ <button class="btn-check" id="check-btn" onclick="checkAll()">⬤ Check All</button>
153
+ <button class="btn-lock" onclick="lockVault()">🔒 Lock</button>
154
+ <button class="btn-cancel" style="margin-left:auto" onclick="toggleChangePw()">Change Password</button>
155
+ </div>
156
+
157
+ <div class="chpw-panel" id="chpw-panel">
158
+ <h3>Change Master Password</h3>
159
+ <div class="chpw-row">
160
+ <div class="chpw-field">
161
+ <label>New password</label>
162
+ <input class="chpw-input" id="chpw-new" type="password" placeholder="min 8 chars" autocomplete="new-password">
163
+ </div>
164
+ <div class="chpw-field">
165
+ <label>Confirm</label>
166
+ <input class="chpw-input" id="chpw-confirm" type="password" placeholder="repeat" autocomplete="new-password">
167
+ </div>
168
+ </div>
169
+ <div class="chpw-foot">
170
+ <button class="btn-chpw-save" onclick="changePassword()">Update Password</button>
171
+ <button class="btn-cancel" onclick="toggleChangePw()">Cancel</button>
172
+ <span class="chpw-msg" id="chpw-msg"></span>
173
+ </div>
174
+ </div>
175
+
176
+ <div id="grid" class="grid"><p class="loading">Loading services…</p></div>
177
+ <div class="footer">localhost:${port} · 127.0.0.1 only · 3-strike lockout</div>
178
+ </div>
179
+
180
+ <script>
181
+ const BASE = "http://127.0.0.1:${port}";
182
+
183
+ const SERVICE_HINTS = {
184
+ "neo4j": "neo4j+s://username:password@instance.databases.neo4j.io",
185
+ "supabase-db": "postgresql://postgres:password@db.ref.supabase.co:5432/postgres",
186
+ "r2": "accountId:accessKeyId:secretAccessKey",
187
+ "namecheap": "apiUser:apiKey",
188
+ };
189
+
190
+ const KEY_URLS = {
191
+ "github": "https://github.com/settings/tokens",
192
+ "vercel": "https://vercel.com/account/tokens",
193
+ "supabase-anon": "https://supabase.com/dashboard/project/uvojezuorjgqzmhhgluu/settings/api",
194
+ "supabase-service":"https://supabase.com/dashboard/project/uvojezuorjgqzmhhgluu/settings/api",
195
+ "supabase-db": "https://supabase.com/dashboard/project/uvojezuorjgqzmhhgluu/settings/database",
196
+ "anthropic": "https://console.anthropic.com/settings/keys",
197
+ "cloudflare": "https://dash.cloudflare.com/profile/api-tokens",
198
+ "r2": "https://dash.cloudflare.com/profile/api-tokens",
199
+ "r2-bucket": "https://dash.cloudflare.com/profile/api-tokens",
200
+ "rocketreach": "https://rocketreach.co/account?section=security",
201
+ "namecheap": "https://ap.www.namecheap.com/settings/tools/apiaccess/",
202
+ "neo4j": "https://console.neo4j.io/",
203
+ "npm": "https://www.npmjs.com/settings/~/tokens",
204
+ };
205
+
206
+ // ── Boot: check lock state ──────────────────
207
+ async function boot() {
208
+ try {
209
+ const ping = await fetch(BASE + "/ping").then(r => r.json());
210
+ if (ping.locked) {
211
+ showLockScreen();
212
+ } else {
213
+ showMain(ping);
214
+ loadServices();
215
+ }
216
+ } catch {
217
+ showLockScreen();
218
+ }
219
+ }
220
+
221
+ function showLockScreen() {
222
+ document.getElementById("lock-screen").style.display = "flex";
223
+ document.getElementById("main-view").style.display = "none";
224
+ setTimeout(() => document.getElementById("lock-input").focus(), 50);
225
+ }
226
+
227
+ function showMain(ping) {
228
+ document.getElementById("lock-screen").style.display = "none";
229
+ document.getElementById("main-view").style.display = "block";
230
+ if (ping) {
231
+ document.getElementById("s-pid").textContent = ping.pid || "—";
232
+ document.getElementById("s-fails").textContent =
233
+ ping.failures + "/" + (ping.failures + ping.failures_remaining);
234
+ }
235
+ }
236
+
237
+ // ── Unlock ──────────────────────────────────
238
+ async function unlock() {
239
+ const input = document.getElementById("lock-input");
240
+ const btn = document.getElementById("unlock-btn");
241
+ const err = document.getElementById("lock-err");
242
+ const pw = input.value;
243
+
244
+ if (!pw) { err.textContent = "Password is required."; return; }
245
+
246
+ btn.disabled = true; btn.textContent = "Verifying…"; err.textContent = "";
247
+
248
+ try {
249
+ const r = await fetch(BASE + "/auth", {
250
+ method: "POST",
251
+ headers: { "Content-Type": "application/json" },
252
+ body: JSON.stringify({ password: pw })
253
+ }).then(r => r.json());
254
+
255
+ if (r.error) throw new Error(r.error);
256
+
257
+ input.value = "";
258
+ const ping = await fetch(BASE + "/ping").then(r => r.json());
259
+ showMain(ping);
260
+ loadServices();
261
+ } catch (e) {
262
+ input.value = "";
263
+ input.className = "lock-input error";
264
+ err.textContent = "✗ " + (e.message || "Invalid password");
265
+ setTimeout(() => input.className = "lock-input", 600);
266
+ } finally {
267
+ btn.disabled = false; btn.textContent = "Unlock";
268
+ }
269
+ }
270
+
271
+ // ── Lock ────────────────────────────────────
272
+ async function lockVault() {
273
+ await fetch(BASE + "/lock", { method: "POST" }).catch(() => {});
274
+ showLockScreen();
275
+ }
276
+
277
+ // ── Load services ───────────────────────────
278
+ async function loadServices() {
279
+ const grid = document.getElementById("grid");
280
+ const err = document.getElementById("error-bar");
281
+ err.style.display = "none";
282
+ grid.innerHTML = '<p class="loading">Loading…</p>';
283
+
284
+ try {
285
+ const status = await fetch(BASE + "/status").then(r => r.json());
286
+ if (status.locked) { showLockScreen(); return; }
287
+ if (status.error) throw new Error(status.error);
288
+
289
+ const services = status.services || [];
290
+ if (!services.length) { grid.innerHTML = '<p class="loading">No services registered.</p>'; return; }
291
+
292
+ grid.innerHTML = services.map(s => \`
293
+ <div class="card">
294
+ <div style="display:flex;align-items:flex-start;justify-content:space-between">
295
+ <div>
296
+ <div class="card-name">\${s.name}</div>
297
+ <div class="card-type">\${s.key_type || "secret"}</div>
298
+ \${KEY_URLS[s.name] ? \`<a class="card-getkey" href="\${KEY_URLS[s.name]}" target="_blank" rel="noopener">↗ Get / rotate key</a>\` : ""}
299
+ </div>
300
+ <div class="status-dot" id="sdot-\${s.name}" title=""></div>
301
+ </div>
302
+ <div class="card-value" id="val-\${s.name}"></div>
303
+ <div class="card-actions">
304
+ <button class="btn btn-reveal" onclick="reveal('\${s.name}', this)">Reveal</button>
305
+ <button class="btn btn-copy" id="copybtn-\${s.name}" style="display:none" onclick="copyKey('\${s.name}')">Copy</button>
306
+ <button class="btn btn-set" onclick="toggleSet('\${s.name}')">Set</button>
307
+ </div>
308
+ <div class="set-panel" id="set-panel-\${s.name}">
309
+ <label>New value for <strong>\${s.name}</strong> — paste here, never in chat</label>
310
+ <textarea class="set-input" id="set-input-\${s.name}" placeholder="\${SERVICE_HINTS[s.name] || "Paste credential…"}" spellcheck="false"></textarea>
311
+ \${SERVICE_HINTS[s.name] ? \`<div style="font-size:.72rem;color:#475569;margin-top:4px;font-family:'Courier New',monospace">\${SERVICE_HINTS[s.name]}</div>\` : ""}
312
+ <div class="set-foot">
313
+ <button class="btn btn-save" onclick="saveKey('\${s.name}')">Save</button>
314
+ <button class="btn btn-cancel" onclick="toggleSet('\${s.name}')">Cancel</button>
315
+ <span class="set-msg" id="set-msg-\${s.name}"></span>
316
+ </div>
317
+ </div>
318
+ </div>
319
+ \`).join("");
320
+ } catch (e) {
321
+ err.textContent = "⚠ " + e.message;
322
+ err.style.display = "block";
323
+ document.getElementById("dot").style.background = "#ef4444";
324
+ grid.innerHTML = "";
325
+ }
326
+ }
327
+
328
+ // ── Reveal ──────────────────────────────────
329
+ async function reveal(name, btn) {
330
+ const valEl = document.getElementById("val-" + name);
331
+ const copyBtn = document.getElementById("copybtn-" + name);
332
+ if (valEl.style.display === "block") {
333
+ valEl.style.display = "none"; copyBtn.style.display = "none";
334
+ btn.textContent = "Reveal"; return;
335
+ }
336
+ valEl.textContent = "fetching…"; valEl.style.display = "block"; btn.textContent = "Hide";
337
+ try {
338
+ const r = await fetch(BASE + "/get/" + name).then(r => r.json());
339
+ if (r.locked) { showLockScreen(); return; }
340
+ if (r.error) throw new Error(r.error);
341
+ valEl.textContent = r.value; copyBtn.style.display = "inline-block";
342
+ } catch (e) {
343
+ valEl.textContent = "Error: " + e.message; valEl.style.color = "#ef4444";
344
+ }
345
+ }
346
+
347
+ async function copyKey(name) {
348
+ const val = document.getElementById("val-" + name).textContent;
349
+ try {
350
+ await navigator.clipboard.writeText(val);
351
+ const btn = document.getElementById("copybtn-" + name);
352
+ btn.textContent = "Copied!"; setTimeout(() => btn.textContent = "Copy", 1500);
353
+ } catch {}
354
+ }
355
+
356
+ // ── Set key ─────────────────────────────────
357
+ function toggleSet(name) {
358
+ const panel = document.getElementById("set-panel-" + name);
359
+ const input = document.getElementById("set-input-" + name);
360
+ const msg = document.getElementById("set-msg-" + name);
361
+ const open = panel.style.display === "block";
362
+ panel.style.display = open ? "none" : "block";
363
+ if (!open) { input.value = ""; msg.textContent = ""; input.focus(); }
364
+ }
365
+
366
+ async function saveKey(name) {
367
+ const input = document.getElementById("set-input-" + name);
368
+ const msg = document.getElementById("set-msg-" + name);
369
+ const value = input.value.trim();
370
+ if (!value) { msg.className = "set-msg fail"; msg.textContent = "Value is empty."; return; }
371
+
372
+ msg.className = "set-msg"; msg.textContent = "Saving…";
373
+ try {
374
+ const r = await fetch(BASE + "/set/" + name, {
375
+ method: "POST",
376
+ headers: { "Content-Type": "application/json" },
377
+ body: JSON.stringify({ value })
378
+ }).then(r => r.json());
379
+
380
+ if (r.locked) { showLockScreen(); return; }
381
+ if (r.error) throw new Error(r.error);
382
+ msg.className = "set-msg ok"; msg.textContent = "✓ Saved";
383
+ input.value = "";
384
+ const dot = document.getElementById("sdot-" + name);
385
+ if (dot) { dot.className = "status-dot"; dot.title = ""; }
386
+ setTimeout(() => {
387
+ document.getElementById("set-panel-" + name).style.display = "none";
388
+ msg.textContent = "";
389
+ }, 1800);
390
+ } catch (e) {
391
+ msg.className = "set-msg fail"; msg.textContent = "✗ " + e.message;
392
+ }
393
+ }
394
+
395
+ // ── Check all credentials ───────────────────
396
+ async function checkAll() {
397
+ const btn = document.getElementById("check-btn");
398
+ btn.disabled = true; btn.textContent = "Checking…";
399
+
400
+ // Show checking state on every visible dot
401
+ document.querySelectorAll(".status-dot").forEach(d => {
402
+ d.className = "status-dot checking"; d.title = "Checking…";
403
+ });
404
+
405
+ try {
406
+ const r = await fetch(BASE + "/check-all").then(r => r.json());
407
+ if (r.locked) { showLockScreen(); return; }
408
+ if (r.error) throw new Error(r.error);
409
+
410
+ const results = r.results || {};
411
+ for (const [name, result] of Object.entries(results)) {
412
+ const dot = document.getElementById("sdot-" + name);
413
+ if (!dot) continue;
414
+ if (result.ok) {
415
+ dot.className = "status-dot ok"; dot.title = "OK";
416
+ } else {
417
+ dot.className = "status-dot fail";
418
+ dot.title = result.reason || "No key stored";
419
+ }
420
+ }
421
+
422
+ // Fade green dots after 3 s — red dots stay until next check
423
+ setTimeout(() => {
424
+ document.querySelectorAll(".status-dot.ok").forEach(d => d.classList.add("fading"));
425
+ }, 3000);
426
+
427
+ } catch (e) {
428
+ document.querySelectorAll(".status-dot").forEach(d => { d.className = "status-dot"; d.title = ""; });
429
+ const errBar = document.getElementById("error-bar");
430
+ errBar.textContent = "⚠ Check failed: " + e.message;
431
+ errBar.style.display = "block";
432
+ } finally {
433
+ btn.disabled = false; btn.textContent = "⬤ Check All";
434
+ }
435
+ }
436
+
437
+ // ── Change password ─────────────────────────
438
+ function toggleChangePw() {
439
+ const panel = document.getElementById("chpw-panel");
440
+ const open = panel.style.display === "block";
441
+ panel.style.display = open ? "none" : "block";
442
+ if (!open) {
443
+ document.getElementById("chpw-new").value = "";
444
+ document.getElementById("chpw-confirm").value = "";
445
+ document.getElementById("chpw-msg").textContent = "";
446
+ document.getElementById("chpw-new").focus();
447
+ }
448
+ }
449
+
450
+ async function changePassword() {
451
+ const newPw = document.getElementById("chpw-new").value;
452
+ const confPw = document.getElementById("chpw-confirm").value;
453
+ const msg = document.getElementById("chpw-msg");
454
+
455
+ if (!newPw) { msg.className = "chpw-msg fail"; msg.textContent = "Enter a new password."; return; }
456
+ if (newPw.length < 8) { msg.className = "chpw-msg fail"; msg.textContent = "Minimum 8 characters."; return; }
457
+ if (newPw !== confPw) { msg.className = "chpw-msg fail"; msg.textContent = "Passwords don't match."; return; }
458
+
459
+ msg.className = "chpw-msg"; msg.textContent = "Updating…";
460
+ try {
461
+ const r = await fetch(BASE + "/change-pw", {
462
+ method: "POST",
463
+ headers: { "Content-Type": "application/json" },
464
+ body: JSON.stringify({ newPassword: newPw })
465
+ }).then(r => r.json());
466
+
467
+ if (r.locked) { showLockScreen(); return; }
468
+ if (r.error) throw new Error(r.error);
469
+
470
+ msg.className = "chpw-msg ok"; msg.textContent = "✓ Password updated";
471
+ document.getElementById("chpw-new").value = "";
472
+ document.getElementById("chpw-confirm").value = "";
473
+ setTimeout(() => {
474
+ document.getElementById("chpw-panel").style.display = "none";
475
+ msg.textContent = "";
476
+ }, 2000);
477
+ } catch (e) {
478
+ msg.className = "chpw-msg fail"; msg.textContent = "✗ " + e.message;
479
+ }
480
+ }
481
+
482
+ // Enter key on lock screen
483
+ document.addEventListener("DOMContentLoaded", () => {
484
+ document.getElementById("lock-input").addEventListener("keydown", e => {
485
+ if (e.key === "Enter") unlock();
486
+ });
487
+ });
488
+
489
+ boot();
490
+ </script>
491
+ </body>
492
+ </html>`;
493
+ }
494
+
495
+ // ── Body parser helper ────────────────────────────────────────
496
+ function readBody(req) {
497
+ return new Promise((resolve, reject) => {
498
+ let data = "";
499
+ req.on("data", chunk => { data += chunk; if (data.length > 65536) reject(new Error("Body too large")); });
500
+ req.on("end", () => { try { resolve(JSON.parse(data)); } catch { reject(new Error("Invalid JSON")); } });
501
+ req.on("error", reject);
502
+ });
503
+ }
504
+
39
505
  // ── Server logic (shared by foreground + daemon) ─────────────
40
- function createServer(password, whitelist, port) {
506
+ function createServer(initPassword, whitelist, port) {
41
507
  const MAX_FAILS = 3;
42
508
  let failCount = 0;
509
+ let password = initPassword || null; // null = locked; set via POST /auth
43
510
  const machineHash = getMachineHash();
44
511
 
512
+ const CORS = {
513
+ "Access-Control-Allow-Origin": "*",
514
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
515
+ "Access-Control-Allow-Headers": "Content-Type",
516
+ };
517
+
45
518
  function strike(res, code, message) {
46
519
  failCount++;
47
520
  const remaining = MAX_FAILS - failCount;
@@ -55,7 +528,7 @@ function createServer(password, whitelist, port) {
55
528
  ...(failCount >= MAX_FAILS ? { shutdown: true } : {})
56
529
  });
57
530
 
58
- res.writeHead(code, { "Content-Type": "application/json" });
531
+ res.writeHead(code, { "Content-Type": "application/json", ...CORS });
59
532
  res.end(body);
60
533
 
61
534
  if (failCount >= MAX_FAILS) {
@@ -67,7 +540,7 @@ function createServer(password, whitelist, port) {
67
540
  }
68
541
 
69
542
  function ok(res, data) {
70
- res.writeHead(200, { "Content-Type": "application/json" });
543
+ res.writeHead(200, { "Content-Type": "application/json", ...CORS });
71
544
  res.end(JSON.stringify(data));
72
545
  }
73
546
 
@@ -79,15 +552,28 @@ function createServer(password, whitelist, port) {
79
552
  return strike(res, 403, `Rejected non-local address: ${remote}`);
80
553
  }
81
554
 
555
+ // CORS preflight
556
+ if (req.method === "OPTIONS") {
557
+ res.writeHead(204, CORS);
558
+ return res.end();
559
+ }
560
+
82
561
  const url = new URL(req.url, `http://127.0.0.1:${port}`);
83
562
  const reqPath = url.pathname;
84
563
  const method = req.method;
85
564
 
565
+ // GET / — built-in web dashboard
566
+ if (method === "GET" && reqPath === "/") {
567
+ res.writeHead(200, { "Content-Type": "text/html", ...CORS });
568
+ return res.end(dashboardHtml(port, whitelist));
569
+ }
570
+
86
571
  // GET /ping
87
572
  if (method === "GET" && reqPath === "/ping") {
88
573
  return ok(res, {
89
574
  status: "ok",
90
575
  pid: process.pid,
576
+ locked: !password,
91
577
  failures: failCount,
92
578
  failures_remaining: MAX_FAILS - failCount,
93
579
  services: whitelist || "all",
@@ -103,8 +589,19 @@ function createServer(password, whitelist, port) {
103
589
  return;
104
590
  }
105
591
 
592
+ // Locked guard — returns true if locked and already responded
593
+ function lockedGuard(res) {
594
+ if (!password) {
595
+ res.writeHead(401, { "Content-Type": "application/json", ...CORS });
596
+ res.end(JSON.stringify({ error: "Vault is locked", locked: true }));
597
+ return true;
598
+ }
599
+ return false;
600
+ }
601
+
106
602
  // GET /status
107
603
  if (method === "GET" && reqPath === "/status") {
604
+ if (lockedGuard(res)) return;
108
605
  try {
109
606
  const { token, timestamp } = deriveToken(password, machineHash);
110
607
  const result = await api.status(password, machineHash, token, timestamp);
@@ -123,6 +620,7 @@ function createServer(password, whitelist, port) {
123
620
  // GET /get/:service
124
621
  const getMatch = reqPath.match(/^\/get\/([a-zA-Z0-9_-]+)$/);
125
622
  if (method === "GET" && getMatch) {
623
+ if (lockedGuard(res)) return;
126
624
  const service = getMatch[1].toLowerCase();
127
625
 
128
626
  if (whitelist && !whitelist.includes(service)) {
@@ -139,6 +637,135 @@ function createServer(password, whitelist, port) {
139
637
  }
140
638
  }
141
639
 
640
+ // POST /auth — unlock the vault with a password (verifies against Edge Function)
641
+ if (method === "POST" && reqPath === "/auth") {
642
+ let body;
643
+ try { body = await readBody(req); } catch {
644
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
645
+ return res.end(JSON.stringify({ error: "Invalid JSON body" }));
646
+ }
647
+
648
+ const pw = body.password;
649
+ if (!pw || typeof pw !== "string") {
650
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
651
+ return res.end(JSON.stringify({ error: "password is required" }));
652
+ }
653
+
654
+ try {
655
+ const { token, timestamp } = deriveToken(pw, machineHash);
656
+ const result = await api.test(pw, machineHash, token, timestamp);
657
+ if (result.error) throw new Error(result.error);
658
+ password = pw; // unlock — store in process memory only
659
+ const logLine = `[${new Date().toISOString()}] Vault unlocked\n`;
660
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
661
+ return ok(res, { ok: true, locked: false });
662
+ } catch {
663
+ // Wrong password — not a lockout strike, just a UI auth attempt
664
+ res.writeHead(401, { "Content-Type": "application/json", ...CORS });
665
+ return res.end(JSON.stringify({ error: "Invalid password" }));
666
+ }
667
+ }
668
+
669
+ // POST /lock — clear password from memory
670
+ if (method === "POST" && reqPath === "/lock") {
671
+ password = null;
672
+ const logLine = `[${new Date().toISOString()}] Vault locked\n`;
673
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
674
+ return ok(res, { ok: true, locked: true });
675
+ }
676
+
677
+ // GET /check-all — soft health check, no strikes for missing/disabled keys
678
+ if (method === "GET" && reqPath === "/check-all") {
679
+ if (lockedGuard(res)) return;
680
+ try {
681
+ const { token: st, timestamp: sts } = deriveToken(password, machineHash);
682
+ const statusResult = await api.status(password, machineHash, st, sts);
683
+ if (statusResult.error) return strike(res, 502, statusResult.error);
684
+
685
+ const services = (statusResult.services || []).filter(
686
+ s => !whitelist || whitelist.includes(s.name.toLowerCase())
687
+ );
688
+
689
+ const results = {};
690
+ for (const svc of services) {
691
+ try {
692
+ const { token, timestamp } = deriveToken(password, machineHash);
693
+ const r = await api.retrieve(password, machineHash, token, timestamp, svc.name);
694
+ results[svc.name] = r.error
695
+ ? { ok: false, reason: r.error }
696
+ : { ok: true };
697
+ } catch (e) {
698
+ results[svc.name] = { ok: false, reason: e.message };
699
+ }
700
+ }
701
+ return ok(res, { results });
702
+ } catch (err) {
703
+ return strike(res, 502, err.message);
704
+ }
705
+ }
706
+
707
+ // POST /change-pw — change master password (must be unlocked)
708
+ if (method === "POST" && reqPath === "/change-pw") {
709
+ if (lockedGuard(res)) return;
710
+
711
+ let body;
712
+ try { body = await readBody(req); } catch {
713
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
714
+ return res.end(JSON.stringify({ error: "Invalid JSON body" }));
715
+ }
716
+
717
+ const { newPassword } = body;
718
+ if (!newPassword || typeof newPassword !== "string" || newPassword.length < 8) {
719
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
720
+ return res.end(JSON.stringify({ error: "newPassword must be at least 8 characters" }));
721
+ }
722
+
723
+ try {
724
+ const { token, timestamp } = deriveToken(password, machineHash);
725
+ const newSeedHash = deriveSeedHash(machineHash, newPassword);
726
+ const result = await api.changePassword(password, machineHash, token, timestamp, newSeedHash);
727
+ if (result.error) throw new Error(result.error);
728
+ password = newPassword; // update in-memory password to new one
729
+ const logLine = `[${new Date().toISOString()}] Password changed\n`;
730
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
731
+ return ok(res, { ok: true });
732
+ } catch (err) {
733
+ res.writeHead(502, { "Content-Type": "application/json", ...CORS });
734
+ return res.end(JSON.stringify({ error: err.message }));
735
+ }
736
+ }
737
+
738
+ // POST /set/:service — write a new key value into vault
739
+ const setMatch = reqPath.match(/^\/set\/([a-zA-Z0-9_-]+)$/);
740
+ if (method === "POST" && setMatch) {
741
+ if (lockedGuard(res)) return;
742
+ const service = setMatch[1].toLowerCase();
743
+
744
+ if (whitelist && !whitelist.includes(service)) {
745
+ return strike(res, 403, `Service '${service}' not in whitelist`);
746
+ }
747
+
748
+ let body;
749
+ try { body = await readBody(req); } catch {
750
+ return strike(res, 400, "Invalid JSON body");
751
+ }
752
+
753
+ const value = body.value;
754
+ if (!value || typeof value !== "string" || !value.trim()) {
755
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
756
+ return res.end(JSON.stringify({ error: "value is required" }));
757
+ }
758
+
759
+ try {
760
+ const { token, timestamp } = deriveToken(password, machineHash);
761
+ const result = await api.write(password, machineHash, token, timestamp, service, value.trim());
762
+ if (result.error) return strike(res, 502, result.error);
763
+ return ok(res, { ok: true, service });
764
+ } catch (err) {
765
+ return strike(res, 502, err.message);
766
+ }
767
+ }
768
+
142
769
  // Unknown route
143
770
  return strike(res, 404, `Unknown endpoint: ${reqPath}`);
144
771
  });
@@ -172,12 +799,15 @@ async function actionStart(opts) {
172
799
 
173
800
  // If we're the daemon child, run the server directly
174
801
  if (process.env.__CLAUTH_DAEMON === "1") {
175
- try {
176
- await verifyAuth(password);
177
- } catch (err) {
178
- const msg = `[${new Date().toISOString()}] Auth failed: ${err.message}\n`;
179
- fs.appendFileSync(LOG_FILE, msg);
180
- process.exit(1);
802
+ // Verify password only if one was provided at start (optional — browser can unlock later)
803
+ if (password) {
804
+ try {
805
+ await verifyAuth(password);
806
+ } catch (err) {
807
+ const msg = `[${new Date().toISOString()}] Auth failed: ${err.message}\n`;
808
+ fs.appendFileSync(LOG_FILE, msg);
809
+ process.exit(1);
810
+ }
181
811
  }
182
812
 
183
813
  const server = createServer(password, whitelist, port);
@@ -199,15 +829,19 @@ async function actionStart(opts) {
199
829
  return;
200
830
  }
201
831
 
202
- // Parent: verify auth first (show errors to user)
203
- console.log(chalk.gray("\n Verifying vault credentials..."));
204
- try {
205
- await verifyAuth(password);
206
- } catch (err) {
207
- console.log(chalk.red(`\n Auth failed: ${err.message}\n`));
208
- process.exit(1);
832
+ // If --pw provided, verify it before spawning (fast-fail on wrong password)
833
+ if (password) {
834
+ console.log(chalk.gray("\n Verifying vault credentials..."));
835
+ try {
836
+ await verifyAuth(password);
837
+ } catch (err) {
838
+ console.log(chalk.red(`\n Auth failed: ${err.message}\n`));
839
+ process.exit(1);
840
+ }
841
+ console.log(chalk.green(" ✓ Vault auth verified"));
842
+ } else {
843
+ console.log(chalk.yellow("\n Starting in locked state — open browser to unlock"));
209
844
  }
210
- console.log(chalk.green(" ✓ Vault auth verified"));
211
845
 
212
846
  // Spawn detached daemon child via the clauth CLI entry point
213
847
  const { spawn } = await import("child_process");
@@ -216,8 +850,9 @@ async function actionStart(opts) {
216
850
  const __filename = fileURLToPath(import.meta.url);
217
851
  const cliEntry = join(dirname(__filename), "..", "index.js");
218
852
 
219
- // Build args: node index.js serve start --port N --pw PW [--services S]
220
- const childArgs = [cliEntry, "serve", "start", "--port", String(port), "--pw", password];
853
+ // Build args: node index.js serve start --port N [--pw PW] [--services S]
854
+ const childArgs = [cliEntry, "serve", "start", "--port", String(port)];
855
+ if (password) childArgs.push("--pw", password);
221
856
  if (opts.services) childArgs.push("--services", opts.services);
222
857
 
223
858
  const out = fs.openSync(LOG_FILE, "a");
@@ -246,6 +881,9 @@ async function actionStart(opts) {
246
881
  console.log(chalk.gray(` Port: 127.0.0.1:${info.port}`));
247
882
  console.log(chalk.gray(` Services: ${whitelist ? whitelist.join(", ") : "all"}`));
248
883
  console.log(chalk.gray(` Log: ${LOG_FILE}`));
884
+ if (!password) {
885
+ console.log(chalk.cyan(`\n 👉 Open http://127.0.0.1:${info.port} to unlock the vault`));
886
+ }
249
887
  console.log(chalk.gray(` Stop: clauth serve stop\n`));
250
888
  } else {
251
889
  console.log(chalk.red(`\n ❌ Failed to start daemon — check ${LOG_FILE}\n`));
@@ -328,21 +966,25 @@ async function actionRestart(opts) {
328
966
  }
329
967
 
330
968
  async function actionForeground(opts) {
331
- const port = parseInt(opts.port || "52437", 10);
332
- const password = opts.pw;
969
+ const port = parseInt(opts.port || "52437", 10);
970
+ const password = opts.pw || null;
333
971
  const whitelist = opts.services
334
972
  ? opts.services.split(",").map(s => s.trim().toLowerCase())
335
973
  : null;
336
974
 
337
- console.log(chalk.gray("\n Verifying vault credentials..."));
338
- try {
339
- await verifyAuth(password);
340
- } catch (err) {
341
- console.log(chalk.red(`\n Auth failed: ${err.message}\n`));
342
- process.exit(1);
975
+ if (password) {
976
+ console.log(chalk.gray("\n Verifying vault credentials..."));
977
+ try {
978
+ await verifyAuth(password);
979
+ } catch (err) {
980
+ console.log(chalk.red(`\n Auth failed: ${err.message}\n`));
981
+ process.exit(1);
982
+ }
983
+ console.log(chalk.green(" ✓ Vault auth verified"));
984
+ } else {
985
+ console.log(chalk.yellow("\n Starting in locked state — open browser to unlock"));
343
986
  }
344
987
 
345
- console.log(chalk.green(" ✓ Vault auth verified"));
346
988
  console.log(chalk.gray(` Port: 127.0.0.1:${port}`));
347
989
  console.log(chalk.gray(` Services: ${whitelist ? whitelist.join(", ") : "all"}`));
348
990
  console.log(chalk.gray(` Lockout: 3 failures → exit\n`));
@@ -351,6 +993,7 @@ async function actionForeground(opts) {
351
993
  server.listen(port, "127.0.0.1", () => {
352
994
  writePid(process.pid, port);
353
995
  console.log(chalk.green(` clauth serve → http://127.0.0.1:${port}`));
996
+ if (!password) console.log(chalk.cyan(` 👉 Open http://127.0.0.1:${port} to unlock`));
354
997
  console.log(chalk.gray(" Ctrl+C to stop\n"));
355
998
  });
356
999