@serfab/cadre-core 1.17.0 → 1.19.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 (148) hide show
  1. package/README.md +3 -2
  2. package/dist/cadre-invite-protocol.d.ts +35 -13
  3. package/dist/cadre-invite-protocol.js +90 -25
  4. package/dist/cadre-invite-protocol.js.map +1 -1
  5. package/dist/cadre-node.d.ts +586 -255
  6. package/dist/cadre-node.js +1182 -566
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/control-cohort.d.ts +66 -9
  9. package/dist/control-cohort.js +67 -9
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +451 -193
  12. package/dist/control-database.js +701 -359
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +46 -43
  15. package/dist/control-formation-recorder.js +77 -78
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-protocol-guard.d.ts +6 -0
  18. package/dist/control-protocol-guard.js +14 -2
  19. package/dist/control-protocol-guard.js.map +1 -1
  20. package/dist/control-schema-version.d.ts +48 -31
  21. package/dist/control-schema-version.js +67 -45
  22. package/dist/control-schema-version.js.map +1 -1
  23. package/dist/control-schema.d.ts +8 -8
  24. package/dist/control-schema.js +16 -16
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +42 -7
  27. package/dist/control-write-retry.js +87 -19
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/enrolled-machine-store.d.ts +3 -3
  30. package/dist/enrolled-machine-store.js +3 -3
  31. package/dist/formation-token.d.ts +48 -0
  32. package/dist/formation-token.js +26 -0
  33. package/dist/formation-token.js.map +1 -0
  34. package/dist/index.d.ts +15 -14
  35. package/dist/index.js +17 -14
  36. package/dist/index.js.map +1 -1
  37. package/dist/joined-strand-store.d.ts +26 -9
  38. package/dist/joined-strand-store.js +63 -24
  39. package/dist/joined-strand-store.js.map +1 -1
  40. package/dist/link-budget.d.ts +9 -7
  41. package/dist/link-budget.js +9 -7
  42. package/dist/link-budget.js.map +1 -1
  43. package/dist/member-redemption.d.ts +15 -9
  44. package/dist/member-redemption.js +13 -8
  45. package/dist/member-redemption.js.map +1 -1
  46. package/dist/membership-view.d.ts +91 -0
  47. package/dist/membership-view.js +110 -0
  48. package/dist/membership-view.js.map +1 -0
  49. package/dist/owner-grant-protocol.d.ts +11 -7
  50. package/dist/owner-grant-protocol.js +31 -17
  51. package/dist/owner-grant-protocol.js.map +1 -1
  52. package/dist/peer-addr-book.d.ts +10 -1
  53. package/dist/peer-addr-book.js +4 -3
  54. package/dist/peer-addr-book.js.map +1 -1
  55. package/dist/peer-join-backfill.d.ts +96 -30
  56. package/dist/peer-join-backfill.js +272 -128
  57. package/dist/peer-join-backfill.js.map +1 -1
  58. package/dist/pending-join-runner.d.ts +48 -18
  59. package/dist/pending-join-runner.js +74 -56
  60. package/dist/pending-join-runner.js.map +1 -1
  61. package/dist/seed-bootstrap.d.ts +47 -40
  62. package/dist/seed-bootstrap.js +66 -61
  63. package/dist/seed-bootstrap.js.map +1 -1
  64. package/dist/self-address-update.d.ts +13 -0
  65. package/dist/self-address-update.js +19 -0
  66. package/dist/self-address-update.js.map +1 -0
  67. package/dist/strand-database.d.ts +23 -2
  68. package/dist/strand-database.js +26 -6
  69. package/dist/strand-database.js.map +1 -1
  70. package/dist/strand-first-sync-gate.d.ts +176 -89
  71. package/dist/strand-first-sync-gate.js +288 -122
  72. package/dist/strand-first-sync-gate.js.map +1 -1
  73. package/dist/strand-formation-deadlines.d.ts +9 -9
  74. package/dist/strand-formation-deadlines.js +9 -9
  75. package/dist/strand-formation-manager.d.ts +23 -17
  76. package/dist/strand-formation-manager.js +46 -44
  77. package/dist/strand-formation-manager.js.map +1 -1
  78. package/dist/strand-formation-protocol.d.ts +5 -8
  79. package/dist/strand-formation-protocol.js +101 -16
  80. package/dist/strand-formation-protocol.js.map +1 -1
  81. package/dist/strand-instance-manager.d.ts +26 -17
  82. package/dist/strand-instance-manager.js +119 -50
  83. package/dist/strand-instance-manager.js.map +1 -1
  84. package/dist/strand-membership-reconciler.d.ts +16 -13
  85. package/dist/strand-membership-reconciler.js +28 -22
  86. package/dist/strand-membership-reconciler.js.map +1 -1
  87. package/dist/strand-membership-writer.d.ts +40 -28
  88. package/dist/strand-membership-writer.js +212 -153
  89. package/dist/strand-membership-writer.js.map +1 -1
  90. package/dist/strand-move.js +5 -2
  91. package/dist/strand-move.js.map +1 -1
  92. package/dist/strand-revocation-enforcer.d.ts +134 -60
  93. package/dist/strand-revocation-enforcer.js +144 -71
  94. package/dist/strand-revocation-enforcer.js.map +1 -1
  95. package/dist/strand-solicitation.d.ts +50 -39
  96. package/dist/strand-solicitation.js +10 -3
  97. package/dist/strand-solicitation.js.map +1 -1
  98. package/dist/strand-watcher.d.ts +80 -13
  99. package/dist/strand-watcher.js +152 -48
  100. package/dist/strand-watcher.js.map +1 -1
  101. package/dist/table-change-watch.d.ts +63 -0
  102. package/dist/table-change-watch.js +209 -0
  103. package/dist/table-change-watch.js.map +1 -0
  104. package/dist/timeout-scheduler.d.ts +4 -3
  105. package/dist/timeout-scheduler.js +4 -3
  106. package/dist/timeout-scheduler.js.map +1 -1
  107. package/dist/types.d.ts +90 -49
  108. package/dist/types.js +4 -4
  109. package/dist/types.js.map +1 -1
  110. package/package.json +8 -8
  111. package/src/cadre-invite-protocol.ts +119 -31
  112. package/src/cadre-node.ts +1289 -588
  113. package/src/control-cohort.ts +86 -9
  114. package/src/control-database.ts +848 -403
  115. package/src/control-formation-recorder.ts +359 -353
  116. package/src/control-protocol-guard.ts +16 -2
  117. package/src/control-schema-version.ts +101 -51
  118. package/src/control-schema.ts +16 -16
  119. package/src/control-write-retry.ts +91 -19
  120. package/src/delegate-admission.ts +351 -351
  121. package/src/enrolled-machine-store.ts +3 -3
  122. package/src/formation-token.ts +63 -0
  123. package/src/index.ts +30 -18
  124. package/src/joined-strand-store.ts +69 -24
  125. package/src/link-budget.ts +9 -7
  126. package/src/member-redemption.ts +20 -13
  127. package/src/membership-view.ts +144 -0
  128. package/src/owner-grant-protocol.ts +33 -18
  129. package/src/peer-addr-book.ts +4 -3
  130. package/src/peer-join-backfill.ts +961 -761
  131. package/src/pending-join-runner.ts +76 -53
  132. package/src/seed-bootstrap.ts +72 -67
  133. package/src/self-address-update.ts +19 -0
  134. package/src/strand-database.ts +352 -322
  135. package/src/strand-first-sync-gate.ts +334 -134
  136. package/src/strand-formation-deadlines.ts +227 -227
  137. package/src/strand-formation-manager.ts +731 -725
  138. package/src/strand-formation-protocol.ts +122 -24
  139. package/src/strand-instance-manager.ts +2092 -2026
  140. package/src/strand-membership-reconciler.ts +31 -25
  141. package/src/strand-membership-writer.ts +2093 -2019
  142. package/src/strand-move.ts +5 -2
  143. package/src/strand-revocation-enforcer.ts +740 -654
  144. package/src/strand-solicitation.ts +61 -34
  145. package/src/strand-watcher.ts +175 -56
  146. package/src/table-change-watch.ts +285 -0
  147. package/src/timeout-scheduler.ts +29 -28
  148. package/src/types.ts +89 -51
@@ -33,6 +33,7 @@ import {
33
33
  type ControlDatabase
34
34
  } from './control-database.js';
35
35
  import { verifyCadreInviteRow, verifyCadreInviteRedemption } from './peer-authorization.js';
36
+ import { verifyPeerRecordSignature } from './peer-record.js';
36
37
  import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
37
38
  import { CADRE_INVITATION_PREFIX, CADRE_JOIN_REQUEST_PREFIX, OWNER_GRANT_PREFIX, OWNER_KEY_EXPORT_STEM } from './cadre-join-request.js';
38
39
  import {
@@ -54,7 +55,7 @@ import {
54
55
  type RedemptionAddressOutcome,
55
56
  type RedemptionRejection
56
57
  } from './member-redemption.js';
57
- import type { CadreInviteRow, CadreJoinProgress, SeedPeer } from './types.js';
58
+ import type { CadreInviteRow, CadreJoinProgress, PeerAddressRecord, SeedPeer } from './types.js';
58
59
 
59
60
  const log = debug('sereus:cadre:invite-proto');
60
61
 
@@ -168,17 +169,28 @@ function isWellFormedCadreInvitation(value: unknown): value is CadreInvitation {
168
169
 
169
170
  // ── Wire messages ────────────────────────────────────────────────────────────
170
171
 
172
+ /**
173
+ * The device's own address record as a request carries it: a {@link PeerAddressRecord} without
174
+ * the peer id, which the connection gives, and the key, which is the request's `peerKey`.
175
+ */
176
+ export type RedeemSelfRecord = Pick<PeerAddressRecord, 'addrs' | 'updatedAt' | 'profile' | 'sig'>;
177
+
171
178
  /** Device → member: one frame, the whole redemption. */
172
179
  export interface CadreInviteRedeemRequest {
173
- v: 1;
180
+ v: 2;
174
181
  /** The cadre the device believes it is joining; a machine serving another party refuses by name. */
175
182
  partyId: string;
176
183
  /** The owner-signed row from the bundle. */
177
184
  invite: CadreInviteRow;
178
185
  /** The device's ed25519 public key, base64url; must be the key behind the connecting peer id. */
179
186
  peerKey: string;
180
- /** The device's dialable addresses; empty for a phone. Stored on its `CadrePeer` row. */
181
- multiaddrs: string[];
187
+ /**
188
+ * The device's own address record for its `CadrePeer` row, self-signed for the invitation's
189
+ * party as `CadreNode.registerSelf` signs it (`signPeerRecord`); `addrs` is empty for a phone.
190
+ * The member stores `addrs` and, when the signature verifies over them, the rest of the
191
+ * record too ({@link admittedRecord}), so the device's first publish is its admission.
192
+ */
193
+ record: RedeemSelfRecord;
182
194
  /** Single-use nonce the device minted (`generateStampId`); both signatures cover it. */
183
195
  usageStampId: string;
184
196
  /** By `invitePrivateKey` over the `'redeem'` digest. */
@@ -336,19 +348,62 @@ export function isWellFormedCadreInviteRow(value: unknown): value is CadreInvite
336
348
  && isBoundedString(row.issuerSig);
337
349
  }
338
350
 
351
+ /**
352
+ * The record's other fields are left to {@link admittedRecord}: a bad value there costs the
353
+ * device its signature on the row, not its admission.
354
+ */
339
355
  function isWellFormedRedeemRequest(value: unknown): value is CadreInviteRedeemRequest {
340
356
  if (typeof value !== 'object' || value === null) return false;
341
- const { v, partyId, invite, peerKey, multiaddrs, usageStampId, inviteSig, peerSig } = value as Record<string, unknown>;
342
- return v === 1
357
+ const { v, partyId, invite, peerKey, record, usageStampId, inviteSig, peerSig } = value as Record<string, unknown>;
358
+ return v === 2
343
359
  && isBoundedString(partyId)
344
360
  && isWellFormedCadreInviteRow(invite)
345
361
  && isEd25519KeyB64(peerKey)
346
- && Array.isArray(multiaddrs)
362
+ && typeof record === 'object' && record !== null && Array.isArray((record as Record<string, unknown>).addrs)
347
363
  && isBoundedString(usageStampId)
348
364
  && isBoundedString(inviteSig)
349
365
  && isBoundedString(peerSig);
350
366
  }
351
367
 
368
+ /** What the member writes on the device's `CadrePeer` row from the request's record. */
369
+ interface AdmittedRecord {
370
+ /** The record's addresses, sanitized ({@link sanitizeAddrs}): stored whether or not the rest is. */
371
+ addrs: string[];
372
+ /** The record's freshness, profile and signature, or null when they cannot be stored as the device's own. */
373
+ signed: Pick<PeerAddressRecord, 'updatedAt' | 'profile' | 'sig'> | null;
374
+ /** Why `signed` is null; absent when it is not. */
375
+ unsignedBecause?: string;
376
+ }
377
+
378
+ /**
379
+ * Judge the device's record (`request.record`) for its admission row. The signature covers the
380
+ * exact address list, so it is kept only when sanitizing dropped nothing, the profile is one the
381
+ * schema's `KnownProfile` check admits, the stamp and signature are in their stored forms, and
382
+ * the signature verifies for `partyId` under the device's key. Anything less admits the device
383
+ * with its addresses and no signature, as an owner-written row is, and the device's next
384
+ * publish fills it in. No clock check: a stamp in the future or the past is one the device's own
385
+ * self-publish could write too.
386
+ */
387
+ function admittedRecord(partyId: string, remotePeerId: string, request: Pick<CadreInviteRedeemRequest, 'peerKey' | 'record'>): AdmittedRecord {
388
+ const { addrs: offered, updatedAt, profile, sig } = request.record as Partial<Record<keyof RedeemSelfRecord, unknown>>;
389
+ const addrs = sanitizeAddrs(offered);
390
+ const unsigned = (because: string): AdmittedRecord => ({ addrs, signed: null, unsignedBecause: because });
391
+ // Sanitizing only drops entries, so an unchanged length is an unchanged list.
392
+ if (!Array.isArray(offered) || addrs.length !== offered.length) {
393
+ return unsigned('sanitizing changed the signed address list');
394
+ }
395
+ if (profile !== null && profile !== 'transaction' && profile !== 'storage') {
396
+ return unsigned('unknown profile');
397
+ }
398
+ if (typeof updatedAt !== 'number' || !Number.isSafeInteger(updatedAt) || updatedAt <= 0 || !isBoundedString(sig)) {
399
+ return unsigned('malformed stamp or signature');
400
+ }
401
+ if (!verifyPeerRecordSignature(partyId, { peerId: remotePeerId, publicKey: request.peerKey, addrs, updatedAt, profile, sig })) {
402
+ return unsigned('signature does not verify');
403
+ }
404
+ return { addrs, signed: { updatedAt, profile, sig } };
405
+ }
406
+
352
407
  /**
353
408
  * Keep only parsable, distinct multiaddr strings, capped at {@link MAX_ADDRS}, order kept.
354
409
  * Applied on both sides: the member bounds what it stores on the device's row, the device
@@ -411,22 +466,26 @@ export interface RedeemSigner {
411
466
  * (invitation key, `usageStampId`, device key) for the invitation's party. The same bytes
412
467
  * the schema verifies at the member (`cadreInviteRedeemMessage` / `cadreInviteConsentMessage`),
413
468
  * whose own `party_id()` must therefore be the party the bundle names.
469
+ *
470
+ * @param record - the device's address record, already signed for the invitation's party over
471
+ * sanitized addresses ({@link sanitizeAddrs}); copied in unchanged, since the signature
472
+ * covers the exact list.
414
473
  */
415
474
  export function signRedeemRequest(
416
475
  invitation: CadreInvitation,
417
476
  signer: RedeemSigner,
418
477
  usageStampId: string,
419
- multiaddrs: string[]
478
+ record: RedeemSelfRecord
420
479
  ): CadreInviteRedeemRequest {
421
480
  const fields = { inviteKey: invitation.invite.key, usageStampId, peerKey: signer.peerKey };
422
481
  const signB64 = (message: Uint8Array, privateKey: string): string =>
423
482
  sign(message, privateKey, 'ed25519', 'bytes', 'base64url', 'base64url') as string;
424
483
  return {
425
- v: 1,
484
+ v: 2,
426
485
  partyId: invitation.partyId,
427
486
  invite: invitation.invite,
428
487
  peerKey: signer.peerKey,
429
- multiaddrs: sanitizeAddrs(multiaddrs),
488
+ record: { addrs: [...record.addrs], updatedAt: record.updatedAt, profile: record.profile, sig: record.sig },
430
489
  usageStampId,
431
490
  inviteSig: signB64(cadreInviteRedeemMessage(invitation.partyId, fields), invitation.invitePrivateKey),
432
491
  peerSig: signB64(cadreInviteConsentMessage(invitation.partyId, fields), signer.peerPrivateKey)
@@ -466,7 +525,7 @@ export function verifyRedeemReply(
466
525
  // ── Member side (handler) ────────────────────────────────────────────────────
467
526
 
468
527
  /** The control-database surface a redemption needs; a `ControlDatabase` satisfies it. */
469
- export type CadreInviteStore = Pick<ControlDatabase, 'seatCadreInvite' | 'isCadreInviteLive' | 'redeemCadreInvite' | 'querySeedPeers'>;
528
+ export type CadreInviteStore = Pick<ControlDatabase, 'queryCadreInvite' | 'seatCadreInvite' | 'isCadreInviteLive' | 'redeemCadreInvite' | 'querySeedPeers'>;
470
529
 
471
530
  export interface CadreInviteHandlerOptions extends MemberRedemptionHandlerOptions {
472
531
  /** This member's peer id: the reply's peer list marks it preferred, since the device just reached it. */
@@ -485,11 +544,11 @@ const REDEEM_CONSTRAINT_CODES: ReadonlyArray<ConstraintRefusalRow<CadreInviteRej
485
544
  * Member side of the invitation protocol (`MemberRedemptionHandler` has the shape): after the
486
545
  * request's shape, it checks the party, the connecting identity (an ed25519 peer id is an
487
546
  * identity multihash of the very key, and this is the only layer that can check the pair),
488
- * both signatures and a targeted invitation's device, then seats the row from the bundle and
489
- * redeems. An issuer this member does not know as an owner is the one retryable refusal left:
490
- * the device tries the next address, which may hold the issuer's row.
547
+ * both signatures and a targeted invitation's device, then seats the row from the bundle when
548
+ * this member holds none and redeems. An issuer this member does not know as an owner is the
549
+ * one retryable refusal left: the device tries the next address, which may hold the issuer's row.
491
550
  */
492
- export class CadreInviteHandler extends MemberRedemptionHandler<CadreInviteRedeemRequest, CadreInviteRedeemReply> {
551
+ export class CadreInviteHandler extends MemberRedemptionHandler<CadreInviteRedeemRequest, CadreInviteRedeemReply, CadreInviteRow> {
493
552
  private readonly peerId: string;
494
553
  private readonly store: CadreInviteStore;
495
554
  protected readonly busyRefusal = rejection('busy', 'Too many concurrent redemptions');
@@ -523,27 +582,41 @@ export class CadreInviteHandler extends MemberRedemptionHandler<CadreInviteRedee
523
582
  return null;
524
583
  }
525
584
 
526
- protected isLive(request: CadreInviteRedeemRequest): Promise<boolean> {
527
- return this.store.isCadreInviteLive(request.invite);
585
+ protected readHeld(request: CadreInviteRedeemRequest): Promise<CadreInviteRow | null> {
586
+ return this.store.queryCadreInvite(request.invite.key);
528
587
  }
529
588
 
530
- /** The two writes — the seat (its own transaction; a no-op when held) and the redemption — each answered by name when it fails. */
531
- protected async redeemAdmitted(request: CadreInviteRedeemRequest, remotePeerId: string): Promise<CadreInviteRedeemReply> {
532
- try {
533
- await this.store.seatCadreInvite(request.invite);
534
- } catch (error) {
535
- if (error instanceof CadreInviteIssuerUnknownError) {
536
- return rejection('issuer-unknown', 'The invitation\'s issuer is not known as an owner here');
537
- }
538
- log('seating invitation %s from %s failed: %o', request.invite.key, remotePeerId, error);
539
- return constraintRefusal(error, REDEEM_CONSTRAINT_CODES) ?? writeFailureRefusal(error);
589
+ protected isLive(request: CadreInviteRedeemRequest, held: CadreInviteRow | null): Promise<boolean> {
590
+ return this.store.isCadreInviteLive(request.invite, held);
591
+ }
592
+
593
+ /**
594
+ * The two writes — the seat (its own transaction), only when this member holds no row, and
595
+ * the redemption — each answered by name when it fails. The held row is carried into the
596
+ * redemption so it is not read again in the write lock; after a fresh seat nothing is carried
597
+ * and the redemption reads the row it seated, since the bundle's copy is the requester's.
598
+ */
599
+ protected async redeemAdmitted(request: CadreInviteRedeemRequest, remotePeerId: string, held: CadreInviteRow | null): Promise<CadreInviteRedeemReply> {
600
+ if (held === null) {
601
+ const refused = await this.seat(request, remotePeerId);
602
+ if (refused) return refused;
603
+ }
604
+ // Built before the write: the `CadrePeer` membership view is normally warm then (the
605
+ // connection gate read it when the device connected), and the redemption's commit drops it.
606
+ // The list lacks the device's own row, which the device drops from it anyway.
607
+ const peers = await this.peersBestEffort();
608
+ const record = admittedRecord(this.partyId, remotePeerId, request);
609
+ if (record.signed === null) {
610
+ log('admitting %s without its own record signature: %s', remotePeerId, record.unsignedBecause);
540
611
  }
541
612
  try {
542
613
  const result = await this.store.redeemCadreInvite({
543
614
  inviteKey: request.invite.key,
615
+ ...(held ? { held } : {}),
544
616
  peerId: remotePeerId,
545
617
  peerKey: request.peerKey,
546
- multiaddrs: sanitizeAddrs(request.multiaddrs),
618
+ multiaddrs: record.addrs,
619
+ ...(record.signed ? { record: record.signed } : {}),
547
620
  usageStampId: request.usageStampId,
548
621
  inviteSig: request.inviteSig,
549
622
  peerSig: request.peerSig
@@ -556,9 +629,24 @@ export class CadreInviteHandler extends MemberRedemptionHandler<CadreInviteRedee
556
629
  log('redeeming invitation %s for %s failed: %o', request.invite.key, remotePeerId, error);
557
630
  return constraintRefusal(error, REDEEM_CONSTRAINT_CODES) ?? writeFailureRefusal(error);
558
631
  }
559
- // The row as held: the seat inserted this copy verbatim, or found the same row (its key
560
- // is the primary key and every column is under the issuer's signature).
561
- return { accepted: true, partyId: this.partyId, invite: request.invite, peers: await this.peersBestEffort() };
632
+ // The row as held: read before the write, or the bundle's copy the seat inserted verbatim
633
+ // or found already held (its key is the primary key and every column is under the issuer's
634
+ // signature).
635
+ return { accepted: true, partyId: this.partyId, invite: held ?? request.invite, peers };
636
+ }
637
+
638
+ /** Seat the bundle's copy of the row; a refusal answered by name, or null once it is held. */
639
+ private async seat(request: CadreInviteRedeemRequest, remotePeerId: string): Promise<CadreInviteRejection | null> {
640
+ try {
641
+ await this.store.seatCadreInvite(request.invite);
642
+ return null;
643
+ } catch (error) {
644
+ if (error instanceof CadreInviteIssuerUnknownError) {
645
+ return rejection('issuer-unknown', 'The invitation\'s issuer is not known as an owner here');
646
+ }
647
+ log('seating invitation %s from %s failed: %o', request.invite.key, remotePeerId, error);
648
+ return constraintRefusal(error, REDEEM_CONSTRAINT_CODES) ?? writeFailureRefusal(error);
649
+ }
562
650
  }
563
651
 
564
652
  /** Dial hints are an optimization; a failed read costs the device its hints, not its membership. */