@everystack/cli 0.3.22 → 0.3.23
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/README.md +2 -1
- package/package.json +9 -4
- package/src/cli/apply-authority.ts +150 -0
- package/src/cli/commands/db-apply.ts +117 -2
- package/src/cli/commands/db-approvers.ts +71 -0
- package/src/cli/commands/db-sync.ts +5 -0
- package/src/cli/edge-plan.ts +35 -8
- package/src/cli/index.ts +6 -1
- package/src/cli/schema-source.ts +5 -0
- package/src/cli/state-apply.ts +30 -0
package/README.md
CHANGED
|
@@ -220,8 +220,9 @@ everystack db:reconcile # deploy the derived layer (functions/vi
|
|
|
220
220
|
everystack db:fingerprint # content-address the live base schema vs the Models — MATCH/MISMATCH
|
|
221
221
|
everystack db:sync # make the database match your checkout — state + compute, one verb (dev DBs)
|
|
222
222
|
everystack db:diff # the state edge between two declared states — no DB, CI-pure; reverse = computed rollback
|
|
223
|
-
everystack db:plan | db:apply # mint a reviewable plan against a target, apply it fingerprint-verified at both ends + the fast-forward rule (checkout must descend from the commit declaring the target's state)
|
|
223
|
+
everystack db:plan | db:apply # mint a reviewable plan against a target, apply it fingerprint-verified at both ends + the fast-forward rule (checkout must descend from the commit declaring the target's state); destructive plans: --confirm + snapshot + approver set when declared
|
|
224
224
|
everystack db:check # the CI gate per PR: merged declared state composes + artifacts match regeneration (+ ephemeral from-scratch build w/ a scratch PG)
|
|
225
|
+
everystack db:approvers # declare who can DESTROY: the stage's destructive-approver set (SSM, STS-verified at apply)
|
|
225
226
|
|
|
226
227
|
# Security
|
|
227
228
|
everystack db:doctor # is the DB least-privilege + RLS-subject?
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.23",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"structured-headers": "1.0.1",
|
|
79
79
|
"tsx": "4.21.0",
|
|
80
80
|
"typescript": "5.9.3",
|
|
81
|
-
"@everystack/model": "0.3.
|
|
81
|
+
"@everystack/model": "0.3.6"
|
|
82
82
|
},
|
|
83
83
|
"peerDependencies": {
|
|
84
84
|
"@everystack/server": ">=0.1.0",
|
|
@@ -93,7 +93,8 @@
|
|
|
93
93
|
"expo-updates": "55.0.21",
|
|
94
94
|
"postgres": "3.4.9",
|
|
95
95
|
"react": "19.2.0",
|
|
96
|
-
"react-native": "0.83.6"
|
|
96
|
+
"react-native": "0.83.6",
|
|
97
|
+
"@aws-sdk/client-sts": "3.1053.0"
|
|
97
98
|
},
|
|
98
99
|
"peerDependenciesMeta": {
|
|
99
100
|
"postgres": {
|
|
@@ -131,6 +132,9 @@
|
|
|
131
132
|
},
|
|
132
133
|
"@aws-sdk/s3-request-presigner": {
|
|
133
134
|
"optional": true
|
|
135
|
+
},
|
|
136
|
+
"@aws-sdk/client-sts": {
|
|
137
|
+
"optional": true
|
|
134
138
|
}
|
|
135
139
|
},
|
|
136
140
|
"devDependencies": {
|
|
@@ -148,7 +152,8 @@
|
|
|
148
152
|
"drizzle-orm": "0.41.0",
|
|
149
153
|
"jest": "29.7.0",
|
|
150
154
|
"react": "19.2.0",
|
|
151
|
-
"ts-jest": "29.4.9"
|
|
155
|
+
"ts-jest": "29.4.9",
|
|
156
|
+
"@aws-sdk/client-sts": "3.1053.0"
|
|
152
157
|
},
|
|
153
158
|
"scripts": {
|
|
154
159
|
"test": "jest",
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* apply-authority — destructive applies are role-gated to a declared
|
|
3
|
+
* approver set, enforced against the caller's REAL identity (brick 9 of the
|
|
4
|
+
* migration-authority design, decisions 11 + 12).
|
|
5
|
+
*
|
|
6
|
+
* "CTO drops tables; everyone else creates functions and views." The
|
|
7
|
+
* approver set lives STAGE-SIDE — an SSM parameter in the stage's namespace,
|
|
8
|
+
* admin-writable only — never in the repo, where a checkout edit would
|
|
9
|
+
* defeat it (a repo-declared list is a convention wearing a gate's clothes).
|
|
10
|
+
* The chokepoint resolves the caller's real identity via STS — the same
|
|
11
|
+
* pattern as the `db:psql` break-glass, where the IAM read IS the gate.
|
|
12
|
+
*
|
|
13
|
+
* The gate engages on DECLARATION: a stage that never declared approvers
|
|
14
|
+
* keeps the ceremony (explicit confirmation + snapshot, always) but is not
|
|
15
|
+
* identity-gated — the migration-friendly posture, stated honestly. A
|
|
16
|
+
* declared-but-EMPTY set means "no one": destructive applies are disabled
|
|
17
|
+
* on that stage until the set says otherwise.
|
|
18
|
+
*
|
|
19
|
+
* The refusal is the two-person flow's handoff: it names who CAN approve,
|
|
20
|
+
* and `plan_ref` already pins the exact reviewed artifact — a dev mints the
|
|
21
|
+
* destructive plan, an approver applies that same file.
|
|
22
|
+
*
|
|
23
|
+
* The decision core is pure; the AWS edge (SSM + STS) is thin and injected
|
|
24
|
+
* by the command shell.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** The SSM parameter holding the approver set — a JSON array of entries. */
|
|
28
|
+
export function approverParamName(appName: string, stage: string): string {
|
|
29
|
+
return `/everystack/${appName}/${stage}/db-approvers`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Does an approver entry match a caller ARN? Exact ARN match, or a match on
|
|
34
|
+
* any path segment after the first slash — so the set can hold plain names:
|
|
35
|
+
* `ty` matches `arn:aws:iam::123:user/ty` and the session name in
|
|
36
|
+
* `arn:aws:sts::123:assumed-role/Admin/ty`. Segment (not suffix) matching:
|
|
37
|
+
* `ty` never matches `user/notty`.
|
|
38
|
+
*/
|
|
39
|
+
export function approverMatches(entry: string, identityArn: string): boolean {
|
|
40
|
+
if (entry === identityArn) return true;
|
|
41
|
+
const segments = identityArn.split('/').slice(1);
|
|
42
|
+
return segments.includes(entry);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AuthorityInputs {
|
|
46
|
+
/** The declared approver set; null = the stage never declared one. */
|
|
47
|
+
approvers: string[] | null;
|
|
48
|
+
/** The caller's resolved identity (STS ARN); null = unresolvable. */
|
|
49
|
+
identity: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type AuthorityVerdict =
|
|
53
|
+
| { ok: true; reason: 'approved' | 'undeclared' }
|
|
54
|
+
| { ok: false; reason: string };
|
|
55
|
+
|
|
56
|
+
/** The pure decision core for a DESTRUCTIVE apply. Non-destructive plans never reach it. */
|
|
57
|
+
export function checkDestructiveAuthority({ approvers, identity }: AuthorityInputs): AuthorityVerdict {
|
|
58
|
+
if (approvers === null) return { ok: true, reason: 'undeclared' };
|
|
59
|
+
if (approvers.length === 0) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
reason:
|
|
63
|
+
'the declared approver set is EMPTY — destructive applies are disabled on this stage. ' +
|
|
64
|
+
'Declare approvers with `everystack db:approvers --set <arn-or-name,...>` (admin-writable parameter).',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (identity === null) {
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
reason:
|
|
71
|
+
'this stage declares a destructive-approver set, so the apply must be identity-verified — ' +
|
|
72
|
+
'and your AWS identity could not be resolved (credentials?). Fix the credentials or hand the plan to an approver.',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (approvers.some((entry) => approverMatches(entry, identity))) {
|
|
76
|
+
return { ok: true, reason: 'approved' };
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
reason:
|
|
81
|
+
`you are ${identity}, and the declared approvers are: ${approvers.join(', ')}. ` +
|
|
82
|
+
'Hand the plan file to an approver — they review and apply the exact same artifact (its plan_ref pins it).',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// The thin AWS edge (lazy imports, injected by the command shell).
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
/** Read the approver set from SSM. Null when the parameter does not exist. */
|
|
91
|
+
export async function readApproverParam(region: string, appName: string, stage: string): Promise<string[] | null> {
|
|
92
|
+
const { SSMClient, GetParameterCommand } = await import('@aws-sdk/client-ssm');
|
|
93
|
+
const client = new SSMClient({ region });
|
|
94
|
+
try {
|
|
95
|
+
const result = await client.send(new GetParameterCommand({ Name: approverParamName(appName, stage) }));
|
|
96
|
+
return parseApproverValue(result.Parameter?.Value ?? '');
|
|
97
|
+
} catch (err: any) {
|
|
98
|
+
if (err?.name === 'ParameterNotFound') return null;
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The stored form is a JSON array; a bare comma-separated string is accepted on read. */
|
|
104
|
+
export function parseApproverValue(value: string): string[] {
|
|
105
|
+
const trimmed = value.trim();
|
|
106
|
+
if (trimmed === '') return [];
|
|
107
|
+
if (trimmed.startsWith('[')) {
|
|
108
|
+
const parsed = JSON.parse(trimmed);
|
|
109
|
+
if (!Array.isArray(parsed) || parsed.some((e) => typeof e !== 'string')) {
|
|
110
|
+
throw new Error('db-approvers parameter must be a JSON array of strings');
|
|
111
|
+
}
|
|
112
|
+
return parsed.map((e: string) => e.trim()).filter(Boolean);
|
|
113
|
+
}
|
|
114
|
+
return trimmed.split(',').map((e) => e.trim()).filter(Boolean);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Declare (or replace) the approver set. Stored as a JSON array. */
|
|
118
|
+
export async function writeApproverParam(region: string, appName: string, stage: string, approvers: string[]): Promise<void> {
|
|
119
|
+
const { SSMClient, PutParameterCommand } = await import('@aws-sdk/client-ssm');
|
|
120
|
+
const client = new SSMClient({ region });
|
|
121
|
+
await client.send(new PutParameterCommand({
|
|
122
|
+
Name: approverParamName(appName, stage),
|
|
123
|
+
Value: JSON.stringify(approvers),
|
|
124
|
+
Type: 'String',
|
|
125
|
+
Overwrite: true,
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Remove the declaration entirely — the stage returns to the undeclared (ceremony-only) posture. */
|
|
130
|
+
export async function removeApproverParam(region: string, appName: string, stage: string): Promise<void> {
|
|
131
|
+
const { SSMClient, DeleteParameterCommand } = await import('@aws-sdk/client-ssm');
|
|
132
|
+
const client = new SSMClient({ region });
|
|
133
|
+
try {
|
|
134
|
+
await client.send(new DeleteParameterCommand({ Name: approverParamName(appName, stage) }));
|
|
135
|
+
} catch (err: any) {
|
|
136
|
+
if (err?.name !== 'ParameterNotFound') throw err;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The caller's real identity — the STS ARN. Null when credentials can't answer. */
|
|
141
|
+
export async function resolveCallerIdentity(region: string): Promise<string | null> {
|
|
142
|
+
const { STSClient, GetCallerIdentityCommand } = await import('@aws-sdk/client-sts');
|
|
143
|
+
const client = new STSClient({ region });
|
|
144
|
+
try {
|
|
145
|
+
const result = await client.send(new GetCallerIdentityCommand({}));
|
|
146
|
+
return result.Arn ?? null;
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -27,11 +27,14 @@ import { introspectSchema, type SchemaSnapshot } from '../schema-introspect.js';
|
|
|
27
27
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
28
28
|
import { fingerprintLive } from '../schema-fingerprint.js';
|
|
29
29
|
import { applyGeneratedStatements, currentGitRef } from '../state-apply.js';
|
|
30
|
-
import { renderSchemaLogFingerprintUpdate } from '../derived-apply.js';
|
|
30
|
+
import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from '../derived-apply.js';
|
|
31
31
|
import { checkPlanPrecondition, planHash, PLAN_VERSION, type EdgePlan } from '../edge-plan.js';
|
|
32
32
|
import { verifyDescent, type LiveState } from '../git-descent.js';
|
|
33
|
+
import { checkDestructiveAuthority, readApproverParam, resolveCallerIdentity } from '../apply-authority.js';
|
|
33
34
|
import { resolveModelsPath } from '../models-path.js';
|
|
34
35
|
import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
|
|
36
|
+
import { resolveConfig, opsFunction } from '../config.js';
|
|
37
|
+
import { invokeAction } from '../aws.js';
|
|
35
38
|
import { step, success, fail, info, warn } from '../output.js';
|
|
36
39
|
|
|
37
40
|
// ---------------------------------------------------------------------------
|
|
@@ -54,17 +57,63 @@ export interface ApplyPlanOptions {
|
|
|
54
57
|
contract: AuthzContract;
|
|
55
58
|
fingerprint: string;
|
|
56
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>;
|
|
57
75
|
/** Injectable clock for tests. */
|
|
58
76
|
now?: () => number;
|
|
59
77
|
}
|
|
60
78
|
|
|
61
79
|
export interface ApplyPlanResult {
|
|
62
|
-
status: 'applied' | 'already-applied' | 'refused' | 'descent-refused' | 'verify-failed';
|
|
80
|
+
status: 'applied' | 'already-applied' | 'refused' | 'descent-refused' | 'authority-refused' | 'verify-failed';
|
|
63
81
|
liveFingerprint: string;
|
|
64
82
|
reason?: string;
|
|
65
83
|
logId?: number;
|
|
66
84
|
}
|
|
67
85
|
|
|
86
|
+
/**
|
|
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).
|
|
91
|
+
*/
|
|
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.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
68
117
|
/** Verify → apply → verify. Pure orchestration over an injected QueryRunner. */
|
|
69
118
|
export async function executeApplyPlan(
|
|
70
119
|
runner: QueryRunner,
|
|
@@ -80,16 +129,30 @@ export async function executeApplyPlan(
|
|
|
80
129
|
}
|
|
81
130
|
const pre = checkPlanPrecondition(plan, live);
|
|
82
131
|
if (!pre.ok) {
|
|
132
|
+
await recordRefusal(runner, plan, live, 'concurrency lock', pre.reason, options);
|
|
83
133
|
return { status: 'refused', liveFingerprint: live, reason: pre.reason };
|
|
84
134
|
}
|
|
85
135
|
|
|
86
136
|
if (options.verifyDescent) {
|
|
87
137
|
const descent = await options.verifyDescent({ snapshot: before, contract: beforeAuthz, fingerprint: live });
|
|
88
138
|
if (!descent.ok) {
|
|
139
|
+
await recordRefusal(runner, plan, live, 'fast-forward rule', descent.reason, options);
|
|
89
140
|
return { status: 'descent-refused', liveFingerprint: live, reason: descent.reason };
|
|
90
141
|
}
|
|
91
142
|
}
|
|
92
143
|
|
|
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 };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (plan.destructive > 0 && options.beforeDestructive) {
|
|
153
|
+
await options.beforeDestructive();
|
|
154
|
+
}
|
|
155
|
+
|
|
93
156
|
const result = await applyGeneratedStatements(runner, plan.statements, {
|
|
94
157
|
actor: options.actor,
|
|
95
158
|
gitRef: options.gitRef ?? plan.gitRef,
|
|
@@ -170,6 +233,52 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
170
233
|
}
|
|
171
234
|
}
|
|
172
235
|
|
|
236
|
+
// Losing data should be hard (decision 11): a destructive plan applies
|
|
237
|
+
// only confirmed (--confirm, always), snapshotted (automatic with --stage,
|
|
238
|
+
// named over a bare connection), and — when the stage declares an approver
|
|
239
|
+
// set — identity-approved (decision 12).
|
|
240
|
+
const isDestructive = plan.destructive > 0;
|
|
241
|
+
let verifyAuthority: (() => Promise<{ ok: true } | { ok: false; reason: string }>) | undefined;
|
|
242
|
+
let beforeDestructive: (() => Promise<void>) | undefined;
|
|
243
|
+
if (isDestructive) {
|
|
244
|
+
const shape = `${plan.classification.drops} drop(s), ${plan.classification.narrowings} narrowing type change(s)`;
|
|
245
|
+
if (flags.confirm !== 'true') {
|
|
246
|
+
fail(`This plan is DESTRUCTIVE — ${plan.destructive} statement(s) lose data (${shape}). Explicit confirmation is required, always: re-run with --confirm.`);
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
if (flags.stage) {
|
|
250
|
+
const config = await resolveConfig(flags.stage);
|
|
251
|
+
const { parseAppName } = await import('../discover.js');
|
|
252
|
+
const appName = await parseAppName();
|
|
253
|
+
verifyAuthority = async () => {
|
|
254
|
+
step(`Destructive authority: checking the approver set for stage ${flags.stage}...`);
|
|
255
|
+
const approvers = await readApproverParam(config.region, appName, flags.stage);
|
|
256
|
+
const identity = approvers === null ? null : await resolveCallerIdentity(config.region);
|
|
257
|
+
const verdict = checkDestructiveAuthority({ approvers, identity });
|
|
258
|
+
if (!verdict.ok) return verdict;
|
|
259
|
+
if (verdict.reason === 'approved') {
|
|
260
|
+
info(`authority: ${identity} is a declared approver.`);
|
|
261
|
+
} else {
|
|
262
|
+
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);
|
|
263
|
+
}
|
|
264
|
+
return { ok: true };
|
|
265
|
+
};
|
|
266
|
+
beforeDestructive = async () => {
|
|
267
|
+
step('Destructive apply — taking a snapshot first (db:backup)...');
|
|
268
|
+
const result: any = await invokeAction(config.region, opsFunction(config), 'db:backup', { stage: flags.stage });
|
|
269
|
+
if (result?.error) {
|
|
270
|
+
throw new Error(`auto-snapshot failed, so the destructive plan was NOT applied: ${result.error}`);
|
|
271
|
+
}
|
|
272
|
+
info(`snapshot on record: ${result?.id ?? 'backup complete'} — restore with db:restore --from ${result?.id ?? '<id>'} --confirm.`);
|
|
273
|
+
};
|
|
274
|
+
} else if (!flags['snapshot-ref']) {
|
|
275
|
+
fail(`This plan is DESTRUCTIVE (${shape}) and the target is a bare connection — the apply cannot take the snapshot itself. Take one (db:snapshot / db:backup / pg_dump) and name it: --snapshot-ref <ref>. Or pass --stage and the apply snapshots automatically, with the stage's approver set enforced.`);
|
|
276
|
+
process.exit(1);
|
|
277
|
+
} else {
|
|
278
|
+
warn(`DESTRUCTIVE apply over a bare connection — approver gating unavailable (no --stage), proceeding on ceremony: --confirm + snapshot ${flags['snapshot-ref']}.`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
173
282
|
step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
|
|
174
283
|
const { runner, end } = await createUrlRunner(dbSource.url);
|
|
175
284
|
|
|
@@ -182,6 +291,8 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
182
291
|
const result = await executeApplyPlan(runner, plan, {
|
|
183
292
|
actor: process.env.USER ?? null,
|
|
184
293
|
gitRef: currentGitRef() ?? plan.gitRef,
|
|
294
|
+
...(verifyAuthority ? { verifyAuthority } : {}),
|
|
295
|
+
...(beforeDestructive ? { beforeDestructive } : {}),
|
|
185
296
|
...(forceDescent === undefined ? {
|
|
186
297
|
verifyDescent: async (live: LiveState & { fingerprint: string }) => {
|
|
187
298
|
step('Descent: searching git for the commit that declares the target\'s state...');
|
|
@@ -216,6 +327,10 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
216
327
|
fail(`REFUSED (fast-forward rule): ${result.reason}`);
|
|
217
328
|
process.exit(1);
|
|
218
329
|
break;
|
|
330
|
+
case 'authority-refused':
|
|
331
|
+
fail(`REFUSED (destructive authority): ${result.reason}`);
|
|
332
|
+
process.exit(1);
|
|
333
|
+
break;
|
|
219
334
|
case 'verify-failed':
|
|
220
335
|
fail(`Verify FAILED: ${result.reason}`);
|
|
221
336
|
process.exit(1);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `everystack db:approvers` — declare who can destroy (brick 9, decision 12).
|
|
3
|
+
*
|
|
4
|
+
* db:approvers --stage <name> # show the declared set
|
|
5
|
+
* db:approvers --stage <name> --set "cto,arn:..." # declare / replace
|
|
6
|
+
* db:approvers --stage <name> --set '' # declare EMPTY: destructive applies disabled
|
|
7
|
+
* db:approvers --stage <name> --remove # undeclare: back to ceremony-only
|
|
8
|
+
*
|
|
9
|
+
* The set lives stage-side (an SSM parameter in the stage's namespace) so a
|
|
10
|
+
* checkout edit can't reach it; lock it down further with IAM by denying
|
|
11
|
+
* `ssm:PutParameter` on `/everystack/<app>/<stage>/db-approvers` to
|
|
12
|
+
* non-admins — the same posture as the rest of the stage's parameters.
|
|
13
|
+
* Entries are exact ARNs or bare names matched against the caller's ARN
|
|
14
|
+
* path segments (`ty` matches `user/ty` and an assumed-role session `ty`).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { resolveConfig } from '../config.js';
|
|
18
|
+
import {
|
|
19
|
+
approverParamName,
|
|
20
|
+
parseApproverValue,
|
|
21
|
+
readApproverParam,
|
|
22
|
+
writeApproverParam,
|
|
23
|
+
removeApproverParam,
|
|
24
|
+
} from '../apply-authority.js';
|
|
25
|
+
import { step, success, fail, info, warn } from '../output.js';
|
|
26
|
+
|
|
27
|
+
export async function dbApproversCommand(flags: Record<string, string>): Promise<void> {
|
|
28
|
+
if (!flags.stage) {
|
|
29
|
+
fail('db:approvers needs --stage <name> — the approver set is per-stage configuration.');
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
step('Resolving deployed config...');
|
|
34
|
+
const config = await resolveConfig(flags.stage);
|
|
35
|
+
const { parseAppName } = await import('../discover.js');
|
|
36
|
+
const appName = await parseAppName();
|
|
37
|
+
const paramName = approverParamName(appName, flags.stage);
|
|
38
|
+
|
|
39
|
+
if (flags.remove === 'true') {
|
|
40
|
+
await removeApproverParam(config.region, appName, flags.stage);
|
|
41
|
+
success(`Removed ${paramName} — the stage is back to the undeclared posture (destructive applies gated by ceremony only).`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (flags.set !== undefined) {
|
|
46
|
+
if (flags.set === 'true') {
|
|
47
|
+
fail("--set needs a value: a comma-separated list of ARNs or names (--set '' declares an EMPTY set, disabling destructive applies).");
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
const approvers = parseApproverValue(flags.set);
|
|
51
|
+
await writeApproverParam(config.region, appName, flags.stage, approvers);
|
|
52
|
+
if (approvers.length === 0) {
|
|
53
|
+
warn(`Declared an EMPTY approver set at ${paramName} — destructive applies are now DISABLED on ${flags.stage}.`);
|
|
54
|
+
} else {
|
|
55
|
+
success(`Declared ${approvers.length} approver(s) at ${paramName}: ${approvers.join(', ')}`);
|
|
56
|
+
info('Destructive db:apply runs against this stage now require one of these identities (STS-verified).');
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const approvers = await readApproverParam(config.region, appName, flags.stage);
|
|
62
|
+
if (approvers === null) {
|
|
63
|
+
info(`No approver set declared (${paramName} absent) — destructive applies are gated by ceremony only (--confirm + snapshot).`);
|
|
64
|
+
info("Declare one: everystack db:approvers --set 'cto,arn:aws:iam::…:user/…' --stage " + flags.stage);
|
|
65
|
+
} else if (approvers.length === 0) {
|
|
66
|
+
warn(`The approver set is declared EMPTY — destructive applies are DISABLED on ${flags.stage}.`);
|
|
67
|
+
} else {
|
|
68
|
+
info(`Destructive-approver set for ${flags.stage} (${approvers.length}):`);
|
|
69
|
+
for (const a of approvers) info(` - ${a}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -37,6 +37,7 @@ import { generateMigrationSql, unmodeledTables } from '../migration-generate.js'
|
|
|
37
37
|
import {
|
|
38
38
|
applyStateAndVerify,
|
|
39
39
|
classifyGeneratedStatements,
|
|
40
|
+
classifyDestructive,
|
|
40
41
|
currentGitRef,
|
|
41
42
|
type ClassifiedStatements,
|
|
42
43
|
type StateSyncOutcome,
|
|
@@ -277,6 +278,10 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
|
|
|
277
278
|
onStatePlan: (statements, classified) => {
|
|
278
279
|
if (classified.executable.length > 0) {
|
|
279
280
|
info(`state plan — ${classified.executable.length} statement(s):`);
|
|
281
|
+
const { drops, narrowings } = classifyDestructive(classified.executable);
|
|
282
|
+
if (drops.length + narrowings.length > 0) {
|
|
283
|
+
warn(`${drops.length + narrowings.length} DESTRUCTIVE — ${drops.length} drop(s), ${narrowings.length} narrowing type change(s). Dev sync runs them; protected stages gate them (db:plan → db:apply).`);
|
|
284
|
+
}
|
|
280
285
|
console.log('');
|
|
281
286
|
for (const s of statements) console.log(s.endsWith(';') ? s : `${s};`);
|
|
282
287
|
console.log('');
|
package/src/cli/edge-plan.ts
CHANGED
|
@@ -29,12 +29,22 @@ import type { ModelDescriptor } from '@everystack/model';
|
|
|
29
29
|
import type { SchemaSnapshot } from './schema-introspect.js';
|
|
30
30
|
import type { AuthzContract } from './authz-contract.js';
|
|
31
31
|
import { generateMigrationSql, unmodeledTables } from './migration-generate.js';
|
|
32
|
-
import { classifyGeneratedStatements } from './state-apply.js';
|
|
32
|
+
import { classifyGeneratedStatements, classifyDestructive } from './state-apply.js';
|
|
33
33
|
import { fingerprintLive, fingerprintState, fingerprintModels, stableStringify } from './schema-fingerprint.js';
|
|
34
34
|
import { compileDeclaredState } from './declared-diff.js';
|
|
35
35
|
import { compileTableRenames } from './schema-compile.js';
|
|
36
36
|
|
|
37
|
-
export const PLAN_VERSION =
|
|
37
|
+
export const PLAN_VERSION = 2;
|
|
38
|
+
|
|
39
|
+
/** Classification counts (brick 9, decision 11): destructive = drops + narrowings. */
|
|
40
|
+
export interface PlanClassification {
|
|
41
|
+
/** Executable statements that lose no data. */
|
|
42
|
+
additive: number;
|
|
43
|
+
/** `DROP TABLE/COLUMN/TYPE` — the data is gone. */
|
|
44
|
+
drops: number;
|
|
45
|
+
/** Lossy/risky `ALTER COLUMN … TYPE` — data loss wearing an ALTER. */
|
|
46
|
+
narrowings: number;
|
|
47
|
+
}
|
|
38
48
|
|
|
39
49
|
export interface EdgePlan {
|
|
40
50
|
v: number;
|
|
@@ -47,8 +57,9 @@ export interface EdgePlan {
|
|
|
47
57
|
/** The edge, in db:generate's statement grammar (no held drops — mint refuses them). */
|
|
48
58
|
statements: string[];
|
|
49
59
|
executable: number;
|
|
50
|
-
/** Executable statements that
|
|
60
|
+
/** Executable statements that LOSE DATA (drops + narrowing type changes) — red, gated at apply. */
|
|
51
61
|
destructive: number;
|
|
62
|
+
classification: PlanClassification;
|
|
52
63
|
notices: number;
|
|
53
64
|
/** Live tables no model declares — untouched by the edge, riding into `to` as-is. */
|
|
54
65
|
unmodeled: string[];
|
|
@@ -135,6 +146,13 @@ export function mintEdgePlan(
|
|
|
135
146
|
);
|
|
136
147
|
}
|
|
137
148
|
|
|
149
|
+
// Data-destructive only: dropping a policy/grant is recoverable metadata
|
|
150
|
+
// (the authz phase is data-safe by construction); dropping a table,
|
|
151
|
+
// column, or type is not — and neither is a narrowing type change, which
|
|
152
|
+
// is data loss wearing an ALTER.
|
|
153
|
+
const breakdown = classifyDestructive(classified.executable);
|
|
154
|
+
const destructive = breakdown.drops.length + breakdown.narrowings.length;
|
|
155
|
+
|
|
138
156
|
return {
|
|
139
157
|
v: PLAN_VERSION,
|
|
140
158
|
fromFingerprint: fingerprintLive(snapshot, contract).hash,
|
|
@@ -142,10 +160,12 @@ export function mintEdgePlan(
|
|
|
142
160
|
declaredFingerprint: fingerprintModels(models, { schema: opts.schema }).hash,
|
|
143
161
|
statements,
|
|
144
162
|
executable: classified.executable.length,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
163
|
+
destructive,
|
|
164
|
+
classification: {
|
|
165
|
+
additive: classified.executable.length - destructive,
|
|
166
|
+
drops: breakdown.drops.length,
|
|
167
|
+
narrowings: breakdown.narrowings.length,
|
|
168
|
+
},
|
|
149
169
|
notices: classified.notices.length,
|
|
150
170
|
unmodeled: unmodeledTables(models, snapshot),
|
|
151
171
|
gitRef: opts.gitRef ?? null,
|
|
@@ -183,7 +203,14 @@ export function buildPlanSummary(plan: EdgePlan): string[] {
|
|
|
183
203
|
}
|
|
184
204
|
lines.push(`${plan.executable} executable statement(s), ${plan.notices} notice(s)`);
|
|
185
205
|
if (plan.destructive > 0) {
|
|
186
|
-
|
|
206
|
+
const { drops, narrowings } = classifyDestructive(classifyGeneratedStatements(plan.statements).executable);
|
|
207
|
+
lines.push(
|
|
208
|
+
`! ${plan.destructive} DESTRUCTIVE statement(s) — ${drops.length} drop(s), ${narrowings.length} narrowing type change(s). Data loss carried explicitly; the apply is gated (--confirm + snapshot, approver set when declared):`,
|
|
209
|
+
);
|
|
210
|
+
for (const statement of [...drops, ...narrowings]) {
|
|
211
|
+
const ddl = statement.split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('--')) ?? statement;
|
|
212
|
+
lines.push(`! ${ddl.trim()}`);
|
|
213
|
+
}
|
|
187
214
|
}
|
|
188
215
|
if (plan.unmodeled.length > 0) {
|
|
189
216
|
lines.push(`· ${plan.unmodeled.length} unmodeled table(s) ride through untouched: ${plan.unmodeled.join(', ')}`);
|
package/src/cli/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { dbDiffCommand } from './commands/db-diff.js';
|
|
|
18
18
|
import { dbPlanCommand } from './commands/db-plan.js';
|
|
19
19
|
import { dbApplyCommand } from './commands/db-apply.js';
|
|
20
20
|
import { dbCheckCommand } from './commands/db-check.js';
|
|
21
|
+
import { dbApproversCommand } from './commands/db-approvers.js';
|
|
21
22
|
import { dbSnapshotCommand, dbSnapshotsCommand } from './commands/db-snapshot.js';
|
|
22
23
|
import { dbBackupProbeCommand, dbBackupCommand, dbBackupsCommand, dbRestoreCommand, dbBackupDownloadCommand } from './commands/db-backup.js';
|
|
23
24
|
import { consoleCommand } from './commands/console.js';
|
|
@@ -200,6 +201,9 @@ async function main() {
|
|
|
200
201
|
case 'db:check':
|
|
201
202
|
await dbCheckCommand(flags);
|
|
202
203
|
break;
|
|
204
|
+
case 'db:approvers':
|
|
205
|
+
await dbApproversCommand(flags);
|
|
206
|
+
break;
|
|
203
207
|
case 'db:authz:pull':
|
|
204
208
|
await dbAuthzPullCommand(flags);
|
|
205
209
|
break;
|
|
@@ -327,8 +331,9 @@ Usage:
|
|
|
327
331
|
everystack db:sync [--database-url <url>] [--models <barrel>] [--sql-dir db/sql] [--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 db/sql, 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
|
|
328
332
|
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)
|
|
329
333
|
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 — works via the ops Lambda; plans are ephemeral, never committed
|
|
330
|
-
everystack db:apply --plan <file.plan.json> [--database-url <url>] [--models <barrel>] [--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"
|
|
334
|
+
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 + a snapshot (automatic via db:backup with --stage, else --snapshot-ref) + the stage's approver set when declared (STS identity-verified). Every refusal is recorded in schema_log. Apply as one transaction (plan_ref stamped), verify it landed exactly on plan.to; idempotent when already there; direct connection required
|
|
331
335
|
everystack db:check [--models <barrel>] [--sql-dir db/sql] [--schema-out <file.ts>] [--database-url <url>] [--json] The CI gate, per PR: the merged declared state must COMPOSE (models load, no duplicate tables, db/sql parses) 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
|
|
336
|
+
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
|
|
332
337
|
everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
|
|
333
338
|
everystack db:authz:diff [--stage <name>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
|
|
334
339
|
everystack db:authz:test [--stage <name>] [--dir authz] Red-team enforcement: SET ROLE + attempt per role/table/command (non-zero exit on a hole)
|
package/src/cli/schema-source.ts
CHANGED
|
@@ -304,6 +304,11 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
|
|
|
304
304
|
const { call, builder: pgBuilder } = baseColumnSource(toSnakeCase(key), spec);
|
|
305
305
|
pgCoreBuilders.add(pgBuilder);
|
|
306
306
|
const mods = columnModifiers(spec, isComposite, modelsByDescriptor);
|
|
307
|
+
if (spec.isDeprecated) {
|
|
308
|
+
// The contract phase (decision 13): the column stays in the database and
|
|
309
|
+
// stays readable; the strikethrough warns new code away at author time.
|
|
310
|
+
colLines.push(` /** @deprecated contract phase — readable, not writable; the physical drop is a later, gated act. */`);
|
|
311
|
+
}
|
|
307
312
|
colLines.push(` ${key}: ${call}${mods},`);
|
|
308
313
|
}
|
|
309
314
|
|
package/src/cli/state-apply.ts
CHANGED
|
@@ -56,6 +56,36 @@ export function classifyGeneratedStatements(statements: string[]): ClassifiedSta
|
|
|
56
56
|
return { executable, heldDrops, notices };
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The destructive subset of an executable statement stream — destructive
|
|
61
|
+
* means DATA LOSS (brick 9's classification, design decision 11):
|
|
62
|
+
*
|
|
63
|
+
* drops — `DROP TABLE/COLUMN/TYPE`: the data is gone.
|
|
64
|
+
* narrowings — lossy/risky `ALTER COLUMN … SET DATA TYPE`: data loss
|
|
65
|
+
* wearing an ALTER. Detection rides the emitter's structural
|
|
66
|
+
* contract, not prose: a SAFE type change is a bare
|
|
67
|
+
* `SET DATA TYPE`; anything lossy or risky carries a `USING`
|
|
68
|
+
* cast (schema-diff's `classifyTypeChange`, conservative by
|
|
69
|
+
* default — the contract is pinned by test).
|
|
70
|
+
*
|
|
71
|
+
* Recoverable metadata is never destructive: policies, grants, constraints,
|
|
72
|
+
* defaults, nullability all re-declare from the models without touching a row.
|
|
73
|
+
*/
|
|
74
|
+
export interface DestructiveBreakdown {
|
|
75
|
+
drops: string[];
|
|
76
|
+
narrowings: string[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function classifyDestructive(executable: string[]): DestructiveBreakdown {
|
|
80
|
+
const drops: string[] = [];
|
|
81
|
+
const narrowings: string[] = [];
|
|
82
|
+
for (const statement of executable) {
|
|
83
|
+
if (/\bDROP\s+(TABLE|COLUMN|TYPE)\b/.test(statement)) drops.push(statement);
|
|
84
|
+
else if (/\bSET DATA TYPE\b/.test(statement) && /\bUSING\b/.test(statement)) narrowings.push(statement);
|
|
85
|
+
}
|
|
86
|
+
return { drops, narrowings };
|
|
87
|
+
}
|
|
88
|
+
|
|
59
89
|
/** The current git HEAD, for schema_log's intent pointer. Null outside a repo. */
|
|
60
90
|
export function currentGitRef(): string | null {
|
|
61
91
|
try {
|