fix(cli): working auth, served routes, and a real wave init (connectivity for 1.0.10)

YA
1mo ago
+4599-1469

Why

A probe of the published @wave-av/cli@1.0.10 found that the CLI can't authenticate. Four things were wrong:

  • wave login, the command the README and wave doctor tell users to run, doesn't exist.
  • wave auth login --api-key crashes with keytar.setPassword is not a function.
  • The device flow calls /api/oauth/device/*, which returns 404 ROUTE_NOT_FOUND.
  • A stored key could never be used anyway: No project "default" configured.

Setting WAVE_API_KEY was the only auth path that worked. Even with it set, whoami, auth status, status, billing, api and link still said "Not authenticated", because they read only the keychain. Billing, admin, logs, listen, dev and trigger all called https://wave.online/api/*, the marketing host, which returns 404 on every path. wave init produced an empty project and then printed "Ready!".

This PR fixes the client side of all of that. Everything below was checked against the live API, not just against mocks.

What changed (24 commits: the original 5, then review fixes)

  1. Credentials (fix(auth)).
    • keytar interop. keytar is CommonJS. Under ESM, its namespace exposes only getPassword. The loader now takes the complete object from .default. A keytar that loads without its full API is refused with an error; the 0600 ~/.wave/credentials.json fallback is used only when no keychain can load (announced once on stderr) or when WAVE_CREDENTIAL_STORE=file asks for it.
    • Device flow. It now uses the SDK agent-auth ceremony, POST /v1/agent/auth/device and /token. It stores the refresh token and refreshes before expiry. A refused refresh sends the old token (the API's 401 then says to log in again); a network failure past expiry, or a refreshed token that cannot be saved, is reported. --no-browser prints the URL instead of opening it.
    • One resolver. Every command now finds the key, host and org the same way:
      • key: WAVE_API_KEY first, then the stored key
      • host: WAVE_BASE_URL, then the project's saved baseUrl, then https://api.wave.online; it must be https:// (plain http only for loopback) before any credential is sent
      • org: --org, then WAVE_ORG_ID, then the saved org, on SDK and raw routes alike
    • auth login writes the project entry. wave login / wave logout exist as aliases. --api-key-stdin keeps the key out of argv.
    • Files. Credentials and config are written atomically under an inter-process lock. A file that does not parse is reported and never overwritten (1.0.10 saved defaults over it).
    • whoami. It calls GET /v1/me. When the key lacks me:read (403), it takes the org from GET /v1/billing; a 401/5xx is an error, not a partial identity.
    • Exit codes. A missing credential exits 2 everywhere, including auth status. ROUTE_NOT_FOUND / ROUTE_NOT_MAPPED exit 11 as "not served".
  2. Routes (fix(commands)).
    • billing status|usage call GET /v1/billing and /v1/billing/usage.
    • analytics overview|engagement|top-content are new and call the served /v1/analytics/* routes.
    • identity resolve sends GET ?agent= (it was POST, which got ROUTE_NOT_MAPPED).
    • wave api targets the API host, refuses to send the key to any other origin (a file: or gopher: URL included), and exits with the shared codes. A malformed -H (no colon, a bad name, a line break or NUL in the value) is an error that never echoes the value; 1.0.10 dropped a header with no colon silently.
    • link gets the org from served routes and exits non-zero when unauthenticated.
    • logs tail, listen, trigger, dev, admin jobs, mesh status|regions, and billing invoices|limits|portal|upgrade now exit 11 before sending anything.
  3. Keychain bound (fix(auth)). A locked macOS login keychain made auth login --api-key hang with no output (seen live). Every keychain call now times out after 60s (WAVE_KEYCHAIN_TIMEOUT_MS, capped at Node's 2^31-1 ms timer limit), says what to do instead, and ends via SIGKILL with the real status (137), because process.exit() cannot end a process with a native call stuck on a libuv worker.
  4. init (fix(init)). Templates are found from the package root (missing: fail, not an empty project). Generated projects depend on @wave-av/sdk, get tsconfig.json, and every offered template type-checks against SDK 2.1.3. Templates that call SDK methods that don't exist are no longer shipped.
  5. Docs. README auth section, CHANGELOG under [Unreleased], homepage → https://docs.wave.online/docs/cli (200), and npm run smoke:live.

Review rounds

  • Round 3 (f4f431d, b537b8d, c8d739e). Lock takeover race (greptile P1): removing a stale lock now needs an O_EXCL marker named after its identity (dev, inode, mtime ns) plus a re-check, and release removes only a lock whose random stamp is ours. doctor on a broken store (greptile P2) is a failed Auth check in the full report. Trojan Source (semgrep): no literal bidi controls in source.
  • Round 4 (1357235, 3b0a7a3, 8bed18c, 15372f8). A process killed mid-recovery no longer leaves a marker that blocks every later write, and release can't race a takeover (greptile P1 x2). A device grant with no usable interval/expires_in polls every 5s instead of in a tight loop (RFC 8628 §3.2). Every human-mode error message goes through sanitizeForTerminal. The smoke resolves --bin to an absolute path and requires a real stream page once /v1/streams is served.
  • Round 5 (54c1101, 8ee594d). The smoke runs a Windows npm wave.cmd shim's JavaScript entry without a shell (semgrep high). A 402 says what it means (PAYMENT_REQUIRED: nothing was performed or charged) and keeps the gateway's code, message and metered dimension on raw routes.
  • Round 6 (9396f52, a9532dd, f06d62f, 6b4eb44; WAVE BugBot on 8ee594d, greptile on a9532dd). WAVE_KEYCHAIN_TIMEOUT_MS=1e15 failed every keychain call after 1 ms, because Node runs an oversized timer at once; overrides are now capped at 2147483647 ms. wave api -H parsing is strict (above), and it checks for a line break before trimming, because trim() hid a trailing CR. The init command tests load the command tree once in beforeAll: on a loaded machine the cold import alone exceeded the first test's 5s timeout.

Proof

  • On 6b4eb44: tsc --noEmit clean. vitest run: 25 files / 293 tests, green on 3 consecutive full runs at load average ~86 on 20 cores. npm run build OK. (npm run lint is broken on main too: there's no eslint.config.* and eslint isn't a dependency.)
  • Live, from the installed tarball (npm pack of 6b4eb44, global install into a temp prefix from the public registry): smoke:live --bin <prefix>/bin/wave passed 20/20. It first checks two known-served controls (/v1/network/surface, /v1/x402/facilitator/supported, both 200), then a body field for every check: billing status → plan:"free", billing usage → period, analytics overview → summary, webhook-subscriptions → subscriptions[], whoami → org via the billing fallback, identity resolve → the handler's own UNKNOWN_AGENT (req c1524170-c8c3-434a-ba5b-c0ec7185d687), stream list and api GET /v1/streams → exit 11 ROUTE_NOT_FOUND (req 8ea8561c-5c8b-4e42-bb54-d4dc2fdd82b0), a non-loopback http:// base URL → refused, exit 9, and then login --api-key-stdin → whoami/billing with the stored key only → logout → auth status exits 2.
  • wave api -H, live on the same installed binary: api GET /v1/billing -H "Accept: application/json" → exit 0 with plan:"free"; -H X-Organization-Id (no colon) → refused before sending, exit 1; a CR/LF value, or a trailing CR alone → refused, the value not echoed; api GET file:///etc/passwd → "Refusing to send your WAVE credential to a file: URL".
  • Earlier rounds, on their own heads: 16 concurrent wave login processes to 16 projects kept every key and entry and left no lock, marker or temp file; 6 processes recovering a 2-minute-old dead lock all succeeded.
  • Review-bot findings that did not hold up, each checked against the code: wave api SSRF/scheme (the credential goes only to the configured, TLS-validated origin; tests pin file:/gopher: refused and data:/javascript: resolving on the API host); -H header injection (Node's fetch refuses CR/LF values, and the CLI now refuses them first); dev --forward-to (the command exits 11 before any network call); readApiKeyFromStdin without isTTY (a default parameter, always a stream); loadCredentials JSON.parse (already in a try/catch, and a bad file is reported, never overwritten); loadConfig swallowing errors (only ENOENT returns defaults).

Not in this repo

  • @wave-av/sdk drops the message from flat error bodies ({"error":"spend_cap_exceeded","code":...}); the fix is wave-av/sdk#151 (open). Until it ships, this PR reports a bare SDK 402 as PAYMENT_REQUIRED, and its raw-route parser already keeps that shape.
  • The gateway returns 404 for /v1/streams, but the spec still lists the route. Mesh routes require an x-wave-node header that the spec doesn't declare. Default customer keys lack me:read.
  • #82 and #84 also bump the version and hide/preview the stream groups. This PR doesn't change version, so whichever release comes next picks these changes up.

🤖 Generated with Claude Code