@everystack/cli 0.4.28 → 0.4.29

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.28",
3
+ "version": "0.4.29",
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>",
@@ -34,6 +34,7 @@ import { withMutationLease, MutationLeaseError } from '../mutation-lease.js';
34
34
  import { resolveConfig, opsFunction } from '../config.js';
35
35
  import { invokeAction, presignGet } from '../aws.js';
36
36
  import { keyForArtifactId, metaKey } from '../backup.js';
37
+ import { pgEnvFromUrl } from './db.js';
37
38
  import { step, success, fail, warn, info } from '../output.js';
38
39
 
39
40
  /** A COPY-aware line transform that rewrites the schema token on statement lines only. */
@@ -73,10 +74,17 @@ function schemaRewriteStream(from: string, to: string): Transform {
73
74
  * rewrite → psql. The archive names `<schema>`; the rewrite lands it as `<incoming>`, COPY-data-safe.
74
75
  */
75
76
  async function restoreIntoIncoming(url: string, artifactPath: string, schema: string, incoming: string): Promise<void> {
76
- // psql takes the connection URL directly (credential parsed by libpq, not on the process table
77
- // beyond argv-as-URL); pg_restore -f - just reads the archive file to SQL on stdout.
77
+ // Connect via PG* env, not `-d <url>`. libpq VALIDATES URI query params against its keyword
78
+ // list and REJECTS non-keywords like `search_path` ("invalid URI query parameter") and the
79
+ // operator URL db:operator-url mints bakes search_path in (fine for postgres.js, fatal for a
80
+ // libpq client). pgEnvFromUrl extracts only libpq keywords (dropping search_path, routing
81
+ // sslmode→PGSSLMODE) and puts the password in PGPASSWORD, off the process argv. pg_restore -f -
82
+ // just reads the archive file to SQL on stdout (no connection).
78
83
  const restore = spawn('pg_restore', ['-f', '-', artifactPath], { stdio: ['ignore', 'pipe', 'pipe'] });
79
- const psql = spawn('psql', ['-v', 'ON_ERROR_STOP=1', '-d', url], { stdio: ['pipe', 'ignore', 'pipe'] });
84
+ const psql = spawn('psql', ['-v', 'ON_ERROR_STOP=1'], {
85
+ stdio: ['pipe', 'ignore', 'pipe'],
86
+ env: { ...process.env, ...pgEnvFromUrl(url) },
87
+ });
80
88
  let rErr = '', pErr = '';
81
89
  restore.stderr.on('data', (d) => { rErr += d.toString(); });
82
90
  psql.stderr.on('data', (d) => { pErr += d.toString(); });
@@ -211,7 +211,7 @@ export function buildProvisionSecretPlan(args: {
211
211
  * same seam as pipeline-loader). */
212
212
  export interface ServerProvision {
213
213
  runProvision(
214
- payload: { authPassword?: string; adminPassword?: string },
214
+ payload: { authPassword?: string; adminPassword?: string; searchPath?: string[] },
215
215
  deps: {
216
216
  execute: (sql: string) => Promise<unknown>;
217
217
  connection?: { host: string; port?: number | string; database: string } | null;
@@ -473,6 +473,31 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
473
473
  info('Creating the least-privilege role chain on your EXISTING database (no database is created).');
474
474
  step('Provisioning roles...');
475
475
 
476
+ // The declared schema set — resolved BEFORE provisioning so it can be set as the ROLE default
477
+ // (ALTER ROLE … SET search_path), the right layer for bare-ref resolution (search-path-ownership).
478
+ // Union the DERIVED-object schemas (matviews/views live in schemas the barrel never names) so the
479
+ // authenticator flip doesn't go dark on bare-ref matview reads. Best-effort: a modelless or
480
+ // all-public app sets no path (unchanged behavior).
481
+ let declaredSchemas: string[] = [];
482
+ try {
483
+ const { resolveModelsPath } = await import('../models-path.js');
484
+ const { loadModels } = await import('./db-generate.js');
485
+ const models = await loadModels(resolveModelsPath(flags.models));
486
+ let derivedObjects: Array<{ schema: string }> = [];
487
+ try {
488
+ const { loadDeclaredDerived } = await import('../declared-derived.js');
489
+ const declared = await loadDeclaredDerived(flags.models);
490
+ if (declared) derivedObjects = declared.objects;
491
+ } catch {
492
+ // No derived layer resolvable — the table schemas still resolve correctly.
493
+ }
494
+ declaredSchemas = collectDeclaredSchemas({ models, derivedObjects });
495
+ } catch {
496
+ // No models resolvable here — leave the roles schema-agnostic.
497
+ }
498
+ const searchPath = declaredSearchPath(declaredSchemas);
499
+ if (searchPath.length) info(` Declared search_path (set as the role default via ALTER ROLE): ${searchPath.join(', ')}`);
500
+
476
501
  // Generate BOTH login passwords and keep them in memory only — they are written straight
477
502
  // into the secret store and are NEVER printed, logged, or returned to a human.
478
503
  const { randomBytes } = await import('node:crypto');
@@ -486,7 +511,7 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
486
511
  const { createUrlRunner } = await import('../db-source.js');
487
512
  const { runner, end } = await createUrlRunner(url);
488
513
  try {
489
- return await runProvision({ authPassword, adminPassword }, {
514
+ return await runProvision({ authPassword, adminPassword, searchPath }, {
490
515
  execute: async (statement) => runner(statement),
491
516
  connection: parseUrlConnection(url),
492
517
  // Same probe as the ops-Lambda venue: a real login as the new role, DDL-capable.
@@ -519,7 +544,7 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
519
544
  conn = parseUrlConnection(directUrl);
520
545
  } else {
521
546
  try {
522
- result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword, adminPassword });
547
+ result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword, adminPassword, searchPath });
523
548
  } catch (err: any) {
524
549
  // Auto-fallback: no Ops Lambda (Unknown action), but an ADMIN_DATABASE_URL secret exists —
525
550
  // provision directly over it. Master never crosses the CLI; the operator sets nothing new.
@@ -575,31 +600,6 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
575
600
  // wrong-database scare, and that check meant re-handling the master credential).
576
601
  success(`Target database: ${conn.database} @ ${conn.host}:${conn.port ?? 5432} — secrets for stage "${flags.stage}" will point here.`);
577
602
 
578
- // Bake the app's declared schemas into the minted URLs, so a multi-schema API connects with
579
- // the right search_path instead of going dark on bare refs (best-effort: a modelless app or
580
- // an all-public one changes nothing).
581
- let declaredSchemas: string[] = [];
582
- try {
583
- const { resolveModelsPath } = await import('../models-path.js');
584
- const { loadModels } = await import('./db-generate.js');
585
- const models = await loadModels(resolveModelsPath(flags.models));
586
- // Union the DERIVED-object schemas (matviews/views live in schemas the barrel never
587
- // names) so the authenticator flip doesn't go dark on bare-ref matview reads.
588
- let derivedObjects: Array<{ schema: string }> = [];
589
- try {
590
- const { loadDeclaredDerived } = await import('../declared-derived.js');
591
- const declared = await loadDeclaredDerived(flags.models);
592
- if (declared) derivedObjects = declared.objects;
593
- } catch {
594
- // No derived layer resolvable — the table schemas still bake correctly.
595
- }
596
- declaredSchemas = collectDeclaredSchemas({ models, derivedObjects });
597
- } catch {
598
- // No models resolvable here — leave the URLs schema-agnostic.
599
- }
600
- const searchPath = declaredSearchPath(declaredSchemas);
601
- if (searchPath.length) info(` search_path baked into DATABASE_URL: ${searchPath.join(', ')}`);
602
-
603
603
  // Preserve the connection params (sslmode, …) from the URL the app already connects with — the
604
604
  // minted URL is built from bare host/port/db components, so without this a `?sslmode=require`
605
605
  // would be dropped and the new roles could fail to connect on an SSL-forced RDS. Prefer the
@@ -608,11 +608,12 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
608
608
  ?? stageSecrets.ADMIN_DATABASE_URL ?? stageSecrets.AdminDatabaseUrl
609
609
  ?? stageSecrets.DATABASE_URL ?? stageSecrets.DatabaseUrl;
610
610
 
611
+ // The minted secret is credentials-only: search_path now lives on the ROLE (ALTER ROLE, above),
612
+ // not baked into the URL. This keeps the secret a pure credential and — critically — yields a
613
+ // libpq-clean URL (no ?search_path=, which psql/pg_restore reject as an unknown keyword). Real
614
+ // connection params (sslmode, …) are still carried from the source URL.
611
615
  const mint = (role: string, password: string): string =>
612
- withSearchPath(
613
- preserveConnParams(`postgresql://${role}:${password}@${conn.host}:${conn.port ?? 5432}/${conn.database}`, sourceUrl),
614
- declaredSchemas,
615
- );
616
+ preserveConnParams(`postgresql://${role}:${password}@${conn.host}:${conn.port ?? 5432}/${conn.database}`, sourceUrl);
616
617
 
617
618
  const authUrl = mint(result.loginRole, authPassword);
618
619
  const adminUrl = result.adminRole ? mint(result.adminRole, adminPassword) : undefined;
package/src/cli/index.ts CHANGED
@@ -357,7 +357,7 @@ Usage:
357
357
  everystack db:psql --stage <name> Interactive ADMIN psql (IAM-gated; resolves the admin URL in-process)
358
358
  everystack db:psql [--stage <name>] -c <command> Run one query via Lambda (works for private RDS)
359
359
  everystack db:doctor [--stage <name>] [--direct | --database-url <api> [--admin-database-url <ops>]] Check the DB is least-privilege + RLS-subject (api vs operator connection). No flag = ops-Lambda venue (auto-falls to direct via the stage secrets if the handler has no dbPlugin). --direct = probe both connections from the stage's DATABASE_URL + ADMIN_DATABASE_URL secrets; --database-url = explicit local venue (a lone URL is probed as both)
360
- everystack db:provision --stage <name> [--direct | --database-url <url>] Create the least-privilege role chain on an EXISTING database (idempotent; creates no DB). No flag = ops-Lambda venue (auto-falls to direct via the ADMIN_DATABASE_URL secret if the handler has no dbPlugin). --direct = direct connection reading that secret (master never on argv); --database-url = explicit URL. Declared schemas are baked into the minted DATABASE_URL as search_path
360
+ everystack db:provision --stage <name> [--direct | --database-url <url>] Create the least-privilege role chain on an EXISTING database (idempotent; creates no DB). No flag = ops-Lambda venue (auto-falls to direct via the ADMIN_DATABASE_URL secret if the handler has no dbPlugin). --direct = direct connection reading that secret (master never on argv); --database-url = explicit URL. Declared schemas are set as the ROLE default (ALTER ROLE … SET search_path) — the secret stays credentials-only, and the URL is libpq-clean
361
361
  everystack db:snapshot [--stage <name>] [--instance <id>] Take a physical RDS snapshot (instant DR point; RDS only — use db:backup for portable logical backups)
362
362
  everystack db:snapshots [--stage <name>] [--instance <id>] List manual RDS snapshots for the instance
363
363
  everystack db:backup:probe [--stage <name>] Verify the pg_dump layer is attached + version-compatible with the server