@coffre/vault 0.14.4 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/dist/cloudflare.d.ts +343 -9
- package/dist/cloudflare.js +802 -6
- package/dist/{index-CIq4i_KT.d.ts → index-DQ-FKVF3.d.ts} +4 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/log-entry-BYWqj3O5.d.ts +364 -0
- package/dist/node.d.ts +182 -7
- package/dist/node.js +358 -16
- package/dist/{src-NM5NsYFs.js → src-DheG92EM.js} +9 -2
- package/dist/vault-kmtojYnW.js +14623 -0
- package/package.json +4 -5
- package/src/bulk.ts +89 -0
- package/src/changes.ts +478 -0
- package/src/checkpointer-node.ts +342 -0
- package/src/checkpointer-workers.ts +514 -0
- package/src/checkpointer.ts +1165 -0
- package/src/cloudflare.ts +207 -13
- package/src/config.ts +15 -3
- package/src/credentials.postgres.ts +78 -0
- package/src/credentials.sqlite.ts +240 -0
- package/src/credentials.ts +630 -0
- package/src/cutover.ts +275 -0
- package/src/decide.ts +437 -2
- package/src/inputs.ts +499 -0
- package/src/local.ts +20 -4
- package/src/log-checkpointer.ts +116 -0
- package/src/log-entry.ts +113 -0
- package/src/log-replay.ts +188 -0
- package/src/log-store.ts +695 -0
- package/src/node.ts +73 -9
- package/src/objects.ts +141 -0
- package/src/reads.ts +502 -0
- package/src/retention.ts +229 -0
- package/src/snapshot.postgres.ts +39 -9
- package/src/snapshot.sqlite.ts +28 -3
- package/src/snapshot.ts +72 -12
- package/src/store.ts +862 -37
- package/src/use.postgres.ts +111 -0
- package/src/use.sqlite.ts +542 -0
- package/src/use.ts +1060 -0
- package/src/vault.ts +1513 -240
- package/src/verifier.ts +136 -0
- package/src/witness-workers.ts +163 -0
- package/dist/vault-BBdXsny3.js +0 -5077
package/src/reads.ts
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { entryLeaf, inclusionProof, nodesFrom, openPair, tileMap, tilesFor, verifyInclusion, verifyLog, type Cut, type LogEntry, type LogReader, type Pair, type PairVerifiers, type StoredPair } from '@coffre/core/merkle';
|
|
4
|
+
import { USE_WAYS } from '@coffre/core/vault';
|
|
5
|
+
|
|
6
|
+
import { logObject } from './inputs.ts';
|
|
7
|
+
import { authentic, retentionCut, type LogMacKey } from './log-entry.ts';
|
|
8
|
+
|
|
9
|
+
// The audit pages' reads (T166; docs/design/0.15-plan.md, "As built: T166";
|
|
10
|
+
// formal/Reads.tla). A use is the slim row of R53: its way in, its key, the
|
|
11
|
+
// fork it was read in, its purpose and when, under a MAC that cannot cover
|
|
12
|
+
// the number Postgres gives it. It names neither whose read it was nor where.
|
|
13
|
+
// The projector says so, for every use a signed pair seals: its account, its
|
|
14
|
+
// key's place, through a reference the source and its place, the version
|
|
15
|
+
// read, each from the sealed objects at the use's number (the number orders
|
|
16
|
+
// every use against every change, R50), under a MAC over all of it, the
|
|
17
|
+
// number included. It runs apart from the checkpointer, from a cursor of its
|
|
18
|
+
// own: a projection behind, or failing, lags the pages, which say so, and
|
|
19
|
+
// delays no seal, no change, no use.
|
|
20
|
+
|
|
21
|
+
/** A use as the pages list it (`log_reads`), every id lower-case. */
|
|
22
|
+
export type ReadRow = {
|
|
23
|
+
number: bigint;
|
|
24
|
+
at: number;
|
|
25
|
+
credential: string;
|
|
26
|
+
/** The use's code: its purpose, keepsSensitive and way in (`USE_PURPOSES`). */
|
|
27
|
+
purpose: number;
|
|
28
|
+
principal: string;
|
|
29
|
+
/** The key the use named, where it is held: a reference's holder. */
|
|
30
|
+
secret: string;
|
|
31
|
+
environment: string;
|
|
32
|
+
project: string;
|
|
33
|
+
/** The fork it was read in. */
|
|
34
|
+
place: string | null;
|
|
35
|
+
/** Through a reference: its source then, and where that is. */
|
|
36
|
+
source: string | null;
|
|
37
|
+
sourceEnvironment: string | null;
|
|
38
|
+
sourceProject: string | null;
|
|
39
|
+
/** The version read: its key's, or its source's; null where its object names none. */
|
|
40
|
+
version: string | null;
|
|
41
|
+
mac: Buffer;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* How far the projector has gone: every use at or below `through` is
|
|
46
|
+
* projected; `pair`, the signed pair at or below it; `at`, the signed time
|
|
47
|
+
* of the newest pair whose uses are all projected, which the pages say they
|
|
48
|
+
* list reads through when it is behind.
|
|
49
|
+
*/
|
|
50
|
+
export type ReadsCursor = { through: bigint; pair: bigint; at: number; mac: Buffer | null };
|
|
51
|
+
|
|
52
|
+
/** The cursor's row gone: the migration made it, so only a database writer deletes it (review r6), and nothing it says holds. */
|
|
53
|
+
export class CursorGone extends Error {
|
|
54
|
+
constructor() {
|
|
55
|
+
super("the reads' cursor is gone: a database writer deleted it");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A way in's or key's object as the log holds it, for a projection: a change of it, whole, for its MAC; null for none. */
|
|
60
|
+
export type Sealed = LogEntry | null;
|
|
61
|
+
|
|
62
|
+
/** What the projector needs of the database (log-store.ts, on both engines). */
|
|
63
|
+
export type ReadsStore = Pick<LogReader, 'tiles'> & {
|
|
64
|
+
readsCursor(): Promise<ReadsCursor>;
|
|
65
|
+
/** The newest retention change and its leaf index in the changes tree (plan PR 7), for `sealedCut`. */
|
|
66
|
+
retention(): Promise<{ entry: LogEntry; index: number } | null>;
|
|
67
|
+
pair(upto: bigint): Promise<{ upto: bigint; notes: { changes: string; uses: string } } | null>;
|
|
68
|
+
pairs(after: bigint, limit: number): Promise<{ upto: bigint; notes: { changes: string; uses: string } }[]>;
|
|
69
|
+
newest(): Promise<{ upto: bigint; notes: { changes: string; uses: string } } | null>;
|
|
70
|
+
entries(after: bigint, upto: bigint, options?: { floor?: number; limit?: number }): Promise<LogEntry[]>;
|
|
71
|
+
/**
|
|
72
|
+
* Each object asked, in order: its first change (`first`), or its newest
|
|
73
|
+
* numbered below `below`; null for none.
|
|
74
|
+
*/
|
|
75
|
+
objects(asked: readonly { object: string; below: bigint; first: boolean }[]): Promise<Sealed[]>;
|
|
76
|
+
/**
|
|
77
|
+
* The reads, each once by its number, their days made (`make_reads_day` on Postgres: a log older than the days made
|
|
78
|
+
* ahead holds uses of others), and the cursor on to `cursor`, never back: one write, and only while the cursor still
|
|
79
|
+
* stands at `from`, where the chunk started (FromChecked): whether it wrote. A projector another overtook writes
|
|
80
|
+
* nothing, so never the reads of a day retention has since dropped (review r11).
|
|
81
|
+
*/
|
|
82
|
+
writeReads(reads: readonly ReadRow[], from: bigint, cursor: ReadsCursor): Promise<boolean>;
|
|
83
|
+
/**
|
|
84
|
+
* What the listing holds of a stretch (`checkReads`): its reads numbered in `(after, upto]` at times in `[from, to]`,
|
|
85
|
+
* each with whether its member's day of it is in `log_read_days` (`dayed`), which their pages read.
|
|
86
|
+
*/
|
|
87
|
+
listing(stretch: { after: bigint; upto: bigint; from: number; to: number }): Promise<(ReadRow & { dayed: boolean })[]>;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** How many entries one step of the projector reads: a tick's worth at R44's 1,000 uses a second, and more. */
|
|
91
|
+
export const PROJECTED_AT_ONCE = 1_000;
|
|
92
|
+
|
|
93
|
+
const MAC_BYTES = 16;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A read's MAC: HMAC-SHA256 under the log's MAC key (its key, not its tag: a read is no entry of the log, and the
|
|
97
|
+
* database refuses none by it), over every column but itself, the number included, cut to 16 bytes.
|
|
98
|
+
*/
|
|
99
|
+
export function readMac(key: LogMacKey, row: Omit<ReadRow, 'mac'>): Buffer {
|
|
100
|
+
const columns = [
|
|
101
|
+
String(row.number), String(row.at), row.credential, String(row.purpose), row.principal, row.secret, row.environment, row.project,
|
|
102
|
+
row.place, row.source, row.sourceEnvironment, row.sourceProject, row.version,
|
|
103
|
+
];
|
|
104
|
+
return createHmac('sha256', key.key).update('coffre.reads.v1\0').update(JSON.stringify(columns)).digest().subarray(0, MAC_BYTES);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The cursor's MAC (formal/Reads.tla, CursorMac): over where it stands, so that one moved ahead never has the pages list reads as whole. */
|
|
108
|
+
export function cursorMac(key: LogMacKey, cursor: Pick<ReadsCursor, 'through' | 'pair' | 'at'>): Buffer {
|
|
109
|
+
return createHmac('sha256', key.key).update('coffre.reads.cursor.v1\0').update(`${cursor.through}:${cursor.pair}:${cursor.at}`).digest().subarray(0, MAC_BYTES);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Whether a cursor's MAC holds under `key`: the start, before any read is projected, has none. */
|
|
113
|
+
export function cursorHolds(key: LogMacKey, cursor: ReadsCursor): boolean {
|
|
114
|
+
if (cursor.mac === null) return cursor.through === 0n && cursor.pair === 0n && cursor.at === 0;
|
|
115
|
+
return cursor.mac.length === MAC_BYTES && timingSafeEqual(cursorMac(key, cursor), cursor.mac);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Whether a read's MAC holds under `key`. */
|
|
119
|
+
export function readHolds(key: LogMacKey, row: ReadRow): boolean {
|
|
120
|
+
return row.mac.length === MAC_BYTES && timingSafeEqual(readMac(key, row), row.mac);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** What one run of the projector did: where it stands, the newest signed pair's number, and how many reads it wrote. */
|
|
124
|
+
export type Projected = { through: bigint; sealed: bigint; projected: number };
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A range of the log, from the pair at `start` (null: the genesis) to
|
|
128
|
+
* `next`, read whole and held to the trees (WindowChecked): its entries,
|
|
129
|
+
* from the floor `start` signed, must give `next`'s signed heads from
|
|
130
|
+
* `start`'s, both trees, so that a use or change deleted, edited,
|
|
131
|
+
* renumbered or slipped in since its seal fails the range, never a read
|
|
132
|
+
* derived from it. A tick seals its whole window, so a range is what one
|
|
133
|
+
* tick sealed: a few hundred entries, more after an outage, which the
|
|
134
|
+
* checkpointer read whole too. Only a range at or above retention's cut
|
|
135
|
+
* (`sealedCut`) is asked for, whose uses are all kept. Throws on a range
|
|
136
|
+
* that does not hold.
|
|
137
|
+
*/
|
|
138
|
+
async function sealedRange(store: ReadsStore, verifiers: PairVerifiers, start: StoredPair | null, next: StoredPair): Promise<LogEntry[]> {
|
|
139
|
+
const walked: LogEntry[] = [];
|
|
140
|
+
// The log as far as `next`, and no further: the walk takes `next` for the newest pair.
|
|
141
|
+
const reader: LogReader = {
|
|
142
|
+
pairs: async (after, limit) => (after >= next.upto ? [] : (await store.pairs(after, limit)).filter((row) => row.upto <= next.upto)),
|
|
143
|
+
pair: async (upto) => (upto > next.upto ? null : store.pair(upto)),
|
|
144
|
+
entries: async (after, upto, options) => (after >= next.upto ? [] : store.entries(after, upto < next.upto ? upto : next.upto, options)),
|
|
145
|
+
tiles: (tree, keys) => store.tiles(tree, keys),
|
|
146
|
+
};
|
|
147
|
+
const verdict = await verifyLog(reader, { verifiers, ...(start === null ? {} : { from: start.notes }), check: (entry) => (walked.push(entry), null) });
|
|
148
|
+
if (!verdict.ok || verdict.headPair?.upto !== next.upto) {
|
|
149
|
+
throw new Error(`the log up to ${next.upto} does not hold to the pair that sealed it: ${verdict.problems[0] ?? 'the walk stopped short'}`);
|
|
150
|
+
}
|
|
151
|
+
return walked;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Where retention cut the log, when it has (plan PR 7): the newest
|
|
156
|
+
* retention change, proven in the changes tree of the newest pair (`head`)
|
|
157
|
+
* from its tiles, as the whole log's walk holds it (Seal.tla's CutSealed),
|
|
158
|
+
* and the cut it names. Below it, uses are gone with their days, and their
|
|
159
|
+
* reads with them: the projector and the check start at the cut. Null
|
|
160
|
+
* before any retention change is sealed; throws on one a writer put there.
|
|
161
|
+
*/
|
|
162
|
+
async function sealedCut(store: ReadsStore, head: Pair): Promise<Cut | null> {
|
|
163
|
+
const retained = await store.retention();
|
|
164
|
+
if (retained === null || retained.entry.number > head.upto) return null;
|
|
165
|
+
const { entry, index } = retained;
|
|
166
|
+
const cut = retentionCut(entry);
|
|
167
|
+
const size = head.changes.size;
|
|
168
|
+
const proof = cut !== null && Number.isSafeInteger(index) && index >= 0 && index < size
|
|
169
|
+
? inclusionProof(nodesFrom(tileMap(await store.tiles('changes', tilesFor((nodes) => inclusionProof(nodes, index, size))))), index, size)
|
|
170
|
+
: null;
|
|
171
|
+
if (cut === null || proof === null || !verifyInclusion(entryLeaf('changes', entry.number, entry.columns), index, size, proof, head.changes.root)) {
|
|
172
|
+
throw new Error(`the retention change ${entry.number} is not one the log sealed`);
|
|
173
|
+
}
|
|
174
|
+
return cut;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The range the pair at `at` starts: its row (none for the genesis at 0), the next pair's, and that one opened. */
|
|
178
|
+
async function rangeAt(store: ReadsStore, verifiers: PairVerifiers, at: bigint): Promise<{ start: StoredPair | null; next: StoredPair; pair: Pair }> {
|
|
179
|
+
const start = await store.pair(at);
|
|
180
|
+
if (start === null && at !== 0n) throw new Error(`no pair at ${at}, where the reads' projection stands: checkpoints were deleted`);
|
|
181
|
+
const [next] = await store.pairs(at, 1);
|
|
182
|
+
const pair = next === undefined ? null : await openPair(next.notes, verifiers);
|
|
183
|
+
if (next === undefined || pair === null) throw new Error(`the pair after ${at} is not one the vault signed`);
|
|
184
|
+
return { start, next, pair };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Project the sealed uses above the cursor, `atOnce` at a time (a step),
|
|
189
|
+
* up to `steps` steps, a checkpoint's range after another: never past the
|
|
190
|
+
* newest pair whose signatures hold (ProjectSealedOnly), each range read
|
|
191
|
+
* whole and held to the trees (`sealedRange`, WindowChecked), once a run,
|
|
192
|
+
* then derived (`derive`) and written, the rows before the cursor
|
|
193
|
+
* (CursorAfterRows), each once (IdempotentByNumber). A range that does not
|
|
194
|
+
* hold stops the projection, an alarm, and the pages say how far they
|
|
195
|
+
* list: nothing it would derive is listed (fail closed).
|
|
196
|
+
*/
|
|
197
|
+
export async function projectReads(store: ReadsStore, keys: { verifiers: PairVerifiers; mac: LogMacKey }, options: { steps?: number; atOnce?: number } = {}): Promise<Projected> {
|
|
198
|
+
const atOnce = options.atOnce ?? PROJECTED_AT_ONCE;
|
|
199
|
+
let cursor = await store.readsCursor();
|
|
200
|
+
if (!cursorHolds(keys.mac, cursor)) throw new Error(`the reads' cursor, at ${cursor.through}, is not the vault's: it was moved around the projector`);
|
|
201
|
+
const newest = await newestHeld(store, keys.verifiers, cursor);
|
|
202
|
+
if (typeof newest === 'string') throw new Error(newest);
|
|
203
|
+
if (newest === null) return { through: cursor.through, sealed: cursor.through, projected: 0 };
|
|
204
|
+
const head = newest;
|
|
205
|
+
// Retention drops no day, nor tile, the projector has yet to read (retention.ts): below its cut it goes on as anywhere,
|
|
206
|
+
// and a range gone all the same stops it, never skipped (review r4).
|
|
207
|
+
let projected = 0;
|
|
208
|
+
// The range the cursor stands in, read once a run however many chunks it takes.
|
|
209
|
+
let range: { at: bigint; pair: Pair; uses: LogEntry[] } | null = null;
|
|
210
|
+
for (let step = 0; step < (options.steps ?? 4) && cursor.through < head.upto; step += 1) {
|
|
211
|
+
if (range === null || range.at !== cursor.pair) {
|
|
212
|
+
const { start, next, pair } = await rangeAt(store, keys.verifiers, cursor.pair);
|
|
213
|
+
if (pair.upto > head.upto) throw new Error(`the pair after ${cursor.pair} is past the newest`);
|
|
214
|
+
range = { at: cursor.pair, pair, uses: (await sealedRange(store, keys.verifiers, start, next)).filter((entry) => entry.tree === 'uses') };
|
|
215
|
+
}
|
|
216
|
+
const { pair } = range;
|
|
217
|
+
// Where a step before this one stopped inside the range, on from there.
|
|
218
|
+
const left = range.uses.filter((entry) => entry.number > cursor.through);
|
|
219
|
+
const chunk = left.slice(0, atOnce);
|
|
220
|
+
const reads = await derive(store, keys.mac, chunk);
|
|
221
|
+
// Its time, once a range is whole: the pair's, as signed, which every read of it is at or below.
|
|
222
|
+
const moved = chunk.length === left.length ? { through: pair.upto, pair: pair.upto, at: pair.at } : { through: chunk.at(-1)!.number, pair: cursor.pair, at: cursor.at };
|
|
223
|
+
const from = cursor.through;
|
|
224
|
+
cursor = { ...moved, mac: cursorMac(keys.mac, moved) };
|
|
225
|
+
// Overtaken: the run ends, and the next starts from where the other left the cursor.
|
|
226
|
+
if (!(await store.writeReads(reads, from, cursor))) return { through: from, sealed: head.upto, projected };
|
|
227
|
+
projected += reads.length;
|
|
228
|
+
}
|
|
229
|
+
return { through: cursor.through, sealed: head.upto, projected };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The newest signed pair, opened under the vault's keys; null for a log that
|
|
234
|
+
* has none yet, while the cursor is still at the start; otherwise why it
|
|
235
|
+
* cannot be read: no pair under a cursor moved on, a pair whose signatures
|
|
236
|
+
* fail, or a cursor past it (pairs deleted under it). Never taken for a log
|
|
237
|
+
* with nothing to project or check (review r12: a newest pair edited had
|
|
238
|
+
* the check answer ok, and the projector caught up, unsaid).
|
|
239
|
+
*/
|
|
240
|
+
async function newestHeld(store: ReadsStore, verifiers: PairVerifiers, cursor: ReadsCursor): Promise<Pair | null | string> {
|
|
241
|
+
const newest = await store.newest();
|
|
242
|
+
if (newest === null) return cursor.through === 0n ? null : `the reads are listed through ${cursor.through}, but the log holds no signed pair`;
|
|
243
|
+
const head = await openPair(newest.notes, verifiers);
|
|
244
|
+
if (head === null) return `the newest pair, through ${newest.upto}, does not hold to the vault's signatures`;
|
|
245
|
+
if (cursor.through > head.upto) return `the reads are listed through ${cursor.through}, past the newest pair, through ${head.upto}`;
|
|
246
|
+
return head;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The most problems a stretch names; more are counted. */
|
|
250
|
+
const PROBLEMS_NAMED = 20;
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The reads' listing held to the sealed log, a checkpoint's range at a
|
|
254
|
+
* time, from the pair at `from` (T166, steer 3): the explicit check of
|
|
255
|
+
* the whole log, `coffre verify log`, never a page's. Each range is read
|
|
256
|
+
* whole and held to the trees (`sealedRange`), its uses derived again as
|
|
257
|
+
* the projector derives them, and each read held to the listing, as far as
|
|
258
|
+
* the projector has gone: the range it stands in too, to the cursor. A
|
|
259
|
+
* read deleted from it, edited, renumbered or listed twice, each is named;
|
|
260
|
+
* and a range that does not hold, which stops the projector there.
|
|
261
|
+
* A read planted under a number, or at a time, no use of the range may
|
|
262
|
+
* have, it reads no further for: every page shows that one forged, by its
|
|
263
|
+
* MAC. The check starts at retention's cut. Below it only the next day's
|
|
264
|
+
* stragglers are left, a few seconds of uses at each midnight, and their
|
|
265
|
+
* listing rows go unchecked until their day goes (a known limit, D161;
|
|
266
|
+
* docs/design/0.15-plan.md). Flat: a stretch reads one range however long
|
|
267
|
+
* the log.
|
|
268
|
+
*/
|
|
269
|
+
export async function checkReads(store: ReadsStore, keys: { verifiers: PairVerifiers; mac: LogMacKey }, from: bigint): Promise<{ problems: string[]; through: bigint; listed: bigint; next: bigint | null }> {
|
|
270
|
+
let cursor: ReadsCursor;
|
|
271
|
+
try {
|
|
272
|
+
cursor = await store.readsCursor();
|
|
273
|
+
} catch (error) {
|
|
274
|
+
if (!(error instanceof CursorGone)) throw error;
|
|
275
|
+
return { problems: [error.message], through: from, listed: 0n, next: null };
|
|
276
|
+
}
|
|
277
|
+
const stop = (problem: string) => ({ problems: [problem], through: from, listed: cursor.through, next: null });
|
|
278
|
+
if (!cursorHolds(keys.mac, cursor)) return stop(`the reads' cursor, at ${cursor.through}, is not the vault's: it was moved around the projector`);
|
|
279
|
+
const head = await newestHeld(store, keys.verifiers, cursor);
|
|
280
|
+
if (typeof head === 'string') return stop(head);
|
|
281
|
+
if (head === null) return { problems: [], through: from, listed: cursor.through, next: null };
|
|
282
|
+
// From the retention cut, when it is past `from`.
|
|
283
|
+
let cut;
|
|
284
|
+
try {
|
|
285
|
+
cut = await sealedCut(store, head);
|
|
286
|
+
} catch (error) {
|
|
287
|
+
return stop((error as Error).message);
|
|
288
|
+
}
|
|
289
|
+
const at = cut !== null && cut.upto > from ? cut.upto : from;
|
|
290
|
+
// The range the projector stands in is held as far as it has gone; past it, none.
|
|
291
|
+
const last = at >= cursor.pair;
|
|
292
|
+
if (last && (await store.pairs(cursor.pair, 1)).length === 0) return { problems: [], through: cursor.through, listed: cursor.through, next: null };
|
|
293
|
+
let range;
|
|
294
|
+
try {
|
|
295
|
+
range = await rangeAt(store, keys.verifiers, last ? cursor.pair : at);
|
|
296
|
+
range = { ...range, entries: await sealedRange(store, keys.verifiers, range.start, range.next) };
|
|
297
|
+
} catch (error) {
|
|
298
|
+
return stop(last ? `the projection is stopped at ${cursor.through}: ${(error as Error).message}` : (error as Error).message);
|
|
299
|
+
}
|
|
300
|
+
const { pair, entries, start } = range;
|
|
301
|
+
const upto = last ? cursor.through : pair.upto;
|
|
302
|
+
const floor = start === null ? 0 : ((await openPair(start.notes, keys.verifiers))?.floor ?? 0);
|
|
303
|
+
const uses = entries.filter((entry) => entry.tree === 'uses' && entry.number <= upto);
|
|
304
|
+
let problems: string[];
|
|
305
|
+
try {
|
|
306
|
+
problems = await listedAsSealed(store, keys.mac, uses, { after: start?.upto ?? 0n, upto, floor, signedAt: pair.at });
|
|
307
|
+
} catch (error) {
|
|
308
|
+
// A change a use is read from that does not hold: what stops the projector stops the check too, said.
|
|
309
|
+
return stop((error as Error).message);
|
|
310
|
+
}
|
|
311
|
+
const named = problems.length <= PROBLEMS_NAMED ? problems : [...problems.slice(0, PROBLEMS_NAMED), `and ${problems.length - PROBLEMS_NAMED} more`];
|
|
312
|
+
return { problems: named, through: upto, listed: cursor.through, next: last ? null : pair.upto };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** What a range's uses, derived again, find wrong in the listing between `after` and `upto` (`checkReads`). */
|
|
316
|
+
async function listedAsSealed(store: ReadsStore, mac: LogMacKey, uses: readonly LogEntry[], range: { after: bigint; upto: bigint; floor: number; signedAt: number }): Promise<string[]> {
|
|
317
|
+
const reads = await derive(store, mac, uses);
|
|
318
|
+
if (reads.length === 0) return [];
|
|
319
|
+
const problems: string[] = [];
|
|
320
|
+
// Every time a use of the range may be at: none is below the floor the pair under it signed, nor, but for a clock
|
|
321
|
+
// apart, past the time the pair over it was signed at.
|
|
322
|
+
// A loop, not spread arguments: a range after an outage holds more uses than a call takes arguments (review r5).
|
|
323
|
+
let [from, to] = [range.floor, range.signedAt];
|
|
324
|
+
for (const read of reads) [from, to] = [Math.min(from, read.at), Math.max(to, read.at)];
|
|
325
|
+
const listing = await store.listing({ after: range.after, upto: range.upto, from, to });
|
|
326
|
+
const listed = new Map<bigint, ReadRow & { dayed: boolean }>();
|
|
327
|
+
for (const read of listing) {
|
|
328
|
+
if (listed.has(read.number)) problems.push(`read ${read.number} is listed twice`);
|
|
329
|
+
listed.set(read.number, read);
|
|
330
|
+
}
|
|
331
|
+
for (const read of reads) {
|
|
332
|
+
const row = listed.get(read.number);
|
|
333
|
+
listed.delete(read.number);
|
|
334
|
+
if (row === undefined) problems.push(`read ${read.number} is sealed but not listed`);
|
|
335
|
+
else if (!readHolds(mac, row) || !row.mac.equals(read.mac)) problems.push(`read ${read.number} is listed otherwise than the log sealed it`);
|
|
336
|
+
// Its member's day of it gone, their page and offboarding report would leave it out unseen (review r6).
|
|
337
|
+
else if (!row.dayed) problems.push(`read ${read.number} is off its member's pages: their day of it is not listed`);
|
|
338
|
+
}
|
|
339
|
+
for (const number of listed.keys()) problems.push(`read ${number} is listed, but the log seals no such use`);
|
|
340
|
+
return problems;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** A use's columns as the uses tree holds them: [at, request, credential, secret, purpose, place, mac]. */
|
|
344
|
+
function useOf(entry: LogEntry) {
|
|
345
|
+
const [at, , credential, secret, purpose, place] = entry.columns as [bigint, bigint, string, string, number, string | null];
|
|
346
|
+
return { number: entry.number, at: Number(at), credential, secret, purpose, place };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** A change's payload as JSON, or nothing for one that is not an object. */
|
|
350
|
+
function parsed(sealed: Sealed): Record<string, unknown> | null {
|
|
351
|
+
const payload = sealed?.columns[4];
|
|
352
|
+
if (typeof payload !== 'string') return null;
|
|
353
|
+
try {
|
|
354
|
+
const value = JSON.parse(payload) as unknown;
|
|
355
|
+
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
|
|
356
|
+
} catch {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const text = (value: unknown) => (typeof value === 'string' && value !== '' ? value : null);
|
|
362
|
+
const record = (value: unknown) => (typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null);
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The account a way in's first change names: a credential's issue, a
|
|
366
|
+
* connection's or binding's making, the cutover's object, a principal's
|
|
367
|
+
* admission. An account never changes: its first change says it for good.
|
|
368
|
+
*/
|
|
369
|
+
function accountOf(sealed: Sealed): string | null {
|
|
370
|
+
const op = parsed(sealed);
|
|
371
|
+
if (op === null) return null;
|
|
372
|
+
const value = record(op.value);
|
|
373
|
+
if (op.op === 'genesis') return text(value?.principal) ?? (value?.kind === 'principal' ? text(value.id) : null);
|
|
374
|
+
if (op.op === 'member.add') return text(record(op.ref)?.id);
|
|
375
|
+
return text(op.principal) ?? text(value?.principal);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** A key's object as a change holds it whole: a write's, or the cutover's; nothing for a deletion, or a payload of another shape. */
|
|
379
|
+
function keyOf(sealed: Sealed): { projectId: string; environmentId: string; version: string | null; source: string | null } | null {
|
|
380
|
+
const op = parsed(sealed);
|
|
381
|
+
const object = op?.op === 'secret.write' ? op : op?.op === 'genesis' && record(op.value)?.kind === 'secret' ? record(op.value) : null;
|
|
382
|
+
const [projectId, environmentId] = [text(object?.projectId), text(object?.environmentId)];
|
|
383
|
+
if (object === null || projectId === null || environmentId === null) return null;
|
|
384
|
+
return { projectId, environmentId, version: text(object.version), source: text(object.source) };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** A way in's object, by its code (`USE_WAYS`): a credential's, a connection's or a binding's, or behind Access the person's own, which the use names. */
|
|
388
|
+
function wayObject(use: { credential: string; purpose: number }): string {
|
|
389
|
+
const way = USE_WAYS[Math.floor(use.purpose / 512)];
|
|
390
|
+
if (way === undefined) throw new Error(`a use's code, ${use.purpose}, names no way in`);
|
|
391
|
+
return way === 'access' ? use.credential : logObject(way, use.credential);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* What each use read, from the sealed objects at its number: its way in's
|
|
396
|
+
* account (its first change), its key's object below it, and through a
|
|
397
|
+
* reference its source's below it. A use whose objects say nothing of it
|
|
398
|
+
* (only a database writer leaves one so: a use of a key never sealed is
|
|
399
|
+
* refused) is projected as the log says it, its account and place unknown:
|
|
400
|
+
* shown, never dropped. Each change read is held to its MAC: one a writer
|
|
401
|
+
* edited since its seal stops the projection (fail closed), never derives
|
|
402
|
+
* a read from it.
|
|
403
|
+
*/
|
|
404
|
+
async function derive(store: ReadsStore, mac: LogMacKey, entries: readonly LogEntry[]): Promise<ReadRow[]> {
|
|
405
|
+
const holds = authentic(mac);
|
|
406
|
+
const read = async (asked: { object: string; below: bigint; first: boolean; use: bigint }[]) => (await store.objects(asked)).map((change, i) => {
|
|
407
|
+
if (change !== null && !holds(change)) throw new Error(`change ${change.number}, which use ${asked[i]!.use} is read from, does not hold to its MAC: edited since its seal`);
|
|
408
|
+
return change;
|
|
409
|
+
});
|
|
410
|
+
const uses = entries.map(useOf);
|
|
411
|
+
const sealed = await read(uses.flatMap((use) => [
|
|
412
|
+
{ object: wayObject(use), below: use.number, first: true, use: use.number },
|
|
413
|
+
{ object: logObject('secret', use.secret), below: use.number, first: false, use: use.number },
|
|
414
|
+
]));
|
|
415
|
+
const keys = uses.map((_, i) => keyOf(sealed[2 * i + 1]!));
|
|
416
|
+
const through = uses.flatMap((use, i) => (keys[i]?.source == null ? [] : [{ i, object: logObject('secret', keys[i]!.source!), below: use.number, first: false, use: use.number }]));
|
|
417
|
+
const sources = new Map((await read(through)).map((object, j) => [through[j]!.i, keyOf(object)]));
|
|
418
|
+
return uses.map((use, i) => {
|
|
419
|
+
const key = keys[i];
|
|
420
|
+
const source = key?.source == null ? null : sources.get(i) ?? null;
|
|
421
|
+
const row: Omit<ReadRow, 'mac'> = {
|
|
422
|
+
number: use.number,
|
|
423
|
+
at: use.at,
|
|
424
|
+
credential: use.credential,
|
|
425
|
+
purpose: use.purpose,
|
|
426
|
+
principal: accountOf(sealed[2 * i]!) ?? 'system:unknown',
|
|
427
|
+
secret: use.secret,
|
|
428
|
+
environment: key?.environmentId ?? NOWHERE,
|
|
429
|
+
project: key?.projectId ?? NOWHERE,
|
|
430
|
+
place: use.place,
|
|
431
|
+
source: key?.source ?? null,
|
|
432
|
+
sourceEnvironment: source?.environmentId ?? null,
|
|
433
|
+
sourceProject: source?.projectId ?? null,
|
|
434
|
+
version: key?.source == null ? (key?.version ?? null) : (source?.version ?? null),
|
|
435
|
+
};
|
|
436
|
+
return { ...row, mac: readMac(mac, row) };
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** The place of a read whose key's object names none: no environment's; the whole log and its member's page list it. */
|
|
441
|
+
export const NOWHERE = '00000000-0000-0000-0000-000000000000';
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* The projector as a host runs it, apart from its checkpointer: on Node,
|
|
445
|
+
* `kick` after each tick starts a run unless one is going, and returns at
|
|
446
|
+
* once (checkpointer-node.ts); on Workers, an object of its own runs it on
|
|
447
|
+
* its own alarm (checkpointer-workers.ts). Either way no tick, seal, change
|
|
448
|
+
* or use ever waits on it (formal/Reads.tla, SealNeverWaitsOnProjection).
|
|
449
|
+
* Each run goes some steps over the host's store. A failure is an alarm, said once while it stands; the pages show the
|
|
450
|
+
* lag meanwhile.
|
|
451
|
+
*/
|
|
452
|
+
export class Projector {
|
|
453
|
+
readonly #store: ReadsStore;
|
|
454
|
+
readonly #keys: { verifiers: PairVerifiers; mac: LogMacKey };
|
|
455
|
+
readonly #alert: (message: string) => void;
|
|
456
|
+
#running: Promise<boolean> | null = null;
|
|
457
|
+
#failed: string | null = null;
|
|
458
|
+
#stopped = false;
|
|
459
|
+
|
|
460
|
+
constructor(store: ReadsStore, keys: { verifiers: PairVerifiers; mac: LogMacKey }, alert: (message: string) => void) {
|
|
461
|
+
this.#store = store;
|
|
462
|
+
this.#keys = keys;
|
|
463
|
+
this.#alert = alert;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Start a run unless one is going or the projector is stopped; never waits on it. */
|
|
467
|
+
kick(): void {
|
|
468
|
+
void this.run();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** A run, or the one going: whether it left sealed uses to project. Never throws. */
|
|
472
|
+
run(): Promise<boolean> {
|
|
473
|
+
if (this.#stopped) return Promise.resolve(false);
|
|
474
|
+
return (this.#running ??= this.#run().finally(() => {
|
|
475
|
+
this.#running = null;
|
|
476
|
+
}));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** The run going, if any, ended: for a host that stops, and for tests. */
|
|
480
|
+
async settled(): Promise<void> {
|
|
481
|
+
await this.#running;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** No run after this one: a host's stop. */
|
|
485
|
+
async stop(): Promise<void> {
|
|
486
|
+
this.#stopped = true;
|
|
487
|
+
await this.#running;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async #run(): Promise<boolean> {
|
|
491
|
+
try {
|
|
492
|
+
const { through, sealed } = await projectReads(this.#store, this.#keys);
|
|
493
|
+
this.#failed = null;
|
|
494
|
+
return through < sealed;
|
|
495
|
+
} catch (error) {
|
|
496
|
+
const message = `the reads' projection failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
497
|
+
if (message !== this.#failed) this.#alert(message);
|
|
498
|
+
this.#failed = message;
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|