@devicecloud.dev/dcd 5.6.0 → 5.7.0
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 +3 -2
- package/dist/commands/cloud.js +25 -8
- package/dist/commands/upload.d.ts +2 -1
- package/dist/commands/upload.js +38 -8
- package/dist/config/environments.d.ts +9 -0
- package/dist/config/environments.js +21 -0
- package/dist/config/flags/binary.flags.d.ts +2 -1
- package/dist/config/flags/binary.flags.js +4 -1
- package/dist/config/flags/execution.flags.d.ts +1 -1
- package/dist/config/flags/execution.flags.js +1 -1
- package/dist/constants.d.ts +3 -2
- package/dist/index.js +8 -2
- package/dist/mcp/helpers.d.ts +4 -2
- package/dist/mcp/helpers.js +7 -4
- package/dist/mcp/tools/run-cloud-test.js +30 -6
- package/dist/methods.d.ts +3 -3
- package/dist/methods.js +17 -7
- package/dist/services/metadata-extractor.service.d.ts +19 -0
- package/dist/services/metadata-extractor.service.js +31 -0
- package/dist/services/telemetry.service.d.ts +20 -2
- package/dist/services/telemetry.service.js +27 -2
- package/dist/services/test-submission.service.d.ts +3 -2
- package/dist/services/test-submission.service.js +3 -3
- package/dist/types/generated/schema.types.d.ts +192 -14
- package/dist/types.d.ts +5 -0
- package/dist/utils/compatibility.d.ts +8 -0
- package/dist/utils/envelope.d.ts +32 -6
- package/dist/utils/envelope.js +59 -11
- package/dist/utils/help.d.ts +12 -0
- package/dist/utils/help.js +8 -0
- package/package.json +4 -4
|
@@ -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
|
|
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
|
|
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
|
|
84
|
-
'Set DCD_BINARY_KEK_PUBLIC=<version>:<base64
|
|
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-
|
|
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-
|
|
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-
|
|
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":
|
|
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
|
|
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
|
|
3801
|
-
|
|
3802
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
package/dist/utils/envelope.d.ts
CHANGED
|
@@ -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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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;
|
package/dist/utils/envelope.js
CHANGED
|
@@ -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 {
|
|
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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
53
|
+
export function resolveEncryption(opts) {
|
|
54
|
+
const { env = process.env, flag, serverDefault } = opts;
|
|
35
55
|
if (flag !== undefined)
|
|
36
|
-
return flag;
|
|
37
|
-
|
|
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
|
-
*
|
|
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 =
|
|
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>>;
|