@devicecloud.dev/dcd 5.9.0-beta.1 → 5.10.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/cloud.d.ts +1 -2
- package/dist/commands/cloud.js +3 -3
- package/dist/commands/login.js +5 -4
- package/dist/commands/logout.js +1 -3
- package/dist/commands/upload.d.ts +1 -2
- package/dist/commands/upload.js +3 -3
- package/dist/config/cli-config.d.ts +30 -0
- package/dist/config/cli-config.js +49 -0
- package/dist/config/environments.d.ts +6 -33
- package/dist/config/environments.js +6 -31
- package/dist/config/flags/binary.flags.d.ts +1 -2
- package/dist/config/flags/binary.flags.js +1 -4
- package/dist/config/supabase-key.d.ts +11 -0
- package/dist/config/supabase-key.js +35 -0
- package/dist/constants.d.ts +1 -2
- package/dist/gateways/cli-auth-gateway.d.ts +3 -2
- package/dist/gateways/cli-auth-gateway.js +16 -6
- package/dist/gateways/realtime-gateway.js +48 -34
- package/dist/gateways/supabase-gateway.d.ts +1 -1
- package/dist/gateways/supabase-gateway.js +8 -6
- package/dist/index.js +2 -8
- package/dist/mcp/tools/run-cloud-test.js +4 -4
- package/dist/methods.js +3 -3
- package/dist/services/test-submission.service.js +2 -2
- package/dist/utils/auth.js +1 -3
- package/dist/utils/compatibility.d.ts +2 -3
- package/dist/utils/envelope.d.ts +19 -13
- package/dist/utils/envelope.js +32 -28
- package/package.json +1 -1
- package/dist/utils/help.d.ts +0 -12
- package/dist/utils/help.js +0 -8
package/dist/commands/cloud.d.ts
CHANGED
|
@@ -242,8 +242,7 @@ export declare const cloudCommand: import("citty").CommandDef<{
|
|
|
242
242
|
};
|
|
243
243
|
readonly encrypt: {
|
|
244
244
|
readonly type: "boolean";
|
|
245
|
-
readonly
|
|
246
|
-
readonly 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.";
|
|
245
|
+
readonly description: "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.";
|
|
247
246
|
};
|
|
248
247
|
readonly 'api-key': {
|
|
249
248
|
readonly type: "string";
|
package/dist/commands/cloud.js
CHANGED
|
@@ -140,7 +140,7 @@ export const cloudCommand = defineCommand({
|
|
|
140
140
|
// Client-side envelope encryption of every sensitive artifact: the
|
|
141
141
|
// binary (#1138), the flow zip (#1151), and env vars (#1152). Kept as
|
|
142
142
|
// given (--no-encrypt is an explicit false) and resolved once the
|
|
143
|
-
// compatibility data, which carries the
|
|
143
|
+
// compatibility data, which carries the server default, is in.
|
|
144
144
|
const encryptFlag = args.encrypt;
|
|
145
145
|
const includeTags = coerceArray(collectRepeatedFlag(rawArgs, ['--include-tags']));
|
|
146
146
|
// Devices and OS versions are checked against the API's live
|
|
@@ -261,7 +261,7 @@ export const cloudCommand = defineCommand({
|
|
|
261
261
|
if (debug) {
|
|
262
262
|
out(`[DEBUG] API URL: ${apiUrl}`);
|
|
263
263
|
}
|
|
264
|
-
const encryption = checkKekForEncryption(resolveEncryption({
|
|
264
|
+
const encryption = await checkKekForEncryption(resolveEncryption({
|
|
265
265
|
flag: encryptFlag,
|
|
266
266
|
serverDefault: compatibilityData.encryption?.defaultOn,
|
|
267
267
|
}), apiUrl);
|
|
@@ -537,7 +537,7 @@ export const cloudCommand = defineCommand({
|
|
|
537
537
|
return;
|
|
538
538
|
}
|
|
539
539
|
if (encrypt && encryption.source === 'server') {
|
|
540
|
-
out(ui.note('Encrypting uploads
|
|
540
|
+
out(ui.note('Encrypting uploads; opt out with --no-encrypt'));
|
|
541
541
|
}
|
|
542
542
|
if (!finalBinaryId) {
|
|
543
543
|
if (!finalAppFile) {
|
package/dist/commands/login.js
CHANGED
|
@@ -35,7 +35,8 @@ import * as p from '@clack/prompts';
|
|
|
35
35
|
import { defineCommand } from 'citty';
|
|
36
36
|
import { spawn } from 'node:child_process';
|
|
37
37
|
import { createHash, randomBytes } from 'node:crypto';
|
|
38
|
-
import { ENVIRONMENTS, inferEnvFromApiUrl, resolveFrontendUrl } from '../config/environments.js';
|
|
38
|
+
import { ENVIRONMENTS, inferEnvFromApiUrl, resolveFrontendUrl, } from '../config/environments.js';
|
|
39
|
+
import { getSupabaseKey } from '../config/supabase-key.js';
|
|
39
40
|
import { jsonFlag } from '../config/flags/output.flags.js';
|
|
40
41
|
import { canPrompt } from '../utils/ci.js';
|
|
41
42
|
import { CliError, logger } from '../utils/cli.js';
|
|
@@ -179,7 +180,7 @@ export const loginCommand = defineCommand({
|
|
|
179
180
|
// rejects it, the token itself is bad; if Supabase accepts, the
|
|
180
181
|
// api's JwtService is misconfigured.
|
|
181
182
|
const iss = decodeJwtIssuer(payload.access_token);
|
|
182
|
-
const supabaseUser = await probeSupabaseUser(iss,
|
|
183
|
+
const supabaseUser = await probeSupabaseUser(iss, env, payload.access_token);
|
|
183
184
|
const parts = [
|
|
184
185
|
iss ? `token iss: ${iss}` : null,
|
|
185
186
|
`supabase /auth/v1/user: ${supabaseUser}`,
|
|
@@ -287,14 +288,14 @@ async function pollForClaim(apiUrl, state, codeVerifier) {
|
|
|
287
288
|
function base64url(buf) {
|
|
288
289
|
return buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
|
289
290
|
}
|
|
290
|
-
async function probeSupabaseUser(iss,
|
|
291
|
+
async function probeSupabaseUser(iss, env, token) {
|
|
291
292
|
if (!iss)
|
|
292
293
|
return 'skipped (no iss)';
|
|
293
294
|
try {
|
|
294
295
|
const res = await fetch(`${iss}/user`, {
|
|
295
296
|
headers: {
|
|
296
297
|
authorization: `Bearer ${token}`,
|
|
297
|
-
apikey:
|
|
298
|
+
apikey: await getSupabaseKey(env),
|
|
298
299
|
},
|
|
299
300
|
});
|
|
300
301
|
const bodyText = await res.text().catch(() => '');
|
package/dist/commands/logout.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
* it on Supabase. Leaves DEVICE_CLOUD_API_KEY (env/flag) untouched.
|
|
4
4
|
*/
|
|
5
5
|
import { defineCommand } from 'citty';
|
|
6
|
-
import { ENVIRONMENTS } from '../config/environments.js';
|
|
7
6
|
import { jsonFlag } from '../config/flags/output.flags.js';
|
|
8
7
|
import { CliAuthGateway } from '../gateways/cli-auth-gateway.js';
|
|
9
8
|
import { logger } from '../utils/cli.js';
|
|
@@ -31,8 +30,7 @@ export const logoutCommand = defineCommand({
|
|
|
31
30
|
}
|
|
32
31
|
return;
|
|
33
32
|
}
|
|
34
|
-
|
|
35
|
-
await CliAuthGateway.signOut(config.supabase_url, anonKey, config.session);
|
|
33
|
+
await CliAuthGateway.signOut(config.supabase_url, config.env, config.session);
|
|
36
34
|
clearConfig();
|
|
37
35
|
if (json) {
|
|
38
36
|
logger.json({ status: 'SUCCESS', loggedOut: true, user: config.session.user_email });
|
|
@@ -9,8 +9,7 @@ export declare const uploadCommand: import("citty").CommandDef<{
|
|
|
9
9
|
};
|
|
10
10
|
readonly encrypt: {
|
|
11
11
|
readonly type: "boolean";
|
|
12
|
-
readonly
|
|
13
|
-
readonly 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.";
|
|
12
|
+
readonly description: "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.";
|
|
14
13
|
};
|
|
15
14
|
readonly debug: {
|
|
16
15
|
readonly type: "boolean";
|
package/dist/commands/upload.js
CHANGED
|
@@ -46,7 +46,7 @@ export const uploadCommand = defineCommand({
|
|
|
46
46
|
const appUrl = args['app-url'];
|
|
47
47
|
const ignoreShaCheck = Boolean(args['ignore-sha-check']);
|
|
48
48
|
// Kept as given, so --no-encrypt is an explicit false and a missing flag
|
|
49
|
-
// leaves DCD_ENCRYPT and then the
|
|
49
|
+
// leaves DCD_ENCRYPT and then the server default to decide.
|
|
50
50
|
const encryptFlag = args.encrypt;
|
|
51
51
|
const debug = Boolean(args.debug);
|
|
52
52
|
const positional = args.appFile;
|
|
@@ -69,7 +69,7 @@ export const uploadCommand = defineCommand({
|
|
|
69
69
|
out(`[DEBUG] Failed to fetch compatibility data, so the org's encryption default is off: ${error instanceof Error ? error.message : String(error)}`);
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
|
-
const encryption = checkKekForEncryption(resolveEncryption({ flag: encryptFlag, serverDefault }), apiUrl);
|
|
72
|
+
const encryption = await checkKekForEncryption(resolveEncryption({ flag: encryptFlag, serverDefault }), apiUrl);
|
|
73
73
|
if (encryption.notice)
|
|
74
74
|
out(ui.note(encryption.notice));
|
|
75
75
|
telemetry.recordEncryption({ source: encryption.source });
|
|
@@ -102,7 +102,7 @@ export const uploadCommand = defineCommand({
|
|
|
102
102
|
out(ui.branch([
|
|
103
103
|
...ui.fields([['file', colors.highlight(resolvedFile)]]),
|
|
104
104
|
...(encryption.enabled && encryption.source === 'server'
|
|
105
|
-
? [ui.note('Encrypting uploads
|
|
105
|
+
? [ui.note('Encrypting uploads; opt out with --no-encrypt')]
|
|
106
106
|
: []),
|
|
107
107
|
]));
|
|
108
108
|
const appBinaryId = await uploadBinary({
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dcd API's public `GET /cli-config`: runtime configuration the CLI used to
|
|
3
|
+
* compile in, fetched from the API it talks to so that rotating any of it is an
|
|
4
|
+
* env change on the API rather than a CLI release.
|
|
5
|
+
*
|
|
6
|
+
* - `supabase_publishable_key` (dcd#1112): see ./supabase-key.ts.
|
|
7
|
+
* - `binary_kek_public_key` (dcd#1138): the newest KEK public key the API
|
|
8
|
+
* publishes for client-side envelope encryption, with the version the API
|
|
9
|
+
* unwraps it with. Absent when the API publishes none.
|
|
10
|
+
*
|
|
11
|
+
* Fetched once per process per API URL and kept in memory only. It is
|
|
12
|
+
* deliberately not cached on disk: a stale Supabase key makes GoTrue answer a
|
|
13
|
+
* refresh with a 4xx, which `resolveAuth` treats as a dead session and deletes
|
|
14
|
+
* the user's login, and a stale KEK would wrap uploads the API can no longer
|
|
15
|
+
* unwrap. A failed fetch is not cached, so the next call retries.
|
|
16
|
+
*/
|
|
17
|
+
export interface CliConfig {
|
|
18
|
+
supabase_url?: string;
|
|
19
|
+
supabase_publishable_key?: string;
|
|
20
|
+
binary_kek_public_key?: {
|
|
21
|
+
version: number;
|
|
22
|
+
key: string;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** `apiUrl` without trailing slashes, so `…/` and `…` share one cache entry. */
|
|
26
|
+
export declare function cliConfigUrl(apiUrl: string): string;
|
|
27
|
+
/** The API's CLI config. A failed fetch is not cached, so the next call retries. */
|
|
28
|
+
export declare function getCliConfig(apiUrl: string): Promise<CliConfig>;
|
|
29
|
+
/** Test seam: forget fetched configs. */
|
|
30
|
+
export declare function resetCliConfigCache(): void;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dcd API's public `GET /cli-config`: runtime configuration the CLI used to
|
|
3
|
+
* compile in, fetched from the API it talks to so that rotating any of it is an
|
|
4
|
+
* env change on the API rather than a CLI release.
|
|
5
|
+
*
|
|
6
|
+
* - `supabase_publishable_key` (dcd#1112): see ./supabase-key.ts.
|
|
7
|
+
* - `binary_kek_public_key` (dcd#1138): the newest KEK public key the API
|
|
8
|
+
* publishes for client-side envelope encryption, with the version the API
|
|
9
|
+
* unwraps it with. Absent when the API publishes none.
|
|
10
|
+
*
|
|
11
|
+
* Fetched once per process per API URL and kept in memory only. It is
|
|
12
|
+
* deliberately not cached on disk: a stale Supabase key makes GoTrue answer a
|
|
13
|
+
* refresh with a 4xx, which `resolveAuth` treats as a dead session and deletes
|
|
14
|
+
* the user's login, and a stale KEK would wrap uploads the API can no longer
|
|
15
|
+
* unwrap. A failed fetch is not cached, so the next call retries.
|
|
16
|
+
*/
|
|
17
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
18
|
+
const cache = new Map();
|
|
19
|
+
/** `apiUrl` without trailing slashes, so `…/` and `…` share one cache entry. */
|
|
20
|
+
export function cliConfigUrl(apiUrl) {
|
|
21
|
+
return `${apiUrl.replace(/\/+$/, '')}/cli-config`;
|
|
22
|
+
}
|
|
23
|
+
async function fetchCliConfig(apiUrl) {
|
|
24
|
+
const url = cliConfigUrl(apiUrl);
|
|
25
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
throw new Error(`Failed to fetch CLI config from ${url}: HTTP ${res.status}`);
|
|
28
|
+
}
|
|
29
|
+
const body = await res.json();
|
|
30
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
31
|
+
throw new Error(`CLI config from ${url} is not a JSON object`);
|
|
32
|
+
}
|
|
33
|
+
return body;
|
|
34
|
+
}
|
|
35
|
+
/** The API's CLI config. A failed fetch is not cached, so the next call retries. */
|
|
36
|
+
export function getCliConfig(apiUrl) {
|
|
37
|
+
const key = cliConfigUrl(apiUrl);
|
|
38
|
+
let pending = cache.get(key);
|
|
39
|
+
if (!pending) {
|
|
40
|
+
pending = fetchCliConfig(apiUrl);
|
|
41
|
+
cache.set(key, pending);
|
|
42
|
+
pending.catch(() => cache.delete(key));
|
|
43
|
+
}
|
|
44
|
+
return pending;
|
|
45
|
+
}
|
|
46
|
+
/** Test seam: forget fetched configs. */
|
|
47
|
+
export function resetCliConfigCache() {
|
|
48
|
+
cache.clear();
|
|
49
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Per-env devicecloud.dev coordinates — single source of truth for the URLs
|
|
3
|
-
*
|
|
3
|
+
* the CLI uses.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* There is deliberately no Supabase key and no KEK public key here. A
|
|
6
|
+
* compiled-in key can never be rotated without breaking every pinned CLI
|
|
7
|
+
* version, so both are fetched from the API at runtime instead: see
|
|
8
|
+
* ./cli-config.ts (`getSupabaseKey` in ./supabase-key.ts for dcd#1112, and
|
|
9
|
+
* `resolveKekPublicKey` in ../utils/envelope.ts for dcd#1138).
|
|
10
10
|
*/
|
|
11
11
|
export type DcdEnvName = 'dev' | 'prod';
|
|
12
12
|
export interface DcdEnvironment {
|
|
@@ -15,40 +15,13 @@ export interface DcdEnvironment {
|
|
|
15
15
|
supabase: {
|
|
16
16
|
url: string;
|
|
17
17
|
projectRef: string;
|
|
18
|
-
anonKey: string;
|
|
19
18
|
};
|
|
20
|
-
/**
|
|
21
|
-
* Pinned KEK public key for client-side binary envelope encryption (dcd#1138).
|
|
22
|
-
* `key` is base64 of the raw 32-byte X25519 public key; `version` selects which
|
|
23
|
-
* KEK the platform API unwraps with. **Public — safe to embed** (like the anon
|
|
24
|
-
* key above): it can only *encrypt*; the private half lives solely on the API.
|
|
25
|
-
*
|
|
26
|
-
* Left `null` until the KEK is generated and provisioned. Generate a keypair
|
|
27
|
-
* with, e.g.:
|
|
28
|
-
* 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"))'
|
|
29
|
-
* Pin `public` here; set `private` as `BINARY_KEK_PRIVATE_KEYS={"<version>":"<private base64>"}`
|
|
30
|
-
* on the API service and keep one offline escrow copy. Until then, encryption
|
|
31
|
-
* can be exercised via the `DCD_BINARY_KEK_PUBLIC` env override (see envelope.ts).
|
|
32
|
-
*/
|
|
33
|
-
kekPublicKey: {
|
|
34
|
-
version: number;
|
|
35
|
-
key: string;
|
|
36
|
-
} | null;
|
|
37
19
|
}
|
|
38
20
|
export declare const ENVIRONMENTS: Record<DcdEnvName, DcdEnvironment>;
|
|
39
21
|
/** Exact-match lookup of a known environment by API URL (trailing slashes ignored). */
|
|
40
22
|
export declare function findEnvByApiUrl(apiUrl: string): DcdEnvironment | undefined;
|
|
41
23
|
/** Map a caller-supplied API URL to one of the known environments. */
|
|
42
24
|
export declare function inferEnvFromApiUrl(apiUrl: string): DcdEnvName;
|
|
43
|
-
/**
|
|
44
|
-
* The environment whose pinned KEK may encrypt uploads to `apiUrl` (dcd#1138),
|
|
45
|
-
* or undefined when the URL isn't a known one. Stricter than
|
|
46
|
-
* {@link inferEnvFromApiUrl}, which falls back to prod for any URL: that is
|
|
47
|
-
* harmless for picking a console link, but an upload encrypted for a KEK its
|
|
48
|
-
* API can't unwrap is unusable. Known means the host of a pinned `apiUrl`, or
|
|
49
|
-
* `localhost`, which runs against dev.
|
|
50
|
-
*/
|
|
51
|
-
export declare function kekEnvForApiUrl(apiUrl: string): DcdEnvName | undefined;
|
|
52
25
|
/**
|
|
53
26
|
* Frontend URL to open for a given API URL. Same as
|
|
54
27
|
* `ENVIRONMENTS[inferEnvFromApiUrl(apiUrl)].frontendUrl`, except that when the
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Per-env devicecloud.dev coordinates — single source of truth for the URLs
|
|
3
|
-
*
|
|
3
|
+
* the CLI uses.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* There is deliberately no Supabase key and no KEK public key here. A
|
|
6
|
+
* compiled-in key can never be rotated without breaking every pinned CLI
|
|
7
|
+
* version, so both are fetched from the API at runtime instead: see
|
|
8
|
+
* ./cli-config.ts (`getSupabaseKey` in ./supabase-key.ts for dcd#1112, and
|
|
9
|
+
* `resolveKekPublicKey` in ../utils/envelope.ts for dcd#1138).
|
|
10
10
|
*/
|
|
11
11
|
export const ENVIRONMENTS = {
|
|
12
12
|
prod: {
|
|
@@ -15,9 +15,7 @@ export const ENVIRONMENTS = {
|
|
|
15
15
|
supabase: {
|
|
16
16
|
url: 'https://cloud.devicecloud.dev',
|
|
17
17
|
projectRef: 'pgydnphbimetinsgfkbo',
|
|
18
|
-
anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBneWRucGhiaW1ldGluc2dma2JvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDc1OTQzNDYsImV4cCI6MjAyMzE3MDM0Nn0.hAYOMFxxwX1exkQkY9xyQJGC_GhGnyogkj2N-kBkMI8',
|
|
19
18
|
},
|
|
20
|
-
kekPublicKey: { version: 1, key: 'wtfyWEwK7nJzwI4PD+9RAW8jxIR1u8kMQq2IhsrVnH4=' },
|
|
21
19
|
},
|
|
22
20
|
dev: {
|
|
23
21
|
apiUrl: 'https://api.dev.devicecloud.dev',
|
|
@@ -25,9 +23,7 @@ export const ENVIRONMENTS = {
|
|
|
25
23
|
supabase: {
|
|
26
24
|
url: 'https://lbmsowehtjwnqlurpemb.supabase.co',
|
|
27
25
|
projectRef: 'lbmsowehtjwnqlurpemb',
|
|
28
|
-
anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxibXNvd2VodGp3bnFsdXJwZW1iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDkyMTg0ODcsImV4cCI6MjAyNDc5NDQ4N30.zeLTMAuZ_WwYvGdeP0kdvL_Zrs-RQee5APPyxmWq7qQ',
|
|
29
26
|
},
|
|
30
|
-
kekPublicKey: { version: 1, key: 'RgcToF/OJpcQI9koYvSvtj/WLaebfcN4v5GJoqtr/00=' },
|
|
31
27
|
},
|
|
32
28
|
};
|
|
33
29
|
/** Exact-match lookup of a known environment by API URL (trailing slashes ignored). */
|
|
@@ -42,27 +38,6 @@ export function inferEnvFromApiUrl(apiUrl) {
|
|
|
42
38
|
return 'dev';
|
|
43
39
|
return 'prod';
|
|
44
40
|
}
|
|
45
|
-
/**
|
|
46
|
-
* The environment whose pinned KEK may encrypt uploads to `apiUrl` (dcd#1138),
|
|
47
|
-
* or undefined when the URL isn't a known one. Stricter than
|
|
48
|
-
* {@link inferEnvFromApiUrl}, which falls back to prod for any URL: that is
|
|
49
|
-
* harmless for picking a console link, but an upload encrypted for a KEK its
|
|
50
|
-
* API can't unwrap is unusable. Known means the host of a pinned `apiUrl`, or
|
|
51
|
-
* `localhost`, which runs against dev.
|
|
52
|
-
*/
|
|
53
|
-
export function kekEnvForApiUrl(apiUrl) {
|
|
54
|
-
let host;
|
|
55
|
-
try {
|
|
56
|
-
host = new URL(apiUrl).hostname;
|
|
57
|
-
}
|
|
58
|
-
catch {
|
|
59
|
-
return undefined;
|
|
60
|
-
}
|
|
61
|
-
if (host === 'localhost')
|
|
62
|
-
return 'dev';
|
|
63
|
-
const names = Object.keys(ENVIRONMENTS);
|
|
64
|
-
return names.find((name) => new URL(ENVIRONMENTS[name].apiUrl).hostname === host);
|
|
65
|
-
}
|
|
66
41
|
/**
|
|
67
42
|
* Frontend URL to open for a given API URL. Same as
|
|
68
43
|
* `ENVIRONMENTS[inferEnvFromApiUrl(apiUrl)].frontendUrl`, except that when the
|
|
@@ -21,7 +21,6 @@ export declare const binaryFlags: {
|
|
|
21
21
|
};
|
|
22
22
|
readonly encrypt: {
|
|
23
23
|
readonly type: "boolean";
|
|
24
|
-
readonly
|
|
25
|
-
readonly 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.";
|
|
24
|
+
readonly description: "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.";
|
|
26
25
|
};
|
|
27
26
|
};
|
|
@@ -21,9 +21,6 @@ export const binaryFlags = {
|
|
|
21
21
|
},
|
|
22
22
|
encrypt: {
|
|
23
23
|
type: 'boolean',
|
|
24
|
-
|
|
25
|
-
// flag still parses, --no-encrypt included.
|
|
26
|
-
hidden: true,
|
|
27
|
-
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.',
|
|
24
|
+
description: '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.',
|
|
28
25
|
},
|
|
29
26
|
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type DcdEnvName } from './environments.js';
|
|
2
|
+
/** The publishable key for `env`. A failed fetch is not cached, so the next call retries. */
|
|
3
|
+
export declare function getSupabaseKey(env: DcdEnvName): Promise<string>;
|
|
4
|
+
/**
|
|
5
|
+
* Headers that authenticate a raw Storage/Auth request with `key` alone. A
|
|
6
|
+
* legacy anon key is a JWT and also goes in `Authorization`; an
|
|
7
|
+
* `sb_publishable_` key is not a JWT and must only be sent as `apikey`.
|
|
8
|
+
*/
|
|
9
|
+
export declare function supabaseKeyHeaders(key: string): Record<string, string>;
|
|
10
|
+
/** Test seam: forget fetched keys. */
|
|
11
|
+
export declare function resetSupabaseKeyCache(): void;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Supabase publishable key, fetched from the dcd API at runtime (dcd#1112).
|
|
3
|
+
*
|
|
4
|
+
* Releases up to this one compiled the anon key in, so the key could never be
|
|
5
|
+
* rotated without breaking every CLI version pinned in customer CI. The API now
|
|
6
|
+
* serves it from the public `GET /cli-config` (see ./cli-config.ts for the
|
|
7
|
+
* fetch and its in-memory cache), and rotating it is an env change on the API.
|
|
8
|
+
*
|
|
9
|
+
* Only the key comes from the API. The Supabase URLs stay pinned in
|
|
10
|
+
* `ENVIRONMENTS`: prod goes through the `cloud.devicecloud.dev` custom domain,
|
|
11
|
+
* which customer firewalls may allow-list, not the API's raw `supabase.co` URL.
|
|
12
|
+
*/
|
|
13
|
+
import { cliConfigUrl, getCliConfig, resetCliConfigCache } from './cli-config.js';
|
|
14
|
+
import { ENVIRONMENTS } from './environments.js';
|
|
15
|
+
/** The publishable key for `env`. A failed fetch is not cached, so the next call retries. */
|
|
16
|
+
export async function getSupabaseKey(env) {
|
|
17
|
+
const apiUrl = ENVIRONMENTS[env].apiUrl;
|
|
18
|
+
const key = (await getCliConfig(apiUrl)).supabase_publishable_key;
|
|
19
|
+
if (typeof key !== 'string' || key.length === 0) {
|
|
20
|
+
throw new Error(`CLI config from ${cliConfigUrl(apiUrl)} has no supabase_publishable_key`);
|
|
21
|
+
}
|
|
22
|
+
return key;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Headers that authenticate a raw Storage/Auth request with `key` alone. A
|
|
26
|
+
* legacy anon key is a JWT and also goes in `Authorization`; an
|
|
27
|
+
* `sb_publishable_` key is not a JWT and must only be sent as `apikey`.
|
|
28
|
+
*/
|
|
29
|
+
export function supabaseKeyHeaders(key) {
|
|
30
|
+
return key.startsWith('sb_') ? { apikey: key } : { apikey: key, authorization: `Bearer ${key}` };
|
|
31
|
+
}
|
|
32
|
+
/** Test seam: forget fetched keys. */
|
|
33
|
+
export function resetSupabaseKeyCache() {
|
|
34
|
+
resetCliConfigCache();
|
|
35
|
+
}
|
package/dist/constants.d.ts
CHANGED
|
@@ -228,8 +228,7 @@ export declare const flags: {
|
|
|
228
228
|
};
|
|
229
229
|
readonly encrypt: {
|
|
230
230
|
readonly type: "boolean";
|
|
231
|
-
readonly
|
|
232
|
-
readonly 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.";
|
|
231
|
+
readonly description: "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.";
|
|
233
232
|
};
|
|
234
233
|
readonly 'api-key': {
|
|
235
234
|
readonly type: "string";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DcdEnvName } from '../config/environments.js';
|
|
1
2
|
import type { StoredSession } from '../utils/config-store.js';
|
|
2
3
|
/**
|
|
3
4
|
* Thrown when a session refresh fails. `definitive` distinguishes "Supabase
|
|
@@ -18,6 +19,6 @@ export interface RefreshedSession {
|
|
|
18
19
|
user_id: string;
|
|
19
20
|
}
|
|
20
21
|
export declare const CliAuthGateway: {
|
|
21
|
-
refresh(supabaseUrl: string,
|
|
22
|
-
signOut(supabaseUrl: string,
|
|
22
|
+
refresh(supabaseUrl: string, env: DcdEnvName, session: StoredSession): Promise<RefreshedSession>;
|
|
23
|
+
signOut(supabaseUrl: string, env: DcdEnvName, session: StoredSession): Promise<void>;
|
|
23
24
|
};
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* login command's PKCE rendezvous flow (see src/commands/login.ts).
|
|
7
7
|
*/
|
|
8
8
|
import { createClient, isAuthApiError } from '@supabase/supabase-js';
|
|
9
|
+
import { getSupabaseKey } from '../config/supabase-key.js';
|
|
9
10
|
/**
|
|
10
11
|
* Thrown when a session refresh fails. `definitive` distinguishes "Supabase
|
|
11
12
|
* rejected this refresh token" (revoked, already used, malformed — re-login
|
|
@@ -20,14 +21,23 @@ export class SessionRefreshError extends Error {
|
|
|
20
21
|
this.name = 'SessionRefreshError';
|
|
21
22
|
}
|
|
22
23
|
}
|
|
23
|
-
function client(supabaseUrl,
|
|
24
|
-
return createClient(supabaseUrl,
|
|
24
|
+
function client(supabaseUrl, supabaseKey) {
|
|
25
|
+
return createClient(supabaseUrl, supabaseKey, {
|
|
25
26
|
auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
|
|
26
27
|
});
|
|
27
28
|
}
|
|
28
29
|
export const CliAuthGateway = {
|
|
29
|
-
async refresh(supabaseUrl,
|
|
30
|
-
|
|
30
|
+
async refresh(supabaseUrl, env, session) {
|
|
31
|
+
let supabaseKey;
|
|
32
|
+
try {
|
|
33
|
+
supabaseKey = await getSupabaseKey(env);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
// Never definitive: the refresh token was not even tried, so the stored
|
|
37
|
+
// session must survive for the next attempt.
|
|
38
|
+
throw new SessionRefreshError(`Failed to refresh session: ${error.message}`, false);
|
|
39
|
+
}
|
|
40
|
+
const sb = client(supabaseUrl, supabaseKey);
|
|
31
41
|
const { data, error } = await sb.auth.refreshSession({
|
|
32
42
|
refresh_token: session.refresh_token,
|
|
33
43
|
});
|
|
@@ -53,14 +63,14 @@ export const CliAuthGateway = {
|
|
|
53
63
|
user_id: u.id,
|
|
54
64
|
};
|
|
55
65
|
},
|
|
56
|
-
async signOut(supabaseUrl,
|
|
66
|
+
async signOut(supabaseUrl, env, session) {
|
|
57
67
|
// `scope: 'local'` clears this client's SDK state only — no server call,
|
|
58
68
|
// no revocation. supabase-js defaults to `scope: 'global'`, which revokes
|
|
59
69
|
// EVERY session for the user (browser, mobile, other CLIs), so
|
|
60
70
|
// `dcd logout` would kick the user out of the web app. We don't want
|
|
61
71
|
// that — logging out locally should be local.
|
|
62
|
-
const sb = client(supabaseUrl, supabaseAnonKey);
|
|
63
72
|
try {
|
|
73
|
+
const sb = client(supabaseUrl, await getSupabaseKey(env));
|
|
64
74
|
await sb.auth.setSession({
|
|
65
75
|
access_token: session.access_token,
|
|
66
76
|
refresh_token: session.refresh_token,
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { createClient, REALTIME_SUBSCRIBE_STATES, } from '@supabase/supabase-js';
|
|
17
17
|
import { ENVIRONMENTS } from '../config/environments.js';
|
|
18
|
+
import { getSupabaseKey } from '../config/supabase-key.js';
|
|
18
19
|
export class RealtimeResultsGateway {
|
|
19
20
|
/**
|
|
20
21
|
* Open a realtime subscription to `results` changes for the given upload.
|
|
@@ -34,17 +35,28 @@ export class RealtimeResultsGateway {
|
|
|
34
35
|
onConnectionChange?.(next);
|
|
35
36
|
};
|
|
36
37
|
let client;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
let channel;
|
|
39
|
+
let closed = false;
|
|
40
|
+
// The latest JWT, so a refresh that lands while the key is still being
|
|
41
|
+
// fetched is the one the socket joins with.
|
|
42
|
+
let token = accessToken;
|
|
43
|
+
// The publishable key comes from the API (dcd#1112), so the socket opens
|
|
44
|
+
// asynchronously. `subscribe` stays synchronous for the caller: until it
|
|
45
|
+
// connects, `isConnected` is false and the backstop poll covers the gap.
|
|
46
|
+
const ready = getSupabaseKey(env)
|
|
47
|
+
.then((supabaseKey) => {
|
|
48
|
+
if (closed)
|
|
49
|
+
return;
|
|
50
|
+
const sb = createClient(ENVIRONMENTS[env].supabase.url, supabaseKey, {
|
|
40
51
|
// Match SupabaseClientGateway / the frontend; no session persistence —
|
|
41
52
|
// we set the token explicitly below.
|
|
42
53
|
auth: { autoRefreshToken: false, persistSession: false },
|
|
43
54
|
realtime: { params: { eventsPerSecond: 10 } },
|
|
44
55
|
});
|
|
56
|
+
client = sb;
|
|
45
57
|
// Attach the user's JWT to the socket so RLS is enforced on the channel.
|
|
46
|
-
|
|
47
|
-
|
|
58
|
+
sb.realtime.setAuth(token);
|
|
59
|
+
channel = sb
|
|
48
60
|
.channel(`results-cli-${uploadId}`)
|
|
49
61
|
.on(
|
|
50
62
|
// The typings don't cover the postgres_changes overload cleanly.
|
|
@@ -73,29 +85,8 @@ export class RealtimeResultsGateway {
|
|
|
73
85
|
setConnected(false);
|
|
74
86
|
}
|
|
75
87
|
});
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
isConnected: () => connected,
|
|
79
|
-
async unsubscribe() {
|
|
80
|
-
setConnected(false);
|
|
81
|
-
try {
|
|
82
|
-
await activeClient.removeChannel(channel);
|
|
83
|
-
await activeClient.realtime.disconnect();
|
|
84
|
-
}
|
|
85
|
-
catch {
|
|
86
|
-
/* best effort — process is exiting anyway */
|
|
87
|
-
}
|
|
88
|
-
},
|
|
89
|
-
updateAccessToken(nextToken) {
|
|
90
|
-
// setAuth updates the join payload and pushes the token to joined
|
|
91
|
-
// channels. A failure only costs realtime; the backstop poll stays.
|
|
92
|
-
activeClient.realtime.setAuth(nextToken).catch((error) => {
|
|
93
|
-
dbg(`failed to update the socket's token: ${error instanceof Error ? error.message : String(error)}`);
|
|
94
|
-
});
|
|
95
|
-
},
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
catch (error) {
|
|
88
|
+
})
|
|
89
|
+
.catch((error) => {
|
|
99
90
|
dbg(`failed to subscribe: ${error instanceof Error ? error.message : String(error)}`);
|
|
100
91
|
// Best-effort cleanup of a half-built client, then degrade to polling.
|
|
101
92
|
try {
|
|
@@ -104,11 +95,34 @@ export class RealtimeResultsGateway {
|
|
|
104
95
|
catch {
|
|
105
96
|
/* ignore */
|
|
106
97
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
98
|
+
client = undefined;
|
|
99
|
+
channel = undefined;
|
|
100
|
+
});
|
|
101
|
+
return {
|
|
102
|
+
isConnected: () => connected,
|
|
103
|
+
async unsubscribe() {
|
|
104
|
+
closed = true;
|
|
105
|
+
setConnected(false);
|
|
106
|
+
await ready;
|
|
107
|
+
if (!client)
|
|
108
|
+
return;
|
|
109
|
+
try {
|
|
110
|
+
if (channel)
|
|
111
|
+
await client.removeChannel(channel);
|
|
112
|
+
await client.realtime.disconnect();
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
/* best effort — process is exiting anyway */
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
updateAccessToken(nextToken) {
|
|
119
|
+
token = nextToken;
|
|
120
|
+
// setAuth updates the join payload and pushes the token to joined
|
|
121
|
+
// channels. A failure only costs realtime; the backstop poll stays.
|
|
122
|
+
client?.realtime.setAuth(nextToken).catch((error) => {
|
|
123
|
+
dbg(`failed to update the socket's token: ${error instanceof Error ? error.message : String(error)}`);
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
};
|
|
113
127
|
}
|
|
114
128
|
}
|
|
@@ -9,7 +9,7 @@ export interface ResumableUploadSource {
|
|
|
9
9
|
export declare class SupabaseGateway {
|
|
10
10
|
/**
|
|
11
11
|
* Upload to Supabase using resumable uploads (TUS protocol)
|
|
12
|
-
* Uploads to staging location (uploads/{id}/) using
|
|
12
|
+
* Uploads to staging location (uploads/{id}/) using the publishable key
|
|
13
13
|
* File is later moved to final location by API after finalization
|
|
14
14
|
* @param env - Environment (dev or prod)
|
|
15
15
|
* @param path - Staging storage path (uploads/{id}/file.ext)
|
|
@@ -2,10 +2,11 @@ import { createReadStream } from 'node:fs';
|
|
|
2
2
|
import { createClient } from '@supabase/supabase-js';
|
|
3
3
|
import * as tus from 'tus-js-client';
|
|
4
4
|
import { ENVIRONMENTS } from '../config/environments.js';
|
|
5
|
+
import { getSupabaseKey, supabaseKeyHeaders } from '../config/supabase-key.js';
|
|
5
6
|
export class SupabaseGateway {
|
|
6
7
|
/**
|
|
7
8
|
* Upload to Supabase using resumable uploads (TUS protocol)
|
|
8
|
-
* Uploads to staging location (uploads/{id}/) using
|
|
9
|
+
* Uploads to staging location (uploads/{id}/) using the publishable key
|
|
9
10
|
* File is later moved to final location by API after finalization
|
|
10
11
|
* @param env - Environment (dev or prod)
|
|
11
12
|
* @param path - Staging storage path (uploads/{id}/file.ext)
|
|
@@ -15,7 +16,8 @@ export class SupabaseGateway {
|
|
|
15
16
|
* @returns Promise that resolves when upload completes
|
|
16
17
|
*/
|
|
17
18
|
static async uploadResumable(env, path, source, debug = false, onProgress) {
|
|
18
|
-
const {
|
|
19
|
+
const { projectRef } = ENVIRONMENTS[env].supabase;
|
|
20
|
+
const supabaseKey = await getSupabaseKey(env);
|
|
19
21
|
const storageUrl = `https://${projectRef}.storage.supabase.co`;
|
|
20
22
|
if (debug) {
|
|
21
23
|
console.log(`[DEBUG] Resumable upload starting...`);
|
|
@@ -35,9 +37,9 @@ export class SupabaseGateway {
|
|
|
35
37
|
// Retry configuration - will retry 5 times with increasing delays
|
|
36
38
|
retryDelays: [0, 3000, 5000, 10_000, 20_000],
|
|
37
39
|
// Authentication and headers
|
|
38
|
-
//
|
|
40
|
+
// Publishable key for staging uploads to uploads/* folder
|
|
39
41
|
headers: {
|
|
40
|
-
|
|
42
|
+
...supabaseKeyHeaders(supabaseKey),
|
|
41
43
|
'x-upsert': 'true', // Allow overwriting existing files
|
|
42
44
|
},
|
|
43
45
|
// Upload metadata
|
|
@@ -93,7 +95,7 @@ export class SupabaseGateway {
|
|
|
93
95
|
});
|
|
94
96
|
}
|
|
95
97
|
static async uploadToSignedUrl(env, path, token, file, debug = false) {
|
|
96
|
-
const { url: SUPABASE_URL
|
|
98
|
+
const { url: SUPABASE_URL } = ENVIRONMENTS[env].supabase;
|
|
97
99
|
if (debug) {
|
|
98
100
|
console.log(`[DEBUG] Supabase upload starting...`);
|
|
99
101
|
console.log(`[DEBUG] Supabase URL: ${SUPABASE_URL}`);
|
|
@@ -102,7 +104,7 @@ export class SupabaseGateway {
|
|
|
102
104
|
console.log(`[DEBUG] File type: ${file.type}`);
|
|
103
105
|
}
|
|
104
106
|
try {
|
|
105
|
-
const supabase = createClient(SUPABASE_URL,
|
|
107
|
+
const supabase = createClient(SUPABASE_URL, await getSupabaseKey(env));
|
|
106
108
|
const uploadToUrl = await supabase.storage
|
|
107
109
|
.from('organizations')
|
|
108
110
|
.uploadToSignedUrl(path, token, file);
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,6 @@ import { uploadCommand } from './commands/upload.js';
|
|
|
14
14
|
import { whoamiCommand } from './commands/whoami.js';
|
|
15
15
|
import { telemetry } from './services/telemetry.service.js';
|
|
16
16
|
import { CliError, getCliVersion, logger, wantsJson } from './utils/cli.js';
|
|
17
|
-
import { withoutHiddenArgs } from './utils/help.js';
|
|
18
17
|
// @clack/prompts ships the US spelling ("Canceled") for its built-in
|
|
19
18
|
// spinner/prompt cancellation message; align it with the British spelling
|
|
20
19
|
// ("Cancelled") used everywhere else in the CLI.
|
|
@@ -66,17 +65,12 @@ async function resolveSubCommand(cmd, rawArgs, parent) {
|
|
|
66
65
|
}
|
|
67
66
|
return [cmd, parent];
|
|
68
67
|
}
|
|
69
|
-
// Usage for the (sub)command named in rawArgs, without the args marked hidden.
|
|
70
|
-
async function showHelp(rawArgs) {
|
|
71
|
-
const [cmd, parent] = await resolveSubCommand(main, rawArgs);
|
|
72
|
-
await showUsage(await withoutHiddenArgs(cmd), parent);
|
|
73
|
-
}
|
|
74
68
|
async function run() {
|
|
75
69
|
const rawArgs = process.argv.slice(2);
|
|
76
70
|
telemetry.recordCommandStart();
|
|
77
71
|
try {
|
|
78
72
|
if (rawArgs.includes('--help') || rawArgs.includes('-h')) {
|
|
79
|
-
await
|
|
73
|
+
await showUsage(...(await resolveSubCommand(main, rawArgs)));
|
|
80
74
|
}
|
|
81
75
|
else if (rawArgs.length === 1 && rawArgs[0] === '--version') {
|
|
82
76
|
logger.log(getCliVersion());
|
|
@@ -94,7 +88,7 @@ async function run() {
|
|
|
94
88
|
// citty throws CLIError (by name — the class isn't exported) for usage
|
|
95
89
|
// problems like unknown commands or missing required args.
|
|
96
90
|
if (!json && error instanceof Error && error.name === 'CLIError') {
|
|
97
|
-
await
|
|
91
|
+
await showUsage(...(await resolveSubCommand(main, rawArgs)));
|
|
98
92
|
}
|
|
99
93
|
const exitCode = error instanceof CliError ? error.exitCode : 1;
|
|
100
94
|
// logger.error prints the message, records failure telemetry, flushes
|
|
@@ -84,7 +84,7 @@ export function registerRunCloudTest(server) {
|
|
|
84
84
|
encrypt: z
|
|
85
85
|
.boolean()
|
|
86
86
|
.optional()
|
|
87
|
-
.describe('true encrypts the binary, flow zip and env vars client-side before upload; false never does. Omit to follow DCD_ENCRYPT, then the
|
|
87
|
+
.describe('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.'),
|
|
88
88
|
wait: z
|
|
89
89
|
.boolean()
|
|
90
90
|
.optional()
|
|
@@ -152,8 +152,8 @@ export function registerRunCloudTest(server) {
|
|
|
152
152
|
});
|
|
153
153
|
}
|
|
154
154
|
// Client-side envelope encryption (binary/flow/env), in the same order
|
|
155
|
-
// as `dcd cloud`: the encrypt input, DCD_ENCRYPT, then the
|
|
156
|
-
const encryption = checkKekForEncryption(resolveEncryption({
|
|
155
|
+
// as `dcd cloud`: the encrypt input, DCD_ENCRYPT, then the server default.
|
|
156
|
+
const encryption = await checkKekForEncryption(resolveEncryption({
|
|
157
157
|
flag: args.encrypt,
|
|
158
158
|
serverDefault: compatibilityData.encryption?.defaultOn,
|
|
159
159
|
}), apiUrl);
|
|
@@ -169,7 +169,7 @@ export function registerRunCloudTest(server) {
|
|
|
169
169
|
};
|
|
170
170
|
telemetryExtra.encrypt = encryptTelemetry;
|
|
171
171
|
if (encrypt && encryption.source === 'server') {
|
|
172
|
-
logStderr('Encrypting uploads
|
|
172
|
+
logStderr('Encrypting uploads; opt out with encrypt: false');
|
|
173
173
|
}
|
|
174
174
|
// Resolve the binary: existing id, or upload the local file.
|
|
175
175
|
let appBinaryId = args.appBinaryId;
|
package/dist/methods.js
CHANGED
|
@@ -190,15 +190,15 @@ export const uploadBinary = async (config) => {
|
|
|
190
190
|
/**
|
|
191
191
|
* Encrypt the prepared upload source in place (dcd#1138): generate a per-upload
|
|
192
192
|
* DEK, stream-encrypt `source.diskPath` into a temp ciphertext file, wrap the
|
|
193
|
-
* DEK with the
|
|
193
|
+
* DEK with the KEK public key the API publishes, and repoint `source` at the
|
|
194
194
|
* ciphertext so the SHA, dedup check, and both uploaders operate on ciphertext.
|
|
195
195
|
* Returns the envelope metadata (for `binaries.metadata.enc`) plus the temp dir
|
|
196
196
|
* to clean up. Throws if no KEK is available for the environment.
|
|
197
197
|
*/
|
|
198
198
|
async function encryptUploadSource(source, apiUrl, debug) {
|
|
199
|
-
const kek = resolveKekPublicKey(apiUrl);
|
|
199
|
+
const kek = await resolveKekPublicKey(apiUrl);
|
|
200
200
|
if (!kek) {
|
|
201
|
-
throw new Error('Encryption is on (from --encrypt, DCD_ENCRYPT or the
|
|
201
|
+
throw new Error('Encryption is on (from --encrypt, DCD_ENCRYPT or the default) but no KEK public key is configured for this API. ' +
|
|
202
202
|
'Set DCD_BINARY_KEK_PUBLIC=<version>:<base64>, or pass --no-encrypt to upload without encryption.');
|
|
203
203
|
}
|
|
204
204
|
const dek = generateDek();
|
|
@@ -78,9 +78,9 @@ export class TestSubmissionService {
|
|
|
78
78
|
if (!apiUrl) {
|
|
79
79
|
throw new Error('Encryption requires apiUrl to resolve the KEK public key');
|
|
80
80
|
}
|
|
81
|
-
const kek = resolveKekPublicKey(apiUrl);
|
|
81
|
+
const kek = await resolveKekPublicKey(apiUrl);
|
|
82
82
|
if (!kek) {
|
|
83
|
-
throw new Error('Encryption is on (from --encrypt, DCD_ENCRYPT or the
|
|
83
|
+
throw new Error('Encryption is on (from --encrypt, DCD_ENCRYPT or the default) but no KEK public key is configured for this API. ' +
|
|
84
84
|
'Set DCD_BINARY_KEK_PUBLIC=<version>:<base64>, or pass --no-encrypt to upload without encryption.');
|
|
85
85
|
}
|
|
86
86
|
const flow = encryptFlowBuffer(plaintextZip, kek);
|
package/dist/utils/auth.js
CHANGED
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
* poll), which re-checks with isAuthExpiring / refreshAuth below.
|
|
10
10
|
*/
|
|
11
11
|
import { closeSync, openSync, rmSync, statSync } from 'node:fs';
|
|
12
|
-
import { ENVIRONMENTS } from '../config/environments.js';
|
|
13
12
|
import { CliAuthGateway, SessionRefreshError, } from '../gateways/cli-auth-gateway.js';
|
|
14
13
|
import { telemetry } from '../services/telemetry.service.js';
|
|
15
14
|
import { CliError } from './cli.js';
|
|
@@ -144,10 +143,9 @@ async function refreshSessionWithLock(initial) {
|
|
|
144
143
|
if (session.expires_at > now + REFRESH_SKEW_SECONDS) {
|
|
145
144
|
return { config: current, session };
|
|
146
145
|
}
|
|
147
|
-
const { anonKey } = ENVIRONMENTS[current.env].supabase;
|
|
148
146
|
let refreshed;
|
|
149
147
|
try {
|
|
150
|
-
refreshed = await CliAuthGateway.refresh(current.supabase_url,
|
|
148
|
+
refreshed = await CliAuthGateway.refresh(current.supabase_url, current.env, session);
|
|
151
149
|
}
|
|
152
150
|
catch (error) {
|
|
153
151
|
if (error instanceof SessionRefreshError && error.definitive) {
|
|
@@ -12,9 +12,8 @@ export interface CompatibilityData {
|
|
|
12
12
|
/** Active CLI notices, piggybacked onto the compatibility response by the API. */
|
|
13
13
|
notices?: Notice[];
|
|
14
14
|
/**
|
|
15
|
-
* The
|
|
16
|
-
*
|
|
17
|
-
* omits it, which means off.
|
|
15
|
+
* The client-side encryption default (dcd#1138): on for every org unless the
|
|
16
|
+
* API's kill switch is set. An older API omits it, which means off.
|
|
18
17
|
*/
|
|
19
18
|
encryption?: {
|
|
20
19
|
defaultOn: boolean;
|
package/dist/utils/envelope.d.ts
CHANGED
|
@@ -29,7 +29,7 @@ export interface EncryptionDecision {
|
|
|
29
29
|
* 1. `flag`: `--encrypt` / `--no-encrypt`, or the MCP tool's `encrypt` input.
|
|
30
30
|
* 2. `DCD_ENCRYPT`, then its legacy alias `DCD_ENCRYPT_BINARIES`.
|
|
31
31
|
* 3. `serverDefault`: `encryption.defaultOn` from the compatibility data,
|
|
32
|
-
* which the API turns on for
|
|
32
|
+
* which the API turns on for every org unless its kill switch is set.
|
|
33
33
|
* 4. Off. An API too old to send `encryption` ends up here.
|
|
34
34
|
* When on, the binary, the flow zip, and the env map are each encrypted with
|
|
35
35
|
* their **own** per-upload DEK (all wrapped under the same per-env KEK).
|
|
@@ -41,26 +41,32 @@ export declare function resolveEncryption(opts: {
|
|
|
41
41
|
}): EncryptionDecision;
|
|
42
42
|
/**
|
|
43
43
|
* Check that `apiUrl` has a KEK before a run encrypts against it: either
|
|
44
|
-
* `DCD_BINARY_KEK_PUBLIC` is set or the
|
|
45
|
-
* (see
|
|
46
|
-
* while encryption from DCD_ENCRYPT or the
|
|
47
|
-
* notice, so a run against
|
|
44
|
+
* `DCD_BINARY_KEK_PUBLIC` is set or the API publishes one on `GET /cli-config`
|
|
45
|
+
* (see resolveKekPublicKey). Without one, an explicit `--encrypt` is an error,
|
|
46
|
+
* while encryption from DCD_ENCRYPT or the server default is skipped with a
|
|
47
|
+
* notice, so a run against an API that publishes no KEK uploads in plaintext
|
|
48
|
+
* as before. An API that cannot be asked at all is an error whatever decided
|
|
49
|
+
* encryption: silently falling back to plaintext would be the one outcome the
|
|
50
|
+
* org asked not to have.
|
|
48
51
|
*/
|
|
49
|
-
export declare function checkKekForEncryption(decision: EncryptionDecision, apiUrl: string): EncryptionDecision
|
|
50
|
-
/**
|
|
52
|
+
export declare function checkKekForEncryption(decision: EncryptionDecision, apiUrl: string): Promise<EncryptionDecision>;
|
|
53
|
+
/** KEK public key (raw 32-byte X25519) + the version the API unwraps it with. */
|
|
51
54
|
interface KekPublicKey {
|
|
52
55
|
version: number;
|
|
53
56
|
keyRaw: Buffer;
|
|
54
57
|
}
|
|
55
58
|
/**
|
|
56
|
-
* Resolve the KEK public key for the
|
|
59
|
+
* Resolve the KEK public key for the API behind `apiUrl`. Order:
|
|
57
60
|
* 1. `DCD_BINARY_KEK_PUBLIC` env override (`<version>:<base64>`, e.g. `1:AAAA…`)
|
|
58
|
-
* — lets the feature be exercised
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
61
|
+
* — lets the feature be exercised against an API that publishes nothing.
|
|
62
|
+
* 2. `binary_kek_public_key` from the API's own `GET /cli-config` (dcd#1138):
|
|
63
|
+
* the newest KEK the API holds the private half of. Fetched once per
|
|
64
|
+
* process (see ../config/cli-config.ts), so a KEK rotation is an env
|
|
65
|
+
* change on the API and never a CLI release.
|
|
66
|
+
* Returns null when the API publishes no key (encryption cannot proceed).
|
|
67
|
+
* Throws when the API cannot be asked, or publishes something malformed.
|
|
62
68
|
*/
|
|
63
|
-
export declare function resolveKekPublicKey(apiUrl: string): KekPublicKey | null
|
|
69
|
+
export declare function resolveKekPublicKey(apiUrl: string): Promise<KekPublicKey | null>;
|
|
64
70
|
/**
|
|
65
71
|
* Wrap a DEK for the given KEK public key (X25519 sealed box). Produces base64 of
|
|
66
72
|
* `ephPub(32) || iv(12) || ciphertext(32) || tag(16)`.
|
package/dist/utils/envelope.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createCipheriv, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes, } from 'node:crypto';
|
|
2
2
|
import { open, stat } from 'node:fs/promises';
|
|
3
|
-
import {
|
|
3
|
+
import { cliConfigUrl, getCliConfig } from '../config/cli-config.js';
|
|
4
4
|
/**
|
|
5
5
|
* Client-side envelope encryption of app binaries before upload (dcd#1138).
|
|
6
6
|
*
|
|
@@ -10,9 +10,9 @@ import { ENVIRONMENTS, kekEnvForApiUrl } from '../config/environments.js';
|
|
|
10
10
|
* specified in `dcd/docs/binary-envelope-encryption.md`.
|
|
11
11
|
*
|
|
12
12
|
* Scheme: a per-upload random 256-bit DEK encrypts the binary (chunked
|
|
13
|
-
* AES-256-GCM); the DEK is wrapped with the
|
|
14
|
-
*
|
|
15
|
-
* every storage/transport tier sees ciphertext only.
|
|
13
|
+
* AES-256-GCM); the DEK is wrapped with the X25519 KEK public key the API
|
|
14
|
+
* publishes on `GET /cli-config` (sealed box). Only the platform API holds the
|
|
15
|
+
* KEK private half, so every storage/transport tier sees ciphertext only.
|
|
16
16
|
*/
|
|
17
17
|
const MAGIC = Buffer.from('DCDE', 'ascii');
|
|
18
18
|
const CONTAINER_VERSION = 1;
|
|
@@ -45,7 +45,7 @@ function parseEncryptEnv(value) {
|
|
|
45
45
|
* 1. `flag`: `--encrypt` / `--no-encrypt`, or the MCP tool's `encrypt` input.
|
|
46
46
|
* 2. `DCD_ENCRYPT`, then its legacy alias `DCD_ENCRYPT_BINARIES`.
|
|
47
47
|
* 3. `serverDefault`: `encryption.defaultOn` from the compatibility data,
|
|
48
|
-
* which the API turns on for
|
|
48
|
+
* which the API turns on for every org unless its kill switch is set.
|
|
49
49
|
* 4. Off. An API too old to send `encryption` ends up here.
|
|
50
50
|
* When on, the binary, the flow zip, and the env map are each encrypted with
|
|
51
51
|
* their **own** per-upload DEK (all wrapped under the same per-env KEK).
|
|
@@ -63,21 +63,24 @@ export function resolveEncryption(opts) {
|
|
|
63
63
|
}
|
|
64
64
|
/**
|
|
65
65
|
* Check that `apiUrl` has a KEK before a run encrypts against it: either
|
|
66
|
-
* `DCD_BINARY_KEK_PUBLIC` is set or the
|
|
67
|
-
* (see
|
|
68
|
-
* while encryption from DCD_ENCRYPT or the
|
|
69
|
-
* notice, so a run against
|
|
66
|
+
* `DCD_BINARY_KEK_PUBLIC` is set or the API publishes one on `GET /cli-config`
|
|
67
|
+
* (see resolveKekPublicKey). Without one, an explicit `--encrypt` is an error,
|
|
68
|
+
* while encryption from DCD_ENCRYPT or the server default is skipped with a
|
|
69
|
+
* notice, so a run against an API that publishes no KEK uploads in plaintext
|
|
70
|
+
* as before. An API that cannot be asked at all is an error whatever decided
|
|
71
|
+
* encryption: silently falling back to plaintext would be the one outcome the
|
|
72
|
+
* org asked not to have.
|
|
70
73
|
*/
|
|
71
|
-
export function checkKekForEncryption(decision, apiUrl) {
|
|
72
|
-
if (!decision.enabled || resolveKekPublicKey(apiUrl))
|
|
74
|
+
export async function checkKekForEncryption(decision, apiUrl) {
|
|
75
|
+
if (!decision.enabled || (await resolveKekPublicKey(apiUrl)))
|
|
73
76
|
return decision;
|
|
74
77
|
if (decision.source === 'flag') {
|
|
75
|
-
throw new Error(`--encrypt needs the KEK public key of the API it uploads to, and ${apiUrl}
|
|
78
|
+
throw new Error(`--encrypt needs the KEK public key of the API it uploads to, and ${apiUrl} does not publish one. ` +
|
|
76
79
|
'Set DCD_BINARY_KEK_PUBLIC=<version>:<base64> to encrypt against it, or drop --encrypt.');
|
|
77
80
|
}
|
|
78
81
|
return {
|
|
79
82
|
enabled: false,
|
|
80
|
-
notice: `Not encrypting uploads: ${apiUrl}
|
|
83
|
+
notice: `Not encrypting uploads: ${apiUrl} does not publish a KEK public key. Set DCD_BINARY_KEK_PUBLIC to encrypt against it.`,
|
|
81
84
|
source: decision.source,
|
|
82
85
|
};
|
|
83
86
|
}
|
|
@@ -89,14 +92,17 @@ function x25519PublicFromRaw(raw) {
|
|
|
89
92
|
});
|
|
90
93
|
}
|
|
91
94
|
/**
|
|
92
|
-
* Resolve the KEK public key for the
|
|
95
|
+
* Resolve the KEK public key for the API behind `apiUrl`. Order:
|
|
93
96
|
* 1. `DCD_BINARY_KEK_PUBLIC` env override (`<version>:<base64>`, e.g. `1:AAAA…`)
|
|
94
|
-
* — lets the feature be exercised
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
97
|
+
* — lets the feature be exercised against an API that publishes nothing.
|
|
98
|
+
* 2. `binary_kek_public_key` from the API's own `GET /cli-config` (dcd#1138):
|
|
99
|
+
* the newest KEK the API holds the private half of. Fetched once per
|
|
100
|
+
* process (see ../config/cli-config.ts), so a KEK rotation is an env
|
|
101
|
+
* change on the API and never a CLI release.
|
|
102
|
+
* Returns null when the API publishes no key (encryption cannot proceed).
|
|
103
|
+
* Throws when the API cannot be asked, or publishes something malformed.
|
|
98
104
|
*/
|
|
99
|
-
export function resolveKekPublicKey(apiUrl) {
|
|
105
|
+
export async function resolveKekPublicKey(apiUrl) {
|
|
100
106
|
const override = process.env.DCD_BINARY_KEK_PUBLIC;
|
|
101
107
|
if (override) {
|
|
102
108
|
const [versionPart, b64] = override.split(':');
|
|
@@ -108,17 +114,15 @@ export function resolveKekPublicKey(apiUrl) {
|
|
|
108
114
|
}
|
|
109
115
|
throw new Error('DCD_BINARY_KEK_PUBLIC must be "<version>:<base64 32-byte X25519 public key>"');
|
|
110
116
|
}
|
|
111
|
-
const
|
|
112
|
-
if (
|
|
117
|
+
const published = (await getCliConfig(apiUrl)).binary_kek_public_key;
|
|
118
|
+
if (published === undefined || published === null)
|
|
113
119
|
return null;
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (keyRaw.length !== 32) {
|
|
119
|
-
throw new Error(`Pinned KEK public key for ${env} is not a 32-byte key`);
|
|
120
|
+
const { version, key } = published;
|
|
121
|
+
const keyRaw = typeof key === 'string' ? Buffer.from(key, 'base64') : Buffer.alloc(0);
|
|
122
|
+
if (!Number.isInteger(version) || keyRaw.length !== 32) {
|
|
123
|
+
throw new Error(`CLI config from ${cliConfigUrl(apiUrl)} has a malformed binary_kek_public_key; expected { version: integer, key: base64 32-byte X25519 public key }`);
|
|
120
124
|
}
|
|
121
|
-
return { version:
|
|
125
|
+
return { version: version, keyRaw };
|
|
122
126
|
}
|
|
123
127
|
/**
|
|
124
128
|
* Wrap a DEK for the given KEK public key (X25519 sealed box). Produces base64 of
|
package/package.json
CHANGED
package/dist/utils/help.d.ts
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import type { ArgDef, ArgsDef, CommandDef } from 'citty';
|
|
2
|
-
/**
|
|
3
|
-
* A citty arg definition plus a local `hidden` marker. citty (0.2) can hide a
|
|
4
|
-
* command from help (`meta.hidden`) but not an arg, so {@link withoutHiddenArgs}
|
|
5
|
-
* drops hidden args before usage is rendered. They still parse as normal.
|
|
6
|
-
*/
|
|
7
|
-
export type CliArgDef = ArgDef & {
|
|
8
|
-
hidden?: boolean;
|
|
9
|
-
};
|
|
10
|
-
export type CliArgsDef = Record<string, CliArgDef>;
|
|
11
|
-
/** `cmd` with its hidden args left out, for rendering `--help` only. */
|
|
12
|
-
export declare function withoutHiddenArgs<T extends ArgsDef>(cmd: CommandDef<T>): Promise<CommandDef<T>>;
|
package/dist/utils/help.js
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
/** `cmd` with its hidden args left out, for rendering `--help` only. */
|
|
2
|
-
export async function withoutHiddenArgs(cmd) {
|
|
3
|
-
const args = typeof cmd.args === 'function' ? await cmd.args() : await cmd.args;
|
|
4
|
-
if (!args)
|
|
5
|
-
return cmd;
|
|
6
|
-
const visible = Object.fromEntries(Object.entries(args).filter(([, def]) => !def.hidden));
|
|
7
|
-
return { ...cmd, args: visible };
|
|
8
|
-
}
|