@lifeaitools/clauth 1.8.1 ā 1.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -13
- package/cli/api.js +35 -17
- package/cli/commands/codevelop.js +283 -0
- package/cli/commands/install.js +10 -8
- package/cli/commands/serve.js +10733 -9219
- package/cli/index.js +145 -14
- package/package.json +1 -1
- package/scripts/bin/bootstrap-linux +0 -0
- package/scripts/bin/bootstrap-macos +0 -0
- package/scripts/bin/bootstrap-win.exe +0 -0
- package/supabase/functions/auth-vault/index.ts +98 -3
- package/supabase/migrations/001_clauth_schema.sql +12 -9
- package/supabase/migrations/003_machine_enrollments.sql +39 -0
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("--
|
|
198
|
-
.option("--
|
|
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
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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 = {
|
|
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: "
|
|
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 =
|
|
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
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -15,6 +15,7 @@ const RATE_LIMIT_MAX = 30;
|
|
|
15
15
|
const RATE_LIMIT_WINDOW = 60;
|
|
16
16
|
const REPLAY_WINDOW_MS = 5 * 60 * 1000;
|
|
17
17
|
const MAX_FAIL_COUNT = 5;
|
|
18
|
+
const DEFAULT_INSTALL_ID = "default";
|
|
18
19
|
|
|
19
20
|
async function hmacSha256(key: string, message: string): Promise<string> {
|
|
20
21
|
const cryptoKey = await crypto.subtle.importKey(
|
|
@@ -25,6 +26,23 @@ async function hmacSha256(key: string, message: string): Promise<string> {
|
|
|
25
26
|
return Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, "0")).join("");
|
|
26
27
|
}
|
|
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
|
+
|
|
28
46
|
function getClientIP(req: Request): string {
|
|
29
47
|
return req.headers.get("cf-connecting-ip") ||
|
|
30
48
|
req.headers.get("x-real-ip") ||
|
|
@@ -64,8 +82,9 @@ async function validateHMAC(sb: any, body: any): Promise<{ valid: boolean; reaso
|
|
|
64
82
|
const window = Math.floor(body.timestamp / REPLAY_WINDOW_MS);
|
|
65
83
|
const message = `${body.machine_hash}:${window}`;
|
|
66
84
|
const expected = await hmacSha256(body.password, message);
|
|
85
|
+
const seedHash = await sha256Hex(`seed:${body.machine_hash}:${body.password}`);
|
|
67
86
|
|
|
68
|
-
if (expected !== body.token) {
|
|
87
|
+
if (seedHash !== machine.hmac_seed_hash || expected !== body.token) {
|
|
69
88
|
const newCount = (machine.fail_count || 0) + 1;
|
|
70
89
|
const shouldLock = newCount >= MAX_FAIL_COUNT;
|
|
71
90
|
await sb.from("clauth_machines")
|
|
@@ -189,15 +208,89 @@ async function handleChangePassword(sb: any, body: any, mh: string) {
|
|
|
189
208
|
return { success: true };
|
|
190
209
|
}
|
|
191
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
|
+
|
|
192
281
|
async function handleRegisterMachine(sb: any, body: any) {
|
|
193
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
|
+
}
|
|
194
286
|
if (admin_token !== ADMIN_BOOTSTRAP_TOKEN) return { error: "invalid_admin_token" };
|
|
287
|
+
const install_id = normalizeInstallId(body.install_id);
|
|
195
288
|
const { error } = await sb.from("clauth_machines").upsert(
|
|
196
|
-
{ machine_hash, hmac_seed_hash, label, enabled: true, fail_count: 0, locked: false },
|
|
289
|
+
{ machine_hash, hmac_seed_hash, label, install_id, enabled: true, fail_count: 0, locked: false },
|
|
197
290
|
{ onConflict: "machine_hash" }
|
|
198
291
|
);
|
|
199
292
|
if (error) return { error: error.message };
|
|
200
|
-
return { success: true, machine_hash };
|
|
293
|
+
return { success: true, machine_hash, install_id };
|
|
201
294
|
}
|
|
202
295
|
|
|
203
296
|
Deno.serve(async (req: Request) => {
|
|
@@ -217,6 +310,7 @@ Deno.serve(async (req: Request) => {
|
|
|
217
310
|
const ip = getClientIP(req);
|
|
218
311
|
|
|
219
312
|
if (route === "register-machine") return Response.json(await handleRegisterMachine(sb, body));
|
|
313
|
+
if (route === "redeem-enrollment") return Response.json(await handleRedeemEnrollment(sb, body));
|
|
220
314
|
|
|
221
315
|
const ipCheck = checkIP(ip);
|
|
222
316
|
if (!ipCheck.allowed) {
|
|
@@ -249,6 +343,7 @@ Deno.serve(async (req: Request) => {
|
|
|
249
343
|
case "revoke": return Response.json(await handleRevoke(sb, body, mh));
|
|
250
344
|
case "status": return Response.json(await handleStatus(sb, body, mh));
|
|
251
345
|
case "change-password": return Response.json(await handleChangePassword(sb, body, mh));
|
|
346
|
+
case "create-enrollment": return Response.json(await handleCreateEnrollment(sb, body, mh));
|
|
252
347
|
case "test": return Response.json({ valid: true, machine_hash: mh, timestamp: body.timestamp, ip });
|
|
253
348
|
default: return Response.json({ error: "unknown_route", route }, { status: 404 });
|
|
254
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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 $$;
|