@forgezero/runtime 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +89 -0
- package/contracts/foundry.toml +9 -0
- package/contracts/src/ColdVault.sol +206 -0
- package/contracts/src/DepositFactory.sol +202 -0
- package/contracts/src/DepositProxy.sol +72 -0
- package/contracts/src/IERC20.sol +7 -0
- package/contracts/src/MockTokens.sol +32 -0
- package/contracts/src/SafeTransferLib.sol +31 -0
- package/contracts/test/Custody.t.sol +361 -0
- package/contracts/test/Vectors.t.sol +45 -0
- package/dist/audit.d.ts +265 -0
- package/dist/audit.js +291 -0
- package/dist/backup.d.ts +243 -0
- package/dist/backup.js +302 -0
- package/dist/calendar.d.ts +136 -0
- package/dist/calendar.js +129 -0
- package/dist/compliance.d.ts +172 -0
- package/dist/compliance.js +168 -0
- package/dist/finance/binance.d.ts +27 -0
- package/dist/finance/binance.js +452 -0
- package/dist/finance/chain-addresses.d.ts +130 -0
- package/dist/finance/chain-addresses.js +462 -0
- package/dist/finance/chain-deposits.d.ts +193 -0
- package/dist/finance/chain-deposits.js +596 -0
- package/dist/finance/chain-reconcile.d.ts +112 -0
- package/dist/finance/chain-reconcile.js +76 -0
- package/dist/finance/chain-withdrawals.d.ts +223 -0
- package/dist/finance/chain-withdrawals.js +631 -0
- package/dist/finance/chain.d.ts +116 -0
- package/dist/finance/chain.js +316 -0
- package/dist/finance/commission.d.ts +155 -0
- package/dist/finance/commission.js +419 -0
- package/dist/finance/custody.d.ts +68 -0
- package/dist/finance/custody.js +107 -0
- package/dist/finance/derive.d.ts +115 -0
- package/dist/finance/derive.js +116 -0
- package/dist/finance/discounts.d.ts +98 -0
- package/dist/finance/discounts.js +90 -0
- package/dist/finance/ledger.d.ts +221 -0
- package/dist/finance/ledger.js +308 -0
- package/dist/finance/market.d.ts +209 -0
- package/dist/finance/market.js +112 -0
- package/dist/finance/money.d.ts +118 -0
- package/dist/finance/money.js +176 -0
- package/dist/finance/rates.d.ts +178 -0
- package/dist/finance/rates.js +292 -0
- package/dist/finance/storage.d.ts +113 -0
- package/dist/finance/storage.js +226 -0
- package/dist/finance/tax.d.ts +132 -0
- package/dist/finance/tax.js +291 -0
- package/dist/finance/transfers.d.ts +153 -0
- package/dist/finance/transfers.js +292 -0
- package/dist/finance/venues.d.ts +190 -0
- package/dist/finance/venues.js +251 -0
- package/dist/identity.d.ts +115 -0
- package/dist/identity.js +111 -0
- package/dist/importers.d.ts +87 -0
- package/dist/importers.js +250 -0
- package/dist/jobs.d.ts +171 -0
- package/dist/jobs.js +250 -0
- package/dist/notify-templates.d.ts +11 -0
- package/dist/notify-templates.js +254 -0
- package/dist/notify.d.ts +172 -0
- package/dist/notify.js +122 -0
- package/dist/openssh.d.ts +36 -0
- package/dist/openssh.js +106 -0
- package/dist/otpauth.d.ts +57 -0
- package/dist/otpauth.js +223 -0
- package/dist/outbox.d.ts +234 -0
- package/dist/outbox.js +236 -0
- package/dist/passkey.d.ts +120 -0
- package/dist/passkey.js +105 -0
- package/dist/phrase.d.ts +87 -0
- package/dist/phrase.js +87 -0
- package/dist/pipeline.d.ts +137 -0
- package/dist/pipeline.js +121 -0
- package/dist/queue.d.ts +243 -0
- package/dist/queue.js +246 -0
- package/dist/schema-typebox.d.ts +24 -0
- package/dist/schema-typebox.js +201 -0
- package/dist/schema.d.ts +134 -0
- package/dist/schema.js +169 -0
- package/dist/serial.d.ts +54 -0
- package/dist/serial.js +40 -0
- package/dist/slip10.d.ts +37 -0
- package/dist/slip10.js +74 -0
- package/dist/snp.d.ts +115 -0
- package/dist/snp.js +109 -0
- package/dist/ssh-agent.d.ts +70 -0
- package/dist/ssh-agent.js +141 -0
- package/dist/ssh-cert.d.ts +73 -0
- package/dist/ssh-cert.js +111 -0
- package/dist/totp.d.ts +104 -0
- package/dist/totp.js +143 -0
- package/package.json +248 -0
package/dist/audit.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined")
|
|
5
|
+
return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// src/serial.ts
|
|
10
|
+
function createKeyedSerial(options = {}) {
|
|
11
|
+
const maxKeys = options.maxKeys ?? 1e4;
|
|
12
|
+
const chains = new Map;
|
|
13
|
+
return {
|
|
14
|
+
run(key, work) {
|
|
15
|
+
const previous = chains.get(key) ?? Promise.resolve();
|
|
16
|
+
const next = previous.then(work, work);
|
|
17
|
+
const settled = next.then(() => {
|
|
18
|
+
return;
|
|
19
|
+
}, () => {
|
|
20
|
+
return;
|
|
21
|
+
});
|
|
22
|
+
chains.set(key, settled);
|
|
23
|
+
settled.then(() => {
|
|
24
|
+
if (chains.get(key) === settled)
|
|
25
|
+
chains.delete(key);
|
|
26
|
+
});
|
|
27
|
+
if (chains.size > maxKeys) {
|
|
28
|
+
console.warn(`[serial] ${chains.size} keys in flight, above the ${maxKeys} guideline.`);
|
|
29
|
+
}
|
|
30
|
+
return next;
|
|
31
|
+
},
|
|
32
|
+
size: () => chains.size,
|
|
33
|
+
drain: async () => {
|
|
34
|
+
await Promise.allSettled([...chains.values()]);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// src/audit.ts
|
|
40
|
+
import { sha256, timingSafeEqual } from "@forgezero/access/security";
|
|
41
|
+
class AuditChainError extends Error {
|
|
42
|
+
code;
|
|
43
|
+
at;
|
|
44
|
+
constructor(code, message, at) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.code = code;
|
|
47
|
+
this.at = at;
|
|
48
|
+
this.name = "AuditChainError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
var GENESIS_DIGEST = "0".repeat(64);
|
|
52
|
+
function canonicalise(record) {
|
|
53
|
+
const detail = record.detail ? Object.keys(record.detail).sort().map((key) => `${key}=${String(record.detail[key])}`).join("\x1F") : "";
|
|
54
|
+
return [
|
|
55
|
+
record.sequence,
|
|
56
|
+
record.previousDigest,
|
|
57
|
+
record.atMs,
|
|
58
|
+
record.action,
|
|
59
|
+
record.outcome,
|
|
60
|
+
record.actor ?? "",
|
|
61
|
+
record.realm ?? "",
|
|
62
|
+
record.targetCollection ?? "",
|
|
63
|
+
record.targetKey ?? "",
|
|
64
|
+
record.reason ?? "",
|
|
65
|
+
detail
|
|
66
|
+
].join("\x1E");
|
|
67
|
+
}
|
|
68
|
+
var hashDigester = (input) => sha256(input);
|
|
69
|
+
var sealedDigester = (key) => {
|
|
70
|
+
return async (input) => {
|
|
71
|
+
const { hmacSha256 } = await import("@forgezero/access/security");
|
|
72
|
+
return hmacSha256(key, input);
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
async function appendRecord(store, entry, options = {}) {
|
|
76
|
+
const digester = options.digester ?? hashDigester;
|
|
77
|
+
const previous = await store.tail(entry.realm);
|
|
78
|
+
const unsigned = {
|
|
79
|
+
...entry,
|
|
80
|
+
sequence: (previous?.sequence ?? 0) + 1,
|
|
81
|
+
atMs: (options.now ?? Date.now)(),
|
|
82
|
+
previousDigest: previous?.digest ?? GENESIS_DIGEST
|
|
83
|
+
};
|
|
84
|
+
const record = { ...unsigned, digest: await digester(canonicalise(unsigned)) };
|
|
85
|
+
await store.append(record);
|
|
86
|
+
return record;
|
|
87
|
+
}
|
|
88
|
+
async function verifyChain(records, options = {}) {
|
|
89
|
+
const digester = options.digester ?? hashDigester;
|
|
90
|
+
if (records.length === 0) {
|
|
91
|
+
return { ok: true, checked: 0 };
|
|
92
|
+
}
|
|
93
|
+
if (options.expectGenesis && records[0].previousDigest !== GENESIS_DIGEST) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
checked: 0,
|
|
97
|
+
brokenAt: records[0].sequence,
|
|
98
|
+
reason: "NOT_GENESIS",
|
|
99
|
+
message: `The chain starts at sequence ${records[0].sequence} without a genesis link — records before it are missing.`
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
let previous;
|
|
103
|
+
for (const [index, record] of records.entries()) {
|
|
104
|
+
if (previous) {
|
|
105
|
+
if (record.sequence !== previous.sequence + 1) {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
checked: index,
|
|
109
|
+
brokenAt: record.sequence,
|
|
110
|
+
reason: "OUT_OF_ORDER",
|
|
111
|
+
message: `Sequence jumps from ${previous.sequence} to ${record.sequence}; ${record.sequence - previous.sequence - 1} record(s) are missing.`
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (!timingSafeEqual(record.previousDigest, previous.digest)) {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
checked: index,
|
|
118
|
+
brokenAt: record.sequence,
|
|
119
|
+
reason: "BROKEN_LINK",
|
|
120
|
+
message: `Record ${record.sequence} does not follow record ${previous.sequence} — one of them was altered.`
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const expected = await digester(canonicalise(record));
|
|
125
|
+
if (!timingSafeEqual(expected, record.digest)) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
checked: index,
|
|
129
|
+
brokenAt: record.sequence,
|
|
130
|
+
reason: "BAD_DIGEST",
|
|
131
|
+
message: `Record ${record.sequence} does not hash to its own digest — its contents were edited in place.`
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
previous = record;
|
|
135
|
+
}
|
|
136
|
+
return { ok: true, checked: records.length };
|
|
137
|
+
}
|
|
138
|
+
async function exportRange(store, args, options = {}) {
|
|
139
|
+
const digester = options.digester ?? hashDigester;
|
|
140
|
+
if (args.toSequence < args.fromSequence) {
|
|
141
|
+
throw new AuditChainError("EMPTY_RANGE", "The range ends before it starts.");
|
|
142
|
+
}
|
|
143
|
+
const records = await store.range(args.realm, args.fromSequence, args.toSequence);
|
|
144
|
+
if (records.length === 0) {
|
|
145
|
+
throw new AuditChainError("EMPTY_RANGE", "No records in that range.");
|
|
146
|
+
}
|
|
147
|
+
const verdict = await verifyChain(records, {
|
|
148
|
+
digester,
|
|
149
|
+
expectGenesis: args.fromSequence <= 1
|
|
150
|
+
});
|
|
151
|
+
if (!verdict.ok) {
|
|
152
|
+
throw new AuditChainError(verdict.reason, verdict.message, verdict.brokenAt);
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
realm: args.realm,
|
|
156
|
+
fromSequence: records[0].sequence,
|
|
157
|
+
toSequence: records.at(-1).sequence,
|
|
158
|
+
anchorDigest: records[0].previousDigest,
|
|
159
|
+
records,
|
|
160
|
+
sealDigest: await digester(records.map((record) => record.digest).join("\x1E")),
|
|
161
|
+
exportedAtMs: (options.now ?? Date.now)()
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
async function verifyExport(slice, options = {}) {
|
|
165
|
+
const digester = options.digester ?? hashDigester;
|
|
166
|
+
const verdict = await verifyChain(slice.records, {
|
|
167
|
+
digester,
|
|
168
|
+
expectGenesis: slice.anchorDigest === GENESIS_DIGEST
|
|
169
|
+
});
|
|
170
|
+
if (!verdict.ok)
|
|
171
|
+
return verdict;
|
|
172
|
+
if (slice.records[0].previousDigest !== slice.anchorDigest) {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
checked: 0,
|
|
176
|
+
brokenAt: slice.fromSequence,
|
|
177
|
+
reason: "BROKEN_LINK",
|
|
178
|
+
message: "The slice does not hang from the anchor it claims."
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const seal = await digester(slice.records.map((record) => record.digest).join("\x1E"));
|
|
182
|
+
if (!timingSafeEqual(seal, slice.sealDigest)) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
checked: slice.records.length,
|
|
186
|
+
brokenAt: slice.fromSequence,
|
|
187
|
+
reason: "BAD_DIGEST",
|
|
188
|
+
message: "The slice does not match its own seal — records were added or removed after export."
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
if (options.expectSealDigest && !timingSafeEqual(seal, options.expectSealDigest)) {
|
|
192
|
+
return {
|
|
193
|
+
ok: false,
|
|
194
|
+
checked: slice.records.length,
|
|
195
|
+
brokenAt: slice.fromSequence,
|
|
196
|
+
reason: "BAD_DIGEST",
|
|
197
|
+
message: "The slice does not match the seal that was published at the time — history was rewritten after it."
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
return verdict;
|
|
201
|
+
}
|
|
202
|
+
function createAuditChain(options) {
|
|
203
|
+
const digester = options.digester ?? hashDigester;
|
|
204
|
+
const serial = createKeyedSerial();
|
|
205
|
+
const enqueue = (realm, run) => serial.run(realm ?? "\x00platform", run);
|
|
206
|
+
return {
|
|
207
|
+
append: (entry) => enqueue(entry.realm, () => appendRecord(options.store, entry, { ...options, digester })),
|
|
208
|
+
verify: async (realm, fromSequence = 1, toSequence = Number.MAX_SAFE_INTEGER) => {
|
|
209
|
+
const records = await options.store.range(realm, fromSequence, toSequence);
|
|
210
|
+
return verifyChain(records, { digester, expectGenesis: fromSequence <= 1 });
|
|
211
|
+
},
|
|
212
|
+
export: (args) => exportRange(options.store, args, { ...options, digester }),
|
|
213
|
+
sink: {
|
|
214
|
+
write(record) {
|
|
215
|
+
const entry = {
|
|
216
|
+
action: `route.${record.route}`,
|
|
217
|
+
outcome: record.outcome,
|
|
218
|
+
actor: record.userKey,
|
|
219
|
+
realm: record.realm,
|
|
220
|
+
targetKey: record.targetKey,
|
|
221
|
+
detail: {
|
|
222
|
+
method: record.method,
|
|
223
|
+
status: record.status,
|
|
224
|
+
...record.code ? { code: record.code } : {},
|
|
225
|
+
...flattenDetail(record.detail)
|
|
226
|
+
},
|
|
227
|
+
...record.outcome === "denied" && record.code ? { reason: record.code } : {}
|
|
228
|
+
};
|
|
229
|
+
enqueue(record.realm, () => appendRecord(options.store, entry, { ...options, digester })).catch((error) => options.onError?.(error, entry));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
function flattenDetail(detail) {
|
|
235
|
+
if (!detail)
|
|
236
|
+
return {};
|
|
237
|
+
const flat = {};
|
|
238
|
+
for (const [key, value] of Object.entries(detail)) {
|
|
239
|
+
if (value === null || value === undefined)
|
|
240
|
+
flat[key] = null;
|
|
241
|
+
else if (typeof value === "object")
|
|
242
|
+
flat[key] = JSON.stringify(value);
|
|
243
|
+
else if (typeof value === "bigint")
|
|
244
|
+
flat[key] = value.toString();
|
|
245
|
+
else
|
|
246
|
+
flat[key] = value;
|
|
247
|
+
}
|
|
248
|
+
return flat;
|
|
249
|
+
}
|
|
250
|
+
function memoryStore() {
|
|
251
|
+
const chains = new Map;
|
|
252
|
+
const keyOf = (realm) => realm ?? "\x00platform";
|
|
253
|
+
return {
|
|
254
|
+
async tail(realm) {
|
|
255
|
+
return chains.get(keyOf(realm))?.at(-1) ?? null;
|
|
256
|
+
},
|
|
257
|
+
async append(record) {
|
|
258
|
+
const key = keyOf(record.realm);
|
|
259
|
+
const chain = chains.get(key) ?? [];
|
|
260
|
+
if (chain.some((existing) => existing.sequence === record.sequence)) {
|
|
261
|
+
throw new AuditChainError("OUT_OF_ORDER", `Sequence ${record.sequence} already exists in this realm.`, record.sequence);
|
|
262
|
+
}
|
|
263
|
+
chain.push(record);
|
|
264
|
+
chains.set(key, chain);
|
|
265
|
+
},
|
|
266
|
+
async range(realm, fromSequence, toSequence) {
|
|
267
|
+
return (chains.get(keyOf(realm)) ?? []).filter((record) => record.sequence >= fromSequence && record.sequence <= toSequence).sort((a, b) => a.sequence - b.sequence);
|
|
268
|
+
},
|
|
269
|
+
all(realm) {
|
|
270
|
+
return [...chains.get(keyOf(realm)) ?? []];
|
|
271
|
+
},
|
|
272
|
+
clear() {
|
|
273
|
+
chains.clear();
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
var VERSION = "0.1.0";
|
|
278
|
+
export {
|
|
279
|
+
verifyExport,
|
|
280
|
+
verifyChain,
|
|
281
|
+
sealedDigester,
|
|
282
|
+
memoryStore,
|
|
283
|
+
hashDigester,
|
|
284
|
+
exportRange,
|
|
285
|
+
createAuditChain,
|
|
286
|
+
canonicalise,
|
|
287
|
+
appendRecord,
|
|
288
|
+
VERSION,
|
|
289
|
+
GENESIS_DIGEST,
|
|
290
|
+
AuditChainError
|
|
291
|
+
};
|
package/dist/backup.d.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encrypted snapshots, and the restore that reads them back.
|
|
3
|
+
*
|
|
4
|
+
* A backup nobody has restored is a hypothesis. The two halves are one module
|
|
5
|
+
* precisely so the restore is exercised by the same test that writes the
|
|
6
|
+
* snapshot — the alternative is a backup job that has run nightly for a year
|
|
7
|
+
* and a restore path that has never been executed, which is the configuration
|
|
8
|
+
* everybody discovers on the worst possible day.
|
|
9
|
+
*
|
|
10
|
+
* ## Sealed before it leaves the machine
|
|
11
|
+
*
|
|
12
|
+
* Every chunk is encrypted under a key derived from the realm master seed, with
|
|
13
|
+
* AAD binding the realm, the snapshot and the chunk index. The object store —
|
|
14
|
+
* ours or the tenant's — holds ciphertext it cannot read, which is what makes
|
|
15
|
+
* "point it at your own bucket" a real offer rather than a compliance-flavoured
|
|
16
|
+
* one. It also means a snapshot lifted from realm A fails its GCM tag when
|
|
17
|
+
* restored into realm B rather than quietly loading someone else's data.
|
|
18
|
+
*
|
|
19
|
+
* ## Chunked, and each chunk verified
|
|
20
|
+
*
|
|
21
|
+
* A snapshot is written in chunks with a digest per chunk in the manifest. A
|
|
22
|
+
* truncated upload — the ordinary outcome of a network failure mid-transfer —
|
|
23
|
+
* otherwise produces an object that exists, has a plausible size, and restores
|
|
24
|
+
* into a silently incomplete database. Verification catches it while there is
|
|
25
|
+
* still a good copy to fall back on.
|
|
26
|
+
*
|
|
27
|
+
* ## Restore refuses more than it accepts
|
|
28
|
+
*
|
|
29
|
+
* A failed restore is recoverable; a corrupt one is not, because it overwrites
|
|
30
|
+
* the thing it was supposed to save. Every check therefore fails closed: a
|
|
31
|
+
* missing chunk, a digest mismatch, a realm mismatch and an unknown format all
|
|
32
|
+
* refuse rather than doing their best.
|
|
33
|
+
*/
|
|
34
|
+
export declare class BackupError extends Error {
|
|
35
|
+
readonly code: 'CHUNK_MISSING' | 'DIGEST_MISMATCH' | 'REALM_MISMATCH' | 'BAD_MANIFEST' | 'UNSUPPORTED_FORMAT' | 'DECRYPT_FAILED' | 'EMPTY_SNAPSHOT' | 'RETENTION_WOULD_EMPTY';
|
|
36
|
+
constructor(code: 'CHUNK_MISSING' | 'DIGEST_MISMATCH' | 'REALM_MISMATCH' | 'BAD_MANIFEST' | 'UNSUPPORTED_FORMAT' | 'DECRYPT_FAILED' | 'EMPTY_SNAPSHOT' | 'RETENTION_WOULD_EMPTY', message: string);
|
|
37
|
+
}
|
|
38
|
+
/** Bumped only for a change that an older reader cannot handle. */
|
|
39
|
+
export declare const SNAPSHOT_FORMAT = 1;
|
|
40
|
+
export interface ChunkRecord {
|
|
41
|
+
index: number;
|
|
42
|
+
/** Object key in the store. */
|
|
43
|
+
object: string;
|
|
44
|
+
/** SHA-256 of the PLAINTEXT, so a restore verifies what it actually recovered. */
|
|
45
|
+
digest: string;
|
|
46
|
+
rows: number;
|
|
47
|
+
bytes: number;
|
|
48
|
+
}
|
|
49
|
+
export interface SnapshotManifest {
|
|
50
|
+
format: number;
|
|
51
|
+
id: string;
|
|
52
|
+
realm: string;
|
|
53
|
+
createdAtMs: number;
|
|
54
|
+
/** Which collections, and how many rows from each. */
|
|
55
|
+
collections: Record<string, number>;
|
|
56
|
+
chunks: readonly ChunkRecord[];
|
|
57
|
+
totalRows: number;
|
|
58
|
+
/** Digest over every chunk digest — one value that fixes the whole snapshot. */
|
|
59
|
+
sealDigest: string;
|
|
60
|
+
/** Free-form: schema version, application version, who triggered it. */
|
|
61
|
+
labels?: Record<string, string>;
|
|
62
|
+
}
|
|
63
|
+
/** The subset of an object store this needs. `@forgezero/providers/storage` satisfies it. */
|
|
64
|
+
export interface ObjectStore {
|
|
65
|
+
putObject(args: {
|
|
66
|
+
key: string;
|
|
67
|
+
body: Uint8Array | string;
|
|
68
|
+
contentType?: string;
|
|
69
|
+
}): Promise<unknown>;
|
|
70
|
+
getObject(key: string): Promise<Uint8Array>;
|
|
71
|
+
listObjects(prefix?: string, limit?: number): Promise<{
|
|
72
|
+
key: string;
|
|
73
|
+
size: number;
|
|
74
|
+
}[]>;
|
|
75
|
+
deleteObject(key: string): Promise<void>;
|
|
76
|
+
}
|
|
77
|
+
/** Where the rows come from. Async-iterable so a large collection never lands in memory whole. */
|
|
78
|
+
export interface RowSource {
|
|
79
|
+
collections(): Promise<string[]>;
|
|
80
|
+
rows(collection: string): AsyncIterable<Record<string, unknown>>;
|
|
81
|
+
}
|
|
82
|
+
/** Where they go on the way back. */
|
|
83
|
+
export interface RowSink {
|
|
84
|
+
/**
|
|
85
|
+
* Called once per chunk with the rows it held.
|
|
86
|
+
*
|
|
87
|
+
* Per chunk rather than per row so a sink can use a bulk import, and so a
|
|
88
|
+
* failure names a bounded set of rows rather than one.
|
|
89
|
+
*/
|
|
90
|
+
write(collection: string, rows: Record<string, unknown>[]): Promise<void>;
|
|
91
|
+
/** Called before anything is written. The sink's chance to refuse or truncate. */
|
|
92
|
+
begin?(manifest: SnapshotManifest): Promise<void>;
|
|
93
|
+
finish?(manifest: SnapshotManifest): Promise<void>;
|
|
94
|
+
}
|
|
95
|
+
export interface SnapshotOptions {
|
|
96
|
+
store: ObjectStore;
|
|
97
|
+
source: RowSource;
|
|
98
|
+
realm: string;
|
|
99
|
+
masterSeed: Uint8Array;
|
|
100
|
+
/** Rows per chunk. Smaller means more objects; larger means more memory. */
|
|
101
|
+
chunkRows?: number;
|
|
102
|
+
prefix?: string;
|
|
103
|
+
labels?: Record<string, string>;
|
|
104
|
+
id?: string;
|
|
105
|
+
now?: () => number;
|
|
106
|
+
onProgress?: (progress: {
|
|
107
|
+
collection: string;
|
|
108
|
+
rows: number;
|
|
109
|
+
chunks: number;
|
|
110
|
+
}) => void;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Write a snapshot.
|
|
114
|
+
*
|
|
115
|
+
* The manifest is written **last**, deliberately. Its presence is what marks a
|
|
116
|
+
* snapshot complete, so a run that dies halfway leaves orphaned chunks and no
|
|
117
|
+
* manifest — which `listSnapshots` skips and `prune` can collect. The opposite
|
|
118
|
+
* order produces a manifest advertising chunks that were never uploaded, and
|
|
119
|
+
* that snapshot looks valid until somebody needs it.
|
|
120
|
+
*/
|
|
121
|
+
export declare function snapshot(options: SnapshotOptions): Promise<SnapshotManifest>;
|
|
122
|
+
export interface VerifyReport {
|
|
123
|
+
ok: boolean;
|
|
124
|
+
id: string;
|
|
125
|
+
chunksChecked: number;
|
|
126
|
+
rowsChecked: number;
|
|
127
|
+
problems: {
|
|
128
|
+
chunk: number;
|
|
129
|
+
code: BackupError['code'];
|
|
130
|
+
message: string;
|
|
131
|
+
}[];
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Read every chunk, decrypt it, and check it against the manifest.
|
|
135
|
+
*
|
|
136
|
+
* Reports EVERY problem rather than the first, which is the opposite of the
|
|
137
|
+
* audit chain's rule and correct for the same underlying reason: chunks are
|
|
138
|
+
* independent, so a second failure is genuinely new information, whereas a
|
|
139
|
+
* second broken chain link is only a consequence of the first. Knowing whether
|
|
140
|
+
* one chunk or forty are bad decides whether the snapshot is repairable.
|
|
141
|
+
*/
|
|
142
|
+
export declare function verifySnapshot(args: {
|
|
143
|
+
store: ObjectStore;
|
|
144
|
+
manifest: SnapshotManifest;
|
|
145
|
+
masterSeed: Uint8Array;
|
|
146
|
+
}): Promise<VerifyReport>;
|
|
147
|
+
export interface RestoreOptions {
|
|
148
|
+
store: ObjectStore;
|
|
149
|
+
sink: RowSink;
|
|
150
|
+
manifest: SnapshotManifest;
|
|
151
|
+
masterSeed: Uint8Array;
|
|
152
|
+
/** The realm being restored INTO. Must match the manifest. */
|
|
153
|
+
realm: string;
|
|
154
|
+
/**
|
|
155
|
+
* Skip the verify pass.
|
|
156
|
+
*
|
|
157
|
+
* Exists for a restore under time pressure on a snapshot verified an hour
|
|
158
|
+
* ago. Off by default: writing unverified data over a broken database is how
|
|
159
|
+
* a recoverable outage becomes data loss.
|
|
160
|
+
*/
|
|
161
|
+
skipVerify?: boolean;
|
|
162
|
+
onProgress?: (progress: {
|
|
163
|
+
chunk: number;
|
|
164
|
+
of: number;
|
|
165
|
+
rows: number;
|
|
166
|
+
}) => void;
|
|
167
|
+
}
|
|
168
|
+
export interface RestoreReport {
|
|
169
|
+
id: string;
|
|
170
|
+
rowsRestored: number;
|
|
171
|
+
collections: Record<string, number>;
|
|
172
|
+
}
|
|
173
|
+
export declare function restore(options: RestoreOptions): Promise<RestoreReport>;
|
|
174
|
+
export declare function listSnapshots(args: {
|
|
175
|
+
store: ObjectStore;
|
|
176
|
+
realm: string;
|
|
177
|
+
prefix?: string;
|
|
178
|
+
}): Promise<SnapshotManifest[]>;
|
|
179
|
+
export interface RetentionPolicy {
|
|
180
|
+
/** Most recent N, regardless of age. */
|
|
181
|
+
keepLast: number;
|
|
182
|
+
keepDaily?: number;
|
|
183
|
+
keepWeekly?: number;
|
|
184
|
+
keepMonthly?: number;
|
|
185
|
+
}
|
|
186
|
+
export declare const DEFAULT_RETENTION: RetentionPolicy;
|
|
187
|
+
/**
|
|
188
|
+
* Decide what to keep.
|
|
189
|
+
*
|
|
190
|
+
* Grandfather-father-son: the newest few unconditionally, then one per day, per
|
|
191
|
+
* week and per month. The alternative — "delete anything older than N days" —
|
|
192
|
+
* loses the ability to recover from a corruption that was introduced before the
|
|
193
|
+
* window and only noticed afterwards, which is the common case for corruption.
|
|
194
|
+
*
|
|
195
|
+
* The **newest snapshot is never selected for deletion**, whatever the policy
|
|
196
|
+
* says. A retention rule that can empty the bucket is a data-loss bug waiting
|
|
197
|
+
* for a clock skew or a misconfigured `keepLast: 0`.
|
|
198
|
+
*/
|
|
199
|
+
export declare function selectForDeletion(manifests: readonly SnapshotManifest[], policy?: RetentionPolicy): SnapshotManifest[];
|
|
200
|
+
export declare function prune(args: {
|
|
201
|
+
store: ObjectStore;
|
|
202
|
+
realm: string;
|
|
203
|
+
policy?: RetentionPolicy;
|
|
204
|
+
prefix?: string;
|
|
205
|
+
/** Report what would go without removing it. */
|
|
206
|
+
dryRun?: boolean;
|
|
207
|
+
}): Promise<{
|
|
208
|
+
deleted: string[];
|
|
209
|
+
kept: number;
|
|
210
|
+
}>;
|
|
211
|
+
/**
|
|
212
|
+
* The nightly snapshot, as a job spec.
|
|
213
|
+
*
|
|
214
|
+
* Verification runs immediately after the write, on purpose. Verifying a
|
|
215
|
+
* snapshot a week later tells you it was broken a week ago; verifying it now
|
|
216
|
+
* tells you while the source data still exists to take another one.
|
|
217
|
+
*/
|
|
218
|
+
export declare function backupJob(options: SnapshotOptions & {
|
|
219
|
+
policy?: RetentionPolicy;
|
|
220
|
+
key?: string;
|
|
221
|
+
everyMs?: number;
|
|
222
|
+
}): {
|
|
223
|
+
key: string;
|
|
224
|
+
everyMs: number;
|
|
225
|
+
run: () => Promise<{
|
|
226
|
+
ok: boolean;
|
|
227
|
+
detail: {
|
|
228
|
+
id: string;
|
|
229
|
+
problems: number;
|
|
230
|
+
rows?: undefined;
|
|
231
|
+
pruned?: undefined;
|
|
232
|
+
};
|
|
233
|
+
} | {
|
|
234
|
+
ok: boolean;
|
|
235
|
+
detail: {
|
|
236
|
+
id: string;
|
|
237
|
+
rows: number;
|
|
238
|
+
pruned: number;
|
|
239
|
+
problems?: undefined;
|
|
240
|
+
};
|
|
241
|
+
}>;
|
|
242
|
+
};
|
|
243
|
+
export declare const VERSION = "0.1.0";
|