@lifeaitools/clauth 0.2.1 → 0.3.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.
@@ -1,326 +1,223 @@
1
- // ============================================================
2
- // clauth auth-vault Edge Function
3
- // Supabase Deno runtime
4
- // Routes:
5
- // POST /auth-vault/retrieve — validate HMAC, return key
6
- // POST /auth-vault/write — write/update key in vault
7
- // POST /auth-vault/enable — enable/disable service
8
- // POST /auth-vault/add — add new service to registry
9
- // POST /auth-vault/remove — remove service from registry
10
- // POST /auth-vault/status — list all services + state
11
- // POST /auth-vault/test — dry-run HMAC check only
12
- // POST /auth-vault/rotate — flag a service for rotation
13
- // POST /auth-vault/revoke — delete key from vault
14
- // ============================================================
1
+ // clauth — auth-vault Edge Function v2
2
+ // Added: IP whitelist, rate limiting, machine lockout (fail_count + locked)
15
3
 
16
4
  import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
17
5
 
18
- const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
19
- const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
20
- const CLAUTH_HMAC_SALT = Deno.env.get("CLAUTH_HMAC_SALT")!; // stored in Supabase secrets
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")!;
21
10
 
22
- const REPLAY_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
11
+ const ALLOWED_IPS: string[] = (Deno.env.get("CLAUTH_ALLOWED_IPS") || "")
12
+ .split(",").map(s => s.trim()).filter(Boolean);
23
13
 
24
- // ============================================================
25
- // Helpers
26
- // ============================================================
27
-
28
- async function sha256hex(input: string): Promise<string> {
29
- const buf = await crypto.subtle.digest(
30
- "SHA-256",
31
- new TextEncoder().encode(input)
32
- );
33
- return Array.from(new Uint8Array(buf))
34
- .map(b => b.toString(16).padStart(2, "0"))
35
- .join("");
36
- }
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;
37
18
 
38
19
  async function hmacSha256(key: string, message: string): Promise<string> {
39
20
  const cryptoKey = await crypto.subtle.importKey(
40
- "raw",
41
- new TextEncoder().encode(key),
42
- { name: "HMAC", hash: "SHA-256" },
43
- false,
44
- ["sign"]
45
- );
46
- const sig = await crypto.subtle.sign(
47
- "HMAC",
48
- cryptoKey,
49
- new TextEncoder().encode(message)
21
+ "raw", new TextEncoder().encode(key),
22
+ { name: "HMAC", hash: "SHA-256" }, false, ["sign"]
50
23
  );
51
- return Array.from(new Uint8Array(sig))
52
- .map(b => b.toString(16).padStart(2, "0"))
53
- .join("");
24
+ const sig = await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(message));
25
+ return Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, "0")).join("");
54
26
  }
55
27
 
56
- async function validateHMAC(body: {
57
- machine_hash: string;
58
- token: string;
59
- timestamp: number;
60
- password: string;
61
- }): Promise<{ valid: boolean; reason?: string }> {
62
- const now = Date.now();
28
+ function getClientIP(req: Request): string {
29
+ return req.headers.get("cf-connecting-ip") ||
30
+ req.headers.get("x-real-ip") ||
31
+ req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
32
+ }
63
33
 
64
- // Replay check
65
- if (Math.abs(now - body.timestamp) > REPLAY_WINDOW_MS) {
66
- return { valid: false, reason: "timestamp_expired" };
34
+ function checkIP(ip: string): { allowed: boolean; reason?: string } {
35
+ if (ALLOWED_IPS.length === 0) return { allowed: true };
36
+ if (ALLOWED_IPS.includes(ip)) return { allowed: true };
37
+ return { allowed: false, reason: `IP not whitelisted: ${ip}` };
38
+ }
39
+
40
+ async function checkRateLimit(sb: any, machine_hash: string): Promise<{ allowed: boolean; reason?: string }> {
41
+ const windowStart = new Date(Date.now() - RATE_LIMIT_WINDOW * 1000).toISOString();
42
+ const { count } = await sb.from("clauth_audit")
43
+ .select("id", { count: "exact", head: true })
44
+ .eq("machine_hash", machine_hash)
45
+ .gte("created_at", windowStart);
46
+ if ((count || 0) >= RATE_LIMIT_MAX) {
47
+ return { allowed: false, reason: `Rate limit: ${count}/${RATE_LIMIT_MAX} per ${RATE_LIMIT_WINDOW}s` };
67
48
  }
49
+ return { allowed: true };
50
+ }
51
+
52
+ async function validateHMAC(sb: any, body: any): Promise<{ valid: boolean; reason?: string }> {
53
+ const now = Date.now();
54
+ if (Math.abs(now - body.timestamp) > REPLAY_WINDOW_MS) return { valid: false, reason: "timestamp_expired" };
68
55
 
69
- // Lookup machine
70
- const sb = createClient(SUPABASE_URL, SERVICE_ROLE_KEY);
71
- const { data: machine, error } = await sb
72
- .from("clauth_machines")
73
- .select("hmac_seed_hash, enabled")
74
- .eq("machine_hash", body.machine_hash)
75
- .single();
56
+ const { data: machine, error } = await sb.from("clauth_machines")
57
+ .select("hmac_seed_hash, enabled, fail_count, locked")
58
+ .eq("machine_hash", body.machine_hash).single();
76
59
 
77
- if (error || !machine) {
78
- return { valid: false, reason: "machine_not_found" };
79
- }
80
- if (!machine.enabled) {
81
- return { valid: false, reason: "machine_disabled" };
82
- }
60
+ if (error || !machine) return { valid: false, reason: "machine_not_found" };
61
+ if (!machine.enabled) return { valid: false, reason: "machine_disabled" };
62
+ if (machine.locked) return { valid: false, reason: "machine_locked" };
83
63
 
84
- // Expected token = HMAC(machine_hash:window, password)
85
- // Client derives token with password only; server verifies the same way
86
- const window = Math.floor(body.timestamp / REPLAY_WINDOW_MS);
87
- const message = `${body.machine_hash}:${window}`;
64
+ const window = Math.floor(body.timestamp / REPLAY_WINDOW_MS);
65
+ const message = `${body.machine_hash}:${window}`;
88
66
  const expected = await hmacSha256(body.password, message);
89
67
 
90
68
  if (expected !== body.token) {
91
- return { valid: false, reason: "invalid_token" };
69
+ const newCount = (machine.fail_count || 0) + 1;
70
+ const shouldLock = newCount >= MAX_FAIL_COUNT;
71
+ await sb.from("clauth_machines")
72
+ .update({ fail_count: newCount, locked: shouldLock })
73
+ .eq("machine_hash", body.machine_hash);
74
+ return { valid: false, reason: shouldLock ? `machine_locked after ${newCount} failures` : `invalid_token (${newCount}/${MAX_FAIL_COUNT})` };
92
75
  }
93
76
 
94
- // Update last_seen
95
- await sb
96
- .from("clauth_machines")
97
- .update({ last_seen: new Date().toISOString() })
77
+ await sb.from("clauth_machines")
78
+ .update({ last_seen: new Date().toISOString(), fail_count: 0 })
98
79
  .eq("machine_hash", body.machine_hash);
99
-
100
80
  return { valid: true };
101
81
  }
102
82
 
103
- async function auditLog(
104
- sb: ReturnType<typeof createClient>,
105
- machine_hash: string,
106
- service_name: string,
107
- action: string,
108
- result: string,
109
- detail?: string
110
- ) {
111
- await sb.from("clauth_audit").insert({
112
- machine_hash, service_name, action, result, detail
113
- });
83
+ async function auditLog(sb: any, machine_hash: string, service_name: string, action: string, result: string, detail?: string) {
84
+ await sb.from("clauth_audit").insert({ machine_hash, service_name, action, result, detail });
114
85
  }
115
86
 
116
- // ============================================================
117
- // Route handlers
118
- // ============================================================
119
-
120
- async function handleRetrieve(sb: ReturnType<typeof createClient>, body: any, machine_hash: string) {
87
+ async function handleRetrieve(sb: any, body: any, mh: string) {
121
88
  const { service } = body;
122
89
  if (!service) return { error: "service required" };
123
-
124
- const { data: svc } = await sb
125
- .from("clauth_services")
126
- .select("*")
127
- .eq("name", service)
128
- .single();
129
-
130
- if (!svc) {
131
- await auditLog(sb, machine_hash, service, "retrieve", "fail", "service_not_found");
132
- return { error: "service_not_found" };
133
- }
134
- if (!svc.enabled) {
135
- await auditLog(sb, machine_hash, service, "retrieve", "denied", "service_disabled");
136
- return { error: "service_disabled" };
137
- }
138
- if (!svc.vault_key) {
139
- await auditLog(sb, machine_hash, service, "retrieve", "fail", "no_key_stored");
140
- return { error: "no_key_stored" };
141
- }
142
-
143
- // Retrieve from vault
144
- const { data: secret } = await sb.rpc("vault_decrypt_secret", {
145
- secret_name: svc.vault_key
146
- });
147
-
148
- await sb
149
- .from("clauth_services")
150
- .update({ last_retrieved: new Date().toISOString() })
151
- .eq("name", service);
152
-
153
- await auditLog(sb, machine_hash, service, "retrieve", "success");
90
+ const { data: svc } = await sb.from("clauth_services").select("*").eq("name", service).single();
91
+ if (!svc) { await auditLog(sb, mh, service, "retrieve", "fail", "service_not_found"); return { error: "service_not_found" }; }
92
+ if (!svc.enabled) { await auditLog(sb, mh, service, "retrieve", "denied", "service_disabled"); return { error: "service_disabled" }; }
93
+ if (!svc.vault_key) { await auditLog(sb, mh, service, "retrieve", "fail", "no_key_stored"); return { error: "no_key_stored" }; }
94
+ const { data: secret } = await sb.rpc("vault_decrypt_secret", { secret_name: svc.vault_key });
95
+ await sb.from("clauth_services").update({ last_retrieved: new Date().toISOString() }).eq("name", service);
96
+ await auditLog(sb, mh, service, "retrieve", "success");
154
97
  return { service, key_type: svc.key_type, value: secret };
155
98
  }
156
99
 
157
- async function handleWrite(sb: ReturnType<typeof createClient>, body: any, machine_hash: string) {
100
+ async function handleWrite(sb: any, body: any, mh: string) {
158
101
  const { service, value } = body;
159
102
  if (!service || !value) return { error: "service and value required" };
160
-
161
103
  const vaultKey = `clauth.${service}`;
162
-
163
- // Upsert into vault
164
- const { error } = await sb.rpc("vault_upsert_secret", {
165
- secret_name: vaultKey,
166
- secret_value: typeof value === "string" ? value : JSON.stringify(value)
167
- });
168
-
169
- if (error) {
170
- await auditLog(sb, machine_hash, service, "write", "fail", error.message);
171
- return { error: error.message };
172
- }
173
-
174
- // Update registry
175
- await sb
176
- .from("clauth_services")
177
- .update({ vault_key: vaultKey, last_rotated: new Date().toISOString() })
178
- .eq("name", service);
179
-
180
- await auditLog(sb, machine_hash, service, "write", "success");
104
+ const { error } = await sb.rpc("vault_upsert_secret", { secret_name: vaultKey, secret_value: typeof value === "string" ? value : JSON.stringify(value) });
105
+ if (error) { await auditLog(sb, mh, service, "write", "fail", error.message); return { error: error.message }; }
106
+ await sb.from("clauth_services").update({ vault_key: vaultKey, last_rotated: new Date().toISOString() }).eq("name", service);
107
+ await auditLog(sb, mh, service, "write", "success");
181
108
  return { success: true, service, vault_key: vaultKey };
182
109
  }
183
110
 
184
- async function handleEnable(sb: ReturnType<typeof createClient>, body: any, machine_hash: string) {
185
- const { service, enabled } = body; // enabled: true | false
186
- const target = service === "all" ? null : service;
187
-
188
- let query = sb.from("clauth_services").update({ enabled });
189
- if (target) query = query.eq("name", target);
190
- else query = query.not("vault_key", "is", null); // only enable services with keys
191
-
192
- const { error } = await query;
111
+ async function handleEnable(sb: any, body: any, mh: string) {
112
+ const { service, enabled } = body;
113
+ let q = sb.from("clauth_services").update({ enabled });
114
+ q = service !== "all" ? q.eq("name", service) : q.not("vault_key", "is", null);
115
+ const { error } = await q;
193
116
  if (error) return { error: error.message };
194
-
195
- await auditLog(sb, machine_hash, service, enabled ? "enable" : "disable", "success");
117
+ await auditLog(sb, mh, service, enabled ? "enable" : "disable", "success");
196
118
  return { success: true, service, enabled };
197
119
  }
198
120
 
199
- async function handleAdd(sb: ReturnType<typeof createClient>, body: any, machine_hash: string) {
121
+ async function handleAdd(sb: any, body: any, mh: string) {
200
122
  const { name, label, key_type, description } = body;
201
123
  if (!name || !label || !key_type) return { error: "name, label, key_type required" };
202
-
203
- const { error } = await sb.from("clauth_services").insert({
204
- name, label, key_type, description: description || null
205
- });
206
-
124
+ const { error } = await sb.from("clauth_services").insert({ name, label, key_type, description: description || null });
207
125
  if (error) return { error: error.message };
208
- await auditLog(sb, machine_hash, name, "add", "success");
126
+ await auditLog(sb, mh, name, "add", "success");
209
127
  return { success: true, name, label, key_type };
210
128
  }
211
129
 
212
- async function handleRemove(sb: ReturnType<typeof createClient>, body: any, machine_hash: string) {
130
+ async function handleRemove(sb: any, body: any, mh: string) {
213
131
  const { service, confirm } = body;
214
- if (confirm !== `CONFIRM REMOVE ${service.toUpperCase()}`) {
215
- return { error: "confirm phrase mismatch", required: `CONFIRM REMOVE ${service.toUpperCase()}` };
216
- }
217
-
132
+ if (confirm !== `CONFIRM REMOVE ${service.toUpperCase()}`) return { error: "confirm phrase mismatch" };
218
133
  await sb.rpc("vault_delete_secret", { secret_name: `clauth.${service}` });
219
134
  await sb.from("clauth_services").delete().eq("name", service);
220
- await auditLog(sb, machine_hash, service, "remove", "success");
135
+ await auditLog(sb, mh, service, "remove", "success");
221
136
  return { success: true, service };
222
137
  }
223
138
 
224
- async function handleRevoke(sb: ReturnType<typeof createClient>, body: any, machine_hash: string) {
139
+ async function handleRevoke(sb: any, body: any, mh: string) {
225
140
  const { service, confirm } = body;
226
141
  const phrase = service === "all" ? "CONFIRM REVOKE ALL" : `CONFIRM REVOKE ${service.toUpperCase()}`;
227
- if (confirm !== phrase) {
228
- return { error: "confirm phrase mismatch", required: phrase };
229
- }
230
-
142
+ if (confirm !== phrase) return { error: "confirm phrase mismatch", required: phrase };
231
143
  if (service === "all") {
232
144
  const { data: svcs } = await sb.from("clauth_services").select("name").not("vault_key", "is", null);
233
- for (const s of svcs || []) {
234
- await sb.rpc("vault_delete_secret", { secret_name: `clauth.${s.name}` });
235
- }
145
+ for (const s of svcs || []) await sb.rpc("vault_delete_secret", { secret_name: `clauth.${s.name}` });
236
146
  await sb.from("clauth_services").update({ vault_key: null, enabled: false }).neq("id", "00000000-0000-0000-0000-000000000000");
237
147
  } else {
238
148
  await sb.rpc("vault_delete_secret", { secret_name: `clauth.${service}` });
239
149
  await sb.from("clauth_services").update({ vault_key: null, enabled: false }).eq("name", service);
240
150
  }
241
-
242
- await auditLog(sb, machine_hash, service, "revoke", "success");
151
+ await auditLog(sb, mh, service, "revoke", "success");
243
152
  return { success: true, service };
244
153
  }
245
154
 
246
- async function handleStatus(sb: ReturnType<typeof createClient>, machine_hash: string) {
247
- const { data: services } = await sb
248
- .from("clauth_services")
249
- .select("name, label, key_type, enabled, vault_key, last_retrieved, last_rotated, created_at")
250
- .order("name");
251
-
252
- await auditLog(sb, machine_hash, "all", "status", "success");
155
+ async function handleStatus(sb: any, mh: string) {
156
+ const { data: services } = await sb.from("clauth_services")
157
+ .select("name, label, key_type, enabled, vault_key, last_retrieved, last_rotated, created_at").order("name");
158
+ await auditLog(sb, mh, "all", "status", "success");
253
159
  return { services: services || [] };
254
160
  }
255
161
 
256
- async function handleRegisterMachine(sb: ReturnType<typeof createClient>, body: any) {
162
+ async function handleRegisterMachine(sb: any, body: any) {
257
163
  const { machine_hash, hmac_seed_hash, label, admin_token } = body;
258
- // Bootstrap: requires a one-time admin token stored as env var
259
- if (admin_token !== Deno.env.get("CLAUTH_ADMIN_BOOTSTRAP_TOKEN")) {
260
- return { error: "invalid_admin_token" };
261
- }
262
- const { error } = await sb.from("clauth_machines").upsert({
263
- machine_hash, hmac_seed_hash, label, enabled: true
264
- }, { onConflict: "machine_hash" });
265
-
164
+ if (admin_token !== ADMIN_BOOTSTRAP_TOKEN) return { error: "invalid_admin_token" };
165
+ const { error } = await sb.from("clauth_machines").upsert(
166
+ { machine_hash, hmac_seed_hash, label, enabled: true, fail_count: 0, locked: false },
167
+ { onConflict: "machine_hash" }
168
+ );
266
169
  if (error) return { error: error.message };
267
170
  return { success: true, machine_hash };
268
171
  }
269
172
 
270
- // ============================================================
271
- // Main handler
272
- // ============================================================
273
-
274
173
  Deno.serve(async (req: Request) => {
275
- const url = new URL(req.url);
276
- const route = url.pathname.replace(/^\/auth-vault\/?/, "").replace(/^\//, "");
277
-
278
174
  if (req.method === "OPTIONS") {
279
- return new Response(null, {
280
- headers: {
281
- "Access-Control-Allow-Origin": "*",
282
- "Access-Control-Allow-Methods": "POST, OPTIONS",
283
- "Access-Control-Allow-Headers": "Content-Type, Authorization"
284
- }
285
- });
175
+ return new Response(null, { headers: {
176
+ "Access-Control-Allow-Origin": "*",
177
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
178
+ "Access-Control-Allow-Headers": "Content-Type, Authorization"
179
+ }});
286
180
  }
181
+ if (req.method !== "POST") return Response.json({ error: "POST only" }, { status: 405 });
287
182
 
288
- if (req.method !== "POST") {
289
- return Response.json({ error: "POST only" }, { status: 405 });
290
- }
183
+ const url = new URL(req.url);
184
+ const route = url.pathname.replace(/^\/auth-vault\/?/, "").replace(/^\//, "");
185
+ const body = await req.json().catch(() => ({}));
186
+ const sb = createClient(SUPABASE_URL, SERVICE_ROLE_KEY);
187
+ const ip = getClientIP(req);
291
188
 
292
- const body = await req.json().catch(() => ({}));
293
- const sb = createClient(SUPABASE_URL, SERVICE_ROLE_KEY);
189
+ if (route === "register-machine") return Response.json(await handleRegisterMachine(sb, body));
294
190
 
295
- // Bootstrap machine registration — no HMAC required, uses admin token
296
- if (route === "register-machine") {
297
- return Response.json(await handleRegisterMachine(sb, body));
191
+ const ipCheck = checkIP(ip);
192
+ if (!ipCheck.allowed) {
193
+ await auditLog(sb, body.machine_hash || "unknown", "system", route, "blocked", ipCheck.reason);
194
+ return Response.json({ error: "ip_blocked", reason: ipCheck.reason }, { status: 403 });
298
195
  }
299
196
 
300
- // All other routes require HMAC validation
301
- const authResult = await validateHMAC({
302
- machine_hash: body.machine_hash,
303
- token: body.token,
304
- timestamp: body.timestamp,
305
- password: body.password
306
- });
197
+ if (body.machine_hash) {
198
+ const rateCheck = await checkRateLimit(sb, body.machine_hash);
199
+ if (!rateCheck.allowed) {
200
+ await auditLog(sb, body.machine_hash, "system", route, "rate_limited", rateCheck.reason);
201
+ return Response.json({ error: "rate_limited", reason: rateCheck.reason }, { status: 429 });
202
+ }
203
+ }
307
204
 
205
+ const authResult = await validateHMAC(sb, { machine_hash: body.machine_hash, token: body.token, timestamp: body.timestamp, password: body.password });
308
206
  if (!authResult.valid) {
309
207
  await auditLog(sb, body.machine_hash || "unknown", body.service || "unknown", route, "denied", authResult.reason);
310
208
  return Response.json({ error: "auth_failed", reason: authResult.reason }, { status: 401 });
311
209
  }
312
210
 
313
- const machine_hash = body.machine_hash;
314
-
211
+ const mh = body.machine_hash;
315
212
  switch (route) {
316
- case "retrieve": return Response.json(await handleRetrieve(sb, body, machine_hash));
317
- case "write": return Response.json(await handleWrite(sb, body, machine_hash));
318
- case "enable": return Response.json(await handleEnable(sb, body, machine_hash));
319
- case "add": return Response.json(await handleAdd(sb, body, machine_hash));
320
- case "remove": return Response.json(await handleRemove(sb, body, machine_hash));
321
- case "revoke": return Response.json(await handleRevoke(sb, body, machine_hash));
322
- case "status": return Response.json(await handleStatus(sb, machine_hash));
323
- case "test": return Response.json({ valid: true, machine_hash, timestamp: body.timestamp });
324
- default: return Response.json({ error: "unknown_route", route }, { status: 404 });
213
+ case "retrieve": return Response.json(await handleRetrieve(sb, body, mh));
214
+ case "write": return Response.json(await handleWrite(sb, body, mh));
215
+ case "enable": return Response.json(await handleEnable(sb, body, mh));
216
+ case "add": return Response.json(await handleAdd(sb, body, mh));
217
+ case "remove": return Response.json(await handleRemove(sb, body, mh));
218
+ case "revoke": return Response.json(await handleRevoke(sb, body, mh));
219
+ case "status": return Response.json(await handleStatus(sb, mh));
220
+ case "test": return Response.json({ valid: true, machine_hash: mh, timestamp: body.timestamp, ip });
221
+ default: return Response.json({ error: "unknown_route", route }, { status: 404 });
325
222
  }
326
223
  });
@@ -0,0 +1,26 @@
1
+ -- Migration: add fail_count and locked to clauth_machines
2
+ -- Run via: clauth install (picks this up automatically) or apply manually
3
+
4
+ ALTER TABLE clauth_machines
5
+ ADD COLUMN IF NOT EXISTS fail_count INTEGER NOT NULL DEFAULT 0,
6
+ ADD COLUMN IF NOT EXISTS locked BOOLEAN NOT NULL DEFAULT false;
7
+
8
+ -- Index for fast lockout checks
9
+ CREATE INDEX IF NOT EXISTS idx_clauth_machines_locked
10
+ ON clauth_machines (machine_hash, locked);
11
+
12
+ -- Admin helper: unlock a machine
13
+ -- Usage: SELECT clauth_unlock_machine('your_machine_hash');
14
+ CREATE OR REPLACE FUNCTION clauth_unlock_machine(p_machine_hash TEXT)
15
+ RETURNS void
16
+ LANGUAGE plpgsql
17
+ SECURITY DEFINER
18
+ AS $$
19
+ BEGIN
20
+ UPDATE clauth_machines
21
+ SET locked = false, fail_count = 0
22
+ WHERE machine_hash = p_machine_hash;
23
+ END;
24
+ $$;
25
+
26
+ REVOKE EXECUTE ON FUNCTION clauth_unlock_machine(TEXT) FROM PUBLIC;