@lifeaitools/clauth 1.31.1 → 2.0.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.
@@ -0,0 +1,211 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import pm2 from "pm2";
5
+
6
+ const DEFAULT_PORT = 52439;
7
+ const DEFAULT_NAME = "clauth-deployment";
8
+ const INSTALLER_MARKER = "clauth.ops.install.v1";
9
+ const OPS_ENV_KEYS = new Set([
10
+ "CLAUTH_OPS_INSTALLER_MARKER", "CLAUTH_OPS_ENABLED", "CLAUTH_OPS_APPLICATIONS", "CLAUTH_OPS_ADMIN_ENABLED",
11
+ "CLAUTH_OPS_ADMIN_APPLICATIONS", "CLAUTH_OPS_ALLOW_HOST_WIDE", "CLAUTH_OPS_DEPLOYMENTS", "CLAUTH_COOLIFY_PROMOTE_UUIDS",
12
+ ]);
13
+ const RUNTIME_ENV_KEYS = new Set(["PATH", "Path", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "SystemRoot", "SYSTEMROOT", "ComSpec", "TMP", "TEMP"]);
14
+ const RESTORABLE_PM2_KEYS = ["node_args", "exec_mode", "instances", "namespace", "watch", "kill_retry_time", "merge_logs", "windowsHide", "stop_exit_codes"];
15
+
16
+ function requiredString(value, label) {
17
+ if (typeof value !== "string" || !value.trim()) throw new Error(`${label} is required`);
18
+ return value.trim();
19
+ }
20
+
21
+ function stringList(value, label) {
22
+ if (value == null) return [];
23
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string" && item.trim())) throw new Error(`${label} must be an array of non-empty strings`);
24
+ return value.map((item) => item.trim());
25
+ }
26
+
27
+ function applicationPolicy(value, label) {
28
+ if (value == null) return {};
29
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
30
+ return Object.fromEntries(Object.entries(value).map(([operation, targets]) => [operation, stringList(targets, `${label}.${operation}`)]));
31
+ }
32
+
33
+ /**
34
+ * An operation is only usable when it ALSO has a target allowlist.
35
+ *
36
+ * `createOperationPolicy` reads the allowlist from `applications[operation]` and
37
+ * rejects with `service_not_available` when it is empty (operation-policy.js,
38
+ * the `!allowed.length` guard). That code reads like the service is down, but
39
+ * it actually means "no targets configured" — so a config carrying only
40
+ * `agent_enabled` installs cleanly, passes the health and bearer-gate probes,
41
+ * and then rejects every authenticated call. That exact combination shipped to
42
+ * the canary and was misdiagnosed for a day as a missing credential.
43
+ *
44
+ * Read operations (`list`, `describe`, `ping`, `logs`, …) are affected too: the
45
+ * `!allowed.length` guard runs before the read/mutate distinction, so a read op
46
+ * with no allowlist is rejected just the same.
47
+ *
48
+ * Fail at install instead, naming the operations that would silently break.
49
+ */
50
+ function assertEnabledOperationsHaveTargets(enabled, applications, label) {
51
+ const missing = enabled.filter((operation) => !(applications[operation] || []).length);
52
+ if (!missing.length) return;
53
+ const applicationsLabel = label.replace("_enabled", "_applications");
54
+ throw new Error(
55
+ `${label}: ${missing.join(", ")} enabled but absent from ${applicationsLabel}. An enabled operation with no target allowlist is rejected at runtime with service_not_available. Add a target list (e.g. {"${missing[0]}": ["*"]}) or remove it from the enabled list.`,
56
+ );
57
+ }
58
+
59
+ export function loadOpsInstallConfig(configPath) {
60
+ const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
61
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("ops installer config must be a JSON object");
62
+ const port = raw.port == null ? DEFAULT_PORT : Number(raw.port);
63
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("port must be an integer from 1024 through 65535");
64
+ const pm2Name = raw.pm2_name == null ? DEFAULT_NAME : requiredString(raw.pm2_name, "pm2_name");
65
+ const operations = raw.operations && typeof raw.operations === "object" && !Array.isArray(raw.operations) ? raw.operations : {};
66
+
67
+ const enabled = stringList(operations.agent_enabled, "operations.agent_enabled");
68
+ const applications = applicationPolicy(operations.agent_applications, "operations.agent_applications");
69
+ const adminEnabled = stringList(operations.admin_enabled, "operations.admin_enabled");
70
+ const adminApplications = applicationPolicy(operations.admin_applications, "operations.admin_applications");
71
+
72
+ assertEnabledOperationsHaveTargets(enabled, applications, "operations.agent_enabled");
73
+ assertEnabledOperationsHaveTargets(adminEnabled, adminApplications, "operations.admin_enabled");
74
+
75
+ return {
76
+ port,
77
+ pm2Name,
78
+ enabled,
79
+ applications,
80
+ adminEnabled,
81
+ adminApplications,
82
+ allowHostWide: operations.allow_host_wide === true,
83
+ deployments: raw.deployments && typeof raw.deployments === "object" && !Array.isArray(raw.deployments) ? raw.deployments : {},
84
+ coolifyPromoteUuids: stringList(raw.coolify_promote_uuids, "coolify_promote_uuids"),
85
+ };
86
+ }
87
+
88
+ export function buildOpsServiceSpec({ config, rootDir }) {
89
+ const cli = path.join(rootDir, "cli", "index.js");
90
+ const runtimeEnv = Object.fromEntries([...RUNTIME_ENV_KEYS].flatMap((key) => process.env[key] == null ? [] : [[key, process.env[key]]]));
91
+ return {
92
+ name: config.pm2Name,
93
+ script: cli,
94
+ args: `serve foreground --port ${config.port} --isolated`,
95
+ cwd: rootDir,
96
+ autorestart: true,
97
+ restart_delay: 1000,
98
+ max_restarts: 20,
99
+ // Do not inherit the invoking agent or shell environment: PM2 otherwise
100
+ // stores every inherited value in its process metadata.
101
+ // PM2 only activates filtering for a non-empty array. Exclude every
102
+ // inherited key, then explicitly add the small runtime allowlist above.
103
+ filter_env: Object.keys(process.env),
104
+ env: {
105
+ ...runtimeEnv,
106
+ CLAUTH_OPS_INSTALLER_MARKER: INSTALLER_MARKER,
107
+ CLAUTH_OPS_ENABLED: [...config.enabled, ...(Object.keys(config.deployments).length ? ["deploy"] : []), ...(config.coolifyPromoteUuids.length ? ["coolify_promote"] : [])].join(","),
108
+ CLAUTH_OPS_APPLICATIONS: JSON.stringify(config.applications),
109
+ CLAUTH_OPS_ADMIN_ENABLED: config.adminEnabled.join(","),
110
+ CLAUTH_OPS_ADMIN_APPLICATIONS: JSON.stringify(config.adminApplications),
111
+ CLAUTH_OPS_ALLOW_HOST_WIDE: config.allowHostWide ? "1" : "0",
112
+ CLAUTH_OPS_DEPLOYMENTS: JSON.stringify(config.deployments),
113
+ CLAUTH_COOLIFY_PROMOTE_UUIDS: JSON.stringify(config.coolifyPromoteUuids),
114
+ },
115
+ };
116
+ }
117
+
118
+ function pm2Call(method, ...args) {
119
+ return new Promise((resolve, reject) => pm2[method](...args, (error, value) => error ? reject(error) : resolve(value)));
120
+ }
121
+
122
+ async function probe(endpoint) {
123
+ const health = await fetch(`${endpoint}/health`, { signal: AbortSignal.timeout(5000) });
124
+ const healthBody = health.ok ? await health.json() : null;
125
+ if (healthBody?.status !== "ok") throw new Error(`health probe failed at ${endpoint}`);
126
+ const catalog = await fetch(`${endpoint}/v1/ops/catalog`, { signal: AbortSignal.timeout(5000) });
127
+ if (catalog.status !== 401) throw new Error(`bearer gate probe failed: expected HTTP 401, received ${catalog.status}`);
128
+ return { health_status: health.status, bearer_gate_status: catalog.status, listening_port: healthBody.listening_port, process_id: healthBody.process_id };
129
+ }
130
+
131
+ function argsFor(entry) {
132
+ const value = entry?.pm2_env?.args ?? entry?.args ?? "";
133
+ return Array.isArray(value) ? value.join(" ") : String(value || "");
134
+ }
135
+
136
+ function ownedService(entry, spec) {
137
+ const env = entry?.pm2_env || {};
138
+ return env.CLAUTH_OPS_INSTALLER_MARKER === INSTALLER_MARKER
139
+ && path.resolve(env.pm_exec_path || "") === path.resolve(spec.script)
140
+ && path.resolve(env.pm_cwd || "") === path.resolve(spec.cwd)
141
+ && argsFor(entry) === spec.args;
142
+ }
143
+
144
+ export function previousSpec(entry) {
145
+ const env = entry.pm2_env || {};
146
+ const restored = {
147
+ name: env.name,
148
+ script: env.pm_exec_path,
149
+ args: argsFor(entry),
150
+ cwd: env.pm_cwd,
151
+ autorestart: env.autorestart !== false,
152
+ restart_delay: env.restart_delay || 1000,
153
+ max_restarts: env.max_restarts || 20,
154
+ filter_env: Object.keys(process.env),
155
+ env: Object.fromEntries(Object.entries(env).filter(([key]) => OPS_ENV_KEYS.has(key) || RUNTIME_ENV_KEYS.has(key))),
156
+ };
157
+ if (env.exec_interpreter !== undefined) restored.interpreter = env.exec_interpreter;
158
+ for (const key of RESTORABLE_PM2_KEYS) if (env[key] !== undefined) restored[key] = env[key];
159
+ return restored;
160
+ }
161
+
162
+ async function rollback(previous, name) {
163
+ try { await pm2Call("delete", name); } catch {}
164
+ if (previous) await pm2Call("start", previous);
165
+ }
166
+
167
+ export async function runOpsInstall(opts = {}) {
168
+ const configPath = path.resolve(requiredString(opts.config, "--config"));
169
+ const config = loadOpsInstallConfig(configPath);
170
+ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
171
+ const spec = buildOpsServiceSpec({ config, rootDir });
172
+ const endpoint = `http://127.0.0.1:${config.port}`;
173
+ if (opts.dryRun) {
174
+ process.stdout.write(`${JSON.stringify({ action: "install", dry_run: true, platform: process.platform, endpoint, pm2_name: config.pm2Name, policy_file: configPath, operations: Object.keys(config.applications).sort() }, null, 2)}\n`);
175
+ return;
176
+ }
177
+ await new Promise((resolve, reject) => pm2.connect(false, (error) => error ? reject(error) : resolve()));
178
+ let result;
179
+ let previous = null;
180
+ try {
181
+ const existing = (await pm2Call("list")).find((entry) => (entry.name || entry.pm2_env?.name) === config.pm2Name);
182
+ // PM2 rejects an inline `env` object on restart-by-name. Replacing only
183
+ // this dedicated control-plane process is the supported way to apply a
184
+ // changed server policy without touching any managed application.
185
+ if (existing && !ownedService(existing, spec)) throw new Error(`refusing to replace non-installer PM2 process: ${config.pm2Name}`);
186
+ previous = existing ? previousSpec(existing) : null;
187
+ if (existing) await pm2Call("delete", config.pm2Name);
188
+ await pm2Call("start", spec);
189
+ for (let attempt = 0; attempt < 20; attempt += 1) {
190
+ try {
191
+ result = await probe(endpoint);
192
+ const current = (await pm2Call("list")).find((entry) => (entry.name || entry.pm2_env?.name) === config.pm2Name);
193
+ const pid = current?.pid ?? current?.pm2_env?.pm_pid;
194
+ const status = current?.pm2_env?.status ?? current?.status;
195
+ if (status !== "online" || !Number.isInteger(pid) || result.process_id !== pid || result.listening_port !== config.port) {
196
+ throw new Error("new PM2 process does not own the verified control-plane endpoint");
197
+ }
198
+ break;
199
+ } catch (error) {
200
+ if (attempt === 19) throw error;
201
+ await new Promise((resolve) => setTimeout(resolve, 250));
202
+ }
203
+ }
204
+ } catch (error) {
205
+ await rollback(previous, config.pm2Name);
206
+ throw error;
207
+ } finally {
208
+ await new Promise((resolve) => pm2.disconnect(() => resolve()));
209
+ }
210
+ process.stdout.write(`${JSON.stringify({ action: "install", platform: process.platform, endpoint, pm2_name: config.pm2Name, policy_file: configPath, health_status: result.health_status, bearer_gate_status: result.bearer_gate_status }, null, 2)}\n`);
211
+ }
@@ -0,0 +1,69 @@
1
+ const DEFAULT_LOCAL_CLAUTH = "http://127.0.0.1:52437";
2
+
3
+ function endpointFrom(opts) {
4
+ const approved = process.env.CLAUTH_OPS_APPROVED_ORIGIN;
5
+ if (!approved) throw new Error("Ops endpoint unavailable: CLAUTH_OPS_APPROVED_ORIGIN is required");
6
+ const approvedUrl = new URL(approved);
7
+ const requested = opts.endpoint ? new URL(opts.endpoint) : approvedUrl;
8
+ if (approvedUrl.protocol !== "https:" || requested.origin !== approvedUrl.origin) throw new Error("Ops endpoint is not the approved Vultr origin");
9
+ return approvedUrl.toString().replace(/\/$/, "");
10
+ }
11
+
12
+ async function bearer(service = process.env.CLAUTH_OPS_TOKEN_SERVICE || "vultr-ops-api-token") {
13
+ const response = await fetch(`${process.env.CLAUTH_LOCAL_URL || DEFAULT_LOCAL_CLAUTH}/v/${encodeURIComponent(service)}`);
14
+ if (!response.ok) throw new Error(`${service} unavailable from local clauth`);
15
+ const token = (await response.text()).trim();
16
+ if (!token) throw new Error("vultr-ops-api-token is empty");
17
+ return token;
18
+ }
19
+
20
+ export async function requestOps({ endpoint, token, method, path, body }) {
21
+ const response = await fetch(`${endpoint}${path}`, {
22
+ method,
23
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json", ...(body ? { "Content-Type": "application/json" } : {}) },
24
+ ...(body ? { body: JSON.stringify(body) } : {}),
25
+ });
26
+ const payload = await response.json().catch(() => ({ error: "invalid_json_response" }));
27
+ if (!response.ok) throw new Error(payload.error || `ops HTTP ${response.status}`);
28
+ return payload;
29
+ }
30
+
31
+ function print(value) {
32
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
33
+ }
34
+
35
+ export async function runOps(action, opts = {}) {
36
+ const endpoint = endpointFrom(opts);
37
+ const token = await bearer(opts.tokenService);
38
+ const request = (method, path, body) => requestOps({ endpoint, token, method, path, body });
39
+ if (action === "catalog") return print(await request("GET", "/v1/ops/catalog"));
40
+ if (action === "list") return print(await request("GET", "/v1/ops/processes"));
41
+ if (action === "describe") {
42
+ if (!opts.target) throw new Error("--target is required for describe");
43
+ return print(await request("GET", `/v1/ops/processes/${encodeURIComponent(opts.target)}`));
44
+ }
45
+ if (action === "run") {
46
+ if (!opts.operation) throw new Error("--operation is required for run");
47
+ const input = {
48
+ ...(opts.target ? { target: opts.target } : {}),
49
+ ...(opts.script ? { script: opts.script } : {}),
50
+ ...(opts.instances ? { instances: Number(opts.instances) } : {}),
51
+ ...(opts.argsJson ? { args: JSON.parse(opts.argsJson) } : {}),
52
+ ...(opts.optionsJson ? { options: JSON.parse(opts.optionsJson) } : {}),
53
+ };
54
+ return print(await request("POST", "/v1/ops/operations", { operation: opts.operation, input }));
55
+ }
56
+ if (action === "promote") {
57
+ if (!opts.application) throw new Error("--application is required for promote");
58
+ return print(await request("POST", "/v1/ops/promotions", { application_uuid: opts.application }));
59
+ }
60
+ if (action === "deploy") {
61
+ if (!opts.application) throw new Error("--application is required for deploy");
62
+ return print(await request("POST", "/v1/ops/deployments", { application: opts.application, ...(opts.ref ? { ref: opts.ref } : {}) }));
63
+ }
64
+ if (action === "job") {
65
+ if (!opts.job) throw new Error("--job is required for job");
66
+ return print(await request("GET", `/v1/ops/jobs/${encodeURIComponent(opts.job)}`));
67
+ }
68
+ throw new Error(`unknown ops action: ${action}`);
69
+ }