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
5 changes: 4 additions & 1 deletion .github/workflows/cli-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,11 @@ jobs:
#
# It has to be on a NORMAL commit, not the merge commit — release-please's
# commit splitting is unreliable on merges.
#
# Release-please's own release PR also targets `production` but is not a
# promotion: it never carries a pin, so the check would always fail on it.
promotion-pin:
if: github.event_name == 'pull_request' && github.base_ref == 'production'
if: github.event_name == 'pull_request' && github.base_ref == 'production' && !startsWith(github.head_ref, 'release-please--')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
Expand Down
5 changes: 5 additions & 0 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@
"name": "DCD_API_URL",
"description": "Override the API base URL (e.g. for a non-production environment).",
"isRequired": false
},
{
"name": "DCD_ENCRYPT",
"description": "Uploads (app binary, flows, env vars) are encrypted on your machine by default. Set to 0 to upload them unencrypted; a run's encrypt input overrides this.",
"isRequired": false
}
]
}
Expand Down
6 changes: 3 additions & 3 deletions src/commands/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ export const cloudCommand = defineCommand({
// Client-side envelope encryption of every sensitive artifact: the
// binary (#1138), the flow zip (#1151), and env vars (#1152). Kept as
// given (--no-encrypt is an explicit false) and resolved once the
// compatibility data, which carries the org's default, is in.
// compatibility data, which carries the server default, is in.
const encryptFlag = args.encrypt as boolean | undefined;
const includeTags = coerceArray(
collectRepeatedFlag(rawArgs, ['--include-tags']),
Expand Down Expand Up @@ -390,7 +390,7 @@ export const cloudCommand = defineCommand({
out(`[DEBUG] API URL: ${apiUrl}`);
}

const encryption = checkKekForEncryption(
const encryption = await checkKekForEncryption(
resolveEncryption({
flag: encryptFlag,
serverDefault: compatibilityData.encryption?.defaultOn,
Expand Down Expand Up @@ -797,7 +797,7 @@ export const cloudCommand = defineCommand({
}

if (encrypt && encryption.source === 'server') {
out(ui.note('Encrypting uploads (beta); opt out with --no-encrypt'));
out(ui.note('Encrypting uploads; opt out with --no-encrypt'));
}

if (!finalBinaryId) {
Expand Down
6 changes: 3 additions & 3 deletions src/commands/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const uploadCommand = defineCommand({
const appUrl = args['app-url'] as string | undefined;
const ignoreShaCheck = Boolean(args['ignore-sha-check']);
// Kept as given, so --no-encrypt is an explicit false and a missing flag
// leaves DCD_ENCRYPT and then the org's default to decide.
// leaves DCD_ENCRYPT and then the server default to decide.
const encryptFlag = args.encrypt as boolean | undefined;
const debug = Boolean(args.debug);
const positional = args.appFile as string | undefined;
Expand All @@ -75,7 +75,7 @@ export const uploadCommand = defineCommand({
}
}

const encryption = checkKekForEncryption(
const encryption = await checkKekForEncryption(
resolveEncryption({ flag: encryptFlag, serverDefault }),
apiUrl,
);
Expand Down Expand Up @@ -121,7 +121,7 @@ export const uploadCommand = defineCommand({
ui.branch([
...ui.fields([['file', colors.highlight(resolvedFile)]]),
...(encryption.enabled && encryption.source === 'server'
? [ui.note('Encrypting uploads (beta); opt out with --no-encrypt')]
? [ui.note('Encrypting uploads; opt out with --no-encrypt')]
: []),
]),
);
Expand Down
61 changes: 61 additions & 0 deletions src/config/cli-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* The dcd API's public `GET /cli-config`: runtime configuration the CLI used to
* compile in, fetched from the API it talks to so that rotating any of it is an
* env change on the API rather than a CLI release.
*
* - `supabase_publishable_key` (dcd#1112): see ./supabase-key.ts.
* - `binary_kek_public_key` (dcd#1138): the newest KEK public key the API
* publishes for client-side envelope encryption, with the version the API
* unwraps it with. Absent when the API publishes none.
*
* Fetched once per process per API URL and kept in memory only. It is
* deliberately not cached on disk: a stale Supabase key makes GoTrue answer a
* refresh with a 4xx, which `resolveAuth` treats as a dead session and deletes
* the user's login, and a stale KEK would wrap uploads the API can no longer
* unwrap. A failed fetch is not cached, so the next call retries.
*/

const FETCH_TIMEOUT_MS = 10_000;

export interface CliConfig {
supabase_url?: string;
supabase_publishable_key?: string;
binary_kek_public_key?: { version: number; key: string };
}

const cache = new Map<string, Promise<CliConfig>>();

/** `apiUrl` without trailing slashes, so `…/` and `…` share one cache entry. */
export function cliConfigUrl(apiUrl: string): string {
return `${apiUrl.replace(/\/+$/, '')}/cli-config`;
}

async function fetchCliConfig(apiUrl: string): Promise<CliConfig> {
const url = cliConfigUrl(apiUrl);
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
if (!res.ok) {
throw new Error(`Failed to fetch CLI config from ${url}: HTTP ${res.status}`);
}
const body: unknown = await res.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) {
throw new Error(`CLI config from ${url} is not a JSON object`);
}
return body as CliConfig;
}

/** The API's CLI config. A failed fetch is not cached, so the next call retries. */
export function getCliConfig(apiUrl: string): Promise<CliConfig> {
const key = cliConfigUrl(apiUrl);
let pending = cache.get(key);
if (!pending) {
pending = fetchCliConfig(apiUrl);
cache.set(key, pending);
pending.catch(() => cache.delete(key));
}
return pending;
}

/** Test seam: forget fetched configs. */
export function resetCliConfigCache(): void {
cache.clear();
}
45 changes: 5 additions & 40 deletions src/config/environments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
* Per-env devicecloud.dev coordinates — single source of truth for the URLs
* the CLI uses.
*
* There is deliberately no Supabase key here. A compiled-in key can never be
* rotated without breaking every pinned CLI version, so it is fetched from the
* API at runtime instead: see `getSupabaseKey` in ./supabase-key.ts (dcd#1112).
* There is deliberately no Supabase key and no KEK public key here. A
* compiled-in key can never be rotated without breaking every pinned CLI
* version, so both are fetched from the API at runtime instead: see
* ./cli-config.ts (`getSupabaseKey` in ./supabase-key.ts for dcd#1112, and
* `resolveKekPublicKey` in ../utils/envelope.ts for dcd#1138).
*/

export type DcdEnvName = 'dev' | 'prod';
Expand All @@ -16,20 +18,6 @@ export interface DcdEnvironment {
url: string;
projectRef: string;
};
/**
* Pinned KEK public key for client-side binary envelope encryption (dcd#1138).
* `key` is base64 of the raw 32-byte X25519 public key; `version` selects which
* KEK the platform API unwraps with. **Public — safe to embed**: it can only
* *encrypt*; the private half lives solely on the API.
*
* Left `null` until the KEK is generated and provisioned. Generate a keypair
* with, e.g.:
* node -e 'const c=require("crypto");const{publicKey,privateKey}=c.generateKeyPairSync("x25519");const pub=publicKey.export({type:"spki",format:"der"}).subarray(12);const priv=privateKey.export({type:"pkcs8",format:"der"}).subarray(16);console.log("public :",pub.toString("base64"));console.log("private:",priv.toString("base64"))'
* Pin `public` here; set `private` as `BINARY_KEK_PRIVATE_KEYS={"<version>":"<private base64>"}`
* on the API service and keep one offline escrow copy. Until then, encryption
* can be exercised via the `DCD_BINARY_KEK_PUBLIC` env override (see envelope.ts).
*/
kekPublicKey: { version: number; key: string } | null;
}

export const ENVIRONMENTS: Record<DcdEnvName, DcdEnvironment> = {
Expand All @@ -40,7 +28,6 @@ export const ENVIRONMENTS: Record<DcdEnvName, DcdEnvironment> = {
url: 'https://cloud.devicecloud.dev',
projectRef: 'pgydnphbimetinsgfkbo',
},
kekPublicKey: { version: 1, key: 'wtfyWEwK7nJzwI4PD+9RAW8jxIR1u8kMQq2IhsrVnH4=' },
},
dev: {
apiUrl: 'https://api.dev.devicecloud.dev',
Expand All @@ -49,7 +36,6 @@ export const ENVIRONMENTS: Record<DcdEnvName, DcdEnvironment> = {
url: 'https://lbmsowehtjwnqlurpemb.supabase.co',
projectRef: 'lbmsowehtjwnqlurpemb',
},
kekPublicKey: { version: 1, key: 'RgcToF/OJpcQI9koYvSvtj/WLaebfcN4v5GJoqtr/00=' },
},
};

Expand All @@ -68,27 +54,6 @@ export function inferEnvFromApiUrl(apiUrl: string): DcdEnvName {
return 'prod';
}

/**
* The environment whose pinned KEK may encrypt uploads to `apiUrl` (dcd#1138),
* or undefined when the URL isn't a known one. Stricter than
* {@link inferEnvFromApiUrl}, which falls back to prod for any URL: that is
* harmless for picking a console link, but an upload encrypted for a KEK its
* API can't unwrap is unusable. Known means the host of a pinned `apiUrl`, or
* `localhost`, which runs against dev.
*/
export function kekEnvForApiUrl(apiUrl: string): DcdEnvName | undefined {
let host: string;
try {
host = new URL(apiUrl).hostname;
} catch {
return undefined;
}

if (host === 'localhost') return 'dev';
const names = Object.keys(ENVIRONMENTS) as DcdEnvName[];
return names.find((name) => new URL(ENVIRONMENTS[name].apiUrl).hostname === host);
}

/**
* Frontend URL to open for a given API URL. Same as
* `ENVIRONMENTS[inferEnvFromApiUrl(apiUrl)].frontendUrl`, except that when the
Expand Down
9 changes: 3 additions & 6 deletions src/config/flags/binary.flags.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { CliArgsDef } from '../../utils/help.js';
import type { ArgsDef } from 'citty';

/**
* Binary upload and management flags
Expand Down Expand Up @@ -26,10 +26,7 @@ export const binaryFlags = {
},
encrypt: {
type: 'boolean',
// Left out of --help while encryption is a per-org beta (dcd#1138); the
// flag still parses, --no-encrypt included.
hidden: true,
description:
'Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). The binary is still deduplicated across runs, so an unchanged app is not re-uploaded. Also turned on by DCD_ENCRYPT=1, and by default for orgs in the encryption beta; --no-encrypt or DCD_ENCRYPT=0 turns it off.',
'Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). On by default; --no-encrypt or DCD_ENCRYPT=0 turns it off. The binary is still deduplicated across runs, so an unchanged app is not re-uploaded.',
},
} as const satisfies CliArgsDef;
} as const satisfies ArgsDef;
40 changes: 9 additions & 31 deletions src/config/supabase-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,48 +3,26 @@
*
* Releases up to this one compiled the anon key in, so the key could never be
* rotated without breaking every CLI version pinned in customer CI. The API now
* serves it from the public `GET /cli-config`, and rotating it is an env change
* on the API.
*
* Fetched once per process and kept in memory only. It is deliberately not
* cached on disk: a stale key makes GoTrue answer a refresh with a 4xx, which
* `resolveAuth` treats as a dead session and deletes the user's login.
* serves it from the public `GET /cli-config` (see ./cli-config.ts for the
* fetch and its in-memory cache), and rotating it is an env change on the API.
*
* Only the key comes from the API. The Supabase URLs stay pinned in
* `ENVIRONMENTS`: prod goes through the `cloud.devicecloud.dev` custom domain,
* which customer firewalls may allow-list, not the API's raw `supabase.co` URL.
*/
import { cliConfigUrl, getCliConfig, resetCliConfigCache } from './cli-config.js';
import { ENVIRONMENTS, type DcdEnvName } from './environments.js';

const FETCH_TIMEOUT_MS = 10_000;

const cache = new Map<DcdEnvName, Promise<string>>();

async function fetchSupabaseKey(env: DcdEnvName): Promise<string> {
const url = `${ENVIRONMENTS[env].apiUrl}/cli-config`;
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
if (!res.ok) {
throw new Error(`Failed to fetch CLI config from ${url}: HTTP ${res.status}`);
}
const body = (await res.json()) as { supabase_publishable_key?: unknown };
const key = body.supabase_publishable_key;
/** The publishable key for `env`. A failed fetch is not cached, so the next call retries. */
export async function getSupabaseKey(env: DcdEnvName): Promise<string> {
const apiUrl = ENVIRONMENTS[env].apiUrl;
const key = (await getCliConfig(apiUrl)).supabase_publishable_key;
if (typeof key !== 'string' || key.length === 0) {
throw new Error(`CLI config from ${url} has no supabase_publishable_key`);
throw new Error(`CLI config from ${cliConfigUrl(apiUrl)} has no supabase_publishable_key`);
}
return key;
}

/** The publishable key for `env`. A failed fetch is not cached, so the next call retries. */
export function getSupabaseKey(env: DcdEnvName): Promise<string> {
let pending = cache.get(env);
if (!pending) {
pending = fetchSupabaseKey(env);
cache.set(env, pending);
pending.catch(() => cache.delete(env));
}
return pending;
}

/**
* Headers that authenticate a raw Storage/Auth request with `key` alone. A
* legacy anon key is a JWT and also goes in `Authorization`; an
Expand All @@ -56,5 +34,5 @@ export function supabaseKeyHeaders(key: string): Record<string, string> {

/** Test seam: forget fetched keys. */
export function resetSupabaseKeyCache(): void {
cache.clear();
resetCliConfigCache();
}
5 changes: 3 additions & 2 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
* Flags are organized by domain in src/config/flags/
*/

import type { ArgsDef } from 'citty';

import { apiFlags } from './config/flags/api.flags.js';
import { binaryFlags } from './config/flags/binary.flags.js';
import { deviceFlags } from './config/flags/device.flags.js';
import { environmentFlags } from './config/flags/environment.flags.js';
import { executionFlags } from './config/flags/execution.flags.js';
import { githubFlags } from './config/flags/github.flags.js';
import { outputFlags } from './config/flags/output.flags.js';
import type { CliArgsDef } from './utils/help.js';

/**
* All flag definitions consolidated from domain-specific flag modules.
Expand All @@ -24,6 +25,6 @@ export const flags = {
...executionFlags,
...githubFlags,
...outputFlags,
} as const satisfies CliArgsDef;
} as const satisfies ArgsDef;

export type FlagKey = keyof typeof flags;
11 changes: 2 additions & 9 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import { uploadCommand } from './commands/upload.js';
import { whoamiCommand } from './commands/whoami.js';
import { telemetry } from './services/telemetry.service.js';
import { CliError, getCliVersion, logger, wantsJson } from './utils/cli.js';
import { withoutHiddenArgs } from './utils/help.js';

// @clack/prompts ships the US spelling ("Canceled") for its built-in
// spinner/prompt cancellation message; align it with the British spelling
Expand Down Expand Up @@ -83,18 +82,12 @@ async function resolveSubCommand(
return [cmd, parent];
}

// Usage for the (sub)command named in rawArgs, without the args marked hidden.
async function showHelp(rawArgs: string[]): Promise<void> {
const [cmd, parent] = await resolveSubCommand(main, rawArgs);
await showUsage(await withoutHiddenArgs(cmd), parent);
}

async function run(): Promise<void> {
const rawArgs = process.argv.slice(2);
telemetry.recordCommandStart();
try {
if (rawArgs.includes('--help') || rawArgs.includes('-h')) {
await showHelp(rawArgs);
await showUsage(...(await resolveSubCommand(main, rawArgs)));
} else if (rawArgs.length === 1 && rawArgs[0] === '--version') {
logger.log(getCliVersion());
} else {
Expand All @@ -110,7 +103,7 @@ async function run(): Promise<void> {
// citty throws CLIError (by name — the class isn't exported) for usage
// problems like unknown commands or missing required args.
if (!json && error instanceof Error && error.name === 'CLIError') {
await showHelp(rawArgs);
await showUsage(...(await resolveSubCommand(main, rawArgs)));
}

const exitCode = error instanceof CliError ? error.exitCode : 1;
Expand Down
8 changes: 4 additions & 4 deletions src/mcp/tools/run-cloud-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export function registerRunCloudTest(server: McpServer): void {
.boolean()
.optional()
.describe(
'true encrypts the binary, flow zip and env vars client-side before upload; false never does. Omit to follow DCD_ENCRYPT, then the organization default.',
'true encrypts the binary, flow zip and env vars client-side before upload; false never does. Omit to follow DCD_ENCRYPT, then the default, which is on.',
),
wait: z
.boolean()
Expand Down Expand Up @@ -206,8 +206,8 @@ export function registerRunCloudTest(server: McpServer): void {
}

// Client-side envelope encryption (binary/flow/env), in the same order
// as `dcd cloud`: the encrypt input, DCD_ENCRYPT, then the org default.
const encryption = checkKekForEncryption(
// as `dcd cloud`: the encrypt input, DCD_ENCRYPT, then the server default.
const encryption = await checkKekForEncryption(
resolveEncryption({
flag: args.encrypt,
serverDefault: compatibilityData.encryption?.defaultOn,
Expand All @@ -225,7 +225,7 @@ export function registerRunCloudTest(server: McpServer): void {
};
telemetryExtra.encrypt = encryptTelemetry;
if (encrypt && encryption.source === 'server') {
logStderr('Encrypting uploads (beta); opt out with encrypt: false');
logStderr('Encrypting uploads; opt out with encrypt: false');
}

// Resolve the binary: existing id, or upload the local file.
Expand Down
Loading
Loading