@plane31/blind-relay 0.1.1 → 0.1.3

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/src/inbox.ts ADDED
@@ -0,0 +1,560 @@
1
+ /**
2
+ * The receiver: records, history, the acknowledged prefix and the live pin
3
+ * (protocol section 10, receiving; transaction T7).
4
+ */
5
+ import { equalBytes, fromHex, toHex } from "./bytes.js";
6
+ import { DocumentType, expectType, parseDocument, type EncryptedRecord, type RecordKind } from "./codec.js";
7
+ import { bodyHash } from "./crypto.js";
8
+ import { openEpoch, type AuthorView, type SelfView } from "./epoch.js";
9
+ import { KavachError, isKavachError } from "./errors.js";
10
+ import { authorPrefix, indexHistory, padTime, timelinePrefix, unindexHistory } from "./historyIndex.js";
11
+ import { historyKinds, kindOf, type RetentionPolicy } from "./policy.js";
12
+ import { openRecordWire } from "./record.js";
13
+ import { big, unbig } from "./rows.js";
14
+ import { clampEventTime, slotKey, takeSlot } from "./slots.js";
15
+ import type { HistoryRow, KeyRange, Store, SubscriptionRow, Tx } from "./store.js";
16
+ import type { ReadRecordsResult, Relay } from "./transport.js";
17
+
18
+ export const MAX_FUTURE_SKEW_MS = 300_000n;
19
+ export const READ_BATCH = 100;
20
+
21
+ export type ReceiveResult =
22
+ | { readonly result: "updated"; readonly recordId: Uint8Array; readonly authorIdentityId: Uint8Array; readonly kind: RecordKind }
23
+ | { readonly result: "history"; readonly recordId: Uint8Array; readonly authorIdentityId: Uint8Array; readonly kind: RecordKind }
24
+ /** Verified, of a slot-only kind, and older than the slot's holder (or from a retired device): nothing kept. */
25
+ | { readonly result: "older"; readonly recordId: Uint8Array; readonly authorIdentityId: Uint8Array; readonly kind: RecordKind }
26
+ | { readonly result: "duplicate"; readonly recordId: Uint8Array }
27
+ | { readonly result: "keyPending"; readonly streamId: Uint8Array; readonly epoch: number }
28
+ | { readonly result: "expired"; readonly reason: "window" | "CLOCK_SKEW" }
29
+ | { readonly result: "invalid"; readonly reason: string };
30
+
31
+ export interface InboxEvents {
32
+ received?(r: ReceiveResult): void;
33
+ /** The author's current epoch has no key for this device yet: show the person as waiting. */
34
+ waiting?(authorIdentityId: Uint8Array): void;
35
+ securityError?(code: string, detail: string): void;
36
+ }
37
+
38
+ export interface InboxDeps {
39
+ readonly store: Store;
40
+ readonly relay: Relay;
41
+ readonly policy: RetentionPolicy;
42
+ readonly now: () => bigint;
43
+ readonly self: () => SelfView;
44
+ readonly events?: InboxEvents;
45
+ }
46
+
47
+ // Views over the store --------------------------------------------------------------------------
48
+
49
+ function authorViewFor(tx: Pick<Tx, "get" | "list">, streamId: Uint8Array): { view: AuthorView; identityId: Uint8Array } | undefined {
50
+ const certs = tx.list("certificates").filter(([, c]) => c.streamId === toHex(streamId));
51
+ const first = certs[0]?.[1];
52
+ if (!first) return undefined;
53
+ const all = tx.list("certificates").filter(([, c]) => c.identityId === first.identityId);
54
+ const certificates = new Map(
55
+ all.map(([, c]) => [c.certificateHash, { certificateHash: fromHex(c.certificateHash), signingKey: fromHex(c.signingKey), streamId: fromHex(c.streamId), current: c.current }])
56
+ );
57
+ return { view: { identityId: fromHex(first.identityId), certificates }, identityId: fromHex(first.identityId) };
58
+ }
59
+
60
+ function isAuthorized(tx: Pick<Tx, "get" | "list">, authorIdentityId: Uint8Array, self: Uint8Array): boolean {
61
+ if (equalBytes(authorIdentityId, self)) return true;
62
+ const hex = toHex(authorIdentityId);
63
+ return tx.list("circles").some(([, c]) => c.status === "member" && c.members.includes(hex));
64
+ }
65
+
66
+ function epochKeyFor(tx: Pick<Tx, "get">, streamId: Uint8Array, epoch: number): Uint8Array | undefined {
67
+ const row = tx.get("readerEpochs", `${toHex(streamId)}:${epoch}`);
68
+ return row ? fromHex(row.epochKey) : undefined;
69
+ }
70
+
71
+ /** Fetch and verify an epoch's key through the relay and store it. Returns false when it cannot be had yet. */
72
+ async function fetchEpochKey(deps: InboxDeps, streamId: Uint8Array, epoch: number): Promise<boolean> {
73
+ let bundle;
74
+ try {
75
+ bundle = await deps.relay.getEpoch(streamId, epoch);
76
+ } catch {
77
+ return false;
78
+ }
79
+ if (!bundle?.wrap) return false;
80
+ const wrap = bundle.wrap;
81
+ const self = deps.self();
82
+ return deps.store.transaction((tx) => {
83
+ const author = authorViewFor(tx, streamId);
84
+ if (!author) return false;
85
+ const opened = openEpoch(bundle.manifest, author.view, self, wrap); // throws the section 8 codes
86
+ tx.put("readerEpochs", `${toHex(streamId)}:${epoch}`, {
87
+ streamId: toHex(streamId),
88
+ epoch,
89
+ epochKey: toHex(opened.epochKey),
90
+ manifestHash: toHex(opened.manifest.bodyHash),
91
+ authorCertificateHash: toHex(opened.manifest.authorCertificateHash),
92
+ superseded: opened.superseded
93
+ });
94
+ return true;
95
+ });
96
+ }
97
+
98
+ // T7 -------------------------------------------------------------------------------------------------
99
+
100
+ /**
101
+ * Verify, decrypt and store one record (T7). Replays are harmless upserts; a different valid
102
+ * body for a retained id is `EQUIVOCATION`. The live pin moves only for kind 0 from the author's
103
+ * current device and a greater (generation, epoch, counter).
104
+ */
105
+ function storeRecord(tx: Tx, deps: InboxDeps, wire: Uint8Array, now: bigint): ReceiveResult {
106
+ const self = deps.self();
107
+ const parsed = expectType<EncryptedRecord>(parseDocument(wire), DocumentType.EncryptedRecord, "receive");
108
+ if (parsed.sentAt > now + MAX_FUTURE_SKEW_MS) return { result: "expired", reason: "CLOCK_SKEW" };
109
+ const author = authorViewFor(tx, parsed.streamId);
110
+ if (!author) return { result: "invalid", reason: "NOT_AUTHORIZED" };
111
+ if (!isAuthorized(tx, author.identityId, self.identityId)) return { result: "invalid", reason: "NOT_AUTHORIZED" };
112
+ let opened;
113
+ try {
114
+ opened = openRecordWire(wire, author.view, (s, e) => epochKeyFor(tx, s, e));
115
+ } catch (e) {
116
+ if (isKavachError(e, "KEY_PENDING")) return { result: "keyPending", streamId: parsed.streamId, epoch: parsed.epoch };
117
+ if (e instanceof KavachError) return { result: "invalid", reason: e.code };
118
+ throw e;
119
+ }
120
+ const id = toHex(opened.recordId);
121
+ const hash = toHex(bodyHash(opened.record.body));
122
+ const cert = [...author.view.certificates.values()].find((c) => equalBytes(c.streamId, parsed.streamId));
123
+ const generation = unbig(tx.list("certificates").find(([, c]) => c.streamId === toHex(parsed.streamId))?.[1].generation ?? "0");
124
+ const kind = opened.kind;
125
+ const base = { recordId: opened.recordId, authorIdentityId: author.identityId, kind };
126
+ const declared = kindOf(deps.policy, kind);
127
+ if (!declared) return { result: "invalid", reason: "BAD_PAYLOAD" }; // a kind this product did not declare
128
+ const authorHex = toHex(author.identityId);
129
+ const fromCurrentDevice = !opened.superseded && !!cert?.current;
130
+ const candidate = { generation, eventTime: clampEventTime(opened.eventTime, parsed.sentAt), epoch: parsed.epoch, counter: parsed.counter, recordId: id, bodyHash: hash, sentAt: parsed.sentAt, receivedAt: now, payload: opened.payload };
131
+ if (!declared.history) {
132
+ // A slot-only kind is the author's current state, not an event: one row per author, the newest
133
+ // from the current device wins. An older or superseded one changes nothing.
134
+ const current = tx.get("newest", slotKey(authorHex, kind));
135
+ if (current?.recordId === id) {
136
+ if (current.bodyHash === hash) return { result: "duplicate", recordId: opened.recordId };
137
+ return { result: "invalid", reason: "EQUIVOCATION" };
138
+ }
139
+ if (!fromCurrentDevice) return { result: "older", ...base };
140
+ return takeSlot(tx, authorHex, kind, candidate, toHex) ? { result: "updated", ...base } : { result: "older", ...base };
141
+ }
142
+ const existing = tx.get("history", id);
143
+ if (existing) {
144
+ if (existing.bodyHash === hash) return { result: "duplicate", recordId: opened.recordId };
145
+ return { result: "invalid", reason: "EQUIVOCATION" };
146
+ }
147
+ const row: HistoryRow = {
148
+ recordId: id,
149
+ authorIdentityId: toHex(author.identityId),
150
+ streamId: toHex(parsed.streamId),
151
+ generation: big(generation),
152
+ epoch: parsed.epoch,
153
+ counter: parsed.counter,
154
+ kind,
155
+ sentAt: big(parsed.sentAt),
156
+ receivedAt: big(now),
157
+ plaintext: toHex(opened.payload),
158
+ superseded: opened.superseded || !cert?.current,
159
+ own: false,
160
+ bodyHash: hash,
161
+ ...(opened.eventTime === undefined ? {} : { eventTime: big(opened.eventTime) })
162
+ };
163
+ tx.put("history", id, row);
164
+ indexHistory(tx, row);
165
+ // A kind with a slot is news when it takes the slot; a kind without one is news every time.
166
+ const took = declared.newest && fromCurrentDevice ? takeSlot(tx, authorHex, kind, candidate, toHex) : false;
167
+ return took || !declared.newest ? { result: "updated", ...base } : { result: "history", ...base };
168
+ }
169
+
170
+ /** A record pushed by the host outside `sync`: stored, but it never advances a relay cursor. */
171
+ export async function receive(deps: InboxDeps, wire: Uint8Array): Promise<ReceiveResult> {
172
+ const now = deps.now();
173
+ let r = await deps.store.transaction((tx) => storeRecord(tx, deps, wire, now));
174
+ if (r.result === "keyPending" && (await fetchEpochKey(deps, r.streamId, r.epoch).catch(() => false))) {
175
+ r = await deps.store.transaction((tx) => storeRecord(tx, deps, wire, now));
176
+ }
177
+ if (r.result === "invalid" && r.reason === "EQUIVOCATION") deps.events?.securityError?.("EQUIVOCATION", "a different valid body for a retained record id");
178
+ deps.events?.received?.(r);
179
+ return r;
180
+ }
181
+
182
+ // Sync: read, process, acknowledge -------------------------------------------------------------------
183
+
184
+ export interface StreamSyncSummary {
185
+ read: number;
186
+ stored: number;
187
+ duplicate: number;
188
+ keyPending: number;
189
+ invalid: number;
190
+ expired: number;
191
+ acked: bigint | undefined;
192
+ /** The relay holds more records past the last page read; the budget stopped the catch-up. */
193
+ hasMore: boolean;
194
+ }
195
+
196
+ function subscription(tx: Pick<Tx, "get">, streamId: Uint8Array, authorIdentityId: Uint8Array): SubscriptionRow {
197
+ return (
198
+ tx.get("subscriptions", toHex(streamId)) ?? {
199
+ streamId: toHex(streamId),
200
+ authorIdentityId: toHex(authorIdentityId),
201
+ scanPos: "0",
202
+ ackPrefix: "0",
203
+ committedCursor: "0",
204
+ ackInFlight: false
205
+ }
206
+ );
207
+ }
208
+
209
+ /** The eligible prefix: the scan position, held back to just before the oldest unresolved sequence. */
210
+ function eligiblePrefix(tx: Pick<Tx, "list">, streamId: Uint8Array, scanPos: bigint, expiredThrough: bigint): bigint {
211
+ let prefix = scanPos;
212
+ for (const [, u] of tx.list("unresolved", toHex(streamId) + ":")) {
213
+ const seq = unbig(u.deliverySeq);
214
+ if (seq <= expiredThrough) continue; // the relay expired it: queue progress, never a receipt
215
+ if (seq - 1n < prefix) prefix = seq - 1n;
216
+ }
217
+ return prefix;
218
+ }
219
+
220
+ /**
221
+ * One pass over one author stream: read a batch above the local scan position, process every
222
+ * record, hold the prefix at any `keyPending` record, and send at most one acknowledgement.
223
+ */
224
+ /** What one sync may still read across every stream: records left and the time it must stop at. */
225
+ export interface ReadBudget {
226
+ remainingRecords: number;
227
+ readonly deadline: bigint;
228
+ }
229
+
230
+ /**
231
+ * Read a stream in pages until it is caught up or the budget is spent. The first page restarts
232
+ * from the eligible prefix while a record is unresolved (the held record is read again; stored
233
+ * ones come back as harmless duplicates); later pages of the same sync continue from the last
234
+ * sequence examined, so a held record never makes the loop re-read its own page. Each page is
235
+ * processed and acknowledged before the next is read. `hasMore` says the relay still holds
236
+ * records past the last page when the budget stopped the loop.
237
+ */
238
+ export async function syncStream(deps: InboxDeps, streamId: Uint8Array, authorIdentityId: Uint8Array, budget: ReadBudget = { remainingRecords: READ_BATCH, deadline: deps.now() }): Promise<StreamSyncSummary> {
239
+ const summary: StreamSyncSummary = { read: 0, stored: 0, duplicate: 0, keyPending: 0, invalid: 0, expired: 0, acked: undefined, hasMore: false };
240
+ const sub = await deps.store.read((tx) => subscription(tx, streamId, authorIdentityId));
241
+ const unresolved = await deps.store.read((tx) => tx.list("unresolved", toHex(streamId) + ":"));
242
+ let from = unresolved.length > 0 ? unbig(sub.ackPrefix) : unbig(sub.scanPos);
243
+ for (;;) {
244
+ // A failed read is the caller's to report: the sync names the stream in its report and goes
245
+ // on to the next one, rather than counting a stream it never read as synced.
246
+ const page = await deps.relay.readRecords(streamId, from, READ_BATCH);
247
+ await processPage(deps, streamId, authorIdentityId, page, summary);
248
+ budget.remainingRecords -= page.records.length;
249
+ const caughtUp = page.nextSeq >= page.tail || page.nextSeq <= from;
250
+ if (caughtUp) break;
251
+ if (budget.remainingRecords <= 0 || deps.now() >= budget.deadline) {
252
+ summary.hasMore = true;
253
+ break;
254
+ }
255
+ from = page.nextSeq;
256
+ }
257
+ return summary;
258
+ }
259
+
260
+ /** T7 for every record of one page, then the scan position, the eligible prefix and one acknowledgement. */
261
+ async function processPage(deps: InboxDeps, streamId: Uint8Array, authorIdentityId: Uint8Array, page: ReadRecordsResult, summary: StreamSyncSummary): Promise<void> {
262
+ const now = deps.now();
263
+ // Drop unresolved entries the relay has expired.
264
+ for (const [key, u] of await deps.store.read((tx) => tx.list("unresolved", toHex(streamId) + ":"))) {
265
+ if (unbig(u.deliverySeq) <= page.expiredThrough) {
266
+ await deps.store.transaction((tx) => tx.delete("unresolved", key));
267
+ summary.expired++;
268
+ }
269
+ }
270
+ for (const item of page.records) {
271
+ summary.read++;
272
+ if (item.deliverySeq <= page.expiredThrough) {
273
+ summary.expired++;
274
+ continue;
275
+ }
276
+ let r = await deps.store.transaction((tx) => storeRecord(tx, deps, item.wire, now));
277
+ if (r.result === "keyPending") {
278
+ let fetched = false;
279
+ try {
280
+ fetched = await fetchEpochKey(deps, r.streamId, r.epoch);
281
+ } catch (e) {
282
+ if (isKavachError(e, "NOT_AUTHORIZED")) r = { result: "invalid", reason: "NOT_AUTHORIZED" };
283
+ else if (e instanceof KavachError) {
284
+ // The served manifest or wrap did not verify. The record may still be honest, so it is
285
+ // held as keyPending (the prefix stops here, scenario 21) and the code is named; the
286
+ // relay's expiry resolves a manifest that never verifies.
287
+ deps.events?.securityError?.(e.code, `epoch ${r.epoch} of stream ${toHex(r.streamId).slice(0, 8)} refused`);
288
+ } else throw e;
289
+ }
290
+ if (fetched) r = await deps.store.transaction((tx) => storeRecord(tx, deps, item.wire, now));
291
+ }
292
+ const seqKey = `${toHex(streamId)}:${item.deliverySeq.toString().padStart(20, "0")}`;
293
+ await deps.store.transaction((tx) => {
294
+ if (r.result === "keyPending") {
295
+ tx.put("unresolved", seqKey, { streamId: toHex(streamId), deliverySeq: big(item.deliverySeq), reason: "KEY_PENDING", epoch: r.epoch, recordedAt: big(now) });
296
+ } else tx.delete("unresolved", seqKey);
297
+ });
298
+ if (r.result === "keyPending") {
299
+ summary.keyPending++;
300
+ deps.events?.waiting?.(authorIdentityId);
301
+ } else if (r.result === "invalid") {
302
+ // The relay serves only records addressed to this device, so anything unverifiable here is
303
+ // the host's or the author's doing: it is named to the app and never stored.
304
+ summary.invalid++;
305
+ deps.events?.securityError?.(r.reason, `record at sequence ${item.deliverySeq} refused`);
306
+ } else if (r.result === "expired") summary.expired++;
307
+ else if (r.result === "duplicate") summary.duplicate++;
308
+ else summary.stored++;
309
+ deps.events?.received?.(r);
310
+ }
311
+
312
+ // Advance the scan position and the eligible prefix; acknowledge once if it moved.
313
+ const toAck = await deps.store.transaction((tx) => {
314
+ const s = subscription(tx, streamId, authorIdentityId);
315
+ const scanPos = page.nextSeq > unbig(s.scanPos) ? page.nextSeq : unbig(s.scanPos);
316
+ const prefix = eligiblePrefix(tx, streamId, scanPos, page.expiredThrough);
317
+ const committed = page.committedCursor > unbig(s.committedCursor) ? page.committedCursor : unbig(s.committedCursor);
318
+ const next: SubscriptionRow = { ...s, scanPos: big(scanPos), ackPrefix: big(prefix), committedCursor: big(committed) };
319
+ const send = prefix > committed && !s.ackInFlight;
320
+ tx.put("subscriptions", toHex(streamId), { ...next, ackInFlight: send });
321
+ return send ? prefix : undefined;
322
+ });
323
+ if (toAck !== undefined) {
324
+ let committed: bigint | undefined;
325
+ try {
326
+ const ack = await deps.relay.ackThrough(streamId, toAck);
327
+ if (ack.status === "COMMITTED") committed = ack.committedCursor ?? toAck;
328
+ } catch {
329
+ committed = undefined;
330
+ }
331
+ await deps.store.transaction((tx) => {
332
+ const s = subscription(tx, streamId, authorIdentityId);
333
+ tx.put("subscriptions", toHex(streamId), { ...s, ackInFlight: false, committedCursor: committed !== undefined && committed > unbig(s.committedCursor) ? big(committed) : s.committedCursor });
334
+ });
335
+ summary.acked = committed;
336
+ }
337
+ }
338
+
339
+ // Reads ------------------------------------------------------------------------------------------------
340
+
341
+ export interface HistoryEntry {
342
+ readonly recordId: Uint8Array;
343
+ readonly authorIdentityId: Uint8Array;
344
+ readonly kind: RecordKind;
345
+ /** The sender's event time for the record, when it gave one. */
346
+ readonly eventTime: bigint | undefined;
347
+ readonly sentAt: bigint;
348
+ readonly receivedAt: bigint;
349
+ readonly plaintext: Uint8Array;
350
+ readonly superseded: boolean;
351
+ readonly own: boolean;
352
+ }
353
+
354
+ /**
355
+ * A history query. Every filter is served from the index: an author, a kind, a `sentAt` window
356
+ * (`since` inclusive, `before` exclusive), the order and a page size. `cursor` continues a page
357
+ * from `HistoryPage.cursor`; it names a fixed position in time order, so pages stay stable while
358
+ * records arrive or expire.
359
+ */
360
+ export interface HistoryQuery {
361
+ readonly authorIdentityId?: Uint8Array;
362
+ readonly kind?: RecordKind;
363
+ readonly since?: bigint;
364
+ readonly before?: bigint;
365
+ /** At most this many entries; 1000 when absent. */
366
+ readonly limit?: number;
367
+ /** Oldest first by default; `"desc"` reads newest first. */
368
+ readonly order?: "asc" | "desc";
369
+ readonly cursor?: string;
370
+ }
371
+
372
+ export interface HistoryPage {
373
+ readonly entries: HistoryEntry[];
374
+ /** Present when the page was full: pass it back as `cursor` for the next page. */
375
+ readonly cursor: string | undefined;
376
+ }
377
+
378
+ export const HISTORY_PAGE = 1000;
379
+
380
+ function toEntry(r: HistoryRow): HistoryEntry {
381
+ return {
382
+ recordId: fromHex(r.recordId),
383
+ authorIdentityId: fromHex(r.authorIdentityId),
384
+ kind: r.kind,
385
+ eventTime: r.eventTime === undefined ? undefined : unbig(r.eventTime),
386
+ sentAt: unbig(r.sentAt),
387
+ receivedAt: unbig(r.receivedAt),
388
+ plaintext: fromHex(r.plaintext),
389
+ superseded: r.superseded,
390
+ own: r.own
391
+ };
392
+ }
393
+
394
+ /** One page of history in time order, read through the index: one bounded scan per kind, merged. */
395
+ export async function historyPage(store: Store, policy: RetentionPolicy, q: HistoryQuery = {}): Promise<HistoryPage> {
396
+ const limit = Math.max(0, Math.floor(q.limit ?? HISTORY_PAGE));
397
+ const reverse = q.order === "desc";
398
+ const all = historyKinds(policy);
399
+ const kinds = q.kind === undefined ? all : all.includes(q.kind) ? [q.kind] : [];
400
+ if (limit === 0 || kinds.length === 0) return { entries: [], cursor: undefined };
401
+ return store.read((tx) => {
402
+ const scans = kinds.map((kind) => {
403
+ const prefix = q.authorIdentityId ? authorPrefix(toHex(q.authorIdentityId), kind) : timelinePrefix(kind);
404
+ // Bounds in tuple space, prefixed into key space: the lower end from `since` and an
405
+ // ascending cursor, the upper end from `before` and a descending cursor, the tighter winning.
406
+ const upper = [q.before === undefined ? undefined : padTime(q.before), reverse ? q.cursor : undefined].filter((b): b is string => b !== undefined).sort()[0];
407
+ const range: KeyRange = {
408
+ prefix,
409
+ ...(q.since === undefined ? {} : { gte: prefix + padTime(q.since) }),
410
+ ...(!reverse && q.cursor !== undefined ? { gt: prefix + q.cursor } : {}),
411
+ ...(upper === undefined ? {} : { lt: prefix + upper }),
412
+ reverse,
413
+ limit
414
+ };
415
+ return tx.range("historyIndex", range).map(([key, r]) => ({ tuple: key.slice(prefix.length), recordId: r.recordId }));
416
+ });
417
+ const merged = mergeByTuple(scans, reverse, limit);
418
+ const entries: HistoryEntry[] = [];
419
+ for (const m of merged) {
420
+ const row = tx.get("history", m.recordId);
421
+ if (row) entries.push(toEntry(row));
422
+ }
423
+ return { entries, cursor: merged.length === limit ? merged[merged.length - 1]?.tuple : undefined };
424
+ });
425
+ }
426
+
427
+ /** The first page only, for callers that want a plain list. */
428
+ export async function history(store: Store, policy: RetentionPolicy, q: HistoryQuery = {}): Promise<HistoryEntry[]> {
429
+ return (await historyPage(store, policy, q)).entries;
430
+ }
431
+
432
+ /** The newest history entry matching the filters, read as a one-entry descending page. */
433
+ export async function newest(store: Store, policy: RetentionPolicy, q: Pick<HistoryQuery, "authorIdentityId" | "kind"> = {}): Promise<HistoryEntry | undefined> {
434
+ return (await historyPage(store, policy, { ...q, order: "desc", limit: 1 })).entries[0];
435
+ }
436
+
437
+ interface IndexHit {
438
+ readonly tuple: string;
439
+ readonly recordId: string;
440
+ }
441
+
442
+ /** Merge per-kind scans that are each in order into one ordered list of at most `limit`. */
443
+ function mergeByTuple(scans: IndexHit[][], reverse: boolean, limit: number): IndexHit[] {
444
+ const heads = scans.map(() => 0);
445
+ const out: IndexHit[] = [];
446
+ while (out.length < limit) {
447
+ let pick: { i: number; hit: IndexHit } | undefined;
448
+ for (let i = 0; i < scans.length; i++) {
449
+ const hit = scans[i]?.[heads[i] ?? 0];
450
+ if (!hit) continue;
451
+ if (!pick || (reverse ? hit.tuple > pick.hit.tuple : hit.tuple < pick.hit.tuple)) pick = { i, hit };
452
+ }
453
+ if (!pick) break;
454
+ out.push(pick.hit);
455
+ heads[pick.i] = (heads[pick.i] ?? 0) + 1;
456
+ }
457
+ return out;
458
+ }
459
+
460
+ /** An author's newest record of a kind: the slot's holder, with the event time it was chosen by and its age. */
461
+ export interface Newest {
462
+ readonly recordId: Uint8Array;
463
+ readonly kind: number;
464
+ readonly payload: Uint8Array;
465
+ readonly sentAt: bigint;
466
+ /** The event time the slot was ordered by, never later than `sentAt`. */
467
+ readonly eventTime: bigint;
468
+ readonly ageMs: bigint;
469
+ }
470
+
471
+ /** The newest slot of an author and kind, or `undefined` when there is none or its payload expired (the slot stays, the payload is gone). */
472
+ export async function newestSlot(store: Store, authorIdentityId: Uint8Array, kind: number, now: bigint): Promise<Newest | undefined> {
473
+ return store.read((tx) => {
474
+ const row = tx.get("newest", slotKey(toHex(authorIdentityId), kind));
475
+ if (!row || row.payload === null) return undefined;
476
+ return { recordId: fromHex(row.recordId), kind, payload: fromHex(row.payload), sentAt: unbig(row.sentAt), eventTime: unbig(row.eventTime), ageMs: now - unbig(row.sentAt) };
477
+ });
478
+ }
479
+
480
+ /**
481
+ * Build the slots once for a store written before the slot table: every non-superseded history
482
+ * row of a kind declared `newest` is offered to its slot in time order. A fresh store is marked
483
+ * at T1 and never runs this. Idempotent.
484
+ */
485
+ export async function ensureSlots(store: Store, policy: RetentionPolicy): Promise<boolean> {
486
+ const built = await store.read((tx) => tx.get("meta", "meta")?.newest === 1);
487
+ if (built) return false;
488
+ return store.transaction((tx) => {
489
+ const meta = tx.get("meta", "meta");
490
+ if (!meta || meta.newest === 1) return false;
491
+ for (const [, r] of tx.list("history")) {
492
+ if (r.superseded || !kindOf(policy, r.kind)?.newest) continue;
493
+ const payload = fromHex(r.plaintext);
494
+ takeSlot(tx, r.authorIdentityId, r.kind, { generation: unbig(r.generation), eventTime: clampEventTime(r.eventTime === undefined ? undefined : unbig(r.eventTime), unbig(r.sentAt)), epoch: r.epoch, counter: r.counter, recordId: r.recordId, bodyHash: r.bodyHash ?? "", sentAt: unbig(r.sentAt), receivedAt: unbig(r.receivedAt), payload }, toHex);
495
+ }
496
+ tx.put("meta", "meta", { ...meta, newest: 1 });
497
+ return true;
498
+ });
499
+ }
500
+
501
+ /**
502
+ * Retention, per declared kind: drop history past the kind's window, and drop the payload of a
503
+ * newest slot past it while the row keeps its order, so an older record replayed later cannot
504
+ * become current.
505
+ */
506
+ export async function purgeHistory(store: Store, policy: RetentionPolicy, now: bigint): Promise<number> {
507
+ const window = (kind: number): bigint | undefined => kindOf(policy, kind)?.retentionMs;
508
+ const kindOfSlot = (key: string): number => Number(key.slice(key.lastIndexOf(":") + 1));
509
+ const staleSlot = (key: string, r: { payload: string | null; sentAt: string }): boolean => {
510
+ const w = window(kindOfSlot(key));
511
+ return r.payload !== null && w !== undefined && unbig(r.sentAt) + w <= now;
512
+ };
513
+ // The expired prefix of each kind's timeline: `sentAt + window <= now`, as a key bound.
514
+ const expiredRange = (kind: number, limit?: number): KeyRange | undefined => {
515
+ const w = window(kind);
516
+ const cutoff = w === undefined ? 0n : now - w + 1n;
517
+ return cutoff <= 0n ? undefined : { prefix: timelinePrefix(kind), lt: timelinePrefix(kind) + padTime(cutoff), ...(limit === undefined ? {} : { limit }) };
518
+ };
519
+ const expired = (tx: Pick<Tx, "range">, limit?: number) => historyKinds(policy).flatMap((kind) => {
520
+ const r = expiredRange(kind, limit);
521
+ return r ? tx.range("historyIndex", r) : [];
522
+ });
523
+ // Read first: most syncs purge nothing and must not take the writer lock for it.
524
+ const any = await store.read((tx) => expired(tx, 1).length > 0 || tx.list("newest").some(([k, r]) => staleSlot(k, r)));
525
+ if (!any) return 0;
526
+ return store.transaction((tx) => {
527
+ let removed = 0;
528
+ for (const [key, hit] of expired(tx)) {
529
+ const row = tx.get("history", hit.recordId);
530
+ if (!row) {
531
+ tx.delete("historyIndex", key);
532
+ continue;
533
+ }
534
+ unindexHistory(tx, row);
535
+ tx.delete("history", hit.recordId);
536
+ removed++;
537
+ }
538
+ for (const [key, r] of tx.list("newest")) {
539
+ if (staleSlot(key, r)) {
540
+ tx.put("newest", key, { ...r, payload: null });
541
+ if (!kindOf(policy, kindOfSlot(key))?.history) removed++; // a history kind's record was counted with its row
542
+ }
543
+ }
544
+ return removed;
545
+ });
546
+ }
547
+
548
+ /** Clear a removed author's newest slots and keys; history already received stays. */
549
+ export async function forgetAuthor(store: Store, authorIdentityId: Uint8Array): Promise<void> {
550
+ await store.transaction((tx) => forgetAuthorIn(tx, authorIdentityId));
551
+ }
552
+
553
+ /** The same, inside a caller's transaction. */
554
+ export function forgetAuthorIn(tx: Tx, authorIdentityId: Uint8Array): void {
555
+ for (const [k] of tx.list("newest", toHex(authorIdentityId) + ":")) tx.delete("newest", k);
556
+ for (const [, c] of tx.list("certificates")) {
557
+ if (c.identityId !== toHex(authorIdentityId)) continue;
558
+ for (const [k] of tx.list("readerEpochs", c.streamId + ":")) tx.delete("readerEpochs", k);
559
+ }
560
+ }
package/src/index.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @plane31/blind-relay: Kavach v1 protocol (revision 6, wire version 5).
3
+ *
4
+ * This entry is what an application imports: the client, its types, the errors and the few
5
+ * helpers an app needs around it. Adapter and host authors import `@plane31/blind-relay/adapters`;
6
+ * test harnesses import `@plane31/blind-relay/testing`; the optional libsodium backend is
7
+ * `@plane31/blind-relay/sodium`. Everything else is internal and reachable only by relative import.
8
+ */
9
+ export const PROTOCOL_NAME = "Kavach";
10
+ export const PROTOCOL_REVISION = 8;
11
+
12
+ // The client ---------------------------------------------------------------------------------------
13
+ export {
14
+ open,
15
+ type CircleSummary,
16
+ type Client,
17
+ type ClientEvent,
18
+ type ClientState,
19
+ type ClientStatus,
20
+ type EnqueueOptions,
21
+ type EnqueueResult,
22
+ type HistoryEntry,
23
+ type HistoryPage,
24
+ type HistoryQuery,
25
+ type Inspection,
26
+ type Newest,
27
+ type OpenOptions,
28
+ type PauseReason,
29
+ type ReceiveResult,
30
+ type SyncError,
31
+ type SyncOptions,
32
+ type SyncReport
33
+ } from "./client.js";
34
+
35
+ // Errors -------------------------------------------------------------------------------------------
36
+ export { KavachError, isKavachError, type ErrorCode } from "./errors.js";
37
+
38
+ // Records -----------------------------------------------------------------------------------------
39
+ export type { RecordKind } from "./codec.js";
40
+
41
+ // Configuration ------------------------------------------------------------------------------------
42
+ export { validatePolicy, type KindDeclaration, type RetentionPolicy } from "./policy.js";
43
+ export type { ResetTrustConfig } from "./reset.js";
44
+ export { setCryptoBackend, type CryptoBackend } from "./crypto.js";
45
+
46
+ // Recovery -----------------------------------------------------------------------------------------
47
+ export {
48
+ decodeRecoveryCode,
49
+ encodeRecoveryCode,
50
+ type BackupState,
51
+ type ProviderLoadResult,
52
+ type ProviderSaveResult,
53
+ type ProviderStatus,
54
+ type ProviderStatusResult,
55
+ type RecoveryProvider
56
+ } from "./vault.js";
57
+
58
+ // Identifiers --------------------------------------------------------------------------------------
59
+ export { equalBytes, fromHex, toHex } from "./bytes.js";