@lifeaitools/clauth 0.5.2 → 0.7.3

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.
@@ -5,6 +5,7 @@
5
5
  // Supports: start (background daemon), stop, restart, ping, foreground
6
6
 
7
7
  import http from "http";
8
+ import crypto from "crypto";
8
9
  import fs from "fs";
9
10
  import os from "os";
10
11
  import path from "path";
@@ -153,6 +154,32 @@ function dashboardHtml(port, whitelist) {
153
154
  .btn-tunnel-stop{background:#1e293b;color:#f87171;border:1px solid #334155;padding:6px 12px;font-size:.8rem;border-radius:6px;cursor:pointer;font-weight:500}
154
155
  .btn-tunnel-stop:hover{background:#2d1f1f;border-color:#f87171}
155
156
  .tunnel-err{font-size:.78rem;color:#f87171;width:100%;margin-top:4px}
157
+ .mcp-setup{background:#0f1d2d;border:1px solid #1e3a5f;border-radius:8px;padding:1rem 1.25rem;margin-bottom:1.25rem;display:none}
158
+ .mcp-setup.open{display:block}
159
+ .mcp-setup-title{font-size:.85rem;font-weight:600;color:#e2e8f0;margin-bottom:.75rem}
160
+ .mcp-row{display:flex;align-items:center;gap:8px;margin-bottom:8px}
161
+ .mcp-label{font-size:.72rem;color:#64748b;min-width:80px;text-transform:uppercase;letter-spacing:.5px;font-weight:600}
162
+ .mcp-val{flex:1;font-family:'Courier New',monospace;font-size:.82rem;color:#60a5fa;background:#0a0f1a;border:1px solid #1e3a5f;border-radius:4px;padding:6px 10px;word-break:break-all;user-select:all}
163
+ .mcp-copy{background:none;border:1px solid #334155;color:#94a3b8;border-radius:4px;padding:4px 8px;cursor:pointer;font-size:.75rem;font-family:'Courier New',monospace;transition:all .15s;flex-shrink:0}
164
+ .mcp-copy:hover{border-color:#60a5fa;color:#60a5fa}
165
+ .mcp-copy.ok{border-color:#4ade80;color:#4ade80}
166
+ .btn-mcp-setup{background:#1e293b;color:#94a3b8;border:1px solid #334155;padding:6px 12px;font-size:.8rem;border-radius:6px;cursor:pointer;font-weight:500;transition:all .15s}
167
+ .btn-mcp-setup:hover{border-color:#60a5fa;color:#60a5fa}
168
+ .btn-mcp-setup:disabled{opacity:.4;cursor:not-allowed}
169
+ .btn-add{background:#1a2e1a;color:#4ade80;border:1px solid #166534;padding:7px 16px;font-size:.85rem;border-radius:7px;cursor:pointer;font-weight:500;transition:all .15s}
170
+ .btn-add:hover{background:#1a3d22;border-color:#4ade80}
171
+ .add-panel{display:none;background:#1a1f2e;border:1px solid #334155;border-radius:8px;padding:1.25rem;margin-bottom:1.5rem}
172
+ .add-panel h3{font-size:.9rem;font-weight:600;color:#f8fafc;margin-bottom:1rem}
173
+ .add-row{display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;margin-bottom:.75rem}
174
+ .add-field{display:flex;flex-direction:column;gap:4px}
175
+ .add-field label{font-size:.75rem;color:#64748b}
176
+ .add-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}
177
+ .add-input:focus{border-color:#3b82f6}
178
+ .add-select{background:#0f172a;border:1px solid #334155;border-radius:6px;color:#e2e8f0;font-size:.88rem;padding:7px 12px;outline:none;width:200px;transition:border-color .2s;cursor:pointer}
179
+ .add-select:focus{border-color:#3b82f6}
180
+ .add-foot{display:flex;gap:8px;align-items:center}
181
+ .add-msg{font-size:.82rem}
182
+ .add-msg.ok{color:#4ade80} .add-msg.fail{color:#f87171}
156
183
  .footer{margin-top:2rem;font-size:.75rem;color:#475569;text-align:center}
157
184
  .oauth-fields{display:flex;flex-direction:column;gap:8px;margin-bottom:8px}
158
185
  .oauth-field{display:flex;flex-direction:column;gap:3px}
@@ -191,11 +218,33 @@ function dashboardHtml(port, whitelist) {
191
218
  </div>
192
219
  <div class="toolbar">
193
220
  <button class="btn-refresh" onclick="loadServices()">↻ Refresh</button>
221
+ <button class="btn-add" onclick="toggleAddService()">+ Add Service</button>
194
222
  <button class="btn-check" id="check-btn" onclick="checkAll()">⬤ Check All</button>
195
223
  <button class="btn-lock" onclick="lockVault()">🔒 Lock</button>
196
224
  <button class="btn-cancel" style="margin-left:auto" onclick="toggleChangePw()">Change Password</button>
197
225
  </div>
198
226
 
227
+ <div class="add-panel" id="add-panel">
228
+ <h3>Add New Service</h3>
229
+ <div class="add-row">
230
+ <div class="add-field">
231
+ <label>Service name</label>
232
+ <input class="add-input" id="add-name" type="text" placeholder="e.g. coolify-admin" autocomplete="off" spellcheck="false">
233
+ </div>
234
+ <div class="add-field">
235
+ <label>Key type</label>
236
+ <select class="add-select" id="add-type">
237
+ <option value="">Loading…</option>
238
+ </select>
239
+ </div>
240
+ </div>
241
+ <div class="add-foot">
242
+ <button class="btn-chpw-save" onclick="addService()">Create Service</button>
243
+ <button class="btn-cancel" onclick="toggleAddService()">Cancel</button>
244
+ <span class="add-msg" id="add-msg"></span>
245
+ </div>
246
+ </div>
247
+
199
248
  <div class="chpw-panel" id="chpw-panel">
200
249
  <h3>Change Master Password</h3>
201
250
  <div class="chpw-row">
@@ -223,11 +272,34 @@ function dashboardHtml(port, whitelist) {
223
272
  <button class="btn-check" id="btn-tunnel-test" style="display:none;padding:6px 12px;font-size:.8rem" onclick="testTunnel()">Test</button>
224
273
  <button class="btn-claude" id="btn-claude" disabled onclick="openClaude()">Connect claude.ai</button>
225
274
  <button class="btn-tunnel-stop" id="btn-tunnel-toggle" style="display:none" onclick="toggleTunnel()">Stop</button>
275
+ <button class="btn-mcp-setup" id="btn-mcp-setup" style="display:none" onclick="toggleMcpSetup()">Setup MCP</button>
226
276
  <div class="tunnel-err" id="tunnel-err" style="display:none"></div>
227
277
  </div>
228
278
 
279
+ <div class="mcp-setup" id="mcp-setup-panel">
280
+ <div class="mcp-setup-title">claude.ai MCP Integration</div>
281
+ <div class="oauth-fields">
282
+ <div class="mcp-row">
283
+ <span class="mcp-label">URL</span>
284
+ <span class="mcp-val" id="mcp-url">—</span>
285
+ <button class="mcp-copy" onclick="copyMcp('mcp-url')">copy</button>
286
+ </div>
287
+ <div class="mcp-row">
288
+ <span class="mcp-label">Client ID</span>
289
+ <span class="mcp-val" id="mcp-client-id">—</span>
290
+ <button class="mcp-copy" onclick="copyMcp('mcp-client-id')">copy</button>
291
+ </div>
292
+ <div class="mcp-row">
293
+ <span class="mcp-label">Secret</span>
294
+ <span class="mcp-val" id="mcp-client-secret">—</span>
295
+ <button class="mcp-copy" onclick="copyMcp('mcp-client-secret')">copy</button>
296
+ </div>
297
+ </div>
298
+ <div style="font-size:.72rem;color:#64748b;margin-top:4px">Paste these into <a href="https://claude.ai/settings/integrations" target="_blank" style="color:#60a5fa">claude.ai Settings → Integrations</a></div>
299
+ </div>
300
+
229
301
  <div id="grid" class="grid"><p class="loading">Loading services…</p></div>
230
- <div class="footer">localhost:${port} · 127.0.0.1 only · 3-strike lockout</div>
302
+ <div class="footer">localhost:${port} · 127.0.0.1 only · 10-strike lockout</div>
231
303
  </div>
232
304
 
233
305
  <script>
@@ -686,11 +758,78 @@ async function changePassword() {
686
758
  }
687
759
  }
688
760
 
689
- // Enter key on lock screen
761
+ // ── Add Service ──────────────────────────────
762
+ const TYPE_LABELS = {
763
+ token: "token (API key)",
764
+ secret: "secret (password, secret)",
765
+ keypair: "keypair (user:key pair)",
766
+ connstring: "connstring (connection string)",
767
+ oauth: "oauth (OAuth credentials)",
768
+ };
769
+
770
+ async function toggleAddService() {
771
+ const panel = document.getElementById("add-panel");
772
+ const open = panel.style.display === "block";
773
+ panel.style.display = open ? "none" : "block";
774
+ if (!open) {
775
+ document.getElementById("add-name").value = "";
776
+ document.getElementById("add-msg").textContent = "";
777
+ // Fetch available key types from server
778
+ const sel = document.getElementById("add-type");
779
+ sel.innerHTML = '<option value="">Loading…</option>';
780
+ try {
781
+ const r = await fetch(BASE + "/meta").then(r => r.json());
782
+ const types = r.key_types || ["token", "secret", "keypair", "connstring", "oauth"];
783
+ sel.innerHTML = types.map(t =>
784
+ \`<option value="\${t}">\${TYPE_LABELS[t] || t}</option>\`
785
+ ).join("");
786
+ } catch {
787
+ sel.innerHTML = ["token","secret","keypair","connstring","oauth"].map(t =>
788
+ \`<option value="\${t}">\${TYPE_LABELS[t] || t}</option>\`
789
+ ).join("");
790
+ }
791
+ document.getElementById("add-name").focus();
792
+ }
793
+ }
794
+
795
+ async function addService() {
796
+ const name = document.getElementById("add-name").value.trim().toLowerCase();
797
+ const type = document.getElementById("add-type").value;
798
+ const msg = document.getElementById("add-msg");
799
+
800
+ if (!name) { msg.className = "add-msg fail"; msg.textContent = "Service name is required."; return; }
801
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) { msg.className = "add-msg fail"; msg.textContent = "Lowercase letters, numbers, hyphens, underscores only."; return; }
802
+
803
+ msg.className = "add-msg"; msg.textContent = "Creating…";
804
+ try {
805
+ const r = await fetch(BASE + "/add-service", {
806
+ method: "POST",
807
+ headers: { "Content-Type": "application/json" },
808
+ body: JSON.stringify({ name, key_type: type, label: name })
809
+ }).then(r => r.json());
810
+
811
+ if (r.locked) { showLockScreen(); return; }
812
+ if (r.error) throw new Error(r.error);
813
+
814
+ msg.className = "add-msg ok"; msg.textContent = "✓ " + name + " created";
815
+ setTimeout(() => {
816
+ document.getElementById("add-panel").style.display = "none";
817
+ msg.textContent = "";
818
+ loadServices();
819
+ }, 1200);
820
+ } catch (e) {
821
+ msg.className = "add-msg fail"; msg.textContent = "✗ " + e.message;
822
+ }
823
+ }
824
+
825
+ // Enter key on lock screen and add-service panel
690
826
  document.addEventListener("DOMContentLoaded", () => {
691
827
  document.getElementById("lock-input").addEventListener("keydown", e => {
692
828
  if (e.key === "Enter") unlock();
693
829
  });
830
+ document.getElementById("add-name").addEventListener("keydown", e => {
831
+ if (e.key === "Enter") addService();
832
+ });
694
833
  });
695
834
 
696
835
  // ── Tunnel management ───────────────────────
@@ -714,6 +853,7 @@ async function updateTunnelUI() {
714
853
  const label = document.getElementById("tunnel-label");
715
854
  const btn = document.getElementById("btn-claude");
716
855
  const togBtn = document.getElementById("btn-tunnel-toggle");
856
+ const mcpBtn = document.getElementById("btn-mcp-setup");
717
857
  const errEl = document.getElementById("tunnel-err");
718
858
 
719
859
  try {
@@ -725,10 +865,12 @@ async function updateTunnelUI() {
725
865
  dot.className = "tunnel-dot err";
726
866
  label.innerHTML = '<strong>claude.ai MCP</strong> — ' + t.error;
727
867
  btn.disabled = true;
868
+ mcpBtn.style.display = "none";
728
869
  togBtn.style.display = "none";
729
870
  togBtn.textContent = "Start Tunnel";
730
871
  togBtn.onclick = () => toggleTunnel("start");
731
872
  togBtn.style.display = "inline-block";
873
+ document.getElementById("mcp-setup-panel").classList.remove("open");
732
874
  return "error";
733
875
  }
734
876
 
@@ -740,6 +882,7 @@ async function updateTunnelUI() {
740
882
  togBtn.textContent = "Stop Tunnel";
741
883
  togBtn.onclick = () => toggleTunnel("stop");
742
884
  togBtn.style.display = "inline-block";
885
+ mcpBtn.style.display = "inline-block";
743
886
  return "connected";
744
887
  }
745
888
 
@@ -747,6 +890,7 @@ async function updateTunnelUI() {
747
890
  dot.className = "tunnel-dot starting";
748
891
  label.innerHTML = '<strong>claude.ai MCP</strong> — starting tunnel…';
749
892
  btn.disabled = true;
893
+ mcpBtn.style.display = "none";
750
894
  togBtn.textContent = "Stop Tunnel";
751
895
  togBtn.onclick = () => toggleTunnel("stop");
752
896
  togBtn.style.display = "inline-block";
@@ -757,16 +901,20 @@ async function updateTunnelUI() {
757
901
  dot.className = "tunnel-dot off";
758
902
  label.innerHTML = '<strong>claude.ai MCP</strong> — tunnel not running';
759
903
  btn.disabled = true;
904
+ mcpBtn.style.display = "none";
760
905
  togBtn.textContent = "Start Tunnel";
761
906
  togBtn.onclick = () => toggleTunnel("start");
762
907
  togBtn.style.display = "inline-block";
908
+ document.getElementById("mcp-setup-panel").classList.remove("open");
763
909
  return "stopped";
764
910
 
765
911
  } catch {
766
912
  dot.className = "tunnel-dot off";
767
913
  label.innerHTML = '<strong>claude.ai MCP</strong> — unable to reach daemon';
768
914
  btn.disabled = true;
915
+ mcpBtn.style.display = "none";
769
916
  togBtn.style.display = "none";
917
+ document.getElementById("mcp-setup-panel").classList.remove("open");
770
918
  return "error";
771
919
  }
772
920
  }
@@ -827,6 +975,32 @@ function openClaude() {
827
975
  });
828
976
  }
829
977
 
978
+ async function toggleMcpSetup() {
979
+ const panel = document.getElementById("mcp-setup-panel");
980
+ const isOpen = panel.classList.toggle("open");
981
+ if (isOpen) {
982
+ try {
983
+ const m = await fetch(BASE + "/mcp-setup").then(r => r.json());
984
+ document.getElementById("mcp-url").textContent = m.url || "(tunnel not running)";
985
+ document.getElementById("mcp-client-id").textContent = m.clientId || "—";
986
+ document.getElementById("mcp-client-secret").textContent = m.clientSecret || "—";
987
+ } catch {
988
+ document.getElementById("mcp-url").textContent = "(error fetching)";
989
+ }
990
+ }
991
+ }
992
+
993
+ function copyMcp(elId) {
994
+ const val = document.getElementById(elId).textContent;
995
+ if (!val || val === "—" || val.startsWith("(")) return;
996
+ const btn = document.getElementById(elId).nextElementSibling;
997
+ navigator.clipboard.writeText(val).then(() => {
998
+ btn.textContent = "ok";
999
+ btn.classList.add("ok");
1000
+ setTimeout(() => { btn.textContent = "copy"; btn.classList.remove("ok"); }, 1500);
1001
+ }).catch(() => {});
1002
+ }
1003
+
830
1004
  boot();
831
1005
  </script>
832
1006
  </body>
@@ -844,7 +1018,7 @@ function readBody(req) {
844
1018
  }
845
1019
 
846
1020
  // ── Server logic (shared by foreground + daemon) ─────────────
847
- function createServer(initPassword, whitelist, port) {
1021
+ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
848
1022
  // Ensure Windows system tools are reachable (bash shells may lack these on PATH)
849
1023
  if (os.platform() === "win32") {
850
1024
  const sys32 = "C:\\Windows\\System32";
@@ -855,7 +1029,7 @@ function createServer(initPassword, whitelist, port) {
855
1029
  process.env.PATH = (process.env.PATH || "") + ";" + sys32;
856
1030
  }
857
1031
  }
858
- const MAX_FAILS = 3;
1032
+ const MAX_FAILS = 10;
859
1033
  let failCount = 0;
860
1034
  let password = initPassword || null; // null = locked; set via POST /auth
861
1035
  const machineHash = getMachineHash();
@@ -863,7 +1037,7 @@ function createServer(initPassword, whitelist, port) {
863
1037
  const CORS = {
864
1038
  "Access-Control-Allow-Origin": "*",
865
1039
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
866
- "Access-Control-Allow-Headers": "Content-Type",
1040
+ "Access-Control-Allow-Headers": "Content-Type, Authorization, Mcp-Session-Id",
867
1041
  };
868
1042
 
869
1043
  // ── MCP SSE session tracking ──────────────────────────────
@@ -891,13 +1065,39 @@ function createServer(initPassword, whitelist, port) {
891
1065
  let tunnelUrl = null;
892
1066
  let tunnelError = null;
893
1067
 
1068
+ // ── OAuth provider (self-contained for claude.ai MCP) ──────
1069
+ const oauthClients = new Map(); // client_id → { client_secret, redirect_uris, client_name }
1070
+ const oauthCodes = new Map(); // code → { client_id, redirect_uri, code_challenge, expires }
1071
+ const oauthTokens = new Set(); // active access tokens
1072
+
1073
+ // Pre-generate a stable client for claude.ai (shown at startup)
1074
+ const OAUTH_CLIENT_ID = crypto.randomBytes(16).toString("hex");
1075
+ const OAUTH_CLIENT_SECRET = crypto.randomBytes(32).toString("hex");
1076
+ oauthClients.set(OAUTH_CLIENT_ID, {
1077
+ client_id: OAUTH_CLIENT_ID, client_secret: OAUTH_CLIENT_SECRET,
1078
+ client_name: "claude.ai", redirect_uris: ["https://claude.ai/api/mcp/auth_callback"],
1079
+ grant_types: ["authorization_code"], response_types: ["code"],
1080
+ token_endpoint_auth_method: "client_secret_post",
1081
+ });
1082
+
1083
+ function oauthBase() { return tunnelUrl || `http://127.0.0.1:${port}`; }
1084
+ function sha256base64url(str) { return crypto.createHash("sha256").update(str).digest("base64url"); }
1085
+
1086
+ function readRawBody(req) {
1087
+ return new Promise((resolve, reject) => {
1088
+ let data = "";
1089
+ req.on("data", chunk => { data += chunk; if (data.length > 65536) reject(new Error("Body too large")); });
1090
+ req.on("end", () => resolve(data));
1091
+ req.on("error", reject);
1092
+ });
1093
+ }
1094
+
894
1095
  async function startTunnel() {
895
1096
  if (tunnelProc) return; // already running
896
1097
  tunnelUrl = null;
897
1098
  tunnelError = null;
898
1099
 
899
1100
  try {
900
- // Quick tunnel — no DNS/config needed, new random URL each session
901
1101
  // Resolve cloudflared binary — may not be on PATH in bash shells
902
1102
  let cfBin = "cloudflared";
903
1103
  if (os.platform() === "win32") {
@@ -913,17 +1113,30 @@ function createServer(initPassword, whitelist, port) {
913
1113
  }
914
1114
  }
915
1115
 
916
- const proc = spawnProc(cfBin, ["tunnel", "--url", `http://127.0.0.1:${port}`], {
1116
+ // Named tunnel (fixed subdomain) or quick tunnel (random URL)
1117
+ let args;
1118
+ if (tunnelHostname) {
1119
+ // Named tunnel: cloudflared tunnel run (uses ~/.cloudflared/config.yml)
1120
+ // Config maps hostname → local service, so no --url needed
1121
+ args = ["tunnel", "run"];
1122
+ tunnelUrl = `https://${tunnelHostname}`;
1123
+ } else {
1124
+ // Quick tunnel — random URL each session
1125
+ args = ["tunnel", "--url", `http://127.0.0.1:${port}`];
1126
+ }
1127
+
1128
+ const proc = spawnProc(cfBin, args, {
917
1129
  stdio: ["ignore", "pipe", "pipe"],
918
1130
  });
919
1131
 
920
1132
  tunnelProc = proc;
921
1133
 
922
- // cloudflared prints the quick tunnel URL to stderr
1134
+ // cloudflared prints output to stderr
923
1135
  let stderrBuf = "";
924
1136
  proc.stderr.on("data", (chunk) => {
925
1137
  stderrBuf += chunk.toString();
926
- if (!tunnelUrl) {
1138
+ // For quick tunnels, capture the random URL from stderr
1139
+ if (!tunnelHostname && !tunnelUrl) {
927
1140
  const match = stderrBuf.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
928
1141
  if (match) {
929
1142
  tunnelUrl = match[0];
@@ -954,6 +1167,11 @@ function createServer(initPassword, whitelist, port) {
954
1167
  // Give it a moment to start
955
1168
  await new Promise(r => setTimeout(r, 4000));
956
1169
 
1170
+ if (tunnelHostname && tunnelProc) {
1171
+ const logLine = `[${new Date().toISOString()}] Named tunnel started: ${tunnelUrl}\n`;
1172
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
1173
+ }
1174
+
957
1175
  } catch (err) {
958
1176
  tunnelError = err.message;
959
1177
  tunnelProc = null;
@@ -1006,9 +1224,18 @@ function createServer(initPassword, whitelist, port) {
1006
1224
  }
1007
1225
 
1008
1226
  const server = http.createServer(async (req, res) => {
1009
- // Hard reject anything not from loopback
1010
1227
  const remote = req.socket.remoteAddress;
1011
1228
  const isLocal = remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
1229
+
1230
+ const url = new URL(req.url, `http://127.0.0.1:${port}`);
1231
+ const reqPath = url.pathname;
1232
+ const method = req.method;
1233
+
1234
+ // Log every request
1235
+ const logLine = `[${new Date().toISOString()}] ${method} ${reqPath} from=${remote} local=${isLocal}\n`;
1236
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
1237
+
1238
+ // Hard reject anything not from loopback
1012
1239
  if (!isLocal) {
1013
1240
  return strike(res, 403, `Rejected non-local address: ${remote}`);
1014
1241
  }
@@ -1019,11 +1246,216 @@ function createServer(initPassword, whitelist, port) {
1019
1246
  return res.end();
1020
1247
  }
1021
1248
 
1022
- const url = new URL(req.url, `http://127.0.0.1:${port}`);
1023
- const reqPath = url.pathname;
1024
- const method = req.method;
1249
+ // ── OAuth Discovery (RFC 9728 + RFC 8414) ──────────────
1250
+ if (reqPath === "/.well-known/oauth-protected-resource" ||
1251
+ reqPath === "/.well-known/oauth-protected-resource/mcp" ||
1252
+ reqPath === "/.well-known/oauth-protected-resource/sse") {
1253
+ const base = oauthBase();
1254
+ res.writeHead(200, { "Content-Type": "application/json", ...CORS });
1255
+ return res.end(JSON.stringify({
1256
+ resource: `${base}/mcp`,
1257
+ authorization_servers: [base],
1258
+ scopes_supported: ["mcp:tools"],
1259
+ bearer_methods_supported: ["header"],
1260
+ }));
1261
+ }
1262
+
1263
+ if (reqPath === "/.well-known/oauth-authorization-server") {
1264
+ const base = oauthBase();
1265
+ res.writeHead(200, { "Content-Type": "application/json", ...CORS });
1266
+ return res.end(JSON.stringify({
1267
+ issuer: base,
1268
+ authorization_endpoint: `${base}/authorize`,
1269
+ token_endpoint: `${base}/token`,
1270
+ registration_endpoint: `${base}/register`,
1271
+ response_types_supported: ["code"],
1272
+ grant_types_supported: ["authorization_code"],
1273
+ code_challenge_methods_supported: ["S256"],
1274
+ scopes_supported: ["mcp:tools"],
1275
+ }));
1276
+ }
1277
+
1278
+ // ── Dynamic Client Registration (RFC 7591) ──────────────
1279
+ if (method === "POST" && reqPath === "/register") {
1280
+ let body;
1281
+ try { body = await readBody(req); } catch {
1282
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
1283
+ return res.end(JSON.stringify({ error: "invalid_request" }));
1284
+ }
1285
+ const clientId = crypto.randomBytes(16).toString("hex");
1286
+ const clientSecret = crypto.randomBytes(32).toString("hex");
1287
+ const client = {
1288
+ client_id: clientId, client_secret: clientSecret,
1289
+ client_name: body.client_name || "unknown",
1290
+ redirect_uris: body.redirect_uris || [],
1291
+ grant_types: body.grant_types || ["authorization_code"],
1292
+ response_types: body.response_types || ["code"],
1293
+ token_endpoint_auth_method: body.token_endpoint_auth_method || "client_secret_post",
1294
+ };
1295
+ oauthClients.set(clientId, client);
1296
+ const logMsg = `[${new Date().toISOString()}] OAuth: registered client ${clientId} (${client.client_name})\n`;
1297
+ try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
1298
+ res.writeHead(201, { "Content-Type": "application/json", ...CORS });
1299
+ return res.end(JSON.stringify(client));
1300
+ }
1301
+
1302
+ // ── Authorization endpoint — auto-approve ──────────────
1303
+ if (method === "GET" && reqPath === "/authorize") {
1304
+ const clientId = url.searchParams.get("client_id");
1305
+ const redirectUri = url.searchParams.get("redirect_uri");
1306
+ const state = url.searchParams.get("state");
1307
+ const codeChallenge = url.searchParams.get("code_challenge");
1308
+ const codeChallengeMethod = url.searchParams.get("code_challenge_method");
1309
+
1310
+ if (!clientId || !redirectUri) {
1311
+ res.writeHead(400, { "Content-Type": "text/plain", ...CORS });
1312
+ return res.end("Missing client_id or redirect_uri");
1313
+ }
1314
+
1315
+ const code = crypto.randomBytes(32).toString("hex");
1316
+ oauthCodes.set(code, {
1317
+ client_id: clientId, redirect_uri: redirectUri,
1318
+ code_challenge: codeChallenge, code_challenge_method: codeChallengeMethod,
1319
+ expires: Date.now() + 300_000,
1320
+ });
1321
+
1322
+ const redirect = new URL(redirectUri);
1323
+ redirect.searchParams.set("code", code);
1324
+ if (state) redirect.searchParams.set("state", state);
1325
+
1326
+ const logMsg = `[${new Date().toISOString()}] OAuth: authorize → code issued for ${clientId}, redirecting to ${redirect.origin}\n`;
1327
+ try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
1328
+ res.writeHead(302, { Location: redirect.toString(), ...CORS });
1329
+ return res.end();
1330
+ }
1331
+
1332
+ // ── Token endpoint ──────────────────────────────────────
1333
+ if (method === "POST" && reqPath === "/token") {
1334
+ let body;
1335
+ const ct = req.headers["content-type"] || "";
1336
+ try {
1337
+ if (ct.includes("application/json")) {
1338
+ body = await readBody(req);
1339
+ } else {
1340
+ const raw = await readRawBody(req);
1341
+ body = Object.fromEntries(new URLSearchParams(raw));
1342
+ }
1343
+ } catch {
1344
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
1345
+ return res.end(JSON.stringify({ error: "invalid_request" }));
1346
+ }
1347
+
1348
+ if (body.grant_type !== "authorization_code") {
1349
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
1350
+ return res.end(JSON.stringify({ error: "unsupported_grant_type" }));
1351
+ }
1352
+
1353
+ const stored = oauthCodes.get(body.code);
1354
+ if (!stored || stored.expires < Date.now()) {
1355
+ oauthCodes.delete(body.code);
1356
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
1357
+ return res.end(JSON.stringify({ error: "invalid_grant" }));
1358
+ }
1359
+
1360
+ // PKCE verification
1361
+ if (stored.code_challenge && body.code_verifier) {
1362
+ const computed = sha256base64url(body.code_verifier);
1363
+ if (computed !== stored.code_challenge) {
1364
+ oauthCodes.delete(body.code);
1365
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
1366
+ return res.end(JSON.stringify({ error: "invalid_grant", error_description: "PKCE verification failed" }));
1367
+ }
1368
+ }
1369
+
1370
+ oauthCodes.delete(body.code);
1371
+ const accessToken = crypto.randomBytes(32).toString("hex");
1372
+ oauthTokens.add(accessToken);
1373
+
1374
+ const logMsg = `[${new Date().toISOString()}] OAuth: token issued for client ${stored.client_id}\n`;
1375
+ try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
1376
+ res.writeHead(200, { "Content-Type": "application/json", ...CORS });
1377
+ return res.end(JSON.stringify({ access_token: accessToken, token_type: "Bearer", expires_in: 86400 }));
1378
+ }
1379
+
1380
+ // ── MCP OAuth-protected endpoint (for claude.ai web) ──
1381
+ // POST /mcp — requires Bearer token; returns 401 to trigger OAuth flow
1382
+ if (method === "POST" && reqPath === "/mcp") {
1383
+ const authHeader = req.headers.authorization;
1384
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
1385
+ const base = oauthBase();
1386
+ res.writeHead(401, {
1387
+ "Content-Type": "application/json",
1388
+ "WWW-Authenticate": `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource"`,
1389
+ ...CORS,
1390
+ });
1391
+ return res.end(JSON.stringify({ error: "unauthorized" }));
1392
+ }
1393
+ const token = authHeader.slice(7);
1394
+ if (!oauthTokens.has(token)) {
1395
+ res.writeHead(401, { "Content-Type": "application/json", ...CORS });
1396
+ return res.end(JSON.stringify({ error: "invalid_token" }));
1397
+ }
1398
+ // Token valid — mark as remote caller (claude.ai via tunnel)
1399
+ req._clauthRemote = true;
1400
+ // fall through to MCP handling below
1401
+ }
1402
+
1403
+ // ── MCP Streamable HTTP transport (2025-03-26 spec) ──
1404
+ // POST /sse or POST /mcp — JSON-RPC over HTTP
1405
+ if (method === "POST" && (reqPath === "/sse" || reqPath === "/mcp")) {
1406
+ let body;
1407
+ try { body = await readBody(req); } catch {
1408
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
1409
+ return res.end(JSON.stringify({ error: "Invalid JSON" }));
1410
+ }
1411
+
1412
+ const id = body.id;
1413
+ const rpcMethod = body.method;
1414
+ const logMsg = `[${new Date().toISOString()}] Streamable HTTP: ${rpcMethod} id=${id}\n`;
1415
+ try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
1416
+
1417
+ // Notifications — no response needed
1418
+ if (rpcMethod === "notifications/initialized" || rpcMethod === "initialized") {
1419
+ res.writeHead(204, CORS);
1420
+ return res.end();
1421
+ }
1025
1422
 
1026
- // ── MCP SSE transport ─────────────────────────────────
1423
+ if (rpcMethod === "initialize") {
1424
+ const result = {
1425
+ protocolVersion: "2025-03-26",
1426
+ serverInfo: { name: "clauth", version: VERSION },
1427
+ capabilities: { tools: {} }
1428
+ };
1429
+ res.writeHead(200, { "Content-Type": "application/json", ...CORS });
1430
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, result }));
1431
+ }
1432
+
1433
+ if (rpcMethod === "tools/list") {
1434
+ res.writeHead(200, { "Content-Type": "application/json", ...CORS });
1435
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, result: { tools: MCP_TOOLS } }));
1436
+ }
1437
+
1438
+ if (rpcMethod === "tools/call") {
1439
+ const { name, arguments: args } = body.params || {};
1440
+ const vault = sseVault();
1441
+ vault.remote = !!req._clauthRemote;
1442
+ try {
1443
+ const result = await handleMcpTool(vault, name, args || {});
1444
+ password = vault.password;
1445
+ res.writeHead(200, { "Content-Type": "application/json", ...CORS });
1446
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, result }));
1447
+ } catch (err) {
1448
+ res.writeHead(200, { "Content-Type": "application/json", ...CORS });
1449
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, result: mcpError(`Internal error: ${err.message}`) }));
1450
+ }
1451
+ }
1452
+
1453
+ // Unknown method
1454
+ res.writeHead(200, { "Content-Type": "application/json", ...CORS });
1455
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32601, message: `Unknown method: ${rpcMethod}` } }));
1456
+ }
1457
+
1458
+ // ── MCP SSE transport (legacy) ───────────────────────
1027
1459
  // GET /sse — open SSE stream, receive endpoint event
1028
1460
  if (method === "GET" && reqPath === "/sse") {
1029
1461
  const sessionId = `ses_${++sseCounter}_${Date.now()}`;
@@ -1038,7 +1470,9 @@ function createServer(initPassword, whitelist, port) {
1038
1470
  sseSessions.set(sessionId, { res, initialized: false });
1039
1471
 
1040
1472
  // Send the endpoint URI the client should POST to
1041
- const endpoint = `/message?sessionId=${sessionId}`;
1473
+ // Use absolute URL when tunnel is active so remote clients can resolve it
1474
+ const basePath = tunnelUrl || `http://127.0.0.1:${port}`;
1475
+ const endpoint = `${basePath}/message?sessionId=${sessionId}`;
1042
1476
  res.write(`event: endpoint\ndata: ${endpoint}\n\n`);
1043
1477
 
1044
1478
  // Keepalive every 15s
@@ -1162,6 +1596,15 @@ function createServer(initPassword, whitelist, port) {
1162
1596
  });
1163
1597
  }
1164
1598
 
1599
+ // GET /mcp-setup — OAuth credentials for claude.ai MCP setup (localhost only)
1600
+ if (method === "GET" && reqPath === "/mcp-setup") {
1601
+ return ok(res, {
1602
+ url: tunnelUrl && tunnelUrl.startsWith("http") ? `${tunnelUrl}/mcp` : null,
1603
+ clientId: OAUTH_CLIENT_ID,
1604
+ clientSecret: OAUTH_CLIENT_SECRET,
1605
+ });
1606
+ }
1607
+
1165
1608
  // POST /tunnel — start or stop tunnel manually
1166
1609
  if (method === "POST" && reqPath === "/tunnel") {
1167
1610
  if (lockedGuard(res)) return;
@@ -1198,6 +1641,31 @@ function createServer(initPassword, whitelist, port) {
1198
1641
  return false;
1199
1642
  }
1200
1643
 
1644
+ // GET /meta — return valid key_types from DB check constraint
1645
+ if (method === "GET" && reqPath === "/meta") {
1646
+ if (lockedGuard(res)) return;
1647
+ try {
1648
+ const baseUrl = api.getBaseUrl().replace("/functions/v1/auth-vault", "");
1649
+ const anonKey = api.getAnonKey();
1650
+ const sql = `SELECT check_clause FROM information_schema.check_constraints WHERE constraint_name = 'clauth_services_key_type_check'`;
1651
+ const r = await fetch(`${baseUrl}/rest/v1/rpc/`, {
1652
+ method: "POST",
1653
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${anonKey}`, "apikey": anonKey }
1654
+ }).catch(() => null);
1655
+ // Fallback: parse from a direct SQL query via postgrest isn't possible without an RPC,
1656
+ // so we query the status endpoint which returns services with their key_types
1657
+ const { token, timestamp } = deriveToken(password, machineHash);
1658
+ const statusResult = await api.status(password, machineHash, token, timestamp);
1659
+ const existingTypes = [...new Set((statusResult.services || []).map(s => s.key_type).filter(Boolean))];
1660
+ // Merge with known types (in case no service of that type exists yet)
1661
+ const knownTypes = ["token", "secret", "keypair", "connstring", "oauth"];
1662
+ const allTypes = [...new Set([...knownTypes, ...existingTypes])];
1663
+ return ok(res, { key_types: allTypes });
1664
+ } catch (err) {
1665
+ return ok(res, { key_types: ["token", "secret", "keypair", "connstring", "oauth"] });
1666
+ }
1667
+ }
1668
+
1201
1669
  // GET /status
1202
1670
  if (method === "GET" && reqPath === "/status") {
1203
1671
  if (lockedGuard(res)) return;
@@ -1446,10 +1914,53 @@ function createServer(initPassword, whitelist, port) {
1446
1914
  }
1447
1915
  }
1448
1916
 
1449
- // Unknown route
1917
+ // POST /add-service — register a new service in the vault
1918
+ if (method === "POST" && reqPath === "/add-service") {
1919
+ if (lockedGuard(res)) return;
1920
+
1921
+ let body;
1922
+ try { body = await readBody(req); } catch {
1923
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
1924
+ return res.end(JSON.stringify({ error: "Invalid JSON body" }));
1925
+ }
1926
+
1927
+ const { name, label, key_type, description } = body;
1928
+ if (!name || typeof name !== "string" || !name.trim()) {
1929
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
1930
+ return res.end(JSON.stringify({ error: "name is required" }));
1931
+ }
1932
+ const validTypes = ["token", "keypair", "connstring", "oauth", "secret"];
1933
+ const type = (key_type || "token").toLowerCase();
1934
+ if (!validTypes.includes(type)) {
1935
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
1936
+ return res.end(JSON.stringify({ error: `key_type must be one of: ${validTypes.join(", ")}` }));
1937
+ }
1938
+
1939
+ try {
1940
+ const { token, timestamp } = deriveToken(password, machineHash);
1941
+ const result = await api.addService(password, machineHash, token, timestamp, name.trim().toLowerCase(), label || name.trim(), type, description || "");
1942
+ if (result.error) return strike(res, 502, result.error);
1943
+ return ok(res, { ok: true, service: name.trim().toLowerCase() });
1944
+ } catch (err) {
1945
+ return strike(res, 502, err.message);
1946
+ }
1947
+ }
1948
+
1949
+ // Unknown route — don't count browser/MCP noise as auth failures
1950
+ // Don't count browser noise, MCP discovery probes, or OAuth probes as auth failures
1951
+ const isBenign = reqPath.startsWith("/.well-known/") || [
1952
+ "/favicon.ico", "/robots.txt", "/apple-touch-icon.png", "/apple-touch-icon-precomposed.png",
1953
+ "/sse", "/mcp", "/message", "/register", "/authorize", "/token",
1954
+ ].includes(reqPath);
1955
+ if (isBenign) {
1956
+ res.writeHead(404, { "Content-Type": "application/json", ...CORS });
1957
+ return res.end(JSON.stringify({ error: "Not found" }));
1958
+ }
1450
1959
  return strike(res, 404, `Unknown endpoint: ${reqPath}`);
1451
1960
  });
1452
1961
 
1962
+ server.__oauthClientId = OAUTH_CLIENT_ID;
1963
+ server.__oauthClientSecret = OAUTH_CLIENT_SECRET;
1453
1964
  return server;
1454
1965
  }
1455
1966
 
@@ -1465,6 +1976,7 @@ async function verifyAuth(password) {
1465
1976
  async function actionStart(opts) {
1466
1977
  const port = parseInt(opts.port || "52437", 10);
1467
1978
  const password = opts.pw;
1979
+ const tunnelHostname = opts.tunnel || null;
1468
1980
  const whitelist = opts.services
1469
1981
  ? opts.services.split(",").map(s => s.trim().toLowerCase())
1470
1982
  : null;
@@ -1490,7 +2002,7 @@ async function actionStart(opts) {
1490
2002
  }
1491
2003
  }
1492
2004
 
1493
- const server = createServer(password, whitelist, port);
2005
+ const server = createServer(password, whitelist, port, tunnelHostname);
1494
2006
  server.listen(port, "127.0.0.1", () => {
1495
2007
  writePid(process.pid, port);
1496
2008
  const msg = `[${new Date().toISOString()}] clauth serve started — PID ${process.pid}, port ${port}, services: ${whitelist ? whitelist.join(",") : "all"}\n`;
@@ -1534,6 +2046,7 @@ async function actionStart(opts) {
1534
2046
  const childArgs = [cliEntry, "serve", "start", "--port", String(port)];
1535
2047
  if (password) childArgs.push("--pw", password);
1536
2048
  if (opts.services) childArgs.push("--services", opts.services);
2049
+ if (tunnelHostname) childArgs.push("--tunnel", tunnelHostname);
1537
2050
 
1538
2051
  const out = fs.openSync(LOG_FILE, "a");
1539
2052
  const child = spawn(process.execPath, childArgs, {
@@ -1650,6 +2163,7 @@ async function actionRestart(opts) {
1650
2163
  async function actionForeground(opts) {
1651
2164
  const port = parseInt(opts.port || "52437", 10);
1652
2165
  const password = opts.pw || null;
2166
+ const tunnelHostname = opts.tunnel || null;
1653
2167
  const whitelist = opts.services
1654
2168
  ? opts.services.split(",").map(s => s.trim().toLowerCase())
1655
2169
  : null;
@@ -1671,10 +2185,19 @@ async function actionForeground(opts) {
1671
2185
  console.log(chalk.gray(` Services: ${whitelist ? whitelist.join(", ") : "all"}`));
1672
2186
  console.log(chalk.gray(` Lockout: 3 failures → exit\n`));
1673
2187
 
1674
- const server = createServer(password, whitelist, port);
2188
+ const server = createServer(password, whitelist, port, tunnelHostname);
1675
2189
  server.listen(port, "127.0.0.1", () => {
1676
2190
  writePid(process.pid, port);
1677
2191
  console.log(chalk.green(` clauth serve → http://127.0.0.1:${port}`));
2192
+ if (tunnelHostname) {
2193
+ console.log(chalk.cyan(` Tunnel: https://${tunnelHostname}/sse`));
2194
+ console.log("");
2195
+ console.log(chalk.yellow(" ── claude.ai Custom Connector ──"));
2196
+ console.log(chalk.white(` URL: https://${tunnelHostname}/mcp`));
2197
+ console.log(chalk.white(` Client ID: ${server.__oauthClientId}`));
2198
+ console.log(chalk.white(` Client Secret: ${server.__oauthClientSecret}`));
2199
+ console.log(chalk.gray(" (paste these into Advanced Settings when adding the connector)"));
2200
+ }
1678
2201
  if (!password) console.log(chalk.cyan(` 👉 Open http://127.0.0.1:${port} to unlock`));
1679
2202
  console.log(chalk.gray(" Ctrl+C to stop\n"));
1680
2203
  // Auto-open browser
@@ -1926,7 +2449,14 @@ async function handleMcpTool(vault, name, args) {
1926
2449
  };
1927
2450
  }
1928
2451
 
1929
- // Default: file
2452
+ // Remote caller (claude.ai via tunnel) — can't read local temp files
2453
+ // Return value inline in MCP response (safe: stays in AI context, not a shell transcript)
2454
+ if (vault.remote) {
2455
+ const envVar = ENV_MAP[service] || service.toUpperCase().replace(/-/g, "_");
2456
+ return mcpResult(`${envVar}=${value}`);
2457
+ }
2458
+
2459
+ // Default: file (local callers)
1930
2460
  const envVar = ENV_MAP[service] || service.toUpperCase().replace(/-/g, "_");
1931
2461
  const filePath = writeTempSecret(service, value);
1932
2462
  return mcpResult(`${service} → ${filePath} (auto-deletes in 30s)\nEnv var: ${envVar}\nUsage: export ${envVar}=$(cat ${filePath.replace(/\\/g, "/")})`);
@@ -1962,6 +2492,14 @@ async function handleMcpTool(vault, name, args) {
1962
2492
 
1963
2493
  if (!lines.length) return mcpError(`No services retrieved:\n${errors.join("\n")}`);
1964
2494
 
2495
+ // Remote caller — return env vars inline
2496
+ if (vault.remote) {
2497
+ let msg = lines.join("\n");
2498
+ if (errors.length) msg += `\n\nErrors:\n${errors.join("\n")}`;
2499
+ return mcpResult(msg);
2500
+ }
2501
+
2502
+ // Local caller — write temp env file
1965
2503
  const envFilePath = path.join(os.tmpdir(), ".clauth-env");
1966
2504
  fs.writeFileSync(envFilePath, lines.join("\n") + "\n", { mode: 0o600 });
1967
2505
  setTimeout(() => { try { fs.unlinkSync(envFilePath); } catch {} }, 30_000);
@@ -2048,7 +2586,7 @@ function createMcpServer(initPassword, whitelist) {
2048
2586
  get machineHash() { return ensureMachineHash(); },
2049
2587
  whitelist,
2050
2588
  failCount: 0,
2051
- MAX_FAILS: 3,
2589
+ MAX_FAILS: 10,
2052
2590
  };
2053
2591
 
2054
2592
  const rl = createInterface({ input: process.stdin, terminal: false });
@@ -2137,6 +2675,98 @@ async function actionMcp(opts) {
2137
2675
  createMcpServer(password, whitelist);
2138
2676
  }
2139
2677
 
2678
+ // ── DPAPI auto-start install / uninstall (Windows only) ──────
2679
+ const AUTOSTART_DIR = path.join(os.homedir(), "AppData", "Roaming", "clauth");
2680
+ const BOOT_KEY_PATH = path.join(AUTOSTART_DIR, "boot.key");
2681
+ const PS_SCRIPT_PATH = path.join(AUTOSTART_DIR, "autostart.ps1");
2682
+ const TASK_NAME = "ClauthAutostart";
2683
+
2684
+ async function actionInstall(opts) {
2685
+ if (os.platform() !== "win32") {
2686
+ console.log(chalk.red("\n serve install is only supported on Windows\n"));
2687
+ process.exit(1);
2688
+ }
2689
+
2690
+ const { default: inquirer } = await import("inquirer");
2691
+ const { pw } = await inquirer.prompt([{
2692
+ type: "password", name: "pw",
2693
+ message: "Enter clauth password to store for auto-start:", mask: "*"
2694
+ }]);
2695
+
2696
+ fs.mkdirSync(AUTOSTART_DIR, { recursive: true });
2697
+
2698
+ // Encrypt password with Windows DPAPI (CurrentUser scope — machine+user bound)
2699
+ const spinner = ora("Encrypting password with Windows DPAPI...").start();
2700
+ let encrypted;
2701
+ try {
2702
+ const { execSync } = await import("child_process");
2703
+ const pwEscaped = pw.replace(/'/g, "''");
2704
+ const psExpr = `[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes('${pwEscaped}'),$null,'CurrentUser'))`;
2705
+ encrypted = execSync(`powershell -NoProfile -Command "${psExpr}"`, { encoding: "utf8" }).trim();
2706
+ fs.writeFileSync(BOOT_KEY_PATH, encrypted, "utf8");
2707
+ spinner.succeed(chalk.green("Password encrypted → boot.key"));
2708
+ } catch (err) {
2709
+ spinner.fail(chalk.red(`DPAPI encryption failed: ${err.message}`));
2710
+ process.exit(1);
2711
+ }
2712
+
2713
+ // Write PowerShell autostart script — decrypts boot.key and pipes to clauth serve start
2714
+ const cliEntry = path.resolve(__dirname, "../index.js").replace(/\\/g, "\\\\");
2715
+ const nodeExe = process.execPath.replace(/\\/g, "\\\\");
2716
+ const bootKey = BOOT_KEY_PATH.replace(/\\/g, "\\\\");
2717
+ const psScript = [
2718
+ "# clauth autostart — generated by clauth serve install",
2719
+ `$enc = (Get-Content '${bootKey}' -Raw).Trim()`,
2720
+ `$pw = [Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($enc),$null,'CurrentUser'))`,
2721
+ `Start-Process '${nodeExe}' -ArgumentList "'${cliEntry}' serve start -p $pw" -WindowStyle Hidden`,
2722
+ ].join("\n");
2723
+ fs.writeFileSync(PS_SCRIPT_PATH, psScript, "utf8");
2724
+
2725
+ // Register Windows Scheduled Task — triggers on user logon
2726
+ const spinner2 = ora("Registering Windows Scheduled Task...").start();
2727
+ try {
2728
+ const { execSync } = await import("child_process");
2729
+ const psScriptEsc = PS_SCRIPT_PATH.replace(/\\/g, "\\\\");
2730
+ const args = `-NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${psScriptEsc}"`;
2731
+ execSync(
2732
+ `schtasks /create /f /tn "${TASK_NAME}" /sc onlogon /tr "powershell.exe ${args}"`,
2733
+ { encoding: "utf8", stdio: "pipe" }
2734
+ );
2735
+ spinner2.succeed(chalk.green(`Scheduled Task "${TASK_NAME}" registered`));
2736
+ } catch (err) {
2737
+ spinner2.fail(chalk.yellow(`Scheduled task failed (non-fatal): ${err.message}`));
2738
+ console.log(chalk.gray(" You can still start manually: clauth serve start"));
2739
+ }
2740
+
2741
+ console.log(chalk.cyan("\n Auto-start installed:\n"));
2742
+ console.log(chalk.gray(` boot.key: ${BOOT_KEY_PATH}`));
2743
+ console.log(chalk.gray(` script: ${PS_SCRIPT_PATH}`));
2744
+ console.log(chalk.gray(` task: ${TASK_NAME}\n`));
2745
+ console.log(chalk.green(" Daemon will auto-start on next Windows login.\n"));
2746
+ }
2747
+
2748
+ async function actionUninstall() {
2749
+ if (os.platform() !== "win32") {
2750
+ console.log(chalk.red("\n serve uninstall is only supported on Windows\n"));
2751
+ process.exit(1);
2752
+ }
2753
+ const { execSync } = await import("child_process");
2754
+
2755
+ // Remove scheduled task
2756
+ try {
2757
+ execSync(`schtasks /delete /f /tn "${TASK_NAME}"`, { encoding: "utf8", stdio: "pipe" });
2758
+ console.log(chalk.green(` Removed Scheduled Task: ${TASK_NAME}`));
2759
+ } catch { console.log(chalk.gray(` Task not found (already removed): ${TASK_NAME}`)); }
2760
+
2761
+ // Remove boot.key and autostart script
2762
+ for (const f of [BOOT_KEY_PATH, PS_SCRIPT_PATH]) {
2763
+ try { fs.unlinkSync(f); console.log(chalk.green(` Deleted: ${f}`)); }
2764
+ catch { console.log(chalk.gray(` Not found (skipped): ${f}`)); }
2765
+ }
2766
+
2767
+ console.log(chalk.cyan("\n Auto-start uninstalled.\n"));
2768
+ }
2769
+
2140
2770
  // ── Export ────────────────────────────────────────────────────
2141
2771
  export async function runServe(opts) {
2142
2772
  const action = opts.action || "foreground";
@@ -2148,9 +2778,11 @@ export async function runServe(opts) {
2148
2778
  case "ping": return actionPing();
2149
2779
  case "foreground": return actionForeground(opts);
2150
2780
  case "mcp": return actionMcp(opts);
2781
+ case "install": return actionInstall(opts);
2782
+ case "uninstall": return actionUninstall();
2151
2783
  default:
2152
2784
  console.log(chalk.red(`\n Unknown serve action: ${action}`));
2153
- console.log(chalk.gray(" Actions: start | stop | restart | ping | foreground | mcp\n"));
2785
+ console.log(chalk.gray(" Actions: start | stop | restart | ping | foreground | mcp | install | uninstall\n"));
2154
2786
  process.exit(1);
2155
2787
  }
2156
2788
  }
@@ -15,16 +15,18 @@ function getMachineId() {
15
15
 
16
16
  try {
17
17
  if (platform === "win32") {
18
- // Primary: BIOS UUID via WMIC
19
- const uuid = execSync("wmic csproduct get uuid /format:value", {
20
- encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"]
21
- }).match(/UUID=([A-F0-9-]+)/i)?.[1]?.trim();
18
+ // Primary: BIOS UUID via PowerShell (wmic removed in Win11 26xxx+)
19
+ const psPath = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
20
+ const uuid = execSync(
21
+ `${psPath} -NoProfile -Command "(Get-CimInstance Win32_ComputerSystemProduct).UUID"`,
22
+ { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }
23
+ ).trim();
22
24
 
23
- // Secondary: Windows MachineGuid from registry
25
+ // Secondary: Windows MachineGuid from registry (via PowerShell for Win11 compat)
24
26
  const machineGuid = execSync(
25
- "reg query HKLM\\SOFTWARE\\Microsoft\\Cryptography /v MachineGuid",
27
+ `${psPath} -NoProfile -Command "(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Cryptography').MachineGuid"`,
26
28
  { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }
27
- ).match(/MachineGuid\s+REG_SZ\s+([a-f0-9-]+)/i)?.[1]?.trim();
29
+ ).trim();
28
30
 
29
31
  if (!uuid || !machineGuid) throw new Error("Could not read Windows machine IDs");
30
32
  return { primary: uuid, secondary: machineGuid, platform: "win32" };
package/cli/index.js CHANGED
@@ -12,7 +12,7 @@ import * as api from "./api.js";
12
12
  import os from "os";
13
13
 
14
14
  const config = new Conf(getConfOptions());
15
- const VERSION = "0.5.2";
15
+ const VERSION = "0.7.0";
16
16
 
17
17
  // ============================================================
18
18
  // Password prompt helper
@@ -464,10 +464,11 @@ program.addHelpText("beforeAll", chalk.cyan(`
464
464
  // ──────────────────────────────────────────────
465
465
  program
466
466
  .command("serve [action]")
467
- .description("Manage localhost HTTP vault daemon (start|stop|restart|ping)")
467
+ .description("Manage localhost HTTP vault daemon (start|stop|restart|ping|install|uninstall)")
468
468
  .option("--port <n>", "Port (default: 52437)")
469
469
  .option("-p, --pw <password>", "clauth password (optional — omit to start locked, unlock in browser)")
470
470
  .option("--services <list>", "Comma-separated service whitelist (default: all)")
471
+ .option("--tunnel <hostname>", "Fixed tunnel hostname (e.g. clauth.prtrust.fund) — uses named Cloudflare Tunnel instead of random URL")
471
472
  .option("--action <action>", "Internal: action override for daemon child")
472
473
  .addHelpText("after", `
473
474
  Actions:
@@ -477,6 +478,8 @@ Actions:
477
478
  ping Check if the daemon is running
478
479
  foreground Run in foreground (Ctrl+C to stop) — default if no action given
479
480
  mcp Run as MCP stdio server for Claude Code (JSON-RPC over stdin/stdout)
481
+ install (Windows) Encrypt password with DPAPI + create Scheduled Task for auto-start on login
482
+ uninstall (Windows) Remove Scheduled Task + delete boot.key
480
483
 
481
484
  MCP SSE (built into start/foreground):
482
485
  The HTTP daemon also serves MCP SSE transport at GET /sse + POST /message.
@@ -491,6 +494,8 @@ Examples:
491
494
  clauth serve start --services github,vercel
492
495
  clauth serve mcp Start MCP server for Claude Code
493
496
  clauth serve mcp -p mypass Start MCP server pre-unlocked
497
+ clauth serve install (Windows) Set up auto-start on login via DPAPI
498
+ clauth serve uninstall (Windows) Remove auto-start
494
499
  `)
495
500
  .action(async (action, opts) => {
496
501
  const resolvedAction = opts.action || action || "foreground";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/clauth",
3
- "version": "0.5.2",
3
+ "version": "0.7.3",
4
4
  "description": "Hardware-bound credential vault for the LIFEAI infrastructure stack",
5
5
  "type": "module",
6
6
  "bin": {