@lifeaitools/clauth 2.10.1 → 2.15.2
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/operator-guide.md +2 -12
- package/cli/commands/serve.js +204 -15
- package/cli/http/components/callagent-terminal-component.js +5 -5
- package/cli/http/components/mcp-transport-component.js +10 -17
- package/cli/http/components/oauth-component.js +74 -40
- package/cli/http/components/supervisor-component.js +18 -14
- package/cli/http/components/test-static-oauth-component.js +233 -0
- package/cli/index.js +9 -17
- package/cli/services/file-io.js +21 -3
- package/cli/services/file-io.test.js +33 -1
- package/cli/supervisor-registry.js +644 -59
- package/cli/supervisor-registry.test.js +568 -28
- package/cli/watchdog-registry.js +9 -0
- package/package.json +2 -1
- package/scripts/bin/bootstrap-linux +0 -0
- package/scripts/bin/bootstrap-macos +0 -0
- package/scripts/bin/bootstrap-win.exe +0 -0
- package/supabase/functions/auth-vault/index.ts +38 -49
- package/supabase/migrations/004_remove_audit_and_rate_limiting.sql +29 -0
package/cli/watchdog-registry.js
CHANGED
|
@@ -66,6 +66,15 @@ function validateCommand(command, field) {
|
|
|
66
66
|
export function validateWatchdogService(service) {
|
|
67
67
|
if (!service || typeof service !== "object") throw new Error("service must be an object");
|
|
68
68
|
if (!service.id || typeof service.id !== "string") throw new Error("service.id is required");
|
|
69
|
+
// rdc:review finding (2026-09-02): shaped identically to the id checks in
|
|
70
|
+
// cli/supervisor-registry.js (validatePluginManifest, normalizeSurface),
|
|
71
|
+
// which also reject Windows-reserved device names (con/nul/aux/prn/
|
|
72
|
+
// com1-9/lpt1-9) at registration. NOT extended here deliberately: unlike
|
|
73
|
+
// those, service.id is not used to derive a filesystem path anywhere in
|
|
74
|
+
// this file today, so there is nothing for a reserved name to collide
|
|
75
|
+
// with. Revisit if a future change starts deriving a per-service path from
|
|
76
|
+
// service.id -- that would reintroduce the same defect class this comment
|
|
77
|
+
// exists to flag before it does.
|
|
69
78
|
if (!/^[a-zA-Z0-9_.-]+$/.test(service.id)) throw new Error("service.id may contain only letters, numbers, dot, underscore, and dash");
|
|
70
79
|
if (!service.label || typeof service.label !== "string") throw new Error("service.label is required");
|
|
71
80
|
if (!VALID_KINDS.has(service.kind)) throw new Error(`service.kind must be one of ${[...VALID_KINDS].join(", ")}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lifeaitools/clauth",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.15.2",
|
|
4
4
|
"description": "Hardware-bound credential vault for the LIFEAI infrastructure stack",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"node-fetch": "^3.3.2",
|
|
29
29
|
"ora": "^8.1.0",
|
|
30
30
|
"pm2": "^7.0.3",
|
|
31
|
+
"proper-lockfile": "^4.1.2",
|
|
31
32
|
"typescript": "^5.9.3"
|
|
32
33
|
},
|
|
33
34
|
"engines": {
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
// clauth — auth-vault Edge Function
|
|
2
|
-
//
|
|
1
|
+
// clauth — auth-vault Edge Function v3
|
|
2
|
+
// IP whitelist + machine lockout (fail_count + locked) remain. Rate limiting
|
|
3
|
+
// and audit logging (clauth_audit) removed 2026-09-03 -- see the comments at
|
|
4
|
+
// validateHMAC and where auditLog used to be defined for why: the rate-limit
|
|
5
|
+
// check was an unindexed COUNT against clauth_audit run on EVERY request,
|
|
6
|
+
// pre-auth, and every rejection it produced also wrote an audit row -- a
|
|
7
|
+
// feedback loop that took the whole project down under sustained load. The
|
|
8
|
+
// security value it provided was already covered, tighter, by the
|
|
9
|
+
// per-machine 5-failed-attempts lockout below.
|
|
3
10
|
|
|
4
11
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
|
5
12
|
|
|
@@ -11,8 +18,6 @@ const ADMIN_BOOTSTRAP_TOKEN = Deno.env.get("CLAUTH_ADMIN_BOOTSTRAP_TOKEN")!;
|
|
|
11
18
|
const ALLOWED_IPS: string[] = (Deno.env.get("CLAUTH_ALLOWED_IPS") || "")
|
|
12
19
|
.split(",").map(s => s.trim()).filter(Boolean);
|
|
13
20
|
|
|
14
|
-
const RATE_LIMIT_MAX = 30;
|
|
15
|
-
const RATE_LIMIT_WINDOW = 60;
|
|
16
21
|
const REPLAY_WINDOW_MS = 5 * 60 * 1000;
|
|
17
22
|
const MAX_FAIL_COUNT = 5;
|
|
18
23
|
const DEFAULT_INSTALL_ID = "default";
|
|
@@ -55,18 +60,22 @@ function checkIP(ip: string): { allowed: boolean; reason?: string } {
|
|
|
55
60
|
return { allowed: false, reason: `IP not whitelisted: ${ip}` };
|
|
56
61
|
}
|
|
57
62
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
// rate-limiting removed 2026-09-03 -- see supervisor-registry.js's own
|
|
64
|
+
// state.json lock history for the shape of this bug: checkRateLimit() ran
|
|
65
|
+
// this exact query -- a COUNT against clauth_audit filtered by machine_hash
|
|
66
|
+
// and created_at -- on EVERY request, BEFORE authentication (validateHMAC
|
|
67
|
+
// below), against a table with no index on either column and no retention.
|
|
68
|
+
// As the table grew (174,385 rows, unbounded), the scan cost grew with it;
|
|
69
|
+
// under sustained traffic the queries started stacking, Postgres killed them
|
|
70
|
+
// at statement_timeout, and Cloudflare killed the stacked connections at its
|
|
71
|
+
// own 90s ceiling (522s) -- and every one of those rejections ALSO wrote an
|
|
72
|
+
// audit row (see auditLog's removal below), so the failure fed itself. The
|
|
73
|
+
// actual security protection this duplicated is already provided, tighter,
|
|
74
|
+
// by the per-machine lockout in validateHMAC below (5 failed attempts locks
|
|
75
|
+
// the machine -- well under the 30-per-60s this used to allow). Rate
|
|
76
|
+
// limiting for a genuine runaway client belongs at Cloudflare, in front of
|
|
77
|
+
// this function, not as a synchronous Postgres query on the hot path of
|
|
78
|
+
// every request.
|
|
70
79
|
async function validateHMAC(sb: any, body: any): Promise<{ valid: boolean; reason?: string }> {
|
|
71
80
|
const now = Date.now();
|
|
72
81
|
if (Math.abs(now - body.timestamp) > REPLAY_WINDOW_MS) return { valid: false, reason: "timestamp_expired" };
|
|
@@ -99,20 +108,23 @@ async function validateHMAC(sb: any, body: any): Promise<{ valid: boolean; reaso
|
|
|
99
108
|
return { valid: true };
|
|
100
109
|
}
|
|
101
110
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
111
|
+
// auditLog() removed 2026-09-03 alongside checkRateLimit() -- it wrote a row
|
|
112
|
+
// to clauth_audit on every path through this function, including rejections
|
|
113
|
+
// (rate-limited, auth-denied, IP-blocked), which is what turned "the table
|
|
114
|
+
// got slow" into a feedback loop (more rejections -> more rows -> slower
|
|
115
|
+
// scans -> more rejections). No retention policy ever existed for this
|
|
116
|
+
// table. If durable audit logging is wanted back, it needs its own design --
|
|
117
|
+
// write-only, indexed for its actual read pattern (if any), with a real
|
|
118
|
+
// retention/partition policy -- not a bare insert-on-every-call with no cap.
|
|
106
119
|
async function handleRetrieve(sb: any, body: any, mh: string) {
|
|
107
120
|
const { service } = body;
|
|
108
121
|
if (!service) return { error: "service required" };
|
|
109
122
|
const { data: svc } = await sb.from("clauth_services").select("*").eq("name", service).single();
|
|
110
|
-
if (!svc)
|
|
111
|
-
if (!svc.enabled)
|
|
112
|
-
if (!svc.vault_key)
|
|
123
|
+
if (!svc) return { error: "service_not_found" };
|
|
124
|
+
if (!svc.enabled) return { error: "service_disabled" };
|
|
125
|
+
if (!svc.vault_key) return { error: "no_key_stored" };
|
|
113
126
|
const { data: secret } = await sb.rpc("vault_decrypt_secret", { secret_name: svc.vault_key });
|
|
114
127
|
await sb.from("clauth_services").update({ last_retrieved: new Date().toISOString() }).eq("name", service);
|
|
115
|
-
await auditLog(sb, mh, service, "retrieve", "success");
|
|
116
128
|
return { service, key_type: svc.key_type, value: secret };
|
|
117
129
|
}
|
|
118
130
|
|
|
@@ -121,9 +133,8 @@ async function handleWrite(sb: any, body: any, mh: string) {
|
|
|
121
133
|
if (!service || !value) return { error: "service and value required" };
|
|
122
134
|
const vaultKey = `clauth.${service}`;
|
|
123
135
|
const { error } = await sb.rpc("vault_upsert_secret", { secret_name: vaultKey, secret_value: typeof value === "string" ? value : JSON.stringify(value) });
|
|
124
|
-
if (error)
|
|
136
|
+
if (error) return { error: error.message };
|
|
125
137
|
await sb.from("clauth_services").update({ vault_key: vaultKey, last_rotated: new Date().toISOString() }).eq("name", service);
|
|
126
|
-
await auditLog(sb, mh, service, "write", "success");
|
|
127
138
|
return { success: true, service, vault_key: vaultKey };
|
|
128
139
|
}
|
|
129
140
|
|
|
@@ -133,7 +144,6 @@ async function handleEnable(sb: any, body: any, mh: string) {
|
|
|
133
144
|
q = service !== "all" ? q.eq("name", service) : q.not("vault_key", "is", null);
|
|
134
145
|
const { error } = await q;
|
|
135
146
|
if (error) return { error: error.message };
|
|
136
|
-
await auditLog(sb, mh, service, enabled ? "enable" : "disable", "success");
|
|
137
147
|
return { success: true, service, enabled };
|
|
138
148
|
}
|
|
139
149
|
|
|
@@ -144,7 +154,6 @@ async function handleAdd(sb: any, body: any, mh: string) {
|
|
|
144
154
|
if (project) row.project = project;
|
|
145
155
|
const { error } = await sb.from("clauth_services").insert(row);
|
|
146
156
|
if (error) return { error: error.message };
|
|
147
|
-
await auditLog(sb, mh, name, "add", "success");
|
|
148
157
|
return { success: true, name, label, key_type, project: project || null };
|
|
149
158
|
}
|
|
150
159
|
|
|
@@ -157,7 +166,6 @@ async function handleUpdate(sb: any, body: any, mh: string) {
|
|
|
157
166
|
if (description !== undefined) updates.description = description || null;
|
|
158
167
|
const { error } = await sb.from("clauth_services").update(updates).eq("name", service);
|
|
159
168
|
if (error) return { error: error.message };
|
|
160
|
-
await auditLog(sb, mh, service, "update", "success", `fields: ${Object.keys(updates).join(", ")}`);
|
|
161
169
|
return { success: true, service, ...updates };
|
|
162
170
|
}
|
|
163
171
|
|
|
@@ -166,7 +174,6 @@ async function handleRemove(sb: any, body: any, mh: string) {
|
|
|
166
174
|
if (confirm !== `CONFIRM REMOVE ${service.toUpperCase()}`) return { error: "confirm phrase mismatch" };
|
|
167
175
|
await sb.rpc("vault_delete_secret", { secret_name: `clauth.${service}` });
|
|
168
176
|
await sb.from("clauth_services").delete().eq("name", service);
|
|
169
|
-
await auditLog(sb, mh, service, "remove", "success");
|
|
170
177
|
return { success: true, service };
|
|
171
178
|
}
|
|
172
179
|
|
|
@@ -182,7 +189,6 @@ async function handleRevoke(sb: any, body: any, mh: string) {
|
|
|
182
189
|
await sb.rpc("vault_delete_secret", { secret_name: `clauth.${service}` });
|
|
183
190
|
await sb.from("clauth_services").update({ vault_key: null, enabled: false }).eq("name", service);
|
|
184
191
|
}
|
|
185
|
-
await auditLog(sb, mh, service, "revoke", "success");
|
|
186
192
|
return { success: true, service };
|
|
187
193
|
}
|
|
188
194
|
|
|
@@ -193,7 +199,6 @@ async function handleStatus(sb: any, body: any, mh: string) {
|
|
|
193
199
|
.order("name");
|
|
194
200
|
if (body.project) q = q.eq("project", body.project);
|
|
195
201
|
const { data: services } = await q;
|
|
196
|
-
await auditLog(sb, mh, "all", "status", "success");
|
|
197
202
|
return { services: services || [] };
|
|
198
203
|
}
|
|
199
204
|
|
|
@@ -204,7 +209,6 @@ async function handleChangePassword(sb: any, body: any, mh: string) {
|
|
|
204
209
|
.update({ hmac_seed_hash: new_hmac_seed_hash, fail_count: 0, locked: false })
|
|
205
210
|
.eq("machine_hash", mh);
|
|
206
211
|
if (error) return { error: error.message };
|
|
207
|
-
await auditLog(sb, mh, "system", "change-password", "success");
|
|
208
212
|
return { success: true };
|
|
209
213
|
}
|
|
210
214
|
|
|
@@ -231,12 +235,8 @@ async function handleCreateEnrollment(sb: any, body: any, mh: string) {
|
|
|
231
235
|
created_by_machine_hash: mh,
|
|
232
236
|
expires_at,
|
|
233
237
|
});
|
|
234
|
-
if (error) {
|
|
235
|
-
await auditLog(sb, mh, "system", "create-enrollment", "fail", error.message);
|
|
236
|
-
return { error: error.message };
|
|
237
|
-
}
|
|
238
|
+
if (error) return { error: error.message };
|
|
238
239
|
|
|
239
|
-
await auditLog(sb, mh, "system", "create-enrollment", "success", `install_id=${install_id}`);
|
|
240
240
|
return { success: true, enrollment_code: code, install_id, expires_at, label };
|
|
241
241
|
}
|
|
242
242
|
|
|
@@ -274,7 +274,6 @@ async function handleRedeemEnrollment(sb: any, body: any) {
|
|
|
274
274
|
if (consumeError) return { error: consumeError.message };
|
|
275
275
|
if (!consumedRows || consumedRows.length !== 1) return { error: "enrollment_already_used" };
|
|
276
276
|
|
|
277
|
-
await auditLog(sb, machine_hash, "system", "redeem-enrollment", "success", `install_id=${install_id}`);
|
|
278
277
|
return { success: true, machine_hash, install_id };
|
|
279
278
|
}
|
|
280
279
|
|
|
@@ -314,21 +313,11 @@ Deno.serve(async (req: Request) => {
|
|
|
314
313
|
|
|
315
314
|
const ipCheck = checkIP(ip);
|
|
316
315
|
if (!ipCheck.allowed) {
|
|
317
|
-
await auditLog(sb, body.machine_hash || "unknown", "system", route, "blocked", ipCheck.reason);
|
|
318
316
|
return Response.json({ error: "ip_blocked", reason: ipCheck.reason }, { status: 403 });
|
|
319
317
|
}
|
|
320
318
|
|
|
321
|
-
if (body.machine_hash) {
|
|
322
|
-
const rateCheck = await checkRateLimit(sb, body.machine_hash);
|
|
323
|
-
if (!rateCheck.allowed) {
|
|
324
|
-
await auditLog(sb, body.machine_hash, "system", route, "rate_limited", rateCheck.reason);
|
|
325
|
-
return Response.json({ error: "rate_limited", reason: rateCheck.reason }, { status: 429 });
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
319
|
const authResult = await validateHMAC(sb, { machine_hash: body.machine_hash, token: body.token, timestamp: body.timestamp, password: body.password });
|
|
330
320
|
if (!authResult.valid) {
|
|
331
|
-
await auditLog(sb, body.machine_hash || "unknown", body.service || "unknown", route, "denied", authResult.reason);
|
|
332
321
|
return Response.json({ error: "auth_failed", reason: authResult.reason }, { status: 401 });
|
|
333
322
|
}
|
|
334
323
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
-- Migration: remove clauth_audit and the rate-limiting it backed
|
|
2
|
+
-- Applied live to the LIFEAI project 2026-09-03; this file makes a fresh
|
|
3
|
+
-- `supabase db push` (a new operator following the setup guide) match that
|
|
4
|
+
-- same state instead of creating a table the current auth-vault code never
|
|
5
|
+
-- reads or writes.
|
|
6
|
+
--
|
|
7
|
+
-- Root cause of the 2026-09-03 incident this closes: checkRateLimit() in
|
|
8
|
+
-- supabase/functions/auth-vault/index.ts ran an unindexed COUNT against
|
|
9
|
+
-- clauth_audit, filtered by machine_hash + created_at, on EVERY request,
|
|
10
|
+
-- BEFORE authentication. As the table grew unbounded (no retention policy
|
|
11
|
+
-- ever existed), the scan cost grew with it; under sustained traffic the
|
|
12
|
+
-- queries stacked, Postgres killed them at statement_timeout, and Cloudflare
|
|
13
|
+
-- killed the stacked connections at its own 90s ceiling (522s). Every one of
|
|
14
|
+
-- those rejections also wrote an audit row via auditLog(), which fed the
|
|
15
|
+
-- same table the check was scanning -- a self-reinforcing feedback loop.
|
|
16
|
+
--
|
|
17
|
+
-- The security value the rate limiter provided was already covered, tighter,
|
|
18
|
+
-- by the per-machine lockout in validateHMAC (clauth_machines.fail_count /
|
|
19
|
+
-- .locked -- 5 failed attempts locks the machine, well under the 30-per-60s
|
|
20
|
+
-- window this used to allow). Rate limiting for a genuine runaway client
|
|
21
|
+
-- belongs at Cloudflare, in front of this function, not as a synchronous
|
|
22
|
+
-- Postgres query on the hot path of every request.
|
|
23
|
+
--
|
|
24
|
+
-- Confirmed before dropping the live table: no function in pg_proc
|
|
25
|
+
-- referenced clauth_audit in its source, and no trigger targeted it --
|
|
26
|
+
-- auth-vault's own auditLog() (now removed from the deployed function) was
|
|
27
|
+
-- its only writer, and RLS (no_anon_audit, see 001_clauth_schema.sql) meant
|
|
28
|
+
-- no client ever had direct write access either.
|
|
29
|
+
DROP TABLE IF EXISTS public.clauth_audit CASCADE;
|