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.
- package/dist/attention-events.d.ts +1 -1
- package/dist/attention-store.d.ts +3 -0
- package/dist/attention-store.js +25 -1
- package/dist/attention-store.js.map +1 -1
- package/dist/centrifugo-client.d.ts +28 -11
- package/dist/centrifugo-client.js +84 -70
- package/dist/centrifugo-client.js.map +1 -1
- package/dist/cli-client.d.ts +11 -1
- package/dist/cli-client.js +23 -3
- package/dist/cli-client.js.map +1 -1
- package/dist/command-rpc.d.ts +2 -0
- package/dist/command-rpc.js +59 -17
- package/dist/command-rpc.js.map +1 -1
- package/dist/identity-reset.d.ts +4 -1
- package/dist/identity-reset.js +54 -6
- package/dist/identity-reset.js.map +1 -1
- package/dist/index.js +194 -288
- package/dist/index.js.map +1 -1
- package/dist/ledger-pairing-client.d.ts +28 -0
- package/dist/ledger-pairing-client.js +154 -0
- package/dist/ledger-pairing-client.js.map +1 -0
- package/dist/ledger-relay.d.ts +159 -0
- package/dist/ledger-relay.js +595 -0
- package/dist/ledger-relay.js.map +1 -0
- package/dist/ledger-reoffer.d.ts +56 -0
- package/dist/ledger-reoffer.js +210 -0
- package/dist/ledger-reoffer.js.map +1 -0
- package/dist/ledger-startup.d.ts +176 -0
- package/dist/ledger-startup.js +233 -0
- package/dist/ledger-startup.js.map +1 -0
- package/dist/local-api-server.d.ts +3 -0
- package/dist/local-api-server.js +68 -6
- package/dist/local-api-server.js.map +1 -1
- package/dist/mail-delivery.d.ts +26 -3
- package/dist/mail-delivery.js +65 -7
- package/dist/mail-delivery.js.map +1 -1
- package/dist/preview-protocol.d.ts +19 -3
- package/dist/preview-protocol.js +14 -0
- package/dist/preview-protocol.js.map +1 -1
- package/dist/relay-token.d.ts +45 -0
- package/dist/relay-token.js +138 -0
- package/dist/relay-token.js.map +1 -0
- package/dist/revocation-gating.d.ts +0 -13
- package/dist/revocation-gating.js +0 -16
- package/dist/revocation-gating.js.map +1 -1
- package/dist/security/bridge/identity.d.ts +2 -47
- package/dist/security/bridge/identity.js +3 -3
- package/dist/security/bridge/identity.js.map +1 -1
- package/dist/security/bridge/ledger-identity.d.ts +48 -0
- package/dist/security/bridge/ledger-identity.js +342 -0
- package/dist/security/bridge/ledger-identity.js.map +1 -0
- package/dist/security/bridge/ledger-rpc.d.ts +34 -0
- package/dist/security/bridge/ledger-rpc.js +58 -0
- package/dist/security/bridge/ledger-rpc.js.map +1 -0
- package/dist/security/bridge/ledger-store.d.ts +153 -0
- package/dist/security/bridge/ledger-store.js +825 -0
- package/dist/security/bridge/ledger-store.js.map +1 -0
- package/dist/security/bridge/metadata.d.ts +2 -2
- package/dist/security/bridge/permissions.js +4 -3
- package/dist/security/bridge/permissions.js.map +1 -1
- package/dist/security/bridge/policy.d.ts +43 -23
- package/dist/security/bridge/policy.js +214 -146
- package/dist/security/bridge/policy.js.map +1 -1
- package/dist/security/bridge/security.d.ts +212 -33
- package/dist/security/bridge/security.js +437 -165
- package/dist/security/bridge/security.js.map +1 -1
- package/dist/security/crypto.js +1 -1
- package/dist/security/crypto.js.map +1 -1
- package/dist/security/ledger/announce.d.ts +32 -0
- package/dist/security/ledger/announce.js +101 -0
- package/dist/security/ledger/announce.js.map +1 -0
- package/dist/security/ledger/channels.d.ts +5 -0
- package/dist/security/ledger/channels.js +21 -0
- package/dist/security/ledger/channels.js.map +1 -0
- package/dist/security/ledger/ledger.d.ts +154 -0
- package/dist/security/ledger/ledger.js +878 -0
- package/dist/security/ledger/ledger.js.map +1 -0
- package/dist/security/ledger/pairing.d.ts +81 -0
- package/dist/security/ledger/pairing.js +414 -0
- package/dist/security/ledger/pairing.js.map +1 -0
- package/dist/security/ledger/proof.d.ts +36 -0
- package/dist/security/ledger/proof.js +193 -0
- package/dist/security/ledger/proof.js.map +1 -0
- package/dist/security/ledger/protocol.d.ts +113 -0
- package/dist/security/ledger/protocol.js +16 -0
- package/dist/security/ledger/protocol.js.map +1 -0
- package/dist/security/ledger/testing.d.ts +27 -0
- package/dist/security/ledger/testing.js +70 -0
- package/dist/security/ledger/testing.js.map +1 -0
- package/dist/security/ledger/webauthn.d.ts +12 -0
- package/dist/security/ledger/webauthn.js +147 -0
- package/dist/security/ledger/webauthn.js.map +1 -0
- package/dist/security/protocol.d.ts +5 -10
- package/dist/security-cli.d.ts +5 -0
- package/dist/security-cli.js +28 -16
- package/dist/security-cli.js.map +1 -1
- package/dist/session-controller.d.ts +9 -3
- package/dist/session-controller.js +120 -20
- package/dist/session-controller.js.map +1 -1
- package/dist/session-inbox-protocol.d.ts +2 -0
- package/dist/session-preview.d.ts +20 -5
- package/dist/session-preview.js +185 -37
- package/dist/session-preview.js.map +1 -1
- package/dist/session-store.js +19 -1
- package/dist/session-store.js.map +1 -1
- package/dist/sessions-commands.js +41 -7
- package/dist/sessions-commands.js.map +1 -1
- package/dist/token-provider.d.ts +39 -0
- package/dist/token-provider.js +101 -0
- package/dist/token-provider.js.map +1 -0
- package/dist/types.d.ts +1 -1
- package/opencode-plugin/ftown.js +15 -2
- package/package.json +2 -2
- package/skills/ftown/SKILL.md +18 -7
- package/skills/ftown/references/sessions.md +38 -12
- package/dist/bridge-auth.d.ts +0 -31
- package/dist/bridge-auth.js +0 -70
- package/dist/bridge-auth.js.map +0 -1
- package/dist/pairing-client.d.ts +0 -62
- package/dist/pairing-client.js +0 -469
- package/dist/pairing-client.js.map +0 -1
- package/dist/rotating-token-refresher.d.ts +0 -42
- package/dist/rotating-token-refresher.js +0 -91
- package/dist/rotating-token-refresher.js.map +0 -1
- package/dist/security/account-crypto.d.ts +0 -33
- package/dist/security/account-crypto.js +0 -649
- package/dist/security/account-crypto.js.map +0 -1
- package/dist/security/account-protocol.d.ts +0 -228
- package/dist/security/account-protocol.js +0 -2
- package/dist/security/account-protocol.js.map +0 -1
- package/dist/security/bridge/account-client.d.ts +0 -7
- package/dist/security/bridge/account-client.js +0 -101
- package/dist/security/bridge/account-client.js.map +0 -1
- package/dist/security/bridge/account-state.d.ts +0 -105
- package/dist/security/bridge/account-state.js +0 -79
- package/dist/security/bridge/account-state.js.map +0 -1
- package/dist/security/bridge/pending-approval.d.ts +0 -21
- package/dist/security/bridge/pending-approval.js +0 -80
- package/dist/security/bridge/pending-approval.js.map +0 -1
- package/dist/security/bridge/pending-enrollment.d.ts +0 -33
- package/dist/security/bridge/pending-enrollment.js +0 -177
- package/dist/security/bridge/pending-enrollment.js.map +0 -1
- package/dist/security/bridge/revocation.d.ts +0 -14
- package/dist/security/bridge/revocation.js +0 -40
- package/dist/security/bridge/revocation.js.map +0 -1
- package/dist/security/pending-approval-crypto.d.ts +0 -29
- package/dist/security/pending-approval-crypto.js +0 -716
- package/dist/security/pending-approval-crypto.js.map +0 -1
- package/dist/security/pending-approval-protocol.d.ts +0 -329
- package/dist/security/pending-approval-protocol.js +0 -2
- package/dist/security/pending-approval-protocol.js.map +0 -1
- package/dist/security/security-nudge.d.ts +0 -31
- package/dist/security/security-nudge.js +0 -98
- package/dist/security/security-nudge.js.map +0 -1
|
@@ -5,10 +5,9 @@ import { BridgePolicyStore } from './policy.js';
|
|
|
5
5
|
import { BridgeSecurityError } from './errors.js';
|
|
6
6
|
import { classifyCommand, classifyHttp, classifyTerminal } from './permissions.js';
|
|
7
7
|
import { endpointSchema } from './metadata.js';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import { ACCOUNT_SAFETY_REFRESH_MS, SECURITY_NUDGE_MIN_SPACING_MS, createCoalescedTrigger } from '../security-nudge.js';
|
|
8
|
+
import { accountIdOf, ledgerHead, ledgerHeads, parseLedger, relateHead } from '../ledger/ledger.js';
|
|
9
|
+
import { verifyHeadAnnouncement } from '../ledger/announce.js';
|
|
10
|
+
import { ledgerRemovesBridge, shortHead } from './ledger-store.js';
|
|
12
11
|
const token = z.string().regex(/^[A-Za-z0-9_-]{1,200}$/);
|
|
13
12
|
const routeSchema = z.discriminatedUnion('kind', [
|
|
14
13
|
z.object({ kind: z.literal('command'), requestId: token, command: z.string().min(1).max(100), sessionId: token.nullable() }).strict(),
|
|
@@ -22,8 +21,24 @@ const object = (value) => {
|
|
|
22
21
|
return value;
|
|
23
22
|
};
|
|
24
23
|
export const applicationJson = (value) => JSON.parse(JSON.stringify(value));
|
|
25
|
-
|
|
26
|
-
const
|
|
24
|
+
/** onApprovalRequired: at most one emission per endpoint per spacing, and at most this many endpoints tracked at once. */
|
|
25
|
+
const APPROVAL_SPACING_MS = 2000;
|
|
26
|
+
const APPROVAL_TRACKED_LIMIT = 1024;
|
|
27
|
+
const REJECTED = { decision: 'reject', relation: null, head: null, revision: null };
|
|
28
|
+
const reason = (error) => error instanceof Error ? error.message : String(error);
|
|
29
|
+
const httpStatus = (error) => typeof error === 'object' && error !== null && 'status' in error && typeof error.status === 'number' ? error.status : null;
|
|
30
|
+
/** The candidate's last record ({revision, head}), or null when it is not a well-formed chain. One hash, no signature work. */
|
|
31
|
+
async function chainTip(candidate) {
|
|
32
|
+
try {
|
|
33
|
+
const ledger = parseLedger(candidate);
|
|
34
|
+
const last = ledger.transitions[ledger.transitions.length - 1];
|
|
35
|
+
return { revision: ledger.transitions.length, head: last === undefined ? await accountIdOf(ledger.genesis.payload) : await ledgerHead(last.payload) };
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// adoptAccount is v1 (removed from this service); the shared interface drops it in the v1 cleanup.
|
|
27
42
|
export class BridgeSecurityService {
|
|
28
43
|
identity;
|
|
29
44
|
bridgeId;
|
|
@@ -39,20 +54,42 @@ export class BridgeSecurityService {
|
|
|
39
54
|
bridgeRevokedEmitted = false;
|
|
40
55
|
verified = new WeakSet();
|
|
41
56
|
closed = false;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
57
|
+
ledgerNow;
|
|
58
|
+
/** Ledger offers and their projections run one at a time, so the policy never sees an older view after a newer one. */
|
|
59
|
+
ledgerTail = Promise.resolve();
|
|
60
|
+
status = {
|
|
61
|
+
accountId: null, revision: null, head: null, own: 'unpaired', divergence: null, serverBehind: false, removedOnOtherHistory: false,
|
|
62
|
+
};
|
|
63
|
+
announcements = { accepted: 0, dropped: 0 };
|
|
64
|
+
headListeners = new Set();
|
|
65
|
+
ledgerMonotonic;
|
|
66
|
+
/** The trusted view of the last projection (null: none yet, or another account's). */
|
|
67
|
+
trustedView = null;
|
|
68
|
+
/** The state of the last projection (null: none yet, or an empty store), and the record heads of its chain (null: not computed). */
|
|
69
|
+
held = null;
|
|
70
|
+
heads = null;
|
|
71
|
+
approvalListeners = new Set();
|
|
72
|
+
approvalEmitted = new Map();
|
|
73
|
+
pendingOffers = new Map();
|
|
74
|
+
serverBehind = false;
|
|
75
|
+
/** The server's revision when it was last seen behind us (null: unknown). */
|
|
76
|
+
serverRevision = null;
|
|
77
|
+
refreshing = null;
|
|
78
|
+
refreshQueued = null;
|
|
49
79
|
constructor(identity, bridgeId, dataDir, options) {
|
|
50
80
|
this.identity = identity;
|
|
51
81
|
this.bridgeId = bridgeId;
|
|
52
82
|
this.options = options;
|
|
53
83
|
this.endpoint = identity.endpoint;
|
|
54
84
|
this.now = options.now ?? Date.now;
|
|
85
|
+
this.ledgerNow = options.ledger?.now ?? this.now;
|
|
86
|
+
this.ledgerMonotonic = options.ledger?.monotonic ?? (() => performance.now());
|
|
55
87
|
this.policy = new BridgePolicyStore(dataDir, bridgeId, this.endpoint, options);
|
|
88
|
+
if (options.ledger) {
|
|
89
|
+
if (options.ledger.bridgeId !== bridgeId)
|
|
90
|
+
throw new BridgeSecurityError('wrong_context');
|
|
91
|
+
this.policy.enableLedger(this.ledgerNow, this.ledgerMonotonic);
|
|
92
|
+
}
|
|
56
93
|
}
|
|
57
94
|
context(route) {
|
|
58
95
|
if (this.closed || route.bridgeId !== this.bridgeId)
|
|
@@ -121,7 +158,7 @@ export class BridgeSecurityService {
|
|
|
121
158
|
if (envelope.purpose !== 'hello')
|
|
122
159
|
throw new BridgeSecurityError('wrong_context');
|
|
123
160
|
this.freshness(envelope, false);
|
|
124
|
-
const principal = await this.
|
|
161
|
+
const principal = await this.senderPrincipal(envelope.senderId);
|
|
125
162
|
const hello = z.object({ type: z.literal('hello'), challenge: token }).strict().parse(await this.decode(principal.endpoint, envelope, route));
|
|
126
163
|
const welcome = await this.reply(principal.endpoint, 'welcome', { type: 'welcome', challenge: hello.challenge, receiverEpoch: this.receiverEpoch }, route, envelope.messageId);
|
|
127
164
|
const current = await this.policy.principal(principal.endpoint.id);
|
|
@@ -174,7 +211,7 @@ export class BridgeSecurityService {
|
|
|
174
211
|
const envelope = parseSecureEnvelope(packet);
|
|
175
212
|
if (envelope.purpose !== 'application')
|
|
176
213
|
throw new BridgeSecurityError('wrong_context');
|
|
177
|
-
const principal = await this.
|
|
214
|
+
const principal = await this.senderPrincipal(envelope.senderId);
|
|
178
215
|
const app = this.parseApplication(await this.decode(principal.endpoint, envelope, route));
|
|
179
216
|
if (app.route.kind === 'http') {
|
|
180
217
|
const data = object(app.data);
|
|
@@ -229,7 +266,7 @@ export class BridgeSecurityService {
|
|
|
229
266
|
packets.push(await this.sealTo(recipient.endpoint.id, application, { transport: 'cloud', channel, bridgeId: this.bridgeId, pairId: null }));
|
|
230
267
|
}
|
|
231
268
|
catch (error) {
|
|
232
|
-
if (!(error instanceof BridgeSecurityError) || (!['endpoint_revoked', 'approval_required', 'expired'
|
|
269
|
+
if (!(error instanceof BridgeSecurityError) || (!['endpoint_revoked', 'approval_required', 'expired'].includes(error.code)
|
|
233
270
|
&& !error.code.startsWith('account_')))
|
|
234
271
|
throw error;
|
|
235
272
|
}
|
|
@@ -243,117 +280,367 @@ export class BridgeSecurityService {
|
|
|
243
280
|
for (const listener of this.revoked)
|
|
244
281
|
listener(endpointId);
|
|
245
282
|
}
|
|
246
|
-
/**
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
283
|
+
/**
|
|
284
|
+
* Account mode only (no-op without a ledger). Loads the replica, projects it and checks this bridge's own status; a stored chain that
|
|
285
|
+
* already revokes this bridge is signed proof on its own, so it emits and nothing is fetched. Otherwise offers the server's ledger
|
|
286
|
+
* once; a fetch failure is logged, not fatal, and a successful read that is not rejected counts as server contact.
|
|
287
|
+
* Throws when the local replica cannot be read (corrupted, another account's, or unreadable; it is never reset) or the policy
|
|
288
|
+
* cannot be written.
|
|
289
|
+
*/
|
|
290
|
+
async startLedger() {
|
|
291
|
+
const ledger = this.options.ledger;
|
|
292
|
+
if (!ledger)
|
|
251
293
|
return;
|
|
294
|
+
const own = await this.ledgerTask(async () => {
|
|
295
|
+
if (this.closed)
|
|
296
|
+
return null;
|
|
297
|
+
const state = await ledger.store.read();
|
|
298
|
+
return state === null ? this.project(null, null) : this.projectState(state);
|
|
299
|
+
});
|
|
300
|
+
if (own === null || own === 'revoked' || this.closed)
|
|
301
|
+
return;
|
|
302
|
+
await this.refreshLedger();
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Reads the server's ledger and offers it ('server') through readServerLedger. A result that is not 'reject' counts as server contact;
|
|
306
|
+
* a fetch failure is logged, never thrown. Single in flight: calls during a read share one trailing read, so a change announced
|
|
307
|
+
* mid-read is not missed. The wiring calls it on every relay (re)subscribe and on a `ledger_head` that differs from ledgerStatus().head,
|
|
308
|
+
* and checks ledgerStatus().serverBehind afterwards.
|
|
309
|
+
*/
|
|
310
|
+
refreshLedger() {
|
|
311
|
+
if (!this.options.ledger || this.closed)
|
|
312
|
+
return Promise.resolve();
|
|
313
|
+
if (this.refreshing) {
|
|
314
|
+
this.refreshQueued ??= this.refreshing.then(() => { this.refreshQueued = null; return this.refreshLedger(); });
|
|
315
|
+
return this.refreshQueued;
|
|
252
316
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
317
|
+
this.refreshing = this.refreshOnce().finally(() => { this.refreshing = null; });
|
|
318
|
+
return this.refreshing;
|
|
319
|
+
}
|
|
320
|
+
async refreshOnce() {
|
|
321
|
+
const read = await this.readServerLedger();
|
|
322
|
+
if (read.kind !== 'read')
|
|
323
|
+
console.warn('[Security] Account ledger read failed:', read.reason);
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* One read of the server's ledger (not coalesced), offered as 'server'; a result that is not 'reject' counts as server contact. Never
|
|
327
|
+
* throws: a 404 is 'unknown_account' (the server does not know this account), any other failure 'failed'. The re-offer runs it right
|
|
328
|
+
* before it posts; refreshLedger runs it for everything else.
|
|
329
|
+
*/
|
|
330
|
+
async readServerLedger() {
|
|
331
|
+
const ledger = this.options.ledger;
|
|
332
|
+
if (!ledger || this.closed)
|
|
333
|
+
return { kind: 'failed', reason: 'Account ledger unavailable' };
|
|
334
|
+
let fetched;
|
|
335
|
+
try {
|
|
336
|
+
fetched = await ledger.fetchLedger();
|
|
267
337
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if (
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
this.
|
|
279
|
-
}
|
|
280
|
-
|
|
338
|
+
catch (error) {
|
|
339
|
+
return httpStatus(error) === 404 ? { kind: 'unknown_account', reason: reason(error) } : { kind: 'failed', reason: reason(error) };
|
|
340
|
+
}
|
|
341
|
+
const outcome = await this.offerLedgerResult(fetched, 'server');
|
|
342
|
+
if (outcome.decision !== 'reject')
|
|
343
|
+
this.noteServerContact();
|
|
344
|
+
return { kind: 'read', outcome };
|
|
345
|
+
}
|
|
346
|
+
/** offerLedgerResult(candidate, source).decision. */
|
|
347
|
+
async offerLedger(candidate, source) {
|
|
348
|
+
return (await this.offerLedgerResult(candidate, source)).decision;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Offers an untrusted chain (the relay, a relay-token response, a server read or answer) to the replica, then projects the result and
|
|
352
|
+
* checks this bridge's own status. Never throws: an invalid candidate, a closed service and a local storage failure all return 'reject'.
|
|
353
|
+
* Per source at most one offer waits behind the one in flight; a newer one replaces it, and the replaced caller gets the newer outcome.
|
|
354
|
+
* A chain equal to the stored one or a prefix of it is answered keep/same or keep/behind from record heads alone (no signature check),
|
|
355
|
+
* unless a divergence is recorded for that source (the store clears it). The outcome of a 'server' chain sets serverBehind. Offers never
|
|
356
|
+
* count as server contact: readServerLedger and noteServerContact decide that.
|
|
357
|
+
*/
|
|
358
|
+
offerLedgerResult(candidate, source) {
|
|
359
|
+
if (!this.options.ledger || this.closed)
|
|
360
|
+
return Promise.resolve(REJECTED);
|
|
361
|
+
return new Promise(resolve => {
|
|
362
|
+
const waiting = this.pendingOffers.get(source);
|
|
363
|
+
if (waiting) {
|
|
364
|
+
waiting.candidate = candidate;
|
|
365
|
+
waiting.waiters.push(resolve);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const pending = { candidate, waiters: [resolve] };
|
|
369
|
+
this.pendingOffers.set(source, pending);
|
|
370
|
+
void this.ledgerTask(async () => {
|
|
371
|
+
if (this.pendingOffers.get(source) === pending)
|
|
372
|
+
this.pendingOffers.delete(source);
|
|
373
|
+
return this.evaluate(pending.candidate, source);
|
|
374
|
+
}).catch(() => REJECTED).then(outcome => { for (const waiter of pending.waiters)
|
|
375
|
+
waiter({ ...outcome }); });
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
async evaluate(candidate, source) {
|
|
379
|
+
const ledger = this.options.ledger;
|
|
380
|
+
if (this.closed)
|
|
381
|
+
return REJECTED;
|
|
382
|
+
const tip = await chainTip(candidate);
|
|
383
|
+
const heads = this.heads;
|
|
384
|
+
if (tip !== null && heads !== null && this.held !== null && heads.of === this.held.checkpoint.head
|
|
385
|
+
&& !this.held.divergences.some(entry => entry.source === source)) {
|
|
386
|
+
let known = 'unknown';
|
|
387
|
+
try {
|
|
388
|
+
known = relateHead(heads.list, tip);
|
|
389
|
+
}
|
|
390
|
+
catch { /* not a head of ours */ }
|
|
391
|
+
if (known === 'same' || known === 'behind')
|
|
392
|
+
return this.observed(source, { decision: 'keep', relation: known, head: tip.head, revision: tip.revision });
|
|
393
|
+
}
|
|
394
|
+
let result;
|
|
281
395
|
try {
|
|
282
|
-
await
|
|
283
|
-
this.recordAccountRead(true);
|
|
396
|
+
result = await ledger.store.offer(candidate, source);
|
|
284
397
|
}
|
|
285
|
-
catch {
|
|
286
|
-
|
|
398
|
+
catch (error) {
|
|
399
|
+
console.error('[Security] Account ledger offer failed:', reason(error));
|
|
400
|
+
return REJECTED;
|
|
287
401
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
402
|
+
const outcome = { decision: result.decision, relation: result.relation, head: result.candidate?.head ?? null, revision: result.candidate?.revision ?? null };
|
|
403
|
+
if (result.decision === 'reject' || result.state === null)
|
|
404
|
+
return { ...outcome, decision: 'reject' };
|
|
405
|
+
try {
|
|
406
|
+
await this.projectState(result.state);
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
console.error('[Security] Account ledger projection failed:', reason(error));
|
|
296
410
|
}
|
|
411
|
+
return this.observed(source, outcome);
|
|
412
|
+
}
|
|
413
|
+
/** Records what a non-rejected 'server' chain says about the server: behind (a strict prefix of ours) or not. */
|
|
414
|
+
observed(source, outcome) {
|
|
415
|
+
if (source === 'server' && outcome.decision !== 'reject')
|
|
416
|
+
this.setServerBehind(outcome.relation === 'behind', outcome.revision);
|
|
417
|
+
return outcome;
|
|
418
|
+
}
|
|
419
|
+
setServerBehind(behind, revision) {
|
|
420
|
+
this.serverBehind = behind;
|
|
421
|
+
this.serverRevision = behind ? revision : null;
|
|
422
|
+
this.status = { ...this.status, serverBehind: behind };
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* A relay-token answer that carried no chain: the server's head is `head` (at `revision` when the answer named one, else the head the
|
|
426
|
+
* request sent). Classified against the stored chain from record heads alone: 'same', 'behind' (sets serverBehind), or 'unknown'
|
|
427
|
+
* (ahead, diverged, or not a head of ours: the caller reads the server). null without a ledger, a head, or a stored chain.
|
|
428
|
+
*/
|
|
429
|
+
noteServerHead(server) {
|
|
430
|
+
const heads = this.heads;
|
|
431
|
+
if (!this.options.ledger || this.closed || server.head === null || heads === null)
|
|
432
|
+
return null;
|
|
433
|
+
const revision = server.revision ?? heads.list.lastIndexOf(server.head);
|
|
434
|
+
let relation = 'unknown';
|
|
435
|
+
if (revision >= 0) {
|
|
436
|
+
try {
|
|
437
|
+
relation = relateHead(heads.list, { revision, head: server.head });
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (relation !== 'unknown')
|
|
444
|
+
this.setServerBehind(relation === 'behind', revision);
|
|
445
|
+
return relation;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* What this bridge holds that the server does not (for the startup message when the server refuses our key): our records after the
|
|
449
|
+
* fork point of a recorded server divergence, or after the server's revision when it was seen behind us; null when neither is known.
|
|
450
|
+
*/
|
|
451
|
+
uniqueHistory() {
|
|
452
|
+
const held = this.held;
|
|
453
|
+
if (held === null)
|
|
454
|
+
return null;
|
|
455
|
+
const fork = held.divergences.find(entry => entry.source === 'server')?.forkRevision ?? null;
|
|
456
|
+
const after = fork ?? (this.serverBehind ? this.serverRevision : null);
|
|
457
|
+
if (after === null || after < 0 || after > held.ledger.transitions.length)
|
|
458
|
+
return null;
|
|
459
|
+
const records = held.ledger.transitions.slice(after);
|
|
460
|
+
let removals = 0;
|
|
461
|
+
let passkeys = 0;
|
|
462
|
+
for (const record of records) {
|
|
463
|
+
const type = record.payload.operation.type;
|
|
464
|
+
if (type === 'revoke_device' || type === 'revoke_bridge')
|
|
465
|
+
removals++;
|
|
466
|
+
if (type === 'add_authority')
|
|
467
|
+
passkeys++;
|
|
468
|
+
}
|
|
469
|
+
return { afterRevision: after, records: records.length, removals, passkeys };
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Account mode, read-only (ledger_head / ledger_chain): the stored chain, for a BROWSER that is a member of this bridge's trusted
|
|
473
|
+
* checkpoint (listed with exactly this endpoint key, not revoked; policy access, offline grace and own status included). Throws
|
|
474
|
+
* BridgeSecurityError('permission_denied') for anyone else, Solo, --local and the local CLI.
|
|
475
|
+
*/
|
|
476
|
+
async memberLedger(principal) {
|
|
477
|
+
const held = await this.memberState(principal);
|
|
478
|
+
// Read-only by contract (the ledger_chain pager copies only the records it returns): no copy of the whole chain per request.
|
|
479
|
+
return { ledger: held.ledger, accountId: held.checkpoint.accountId, head: held.checkpoint.head, revision: held.checkpoint.revision };
|
|
480
|
+
}
|
|
481
|
+
/** ledger_head: like memberLedger, but answers the tip only, without touching the chain. */
|
|
482
|
+
async memberHead(principal) {
|
|
483
|
+
const held = await this.memberState(principal);
|
|
484
|
+
return { accountId: held.checkpoint.accountId, head: held.checkpoint.head, revision: held.checkpoint.revision };
|
|
485
|
+
}
|
|
486
|
+
async memberState(principal) {
|
|
487
|
+
if (!this.options.ledger || this.closed)
|
|
488
|
+
throw new BridgeSecurityError('permission_denied');
|
|
489
|
+
await this.policy.revalidate(principal, 'read');
|
|
490
|
+
const held = this.held;
|
|
491
|
+
const endpoint = principal.endpoint;
|
|
492
|
+
// The trusted checkpoint (sticky applied): a browser removed on a refused history is refused here too.
|
|
493
|
+
if (held === null || endpoint.kind !== 'browser' || held.trusted.accountId !== this.endpoint.ownerId
|
|
494
|
+
|| held.trusted.revokedDeviceIds.includes(endpoint.id)
|
|
495
|
+
|| !held.trusted.devices.some(device => device.endpoint.id === endpoint.id && device.endpoint.publicKey === endpoint.publicKey)) {
|
|
496
|
+
throw new BridgeSecurityError('permission_denied');
|
|
497
|
+
}
|
|
498
|
+
return held;
|
|
297
499
|
}
|
|
298
|
-
/**
|
|
299
|
-
|
|
300
|
-
|
|
500
|
+
/** A successful server contact (the token provider calls this after a relay-token response): re-anchors offline grace at now and
|
|
501
|
+
* persists it (the replica's lastServerReadAt and the clock high-water). An invalid time is ignored. */
|
|
502
|
+
noteServerContact(now) {
|
|
503
|
+
const ledger = this.options.ledger;
|
|
504
|
+
if (!ledger || this.closed)
|
|
301
505
|
return;
|
|
302
|
-
this.
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
506
|
+
const at = this.policy.noteServerRead(now ?? this.ledgerNow());
|
|
507
|
+
if (at === null)
|
|
508
|
+
return;
|
|
509
|
+
const failed = (error) => console.error('[Security] Recording server contact failed:', reason(error));
|
|
510
|
+
ledger.store.markServerRead(at).catch(failed);
|
|
511
|
+
this.policy.persistLedgerClock().catch(failed);
|
|
512
|
+
}
|
|
513
|
+
ledgerStatus() {
|
|
514
|
+
return { ...this.status, divergence: this.status.divergence === null ? null : { ...this.status.divergence }, announcements: { ...this.announcements } };
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* A head announcement from the ledger channel (parsed, from an owner publication), judged against this bridge's TRUSTED checkpoint (sticky
|
|
518
|
+
* revocations applied) and the record heads of its stored chain: its relation ('same', 'behind' or 'unknown': the caller runs a paced server
|
|
519
|
+
* read) when it verifies, else null. This bridge's own announcements (kind bridge, its bridgeId) return null before any work and are not
|
|
520
|
+
* counted; every other null is counted as dropped, every relation as accepted. At most one signature verification, then an O(1) compare.
|
|
521
|
+
* Never throws. An announcement is never proof: nothing here changes the replica.
|
|
522
|
+
*/
|
|
523
|
+
async judgeHeadAnnouncement(announcement) {
|
|
524
|
+
if (!this.options.ledger || this.closed)
|
|
525
|
+
return null;
|
|
526
|
+
let relation = null;
|
|
319
527
|
try {
|
|
320
|
-
|
|
528
|
+
if (announcement.kind === 'bridge' && announcement.keyId === this.bridgeId)
|
|
529
|
+
return null;
|
|
530
|
+
const trusted = this.trustedView;
|
|
531
|
+
if (trusted !== null && this.heads !== null && this.heads.of === trusted.head
|
|
532
|
+
&& await verifyHeadAnnouncement(trusted, announcement, this.ledgerNow())) {
|
|
533
|
+
// The chain may have moved during the verification: compare with the heads held now.
|
|
534
|
+
const heads = this.heads;
|
|
535
|
+
if (heads !== null)
|
|
536
|
+
relation = relateHead(heads.list, { revision: announcement.revision, head: announcement.head });
|
|
537
|
+
}
|
|
321
538
|
}
|
|
322
539
|
catch {
|
|
323
|
-
|
|
540
|
+
relation = null;
|
|
324
541
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
542
|
+
if (relation === null)
|
|
543
|
+
this.announcements.dropped++;
|
|
544
|
+
else
|
|
545
|
+
this.announcements.accepted++;
|
|
546
|
+
return relation;
|
|
547
|
+
}
|
|
548
|
+
/** Account mode: fires (synchronously, after the projection) whenever the stored head or this bridge's own status changes, e.g. after an
|
|
549
|
+
* adopt. Listener errors are logged. */
|
|
550
|
+
onLedgerHead(listener) {
|
|
551
|
+
this.headListeners.add(listener);
|
|
552
|
+
return () => { this.headListeners.delete(listener); };
|
|
553
|
+
}
|
|
554
|
+
ledgerTask(run) {
|
|
555
|
+
const next = this.ledgerTail.then(run);
|
|
556
|
+
this.ledgerTail = next.catch(() => undefined);
|
|
557
|
+
return next;
|
|
558
|
+
}
|
|
559
|
+
/** This bridge's keys, exactly as its add_bridge lists them. */
|
|
560
|
+
ownKeys() {
|
|
561
|
+
return { bridgeId: this.bridgeId, endpointId: this.endpoint.id, endpointPublicKey: this.endpoint.publicKey, authPublicKey: this.options.ledger?.authPublicKey ?? '' };
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* This bridge in `trusted` (applyRevocations of the projected RAW chain `ledger`). 'active' = listed with exactly its keys. 'revoked' =
|
|
565
|
+
* the RAW chain proves the removal of THIS bridge (ledgerRemovesBridge: it added these keys, then revoked the id), the only status that
|
|
566
|
+
* resets the identity, decided exactly like the startup check (store.ownBridgeStatus). A sticky-only id, a removal seen on a refused
|
|
567
|
+
* history (C1c `seen`, never merged for our own id anyway) or a tombstone of our id is 'absent': fail closed, never a reset.
|
|
568
|
+
*/
|
|
569
|
+
ownOf(trusted, ledger) {
|
|
570
|
+
if (trusted === null)
|
|
571
|
+
return 'unpaired';
|
|
572
|
+
if (trusted.accountId !== this.endpoint.ownerId)
|
|
573
|
+
return 'absent';
|
|
574
|
+
const own = this.ownKeys();
|
|
575
|
+
if (trusted.bridges.some(item => item.bridgeId === own.bridgeId && item.authPublicKey === own.authPublicKey && item.endpoint.id === own.endpointId
|
|
576
|
+
&& item.endpoint.publicKey === own.endpointPublicKey))
|
|
577
|
+
return 'active';
|
|
578
|
+
return ledger !== null && ledgerRemovesBridge(ledger, own) ? 'revoked' : 'absent';
|
|
579
|
+
}
|
|
580
|
+
/** `trusted` is always the store's applyRevocations output, never its raw checkpoint. Own status acts before the policy write, so
|
|
581
|
+
* neither the revocation nor its emission waits on (or is lost to) a policy storage failure. */
|
|
582
|
+
async projectState(state) {
|
|
583
|
+
// The stored chain's record heads let an echo of it (or of a prefix) be answered without verification.
|
|
584
|
+
if (this.heads === null || this.heads.of !== state.checkpoint.head) {
|
|
585
|
+
try {
|
|
586
|
+
this.heads = { of: state.checkpoint.head, list: await ledgerHeads(state.ledger) };
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
this.heads = null;
|
|
590
|
+
}
|
|
332
591
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
592
|
+
this.held = state;
|
|
593
|
+
return this.project(state.trusted, state.lastServerReadAt, state.divergences, state.ledger);
|
|
594
|
+
}
|
|
595
|
+
async project(trusted, lastServerReadAt, divergences = [], ledger = null) {
|
|
596
|
+
const own = this.ownOf(trusted, ledger);
|
|
597
|
+
this.trustedView = trusted !== null && trusted.accountId === this.endpoint.ownerId ? trusted : null;
|
|
598
|
+
if (trusted === null) {
|
|
599
|
+
this.held = null;
|
|
600
|
+
this.heads = null;
|
|
337
601
|
}
|
|
338
|
-
|
|
602
|
+
const projection = this.policy.projectLedger({ trusted, own, lastServerReadAt });
|
|
603
|
+
const previous = this.status.own;
|
|
604
|
+
const previousHead = this.status.head;
|
|
605
|
+
// Server first: a server holding another history matters more than a relay publisher that does.
|
|
606
|
+
const shown = divergences.find(entry => entry.source === 'server') ?? divergences[0];
|
|
607
|
+
const divergence = shown === undefined || trusted === null ? null : {
|
|
608
|
+
source: shown.source, otherHead: shortHead(shown.otherHead), otherRevision: shown.otherRevision, ourRevision: trusted.revision, firstSeen: shown.firstSeen,
|
|
609
|
+
forkRevision: shown.forkRevision, removedOnOtherHistory: shown.removedOnOtherHistory,
|
|
610
|
+
};
|
|
611
|
+
this.status = {
|
|
612
|
+
accountId: trusted?.accountId ?? null, revision: trusted?.revision ?? null, head: trusted?.head ?? null, own, divergence, serverBehind: this.serverBehind,
|
|
613
|
+
removedOnOtherHistory: trusted !== null && divergences.some(entry => entry.removedOnOtherHistory),
|
|
614
|
+
};
|
|
615
|
+
if (own === 'revoked')
|
|
339
616
|
this.emitBridgeRevoked();
|
|
340
|
-
|
|
617
|
+
// Not listed is not a signed revocation: fail closed and say so, but never reset the identity on it.
|
|
618
|
+
else if (own === 'absent' && previous !== 'absent')
|
|
619
|
+
console.warn('[Security] This bridge is not in its account ledger; account devices are refused.');
|
|
620
|
+
if (own !== previous || this.status.head !== previousHead)
|
|
621
|
+
this.emitLedgerHead();
|
|
622
|
+
this.emitRevoked(await projection);
|
|
623
|
+
return own;
|
|
624
|
+
}
|
|
625
|
+
emitLedgerHead() {
|
|
626
|
+
for (const listener of Array.from(this.headListeners)) {
|
|
627
|
+
try {
|
|
628
|
+
listener();
|
|
629
|
+
}
|
|
630
|
+
catch (error) {
|
|
631
|
+
console.error('[Security] ledgerHead listener failed:', error);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
341
634
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
return confirmed;
|
|
635
|
+
emitRevoked(endpointIds) {
|
|
636
|
+
for (const id of endpointIds)
|
|
637
|
+
for (const listener of this.revoked)
|
|
638
|
+
listener(id);
|
|
347
639
|
}
|
|
348
640
|
emitBridgeRevoked() {
|
|
349
641
|
if (this.bridgeRevokedEmitted)
|
|
350
642
|
return;
|
|
351
643
|
this.bridgeRevokedEmitted = true;
|
|
352
|
-
if (this.accountTimer)
|
|
353
|
-
clearTimeout(this.accountTimer);
|
|
354
|
-
this.accountTimer = null;
|
|
355
|
-
this.accountTrigger?.dispose();
|
|
356
|
-
this.accountTrigger = null;
|
|
357
644
|
for (const listener of Array.from(this.bridgeRevokedListeners))
|
|
358
645
|
this.notifyBridgeRevoked(listener);
|
|
359
646
|
}
|
|
@@ -371,65 +658,53 @@ export class BridgeSecurityService {
|
|
|
371
658
|
failed(error);
|
|
372
659
|
}
|
|
373
660
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
if (this.closed)
|
|
387
|
-
return;
|
|
388
|
-
const result = await this.adoptFetched(history, () => this.policy.applyAccountRead(history));
|
|
389
|
-
this.emitRevoked(result.revoked);
|
|
390
|
-
}
|
|
391
|
-
catch (error) {
|
|
392
|
-
// Unverified revocation evidence is only an unavailable read: a forged refusal must neither reset nor block.
|
|
393
|
-
if (error instanceof AccountBridgeRevoked)
|
|
394
|
-
await this.confirmRevocationEvidence(error.evidence);
|
|
395
|
-
else if (!(error instanceof AccountReadUnavailable))
|
|
396
|
-
this.policy.blockAccount(this.accountFailure(error).code);
|
|
397
|
-
if (!await this.policy.accountAvailable())
|
|
398
|
-
this.emitRevoked(await this.policy.accountEndpointIds());
|
|
399
|
-
throw error;
|
|
400
|
-
}
|
|
401
|
-
};
|
|
402
|
-
this.accountRefresh = run().finally(() => { this.accountRefresh = null; });
|
|
403
|
-
return this.accountRefresh;
|
|
404
|
-
}
|
|
405
|
-
async adoptAccount(actor, head) {
|
|
406
|
-
await this.policy.revalidate(actor, 'admin');
|
|
407
|
-
if (actor.endpoint.kind !== 'browser' || !this.options.readAccountHistory)
|
|
408
|
-
throw new BridgeSecurityError('account_approval_required');
|
|
409
|
-
if (this.bridgeRevokedEmitted)
|
|
410
|
-
throw new BridgeSecurityError('account_bridge_revoked');
|
|
661
|
+
onRevoked(listener) { this.revoked.add(listener); return () => this.revoked.delete(listener); }
|
|
662
|
+
/**
|
|
663
|
+
* Account mode only: fires when a request (hello or application) is refused with approval_required for an endpoint the trusted
|
|
664
|
+
* ledger does not know, e.g. a browser whose add_device this replica has not seen yet (the wiring refreshes the ledger). Never for
|
|
665
|
+
* a revoked endpoint, never in Solo/local, never for the provisioned local CLI. At most once per endpoint per 2 s; listener errors
|
|
666
|
+
* are logged; the refusal itself is unchanged.
|
|
667
|
+
*/
|
|
668
|
+
onApprovalRequired(listener) {
|
|
669
|
+
this.approvalListeners.add(listener);
|
|
670
|
+
return () => { this.approvalListeners.delete(listener); };
|
|
671
|
+
}
|
|
672
|
+
async senderPrincipal(endpointId) {
|
|
411
673
|
try {
|
|
412
|
-
|
|
413
|
-
revision: z.number().int().min(0).max(ACCOUNT_TRUST_LIMITS.maxTransitions) }).strict().parse(head);
|
|
414
|
-
const history = await this.options.readAccountHistory(this.accountAbort.signal);
|
|
415
|
-
if (!history)
|
|
416
|
-
throw new BridgeSecurityError('account_approval_required');
|
|
417
|
-
if (this.closed)
|
|
418
|
-
throw new BridgeSecurityError('account_storage_unavailable');
|
|
419
|
-
const result = await this.adoptFetched(history, () => this.policy.applyAccountRead(history, { actor, head: parsedHead }));
|
|
420
|
-
this.emitRevoked(result.revoked);
|
|
421
|
-
return result.head;
|
|
674
|
+
return await this.policy.principal(endpointId);
|
|
422
675
|
}
|
|
423
676
|
catch (error) {
|
|
424
|
-
if (error instanceof
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
677
|
+
if (error instanceof BridgeSecurityError && error.code === 'approval_required')
|
|
678
|
+
this.noteApprovalRequired(endpointId);
|
|
679
|
+
throw error;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
noteApprovalRequired(endpointId) {
|
|
683
|
+
if (!this.options.ledger || this.closed || this.approvalListeners.size === 0)
|
|
684
|
+
return;
|
|
685
|
+
const trusted = this.trustedView;
|
|
686
|
+
if (trusted !== null && (trusted.revokedDeviceIds.includes(endpointId) || trusted.devices.some(item => item.endpoint.id === endpointId)))
|
|
687
|
+
return;
|
|
688
|
+
const at = this.ledgerMonotonic();
|
|
689
|
+
for (const [id, emittedAt] of this.approvalEmitted) {
|
|
690
|
+
if (!(at - emittedAt < APPROVAL_SPACING_MS && at >= emittedAt))
|
|
691
|
+
this.approvalEmitted.delete(id);
|
|
692
|
+
}
|
|
693
|
+
if (this.approvalEmitted.has(endpointId) || this.approvalEmitted.size >= APPROVAL_TRACKED_LIMIT)
|
|
694
|
+
return;
|
|
695
|
+
this.approvalEmitted.set(endpointId, at);
|
|
696
|
+
const failed = (error) => console.error('[Security] approvalRequired listener failed:', error);
|
|
697
|
+
for (const listener of Array.from(this.approvalListeners)) {
|
|
698
|
+
try {
|
|
699
|
+
const result = listener(endpointId);
|
|
700
|
+
if (result instanceof Promise)
|
|
701
|
+
result.catch(failed);
|
|
702
|
+
}
|
|
703
|
+
catch (error) {
|
|
704
|
+
failed(error);
|
|
428
705
|
}
|
|
429
|
-
throw this.accountFailure(error);
|
|
430
706
|
}
|
|
431
707
|
}
|
|
432
|
-
onRevoked(listener) { this.revoked.add(listener); return () => this.revoked.delete(listener); }
|
|
433
708
|
/** Fires at most once per instance, only on signed proof that this bridge was revoked. */
|
|
434
709
|
onBridgeRevoked(listener) {
|
|
435
710
|
this.bridgeRevokedListeners.add(listener);
|
|
@@ -441,16 +716,13 @@ export class BridgeSecurityService {
|
|
|
441
716
|
}
|
|
442
717
|
async close() {
|
|
443
718
|
this.closed = true;
|
|
444
|
-
this.
|
|
445
|
-
this.accountTrigger?.dispose();
|
|
446
|
-
this.accountTrigger = null;
|
|
447
|
-
if (this.accountTimer)
|
|
448
|
-
clearTimeout(this.accountTimer);
|
|
449
|
-
this.accountTimer = null;
|
|
450
|
-
await this.accountRefresh?.catch(() => undefined);
|
|
719
|
+
await this.ledgerTail.catch(() => undefined);
|
|
451
720
|
this.revoked.clear();
|
|
452
721
|
this.bridgeRevokedListeners.clear();
|
|
722
|
+
this.approvalListeners.clear();
|
|
723
|
+
this.approvalEmitted.clear();
|
|
453
724
|
this.replay.clear();
|
|
725
|
+
this.headListeners.clear();
|
|
454
726
|
await this.policy.close();
|
|
455
727
|
}
|
|
456
728
|
}
|