@lifeaitools/clauth 1.8.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/index.js CHANGED
@@ -17,6 +17,49 @@ import path from "path";
17
17
  const config = new Conf(getConfOptions());
18
18
  const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
19
19
 
20
+ function shellSingleQuote(value) {
21
+ return `'${String(value ?? "").replace(/'/g, "''")}'`;
22
+ }
23
+
24
+ function enrollmentScriptName(label) {
25
+ const slug = String(label || "new-computer")
26
+ .toLowerCase()
27
+ .replace(/[^a-z0-9]+/g, "-")
28
+ .replace(/^-+|-+$/g, "")
29
+ .slice(0, 40) || "new-computer";
30
+ return `clauth-enroll-${slug}.ps1`;
31
+ }
32
+
33
+ function writeEnrollmentScript({ supabaseUrl, anonKey, enrollmentCode, label }) {
34
+ const appDir = process.platform === "win32"
35
+ ? path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "clauth")
36
+ : path.join(os.homedir(), ".config", "clauth");
37
+ fs.mkdirSync(appDir, { recursive: true });
38
+ const scriptPath = path.join(appDir, enrollmentScriptName(label));
39
+ const script = [
40
+ "$ErrorActionPreference = 'Stop'",
41
+ "$label = $env:COMPUTERNAME",
42
+ "if (-not $label) { $label = [System.Net.Dns]::GetHostName() }",
43
+ "Write-Host 'Installing clauth...'",
44
+ "npm install -g @lifeaitools/clauth@latest",
45
+ "Write-Host 'Enrolling this computer with clauth...'",
46
+ [
47
+ "clauth setup",
48
+ `--supabase-url ${shellSingleQuote(supabaseUrl)}`,
49
+ `--anon-key ${shellSingleQuote(anonKey)}`,
50
+ `--enrollment-code ${shellSingleQuote(enrollmentCode)}`,
51
+ "--label \"$label\"",
52
+ ].join(" "),
53
+ "Write-Host 'Installing clauth startup service...'",
54
+ "clauth serve install",
55
+ "Write-Host 'clauth enrollment complete.'",
56
+ "$self = $PSCommandPath",
57
+ "Start-Process powershell.exe -WindowStyle Hidden -ArgumentList @('-NoProfile','-Command',\"Start-Sleep -Seconds 2; Remove-Item -LiteralPath '$self' -Force -ErrorAction SilentlyContinue\")",
58
+ ].join("\r\n");
59
+ fs.writeFileSync(scriptPath, `${script}\r\n`, "utf8");
60
+ return scriptPath;
61
+ }
62
+
20
63
  // ============================================================
21
64
  // Password prompt helper
22
65
  // ============================================================
@@ -186,7 +229,7 @@ program
186
229
  program
187
230
  .command("codevelop")
188
231
  .description("Install and launch Claude/Codex co-development terminal sessions")
189
- .argument("[action]", "install-terminal | start | ask | reply | inbox | listen | request-partner | launch-peer | check-partner | sync | help", "help")
232
+ .argument("[action]", "install-terminal | start | join | say | read | watch | who | ask | reply | inbox | listen | request-partner | launch-peer | check-partner | sync | help", "help")
190
233
  .option("--repo <path>", "Repo root", "C:\\Dev\\regen-root")
191
234
  .option("--port <port>", "Isolated clauth port", "53137")
192
235
  .option("--base-url <url>", "Override clauth base URL")
@@ -194,8 +237,10 @@ program
194
237
  .option("--session <idOrManifestPath>", "Co-develop session id or manifest path")
195
238
  .option("--task <task>", "Initial partner request task")
196
239
  .option("--context <path>", "Context, plan, or architecture file the partner should read first")
197
- .option("--to <peer>", "Target peer for ask/reply: claude or codex")
198
- .option("--from <peer>", "Sender peer for ask/reply: claude or codex")
240
+ .option("--channel <name>", "Ad hoc channel name for join/say/read/watch")
241
+ .option("--message <text>", "Ad hoc message for say")
242
+ .option("--to <peer>", "Target peer for ask/reply, or optional direct ad hoc recipient for say")
243
+ .option("--from <peer>", "Sender peer for ask/reply")
199
244
  .option("--turn <turn_id>", "Turn ID for reply")
200
245
  .option("--role <role>", "Turn role, e.g. reviewer or builder")
201
246
  .option("--skill <skill>", "Requested skill name, e.g. rdc:review")
@@ -208,10 +253,11 @@ program
208
253
  .option("--next <items>", "Reply next actions; separate multiple items with semicolons")
209
254
  .option("--wait", "For ask: wait for a matching reply")
210
255
  .option("--once", "For listen: exit after the first message event")
256
+ .option("--json", "For read: print raw JSON")
211
257
  .option("--timeout-ms <ms>", "Wait timeout in milliseconds", "300000")
212
258
  .option("--interval-ms <ms>", "Wait polling interval in milliseconds", "2000")
213
259
  .option("--start-isolated-clauth", "Start isolated clauth if the selected port is not running")
214
- .option("--peer <peer>", "Peer for launch-peer: claude or codex")
260
+ .option("--peer <peer>", "Peer name for launch-peer/check-partner/sync, or ad hoc name such as codex-1")
215
261
  .option("--dry-run", "Print and write session manifest without opening Windows Terminal")
216
262
  .option("--no-open", "Create session/config but do not open Windows Terminal")
217
263
  .option("--print-only", "For launch-peer: resolve command without starting the CLI")
@@ -257,29 +303,62 @@ program
257
303
  .command("setup")
258
304
  .description("Register this machine with the vault (run after clauth install)")
259
305
  .option("--admin-token <token>", "Bootstrap token (from clauth install output)")
306
+ .option("--enrollment-code <code>", "One-time enrollment code from clauth enroll")
307
+ .option("--supabase-url <url>", "Vault Supabase URL, for enrolling a new computer without running clauth install")
308
+ .option("--anon-key <key>", "Vault Supabase anon key, for enrolling a new computer without running clauth install")
309
+ .option("--install-id <id>", "Logical install/owner group for admin-token setup", "default")
260
310
  .option("--label <label>", "Human label for this machine")
261
311
  .option("-p, --pw <password>", "Password (skip interactive prompt)")
262
312
  .action(async (opts) => {
263
313
  console.log(chalk.cyan("\nšŸ” clauth setup\n"));
264
314
 
265
- // URL + anon key already saved by clauth install — fail fast if missing
266
- const savedUrl = config.get("supabase_url");
267
- const savedAnon = config.get("supabase_anon_key");
315
+ if (opts.supabaseUrl) config.set("supabase_url", opts.supabaseUrl);
316
+ if (opts.anonKey) config.set("supabase_anon_key", opts.anonKey);
317
+
318
+ // URL + anon key may already be saved by clauth install, or provided by
319
+ // an old-machine enrollment command.
320
+ let savedUrl = config.get("supabase_url");
321
+ let savedAnon = config.get("supabase_anon_key");
268
322
  if (!savedUrl || !savedAnon) {
269
- console.log(chalk.yellow(" Supabase config not found. Run clauth install first.\n"));
270
- process.exit(1);
323
+ const configAnswers = await inquirer.prompt([
324
+ { type: "input", name: "supabaseUrl", message: "Vault Supabase URL:", default: savedUrl || opts.supabaseUrl || "" },
325
+ { type: "password", name: "anonKey", message: "Vault anon key:", mask: "*", default: savedAnon || opts.anonKey || "" },
326
+ ]);
327
+ if (!configAnswers.supabaseUrl || !configAnswers.anonKey) {
328
+ console.log(chalk.yellow(" Supabase config not found. Run clauth install first, or provide --supabase-url and --anon-key.\n"));
329
+ process.exit(1);
330
+ }
331
+ config.set("supabase_url", configAnswers.supabaseUrl);
332
+ config.set("supabase_anon_key", configAnswers.anonKey);
333
+ savedUrl = configAnswers.supabaseUrl;
334
+ savedAnon = configAnswers.anonKey;
271
335
  }
272
336
  console.log(chalk.gray(` Project: ${savedUrl}\n`));
273
337
 
274
338
  let answers;
275
- if (opts.pw && opts.adminToken) {
339
+ if (opts.pw && (opts.adminToken || opts.enrollmentCode)) {
276
340
  // Non-interactive mode — all flags provided
277
- answers = { label: opts.label || os.hostname(), pw: opts.pw, adminTk: opts.adminToken };
341
+ answers = {
342
+ label: opts.label || os.hostname(),
343
+ pw: opts.pw,
344
+ adminTk: opts.adminToken,
345
+ enrollmentCode: opts.enrollmentCode,
346
+ };
347
+ } else if (opts.enrollmentCode) {
348
+ const pw = opts.pw || await promptPassword("Set clauth password for this computer");
349
+ answers = {
350
+ label: opts.label || os.hostname(),
351
+ pw,
352
+ adminTk: opts.adminToken,
353
+ enrollmentCode: opts.enrollmentCode,
354
+ };
278
355
  } else {
279
356
  answers = await inquirer.prompt([
280
357
  { type: "input", name: "label", message: "Machine label:", default: opts.label || os.hostname() },
281
358
  { type: "password", name: "pw", message: "Set password:", mask: "*", default: opts.pw || "" },
282
- { type: "password", name: "adminTk", message: "Bootstrap token:", mask: "*",
359
+ { type: "password", name: "enrollmentCode", message: "Enrollment code (preferred for new computer; leave blank if using bootstrap token):", mask: "*",
360
+ default: opts.enrollmentCode || "" },
361
+ { type: "password", name: "adminTk", message: "Bootstrap token (admin fallback):", mask: "*",
283
362
  default: opts.adminToken || "" },
284
363
  ]);
285
364
  }
@@ -288,9 +367,11 @@ program
288
367
  try {
289
368
  const machineHash = getMachineHash();
290
369
  const seedHash = deriveSeedHash(machineHash, answers.pw);
291
- const result = await api.registerMachine(machineHash, seedHash, answers.label, answers.adminTk);
370
+ const result = answers.enrollmentCode
371
+ ? await api.redeemEnrollment(machineHash, seedHash, answers.label, answers.enrollmentCode)
372
+ : await api.registerMachine(machineHash, seedHash, answers.label, answers.adminTk, { install_id: opts.installId || "default" });
292
373
  if (result.error) throw new Error(result.error);
293
- spinner.succeed(chalk.green(`Machine registered: ${machineHash.slice(0,12)}...`));
374
+ spinner.succeed(chalk.green(`Machine registered: ${machineHash.slice(0,12)}... install_id=${result.install_id || opts.installId || "default"}`));
294
375
 
295
376
  console.log(chalk.green("\nāœ“ clauth is ready.\n"));
296
377
  console.log(chalk.cyan(" clauth test — verify connection"));
@@ -301,6 +382,55 @@ program
301
382
  }
302
383
  });
303
384
 
385
+ // ──────────────────────────────────────────────
386
+ // clauth enroll
387
+ // ──────────────────────────────────────────────
388
+ program
389
+ .command("enroll")
390
+ .description("Create a one-time enrollment code for adding another computer")
391
+ .option("--label <label>", "Suggested label for the new computer")
392
+ .option("--ttl-minutes <minutes>", "Enrollment lifetime, 5 to 1440 minutes", "60")
393
+ .option("--install-id <id>", "Override install id; default is current machine's install id")
394
+ .option("-p, --pw <password>", "Password (or will prompt)")
395
+ .action(async (opts) => {
396
+ console.log(chalk.cyan("\nšŸ” clauth enroll\n"));
397
+ const auth = await getAuth(opts.pw);
398
+ const spinner = ora("Creating one-time machine enrollment...").start();
399
+ try {
400
+ const result = await api.createEnrollment(
401
+ auth.password,
402
+ auth.machineHash,
403
+ auth.token,
404
+ auth.timestamp,
405
+ opts.label,
406
+ Number(opts.ttlMinutes || 60),
407
+ opts.installId
408
+ );
409
+ if (result.error) throw new Error(result.error);
410
+ const supabaseUrl = config.get("supabase_url");
411
+ const anonKey = config.get("supabase_anon_key");
412
+ const scriptPath = writeEnrollmentScript({
413
+ supabaseUrl,
414
+ anonKey,
415
+ enrollmentCode: result.enrollment_code,
416
+ label: opts.label,
417
+ });
418
+ spinner.succeed(chalk.green(`Enrollment created for install_id=${result.install_id}`));
419
+ console.log("");
420
+ console.log(chalk.bold(" Enrollment code:"));
421
+ console.log(chalk.white(` ${result.enrollment_code}`));
422
+ console.log("");
423
+ console.log(chalk.bold(" On the new computer:"));
424
+ console.log(chalk.gray(` Run this one-time script: ${scriptPath}`));
425
+ console.log(chalk.gray(" It installs clauth, enrolls with this code, installs startup, then deletes itself."));
426
+ console.log("");
427
+ console.log(chalk.gray(` Expires: ${result.expires_at}`));
428
+ } catch (err) {
429
+ spinner.fail(chalk.red(`Enroll failed: ${err.message}`));
430
+ process.exitCode = 1;
431
+ }
432
+ });
433
+
304
434
  // ──────────────────────────────────────────────
305
435
  // clauth status
306
436
  // ──────────────────────────────────────────────
@@ -913,6 +1043,7 @@ program
913
1043
  .option("--tunnel <hostname>", "Fixed tunnel hostname (e.g. clauth.prtrust.fund) — uses named Cloudflare Tunnel instead of random URL")
914
1044
  .option("--staged", "Start on staging port (52438) for blue-green verification before make-live")
915
1045
  .option("--isolated", "Run on a non-live port without touching live PID files, browser, or boot-key credentials")
1046
+ .option("--from-boot-key", "Internal: password came from boot.key auto-unlock (degrade gracefully on verify failure)")
916
1047
  .option("--action <action>", "Internal: action override for daemon child")
917
1048
  .addHelpText("after", `
918
1049
  Actions:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/clauth",
3
- "version": "1.8.0",
3
+ "version": "1.9.1",
4
4
  "description": "Hardware-bound credential vault for the LIFEAI infrastructure stack",
5
5
  "type": "module",
6
6
  "bin": {
Binary file
Binary file
Binary file
@@ -12,18 +12,36 @@ const ALLOWED_IPS: string[] = (Deno.env.get("CLAUTH_ALLOWED_IPS") || "")
12
12
  .split(",").map(s => s.trim()).filter(Boolean);
13
13
 
14
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;
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";
18
19
 
19
- async function hmacSha256(key: string, message: string): Promise<string> {
20
+ async function hmacSha256(key: string, message: string): Promise<string> {
20
21
  const cryptoKey = await crypto.subtle.importKey(
21
22
  "raw", new TextEncoder().encode(key),
22
23
  { name: "HMAC", hash: "SHA-256" }, false, ["sign"]
23
24
  );
24
25
  const sig = await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(message));
25
26
  return Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, "0")).join("");
26
- }
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
+ }
27
45
 
28
46
  function getClientIP(req: Request): string {
29
47
  return req.headers.get("cf-connecting-ip") ||
@@ -53,22 +71,23 @@ async function validateHMAC(sb: any, body: any): Promise<{ valid: boolean; reaso
53
71
  const now = Date.now();
54
72
  if (Math.abs(now - body.timestamp) > REPLAY_WINDOW_MS) return { valid: false, reason: "timestamp_expired" };
55
73
 
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();
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();
59
77
 
60
78
  if (error || !machine) return { valid: false, reason: "machine_not_found" };
61
79
  if (!machine.enabled) return { valid: false, reason: "machine_disabled" };
62
80
  if (machine.locked) return { valid: false, reason: "machine_locked" };
63
81
 
64
82
  const window = Math.floor(body.timestamp / REPLAY_WINDOW_MS);
65
- const message = `${body.machine_hash}:${window}`;
66
- const expected = await hmacSha256(body.password, message);
67
-
68
- if (expected !== body.token) {
69
- const newCount = (machine.fail_count || 0) + 1;
70
- const shouldLock = newCount >= MAX_FAIL_COUNT;
71
- await sb.from("clauth_machines")
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")
72
91
  .update({ fail_count: newCount, locked: shouldLock })
73
92
  .eq("machine_hash", body.machine_hash);
74
93
  return { valid: false, reason: shouldLock ? `machine_locked after ${newCount} failures` : `invalid_token (${newCount}/${MAX_FAIL_COUNT})` };
@@ -178,7 +197,7 @@ async function handleStatus(sb: any, body: any, mh: string) {
178
197
  return { services: services || [] };
179
198
  }
180
199
 
181
- async function handleChangePassword(sb: any, body: any, mh: string) {
200
+ async function handleChangePassword(sb: any, body: any, mh: string) {
182
201
  const { new_hmac_seed_hash } = body;
183
202
  if (!new_hmac_seed_hash) return { error: "new_hmac_seed_hash required" };
184
203
  const { error } = await sb.from("clauth_machines")
@@ -187,18 +206,91 @@ async function handleChangePassword(sb: any, body: any, mh: string) {
187
206
  if (error) return { error: error.message };
188
207
  await auditLog(sb, mh, "system", "change-password", "success");
189
208
  return { success: true };
190
- }
191
-
192
- async function handleRegisterMachine(sb: any, body: any) {
193
- const { machine_hash, hmac_seed_hash, label, admin_token } = body;
194
- if (admin_token !== ADMIN_BOOTSTRAP_TOKEN) return { error: "invalid_admin_token" };
195
- const { error } = await sb.from("clauth_machines").upsert(
196
- { machine_hash, hmac_seed_hash, label, enabled: true, fail_count: 0, locked: false },
197
- { onConflict: "machine_hash" }
198
- );
199
- if (error) return { error: error.message };
200
- return { success: true, machine_hash };
201
- }
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
+ const { data: consumedRows, error: consumeError } = await sb.from("clauth_machine_enrollments")
263
+ .update({ consumed_at: nowIso, redeemed_by_machine_hash: machine_hash })
264
+ .eq("id", enrollment.id)
265
+ .is("consumed_at", null)
266
+ .select("id");
267
+ if (consumeError) return { error: consumeError.message };
268
+ if (!consumedRows || consumedRows.length !== 1) return { error: "enrollment_already_used" };
269
+
270
+ const { error: machineError } = await sb.from("clauth_machines").upsert(
271
+ { machine_hash, hmac_seed_hash, label, install_id, enabled: true, fail_count: 0, locked: false },
272
+ { onConflict: "machine_hash" }
273
+ );
274
+ if (machineError) return { error: machineError.message };
275
+
276
+ await auditLog(sb, machine_hash, "system", "redeem-enrollment", "success", `install_id=${install_id}`);
277
+ return { success: true, machine_hash, install_id };
278
+ }
279
+
280
+ async function handleRegisterMachine(sb: any, body: any) {
281
+ const { machine_hash, hmac_seed_hash, label, admin_token } = body;
282
+ if (body.enrollment_code || body.invite_code) {
283
+ return handleRedeemEnrollment(sb, { ...body, enrollment_code: body.enrollment_code || body.invite_code });
284
+ }
285
+ if (admin_token !== ADMIN_BOOTSTRAP_TOKEN) return { error: "invalid_admin_token" };
286
+ const install_id = normalizeInstallId(body.install_id);
287
+ const { error } = await sb.from("clauth_machines").upsert(
288
+ { machine_hash, hmac_seed_hash, label, install_id, enabled: true, fail_count: 0, locked: false },
289
+ { onConflict: "machine_hash" }
290
+ );
291
+ if (error) return { error: error.message };
292
+ return { success: true, machine_hash, install_id };
293
+ }
202
294
 
203
295
  Deno.serve(async (req: Request) => {
204
296
  if (req.method === "OPTIONS") {
@@ -216,7 +308,8 @@ Deno.serve(async (req: Request) => {
216
308
  const sb = createClient(SUPABASE_URL, SERVICE_ROLE_KEY);
217
309
  const ip = getClientIP(req);
218
310
 
219
- if (route === "register-machine") return Response.json(await handleRegisterMachine(sb, body));
311
+ if (route === "register-machine") return Response.json(await handleRegisterMachine(sb, body));
312
+ if (route === "redeem-enrollment") return Response.json(await handleRedeemEnrollment(sb, body));
220
313
 
221
314
  const ipCheck = checkIP(ip);
222
315
  if (!ipCheck.allowed) {
@@ -247,9 +340,10 @@ Deno.serve(async (req: Request) => {
247
340
  case "update": return Response.json(await handleUpdate(sb, body, mh));
248
341
  case "remove": return Response.json(await handleRemove(sb, body, mh));
249
342
  case "revoke": return Response.json(await handleRevoke(sb, body, mh));
250
- case "status": return Response.json(await handleStatus(sb, body, mh));
251
- case "change-password": return Response.json(await handleChangePassword(sb, body, mh));
252
- case "test": return Response.json({ valid: true, machine_hash: mh, timestamp: body.timestamp, ip });
343
+ case "status": return Response.json(await handleStatus(sb, body, mh));
344
+ case "change-password": return Response.json(await handleChangePassword(sb, body, mh));
345
+ case "create-enrollment": return Response.json(await handleCreateEnrollment(sb, body, mh));
346
+ case "test": return Response.json({ valid: true, machine_hash: mh, timestamp: body.timestamp, ip });
253
347
  default: return Response.json({ error: "unknown_route", route }, { status: 404 });
254
348
  }
255
349
  });
@@ -27,15 +27,18 @@ create table if not exists public.clauth_services (
27
27
  -- ============================================================
28
28
  -- Machine Registry (hardware fingerprints — hashed only)
29
29
  -- ============================================================
30
- create table if not exists public.clauth_machines (
31
- id uuid primary key default gen_random_uuid(),
32
- machine_hash text not null unique, -- SHA256(machine_id + os_install_id)
33
- label text, -- e.g. 'Dave-Desktop-Win11'
34
- hmac_seed_hash text not null, -- SHA256 of the HMAC seed stored in vault
35
- enabled boolean not null default true,
36
- created_at timestamptz not null default now(),
37
- last_seen timestamptz
38
- );
30
+ create table if not exists public.clauth_machines (
31
+ id uuid primary key default gen_random_uuid(),
32
+ install_id text not null default 'default', -- logical vault install / owner group
33
+ machine_hash text not null unique, -- SHA256(machine_id + os_install_id)
34
+ label text, -- e.g. 'Dave-Desktop-Win11'
35
+ hmac_seed_hash text not null, -- SHA256 of the HMAC seed stored in vault
36
+ enabled boolean not null default true,
37
+ fail_count integer not null default 0,
38
+ locked boolean not null default false,
39
+ created_at timestamptz not null default now(),
40
+ last_seen timestamptz
41
+ );
39
42
 
40
43
  -- ============================================================
41
44
  -- Audit Log
@@ -0,0 +1,39 @@
1
+ -- ============================================================
2
+ -- clauth machine enrollment
3
+ -- Migration: 003_machine_enrollments.sql
4
+ -- ============================================================
5
+
6
+ alter table public.clauth_machines
7
+ add column if not exists install_id text not null default 'default';
8
+
9
+ create index if not exists clauth_machines_install_id_idx
10
+ on public.clauth_machines (install_id);
11
+
12
+ create table if not exists public.clauth_machine_enrollments (
13
+ id uuid primary key default gen_random_uuid(),
14
+ install_id text not null default 'default',
15
+ token_hash text not null unique,
16
+ label text,
17
+ created_by_machine_hash text not null references public.clauth_machines(machine_hash) on delete cascade,
18
+ redeemed_by_machine_hash text references public.clauth_machines(machine_hash) on delete set null,
19
+ expires_at timestamptz not null,
20
+ consumed_at timestamptz,
21
+ created_at timestamptz not null default now()
22
+ );
23
+
24
+ create index if not exists clauth_machine_enrollments_install_idx
25
+ on public.clauth_machine_enrollments (install_id, expires_at);
26
+
27
+ alter table public.clauth_machine_enrollments enable row level security;
28
+
29
+ do $$ begin
30
+ if not exists (
31
+ select 1
32
+ from pg_policies
33
+ where policyname = 'no_anon_machine_enrollments'
34
+ and tablename = 'clauth_machine_enrollments'
35
+ ) then
36
+ create policy "no_anon_machine_enrollments"
37
+ on public.clauth_machine_enrollments for all using (false);
38
+ end if;
39
+ end $$;