Files
claudemesh/docs/cli-auth-sync-spec.md
Alejandro Gutiérrez 0661e6223a
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
fix(web): correct LinkedIn URL on about page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:17:24 +01:00

20 KiB

CLI Auth Sync: Zero-Friction Onboarding

Spec for syncing dashboard meshes to the CLI without manual join commands. Goal: npm i -g claudemesh-cli && claudemesh launch — one install, one command, even for users who already created meshes on the dashboard.


Problem

Today a user who created a mesh on claudemesh.com must:

  1. npm i -g claudemesh-cli
  2. Go to dashboard → generate invite → copy token
  3. claudemesh join <token>
  4. claudemesh launch --name Alice

Steps 2-3 are friction. The dashboard already knows their meshes. The CLI should sync them automatically.

Design goal

npm i -g claudemesh-cli
claudemesh launch --name Alice

Two commands total. If the user has meshes on the dashboard, they appear automatically. If they have none, the CLI walks them through creating one.

UX principles:

  • No menus on the happy path. If the user typed launch, they want to launch — not answer 7 prompts. Default to browser sync, auto-pick the first mesh, default to push mode. Everything overridable with flags.
  • Headless fallback. SSH users can't open a browser. Always provide a pairing code + paste-token alternative.
  • Sync anytime. First-time wizard is not the only entry point. A standalone claudemesh sync command re-syncs meshes at any time.

Identity model

Two separate auth systems exist today:

System Auth method Where identity lives
Dashboard Google OAuth (via Payload CMS) user table in Postgres, session cookie
CLI/Broker ed25519 keypairs ~/.claudemesh/config.json + mesh.member table

These are currently unlinked. The broker doesn't know which dashboard user owns a keypair, and the dashboard doesn't know a CLI user's pubkey.

Keep them separate

Don't merge them into one auth system. OAuth is for web sessions. Ed25519 is for peer identity and E2E crypto. They serve different purposes.

Instead, link them: a dashboard user can claim a CLI keypair, and vice versa. The link is stored in the DB and used for mesh sync.


Architecture

claudemesh launch --name Alice
│
├── 1. Check ~/.claudemesh/config.json
│      Has meshes? → pick one, launch (existing flow)
│
├── 2. No meshes → check for linked dashboard account
│      ~/.claudemesh/config.json has accountId? → fetch meshes from broker
│      Has meshes on broker? → auto-enroll locally, launch
│
├── 3. No linked account → auto-start browser sync
│      Generate 4-char pairing code (e.g. A3Kx)
│      Start localhost callback listener
│      Open browser: https://claudemesh.com/cli-auth?port=<port>&code=<code>
│      Print fallback: "Can't open browser? Visit: <url>"
│      Print fallback: "Or join with invite: claudemesh launch --join <url>"
│
│      Wait for sync token (from localhost redirect or manual paste)
│
└── 4. On sync token received
       ├── Generate ed25519 keypair
       ├── POST /cli-sync → broker creates members, returns mesh list
       ├── Write all meshes + accountId to config
       ├── Auto-select first mesh (or --mesh flag)
       └── Launch immediately (no further prompts)

The sync token

A short-lived JWT issued by the dashboard after OAuth, containing:

{
  "sub": "user_abc123",
  "email": "alice@example.com",
  "meshes": [
    { "id": "mesh_xyz", "slug": "dev-team", "role": "admin" },
    { "id": "mesh_abc", "slug": "research", "role": "member" }
  ],
  "action": "sync",        // or "create"
  "newMesh": {              // only if action=create
    "name": "My Team",
    "slug": "my-team"
  },
  "iat": 1712000000,
  "exp": 1712000900         // 15 min TTL
}

The CLI never sees the user's OAuth tokens. It only gets this sync token, which the broker validates and uses to create/find members.

TTL: 15 minutes (not 5). First-time users may need to create a Google account, go through OAuth consent, and create a mesh. The real protection is single-use JTI dedup, not a tight TTL.


Broker: POST /cli-sync

New endpoint. Accepts a sync token, returns mesh details for each mesh.

// Request
POST /cli-sync
{
  "sync_token": "<JWT>",
  "peer_pubkey": "<ed25519 hex>",  // CLI's freshly generated keypair
  "display_name": "Alice"
}

// Response
{
  "ok": true,
  "account_id": "user_abc123",
  "meshes": [
    {
      "mesh_id": "mesh_xyz",
      "slug": "dev-team",
      "broker_url": "wss://ic.claudemesh.com/ws",
      "member_id": "member_123",
      "role": "admin"
    },
    {
      "mesh_id": "mesh_abc",
      "slug": "research",
      "broker_url": "wss://ic.claudemesh.com/ws",
      "member_id": "member_456",
      "role": "member"
    }
  ]
}

The broker:

  1. Validates the JWT signature and expiry
  2. Checks the JTI hasn't been used (in-memory Set, TTL-evicted)
  3. For each mesh: creates a mesh.member row with the CLI's pubkey (or reuses existing if this pubkey is already a member)
  4. Links the dashboard user.id to the mesh.member via a new dashboard_user_id column
  5. Returns mesh details so the CLI can write config.json

Web: /cli-auth page

New page at https://claudemesh.com/cli-auth?port=<port>&code=<code>.

The code param is the 4-char pairing code displayed in the CLI terminal, shown on the page so the user can confirm they're syncing the right session.

Flow

  1. User lands on the page (already signed in via Google, or signs in now)
  2. Page shows their meshes + the pairing code for confirmation:
    Sync with claudemesh CLI
    
    Pairing code: A3Kx
    Confirm this matches your terminal.
    
    Your meshes:
    ☑ dev-team (3 members, admin)
    ☑ research (1 member, member)
    
    [Sync to CLI]
    
  3. User clicks "Sync to CLI"
  4. Dashboard generates a sync JWT
  5. Redirect attempt: http://localhost:<port>/callback?token=<JWT>
  6. If redirect fails (port unreachable, headless, different device): show the token on-screen with copy button and instructions:
    Couldn't reach your terminal automatically.
    Copy this token and paste it in your terminal:
    
    [eyJhbGciOi...] [Copy]
    

Localhost reachability check

Before redirecting, the page does a preflight check:

try {
  const res = await fetch(`http://localhost:${port}/ping`, { signal: AbortSignal.timeout(2000) });
  if (res.ok) redirect(`http://localhost:${port}/callback?token=${jwt}`);
  else showManualToken(jwt);
} catch {
  showManualToken(jwt);
}

The CLI's callback listener responds to /ping with 200 OK (no token needed).

If user has no meshes

   Welcome to claudemesh!

   You don't have any meshes yet. Let's create one.

   Name: [My Team        ]
   Slug: [my-team         ]

   [Create & sync to CLI]

Creates the mesh, generates the sync token with the new mesh, redirects.


CLI: localhost listener

Minimal HTTP server, adapted from Claude Code's AuthCodeListener pattern:

import { createServer } from "node:http";

interface CallbackListener {
  port: number;
  token: Promise<string>;
  close: () => void;
}

function startCallbackListener(): Promise<CallbackListener> {
  return new Promise((resolveStart) => {
    let resolveToken: (token: string) => void;
    const tokenPromise = new Promise<string>((r) => { resolveToken = r; });

    const server = createServer((req, res) => {
      const url = new URL(req.url!, `http://localhost`);

      if (url.pathname === "/ping") {
        // Reachability check from the web page
        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) {
          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>
            <p>Launching claudemesh...</p>
          </body></html>`);
          resolveToken(token);
          server.close();
        } else {
          res.writeHead(400);
          res.end("Missing token");
        }
        return;
      }

      // CORS preflight for /ping
      if (req.method === "OPTIONS") {
        res.writeHead(204, {
          "Access-Control-Allow-Origin": "https://claudemesh.com",
          "Access-Control-Allow-Methods": "GET",
        });
        res.end();
        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(),
      });
    });
  });
}

CLI: first-time sync flow

In launch.ts, when config.meshes.length === 0:

if (config.meshes.length === 0 && !joinUrl) {
  // Generate pairing code (4 alphanumeric chars)
  const code = generatePairingCode();

  // Start listener
  const listener = await startCallbackListener();
  const action = "sync";
  const url = `https://claudemesh.com/cli-auth?port=${listener.port}&code=${code}&action=${action}`;

  console.log(`
  ${bold("Welcome to claudemesh!")} No meshes found.
  Opening browser to sign in...
  `);

  // Try to open browser (non-fatal if it fails)
  const opened = await openBrowser(url);

  if (!opened) {
    console.log(`  Couldn't open browser automatically.`);
  }

  console.log(`  ${dim(`Visit: ${url}`)}`);
  console.log(`  ${dim(`Or join with invite: claudemesh launch --join <url>`)}`);
  console.log();

  // Race: localhost callback vs manual paste vs timeout
  const syncToken = await Promise.race([
    listener.token,
    askManualToken(),      // "Paste sync token: " prompt (resolves on paste)
    timeout(15 * 60_000),  // 15 min, matches JWT TTL
  ]);

  listener.close();

  if (!syncToken) {
    console.error("  Timed out waiting for sign-in.");
    process.exit(1);
  }

  // Generate keypair and sync with broker
  const keypair = await generateKeypair();
  const result = await syncWithBroker(syncToken, keypair, displayName);

  // Write all meshes to config
  for (const m of result.meshes) {
    config.meshes.push({
      meshId: m.mesh_id,
      memberId: m.member_id,
      slug: m.slug,
      name: m.slug,
      pubkey: keypair.publicKey,
      secretKey: keypair.secretKey,
      brokerUrl: m.broker_url,
      joinedAt: new Date().toISOString(),
    });
  }
  config.accountId = result.account_id;
  saveConfig(config);

  console.log(`  ${green("✓")} Synced ${result.meshes.length} mesh(es): ${result.meshes.map(m => m.slug).join(", ")}`);
}

// Auto-select mesh: first one, or --mesh flag
const mesh = flags.mesh
  ? config.meshes.find(m => m.slug === flags.mesh)
  : config.meshes[0];

if (!mesh) {
  console.error(`Mesh not found: ${flags.mesh}`);
  console.error(`Available: ${config.meshes.map(m => m.slug).join(", ")}`);
  process.exit(1);
}

// Launch immediately with defaults
// Role, groups, messageMode all use flag values or defaults (no prompts)

No prompts on the happy path

Setting Default Override
Mesh First in list --mesh <slug>
Role (none) --role <role>
Groups (none) --groups <a,b>
Message mode push --message-mode <mode>
Confirmation Skip on first sync -y for all future launches

The existing interactive prompts (role, groups, message mode) are kept for claudemesh launch when the user has meshes and runs without flags and without --quiet. But they're skipped entirely on the first sync flow — the user just signed in via browser, that's enough friction.


CLI: claudemesh sync command

Standalone command for re-syncing meshes anytime:

# Sync new meshes from dashboard
claudemesh sync

# Force re-sync (re-link account even if already linked)
claudemesh sync --force
// commands/sync.ts
export default defineCommand({
  meta: { name: "sync", description: "Sync meshes from your dashboard account" },
  args: {
    force: { type: "boolean", description: "Re-link account even if already linked" },
  },
  async run({ args }) {
    const config = loadConfig();

    // Start browser flow (same as first-time, but action=sync always)
    const code = generatePairingCode();
    const listener = await startCallbackListener();
    const url = `https://claudemesh.com/cli-auth?port=${listener.port}&code=${code}&action=sync`;

    console.log(`Opening browser...`);
    console.log(dim(`Visit: ${url}`));
    await openBrowser(url);

    const syncToken = await Promise.race([
      listener.token,
      askManualToken(),
      timeout(15 * 60_000),
    ]);
    listener.close();

    if (!syncToken) {
      console.error("Timed out.");
      process.exit(1);
    }

    // Use existing keypair from first mesh, or generate new
    const keypair = config.meshes.length > 0
      ? { publicKey: config.meshes[0].pubkey, secretKey: config.meshes[0].secretKey }
      : await generateKeypair();

    const result = await syncWithBroker(syncToken, keypair, config.displayName ?? "unnamed");

    // Merge: add new meshes, skip duplicates
    let added = 0;
    for (const m of result.meshes) {
      if (config.meshes.some(existing => existing.meshId === m.mesh_id)) continue;
      config.meshes.push({
        meshId: m.mesh_id,
        memberId: m.member_id,
        slug: m.slug,
        name: m.slug,
        pubkey: keypair.publicKey,
        secretKey: keypair.secretKey,
        brokerUrl: m.broker_url,
        joinedAt: new Date().toISOString(),
      });
      added++;
    }
    config.accountId = result.account_id;
    saveConfig(config);

    if (added > 0) {
      console.log(green(`✓ Added ${added} new mesh(es)`));
    } else {
      console.log(`Already up to date (${config.meshes.length} meshes)`);
    }
  },
});

CLI: openBrowser utility

Cross-platform browser launcher adapted from Claude Code's utils/browser.ts:

import { exec } from "node:child_process";

export async function openBrowser(url: string): Promise<boolean> {
  // Validate URL
  if (!url.startsWith("http://") && !url.startsWith("https://")) return false;

  // Respect BROWSER env var
  const browserCmd = process.env.BROWSER;

  const cmd = browserCmd
    ? `${browserCmd} ${JSON.stringify(url)}`
    : process.platform === "darwin"
      ? `open ${JSON.stringify(url)}`
      : process.platform === "win32"
        ? `rundll32 url.dll,FileProtocolHandler ${JSON.stringify(url)}`
        : `xdg-open ${JSON.stringify(url)}`;

  return new Promise((resolve) => {
    exec(cmd, (err) => resolve(!err));
  });
}

CLI: pairing code

Short alphanumeric code for visual confirmation between terminal and browser:

function generatePairingCode(): string {
  const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
  const bytes = crypto.getRandomValues(new Uint8Array(4));
  return Array.from(bytes, b => chars[b % chars.length]).join("");
}

Excludes ambiguous characters (0/O, 1/l/I) for readability.


Config extension

// state/config.ts
export interface Config {
  version: 1;
  meshes: JoinedMesh[];
  displayName?: string;
  role?: string;
  groups?: GroupEntry[];
  messageMode?: "push" | "inbox" | "off";
  accountId?: string;        // NEW: linked dashboard user ID
}

The accountId enables future features:

  • Re-sync meshes if new ones are created on the dashboard
  • Show account email in claudemesh status
  • Revoke CLI access from the dashboard

DB changes

Extend mesh.member

ALTER TABLE mesh.member
  ADD COLUMN dashboard_user_id TEXT;  -- links to Payload CMS user.id

CREATE INDEX member_dashboard_user_idx
  ON mesh.member(dashboard_user_id)
  WHERE dashboard_user_id IS NOT NULL;

No new tables needed

The sync token is a JWT — stateless, validated by signature. No DB storage required. The broker just reads the claims and creates/finds members.

JTI dedup is in-memory (Set with TTL eviction matching the JWT expiry).


Security

Concern Mitigation
Sync token theft 15 min TTL, single-use (broker tracks used JTIs in memory), localhost-only redirect
Localhost port scanning Random port, CORS restricted to https://claudemesh.com, /ping only returns "ok"
Reachability check spoofing Pairing code shown on both terminal and web page — user visually confirms match
CSRF on /cli-auth Require existing dashboard session (Google OAuth)
Multiple CLI devices Each generates its own keypair — one dashboard user can have multiple CLI identities
Revoking CLI access Dashboard can delete mesh.member rows linked to a dashboard_user_id
Headless environments Manual token paste fallback — no browser required

UX flow: first-time experience

Happy path (has browser, has meshes)

$ npm i -g claudemesh-cli

$ claudemesh launch --name Alice

  Welcome to claudemesh! No meshes found.
  Opening browser to sign in...

  Visit: https://claudemesh.com/cli-auth?port=54321&code=A3Kx
  Or join with invite: claudemesh launch --join <url>

  ⣾ Waiting...

  ✓ Synced 2 mesh(es): dev-team, research
  Launching on dev-team (use --mesh to change)

claudemesh launch — as Alice on dev-team [push]
────────────────────────────────────────────────────────────

  Launching...

Headless path (SSH, no browser)

$ claudemesh launch --name Alice

  Welcome to claudemesh! No meshes found.
  Opening browser to sign in...

  Couldn't open browser automatically.
  Visit: https://claudemesh.com/cli-auth?port=54321&code=A3Kx
  Or join with invite: claudemesh launch --join <url>

  Paste sync token: eyJhbGciOi...█

  ✓ Synced 1 mesh(es): dev-team

claudemesh launch — as Alice on dev-team [push]

No meshes on dashboard

Browser shows "Create a mesh" form. User creates one. Redirects back.

  ✓ Synced 1 mesh(es): my-team (just created)

Second launch (instant, no prompts)

$ claudemesh launch --name Alice

claudemesh launch — as Alice on dev-team [push]
────────────────────────────────────────────────────────────

  Launching...

Customized launch

$ claudemesh launch --name Alice --mesh research --role lead --groups eng,review --message-mode inbox

claudemesh launch — as Alice (lead) on research [@eng:lead, @review] [inbox]

Implementation order

  1. Broker: POST /cli-sync endpoint — validate JWT, JTI dedup, create/find members, return mesh list
  2. DB: Add dashboard_user_id to mesh.member
  3. Web: /cli-auth page — OAuth gate, mesh picker, pairing code display, sync token generation, localhost preflight + redirect, manual token fallback
  4. CLI: startCallbackListener() — localhost HTTP server with /ping and /callback
  5. CLI: openBrowser() — cross-platform browser opener
  6. CLI: First-time sync flow in launch.ts — no-prompt happy path with race (callback vs paste vs timeout)
  7. CLI: claudemesh sync command — standalone re-sync
  8. Config: Add accountId field

What stays the same

  • claudemesh join <url> still works — for users who receive invite links
  • claudemesh launch --join <url> still works — join + launch in one step
  • Ed25519 keypairs remain the mesh identity — OAuth is only for sync
  • The broker never sees OAuth tokens — only the sync JWT
  • Existing users with local meshes are unaffected — sync flow only triggers when config.meshes is empty
  • Interactive prompts (role, groups, mode) still work on subsequent launches without flags

  • Member Profile — Persistent identity (role tag, groups, message mode) on the member row, dashboard management, self-edit permissions, invite presets. The sync spec gets users into the mesh; the member profile spec defines who they are once they're in.

Open questions

  1. Shared keypair across meshes? Current spec generates one keypair and uses it for all synced meshes. Simpler, but means revoking one mesh doesn't rotate the key for others. Alternative: one keypair per mesh (more isolation, more config complexity). Decision: shared for v1.

  2. claudemesh sync --auto? Could auto-sync on every launch if accountId is set (hit broker, check for new meshes). Adds latency to every launch. Decision: not in v1. Manual claudemesh sync only.