@everystack/server 0.2.26 → 0.3.0
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 +41 -0
- package/package.json +8 -2
- package/src/backup-run.ts +161 -0
- package/src/backup.ts +149 -0
- package/src/db-doctor.ts +112 -0
- package/src/plugin.ts +134 -2
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.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|
|
@@ -134,7 +140,7 @@
|
|
|
134
140
|
"ts-jest": "29.4.9",
|
|
135
141
|
"typescript": "5.9.3",
|
|
136
142
|
"@everystack/auth": "0.2.6",
|
|
137
|
-
"@everystack/cli": "0.
|
|
143
|
+
"@everystack/cli": "0.3.0"
|
|
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;
|
|
@@ -93,6 +111,62 @@ export function pgBool(v: unknown): boolean {
|
|
|
93
111
|
return v === true || v === 't' || v === 'true' || v === 1 || v === '1';
|
|
94
112
|
}
|
|
95
113
|
|
|
114
|
+
/** Coerce a Postgres text[] (a JS array, or the `{a,b}` text form some drivers return) to string[]. */
|
|
115
|
+
function pgTextArray(v: unknown): string[] {
|
|
116
|
+
if (Array.isArray(v)) return v.map(String);
|
|
117
|
+
if (typeof v === 'string') {
|
|
118
|
+
return v.replace(/^\{|\}$/g, '').split(',').map((s) => s.replace(/^"|"$/g, '').trim()).filter(Boolean);
|
|
119
|
+
}
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const ROLE_IDENT = /^[a-z_][a-z0-9_]*$/;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Every base table an app role can reach — a table-level grant (`relacl`) OR a column-level
|
|
127
|
+
* grant (`attacl`, the "revoke the table, grant safe columns" shape) — with its RLS flags.
|
|
128
|
+
* The "we require RLS" check reads these: a table granted to a request role with RLS disabled
|
|
129
|
+
* exposes every row to that role, the PostgREST fail-open shape the FORCE check can't see
|
|
130
|
+
* (it only inspects tables that already have RLS on). App-role names are validated idents
|
|
131
|
+
* (they're interpolated); they default to the role-chain set. Extension tables are excluded.
|
|
132
|
+
*/
|
|
133
|
+
export function appGrantsRlsSql(appRoles: string[] = ['anon', 'authenticated', 'admin']): string {
|
|
134
|
+
for (const r of appRoles) {
|
|
135
|
+
if (!ROLE_IDENT.test(r)) throw new Error(`Invalid app role: ${JSON.stringify(r)}`);
|
|
136
|
+
}
|
|
137
|
+
const roleList = appRoles.map((r) => `'${r}'`).join(', ');
|
|
138
|
+
return `
|
|
139
|
+
WITH app_granted AS (
|
|
140
|
+
SELECT c.oid, r.rolname
|
|
141
|
+
FROM pg_class c
|
|
142
|
+
CROSS JOIN LATERAL aclexplode(c.relacl) a
|
|
143
|
+
JOIN pg_roles r ON r.oid = a.grantee
|
|
144
|
+
WHERE c.relkind = 'r' AND r.rolname IN (${roleList})
|
|
145
|
+
UNION
|
|
146
|
+
SELECT c.oid, r.rolname
|
|
147
|
+
FROM pg_class c
|
|
148
|
+
JOIN pg_attribute att ON att.attrelid = c.oid AND att.attnum > 0 AND NOT att.attisdropped
|
|
149
|
+
CROSS JOIN LATERAL aclexplode(att.attacl) a
|
|
150
|
+
JOIN pg_roles r ON r.oid = a.grantee
|
|
151
|
+
WHERE c.relkind = 'r' AND r.rolname IN (${roleList})
|
|
152
|
+
)
|
|
153
|
+
SELECT
|
|
154
|
+
n.nspname AS schema,
|
|
155
|
+
c.relname AS "table",
|
|
156
|
+
c.relrowsecurity AS enabled,
|
|
157
|
+
c.relforcerowsecurity AS forced,
|
|
158
|
+
array_agg(DISTINCT g.rolname ORDER BY g.rolname) AS granted_to
|
|
159
|
+
FROM app_granted g
|
|
160
|
+
JOIN pg_class c ON c.oid = g.oid
|
|
161
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
162
|
+
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
163
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
164
|
+
AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = c.oid AND d.deptype = 'e')
|
|
165
|
+
GROUP BY n.nspname, c.relname, c.relrowsecurity, c.relforcerowsecurity
|
|
166
|
+
ORDER BY n.nspname, c.relname;
|
|
167
|
+
`.trim();
|
|
168
|
+
}
|
|
169
|
+
|
|
96
170
|
/** Options for the report builder. */
|
|
97
171
|
export interface DoctorReportOptions {
|
|
98
172
|
/**
|
|
@@ -162,6 +236,33 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
|
|
|
162
236
|
});
|
|
163
237
|
}
|
|
164
238
|
|
|
239
|
+
// The "we require RLS" invariant — the line that separates everystack from PostgREST,
|
|
240
|
+
// which serves a granted table with no RLS. A table reachable by an app role
|
|
241
|
+
// (anon/authenticated/admin) with RLS DISABLED exposes every row to that role. The FORCE
|
|
242
|
+
// check below can't catch it (it only sees tables that already have RLS on), so this is the
|
|
243
|
+
// check that actually enforces "every app-reachable table is row-secured." A consumer who
|
|
244
|
+
// hand-writes a migration and grants a table without RLS is caught here, not in production.
|
|
245
|
+
if (probe.appGrantedRls) {
|
|
246
|
+
const exposed = probe.appGrantedRls.filter((t) => !t.enabled);
|
|
247
|
+
if (exposed.length > 0) {
|
|
248
|
+
checks.push({
|
|
249
|
+
name: 'App-reachable tables require RLS',
|
|
250
|
+
status: 'fail',
|
|
251
|
+
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(', ')}`,
|
|
252
|
+
remediation: exposed
|
|
253
|
+
.map((t) => `ALTER TABLE ${t.schema}.${t.table} ENABLE ROW LEVEL SECURITY; ALTER TABLE ${t.schema}.${t.table} FORCE ROW LEVEL SECURITY;`)
|
|
254
|
+
.join(' ')
|
|
255
|
+
+ ' Then declare a policy — or model the table with the v3 Model so `db:generate` emits RLS + policies + grants together.',
|
|
256
|
+
});
|
|
257
|
+
} else {
|
|
258
|
+
checks.push({
|
|
259
|
+
name: 'App-reachable tables require RLS',
|
|
260
|
+
status: 'pass',
|
|
261
|
+
detail: `all ${probe.appGrantedRls.length} app-reachable table(s) have RLS enabled`,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
165
266
|
// FORCE coverage — RLS enabled but not FORCEd means the owner silently bypasses it.
|
|
166
267
|
//
|
|
167
268
|
// Carve-out: a credential table whose SECURITY DEFINER auth functions (verify the
|
|
@@ -227,3 +328,14 @@ export function rowToForceRls(row: any): ForceRlsRow {
|
|
|
227
328
|
forced: pgBool(row.forced),
|
|
228
329
|
};
|
|
229
330
|
}
|
|
331
|
+
|
|
332
|
+
/** Map an appGrantsRlsSql row to AppGrantedRow (tolerant of array vs text[] driver encodings). */
|
|
333
|
+
export function rowToAppGranted(row: any): AppGrantedRow {
|
|
334
|
+
return {
|
|
335
|
+
schema: String(row.schema),
|
|
336
|
+
table: String(row.table),
|
|
337
|
+
enabled: pgBool(row.enabled),
|
|
338
|
+
forced: pgBool(row.forced),
|
|
339
|
+
grantedTo: pgTextArray(row.granted_to),
|
|
340
|
+
};
|
|
341
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -475,6 +475,14 @@ export interface DbPluginOptions {
|
|
|
475
475
|
passwordColumn?: string;
|
|
476
476
|
claimColumns?: string[];
|
|
477
477
|
};
|
|
478
|
+
/**
|
|
479
|
+
* S3 bucket (name) for logical backups (db:backup/db:backups/db:restore). Pass the dedicated
|
|
480
|
+
* private backups bucket, e.g. `Resource.Backups.name`. When unset those actions return a
|
|
481
|
+
* "backup storage not configured" error — the rest of the plugin is unaffected.
|
|
482
|
+
*/
|
|
483
|
+
backupBucket?: string;
|
|
484
|
+
/** AWS region for the backups bucket (defaults to the Lambda's AWS_REGION). */
|
|
485
|
+
backupRegion?: string;
|
|
478
486
|
}
|
|
479
487
|
|
|
480
488
|
/**
|
|
@@ -582,6 +590,36 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
582
590
|
}
|
|
583
591
|
};
|
|
584
592
|
|
|
593
|
+
// --- db:authz:probe — the authorization red-team's execution path ---
|
|
594
|
+
// Runs the CLI-built probe SQL (SET ROLE + attempt per role/table/command, each in
|
|
595
|
+
// its own savepoint) inside ONE transaction that ALWAYS rolls back, then returns the
|
|
596
|
+
// outcome rows. Unlike db:query this permits writes — they are required to test
|
|
597
|
+
// INSERT/UPDATE/DELETE privileges — but the forced rollback guarantees nothing
|
|
598
|
+
// persists. IAM-gated like every action. The CLI owns the SQL (authz-redteam.ts);
|
|
599
|
+
// this is the thin, transactional, self-reverting runner it needs.
|
|
600
|
+
actions['db:authz:probe'] = async (payload, _ctx) => {
|
|
601
|
+
const { setup, read } = (payload ?? {}) as { setup?: string; read?: string };
|
|
602
|
+
if (!setup || !read) {
|
|
603
|
+
return { error: 'db:authz:probe requires { setup, read } SQL strings' };
|
|
604
|
+
}
|
|
605
|
+
const { sql } = await import('drizzle-orm');
|
|
606
|
+
const PROBE_ROLLBACK = Symbol('authz_probe_rollback');
|
|
607
|
+
let rows: any[] = [];
|
|
608
|
+
try {
|
|
609
|
+
await opsDb.transaction(async (tx: any) => {
|
|
610
|
+
await tx.execute(sql.raw(setup));
|
|
611
|
+
const result: any = await tx.execute(sql.raw(read));
|
|
612
|
+
rows = Array.isArray(result) ? result : result?.rows ?? [];
|
|
613
|
+
throw PROBE_ROLLBACK; // discard every probe write
|
|
614
|
+
});
|
|
615
|
+
} catch (err: unknown) {
|
|
616
|
+
if (err !== PROBE_ROLLBACK) {
|
|
617
|
+
return { error: (err as any)?.message || String(err) };
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return { rows };
|
|
621
|
+
};
|
|
622
|
+
|
|
585
623
|
// --- db:doctor — "is my database actually secure?" ---
|
|
586
624
|
// Probes the api connection (createDb / DATABASE_URL) and the operator connection
|
|
587
625
|
// (opsDb / ADMIN_DATABASE_URL) from inside the VPC and reports whether the api path
|
|
@@ -590,7 +628,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
590
628
|
actions['db:doctor'] = async (_payload, ctx) => {
|
|
591
629
|
const { sql } = await import('drizzle-orm');
|
|
592
630
|
const {
|
|
593
|
-
buildDoctorReport, rowToConnInfo, rowToForceRls, CONN_SQL, FORCE_RLS_SQL,
|
|
631
|
+
buildDoctorReport, rowToConnInfo, rowToForceRls, rowToAppGranted, CONN_SQL, FORCE_RLS_SQL, appGrantsRlsSql,
|
|
594
632
|
} = await import('./db-doctor');
|
|
595
633
|
const rowsOf = (result: any): any[] => (Array.isArray(result) ? result : result?.rows || []);
|
|
596
634
|
|
|
@@ -608,6 +646,9 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
608
646
|
|
|
609
647
|
const admin = rowToConnInfo(rowsOf(await opsDb.execute(sql.raw(CONN_SQL)))[0]);
|
|
610
648
|
const forceRls = rowsOf(await opsDb.execute(sql.raw(FORCE_RLS_SQL))).map(rowToForceRls);
|
|
649
|
+
// The "we require RLS" set — every app-reachable table + its RLS posture, so a table
|
|
650
|
+
// granted to a request role with RLS off (the PostgREST fail-open shape) is caught.
|
|
651
|
+
const appGrantedRls = rowsOf(await opsDb.execute(sql.raw(appGrantsRlsSql()))).map(rowToAppGranted);
|
|
611
652
|
|
|
612
653
|
// Ambient-access probe: can the api connection read an RLS table WITHOUT SET ROLE?
|
|
613
654
|
let apiAmbientRead: { table: string; accessible: boolean } | undefined;
|
|
@@ -623,7 +664,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
623
664
|
}
|
|
624
665
|
|
|
625
666
|
return buildDoctorReport(
|
|
626
|
-
{ api, admin, forceRls, apiAmbientRead },
|
|
667
|
+
{ api, admin, forceRls, appGrantedRls, apiAmbientRead },
|
|
627
668
|
{ forceRlsCarveouts: options.forceRlsCarveouts },
|
|
628
669
|
);
|
|
629
670
|
};
|
|
@@ -670,6 +711,97 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
670
711
|
};
|
|
671
712
|
};
|
|
672
713
|
|
|
714
|
+
// --- db:backup:probe — is the pg_dump layer attached, and does it match the server? ---
|
|
715
|
+
// The version gate for logical backups: pg_dump must be >= the server's MAJOR (no bypass flag
|
|
716
|
+
// exists). Returns the layer's pg_dump version + the live server_version_num so a deploy can
|
|
717
|
+
// confirm the layer is present AND version-compatible before db:backup is trusted. See backup.ts.
|
|
718
|
+
actions['db:backup:probe'] = async () => {
|
|
719
|
+
const { pgClientVersion, parsePgDumpMajor, serverMajorFromNum, isDumpCompatible } = await import('./backup');
|
|
720
|
+
const { sql } = await import('drizzle-orm');
|
|
721
|
+
|
|
722
|
+
let pgDump: string;
|
|
723
|
+
try {
|
|
724
|
+
pgDump = await pgClientVersion('pg_dump');
|
|
725
|
+
} catch (err: any) {
|
|
726
|
+
return { error: `pg_dump not available — is the pg_dump layer attached to this function? (${err?.message || String(err)})` };
|
|
727
|
+
}
|
|
728
|
+
const pgDumpMajor = parsePgDumpMajor(pgDump);
|
|
729
|
+
|
|
730
|
+
const rows: any = await opsDb.execute(sql.raw('SHOW server_version_num'));
|
|
731
|
+
const row = (Array.isArray(rows) ? rows : rows?.rows ?? [])[0];
|
|
732
|
+
const serverVersionNum = Number(row?.server_version_num);
|
|
733
|
+
const serverMajor = Number.isFinite(serverVersionNum) ? serverMajorFromNum(serverVersionNum) : null;
|
|
734
|
+
|
|
735
|
+
return {
|
|
736
|
+
pgDump,
|
|
737
|
+
pgDumpMajor,
|
|
738
|
+
serverVersionNum: Number.isFinite(serverVersionNum) ? serverVersionNum : null,
|
|
739
|
+
serverMajor,
|
|
740
|
+
compatible: pgDumpMajor != null && Number.isFinite(serverVersionNum)
|
|
741
|
+
? isDumpCompatible(pgDumpMajor, serverVersionNum)
|
|
742
|
+
: false,
|
|
743
|
+
};
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
// --- db:backup / db:backups / db:restore — logical pg_dump backups streamed to S3 ---
|
|
747
|
+
// The operator (privileged) connection drives pg_dump/pg_restore; the dump streams to the
|
|
748
|
+
// dedicated private backups bucket. Restore is destructive and requires an explicit confirm in
|
|
749
|
+
// the payload (the CLI sets it after the user confirms). See backup-run.ts.
|
|
750
|
+
{
|
|
751
|
+
const resolveBackupDeps = async () => {
|
|
752
|
+
const bucket = options.backupBucket;
|
|
753
|
+
if (!bucket) {
|
|
754
|
+
return { error: 'backup storage not configured — pass backupBucket to dbPlugin (e.g. Resource.Backups.name)' as string };
|
|
755
|
+
}
|
|
756
|
+
const { getAdminDatabaseUrl, getMasterDatabaseUrl } = await import('./db');
|
|
757
|
+
const adminUrl = getAdminDatabaseUrl() ?? getMasterDatabaseUrl();
|
|
758
|
+
if (!adminUrl) {
|
|
759
|
+
return { error: 'no operator connection available (ADMIN_DATABASE_URL / Resource.Database)' as string };
|
|
760
|
+
}
|
|
761
|
+
return { deps: { bucket, region: options.backupRegion ?? process.env.AWS_REGION, adminUrl } };
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
actions['db:backup'] = async (payload, ctx) => {
|
|
765
|
+
const r = await resolveBackupDeps();
|
|
766
|
+
if ('error' in r) return { error: r.error };
|
|
767
|
+
const stage = ((payload ?? {}) as { stage?: string }).stage ?? ctx.environment ?? 'default';
|
|
768
|
+
const { runBackup } = await import('./backup-run');
|
|
769
|
+
try {
|
|
770
|
+
return await runBackup(r.deps, stage);
|
|
771
|
+
} catch (err: any) {
|
|
772
|
+
return { error: err?.message || String(err) };
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
actions['db:backups'] = async (payload, ctx) => {
|
|
777
|
+
const r = await resolveBackupDeps();
|
|
778
|
+
if ('error' in r) return { error: r.error };
|
|
779
|
+
const stage = ((payload ?? {}) as { stage?: string }).stage ?? ctx.environment ?? 'default';
|
|
780
|
+
const { listBackups } = await import('./backup-run');
|
|
781
|
+
try {
|
|
782
|
+
return { backups: await listBackups(r.deps, stage) };
|
|
783
|
+
} catch (err: any) {
|
|
784
|
+
return { error: err?.message || String(err) };
|
|
785
|
+
}
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
actions['db:restore'] = async (payload, _ctx) => {
|
|
789
|
+
const { id, confirm } = (payload ?? {}) as { id?: string; confirm?: boolean };
|
|
790
|
+
if (!id) return { error: 'db:restore requires { id }' };
|
|
791
|
+
if (confirm !== true) {
|
|
792
|
+
return { error: 'db:restore is destructive (drops + replaces the target DB). Pass confirm:true to proceed.' };
|
|
793
|
+
}
|
|
794
|
+
const r = await resolveBackupDeps();
|
|
795
|
+
if ('error' in r) return { error: r.error };
|
|
796
|
+
const { runRestore } = await import('./backup-run');
|
|
797
|
+
try {
|
|
798
|
+
return await runRestore(r.deps, id);
|
|
799
|
+
} catch (err: any) {
|
|
800
|
+
return { error: err?.message || String(err) };
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
|
|
673
805
|
// --- console ---
|
|
674
806
|
if (options.allowConsole !== false) {
|
|
675
807
|
actions['console:meta'] = async (_payload, ctx) => {
|