- 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>
594 lines
26 KiB
Markdown
594 lines
26 KiB
Markdown
# 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 (4–6 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 (3–4 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 (4–5 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 (3–4 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 (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.json` to `0.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
|
||
|
||
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.
|