provision TLS certs during grove auth login

Hub API serves client certs at GET /api/auth/tls-certs (authenticated).
CLI downloads ca.crt, client.crt, client.key to ~/.grove/tls/ after login.
Mount /data/grove/tls read-only into hub-api container.
Anton Kaminsky22d agofafa2604c117parent 15612e6
4 files changed+56-1
cli/package.json
@@ -1,6 +1,6 @@
11{
22 "name": "grove-scm",
3 "version": "0.1.2",
3 "version": "0.1.3",
44 "description": "CLI for Grove — self-hosted source control built on Sapling and Mononoke",
55 "type": "module",
66 "bin": {
77
cli/src/commands/auth-login.ts
@@ -1,4 +1,7 @@
11import { intro, outro, spinner, log } from "@clack/prompts";
2import { mkdirSync, writeFileSync, existsSync } from "node:fs";
3import { join } from "node:path";
4import { homedir } from "node:os";
25import { waitForAuthCallback } from "../auth-server.js";
36import { loadConfig, saveConfig } from "../config.js";
47
@@ -41,6 +44,29 @@
4144 throw new Error("Authentication timed out (10 minutes)");
4245}
4346
47async function provisionTlsCerts(hub: string, token: string) {
48 const s = spinner();
49 s.start("Downloading TLS certificates");
50 try {
51 const res = await fetch(`${hub}/api/auth/tls-certs`, {
52 headers: { Authorization: `Bearer ${token}` },
53 });
54 if (!res.ok) {
55 s.stop("TLS certificates not available (non-fatal)");
56 return;
57 }
58 const { ca, cert, key } = await res.json() as { ca: string; cert: string; key: string };
59 const tlsDir = join(homedir(), ".grove", "tls");
60 mkdirSync(tlsDir, { recursive: true });
61 writeFileSync(join(tlsDir, "ca.crt"), ca);
62 writeFileSync(join(tlsDir, "client.crt"), cert);
63 writeFileSync(join(tlsDir, "client.key"), key, { mode: 0o600 });
64 s.stop("TLS certificates saved");
65 } catch {
66 s.stop("TLS certificates not available (non-fatal)");
67 }
68}
69
4470export async function authLogin(args: string[]) {
4571 const config = await loadConfig();
4672
@@ -58,6 +84,7 @@
5884 const token = await deviceCodeFlow(config);
5985 config.token = token;
6086 await saveConfig(config);
87 await provisionTlsCerts(config.hub, token);
6188 outro("Authenticated! Token saved to ~/.grove/config.json");
6289 } catch (err: any) {
6390 log.error(err.message);
@@ -85,6 +112,7 @@
85112 s.stop("Authentication received");
86113 config.token = token;
87114 await saveConfig(config);
115 await provisionTlsCerts(config.hub, token);
88116 outro("Authenticated! Token saved to ~/.grove/config.json");
89117 } catch (err: any) {
90118 s.stop("Authentication failed");
91119
hub/docker-compose.yml
@@ -116,6 +116,7 @@
116116 image: localhost:5000/grove-hub-api:latest
117117 volumes:
118118 - hub-data:/data
119 - /data/grove/tls:/data/grove/tls:ro
119120 environment:
120121 - PORT=4000
121122 - DATABASE_PATH=/data/hub.db
122123
hub-api/src/routes/auth.ts
@@ -358,6 +358,32 @@
358358 },
359359 });
360360
361 // ── TLS client certificates (for Sapling mTLS with Mononoke) ────
362
363 const TLS_DIR = process.env.TLS_DIR ?? "/data/grove/tls";
364
365 app.get("/tls-certs", {
366 preHandler: [(app as any).authenticate],
367 handler: async (_request, reply) => {
368 const { readFileSync, existsSync } = await import("fs");
369 const { join } = await import("path");
370
371 const caPath = join(TLS_DIR, "ca.crt");
372 const certPath = join(TLS_DIR, "client.crt");
373 const keyPath = join(TLS_DIR, "client.key");
374
375 if (!existsSync(caPath) || !existsSync(certPath) || !existsSync(keyPath)) {
376 return reply.code(503).send({ error: "TLS certificates not configured on this instance" });
377 }
378
379 return {
380 ca: readFileSync(caPath, "utf-8"),
381 cert: readFileSync(certPath, "utf-8"),
382 key: readFileSync(keyPath, "utf-8"),
383 };
384 },
385 });
386
361387 // ── Get current user ─────────────────────────────────────────────
362388
363389 app.get("/me", {
364390