@everystack/cli 0.4.4 → 0.4.5

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.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -144,6 +144,69 @@ export async function dbResetCommand(flags: Record<string, string>): Promise<voi
144
144
  }
145
145
  }
146
146
 
147
+ /** The username in a postgres URL — the only part of a stored secret we ever display. */
148
+ function roleOfUrl(url: string | undefined): string | null {
149
+ const match = url?.match(/^postgres(?:ql)?:\/\/([^:@/]+)[:@]/);
150
+ return match ? match[1] : null;
151
+ }
152
+
153
+ export interface ProvisionSecretPlan {
154
+ /** Key → URL updates, merged into the store in ONE write. */
155
+ updates: Record<string, string>;
156
+ /** Per-secret before → after role notes (roles only — never a credential). */
157
+ notes: string[];
158
+ /** Loud warnings: flipping a live secret, an unverified admin login, an old server. */
159
+ warnings: string[];
160
+ }
161
+
162
+ /**
163
+ * Decide what db:provision writes and what it announces. Both spellings of each secret
164
+ * are written — PascalCase is the only name an `sst.Secret` component can carry (so the
165
+ * wiring text and the store finally agree), the raw name keeps env-style consumers and
166
+ * `secrets export` working. Pure, so the announcement contract is pinned by tests.
167
+ */
168
+ export function buildProvisionSecretPlan(args: {
169
+ result: { loginRole: string; adminRole?: string; adminVerified?: boolean | null };
170
+ authUrl: string;
171
+ adminUrl?: string;
172
+ existing: Record<string, string>;
173
+ }): ProvisionSecretPlan {
174
+ const { result, authUrl, adminUrl, existing } = args;
175
+ const updates: Record<string, string> = {
176
+ DATABASE_URL: authUrl,
177
+ DatabaseUrl: authUrl,
178
+ };
179
+ const notes: string[] = [];
180
+ const warnings: string[] = [];
181
+
182
+ const prevAuthRole = roleOfUrl(existing.DATABASE_URL ?? existing.DatabaseUrl);
183
+ notes.push(`DATABASE_URL / DatabaseUrl: ${prevAuthRole ?? 'unset'} → ${result.loginRole}`);
184
+ if (prevAuthRole && prevAuthRole !== result.loginRole) {
185
+ warnings.push(
186
+ `DATABASE_URL was already set (role '${prevAuthRole}') — any function linked to it connects as '${result.loginRole}' after its next cold start. Ensure grants are in place: everystack db:reconcile && everystack db:doctor.`,
187
+ );
188
+ }
189
+
190
+ if (result.adminRole && adminUrl) {
191
+ updates.ADMIN_DATABASE_URL = adminUrl;
192
+ updates.AdminDatabaseUrl = adminUrl;
193
+ const prevAdminRole = roleOfUrl(existing.AdminDatabaseUrl ?? existing.ADMIN_DATABASE_URL);
194
+ const verified = result.adminVerified === true ? 'login verified' : 'login NOT verified';
195
+ notes.push(`AdminDatabaseUrl / ADMIN_DATABASE_URL: ${prevAdminRole ?? 'unset'} → ${result.adminRole} (${verified})`);
196
+ if (result.adminVerified !== true) {
197
+ warnings.push(
198
+ `The '${result.adminRole}' login could not be verified from the ops function — confirm operator connectivity before relying on it: everystack db:doctor.`,
199
+ );
200
+ }
201
+ } else {
202
+ warnings.push(
203
+ 'No operator login was created — this @everystack/server predates the both-sides split (<0.3.7). Operator tasks stay on the linked Postgres master; upgrade the server pin and re-run db:provision for the full credential split.',
204
+ );
205
+ }
206
+
207
+ return { updates, notes, warnings };
208
+ }
209
+
147
210
  export async function dbProvisionCommand(flags: Record<string, string>): Promise<void> {
148
211
  if (!flags.stage) {
149
212
  fail('--stage is required for db:provision (it writes the DATABASE_URL secret for that stage)');
@@ -163,26 +226,32 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
163
226
  info('Creating the least-privilege role chain on your EXISTING database (no database is created).');
164
227
  step('Provisioning roles...');
165
228
 
166
- // Generate the login password and keep it in memory only — it is written straight into
167
- // the secret store and is NEVER printed, logged, or returned to a human.
229
+ // Generate BOTH login passwords and keep them in memory only — they are written straight
230
+ // into the secret store and are NEVER printed, logged, or returned to a human.
168
231
  const { randomBytes } = await import('node:crypto');
169
232
  const authPassword = randomBytes(24).toString('hex');
233
+ const adminPassword = randomBytes(24).toString('hex');
170
234
 
171
235
  let result: any;
172
236
  try {
173
- result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword });
237
+ result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword, adminPassword });
174
238
  } catch (err: any) {
175
239
  fail(`db:provision failed: ${err.message}`);
176
240
  for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
177
241
  process.exit(1);
178
242
  }
179
243
  if (result?.error) {
244
+ // Includes the admin-verification refusal: the action set NO authenticator password,
245
+ // so nothing here rewrites DATABASE_URL — the working credentials are untouched.
180
246
  fail(`db:provision failed: ${result.error}`);
181
247
  process.exit(1);
182
248
  }
183
249
  success(`Role chain ready: ${result.loginRole} (LOGIN, NOINHERIT, no BYPASSRLS) → can SET ROLE to ${result.appRoles.join(', ')}`);
250
+ if (result.adminRole) {
251
+ success(`Operator login ready: ${result.adminRole} (LOGIN, INHERIT owner) — ${result.adminVerified === true ? 'connectivity verified' : 'connectivity NOT verified'}`);
252
+ }
184
253
 
185
- // Resolve host/port/db (not secret) to build the connection string in memory.
254
+ // Resolve host/port/db (not secret) to build the connection strings in memory.
186
255
  let conn: any;
187
256
  try {
188
257
  conn = await invokeAction(config.region, opsFunction(config), 'db:psql', {});
@@ -194,28 +263,42 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
194
263
  info('Provide a db:psql connectionInfo callback, or set the DATABASE_URL secret yourself.');
195
264
  process.exit(1);
196
265
  }
197
- const url = `postgresql://${result.loginRole}:${authPassword}@${conn.host}:${conn.port ?? 5432}/${conn.database}`;
198
-
199
- // Write the secret directly to the SST secret store — never display it.
200
- step('Writing the DATABASE_URL secret (not displayed)...');
266
+ const authUrl = `postgresql://${result.loginRole}:${authPassword}@${conn.host}:${conn.port ?? 5432}/${conn.database}`;
267
+ const adminUrl = result.adminRole
268
+ ? `postgresql://${result.adminRole}:${adminPassword}@${conn.host}:${conn.port ?? 5432}/${conn.database}`
269
+ : undefined;
270
+
271
+ // Write the secrets directly to the SST secret store — never display them. One blob
272
+ // write updates every key together, so there is no window where the API side is the
273
+ // authenticator but the operator side is still unset.
274
+ step('Writing the database secrets (not displayed)...');
275
+ let plan: ProvisionSecretPlan;
201
276
  try {
202
277
  const { loadSstSecrets, putSstSecrets } = await import('../utils/secrets.js');
203
278
  const { parseAppName } = await import('../discover.js');
204
279
  const ctx = { appName: await parseAppName(), stage: flags.stage, region: config.region };
205
280
  const secrets = await loadSstSecrets(ctx);
206
- secrets.DATABASE_URL = url;
281
+ plan = buildProvisionSecretPlan({ result, authUrl, adminUrl, existing: secrets });
282
+ Object.assign(secrets, plan.updates);
207
283
  await putSstSecrets(ctx, secrets);
208
284
  } catch (err: any) {
209
- fail(`Failed to write the DATABASE_URL secret: ${err.message}`);
285
+ fail(`Failed to write the database secrets: ${err.message}`);
210
286
  info('Ensure your IAM role can write the SST secret store (s3:PutObject, kms:Encrypt).');
211
287
  process.exit(1);
212
288
  }
213
289
 
214
- success(`DATABASE_URL secret set for stage "${flags.stage}" → role ${result.loginRole}. No credential was displayed.`);
290
+ success(`Secrets written for stage "${flags.stage}". No credential was displayed.`);
291
+ for (const note of plan.notes) info(` ${note}`);
292
+ for (const warning of plan.warnings) info(` ⚠ ${warning}`);
215
293
  console.log('');
216
- info('Finish the wiring in sst.config.ts (one-time): declare `new sst.Secret("DatabaseUrl")`,');
217
- info('link it to the Api function, and remove the raw Postgres component from the Api link');
218
- info('(keep it on the ops/worker functions). Then redeploy.');
294
+ info('Finish the wiring in sst.config.ts (one-time):');
295
+ info(' API: declare `new sst.Secret("DatabaseUrl")` and link it to the Api function; remove the');
296
+ info(' raw Postgres component from the Api link (keep it on the ops/worker functions).');
297
+ if (result.adminRole) {
298
+ info(' Ops: declare `new sst.Secret("AdminDatabaseUrl", "")` and link it to the Ops function —');
299
+ info(` operator tasks then run as ${result.adminRole}, not the Postgres master.`);
300
+ }
301
+ info('Then redeploy.');
219
302
  info(`Verify: everystack db:doctor --stage ${flags.stage}`);
220
303
  }
221
304