@everystack/server 0.4.34 → 0.4.35

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,25 @@ All notable changes to `@everystack/server` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.35] - 2026-10-14
8
+
9
+ ### Added
10
+
11
+ - stamp originCommit and authzManifest into the backup meta
12
+ - db:backup:stamp-authz merges the manifest into a finished backup
13
+ - db:restore and db:fork write a restore witness from the backup's origin (#15)
14
+ - db:doctor fails when an app role bypasses RLS
15
+
16
+ ### Fixed
17
+
18
+ - lease the ops-Lambda write path, no driver fork
19
+ - never recycle the leased mutation connection
20
+ - import the lease inside db:apply's try
21
+ - three fold-ins from the bounded re-review
22
+ - an _action name resolves only to an own key of actions
23
+ - db:doctor probes the app's own request roles
24
+ - db:doctor prints each fix once and an accurate closing line
25
+
7
26
  ## [0.4.34] - 2026-09-27
8
27
 
9
28
  ### Added
package/README.md CHANGED
@@ -147,7 +147,7 @@ Actions contributed (each is an `everystack db:*` command):
147
147
  |---|---|
148
148
  | `migrate` / `seed` / `db:reset` | run migrations, seed (dev), drop+remigrate (dev) |
149
149
  | `db:psql` / `db:query` | connection info / read-only SQL via Lambda |
150
- | `db:doctor` | audit: is the API connection least-privilege + RLS-subject? Flags BYPASSRLS, un-FORCEd RLS, and **app-reachable tables without RLS** |
150
+ | `db:doctor` | audit: is the API connection least-privilege + RLS-subject? Flags BYPASSRLS, un-FORCEd RLS, **app-reachable tables without RLS**, and **an app role (anon/authenticated/admin) that is SUPERUSER or BYPASSRLS** |
151
151
  | `db:provision` | create the least-privilege role chain on an existing DB (idempotent; roles only) |
152
152
  | `db:backup` | dispatched to the Fargate Task lane: `pg_dump -Fc` → gzip → S3 (streamed), fingerprint-stamped; awaits the child exit code, deletes the partial on failure |
153
153
  | `db:backups` | list a stage's backups |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.4.34",
3
+ "version": "0.4.35",
4
4
  "description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -120,6 +120,11 @@
120
120
  "source": "./src/backup-run.ts",
121
121
  "types": "./src/backup-run.ts",
122
122
  "default": "./src/backup-run.ts"
123
+ },
124
+ "./restore-witness": {
125
+ "source": "./src/restore-witness.ts",
126
+ "types": "./src/restore-witness.ts",
127
+ "default": "./src/restore-witness.ts"
123
128
  }
124
129
  },
125
130
  "peerDependencies": {
@@ -130,7 +135,7 @@
130
135
  "@aws-sdk/s3-request-presigner": "3.1053.0",
131
136
  "@aws-sdk/signature-v4a": ">=3.1048.0",
132
137
  "@everystack/auth": ">=0.1.0",
133
- "@everystack/cli": ">=0.1.0",
138
+ "@everystack/cli": ">=0.4.99",
134
139
  "drizzle-orm": "0.41.0",
135
140
  "heic-decode": "2.1.0",
136
141
  "postgres": "3.4.9",
@@ -187,8 +192,8 @@
187
192
  "sst": "4.13.1",
188
193
  "ts-jest": "29.4.9",
189
194
  "typescript": "5.9.3",
190
- "@everystack/cli": "0.4.98",
191
- "@everystack/auth": "0.4.8"
195
+ "@everystack/auth": "0.4.9",
196
+ "@everystack/cli": "0.4.99"
192
197
  },
193
198
  "scripts": {
194
199
  "test": "jest",
package/src/backup-run.ts CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  backupPrefix, metaKey, idForKey, keyForId,
21
21
  pgEnvFromUrl, dbNameFromUrl, pgDumpArgs, pgRestoreArgs,
22
22
  restoreIntentPrefix,
23
- type BackupManifest, type RestoreIntent,
23
+ type BackupManifest, type BackupAuthzManifest, type RestoreIntent,
24
24
  } from './backup';
25
25
 
26
26
  /** Where backups live + the operator connection pg_dump/pg_restore use. */
@@ -91,6 +91,8 @@ export interface BackupTarget {
91
91
  * pre-B3 or fp-less backup can't be a verified net).
92
92
  */
93
93
  fingerprint?: string;
94
+ /** The operator-read origin commit (BackupManifest.originCommit) — stamped as sent, never read here. */
95
+ originCommit?: string | null;
94
96
  }
95
97
 
96
98
  /**
@@ -121,23 +123,86 @@ export function buildBackupMeta(input: {
121
123
  fingerprint?: string;
122
124
  /** Who computed it. Defaults to 'operator' — the only producer today. */
123
125
  fingerprintSource?: string;
126
+ /** The operator-read origin commit. `null` is a value (proven none) and is stamped; undefined is not. */
127
+ originCommit?: string | null;
128
+ /** The operator-rendered declared-authz manifest, stamped as sent. */
129
+ authzManifest?: BackupAuthzManifest;
124
130
  // Returns the CONSUMER's type, not Record<string, unknown>: that is what makes producer/consumer
125
131
  // drift a compile error instead of a runtime surprise the gate discovers.
126
132
  }): BackupManifest {
127
- const { id, stage, key, createdAt, bytes, fingerprint, fingerprintSource } = input;
128
- return {
133
+ const { id, stage, key, createdAt, bytes, fingerprint, fingerprintSource, originCommit, authzManifest } = input;
134
+ const meta: BackupManifest = {
129
135
  id, stage, createdAt, bytes, key,
130
136
  // `fingerprintSource` records HOW the value was obtained, not just that it exists. The gate
131
137
  // accepts an operator-computed fingerprint by design, so that decision should be auditable
132
138
  // after the fact rather than reconstructed from version archaeology — and a future gate can
133
139
  // tighten on it without guessing.
134
140
  ...(fingerprint ? { fingerprint, metaVersion: 2, fingerprintSource: fingerprintSource ?? 'operator' } : {}),
141
+ // Appended AFTER every pre-existing key, and only when present, so a caller passing neither
142
+ // writes byte-identical JSON to before these fields existed.
143
+ ...(originCommit !== undefined ? { originCommit } : {}),
135
144
  };
145
+ return authzManifest !== undefined ? withAuthzManifest(meta, authzManifest) : meta;
146
+ }
147
+
148
+ /**
149
+ * Stamp the declared-authz manifest onto a backup manifest — the ONE place that field is written,
150
+ * whether at build time or by the post-dump stamp. Appended after every existing key; nothing else
151
+ * is touched. In particular provenance is never re-stamped: running an already-written manifest back
152
+ * through buildBackupMeta would mark any fingerprint `metaVersion: 2`, upgrading a task-computed
153
+ * fingerprint into one the destructive-apply gate trusts.
154
+ */
155
+ export function withAuthzManifest(meta: BackupManifest, authzManifest: BackupAuthzManifest): BackupManifest {
156
+ const { authzManifest: _replaced, ...rest } = meta;
157
+ return { ...rest, authzManifest };
158
+ }
159
+
160
+ /**
161
+ * The post-dump stamp: read the `.meta.json` the task wrote for `key`, merge the operator-rendered
162
+ * authz manifest in (withAuthzManifest), write it back. The manifest cannot ride the task job — the
163
+ * job is one ECS env override capped at 8192 chars — so the CLI sends it here once the dump has
164
+ * succeeded. A backup this never reaches simply lacks the field, which a restore treats as "no
165
+ * manifest", never as an empty one.
166
+ *
167
+ * Refuses rather than invents: no stored manifest (the dump failed or was retracted) or one that
168
+ * describes a different dump is an error, and nothing is written.
169
+ */
170
+ export async function stampBackupAuthz(
171
+ deps: Pick<BackupRunDeps, 'bucket' | 'region'> & {
172
+ /** Injected S3 client — the seam that lets the read/merge/write be tested. */
173
+ s3?: { send: (command: unknown) => Promise<unknown> };
174
+ },
175
+ key: string,
176
+ authzManifest: BackupAuthzManifest,
177
+ ): Promise<BackupManifest> {
178
+ const s3 = deps.s3 ?? (await getS3(deps.region));
179
+ const { GetObjectCommand, PutObjectCommand } = await importOptionalAws(
180
+ () => import('@aws-sdk/client-s3'),
181
+ '@aws-sdk/client-s3',
182
+ 'db:backup',
183
+ );
184
+ const mkey = metaKey(key);
185
+ let existing: BackupManifest;
186
+ try {
187
+ const obj: any = await s3.send(new GetObjectCommand({ Bucket: deps.bucket, Key: mkey }));
188
+ existing = JSON.parse(await obj.Body.transformToString()) as BackupManifest;
189
+ } catch (err: any) {
190
+ throw new Error(`no manifest to stamp at ${mkey} (${err?.name ?? 'error'}: ${err?.message ?? String(err)}) — the dump may have failed or been retracted.`);
191
+ }
192
+ if (existing.key !== key) {
193
+ throw new Error(`the manifest at ${mkey} describes ${existing.key}, not ${key} — refusing to stamp it.`);
194
+ }
195
+ const merged = withAuthzManifest(existing, authzManifest);
196
+ await s3.send(new PutObjectCommand({
197
+ Bucket: deps.bucket, Key: mkey, Body: JSON.stringify(merged),
198
+ ContentType: 'application/json', ServerSideEncryption: 'AES256',
199
+ }));
200
+ return merged;
136
201
  }
137
202
 
138
203
  export async function runBackup(deps: BackupRunDeps, target: BackupTarget): Promise<BackupSummary> {
139
204
  const { bucket, region, adminUrl } = deps;
140
- const { stage, id, key, fingerprint } = target;
205
+ const { stage, id, key, fingerprint, originCommit } = target;
141
206
  const env = { ...process.env, ...pgEnvFromUrl(adminUrl) };
142
207
  const mkey = metaKey(key);
143
208
 
@@ -189,7 +254,7 @@ export async function runBackup(deps: BackupRunDeps, target: BackupTarget): Prom
189
254
  bytes = head.ContentLength;
190
255
  } catch { /* size is best-effort */ }
191
256
 
192
- const meta = buildBackupMeta({ id, stage, key, bytes, fingerprint, createdAt: new Date().toISOString() });
257
+ const meta = buildBackupMeta({ id, stage, key, bytes, fingerprint, originCommit, createdAt: new Date().toISOString() });
193
258
  await s3.send(new PutObjectCommand({
194
259
  Bucket: bucket, Key: mkey, Body: JSON.stringify(meta),
195
260
  ContentType: 'application/json', ServerSideEncryption: 'AES256',
@@ -271,7 +336,7 @@ export async function runExport(
271
336
  * ONLY on a successful dump, so absence == "no completed backup with that id"). The verify treats
272
337
  * null as a refusal.
273
338
  */
274
- export async function fetchBackupManifest(deps: BackupRunDeps, id: string): Promise<BackupManifest | null> {
339
+ export async function fetchBackupManifest(deps: Pick<BackupRunDeps, 'bucket' | 'region'>, id: string): Promise<BackupManifest | null> {
275
340
  const key = keyForId(id);
276
341
  if (!key) return null;
277
342
  const s3 = await getS3(deps.region);
package/src/backup.ts CHANGED
@@ -165,6 +165,27 @@ export interface BackupManifest {
165
165
  fingerprintSource?: string;
166
166
  bytes?: number;
167
167
  key: string;
168
+ /**
169
+ * The commit that last deployed the dumped database — schema_log's anchor (the newest successful
170
+ * apply that recorded a commit), read by the operator in the same session as `fingerprint`.
171
+ * `null` = a clean read proved there is none; absent = unknown (pre-R1, or the read failed).
172
+ */
173
+ originCommit?: string | null;
174
+ /** What the Models declared for authz when the backup was taken — see BackupAuthzManifest. */
175
+ authzManifest?: BackupAuthzManifest;
176
+ }
177
+
178
+ /**
179
+ * The declared-authz manifest a backup records: rendered by the OPERATOR's CLI from the models
180
+ * checked out at dump time (@everystack/cli authz-manifest.ts, which narrows each section to its
181
+ * real type), so a later restore can converge authz without compiling a historical models tree.
182
+ * The sections are opaque on this side of the mirror: they ride into the `.meta.json` unchanged.
183
+ */
184
+ export interface BackupAuthzManifest {
185
+ version: number;
186
+ tables: unknown[];
187
+ derived: unknown;
188
+ owners: unknown;
168
189
  }
169
190
 
170
191
  export interface SnapshotVerifyInput {
@@ -265,6 +286,10 @@ export interface RestoreIntent {
265
286
  taskArn?: string;
266
287
  status?: 'started' | 'running' | 'succeeded' | 'failed';
267
288
  finishedAt?: string;
289
+ /** The schema_log witness row the dispatcher wrote in the target (restore-witness.ts). */
290
+ witnessId?: string;
291
+ /** The commit the restored state descends from — the backup meta's `originCommit`. */
292
+ origin?: string;
268
293
  }
269
294
 
270
295
  /** Format a Date as the UTC `YYYYMMDDTHHMMSSZ` stamp (caller's clock; pure). */
package/src/db-doctor.ts CHANGED
@@ -87,6 +87,21 @@ export interface DoctorProbe {
87
87
  * an older probe (the check is then skipped). B4.
88
88
  */
89
89
  darkRelations?: DarkRelation[];
90
+ /**
91
+ * The app roles the handler SET ROLEs into (anon/authenticated/admin), with their own
92
+ * SUPERUSER and BYPASSRLS attributes. Either one skips every policy, so RLS on every table
93
+ * enforces nothing for that role. Membership does not pass these attributes on, so no
94
+ * transitive check is needed. A configured role missing from the database is simply absent
95
+ * here. Absent on an older probe (the check is then skipped).
96
+ */
97
+ appRoleAttrs?: AppRoleAttrs[];
98
+ }
99
+
100
+ /** An app role's own RLS-relevant attributes, read from pg_roles. */
101
+ export interface AppRoleAttrs {
102
+ role: string;
103
+ isSuperuser: boolean;
104
+ bypassRls: boolean;
90
105
  }
91
106
 
92
107
  /** A function PUBLIC can EXECUTE, and whether it runs as its owner (the dangerous half). */
@@ -259,6 +274,18 @@ function pgTextArray(v: unknown): string[] {
259
274
 
260
275
  const ROLE_IDENT = /^[a-z_][a-z0-9_]*$/;
261
276
 
277
+ /** The role-chain request roles db:doctor probes when the app names none of its own. */
278
+ export const DEFAULT_APP_ROLES: readonly string[] = ['anon', 'authenticated', 'admin'];
279
+
280
+ /** Validate app-role names as plain idents, then quote each as a SQL literal: `'a', 'b'`. */
281
+ function appRoleLiterals(appRoles: readonly string[]): string {
282
+ if (appRoles.length === 0) throw new Error('Invalid app roles: empty list');
283
+ for (const r of appRoles) {
284
+ if (!ROLE_IDENT.test(r)) throw new Error(`Invalid app role: ${JSON.stringify(r)}`);
285
+ }
286
+ return appRoles.map((r) => `'${r}'`).join(', ');
287
+ }
288
+
262
289
  /**
263
290
  * Every base table an app role can reach — a table-level grant (`relacl`) OR a column-level
264
291
  * grant (`attacl`, the "revoke the table, grant safe columns" shape) — with its RLS flags.
@@ -267,11 +294,8 @@ const ROLE_IDENT = /^[a-z_][a-z0-9_]*$/;
267
294
  * (it only inspects tables that already have RLS on). App-role names are validated idents
268
295
  * (they're interpolated); they default to the role-chain set. Extension tables are excluded.
269
296
  */
270
- export function appGrantsRlsSql(appRoles: string[] = ['anon', 'authenticated', 'admin']): string {
271
- for (const r of appRoles) {
272
- if (!ROLE_IDENT.test(r)) throw new Error(`Invalid app role: ${JSON.stringify(r)}`);
273
- }
274
- const roleList = appRoles.map((r) => `'${r}'`).join(', ');
297
+ export function appGrantsRlsSql(appRoles: readonly string[] = DEFAULT_APP_ROLES): string {
298
+ const roleList = appRoleLiterals(appRoles);
275
299
  return `
276
300
  WITH app_granted AS (
277
301
  SELECT c.oid, r.rolname
@@ -304,6 +328,29 @@ ORDER BY n.nspname, c.relname;
304
328
  `.trim();
305
329
  }
306
330
 
331
+ /**
332
+ * The app roles' own SUPERUSER and BYPASSRLS attributes. The runner takes plain SQL text (no
333
+ * bind parameters), so the role names are validated the same way appGrantsRlsSql does it:
334
+ * each must match ROLE_IDENT before it is quoted into a literal, and anything else throws.
335
+ */
336
+ export function appRoleAttrsSql(appRoles: readonly string[] = DEFAULT_APP_ROLES): string {
337
+ return `
338
+ SELECT rolname, rolsuper, rolbypassrls
339
+ FROM pg_roles
340
+ WHERE rolname = ANY (ARRAY[${appRoleLiterals(appRoles)}]::text[])
341
+ ORDER BY rolname
342
+ `.trim();
343
+ }
344
+
345
+ /** Map an appRoleAttrsSql row to AppRoleAttrs (tolerant of pg driver boolean encodings). */
346
+ export function rowToAppRoleAttrs(row: any): AppRoleAttrs {
347
+ return {
348
+ role: String(row.rolname),
349
+ isSuperuser: pgBool(row.rolsuper),
350
+ bypassRls: pgBool(row.rolbypassrls),
351
+ };
352
+ }
353
+
307
354
  /** Options for the report builder. */
308
355
  export interface DoctorReportOptions {
309
356
  /**
@@ -368,6 +415,36 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
368
415
  detail: api.bypassRls ? `"${api.role}" has the BYPASSRLS attribute — every policy is skipped` : `"${api.role}" is subject to RLS`,
369
416
  });
370
417
 
418
+ // The roles the handler SET ROLEs into. The api role above can be clean and every table can
419
+ // have RLS + FORCE, yet a superuser or BYPASSRLS app role still skips every policy. A role
420
+ // missing from the database is not this check's failure: SET ROLE into it errors (fails closed).
421
+ if (probe.appRoleAttrs) {
422
+ const lines: string[] = [];
423
+ const fixes: string[] = [];
424
+ for (const r of probe.appRoleAttrs) {
425
+ if (r.isSuperuser) {
426
+ lines.push(`app role ${r.role} is a superuser; every policy is skipped.`);
427
+ fixes.push(`ALTER ROLE ${r.role} NOSUPERUSER;`);
428
+ }
429
+ if (r.bypassRls) {
430
+ lines.push(`app role ${r.role} is BYPASSRLS; every policy is skipped.`);
431
+ fixes.push(`ALTER ROLE ${r.role} NOBYPASSRLS;`);
432
+ }
433
+ }
434
+ const names = probe.appRoleAttrs.map((r) => r.role).sort();
435
+ checks.push(
436
+ lines.length > 0
437
+ ? { name: 'App roles are subject to RLS', status: 'fail', detail: lines.join(' '), remediation: fixes.join(' ') }
438
+ : {
439
+ name: 'App roles are subject to RLS',
440
+ status: 'pass',
441
+ detail: names.length > 0
442
+ ? `app role(s) ${names.join(', ')} are neither superuser nor BYPASSRLS`
443
+ : 'no app role found on this database; nothing to check',
444
+ },
445
+ );
446
+ }
447
+
371
448
  // The definitive least-privilege test: can the api connection read an RLS table with no
372
449
  // SET ROLE? A role can pass "not superuser / no bypassrls / FORCE on" and still see every
373
450
  // row through INHERITed membership in a USING(true) role (the master's leak path). Only a
@@ -632,6 +709,10 @@ export interface DoctorProbeRunners {
632
709
  export interface RunDoctorProbeOptions extends DoctorReportOptions {
633
710
  /** Names the api credential source in the connect-failure message (e.g. 'DATABASE_URL'). */
634
711
  apiSourceLabel?: string;
712
+ /** The app's request roles (the ones the handler SET ROLEs into), probed for RLS-off grants
713
+ * and SUPERUSER/BYPASSRLS. Default: DEFAULT_APP_ROLES (anon/authenticated/admin). Each must
714
+ * be a plain ident; a bad name or an empty list throws. */
715
+ appRoles?: string[];
635
716
  }
636
717
 
637
718
  const quoteDoctorIdent = (s: string): string => '"' + String(s).replace(/"/g, '""') + '"';
@@ -656,6 +737,9 @@ export async function runDoctorProbe(
656
737
  ): Promise<DoctorReport> {
657
738
  const { api: apiRun, ops: opsRun } = runners;
658
739
  const sourceLabel = options.apiSourceLabel ?? 'DATABASE_URL';
740
+ // Built before any query so a bad role name throws before a connection is touched.
741
+ const grantsSql = appGrantsRlsSql(options.appRoles);
742
+ const roleAttrsSql = appRoleAttrsSql(options.appRoles);
659
743
 
660
744
  let api: ConnInfo;
661
745
  try {
@@ -666,10 +750,11 @@ export async function runDoctorProbe(
666
750
 
667
751
  const admin = rowToConnInfo((await opsRun(CONN_SQL))[0]);
668
752
  const forceRls = (await opsRun(FORCE_RLS_SQL)).map(rowToForceRls);
669
- const appGrantedRls = (await opsRun(appGrantsRlsSql())).map(rowToAppGranted);
753
+ const appGrantedRls = (await opsRun(grantsSql)).map(rowToAppGranted);
670
754
  const secdefFunctions = (await opsRun(SECDEF_FUNCTIONS_SQL)).map(rowToSecdefFn);
671
755
  const publicExecutableFunctions = (await opsRun(PUBLIC_EXECUTABLE_FUNCTIONS_SQL)).map(rowToPublicExecutableFn);
672
756
  const darkRelations = (await opsRun(DARK_RELATIONS_SQL)).map(rowToDarkRelation);
757
+ const appRoleAttrs = (await opsRun(roleAttrsSql)).map(rowToAppRoleAttrs);
673
758
 
674
759
  // Ambient-access probe: can the api connection read an RLS table WITHOUT SET ROLE? A
675
760
  // least-privilege role must fail closed here; a role that returns rows carries ambient
@@ -687,7 +772,7 @@ export async function runDoctorProbe(
687
772
  }
688
773
 
689
774
  return buildDoctorReport(
690
- { api, admin, forceRls, appGrantedRls, apiAmbientRead, secdefFunctions, publicExecutableFunctions, darkRelations },
775
+ { api, admin, forceRls, appGrantedRls, apiAmbientRead, secdefFunctions, publicExecutableFunctions, darkRelations, appRoleAttrs },
691
776
  { forceRlsCarveouts: options.forceRlsCarveouts, functionSchemas: options.functionSchemas },
692
777
  );
693
778
  }
package/src/db.ts CHANGED
@@ -198,7 +198,7 @@ export function sslOptionForUrl(url: string): 'require' | undefined {
198
198
  }
199
199
 
200
200
  /** postgres.js options shared by every connection — ssl derived from the URL. */
201
- function connectionOptions(url: string, maxConnections?: number): Parameters<typeof postgres>[1] {
201
+ export function connectionOptions(url: string, maxConnections?: number): Parameters<typeof postgres>[1] {
202
202
  const ssl = sslOptionForUrl(url);
203
203
  return {
204
204
  max: maxConnections ?? 1,
@@ -209,6 +209,17 @@ function connectionOptions(url: string, maxConnections?: number): Parameters<typ
209
209
  };
210
210
  }
211
211
 
212
+ /**
213
+ * Options for the disposable connection a mutating ops action leases for its whole duration.
214
+ * It holds a session advisory lock (the mutation lease), so the driver must never recycle it:
215
+ * a `max_lifetime` or `idle_timeout` close mid-action silently swaps in a new backend and the
216
+ * lock is gone with the old one. Both are off; the caller always `.end()`s it in a `finally`.
217
+ * Same reasoning as the CLI's `createUrlRunner` (packages/cli/src/cli/db-source.ts).
218
+ */
219
+ export function mutationConnectionOptions(url: string): Parameters<typeof postgres>[1] {
220
+ return { ...connectionOptions(url, 1), max_lifetime: null, idle_timeout: 0 };
221
+ }
222
+
212
223
  // Lazy singleton DB connection
213
224
  let dbInstance: ReturnType<typeof drizzle> | null = null;
214
225
  let sqlClient: ReturnType<typeof postgres> | null = null;
package/src/index.ts CHANGED
@@ -233,6 +233,8 @@ export function createLambdaHandler(
233
233
  // Events with _action are NOT HTTP events (no requestContext.http).
234
234
  if ('_action' in event && typeof event._action === 'string' && options.onAction) {
235
235
  const { handlers } = await ensureInitialized();
236
+ // The app's `onAction` owns the name lookup. A map lookup there should read own keys only
237
+ // (`Object.hasOwn`), or `constructor` and the other Object.prototype members resolve.
236
238
  log('info', 'CLI action invoked', { action: event._action });
237
239
  try {
238
240
  return await options.onAction(event._action, event._payload, handlers);
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The disposable operator connection a mutating ops action leases for its own duration: its
3
+ * own dedicated socket, never the shared opsDb. opsDb is long-lived per warm container and
4
+ * reused across unrelated invocations; pinning a session advisory lock (the mutation lease)
5
+ * or a multi-statement DDL session onto it would leak the lock, or leftover session state,
6
+ * into whatever unrelated action this container serves next. This connection starts clean,
7
+ * is used for exactly one write, and is always closed when that write ends.
8
+ *
9
+ * It is FENCED (@everystack/cli/connection-fence): if its backend dies mid-action, the next
10
+ * statement fails with a named ConnectionLostError. It never reconnects onto a new, unleased
11
+ * backend, and it never reaches the driver's uncaught write on a closed socket. So this
12
+ * connection carries no drizzle `transaction`: transactions and sessions are plain
13
+ * statements through the fence.
14
+ */
15
+
16
+ import type { FencedConnection } from '@everystack/cli/connection-fence';
17
+ import * as db from './db';
18
+
19
+ export async function openMutationConnection(): Promise<FencedConnection> {
20
+ const op = db.getOperatorDatabaseUrl();
21
+ if (!op) {
22
+ throw new Error('no operator database connection configured (ADMIN_DATABASE_URL / Postgres master)');
23
+ }
24
+ const postgres = (await import('postgres')).default;
25
+ const { createConnectionFence, fencedConnection } = await import('@everystack/cli/connection-fence');
26
+ const fence = createConnectionFence();
27
+ const options = db.mutationConnectionOptions(op.url) as Record<string, unknown>;
28
+ const client = postgres(op.url, {
29
+ ...options,
30
+ onclose: fence.onclose(options.onclose as ((id: number) => void) | undefined),
31
+ });
32
+ return fencedConnection(client as unknown as Parameters<typeof fencedConnection>[0], fence);
33
+ }
package/src/plugin.ts CHANGED
@@ -13,6 +13,7 @@ import type {
13
13
  APIGatewayProxyStructuredResultV2,
14
14
  } from 'aws-lambda';
15
15
  import type { ApplyPlanOptions } from '@everystack/cli/apply';
16
+ import type { FencedConnection } from '@everystack/cli/connection-fence';
16
17
  // The Model-derived wire payloads, declared once in @everystack/model/wire and re-exported by
17
18
  // @everystack/cli/wire (the CLI is the only @everystack/* package this bundle can resolve the
18
19
  // declaration through — see packages/cli/src/wire.ts). Type-only, so nothing new is bundled.
@@ -230,7 +231,9 @@ export function createPluginLambdaHandler(
230
231
  }
231
232
  const { ctx, actions } = await ensureInitialized();
232
233
  const actionName = event._action;
233
- const actionHandler = actions[actionName];
234
+ // Own keys only: `actions` is a plain object, so a bare index would also resolve the members
235
+ // it inherits from Object.prototype (`constructor`, `toString`, ...) and call one as a handler.
236
+ const actionHandler = Object.hasOwn(actions, actionName) ? actions[actionName] : undefined;
234
237
  if (!actionHandler) {
235
238
  return { error: `Unknown action: ${actionName}` };
236
239
  }
@@ -608,6 +611,12 @@ export interface DbPluginOptions {
608
611
  * notification tables), so the list can't drift from the contract.
609
612
  */
610
613
  forceRlsCarveouts?: string[];
614
+ /**
615
+ * The app's request roles, if it has its own (a `roleHierarchy`, or a role like 'member').
616
+ * db:doctor probes these for RLS-off grants and SUPERUSER/BYPASSRLS. Default: anon,
617
+ * authenticated, admin. Names are lowercase letters, digits and underscores; a bad name or an empty list fails the probe.
618
+ */
619
+ appRoles?: string[];
611
620
  /**
612
621
  * Schemas that own a `writtenBy: 'functions'` table (e.g. `['auth']`). Pass
613
622
  * `deriveFunctionSchemas(models)`. db:doctor's `functions` teeth assert each such schema has
@@ -961,14 +970,22 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
961
970
  return { rows };
962
971
  };
963
972
 
964
- // A SessionRunner over THIS container's operator connection, for the in-Lambda verbs
965
- // (db:apply's fingerprint endpoints, db:reconcile's catalog read). Same core the
966
- // db:session action serves over the wire, so both mean the same thing by "one session".
967
- const sessionOverOpsDb = (sql: { raw: (q: string) => any }) =>
973
+ // A SessionRunner over a given drizzle db — the in-Lambda verbs (db:apply's fingerprint
974
+ // endpoints, db:reconcile's catalog read) share this core with the db:session action, so
975
+ // both mean the same thing by "one session".
976
+ const sessionOverDb = (db: any, sql: { raw: (q: string) => any }) =>
968
977
  async (statements: any, opts?: any) => {
969
978
  const { runSessionStatements } = await import('./db-session');
970
- return runSessionStatements(opsDb as any, statements, opts, (q: string) => sql.raw(q));
979
+ return runSessionStatements(db, statements, opts, (q: string) => sql.raw(q));
971
980
  };
981
+ // THIS container's long-lived operator connection — for reads only (db:apply's
982
+ // fingerprint endpoints, db:reconcile's catalog read when it isn't also writing).
983
+ const sessionOverOpsDb = (sql: { raw: (q: string) => any }) => sessionOverDb(opsDb, sql);
984
+
985
+ // The disposable, fenced connection a mutating action leases for its own duration: see
986
+ // mutation-connection.ts. Never opsDb.
987
+ const openMutationConnection = async () =>
988
+ (await import('./mutation-connection')).openMutationConnection();
972
989
 
973
990
  // --- db:session — N statements, ONE connection, ONE transaction ---
974
991
  // The primitive that makes a multi-statement remote read describe ONE moment.
@@ -1016,6 +1033,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
1016
1033
  apiSourceLabel: 'DATABASE_URL',
1017
1034
  forceRlsCarveouts: options.forceRlsCarveouts,
1018
1035
  functionSchemas: options.functionSchemas,
1036
+ appRoles: options.appRoles,
1019
1037
  },
1020
1038
  );
1021
1039
  } catch (err: any) {
@@ -1293,77 +1311,88 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
1293
1311
  return { error: 'this db:apply payload carries no descent verdict — the fast-forward rule cannot be waived by omission. Upgrade the everystack CLI (an older CLI omits it), or this is a client bug.' };
1294
1312
  }
1295
1313
 
1296
- const { sql } = await import('drizzle-orm');
1297
- const runner = async (query: string): Promise<any[]> => {
1298
- const result: any = await opsDb.execute(sql.raw(query));
1299
- return Array.isArray(result) ? result : result?.rows ?? [];
1300
- };
1301
- const transaction = async <T>(work: (transactionRunner: (query: string) => Promise<any[]>) => Promise<T>): Promise<T> =>
1302
- opsDb.transaction(async (tx: any) => work(async (query: string) => {
1303
- const result: any = await tx.execute(sql.raw(query));
1304
- return Array.isArray(result) ? result : result?.rows ?? [];
1305
- }));
1306
- // The fingerprint endpoints this apply gates on are multi-statement reads, so they
1307
- // run as ONE session on this container's operator connection. Server-side that was
1308
- // always one connection; saying so in the type is what stops a future caller from
1309
- // quietly reintroducing a per-statement read.
1310
- const session = sessionOverOpsDb(sql);
1311
-
1312
1314
  const destructive = Number(plan.destructive) > 0;
1313
1315
  const snapStage = stage ?? ctx.environment ?? 'default';
1314
1316
  // How old a backup may be and still auto-qualify as the safety net (the fp match already proves
1315
1317
  // schema identity; this only bounds how much data a restore would roll back).
1316
1318
  const SNAPSHOT_FRESHNESS_MS = 60 * 60_000;
1317
1319
  let snapshotId: string | undefined;
1320
+ let endLease: (() => Promise<void>) | undefined;
1318
1321
  try {
1319
- // checkDescent is REQUIRED by StageApplyOptions — no conditional spread can drop it. The
1320
- // verdict is guaranteed well-formed by the guard above; the hook enforces + records it here,
1321
- // in order, atomic with the lock. Authority/snapshot stay conditional (destructive-only, and
1322
- // the stage lane refuses destructive outright, so they never light up on this lane).
1323
- const applyOptions: StageApplyOptions = {
1324
- transaction,
1325
- actor: actor ?? null,
1326
- gitRef: gitRef ?? null,
1327
- dirty: dirty === true,
1328
- stateOnly: stateOnly === true,
1329
- checkDescent: async () => (descentVerdict.ok ? { ok: true as const } : { ok: false as const, reason: descentVerdict.reason ?? 'descent refused' }),
1330
- // The CLI's descent record (a honoured waiver, or a proven-drift origin) lands in the
1331
- // schema_log outcome; the witness query matches `applied%`, so the row stays a witness.
1332
- ...(descentAttested ? { descentNote: () => descentAttested } : {}),
1333
- ...(destructive && authorityVerdict
1334
- ? { verifyAuthority: async () => (authorityVerdict.ok ? { ok: true as const } : { ok: false as const, reason: authorityVerdict.reason ?? 'authority refused' }) }
1335
- : {}),
1336
- // The B3 safety gate: a destructive apply must be covered by a VERIFIED backup — the apply
1337
- // no longer takes its own. Resolve the ref (explicit id, else the stage's newest) and verify
1338
- // it SERVER-SIDE (never a CLI-attested boolean): succeeded, this stage, fresh, and
1339
- // fingerprint == plan.from. A miss is recorded in schema_log by executeApplyPlan.
1340
- ...(destructive
1341
- ? {
1342
- verifySnapshot: async ({ planFrom }: { planFrom: string }) => {
1343
- const r = await resolveBackupDeps();
1344
- if ('error' in r) return { ok: false as const, reason: `snapshot gate: ${r.error}` };
1345
- const { fetchBackupManifest, latestBackupManifest } = await import('./backup-run');
1346
- const { verifySnapshotRef } = await import('./backup');
1347
- const manifest = snapshotRef
1348
- ? await fetchBackupManifest(r.deps, snapshotRef)
1349
- : await latestBackupManifest(r.deps, snapStage);
1350
- const verdict = verifySnapshotRef({
1351
- manifest, planFrom, targetStage: snapStage,
1352
- now: new Date(), freshnessWindowMs: SNAPSHOT_FRESHNESS_MS,
1353
- });
1354
- if (!verdict.ok) return { ok: false as const, reason: verdict.reason };
1355
- snapshotId = verdict.id;
1356
- return { ok: true as const };
1357
- },
1358
- }
1359
- : {}),
1360
- };
1361
- const result = await executeApplyPlan(runner, session, plan, applyOptions);
1362
- // Echo the contract version the write ran under (SHOULD, legibility — the safety mechanism is
1363
- // the pre-transaction refusal above, not this field).
1364
- return { ...result, contractVersion: APPLY_CONTRACT_VERSION, ...(snapshotId ? { snapshotId } : {}) };
1322
+ // db:apply always writes (a reviewed plan is only ever sent here to be executed), so
1323
+ // the lease and every statement below — fingerprint reads included — run on ONE
1324
+ // disposable connection, never the shared opsDb. See openMutationConnection. Inside the
1325
+ // try: a resolution failure (no operator connection configured) must come back as a
1326
+ // clean { error }, not an uncaught rejection. The lease import too: a server paired with
1327
+ // an older @everystack/cli lacking it must refuse cleanly, same as db:reconcile.
1328
+ const { withMutationLease } = await import('@everystack/cli/mutation-lease');
1329
+ // Runner, transaction and session all ride the ONE fenced connection: the fingerprint
1330
+ // endpoints this apply gates on are multi-statement reads, and they run on the same
1331
+ // connection the DDL runs on, so the lease covers the whole thing. A backend that dies
1332
+ // mid-apply is a named ConnectionLostError, never a reconnect onto an unleased backend.
1333
+ const leased = await openMutationConnection();
1334
+ endLease = leased.end;
1335
+ const { runner, transaction, session } = leased;
1336
+
1337
+ // The universal mutation lease (mutation-lease.ts): one operator mutates a stage at a
1338
+ // time. Acquired on the SAME leased connection the DDL below runs on, held for the
1339
+ // whole write, released (or dropped with the connection) when it ends.
1340
+ return await withMutationLease(
1341
+ runner,
1342
+ { verb: 'db:apply', actor: actor ?? 'ops-lambda' },
1343
+ async () => {
1344
+ // checkDescent is REQUIRED by StageApplyOptions — no conditional spread can drop it. The
1345
+ // verdict is guaranteed well-formed by the guard above; the hook enforces + records it here,
1346
+ // in order, atomic with the lock. Authority/snapshot stay conditional (destructive-only, and
1347
+ // the stage lane refuses destructive outright, so they never light up on this lane).
1348
+ const applyOptions: StageApplyOptions = {
1349
+ transaction,
1350
+ actor: actor ?? null,
1351
+ gitRef: gitRef ?? null,
1352
+ dirty: dirty === true,
1353
+ stateOnly: stateOnly === true,
1354
+ checkDescent: async () => (descentVerdict.ok ? { ok: true as const } : { ok: false as const, reason: descentVerdict.reason ?? 'descent refused' }),
1355
+ // The CLI's descent record (a honoured waiver, or a proven-drift origin) lands in the
1356
+ // schema_log outcome; the witness query matches `applied%`, so the row stays a witness.
1357
+ ...(descentAttested ? { descentNote: () => descentAttested } : {}),
1358
+ ...(destructive && authorityVerdict
1359
+ ? { verifyAuthority: async () => (authorityVerdict.ok ? { ok: true as const } : { ok: false as const, reason: authorityVerdict.reason ?? 'authority refused' }) }
1360
+ : {}),
1361
+ // The B3 safety gate: a destructive apply must be covered by a VERIFIED backup — the apply
1362
+ // no longer takes its own. Resolve the ref (explicit id, else the stage's newest) and verify
1363
+ // it SERVER-SIDE (never a CLI-attested boolean): succeeded, this stage, fresh, and
1364
+ // fingerprint == plan.from. A miss is recorded in schema_log by executeApplyPlan.
1365
+ ...(destructive
1366
+ ? {
1367
+ verifySnapshot: async ({ planFrom }: { planFrom: string }) => {
1368
+ const r = await resolveBackupDeps();
1369
+ if ('error' in r) return { ok: false as const, reason: `snapshot gate: ${r.error}` };
1370
+ const { fetchBackupManifest, latestBackupManifest } = await import('./backup-run');
1371
+ const { verifySnapshotRef } = await import('./backup');
1372
+ const manifest = snapshotRef
1373
+ ? await fetchBackupManifest(r.deps, snapshotRef)
1374
+ : await latestBackupManifest(r.deps, snapStage);
1375
+ const verdict = verifySnapshotRef({
1376
+ manifest, planFrom, targetStage: snapStage,
1377
+ now: new Date(), freshnessWindowMs: SNAPSHOT_FRESHNESS_MS,
1378
+ });
1379
+ if (!verdict.ok) return { ok: false as const, reason: verdict.reason };
1380
+ snapshotId = verdict.id;
1381
+ return { ok: true as const };
1382
+ },
1383
+ }
1384
+ : {}),
1385
+ };
1386
+ const result = await executeApplyPlan(runner, session, plan, applyOptions);
1387
+ // Echo the contract version the write ran under (SHOULD, legibility — the safety mechanism is
1388
+ // the pre-transaction refusal above, not this field).
1389
+ return { ...result, contractVersion: APPLY_CONTRACT_VERSION, ...(snapshotId ? { snapshotId } : {}) };
1390
+ },
1391
+ );
1365
1392
  } catch (err: any) {
1366
1393
  return { error: err?.message || String(err) };
1394
+ } finally {
1395
+ if (endLease) await endLease();
1367
1396
  }
1368
1397
  };
1369
1398
 
@@ -1386,18 +1415,34 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
1386
1415
  // @everystack/cli/wire) — the same type the CLI builds this payload against, so the two
1387
1416
  // sides no longer agree by convention alone.
1388
1417
  const p = (payload ?? {}) as ReconcileWirePayload;
1418
+ // Only the WRITE path (p.apply === true) needs the lease and the disposable connection:
1419
+ // a plan-only call is a read, and reads never race a mutation. When mutating, the whole
1420
+ // introspect → plan → apply — not just the final DDL — runs on the leased connection, so
1421
+ // the plan it applies is never read on one session and written on another.
1422
+ const mutating = p.apply === true;
1423
+ let endLease: (() => Promise<void>) | undefined;
1389
1424
  try {
1390
1425
  const { executeReconcile } = await import('@everystack/cli/reconcile');
1391
1426
  const { sql } = await import('drizzle-orm');
1392
- const runner = async (query: string): Promise<any[]> => {
1427
+ const { withMutationLease } = await import('@everystack/cli/mutation-lease');
1428
+
1429
+ // A write runs every statement on the fenced leased connection; a read stays on opsDb.
1430
+ let runner = async (query: string): Promise<any[]> => {
1393
1431
  const result: any = await opsDb.execute(sql.raw(query));
1394
1432
  return Array.isArray(result) ? result : result?.rows ?? [];
1395
1433
  };
1434
+ let session: FencedConnection['session'] = sessionOverOpsDb(sql);
1435
+ if (mutating) {
1436
+ const leased = await openMutationConnection();
1437
+ endLease = leased.end;
1438
+ runner = leased.runner;
1439
+ session = leased.session;
1440
+ }
1396
1441
  // No `as any` on this call any more: `p.declared` is DerivedDescriptor[], which IS the
1397
1442
  // CLI's SourceObject[] (one declaration, in @everystack/model/wire), so the reconcile
1398
1443
  // core's options type accepts it directly. The cast used to hide exactly the kind of
1399
1444
  // mismatch this whole contract exists to catch.
1400
- const run = await executeReconcile(runner, sessionOverOpsDb(sql), {
1445
+ const runReconcile = () => executeReconcile(runner, session, {
1401
1446
  declared: p.declared,
1402
1447
  renamedTables: p.renamedTables,
1403
1448
  apply: p.apply,
@@ -1409,9 +1454,16 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
1409
1454
  actor: p.actor ?? null,
1410
1455
  gitRef: p.gitRef ?? null,
1411
1456
  });
1457
+ // The universal mutation lease: one operator mutates a stage at a time. Acquired on the
1458
+ // SAME leased connection the reconcile runs on, held for the whole call.
1459
+ const run = mutating
1460
+ ? await withMutationLease(runner, { verb: 'db:reconcile', actor: p.actor ?? 'ops-lambda' }, runReconcile)
1461
+ : await runReconcile();
1412
1462
  return { plan: run.plan, applied: run.applied, statements: run.statements, refusal: run.refusal ?? null };
1413
1463
  } catch (err: any) {
1414
1464
  return { error: err?.message || String(err) };
1465
+ } finally {
1466
+ if (endLease) await endLease();
1415
1467
  }
1416
1468
  };
1417
1469
 
@@ -0,0 +1,353 @@
1
+ /**
2
+ * restore-witness — what a restore leaves in the restored database's own ledger, and the refusals
3
+ * the ops Lambda's db:restore dispatcher runs before anything is written.
4
+ *
5
+ * pg_restore runs with --no-owner --no-privileges --no-comments (the dump must land in any stage),
6
+ * so every restored object comes back with PostgreSQL's DEFAULT privileges. The live authz then
7
+ * matches no commit. The witness is a schema_log row naming the commit the RESTORED state descends
8
+ * from, so a later converge can descend from it.
9
+ *
10
+ * That commit is the BACKUP's origin (`originCommit` in its .meta.json, recorded by db:backup),
11
+ * never the target's own last deploy. A DR restore of an older backup, or a fork into another
12
+ * stage, lands a state the target's ledger never deployed — its anchor would name the wrong commit.
13
+ * A backup with no recorded origin is refused: there is nothing honest to write.
14
+ *
15
+ * The ledger survives the restore (the dump excludes the `everystack` schema), so the dispatcher
16
+ * writes the witness BEFORE the task starts; a restore that dies half-way still leaves it standing.
17
+ * On a fresh target (a first fork) the ledger does not exist yet, so the witness creates it from
18
+ * the CLI's own ledger DDL.
19
+ */
20
+
21
+ import type { BackupAuthzManifest, BackupManifest } from './backup';
22
+
23
+ /** A query over the stage's operator connection (raw SQL — the ops runner binds no parameters). */
24
+ export type WitnessQuery = (sql: string) => Promise<any[]>;
25
+
26
+ const lit = (v: string | null): string => (v === null ? 'NULL' : `'${v.replace(/'/g, "''")}'`);
27
+
28
+ export const RESTORE_WITNESS_KIND = 'restore';
29
+ export const RESTORED_OUTCOME = 'restored';
30
+
31
+ /** Who the restore connection is — read before pg_restore runs. */
32
+ export interface RestoreRole {
33
+ role: string;
34
+ superuser: boolean;
35
+ /** RDS's master is NOSUPERUSER but a member of rds_superuser. */
36
+ rdsSuperuser: boolean;
37
+ }
38
+
39
+ export const RESTORE_ROLE_SQL = `
40
+ SELECT CURRENT_USER::text AS role,
41
+ b.rolsuper AS superuser,
42
+ EXISTS (
43
+ SELECT 1 FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid
44
+ WHERE m.member = b.oid AND g.rolname = 'rds_superuser'
45
+ ) AS rds_superuser
46
+ FROM pg_roles b WHERE b.rolname = CURRENT_USER`.trim();
47
+
48
+ const truthy = (v: unknown): boolean => v === true || v === 't' || v === 'true';
49
+
50
+ /**
51
+ * The refusal for restoring as the master, or null for the operator.
52
+ *
53
+ * Every object pg_restore creates is owned by the connecting role. Restored by the master, the
54
+ * operator can neither replace them nor revoke the PUBLIC grants they come back with.
55
+ */
56
+ export function restoreRoleRefusal(r: RestoreRole): string | null {
57
+ if (!r.superuser && !r.rdsSuperuser) return null;
58
+ return (
59
+ `refusing to restore as '${r.role}', the ${r.superuser ? 'superuser' : 'RDS master (rds_superuser member)'}: every restored ` +
60
+ `object would be owned by it, and the operator could not converge their grants afterwards. Run ` +
61
+ `\`everystack db:provision --stage <stage>\` so the restore runs as the operator, then restore again.`
62
+ );
63
+ }
64
+
65
+ export async function readRestoreRole(query: WitnessQuery): Promise<RestoreRole> {
66
+ const [r] = await query(RESTORE_ROLE_SQL);
67
+ return { role: String(r?.role ?? ''), superuser: truthy(r?.superuser), rdsSuperuser: truthy(r?.rds_superuser) };
68
+ }
69
+
70
+ export type OriginVerdict = { ok: true; origin: string } | { ok: false; reason: string };
71
+
72
+ /** A full git commit id: SHA-1 (40 hex) or SHA-256 (64 hex), lowercase, nothing else. */
73
+ export const COMMIT_SHA = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
74
+
75
+ /**
76
+ * The restore's origin commit, read from the backup's own meta. Pure.
77
+ *
78
+ * `expected` is the dump actually being restored: a meta that describes another dump is refused,
79
+ * so an origin can never be borrowed from a different backup.
80
+ */
81
+ export function restoreOriginFromMeta(
82
+ meta: BackupManifest | null,
83
+ expected: { id: string; key: string },
84
+ ): OriginVerdict {
85
+ const fresh = `take a fresh db:backup of the source stage and restore that`;
86
+ const predates = `backup ${expected.id} predates origin recording (its meta has no originCommit), so the restore cannot name the commit it lands — ${fresh}.`;
87
+ // A local-venue backup (db:backup --database-url) writes no fingerprint and no origin; so did a
88
+ // stage backup from before fingerprints. Only a fingerprint proves a stage backup, so without one
89
+ // the refusal names both causes rather than guessing.
90
+ const localOrPredates =
91
+ `backup ${expected.id} has no originCommit in its meta, so the restore cannot name the commit it lands. Either it is a ` +
92
+ `local backup (db:backup --database-url), which carries no origin — restoring a local backup into a stage is closed ` +
93
+ `until local backups can record a verified origin — or it is a stage backup that predates origin recording — ${fresh}.`;
94
+ if (!meta) {
95
+ return { ok: false, reason: `backup ${expected.id} has no readable meta, so it predates origin recording or its .meta.json could not be read — ${fresh}.` };
96
+ }
97
+ if (meta.key !== expected.key) {
98
+ return { ok: false, reason: `the meta for backup ${expected.id} describes ${String(meta.key)}, not ${expected.key} — refusing to take a restore's origin from another dump.` };
99
+ }
100
+ if (meta.originCommit === undefined) return { ok: false, reason: meta.fingerprint ? predates : localOrPredates };
101
+ if (meta.originCommit === null) {
102
+ return {
103
+ ok: false,
104
+ reason: `no commit deployed the backed-up database (backup ${expected.id} recorded originCommit: null), so the restore has no origin to descend from — deploy the source with db:apply, then ${fresh}.`,
105
+ };
106
+ }
107
+ if (typeof meta.originCommit !== 'string' || meta.originCommit.length === 0) {
108
+ return { ok: false, reason: `backup ${expected.id} records an unreadable originCommit (${JSON.stringify(meta.originCommit)}) — ${fresh}.` };
109
+ }
110
+ if (!COMMIT_SHA.test(meta.originCommit)) {
111
+ return { ok: false, reason: `backup ${expected.id} records originCommit ${JSON.stringify(meta.originCommit)}, which is not a commit sha (40 or 64 lowercase hex) — ${fresh}.` };
112
+ }
113
+ return { ok: true, origin: meta.originCommit };
114
+ }
115
+
116
+ /** How the fork meta is fetched: never following a redirect, and bounded in time. */
117
+ export interface MetaFetchInit {
118
+ redirect: 'error';
119
+ signal: AbortSignal;
120
+ }
121
+
122
+ /** The part of a fetch Response the fork meta read uses. `body` is read with a size cap. */
123
+ export interface MetaResponse {
124
+ ok: boolean;
125
+ status: number;
126
+ body?: ReadableStream<Uint8Array> | null;
127
+ text?: () => Promise<string>;
128
+ }
129
+
130
+ /** The minimal fetch the fork meta read needs (injectable for tests; the global fetch fits it). */
131
+ export type MetaFetch = (url: string, init: MetaFetchInit) => Promise<MetaResponse>;
132
+
133
+ /** A backup meta is a few KB even with its authz manifest. Anything past this is not one. */
134
+ export const META_MAX_BYTES = 1024 * 1024;
135
+ const META_TIMEOUT_MS = 10_000;
136
+
137
+ /** The body as text, or null when it exceeds `max` bytes (read no further than that). */
138
+ async function readCapped(res: MetaResponse, max: number): Promise<string | null> {
139
+ if (res.body && typeof res.body.getReader === 'function') {
140
+ const reader = res.body.getReader();
141
+ const chunks: Uint8Array[] = [];
142
+ let size = 0;
143
+ for (;;) {
144
+ const { done, value } = await reader.read();
145
+ if (done) break;
146
+ size += value.byteLength;
147
+ if (size > max) {
148
+ await reader.cancel().catch(() => {});
149
+ return null;
150
+ }
151
+ chunks.push(value);
152
+ }
153
+ return Buffer.concat(chunks).toString('utf8');
154
+ }
155
+ if (!res.text) throw new Error('the response has no body');
156
+ const text = await res.text();
157
+ return Buffer.byteLength(text, 'utf8') > max ? null : text;
158
+ }
159
+
160
+ const errText = (err: unknown): string => {
161
+ if (!(err instanceof Error)) return String(err);
162
+ const cause = (err as { cause?: unknown }).cause;
163
+ return cause instanceof Error ? `${err.message} (${cause.message})` : err.message;
164
+ };
165
+
166
+ export type ForkMetaVerdict =
167
+ | { ok: true; meta: BackupManifest; id: string; key: string }
168
+ | { ok: false; reason: string };
169
+
170
+ /** The S3 object key a presigned URL points at (virtual-hosted or path-style), or null. */
171
+ function keyOfPresigned(url: string, suffix: RegExp): string | null {
172
+ let path: string;
173
+ try {
174
+ path = decodeURIComponent(new URL(url).pathname);
175
+ } catch {
176
+ return null;
177
+ }
178
+ const m = path.match(new RegExp(`(backups/[^/]+/[^/]+${suffix.source})$`));
179
+ return m ? m[1] : null;
180
+ }
181
+
182
+ /**
183
+ * A fork's source meta, read by the dispatcher itself from the presigned `metaUrl` db:fork sends
184
+ * beside the dump `url`. Never taken from the payload: a gitRef in the payload is ignored. Refuses
185
+ * when the metaUrl is missing, is not the dump's own `.meta.json`, cannot be fetched, or is not JSON.
186
+ * Whether the meta describes this dump, and whether it records an origin, is restoreOriginFromMeta's.
187
+ */
188
+ export async function readForkMeta(
189
+ fetchFn: MetaFetch,
190
+ url: string,
191
+ metaUrl: string | undefined,
192
+ opts: { timeoutMs?: number } = {},
193
+ ): Promise<ForkMetaVerdict> {
194
+ const key = keyOfPresigned(url, /\.dump\.gz/);
195
+ if (!key) return { ok: false, reason: `db:restore {url} does not name a backup dump (backups/<stage>/<name>.dump.gz) — mint it with db:fork.` };
196
+ const id = key.replace(/^backups\//, '').replace(/\.dump\.gz$/, '');
197
+ if (!metaUrl) {
198
+ return { ok: false, reason: `db:restore {url} requires {metaUrl} — the presigned .meta.json of backup ${id}, which records its origin commit. Upgrade @everystack/cli and re-run db:fork.` };
199
+ }
200
+ // The Lambda fetches metaUrl itself, so it must be the presigned object beside the dump: https,
201
+ // on the dump's own host, never redirected, bounded in time and size.
202
+ const parsed = (u: string): URL | null => { try { return new URL(u); } catch { return null; } };
203
+ const dump = parsed(url);
204
+ const metaAt = parsed(metaUrl);
205
+ if (dump?.protocol !== 'https:') return { ok: false, reason: `db:restore {url} must be https (backup ${id}) — a presigned S3 URL always is.` };
206
+ if (metaAt?.protocol !== 'https:') return { ok: false, reason: `db:restore {metaUrl} must be https (backup ${id}) — a presigned S3 URL always is.` };
207
+ if (metaAt.host !== dump.host) {
208
+ return { ok: false, reason: `db:restore {metaUrl} (${metaAt.host}) is not on the same host as the dump {url} (${dump.host}) — a fork's meta must sit beside its dump.` };
209
+ }
210
+ if (keyOfPresigned(metaUrl, /\.meta\.json/) !== key.replace(/\.dump\.gz$/, '.meta.json')) {
211
+ return { ok: false, reason: `db:restore {metaUrl} is not the .meta.json of backup ${id} — a fork's meta must be the dump's own.` };
212
+ }
213
+ const timeoutMs = opts.timeoutMs ?? META_TIMEOUT_MS;
214
+ const signal = AbortSignal.timeout(timeoutMs);
215
+ let text: string | null;
216
+ try {
217
+ const res = await fetchFn(metaUrl, { redirect: 'error', signal });
218
+ if (!res.ok) return { ok: false, reason: `fetching the meta of backup ${id} failed: HTTP ${res.status} — presigned URLs expire in an hour; re-run db:fork.` };
219
+ text = await readCapped(res, META_MAX_BYTES);
220
+ } catch (err: unknown) {
221
+ if (signal.aborted) return { ok: false, reason: `fetching the meta of backup ${id} timed out after ${timeoutMs / 1000}s — re-run db:fork.` };
222
+ return { ok: false, reason: `fetching the meta of backup ${id} failed: ${errText(err)}` };
223
+ }
224
+ if (text === null) {
225
+ return { ok: false, reason: `the meta of backup ${id} is larger than 1 MiB — that is not a backup manifest; refusing to restore.` };
226
+ }
227
+ let meta: unknown;
228
+ try {
229
+ meta = JSON.parse(text);
230
+ } catch {
231
+ return { ok: false, reason: `the meta of backup ${id} is not valid JSON — refusing to restore without its recorded origin.` };
232
+ }
233
+ if (!meta || typeof meta !== 'object' || Array.isArray(meta)) {
234
+ return { ok: false, reason: `the meta of backup ${id} is not a backup manifest — refusing to restore without its recorded origin.` };
235
+ }
236
+ return { ok: true, meta: meta as BackupManifest, id, key };
237
+ }
238
+
239
+ const PUBLIC = /^public$/i;
240
+
241
+ const isRecord = (o: unknown): o is Record<string, unknown> => !!o && typeof o === 'object' && !Array.isArray(o);
242
+
243
+ /**
244
+ * Every role the backup's authz manifest names: grantees (tables, columns, policies, derived
245
+ * objects, schema USAGE) and declared owners, minus PUBLIC. `null` when there is no manifest —
246
+ * "unchecked", never "no roles". The manifest is opaque on this side of the mirror, so every
247
+ * section is read defensively.
248
+ */
249
+ export function declaredRoles(manifest: BackupAuthzManifest | null | undefined): string[] | null {
250
+ if (!isRecord(manifest)) return null;
251
+ const roles = new Set<string>();
252
+ const add = (r: unknown): void => {
253
+ if (typeof r === 'string' && r.length > 0 && !PUBLIC.test(r)) roles.add(r);
254
+ };
255
+ // A section that should be a { role: … } map but is an array would yield its indexes ('0', '1').
256
+ const keys = (o: unknown): string[] => (isRecord(o) ? Object.keys(o) : []);
257
+ const list = (a: unknown): unknown[] => (Array.isArray(a) ? a : []);
258
+
259
+ for (const t of list(manifest.tables) as any[]) {
260
+ keys(t?.grants).forEach(add);
261
+ keys(t?.columnGrants).forEach(add);
262
+ for (const p of list(t?.policies) as any[]) list(p?.roles).forEach(add);
263
+ }
264
+ const derived = manifest.derived as any;
265
+ for (const o of list(derived?.objects) as any[]) keys(o?.grants).forEach(add);
266
+ const usage: unknown = derived?.schemaUsage;
267
+ for (const grantees of isRecord(usage) ? Object.values(usage) : []) list(grantees).forEach(add);
268
+ const owners = manifest.owners as any;
269
+ for (const o of list(owners?.declaredOwners) as any[]) add(o?.owner);
270
+ // declaredObjects[].currentOwner is what the live database said at dump time, not a declaration.
271
+ for (const o of list(owners?.declaredObjects) as any[]) add(o?.declaredOwner);
272
+ return [...roles].sort();
273
+ }
274
+
275
+ /** The roles in `roles` that pg_roles does not have, sorted. */
276
+ export async function readMissingRoles(query: WitnessQuery, roles: string[]): Promise<string[]> {
277
+ if (roles.length === 0) return [];
278
+ const rows = await query(
279
+ `SELECT r AS role FROM unnest(ARRAY[${roles.map(lit).join(', ')}]::text[]) AS r ` +
280
+ `WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = r) ORDER BY r`,
281
+ );
282
+ return rows.map((row) => String(row.role));
283
+ }
284
+
285
+ /**
286
+ * The refusal for role names PostgreSQL cannot hold, or null. A NUL byte cannot appear in any
287
+ * PostgreSQL string, so asking pg_roles about one would throw instead of refusing.
288
+ */
289
+ export function unnameableRolesRefusal(roles: string[], ctx: { backupId: string }): string | null {
290
+ const bad = roles.filter((r) => r.includes('\u0000'));
291
+ if (bad.length === 0) return null;
292
+ return (
293
+ `refusing to restore backup ${ctx.backupId}: its authz manifest names role(s) containing a NUL byte ` +
294
+ `(${bad.map((r) => JSON.stringify(r)).join(', ')}), which no PostgreSQL role can have — the manifest is malformed.`
295
+ );
296
+ }
297
+
298
+ /** The refusal for a target missing roles the backup declares grants to, or null. */
299
+ export function missingRolesRefusal(missing: string[], ctx: { stage: string; backupId: string }): string | null {
300
+ if (missing.length === 0) return null;
301
+ return (
302
+ `refusing to restore backup ${ctx.backupId}: it declares grants to role(s) this database does not have (${missing.join(', ')}). ` +
303
+ `Restored objects would come back without them and the grants could not converge. Run ` +
304
+ `\`everystack db:provision --stage ${ctx.stage}\` to create the role chain, then restore again.`
305
+ );
306
+ }
307
+
308
+ /** Create the ledger if absent, from the CLI's own DDL (one source, idempotent). */
309
+ export async function ensureLedger(query: WitnessQuery): Promise<void> {
310
+ const { ENSURE_RECONCILER_SQL } = await import('@everystack/cli/apply');
311
+ for (const ddl of ENSURE_RECONCILER_SQL) await query(ddl);
312
+ }
313
+
314
+ export interface RestoreWitnessInput {
315
+ /** The S3 intent key — stored as the row's plan_ref so the two records name each other. */
316
+ intentKey: string;
317
+ /** What was restored, for the reader: a backup id, or "<id> (fork)". */
318
+ source: string;
319
+ actor: string | null;
320
+ /** The backup's origin commit (restoreOriginFromMeta). Required: there is no fallback. */
321
+ origin: string;
322
+ }
323
+
324
+ export interface RestoreWitness {
325
+ witnessId: string;
326
+ origin: string;
327
+ }
328
+
329
+ export async function writeRestoreWitness(query: WitnessQuery, input: RestoreWitnessInput): Promise<RestoreWitness> {
330
+ if (typeof input.origin !== 'string' || input.origin.length === 0) {
331
+ throw new Error('writeRestoreWitness requires the backup\'s origin commit');
332
+ }
333
+ await ensureLedger(query);
334
+ const note = `-- restore of ${input.source} (intent ${input.intentKey}): objects come back with default privileges; converge before the next apply`;
335
+ const [inserted] = await query(
336
+ `INSERT INTO everystack.schema_log (kind, actor, git_ref, plan_ref, sql, outcome) VALUES (` +
337
+ [RESTORE_WITNESS_KIND, input.actor, input.origin, input.intentKey, note, RESTORED_OUTCOME].map(lit).join(', ') +
338
+ `) RETURNING id`,
339
+ );
340
+ return { witnessId: String(inserted.id), origin: input.origin };
341
+ }
342
+
343
+ /**
344
+ * Void a witness whose restore never started (RunTask failed). A `failed…` outcome is not a
345
+ * witness to anything: a restore that never ran must not hold the stage.
346
+ */
347
+ export async function voidRestoreWitness(query: WitnessQuery, witnessId: string, reason: string): Promise<void> {
348
+ if (!/^\d+$/.test(witnessId)) throw new Error(`not a schema_log id: ${witnessId}`);
349
+ await query(
350
+ `UPDATE everystack.schema_log SET outcome = ${lit(`failed: restore did not start — ${reason}`.slice(0, 500))} ` +
351
+ `WHERE id = ${witnessId} AND kind = '${RESTORE_WITNESS_KIND}'`,
352
+ );
353
+ }