ftown 0.20.9 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/dist/attention-events.d.ts +1 -1
  2. package/dist/attention-store.d.ts +3 -0
  3. package/dist/attention-store.js +25 -1
  4. package/dist/attention-store.js.map +1 -1
  5. package/dist/centrifugo-client.d.ts +28 -11
  6. package/dist/centrifugo-client.js +84 -70
  7. package/dist/centrifugo-client.js.map +1 -1
  8. package/dist/cli-client.d.ts +11 -1
  9. package/dist/cli-client.js +23 -3
  10. package/dist/cli-client.js.map +1 -1
  11. package/dist/command-rpc.d.ts +2 -0
  12. package/dist/command-rpc.js +59 -17
  13. package/dist/command-rpc.js.map +1 -1
  14. package/dist/identity-reset.d.ts +4 -1
  15. package/dist/identity-reset.js +54 -6
  16. package/dist/identity-reset.js.map +1 -1
  17. package/dist/index.js +194 -288
  18. package/dist/index.js.map +1 -1
  19. package/dist/ledger-pairing-client.d.ts +28 -0
  20. package/dist/ledger-pairing-client.js +154 -0
  21. package/dist/ledger-pairing-client.js.map +1 -0
  22. package/dist/ledger-relay.d.ts +159 -0
  23. package/dist/ledger-relay.js +595 -0
  24. package/dist/ledger-relay.js.map +1 -0
  25. package/dist/ledger-reoffer.d.ts +56 -0
  26. package/dist/ledger-reoffer.js +210 -0
  27. package/dist/ledger-reoffer.js.map +1 -0
  28. package/dist/ledger-startup.d.ts +176 -0
  29. package/dist/ledger-startup.js +233 -0
  30. package/dist/ledger-startup.js.map +1 -0
  31. package/dist/local-api-server.d.ts +3 -0
  32. package/dist/local-api-server.js +68 -6
  33. package/dist/local-api-server.js.map +1 -1
  34. package/dist/mail-delivery.d.ts +26 -3
  35. package/dist/mail-delivery.js +65 -7
  36. package/dist/mail-delivery.js.map +1 -1
  37. package/dist/preview-protocol.d.ts +19 -3
  38. package/dist/preview-protocol.js +14 -0
  39. package/dist/preview-protocol.js.map +1 -1
  40. package/dist/relay-token.d.ts +45 -0
  41. package/dist/relay-token.js +138 -0
  42. package/dist/relay-token.js.map +1 -0
  43. package/dist/revocation-gating.d.ts +0 -13
  44. package/dist/revocation-gating.js +0 -16
  45. package/dist/revocation-gating.js.map +1 -1
  46. package/dist/security/bridge/identity.d.ts +2 -47
  47. package/dist/security/bridge/identity.js +3 -3
  48. package/dist/security/bridge/identity.js.map +1 -1
  49. package/dist/security/bridge/ledger-identity.d.ts +48 -0
  50. package/dist/security/bridge/ledger-identity.js +342 -0
  51. package/dist/security/bridge/ledger-identity.js.map +1 -0
  52. package/dist/security/bridge/ledger-rpc.d.ts +34 -0
  53. package/dist/security/bridge/ledger-rpc.js +58 -0
  54. package/dist/security/bridge/ledger-rpc.js.map +1 -0
  55. package/dist/security/bridge/ledger-store.d.ts +153 -0
  56. package/dist/security/bridge/ledger-store.js +825 -0
  57. package/dist/security/bridge/ledger-store.js.map +1 -0
  58. package/dist/security/bridge/metadata.d.ts +2 -2
  59. package/dist/security/bridge/permissions.js +4 -3
  60. package/dist/security/bridge/permissions.js.map +1 -1
  61. package/dist/security/bridge/policy.d.ts +43 -23
  62. package/dist/security/bridge/policy.js +214 -146
  63. package/dist/security/bridge/policy.js.map +1 -1
  64. package/dist/security/bridge/security.d.ts +212 -33
  65. package/dist/security/bridge/security.js +437 -165
  66. package/dist/security/bridge/security.js.map +1 -1
  67. package/dist/security/crypto.js +1 -1
  68. package/dist/security/crypto.js.map +1 -1
  69. package/dist/security/ledger/announce.d.ts +32 -0
  70. package/dist/security/ledger/announce.js +101 -0
  71. package/dist/security/ledger/announce.js.map +1 -0
  72. package/dist/security/ledger/channels.d.ts +5 -0
  73. package/dist/security/ledger/channels.js +21 -0
  74. package/dist/security/ledger/channels.js.map +1 -0
  75. package/dist/security/ledger/ledger.d.ts +154 -0
  76. package/dist/security/ledger/ledger.js +878 -0
  77. package/dist/security/ledger/ledger.js.map +1 -0
  78. package/dist/security/ledger/pairing.d.ts +81 -0
  79. package/dist/security/ledger/pairing.js +414 -0
  80. package/dist/security/ledger/pairing.js.map +1 -0
  81. package/dist/security/ledger/proof.d.ts +36 -0
  82. package/dist/security/ledger/proof.js +193 -0
  83. package/dist/security/ledger/proof.js.map +1 -0
  84. package/dist/security/ledger/protocol.d.ts +113 -0
  85. package/dist/security/ledger/protocol.js +16 -0
  86. package/dist/security/ledger/protocol.js.map +1 -0
  87. package/dist/security/ledger/testing.d.ts +27 -0
  88. package/dist/security/ledger/testing.js +70 -0
  89. package/dist/security/ledger/testing.js.map +1 -0
  90. package/dist/security/ledger/webauthn.d.ts +12 -0
  91. package/dist/security/ledger/webauthn.js +147 -0
  92. package/dist/security/ledger/webauthn.js.map +1 -0
  93. package/dist/security/protocol.d.ts +5 -10
  94. package/dist/security-cli.d.ts +5 -0
  95. package/dist/security-cli.js +28 -16
  96. package/dist/security-cli.js.map +1 -1
  97. package/dist/session-controller.d.ts +9 -3
  98. package/dist/session-controller.js +120 -20
  99. package/dist/session-controller.js.map +1 -1
  100. package/dist/session-inbox-protocol.d.ts +2 -0
  101. package/dist/session-preview.d.ts +20 -5
  102. package/dist/session-preview.js +185 -37
  103. package/dist/session-preview.js.map +1 -1
  104. package/dist/session-store.js +19 -1
  105. package/dist/session-store.js.map +1 -1
  106. package/dist/sessions-commands.js +41 -7
  107. package/dist/sessions-commands.js.map +1 -1
  108. package/dist/token-provider.d.ts +39 -0
  109. package/dist/token-provider.js +101 -0
  110. package/dist/token-provider.js.map +1 -0
  111. package/dist/types.d.ts +1 -1
  112. package/opencode-plugin/ftown.js +15 -2
  113. package/package.json +2 -2
  114. package/skills/ftown/SKILL.md +18 -7
  115. package/skills/ftown/references/sessions.md +38 -12
  116. package/dist/bridge-auth.d.ts +0 -31
  117. package/dist/bridge-auth.js +0 -70
  118. package/dist/bridge-auth.js.map +0 -1
  119. package/dist/pairing-client.d.ts +0 -62
  120. package/dist/pairing-client.js +0 -469
  121. package/dist/pairing-client.js.map +0 -1
  122. package/dist/rotating-token-refresher.d.ts +0 -42
  123. package/dist/rotating-token-refresher.js +0 -91
  124. package/dist/rotating-token-refresher.js.map +0 -1
  125. package/dist/security/account-crypto.d.ts +0 -33
  126. package/dist/security/account-crypto.js +0 -649
  127. package/dist/security/account-crypto.js.map +0 -1
  128. package/dist/security/account-protocol.d.ts +0 -228
  129. package/dist/security/account-protocol.js +0 -2
  130. package/dist/security/account-protocol.js.map +0 -1
  131. package/dist/security/bridge/account-client.d.ts +0 -7
  132. package/dist/security/bridge/account-client.js +0 -101
  133. package/dist/security/bridge/account-client.js.map +0 -1
  134. package/dist/security/bridge/account-state.d.ts +0 -105
  135. package/dist/security/bridge/account-state.js +0 -79
  136. package/dist/security/bridge/account-state.js.map +0 -1
  137. package/dist/security/bridge/pending-approval.d.ts +0 -21
  138. package/dist/security/bridge/pending-approval.js +0 -80
  139. package/dist/security/bridge/pending-approval.js.map +0 -1
  140. package/dist/security/bridge/pending-enrollment.d.ts +0 -33
  141. package/dist/security/bridge/pending-enrollment.js +0 -177
  142. package/dist/security/bridge/pending-enrollment.js.map +0 -1
  143. package/dist/security/bridge/revocation.d.ts +0 -14
  144. package/dist/security/bridge/revocation.js +0 -40
  145. package/dist/security/bridge/revocation.js.map +0 -1
  146. package/dist/security/pending-approval-crypto.d.ts +0 -29
  147. package/dist/security/pending-approval-crypto.js +0 -716
  148. package/dist/security/pending-approval-crypto.js.map +0 -1
  149. package/dist/security/pending-approval-protocol.d.ts +0 -329
  150. package/dist/security/pending-approval-protocol.js +0 -2
  151. package/dist/security/pending-approval-protocol.js.map +0 -1
  152. package/dist/security/security-nudge.d.ts +0 -31
  153. package/dist/security/security-nudge.js +0 -98
  154. package/dist/security/security-nudge.js.map +0 -1
@@ -0,0 +1,825 @@
1
+ /*
2
+ * The bridge's local replica of its account ledger: `<dataDir>/security/ledger.json`,
3
+ * {version:3, ledger, checkpoint, sticky, lastServerReadAt, divergences, refused}, file mode 0600, replaced atomically. Version 1 (0.21:
4
+ * without divergences/refused) and version 2 (an earlier 0.22 build) files are still read, and rewritten as version 3 on the next change.
5
+ * - Append-only rule (CONTRACT §2, §5, §12): chooseLedger decides, always with the RAW checkpoint of the stored chain as its cache. What is
6
+ * written is never rewritten. A candidate is adopted only when it strictly extends the stored chain ('ahead'), or when it diverges and
7
+ * carries an owner-signed resolve_fork that applies to the stored chain (the single owner-authorized exception). Same and behind change
8
+ * nothing; behind is reported to the caller, which may re-offer the stored chain to the server.
9
+ * - A VALID diverged chain without an applicable resolve_fork is refused: the stored chain is kept, its RAW checkpoint is unchanged, and the
10
+ * only thing taken from the refused chain is the list of removals chooseLedger saw on it (C1c `seen`), added to the sticky set so a
11
+ * browser removed on the other history is refused here at once; this bridge's own id is never added (status only:
12
+ * removedOnOtherHistory). The divergence is recorded per source (one entry each for server and relay, persisted here); a REFUSED line is
13
+ * logged once per refused head (the last 16 per source are persisted) and at most once a minute per source. The entry is cleared when a
14
+ * later chain from THAT source agrees with ours again (same, ahead or behind), and every entry by an owner-chosen adoption. A refused
15
+ * chain seen again (same source, our head, its tip) is not verified again.
16
+ * - An invalid candidate (malformed, another account, a bad record) is ignored, logged at most once per 60 s per source.
17
+ * - Sticky set: the revocations of every chain this replica adopted (its own chain included), plus, on an owner-chosen adoption, the
18
+ * revocations among the records it discarded, plus the `seen` removals of refused histories, so a removal is never silently undone.
19
+ * trusted() = applyRevocations(checkpoint, sticky) is the only checkpoint for trust decisions. This bridge's OWN removal is decided from
20
+ * the raw chain alone (ledgerRemovesBridge), never from the sticky set.
21
+ * - An empty store is bound to an account ONLY by adoptPaired (link secret proof + confirmPairedLedger) or adoptBound (the identity is
22
+ * already bound to that account and the chain lists this bridge's keys). offer() never binds an empty store.
23
+ * - Once bound (a stored ledger, or the `accountId` option) every other account's ledger is rejected.
24
+ * - The file is written only after a decision that changes it. A corrupted file throws LedgerStoreError and is never reset.
25
+ * - All operations on one file run one at a time within this process; across processes the bridge's single-instance guarantee is
26
+ * relied on. Stale `ledger.json.<uuid>.tmp` files left by a crash are removed on the first load in a process.
27
+ */
28
+ import { createHash, randomUUID } from 'node:crypto';
29
+ import { mkdir, open, readdir, rename, rm, unlink } from 'node:fs/promises';
30
+ import { basename, dirname, join, resolve } from 'node:path';
31
+ import { accountIdOf, applyRevocations, chooseLedger, forkPoint, ledgerHead, ledgerHeads, ledgerLastIssuedAt, ledgerRevocations, parseLedger, verifyLedger, } from '../ledger/ledger.js';
32
+ import { confirmPairedLedger } from '../ledger/pairing.js';
33
+ import { LEDGER_LIMITS } from '../ledger/protocol.js';
34
+ import { BridgeSecurityError } from './errors.js';
35
+ import { bindLedgerIdentity, readLedgerIdentity } from './ledger-identity.js';
36
+ /** Divergence lastSeen is rewritten at most this often while the same other head keeps arriving. */
37
+ export const DIVERGENCE_LAST_SEEN_RESOLUTION_MS = 10 * 60_000;
38
+ /** "ignored an invalid chain" is logged at most once per this interval per source. */
39
+ export const INVALID_CHAIN_LOG_SPACING_MS = 60_000;
40
+ /** A REFUSED line for a head not refused before is logged at most once per this interval per source. */
41
+ export const REFUSED_LOG_SPACING_MS = 60_000;
42
+ /** Refused heads remembered per source (persisted), and verdicts on refused chains cached per process. */
43
+ export const REFUSED_HEADS_PER_SOURCE = 16;
44
+ export const REFUSED_VERDICT_CACHE_SIZE = 16;
45
+ /** The first 8 characters of a head, as the log lines and the status show it. */
46
+ export const shortHead = (head) => head.slice(0, 8);
47
+ const messages = {
48
+ ledger_store_corrupt: 'Local account ledger is corrupted.',
49
+ ledger_store_wrong_account: 'Local account ledger belongs to a different account.',
50
+ ledger_store_pairing_rejected: 'Paired account ledger was rejected.',
51
+ ledger_store_pairing_revoked: "This computer's id was removed from the account before pairing completed.",
52
+ ledger_store_bound_rejected: 'Account ledger does not match this bridge identity.',
53
+ };
54
+ /** Not retryable; file contents and crypto errors never enter it. Read/write failures throw BridgeSecurityError('storage_unavailable'). */
55
+ export class LedgerStoreError extends Error {
56
+ code;
57
+ retryable = false;
58
+ constructor(code) {
59
+ super(messages[code]);
60
+ this.code = code;
61
+ this.name = 'LedgerStoreError';
62
+ }
63
+ }
64
+ const FILE_NAME = 'ledger.json';
65
+ const FILE_VERSION = 3;
66
+ const FILE_KEYS_V1 = ['version', 'ledger', 'checkpoint', 'sticky', 'lastServerReadAt'];
67
+ const FILE_KEYS_V2 = [...FILE_KEYS_V1, 'divergences'];
68
+ const FILE_KEYS = [...FILE_KEYS_V2, 'refused'];
69
+ const DIVERGENCE_KEYS_V2 = ['source', 'otherHead', 'otherRevision', 'firstSeen', 'lastSeen'];
70
+ const DIVERGENCE_KEYS = [...DIVERGENCE_KEYS_V2, 'forkRevision', 'removedOnOtherHistory'];
71
+ const SOURCES = ['server', 'relay'];
72
+ const NO_REVOCATIONS = { deviceIds: [], bridgeIds: [] };
73
+ const MAX_FILE_BYTES = 16 * 1024 * 1024;
74
+ const MAX_TIME = 253402300799999;
75
+ const TEMPORARY = /^\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.tmp$/;
76
+ const queues = new Map();
77
+ const swept = new Set();
78
+ function corrupt() {
79
+ throw new LedgerStoreError('ledger_store_corrupt');
80
+ }
81
+ function errorCode(error) {
82
+ return typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string' ? error.code : undefined;
83
+ }
84
+ function isRecord(value) {
85
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
86
+ }
87
+ function isAccountId(value) {
88
+ if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(value))
89
+ return false;
90
+ const raw = Buffer.from(value, 'base64url');
91
+ return raw.length === 32 && raw.toString('base64url') === value;
92
+ }
93
+ function isTime(value) {
94
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= MAX_TIME;
95
+ }
96
+ /** Sorted-key JSON, to compare a stored checkpoint with a recomputed one. */
97
+ function canonical(value) {
98
+ if (Array.isArray(value)) {
99
+ const items = value;
100
+ return `[${items.map(canonical).join(',')}]`;
101
+ }
102
+ if (isRecord(value))
103
+ return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
104
+ return JSON.stringify(value);
105
+ }
106
+ function uniqueStrings(value) {
107
+ if (!Array.isArray(value))
108
+ return corrupt();
109
+ const items = value;
110
+ const result = [];
111
+ for (const item of items) {
112
+ if (typeof item !== 'string' || result.indexOf(item) !== -1)
113
+ return corrupt();
114
+ result.push(item);
115
+ }
116
+ return result;
117
+ }
118
+ function union(left, right) {
119
+ const merge = (base, extra) => base.concat(extra.filter((id, index) => base.indexOf(id) === -1 && extra.indexOf(id) === index));
120
+ return { deviceIds: merge(left.deviceIds, right.deviceIds), bridgeIds: merge(left.bridgeIds, right.bridgeIds) };
121
+ }
122
+ function covers(sticky, ids) {
123
+ return ids.deviceIds.every(id => sticky.deviceIds.indexOf(id) !== -1) && ids.bridgeIds.every(id => sticky.bridgeIds.indexOf(id) !== -1);
124
+ }
125
+ function sameSticky(left, right) {
126
+ return left.deviceIds.length === right.deviceIds.length && left.bridgeIds.length === right.bridgeIds.length && covers(left, right);
127
+ }
128
+ function isOwnBridge(bridge, own) {
129
+ return bridge.bridgeId === own.bridgeId && bridge.endpoint.id === own.endpointId && bridge.endpoint.publicKey === own.endpointPublicKey
130
+ && bridge.authPublicKey === own.authPublicKey;
131
+ }
132
+ /**
133
+ * Proof in the RAW chain `ledger` that THIS bridge was removed: an add_bridge of exactly these keys (bridgeId, endpoint id, endpoint public
134
+ * key and auth key), followed by a revoke_bridge of its bridgeId. A revoke_bridge of an id the chain never added with these keys is only a
135
+ * tombstone (C1b: revoking a non-member is valid) and proves nothing about this bridge. Sticky entries and refused histories never count:
136
+ * this is the only evidence that resets the identity, on a running bridge and at startup alike.
137
+ */
138
+ export function ledgerRemovesBridge(ledger, own) {
139
+ let member = false;
140
+ for (const record of ledger.transitions) {
141
+ const operation = record.payload.operation;
142
+ if (operation.type === 'add_bridge' && isOwnBridge(operation.bridge, own))
143
+ member = true;
144
+ if (operation.type === 'revoke_bridge' && operation.bridgeId === own.bridgeId && member)
145
+ return true;
146
+ }
147
+ return false;
148
+ }
149
+ /** 'active': trusted() lists this bridge with exactly its keys. 'revoked': the stored chain itself proves its removal (ledgerRemovesBridge).
150
+ * Anything else, a tombstone, a sticky-only id or a removal seen on a refused history included, is 'absent': fail closed, never a reset. */
151
+ function statusOf(state, own) {
152
+ if (state === null)
153
+ return 'absent';
154
+ if (state.trusted.bridges.some(bridge => isOwnBridge(bridge, own)))
155
+ return 'active';
156
+ return ledgerRemovesBridge(state.ledger, own) ? 'revoked' : 'absent';
157
+ }
158
+ /** Is a bridge with exactly these keys a member of the trusted roster (any bridgeId when `bridgeId` is null)? */
159
+ function lists(trusted, bridgeId, endpointId, authPublicKey) {
160
+ return trusted.bridges.some((bridge) => (bridgeId === null || bridge.bridgeId === bridgeId)
161
+ && bridge.endpoint.id === endpointId && bridge.authPublicKey === authPublicKey);
162
+ }
163
+ function cacheOf(stored) {
164
+ // The cache is the stored chain's raw checkpoint, never the trusted one.
165
+ return { head: stored.checkpoint.head, checkpoint: stored.checkpoint };
166
+ }
167
+ async function tipOf(ledger) {
168
+ const last = ledger.transitions[ledger.transitions.length - 1];
169
+ return { revision: ledger.transitions.length, head: last === undefined ? await accountIdOf(ledger.genesis.payload) : await ledgerHead(last.payload) };
170
+ }
171
+ /** The sticky set after an adoption: the adopted chain's own revocations, plus, for an owner-chosen history, those of the local records it
172
+ * discarded (C1 adopts a resolution only when it carries them, so those are normally already in the chain; merged regardless). */
173
+ function adoptedSticky(stored, choice) {
174
+ const discarded = choice.relation === 'diverged' ? { deviceIds: choice.discarded.deviceIds, bridgeIds: choice.discarded.bridgeIds } : NO_REVOCATIONS;
175
+ return union(union(stored, ledgerRevocations(choice.ledger)), discarded);
176
+ }
177
+ function withoutSource(divergences, source) {
178
+ return divergences.filter(entry => entry.source !== source);
179
+ }
180
+ function ordered(divergences) {
181
+ return divergences.sort((left, right) => SOURCES.indexOf(left.source) - SOURCES.indexOf(right.source));
182
+ }
183
+ function adoptionLine(stored, choice) {
184
+ const discarded = choice.discarded.records.length;
185
+ return `[ledger] adopted an owner-chosen history: fork at revision ${stored.checkpoint.revision - discarded}, discarded ${discarded} of our records`;
186
+ }
187
+ /** "ignored an invalid chain from <source>", at most once per INVALID_CHAIN_LOG_SPACING_MS per source. */
188
+ function ignored(replica, source, now) {
189
+ const last = replica.ignoredAt.get(source);
190
+ if (last !== undefined && now >= last && now - last < INVALID_CHAIN_LOG_SPACING_MS)
191
+ return;
192
+ replica.ignoredAt.set(source, now);
193
+ replica.log(`[ledger] ignored an invalid chain from ${source}`);
194
+ }
195
+ const isRevision = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= LEDGER_LIMITS.maxTransitions;
196
+ /** Version 3 entries; a version 2 entry (no forkRevision / removedOnOtherHistory) reads as fork unknown, not removing us. */
197
+ function parseDivergences(value, version) {
198
+ if (!Array.isArray(value) || value.length > SOURCES.length)
199
+ return corrupt();
200
+ const expected = version === 2 ? DIVERGENCE_KEYS_V2 : DIVERGENCE_KEYS;
201
+ const items = value;
202
+ const result = [];
203
+ for (const item of items) {
204
+ if (!isRecord(item))
205
+ return corrupt();
206
+ const keys = Object.keys(item);
207
+ if (keys.length !== expected.length || keys.some(key => expected.indexOf(key) === -1))
208
+ return corrupt();
209
+ const { source, otherHead, otherRevision, firstSeen, lastSeen } = item;
210
+ const forkRevision = version === 2 ? null : item.forkRevision;
211
+ const removedOnOtherHistory = version === 2 ? false : item.removedOnOtherHistory;
212
+ if ((source !== 'server' && source !== 'relay') || result.some(entry => entry.source === source))
213
+ return corrupt();
214
+ if (!isAccountId(otherHead) || !isRevision(otherRevision) || !isTime(firstSeen) || !isTime(lastSeen) || firstSeen > lastSeen)
215
+ return corrupt();
216
+ if ((forkRevision !== null && (!isRevision(forkRevision) || forkRevision >= otherRevision)) || typeof removedOnOtherHistory !== 'boolean')
217
+ return corrupt();
218
+ result.push({ source, otherHead, otherRevision, forkRevision, removedOnOtherHistory, firstSeen, lastSeen });
219
+ }
220
+ return ordered(result);
221
+ }
222
+ function parseRefused(value) {
223
+ if (!isRecord(value))
224
+ return corrupt();
225
+ const keys = Object.keys(value);
226
+ if (keys.length !== 2 || keys.indexOf('server') === -1 || keys.indexOf('relay') === -1)
227
+ return corrupt();
228
+ const heads = (list) => {
229
+ const items = uniqueStrings(list);
230
+ return items.length <= REFUSED_HEADS_PER_SOURCE && items.every(isAccountId) ? items : corrupt();
231
+ };
232
+ return { server: heads(value.server), relay: heads(value.relay) };
233
+ }
234
+ /** Refused heads of a version 2 file: the heads its entries name. */
235
+ function refusedOf(divergences) {
236
+ return { server: divergences.filter(entry => entry.source === 'server').map(entry => entry.otherHead),
237
+ relay: divergences.filter(entry => entry.source === 'relay').map(entry => entry.otherHead) };
238
+ }
239
+ const NO_REFUSED = { server: [], relay: [] };
240
+ /** Operations on one file run one at a time within this process, whichever store instance or helper issues them. */
241
+ function serialized(file, run) {
242
+ const previous = queues.get(file) ?? Promise.resolve();
243
+ const next = previous.then(run);
244
+ const settled = next.then(() => undefined, () => undefined);
245
+ queues.set(file, settled);
246
+ void settled.then(() => { if (queues.get(file) === settled)
247
+ queues.delete(file); });
248
+ return next;
249
+ }
250
+ async function syncDirectory(directory) {
251
+ let handle;
252
+ try {
253
+ handle = await open(directory, 'r');
254
+ }
255
+ catch {
256
+ return;
257
+ }
258
+ try {
259
+ await handle.sync();
260
+ }
261
+ catch { /* directory fsync is unsupported on some platforms */ }
262
+ finally {
263
+ await handle.close();
264
+ }
265
+ }
266
+ /** Once per process: removes `ledger.json.<uuid>.tmp` left by a crash mid-write. Runs inside the queue, so no own write is in flight. */
267
+ async function sweepTemporaries(file) {
268
+ if (swept.has(file))
269
+ return;
270
+ swept.add(file);
271
+ const prefix = basename(file);
272
+ let names;
273
+ try {
274
+ names = await readdir(dirname(file));
275
+ }
276
+ catch {
277
+ return;
278
+ }
279
+ for (const name of names) {
280
+ if (name.indexOf(prefix) === 0 && TEMPORARY.test(name.slice(prefix.length)))
281
+ await rm(join(dirname(file), name), { force: true }).catch(() => undefined);
282
+ }
283
+ }
284
+ function replicaOf(dataDir, options) {
285
+ const accountId = options.accountId ?? null;
286
+ if (accountId !== null && !isAccountId(accountId))
287
+ throw new BridgeSecurityError('wrong_context');
288
+ return {
289
+ dataDir, file: resolve(join(dataDir, 'security', FILE_NAME)), accountId, clock: options.now ?? Date.now,
290
+ log: options.log ?? (line => console.warn(line)), bridgeId: options.bridgeId ?? null, ignoredAt: new Map(), refusedLoggedAt: new Map(),
291
+ verdicts: new Map(), memo: null,
292
+ };
293
+ }
294
+ async function readText(replica) {
295
+ let handle;
296
+ try {
297
+ handle = await open(replica.file, 'r');
298
+ }
299
+ catch (error) {
300
+ if (errorCode(error) === 'ENOENT')
301
+ return null;
302
+ throw new BridgeSecurityError('storage_unavailable');
303
+ }
304
+ try {
305
+ const info = await handle.stat();
306
+ if (!info.isFile() || info.size > MAX_FILE_BYTES)
307
+ return corrupt();
308
+ return await handle.readFile('utf8');
309
+ }
310
+ catch (error) {
311
+ if (error instanceof LedgerStoreError)
312
+ throw error;
313
+ throw new BridgeSecurityError('storage_unavailable');
314
+ }
315
+ finally {
316
+ await handle.close();
317
+ }
318
+ }
319
+ /** Full check of a stored file: shape, the chain from genesis, the checkpoint it yields, and sticky covering its own revocations. */
320
+ async function decode(replica, text) {
321
+ try {
322
+ const record = JSON.parse(text);
323
+ if (!isRecord(record))
324
+ return corrupt();
325
+ const keys = Object.keys(record);
326
+ const version = record.version === 1 || record.version === 2 || record.version === FILE_VERSION ? record.version : corrupt();
327
+ const expected = version === 1 ? FILE_KEYS_V1 : version === 2 ? FILE_KEYS_V2 : FILE_KEYS;
328
+ if (keys.length !== expected.length || keys.some(key => expected.indexOf(key) === -1))
329
+ return corrupt();
330
+ const divergences = version === 1 ? [] : parseDivergences(record.divergences, version);
331
+ const refused = version === 1 ? NO_REFUSED : version === 2 ? refusedOf(divergences) : parseRefused(record.refused);
332
+ const lastServerReadAt = record.lastServerReadAt === null ? null : isTime(record.lastServerReadAt) ? record.lastServerReadAt : corrupt();
333
+ if (!isRecord(record.sticky))
334
+ return corrupt();
335
+ const stickyKeys = Object.keys(record.sticky);
336
+ if (stickyKeys.length !== 2 || stickyKeys.indexOf('deviceIds') === -1 || stickyKeys.indexOf('bridgeIds') === -1)
337
+ return corrupt();
338
+ const sticky = { deviceIds: uniqueStrings(record.sticky.deviceIds), bridgeIds: uniqueStrings(record.sticky.bridgeIds) };
339
+ const ledger = parseLedger(record.ledger);
340
+ // An accepted chain stays valid when the clock later moves back: verify at no earlier than its last record.
341
+ const checkpoint = await verifyLedger(ledger, Math.max(replica.clock(), ledgerLastIssuedAt(ledger)));
342
+ if (canonical(record.checkpoint) !== canonical(checkpoint))
343
+ return corrupt();
344
+ if (!covers(sticky, ledgerRevocations(ledger)))
345
+ return corrupt();
346
+ return { ledger, checkpoint, trusted: applyRevocations(checkpoint, sticky), sticky, lastServerReadAt, divergences, refused };
347
+ }
348
+ catch {
349
+ return corrupt();
350
+ }
351
+ }
352
+ /** null when absent. Re-reads the file on every call and re-verifies it whenever its text changed. */
353
+ async function load(replica) {
354
+ await sweepTemporaries(replica.file);
355
+ const text = await readText(replica);
356
+ if (text === null) {
357
+ replica.memo = null;
358
+ return null;
359
+ }
360
+ const state = replica.memo !== null && replica.memo.text === text ? replica.memo.state : await decode(replica, text);
361
+ if (replica.accountId !== null && state.checkpoint.accountId !== replica.accountId)
362
+ throw new LedgerStoreError('ledger_store_wrong_account');
363
+ replica.memo = { text, state };
364
+ return state;
365
+ }
366
+ async function save(replica, state) {
367
+ const text = JSON.stringify({
368
+ version: FILE_VERSION, ledger: state.ledger, checkpoint: state.checkpoint, sticky: state.sticky, lastServerReadAt: state.lastServerReadAt,
369
+ divergences: state.divergences, refused: state.refused,
370
+ });
371
+ const directory = dirname(replica.file);
372
+ const temporary = `${replica.file}.${randomUUID()}.tmp`;
373
+ replica.memo = null;
374
+ try {
375
+ await mkdir(directory, { recursive: true, mode: 0o700 });
376
+ const handle = await open(temporary, 'wx', 0o600);
377
+ try {
378
+ await handle.writeFile(text);
379
+ await handle.sync();
380
+ }
381
+ finally {
382
+ await handle.close();
383
+ }
384
+ await rename(temporary, replica.file);
385
+ }
386
+ catch {
387
+ await rm(temporary, { force: true }).catch(() => undefined);
388
+ throw new BridgeSecurityError('storage_unavailable');
389
+ }
390
+ await syncDirectory(directory);
391
+ replica.memo = { text, state };
392
+ }
393
+ async function erase(replica) {
394
+ replica.memo = null;
395
+ try {
396
+ await unlink(replica.file);
397
+ }
398
+ catch (error) {
399
+ if (errorCode(error) !== 'ENOENT')
400
+ throw new BridgeSecurityError('storage_unavailable');
401
+ }
402
+ await syncDirectory(dirname(replica.file));
403
+ }
404
+ /** The removals of a refused history this replica enforces (C1c `seen`): every device id, every bridge id except this bridge's own, and no
405
+ * bridge id at all when this store does not know its own. */
406
+ function enforced(replica, seen) {
407
+ const own = replica.bridgeId;
408
+ return { deviceIds: seen.deviceIds.slice(), bridgeIds: own === null ? [] : seen.bridgeIds.filter(id => id !== own) };
409
+ }
410
+ function verdictKey(source, ours, tip) {
411
+ return `${source}\n${ours}\n${tip.revision}:${tip.head}`;
412
+ }
413
+ function rememberVerdict(replica, key, verdict) {
414
+ replica.verdicts.delete(key);
415
+ replica.verdicts.set(key, verdict);
416
+ for (const oldest of replica.verdicts.keys()) {
417
+ if (replica.verdicts.size <= REFUSED_VERDICT_CACHE_SIZE)
418
+ break;
419
+ replica.verdicts.delete(oldest);
420
+ }
421
+ }
422
+ /**
423
+ * Is the diverged `theirs` (record heads) the branch a resolve_fork in OUR chain already discarded? The resolve_fork must name our fork
424
+ * point with them (forkHead) and their first record after it (discardedFirst, as C1's resolves() identifies a branch), and their tip must
425
+ * be the very tip it discarded (discardedHead at discardedRevision) or that first record itself. A tip elsewhere on that branch is not
426
+ * covered: past discardedHead it may hold records the owner never saw (C1c: the owner resolves again), and below it nothing stored proves
427
+ * it is a prefix of the discarded tip. Pure; O(our records).
428
+ */
429
+ function coveredBy(ledger, ours, theirs, fork) {
430
+ if (fork + 1 >= theirs.length || fork >= ours.length)
431
+ return false;
432
+ const tipRevision = theirs.length - 1;
433
+ for (const record of ledger.transitions) {
434
+ const operation = record.payload.operation;
435
+ if (operation.type !== 'resolve_fork' || operation.forkHead !== ours[fork] || operation.discardedFirst !== theirs[fork + 1])
436
+ continue;
437
+ if (tipRevision === fork + 1 || (tipRevision === operation.discardedRevision && theirs[tipRevision] === operation.discardedHead))
438
+ return true;
439
+ }
440
+ return false;
441
+ }
442
+ /** What a keep/diverged verdict needs beyond chooseLedger's: the fork point and whether one of our resolve_forks covers the candidate. */
443
+ async function refusedVerdict(local, other, seen) {
444
+ let ours;
445
+ let theirs;
446
+ try {
447
+ ours = await ledgerHeads(local);
448
+ theirs = await ledgerHeads(other);
449
+ }
450
+ catch {
451
+ return { seen, forkRevision: null, covered: false };
452
+ }
453
+ const forkRevision = forkPoint(ours, theirs)?.revision ?? null;
454
+ return { seen, forkRevision, covered: forkRevision !== null && coveredBy(local, ours, theirs, forkRevision) };
455
+ }
456
+ /**
457
+ * Clearing rule 2: the divergences an adopted `ledger` settles. An entry whose recorded other tip is exactly the tip a resolve_fork in
458
+ * `ledger` discards (discardedHead at discardedRevision) is over, whatever source recorded it: the owner chose our side against it. Only
459
+ * the exact tip is matched; any other entry stays until its own source agrees (clearing rule 1).
460
+ */
461
+ function unresolved(ledger, divergences) {
462
+ const discarded = new Set();
463
+ for (const record of ledger.transitions) {
464
+ const operation = record.payload.operation;
465
+ if (operation.type === 'resolve_fork')
466
+ discarded.add(`${operation.discardedRevision}:${operation.discardedHead}`);
467
+ }
468
+ return divergences.filter(entry => !discarded.has(`${entry.otherRevision}:${entry.otherHead}`));
469
+ }
470
+ /**
471
+ * The append-only rule for an untrusted candidate on a bound store (C1's chooseLedger table). Never binds an empty store.
472
+ * - adopt (ahead, or diverged with an applicable owner-signed resolve_fork): store choice.ledger; sticky += its revocations (+ the discarded
473
+ * ones); this source's divergence is cleared (every divergence on an owner-chosen adoption).
474
+ * - keep / same, behind: nothing changes, except that a divergence recorded FROM THIS SOURCE is cleared (the histories agree again).
475
+ * - keep / diverged: ours is kept and decides; the divergence is recorded (refuse) and the removals chooseLedger saw on it (`seen`) are
476
+ * added to the sticky set, except this bridge's own id (status only). A chain already judged (same source, our head, its tip) is not
477
+ * verified again. A diverged chain that one of OUR resolve_forks already discards is `covered` (cover): answered keep/'behind', its
478
+ * removals enforced, never recorded or logged.
479
+ * Clearing a recorded divergence: (1) an offer from the SAME source that is same, behind, covered or adopted ahead clears that source's
480
+ * entry; (2) any adoption clears every entry whose tip a resolve_fork of the adopted chain discards (exact tip), and an owner-chosen
481
+ * adoption clears all of them.
482
+ * - reject: ignored (logged, spaced).
483
+ */
484
+ async function decide(replica, candidate, source) {
485
+ const now = replica.clock();
486
+ const stored = await load(replica);
487
+ const answer = (decision, relation, tip, state = stored, discarded = []) => ({ decision, relation, candidate: tip, state, discarded, covered: false });
488
+ const settled = async (verdict, tip, from) => verdict.covered
489
+ ? { ...answer('keep', 'behind', tip, await cover(replica, from, source, verdict)), covered: true }
490
+ : answer('keep', 'diverged', tip, await refuse(replica, from, source, tip, now, verdict));
491
+ if (source !== 'server' && source !== 'relay')
492
+ return answer('reject', null, null);
493
+ if (stored === null)
494
+ return answer('reject', null, null);
495
+ let ledger;
496
+ try {
497
+ ledger = parseLedger(candidate);
498
+ }
499
+ catch {
500
+ ignored(replica, source, now);
501
+ return answer('reject', null, null);
502
+ }
503
+ const tip = await tipOf(ledger);
504
+ if (await accountIdOf(ledger.genesis.payload) !== stored.checkpoint.accountId) {
505
+ ignored(replica, source, now);
506
+ return answer('reject', null, tip);
507
+ }
508
+ const key = verdictKey(source, stored.checkpoint.head, tip);
509
+ const known = replica.verdicts.get(key);
510
+ // Same payloads (hash-linked to this tip) against the same chain of ours: chooseLedger would answer keep/diverged again.
511
+ if (known !== undefined)
512
+ return settled(known, tip, stored);
513
+ const choice = await chooseLedger(stored.ledger, ledger, now, cacheOf(stored));
514
+ if (choice.decision === 'reject') {
515
+ ignored(replica, source, now);
516
+ return answer('reject', choice.relation, tip);
517
+ }
518
+ if (choice.decision === 'adopt') {
519
+ const divergent = choice.relation === 'diverged';
520
+ // choice.ledger, never the candidate as passed in: our own prefix byte-for-byte, then the candidate's verified records.
521
+ const adopted = await commit(replica, {
522
+ ledger: choice.ledger, checkpoint: choice.checkpoint, sticky: adoptedSticky(stored.sticky, choice), lastServerReadAt: stored.lastServerReadAt,
523
+ // An owner-chosen history replaces ours: every recorded divergence was measured against the chain it discarded. An extension clears
524
+ // this source's entry (rule 1) and every entry whose tip one of its resolve_forks discards (rule 2), whichever source brought it.
525
+ divergences: divergent ? [] : unresolved(choice.ledger, withoutSource(stored.divergences, source)), refused: stored.refused,
526
+ });
527
+ if (adopted === null)
528
+ return answer('reject', choice.relation, tip);
529
+ if (divergent)
530
+ replica.log(adoptionLine(stored, choice));
531
+ return answer('adopt', choice.relation, tip, adopted, choice.discarded.records);
532
+ }
533
+ if (choice.relation === 'diverged') {
534
+ const verdict = await refusedVerdict(stored.ledger, ledger, choice.seen);
535
+ rememberVerdict(replica, key, verdict);
536
+ return settled(verdict, tip, stored);
537
+ }
538
+ // same / behind: the histories agree; a divergence recorded FROM THIS SOURCE is over (another source's stays).
539
+ if (!stored.divergences.some(entry => entry.source === source))
540
+ return answer('keep', choice.relation, tip);
541
+ return answer('keep', choice.relation, tip, await persist(replica, { ...stored, divergences: withoutSource(stored.divergences, source) }));
542
+ }
543
+ /**
544
+ * keep / diverged, but covered: our own resolve_fork already discards that chain, so the source is behind the owner's decision. Like
545
+ * behind: this source's divergence entry is over (rule 1) and nothing is logged or recorded (rule 3); the chain's removals are still
546
+ * enforced (`seen`, never our own id), exactly as for any refused chain. Written only when something changes.
547
+ */
548
+ async function cover(replica, stored, source, verdict) {
549
+ const sticky = union(stored.sticky, enforced(replica, verdict.seen));
550
+ const divergences = withoutSource(stored.divergences, source);
551
+ const stickyChanged = !sameSticky(sticky, stored.sticky);
552
+ if (!stickyChanged && divergences.length === stored.divergences.length)
553
+ return stored;
554
+ if (stickyChanged) {
555
+ const merged = await commit(replica, { ledger: stored.ledger, checkpoint: stored.checkpoint, sticky, lastServerReadAt: stored.lastServerReadAt, divergences, refused: stored.refused });
556
+ if (merged !== null)
557
+ return merged;
558
+ }
559
+ return persist(replica, { ...stored, divergences });
560
+ }
561
+ /**
562
+ * keep / diverged. Our chain and checkpoint are untouched; the file is written only when something changes:
563
+ * - sticky += the removals the refused history shows (enforced(): never this bridge's own id, which only sets removedOnOtherHistory);
564
+ * - the source's entry is created, or replaced when the source shows a head it never showed before (an older refused head coming back
565
+ * changes nothing), and lastSeen is refreshed at most every DIVERGENCE_LAST_SEEN_RESOLUTION_MS;
566
+ * - the head joins the source's refused heads (the last REFUSED_HEADS_PER_SOURCE). A REFUSED line is logged only for a head no source
567
+ * refused before, and at most once per REFUSED_LOG_SPACING_MS per source.
568
+ */
569
+ async function refuse(replica, stored, source, tip, now, verdict) {
570
+ const removesUs = replica.bridgeId !== null && verdict.seen.bridgeIds.indexOf(replica.bridgeId) !== -1;
571
+ const sticky = union(stored.sticky, enforced(replica, verdict.seen));
572
+ const heads = stored.refused[source];
573
+ const fresh = heads.indexOf(tip.head) === -1;
574
+ const refused = fresh ? { ...stored.refused, [source]: [...heads, tip.head].slice(-REFUSED_HEADS_PER_SOURCE) } : stored.refused;
575
+ if (fresh && stored.refused.server.indexOf(tip.head) === -1 && stored.refused.relay.indexOf(tip.head) === -1) {
576
+ const last = replica.refusedLoggedAt.get(source);
577
+ if (last === undefined || now < last || now - last >= REFUSED_LOG_SPACING_MS) {
578
+ replica.refusedLoggedAt.set(source, now);
579
+ replica.log(`[ledger] REFUSED a different account history from ${source} (head ${shortHead(tip.head)}, revision ${tip.revision}); `
580
+ + `keeping ours (head ${shortHead(stored.checkpoint.head)}, revision ${stored.checkpoint.revision})${removesUs ? '; that history removes this computer' : ''}`);
581
+ }
582
+ }
583
+ const known = stored.divergences.find(entry => entry.source === source);
584
+ let entry = known;
585
+ if (known === undefined || (fresh && known.otherHead !== tip.head)) {
586
+ const firstSeen = known?.firstSeen ?? now;
587
+ entry = { source, otherHead: tip.head, otherRevision: tip.revision, forkRevision: verdict.forkRevision, removedOnOtherHistory: removesUs,
588
+ firstSeen, lastSeen: Math.max(now, firstSeen) };
589
+ }
590
+ else if ((removesUs && !known.removedOnOtherHistory) || now < known.lastSeen || now - known.lastSeen >= DIVERGENCE_LAST_SEEN_RESOLUTION_MS) {
591
+ entry = { ...known, removedOnOtherHistory: known.removedOnOtherHistory || removesUs, lastSeen: Math.max(now, known.lastSeen) };
592
+ }
593
+ const stickyChanged = !sameSticky(sticky, stored.sticky);
594
+ if (!stickyChanged && entry === known && refused === stored.refused)
595
+ return stored;
596
+ const divergences = entry === known ? stored.divergences : ordered([...withoutSource(stored.divergences, source), entry]);
597
+ if (stickyChanged) {
598
+ const merged = await commit(replica, { ledger: stored.ledger, checkpoint: stored.checkpoint, sticky, lastServerReadAt: stored.lastServerReadAt, divergences, refused });
599
+ if (merged !== null)
600
+ return merged;
601
+ replica.log('[ledger] could not enforce the removals of a refused history: too many removals recorded on this computer');
602
+ }
603
+ return persist(replica, { ...stored, divergences, refused });
604
+ }
605
+ /** Writes a state whose chain and sticky set are unchanged (only divergences or lastServerReadAt differ). */
606
+ async function persist(replica, state) {
607
+ await save(replica, state);
608
+ return state;
609
+ }
610
+ /** Persists a decided state; null (nothing written) when its sticky set exceeds applyRevocations' limits. */
611
+ async function commit(replica, next) {
612
+ let trusted;
613
+ try {
614
+ trusted = applyRevocations(next.checkpoint, next.sticky);
615
+ }
616
+ catch {
617
+ return null;
618
+ }
619
+ const state = { ...next, trusted };
620
+ await save(replica, state);
621
+ return state;
622
+ }
623
+ /**
624
+ * Binds an empty store, or applies the append-only rule to a bound one, for a chain the caller has authenticated (a refused or behind
625
+ * chain leaves the stored one deciding). The resulting trusted roster must list this bridge (`isMember`); on any failure throws
626
+ * LedgerStoreError(code) and writes nothing. Returns trusted().
627
+ */
628
+ async function admit(replica, stored, ledger, now, isMember, code) {
629
+ let next;
630
+ let adoption = null;
631
+ if (stored === null) {
632
+ let first;
633
+ try {
634
+ first = await chooseLedger(null, ledger, now);
635
+ }
636
+ catch {
637
+ throw new LedgerStoreError(code);
638
+ }
639
+ next = { ledger: first.ledger, checkpoint: first.checkpoint, sticky: union(NO_REVOCATIONS, ledgerRevocations(first.ledger)), lastServerReadAt: null,
640
+ divergences: [], refused: NO_REFUSED };
641
+ }
642
+ else {
643
+ const choice = await chooseLedger(stored.ledger, ledger, now, cacheOf(stored));
644
+ if (choice.decision === 'reject')
645
+ throw new LedgerStoreError(code);
646
+ if (choice.decision === 'adopt') {
647
+ const divergent = choice.relation === 'diverged';
648
+ next = { ledger: choice.ledger, checkpoint: choice.checkpoint, sticky: adoptedSticky(stored.sticky, choice), lastServerReadAt: stored.lastServerReadAt,
649
+ divergences: divergent ? [] : unresolved(choice.ledger, stored.divergences), refused: stored.refused };
650
+ if (divergent)
651
+ adoption = adoptionLine(stored, choice);
652
+ }
653
+ else {
654
+ // keep (same, behind or a refused divergence): ours decides; of a refused history only its removals are enforced (C1c `seen`, never
655
+ // this bridge's own id).
656
+ next = { ledger: stored.ledger, checkpoint: stored.checkpoint, sticky: union(stored.sticky, enforced(replica, choice.seen)),
657
+ lastServerReadAt: stored.lastServerReadAt, divergences: stored.divergences, refused: stored.refused };
658
+ }
659
+ }
660
+ let trusted;
661
+ try {
662
+ trusted = applyRevocations(next.checkpoint, next.sticky);
663
+ }
664
+ catch {
665
+ throw new LedgerStoreError(code);
666
+ }
667
+ if (!isMember(trusted))
668
+ throw new LedgerStoreError(code);
669
+ if (stored === null || next.ledger !== stored.ledger || !sameSticky(next.sticky, stored.sticky))
670
+ await save(replica, { ...next, trusted });
671
+ if (adoption !== null)
672
+ replica.log(adoption);
673
+ return structuredClone(trusted);
674
+ }
675
+ export class BridgeLedgerStore {
676
+ replica;
677
+ constructor(dataDir, options = {}) {
678
+ this.replica = replicaOf(dataDir, options);
679
+ }
680
+ /** The stored state, or null while no ledger is stored. Throws LedgerStoreError for a corrupted or wrong-account file. */
681
+ read() {
682
+ return serialized(this.replica.file, async () => structuredClone(await load(this.replica)));
683
+ }
684
+ /** applyRevocations(checkpoint, sticky) of the stored chain, or null while no ledger is stored. */
685
+ trusted() {
686
+ return serialized(this.replica.file, async () => {
687
+ const state = await load(this.replica);
688
+ return state === null ? null : structuredClone(state.trusted);
689
+ });
690
+ }
691
+ /** The startup decision (ledger-startup ownStatusAction): 'active' iff trusted() lists this bridge with exactly its keys; 'revoked' iff the
692
+ * stored RAW chain proves its removal (ledgerRemovesBridge: it added these keys, then revoked the id); else 'absent' (a tombstone, a
693
+ * sticky-only id or a removal seen on a refused history is no removal). The same rule as BridgeSecurityService's running check. */
694
+ async ownBridgeStatus(own) {
695
+ return serialized(this.replica.file, async () => statusOf(await load(this.replica), own));
696
+ }
697
+ /**
698
+ * Offers an untrusted candidate chain from the server or the relay (see decide for the rule). Never binds an empty store, and never
699
+ * throws for a bad candidate: it is rejected. Throws LedgerStoreError for a corrupted or wrong-account local file, and
700
+ * BridgeSecurityError('storage_unavailable') when the file cannot be read or written.
701
+ */
702
+ offer(candidate, source) {
703
+ return serialized(this.replica.file, async () => structuredClone(await decide(this.replica, candidate, source)));
704
+ }
705
+ /**
706
+ * The pairing path, after openPairApproval. Runs confirmPairedLedger(link, approval, ledger, now, sticky) with this replica's sticky
707
+ * set, then binds the empty store (or applies the append-only rule to a bound store of the same account); the request's bridge must be in
708
+ * the resulting trusted roster. Returns trusted(). Throws LedgerStoreError('ledger_store_pairing_revoked') when the chain (or this
709
+ * replica's sticky set) revokes the request's bridge id, LedgerStoreError('ledger_store_pairing_rejected') on any other pairing failure;
710
+ * writes nothing in either case.
711
+ */
712
+ adoptPaired(link, approval, ledger, now) {
713
+ const replica = this.replica;
714
+ return serialized(replica.file, async () => {
715
+ const at = now === undefined ? replica.clock() : now;
716
+ const stored = await load(replica);
717
+ const rejected = () => new LedgerStoreError('ledger_store_pairing_rejected');
718
+ let parsed;
719
+ try {
720
+ parsed = parseLedger(ledger);
721
+ }
722
+ catch {
723
+ throw rejected();
724
+ }
725
+ const bound = stored === null ? replica.accountId : stored.checkpoint.accountId;
726
+ if (bound !== null && await accountIdOf(parsed.genesis.payload) !== bound)
727
+ throw rejected();
728
+ const sticky = union(stored === null ? { deviceIds: [], bridgeIds: [] } : stored.sticky, ledgerRevocations(parsed));
729
+ try {
730
+ await confirmPairedLedger(link, approval, parsed, at, sticky);
731
+ }
732
+ catch {
733
+ // A VALID chain of the approved account that revokes this request's bridge id (a tombstone, or a removal right after the approval):
734
+ // the id can never be a member, so retrying the same pairing would loop. It is not the removal of a paired bridge: nothing is reset.
735
+ if (sticky.bridgeIds.indexOf(link.request.bridgeId) !== -1 && await accountIdOf(parsed.genesis.payload) === approval.accountId) {
736
+ let valid = true;
737
+ try {
738
+ await verifyLedger(parsed, at);
739
+ }
740
+ catch {
741
+ valid = false;
742
+ }
743
+ if (valid)
744
+ throw new LedgerStoreError('ledger_store_pairing_revoked');
745
+ }
746
+ throw rejected();
747
+ }
748
+ const request = link.request;
749
+ const endpointId = createHash('sha256').update(Buffer.from(request.endpointPublicKey, 'base64url')).digest('base64url');
750
+ return admit(replica, stored, parsed, at, trusted => lists(trusted, request.bridgeId, endpointId, request.authPublicKey), 'ledger_store_pairing_rejected');
751
+ });
752
+ }
753
+ /**
754
+ * Recovery when the identity is bound but the store is empty (reconcileIdentityAndStore returned 'needs_ledger'): accepts a server
755
+ * ledger only if its account is the identity's bound account and its trusted roster lists this identity's bridge keys (endpoint id and
756
+ * authPublicKey). Returns trusted(). Throws LedgerStoreError('ledger_store_bound_rejected') and writes nothing otherwise.
757
+ */
758
+ adoptBound(ledger, now) {
759
+ const replica = this.replica;
760
+ return serialized(replica.file, async () => {
761
+ const at = now === undefined ? replica.clock() : now;
762
+ const rejected = () => new LedgerStoreError('ledger_store_bound_rejected');
763
+ const identity = await readLedgerIdentity(replica.dataDir);
764
+ if (identity === null || identity.accountId === null)
765
+ throw rejected();
766
+ const stored = await load(replica);
767
+ let parsed;
768
+ try {
769
+ parsed = parseLedger(ledger);
770
+ }
771
+ catch {
772
+ throw rejected();
773
+ }
774
+ const accountId = await accountIdOf(parsed.genesis.payload);
775
+ if (accountId !== identity.accountId)
776
+ throw rejected();
777
+ if ((replica.accountId !== null && accountId !== replica.accountId) || (stored !== null && accountId !== stored.checkpoint.accountId))
778
+ throw rejected();
779
+ return admit(replica, stored, parsed, at, trusted => lists(trusted, null, identity.endpointId, identity.authPublicKey), 'ledger_store_bound_rejected');
780
+ });
781
+ }
782
+ /** Records the time of the last successful server ledger read. No-op while no ledger is stored. */
783
+ markServerRead(now) {
784
+ if (!isTime(now))
785
+ return Promise.reject(new TypeError('Invalid server read time'));
786
+ return serialized(this.replica.file, async () => {
787
+ const state = await load(this.replica);
788
+ if (state === null || state.lastServerReadAt === now)
789
+ return;
790
+ await save(this.replica, { ...state, lastServerReadAt: now });
791
+ });
792
+ }
793
+ }
794
+ /**
795
+ * Startup recovery between the identity file and the ledger store, under the store's queue:
796
+ * - identity bound, store holds its account's ledger -> 'ok'; store holds another account's -> throws ledger_store_wrong_account;
797
+ * - identity bound, store empty -> 'needs_ledger' (fetch the server ledger, then adoptBound); a bound identity is never unbound;
798
+ * - identity unbound (or absent), store empty -> 'unpaired';
799
+ * - identity unbound, store's trusted roster lists this identity's bridge keys -> binds the identity to that account -> 'ok';
800
+ * - otherwise (absent identity, or keys not listed) the stale ledger.json is deleted -> 'unpaired'.
801
+ * Corrupted files throw (LedgerStoreError / BridgeSecurityError('key_unavailable')) and are never reset.
802
+ */
803
+ export function reconcileIdentityAndStore(dataDir, options = {}) {
804
+ const replica = replicaOf(dataDir, { now: options.now });
805
+ return serialized(replica.file, async () => {
806
+ const identity = await readLedgerIdentity(dataDir);
807
+ const stored = await load(replica);
808
+ if (identity !== null && identity.accountId !== null) {
809
+ if (stored === null)
810
+ return 'needs_ledger';
811
+ if (stored.checkpoint.accountId !== identity.accountId)
812
+ throw new LedgerStoreError('ledger_store_wrong_account');
813
+ return 'ok';
814
+ }
815
+ if (stored === null)
816
+ return 'unpaired';
817
+ if (identity !== null && lists(stored.trusted, null, identity.endpointId, identity.authPublicKey)) {
818
+ await bindLedgerIdentity(dataDir, stored.checkpoint.accountId);
819
+ return 'ok';
820
+ }
821
+ await erase(replica);
822
+ return 'unpaired';
823
+ });
824
+ }
825
+ //# sourceMappingURL=ledger-store.js.map