@everystack/cli 0.2.13 → 0.2.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.2.13",
3
+ "version": "0.2.14",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
@@ -1,17 +1,20 @@
1
1
  /**
2
- * Secrets management — read/write SST secrets in AWS SSM Parameter Store.
2
+ * Secrets management — read/write SST Ion secrets.
3
3
  *
4
- * SST v3 stores secrets at: /sst/{appName}/{stage}/Secret/{name}/value
5
- * These are SecureString parameters encrypted with the default KMS key.
4
+ * SST Ion stores secrets as AES-256-GCM encrypted JSON in S3:
5
+ * Bucket: sst-state-{random} (resolved from /sst/bootstrap SSM param)
6
+ * Key: secret/{appName}/{stage}.json
6
7
  *
7
- * Full interop with `sst secret set/list` — same SSM paths, same encryption.
8
+ * The encryption passphrase lives in SSM at /sst/passphrase/{appName}/{stage}.
9
+ *
10
+ * Full interop with `sst secret set/list` — same S3 objects, same encryption.
8
11
  * Secrets set via `everystack secrets set` are readable by SST and vice versa.
9
12
  *
10
- * Requires IAM permissions: ssm:GetParametersByPath, ssm:GetParameter,
11
- * ssm:PutParameter, ssm:DeleteParameter, kms:Decrypt, kms:Encrypt.
13
+ * Requires IAM permissions: ssm:GetParameter, s3:GetObject, s3:PutObject, kms:Decrypt.
12
14
  */
13
15
 
14
16
  import { step, success, fail, info, warn } from '../output.js';
17
+ import { loadSstSecrets, putSstSecrets } from '../utils/secrets.js';
15
18
 
16
19
  interface SecretsContext {
17
20
  appName: string;
@@ -34,16 +37,6 @@ async function resolveContext(flags: Record<string, string>): Promise<SecretsCon
34
37
  return { appName, stage, region };
35
38
  }
36
39
 
37
- function ssmPath(ctx: SecretsContext, name?: string): string {
38
- const base = `/sst/${ctx.appName}/${ctx.stage}/Secret`;
39
- return name ? `${base}/${name}/value` : `${base}/`;
40
- }
41
-
42
- async function getSSM(region: string) {
43
- const { SSMClient } = await import('@aws-sdk/client-ssm');
44
- return new SSMClient({ region });
45
- }
46
-
47
40
  // ---------------------------------------------------------------------------
48
41
  // List
49
42
  // ---------------------------------------------------------------------------
@@ -51,33 +44,8 @@ async function getSSM(region: string) {
51
44
  async function listSecrets(ctx: SecretsContext): Promise<void> {
52
45
  step(`Loading secrets for ${ctx.appName}/${ctx.stage}...`);
53
46
 
54
- const client = await getSSM(ctx.region);
55
- const { GetParametersByPathCommand } = await import('@aws-sdk/client-ssm');
56
-
57
- const names: string[] = [];
58
- let nextToken: string | undefined;
59
-
60
- do {
61
- const response = await client.send(
62
- new GetParametersByPathCommand({
63
- Path: ssmPath(ctx),
64
- Recursive: true,
65
- WithDecryption: false, // Don't decrypt — we only need names
66
- NextToken: nextToken,
67
- }),
68
- );
69
-
70
- for (const param of response.Parameters || []) {
71
- if (!param.Name) continue;
72
- const parts = param.Name.split('/');
73
- const secretIndex = parts.indexOf('Secret');
74
- if (secretIndex >= 0 && secretIndex + 1 < parts.length) {
75
- names.push(parts[secretIndex + 1]);
76
- }
77
- }
78
-
79
- nextToken = response.NextToken;
80
- } while (nextToken);
47
+ const secrets = await loadSstSecrets(ctx);
48
+ const names = Object.keys(secrets).sort();
81
49
 
82
50
  if (names.length === 0) {
83
51
  info('No secrets found');
@@ -86,7 +54,7 @@ async function listSecrets(ctx: SecretsContext): Promise<void> {
86
54
  }
87
55
 
88
56
  success(`${names.length} secret(s):`);
89
- for (const name of names.sort()) {
57
+ for (const name of names) {
90
58
  console.log(` ${name}`);
91
59
  }
92
60
  }
@@ -98,30 +66,15 @@ async function listSecrets(ctx: SecretsContext): Promise<void> {
98
66
  async function getSecret(ctx: SecretsContext, name: string): Promise<void> {
99
67
  step(`Reading ${name}...`);
100
68
 
101
- const client = await getSSM(ctx.region);
102
- const { GetParameterCommand } = await import('@aws-sdk/client-ssm');
69
+ const secrets = await loadSstSecrets(ctx);
70
+ const value = secrets[name];
103
71
 
104
- try {
105
- const response = await client.send(
106
- new GetParameterCommand({
107
- Name: ssmPath(ctx, name),
108
- WithDecryption: true,
109
- }),
110
- );
111
-
112
- if (response.Parameter?.Value !== undefined) {
113
- console.log(response.Parameter.Value);
114
- } else {
115
- fail(`Secret "${name}" has no value`);
116
- process.exit(1);
117
- }
118
- } catch (err: any) {
119
- if (err.name === 'ParameterNotFound') {
120
- fail(`Secret "${name}" not found in stage "${ctx.stage}"`);
121
- info(`Set it with: everystack secrets set ${name} "value" --stage ${ctx.stage}`);
122
- process.exit(1);
123
- }
124
- throw err;
72
+ if (value !== undefined) {
73
+ console.log(value);
74
+ } else {
75
+ fail(`Secret "${name}" not found in stage "${ctx.stage}"`);
76
+ info(`Set it with: everystack secrets set ${name} "value" --stage ${ctx.stage}`);
77
+ process.exit(1);
125
78
  }
126
79
  }
127
80
 
@@ -132,17 +85,10 @@ async function getSecret(ctx: SecretsContext, name: string): Promise<void> {
132
85
  async function setSecret(ctx: SecretsContext, name: string, value: string): Promise<void> {
133
86
  step(`Setting ${name}...`);
134
87
 
135
- const client = await getSSM(ctx.region);
136
- const { PutParameterCommand } = await import('@aws-sdk/client-ssm');
137
-
138
- await client.send(
139
- new PutParameterCommand({
140
- Name: ssmPath(ctx, name),
141
- Value: value,
142
- Type: 'SecureString',
143
- Overwrite: true,
144
- }),
145
- );
88
+ // Load existing secrets, merge, and write back
89
+ const secrets = await loadSstSecrets(ctx);
90
+ secrets[name] = value;
91
+ await putSstSecrets(ctx, secrets);
146
92
 
147
93
  success(`${name} set for stage "${ctx.stage}"`);
148
94
  }
@@ -154,23 +100,16 @@ async function setSecret(ctx: SecretsContext, name: string, value: string): Prom
154
100
  async function removeSecret(ctx: SecretsContext, name: string): Promise<void> {
155
101
  step(`Removing ${name}...`);
156
102
 
157
- const client = await getSSM(ctx.region);
158
- const { DeleteParameterCommand } = await import('@aws-sdk/client-ssm');
159
-
160
- try {
161
- await client.send(
162
- new DeleteParameterCommand({
163
- Name: ssmPath(ctx, name),
164
- }),
165
- );
166
- success(`${name} removed from stage "${ctx.stage}"`);
167
- } catch (err: any) {
168
- if (err.name === 'ParameterNotFound') {
169
- warn(`Secret "${name}" not found in stage "${ctx.stage}" (already removed?)`);
170
- return;
171
- }
172
- throw err;
103
+ const secrets = await loadSstSecrets(ctx);
104
+ if (!(name in secrets)) {
105
+ warn(`Secret "${name}" not found in stage "${ctx.stage}" (already removed?)`);
106
+ return;
173
107
  }
108
+
109
+ delete secrets[name];
110
+ await putSstSecrets(ctx, secrets);
111
+
112
+ success(`${name} removed from stage "${ctx.stage}"`);
174
113
  }
175
114
 
176
115
  // ---------------------------------------------------------------------------
@@ -242,14 +181,14 @@ export async function secretsCommand(
242
181
  }
243
182
  } catch (err: any) {
244
183
  fail(err.message);
245
- info('Ensure your IAM user/role has ssm:* and kms:Decrypt permissions.');
184
+ info('Ensure your IAM user/role has ssm:GetParameter, s3:GetObject, and kms:Decrypt permissions.');
246
185
  process.exit(1);
247
186
  }
248
187
  }
249
188
 
250
189
  function printSecretsHelp(): void {
251
190
  console.log(`
252
- everystack secrets — Manage SST secrets (SSM Parameter Store)
191
+ everystack secrets — Manage SST Ion secrets
253
192
 
254
193
  Usage:
255
194
  everystack secrets list --stage <name> List all secret names
@@ -264,7 +203,7 @@ Examples:
264
203
  everystack secrets remove OLD_KEY --stage dev
265
204
 
266
205
  Interop:
267
- Secrets are stored at the same SSM path as \`sst secret set\`.
268
- Both tools read and write the same values — full interop.
206
+ Secrets are stored in the SST Ion S3 state backend with AES-256-GCM encryption.
207
+ Both tools read and write the same values — full interop with \`sst secret\`.
269
208
  `);
270
209
  }
@@ -60,10 +60,10 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
60
60
  // Stage may not be deployed yet — HOST_URL falls back to loadSstOutputs
61
61
  }
62
62
 
63
- // Load SST secrets for this stage and inject into process.env.
64
- // This replaces .env.local during `everystack update` — same pattern
65
- // as EAS injecting stored secrets during `eas update`.
66
- // app.config.js merges { ...easInfo, ...process.env }, so these win.
63
+ // Load EXPO_PUBLIC_* secrets for this stage and inject into process.env.
64
+ // Only public secrets are injected — server secrets (DATABASE_URL, JWT_SECRET, etc.)
65
+ // are provided by SST Resource linking at Lambda runtime.
66
+ // app.config.js merges { ...easInfo, ...process.env }, so these win over .env.local.
67
67
  try {
68
68
  step('Loading SST secrets...');
69
69
  const { parseAppName } = await import('../discover.js');
@@ -71,20 +71,36 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
71
71
  const region = resolvedConfig?.region
72
72
  || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1';
73
73
  const secrets = await loadSstSecrets({ appName, stage: flags.stage, region });
74
- const names = Object.keys(secrets);
75
- if (names.length > 0) {
76
- for (const [key, value] of Object.entries(secrets)) {
74
+ const allNames = Object.keys(secrets);
75
+ const publicSecrets = Object.entries(secrets).filter(([k]) => k.startsWith('EXPO_PUBLIC_'));
76
+ const skipped = allNames.length - publicSecrets.length;
77
+ if (publicSecrets.length > 0) {
78
+ for (const [key, value] of publicSecrets) {
77
79
  process.env[key] ||= value;
78
80
  }
79
- success(`Loaded ${names.length} secrets: ${names.join(', ')}`);
81
+ success(`Injected ${publicSecrets.length} EXPO_PUBLIC_* secrets: ${publicSecrets.map(([k]) => k).join(', ')}`);
82
+ } else if (allNames.length > 0) {
83
+ info('No EXPO_PUBLIC_* secrets found (server secrets are provided by SST at runtime)');
80
84
  } else {
81
85
  info('No SST secrets found for this stage');
82
86
  }
87
+ if (skipped > 0) {
88
+ info(`Skipped ${skipped} server secret(s) — provided by SST Resource linking at Lambda runtime`);
89
+ }
83
90
  } catch (err: any) {
84
- // Secret loading is best-effort — the user may not have SSM permissions
91
+ // Secret loading is best-effort — the user may not have permissions
85
92
  // or may not use SST secrets (e.g. env vars set manually).
86
- warn(`Could not load SST secrets: ${err.message}`);
87
- info('Set secrets with: pnpm sst secret set KEY "value" --stage ' + flags.stage);
93
+ const msg = err.message || String(err);
94
+ if (msg.includes("Cannot find package '@aws-sdk/client-ssm'")) {
95
+ warn('Secrets skipped: @aws-sdk/client-ssm not installed');
96
+ info('Install with: pnpm add -D @aws-sdk/client-ssm');
97
+ } else if (msg.includes("Cannot find package '@aws-sdk/client-s3'")) {
98
+ warn('Secrets skipped: @aws-sdk/client-s3 not installed');
99
+ info('Install with: pnpm add -D @aws-sdk/client-s3');
100
+ } else {
101
+ warn(`Could not load SST secrets: ${msg}`);
102
+ }
103
+ info('Set secrets with: everystack secrets set KEY "value" --stage ' + flags.stage);
88
104
  }
89
105
  }
90
106
 
@@ -1,14 +1,21 @@
1
1
  /**
2
- * Load SST secrets from AWS SSM Parameter Store.
2
+ * Load SST Ion secrets from S3 state backend.
3
3
  *
4
- * SST v3 stores secrets at: /sst/{appName}/{stage}/Secret/{name}/value
5
- * These are SecureString parameters encrypted with the default KMS key.
4
+ * SST Ion stores secrets as AES-256-GCM encrypted JSON in S3:
5
+ * Bucket: sst-state-{random} (from /sst/bootstrap SSM parameter)
6
+ * Key: secret/{appName}/{stage}.json
6
7
  *
7
- * The CLI fetches all secrets for a stage and returns them as a flat
8
- * key-value map. The caller injects them into process.env before
9
- * expo export, so app.config.js picks them up via `...process.env`.
8
+ * The encryption passphrase is stored in SSM at:
9
+ * /sst/passphrase/{appName}/{stage} (SecureString)
10
+ *
11
+ * Flow: fetch passphrase from SSM → fetch encrypted blob from S3 → AES-GCM decrypt → JSON parse.
12
+ *
13
+ * Requires IAM permissions: ssm:GetParameter, s3:GetObject, kms:Decrypt.
10
14
  */
11
15
 
16
+ import crypto from 'node:crypto';
17
+ import { gunzipSync } from 'node:zlib';
18
+
12
19
  export interface LoadSecretsOptions {
13
20
  appName: string;
14
21
  stage: string;
@@ -16,49 +23,173 @@ export interface LoadSecretsOptions {
16
23
  }
17
24
 
18
25
  /**
19
- * Fetch all SST secrets for a given app + stage from SSM Parameter Store.
26
+ * Resolve the SST state bucket name from the /sst/bootstrap SSM parameter.
27
+ */
28
+ async function getStateBucket(region: string): Promise<string> {
29
+ const { SSMClient, GetParameterCommand } = await import('@aws-sdk/client-ssm');
30
+ const client = new SSMClient({ region });
31
+ const result = await client.send(
32
+ new GetParameterCommand({
33
+ Name: '/sst/bootstrap',
34
+ WithDecryption: false,
35
+ }),
36
+ );
37
+ if (!result.Parameter?.Value) {
38
+ throw new Error('SST bootstrap not found — has `sst deploy` been run?');
39
+ }
40
+ const data = JSON.parse(result.Parameter.Value);
41
+ if (!data.state) {
42
+ throw new Error('SST bootstrap missing state bucket');
43
+ }
44
+ return data.state as string;
45
+ }
46
+
47
+ /**
48
+ * Fetch the passphrase for a given app + stage from SSM.
49
+ */
50
+ async function getPassphrase(region: string, appName: string, stage: string): Promise<string> {
51
+ const { SSMClient, GetParameterCommand } = await import('@aws-sdk/client-ssm');
52
+ const client = new SSMClient({ region });
53
+ const result = await client.send(
54
+ new GetParameterCommand({
55
+ Name: `/sst/passphrase/${appName}/${stage}`,
56
+ WithDecryption: true,
57
+ }),
58
+ );
59
+ if (!result.Parameter?.Value) {
60
+ throw new Error(`No SST passphrase found for ${appName}/${stage}`);
61
+ }
62
+ return result.Parameter.Value;
63
+ }
64
+
65
+ /**
66
+ * Fetch the encrypted secrets blob from S3.
67
+ */
68
+ async function getSecretBlob(
69
+ region: string,
70
+ bucket: string,
71
+ appName: string,
72
+ stage: string,
73
+ ): Promise<Buffer | null> {
74
+ const { S3Client, GetObjectCommand } = await import('@aws-sdk/client-s3');
75
+ const client = new S3Client({ region });
76
+ try {
77
+ const result = await client.send(
78
+ new GetObjectCommand({
79
+ Bucket: bucket,
80
+ Key: `secret/${appName}/${stage}.json`,
81
+ }),
82
+ );
83
+ if (!result.Body) return null;
84
+ const bytes = await result.Body.transformToByteArray();
85
+ return Buffer.from(bytes);
86
+ } catch (err: any) {
87
+ if (err.name === 'NoSuchKey' || err.Code === 'NoSuchKey') {
88
+ return null;
89
+ }
90
+ throw err;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Decrypt AES-256-GCM ciphertext using a base64-encoded passphrase.
96
+ *
97
+ * SST Ion format: nonce (12 bytes) + ciphertext + auth tag (16 bytes appended by GCM Seal).
98
+ * Go's gcm.Seal() appends the tag to the ciphertext, so we split accordingly.
99
+ */
100
+ function decrypt(encrypted: Buffer, passphrase: string): Buffer {
101
+ const key = Buffer.from(passphrase, 'base64');
102
+ const nonceSize = 12; // AES-GCM standard nonce size
103
+ const tagSize = 16; // AES-GCM auth tag size
104
+
105
+ if (encrypted.length < nonceSize + tagSize) {
106
+ throw new Error('Encrypted data too short');
107
+ }
108
+
109
+ const nonce = encrypted.subarray(0, nonceSize);
110
+ const ciphertextWithTag = encrypted.subarray(nonceSize);
111
+ const ciphertext = ciphertextWithTag.subarray(0, ciphertextWithTag.length - tagSize);
112
+ const tag = ciphertextWithTag.subarray(ciphertextWithTag.length - tagSize);
113
+
114
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
115
+ decipher.setAuthTag(tag);
116
+
117
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
118
+ return decrypted;
119
+ }
120
+
121
+ /**
122
+ * Fetch all SST Ion secrets for a given app + stage.
20
123
  *
21
124
  * Returns a map of { SECRET_NAME: decrypted_value }.
22
- * Paginates automatically — SSM returns max 10 parameters per page.
23
125
  *
24
- * Requires IAM permissions: ssm:GetParametersByPath, kms:Decrypt.
126
+ * Requires IAM permissions: ssm:GetParameter, s3:GetObject, kms:Decrypt.
25
127
  */
26
128
  export async function loadSstSecrets(
27
129
  options: LoadSecretsOptions,
28
130
  ): Promise<Record<string, string>> {
29
- const { SSMClient, GetParametersByPathCommand } = await import(
30
- '@aws-sdk/client-ssm'
31
- );
131
+ const { appName, stage, region } = options;
32
132
 
33
- const client = new SSMClient({ region: options.region });
34
- const prefix = `/sst/${options.appName}/${options.stage}/Secret/`;
35
- const secrets: Record<string, string> = {};
36
-
37
- let nextToken: string | undefined;
38
- do {
39
- const response = await client.send(
40
- new GetParametersByPathCommand({
41
- Path: prefix,
42
- WithDecryption: true,
43
- Recursive: true,
44
- NextToken: nextToken,
45
- }),
46
- );
133
+ // 1. Get the state bucket name from /sst/bootstrap
134
+ const bucket = await getStateBucket(region);
47
135
 
48
- for (const param of response.Parameters || []) {
49
- if (!param.Name || param.Value === undefined) continue;
50
- // Path: /sst/{app}/{stage}/Secret/{NAME}/value → extract NAME
51
- const parts = param.Name.split('/');
52
- // parts: ['', 'sst', app, stage, 'Secret', NAME, 'value']
53
- const secretIndex = parts.indexOf('Secret');
54
- if (secretIndex >= 0 && secretIndex + 1 < parts.length) {
55
- const name = parts[secretIndex + 1];
56
- secrets[name] = param.Value;
57
- }
58
- }
136
+ // 2. Get the passphrase from SSM
137
+ const passphrase = await getPassphrase(region, appName, stage);
138
+
139
+ // 3. Fetch the encrypted secrets blob from S3
140
+ let blob = await getSecretBlob(region, bucket, appName, stage);
141
+ if (!blob) {
142
+ return {};
143
+ }
144
+
145
+ // 4. Try gzip decompress (SST may compress the data)
146
+ try {
147
+ blob = gunzipSync(blob);
148
+ } catch {
149
+ // Not gzipped, use raw
150
+ }
59
151
 
60
- nextToken = response.NextToken;
61
- } while (nextToken);
152
+ // 5. Decrypt with AES-256-GCM
153
+ const decrypted = decrypt(blob, passphrase);
62
154
 
155
+ // 6. Parse the JSON map
156
+ const secrets: Record<string, string> = JSON.parse(decrypted.toString('utf8'));
63
157
  return secrets;
64
158
  }
159
+
160
+ /**
161
+ * Encrypt and store secrets to the SST Ion S3 state backend.
162
+ */
163
+ export async function putSstSecrets(
164
+ options: LoadSecretsOptions,
165
+ secrets: Record<string, string>,
166
+ ): Promise<void> {
167
+ const { appName, stage, region } = options;
168
+
169
+ const bucket = await getStateBucket(region);
170
+ const passphrase = await getPassphrase(region, appName, stage);
171
+
172
+ // Encrypt with AES-256-GCM (same format as SST Ion's Go code)
173
+ const key = Buffer.from(passphrase, 'base64');
174
+ const nonce = crypto.randomBytes(12);
175
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
176
+
177
+ const jsonBytes = Buffer.from(JSON.stringify(secrets), 'utf8');
178
+ const encrypted = Buffer.concat([cipher.update(jsonBytes), cipher.final()]);
179
+ const tag = cipher.getAuthTag();
180
+
181
+ // Format: nonce + ciphertext + tag (matches Go's gcm.Seal output)
182
+ const blob = Buffer.concat([nonce, encrypted, tag]);
183
+
184
+ // Write to S3
185
+ const { S3Client, PutObjectCommand } = await import('@aws-sdk/client-s3');
186
+ const client = new S3Client({ region });
187
+ await client.send(
188
+ new PutObjectCommand({
189
+ Bucket: bucket,
190
+ Key: `secret/${appName}/${stage}.json`,
191
+ Body: blob,
192
+ ContentType: 'application/json',
193
+ }),
194
+ );
195
+ }