From 7cdaa4c090621a86183b9aa33d8ea4112d558bb5 Mon Sep 17 00:00:00 2001 From: finalerock44 Date: Mon, 26 Oct 2026 15:28:50 +0000 Subject: [PATCH 1/3] feat(api): send the CLI version and CI identity on every API request x-dcd-cli-version, x-dcd-ci-provider and x-dcd-ci-wrapper-version were only sent on the compatibility call, for notices. resolveAuth now puts them on every auth context, so every gateway sends them. The API records them on each upload and binary, and on its HTTP log line, so it can tell which CLIs and CI wrappers still use an endpoint before it is retired. fetchCompatibilityData loses its client-context argument, since the auth headers carry the same values. MCP calls now send them too. Refs dcd#1622 --- CLAUDE.md | 2 +- src/commands/cloud.ts | 6 +-- src/commands/upload.ts | 10 +--- src/utils/app-upload.ts | 10 +--- src/utils/auth.ts | 25 +++++++-- src/utils/compatibility.ts | 25 ++------- src/utils/device-bundle.ts | 10 +--- test/unit/auth.test.ts | 84 +++++++++++++++++++++++++++++++ test/unit/session-refresh.test.ts | 5 +- 9 files changed, 122 insertions(+), 55 deletions(-) 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 (dcd#1622). + * 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..22149fb 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', () => { }); }); }); + +// dcd#1622: 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']); }); From c2893f625f016dd612c81d47a572f3c1633ea6b5 Mon Sep 17 00:00:00 2001 From: finalerock44 <77282157+finalerock44@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:33:38 +0000 Subject: [PATCH 2/3] Update src/utils/auth.ts Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- src/utils/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 5bbe596..b923ea1 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -62,7 +62,7 @@ export function apiKeyOverride( /** * 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 (dcd#1622). + * 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. */ From b4b28017dd4d2fe09f64250f625d095eed39eafe Mon Sep 17 00:00:00 2001 From: finalerock44 <77282157+finalerock44@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:37:50 +0000 Subject: [PATCH 3/3] Correct comment formatting in auth.test.ts Fix comment formatting in auth test file. --- test/unit/auth.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts index 22149fb..ba8c808 100644 --- a/test/unit/auth.test.ts +++ b/test/unit/auth.test.ts @@ -262,7 +262,7 @@ describe('resolveAuth precedence', () => { }); }); -// dcd#1622: the API records which CLI and CI wrapper sent each upload, so the +// 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.