@everystack/cli 0.4.64 → 0.4.65

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.64",
3
+ "version": "0.4.65",
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>",
@@ -177,7 +177,7 @@
177
177
  "jest": "29.7.0",
178
178
  "react": "19.2.0",
179
179
  "ts-jest": "29.4.9",
180
- "@everystack/server": "0.4.25"
180
+ "@everystack/server": "0.4.26"
181
181
  },
182
182
  "scripts": {
183
183
  "test": "jest",
@@ -501,41 +501,27 @@ async function rotateCredentials(flags: Record<string, string>): Promise<void> {
501
501
  process.exit(1);
502
502
  }
503
503
 
504
- // Resolve the direct connection (the rotate command always runs direct — it needs
505
- // to query pg_roles and ALTER ROLE, which the ops action doesn't expose).
506
- const adminUrl = stageSecrets.ADMIN_DATABASE_URL ?? stageSecrets.AdminDatabaseUrl;
507
- if (!adminUrl) {
508
- fail('No ADMIN_DATABASE_URL secret found. Run db:provision first to set up the credential split.');
509
- process.exit(1);
510
- }
511
-
512
- // Step 0: preflight — verify the master credential works (break-glass path).
513
- step('Preflight: verifying master connection...');
504
+ // Step 0: preflight verify the operator connection works (break-glass path).
505
+ step('Preflight: verifying operator connection...');
506
+ const opsFn = opsFunction(config);
514
507
  try {
515
- const result = await invokeAction(config.region, opsFunction(config), 'db:query', { sql: 'SELECT current_user' });
508
+ const result = await invokeAction(config.region, opsFn, 'db:query', { sql: 'SELECT current_user' });
516
509
  if ((result as any)?.error) throw new Error((result as any).error);
517
- success('Master connection verified.');
510
+ success('Operator connection verified.');
518
511
  } catch (err: any) {
519
- fail(`Master connection failed: ${err.message}`);
520
- info('The master credential is the break-glass. Fix it before rotating.');
512
+ fail(`Operator connection failed: ${err.message}`);
513
+ info('Fix the operator connection before rotating.');
521
514
  process.exit(1);
522
515
  }
523
516
 
524
- // Connect directly for role management.
525
- const { createUrlRunner } = await import('../db-source');
526
- const { runner, end } = await createUrlRunner(adminUrl);
527
- const deps = {
528
- query: async (sql: string) => runner(sql) as Promise<any[]>,
529
- execute: async (sql: string) => { await runner(sql); },
517
+ // All DB operations run through the ops Lambda (inside the VPC).
518
+ const rotate = async (action: string, payload?: Record<string, unknown>) => {
519
+ const result = await invokeAction(config.region, opsFn, 'db:rotate', { action, ...payload });
520
+ if ((result as any)?.error) throw new Error((result as any).error);
521
+ return result as any;
530
522
  };
531
523
 
532
524
  try {
533
- const {
534
- detectActiveRole,
535
- provisionInactiveRole,
536
- retireRole,
537
- drainRole,
538
- } = await import('@everystack/server/rotate');
539
525
 
540
526
  // Detect which slots to rotate.
541
527
  const slotFlag = flags.slot || 'both';
@@ -552,8 +538,19 @@ async function rotateCredentials(flags: Record<string, string>): Promise<void> {
552
538
  }
553
539
 
554
540
  const { randomBytes } = await import('node:crypto');
555
- const conn = parseUrlConnection(adminUrl);
556
- const sourceUrl = adminUrl;
541
+
542
+ // Resolve host/port/database from the ops Lambda (same as db:provision does).
543
+ let conn: { host: string; port: string; database: string } | undefined;
544
+ try {
545
+ const psqlInfo = await invokeAction(config.region, opsFn, 'db:psql', {});
546
+ if ((psqlInfo as any)?.host) conn = psqlInfo as any;
547
+ } catch { /* handled below */ }
548
+ if (!conn?.host) {
549
+ fail('Could not resolve database host from the ops Lambda.');
550
+ process.exit(1);
551
+ }
552
+ const sourceUrl = stageSecrets.ADMIN_DATABASE_URL ?? stageSecrets.AdminDatabaseUrl
553
+ ?? stageSecrets.DATABASE_URL ?? stageSecrets.DatabaseUrl;
557
554
 
558
555
  // Track the old active role for each slot so retire uses the RIGHT role (F1 fix).
559
556
  const oldActiveRoles: Array<{ base: string; oldRole: string }> = [];
@@ -563,23 +560,22 @@ async function rotateCredentials(flags: Record<string, string>): Promise<void> {
563
560
  for (const { base, secretKeys } of slots) {
564
561
  step(`Rotating ${base}...`);
565
562
 
566
- const detected = await detectActiveRole(base, deps);
567
- if (!detected) {
563
+ const detected = await rotate('detect', { baseRole: base });
564
+ if (!detected || (!detected.activeRole && !detected.inactiveRole)) {
568
565
  fail(`No rotation pairs found for ${base}. Run db:provision --rotation-pairs first.`);
569
566
  process.exit(1);
570
567
  }
571
568
  info(` Active: ${detected.activeRole}, inactive: ${detected.inactiveRole}`);
572
569
  oldActiveRoles.push({ base, oldRole: detected.activeRole });
573
- const active = detected;
574
570
 
575
- // Provision the inactive role.
571
+ // Provision the inactive role via the ops Lambda.
576
572
  const password = randomBytes(24).toString('hex');
577
- await provisionInactiveRole(active, password, deps);
578
- success(` ${active.inactiveRole} provisioned with new password.`);
573
+ const provisioned = await rotate('provision', { baseRole: base, password });
574
+ success(` ${detected.inactiveRole} provisioned with new password.`);
579
575
 
580
576
  // Build the new URL.
581
577
  const newUrl = preserveConnParams(
582
- `postgresql://${active.inactiveRole}:${password}@${conn.host}:${conn.port}/${conn.database}`,
578
+ `postgresql://${detected.inactiveRole}:${password}@${conn.host}:${conn.port}/${conn.database}`,
583
579
  sourceUrl,
584
580
  );
585
581
 
@@ -658,29 +654,19 @@ async function rotateCredentials(flags: Record<string, string>): Promise<void> {
658
654
  process.exit(1);
659
655
  }
660
656
 
661
- // Verify origin with a login probe — prove the new credential works
662
- // before revoking the old one.
657
+ // Verify the new credential can log in — prove it works before revoking
658
+ // the old one. Runs inside the VPC via the ops Lambda.
663
659
  step('Verifying new credential...');
664
- let verifyPassed = false;
665
- try {
666
- const { default: pg } = await import('postgres');
667
- const newApiUrl = credEnvOverrides.EVERYSTACK_CRED_DatabaseUrl;
668
- if (newApiUrl) {
669
- const probe = pg(newApiUrl, { max: 1, connect_timeout: 5, ssl: 'prefer' });
670
- try {
671
- await probe`SELECT 1`;
672
- verifyPassed = true;
673
- success('New credential connects to the database.');
674
- } finally {
675
- await probe.end({ timeout: 1 });
676
- }
677
- } else {
678
- verifyPassed = true;
660
+ const newApiUrl = credEnvOverrides.EVERYSTACK_CRED_DatabaseUrl;
661
+ if (newApiUrl) {
662
+ try {
663
+ await rotate('probe-login', { url: newApiUrl });
664
+ success('New credential connects to the database.');
665
+ } catch (err: any) {
666
+ fail(`New credential login failed: ${err.message}`);
667
+ info('Both roles keep LOGIN (safe state). Fix and re-run.');
668
+ process.exit(1);
679
669
  }
680
- } catch (err: any) {
681
- fail(`New credential login failed: ${err.message}`);
682
- info('Both roles keep LOGIN (safe state). Fix and re-run.');
683
- process.exit(1);
684
670
  }
685
671
 
686
672
  // Origin HTTP check (non-blocking — the login probe is the real gate).
@@ -700,34 +686,48 @@ async function rotateCredentials(flags: Record<string, string>): Promise<void> {
700
686
  // re-detection would pick the wrong one via the tiebreak).
701
687
  for (const { base, oldRole } of oldActiveRoles) {
702
688
  step(`Retiring ${oldRole}...`);
703
- await retireRole(oldRole, deps);
689
+ await rotate('retire', { role: oldRole });
704
690
  success(` ${oldRole} set to NOLOGIN.`);
705
691
 
706
- // Also retire the base role if it still has LOGIN (F6 fix: a leaked
707
- // pre-rotation credential must not survive rotation).
692
+ // Also retire the base role if it still has LOGIN (F6 fix).
708
693
  try {
709
- const baseRows = await deps.query(
710
- `SELECT rolcanlogin FROM pg_roles WHERE rolname = '${base}'`,
711
- );
712
- if (baseRows[0]?.rolcanlogin) {
713
- await retireRole(base, deps);
714
- success(` ${base} (base role) set to NOLOGIN.`);
694
+ const baseDetect = await rotate('detect', { baseRole: base });
695
+ if (baseDetect === null) {
696
+ // Base role check — query directly via db:query
697
+ const baseResult = await invokeAction(config.region, opsFn, 'db:query', {
698
+ sql: `SELECT rolcanlogin FROM pg_roles WHERE rolname = '${base}'`,
699
+ });
700
+ const rows = (baseResult as any)?.rows ?? baseResult;
701
+ if (Array.isArray(rows) && rows[0]?.rolcanlogin) {
702
+ await rotate('retire', { role: base });
703
+ success(` ${base} (base role) set to NOLOGIN.`);
704
+ }
715
705
  }
716
706
  } catch { /* best-effort */ }
717
707
 
718
708
  step(`Draining ${oldRole} sessions (grace: ${graceMs / 1000}s)...`);
719
- const { drained, terminated } = await drainRole(oldRole, graceMs, deps);
720
- if (drained && terminated === 0) {
721
- success(` ${oldRole} drained cleanly.`);
722
- } else if (drained) {
709
+ const deadline = Date.now() + graceMs;
710
+ const POLL_MS = 2000;
711
+ while (Date.now() < deadline) {
712
+ const { count } = await rotate('count-sessions', { role: oldRole });
713
+ if (count === 0) { success(` ${oldRole} drained cleanly.`); break; }
714
+ await new Promise((r) => setTimeout(r, POLL_MS));
715
+ }
716
+ const { count: remaining } = await rotate('count-sessions', { role: oldRole });
717
+ if (remaining > 0) {
718
+ const { terminated } = await rotate('terminate-sessions', { role: oldRole });
723
719
  success(` ${oldRole} drained (${terminated} session(s) terminated after grace period).`);
724
720
  }
725
721
  }
726
722
 
727
723
  success('Credential rotation complete.');
728
724
 
729
- } finally {
730
- await end?.();
725
+ } catch (err: any) {
726
+ if (err && (err as any).code !== 'EEXIT') {
727
+ fail(`Rotation error: ${err.message}`);
728
+ process.exit(1);
729
+ }
730
+ throw err;
731
731
  }
732
732
  }
733
733