ftown 0.21.0 → 0.25.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 (107) 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 +14 -1
  6. package/dist/centrifugo-client.js +38 -4
  7. package/dist/centrifugo-client.js.map +1 -1
  8. package/dist/command-rpc.d.ts +2 -0
  9. package/dist/command-rpc.js +55 -9
  10. package/dist/command-rpc.js.map +1 -1
  11. package/dist/direct-transport/loopback-server.d.ts +10 -3
  12. package/dist/direct-transport/loopback-server.js +20 -4
  13. package/dist/direct-transport/loopback-server.js.map +1 -1
  14. package/dist/direct-transport/peer-manager.d.ts +8 -3
  15. package/dist/direct-transport/peer-manager.js +20 -5
  16. package/dist/direct-transport/peer-manager.js.map +1 -1
  17. package/dist/file-stamp.d.ts +33 -0
  18. package/dist/file-stamp.js +35 -0
  19. package/dist/file-stamp.js.map +1 -0
  20. package/dist/identity-reset.d.ts +3 -1
  21. package/dist/identity-reset.js +27 -5
  22. package/dist/identity-reset.js.map +1 -1
  23. package/dist/index.js +31 -37
  24. package/dist/index.js.map +1 -1
  25. package/dist/ledger-pairing-client.d.ts +1 -1
  26. package/dist/ledger-pairing-client.js +5 -3
  27. package/dist/ledger-pairing-client.js.map +1 -1
  28. package/dist/ledger-relay.d.ts +42 -7
  29. package/dist/ledger-relay.js +175 -33
  30. package/dist/ledger-relay.js.map +1 -1
  31. package/dist/ledger-reoffer.d.ts +56 -0
  32. package/dist/ledger-reoffer.js +210 -0
  33. package/dist/ledger-reoffer.js.map +1 -0
  34. package/dist/ledger-startup.d.ts +30 -5
  35. package/dist/ledger-startup.js +40 -10
  36. package/dist/ledger-startup.js.map +1 -1
  37. package/dist/local-api-server.d.ts +3 -0
  38. package/dist/local-api-server.js +68 -6
  39. package/dist/local-api-server.js.map +1 -1
  40. package/dist/mail-delivery.d.ts +26 -3
  41. package/dist/mail-delivery.js +65 -7
  42. package/dist/mail-delivery.js.map +1 -1
  43. package/dist/mail-store.d.ts +15 -1
  44. package/dist/mail-store.js +78 -16
  45. package/dist/mail-store.js.map +1 -1
  46. package/dist/outbound-pipeline-types.d.ts +5 -2
  47. package/dist/outbound-pipeline-types.js.map +1 -1
  48. package/dist/preview-protocol.d.ts +19 -3
  49. package/dist/preview-protocol.js +14 -0
  50. package/dist/preview-protocol.js.map +1 -1
  51. package/dist/relay-token.d.ts +6 -0
  52. package/dist/relay-token.js +9 -1
  53. package/dist/relay-token.js.map +1 -1
  54. package/dist/security/bridge/ledger-rpc.d.ts +34 -0
  55. package/dist/security/bridge/ledger-rpc.js +58 -0
  56. package/dist/security/bridge/ledger-rpc.js.map +1 -0
  57. package/dist/security/bridge/ledger-store.d.ts +82 -13
  58. package/dist/security/bridge/ledger-store.js +394 -54
  59. package/dist/security/bridge/ledger-store.js.map +1 -1
  60. package/dist/security/bridge/permissions.js +2 -1
  61. package/dist/security/bridge/permissions.js.map +1 -1
  62. package/dist/security/bridge/policy.d.ts +5 -1
  63. package/dist/security/bridge/policy.js +17 -0
  64. package/dist/security/bridge/policy.js.map +1 -1
  65. package/dist/security/bridge/security.d.ts +148 -10
  66. package/dist/security/bridge/security.js +270 -65
  67. package/dist/security/bridge/security.js.map +1 -1
  68. package/dist/security/crypto.d.ts +2 -0
  69. package/dist/security/crypto.js +43 -4
  70. package/dist/security/crypto.js.map +1 -1
  71. package/dist/security/ledger/announce.d.ts +32 -0
  72. package/dist/security/ledger/announce.js +101 -0
  73. package/dist/security/ledger/announce.js.map +1 -0
  74. package/dist/security/ledger/ledger.d.ts +107 -14
  75. package/dist/security/ledger/ledger.js +364 -83
  76. package/dist/security/ledger/ledger.js.map +1 -1
  77. package/dist/security/ledger/proof.d.ts +12 -0
  78. package/dist/security/ledger/proof.js +40 -8
  79. package/dist/security/ledger/proof.js.map +1 -1
  80. package/dist/security/ledger/protocol.d.ts +14 -1
  81. package/dist/security/ledger/protocol.js.map +1 -1
  82. package/dist/session-controller.d.ts +9 -3
  83. package/dist/session-controller.js +120 -20
  84. package/dist/session-controller.js.map +1 -1
  85. package/dist/session-inbox-protocol.d.ts +2 -0
  86. package/dist/session-preview.d.ts +20 -5
  87. package/dist/session-preview.js +185 -37
  88. package/dist/session-preview.js.map +1 -1
  89. package/dist/session-store.d.ts +22 -0
  90. package/dist/session-store.js +180 -13
  91. package/dist/session-store.js.map +1 -1
  92. package/dist/sessions-commands.js +41 -7
  93. package/dist/sessions-commands.js.map +1 -1
  94. package/dist/terminal-manager.d.ts +8 -0
  95. package/dist/terminal-manager.js +23 -1
  96. package/dist/terminal-manager.js.map +1 -1
  97. package/dist/terminal-output.d.ts +34 -0
  98. package/dist/terminal-output.js +68 -0
  99. package/dist/terminal-output.js.map +1 -0
  100. package/dist/terminal-pump.d.ts +48 -5
  101. package/dist/terminal-pump.js +143 -20
  102. package/dist/terminal-pump.js.map +1 -1
  103. package/dist/types.d.ts +1 -1
  104. package/opencode-plugin/ftown.js +15 -2
  105. package/package.json +2 -2
  106. package/skills/ftown/SKILL.md +18 -7
  107. package/skills/ftown/references/sessions.md +35 -10
@@ -5,6 +5,7 @@ const messages = {
5
5
  ledger_other_account: 'Account ledger entry belongs to a different account.',
6
6
  ledger_revoked: 'Account ledger entry refers to a revoked key.',
7
7
  ledger_limit: 'Account ledger reached its supported capacity.',
8
+ resolution_incomplete: 'Account ledger resolution must first carry the other history\'s removals and passkeys.',
8
9
  };
9
10
  /** No untrusted text, key material or underlying crypto error enters this error. */
10
11
  export class LedgerError extends Error {
@@ -166,6 +167,13 @@ function parseOperation(value) {
166
167
  const fields = exact(record, ['type', 'authority', 'consent']);
167
168
  return { type: 'add_authority', authority: parseAuthority(fields.authority), consent: parseSignature(fields.consent) };
168
169
  }
170
+ case 'resolve_fork': {
171
+ const fields = exact(record, ['type', 'forkHead', 'discardedFirst', 'discardedHead', 'discardedRevision']);
172
+ return {
173
+ type: 'resolve_fork', forkHead: fingerprint(fields.forkHead), discardedFirst: fingerprint(fields.discardedFirst),
174
+ discardedHead: fingerprint(fields.discardedHead), discardedRevision: count(fields.discardedRevision, 1),
175
+ };
176
+ }
169
177
  default: return fail();
170
178
  }
171
179
  }
@@ -291,19 +299,25 @@ function apply(current, operation, issuedAt) {
291
299
  next.bridges.push(added);
292
300
  break;
293
301
  }
302
+ // A revoked id is a permanent tombstone. Revoking an id that is not a member only tombstones it, so it can never be added later: that is
303
+ // how a resolution carries a removal made on the other history. A device revocation never names a bridge member's endpoint.
294
304
  case 'revoke_device': {
305
+ if (next.revokedDeviceIds.indexOf(operation.endpointId) !== -1)
306
+ fail('ledger_revoked');
307
+ if (next.bridges.some(item => item.endpoint.id === operation.endpointId))
308
+ fail();
295
309
  const index = next.devices.findIndex(item => item.endpoint.id === operation.endpointId);
296
- if (index === -1)
297
- fail(next.revokedDeviceIds.indexOf(operation.endpointId) !== -1 ? 'ledger_revoked' : 'invalid_ledger');
298
- next.devices.splice(index, 1);
310
+ if (index !== -1)
311
+ next.devices.splice(index, 1);
299
312
  next.revokedDeviceIds.push(operation.endpointId);
300
313
  break;
301
314
  }
302
315
  case 'revoke_bridge': {
316
+ if (next.revokedBridgeIds.indexOf(operation.bridgeId) !== -1)
317
+ fail('ledger_revoked');
303
318
  const index = next.bridges.findIndex(item => item.bridgeId === operation.bridgeId);
304
- if (index === -1)
305
- fail(next.revokedBridgeIds.indexOf(operation.bridgeId) !== -1 ? 'ledger_revoked' : 'invalid_ledger');
306
- next.bridges.splice(index, 1);
319
+ if (index !== -1)
320
+ next.bridges.splice(index, 1);
307
321
  next.revokedBridgeIds.push(operation.bridgeId);
308
322
  break;
309
323
  }
@@ -318,10 +332,108 @@ function apply(current, operation, issuedAt) {
318
332
  next.authorities.push(added);
319
333
  break;
320
334
  }
335
+ case 'resolve_fork': {
336
+ // Membership is unchanged. Only the rules that need no history are here; settle() checks the heads against the chain itself.
337
+ const own = [operation.forkHead, next.head, next.accountId];
338
+ if (own.indexOf(operation.discardedFirst) !== -1 || own.indexOf(operation.discardedHead) !== -1)
339
+ fail();
340
+ break;
341
+ }
321
342
  default: return fail();
322
343
  }
323
344
  return next;
324
345
  }
346
+ function joinedAt(trail, credentialId) {
347
+ for (const item of trail.joined)
348
+ if (item.credentialId === credentialId)
349
+ return item.revision;
350
+ return -1;
351
+ }
352
+ /** History rules of resolve_fork, against the chain before the record: forkHead is one of its heads and the signer was an authority at it;
353
+ * the discarded branch (discardedFirst ... discardedHead at discardedRevision) starts after it and is no part of this chain; its first record
354
+ * is its tip exactly when it is one record long. */
355
+ function settle(trail, operation, credentialId) {
356
+ if (operation.type !== 'resolve_fork')
357
+ return;
358
+ const fork = trail.heads.indexOf(operation.forkHead);
359
+ if (fork === -1 || operation.discardedRevision <= fork)
360
+ fail();
361
+ if (trail.heads.indexOf(operation.discardedFirst) !== -1 || trail.heads.indexOf(operation.discardedHead) !== -1)
362
+ fail();
363
+ if ((operation.discardedRevision === fork + 1) !== (operation.discardedHead === operation.discardedFirst))
364
+ fail();
365
+ const joined = joinedAt(trail, credentialId);
366
+ if (joined === -1 || joined > fork)
367
+ fail();
368
+ }
369
+ /** Revoked ids and authorities of a chain from its payloads alone; equals its checkpoint's for a chain already verified or vouched for. */
370
+ function holdings(ledger) {
371
+ const held = { revokedDeviceIds: [], revokedBridgeIds: [], authorities: [ledger.genesis.payload.authority] };
372
+ for (const record of ledger.transitions) {
373
+ const operation = record.payload.operation;
374
+ if (operation.type === 'revoke_device')
375
+ held.revokedDeviceIds.push(operation.endpointId);
376
+ else if (operation.type === 'revoke_bridge')
377
+ held.revokedBridgeIds.push(operation.bridgeId);
378
+ else if (operation.type === 'add_authority')
379
+ held.authorities.push(operation.authority);
380
+ }
381
+ return held;
382
+ }
383
+ /** The security-relevant operations of `branch` that `held` lacks, in branch order: one revocation per id `held` has not revoked, and one
384
+ * add_authority (the original authority and consent) per authority `held` does not hold with the same credentialId and key. */
385
+ function missing(branch, held) {
386
+ const devices = held.revokedDeviceIds.slice();
387
+ const bridges = held.revokedBridgeIds.slice();
388
+ const authorities = held.authorities.slice();
389
+ const carry = [];
390
+ for (const record of branch) {
391
+ const operation = record.payload.operation;
392
+ if (operation.type === 'revoke_device' && devices.indexOf(operation.endpointId) === -1) {
393
+ devices.push(operation.endpointId);
394
+ carry.push({ type: 'revoke_device', endpointId: operation.endpointId });
395
+ }
396
+ else if (operation.type === 'revoke_bridge' && bridges.indexOf(operation.bridgeId) === -1) {
397
+ bridges.push(operation.bridgeId);
398
+ carry.push({ type: 'revoke_bridge', bridgeId: operation.bridgeId });
399
+ }
400
+ else if (operation.type === 'add_authority') {
401
+ const added = operation.authority;
402
+ if (authorities.some(item => item.credentialId === added.credentialId && item.publicKey === added.publicKey))
403
+ continue;
404
+ authorities.push(added);
405
+ carry.push({ type: 'add_authority', authority: { ...added }, consent: { ...operation.consent } });
406
+ }
407
+ }
408
+ return carry;
409
+ }
410
+ function note(trail, payload, head) {
411
+ trail.heads.push(head);
412
+ if (payload.operation.type === 'add_authority')
413
+ trail.joined.push({ credentialId: payload.operation.authority.credentialId, revision: payload.revision });
414
+ }
415
+ function initialState(root, accountId) {
416
+ return { version: 2, accountId, revision: 0, head: accountId, authorities: [root.authority], devices: [], bridges: [], revokedDeviceIds: [], revokedBridgeIds: [] };
417
+ }
418
+ function initialTrail(root, accountId) {
419
+ return { heads: [accountId], joined: [{ credentialId: root.authority.credentialId, revision: 0 }] };
420
+ }
421
+ /** The trail of the first `length` records of a chain already verified or vouched for; no signature work. */
422
+ function trailOf(chain, length) {
423
+ const trail = initialTrail(chain.ledger.genesis.payload, chain.accountId);
424
+ for (let index = 0; index < length; index += 1)
425
+ note(trail, chain.ledger.transitions[index].payload, chain.heads[index + 1]);
426
+ return trail;
427
+ }
428
+ /** The checkpoint after the first `length` records of a chain already verified or vouched for: the verifier's state rules, no signature work. */
429
+ function replay(chain, length) {
430
+ let state = initialState(chain.ledger.genesis.payload, chain.accountId);
431
+ for (let index = 0; index < length; index += 1) {
432
+ const payload = chain.ledger.transitions[index].payload;
433
+ state = { ...apply(state, payload.operation, payload.issuedAt), revision: payload.revision, head: chain.heads[index + 1] };
434
+ }
435
+ return state;
436
+ }
325
437
  function copyOf(state) {
326
438
  return {
327
439
  version: 2, accountId: state.accountId, revision: state.revision, head: state.head,
@@ -335,8 +447,9 @@ async function consented(operation, accountId, root) {
335
447
  if (!await verifyPasskeyAssertion({ rpId: root.rpId, origins: root.origins, publicKey: operation.authority.publicKey, challenge, signature: operation.consent }))
336
448
  fail();
337
449
  }
338
- /** Verifies `records` in order on top of a verified `state` whose last record was issued at `previousTime`. */
339
- async function extend(state, root, previousTime, records, at) {
450
+ /** Verifies `records` in order on top of a verified `state` whose last record was issued at `previousTime`; `trail` describes the chain up
451
+ * to `state` and is extended in place. */
452
+ async function extend(state, root, previousTime, records, at, trail) {
340
453
  let current = state;
341
454
  let lastTime = previousTime;
342
455
  for (const record of records) {
@@ -350,13 +463,16 @@ async function extend(state, root, previousTime, records, at) {
350
463
  if (payload.issuedAt < lastTime || payload.issuedAt > at + LEDGER_LIMITS.maxFutureSkewMs)
351
464
  fail();
352
465
  const signer = signerOf(current, payload.credentialId);
466
+ settle(trail, payload.operation, signer.credentialId);
353
467
  const challenge = await ledgerChallenge(payload);
354
468
  if (!await verifyPasskeyAssertion({ rpId: root.rpId, origins: root.origins, publicKey: signer.publicKey, challenge, signature: record.signature }))
355
469
  fail();
356
470
  await keyMaterial(payload.operation);
357
471
  if (payload.operation.type === 'add_authority')
358
472
  await consented(payload.operation, current.accountId, root);
359
- current = { ...apply(current, payload.operation, payload.issuedAt), revision: payload.revision, head: b64url(challenge) };
473
+ const head = b64url(challenge);
474
+ current = { ...apply(current, payload.operation, payload.issuedAt), revision: payload.revision, head };
475
+ note(trail, payload, head);
360
476
  lastTime = payload.issuedAt;
361
477
  }
362
478
  return current;
@@ -370,10 +486,7 @@ async function verifyAll(ledger, at) {
370
486
  if (!await verifyPasskeyAssertion({ rpId: root.rpId, origins: root.origins, publicKey: root.authority.publicKey, challenge, signature: ledger.genesis.signature }))
371
487
  fail();
372
488
  const accountId = b64url(challenge);
373
- const initial = {
374
- version: 2, accountId, revision: 0, head: accountId, authorities: [root.authority], devices: [], bridges: [], revokedDeviceIds: [], revokedBridgeIds: [],
375
- };
376
- return extend(initial, root, root.createdAt, ledger.transitions, at);
489
+ return extend(initialState(root, accountId), root, root.createdAt, ledger.transitions, at, initialTrail(root, accountId));
377
490
  }
378
491
  /** Unexpected (non-ledger) failures become invalid_ledger. */
379
492
  async function guarded(run) {
@@ -388,11 +501,10 @@ async function guarded(run) {
388
501
  }
389
502
  async function indexed(value) {
390
503
  const ledger = parseLedger(value);
391
- const accountId = await ledgerHead(ledger.genesis.payload);
392
- const heads = [];
504
+ const heads = [await ledgerHead(ledger.genesis.payload)];
393
505
  for (const record of ledger.transitions)
394
506
  heads.push(await ledgerHead(record.payload));
395
- return { ledger, accountId, heads, head: heads.length ? heads[heads.length - 1] : accountId };
507
+ return { ledger, accountId: heads[0], heads };
396
508
  }
397
509
  /** The cache vouches for `chain` only if it names the chain's last head and the chain is hash-linked up to it. */
398
510
  function vouched(chain, cache) {
@@ -401,12 +513,13 @@ function vouched(chain, cache) {
401
513
  try {
402
514
  const record = object(cache);
403
515
  const checkpoint = parseCheckpoint(record.checkpoint);
404
- if (record.head !== chain.head || checkpoint.head !== chain.head || checkpoint.accountId !== chain.accountId
405
- || checkpoint.revision !== chain.heads.length)
516
+ const head = chain.heads[chain.heads.length - 1];
517
+ if (record.head !== head || checkpoint.head !== head || checkpoint.accountId !== chain.accountId
518
+ || checkpoint.revision !== chain.heads.length - 1)
406
519
  return null;
407
- for (let index = 0; index < chain.heads.length; index += 1) {
520
+ for (let index = 0; index < chain.ledger.transitions.length; index += 1) {
408
521
  const payload = chain.ledger.transitions[index].payload;
409
- if (payload.revision !== index + 1 || payload.previousHead !== (index === 0 ? chain.accountId : chain.heads[index - 1]))
522
+ if (payload.revision !== index + 1 || payload.previousHead !== chain.heads[index])
410
523
  return null;
411
524
  }
412
525
  return checkpoint;
@@ -415,39 +528,110 @@ function vouched(chain, cache) {
415
528
  return null;
416
529
  }
417
530
  }
418
- function sameSignature(left, right) {
419
- return left.authenticatorData === right.authenticatorData && left.clientDataJSON === right.clientDataJSON && left.signature === right.signature;
531
+ /** Index of the first revision whose heads differ, or the shorter length when one list is a prefix of the other. String compares only. */
532
+ function firstDifference(local, candidate) {
533
+ const length = Math.min(local.length, candidate.length);
534
+ let index = 0;
535
+ while (index < length && local[index] === candidate[index])
536
+ index += 1;
537
+ return index;
538
+ }
539
+ /** null when even the genesis differs (another account). */
540
+ function classify(local, candidate) {
541
+ const index = firstDifference(local, candidate);
542
+ if (index === 0)
543
+ return null;
544
+ if (index < local.length && index < candidate.length)
545
+ return 'diverged';
546
+ if (local.length === candidate.length)
547
+ return 'same';
548
+ return candidate.length > local.length ? 'ahead' : 'behind';
549
+ }
550
+ function headList(value) {
551
+ if (!Array.isArray(value))
552
+ return fail();
553
+ const heads = value;
554
+ if (heads.length < 1 || heads.length > LEDGER_LIMITS.maxTransitions + 1 || heads.some(item => typeof item !== 'string'))
555
+ return fail();
556
+ return value;
420
557
  }
421
- /** True iff the genesis and the first `length` records carry byte-identical signatures, so the candidate's prefix is the verified local one. */
422
- function sameRecords(local, candidate, length) {
423
- if (!sameSignature(local.genesis.signature, candidate.genesis.signature))
558
+ /** The fork revision of two diverged chains; ledger_other_account for another account, invalid_ledger for any other relation. */
559
+ function divergence(mine, theirs) {
560
+ const relation = classify(mine.heads, theirs.heads);
561
+ if (relation === null)
562
+ fail('ledger_other_account');
563
+ if (relation !== 'diverged')
564
+ fail();
565
+ return firstDifference(mine.heads, theirs.heads) - 1;
566
+ }
567
+ /**
568
+ * True iff the verified diverged candidate (checkpoint `adopted`) may replace local's branch, i.e. local's records after `fork`:
569
+ * - the candidate already holds every revocation and every authority that local's branch makes (nothing security-relevant is discarded), and
570
+ * - it carries, after the fork point, a resolve_fork R with
571
+ * 1. R.forkHead = the fork point and R.discardedFirst = local's first record after it (so a lagging or sibling replica of that branch matches);
572
+ * 2. if local's branch holds any resolve_fork (whatever it targets), R.discardedHead = local's OWN record at R.discardedRevision, at or after
573
+ * the latest one: R's payload hashes over it, so its signer saw that choice. A revision number alone is not proof (a signer who saw a
574
+ * sibling branch at a higher revision never saw it). Each adoption thus moves to a resolution that hashes over the one held before, and
575
+ * as hashes cannot form a cycle, no replica ever adopts its way back to a chain it held;
576
+ * 3. no record of local's branch is newer than R: issuedAt <= R.issuedAt + maxFutureSkewMs (the module's clock allowance).
577
+ */
578
+ function resolves(mine, theirs, fork, adopted) {
579
+ const branch = mine.ledger.transitions.slice(fork);
580
+ if (missing(branch, adopted).length)
424
581
  return false;
425
- for (let index = 0; index < length; index += 1) {
426
- if (!sameSignature(local.transitions[index].signature, candidate.transitions[index].signature))
427
- return false;
582
+ let seen = 0;
583
+ let newest = 0;
584
+ for (const record of branch) {
585
+ if (record.payload.operation.type === 'resolve_fork')
586
+ seen = record.payload.revision;
587
+ newest = Math.max(newest, record.payload.issuedAt);
428
588
  }
429
- return true;
430
- }
431
- function revocationCount(ledger) {
432
- let total = 0;
433
- for (const record of ledger.transitions) {
434
- const type = record.payload.operation.type;
435
- if (type === 'revoke_device' || type === 'revoke_bridge')
436
- total += 1;
589
+ for (const record of theirs.ledger.transitions.slice(fork)) {
590
+ const operation = record.payload.operation;
591
+ if (operation.type !== 'resolve_fork' || operation.forkHead !== mine.heads[fork] || operation.discardedFirst !== mine.heads[fork + 1])
592
+ continue;
593
+ if (seen > 0 && (operation.discardedRevision < seen || mine.heads[operation.discardedRevision] !== operation.discardedHead))
594
+ continue;
595
+ if (newest > record.payload.issuedAt + LEDGER_LIMITS.maxFutureSkewMs)
596
+ continue;
597
+ return true;
437
598
  }
438
- return total;
599
+ return false;
439
600
  }
440
- /** The fork order, strict and total on distinct chains of one account: longer, then more revocations in the whole chain, then the smaller head. */
441
- function outranks(candidate, local) {
442
- if (candidate.heads.length !== local.heads.length)
443
- return candidate.heads.length > local.heads.length;
444
- const revocations = revocationCount(candidate.ledger) - revocationCount(local.ledger);
445
- if (revocations !== 0)
446
- return revocations > 0;
447
- return candidate.head < local.head;
601
+ /** Local's records after the fork, and the revocations among them that the adopted chain lacks. */
602
+ function discardedBy(mine, fork, adopted) {
603
+ const records = mine.ledger.transitions.slice(fork);
604
+ const deviceIds = [];
605
+ const bridgeIds = [];
606
+ for (const record of records) {
607
+ const operation = record.payload.operation;
608
+ if (operation.type === 'revoke_device' && adopted.revokedDeviceIds.indexOf(operation.endpointId) === -1)
609
+ deviceIds.push(operation.endpointId);
610
+ if (operation.type === 'revoke_bridge' && adopted.revokedBridgeIds.indexOf(operation.bridgeId) === -1)
611
+ bridgeIds.push(operation.bridgeId);
612
+ }
613
+ return { records, deviceIds, bridgeIds };
614
+ }
615
+ /** LedgerSeen of a VERIFIED diverged candidate: revocations after `fork` signed by an authority of the shared prefix (joined at or before `fork`). */
616
+ function seenIn(mine, theirs, fork) {
617
+ const authorities = trailOf(mine, fork).joined.map(item => item.credentialId);
618
+ const seen = { deviceIds: [], bridgeIds: [] };
619
+ for (const record of theirs.ledger.transitions.slice(fork)) {
620
+ const { operation, credentialId } = record.payload;
621
+ if (authorities.indexOf(credentialId) === -1)
622
+ continue;
623
+ if (operation.type === 'revoke_device' && seen.deviceIds.indexOf(operation.endpointId) === -1)
624
+ seen.deviceIds.push(operation.endpointId);
625
+ if (operation.type === 'revoke_bridge' && seen.bridgeIds.indexOf(operation.bridgeId) === -1)
626
+ seen.bridgeIds.push(operation.bridgeId);
627
+ }
628
+ return seen;
448
629
  }
449
- function choice(decision, checkpoint, discarded) {
450
- return { decision, checkpoint: copyOf(checkpoint), discarded };
630
+ function choice(decision, relation, checkpoint, ledger, discarded, seen) {
631
+ return {
632
+ decision, relation, checkpoint: copyOf(checkpoint), discarded: discarded ?? { records: [], deviceIds: [], bridgeIds: [] }, ledger,
633
+ seen: seen ?? { deviceIds: [], bridgeIds: [] },
634
+ };
451
635
  }
452
636
  /** SHA-256(UTF-8(LEDGER_DOMAIN + canonical(payload))): the WebAuthn challenge, and the record's head. */
453
637
  export async function ledgerChallenge(payload) {
@@ -477,8 +661,8 @@ export function ledgerLastIssuedAt(ledger) {
477
661
  const last = ledger.transitions[ledger.transitions.length - 1];
478
662
  return time(last === undefined ? ledger.genesis.payload.createdAt : last.payload.issuedAt);
479
663
  }
480
- /** Every device endpoint id and bridgeId revoked anywhere in the chain, in chain order. Only for chains that verified (or that
481
- * chooseLedger did not reject): replicas keep the union over all of them and never trust those ids again. Throws on a malformed shape. */
664
+ /** Every device endpoint id and bridgeId revoked anywhere in the chain, in chain order. Only for chains that verified: replicas keep the
665
+ * union over the chains they held (plus chooseLedger's `discarded` ids) and never trust those ids again. Throws on a malformed shape. */
482
666
  export function ledgerRevocations(ledger) {
483
667
  const deviceIds = [];
484
668
  const bridgeIds = [];
@@ -527,17 +711,61 @@ export async function verifyLedger(ledger, now = Date.now()) {
527
711
  const at = time(now);
528
712
  return copyOf(await guarded(() => verifyAll(parseLedger(ledger), at)));
529
713
  }
530
- /** Fork choice, a pure total order on valid chains of one account: longer wins, then more revoke_device/revoke_bridge records in the
531
- * whole chain, then the lexicographically smaller head.
532
- * - different genesis or invalid candidate -> 'reject'; equal chain or a prefix of local -> 'keep' (decided from record heads alone)
533
- * - otherwise the order decides; when a fork wins, `discarded` lists the local records after the common prefix.
534
- * `cache` is the checkpoint of the already-verified local chain; when it vouches for `local`, local signatures and timestamps are not
535
- * re-checked, and an extension only has its new records verified. Throws only when `local` is invalid (and not vouched for), or when
536
- * `local` is null and the candidate is invalid. */
714
+ /** The head of every record, index = revision: [accountId, head of revision 1, ..., head of the tip]. O(n) SHA-256 over the parsed chain and no
715
+ * signature work; compute it once per chain and keep it for relate/forkPoint/relateHead. Throws like parseLedger. */
716
+ export async function ledgerHeads(ledger) {
717
+ return (await guarded(() => indexed(ledger))).heads;
718
+ }
719
+ /** How `candidate` relates to `local` (both ledgerHeads lists of chains of ONE account): 'same' (identical heads), 'ahead' (candidate strictly
720
+ * extends local), 'behind' (local strictly extends candidate), 'diverged' (they differ at some revision both have). Pure, O(min(n, m)) string
721
+ * compares, no hashing. Another account is not a relation: throws ledger_other_account (genesis heads differ); a malformed list throws
722
+ * invalid_ledger. Every head of the shorter list is compared, so a list whose tip matches but whose middle differs is 'diverged'. */
723
+ export function relate(local, candidate) {
724
+ const relation = classify(headList(local), headList(candidate));
725
+ return relation === null ? fail('ledger_other_account') : relation;
726
+ }
727
+ /** The last record `local` and `candidate` (ledgerHeads lists) share, or null when even the genesis differs. Same cost as relate. */
728
+ export function forkPoint(local, candidate) {
729
+ const mine = headList(local);
730
+ const index = firstDifference(mine, headList(candidate));
731
+ return index === 0 ? null : { revision: index - 1, head: mine[index - 1] };
732
+ }
733
+ /** What another replica's bare `{ revision, head }` (an RPC answer or head announcement; other fields ignored) says relative to `local`
734
+ * (a ledgerHeads list): 'same', 'behind' (local holds that head at that revision and more), or 'unknown' (ahead, diverged or another
735
+ * account: fetch its chain and run chooseLedger). O(1). Throws invalid_ledger on a malformed head or revision. */
736
+ export function relateHead(local, other) {
737
+ const mine = headList(local);
738
+ const record = object(other);
739
+ const revision = count(record.revision, 0);
740
+ const head = fingerprint(record.head);
741
+ if (revision >= mine.length || mine[revision] !== head)
742
+ return 'unknown';
743
+ return revision === mine.length - 1 ? 'same' : 'behind';
744
+ }
745
+ /**
746
+ * The append-only acceptance rule, the same on every replica (server, browser, bridge). The candidate's relation decides:
747
+ * - no local chain (first bind): verified from genesis and adopted, relation null; an invalid candidate throws.
748
+ * - malformed or another account: 'reject', relation null.
749
+ * - 'same' / 'behind': 'keep', decided from record heads alone (no candidate signature is checked).
750
+ * - 'ahead': only the records after local's head are verified, on top of local's state -> 'adopt', or 'reject' if any is invalid.
751
+ * - 'diverged': the candidate's records after the fork point are verified on top of the shared prefix -> 'reject' if invalid. It is
752
+ * 'adopt'ed only under the owner-signed resolve_fork rule of resolves(); `discarded.records` then lists local's records after the fork.
753
+ * Otherwise 'keep': a valid different history is never adopted, whatever its length or content; `seen` lists its removals to enforce locally.
754
+ * The candidate's copy of the shared prefix is never verified and never stored: its heads equal local's, so its payloads and state do, and
755
+ * only the candidate's records after the fork point (after local's tip when 'ahead') are checked. Store `choice.ledger`, which keeps local's
756
+ * prefix records byte-for-byte; re-encoded prefix signatures therefore cost nothing and are dropped. `cache` is the checkpoint of the
757
+ * already-verified local chain: when it vouches for `local` (same last head, hash-linked) local is not re-verified, else local is verified
758
+ * from genesis. Throws only when `local` is invalid (and not vouched for), or when `local` is null and the candidate is invalid.
759
+ */
537
760
  export async function chooseLedger(local, candidate, now = Date.now(), cache) {
538
761
  const at = time(now);
539
- if (local === null)
540
- return choice('adopt', await guarded(() => verifyAll(parseLedger(candidate), at)), []);
762
+ if (local === null) {
763
+ const first = await guarded(async () => {
764
+ const ledger = parseLedger(candidate);
765
+ return { ledger, checkpoint: await verifyAll(ledger, at) };
766
+ });
767
+ return choice('adopt', null, first.checkpoint, first.ledger);
768
+ }
541
769
  const mine = await guarded(() => indexed(local));
542
770
  const base = vouched(mine, cache) ?? await guarded(() => verifyAll(mine.ledger, at));
543
771
  let theirs;
@@ -545,38 +773,91 @@ export async function chooseLedger(local, candidate, now = Date.now(), cache) {
545
773
  theirs = await indexed(candidate);
546
774
  }
547
775
  catch {
548
- return choice('reject', base, []);
549
- }
550
- if (theirs.accountId !== mine.accountId)
551
- return choice('reject', base, []);
552
- const localLength = mine.heads.length;
553
- const candidateLength = theirs.heads.length;
554
- let common = 0;
555
- while (common < localLength && common < candidateLength && mine.heads[common] === theirs.heads[common])
556
- common += 1;
557
- if (common === candidateLength)
558
- return choice('keep', base, []);
559
- if (common === localLength && sameRecords(mine.ledger, theirs.ledger, localLength)) {
560
- try {
561
- const fresh = theirs.ledger.transitions.slice(localLength);
562
- return choice('adopt', await extend(base, mine.ledger.genesis.payload, ledgerLastIssuedAt(mine.ledger), fresh, at), []);
563
- }
564
- catch {
565
- return choice('reject', base, []);
566
- }
776
+ return choice('reject', null, base, mine.ledger);
567
777
  }
568
- let winner;
778
+ const relation = classify(mine.heads, theirs.heads);
779
+ if (relation === null)
780
+ return choice('reject', null, base, mine.ledger);
781
+ if (relation === 'same' || relation === 'behind')
782
+ return choice('keep', relation, base, mine.ledger);
783
+ // Revision of the last shared record: local's tip when 'ahead'.
784
+ const fork = firstDifference(mine.heads, theirs.heads) - 1;
785
+ // Local is verified or vouched for, so replaying its prefix can only fail on corrupt local state, which throws like an invalid local.
786
+ const start = relation === 'ahead' ? base : await guarded(async () => replay(mine, fork));
787
+ const suffix = theirs.ledger.transitions.slice(fork);
788
+ let verified;
569
789
  try {
570
- winner = await verifyAll(theirs.ledger, at);
790
+ const root = mine.ledger.genesis.payload;
791
+ const previousTime = fork === 0 ? root.createdAt : mine.ledger.transitions[fork - 1].payload.issuedAt;
792
+ verified = await extend(start, root, previousTime, suffix, at, trailOf(mine, fork));
571
793
  }
572
794
  catch {
573
- return choice('reject', base, []);
795
+ return choice('reject', relation, base, mine.ledger);
574
796
  }
575
- return outranks(theirs, mine) ? choice('adopt', winner, mine.ledger.transitions.slice(common)) : choice('keep', base, []);
797
+ const adopted = { genesis: mine.ledger.genesis, transitions: mine.ledger.transitions.slice(0, fork).concat(suffix) };
798
+ if (relation === 'ahead')
799
+ return choice('adopt', relation, verified, adopted);
800
+ return resolves(mine, theirs, fork, verified)
801
+ ? choice('adopt', relation, verified, adopted, discardedBy(mine, fork, verified))
802
+ : choice('keep', relation, base, mine.ledger, undefined, seenIn(mine, theirs, fork));
803
+ }
804
+ /** describeFork(local, other): where the two chains part and each side's payloads after that point, for showing two histories side by side.
805
+ * Works for any relation of one account (for 'same' both lists are empty). Throws ledger_other_account for another account, else like parseLedger.
806
+ * Neither chain is verified: pass only a local chain and one that chooseLedger classified as 'keep' + 'diverged'. */
807
+ export async function describeFork(local, other) {
808
+ const mine = await guarded(() => indexed(local));
809
+ const theirs = await guarded(() => indexed(other));
810
+ const relation = classify(mine.heads, theirs.heads);
811
+ if (relation === null)
812
+ return fail('ledger_other_account');
813
+ const revision = firstDifference(mine.heads, theirs.heads) - 1;
814
+ return {
815
+ relation, fork: { revision, head: mine.heads[revision] },
816
+ mine: mine.ledger.transitions.slice(revision).map(record => record.payload),
817
+ theirs: theirs.ledger.transitions.slice(revision).map(record => record.payload),
818
+ };
819
+ }
820
+ /** What keeping `chosen` over `other` requires first: their fork point, and `carry`, the operations to append to `chosen` (each signed as an
821
+ * ordinary record, in this order) before resolveForkTransition. In `other`'s chain order: one revoke_device / revoke_bridge per id revoked on
822
+ * other's branch after the fork and not revoked in `chosen` (an id that is not a member of `chosen` is tombstoned, so it can never be added
823
+ * there), and one add_authority, with the ORIGINAL authority and consent (a consent binds only accountId, credentialId and key), per authority
824
+ * added on other's branch and absent from `chosen` with the same credentialId and key. Building a carried record can still throw (for example
825
+ * ledger_limit, or invalid_ledger when `chosen` holds that credentialId or key under another authority): then `chosen` cannot be kept.
826
+ * Throws ledger_other_account for another account and invalid_ledger unless the chains diverge. Neither chain is verified: pass only a
827
+ * verified local chain and one that chooseLedger classified as 'keep' + 'diverged'. */
828
+ export async function resolutionPlan(chosen, other) {
829
+ const mine = await guarded(() => indexed(chosen));
830
+ const theirs = await guarded(() => indexed(other));
831
+ const fork = divergence(mine, theirs);
832
+ return { fork: { revision: fork, head: mine.heads[fork] }, carry: missing(theirs.ledger.transitions.slice(fork), holdings(mine.ledger)) };
833
+ }
834
+ /** The unsigned resolve_fork that keeps `chosen` and discards `other` from their fork point: appended to `chosen`, forkHead = the last shared
835
+ * record, discardedFirst = other's first record after it, discardedHead/discardedRevision = other's tip. Verifies `chosen` (skipped when
836
+ * `cache` vouches for it, as in chooseLedger) and throws invalid_ledger when the chains do not diverge or `credentialId` was not an authority
837
+ * at the fork point (one added on chosen's own branch cannot discard the other); resolution_incomplete while resolutionPlan(chosen, other)
838
+ * still has operations to carry; ledger_other_account for another account; else nextTransition's errors. `other` is not verified: pass only
839
+ * a chain that chooseLedger classified as 'keep' + 'diverged'. */
840
+ export async function resolveForkTransition(chosen, other, credentialId, now = Date.now(), cache) {
841
+ const at = time(now);
842
+ const mine = await guarded(() => indexed(chosen));
843
+ const theirs = await guarded(() => indexed(other));
844
+ const fork = divergence(mine, theirs);
845
+ const checkpoint = vouched(mine, cache) ?? await guarded(() => verifyAll(mine.ledger, at));
846
+ const signer = credential(credentialId);
847
+ if (joinedAt(trailOf(mine, fork), signer) === -1)
848
+ fail();
849
+ if (missing(theirs.ledger.transitions.slice(fork), checkpoint).length)
850
+ fail('resolution_incomplete');
851
+ const operation = {
852
+ type: 'resolve_fork', forkHead: mine.heads[fork], discardedFirst: theirs.heads[fork + 1],
853
+ discardedHead: theirs.heads[theirs.heads.length - 1], discardedRevision: theirs.heads.length - 1,
854
+ };
855
+ return nextTransition(checkpoint, signer, operation, at, ledgerLastIssuedAt(mine.ledger));
576
856
  }
577
857
  /** Pure next-payload builder for the signer. Applies the operation to a copy of the checkpoint with the verifier's rules and throws the
578
- * same errors. Not checked here (they need async crypto): endpoint id = SHA-256(point), points on the curve, and the add_authority
579
- * consent signature (only its shape is checked); verifyLedger enforces all three.
858
+ * same errors. Not checked here (they need async crypto or the chain): endpoint id = SHA-256(point), points on the curve, the add_authority
859
+ * consent signature (only its shape is checked) and resolve_fork's forkHead and signer rules (build it with resolveForkTransition);
860
+ * verifyLedger enforces all of them.
580
861
  * `lastIssuedAt` is the issuedAt of the ledger's last record (see ledgerLastIssuedAt); the result's issuedAt is max(now, lastIssuedAt). */
581
862
  export function nextTransition(checkpoint, credentialId, operation, now = Date.now(), lastIssuedAt) {
582
863
  const current = parseCheckpoint(checkpoint);