@everystack/server 0.2.26 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -125,6 +125,47 @@ Returns PostgreSQL connection URL from SST Resources.
125
125
 
126
126
  Returns JWT secret as `Uint8Array` from `Resource.JwtSecret.value`.
127
127
 
128
+ ## Database operations (`dbPlugin`)
129
+
130
+ `dbPlugin` registers the operator actions the CLI invokes over IAM-authed Lambda invoke (no HTTP, no shared secret). Add it to the ops Lambda's plugin list; it runs on the privileged operator connection (`ctx.adminDb`).
131
+
132
+ ```typescript
133
+ import { dbPlugin } from '@everystack/server/plugin';
134
+
135
+ dbPlugin({
136
+ migrationsFolder: join(__dirname, 'drizzle'),
137
+ seed: runSeed, // optional — registers `seed` (dev only)
138
+ schemas: ['auth', 'public', 'drizzle'], // db:reset drop set (dev only)
139
+ backupBucket: Resource.Backups.name, // private bucket for logical backups
140
+ forceRlsCarveouts: ['ops.runs'], // ENABLE-not-FORCE tables db:doctor shouldn't flag
141
+ });
142
+ ```
143
+
144
+ Actions contributed (each is an `everystack db:*` command):
145
+
146
+ | Action | Does |
147
+ |---|---|
148
+ | `migrate` / `seed` / `db:reset` | run migrations, seed (dev), drop+remigrate (dev) |
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** |
151
+ | `db:provision` | create the least-privilege role chain on an existing DB (idempotent; roles only) |
152
+ | `db:backup:probe` | verify the `pg_dump` layer is attached and `pg_dump` major ≥ the server major |
153
+ | `db:backup` | `pg_dump -Fc` → gzip → S3 (streamed); awaits the child exit code, deletes the partial on failure |
154
+ | `db:backups` | list a stage's backups |
155
+ | `db:restore` | S3 → gunzip → `pg_restore --clean --if-exists` (requires `confirm:true`) |
156
+ | `db:authz:probe` | run the CLI-built authorization red-team SQL in a rolled-back transaction |
157
+ | `console` / `console:meta` | the operator REPL (db, schema, models, auth in scope) |
158
+
159
+ ### Backup options
160
+
161
+ | Option | Type | Description |
162
+ |---|---|---|
163
+ | `backupBucket` | `string` | Private S3 bucket for logical backups. Pass `Resource.Backups.name`. Unset → `db:backup`/`db:backups`/`db:restore` return "backup storage not configured"; the rest of the plugin is unaffected. |
164
+ | `backupRegion` | `string` | Region for the backups bucket. Defaults to the Lambda's `AWS_REGION`. |
165
+ | `forceRlsCarveouts` | `string[]` | App-owned tables intentionally ENABLE-not-FORCE RLS (owner must write them), so `db:doctor` doesn't flag them. `auth.users` is always carved out. |
166
+
167
+ Logical backups need the `pg_dump` Lambda layer — see [docs/backups.md](../../docs/backups.md). Credentials reach `pg_dump`/`pg_restore` as `PG*` env vars, never on argv.
168
+
128
169
  ## Web SSR (`@everystack/server/ssr`)
129
170
 
130
171
  Serves Expo web builds deployed via `@everystack/cli`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.2.26",
3
+ "version": "0.3.1",
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>",
@@ -78,6 +78,7 @@
78
78
  "esbuild": ">=0.20.0",
79
79
  "@aws-sdk/client-cloudfront-keyvaluestore": ">=3.1053.0",
80
80
  "@aws-sdk/client-s3": "3.1053.0",
81
+ "@aws-sdk/lib-storage": "3.1053.0",
81
82
  "@aws-sdk/s3-request-presigner": "3.1053.0",
82
83
  "@aws-sdk/signature-v4a": ">=3.1048.0",
83
84
  "@everystack/auth": ">=0.1.0",
@@ -116,12 +117,17 @@
116
117
  "@aws-sdk/client-s3": {
117
118
  "optional": true
118
119
  },
120
+ "@aws-sdk/lib-storage": {
121
+ "optional": true
122
+ },
119
123
  "@aws-sdk/s3-request-presigner": {
120
124
  "optional": true
121
125
  }
122
126
  },
123
127
  "devDependencies": {
124
128
  "@aws-sdk/client-cloudfront-keyvaluestore": "3.1053.0",
129
+ "@aws-sdk/client-s3": "3.1053.0",
130
+ "@aws-sdk/lib-storage": "3.1053.0",
125
131
  "@aws-sdk/signature-v4a": "3.1063.0",
126
132
  "@types/aws-lambda": "8.10.161",
127
133
  "@types/jest": "29.5.14",
@@ -133,8 +139,8 @@
133
139
  "sst": "4.13.1",
134
140
  "ts-jest": "29.4.9",
135
141
  "typescript": "5.9.3",
136
- "@everystack/auth": "0.2.6",
137
- "@everystack/cli": "0.2.39"
142
+ "@everystack/auth": "0.3.0",
143
+ "@everystack/cli": "0.3.3"
138
144
  },
139
145
  "scripts": {
140
146
  "test": "jest",
@@ -0,0 +1,161 @@
1
+ /**
2
+ * db:backup execution — spawn pg_dump/pg_restore in the ops Lambda and stream to/from S3.
3
+ *
4
+ * The streaming is the whole point: pg_dump stdout → gzip → S3 multipart upload (never buffer the
5
+ * dump), and the reverse for restore. The load-bearing correctness rule (see docs/plans/
6
+ * db-backup-restore.md): the S3 Upload resolving does NOT mean pg_dump succeeded — pg_dump can exit
7
+ * non-zero mid-stream while the multipart "completes". So we await the child exit code SEPARATELY
8
+ * and, on failure, delete the partial object. A backup tool that ships a truncated dump is worse
9
+ * than none.
10
+ *
11
+ * The pure helpers (key scheme, PG* env, arg builders) live in ./backup and are unit-tested; this
12
+ * module is the IO and is proven by a deployed smoke test.
13
+ */
14
+
15
+ import { spawn } from 'node:child_process';
16
+ import { createGzip, createGunzip } from 'node:zlib';
17
+ import { pipeline } from 'node:stream/promises';
18
+ import { randomBytes } from 'node:crypto';
19
+ import type { Readable } from 'node:stream';
20
+ import {
21
+ backupPrefix, backupKey, metaKey, backupId, idForKey, keyForId, utcStamp,
22
+ pgEnvFromUrl, dbNameFromUrl, pgDumpArgs, pgRestoreArgs,
23
+ } from './backup';
24
+
25
+ /** Where backups live + the operator connection pg_dump/pg_restore use. */
26
+ export interface BackupRunDeps {
27
+ bucket: string;
28
+ region?: string;
29
+ /** The resolved operator (privileged) connection URL — never logged, never on argv. */
30
+ adminUrl: string;
31
+ }
32
+
33
+ export interface BackupSummary {
34
+ id: string;
35
+ key: string;
36
+ bytes?: number;
37
+ lastModified?: string;
38
+ }
39
+
40
+ async function getS3(region?: string): Promise<any> {
41
+ const { S3Client } = await import('@aws-sdk/client-s3');
42
+ return new S3Client(region ? { region } : {});
43
+ }
44
+
45
+ /**
46
+ * pg_dump -Fc → gzip → S3 (multipart, streamed). Writes a `.meta.json` sidecar on success.
47
+ * Throws (after deleting the partial object) if pg_dump exits non-zero — the dump is never trusted
48
+ * on a successful upload alone.
49
+ */
50
+ export async function runBackup(deps: BackupRunDeps, stage: string): Promise<BackupSummary> {
51
+ const { bucket, region, adminUrl } = deps;
52
+ const env = { ...process.env, ...pgEnvFromUrl(adminUrl) };
53
+ const stamp = utcStamp(new Date());
54
+ const shortId = randomBytes(3).toString('hex');
55
+ const key = backupKey(stage, stamp, shortId);
56
+ const mkey = metaKey(key);
57
+ const id = backupId(stage, stamp, shortId);
58
+
59
+ const s3 = await getS3(region);
60
+ const { Upload } = await import('@aws-sdk/lib-storage');
61
+ const { DeleteObjectCommand, PutObjectCommand, HeadObjectCommand } = await import('@aws-sdk/client-s3');
62
+
63
+ const child = spawn('pg_dump', pgDumpArgs(), { env, stdio: ['ignore', 'pipe', 'pipe'] });
64
+ let stderr = '';
65
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
66
+ const childExit = new Promise<void>((resolve, reject) => {
67
+ child.on('error', reject);
68
+ child.on('close', (code) =>
69
+ code === 0 ? resolve() : reject(new Error(`pg_dump exited ${code}: ${stderr.trim()}`)));
70
+ });
71
+
72
+ const gzip = createGzip();
73
+ const upload = new Upload({
74
+ client: s3,
75
+ params: {
76
+ Bucket: bucket, Key: key, Body: gzip,
77
+ ContentType: 'application/gzip', ServerSideEncryption: 'AES256',
78
+ },
79
+ });
80
+
81
+ try {
82
+ await Promise.all([
83
+ pipeline(child.stdout!, gzip), // pg_dump → gzip; errors propagate, streams destroyed
84
+ upload.done(), // gzip → S3 multipart
85
+ childExit, // the guard: non-zero pg_dump rejects the whole thing
86
+ ]);
87
+ } catch (err) {
88
+ try { await upload.abort(); } catch { /* may already have completed */ }
89
+ await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key })).catch(() => {});
90
+ throw err;
91
+ }
92
+
93
+ let bytes: number | undefined;
94
+ try {
95
+ const head = await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
96
+ bytes = head.ContentLength;
97
+ } catch { /* size is best-effort */ }
98
+
99
+ const meta = { id, stage, createdAt: new Date().toISOString(), bytes, key };
100
+ await s3.send(new PutObjectCommand({
101
+ Bucket: bucket, Key: mkey, Body: JSON.stringify(meta),
102
+ ContentType: 'application/json', ServerSideEncryption: 'AES256',
103
+ }));
104
+
105
+ return { id, key, bytes };
106
+ }
107
+
108
+ /** List a stage's backups (newest first) from S3 object metadata — one list call, no per-item gets. */
109
+ export async function listBackups(deps: BackupRunDeps, stage: string): Promise<BackupSummary[]> {
110
+ const s3 = await getS3(deps.region);
111
+ const { ListObjectsV2Command } = await import('@aws-sdk/client-s3');
112
+ const prefix = `${backupPrefix(stage)}/`;
113
+ const out: BackupSummary[] = [];
114
+ let token: string | undefined;
115
+ do {
116
+ const res: any = await s3.send(new ListObjectsV2Command({
117
+ Bucket: deps.bucket, Prefix: prefix, ContinuationToken: token,
118
+ }));
119
+ for (const o of res.Contents ?? []) {
120
+ if (!o.Key?.endsWith('.dump.gz')) continue;
121
+ const id = idForKey(o.Key);
122
+ if (id) out.push({ id, key: o.Key, bytes: o.Size, lastModified: o.LastModified?.toISOString() });
123
+ }
124
+ token = res.IsTruncated ? res.NextContinuationToken : undefined;
125
+ } while (token);
126
+ out.sort((a, b) => (b.lastModified ?? '').localeCompare(a.lastModified ?? ''));
127
+ return out;
128
+ }
129
+
130
+ /**
131
+ * S3 → gunzip → pg_restore (--clean --if-exists) into the operator connection. Strict: a non-zero
132
+ * pg_restore throws with its stderr. (We start strict and observe benign-error behavior on the
133
+ * deploy smoke test; the flags --no-owner/--no-privileges/--no-comments remove the usual offenders.)
134
+ */
135
+ export async function runRestore(deps: BackupRunDeps, id: string): Promise<{ id: string; restored: true }> {
136
+ const key = keyForId(id);
137
+ if (!key) throw new Error(`Malformed backup id: ${id}`);
138
+ const env = { ...process.env, ...pgEnvFromUrl(deps.adminUrl) };
139
+ const dbName = dbNameFromUrl(deps.adminUrl);
140
+
141
+ const s3 = await getS3(deps.region);
142
+ const { GetObjectCommand } = await import('@aws-sdk/client-s3');
143
+ const obj: any = await s3.send(new GetObjectCommand({ Bucket: deps.bucket, Key: key }));
144
+ if (!obj.Body) throw new Error(`Backup not found: ${id}`);
145
+ const body = obj.Body as Readable;
146
+
147
+ const child = spawn('pg_restore', pgRestoreArgs(dbName), { env, stdio: ['pipe', 'ignore', 'pipe'] });
148
+ let stderr = '';
149
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
150
+ const childExit = new Promise<void>((resolve, reject) => {
151
+ child.on('error', reject);
152
+ child.on('close', (code) =>
153
+ code === 0 ? resolve() : reject(new Error(`pg_restore exited ${code}: ${stderr.trim()}`)));
154
+ });
155
+
156
+ await Promise.all([
157
+ pipeline(body, createGunzip(), child.stdin!), // S3 → gunzip → pg_restore stdin
158
+ childExit,
159
+ ]);
160
+ return { id, restored: true };
161
+ }
package/src/backup.ts ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * db:backup — logical pg_dump/pg_restore helpers (server side).
3
+ *
4
+ * The version gate is load-bearing: pg_dump must be >= the server's MAJOR version, and there is no
5
+ * bypass flag (a newer server refuses an older client; a custom-format archive won't restore under
6
+ * an older pg_restore). So before any dump we compare the layer's pg_dump major against the live
7
+ * server's `server_version_num`. The parsing/compat logic is pure and unit-tested here; the actual
8
+ * spawn (which needs the pg_dump layer at runtime) is exercised by the deployed db:backup:probe.
9
+ * See docs/plans/db-backup-restore.md.
10
+ */
11
+
12
+ /**
13
+ * Parse the major version from `pg_dump --version` output, e.g.
14
+ * "pg_dump (PostgreSQL) 16.9" → 16. Returns null when no version can be found.
15
+ */
16
+ export function parsePgDumpMajor(versionOutput: string): number | null {
17
+ const m = versionOutput.match(/\b(\d+)(?:\.\d+)?\s*$/) ?? versionOutput.match(/\b(\d+)\.\d+\b/);
18
+ return m ? Number(m[1]) : null;
19
+ }
20
+
21
+ /** The server major from libpq's `server_version_num` (e.g. 160009 → 16). */
22
+ export function serverMajorFromNum(serverVersionNum: number): number {
23
+ return Math.floor(serverVersionNum / 10000);
24
+ }
25
+
26
+ /**
27
+ * Whether a pg_dump of `clientMajor` can safely dump/restore a server at `serverVersionNum`.
28
+ * The rule is simply clientMajor >= serverMajor — a newer client can read an older server, never
29
+ * the reverse.
30
+ */
31
+ export function isDumpCompatible(clientMajor: number, serverVersionNum: number): boolean {
32
+ return clientMajor >= serverMajorFromNum(serverVersionNum);
33
+ }
34
+
35
+ // --- S3 key scheme (canonical; the CLI's cli/backup.ts mirrors these for display/guards) --------
36
+
37
+ /** The S3 prefix all of a stage's logical backups live under (admin-only, encrypted). */
38
+ export function backupPrefix(stage: string): string {
39
+ return `backups/${stage}`;
40
+ }
41
+
42
+ /** The object key for a new backup dump. `stamp` is a UTC `YYYYMMDDTHHMMSSZ` string. */
43
+ export function backupKey(stage: string, stamp: string, shortId: string): string {
44
+ return `${backupPrefix(stage)}/${stamp}-${shortId}.dump.gz`;
45
+ }
46
+
47
+ /** The metadata sidecar key for a backup dump key. */
48
+ export function metaKey(dumpKey: string): string {
49
+ return dumpKey.replace(/\.dump\.gz$/, '.meta.json');
50
+ }
51
+
52
+ /** A stage-qualified backup id — what `list` returns and `restore` consumes: `dev/20260628T120000Z-ab12cd`. */
53
+ export function backupId(stage: string, stamp: string, shortId: string): string {
54
+ return `${stage}/${stamp}-${shortId}`;
55
+ }
56
+
57
+ /** Parse a backup id into its stage and name, or null when malformed. */
58
+ export function parseBackupRef(id: string): { stage: string; name: string } | null {
59
+ const m = id.match(/^([A-Za-z0-9_-]+)\/([A-Za-z0-9_.:-]+)$/);
60
+ return m ? { stage: m[1], name: m[2] } : null;
61
+ }
62
+
63
+ /** The S3 dump key for a backup id (inverse of backupId), or null when malformed. */
64
+ export function keyForId(id: string): string | null {
65
+ const ref = parseBackupRef(id);
66
+ return ref ? `${backupPrefix(ref.stage)}/${ref.name}.dump.gz` : null;
67
+ }
68
+
69
+ /** Derive the backup id from a dump key (inverse of backupKey), or null when it isn't a dump key. */
70
+ export function idForKey(dumpKey: string): string | null {
71
+ const m = dumpKey.match(/^backups\/([A-Za-z0-9_-]+)\/(.+)\.dump\.gz$/);
72
+ return m ? `${m[1]}/${m[2]}` : null;
73
+ }
74
+
75
+ /** Format a Date as the UTC `YYYYMMDDTHHMMSSZ` stamp (caller's clock; pure). */
76
+ export function utcStamp(date: Date): string {
77
+ const p = (n: number): string => String(n).padStart(2, '0');
78
+ return (
79
+ `${date.getUTCFullYear()}${p(date.getUTCMonth() + 1)}${p(date.getUTCDate())}` +
80
+ `T${p(date.getUTCHours())}${p(date.getUTCMinutes())}${p(date.getUTCSeconds())}Z`
81
+ );
82
+ }
83
+
84
+ // --- pg_dump / pg_restore invocation (PG* env from the admin URL, never argv) -------------------
85
+
86
+ /**
87
+ * Parse a postgres connection URL into libpq's PG* environment variables. Passing the credential
88
+ * via env (not argv) keeps the password out of the process table. Pure; callers must never log the
89
+ * result (it contains PGPASSWORD). Mirrors the CLI's pgEnvFromUrl.
90
+ */
91
+ export function pgEnvFromUrl(url: string): Record<string, string> {
92
+ const u = new URL(url);
93
+ if (!/^postgres(ql)?:$/.test(u.protocol)) {
94
+ throw new Error(`Not a postgres connection URL (protocol: ${u.protocol})`);
95
+ }
96
+ const env: Record<string, string> = {};
97
+ if (u.hostname) env.PGHOST = decodeURIComponent(u.hostname);
98
+ if (u.port) env.PGPORT = u.port;
99
+ if (u.username) env.PGUSER = decodeURIComponent(u.username);
100
+ if (u.password) env.PGPASSWORD = decodeURIComponent(u.password);
101
+ const database = u.pathname.replace(/^\//, '');
102
+ if (database) env.PGDATABASE = decodeURIComponent(database);
103
+ const sslmode = u.searchParams.get('sslmode');
104
+ if (sslmode) env.PGSSLMODE = sslmode;
105
+ return env;
106
+ }
107
+
108
+ /** The database name from a postgres connection URL (for pg_restore -d). */
109
+ export function dbNameFromUrl(url: string): string {
110
+ return decodeURIComponent(new URL(url).pathname.replace(/^\//, ''));
111
+ }
112
+
113
+ /**
114
+ * Args for `pg_dump` — custom format (portable, restorable under a different role/stage). The
115
+ * connection comes from PG* env, so no host/db on argv. --no-owner/--no-privileges/--no-comments
116
+ * make the dump restore cleanly into a different stage (and dodge the RDS plpgsql-comment error).
117
+ */
118
+ export function pgDumpArgs(): string[] {
119
+ return ['-Fc', '--no-owner', '--no-privileges', '--no-comments'];
120
+ }
121
+
122
+ /**
123
+ * Args for `pg_restore` into `dbName` — drop-then-restore (--clean --if-exists), ownership-neutral.
124
+ * With no archive-file argument pg_restore reads the dump from stdin.
125
+ */
126
+ export function pgRestoreArgs(dbName: string): string[] {
127
+ return ['--clean', '--if-exists', '--no-owner', '--no-privileges', '--no-comments', '-d', dbName];
128
+ }
129
+
130
+ /**
131
+ * Spawn `<bin> --version` and return its trimmed stdout (e.g. "pg_dump (PostgreSQL) 16.9").
132
+ * Rejects when the binary is missing — which, in the ops Lambda, means the pg_dump layer is not
133
+ * attached. `bin` defaults to `pg_dump`, resolved on PATH (the layer lands at /opt/bin, on PATH).
134
+ */
135
+ export async function pgClientVersion(bin = 'pg_dump'): Promise<string> {
136
+ const { spawn } = await import('node:child_process');
137
+ return await new Promise<string>((resolve, reject) => {
138
+ let out = '';
139
+ let err = '';
140
+ const child = spawn(bin, ['--version']);
141
+ child.stdout.on('data', (d) => { out += d.toString(); });
142
+ child.stderr.on('data', (d) => { err += d.toString(); });
143
+ child.on('error', (e) => reject(e));
144
+ child.on('close', (code) => {
145
+ if (code === 0) resolve(out.trim());
146
+ else reject(new Error(`${bin} --version exited ${code}: ${err.trim()}`));
147
+ });
148
+ });
149
+ }
package/src/db-doctor.ts CHANGED
@@ -30,6 +30,18 @@ export interface ForceRlsRow {
30
30
  forced: boolean;
31
31
  }
32
32
 
33
+ /** A base table an app role can reach (table- or column-level grant), with its RLS posture. */
34
+ export interface AppGrantedRow {
35
+ schema: string;
36
+ table: string;
37
+ /** RLS enabled on the table. */
38
+ enabled: boolean;
39
+ /** FORCE ROW LEVEL SECURITY. */
40
+ forced: boolean;
41
+ /** The app roles holding a privilege on it (sorted). */
42
+ grantedTo: string[];
43
+ }
44
+
33
45
  export interface DoctorProbe {
34
46
  /** The api credential's connection facts (createDb / DATABASE_URL). */
35
47
  api: ConnInfo;
@@ -37,6 +49,12 @@ export interface DoctorProbe {
37
49
  admin: ConnInfo;
38
50
  /** Tables with RLS enabled, and whether it's FORCEd. Gathered as admin. */
39
51
  forceRls: ForceRlsRow[];
52
+ /**
53
+ * Every base table reachable by an app role (anon/authenticated/admin), with its RLS
54
+ * posture. Drives the "we require RLS" check — a table granted to a request role with RLS
55
+ * OFF is the PostgREST fail-open shape. Absent on an older probe (the check is then skipped).
56
+ */
57
+ appGrantedRls?: AppGrantedRow[];
40
58
  /**
41
59
  * Result of a bare `SELECT` on an RLS table as the api connection, WITHOUT setting a
42
60
  * role. A least-privilege role (NOINHERIT, no grants of its own) must fail closed here;
@@ -44,6 +62,20 @@ export interface DoctorProbe {
44
62
  * `USING(true)` role like the master). Absent when there is no RLS table to probe.
45
63
  */
46
64
  apiAmbientRead?: { table: string; accessible: boolean };
65
+ /**
66
+ * Every SECURITY DEFINER function (schema, name, whether search_path is pinned). The
67
+ * `functions` teeth read this: for each schema that owns a `writtenBy: 'functions'` table
68
+ * (passed as `functionSchemas`), the package's SECDEF functions must exist and each must
69
+ * pin its search_path. Absent on an older probe (the check is then skipped).
70
+ */
71
+ secdefFunctions?: SecdefFn[];
72
+ }
73
+
74
+ /** A SECURITY DEFINER function and whether its `search_path` is pinned. */
75
+ export interface SecdefFn {
76
+ schema: string;
77
+ name: string;
78
+ hasSearchPath: boolean;
47
79
  }
48
80
 
49
81
  export type CheckStatus = 'pass' | 'fail' | 'warn';
@@ -88,20 +120,107 @@ WHERE c.relkind = 'r'
88
120
  ORDER BY n.nspname, c.relname
89
121
  `.trim();
90
122
 
123
+ /**
124
+ * Every SECURITY DEFINER function with whether its `search_path` is pinned. A SECDEF
125
+ * function runs as its owner (above RLS), so an unpinned `search_path` is a hijack vector:
126
+ * a caller can shadow an unqualified name the body calls and run code as the owner. This is
127
+ * the exact bug class that bit dev (migration 0022 went unapplied via a journal gap, leaving
128
+ * the auth SECDEF functions mutable) — the `functions` teeth read this to catch it on the
129
+ * deployed database, not just in the migration SQL (which `security:audit` already covers).
130
+ */
131
+ export const SECDEF_FUNCTIONS_SQL = `
132
+ SELECT
133
+ n.nspname AS schema,
134
+ p.proname AS name,
135
+ EXISTS (
136
+ SELECT 1 FROM unnest(COALESCE(p.proconfig, '{}'::text[]) ) cfg WHERE cfg LIKE 'search_path=%'
137
+ ) AS has_search_path
138
+ FROM pg_proc p
139
+ JOIN pg_namespace n ON n.oid = p.pronamespace
140
+ WHERE p.prosecdef
141
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
142
+ AND n.nspname NOT LIKE 'pg_%'
143
+ ORDER BY n.nspname, p.proname
144
+ `.trim();
145
+
91
146
  /** Coerce a Postgres boolean (true | 't' | 'true' | 1) to a JS boolean. */
92
147
  export function pgBool(v: unknown): boolean {
93
148
  return v === true || v === 't' || v === 'true' || v === 1 || v === '1';
94
149
  }
95
150
 
151
+ /** Coerce a Postgres text[] (a JS array, or the `{a,b}` text form some drivers return) to string[]. */
152
+ function pgTextArray(v: unknown): string[] {
153
+ if (Array.isArray(v)) return v.map(String);
154
+ if (typeof v === 'string') {
155
+ return v.replace(/^\{|\}$/g, '').split(',').map((s) => s.replace(/^"|"$/g, '').trim()).filter(Boolean);
156
+ }
157
+ return [];
158
+ }
159
+
160
+ const ROLE_IDENT = /^[a-z_][a-z0-9_]*$/;
161
+
162
+ /**
163
+ * Every base table an app role can reach — a table-level grant (`relacl`) OR a column-level
164
+ * grant (`attacl`, the "revoke the table, grant safe columns" shape) — with its RLS flags.
165
+ * The "we require RLS" check reads these: a table granted to a request role with RLS disabled
166
+ * exposes every row to that role, the PostgREST fail-open shape the FORCE check can't see
167
+ * (it only inspects tables that already have RLS on). App-role names are validated idents
168
+ * (they're interpolated); they default to the role-chain set. Extension tables are excluded.
169
+ */
170
+ export function appGrantsRlsSql(appRoles: string[] = ['anon', 'authenticated', 'admin']): string {
171
+ for (const r of appRoles) {
172
+ if (!ROLE_IDENT.test(r)) throw new Error(`Invalid app role: ${JSON.stringify(r)}`);
173
+ }
174
+ const roleList = appRoles.map((r) => `'${r}'`).join(', ');
175
+ return `
176
+ WITH app_granted AS (
177
+ SELECT c.oid, r.rolname
178
+ FROM pg_class c
179
+ CROSS JOIN LATERAL aclexplode(c.relacl) a
180
+ JOIN pg_roles r ON r.oid = a.grantee
181
+ WHERE c.relkind = 'r' AND r.rolname IN (${roleList})
182
+ UNION
183
+ SELECT c.oid, r.rolname
184
+ FROM pg_class c
185
+ JOIN pg_attribute att ON att.attrelid = c.oid AND att.attnum > 0 AND NOT att.attisdropped
186
+ CROSS JOIN LATERAL aclexplode(att.attacl) a
187
+ JOIN pg_roles r ON r.oid = a.grantee
188
+ WHERE c.relkind = 'r' AND r.rolname IN (${roleList})
189
+ )
190
+ SELECT
191
+ n.nspname AS schema,
192
+ c.relname AS "table",
193
+ c.relrowsecurity AS enabled,
194
+ c.relforcerowsecurity AS forced,
195
+ array_agg(DISTINCT g.rolname ORDER BY g.rolname) AS granted_to
196
+ FROM app_granted g
197
+ JOIN pg_class c ON c.oid = g.oid
198
+ JOIN pg_namespace n ON n.oid = c.relnamespace
199
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
200
+ AND n.nspname NOT LIKE 'pg_%'
201
+ AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = c.oid AND d.deptype = 'e')
202
+ GROUP BY n.nspname, c.relname, c.relrowsecurity, c.relforcerowsecurity
203
+ ORDER BY n.nspname, c.relname;
204
+ `.trim();
205
+ }
206
+
96
207
  /** Options for the report builder. */
97
208
  export interface DoctorReportOptions {
98
209
  /**
99
- * App-owned tables that are intentionally ENABLE-not-FORCE: an operational table whose
100
- * owner (e.g. the worker, or a SECURITY DEFINER function) must read/write it, where FORCE
101
- * would block the owner. These are added to the framework default (`auth.users`) and not
102
- * flagged by the FORCE check. Example: `['ops.runs']` (the jobs queue the worker writes).
210
+ * Tables that are intentionally ENABLE-not-FORCE: a table whose owner (the worker, or a
211
+ * SECURITY DEFINER function) must read/write it, where FORCE would block the owner. These
212
+ * are not flagged by the FORCE check. Read from each model's `writtenBy` declaration via
213
+ * `deriveForceRlsCarveouts(models)` including `auth.users` and the six other `auth.*`
214
+ * tables (all `writtenBy: 'functions'`), `ops.runs`, `dist.blobs`, and the notification
215
+ * tables — so the list can't drift from the contract.
103
216
  */
104
217
  forceRlsCarveouts?: string[];
218
+ /**
219
+ * Schemas that own a `writtenBy: 'functions'` table (e.g. `['auth']`), via
220
+ * `deriveFunctionSchemas(models)`. The `functions` teeth assert each such schema has
221
+ * SECDEF functions and that every one pins its `search_path`. Omitted/empty skips the check.
222
+ */
223
+ functionSchemas?: string[];
105
224
  }
106
225
 
107
226
  /** Build the green/red report from a gathered probe. Pure. */
@@ -162,6 +281,33 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
162
281
  });
163
282
  }
164
283
 
284
+ // The "we require RLS" invariant — the line that separates everystack from PostgREST,
285
+ // which serves a granted table with no RLS. A table reachable by an app role
286
+ // (anon/authenticated/admin) with RLS DISABLED exposes every row to that role. The FORCE
287
+ // check below can't catch it (it only sees tables that already have RLS on), so this is the
288
+ // check that actually enforces "every app-reachable table is row-secured." A consumer who
289
+ // hand-writes a migration and grants a table without RLS is caught here, not in production.
290
+ if (probe.appGrantedRls) {
291
+ const exposed = probe.appGrantedRls.filter((t) => !t.enabled);
292
+ if (exposed.length > 0) {
293
+ checks.push({
294
+ name: 'App-reachable tables require RLS',
295
+ status: 'fail',
296
+ detail: `granted to an app role but RLS is NOT enabled — every row is exposed to that role: ${exposed.map((t) => `${t.schema}.${t.table} [${t.grantedTo.join('/')}]`).join(', ')}`,
297
+ remediation: exposed
298
+ .map((t) => `ALTER TABLE ${t.schema}.${t.table} ENABLE ROW LEVEL SECURITY; ALTER TABLE ${t.schema}.${t.table} FORCE ROW LEVEL SECURITY;`)
299
+ .join(' ')
300
+ + ' Then declare a policy — or model the table with the v3 Model so `db:generate` emits RLS + policies + grants together.',
301
+ });
302
+ } else {
303
+ checks.push({
304
+ name: 'App-reachable tables require RLS',
305
+ status: 'pass',
306
+ detail: `all ${probe.appGrantedRls.length} app-reachable table(s) have RLS enabled`,
307
+ });
308
+ }
309
+ }
310
+
165
311
  // FORCE coverage — RLS enabled but not FORCEd means the owner silently bypasses it.
166
312
  //
167
313
  // Carve-out: a credential table whose SECURITY DEFINER auth functions (verify the
@@ -171,9 +317,11 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
171
317
  // ENABLE-but-not-FORCE: the API/SSR roles are non-owners and are still gated by its
172
318
  // policies regardless of FORCE. `auth.users` (the @everystack/auth credential table) is
173
319
  // the framework's one such table.
174
- // auth.users is the framework's own credential carve-out (always); apps add operational
175
- // tables whose owner must write them (e.g. ops.runs, the worker-written jobs queue).
176
- const FORCE_RLS_CARVEOUTS = new Set(['auth.users', ...(options.forceRlsCarveouts ?? [])]);
320
+ // The carveouts come entirely from the models' `writtenBy` declarations
321
+ // (deriveForceRlsCarveouts): auth.users and the six other auth.* tables (functions),
322
+ // ops.runs and dist.blobs (worker), the notification tables. No hand-list, no framework
323
+ // default that could drift from — or fall short of — the modeled set.
324
+ const FORCE_RLS_CARVEOUTS = new Set(options.forceRlsCarveouts ?? []);
177
325
  const allUnforced = probe.forceRls.filter((r) => r.enabled && !r.forced);
178
326
  const carved = allUnforced.filter((r) => FORCE_RLS_CARVEOUTS.has(`${r.schema}.${r.table}`));
179
327
  const unforced = allUnforced.filter((r) => !FORCE_RLS_CARVEOUTS.has(`${r.schema}.${r.table}`));
@@ -201,6 +349,42 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
201
349
  });
202
350
  }
203
351
 
352
+ // The `functions` teeth — the other half of `writtenBy: 'functions'`. A table written only
353
+ // by SECURITY DEFINER functions is ENABLE-not-FORCE (carved out above) precisely BECAUSE the
354
+ // package's functions run as the owner. That bargain is only safe if those functions exist
355
+ // and each pins its search_path; an unpinned one is an owner-privilege hijack vector. This
356
+ // asserts it on the deployed database — the gate that was missing when 0022 silently went
357
+ // unapplied and left the auth SECDEF functions mutable.
358
+ if (probe.secdefFunctions && (options.functionSchemas?.length ?? 0) > 0) {
359
+ const schemas = new Set(options.functionSchemas);
360
+ const relevant = probe.secdefFunctions.filter((f) => schemas.has(f.schema));
361
+ const unpinned = relevant.filter((f) => !f.hasSearchPath);
362
+ const present = new Set(relevant.map((f) => f.schema));
363
+ const missing = [...schemas].filter((s) => !present.has(s)).sort();
364
+ if (unpinned.length > 0) {
365
+ checks.push({
366
+ name: 'SECURITY DEFINER functions pin search_path',
367
+ status: 'fail',
368
+ detail: `SECDEF function(s) in a functions-written schema have a mutable search_path (owner-privilege hijack vector): ${unpinned.map((f) => `${f.schema}.${f.name}`).join(', ')}`,
369
+ remediation: unpinned.map((f) => `ALTER FUNCTION ${f.schema}.${f.name} SET search_path = ${f.schema}, public, pg_temp;`).join(' ')
370
+ + ' (Then redeploy so the migration that defines them carries the pin.)',
371
+ });
372
+ } else if (missing.length > 0) {
373
+ checks.push({
374
+ name: 'SECURITY DEFINER functions exist for functions-written tables',
375
+ status: 'fail',
376
+ detail: `no SECDEF functions found in schema(s) that own a writtenBy:'functions' table: ${missing.join(', ')} — the package SQL that writes those tables was not applied`,
377
+ remediation: `Apply the package's functions migration (e.g. everystack db:migrate). A functions-written table with no SECDEF functions cannot be written at all.`,
378
+ });
379
+ } else {
380
+ checks.push({
381
+ name: 'SECURITY DEFINER functions pin search_path',
382
+ status: 'pass',
383
+ detail: `all ${relevant.length} SECDEF function(s) in ${[...present].sort().join(', ')} pin their search_path`,
384
+ });
385
+ }
386
+ }
387
+
204
388
  return {
205
389
  api,
206
390
  admin,
@@ -209,6 +393,15 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
209
393
  };
210
394
  }
211
395
 
396
+ /** Map a SECDEF_FUNCTIONS_SQL row to SecdefFn. */
397
+ export function rowToSecdefFn(row: any): SecdefFn {
398
+ return {
399
+ schema: String(row.schema),
400
+ name: String(row.name),
401
+ hasSearchPath: pgBool(row.has_search_path),
402
+ };
403
+ }
404
+
212
405
  /** Map a CONN_SQL row to ConnInfo (tolerant of pg driver boolean encodings). */
213
406
  export function rowToConnInfo(row: any): ConnInfo {
214
407
  return {
@@ -227,3 +420,14 @@ export function rowToForceRls(row: any): ForceRlsRow {
227
420
  forced: pgBool(row.forced),
228
421
  };
229
422
  }
423
+
424
+ /** Map an appGrantsRlsSql row to AppGrantedRow (tolerant of array vs text[] driver encodings). */
425
+ export function rowToAppGranted(row: any): AppGrantedRow {
426
+ return {
427
+ schema: String(row.schema),
428
+ table: String(row.table),
429
+ enabled: pgBool(row.enabled),
430
+ forced: pgBool(row.forced),
431
+ grantedTo: pgTextArray(row.granted_to),
432
+ };
433
+ }
package/src/plugin.ts CHANGED
@@ -463,11 +463,18 @@ export interface DbPluginOptions {
463
463
  /** pgSettings callback for console auth context — applied via SET LOCAL when user is present */
464
464
  pgSettings?: (user: Record<string, unknown> | null) => Record<string, string>;
465
465
  /**
466
- * App-owned tables that are intentionally ENABLE-not-FORCE row-level security, so db:doctor
467
- * does not flag them. The framework always carves out `auth.users`; add operational tables
468
- * whose owner must write them, e.g. `['ops.runs']` (the worker-written jobs queue).
466
+ * Tables that are intentionally ENABLE-not-FORCE row-level security, so db:doctor does not
467
+ * flag them. Pass `deriveForceRlsCarveouts(models)` it reads each model's `writtenBy`
468
+ * declaration (auth.users + the six other auth.* tables, ops.runs, dist.blobs, the
469
+ * notification tables), so the list can't drift from the contract.
469
470
  */
470
471
  forceRlsCarveouts?: string[];
472
+ /**
473
+ * Schemas that own a `writtenBy: 'functions'` table (e.g. `['auth']`). Pass
474
+ * `deriveFunctionSchemas(models)`. db:doctor's `functions` teeth assert each such schema has
475
+ * SECDEF functions and that every one pins its search_path (the unpinned-search_path gate).
476
+ */
477
+ functionSchemas?: string[];
471
478
  /** Console auth helper config — column names for login/asUser (defaults to everystack conventions) */
472
479
  consoleAuth?: {
473
480
  usersTable?: string;
@@ -475,6 +482,14 @@ export interface DbPluginOptions {
475
482
  passwordColumn?: string;
476
483
  claimColumns?: string[];
477
484
  };
485
+ /**
486
+ * S3 bucket (name) for logical backups (db:backup/db:backups/db:restore). Pass the dedicated
487
+ * private backups bucket, e.g. `Resource.Backups.name`. When unset those actions return a
488
+ * "backup storage not configured" error — the rest of the plugin is unaffected.
489
+ */
490
+ backupBucket?: string;
491
+ /** AWS region for the backups bucket (defaults to the Lambda's AWS_REGION). */
492
+ backupRegion?: string;
478
493
  }
479
494
 
480
495
  /**
@@ -582,6 +597,36 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
582
597
  }
583
598
  };
584
599
 
600
+ // --- db:authz:probe — the authorization red-team's execution path ---
601
+ // Runs the CLI-built probe SQL (SET ROLE + attempt per role/table/command, each in
602
+ // its own savepoint) inside ONE transaction that ALWAYS rolls back, then returns the
603
+ // outcome rows. Unlike db:query this permits writes — they are required to test
604
+ // INSERT/UPDATE/DELETE privileges — but the forced rollback guarantees nothing
605
+ // persists. IAM-gated like every action. The CLI owns the SQL (authz-redteam.ts);
606
+ // this is the thin, transactional, self-reverting runner it needs.
607
+ actions['db:authz:probe'] = async (payload, _ctx) => {
608
+ const { setup, read } = (payload ?? {}) as { setup?: string; read?: string };
609
+ if (!setup || !read) {
610
+ return { error: 'db:authz:probe requires { setup, read } SQL strings' };
611
+ }
612
+ const { sql } = await import('drizzle-orm');
613
+ const PROBE_ROLLBACK = Symbol('authz_probe_rollback');
614
+ let rows: any[] = [];
615
+ try {
616
+ await opsDb.transaction(async (tx: any) => {
617
+ await tx.execute(sql.raw(setup));
618
+ const result: any = await tx.execute(sql.raw(read));
619
+ rows = Array.isArray(result) ? result : result?.rows ?? [];
620
+ throw PROBE_ROLLBACK; // discard every probe write
621
+ });
622
+ } catch (err: unknown) {
623
+ if (err !== PROBE_ROLLBACK) {
624
+ return { error: (err as any)?.message || String(err) };
625
+ }
626
+ }
627
+ return { rows };
628
+ };
629
+
585
630
  // --- db:doctor — "is my database actually secure?" ---
586
631
  // Probes the api connection (createDb / DATABASE_URL) and the operator connection
587
632
  // (opsDb / ADMIN_DATABASE_URL) from inside the VPC and reports whether the api path
@@ -590,7 +635,8 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
590
635
  actions['db:doctor'] = async (_payload, ctx) => {
591
636
  const { sql } = await import('drizzle-orm');
592
637
  const {
593
- buildDoctorReport, rowToConnInfo, rowToForceRls, CONN_SQL, FORCE_RLS_SQL,
638
+ buildDoctorReport, rowToConnInfo, rowToForceRls, rowToAppGranted, rowToSecdefFn,
639
+ CONN_SQL, FORCE_RLS_SQL, appGrantsRlsSql, SECDEF_FUNCTIONS_SQL,
594
640
  } = await import('./db-doctor');
595
641
  const rowsOf = (result: any): any[] => (Array.isArray(result) ? result : result?.rows || []);
596
642
 
@@ -608,6 +654,12 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
608
654
 
609
655
  const admin = rowToConnInfo(rowsOf(await opsDb.execute(sql.raw(CONN_SQL)))[0]);
610
656
  const forceRls = rowsOf(await opsDb.execute(sql.raw(FORCE_RLS_SQL))).map(rowToForceRls);
657
+ // The "we require RLS" set — every app-reachable table + its RLS posture, so a table
658
+ // granted to a request role with RLS off (the PostgREST fail-open shape) is caught.
659
+ const appGrantedRls = rowsOf(await opsDb.execute(sql.raw(appGrantsRlsSql()))).map(rowToAppGranted);
660
+ // SECDEF functions (schema, name, search_path pinned?) — the `functions` teeth read these
661
+ // to assert every SECDEF function in a functions-written schema pins its search_path.
662
+ const secdefFunctions = rowsOf(await opsDb.execute(sql.raw(SECDEF_FUNCTIONS_SQL))).map(rowToSecdefFn);
611
663
 
612
664
  // Ambient-access probe: can the api connection read an RLS table WITHOUT SET ROLE?
613
665
  let apiAmbientRead: { table: string; accessible: boolean } | undefined;
@@ -623,8 +675,8 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
623
675
  }
624
676
 
625
677
  return buildDoctorReport(
626
- { api, admin, forceRls, apiAmbientRead },
627
- { forceRlsCarveouts: options.forceRlsCarveouts },
678
+ { api, admin, forceRls, appGrantedRls, apiAmbientRead, secdefFunctions },
679
+ { forceRlsCarveouts: options.forceRlsCarveouts, functionSchemas: options.functionSchemas },
628
680
  );
629
681
  };
630
682
 
@@ -670,6 +722,97 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
670
722
  };
671
723
  };
672
724
 
725
+ // --- db:backup:probe — is the pg_dump layer attached, and does it match the server? ---
726
+ // The version gate for logical backups: pg_dump must be >= the server's MAJOR (no bypass flag
727
+ // exists). Returns the layer's pg_dump version + the live server_version_num so a deploy can
728
+ // confirm the layer is present AND version-compatible before db:backup is trusted. See backup.ts.
729
+ actions['db:backup:probe'] = async () => {
730
+ const { pgClientVersion, parsePgDumpMajor, serverMajorFromNum, isDumpCompatible } = await import('./backup');
731
+ const { sql } = await import('drizzle-orm');
732
+
733
+ let pgDump: string;
734
+ try {
735
+ pgDump = await pgClientVersion('pg_dump');
736
+ } catch (err: any) {
737
+ return { error: `pg_dump not available — is the pg_dump layer attached to this function? (${err?.message || String(err)})` };
738
+ }
739
+ const pgDumpMajor = parsePgDumpMajor(pgDump);
740
+
741
+ const rows: any = await opsDb.execute(sql.raw('SHOW server_version_num'));
742
+ const row = (Array.isArray(rows) ? rows : rows?.rows ?? [])[0];
743
+ const serverVersionNum = Number(row?.server_version_num);
744
+ const serverMajor = Number.isFinite(serverVersionNum) ? serverMajorFromNum(serverVersionNum) : null;
745
+
746
+ return {
747
+ pgDump,
748
+ pgDumpMajor,
749
+ serverVersionNum: Number.isFinite(serverVersionNum) ? serverVersionNum : null,
750
+ serverMajor,
751
+ compatible: pgDumpMajor != null && Number.isFinite(serverVersionNum)
752
+ ? isDumpCompatible(pgDumpMajor, serverVersionNum)
753
+ : false,
754
+ };
755
+ };
756
+
757
+ // --- db:backup / db:backups / db:restore — logical pg_dump backups streamed to S3 ---
758
+ // The operator (privileged) connection drives pg_dump/pg_restore; the dump streams to the
759
+ // dedicated private backups bucket. Restore is destructive and requires an explicit confirm in
760
+ // the payload (the CLI sets it after the user confirms). See backup-run.ts.
761
+ {
762
+ const resolveBackupDeps = async () => {
763
+ const bucket = options.backupBucket;
764
+ if (!bucket) {
765
+ return { error: 'backup storage not configured — pass backupBucket to dbPlugin (e.g. Resource.Backups.name)' as string };
766
+ }
767
+ const { getAdminDatabaseUrl, getMasterDatabaseUrl } = await import('./db');
768
+ const adminUrl = getAdminDatabaseUrl() ?? getMasterDatabaseUrl();
769
+ if (!adminUrl) {
770
+ return { error: 'no operator connection available (ADMIN_DATABASE_URL / Resource.Database)' as string };
771
+ }
772
+ return { deps: { bucket, region: options.backupRegion ?? process.env.AWS_REGION, adminUrl } };
773
+ };
774
+
775
+ actions['db:backup'] = async (payload, ctx) => {
776
+ const r = await resolveBackupDeps();
777
+ if ('error' in r) return { error: r.error };
778
+ const stage = ((payload ?? {}) as { stage?: string }).stage ?? ctx.environment ?? 'default';
779
+ const { runBackup } = await import('./backup-run');
780
+ try {
781
+ return await runBackup(r.deps, stage);
782
+ } catch (err: any) {
783
+ return { error: err?.message || String(err) };
784
+ }
785
+ };
786
+
787
+ actions['db:backups'] = async (payload, ctx) => {
788
+ const r = await resolveBackupDeps();
789
+ if ('error' in r) return { error: r.error };
790
+ const stage = ((payload ?? {}) as { stage?: string }).stage ?? ctx.environment ?? 'default';
791
+ const { listBackups } = await import('./backup-run');
792
+ try {
793
+ return { backups: await listBackups(r.deps, stage) };
794
+ } catch (err: any) {
795
+ return { error: err?.message || String(err) };
796
+ }
797
+ };
798
+
799
+ actions['db:restore'] = async (payload, _ctx) => {
800
+ const { id, confirm } = (payload ?? {}) as { id?: string; confirm?: boolean };
801
+ if (!id) return { error: 'db:restore requires { id }' };
802
+ if (confirm !== true) {
803
+ return { error: 'db:restore is destructive (drops + replaces the target DB). Pass confirm:true to proceed.' };
804
+ }
805
+ const r = await resolveBackupDeps();
806
+ if ('error' in r) return { error: r.error };
807
+ const { runRestore } = await import('./backup-run');
808
+ try {
809
+ return await runRestore(r.deps, id);
810
+ } catch (err: any) {
811
+ return { error: err?.message || String(err) };
812
+ }
813
+ };
814
+ }
815
+
673
816
  // --- console ---
674
817
  if (options.allowConsole !== false) {
675
818
  actions['console:meta'] = async (_payload, ctx) => {