@plane31/blind-relay 0.1.1 → 0.1.2

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/store.ts ADDED
@@ -0,0 +1,557 @@
1
+ /**
2
+ * Durable state (protocol section 11): the store interface the profile implements, the secret
3
+ * store, the logical tables, and an in-memory implementation with the same transaction semantics
4
+ * for Node tests.
5
+ *
6
+ * Rules the interface carries: one writer across every execution context; a transaction is
7
+ * atomic; the callback finishes synchronously, so the lock is never held across a suspension
8
+ * of the caller; the callback may initiate a transport send before commit through `tx.handoff`,
9
+ * and must not await it inside; a writer that cannot acquire the lock gets `LOCKED`.
10
+ */
11
+ import type { RecordKind } from "./codec.js";
12
+ import { KavachError } from "./errors.js";
13
+
14
+ export interface SecretStore {
15
+ get(name: SecretName): Promise<Uint8Array | undefined>;
16
+ set(name: SecretName, value: Uint8Array): Promise<void>;
17
+ delete(name: SecretName): Promise<void>;
18
+ }
19
+
20
+ export type SecretName = "accountSeed" | "deviceSeed" | "recoveryKey" | "storageKey" | "instanceId" | "pendingReset";
21
+
22
+ // Logical tables. Rows are JSON-safe: bytes as hex strings, u64 as decimal strings.
23
+
24
+ export type StoreState = "INITIALIZING" | "ACTIVE" | "ACTIVATION_PENDING" | "RETIRED";
25
+
26
+ export interface MetaRow {
27
+ schema: 1;
28
+ /** Set once the history index holds every history row; a store from before the index is backfilled on open. */
29
+ historyIndex?: 1;
30
+ /** Set once the newest slots are built from history; a store from before the slot table is rebuilt on open. */
31
+ newest?: 1;
32
+ instanceId: string;
33
+ accountName: string;
34
+ identityId: string;
35
+ certificateHash: string;
36
+ state: StoreState;
37
+ }
38
+
39
+ export interface SelfRow {
40
+ generation: string;
41
+ deviceId: string;
42
+ certificate: string;
43
+ certificateHash: string;
44
+ streamId: string;
45
+ }
46
+
47
+ export interface PinRow {
48
+ accountName: string;
49
+ accountKey: string;
50
+ identityId: string;
51
+ generation: string;
52
+ certificateHash: string;
53
+ certificate: string;
54
+ deviceId: string;
55
+ signingKey: string;
56
+ boxKey: string;
57
+ streamId: string;
58
+ deviceIds: string[];
59
+ }
60
+
61
+ export interface CertificateRow {
62
+ identityId: string;
63
+ certificateHash: string;
64
+ signingKey: string;
65
+ boxKey: string;
66
+ streamId: string;
67
+ generation: string;
68
+ wire: string;
69
+ current: boolean;
70
+ }
71
+
72
+ export interface QuarantineRow {
73
+ accountName: string;
74
+ certificate: string;
75
+ reason: string;
76
+ at: string;
77
+ }
78
+
79
+ export interface CircleRow {
80
+ circleId: string;
81
+ ownerIdentityId: string;
82
+ revision: string;
83
+ bodyHash: string | undefined;
84
+ snapshot: string | undefined;
85
+ members: string[];
86
+ enabled: boolean;
87
+ status: "requested" | "member" | "left" | "quarantined" | "closed";
88
+ verifiedAt: string;
89
+ /** An owner's successor snapshot awaiting host confirmation (T8). */
90
+ pending?: { snapshot: string; revision: string; close?: boolean } | undefined;
91
+ }
92
+
93
+ export interface RevisionRow {
94
+ desiredRevision: string;
95
+ seenSnapshots: Record<string, string>;
96
+ seenCertificates: Record<string, string>;
97
+ paused: "rotation" | "metadata" | "limit" | "sync" | undefined;
98
+ }
99
+
100
+ export interface VaultRow {
101
+ revision: string;
102
+ hash: string;
103
+ wire: string;
104
+ /** Prepared but not yet confirmed by the host. */
105
+ pending: boolean;
106
+ /** The circle rows changed since this vault was derived from them; set in the same transaction. */
107
+ dirty: boolean;
108
+ }
109
+
110
+ export type EpochStatus = "prepared" | "committed" | "active" | "obsolete" | "retired";
111
+
112
+ export interface EpochRow {
113
+ epoch: number;
114
+ status: EpochStatus;
115
+ manifest: string;
116
+ manifestHash: string;
117
+ wraps: Record<string, string>;
118
+ epochKey: string;
119
+ capturedRevision: string;
120
+ audienceKey: string;
121
+ createdAt: string;
122
+ nextCounter: number;
123
+ lastSentAt: string | undefined;
124
+ }
125
+
126
+ export interface StreamRow {
127
+ highestCommittedEpoch: number;
128
+ activeEpoch: number | undefined;
129
+ pendingEpoch: number | undefined;
130
+ }
131
+
132
+ export type OutboxStatus = "queued" | "inflight" | "accepted" | "cancelled" | "unknown";
133
+
134
+ export interface OutboxRow {
135
+ recordId: string;
136
+ wire: string;
137
+ epoch: number;
138
+ counter: number;
139
+ kind: RecordKind;
140
+ sentAt: string;
141
+ desiredRevision: string;
142
+ status: OutboxStatus;
143
+ attempts: number;
144
+ reason: string | undefined;
145
+ }
146
+
147
+ export interface HistoryRow {
148
+ recordId: string;
149
+ authorIdentityId: string;
150
+ streamId: string;
151
+ generation: string;
152
+ epoch: number;
153
+ counter: number;
154
+ kind: RecordKind;
155
+ sentAt: string;
156
+ receivedAt: string;
157
+ plaintext: string;
158
+ superseded: boolean;
159
+ own: boolean;
160
+ /** Body hash of the wire, for duplicate and equivocation detection. */
161
+ bodyHash?: string;
162
+ /** The envelope's event time, when the sender gave one. */
163
+ eventTime?: string;
164
+ }
165
+
166
+ /**
167
+ * The newest record per author and kind (for kinds declared `newest`), keyed `<author>:<kind>`.
168
+ * The order fields decide replacement; the payload is dropped at expiry while the row keeps its
169
+ * order, so an older record replayed later cannot become current.
170
+ */
171
+ export interface NewestRow {
172
+ recordId: string;
173
+ bodyHash: string;
174
+ generation: string;
175
+ epoch: number;
176
+ counter: number;
177
+ sentAt: string;
178
+ receivedAt: string;
179
+ /** The event time the slot was ordered by, never later than `sentAt`. */
180
+ eventTime: string;
181
+ /** Hex, possibly empty; `null` once expired. */
182
+ payload: string | null;
183
+ }
184
+
185
+ export interface ReaderEpochRow {
186
+ streamId: string;
187
+ epoch: number;
188
+ epochKey: string;
189
+ manifestHash: string;
190
+ authorCertificateHash: string;
191
+ superseded: boolean;
192
+ }
193
+
194
+ export interface SubscriptionRow {
195
+ streamId: string;
196
+ authorIdentityId: string;
197
+ /** Local scan position: the last sequence examined. */
198
+ scanPos: string;
199
+ /** The eligible acknowledged prefix. */
200
+ ackPrefix: string;
201
+ /** The last cursor the relay confirmed. */
202
+ committedCursor: string;
203
+ /** A send in flight for this stream. */
204
+ ackInFlight: boolean;
205
+ }
206
+
207
+ export interface UnresolvedRow {
208
+ streamId: string;
209
+ deliverySeq: string;
210
+ reason: "KEY_PENDING";
211
+ epoch: number;
212
+ recordedAt: string;
213
+ }
214
+
215
+ export interface ActivationRow {
216
+ certificate: string;
217
+ vault: string;
218
+ vaultRevision: string;
219
+ expectedCertificateHash: string;
220
+ expectedVaultRevision: string;
221
+ expectedVaultHash: string;
222
+ }
223
+
224
+ export interface ResetListRow {
225
+ wire: string;
226
+ version: string;
227
+ hash: string;
228
+ checkedAt: string;
229
+ }
230
+
231
+ export interface ResetReceiptRow {
232
+ accountName: string;
233
+ oldIdentityId: string;
234
+ newIdentityId: string;
235
+ receiptHash: string;
236
+ }
237
+
238
+ export interface ClockRow {
239
+ highWater: string;
240
+ }
241
+
242
+ /** A last-resort reset prepared and persisted before it is sent, so a lost response can be resumed. */
243
+ export interface PendingResetRow {
244
+ resetId: string;
245
+ oldIdentityId: string;
246
+ certificate: string;
247
+ vault: string;
248
+ vaultHash: string;
249
+ }
250
+
251
+ /**
252
+ * One entry of the history index. Two keys point at every history row: `t:<kind>:<tuple>` for the
253
+ * timeline and `a:<author>:<kind>:<tuple>` for one author, where the tuple is the zero-padded
254
+ * `sentAt`, epoch and counter followed by the record id, so keys sort by time and never collide.
255
+ */
256
+ export interface HistoryIndexRow {
257
+ recordId: string;
258
+ }
259
+
260
+ /** A caller's idempotency key and the record it produced, kept for the delivery window. */
261
+ export interface EnqueueKeyRow {
262
+ recordId: string;
263
+ epoch: number;
264
+ counter: number;
265
+ sentAt: string;
266
+ }
267
+
268
+ export interface Tables {
269
+ meta: MetaRow;
270
+ self: SelfRow;
271
+ pins: PinRow;
272
+ certificates: CertificateRow;
273
+ quarantine: QuarantineRow;
274
+ circles: CircleRow;
275
+ revision: RevisionRow;
276
+ vault: VaultRow;
277
+ epochs: EpochRow;
278
+ stream: StreamRow;
279
+ outbox: OutboxRow;
280
+ history: HistoryRow;
281
+ historyIndex: HistoryIndexRow;
282
+ enqueueKeys: EnqueueKeyRow;
283
+ newest: NewestRow;
284
+ readerEpochs: ReaderEpochRow;
285
+ subscriptions: SubscriptionRow;
286
+ unresolved: UnresolvedRow;
287
+ activation: ActivationRow;
288
+ resetList: ResetListRow;
289
+ resetReceipts: ResetReceiptRow;
290
+ retiredIdentities: { identityId: string };
291
+ clock: ClockRow;
292
+ pendingReset: PendingResetRow;
293
+ }
294
+
295
+ export type TableName = keyof Tables;
296
+
297
+ /** Bounds for `Tx.range`: every given bound applies; an absent one is open. */
298
+ export interface KeyRange {
299
+ readonly prefix?: string;
300
+ /** Inclusive lower bound. */
301
+ readonly gte?: string;
302
+ /** Exclusive lower bound. */
303
+ readonly gt?: string;
304
+ /** Exclusive upper bound. */
305
+ readonly lt?: string;
306
+ /** Inclusive upper bound. */
307
+ readonly lte?: string;
308
+ /** Descending key order when true. */
309
+ readonly reverse?: boolean;
310
+ readonly limit?: number;
311
+ }
312
+
313
+ /** Whether a key satisfies the bounds of a range (not its limit or order); shared by the stores. */
314
+ export function inKeyRange(key: string, q: KeyRange): boolean {
315
+ if (q.prefix !== undefined && !key.startsWith(q.prefix)) return false;
316
+ if (q.gte !== undefined && key < q.gte) return false;
317
+ if (q.gt !== undefined && key <= q.gt) return false;
318
+ if (q.lt !== undefined && key >= q.lt) return false;
319
+ if (q.lte !== undefined && key > q.lte) return false;
320
+ return true;
321
+ }
322
+
323
+ export interface Tx {
324
+ get<T extends TableName>(table: T, key: string): Tables[T] | undefined;
325
+ put<T extends TableName>(table: T, key: string, row: Tables[T]): void;
326
+ delete<T extends TableName>(table: T, key: string): void;
327
+ list<T extends TableName>(table: T, prefix?: string): readonly [string, Tables[T]][];
328
+ /**
329
+ * A bounded slice of a table in key order: the rows under `prefix` whose keys fall inside the
330
+ * bounds, ascending or descending, at most `limit` of them. The store serves it without reading
331
+ * the rest of the table.
332
+ */
333
+ range<T extends TableName>(table: T, q: KeyRange): readonly [string, Tables[T]][];
334
+ count(table: TableName): number;
335
+ /**
336
+ * Initiate a network operation inside this transaction (T6h). The call starts now, before
337
+ * commit; the returned promise is awaited by the caller after the transaction returns.
338
+ */
339
+ handoff<R>(start: () => Promise<R>): Promise<R>;
340
+ }
341
+
342
+ export interface Store {
343
+ /**
344
+ * BEGIN IMMEDIATE semantics: exclusive, atomic, serialised across every context. The callback
345
+ * runs to completion synchronously between begin and commit; a callback that returns a promise
346
+ * is refused with `STORAGE_FAILURE` and nothing is written. The one asynchronous thing a
347
+ * callback may do is start a network operation through `tx.handoff`.
348
+ */
349
+ transaction<R>(fn: (tx: Tx) => R): Promise<R>;
350
+ /** A consistent read without taking the writer lock. */
351
+ read<R>(fn: (tx: Omit<Tx, "put" | "delete" | "handoff">) => R): Promise<R>;
352
+ }
353
+
354
+ // In-memory implementation -----------------------------------------------------------------------
355
+
356
+ /**
357
+ * Run a transaction callback and refuse one that returns a promise. Shared by every `Store`
358
+ * implementation so the refusal is the same error everywhere.
359
+ */
360
+ export function invokeSync<R>(fn: (tx: Tx) => R, tx: Tx): R {
361
+ const result = fn(tx);
362
+ if (isThenable(result)) throw new KavachError("STORAGE_FAILURE", "store", "transaction callbacks are synchronous");
363
+ return result;
364
+ }
365
+
366
+ function isThenable(value: unknown): boolean {
367
+ return (typeof value === "object" || typeof value === "function") && value !== null && typeof (value as { then?: unknown }).then === "function";
368
+ }
369
+
370
+ type TableMap = Map<TableName, Map<string, unknown>>;
371
+
372
+ function cloneRow<T>(row: T): T {
373
+ return JSON.parse(JSON.stringify(row)) as T;
374
+ }
375
+
376
+ class MemoryTx implements Tx {
377
+ readonly journal: Map<TableName, Map<string, unknown | null>> = new Map();
378
+ readonly handoffs: Promise<unknown>[] = [];
379
+
380
+ constructor(
381
+ private readonly base: TableMap,
382
+ private readonly readOnly: boolean
383
+ ) {}
384
+
385
+ private tableJournal(table: TableName): Map<string, unknown | null> {
386
+ let j = this.journal.get(table);
387
+ if (!j) {
388
+ j = new Map();
389
+ this.journal.set(table, j);
390
+ }
391
+ return j;
392
+ }
393
+
394
+ get<T extends TableName>(table: T, key: string): Tables[T] | undefined {
395
+ const j = this.journal.get(table);
396
+ if (j && j.has(key)) {
397
+ const v = j.get(key);
398
+ return v === null ? undefined : cloneRow(v as Tables[T]);
399
+ }
400
+ const v = this.base.get(table)?.get(key);
401
+ return v === undefined ? undefined : cloneRow(v as Tables[T]);
402
+ }
403
+
404
+ put<T extends TableName>(table: T, key: string, row: Tables[T]): void {
405
+ if (this.readOnly) throw new KavachError("STORAGE_FAILURE", "store", "write in a read transaction");
406
+ this.tableJournal(table).set(key, cloneRow(row));
407
+ }
408
+
409
+ delete<T extends TableName>(table: T, key: string): void {
410
+ if (this.readOnly) throw new KavachError("STORAGE_FAILURE", "store", "write in a read transaction");
411
+ this.tableJournal(table).set(key, null);
412
+ }
413
+
414
+ list<T extends TableName>(table: T, prefix = ""): readonly [string, Tables[T]][] {
415
+ const merged = new Map<string, unknown>(this.base.get(table) ?? []);
416
+ for (const [k, v] of this.journal.get(table) ?? []) {
417
+ if (v === null) merged.delete(k);
418
+ else merged.set(k, v);
419
+ }
420
+ return [...merged.entries()]
421
+ .filter(([k]) => k.startsWith(prefix))
422
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
423
+ .map(([k, v]) => [k, cloneRow(v as Tables[T])]);
424
+ }
425
+
426
+ range<T extends TableName>(table: T, q: KeyRange): readonly [string, Tables[T]][] {
427
+ const rows = this.list(table, q.prefix).filter(([k]) => inKeyRange(k, q));
428
+ if (q.reverse) rows.reverse();
429
+ return q.limit === undefined ? rows : rows.slice(0, Math.max(0, q.limit));
430
+ }
431
+
432
+ count(table: TableName): number {
433
+ return this.list(table).length;
434
+ }
435
+
436
+ handoff<R>(start: () => Promise<R>): Promise<R> {
437
+ if (this.readOnly) throw new KavachError("STORAGE_FAILURE", "store", "handoff in a read transaction");
438
+ const p = start();
439
+ this.handoffs.push(p.catch(() => undefined));
440
+ return p;
441
+ }
442
+
443
+ commitInto(base: TableMap): void {
444
+ for (const [table, j] of this.journal) {
445
+ let t = base.get(table);
446
+ if (!t) {
447
+ t = new Map();
448
+ base.set(table, t);
449
+ }
450
+ for (const [k, v] of j) {
451
+ if (v === null) t.delete(k);
452
+ else t.set(k, v);
453
+ }
454
+ }
455
+ }
456
+ }
457
+
458
+ /** Crash injection for tests: throw at a chosen point. */
459
+ export interface MemoryStoreOptions {
460
+ /** Called just before a transaction commits; throwing simulates a crash before durability. */
461
+ beforeCommit?: (count: number) => void;
462
+ }
463
+
464
+ interface Shared {
465
+ tables: TableMap;
466
+ chain: Promise<void>;
467
+ held: boolean;
468
+ commits: number;
469
+ }
470
+
471
+ export class MemoryStore implements Store {
472
+ private readonly shared: Shared;
473
+ /** Every handoff started inside a committed transaction, for tests that wait for the network. */
474
+ readonly handoffs: Promise<unknown>[] = [];
475
+
476
+ constructor(
477
+ private readonly options: MemoryStoreOptions = {},
478
+ shared?: Shared
479
+ ) {
480
+ this.shared = shared ?? { tables: new Map(), chain: Promise.resolve(), held: false, commits: 0 };
481
+ }
482
+
483
+ async transaction<R>(fn: (tx: Tx) => R): Promise<R> {
484
+ const release = await this.acquire();
485
+ try {
486
+ const tx = new MemoryTx(this.shared.tables, false);
487
+ const result = invokeSync(fn, tx);
488
+ this.options.beforeCommit?.(this.shared.commits + 1);
489
+ tx.commitInto(this.shared.tables);
490
+ this.shared.commits++;
491
+ this.handoffs.push(...tx.handoffs);
492
+ return result;
493
+ } finally {
494
+ release();
495
+ }
496
+ }
497
+
498
+ async read<R>(fn: (tx: Omit<Tx, "put" | "delete" | "handoff">) => R): Promise<R> {
499
+ return fn(new MemoryTx(this.shared.tables, true));
500
+ }
501
+
502
+ /** One writer at a time across every handle on the same tables, like SQLite's file lock. */
503
+ private acquire(): Promise<() => void> {
504
+ const previous = this.shared.chain;
505
+ let release: () => void = () => undefined;
506
+ this.shared.chain = new Promise<void>((resolve) => {
507
+ release = () => {
508
+ this.shared.held = false;
509
+ resolve();
510
+ };
511
+ });
512
+ return previous.then(() => {
513
+ this.shared.held = true;
514
+ return release;
515
+ });
516
+ }
517
+
518
+ /** A second handle on the same tables and lock, as another execution context would have. */
519
+ fork(): MemoryStore {
520
+ return new MemoryStore(this.options, this.shared);
521
+ }
522
+
523
+ get isHeld(): boolean {
524
+ return this.shared.held;
525
+ }
526
+
527
+ get commitCount(): number {
528
+ return this.shared.commits;
529
+ }
530
+
531
+ snapshot(): Record<string, Record<string, unknown>> {
532
+ const out: Record<string, Record<string, unknown>> = {};
533
+ for (const [t, m] of this.shared.tables) out[t] = Object.fromEntries([...m.entries()].map(([k, v]) => [k, cloneRow(v)]));
534
+ return out;
535
+ }
536
+ }
537
+
538
+ export class MemorySecretStore implements SecretStore {
539
+ private readonly items = new Map<SecretName, Uint8Array>();
540
+ /** Test hook: throw on the next write to simulate a crash between the two stores. */
541
+ failNextWrite = false;
542
+
543
+ async get(name: SecretName): Promise<Uint8Array | undefined> {
544
+ const v = this.items.get(name);
545
+ return v ? Uint8Array.from(v) : undefined;
546
+ }
547
+ async set(name: SecretName, value: Uint8Array): Promise<void> {
548
+ if (this.failNextWrite) {
549
+ this.failNextWrite = false;
550
+ throw new KavachError("STORAGE_FAILURE", "secretStore", "simulated crash");
551
+ }
552
+ this.items.set(name, Uint8Array.from(value));
553
+ }
554
+ async delete(name: SecretName): Promise<void> {
555
+ this.items.delete(name);
556
+ }
557
+ }
@@ -0,0 +1,29 @@
1
+ import type { RetentionPolicy } from "../policy.js";
2
+
3
+ /**
4
+ * A retention policy for tests. Three kinds: 0 is kept a day in history with a newest slot
5
+ * (a position); 1 is kept thirty days in history with no slot, every one news (an event); 2 is a
6
+ * one-day slot only, latest wins while queued (a state).
7
+ */
8
+ export const testPolicy: RetentionPolicy = {
9
+ version: 1,
10
+ deliveryWindowMs: 86_400_000n,
11
+ appendRetryWindowMs: 604_800_000n,
12
+ hygieneRotationMs: 2_592_000_000n,
13
+ maxOutboxRecords: 2000,
14
+ maxOutboxBytes: 4 << 20,
15
+ kinds: [
16
+ { kind: 0, retentionMs: 86_400_000n, queue: "keepAll", history: true, newest: true, headerClass: 0 },
17
+ { kind: 1, retentionMs: 2_592_000_000n, queue: "keepAll", history: true, newest: false, headerClass: 1 },
18
+ { kind: 2, retentionMs: 86_400_000n, queue: "latestWins", history: false, newest: true, headerClass: 2 }
19
+ ]
20
+ };
21
+
22
+ /** A controllable clock. */
23
+ export class TestClock {
24
+ t = 1_700_000_000_000n;
25
+ now = (): bigint => this.t;
26
+ advance(ms: bigint): void {
27
+ this.t += ms;
28
+ }
29
+ }