@creatornader/agent-bridge 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -4
- package/README.md +567 -55
- package/ROADMAP.md +114 -0
- package/SECURITY.md +90 -0
- package/SKILL.md +68 -2
- package/bin/agent-bridge +2 -2
- package/clients/claude-desktop.json +3 -1
- package/dist/archive.d.ts +163 -0
- package/dist/archive.js +50 -0
- package/dist/archive.js.map +1 -0
- package/dist/bridge-domain-CBX3IVqC.d.ts +153 -0
- package/dist/{bridge-service-CfqMcMAn.d.ts → bridge-service-hX1ZQp4W.d.ts} +15 -5
- package/dist/bridge-store-DPVSCwfs.d.ts +102 -0
- package/dist/bridge.d.ts +76 -17
- package/dist/bridge.js +52 -24
- package/dist/chunk-2DFNOLMO.js +618 -0
- package/dist/chunk-2DFNOLMO.js.map +1 -0
- package/dist/{chunk-KVR4JBRF.js → chunk-2EVLG3YA.js} +2 -2
- package/dist/{chunk-KVR4JBRF.js.map → chunk-2EVLG3YA.js.map} +1 -1
- package/dist/chunk-45BZGB77.js +709 -0
- package/dist/chunk-45BZGB77.js.map +1 -0
- package/dist/chunk-4VB44KJU.js +230 -0
- package/dist/chunk-4VB44KJU.js.map +1 -0
- package/dist/chunk-5GMB3A77.js +1013 -0
- package/dist/chunk-5GMB3A77.js.map +1 -0
- package/dist/chunk-A6W4QALF.js +1258 -0
- package/dist/chunk-A6W4QALF.js.map +1 -0
- package/dist/chunk-BZLMF5O5.js +328 -0
- package/dist/chunk-BZLMF5O5.js.map +1 -0
- package/dist/{chunk-AXL2I7FH.js → chunk-CEXBMOI5.js} +240 -79
- package/dist/chunk-CEXBMOI5.js.map +1 -0
- package/dist/chunk-GADH244D.js +527 -0
- package/dist/chunk-GADH244D.js.map +1 -0
- package/dist/chunk-MBPZXTJA.js +2428 -0
- package/dist/chunk-MBPZXTJA.js.map +1 -0
- package/dist/chunk-NCO7RLBM.js +146 -0
- package/dist/chunk-NCO7RLBM.js.map +1 -0
- package/dist/chunk-Q6PPMXJC.js +1050 -0
- package/dist/chunk-Q6PPMXJC.js.map +1 -0
- package/dist/{chunk-4KAOBC5X.js → chunk-SPSMZOCJ.js} +190 -165
- package/dist/chunk-SPSMZOCJ.js.map +1 -0
- package/dist/chunk-UAHMP6GC.js +54 -0
- package/dist/chunk-UAHMP6GC.js.map +1 -0
- package/dist/{chunk-YD6YJATN.js → chunk-VOQIDTCJ.js} +4 -2
- package/dist/chunk-VOQIDTCJ.js.map +1 -0
- package/dist/chunk-WAI2IYIQ.js +554 -0
- package/dist/chunk-WAI2IYIQ.js.map +1 -0
- package/dist/cli.d.ts +2 -1
- package/dist/cli.js +3555 -214
- package/dist/cli.js.map +1 -1
- package/dist/client-config.d.ts +1 -1
- package/dist/client-config.js +1 -1
- package/dist/client-runtime.d.ts +10 -6
- package/dist/client-runtime.js +9 -6
- package/dist/contracts/generate.d.ts +2 -0
- package/dist/contracts/generate.js +247 -0
- package/dist/contracts/generate.js.map +1 -0
- package/dist/dr.d.ts +324 -0
- package/dist/dr.js +124 -0
- package/dist/dr.js.map +1 -0
- package/dist/gateway-CggQ8vTC.d.ts +78 -0
- package/dist/gateway-main.js +190 -8
- package/dist/gateway-main.js.map +1 -1
- package/dist/gateway.d.ts +4 -3
- package/dist/gateway.js +4 -3
- package/dist/index.js +11 -8
- package/dist/index.js.map +1 -1
- package/dist/postgres-bridge-store-Bfd3hRId.d.ts +49 -0
- package/dist/server.d.ts +3 -2
- package/dist/server.js +13 -8
- package/dist/sqlite.d.ts +94 -12
- package/dist/sqlite.js +7 -4
- package/docs/architecture-v2.md +394 -0
- package/docs/decisions/0001-protocol-layers-and-acknowledgment-semantics.md +85 -0
- package/docs/decisions/0002-canonical-operation-contract-registry.md +46 -0
- package/docs/decisions/0003-host-adapters-and-consumer-instance-keys.md +88 -0
- package/docs/ecosystem.md +86 -0
- package/docs/postmortems/2026-07-08-wrapper-source-drift.md +82 -0
- package/docs/troubleshooting.md +89 -0
- package/llms.txt +107 -6
- package/openapi/agent-bridge-v2.openapi.json +9610 -0
- package/package.json +24 -4
- package/schemas/agent-bridge-v2.capabilities.json +1829 -0
- package/schemas/agent-bridge-v2.mcp.json +2074 -0
- package/schemas/agent-bridge-v2.schema.json +4560 -0
- package/sql/migrations/008_message_projects.sql +36 -0
- package/sql/migrations/009_mailbox_query_indexes.sql +28 -0
- package/sql/migrations/010_publisher_delivery_policy.sql +210 -0
- package/sql/migrations/011_credential_security.sql +875 -0
- package/sql/migrations/012_request_authority.sql +177 -0
- package/sql/migrations/013_row_isolation.sql +495 -0
- package/sql/migrations/014_owner_control_plane.sql +1641 -0
- package/sql/migrations/015_portable_archives.sql +1813 -0
- package/sql/migrations/016_native_dr_fence.sql +1005 -0
- package/bin/CLAUDE.md +0 -11
- package/dist/bridge-domain-BcnBeHOn.d.ts +0 -97
- package/dist/bridge-store-BuGjxTn4.d.ts +0 -61
- package/dist/chunk-4KAOBC5X.js.map +0 -1
- package/dist/chunk-6MAZ6YLZ.js +0 -146
- package/dist/chunk-6MAZ6YLZ.js.map +0 -1
- package/dist/chunk-AXL2I7FH.js.map +0 -1
- package/dist/chunk-BV6W4AJK.js +0 -211
- package/dist/chunk-BV6W4AJK.js.map +0 -1
- package/dist/chunk-CKSKUBDQ.js +0 -153
- package/dist/chunk-CKSKUBDQ.js.map +0 -1
- package/dist/chunk-JOUBDRYX.js +0 -272
- package/dist/chunk-JOUBDRYX.js.map +0 -1
- package/dist/chunk-JTC5YURG.js +0 -49
- package/dist/chunk-JTC5YURG.js.map +0 -1
- package/dist/chunk-O26W5QHS.js +0 -409
- package/dist/chunk-O26W5QHS.js.map +0 -1
- package/dist/chunk-WEULPMUY.js +0 -988
- package/dist/chunk-WEULPMUY.js.map +0 -1
- package/dist/chunk-YD6YJATN.js.map +0 -1
- package/dist/gateway-BULozv3K.d.ts +0 -54
|
@@ -0,0 +1,2428 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
EDGE_SQLITE_APPLICATION_ID,
|
|
4
|
+
LOCAL_SQLITE_APPLICATION_ID,
|
|
5
|
+
SQLITE_DATABASE_SCHEMA_VERSION,
|
|
6
|
+
SQLiteBridgeStore,
|
|
7
|
+
SQLiteDatabaseContractError,
|
|
8
|
+
assertLocalAuthorityDatabase,
|
|
9
|
+
identifySqliteDatabase,
|
|
10
|
+
isSupportedLocalSqliteSchemaContract,
|
|
11
|
+
sqliteSchemaContractHash,
|
|
12
|
+
verifySqliteHealth
|
|
13
|
+
} from "./chunk-5GMB3A77.js";
|
|
14
|
+
import {
|
|
15
|
+
preparePrivateFileLocation,
|
|
16
|
+
preparePrivateSqliteLocation,
|
|
17
|
+
securePrivatePath,
|
|
18
|
+
securePrivateSqliteFiles,
|
|
19
|
+
verifyPrivatePathAccess
|
|
20
|
+
} from "./chunk-NCO7RLBM.js";
|
|
21
|
+
import {
|
|
22
|
+
NATIVE_DR_ADVISORY_LOCK_KEY,
|
|
23
|
+
rowIsolationReady
|
|
24
|
+
} from "./chunk-45BZGB77.js";
|
|
25
|
+
|
|
26
|
+
// src/native-dr-bundle.ts
|
|
27
|
+
import { createHash } from "crypto";
|
|
28
|
+
import {
|
|
29
|
+
closeSync,
|
|
30
|
+
constants,
|
|
31
|
+
fstatSync,
|
|
32
|
+
fsyncSync,
|
|
33
|
+
lstatSync,
|
|
34
|
+
openSync,
|
|
35
|
+
readSync,
|
|
36
|
+
writeSync
|
|
37
|
+
} from "fs";
|
|
38
|
+
import { dirname, resolve } from "path";
|
|
39
|
+
var MAGIC = Buffer.from("AGENT-BRIDGE-DR!", "ascii");
|
|
40
|
+
var HEADER_BYTES = MAGIC.length + 2 + 4;
|
|
41
|
+
var FRAME_BYTES = 2 + 8 + 32;
|
|
42
|
+
var CHUNK_BYTES = 64 * 1024;
|
|
43
|
+
var MAX_MANIFEST_BYTES = 64 * 1024;
|
|
44
|
+
var NATIVE_DR_MAX_BUNDLE_BYTES = 64 * 1024 * 1024 * 1024;
|
|
45
|
+
var MAX_ENTRIES = 16;
|
|
46
|
+
var NATIVE_DR_FORMAT = "agent-bridge-native-dr";
|
|
47
|
+
var NATIVE_DR_VERSION = 1;
|
|
48
|
+
var NativeDrBundleError = class extends Error {
|
|
49
|
+
};
|
|
50
|
+
function assertString(value, label) {
|
|
51
|
+
if (value.includes("\0")) throw new NativeDrBundleError(`${label} contains U+0000`);
|
|
52
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
53
|
+
const unit = value.charCodeAt(index);
|
|
54
|
+
if (unit >= 55296 && unit <= 56319) {
|
|
55
|
+
const next = value.charCodeAt(index + 1);
|
|
56
|
+
if (!(next >= 56320 && next <= 57343)) throw new NativeDrBundleError(`${label} contains an unpaired surrogate`);
|
|
57
|
+
index += 1;
|
|
58
|
+
} else if (unit >= 56320 && unit <= 57343) throw new NativeDrBundleError(`${label} contains an unpaired surrogate`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function canonical(value) {
|
|
62
|
+
if (typeof value === "string") {
|
|
63
|
+
assertString(value, "DR manifest string");
|
|
64
|
+
return JSON.stringify(value);
|
|
65
|
+
}
|
|
66
|
+
if (typeof value === "number" && !Number.isFinite(value)) throw new NativeDrBundleError("DR manifest contains a non-finite number");
|
|
67
|
+
if (value === void 0 || typeof value === "function" || typeof value === "symbol" || typeof value === "bigint") {
|
|
68
|
+
throw new NativeDrBundleError("DR manifest contains an unsupported value");
|
|
69
|
+
}
|
|
70
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
71
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
72
|
+
const record = value;
|
|
73
|
+
return `{${Object.keys(record).sort().map((key) => {
|
|
74
|
+
assertString(key, "DR manifest field");
|
|
75
|
+
return `${JSON.stringify(key)}:${canonical(record[key])}`;
|
|
76
|
+
}).join(",")}}`;
|
|
77
|
+
}
|
|
78
|
+
var POSTGRES_SCHEMA_FIELDS = [
|
|
79
|
+
"databaseName",
|
|
80
|
+
"serverVersionNum",
|
|
81
|
+
"serverMajor",
|
|
82
|
+
"schemaVersion",
|
|
83
|
+
"migrations",
|
|
84
|
+
"tableCounts",
|
|
85
|
+
"excludedDataTables",
|
|
86
|
+
"pgDumpVersion",
|
|
87
|
+
"roleInventorySha256",
|
|
88
|
+
"claimedDeliveryCount",
|
|
89
|
+
"readinessAttestations"
|
|
90
|
+
];
|
|
91
|
+
var POSTGRES_EXCLUDED_DATA = [
|
|
92
|
+
"agent_bridge.agent_instances",
|
|
93
|
+
"agent_bridge.rate_limit_buckets",
|
|
94
|
+
"agent_bridge.request_authorities",
|
|
95
|
+
"agent_bridge.archive_transaction_authorizations"
|
|
96
|
+
];
|
|
97
|
+
function validateSchema(kind, value) {
|
|
98
|
+
if (kind === "sqlite") {
|
|
99
|
+
const schema2 = exactObject(value, ["applicationId", "schemaContractSha256", "userVersion"], "DR SQLite schema");
|
|
100
|
+
if (!Number.isSafeInteger(schema2.applicationId) || !Number.isSafeInteger(schema2.userVersion) || typeof schema2.schemaContractSha256 !== "string" || !/^[a-f0-9]{64}$/.test(schema2.schemaContractSha256)) throw new NativeDrBundleError("DR SQLite schema metadata is invalid");
|
|
101
|
+
return schema2;
|
|
102
|
+
}
|
|
103
|
+
const schema = exactObject(value, POSTGRES_SCHEMA_FIELDS, "DR PostgreSQL schema");
|
|
104
|
+
if (typeof schema.databaseName !== "string" || !schema.databaseName || schema.databaseName.length > 128 || !Number.isSafeInteger(schema.serverVersionNum) || Number(schema.serverVersionNum) < 1e5 || !Number.isSafeInteger(schema.serverMajor) || Number(schema.serverMajor) < 10 || !Number.isSafeInteger(schema.schemaVersion) || Number(schema.schemaVersion) < 1 || typeof schema.pgDumpVersion !== "string" || !schema.pgDumpVersion || schema.pgDumpVersion.length > 128 || typeof schema.roleInventorySha256 !== "string" || !/^[a-f0-9]{64}$/.test(schema.roleInventorySha256) || typeof schema.claimedDeliveryCount !== "string" || !/^(0|[1-9][0-9]*)$/.test(schema.claimedDeliveryCount)) {
|
|
105
|
+
throw new NativeDrBundleError("DR PostgreSQL schema metadata is invalid");
|
|
106
|
+
}
|
|
107
|
+
assertString(schema.databaseName, "DR database name");
|
|
108
|
+
assertString(schema.pgDumpVersion, "DR pg_dump version");
|
|
109
|
+
if (!Array.isArray(schema.migrations) || schema.migrations.length > 1e4) throw new NativeDrBundleError("DR PostgreSQL migration inventory is invalid");
|
|
110
|
+
let priorVersion = 0;
|
|
111
|
+
for (const raw of schema.migrations) {
|
|
112
|
+
const migration = exactObject(raw, ["checksum", "name", "version"], "DR migration");
|
|
113
|
+
if (!Number.isSafeInteger(migration.version) || Number(migration.version) <= priorVersion || typeof migration.name !== "string" || !/^[a-z0-9][a-z0-9_]{0,127}$/.test(migration.name) || typeof migration.checksum !== "string" || !/^[a-f0-9]{64}$/.test(migration.checksum)) {
|
|
114
|
+
throw new NativeDrBundleError("DR PostgreSQL migration inventory is invalid");
|
|
115
|
+
}
|
|
116
|
+
priorVersion = Number(migration.version);
|
|
117
|
+
}
|
|
118
|
+
if (!schema.tableCounts || typeof schema.tableCounts !== "object" || Array.isArray(schema.tableCounts)) throw new NativeDrBundleError("DR PostgreSQL table counts are invalid");
|
|
119
|
+
for (const [table, count] of Object.entries(schema.tableCounts)) {
|
|
120
|
+
if (!/^[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*$/.test(table) || typeof count !== "string" || !/^(0|[1-9][0-9]*)$/.test(count)) {
|
|
121
|
+
throw new NativeDrBundleError("DR PostgreSQL table counts are invalid");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (!Array.isArray(schema.excludedDataTables) || schema.excludedDataTables.length !== POSTGRES_EXCLUDED_DATA.length || schema.excludedDataTables.some((name, index) => name !== POSTGRES_EXCLUDED_DATA[index])) {
|
|
125
|
+
throw new NativeDrBundleError("DR PostgreSQL excluded-data inventory is invalid");
|
|
126
|
+
}
|
|
127
|
+
const readiness = exactObject(schema.readinessAttestations, [
|
|
128
|
+
"ownerControlSha256",
|
|
129
|
+
"portableArchiveSha256",
|
|
130
|
+
"rowIsolationSha256",
|
|
131
|
+
"securitySchemaSha256"
|
|
132
|
+
], "DR PostgreSQL readiness attestations");
|
|
133
|
+
if (Object.values(readiness).some((value2) => typeof value2 !== "string" || !/^[a-f0-9]{64}$/.test(value2))) {
|
|
134
|
+
throw new NativeDrBundleError("DR PostgreSQL readiness attestations are invalid");
|
|
135
|
+
}
|
|
136
|
+
return schema;
|
|
137
|
+
}
|
|
138
|
+
function expectedEntries(kind) {
|
|
139
|
+
return kind === "sqlite" ? ["sqlite/database.sqlite3"] : ["postgres/database.dump", "postgres/roles.json"];
|
|
140
|
+
}
|
|
141
|
+
function sameFile(left, right) {
|
|
142
|
+
return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
|
|
143
|
+
}
|
|
144
|
+
function writeAll(descriptor, bytes) {
|
|
145
|
+
let offset = 0;
|
|
146
|
+
while (offset < bytes.length) offset += writeSync(descriptor, bytes, offset, bytes.length - offset);
|
|
147
|
+
}
|
|
148
|
+
function exactObject(value, fields, label) {
|
|
149
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new NativeDrBundleError(`${label} must be an object`);
|
|
150
|
+
const record = value;
|
|
151
|
+
const actual = Object.keys(record).sort();
|
|
152
|
+
const expected = [...fields].sort();
|
|
153
|
+
if (actual.length !== expected.length || actual.some((field, index) => field !== expected[index])) {
|
|
154
|
+
throw new NativeDrBundleError(`${label} has unexpected fields`);
|
|
155
|
+
}
|
|
156
|
+
return record;
|
|
157
|
+
}
|
|
158
|
+
function parseManifest(bytes) {
|
|
159
|
+
let text;
|
|
160
|
+
try {
|
|
161
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
162
|
+
} catch {
|
|
163
|
+
throw new NativeDrBundleError("DR manifest is not valid UTF-8");
|
|
164
|
+
}
|
|
165
|
+
let parsed;
|
|
166
|
+
try {
|
|
167
|
+
parsed = JSON.parse(text);
|
|
168
|
+
} catch {
|
|
169
|
+
throw new NativeDrBundleError("DR manifest is not valid JSON");
|
|
170
|
+
}
|
|
171
|
+
if (canonical(parsed) !== text) throw new NativeDrBundleError("DR manifest is not canonical JSON");
|
|
172
|
+
const manifest = exactObject(parsed, ["backupId", "createdAt", "entries", "format", "kind", "schema", "version"], "DR manifest");
|
|
173
|
+
if (manifest.format !== NATIVE_DR_FORMAT || manifest.version !== NATIVE_DR_VERSION || manifest.kind !== "sqlite" && manifest.kind !== "postgres") {
|
|
174
|
+
throw new NativeDrBundleError("DR bundle format is unsupported");
|
|
175
|
+
}
|
|
176
|
+
if (typeof manifest.backupId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(manifest.backupId)) {
|
|
177
|
+
throw new NativeDrBundleError("DR backup ID is invalid");
|
|
178
|
+
}
|
|
179
|
+
let canonicalTimestamp = "";
|
|
180
|
+
try {
|
|
181
|
+
canonicalTimestamp = typeof manifest.createdAt === "string" ? new Date(manifest.createdAt).toISOString() : "";
|
|
182
|
+
} catch {
|
|
183
|
+
}
|
|
184
|
+
if (typeof manifest.createdAt !== "string" || canonicalTimestamp !== manifest.createdAt) {
|
|
185
|
+
throw new NativeDrBundleError("DR creation timestamp is invalid");
|
|
186
|
+
}
|
|
187
|
+
if (!Array.isArray(manifest.entries) || !manifest.entries.length || manifest.entries.length > MAX_ENTRIES) throw new NativeDrBundleError("DR entry set is invalid");
|
|
188
|
+
let previous = "";
|
|
189
|
+
for (const rawEntry of manifest.entries) {
|
|
190
|
+
const entry = exactObject(rawEntry, ["length", "name", "sha256"], "DR entry");
|
|
191
|
+
if (typeof entry.name !== "string" || !/^[a-z0-9][a-z0-9/_.-]{0,255}$/.test(entry.name) || entry.name.split("/").includes("..") || entry.name <= previous || !Number.isSafeInteger(entry.length) || Number(entry.length) < 1 || Number(entry.length) > NATIVE_DR_MAX_BUNDLE_BYTES || typeof entry.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(entry.sha256)) {
|
|
192
|
+
throw new NativeDrBundleError("DR entry metadata is invalid");
|
|
193
|
+
}
|
|
194
|
+
previous = entry.name;
|
|
195
|
+
}
|
|
196
|
+
const allowedEntries = expectedEntries(manifest.kind);
|
|
197
|
+
if (manifest.entries.length !== allowedEntries.length || manifest.entries.some((entry, index) => entry.name !== allowedEntries[index])) throw new NativeDrBundleError("DR entries do not match the provider kind");
|
|
198
|
+
validateSchema(manifest.kind, manifest.schema);
|
|
199
|
+
return parsed;
|
|
200
|
+
}
|
|
201
|
+
function hashFile(descriptor, identity) {
|
|
202
|
+
const hash = createHash("sha256");
|
|
203
|
+
const chunk = Buffer.allocUnsafe(CHUNK_BYTES);
|
|
204
|
+
let position = 0;
|
|
205
|
+
while (position < identity.size) {
|
|
206
|
+
const read = readSync(descriptor, chunk, 0, Math.min(chunk.length, identity.size - position), position);
|
|
207
|
+
if (read <= 0) throw new NativeDrBundleError("DR source ended before its recorded size");
|
|
208
|
+
hash.update(chunk.subarray(0, read));
|
|
209
|
+
position += read;
|
|
210
|
+
}
|
|
211
|
+
if (!sameFile(identity, fstatSync(descriptor))) throw new NativeDrBundleError("DR source changed while hashing");
|
|
212
|
+
return hash.digest("hex");
|
|
213
|
+
}
|
|
214
|
+
function openInput(entry) {
|
|
215
|
+
const path = resolve(entry.path);
|
|
216
|
+
verifyPrivatePathAccess(dirname(path), "directory");
|
|
217
|
+
verifyPrivatePathAccess(path, "file");
|
|
218
|
+
const before = lstatSync(path);
|
|
219
|
+
const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
|
|
220
|
+
const descriptor = openSync(path, constants.O_RDONLY | noFollow);
|
|
221
|
+
try {
|
|
222
|
+
const identity = fstatSync(descriptor);
|
|
223
|
+
const named = lstatSync(path);
|
|
224
|
+
if (!identity.isFile() || before.isSymbolicLink() || !sameFile(before, identity) || !sameFile(named, identity) || identity.size < 1 || identity.size > NATIVE_DR_MAX_BUNDLE_BYTES) throw new NativeDrBundleError("DR source is not a stable regular file");
|
|
225
|
+
return { descriptor, identity, name: entry.name, path };
|
|
226
|
+
} catch (error) {
|
|
227
|
+
closeSync(descriptor);
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function validateNativeDrAggregateSize(entryBytes, framingBytes = 0) {
|
|
232
|
+
let total = framingBytes;
|
|
233
|
+
for (const bytes of entryBytes) {
|
|
234
|
+
if (!Number.isSafeInteger(bytes) || bytes < 0 || total > NATIVE_DR_MAX_BUNDLE_BYTES - bytes) {
|
|
235
|
+
throw new NativeDrBundleError("DR bundle exceeds its aggregate size limit");
|
|
236
|
+
}
|
|
237
|
+
total += bytes;
|
|
238
|
+
}
|
|
239
|
+
return total;
|
|
240
|
+
}
|
|
241
|
+
function writeNativeDrBundle(outputDescriptor, input, outputPath, hooks = {}) {
|
|
242
|
+
if (!input.entries.length || input.entries.length > MAX_ENTRIES) throw new NativeDrBundleError("DR entry set is invalid");
|
|
243
|
+
const names = input.entries.map((entry) => entry.name);
|
|
244
|
+
if (names.some((name, index) => index > 0 && name <= names[index - 1])) throw new NativeDrBundleError("DR entries must be uniquely ordered by name");
|
|
245
|
+
const allowedEntries = expectedEntries(input.kind);
|
|
246
|
+
if (names.length !== allowedEntries.length || names.some((name, index) => name !== allowedEntries[index])) {
|
|
247
|
+
throw new NativeDrBundleError("DR entries do not match the provider kind");
|
|
248
|
+
}
|
|
249
|
+
validateSchema(input.kind, input.schema);
|
|
250
|
+
const sources = [];
|
|
251
|
+
try {
|
|
252
|
+
for (const entry of input.entries) {
|
|
253
|
+
const opened = openInput(entry);
|
|
254
|
+
sources.push({ ...opened, sha256: hashFile(opened.descriptor, opened.identity) });
|
|
255
|
+
}
|
|
256
|
+
hooks.afterHash?.();
|
|
257
|
+
const manifest = {
|
|
258
|
+
backupId: input.backupId,
|
|
259
|
+
createdAt: input.createdAt,
|
|
260
|
+
entries: sources.map((source) => ({ length: source.identity.size, name: source.name, sha256: source.sha256 })),
|
|
261
|
+
format: NATIVE_DR_FORMAT,
|
|
262
|
+
kind: input.kind,
|
|
263
|
+
schema: input.schema,
|
|
264
|
+
version: NATIVE_DR_VERSION
|
|
265
|
+
};
|
|
266
|
+
const manifestBytes = Buffer.from(canonical(manifest), "utf8");
|
|
267
|
+
if (manifestBytes.length > MAX_MANIFEST_BYTES) throw new NativeDrBundleError("DR manifest exceeds 64 KiB");
|
|
268
|
+
parseManifest(manifestBytes);
|
|
269
|
+
const output = resolve(outputPath);
|
|
270
|
+
verifyPrivatePathAccess(dirname(output), "directory");
|
|
271
|
+
verifyPrivatePathAccess(output, "file");
|
|
272
|
+
const openedOutput = fstatSync(outputDescriptor);
|
|
273
|
+
const namedOutput = lstatSync(output);
|
|
274
|
+
if (!openedOutput.isFile() || namedOutput.isSymbolicLink() || !sameFile(openedOutput, namedOutput) || openedOutput.size !== 0) {
|
|
275
|
+
throw new NativeDrBundleError("DR output file identity changed during staging");
|
|
276
|
+
}
|
|
277
|
+
validateNativeDrAggregateSize(
|
|
278
|
+
sources.map((source) => source.identity.size),
|
|
279
|
+
HEADER_BYTES + manifestBytes.length + sources.reduce((total, source) => total + FRAME_BYTES + Buffer.byteLength(source.name), 0)
|
|
280
|
+
);
|
|
281
|
+
const header = Buffer.alloc(HEADER_BYTES);
|
|
282
|
+
MAGIC.copy(header);
|
|
283
|
+
header.writeUInt16BE(NATIVE_DR_VERSION, MAGIC.length);
|
|
284
|
+
header.writeUInt32BE(manifestBytes.length, MAGIC.length + 2);
|
|
285
|
+
writeAll(outputDescriptor, header);
|
|
286
|
+
writeAll(outputDescriptor, manifestBytes);
|
|
287
|
+
for (const source of sources) {
|
|
288
|
+
const name = Buffer.from(source.name, "utf8");
|
|
289
|
+
const frame = Buffer.alloc(FRAME_BYTES);
|
|
290
|
+
frame.writeUInt16BE(name.length, 0);
|
|
291
|
+
frame.writeBigUInt64BE(BigInt(source.identity.size), 2);
|
|
292
|
+
Buffer.from(source.sha256, "hex").copy(frame, 10);
|
|
293
|
+
writeAll(outputDescriptor, frame);
|
|
294
|
+
writeAll(outputDescriptor, name);
|
|
295
|
+
const streamedHash = createHash("sha256");
|
|
296
|
+
const chunk = Buffer.allocUnsafe(CHUNK_BYTES);
|
|
297
|
+
let position = 0;
|
|
298
|
+
while (position < source.identity.size) {
|
|
299
|
+
const read = readSync(source.descriptor, chunk, 0, Math.min(chunk.length, source.identity.size - position), position);
|
|
300
|
+
if (read <= 0) throw new NativeDrBundleError("DR source ended while streaming");
|
|
301
|
+
const bytes = chunk.subarray(0, read);
|
|
302
|
+
streamedHash.update(bytes);
|
|
303
|
+
writeAll(outputDescriptor, bytes);
|
|
304
|
+
position += read;
|
|
305
|
+
}
|
|
306
|
+
if (streamedHash.digest("hex") !== source.sha256) throw new NativeDrBundleError("DR source changed between hashing and bundling");
|
|
307
|
+
if (!sameFile(source.identity, fstatSync(source.descriptor)) || !sameFile(source.identity, lstatSync(source.path))) {
|
|
308
|
+
throw new NativeDrBundleError("DR source changed while bundling");
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
fsyncSync(outputDescriptor);
|
|
312
|
+
return manifest;
|
|
313
|
+
} finally {
|
|
314
|
+
for (const source of sources) closeSync(source.descriptor);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
var NativeDrBundleReader = class {
|
|
318
|
+
constructor(path) {
|
|
319
|
+
this.closed = false;
|
|
320
|
+
this.path = resolve(path);
|
|
321
|
+
verifyPrivatePathAccess(dirname(this.path), "directory");
|
|
322
|
+
verifyPrivatePathAccess(this.path, "file");
|
|
323
|
+
const before = lstatSync(this.path);
|
|
324
|
+
const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
|
|
325
|
+
this.descriptor = openSync(this.path, constants.O_RDONLY | noFollow);
|
|
326
|
+
try {
|
|
327
|
+
this.identity = fstatSync(this.descriptor);
|
|
328
|
+
const after = lstatSync(this.path);
|
|
329
|
+
if (!this.identity.isFile() || before.isSymbolicLink() || !sameFile(before, this.identity) || !sameFile(after, this.identity) || this.identity.size < HEADER_BYTES + FRAME_BYTES + 1 || this.identity.size > NATIVE_DR_MAX_BUNDLE_BYTES) {
|
|
330
|
+
throw new NativeDrBundleError("DR bundle is not a stable regular file");
|
|
331
|
+
}
|
|
332
|
+
} catch (error) {
|
|
333
|
+
closeSync(this.descriptor);
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
readExact(position, length, hash) {
|
|
338
|
+
const output = Buffer.allocUnsafe(length);
|
|
339
|
+
let offset = 0;
|
|
340
|
+
while (offset < length) {
|
|
341
|
+
const read = readSync(this.descriptor, output, offset, length - offset, position + offset);
|
|
342
|
+
if (read <= 0) throw new NativeDrBundleError("DR bundle ended unexpectedly");
|
|
343
|
+
offset += read;
|
|
344
|
+
}
|
|
345
|
+
hash.update(output);
|
|
346
|
+
return output;
|
|
347
|
+
}
|
|
348
|
+
inspect(output) {
|
|
349
|
+
if (this.closed) throw new NativeDrBundleError("DR bundle is closed");
|
|
350
|
+
const bundleHash = createHash("sha256");
|
|
351
|
+
let position = 0;
|
|
352
|
+
const header = this.readExact(position, HEADER_BYTES, bundleHash);
|
|
353
|
+
position += header.length;
|
|
354
|
+
if (!header.subarray(0, MAGIC.length).equals(MAGIC) || header.readUInt16BE(MAGIC.length) !== NATIVE_DR_VERSION) {
|
|
355
|
+
throw new NativeDrBundleError("DR bundle header is unsupported");
|
|
356
|
+
}
|
|
357
|
+
const manifestLength = header.readUInt32BE(MAGIC.length + 2);
|
|
358
|
+
if (manifestLength < 1 || manifestLength > MAX_MANIFEST_BYTES) throw new NativeDrBundleError("DR manifest size is invalid");
|
|
359
|
+
const manifest = parseManifest(this.readExact(position, manifestLength, bundleHash));
|
|
360
|
+
position += manifestLength;
|
|
361
|
+
for (const expected of manifest.entries) {
|
|
362
|
+
const frame = this.readExact(position, FRAME_BYTES, bundleHash);
|
|
363
|
+
position += frame.length;
|
|
364
|
+
const nameLength = frame.readUInt16BE(0);
|
|
365
|
+
const length = Number(frame.readBigUInt64BE(2));
|
|
366
|
+
const digest = frame.subarray(10).toString("hex");
|
|
367
|
+
if (!Number.isSafeInteger(length) || length !== expected.length || digest !== expected.sha256) {
|
|
368
|
+
throw new NativeDrBundleError("DR entry frame does not match the manifest");
|
|
369
|
+
}
|
|
370
|
+
const name = this.readExact(position, nameLength, bundleHash);
|
|
371
|
+
position += nameLength;
|
|
372
|
+
let decoded;
|
|
373
|
+
try {
|
|
374
|
+
decoded = new TextDecoder("utf-8", { fatal: true }).decode(name);
|
|
375
|
+
} catch {
|
|
376
|
+
throw new NativeDrBundleError("DR entry name is not valid UTF-8");
|
|
377
|
+
}
|
|
378
|
+
if (decoded !== expected.name) throw new NativeDrBundleError("DR entry name does not match the manifest");
|
|
379
|
+
const outputDescriptor = output?.(expected);
|
|
380
|
+
const entryHash = createHash("sha256");
|
|
381
|
+
const chunk = Buffer.allocUnsafe(CHUNK_BYTES);
|
|
382
|
+
let remaining = length;
|
|
383
|
+
while (remaining > 0) {
|
|
384
|
+
const requested = Math.min(chunk.length, remaining);
|
|
385
|
+
const read = readSync(this.descriptor, chunk, 0, requested, position);
|
|
386
|
+
if (read <= 0) throw new NativeDrBundleError("DR entry ended unexpectedly");
|
|
387
|
+
const bytes = chunk.subarray(0, read);
|
|
388
|
+
bundleHash.update(bytes);
|
|
389
|
+
entryHash.update(bytes);
|
|
390
|
+
if (outputDescriptor !== void 0) writeAll(outputDescriptor, bytes);
|
|
391
|
+
position += read;
|
|
392
|
+
remaining -= read;
|
|
393
|
+
}
|
|
394
|
+
if (entryHash.digest("hex") !== digest) throw new NativeDrBundleError("DR entry digest verification failed");
|
|
395
|
+
}
|
|
396
|
+
if (position !== this.identity.size) throw new NativeDrBundleError("DR bundle has trailing bytes");
|
|
397
|
+
if (!sameFile(this.identity, fstatSync(this.descriptor)) || !sameFile(this.identity, lstatSync(this.path))) {
|
|
398
|
+
throw new NativeDrBundleError("DR bundle changed while reading");
|
|
399
|
+
}
|
|
400
|
+
return { manifest, bundleSha256: bundleHash.digest("hex"), bundleBytes: this.identity.size };
|
|
401
|
+
}
|
|
402
|
+
close() {
|
|
403
|
+
if (!this.closed) {
|
|
404
|
+
this.closed = true;
|
|
405
|
+
closeSync(this.descriptor);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
// src/postgres-native-dr.ts
|
|
411
|
+
import { spawn } from "child_process";
|
|
412
|
+
import { createHash as createHash2, randomUUID } from "crypto";
|
|
413
|
+
import {
|
|
414
|
+
accessSync,
|
|
415
|
+
closeSync as closeSync2,
|
|
416
|
+
constants as constants2,
|
|
417
|
+
existsSync,
|
|
418
|
+
fstatSync as fstatSync2,
|
|
419
|
+
lstatSync as lstatSync2,
|
|
420
|
+
mkdtempSync,
|
|
421
|
+
openSync as openSync2,
|
|
422
|
+
readSync as readSync2,
|
|
423
|
+
rmSync,
|
|
424
|
+
statSync,
|
|
425
|
+
writeFileSync
|
|
426
|
+
} from "fs";
|
|
427
|
+
import { delimiter, join, resolve as resolve2 } from "path";
|
|
428
|
+
import pg from "pg";
|
|
429
|
+
|
|
430
|
+
// src/bounded-tool-output.ts
|
|
431
|
+
function createBoundedToolOutput(maximumBytes) {
|
|
432
|
+
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 0) throw new Error("tool output capture limit is invalid");
|
|
433
|
+
const chunks = [];
|
|
434
|
+
let capturedBytes = 0;
|
|
435
|
+
let truncated = false;
|
|
436
|
+
return {
|
|
437
|
+
push(chunk) {
|
|
438
|
+
const remaining = maximumBytes - capturedBytes;
|
|
439
|
+
if (remaining > 0) {
|
|
440
|
+
const captured = chunk.length <= remaining ? chunk : chunk.subarray(0, remaining);
|
|
441
|
+
chunks.push(captured);
|
|
442
|
+
capturedBytes += captured.length;
|
|
443
|
+
}
|
|
444
|
+
if (chunk.length > remaining) truncated = true;
|
|
445
|
+
},
|
|
446
|
+
read() {
|
|
447
|
+
return { bytes: Buffer.concat(chunks, capturedBytes), truncated };
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// src/postgres-native-dr.ts
|
|
453
|
+
var POSTGRES_NATIVE_DR_ROLE_SCHEMA = "agent-bridge.postgres-native-dr-roles";
|
|
454
|
+
var POSTGRES_NATIVE_DR_ROLE_VERSION = 1;
|
|
455
|
+
var POSTGRES_NATIVE_DR_SERVICE = "agent_bridge_dr";
|
|
456
|
+
var POSTGRES_NATIVE_DR_EXCLUDED_DATA_TABLES = [
|
|
457
|
+
"agent_bridge.agent_instances",
|
|
458
|
+
"agent_bridge.rate_limit_buckets",
|
|
459
|
+
"agent_bridge.request_authorities",
|
|
460
|
+
"agent_bridge.archive_transaction_authorizations"
|
|
461
|
+
];
|
|
462
|
+
var SSL_PARAMETERS = /* @__PURE__ */ new Set([
|
|
463
|
+
"channel_binding",
|
|
464
|
+
"sslcert",
|
|
465
|
+
"sslcrl",
|
|
466
|
+
"sslcrldir",
|
|
467
|
+
"sslkey",
|
|
468
|
+
"sslmode",
|
|
469
|
+
"sslrootcert",
|
|
470
|
+
"sslsni"
|
|
471
|
+
]);
|
|
472
|
+
var DERIVED_ROLE = /^agent_bridge_(?:runtime|data_owner|context_reader|event_writer|control_owner|control_operator|control_auditor|archive_operator)_[0-9a-f]{16}$/;
|
|
473
|
+
var ROLE_NAME = /^[^\u0000-\u001f\u007f]{1,63}$/u;
|
|
474
|
+
function validPostgresIdentifier(value) {
|
|
475
|
+
return value !== "PUBLIC" && !value.includes(":") && ROLE_NAME.test(value) && Buffer.byteLength(value, "utf8") <= 63;
|
|
476
|
+
}
|
|
477
|
+
var PostgresNativeDrError = class extends Error {
|
|
478
|
+
constructor(code, message, details = {}) {
|
|
479
|
+
super(message);
|
|
480
|
+
this.code = code;
|
|
481
|
+
this.details = details;
|
|
482
|
+
this.name = "PostgresNativeDrError";
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
function assertRecord(value, label) {
|
|
486
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
487
|
+
throw new Error(`${label} must be an object`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
function exactKeys(value, expected, label) {
|
|
491
|
+
const expectedSet = new Set(expected);
|
|
492
|
+
const unexpected = Object.keys(value).filter((key) => !expectedSet.has(key));
|
|
493
|
+
const missing = expected.filter((key) => !(key in value));
|
|
494
|
+
if (unexpected.length > 0) throw new Error(`${label} has unexpected fields: ${unexpected.join(", ")}`);
|
|
495
|
+
if (missing.length > 0) throw new Error(`${label} is missing fields: ${missing.join(", ")}`);
|
|
496
|
+
}
|
|
497
|
+
function assertSafeText(value, label) {
|
|
498
|
+
if (value.length === 0 || /[\u0000\r\n]/u.test(value)) throw new Error(`${label} is invalid`);
|
|
499
|
+
}
|
|
500
|
+
function serviceValue(value, label) {
|
|
501
|
+
assertSafeText(value, label);
|
|
502
|
+
if (value.trim() !== value) throw new Error(`${label} cannot begin or end with whitespace`);
|
|
503
|
+
return value;
|
|
504
|
+
}
|
|
505
|
+
function pgpassValue(value) {
|
|
506
|
+
assertSafeText(value, "PostgreSQL credential component");
|
|
507
|
+
return value.split("\\").join("\\\\").split(":").join("\\:");
|
|
508
|
+
}
|
|
509
|
+
function createPostgresDrLibpqFiles(connectionUrl, directory) {
|
|
510
|
+
let url;
|
|
511
|
+
try {
|
|
512
|
+
url = new URL(connectionUrl);
|
|
513
|
+
} catch {
|
|
514
|
+
throw new Error("PostgreSQL disaster recovery URL is invalid");
|
|
515
|
+
}
|
|
516
|
+
if (url.protocol !== "postgresql:" && url.protocol !== "postgres:") {
|
|
517
|
+
throw new Error("PostgreSQL disaster recovery URL must use postgres or postgresql");
|
|
518
|
+
}
|
|
519
|
+
if (!url.hostname || !url.username || !url.pathname || url.pathname === "/") {
|
|
520
|
+
throw new Error("PostgreSQL disaster recovery URL must include host, user, and database");
|
|
521
|
+
}
|
|
522
|
+
for (const key of url.searchParams.keys()) {
|
|
523
|
+
if (!SSL_PARAMETERS.has(key)) throw new Error(`unsupported PostgreSQL connection parameter: ${key}`);
|
|
524
|
+
}
|
|
525
|
+
const duplicateKeys = [...new Set(url.searchParams.keys())].filter((key) => url.searchParams.getAll(key).length !== 1);
|
|
526
|
+
if (duplicateKeys.length > 0) throw new Error(`duplicate PostgreSQL connection parameter: ${duplicateKeys[0]}`);
|
|
527
|
+
const targetDirectory = resolve2(directory);
|
|
528
|
+
securePrivatePath(targetDirectory, "directory");
|
|
529
|
+
verifyPrivatePathAccess(targetDirectory, "directory");
|
|
530
|
+
const serviceFile = resolve2(targetDirectory, "pg_service.conf");
|
|
531
|
+
const passFile = resolve2(targetDirectory, "pgpass");
|
|
532
|
+
let database2;
|
|
533
|
+
let user;
|
|
534
|
+
let password;
|
|
535
|
+
try {
|
|
536
|
+
database2 = decodeURIComponent(url.pathname.slice(1));
|
|
537
|
+
user = decodeURIComponent(url.username);
|
|
538
|
+
password = decodeURIComponent(url.password);
|
|
539
|
+
} catch {
|
|
540
|
+
throw new Error("PostgreSQL disaster recovery URL contains invalid percent encoding");
|
|
541
|
+
}
|
|
542
|
+
const host = url.hostname.startsWith("[") && url.hostname.endsWith("]") ? url.hostname.slice(1, -1) : url.hostname;
|
|
543
|
+
const port = url.port || "5432";
|
|
544
|
+
const serviceLines = [
|
|
545
|
+
`[${POSTGRES_NATIVE_DR_SERVICE}]`,
|
|
546
|
+
`host=${serviceValue(host, "PostgreSQL host")}`,
|
|
547
|
+
`port=${serviceValue(port, "PostgreSQL port")}`,
|
|
548
|
+
`dbname=${serviceValue(database2, "PostgreSQL database")}`,
|
|
549
|
+
`user=${serviceValue(user, "PostgreSQL user")}`
|
|
550
|
+
];
|
|
551
|
+
for (const [key, value] of [...url.searchParams.entries()].sort(([left], [right]) => left.localeCompare(right))) {
|
|
552
|
+
serviceLines.push(`${key}=${serviceValue(value, `PostgreSQL ${key}`)}`);
|
|
553
|
+
}
|
|
554
|
+
writeFileSync(serviceFile, `${serviceLines.join("\n")}
|
|
555
|
+
`, { encoding: "utf8", mode: 384, flag: "wx" });
|
|
556
|
+
writeFileSync(passFile, `${[
|
|
557
|
+
pgpassValue(host),
|
|
558
|
+
pgpassValue(port),
|
|
559
|
+
pgpassValue(database2),
|
|
560
|
+
pgpassValue(user),
|
|
561
|
+
pgpassValue(password)
|
|
562
|
+
].join(":")}
|
|
563
|
+
`, { encoding: "utf8", mode: 384, flag: "wx" });
|
|
564
|
+
securePrivatePath(serviceFile, "file");
|
|
565
|
+
securePrivatePath(passFile, "file");
|
|
566
|
+
verifyPrivatePathAccess(serviceFile, "file");
|
|
567
|
+
verifyPrivatePathAccess(passFile, "file");
|
|
568
|
+
return {
|
|
569
|
+
serviceName: POSTGRES_NATIVE_DR_SERVICE,
|
|
570
|
+
serviceFile,
|
|
571
|
+
passFile,
|
|
572
|
+
environment: {
|
|
573
|
+
LC_ALL: "C",
|
|
574
|
+
PGAPPNAME: "agent-bridge-native-dr",
|
|
575
|
+
PGPASSFILE: passFile,
|
|
576
|
+
PGSERVICE: POSTGRES_NATIVE_DR_SERVICE,
|
|
577
|
+
PGSERVICEFILE: serviceFile
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
function buildPgDumpArgs(outputPath, snapshot, serviceName = POSTGRES_NATIVE_DR_SERVICE) {
|
|
582
|
+
assertSafeText(outputPath, "PostgreSQL dump path");
|
|
583
|
+
assertSafeText(snapshot, "PostgreSQL snapshot");
|
|
584
|
+
assertSafeText(serviceName, "PostgreSQL service name");
|
|
585
|
+
return [
|
|
586
|
+
"--format=custom",
|
|
587
|
+
`--file=${outputPath}`,
|
|
588
|
+
"--schema=agent_bridge",
|
|
589
|
+
"--no-tablespaces",
|
|
590
|
+
`--snapshot=${snapshot}`,
|
|
591
|
+
...POSTGRES_NATIVE_DR_EXCLUDED_DATA_TABLES.map((table) => `--exclude-table-data=${table}`),
|
|
592
|
+
`--dbname=service=${serviceName}`
|
|
593
|
+
];
|
|
594
|
+
}
|
|
595
|
+
function buildPgRestoreArgs(inputPath, serviceName = POSTGRES_NATIVE_DR_SERVICE) {
|
|
596
|
+
if (inputPath !== void 0) assertSafeText(inputPath, "PostgreSQL dump path");
|
|
597
|
+
assertSafeText(serviceName, "PostgreSQL service name");
|
|
598
|
+
return [
|
|
599
|
+
"--exit-on-error",
|
|
600
|
+
"--single-transaction",
|
|
601
|
+
"--no-tablespaces",
|
|
602
|
+
"--schema=agent_bridge",
|
|
603
|
+
`--dbname=service=${serviceName}`,
|
|
604
|
+
...inputPath === void 0 ? [] : [inputPath]
|
|
605
|
+
];
|
|
606
|
+
}
|
|
607
|
+
function buildPgRestoreListArgs(inputPath) {
|
|
608
|
+
if (inputPath !== void 0) assertSafeText(inputPath, "PostgreSQL dump path");
|
|
609
|
+
return inputPath === void 0 ? ["--list"] : ["--list", inputPath];
|
|
610
|
+
}
|
|
611
|
+
function buildPgRestoreSchemaAclArgs(inputPath, listPath, serviceName = POSTGRES_NATIVE_DR_SERVICE) {
|
|
612
|
+
if (inputPath !== void 0) assertSafeText(inputPath, "PostgreSQL dump path");
|
|
613
|
+
assertSafeText(listPath, "PostgreSQL restore list path");
|
|
614
|
+
assertSafeText(serviceName, "PostgreSQL service name");
|
|
615
|
+
return [
|
|
616
|
+
"--exit-on-error",
|
|
617
|
+
"--single-transaction",
|
|
618
|
+
"--no-tablespaces",
|
|
619
|
+
`--use-list=${listPath}`,
|
|
620
|
+
`--dbname=service=${serviceName}`,
|
|
621
|
+
...inputPath === void 0 ? [] : [inputPath]
|
|
622
|
+
];
|
|
623
|
+
}
|
|
624
|
+
var POSTGRES_TOC_DESCRIPTORS = [
|
|
625
|
+
"MATERIALIZED VIEW DATA",
|
|
626
|
+
"SEQUENCE OWNED BY",
|
|
627
|
+
"SECURITY LABEL",
|
|
628
|
+
"CHECK CONSTRAINT",
|
|
629
|
+
"PUBLICATION TABLE",
|
|
630
|
+
"MATERIALIZED VIEW",
|
|
631
|
+
"SEQUENCE SET",
|
|
632
|
+
"TABLE ATTACH",
|
|
633
|
+
"ROW SECURITY",
|
|
634
|
+
"DEFAULT ACL",
|
|
635
|
+
"TABLE DATA",
|
|
636
|
+
"FK CONSTRAINT",
|
|
637
|
+
"OPERATOR CLASS",
|
|
638
|
+
"OPERATOR FAMILY",
|
|
639
|
+
"FOREIGN TABLE",
|
|
640
|
+
"BLOB COMMENTS",
|
|
641
|
+
"BLOB ACL",
|
|
642
|
+
"PROCEDURE",
|
|
643
|
+
"AGGREGATE",
|
|
644
|
+
"COLLATION",
|
|
645
|
+
"CONVERSION",
|
|
646
|
+
"CONSTRAINT",
|
|
647
|
+
"FUNCTION",
|
|
648
|
+
"OPERATOR",
|
|
649
|
+
"SEQUENCE",
|
|
650
|
+
"TRIGGER",
|
|
651
|
+
"POLICY",
|
|
652
|
+
"COMMENT",
|
|
653
|
+
"DEFAULT",
|
|
654
|
+
"SCHEMA",
|
|
655
|
+
"TABLE",
|
|
656
|
+
"TYPE",
|
|
657
|
+
"DOMAIN",
|
|
658
|
+
"INDEX",
|
|
659
|
+
"RULE",
|
|
660
|
+
"VIEW",
|
|
661
|
+
"ACL"
|
|
662
|
+
];
|
|
663
|
+
var POSTGRES_TOC_MAX_BYTES = 16 * 1024 * 1024;
|
|
664
|
+
function validatePostgresDumpToc(text) {
|
|
665
|
+
if (Buffer.byteLength(text, "utf8") > POSTGRES_TOC_MAX_BYTES) {
|
|
666
|
+
throw new PostgresNativeDrError("PG_DUMP_TOC_INVALID", "PostgreSQL dump table of contents exceeds 16 MiB");
|
|
667
|
+
}
|
|
668
|
+
const entries = text.split(/\r?\n/u).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith(";"));
|
|
669
|
+
if (entries.length === 0) throw new PostgresNativeDrError("PG_DUMP_TOC_INVALID", "PostgreSQL dump table of contents is empty");
|
|
670
|
+
for (const entry of entries) {
|
|
671
|
+
const match = /^\d+;\s+\d+\s+\d+\s+(.+)$/u.exec(entry);
|
|
672
|
+
if (!match) throw new PostgresNativeDrError("PG_DUMP_TOC_INVALID", "PostgreSQL dump table of contents has an invalid record");
|
|
673
|
+
const body = match[1];
|
|
674
|
+
const descriptor = POSTGRES_TOC_DESCRIPTORS.find((candidate) => body.startsWith(`${candidate} `));
|
|
675
|
+
if (!descriptor) throw new PostgresNativeDrError("PG_DUMP_TOC_OUT_OF_SCOPE", "PostgreSQL dump contains an unsupported object kind");
|
|
676
|
+
const remainder = body.slice(descriptor.length + 1).split(/\s+/u);
|
|
677
|
+
const namespace = remainder[0] === "-" ? descriptor === "SCHEMA" ? remainder[1] : remainder[2] : remainder[0];
|
|
678
|
+
if (namespace !== "agent_bridge") {
|
|
679
|
+
throw new PostgresNativeDrError("PG_DUMP_TOC_OUT_OF_SCOPE", "PostgreSQL dump contains an object outside the agent_bridge schema");
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
function schemaAclRestoreList(text) {
|
|
684
|
+
validatePostgresDumpToc(text);
|
|
685
|
+
const entries = text.split(/\r?\n/u).map((line) => line.trim()).filter((line) => /^\d+;\s+\d+\s+\d+\s+ACL\s+-\s+SCHEMA\s+agent_bridge(?:\s|$)/u.test(line));
|
|
686
|
+
if (entries.length !== 1) {
|
|
687
|
+
throw new PostgresNativeDrError("PG_DUMP_TOC_INVALID", "PostgreSQL dump must contain exactly one Agent Bridge schema ACL entry");
|
|
688
|
+
}
|
|
689
|
+
return `${entries[0]}
|
|
690
|
+
`;
|
|
691
|
+
}
|
|
692
|
+
function parsePostgresToolMajor(output) {
|
|
693
|
+
const match = /\(PostgreSQL\)\s+(\d+)(?:\.\d+)?(?:\s|$)/u.exec(output.trim());
|
|
694
|
+
if (!match) throw new Error("cannot parse PostgreSQL tool version");
|
|
695
|
+
const major = Number(match[1]);
|
|
696
|
+
if (!Number.isSafeInteger(major) || major < 15 || major > 18) {
|
|
697
|
+
throw new Error(`unsupported PostgreSQL tool version: ${match[1]}`);
|
|
698
|
+
}
|
|
699
|
+
return major;
|
|
700
|
+
}
|
|
701
|
+
function validateRole(value, index) {
|
|
702
|
+
assertRecord(value, `roles[${index}]`);
|
|
703
|
+
exactKeys(value, ["name", "kind"], `roles[${index}]`);
|
|
704
|
+
if (typeof value.name !== "string" || !validPostgresIdentifier(value.name)) throw new Error(`roles[${index}].name is invalid`);
|
|
705
|
+
if (!["derived", "schema-owner", "object-role", "external-principal"].includes(value.kind)) {
|
|
706
|
+
throw new Error(`roles[${index}].kind is invalid`);
|
|
707
|
+
}
|
|
708
|
+
if (value.kind === "derived" && !DERIVED_ROLE.test(value.name)) {
|
|
709
|
+
throw new Error(`roles[${index}] is not an Agent Bridge derived role`);
|
|
710
|
+
}
|
|
711
|
+
if (value.kind !== "derived" && value.kind !== "schema-owner" && DERIVED_ROLE.test(value.name)) {
|
|
712
|
+
throw new Error(`roles[${index}] must classify its Agent Bridge derived role`);
|
|
713
|
+
}
|
|
714
|
+
return { name: value.name, kind: value.kind };
|
|
715
|
+
}
|
|
716
|
+
function validateMembership(value, index) {
|
|
717
|
+
assertRecord(value, `memberships[${index}]`);
|
|
718
|
+
exactKeys(value, ["role", "member", "adminOption", "inheritOption", "setOption"], `memberships[${index}]`);
|
|
719
|
+
if (typeof value.role !== "string" || typeof value.member !== "string") {
|
|
720
|
+
throw new Error(`memberships[${index}] role and member must be strings`);
|
|
721
|
+
}
|
|
722
|
+
for (const field of ["adminOption", "inheritOption", "setOption"]) {
|
|
723
|
+
if (typeof value[field] !== "boolean") throw new Error(`memberships[${index}].${field} must be boolean`);
|
|
724
|
+
}
|
|
725
|
+
const adminOption = value.adminOption;
|
|
726
|
+
const inheritOption = value.inheritOption;
|
|
727
|
+
const setOption = value.setOption;
|
|
728
|
+
return {
|
|
729
|
+
role: value.role,
|
|
730
|
+
member: value.member,
|
|
731
|
+
adminOption,
|
|
732
|
+
inheritOption,
|
|
733
|
+
setOption
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
var DEFAULT_PRIVILEGE_TYPES = /* @__PURE__ */ new Set([
|
|
737
|
+
"CREATE",
|
|
738
|
+
"DELETE",
|
|
739
|
+
"EXECUTE",
|
|
740
|
+
"INSERT",
|
|
741
|
+
"MAINTAIN",
|
|
742
|
+
"REFERENCES",
|
|
743
|
+
"SELECT",
|
|
744
|
+
"TRIGGER",
|
|
745
|
+
"TRUNCATE",
|
|
746
|
+
"UPDATE",
|
|
747
|
+
"USAGE"
|
|
748
|
+
]);
|
|
749
|
+
var DEFAULT_PRIVILEGES_BY_OBJECT = {
|
|
750
|
+
r: /* @__PURE__ */ new Set(["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES", "TRIGGER", "MAINTAIN"]),
|
|
751
|
+
S: /* @__PURE__ */ new Set(["USAGE", "SELECT", "UPDATE"]),
|
|
752
|
+
f: /* @__PURE__ */ new Set(["EXECUTE"]),
|
|
753
|
+
T: /* @__PURE__ */ new Set(["USAGE"]),
|
|
754
|
+
n: /* @__PURE__ */ new Set(["USAGE", "CREATE"])
|
|
755
|
+
};
|
|
756
|
+
function validateDefaultAclGrant(value, aclIndex, grantIndex) {
|
|
757
|
+
const label = `defaultAcls[${aclIndex}].grants[${grantIndex}]`;
|
|
758
|
+
assertRecord(value, label);
|
|
759
|
+
if (value.granteeKind === "public") {
|
|
760
|
+
exactKeys(value, ["grantor", "granteeKind", "privilege", "grantable"], label);
|
|
761
|
+
} else if (value.granteeKind === "role") {
|
|
762
|
+
exactKeys(value, ["grantor", "granteeKind", "grantee", "privilege", "grantable"], label);
|
|
763
|
+
if (typeof value.grantee !== "string" || !validPostgresIdentifier(value.grantee)) {
|
|
764
|
+
throw new Error(`${label}.grantee is invalid`);
|
|
765
|
+
}
|
|
766
|
+
} else {
|
|
767
|
+
throw new Error(`${label}.granteeKind is invalid`);
|
|
768
|
+
}
|
|
769
|
+
if (typeof value.grantor !== "string" || !validPostgresIdentifier(value.grantor)) throw new Error(`${label}.grantor is invalid`);
|
|
770
|
+
if (typeof value.privilege !== "string" || !DEFAULT_PRIVILEGE_TYPES.has(value.privilege)) {
|
|
771
|
+
throw new Error(`${label}.privilege is invalid`);
|
|
772
|
+
}
|
|
773
|
+
if (typeof value.grantable !== "boolean") throw new Error(`${label}.grantable must be boolean`);
|
|
774
|
+
return value;
|
|
775
|
+
}
|
|
776
|
+
function validateDefaultAcl(value, index) {
|
|
777
|
+
assertRecord(value, `defaultAcls[${index}]`);
|
|
778
|
+
exactKeys(value, ["owner", "schema", "objectType", "grants"], `defaultAcls[${index}]`);
|
|
779
|
+
if (typeof value.owner !== "string" || !validPostgresIdentifier(value.owner)) {
|
|
780
|
+
throw new Error(`defaultAcls[${index}].owner is invalid`);
|
|
781
|
+
}
|
|
782
|
+
if (value.schema !== null && value.schema !== "agent_bridge") {
|
|
783
|
+
throw new Error(`defaultAcls[${index}] references a schema outside Agent Bridge`);
|
|
784
|
+
}
|
|
785
|
+
if (!(value.objectType === "r" || value.objectType === "S" || value.objectType === "f" || value.objectType === "T" || value.objectType === "n")) {
|
|
786
|
+
throw new Error(`defaultAcls[${index}].objectType is invalid`);
|
|
787
|
+
}
|
|
788
|
+
if (value.objectType === "n" && value.schema !== null) {
|
|
789
|
+
throw new Error(`defaultAcls[${index}] cannot scope schema privileges to a schema`);
|
|
790
|
+
}
|
|
791
|
+
if (!Array.isArray(value.grants)) throw new Error(`defaultAcls[${index}].grants must be an array`);
|
|
792
|
+
const grants = value.grants.map((grant, grantIndex) => validateDefaultAclGrant(grant, index, grantIndex));
|
|
793
|
+
if (grants.some((grant) => !DEFAULT_PRIVILEGES_BY_OBJECT[value.objectType].has(grant.privilege))) {
|
|
794
|
+
throw new Error(`defaultAcls[${index}] contains a privilege invalid for its object type`);
|
|
795
|
+
}
|
|
796
|
+
return {
|
|
797
|
+
owner: value.owner,
|
|
798
|
+
schema: value.schema,
|
|
799
|
+
objectType: value.objectType,
|
|
800
|
+
grants
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
function validatePostgresRoleInventory(value) {
|
|
804
|
+
assertRecord(value, "PostgreSQL role inventory");
|
|
805
|
+
exactKeys(value, ["schema", "version", "databaseName", "roles", "memberships", "defaultAcls"], "PostgreSQL role inventory");
|
|
806
|
+
if (value.schema !== POSTGRES_NATIVE_DR_ROLE_SCHEMA || value.version !== POSTGRES_NATIVE_DR_ROLE_VERSION) {
|
|
807
|
+
throw new Error("PostgreSQL role inventory schema or version is unsupported");
|
|
808
|
+
}
|
|
809
|
+
if (typeof value.databaseName !== "string" || !validPostgresIdentifier(value.databaseName)) {
|
|
810
|
+
throw new Error("PostgreSQL role inventory databaseName is invalid");
|
|
811
|
+
}
|
|
812
|
+
if (!Array.isArray(value.roles) || !Array.isArray(value.memberships) || !Array.isArray(value.defaultAcls)) {
|
|
813
|
+
throw new Error("PostgreSQL role inventory roles, memberships, and defaultAcls must be arrays");
|
|
814
|
+
}
|
|
815
|
+
const roles = value.roles.map(validateRole);
|
|
816
|
+
const expectedDerivedSuffix = createHash2("md5").update(value.databaseName).digest("hex").slice(0, 16);
|
|
817
|
+
if (roles.some((role) => DERIVED_ROLE.test(role.name) && !role.name.endsWith(`_${expectedDerivedSuffix}`))) {
|
|
818
|
+
throw new Error("PostgreSQL role inventory contains a derived role for another database");
|
|
819
|
+
}
|
|
820
|
+
if (roles.filter((role) => role.kind === "schema-owner").length !== 1) {
|
|
821
|
+
throw new Error("PostgreSQL role inventory must contain exactly one schema owner");
|
|
822
|
+
}
|
|
823
|
+
const roleNames = /* @__PURE__ */ new Set();
|
|
824
|
+
for (const role of roles) {
|
|
825
|
+
if (roleNames.has(role.name)) throw new Error(`duplicate PostgreSQL role: ${role.name}`);
|
|
826
|
+
roleNames.add(role.name);
|
|
827
|
+
}
|
|
828
|
+
const memberships = value.memberships.map(validateMembership);
|
|
829
|
+
const membershipKeys = /* @__PURE__ */ new Set();
|
|
830
|
+
for (const membership of memberships) {
|
|
831
|
+
if (!roleNames.has(membership.role) || !roleNames.has(membership.member)) {
|
|
832
|
+
throw new Error("PostgreSQL role membership references a role outside the inventory");
|
|
833
|
+
}
|
|
834
|
+
if (membership.role === membership.member) throw new Error("PostgreSQL role membership cannot be self-referential");
|
|
835
|
+
const key = `${membership.role}\0${membership.member}`;
|
|
836
|
+
if (membershipKeys.has(key)) throw new Error("duplicate PostgreSQL role membership");
|
|
837
|
+
membershipKeys.add(key);
|
|
838
|
+
}
|
|
839
|
+
const defaultAcls = value.defaultAcls.map(validateDefaultAcl);
|
|
840
|
+
const defaultAclKeys = /* @__PURE__ */ new Set();
|
|
841
|
+
for (const acl of defaultAcls) {
|
|
842
|
+
if (!roleNames.has(acl.owner)) throw new Error("PostgreSQL default ACL owner is outside the inventory");
|
|
843
|
+
const aclKey = [acl.owner, acl.schema ?? "", acl.objectType].join("\0");
|
|
844
|
+
if (defaultAclKeys.has(aclKey)) throw new Error("duplicate PostgreSQL default ACL");
|
|
845
|
+
defaultAclKeys.add(aclKey);
|
|
846
|
+
const grantKeys = /* @__PURE__ */ new Set();
|
|
847
|
+
for (const grant of acl.grants) {
|
|
848
|
+
if (grant.grantor !== acl.owner) {
|
|
849
|
+
throw new Error("PostgreSQL default ACL with a grantor distinct from its owner is unsupported");
|
|
850
|
+
}
|
|
851
|
+
if (grant.granteeKind === "role" && !roleNames.has(grant.grantee)) {
|
|
852
|
+
throw new Error("PostgreSQL default ACL references a grantee outside the inventory");
|
|
853
|
+
}
|
|
854
|
+
const grantKey = [
|
|
855
|
+
grant.grantor,
|
|
856
|
+
grant.granteeKind,
|
|
857
|
+
grant.granteeKind === "role" ? grant.grantee : "",
|
|
858
|
+
grant.privilege,
|
|
859
|
+
String(grant.grantable)
|
|
860
|
+
].join("\0");
|
|
861
|
+
if (grantKeys.has(grantKey)) throw new Error("duplicate PostgreSQL default ACL grant");
|
|
862
|
+
grantKeys.add(grantKey);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
return {
|
|
866
|
+
schema: POSTGRES_NATIVE_DR_ROLE_SCHEMA,
|
|
867
|
+
version: POSTGRES_NATIVE_DR_ROLE_VERSION,
|
|
868
|
+
databaseName: value.databaseName,
|
|
869
|
+
roles: roles.sort((left, right) => left.name.localeCompare(right.name)),
|
|
870
|
+
memberships: memberships.sort((left, right) => left.role.localeCompare(right.role) || left.member.localeCompare(right.member)),
|
|
871
|
+
defaultAcls: defaultAcls.map((acl) => ({
|
|
872
|
+
...acl,
|
|
873
|
+
grants: acl.grants.sort((left, right) => left.grantor.localeCompare(right.grantor) || left.granteeKind.localeCompare(right.granteeKind) || (left.granteeKind === "role" ? left.grantee : "").localeCompare(
|
|
874
|
+
right.granteeKind === "role" ? right.grantee : ""
|
|
875
|
+
) || left.privilege.localeCompare(right.privilege) || Number(left.grantable) - Number(right.grantable))
|
|
876
|
+
})).sort((left, right) => left.owner.localeCompare(right.owner) || (left.schema ?? "").localeCompare(right.schema ?? "") || left.objectType.localeCompare(right.objectType))
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
function canonicalPostgresRoleInventory(inventory) {
|
|
880
|
+
return `${JSON.stringify(validatePostgresRoleInventory(inventory))}
|
|
881
|
+
`;
|
|
882
|
+
}
|
|
883
|
+
function quoteIdentifier(value) {
|
|
884
|
+
if (!validPostgresIdentifier(value)) throw new Error("PostgreSQL role name is invalid");
|
|
885
|
+
return `"${value.split('"').join('""')}"`;
|
|
886
|
+
}
|
|
887
|
+
function buildPostgresRoleShellStatements(inventory) {
|
|
888
|
+
return validatePostgresRoleInventory(inventory).roles.map((role) => `CREATE ROLE ${quoteIdentifier(role.name)} NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS INHERIT;`);
|
|
889
|
+
}
|
|
890
|
+
function buildPostgresMembershipStatements(inventory, serverMajor2) {
|
|
891
|
+
if (!Number.isSafeInteger(serverMajor2) || serverMajor2 < 15 || serverMajor2 > 18) {
|
|
892
|
+
throw new Error("PostgreSQL membership target major must be between 15 and 18");
|
|
893
|
+
}
|
|
894
|
+
return validatePostgresRoleInventory(inventory).memberships.map((membership) => {
|
|
895
|
+
if (serverMajor2 === 15) {
|
|
896
|
+
if (!membership.inheritOption || !membership.setOption) {
|
|
897
|
+
throw new Error("PostgreSQL 15 cannot preserve membership INHERIT FALSE or SET FALSE semantics");
|
|
898
|
+
}
|
|
899
|
+
return `GRANT ${quoteIdentifier(membership.role)} TO ${quoteIdentifier(membership.member)}${membership.adminOption ? " WITH ADMIN OPTION" : ""};`;
|
|
900
|
+
}
|
|
901
|
+
const options = [
|
|
902
|
+
`ADMIN ${membership.adminOption ? "OPTION" : "FALSE"}`,
|
|
903
|
+
`INHERIT ${membership.inheritOption ? "TRUE" : "FALSE"}`,
|
|
904
|
+
`SET ${membership.setOption ? "TRUE" : "FALSE"}`
|
|
905
|
+
].join(", ");
|
|
906
|
+
return `GRANT ${quoteIdentifier(membership.role)} TO ${quoteIdentifier(membership.member)} WITH ${options};`;
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
var DEFAULT_PRIVILEGE_OBJECTS = {
|
|
910
|
+
r: "TABLES",
|
|
911
|
+
S: "SEQUENCES",
|
|
912
|
+
f: "FUNCTIONS",
|
|
913
|
+
T: "TYPES",
|
|
914
|
+
n: "SCHEMAS"
|
|
915
|
+
};
|
|
916
|
+
function buildPostgresDefaultPrivilegeStatements(inventory) {
|
|
917
|
+
return validatePostgresRoleInventory(inventory).defaultAcls.map((acl) => {
|
|
918
|
+
const prefix = `ALTER DEFAULT PRIVILEGES FOR ROLE ${quoteIdentifier(acl.owner)}${acl.schema ? ` IN SCHEMA ${quoteIdentifier(acl.schema)}` : ""}`;
|
|
919
|
+
const objects = DEFAULT_PRIVILEGE_OBJECTS[acl.objectType];
|
|
920
|
+
const statements = [
|
|
921
|
+
`SET LOCAL ROLE ${quoteIdentifier(acl.owner)}`,
|
|
922
|
+
`${prefix} REVOKE ALL PRIVILEGES ON ${objects} FROM PUBLIC`,
|
|
923
|
+
`${prefix} REVOKE ALL PRIVILEGES ON ${objects} FROM ${quoteIdentifier(acl.owner)}`
|
|
924
|
+
];
|
|
925
|
+
for (const grant of acl.grants) {
|
|
926
|
+
const grantee = grant.granteeKind === "public" ? "PUBLIC" : quoteIdentifier(grant.grantee);
|
|
927
|
+
statements.push(`${prefix} GRANT ${grant.privilege} ON ${objects} TO ${grantee}${grant.grantable ? " WITH GRANT OPTION" : ""}`);
|
|
928
|
+
}
|
|
929
|
+
statements.push("RESET ROLE");
|
|
930
|
+
return `${statements.join("; ")};`;
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
function resolvePostgresTool(tool, toolDirectory) {
|
|
934
|
+
const executable = process.platform === "win32" ? `${tool}.exe` : tool;
|
|
935
|
+
const candidates = toolDirectory ? [resolve2(toolDirectory, executable)] : (process.env.PATH ?? "").split(delimiter).filter(Boolean).map((directory) => resolve2(directory, executable));
|
|
936
|
+
for (const candidate of candidates) {
|
|
937
|
+
try {
|
|
938
|
+
accessSync(candidate, constants2.X_OK);
|
|
939
|
+
return candidate;
|
|
940
|
+
} catch {
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
throw new PostgresNativeDrError("POSTGRES_TOOL_NOT_FOUND", `${tool} was not found in the selected PostgreSQL tool directory`);
|
|
944
|
+
}
|
|
945
|
+
async function runPostgresTool(command, args, environment, inputFileDescriptor) {
|
|
946
|
+
return await new Promise((resolveResult, reject) => {
|
|
947
|
+
const child = spawn(command, args, {
|
|
948
|
+
env: environment,
|
|
949
|
+
shell: false,
|
|
950
|
+
windowsHide: true,
|
|
951
|
+
stdio: [inputFileDescriptor ?? "ignore", "pipe", "pipe"]
|
|
952
|
+
});
|
|
953
|
+
const stdout = createBoundedToolOutput(POSTGRES_TOC_MAX_BYTES);
|
|
954
|
+
const stderr = createBoundedToolOutput(POSTGRES_TOC_MAX_BYTES);
|
|
955
|
+
let timedOut = false;
|
|
956
|
+
let forceKill;
|
|
957
|
+
const deadline = setTimeout(() => {
|
|
958
|
+
timedOut = true;
|
|
959
|
+
child.kill("SIGTERM");
|
|
960
|
+
forceKill = setTimeout(() => child.kill("SIGKILL"), 5e3);
|
|
961
|
+
forceKill.unref();
|
|
962
|
+
}, 30 * 6e4);
|
|
963
|
+
deadline.unref();
|
|
964
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
965
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
966
|
+
child.once("error", (error) => {
|
|
967
|
+
clearTimeout(deadline);
|
|
968
|
+
if (forceKill) clearTimeout(forceKill);
|
|
969
|
+
reject(error);
|
|
970
|
+
});
|
|
971
|
+
child.once("close", (code) => {
|
|
972
|
+
clearTimeout(deadline);
|
|
973
|
+
if (forceKill) clearTimeout(forceKill);
|
|
974
|
+
const capturedStdout = stdout.read();
|
|
975
|
+
const capturedStderr = stderr.read();
|
|
976
|
+
resolveResult({
|
|
977
|
+
stdout: capturedStdout.bytes.toString("utf8"),
|
|
978
|
+
stderr: capturedStderr.bytes.toString("utf8"),
|
|
979
|
+
exitCode: code ?? -1,
|
|
980
|
+
timedOut,
|
|
981
|
+
stdoutTruncated: capturedStdout.truncated,
|
|
982
|
+
stderrTruncated: capturedStderr.truncated
|
|
983
|
+
});
|
|
984
|
+
});
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
var defaultDependencies = {
|
|
988
|
+
createClient: (connectionUrl) => {
|
|
989
|
+
const client = new pg.Client({
|
|
990
|
+
connectionString: connectionUrl,
|
|
991
|
+
application_name: "agent-bridge-native-dr",
|
|
992
|
+
connectionTimeoutMillis: 1e4,
|
|
993
|
+
query_timeout: 6e4
|
|
994
|
+
});
|
|
995
|
+
client.on("error", () => {
|
|
996
|
+
});
|
|
997
|
+
return client;
|
|
998
|
+
},
|
|
999
|
+
resolveTool: resolvePostgresTool,
|
|
1000
|
+
runTool: runPostgresTool,
|
|
1001
|
+
now: () => /* @__PURE__ */ new Date(),
|
|
1002
|
+
randomId: () => randomUUID(),
|
|
1003
|
+
checkRowIsolationReady: (client) => rowIsolationReady(
|
|
1004
|
+
client,
|
|
1005
|
+
true
|
|
1006
|
+
),
|
|
1007
|
+
removePath: (path) => rmSync(path, { recursive: true, force: true })
|
|
1008
|
+
};
|
|
1009
|
+
function dependencies(overrides) {
|
|
1010
|
+
return { ...defaultDependencies, ...overrides };
|
|
1011
|
+
}
|
|
1012
|
+
function requiredEnvironmentUrl(environment, name) {
|
|
1013
|
+
const value = environment[name]?.trim();
|
|
1014
|
+
if (!value) throw new PostgresNativeDrError("MISSING_DATABASE_AUTHORITY", `${name} is required`);
|
|
1015
|
+
return value;
|
|
1016
|
+
}
|
|
1017
|
+
function validateBackupId(value) {
|
|
1018
|
+
const normalized = value.trim().toLowerCase();
|
|
1019
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test(normalized)) {
|
|
1020
|
+
throw new PostgresNativeDrError("INVALID_BACKUP_ID", "PostgreSQL native DR backup ID must be a UUID");
|
|
1021
|
+
}
|
|
1022
|
+
return normalized;
|
|
1023
|
+
}
|
|
1024
|
+
function sanitizedUnexpectedError(error, authorityUrl) {
|
|
1025
|
+
const message = error instanceof Error ? error.message : "unknown error";
|
|
1026
|
+
let password = "";
|
|
1027
|
+
try {
|
|
1028
|
+
password = decodeURIComponent(new URL(authorityUrl).password);
|
|
1029
|
+
} catch {
|
|
1030
|
+
}
|
|
1031
|
+
return [authorityUrl, password].filter(Boolean).reduce(
|
|
1032
|
+
(text, secret) => text.split(secret).join("[redacted]"),
|
|
1033
|
+
message
|
|
1034
|
+
).slice(0, 512);
|
|
1035
|
+
}
|
|
1036
|
+
function serverMajor(serverVersionNum) {
|
|
1037
|
+
if (!Number.isSafeInteger(serverVersionNum)) throw new PostgresNativeDrError("INVALID_SERVER_VERSION", "PostgreSQL server version is invalid");
|
|
1038
|
+
const major = Math.floor(serverVersionNum / 1e4);
|
|
1039
|
+
if (major < 15 || major > 18) throw new PostgresNativeDrError("UNSUPPORTED_SERVER_VERSION", "PostgreSQL server major must be between 15 and 18");
|
|
1040
|
+
return major;
|
|
1041
|
+
}
|
|
1042
|
+
function sha256(value) {
|
|
1043
|
+
return createHash2("sha256").update(value).digest("hex");
|
|
1044
|
+
}
|
|
1045
|
+
function validateArtifactAnchor(value, label) {
|
|
1046
|
+
assertRecord(value, label);
|
|
1047
|
+
exactKeys(value, [
|
|
1048
|
+
"descriptor",
|
|
1049
|
+
"device",
|
|
1050
|
+
"inode",
|
|
1051
|
+
"size",
|
|
1052
|
+
"ctimeNanoseconds",
|
|
1053
|
+
"mtimeNanoseconds",
|
|
1054
|
+
"sha256"
|
|
1055
|
+
], label);
|
|
1056
|
+
if (!Number.isSafeInteger(value.descriptor) || value.descriptor < 0 || typeof value.device !== "string" || !/^[0-9]+$/u.test(value.device) || typeof value.inode !== "string" || !/^[0-9]+$/u.test(value.inode) || typeof value.size !== "string" || !/^(?:0|[1-9][0-9]*)$/u.test(value.size) || typeof value.ctimeNanoseconds !== "string" || !/^[0-9]+$/u.test(value.ctimeNanoseconds) || typeof value.mtimeNanoseconds !== "string" || !/^[0-9]+$/u.test(value.mtimeNanoseconds) || typeof value.sha256 !== "string" || !/^[0-9a-f]{64}$/u.test(value.sha256)) {
|
|
1057
|
+
throw new PostgresNativeDrError("ARTIFACT_ANCHOR_INVALID", `${label} is invalid`);
|
|
1058
|
+
}
|
|
1059
|
+
return value;
|
|
1060
|
+
}
|
|
1061
|
+
function artifactStat(descriptor) {
|
|
1062
|
+
try {
|
|
1063
|
+
const stat = fstatSync2(descriptor, { bigint: true });
|
|
1064
|
+
if (!stat.isFile()) throw new Error("not a regular file");
|
|
1065
|
+
return stat;
|
|
1066
|
+
} catch {
|
|
1067
|
+
throw new PostgresNativeDrError("ARTIFACT_ANCHOR_INVALID", "PostgreSQL native DR artifact anchor is not an open regular file");
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
function sameArtifact(left, right) {
|
|
1071
|
+
return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
|
|
1072
|
+
}
|
|
1073
|
+
function statMatchesAnchor(stat, anchor) {
|
|
1074
|
+
return stat.dev.toString() === anchor.device && stat.ino.toString() === anchor.inode && stat.size.toString() === anchor.size && stat.ctimeNs.toString() === anchor.ctimeNanoseconds && stat.mtimeNs.toString() === anchor.mtimeNanoseconds;
|
|
1075
|
+
}
|
|
1076
|
+
function openAnchoredArtifact(inputPath, anchorInput) {
|
|
1077
|
+
const anchor = validateArtifactAnchor(anchorInput, "PostgreSQL artifact anchor");
|
|
1078
|
+
const anchorFileStat = artifactStat(anchor.descriptor);
|
|
1079
|
+
if (!statMatchesAnchor(anchorFileStat, anchor)) {
|
|
1080
|
+
throw new PostgresNativeDrError("ARTIFACT_IDENTITY_MISMATCH", "PostgreSQL native DR retained anchor changed after bundle verification");
|
|
1081
|
+
}
|
|
1082
|
+
const path = resolve2(inputPath);
|
|
1083
|
+
verifyPrivatePathAccess(path, "file");
|
|
1084
|
+
const pathStat = lstatSync2(path, { bigint: true });
|
|
1085
|
+
if (!pathStat.isFile() || pathStat.isSymbolicLink()) {
|
|
1086
|
+
throw new PostgresNativeDrError("ARTIFACT_TYPE_INVALID", "PostgreSQL native DR artifact must be a regular file");
|
|
1087
|
+
}
|
|
1088
|
+
const noFollow = "O_NOFOLLOW" in constants2 ? constants2.O_NOFOLLOW : 0;
|
|
1089
|
+
const descriptor = openSync2(path, constants2.O_RDONLY | noFollow);
|
|
1090
|
+
const opened = fstatSync2(descriptor, { bigint: true });
|
|
1091
|
+
if (!opened.isFile() || opened.dev !== pathStat.dev || opened.ino !== pathStat.ino || !sameArtifact(opened, anchorFileStat)) {
|
|
1092
|
+
closeSync2(descriptor);
|
|
1093
|
+
throw new PostgresNativeDrError("ARTIFACT_IDENTITY_MISMATCH", "PostgreSQL native DR artifact path does not match its retained anchor");
|
|
1094
|
+
}
|
|
1095
|
+
return { descriptor, anchorStat: anchorFileStat };
|
|
1096
|
+
}
|
|
1097
|
+
function assertAnchoredDescriptorUnchanged(descriptor, anchorStat) {
|
|
1098
|
+
if (!sameArtifact(artifactStat(descriptor), anchorStat)) {
|
|
1099
|
+
throw new PostgresNativeDrError("ARTIFACT_IDENTITY_MISMATCH", "PostgreSQL native DR artifact changed while it was in use");
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
function readAnchoredArtifact(path, anchor, maximumBytes) {
|
|
1103
|
+
const opened = openAnchoredArtifact(path, anchor);
|
|
1104
|
+
try {
|
|
1105
|
+
if (opened.anchorStat.size > BigInt(maximumBytes)) {
|
|
1106
|
+
throw new PostgresNativeDrError("ROLE_INVENTORY_TOO_LARGE", "PostgreSQL role inventory exceeds 1 MiB");
|
|
1107
|
+
}
|
|
1108
|
+
const content = Buffer.alloc(Number(opened.anchorStat.size));
|
|
1109
|
+
let offset = 0;
|
|
1110
|
+
while (offset < content.length) {
|
|
1111
|
+
const count = readSync2(opened.descriptor, content, offset, content.length - offset, null);
|
|
1112
|
+
if (count === 0) break;
|
|
1113
|
+
offset += count;
|
|
1114
|
+
}
|
|
1115
|
+
if (offset !== content.length) throw new PostgresNativeDrError("ARTIFACT_IDENTITY_MISMATCH", "PostgreSQL native DR artifact changed while it was read");
|
|
1116
|
+
assertAnchoredDescriptorUnchanged(opened.descriptor, opened.anchorStat);
|
|
1117
|
+
return content;
|
|
1118
|
+
} finally {
|
|
1119
|
+
closeSync2(opened.descriptor);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
async function runToolWithAnchoredInput(path, anchor, run) {
|
|
1123
|
+
const opened = openAnchoredArtifact(path, anchor);
|
|
1124
|
+
try {
|
|
1125
|
+
const result = await run(opened.descriptor);
|
|
1126
|
+
assertAnchoredDescriptorUnchanged(opened.descriptor, opened.anchorStat);
|
|
1127
|
+
return result;
|
|
1128
|
+
} finally {
|
|
1129
|
+
closeSync2(opened.descriptor);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
function validateArtifactAnchors(value) {
|
|
1133
|
+
assertRecord(value, "PostgreSQL artifact anchors");
|
|
1134
|
+
exactKeys(value, ["dump", "roles"], "PostgreSQL artifact anchors");
|
|
1135
|
+
return {
|
|
1136
|
+
dump: validateArtifactAnchor(value.dump, "PostgreSQL dump anchor"),
|
|
1137
|
+
roles: validateArtifactAnchor(value.roles, "PostgreSQL roles anchor")
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
function quoteSqlIdentifier(value) {
|
|
1141
|
+
if (!value || /[\u0000-\u001f\u007f]/u.test(value)) throw new Error("PostgreSQL identifier is invalid");
|
|
1142
|
+
return `"${value.split('"').join('""')}"`;
|
|
1143
|
+
}
|
|
1144
|
+
function assertToolSucceeded(result, code, tool) {
|
|
1145
|
+
if (result.timedOut) throw new PostgresNativeDrError(`${code}_TIMEOUT`, `${tool} exceeded the 30 minute execution deadline and was terminated`);
|
|
1146
|
+
if (result.exitCode !== 0) {
|
|
1147
|
+
const diagnostic = result.stderr.trim().replace(/postgres(?:ql)?:\/\/\S+/giu, "[redacted PostgreSQL URL]").replace(/password\s*=\s*\S+/giu, "password=[redacted]").slice(0, 8192);
|
|
1148
|
+
throw new PostgresNativeDrError(
|
|
1149
|
+
code,
|
|
1150
|
+
`${tool} failed with exit code ${result.exitCode}${diagnostic ? `: ${diagnostic}` : ""}`
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
function assertPostgresTocOutputComplete(result) {
|
|
1155
|
+
if (result.stdoutTruncated) {
|
|
1156
|
+
throw new PostgresNativeDrError(
|
|
1157
|
+
"PG_DUMP_TOC_TRUNCATED",
|
|
1158
|
+
"PostgreSQL dump table of contents exceeds the 16 MiB verification limit"
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
async function checkedTools(sourceMajor, libpqEnvironment, toolDirectory, deps) {
|
|
1163
|
+
const pgDump = deps.resolveTool("pg_dump", toolDirectory);
|
|
1164
|
+
const pgRestore = deps.resolveTool("pg_restore", toolDirectory);
|
|
1165
|
+
const dumpVersion = await deps.runTool(pgDump, ["--version"], libpqEnvironment);
|
|
1166
|
+
const restoreVersion = await deps.runTool(pgRestore, ["--version"], libpqEnvironment);
|
|
1167
|
+
assertToolSucceeded(dumpVersion, "PG_DUMP_VERSION_FAILED", "pg_dump --version");
|
|
1168
|
+
assertToolSucceeded(restoreVersion, "PG_RESTORE_VERSION_FAILED", "pg_restore --version");
|
|
1169
|
+
if (parsePostgresToolMajor(dumpVersion.stdout) !== sourceMajor || parsePostgresToolMajor(restoreVersion.stdout) !== sourceMajor) {
|
|
1170
|
+
throw new PostgresNativeDrError("POSTGRES_TOOL_MAJOR_MISMATCH", "pg_dump and pg_restore must exactly match the database server major");
|
|
1171
|
+
}
|
|
1172
|
+
return { pgDump, pgRestore, pgDumpVersion: dumpVersion.stdout.trim() };
|
|
1173
|
+
}
|
|
1174
|
+
async function collectPostgresRoleInventory(client, databaseName) {
|
|
1175
|
+
const roleRows = await client.query(`
|
|
1176
|
+
WITH derived_names(name) AS (
|
|
1177
|
+
SELECT prefix||substr(pg_catalog.md5(pg_catalog.current_database()),1,16)
|
|
1178
|
+
FROM unnest(ARRAY[
|
|
1179
|
+
'agent_bridge_runtime_','agent_bridge_data_owner_','agent_bridge_context_reader_',
|
|
1180
|
+
'agent_bridge_event_writer_','agent_bridge_control_owner_','agent_bridge_control_operator_',
|
|
1181
|
+
'agent_bridge_control_auditor_','agent_bridge_archive_operator_'
|
|
1182
|
+
]) prefix
|
|
1183
|
+
), object_roles AS (
|
|
1184
|
+
SELECT namespace.nspowner AS oid, 'schema-owner'::text AS kind
|
|
1185
|
+
FROM pg_catalog.pg_namespace namespace WHERE namespace.nspname='agent_bridge'
|
|
1186
|
+
UNION SELECT class.relowner, 'object-role' FROM pg_catalog.pg_class class
|
|
1187
|
+
JOIN pg_catalog.pg_namespace namespace ON namespace.oid=class.relnamespace
|
|
1188
|
+
WHERE namespace.nspname='agent_bridge'
|
|
1189
|
+
UNION SELECT procedure.proowner, 'object-role' FROM pg_catalog.pg_proc procedure
|
|
1190
|
+
JOIN pg_catalog.pg_namespace namespace ON namespace.oid=procedure.pronamespace
|
|
1191
|
+
WHERE namespace.nspname='agent_bridge'
|
|
1192
|
+
UNION SELECT type.typowner, 'object-role' FROM pg_catalog.pg_type type
|
|
1193
|
+
JOIN pg_catalog.pg_namespace namespace ON namespace.oid=type.typnamespace
|
|
1194
|
+
WHERE namespace.nspname='agent_bridge'
|
|
1195
|
+
UNION SELECT acl.grantor, 'object-role' FROM pg_catalog.pg_namespace namespace
|
|
1196
|
+
CROSS JOIN LATERAL pg_catalog.aclexplode(namespace.nspacl) acl WHERE namespace.nspname='agent_bridge'
|
|
1197
|
+
UNION SELECT acl.grantee, 'object-role' FROM pg_catalog.pg_namespace namespace
|
|
1198
|
+
CROSS JOIN LATERAL pg_catalog.aclexplode(namespace.nspacl) acl WHERE namespace.nspname='agent_bridge' AND acl.grantee<>0
|
|
1199
|
+
UNION SELECT acl.grantor, 'object-role' FROM pg_catalog.pg_class class
|
|
1200
|
+
JOIN pg_catalog.pg_namespace namespace ON namespace.oid=class.relnamespace
|
|
1201
|
+
CROSS JOIN LATERAL pg_catalog.aclexplode(class.relacl) acl WHERE namespace.nspname='agent_bridge'
|
|
1202
|
+
UNION SELECT acl.grantee, 'object-role' FROM pg_catalog.pg_class class
|
|
1203
|
+
JOIN pg_catalog.pg_namespace namespace ON namespace.oid=class.relnamespace
|
|
1204
|
+
CROSS JOIN LATERAL pg_catalog.aclexplode(class.relacl) acl WHERE namespace.nspname='agent_bridge' AND acl.grantee<>0
|
|
1205
|
+
UNION SELECT acl.grantor, 'object-role' FROM pg_catalog.pg_proc procedure
|
|
1206
|
+
JOIN pg_catalog.pg_namespace namespace ON namespace.oid=procedure.pronamespace
|
|
1207
|
+
CROSS JOIN LATERAL pg_catalog.aclexplode(procedure.proacl) acl WHERE namespace.nspname='agent_bridge'
|
|
1208
|
+
UNION SELECT acl.grantee, 'object-role' FROM pg_catalog.pg_proc procedure
|
|
1209
|
+
JOIN pg_catalog.pg_namespace namespace ON namespace.oid=procedure.pronamespace
|
|
1210
|
+
CROSS JOIN LATERAL pg_catalog.aclexplode(procedure.proacl) acl WHERE namespace.nspname='agent_bridge' AND acl.grantee<>0
|
|
1211
|
+
UNION SELECT acl.grantor, 'object-role' FROM pg_catalog.pg_type type
|
|
1212
|
+
JOIN pg_catalog.pg_namespace namespace ON namespace.oid=type.typnamespace
|
|
1213
|
+
CROSS JOIN LATERAL pg_catalog.aclexplode(type.typacl) acl WHERE namespace.nspname='agent_bridge'
|
|
1214
|
+
UNION SELECT acl.grantee, 'object-role' FROM pg_catalog.pg_type type
|
|
1215
|
+
JOIN pg_catalog.pg_namespace namespace ON namespace.oid=type.typnamespace
|
|
1216
|
+
CROSS JOIN LATERAL pg_catalog.aclexplode(type.typacl) acl WHERE namespace.nspname='agent_bridge' AND acl.grantee<>0
|
|
1217
|
+
UNION SELECT defaults.defaclrole, 'object-role' FROM pg_catalog.pg_default_acl defaults
|
|
1218
|
+
LEFT JOIN pg_catalog.pg_namespace namespace ON namespace.oid=defaults.defaclnamespace
|
|
1219
|
+
WHERE namespace.nspname='agent_bridge' OR defaults.defaclrole=(
|
|
1220
|
+
SELECT nspowner FROM pg_catalog.pg_namespace WHERE nspname='agent_bridge')
|
|
1221
|
+
OR (defaults.defaclnamespace=0 AND defaults.defaclrole=(SELECT oid FROM pg_catalog.pg_roles
|
|
1222
|
+
WHERE rolname='agent_bridge_control_owner_'||substr(pg_catalog.md5(pg_catalog.current_database()),1,16)))
|
|
1223
|
+
UNION SELECT acl.grantor, 'object-role' FROM pg_catalog.pg_default_acl defaults
|
|
1224
|
+
LEFT JOIN pg_catalog.pg_namespace namespace ON namespace.oid=defaults.defaclnamespace
|
|
1225
|
+
CROSS JOIN LATERAL pg_catalog.aclexplode(defaults.defaclacl) acl WHERE namespace.nspname='agent_bridge'
|
|
1226
|
+
OR defaults.defaclrole=(SELECT nspowner FROM pg_catalog.pg_namespace WHERE nspname='agent_bridge')
|
|
1227
|
+
OR (defaults.defaclnamespace=0 AND defaults.defaclrole=(SELECT oid FROM pg_catalog.pg_roles
|
|
1228
|
+
WHERE rolname='agent_bridge_control_owner_'||substr(pg_catalog.md5(pg_catalog.current_database()),1,16)))
|
|
1229
|
+
UNION SELECT acl.grantee, 'object-role' FROM pg_catalog.pg_default_acl defaults
|
|
1230
|
+
LEFT JOIN pg_catalog.pg_namespace namespace ON namespace.oid=defaults.defaclnamespace
|
|
1231
|
+
CROSS JOIN LATERAL pg_catalog.aclexplode(defaults.defaclacl) acl WHERE acl.grantee<>0 AND (
|
|
1232
|
+
namespace.nspname='agent_bridge' OR defaults.defaclrole=(
|
|
1233
|
+
SELECT nspowner FROM pg_catalog.pg_namespace WHERE nspname='agent_bridge')
|
|
1234
|
+
OR (defaults.defaclnamespace=0 AND defaults.defaclrole=(SELECT oid FROM pg_catalog.pg_roles
|
|
1235
|
+
WHERE rolname='agent_bridge_control_owner_'||substr(pg_catalog.md5(pg_catalog.current_database()),1,16))))
|
|
1236
|
+
), base_roles AS (
|
|
1237
|
+
SELECT role.oid, CASE WHEN role.rolname IN (SELECT name FROM derived_names)
|
|
1238
|
+
THEN 'derived' ELSE object_roles.kind END AS kind
|
|
1239
|
+
FROM pg_catalog.pg_roles role JOIN object_roles ON object_roles.oid=role.oid
|
|
1240
|
+
UNION
|
|
1241
|
+
SELECT role.oid, 'derived' FROM pg_catalog.pg_roles role
|
|
1242
|
+
WHERE role.rolname IN (SELECT name FROM derived_names)
|
|
1243
|
+
), related_roles AS (
|
|
1244
|
+
SELECT oid, kind FROM base_roles
|
|
1245
|
+
UNION SELECT membership.roleid, 'external-principal' FROM pg_catalog.pg_auth_members membership
|
|
1246
|
+
WHERE membership.member IN (SELECT oid FROM base_roles)
|
|
1247
|
+
UNION SELECT membership.member, 'external-principal' FROM pg_catalog.pg_auth_members membership
|
|
1248
|
+
WHERE membership.roleid IN (SELECT oid FROM base_roles)
|
|
1249
|
+
)
|
|
1250
|
+
SELECT role.rolname AS name,
|
|
1251
|
+
CASE WHEN bool_or(related.kind='schema-owner') THEN 'schema-owner'
|
|
1252
|
+
WHEN role.rolname IN (SELECT name FROM derived_names) THEN 'derived'
|
|
1253
|
+
WHEN bool_or(related.kind='object-role') THEN 'object-role'
|
|
1254
|
+
ELSE 'external-principal' END AS kind
|
|
1255
|
+
FROM related_roles related JOIN pg_catalog.pg_roles role ON role.oid=related.oid
|
|
1256
|
+
GROUP BY role.rolname ORDER BY role.rolname`);
|
|
1257
|
+
const names = roleRows.rows.map((row) => row.name);
|
|
1258
|
+
const membershipRows = names.length === 0 ? { rows: [], rowCount: 0 } : await client.query(`
|
|
1259
|
+
SELECT granted.rolname AS role, member.rolname AS member,
|
|
1260
|
+
membership.admin_option AS "adminOption",
|
|
1261
|
+
coalesce((to_jsonb(membership)->>'inherit_option')::boolean,true) AS "inheritOption",
|
|
1262
|
+
coalesce((to_jsonb(membership)->>'set_option')::boolean,true) AS "setOption"
|
|
1263
|
+
FROM pg_catalog.pg_auth_members membership
|
|
1264
|
+
JOIN pg_catalog.pg_roles granted ON granted.oid=membership.roleid
|
|
1265
|
+
JOIN pg_catalog.pg_roles member ON member.oid=membership.member
|
|
1266
|
+
WHERE granted.rolname=ANY($1::text[]) AND member.rolname=ANY($1::text[])
|
|
1267
|
+
ORDER BY granted.rolname,member.rolname`, [names]);
|
|
1268
|
+
if (names.length > 0) {
|
|
1269
|
+
const boundary = await client.query(`
|
|
1270
|
+
SELECT 1 AS found FROM pg_catalog.pg_auth_members membership
|
|
1271
|
+
JOIN pg_catalog.pg_roles granted ON granted.oid=membership.roleid
|
|
1272
|
+
JOIN pg_catalog.pg_roles member ON member.oid=membership.member
|
|
1273
|
+
WHERE (granted.rolname=ANY($1::text[])) <> (member.rolname=ANY($1::text[]))
|
|
1274
|
+
LIMIT 1`, [names]);
|
|
1275
|
+
if (boundary.rows.length > 0) {
|
|
1276
|
+
throw new PostgresNativeDrError(
|
|
1277
|
+
"TRANSITIVE_ROLE_MEMBERSHIP_UNSUPPORTED",
|
|
1278
|
+
"PostgreSQL role inventory has a transitive external membership outside the bounded restore graph"
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
const defaultPrivilegeRows = await client.query(`
|
|
1283
|
+
SELECT owner.rolname AS owner,
|
|
1284
|
+
namespace.nspname AS schema,
|
|
1285
|
+
defaults.defaclobjtype::text AS "objectType",
|
|
1286
|
+
grantor.rolname AS grantor,
|
|
1287
|
+
CASE WHEN acl.grantee IS NULL THEN NULL ELSE acl.grantee=0 END AS "granteePublic",
|
|
1288
|
+
grantee.rolname AS grantee,
|
|
1289
|
+
acl.privilege_type AS privilege,
|
|
1290
|
+
acl.is_grantable AS grantable
|
|
1291
|
+
FROM pg_catalog.pg_default_acl defaults
|
|
1292
|
+
JOIN pg_catalog.pg_roles owner ON owner.oid=defaults.defaclrole
|
|
1293
|
+
LEFT JOIN pg_catalog.pg_namespace namespace ON namespace.oid=defaults.defaclnamespace
|
|
1294
|
+
LEFT JOIN LATERAL pg_catalog.aclexplode(defaults.defaclacl) acl ON true
|
|
1295
|
+
LEFT JOIN pg_catalog.pg_roles grantor ON grantor.oid=acl.grantor
|
|
1296
|
+
LEFT JOIN pg_catalog.pg_roles grantee ON grantee.oid=acl.grantee
|
|
1297
|
+
WHERE namespace.nspname='agent_bridge' OR defaults.defaclrole=(
|
|
1298
|
+
SELECT nspowner FROM pg_catalog.pg_namespace WHERE nspname='agent_bridge')
|
|
1299
|
+
OR (defaults.defaclnamespace=0 AND defaults.defaclrole=(SELECT oid FROM pg_catalog.pg_roles
|
|
1300
|
+
WHERE rolname='agent_bridge_control_owner_'||substr(pg_catalog.md5(pg_catalog.current_database()),1,16)))
|
|
1301
|
+
ORDER BY owner.rolname,namespace.nspname NULLS FIRST,defaults.defaclobjtype,
|
|
1302
|
+
grantor.rolname,grantee,acl.privilege_type,acl.is_grantable`);
|
|
1303
|
+
const defaultAcls = [];
|
|
1304
|
+
for (const row of defaultPrivilegeRows.rows) {
|
|
1305
|
+
let acl = defaultAcls.find((candidate) => candidate.owner === row.owner && candidate.schema === row.schema && candidate.objectType === row.objectType);
|
|
1306
|
+
if (!acl) {
|
|
1307
|
+
acl = { owner: row.owner, schema: row.schema, objectType: row.objectType, grants: [] };
|
|
1308
|
+
defaultAcls.push(acl);
|
|
1309
|
+
}
|
|
1310
|
+
if (row.grantor !== null || row.granteePublic !== null || row.grantee !== null || row.privilege !== null || row.grantable !== null) {
|
|
1311
|
+
if (row.grantor === null || row.granteePublic === null || row.privilege === null || row.grantable === null || !row.granteePublic && row.grantee === null) {
|
|
1312
|
+
throw new PostgresNativeDrError("DEFAULT_ACL_INVALID", "PostgreSQL default ACL has an incomplete privilege record");
|
|
1313
|
+
}
|
|
1314
|
+
acl.grants.push({
|
|
1315
|
+
grantor: row.grantor,
|
|
1316
|
+
...row.granteePublic ? { granteeKind: "public" } : { granteeKind: "role", grantee: row.grantee },
|
|
1317
|
+
privilege: row.privilege,
|
|
1318
|
+
grantable: row.grantable
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
return validatePostgresRoleInventory({
|
|
1323
|
+
schema: POSTGRES_NATIVE_DR_ROLE_SCHEMA,
|
|
1324
|
+
version: POSTGRES_NATIVE_DR_ROLE_VERSION,
|
|
1325
|
+
databaseName,
|
|
1326
|
+
roles: roleRows.rows,
|
|
1327
|
+
memberships: membershipRows.rows,
|
|
1328
|
+
defaultAcls
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
async function collectSnapshotMetadata(client, checkRowReady) {
|
|
1332
|
+
const metadata = await client.query(
|
|
1333
|
+
`SELECT current_database() AS "databaseName", current_setting('server_version_num') AS "serverVersionNum"`
|
|
1334
|
+
);
|
|
1335
|
+
const databaseName = metadata.rows[0]?.databaseName;
|
|
1336
|
+
const serverVersionNum = Number(metadata.rows[0]?.serverVersionNum);
|
|
1337
|
+
if (!databaseName) throw new PostgresNativeDrError("SOURCE_METADATA_INVALID", "source database metadata is unavailable");
|
|
1338
|
+
serverMajor(serverVersionNum);
|
|
1339
|
+
const migrationRows = await client.query(
|
|
1340
|
+
`SELECT version,name,checksum FROM agent_bridge.schema_migrations ORDER BY version`
|
|
1341
|
+
);
|
|
1342
|
+
const migrations = migrationRows.rows.map((row) => ({
|
|
1343
|
+
version: Number(row.version),
|
|
1344
|
+
name: row.name,
|
|
1345
|
+
checksum: row.checksum
|
|
1346
|
+
}));
|
|
1347
|
+
for (const migration of migrations) {
|
|
1348
|
+
if (!Number.isSafeInteger(migration.version) || migration.version < 1 || !/^[a-z0-9_]+$/u.test(migration.name) || !/^[0-9a-f]{64}$/u.test(migration.checksum)) {
|
|
1349
|
+
throw new PostgresNativeDrError("SOURCE_MIGRATION_INVALID", "source migration inventory is invalid");
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
if (migrations.length === 0) throw new PostgresNativeDrError("SOURCE_SCHEMA_EMPTY", "source has no Agent Bridge migrations");
|
|
1353
|
+
const tableRows = await client.query(`
|
|
1354
|
+
SELECT table_name AS "tableName" FROM information_schema.tables
|
|
1355
|
+
WHERE table_schema='agent_bridge' AND table_type='BASE TABLE' ORDER BY table_name`);
|
|
1356
|
+
const tableCounts = {};
|
|
1357
|
+
for (const row of tableRows.rows) {
|
|
1358
|
+
const qualified = `agent_bridge.${row.tableName}`;
|
|
1359
|
+
const count = await client.query(
|
|
1360
|
+
`SELECT count(*)::text AS count FROM agent_bridge.${quoteSqlIdentifier(row.tableName)}`
|
|
1361
|
+
);
|
|
1362
|
+
const value = count.rows[0]?.count;
|
|
1363
|
+
if (!value || !/^(?:0|[1-9][0-9]*)$/u.test(value)) throw new PostgresNativeDrError("SOURCE_COUNT_INVALID", `source count is invalid for ${qualified}`);
|
|
1364
|
+
tableCounts[qualified] = value;
|
|
1365
|
+
}
|
|
1366
|
+
const claimed = await client.query(
|
|
1367
|
+
`SELECT count(*)::text AS count FROM agent_bridge.deliveries WHERE state='claimed'`
|
|
1368
|
+
);
|
|
1369
|
+
const claimedDeliveryCount = claimed.rows[0]?.count;
|
|
1370
|
+
if (!claimedDeliveryCount || !/^(?:0|[1-9][0-9]*)$/u.test(claimedDeliveryCount)) {
|
|
1371
|
+
throw new PostgresNativeDrError("SOURCE_COUNT_INVALID", "source claimed delivery count is invalid");
|
|
1372
|
+
}
|
|
1373
|
+
const readiness = await client.query(`
|
|
1374
|
+
SELECT agent_bridge.security_schema_ready() AS security,
|
|
1375
|
+
agent_bridge.owner_control_plane_ready() AS "ownerControl",
|
|
1376
|
+
agent_bridge.portable_archive_ready() AS "portableArchive",
|
|
1377
|
+
agent_bridge.credential_security_prerequisite_definition() AS "securityDefinition",
|
|
1378
|
+
agent_bridge.row_isolation_catalog_definition() AS "rowIsolationDefinition",
|
|
1379
|
+
agent_bridge.owner_control_attestation_definition() AS "ownerControlDefinition",
|
|
1380
|
+
agent_bridge.portable_archive_attestation_definition() AS "portableArchiveDefinition"`);
|
|
1381
|
+
const ready = readiness.rows[0];
|
|
1382
|
+
const rowReady = await checkRowReady(client);
|
|
1383
|
+
if (!ready?.security || !rowReady || !ready.ownerControl || !ready.portableArchive || typeof ready.securityDefinition !== "string" || typeof ready.rowIsolationDefinition !== "string" || typeof ready.ownerControlDefinition !== "string" || typeof ready.portableArchiveDefinition !== "string") {
|
|
1384
|
+
throw new PostgresNativeDrError("SOURCE_NOT_READY", "source PostgreSQL schema failed readiness attestation");
|
|
1385
|
+
}
|
|
1386
|
+
return {
|
|
1387
|
+
databaseName,
|
|
1388
|
+
serverVersionNum,
|
|
1389
|
+
migrations,
|
|
1390
|
+
tableCounts,
|
|
1391
|
+
claimedDeliveryCount,
|
|
1392
|
+
readinessAttestations: {
|
|
1393
|
+
securitySchemaSha256: sha256(ready.securityDefinition),
|
|
1394
|
+
rowIsolationSha256: sha256(ready.rowIsolationDefinition),
|
|
1395
|
+
ownerControlSha256: sha256(ready.ownerControlDefinition),
|
|
1396
|
+
portableArchiveSha256: sha256(ready.portableArchiveDefinition)
|
|
1397
|
+
}
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
function createPrivateEmptyFile(path) {
|
|
1401
|
+
const descriptor = openSync2(path, constants2.O_WRONLY | constants2.O_CREAT | constants2.O_EXCL, 384);
|
|
1402
|
+
closeSync2(descriptor);
|
|
1403
|
+
securePrivatePath(path, "file");
|
|
1404
|
+
verifyPrivatePathAccess(path, "file");
|
|
1405
|
+
}
|
|
1406
|
+
async function backupPostgresNativeDr(options) {
|
|
1407
|
+
const env = options.environment ?? process.env;
|
|
1408
|
+
const sourceUrl = requiredEnvironmentUrl(env, "AGENT_BRIDGE_DR_SOURCE_DATABASE_URL");
|
|
1409
|
+
const stage = resolve2(options.stagingDirectory);
|
|
1410
|
+
verifyPrivatePathAccess(stage, "directory");
|
|
1411
|
+
const deps = dependencies(options.dependencies);
|
|
1412
|
+
const libpqDirectory = mkdtempSync(join(stage, ".postgres-dr-libpq-"));
|
|
1413
|
+
securePrivatePath(libpqDirectory, "directory");
|
|
1414
|
+
verifyPrivatePathAccess(libpqDirectory, "directory");
|
|
1415
|
+
const dumpPath = join(stage, "postgres-database.dump");
|
|
1416
|
+
const rolesPath = join(stage, "postgres-roles.json");
|
|
1417
|
+
let source;
|
|
1418
|
+
let postInventoryClient;
|
|
1419
|
+
let lockHeld = false;
|
|
1420
|
+
let transactionOpen = false;
|
|
1421
|
+
let primaryFailure;
|
|
1422
|
+
let backupCompleted = false;
|
|
1423
|
+
try {
|
|
1424
|
+
const libpq = createPostgresDrLibpqFiles(sourceUrl, libpqDirectory);
|
|
1425
|
+
createPrivateEmptyFile(dumpPath);
|
|
1426
|
+
source = deps.createClient(sourceUrl);
|
|
1427
|
+
await source.connect();
|
|
1428
|
+
await source.query("SELECT pg_catalog.pg_advisory_lock($1)", [NATIVE_DR_ADVISORY_LOCK_KEY]);
|
|
1429
|
+
lockHeld = true;
|
|
1430
|
+
await source.query("BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY");
|
|
1431
|
+
transactionOpen = true;
|
|
1432
|
+
const snapshotResult = await source.query("SELECT pg_catalog.pg_export_snapshot() AS snapshot");
|
|
1433
|
+
const snapshot = snapshotResult.rows[0]?.snapshot;
|
|
1434
|
+
if (!snapshot) throw new PostgresNativeDrError("SNAPSHOT_EXPORT_FAILED", "PostgreSQL did not export a disaster recovery snapshot");
|
|
1435
|
+
const metadata = await collectSnapshotMetadata(source, deps.checkRowIsolationReady);
|
|
1436
|
+
const major = serverMajor(metadata.serverVersionNum);
|
|
1437
|
+
const beforeInventory = await collectPostgresRoleInventory(source, metadata.databaseName);
|
|
1438
|
+
const canonicalRoles = canonicalPostgresRoleInventory(beforeInventory);
|
|
1439
|
+
const tools = await checkedTools(major, libpq.environment, options.toolDirectory, deps);
|
|
1440
|
+
const dump = await deps.runTool(tools.pgDump, buildPgDumpArgs(dumpPath, snapshot, libpq.serviceName), libpq.environment);
|
|
1441
|
+
assertToolSucceeded(dump, "PG_DUMP_FAILED", "pg_dump");
|
|
1442
|
+
verifyPrivatePathAccess(dumpPath, "file");
|
|
1443
|
+
if (statSync(dumpPath).size === 0) throw new PostgresNativeDrError("PG_DUMP_EMPTY", "pg_dump produced an empty file");
|
|
1444
|
+
const listed = await deps.runTool(tools.pgRestore, buildPgRestoreListArgs(dumpPath), libpq.environment);
|
|
1445
|
+
assertToolSucceeded(listed, "PG_RESTORE_LIST_FAILED", "pg_restore --list");
|
|
1446
|
+
assertPostgresTocOutputComplete(listed);
|
|
1447
|
+
validatePostgresDumpToc(listed.stdout);
|
|
1448
|
+
schemaAclRestoreList(listed.stdout);
|
|
1449
|
+
postInventoryClient = deps.createClient(sourceUrl);
|
|
1450
|
+
await postInventoryClient.connect();
|
|
1451
|
+
const afterInventory = await collectPostgresRoleInventory(postInventoryClient, metadata.databaseName);
|
|
1452
|
+
if (canonicalPostgresRoleInventory(afterInventory) !== canonicalRoles) {
|
|
1453
|
+
throw new PostgresNativeDrError("ROLE_INVENTORY_DRIFT", "PostgreSQL role inventory changed while the native DR fence was held");
|
|
1454
|
+
}
|
|
1455
|
+
writeFileSync(rolesPath, canonicalRoles, { encoding: "utf8", mode: 384, flag: "wx" });
|
|
1456
|
+
securePrivatePath(rolesPath, "file");
|
|
1457
|
+
verifyPrivatePathAccess(rolesPath, "file");
|
|
1458
|
+
await source.query("COMMIT");
|
|
1459
|
+
transactionOpen = false;
|
|
1460
|
+
await source.query("SELECT pg_catalog.pg_advisory_unlock($1)", [NATIVE_DR_ADVISORY_LOCK_KEY]);
|
|
1461
|
+
lockHeld = false;
|
|
1462
|
+
const schemaVersion = Math.max(...metadata.migrations.map((migration) => migration.version));
|
|
1463
|
+
const result = {
|
|
1464
|
+
backupId: validateBackupId(options.backupId ?? deps.randomId()),
|
|
1465
|
+
createdAt: deps.now().toISOString(),
|
|
1466
|
+
kind: "postgres",
|
|
1467
|
+
schema: {
|
|
1468
|
+
databaseName: metadata.databaseName,
|
|
1469
|
+
serverVersionNum: metadata.serverVersionNum,
|
|
1470
|
+
serverMajor: major,
|
|
1471
|
+
schemaVersion,
|
|
1472
|
+
migrations: metadata.migrations,
|
|
1473
|
+
tableCounts: metadata.tableCounts,
|
|
1474
|
+
excludedDataTables: [...POSTGRES_NATIVE_DR_EXCLUDED_DATA_TABLES],
|
|
1475
|
+
claimedDeliveryCount: metadata.claimedDeliveryCount,
|
|
1476
|
+
pgDumpVersion: tools.pgDumpVersion,
|
|
1477
|
+
roleInventorySha256: sha256(canonicalRoles),
|
|
1478
|
+
readinessAttestations: metadata.readinessAttestations
|
|
1479
|
+
},
|
|
1480
|
+
entries: [
|
|
1481
|
+
{ name: "postgres/database.dump", path: dumpPath },
|
|
1482
|
+
{ name: "postgres/roles.json", path: rolesPath }
|
|
1483
|
+
]
|
|
1484
|
+
};
|
|
1485
|
+
backupCompleted = true;
|
|
1486
|
+
return result;
|
|
1487
|
+
} catch (error) {
|
|
1488
|
+
if (transactionOpen && source) await source.query("ROLLBACK").catch(() => void 0);
|
|
1489
|
+
const recoveryPaths = [];
|
|
1490
|
+
for (const path of [dumpPath, rolesPath]) {
|
|
1491
|
+
try {
|
|
1492
|
+
deps.removePath(path);
|
|
1493
|
+
} catch {
|
|
1494
|
+
if (existsSync(path)) recoveryPaths.push(path);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
if (recoveryPaths.length > 0) {
|
|
1498
|
+
const original = error instanceof PostgresNativeDrError ? error.code : "POSTGRES_NATIVE_BACKUP_FAILED";
|
|
1499
|
+
primaryFailure = new PostgresNativeDrError(
|
|
1500
|
+
"BACKUP_CLEANUP_FAILED",
|
|
1501
|
+
`PostgreSQL native backup failed after ${original} and confidential staging files could not be removed`,
|
|
1502
|
+
{ recoveryPaths, causeCode: original }
|
|
1503
|
+
);
|
|
1504
|
+
throw primaryFailure;
|
|
1505
|
+
}
|
|
1506
|
+
if (error instanceof PostgresNativeDrError) primaryFailure = error;
|
|
1507
|
+
throw error;
|
|
1508
|
+
} finally {
|
|
1509
|
+
if (lockHeld && source) await source.query("SELECT pg_catalog.pg_advisory_unlock($1)", [NATIVE_DR_ADVISORY_LOCK_KEY]).catch(() => void 0);
|
|
1510
|
+
await postInventoryClient?.end().catch(() => void 0);
|
|
1511
|
+
await source?.end().catch(() => void 0);
|
|
1512
|
+
try {
|
|
1513
|
+
deps.removePath(libpqDirectory);
|
|
1514
|
+
} catch {
|
|
1515
|
+
if (existsSync(libpqDirectory)) {
|
|
1516
|
+
const recoveryPaths = [libpqDirectory, dumpPath, rolesPath].filter(existsSync);
|
|
1517
|
+
throw new PostgresNativeDrError(
|
|
1518
|
+
"CREDENTIAL_CLEANUP_FAILED",
|
|
1519
|
+
`PostgreSQL native backup ${backupCompleted ? "completed" : "failed"}, but its credential directory could not be removed`,
|
|
1520
|
+
{ recoveryPaths, causeCode: primaryFailure?.code }
|
|
1521
|
+
);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
function validatePostgresNativeDrSchema(value) {
|
|
1527
|
+
assertRecord(value, "PostgreSQL native DR schema");
|
|
1528
|
+
exactKeys(value, [
|
|
1529
|
+
"databaseName",
|
|
1530
|
+
"serverVersionNum",
|
|
1531
|
+
"serverMajor",
|
|
1532
|
+
"schemaVersion",
|
|
1533
|
+
"migrations",
|
|
1534
|
+
"tableCounts",
|
|
1535
|
+
"excludedDataTables",
|
|
1536
|
+
"claimedDeliveryCount",
|
|
1537
|
+
"pgDumpVersion",
|
|
1538
|
+
"roleInventorySha256",
|
|
1539
|
+
"readinessAttestations"
|
|
1540
|
+
], "PostgreSQL native DR schema");
|
|
1541
|
+
if (typeof value.databaseName !== "string" || !ROLE_NAME.test(value.databaseName)) throw new PostgresNativeDrError("INVALID_MANIFEST", "PostgreSQL database name is invalid");
|
|
1542
|
+
if (!Number.isSafeInteger(value.serverVersionNum) || serverMajor(value.serverVersionNum) !== value.serverMajor) throw new PostgresNativeDrError("INVALID_MANIFEST", "PostgreSQL server version is inconsistent");
|
|
1543
|
+
if (!Number.isSafeInteger(value.schemaVersion) || value.schemaVersion < 1) throw new PostgresNativeDrError("INVALID_MANIFEST", "PostgreSQL schema version is invalid");
|
|
1544
|
+
if (!Array.isArray(value.migrations) || value.migrations.length === 0) throw new PostgresNativeDrError("INVALID_MANIFEST", "PostgreSQL migrations are missing");
|
|
1545
|
+
let previous = 0;
|
|
1546
|
+
for (const migration of value.migrations) {
|
|
1547
|
+
assertRecord(migration, "PostgreSQL migration");
|
|
1548
|
+
exactKeys(migration, ["version", "name", "checksum"], "PostgreSQL migration");
|
|
1549
|
+
if (!Number.isSafeInteger(migration.version) || migration.version <= previous || !/^[a-z0-9_]+$/u.test(migration.name) || !/^[0-9a-f]{64}$/u.test(migration.checksum)) {
|
|
1550
|
+
throw new PostgresNativeDrError("INVALID_MANIFEST", "PostgreSQL migration inventory is invalid");
|
|
1551
|
+
}
|
|
1552
|
+
previous = migration.version;
|
|
1553
|
+
}
|
|
1554
|
+
if (previous !== value.schemaVersion) throw new PostgresNativeDrError("INVALID_MANIFEST", "PostgreSQL schema version does not match migrations");
|
|
1555
|
+
assertRecord(value.tableCounts, "PostgreSQL table counts");
|
|
1556
|
+
for (const [table, count] of Object.entries(value.tableCounts)) {
|
|
1557
|
+
if (!/^agent_bridge\.[a-z][a-z0-9_]*$/u.test(table) || typeof count !== "string" || !/^(?:0|[1-9][0-9]*)$/u.test(count)) {
|
|
1558
|
+
throw new PostgresNativeDrError("INVALID_MANIFEST", "PostgreSQL table count inventory is invalid");
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
for (const table of ["agent_bridge.deliveries", "agent_bridge.delivery_events", ...POSTGRES_NATIVE_DR_EXCLUDED_DATA_TABLES]) {
|
|
1562
|
+
if (!(table in value.tableCounts)) throw new PostgresNativeDrError("INVALID_MANIFEST", `PostgreSQL table count is missing for ${table}`);
|
|
1563
|
+
}
|
|
1564
|
+
if (JSON.stringify(value.excludedDataTables) !== JSON.stringify(POSTGRES_NATIVE_DR_EXCLUDED_DATA_TABLES)) {
|
|
1565
|
+
throw new PostgresNativeDrError("INVALID_MANIFEST", "PostgreSQL excluded table inventory is invalid");
|
|
1566
|
+
}
|
|
1567
|
+
if (!/^(?:0|[1-9][0-9]*)$/u.test(value.claimedDeliveryCount)) throw new PostgresNativeDrError("INVALID_MANIFEST", "PostgreSQL claimed delivery count is invalid");
|
|
1568
|
+
if (!/^[0-9a-f]{64}$/u.test(value.roleInventorySha256)) throw new PostgresNativeDrError("INVALID_MANIFEST", "PostgreSQL role inventory digest is invalid");
|
|
1569
|
+
assertRecord(value.readinessAttestations, "PostgreSQL readiness attestations");
|
|
1570
|
+
exactKeys(value.readinessAttestations, [
|
|
1571
|
+
"securitySchemaSha256",
|
|
1572
|
+
"rowIsolationSha256",
|
|
1573
|
+
"ownerControlSha256",
|
|
1574
|
+
"portableArchiveSha256"
|
|
1575
|
+
], "PostgreSQL readiness attestations");
|
|
1576
|
+
for (const digest of Object.values(value.readinessAttestations)) {
|
|
1577
|
+
if (typeof digest !== "string" || !/^[0-9a-f]{64}$/u.test(digest)) {
|
|
1578
|
+
throw new PostgresNativeDrError("INVALID_MANIFEST", "PostgreSQL readiness attestation digest is invalid");
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
if (parsePostgresToolMajor(value.pgDumpVersion) !== value.serverMajor) throw new PostgresNativeDrError("INVALID_MANIFEST", "PostgreSQL dump version does not match the source server");
|
|
1582
|
+
return value;
|
|
1583
|
+
}
|
|
1584
|
+
async function verifyPostgresNativeDrArtifacts(options) {
|
|
1585
|
+
const schema = validatePostgresNativeDrSchema(options.schema);
|
|
1586
|
+
const anchors = validateArtifactAnchors(options.artifactAnchors);
|
|
1587
|
+
const dumpPath = resolve2(options.dumpPath);
|
|
1588
|
+
const rolesPath = resolve2(options.rolesPath);
|
|
1589
|
+
if (anchors.roles.sha256 !== schema.roleInventorySha256) {
|
|
1590
|
+
throw new PostgresNativeDrError("ROLE_INVENTORY_DIGEST_MISMATCH", "PostgreSQL role inventory digest does not match the manifest");
|
|
1591
|
+
}
|
|
1592
|
+
const rolesText = readAnchoredArtifact(rolesPath, anchors.roles, 1024 * 1024).toString("utf8");
|
|
1593
|
+
let parsedRoles;
|
|
1594
|
+
try {
|
|
1595
|
+
parsedRoles = JSON.parse(rolesText);
|
|
1596
|
+
} catch {
|
|
1597
|
+
throw new PostgresNativeDrError("ROLE_INVENTORY_INVALID", "PostgreSQL role inventory is not valid JSON");
|
|
1598
|
+
}
|
|
1599
|
+
const roleInventory = validatePostgresRoleInventory(parsedRoles);
|
|
1600
|
+
if (canonicalPostgresRoleInventory(roleInventory) !== rolesText) {
|
|
1601
|
+
throw new PostgresNativeDrError("ROLE_INVENTORY_NOT_CANONICAL", "PostgreSQL role inventory is not in canonical form");
|
|
1602
|
+
}
|
|
1603
|
+
if (roleInventory.databaseName !== schema.databaseName) {
|
|
1604
|
+
throw new PostgresNativeDrError("DATABASE_NAME_MISMATCH", "PostgreSQL role inventory database does not match the manifest");
|
|
1605
|
+
}
|
|
1606
|
+
const deps = dependencies(options.dependencies);
|
|
1607
|
+
const environment = { LC_ALL: "C" };
|
|
1608
|
+
const pgRestore = deps.resolveTool("pg_restore", options.toolDirectory);
|
|
1609
|
+
const version = await deps.runTool(pgRestore, ["--version"], environment);
|
|
1610
|
+
assertToolSucceeded(version, "PG_RESTORE_VERSION_FAILED", "pg_restore --version");
|
|
1611
|
+
if (parsePostgresToolMajor(version.stdout) !== schema.serverMajor) {
|
|
1612
|
+
throw new PostgresNativeDrError("POSTGRES_TOOL_MAJOR_MISMATCH", "pg_restore must exactly match the backup server major");
|
|
1613
|
+
}
|
|
1614
|
+
const listed = await runToolWithAnchoredInput(dumpPath, anchors.dump, (descriptor) => deps.runTool(pgRestore, buildPgRestoreListArgs(), environment, descriptor));
|
|
1615
|
+
assertToolSucceeded(listed, "PG_RESTORE_LIST_FAILED", "pg_restore --list");
|
|
1616
|
+
assertPostgresTocOutputComplete(listed);
|
|
1617
|
+
validatePostgresDumpToc(listed.stdout);
|
|
1618
|
+
schemaAclRestoreList(listed.stdout);
|
|
1619
|
+
return {
|
|
1620
|
+
schema,
|
|
1621
|
+
roleInventory,
|
|
1622
|
+
artifactAnchors: anchors,
|
|
1623
|
+
dumpTocVerified: true,
|
|
1624
|
+
dumpToc: listed.stdout
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
async function targetMetadata(client) {
|
|
1628
|
+
const result = await client.query(`
|
|
1629
|
+
SELECT current_database() AS "databaseName",
|
|
1630
|
+
current_setting('server_version_num') AS "serverVersionNum",
|
|
1631
|
+
pg_catalog.to_regnamespace('agent_bridge') IS NOT NULL AS "schemaExists",
|
|
1632
|
+
NOT EXISTS (
|
|
1633
|
+
SELECT 1 FROM pg_catalog.pg_namespace namespace
|
|
1634
|
+
WHERE namespace.nspname NOT IN ('pg_catalog','information_schema','pg_toast','public')
|
|
1635
|
+
AND namespace.nspname NOT LIKE 'pg_toast_temp_%'
|
|
1636
|
+
AND namespace.nspname NOT LIKE 'pg_temp_%'
|
|
1637
|
+
) AND NOT EXISTS (
|
|
1638
|
+
SELECT 1 FROM pg_catalog.pg_class class
|
|
1639
|
+
JOIN pg_catalog.pg_namespace namespace ON namespace.oid=class.relnamespace
|
|
1640
|
+
WHERE namespace.nspname='public'
|
|
1641
|
+
) AND NOT EXISTS (
|
|
1642
|
+
SELECT 1 FROM pg_catalog.pg_proc procedure
|
|
1643
|
+
JOIN pg_catalog.pg_namespace namespace ON namespace.oid=procedure.pronamespace
|
|
1644
|
+
WHERE namespace.nspname='public'
|
|
1645
|
+
) AND NOT EXISTS (
|
|
1646
|
+
SELECT 1 FROM pg_catalog.pg_type type
|
|
1647
|
+
JOIN pg_catalog.pg_namespace namespace ON namespace.oid=type.typnamespace
|
|
1648
|
+
WHERE namespace.nspname='public' AND type.typtype<>'p'
|
|
1649
|
+
) AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_default_acl) AS "databaseFresh"`);
|
|
1650
|
+
const row = result.rows[0];
|
|
1651
|
+
if (!row) throw new PostgresNativeDrError("TARGET_METADATA_INVALID", "target database metadata is unavailable");
|
|
1652
|
+
return {
|
|
1653
|
+
databaseName: row.databaseName,
|
|
1654
|
+
serverVersionNum: Number(row.serverVersionNum),
|
|
1655
|
+
schemaExists: row.schemaExists,
|
|
1656
|
+
databaseFresh: row.databaseFresh
|
|
1657
|
+
};
|
|
1658
|
+
}
|
|
1659
|
+
async function targetTableCounts(client) {
|
|
1660
|
+
const tableRows = await client.query(`
|
|
1661
|
+
SELECT table_name AS "tableName" FROM information_schema.tables
|
|
1662
|
+
WHERE table_schema='agent_bridge' AND table_type='BASE TABLE' ORDER BY table_name`);
|
|
1663
|
+
const counts = {};
|
|
1664
|
+
for (const row of tableRows.rows) {
|
|
1665
|
+
const result = await client.query(
|
|
1666
|
+
`SELECT count(*)::text AS count FROM agent_bridge.${quoteSqlIdentifier(row.tableName)}`
|
|
1667
|
+
);
|
|
1668
|
+
counts[`agent_bridge.${row.tableName}`] = result.rows[0]?.count ?? "";
|
|
1669
|
+
}
|
|
1670
|
+
return counts;
|
|
1671
|
+
}
|
|
1672
|
+
function expectedRestoredTableCounts(schema) {
|
|
1673
|
+
const expected = { ...schema.tableCounts };
|
|
1674
|
+
for (const table of POSTGRES_NATIVE_DR_EXCLUDED_DATA_TABLES) expected[table] = "0";
|
|
1675
|
+
const deliveryEvents = expected["agent_bridge.delivery_events"];
|
|
1676
|
+
if (deliveryEvents === void 0) throw new PostgresNativeDrError("INVALID_MANIFEST", "PostgreSQL delivery event count is missing");
|
|
1677
|
+
expected["agent_bridge.delivery_events"] = (BigInt(deliveryEvents) + BigInt(schema.claimedDeliveryCount)).toString();
|
|
1678
|
+
return expected;
|
|
1679
|
+
}
|
|
1680
|
+
function compareStringMaps(actual, expected, label) {
|
|
1681
|
+
const actualKeys = Object.keys(actual).sort();
|
|
1682
|
+
const expectedKeys = Object.keys(expected).sort();
|
|
1683
|
+
if (JSON.stringify(actualKeys) !== JSON.stringify(expectedKeys)) throw new PostgresNativeDrError("RESTORE_COUNT_MISMATCH", `${label} table set does not match the backup`);
|
|
1684
|
+
for (const key of expectedKeys) {
|
|
1685
|
+
if (actual[key] !== expected[key]) throw new PostgresNativeDrError("RESTORE_COUNT_MISMATCH", `${label} count does not match for ${key}`);
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
function administrativeDatabaseUrl(targetUrl, targetDatabase) {
|
|
1689
|
+
const url = new URL(targetUrl);
|
|
1690
|
+
url.pathname = `/${targetDatabase === "postgres" ? "template1" : "postgres"}`;
|
|
1691
|
+
return url.toString();
|
|
1692
|
+
}
|
|
1693
|
+
async function assertPostgresRestoreAuthority(client) {
|
|
1694
|
+
const result = await client.query(`
|
|
1695
|
+
SELECT role.rolsuper AS "isSuperuser"
|
|
1696
|
+
FROM pg_catalog.pg_roles role
|
|
1697
|
+
WHERE role.rolname=current_user`);
|
|
1698
|
+
const authority = result.rows[0];
|
|
1699
|
+
if (!authority?.isSuperuser) {
|
|
1700
|
+
throw new PostgresNativeDrError(
|
|
1701
|
+
"TARGET_AUTHORITY_INSUFFICIENT",
|
|
1702
|
+
"PostgreSQL native restore v1 requires a superuser DBA authority"
|
|
1703
|
+
);
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
async function markTargetOffline(client, databaseName) {
|
|
1707
|
+
try {
|
|
1708
|
+
await client.query(`ALTER DATABASE ${quoteSqlIdentifier(databaseName)} WITH ALLOW_CONNECTIONS false`);
|
|
1709
|
+
await client.query(`SELECT pg_catalog.pg_terminate_backend(pid)
|
|
1710
|
+
FROM pg_catalog.pg_stat_activity WHERE datname=$1`, [databaseName]);
|
|
1711
|
+
return true;
|
|
1712
|
+
} catch {
|
|
1713
|
+
return false;
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
async function restorePostgresNativeDrInternal(options) {
|
|
1717
|
+
if (options.acceptSourceSqlRisk !== true) {
|
|
1718
|
+
throw new PostgresNativeDrError("SOURCE_SQL_RISK_NOT_ACCEPTED", "PostgreSQL native restore requires explicit acceptance of source SQL risk");
|
|
1719
|
+
}
|
|
1720
|
+
const schema = validatePostgresNativeDrSchema(options.schema);
|
|
1721
|
+
const env = options.environment ?? process.env;
|
|
1722
|
+
const targetUrl = requiredEnvironmentUrl(env, "AGENT_BRIDGE_DR_TARGET_DATABASE_URL");
|
|
1723
|
+
const dumpPath = resolve2(options.dumpPath);
|
|
1724
|
+
const rolesPath = resolve2(options.rolesPath);
|
|
1725
|
+
const deps = dependencies(options.dependencies);
|
|
1726
|
+
const verifiedArtifacts = await verifyPostgresNativeDrArtifacts({
|
|
1727
|
+
dumpPath,
|
|
1728
|
+
rolesPath,
|
|
1729
|
+
schema,
|
|
1730
|
+
artifactAnchors: options.artifactAnchors,
|
|
1731
|
+
toolDirectory: options.toolDirectory,
|
|
1732
|
+
dependencies: deps
|
|
1733
|
+
});
|
|
1734
|
+
const inventory = verifiedArtifacts.roleInventory;
|
|
1735
|
+
const libpqParent = resolve2(rolesPath, "..");
|
|
1736
|
+
verifyPrivatePathAccess(libpqParent, "directory");
|
|
1737
|
+
const libpqDirectory = mkdtempSync(join(libpqParent, ".postgres-dr-libpq-"));
|
|
1738
|
+
securePrivatePath(libpqDirectory, "directory");
|
|
1739
|
+
verifyPrivatePathAccess(libpqDirectory, "directory");
|
|
1740
|
+
let target;
|
|
1741
|
+
let admin;
|
|
1742
|
+
let transactionOpen = false;
|
|
1743
|
+
const createdRoles = [];
|
|
1744
|
+
let targetName = schema.databaseName;
|
|
1745
|
+
let restoreStarted = false;
|
|
1746
|
+
let restoreLockHeld = false;
|
|
1747
|
+
let restoreCompleted = false;
|
|
1748
|
+
let primaryFailure;
|
|
1749
|
+
let phase = "target preflight";
|
|
1750
|
+
try {
|
|
1751
|
+
const libpq = createPostgresDrLibpqFiles(targetUrl, libpqDirectory);
|
|
1752
|
+
target = deps.createClient(targetUrl);
|
|
1753
|
+
await target.connect();
|
|
1754
|
+
const restoreLock = await target.query(
|
|
1755
|
+
"SELECT pg_catalog.pg_try_advisory_lock($1) AS acquired",
|
|
1756
|
+
[NATIVE_DR_ADVISORY_LOCK_KEY]
|
|
1757
|
+
);
|
|
1758
|
+
if (!restoreLock.rows[0]?.acquired) throw new PostgresNativeDrError("RESTORE_IN_PROGRESS", "another native restore already holds the target fence");
|
|
1759
|
+
restoreLockHeld = true;
|
|
1760
|
+
const metadata = await targetMetadata(target);
|
|
1761
|
+
targetName = metadata.databaseName;
|
|
1762
|
+
const major = serverMajor(metadata.serverVersionNum);
|
|
1763
|
+
if (metadata.databaseName !== schema.databaseName) throw new PostgresNativeDrError("DATABASE_NAME_MISMATCH", "target database name must exactly match the backup");
|
|
1764
|
+
if (major !== schema.serverMajor) throw new PostgresNativeDrError("SERVER_MAJOR_MISMATCH", "target PostgreSQL major must exactly match the backup");
|
|
1765
|
+
if (metadata.schemaExists || !metadata.databaseFresh) {
|
|
1766
|
+
throw new PostgresNativeDrError("TARGET_NOT_FRESH", "target must be a dedicated fresh database without user objects or default ACLs");
|
|
1767
|
+
}
|
|
1768
|
+
const conflicts = await target.query(
|
|
1769
|
+
`SELECT rolname AS name FROM pg_catalog.pg_roles WHERE rolname=ANY($1::text[]) ORDER BY rolname`,
|
|
1770
|
+
[inventory.roles.map((role) => role.name)]
|
|
1771
|
+
);
|
|
1772
|
+
if (conflicts.rows.length > 0) throw new PostgresNativeDrError("TARGET_ROLE_CONFLICT", "target contains a role required by the backup");
|
|
1773
|
+
await assertPostgresRestoreAuthority(target);
|
|
1774
|
+
admin = deps.createClient(administrativeDatabaseUrl(targetUrl, metadata.databaseName));
|
|
1775
|
+
await admin.connect();
|
|
1776
|
+
const pgRestore = deps.resolveTool("pg_restore", options.toolDirectory);
|
|
1777
|
+
const schemaAclListPath = join(libpqDirectory, "schema-acl.list");
|
|
1778
|
+
writeFileSync(schemaAclListPath, schemaAclRestoreList(verifiedArtifacts.dumpToc), { encoding: "utf8", mode: 384, flag: "wx" });
|
|
1779
|
+
securePrivatePath(schemaAclListPath, "file");
|
|
1780
|
+
verifyPrivatePathAccess(schemaAclListPath, "file");
|
|
1781
|
+
for (const [index, statement] of buildPostgresRoleShellStatements(inventory).entries()) {
|
|
1782
|
+
phase = "role shell creation";
|
|
1783
|
+
restoreStarted = true;
|
|
1784
|
+
await target.query(statement);
|
|
1785
|
+
createdRoles.push(inventory.roles[index].name);
|
|
1786
|
+
}
|
|
1787
|
+
const schemaOwner = inventory.roles.find((role) => role.kind === "schema-owner");
|
|
1788
|
+
phase = "schema creation";
|
|
1789
|
+
await target.query(`CREATE SCHEMA agent_bridge AUTHORIZATION ${quoteIdentifier(schemaOwner.name)}`);
|
|
1790
|
+
phase = "schema ACL restore";
|
|
1791
|
+
const schemaAclRestore = await runToolWithAnchoredInput(
|
|
1792
|
+
dumpPath,
|
|
1793
|
+
verifiedArtifacts.artifactAnchors.dump,
|
|
1794
|
+
(descriptor) => deps.runTool(
|
|
1795
|
+
pgRestore,
|
|
1796
|
+
buildPgRestoreSchemaAclArgs(void 0, schemaAclListPath, libpq.serviceName),
|
|
1797
|
+
libpq.environment,
|
|
1798
|
+
descriptor
|
|
1799
|
+
)
|
|
1800
|
+
);
|
|
1801
|
+
assertToolSucceeded(schemaAclRestore, "PG_SCHEMA_ACL_RESTORE_FAILED", "pg_restore schema ACL");
|
|
1802
|
+
phase = "pg_restore";
|
|
1803
|
+
const restored = await runToolWithAnchoredInput(
|
|
1804
|
+
dumpPath,
|
|
1805
|
+
verifiedArtifacts.artifactAnchors.dump,
|
|
1806
|
+
(descriptor) => deps.runTool(
|
|
1807
|
+
pgRestore,
|
|
1808
|
+
buildPgRestoreArgs(void 0, libpq.serviceName),
|
|
1809
|
+
libpq.environment,
|
|
1810
|
+
descriptor
|
|
1811
|
+
)
|
|
1812
|
+
);
|
|
1813
|
+
assertToolSucceeded(restored, "PG_RESTORE_FAILED", "pg_restore");
|
|
1814
|
+
await target.query("BEGIN");
|
|
1815
|
+
transactionOpen = true;
|
|
1816
|
+
phase = "membership restore";
|
|
1817
|
+
for (const statement of buildPostgresMembershipStatements(inventory, major)) await target.query(statement);
|
|
1818
|
+
phase = "default privilege restore";
|
|
1819
|
+
for (const statement of buildPostgresDefaultPrivilegeStatements(inventory)) await target.query(statement);
|
|
1820
|
+
phase = "claim normalization";
|
|
1821
|
+
const normalized = await target.query(`
|
|
1822
|
+
WITH changed AS (
|
|
1823
|
+
UPDATE agent_bridge.deliveries SET state='retrying', lease_token=NULL, lease_owner=NULL,
|
|
1824
|
+
lease_expires_at=NULL, available_at=pg_catalog.now(),
|
|
1825
|
+
last_error='lease invalidated by native restore', last_actor='agent-bridge', last_action='lease_expired'
|
|
1826
|
+
WHERE state='claimed' RETURNING 1
|
|
1827
|
+
) SELECT count(*)::text AS count FROM changed`);
|
|
1828
|
+
const normalizedCount = normalized.rows[0]?.count;
|
|
1829
|
+
if (normalizedCount !== schema.claimedDeliveryCount) throw new PostgresNativeDrError("CLAIM_NORMALIZATION_MISMATCH", "restored claimed delivery count does not match the backup");
|
|
1830
|
+
phase = "migration validation";
|
|
1831
|
+
const migrationRows = await target.query(
|
|
1832
|
+
`SELECT version,name,checksum FROM agent_bridge.schema_migrations ORDER BY version`
|
|
1833
|
+
);
|
|
1834
|
+
const migrations = migrationRows.rows.map((row) => ({ version: Number(row.version), name: row.name, checksum: row.checksum }));
|
|
1835
|
+
if (migrations.length !== schema.migrations.length || migrations.some((migration, index) => {
|
|
1836
|
+
const expected = schema.migrations[index];
|
|
1837
|
+
return expected === void 0 || migration.version !== expected.version || migration.name !== expected.name || migration.checksum !== expected.checksum;
|
|
1838
|
+
})) {
|
|
1839
|
+
throw new PostgresNativeDrError("RESTORE_MIGRATION_MISMATCH", "restored migrations do not match the backup");
|
|
1840
|
+
}
|
|
1841
|
+
phase = "table count validation";
|
|
1842
|
+
const counts = await targetTableCounts(target);
|
|
1843
|
+
compareStringMaps(counts, expectedRestoredTableCounts(schema), "restored PostgreSQL");
|
|
1844
|
+
phase = "readiness validation";
|
|
1845
|
+
const readiness = await target.query(`
|
|
1846
|
+
SELECT agent_bridge.security_schema_ready() AS security,
|
|
1847
|
+
agent_bridge.owner_control_plane_ready() AS "ownerControl",
|
|
1848
|
+
agent_bridge.portable_archive_ready() AS "portableArchive",
|
|
1849
|
+
agent_bridge.credential_security_prerequisite_definition() AS "securityDefinition",
|
|
1850
|
+
agent_bridge.row_isolation_catalog_definition() AS "rowIsolationDefinition",
|
|
1851
|
+
agent_bridge.owner_control_attestation_definition() AS "ownerControlDefinition",
|
|
1852
|
+
agent_bridge.portable_archive_attestation_definition() AS "portableArchiveDefinition"`);
|
|
1853
|
+
const ready = readiness.rows[0];
|
|
1854
|
+
const restoredRowReady = await deps.checkRowIsolationReady(target);
|
|
1855
|
+
const failedReadiness = [
|
|
1856
|
+
!ready?.security ? "security" : void 0,
|
|
1857
|
+
!restoredRowReady ? "row-isolation" : void 0,
|
|
1858
|
+
!ready?.ownerControl ? "owner-control" : void 0,
|
|
1859
|
+
!ready?.portableArchive ? "portable-archive" : void 0
|
|
1860
|
+
].filter(Boolean);
|
|
1861
|
+
if (failedReadiness.length > 0) {
|
|
1862
|
+
throw new PostgresNativeDrError("RESTORE_NOT_READY", `restored PostgreSQL schema failed readiness attestation: ${failedReadiness.join(", ")}`);
|
|
1863
|
+
}
|
|
1864
|
+
const restoredAttestations = {
|
|
1865
|
+
securitySchemaSha256: sha256(ready.securityDefinition),
|
|
1866
|
+
rowIsolationSha256: sha256(ready.rowIsolationDefinition),
|
|
1867
|
+
ownerControlSha256: sha256(ready.ownerControlDefinition),
|
|
1868
|
+
portableArchiveSha256: sha256(ready.portableArchiveDefinition)
|
|
1869
|
+
};
|
|
1870
|
+
const attestationKeys = [
|
|
1871
|
+
"securitySchemaSha256",
|
|
1872
|
+
"rowIsolationSha256",
|
|
1873
|
+
"ownerControlSha256",
|
|
1874
|
+
"portableArchiveSha256"
|
|
1875
|
+
];
|
|
1876
|
+
if (attestationKeys.some((key) => restoredAttestations[key] !== schema.readinessAttestations[key])) {
|
|
1877
|
+
throw new PostgresNativeDrError("RESTORE_ATTESTATION_MISMATCH", "restored PostgreSQL readiness attestations do not match the backup");
|
|
1878
|
+
}
|
|
1879
|
+
const externalRoles = inventory.roles.filter((role) => role.kind === "external-principal").map((role) => role.name);
|
|
1880
|
+
if (externalRoles.length > 0) {
|
|
1881
|
+
const loginRoles = await target.query(
|
|
1882
|
+
`SELECT rolname AS name FROM pg_catalog.pg_roles WHERE rolname=ANY($1::text[]) AND rolcanlogin ORDER BY rolname`,
|
|
1883
|
+
[externalRoles]
|
|
1884
|
+
);
|
|
1885
|
+
if (loginRoles.rows.length > 0) throw new PostgresNativeDrError("EXTERNAL_ROLE_CAN_LOGIN", "restored external principals must remain NOLOGIN");
|
|
1886
|
+
}
|
|
1887
|
+
await target.query("COMMIT");
|
|
1888
|
+
transactionOpen = false;
|
|
1889
|
+
const result = {
|
|
1890
|
+
databaseName: metadata.databaseName,
|
|
1891
|
+
normalizedClaimedDeliveries: normalizedCount,
|
|
1892
|
+
tableCounts: counts,
|
|
1893
|
+
readiness: { security: true, rowIsolation: true, ownerControl: true, portableArchive: true }
|
|
1894
|
+
};
|
|
1895
|
+
restoreCompleted = true;
|
|
1896
|
+
return result;
|
|
1897
|
+
} catch (error) {
|
|
1898
|
+
if (transactionOpen && target) await target.query("ROLLBACK").catch(() => void 0);
|
|
1899
|
+
const targetOffline = restoreStarted && admin ? await markTargetOffline(admin, targetName) : false;
|
|
1900
|
+
const original = error instanceof PostgresNativeDrError ? error : void 0;
|
|
1901
|
+
if (restoreStarted && !targetOffline) {
|
|
1902
|
+
primaryFailure = new PostgresNativeDrError(
|
|
1903
|
+
"TARGET_OFFLINE_FAILED",
|
|
1904
|
+
`PostgreSQL native restore failed and the target could not be forced offline${original ? ` after ${original.code}` : ""}`,
|
|
1905
|
+
{
|
|
1906
|
+
residualRoleShells: createdRoles,
|
|
1907
|
+
targetOffline: false,
|
|
1908
|
+
targetMutated: restoreStarted,
|
|
1909
|
+
restoreCompleted: false
|
|
1910
|
+
}
|
|
1911
|
+
);
|
|
1912
|
+
throw primaryFailure;
|
|
1913
|
+
}
|
|
1914
|
+
primaryFailure = new PostgresNativeDrError(
|
|
1915
|
+
original?.code ?? "POSTGRES_NATIVE_RESTORE_FAILED",
|
|
1916
|
+
original?.message ?? `PostgreSQL native restore failed during ${phase}: ${sanitizedUnexpectedError(error, targetUrl)}`,
|
|
1917
|
+
{
|
|
1918
|
+
residualRoleShells: createdRoles,
|
|
1919
|
+
targetOffline,
|
|
1920
|
+
targetMutated: restoreStarted,
|
|
1921
|
+
restoreCompleted: false
|
|
1922
|
+
}
|
|
1923
|
+
);
|
|
1924
|
+
throw primaryFailure;
|
|
1925
|
+
} finally {
|
|
1926
|
+
if (restoreLockHeld && target) {
|
|
1927
|
+
await target.query("SELECT pg_catalog.pg_advisory_unlock($1)", [NATIVE_DR_ADVISORY_LOCK_KEY]).catch(() => void 0);
|
|
1928
|
+
}
|
|
1929
|
+
await target?.end().catch(() => void 0);
|
|
1930
|
+
await admin?.end().catch(() => void 0);
|
|
1931
|
+
try {
|
|
1932
|
+
deps.removePath(libpqDirectory);
|
|
1933
|
+
} catch {
|
|
1934
|
+
if (existsSync(libpqDirectory)) {
|
|
1935
|
+
throw new PostgresNativeDrError(
|
|
1936
|
+
"CREDENTIAL_CLEANUP_FAILED",
|
|
1937
|
+
`PostgreSQL native restore ${restoreCompleted ? "completed" : "failed"}, but its credential directory could not be removed`,
|
|
1938
|
+
{
|
|
1939
|
+
residualRoleShells: primaryFailure?.details.residualRoleShells,
|
|
1940
|
+
targetOffline: primaryFailure?.details.targetOffline,
|
|
1941
|
+
targetMutated: restoreStarted,
|
|
1942
|
+
restoreCompleted,
|
|
1943
|
+
recoveryPaths: [libpqDirectory],
|
|
1944
|
+
causeCode: primaryFailure?.code
|
|
1945
|
+
}
|
|
1946
|
+
);
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
async function restorePostgresNativeDr(options) {
|
|
1952
|
+
try {
|
|
1953
|
+
return await restorePostgresNativeDrInternal(options);
|
|
1954
|
+
} catch (error) {
|
|
1955
|
+
if (error instanceof PostgresNativeDrError) {
|
|
1956
|
+
if (typeof error.details.targetMutated === "boolean" && typeof error.details.restoreCompleted === "boolean") {
|
|
1957
|
+
throw error;
|
|
1958
|
+
}
|
|
1959
|
+
throw new PostgresNativeDrError(error.code, error.message, {
|
|
1960
|
+
...error.details,
|
|
1961
|
+
targetMutated: false,
|
|
1962
|
+
restoreCompleted: false
|
|
1963
|
+
});
|
|
1964
|
+
}
|
|
1965
|
+
throw new PostgresNativeDrError(
|
|
1966
|
+
"POSTGRES_NATIVE_RESTORE_FAILED",
|
|
1967
|
+
"PostgreSQL native restore failed before target mutation",
|
|
1968
|
+
{ targetMutated: false, restoreCompleted: false }
|
|
1969
|
+
);
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
// src/sqlite-native-dr.ts
|
|
1974
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
1975
|
+
import {
|
|
1976
|
+
closeSync as closeSync3,
|
|
1977
|
+
constants as constants3,
|
|
1978
|
+
existsSync as existsSync2,
|
|
1979
|
+
fsyncSync as fsyncSync2,
|
|
1980
|
+
linkSync,
|
|
1981
|
+
lstatSync as lstatSync3,
|
|
1982
|
+
openSync as openSync3,
|
|
1983
|
+
unlinkSync
|
|
1984
|
+
} from "fs";
|
|
1985
|
+
import { createRequire } from "module";
|
|
1986
|
+
import { dirname as dirname2, join as join2, resolve as resolve3 } from "path";
|
|
1987
|
+
import { Worker } from "worker_threads";
|
|
1988
|
+
var require2 = createRequire(import.meta.url);
|
|
1989
|
+
var SQLITE_ENTRY = "sqlite/database.sqlite3";
|
|
1990
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
1991
|
+
var MAX_TIMEOUT_MS = 24 * 60 * 60 * 1e3;
|
|
1992
|
+
var WORKER_TERMINATION_TIMEOUT_MS = 5e3;
|
|
1993
|
+
var NativeDrCommandError = class extends Error {
|
|
1994
|
+
constructor(code, message, details) {
|
|
1995
|
+
super(message);
|
|
1996
|
+
this.code = code;
|
|
1997
|
+
this.details = details;
|
|
1998
|
+
}
|
|
1999
|
+
};
|
|
2000
|
+
function fail(code, message, details) {
|
|
2001
|
+
throw new NativeDrCommandError(code, message, details);
|
|
2002
|
+
}
|
|
2003
|
+
function validateNativeDrId(value) {
|
|
2004
|
+
const normalized = value.toLowerCase();
|
|
2005
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(normalized)) {
|
|
2006
|
+
throw new NativeDrCommandError("INVALID_REQUEST_ID", "DR request and backup IDs must be UUIDs");
|
|
2007
|
+
}
|
|
2008
|
+
return normalized;
|
|
2009
|
+
}
|
|
2010
|
+
function validateNativeDrTimeout(value) {
|
|
2011
|
+
const selected = value ?? DEFAULT_TIMEOUT_MS;
|
|
2012
|
+
if (!Number.isSafeInteger(selected) || selected < 100 || selected > MAX_TIMEOUT_MS) {
|
|
2013
|
+
throw new NativeDrCommandError("INVALID_TIMEOUT", "DR timeout must be an integer from 100 to 86400000 milliseconds");
|
|
2014
|
+
}
|
|
2015
|
+
return selected;
|
|
2016
|
+
}
|
|
2017
|
+
function syncDirectory(path) {
|
|
2018
|
+
if (process.platform === "win32") return;
|
|
2019
|
+
const descriptor = openSync3(path, "r");
|
|
2020
|
+
try {
|
|
2021
|
+
fsyncSync2(descriptor);
|
|
2022
|
+
} finally {
|
|
2023
|
+
closeSync3(descriptor);
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
function operations(options) {
|
|
2027
|
+
return {
|
|
2028
|
+
link: options.fileOperations?.link ?? linkSync,
|
|
2029
|
+
unlink: options.fileOperations?.unlink ?? unlinkSync,
|
|
2030
|
+
syncDirectory: options.fileOperations?.syncDirectory ?? syncDirectory,
|
|
2031
|
+
afterPublish: options.fileOperations?.afterPublish
|
|
2032
|
+
};
|
|
2033
|
+
}
|
|
2034
|
+
function sameIdentity(left, right) {
|
|
2035
|
+
return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
|
|
2036
|
+
}
|
|
2037
|
+
function database(path, readOnly = true) {
|
|
2038
|
+
const { DatabaseSync } = require2("node:sqlite");
|
|
2039
|
+
return new DatabaseSync(path, { readOnly });
|
|
2040
|
+
}
|
|
2041
|
+
function verifyLocalDatabase(path) {
|
|
2042
|
+
const target = preparePrivateSqliteLocation(path);
|
|
2043
|
+
const before = lstatSync3(target);
|
|
2044
|
+
const db = database(target);
|
|
2045
|
+
try {
|
|
2046
|
+
assertLocalAuthorityDatabase(db);
|
|
2047
|
+
verifySqliteHealth(db);
|
|
2048
|
+
const contract = sqliteSchemaContractHash(db);
|
|
2049
|
+
const after = lstatSync3(target);
|
|
2050
|
+
if (!sameIdentity(before, after)) throw new SQLiteDatabaseContractError("SQLite database path changed while verifying");
|
|
2051
|
+
return contract;
|
|
2052
|
+
} finally {
|
|
2053
|
+
db.close();
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
async function prepareBackupSource(path) {
|
|
2057
|
+
const source = preparePrivateSqliteLocation(path);
|
|
2058
|
+
if (!existsSync2(source)) fail("SOURCE_NOT_FOUND", "local DR source does not exist");
|
|
2059
|
+
let db;
|
|
2060
|
+
let kind = "unknown";
|
|
2061
|
+
try {
|
|
2062
|
+
db = database(source);
|
|
2063
|
+
kind = identifySqliteDatabase(db);
|
|
2064
|
+
if (kind === "edge-cache" || kind === "legacy-edge") fail("EDGE_DATABASE_REJECTED", "edge databases are not supported by native DR v1");
|
|
2065
|
+
if (kind !== "local-authority" && kind !== "legacy-local") fail("INVALID_SOURCE", "DR source is not a local authority database");
|
|
2066
|
+
if (kind === "local-authority") {
|
|
2067
|
+
assertLocalAuthorityDatabase(db);
|
|
2068
|
+
verifySqliteHealth(db);
|
|
2069
|
+
}
|
|
2070
|
+
} finally {
|
|
2071
|
+
db?.close();
|
|
2072
|
+
}
|
|
2073
|
+
if (kind === "legacy-local") {
|
|
2074
|
+
const store = new SQLiteBridgeStore(source);
|
|
2075
|
+
try {
|
|
2076
|
+
await store.initialize();
|
|
2077
|
+
} finally {
|
|
2078
|
+
await store.close();
|
|
2079
|
+
}
|
|
2080
|
+
verifyLocalDatabase(source);
|
|
2081
|
+
}
|
|
2082
|
+
return source;
|
|
2083
|
+
}
|
|
2084
|
+
var BACKUP_WORKER = String.raw`
|
|
2085
|
+
const { parentPort, workerData } = require("node:worker_threads");
|
|
2086
|
+
const { DatabaseSync, backup } = require("node:sqlite");
|
|
2087
|
+
const { lstatSync } = require("node:fs");
|
|
2088
|
+
(async () => {
|
|
2089
|
+
let source;
|
|
2090
|
+
try {
|
|
2091
|
+
const before = lstatSync(workerData.source);
|
|
2092
|
+
if (before.dev !== workerData.identity.dev || before.ino !== workerData.identity.ino) throw new Error("source identity changed");
|
|
2093
|
+
source = new DatabaseSync(workerData.source, { readOnly: true });
|
|
2094
|
+
const pages = await backup(source, workerData.destination, { rate: 64 });
|
|
2095
|
+
source.close(); source = undefined;
|
|
2096
|
+
const after = lstatSync(workerData.source);
|
|
2097
|
+
if (after.dev !== workerData.identity.dev || after.ino !== workerData.identity.ino) throw new Error("source identity changed");
|
|
2098
|
+
parentPort.postMessage({ ok: true, pages });
|
|
2099
|
+
} catch (error) {
|
|
2100
|
+
try { source?.close(); } catch {}
|
|
2101
|
+
parentPort.postMessage({ ok: false, code: error && typeof error.code === "string" ? error.code : "SQLITE_BACKUP_FAILED" });
|
|
2102
|
+
}
|
|
2103
|
+
})();`;
|
|
2104
|
+
async function runSqliteBackupWorker(source, destination, timeoutMs) {
|
|
2105
|
+
const identity = lstatSync3(source);
|
|
2106
|
+
const worker = new Worker(BACKUP_WORKER, { eval: true, workerData: { source, destination, identity: { dev: identity.dev, ino: identity.ino } } });
|
|
2107
|
+
return await new Promise((resolvePromise, reject) => {
|
|
2108
|
+
let settled = false;
|
|
2109
|
+
const finish = async (callback) => {
|
|
2110
|
+
if (settled) return;
|
|
2111
|
+
settled = true;
|
|
2112
|
+
clearTimeout(timer);
|
|
2113
|
+
try {
|
|
2114
|
+
await Promise.race([
|
|
2115
|
+
worker.terminate(),
|
|
2116
|
+
new Promise((_resolve, rejectTermination) => {
|
|
2117
|
+
const terminationTimer = setTimeout(() => rejectTermination(new Error("worker termination timed out")), WORKER_TERMINATION_TIMEOUT_MS);
|
|
2118
|
+
terminationTimer.unref();
|
|
2119
|
+
})
|
|
2120
|
+
]);
|
|
2121
|
+
} catch {
|
|
2122
|
+
reject(new NativeDrCommandError(
|
|
2123
|
+
"SQLITE_BACKUP_TERMINATION_UNKNOWN",
|
|
2124
|
+
"SQLite backup worker termination could not be confirmed",
|
|
2125
|
+
{ workerTermination: "unknown", recoveryPaths: [destination] }
|
|
2126
|
+
));
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
callback();
|
|
2130
|
+
};
|
|
2131
|
+
const timer = setTimeout(() => {
|
|
2132
|
+
void finish(() => reject(new NativeDrCommandError("SQLITE_BACKUP_TIMEOUT", "SQLite online backup exceeded its hard deadline")));
|
|
2133
|
+
}, timeoutMs);
|
|
2134
|
+
timer.unref();
|
|
2135
|
+
worker.once("message", (message) => void finish(() => {
|
|
2136
|
+
if (!message?.ok || !Number.isSafeInteger(message.pages)) reject(new NativeDrCommandError("SQLITE_BACKUP_FAILED", "SQLite online backup failed"));
|
|
2137
|
+
else resolvePromise(message.pages);
|
|
2138
|
+
}));
|
|
2139
|
+
worker.once("error", () => void finish(() => reject(new NativeDrCommandError("SQLITE_BACKUP_FAILED", "SQLite online backup worker failed"))));
|
|
2140
|
+
worker.once("exit", (code) => {
|
|
2141
|
+
if (!settled) {
|
|
2142
|
+
settled = true;
|
|
2143
|
+
clearTimeout(timer);
|
|
2144
|
+
reject(new NativeDrCommandError("SQLITE_BACKUP_FAILED", `SQLite online backup worker exited before completion (${code})`));
|
|
2145
|
+
}
|
|
2146
|
+
});
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
function cleanup(paths) {
|
|
2150
|
+
for (const path of paths) {
|
|
2151
|
+
try {
|
|
2152
|
+
if (existsSync2(path)) unlinkSync(path);
|
|
2153
|
+
} catch {
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
return paths.filter(existsSync2);
|
|
2157
|
+
}
|
|
2158
|
+
function verifyBundle(path) {
|
|
2159
|
+
const reader = new NativeDrBundleReader(path);
|
|
2160
|
+
try {
|
|
2161
|
+
const result = reader.inspect();
|
|
2162
|
+
if (result.manifest.kind !== "sqlite" || result.manifest.entries.length !== 1 || result.manifest.entries[0].name !== SQLITE_ENTRY || result.manifest.schema.applicationId !== LOCAL_SQLITE_APPLICATION_ID || result.manifest.schema.userVersion !== SQLITE_DATABASE_SCHEMA_VERSION || typeof result.manifest.schema.schemaContractSha256 !== "string" || !isSupportedLocalSqliteSchemaContract(result.manifest.schema.schemaContractSha256)) {
|
|
2163
|
+
throw new NativeDrBundleError("DR bundle is not a supported local SQLite backup");
|
|
2164
|
+
}
|
|
2165
|
+
return result;
|
|
2166
|
+
} finally {
|
|
2167
|
+
reader.close();
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
async function backupLocalSqlite(sourcePath, outputPath, backupIdInput = randomUUID2(), options = {}) {
|
|
2171
|
+
const backupId = validateNativeDrId(backupIdInput);
|
|
2172
|
+
const timeoutMs = validateNativeDrTimeout(options.timeoutMs);
|
|
2173
|
+
const source = await prepareBackupSource(sourcePath);
|
|
2174
|
+
const sourceIdentity = lstatSync3(source);
|
|
2175
|
+
const target = resolve3(outputPath);
|
|
2176
|
+
const directory = dirname2(target);
|
|
2177
|
+
preparePrivateFileLocation(target);
|
|
2178
|
+
const snapshot = join2(directory, `.${backupId}.agent-bridge-dr.sqlite.tmp`);
|
|
2179
|
+
const bundle = join2(directory, `.${backupId}.agent-bridge-dr.bundle.tmp`);
|
|
2180
|
+
const existingRecoveryPaths = [bundle, snapshot].filter(existsSync2);
|
|
2181
|
+
if (existingRecoveryPaths.length) fail("DR_RECOVERY_REQUIRED", "a prior DR backup with this ID requires recovery", {
|
|
2182
|
+
backupId,
|
|
2183
|
+
output: target,
|
|
2184
|
+
outputExists: existsSync2(target),
|
|
2185
|
+
published: existsSync2(target) ? "unknown" : false,
|
|
2186
|
+
recoveryPaths: existingRecoveryPaths
|
|
2187
|
+
});
|
|
2188
|
+
if (existsSync2(target)) fail("OUTPUT_EXISTS", "DR output already exists");
|
|
2189
|
+
let bundleDescriptor;
|
|
2190
|
+
let published = false;
|
|
2191
|
+
let durable = false;
|
|
2192
|
+
const fileOps = operations(options);
|
|
2193
|
+
try {
|
|
2194
|
+
await (options.runBackupWorker ?? runSqliteBackupWorker)(source, snapshot, timeoutMs);
|
|
2195
|
+
securePrivateSqliteFiles(snapshot);
|
|
2196
|
+
const schemaContractSha256 = verifyLocalDatabase(snapshot);
|
|
2197
|
+
const afterBackup = lstatSync3(source);
|
|
2198
|
+
if (sourceIdentity.dev !== afterBackup.dev || sourceIdentity.ino !== afterBackup.ino) fail("SOURCE_CHANGED", "DR source path changed during online backup");
|
|
2199
|
+
bundleDescriptor = openSync3(bundle, constants3.O_WRONLY | constants3.O_CREAT | constants3.O_EXCL, 384);
|
|
2200
|
+
securePrivatePath(bundle, "file");
|
|
2201
|
+
writeNativeDrBundle(bundleDescriptor, {
|
|
2202
|
+
backupId,
|
|
2203
|
+
createdAt: (options.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
|
|
2204
|
+
entries: [{ name: SQLITE_ENTRY, path: snapshot }],
|
|
2205
|
+
kind: "sqlite",
|
|
2206
|
+
schema: { applicationId: LOCAL_SQLITE_APPLICATION_ID, userVersion: SQLITE_DATABASE_SCHEMA_VERSION, schemaContractSha256 }
|
|
2207
|
+
}, bundle);
|
|
2208
|
+
closeSync3(bundleDescriptor);
|
|
2209
|
+
bundleDescriptor = void 0;
|
|
2210
|
+
const beforePublication = verifyBundle(bundle);
|
|
2211
|
+
try {
|
|
2212
|
+
fileOps.link(bundle, target);
|
|
2213
|
+
published = true;
|
|
2214
|
+
} catch (error) {
|
|
2215
|
+
if (error.code === "EEXIST") fail("OUTPUT_EXISTS", "DR output already exists");
|
|
2216
|
+
throw error;
|
|
2217
|
+
}
|
|
2218
|
+
try {
|
|
2219
|
+
fileOps.afterPublish?.(target);
|
|
2220
|
+
fileOps.syncDirectory(directory);
|
|
2221
|
+
durable = true;
|
|
2222
|
+
} catch {
|
|
2223
|
+
fail("DR_PUBLICATION_AMBIGUOUS", "DR output is visible but durability is unknown", { backupId, output: target, published: true, durable: "unknown", recoveryPaths: [bundle, snapshot].filter(existsSync2) });
|
|
2224
|
+
}
|
|
2225
|
+
let publishedMetadata;
|
|
2226
|
+
try {
|
|
2227
|
+
publishedMetadata = verifyBundle(target);
|
|
2228
|
+
} catch {
|
|
2229
|
+
fail("DR_PUBLICATION_INVALID", "published DR output could not be verified", { backupId, output: target, published: true, durable: true, verified: false, recoveryPaths: [bundle, snapshot].filter(existsSync2) });
|
|
2230
|
+
}
|
|
2231
|
+
if (publishedMetadata.bundleSha256 !== beforePublication.bundleSha256) {
|
|
2232
|
+
fail("DR_PUBLICATION_INVALID", "published DR output does not match its verified staging bundle", { backupId, output: target, published: true, durable: true, verified: false, recoveryPaths: [bundle, snapshot].filter(existsSync2) });
|
|
2233
|
+
}
|
|
2234
|
+
try {
|
|
2235
|
+
fileOps.unlink(bundle);
|
|
2236
|
+
fileOps.unlink(snapshot);
|
|
2237
|
+
fileOps.syncDirectory(directory);
|
|
2238
|
+
} catch {
|
|
2239
|
+
fail("DR_RECOVERY_ARTIFACT_RETAINED", "DR output is valid but a recovery artifact remains", { backupId, output: target, published: true, durable: true, verified: true, recoveryPaths: [bundle, snapshot].filter(existsSync2) });
|
|
2240
|
+
}
|
|
2241
|
+
return publishedMetadata;
|
|
2242
|
+
} catch (error) {
|
|
2243
|
+
if (bundleDescriptor !== void 0) {
|
|
2244
|
+
closeSync3(bundleDescriptor);
|
|
2245
|
+
bundleDescriptor = void 0;
|
|
2246
|
+
}
|
|
2247
|
+
if (!published) {
|
|
2248
|
+
const retainPossiblyActiveSnapshot = error instanceof NativeDrCommandError && error.code === "SQLITE_BACKUP_TERMINATION_UNKNOWN";
|
|
2249
|
+
const recoveryPaths = retainPossiblyActiveSnapshot ? [bundle, snapshot].filter(existsSync2) : cleanup([bundle, snapshot]);
|
|
2250
|
+
if (error instanceof NativeDrCommandError) {
|
|
2251
|
+
throw new NativeDrCommandError(error.code, error.message, recoveryPaths.length ? { ...error.details, backupId, recoveryPaths } : error.details);
|
|
2252
|
+
}
|
|
2253
|
+
fail("DR_BACKUP_FAILED", "local SQLite backup failed", recoveryPaths.length ? { backupId, recoveryPaths } : { backupId });
|
|
2254
|
+
}
|
|
2255
|
+
if (error instanceof NativeDrCommandError) throw error;
|
|
2256
|
+
if (published) fail("DR_PUBLICATION_AMBIGUOUS", "DR publication outcome is incomplete", { backupId, output: target, published: true, durable: durable || "unknown", recoveryPaths: [bundle, snapshot].filter(existsSync2) });
|
|
2257
|
+
} finally {
|
|
2258
|
+
if (bundleDescriptor !== void 0) closeSync3(bundleDescriptor);
|
|
2259
|
+
}
|
|
2260
|
+
throw new NativeDrCommandError("DR_BACKUP_FAILED", "local SQLite backup failed");
|
|
2261
|
+
}
|
|
2262
|
+
function verifyNativeDrBundle(path) {
|
|
2263
|
+
try {
|
|
2264
|
+
return verifyBundle(path);
|
|
2265
|
+
} catch (error) {
|
|
2266
|
+
if (error instanceof NativeDrCommandError) throw error;
|
|
2267
|
+
fail("INVALID_DR_BUNDLE", "DR bundle verification failed");
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
async function restoreLocalSqlite(bundlePath, targetPath, requestIdInput = randomUUID2(), options = {}) {
|
|
2271
|
+
const requestId = validateNativeDrId(requestIdInput);
|
|
2272
|
+
validateNativeDrTimeout(options.timeoutMs);
|
|
2273
|
+
const bundle = resolve3(bundlePath);
|
|
2274
|
+
const metadata = verifyBundle(bundle);
|
|
2275
|
+
const target = resolve3(targetPath);
|
|
2276
|
+
const directory = dirname2(target);
|
|
2277
|
+
preparePrivateFileLocation(target);
|
|
2278
|
+
const staged = join2(directory, `.${requestId}.agent-bridge-dr.restore.sqlite.tmp`);
|
|
2279
|
+
if (existsSync2(staged)) fail("DR_RECOVERY_REQUIRED", "a prior DR restore with this ID requires recovery", {
|
|
2280
|
+
requestId,
|
|
2281
|
+
target,
|
|
2282
|
+
targetExists: existsSync2(target),
|
|
2283
|
+
published: existsSync2(target) ? "unknown" : false,
|
|
2284
|
+
recoveryPaths: [staged]
|
|
2285
|
+
});
|
|
2286
|
+
if (existsSync2(target) || existsSync2(`${target}-wal`) || existsSync2(`${target}-shm`)) fail("TARGET_EXISTS", "DR restore target must be fresh");
|
|
2287
|
+
let descriptor;
|
|
2288
|
+
let published = false;
|
|
2289
|
+
let durable = false;
|
|
2290
|
+
const fileOps = operations(options);
|
|
2291
|
+
try {
|
|
2292
|
+
descriptor = openSync3(staged, constants3.O_WRONLY | constants3.O_CREAT | constants3.O_EXCL, 384);
|
|
2293
|
+
securePrivatePath(staged, "file");
|
|
2294
|
+
const reader = new NativeDrBundleReader(bundle);
|
|
2295
|
+
try {
|
|
2296
|
+
const extracted = reader.inspect((entry) => entry.name === SQLITE_ENTRY ? descriptor : void 0);
|
|
2297
|
+
if (extracted.bundleSha256 !== metadata.bundleSha256) throw new NativeDrBundleError("DR bundle changed between verification passes");
|
|
2298
|
+
} finally {
|
|
2299
|
+
reader.close();
|
|
2300
|
+
}
|
|
2301
|
+
fsyncSync2(descriptor);
|
|
2302
|
+
closeSync3(descriptor);
|
|
2303
|
+
descriptor = void 0;
|
|
2304
|
+
securePrivateSqliteFiles(staged);
|
|
2305
|
+
const restoredContract = verifyLocalDatabase(staged);
|
|
2306
|
+
if (restoredContract !== metadata.manifest.schema.schemaContractSha256) throw new NativeDrBundleError("restored SQLite schema contract does not match the manifest");
|
|
2307
|
+
try {
|
|
2308
|
+
fileOps.link(staged, target);
|
|
2309
|
+
published = true;
|
|
2310
|
+
} catch (error) {
|
|
2311
|
+
if (error.code === "EEXIST") fail("TARGET_EXISTS", "DR restore target must be fresh");
|
|
2312
|
+
throw error;
|
|
2313
|
+
}
|
|
2314
|
+
try {
|
|
2315
|
+
fileOps.afterPublish?.(target);
|
|
2316
|
+
fileOps.syncDirectory(directory);
|
|
2317
|
+
durable = true;
|
|
2318
|
+
} catch {
|
|
2319
|
+
fail("DR_RESTORE_AMBIGUOUS", "restored database is visible but durability is unknown", { requestId, target, published: true, durable: "unknown", verified: false, recoveryPaths: [staged] });
|
|
2320
|
+
}
|
|
2321
|
+
try {
|
|
2322
|
+
const publishedContract = verifyLocalDatabase(target);
|
|
2323
|
+
if (publishedContract !== metadata.manifest.schema.schemaContractSha256) throw new NativeDrBundleError("published SQLite schema contract does not match the manifest");
|
|
2324
|
+
} catch {
|
|
2325
|
+
fail("DR_RESTORE_INVALID", "restored database is visible but failed verification", { requestId, target, published: true, durable: true, verified: false, recoveryPaths: [staged] });
|
|
2326
|
+
}
|
|
2327
|
+
try {
|
|
2328
|
+
fileOps.unlink(staged);
|
|
2329
|
+
} catch {
|
|
2330
|
+
fail("DR_RECOVERY_ARTIFACT_RETAINED", "restored database is valid but a recovery artifact remains", {
|
|
2331
|
+
requestId,
|
|
2332
|
+
target,
|
|
2333
|
+
published: true,
|
|
2334
|
+
durable: true,
|
|
2335
|
+
verified: true,
|
|
2336
|
+
recoveryPaths: [staged].filter(existsSync2)
|
|
2337
|
+
});
|
|
2338
|
+
}
|
|
2339
|
+
try {
|
|
2340
|
+
securePrivateSqliteFiles(target);
|
|
2341
|
+
if (verifyLocalDatabase(target) !== metadata.manifest.schema.schemaContractSha256) {
|
|
2342
|
+
throw new NativeDrBundleError("published SQLite schema contract changed during cleanup");
|
|
2343
|
+
}
|
|
2344
|
+
} catch {
|
|
2345
|
+
fail("DR_RESTORE_INVALID", "restored database failed post-cleanup verification", {
|
|
2346
|
+
requestId,
|
|
2347
|
+
target,
|
|
2348
|
+
published: true,
|
|
2349
|
+
durable: true,
|
|
2350
|
+
verified: false,
|
|
2351
|
+
recoveryPaths: []
|
|
2352
|
+
});
|
|
2353
|
+
}
|
|
2354
|
+
try {
|
|
2355
|
+
fileOps.syncDirectory(directory);
|
|
2356
|
+
} catch {
|
|
2357
|
+
fail("DR_CLEANUP_DURABILITY_UNKNOWN", "restored database is valid but staging cleanup durability is unknown", {
|
|
2358
|
+
requestId,
|
|
2359
|
+
target,
|
|
2360
|
+
published: true,
|
|
2361
|
+
durable: true,
|
|
2362
|
+
verified: true,
|
|
2363
|
+
recoveryPaths: []
|
|
2364
|
+
});
|
|
2365
|
+
}
|
|
2366
|
+
return { ...metadata, requestId };
|
|
2367
|
+
} catch (error) {
|
|
2368
|
+
if (descriptor !== void 0) {
|
|
2369
|
+
closeSync3(descriptor);
|
|
2370
|
+
descriptor = void 0;
|
|
2371
|
+
}
|
|
2372
|
+
if (!published) {
|
|
2373
|
+
const recoveryPaths = cleanup([staged]);
|
|
2374
|
+
if (error instanceof NativeDrCommandError) {
|
|
2375
|
+
throw new NativeDrCommandError(error.code, error.message, recoveryPaths.length ? { ...error.details, requestId, recoveryPaths } : error.details);
|
|
2376
|
+
}
|
|
2377
|
+
fail("DR_RESTORE_FAILED", "local SQLite restore failed", recoveryPaths.length ? { requestId, recoveryPaths } : { requestId });
|
|
2378
|
+
}
|
|
2379
|
+
if (error instanceof NativeDrCommandError) throw error;
|
|
2380
|
+
if (published) fail("DR_RESTORE_AMBIGUOUS", "DR restore outcome is incomplete", { requestId, target, published: true, durable: durable || "unknown", verified: false, recoveryPaths: [staged].filter(existsSync2) });
|
|
2381
|
+
} finally {
|
|
2382
|
+
if (descriptor !== void 0) closeSync3(descriptor);
|
|
2383
|
+
}
|
|
2384
|
+
throw new NativeDrCommandError("DR_RESTORE_FAILED", "local SQLite restore failed");
|
|
2385
|
+
}
|
|
2386
|
+
var SQLITE_EDGE_APPLICATION_ID = EDGE_SQLITE_APPLICATION_ID;
|
|
2387
|
+
|
|
2388
|
+
export {
|
|
2389
|
+
NATIVE_DR_MAX_BUNDLE_BYTES,
|
|
2390
|
+
NATIVE_DR_FORMAT,
|
|
2391
|
+
NATIVE_DR_VERSION,
|
|
2392
|
+
NativeDrBundleError,
|
|
2393
|
+
validateNativeDrAggregateSize,
|
|
2394
|
+
writeNativeDrBundle,
|
|
2395
|
+
NativeDrBundleReader,
|
|
2396
|
+
POSTGRES_NATIVE_DR_ROLE_SCHEMA,
|
|
2397
|
+
POSTGRES_NATIVE_DR_ROLE_VERSION,
|
|
2398
|
+
POSTGRES_NATIVE_DR_SERVICE,
|
|
2399
|
+
POSTGRES_NATIVE_DR_EXCLUDED_DATA_TABLES,
|
|
2400
|
+
PostgresNativeDrError,
|
|
2401
|
+
createPostgresDrLibpqFiles,
|
|
2402
|
+
buildPgDumpArgs,
|
|
2403
|
+
buildPgRestoreArgs,
|
|
2404
|
+
buildPgRestoreListArgs,
|
|
2405
|
+
buildPgRestoreSchemaAclArgs,
|
|
2406
|
+
validatePostgresDumpToc,
|
|
2407
|
+
schemaAclRestoreList,
|
|
2408
|
+
parsePostgresToolMajor,
|
|
2409
|
+
validatePostgresRoleInventory,
|
|
2410
|
+
canonicalPostgresRoleInventory,
|
|
2411
|
+
buildPostgresRoleShellStatements,
|
|
2412
|
+
buildPostgresMembershipStatements,
|
|
2413
|
+
buildPostgresDefaultPrivilegeStatements,
|
|
2414
|
+
collectPostgresRoleInventory,
|
|
2415
|
+
backupPostgresNativeDr,
|
|
2416
|
+
verifyPostgresNativeDrArtifacts,
|
|
2417
|
+
assertPostgresRestoreAuthority,
|
|
2418
|
+
restorePostgresNativeDr,
|
|
2419
|
+
NativeDrCommandError,
|
|
2420
|
+
validateNativeDrId,
|
|
2421
|
+
validateNativeDrTimeout,
|
|
2422
|
+
runSqliteBackupWorker,
|
|
2423
|
+
backupLocalSqlite,
|
|
2424
|
+
verifyNativeDrBundle,
|
|
2425
|
+
restoreLocalSqlite,
|
|
2426
|
+
SQLITE_EDGE_APPLICATION_ID
|
|
2427
|
+
};
|
|
2428
|
+
//# sourceMappingURL=chunk-MBPZXTJA.js.map
|