@plane31/blind-relay 0.0.0-stage → 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.
Files changed (169) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +26 -2
  3. package/dist/adapters/index.d.ts +18 -0
  4. package/dist/adapters/index.d.ts.map +1 -0
  5. package/dist/adapters/index.js +22 -0
  6. package/dist/adapters/index.js.map +1 -0
  7. package/dist/audience.d.ts +77 -0
  8. package/dist/audience.d.ts.map +1 -0
  9. package/dist/audience.js +76 -0
  10. package/dist/audience.js.map +1 -0
  11. package/dist/bytes.d.ts +54 -0
  12. package/dist/bytes.d.ts.map +1 -0
  13. package/dist/bytes.js +200 -0
  14. package/dist/bytes.js.map +1 -0
  15. package/dist/circle.d.ts +59 -0
  16. package/dist/circle.d.ts.map +1 -0
  17. package/dist/circle.js +105 -0
  18. package/dist/circle.js.map +1 -0
  19. package/dist/client.d.ts +234 -0
  20. package/dist/client.d.ts.map +1 -0
  21. package/dist/client.js +1365 -0
  22. package/dist/client.js.map +1 -0
  23. package/dist/codec.d.ts +189 -0
  24. package/dist/codec.d.ts.map +1 -0
  25. package/dist/codec.js +512 -0
  26. package/dist/codec.js.map +1 -0
  27. package/dist/crypto/backend.d.ts +39 -0
  28. package/dist/crypto/backend.d.ts.map +1 -0
  29. package/dist/crypto/backend.js +8 -0
  30. package/dist/crypto/backend.js.map +1 -0
  31. package/dist/crypto/noble.d.ts +3 -0
  32. package/dist/crypto/noble.d.ts.map +1 -0
  33. package/dist/crypto/noble.js +107 -0
  34. package/dist/crypto/noble.js.map +1 -0
  35. package/dist/crypto/sodium.d.ts +9 -0
  36. package/dist/crypto/sodium.d.ts.map +1 -0
  37. package/dist/crypto/sodium.js +51 -0
  38. package/dist/crypto/sodium.js.map +1 -0
  39. package/dist/crypto.d.ts +66 -0
  40. package/dist/crypto.d.ts.map +1 -0
  41. package/dist/crypto.js +225 -0
  42. package/dist/crypto.js.map +1 -0
  43. package/dist/epoch.d.ts +93 -0
  44. package/dist/epoch.d.ts.map +1 -0
  45. package/dist/epoch.js +121 -0
  46. package/dist/epoch.js.map +1 -0
  47. package/dist/errors.d.ts +17 -0
  48. package/dist/errors.d.ts.map +1 -0
  49. package/dist/errors.js +30 -0
  50. package/dist/errors.js.map +1 -0
  51. package/dist/historyIndex.d.ts +15 -0
  52. package/dist/historyIndex.d.ts.map +1 -0
  53. package/dist/historyIndex.js +57 -0
  54. package/dist/historyIndex.js.map +1 -0
  55. package/dist/identity.d.ts +62 -0
  56. package/dist/identity.d.ts.map +1 -0
  57. package/dist/identity.js +99 -0
  58. package/dist/identity.js.map +1 -0
  59. package/dist/inbox.d.ts +153 -0
  60. package/dist/inbox.d.ts.map +1 -0
  61. package/dist/inbox.js +472 -0
  62. package/dist/inbox.js.map +1 -0
  63. package/dist/index.d.ts +19 -0
  64. package/dist/index.d.ts.map +1 -0
  65. package/dist/index.js +22 -0
  66. package/dist/index.js.map +1 -0
  67. package/dist/outbox.d.ts +96 -0
  68. package/dist/outbox.d.ts.map +1 -0
  69. package/dist/outbox.js +339 -0
  70. package/dist/outbox.js.map +1 -0
  71. package/dist/policy.d.ts +33 -0
  72. package/dist/policy.d.ts.map +1 -0
  73. package/dist/policy.js +71 -0
  74. package/dist/policy.js.map +1 -0
  75. package/dist/record.d.ts +49 -0
  76. package/dist/record.d.ts.map +1 -0
  77. package/dist/record.js +46 -0
  78. package/dist/record.js.map +1 -0
  79. package/dist/reset.d.ts +74 -0
  80. package/dist/reset.d.ts.map +1 -0
  81. package/dist/reset.js +127 -0
  82. package/dist/reset.js.map +1 -0
  83. package/dist/rows.d.ts +16 -0
  84. package/dist/rows.d.ts.map +1 -0
  85. package/dist/rows.js +103 -0
  86. package/dist/rows.js.map +1 -0
  87. package/dist/slots.d.ts +22 -0
  88. package/dist/slots.d.ts.map +1 -0
  89. package/dist/slots.js +38 -0
  90. package/dist/slots.js.map +1 -0
  91. package/dist/store.d.ts +357 -0
  92. package/dist/store.d.ts.map +1 -0
  93. package/dist/store.js +192 -0
  94. package/dist/store.js.map +1 -0
  95. package/dist/testing/clock.d.ts +14 -0
  96. package/dist/testing/clock.d.ts.map +1 -0
  97. package/dist/testing/clock.js +27 -0
  98. package/dist/testing/clock.js.map +1 -0
  99. package/dist/testing/fakeHost.d.ts +118 -0
  100. package/dist/testing/fakeHost.d.ts.map +1 -0
  101. package/dist/testing/fakeHost.js +462 -0
  102. package/dist/testing/fakeHost.js.map +1 -0
  103. package/dist/testing/faults.d.ts +97 -0
  104. package/dist/testing/faults.d.ts.map +1 -0
  105. package/dist/testing/faults.js +264 -0
  106. package/dist/testing/faults.js.map +1 -0
  107. package/dist/testing/fixtures.d.ts +15 -0
  108. package/dist/testing/fixtures.d.ts.map +1 -0
  109. package/dist/testing/fixtures.js +22 -0
  110. package/dist/testing/fixtures.js.map +1 -0
  111. package/dist/testing/index.d.ts +7 -0
  112. package/dist/testing/index.d.ts.map +1 -0
  113. package/dist/testing/index.js +7 -0
  114. package/dist/testing/index.js.map +1 -0
  115. package/dist/testing/providers.d.ts +16 -0
  116. package/dist/testing/providers.d.ts.map +1 -0
  117. package/dist/testing/providers.js +36 -0
  118. package/dist/testing/providers.js.map +1 -0
  119. package/dist/testing/sweep.d.ts +46 -0
  120. package/dist/testing/sweep.d.ts.map +1 -0
  121. package/dist/testing/sweep.js +366 -0
  122. package/dist/testing/sweep.js.map +1 -0
  123. package/dist/transactions.d.ts +75 -0
  124. package/dist/transactions.d.ts.map +1 -0
  125. package/dist/transactions.js +212 -0
  126. package/dist/transactions.js.map +1 -0
  127. package/dist/transport.d.ts +125 -0
  128. package/dist/transport.d.ts.map +1 -0
  129. package/dist/transport.js +6 -0
  130. package/dist/transport.js.map +1 -0
  131. package/dist/vault.d.ts +59 -0
  132. package/dist/vault.d.ts.map +1 -0
  133. package/dist/vault.js +69 -0
  134. package/dist/vault.js.map +1 -0
  135. package/package.json +67 -3
  136. package/src/adapters/index.ts +102 -0
  137. package/src/audience.ts +123 -0
  138. package/src/bytes.ts +209 -0
  139. package/src/circle.ts +127 -0
  140. package/src/client.ts +1442 -0
  141. package/src/codec.ts +642 -0
  142. package/src/crypto/backend.ts +48 -0
  143. package/src/crypto/noble.ts +104 -0
  144. package/src/crypto/node-module.d.ts +11 -0
  145. package/src/crypto/sodium.ts +53 -0
  146. package/src/crypto.ts +266 -0
  147. package/src/epoch.ts +201 -0
  148. package/src/errors.ts +78 -0
  149. package/src/historyIndex.ts +61 -0
  150. package/src/identity.ts +158 -0
  151. package/src/inbox.ts +560 -0
  152. package/src/index.ts +59 -0
  153. package/src/outbox.ts +385 -0
  154. package/src/policy.ts +88 -0
  155. package/src/record.ts +106 -0
  156. package/src/reset.ts +176 -0
  157. package/src/rows.ts +116 -0
  158. package/src/slots.ts +55 -0
  159. package/src/store.ts +557 -0
  160. package/src/testing/clock.ts +29 -0
  161. package/src/testing/fakeHost.ts +482 -0
  162. package/src/testing/faults.ts +321 -0
  163. package/src/testing/fixtures.ts +35 -0
  164. package/src/testing/index.ts +6 -0
  165. package/src/testing/providers.ts +39 -0
  166. package/src/testing/sweep.ts +407 -0
  167. package/src/transactions.ts +231 -0
  168. package/src/transport.ts +106 -0
  169. package/src/vault.ts +126 -0
package/src/client.ts ADDED
@@ -0,0 +1,1442 @@
1
+ /**
2
+ * The client (protocol sections 13 and 14): one asynchronous object over the store, the
3
+ * transport, the recovery provider and the reset trust. States are emitted only after the
4
+ * corresponding durable transition.
5
+ */
6
+ import { concat, equalBytes, fromHex, toHex } from "./bytes.js";
7
+ import { resolveAudience, type ResolvedAudience } from "./audience.js";
8
+ import { acceptSnapshot, checkMemberResolution, createCircleSnapshot, newCircleId, pinCircle, successorSnapshot, type CircleState } from "./circle.js";
9
+ import { DocumentType, expectType, parseVaultHeader, verifyDocument, type AccountDevice, type RecordKind } from "./codec.js";
10
+ import { randomBytes, ready, type BoxKeypair } from "./crypto.js";
11
+ import { prepareEpoch, resolvePublish, type PreparedEpoch } from "./epoch.js";
12
+ import { KavachError, isKavachError, type ErrorCode } from "./errors.js";
13
+ import { acceptCertificate, certify, deviceFromSeed, newAccountSeed, newDevice, openAccount, pinFromCertificate, type AccountPin, type DeviceKeys, type OwnAccount } from "./identity.js";
14
+ import { ensureHistoryIndex } from "./historyIndex.js";
15
+ import { ensureSlots, forgetAuthor, forgetAuthorIn, history, historyPage, newest, newestSlot, purgeHistory, receive as receiveRecord, syncStream, type HistoryEntry, type HistoryPage, type HistoryQuery, type Newest, type ReadBudget, type ReceiveResult } from "./inbox.js";
16
+ import { drain, enqueue, outboxCount, outboxCounts, purgeOutbox, type EnqueueOptions, type OutboxCounts } from "./outbox.js";
17
+ export type { EnqueueOptions } from "./outbox.js";
18
+
19
+ /** The durable record a hand-over produced; `duplicate` when an idempotency key matched an earlier one. */
20
+ export interface EnqueueResult {
21
+ readonly recordId: Uint8Array;
22
+ readonly duplicate: boolean;
23
+ }
24
+ import { kindOf, validatePolicy, type RetentionPolicy } from "./policy.js";
25
+ import { ResetTrust, type ResetTrustConfig } from "./reset.js";
26
+ import { big, circleToRow, pinToRow, rowToCircle, rowToPin, rowToPrepared, unbig } from "./rows.js";
27
+ import type { CircleRow, PendingResetRow, SecretStore, Store, Tx, VaultRow } from "./store.js";
28
+ import { META, REVISION, STREAM, VAULT, openStore, t1Activate, t1Header, t1Secrets, t2PrepareActivation, t3ApplyChange, t3Circle, t4PersistPrepared, t5Committed, t8OwnerSnapshot, t9MarkVault, t9Vault } from "./transactions.js";
29
+ import type { Relay, Transport } from "./transport.js";
30
+ import { buildVault, encodeRecoveryCode, newRecoveryKey, openVault, recoverAccount, removeEntry, upsertEntry, type ProviderStatusResult, type RecoveryProvider, type VaultEntry } from "./vault.js";
31
+
32
+ /** The types of the client's queries and of `receive`, defined with the inbox. */
33
+ export type { HistoryEntry, HistoryPage, HistoryQuery, Newest, ReceiveResult } from "./inbox.js";
34
+
35
+ /** accountSeed, deviceSeed, recoveryKey, instanceId and deviceId of a pending reset, in the secret store. */
36
+ const RESET_ROOTS_BYTES = 32 * 3 + 16 + 16;
37
+
38
+ /** The vault's entries as the circle rows say they should be. Entries of circles without a row are kept. */
39
+ function reconcileEntries(entries: readonly VaultEntry[], rows: readonly CircleRow[]): VaultEntry[] {
40
+ let out: VaultEntry[] = [...entries];
41
+ for (const r of rows) {
42
+ const circleId = fromHex(r.circleId);
43
+ if (r.status === "left" || r.status === "closed") out = removeEntry(out, circleId);
44
+ else if (r.status === "member" || r.status === "requested") out = upsertEntry(out, { circleId, ownerIdentityId: fromHex(r.ownerIdentityId), sharingEnabled: r.enabled });
45
+ }
46
+ return out;
47
+ }
48
+
49
+ function sameEntries(a: readonly VaultEntry[], b: readonly VaultEntry[]): boolean {
50
+ const key = (es: readonly VaultEntry[]) => es.map((e) => `${toHex(e.circleId)}:${toHex(e.ownerIdentityId)}:${e.sharingEnabled ? 1 : 0}`).sort().join(",");
51
+ return key(a) === key(b);
52
+ }
53
+
54
+ export type ClientStatus = "fresh" | "ready" | "locked" | "accountRecoveryRequired" | "activationPending" | "retired" | "paused" | "quarantined";
55
+ export type PauseReason = "sync" | "rotation" | "metadata" | "limit";
56
+
57
+ export interface ClientState {
58
+ readonly status: ClientStatus;
59
+ readonly paused: PauseReason | undefined;
60
+ readonly identityId: Uint8Array | undefined;
61
+ readonly certificateHash: Uint8Array | undefined;
62
+ readonly generation: bigint | undefined;
63
+ readonly outbox: number;
64
+ /** Authors whose current epoch has not yet reached this device. */
65
+ readonly waitingFor: readonly Uint8Array[];
66
+ readonly circles: readonly CircleSummary[];
67
+ }
68
+
69
+ /** Diagnostics: where the protocol stands on this device. Never a secret or a plaintext. */
70
+ export interface Inspection {
71
+ readonly identityId: Uint8Array | undefined;
72
+ readonly certificateHash: Uint8Array | undefined;
73
+ readonly generation: bigint | undefined;
74
+ readonly desiredRevision: bigint;
75
+ readonly paused: PauseReason | undefined;
76
+ readonly activeEpoch: number | undefined;
77
+ /** An epoch prepared or committed that is not active yet. */
78
+ readonly pendingEpoch: number | undefined;
79
+ readonly outbox: OutboxCounts;
80
+ readonly vault: { readonly revision: bigint; readonly pending: boolean; readonly dirty: boolean } | undefined;
81
+ }
82
+
83
+ export interface CircleSummary {
84
+ readonly circleId: Uint8Array;
85
+ readonly ownerIdentityId: Uint8Array;
86
+ readonly revision: bigint;
87
+ readonly members: readonly Uint8Array[];
88
+ readonly enabled: boolean;
89
+ readonly status: CircleState["status"];
90
+ readonly snapshotAgeMs: bigint;
91
+ readonly owned: boolean;
92
+ }
93
+
94
+ export type ClientEvent =
95
+ | { kind: "deviceChanged"; identityId: Uint8Array; generation: bigint }
96
+ /** After a rotation, or when a sent record's acceptance is in doubt: the app may resend its current state of each kind with a slot. */
97
+ | { kind: "refresh"; reason: string }
98
+ | { kind: "cancelled"; recordId: Uint8Array; reason: string }
99
+ | { kind: "queued"; recordId: Uint8Array; recordKind: RecordKind }
100
+ | { kind: "accepted"; recordId: Uint8Array }
101
+ /** A record was stored, received or the device's own; `tookSlot` when it became the author's newest of its kind. */
102
+ | { kind: "recordStored"; recordKind: RecordKind; authorIdentityId: Uint8Array; recordId: Uint8Array; tookSlot: boolean }
103
+ | { kind: "waiting"; identityId: Uint8Array }
104
+ | { kind: "joinRequests"; circleId: Uint8Array; accountNames: readonly string[] }
105
+ | { kind: "circleChanged"; circleId: Uint8Array; revision: bigint; status: CircleState["status"] }
106
+ | { kind: "accountKeyChanged"; accountName: string; quarantined: boolean }
107
+ | { kind: "securityError"; code: string; detail: string }
108
+ | { kind: "stateChanged"; state: ClientState };
109
+
110
+ export interface OpenOptions {
111
+ readonly accountName: string;
112
+ readonly retentionPolicy: unknown;
113
+ readonly recoveryProvider: RecoveryProvider;
114
+ readonly resetTrust: ResetTrustConfig;
115
+ readonly secretStore: SecretStore;
116
+ readonly store: Store;
117
+ readonly transport: Transport;
118
+ readonly now?: () => bigint;
119
+ /** Called when a subscriber throws. Listeners are isolated from the protocol and from each other either way. */
120
+ readonly onListenerError?: (error: unknown, event: ClientEvent) => void;
121
+ }
122
+
123
+ export interface Client {
124
+ readonly accountName: string;
125
+ getState(): Promise<ClientState>;
126
+ subscribe(listener: (event: ClientEvent) => void): () => void;
127
+ initializeAccount(): Promise<{ identityId: Uint8Array }>;
128
+ recoveryStatus(): Promise<ProviderStatusResult>;
129
+ recoveryCode(): Promise<string>;
130
+ restoreAccount(recoveryCode?: string): Promise<{ identityId: Uint8Array; generation: bigint }>;
131
+ /** A durable outbound record of a declared kind; the payload is the product's bytes, never read by the SDK. */
132
+ enqueue(kind: RecordKind, payload: Uint8Array, options?: EnqueueOptions): Promise<EnqueueResult>;
133
+ /** One synchronisation; `options.catchUp` bounds how much of a backlog this call reads (`SyncReport.inbox.hasMore` says when to call again). */
134
+ sync(options?: SyncOptions): Promise<SyncReport>;
135
+ receive(wire: Uint8Array): Promise<ReceiveResult>;
136
+ /** The first page of `historyPage`, as a list. */
137
+ history(query?: HistoryQuery): Promise<HistoryEntry[]>;
138
+ /** One page of history in time order with a cursor for the next; served from the index. */
139
+ historyPage(query?: HistoryQuery): Promise<HistoryPage>;
140
+ /** The newest history entry for an author or kind, own records and superseded ones included. */
141
+ newestEntry(query?: Pick<HistoryQuery, "authorIdentityId" | "kind">): Promise<HistoryEntry | undefined>;
142
+ /** The newest record of a kind from an author's current device, self included; `undefined` when none or expired. */
143
+ newest(identityId: Uint8Array, kind: RecordKind): Promise<Newest | undefined>;
144
+ inspect(): Promise<Inspection>;
145
+ readonly circles: {
146
+ create(): Promise<{ circleId: Uint8Array }>;
147
+ requestJoin(circleId: Uint8Array, ownerIdentityId: Uint8Array, options?: { shareOnApproval?: boolean }): Promise<void>;
148
+ approve(circleId: Uint8Array, accountName: string): Promise<{ revision: bigint }>;
149
+ remove(circleId: Uint8Array, memberIdentityId: Uint8Array): Promise<{ revision: bigint }>;
150
+ leave(circleId: Uint8Array): Promise<void>;
151
+ setEnabled(circleId: Uint8Array, enabled: boolean): Promise<void>;
152
+ closeCircle(circleId: Uint8Array): Promise<void>;
153
+ list(): Promise<readonly CircleSummary[]>;
154
+ };
155
+ resetAccount(options: { confirmed: true; reauthProof?: Uint8Array }): Promise<{ identityId: Uint8Array }>;
156
+ close(): Promise<void>;
157
+ }
158
+
159
+ export interface SyncOptions {
160
+ /**
161
+ * The inbox budget of this sync, shared by every stream: at most this many records
162
+ * (default 500) and this many milliseconds of reading (default 5,000) before the sync stops
163
+ * and reports `inbox.hasMore`, so a long backlog never holds a wake-up open. Each stream reads
164
+ * at least one page.
165
+ */
166
+ readonly catchUp?: { readonly maxRecords?: number; readonly maxMs?: number };
167
+ }
168
+
169
+ export const DEFAULT_CATCH_UP = { maxRecords: 500, maxMs: 5_000 } as const;
170
+
171
+ export interface SyncReport {
172
+ metadata: { certificates: number; snapshots: number; rotations: number; quarantined: number; resets: number };
173
+ epoch: "unchanged" | "prepared" | "paused" | "pending";
174
+ outbox: { sent: number; accepted: number; cancelled: number; unknown: number; retry: number };
175
+ /** `hasMore`: a stream still has records on the relay that this sync's budget did not reach; sync again soon. */
176
+ inbox: { streams: number; stored: number; keyPending: number; invalid: number; acked: number; hasMore: boolean };
177
+ purged: number;
178
+ errors: SyncError[];
179
+ }
180
+
181
+ /** One failure a sync worked around: which step, the protocol code and stage, and whether the same step may succeed later unchanged. */
182
+ export interface SyncError {
183
+ readonly operation: string;
184
+ readonly code: ErrorCode | "ACTIVATION_PENDING" | "UNEXPECTED";
185
+ readonly stage: string;
186
+ readonly retryable: boolean;
187
+ /**
188
+ * On an `inbox` entry: the stream whose download or processing failed and its author, so the
189
+ * host can tell which sender is affected. Identifying metadata (protocol section 14): keep it
190
+ * local or pseudonymise it before logging.
191
+ */
192
+ readonly streamId?: Uint8Array;
193
+ readonly authorIdentityId?: Uint8Array;
194
+ }
195
+
196
+ function syncError(operation: string, e: unknown): SyncError {
197
+ return e instanceof KavachError ? { operation, code: e.code, stage: e.stage, retryable: e.retryable } : { operation, code: "UNEXPECTED", stage: operation, retryable: false };
198
+ }
199
+
200
+ const RESET_LIST_RECHECK_MS = 86_400_000n;
201
+
202
+ export async function open(options: OpenOptions): Promise<Client> {
203
+ await ready();
204
+ const policy = validatePolicy(options.retentionPolicy);
205
+ const impl = new ClientImpl(options, policy);
206
+ await impl.load();
207
+ return impl;
208
+ }
209
+
210
+ class ClientImpl implements Client {
211
+ readonly accountName: string;
212
+ private readonly store: Store;
213
+ private readonly secrets: SecretStore;
214
+ private readonly transport: Transport;
215
+ private readonly provider: RecoveryProvider;
216
+ private readonly now: () => bigint;
217
+ private readonly listeners = new Set<(e: ClientEvent) => void>();
218
+ private account: OwnAccount | undefined;
219
+ private device: DeviceKeys | undefined;
220
+ private selfPin: AccountPin | undefined;
221
+ private trust: ResetTrust;
222
+ private syncing: Promise<SyncReport> | undefined;
223
+ private backupChecked = false;
224
+ readonly circles: Client["circles"];
225
+
226
+ constructor(
227
+ private readonly options: OpenOptions,
228
+ private readonly policy: RetentionPolicy
229
+ ) {
230
+ this.accountName = options.accountName;
231
+ this.store = options.store;
232
+ this.secrets = options.secretStore;
233
+ this.transport = options.transport;
234
+ this.provider = options.recoveryProvider;
235
+ this.now = options.now ?? ((): bigint => BigInt(Date.now()));
236
+ this.trust = new ResetTrust(options.resetTrust);
237
+ this.circles = {
238
+ create: () => this.createCircle(),
239
+ requestJoin: (c, o, opts) => this.requestJoin(c, o, opts?.shareOnApproval ?? true),
240
+ approve: (c, n) => this.approve(c, n),
241
+ remove: (c, m) => this.changeMembership(c, { remove: m }),
242
+ leave: (c) => this.leave(c),
243
+ setEnabled: (c, e) => this.setEnabled(c, e),
244
+ closeCircle: (c) => this.closeCircle(c),
245
+ list: () => this.listCircles()
246
+ };
247
+ }
248
+
249
+ // Loading ---------------------------------------------------------------------------------------
250
+
251
+ async load(): Promise<void> {
252
+ await this.finishInterruptedReset();
253
+ const opened = await openStore(this.store, this.secrets, this.accountName);
254
+ if (opened.kind === "fresh") return;
255
+ await ensureHistoryIndex(this.store);
256
+ await ensureSlots(this.store, this.policy);
257
+ this.account = opened.account;
258
+ const deviceSeed = await this.secrets.get("deviceSeed");
259
+ const self = await this.store.read((tx) => tx.get("self", "self"));
260
+ if (deviceSeed && self) {
261
+ this.device = deviceFromSeed(deviceSeed, fromHex(self.deviceId));
262
+ const pinRow = await this.store.read((tx) => tx.get("pins", toHex(opened.account.identityId)));
263
+ if (pinRow) this.selfPin = rowToPin(pinRow);
264
+ }
265
+ const rows = await this.store.read((tx) => ({
266
+ list: tx.get("resetList", "list"),
267
+ clock: tx.get("clock", "clock"),
268
+ accepted: tx.list("resetReceipts").map(([k]) => k),
269
+ events: tx.list("resetReceipts").map(([k, r]) => ({ resetId: k, accountName: r.accountName, oldIdentityId: r.oldIdentityId, newIdentityId: r.newIdentityId })),
270
+ retired: tx.list("retiredIdentities").map(([k]) => k)
271
+ }));
272
+ this.trust = ResetTrust.fromRows(this.options.resetTrust, {
273
+ list: rows.list,
274
+ clock: rows.clock ?? { highWater: "0" },
275
+ accepted: rows.events.map((e) => e.resetId).length ? (await this.store.read((tx) => tx.list("resetReceipts").map(([, r]) => r.receiptHash))) : [],
276
+ events: rows.events,
277
+ retired: rows.retired
278
+ });
279
+ }
280
+
281
+ /** Each listener on its own: a throwing subscriber affects neither the other listeners nor the protocol. */
282
+ private emit(event: ClientEvent): void {
283
+ for (const l of this.listeners) {
284
+ try {
285
+ l(event);
286
+ } catch (e) {
287
+ try {
288
+ this.options.onListenerError?.(e, event);
289
+ } catch {
290
+ /* the diagnostic hook is the application's too */
291
+ }
292
+ }
293
+ }
294
+ }
295
+
296
+ subscribe(listener: (event: ClientEvent) => void): () => void {
297
+ this.listeners.add(listener);
298
+ return () => this.listeners.delete(listener);
299
+ }
300
+
301
+ private requireAccount(stage: string): { account: OwnAccount; device: DeviceKeys; pin: AccountPin } {
302
+ if (!this.account || !this.device || !this.selfPin) throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "no account on this device");
303
+ return { account: this.account, device: this.device, pin: this.selfPin };
304
+ }
305
+
306
+ private selfView(): { identityId: Uint8Array; certificateHash: Uint8Array; box: BoxKeypair } {
307
+ const { device, pin } = this.requireAccount("self");
308
+ return { identityId: pin.identityId, certificateHash: pin.certificateHash, box: device.box };
309
+ }
310
+
311
+ private outboxDeps() {
312
+ const { device, pin } = this.requireAccount("outbox");
313
+ return {
314
+ store: this.store,
315
+ relay: this.transport.relay,
316
+ policy: this.policy,
317
+ now: this.now,
318
+ author: () => ({ streamId: pin.streamId, signingSecretKey: device.signing.secretKey, generation: pin.generation, identityId: pin.identityId }),
319
+ events: {
320
+ queued: (recordId: Uint8Array, recordKind: RecordKind) => this.emit({ kind: "queued", recordId, recordKind }),
321
+ accepted: (recordId: Uint8Array) => this.emit({ kind: "accepted", recordId }),
322
+ cancelled: (recordId: Uint8Array, reason: string) => this.emit({ kind: "cancelled", recordId, reason }),
323
+ refresh: (reason: string) => this.emit({ kind: "refresh", reason }),
324
+ securityError: (code: string, detail: string) => this.emit({ kind: "securityError", code, detail })
325
+ }
326
+ };
327
+ }
328
+
329
+ private inboxDeps() {
330
+ return {
331
+ store: this.store,
332
+ relay: this.transport.relay,
333
+ policy: this.policy,
334
+ now: this.now,
335
+ self: () => this.selfView(),
336
+ events: {
337
+ received: (r: ReceiveResult) => {
338
+ if (r.result === "updated" || r.result === "history") this.emit({ kind: "recordStored", recordKind: r.kind, authorIdentityId: r.authorIdentityId, recordId: r.recordId, tookSlot: r.result === "updated" && (kindOf(this.policy, r.kind)?.newest ?? false) });
339
+ },
340
+ waiting: (identityId: Uint8Array) => this.emit({ kind: "waiting", identityId }),
341
+ securityError: (code: string, detail: string) => this.emit({ kind: "securityError", code, detail })
342
+ }
343
+ };
344
+ }
345
+
346
+ // State -----------------------------------------------------------------------------------------
347
+
348
+ async getState(): Promise<ClientState> {
349
+ const meta = await this.store.read((tx) => tx.get("meta", META));
350
+ const base = { identityId: this.selfPin?.identityId, certificateHash: this.selfPin?.certificateHash, generation: this.selfPin?.generation, outbox: 0, waitingFor: [] as Uint8Array[], circles: [] as CircleSummary[] };
351
+ if (!meta || !this.account) return { status: "fresh", paused: undefined, ...base };
352
+ const circles = await this.listCircles();
353
+ const outbox = await outboxCount(this.store);
354
+ const waitingFor = await this.waitingFor();
355
+ const revision = await this.store.read((tx) => tx.get("revision", REVISION));
356
+ const quarantined = await this.store.read((tx) => tx.count("quarantine") > 0);
357
+ let status: ClientStatus = "ready";
358
+ if (meta.state === "INITIALIZING") status = "accountRecoveryRequired";
359
+ else if (meta.state === "ACTIVATION_PENDING") status = "activationPending";
360
+ else if (meta.state === "RETIRED") status = "retired";
361
+ else if (revision?.paused) status = "paused";
362
+ else if (quarantined) status = "quarantined";
363
+ return { status, paused: revision?.paused, ...base, outbox, waitingFor, circles };
364
+ }
365
+
366
+ private async waitingFor(): Promise<Uint8Array[]> {
367
+ if (!this.selfPin) return [];
368
+ const selfHex = toHex(this.selfPin.identityId);
369
+ return this.store.read((tx) => {
370
+ const authors = new Set<string>();
371
+ for (const [, c] of tx.list("circles")) if (c.status === "member") for (const m of c.members) if (m !== selfHex) authors.add(m);
372
+ const out: Uint8Array[] = [];
373
+ for (const a of authors) {
374
+ const pin = tx.get("pins", a);
375
+ if (!pin) continue;
376
+ const hasKey = tx.list("readerEpochs", pin.streamId + ":").length > 0;
377
+ if (!hasKey) out.push(fromHex(a));
378
+ }
379
+ return out;
380
+ });
381
+ }
382
+
383
+ async inspect(): Promise<Inspection> {
384
+ const now = this.now();
385
+ return this.store.read((tx) => {
386
+ const stream = tx.get("stream", STREAM);
387
+ const revision = tx.get("revision", REVISION);
388
+ const vault = tx.get("vault", VAULT);
389
+ const pendingEpoch = tx.list("epochs").find(([, e]) => e.status === "prepared" || e.status === "committed")?.[1].epoch;
390
+ return {
391
+ identityId: this.selfPin?.identityId,
392
+ certificateHash: this.selfPin?.certificateHash,
393
+ generation: this.selfPin?.generation,
394
+ desiredRevision: revision ? unbig(revision.desiredRevision) : 1n,
395
+ paused: revision?.paused,
396
+ activeEpoch: stream?.activeEpoch,
397
+ pendingEpoch,
398
+ outbox: outboxCounts(tx, now),
399
+ vault: vault ? { revision: unbig(vault.revision), pending: vault.pending, dirty: vault.dirty } : undefined
400
+ };
401
+ });
402
+ }
403
+
404
+ // Enrollment ------------------------------------------------------------------------------------
405
+
406
+ async initializeAccount(): Promise<{ identityId: Uint8Array }> {
407
+ const stage = "initializeAccount";
408
+ const opened = await openStore(this.store, this.secrets, this.accountName);
409
+ if (opened.kind !== "fresh" && opened.kind !== "initializing") throw new KavachError("MALFORMED", stage, "an account already exists on this device");
410
+ let accountSeed = await this.secrets.get("accountSeed");
411
+ let deviceSeed = await this.secrets.get("deviceSeed");
412
+ let recoveryKey = await this.secrets.get("recoveryKey");
413
+ let instanceId = await this.secrets.get("instanceId");
414
+ const device = deviceSeed ? deviceFromSeed(deviceSeed, (await this.store.read((tx) => tx.get("self", "self")))?.deviceId ? fromHex((await this.store.read((tx) => tx.get("self", "self")))?.deviceId ?? "") : newDevice().deviceId) : newDevice();
415
+ if (!accountSeed || !deviceSeed || !recoveryKey || !instanceId) {
416
+ accountSeed = newAccountSeed();
417
+ deviceSeed = device.deviceSeed;
418
+ recoveryKey = newRecoveryKey();
419
+ instanceId = randomBytes(16);
420
+ await t1Secrets(this.secrets, { accountSeed, deviceSeed, recoveryKey, instanceId });
421
+ }
422
+ const account = openAccount(this.accountName, accountSeed);
423
+ const certificate = certify(account, device, 1n);
424
+ const pin = pinFromCertificate(expectType<AccountDevice>(verifyDocument(certificate), DocumentType.AccountDevice));
425
+ const iid = instanceId;
426
+ await this.store.transaction((tx) => t1Header(tx, { account, device, certificate, instanceId: iid, generation: 1n }));
427
+ const vault = buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: 1n, lastCertificateHash: pin.certificateHash, entries: [] }, recoveryKey);
428
+ const outcome = await this.transport.directory.registerAccount({ certificate, vault: vault.wire });
429
+ if (outcome.status === "CONFLICT") {
430
+ // A restart or a lost response after the host committed: the name is ours if the host holds
431
+ // exactly the certificate this device saved; anything else is somebody else's account.
432
+ const [me] = await this.transport.directory.resolveAccounts([this.accountName]);
433
+ if (!me || !equalBytes(me.certificate, certificate)) throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "an account with this name exists under another key");
434
+ } else if (outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED") {
435
+ throw new KavachError("NOT_AUTHORIZED", stage, `host refused registration: ${outcome.status}`);
436
+ }
437
+ await this.store.transaction((tx) => {
438
+ t9Vault(tx, { revision: "1", hash: toHex(vault.hash), wire: toHex(vault.wire), pending: false, dirty: false });
439
+ t1Activate(tx);
440
+ });
441
+ this.account = account;
442
+ this.device = device;
443
+ this.selfPin = pin;
444
+ // The recovery key goes to the provider; enrollment never blocks on it or on the code.
445
+ try {
446
+ await this.provider.save(this.accountName, pin.identityId, recoveryKey);
447
+ } catch {
448
+ /* status is reported by recoveryStatus() */
449
+ }
450
+ await this.emitState();
451
+ return { identityId: pin.identityId };
452
+ }
453
+
454
+ async recoveryStatus(): Promise<ProviderStatusResult> {
455
+ return this.provider.status();
456
+ }
457
+
458
+ async recoveryCode(): Promise<string> {
459
+ const key = await this.secrets.get("recoveryKey");
460
+ if (!key) throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", "recoveryCode");
461
+ return encodeRecoveryCode(key);
462
+ }
463
+
464
+ async restoreAccount(recoveryCode?: string): Promise<{ identityId: Uint8Array; generation: bigint }> {
465
+ const stage = "restoreAccount";
466
+ const opened = await openStore(this.store, this.secrets, this.accountName);
467
+ if (opened.kind === "ACTIVE" || opened.kind === "RETIRED") throw new KavachError("MALFORMED", stage, "an account already exists on this device");
468
+ let pending: { device: DeviceKeys; instanceId: Uint8Array } | undefined;
469
+ if (opened.kind === "ACTIVATION_PENDING") {
470
+ const resumed = await this.resumeActivation(stage);
471
+ if (resumed.outcome === "done") return resumed.result;
472
+ // The host still shows the certificate we expected but refused our prepared bytes (the vault
473
+ // moved under us): prepare again with the same device keys. Nothing was allocated remotely.
474
+ pending = resumed;
475
+ }
476
+ const [resolved] = await this.transport.directory.resolveAccounts([this.accountName]);
477
+ if (!resolved) throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "unknown account");
478
+ const current = expectType<AccountDevice>(verifyDocument(resolved.certificate), DocumentType.AccountDevice, stage);
479
+ const vaultWire = await this.transport.directory.getVault(this.accountName);
480
+ if (!vaultWire) throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "no vault");
481
+ const identity = pinFromCertificate(current).identityId;
482
+ const recovered = await recoverAccount({
483
+ accountName: this.accountName,
484
+ identityId: identity,
485
+ accountKey: resolved.accountKey,
486
+ vaultWire,
487
+ provider: this.provider,
488
+ ...(recoveryCode === undefined ? {} : { recoveryCode })
489
+ });
490
+ const account = openAccount(this.accountName, recovered.vault.accountSeed);
491
+ // Fresh device keys and a fresh local store. Nothing from the old device is copied.
492
+ const device = pending?.device ?? newDevice();
493
+ const generation = current.generation + 1n;
494
+ const certificate = certify(account, device, generation);
495
+ const pin = pinFromCertificate(expectType<AccountDevice>(verifyDocument(certificate), DocumentType.AccountDevice));
496
+ const header = parseVaultHeader(vaultWire);
497
+ const nextVault = buildVault(
498
+ { accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: header.vaultRevision + 1n },
499
+ { accountSeed: recovered.vault.accountSeed, lastDeviceGeneration: generation, lastCertificateHash: pin.certificateHash, entries: recovered.vault.entries },
500
+ recovered.recoveryKey
501
+ );
502
+ const instanceId = pending?.instanceId ?? randomBytes(16);
503
+ if (!pending) await t1Secrets(this.secrets, { accountSeed: recovered.vault.accountSeed, deviceSeed: device.deviceSeed, recoveryKey: recovered.recoveryKey, instanceId });
504
+ await this.store.transaction((tx) => {
505
+ t1Header(tx, { account, device, certificate, instanceId, generation });
506
+ t2PrepareActivation(tx, {
507
+ certificate: toHex(certificate),
508
+ vault: toHex(nextVault.wire),
509
+ vaultRevision: big(header.vaultRevision + 1n),
510
+ expectedCertificateHash: toHex(current.bodyHash),
511
+ expectedVaultRevision: big(header.vaultRevision),
512
+ expectedVaultHash: toHex(header.hash)
513
+ });
514
+ });
515
+ // Durable now: the device exists locally as activationPending even if the next call fails.
516
+ this.account = account;
517
+ this.device = device;
518
+ this.selfPin = pin;
519
+ const outcome = await this.transport.directory.activateDevice({
520
+ accountName: this.accountName,
521
+ expectedCertificateHash: current.bodyHash,
522
+ expectedVaultRevision: header.vaultRevision,
523
+ expectedVaultHash: header.hash,
524
+ newCertificate: certificate,
525
+ newVault: nextVault.wire
526
+ });
527
+ if (outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED") {
528
+ // Another candidate won, or the metadata moved: report it; the user may try again.
529
+ throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, `activation ${outcome.status}`);
530
+ }
531
+ return this.finishActivation({ account, device, pin, vaultRevision: header.vaultRevision + 1n, vault: nextVault, entries: recovered.vault.entries, recoveryKey: recovered.recoveryKey });
532
+ }
533
+
534
+ /** The local half of activation, after the host committed it: no new randomness, ever. */
535
+ private async finishActivation(input: { account: OwnAccount; device: DeviceKeys; pin: AccountPin; vaultRevision: bigint; vault: { wire: Uint8Array; hash: Uint8Array }; entries: readonly VaultEntry[]; recoveryKey: Uint8Array }): Promise<{ identityId: Uint8Array; generation: bigint }> {
536
+ const { account, device, pin } = input;
537
+ await this.store.transaction((tx) => {
538
+ tx.delete("activation", "pending");
539
+ t9Vault(tx, { revision: big(input.vaultRevision), hash: toHex(input.vault.hash), wire: toHex(input.vault.wire), pending: false, dirty: false });
540
+ for (const e of input.entries) {
541
+ const owned = equalBytes(e.ownerIdentityId, pin.identityId);
542
+ const c = pinCircle(e.circleId, e.ownerIdentityId, e.sharingEnabled, owned ? "member" : "requested");
543
+ tx.put("circles", toHex(e.circleId), circleToRow(c));
544
+ }
545
+ t3ApplyChange(tx, { kind: "ownActivation" });
546
+ t1Activate(tx);
547
+ });
548
+ this.account = account;
549
+ this.device = device;
550
+ this.selfPin = pin;
551
+ try {
552
+ await this.provider.save(this.accountName, pin.identityId, input.recoveryKey);
553
+ } catch {
554
+ /* reported by recoveryStatus() */
555
+ }
556
+ await this.emitState();
557
+ return { identityId: pin.identityId, generation: pin.generation };
558
+ }
559
+
560
+ /**
561
+ * A device that crashed, or lost the response, between T2 and the local acknowledgement: the
562
+ * host is asked what it holds. Our prepared certificate there means the activation happened
563
+ * and is finished locally; the expected old certificate means it did not, and the exact
564
+ * prepared bytes are sent again; anything else means another candidate won. No key, device
565
+ * id or vault is ever generated here.
566
+ */
567
+ private async resumeActivation(stage: string): Promise<{ outcome: "done"; result: { identityId: Uint8Array; generation: bigint } } | { outcome: "prepareAgain"; device: DeviceKeys; instanceId: Uint8Array }> {
568
+ const row = await this.store.read((tx) => tx.get("activation", "pending"));
569
+ if (!row) throw new KavachError("RECOVERY_REQUIRED", stage, "activation pending without its prepared bytes");
570
+ const [accountSeed, deviceSeed, recoveryKey, instanceId] = await Promise.all([this.secrets.get("accountSeed"), this.secrets.get("deviceSeed"), this.secrets.get("recoveryKey"), this.secrets.get("instanceId")]);
571
+ const self = await this.store.read((tx) => tx.get("self", "self"));
572
+ if (!accountSeed || !deviceSeed || !recoveryKey || !instanceId || !self) throw new KavachError("RECOVERY_REQUIRED", stage, "activation pending without its roots");
573
+ const account = openAccount(this.accountName, accountSeed);
574
+ const device = deviceFromSeed(deviceSeed, fromHex(self.deviceId));
575
+ const certificate = fromHex(row.certificate);
576
+ const prepared = expectType<AccountDevice>(verifyDocument(certificate), DocumentType.AccountDevice, stage);
577
+ const pin = pinFromCertificate(prepared);
578
+ const [me] = await this.transport.directory.resolveAccounts([this.accountName]);
579
+ if (!me) throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "unknown account");
580
+ const theirs = expectType<AccountDevice>(verifyDocument(me.certificate), DocumentType.AccountDevice, stage);
581
+ const vaultWire = fromHex(row.vault);
582
+ if (!equalBytes(theirs.bodyHash, prepared.bodyHash)) {
583
+ if (toHex(theirs.bodyHash) !== row.expectedCertificateHash) throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "another device activated first");
584
+ const outcome = await this.transport.directory.activateDevice({
585
+ accountName: this.accountName,
586
+ expectedCertificateHash: fromHex(row.expectedCertificateHash),
587
+ expectedVaultRevision: unbig(row.expectedVaultRevision),
588
+ expectedVaultHash: fromHex(row.expectedVaultHash),
589
+ newCertificate: certificate,
590
+ newVault: vaultWire
591
+ });
592
+ if (outcome.status === "CONFLICT") return { outcome: "prepareAgain", device, instanceId };
593
+ if (outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED") throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, `activation ${outcome.status}`);
594
+ }
595
+ const opened = openVault(vaultWire, recoveryKey, this.accountName, account.account.publicKey);
596
+ const result = await this.finishActivation({ account, device, pin, vaultRevision: unbig(row.vaultRevision), vault: { wire: vaultWire, hash: parseVaultHeader(vaultWire).hash }, entries: opened.entries, recoveryKey });
597
+ return { outcome: "done", result };
598
+ }
599
+
600
+ /**
601
+ * Enrollment and activation hand the recovery key to the provider after their last transaction,
602
+ * so a process that dies in between leaves the account unbacked. The first sync of each process
603
+ * asks the provider and saves again when it has nothing for this identity.
604
+ */
605
+ private async ensureBackup(): Promise<void> {
606
+ if (this.backupChecked || !this.selfPin) return;
607
+ this.backupChecked = true;
608
+ try {
609
+ const key = await this.secrets.get("recoveryKey");
610
+ if (!key) return;
611
+ const have = await this.provider.load(this.accountName, this.selfPin.identityId);
612
+ if (have.status === "unavailable") await this.provider.save(this.accountName, this.selfPin.identityId, key);
613
+ } catch {
614
+ /* reported by recoveryStatus() */
615
+ }
616
+ }
617
+
618
+ private async emitState(): Promise<void> {
619
+ this.emit({ kind: "stateChanged", state: await this.getState() });
620
+ }
621
+
622
+ // Sending ---------------------------------------------------------------------------------------
623
+
624
+ async enqueue(kind: RecordKind, payload: Uint8Array, options: EnqueueOptions = {}): Promise<EnqueueResult> {
625
+ const deps = this.outboxDeps();
626
+ // A key answered before anything that may need the network: the record it names is already
627
+ // durable, so a retry offline, or with a rotation due, returns it. The outbox checks the key
628
+ // again inside its transaction for calls that race.
629
+ if (options.idempotencyKey !== undefined) {
630
+ const seen = await this.store.read((tx) => tx.get("enqueueKeys", options.idempotencyKey ?? ""));
631
+ if (seen) return { recordId: fromHex(seen.recordId), duplicate: true };
632
+ }
633
+ await this.ensureEpoch();
634
+ try {
635
+ return this.enqueued(kind, await enqueue(deps, kind, payload, options));
636
+ } catch (e) {
637
+ if (isKavachError(e, "ROTATION_REQUIRED") || isKavachError(e, "COUNTER_EXHAUSTED")) {
638
+ await this.ensureEpoch();
639
+ const r = await enqueue(deps, kind, payload, options);
640
+ this.emit({ kind: "refresh", reason: "rotation" });
641
+ return this.enqueued(kind, r);
642
+ }
643
+ throw e;
644
+ }
645
+ }
646
+
647
+ /** After commit: the device's own record is stored like any author's, so one subscription covers self and others. */
648
+ private enqueued(kind: RecordKind, r: { recordId: Uint8Array; duplicate: boolean; own: boolean }): EnqueueResult {
649
+ if (!r.duplicate && this.selfPin) this.emit({ kind: "recordStored", recordKind: kind, authorIdentityId: this.selfPin.identityId, recordId: r.recordId, tookSlot: r.own });
650
+ return { recordId: r.recordId, duplicate: r.duplicate };
651
+ }
652
+
653
+ // Epochs ----------------------------------------------------------------------------------------
654
+
655
+ private async currentAudience(): Promise<ResolvedAudience> {
656
+ const { pin } = this.requireAccount("audience");
657
+ const retired = new Set(this.trust.state.retired);
658
+ return this.store.read((tx) => {
659
+ const pins = new Map(tx.list("pins").map(([k, p]) => [k, rowToPin(p)]));
660
+ const circles = tx.list("circles").map(([, c]) => rowToCircle(c));
661
+ return resolveAudience(pin, circles, pins, retired);
662
+ });
663
+ }
664
+
665
+ /** Prepare, publish and promote epochs until one is active under the current revision. */
666
+ private async ensureEpoch(): Promise<SyncReport["epoch"]> {
667
+ const { device, pin } = this.requireAccount("epoch");
668
+ for (let attempt = 0; attempt < 4; attempt++) {
669
+ const stream = await this.store.read((tx) => tx.get("stream", STREAM));
670
+ const revisionRow = await this.store.read((tx) => tx.get("revision", REVISION));
671
+ if (!stream) throw new KavachError("RECOVERY_REQUIRED", "epoch");
672
+ if (stream.activeEpoch !== undefined && revisionRow?.paused !== "rotation") return attempt === 0 ? "unchanged" : "prepared";
673
+ let audience: ResolvedAudience;
674
+ try {
675
+ audience = await this.currentAudience();
676
+ } catch (e) {
677
+ if (isKavachError(e, "METADATA_REQUIRED") || isKavachError(e, "RECIPIENT_LIMIT")) {
678
+ const reason = e.code === "METADATA_REQUIRED" ? "metadata" : "limit";
679
+ await this.store.transaction((tx) => {
680
+ const r = tx.get("revision", REVISION);
681
+ if (r) tx.put("revision", REVISION, { ...r, paused: reason });
682
+ });
683
+ return "paused";
684
+ }
685
+ throw e;
686
+ }
687
+ let prepared: PreparedEpoch;
688
+ if (stream.pendingEpoch !== undefined) {
689
+ const row = await this.store.read((tx) => tx.get("epochs", String(stream.pendingEpoch)));
690
+ if (!row) throw new KavachError("STORAGE_FAILURE", "epoch", "pending epoch row missing");
691
+ prepared = rowToPrepared(row);
692
+ } else {
693
+ const revision = revisionRow ? unbig(revisionRow.desiredRevision) : 1n;
694
+ const fresh = prepareEpoch({
695
+ author: { identityId: pin.identityId, certificateHash: pin.certificateHash, streamId: pin.streamId, signingSecretKey: device.signing.secretKey },
696
+ epoch: stream.highestCommittedEpoch + 1,
697
+ createdAt: this.now(),
698
+ audience,
699
+ desiredRevision: revision
700
+ });
701
+ // Under the lock, another context may have prepared or even promoted first. One unresolved
702
+ // epoch per stream: adopt theirs, or start over if the stream moved on.
703
+ const adopted = await this.store.transaction((tx): PreparedEpoch | undefined => {
704
+ const st = tx.get("stream", STREAM);
705
+ if (!st) throw new KavachError("RECOVERY_REQUIRED", "epoch");
706
+ if (st.pendingEpoch !== undefined) {
707
+ const row = tx.get("epochs", String(st.pendingEpoch));
708
+ if (!row) throw new KavachError("STORAGE_FAILURE", "epoch", "pending epoch row missing");
709
+ return rowToPrepared(row);
710
+ }
711
+ if (st.highestCommittedEpoch !== stream.highestCommittedEpoch || st.activeEpoch !== undefined) return undefined;
712
+ t4PersistPrepared(tx, fresh);
713
+ return fresh;
714
+ });
715
+ if (!adopted) continue;
716
+ prepared = adopted;
717
+ }
718
+ const outcome = await this.transport.relay.publishEpoch({ streamId: pin.streamId, expectedEpoch: prepared.epoch - 1, expectedHash: undefined, manifest: prepared.manifest, wraps: prepared.wraps });
719
+ const resolution = resolvePublish(
720
+ prepared,
721
+ outcome.status === "CONFLICT"
722
+ ? { status: "CONFLICT", currentEpoch: outcome.currentEpoch ?? 0, currentManifest: outcome.currentManifest }
723
+ : { status: outcome.status === "IGNORED_OLDER" ? "RETRYABLE" : outcome.status }
724
+ );
725
+ if (resolution === "RETRY") return "pending";
726
+ const promoted = await this.store.transaction((tx) => t5Committed(tx, prepared.epoch, audience.key));
727
+ if (promoted === "PROMOTED") {
728
+ if (attempt > 0 || stream.activeEpoch === undefined) this.emit({ kind: "refresh", reason: "rotation" });
729
+ return "prepared";
730
+ }
731
+ }
732
+ return "pending";
733
+ }
734
+
735
+ // Sync ------------------------------------------------------------------------------------------
736
+
737
+ async sync(options: SyncOptions = {}): Promise<SyncReport> {
738
+ if (this.syncing) return this.syncing;
739
+ this.syncing = this.runSync(options).finally(() => {
740
+ this.syncing = undefined;
741
+ });
742
+ return this.syncing;
743
+ }
744
+
745
+ private async runSync(options: SyncOptions): Promise<SyncReport> {
746
+ const report: SyncReport = {
747
+ metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0 },
748
+ epoch: "unchanged",
749
+ outbox: { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 },
750
+ inbox: { streams: 0, stored: 0, keyPending: 0, invalid: 0, acked: 0, hasMore: false },
751
+ purged: 0,
752
+ errors: []
753
+ };
754
+ if (!this.account || !this.selfPin) return report;
755
+ const meta = await this.store.read((tx) => tx.get("meta", META));
756
+ if (!meta || meta.state === "RETIRED") return report;
757
+ if (await this.store.read((tx) => tx.get("pendingReset", "pending"))) {
758
+ // A reset the host may already hold: nothing else runs under the old identity.
759
+ try {
760
+ await this.resumeReset("sync");
761
+ } catch (e) {
762
+ report.errors.push(syncError("resumeReset", e));
763
+ return report;
764
+ }
765
+ }
766
+ if (meta.state === "ACTIVATION_PENDING") {
767
+ try {
768
+ const resumed = await this.resumeActivation("sync");
769
+ if (resumed.outcome !== "done") {
770
+ report.errors.push({ operation: "resumeActivation", code: "ACTIVATION_PENDING", stage: "sync", retryable: true }); // restoreAccount prepares again
771
+ return report;
772
+ }
773
+ } catch (e) {
774
+ report.errors.push(syncError("resumeActivation", e));
775
+ return report;
776
+ }
777
+ } else if (meta.state !== "ACTIVE") return report;
778
+ await this.ensureBackup();
779
+ try {
780
+ await this.syncMetadata(report);
781
+ } catch (e) {
782
+ report.errors.push(syncError("metadata", e));
783
+ }
784
+ const after = await this.store.read((tx) => tx.get("meta", META));
785
+ if (after?.state === "RETIRED") return report;
786
+ try {
787
+ report.epoch = await this.ensureEpoch();
788
+ } catch (e) {
789
+ report.errors.push(syncError("epoch", e));
790
+ }
791
+ try {
792
+ report.outbox = await drain(this.outboxDeps());
793
+ } catch (e) {
794
+ report.errors.push(syncError("outbox", e));
795
+ }
796
+ let subs: Awaited<ReturnType<Relay["listSubscriptions"]>> = [];
797
+ try {
798
+ subs = await this.transport.relay.listSubscriptions();
799
+ } catch (e) {
800
+ report.errors.push(syncError("listSubscriptions", e));
801
+ }
802
+ // Each stream on its own: a failed download or a refused record names its stream in the report
803
+ // and leaves the other streams to be read; a stream that failed is not counted as synced.
804
+ // One budget spans the streams; a stream it stops early reports hasMore.
805
+ const budget: ReadBudget = {
806
+ remainingRecords: Math.max(1, Math.floor(options.catchUp?.maxRecords ?? DEFAULT_CATCH_UP.maxRecords)),
807
+ deadline: this.now() + BigInt(Math.max(0, Math.floor(options.catchUp?.maxMs ?? DEFAULT_CATCH_UP.maxMs)))
808
+ };
809
+ for (const s of subs) {
810
+ try {
811
+ const known = await this.store.read((tx) => tx.list("certificates").some(([, c]) => c.streamId === toHex(s.streamId)));
812
+ if (!known) continue;
813
+ const r = await syncStream(this.inboxDeps(), s.streamId, s.authorIdentityId, budget);
814
+ report.inbox.streams++;
815
+ report.inbox.stored += r.stored;
816
+ report.inbox.keyPending += r.keyPending;
817
+ report.inbox.invalid += r.invalid;
818
+ if (r.acked !== undefined) report.inbox.acked++;
819
+ if (r.hasMore) report.inbox.hasMore = true;
820
+ } catch (e) {
821
+ report.errors.push({ ...syncError("inbox", e), streamId: s.streamId, authorIdentityId: s.authorIdentityId });
822
+ }
823
+ }
824
+ report.purged = (await purgeHistory(this.store, this.policy, this.now())) + (await purgeOutbox(this.store, this.policy, this.now()));
825
+ await this.emitState();
826
+ return report;
827
+ }
828
+
829
+ private async syncMetadata(report: SyncReport): Promise<void> {
830
+ const { pin } = this.requireAccount("sync");
831
+ // Own account: has this device been superseded?
832
+ const [me] = await this.transport.directory.resolveAccounts([this.accountName]);
833
+ if (me) {
834
+ const theirs = expectType<AccountDevice>(verifyDocument(me.certificate), DocumentType.AccountDevice, "sync");
835
+ if (!equalBytes(theirs.accountKey, pin.accountKey)) {
836
+ // Our own key changed at the host: only a receipt we produced explains that; otherwise ignore.
837
+ } else if (!equalBytes(theirs.bodyHash, pin.certificateHash) && theirs.generation > pin.generation) {
838
+ await this.retire();
839
+ return;
840
+ }
841
+ }
842
+ // Reset-key list when due.
843
+ const now = this.now();
844
+ const due = !this.trust.fresh(now) || now - this.trust.state.timeHighWater >= RESET_LIST_RECHECK_MS;
845
+ if (due) {
846
+ const wire = await this.transport.directory.getResetKeyList();
847
+ if (wire) {
848
+ try {
849
+ this.trust.install(wire, now);
850
+ await this.persistTrust();
851
+ } catch (e) {
852
+ report.errors.push(syncError("resetKeyList", e));
853
+ }
854
+ }
855
+ }
856
+ // Unconfirmed writes of our own first: a successor snapshot the host never took, a vault update
857
+ // it never saw. Until they land, peers read stale membership and a replacement phone a stale
858
+ // preference.
859
+ const pendingOwned = await this.store.read((tx) => tx.list("circles").filter(([, r]) => r.pending && r.ownerIdentityId === toHex(pin.identityId)).map(([, r]) => fromHex(r.circleId)));
860
+ for (const circleId of pendingOwned) {
861
+ try {
862
+ if ((await this.publishPendingSnapshot(circleId)) === "COMMITTED") report.metadata.snapshots++;
863
+ } catch (e) {
864
+ report.errors.push(syncError("publishSnapshot", e));
865
+ }
866
+ }
867
+ try {
868
+ await this.syncVault();
869
+ } catch (e) {
870
+ report.errors.push(syncError("vault", e));
871
+ }
872
+ // Circles: owners we have not pinned yet, then snapshots, then members' certificates.
873
+ const circles = await this.store.read((tx) => tx.list("circles").map(([, c]) => rowToCircle(c)));
874
+ const unknownOwners: Uint8Array[] = [];
875
+ for (const c of circles) {
876
+ if (c.status === "closed" || c.status === "quarantined" || c.status === "left") continue;
877
+ if (!equalBytes(c.ownerIdentityId, pin.identityId) && !(await this.pinFor(c.ownerIdentityId))) unknownOwners.push(c.ownerIdentityId);
878
+ }
879
+ if (unknownOwners.length > 0) {
880
+ for (const r of await this.transport.directory.resolveIdentities(unknownOwners)) await this.acceptMemberCertificate(r.certificate, r.resetReceipt, report);
881
+ }
882
+ const memberIds = new Map<string, Uint8Array>();
883
+ for (const c of circles) {
884
+ if (c.status === "closed" || c.status === "quarantined" || c.status === "left") continue;
885
+ const ownerPin = equalBytes(c.ownerIdentityId, pin.identityId) ? pin : await this.pinFor(c.ownerIdentityId);
886
+ if (!ownerPin) {
887
+ memberIds.set(toHex(c.ownerIdentityId), c.ownerIdentityId);
888
+ continue;
889
+ }
890
+ const wire = await this.transport.directory.getCircle(c.circleId);
891
+ if (wire) {
892
+ try {
893
+ const r = acceptSnapshot(c, wire, ownerPin.accountKey, pin.identityId, now);
894
+ if (r.outcome === "ACCEPTED") {
895
+ // The row may have moved since it was read: a sharing preference, a leave or a close
896
+ // made meanwhile wins over the fetched snapshot, and a newer snapshot already stored
897
+ // makes this one stale.
898
+ const committed = await this.store.transaction((tx) => {
899
+ const row = tx.get("circles", toHex(c.circleId));
900
+ const fresh = row ? rowToCircle(row) : c;
901
+ if (fresh.revision >= r.state.revision) return undefined;
902
+ const localFinal = fresh.status === "left" || fresh.status === "closed" || fresh.status === "quarantined";
903
+ const state: CircleState = { ...r.state, enabled: fresh.enabled, status: localFinal ? fresh.status : r.state.status };
904
+ const ch = t3Circle(tx, { ...circleToRow(state), pending: row?.pending && unbig(row.pending.revision) <= state.revision ? undefined : row?.pending }, { kind: "snapshot", circleId: c.circleId, revision: state.revision });
905
+ return { state, rotated: ch.changed, leftNow: state.status === "left" && fresh.status !== "left" };
906
+ });
907
+ if (committed) {
908
+ if (committed.rotated) report.metadata.rotations++;
909
+ report.metadata.snapshots++;
910
+ this.emit({ kind: "circleChanged", circleId: c.circleId, revision: committed.state.revision, status: committed.state.status });
911
+ if (committed.leftNow) await this.onLeft(c);
912
+ for (const m of committed.state.members) memberIds.set(toHex(m), m);
913
+ } else for (const m of c.members) memberIds.set(toHex(m), m);
914
+ } else for (const m of c.members) memberIds.set(toHex(m), m);
915
+ } catch (e) {
916
+ if (isKavachError(e, "EQUIVOCATION")) {
917
+ await this.store.transaction((tx) => t3Circle(tx, circleToRow({ ...c, status: "quarantined" })));
918
+ this.emit({ kind: "securityError", code: "EQUIVOCATION", detail: "two snapshots at one revision" });
919
+ } else report.errors.push(syncError("circleSnapshot", e));
920
+ for (const m of c.members) memberIds.set(toHex(m), m);
921
+ }
922
+ } else for (const m of c.members) memberIds.set(toHex(m), m);
923
+ if (equalBytes(c.ownerIdentityId, pin.identityId) && c.status === "member") {
924
+ const requests = await this.transport.directory.readRequests(c.circleId);
925
+ const joins = requests.filter((r) => r.action === "join").map((r) => r.accountName);
926
+ if (joins.length) this.emit({ kind: "joinRequests", circleId: c.circleId, accountNames: joins });
927
+ for (const r of requests.filter((r) => r.action === "leave")) {
928
+ const [acct] = await this.transport.directory.resolveAccounts([r.accountName]);
929
+ if (!acct) continue;
930
+ const leaver = pinFromCertificate(expectType<AccountDevice>(verifyDocument(acct.certificate), DocumentType.AccountDevice));
931
+ try {
932
+ await this.changeMembership(c.circleId, { remove: leaver.identityId });
933
+ } catch (e) {
934
+ report.errors.push(syncError("removeLeaver", e));
935
+ }
936
+ }
937
+ }
938
+ }
939
+ memberIds.delete(toHex(pin.identityId));
940
+ if (memberIds.size > 0) {
941
+ const resolved = await this.transport.directory.resolveIdentities([...memberIds.values()]);
942
+ const byIdentity = new Map<string, { accountName: string; accountKey: Uint8Array }>();
943
+ for (const r of resolved) {
944
+ const doc = expectType<AccountDevice>(verifyDocument(r.certificate), DocumentType.AccountDevice, "sync");
945
+ const iid = toHex(pinFromCertificate(doc).identityId);
946
+ byIdentity.set(iid, { accountName: r.accountName, accountKey: r.accountKey });
947
+ await this.acceptMemberCertificate(r.certificate, r.resetReceipt, report);
948
+ }
949
+ checkMemberResolution(byIdentity);
950
+ }
951
+ }
952
+
953
+ private async pinFor(identityId: Uint8Array): Promise<AccountPin | undefined> {
954
+ const row = await this.store.read((tx) => tx.get("pins", toHex(identityId)));
955
+ return row ? rowToPin(row) : undefined;
956
+ }
957
+
958
+ /** Accept a member's certificate; a changed account key is quarantined unless a valid receipt explains it. */
959
+ private async acceptMemberCertificate(wire: Uint8Array, resetReceipt: Uint8Array | undefined, report: SyncReport): Promise<void> {
960
+ const doc = expectType<AccountDevice>(verifyDocument(wire), DocumentType.AccountDevice, "sync");
961
+ const existing = await this.store.read((tx) => tx.list("pins").find(([, p]) => p.accountName === doc.accountName)?.[1]);
962
+ const current = existing ? rowToPin(existing) : undefined;
963
+ let outcome;
964
+ try {
965
+ outcome = acceptCertificate(current, wire);
966
+ } catch (e) {
967
+ if (isKavachError(e, "ACCOUNT_KEY_CHANGED") && current) {
968
+ await this.onKeyChanged(current, wire, resetReceipt, report);
969
+ return;
970
+ }
971
+ if (e instanceof KavachError) {
972
+ report.errors.push(syncError("certificate", e));
973
+ if (e.code === "EQUIVOCATION" || e.code === "DEVICE_ROLLBACK" || e.code === "DEVICE_ID_REUSED") this.emit({ kind: "securityError", code: e.code, detail: doc.accountName });
974
+ return;
975
+ }
976
+ throw e;
977
+ }
978
+ if (outcome.outcome === "DUPLICATE") return;
979
+ const p = outcome.pin;
980
+ await this.store.transaction((tx) => {
981
+ tx.put("pins", toHex(p.identityId), pinToRow(p));
982
+ if (outcome.previous) {
983
+ const old = tx.get("certificates", toHex(outcome.previous.certificateHash));
984
+ if (old) tx.put("certificates", toHex(outcome.previous.certificateHash), { ...old, current: false });
985
+ }
986
+ tx.put("certificates", toHex(p.certificateHash), { identityId: toHex(p.identityId), certificateHash: toHex(p.certificateHash), signingKey: toHex(p.signingKey), boxKey: toHex(p.boxKey), streamId: toHex(p.streamId), generation: big(p.generation), wire: toHex(p.certificate), current: true });
987
+ if (outcome.rotate) {
988
+ const ch = t3ApplyChange(tx, { kind: "deviceChanged", identityId: p.identityId, certificateHash: p.certificateHash });
989
+ if (ch.changed) report.metadata.rotations++;
990
+ }
991
+ });
992
+ report.metadata.certificates++;
993
+ if (outcome.rotate) this.emit({ kind: "deviceChanged", identityId: p.identityId, generation: p.generation });
994
+ }
995
+
996
+ private async onKeyChanged(current: AccountPin, wire: Uint8Array, receipt: Uint8Array | undefined, report: SyncReport): Promise<void> {
997
+ if (receipt) {
998
+ try {
999
+ const effect = this.trust.apply(receipt, wire, current.accountName, current.identityId, this.now());
1000
+ // One transaction: a receipt is never on record without the closures and rotations it
1001
+ // causes, so a restart cannot see it as a duplicate with nothing done.
1002
+ await this.store.transaction((tx) => {
1003
+ this.persistTrustIn(tx);
1004
+ if (effect.outcome === "ACCOUNT_KEY_CHANGED") this.applyResetIn(tx, current, effect.newIdentityId);
1005
+ });
1006
+ if (effect.outcome === "ACCOUNT_KEY_CHANGED") {
1007
+ report.metadata.resets++;
1008
+ this.emit({ kind: "accountKeyChanged", accountName: current.accountName, quarantined: false });
1009
+ }
1010
+ return;
1011
+ } catch (e) {
1012
+ report.errors.push(syncError("resetReceipt", e));
1013
+ if (isKavachError(e, "RESET_KEYS_REQUIRED") || isKavachError(e, "RESET_CLOCK_UNTRUSTED")) {
1014
+ // Deferred: sharing continues under the pinned key; the receipt is tried again later.
1015
+ return;
1016
+ }
1017
+ }
1018
+ }
1019
+ // A bare directory key change: quarantine the new key, keep the pin, close nothing.
1020
+ const already = await this.store.read((tx) => tx.get("quarantine", current.accountName));
1021
+ await this.store.transaction((tx) => tx.put("quarantine", current.accountName, { accountName: current.accountName, certificate: toHex(wire), reason: receipt ? "receipt rejected" : "no receipt", at: big(this.now()) }));
1022
+ report.metadata.quarantined++;
1023
+ if (!already) this.emit({ kind: "accountKeyChanged", accountName: current.accountName, quarantined: true });
1024
+ }
1025
+
1026
+ /** Peers on a validated reset: stop sharing to the old identity, close its circles, rotate. */
1027
+ private applyResetIn(tx: Tx, old: AccountPin, newIdentityId: Uint8Array): void {
1028
+ tx.delete("pins", toHex(old.identityId));
1029
+ tx.delete("quarantine", old.accountName);
1030
+ for (const [k, c] of tx.list("certificates")) if (c.identityId === toHex(old.identityId)) tx.put("certificates", k, { ...c, current: false });
1031
+ for (const [k, c] of tx.list("circles")) {
1032
+ if (c.ownerIdentityId === toHex(old.identityId)) {
1033
+ t3Circle(tx, { ...c, status: "closed" }, { kind: "leave", circleId: fromHex(k) });
1034
+ } else if (c.members.includes(toHex(old.identityId))) {
1035
+ t3ApplyChange(tx, { kind: "deviceChanged", identityId: old.identityId, certificateHash: newIdentityId });
1036
+ }
1037
+ }
1038
+ forgetAuthorIn(tx, old.identityId);
1039
+ }
1040
+
1041
+ private persistTrustIn(tx: Tx): void {
1042
+ const rows = this.trust.toRows();
1043
+ if (rows.list) tx.put("resetList", "list", rows.list);
1044
+ tx.put("clock", "clock", rows.clock);
1045
+ const hashes = rows.accepted;
1046
+ for (const e of rows.events) tx.put("resetReceipts", e.resetId, { accountName: e.accountName, oldIdentityId: e.oldIdentityId, newIdentityId: e.newIdentityId, receiptHash: hashes.find((h) => h) ?? "" });
1047
+ for (const r of rows.retired) tx.put("retiredIdentities", r, { identityId: r });
1048
+ }
1049
+
1050
+ private async persistTrust(): Promise<void> {
1051
+ await this.store.transaction((tx) => this.persistTrustIn(tx));
1052
+ }
1053
+
1054
+ private async onLeft(c: CircleState): Promise<void> {
1055
+ // Removed from a circle: stop opening its authors unless another circle still has them.
1056
+ const { pin } = this.requireAccount("left");
1057
+ const others = await this.store.read((tx) => tx.list("circles").filter(([, x]) => x.circleId !== toHex(c.circleId) && x.status === "member").flatMap(([, x]) => x.members));
1058
+ for (const m of c.members) {
1059
+ if (equalBytes(m, pin.identityId) || others.includes(toHex(m))) continue;
1060
+ await forgetAuthor(this.store, m);
1061
+ }
1062
+ }
1063
+
1064
+ private async retire(): Promise<void> {
1065
+ await this.store.transaction((tx) => {
1066
+ const meta = tx.get("meta", META);
1067
+ if (meta) tx.put("meta", META, { ...meta, state: "RETIRED" });
1068
+ for (const [k, r] of tx.list("outbox")) if (r.status === "queued" || r.status === "inflight") tx.put("outbox", k, { ...r, wire: "", status: "cancelled", reason: "retired" });
1069
+ });
1070
+ await this.secrets.delete("deviceSeed");
1071
+ this.device = undefined;
1072
+ await this.emitState();
1073
+ }
1074
+
1075
+ // Receiving and reads ---------------------------------------------------------------------------
1076
+
1077
+ receive(wire: Uint8Array): Promise<ReceiveResult> {
1078
+ return receiveRecord(this.inboxDeps(), wire);
1079
+ }
1080
+
1081
+ history(query?: HistoryQuery): Promise<HistoryEntry[]> {
1082
+ return history(this.store, this.policy, query);
1083
+ }
1084
+
1085
+ historyPage(query?: HistoryQuery): Promise<HistoryPage> {
1086
+ return historyPage(this.store, this.policy, query);
1087
+ }
1088
+
1089
+ newestEntry(query?: Pick<HistoryQuery, "authorIdentityId" | "kind">): Promise<HistoryEntry | undefined> {
1090
+ return newest(this.store, this.policy, query);
1091
+ }
1092
+
1093
+ newest(identityId: Uint8Array, kind: RecordKind): Promise<Newest | undefined> {
1094
+ return newestSlot(this.store, identityId, kind, this.now());
1095
+ }
1096
+
1097
+
1098
+
1099
+ // Circles ---------------------------------------------------------------------------------------
1100
+
1101
+ private async listCircles(): Promise<CircleSummary[]> {
1102
+ const self = this.selfPin?.identityId;
1103
+ const now = this.now();
1104
+ return this.store.read((tx) =>
1105
+ tx.list("circles").map(([, r]) => {
1106
+ const c = rowToCircle(r);
1107
+ return { circleId: c.circleId, ownerIdentityId: c.ownerIdentityId, revision: c.revision, members: c.members, enabled: c.enabled, status: c.status, snapshotAgeMs: c.verifiedAt ? now - c.verifiedAt : 0n, owned: !!self && equalBytes(c.ownerIdentityId, self) };
1108
+ })
1109
+ );
1110
+ }
1111
+
1112
+ /**
1113
+ * Bring the vault in line with the circle rows: the one path for every cause. A write the host
1114
+ * never confirmed is settled first (its exact bytes, else the host's verified current vault).
1115
+ * Then, while the rows are marked as changed, the vault is derived from them inside one
1116
+ * transaction, stored as prepared bytes and published. Nothing comes from a caller's view of
1117
+ * the state, so a change that landed in the rows reaches the vault however the process dies.
1118
+ */
1119
+ private async syncVault(): Promise<void> {
1120
+ const { account, pin } = this.requireAccount("vault");
1121
+ const recoveryKey = await this.secrets.get("recoveryKey");
1122
+ const accountSeed = await this.secrets.get("accountSeed");
1123
+ if (!recoveryKey || !accountSeed) throw new KavachError("RECOVERY_REQUIRED", "vault");
1124
+ const accountKey = account.account.publicKey;
1125
+ let conflicts = 0;
1126
+ for (let round = 0; round < 8; round++) {
1127
+ const unconfirmed = await this.store.read((tx) => tx.get("vault", VAULT));
1128
+ if (unconfirmed?.pending) {
1129
+ await this.settlePendingVault(unconfirmed, recoveryKey, accountKey);
1130
+ continue;
1131
+ }
1132
+ const prepared = await this.store.transaction((tx) => {
1133
+ const row = tx.get("vault", VAULT);
1134
+ if (!row || row.pending || !row.dirty) return undefined;
1135
+ const opened = openVault(fromHex(row.wire), recoveryKey, this.accountName, accountKey);
1136
+ const entries = reconcileEntries(opened.entries, tx.list("circles").map(([, r]) => r));
1137
+ if (sameEntries(entries, opened.entries)) {
1138
+ t9Vault(tx, { ...row, dirty: false });
1139
+ return undefined;
1140
+ }
1141
+ const revision = unbig(row.revision);
1142
+ const next = buildVault({ accountName: this.accountName, accountKey, vaultRevision: revision + 1n }, { accountSeed, lastDeviceGeneration: pin.generation, lastCertificateHash: pin.certificateHash, entries }, recoveryKey);
1143
+ t9Vault(tx, { revision: big(revision + 1n), hash: toHex(next.hash), wire: toHex(next.wire), pending: true, dirty: false });
1144
+ return { wire: next.wire, hash: toHex(next.hash), expectedRevision: revision, expectedHash: fromHex(row.hash) };
1145
+ });
1146
+ if (!prepared) return;
1147
+ const outcome = await this.transport.directory.publishVault({ expectedRevision: prepared.expectedRevision, expectedHash: prepared.expectedHash, newVault: prepared.wire });
1148
+ if (outcome.status === "COMMITTED" || outcome.status === "ALREADY_COMMITTED") {
1149
+ await this.confirmVault(prepared.hash);
1150
+ continue; // a change made while publishing has marked the row again
1151
+ }
1152
+ if (outcome.status === "CONFLICT" && outcome.current) {
1153
+ if (++conflicts >= 3) break;
1154
+ await this.adoptCurrentVault(outcome.current, recoveryKey, accountKey, prepared.expectedRevision);
1155
+ continue;
1156
+ }
1157
+ throw new KavachError("STORAGE_FAILURE", "vault", `publishVault ${outcome.status}`);
1158
+ }
1159
+ throw new KavachError("STORAGE_FAILURE", "vault", "vault conflict persisted");
1160
+ }
1161
+
1162
+ /** The host holds the prepared bytes with this hash: they are the committed vault now. A marker set meanwhile stays. */
1163
+ private async confirmVault(hash: string): Promise<void> {
1164
+ await this.store.transaction((tx) => {
1165
+ const row = tx.get("vault", VAULT);
1166
+ if (row?.pending && row.hash === hash) t9Vault(tx, { ...row, pending: false });
1167
+ });
1168
+ }
1169
+
1170
+ /**
1171
+ * Publish the exact bytes of a vault write the host never confirmed. Either the host has them
1172
+ * now or its current vault replaces the unconfirmed one locally.
1173
+ */
1174
+ private async settlePendingVault(row: VaultRow, recoveryKey: Uint8Array, accountKey: Uint8Array): Promise<void> {
1175
+ const localRevision = unbig(row.revision) - 1n;
1176
+ const outcome = await this.transport.directory.publishVault({ expectedRevision: localRevision, expectedHash: undefined, newVault: fromHex(row.wire) });
1177
+ if (outcome.status === "COMMITTED" || outcome.status === "ALREADY_COMMITTED") return this.confirmVault(row.hash);
1178
+ if (outcome.status === "CONFLICT" && outcome.current) return this.adoptCurrentVault(outcome.current, recoveryKey, accountKey, localRevision);
1179
+ throw new KavachError("STORAGE_FAILURE", "vault", `publishVault ${outcome.status}`);
1180
+ }
1181
+
1182
+ /**
1183
+ * Take the host's current vault as the local committed one. It has to open under this account's
1184
+ * recovery key, be bound to this account, and not fall behind the revision already committed
1185
+ * locally; otherwise the local copy is kept and the caller fails. The adopted vault is marked:
1186
+ * whatever the rows say and it does not is derived again on top of it.
1187
+ */
1188
+ private async adoptCurrentVault(current: Uint8Array, recoveryKey: Uint8Array, accountKey: Uint8Array, localRevision: bigint): Promise<void> {
1189
+ const { header } = openVault(current, recoveryKey, this.accountName, accountKey);
1190
+ if (header.vaultRevision < localRevision) throw new KavachError("ROLLBACK", "vault", `host vault at ${header.vaultRevision}, local at ${localRevision}`);
1191
+ const { hash } = parseVaultHeader(current);
1192
+ await this.store.transaction((tx) => t9Vault(tx, { revision: big(header.vaultRevision), hash: toHex(hash), wire: toHex(current), pending: false, dirty: true }));
1193
+ }
1194
+
1195
+ private async createCircle(): Promise<{ circleId: Uint8Array }> {
1196
+ const { account, pin } = this.requireAccount("createCircle");
1197
+ const circleId = newCircleId();
1198
+ const genesis = createCircleSnapshot({ identityId: pin.identityId, accountSecretKey: account.account.secretKey }, circleId);
1199
+ const state = acceptSnapshot(pinCircle(circleId, pin.identityId, true, "member"), genesis, pin.accountKey, pin.identityId, this.now());
1200
+ if (state.outcome !== "ACCEPTED") throw new KavachError("STORAGE_FAILURE", "createCircle");
1201
+ await this.store.transaction((tx) => t3Circle(tx, { ...circleToRow(state.state), pending: { snapshot: toHex(genesis), revision: "1" } }));
1202
+ const published = await this.publishPendingSnapshot(circleId);
1203
+ if (published !== "COMMITTED") throw new KavachError("STORAGE_FAILURE", "createCircle", published);
1204
+ await this.store.transaction((tx) => {
1205
+ t3ApplyChange(tx, { kind: "join", circleId });
1206
+ t9MarkVault(tx);
1207
+ });
1208
+ await this.syncVault();
1209
+ this.emit({ kind: "circleChanged", circleId, revision: 1n, status: "member" });
1210
+ return { circleId };
1211
+ }
1212
+
1213
+ private async requestJoin(circleId: Uint8Array, ownerIdentityId: Uint8Array, shareOnApproval: boolean): Promise<void> {
1214
+ this.requireAccount("requestJoin");
1215
+ await this.store.transaction((tx) => {
1216
+ const existing = tx.get("circles", toHex(circleId));
1217
+ if (!existing) t3Circle(tx, circleToRow(pinCircle(circleId, ownerIdentityId, shareOnApproval, "requested")));
1218
+ else if (existing.status === "left") {
1219
+ // An explicit rejoin: the pinned owner and the revision reached stay, so no older snapshot
1220
+ // can be served, and the new preference replaces the one the leave cleared.
1221
+ if (existing.ownerIdentityId !== toHex(ownerIdentityId)) throw new KavachError("NOT_AUTHORIZED", "requestJoin", "another owner is pinned for this circle");
1222
+ t3Circle(tx, { ...existing, status: "requested", enabled: shareOnApproval, pending: undefined });
1223
+ }
1224
+ });
1225
+ // The vault before the request: a recovering device finds the pin either way.
1226
+ await this.syncVault();
1227
+ const outcome = await this.transport.directory.requestJoin(circleId);
1228
+ if (outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED") throw new KavachError("STORAGE_FAILURE", "requestJoin", outcome.status);
1229
+ }
1230
+
1231
+ private async approve(circleId: Uint8Array, accountName: string): Promise<{ revision: bigint }> {
1232
+ const [resolved] = await this.transport.directory.resolveAccounts([accountName]);
1233
+ if (!resolved) throw new KavachError("METADATA_REQUIRED", "approve", "unknown account");
1234
+ const report: SyncReport = { metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0 }, epoch: "unchanged", outbox: { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 }, inbox: { streams: 0, stored: 0, keyPending: 0, invalid: 0, acked: 0, hasMore: false }, purged: 0, errors: [] };
1235
+ await this.acceptMemberCertificate(resolved.certificate, resolved.resetReceipt, report);
1236
+ const requester = pinFromCertificate(expectType<AccountDevice>(verifyDocument(resolved.certificate), DocumentType.AccountDevice));
1237
+ const pinned = await this.pinFor(requester.identityId);
1238
+ if (!pinned) throw new KavachError("ACCOUNT_KEY_CHANGED", "approve", "requester's key is quarantined");
1239
+ return this.changeMembership(circleId, { admit: requester.identityId });
1240
+ }
1241
+
1242
+ private async changeMembership(circleId: Uint8Array, change: { admit: Uint8Array } | { remove: Uint8Array } | { close: true }): Promise<{ revision: bigint }> {
1243
+ const { account, pin } = this.requireAccount("changeMembership");
1244
+ for (let attempt = 0; attempt < 3; attempt++) {
1245
+ const row = await this.store.read((tx) => tx.get("circles", toHex(circleId)));
1246
+ if (!row) throw new KavachError("NOT_AUTHORIZED", "changeMembership", "unknown circle");
1247
+ if (row.pending) {
1248
+ // An earlier change the host never confirmed goes first; this one is built on its result.
1249
+ await this.publishPendingSnapshot(circleId);
1250
+ continue;
1251
+ }
1252
+ const state = rowToCircle(row);
1253
+ if (state.status === "closed") return { revision: state.revision };
1254
+ const successor = successorSnapshot(state, change, { identityId: pin.identityId, accountSecretKey: account.account.secretKey });
1255
+ if (!successor) {
1256
+ if (!("close" in change)) return { revision: state.revision };
1257
+ // Nobody but the owner to tell: the close is local, and the vault entry goes with it.
1258
+ await this.store.transaction((tx) => {
1259
+ const r = tx.get("circles", toHex(circleId));
1260
+ if (r) t3Circle(tx, { ...r, status: "closed", enabled: false, pending: undefined }, { kind: "leave", circleId });
1261
+ else t3ApplyChange(tx, { kind: "leave", circleId });
1262
+ });
1263
+ this.emit({ kind: "circleChanged", circleId, revision: state.revision, status: "closed" });
1264
+ await this.syncVault();
1265
+ return { revision: state.revision };
1266
+ }
1267
+ await this.store.transaction((tx) => t8OwnerSnapshot(tx, circleId, successor, state.revision + 1n, "close" in change));
1268
+ const published = await this.publishPendingSnapshot(circleId);
1269
+ if (published === "COMMITTED") return { revision: await this.store.read((tx) => unbig(tx.get("circles", toHex(circleId))?.revision ?? "0")) };
1270
+ }
1271
+ throw new KavachError("STORAGE_FAILURE", "changeMembership", "conflict persisted");
1272
+ }
1273
+
1274
+ /**
1275
+ * Publish a circle's pending successor (T8) and, once the host holds it, accept it locally with
1276
+ * the revision bump, the live-pin cleanup of anyone it removed and the change event. Called by
1277
+ * the change itself and again by every sync while a successor is pending, so a change whose
1278
+ * publication failed is never left admitted-but-sharing. On CONFLICT the host's snapshot is
1279
+ * accepted and the pending successor dropped; the caller rebuilds its change on top.
1280
+ */
1281
+ private async publishPendingSnapshot(circleId: Uint8Array): Promise<"COMMITTED" | "CONFLICT" | "NONE"> {
1282
+ const stage = "publishSnapshot";
1283
+ const { pin } = this.requireAccount(stage);
1284
+ const row = await this.store.read((tx) => tx.get("circles", toHex(circleId)));
1285
+ if (!row?.pending) return "NONE";
1286
+ const pending = row.pending;
1287
+ const state = rowToCircle(row);
1288
+ const successor = fromHex(pending.snapshot);
1289
+ const expectedRevision = unbig(pending.revision) - 1n;
1290
+ const outcome = await this.transport.directory.publishOwnedCircle({ circleId, expectedRevision, expectedHash: expectedRevision === state.revision ? state.bodyHash : undefined, newSnapshot: successor });
1291
+ if (outcome.status === "COMMITTED" || outcome.status === "ALREADY_COMMITTED") {
1292
+ const accepted = acceptSnapshot(state, successor, pin.accountKey, pin.identityId, this.now());
1293
+ if (accepted.outcome !== "ACCEPTED" && accepted.outcome !== "DUPLICATE") throw new KavachError("STORAGE_FAILURE", stage, "the successor was refused locally");
1294
+ let next = pending.close ? { ...accepted.state, status: "closed" as const } : accepted.state;
1295
+ await this.store.transaction((tx) => {
1296
+ // The row may have moved while the publication was in flight (sharing disabled, the
1297
+ // circle closed): the host's answer settles membership, never the user's newer preference.
1298
+ const live = tx.get("circles", toHex(circleId));
1299
+ if (live) next = { ...next, enabled: live.enabled, status: live.status === "closed" ? "closed" : next.status };
1300
+ t3Circle(tx, { ...circleToRow(next), pending: undefined }, { kind: "snapshot", circleId, revision: next.revision });
1301
+ });
1302
+ const after = new Set(next.members.map(toHex));
1303
+ for (const m of state.members) {
1304
+ if (after.has(toHex(m)) || equalBytes(m, pin.identityId)) continue;
1305
+ const stillElsewhere = await this.store.read((tx) => tx.list("circles").some(([, c]) => c.circleId !== toHex(circleId) && c.status === "member" && c.members.includes(toHex(m))));
1306
+ if (!stillElsewhere) await forgetAuthor(this.store, m);
1307
+ }
1308
+ this.emit({ kind: "circleChanged", circleId, revision: next.revision, status: next.status });
1309
+ if (pending.close) await this.syncVault();
1310
+ return "COMMITTED";
1311
+ }
1312
+ if (outcome.status === "CONFLICT" && outcome.current) {
1313
+ const accepted = acceptSnapshot(state, outcome.current, pin.accountKey, pin.identityId, this.now());
1314
+ await this.store.transaction((tx) => t3Circle(tx, { ...circleToRow(accepted.outcome === "ACCEPTED" ? accepted.state : state), pending: undefined }));
1315
+ return "CONFLICT";
1316
+ }
1317
+ throw new KavachError("STORAGE_FAILURE", stage, outcome.status);
1318
+ }
1319
+
1320
+ private async leave(circleId: Uint8Array): Promise<void> {
1321
+ const { pin } = this.requireAccount("leave");
1322
+ const row = await this.store.read((tx) => tx.get("circles", toHex(circleId)));
1323
+ if (!row) return;
1324
+ if (row.ownerIdentityId === toHex(pin.identityId)) throw new KavachError("NOT_AUTHORIZED", "leave", "the owner closes instead");
1325
+ const state = rowToCircle(row);
1326
+ await this.store.transaction((tx) => {
1327
+ t3Circle(tx, circleToRow({ ...state, enabled: false, status: "left" }), { kind: "leave", circleId });
1328
+ });
1329
+ await this.onLeft(state);
1330
+ await this.syncVault();
1331
+ await this.transport.directory.requestLeave(circleId);
1332
+ this.emit({ kind: "circleChanged", circleId, revision: state.revision, status: "left" });
1333
+ }
1334
+
1335
+ private async setEnabled(circleId: Uint8Array, enabled: boolean): Promise<void> {
1336
+ const row = await this.store.read((tx) => tx.get("circles", toHex(circleId)));
1337
+ if (!row) throw new KavachError("NOT_AUTHORIZED", "setEnabled", "unknown circle");
1338
+ if (row.enabled === enabled) {
1339
+ await this.syncVault();
1340
+ return;
1341
+ }
1342
+ await this.store.transaction((tx) => {
1343
+ t3Circle(tx, { ...row, enabled }, { kind: enabled ? "enable" : "disable", circleId });
1344
+ });
1345
+ await this.syncVault();
1346
+ }
1347
+
1348
+ /** Close: the successor names only the owner; its publication forgets the members and removes the vault entry. */
1349
+ private async closeCircle(circleId: Uint8Array): Promise<void> {
1350
+ await this.changeMembership(circleId, { close: true });
1351
+ }
1352
+
1353
+ // Reset -----------------------------------------------------------------------------------------
1354
+
1355
+ async resetAccount(options: { confirmed: true; reauthProof?: Uint8Array }): Promise<{ identityId: Uint8Array }> {
1356
+ const stage = "resetAccount";
1357
+ if (!options.confirmed) throw new KavachError("NOT_AUTHORIZED", stage, "reset requires confirmation");
1358
+ const { pin } = this.requireAccount(stage);
1359
+ if (await this.store.read((tx) => tx.get("pendingReset", "pending"))) return this.resumeReset(stage, options.reauthProof);
1360
+ const accountSeed = newAccountSeed();
1361
+ const account = openAccount(this.accountName, accountSeed);
1362
+ const device = newDevice();
1363
+ const certificate = certify(account, device, 1n);
1364
+ const newPin = pinFromCertificate(expectType<AccountDevice>(verifyDocument(certificate), DocumentType.AccountDevice));
1365
+ const recoveryKey = newRecoveryKey();
1366
+ const vault = buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: 1n, lastCertificateHash: newPin.certificateHash, entries: [] }, recoveryKey);
1367
+ const resetId = randomBytes(16);
1368
+ const instanceId = randomBytes(16);
1369
+ // Persisted before it is sent: the host may commit the new identity and the response be lost,
1370
+ // and the only keys that can continue are these. Roots in the secret store, wires in the store.
1371
+ await this.secrets.set("pendingReset", concat(accountSeed, device.deviceSeed, recoveryKey, instanceId, device.deviceId));
1372
+ await this.store.transaction((tx) => tx.put("pendingReset", "pending", { resetId: toHex(resetId), oldIdentityId: toHex(pin.identityId), certificate: toHex(certificate), vault: toHex(vault.wire), vaultHash: toHex(vault.hash) }));
1373
+ return this.resumeReset(stage, options.reauthProof);
1374
+ }
1375
+
1376
+ /** Send (or re-send) the persisted reset with its own reset id, and finish locally once the host holds it. */
1377
+ private async resumeReset(stage: string, reauthProof?: Uint8Array): Promise<{ identityId: Uint8Array }> {
1378
+ const row = await this.store.read((tx) => tx.get("pendingReset", "pending"));
1379
+ const roots = await this.secrets.get("pendingReset");
1380
+ if (!row || !roots || roots.length !== RESET_ROOTS_BYTES) throw new KavachError("RECOVERY_REQUIRED", stage, "reset pending without its roots");
1381
+ const certificate = fromHex(row.certificate);
1382
+ expectType<AccountDevice>(verifyDocument(certificate), DocumentType.AccountDevice, stage);
1383
+ const outcome = await this.transport.directory.resetAccount({ accountName: this.accountName, expectedOldIdentityId: fromHex(row.oldIdentityId), newCertificate: certificate, newVault: fromHex(row.vault), resetId: fromHex(row.resetId), reauthProof: reauthProof ?? new Uint8Array() });
1384
+ if ((outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED") || !outcome.receipt) throw new KavachError("NOT_AUTHORIZED", stage, `host refused reset: ${outcome.status}`);
1385
+ return this.finishReset(stage, row, roots);
1386
+ }
1387
+
1388
+ /**
1389
+ * The local half of a reset the host holds: the new roots, then a new store under the new
1390
+ * identity. Nothing of the old identity's keys survives. Safe to run again after a crash at
1391
+ * any point, until the pending row is gone.
1392
+ */
1393
+ private async finishReset(stage: string, row: PendingResetRow, roots: Uint8Array): Promise<{ identityId: Uint8Array }> {
1394
+ const accountSeed = roots.subarray(0, 32);
1395
+ const deviceSeed = roots.subarray(32, 64);
1396
+ const recoveryKey = roots.subarray(64, 96);
1397
+ const instanceId = roots.subarray(96, 112);
1398
+ const deviceId = roots.subarray(112, 128);
1399
+ const account = openAccount(this.accountName, accountSeed);
1400
+ const device = deviceFromSeed(deviceSeed, deviceId);
1401
+ const certificate = fromHex(row.certificate);
1402
+ const newPin = pinFromCertificate(expectType<AccountDevice>(verifyDocument(certificate), DocumentType.AccountDevice, stage));
1403
+ await t1Secrets(this.secrets, { accountSeed, deviceSeed, recoveryKey, instanceId });
1404
+ await this.store.transaction((tx) => {
1405
+ for (const table of ["meta", "self", "pins", "certificates", "quarantine", "circles", "revision", "vault", "epochs", "stream", "outbox", "history", "historyIndex", "enqueueKeys", "newest", "readerEpochs", "subscriptions", "unresolved", "activation", "pendingReset"] as const) {
1406
+ for (const [k] of tx.list(table)) tx.delete(table, k);
1407
+ }
1408
+ t1Header(tx, { account, device, certificate, instanceId, generation: 1n });
1409
+ t9Vault(tx, { revision: "1", hash: row.vaultHash, wire: row.vault, pending: false, dirty: false });
1410
+ t1Activate(tx);
1411
+ });
1412
+ await this.secrets.delete("pendingReset");
1413
+ this.account = account;
1414
+ this.device = device;
1415
+ this.selfPin = newPin;
1416
+ try {
1417
+ await this.provider.save(this.accountName, newPin.identityId, recoveryKey);
1418
+ } catch {
1419
+ /* reported by recoveryStatus() */
1420
+ }
1421
+ await this.emitState();
1422
+ return { identityId: newPin.identityId };
1423
+ }
1424
+
1425
+ /**
1426
+ * A reset the host committed whose local half was interrupted: the roots already name the new
1427
+ * identity while the store header still names the old one, which openStore would refuse. The
1428
+ * roots are switched only after the host confirmed, so the new identity is the one to finish.
1429
+ */
1430
+ private async finishInterruptedReset(): Promise<void> {
1431
+ const row = await this.store.read((tx) => tx.get("pendingReset", "pending"));
1432
+ if (!row) return;
1433
+ const [roots, accountSeed] = await Promise.all([this.secrets.get("pendingReset"), this.secrets.get("accountSeed")]);
1434
+ if (!roots || roots.length !== RESET_ROOTS_BYTES || !accountSeed || !equalBytes(accountSeed, roots.subarray(0, 32))) return;
1435
+ await this.finishReset("load", row, roots);
1436
+ }
1437
+
1438
+ async close(): Promise<void> {
1439
+ this.listeners.clear();
1440
+ }
1441
+ }
1442
+