Files
claudemesh/.artifacts/specs/2026-04-10-cli-auth-device-code-pat.md
Alejandro Gutiérrez ee12510ef1
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
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>
2026-04-15 08:44:52 +01:00

594 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 sessions
- `cm_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:
1. User lands on `/cli-auth?code=ABCD-EFGH`
2. If not signed in, Better Auth login screen appears, then redirects back
3. 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]
```
4. 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:
```ts
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.
```sql
-- 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'|'approved'|'denied'|'expired', token?: string, user?: { id, name, email } }`. Token only present when status=approved, and only **once** (subsequent polls return approved without token). |
| `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_…`:
```ts
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-EFGH` from 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`
```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`:
```ts
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
```ts
// 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
1. **Tokens are hashed at rest** (Better Auth `apiKey` plugin handles this with bcrypt or argon2).
2. **Raw tokens shown to user once.** PATs in dashboard, device-code tokens via `claudemesh login` output. Never logged, never re-displayable.
3. **`auth.json` is `0600`.** CLI refuses to write if parent dir can't be made `0700`. Warns on read if mode is wider.
4. **Token prefix `cm_` enables secret scanning.** Document the regex `cm_(session|pat)_[a-z0-9]{32,}` in security docs so GitHub secret scanning, GitGuardian, etc. can detect leaks.
5. **`/api/auth/cli/device-code/:device_code` polling is rate-limited** to 1 req/sec per IP per device_code. Returns `429` with `slow_down: true` body.
6. **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).
7. **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 new `audit_log` table.
8. **Session invalidation on password change.** When a user changes their password via Better Auth, all `cli_session` `api_key` rows for that user are revoked. PATs are NOT auto-revoked (they're explicitly user-managed).
9. **Token revocation is immediate.** `auth.api.verifyApiKey` checks DB on every request — no in-memory cache.
10. **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 (46 hours)
- [ ] Wire Better Auth `apiKey` plugin in `apps/web/src/lib/auth/`
- [ ] Migration `0020_cli-device-code.sql`
- [ ] Drizzle schema for `cli_device_code` in `packages/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 `enforceAuth` middleware to accept `Authorization: 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 (34 hours)
- [ ] `/cli-auth?code=...` page (server component + approve/deny client component)
- [ ] `/dashboard/settings/cli-tokens` page (list + create modal + revoke)
- [ ] Translations for both pages (en, es)
- [ ] E2E test: full device-code happy path with Playwright
### Phase 3 — CLI auth core (45 hours)
- [ ] `lib/device-info.ts` — collect hostname, os, arch
- [ ] `lib/auth-store.ts` — read/write `~/.claudemesh/auth.json` with mode checks
- [ ] `lib/api-client.ts` — typed fetch wrapper with bearer header
- [ ] `commands/login.ts` — device-code flow + `--token` PAT path
- [ ] `commands/logout.ts` — revoke + delete local
- [ ] `commands/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 (34 hours)
- [ ] Rewrite `commands/create.ts` to call `POST /api/my/meshes`
- [ ] New `commands/invite.ts` with `--email`, `--mesh`, `--role`, `--expires-in`
- [ ] Rewrite `commands/sync.ts` to call `GET /api/my/meshes` and reconcile local config
- [ ] Update `commands/list.ts` to show server-side meshes too
- [ ] Integration tests against staging broker + web
### Phase 5 — Wizard integration (34 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 (23 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.json` to `0.11.0`
- [ ] Publish to npm
- [ ] Deploy broker / web (no broker changes, web for new routes)
**Total estimate:** 1926 hours of focused work. Realistic: 34 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
1. **Better Auth `apiKey` plugin version** — confirm it's installed and at a version that supports `enableMetadata`. Check `pnpm why better-auth` in `apps/web`.
2. **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.log` with structured JSON to stderr and let the platform's log collector handle it.
3. **Email sending** — `claudemesh invite --email` requires 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.
4. **Token scopes** — v1 ships with no scopes; every token has full account access. Should we add `mesh:read`, `mesh:write`, `invite:create` scopes from day one, or wait? **Recommendation:** wait. YAGNI. Add when a user actually wants a read-only CI token.
5. **PAT expiry default** — 90 days? 1 year? Never? Better Auth supports all three. **Recommendation:** 1 year default, user can pick "never" with explicit warning.
6. **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`.
7. **`claudemesh login` when 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 login` on a fresh machine (no `auth.json`) opens browser, completes device-code flow, writes `auth.json`, runs in <30 seconds end-to-end
- [ ] `claudemesh login --token cm_pat_…` works without browser
- [ ] `claudemesh logout` revokes server-side and deletes local file
- [ ] `claudemesh whoami` prints user identity and token source
- [ ] `claudemesh create "Test mesh"` creates a real mesh on the server, joins it locally, and the user can see it on the dashboard
- [ ] `claudemesh invite --email a@b.c --mesh test` creates an invite and prints the URL
- [ ] `claudemesh launch` (bare) on a fresh machine walks login → mesh picker → name/role → Claude Code, all in one wizard
- [ ] Dashboard `/dashboard/settings/cli-tokens` lists, creates, and revokes PATs
- [ ] All flows work in `en` and `es`
- [ ] Existing `claudemesh launch` invocations (with token already in `auth.json`) still work without prompting
- [ ] Token in `auth.json` survives 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 add`
- `claudemesh billing usage`
- `claudemesh mesh archive`
- `claudemesh 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.