@everystack/cli 0.4.36 → 0.4.39

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.
@@ -9,9 +9,20 @@
9
9
  * can diff a deployed database against. `pull` is the export + on-ramp; `diff` is the
10
10
  * audit and the CI gate (non-zero exit on any drift).
11
11
  *
12
- * Both introspect through the ops Lambda `db:query` action (read-only SQL), the same
13
- * path security:audit uses. The core (introspect/assemble/diff) is backend-agnostic and
14
- * pure; only the runner here is AWS-bound.
12
+ * Two venues, one verdict. The core (introspect/assemble/diff/evaluate) is backend-agnostic
13
+ * and pure only the VENUE differs, and both satisfy the same `AuthzVenue` contract, so
14
+ * every command below runs identical evaluation code either way:
15
+ *
16
+ * - **Deployed** (`--stage`, the default): the ops Lambda's `db:query` + `db:authz:probe`
17
+ * actions. Credentials never leave AWS.
18
+ * - **Direct** (`--database-url`, or an inherited ADMIN_DATABASE_URL / DATABASE_URL): a
19
+ * postgres.js connection from this process.
20
+ *
21
+ * The direct venue is what makes a brownfield authz migration rehearsable. Translating
22
+ * hand-written RLS into Model abilities is the hardest part of an adoption, and until the
23
+ * local venue existed it was the ONLY part that required a deployed stage to iterate
24
+ * against. Every run names its venue, because a security verdict against an unintended
25
+ * database is worse than no verdict.
15
26
  */
16
27
 
17
28
  import path from 'node:path';
@@ -29,6 +40,8 @@ import {
29
40
  evaluateRedTeam,
30
41
  GRANT_COMPLETENESS_SQL,
31
42
  toGrantGap,
43
+ buildInheritedPrivilegeSql,
44
+ toInheritedGrant,
32
45
  } from '../authz-redteam.js';
33
46
  import {
34
47
  buildOwnerProbeSql,
@@ -43,6 +56,7 @@ import { renderContractMarkdown } from '../authz-render.js';
43
56
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
44
57
  import { resolveConfig, opsFunction } from '../config.js';
45
58
  import { resolveModelsPath } from '../models-path.js';
59
+ import { resolveDbSource, connectingVia, createUrlProbeRunner } from '../db-source.js';
46
60
  import { invokeAction } from '../aws.js';
47
61
  import { step, success, fail, info, warn } from '../output.js';
48
62
  import { opsAdviceLines, IAM_ADVICE } from '../ops-advice.js';
@@ -59,20 +73,65 @@ function lambdaRunner(region: string, fn: string): QueryRunner {
59
73
  };
60
74
  }
61
75
 
62
- async function introspectStage(flags: Record<string, string>): Promise<AuthzContract> {
76
+ /**
77
+ * Where an authz command runs. Both venues expose the same two capabilities, so the
78
+ * commands never branch on venue after this point — that is what keeps the local
79
+ * rehearsal honest: same SQL, same evaluation, same verdict.
80
+ */
81
+ interface AuthzVenue {
82
+ /** Read-only introspection. */
83
+ runner: QueryRunner;
84
+ /** Self-reverting red-team probe (writes, always rolled back). */
85
+ probe: (setup: string, read: string) => Promise<any[]>;
86
+ /** Human-readable venue, printed with every verdict. */
87
+ label: string;
88
+ /** Release any connection this venue holds. */
89
+ end: () => Promise<void>;
90
+ }
91
+
92
+ /**
93
+ * Precedence is `resolveDbSource`'s, shared with db:pull / db:generate / db:check:
94
+ * `--database-url` > explicit `--stage` > ADMIN_DATABASE_URL > DATABASE_URL > default stage.
95
+ */
96
+ async function resolveVenue(flags: Record<string, string>): Promise<AuthzVenue> {
97
+ const source = resolveDbSource(flags);
98
+ if (source.kind === 'url') {
99
+ step(connectingVia(source));
100
+ const { runner, probe, end } = await createUrlProbeRunner(source.url);
101
+ return { runner, probe, end, label: 'direct connection' };
102
+ }
63
103
  step('Resolving deployed config...');
64
104
  const config = await resolveConfig(flags.stage);
65
- info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
66
- step('Introspecting authorization (rls + grants + policies + secdef)...');
67
- const runner = lambdaRunner(config.region, opsFunction(config));
68
- return introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
105
+ const fn = opsFunction(config);
106
+ info(`Region: ${config.region}, Function: ${fn}`);
107
+ return {
108
+ runner: lambdaRunner(config.region, fn),
109
+ probe: async (setup: string, read: string) => {
110
+ const result: any = await invokeAction(config.region, fn, 'db:authz:probe', { setup, read });
111
+ if (result?.error) throw new Error(result.error);
112
+ return result?.rows ?? [];
113
+ },
114
+ label: flags.stage ? `stage ${flags.stage}` : 'deployed stage (.sst/outputs.json)',
115
+ end: async () => {},
116
+ };
117
+ }
118
+
119
+ async function introspectVenue(flags: Record<string, string>): Promise<{ contract: AuthzContract; label: string }> {
120
+ const venue = await resolveVenue(flags);
121
+ try {
122
+ step('Introspecting authorization (rls + grants + policies + secdef)...');
123
+ return { contract: await introspectContract(venue.runner, contractFunctionRow, FUNCTIONS_SQL), label: venue.label };
124
+ } finally {
125
+ await venue.end();
126
+ }
69
127
  }
70
128
 
71
129
  export async function dbAuthzPullCommand(flags: Record<string, string>): Promise<void> {
72
130
  const dir = path.resolve(flags.dir || flags.out || DEFAULT_DIR);
73
131
  let contract: AuthzContract;
132
+ let venueLabel: string;
74
133
  try {
75
- contract = await introspectStage(flags);
134
+ ({ contract, label: venueLabel } = await introspectVenue(flags));
76
135
  } catch (err: any) {
77
136
  fail(err.message);
78
137
  for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
@@ -82,7 +141,7 @@ export async function dbAuthzPullCommand(flags: Record<string, string>): Promise
82
141
  const files = await writeContract(dir, contract);
83
142
  const policies = contract.tables.reduce((n, t) => n + t.policies.length, 0);
84
143
  console.log('');
85
- success(`Wrote ${files.length} file(s) to ${dir}`);
144
+ success(`Wrote ${files.length} file(s) to ${dir} (from ${venueLabel})`);
86
145
  info(`${contract.tables.length} table(s), ${policies} policy(ies), ${contract.functions.length} SECDEF function(s).`);
87
146
  console.log('');
88
147
  warn('pull OVERWROTE the committed contract with the live state — `git diff` IS your drift report.');
@@ -97,9 +156,10 @@ export async function dbAuthzDiffCommand(flags: Record<string, string>): Promise
97
156
 
98
157
  let declared: AuthzContract;
99
158
  let live: AuthzContract;
159
+ let venueLabel: string;
100
160
  try {
101
161
  declared = await loadContract(dir);
102
- live = await introspectStage(flags);
162
+ ({ contract: live, label: venueLabel } = await introspectVenue(flags));
103
163
  } catch (err: any) {
104
164
  fail(err.message);
105
165
  process.exit(1);
@@ -113,11 +173,11 @@ export async function dbAuthzDiffCommand(flags: Record<string, string>): Promise
113
173
  const findings = diffContracts(declared, live);
114
174
  console.log('');
115
175
  if (findings.length === 0) {
116
- success(`db:authz:diff — live database matches the declared contract (${declared.tables.length} tables)`);
176
+ success(`db:authz:diff — ${venueLabel} matches the declared contract (${declared.tables.length} tables)`);
117
177
  process.exit(0);
118
178
  }
119
179
 
120
- fail(`db:authz:diff — ${findings.length} drift finding(s): the live database does NOT match the declared contract`);
180
+ fail(`db:authz:diff — ${findings.length} drift finding(s): ${venueLabel} does NOT match the declared contract`);
121
181
  console.log('');
122
182
  for (const f of findings) {
123
183
  warn(`[${f.kind}] ${f.subject} — ${f.detail}`);
@@ -163,36 +223,49 @@ export async function dbAuthzTestCommand(flags: Record<string, string>): Promise
163
223
 
164
224
  let rows: any[];
165
225
  let gapRows: any[];
226
+ let inheritedRows: any[];
227
+ let venueLabel: string;
228
+ let venue: AuthzVenue | undefined;
166
229
  try {
167
- step('Resolving deployed config...');
168
- const config = await resolveConfig(flags.stage);
169
- const fn = opsFunction(config);
170
- info(`Region: ${config.region}, Function: ${fn}`);
230
+ venue = await resolveVenue(flags);
231
+ venueLabel = venue.label;
232
+ const roles = probeRoles(contract);
233
+ const tables = contract.tables.map((t) => t.table);
171
234
  step('Red-teaming enforcement (SET ROLE + attempt per role/table/command, rolled back)...');
172
- const setup = buildProbeSql(probeRoles(contract), contract.tables.map((t) => t.table));
173
- const result: any = await invokeAction(config.region, fn, 'db:authz:probe', { setup, read: PROBE_SELECT_SQL });
174
- if (result?.error) throw new Error(result.error);
175
- rows = result?.rows ?? [];
235
+ rows = await venue.probe(buildProbeSql(roles, tables), PROBE_SELECT_SQL);
236
+ step('Attributing undeclared privileges to role membership...');
237
+ inheritedRows = await venue.runner(buildInheritedPrivilegeSql(roles, tables));
176
238
  step('Checking SECDEF grant-completeness (owner can EXECUTE every helper it calls)...');
177
- const gc: any = await invokeAction(config.region, fn, 'db:query', { sql: GRANT_COMPLETENESS_SQL });
178
- if (gc?.error) throw new Error(gc.error);
179
- gapRows = gc?.rows ?? [];
239
+ gapRows = await venue.runner(GRANT_COMPLETENESS_SQL);
180
240
  } catch (err: any) {
181
241
  fail(`db:authz:test failed: ${err.message}`);
182
- info('Ensure the stage is deployed with @everystack/server >= the version that adds db:authz:probe.');
242
+ info('Ensure the stage is deployed with @everystack/server >= the version that adds db:authz:probe,');
243
+ info('or rehearse locally: db:authz:test --database-url <url>.');
183
244
  process.exit(1);
245
+ } finally {
246
+ await venue?.end();
184
247
  }
185
248
 
186
- const findings = evaluateRedTeam(contract, rows.map(toProbeResult));
249
+ const findings = evaluateRedTeam(contract, rows.map(toProbeResult), inheritedRows.map(toInheritedGrant));
187
250
  const holes = findings.filter((f) => f.severity === 'hole');
188
251
  const broken = findings.filter((f) => f.severity === 'broken');
189
252
  const unprobed = findings.filter((f) => f.severity === 'unprobed');
253
+ const inconclusive = findings.filter((f) => f.severity === 'inconclusive');
254
+ const inheritedFindings = findings.filter((f) => f.severity === 'inherited');
190
255
  const gaps = gapRows.map(toGrantGap);
191
256
 
192
257
  console.log('');
193
258
  for (const f of holes) fail(`[HOLE] ${f.detail}`);
194
259
  for (const f of broken) warn(`[BROKEN] ${f.detail}`);
195
260
  for (const g of gaps) fail(`[GRANT-GAP] ${g.secdef} calls ${g.helper} but its owner cannot EXECUTE it (42501 in prod once ownership is normalized)`);
261
+ for (const f of inheritedFindings) info(`[inherited] ${f.detail}`);
262
+ if (inconclusive.length) {
263
+ // Never a pass and never a failure — a probe that proved nothing, said out loud so it
264
+ // can be fixed rather than silently counted as clean.
265
+ info(`[inconclusive] ${inconclusive.length} probe(s) failed before the privilege check and prove nothing:`);
266
+ for (const f of inconclusive.slice(0, 5)) info(` ${f.command} ${f.table} (${f.role})`);
267
+ if (inconclusive.length > 5) info(` …and ${inconclusive.length - 5} more`);
268
+ }
196
269
  if (unprobed.length) {
197
270
  const roles = [...new Set(unprobed.map((f) => f.role))].join(', ');
198
271
  info(`[unprobed] could not SET ROLE into: ${roles} — grant the operator membership to cover them.`);
@@ -200,10 +273,10 @@ export async function dbAuthzTestCommand(flags: Record<string, string>): Promise
200
273
  console.log('');
201
274
 
202
275
  if (holes.length === 0 && broken.length === 0 && gaps.length === 0) {
203
- success(`db:authz:test — enforcement matches the contract (${contract.tables.length} tables probed, default-deny holds, SECDEF grants complete)`);
276
+ success(`db:authz:test — ${venueLabel} enforces the contract (${contract.tables.length} tables probed, default-deny holds, SECDEF grants complete)`);
204
277
  process.exit(0);
205
278
  }
206
- fail(`db:authz:test — ${holes.length} enforcement hole(s), ${broken.length} broken grant(s), ${gaps.length} SECDEF grant gap(s). The database does not enforce the contract.`);
279
+ fail(`db:authz:test — ${holes.length} enforcement hole(s), ${broken.length} broken grant(s), ${gaps.length} SECDEF grant gap(s). ${venueLabel} does not enforce the contract.`);
207
280
  process.exit(1);
208
281
  }
209
282
 
@@ -223,6 +296,8 @@ export async function dbAuthzOwnerCommand(flags: Record<string, string>): Promis
223
296
  let probes: OwnerProbe[];
224
297
  let publicReadTables = new Set<string>();
225
298
  let rows: any[];
299
+ let venueLabel = '';
300
+ let venue: AuthzVenue | undefined;
226
301
  try {
227
302
  step(`Loading models from ${modelsPath}...`);
228
303
  const models = await loadModels(modelsPath);
@@ -240,20 +315,17 @@ export async function dbAuthzOwnerCommand(flags: Record<string, string>): Promis
240
315
  success('db:authz:owner — no owner-scoped models (no `can({ owner })`); nothing to probe.');
241
316
  process.exit(0);
242
317
  }
243
- step('Resolving deployed config...');
244
- const config = await resolveConfig(flags.stage);
245
- const fn = opsFunction(config);
246
- info(`Region: ${config.region}, Function: ${fn}`);
318
+ venue = await resolveVenue(flags);
319
+ venueLabel = venue.label;
247
320
  step('Red-teaming owner isolation (two JWT identities per table, rolled back)...');
248
- const result: any = await invokeAction(config.region, fn, 'db:authz:probe', {
249
- setup: buildOwnerProbeSql(probes), read: OWNER_PROBE_SELECT_SQL,
250
- });
251
- if (result?.error) throw new Error(result.error);
252
- rows = result?.rows ?? [];
321
+ rows = await venue.probe(buildOwnerProbeSql(probes), OWNER_PROBE_SELECT_SQL);
253
322
  } catch (err: any) {
254
323
  fail(`db:authz:owner failed: ${err.message}`);
255
- info('Ensure the stage is deployed with @everystack/server >= the version that adds db:authz:probe.');
324
+ info('Ensure the stage is deployed with @everystack/server >= the version that adds db:authz:probe,');
325
+ info('or rehearse locally: db:authz:owner --database-url <url>.');
256
326
  process.exit(1);
327
+ } finally {
328
+ await venue?.end();
257
329
  }
258
330
 
259
331
  const findings = evaluateOwnerProbe(rows.map(toOwnerProbeResult), publicReadTables);
@@ -270,9 +342,9 @@ export async function dbAuthzOwnerCommand(flags: Record<string, string>): Promis
270
342
  console.log('');
271
343
 
272
344
  if (leaks.length === 0 && vacuous.length === 0) {
273
- success(`db:authz:owner — owner isolation holds (${probes.length} table(s) probed${unprobed.length ? `, ${unprobed.length} unprobed` : ''}).`);
345
+ success(`db:authz:owner — owner isolation holds on ${venueLabel} (${probes.length} table(s) probed${unprobed.length ? `, ${unprobed.length} unprobed` : ''}).`);
274
346
  process.exit(0);
275
347
  }
276
- fail(`db:authz:owner — ${leaks.length} IDOR leak(s), ${vacuous.length} vacuous policy(ies). One user can reach another's rows.`);
348
+ fail(`db:authz:owner — ${leaks.length} IDOR leak(s), ${vacuous.length} vacuous policy(ies) on ${venueLabel}. One user can reach another's rows.`);
277
349
  process.exit(1);
278
350
  }
@@ -12,6 +12,7 @@
12
12
  * is deliberately absent here — `db:reconcile --check` is its verifier.
13
13
  */
14
14
 
15
+ import { existsSync } from 'node:fs';
15
16
  import type { ModelDescriptor } from '@everystack/model';
16
17
  import type { QueryRunner } from '../authz-contract.js';
17
18
  import { introspectSchema } from '../schema-introspect.js';
@@ -78,8 +79,24 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
78
79
  try {
79
80
  models = await loadModels(modelsPath);
80
81
  sequences = (await loadDeclaredDerived(flags.models))?.sequences;
81
- } catch {
82
- // Live-only mode: no models barrel still useful (what fingerprint is this DB at?).
82
+ } catch (err: any) {
83
+ // Two very different situations used to land here identically.
84
+ //
85
+ // No barrel at all is legitimate live-only mode — "what fingerprint is this database
86
+ // at?" is a useful question on its own.
87
+ //
88
+ // A barrel that EXISTS but will not load is not. Reporting "(live-only)" for it turns
89
+ // a broken measurement into a passing one: the operator asked whether the database
90
+ // matches their models, and got an answer that never looked at the models. That is
91
+ // how someone concludes a round-trip closed when nothing was compared.
92
+ if (existsSync(modelsPath)) {
93
+ fail(`Could not load the models barrel at ${modelsPath}`);
94
+ info(String(err?.message ?? err).split('\n').slice(0, 6).join('\n'));
95
+ info('');
96
+ info('Fix the barrel and re-run. (Refusing rather than falling back to live-only —');
97
+ info('a comparison that silently compares nothing is worse than an error.)');
98
+ process.exit(1);
99
+ }
83
100
  }
84
101
 
85
102
  let runner: QueryRunner;
@@ -31,6 +31,8 @@ import fs from 'node:fs/promises';
31
31
  import path from 'node:path';
32
32
  import { introspectSchema, MATVIEW_COLUMNS_SQL, matviewColumnsByIdentity, type ColumnRow, type ColumnSchema } from '../schema-introspect.js';
33
33
  import { introspectDerived, type DerivedCatalog } from '../derived-introspect.js';
34
+ import { introspectContract, type TableContract } from '../authz-contract.js';
35
+ import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
34
36
  import { renderDerivedSource, renderDerivedFile, type DerivedRenderResult } from '../derived-render.js';
35
37
  import { renderModelSource, renderModelFiles, pullableTables, modelVarName, ABILITY_PRESETS } from '../model-render.js';
36
38
  import type { QueryRunner } from '../authz-contract.js';
@@ -102,8 +104,8 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
102
104
  // The read-model scaffold: default 'commented' surfaces the authz decision in every
103
105
  // model; a preset stamps it. Validated up front so a typo fails before introspection.
104
106
  const abilities = flags.abilities || 'commented';
105
- if (abilities !== 'commented' && !ABILITY_PRESETS[abilities]) {
106
- fail(`Unknown --abilities preset '${abilities}'. Known: ${Object.keys(ABILITY_PRESETS).join(', ')} (omit the flag to scaffold the decision as comments).`);
107
+ if (abilities !== 'commented' && abilities !== 'live' && !ABILITY_PRESETS[abilities]) {
108
+ fail(`Unknown --abilities preset '${abilities}'. Known: live, ${Object.keys(ABILITY_PRESETS).join(', ')} (omit the flag to scaffold the decision as comments).`);
107
109
  process.exit(1);
108
110
  }
109
111
 
@@ -123,6 +125,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
123
125
  }
124
126
 
125
127
  let current;
128
+ let liveAuthz: Map<string, TableContract> | undefined;
126
129
  let derivedCatalog: DerivedCatalog | undefined;
127
130
  let matviewColumns: Map<string, ColumnSchema[]> | undefined;
128
131
  let candidatesByIdentity: Map<string, string[]> | undefined;
@@ -143,6 +146,28 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
143
146
  // The derived layer rides the same pull (B5) — views/matviews/functions/sequences
144
147
  // render as descriptors; adoption is pull → commit → --baseline → clean reconcile.
145
148
  derivedCatalog = await introspectDerived(runner);
149
+ // --abilities live: the authz half of the on-ramp. Same connection, one more read —
150
+ // the grants and policies that already exist become the models' declared abilities,
151
+ // instead of a human transcribing them by hand.
152
+ if (abilities === 'live') {
153
+ note('Introspecting live authorization (grants + policies)...');
154
+ const contract = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
155
+ liveAuthz = new Map(contract.tables.map((t) => [t.table, t]));
156
+ detail(`${liveAuthz.size} table(s) carry authorization.`);
157
+ // Roles outside the model vocabulary (anon/authenticated/admin) are usually ONE
158
+ // operator account granted across the whole schema. Said once here rather than
159
+ // repeated in all N models, where it would bury the per-table findings.
160
+ const unmapped = new Set<string>();
161
+ for (const t of contract.tables) {
162
+ for (const r of Object.keys(t.grants)) {
163
+ if (!['anon', 'authenticated', 'admin', 'PUBLIC', 'public'].includes(r)) unmapped.add(r);
164
+ }
165
+ }
166
+ if (unmapped.size) {
167
+ note(`Grants exist for ${[...unmapped].sort().join(', ')} — not rendered: abilities name the roles the model knows (anon/authenticated/admin).`);
168
+ detail(`These are left exactly as they are in the database. Add can(..., { role }) only if you want the models to own them.`);
169
+ }
170
+ }
146
171
  // --matviews-as-tables: the flip needs real fields — one extra catalog read for the
147
172
  // matview columns the derived layer (definition-only) doesn't carry.
148
173
  if (matviewsAsTables) {
@@ -235,7 +260,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
235
260
  if (flags.out && !flags.out.endsWith('.ts')) {
236
261
  // A directory: one file per model + index.ts — the default shape for a real app.
237
262
  const dir = path.resolve(flags.out);
238
- const files = renderModelFiles(current, { schema, abilities, derived: embeddedDerived });
263
+ const files = renderModelFiles(current, { schema, abilities, liveAuthz, derived: embeddedDerived });
239
264
  try {
240
265
  await fs.mkdir(dir, { recursive: true });
241
266
  const written = new Set(files.map((f) => f.file));
@@ -252,7 +277,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
252
277
  source = files.map((f) => f.source).join('\n');
253
278
  } else if (flags.out) {
254
279
  const outPath = path.resolve(flags.out);
255
- source = renderModelSource(current, { schema, abilities, derived: embeddedDerived });
280
+ source = renderModelSource(current, { schema, abilities, liveAuthz, derived: embeddedDerived });
256
281
  try {
257
282
  await fs.mkdir(path.dirname(outPath), { recursive: true });
258
283
  await fs.writeFile(outPath, source, 'utf8');
@@ -262,7 +287,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
262
287
  }
263
288
  ok(`Wrote ${path.relative(process.cwd(), outPath)} — ${pulled.length} model(s).`);
264
289
  } else {
265
- source = renderModelSource(current, { schema, abilities, derived: embeddedDerived });
290
+ source = renderModelSource(current, { schema, abilities, liveAuthz, derived: embeddedDerived });
266
291
  process.stdout.write(source);
267
292
  ok(`Rendered ${pulled.length} model(s) from schema "${schema}" (stdout — redirect or pass --out to save).`);
268
293
  }
@@ -29,6 +29,7 @@ import { loadDeclaredDerived } from '../declared-derived.js';
29
29
  import type { SourceObject } from '../derived-source.js';
30
30
  import { pairedDerivedSchemas, renderPairedDerivedBuild, renderSwapSchemaUsage, swapSchemaRoles, expectedIncomingObjects, renderPairedProvenance } from '../swap-pair.js';
31
31
  import { introspectDerived } from '../derived-introspect.js';
32
+ import { legacyFunctionIdentity } from '../pg-argtypes.js';
32
33
  import { createUrlRunner } from '../db-source.js';
33
34
  import type { QueryRunner } from '../authz-contract.js';
34
35
  import { executeSwap, type SwapVerdict } from '../swap-execute.js';
@@ -37,9 +38,12 @@ import { formatBytes } from '../bundle-weight.js';
37
38
  import { rewriteStatementLine, opensCopyData, closesCopyData } from '../schema-rewrite.js';
38
39
  import { resolveOperatorUrlViaStage } from '../direct-venue.js';
39
40
  import { withMutationLease, MutationLeaseError } from '../mutation-lease.js';
40
- import { resolveConfig, opsFunction } from '../config.js';
41
- import { invokeAction, presignGet } from '../aws.js';
42
- import { keyForArtifactId, metaKey } from '../backup.js';
41
+ import { resolveConfig, opsFunction, type CliConfig } from '../config.js';
42
+ import { invokeAction, presignGet, createRdsSnapshot, describeRdsSnapshots } from '../aws.js';
43
+ import { keyForArtifactId, metaKey, utcStamp } from '../backup.js';
44
+ import { rdsSnapshotIdentifier } from '../rds-snapshot.js';
45
+ import { pollTaskUntilStopped } from '../task-poll.js';
46
+ import { decideSnapshotMode, confirmPhysicalSnapshot, interpretBackupPoll, type SnapshotModeRequest } from '../swap-snapshot.js';
43
47
  import { pgEnvFromUrl, pgKeepaliveConninfo } from './db.js';
44
48
  import { step, success, fail, warn, info } from '../output.js';
45
49
 
@@ -264,11 +268,29 @@ async function restoreIntoIncoming(
264
268
  // never argv (libpq also REJECTS non-keyword URI params like the `search_path` the operator URL
265
269
  // bakes in — fine for postgres.js, fatal for a libpq URI). `-d` carries ONLY keepalives, which
266
270
  // have no PG* env equivalent and are what keep this connection from dying in the index phase.
271
+ //
272
+ // psql reads the file from STDIN (`-f -`) rather than opening it itself, purely so the restore
273
+ // knows its own write position. That position is the fact the heartbeat was missing: a server
274
+ // parked in `Client/ClientRead` is a STALL when bytes remain unsent and the successful TAIL when
275
+ // they do not, and those two used to print the same line. `-f -` keeps psql's `psql:<stdin>:N:`
276
+ // error prefixes, so the line number of a failing statement survives the change (verified
277
+ // against psql 16).
267
278
  io.log(`restore phase B: psql streaming ${formatBytes(written)} to the target — heartbeat every 10s.`);
268
- const psql = spawn('psql', ['-d', pgKeepaliveConninfo(), '-v', 'ON_ERROR_STOP=1', '-f', sqlPath], {
269
- stdio: ['ignore', 'ignore', 'pipe'],
279
+ const psql = spawn('psql', ['-d', pgKeepaliveConninfo(), '-v', 'ON_ERROR_STOP=1', '-f', '-'], {
280
+ stdio: ['pipe', 'ignore', 'pipe'],
270
281
  env: { ...process.env, ...pgEnvFromUrl(url) },
271
282
  });
283
+ let fedBytes = 0;
284
+ let feedDone = false;
285
+ // A psql that exits early (ON_ERROR_STOP) makes this pipeline fail with EPIPE. That is a
286
+ // DOWNSTREAM symptom — psql's own exit code and stderr are the authority on what went wrong, and
287
+ // a previous version of this code mistook the EPIPE for the cause and chased the wrong bug for
288
+ // two sessions. So the feed's error is swallowed here and psql's exit decides.
289
+ const feeding = pipeline(
290
+ fs.createReadStream(sqlPath),
291
+ countingTap((n) => { fedBytes = n; }),
292
+ psql.stdin!,
293
+ ).then(() => { feedDone = true; }).catch(() => { /* psql's exit is the authority */ });
272
294
  let pErr = '';
273
295
  psql.stderr.on('data', (d) => {
274
296
  const s = d.toString();
@@ -285,6 +307,9 @@ async function restoreIntoIncoming(
285
307
  incoming,
286
308
  log: io.log,
287
309
  warn: io.warn,
310
+ // The client half of the picture. Without it the heartbeat cried "deadlock signature" over the
311
+ // last poll of a run that had landed every row and was about to succeed.
312
+ clientFeed: () => ({ fedBytes, totalBytes: written, done: feedDone }),
288
313
  onSample: (sample) => {
289
314
  if (sample.state === null) deadBackendPolls += 1;
290
315
  else deadBackendPolls = 0;
@@ -319,8 +344,11 @@ async function restoreIntoIncoming(
319
344
  });
320
345
  } finally {
321
346
  await stopHeartbeat();
347
+ // The feed is already finished on the success path; on a failure path it is rejecting with
348
+ // EPIPE. Either way, await it so no stream work outlives the phase.
349
+ await feeding;
322
350
  }
323
- io.log(`restore phase B done in ${humanElapsed(Date.now() - bStart)} (restore total ${humanElapsed(Date.now() - t0)}).`);
351
+ io.log(`restore phase B done in ${humanElapsed(Date.now() - bStart)} (restore total ${humanElapsed(Date.now() - t0)}); fed ${formatBytes(fedBytes)} of ${formatBytes(written)}.`);
324
352
  } finally {
325
353
  await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
326
354
  }
@@ -407,6 +435,60 @@ async function resolveSwapArtifact(
407
435
  return fetchArtifactFromS3(from, stage, fingerprintFlag);
408
436
  }
409
437
 
438
+ /**
439
+ * Take (or account for) the pre-swap rollback point, and do not return until it EXISTS.
440
+ *
441
+ * Every branch here either produces a confirmed rollback point or throws — and a throw at this point
442
+ * means executeSwap never reaches the restore, so live is untouched. That property is the entire
443
+ * reason this is not a fire-and-forget dispatch any more.
444
+ */
445
+ async function takePreSwapSnapshot(
446
+ plan: Exclude<ReturnType<typeof decideSnapshotMode>, { mode: 'refuse' }>,
447
+ ctx: { stage?: string; region?: string; opsFn?: string },
448
+ ): Promise<void> {
449
+ if (plan.mode === 'attested') {
450
+ info(`pre-swap rollback point: ${plan.ref} (attested via --snapshot-ref — no new snapshot taken).`);
451
+ return;
452
+ }
453
+
454
+ if (plan.mode === 'none') {
455
+ warn('NO pre-swap snapshot (--snapshot none). If this swap lands bad data there is no rollback point — the retiring schema is dropped once verify passes.');
456
+ return;
457
+ }
458
+
459
+ if (plan.mode === 'physical') {
460
+ step(`Snapshotting the instance before the swap (RDS physical snapshot of ${plan.instanceId})...`);
461
+ const snapshotId = rdsSnapshotIdentifier(`${ctx.stage ?? 'swap'}-swap`, utcStamp(new Date()));
462
+ const region = ctx.region!;
463
+ const { id } = await confirmPhysicalSnapshot({
464
+ create: (sid) => createRdsSnapshot(region, plan.instanceId, sid),
465
+ describe: () => describeRdsSnapshots(region, plan.instanceId),
466
+ log: (m) => info(m),
467
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
468
+ now: () => Date.now(),
469
+ }, { instanceId: plan.instanceId, snapshotId });
470
+ info(`rollback point CONFIRMED: RDS snapshot ${id} is available — restore the instance from it if this swap goes wrong.`);
471
+ return;
472
+ }
473
+
474
+ // Logical: dispatch the Task and WAIT. The dispatch returning is not the backup existing — that
475
+ // conflation is what let a pg_dump run concurrently with the restore it was supposed to precede.
476
+ step('Snapshotting the stage before the swap (db:backup — waiting for the dump to finish)...');
477
+ const dispatched: any = await invokeAction(ctx.region!, ctx.opsFn!, 'db:backup', {
478
+ stage: ctx.stage,
479
+ actor: process.env.USER ?? null,
480
+ });
481
+ if (dispatched?.error) throw new Error(`the pre-swap backup would not dispatch, so the swap was NOT applied: ${dispatched.error}`);
482
+ const { runId, taskArn, id } = dispatched as { runId: string; taskArn: string; id: string };
483
+ info(`backup ${id} dispatched (run ${runId}) — waiting for the task to stop before the restore starts.`);
484
+ const verdict = interpretBackupPoll(
485
+ await pollTaskUntilStopped(ctx.region!, ctx.opsFn!, { runId, taskArn }),
486
+ { runId, id },
487
+ );
488
+ if (!verdict.ok) throw new Error(verdict.reason);
489
+ info(`rollback point CONFIRMED: backup ${id} complete — restore with db:restore --from ${id} --confirm.`);
490
+ }
491
+
410
492
  export async function dbSwapCommand(flags: Record<string, string>): Promise<void> {
411
493
  const schema = flags.schema;
412
494
  const from = flags.from;
@@ -445,9 +527,9 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
445
527
  process.exit(1);
446
528
  }
447
529
  let url = urlFlag;
448
- let snapshotViaStage = false;
449
530
  let region: string | undefined;
450
531
  let opsFn: string | undefined;
532
+ let stageConfig: CliConfig | undefined;
451
533
 
452
534
  if (stage) {
453
535
  if (!direct) {
@@ -459,13 +541,12 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
459
541
  process.exit(1);
460
542
  }
461
543
  try {
462
- const config = await resolveConfig(stage);
463
- region = config.region;
464
- opsFn = opsFunction(config);
544
+ stageConfig = await resolveConfig(stage);
545
+ region = stageConfig.region;
546
+ opsFn = opsFunction(stageConfig);
465
547
  step('Resolving the operator connection from the stage (--direct)...');
466
548
  const op = await resolveOperatorUrlViaStage(stage);
467
549
  url = op.url;
468
- snapshotViaStage = true;
469
550
  info(`operator credential resolved (${op.source}) — swapping CLI-side, unbounded clock.`);
470
551
  } catch (err: any) { fail(err.message); process.exit(1); }
471
552
  }
@@ -475,6 +556,29 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
475
556
  process.exit(1);
476
557
  }
477
558
 
559
+ // THE ROLLBACK POINT. Decided here — before the models load, before the artifact is fetched, and
560
+ // long before anything is renamed — so a refusal costs nothing but a second.
561
+ //
562
+ // This step used to be a `db:backup` the swap did not wait for. The ops action dispatches a Task
563
+ // and returns, so the swap printed "snapshot on record" and began restoring while the pg_dump was
564
+ // still running: a consumer measured the restore blocked ~5 minutes on
565
+ // `Lock/relation HELD BY pid [pg_dump]`, the swap contending with its own backup. And a task that
566
+ // failed to start left a destructive swap running against a rollback point that did not exist.
567
+ //
568
+ // A physical RDS snapshot is now the default where the target is RDS (a control-plane call: no
569
+ // locks, no buffer-cache read, no client connection), the logical backup is the non-RDS fallback
570
+ // and is now WAITED ON, and the bare `--database-url` venue refuses rather than warning.
571
+ const snapshotPlan = decideSnapshotMode({
572
+ venue: stage ? 'stage' : 'url',
573
+ instanceId: flags.instance ?? stageConfig?.databaseInstanceId,
574
+ snapshotRef: flags['snapshot-ref'],
575
+ requested: flags.snapshot as SnapshotModeRequest | undefined,
576
+ });
577
+ if (snapshotPlan.mode === 'refuse') {
578
+ fail(snapshotPlan.reason);
579
+ process.exit(1);
580
+ }
581
+
478
582
  // --rebuild-derived carries a real outage window: the derived layer does not exist between the
479
583
  // swap committing and db:reconcile --apply finishing. State it BEFORE the work starts — saying it
480
584
  // only afterward tells the operator about an outage they are already in. It is now the OPT-OUT:
@@ -534,7 +638,10 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
534
638
  artifactFingerprint,
535
639
  declaredFingerprint,
536
640
  // What db:reconcile can regenerate — the set a dependent must be in to be safe to drop.
537
- declaredIdentities: declaredDerivedObjects.map((o) => o.identity),
641
+ // NAME granularity: both consumers (the paired pre-flight's dependentIdentity, the TOC
642
+ // filter) read a function as `schema.name` with its argument list stripped, so the
643
+ // signature half of a derived identity is dropped here rather than never matching.
644
+ declaredIdentities: declaredDerivedObjects.map((o) => legacyFunctionIdentity(o.identity)),
538
645
  rebuildDerived: flags['rebuild-derived'] === 'true',
539
646
  paired,
540
647
  // Schema-level USAGE, re-applied in the swap transaction and asserted after it commits.
@@ -606,17 +713,11 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
606
713
  runner: r,
607
714
  log: (m) => info(m),
608
715
  warn: (m) => warn(m),
609
- declaredIdentities: declaredDerivedObjects.map((o) => o.identity),
716
+ // Name granularity — the TOC entry's argument list is stripped before the lookup.
717
+ declaredIdentities: declaredDerivedObjects.map((o) => legacyFunctionIdentity(o.identity)),
610
718
  });
611
719
  },
612
- snapshot: snapshotViaStage
613
- ? async () => {
614
- step('Snapshotting the stage before the swap (db:backup)...');
615
- const r: any = await invokeAction(region!, opsFn!, 'db:backup', { stage });
616
- if (r?.error) throw new Error(`pre-swap snapshot failed, so the swap was NOT applied: ${r.error}`);
617
- info(`snapshot on record: ${r?.id ?? 'backup complete'} — restore with db:restore --from ${r?.id ?? '<id>'} --confirm.`);
618
- }
619
- : async () => { warn('no snapshot taken (direct v1) — take one first: everystack db:backup --database-url … before a production swap.'); },
720
+ snapshot: () => takePreSwapSnapshot(snapshotPlan, { stage, region, opsFn }),
620
721
  }),
621
722
  );
622
723
 
@@ -721,6 +721,17 @@ function printDoctorReport(report: any): void {
721
721
  success('db:doctor — database is least-privilege and RLS-subject');
722
722
  } else {
723
723
  fail('db:doctor — the api connection is NOT correctly least-privilege (see failures above)');
724
+ // Probing a local database as your own superuser makes these fail by construction:
725
+ // the checks describe the credential the API serves with, not the schema. A developer
726
+ // reading them as defects in their app will go hunting for a bug that is not there.
727
+ if (report.api?.isSuperuser) {
728
+ console.log('');
729
+ info('Note: this probe connected as a SUPERUSER, so least-privilege / not-superuser / fails-closed');
730
+ info('cannot pass by construction. Those three describe the credential your API serves with —');
731
+ info('they are only meaningful against the app role (a deployed stage, or a local DATABASE_URL');
732
+ info('pointing at the least-privilege role from db:provision). The RLS and grant findings above');
733
+ info('are still real.');
734
+ }
724
735
  }
725
736
  }
726
737