@lifeaitools/clauth 2.1.1 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.clauth-skill/references/keys-guide.md +270 -270
- package/cli/api.js +238 -238
- package/cli/commands/install.js +413 -396
- package/cli/commands/login.test.js +49 -1
- package/cli/commands/ops-install.js +11 -10
- package/cli/commands/ops.test.js +270 -0
- package/cli/commands/scrub.test.js +70 -0
- package/cli/commands/serve.js +2613 -11219
- package/cli/commands/uninstall.js +164 -164
- package/cli/dashboard/dashboard.js +592 -0
- package/cli/dashboard/global.css +65 -0
- package/cli/dashboard/panel-element.js +79 -0
- package/cli/dashboard/panels/build-status-panel.css +12 -0
- package/cli/dashboard/panels/build-status-panel.js +98 -0
- package/cli/dashboard/panels/panel-registry.js +58 -0
- package/cli/dashboard/panels/services-panel.css +95 -0
- package/cli/dashboard/panels/services-panel.js +872 -0
- package/cli/dashboard/panels/surfaces-panel.css +59 -0
- package/cli/dashboard/panels/surfaces-panel.js +474 -0
- package/cli/dashboard/panels/toolbar-panel.css +42 -0
- package/cli/dashboard/panels/toolbar-panel.js +374 -0
- package/cli/dashboard/panels/tunnel-panel.css +61 -0
- package/cli/dashboard/panels/tunnel-panel.js +688 -0
- package/cli/dashboard/panels/webdav-panel.css +38 -0
- package/cli/dashboard/panels/webdav-panel.js +166 -0
- package/cli/fingerprint.test.js +95 -0
- package/cli/http/components/callagent-terminal-component.js +310 -0
- package/cli/http/components/dashboard-component.js +123 -0
- package/cli/http/components/inbox-component.js +41 -0
- package/cli/http/components/mcp-transport-component.js +275 -0
- package/cli/http/components/oauth-component.js +207 -0
- package/cli/http/components/service-crud-component.js +288 -0
- package/cli/http/components/supervisor-component.js +104 -0
- package/cli/http/components/system-component.js +184 -0
- package/cli/http/components/tunnel-component.js +508 -0
- package/cli/http/components/vault-auth-component.js +264 -0
- package/cli/http/components/watchdog-component.js +28 -0
- package/cli/http/request-utils.js +21 -0
- package/cli/http/servers/ops-server.js +335 -0
- package/cli/index.js +5 -91
- package/cli/lib/fs-git.test.js +467 -0
- package/cli/mcp/providers/call-agent-provider.js +30 -0
- package/cli/mcp/providers/clauth-core-provider.js +461 -0
- package/cli/mcp/providers/ops-provider.js +42 -0
- package/cli/mcp/providers/terminal-provider.js +81 -0
- package/cli/mcp/tool-registry.js +48 -0
- package/cli/mcp/tool-schemas.js +822 -0
- package/cli/mcp/tool-schemas.test.js +49 -0
- package/cli/ops/pm2-preflight.js +54 -0
- package/cli/recovery.js +6 -0
- package/cli/recovery.test.js +179 -0
- package/cli/services/agent-pool-instance.js +38 -0
- package/cli/services/claude-binary.js +29 -0
- package/cli/services/delegation-lane-instance.js +35 -0
- package/cli/services/delegation-lane-instance.test.js +37 -0
- package/cli/services/file-io.js +38 -0
- package/cli/services/file-io.test.js +10 -0
- package/cli/services/fs-mount.js +66 -0
- package/cli/services/fs-mount.test.js +14 -0
- package/cli/services/fs-upload-sessions.js +20 -0
- package/cli/services/fs-upload-sessions.test.js +14 -0
- package/cli/services/git-exec.js +37 -0
- package/cli/services/git-exec.test.js +41 -0
- package/cli/services/git-import-guard.js +28 -0
- package/cli/services/git-import-guard.test.js +18 -0
- package/cli/services/service-status.js +191 -0
- package/cli/services/service-status.test.js +39 -0
- package/cli/services/terminal-dispatch.js +135 -0
- package/cli/services/terminal-dispatch.test.js +9 -0
- package/cli/services/terminal-registry.js +39 -0
- package/cli/services/terminal-registry.test.js +31 -0
- package/cli/services/terminal-sessions.js +100 -0
- package/cli/services/terminal-sessions.test.js +26 -0
- package/cli/services/terminal-window.js +106 -0
- package/cli/services/vault-read.js +14 -0
- package/cli/services/vault-read.test.js +35 -0
- package/cli/supervisor-registry.js +116 -0
- package/cli/supervisor-registry.test.js +165 -0
- package/cli/supervisor-ui.test.js +230 -30
- package/cli/webdav-config.js +84 -0
- package/cli/webdav-config.test.js +79 -0
- package/cli/webdav-obscure.js +23 -0
- package/cli/webdav-obscure.test.js +41 -0
- package/cli/webdav-rclone-discovery.js +44 -0
- package/cli/webdav-service.js +30 -118
- package/cli/webdav-service.test.js +107 -0
- package/install.ps1 +102 -102
- package/install.sh +49 -49
- package/package.json +2 -2
- package/scripts/bootstrap.cjs +121 -121
- package/supabase/functions/auth-vault/index.ts +350 -350
- package/supabase/migrations/001_clauth_schema.sql +94 -94
- package/supabase/migrations/002_vault_helpers.sql +90 -90
- package/supabase/migrations/20260317_lockout.sql +26 -26
- package/cli/assets/codevelop/launcher-active.cmd.template +0 -20
- package/cli/assets/codevelop/launcher-static.cmd.template +0 -7
- package/cli/assets/codevelop/windows-terminal.profiles.json +0 -48
- package/cli/commands/codevelop.js +0 -1190
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// cli/http/components/dashboard-component.js
|
|
2
|
+
// The Dashboard (Site/Website) component: GET / -- the built-in web
|
|
3
|
+
// dashboard's HTML shell -- plus POST /message, the legacy SSE-transport
|
|
4
|
+
// JSON-RPC endpoint a GET /sse session's client POSTs its calls to (the
|
|
5
|
+
// Streamable-HTTP transport's own POST handling lives in the MCP-transport
|
|
6
|
+
// component; this is only the older two-leg SSE-session sibling).
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import crypto from "node:crypto";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { readBody } from "../request-utils.js";
|
|
12
|
+
import { MCP_TOOLS, filterMcpToolsForWriteMode } from "../../mcp/tool-schemas.js";
|
|
13
|
+
import { mcpError } from "../../mcp/tool-registry.js";
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../package.json"), "utf8"));
|
|
17
|
+
const VERSION = pkg.version;
|
|
18
|
+
|
|
19
|
+
const WRITE_TOKEN_BYTES = 32;
|
|
20
|
+
const WRITE_TOKEN_TTL_MS = 10 * 60 * 1000;
|
|
21
|
+
function makeWriteToken() {
|
|
22
|
+
return {
|
|
23
|
+
token: crypto.randomBytes(WRITE_TOKEN_BYTES).toString("base64url"),
|
|
24
|
+
expiresAt: Date.now() + WRITE_TOKEN_TTL_MS,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sseSend(sessionRes, event, data) {
|
|
29
|
+
sessionRes.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function registerDashboardRoutes(ctx) {
|
|
33
|
+
// POST /message?sessionId=xxx — JSON-RPC over SSE
|
|
34
|
+
ctx.registerWriteRoute("POST", "/message", async (req, res, url) => {
|
|
35
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
36
|
+
const session = sessionId ? ctx.sseSessions.get(sessionId) : null;
|
|
37
|
+
|
|
38
|
+
if (!session) {
|
|
39
|
+
res.writeHead(404, { "Content-Type": "application/json", ...ctx.CORS });
|
|
40
|
+
return res.end(JSON.stringify({ error: "Unknown or expired session" }));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let body;
|
|
44
|
+
try { body = await readBody(req); } catch {
|
|
45
|
+
res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
|
|
46
|
+
return res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const id = body.id;
|
|
50
|
+
const rpcMethod = body.method;
|
|
51
|
+
|
|
52
|
+
// Acknowledge the POST immediately
|
|
53
|
+
res.writeHead(202, { "Content-Type": "application/json", ...ctx.CORS });
|
|
54
|
+
res.end(JSON.stringify({ ok: true }));
|
|
55
|
+
|
|
56
|
+
// Handle JSON-RPC methods
|
|
57
|
+
if (rpcMethod === "notifications/initialized" || rpcMethod === "initialized") {
|
|
58
|
+
session.initialized = true;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (rpcMethod === "initialize") {
|
|
63
|
+
sseSend(session.res, "message", {
|
|
64
|
+
jsonrpc: "2.0", id,
|
|
65
|
+
result: {
|
|
66
|
+
protocolVersion: "2024-11-05",
|
|
67
|
+
serverInfo: { name: "clauth", version: VERSION },
|
|
68
|
+
capabilities: { tools: {} }
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (rpcMethod === "tools/list") {
|
|
75
|
+
sseSend(session.res, "message", {
|
|
76
|
+
jsonrpc: "2.0", id,
|
|
77
|
+
result: { tools: filterMcpToolsForWriteMode(MCP_TOOLS) }
|
|
78
|
+
});
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (rpcMethod === "tools/call") {
|
|
83
|
+
const { name, arguments: args } = body.params || {};
|
|
84
|
+
const vault = ctx.sseVault();
|
|
85
|
+
try {
|
|
86
|
+
const result = await ctx.handleMcpTool(vault, name, args || {});
|
|
87
|
+
// Sync vault mutations back (e.g. unlock sets password)
|
|
88
|
+
ctx.password = vault.password;
|
|
89
|
+
sseSend(session.res, "message", { jsonrpc: "2.0", id, result });
|
|
90
|
+
} catch (err) {
|
|
91
|
+
sseSend(session.res, "message", {
|
|
92
|
+
jsonrpc: "2.0", id,
|
|
93
|
+
result: mcpError(`Internal error: ${err.message}`)
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Unknown method
|
|
100
|
+
sseSend(session.res, "message", {
|
|
101
|
+
jsonrpc: "2.0", id,
|
|
102
|
+
error: { code: -32601, message: `Unknown method: ${rpcMethod}` }
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// GET / — built-in web dashboard
|
|
108
|
+
ctx.registerWriteRoute("GET", "/", async (req, res) => {
|
|
109
|
+
// When unlocked, ensure a non-expired write session exists and hand its
|
|
110
|
+
// token to the page so the dashboard is write-ready on load — fixes the
|
|
111
|
+
// --pw/boot.key auto-unlock case where the page never saw the unlock
|
|
112
|
+
// screen and so held no write token. (Consistent with the existing trust
|
|
113
|
+
// model: /v/<service> already serves raw credentials to localhost when
|
|
114
|
+
// unlocked, so a same-origin write token is no broader.)
|
|
115
|
+
let initWriteToken = null;
|
|
116
|
+
if (ctx.password) {
|
|
117
|
+
if (!ctx.writeSession || Date.now() > ctx.writeSession.expiresAt) ctx.writeSession = makeWriteToken();
|
|
118
|
+
initWriteToken = ctx.writeSession.token;
|
|
119
|
+
}
|
|
120
|
+
res.writeHead(200, { "Content-Type": "text/html", ...ctx.CORS });
|
|
121
|
+
return res.end(ctx.dashboardHtml(ctx.port, ctx.whitelist, ctx.isStaged, initWriteToken));
|
|
122
|
+
});
|
|
123
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// cli/http/components/inbox-component.js
|
|
2
|
+
// The Inbox component: GET /inbox/:sessionKey/peek -- convergence layer 5
|
|
3
|
+
// messaging probe. Returns { pending, sources, max_priority } from
|
|
4
|
+
// collab.inbox_messages via the public.inbox_peek Supabase RPC. An unlocked
|
|
5
|
+
// daemon reads Supabase creds from its own vault; a locked isolated TEST
|
|
6
|
+
// daemon borrows them from the upstream live daemon (CLAUTH_UPSTREAM,
|
|
7
|
+
// default :52437) so regression harnesses need no manual unlock.
|
|
8
|
+
import { deriveToken } from "../../fingerprint.js";
|
|
9
|
+
import * as api from "../../api.js";
|
|
10
|
+
|
|
11
|
+
export function registerInboxRoutes(ctx) {
|
|
12
|
+
ctx.registerPatternRoute("GET", /^\/inbox\/([^/]+)\/peek$/, async (req, res, match) => {
|
|
13
|
+
const sessionKey = decodeURIComponent(match[1]);
|
|
14
|
+
try {
|
|
15
|
+
let key = null;
|
|
16
|
+
let ref = null;
|
|
17
|
+
if (ctx.password) {
|
|
18
|
+
const { token, timestamp } = deriveToken(ctx.password, ctx.machineHash);
|
|
19
|
+
const svc = await api.retrieve(ctx.password, ctx.machineHash, token, timestamp, "supabase-service");
|
|
20
|
+
const dbRaw = await api.retrieve(ctx.password, ctx.machineHash, token, timestamp, "supabase-db");
|
|
21
|
+
if (svc.error || dbRaw.error) return ctx.noBrowserJson(res, 503, { error: svc.error || dbRaw.error });
|
|
22
|
+
key = svc.value;
|
|
23
|
+
try { ref = JSON.parse(dbRaw.value).ref; } catch { /* below */ }
|
|
24
|
+
} else {
|
|
25
|
+
const up = process.env.CLAUTH_UPSTREAM || "http://127.0.0.1:52437";
|
|
26
|
+
key = (await (await fetch(`${up}/v/supabase-service`)).text()).trim();
|
|
27
|
+
try { ref = JSON.parse(await (await fetch(`${up}/v/supabase-db`)).text()).ref; } catch { /* below */ }
|
|
28
|
+
}
|
|
29
|
+
if (!key || !ref) return ctx.noBrowserJson(res, 503, { error: "supabase creds unavailable" });
|
|
30
|
+
const resp = await fetch(`https://${ref}.supabase.co/rest/v1/rpc/inbox_peek`, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: { apikey: key, Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
|
33
|
+
body: JSON.stringify({ p_session_key: sessionKey }),
|
|
34
|
+
});
|
|
35
|
+
if (!resp.ok) return ctx.noBrowserJson(res, 502, { error: `supabase rpc ${resp.status}` });
|
|
36
|
+
return ctx.noBrowserJson(res, 200, await resp.json());
|
|
37
|
+
} catch (err) {
|
|
38
|
+
return ctx.noBrowserJson(res, 503, { error: err.message });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// cli/http/components/mcp-transport-component.js
|
|
2
|
+
// The MCP-transport component: OAuth 2.1 discovery (.well-known/* -- RFC 9728
|
|
3
|
+
// + RFC 8414), the Bearer-token gate that fronts every MCP endpoint, the
|
|
4
|
+
// Streamable HTTP JSON-RPC transport (POST /sse, /mcp, /gws, /clauth, /fs),
|
|
5
|
+
// and the legacy SSE session-open handshake (GET /sse and namespaced paths).
|
|
6
|
+
// This is the live transport claude.ai's Custom Connector and every local
|
|
7
|
+
// MCP client actually talk to -- the most sensitive extraction this session,
|
|
8
|
+
// handled last and verified against the isolated instance only.
|
|
9
|
+
//
|
|
10
|
+
// noAuthHost/localTrustedHost/requestHost are derived fresh from req.headers.host
|
|
11
|
+
// on every request -- pure per-request logic, duplicated locally rather than
|
|
12
|
+
// passed via ctx (there's nothing closure-scoped about them). MCP_PATHS/
|
|
13
|
+
// toolsForPath/serverNameForPath are likewise pure and self-contained.
|
|
14
|
+
//
|
|
15
|
+
// oauthBase, sseVault, handleMcpTool stay defined in serve.js as closure
|
|
16
|
+
// functions and are passed via ctx as references, matching every other
|
|
17
|
+
// extraction this session (dashboard-component.js's dashboardHtml/sseVault/
|
|
18
|
+
// handleMcpTool trio is the direct precedent). oauthTokens (a Set, mutated
|
|
19
|
+
// only via .has()) and sseSessions (a Map, mutated via .set()/.delete()) are
|
|
20
|
+
// passed by reference -- no accessor wrapping needed. password needs a
|
|
21
|
+
// setter (tools/call syncs vault mutations back) and sseCounter needs both
|
|
22
|
+
// getter and setter since it's incremented in place (ctx.sseCounter++ invokes
|
|
23
|
+
// both transparently, same as any other JS accessor pair).
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import os from "node:os";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
import { readBody } from "../request-utils.js";
|
|
29
|
+
import { MCP_TOOLS, filterMcpToolsForWriteMode } from "../../mcp/tool-schemas.js";
|
|
30
|
+
import { mcpError } from "../../mcp/tool-registry.js";
|
|
31
|
+
|
|
32
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../package.json"), "utf8"));
|
|
34
|
+
const VERSION = pkg.version;
|
|
35
|
+
const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
|
|
36
|
+
|
|
37
|
+
// Hosts that bypass OAuth (fresh domains for claude.ai compatibility) —
|
|
38
|
+
// those domains use the tunnel URL itself as a shared secret (like regen-media).
|
|
39
|
+
const NOAUTH_HOSTS = ["fs.regendevcorp.com", "clauth.regendevcorp.com", "chitchat.regendevcorp.com"];
|
|
40
|
+
const MCP_PATHS = ["/mcp", "/gws", "/clauth", "/fs"];
|
|
41
|
+
|
|
42
|
+
function hostFlags(req) {
|
|
43
|
+
const requestHost = (req.headers.host || "").split(":")[0].toLowerCase();
|
|
44
|
+
return {
|
|
45
|
+
noAuthHost: NOAUTH_HOSTS.includes(requestHost),
|
|
46
|
+
localTrustedHost: ["127.0.0.1", "localhost", "::1", "[::1]"].includes(requestHost),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function toolsForPath(p) {
|
|
51
|
+
const tools = filterMcpToolsForWriteMode(MCP_TOOLS);
|
|
52
|
+
if (p === "/gws") return tools.filter(t => t.name.startsWith("gws_"));
|
|
53
|
+
if (p === "/clauth") return tools.filter(t => t.name.startsWith("clauth_") || t.name === "call_agent" || t.name.startsWith("terminal_") || t.name.startsWith("channel_"));
|
|
54
|
+
if (p === "/fs") return tools.filter(t => t.name.startsWith("fs_"));
|
|
55
|
+
return tools; // /mcp — all tools
|
|
56
|
+
}
|
|
57
|
+
function serverNameForPath(p) {
|
|
58
|
+
if (p === "/gws") return "gws";
|
|
59
|
+
if (p === "/clauth") return "clauth";
|
|
60
|
+
if (p === "/fs") return "fs";
|
|
61
|
+
return "clauth";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function registerMcpTransportRoutes(ctx) {
|
|
65
|
+
// ── OAuth Discovery (RFC 9728 + RFC 8414) — any /.well-known/* path ──
|
|
66
|
+
ctx.registerPatternRoute("GET", /^\/\.well-known\//, async (req, res, match, url) => {
|
|
67
|
+
const reqPath = url.pathname;
|
|
68
|
+
const { noAuthHost } = hostFlags(req);
|
|
69
|
+
|
|
70
|
+
// Suppress OAuth discovery on noauth hosts — prevents claude.ai from entering OAuth flow
|
|
71
|
+
if (noAuthHost) {
|
|
72
|
+
res.writeHead(404, { "Content-Type": "application/json", ...ctx.CORS });
|
|
73
|
+
return res.end(JSON.stringify({ error: "not_found" }));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Restore full well-known + OAuth so custom setup with client_id/secret works.
|
|
77
|
+
if (reqPath.startsWith("/.well-known/oauth-protected-resource")) {
|
|
78
|
+
const base = ctx.oauthBase();
|
|
79
|
+
const suffix = reqPath.replace("/.well-known/oauth-protected-resource", "").replace(/^\//, "");
|
|
80
|
+
const resourcePath = suffix && ["/gws", "/clauth", "/mcp", "/fs", "/sse"].includes("/" + suffix) ? "/" + suffix : "/sse";
|
|
81
|
+
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store", ...ctx.CORS });
|
|
82
|
+
return res.end(JSON.stringify({
|
|
83
|
+
resource: `${base}${resourcePath}`,
|
|
84
|
+
authorization_servers: [base],
|
|
85
|
+
scopes_supported: ["mcp:tools"],
|
|
86
|
+
bearer_methods_supported: ["header"],
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (reqPath === "/.well-known/oauth-authorization-server") {
|
|
91
|
+
const base = ctx.oauthBase();
|
|
92
|
+
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store", ...ctx.CORS });
|
|
93
|
+
return res.end(JSON.stringify({
|
|
94
|
+
issuer: base,
|
|
95
|
+
authorization_endpoint: `${base}/authorize`,
|
|
96
|
+
token_endpoint: `${base}/token`,
|
|
97
|
+
registration_endpoint: `${base}/register`,
|
|
98
|
+
response_types_supported: ["code"],
|
|
99
|
+
grant_types_supported: ["authorization_code"],
|
|
100
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
101
|
+
code_challenge_methods_supported: ["S256"],
|
|
102
|
+
scopes_supported: ["mcp:tools"],
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// No known well-known sub-path matched — same generic 404 shape as the
|
|
107
|
+
// real terminal fallback in serve.js, since this pattern-route's regex
|
|
108
|
+
// has already claimed every /.well-known/* request.
|
|
109
|
+
try { fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] 404 GET ${reqPath}\n`); } catch {}
|
|
110
|
+
res.writeHead(404, { "Content-Type": "application/json", ...ctx.CORS });
|
|
111
|
+
return res.end(JSON.stringify({ error: `Unknown endpoint: ${reqPath}` }));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ── MCP endpoint auth (401 gate) + Streamable HTTP transport + SSE open ──
|
|
115
|
+
// Registered once per literal path (POST and GET) rather than a pattern
|
|
116
|
+
// route — these are five fixed strings, not a dynamic segment.
|
|
117
|
+
const ALL_MCP_POST_PATHS = ["/sse", ...MCP_PATHS];
|
|
118
|
+
for (const reqPath of ALL_MCP_POST_PATHS) {
|
|
119
|
+
ctx.registerWriteRoute("POST", reqPath, (req, res, url) => handleMcpPost(ctx, req, res, url, reqPath));
|
|
120
|
+
ctx.registerWriteRoute("GET", reqPath, (req, res, url) => handleMcpGet(ctx, req, res, url, reqPath));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function handleMcpPost(ctx, req, res, url, reqPath) {
|
|
125
|
+
const isMcpPath = MCP_PATHS.includes(reqPath);
|
|
126
|
+
const { noAuthHost, localTrustedHost } = hostFlags(req);
|
|
127
|
+
|
|
128
|
+
// ── MCP endpoint auth — 401 gate (OAuth 2.1 protocol) ──
|
|
129
|
+
// Skipped for noauth hosts — those domains use tunnel URL as shared secret (like regen-media)
|
|
130
|
+
const authHeader = req.headers.authorization;
|
|
131
|
+
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
|
132
|
+
|
|
133
|
+
if (!noAuthHost && !localTrustedHost && (!token || !ctx.oauthTokens.has(token))) {
|
|
134
|
+
// No valid Bearer token → return 401 with discovery hint
|
|
135
|
+
const base = ctx.oauthBase();
|
|
136
|
+
const resourcePath = isMcpPath ? reqPath.slice(1) : "sse"; // "mcp", "gws", "clauth", or "sse"
|
|
137
|
+
const resourceMeta = `${base}/.well-known/oauth-protected-resource/${resourcePath}`;
|
|
138
|
+
res.writeHead(401, {
|
|
139
|
+
"Content-Type": "application/json",
|
|
140
|
+
"WWW-Authenticate": `Bearer realm="MCP", resource_metadata="${resourceMeta}"`,
|
|
141
|
+
"Cache-Control": "no-store",
|
|
142
|
+
...ctx.CORS,
|
|
143
|
+
});
|
|
144
|
+
return res.end(JSON.stringify({
|
|
145
|
+
error: "unauthorized",
|
|
146
|
+
error_description: "Bearer token required",
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Valid token or trusted localhost — mark remote status and fall through to MCP handling
|
|
151
|
+
req._clauthRemote = !localTrustedHost;
|
|
152
|
+
|
|
153
|
+
// ── MCP Streamable HTTP transport ──
|
|
154
|
+
// claude.ai requires text/event-stream SSE format (like regen-media/Express).
|
|
155
|
+
let body;
|
|
156
|
+
try { body = await readBody(req); } catch {
|
|
157
|
+
res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
|
|
158
|
+
return res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const id = body.id;
|
|
162
|
+
const rpcMethod = body.method;
|
|
163
|
+
const logMsg = `[${new Date().toISOString()}] Streamable HTTP [${reqPath}]: ${rpcMethod} id=${id}\n`;
|
|
164
|
+
try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
|
|
165
|
+
|
|
166
|
+
// Check if client accepts SSE — claude.ai sends Accept: application/json, text/event-stream
|
|
167
|
+
const acceptsSSE = (req.headers.accept || "").includes("text/event-stream");
|
|
168
|
+
|
|
169
|
+
// Helper: respond in SSE or JSON format based on client preference
|
|
170
|
+
// SSE response matches regen-media/Express exactly: no CORS, Content-Length, x-powered-by
|
|
171
|
+
function mcpRespond(res, jsonRpcResponse) {
|
|
172
|
+
if (acceptsSSE) {
|
|
173
|
+
const ssePayload = `event: message\ndata: ${JSON.stringify(jsonRpcResponse)}\n\n`;
|
|
174
|
+
const buf = Buffer.from(ssePayload, "utf8");
|
|
175
|
+
res.writeHead(200, {
|
|
176
|
+
"Content-Type": "text/event-stream",
|
|
177
|
+
"Content-Length": buf.length,
|
|
178
|
+
"Cache-Control": "no-cache",
|
|
179
|
+
"vary": "Accept-Encoding",
|
|
180
|
+
"x-powered-by": "Express",
|
|
181
|
+
});
|
|
182
|
+
return res.end(buf);
|
|
183
|
+
}
|
|
184
|
+
res.writeHead(200, { "Content-Type": "application/json", ...ctx.CORS });
|
|
185
|
+
return res.end(JSON.stringify(jsonRpcResponse));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Notifications — no response needed
|
|
189
|
+
if (rpcMethod === "notifications/initialized" || rpcMethod === "initialized") {
|
|
190
|
+
res.writeHead(202, ctx.CORS);
|
|
191
|
+
return res.end();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (rpcMethod === "initialize") {
|
|
195
|
+
const result = {
|
|
196
|
+
protocolVersion: "2025-03-26",
|
|
197
|
+
serverInfo: { name: serverNameForPath(reqPath), version: VERSION },
|
|
198
|
+
capabilities: { tools: { listChanged: true } }
|
|
199
|
+
};
|
|
200
|
+
return mcpRespond(res, { jsonrpc: "2.0", id, result });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (rpcMethod === "tools/list") {
|
|
204
|
+
return mcpRespond(res, { jsonrpc: "2.0", id, result: { tools: toolsForPath(reqPath) } });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (rpcMethod === "tools/call") {
|
|
208
|
+
const { name, arguments: args } = body.params || {};
|
|
209
|
+
const vault = ctx.sseVault();
|
|
210
|
+
vault.remote = !!req._clauthRemote;
|
|
211
|
+
try {
|
|
212
|
+
const result = await ctx.handleMcpTool(vault, name, args || {});
|
|
213
|
+
ctx.password = vault.password;
|
|
214
|
+
return mcpRespond(res, { jsonrpc: "2.0", id, result });
|
|
215
|
+
} catch (err) {
|
|
216
|
+
return mcpRespond(res, { jsonrpc: "2.0", id, result: mcpError(`Internal error: ${err.message}`) });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Unknown method
|
|
221
|
+
return mcpRespond(res, { jsonrpc: "2.0", id, error: { code: -32601, message: `Unknown method: ${rpcMethod}` } });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function handleMcpGet(ctx, req, res, url, reqPath) {
|
|
225
|
+
const { noAuthHost } = hostFlags(req);
|
|
226
|
+
|
|
227
|
+
// ── MCP SSE transport — /sse and namespaced paths ────
|
|
228
|
+
// Remote clients (claude.ai) arrive with a Bearer token via OAuth.
|
|
229
|
+
// They use Streamable HTTP (POST only) — return 405 on GET so claude.ai
|
|
230
|
+
// knows to POST directly. Local clients have no Bearer token and use SSE.
|
|
231
|
+
const isMcpPath = MCP_PATHS.includes(reqPath);
|
|
232
|
+
if (isMcpPath) {
|
|
233
|
+
const getAuthHeader = req.headers.authorization;
|
|
234
|
+
const getToken = getAuthHeader?.startsWith("Bearer ") ? getAuthHeader.slice(7) : null;
|
|
235
|
+
if (!noAuthHost && getToken && ctx.oauthTokens.has(getToken)) {
|
|
236
|
+
res.writeHead(405, { "Content-Type": "application/json", "Allow": "POST", ...ctx.CORS });
|
|
237
|
+
return res.end(JSON.stringify({ error: "Method Not Allowed", detail: "Use POST for Streamable HTTP transport" }));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// GET /sse|/clauth — open SSE stream, receive endpoint event
|
|
242
|
+
const sessionId = `ses_${++ctx.sseCounter}_${Date.now()}`;
|
|
243
|
+
|
|
244
|
+
res.writeHead(200, {
|
|
245
|
+
"Content-Type": "text/event-stream",
|
|
246
|
+
"Cache-Control": "no-cache",
|
|
247
|
+
"Connection": "keep-alive",
|
|
248
|
+
...ctx.CORS,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
ctx.sseSessions.set(sessionId, { res, initialized: false });
|
|
252
|
+
|
|
253
|
+
// Send the endpoint URI the client should POST to
|
|
254
|
+
// Use absolute URL when tunnel is active so remote clients can resolve it
|
|
255
|
+
const basePath = ctx.tunnelUrl || `http://127.0.0.1:${ctx.port}`;
|
|
256
|
+
const endpoint = `${basePath}/message?sessionId=${sessionId}`;
|
|
257
|
+
res.write(`event: endpoint\ndata: ${endpoint}\n\n`);
|
|
258
|
+
|
|
259
|
+
// Keepalive every 15s
|
|
260
|
+
const keepalive = setInterval(() => {
|
|
261
|
+
try { res.write(": keepalive\n\n"); } catch {}
|
|
262
|
+
}, 15_000);
|
|
263
|
+
|
|
264
|
+
// Cleanup on disconnect
|
|
265
|
+
req.on("close", () => {
|
|
266
|
+
clearInterval(keepalive);
|
|
267
|
+
ctx.sseSessions.delete(sessionId);
|
|
268
|
+
const logLine = `[${new Date().toISOString()}] SSE session closed: ${sessionId}\n`;
|
|
269
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const logLine = `[${new Date().toISOString()}] SSE session opened: ${sessionId}\n`;
|
|
273
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// cli/http/components/oauth-component.js
|
|
2
|
+
// The OAuth component: OAuth 2.1 dynamic client registration
|
|
3
|
+
// (POST /register), the auto-approving authorization endpoint (GET
|
|
4
|
+
// /authorize -- PKCE mandatory, no user interaction since clauth is a
|
|
5
|
+
// personal vault), and the token endpoint (POST /token -- exchanges an
|
|
6
|
+
// authorization code + PKCE verifier for a bearer access token). This
|
|
7
|
+
// is the auth flow claude.ai's Custom Connector uses to reach the MCP
|
|
8
|
+
// transport, distinct from the vault password gate.
|
|
9
|
+
//
|
|
10
|
+
// /authorize is a GET that MUTATES state (issues a one-time code into
|
|
11
|
+
// oauthCodes) -- registered via ctx.registerWriteRoute despite the verb,
|
|
12
|
+
// same "mutation decides the registry, not the HTTP method" principle
|
|
13
|
+
// used for /tunnel/setup-state earlier this session.
|
|
14
|
+
//
|
|
15
|
+
// oauthClients/oauthCodes/oauthTokens are Map/Set instances that stay
|
|
16
|
+
// defined in serve.js and are passed via ctx as plain object
|
|
17
|
+
// references -- calling .get()/.set()/.delete()/.add() on the SAME
|
|
18
|
+
// instance needs no accessor wrapping, unlike a primitive "let" that
|
|
19
|
+
// gets reassigned (contrast with vault-auth-component.js's password/
|
|
20
|
+
// writeSession get/set pairs). saveClients/saveTokens (persist-to-disk)
|
|
21
|
+
// stay in serve.js too, passed as plain function references.
|
|
22
|
+
import crypto from "node:crypto";
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import os from "node:os";
|
|
26
|
+
import { operation } from "../../supervisor-registry.js";
|
|
27
|
+
import { readBody, readRawBody } from "../request-utils.js";
|
|
28
|
+
|
|
29
|
+
const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
|
|
30
|
+
|
|
31
|
+
// Hosts that bypass OAuth entirely (fresh domains for claude.ai compatibility
|
|
32
|
+
// -- those domains use the tunnel URL itself as a shared secret, like
|
|
33
|
+
// regen-media). Dynamic Client Registration (RFC 7591) must not be reachable
|
|
34
|
+
// on them. This check used to live as a raw if-block in serve.js's if-chain,
|
|
35
|
+
// but the exact-match write-route Map (where these 3 routes now live) is
|
|
36
|
+
// checked BEFORE that if-chain -- so the block never actually ran once these
|
|
37
|
+
// routes moved to the registry, silently exposing /register, /authorize, and
|
|
38
|
+
// /token on the noauth hosts. Restored here, at the point the Map actually
|
|
39
|
+
// intercepts the request.
|
|
40
|
+
const NOAUTH_HOSTS = ["fs.regendevcorp.com", "clauth.regendevcorp.com", "chitchat.regendevcorp.com"];
|
|
41
|
+
function isNoAuthHost(req) {
|
|
42
|
+
const requestHost = (req.headers.host || "").split(":")[0].toLowerCase();
|
|
43
|
+
return NOAUTH_HOSTS.includes(requestHost);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sha256base64url(str) {
|
|
47
|
+
return crypto.createHash("sha256").update(str).digest("base64url");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function registerOAuthRoutes(ctx) {
|
|
51
|
+
ctx.registerWriteRoute("POST", "/register", async (req, res, url) => {
|
|
52
|
+
if (isNoAuthHost(req)) {
|
|
53
|
+
res.writeHead(404, { "Content-Type": "application/json", ...ctx.CORS });
|
|
54
|
+
return res.end(JSON.stringify({ error: "not_found" }));
|
|
55
|
+
}
|
|
56
|
+
let body;
|
|
57
|
+
try { body = await readBody(req); } catch {
|
|
58
|
+
res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
|
|
59
|
+
return res.end(JSON.stringify({ error: "invalid_request" }));
|
|
60
|
+
}
|
|
61
|
+
const clientId = crypto.randomBytes(16).toString("hex");
|
|
62
|
+
const client = {
|
|
63
|
+
client_id: clientId,
|
|
64
|
+
// NO client_secret — public client (OAuth 2.1)
|
|
65
|
+
client_name: body.client_name || "unknown",
|
|
66
|
+
redirect_uris: body.redirect_uris || [],
|
|
67
|
+
grant_types: body.grant_types || ["authorization_code"],
|
|
68
|
+
response_types: body.response_types || ["code"],
|
|
69
|
+
token_endpoint_auth_method: "none", // PUBLIC CLIENT
|
|
70
|
+
};
|
|
71
|
+
ctx.oauthClients.set(clientId, client);
|
|
72
|
+
ctx.saveClients(ctx.oauthClients);
|
|
73
|
+
const logMsg = `[${new Date().toISOString()}] OAuth: registered public client ${clientId} (${client.client_name})\n`;
|
|
74
|
+
try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
|
|
75
|
+
operation("oauth.client_register", { client_id: clientId, client_name: client.client_name }, null, { ok: true });
|
|
76
|
+
res.writeHead(201, { "Content-Type": "application/json", "Cache-Control": "no-store", ...ctx.CORS });
|
|
77
|
+
return res.end(JSON.stringify(client));
|
|
78
|
+
});
|
|
79
|
+
ctx.registerWriteRoute("GET", "/authorize", async (req, res, url) => {
|
|
80
|
+
if (isNoAuthHost(req)) {
|
|
81
|
+
res.writeHead(404, { "Content-Type": "application/json", ...ctx.CORS });
|
|
82
|
+
return res.end(JSON.stringify({ error: "not_found" }));
|
|
83
|
+
}
|
|
84
|
+
const clientId = url.searchParams.get("client_id");
|
|
85
|
+
const redirectUri = url.searchParams.get("redirect_uri");
|
|
86
|
+
const state = url.searchParams.get("state");
|
|
87
|
+
const codeChallenge = url.searchParams.get("code_challenge");
|
|
88
|
+
const codeChallengeMethod = url.searchParams.get("code_challenge_method");
|
|
89
|
+
|
|
90
|
+
// Validate required params
|
|
91
|
+
if (!clientId || !redirectUri) {
|
|
92
|
+
res.writeHead(400, { "Content-Type": "text/plain", ...ctx.CORS });
|
|
93
|
+
return res.end("Missing client_id or redirect_uri");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// PKCE is MANDATORY — reject if missing
|
|
97
|
+
if (!codeChallenge || codeChallengeMethod !== "S256") {
|
|
98
|
+
res.writeHead(400, { "Content-Type": "text/plain", ...ctx.CORS });
|
|
99
|
+
return res.end("PKCE required: code_challenge with S256 method");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Validate client exists
|
|
103
|
+
if (!ctx.oauthClients.has(clientId)) {
|
|
104
|
+
res.writeHead(400, { "Content-Type": "text/plain", ...ctx.CORS });
|
|
105
|
+
return res.end("Unknown client_id");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Validate redirect_uri matches registered URIs
|
|
109
|
+
const client = ctx.oauthClients.get(clientId);
|
|
110
|
+
if (client.redirect_uris.length > 0 && !client.redirect_uris.includes(redirectUri)) {
|
|
111
|
+
res.writeHead(400, { "Content-Type": "text/plain", ...ctx.CORS });
|
|
112
|
+
return res.end("redirect_uri mismatch");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Auto-approve (no user interaction — clauth is a personal vault)
|
|
116
|
+
const code = crypto.randomBytes(32).toString("hex");
|
|
117
|
+
ctx.oauthCodes.set(code, {
|
|
118
|
+
client_id: clientId,
|
|
119
|
+
redirect_uri: redirectUri,
|
|
120
|
+
code_challenge: codeChallenge,
|
|
121
|
+
expires: Date.now() + 300_000, // 5 minutes
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const redirect = new URL(redirectUri);
|
|
125
|
+
redirect.searchParams.set("code", code);
|
|
126
|
+
if (state) redirect.searchParams.set("state", state);
|
|
127
|
+
|
|
128
|
+
const logMsg = `[${new Date().toISOString()}] OAuth: authorize → code for ${clientId}, redirect to ${redirect.origin}\n`;
|
|
129
|
+
try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
|
|
130
|
+
res.writeHead(302, { Location: redirect.toString(), "Cache-Control": "no-store", ...ctx.CORS });
|
|
131
|
+
return res.end();
|
|
132
|
+
});
|
|
133
|
+
ctx.registerWriteRoute("POST", "/token", async (req, res, url) => {
|
|
134
|
+
if (isNoAuthHost(req)) {
|
|
135
|
+
res.writeHead(404, { "Content-Type": "application/json", ...ctx.CORS });
|
|
136
|
+
return res.end(JSON.stringify({ error: "not_found" }));
|
|
137
|
+
}
|
|
138
|
+
let body;
|
|
139
|
+
const ct = req.headers["content-type"] || "";
|
|
140
|
+
try {
|
|
141
|
+
if (ct.includes("application/json")) {
|
|
142
|
+
body = await readBody(req);
|
|
143
|
+
} else {
|
|
144
|
+
// application/x-www-form-urlencoded (Claude uses this)
|
|
145
|
+
const raw = await readRawBody(req);
|
|
146
|
+
body = Object.fromEntries(new URLSearchParams(raw));
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
|
|
150
|
+
return res.end(JSON.stringify({ error: "invalid_request" }));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Validate grant_type
|
|
154
|
+
if (body.grant_type !== "authorization_code") {
|
|
155
|
+
res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
|
|
156
|
+
return res.end(JSON.stringify({ error: "unsupported_grant_type" }));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Validate authorization code exists and is not expired
|
|
160
|
+
const stored = ctx.oauthCodes.get(body.code);
|
|
161
|
+
if (!stored || stored.expires < Date.now()) {
|
|
162
|
+
ctx.oauthCodes.delete(body.code);
|
|
163
|
+
res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
|
|
164
|
+
return res.end(JSON.stringify({ error: "invalid_grant", error_description: "Code expired or invalid" }));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Validate client_id matches
|
|
168
|
+
if (body.client_id !== stored.client_id) {
|
|
169
|
+
ctx.oauthCodes.delete(body.code);
|
|
170
|
+
res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
|
|
171
|
+
return res.end(JSON.stringify({ error: "invalid_grant", error_description: "client_id mismatch" }));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Validate redirect_uri matches
|
|
175
|
+
if (body.redirect_uri !== stored.redirect_uri) {
|
|
176
|
+
ctx.oauthCodes.delete(body.code);
|
|
177
|
+
res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
|
|
178
|
+
return res.end(JSON.stringify({ error: "invalid_grant", error_description: "redirect_uri mismatch" }));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// PKCE verification — MANDATORY (not optional)
|
|
182
|
+
if (!body.code_verifier) {
|
|
183
|
+
ctx.oauthCodes.delete(body.code);
|
|
184
|
+
res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
|
|
185
|
+
return res.end(JSON.stringify({ error: "invalid_grant", error_description: "code_verifier required" }));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const computed = sha256base64url(body.code_verifier);
|
|
189
|
+
if (computed !== stored.code_challenge) {
|
|
190
|
+
ctx.oauthCodes.delete(body.code);
|
|
191
|
+
res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
|
|
192
|
+
return res.end(JSON.stringify({ error: "invalid_grant", error_description: "PKCE verification failed" }));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// All checks passed — delete code (one-time use) and issue token
|
|
196
|
+
ctx.oauthCodes.delete(body.code);
|
|
197
|
+
const accessToken = crypto.randomBytes(32).toString("hex");
|
|
198
|
+
ctx.oauthTokens.add(accessToken);
|
|
199
|
+
ctx.saveTokens(ctx.oauthTokens);
|
|
200
|
+
|
|
201
|
+
const logMsg = `[${new Date().toISOString()}] OAuth: token issued for ${stored.client_id} (token=${accessToken.slice(0,8)}…)\n`;
|
|
202
|
+
try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
|
|
203
|
+
operation("oauth.token_issue", { client_id: stored.client_id }, null, { ok: true });
|
|
204
|
+
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store", ...ctx.CORS });
|
|
205
|
+
return res.end(JSON.stringify({ access_token: accessToken, token_type: "Bearer", scope: "mcp:tools", expires_in: 86400 }));
|
|
206
|
+
});
|
|
207
|
+
}
|