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
@@ -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 { ACCOUNT_TRUST_LIMITS, AccountTrustError } from '../account-crypto.js';
9
- import { AccountReadUnavailable } from './account-client.js';
10
- import { AccountBridgeRevoked, confirmOwnRevocation, isOwnBridgeRevoked } from './revocation.js';
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
- const ACCOUNT_READ_RETRY_BASE_MS = 60_000;
26
- const ACCOUNT_READ_RETRY_MAX_MS = 1_800_000;
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
- accountTimer = null;
43
- accountTimerDue = 0;
44
- accountPushHealthy = false;
45
- accountReadFailures = 0;
46
- accountRefresh = null;
47
- accountTrigger = null;
48
- accountAbort = new AbortController();
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.policy.principal(envelope.senderId);
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.policy.principal(envelope.senderId);
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', 'invalid_account_trust'].includes(error.code)
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
- /** Called once at startup; legacy/Solo stores never adopt an unsigned server directory. */
247
- async startAccount(trust) {
248
- // A pin that already tombstones this bridge is signed proof on its own; no read can undo it.
249
- if (isOwnBridgeRevoked(await this.pinnedCheckpoint(), this.bridgeId)) {
250
- this.emitBridgeRevoked();
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
- if (trust)
254
- await this.policy.pinBootstrap(trust);
255
- this.recordAccountRead(await this.refreshAccount().then(() => true, () => false));
256
- if (!this.closed && !this.accountTimer && this.options.readAccountHistory)
257
- this.scheduleAccountRead();
258
- }
259
- recordAccountRead(ok) {
260
- this.accountReadFailures = ok ? 0 : this.accountReadFailures + 1;
261
- }
262
- /** After failures the server is the only writer of account state, so retrying fast buys nothing: back off with jitter. */
263
- nextAccountReadDelay() {
264
- if (this.accountReadFailures > 0) {
265
- const base = Math.min(ACCOUNT_READ_RETRY_BASE_MS * 2 ** (this.accountReadFailures - 1), ACCOUNT_READ_RETRY_MAX_MS);
266
- return base * (0.8 + 0.4 * (this.options.random ?? Math.random)());
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
- return this.accountPushHealthy ? ACCOUNT_SAFETY_REFRESH_MS : ACCOUNT_TRUST_LIMITS.refreshIntervalMs;
269
- }
270
- /** Safety read: 1 h while push is healthy and the last read succeeded, else the 10 min cadence; failed reads back off from 60 s to 30 min. */
271
- scheduleAccountRead(delay = this.nextAccountReadDelay()) {
272
- if (this.closed || this.bridgeRevokedEmitted || !this.options.readAccountHistory)
273
- return;
274
- if (this.accountTimer)
275
- clearTimeout(this.accountTimer);
276
- this.accountTimerDue = performance.now() + delay; // monotonic: wall-clock steps and sleep must not skew the pull-in
277
- this.accountTimer = setTimeout(() => { this.accountTimer = null; void this.trackedAccountRead(); }, delay);
278
- this.accountTimer.unref();
279
- }
280
- async trackedAccountRead() {
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 this.refreshAccount();
283
- this.recordAccountRead(true);
396
+ result = await ledger.store.offer(candidate, source);
284
397
  }
285
- catch {
286
- this.recordAccountRead(false);
398
+ catch (error) {
399
+ console.error('[Security] Account ledger offer failed:', reason(error));
400
+ return REJECTED;
287
401
  }
288
- this.scheduleAccountRead();
289
- }
290
- /** Push (nudge subscription) health; losing it pulls the next safety read in to the 10 min cadence. */
291
- setAccountPushHealthy(healthy) {
292
- const lost = this.accountPushHealthy && !healthy;
293
- this.accountPushHealthy = healthy;
294
- if (lost && this.accountTimer && this.accountTimerDue - performance.now() > ACCOUNT_TRUST_LIMITS.refreshIntervalMs) {
295
- this.scheduleAccountRead(ACCOUNT_TRUST_LIMITS.refreshIntervalMs);
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
- /** Push-driven re-read (account nudge); bursts coalesce into at most one trailing read. */
299
- requestAccountRefresh() {
300
- if (this.closed || this.bridgeRevokedEmitted || !this.options.readAccountHistory)
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.accountTrigger ??= createCoalescedTrigger(async () => {
303
- // An in-flight read may predate the change, so it never satisfies a request.
304
- await this.accountRefresh?.catch(() => undefined);
305
- if (!this.closed)
306
- await this.trackedAccountRead();
307
- }, SECURITY_NUDGE_MIN_SPACING_MS);
308
- this.accountTrigger.request();
309
- }
310
- accountFailure(error) {
311
- return error instanceof BridgeSecurityError ? error : new BridgeSecurityError(error instanceof AccountTrustError ? error.code : 'invalid_account_trust');
312
- }
313
- emitRevoked(endpointIds) {
314
- for (const id of endpointIds)
315
- for (const listener of this.revoked)
316
- listener(id);
317
- }
318
- async pinnedCheckpoint() {
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
- return (await this.policy.accountTrust())?.checkpoint ?? null;
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
- return null;
540
+ relation = null;
324
541
  }
325
- }
326
- /** A refused fetched history is still evidence, but only against the pin as it stands now: adoption is not serialized
327
- * with reads, and a fork from an older pin must never confirm. Pins only move forward by verified extension. */
328
- async adoptFetched(fetched, adopt) {
329
- let adopted;
330
- try {
331
- adopted = await adopt();
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
- catch (error) {
334
- if (!this.bridgeRevokedEmitted)
335
- await this.confirmRevocationEvidence(fetched);
336
- throw error;
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
- if (!this.bridgeRevokedEmitted && isOwnBridgeRevoked(await this.pinnedCheckpoint(), this.bridgeId))
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
- return adopted;
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
- async confirmRevocationEvidence(evidence) {
343
- const confirmed = await confirmOwnRevocation({ evidence, pinned: await this.pinnedCheckpoint(), bridgeId: this.bridgeId, now: this.now() });
344
- if (confirmed)
345
- this.emitBridgeRevoked();
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
- async refreshAccount() {
375
- if (this.accountRefresh)
376
- return this.accountRefresh;
377
- const run = async () => {
378
- if (this.closed || this.bridgeRevokedEmitted || !await this.policy.accountTrust())
379
- return;
380
- try {
381
- if (!this.options.readAccountHistory)
382
- throw new AccountReadUnavailable();
383
- const history = await this.options.readAccountHistory(this.accountAbort.signal);
384
- if (!history)
385
- throw new BridgeSecurityError('account_trust_rollback');
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
- const parsedHead = z.object({ rootId: z.string().regex(/^[A-Za-z0-9_-]{43}$/), head: z.string().regex(/^[A-Za-z0-9_-]{43}$/),
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 AccountReadUnavailable)
425
- throw new BridgeSecurityError('account_storage_unavailable');
426
- if (error instanceof AccountBridgeRevoked) {
427
- throw new BridgeSecurityError(await this.confirmRevocationEvidence(error.evidence) ? 'account_bridge_revoked' : 'account_storage_unavailable');
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.accountAbort.abort();
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
  }