refactor: rename cli-v2 → cli, archive legacy cli, plus broker-side grants + auto-migrate
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

- apps/cli/ is now the canonical CLI (was apps/cli-v2/).
- apps/cli/ legacy v0 archived as branch 'legacy-cli-archive' and tag
  'cli-v0-legacy-final' before deletion; git history preserves it too.
- .github/workflows/release-cli.yml paths updated.
- pnpm-lock.yaml regenerated.

Broker-side peer-grant enforcement (spec: 2026-04-15-per-peer-capabilities):
- 0020_peer-grants.sql adds peer_grants jsonb + GIN index on mesh.member.
- handleSend in broker fetches recipient grant maps once per send, drops
  messages silently when sender lacks the required capability.
- POST /cli/mesh/:slug/grants to update from CLI; broker_messages_dropped_by_grant_total metric.
- CLI grant/revoke/block now mirror to broker via syncToBroker.

Auto-migrate on broker startup:
- apps/broker/src/migrate.ts runs drizzle migrate with pg_advisory_lock
  before the HTTP server binds. Exits non-zero on failure so Coolify
  healthcheck fails closed.
- Dockerfile copies packages/db/migrations into /app/migrations.
- postgres 3.4.5 added as direct broker dep.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-15 08:44:52 +01:00
parent c9ede3d469
commit ee12510ef1
374 changed files with 14706 additions and 11307 deletions

View File

@@ -0,0 +1,70 @@
import { createServer, type Server } from "node:http";
export interface CallbackListener {
port: number;
token: Promise<string>;
close: () => void;
}
export function startCallbackListener(): Promise<CallbackListener> {
return new Promise((resolveStart) => {
let resolveToken: (token: string) => void;
let resolved = false;
const tokenPromise = new Promise<string>((r) => {
resolveToken = r;
});
const server: Server = createServer((req, res) => {
const url = new URL(req.url!, "http://localhost");
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Origin": "https://claudemesh.com",
"Access-Control-Allow-Methods": "GET",
"Access-Control-Allow-Headers": "Content-Type",
});
res.end();
return;
}
if (url.pathname === "/ping") {
res.writeHead(200, {
"Content-Type": "text/plain",
"Access-Control-Allow-Origin": "https://claudemesh.com",
});
res.end("ok");
return;
}
if (url.pathname === "/callback") {
const token = url.searchParams.get("token");
if (token && !resolved) {
resolved = true;
res.writeHead(200, {
"Content-Type": "text/html",
"Access-Control-Allow-Origin": "https://claudemesh.com",
});
res.end("<html><body><h2>Done! You can close this tab.</h2></body></html>");
resolveToken(token);
setTimeout(() => server.close(), 500);
} else {
res.writeHead(400, { "Content-Type": "text/plain" });
res.end("Missing token");
}
return;
}
res.writeHead(404);
res.end();
});
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as { port: number };
resolveStart({
port: addr.port,
token: tokenPromise,
close: () => server.close(),
});
});
});
}

View File

@@ -0,0 +1,51 @@
import { my } from "~/services/api/facade.js";
import { ApiError } from "~/services/api/facade.js";
import { getStoredToken, clearToken } from "./token-store.js";
import { NotSignedIn } from "./errors.js";
import type { WhoAmIResult } from "./schemas.js";
function requireToken(): string {
const auth = getStoredToken();
if (!auth) throw new NotSignedIn();
return auth.session_token;
}
export async function whoAmI(): Promise<WhoAmIResult> {
const auth = getStoredToken();
if (!auth) return { signed_in: false };
try {
const profile = await my.getProfile(auth.session_token);
const meshes = await my.getMeshes(auth.session_token);
const owned = meshes.filter((m) => m.role === "owner").length;
return {
signed_in: true,
user: profile,
token_source: auth.token_source,
meshes: { owned, guest: meshes.length - owned },
};
} catch (err) {
if (err instanceof ApiError && err.isUnauthorized) {
clearToken();
return { signed_in: false };
}
throw err;
}
}
export async function logout(): Promise<{ revoked: boolean }> {
const token = requireToken();
let revoked = false;
try {
await my.revokeSession(token);
revoked = true;
} catch {}
clearToken();
return { revoked };
}
export async function register(callbackPort: number): Promise<void> {
const { openBrowser } = await import("~/services/spawn/facade.js");
const url = `https://claudemesh.com/register?source=cli&callback=http://localhost:${callbackPort}`;
await openBrowser(url);
}

View File

@@ -0,0 +1,50 @@
import { URLS } from "~/constants/urls.js";
export interface SyncResult {
account_id: string;
meshes: Array<{
mesh_id: string;
slug: string;
broker_url: string;
member_id: string;
role: "admin" | "member";
}>;
}
export async function syncWithBroker(
syncToken: string,
peerPubkey: string,
displayName: string,
brokerBaseUrl?: string,
): Promise<SyncResult> {
const base = brokerBaseUrl ?? deriveHttpUrl(URLS.BROKER);
const res = await fetch(`${base}/cli-sync`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sync_token: syncToken,
peer_pubkey: peerPubkey,
display_name: displayName,
}),
});
if (!res.ok) {
const body = await res.text();
let msg: string;
try { msg = (JSON.parse(body) as { error?: string }).error ?? body; } catch { msg = body; }
throw new Error(`Broker sync failed (${res.status}): ${msg}`);
}
const body = (await res.json()) as { ok: boolean; account_id?: string; meshes?: SyncResult["meshes"]; error?: string };
if (!body.ok) throw new Error(`Broker sync failed: ${body.error ?? "unknown error"}`);
return { account_id: body.account_id!, meshes: body.meshes! };
}
function deriveHttpUrl(wssUrl: string): string {
const url = new URL(wssUrl);
url.protocol = url.protocol === "wss:" ? "https:" : "http:";
url.pathname = url.pathname.replace(/\/ws\/?$/, "");
return url.toString().replace(/\/$/, "");
}

View File

@@ -0,0 +1,132 @@
import { createInterface } from "node:readline";
import { TIMINGS } from "~/constants/timings.js";
import { pub } from "~/services/api/facade.js";
import { getDeviceInfo } from "~/services/device/facade.js";
import { openBrowser } from "~/services/spawn/facade.js";
import { log, warn } from "~/services/logger/facade.js";
import { storeToken } from "./token-store.js";
import { DeviceCodeExpired } from "./errors.js";
export interface DeviceCodeResult {
user: { id: string; display_name: string; email: string };
session_token: string;
}
function parseJwtUser(token: string): { id: string; display_name: string; email: string } {
try {
const parts = token.split(".");
if (parts[1]) {
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString()) as {
sub?: string; email?: string; name?: string; exp?: number;
};
if (payload.exp && payload.exp < Date.now() / 1000) throw new Error("expired");
return {
id: payload.sub ?? "",
display_name: payload.name ?? payload.email ?? "",
email: payload.email ?? "",
};
}
} catch {}
throw new Error("Invalid token");
}
export async function loginWithDeviceCode(): Promise<DeviceCodeResult> {
const device = getDeviceInfo();
const { device_code, user_code, session_id, verification_url, token_url } = await pub.requestDeviceCode({
hostname: device.hostname,
platform: device.platform,
arch: device.arch,
});
const browserUrl = `${verification_url}?session=${session_id}`;
const isTTY = process.stdout.isTTY && !process.env.NO_COLOR;
const orange = (s: string) => isTTY ? `\x1b[38;5;208m${s}\x1b[0m` : s;
const bold = (s: string) => isTTY ? `\x1b[1m${s}\x1b[0m` : s;
const dim = (s: string) => isTTY ? `\x1b[2m${s}\x1b[0m` : s;
log("");
log(" " + orange("claudemesh") + " — sign in to connect your terminal");
log("");
log(" ┌──────────────────────────────────┐");
log(" │ │");
log(" │ Your code: " + bold(user_code) + " │");
log(" │ │");
log(" └──────────────────────────────────┘");
log("");
log(" " + dim("Confirm this code matches your browser."));
log("");
log(" " + dim("If the browser didn't open, visit:"));
log(" " + browserUrl);
log("");
log(" " + dim("Can't use a browser? Generate a token at:"));
log(" " + (token_url || verification_url.replace("/cli-auth", "/token")));
log(" " + dim("Then paste it below."));
log("");
log(" Waiting… " + dim("(paste token or Ctrl-C to cancel)"));
try {
await openBrowser(browserUrl);
} catch {
warn(" Could not open browser automatically.");
}
// Race: device-code polling vs stdin token paste
return new Promise<DeviceCodeResult>((resolve, reject) => {
let done = false;
// Stdin paste listener
const rl = createInterface({ input: process.stdin, output: process.stdout });
rl.on("line", (line) => {
if (done) return;
const trimmed = line.trim();
// JWT format: xxx.yyy.zzz
if (trimmed.split(".").length === 3 && trimmed.length > 50) {
done = true;
rl.close();
try {
const user = parseJwtUser(trimmed);
storeToken({ session_token: trimmed, user, token_source: "manual" });
resolve({ user, session_token: trimmed });
} catch (e) {
reject(new Error("Invalid or expired token. Generate a new one."));
}
}
});
// Device-code polling
const startTime = Date.now();
const poll = async () => {
while (!done && Date.now() - startTime < TIMINGS.DEVICE_CODE_TIMEOUT_MS) {
await new Promise((r) => setTimeout(r, TIMINGS.DEVICE_CODE_POLL_MS));
if (done) return;
try {
const result = await pub.pollDeviceCode(device_code);
if (result.status === "approved" && result.session_token && result.user) {
if (done) return;
done = true;
rl.close();
storeToken({ session_token: result.session_token, user: result.user, token_source: "device-code" });
resolve({ user: result.user, session_token: result.session_token });
return;
}
if (result.status === "expired") {
if (done) return;
done = true;
rl.close();
reject(new DeviceCodeExpired());
return;
}
} catch { /* network error, retry */ }
}
if (!done) {
done = true;
rl.close();
reject(new DeviceCodeExpired());
}
};
poll();
});
}

View File

@@ -0,0 +1,18 @@
export class AuthError extends Error {
constructor(message: string) {
super(message);
this.name = "AuthError";
}
}
export class DeviceCodeExpired extends AuthError {
constructor() {
super("Device code expired. Run `claudemesh login` again.");
}
}
export class NotSignedIn extends AuthError {
constructor() {
super("Not signed in. Run `claudemesh login` first.");
}
}

View File

@@ -0,0 +1,16 @@
export { loginWithDeviceCode } from "./device-code.js";
export type { DeviceCodeResult } from "./device-code.js";
export { whoAmI, logout, register } from "./client.js";
export { syncWithBroker } from "./dashboard-sync.js";
export type { SyncResult } from "./dashboard-sync.js";
export { getStoredToken, storeToken, clearToken } from "./token-store.js";
export { startCallbackListener } from "./callback-listener.js";
export type { CallbackListener } from "./callback-listener.js";
export { AuthError, DeviceCodeExpired, NotSignedIn } from "./errors.js";
export type { StoredAuth, WhoAmIResult } from "./schemas.js";
import { randomBytes } from "node:crypto";
const CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
export function generatePairingCode(): string {
const bytes = randomBytes(4);
return Array.from(bytes, (b) => CHARS[b % CHARS.length]).join("");
}

View File

@@ -0,0 +1,5 @@
export { loginWithDeviceCode } from "./device-code.js";
export { whoAmI, logout, register } from "./client.js";
export { syncWithBroker } from "./dashboard-sync.js";
export { getStoredToken, storeToken, clearToken } from "./token-store.js";
export { startCallbackListener } from "./callback-listener.js";

View File

@@ -0,0 +1 @@
export * from "./facade.js";

View File

@@ -0,0 +1,21 @@
export interface StoredAuth {
session_token: string;
user: {
id: string;
display_name: string;
email: string;
};
token_source: "device-code" | "callback" | "manual";
stored_at: string;
}
export interface WhoAmIResult {
signed_in: boolean;
user?: {
id: string;
display_name: string;
email: string;
};
token_source?: string;
meshes?: { owned: number; guest: number };
}

View File

@@ -0,0 +1,30 @@
import { readFileSync, writeFileSync, unlinkSync, existsSync, chmodSync, openSync, closeSync } from "node:fs";
import { PATHS } from "~/constants/paths.js";
import { ensureConfigDir } from "~/services/config/facade.js";
import type { StoredAuth } from "./schemas.js";
export function getStoredToken(): StoredAuth | null {
if (!existsSync(PATHS.AUTH_FILE)) return null;
try {
const raw = readFileSync(PATHS.AUTH_FILE, "utf-8");
return JSON.parse(raw) as StoredAuth;
} catch {
return null;
}
}
export function storeToken(auth: Omit<StoredAuth, "stored_at">): void {
ensureConfigDir();
const data: StoredAuth = { ...auth, stored_at: new Date().toISOString() };
const content = JSON.stringify(data, null, 2) + "\n";
const fd = openSync(PATHS.AUTH_FILE, "w", 0o600);
try {
writeFileSync(fd, content, "utf-8");
} finally {
closeSync(fd);
}
}
export function clearToken(): void {
try { unlinkSync(PATHS.AUTH_FILE); } catch {}
}