@devicecloud.dev/dcd 5.6.0 → 5.7.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.
@@ -1,9 +1,18 @@
1
1
  import type { AuthContext } from '../types/domain/auth.types.js';
2
+ import type { EncryptionSource } from '../utils/envelope.js';
2
3
  export type TelemetryLevel = 'log' | 'info' | 'warn' | 'error';
4
+ /** What a run encrypted, and what decided it (dcd#1138). */
5
+ export interface EncryptionTelemetry {
6
+ binary: boolean;
7
+ env: boolean;
8
+ flow: boolean;
9
+ source: EncryptionSource;
10
+ }
3
11
  declare class Telemetry {
4
12
  private buffer;
5
13
  private config;
6
14
  private command;
15
+ private encryption;
7
16
  private sessionId;
8
17
  private startedAt;
9
18
  private readonly disabled;
@@ -27,13 +36,22 @@ declare class Telemetry {
27
36
  setCommand(command: string): void;
28
37
  recordCommandStart(): void;
29
38
  recordMcpToolStart(tool: string): void;
30
- recordMcpToolSuccess(tool: string, durationMs: number): void;
31
- recordMcpToolFailure(tool: string, error: unknown, durationMs: number): void;
39
+ recordMcpToolSuccess(tool: string, durationMs: number, extra?: Record<string, unknown>): void;
40
+ recordMcpToolFailure(tool: string, error: unknown, durationMs: number, extra?: Record<string, unknown>): void;
41
+ /**
42
+ * Record what this command encrypts (dcd#1138), sent as `encrypt` on its
43
+ * `command completed` / `command failed` event. Merged into what is already
44
+ * recorded, so a command can fill each part in as it happens. On the event
45
+ * rather than in `meta`, because the API's `/cli/logs` proxy forwards only
46
+ * the meta fields it knows, but forwards every event's `extra`.
47
+ */
48
+ recordEncryption(update: Partial<EncryptionTelemetry>): void;
32
49
  recordCommandSuccess(): void;
33
50
  recordCommandFailure(opts: {
34
51
  error: Error | string;
35
52
  exitCode: number;
36
53
  }): void;
54
+ private encryptionExtra;
37
55
  private enqueue;
38
56
  flush(): Promise<void>;
39
57
  /**
@@ -23,6 +23,7 @@ class Telemetry {
23
23
  buffer = [];
24
24
  config = null;
25
25
  command = inferCommandFromArgv();
26
+ encryption = null;
26
27
  sessionId = randomUUID();
27
28
  startedAt = Date.now();
28
29
  disabled = !!process.env.DCD_TELEMETRY_DISABLED;
@@ -60,24 +61,44 @@ class Telemetry {
60
61
  recordMcpToolStart(tool) {
61
62
  this.enqueue('info', 'cli.mcp', 'mcp tool invoked', { tool });
62
63
  }
63
- recordMcpToolSuccess(tool, durationMs) {
64
+ recordMcpToolSuccess(tool, durationMs, extra = {}) {
64
65
  this.enqueue('info', 'cli.mcp', 'mcp tool completed', {
65
66
  tool,
66
67
  duration_ms: durationMs,
68
+ ...extra,
67
69
  });
68
70
  }
69
- recordMcpToolFailure(tool, error, durationMs) {
71
+ recordMcpToolFailure(tool, error, durationMs, extra = {}) {
70
72
  this.enqueue('error', 'cli.mcp', 'mcp tool failed', {
71
73
  tool,
72
74
  duration_ms: durationMs,
73
75
  error_message: error instanceof Error ? error.message : String(error),
74
76
  error_name: error instanceof Error ? error.name : 'Error',
77
+ ...extra,
75
78
  });
76
79
  }
80
+ /**
81
+ * Record what this command encrypts (dcd#1138), sent as `encrypt` on its
82
+ * `command completed` / `command failed` event. Merged into what is already
83
+ * recorded, so a command can fill each part in as it happens. On the event
84
+ * rather than in `meta`, because the API's `/cli/logs` proxy forwards only
85
+ * the meta fields it knows, but forwards every event's `extra`.
86
+ */
87
+ recordEncryption(update) {
88
+ this.encryption = {
89
+ binary: false,
90
+ env: false,
91
+ flow: false,
92
+ source: 'off',
93
+ ...this.encryption,
94
+ ...update,
95
+ };
96
+ }
77
97
  recordCommandSuccess() {
78
98
  this.enqueue('info', 'cli.lifecycle', 'command completed', {
79
99
  duration_ms: Date.now() - this.startedAt,
80
100
  exit_code: 0,
101
+ ...this.encryptionExtra(),
81
102
  });
82
103
  }
83
104
  recordCommandFailure(opts) {
@@ -88,8 +109,12 @@ class Telemetry {
88
109
  error_message: message,
89
110
  error_name: opts.error instanceof Error ? opts.error.name : 'CliError',
90
111
  error_stack: opts.error instanceof Error ? opts.error.stack : undefined,
112
+ ...this.encryptionExtra(),
91
113
  });
92
114
  }
115
+ encryptionExtra() {
116
+ return this.encryption ? { encrypt: this.encryption } : {};
117
+ }
93
118
  enqueue(level, context, message, extra) {
94
119
  if (this.disabled)
95
120
  return;
@@ -34,9 +34,10 @@ export interface TestSubmissionConfig {
34
34
  /**
35
35
  * Encrypt the flow zip and env vars before upload (#1151/#1152), each with its
36
36
  * own per-upload DEK wrapped under the environment KEK. Requires `apiUrl` to
37
- * resolve the pinned KEK public key.
37
+ * resolve the pinned KEK public key. Required, like `uploadBinary`'s: callers
38
+ * pass the decision from `resolveEncryption`.
38
39
  */
39
- encrypt?: boolean;
40
+ encrypt: boolean;
40
41
  env?: string[];
41
42
  executionPlan: IExecutionPlan;
42
43
  flowFile: string;
@@ -20,7 +20,7 @@ export class TestSubmissionService {
20
20
  * @returns The flow zip buffer, its SHA-256, and the string-encoded fields
21
21
  */
22
22
  async buildTestPayload(config) {
23
- const { apiUrl, appBinaryId, appPlatform, encrypt = false, flowFile, executionPlan, commonRoot, cliVersion, env = [], metadata = [], cancelPrevious = false, googlePlay = false, androidApiLevel, androidDevice, androidNoSnapshot, iOSVersion, iOSDevice, name, runnerType, maestroVersion, deviceLocale, orientation, retry, continueOnFailure = true, renderEngine, report, showCrosshairs, maestroChromeOnboarding, raw, disableAnimations, deviceMatrix, debug = false, logger, } = config;
23
+ const { apiUrl, appBinaryId, appPlatform, encrypt, flowFile, executionPlan, commonRoot, cliVersion, env = [], metadata = [], cancelPrevious = false, googlePlay = false, androidApiLevel, androidDevice, androidNoSnapshot, iOSVersion, iOSDevice, name, runnerType, maestroVersion, deviceLocale, orientation, retry, continueOnFailure = true, renderEngine, report, showCrosshairs, maestroChromeOnboarding, raw, disableAnimations, deviceMatrix, debug = false, logger, } = config;
24
24
  const { allExcludeTags, allIncludeTags, flowMetadata, flowOverrides, flowsToRun: testFileNames, includedFiles = [], referencedFiles, sequence, workspaceConfig, } = executionPlan;
25
25
  const { flows: sequentialFlows = [] } = sequence ?? {};
26
26
  const envObject = this.parseKeyValuePairs(env);
@@ -80,8 +80,8 @@ export class TestSubmissionService {
80
80
  }
81
81
  const kek = resolveKekPublicKey(apiUrl);
82
82
  if (!kek) {
83
- throw new Error('Encryption was requested but no KEK public key is configured for this environment. ' +
84
- 'Set DCD_BINARY_KEK_PUBLIC=<version>:<base64> or pin one in src/config/environments.ts.');
83
+ throw new Error('Encryption is on (from --encrypt, DCD_ENCRYPT or the organization default) but no KEK public key is configured for this API. ' +
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);
87
87
  buffer = flow.ciphertext;
@@ -1004,6 +1004,38 @@ export interface paths {
1004
1004
  patch?: never;
1005
1005
  trace?: never;
1006
1006
  };
1007
+ "/frontend/uploads/{uploadId}/download": {
1008
+ parameters: {
1009
+ query?: never;
1010
+ header?: never;
1011
+ path?: never;
1012
+ cookie?: never;
1013
+ };
1014
+ get: operations["FrontendController_downloadUpload"];
1015
+ put?: never;
1016
+ post?: never;
1017
+ delete?: never;
1018
+ options?: never;
1019
+ head?: never;
1020
+ patch?: never;
1021
+ trace?: never;
1022
+ };
1023
+ "/frontend/results/{resultId}/env": {
1024
+ parameters: {
1025
+ query?: never;
1026
+ header?: never;
1027
+ path?: never;
1028
+ cookie?: never;
1029
+ };
1030
+ get: operations["FrontendController_getResultEnv"];
1031
+ put?: never;
1032
+ post?: never;
1033
+ delete?: never;
1034
+ options?: never;
1035
+ head?: never;
1036
+ patch?: never;
1037
+ trace?: never;
1038
+ };
1007
1039
  "/frontend/artifact-download-url": {
1008
1040
  parameters: {
1009
1041
  query?: never;
@@ -1662,7 +1694,7 @@ export interface components {
1662
1694
  workspaceConfig?: string;
1663
1695
  flowMetadata?: string;
1664
1696
  testFileOverrides?: string;
1665
- /** @description JSON array of explicit device configs forming the upload device matrix. Every flow that does not name its own device runs once per entry. Each entry names exactly one validated cell and must match the binary platform — there is no cross-product expansion. iOS: {"iOSDevice":"iphone-16","iOSVersion":"18"}. Android: {"androidDevice":"pixel-7","androidApiLevel":"34","googlePlay":true}. Omit for single-device (legacy) behaviour. */
1697
+ /** @description JSON array of explicit device configs forming the upload device matrix. Every flow that does not name its own device runs once per entry. Each entry names exactly one validated cell and must match the binary platform — there is no cross-product expansion. iOS: {"iOSDevice":"iphone-16","iOSVersion":"18"}. Android: {"androidDevice":"pixel-8","androidApiLevel":"34","googlePlay":true}. Omit for single-device (legacy) behaviour. */
1666
1698
  deviceMatrix?: string;
1667
1699
  /** @description SHA-256 hash of the flow ZIP file */
1668
1700
  sha?: string;
@@ -1704,7 +1736,7 @@ export interface components {
1704
1736
  workspaceConfig?: string;
1705
1737
  flowMetadata?: string;
1706
1738
  testFileOverrides?: string;
1707
- /** @description JSON array of explicit device configs forming the upload device matrix. Every flow that does not name its own device runs once per entry. Each entry names exactly one validated cell and must match the binary platform — there is no cross-product expansion. iOS: {"iOSDevice":"iphone-16","iOSVersion":"18"}. Android: {"androidDevice":"pixel-7","androidApiLevel":"34","googlePlay":true}. Omit for single-device (legacy) behaviour. */
1739
+ /** @description JSON array of explicit device configs forming the upload device matrix. Every flow that does not name its own device runs once per entry. Each entry names exactly one validated cell and must match the binary platform — there is no cross-product expansion. iOS: {"iOSDevice":"iphone-16","iOSVersion":"18"}. Android: {"androidDevice":"pixel-8","androidApiLevel":"34","googlePlay":true}. Omit for single-device (legacy) behaviour. */
1708
1740
  deviceMatrix?: string;
1709
1741
  /** @description SHA-256 hash of the flow ZIP file */
1710
1742
  sha?: string;
@@ -1757,7 +1789,7 @@ export interface components {
1757
1789
  workspaceConfig?: string;
1758
1790
  flowMetadata?: string;
1759
1791
  testFileOverrides?: string;
1760
- /** @description JSON array of explicit device configs forming the upload device matrix. Every flow that does not name its own device runs once per entry. Each entry names exactly one validated cell and must match the binary platform — there is no cross-product expansion. iOS: {"iOSDevice":"iphone-16","iOSVersion":"18"}. Android: {"androidDevice":"pixel-7","androidApiLevel":"34","googlePlay":true}. Omit for single-device (legacy) behaviour. */
1792
+ /** @description JSON array of explicit device configs forming the upload device matrix. Every flow that does not name its own device runs once per entry. Each entry names exactly one validated cell and must match the binary platform — there is no cross-product expansion. iOS: {"iOSDevice":"iphone-16","iOSVersion":"18"}. Android: {"androidDevice":"pixel-8","androidApiLevel":"34","googlePlay":true}. Omit for single-device (legacy) behaviour. */
1761
1793
  deviceMatrix?: string;
1762
1794
  /** @description SHA-256 hash of the flow ZIP file */
1763
1795
  sha?: string;
@@ -2235,7 +2267,15 @@ export interface operations {
2235
2267
  * ]
2236
2268
  * }
2237
2269
  */
2238
- "application/json": Record<string, never>;
2270
+ "application/json": {
2271
+ /**
2272
+ * Format: uuid
2273
+ * @description Present only when a newer run from the same CI context (`dcd cloud --cancel-previous`) superseded this one by cancelling its queued tests: the id of that newer upload. `status` still rolls up to FAILED for such a run (its queued tests were cancelled), so check this field before failing a CI job on `status`.
2274
+ */
2275
+ supersededBy?: string;
2276
+ } & {
2277
+ [key: string]: unknown;
2278
+ };
2239
2279
  };
2240
2280
  };
2241
2281
  };
@@ -2590,6 +2630,7 @@ export interface operations {
2590
2630
  content: {
2591
2631
  "application/json": {
2592
2632
  orgId: string;
2633
+ /** @description Overage allowed on top of the plan’s included credits, rounded down to a whole number. 0 allows no overage: usage is capped at the included credits. Negative values are rejected. */
2593
2634
  overageLimit: number;
2594
2635
  };
2595
2636
  };
@@ -3076,6 +3117,13 @@ export interface operations {
3076
3117
  * "androidPlay": {
3077
3118
  * "pixel-7": [
3078
3119
  * "34"
3120
+ * ],
3121
+ * "pixel-8": [
3122
+ * "34"
3123
+ * ],
3124
+ * "pixel-10": [
3125
+ * "36",
3126
+ * "37"
3079
3127
  * ]
3080
3128
  * },
3081
3129
  * "maestro": {
@@ -3095,6 +3143,9 @@ export interface operations {
3095
3143
  * ],
3096
3144
  * "defaultVersion": "2.2.0",
3097
3145
  * "latestVersion": "2.10.0"
3146
+ * },
3147
+ * "encryption": {
3148
+ * "defaultOn": false
3098
3149
  * }
3099
3150
  * }
3100
3151
  * }
@@ -3110,6 +3161,11 @@ export interface operations {
3110
3161
  defaultVersion?: string;
3111
3162
  latestVersion?: string;
3112
3163
  };
3164
+ /** @description The org's client-side encryption default (#1138). An API that predates it omits the field, which a client must read as `defaultOn: false`. */
3165
+ encryption?: {
3166
+ /** @description Encrypt uploads unless the caller opts out (`--no-encrypt` / `DCD_ENCRYPT=0`). True only for orgs in the `client_encryption` beta, and forced false by `CLIENT_ENCRYPTION_DEFAULT=off` on the API. */
3167
+ defaultOn: boolean;
3168
+ };
3113
3169
  };
3114
3170
  };
3115
3171
  };
@@ -3790,16 +3846,16 @@ export interface operations {
3790
3846
  };
3791
3847
  };
3792
3848
  responses: {
3793
- /** @description Signed download URL for the binary; `dek` (base64) is present when the binary is encrypted */
3849
+ /** @description Signed download URL for the stored binary. `encrypted` is present (true) when that object is ciphertext; download it through GET /frontend/binaries/{binaryId}/download instead. */
3794
3850
  200: {
3795
3851
  headers: {
3796
3852
  [name: string]: unknown;
3797
3853
  };
3798
3854
  content: {
3799
3855
  "application/json": {
3800
- url?: string;
3801
- encrypted?: boolean;
3802
- dek?: string;
3856
+ url: string;
3857
+ /** @enum {boolean} */
3858
+ encrypted?: true;
3803
3859
  };
3804
3860
  };
3805
3861
  };
@@ -3807,9 +3863,7 @@ export interface operations {
3807
3863
  headers: {
3808
3864
  [name: string]: unknown;
3809
3865
  };
3810
- content: {
3811
- "application/json": Record<string, never>;
3812
- };
3866
+ content?: never;
3813
3867
  };
3814
3868
  /** @description Binary not found or not accessible */
3815
3869
  400: {
@@ -3829,8 +3883,13 @@ export interface operations {
3829
3883
  query: {
3830
3884
  orgId: number;
3831
3885
  };
3832
- header: {
3833
- authorization: string;
3886
+ header?: {
3887
+ /** @description The @devicecloud.dev staff member a back-office request acts for; recorded as the key-release actor. */
3888
+ "x-staff-email"?: string;
3889
+ /** @description Back-office service key (BACKOFFICE_API_KEY), in place of a JWT. Requires x-staff-email. */
3890
+ "x-backoffice-key"?: string;
3891
+ /** @description Bearer <Supabase JWT>. Omit when sending x-backoffice-key. */
3892
+ authorization?: string;
3834
3893
  };
3835
3894
  path: {
3836
3895
  binaryId: string;
@@ -3839,13 +3898,20 @@ export interface operations {
3839
3898
  };
3840
3899
  requestBody?: never;
3841
3900
  responses: {
3842
- /** @description Binary bytes (decrypted if encrypted) */
3901
+ /** @description Encrypted binary: the stream-decrypted plaintext bytes */
3843
3902
  200: {
3844
3903
  headers: {
3845
3904
  [name: string]: unknown;
3846
3905
  };
3847
3906
  content?: never;
3848
3907
  };
3908
+ /** @description Plaintext binary: redirect to a signed storage URL */
3909
+ 302: {
3910
+ headers: {
3911
+ [name: string]: unknown;
3912
+ };
3913
+ content?: never;
3914
+ };
3849
3915
  /** @description Binary not found or not accessible */
3850
3916
  400: {
3851
3917
  headers: {
@@ -3853,6 +3919,118 @@ export interface operations {
3853
3919
  };
3854
3920
  content?: never;
3855
3921
  };
3922
+ /** @description No valid JWT or back-office key, or not an org member */
3923
+ 401: {
3924
+ headers: {
3925
+ [name: string]: unknown;
3926
+ };
3927
+ content?: never;
3928
+ };
3929
+ };
3930
+ };
3931
+ FrontendController_downloadUpload: {
3932
+ parameters: {
3933
+ query: {
3934
+ orgId: number;
3935
+ };
3936
+ header?: {
3937
+ /** @description The @devicecloud.dev staff member a back-office request acts for; recorded as the key-release actor. */
3938
+ "x-staff-email"?: string;
3939
+ /** @description Back-office service key (BACKOFFICE_API_KEY), in place of a JWT. Requires x-staff-email. */
3940
+ "x-backoffice-key"?: string;
3941
+ /** @description Bearer <Supabase JWT>. Omit when sending x-backoffice-key. */
3942
+ authorization?: string;
3943
+ };
3944
+ path: {
3945
+ uploadId: string;
3946
+ };
3947
+ cookie?: never;
3948
+ };
3949
+ requestBody?: never;
3950
+ responses: {
3951
+ /** @description Encrypted flow upload: the stream-decrypted zip */
3952
+ 200: {
3953
+ headers: {
3954
+ [name: string]: unknown;
3955
+ };
3956
+ content?: never;
3957
+ };
3958
+ /** @description Plaintext flow upload: redirect to a signed storage URL */
3959
+ 302: {
3960
+ headers: {
3961
+ [name: string]: unknown;
3962
+ };
3963
+ content?: never;
3964
+ };
3965
+ /** @description Upload not found or not accessible */
3966
+ 400: {
3967
+ headers: {
3968
+ [name: string]: unknown;
3969
+ };
3970
+ content?: never;
3971
+ };
3972
+ /** @description No valid JWT or back-office key, or not an org member */
3973
+ 401: {
3974
+ headers: {
3975
+ [name: string]: unknown;
3976
+ };
3977
+ content?: never;
3978
+ };
3979
+ };
3980
+ };
3981
+ FrontendController_getResultEnv: {
3982
+ parameters: {
3983
+ query?: never;
3984
+ header?: {
3985
+ /** @description The @devicecloud.dev staff member a back-office request acts for; recorded as the key-release actor. */
3986
+ "x-staff-email"?: string;
3987
+ /** @description Back-office service key (BACKOFFICE_API_KEY), in place of a JWT. Requires x-staff-email. */
3988
+ "x-backoffice-key"?: string;
3989
+ /** @description Bearer <Supabase JWT>. Omit when sending x-backoffice-key. */
3990
+ authorization?: string;
3991
+ };
3992
+ path: {
3993
+ resultId: number;
3994
+ };
3995
+ cookie?: never;
3996
+ };
3997
+ requestBody?: never;
3998
+ responses: {
3999
+ /** @description The result's env. `encrypted` says whether it was stored encrypted; `env` is the (decrypted) KEY -> VALUE map, or null when the result has none. */
4000
+ 200: {
4001
+ headers: {
4002
+ [name: string]: unknown;
4003
+ };
4004
+ content: {
4005
+ "application/json": {
4006
+ encrypted: boolean;
4007
+ env: {
4008
+ [key: string]: string;
4009
+ } | null;
4010
+ };
4011
+ };
4012
+ };
4013
+ /** @description No valid JWT or back-office key */
4014
+ 401: {
4015
+ headers: {
4016
+ [name: string]: unknown;
4017
+ };
4018
+ content?: never;
4019
+ };
4020
+ /** @description The JWT is not a staff account */
4021
+ 403: {
4022
+ headers: {
4023
+ [name: string]: unknown;
4024
+ };
4025
+ content?: never;
4026
+ };
4027
+ /** @description Result not found */
4028
+ 404: {
4029
+ headers: {
4030
+ [name: string]: unknown;
4031
+ };
4032
+ content?: never;
4033
+ };
3856
4034
  };
3857
4035
  };
3858
4036
  FrontendController_getArtifactDownloadUrl: {
package/dist/types.d.ts CHANGED
@@ -8,4 +8,9 @@ export type TAppMetadata = {
8
8
  * release the DEK and decrypt. Absent = legacy plaintext upload.
9
9
  */
10
10
  enc?: BinaryEnvelope;
11
+ /**
12
+ * Whether an encrypted APK ships the Flutter engine (dcd#1138). The API
13
+ * detects this itself for plaintext binaries, so it is only sent with `enc`.
14
+ */
15
+ usesFlutterEngine?: boolean;
11
16
  };
@@ -11,6 +11,14 @@ export interface CompatibilityData {
11
11
  };
12
12
  /** Active CLI notices, piggybacked onto the compatibility response by the API. */
13
13
  notices?: Notice[];
14
+ /**
15
+ * The org's client-side encryption default (dcd#1138): on for orgs in the
16
+ * `client_encryption` beta unless the API's kill switch is set. An older API
17
+ * omits it, which means off.
18
+ */
19
+ encryption?: {
20
+ defaultOn: boolean;
21
+ };
14
22
  }
15
23
  /** Identity the CLI forwards so the API can target notices by version / CI. */
16
24
  export interface ClientContext {
@@ -14,14 +14,39 @@ export interface EnvEnvelope extends BinaryEnvelope {
14
14
  /** base64 of a single-segment DCDE container of `JSON.stringify(env)`. */
15
15
  ciphertext: string;
16
16
  }
17
+ /** What decided whether a run encrypts (dcd#1138); recorded in telemetry. */
18
+ export type EncryptionSource = 'env' | 'flag' | 'off' | 'server';
19
+ export interface EncryptionDecision {
20
+ enabled: boolean;
21
+ /** Kept when encryption is skipped, so telemetry shows what asked for it. */
22
+ source: EncryptionSource;
23
+ /** One line for the user when encryption was wanted but is skipped. */
24
+ notice?: string;
25
+ }
17
26
  /**
18
- * Whether client-side envelope encryption is on. Explicit `flag` (the
19
- * `--encrypt` CLI flag) wins; otherwise `DCD_ENCRYPT=1` enables it for binary,
20
- * flow, and env, and the legacy `DCD_ENCRYPT_BINARIES=1` is kept as an alias.
27
+ * Whether client-side envelope encryption is on, and what decided it. The
28
+ * first of these that is set wins:
29
+ * 1. `flag`: `--encrypt` / `--no-encrypt`, or the MCP tool's `encrypt` input.
30
+ * 2. `DCD_ENCRYPT`, then its legacy alias `DCD_ENCRYPT_BINARIES`.
31
+ * 3. `serverDefault`: `encryption.defaultOn` from the compatibility data,
32
+ * which the API turns on for orgs in the `client_encryption` beta.
33
+ * 4. Off. An API too old to send `encryption` ends up here.
21
34
  * When on, the binary, the flow zip, and the env map are each encrypted with
22
35
  * their **own** per-upload DEK (all wrapped under the same per-env KEK).
23
36
  */
24
- export declare function isEncryptionEnabled(flag?: boolean): boolean;
37
+ export declare function resolveEncryption(opts: {
38
+ env?: NodeJS.ProcessEnv;
39
+ flag?: boolean;
40
+ serverDefault?: boolean;
41
+ }): EncryptionDecision;
42
+ /**
43
+ * Check that `apiUrl` has a KEK before a run encrypts against it: either
44
+ * `DCD_BINARY_KEK_PUBLIC` is set or the URL is a known devicecloud.dev API
45
+ * (see kekEnvForApiUrl). Without one, an explicit `--encrypt` is an error,
46
+ * while encryption from DCD_ENCRYPT or the org default is skipped with a
47
+ * notice, so a run against another API URL uploads in plaintext as before.
48
+ */
49
+ export declare function checkKekForEncryption(decision: EncryptionDecision, apiUrl: string): EncryptionDecision;
25
50
  /** Pinned KEK public key (base64 raw 32-byte X25519) + version, per env. */
26
51
  interface KekPublicKey {
27
52
  version: number;
@@ -30,8 +55,9 @@ interface KekPublicKey {
30
55
  /**
31
56
  * Resolve the KEK public key for the environment behind `apiUrl`. Order:
32
57
  * 1. `DCD_BINARY_KEK_PUBLIC` env override (`<version>:<base64>`, e.g. `1:AAAA…`)
33
- * — lets the feature be exercised before keys are pinned in the release.
34
- * 2. the pinned `ENVIRONMENTS[env].kekPublicKey`.
58
+ * — lets the feature be exercised before keys are pinned in the release,
59
+ * or against an API URL that isn't a known one.
60
+ * 2. the pinned `ENVIRONMENTS[env].kekPublicKey`, only for a known API URL.
35
61
  * Returns null when no key is available (encryption cannot proceed).
36
62
  */
37
63
  export declare function resolveKekPublicKey(apiUrl: string): KekPublicKey | null;
@@ -1,7 +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 { inferEnvFromApiUrl } from '../config/environments.js';
4
- import { ENVIRONMENTS } from '../config/environments.js';
3
+ import { ENVIRONMENTS, kekEnvForApiUrl } from '../config/environments.js';
5
4
  /**
6
5
  * Client-side envelope encryption of app binaries before upload (dcd#1138).
7
6
  *
@@ -24,17 +23,63 @@ export const CHUNK_SIZE = 1024 * 1024; // 1 MiB plaintext segments
24
23
  const HKDF_INFO = Buffer.from('dcd-binary-dek-wrap-v1', 'ascii');
25
24
  // DER prefix that turns a raw 32-byte X25519 public key into an importable SPKI.
26
25
  const SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex');
26
+ /** `1`/`true` is on and `0`/`false` is off, in any case; anything else is unset. */
27
+ function parseEncryptEnv(value) {
28
+ switch (value?.trim().toLowerCase()) {
29
+ case '1':
30
+ case 'true': {
31
+ return true;
32
+ }
33
+ case '0':
34
+ case 'false': {
35
+ return false;
36
+ }
37
+ default: {
38
+ return undefined;
39
+ }
40
+ }
41
+ }
27
42
  /**
28
- * Whether client-side envelope encryption is on. Explicit `flag` (the
29
- * `--encrypt` CLI flag) wins; otherwise `DCD_ENCRYPT=1` enables it for binary,
30
- * flow, and env, and the legacy `DCD_ENCRYPT_BINARIES=1` is kept as an alias.
43
+ * Whether client-side envelope encryption is on, and what decided it. The
44
+ * first of these that is set wins:
45
+ * 1. `flag`: `--encrypt` / `--no-encrypt`, or the MCP tool's `encrypt` input.
46
+ * 2. `DCD_ENCRYPT`, then its legacy alias `DCD_ENCRYPT_BINARIES`.
47
+ * 3. `serverDefault`: `encryption.defaultOn` from the compatibility data,
48
+ * which the API turns on for orgs in the `client_encryption` beta.
49
+ * 4. Off. An API too old to send `encryption` ends up here.
31
50
  * When on, the binary, the flow zip, and the env map are each encrypted with
32
51
  * their **own** per-upload DEK (all wrapped under the same per-env KEK).
33
52
  */
34
- export function isEncryptionEnabled(flag) {
53
+ export function resolveEncryption(opts) {
54
+ const { env = process.env, flag, serverDefault } = opts;
35
55
  if (flag !== undefined)
36
- return flag;
37
- return (process.env.DCD_ENCRYPT === '1' || process.env.DCD_ENCRYPT_BINARIES === '1');
56
+ return { enabled: flag, source: 'flag' };
57
+ const fromEnv = parseEncryptEnv(env.DCD_ENCRYPT) ?? parseEncryptEnv(env.DCD_ENCRYPT_BINARIES);
58
+ if (fromEnv !== undefined)
59
+ return { enabled: fromEnv, source: 'env' };
60
+ if (serverDefault === true)
61
+ return { enabled: true, source: 'server' };
62
+ return { enabled: false, source: 'off' };
63
+ }
64
+ /**
65
+ * Check that `apiUrl` has a KEK before a run encrypts against it: either
66
+ * `DCD_BINARY_KEK_PUBLIC` is set or the URL is a known devicecloud.dev API
67
+ * (see kekEnvForApiUrl). Without one, an explicit `--encrypt` is an error,
68
+ * while encryption from DCD_ENCRYPT or the org default is skipped with a
69
+ * notice, so a run against another API URL uploads in plaintext as before.
70
+ */
71
+ export function checkKekForEncryption(decision, apiUrl) {
72
+ if (!decision.enabled || resolveKekPublicKey(apiUrl))
73
+ return decision;
74
+ if (decision.source === 'flag') {
75
+ throw new Error(`--encrypt needs the KEK public key of the API it uploads to, and ${apiUrl} is not a devicecloud.dev API this CLI has one for. ` +
76
+ 'Set DCD_BINARY_KEK_PUBLIC=<version>:<base64> to encrypt against it, or drop --encrypt.');
77
+ }
78
+ return {
79
+ enabled: false,
80
+ notice: `Not encrypting uploads: ${apiUrl} is not a devicecloud.dev API this CLI has a key for. Set DCD_BINARY_KEK_PUBLIC to encrypt against it.`,
81
+ source: decision.source,
82
+ };
38
83
  }
39
84
  function x25519PublicFromRaw(raw) {
40
85
  return createPublicKey({
@@ -46,8 +91,9 @@ function x25519PublicFromRaw(raw) {
46
91
  /**
47
92
  * Resolve the KEK public key for the environment behind `apiUrl`. Order:
48
93
  * 1. `DCD_BINARY_KEK_PUBLIC` env override (`<version>:<base64>`, e.g. `1:AAAA…`)
49
- * — lets the feature be exercised before keys are pinned in the release.
50
- * 2. the pinned `ENVIRONMENTS[env].kekPublicKey`.
94
+ * — lets the feature be exercised before keys are pinned in the release,
95
+ * or against an API URL that isn't a known one.
96
+ * 2. the pinned `ENVIRONMENTS[env].kekPublicKey`, only for a known API URL.
51
97
  * Returns null when no key is available (encryption cannot proceed).
52
98
  */
53
99
  export function resolveKekPublicKey(apiUrl) {
@@ -62,7 +108,9 @@ export function resolveKekPublicKey(apiUrl) {
62
108
  }
63
109
  throw new Error('DCD_BINARY_KEK_PUBLIC must be "<version>:<base64 32-byte X25519 public key>"');
64
110
  }
65
- const env = inferEnvFromApiUrl(apiUrl);
111
+ const env = kekEnvForApiUrl(apiUrl);
112
+ if (!env)
113
+ return null;
66
114
  const pinned = ENVIRONMENTS[env].kekPublicKey;
67
115
  if (!pinned)
68
116
  return null;
@@ -0,0 +1,12 @@
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>>;