@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.
Files changed (98) hide show
  1. package/.clauth-skill/references/keys-guide.md +270 -270
  2. package/cli/api.js +238 -238
  3. package/cli/commands/install.js +413 -396
  4. package/cli/commands/login.test.js +49 -1
  5. package/cli/commands/ops-install.js +11 -10
  6. package/cli/commands/ops.test.js +270 -0
  7. package/cli/commands/scrub.test.js +70 -0
  8. package/cli/commands/serve.js +2613 -11219
  9. package/cli/commands/uninstall.js +164 -164
  10. package/cli/dashboard/dashboard.js +592 -0
  11. package/cli/dashboard/global.css +65 -0
  12. package/cli/dashboard/panel-element.js +79 -0
  13. package/cli/dashboard/panels/build-status-panel.css +12 -0
  14. package/cli/dashboard/panels/build-status-panel.js +98 -0
  15. package/cli/dashboard/panels/panel-registry.js +58 -0
  16. package/cli/dashboard/panels/services-panel.css +95 -0
  17. package/cli/dashboard/panels/services-panel.js +872 -0
  18. package/cli/dashboard/panels/surfaces-panel.css +59 -0
  19. package/cli/dashboard/panels/surfaces-panel.js +474 -0
  20. package/cli/dashboard/panels/toolbar-panel.css +42 -0
  21. package/cli/dashboard/panels/toolbar-panel.js +374 -0
  22. package/cli/dashboard/panels/tunnel-panel.css +61 -0
  23. package/cli/dashboard/panels/tunnel-panel.js +688 -0
  24. package/cli/dashboard/panels/webdav-panel.css +38 -0
  25. package/cli/dashboard/panels/webdav-panel.js +166 -0
  26. package/cli/fingerprint.test.js +95 -0
  27. package/cli/http/components/callagent-terminal-component.js +310 -0
  28. package/cli/http/components/dashboard-component.js +123 -0
  29. package/cli/http/components/inbox-component.js +41 -0
  30. package/cli/http/components/mcp-transport-component.js +275 -0
  31. package/cli/http/components/oauth-component.js +207 -0
  32. package/cli/http/components/service-crud-component.js +288 -0
  33. package/cli/http/components/supervisor-component.js +104 -0
  34. package/cli/http/components/system-component.js +184 -0
  35. package/cli/http/components/tunnel-component.js +508 -0
  36. package/cli/http/components/vault-auth-component.js +264 -0
  37. package/cli/http/components/watchdog-component.js +28 -0
  38. package/cli/http/request-utils.js +21 -0
  39. package/cli/http/servers/ops-server.js +335 -0
  40. package/cli/index.js +5 -91
  41. package/cli/lib/fs-git.test.js +467 -0
  42. package/cli/mcp/providers/call-agent-provider.js +30 -0
  43. package/cli/mcp/providers/clauth-core-provider.js +461 -0
  44. package/cli/mcp/providers/ops-provider.js +42 -0
  45. package/cli/mcp/providers/terminal-provider.js +81 -0
  46. package/cli/mcp/tool-registry.js +48 -0
  47. package/cli/mcp/tool-schemas.js +822 -0
  48. package/cli/mcp/tool-schemas.test.js +49 -0
  49. package/cli/ops/pm2-preflight.js +54 -0
  50. package/cli/recovery.js +6 -0
  51. package/cli/recovery.test.js +179 -0
  52. package/cli/services/agent-pool-instance.js +38 -0
  53. package/cli/services/claude-binary.js +29 -0
  54. package/cli/services/delegation-lane-instance.js +35 -0
  55. package/cli/services/delegation-lane-instance.test.js +37 -0
  56. package/cli/services/file-io.js +38 -0
  57. package/cli/services/file-io.test.js +10 -0
  58. package/cli/services/fs-mount.js +66 -0
  59. package/cli/services/fs-mount.test.js +14 -0
  60. package/cli/services/fs-upload-sessions.js +20 -0
  61. package/cli/services/fs-upload-sessions.test.js +14 -0
  62. package/cli/services/git-exec.js +37 -0
  63. package/cli/services/git-exec.test.js +41 -0
  64. package/cli/services/git-import-guard.js +28 -0
  65. package/cli/services/git-import-guard.test.js +18 -0
  66. package/cli/services/service-status.js +191 -0
  67. package/cli/services/service-status.test.js +39 -0
  68. package/cli/services/terminal-dispatch.js +135 -0
  69. package/cli/services/terminal-dispatch.test.js +9 -0
  70. package/cli/services/terminal-registry.js +39 -0
  71. package/cli/services/terminal-registry.test.js +31 -0
  72. package/cli/services/terminal-sessions.js +100 -0
  73. package/cli/services/terminal-sessions.test.js +26 -0
  74. package/cli/services/terminal-window.js +106 -0
  75. package/cli/services/vault-read.js +14 -0
  76. package/cli/services/vault-read.test.js +35 -0
  77. package/cli/supervisor-registry.js +116 -0
  78. package/cli/supervisor-registry.test.js +165 -0
  79. package/cli/supervisor-ui.test.js +230 -30
  80. package/cli/webdav-config.js +84 -0
  81. package/cli/webdav-config.test.js +79 -0
  82. package/cli/webdav-obscure.js +23 -0
  83. package/cli/webdav-obscure.test.js +41 -0
  84. package/cli/webdav-rclone-discovery.js +44 -0
  85. package/cli/webdav-service.js +30 -118
  86. package/cli/webdav-service.test.js +107 -0
  87. package/install.ps1 +102 -102
  88. package/install.sh +49 -49
  89. package/package.json +2 -2
  90. package/scripts/bootstrap.cjs +121 -121
  91. package/supabase/functions/auth-vault/index.ts +350 -350
  92. package/supabase/migrations/001_clauth_schema.sql +94 -94
  93. package/supabase/migrations/002_vault_helpers.sql +90 -90
  94. package/supabase/migrations/20260317_lockout.sql +26 -26
  95. package/cli/assets/codevelop/launcher-active.cmd.template +0 -20
  96. package/cli/assets/codevelop/launcher-static.cmd.template +0 -7
  97. package/cli/assets/codevelop/windows-terminal.profiles.json +0 -48
  98. package/cli/commands/codevelop.js +0 -1190
@@ -1,350 +1,350 @@
1
- // clauth — auth-vault Edge Function v2
2
- // Added: IP whitelist, rate limiting, machine lockout (fail_count + locked)
3
-
4
- import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
5
-
6
- const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
7
- const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
8
- const CLAUTH_HMAC_SALT = Deno.env.get("CLAUTH_HMAC_SALT")!;
9
- const ADMIN_BOOTSTRAP_TOKEN = Deno.env.get("CLAUTH_ADMIN_BOOTSTRAP_TOKEN")!;
10
-
11
- const ALLOWED_IPS: string[] = (Deno.env.get("CLAUTH_ALLOWED_IPS") || "")
12
- .split(",").map(s => s.trim()).filter(Boolean);
13
-
14
- const RATE_LIMIT_MAX = 30;
15
- const RATE_LIMIT_WINDOW = 60;
16
- const REPLAY_WINDOW_MS = 5 * 60 * 1000;
17
- const MAX_FAIL_COUNT = 5;
18
- const DEFAULT_INSTALL_ID = "default";
19
-
20
- async function hmacSha256(key: string, message: string): Promise<string> {
21
- const cryptoKey = await crypto.subtle.importKey(
22
- "raw", new TextEncoder().encode(key),
23
- { name: "HMAC", hash: "SHA-256" }, false, ["sign"]
24
- );
25
- const sig = await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(message));
26
- return Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, "0")).join("");
27
- }
28
-
29
- async function sha256Hex(value: string): Promise<string> {
30
- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
31
- return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, "0")).join("");
32
- }
33
-
34
- function randomToken(bytes = 24): string {
35
- const data = new Uint8Array(bytes);
36
- crypto.getRandomValues(data);
37
- return Array.from(data).map(b => b.toString(16).padStart(2, "0")).join("");
38
- }
39
-
40
- function normalizeInstallId(value: unknown): string {
41
- const text = String(value || "").trim().toLowerCase();
42
- const normalized = text.replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
43
- return normalized || DEFAULT_INSTALL_ID;
44
- }
45
-
46
- function getClientIP(req: Request): string {
47
- return req.headers.get("cf-connecting-ip") ||
48
- req.headers.get("x-real-ip") ||
49
- req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
50
- }
51
-
52
- function checkIP(ip: string): { allowed: boolean; reason?: string } {
53
- if (ALLOWED_IPS.length === 0) return { allowed: true };
54
- if (ALLOWED_IPS.includes(ip)) return { allowed: true };
55
- return { allowed: false, reason: `IP not whitelisted: ${ip}` };
56
- }
57
-
58
- async function checkRateLimit(sb: any, machine_hash: string): Promise<{ allowed: boolean; reason?: string }> {
59
- const windowStart = new Date(Date.now() - RATE_LIMIT_WINDOW * 1000).toISOString();
60
- const { count } = await sb.from("clauth_audit")
61
- .select("id", { count: "exact", head: true })
62
- .eq("machine_hash", machine_hash)
63
- .gte("created_at", windowStart);
64
- if ((count || 0) >= RATE_LIMIT_MAX) {
65
- return { allowed: false, reason: `Rate limit: ${count}/${RATE_LIMIT_MAX} per ${RATE_LIMIT_WINDOW}s` };
66
- }
67
- return { allowed: true };
68
- }
69
-
70
- async function validateHMAC(sb: any, body: any): Promise<{ valid: boolean; reason?: string }> {
71
- const now = Date.now();
72
- if (Math.abs(now - body.timestamp) > REPLAY_WINDOW_MS) return { valid: false, reason: "timestamp_expired" };
73
-
74
- const { data: machine, error } = await sb.from("clauth_machines")
75
- .select("hmac_seed_hash, enabled, fail_count, locked")
76
- .eq("machine_hash", body.machine_hash).single();
77
-
78
- if (error || !machine) return { valid: false, reason: "machine_not_found" };
79
- if (!machine.enabled) return { valid: false, reason: "machine_disabled" };
80
- if (machine.locked) return { valid: false, reason: "machine_locked" };
81
-
82
- const window = Math.floor(body.timestamp / REPLAY_WINDOW_MS);
83
- const message = `${body.machine_hash}:${window}`;
84
- const expected = await hmacSha256(body.password, message);
85
- const seedHash = await sha256Hex(`seed:${body.machine_hash}:${body.password}`);
86
-
87
- if (seedHash !== machine.hmac_seed_hash || expected !== body.token) {
88
- const newCount = (machine.fail_count || 0) + 1;
89
- const shouldLock = newCount >= MAX_FAIL_COUNT;
90
- await sb.from("clauth_machines")
91
- .update({ fail_count: newCount, locked: shouldLock })
92
- .eq("machine_hash", body.machine_hash);
93
- return { valid: false, reason: shouldLock ? `machine_locked after ${newCount} failures` : `invalid_token (${newCount}/${MAX_FAIL_COUNT})` };
94
- }
95
-
96
- await sb.from("clauth_machines")
97
- .update({ last_seen: new Date().toISOString(), fail_count: 0 })
98
- .eq("machine_hash", body.machine_hash);
99
- return { valid: true };
100
- }
101
-
102
- async function auditLog(sb: any, machine_hash: string, service_name: string, action: string, result: string, detail?: string) {
103
- await sb.from("clauth_audit").insert({ machine_hash, service_name, action, result, detail });
104
- }
105
-
106
- async function handleRetrieve(sb: any, body: any, mh: string) {
107
- const { service } = body;
108
- if (!service) return { error: "service required" };
109
- const { data: svc } = await sb.from("clauth_services").select("*").eq("name", service).single();
110
- if (!svc) { await auditLog(sb, mh, service, "retrieve", "fail", "service_not_found"); return { error: "service_not_found" }; }
111
- if (!svc.enabled) { await auditLog(sb, mh, service, "retrieve", "denied", "service_disabled"); return { error: "service_disabled" }; }
112
- if (!svc.vault_key) { await auditLog(sb, mh, service, "retrieve", "fail", "no_key_stored"); return { error: "no_key_stored" }; }
113
- const { data: secret } = await sb.rpc("vault_decrypt_secret", { secret_name: svc.vault_key });
114
- await sb.from("clauth_services").update({ last_retrieved: new Date().toISOString() }).eq("name", service);
115
- await auditLog(sb, mh, service, "retrieve", "success");
116
- return { service, key_type: svc.key_type, value: secret };
117
- }
118
-
119
- async function handleWrite(sb: any, body: any, mh: string) {
120
- const { service, value } = body;
121
- if (!service || !value) return { error: "service and value required" };
122
- const vaultKey = `clauth.${service}`;
123
- const { error } = await sb.rpc("vault_upsert_secret", { secret_name: vaultKey, secret_value: typeof value === "string" ? value : JSON.stringify(value) });
124
- if (error) { await auditLog(sb, mh, service, "write", "fail", error.message); return { error: error.message }; }
125
- 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
- return { success: true, service, vault_key: vaultKey };
128
- }
129
-
130
- async function handleEnable(sb: any, body: any, mh: string) {
131
- const { service, enabled } = body;
132
- let q = sb.from("clauth_services").update({ enabled });
133
- q = service !== "all" ? q.eq("name", service) : q.not("vault_key", "is", null);
134
- const { error } = await q;
135
- if (error) return { error: error.message };
136
- await auditLog(sb, mh, service, enabled ? "enable" : "disable", "success");
137
- return { success: true, service, enabled };
138
- }
139
-
140
- async function handleAdd(sb: any, body: any, mh: string) {
141
- const { name, label, key_type, description, project } = body;
142
- if (!name || !label || !key_type) return { error: "name, label, key_type required" };
143
- const row: any = { name, label, key_type, description: description || null };
144
- if (project) row.project = project;
145
- const { error } = await sb.from("clauth_services").insert(row);
146
- if (error) return { error: error.message };
147
- await auditLog(sb, mh, name, "add", "success");
148
- return { success: true, name, label, key_type, project: project || null };
149
- }
150
-
151
- async function handleUpdate(sb: any, body: any, mh: string) {
152
- const { service, project, label, description } = body;
153
- if (!service) return { error: "service required" };
154
- const updates: any = { updated_at: new Date().toISOString() };
155
- if (project !== undefined) updates.project = project || null; // empty string clears project
156
- if (label !== undefined) updates.label = label;
157
- if (description !== undefined) updates.description = description || null;
158
- const { error } = await sb.from("clauth_services").update(updates).eq("name", service);
159
- if (error) return { error: error.message };
160
- await auditLog(sb, mh, service, "update", "success", `fields: ${Object.keys(updates).join(", ")}`);
161
- return { success: true, service, ...updates };
162
- }
163
-
164
- async function handleRemove(sb: any, body: any, mh: string) {
165
- const { service, confirm } = body;
166
- if (confirm !== `CONFIRM REMOVE ${service.toUpperCase()}`) return { error: "confirm phrase mismatch" };
167
- await sb.rpc("vault_delete_secret", { secret_name: `clauth.${service}` });
168
- await sb.from("clauth_services").delete().eq("name", service);
169
- await auditLog(sb, mh, service, "remove", "success");
170
- return { success: true, service };
171
- }
172
-
173
- async function handleRevoke(sb: any, body: any, mh: string) {
174
- const { service, confirm } = body;
175
- const phrase = service === "all" ? "CONFIRM REVOKE ALL" : `CONFIRM REVOKE ${service.toUpperCase()}`;
176
- if (confirm !== phrase) return { error: "confirm phrase mismatch", required: phrase };
177
- if (service === "all") {
178
- const { data: svcs } = await sb.from("clauth_services").select("name").not("vault_key", "is", null);
179
- for (const s of svcs || []) await sb.rpc("vault_delete_secret", { secret_name: `clauth.${s.name}` });
180
- await sb.from("clauth_services").update({ vault_key: null, enabled: false }).neq("id", "00000000-0000-0000-0000-000000000000");
181
- } else {
182
- await sb.rpc("vault_delete_secret", { secret_name: `clauth.${service}` });
183
- await sb.from("clauth_services").update({ vault_key: null, enabled: false }).eq("name", service);
184
- }
185
- await auditLog(sb, mh, service, "revoke", "success");
186
- return { success: true, service };
187
- }
188
-
189
- async function handleStatus(sb: any, body: any, mh: string) {
190
- let q = sb.from("clauth_services")
191
- .select("name, label, key_type, enabled, vault_key, last_retrieved, last_rotated, created_at, project, description")
192
- .order("project", { ascending: true, nullsFirst: true })
193
- .order("name");
194
- if (body.project) q = q.eq("project", body.project);
195
- const { data: services } = await q;
196
- await auditLog(sb, mh, "all", "status", "success");
197
- return { services: services || [] };
198
- }
199
-
200
- async function handleChangePassword(sb: any, body: any, mh: string) {
201
- const { new_hmac_seed_hash } = body;
202
- if (!new_hmac_seed_hash) return { error: "new_hmac_seed_hash required" };
203
- const { error } = await sb.from("clauth_machines")
204
- .update({ hmac_seed_hash: new_hmac_seed_hash, fail_count: 0, locked: false })
205
- .eq("machine_hash", mh);
206
- if (error) return { error: error.message };
207
- await auditLog(sb, mh, "system", "change-password", "success");
208
- return { success: true };
209
- }
210
-
211
- async function getMachineInstallId(sb: any, machine_hash: string): Promise<string> {
212
- const { data: machine } = await sb.from("clauth_machines")
213
- .select("install_id")
214
- .eq("machine_hash", machine_hash)
215
- .single();
216
- return normalizeInstallId(machine?.install_id);
217
- }
218
-
219
- async function handleCreateEnrollment(sb: any, body: any, mh: string) {
220
- const install_id = normalizeInstallId(body.install_id || await getMachineInstallId(sb, mh));
221
- const ttl_minutes = Math.max(5, Math.min(Number(body.ttl_minutes || 60), 24 * 60));
222
- const code = `ce_${randomToken(24)}`;
223
- const token_hash = await sha256Hex(code);
224
- const expires_at = new Date(Date.now() + ttl_minutes * 60 * 1000).toISOString();
225
- const label = body.label ? String(body.label).slice(0, 120) : null;
226
-
227
- const { error } = await sb.from("clauth_machine_enrollments").insert({
228
- install_id,
229
- token_hash,
230
- label,
231
- created_by_machine_hash: mh,
232
- expires_at,
233
- });
234
- if (error) {
235
- await auditLog(sb, mh, "system", "create-enrollment", "fail", error.message);
236
- return { error: error.message };
237
- }
238
-
239
- await auditLog(sb, mh, "system", "create-enrollment", "success", `install_id=${install_id}`);
240
- return { success: true, enrollment_code: code, install_id, expires_at, label };
241
- }
242
-
243
- async function handleRedeemEnrollment(sb: any, body: any) {
244
- const { machine_hash, hmac_seed_hash, enrollment_code } = body;
245
- if (!machine_hash || !hmac_seed_hash || !enrollment_code) {
246
- return { error: "machine_hash, hmac_seed_hash, enrollment_code required" };
247
- }
248
-
249
- const token_hash = await sha256Hex(String(enrollment_code).trim());
250
- const nowIso = new Date().toISOString();
251
- const { data: enrollment, error: lookupError } = await sb.from("clauth_machine_enrollments")
252
- .select("id, install_id, label, expires_at, consumed_at")
253
- .eq("token_hash", token_hash)
254
- .single();
255
-
256
- if (lookupError || !enrollment) return { error: "enrollment_not_found" };
257
- if (enrollment.consumed_at) return { error: "enrollment_already_used" };
258
- if (new Date(enrollment.expires_at).getTime() < Date.now()) return { error: "enrollment_expired" };
259
-
260
- const label = body.label || enrollment.label || null;
261
- const install_id = normalizeInstallId(enrollment.install_id);
262
-
263
- const { error: machineError } = await sb.from("clauth_machines").upsert(
264
- { machine_hash, hmac_seed_hash, label, install_id, enabled: true, fail_count: 0, locked: false },
265
- { onConflict: "machine_hash" }
266
- );
267
- if (machineError) return { error: machineError.message };
268
-
269
- const { data: consumedRows, error: consumeError } = await sb.from("clauth_machine_enrollments")
270
- .update({ consumed_at: nowIso, redeemed_by_machine_hash: machine_hash })
271
- .eq("id", enrollment.id)
272
- .is("consumed_at", null)
273
- .select("id");
274
- if (consumeError) return { error: consumeError.message };
275
- if (!consumedRows || consumedRows.length !== 1) return { error: "enrollment_already_used" };
276
-
277
- await auditLog(sb, machine_hash, "system", "redeem-enrollment", "success", `install_id=${install_id}`);
278
- return { success: true, machine_hash, install_id };
279
- }
280
-
281
- async function handleRegisterMachine(sb: any, body: any) {
282
- const { machine_hash, hmac_seed_hash, label, admin_token } = body;
283
- if (body.enrollment_code || body.invite_code) {
284
- return handleRedeemEnrollment(sb, { ...body, enrollment_code: body.enrollment_code || body.invite_code });
285
- }
286
- if (admin_token !== ADMIN_BOOTSTRAP_TOKEN) return { error: "invalid_admin_token" };
287
- const install_id = normalizeInstallId(body.install_id);
288
- const { error } = await sb.from("clauth_machines").upsert(
289
- { machine_hash, hmac_seed_hash, label, install_id, enabled: true, fail_count: 0, locked: false },
290
- { onConflict: "machine_hash" }
291
- );
292
- if (error) return { error: error.message };
293
- return { success: true, machine_hash, install_id };
294
- }
295
-
296
- Deno.serve(async (req: Request) => {
297
- if (req.method === "OPTIONS") {
298
- return new Response(null, { headers: {
299
- "Access-Control-Allow-Origin": "*",
300
- "Access-Control-Allow-Methods": "POST, OPTIONS",
301
- "Access-Control-Allow-Headers": "Content-Type, Authorization"
302
- }});
303
- }
304
- if (req.method !== "POST") return Response.json({ error: "POST only" }, { status: 405 });
305
-
306
- const url = new URL(req.url);
307
- const route = url.pathname.replace(/^\/auth-vault\/?/, "").replace(/^\//, "");
308
- const body = await req.json().catch(() => ({}));
309
- const sb = createClient(SUPABASE_URL, SERVICE_ROLE_KEY);
310
- const ip = getClientIP(req);
311
-
312
- if (route === "register-machine") return Response.json(await handleRegisterMachine(sb, body));
313
- if (route === "redeem-enrollment") return Response.json(await handleRedeemEnrollment(sb, body));
314
-
315
- const ipCheck = checkIP(ip);
316
- if (!ipCheck.allowed) {
317
- await auditLog(sb, body.machine_hash || "unknown", "system", route, "blocked", ipCheck.reason);
318
- return Response.json({ error: "ip_blocked", reason: ipCheck.reason }, { status: 403 });
319
- }
320
-
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
- const authResult = await validateHMAC(sb, { machine_hash: body.machine_hash, token: body.token, timestamp: body.timestamp, password: body.password });
330
- if (!authResult.valid) {
331
- await auditLog(sb, body.machine_hash || "unknown", body.service || "unknown", route, "denied", authResult.reason);
332
- return Response.json({ error: "auth_failed", reason: authResult.reason }, { status: 401 });
333
- }
334
-
335
- const mh = body.machine_hash;
336
- switch (route) {
337
- case "retrieve": return Response.json(await handleRetrieve(sb, body, mh));
338
- case "write": return Response.json(await handleWrite(sb, body, mh));
339
- case "enable": return Response.json(await handleEnable(sb, body, mh));
340
- case "add": return Response.json(await handleAdd(sb, body, mh));
341
- case "update": return Response.json(await handleUpdate(sb, body, mh));
342
- case "remove": return Response.json(await handleRemove(sb, body, mh));
343
- case "revoke": return Response.json(await handleRevoke(sb, body, mh));
344
- case "status": return Response.json(await handleStatus(sb, body, mh));
345
- case "change-password": return Response.json(await handleChangePassword(sb, body, mh));
346
- case "create-enrollment": return Response.json(await handleCreateEnrollment(sb, body, mh));
347
- case "test": return Response.json({ valid: true, machine_hash: mh, timestamp: body.timestamp, ip });
348
- default: return Response.json({ error: "unknown_route", route }, { status: 404 });
349
- }
350
- });
1
+ // clauth — auth-vault Edge Function v2
2
+ // Added: IP whitelist, rate limiting, machine lockout (fail_count + locked)
3
+
4
+ import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
5
+
6
+ const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
7
+ const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
8
+ const CLAUTH_HMAC_SALT = Deno.env.get("CLAUTH_HMAC_SALT")!;
9
+ const ADMIN_BOOTSTRAP_TOKEN = Deno.env.get("CLAUTH_ADMIN_BOOTSTRAP_TOKEN")!;
10
+
11
+ const ALLOWED_IPS: string[] = (Deno.env.get("CLAUTH_ALLOWED_IPS") || "")
12
+ .split(",").map(s => s.trim()).filter(Boolean);
13
+
14
+ const RATE_LIMIT_MAX = 30;
15
+ const RATE_LIMIT_WINDOW = 60;
16
+ const REPLAY_WINDOW_MS = 5 * 60 * 1000;
17
+ const MAX_FAIL_COUNT = 5;
18
+ const DEFAULT_INSTALL_ID = "default";
19
+
20
+ async function hmacSha256(key: string, message: string): Promise<string> {
21
+ const cryptoKey = await crypto.subtle.importKey(
22
+ "raw", new TextEncoder().encode(key),
23
+ { name: "HMAC", hash: "SHA-256" }, false, ["sign"]
24
+ );
25
+ const sig = await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(message));
26
+ return Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, "0")).join("");
27
+ }
28
+
29
+ async function sha256Hex(value: string): Promise<string> {
30
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
31
+ return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, "0")).join("");
32
+ }
33
+
34
+ function randomToken(bytes = 24): string {
35
+ const data = new Uint8Array(bytes);
36
+ crypto.getRandomValues(data);
37
+ return Array.from(data).map(b => b.toString(16).padStart(2, "0")).join("");
38
+ }
39
+
40
+ function normalizeInstallId(value: unknown): string {
41
+ const text = String(value || "").trim().toLowerCase();
42
+ const normalized = text.replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
43
+ return normalized || DEFAULT_INSTALL_ID;
44
+ }
45
+
46
+ function getClientIP(req: Request): string {
47
+ return req.headers.get("cf-connecting-ip") ||
48
+ req.headers.get("x-real-ip") ||
49
+ req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
50
+ }
51
+
52
+ function checkIP(ip: string): { allowed: boolean; reason?: string } {
53
+ if (ALLOWED_IPS.length === 0) return { allowed: true };
54
+ if (ALLOWED_IPS.includes(ip)) return { allowed: true };
55
+ return { allowed: false, reason: `IP not whitelisted: ${ip}` };
56
+ }
57
+
58
+ async function checkRateLimit(sb: any, machine_hash: string): Promise<{ allowed: boolean; reason?: string }> {
59
+ const windowStart = new Date(Date.now() - RATE_LIMIT_WINDOW * 1000).toISOString();
60
+ const { count } = await sb.from("clauth_audit")
61
+ .select("id", { count: "exact", head: true })
62
+ .eq("machine_hash", machine_hash)
63
+ .gte("created_at", windowStart);
64
+ if ((count || 0) >= RATE_LIMIT_MAX) {
65
+ return { allowed: false, reason: `Rate limit: ${count}/${RATE_LIMIT_MAX} per ${RATE_LIMIT_WINDOW}s` };
66
+ }
67
+ return { allowed: true };
68
+ }
69
+
70
+ async function validateHMAC(sb: any, body: any): Promise<{ valid: boolean; reason?: string }> {
71
+ const now = Date.now();
72
+ if (Math.abs(now - body.timestamp) > REPLAY_WINDOW_MS) return { valid: false, reason: "timestamp_expired" };
73
+
74
+ const { data: machine, error } = await sb.from("clauth_machines")
75
+ .select("hmac_seed_hash, enabled, fail_count, locked")
76
+ .eq("machine_hash", body.machine_hash).single();
77
+
78
+ if (error || !machine) return { valid: false, reason: "machine_not_found" };
79
+ if (!machine.enabled) return { valid: false, reason: "machine_disabled" };
80
+ if (machine.locked) return { valid: false, reason: "machine_locked" };
81
+
82
+ const window = Math.floor(body.timestamp / REPLAY_WINDOW_MS);
83
+ const message = `${body.machine_hash}:${window}`;
84
+ const expected = await hmacSha256(body.password, message);
85
+ const seedHash = await sha256Hex(`seed:${body.machine_hash}:${body.password}`);
86
+
87
+ if (seedHash !== machine.hmac_seed_hash || expected !== body.token) {
88
+ const newCount = (machine.fail_count || 0) + 1;
89
+ const shouldLock = newCount >= MAX_FAIL_COUNT;
90
+ await sb.from("clauth_machines")
91
+ .update({ fail_count: newCount, locked: shouldLock })
92
+ .eq("machine_hash", body.machine_hash);
93
+ return { valid: false, reason: shouldLock ? `machine_locked after ${newCount} failures` : `invalid_token (${newCount}/${MAX_FAIL_COUNT})` };
94
+ }
95
+
96
+ await sb.from("clauth_machines")
97
+ .update({ last_seen: new Date().toISOString(), fail_count: 0 })
98
+ .eq("machine_hash", body.machine_hash);
99
+ return { valid: true };
100
+ }
101
+
102
+ async function auditLog(sb: any, machine_hash: string, service_name: string, action: string, result: string, detail?: string) {
103
+ await sb.from("clauth_audit").insert({ machine_hash, service_name, action, result, detail });
104
+ }
105
+
106
+ async function handleRetrieve(sb: any, body: any, mh: string) {
107
+ const { service } = body;
108
+ if (!service) return { error: "service required" };
109
+ const { data: svc } = await sb.from("clauth_services").select("*").eq("name", service).single();
110
+ if (!svc) { await auditLog(sb, mh, service, "retrieve", "fail", "service_not_found"); return { error: "service_not_found" }; }
111
+ if (!svc.enabled) { await auditLog(sb, mh, service, "retrieve", "denied", "service_disabled"); return { error: "service_disabled" }; }
112
+ if (!svc.vault_key) { await auditLog(sb, mh, service, "retrieve", "fail", "no_key_stored"); return { error: "no_key_stored" }; }
113
+ const { data: secret } = await sb.rpc("vault_decrypt_secret", { secret_name: svc.vault_key });
114
+ await sb.from("clauth_services").update({ last_retrieved: new Date().toISOString() }).eq("name", service);
115
+ await auditLog(sb, mh, service, "retrieve", "success");
116
+ return { service, key_type: svc.key_type, value: secret };
117
+ }
118
+
119
+ async function handleWrite(sb: any, body: any, mh: string) {
120
+ const { service, value } = body;
121
+ if (!service || !value) return { error: "service and value required" };
122
+ const vaultKey = `clauth.${service}`;
123
+ const { error } = await sb.rpc("vault_upsert_secret", { secret_name: vaultKey, secret_value: typeof value === "string" ? value : JSON.stringify(value) });
124
+ if (error) { await auditLog(sb, mh, service, "write", "fail", error.message); return { error: error.message }; }
125
+ 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
+ return { success: true, service, vault_key: vaultKey };
128
+ }
129
+
130
+ async function handleEnable(sb: any, body: any, mh: string) {
131
+ const { service, enabled } = body;
132
+ let q = sb.from("clauth_services").update({ enabled });
133
+ q = service !== "all" ? q.eq("name", service) : q.not("vault_key", "is", null);
134
+ const { error } = await q;
135
+ if (error) return { error: error.message };
136
+ await auditLog(sb, mh, service, enabled ? "enable" : "disable", "success");
137
+ return { success: true, service, enabled };
138
+ }
139
+
140
+ async function handleAdd(sb: any, body: any, mh: string) {
141
+ const { name, label, key_type, description, project } = body;
142
+ if (!name || !label || !key_type) return { error: "name, label, key_type required" };
143
+ const row: any = { name, label, key_type, description: description || null };
144
+ if (project) row.project = project;
145
+ const { error } = await sb.from("clauth_services").insert(row);
146
+ if (error) return { error: error.message };
147
+ await auditLog(sb, mh, name, "add", "success");
148
+ return { success: true, name, label, key_type, project: project || null };
149
+ }
150
+
151
+ async function handleUpdate(sb: any, body: any, mh: string) {
152
+ const { service, project, label, description } = body;
153
+ if (!service) return { error: "service required" };
154
+ const updates: any = { updated_at: new Date().toISOString() };
155
+ if (project !== undefined) updates.project = project || null; // empty string clears project
156
+ if (label !== undefined) updates.label = label;
157
+ if (description !== undefined) updates.description = description || null;
158
+ const { error } = await sb.from("clauth_services").update(updates).eq("name", service);
159
+ if (error) return { error: error.message };
160
+ await auditLog(sb, mh, service, "update", "success", `fields: ${Object.keys(updates).join(", ")}`);
161
+ return { success: true, service, ...updates };
162
+ }
163
+
164
+ async function handleRemove(sb: any, body: any, mh: string) {
165
+ const { service, confirm } = body;
166
+ if (confirm !== `CONFIRM REMOVE ${service.toUpperCase()}`) return { error: "confirm phrase mismatch" };
167
+ await sb.rpc("vault_delete_secret", { secret_name: `clauth.${service}` });
168
+ await sb.from("clauth_services").delete().eq("name", service);
169
+ await auditLog(sb, mh, service, "remove", "success");
170
+ return { success: true, service };
171
+ }
172
+
173
+ async function handleRevoke(sb: any, body: any, mh: string) {
174
+ const { service, confirm } = body;
175
+ const phrase = service === "all" ? "CONFIRM REVOKE ALL" : `CONFIRM REVOKE ${service.toUpperCase()}`;
176
+ if (confirm !== phrase) return { error: "confirm phrase mismatch", required: phrase };
177
+ if (service === "all") {
178
+ const { data: svcs } = await sb.from("clauth_services").select("name").not("vault_key", "is", null);
179
+ for (const s of svcs || []) await sb.rpc("vault_delete_secret", { secret_name: `clauth.${s.name}` });
180
+ await sb.from("clauth_services").update({ vault_key: null, enabled: false }).neq("id", "00000000-0000-0000-0000-000000000000");
181
+ } else {
182
+ await sb.rpc("vault_delete_secret", { secret_name: `clauth.${service}` });
183
+ await sb.from("clauth_services").update({ vault_key: null, enabled: false }).eq("name", service);
184
+ }
185
+ await auditLog(sb, mh, service, "revoke", "success");
186
+ return { success: true, service };
187
+ }
188
+
189
+ async function handleStatus(sb: any, body: any, mh: string) {
190
+ let q = sb.from("clauth_services")
191
+ .select("name, label, key_type, enabled, vault_key, last_retrieved, last_rotated, created_at, project, description")
192
+ .order("project", { ascending: true, nullsFirst: true })
193
+ .order("name");
194
+ if (body.project) q = q.eq("project", body.project);
195
+ const { data: services } = await q;
196
+ await auditLog(sb, mh, "all", "status", "success");
197
+ return { services: services || [] };
198
+ }
199
+
200
+ async function handleChangePassword(sb: any, body: any, mh: string) {
201
+ const { new_hmac_seed_hash } = body;
202
+ if (!new_hmac_seed_hash) return { error: "new_hmac_seed_hash required" };
203
+ const { error } = await sb.from("clauth_machines")
204
+ .update({ hmac_seed_hash: new_hmac_seed_hash, fail_count: 0, locked: false })
205
+ .eq("machine_hash", mh);
206
+ if (error) return { error: error.message };
207
+ await auditLog(sb, mh, "system", "change-password", "success");
208
+ return { success: true };
209
+ }
210
+
211
+ async function getMachineInstallId(sb: any, machine_hash: string): Promise<string> {
212
+ const { data: machine } = await sb.from("clauth_machines")
213
+ .select("install_id")
214
+ .eq("machine_hash", machine_hash)
215
+ .single();
216
+ return normalizeInstallId(machine?.install_id);
217
+ }
218
+
219
+ async function handleCreateEnrollment(sb: any, body: any, mh: string) {
220
+ const install_id = normalizeInstallId(body.install_id || await getMachineInstallId(sb, mh));
221
+ const ttl_minutes = Math.max(5, Math.min(Number(body.ttl_minutes || 60), 24 * 60));
222
+ const code = `ce_${randomToken(24)}`;
223
+ const token_hash = await sha256Hex(code);
224
+ const expires_at = new Date(Date.now() + ttl_minutes * 60 * 1000).toISOString();
225
+ const label = body.label ? String(body.label).slice(0, 120) : null;
226
+
227
+ const { error } = await sb.from("clauth_machine_enrollments").insert({
228
+ install_id,
229
+ token_hash,
230
+ label,
231
+ created_by_machine_hash: mh,
232
+ expires_at,
233
+ });
234
+ if (error) {
235
+ await auditLog(sb, mh, "system", "create-enrollment", "fail", error.message);
236
+ return { error: error.message };
237
+ }
238
+
239
+ await auditLog(sb, mh, "system", "create-enrollment", "success", `install_id=${install_id}`);
240
+ return { success: true, enrollment_code: code, install_id, expires_at, label };
241
+ }
242
+
243
+ async function handleRedeemEnrollment(sb: any, body: any) {
244
+ const { machine_hash, hmac_seed_hash, enrollment_code } = body;
245
+ if (!machine_hash || !hmac_seed_hash || !enrollment_code) {
246
+ return { error: "machine_hash, hmac_seed_hash, enrollment_code required" };
247
+ }
248
+
249
+ const token_hash = await sha256Hex(String(enrollment_code).trim());
250
+ const nowIso = new Date().toISOString();
251
+ const { data: enrollment, error: lookupError } = await sb.from("clauth_machine_enrollments")
252
+ .select("id, install_id, label, expires_at, consumed_at")
253
+ .eq("token_hash", token_hash)
254
+ .single();
255
+
256
+ if (lookupError || !enrollment) return { error: "enrollment_not_found" };
257
+ if (enrollment.consumed_at) return { error: "enrollment_already_used" };
258
+ if (new Date(enrollment.expires_at).getTime() < Date.now()) return { error: "enrollment_expired" };
259
+
260
+ const label = body.label || enrollment.label || null;
261
+ const install_id = normalizeInstallId(enrollment.install_id);
262
+
263
+ const { error: machineError } = await sb.from("clauth_machines").upsert(
264
+ { machine_hash, hmac_seed_hash, label, install_id, enabled: true, fail_count: 0, locked: false },
265
+ { onConflict: "machine_hash" }
266
+ );
267
+ if (machineError) return { error: machineError.message };
268
+
269
+ const { data: consumedRows, error: consumeError } = await sb.from("clauth_machine_enrollments")
270
+ .update({ consumed_at: nowIso, redeemed_by_machine_hash: machine_hash })
271
+ .eq("id", enrollment.id)
272
+ .is("consumed_at", null)
273
+ .select("id");
274
+ if (consumeError) return { error: consumeError.message };
275
+ if (!consumedRows || consumedRows.length !== 1) return { error: "enrollment_already_used" };
276
+
277
+ await auditLog(sb, machine_hash, "system", "redeem-enrollment", "success", `install_id=${install_id}`);
278
+ return { success: true, machine_hash, install_id };
279
+ }
280
+
281
+ async function handleRegisterMachine(sb: any, body: any) {
282
+ const { machine_hash, hmac_seed_hash, label, admin_token } = body;
283
+ if (body.enrollment_code || body.invite_code) {
284
+ return handleRedeemEnrollment(sb, { ...body, enrollment_code: body.enrollment_code || body.invite_code });
285
+ }
286
+ if (admin_token !== ADMIN_BOOTSTRAP_TOKEN) return { error: "invalid_admin_token" };
287
+ const install_id = normalizeInstallId(body.install_id);
288
+ const { error } = await sb.from("clauth_machines").upsert(
289
+ { machine_hash, hmac_seed_hash, label, install_id, enabled: true, fail_count: 0, locked: false },
290
+ { onConflict: "machine_hash" }
291
+ );
292
+ if (error) return { error: error.message };
293
+ return { success: true, machine_hash, install_id };
294
+ }
295
+
296
+ Deno.serve(async (req: Request) => {
297
+ if (req.method === "OPTIONS") {
298
+ return new Response(null, { headers: {
299
+ "Access-Control-Allow-Origin": "*",
300
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
301
+ "Access-Control-Allow-Headers": "Content-Type, Authorization"
302
+ }});
303
+ }
304
+ if (req.method !== "POST") return Response.json({ error: "POST only" }, { status: 405 });
305
+
306
+ const url = new URL(req.url);
307
+ const route = url.pathname.replace(/^\/auth-vault\/?/, "").replace(/^\//, "");
308
+ const body = await req.json().catch(() => ({}));
309
+ const sb = createClient(SUPABASE_URL, SERVICE_ROLE_KEY);
310
+ const ip = getClientIP(req);
311
+
312
+ if (route === "register-machine") return Response.json(await handleRegisterMachine(sb, body));
313
+ if (route === "redeem-enrollment") return Response.json(await handleRedeemEnrollment(sb, body));
314
+
315
+ const ipCheck = checkIP(ip);
316
+ if (!ipCheck.allowed) {
317
+ await auditLog(sb, body.machine_hash || "unknown", "system", route, "blocked", ipCheck.reason);
318
+ return Response.json({ error: "ip_blocked", reason: ipCheck.reason }, { status: 403 });
319
+ }
320
+
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
+ const authResult = await validateHMAC(sb, { machine_hash: body.machine_hash, token: body.token, timestamp: body.timestamp, password: body.password });
330
+ if (!authResult.valid) {
331
+ await auditLog(sb, body.machine_hash || "unknown", body.service || "unknown", route, "denied", authResult.reason);
332
+ return Response.json({ error: "auth_failed", reason: authResult.reason }, { status: 401 });
333
+ }
334
+
335
+ const mh = body.machine_hash;
336
+ switch (route) {
337
+ case "retrieve": return Response.json(await handleRetrieve(sb, body, mh));
338
+ case "write": return Response.json(await handleWrite(sb, body, mh));
339
+ case "enable": return Response.json(await handleEnable(sb, body, mh));
340
+ case "add": return Response.json(await handleAdd(sb, body, mh));
341
+ case "update": return Response.json(await handleUpdate(sb, body, mh));
342
+ case "remove": return Response.json(await handleRemove(sb, body, mh));
343
+ case "revoke": return Response.json(await handleRevoke(sb, body, mh));
344
+ case "status": return Response.json(await handleStatus(sb, body, mh));
345
+ case "change-password": return Response.json(await handleChangePassword(sb, body, mh));
346
+ case "create-enrollment": return Response.json(await handleCreateEnrollment(sb, body, mh));
347
+ case "test": return Response.json({ valid: true, machine_hash: mh, timestamp: body.timestamp, ip });
348
+ default: return Response.json({ error: "unknown_route", route }, { status: 404 });
349
+ }
350
+ });