@everystack/cli 0.4.46 → 0.4.47
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 +1 -1
- package/src/cli/commands/db-apply.ts +22 -6
- package/src/cli/commands/db-backfill.ts +1 -1
- package/src/cli/commands/db-exec.ts +20 -1
- package/src/cli/commands/db-plan.ts +39 -7
- package/src/cli/commands/db-reconcile.ts +1 -1
- package/src/cli/commands/db-refresh.ts +33 -5
- package/src/cli/commands/db.ts +2 -1
- package/src/cli/exec-digest.ts +55 -13
- package/src/cli/index.ts +2 -2
- package/src/cli/stage-read-consistency.ts +19 -2
package/package.json
CHANGED
|
@@ -331,13 +331,29 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
331
331
|
fail(`This plan is DESTRUCTIVE — ${plan.destructive} statement(s) lose data (${shape}). Explicit confirmation is required, always: re-run with --confirm.`);
|
|
332
332
|
process.exit(1);
|
|
333
333
|
}
|
|
334
|
-
// A DIRECT apply is operator-attested: the
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
334
|
+
// A DIRECT apply is operator-attested: the safety net is a backup the operator took and
|
|
335
|
+
// NAMES here, because this lane has no ops Lambda verifying one server-side.
|
|
336
|
+
//
|
|
337
|
+
// This used to end by offering "or drop --direct and let the credential-free --stage apply
|
|
338
|
+
// auto-verify your latest backup". THAT ADVICE CANNOT BE FOLLOWED. Auto-verification is
|
|
339
|
+
// real, but only for NON-destructive stage applies — and every plan reaching this block is
|
|
340
|
+
// destructive, so dropping --direct lands on the outright refusal above (the `--stage`
|
|
341
|
+
// destructive gate). It sent operators from a lane that works to one that refuses, and the
|
|
342
|
+
// refusal's own text then pointed them at --database-url. Two wrong signposts in a row are
|
|
343
|
+
// how a consumer ended up printing a privileged DSN to do something --direct already did.
|
|
344
|
+
//
|
|
345
|
+
// For a destructive plan there is exactly one remedy: take a safety point and name it.
|
|
338
346
|
if (!flags['snapshot-ref']) {
|
|
339
|
-
const
|
|
340
|
-
|
|
347
|
+
const stageArg = flags.stage ? ` --stage ${flags.stage}` : '';
|
|
348
|
+
const takeIt = `everystack db:backup${stageArg}`;
|
|
349
|
+
const lane = flags.stage
|
|
350
|
+
? `${stageArg.trim()} --direct`
|
|
351
|
+
: '--database-url <url>';
|
|
352
|
+
fail(
|
|
353
|
+
`This plan is DESTRUCTIVE (${shape}) over a direct connection — the apply does not snapshot for you. `
|
|
354
|
+
+ `Take a safety point (${takeIt}) and name it: everystack db:apply --plan ${planPath} ${lane} --confirm --snapshot-ref <id>. `
|
|
355
|
+
+ 'A destructive plan always requires an attested --snapshot-ref; there is no lane that takes one for you.',
|
|
356
|
+
);
|
|
341
357
|
process.exit(1);
|
|
342
358
|
}
|
|
343
359
|
if (flags.stage) {
|
|
@@ -87,7 +87,7 @@ async function runBackfillViaStage(
|
|
|
87
87
|
if (res?.error) {
|
|
88
88
|
fail(`db:backfill failed: ${res.error}`);
|
|
89
89
|
if (/Unknown action/i.test(String(res.error))) {
|
|
90
|
-
info('The deployed handler predates the db:backfill ops action. Upgrade @everystack/server, or run direct: db:backfill --apply --database-url <url>
|
|
90
|
+
info('The deployed handler predates the db:backfill ops action. Upgrade @everystack/server, or run credential-free over the direct lane: db:backfill --apply --stage ' + (flags.stage ?? '<stage>') + ' --direct. (--database-url <url> is the local venue — against a deployed stage it puts a privileged connection string on argv.)');
|
|
91
91
|
}
|
|
92
92
|
process.exit(1);
|
|
93
93
|
}
|
|
@@ -31,7 +31,18 @@ async function readStdin(): Promise<string> {
|
|
|
31
31
|
return Buffer.concat(chunks).toString('utf8');
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* The schemas argument the digest query still ACCEPTS but no longer scopes by.
|
|
36
|
+
*
|
|
37
|
+
* `--schemas` never reached the stage path at all — the ops invoke sent only `{ sql, actor,
|
|
38
|
+
* stage }`, so a consumer who passed `--schemas auth,public` had it silently dropped and the
|
|
39
|
+
* server fell back to its own `options.schemas ?? ['public']`. Their 21 `auth.*` functions were
|
|
40
|
+
* outside the projection and the guard stayed silent on 43 schema statements.
|
|
41
|
+
*
|
|
42
|
+
* Rather than plumb the flag through, the digest now covers EVERY non-system schema
|
|
43
|
+
* (exec-digest.ts): a guard that can be scoped can be scoped to blindness. This is kept so the
|
|
44
|
+
* call sites read unchanged, and `--schemas` is now a documented no-op rather than a silent one.
|
|
45
|
+
*/
|
|
35
46
|
function directSchemas(flags: Record<string, string>): string[] {
|
|
36
47
|
if (flags.schemas && flags.schemas !== 'true') {
|
|
37
48
|
return flags.schemas.split(',').map((s) => s.trim()).filter(Boolean);
|
|
@@ -39,6 +50,13 @@ function directSchemas(flags: Record<string, string>): string[] {
|
|
|
39
50
|
return ['public'];
|
|
40
51
|
}
|
|
41
52
|
|
|
53
|
+
/** Say it, rather than accept a flag that does nothing. */
|
|
54
|
+
function noteSchemasIsNoLongerNeeded(flags: Record<string, string>): void {
|
|
55
|
+
if (flags.schemas && flags.schemas !== 'true') {
|
|
56
|
+
info('--schemas is no longer needed and no longer narrows anything: the DML-only guard now covers every non-system schema. (It used to default to `public`, which is how a file of auth.* functions once passed it.)');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
42
60
|
function reportOk(res: { rowsAffected?: number[] }): void {
|
|
43
61
|
const rows = res.rowsAffected ?? [];
|
|
44
62
|
const total = rows.reduce((a, b) => a + b, 0);
|
|
@@ -70,6 +88,7 @@ export async function dbExecCommand(flags: Record<string, string>, file?: string
|
|
|
70
88
|
|
|
71
89
|
const source = resolveDbSource(flags);
|
|
72
90
|
const actor = process.env.USER ?? null;
|
|
91
|
+
noteSchemasIsNoLongerNeeded(flags);
|
|
73
92
|
|
|
74
93
|
// --- Stage venue: ship the SQL to the ops Lambda's db:exec action (operator holds no URL). ---
|
|
75
94
|
if (source.kind === 'stage') {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* `everystack db:plan` — mint a verified edge against a target database
|
|
3
3
|
* (brick 4's mint surface; the review artifact of the deploy boundary).
|
|
4
4
|
*
|
|
5
|
-
* db:plan [--stage <name> | --database-url <url>] [--models <barrel>]
|
|
5
|
+
* db:plan [--stage <name> [--direct] | --database-url <url>] [--models <barrel>]
|
|
6
6
|
* [--allow-drops] [--out db.plan.json | --out -]
|
|
7
7
|
*
|
|
8
8
|
* Minting asks the TARGET its fingerprint (no repo-side release pointer to
|
|
@@ -17,12 +17,27 @@
|
|
|
17
17
|
* them (committing plans would rebuild the tape). Minting is read-only, so
|
|
18
18
|
* it works over the ops Lambda (--stage) as well as a direct connection.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
20
|
+
* THREE VENUES, and the difference is what the resulting fingerprint is worth:
|
|
21
|
+
*
|
|
22
|
+
* --stage <name> the ops Lambda. A full read is two invokes (state, then authz),
|
|
23
|
+
* answerable by two containers at two moments, so the minted `from`
|
|
24
|
+
* can describe no single real state. This lane reads TWICE and
|
|
25
|
+
* refuses a disagreement — a DETECTOR, never a verification
|
|
26
|
+
* (stage-read-consistency.ts). Agreement proves nothing.
|
|
27
|
+
* --stage <name> --direct the stage's OPERATOR connection, resolved from its IAM-gated ops
|
|
28
|
+
* Lambda and held in memory only. One real connection, one session,
|
|
29
|
+
* read ONCE — a fingerprint that describes one database at one
|
|
30
|
+
* moment. The credential never reaches argv.
|
|
31
|
+
* --database-url <url> a LOCAL connection. Same read guarantee as --direct, but against a
|
|
32
|
+
* deployed stage it forces a privileged DSN onto the command line.
|
|
33
|
+
*
|
|
34
|
+
* `--direct` EXISTED FOR db:apply AND NOT FOR db:plan, WHICH INVERTED THE COST OF SAFETY. The
|
|
35
|
+
* only lane that can verify a fingerprint was the only lane that made the operator print their
|
|
36
|
+
* credential, so verifying a plan cost them their secret hygiene — and the destructive refusal
|
|
37
|
+
* then recommended exactly that. A consumer's operator put it plainly: "every command that
|
|
38
|
+
* requires ARN is a risk of exposure. A major purpose of everystack is to PREVENT this." The
|
|
39
|
+
* resolution is shared with db:apply, db:reconcile, db:backfill, db:refresh and db:swap
|
|
40
|
+
* (direct-venue.ts) — db:plan was the verb that never adopted it.
|
|
26
41
|
*/
|
|
27
42
|
|
|
28
43
|
import fs from 'node:fs/promises';
|
|
@@ -43,6 +58,7 @@ import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '
|
|
|
43
58
|
import { borrowedSessionRunner, type SessionRunner } from '../session.js';
|
|
44
59
|
import { resolveModelsPath } from '../models-path.js';
|
|
45
60
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
61
|
+
import { resolveOperatorUrlViaStage } from '../direct-venue.js';
|
|
46
62
|
import { lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
|
|
47
63
|
import { loadModels } from './db-generate.js';
|
|
48
64
|
import { loadDeclaredDerived } from '../declared-derived.js';
|
|
@@ -98,6 +114,22 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
98
114
|
process.exit(1);
|
|
99
115
|
}
|
|
100
116
|
|
|
117
|
+
// `--stage --direct`: resolve the stage's OPERATOR connection from its IAM-gated ops Lambda
|
|
118
|
+
// and mint over it. The URL lives in this process's memory only — never printed, never
|
|
119
|
+
// written, never on argv. From here it IS a url source, so the single-read path below picks
|
|
120
|
+
// it up and `connectingVia` reports the operator credential honestly.
|
|
121
|
+
if (dbSource.kind === 'stage' && flags.direct === 'true') {
|
|
122
|
+
try {
|
|
123
|
+
step('Resolving the operator connection from the stage (--direct)...');
|
|
124
|
+
const op = await resolveOperatorUrlViaStage(flags.stage);
|
|
125
|
+
info(`operator credential resolved (${op.source}) — minting CLI-side over one connection.`);
|
|
126
|
+
dbSource = { kind: 'url', url: op.url, from: 'operator' };
|
|
127
|
+
} catch (err: any) {
|
|
128
|
+
fail(err.message);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
101
133
|
let runner: QueryRunner;
|
|
102
134
|
|
|
103
135
|
let session: SessionRunner;
|
|
@@ -480,7 +480,7 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
|
|
|
480
480
|
if (result?.error) {
|
|
481
481
|
fail(`db:reconcile failed: ${result.error}`);
|
|
482
482
|
if (/Unknown action/i.test(String(result.error))) {
|
|
483
|
-
info('The deployed handler predates the db:reconcile ops action (needs @everystack/server >= 0.4.8). Upgrade the server, or apply direct: db:reconcile --apply --database-url <url
|
|
483
|
+
info('The deployed handler predates the db:reconcile ops action (needs @everystack/server >= 0.4.8). Upgrade the server, or apply credential-free over the direct lane: db:reconcile --apply --stage ' + (flags.stage ?? '<stage>') + ' --direct. (--database-url <url> is the local venue — against a deployed stage it puts a privileged connection string on argv.)');
|
|
484
484
|
} else if (/timed out|timeout|task timed out/i.test(String(result.error))) {
|
|
485
485
|
info('A large derived rebuild (dozens of objects) can exceed the ops-Lambda 900-second clock. Re-run credential-free with an unbounded clock: db:reconcile --apply --stage ' + (flags.stage ?? '<stage>') + ' --direct.');
|
|
486
486
|
}
|
|
@@ -8,15 +8,26 @@
|
|
|
8
8
|
* unchanged — a pure DATA-changed matview refresh had no credential-free path. This is the
|
|
9
9
|
* data-lane twin: `--stage` runs the whole refresh in the ops Lambda on the operator connection
|
|
10
10
|
* (the `db:refresh` action), so a least-privileged operator never holds a URL. `--database-url`
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* refreshes over a local connection (dev). `--stage <name> --direct` resolves the stage's
|
|
12
|
+
* operator credential from its ops Lambda and refreshes CLI-side on an UNBOUNDED clock — the
|
|
13
|
+
* lane for a matview set that exceeds the ops-Lambda 900-second limit. `--list` previews the
|
|
14
|
+
* topo-ordered matviews without connecting. Plain REFRESH (ACCESS EXCLUSIVE) — CONCURRENTLY is
|
|
15
|
+
* a follow-on. (consumer field report 2026-07-19.)
|
|
16
|
+
*
|
|
17
|
+
* `--direct` WAS ADVERTISED HERE AND IN `everystack --help` WITHOUT BEING WIRED. `resolveDbSource`
|
|
18
|
+
* does not know the flag — each command wires it — so `db:refresh --direct` fell through to the
|
|
19
|
+
* stage branch and `resolveConfig(undefined)` picked the DEFAULT stage. An operator asking for a
|
|
20
|
+
* direct dev refresh could refresh a deployed stage's matviews instead, believing the flag had
|
|
21
|
+
* scoped them locally. A documented flag that silently does nothing is the same class as the
|
|
22
|
+
* refusal that recommended `--database-url`: the tool's own words sending the operator somewhere
|
|
23
|
+
* they did not choose.
|
|
14
24
|
*/
|
|
15
25
|
|
|
16
26
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
17
27
|
import { loadDeclaredDerived } from '../declared-derived.js';
|
|
18
28
|
import { executeRefresh, matviewIdentities, type RefreshRun } from '../refresh-execute.js';
|
|
19
29
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
30
|
+
import { resolveOperatorUrlViaStage } from '../direct-venue.js';
|
|
20
31
|
import { invokeAction } from '../aws.js';
|
|
21
32
|
import { step, success, fail, info } from '../output.js';
|
|
22
33
|
|
|
@@ -57,9 +68,26 @@ export async function dbRefreshCommand(flags: Record<string, string>): Promise<v
|
|
|
57
68
|
process.exit(1);
|
|
58
69
|
}
|
|
59
70
|
|
|
60
|
-
//
|
|
71
|
+
// `--stage --direct` (the shared direct venue): resolve the stage's OPERATOR connection from
|
|
72
|
+
// its ops Lambda and refresh CLI-side on an unbounded clock. The URL lives in this process's
|
|
73
|
+
// memory only — never printed, never written, never on argv. Same resolution db:apply,
|
|
74
|
+
// db:reconcile, db:backfill and db:swap use.
|
|
75
|
+
if (dbSource.kind === 'stage' && flags.direct === 'true') {
|
|
76
|
+
try {
|
|
77
|
+
step('Resolving the operator connection from the stage (--direct)...');
|
|
78
|
+
const op = await resolveOperatorUrlViaStage(flags.stage);
|
|
79
|
+
info(`operator credential resolved (${op.source}) — refreshing CLI-side, unbounded clock.`);
|
|
80
|
+
dbSource = { kind: 'url', url: op.url, from: 'operator' };
|
|
81
|
+
} catch (err: any) {
|
|
82
|
+
fail(err.message);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Three venues, mirroring the reconcile lane:
|
|
61
88
|
// - a deployed stage runs the whole refresh in the ops Lambda on the operator connection
|
|
62
89
|
// (the db:refresh action) — no raw URL on the operator's machine, the whole point.
|
|
90
|
+
// - --stage --direct resolves that same operator credential and runs CLI-side, unbounded.
|
|
63
91
|
// - a direct URL refreshes locally (dev).
|
|
64
92
|
let end: (() => Promise<void>) | undefined;
|
|
65
93
|
try {
|
|
@@ -79,7 +107,7 @@ export async function dbRefreshCommand(flags: Record<string, string>): Promise<v
|
|
|
79
107
|
if (result?.error) {
|
|
80
108
|
fail(`db:refresh failed: ${result.error}`);
|
|
81
109
|
if (/Unknown action/i.test(String(result.error))) {
|
|
82
|
-
info('The deployed handler predates the db:refresh ops action (needs @everystack/server >= 0.4.9). Upgrade the server, or refresh direct: db:refresh --database-url <url
|
|
110
|
+
info('The deployed handler predates the db:refresh ops action (needs @everystack/server >= 0.4.9). Upgrade the server, or refresh credential-free over the direct lane: db:refresh --stage ' + (flags.stage ?? '<stage>') + ' --direct. (--database-url <url> is the local venue — against a deployed stage it puts a privileged connection string on argv.)');
|
|
83
111
|
}
|
|
84
112
|
process.exit(1);
|
|
85
113
|
}
|
package/src/cli/commands/db.ts
CHANGED
|
@@ -610,7 +610,8 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
610
610
|
fail(`db:provision failed: ${err.message}`);
|
|
611
611
|
if (/Unknown action/i.test(String(err.message))) {
|
|
612
612
|
info('The deployed handler has no dbPlugin (no Ops Lambda), and no ADMIN_DATABASE_URL secret is set.');
|
|
613
|
-
info('
|
|
613
|
+
info('Preferred: set the secret once — everystack secrets set ADMIN_DATABASE_URL <url> --stage <stage> — then everystack db:provision --stage <stage> --direct reads it internally and the credential never reaches argv.');
|
|
614
|
+
info('Bootstrap fallback, when no secret exists yet: everystack db:provision --stage <stage> --database-url <master-url>. This puts the master credential on the command line (shell history, ps, CI logs) — do it once, then use --direct.');
|
|
614
615
|
}
|
|
615
616
|
for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
|
|
616
617
|
process.exit(1);
|
package/src/cli/exec-digest.ts
CHANGED
|
@@ -24,24 +24,63 @@
|
|
|
24
24
|
import { escapeLiteral } from './derived-apply.js';
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
* Build the digest query
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* Build the digest query. Returns SQL selecting a single `digest` column (md5 hex, or md5('')
|
|
28
|
+
* for an empty catalog).
|
|
29
|
+
*
|
|
30
|
+
* `schemas` IS ACCEPTED AND NO LONGER SCOPES THE DIGEST. It was the design error that made this
|
|
31
|
+
* guard fail in the field: a consumer ran 21 `CREATE OR REPLACE FUNCTION` in `auth` through
|
|
32
|
+
* db:exec and the guard stayed silent, because the deploy's schema set was `['public']` and the
|
|
33
|
+
* functions were simply outside the projection. **A guard that can be scoped can be scoped to
|
|
34
|
+
* blindness**, and the question db:exec asks is not "did the app's schemas move?" but "did the
|
|
35
|
+
* schema move AT ALL?". So the projection now covers every non-system schema, and no
|
|
36
|
+
* configuration — or missing configuration — can narrow it.
|
|
37
|
+
*
|
|
38
|
+
* The parameter stays in the signature deliberately: `@everystack/server` calls this through
|
|
39
|
+
* `@everystack/cli/exec`, so removing it would break every deployed ops Lambda that has not
|
|
40
|
+
* upgraded in lockstep. An old server passing `['public']` now gets full coverage.
|
|
41
|
+
*
|
|
42
|
+
* System schemas are excluded because they move for reasons that are not the caller's doing:
|
|
43
|
+
* `pg_toast` gains relations as tables acquire toastable columns, `pg_temp_*` appears and
|
|
44
|
+
* vanishes with sessions, and `pg_catalog`/`information_schema` are not the caller's to change.
|
|
30
45
|
*/
|
|
31
46
|
export function catalogDigestQuery(schemas: string[]): string {
|
|
32
47
|
if (schemas.length === 0) {
|
|
33
48
|
throw new Error('catalogDigestQuery needs at least one schema to digest — pass the app schemas.');
|
|
34
49
|
}
|
|
35
|
-
|
|
50
|
+
// Retained so the argument is not silently meaningless to a reader diffing this file: the
|
|
51
|
+
// value is validated exactly as before, then deliberately not used as a filter.
|
|
52
|
+
void schemas.map(escapeLiteral);
|
|
53
|
+
// A predicate, not an array: `= ANY(array_agg(nspname))` compares name to name[] and
|
|
54
|
+
// PostgreSQL has no such operator. Every branch below aliases pg_namespace as `n`.
|
|
55
|
+
const NS = `n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
|
|
56
|
+
AND n.nspname NOT LIKE 'pg_temp%' AND n.nspname NOT LIKE 'pg_toast_temp%'`;
|
|
36
57
|
return `
|
|
37
58
|
SELECT md5(coalesce(string_agg(line, E'\\n' ORDER BY line), '')) AS digest
|
|
38
59
|
FROM (
|
|
39
60
|
-- Relations: kind + persistence ONLY, never the volatile planner-stats columns (row-count,
|
|
40
61
|
-- page-count, freeze-horizon) — those move under INSERT/UPDATE/DELETE and autovacuum, and
|
|
41
62
|
-- would make the digest lie about pure DML.
|
|
42
|
-
|
|
63
|
+
-- relacl carries the GRANTs, and relrowsecurity/relforcerowsecurity the RLS posture. Both
|
|
64
|
+
-- were missing, so GRANT SELECT ON t TO anon, and ALTER TABLE t ENABLE ROW LEVEL SECURITY,
|
|
65
|
+
-- passed a gate advertising "rejects ANY schema change". A lane that cannot change the schema
|
|
66
|
+
-- but CAN change who may read it is not a DML-only lane. None of the three move under DML.
|
|
67
|
+
SELECT format('rel:%s.%s:%s:%s:%s:%s:%s', n.nspname, c.relname, c.relkind, c.relpersistence,
|
|
68
|
+
coalesce(c.relacl::text, ''), c.relrowsecurity, c.relforcerowsecurity) AS line
|
|
43
69
|
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
44
|
-
WHERE
|
|
70
|
+
WHERE ${NS} AND c.relkind IN ('r', 'p', 'v', 'm', 'S', 'f')
|
|
71
|
+
UNION ALL
|
|
72
|
+
-- Row policies: a CREATE/ALTER/DROP POLICY rewrites who sees which rows and touches no other
|
|
73
|
+
-- catalog this projection reads, so without this it was invisible.
|
|
74
|
+
SELECT format('pol:%s.%s.%s:%s:%s:%s:%s:%s', n.nspname, c.relname, pol.polname, pol.polcmd,
|
|
75
|
+
pol.polpermissive,
|
|
76
|
+
coalesce((SELECT string_agg(r.rolname, ',' ORDER BY r.rolname)
|
|
77
|
+
FROM pg_roles r WHERE r.oid = ANY(pol.polroles)), 'PUBLIC'),
|
|
78
|
+
coalesce(pg_get_expr(pol.polqual, pol.polrelid), ''),
|
|
79
|
+
coalesce(pg_get_expr(pol.polwithcheck, pol.polrelid), ''))
|
|
80
|
+
FROM pg_policy pol
|
|
81
|
+
JOIN pg_class c ON c.oid = pol.polrelid
|
|
82
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
83
|
+
WHERE ${NS}
|
|
45
84
|
UNION ALL
|
|
46
85
|
-- Columns: number, type, typmod, not-null — the shape ALTER TABLE moves, DML never.
|
|
47
86
|
SELECT format('att:%s.%s.%s:%s:%s:%s:%s',
|
|
@@ -49,31 +88,34 @@ FROM (
|
|
|
49
88
|
FROM pg_attribute a
|
|
50
89
|
JOIN pg_class c ON c.oid = a.attrelid
|
|
51
90
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
52
|
-
WHERE
|
|
91
|
+
WHERE ${NS} AND a.attnum > 0 AND NOT a.attisdropped
|
|
53
92
|
UNION ALL
|
|
54
93
|
-- Constraints: the full textual definition (PK/FK/unique/check predicate).
|
|
55
94
|
SELECT format('con:%s.%s:%s', n.nspname, con.conname, pg_get_constraintdef(con.oid))
|
|
56
95
|
FROM pg_constraint con JOIN pg_namespace n ON n.oid = con.connamespace
|
|
57
|
-
WHERE
|
|
96
|
+
WHERE ${NS}
|
|
58
97
|
UNION ALL
|
|
59
98
|
-- Indexes: the full textual definition (columns, uniqueness, partial WHERE).
|
|
60
99
|
SELECT format('idx:%s.%s:%s', n.nspname, ic.relname, pg_get_indexdef(i.indexrelid))
|
|
61
100
|
FROM pg_index i
|
|
62
101
|
JOIN pg_class ic ON ic.oid = i.indexrelid
|
|
63
102
|
JOIN pg_namespace n ON n.oid = ic.relnamespace
|
|
64
|
-
WHERE
|
|
103
|
+
WHERE ${NS}
|
|
65
104
|
UNION ALL
|
|
66
|
-
-- Routines: name + arg types + a hash of the body
|
|
67
|
-
|
|
105
|
+
-- Routines: name + arg types + a hash of the body, PLUS proacl (the EXECUTE grants) and
|
|
106
|
+
-- prosecdef. A REVOKE that leaves a SECURITY DEFINER function PUBLIC-executable, or a GRANT
|
|
107
|
+
-- that opens one, is a privilege change and must trip the guard like any other.
|
|
108
|
+
SELECT format('proc:%s.%s:%s:%s:%s:%s', n.nspname, p.proname, p.proargtypes::text,
|
|
109
|
+
md5(coalesce(p.prosrc, '')), coalesce(p.proacl::text, ''), p.prosecdef)
|
|
68
110
|
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
69
|
-
WHERE
|
|
111
|
+
WHERE ${NS}
|
|
70
112
|
UNION ALL
|
|
71
113
|
-- Types: kind + ordered enum labels. CREATE TYPE / ALTER TYPE ADD VALUE move it.
|
|
72
114
|
SELECT format('type:%s.%s:%s:%s', n.nspname, t.typname, t.typtype,
|
|
73
115
|
coalesce((SELECT string_agg(e.enumlabel, ',' ORDER BY e.enumsortorder)
|
|
74
116
|
FROM pg_enum e WHERE e.enumtypid = t.oid), ''))
|
|
75
117
|
FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace
|
|
76
|
-
WHERE
|
|
118
|
+
WHERE ${NS}
|
|
77
119
|
) s
|
|
78
120
|
`.trim();
|
|
79
121
|
}
|
package/src/cli/index.ts
CHANGED
|
@@ -363,10 +363,10 @@ Usage:
|
|
|
363
363
|
Both introspect via the deployed ops Lambda by default; --database-url (or an inherited DATABASE_URL) connects directly — for a schema that exists only on a local Postgres.
|
|
364
364
|
everystack db:fingerprint [--stage <name> | --database-url <url>] [--models <barrel>] [--json] Content-address the live base schema (tables+constraints+authz) and compare against the models — MATCH/MISMATCH (exit 1), plus the unfingerprinted-objects report
|
|
365
365
|
everystack db:reconcile [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--rebuild] [--overwrite-drift] [--only a,b] [--json] Reconcile the derived layer (functions/views/matviews/triggers) against the DECLARED descriptors (defineView/defineMaterializedView/defineFunction/defineSql/trigger() on models, from the barrel) — the single home (db/sql is retired; leftover .sql files fail with the migration path): plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (atomic — DDL + provenance in one transaction) and records provenance + schema_log; --apply --stage runs credential-free in the ops Lambda (no admin URL on the deployer, the db:apply twin), --apply --database-url runs direct. Hand-edits are drift (never overwritten silently). First contact with existing objects: --baseline TRUSTS live == source (records provenance, verifies nothing), --rebuild GUARANTEES it (drop+create from source). They are mutually exclusive. --only <schema.name,…> restricts the run to the named objects (surgical); with --rebuild it FORCES those to rebuild from source even when the hashes show no diff — the recovery exit when a mistaken --rebaseline left a self-consistent-but-wrong provenance row (the dependency cascade rebuilds their live dependents).
|
|
366
|
-
everystack db:refresh [--stage <name> | --database-url <url> | --direct] [--only a,b] [--verify-nonempty] [--list] Refresh the declared materialized views in dependency order, credential-free. --stage runs the whole refresh in the ops Lambda on the operator connection (no URL on the operator's machine — the data-lane twin of the reconcile lane); --database-url
|
|
366
|
+
everystack db:refresh [--stage <name> | --database-url <url> | --direct] [--only a,b] [--verify-nonempty] [--list] Refresh the declared materialized views in dependency order, credential-free. --stage runs the whole refresh in the ops Lambda on the operator connection (no URL on the operator's machine — the data-lane twin of the reconcile lane); --database-url refreshes over a local connection (dev); --stage <name> --direct resolves the stage's operator connection from its ops Lambda (credential never on argv) and refreshes CLI-side on an UNBOUNDED clock, for a matview set that exceeds the ops-Lambda 900s limit; --only refreshes a named subset (full identity or bare name); --verify-nonempty gates on populated-but-zero-rows matviews (the dark-panel check — a scoped EXISTS on just what was refreshed, so a promotion script gets the gate with no read authority) and FAILS naming any empty; --list previews the order without connecting. Plain REFRESH (ACCESS EXCLUSIVE); fail-fast, idempotent to re-run.
|
|
367
367
|
everystack db:sync [--database-url <url>] [--models <barrel>] [--schema-out <file.ts>] [--allow-drops] [--overwrite-drift] [--baseline] [--json] Make the database match your checkout — one verb, both layers: apply the state diff (tables+authz, one transaction, verified by re-diff), reconcile the derived layer against the declared descriptors, report the resulting fingerprint vs the models' declared one. Dev databases only (direct connection required); DROPs held back unless --allow-drops; derived drift refuses unless --overwrite-drift; exit 1 when not converged
|
|
368
368
|
everystack db:diff --from-models <barrel> [--to-models db/models/index.ts] [--allow-drops] [--check] [--json] The state edge between two declared states, NO database: the SQL db:generate would produce, computed purely — CI plan previews (--check exits 1 on a non-empty edge) and computed rollbacks (swap the flags)
|
|
369
|
-
everystack db:plan [--stage <name> | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] Mint a verified edge against a target: asks the TARGET its fingerprint, diffs the models, writes ONE reviewable plan (edge + both endpoint fingerprints). Held drops refuse the mint (--allow-drops carries destruction explicitly). Read-only
|
|
369
|
+
everystack db:plan [--stage <name> [--direct] | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] Mint a verified edge against a target: asks the TARGET its fingerprint, diffs the models, writes ONE reviewable plan (edge + both endpoint fingerprints). Held drops refuse the mint (--allow-drops carries destruction explicitly). Read-only; plans are ephemeral, never committed. VENUES: --stage runs via the ops Lambda, which reads TWICE and refuses on disagreement — agreement there is a DETECTOR, not a verification. --stage --direct resolves the stage's operator connection from its IAM-gated ops Lambda, holds it in memory only, and reads ONCE over one session: the lane for a fingerprint you intend to trust, with the credential never on argv. --database-url is the local-dev venue (same read guarantee, but against a deployed stage it puts a privileged DSN on the command line)
|
|
370
370
|
everystack db:apply --plan <file.plan.json> [--database-url <url>] [--stage <name>] [--models <barrel>] [--confirm] [--snapshot-ref <ref>] [--force-descent <snapshot-ref> --confirm] Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), verify the checkout DESCENDS from the commit declaring the target's state (the fast-forward rule, else refuse — "rebase first"), and for DESTRUCTIVE plans require --confirm always + an attested --snapshot-ref + the stage's approver set when declared (STS identity-verified). The STAGE lane (--stage without --direct) runs every catalog query in its own ops-Lambda invoke, so one read can be assembled from several containers: it reads the target TWICE and REFUSES when the two disagree (inconsistent containers), reports agreement as a NON-DETECTION (it cannot verify read consistency), and REFUSES a DESTRUCTIVE plan outright — destructive applies go over --database-url (direct) with the full ceremony. Every refusal that reaches the ops Lambda is recorded in schema_log. Apply as one transaction (plan_ref stamped), verify it landed exactly on plan.to; idempotent when already there
|
|
371
371
|
everystack db:check [--models <barrel>] [--schema-out <file.ts>] [--database-url <url>] [--json] The CI gate, per PR: the merged declared state must COMPOSE (models load, no duplicate tables, descriptors compile), every exposed RLS-enabled table must declare a read path (no force-RLS-with-no-read landmine that goes dark on the superuser drop), and generated artifacts must MATCH regeneration byte-for-byte; with a scratch PostgreSQL it builds the state from scratch on an ephemeral database (created + dropped) and requires fingerprint MATCH. Exit 1 on any failure; never touches a real target
|
|
372
372
|
everystack db:approvers --stage <name> [--set "cto,arn:..."] [--remove] Declare who can DESTROY: the stage's destructive-approver set (SSM parameter, admin-writable). Destructive db:apply runs are then identity-verified (STS) against it; --set '' disables destructive applies; --remove returns the stage to ceremony-only
|
|
@@ -89,17 +89,34 @@ export function inconsistentContainersNotDetected(): string {
|
|
|
89
89
|
* The refusal a DESTRUCTIVE plan gets on the stage lane, before anything runs.
|
|
90
90
|
* The double read cannot lift this: a detector that can miss is not a basis for
|
|
91
91
|
* dropping data. The destructive ceremony lives on the direct lane.
|
|
92
|
+
*
|
|
93
|
+
* THE WAY OUT IS `--stage <name> --direct`, AND SAYING SO IS THE POINT OF THIS
|
|
94
|
+
* MESSAGE. It used to end by recommending `--database-url <url>` while calling it
|
|
95
|
+
* "a direct connection" — which is true of the connection and catastrophic as
|
|
96
|
+
* advice: `--database-url` is the ONE lane that puts a privileged DSN on argv, and
|
|
97
|
+
* from there into shell history, `ps`/`/proc/<pid>/cmdline`, scrollback, screen
|
|
98
|
+
* shares and CI logs. `--stage --direct` has identical trust properties (one real
|
|
99
|
+
* connection, one session) and resolves the credential through the stage's IAM-gated
|
|
100
|
+
* ops Lambda into process memory, where it is never printed or written.
|
|
101
|
+
*
|
|
102
|
+
* A consumer read this message, did what it said, and their operator objected:
|
|
103
|
+
* "every command that requires ARN is a risk of exposure. A major purpose of
|
|
104
|
+
* everystack is to PREVENT this." The capability they needed already existed. The
|
|
105
|
+
* refusal pointed away from it. Keep `--stage --direct` first, and keep
|
|
106
|
+
* `--database-url` marked as the LOCAL venue it is.
|
|
92
107
|
*/
|
|
93
108
|
export function stageDestructiveRefusal(
|
|
94
109
|
shape: string,
|
|
95
110
|
planPath: string,
|
|
96
111
|
stage: string | undefined,
|
|
97
112
|
): string {
|
|
98
|
-
const
|
|
113
|
+
const stageArg = stage ? ` --stage ${stage}` : ' --stage <name>';
|
|
114
|
+
const takeIt = `everystack db:backup${stageArg}`;
|
|
99
115
|
return [
|
|
100
116
|
`REFUSED (stage lane, DESTRUCTIVE plan — ${shape}): the stage lane cannot guarantee read consistency.`,
|
|
101
117
|
'A full read is two ops-Lambda invokes (state, then authz), so the fingerprint the concurrency lock gates on can still be assembled from two containers at two moments. A plan that loses data may not ride a read that cannot be verified.',
|
|
102
|
-
`
|
|
118
|
+
`Use the direct lane, which reads over ONE connection and never puts a credential on argv — it resolves the operator connection from the stage itself: ${takeIt}, then everystack db:apply --plan ${planPath}${stageArg} --direct --confirm --snapshot-ref <id>.`,
|
|
119
|
+
'(--database-url is the LOCAL-development venue. Against a deployed stage it forces a privileged connection string onto the command line — shell history, ps, scrollback, CI logs — and buys nothing --direct does not already give you.)',
|
|
103
120
|
].join(' ');
|
|
104
121
|
}
|
|
105
122
|
|