fix(cli): working auth, served routes, and a real wave init (connectivity for 1.0.10)
YA
1mo ago+4599+4599+0123456789012345678901234567890123456789-1469-1469-0123456789012345678901234567890123456789
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 andwave doctortell users to run, doesn't exist.wave auth login --api-keycrashes withkeytar.setPassword is not a function.- The device flow calls
/api/oauth/device/*, which returns404 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)
- 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.jsonfallback is used only when no keychain can load (announced once on stderr) or whenWAVE_CREDENTIAL_STORE=fileasks for it. - Device flow. It now uses the SDK agent-auth ceremony,
POST /v1/agent/auth/deviceand/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-browserprints the URL instead of opening it. - One resolver. Every command now finds the key, host and org the same way:
- key:
WAVE_API_KEYfirst, then the stored key - host:
WAVE_BASE_URL, then the project's savedbaseUrl, thenhttps://api.wave.online; it must behttps://(plain http only for loopback) before any credential is sent - org:
--org, thenWAVE_ORG_ID, then the saved org, on SDK and raw routes alike
- key:
auth loginwrites the project entry.wave login/wave logoutexist as aliases.--api-key-stdinkeeps 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 lacksme:read(403), it takes the org fromGET /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_MAPPEDexit 11 as "not served".
- keytar interop. keytar is CommonJS. Under ESM, its namespace exposes only
- Routes (
fix(commands)).billing status|usagecallGET /v1/billingand/v1/billing/usage.analytics overview|engagement|top-contentare new and call the served/v1/analytics/*routes.identity resolvesendsGET ?agent=(it was POST, which got ROUTE_NOT_MAPPED).wave apitargets the API host, refuses to send the key to any other origin (afile:orgopher: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.linkgets the org from served routes and exits non-zero when unauthenticated.logs tail,listen,trigger,dev,admin jobs,mesh status|regions, andbilling invoices|limits|portal|upgradenow exit 11 before sending anything.
- Keychain bound (
fix(auth)). A locked macOS login keychain madeauth login --api-keyhang 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), becauseprocess.exit()cannot end a process with a native call stuck on a libuv worker. - init (
fix(init)). Templates are found from the package root (missing: fail, not an empty project). Generated projects depend on@wave-av/sdk, gettsconfig.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. - Docs. README auth section, CHANGELOG under
[Unreleased],homepage→https://docs.wave.online/docs/cli(200), andnpm 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 usableinterval/expires_inpolls every 5s instead of in a tight loop (RFC 8628 §3.2). Every human-mode error message goes throughsanitizeForTerminal. The smoke resolves--binto an absolute path and requires a real stream page once/v1/streamsis served. - Round 5 (
54c1101,8ee594d). The smoke runs a Windows npmwave.cmdshim'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 metereddimensionon raw routes. - Round 6 (
9396f52,a9532dd,f06d62f,6b4eb44; WAVE BugBot on8ee594d, greptile ona9532dd).WAVE_KEYCHAIN_TIMEOUT_MS=1e15failed every keychain call after 1 ms, because Node runs an oversized timer at once; overrides are now capped at 2147483647 ms.wave api -Hparsing is strict (above), and it checks for a line break before trimming, becausetrim()hid a trailing CR. The init command tests load the command tree once inbeforeAll: on a loaded machine the cold import alone exceeded the first test's 5s timeout.
Proof
- On
6b4eb44:tsc --noEmitclean.vitest run: 25 files / 293 tests, green on 3 consecutive full runs at load average ~86 on 20 cores.npm run buildOK. (npm run lintis broken onmaintoo: there's noeslint.config.*and eslint isn't a dependency.) - Live, from the installed tarball (
npm packof6b4eb44, global install into a temp prefix from the public registry):smoke:live --bin <prefix>/bin/wavepassed 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 ownUNKNOWN_AGENT(reqc1524170-c8c3-434a-ba5b-c0ec7185d687),stream listandapi GET /v1/streams→ exit 11ROUTE_NOT_FOUND(req8ea8561c-5c8b-4e42-bb54-d4dc2fdd82b0), a non-loopbackhttp://base URL → refused, exit 9, and thenlogin --api-key-stdin→whoami/billingwith the stored key only →logout→auth statusexits 2. wave api -H, live on the same installed binary:api GET /v1/billing -H "Accept: application/json"→ exit 0 withplan:"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 loginprocesses 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 apiSSRF/scheme (the credential goes only to the configured, TLS-validated origin; tests pinfile:/gopher:refused anddata:/javascript:resolving on the API host);-Hheader 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);readApiKeyFromStdinwithoutisTTY(a default parameter, always a stream);loadCredentialsJSON.parse (already in a try/catch, and a bad file is reported, never overwritten);loadConfigswallowing errors (only ENOENT returns defaults).
Not in this repo
@wave-av/sdkdrops 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 asPAYMENT_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 anx-wave-nodeheader that the spec doesn't declare. Default customer keys lackme: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