- 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>
26 KiB
CLI Auth — Device Code Flow + Personal Access Tokens
Status: spec
Created: 2026-04-10
Owner: CLI-Dev (implementation), Orchestrator (spec)
Target version: v0.11.0
Related: 2026-04-10-anthropic-vision-meshes-invites.md, 2026-04-10-cli-wizard-architecture-refactor.md
Goal
The CLI is a first-class client. From a fresh terminal, with zero prior browser interaction, a user can:
claudemesh login # device-code OAuth, browser handshake
claudemesh create "Platform team" # creates real mesh via /api/my/meshes
claudemesh invite --email alice@x.com # generates invite, sends email
claudemesh launch --mesh platform-team -y # spawns Claude Code in the mesh
For CI / scripting / non-interactive contexts, PAT works too:
claudemesh login --token cm_pat_abc123
claudemesh create "CI test mesh" --json | jq .id
This is the auth substrate that unblocks the "Anthropic vision" — every other dashboard-only feature (meshes, invites, members, billing) becomes CLI-accessible after this lands.
Non-goals
- SSO / SAML / enterprise IdP integration (later, post-1.0)
- Refresh tokens with rotation (long-lived API keys are sufficient for v1)
- Multi-account switching (one logged-in identity per
~/.claudemesh/auth.json) - Device fleet management UI (single "revoke" button per token is enough for v1)
Auth model overview
Two coexisting credential types, both backed by Better Auth's apiKey plugin:
| Type | Created via | Lifetime | Use case | Storage |
|---|---|---|---|---|
| Device-code session token | claudemesh login (OAuth-style browser handshake) |
90 days, auto-renew on use | Interactive humans on their workstation | ~/.claudemesh/auth.json |
| Personal access token (PAT) | Dashboard → Settings → CLI tokens → Generate | User-chosen (30d / 90d / 1y / never), explicit revocation | CI, scripts, automation, server-side cron | Anywhere the user puts it; CLI reads from --token flag, env var, or auth.json |
Both flow through the same Authorization: Bearer cm_<type>_<random> header. The API doesn't care which one it gets — it just validates against the api_key table.
Token format:
cm_session_<32-byte base32>— device-code sessionscm_pat_<32-byte base32>— personal access tokens
The cm_ prefix lets us scan for leaked tokens with regex (e.g. GitHub secret scanning, internal scripts). The middle segment (session / pat) is for human readability in token lists, not for security.
User flows
1. First-time login (interactive happy path)
$ claudemesh login
██ claudemesh login
Opening browser for authentication…
If your browser didn't open, visit:
https://claudemesh.com/cli-auth?code=ABCD-EFGH
Enter this code:
ABCD-EFGH
Waiting for confirmation… ⠋
In the browser:
- User lands on
/cli-auth?code=ABCD-EFGH - If not signed in, Better Auth login screen appears, then redirects back
- User sees a confirmation card:
Link this CLI session? Code: ABCD-EFGH Device: Alejandro's MacBook Pro · darwin · arm64 Expires in 9:47 [Approve] [Deny] - User clicks Approve
CLI polls every 1.5s, sees approved, receives token, writes ~/.claudemesh/auth.json with 0600, prints:
✔ Authenticated as Alejandro Gutiérrez
✔ Token saved to ~/.claudemesh/auth.json
✔ Synced 3 meshes: alexis-mou, dev, claudefarm
Run claudemesh --help to get started.
2. First-time login (PAT, non-interactive)
$ claudemesh login --token cm_pat_abc123def456...
✔ Authenticated as Alejandro Gutiérrez (via PAT "ci-deploy")
✔ Token saved to ~/.claudemesh/auth.json
Or one-shot, no save:
$ CLAUDEMESH_TOKEN=cm_pat_abc123 claudemesh create "test"
3. Already logged in, runs a command
$ claudemesh create "Platform team"
✔ Created mesh platform-team (id: q5RI89Fl…)
✔ Joined locally
▸ Invite peers: claudemesh invite --mesh platform-team
No auth prompt — token in auth.json is used silently.
4. Token expired or revoked
$ claudemesh peers
✘ Authentication failed (token expired or revoked)
Run claudemesh login to re-authenticate.
Exit code 2. The auth.json is not auto-deleted (user might be debugging) but the next claudemesh login overwrites it cleanly.
5. Wizard launch flow with auth integration
When claudemesh (bare, no auth) is run:
██ claudemesh
▸ Sign in (opens browser)
Paste a personal access token
Join a mesh via invite URL
Exit
After auth completes, the wizard transitions naturally into the launch flow (mesh picker → name → role → confirm → handoff). One uninterrupted experience from "fresh install" to "Claude Code in a mesh."
6. CI / non-interactive
# .github/workflows/test.yml
- run: |
claudemesh login --token ${{ secrets.CLAUDEMESH_PAT }}
claudemesh create "CI run $GITHUB_RUN_ID" --json > mesh.json
Or zero-state:
- env:
CLAUDEMESH_TOKEN: ${{ secrets.CLAUDEMESH_PAT }}
run: claudemesh create "CI run $GITHUB_RUN_ID" --json
Token resolution order: --token flag > CLAUDEMESH_TOKEN env var > ~/.claudemesh/auth.json.
7. Logout
$ claudemesh logout
✔ Token revoked on server
✔ Removed ~/.claudemesh/auth.json
logout calls DELETE /api/my/cli/sessions/current to revoke server-side, then unlinks the local file. Best-effort: if the server call fails, still delete locally and warn.
Architecture
Backend — Better Auth apiKey plugin
Better Auth ships an apiKey plugin that handles:
- Token generation (cryptographically random)
- Hashed storage (only the hash hits the DB; raw token never persisted)
- Verification middleware (validates
Authorization: Bearer …) - Per-token metadata (name, scopes, expiry, last-used)
- Per-token revocation
We use it for both PAT and device-code sessions. Device-code sessions just have a marker in metadata distinguishing them from user-generated PATs.
Wire-up: apps/web/src/lib/auth/index.ts (or wherever Better Auth is initialized) adds:
import { apiKey } from "better-auth/plugins";
export const auth = betterAuth({
// …existing config
plugins: [
// …
apiKey({
enableMetadata: true,
apiKeyHeaders: ["x-api-key", "authorization"],
defaultPrefix: "cm_",
rateLimit: { enabled: true, timeWindow: 60_000, maxRequests: 100 },
}),
],
});
Backend — device-code table
The apiKey plugin doesn't ship device-code flow out of the box. We add a small table + 4 endpoints on top.
-- packages/db/migrations/0020_cli-device-code.sql
CREATE TABLE cli_device_code (
device_code text PRIMARY KEY, -- opaque random, sent to CLI
user_code text UNIQUE NOT NULL, -- short human code: "ABCD-EFGH"
user_id text REFERENCES "user"(id), -- nullable until approved
api_key_id text REFERENCES api_key(id), -- the issued token, set on approve
device_name text NOT NULL, -- "Alejandro's MacBook Pro"
device_os text NOT NULL, -- "darwin"
device_arch text NOT NULL, -- "arm64"
ip_address text, -- for audit
user_agent text,
status text NOT NULL DEFAULT 'pending', -- 'pending' | 'approved' | 'denied' | 'expired'
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL, -- created_at + 10 min
approved_at timestamptz
);
CREATE INDEX cli_device_code_user_code_idx ON cli_device_code(user_code);
CREATE INDEX cli_device_code_status_expires_idx ON cli_device_code(status, expires_at);
A scheduled job (or lazy cleanup on insert) deletes rows where status='expired' AND expires_at < now() - interval '7 days'.
Backend — endpoints
All under apps/web/src/app/api/auth/cli/ (or wherever you keep public auth routes — these need to be unauthed since the CLI has no token yet).
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST |
/api/auth/cli/device-code |
none | CLI requests a new device code. Body: { device_name, device_os, device_arch }. Returns { device_code, user_code, expires_at, verification_url }. |
GET |
/api/auth/cli/device-code/:device_code |
none | CLI polls for status. Returns `{ status: 'pending' |
POST |
/api/auth/cli/device-code/:user_code/approve |
session | Browser confirms. Creates an api_key row with metadata { kind: 'session', device_name, device_code }, sets cli_device_code.api_key_id, status=approved. |
POST |
/api/auth/cli/device-code/:user_code/deny |
session | Browser denies. Sets status=denied. |
Authed endpoints (under /api/my/cli/):
| Method | Path | Purpose |
|---|---|---|
GET |
/api/my/cli/sessions |
List active CLI sessions for the user (devices, last seen, created). |
DELETE |
/api/my/cli/sessions/:id |
Revoke a specific session. |
POST |
/api/my/cli/tokens |
Create a PAT. Body: { name, expires_in_days?, scopes? }. Returns the raw token once. |
GET |
/api/my/cli/tokens |
List PATs (no raw values, just metadata). |
DELETE |
/api/my/cli/tokens/:id |
Revoke a PAT. |
Backend — middleware
Existing enforceAuth (in packages/api/src/utils/) currently reads cookies. Extend it to also accept Authorization: Bearer cm_…:
export async function enforceAuth(ctx) {
const bearer = ctx.req.headers.get("authorization")?.replace(/^Bearer /, "");
if (bearer?.startsWith("cm_")) {
const result = await auth.api.verifyApiKey({ key: bearer });
if (result.valid) {
// record last_used_at, increment usage counter
return { user: result.user, via: "apiKey", apiKey: result.apiKey };
}
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid token" });
}
// …existing cookie-based auth
}
The apiKey plugin handles last_used_at updates automatically.
Backend — web route
apps/web/src/app/[locale]/cli-auth/page.tsx:
- Reads
?code=ABCD-EFGHfrom query string - If no session, redirects to
/login?next=/cli-auth?code=ABCD-EFGH - If session, fetches device code metadata via server component, renders confirmation card
- Approve button →
POST /api/auth/cli/device-code/:user_code/approve - Deny button →
POST /api/auth/cli/device-code/:user_code/deny - After approve, shows: "✓ CLI authenticated. Return to your terminal."
Mobile-friendly. Confirmation card shows device fingerprint so the user can verify they're approving the right session.
Backend — dashboard PAT UI
apps/web/src/app/[locale]/dashboard/settings/cli-tokens/page.tsx:
- List of existing PATs (name, created, last used, expires)
- "Generate new token" button → modal with name + expiry picker
- After creation, show raw token once with copy button + warning ("This token will not be shown again")
- Per-row revoke button
Reuses existing dashboard layout. Should be ~150 lines including the modal.
CLI — file layout
apps/cli/src/
├── commands/
│ ├── login.ts # NEW
│ ├── logout.ts # NEW
│ ├── whoami.ts # NEW
│ ├── create.ts # rewrite to call API
│ ├── invite.ts # NEW
│ ├── sync.ts # rewrite to call API
│ └── …existing
└── lib/
├── auth-store.ts # NEW: read/write ~/.claudemesh/auth.json
├── api-client.ts # NEW: typed fetch wrapper
├── device-info.ts # NEW: collect hostname, os, arch for device-code request
└── …existing
CLI — auth-store.ts
// ~/.claudemesh/auth.json
type AuthFile = {
version: 1;
token: string; // cm_session_… or cm_pat_…
user: { id: string; name: string; email: string };
created_at: string; // ISO
source: "device-code" | "pat" | "env";
};
Read priority: --token flag > CLAUDEMESH_TOKEN env > auth.json.
Write only on login success. File mode 0600. Parent dir 0700.
On read, if file mode is too permissive, log a warning and continue.
CLI — api-client.ts
Thin wrapper over fetch:
export class ClaudemeshApi {
constructor(private opts: { baseUrl: string; token: string }) {}
async createMesh(input: { name: string; slug?: string }) { … }
async listMeshes() { … }
async createInvite(input: { meshId: string; email?: string; role?: string }) { … }
async listSessions() { … }
async revokeSession(id: string) { … }
async whoami() { … }
}
Type definitions live in packages/api/src/contracts/cli.ts (new file) — generated from the existing tRPC routers as plain types so the CLI doesn't need to import the whole tRPC client.
Base URL from CLAUDEMESH_API_URL env var, defaults to https://claudemesh.com. Allows local dev against http://localhost:3000.
CLI — device-code login flow
// commands/login.ts
async function deviceCodeLogin() {
const device = collectDeviceInfo();
const { device_code, user_code, expires_at, verification_url } =
await api.requestDeviceCode(device);
console.log(` Opening ${verification_url}…`);
console.log(` Code: ${user_code}`);
await openBrowser(`${verification_url}?code=${user_code}`);
const spinner = ora("Waiting for confirmation").start();
const deadline = new Date(expires_at).getTime();
while (Date.now() < deadline) {
await sleep(1500);
const result = await api.pollDeviceCode(device_code);
if (result.status === "approved") {
spinner.succeed("Authenticated");
await authStore.write({ token: result.token, user: result.user, source: "device-code" });
await syncMeshes();
return;
}
if (result.status === "denied") {
spinner.fail("Denied in browser");
process.exit(1);
}
}
spinner.fail("Timed out");
process.exit(1);
}
Polls every 1.5s. Server returns { slow_down: true } if polled too fast (rate limit at 1/sec).
Security
- Tokens are hashed at rest (Better Auth
apiKeyplugin handles this with bcrypt or argon2). - Raw tokens shown to user once. PATs in dashboard, device-code tokens via
claudemesh loginoutput. Never logged, never re-displayable. auth.jsonis0600. CLI refuses to write if parent dir can't be made0700. Warns on read if mode is wider.- Token prefix
cm_enables secret scanning. Document the regexcm_(session|pat)_[a-z0-9]{32,}in security docs so GitHub secret scanning, GitGuardian, etc. can detect leaks. /api/auth/cli/device-code/:device_codepolling is rate-limited to 1 req/sec per IP per device_code. Returns429withslow_down: truebody.- Device codes expire in 10 minutes. Approved-but-unclaimed tokens stay valid (the polling endpoint still returns the token for 60 seconds after approval, then the device_code row is GC'd).
- Audit logging. Every device-code approval, PAT creation, and PAT revocation emits an audit event (
auth.cli.session.created,auth.cli.pat.created, etc.). Stored in existing audit log if there is one, otherwise newaudit_logtable. - Session invalidation on password change. When a user changes their password via Better Auth, all
cli_sessionapi_keyrows for that user are revoked. PATs are NOT auto-revoked (they're explicitly user-managed). - Token revocation is immediate.
auth.api.verifyApiKeychecks DB on every request — no in-memory cache. - No CSRF concern for device-code endpoints — the unauthed ones don't act on user state, the authed ones use Better Auth's existing CSRF protection.
Wizard UX integration
The current welcome wizard already has:
▸ Create account (new to claudemesh)
Sign in (existing account)
Paste an invite URL
Exit
After this spec lands, the welcome screen becomes:
██ claudemesh
▸ Sign in ← device-code OAuth
Paste an access token ← PAT path
Join via invite URL ← unchanged
Create account ← opens /register, then back to login
Exit
"Sign in" becomes the headline option. The current "Create account" still opens browser to /register but flows back through the device-code handshake instead of a custom callback.
Once authenticated, the wizard transitions to:
██ claudemesh launch
Account ✔ Alejandro Gutiérrez
Mesh ▸ (pick one — 3 available)
Name ✔ Alexis (from --name)
Role ▸ (pick one)
▸ Continue
Cancel
Status rows show what's filled and what's left. Mesh picker fetches from GET /api/my/meshes via the freshly minted token.
This integrates cleanly with the wizard architecture refactor in 2026-04-10-cli-wizard-architecture-refactor.md: auth becomes one screen in the launch flow with isComplete: s => s.user !== null. On a fresh machine the auth screen runs; on a returning machine it's auto-skipped.
Error handling
| Scenario | Behavior |
|---|---|
| Browser doesn't open | Print URL prominently, keep polling |
| Network down during poll | Retry with exponential backoff (1.5s → 3s → 6s, max 30s) |
| Device code expires | Print "Login timed out, run claudemesh login to retry", exit 1 |
| Token rejected by API | Print "Authentication failed", suggest claudemesh login, exit 2 |
auth.json corrupted |
Print "Auth file corrupted, run claudemesh login", exit 2 |
auth.json permissions wrong |
Warn, fix to 0600, continue |
PAT pasted to --token is malformed |
Print "Invalid token format (expected cm_pat_…)", exit 1 |
PAT pasted to --token is valid format but unknown |
API returns 401, print "Token rejected", exit 2 |
| Two CLI instances poll simultaneously | Both get the same approved status; first to read gets the token, second gets { status: 'approved', token: null } (already_claimed). Document this. |
| User clicks Approve in browser, then closes tab | CLI's poll catches it, login succeeds. The browser tab closure is irrelevant. |
User completes login on machine A, then runs claudemesh login on machine B with same account |
Both sessions coexist as separate api_key rows. claudemesh whoami --sessions shows both. |
Implementation phases
Each phase ships independently and is independently testable.
Phase 1 — Backend foundation (4–6 hours)
- Wire Better Auth
apiKeyplugin inapps/web/src/lib/auth/ - Migration
0020_cli-device-code.sql - Drizzle schema for
cli_device_codeinpackages/db/src/schema/auth.ts - Endpoints:
POST /api/auth/cli/device-code,GET /api/auth/cli/device-code/:device_code,POST /api/auth/cli/device-code/:user_code/approve,POST /api/auth/cli/device-code/:user_code/deny - Extend
enforceAuthmiddleware to acceptAuthorization: Bearer cm_… - Endpoints:
POST /api/my/cli/tokens,GET /api/my/cli/tokens,DELETE /api/my/cli/tokens/:id,GET /api/my/cli/sessions,DELETE /api/my/cli/sessions/:id - Unit tests for token verification and device-code state machine
Phase 2 — Web routes (3–4 hours)
/cli-auth?code=...page (server component + approve/deny client component)/dashboard/settings/cli-tokenspage (list + create modal + revoke)- Translations for both pages (en, es)
- E2E test: full device-code happy path with Playwright
Phase 3 — CLI auth core (4–5 hours)
lib/device-info.ts— collect hostname, os, archlib/auth-store.ts— read/write~/.claudemesh/auth.jsonwith mode checkslib/api-client.ts— typed fetch wrapper with bearer headercommands/login.ts— device-code flow +--tokenPAT pathcommands/logout.ts— revoke + delete localcommands/whoami.ts— print current identity + token source- Token resolution helper (
--token>CLAUDEMESH_TOKEN>auth.json) - Unit tests for auth-store and token resolution
Phase 4 — CLI commands wired to API (3–4 hours)
- Rewrite
commands/create.tsto callPOST /api/my/meshes - New
commands/invite.tswith--email,--mesh,--role,--expires-in - Rewrite
commands/sync.tsto callGET /api/my/meshesand reconcile local config - Update
commands/list.tsto show server-side meshes too - Integration tests against staging broker + web
Phase 5 — Wizard integration (3–4 hours)
- Welcome screen new options (Sign in / Paste token / Create account / Join invite)
- Auth screen as a flow step with
isComplete: s => s.user !== null - Status rows pattern showing auth state during launch
- First-run detection (no
auth.json) → auto-route to login
Phase 6 — Polish, docs, ship (2–3 hours)
- Update
README.md,apps/cli/README.md,docs/quickstart.md - CHANGELOG entry for v0.11.0
- Telemetry events for
auth.cli.login.{start,success,fail} - Bump
apps/cli/package.jsonto0.11.0 - Publish to npm
- Deploy broker / web (no broker changes, web for new routes)
Total estimate: 19–26 hours of focused work. Realistic: 3–4 days with testing and review.
Dependencies between phases
Phase 1 (backend) ──┬─→ Phase 2 (web routes)
└─→ Phase 3 (CLI auth core)
│
└─→ Phase 4 (commands)
│
└─→ Phase 5 (wizard)
│
└─→ Phase 6 (ship)
Phase 1 and 2 can be parallelized after the schema lands. Phase 3 needs Phase 1 endpoints live (even if on staging). Phase 4 onwards is strictly serial.
Telemetry
Emit these events (PostHog or whatever the existing analytics are):
cli.login.started— properties:{ method: 'device-code' | 'pat' }cli.login.succeeded— properties:{ method, user_id }cli.login.failed— properties:{ method, reason }cli.logout— properties:{ user_id }cli.command.executed— properties:{ command, exit_code, duration_ms, authenticated: boolean }cli.api.error— properties:{ endpoint, status, error_code }
Telemetry is opt-out. First run shows a one-line notice: "claudemesh collects anonymized usage telemetry. Disable with claudemesh telemetry off."
Open questions
- Better Auth
apiKeyplugin version — confirm it's installed and at a version that supportsenableMetadata. Checkpnpm why better-authinapps/web. - Audit log table — does one already exist? If not, this spec adds three rows of log; not worth a new table for that. Use
console.logwith structured JSON to stderr and let the platform's log collector handle it. - Email sending —
claudemesh invite --emailrequires a transactional email path. Does the web app already have one (Resend, Postmark)? If yes, reuse. If no, defer the email send to a follow-up; the invite command can still create the invite and print the URL. - Token scopes — v1 ships with no scopes; every token has full account access. Should we add
mesh:read,mesh:write,invite:createscopes from day one, or wait? Recommendation: wait. YAGNI. Add when a user actually wants a read-only CI token. - PAT expiry default — 90 days? 1 year? Never? Better Auth supports all three. Recommendation: 1 year default, user can pick "never" with explicit warning.
- Mesh slug uniqueness in
claudemesh create— what happens if two users try to create meshes with the same slug? Existing API behavior should be tested. If it errors, the CLI should suggest--slug platform-team-2. claudemesh loginwhen already logged in — re-authenticate (overwrite) or error ("already logged in, run logout first")? Recommendation: re-authenticate silently with a one-line notice ("Replacing existing session for Alejandro").
Acceptance criteria
For v0.11.0 to ship, all of these must be true:
claudemesh loginon a fresh machine (noauth.json) opens browser, completes device-code flow, writesauth.json, runs in <30 seconds end-to-endclaudemesh login --token cm_pat_…works without browserclaudemesh logoutrevokes server-side and deletes local fileclaudemesh whoamiprints user identity and token sourceclaudemesh create "Test mesh"creates a real mesh on the server, joins it locally, and the user can see it on the dashboardclaudemesh invite --email a@b.c --mesh testcreates an invite and prints the URLclaudemesh launch(bare) on a fresh machine walks login → mesh picker → name/role → Claude Code, all in one wizard- Dashboard
/dashboard/settings/cli-tokenslists, creates, and revokes PATs - All flows work in
enandes - Existing
claudemesh launchinvocations (with token already inauth.json) still work without prompting - Token in
auth.jsonsurvives an hour of idle and continues to work (no aggressive expiry) - Revoking a token in the dashboard makes the next CLI call fail with a clear error
- Documentation updated in
README.md,apps/cli/README.md,docs/quickstart.md - CHANGELOG entry written
- Published to npm as
claudemesh-cli@0.11.0
What this unlocks
Once this lands, every dashboard-only feature becomes one CLI command away. Future specs that depend on this:
claudemesh members list/claudemesh members addclaudemesh billing usageclaudemesh mesh archiveclaudemesh stream subscribe(live broker events)claudemesh skill publish(publish a skill to mesh registry)claudemesh log tail(mesh-wide audit log)
This is the foundational unlock. Everything else is incremental on top.