@lifeaitools/clauth 2.1.1 → 2.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +2618 -11238
- 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 +86 -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,264 @@
|
|
|
1
|
+
// cli/http/components/vault-auth-component.js
|
|
2
|
+
// The Vault/Auth component: the actual security core -- vault unlock
|
|
3
|
+
// (/auth), lock (/lock), write-token issuance (/write-token), and master
|
|
4
|
+
// password change (/change-pw). Every closure variable these routes
|
|
5
|
+
// read or mutate (password, writeSession, authFailCount, authHardLocked,
|
|
6
|
+
// tunnelHostname, tunnelStatus) is exposed via ctx as get/set accessor
|
|
7
|
+
// properties -- same pattern as the Tunnel component's ctx.tunnel, just
|
|
8
|
+
// flattened here since these 6 span multiple concerns (auth state,
|
|
9
|
+
// tunnel state) that don't share one natural sub-object.
|
|
10
|
+
//
|
|
11
|
+
// rotationEngine/fetchTunnelConfig/startTunnel/stopTunnel/
|
|
12
|
+
// invalidateCallAgentCtxCache/isTerminalAuthVerdict all stay defined in
|
|
13
|
+
// serve.js and are passed via ctx as plain references -- avoid-circular-
|
|
14
|
+
// import reasoning, same as every other component this session.
|
|
15
|
+
import { deriveToken, deriveSeedHash } from "../../fingerprint.js";
|
|
16
|
+
import * as api from "../../api.js";
|
|
17
|
+
import { operation } from "../../supervisor-registry.js";
|
|
18
|
+
import { readBody } from "../request-utils.js";
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import crypto from "node:crypto";
|
|
23
|
+
import { execSync } from "node:child_process";
|
|
24
|
+
|
|
25
|
+
const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
|
|
26
|
+
const MAX_AUTH_FAILS = 10;
|
|
27
|
+
const WRITE_TOKEN_BYTES = 32;
|
|
28
|
+
const WRITE_TOKEN_TTL_MS = 10 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
function makeWriteToken() {
|
|
31
|
+
return {
|
|
32
|
+
token: crypto.randomBytes(WRITE_TOKEN_BYTES).toString("base64url"),
|
|
33
|
+
expiresAt: Date.now() + WRITE_TOKEN_TTL_MS,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function registerVaultAuthRoutes(ctx) {
|
|
38
|
+
// POST /write-token — hand the dashboard a current write token for an
|
|
39
|
+
// ALREADY-UNLOCKED vault, with no second password prompt.
|
|
40
|
+
//
|
|
41
|
+
// A human looking at the dashboard has already passed the lock screen (or
|
|
42
|
+
// the daemon was auto-unlocked via --pw/boot.key). Making them re-enter the
|
|
43
|
+
// password to press a button is ceremony, not security: GET / at the route
|
|
44
|
+
// above already embeds a write token on exactly this condition. This route
|
|
45
|
+
// just lets the page RENEW it, which GET / could only do on a full reload.
|
|
46
|
+
//
|
|
47
|
+
// Why the write token still exists at all: every request reaching this
|
|
48
|
+
// server is already loopback-only (hard 403 above), but cloudflared proxies
|
|
49
|
+
// the public tunnel FROM localhost, so a remote request and a local one are
|
|
50
|
+
// indistinguishable by address. The token is what a page-driven write has
|
|
51
|
+
// and a blind remote POST does not, so the gate stays; only the prompt goes.
|
|
52
|
+
//
|
|
53
|
+
// Mint-or-reuse, mirroring GET /: reusing a still-valid session keeps
|
|
54
|
+
// multiple open tabs working instead of each one invalidating the last.
|
|
55
|
+
ctx.registerWriteRoute("POST", "/write-token", async (req, res) => {
|
|
56
|
+
if (!ctx.password) {
|
|
57
|
+
res.writeHead(403, { "Content-Type": "application/json", ...ctx.CORS });
|
|
58
|
+
return res.end(JSON.stringify({ error: "vault_locked", locked: true }));
|
|
59
|
+
}
|
|
60
|
+
if (!ctx.writeSession || Date.now() > ctx.writeSession.expiresAt) ctx.writeSession = makeWriteToken();
|
|
61
|
+
return ctx.ok(res, {
|
|
62
|
+
ok: true,
|
|
63
|
+
write_token: ctx.writeSession.token,
|
|
64
|
+
write_expires_at: new Date(ctx.writeSession.expiresAt).toISOString(),
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
// POST /auth — unlock the vault with a password (verifies against Edge Function)
|
|
68
|
+
ctx.registerWriteRoute("POST", "/auth", async (req, res) => {
|
|
69
|
+
let body;
|
|
70
|
+
try { body = await readBody(req); } catch {
|
|
71
|
+
res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
|
|
72
|
+
return res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const pw = body.password;
|
|
76
|
+
if (!pw || typeof pw !== "string") {
|
|
77
|
+
res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
|
|
78
|
+
return res.end(JSON.stringify({ error: "password is required" }));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (ctx.authHardLocked) {
|
|
82
|
+
res.writeHead(401, { "Content-Type": "application/json", ...ctx.CORS });
|
|
83
|
+
return res.end(JSON.stringify({ error: "Too many failed attempts — restart daemon to try again", hard_locked: true }));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const { token, timestamp } = deriveToken(pw, ctx.machineHash);
|
|
88
|
+
const result = await api.test(pw, ctx.machineHash, token, timestamp);
|
|
89
|
+
if (result.error) {
|
|
90
|
+
// Preserve the server's `reason` (invalid_token (N/5), machine_locked, …)
|
|
91
|
+
// so the catch block can surface it AND decide whether to keep retrying.
|
|
92
|
+
const authError = new Error(result.error);
|
|
93
|
+
authError.serverReason = result.reason || null;
|
|
94
|
+
throw authError;
|
|
95
|
+
}
|
|
96
|
+
ctx.password = pw; // unlock — store in process memory only
|
|
97
|
+
ctx.writeSession = makeWriteToken();
|
|
98
|
+
ctx.authFailCount = 0;
|
|
99
|
+
ctx.authHardLocked = false;
|
|
100
|
+
const logLine = `[${new Date().toISOString()}] Vault unlocked\n`;
|
|
101
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
102
|
+
// Start rotation engine on unlock
|
|
103
|
+
ctx.rotationEngine.start();
|
|
104
|
+
// Auto-seal: DPAPI-encrypt password to boot.key for passwordless crash recovery
|
|
105
|
+
// Only on Windows; only if autostart dir exists (i.e., install was run)
|
|
106
|
+
if (os.platform() === "win32") {
|
|
107
|
+
const autostartDir = path.join(os.homedir(), "AppData", "Roaming", "clauth");
|
|
108
|
+
const bootKeyPath = path.join(autostartDir, "boot.key");
|
|
109
|
+
if (fs.existsSync(autostartDir)) {
|
|
110
|
+
try {
|
|
111
|
+
const pwEscaped = pw.replace(/'/g, "''");
|
|
112
|
+
const psExpr = `Add-Type -AssemblyName System.Security; [Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes('${pwEscaped}'),$null,'CurrentUser'))`;
|
|
113
|
+
const psExe = process.env.SystemRoot
|
|
114
|
+
? `${process.env.SystemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`
|
|
115
|
+
: "powershell.exe";
|
|
116
|
+
const encrypted = execSync(`"${psExe}" -NoProfile -Command "${psExpr}"`, { encoding: "utf8", timeout: 5000 }).trim();
|
|
117
|
+
fs.writeFileSync(bootKeyPath, encrypted, "utf8");
|
|
118
|
+
const sealLog = `[${new Date().toISOString()}] Auto-sealed boot.key via DPAPI (crash recovery enabled)\n`;
|
|
119
|
+
try { fs.appendFileSync(LOG_FILE, sealLog); } catch {}
|
|
120
|
+
} catch (sealErr) {
|
|
121
|
+
const sealLog = `[${new Date().toISOString()}] Auto-seal failed (non-fatal): ${sealErr.message}\n`;
|
|
122
|
+
try { fs.appendFileSync(LOG_FILE, sealLog); } catch {}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// Auto-start tunnel: --tunnel flag takes priority, otherwise fetch from DB
|
|
127
|
+
if (!ctx.tunnelHostname) {
|
|
128
|
+
ctx.fetchTunnelConfig().then(configured => {
|
|
129
|
+
if (configured) {
|
|
130
|
+
ctx.tunnelHostname = configured;
|
|
131
|
+
ctx.tunnelStatus = "starting";
|
|
132
|
+
ctx.startTunnel().catch(() => {});
|
|
133
|
+
} else {
|
|
134
|
+
// No tunnel configured in DB and no --tunnel flag
|
|
135
|
+
ctx.tunnelStatus = "not_configured";
|
|
136
|
+
const msg = [
|
|
137
|
+
`[${new Date().toISOString()}] No tunnel configured.`,
|
|
138
|
+
" claude.ai web integration is inactive.",
|
|
139
|
+
" To enable: run 'clauth tunnel setup'",
|
|
140
|
+
].join("\n");
|
|
141
|
+
try { fs.appendFileSync(LOG_FILE, msg + "\n"); } catch {}
|
|
142
|
+
console.log("\n ⚠ No tunnel configured — claude.ai web integration inactive.");
|
|
143
|
+
console.log(" Run: clauth tunnel setup\n");
|
|
144
|
+
}
|
|
145
|
+
}).catch(() => {
|
|
146
|
+
ctx.tunnelStatus = "error";
|
|
147
|
+
});
|
|
148
|
+
} else {
|
|
149
|
+
ctx.tunnelStatus = "starting";
|
|
150
|
+
ctx.startTunnel().catch(() => {});
|
|
151
|
+
}
|
|
152
|
+
operation("vault.unlock", {}, null, { ok: true });
|
|
153
|
+
return ctx.ok(res, { ok: true, locked: false, write_token: ctx.writeSession.token, write_expires_at: new Date(ctx.writeSession.expiresAt).toISOString() });
|
|
154
|
+
} catch (authErr) {
|
|
155
|
+
const msg = authErr.message || "";
|
|
156
|
+
const serverReason = authErr.serverReason || null;
|
|
157
|
+
// Classify FIRST: is this a backend/external-resource failure (the vault
|
|
158
|
+
// backend or its database never rendered an auth verdict), or a genuine
|
|
159
|
+
// credential verdict? A database timeout, a 5xx, a network drop, or a
|
|
160
|
+
// rate-limit is NOT a wrong password and MUST NOT take a strike — that is
|
|
161
|
+
// exactly what turned a DB blip into a permanent lockout. Both the thrown
|
|
162
|
+
// error (timeout/network/typed VaultBackendError) and any server `reason`
|
|
163
|
+
// (rate_limited/db_error) are checked.
|
|
164
|
+
const backendKind = api.classifyBackendError(authErr) || api.classifyServerReason(serverReason);
|
|
165
|
+
if (backendKind) {
|
|
166
|
+
const BACKEND_MSG = {
|
|
167
|
+
timeout: "Vault backend (database) is not responding — try again in a moment",
|
|
168
|
+
db_error: "Vault backend database error — try again in a moment",
|
|
169
|
+
network: "Vault backend is unreachable — check your network and try again",
|
|
170
|
+
rate_limited: "Vault backend is rate-limiting — wait a few seconds and retry",
|
|
171
|
+
server_error: "Vault backend error — try again in a moment",
|
|
172
|
+
};
|
|
173
|
+
const friendly = BACKEND_MSG[backendKind] || "Vault backend is unreachable — try again in a moment";
|
|
174
|
+
const detail = authErr.detail || msg;
|
|
175
|
+
// No strike, no hard-lock — the verdict was never rendered.
|
|
176
|
+
const failLog = `[${new Date().toISOString()}] [BACKEND ${backendKind}] vault backend unreachable, no auth strike — ${detail}\n`;
|
|
177
|
+
try { fs.appendFileSync(LOG_FILE, failLog); } catch {}
|
|
178
|
+
operation("vault.unlock", {}, null, { ok: false, backend_error: true, kind: backendKind });
|
|
179
|
+
res.writeHead(503, { "Content-Type": "application/json", ...ctx.CORS });
|
|
180
|
+
return res.end(JSON.stringify({
|
|
181
|
+
error: friendly,
|
|
182
|
+
transport_error: true, // back-compat: existing lock screen renders this
|
|
183
|
+
backend_error: true,
|
|
184
|
+
kind: backendKind,
|
|
185
|
+
detail,
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
// Server rendered a verdict. Surface its `reason` so the CLI/log shows
|
|
189
|
+
// WHY (invalid_token (N/5) vs machine_locked) instead of a bare "auth_failed".
|
|
190
|
+
const reasonSuffix = serverReason ? ` (${serverReason})` : "";
|
|
191
|
+
// STOP-ON-REJECTION: a server-side machine lock or invalid_token is terminal —
|
|
192
|
+
// each retry only burns another of the 5 server-side strikes toward a DB lockout.
|
|
193
|
+
// Hard-lock locally NOW so the browser/CLI cannot keep hammering the vault.
|
|
194
|
+
const isTerminalVerdict = ctx.isTerminalAuthVerdict(serverReason);
|
|
195
|
+
ctx.authFailCount++;
|
|
196
|
+
const authRemaining = MAX_AUTH_FAILS - ctx.authFailCount;
|
|
197
|
+
const failLog = `[${new Date().toISOString()}] [AUTH FAIL ${ctx.authFailCount}/${MAX_AUTH_FAILS}] ${msg || "Wrong password"}${reasonSuffix}\n`;
|
|
198
|
+
try { fs.appendFileSync(LOG_FILE, failLog); } catch {}
|
|
199
|
+
if (isTerminalVerdict) {
|
|
200
|
+
ctx.authHardLocked = true;
|
|
201
|
+
const lockLog = `[${new Date().toISOString()}] Server rejected with terminal verdict${reasonSuffix} — hard-locking locally to stop strike accrual; recover via the runbook (unlock machine + re-seal boot.key)\n`;
|
|
202
|
+
try { fs.appendFileSync(LOG_FILE, lockLog); } catch {}
|
|
203
|
+
operation("vault.unlock", {}, null, { ok: false, terminal: true, reason: serverReason });
|
|
204
|
+
res.writeHead(401, { "Content-Type": "application/json", ...ctx.CORS });
|
|
205
|
+
return res.end(JSON.stringify({ error: "Vault rejected credentials — recovery required", reason: serverReason, hard_locked: true, terminal: true }));
|
|
206
|
+
}
|
|
207
|
+
if (ctx.authFailCount >= MAX_AUTH_FAILS) {
|
|
208
|
+
ctx.authHardLocked = true;
|
|
209
|
+
const lockLog = `[${new Date().toISOString()}] Auth failure limit reached — vault hard-locked\n`;
|
|
210
|
+
try { fs.appendFileSync(LOG_FILE, lockLog); } catch {}
|
|
211
|
+
operation("vault.unlock", {}, null, { ok: false, hard_locked: true, reason: serverReason });
|
|
212
|
+
res.writeHead(401, { "Content-Type": "application/json", ...ctx.CORS });
|
|
213
|
+
return res.end(JSON.stringify({ error: "Too many failed attempts — restart daemon to try again", reason: serverReason, hard_locked: true }));
|
|
214
|
+
}
|
|
215
|
+
operation("vault.unlock", {}, null, { ok: false, reason: serverReason, failures_remaining: authRemaining });
|
|
216
|
+
res.writeHead(401, { "Content-Type": "application/json", ...ctx.CORS });
|
|
217
|
+
return res.end(JSON.stringify({ error: "Invalid password", reason: serverReason, failures_remaining: authRemaining }));
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
// POST /lock — clear password from memory
|
|
221
|
+
ctx.registerWriteRoute("POST", "/lock", async (req, res) => {
|
|
222
|
+
ctx.password = null;
|
|
223
|
+
ctx.writeSession = null;
|
|
224
|
+
ctx.invalidateCallAgentCtxCache();
|
|
225
|
+
ctx.stopTunnel();
|
|
226
|
+
const logLine = `[${new Date().toISOString()}] Vault locked\n`;
|
|
227
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
228
|
+
operation("vault.lock", {}, null, { ok: true });
|
|
229
|
+
return ctx.ok(res, { ok: true, locked: true, hard_locked: ctx.authHardLocked });
|
|
230
|
+
});
|
|
231
|
+
// POST /change-pw — change master password (must be unlocked)
|
|
232
|
+
ctx.registerWriteRoute("POST", "/change-pw", async (req, res) => {
|
|
233
|
+
if (ctx.writeGuard(req, res)) return;
|
|
234
|
+
|
|
235
|
+
let body;
|
|
236
|
+
try { body = await readBody(req); } catch {
|
|
237
|
+
res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
|
|
238
|
+
return res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const { newPassword } = body;
|
|
242
|
+
if (!newPassword || typeof newPassword !== "string" || newPassword.length < 8) {
|
|
243
|
+
res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
|
|
244
|
+
return res.end(JSON.stringify({ error: "newPassword must be at least 8 characters" }));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
const { token, timestamp } = deriveToken(ctx.password, ctx.machineHash);
|
|
249
|
+
const newSeedHash = deriveSeedHash(ctx.machineHash, newPassword);
|
|
250
|
+
const result = await api.changePassword(ctx.password, ctx.machineHash, token, timestamp, newSeedHash);
|
|
251
|
+
if (result.error) throw new Error(result.error);
|
|
252
|
+
ctx.password = newPassword; // update in-memory password to new one
|
|
253
|
+
ctx.writeSession = makeWriteToken();
|
|
254
|
+
const logLine = `[${new Date().toISOString()}] Password changed\n`;
|
|
255
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
256
|
+
operation("vault.change_password", {}, null, { ok: true });
|
|
257
|
+
return ctx.ok(res, { ok: true, write_token: ctx.writeSession.token, write_expires_at: new Date(ctx.writeSession.expiresAt).toISOString() });
|
|
258
|
+
} catch (err) {
|
|
259
|
+
operation("vault.change_password", {}, null, { ok: false, error: err.message });
|
|
260
|
+
res.writeHead(502, { "Content-Type": "application/json", ...ctx.CORS });
|
|
261
|
+
return res.end(JSON.stringify({ error: err.message }));
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// cli/http/components/watchdog-component.js
|
|
2
|
+
// The Watchdog component: read-only status/event views over
|
|
3
|
+
// watchdog-registry.js's child-service monitor, plus the one dynamic-segment
|
|
4
|
+
// mutation (restart a named watchdog-owned service). All three functions are
|
|
5
|
+
// real exports of watchdog-registry.js -- imported directly, no ctx needed
|
|
6
|
+
// for them since there's no circular-import risk (this component is never
|
|
7
|
+
// imported back into that module).
|
|
8
|
+
import {
|
|
9
|
+
getWatchdogStatuses,
|
|
10
|
+
readWatchdogEvents,
|
|
11
|
+
restartWatchdogService,
|
|
12
|
+
} from "../../watchdog-registry.js";
|
|
13
|
+
|
|
14
|
+
export function registerWatchdogRoutes(ctx) {
|
|
15
|
+
ctx.registerReadOnlyRoute("GET", "/watchdog/services", async (req, res) => ctx.ok(res, await getWatchdogStatuses()));
|
|
16
|
+
|
|
17
|
+
ctx.registerReadOnlyRoute("GET", "/watchdog/events", async (req, res, url) => {
|
|
18
|
+
const limit = Number(url.searchParams.get("limit") || 100);
|
|
19
|
+
return ctx.ok(res, { events: readWatchdogEvents(limit) });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// POST /watchdog/services/:name/restart
|
|
23
|
+
ctx.registerPatternRoute("POST", /^\/watchdog\/services\/([^/]+)\/restart$/, async (req, res, match) => {
|
|
24
|
+
const result = restartWatchdogService(decodeURIComponent(match[1]));
|
|
25
|
+
res.writeHead(result.ok ? 200 : 403, { "Content-Type": "application/json", ...ctx.CORS });
|
|
26
|
+
return res.end(JSON.stringify(result));
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// cli/http/request-utils.js
|
|
2
|
+
// Small stateless HTTP request helpers shared across serve.js and the
|
|
3
|
+
// per-feature components under cli/http/components/. Pure functions only --
|
|
4
|
+
// nothing here closes over daemon instance state.
|
|
5
|
+
export function readBody(req) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
let data = "";
|
|
8
|
+
req.on("data", chunk => { data += chunk; if (data.length > 65536) reject(new Error("Body too large")); });
|
|
9
|
+
req.on("end", () => { try { resolve(JSON.parse(data)); } catch { reject(new Error("Invalid JSON")); } });
|
|
10
|
+
req.on("error", reject);
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function readRawBody(req) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
let data = "";
|
|
17
|
+
req.on("data", chunk => { data += chunk; if (data.length > 65536) reject(new Error("Body too large")); });
|
|
18
|
+
req.on("end", () => resolve(data));
|
|
19
|
+
req.on("error", reject);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// cli/http/servers/ops-server.js
|
|
2
|
+
// The Ops server: a SEPARATE HTTP listener, its own port, for the
|
|
3
|
+
// PM2/Coolify/deployment operations plane. Bearer-token gated
|
|
4
|
+
// (requireOpsBearer), entirely distinct from the main daemon's vault-
|
|
5
|
+
// password gate -- this plane exists for remote/agent-driven
|
|
6
|
+
// infrastructure operations, not local vault access, so it gets its own
|
|
7
|
+
// port rather than sharing fate with the vault daemon's port/lifecycle.
|
|
8
|
+
//
|
|
9
|
+
// Fully self-contained: no reference to the main daemon's createServer
|
|
10
|
+
// closure state (password/whitelist/etc.) survives in this subsystem --
|
|
11
|
+
// confirmed by grep before extraction. createOpsServer() takes no
|
|
12
|
+
// dependency-injection context; it builds its own ops-job state.
|
|
13
|
+
import http from "node:http";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import crypto from "node:crypto";
|
|
18
|
+
import { ensurePm2 } from "../../ops/pm2-preflight.js";
|
|
19
|
+
import { createPm2Adapter, PM2_OPERATION_CATALOG } from "../../ops/pm2-adapter.js";
|
|
20
|
+
import { createOperationPolicy } from "../../ops/operation-policy.js";
|
|
21
|
+
import { createJobStore } from "../../ops/job-store.js";
|
|
22
|
+
import { createSerializedExecutor } from "../../ops/serialized-executor.js";
|
|
23
|
+
import { createCoolifyAdapter, deploymentUuidFrom } from "../../ops/coolify-adapter.js";
|
|
24
|
+
import { createDeploymentAdapter, parseDeploymentRegistry } from "../../ops/deployment-adapter.js";
|
|
25
|
+
import { getSupervisorDir } from "../../supervisor-registry.js";
|
|
26
|
+
import { readBody } from "../request-utils.js";
|
|
27
|
+
|
|
28
|
+
const LIVE_PORT = 52437;
|
|
29
|
+
const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
|
|
30
|
+
|
|
31
|
+
// Duplicated from serve.js's own isLoopbackAddress -- trivial, zero
|
|
32
|
+
// dependency, kept local to avoid a circular import back into serve.js
|
|
33
|
+
// (which imports createOpsServer from this file).
|
|
34
|
+
function isLoopbackAddress(remote) {
|
|
35
|
+
return remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const CORS = {
|
|
39
|
+
"Access-Control-Allow-Origin": "*",
|
|
40
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
41
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization, Mcp-Session-Id, mcp-protocol-version, mcp-session-id",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function ok(res, data) {
|
|
45
|
+
res.writeHead(200, { "Content-Type": "application/json", ...CORS });
|
|
46
|
+
res.end(JSON.stringify(data));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function createOpsServer() {
|
|
50
|
+
// pm2 is a real declared dependency, but was previously a static top-level
|
|
51
|
+
// import here -- a missing/corrupted node_modules/pm2 threw at MODULE-LOAD
|
|
52
|
+
// time, which (since serve.js imports this file unconditionally) crashed
|
|
53
|
+
// the entire clauth daemon, vault included, with no recovery attempt.
|
|
54
|
+
// ensurePm2() resolves it dynamically and repairs via `npm install` once
|
|
55
|
+
// before giving up, so a missing pm2 degrades only the ops plane's own
|
|
56
|
+
// startup, not clauth as a whole.
|
|
57
|
+
const pm2 = await ensurePm2();
|
|
58
|
+
const opsAdapter = createPm2Adapter(pm2);
|
|
59
|
+
const executePm2 = createSerializedExecutor();
|
|
60
|
+
const opsPolicy = createOperationPolicy({
|
|
61
|
+
enabled: String(process.env.CLAUTH_OPS_ENABLED || "").split(",").map((item) => item.trim()).filter(Boolean),
|
|
62
|
+
applications: (() => { try { return JSON.parse(process.env.CLAUTH_OPS_APPLICATIONS || "{}"); } catch { return {}; } })(),
|
|
63
|
+
adminEnabled: String(process.env.CLAUTH_OPS_ADMIN_ENABLED || "").split(",").map((item) => item.trim()).filter(Boolean),
|
|
64
|
+
adminApplications: (() => { try { return JSON.parse(process.env.CLAUTH_OPS_ADMIN_APPLICATIONS || "{}"); } catch { return {}; } })(),
|
|
65
|
+
allowHostWide: process.env.CLAUTH_OPS_ALLOW_HOST_WIDE === "1",
|
|
66
|
+
});
|
|
67
|
+
const opsJobs = createJobStore({
|
|
68
|
+
filePath: process.env.CLAUTH_OPS_JOB_STORE_PATH || path.join(getSupervisorDir(), "ops-jobs.json"),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
async function getLoopbackSecret(service) {
|
|
72
|
+
const response = await fetch(`http://127.0.0.1:${LIVE_PORT}/v/${encodeURIComponent(service)}`, { signal: AbortSignal.timeout(5000) });
|
|
73
|
+
if (!response.ok) throw new Error(`${service} unavailable from local clauth`);
|
|
74
|
+
const value = (await response.text()).trim();
|
|
75
|
+
if (!value) throw new Error(`${service} is empty`);
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
const coolify = createCoolifyAdapter({
|
|
79
|
+
baseUrl: process.env.CLAUTH_COOLIFY_BASE_URL || "https://deploy.regendevcorp.com",
|
|
80
|
+
getToken: () => getLoopbackSecret("coolify-api"),
|
|
81
|
+
});
|
|
82
|
+
const deployments = (() => { try { return parseDeploymentRegistry(process.env.CLAUTH_OPS_DEPLOYMENTS || "{}"); } catch { return {}; } })();
|
|
83
|
+
const deploymentAdapter = createDeploymentAdapter({
|
|
84
|
+
deployments,
|
|
85
|
+
reload: async (target) => {
|
|
86
|
+
await executePm2(async () => {
|
|
87
|
+
await opsAdapter.connect();
|
|
88
|
+
try { await opsAdapter.execute("reload", { target, options: { updateEnv: true } }); } finally { await opsAdapter.disconnect(); }
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Record an ops failure's upstream message to the LOCAL log only.
|
|
95
|
+
*
|
|
96
|
+
* job-store's sanitizer deliberately drops free-form `error` text so an
|
|
97
|
+
* upstream message cannot carry a credential into the persisted job file or
|
|
98
|
+
* the API response. That protection left every failure with an empty detail,
|
|
99
|
+
* so jobs reported `failed` with no reason at all. Jobs now carry an
|
|
100
|
+
* enumerated `code`; the underlying message goes here, to the same
|
|
101
|
+
* operator-only log as the rest of the daemon's diagnostics.
|
|
102
|
+
*/
|
|
103
|
+
function logOpsFailure(kind, operation, error) {
|
|
104
|
+
const message = String(error?.message || error || "unknown");
|
|
105
|
+
try {
|
|
106
|
+
fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] [OPS ${kind}/${operation}] ${message}\n`);
|
|
107
|
+
} catch {}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function opsBearerRole(req) {
|
|
111
|
+
const header = req.headers.authorization;
|
|
112
|
+
const supplied = Array.isArray(header) ? header[0] : header;
|
|
113
|
+
if (!supplied || !String(supplied).startsWith("Bearer ")) return null;
|
|
114
|
+
const actual = String(supplied).slice(7).trim();
|
|
115
|
+
let admin; let agent;
|
|
116
|
+
try { admin = await getLoopbackSecret(process.env.CLAUTH_OPS_ADMIN_TOKEN_SERVICE || "vultr-ops-admin-token"); } catch {}
|
|
117
|
+
try { agent = await getLoopbackSecret(process.env.CLAUTH_OPS_AGENT_TOKEN_SERVICE || "vultr-ops-api-token"); } catch {}
|
|
118
|
+
if (admin && agent && admin === agent) return null;
|
|
119
|
+
for (const [role, expected] of [["admin", admin], ["agent", agent]]) {
|
|
120
|
+
if (!expected) continue;
|
|
121
|
+
const a = Buffer.from(actual); const b = Buffer.from(expected);
|
|
122
|
+
if (a.length === b.length && crypto.timingSafeEqual(a, b)) return role;
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function requireOpsBearer(req, res) {
|
|
128
|
+
const role = await opsBearerRole(req);
|
|
129
|
+
if (role) { req._opsRole = role; return true; }
|
|
130
|
+
res.writeHead(401, { "Content-Type": "application/json", ...CORS });
|
|
131
|
+
res.end(JSON.stringify({ error: "ops_bearer_required" }));
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function submitOpsJob(operation, input, role = "agent") {
|
|
136
|
+
const authorization = opsPolicy.authorize(operation, input, role);
|
|
137
|
+
const job = opsJobs.create({ kind: "pm2", operation, target: input.target || input.name || null });
|
|
138
|
+
if (!authorization.ok) {
|
|
139
|
+
return opsJobs.event(job.id, "rejected", { code: authorization.code });
|
|
140
|
+
}
|
|
141
|
+
void (async () => {
|
|
142
|
+
opsJobs.event(job.id, "running");
|
|
143
|
+
try {
|
|
144
|
+
const result = await executePm2(async () => {
|
|
145
|
+
await opsAdapter.connect();
|
|
146
|
+
try { return await opsAdapter.execute(operation, input); } finally { await opsAdapter.disconnect(); }
|
|
147
|
+
});
|
|
148
|
+
opsJobs.event(job.id, "succeeded", operationReceipt(operation, result, authorization.allowed_targets || []));
|
|
149
|
+
} catch (error) {
|
|
150
|
+
// `error` alone is dropped by job-store's sanitizer (it refuses
|
|
151
|
+
// free-form upstream text so a credential cannot ride along), which
|
|
152
|
+
// left every failure with an empty detail. Emit an enumerated code so
|
|
153
|
+
// the failure has a reason; keep the message for the local log only.
|
|
154
|
+
logOpsFailure("pm2", operation, error);
|
|
155
|
+
opsJobs.event(job.id, "failed", { code: "pm2_operation_failed" });
|
|
156
|
+
}
|
|
157
|
+
})();
|
|
158
|
+
return opsJobs.get(job.id);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function operationReceipt(operation, result, allowedTargets) {
|
|
162
|
+
if (["list", "describe", "logs"].includes(operation)) {
|
|
163
|
+
const processes = Array.isArray(result)
|
|
164
|
+
? result.filter((process) => allowedTargets.includes("*") || allowedTargets.includes(process?.name))
|
|
165
|
+
: [];
|
|
166
|
+
return { processes };
|
|
167
|
+
}
|
|
168
|
+
if (operation === "ping") return { status: "connected" };
|
|
169
|
+
return { status: "completed" };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function submitPromotionJob(applicationUuid) {
|
|
173
|
+
const job = opsJobs.create({ kind: "coolify", operation: "promote", target: applicationUuid });
|
|
174
|
+
const enabled = String(process.env.CLAUTH_OPS_ENABLED || "").split(",").map((item) => item.trim()).includes("coolify_promote");
|
|
175
|
+
const allowlist = (() => { try { return JSON.parse(process.env.CLAUTH_COOLIFY_PROMOTE_UUIDS || "[]"); } catch { return []; } })();
|
|
176
|
+
if (!enabled || !Array.isArray(allowlist) || !allowlist.includes(applicationUuid)) {
|
|
177
|
+
return opsJobs.event(job.id, "rejected", { code: "service_not_available" });
|
|
178
|
+
}
|
|
179
|
+
void (async () => {
|
|
180
|
+
opsJobs.event(job.id, "running");
|
|
181
|
+
try {
|
|
182
|
+
const deployment = await coolify.promote(applicationUuid);
|
|
183
|
+
// Coolify answers with a `deployments` ARRAY, not a flat object — see
|
|
184
|
+
// deploymentUuidFrom. Reading the flat field alone marked the job failed
|
|
185
|
+
// while the deployment was actually running.
|
|
186
|
+
const deploymentUuid = deploymentUuidFrom(deployment);
|
|
187
|
+
if (!deploymentUuid) {
|
|
188
|
+
// The deploy request itself SUCCEEDED (no throw); only the UUID was
|
|
189
|
+
// unreadable, so the deployment may well be RUNNING. The code says so
|
|
190
|
+
// explicitly rather than a bare "failed", because a plain failure
|
|
191
|
+
// invites a retry and a duplicate production deploy.
|
|
192
|
+
//
|
|
193
|
+
// An enumerated code, not a free-form error: job-store's sanitizer
|
|
194
|
+
// drops `error` on purpose to keep upstream text (and any credential
|
|
195
|
+
// inside it) out of the persisted job.
|
|
196
|
+
return opsJobs.event(job.id, "failed", { code: "coolify_deploy_accepted_uuid_unreadable" });
|
|
197
|
+
}
|
|
198
|
+
opsJobs.event(job.id, "waiting", { deployment_uuid: deploymentUuid });
|
|
199
|
+
const terminal = await coolify.poll(deploymentUuid, { attempts: Number(process.env.CLAUTH_COOLIFY_POLL_ATTEMPTS || 60), delay: () => new Promise((resolve) => setTimeout(resolve, 5000)) });
|
|
200
|
+
opsJobs.event(job.id, terminal.state === "succeeded" ? "succeeded" : terminal.state, { deployment_uuid: deploymentUuid, status: terminal.deployment?.status || null });
|
|
201
|
+
} catch (error) {
|
|
202
|
+
// The throw may have happened AFTER Coolify accepted the deploy (e.g.
|
|
203
|
+
// the poll lost the network), so this is not proof nothing shipped.
|
|
204
|
+
logOpsFailure("coolify", "promote", error);
|
|
205
|
+
opsJobs.event(job.id, "failed", { code: "coolify_promote_failed" });
|
|
206
|
+
}
|
|
207
|
+
})();
|
|
208
|
+
return opsJobs.get(job.id);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function submitDeploymentJob(application, ref) {
|
|
212
|
+
const job = opsJobs.create({ kind: "deployment", operation: "deploy", target: application });
|
|
213
|
+
const enabled = String(process.env.CLAUTH_OPS_ENABLED || "").split(",").map((item) => item.trim()).includes("deploy");
|
|
214
|
+
if (!enabled || !deployments[application]) return opsJobs.event(job.id, "rejected", { code: "service_not_available" });
|
|
215
|
+
void (async () => {
|
|
216
|
+
opsJobs.event(job.id, "running");
|
|
217
|
+
try { opsJobs.event(job.id, "building"); opsJobs.event(job.id, "succeeded", { result: await deploymentAdapter.deploy({ application, ref }) }); }
|
|
218
|
+
catch (error) { logOpsFailure("deployment", "deploy", error); opsJobs.event(job.id, "failed", { code: "deployment_failed" }); }
|
|
219
|
+
})();
|
|
220
|
+
return opsJobs.get(job.id);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return http.createServer(async (req, res) => {
|
|
224
|
+
const remote = req.socket.remoteAddress;
|
|
225
|
+
if (!isLoopbackAddress(remote)) {
|
|
226
|
+
res.writeHead(403, { "Content-Type": "application/json", ...CORS });
|
|
227
|
+
return res.end(JSON.stringify({ error: "loopback_only" }));
|
|
228
|
+
}
|
|
229
|
+
if (req.method === "OPTIONS") {
|
|
230
|
+
res.writeHead(204, CORS);
|
|
231
|
+
return res.end();
|
|
232
|
+
}
|
|
233
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
234
|
+
const reqPath = url.pathname;
|
|
235
|
+
const method = req.method;
|
|
236
|
+
|
|
237
|
+
if (method === "GET" && reqPath === "/v1/ops/catalog") {
|
|
238
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
239
|
+
return ok(res, { schema: "clauth.ops.v1", operations: PM2_OPERATION_CATALOG });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (method === "GET" && reqPath === "/v1/ops/processes") {
|
|
243
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
244
|
+
const job = submitOpsJob("list", {}, req._opsRole);
|
|
245
|
+
res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
|
|
246
|
+
return res.end(JSON.stringify(job));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const opsProcessMatch = reqPath.match(/^\/v1\/ops\/processes\/([^/]+)$/);
|
|
250
|
+
if (method === "GET" && opsProcessMatch) {
|
|
251
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
252
|
+
const job = submitOpsJob("describe", { target: decodeURIComponent(opsProcessMatch[1]) }, req._opsRole);
|
|
253
|
+
res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
|
|
254
|
+
return res.end(JSON.stringify(job));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (method === "POST" && reqPath === "/v1/ops/operations") {
|
|
258
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
259
|
+
let body;
|
|
260
|
+
try { body = await readBody(req); } catch {
|
|
261
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
262
|
+
return res.end(JSON.stringify({ error: "invalid_json" }));
|
|
263
|
+
}
|
|
264
|
+
const operation = String(body?.operation || "");
|
|
265
|
+
if (!PM2_OPERATION_CATALOG[operation]) {
|
|
266
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
267
|
+
return res.end(JSON.stringify({ error: "unknown_operation" }));
|
|
268
|
+
}
|
|
269
|
+
const job = submitOpsJob(operation, body?.input && typeof body.input === "object" ? body.input : {}, req._opsRole);
|
|
270
|
+
res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
|
|
271
|
+
return res.end(JSON.stringify(job));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (method === "POST" && reqPath === "/v1/ops/promotions") {
|
|
275
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
276
|
+
let body;
|
|
277
|
+
try { body = await readBody(req); } catch {
|
|
278
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
279
|
+
return res.end(JSON.stringify({ error: "invalid_json" }));
|
|
280
|
+
}
|
|
281
|
+
const applicationUuid = String(body?.application_uuid || "").trim();
|
|
282
|
+
if (!applicationUuid) {
|
|
283
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
284
|
+
return res.end(JSON.stringify({ error: "application_uuid_required" }));
|
|
285
|
+
}
|
|
286
|
+
const job = submitPromotionJob(applicationUuid);
|
|
287
|
+
res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
|
|
288
|
+
return res.end(JSON.stringify(job));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (method === "POST" && reqPath === "/v1/ops/deployments") {
|
|
292
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
293
|
+
let body;
|
|
294
|
+
try { body = await readBody(req); } catch {
|
|
295
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
296
|
+
return res.end(JSON.stringify({ error: "invalid_json" }));
|
|
297
|
+
}
|
|
298
|
+
const application = String(body?.application || "").trim();
|
|
299
|
+
if (!application) {
|
|
300
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
301
|
+
return res.end(JSON.stringify({ error: "application_required" }));
|
|
302
|
+
}
|
|
303
|
+
const job = submitDeploymentJob(application, body?.ref ? String(body.ref) : undefined);
|
|
304
|
+
res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
|
|
305
|
+
return res.end(JSON.stringify(job));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const opsJobMatch = reqPath.match(/^\/v1\/ops\/jobs\/([^/]+)$/);
|
|
309
|
+
if (method === "GET" && opsJobMatch) {
|
|
310
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
311
|
+
const job = opsJobs.get(decodeURIComponent(opsJobMatch[1]));
|
|
312
|
+
res.writeHead(job ? 200 : 404, { "Content-Type": "application/json", ...CORS });
|
|
313
|
+
return res.end(JSON.stringify(job || { error: "job_not_found" }));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const opsJobEventsMatch = reqPath.match(/^\/v1\/ops\/jobs\/([^/]+)\/events$/);
|
|
317
|
+
if (method === "GET" && opsJobEventsMatch) {
|
|
318
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
319
|
+
const jobId = decodeURIComponent(opsJobEventsMatch[1]);
|
|
320
|
+
if (!opsJobs.get(jobId)) {
|
|
321
|
+
res.writeHead(404, { "Content-Type": "application/json", ...CORS });
|
|
322
|
+
return res.end(JSON.stringify({ error: "job_not_found" }));
|
|
323
|
+
}
|
|
324
|
+
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", ...CORS });
|
|
325
|
+
const unsubscribe = opsJobs.subscribe(jobId, (job) => {
|
|
326
|
+
if (!res.writableEnded) res.write(`event: job\ndata: ${JSON.stringify(job)}\n\n`);
|
|
327
|
+
});
|
|
328
|
+
req.on("close", unsubscribe);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
res.writeHead(404, { "Content-Type": "application/json", ...CORS });
|
|
333
|
+
res.end(JSON.stringify({ error: "not_found" }));
|
|
334
|
+
});
|
|
335
|
+
}
|