refactor: rename cli-v2 → cli, archive legacy cli, plus broker-side grants + auto-migrate
- 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:
70
apps/cli/src/services/auth/callback-listener.ts
Normal file
70
apps/cli/src/services/auth/callback-listener.ts
Normal 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(),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
51
apps/cli/src/services/auth/client.ts
Normal file
51
apps/cli/src/services/auth/client.ts
Normal 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);
|
||||
}
|
||||
50
apps/cli/src/services/auth/dashboard-sync.ts
Normal file
50
apps/cli/src/services/auth/dashboard-sync.ts
Normal 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(/\/$/, "");
|
||||
}
|
||||
132
apps/cli/src/services/auth/device-code.ts
Normal file
132
apps/cli/src/services/auth/device-code.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
18
apps/cli/src/services/auth/errors.ts
Normal file
18
apps/cli/src/services/auth/errors.ts
Normal 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.");
|
||||
}
|
||||
}
|
||||
16
apps/cli/src/services/auth/facade.ts
Normal file
16
apps/cli/src/services/auth/facade.ts
Normal 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("");
|
||||
}
|
||||
5
apps/cli/src/services/auth/implementation.ts
Normal file
5
apps/cli/src/services/auth/implementation.ts
Normal 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";
|
||||
1
apps/cli/src/services/auth/index.ts
Normal file
1
apps/cli/src/services/auth/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./facade.js";
|
||||
21
apps/cli/src/services/auth/schemas.ts
Normal file
21
apps/cli/src/services/auth/schemas.ts
Normal 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 };
|
||||
}
|
||||
30
apps/cli/src/services/auth/token-store.ts
Normal file
30
apps/cli/src/services/auth/token-store.ts
Normal 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 {}
|
||||
}
|
||||
Reference in New Issue
Block a user