@coffre/vault 0.13.0 → 0.14.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/dist/cloudflare.d.ts +1 -0
- package/dist/cloudflare.js +1 -1
- package/dist/node.js +40 -5
- package/dist/{vault-DEjPmLNt.js → vault-hLffpMy5.js} +85 -48
- package/package.json +3 -3
- package/src/failure.ts +20 -0
- package/src/node.ts +10 -5
- package/src/vault.ts +83 -42
package/dist/cloudflare.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ export declare class VaultEntrypoint extends WorkerEntrypoint implements Vault {
|
|
|
18
18
|
#private;
|
|
19
19
|
unwrap(input: UnwrapInput): Promise<import("@coffre/core/vault").Outcome<{
|
|
20
20
|
keys: string[];
|
|
21
|
+
accessRevision?: string;
|
|
21
22
|
}>>;
|
|
22
23
|
wrap(input: WrapInput): Promise<import("@coffre/core/vault").Outcome<import("@coffre/core/vault").Wrapped>>;
|
|
23
24
|
rewrap(input: RewrapInput): Promise<import("@coffre/core/vault").Outcome<import("@coffre/core/vault").Wrapped>>;
|
package/dist/cloudflare.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError, o as derivedSigningKey, r as awsKms, s as resolveVaultConfig, t as KekBadClaimError } from "./src-B0d-W-SI.js";
|
|
2
|
-
import { n as prepareVault, t as openVault } from "./vault-
|
|
2
|
+
import { n as prepareVault, t as openVault } from "./vault-hLffpMy5.js";
|
|
3
3
|
import { createDatabase } from "@coffre/db";
|
|
4
4
|
import { HyperdrivePool, postgres } from "@coffre/db/hyperdrive";
|
|
5
5
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
package/dist/node.js
CHANGED
|
@@ -1,8 +1,41 @@
|
|
|
1
1
|
import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError, o as derivedSigningKey, r as awsKms, s as resolveVaultConfig, t as KekBadClaimError } from "./src-B0d-W-SI.js";
|
|
2
|
-
import { n as prepareVault, t as openVault } from "./vault-
|
|
2
|
+
import { n as prepareVault, t as openVault } from "./vault-hLffpMy5.js";
|
|
3
|
+
import { isUnavailable } from "@coffre/db/dialect";
|
|
3
4
|
import { chmodSync, existsSync, lstatSync, rmSync } from "node:fs";
|
|
4
5
|
import { Agent, createServer, request } from "node:http";
|
|
5
6
|
import { openDatabase } from "@coffre/db/connect";
|
|
7
|
+
//#region src/failure.ts
|
|
8
|
+
/**
|
|
9
|
+
* A vault call's failure, as `serveVault` answers it: its message, and the
|
|
10
|
+
* code its causes carry, which `connectVault` puts back on its error, so
|
|
11
|
+
* the app reads it as it would in process. Its database, or a key service,
|
|
12
|
+
* not answering is the app's outage too, not a bug: 503. One only its
|
|
13
|
+
* words say (pg's "Connection terminated") goes as Postgres's code for a
|
|
14
|
+
* connection that failed, 08006.
|
|
15
|
+
*/
|
|
16
|
+
function failureOf(error) {
|
|
17
|
+
const message = error instanceof Error ? error.message : "the vault failed";
|
|
18
|
+
let code;
|
|
19
|
+
for (let cause = error; typeof cause === "object" && cause !== null && code === void 0; cause = cause.cause) {
|
|
20
|
+
const found = cause.code;
|
|
21
|
+
if (typeof found === "string") code = found;
|
|
22
|
+
}
|
|
23
|
+
if (!isUnavailable(error)) return {
|
|
24
|
+
status: 500,
|
|
25
|
+
body: code === void 0 ? { error: message } : {
|
|
26
|
+
error: message,
|
|
27
|
+
code
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
status: 503,
|
|
32
|
+
body: {
|
|
33
|
+
error: message,
|
|
34
|
+
code: code !== void 0 && isUnavailable({ code }) ? code : "08006"
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
6
39
|
//#region src/local.ts
|
|
7
40
|
/** Every call the vault answers, in the order of the `Vault` interface. */
|
|
8
41
|
const METHODS = [
|
|
@@ -89,7 +122,8 @@ async function serveVault(config) {
|
|
|
89
122
|
reply(200, result === void 0 ? {} : { result });
|
|
90
123
|
} catch (error) {
|
|
91
124
|
console.error(`vault ${name} failed`, error);
|
|
92
|
-
|
|
125
|
+
const { status, body } = failureOf(error);
|
|
126
|
+
reply(status, body);
|
|
93
127
|
}
|
|
94
128
|
});
|
|
95
129
|
server.keepAliveTimeout = 0;
|
|
@@ -127,11 +161,12 @@ function connectVault(socket) {
|
|
|
127
161
|
}, (res) => {
|
|
128
162
|
readBody(res).then((text) => {
|
|
129
163
|
const payload = JSON.parse(text);
|
|
130
|
-
if (res.statusCode === 200) resolve(payload.result);
|
|
131
|
-
|
|
164
|
+
if (res.statusCode === 200) return resolve(payload.result);
|
|
165
|
+
const failed = /* @__PURE__ */ new Error(`vault ${name}: ${payload.error ?? `status ${res.statusCode}`}`);
|
|
166
|
+
reject(payload.code === void 0 ? failed : Object.assign(failed, { code: payload.code }));
|
|
132
167
|
}, reject);
|
|
133
168
|
});
|
|
134
|
-
req.on("error", (error) => reject(
|
|
169
|
+
req.on("error", (error) => reject(new Error(`the vault at ${socket} is unreachable: ${error.message}`, { cause: error.code === "ENOENT" ? Object.assign(error, { code: "ECONNREFUSED" }) : error })));
|
|
135
170
|
req.end(body);
|
|
136
171
|
});
|
|
137
172
|
return Object.fromEntries(METHODS.map((name) => [name, call(name)]));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ACCESS_ACTIONS, ACCESS_ACTIONS as ACCESS_ACTIONS$1, POLICY_REVISION, checkpointMessage, checkpointVerifier, describeAccessFault, handsBack, policyRevision } from "@coffre/core/vault";
|
|
1
|
+
import { ACCESS_ACTIONS, ACCESS_ACTIONS as ACCESS_ACTIONS$1, POLICY_REVISION, accessRevision, checkpointMessage, checkpointVerifier, describeAccessFault, handsBack, policyRevision } from "@coffre/core/vault";
|
|
2
2
|
import { DEK_BYTES, KEY_CHECK, KEY_CHECK_CONTEXT, KEY_CHECK_VALUE, KekBadClaimError, KekCancelledError, KekUnavailableError, LocalKekProvider } from "@coffre/core/kek";
|
|
3
3
|
import { createHash, createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
4
|
import { prepared, tablesOf } from "@coffre/db";
|
|
@@ -6,7 +6,7 @@ import { DEFAULT_POLICY, EVERYWHERE, EVERY_ENVIRONMENT, FIXED_ROLES, INSTANCE_RO
|
|
|
6
6
|
import { GENESIS_HASH, deriveLogKey, verifyEntries } from "@coffre/core/audit";
|
|
7
7
|
import { checkContext, ownerKey } from "@coffre/core/envelope";
|
|
8
8
|
import { TOMBSTONE, shownMember } from "@coffre/core/schemas";
|
|
9
|
-
import { SNAPSHOT, clockMillis, engineOf, forUpdate,
|
|
9
|
+
import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUnavailable, isUniqueViolation, rowsOf, tombstone, truth } from "@coffre/db/dialect";
|
|
10
10
|
import { accessEntriesAbout, everyProjectConversion, insertGrant, liveProjects, newestAccess, readGrants, reportStands, roleColumns, storedRole } from "@coffre/db/grants";
|
|
11
11
|
import { LogHeadMismatch, appendEntries, entryColumns, lockLogHead, missingEntry, newestAuthentic, newestVaultEntries, withinBytes } from "@coffre/db/log";
|
|
12
12
|
import { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm";
|
|
@@ -1891,6 +1891,7 @@ const MESSAGES = {
|
|
|
1891
1891
|
last_owner: "the last Owner of the whole instance stays one: make someone else an Owner first",
|
|
1892
1892
|
invalid: "not something the rules allow",
|
|
1893
1893
|
policy_changed: "the instance's roles or protected environments changed while this was asked: try again",
|
|
1894
|
+
access_changed: "what this member holds changed since the read was decided: try again",
|
|
1894
1895
|
log_broken: "the vault log does not hold from the last checkpoint",
|
|
1895
1896
|
wrong_kek: "this vault's key does not open the data it holds",
|
|
1896
1897
|
tampered: "this member's record failed the vault's integrity check"
|
|
@@ -2390,6 +2391,7 @@ var VaultService = class {
|
|
|
2390
2391
|
const { principal } = input;
|
|
2391
2392
|
validateText(input.purpose);
|
|
2392
2393
|
if (input.keepsSensitive !== void 0 && typeof input.keepsSensitive !== "boolean") throw new Error("keepsSensitive is true, false or absent");
|
|
2394
|
+
if (input.accessRevision !== void 0 && typeof input.accessRevision !== "string") throw new Error("accessRevision names an answer of access, or is absent");
|
|
2393
2395
|
const vias = input.items.map((item) => validateVia(item.via));
|
|
2394
2396
|
const throughs = input.items.map((item) => validateThrough(item.through));
|
|
2395
2397
|
const loaded = await this.#versions(input, "secret.read", { purpose: input.purpose });
|
|
@@ -2431,8 +2433,8 @@ var VaultService = class {
|
|
|
2431
2433
|
secretVersionId: versionIds.get(secret)
|
|
2432
2434
|
};
|
|
2433
2435
|
};
|
|
2434
|
-
if (viaOk.includes(false)) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")));
|
|
2435
|
-
if (throughOk.includes(false)) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")));
|
|
2436
|
+
if (viaOk.includes(false)) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")), { asked: input.accessRevision });
|
|
2437
|
+
if (throughOk.includes(false)) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")), { asked: input.accessRevision });
|
|
2436
2438
|
const ends = sealed.flatMap((seal) => seal === null ? [] : [seal.seq]);
|
|
2437
2439
|
const remote = items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
|
|
2438
2440
|
return this.#keys({
|
|
@@ -2453,13 +2455,16 @@ var VaultService = class {
|
|
|
2453
2455
|
key,
|
|
2454
2456
|
wipe: () => key.fill(0)
|
|
2455
2457
|
};
|
|
2456
|
-
}), (opened) => ({
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2458
|
+
}), (opened) => ({
|
|
2459
|
+
keys: opened.map(({ key }) => {
|
|
2460
|
+
try {
|
|
2461
|
+
return base64(key);
|
|
2462
|
+
} finally {
|
|
2463
|
+
key.fill(0);
|
|
2464
|
+
}
|
|
2465
|
+
}),
|
|
2466
|
+
...input.accessRevision === void 0 ? {} : { accessRevision: input.accessRevision }
|
|
2467
|
+
}));
|
|
2463
2468
|
}
|
|
2464
2469
|
async wrap(input) {
|
|
2465
2470
|
const { principal, items } = input;
|
|
@@ -2510,7 +2515,7 @@ var VaultService = class {
|
|
|
2510
2515
|
if (items.some((item, i) => !loosens[i] && item.secret.sensitive !== true && strict(i))) return this.#refuseVersions(principal, "invalid", loaded.versions.map((source) => ({
|
|
2511
2516
|
...keyEntry("key.rewrap", principal, source.secret, "deny", "invalid", input),
|
|
2512
2517
|
secretVersionId: source.id
|
|
2513
|
-
})), LOOSER);
|
|
2518
|
+
})), { message: LOOSER });
|
|
2514
2519
|
const sources = new Map(items.map((item, i) => [item.secret, loaded.versions[i]]));
|
|
2515
2520
|
const loosened = new Set(items.flatMap((item, i) => loosens[i] ? [item.secret] : []));
|
|
2516
2521
|
const entry = (secret, decision, code) => ({
|
|
@@ -2572,7 +2577,7 @@ var VaultService = class {
|
|
|
2572
2577
|
if ([...found.values()].some((version) => version.deleted)) return this.#refuseVersions(input.principal, "deleted", [...found.values()].map((version) => ({
|
|
2573
2578
|
...keyEntry(action, input.principal, version.secret, "deny", "deleted", input, detail),
|
|
2574
2579
|
secretVersionId: version.id
|
|
2575
|
-
})));
|
|
2580
|
+
})), { asked: input.accessRevision });
|
|
2576
2581
|
if (ids.some((id) => !found.has(id))) return this.#refuseVersions(input.principal, "bad_claim", ids.map((id) => {
|
|
2577
2582
|
const version = found.get(id);
|
|
2578
2583
|
if (version === void 0) return {
|
|
@@ -2592,14 +2597,23 @@ var VaultService = class {
|
|
|
2592
2597
|
...keyEntry(action, input.principal, version.secret, "deny", "bad_claim", input, detail),
|
|
2593
2598
|
secretVersionId: id
|
|
2594
2599
|
};
|
|
2595
|
-
}));
|
|
2600
|
+
}), { asked: input.accessRevision });
|
|
2596
2601
|
return {
|
|
2597
2602
|
ok: true,
|
|
2598
2603
|
versions: ids.map((id) => found.get(id))
|
|
2599
2604
|
};
|
|
2600
2605
|
}
|
|
2601
|
-
|
|
2602
|
-
|
|
2606
|
+
/**
|
|
2607
|
+
* Refuse a call for what its versions are, under the principal's row, as
|
|
2608
|
+
* any decision: and a read on a kept answer no longer theirs as that, not
|
|
2609
|
+
* for its versions, logging no read (`accessRevision`).
|
|
2610
|
+
*/
|
|
2611
|
+
async #refuseVersions(principal, code, entries, { message = MESSAGES[code], asked } = {}) {
|
|
2612
|
+
return this.#decide([principal], async (d) => {
|
|
2613
|
+
if (asked !== void 0) {
|
|
2614
|
+
const standing = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads);
|
|
2615
|
+
if (this.#revisionOf(standing, d) !== asked) throw new Refused(refusal("access_changed", MESSAGES.access_changed), []);
|
|
2616
|
+
}
|
|
2603
2617
|
throw new Refused(refusal(code, message), entries);
|
|
2604
2618
|
});
|
|
2605
2619
|
}
|
|
@@ -2636,7 +2650,10 @@ var VaultService = class {
|
|
|
2636
2650
|
});
|
|
2637
2651
|
const owners = /* @__PURE__ */ new Map();
|
|
2638
2652
|
const check = async (d) => {
|
|
2639
|
-
const
|
|
2653
|
+
const standing = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads);
|
|
2654
|
+
const asked = call.input.accessRevision;
|
|
2655
|
+
if (asked !== void 0 && this.#revisionOf(standing, d) !== asked) throw new Refused(refusal("access_changed", MESSAGES.access_changed), []);
|
|
2656
|
+
const reader = through(standing, call.input);
|
|
2640
2657
|
const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
|
|
2641
2658
|
const environments = d.reads.environments;
|
|
2642
2659
|
owners.clear();
|
|
@@ -2725,7 +2742,7 @@ var VaultService = class {
|
|
|
2725
2742
|
try {
|
|
2726
2743
|
await check(d);
|
|
2727
2744
|
} catch (error) {
|
|
2728
|
-
if (error instanceof Refused) throw new Refused(error.refusal, secrets.map((secret, item) => outcomeEntry(secret, item, "deny", error.entries[item]
|
|
2745
|
+
if (error instanceof Refused && !(error.refusal.code === "access_changed" && intentSeq === null)) throw new Refused(error.refusal, secrets.map((secret, item) => outcomeEntry(secret, item, "deny", error.entries[item]?.code ?? error.refusal.code)));
|
|
2729
2746
|
throw error;
|
|
2730
2747
|
}
|
|
2731
2748
|
};
|
|
@@ -3110,7 +3127,7 @@ var VaultService = class {
|
|
|
3110
3127
|
}
|
|
3111
3128
|
#access(principal, row, held, at, tampered, policy) {
|
|
3112
3129
|
const active = PRINCIPAL.test(principal) && !tampered && row?.status === "active";
|
|
3113
|
-
|
|
3130
|
+
const access = {
|
|
3114
3131
|
principal,
|
|
3115
3132
|
status: tampered ? "tampered" : !PRINCIPAL.test(principal) ? "unknown" : row?.status ?? "unknown",
|
|
3116
3133
|
generation: row?.generation ?? 0,
|
|
@@ -3123,6 +3140,16 @@ var VaultService = class {
|
|
|
3123
3140
|
by: row?.statusChangedBy ?? null,
|
|
3124
3141
|
policy
|
|
3125
3142
|
};
|
|
3143
|
+
return {
|
|
3144
|
+
...access,
|
|
3145
|
+
revision: accessRevision(access)
|
|
3146
|
+
};
|
|
3147
|
+
}
|
|
3148
|
+
/** The revision of what `access` would answer about a standing a decision read: none but an active member's matches a kept answer. */
|
|
3149
|
+
#revisionOf(standing, d) {
|
|
3150
|
+
const { policy } = standing.live;
|
|
3151
|
+
if (standing.status !== "active" || policy === void 0) return null;
|
|
3152
|
+
return this.#access(standing.principal, d.members.get(standing.principal), standing.stored, d.at, false, policy).revision;
|
|
3126
3153
|
}
|
|
3127
3154
|
/**
|
|
3128
3155
|
* What `principal` holds now. Read without locks, the fast way; a row that
|
|
@@ -3959,15 +3986,16 @@ var VaultService = class {
|
|
|
3959
3986
|
/**
|
|
3960
3987
|
* Sign the log up to its last entry, from the newest checkpoint's tally.
|
|
3961
3988
|
*
|
|
3962
|
-
*
|
|
3963
|
-
*
|
|
3964
|
-
* write nothing to the log; the prefix the newest checkpoint signed
|
|
3965
|
-
* there, its hash where it was, which a rewrite chained again
|
|
3966
|
-
* every entry of it, which a cut changes
|
|
3967
|
-
*
|
|
3968
|
-
*
|
|
3969
|
-
*
|
|
3970
|
-
*
|
|
3989
|
+
* Without the log's lock, so that no append waits on it, and in one short
|
|
3990
|
+
* snapshot: every member's row checked, since rows changed around the
|
|
3991
|
+
* vault write nothing to the log; the prefix the newest checkpoint signed
|
|
3992
|
+
* still there, its hash where it was, which a rewrite chained again
|
|
3993
|
+
* changes, and every entry of it, which a cut changes. Then, in no
|
|
3994
|
+
* transaction, a batch a statement, the chain recomputed from there, every
|
|
3995
|
+
* hash from content and every vault entry by its MAC, no further than one
|
|
3996
|
+
* walk (`walks.checkpoint`); and a stretch of the prefixes signed before
|
|
3997
|
+
* rehashed (`#scrub`). Then, under the lock, the entries appended since
|
|
3998
|
+
* the walk, so that it signs the head.
|
|
3971
3999
|
*
|
|
3972
4000
|
* A walk cut short signs where it got to, behind the head: the log after
|
|
3973
4001
|
* an upgrade from 0.10.0, whose checkpoints kept no tally, is signed again
|
|
@@ -3980,7 +4008,7 @@ var VaultService = class {
|
|
|
3980
4008
|
const { walks } = this.#prepared.options;
|
|
3981
4009
|
const found = [];
|
|
3982
4010
|
let seen = null;
|
|
3983
|
-
const
|
|
4011
|
+
const resumed = await this.#db.transaction(async (tx) => {
|
|
3984
4012
|
seen = (await newestCheckpointEntry(tx))?.seq ?? null;
|
|
3985
4013
|
await this.#sweep(tx, found);
|
|
3986
4014
|
const from = await this.#resume(tx, "newest");
|
|
@@ -3997,20 +4025,9 @@ var VaultService = class {
|
|
|
3997
4025
|
failedAtSeq: Number(gap.found),
|
|
3998
4026
|
reason: `sequence gap: expected seq ${gap.expected}, found ${gap.found}`
|
|
3999
4027
|
};
|
|
4000
|
-
|
|
4001
|
-
const chain = await rehash(tx, logKeys, from.at, walk, { limit: walks.checkpoint });
|
|
4002
|
-
if (!chain.ok) return chain;
|
|
4003
|
-
const unsigned = await this.#checkpointFault(tx, walk.checkpoints);
|
|
4004
|
-
if (unsigned !== null) return unsigned;
|
|
4005
|
-
return {
|
|
4006
|
-
ok: true,
|
|
4007
|
-
from,
|
|
4008
|
-
tally: walk.tally(),
|
|
4009
|
-
digest: walk.digest(),
|
|
4010
|
-
at: chain.at,
|
|
4011
|
-
done: chain.done
|
|
4012
|
-
};
|
|
4028
|
+
return from;
|
|
4013
4029
|
}, SNAPSHOT);
|
|
4030
|
+
const walked = resumed.ok ? await this.#walkOn(resumed, walks.checkpoint) : resumed;
|
|
4014
4031
|
let scrub = {
|
|
4015
4032
|
ok: true,
|
|
4016
4033
|
next: walked.ok ? walked.from.scrub : null
|
|
@@ -4019,7 +4036,7 @@ var VaultService = class {
|
|
|
4019
4036
|
if (walked.ok) try {
|
|
4020
4037
|
scrub = await this.#db.transaction((tx) => this.#scrub(tx, walked.from), SNAPSHOT);
|
|
4021
4038
|
} catch (error) {
|
|
4022
|
-
if (!
|
|
4039
|
+
if (!isUnavailable(error)) throw error;
|
|
4023
4040
|
stalled = `the rehash of the past did not run, and goes on where it was next time: ${error.message}`;
|
|
4024
4041
|
}
|
|
4025
4042
|
return this.#decide([], async (d) => {
|
|
@@ -4120,6 +4137,31 @@ var VaultService = class {
|
|
|
4120
4137
|
});
|
|
4121
4138
|
}
|
|
4122
4139
|
/**
|
|
4140
|
+
* The walk on from where a checkpoint resumes, no further than `limit`
|
|
4141
|
+
* entries, outside any transaction: a batch a statement, each linked to
|
|
4142
|
+
* the hash it verified last, so that no connection is held while it
|
|
4143
|
+
* rehashes, seconds for a long walk. Entries are appended in order, under
|
|
4144
|
+
* the head, and never change, so no snapshot keeps them: what it read is
|
|
4145
|
+
* held to the log again under the head, before anything is signed over it
|
|
4146
|
+
* (`carries`).
|
|
4147
|
+
*/
|
|
4148
|
+
async #walkOn(from, limit) {
|
|
4149
|
+
const { logKeys } = this.#prepared;
|
|
4150
|
+
const walk = new Walk(from.tally, from.digest);
|
|
4151
|
+
const chain = await rehash(this.#db, logKeys, from.at, walk, { limit });
|
|
4152
|
+
if (!chain.ok) return chain;
|
|
4153
|
+
const unsigned = await this.#checkpointFault(this.#db, walk.checkpoints);
|
|
4154
|
+
if (unsigned !== null) return unsigned;
|
|
4155
|
+
return {
|
|
4156
|
+
ok: true,
|
|
4157
|
+
from,
|
|
4158
|
+
tally: walk.tally(),
|
|
4159
|
+
digest: walk.digest(),
|
|
4160
|
+
at: chain.at,
|
|
4161
|
+
done: chain.done
|
|
4162
|
+
};
|
|
4163
|
+
}
|
|
4164
|
+
/**
|
|
4123
4165
|
* The prefixes signed before, rehashed a stretch at a time, in laps: a lap
|
|
4124
4166
|
* runs from the first entry to the prefix the newest checkpoint signed
|
|
4125
4167
|
* when it began, which it keeps (`end`), `walks.scrub` entries a call, each
|
|
@@ -4438,11 +4480,6 @@ var VaultService = class {
|
|
|
4438
4480
|
return null;
|
|
4439
4481
|
}
|
|
4440
4482
|
};
|
|
4441
|
-
/** Whether a statement was cancelled for taking too long: Postgres's `query_canceled`, a statement timeout among them. */
|
|
4442
|
-
function timedOut(error) {
|
|
4443
|
-
for (let cause = error; typeof cause === "object" && cause !== null; cause = cause.cause) if (cause.code === "57014") return true;
|
|
4444
|
-
return false;
|
|
4445
|
-
}
|
|
4446
4483
|
const FIRST_ENTRY = {
|
|
4447
4484
|
ok: true,
|
|
4448
4485
|
entry: null,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffre/vault",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "coffre's vault: keys, grants and members, in the database the app uses, as a Cloudflare Worker or on Node.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"drizzle-orm": "0.45.2",
|
|
39
|
-
"@coffre/core": "0.
|
|
40
|
-
"@coffre/db": "0.
|
|
39
|
+
"@coffre/core": "0.14.1",
|
|
40
|
+
"@coffre/db": "0.14.1"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "26.1.1",
|
package/src/failure.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { isUnavailable } from '@coffre/db/dialect';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A vault call's failure, as `serveVault` answers it: its message, and the
|
|
5
|
+
* code its causes carry, which `connectVault` puts back on its error, so
|
|
6
|
+
* the app reads it as it would in process. Its database, or a key service,
|
|
7
|
+
* not answering is the app's outage too, not a bug: 503. One only its
|
|
8
|
+
* words say (pg's "Connection terminated") goes as Postgres's code for a
|
|
9
|
+
* connection that failed, 08006.
|
|
10
|
+
*/
|
|
11
|
+
export function failureOf(error: unknown): { status: number; body: { error: string; code?: string } } {
|
|
12
|
+
const message = error instanceof Error ? error.message : 'the vault failed';
|
|
13
|
+
let code: string | undefined;
|
|
14
|
+
for (let cause = error; typeof cause === 'object' && cause !== null && code === undefined; cause = (cause as { cause?: unknown }).cause) {
|
|
15
|
+
const found = (cause as { code?: unknown }).code;
|
|
16
|
+
if (typeof found === 'string') code = found;
|
|
17
|
+
}
|
|
18
|
+
if (!isUnavailable(error)) return { status: 500, body: code === undefined ? { error: message } : { error: message, code } };
|
|
19
|
+
return { status: 503, body: { error: message, code: code !== undefined && isUnavailable({ code }) ? code : '08006' } };
|
|
20
|
+
}
|
package/src/node.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type { Database } from '@coffre/db';
|
|
|
22
22
|
import { openDatabase } from '@coffre/db/connect';
|
|
23
23
|
|
|
24
24
|
import { resolveVaultConfig, type VaultConfig } from './config.ts';
|
|
25
|
+
import { failureOf } from './failure.ts';
|
|
25
26
|
import { METHODS, openLocalVault, type LocalVault } from './local.ts';
|
|
26
27
|
import type { VaultOptions } from './vault.ts';
|
|
27
28
|
|
|
@@ -75,7 +76,8 @@ export async function serveVault(config: NodeVaultConfig & { socket: string }):
|
|
|
75
76
|
reply(200, result === undefined ? {} : { result });
|
|
76
77
|
} catch (error) {
|
|
77
78
|
console.error(`vault ${name} failed`, error);
|
|
78
|
-
|
|
79
|
+
const { status, body } = failureOf(error);
|
|
80
|
+
reply(status, body);
|
|
79
81
|
}
|
|
80
82
|
});
|
|
81
83
|
|
|
@@ -123,13 +125,16 @@ export function connectVault(socket: string): Vault {
|
|
|
123
125
|
},
|
|
124
126
|
(res) => {
|
|
125
127
|
readBody(res).then((text) => {
|
|
126
|
-
const payload = JSON.parse(text) as { result?: unknown; error?: string };
|
|
127
|
-
if (res.statusCode === 200) resolve(payload.result);
|
|
128
|
-
|
|
128
|
+
const payload = JSON.parse(text) as { result?: unknown; error?: string; code?: string };
|
|
129
|
+
if (res.statusCode === 200) return resolve(payload.result);
|
|
130
|
+
const failed = new Error(`vault ${name}: ${payload.error ?? `status ${res.statusCode}`}`);
|
|
131
|
+
reject(payload.code === undefined ? failed : Object.assign(failed, { code: payload.code }));
|
|
129
132
|
}, reject);
|
|
130
133
|
},
|
|
131
134
|
);
|
|
132
|
-
|
|
135
|
+
// No socket file is a vault that stopped cleanly, as a refused connection is one that did not: the same outage.
|
|
136
|
+
req.on('error', (error: NodeJS.ErrnoException) =>
|
|
137
|
+
reject(new Error(`the vault at ${socket} is unreachable: ${error.message}`, { cause: error.code === 'ENOENT' ? Object.assign(error, { code: 'ECONNREFUSED' }) : error })));
|
|
133
138
|
req.end(body);
|
|
134
139
|
});
|
|
135
140
|
return Object.fromEntries(METHODS.map((name) => [name, call(name)])) as unknown as Vault;
|
package/src/vault.ts
CHANGED
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
checkpointVerifier,
|
|
62
62
|
describeAccessFault,
|
|
63
63
|
POLICY_REVISION,
|
|
64
|
+
accessRevision,
|
|
64
65
|
policyRevision,
|
|
65
66
|
type Access,
|
|
66
67
|
type AccessChange,
|
|
@@ -98,7 +99,7 @@ import {
|
|
|
98
99
|
type WrappedKey,
|
|
99
100
|
} from '@coffre/core/vault';
|
|
100
101
|
import type { Database, Queryable, Transaction } from '@coffre/db';
|
|
101
|
-
import {
|
|
102
|
+
import { isUnavailable, isUniqueViolation, SNAPSHOT } from '@coffre/db/dialect';
|
|
102
103
|
import { everyProjectConversion, newestAccess, reportStands } from '@coffre/db/grants';
|
|
103
104
|
import { appendEntries, lockLogHead, missingEntry, newestAuthentic, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
|
|
104
105
|
|
|
@@ -341,6 +342,7 @@ const MESSAGES: Record<RefusalCode, string> = {
|
|
|
341
342
|
last_owner: 'the last Owner of the whole instance stays one: make someone else an Owner first',
|
|
342
343
|
invalid: 'not something the rules allow',
|
|
343
344
|
policy_changed: "the instance's roles or protected environments changed while this was asked: try again",
|
|
345
|
+
access_changed: 'what this member holds changed since the read was decided: try again',
|
|
344
346
|
log_broken: 'the vault log does not hold from the last checkpoint',
|
|
345
347
|
wrong_kek: "this vault's key does not open the data it holds",
|
|
346
348
|
tampered: "this member's record failed the vault's integrity check",
|
|
@@ -906,10 +908,11 @@ class VaultService implements Vault {
|
|
|
906
908
|
|
|
907
909
|
// Raw DEKs are cleared on every path. JSON and base64 leave strings that
|
|
908
910
|
// cannot be wiped, so this is best-effort memory hygiene.
|
|
909
|
-
async unwrap(input: UnwrapInput): Promise<Outcome<{ keys: string[] }>> {
|
|
911
|
+
async unwrap(input: UnwrapInput): Promise<Outcome<{ keys: string[]; accessRevision?: string }>> {
|
|
910
912
|
const { principal } = input;
|
|
911
913
|
validateText(input.purpose);
|
|
912
914
|
if (input.keepsSensitive !== undefined && typeof input.keepsSensitive !== 'boolean') throw new Error('keepsSensitive is true, false or absent');
|
|
915
|
+
if (input.accessRevision !== undefined && typeof input.accessRevision !== 'string') throw new Error('accessRevision names an answer of access, or is absent');
|
|
913
916
|
const vias = input.items.map((item) => validateVia(item.via));
|
|
914
917
|
const throughs = input.items.map((item) => validateThrough(item.through));
|
|
915
918
|
const loaded = await this.#versions(input, 'secret.read', { purpose: input.purpose });
|
|
@@ -947,10 +950,10 @@ class VaultService implements Vault {
|
|
|
947
950
|
};
|
|
948
951
|
};
|
|
949
952
|
if (viaOk.includes(false)) {
|
|
950
|
-
return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')));
|
|
953
|
+
return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')), { asked: input.accessRevision });
|
|
951
954
|
}
|
|
952
955
|
if (throughOk.includes(false)) {
|
|
953
|
-
return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')));
|
|
956
|
+
return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')), { asked: input.accessRevision });
|
|
954
957
|
}
|
|
955
958
|
const ends = sealed.flatMap((seal) => (seal === null ? [] : [seal.seq]));
|
|
956
959
|
const remote = items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
|
|
@@ -984,6 +987,8 @@ class VaultService implements Vault {
|
|
|
984
987
|
key.fill(0);
|
|
985
988
|
}
|
|
986
989
|
}),
|
|
990
|
+
// Named back: the read was decided on that answer, which this vault checked.
|
|
991
|
+
...(input.accessRevision === undefined ? {} : { accessRevision: input.accessRevision }),
|
|
987
992
|
}),
|
|
988
993
|
);
|
|
989
994
|
}
|
|
@@ -1042,7 +1047,7 @@ class VaultService implements Vault {
|
|
|
1042
1047
|
if (items.some((item, i) => !loosens[i] && item.secret.sensitive !== true && strict(i))) {
|
|
1043
1048
|
return this.#refuseVersions(principal, 'invalid', loaded.versions.map((source) => ({
|
|
1044
1049
|
...keyEntry('key.rewrap', principal, source.secret, 'deny', 'invalid', input), secretVersionId: source.id,
|
|
1045
|
-
})), LOOSER);
|
|
1050
|
+
})), { message: LOOSER });
|
|
1046
1051
|
}
|
|
1047
1052
|
const sources = new Map(items.map((item, i) => [item.secret, loaded.versions[i]]));
|
|
1048
1053
|
const loosened = new Set(items.flatMap((item, i) => (loosens[i] ? [item.secret] : [])));
|
|
@@ -1094,7 +1099,7 @@ class VaultService implements Vault {
|
|
|
1094
1099
|
|
|
1095
1100
|
/** Versions need no lock: they change only by being erased, once their place is archived, and their ids determine the whole batch before any key call. */
|
|
1096
1101
|
async #versions(
|
|
1097
|
-
input: Correlation & { principal: string; items: { secretVersionId: string }[] },
|
|
1102
|
+
input: Correlation & { principal: string; items: { secretVersionId: string }[]; accessRevision?: string },
|
|
1098
1103
|
action: KeyAction,
|
|
1099
1104
|
detail: Record<string, unknown> = {},
|
|
1100
1105
|
): Promise<Outcome<{ versions: store.SecretVersion[] }>> {
|
|
@@ -1110,7 +1115,7 @@ class VaultService implements Vault {
|
|
|
1110
1115
|
if ([...found.values()].some((version) => version.deleted)) {
|
|
1111
1116
|
return this.#refuseVersions(input.principal, 'deleted', [...found.values()].map((version) => ({
|
|
1112
1117
|
...keyEntry(action, input.principal, version.secret, 'deny', 'deleted', input, detail), secretVersionId: version.id,
|
|
1113
|
-
})));
|
|
1118
|
+
})), { asked: input.accessRevision });
|
|
1114
1119
|
}
|
|
1115
1120
|
if (ids.some((id) => !found.has(id))) {
|
|
1116
1121
|
return this.#refuseVersions(input.principal, 'bad_claim', ids.map((id) => {
|
|
@@ -1121,13 +1126,27 @@ class VaultService implements Vault {
|
|
|
1121
1126
|
metadata: JSON.stringify({ ...detail, secretVersionId: id, ...traced(input) }),
|
|
1122
1127
|
};
|
|
1123
1128
|
return { ...keyEntry(action, input.principal, version.secret, 'deny', 'bad_claim', input, detail), secretVersionId: id };
|
|
1124
|
-
}));
|
|
1129
|
+
}), { asked: input.accessRevision });
|
|
1125
1130
|
}
|
|
1126
1131
|
return { ok: true, versions: ids.map((id) => found.get(id)!) };
|
|
1127
1132
|
}
|
|
1128
1133
|
|
|
1129
|
-
|
|
1130
|
-
|
|
1134
|
+
/**
|
|
1135
|
+
* Refuse a call for what its versions are, under the principal's row, as
|
|
1136
|
+
* any decision: and a read on a kept answer no longer theirs as that, not
|
|
1137
|
+
* for its versions, logging no read (`accessRevision`).
|
|
1138
|
+
*/
|
|
1139
|
+
async #refuseVersions(
|
|
1140
|
+
principal: string,
|
|
1141
|
+
code: 'bad_claim' | 'deleted' | 'invalid',
|
|
1142
|
+
entries: NewEntry[],
|
|
1143
|
+
{ message = MESSAGES[code], asked }: { message?: string; asked?: string } = {},
|
|
1144
|
+
): Promise<Outcome<never>> {
|
|
1145
|
+
return this.#decide([principal], async (d) => {
|
|
1146
|
+
if (asked !== undefined) {
|
|
1147
|
+
const standing = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads);
|
|
1148
|
+
if (this.#revisionOf(standing, d) !== asked) throw new Refused(refusal('access_changed', MESSAGES.access_changed), []);
|
|
1149
|
+
}
|
|
1131
1150
|
throw new Refused(refusal(code, message), entries);
|
|
1132
1151
|
});
|
|
1133
1152
|
}
|
|
@@ -1185,7 +1204,7 @@ class VaultService implements Vault {
|
|
|
1185
1204
|
viaReference?: readonly boolean[];
|
|
1186
1205
|
remote: boolean;
|
|
1187
1206
|
entry: (secret: SecretRef, decision: 'allow' | 'deny', code: string | null) => NewEntry;
|
|
1188
|
-
input: Correlation & Through & { purpose?: string; keepsSensitive?: boolean };
|
|
1207
|
+
input: Correlation & Through & { purpose?: string; keepsSensitive?: boolean; accessRevision?: string };
|
|
1189
1208
|
},
|
|
1190
1209
|
/**
|
|
1191
1210
|
* One per secret; null for a bad claim, an error for an outage or an
|
|
@@ -1206,7 +1225,11 @@ class VaultService implements Vault {
|
|
|
1206
1225
|
// Each personal config's owner, hashed, by environment, as the decision read them: what its keys' contexts bind.
|
|
1207
1226
|
const owners = new Map<string, string>();
|
|
1208
1227
|
const check = async (d: Decision) => {
|
|
1209
|
-
const
|
|
1228
|
+
const standing = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads);
|
|
1229
|
+
// A read decided on an earlier answer about its reader, only while that is still the answer: nothing read, nothing logged.
|
|
1230
|
+
const asked = call.input.accessRevision;
|
|
1231
|
+
if (asked !== undefined && this.#revisionOf(standing, d) !== asked) throw new Refused(refusal('access_changed', MESSAGES.access_changed), []);
|
|
1232
|
+
const reader = through(standing, call.input);
|
|
1210
1233
|
// One read of the environments the decision is about, as the store has them, not as the app sent them:
|
|
1211
1234
|
// each secret's and, for a read through a reference, its holder's. A grant on one slug in every project
|
|
1212
1235
|
// matches the slug there, and a place deleted on either side opens for none.
|
|
@@ -1290,9 +1313,11 @@ class VaultService implements Vault {
|
|
|
1290
1313
|
try {
|
|
1291
1314
|
await check(d);
|
|
1292
1315
|
} catch (error) {
|
|
1293
|
-
|
|
1316
|
+
// A read on a kept answer no longer the vault's logs no read; but a key service's intent, logged already, is
|
|
1317
|
+
// accounted for all the same, each key refused, as after any other refusal.
|
|
1318
|
+
if (error instanceof Refused && !(error.refusal.code === 'access_changed' && intentSeq === null)) {
|
|
1294
1319
|
throw new Refused(error.refusal, secrets.map((secret, item) =>
|
|
1295
|
-
outcomeEntry(secret, item, 'deny', error.entries[item]
|
|
1320
|
+
outcomeEntry(secret, item, 'deny', error.entries[item]?.code ?? error.refusal.code)));
|
|
1296
1321
|
}
|
|
1297
1322
|
throw error;
|
|
1298
1323
|
}
|
|
@@ -1665,7 +1690,7 @@ class VaultService implements Vault {
|
|
|
1665
1690
|
|
|
1666
1691
|
#access(principal: string, row: Member | undefined, held: readonly GrantRow[], at: number, tampered: boolean, policy: Policy): Access {
|
|
1667
1692
|
const active = PRINCIPAL.test(principal) && !tampered && row?.status === 'active';
|
|
1668
|
-
|
|
1693
|
+
const access: Access = {
|
|
1669
1694
|
principal,
|
|
1670
1695
|
status: tampered ? 'tampered' : !PRINCIPAL.test(principal) ? 'unknown' : (row?.status ?? 'unknown'),
|
|
1671
1696
|
generation: row?.generation ?? 0,
|
|
@@ -1675,6 +1700,14 @@ class VaultService implements Vault {
|
|
|
1675
1700
|
by: row?.statusChangedBy ?? null,
|
|
1676
1701
|
policy,
|
|
1677
1702
|
};
|
|
1703
|
+
return { ...access, revision: accessRevision(access) };
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
/** The revision of what `access` would answer about a standing a decision read: none but an active member's matches a kept answer. */
|
|
1707
|
+
#revisionOf(standing: Standing, d: Decision): string | null {
|
|
1708
|
+
const { policy } = standing.live;
|
|
1709
|
+
if (standing.status !== 'active' || policy === undefined) return null;
|
|
1710
|
+
return this.#access(standing.principal, d.members.get(standing.principal), standing.stored, d.at, false, policy).revision!;
|
|
1678
1711
|
}
|
|
1679
1712
|
|
|
1680
1713
|
/**
|
|
@@ -2498,15 +2531,16 @@ class VaultService implements Vault {
|
|
|
2498
2531
|
/**
|
|
2499
2532
|
* Sign the log up to its last entry, from the newest checkpoint's tally.
|
|
2500
2533
|
*
|
|
2501
|
-
*
|
|
2502
|
-
*
|
|
2503
|
-
* write nothing to the log; the prefix the newest checkpoint signed
|
|
2504
|
-
* there, its hash where it was, which a rewrite chained again
|
|
2505
|
-
* every entry of it, which a cut changes
|
|
2506
|
-
*
|
|
2507
|
-
*
|
|
2508
|
-
*
|
|
2509
|
-
*
|
|
2534
|
+
* Without the log's lock, so that no append waits on it, and in one short
|
|
2535
|
+
* snapshot: every member's row checked, since rows changed around the
|
|
2536
|
+
* vault write nothing to the log; the prefix the newest checkpoint signed
|
|
2537
|
+
* still there, its hash where it was, which a rewrite chained again
|
|
2538
|
+
* changes, and every entry of it, which a cut changes. Then, in no
|
|
2539
|
+
* transaction, a batch a statement, the chain recomputed from there, every
|
|
2540
|
+
* hash from content and every vault entry by its MAC, no further than one
|
|
2541
|
+
* walk (`walks.checkpoint`); and a stretch of the prefixes signed before
|
|
2542
|
+
* rehashed (`#scrub`). Then, under the lock, the entries appended since
|
|
2543
|
+
* the walk, so that it signs the head.
|
|
2510
2544
|
*
|
|
2511
2545
|
* A walk cut short signs where it got to, behind the head: the log after
|
|
2512
2546
|
* an upgrade from 0.10.0, whose checkpoints kept no tally, is signed again
|
|
@@ -2522,7 +2556,7 @@ class VaultService implements Vault {
|
|
|
2522
2556
|
const found: NewEntry[] = [];
|
|
2523
2557
|
// The newest checkpoint entry, signed or refused, as this call's snapshot found it.
|
|
2524
2558
|
let seen: bigint | null = null;
|
|
2525
|
-
const
|
|
2559
|
+
const resumed = await this.#db.transaction(async (tx) => {
|
|
2526
2560
|
seen = (await store.newestCheckpointEntry(tx))?.seq ?? null;
|
|
2527
2561
|
await this.#sweep(tx, found);
|
|
2528
2562
|
const from = await this.#resume(tx, 'newest');
|
|
@@ -2533,13 +2567,9 @@ class VaultService implements Vault {
|
|
|
2533
2567
|
}
|
|
2534
2568
|
const gap = await missingEntry(tx, from.at.seq);
|
|
2535
2569
|
if (gap !== null) return { ok: false as const, failedAtSeq: Number(gap.found), reason: `sequence gap: expected seq ${gap.expected}, found ${gap.found}` };
|
|
2536
|
-
|
|
2537
|
-
const chain = await rehash(tx, logKeys, from.at, walk, { limit: walks.checkpoint });
|
|
2538
|
-
if (!chain.ok) return chain;
|
|
2539
|
-
const unsigned = await this.#checkpointFault(tx, walk.checkpoints);
|
|
2540
|
-
if (unsigned !== null) return unsigned;
|
|
2541
|
-
return { ok: true as const, from, tally: walk.tally(), digest: walk.digest(), at: chain.at, done: chain.done };
|
|
2570
|
+
return from;
|
|
2542
2571
|
}, SNAPSHOT);
|
|
2572
|
+
const walked = resumed.ok ? await this.#walkOn(resumed, walks.checkpoint) : resumed;
|
|
2543
2573
|
// The past rehashed, in a snapshot of its own. The database failing it leaves the stretch for the
|
|
2544
2574
|
// next call, where it was, and the checkpoint is signed all the same, saying why (`warning`); a bug
|
|
2545
2575
|
// throws, as anywhere else.
|
|
@@ -2550,7 +2580,7 @@ class VaultService implements Vault {
|
|
|
2550
2580
|
scrub = await this.#db.transaction((tx) => this.#scrub(tx, walked.from), SNAPSHOT);
|
|
2551
2581
|
} catch (error) {
|
|
2552
2582
|
// Only the database not answering, or a statement it gave up on: anything else is coffre's to fix.
|
|
2553
|
-
if (!
|
|
2583
|
+
if (!isUnavailable(error)) throw error;
|
|
2554
2584
|
stalled = `the rehash of the past did not run, and goes on where it was next time: ${(error as Error).message}`;
|
|
2555
2585
|
}
|
|
2556
2586
|
}
|
|
@@ -2580,7 +2610,7 @@ class VaultService implements Vault {
|
|
|
2580
2610
|
// Nothing since the last one, which signed every entry before its own: it is still the newest prefix.
|
|
2581
2611
|
if (newest !== null && latest!.seq === head.nextSeq - 1n && latest!.relatedSeq === latest!.seq - 1n) return { checkpoint: signedPart(newest) };
|
|
2582
2612
|
// Under the lock, the rest: the prefix signed last still where it was,
|
|
2583
|
-
// and every entry since the
|
|
2613
|
+
// and every entry since the walk, unless the walk is catching up,
|
|
2584
2614
|
// when what it walked must still be there. A rewrite is never signed over.
|
|
2585
2615
|
if (newest !== null && !(await carries(d.tx, newest))) {
|
|
2586
2616
|
throw refused('log_broken', { reason: `the log up to entry ${newest.seq} is not the prefix the last checkpoint signed` });
|
|
@@ -2588,7 +2618,7 @@ class VaultService implements Vault {
|
|
|
2588
2618
|
const walk = new Walk(walked.tally, walked.digest);
|
|
2589
2619
|
let { at } = walked;
|
|
2590
2620
|
if (walked.done) {
|
|
2591
|
-
// Where the
|
|
2621
|
+
// Where the walk ended still has the hash it found there: what follows links to it.
|
|
2592
2622
|
if (!(await carries(d.tx, headOf(at)))) {
|
|
2593
2623
|
throw refused('log_broken', { failedAtSeq: Number(at.seq), reason: `the log up to entry ${at.seq} changed while the vault walked it` });
|
|
2594
2624
|
}
|
|
@@ -2625,6 +2655,25 @@ class VaultService implements Vault {
|
|
|
2625
2655
|
});
|
|
2626
2656
|
}
|
|
2627
2657
|
|
|
2658
|
+
/**
|
|
2659
|
+
* The walk on from where a checkpoint resumes, no further than `limit`
|
|
2660
|
+
* entries, outside any transaction: a batch a statement, each linked to
|
|
2661
|
+
* the hash it verified last, so that no connection is held while it
|
|
2662
|
+
* rehashes, seconds for a long walk. Entries are appended in order, under
|
|
2663
|
+
* the head, and never change, so no snapshot keeps them: what it read is
|
|
2664
|
+
* held to the log again under the head, before anything is signed over it
|
|
2665
|
+
* (`carries`).
|
|
2666
|
+
*/
|
|
2667
|
+
async #walkOn(from: Resumed, limit: number) {
|
|
2668
|
+
const { logKeys } = this.#prepared;
|
|
2669
|
+
const walk = new Walk(from.tally, from.digest);
|
|
2670
|
+
const chain = await rehash(this.#db, logKeys, from.at, walk, { limit });
|
|
2671
|
+
if (!chain.ok) return chain;
|
|
2672
|
+
const unsigned = await this.#checkpointFault(this.#db, walk.checkpoints);
|
|
2673
|
+
if (unsigned !== null) return unsigned;
|
|
2674
|
+
return { ok: true as const, from, tally: walk.tally(), digest: walk.digest(), at: chain.at, done: chain.done };
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2628
2677
|
/**
|
|
2629
2678
|
* The prefixes signed before, rehashed a stretch at a time, in laps: a lap
|
|
2630
2679
|
* runs from the first entry to the prefix the newest checkpoint signed
|
|
@@ -2913,14 +2962,6 @@ type Resumed = {
|
|
|
2913
2962
|
/** A lap of the rehash of the past: the last entry rehashed, and the prefix it rehashes to, signed when it began. */
|
|
2914
2963
|
type Lap = { at: Position; end: Position };
|
|
2915
2964
|
|
|
2916
|
-
/** Whether a statement was cancelled for taking too long: Postgres's `query_canceled`, a statement timeout among them. */
|
|
2917
|
-
function timedOut(error: unknown): boolean {
|
|
2918
|
-
for (let cause = error; typeof cause === 'object' && cause !== null; cause = (cause as { cause?: unknown }).cause) {
|
|
2919
|
-
if ((cause as { code?: unknown }).code === '57014') return true;
|
|
2920
|
-
}
|
|
2921
|
-
return false;
|
|
2922
|
-
}
|
|
2923
|
-
|
|
2924
2965
|
/** Where the next rehash of the past goes on from, null for the first entry; or what it found. */
|
|
2925
2966
|
type Scrubbed = { ok: true; next: Lap | null } | Broken;
|
|
2926
2967
|
|