@everystack/cli 0.3.27 → 0.3.31

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.
@@ -22,171 +22,181 @@
22
22
  */
23
23
 
24
24
  import fs from 'node:fs/promises';
25
- import { introspectContract, type QueryRunner, type AuthzContract } from '../authz-contract.js';
26
- import { introspectSchema, type SchemaSnapshot } from '../schema-introspect.js';
25
+ import { type QueryRunner } from '../authz-contract.js';
26
+ import { introspectSchema } from '../schema-introspect.js';
27
+ import { introspectContract } from '../authz-contract.js';
27
28
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
28
- import { fingerprintLive } from '../schema-fingerprint.js';
29
- import { applyGeneratedStatements, currentGitRef } from '../state-apply.js';
30
- import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from '../derived-apply.js';
31
- import { checkPlanPrecondition, planHash, PLAN_VERSION, type EdgePlan } from '../edge-plan.js';
32
- import { verifyDescent, type LiveState } from '../git-descent.js';
33
- import { checkDestructiveAuthority, readApproverParam, resolveCallerIdentity } from '../apply-authority.js';
29
+ import { currentGitRef } from '../state-apply.js';
30
+ import { planHash, PLAN_VERSION, type EdgePlan } from '../edge-plan.js';
31
+ import { verifyDescent, type DescentVerdict, type LiveState } from '../git-descent.js';
32
+ import { checkDestructiveAuthority, readApproverParam, resolveCallerIdentity, type AuthorityVerdict } from '../apply-authority.js';
34
33
  import { resolveModelsPath } from '../models-path.js';
35
- import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
34
+ import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
36
35
  import { resolveConfig, opsFunction } from '../config.js';
37
- import { invokeAction } from '../aws.js';
36
+ import { invokeAction, lambdaQueryRunner } from '../aws.js';
37
+ import { executeApplyPlan, type ApplyPlanResult } from '../apply-execute.js';
38
38
  import { step, success, fail, info, warn } from '../output.js';
39
39
 
40
+ // The apply core lives in apply-execute.ts (shared with the ops Lambda's
41
+ // db:apply action). Re-exported so the existing integration suites — and any
42
+ // other in-repo caller — keep importing it from this command module.
43
+ export { executeApplyPlan } from '../apply-execute.js';
44
+ export type { ApplyPlanOptions, ApplyPlanResult } from '../apply-execute.js';
45
+
40
46
  // ---------------------------------------------------------------------------
41
- // The testable core.
47
+ // The CLI shell.
42
48
  // ---------------------------------------------------------------------------
43
49
 
44
- export interface ApplyPlanOptions {
45
- actor?: string | null;
46
- /** Defaults to the plan's own gitRef (the intent that minted it). */
47
- gitRef?: string | null;
48
- /**
49
- * The fast-forward rule (brick 7): called with the target's live state
50
- * after the concurrency lock passes, BEFORE anything executes. Return
51
- * `ok: false` to refuse — the checkout does not descend from the commit
52
- * declaring the target's state. Omitted = no descent enforcement (tests,
53
- * or an explicit, confirmed, snapshotted force).
54
- */
55
- verifyDescent?: (live: {
56
- snapshot: SchemaSnapshot;
57
- contract: AuthzContract;
58
- fingerprint: string;
59
- }) => Promise<{ ok: true } | { ok: false; reason: string }>;
60
- /**
61
- * Destructive authority (brick 9, decisions 11+12): called only when the
62
- * plan is destructive, after the lock and descent checks pass. Return
63
- * `ok: false` to refuse — the caller is not in the stage's declared
64
- * approver set. Omitted = no authority gate (non-staged targets keep the
65
- * ceremony the command shell enforces).
66
- */
67
- verifyAuthority?: () => Promise<{ ok: true } | { ok: false; reason: string }>;
68
- /**
69
- * Runs after every verification passes and BEFORE the edge executes, only
70
- * for destructive plans — the auto-snapshot seam ("losing data should be
71
- * hard": confirmed, approved, snapshotted, in that order). A throw here
72
- * aborts the apply with nothing executed.
73
- */
74
- beforeDestructive?: () => Promise<void>;
75
- /** Injectable clock for tests. */
76
- now?: () => number;
77
- }
78
-
79
- export interface ApplyPlanResult {
80
- status: 'applied' | 'already-applied' | 'refused' | 'descent-refused' | 'authority-refused' | 'verify-failed';
81
- liveFingerprint: string;
82
- reason?: string;
83
- logId?: number;
50
+ /**
51
+ * Collapse a DescentVerdict into the ok/reason shape the apply core (and the
52
+ * ops Lambda's db:apply action) enforce. `no-git` / `fresh-target` pass — the
53
+ * rule can't be verified there — matching the direct-URL path's callback.
54
+ */
55
+ export function descentVerdictForApply(verdict: DescentVerdict): { ok: true } | { ok: false; reason: string } {
56
+ switch (verdict.status) {
57
+ case 'ok':
58
+ case 'fresh-target':
59
+ case 'no-git':
60
+ return { ok: true };
61
+ case 'diverged':
62
+ case 'drift':
63
+ return { ok: false, reason: verdict.reason };
64
+ }
84
65
  }
85
66
 
86
67
  /**
87
- * Decision 7, made whole: every database records every refusal, not just
88
- * every apply. Best-effort by construction a memoir INSERT that cannot be
89
- * written must never mask the refusal itself (a target that rejects the
90
- * bookkeeping write would have failed the apply's ENSURE identically).
68
+ * Render an ApplyPlanResult shared by the direct (`--database-url`) and the
69
+ * credential-free staged (`--stage`) paths, so the two report identically.
70
+ * Every refusal and the verify-fault exit non-zero.
91
71
  */
92
- async function recordRefusal(
93
- runner: QueryRunner,
94
- plan: EdgePlan,
95
- liveFingerprint: string,
96
- gate: string,
97
- reason: string,
98
- options: ApplyPlanOptions,
99
- ): Promise<void> {
100
- try {
101
- await runner(ENSURE_RECONCILER_SQL.join(';\n'));
102
- await runner(renderSchemaLogInsert({
103
- kind: 'state refusal',
104
- outcome: 'refused',
105
- actor: options.actor ?? null,
106
- gitRef: options.gitRef ?? plan.gitRef,
107
- planRef: planHash(plan),
108
- fromFingerprint: liveFingerprint,
109
- toFingerprint: plan.toFingerprint,
110
- sql: `-- REFUSED (${gate}): ${reason}\n${plan.statements.join('\n;\n')}`,
111
- }));
112
- } catch {
113
- // Swallowed deliberately — see the docblock.
72
+ function reportApplyResult(result: ApplyPlanResult, plan: EdgePlan): void {
73
+ switch (result.status) {
74
+ case 'already-applied':
75
+ success(`Already at ${plan.toFingerprint.slice(0, 12)} — nothing to do (idempotent re-apply).`);
76
+ break;
77
+ case 'refused':
78
+ fail(`REFUSED: ${result.reason}`);
79
+ process.exit(1);
80
+ break;
81
+ case 'descent-refused':
82
+ fail(`REFUSED (fast-forward rule): ${result.reason}`);
83
+ process.exit(1);
84
+ break;
85
+ case 'authority-refused':
86
+ fail(`REFUSED (destructive authority): ${result.reason}`);
87
+ process.exit(1);
88
+ break;
89
+ case 'verify-failed':
90
+ fail(`Verify FAILED: ${result.reason}`);
91
+ process.exit(1);
92
+ break;
93
+ case 'applied':
94
+ success(`Applied and verified — the target is at ${result.liveFingerprint.slice(0, 12)}, exactly as the plan predicted. Recorded in everystack.schema_log (plan_ref ${planHash(plan).slice(0, 12)}).`);
95
+ break;
114
96
  }
115
97
  }
116
98
 
117
- /** Verify → apply → verify. Pure orchestration over an injected QueryRunner. */
118
- export async function executeApplyPlan(
119
- runner: QueryRunner,
99
+ /**
100
+ * db:apply --stage: the credential-free staged write. The operator never holds
101
+ * a database URL — the WRITE runs in the ops Lambda's db:apply action against
102
+ * its resource-linked connection. The CLI keeps only what needs the checkout
103
+ * or the caller's identity: the fast-forward (descent) rule and the
104
+ * destructive-approver check. The Lambda re-verifies the concurrency lock,
105
+ * applies the DDL in one transaction, stamps schema_log, and verifies plan.to.
106
+ */
107
+ async function applyPlanViaStage(
120
108
  plan: EdgePlan,
121
- options: ApplyPlanOptions = {},
122
- ): Promise<ApplyPlanResult> {
123
- const before = await introspectSchema(runner);
124
- const beforeAuthz = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
125
- const live = fingerprintLive(before, beforeAuthz).hash;
109
+ flags: Record<string, string>,
110
+ forceDescent: string | undefined,
111
+ ): Promise<void> {
112
+ step('Resolving deployed config...');
113
+ const config = await resolveConfig(flags.stage);
114
+ const { region } = config;
115
+ const fn = opsFunction(config);
126
116
 
127
- if (live === plan.toFingerprint) {
128
- return { status: 'already-applied', liveFingerprint: live };
129
- }
130
- const pre = checkPlanPrecondition(plan, live);
131
- if (!pre.ok) {
132
- await recordRefusal(runner, plan, live, 'concurrency lock', pre.reason, options);
133
- return { status: 'refused', liveFingerprint: live, reason: pre.reason };
134
- }
117
+ info(`plan ${planHash(plan).slice(0, 12)}: ${plan.fromFingerprint.slice(0, 12)} → ${plan.toFingerprint.slice(0, 12)} (${plan.executable} statement(s)${plan.destructive ? `, ${plan.destructive} destructive` : ''})`);
135
118
 
136
- if (options.verifyDescent) {
137
- const descent = await options.verifyDescent({ snapshot: before, contract: beforeAuthz, fingerprint: live });
138
- if (!descent.ok) {
139
- await recordRefusal(runner, plan, live, 'fast-forward rule', descent.reason, options);
140
- return { status: 'descent-refused', liveFingerprint: live, reason: descent.reason };
119
+ try {
120
+ // Descent the fast-forward rule needs the git checkout, so the CLI
121
+ // decides it (the Lambda has no checkout). Read the stage's state read-only
122
+ // via the ops Lambda's db:query action, run the rule locally, and hand the
123
+ // verdict to the write action, which enforces + records it in order.
124
+ let descentVerdict: { ok: true } | { ok: false; reason: string };
125
+ if (forceDescent !== undefined) {
126
+ warn(`DESCENT FORCED — the fast-forward rule is bypassed for this apply. Snapshot on record: ${forceDescent}.`);
127
+ descentVerdict = { ok: true };
128
+ } else {
129
+ step('Asking the stage its state (read-only via the ops Lambda)...');
130
+ const readRunner = lambdaQueryRunner(region, fn);
131
+ const snapshot = await introspectSchema(readRunner);
132
+ const contract = await introspectContract(readRunner, contractFunctionRow, FUNCTIONS_SQL);
133
+ step("Descent: searching git for the commit that declares the stage's state...");
134
+ const verdict = await verifyDescent(resolveModelsPath(flags.models), { snapshot, contract }, {});
135
+ switch (verdict.status) {
136
+ case 'ok':
137
+ info(`descent: ${verdict.commit.slice(0, 12)} declares the stage's state and is an ancestor of HEAD — fast-forward.`);
138
+ break;
139
+ case 'fresh-target':
140
+ info('descent: fresh target (no tables) — nothing to protect, bootstrapping.');
141
+ break;
142
+ case 'no-git':
143
+ warn('descent: not a git checkout — the fast-forward rule cannot be verified here.');
144
+ break;
145
+ case 'diverged':
146
+ case 'drift':
147
+ break; // the reason travels to the action's refusal + the memoir
148
+ }
149
+ descentVerdict = descentVerdictForApply(verdict);
141
150
  }
142
- }
143
151
 
144
- if (plan.destructive > 0 && options.verifyAuthority) {
145
- const authority = await options.verifyAuthority();
146
- if (!authority.ok) {
147
- await recordRefusal(runner, plan, live, 'destructive authority', authority.reason, options);
148
- return { status: 'authority-refused', liveFingerprint: live, reason: authority.reason };
152
+ // Destructive ceremony the approver check is against the caller's REAL
153
+ // IAM identity (STS), which the Lambda cannot see, so the CLI resolves it
154
+ // and checks it against the stage's declared approver set (SSM). The
155
+ // snapshot itself runs IN the Lambda, before the DDL. --confirm always.
156
+ let authorityVerdict: AuthorityVerdict | undefined;
157
+ if (plan.destructive > 0) {
158
+ const shape = `${plan.classification.drops} drop(s), ${plan.classification.narrowings} narrowing type change(s)`;
159
+ if (flags.confirm !== 'true') {
160
+ fail(`This plan is DESTRUCTIVE — ${plan.destructive} statement(s) lose data (${shape}). Explicit confirmation is required, always: re-run with --confirm.`);
161
+ process.exit(1);
162
+ }
163
+ step(`Destructive authority: checking the approver set for stage ${flags.stage}...`);
164
+ const { parseAppName } = await import('../discover.js');
165
+ const appName = await parseAppName();
166
+ const approvers = await readApproverParam(region, appName, flags.stage);
167
+ const identity = approvers === null ? null : await resolveCallerIdentity(region);
168
+ authorityVerdict = checkDestructiveAuthority({ approvers, identity });
169
+ if (authorityVerdict.ok && authorityVerdict.reason === 'approved') {
170
+ info(`authority: ${identity} is a declared approver.`);
171
+ } else if (authorityVerdict.ok) {
172
+ warn('authority: no approver set declared for this stage — proceeding on ceremony alone. Declare one: everystack db:approvers --set <arn-or-name,...> --stage ' + flags.stage);
173
+ }
174
+ // A refusal is enforced + recorded server-side; reportApplyResult renders it.
149
175
  }
150
- }
151
-
152
- if (plan.destructive > 0 && options.beforeDestructive) {
153
- await options.beforeDestructive();
154
- }
155
-
156
- const result = await applyGeneratedStatements(runner, plan.statements, {
157
- actor: options.actor,
158
- gitRef: options.gitRef ?? plan.gitRef,
159
- planRef: planHash(plan),
160
- fromFingerprint: live,
161
- now: options.now,
162
- });
163
176
 
164
- const after = await introspectSchema(runner);
165
- const afterAuthz = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
166
- const landed = fingerprintLive(after, afterAuthz).hash;
167
- if (result.logId !== undefined) {
168
- await runner(renderSchemaLogFingerprintUpdate(result.logId, landed));
169
- }
170
-
171
- if (landed !== plan.toFingerprint) {
172
- return {
173
- status: 'verify-failed',
174
- liveFingerprint: landed,
175
- reason: `applied, but the target landed on ${landed.slice(0, 12)} — the plan predicted ${plan.toFingerprint.slice(0, 12)}. Investigate before touching this database again (db:fingerprint, db:generate).`,
176
- ...(result.logId !== undefined ? { logId: result.logId } : {}),
177
- };
177
+ step('Applying in the ops Lambda (credential-free — the operator never holds a database URL)...');
178
+ const result: any = await invokeAction(region, fn, 'db:apply', {
179
+ plan,
180
+ actor: process.env.USER ?? null,
181
+ gitRef: currentGitRef() ?? plan.gitRef,
182
+ descentVerdict,
183
+ ...(authorityVerdict ? { authorityVerdict } : {}),
184
+ stage: flags.stage,
185
+ });
186
+ if (result?.error) {
187
+ fail(`Apply failed: ${result.error}`);
188
+ process.exit(1);
189
+ }
190
+ if (result?.snapshotId) {
191
+ info(`snapshot on record: ${result.snapshotId} — restore with db:restore --from ${result.snapshotId} --confirm.`);
192
+ }
193
+ reportApplyResult(result as ApplyPlanResult, plan);
194
+ } catch (err: any) {
195
+ fail(`Apply failed: ${err.message}`);
196
+ process.exit(1);
178
197
  }
179
- return {
180
- status: 'applied',
181
- liveFingerprint: landed,
182
- ...(result.logId !== undefined ? { logId: result.logId } : {}),
183
- };
184
198
  }
185
199
 
186
- // ---------------------------------------------------------------------------
187
- // The CLI shell.
188
- // ---------------------------------------------------------------------------
189
-
190
200
  export async function dbApplyCommand(flags: Record<string, string>): Promise<void> {
191
201
  const planPath = flags.plan;
192
202
  if (!planPath) {
@@ -213,10 +223,6 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
213
223
  fail(err.message);
214
224
  process.exit(1);
215
225
  }
216
- if (dbSource.kind !== 'url') {
217
- fail('db:apply writes — it needs a direct connection (--database-url or DATABASE_URL).');
218
- process.exit(1);
219
- }
220
226
 
221
227
  // The fast-forward rule's force ceremony (design decision 9): explicit
222
228
  // (--force-descent), snapshotted (its value NAMES the snapshot you took),
@@ -233,6 +239,13 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
233
239
  }
234
240
  }
235
241
 
242
+ // Deployed stage, no direct URL: the write runs in the ops Lambda — the
243
+ // operator never holds a database URL. --database-url stays local-only.
244
+ if (dbSource.kind === 'stage') {
245
+ await applyPlanViaStage(plan, flags, forceDescent);
246
+ return;
247
+ }
248
+
236
249
  // Losing data should be hard (decision 11): a destructive plan applies
237
250
  // only confirmed (--confirm, always), snapshotted (automatic with --stage,
238
251
  // named over a bare connection), and — when the stage declares an approver
@@ -279,7 +292,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
279
292
  }
280
293
  }
281
294
 
282
- step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
295
+ step(connectingVia(dbSource));
283
296
  const { runner, end } = await createUrlRunner(dbSource.url);
284
297
 
285
298
  try {
@@ -315,30 +328,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
315
328
  } : {}),
316
329
  });
317
330
 
318
- switch (result.status) {
319
- case 'already-applied':
320
- success(`Already at ${plan.toFingerprint.slice(0, 12)} — nothing to do (idempotent re-apply).`);
321
- break;
322
- case 'refused':
323
- fail(`REFUSED: ${result.reason}`);
324
- process.exit(1);
325
- break;
326
- case 'descent-refused':
327
- fail(`REFUSED (fast-forward rule): ${result.reason}`);
328
- process.exit(1);
329
- break;
330
- case 'authority-refused':
331
- fail(`REFUSED (destructive authority): ${result.reason}`);
332
- process.exit(1);
333
- break;
334
- case 'verify-failed':
335
- fail(`Verify FAILED: ${result.reason}`);
336
- process.exit(1);
337
- break;
338
- case 'applied':
339
- success(`Applied and verified — the target is at ${result.liveFingerprint.slice(0, 12)}, exactly as the plan predicted. Recorded in everystack.schema_log (plan_ref ${planHash(plan).slice(0, 12)}).`);
340
- break;
341
- }
331
+ reportApplyResult(result, plan);
342
332
  } catch (err: any) {
343
333
  fail(`Apply failed and rolled back: ${err.message}`);
344
334
  process.exit(1);
@@ -27,7 +27,7 @@ import {
27
27
  type BackfillPlan,
28
28
  } from '../backfill.js';
29
29
  import { currentGitRef } from '../state-apply.js';
30
- import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
30
+ import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
31
31
  import { readSqlDirIfPresent } from './db-sync.js';
32
32
  import { step, success, fail, info, warn } from '../output.js';
33
33
 
@@ -69,7 +69,7 @@ export async function dbBackfillCommand(flags: Record<string, string>): Promise<
69
69
  process.exit(1);
70
70
  }
71
71
 
72
- step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
72
+ step(connectingVia(dbSource));
73
73
  const { runner, end } = await createUrlRunner(dbSource.url);
74
74
  try {
75
75
  const opts = { actor: process.env.USER ?? null, gitRef: currentGitRef() };
@@ -63,6 +63,22 @@ export async function dbBackupProbeCommand(flags: Record<string, string>): Promi
63
63
  }
64
64
  }
65
65
 
66
+ /**
67
+ * Interpret a `db:backup:probe` result into a fail-clean remedy, or `null` when a dump is safe to
68
+ * run. Pre-flighting this before the dump turns a missing or version-incompatible pg_dump layer
69
+ * into a one-line remedy instead of a cryptic runtime crash mid-dump.
70
+ */
71
+ export function pgDumpPreflightError(probe: any): string | null {
72
+ if (!probe || probe.error) {
73
+ const detail = probe?.error ?? 'the pg_dump layer probe returned no result';
74
+ return `${detail}\nBuild + publish the layer with scripts/build-pgdump-layer.sh, export PGDUMP_LAYER_ARN, then redeploy.`;
75
+ }
76
+ if (probe.compatible === false) {
77
+ return `pg_dump ${probe.pgDump ?? '(unknown)'} (major ${probe.pgDumpMajor}) is older than the server (major ${probe.serverMajor}) — it will refuse to dump (no bypass exists). Rebuild the layer with pg_dump >= ${probe.serverMajor}.`;
78
+ }
79
+ return null;
80
+ }
81
+
66
82
  /** db:backup — pg_dump the stage's DB to S3 (runs in the ops Lambda). Prints the new backup id. */
67
83
  export async function dbBackupCommand(flags: Record<string, string>): Promise<void> {
68
84
  step('Resolving deployed config...');
@@ -75,6 +91,24 @@ export async function dbBackupCommand(flags: Record<string, string>): Promise<vo
75
91
  }
76
92
 
77
93
  info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
94
+
95
+ // Pre-flight the pg_dump layer before the dump — a missing/incompatible layer becomes a one-line
96
+ // remedy here instead of crashing the runtime mid-dump (the probe returns a clean error, never a
97
+ // crash, when the layer is absent).
98
+ step('Checking the pg_dump layer...');
99
+ let probe: any;
100
+ try {
101
+ probe = await invokeAction(config.region, opsFunction(config), 'db:backup:probe', {});
102
+ } catch (err: any) {
103
+ fail(`Backup pre-flight failed: ${err.message}`);
104
+ process.exit(1);
105
+ }
106
+ const preflightError = pgDumpPreflightError(probe);
107
+ if (preflightError) {
108
+ fail(preflightError);
109
+ process.exit(1);
110
+ }
111
+
78
112
  step('Running pg_dump → S3 (this may take a while for large databases)...');
79
113
 
80
114
  let result: any;
@@ -38,6 +38,7 @@ import { createDatabase, dropDatabase, buildIntoDatabase, withDatabase } from '.
38
38
  import { resolveModelsPath } from '../models-path.js';
39
39
  import { readSqlDirIfPresent } from './db-sync.js';
40
40
  import { loadModels } from './db-generate.js';
41
+ import { findReadAuthzGaps } from '../authz-lint.js';
41
42
  import { step, success, fail, info, warn } from '../output.js';
42
43
 
43
44
  // The role derivation moved to the shared builder (db-build) with brick
@@ -53,7 +54,7 @@ const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
53
54
 
54
55
  export interface CheckFinding {
55
56
  level: 'ok' | 'note' | 'fail';
56
- area: 'models' | 'compose' | 'artifact' | 'compute';
57
+ area: 'models' | 'compose' | 'artifact' | 'compute' | 'authz';
57
58
  message: string;
58
59
  }
59
60
 
@@ -104,6 +105,17 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
104
105
  return findings;
105
106
  }
106
107
 
108
+ // Force-RLS with no read authz: an exposed, RLS-enabled table that declares no read ability reads
109
+ // empty for every app role once it is RLS-subject (the superuser-drop landmine).
110
+ const authzGaps = findReadAuthzGaps(input.models);
111
+ if (authzGaps.length === 0) {
112
+ findings.push({ level: 'ok', area: 'authz', message: 'every exposed table declares a read path — none goes dark under RLS' });
113
+ } else {
114
+ for (const gap of authzGaps) {
115
+ findings.push({ level: 'fail', area: 'authz', message: gap.message });
116
+ }
117
+ }
118
+
107
119
  if (input.artifactSource === null) {
108
120
  findings.push({
109
121
  level: 'note',
@@ -25,7 +25,7 @@ import {
25
25
  type UnfingerprintedObject,
26
26
  } from '../schema-fingerprint.js';
27
27
  import { resolveModelsPath } from '../models-path.js';
28
- import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
28
+ import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
29
29
  import { resolveConfig, opsFunction } from '../config.js';
30
30
  import { invokeAction } from '../aws.js';
31
31
  import { step, success, fail, info, warn } from '../output.js';
@@ -80,7 +80,7 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
80
80
  let runner: QueryRunner;
81
81
  let end: (() => Promise<void>) | undefined;
82
82
  if (dbSource.kind === 'url') {
83
- step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
83
+ step(connectingVia(dbSource));
84
84
  ({ runner, end } = await createUrlRunner(dbSource.url));
85
85
  } else {
86
86
  step('Resolving deployed config...');
@@ -27,7 +27,7 @@ import { compileModuleMigration } from '../migration-compile.js';
27
27
  import { generateMigrationSql, unmodeledTables, formatMigrationFile, planMigrationFile, HELD_DROP_PREFIX, type Journal } from '../migration-generate.js';
28
28
  import { introspectContract, type QueryRunner } from '../authz-contract.js';
29
29
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
30
- import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
30
+ import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
31
31
  import { resolveModelsPath } from '../models-path.js';
32
32
  import { applyStateAndVerify, classifyGeneratedStatements, currentGitRef, type StateSyncOutcome } from '../state-apply.js';
33
33
  import { resolveConfig, opsFunction } from '../config.js';
@@ -157,7 +157,7 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
157
157
  process.exit(1);
158
158
  }
159
159
  if (dbSource.kind === 'url') {
160
- step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
160
+ step(connectingVia(dbSource));
161
161
  ({ runner, end } = await createUrlRunner(dbSource.url));
162
162
  } else {
163
163
  step('Resolving deployed config...');
@@ -28,24 +28,16 @@ import { verifyDescent } from '../git-descent.js';
28
28
  import { planBackfills, readBackfillLog } from '../backfill.js';
29
29
  import { readSqlDirIfPresent } from './db-sync.js';
30
30
  import { currentGitRef } from '../state-apply.js';
31
- import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
31
+ import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
32
32
  import { resolveModelsPath } from '../models-path.js';
33
33
  import { resolveConfig, opsFunction } from '../config.js';
34
- import { invokeAction } from '../aws.js';
34
+ import { lambdaQueryRunner } from '../aws.js';
35
35
  import { loadModels } from './db-generate.js';
36
+ import { reportPipelineLastRun } from './pipeline-run.js';
36
37
  import { step, success, fail, info, warn } from '../output.js';
37
38
 
38
39
  const DEFAULT_OUT = 'db.plan.json';
39
40
 
40
- /** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
41
- function lambdaRunner(region: string, fn: string): QueryRunner {
42
- return async (sql: string) => {
43
- const result: any = await invokeAction(region, fn, 'db:query', { sql });
44
- if (result?.error) throw new Error(`Query failed: ${result.error}`);
45
- return result?.rows ?? [];
46
- };
47
- }
48
-
49
41
  export async function dbPlanCommand(flags: Record<string, string>): Promise<void> {
50
42
  let dbSource: DbSource;
51
43
  try {
@@ -69,12 +61,12 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
69
61
  let runner: QueryRunner;
70
62
  let end: (() => Promise<void>) | undefined;
71
63
  if (dbSource.kind === 'url') {
72
- step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
64
+ step(connectingVia(dbSource));
73
65
  ({ runner, end } = await createUrlRunner(dbSource.url));
74
66
  } else {
75
67
  step('Resolving deployed config...');
76
68
  const config = await resolveConfig(flags.stage);
77
- runner = lambdaRunner(config.region, opsFunction(config));
69
+ runner = lambdaQueryRunner(config.region, opsFunction(config));
78
70
  }
79
71
 
80
72
  try {
@@ -134,6 +126,8 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
134
126
  warn(`backfill lane: could not read everystack.backfill_log (${err.message}) — pending-backfill visibility unavailable.`);
135
127
  }
136
128
  }
129
+
130
+ await reportPipelineLastRun(runner);
137
131
  if (out !== '-') {
138
132
  success(`Wrote ${out} — review it, then \`everystack db:apply --plan ${out}\`.`);
139
133
  warn('Plans are ephemeral release artifacts — attach to the run, do NOT commit.');
@@ -25,7 +25,7 @@ import path from 'node:path';
25
25
  import { introspectSchema } from '../schema-introspect.js';
26
26
  import { renderModelSource, renderModelFiles, pullableTables } from '../model-render.js';
27
27
  import type { QueryRunner } from '../authz-contract.js';
28
- import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
28
+ import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
29
29
  import { resolveConfig, opsFunction } from '../config.js';
30
30
  import { invokeAction } from '../aws.js';
31
31
  import { fail } from '../output.js';
@@ -62,7 +62,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
62
62
  let runner: QueryRunner;
63
63
  let end: (() => Promise<void>) | undefined;
64
64
  if (dbSource.kind === 'url') {
65
- note(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
65
+ note(connectingVia(dbSource));
66
66
  ({ runner, end } = await createUrlRunner(dbSource.url));
67
67
  } else {
68
68
  note('Resolving deployed config...');