diff --git a/CLAUDE.md b/CLAUDE.md index 46c9edf..2e2a5a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ Top-level `defineCommand` in `src/index.ts` wires thirteen subcommands (`cloud`, **Cloud command workflow** (the canonical shape other commands mirror): upload binary with SHA dedup → extract metadata / validate devices → build execution plan → submit tests → poll results every 10s → download artifacts (reports/videos/logs). `RunFailedError` from the polling service is the signal for a failed test run (distinct from infra errors). -**Auth.** Every command calls `resolveAuth({ apiKeyFlag })` (`src/utils/auth.ts`) once and threads the returned `AuthContext` into gateways/services. `ApiGateway` and `fetchCompatibilityData` spread `auth.headers` into fetch headers — they no longer accept a raw api key. Precedence: `--api-key` flag > `DEVICE_CLOUD_API_KEY` env > stored session from `dcd login`. `resolveAuth` refreshes expiring Supabase sessions via `CliAuthGateway.refresh` and rewrites the config atomically. A session's access token lasts about an hour, so code that can outlive it re-checks with `isAuthExpiring` / `refreshAuth`: the `dcd cloud` poll loop and the report downloads after it, the MCP context, and `dcd_run_cloud_test`'s wait loop. `refreshAuth` keeps the org the command started with and leaves API-key contexts untouched. +**Auth.** Every command calls `resolveAuth({ apiKeyFlag })` (`src/utils/auth.ts`) once and threads the returned `AuthContext` into gateways/services. `ApiGateway` and `fetchCompatibilityData` spread `auth.headers` into fetch headers — they no longer accept a raw api key. The same headers carry the CLI's identity on every request (`x-dcd-cli-version`, plus `x-dcd-ci-provider` / `x-dcd-ci-wrapper-version` from `detectCiContext`), which the API uses for notice targeting and records on each upload and binary (dcd#1622). Precedence: `--api-key` flag > `DEVICE_CLOUD_API_KEY` env > stored session from `dcd login`. `resolveAuth` refreshes expiring Supabase sessions via `CliAuthGateway.refresh` and rewrites the config atomically. A session's access token lasts about an hour, so code that can outlive it re-checks with `isAuthExpiring` / `refreshAuth`: the `dcd cloud` poll loop and the report downloads after it, the MCP context, and `dcd_run_cloud_test`'s wait loop. `refreshAuth` keeps the org the command started with and leaves API-key contexts untouched. **Config store.** `dcd login` writes `$XDG_CONFIG_HOME/dcd/config.json` (fallback `~/.dcd/config.json`, 0600). Shape: `{ version, env, api_url, supabase_url, session: { access_token, refresh_token, expires_at, user_email, user_id }, current_org_id, current_org_name }`. `DCD_CONFIG_DIR` overrides the directory (used by tests). The login command itself (`src/commands/login.ts`) uses PKCE (S256) with a server rendezvous — no loopback server: it mints `state`, `code_verifier`, and `code_challenge`, opens `/cli-login?state=...&code_challenge=...`, then polls the dcd API's `POST /cli-login/claim` with `{state, code_verifier}` while the frontend POSTs proof of identity (the browser's access token) to `POST /cli-login/handoff`; the API verifies the token, **mints a dedicated Supabase session for the CLI** (its own refresh-token family — sharing the browser's tokens caused "Invalid Refresh Token: Already Used" whenever either client rotated them), stores it keyed by state, then on claim verifies `sha256(verifier) === challenge` and returns it. After claiming, the CLI fetches `/me/orgs` and picks the org with `selectOrg` (`src/utils/orgs.ts`): `--org ` if given, else the only org, else the picker `dcd switch-org` uses, starting on the last login's org. That org only counts when the stored `api_url` matches, because org ids are per environment. Without a TTY (`canPrompt` in `src/utils/ci.ts`) the prompts can't be answered: the "already logged in" confirm takes its default and signs in again, the last login's org is used, and with none the command fails pointing at `--org`. `dcd logout` deletes the whole config, so nothing is remembered after it. The frontend lives in `../dcd/frontend/app/features/cli-login/CliLoginScreen.tsx`. diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index 204665d..969c633 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -365,11 +365,7 @@ export const cloudCommand = defineCommand({ const ciContext = detectCiContext(); let compatibilityData: CompatibilityData; try { - compatibilityData = await fetchCompatibilityData(apiUrl, auth, { - cliVersion, - ciProvider: ciContext.provider, - ciWrapperVersion: ciContext.wrapperVersion, - }); + compatibilityData = await fetchCompatibilityData(apiUrl, auth); if (debug) { out('[DEBUG] Successfully fetched compatibility data from API'); } diff --git a/src/commands/upload.ts b/src/commands/upload.ts index ffa1359..3aca6f2 100644 --- a/src/commands/upload.ts +++ b/src/commands/upload.ts @@ -7,8 +7,7 @@ import { outputFlags } from '../config/flags/output.flags.js'; import { uploadBinary, verifyAppZip } from '../methods.js'; import { telemetry } from '../services/telemetry.service.js'; import { resolveAuth } from '../utils/auth.js'; -import { detectCiContext } from '../utils/ci.js'; -import { CliError, getCliVersion, logger } from '../utils/cli.js'; +import { CliError, logger } from '../utils/cli.js'; import { fetchCompatibilityData } from '../utils/compatibility.js'; import { resolveApiUrl } from '../utils/config-store.js'; import { checkKekForEncryption, resolveEncryption } from '../utils/envelope.js'; @@ -60,12 +59,7 @@ export const uploadCommand = defineCommand({ // failed fetch leaves the default off rather than failing the upload. let serverDefault: boolean | undefined; try { - const ciContext = detectCiContext(); - const compatibilityData = await fetchCompatibilityData(apiUrl, auth, { - cliVersion: getCliVersion(), - ciProvider: ciContext.provider, - ciWrapperVersion: ciContext.wrapperVersion, - }); + const compatibilityData = await fetchCompatibilityData(apiUrl, auth); serverDefault = compatibilityData.encryption?.defaultOn; } catch (error) { if (debug) { diff --git a/src/utils/app-upload.ts b/src/utils/app-upload.ts index 4aafed7..86c0631 100644 --- a/src/utils/app-upload.ts +++ b/src/utils/app-upload.ts @@ -2,8 +2,7 @@ import { rm } from 'node:fs/promises'; import { uploadBinary, verifyAppZip } from '../methods.js'; import type { AuthContext } from '../types/domain/auth.types.js'; -import { detectCiContext } from './ci.js'; -import { CliError, getCliVersion } from './cli.js'; +import { CliError } from './cli.js'; import { fetchCompatibilityData } from './compatibility.js'; import { checkKekForEncryption, resolveEncryption } from './envelope.js'; import { extractTarGz, findAppBundle } from './expo.js'; @@ -47,12 +46,7 @@ export async function uploadLocalApp(opts: UploadLocalAppOptions): Promise DEVICE_CLOUD_API_KEY env > stored * Supabase session (refreshed if near expiry). Throws CliError if none found. + * The headers also carry the CLI's own identity (see clientHeaders). * * The refresh path writes the rotated tokens back to disk atomically. Callers * resolve once per invocation, not per request — except where a process can @@ -17,7 +18,8 @@ import { import { telemetry } from '../services/telemetry.service.js'; import type { AuthContext } from '../types/domain/auth.types.js'; -import { CliError } from './cli.js'; +import { detectCiContext } from './ci.js'; +import { CliError, getCliVersion } from './cli.js'; import { StoredConfig, StoredSession, @@ -58,6 +60,22 @@ export function apiKeyOverride( return undefined; } +/** + * Who is calling, sent with every request next to the credential: the API + * targets notices by it and records it on each upload and binary. + * The CI headers are sent only when a DCD wrapper forwarded them or the + * provider could be inferred. + */ +function clientHeaders(): Record { + const headers: Record = { + 'x-dcd-cli-version': getCliVersion(), + }; + const ci = detectCiContext(); + if (ci.provider) headers['x-dcd-ci-provider'] = ci.provider; + if (ci.wrapperVersion) headers['x-dcd-ci-wrapper-version'] = ci.wrapperVersion; + return headers; +} + export async function resolveAuth( opts: ResolveAuthOptions, ): Promise { @@ -66,7 +84,7 @@ export async function resolveAuth( if (flag) { const auth: AuthContext = { mode: 'apiKey', - headers: { 'x-app-api-key': flag }, + headers: { ...clientHeaders(), 'x-app-api-key': flag }, }; telemetry.configure({ auth }); return auth; @@ -76,7 +94,7 @@ export async function resolveAuth( if (env) { const auth: AuthContext = { mode: 'apiKey', - headers: { 'x-app-api-key': env }, + headers: { ...clientHeaders(), 'x-app-api-key': env }, }; telemetry.configure({ auth }); return auth; @@ -113,6 +131,7 @@ export async function resolveAuth( orgId: config.current_org_id, userEmail: session.user_email, headers: { + ...clientHeaders(), authorization: `Bearer ${session.access_token}`, 'x-dcd-org': config.current_org_id, }, diff --git a/src/utils/compatibility.ts b/src/utils/compatibility.ts index 0440087..6525dec 100644 --- a/src/utils/compatibility.ts +++ b/src/utils/compatibility.ts @@ -19,42 +19,25 @@ export interface CompatibilityData { encryption?: { defaultOn: boolean }; } -/** Identity the CLI forwards so the API can target notices by version / CI. */ -export interface ClientContext { - cliVersion?: string; - ciProvider?: string | null; - ciWrapperVersion?: string | null; -} - let cachedCompatibilityData: CompatibilityData | null = null; +/** + * The CLI / CI identity the API version-filters and targets notices by rides + * on `auth.headers`, like on every other request (see resolveAuth). + */ export async function fetchCompatibilityData( apiUrl: string, auth: AuthContext, - clientContext?: ClientContext, ): Promise { if (cachedCompatibilityData) { return cachedCompatibilityData; } - // Forward CLI / CI identity so the API can version-filter and target notices. - const noticeHeaders: Record = {}; - if (clientContext?.cliVersion) { - noticeHeaders['x-dcd-cli-version'] = clientContext.cliVersion; - } - if (clientContext?.ciProvider) { - noticeHeaders['x-dcd-ci-provider'] = clientContext.ciProvider; - } - if (clientContext?.ciWrapperVersion) { - noticeHeaders['x-dcd-ci-wrapper-version'] = clientContext.ciWrapperVersion; - } - try { const response = await fetch(`${apiUrl}/results/compatibility/data`, { headers: { 'Content-Type': 'application/json', ...auth.headers, - ...noticeHeaders, }, method: 'GET', }); diff --git a/src/utils/device-bundle.ts b/src/utils/device-bundle.ts index 7cfc103..f17393a 100644 --- a/src/utils/device-bundle.ts +++ b/src/utils/device-bundle.ts @@ -13,8 +13,7 @@ import { } from '../services/execution-plan.utils.js'; import type { AuthContext } from '../types/domain/auth.types.js'; import type { DeviceFlowBundle } from '../types/domain/device-session.types.js'; -import { detectCiContext } from './ci.js'; -import { CliError, getCliVersion } from './cli.js'; +import { CliError } from './cli.js'; import { fetchCompatibilityData } from './compatibility.js'; import { checkKekForEncryption, @@ -156,12 +155,7 @@ export async function uploadFlowBundle( let serverDefault: boolean | undefined; try { - const ci = detectCiContext(); - const compatibility = await fetchCompatibilityData(apiUrl, auth, { - cliVersion: getCliVersion(), - ciProvider: ci.provider, - ciWrapperVersion: ci.wrapperVersion, - }); + const compatibility = await fetchCompatibilityData(apiUrl, auth); serverDefault = compatibility.encryption?.defaultOn; } catch { serverDefault = undefined; diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts index 0a605ae..ba8c808 100644 --- a/test/unit/auth.test.ts +++ b/test/unit/auth.test.ts @@ -8,6 +8,7 @@ import { SessionRefreshError, } from '../../src/gateways/cli-auth-gateway.js'; import { resolveAuth } from '../../src/utils/auth.js'; +import { getCliVersion } from '../../src/utils/cli.js'; import { clearConfig, configFileMode, @@ -260,3 +261,86 @@ describe('resolveAuth precedence', () => { }); }); }); + +// the API records which CLI and CI wrapper sent each upload, so the +// identity has to ride on every request, whichever credential is used. +describe('resolveAuth client identity headers', () => { + // Everything ci.ts reads, so a run inside CI (GITHUB_ACTIONS) can't leak in. + const CI_ENV = [ + 'DCD_CI_PROVIDER', + 'DCD_CI_WRAPPER_VERSION', + 'GITHUB_ACTIONS', + 'BITRISE_IO', + 'BITRISE_BUILD_NUMBER', + 'BITBUCKET_BUILD_NUMBER', + 'EAS_BUILD', + 'EAS_BUILD_RUNNER', + 'EAS_BUILD_ID', + 'GITLAB_CI', + 'CIRCLECI', + ]; + const cliVersion = getCliVersion(); + + beforeEach(() => { + delete process.env.DEVICE_CLOUD_API_KEY; + for (const name of CI_ENV) delete process.env[name]; + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it('sends the CLI version on the --api-key, env and session paths', async () => { + await withTempConfigDir(async () => { + writeConfig({ + version: 1, + env: 'prod', + api_url: 'https://api.devicecloud.dev', + supabase_url: 'https://cloud.devicecloud.dev', + session: { + access_token: 'bearer-token', + refresh_token: 'refresh', + expires_at: Math.floor(Date.now() / 1000) + 3600, + user_email: 'u@example.com', + user_id: 'u1', + }, + current_org_id: '42', + }); + + const flag = await resolveAuth({ apiKeyFlag: 'flag-key' }); + process.env.DEVICE_CLOUD_API_KEY = 'env-key'; + const env = await resolveAuth({ apiKeyFlag: undefined }); + const session = await resolveAuth({ apiKeyFlag: undefined, sessionOnly: true }); + + for (const auth of [flag, env, session]) { + expect(auth.headers['x-dcd-cli-version']).to.equal(cliVersion); + expect(auth.headers).to.not.have.property('x-dcd-ci-provider'); + expect(auth.headers).to.not.have.property('x-dcd-ci-wrapper-version'); + } + expect(session.headers.authorization).to.equal('Bearer bearer-token'); + }); + }); + + it('sends the CI provider and wrapper version a DCD wrapper forwarded', async () => { + process.env.DCD_CI_PROVIDER = 'bitbucket'; + process.env.DCD_CI_WRAPPER_VERSION = '1.5.0'; + + const auth = await resolveAuth({ apiKeyFlag: 'flag-key' }); + + expect(auth.headers).to.include({ + 'x-app-api-key': 'flag-key', + 'x-dcd-cli-version': cliVersion, + 'x-dcd-ci-provider': 'bitbucket', + 'x-dcd-ci-wrapper-version': '1.5.0', + }); + }); + + it('sends an inferred provider without a wrapper version', async () => { + process.env.GITLAB_CI = 'true'; + + const auth = await resolveAuth({ apiKeyFlag: 'flag-key' }); + + expect(auth.headers['x-dcd-ci-provider']).to.equal('gitlab'); + expect(auth.headers).to.not.have.property('x-dcd-ci-wrapper-version'); + }); +}); diff --git a/test/unit/session-refresh.test.ts b/test/unit/session-refresh.test.ts index 919545b..646f1a5 100644 --- a/test/unit/session-refresh.test.ts +++ b/test/unit/session-refresh.test.ts @@ -283,7 +283,10 @@ describe('session refresh for long-running processes', () => { const result = await poll(expiredBearer('stale', '42')); expect(result.status).to.equal('PASSED'); - expect(seen).to.deep.equal([{ authorization: 'Bearer fresh', 'x-dcd-org': '42' }]); + // `include`, not equal: a resolved context also carries the CLI's + // identity headers (dcd#1622), which this test is not about. + expect(seen).to.have.length(1); + expect(seen[0]).to.include({ authorization: 'Bearer fresh', 'x-dcd-org': '42' }); expect(realtime.joinedWith).to.deep.equal(['stale']); expect(realtime.updatedTo).to.deep.equal(['fresh']); });