@everystack/cli 0.4.64 → 0.4.66

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.66",
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>",
@@ -126,7 +126,7 @@
126
126
  "structured-headers": "1.0.1",
127
127
  "tsx": "4.21.0",
128
128
  "typescript": "5.9.3",
129
- "@everystack/model": "0.4.16"
129
+ "@everystack/model": "0.4.17"
130
130
  },
131
131
  "peerDependencies": {
132
132
  "@everystack/server": ">=0.4.0",
@@ -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",
@@ -331,54 +331,53 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
331
331
  const isRoleRead = (a: Ability): boolean =>
332
332
  a.action === 'read' && Boolean(a.condition.role) && !isColumnAbility(a);
333
333
 
334
- // Author-supplied predicates, per action. `manage` carries no predicate of its own —
335
- // it is the admin bypass so only the specific verbs are consulted.
334
+ // `manage` + owner/via desugars into four verb abilities for policy emission.
335
+ // Role-scoped `manage` (the admin bypass) passes through it matches `hasAdminManage`
336
+ // directly. Grants handle `manage` natively via `verbsFor`, so this expansion is
337
+ // policy-compiler-local: `compileGrants` still reads `model.abilities`.
338
+ const abilities: readonly Ability[] = model.abilities.flatMap((a) => {
339
+ if (a.action !== 'manage' || !rowScoped(a)) return [a];
340
+ const c = a.condition;
341
+ return [
342
+ { action: 'read' as const, condition: c },
343
+ { action: 'create' as const, condition: c },
344
+ { action: 'update' as const, condition: c },
345
+ { action: 'delete' as const, condition: c },
346
+ ];
347
+ });
348
+
336
349
  const predFor = (action: Ability['action']): string | null => {
337
- for (const a of model.abilities) {
350
+ for (const a of abilities) {
338
351
  if (a.action !== action || isRoleRead(a) || isColumnAbility(a)) continue;
339
352
  const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('${action}')`);
340
353
  if (p) return p;
341
354
  }
342
355
  return null;
343
356
  };
344
- /**
345
- * The predicate on the PUBLIC read — a read ability with neither a role nor a row scope.
346
- *
347
- * Read separately from {@link ownerReadPred} because the two are different BRANCHES of
348
- * the same authenticated policy. Taking both from one ordered lookup made the anon
349
- * policy depend on the order the abilities happened to be declared in: put the owner
350
- * read first and `anon` inherited the owner branch's guard as its whole public rule.
351
- */
352
357
  const publicReadPred = (): string | null => {
353
- for (const a of model.abilities) {
358
+ for (const a of abilities) {
354
359
  if (a.action !== 'read' || a.condition.role || rowScoped(a)) continue;
355
360
  const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('read')`);
356
361
  if (p) return p;
357
362
  }
358
363
  return null;
359
364
  };
360
-
361
- /** The predicate the OWNER read narrows ITSELF by — `can('read', { owner, sql })`. */
362
365
  const ownerReadPred = (): string | null => {
363
- for (const a of model.abilities) {
366
+ for (const a of abilities) {
364
367
  if (a.action !== 'read' || !rowScoped(a) || isColumnAbility(a)) continue;
365
368
  const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('read', { owner })`);
366
369
  if (p) return p;
367
370
  }
368
371
  return null;
369
372
  };
370
-
371
- /** The WRITE half, when the author declared one distinct from the read half. */
372
373
  const checkFor = (action: Ability['action']): string | null => {
373
- for (const a of model.abilities) {
374
+ for (const a of abilities) {
374
375
  if (a.action !== action) continue;
375
376
  const p = rawPredicate(a.condition.check, `${table}: can('${action}', { check })`);
376
377
  if (p) return p;
377
378
  }
378
379
  return null;
379
380
  };
380
-
381
- const abilities = model.abilities;
382
381
  const hasAdminManage = abilities.some((a) => a.action === 'manage' && a.condition.role === 'admin');
383
382
  const hasPublicRead = abilities.some((a) => a.action === 'read' && !a.condition.role && !rowScoped(a));
384
383
  // The audience is open when the public read says so. Read off the SAME abilities that make
@@ -569,8 +568,8 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
569
568
  // key, and undefined must mean ENABLED — the pre-flag behavior — never a silent
570
569
  // security downgrade via version skew.
571
570
  rls: { enabled: model.rls !== false, forced: model.rls !== false && model.writtenBy === 'app' },
572
- grants: compileGrants(abilities, model.privileges),
573
- ...(compileColumnGrants(abilities) ? { columnGrants: compileColumnGrants(abilities) } : {}),
571
+ grants: compileGrants(model.abilities, model.privileges),
572
+ ...(compileColumnGrants(model.abilities) ? { columnGrants: compileColumnGrants(model.abilities) } : {}),
574
573
  policies,
575
574
  };
576
575
  }
@@ -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
 
@@ -189,6 +189,7 @@ export function findSecdefExecuteGaps(derived: readonly DerivedDescriptor[]): De
189
189
  for (const d of derived) {
190
190
  if (d.kind !== 'function' || d.security !== 'definer' || d.abilities.length > 0) continue;
191
191
  if (d.returns === 'trigger') continue;
192
+ if (d.internal) continue;
192
193
  if (Object.keys(d.privileges ?? {}).length > 0) continue;
193
194
  const identity = identityOf(d);
194
195
  gaps.push({