@everystack/cli 0.4.98 → 0.4.99

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/CHANGELOG.md CHANGED
@@ -4,6 +4,34 @@ All notable changes to `@everystack/cli` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.99] - 2026-10-14
8
+
9
+ ### Added
10
+
11
+ - db:backup reads the origin commit in the fingerprint session
12
+ - render the declared-authz manifest from the checkout's models
13
+ - mirror originCommit and authzManifest into the CLI BackupManifest
14
+ - stamp the authz manifest after a proven stage backup
15
+ - db:backup renders the authz manifest and stamps it post-dump
16
+ - db:restore and db:fork write a restore witness from the backup's origin (#15)
17
+ - link table name references through model thunks
18
+ - db:check warns when a role can write a table but not read it
19
+ - the db:check one-line summary counts warnings
20
+
21
+ ### Fixed
22
+
23
+ - normalize statement keys in plain reports
24
+ - lease the ops-Lambda write path, no driver fork
25
+ - link names before manifest and runbook rendering
26
+ - the select-grant warning no longer suggests a blanket read
27
+ - probe UPDATE per role in the authz red-team
28
+ - probe red-team UPDATE with SET col = DEFAULT
29
+ - db:doctor prints each fix once and an accurate closing line
30
+
31
+ ### Documentation
32
+
33
+ - the select-grant advice is never broader than the write
34
+
7
35
  ## [0.4.98] - 2026-09-27
8
36
 
9
37
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.98",
3
+ "version": "0.4.99",
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>",
@@ -58,6 +58,16 @@
58
58
  "types": "./src/reconcile.ts",
59
59
  "default": "./src/reconcile.ts"
60
60
  },
61
+ "./mutation-lease": {
62
+ "source": "./src/cli/mutation-lease.ts",
63
+ "types": "./src/cli/mutation-lease.ts",
64
+ "default": "./src/cli/mutation-lease.ts"
65
+ },
66
+ "./connection-fence": {
67
+ "source": "./src/cli/connection-fence.ts",
68
+ "types": "./src/cli/connection-fence.ts",
69
+ "default": "./src/cli/connection-fence.ts"
70
+ },
61
71
  "./refresh": {
62
72
  "source": "./src/refresh.ts",
63
73
  "types": "./src/refresh.ts",
@@ -135,7 +145,7 @@
135
145
  "structured-headers": "1.0.1",
136
146
  "tsx": "4.21.0",
137
147
  "typescript": "5.9.3",
138
- "@everystack/model": "0.4.20"
148
+ "@everystack/model": "0.4.21"
139
149
  },
140
150
  "peerDependencies": {
141
151
  "@everystack/server": ">=0.4.0",
@@ -182,7 +192,7 @@
182
192
  "react": "19.2.0",
183
193
  "ts-jest": "29.4.9",
184
194
  "zod": "3.25.67",
185
- "@everystack/server": "0.4.34"
195
+ "@everystack/server": "0.4.35"
186
196
  },
187
197
  "scripts": {
188
198
  "test": "jest",
@@ -416,6 +416,47 @@ export async function liveBaseAndLedger(session: SessionRunner): Promise<LiveBas
416
416
  return { hash: fingerprintLive(snapshot, contract).hash, snapshot, contract, ledger, ledgerRead };
417
417
  }
418
418
 
419
+ /**
420
+ * The commit that last deployed this target — the `git_ref` SCHEMA_LOG_ANCHOR_SQL answers, i.e.
421
+ * the newest successful apply that RECORDED a commit. An app-only apply without one never shadows
422
+ * it, exactly as it never shadows descent's anchor.
423
+ *
424
+ * Three answers, because the ledger read has three outcomes:
425
+ * - a string — the anchor's commit;
426
+ * - `null` — a CLEAN read proved there is none (no schema_log, or no commit-carrying apply);
427
+ * - undefined — the read failed, so nothing is known. Never `null`: that would claim "this
428
+ * target has no deploying commit" on the strength of a read that did not happen.
429
+ */
430
+ export function originCommitFromLedger(ledger: DescentLedgerRow[], ledgerRead: LedgerRead): string | null | undefined {
431
+ if (ledgerRead.kind === 'unreadable') return undefined;
432
+ if (ledgerRead.kind === 'absent' || !ledgerRead.anchored) return null;
433
+ // ledgerFromSlots folds the anchor row into the ledger, so the anchor is the newest row (by id,
434
+ // never by clock — the anchor query's own order) that carries a commit.
435
+ let anchor: DescentLedgerRow | undefined;
436
+ for (const row of ledger) {
437
+ if (row.gitRef == null) continue;
438
+ if (anchor === undefined || BigInt(row.id) > BigInt(anchor.id)) anchor = row;
439
+ }
440
+ return anchor?.gitRef ?? null;
441
+ }
442
+
443
+ /** What a stage backup records about the live target at dump time. */
444
+ export interface BackupLiveRead {
445
+ fingerprint: string;
446
+ /** See originCommitFromLedger — undefined means the ledger read failed. */
447
+ originCommit: string | null | undefined;
448
+ }
449
+
450
+ /**
451
+ * The backup's live read: the base fingerprint AND the origin commit, in the ONE repeatable-read
452
+ * session liveBaseAndLedger already runs — the same fingerprint db:apply's lock computes, and the
453
+ * same anchor its descent proof reads. No second round trip.
454
+ */
455
+ export async function readBackupLive(session: SessionRunner): Promise<BackupLiveRead> {
456
+ const { hash, ledger, ledgerRead } = await liveBaseAndLedger(session);
457
+ return { fingerprint: hash, originCommit: originCommitFromLedger(ledger, ledgerRead) };
458
+ }
459
+
419
460
  /** Verify → apply → verify. Pure orchestration over an injected QueryRunner. */
420
461
  export async function executeApplyPlan(
421
462
  runner: QueryRunner,
@@ -622,3 +663,7 @@ export async function executeApplyPlan(
622
663
  // notably the ops Lambda's db:apply action — everything it needs to validate
623
664
  // and run a plan without reaching into the CLI's internal module layout.
624
665
  export { PLAN_VERSION, APPLY_CONTRACT_VERSION, planHash, checkPlanPrecondition, type EdgePlan } from './edge-plan';
666
+
667
+ // The ledger DDL, so the ops Lambda's restore dispatcher can create `everystack.schema_log` on a
668
+ // fresh target from the one copy every apply already runs — never a second copy of the DDL.
669
+ export { ENSURE_RECONCILER_SQL } from './derived-apply';
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The declared-authz manifest a backup records (restore-converges-authz R1).
3
+ *
4
+ * A restore lands the dump's data AND its privileges-free schema; converging authz afterwards needs
5
+ * to know what the Models declared. Compiling a HISTORICAL models tree to learn that was rejected —
6
+ * an old tree can fail to compile under the current CLI — so the backup records the answer at dump
7
+ * time instead, rendered from the models checked out when `db:backup` runs.
8
+ *
9
+ * Pure and a composition only. Every section is the output of the renderer that already owns it:
10
+ * - tables — compileTableContract, per model (what db:plan / db:check converge tables to);
11
+ * - derived — parseGrantAttachments per view/matview/function, plus the schema USAGE rule
12
+ * renderEnsureObjectSchemas applies (declaredObjectSchemaUsage);
13
+ * - owners — extractDeclaredOwnership (what db:provision's ownership convergence enacts/adopts).
14
+ * No logic is re-derived here: a second copy would drift from what the verbs actually converge to.
15
+ */
16
+
17
+ import { ModelLinkError } from '@everystack/model';
18
+ import type { TableContract } from './authz-contract';
19
+ import { compileTableContract } from './authz-compile';
20
+ import { parseGrantAttachments } from './derived-grants';
21
+ import { declaredObjectSchemaUsage } from './derived-apply';
22
+ import { extractDeclaredOwnership } from './commands/db';
23
+ import type { DeclaredDerived } from './declared-derived';
24
+ import type { DeclaredOwner, Adoptee } from '../wire';
25
+ import type { BackupAuthzManifest } from './backup';
26
+
27
+ /** One view / matview / function's declared grant contract, as parseGrantAttachments reads it. */
28
+ export interface DerivedGrantEntry {
29
+ identity: string;
30
+ kind: string;
31
+ schema: string;
32
+ /** The `ON …` target as the declared statements spell it, or null when none was declared. */
33
+ target: string | null;
34
+ /** grantee → sorted privileges. PUBLIC present-with-[] means explicitly revoked. */
35
+ grants: Record<string, string[]>;
36
+ /** A column-scoped grant is declared (not drift-checked by the reconciler). */
37
+ hasColumnGrants: boolean;
38
+ }
39
+
40
+ /** Narrows the mirrored BackupManifest envelope, so a rendered manifest is assignable to the field. */
41
+ export interface AuthzManifest extends BackupAuthzManifest {
42
+ version: 1;
43
+ /** compileTableContract(model) for every declared model, in declaration order. */
44
+ tables: TableContract[];
45
+ derived: {
46
+ /** Every derived object that carries grants — triggers and `sql` escape hatches do not. */
47
+ objects: DerivedGrantEntry[];
48
+ /** non-public schema → the roles granted USAGE on it (sorted; [] = the schema, no grant). */
49
+ schemaUsage: Record<string, string[]>;
50
+ };
51
+ /** The ENACT pairs and ADOPT objects db:provision converges ownership to. */
52
+ owners: { declaredOwners: DeclaredOwner[]; declaredObjects: Adoptee[] };
53
+ }
54
+
55
+ /** Render the declared-authz manifest from the checkout's models and derived descriptors. */
56
+ export function renderAuthzManifest(declared: Pick<DeclaredDerived, 'models' | 'objects'>): AuthzManifest {
57
+ const objects: DerivedGrantEntry[] = declared.objects
58
+ // The kinds the reconciler grant-checks (objectGrantDelta): triggers have no ACL, and an
59
+ // opaque `sql` object's grants are its own business.
60
+ .filter((o) => o.kind !== 'trigger' && o.kind !== 'sql')
61
+ .map((o) => {
62
+ const parsed = parseGrantAttachments(o.attachments);
63
+ return {
64
+ identity: o.identity, kind: o.kind, schema: o.schema,
65
+ target: parsed.target, grants: parsed.grants, hasColumnGrants: parsed.hasColumnGrants,
66
+ };
67
+ });
68
+ return {
69
+ version: 1,
70
+ tables: declared.models.map((m) => compileTableContract(m)),
71
+ derived: { objects, schemaUsage: declaredObjectSchemaUsage(declared.objects) },
72
+ owners: extractDeclaredOwnership(declared.objects),
73
+ };
74
+ }
75
+
76
+ /**
77
+ * What `db:backup` sends: the manifest rendered from the checkout, loaded through `load`
78
+ * (loadDeclaredDerived in the command). A models-less project has nothing declared, so it sends
79
+ * nothing and says nothing. A name-link failure refuses the backup. Other load failures warn —
80
+ * the dump is the safety net — so that is a warning and the backup proceeds without a manifest.
81
+ */
82
+ export async function loadBackupAuthzManifest(
83
+ load: () => Promise<Pick<DeclaredDerived, 'models' | 'objects'> | null>,
84
+ ): Promise<{ manifest?: AuthzManifest; warning?: string }> {
85
+ try {
86
+ const declared = await load();
87
+ return declared ? { manifest: renderAuthzManifest(declared) } : {};
88
+ } catch (err: unknown) {
89
+ if (err instanceof ModelLinkError) throw err;
90
+ const message = err instanceof Error ? err.message : String(err);
91
+ return { warning: `the authz manifest could not be rendered from your models (${message}), so this backup will not carry one` };
92
+ }
93
+ }
@@ -84,7 +84,7 @@ function pgArrayLiteral(values: string[]): string {
84
84
  * transaction it always rolls back. Probe statements are privilege-isolated:
85
85
  * SELECT 1 FROM t WHERE false — SELECT privilege, reads no rows
86
86
  * INSERT INTO t DEFAULT VALUES — INSERT privilege (rolled back; NOT NULL = allowed)
87
- * UPDATE t SET col = col WHERE false — UPDATE privilege, touches no rows
87
+ * UPDATE t SET col = DEFAULT WHERE false — UPDATE privilege on col only, touches no rows
88
88
  * DELETE FROM t WHERE false — DELETE privilege, touches no rows
89
89
  * "permission denied" -> denied-privilege; "row-level security" -> denied-rls.
90
90
  *
@@ -93,8 +93,9 @@ function pgArrayLiteral(values: string[]): string {
93
93
  * privilege was present -> allowed. But a statement rejected at parse/analysis time never
94
94
  * reached the permission check at all and proves NOTHING -> inconclusive. Classifying the
95
95
  * second kind as `allowed` is what reported `anon` as able to UPDATE a table it cannot
96
- * touch: the probe's `SET pk = pk` hit 428C9 ("can only be updated to DEFAULT") on an
97
- * identity PK, and a non-grant then read as "enforcement exceeds declaration".
96
+ * touch: the probe's old `SET pk = pk` hit 428C9 ("can only be updated to DEFAULT") on an
97
+ * identity PK, and a non-grant then read as "enforcement exceeds declaration". The probe
98
+ * now SETs DEFAULT, which identity and generated columns accept.
98
99
  */
99
100
  export function buildProbeSql(roles: string[], tables: string[]): string {
100
101
  return `
@@ -119,21 +120,29 @@ BEGIN
119
120
  WHEN 'UPDATE' THEN NULL
120
121
  END;
121
122
  IF v_cmd = 'UPDATE' THEN
122
- -- Prefer a column the statement can actually SET. An identity or generated
123
- -- column rejects "SET col = col" at analysis time (428C9), before the permission
124
- -- check — which makes the probe prove nothing. Fall back to the first column
125
- -- only when every column is generated; that run reports inconclusive.
123
+ -- The probe is "SET col = DEFAULT", which needs the UPDATE privilege on col and
124
+ -- nothing else. "SET col = col" READS col, so it also needs SELECT on col: a role
125
+ -- granted UPDATE (b) without SELECT on b probed as denied. DEFAULT is also legal on
126
+ -- identity (GENERATED ALWAYS too) and generated columns, so any column can be SET
127
+ -- and the statement always reaches the permission check.
128
+ --
129
+ -- The column is chosen PER ROLE. The contract grants UPDATE on the table privilege
130
+ -- OR any column privilege, so the probe must SET a column this role may update —
131
+ -- a per-table pick (the first column) reports a column-scoped grant as denied.
132
+ -- has_column_privilege is true for a table-wide grant too, and resolves
133
+ -- inheritance and PUBLIC. A role with no UPDATE on any column falls through to
134
+ -- the first column and is denied.
126
135
  SELECT quote_ident(attname) INTO v_col FROM pg_attribute
127
136
  WHERE attrelid = format('%I.%I', split_part(v_tbl,'.',1), split_part(v_tbl,'.',2))::regclass
128
137
  AND attnum > 0 AND NOT attisdropped
129
- AND attidentity = '' AND attgenerated = ''
138
+ AND has_column_privilege(v_role, attrelid, attnum, 'UPDATE')
130
139
  ORDER BY attnum LIMIT 1;
131
140
  IF v_col IS NULL THEN
132
141
  SELECT quote_ident(attname) INTO v_col FROM pg_attribute
133
142
  WHERE attrelid = format('%I.%I', split_part(v_tbl,'.',1), split_part(v_tbl,'.',2))::regclass
134
143
  AND attnum > 0 AND NOT attisdropped ORDER BY attnum LIMIT 1;
135
144
  END IF;
136
- v_stmt := format('UPDATE %I.%I SET %s = %s WHERE false', split_part(v_tbl,'.',1), split_part(v_tbl,'.',2), v_col, v_col);
145
+ v_stmt := format('UPDATE %I.%I SET %s = DEFAULT WHERE false', split_part(v_tbl,'.',1), split_part(v_tbl,'.',2), v_col);
137
146
  END IF;
138
147
 
139
148
  BEGIN
@@ -150,7 +159,7 @@ BEGIN
150
159
  -- After (data/constraint): the privilege was present.
151
160
  v_outcome := CASE
152
161
  WHEN SQLSTATE IN (
153
- '428C9', -- generated_always: identity/generated column (the probe's own SET pk = pk)
162
+ '428C9', -- generated_always: identity/generated column (no longer hit by SET col = DEFAULT)
154
163
  '42703', -- undefined_column
155
164
  '42P01', -- undefined_table
156
165
  '42601', -- syntax_error
@@ -27,6 +27,7 @@ import { createHash } from 'node:crypto';
27
27
  import { lex, normalizeSql, splitSqlStatements, type SourceFile } from './derived-source';
28
28
  import { escapeLiteral } from './derived-apply';
29
29
  import type { QueryRunner } from './authz-contract';
30
+ import { ConnectionLostError } from './connection-fence';
30
31
 
31
32
  // ---------------------------------------------------------------------------
32
33
  // Identity + advisories (pure).
@@ -204,10 +205,15 @@ export async function executeBackfills(
204
205
  }));
205
206
  result.ran.push({ file: job.file, identity: job.identity, durationMs });
206
207
  } catch (err: any) {
207
- await runner(renderBackfillLogInsert({
208
- identity: job.identity, name, sql: `-- FAILED: ${err.message}\n${job.sql}`, outcome: 'failed',
209
- actor: options.actor, gitRef: options.gitRef, durationMs: now() - started,
210
- }));
208
+ try {
209
+ await runner(renderBackfillLogInsert({
210
+ identity: job.identity, name, sql: `-- FAILED: ${err.message}\n${job.sql}`, outcome: 'failed',
211
+ actor: options.actor, gitRef: options.gitRef, durationMs: now() - started,
212
+ }));
213
+ } catch { /* the memoir is best-effort on failure; it must never replace the job's own error */ }
214
+ // A lost connection is not a job that failed and rolled back: a job lost IN FLIGHT may
215
+ // have committed with no log row. Surface the loss itself, which says so.
216
+ if (err instanceof ConnectionLostError) throw err;
211
217
  result.failed = { file: job.file, identity: job.identity, error: err.message };
212
218
  return result;
213
219
  }
package/src/cli/backup.ts CHANGED
@@ -81,6 +81,27 @@ export interface BackupManifest {
81
81
  fingerprintSource?: string;
82
82
  bytes?: number;
83
83
  key: string;
84
+ /**
85
+ * The commit that last deployed the dumped database — schema_log's anchor (the newest successful
86
+ * apply that recorded a commit), read by the operator in the same session as `fingerprint`.
87
+ * `null` = a clean read proved there is none; absent = unknown (pre-R1, or the read failed).
88
+ */
89
+ originCommit?: string | null;
90
+ /** What the Models declared for authz when the backup was taken — see BackupAuthzManifest. */
91
+ authzManifest?: BackupAuthzManifest;
92
+ }
93
+
94
+ /**
95
+ * The declared-authz manifest a backup records: rendered by the OPERATOR's CLI from the models
96
+ * checked out at dump time (@everystack/cli authz-manifest.ts, which narrows each section to its
97
+ * real type), so a later restore can converge authz without compiling a historical models tree.
98
+ * The sections are opaque on this side of the mirror: they ride into the `.meta.json` unchanged.
99
+ */
100
+ export interface BackupAuthzManifest {
101
+ version: number;
102
+ tables: unknown[];
103
+ derived: unknown;
104
+ owners: unknown;
84
105
  }
85
106
 
86
107
  export interface SnapshotVerifyInput {
@@ -5,14 +5,15 @@
5
5
  * dispatches and polls. db:backups/db:restore/download are thin invokeAction wrappers. The image's
6
6
  * version handshake is the compatibility gate — verify a deploy with `everystack task:probe`.
7
7
  *
8
- * db:backup has a SECOND venue for break-glass: `--database-url <local> --stage <target>` dumps a
9
- * LOCAL database and uploads it into the target stage's backups bucket as a db:backup-shaped object
10
- * (same key scheme + .meta.json + dump flags as a server backup). The standard credential-free
11
- * `db:restore --from <id> --stage <target>` then lands it via the Task lane (pg_restore runs as the
12
- * privileged `migrator` role, which can CREATE SCHEMA + own objects). The local dump is the only
13
- * held connection; the upload rides the operator's AWS credentials — the same trust boundary as
14
- * db:fork's cross-stage presign. This is the sanctioned local→stage full replace on a role-managed
15
- * stage (raw pg_restore can't: the operator/api role can't CREATE SCHEMA, and only the master could).
8
+ * db:backup has a SECOND venue: `--database-url <local> --stage <target>` dumps a LOCAL database and
9
+ * uploads it into the target stage's backups bucket as a db:backup-shaped object (same key scheme +
10
+ * .meta.json + dump flags as a server backup). The local dump is the only held connection; the
11
+ * upload rides the operator's AWS credentials.
12
+ *
13
+ * Restoring such a backup into a stage is CLOSED. A restore must record the commit the restored
14
+ * state descends from (the witness), and a local database has no verified origin: nothing proves
15
+ * it matches any commit. So the local meta carries no originCommit and db:restore refuses it. It
16
+ * reopens only when a local backup can record an origin it has verified against the models.
16
17
  */
17
18
 
18
19
  import { spawn } from 'node:child_process';
@@ -43,7 +44,7 @@ export function resolveBackupVenue(flags: Record<string, string>): BackupVenue {
43
44
  if (!flags.stage) {
44
45
  throw new Error(
45
46
  'db:backup --database-url <local> also needs --stage <target> — the local dump uploads into '
46
- + "that stage's backups bucket, and the stage names the backup id `db:restore --from` consumes.",
47
+ + "that stage's backups bucket, and the stage names the backup id.",
47
48
  );
48
49
  }
49
50
  return { kind: 'local', url, stage: flags.stage };
@@ -177,7 +178,7 @@ export async function dbBackupCommand(flags: Record<string, string>): Promise<vo
177
178
  }
178
179
 
179
180
  // Local venue: dump a reachable database here and upload it into the target stage's backups bucket
180
- // as a db:backup-shaped object, so `db:restore --from <id> --stage <target>` lands it credential-free.
181
+ // as a db:backup-shaped object. It carries no origin, so db:restore refuses it (see the header).
181
182
  if (venue.kind === 'local') {
182
183
  step('Resolving deployed config...');
183
184
  let config: CliConfig;
@@ -192,22 +193,24 @@ export async function dbBackupCommand(flags: Record<string, string>): Promise<vo
192
193
  process.exit(1);
193
194
  }
194
195
  info(`Region: ${config.region}, Bucket: ${config.backupsBucket}`);
195
- // Version gate for the local venue: unlike the stage venue (whose pg binaries are the Task image),
196
- // this dumps with the OPERATOR's pg_dump. pg_restore refuses an archive from a NEWER pg_dump major,
197
- // so a mismatch surfaces only at restore time ("unsupported version in file header") — loud and
198
- // lossless (it fails before --clean drops anything), but discovered exactly when you need the
199
- // backup. Surface the local major up front so the operator can match it to the target's PG major.
196
+ // Unlike the stage venue (whose pg binaries are the Task image), this dumps with the OPERATOR's
197
+ // pg_dump. pg_restore refuses an archive from a NEWER pg_dump major ("unsupported version in file
198
+ // header"), so surface the local major for whoever pg_restores the downloaded dump by hand.
200
199
  try {
201
200
  const { execFileSync } = await import('node:child_process');
202
201
  const v = execFileSync('pg_dump', ['--version']).toString().trim();
203
- info(`Local ${v} — must be ≤ the target stage's PostgreSQL major, or db:restore will reject the dump.`);
202
+ info(`Local ${v} — a manual pg_restore of this dump needs pg_restore of the same major or newer.`);
204
203
  } catch { /* a missing pg_dump surfaces loudly in the dump step below */ }
205
204
  step(`Running pg_dump (full, --exclude-schema=everystack) against --database-url → S3 (this may take a while for large databases)...`);
206
205
  try {
207
206
  const res = await runLocalBackupUpload({
208
207
  region: config.region, bucket: config.backupsBucket, url: venue.url, stage: venue.stage,
209
208
  });
210
- success(`Backup ${res.id} (${fmtBytes(res.bytes)}). Restore with: everystack db:restore --from ${res.id} --stage ${venue.stage} --confirm`);
209
+ success(`Backup ${res.id} (${fmtBytes(res.bytes)}).`);
210
+ warn(
211
+ 'A local backup records no origin commit, so db:restore refuses it: restoring a local backup into a stage is closed ' +
212
+ `until local backups can record a verified origin. It stays downloadable: everystack db:backup:download ${res.id} --stage ${venue.stage}`,
213
+ );
211
214
  } catch (err: any) {
212
215
  fail(`Backup failed: ${err.message}`);
213
216
  process.exit(1);
@@ -227,7 +230,16 @@ export async function dbBackupCommand(flags: Record<string, string>): Promise<vo
227
230
  info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
228
231
  const fn = opsFunction(config);
229
232
 
233
+ // The declared authz at dump time, rendered from THIS checkout's models (never a historical
234
+ // tree) and stamped onto the backup once the dump is proven — so a later restore can converge
235
+ // authz to it. Best-effort: a failure here is a warning, never a lost backup.
236
+ const { loadDeclaredDerived } = await import('../declared-derived');
237
+ const { loadBackupAuthzManifest } = await import('../authz-manifest');
238
+ const authz = await loadBackupAuthzManifest(() => loadDeclaredDerived(flags.models));
239
+ if (authz.warning) warn(authz.warning);
240
+
230
241
  const result = await runStageBackup(stageBackupDeps(config, fn, flags.stage, {
242
+ ...(authz.manifest ? { authzManifest: authz.manifest } : {}),
231
243
  onDispatch: (d) => {
232
244
  info(`backup ${d.id} — task ${d.taskArn}`);
233
245
  info(`run id: ${d.runId} (everystack.task_log)`);
@@ -284,6 +296,32 @@ export async function dbBackupsCommand(flags: Record<string, string>): Promise<v
284
296
  success(`${backups.length} backup${backups.length === 1 ? '' : 's'}.`);
285
297
  }
286
298
 
299
+ /**
300
+ * What the dispatcher recorded in the target before the task started: the schema_log witness row
301
+ * and the commit it names (the backup's own origin), plus whether the declared roles were checked.
302
+ *
303
+ * A successful dispatch with no witnessId came from an ops Lambda that predates restore witnesses:
304
+ * it ignored the origin checks and recorded nothing. The restore is already running, so this warns
305
+ * rather than refuses.
306
+ */
307
+ export function printRestoreWitness(
308
+ dispatched: { witnessId?: string; origin?: string; rolesUnchecked?: boolean },
309
+ stage: string,
310
+ ): void {
311
+ if (dispatched.witnessId) {
312
+ info(`witness ${dispatched.witnessId} in everystack.schema_log — origin ${dispatched.origin ?? '(none)'} (the commit the backup recorded)`);
313
+ } else {
314
+ warn(
315
+ `WARNING: the ops Lambda recorded no restore witness. It predates restore witnesses, so it did not check the backup's ` +
316
+ `origin or roles, and nothing in ${stage}'s everystack.schema_log marks this restore. The restore is already running. ` +
317
+ `Fix: redeploy the ops Lambda (sst deploy --stage ${stage}) so restores record the witness.`,
318
+ );
319
+ }
320
+ if (dispatched.rolesUnchecked) {
321
+ warn('declared roles were not checked: this backup carries no authz manifest.');
322
+ }
323
+ }
324
+
287
325
  /**
288
326
  * db:restore — pg_restore a backup INTO a stage's existing DB. Destructive (drops + replaces), so
289
327
  * it requires --confirm; restoring production-tier data downward or into a production target raises
@@ -340,6 +378,7 @@ export async function dbRestoreCommand(flags: Record<string, string>): Promise<v
340
378
  const { runId, taskArn, intentKey, warning } = dispatched as { runId: string; taskArn: string; intentKey: string; warning?: string };
341
379
  info(`restore of ${id} → ${toStage} — task ${taskArn}`);
342
380
  info(`intent: ${intentKey} (durable S3 record — survives the restore)`);
381
+ printRestoreWitness(dispatched, toStage);
343
382
  if (warning) info(`note: ${warning}`);
344
383
 
345
384
  step(`Restoring ${id} into ${toStage} (pg_restore in the Task; this may take a while)...`);
@@ -44,6 +44,7 @@ import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, t
44
44
  import type { SequenceDescriptor } from '@everystack/model';
45
45
  import type { SourceObject } from '../derived-source';
46
46
  import { findReadAuthzGaps, findNakedGrants } from '../authz-lint';
47
+ import { findSelectGrantGaps } from '../select-grant-advisory';
47
48
  import { parseBaseline, renderBaseline, BASELINE_FILE } from '../authz-baseline';
48
49
  import {
49
50
  findDerivedReadGaps, findSecdefExecuteGaps, findMatviewSnapshotWarnings, findPublicExecutableSecdef,
@@ -69,6 +70,14 @@ export interface CheckFinding {
69
70
  message: string;
70
71
  }
71
72
 
73
+ /** The quiet default's one line: failing findings are "pending", warnings ride along and never fail the gate. */
74
+ export function checkVerdictLines(findings: readonly CheckFinding[], ephemeralFailed: boolean): string[] {
75
+ return renderCheckVerdict(
76
+ findings.filter((f) => f.level === 'fail').length + (ephemeralFailed ? 1 : 0),
77
+ findings.filter((f) => f.level === 'warn').length,
78
+ );
79
+ }
80
+
72
81
  export interface StaticCheckInput {
73
82
  modelsPath: string;
74
83
  /** Barrel siblings and what each declares — the "on disk but ungoverned" scan. */
@@ -197,6 +206,12 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
197
206
  findings.push({ level: 'warn', area: 'authz', message: naked.message });
198
207
  }
199
208
 
209
+ // A write grant with no SELECT beside it: every UPDATE/DELETE ... WHERE (and any RETURNING) by
210
+ // that role is "permission denied". WARN: the grant set is legal, only its use is broken.
211
+ for (const gap of findSelectGrantGaps(input.models)) {
212
+ findings.push({ level: 'warn', area: 'authz', message: gap.message });
213
+ }
214
+
200
215
  // The adoption baseline, ARTIFACT ONLY.
201
216
  //
202
217
  // This check cannot see the threat and must not pretend to. The foreign grantees it
@@ -494,9 +509,7 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
494
509
  // every failing finding, plus the from-scratch build when it did not land where the models
495
510
  // say it should. `db:plan` is the verb that shows each one against a real target, so it is
496
511
  // the only command the line names. Re-run with --verbose to read the findings themselves.
497
- for (const text of renderCheckVerdict(
498
- findings.filter((f) => f.level === 'fail').length + (ephemeralFailed ? 1 : 0),
499
- )) printLine(text);
512
+ for (const text of checkVerdictLines(findings, ephemeralFailed)) printLine(text);
500
513
  }
501
514
 
502
515
  if (failed) process.exit(1);
@@ -21,9 +21,10 @@
21
21
 
22
22
  import { resolveConfig, opsFunction, type CliConfig } from '../config';
23
23
  import { invokeAction, presignGet } from '../aws';
24
- import { keyForId, parseBackupRef, isProductionTier, crossStageGuard } from '../backup';
24
+ import { keyForId, metaKey, parseBackupRef, isProductionTier, crossStageGuard } from '../backup';
25
25
  import { pollTaskUntilStopped } from '../task-poll';
26
26
  import { runStageBackup, stageBackupDeps } from '../stage-backup';
27
+ import { printRestoreWitness } from './db-backup';
27
28
  import { step, success, fail, info, warn } from '../output';
28
29
 
29
30
  export type ForkGuardVerdict =
@@ -109,11 +110,16 @@ export async function dbForkCommand(flags: Record<string, string>): Promise<void
109
110
  info(`Backup ${backupId}.`);
110
111
  }
111
112
 
112
- // Move 2: the presigned hand-off — the operator's credentials ARE the authorization.
113
- step('Presigning the dump (1 hour)...');
113
+ // Move 2: the presigned hand-off — the operator's credentials ARE the authorization. The meta
114
+ // travels beside the dump: the target's Lambda reads the backup's origin commit from it (the
115
+ // restore witness), since it cannot read the source stage's bucket itself.
116
+ step('Presigning the dump and its meta (1 hour)...');
114
117
  let url: string;
118
+ let metaUrl: string;
115
119
  try {
116
- url = await presignGet(source.region, source.backupsBucket, keyForId(backupId)!, 3600);
120
+ const key = keyForId(backupId)!;
121
+ url = await presignGet(source.region, source.backupsBucket, key, 3600);
122
+ metaUrl = await presignGet(source.region, source.backupsBucket, metaKey(key), 3600);
117
123
  } catch (err: any) {
118
124
  fail(`Presign failed: ${err.message}`);
119
125
  process.exit(1);
@@ -131,6 +137,7 @@ export async function dbForkCommand(flags: Record<string, string>): Promise<void
131
137
  step(`Restoring into ${target} (pg_restore in the Task; replaces its database)...`);
132
138
  const restored: any = await invokeAction(targetConfig.region, opsFunction(targetConfig), 'db:restore', {
133
139
  url,
140
+ metaUrl,
134
141
  confirm: true,
135
142
  actor: process.env.USER ?? null,
136
143
  });
@@ -138,6 +145,7 @@ export async function dbForkCommand(flags: Record<string, string>): Promise<void
138
145
  fail(`Restore failed: ${restored.error}`);
139
146
  process.exit(1);
140
147
  }
148
+ printRestoreWitness(restored, target);
141
149
  const rPoll = await pollTaskUntilStopped(targetConfig.region, opsFunction(targetConfig), { runId: restored.runId, taskArn: restored.taskArn });
142
150
  if (rPoll.outcome !== 'stopped' || rPoll.status.exitCode !== 0) {
143
151
  const why = rPoll.outcome === 'stopped' ? `exit ${rPoll.status.exitCode}${rPoll.status.stoppedReason ? ` — ${rPoll.status.stoppedReason}` : ''}` : rPoll.outcome;
@@ -23,6 +23,7 @@ import fs from 'node:fs/promises';
23
23
  import path from 'node:path';
24
24
  import { pathToFileURL } from 'node:url';
25
25
  import type { ModelDescriptor, Module } from '@everystack/model';
26
+ import { linkModels } from '@everystack/model';
26
27
  import { introspectSchema } from '../schema-introspect';
27
28
  import { compileDrizzleSource } from '../schema-source';
28
29
  import { compileModuleMigration } from '../migration-compile';
@@ -60,7 +61,7 @@ export async function loadModels(modelsPath: string): Promise<ModelDescriptor[]>
60
61
  const models = mod.models ?? mod.default;
61
62
  if (!Array.isArray(models)) throw new Error(`${modelsPath} must export a \`models\` array (got ${typeof models}).`);
62
63
  assertNoHoles(modelsPath, 'models', models);
63
- return models;
64
+ return linkModels(models);
64
65
  } catch (err) {
65
66
  throw asModelComposeError(modelsPath, err);
66
67
  }
@@ -80,9 +81,12 @@ export async function loadModules(modelsPath: string): Promise<Module[]> {
80
81
  } catch (err: any) {
81
82
  throw new Error(`Could not load modules from ${modelsPath}: ${err.message}`);
82
83
  }
83
- if (Array.isArray(mod.modules)) return mod.modules;
84
+ if (Array.isArray(mod.modules)) {
85
+ linkModels(mod.modules.flatMap((module: Module) => module.models));
86
+ return mod.modules;
87
+ }
84
88
  const models = mod.models ?? mod.default;
85
- if (Array.isArray(models)) return [{ models, extensions: [], sequences: [], derived: [], governedRoles: [], functions: [] }];
89
+ if (Array.isArray(models)) return [{ models: linkModels(models), extensions: [], sequences: [], derived: [], governedRoles: [], functions: [] }];
86
90
  throw new Error(`${modelsPath} must export a \`modules\` (or \`models\`) array.`);
87
91
  }
88
92
 
@@ -1344,7 +1344,7 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
1344
1344
  }
1345
1345
 
1346
1346
  /** Render a doctor report — identical output whichever venue produced it. */
1347
- function printDoctorReport(report: any): void {
1347
+ export function printDoctorReport(report: any): void {
1348
1348
  console.log('');
1349
1349
  info(`api connection (DATABASE_URL): role=${report.api.role} superuser=${report.api.isSuperuser} bypassrls=${report.api.bypassRls}`);
1350
1350
  info(`operator connection (ADMIN_URL): role=${report.admin.role} superuser=${report.admin.isSuperuser} bypassrls=${report.admin.bypassRls}`);
@@ -1361,7 +1361,8 @@ function printDoctorReport(report: any): void {
1361
1361
  if (report.ok) {
1362
1362
  success('db:doctor — database is least-privilege and RLS-subject');
1363
1363
  } else {
1364
- fail('db:doctor — the api connection is NOT correctly least-privilege (see failures above)');
1364
+ const failed = (report.checks as Array<{ status: string }>).filter((c) => c.status === 'fail').length;
1365
+ fail(`db:doctor — ${failed} check(s) failed (see failures above)`);
1365
1366
  // Probing a local database as your own superuser makes these fail by construction:
1366
1367
  // the checks describe the credential the API serves with, not the schema. A developer
1367
1368
  // reading them as defects in their app will go hunting for a bug that is not there.