@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 +19 -0
- package/README.md +1 -1
- package/package.json +9 -4
- package/src/backup-run.ts +71 -6
- package/src/backup.ts +25 -0
- package/src/db-doctor.ts +92 -7
- package/src/db.ts +12 -1
- package/src/index.ts +2 -0
- package/src/mutation-connection.ts +33 -0
- package/src/plugin.ts +122 -70
- package/src/restore-witness.ts +353 -0
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,
|
|
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.
|
|
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.
|
|
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/
|
|
191
|
-
"@everystack/
|
|
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
|
-
|
|
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[] =
|
|
271
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
965
|
-
//
|
|
966
|
-
//
|
|
967
|
-
const
|
|
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(
|
|
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
|
-
//
|
|
1320
|
-
//
|
|
1321
|
-
//
|
|
1322
|
-
//
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
},
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|