Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<frontend>/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 <name|id>` 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`.

Expand Down
6 changes: 1 addition & 5 deletions src/commands/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
10 changes: 2 additions & 8 deletions src/commands/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
10 changes: 2 additions & 8 deletions src/utils/app-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -47,12 +46,7 @@ export async function uploadLocalApp(opts: UploadLocalAppOptions): Promise<strin
// rather than failing the upload.
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;
Expand Down
25 changes: 22 additions & 3 deletions src/utils/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Resolves which credential a command should use and returns the fetch headers
* to send. Precedence: --api-key flag > 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
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Comment thread
finalerock44 marked this conversation as resolved.
Resolved
*/
function clientHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'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<AuthContext> {
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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,
},
Expand Down
25 changes: 4 additions & 21 deletions src/utils/compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CompatibilityData> {
if (cachedCompatibilityData) {
return cachedCompatibilityData;
}

// Forward CLI / CI identity so the API can version-filter and target notices.
const noticeHeaders: Record<string, string> = {};
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',
});
Expand Down
10 changes: 2 additions & 8 deletions src/utils/device-bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
84 changes: 84 additions & 0 deletions test/unit/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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');
});
});
5 changes: 4 additions & 1 deletion test/unit/session-refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});
Expand Down
Loading