@serfab/cadre-core 1.12.0 → 1.14.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 (128) hide show
  1. package/dist/cadre-invite-protocol.d.ts +348 -0
  2. package/dist/cadre-invite-protocol.js +605 -0
  3. package/dist/cadre-invite-protocol.js.map +1 -0
  4. package/dist/cadre-node.d.ts +530 -180
  5. package/dist/cadre-node.js +966 -307
  6. package/dist/cadre-node.js.map +1 -1
  7. package/dist/claim-proof.d.ts +45 -0
  8. package/dist/claim-proof.js +132 -0
  9. package/dist/claim-proof.js.map +1 -0
  10. package/dist/control-authorization.d.ts +80 -19
  11. package/dist/control-authorization.js +55 -13
  12. package/dist/control-authorization.js.map +1 -1
  13. package/dist/control-cohort.js +1 -1
  14. package/dist/control-cohort.js.map +1 -1
  15. package/dist/control-database.d.ts +514 -97
  16. package/dist/control-database.js +1033 -195
  17. package/dist/control-database.js.map +1 -1
  18. package/dist/control-protocol-guard.d.ts +142 -0
  19. package/dist/control-protocol-guard.js +293 -0
  20. package/dist/control-protocol-guard.js.map +1 -0
  21. package/dist/control-schema.d.ts +1 -1
  22. package/dist/control-schema.js +801 -1067
  23. package/dist/control-schema.js.map +1 -1
  24. package/dist/control-stream.d.ts +4 -0
  25. package/dist/control-stream.js +22 -7
  26. package/dist/control-stream.js.map +1 -1
  27. package/dist/delegate-admission.d.ts +1 -1
  28. package/dist/delegate-admission.js +1 -1
  29. package/dist/device-token.d.ts +11 -9
  30. package/dist/device-token.js +14 -12
  31. package/dist/device-token.js.map +1 -1
  32. package/dist/ed25519-key.d.ts +15 -0
  33. package/dist/ed25519-key.js +28 -1
  34. package/dist/ed25519-key.js.map +1 -1
  35. package/dist/index.d.ts +14 -9
  36. package/dist/index.js +27 -9
  37. package/dist/index.js.map +1 -1
  38. package/dist/link-budget.d.ts +16 -29
  39. package/dist/link-budget.js +16 -31
  40. package/dist/link-budget.js.map +1 -1
  41. package/dist/membership-connection-gater.d.ts +196 -157
  42. package/dist/membership-connection-gater.js +241 -179
  43. package/dist/membership-connection-gater.js.map +1 -1
  44. package/dist/node-claim-payload.d.ts +19 -0
  45. package/dist/node-claim-payload.js +120 -0
  46. package/dist/node-claim-payload.js.map +1 -0
  47. package/dist/owner-anchor-sync.d.ts +22 -0
  48. package/dist/owner-anchor-sync.js +143 -0
  49. package/dist/owner-anchor-sync.js.map +1 -0
  50. package/dist/peer-authorization.d.ts +113 -18
  51. package/dist/peer-authorization.js +225 -29
  52. package/dist/peer-authorization.js.map +1 -1
  53. package/dist/peer-dial.d.ts +19 -0
  54. package/dist/peer-dial.js +22 -1
  55. package/dist/peer-dial.js.map +1 -1
  56. package/dist/peer-join-backfill.d.ts +26 -2
  57. package/dist/peer-join-backfill.js +54 -12
  58. package/dist/peer-join-backfill.js.map +1 -1
  59. package/dist/peer-record.d.ts +13 -9
  60. package/dist/peer-record.js +16 -12
  61. package/dist/peer-record.js.map +1 -1
  62. package/dist/pending-join-runner.d.ts +66 -58
  63. package/dist/pending-join-runner.js +136 -145
  64. package/dist/pending-join-runner.js.map +1 -1
  65. package/dist/push-fanout.d.ts +8 -2
  66. package/dist/push-fanout.js +8 -2
  67. package/dist/push-fanout.js.map +1 -1
  68. package/dist/seed-bootstrap.d.ts +147 -122
  69. package/dist/seed-bootstrap.js +284 -269
  70. package/dist/seed-bootstrap.js.map +1 -1
  71. package/dist/seed-trust-policy.d.ts +98 -37
  72. package/dist/seed-trust-policy.js +188 -2
  73. package/dist/seed-trust-policy.js.map +1 -1
  74. package/dist/strand-addr-protocol.d.ts +24 -26
  75. package/dist/strand-addr-protocol.js +19 -26
  76. package/dist/strand-addr-protocol.js.map +1 -1
  77. package/dist/strand-first-sync-gate.d.ts +5 -4
  78. package/dist/strand-first-sync-gate.js +5 -4
  79. package/dist/strand-first-sync-gate.js.map +1 -1
  80. package/dist/strand-formation-protocol.js +1 -1
  81. package/dist/strand-formation-protocol.js.map +1 -1
  82. package/dist/strand-instance-manager.d.ts +33 -1
  83. package/dist/strand-instance-manager.js +81 -3
  84. package/dist/strand-instance-manager.js.map +1 -1
  85. package/dist/strand-revocation-enforcer.d.ts +11 -11
  86. package/dist/strand-revocation-enforcer.js +11 -11
  87. package/dist/strand-wake-protocol.d.ts +13 -16
  88. package/dist/strand-wake-protocol.js +13 -20
  89. package/dist/strand-wake-protocol.js.map +1 -1
  90. package/dist/trusted-owner-store.d.ts +35 -5
  91. package/dist/trusted-owner-store.js +50 -8
  92. package/dist/trusted-owner-store.js.map +1 -1
  93. package/dist/types.d.ts +302 -87
  94. package/dist/types.js.map +1 -1
  95. package/package.json +9 -8
  96. package/src/cadre-invite-protocol.ts +864 -0
  97. package/src/cadre-node.ts +1043 -317
  98. package/src/claim-proof.ts +147 -0
  99. package/src/control-authorization.ts +203 -117
  100. package/src/control-cohort.ts +1 -1
  101. package/src/control-database.ts +1216 -218
  102. package/src/control-protocol-guard.ts +348 -0
  103. package/src/control-schema.ts +812 -1078
  104. package/src/control-stream.ts +26 -7
  105. package/src/delegate-admission.ts +1 -1
  106. package/src/device-token.ts +14 -10
  107. package/src/ed25519-key.ts +28 -1
  108. package/src/index.ts +84 -10
  109. package/src/link-budget.ts +16 -32
  110. package/src/membership-connection-gater.ts +264 -197
  111. package/src/node-claim-payload.ts +125 -0
  112. package/src/owner-anchor-sync.ts +171 -0
  113. package/src/peer-authorization.ts +258 -29
  114. package/src/peer-dial.ts +22 -1
  115. package/src/peer-join-backfill.ts +56 -11
  116. package/src/peer-record.ts +16 -11
  117. package/src/pending-join-runner.ts +729 -735
  118. package/src/push-fanout.ts +8 -2
  119. package/src/seed-bootstrap.ts +344 -321
  120. package/src/seed-trust-policy.ts +353 -140
  121. package/src/strand-addr-protocol.ts +26 -35
  122. package/src/strand-first-sync-gate.ts +392 -391
  123. package/src/strand-formation-protocol.ts +1 -1
  124. package/src/strand-instance-manager.ts +1883 -1804
  125. package/src/strand-revocation-enforcer.ts +11 -11
  126. package/src/strand-wake-protocol.ts +13 -23
  127. package/src/trusted-owner-store.ts +242 -171
  128. package/src/types.ts +2471 -2242
package/src/cadre-node.ts CHANGED
@@ -18,11 +18,10 @@ import type {
18
18
  CadreNodeEvents,
19
19
  ControlNetworkSeed,
20
20
  ApplySeedResult,
21
+ SeedAckMessage,
22
+ SeedDeliveryTarget,
21
23
  AddDroneOptions,
22
- AddPhoneOptions,
23
24
  DroneInitResult,
24
- InviteResult,
25
- CadreInvite,
26
25
  OpenInvitation,
27
26
  FormStrandResult,
28
27
  StrandFormationDisclosure,
@@ -35,13 +34,18 @@ import type {
35
34
  DeviceTokenRecord,
36
35
  CadrePeerVoucherFields,
37
36
  CadrePeerRow,
37
+ InvitationChain,
38
+ CadreInviteStatus,
39
+ CreateCadreInvitationOptions,
40
+ CreateCadreInvitationResult,
41
+ RedeemCadreInvitationResult,
38
42
  PeerAddressRecord,
39
43
  ResolveDeviceTokenOpts,
40
- PendingJoinRow,
44
+ PendingJoin,
41
45
  PendingJoinStatus
42
46
  } from './types.js';
43
47
  import { controlClusterPolicy, CONTROL_REPLICATION_BREADTH, DEFAULT_CHECKIN_WINDOW_MS, DEFAULT_CONNECTION_MONITOR } from './types.js';
44
- import { sign } from '@optimystic/quereus-plugin-crypto';
48
+ import { generatePrivateKey, sign } from '@optimystic/quereus-plugin-crypto';
45
49
  import { ed25519KeyPairFromLibp2p, ed25519PublicKeyFromPrivate, requireEd25519PublicKeyB64, type Ed25519KeyPair } from './ed25519-key.js';
46
50
  import { strandTransportKey } from './strand-transport-key.js';
47
51
  import {
@@ -69,8 +73,9 @@ import {
69
73
  } from './joined-strand-store.js';
70
74
  import { mergePeerAddrs, groupAddrsByPeerId, type MergeAddrsResult } from './peer-addr-book.js';
71
75
  import { strandFretPeerAddrs } from './strand-fret-addrs.js';
72
- import { verifyCadrePeerVoucher } from './peer-authorization.js';
73
- import { ed25519PublicKeyB64FromPeerId } from './seed-bootstrap.js';
76
+ import { verifyCadrePeerVoucher, verifyInvitationAdmission } from './peer-authorization.js';
77
+ import { deriveOwnerAnchor } from './owner-anchor-sync.js';
78
+ import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
74
79
  import {
75
80
  signPeerRecord,
76
81
  verifyPeerRecordSignature,
@@ -105,21 +110,28 @@ import {
105
110
  CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS,
106
111
  type PeerDialBudget
107
112
  } from './peer-dial.js';
108
- import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, optimysticDialLimits, peerJoinPushBudget, relayAdmissionReserveDeadlineMs, relayReservationBudgetMs, relayedDialBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
113
+ import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, optimysticDialLimits, peerJoinPushBudget, relayReservationBudgetMs, relayedDialBudgetMs, relayedRequestBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
109
114
  import { EnrollmentService } from './enrollment.js';
110
115
  import { HibernationManager, type HibernationCallbacks } from './hibernation-manager.js';
111
- import { ControlDatabase, isPendingJoinConflict, isStrandIdConflict, pendingJoinId, type RevokedRowRef } from './control-database.js';
116
+ import { ControlDatabase, generateStampId, isPendingJoinConflict, isStrandIdConflict, pendingJoinId, type JoinRequestFields, type RevokedRowRef } from './control-database.js';
117
+ import {
118
+ CadreInviteHandler,
119
+ encodeCadreInvitation,
120
+ redeemAtMembers,
121
+ signRedeemRequest,
122
+ type CadreInvitation
123
+ } from './cadre-invite-protocol.js';
112
124
  import { FormationPostApprovalError } from './strand-formation-rejection.js';
113
125
  import {
114
126
  PendingJoinRunner,
115
127
  membershipInvitesToStage,
116
128
  parseStoredDisclosure,
117
- requestedPendingJoin,
118
- type PendingJoinFields
129
+ requestedPendingJoin
119
130
  } from './pending-join-runner.js';
120
131
  import type { ControlRetryAbandonment } from './control-retry.js';
121
- import { SeedBootstrapService, type SeedEventCallbacks, type SeedBootstrapConfig } from './seed-bootstrap.js';
122
- import type { SeedTrustPolicy } from './seed-trust-policy.js';
132
+ import { SeedBootstrapService, mergeSeedPeers, seedDigest, type SeedEventCallbacks, type SeedBootstrapConfig } from './seed-bootstrap.js';
133
+ import { claimSecretTrustPolicy, type SeedTrustPolicy } from './seed-trust-policy.js';
134
+ import { ClaimRefusedError, claimProof, parseClaimSecret } from './claim-proof.js';
123
135
  import {
124
136
  StrandSolicitationService,
125
137
  type StrandSolicitationServiceOptions
@@ -128,10 +140,10 @@ import { ControlFormationUsageRecorder } from './control-formation-recorder.js';
128
140
  import { selectInvitationSiblingAddrs, type InvitationSibling } from './invitation-bootstrap.js';
129
141
  import {
130
142
  createMembershipConnectionGater,
131
- DEFAULT_ENROLLMENT_WINDOW_MS,
132
143
  UnauthorizedReservationBudget,
133
144
  type InboundConnectionVerdict
134
145
  } from './membership-connection-gater.js';
146
+ import { controlNetworkName, installControlProtocolGuard } from './control-protocol-guard.js';
135
147
  import { StrandWakeService, dialWake } from './strand-wake-protocol.js';
136
148
  import { StrandAddrService, collectStrandAddrs, type StrandAddrPeer, type StrandAddrOutcome, type StrandAddrCollection } from './strand-addr-protocol.js';
137
149
  import {
@@ -187,6 +199,17 @@ export const STRAND_PEER_ADDR_REFRESH_MS = 10 * 60 * 1000;
187
199
  */
188
200
  export const STRAND_PEER_ADDR_RETRY_MS = 60 * 1000;
189
201
 
202
+ /**
203
+ * Default lifetime of an UNTARGETED, OWNER-GRANTING cadre invitation
204
+ * ({@link CadreNode.createCadreInvitation}): whoever holds the bundle becomes an owner, so
205
+ * it is a bearer credential for admin rights and lives only long enough to be pasted or
206
+ * scanned by the device it was minted for.
207
+ */
208
+ export const OWNER_INVITATION_DEFAULT_TTL_MS = 15 * 60 * 1000;
209
+
210
+ /** Default lifetime of every other cadre invitation: targeted, or granting membership only. */
211
+ export const CADRE_INVITATION_DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
212
+
190
213
  type EventHandler<T> = (data: T) => void;
191
214
 
192
215
  /**
@@ -235,6 +258,18 @@ function unionAddrs(primary: readonly string[], extra: readonly string[]): strin
235
258
  * canonical row-bound message BYTES (see `buildAuthorizationMessage`), and it ed25519-signs
236
259
  * them directly (no pre-hash) with the owner private key, returning a base64url signature.
237
260
  */
261
+ /**
262
+ * One membership refresh's invitation chain ({@link InvitationChain}), loaded at most once
263
+ * and shared by the anchor sync (`OwnerKey` rows) and the membership read (`CadrePeer`
264
+ * rows), each of which calls it only when some row it judges is invitation-admitted.
265
+ */
266
+ type InvitationChainSource = () => Promise<InvitationChain>;
267
+
268
+ /** Whether a `CadrePeer` or `OwnerKey` row's proof is an invitation admission rather than an owner voucher (`types.ts` → `CadrePeerRow`, `OwnerKeyRow`). */
269
+ function isInvitationAdmitted(row: { vouchSig: string | null; vouchUsage: string | null }): boolean {
270
+ return row.vouchSig === null && row.vouchUsage !== null;
271
+ }
272
+
238
273
  function signMessageWith(privateKeyB64: string): (message: Uint8Array) => string {
239
274
  return (message: Uint8Array): string =>
240
275
  sign(message, privateKeyB64, 'ed25519', 'bytes', 'base64url', 'base64url') as string;
@@ -374,11 +409,25 @@ export class CadreNode implements SAppIdLookup {
374
409
  * start() cycle rebuilds it with a fresh caught-up-peer memo.
375
410
  */
376
411
  private controlBackfill: PeerJoinBackfill | null = null;
412
+ /**
413
+ * Devices whose invitation redemption this member has verified and is admitting right now,
414
+ * each with its count of overlapping redemptions. The control backfill's gate admits them
415
+ * as it admits a member, for that window only ({@link catchUpRedeemingDevice}).
416
+ */
417
+ private readonly admittingDevices = new Map<string, number>();
377
418
  private strandWatcher: StrandWatcher | null = null;
378
419
  private strandManager: StrandInstanceManager;
379
420
  private hibernationManager: HibernationManager;
380
421
  private enrollmentService: EnrollmentService;
381
422
  private seedBootstrapService: SeedBootstrapService | null = null;
423
+ /**
424
+ * The claim-secret trust policy of a node started with `config.claim`, built once by
425
+ * {@link initializeClaimPolicy} over {@link trustedOwnerStore} and handed to every
426
+ * seed service this node constructs ({@link inboundSeedTrustPolicy}). Kept across
427
+ * stop()→start() like the anchor it writes: its in-process latch and failure window
428
+ * are per node, not per seed service. Null when the node has no claim secret.
429
+ */
430
+ private claimTrustPolicy: SeedTrustPolicy | null = null;
382
431
  private strandSolicitationService: StrandSolicitationService | null = null;
383
432
  /**
384
433
  * The service whose formation handler is registered on the control node. Trails
@@ -397,6 +446,13 @@ export class CadreNode implements SAppIdLookup {
397
446
  * stops conflating control addresses with strand seeding.
398
447
  */
399
448
  private strandAddrService: StrandAddrService | null = null;
449
+ /**
450
+ * Cadre invitation redemption handler (`/sereus/cadre-invite/1.0.0`): a device holding an
451
+ * invitation dials this machine and proves possession; the handler seats the row and
452
+ * admits the device against this node's control database. Registered by {@link start}
453
+ * once that database is up, on every node.
454
+ */
455
+ private cadreInviteHandler: CadreInviteHandler | null = null;
400
456
  /**
401
457
  * Server-side push-wake fan-out. Constructed by {@link start} only when
402
458
  * `config.push` (an injected `PushNotifier` + policy) is present — without it
@@ -508,26 +564,11 @@ export class CadreNode implements SAppIdLookup {
508
564
  /**
509
565
  * Most-recently pushed invite addresses (see {@link setInviteAddresses}).
510
566
  * When non-null these take priority over `libp2pNode.getMultiaddrs()` when
511
- * minting invites — the host pushes NAT-resolved addresses here so the
512
- * control-network node never needs to dial back to the manager.
567
+ * minting cadre invitations — the host pushes NAT-resolved addresses here so
568
+ * the control-network node never needs to dial back to the manager.
513
569
  */
514
570
  private latestInviteAddresses: string[] | null = null;
515
571
 
516
- /**
517
- * Epoch ms until which the control-network inbound gate admits
518
- * not-yet-authorized peers (see {@link admitInboundControlConnection} /
519
- * {@link openEnrollmentWindow}). 0 = no window open. Opened automatically by
520
- * {@link createInvite}; deliberately NOT reset on stop() — a stop/start cycle
521
- * inside an outstanding invite's validity must not strand the invitee.
522
- *
523
- * NOTE: in-memory only, so a PROCESS restart mid-invite closes the door until
524
- * the owner re-mints or the invitee is authorized. Fine while invites are
525
- * short-lived and redeemed promptly; if long-lived invites or restart-prone
526
- * hosts become normal, persist the window (or derive it from the issued-invite
527
- * records `SeedBootstrapService` already keeps).
528
- */
529
- private enrollmentWindowUntil = 0;
530
-
531
572
  /**
532
573
  * Lazily-parsed PeerIds of `controlNetwork.bootstrapNodes` (see
533
574
  * {@link getBootstrapPeerIds}) — infrastructure the inbound gate always admits.
@@ -594,10 +635,8 @@ export class CadreNode implements SAppIdLookup {
594
635
  * Does this node's CONTROL libp2p run the circuit-relay server, and with which
595
636
  * init? Resolved once from `network` and `profile` by the same function every
596
637
  * strand node's build uses (`relay-server.ts`). Read by
597
- * {@link buildControlNodeOptions} (which configures the server from it),
598
- * {@link admitInboundControlConnection} (whose deny/admit-for-relay branch must
599
- * agree with whether a reservation is even servable here), and the
600
- * unauthorized budget above (whose TTL is the server's).
638
+ * {@link buildControlNodeOptions} (which configures the server from it) and
639
+ * the unauthorized budget above (whose TTL is the server's).
601
640
  */
602
641
  private readonly relayServer: ResolvedRelayServer;
603
642
 
@@ -709,7 +748,7 @@ export class CadreNode implements SAppIdLookup {
709
748
 
710
749
  /**
711
750
  * Invite keys of the membership invitations this process has staged, by
712
- * {@link adoptFormationMembershipInvite} or from a `joined` `PendingJoin` row
751
+ * {@link adoptFormationMembershipInvite} or from a `JoinSuccess` row
713
752
  * ({@link stageMembershipInvitesFromPendingJoins}), so one the reconciler has settled is not
714
753
  * staged again by the next pending-join pass. Same lifetime as
715
754
  * {@link pendingMembershipInvites}: a restarted process stages each live one once more, and the
@@ -1108,12 +1147,22 @@ export class CadreNode implements SAppIdLookup {
1108
1147
  // store fails closed before a node is created.
1109
1148
  await this.resolveIdentityKey();
1110
1149
 
1150
+ // A conflicting `claim` is refused BEFORE the anchor is built: the store below
1151
+ // writes the config pins, and on a persistent anchor a pin written ahead of
1152
+ // this refusal would survive the failed start and mark the node claimed by it.
1153
+ this.validateClaimConfig();
1154
+
1111
1155
  // Bring up the node-local trusted-owner anchor (and seed config pins)
1112
1156
  // before any network bring-up: a mis-scoped injected store fails closed
1113
1157
  // here, and out-of-band pins are anchored before the first seed/peer
1114
1158
  // interaction could consult them.
1115
1159
  await this.initializeTrustedOwnerStore();
1116
1160
 
1161
+ // A node started with a claim secret gets its trust policy here, over the anchor
1162
+ // just built; a malformed secret fails closed before any network bring-up, like
1163
+ // a mis-scoped anchor above.
1164
+ this.initializeClaimPolicy();
1165
+
1117
1166
  // Bring up the node-local cold-start bootstrap-peer store alongside the
1118
1167
  // anchor, and for the same two reasons: a mis-scoped injected store fails
1119
1168
  // closed before any network bring-up, and the retained dial targets are
@@ -1138,22 +1187,20 @@ export class CadreNode implements SAppIdLookup {
1138
1187
  this.initializeEnrolledMachineStore();
1139
1188
 
1140
1189
  // Arm the connection gate's BRING-UP QUIET PERIOD before the libp2p node
1141
- // exists, so no connection can form ahead of it: `@libp2p/bootstrap` emits
1142
- // its discovery events ~1 s after `libp2p.start()` and the connection
1143
- // manager auto-dials from there, which is INSIDE createControlNode below.
1190
+ // exists, so no connection can form ahead of it: the node is built without
1191
+ // its bootstrap peers (dialed after bring-up), but a peer it already knows
1192
+ // from a previous run, or one that dials in, could otherwise connect inside
1193
+ // createControlNode below.
1144
1194
  // The invariant being protected is "zero control connections during
1145
1195
  // control-database bring-up" — see the field's doc and
1146
1196
  // `membership-connection-gater.ts` → "The bring-up quiet period".
1147
1197
  //
1148
- // NOTE: without this gate the bootstrap arm is a RACE, not a guarantee — its
1149
- // safety margin is bring-up duration versus @libp2p/bootstrap's 1 s discovery
1150
- // fuse, and bring-up duration is (raw-storage operations) × per-operation
1151
- // latency. The cold-start operation count and the 50-90 ms/op figure a loaded
1152
- // phone actually sees live in `control-database.ts`'s `loadSchema` note (and
1153
- // are pinned by `control-start-storage-op-budget.spec.ts`): at the top of that
1154
- // range bring-up is 9-15 s, far past the fuse. So if this gate is ever removed
1155
- // or bypassed, that arm reopens for exactly the nodes most likely to hit it —
1156
- // phones joining a party, whose membership row has not replicated yet.
1198
+ // NOTE: the gate, not the ordering, is what holds the invariant. Bring-up
1199
+ // duration is (raw-storage operations) × per-operation latency; the cold-start
1200
+ // operation count and the 50-90 ms/op figure a loaded phone sees live in
1201
+ // `control-database.ts`'s `loadSchema` note (pinned by
1202
+ // `control-start-storage-op-budget.spec.ts`), so bring-up can take 9-15 s —
1203
+ // ample time for a remembered or inbound peer to connect without the gate.
1157
1204
  this.controlBringUpInFlight = true;
1158
1205
 
1159
1206
  // Create the control network libp2p node
@@ -1161,6 +1208,9 @@ export class CadreNode implements SAppIdLookup {
1161
1208
  this.controlNode = await this.createControlNode();
1162
1209
  timing('[start] createControlNode: %dms', Math.round(performance.now() - t0));
1163
1210
  log('Control node started with ID: %s', this.controlNode.peerId.toString());
1211
+ // Before anything else can happen on the node: the quiet period above holds every
1212
+ // connection off, so no stream can arrive ahead of the guard.
1213
+ this.installControlProtocolGuard(this.controlNode);
1164
1214
 
1165
1215
  // Extract coordinatedRepo from the node (attached by createLibp2pNode)
1166
1216
  const coordinatedRepo = (this.controlNode as Libp2pNodeWithRepo).coordinatedRepo;
@@ -1194,6 +1244,10 @@ export class CadreNode implements SAppIdLookup {
1194
1244
  // bring-up hazard for boot-time strand seeds that are always empty.
1195
1245
  this.controlBringUpInFlight = false;
1196
1246
  log('Control database initialized');
1247
+ // Now that the gate is open, make contact with the configured bootstrap peers the
1248
+ // node was built without (see buildControlNodeOptions). Not awaited: an unreachable
1249
+ // relay must not hold up start().
1250
+ void this.dialControlBootstrapPeers();
1197
1251
 
1198
1252
  // Attach the per-stream gate to the control DB's membership hub, so every
1199
1253
  // committed `CadrePeer` write re-materializes the authorized snapshot on its
@@ -1232,11 +1286,10 @@ export class CadreNode implements SAppIdLookup {
1232
1286
  this.hibernationManager.start();
1233
1287
 
1234
1288
  // Register the control-network push-wake receiver: a same-cadre peer can
1235
- // signal us to bring a hibernating strand online. Gated on AUTHORIZED
1236
- // membership (not the addressable surface); the wake routes through the same
1237
- // path as a local wake.
1289
+ // signal us to bring a hibernating strand online. The protocol guard admits
1290
+ // only AUTHORIZED members (not the addressable surface); the wake routes
1291
+ // through the same path as a local wake.
1238
1292
  this.strandWakeService = new StrandWakeService({
1239
- isMember: (peerId) => this.isAuthorizedMember(peerId),
1240
1293
  getStrand: (strandId) => this.strandManager.getInstance(strandId),
1241
1294
  wake: (strandId) => this.wakeStrand(strandId),
1242
1295
  });
@@ -1244,14 +1297,13 @@ export class CadreNode implements SAppIdLookup {
1244
1297
 
1245
1298
  // Register the control-network strand-address responder: a same-cadre peer
1246
1299
  // resolving a strand's bootstrap seed asks us for our live strand-network
1247
- // multiaddrs (its CadrePeer row only knows our *control* address). Gated on
1248
- // AUTHORIZED membership; answers only for strands we are actively meshing.
1249
- // The same RPC doubles as the delegate-announce channel: a member's request
1250
- // may carry the derived peerId its strand node runs as, and we record an
1251
- // admission grant so our connection gate (and thus our relay server, when
1252
- // enabled) admits it — see delegate-admission.ts.
1300
+ // multiaddrs (its CadrePeer row only knows our *control* address). The
1301
+ // protocol guard admits only AUTHORIZED members; answers only for strands we
1302
+ // are actively meshing. The same RPC doubles as the delegate-announce channel:
1303
+ // a member's request may carry the derived peerId its strand node runs as, and
1304
+ // we record an admission grant so our connection gate (and thus our relay
1305
+ // server, when enabled) admits it — see delegate-admission.ts.
1253
1306
  this.strandAddrService = new StrandAddrService({
1254
- isMember: (peerId) => this.isAuthorizedMember(peerId),
1255
1307
  getStrandMultiaddrs: (strandId) => this.getStrandMultiaddrs(strandId),
1256
1308
  onDelegateAnnounce: (announcer, strandId, delegate) =>
1257
1309
  this.grantDelegateAdmission(announcer, strandId, delegate),
@@ -1264,6 +1316,24 @@ export class CadreNode implements SAppIdLookup {
1264
1316
  // so the responder never reads a database that cannot answer yet.
1265
1317
  await this.installDefaultFormationResponder();
1266
1318
 
1319
+ // Answer cadre invitation redemptions from now on: a device holding an invitation
1320
+ // dials any member machine, this one included, and proves possession in-protocol.
1321
+ // After the control database is up, since the handler seats and redeems against it.
1322
+ this.cadreInviteHandler = new CadreInviteHandler({
1323
+ partyId: this.config.controlNetwork.partyId,
1324
+ store: this.controlDatabase,
1325
+ catchUpDevice: (peerId) => this.catchUpRedeemingDevice(peerId),
1326
+ });
1327
+ await this.cadreInviteHandler.register(this.controlNode);
1328
+
1329
+ // A node waiting to be claimed listens for its claim seed from the start: nobody
1330
+ // else will turn the listener on, and a node no seed can reach cannot be claimed.
1331
+ // Before `control:connected`, so an embedder that reports "awaiting claim" on that
1332
+ // event is already reachable on `/sereus/seed/1.0.0`.
1333
+ if (this.config.claim) {
1334
+ await this.enableSeedListener();
1335
+ }
1336
+
1267
1337
  // Server-side push-wake fan-out: only when push is configured.
1268
1338
  if (this.config.push) {
1269
1339
  this.pushFanoutService = this.buildPushFanout(this.config.push);
@@ -1444,15 +1514,22 @@ export class CadreNode implements SAppIdLookup {
1444
1514
 
1445
1515
  /**
1446
1516
  * Construct (or adopt) the node-local trusted-owner anchor and seed the
1447
- * out-of-band pinned keys from `config.trustedOwners`. The store is NEVER
1448
- * sourced from the replicated control DB — its entries come only from
1449
- * genesis self-trust ({@link initializeSeedBootstrap}), config pins (here),
1450
- * or runtime enrollment pins ({@link trustOwnerKeys}).
1517
+ * out-of-band pinned keys from `config.trustedOwners`. The anchor's BASE comes
1518
+ * only from out of band — genesis self-trust ({@link initializeSeedBootstrap}),
1519
+ * config pins (here), runtime enrollment pins ({@link trustOwnerKeys}) and a
1520
+ * claim; everything else in it is derived from that base along verifiable
1521
+ * chains through the replicated `OwnerKey` table ({@link syncOwnerAnchor}),
1522
+ * never read from the table directly.
1451
1523
  *
1452
1524
  * Idempotent across stop()→start(): the store instance is kept, and
1453
1525
  * re-seeding config pins is a no-op ({@link TrustedOwnerStore.trust} is
1454
1526
  * idempotent). An injected store scoped to a different party is a
1455
1527
  * configuration error (fail closed before any network bring-up).
1528
+ *
1529
+ * NOTE: a config pin of a key the owners have since REMOVED is re-anchored here at
1530
+ * every start and removed again by the first sync (`refreshMembershipGate('start')`,
1531
+ * fired without await from {@link start}), so such a pin is honoured for at most one
1532
+ * membership refresh per start; dropping the pin from the config ends that.
1456
1533
  */
1457
1534
  private async initializeTrustedOwnerStore(): Promise<void> {
1458
1535
  const { trustedOwners } = this.config;
@@ -1476,6 +1553,83 @@ export class CadreNode implements SAppIdLookup {
1476
1553
  }
1477
1554
  }
1478
1555
 
1556
+ /**
1557
+ * Refuse a `config.claim` that sits beside another cold-start trust source. A node
1558
+ * has exactly ONE, so `claim` with a non-empty `trustedOwners.pinnedKeys` or a
1559
+ * `seedTrustPolicy` is refused here rather than silently letting one of them win.
1560
+ * Asserted at this seam, not by a type: the embedders build `CadreNodeConfig` as one
1561
+ * object literal with every optional field present, which a discriminated union over
1562
+ * the whole config would not express.
1563
+ *
1564
+ * Runs BEFORE {@link initializeTrustedOwnerStore}, which writes the config pins into
1565
+ * the anchor: on a persistent anchor a pin written ahead of this refusal would outlive
1566
+ * the failed start, and the node, restarted with the pin dropped from its config and
1567
+ * `claim` kept, would read as claimed by that pin and never accept its claimant.
1568
+ */
1569
+ private validateClaimConfig(): void {
1570
+ if (!this.config.claim) {
1571
+ return;
1572
+ }
1573
+ if ((this.config.trustedOwners?.pinnedKeys?.length ?? 0) > 0) {
1574
+ throw new Error(
1575
+ 'CadreNodeConfig: `claim` and `trustedOwners.pinnedKeys` are both set — a node has one ' +
1576
+ 'cold-start trust source; a node waiting to be claimed anchors its claimant, not a pin'
1577
+ );
1578
+ }
1579
+ if (this.config.seedTrustPolicy) {
1580
+ throw new Error(
1581
+ 'CadreNodeConfig: `claim` and `seedTrustPolicy` are both set — a node has one cold-start ' +
1582
+ 'trust source; the claim secret supplies the seed trust policy itself'
1583
+ );
1584
+ }
1585
+ }
1586
+
1587
+ /**
1588
+ * Build {@link claimTrustPolicy} from `config.claim`, once per node (a restart keeps
1589
+ * the policy, as {@link initializeTrustedOwnerStore} keeps the anchor it writes).
1590
+ * Runs after the anchor exists and before any network bring-up, so a malformed secret
1591
+ * fails `start()` closed with nothing to tear down; `parseClaimSecret` names the
1592
+ * problem and never echoes the value. The conflicts with the other trust sources are
1593
+ * {@link validateClaimConfig}'s, checked before the anchor is written.
1594
+ */
1595
+ private initializeClaimPolicy(): void {
1596
+ const { claim } = this.config;
1597
+ if (!claim || this.claimTrustPolicy) {
1598
+ return;
1599
+ }
1600
+ // Unreachable in practice: start() builds the store just above. Guarded so the
1601
+ // policy can never be built over a missing anchor and silently claim into nothing.
1602
+ if (!this.trustedOwnerStore) {
1603
+ throw new Error('CadreNode: the trusted-owner anchor must exist before the claim policy is built');
1604
+ }
1605
+ this.claimTrustPolicy = claimSecretTrustPolicy({
1606
+ secret: parseClaimSecret(claim.secret),
1607
+ trustedOwners: this.trustedOwnerStore,
1608
+ recordClaim: claim.record,
1609
+ onClaimed: (accepted) => this.emit('claim:accepted', accepted),
1610
+ });
1611
+ log('Node starts awaiting a claim; start() enables the seed listener once the control node is up');
1612
+ }
1613
+
1614
+ /**
1615
+ * Is this node waiting to be claimed: started with `config.claim` and owned by nobody
1616
+ * yet (empty anchor)? Read live from the anchor every time, as
1617
+ * {@link admitControlPeerUnconditionally} reads it per stream: the anchor is the one
1618
+ * source of truth for "claimed", and a cached flag would need its own invalidation
1619
+ * from the policy's persist, the rollback and a restart. False on a node with no claim
1620
+ * secret, on a claimed one (restarted with the secret still set or not), and before
1621
+ * {@link start} has built the anchor.
1622
+ *
1623
+ * While true the node admits strangers' CONNECTIONS (the claim seed has to ride one)
1624
+ * and refuses their control-database streams ({@link authorizeInboundControlStream})
1625
+ * and relay reservations ({@link admitControlRelayReservation}): an unclaimed node has
1626
+ * no siblings to replicate from and must not become a public relay while it waits.
1627
+ * cadre-cli reports this on `/status`; `claim:accepted` is the event form.
1628
+ */
1629
+ isAwaitingClaim(): boolean {
1630
+ return this.config.claim !== undefined && this.trustedOwnerStore?.all().size === 0;
1631
+ }
1632
+
1479
1633
  /**
1480
1634
  * Construct (or adopt) the node-local cold-start bootstrap-peer store (see
1481
1635
  * {@link bootstrapPeerStore}). Synchronous: an injected store has already
@@ -1626,22 +1780,23 @@ export class CadreNode implements SAppIdLookup {
1626
1780
 
1627
1781
  /**
1628
1782
  * Persist out-of-band-established owner keys into the node-local anchor —
1629
- * the runtime enrollment seam: call with `CadreInvite.ownerKeys` when
1630
- * redeeming an invite (BEFORE the first `applySeed`, so the anchor already
1631
- * holds the pins when seed trust consults it), or with an operator-supplied
1632
- * pin. Idempotent. ('genesis' provenance is reserved for the node's own
1633
- * founding key, seeded internally by {@link initializeSeedBootstrap}.)
1783
+ * the runtime enrollment seam: {@link redeemCadreInvitation} calls it with the
1784
+ * cadre invitation's `ownerKeys` BEFORE dialing a member (so the anchor already
1785
+ * holds the pins when the reply and the rows that follow are judged), and an
1786
+ * embedder calls it with an operator-supplied pin. Idempotent. ('genesis'
1787
+ * provenance is reserved for the node's own founding key, seeded internally by
1788
+ * {@link initializeSeedBootstrap}; 'chain' is written only by {@link syncOwnerAnchor}.)
1634
1789
  *
1635
1790
  * Validates every key's shape before trusting any of them (all-or-nothing):
1636
1791
  * a malformed entry anywhere in `keys` rejects the whole call before a
1637
- * single key is anchored. For the invite route this means a `CadreInvite`
1792
+ * single key is anchored. For the invitation route this means a bundle
1638
1793
  * carrying one malformed `ownerKeys` entry fails the redemption outright —
1639
1794
  * consistent with the anchor's existing whole-record-or-nothing policy for
1640
1795
  * a corrupt persisted entry (see `trusted-owner-store.ts`'s
1641
1796
  * `unusableEntry: 'discard-all'`) — rather than silently anchoring a subset
1642
1797
  * and leaving the caller to notice a key went missing.
1643
1798
  */
1644
- async trustOwnerKeys(keys: Iterable<string>, source: Exclude<TrustSource, 'genesis'>): Promise<void> {
1799
+ async trustOwnerKeys(keys: Iterable<string>, source: Exclude<TrustSource, 'genesis' | 'chain'>): Promise<void> {
1645
1800
  if (!this.trustedOwnerStore) {
1646
1801
  throw new Error('CadreNode must be started before trusting owner keys');
1647
1802
  }
@@ -1656,19 +1811,24 @@ export class CadreNode implements SAppIdLookup {
1656
1811
  }
1657
1812
 
1658
1813
  /**
1659
- * The control libp2p node's Optimystic network name — ONE binding for every
1660
- * derivation from it (`db-p2p` namespaces all of the node's protocol ids as
1661
- * `/optimystic/<networkName>/...`), so the node options and the block-transfer
1662
- * protocol prefix the control backfill dials can never drift apart.
1663
- *
1664
- * NOTE: the party id goes in UNENCODED here, unlike in `controlStorageScope`. Safe
1665
- * today because a party id is locally configured rather than replicated in, and both
1666
- * ends of a connection derive this string identically — an odd party id yields an odd
1667
- * but consistent protocol id, not a mismatch or an escaped name. If a party id ever
1668
- * arrives from the network, encode it here as the storage scope key already does.
1814
+ * Refuse non-members on every members-only protocol of the control node, at one seam
1815
+ * (`control-protocol-guard.ts`). Wake and strand-addr are judged by the live
1816
+ * {@link isAuthorizedMember}, bounded by the asker's own attempt deadline at this
1817
+ * node's declared link; everything a control-database read depends on is judged by
1818
+ * the snapshot ({@link authorizeInboundControlStream}).
1669
1819
  */
1820
+ private installControlProtocolGuard(controlNode: Libp2p): void {
1821
+ installControlProtocolGuard(controlNode, {
1822
+ partyId: this.config.controlNetwork.partyId,
1823
+ isMemberLive: (peerId) => this.isAuthorizedMember(peerId),
1824
+ isMemberSnapshot: (peerId, protocol) => this.authorizeInboundControlStream(peerId, protocol),
1825
+ liveDecisionTimeoutMs: relayedRequestBudgetMs(this.config.network?.linkRoundTripMs),
1826
+ });
1827
+ }
1828
+
1829
+ /** This node's control network name ({@link controlNetworkName}, the one binding for it). */
1670
1830
  private controlNetworkName(): string {
1671
- return `control-${this.config.controlNetwork.partyId}`;
1831
+ return controlNetworkName(this.config.controlNetwork.partyId);
1672
1832
  }
1673
1833
 
1674
1834
  /**
@@ -1696,6 +1856,8 @@ export class CadreNode implements SAppIdLookup {
1696
1856
  * reconnect re-runs identify), and — because the production join order is
1697
1857
  * connect-then-authorize — on every committed membership change via
1698
1858
  * {@link refreshAuthorizedControlPeers}'s `scheduleConnectedPeers()` call.
1859
+ * The one non-member the gate admits is a device whose invitation
1860
+ * redemption this member is admitting ({@link catchUpRedeemingDevice}).
1699
1861
  *
1700
1862
  * No-ops (logged) when the embedder configured no control storage or the
1701
1863
  * node exposes no key network — the backfill would have nothing to read or
@@ -1726,7 +1888,7 @@ export class CadreNode implements SAppIdLookup {
1726
1888
  // under — derived from the same networkName binding the node options
1727
1889
  // used, never re-spelled here.
1728
1890
  protocolPrefix: `/optimystic/${networkName}`,
1729
- authorizePeer: (peerId) => this.isAuthorizedMember(peerId)
1891
+ authorizePeer: async (peerId) => this.admittingDevices.has(peerId) || await this.isAuthorizedMember(peerId)
1730
1892
  }, {
1731
1893
  // A shorter settle window than the strand default (1000 ms): the control
1732
1894
  // store is small (a party's membership — dozens of blocks), a re-push is
@@ -1753,6 +1915,39 @@ export class CadreNode implements SAppIdLookup {
1753
1915
  this.controlBackfill.start();
1754
1916
  }
1755
1917
 
1918
+ /**
1919
+ * Push this member's control store to a device whose invitation redemption has verified,
1920
+ * and wait for the push, before the redemption seats the invitation's row and writes the
1921
+ * device's rows (the handler's `catchUpDevice`; `CadreInviteHandler.catchUpDeviceIfLive`
1922
+ * says why those writes need it).
1923
+ * The device is admitted at the backfill's gate for the length of the push only.
1924
+ *
1925
+ * Through {@link PeerJoinBackfill.forceCatchUpPeer}, not `catchUpPeer`: the device's own
1926
+ * `peer:identify` scheduled a run a moment earlier, which was denied if it passed the gate
1927
+ * before the device was added here, and `catchUpPeer` would answer that run's in-flight
1928
+ * status with an empty result.
1929
+ */
1930
+ private async catchUpRedeemingDevice(peerId: string): Promise<void> {
1931
+ const backfill = this.controlBackfill;
1932
+ if (!backfill) {
1933
+ log('No control backfill on this node; device %s is not caught up before its admission', peerId);
1934
+ return;
1935
+ }
1936
+ this.admittingDevices.set(peerId, (this.admittingDevices.get(peerId) ?? 0) + 1);
1937
+ try {
1938
+ const result = await backfill.forceCatchUpPeer(peerIdFromString(peerId));
1939
+ log('Device %s caught up before its admission: offered=%d accepted=%d rejected=%d',
1940
+ peerId, result.offered, result.accepted, result.rejected.length);
1941
+ } finally {
1942
+ const remaining = (this.admittingDevices.get(peerId) ?? 1) - 1;
1943
+ if (remaining > 0) {
1944
+ this.admittingDevices.set(peerId, remaining);
1945
+ } else {
1946
+ this.admittingDevices.delete(peerId);
1947
+ }
1948
+ }
1949
+ }
1950
+
1756
1951
  /**
1757
1952
  * The control database's cache-wrapped raw storage, resolved from
1758
1953
  * `config.storage.provider` on first use and then held in {@link controlStorage}
@@ -1844,7 +2039,7 @@ export class CadreNode implements SAppIdLookup {
1844
2039
  * only caller in production is `createControlNode`.
1845
2040
  */
1846
2041
  private buildControlNodeOptions(): Parameters<typeof createLibp2pNode>[0] {
1847
- const { controlNetwork, network, profile } = this.config;
2042
+ const { network, profile } = this.config;
1848
2043
  const identityKey = this.identityKey;
1849
2044
  // `network.relayAddrs` contributes the bare `/p2p-circuit` SEARCH entry, which
1850
2045
  // opens no connection — the reservation is driven explicitly at the END of
@@ -1863,7 +2058,11 @@ export class CadreNode implements SAppIdLookup {
1863
2058
 
1864
2059
  const nodeOptions: Parameters<typeof createLibp2pNode>[0] = {
1865
2060
  port: 0,
1866
- bootstrapNodes: controlNetwork.bootstrapNodes,
2061
+ // Empty on purpose: Optimystic refuses to treat a block as never-created until it has
2062
+ // heard from every `bootstrapNodes` peer, and the bring-up quiet period refuses those
2063
+ // dials, so a non-empty list fails start() with `cohort-unreachable`. The configured
2064
+ // peers are dialed once bring-up ends ({@link dialControlBootstrapPeers}).
2065
+ bootstrapNodes: [],
1867
2066
  networkName: this.controlNetworkName(),
1868
2067
  storage: controlStorageProvider,
1869
2068
  fretProfile: profile === 'storage' ? 'core' : 'edge',
@@ -1927,9 +2126,11 @@ export class CadreNode implements SAppIdLookup {
1927
2126
  // `announceAddrs` replaces the advertised set (see warnIfAnnounceAddrsDiscardRelay).
1928
2127
  ...resolveAnnounceAddrs(network),
1929
2128
  // The CONTROL node composes the membership admission gate onto any
1930
- // caller-supplied gater (deny from either wins on the inbound-encrypted
1931
- // and relay-reservation hooks; all other hooks pass through). Strand
1932
- // cohort nodes keep the raw configured gater (see
2129
+ // caller-supplied gater (a deny from the caller's wins on every composed
2130
+ // hook; the membership policy refuses reservations, never connections,
2131
+ // and puts an unplaced peer's connection on the provisional deadline,
2132
+ // derived from THIS node's declared link because this node decides).
2133
+ // Strand cohort nodes keep the raw configured gater (see
1933
2134
  // strand-instance-manager.ts) — their peers are legitimately
1934
2135
  // cross-party, so cadre membership must not gate them.
1935
2136
  connectionGater: createMembershipConnectionGater(
@@ -1940,12 +2141,15 @@ export class CadreNode implements SAppIdLookup {
1940
2141
  },
1941
2142
  network?.connectionGater,
1942
2143
  ADMISSION_DECISION_TIMEOUT_MS,
1943
- relayAdmissionReserveDeadlineMs(network?.linkRoundTripMs)
2144
+ relayedRequestBudgetMs(network?.linkRoundTripMs)
1944
2145
  ),
1945
2146
  // Fail-closed per-stream authorization for the four Optimystic control-DB
1946
2147
  // protocols — the members-only layer the connection gater's stranger
1947
2148
  // carve-outs cannot express (see authorizeInboundControlStream). Control
1948
- // node only: strand cohort nodes serve cross-party peers.
2149
+ // node only: strand cohort nodes serve cross-party peers. The protocol guard
2150
+ // (installControlProtocolGuard) checks these four with this same predicate
2151
+ // first; Optimystic's own check is kept because it is a working, supported
2152
+ // upstream defence, not a second policy.
1949
2153
  authorizeInboundStream: (remotePeerId, protocol) =>
1950
2154
  this.authorizeInboundControlStream(remotePeerId, protocol)
1951
2155
  };
@@ -1969,86 +2173,85 @@ export class CadreNode implements SAppIdLookup {
1969
2173
  }
1970
2174
 
1971
2175
  /**
1972
- * Decide whether an inbound CONTROL-network connection from `remotePeerId`
1973
- * should be admitted — the policy behind the connection gater wired in
1974
- * {@link createControlNode}. Deny only on a positive "unauthorized outsider
1975
- * while no stranger path is open" determination; everything ambiguous admits
1976
- * and defers to the fail-closed per-stream gates (see
2176
+ * Decide how an inbound CONTROL-network connection from `remotePeerId` is
2177
+ * admitted — the policy behind the connection gater wired in
2178
+ * {@link createControlNode}. Every connection is admitted; the answer decides
2179
+ * only whether it carries the provisional deadline (see
1977
2180
  * `membership-connection-gater.ts` for the layer's rationale and the
1978
- * stranger-open protocol allowlist).
2181
+ * stranger-open protocol allowlist). Asked once when the connection arrives
2182
+ * and again at a provisional connection's deadline.
1979
2183
  *
1980
- * Returns `'admit'` when ANY of:
2184
+ * Returns `'admit'` (no deadline) when ANY of:
1981
2185
  * 1. a shared-baseline check admits ({@link admitControlPeerUnconditionally}
1982
2186
  * — not running / DB torn down, absent-or-empty trusted-owner anchor, or
1983
2187
  * configured bootstrap/relay infrastructure);
1984
- * 2. an enrollment window is open ({@link openEnrollmentWindow}, opened by
1985
- * {@link createInvite}) — the invitee dials in before it is authorized;
1986
- * 3. the peer holds a live DELEGATE ADMISSION GRANT — an authorized member
2188
+ * 2. the peer holds a live DELEGATE ADMISSION GRANT — an authorized member
1987
2189
  * announced it (over the strand-addr RPC) as the transport peerId of its
1988
2190
  * own strand node, so a NAT'd member's strand node can hold a
1989
2191
  * circuit-relay reservation here (see `delegate-admission.ts`).
1990
2192
  * Connection only: the per-stream gate below never honors a grant;
1991
- * 4. the authorized-member set is empty — cold start: the rows that would
2193
+ * 3. the authorized-member set is empty — cold start: the rows that would
1992
2194
  * authorize anyone arrive by replication over these very connections;
1993
- * 5. the peer IS an authorized member; or
1994
- * 6. an open invitation is OUTSTANDING — at least one unexpired,
2195
+ * 4. the peer IS an authorized member; or
2196
+ * 5. an open invitation is OUTSTANDING — at least one unexpired,
1995
2197
  * not-fully-consumed invitation this node minted or persisted (see
1996
2198
  * `StrandSolicitationService.hasOutstandingInvitation`). A formation
1997
2199
  * initiator is another party's peer by design and its token is only
1998
2200
  * checkable inside the protocol, so the gate asks the coarser question
1999
2201
  * "does this node expect a stranger at all?". REGISTERING the responder
2000
- * does not suspend stranger denial: every node registers one at
2001
- * {@link start}, and registering mints no invitation.
2202
+ * does not count: every node registers one at {@link start}, and
2203
+ * registering mints no invitation; or
2204
+ * 6. a LIVE cadre invitation exists — a `CadreInvite` row this node holds
2205
+ * that is not withdrawn, unexpired, has uses left and whose issuer is
2206
+ * still an owner (`ControlDatabase.hasLiveCadreInvite`). The device that
2207
+ * redeems it is a stranger until the redemption writes its row, and its
2208
+ * proof of possession is only checkable inside `/sereus/cadre-invite/1.0.0`;
2209
+ * same reasoning as check 5, and the same expectation-of-a-stranger key.
2002
2210
  *
2003
2211
  * Ordering is semantically free (the checks are OR'd) but decides who pays:
2004
- * checks 1-3 are in-memory, 4/5 share one control-DB read, and only a peer
2005
- * already on the deny path reaches check 6's invitation lookup.
2212
+ * checks 1-2 are in-memory, 3/4 share one control-DB read, and only a peer
2213
+ * that is otherwise headed for a provisional admission reaches check 5's and
2214
+ * 6's invitation lookups.
2006
2215
  *
2007
- * Caveats of check 6, both self-healing:
2216
+ * Caveats of check 5:
2008
2217
  * - the in-memory mint registry dies with the process, so after a restart
2009
2218
  * only invitations persisted as `FormationInvite` rows still hold the
2010
- * exemption open (re-mint otherwise — same story as the enrollment window);
2219
+ * window open (re-mint otherwise);
2011
2220
  * - a peer holding a token whose `FormationInvite` row has not replicated to
2012
- * this node yet is denied even though the formation handler would have
2013
- * accepted it, exactly like the unreplicated-membership-row case below.
2014
- *
2015
- * When every check falls through, the verdict depends on whether this node
2016
- * runs the circuit-relay server ({@link relayServer}): without one,
2017
- * `'deny'`; with one, `'admit-for-relay'` — a circuit-relay reservation is
2018
- * established by the reserving peer DIALING the relay, so a connection deny
2019
- * here kills the reservation, and that deny is NOT self-healing: an outbound
2020
- * reconcile re-dial re-establishes a data link, but no outbound dial can
2021
- * grant the remote peer a reservation, and a relay-only peer has no address
2022
- * of its own to dial back — the reservation IS its address. The gater admits
2023
- * such a connection, decides the reservation via
2024
- * {@link admitControlRelayReservation}, and drops the connection if no
2025
- * reservation is admitted in time (see `membership-connection-gater.ts` →
2026
- * "The relay-reservation seam").
2027
- *
2028
- * NOTE: check 4/5 runs a control-DB read per inbound connection
2221
+ * this node yet is admitted only provisionally, so its connection is closed
2222
+ * at the deadline unless the row has landed by then.
2223
+ *
2224
+ * When every check falls through: `'admit-provisionally'`, on every node,
2225
+ * relay server or not. The gate sees only a peer id, so it cannot tell an
2226
+ * outsider from a device whose invitation row this node has not received yet
2227
+ * (it redeems here by seating the row from its bundle, after which check 6
2228
+ * admits) or from a sibling whose membership row is still replicating (check 4
2229
+ * admits once it lands; if it has not landed by the deadline, the connection
2230
+ * is closed and either side's next reconcile dial re-forms it, since outbound
2231
+ * dials are never gated). The protocol guard refuses all of them every
2232
+ * members-only protocol meanwhile. A circuit-relay reservation admitted by
2233
+ * {@link admitControlRelayReservation} disarms the deadline (see
2234
+ * `membership-connection-gater.ts` → "The relay-reservation seam").
2235
+ *
2236
+ * NOTE: check 3/4 runs a control-DB read per inbound connection
2029
2237
  * (`listAuthorizedMembers`); connections are rare and cadres small, and this
2030
2238
  * layer is fail-open behind `ADMISSION_DECISION_TIMEOUT_MS`, so the live
2031
2239
  * read is safe here — unlike the per-stream gate, which must consult the
2032
2240
  * materialized {@link authorizedControlPeers} snapshot instead.
2033
- * NOTE: check 6 adds a second control read (`hasOutstandingFormationInvite`) for a
2034
- * stranger with no locally minted invitation in play, on every node now that every
2035
- * node runs the responder — a relay-enabled storage node included. If stranger
2036
- * connections to such a node ever arrive fast enough for that read to show, cache
2037
- * the answer for a few seconds.
2038
- * NOTE: on a relay-DISABLED node, a sibling whose membership row has not yet
2039
- * replicated here is denied until the row converges (typically via the
2040
- * owner); either side's next outbound reconcile dial (outbound is never
2041
- * gated) re-establishes the DATA link — self-healing for data, visible as a
2042
- * transient deny. That self-healing story never covered a reservation, which
2043
- * is exactly why the relay-enabled path above exists.
2241
+ * NOTE: checks 5 and 6 add two more control reads (`hasOutstandingFormationInvite`,
2242
+ * `hasLiveCadreInvite`) for a stranger with no locally minted invitation in play, on
2243
+ * every node, and a provisional connection's deadline runs the whole decision once
2244
+ * more. If stranger connections ever arrive fast enough for those reads — on arrival
2245
+ * or at the deadline — to show, cache the answers for a few seconds.
2044
2246
  */
2045
2247
  private async admitInboundControlConnection(remotePeerId: string): Promise<InboundConnectionVerdict> {
2248
+ // NOTE: a node waiting to be claimed (isAwaitingClaim) lands on this empty-anchor
2249
+ // admission too, so a stranger can hold an idle, mute connection to it for as long
2250
+ // as it waits on a public address; if that ever shows up as a resource problem, put
2251
+ // unclaimed-node admissions on the provisional deadline too.
2046
2252
  if (this.admitControlPeerUnconditionally(remotePeerId)) {
2047
2253
  return 'admit';
2048
2254
  }
2049
- if (Date.now() < this.enrollmentWindowUntil) {
2050
- return 'admit';
2051
- }
2052
2255
  if (this.delegateAdmission.has(remotePeerId)) {
2053
2256
  return 'admit';
2054
2257
  }
@@ -2063,16 +2266,15 @@ export class CadreNode implements SAppIdLookup {
2063
2266
  if (await this.strandSolicitationService?.hasOutstandingInvitation()) {
2064
2267
  return 'admit';
2065
2268
  }
2269
+ if (await this.controlDatabase?.hasLiveCadreInvite()) {
2270
+ return 'admit';
2271
+ }
2066
2272
  } catch (error) {
2067
2273
  log('admitInboundControlConnection: outstanding-invitation check threw for %s — admitting (fail-open): %o', remotePeerId, error);
2068
2274
  return 'admit';
2069
2275
  }
2070
- if (this.relayServer.enabled) {
2071
- log('admitInboundControlConnection: admitting %s FOR RELAY ONLY — not an authorized member and no enrollment path open; the gater drops the connection unless a reservation is admitted', remotePeerId);
2072
- return 'admit-for-relay';
2073
- }
2074
- log('admitInboundControlConnection: DENYING inbound from %s — not an authorized member and no enrollment path open', remotePeerId);
2075
- return 'deny';
2276
+ log('admitInboundControlConnection: %s is not an authorized member and no stranger window is open — admitting provisionally', remotePeerId);
2277
+ return 'admit-provisionally';
2076
2278
  }
2077
2279
 
2078
2280
  /**
@@ -2082,7 +2284,13 @@ export class CadreNode implements SAppIdLookup {
2082
2284
  * "The relay-reservation seam"); the circuit-relay server consults it per
2083
2285
  * RESERVE request, so it is never called on a node whose relay server is off.
2084
2286
  *
2085
- * Admits when ANY of:
2287
+ * Refuses everyone, ahead of every admission below, while this node is waiting to be
2288
+ * claimed ({@link isAwaitingClaim}): a storage-profile node runs the relay server by
2289
+ * default, and an unclaimed node on a public address must not become anyone's relay.
2290
+ * The empty-anchor admission in check 1 exists for a node that will be enrolled by
2291
+ * its siblings, which an unclaimed node has none of.
2292
+ *
2293
+ * Otherwise admits when ANY of:
2086
2294
  * 1. a shared-baseline check admits ({@link admitControlPeerUnconditionally}
2087
2295
  * — not running / DB torn down, absent-or-empty trusted-owner anchor, or
2088
2296
  * configured bootstrap/relay infrastructure);
@@ -2102,14 +2310,18 @@ export class CadreNode implements SAppIdLookup {
2102
2310
  * that boots, reserves, and only then has its row replicate here would
2103
2311
  * otherwise keep that slot spent for the rest of the entry's TTL.
2104
2312
  *
2105
- * The connection gate's stranger carve-outs (an open enrollment window, an
2106
- * outstanding formation invitation) deliberately do NOT extend here: they
2107
- * exist so a stranger's SEED or FORMATION stream can ride a connection, and
2108
- * neither needs relay capacity. A genuine invitee that does need a relay slot
2109
- * takes one from the budget like any other unplaced peer, so an open window
2110
- * never becomes an unbounded grant of this node's forwarding capacity.
2313
+ * The connection gate's stranger carve-outs (an outstanding formation
2314
+ * invitation, a live cadre invitation) deliberately do NOT extend here: they
2315
+ * exist so a stranger's FORMATION or CADRE-INVITE stream can ride a connection,
2316
+ * and neither needs relay capacity. A genuine invitee that does need a relay
2317
+ * slot takes one from the budget like any other unplaced peer, so a live
2318
+ * invitation never becomes an unbounded grant of this node's forwarding capacity.
2111
2319
  */
2112
2320
  private async admitControlRelayReservation(remotePeerId: string): Promise<boolean> {
2321
+ if (this.isAwaitingClaim()) {
2322
+ log('admitControlRelayReservation: REFUSING %s — this node is unclaimed and relays for nobody until it is', remotePeerId);
2323
+ return false;
2324
+ }
2113
2325
  if (this.admitControlPeerUnconditionally(remotePeerId)) {
2114
2326
  return this.admitReservationUncounted(remotePeerId);
2115
2327
  }
@@ -2132,25 +2344,13 @@ export class CadreNode implements SAppIdLookup {
2132
2344
  return true;
2133
2345
  }
2134
2346
 
2135
- /**
2136
- * Hold the control-network inbound gate open for not-yet-authorized peers
2137
- * until `untilEpochMs` (extends, never shrinks, an already-open window).
2138
- * {@link createInvite} calls this automatically; a host running an
2139
- * out-of-band enrollment flow (e.g. accepting a phone whose invite this node
2140
- * never minted) can open it explicitly before the stranger dials in.
2141
- */
2142
- openEnrollmentWindow(untilEpochMs: number): void {
2143
- this.enrollmentWindowUntil = Math.max(this.enrollmentWindowUntil, untilEpochMs);
2144
- log('Enrollment window open until %d', this.enrollmentWindowUntil);
2145
- }
2146
-
2147
2347
  /**
2148
2348
  * Record (or refresh) a delegate admission grant: `delegatePeerId` is the
2149
2349
  * transport peerId of `announcerPeerId`'s strand-`strandId` node, admitted
2150
2350
  * at the CONNECTION and RESERVATION levels (all a circuit-relay reservation
2151
2351
  * needs, without spending the unauthorized-reservation budget) for
2152
- * `DELEGATE_GRANT_TTL_MS`. Called by the strand-addr responder after its
2153
- * authorized-membership gate has passed; public so tests can drive the
2352
+ * `DELEGATE_GRANT_TTL_MS`. Called by the strand-addr responder, which the
2353
+ * protocol guard admits only authorized members to; public so tests can drive the
2154
2354
  * admission policy without a full strand launch. A re-announce for the same
2155
2355
  * (announcer, strand) REPLACES the previous delegate rather than
2156
2356
  * accumulating. Never honored by {@link authorizeInboundControlStream}.
@@ -2192,25 +2392,35 @@ export class CadreNode implements SAppIdLookup {
2192
2392
  }
2193
2393
 
2194
2394
  /**
2195
- * Per-stream authorization for the four Optimystic control-DB protocols
2196
- * (`/optimystic/control-<party>/{repo,cluster,sync,block-transfer}/…`),
2197
- * wired as `authorizeInboundStream` in {@link createControlNode} — the
2198
- * fail-closed layer behind the fail-open connection gater. Control node
2199
- * ONLY: strand cohort nodes legitimately serve cross-party peers.
2395
+ * Per-stream authorization for the protocol guard's `members-snapshot` class
2396
+ * (`control-protocol-guard.ts`): the four Optimystic control-DB protocols
2397
+ * (`/optimystic/control-<party>/{repo,cluster,sync,block-transfer}/…`), FRET's
2398
+ * five, and any protocol nobody classed. Also wired as Optimystic's
2399
+ * `authorizeInboundStream` in {@link createControlNode} for the four DB
2400
+ * protocols. The fail-closed layer behind the fail-open connection gater.
2401
+ * Control node ONLY: strand cohort nodes legitimately serve cross-party peers.
2200
2402
  *
2201
2403
  * The STRICT SUBSET of {@link admitInboundControlConnection}: the same
2202
2404
  * "no basis to judge" admissions (shared via
2203
2405
  * {@link admitControlPeerUnconditionally}), minus the stranger carve-outs
2204
- * (enrollment window, outstanding open invitation, delegate admission
2205
- * grant). The first two exist so a stranger can reach `/sereus/seed/1.0.0`
2206
- * and `/sereus/formation/1.0.0` — neither is gated here, and admitting a
2207
- * stranger to `repo` during an enrollment window is exactly the hole this
2406
+ * (outstanding open invitation, live cadre invitation, delegate admission
2407
+ * grant). The first two exist so a stranger can reach `/sereus/formation/1.0.0`
2408
+ * and `/sereus/cadre-invite/1.0.0` — neither is gated here, and admitting a
2409
+ * stranger to `repo` while an invitation is live is exactly the hole this
2208
2410
  * gate closes. A DELEGATE-admitted connection (a member's strand node
2209
2411
  * holding a circuit-relay reservation, see `delegate-admission.ts`) is
2210
2412
  * likewise exactly a case this gate must still refuse: the delegate gets
2211
2413
  * the connection and never the control DB.
2212
2414
  *
2213
- * Admits when ANY of:
2415
+ * Refuses everyone, ahead of every admission below, while this node is waiting to be
2416
+ * claimed ({@link isAwaitingClaim}). The empty-anchor and empty-snapshot admissions
2417
+ * exist so a brand-new node can take replication from the siblings that will
2418
+ * authorize it; an unclaimed node has no siblings yet, and the rows that will
2419
+ * authorize anyone arrive only after the claim, over the owner's streams that the
2420
+ * same two admissions then let in. Configured bootstrap infra is refused too: a
2421
+ * relay opens no control-database stream.
2422
+ *
2423
+ * Otherwise admits when ANY of:
2214
2424
  * 1. a shared-baseline check admits (not running / no DB, empty anchor,
2215
2425
  * configured bootstrap infra);
2216
2426
  * 2. the materialized authorized set is empty — cold start, before the
@@ -2233,6 +2443,11 @@ export class CadreNode implements SAppIdLookup {
2233
2443
  * ever becomes a supported deployment, this gate needs a key-based admission.
2234
2444
  */
2235
2445
  private authorizeInboundControlStream(remotePeerId: string, protocol: string): boolean {
2446
+ if (this.isAwaitingClaim()) {
2447
+ log('authorizeInboundControlStream: DENYING %s on %s — this node is unclaimed and replicates with nobody until it is',
2448
+ remotePeerId, protocol);
2449
+ return false;
2450
+ }
2236
2451
  if (this.admitControlPeerUnconditionally(remotePeerId)) {
2237
2452
  return true;
2238
2453
  }
@@ -2262,13 +2477,19 @@ export class CadreNode implements SAppIdLookup {
2262
2477
  * read here would sleep its backoff holding the lock and stall every other local
2263
2478
  * writer. Nothing is lost — this refresh already keeps the previous snapshot on
2264
2479
  * failure and is re-driven by the next membership write and by the timed reconcile.
2480
+ *
2481
+ * The trusted-owner anchor sync ({@link syncOwnerAnchor}) runs FIRST, so the snapshot
2482
+ * built here judges `CadrePeer` rows against the anchor as the `OwnerKey` table now
2483
+ * has it. The invitation chain both reads may need is loaded at most once per refresh.
2265
2484
  */
2266
2485
  private async refreshAuthorizedControlPeers(reason: string): Promise<void> {
2267
2486
  if (!this._running || !this.controlDatabase) {
2268
2487
  return;
2269
2488
  }
2270
2489
  try {
2271
- const members = await this.listAuthorizedMembers(false);
2490
+ const loadChain = this.invitationChainSource(this.controlDatabase, false);
2491
+ await this.syncOwnerAnchor(reason, false, loadChain);
2492
+ const members = await this.collectAuthorizedMembers(this.controlDatabase, false, loadChain);
2272
2493
  this.authorizedControlPeers = new Set(members.map((m) => m.peerId));
2273
2494
  log('refreshAuthorizedControlPeers(%s): %d authorized peer(s)', reason, this.authorizedControlPeers.size);
2274
2495
  // Remember the party's size for the NEXT launch's control-node repair yardstick
@@ -2340,6 +2561,31 @@ export class CadreNode implements SAppIdLookup {
2340
2561
  }
2341
2562
  }
2342
2563
 
2564
+ /**
2565
+ * Add the configured `controlNetwork.bootstrapNodes` to the control node's address book
2566
+ * and dial each peer once — what `@libp2p/bootstrap` would have done had the node been
2567
+ * built with them. Best-effort: a failed dial is logged, and the connection manager and
2568
+ * FRET reconnect from the address book as they do for any known peer.
2569
+ */
2570
+ private async dialControlBootstrapPeers(): Promise<void> {
2571
+ const node = this.controlNode;
2572
+ if (!node) {
2573
+ return;
2574
+ }
2575
+ const groups = groupAddrsByPeerId(this.config.controlNetwork.bootstrapNodes);
2576
+ await Promise.all([...groups].map(async ([peerId, addrs]) => {
2577
+ if (peerId === node.peerId.toString()) {
2578
+ return;
2579
+ }
2580
+ await mergePeerAddrs(node, peerId, addrs);
2581
+ try {
2582
+ await node.dial(peerIdFromString(peerId));
2583
+ } catch (error) {
2584
+ log('dialControlBootstrapPeers: dial to bootstrap peer %s failed: %o', peerId, error);
2585
+ }
2586
+ }));
2587
+ }
2588
+
2343
2589
  /**
2344
2590
  * PeerIds of the configured control bootstrap nodes (relay/bootstrap
2345
2591
  * infrastructure — always admitted inbound). Parsed once, lazily; an
@@ -2468,6 +2714,11 @@ export class CadreNode implements SAppIdLookup {
2468
2714
  await this.registerSelf();
2469
2715
  } catch (error) {
2470
2716
  // Background task — a failed publish must not crash the node.
2717
+ // NOTE: a restarted machine whose own CadrePeer row replicates in while this update
2718
+ // runs fails here with ConcurrentModificationError (seen on the owner's restart in
2719
+ // every traced run of cadre-invite-any-member), leaving its record unrefreshed until
2720
+ // the next heartbeat. If a restarted machine's changed addresses must publish sooner,
2721
+ // retry once here.
2471
2722
  log('Self-registration failed: %o', error);
2472
2723
  }
2473
2724
  // Wire the ongoing refresh + control-cohort reconcile cadence, then run
@@ -2541,7 +2792,9 @@ export class CadreNode implements SAppIdLookup {
2541
2792
  const existing = await this.controlDatabase.queryPeerRecord(peerId);
2542
2793
 
2543
2794
  if (!existing) {
2544
- if (!this.seedBootstrapService) {
2795
+ // A listener-only service (`enableSeedListener`, a claim node's) holds no owner key and
2796
+ // cannot sign the insert, so it is "no owner service" here too.
2797
+ if (!this.seedBootstrapService?.canAuthorize()) {
2545
2798
  // "No row" also covers "revoked": queryPeerRecord reads a row whose StampId is
2546
2799
  // retired in Revocation as absent, so a removed node lands here every heartbeat
2547
2800
  // and stops refreshing its own record — correct, but say so, or the message reads
@@ -2689,6 +2942,7 @@ export class CadreNode implements SAppIdLookup {
2689
2942
  ): PeerAddressRecord {
2690
2943
  const updatedAt = Math.max(Date.now(), (existing?.updatedAt ?? 0) + 1);
2691
2944
  return signPeerRecord(
2945
+ this.partyId,
2692
2946
  { peerId, publicKey: signingKey.publicKeyB64, addrs, updatedAt },
2693
2947
  signingKey.privateKeyB64
2694
2948
  );
@@ -2771,6 +3025,28 @@ export class CadreNode implements SAppIdLookup {
2771
3025
  return ed25519KeyPairFromLibp2p(this.identityKey);
2772
3026
  }
2773
3027
 
3028
+ /**
3029
+ * Publish this node's own address record from the reconcile pass, for a CLAIMED node that
3030
+ * has not published one this session. Its owner writes its `CadrePeer` row only after the
3031
+ * claim is accepted (`claimNode`), so the row reaches this node by replication, after the
3032
+ * boot-time publish and the first-growth drain have both found nothing to publish; without
3033
+ * this the record would wait for the heartbeat ({@link startRecordRefresh}), and the cadre
3034
+ * could not resolve the node's addresses until then. The reconcile pass is where replicated
3035
+ * membership is picked up, so each pass tries until the first publish lands. A try before
3036
+ * the row has arrived is a `skipped` publish: one `CadrePeer` read. A claimed node later
3037
+ * removed from its cadre never publishes again, so it pays that read on every pass.
3038
+ */
3039
+ private async publishSelfRecordOnceClaimed(): Promise<void> {
3040
+ if (!this.config.claim || this.isAwaitingClaim() || this.lastSelfRecordPublishAt !== null) {
3041
+ return;
3042
+ }
3043
+ const outcome = await this.registerSelf().catch((error: unknown) => {
3044
+ log('reconcileControlCohort: claimed-node self-record publish failed (continuing): %o', error);
3045
+ return 'skipped' as const;
3046
+ });
3047
+ log('reconcileControlCohort: claimed node self-record publish: %s', outcome);
3048
+ }
3049
+
2774
3050
  /**
2775
3051
  * Collect this node's current dialable addresses for publication, signaling
2776
3052
  * (`/p2p-circuit`) first. Prefers the best invite/NAT-resolved set and folds
@@ -2828,12 +3104,14 @@ export class CadreNode implements SAppIdLookup {
2828
3104
  // `/p2p-circuit` address the boot-time publish could not carry). Harmless
2829
3105
  // where a late-authorized node's addresses keep churning (NAT/relay rotation
2830
3106
  // fire the event anyway), and invisible while parties authorize members before
2831
- // they boot. If late enrollment becomes the normal path — an invited phone is
2832
- // exactly that — republish on the membership-change seam that already exists
2833
- // (`ControlDatabase.setMembershipChangeListener`) rather than lengthening this
2834
- // list of triggers. Measured while writing
2835
- // `relay-only-control-addr.integration.ts` case 4, which drives `registerSelf()`
2836
- // explicitly because neither trigger is dependable there.
3107
+ // they boot. A claimed node is always authorized after it boots, and the reconcile
3108
+ // pass publishes for it ({@link publishSelfRecordOnceClaimed}). If late enrollment
3109
+ // becomes the normal path for other nodes too — an invited phone is exactly that —
3110
+ // drop that method's claim condition rather than lengthening this list of triggers:
3111
+ // the membership-change seam (`ControlDatabase.setMembershipChangeListener`) fires
3112
+ // on LOCAL commits only, and the row a late-authorized node waits for is written
3113
+ // elsewhere. Measured while writing `relay-only-control-addr.integration.ts` case 4,
3114
+ // which drives `registerSelf()` explicitly because neither trigger is dependable there.
2837
3115
  this.recordRefreshTimer = setInterval(() => republish('heartbeat'), DEFAULT_PEER_RECORD_HEARTBEAT_MS);
2838
3116
  (this.recordRefreshTimer as { unref?: () => void } | null)?.unref?.();
2839
3117
 
@@ -2945,7 +3223,7 @@ export class CadreNode implements SAppIdLookup {
2945
3223
  }
2946
3224
 
2947
3225
  // Self-signature over (peerId, addrs, updatedAt).
2948
- if (!verifyPeerRecordSignature(record)) {
3226
+ if (!verifyPeerRecordSignature(this.partyId, record)) {
2949
3227
  // Field detail distinguishes "holding the owner-vouch revision (Sig null,
2950
3228
  // no addrs — a not-yet-self-published row)" from a genuinely corrupt or
2951
3229
  // mixed-revision row. Sig/addrs are public replicated data.
@@ -3138,6 +3416,10 @@ export class CadreNode implements SAppIdLookup {
3138
3416
  if (!this._running || !this.controlNode || !this.controlDatabase) {
3139
3417
  return { dialed: [] };
3140
3418
  }
3419
+ await this.publishSelfRecordOnceClaimed();
3420
+ if (!this._running || !this.controlNode || !this.controlDatabase) {
3421
+ return { dialed: [] };
3422
+ }
3141
3423
  // Refresh relay delegate grants BEFORE the sibling enumeration below: a
3142
3424
  // solo cadre with a party relay has no siblings to dial but must still keep
3143
3425
  // its running strands' grants alive (a dropped relay connection re-dials
@@ -3433,8 +3715,7 @@ export class CadreNode implements SAppIdLookup {
3433
3715
  * addresses ({@link dialPeerAddrs}): the reconcile pass's steady-state sibling
3434
3716
  * ({@link dialControlSibling}) and cold-start bootstrap peer
3435
3717
  * ({@link dialBootstrapPeer}) dials, and — handed to every
3436
- * {@link SeedBootstrapService} this node builds — `applySeed`'s owner dials and
3437
- * `dialInvite`.
3718
+ * {@link SeedBootstrapService} this node builds — `applySeed`'s owner dials.
3438
3719
  *
3439
3720
  * See `peer-dial.ts`'s `DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS` for why a peer's dial is
3440
3721
  * bounded as a whole, and `DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS` for why each
@@ -3870,7 +4151,7 @@ export class CadreNode implements SAppIdLookup {
3870
4151
  return;
3871
4152
  }
3872
4153
  this.pendingRevocations.set(revocation.stampId, revocation);
3873
- log('%s delete of %s committed while alone (0 control connections); Revocation tombstone ' +
4154
+ log('%s tombstone for %s committed while alone (0 control connections); Revocation tombstone ' +
3874
4155
  '(stamp %s) queued for re-issue on cohort growth', revocation.tableName, revocation.rowKey, revocation.stampId);
3875
4156
  }
3876
4157
 
@@ -3945,7 +4226,7 @@ export class CadreNode implements SAppIdLookup {
3945
4226
  // 4. In-session pending owner authorize writes.
3946
4227
  await this.drainPendingPeerWrites();
3947
4228
 
3948
- // 5. `PendingJoin` rows this process wrote while alone. Their tombstones went out in step 1.
4229
+ // 5. Pending joins this process wrote while alone. Their tombstones went out in step 1.
3949
4230
  await this.pendingJoinRunner?.reissueWritesMadeAlone();
3950
4231
  }
3951
4232
 
@@ -4031,6 +4312,12 @@ export class CadreNode implements SAppIdLookup {
4031
4312
  this.pendingRevocations.delete(row.stampId);
4032
4313
  }
4033
4314
  } catch (error) {
4315
+ // NOTE: a device whose owner key has been removed keeps running this drain (and the
4316
+ // membership re-touches of reissuePendingPeerWrites / reissueAuthoredMembershipRows)
4317
+ // on every growth edge, and once its own tombstone has reached it every attempt
4318
+ // fails the schema's live-owner clause and lands here. Noise, not harm; a node that
4319
+ // learns it has been revoked shutting itself down belongs to the device-removal plan
4320
+ // ticket (remove-a-device-or-app-from-a-cadre).
4034
4321
  log('drain: revocation re-issue failed; leaving queued for next growth: %o', error);
4035
4322
  }
4036
4323
  }
@@ -4268,6 +4555,7 @@ export class CadreNode implements SAppIdLookup {
4268
4555
  // Strictly increase UpdatedAt even on a same-millisecond re-publish (rotation).
4269
4556
  const updatedAt = Math.max(Date.now(), (existing?.updatedAt ?? 0) + 1);
4270
4557
  const record = signDeviceTokenRecord(
4558
+ this.partyId,
4271
4559
  { peerId, platform, token, updatedAt },
4272
4560
  signingKey.privateKeyB64
4273
4561
  );
@@ -4364,7 +4652,7 @@ export class CadreNode implements SAppIdLookup {
4364
4652
 
4365
4653
  // Self-signature over (peerId, platform, token, updatedAt), verified against the
4366
4654
  // CadrePeer.PublicKey bound to this peerId.
4367
- if (!verifyDeviceTokenSignature(record, peerRecord.publicKey)) {
4655
+ if (!verifyDeviceTokenSignature(this.partyId, record, peerRecord.publicKey)) {
4368
4656
  log('resolveDeviceToken: signature verification failed for %s', peerId);
4369
4657
  return null;
4370
4658
  }
@@ -4625,6 +4913,12 @@ export class CadreNode implements SAppIdLookup {
4625
4913
  this.strandAddrService = null;
4626
4914
  }
4627
4915
 
4916
+ // Unregister the cadre invitation redemption handler, for the same reason.
4917
+ if (this.cadreInviteHandler && this.controlNode) {
4918
+ await this.cadreInviteHandler.unregister(this.controlNode);
4919
+ }
4920
+ this.cadreInviteHandler = null;
4921
+
4628
4922
  // Tear down the push-wake fan-out (releases the notifier's APNs HTTP/2 session).
4629
4923
  if (this.pushFanoutService) {
4630
4924
  await this.pushFanoutService.close().catch((err) => log('Push fan-out close failed: %o', err));
@@ -7197,6 +7491,12 @@ export class CadreNode implements SAppIdLookup {
7197
7491
  if (!this.controlNode || !this.controlDatabase) {
7198
7492
  throw new Error('CadreNode must be started before initializing seed bootstrap');
7199
7493
  }
7494
+ // A hosted node waiting to be claimed never holds an owner key: the claim is its
7495
+ // one trust source, and genesis-anchoring a key here would mark it claimed by
7496
+ // itself. Refused here rather than in start() because this call comes after it.
7497
+ if (this.config.claim) {
7498
+ throw new Error('CadreNode: a node started with `claim` is owned by its claimant and cannot initialize seed bootstrap with an owner key');
7499
+ }
7200
7500
 
7201
7501
  // A node wiring seed-bootstrap with an owner PRIVATE key is declaring that
7202
7502
  // key an authority of its own party (founder genesis / self-signing owner)
@@ -7206,13 +7506,15 @@ export class CadreNode implements SAppIdLookup {
7206
7506
  // NOTE: non-founder members that also wire seed-bootstrap with their own
7207
7507
  // derived key (e.g. the phone joiner path in runOwnerGenesis, which needs
7208
7508
  // it to self-publish its CadrePeer row) self-anchor a key that is not a
7209
- // party authority. Harmless while such a node never mints an invite: the
7210
- // store does not replicate, so a node trusting itself grants nothing to
7211
- // others. But `createInvite` now hands out the anchor's contents as the
7212
- // invitee's pins, so the moment a non-founder member mints an invite it
7213
- // exports its own non-authority key as a cadre owner key. If that becomes
7214
- // reachable (today only cadre-cli/cadre-host owners mint invites), gate this
7215
- // self-anchor on the actual OwnerKey genesis insert instead.
7509
+ // party authority. The anchor sync (`syncOwnerAnchor`) does not correct this:
7510
+ // a `genesis` entry is base, kept whether or not a live `OwnerKey` row vouches
7511
+ // it. Harmless while such a node never mints an invite: the store does not
7512
+ // replicate, so a node trusting itself grants nothing to others. But
7513
+ // `createCadreInvitation` hands out the anchor's contents as the device's pins,
7514
+ // so the moment a non-founder member mints an invitation it exports its own
7515
+ // non-authority key as a cadre owner key. If that becomes reachable (today only
7516
+ // cadre-cli/cadre-host owners mint invitations), gate this self-anchor on the
7517
+ // actual OwnerKey genesis insert instead.
7216
7518
  if (this.trustedOwnerStore) {
7217
7519
  void this.trustedOwnerStore
7218
7520
  .trust(ed25519PublicKeyFromPrivate(ownerPrivateKey), 'genesis')
@@ -7222,9 +7524,8 @@ export class CadreNode implements SAppIdLookup {
7222
7524
  await this.installSeedBootstrapService(new SeedBootstrapService({
7223
7525
  partyId: this.config.controlNetwork.partyId,
7224
7526
  ownerPrivateKey,
7225
- inviteAddressResolver: () => this.resolveInviteAddresses(),
7226
7527
  ...this.seedServiceBudgets(),
7227
- trustPolicy: this.config.seedTrustPolicy,
7528
+ trustPolicy: this.inboundSeedTrustPolicy(),
7228
7529
  // Seed trust anchors on the node-local store (seeded just above with this
7229
7530
  // node's own genesis key), never on the replicated OwnerKey table.
7230
7531
  ...(this.trustedOwnerStore ? { trustedOwners: this.trustedOwnerStore } : {}),
@@ -7342,14 +7643,21 @@ export class CadreNode implements SAppIdLookup {
7342
7643
  * 1. it is not this node itself — a node publishes its own `CadrePeer` address
7343
7644
  * row so its dialable address rides in seeds, but "self" is not a peer this
7344
7645
  * node authorized;
7345
- * 2. its `CadrePeer` row carries a complete voucher (`StampId`, `VouchOwner`,
7346
- * `VouchSig` all non-null);
7646
+ * 2. its `CadrePeer` row carries a complete proof: EITHER an owner voucher
7647
+ * (`StampId`, `VouchOwner`, `VouchSig` all non-null) OR an invitation admission
7648
+ * (`VouchSig` null, `VouchUsage` naming a `CadreInviteUsage` row);
7347
7649
  * 3. `VouchOwner` is in the NODE-LOCAL trusted-owner anchor
7348
- * ({@link getTrustedOwnerStore}) — never the replicated `OwnerKey` table,
7349
- * which any stranger can genesis-pollute; and
7350
- * 4. `VouchSig` verifies as that owner's signature over the row's voucher
7351
- * digest ({@link verifyCadrePeerVoucher}), so the anchored owner really
7352
- * vouched THIS peer id under THIS row's nonce; and
7650
+ * ({@link getTrustedOwnerStore}), which follows the replicated `OwnerKey` table
7651
+ * only along chains that verify from this node's out-of-band keys
7652
+ * ({@link syncOwnerAnchor}) — never the table itself, which any stranger can
7653
+ * genesis-pollute; and
7654
+ * 4. the proof verifies: for a voucher, `VouchSig` is that owner's signature over
7655
+ * the row's voucher digest ({@link verifyCadrePeerVoucher}), so the anchored
7656
+ * owner really vouched THIS peer id under THIS row's nonce; for an admission,
7657
+ * the chain row → usage → invitation → anchored issuer verifies link by link
7658
+ * ({@link verifyInvitationAdmission} — the issuer is `VouchOwner`, the invitation's
7659
+ * own owner signature, the holder's and the device's signatures, and the stored
7660
+ * peer key really is the key behind the peer id); and
7353
7661
  * 5. the row's `StampId` is NOT retired in `CadreControl.Revocation` — enforced
7354
7662
  * upstream in {@link ControlDatabase.queryCadrePeers}, which drops retired rows
7355
7663
  * before ANY reader (this predicate included) sees them, so a row resurrected
@@ -7358,18 +7666,26 @@ export class CadreNode implements SAppIdLookup {
7358
7666
  * local rows) is still inert to every reader that has the tombstone.
7359
7667
  *
7360
7668
  * Fail-closed at every step: a missing anchor (pre-start), an empty anchor (a
7361
- * not-yet-enrolled node authorizes no one), a null/partial voucher, an
7362
- * unanchored `VouchOwner`, or a bad signature all yield "not authorized" —
7363
- * having an address row is NOT membership. The control-network wake and
7364
- * strand-address gates consult this set, NOT the addressable one.
7669
+ * not-yet-enrolled node authorizes no one), a null/partial proof, an
7670
+ * unanchored `VouchOwner`, a bad signature, or a usage or invitation row this node
7671
+ * does not hold all yield "not authorized" — having an address row is NOT
7672
+ * membership. The protocol guard admits wake and strand-address requests by this set,
7673
+ * NOT the addressable one.
7674
+ *
7675
+ * The usage and invitation tables are read only when at least one row needs them,
7676
+ * so a cadre with no invitation-admitted member stays at one read per call.
7365
7677
  *
7366
7678
  * NOTE (rotation): if a party owner rotates keys and only the NEW key is pinned
7367
7679
  * in the anchor, rows the OLD key vouched fail check 3 until re-vouched — a
7368
7680
  * legit member goes un-authorized on readers that only pin the new key. Full
7369
7681
  * rotation handling (re-vouch on rotate) is the
7370
- * `flip-strand-membership-rotation-known-gap` work, not this predicate's.
7682
+ * `flip-strand-membership-rotation-known-gap` work, not this predicate's. The
7683
+ * `OwnerKey` table differs: an owner the rotated-out (or removed) key vouched keeps
7684
+ * its place in every anchor that had derived it ({@link syncOwnerAnchor}: a derived
7685
+ * entry stays while its own row is live), and is only out of reach for a machine that
7686
+ * never held it until a remaining owner re-adds it or an invitation pins it.
7371
7687
  *
7372
- * @param retry - Whether the underlying membership read may retry a transient cluster
7688
+ * @param retry - Whether the underlying membership reads may retry a transient cluster
7373
7689
  * failure. Only {@link refreshAuthorizedControlPeers} passes `false`, because it runs
7374
7690
  * as the control database's membership listener with that database's write lock held;
7375
7691
  * see its comment.
@@ -7378,10 +7694,23 @@ export class CadreNode implements SAppIdLookup {
7378
7694
  if (!this.controlDatabase) {
7379
7695
  throw new Error('CadreNode must be started before listing members');
7380
7696
  }
7697
+ return await this.collectAuthorizedMembers(this.controlDatabase, retry, this.invitationChainSource(this.controlDatabase, retry));
7698
+ }
7699
+
7700
+ /**
7701
+ * The body of {@link listAuthorizedMembers}, with the invitation chain supplied as a
7702
+ * source so the membership refresh can share one load with {@link syncOwnerAnchor}.
7703
+ */
7704
+ private async collectAuthorizedMembers(
7705
+ controlDatabase: ControlDatabase,
7706
+ retry: boolean,
7707
+ loadChain: InvitationChainSource,
7708
+ ): Promise<Array<{ peerId: string; multiaddr: string | null }>> {
7381
7709
  const selfPeerId = this.peerId?.toString();
7382
- const rows = await this.controlDatabase.queryCadrePeers(retry);
7710
+ const rows = await controlDatabase.queryCadrePeers(retry);
7711
+ const chain = rows.some(isInvitationAdmitted) ? await loadChain() : null;
7383
7712
  const authorized = rows
7384
- .filter(row => row.peerId !== selfPeerId && this.hasAnchoredVoucher(row))
7713
+ .filter(row => row.peerId !== selfPeerId && this.hasAnchoredProof(row, chain))
7385
7714
  .map(({ peerId, multiaddr }) => ({ peerId, multiaddr }));
7386
7715
  // A node whose anchor was never seeded (no invite pin, no operator pin, not a
7387
7716
  // founder) refuses every wake and strand-addr request, which from the outside
@@ -7395,9 +7724,160 @@ export class CadreNode implements SAppIdLookup {
7395
7724
 
7396
7725
  /**
7397
7726
  * Checks 2–4 of the authorized-membership predicate (see
7398
- * {@link listAuthorizedMembers}) for one `CadrePeer` row: complete voucher,
7399
- * `VouchOwner` in the node-local anchor, signature valid over the row's
7400
- * (PeerId, StampId) voucher digest.
7727
+ * {@link listAuthorizedMembers}) for one `CadrePeer` row, by the kind of proof it
7728
+ * carries: an owner voucher ({@link hasAnchoredVoucher}) or an invitation admission
7729
+ * ({@link verifyInvitationAdmission} over the usage and invitation rows in `chain`,
7730
+ * loaded only when some row needs them). A row with a `VouchSig` is judged as a
7731
+ * voucher even if it also names a usage — the signature is the stronger proof and the
7732
+ * one an owner re-vouch leaves behind.
7733
+ */
7734
+ private hasAnchoredProof(row: CadrePeerVoucherFields, chain: InvitationChain | null): boolean {
7735
+ if (!isInvitationAdmitted(row)) {
7736
+ return this.hasAnchoredVoucher(row);
7737
+ }
7738
+ const usage = chain?.usages.get(row.vouchUsage!);
7739
+ const invite = usage === undefined ? undefined : chain?.invites.get(usage.inviteKey);
7740
+ if (usage === undefined || invite === undefined) {
7741
+ return false;
7742
+ }
7743
+ return verifyInvitationAdmission(this.partyId, row, usage, invite, key => this.trustedOwnerStore?.has(key) ?? false);
7744
+ }
7745
+
7746
+ /**
7747
+ * Recompute the node-local trusted-owner anchor from the replicated `OwnerKey` table along
7748
+ * verifiable chains (`owner-anchor-sync.ts` → {@link deriveOwnerAnchor}), as the first step
7749
+ * of every membership refresh ({@link refreshAuthorizedControlPeers}) so the snapshot built
7750
+ * in the same pass judges `CadrePeer` rows against the updated anchor. A key whose live
7751
+ * row's stored proof verifies against a key already anchored enters under `chain` and
7752
+ * stays while its row is live; a key an anchored owner's verifiable tombstone names
7753
+ * leaves, base pins included — which is how a config pin of a removed key, re-anchored at
7754
+ * every start ({@link initializeTrustedOwnerStore}), is removed again. The derivation
7755
+ * refuses removals that would empty the anchor; they are logged here.
7756
+ *
7757
+ * With an EMPTY anchor the sync does nothing: there is no key to derive from, and a node
7758
+ * waiting to be claimed ({@link isAwaitingClaim}: claim secret set, anchor empty) must
7759
+ * stay empty until the claim anchors its claimant — the replicated table must never seat
7760
+ * the first key. The early return below is the seam that rests on.
7761
+ *
7762
+ * Any read failure skips the whole pass and leaves the anchor as it was; the refresh then
7763
+ * builds its snapshot against that anchor, which is also what its previous snapshot was
7764
+ * judged against. Caught here rather than failing the refresh, because the membership read
7765
+ * tolerates an unreachable `Revocation` block on an isolated node
7766
+ * (`ControlDatabase.readRevokedStampRows`) and `queryRevocations` does not: failing the
7767
+ * whole refresh on that would freeze such a node's gate snapshot.
7768
+ * NOTE: on such a node the sync is skipped at every refresh until the block is held; if an
7769
+ * isolated node ever has to derive owner additions while cut off, give `queryRevocations`
7770
+ * the same cohort-unreachable fallback.
7771
+ *
7772
+ * NOTE: cost per membership refresh is one `OwnerKey` read and one `Revocation` read (the
7773
+ * tombstone list, whose `OwnerKey` stamps are handed to `queryOwnerKeyRows` so it does not
7774
+ * read the retired set again), plus the invitation chain when some owner row is
7775
+ * invitation-admitted — shared with the membership read through `loadChain`; the memo
7776
+ * suggestion on {@link loadInvitationChain} covers that part. Before the Revocation ledger
7777
+ * marker is filed that one read is a cohort consult per refresh, which
7778
+ * `control-founding-consult-budget.spec.ts` pins.
7779
+ *
7780
+ * @param retry - forwarded to every read; `false` on the refresh path, for the reason on
7781
+ * {@link refreshAuthorizedControlPeers}.
7782
+ */
7783
+ private async syncOwnerAnchor(reason: string, retry: boolean, loadChain: InvitationChainSource): Promise<void> {
7784
+ const store = this.trustedOwnerStore;
7785
+ const controlDatabase = this.controlDatabase;
7786
+ if (!store || !controlDatabase) {
7787
+ return;
7788
+ }
7789
+ const before = store.sources();
7790
+ if (before.size === 0) {
7791
+ return;
7792
+ }
7793
+ let target: ReadonlySet<string>;
7794
+ try {
7795
+ const tombstones = await controlDatabase.queryRevocations(retry);
7796
+ const retired = new Set(tombstones.filter(tombstone => tombstone.tableName === 'OwnerKey').map(tombstone => tombstone.stampId));
7797
+ const rows = await controlDatabase.queryOwnerKeyRows(retry, retired);
7798
+ const chain = rows.some(isInvitationAdmitted) ? await loadChain() : null;
7799
+ const derivation = deriveOwnerAnchor({ partyId: this.partyId, anchor: before, rows, tombstones, chain });
7800
+ if (derivation.refusedRemovals.length > 0) {
7801
+ log('syncOwnerAnchor(%s): refused %d owner removal(s) that would empty the anchor: %o', reason, derivation.refusedRemovals.length, derivation.refusedRemovals);
7802
+ }
7803
+ target = derivation.target;
7804
+ } catch (error) {
7805
+ log('syncOwnerAnchor(%s): read failed — anchor left as it was: %o', reason, error);
7806
+ return;
7807
+ }
7808
+ await this.applyOwnerAnchor(store, before, target, reason);
7809
+ }
7810
+
7811
+ /**
7812
+ * Apply one pass's result: `chain` entries for the keys it newly derived, removal of the keys
7813
+ * it started with that are outside `target` — base keys a verifiable tombstone named, and
7814
+ * `chain` entries whose row is gone.
7815
+ *
7816
+ * Diffed against `before`, the snapshot the derivation was computed from, NOT the live store:
7817
+ * the anchor is written out of band while the pass's reads are in flight (an invitation pin,
7818
+ * the genesis anchor, a claim, a seed), and such a key is outside `target` only because the
7819
+ * pass never saw it — judging the live store would remove it. Likewise a key removed
7820
+ * meanwhile (a claim rollback) must not come back as `chain`. Both are judged by the next
7821
+ * pass. Each change lands in memory synchronously (`TrustedOwnerStore.trust` / `remove`); a
7822
+ * failed PERSIST is logged and does not stop the pass or fail the refresh, as
7823
+ * `SeedBootstrapService.anchorAcceptedSigner` treats the same failure.
7824
+ */
7825
+ private async applyOwnerAnchor(
7826
+ store: TrustedOwnerStore,
7827
+ before: ReadonlyMap<string, TrustSource>,
7828
+ target: ReadonlySet<string>,
7829
+ reason: string,
7830
+ ): Promise<void> {
7831
+ for (const key of target) {
7832
+ if (!before.has(key) && !store.has(key)) {
7833
+ log('syncOwnerAnchor(%s): owner %s derived from the OwnerKey table', reason, key);
7834
+ await store.trust(key, 'chain').catch(error => log('syncOwnerAnchor(%s): anchor persist failed for %s: %o', reason, key, error));
7835
+ }
7836
+ }
7837
+ for (const key of before.keys()) {
7838
+ if (!target.has(key) && store.has(key)) {
7839
+ log('syncOwnerAnchor(%s): owner %s removed from the anchor', reason, key);
7840
+ await store.remove(key).catch(error => log('syncOwnerAnchor(%s): anchor persist failed for %s: %o', reason, key, error));
7841
+ }
7842
+ }
7843
+ }
7844
+
7845
+ /**
7846
+ * A per-refresh {@link InvitationChainSource}: the first call loads the chain through
7847
+ * {@link loadInvitationChain}, every later call shares that load.
7848
+ */
7849
+ private invitationChainSource(controlDatabase: ControlDatabase, retry: boolean): InvitationChainSource {
7850
+ let loading: Promise<InvitationChain> | null = null;
7851
+ return () => (loading ??= this.loadInvitationChain(controlDatabase, retry));
7852
+ }
7853
+
7854
+ /**
7855
+ * The usage and invitation rows {@link hasAnchoredProof} and {@link syncOwnerAnchor}
7856
+ * resolve an invitation-admitted row through, keyed for lookup. Both reads honour `retry`
7857
+ * for {@link listAuthorizedMembers}' reason.
7858
+ *
7859
+ * NOTE: once invitations are the only admission route (ticket
7860
+ * `cadre-invitations-redeemable-by-any-member`), every cadre with a member not yet
7861
+ * re-vouched pays these two whole-table reads and three ed25519 verifies per row on every
7862
+ * membership refresh. Both tables are append-only and a verified chain never changes (expiry,
7863
+ * use count and withdrawal are not re-checked), so if the gate's admission deadline ever
7864
+ * shows this, memoise verified (row stamp → usage stamp) pairs per node and skip the reads
7865
+ * when every admitted row is already memoised.
7866
+ */
7867
+ private async loadInvitationChain(controlDatabase: ControlDatabase, retry: boolean): Promise<InvitationChain> {
7868
+ const [usages, invites] = await Promise.all([
7869
+ controlDatabase.queryCadreInviteUsages(retry),
7870
+ controlDatabase.queryCadreInvites(retry),
7871
+ ]);
7872
+ return {
7873
+ usages: new Map(usages.map(usage => [usage.usageStampId, usage])),
7874
+ invites: new Map(invites.map(invite => [invite.key, invite])),
7875
+ };
7876
+ }
7877
+
7878
+ /**
7879
+ * The owner-voucher half of {@link hasAnchoredProof}: complete voucher, `VouchOwner` in
7880
+ * the node-local anchor, signature valid over the row's (PeerId, StampId) voucher digest.
7401
7881
  *
7402
7882
  * NOTE: verifies the ed25519 signature on every call (no memo of already-
7403
7883
  * verified (peerId, stampId, vouchSig) triples). Cadres are a handful of
@@ -7412,13 +7892,13 @@ export class CadreNode implements SAppIdLookup {
7412
7892
  if (!this.trustedOwnerStore?.has(row.vouchOwner)) {
7413
7893
  return false;
7414
7894
  }
7415
- return verifyCadrePeerVoucher(row.peerId, row.stampId, row.vouchOwner, row.vouchSig);
7895
+ return verifyCadrePeerVoucher(this.partyId, row.peerId, row.stampId, row.vouchOwner, row.vouchSig);
7416
7896
  }
7417
7897
 
7418
7898
  /**
7419
7899
  * Probe whether a given peer is an AUTHORIZED party member (see
7420
- * {@link listAuthorizedMembers}) — the gate the control-network wake and
7421
- * strand-address responders consult, NOT {@link isMember} (the addressable
7900
+ * {@link listAuthorizedMembers}) — the check the protocol guard runs ahead of the
7901
+ * control-network wake and strand-address handlers, NOT {@link isMember} (the addressable
7422
7902
  * surface). Deliberately scans the full membership (one `CadrePeer` query)
7423
7903
  * rather than adding a single-row read path: cadres are small, and one code
7424
7904
  * path keeps the predicate impossible to drift from the list.
@@ -7435,14 +7915,16 @@ export class CadreNode implements SAppIdLookup {
7435
7915
  * record (via {@link resolvePeerAddrs}, signaling/relay first — so a NAT'd peer
7436
7916
  * is reachable through its circuit-relay address), dials `WAKE_PROTOCOL`, sends
7437
7917
  * the {@link WakeRequest}, and returns the peer's {@link WakeAck}. The receiver
7438
- * gates the request on cadre membership and only resumes a strand it already
7439
- * participates in; it acks once it has decided, before the strand is up. The
7440
- * dial deadlines derive from this node's `network.linkRoundTripMs`.
7918
+ * only resumes a strand it already participates in; it acks once it has decided,
7919
+ * before the strand is up. The dial deadlines derive from this node's
7920
+ * `network.linkRoundTripMs`.
7441
7921
  *
7442
7922
  * @param targetPeerId - The hibernating cadre peer to wake.
7443
7923
  * @param strandId - The strand the caller knows has pending activity.
7444
7924
  * @param reason - Optional cause hint, e.g. `"activity"` or `"manual"`.
7445
- * @throws if the node is not started or the target has no dialable address.
7925
+ * @throws if the node is not started, the target has no dialable address, or no
7926
+ * attempt got an ack — including when the receiver does not count this node as an
7927
+ * authorized member, since its protocol guard resets the stream without a reply.
7446
7928
  */
7447
7929
  async pushWake(targetPeerId: string, strandId: string, reason?: string): Promise<WakeAck> {
7448
7930
  if (!this.controlNode) {
@@ -7459,7 +7941,8 @@ export class CadreNode implements SAppIdLookup {
7459
7941
  /**
7460
7942
  * Enable the seed listener for receiving seeds via the /sereus/seed/1.0.0 protocol.
7461
7943
  * This is for drone nodes that need to receive seeds without being an owner.
7462
- * Does not require an owner key.
7944
+ * Does not require an owner key. Idempotent; a node started with `config.claim`
7945
+ * calls it itself inside {@link start}, so an embedder's own call is a no-op.
7463
7946
  */
7464
7947
  async enableSeedListener(): Promise<void> {
7465
7948
  if (!this.controlNode || !this.controlDatabase) {
@@ -7475,9 +7958,8 @@ export class CadreNode implements SAppIdLookup {
7475
7958
  await this.installSeedBootstrapService(new SeedBootstrapService({
7476
7959
  partyId: this.config.controlNetwork.partyId,
7477
7960
  // No owner key - this node only receives seeds
7478
- inviteAddressResolver: () => this.resolveInviteAddresses(),
7479
7961
  ...this.seedServiceBudgets(),
7480
- trustPolicy: this.config.seedTrustPolicy,
7962
+ trustPolicy: this.inboundSeedTrustPolicy(),
7481
7963
  // A listener-only node accepts a wire-delivered seed solely against this
7482
7964
  // anchor (there is no per-call override on the inbound handler): with no
7483
7965
  // genesis/invite/operator pin it authorizes nobody, which is the point.
@@ -7546,6 +8028,18 @@ export class CadreNode implements SAppIdLookup {
7546
8028
  };
7547
8029
  }
7548
8030
 
8031
+ /**
8032
+ * The node-wide default trust policy for INBOUND seeds, the one value every
8033
+ * {@link SeedBootstrapService} this node constructs is given — the owner service,
8034
+ * the listener, and `applySeed`'s temp service — so the three cannot drift. The
8035
+ * claim policy when the node was started with `config.claim` (the two are refused
8036
+ * together at start, so there is nothing to merge), otherwise the configured
8037
+ * `seedTrustPolicy`, and `undefined` for the service's anchored default.
8038
+ */
8039
+ private inboundSeedTrustPolicy(): SeedTrustPolicy | undefined {
8040
+ return this.claimTrustPolicy ?? this.config.seedTrustPolicy;
8041
+ }
8042
+
7549
8043
  /**
7550
8044
  * Re-materialize the authorized-peer snapshot that the fail-closed per-stream
7551
8045
  * control-DB gate ({@link authorizeInboundControlStream}) judges against.
@@ -7703,6 +8197,48 @@ export class CadreNode implements SAppIdLookup {
7703
8197
  });
7704
8198
  }
7705
8199
 
8200
+ /**
8201
+ * Seat another owner key, signed by this node's owner key (the signed-add branch of
8202
+ * `OwnerKey.Authorized`; the row stores the vouching pair). The write notifies the
8203
+ * membership hub, so the refresh it triggers has run by the time this resolves.
8204
+ *
8205
+ * @param ownerPublicKeyB64 - the new owner's ed25519 public key (base64url).
8206
+ * @returns `true` when the row was seated, `false` when the key is already a live owner.
8207
+ */
8208
+ async addOwner(ownerPublicKeyB64: string): Promise<boolean> {
8209
+ if (!this.seedBootstrapService) {
8210
+ throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
8211
+ }
8212
+ const key = requireEd25519PublicKeyB64(ownerPublicKeyB64, 'owner key');
8213
+ const added = await this.seedBootstrapService.addOwner(key);
8214
+ // NOTE: an OwnerKey add committed alone is local-only and nothing re-issues it (no
8215
+ // update branch to re-touch, unlike CadrePeer), so it is only logged;
8216
+ // tickets/backlog/debt-owner-key-add-while-alone-has-no-reissue.md owns the fix.
8217
+ if (added && this.committedAlone()) {
8218
+ log('addOwner(%s) committed while ALONE (0 control connections): the new owner row is ' +
8219
+ 'local-only and is not re-issued on cohort growth, so other machines may never see it.', key);
8220
+ }
8221
+ return added;
8222
+ }
8223
+
8224
+ /**
8225
+ * Remove an owner key, signed by this node's owner key (which must be a different owner):
8226
+ * the delete and its `Revocation` tombstone commit together. Refused by name when the key
8227
+ * is this node's own or the only live owner. The write notifies the membership hub; the
8228
+ * tombstone rides the committed-delete seam ({@link noteGuardedDelete}), so one committed
8229
+ * while alone is re-issued on cohort growth like every other removal.
8230
+ *
8231
+ * @param ownerPublicKeyB64 - the owner's ed25519 public key (base64url).
8232
+ * @returns `true` when the row was removed, `false` when the key is not a live owner here.
8233
+ */
8234
+ async removeOwner(ownerPublicKeyB64: string): Promise<boolean> {
8235
+ if (!this.seedBootstrapService) {
8236
+ throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
8237
+ }
8238
+ const key = requireEd25519PublicKeyB64(ownerPublicKeyB64, 'owner key');
8239
+ return await this.seedBootstrapService.removeOwner(key);
8240
+ }
8241
+
7706
8242
  /**
7707
8243
  * Create a seed from the current control network state.
7708
8244
  * The seed contains peer information and is signed by an owner.
@@ -7718,10 +8254,12 @@ export class CadreNode implements SAppIdLookup {
7718
8254
  * Apply a seed to populate the peer cache and enable connections.
7719
8255
  *
7720
8256
  * Validates the seed signature, then evaluates a trust anchor for the signer
7721
- * key (see `SeedTrustPolicy`). An enrollment caller can pass a per-seed
7722
- * `trustPolicy` override — e.g. a `pinnedKeyTrustPolicy` built from a
7723
- * `CadreInvite.ownerKeys` — so a cold-start node can accept its first
7724
- * seed without reconfiguring the service.
8257
+ * key (see `SeedTrustPolicy`). An operator-driven caller can pass a per-seed
8258
+ * `trustPolicy` override — e.g. a `pinnedKeyTrustPolicy` built from a pinned
8259
+ * owner key — so a cold-start node can accept its first seed without
8260
+ * reconfiguring the service. The override wins on a node started with
8261
+ * `config.claim` as well: the claim secret is the node's default inbound policy,
8262
+ * not a lock on its local operator, who already controls the process.
7725
8263
  */
7726
8264
  async applySeed(
7727
8265
  seed: ControlNetworkSeed,
@@ -7735,13 +8273,13 @@ export class CadreNode implements SAppIdLookup {
7735
8273
  //
7736
8274
  // This temp service is discarded after the call, so it must NOT own the shared
7737
8275
  // node's inbound seed handler: pass { registerHandler: false }. That keeps
7738
- // repeated service-less applySeed/dialInvite idempotent (no handler leak, no
8276
+ // repeated service-less applySeed idempotent (no handler leak, no
7739
8277
  // DuplicateProtocolHandlerError). The temp service still applies this seed; a
7740
8278
  // node that wants to RECEIVE inbound seeds needs a persistent service
7741
8279
  // (enableSeedListener / initializeSeedBootstrap) to own the handler.
7742
8280
  const tempService = new SeedBootstrapService({
7743
8281
  partyId: seed.partyId,
7744
- trustPolicy: this.config.seedTrustPolicy,
8282
+ trustPolicy: this.inboundSeedTrustPolicy(),
7745
8283
  ...this.seedServiceBudgets(),
7746
8284
  // The anchor is node-scoped, not service-scoped: a throwaway service
7747
8285
  // must consult (and persist an accepted signer into) the SAME store the
@@ -7784,12 +8322,23 @@ export class CadreNode implements SAppIdLookup {
7784
8322
 
7785
8323
  /**
7786
8324
  * Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
8325
+ *
8326
+ * `target` is a multiaddr string, or a peer id with its addresses (the form a phone
8327
+ * uses for a node it scanned), which are dialed one at a time before the request
8328
+ * (`SeedBootstrapService.deliverSeed`). `options.claimProof` rides beside the seed when
8329
+ * the target is a brand-new node being claimed (`claim-proof.ts`).
8330
+ *
8331
+ * @throws {PeerUnreachableError} for a peer id with addresses, when no connection to it formed.
7787
8332
  */
7788
- async deliverSeed(targetMultiaddr: string, seed: ControlNetworkSeed): Promise<{ accepted: boolean; reason?: string }> {
8333
+ async deliverSeed(
8334
+ target: SeedDeliveryTarget,
8335
+ seed: ControlNetworkSeed,
8336
+ options?: { claimProof?: string },
8337
+ ): Promise<SeedAckMessage> {
7789
8338
  if (!this.seedBootstrapService) {
7790
8339
  throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
7791
8340
  }
7792
- return await this.seedBootstrapService.deliverSeed(targetMultiaddr, seed);
8341
+ return await this.seedBootstrapService.deliverSeed(target, seed, options);
7793
8342
  }
7794
8343
 
7795
8344
  /**
@@ -7862,32 +8411,63 @@ export class CadreNode implements SAppIdLookup {
7862
8411
  }
7863
8412
 
7864
8413
  /**
7865
- * Create an invite for a phone to join the cadre.
7866
- * Use when a server wants to invite a NAT'd phone.
8414
+ * Claim a node that was started with a claim secret (`CadreNodeConfig.claim`), from
8415
+ * the peer id, addresses and secret its host showed (the QR code): mint a seed, prove
8416
+ * possession of the secret beside it (`claim-proof.ts`), deliver it, and on acceptance
8417
+ * vouch the node into this cadre and keep its addresses as a dial target. The owner
8418
+ * side of the claim-secret entry in the Seed Delivery Protocol (`docs/architecture.md`).
8419
+ *
8420
+ * Requires the seed service with an owner key ({@link initializeSeedBootstrap}), as
8421
+ * {@link addDrone} does. The order is the REVERSE of `addDrone`'s: seed first, row
8422
+ * second. A refused claim or a failed delivery therefore leaves no `CadrePeer` row and
8423
+ * no retained dial target behind on this node — a phone that typed the wrong secret,
8424
+ * or scanned a node someone else already owns, has nothing to clean up.
8425
+ *
8426
+ * A failure is one of three kinds a caller can tell apart:
8427
+ *
8428
+ * - `ClaimRefusedError` — the node was reached and refused, with its refusal code when
8429
+ * its policy gave one.
8430
+ * - `PeerUnreachableError` — no connection to the node formed from any of its addresses,
8431
+ * so nothing was sent. Each address is dialed in turn on its own limit, so this can
8432
+ * take up to the per-peer dial limit plus the seed request's (114.5 s at the default
8433
+ * declared link) when none answers.
8434
+ * - anything else — the node was reached and the exchange failed, or a local step failed
8435
+ * (a malformed secret or address among them). Repeating the claim with the same secret
8436
+ * is safe (see below).
8437
+ *
8438
+ * Idempotent for the same owner: a second call after a dropped response is accepted
8439
+ * by the node (its anchored branch), the row insert is a no-op on an existing row
8440
+ * (`ControlDatabase.insertCadrePeer`), and the dial target is replaced, not duplicated.
8441
+ *
8442
+ * The first dial is kicked (not awaited) so it happens now rather than on the next
8443
+ * timed reconcile pass; a caller that wants to wait for the connection awaits
8444
+ * {@link reconcileControlCohort} itself, with `addDrone`'s caveat that a pass already
8445
+ * in flight is joined rather than restarted. The claimed node dials this one back too,
8446
+ * from the seed's owner list, when this node has a listen or relay address; a phone
8447
+ * with neither is reached by its own reconcile pass alone.
8448
+ *
8449
+ * @throws {ClaimRefusedError} when the node was reached and refused the claim.
8450
+ * @throws {PeerUnreachableError} when the node was never reached.
7867
8451
  */
7868
- async createInvite(token?: string, expiresIn?: number): Promise<InviteResult> {
8452
+ async claimNode(target: { peerId: string; multiaddrs: string[]; secret: string }): Promise<void> {
7869
8453
  if (!this.seedBootstrapService) {
7870
8454
  throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
7871
8455
  }
7872
- const result = await this.seedBootstrapService.createInvite(token, expiresIn);
7873
- // The invitee dials this node BEFORE it is authorized (dialInvite →
7874
- // acceptPhone), so hold the inbound connection gate open for strangers for
7875
- // the invite's validity (or a bounded default when it never expires).
7876
- this.openEnrollmentWindow(result.invite.expiresAt ?? Date.now() + DEFAULT_ENROLLMENT_WINDOW_MS);
7877
- return result;
7878
- }
7879
-
7880
- /**
7881
- * Accept a phone connection using an invite.
7882
- * Call this when a phone dials in with an invite token.
7883
- */
7884
- async acceptPhone(options: AddPhoneOptions, issuedInvite?: CadreInvite): Promise<void> {
7885
- if (!this.seedBootstrapService) {
7886
- throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
8456
+ const secret = parseClaimSecret(target.secret);
8457
+ const seed = await this.seedBootstrapService.createSeed();
8458
+ const proof = claimProof(secret, target.peerId, seed.signerKey, seedDigest(seed));
8459
+ const ack = await this.seedBootstrapService.deliverSeed(
8460
+ { peerId: target.peerId, multiaddrs: target.multiaddrs }, seed, { claimProof: proof });
8461
+ if (!ack.accepted) {
8462
+ throw new ClaimRefusedError(target.peerId, ack.reason ?? 'no reason given', ack.code);
7887
8463
  }
7888
- // The just-accepted phone opens control-DB streams next; its `CadrePeer` insert
7889
- // notifies the membership hub, so it is already admitted when this resolves.
7890
- await this.seedBootstrapService.acceptPhone(options, issuedInvite);
8464
+ log('claimNode: %s accepted the claim; vouching it into the cadre', target.peerId);
8465
+ // The wrapper queues the write for re-replication: a phone claiming its first
8466
+ // always-on node has no control connection yet, so the insert commits local-only.
8467
+ await this.authorizePeer(target.peerId, target.multiaddrs);
8468
+ this.retainDialTarget(target.peerId, target.multiaddrs, 'claimNode');
8469
+ void this.reconcileControlCohort().catch((error) =>
8470
+ log('claimNode: reconcile kick after claiming %s failed: %o', target.peerId, error));
7891
8471
  }
7892
8472
 
7893
8473
  /**
@@ -7905,47 +8485,187 @@ export class CadreNode implements SAppIdLookup {
7905
8485
  return await this.seedBootstrapService.addPhoneWithRelay(phonePeerId);
7906
8486
  }
7907
8487
 
8488
+ // ============================================================================
8489
+ // Cadre Invitation API (redeemable at any member, `/sereus/cadre-invite/1.0.0`)
8490
+ // ============================================================================
8491
+
7908
8492
  /**
7909
- * Encode an invite for out-of-band delivery (QR, link, etc.).
7910
- */
7911
- encodeInvite(invite: CadreInvite): string {
7912
- const json = JSON.stringify(invite);
7913
- return uint8ArrayToString(new TextEncoder().encode(json), 'base64url');
8493
+ * Mint an owner-signed cadre invitation that a device can redeem at ANY member machine of
8494
+ * this party, the owner offline included ({@link redeemCadreInvitation}). Requires the
8495
+ * owner key ({@link initializeSeedBootstrap}).
8496
+ *
8497
+ * The invitation is an ed25519 keypair: the public half is the `CadreInvite` row's key,
8498
+ * the private half rides in the returned bundle as the proof of possession. Whoever holds
8499
+ * the bundle can redeem it, so an UNTARGETED, OWNER-GRANTING invitation — a bearer
8500
+ * credential for admin rights — defaults to a 15-minute lifetime; every other kind to 24
8501
+ * hours. One use unless `uses` says otherwise. The bundle carries the signed row (so a
8502
+ * member that has not received it by replication seats it from the bundle), this node's
8503
+ * anchored owner keys — every current owner this node has verified, the out-of-band ones
8504
+ * and those derived along verifiable chains ({@link syncOwnerAnchor}) — (the device pins
8505
+ * them and checks the member's reply against them; sourced from the node-local anchor
8506
+ * only, never the raw replicated `OwnerKey` table, because the device anchors whatever
8507
+ * arrives and a stranger's genesis-inserted key must not ride an invitation into a fresh
8508
+ * node's anchor; an empty anchor is refused because a reply could not be checked), and
8509
+ * the addresses of this machine first
8510
+ * ({@link resolveInviteAddresses}, which honours pushed NAT addresses) then up to three
8511
+ * other members ({@link siblingInvitationAddrs}).
8512
+ *
8513
+ * The row commits locally and replicates like any other control write; minted while
8514
+ * alone (a phone with no connection), it reaches the other members through the
8515
+ * peer-join block catch-up on the next connection. The device can redeem at any member it
8516
+ * reaches meanwhile: a member that does not hold the row yet admits the device's connection
8517
+ * provisionally ({@link admitInboundControlConnection}) and seats the row from the bundle.
8518
+ *
8519
+ * @throws when no owner key is wired, the anchor is empty, `uses` is not a positive
8520
+ * integer, or neither this machine nor any other member has an address.
8521
+ */
8522
+ async createCadreInvitation(options: CreateCadreInvitationOptions): Promise<CreateCadreInvitationResult> {
8523
+ const service = this.seedBootstrapService;
8524
+ if (!service?.canAuthorize()) {
8525
+ throw new Error('Seed bootstrap service not initialized with an owner key. Call initializeSeedBootstrap() first.');
8526
+ }
8527
+ const ownerKeys = Array.from(this.trustedOwnerStore?.all() ?? []);
8528
+ if (ownerKeys.length === 0) {
8529
+ throw new Error('createCadreInvitation: this node anchors no owner key, and an invitation without owner keys cannot be verified by the device that redeems it');
8530
+ }
8531
+ const uses = options.uses ?? 1;
8532
+ if (!Number.isInteger(uses) || uses < 1) {
8533
+ throw new Error(`createCadreInvitation: uses must be a positive integer (received ${String(options.uses)})`);
8534
+ }
8535
+ const members = [...await this.resolveInviteAddresses(), ...await this.siblingInvitationAddrs()];
8536
+ if (members.length === 0) {
8537
+ throw new Error('createCadreInvitation: neither this machine nor any other machine of the party has an address a device could dial');
8538
+ }
8539
+ const expiresInMs = options.expiresInMs
8540
+ ?? (options.grantsOwner && !options.peerId ? OWNER_INVITATION_DEFAULT_TTL_MS : CADRE_INVITATION_DEFAULT_TTL_MS);
8541
+ const invitePrivateKey = generatePrivateKey('ed25519', 'base64url') as string;
8542
+ const row = await service.insertCadreInvite({
8543
+ key: ed25519PublicKeyFromPrivate(invitePrivateKey),
8544
+ peerId: options.peerId ?? null,
8545
+ grantsOwner: options.grantsOwner,
8546
+ expiresAtMs: Date.now() + expiresInMs,
8547
+ totalUses: uses,
8548
+ });
8549
+ const invitation: CadreInvitation = {
8550
+ v: 1,
8551
+ partyId: this.config.controlNetwork.partyId,
8552
+ invitePrivateKey,
8553
+ invite: row,
8554
+ ownerKeys,
8555
+ members,
8556
+ };
8557
+ log('Cadre invitation minted: %s (peer=%s owner=%s uses=%d, %d member address(es))',
8558
+ row.key, row.peerId ?? 'any', row.grantsOwner, uses, members.length);
8559
+ return { invitation, encoded: encodeCadreInvitation(invitation) };
7914
8560
  }
7915
8561
 
7916
8562
  /**
7917
- * Decode an invite from base64url encoding.
8563
+ * Every cadre invitation this node holds — minted here or replicated in — with its
8564
+ * standing ({@link CadreInviteStatus}: live, withdrawn, redemptions recorded).
7918
8565
  */
7919
- decodeInvite(encoded: string): CadreInvite {
7920
- const bytes = uint8ArrayFromString(encoded, 'base64url');
7921
- const json = new TextDecoder().decode(bytes);
7922
- return JSON.parse(json) as CadreInvite;
8566
+ async listCadreInvitations(): Promise<CadreInviteStatus[]> {
8567
+ if (!this.controlDatabase) {
8568
+ throw new Error('CadreNode must be started before listing cadre invitations');
8569
+ }
8570
+ return await this.controlDatabase.listCadreInviteStatuses();
7923
8571
  }
7924
8572
 
7925
8573
  /**
7926
- * Dial an owner from an invite (for phone joining via invite).
8574
+ * Withdraw a cadre invitation: an owner-signed `Revocation` tombstone over its row, which
8575
+ * stays as the proof of membership for every device it admitted. No further redemption
8576
+ * succeeds anywhere the tombstone reaches, and a member holding it refuses the bundle's
8577
+ * copy of the row. A withdrawal committed while alone is re-issued on cohort growth like
8578
+ * any other tombstone.
8579
+ *
8580
+ * @returns `true` when this call filed the tombstone, `false` when the row is not held
8581
+ * here or was already withdrawn.
7927
8582
  */
7928
- async dialInvite(invite: CadreInvite): Promise<void> {
8583
+ async withdrawCadreInvitation(key: string): Promise<boolean> {
7929
8584
  if (!this.seedBootstrapService) {
7930
- // Create a temporary service that only dials the invite. It is discarded after
7931
- // the call, so it does NOT own the shared node's inbound seed handler:
7932
- // initialize with { registerHandler: false }. This temp dialInvite never
7933
- // applies a seed itself — a node that wants to RECEIVE an inbound seed back
7934
- // must have a persistent service (enableSeedListener / initializeSeedBootstrap)
7935
- // own the /sereus/seed/1.0.0 handler. trustPolicy is therefore effectively dead
7936
- // on this dial-only path; it is kept for symmetry with the applySeed temp site.
7937
- const tempService = new SeedBootstrapService({
7938
- partyId: invite.partyId,
7939
- trustPolicy: this.config.seedTrustPolicy,
7940
- ...this.seedServiceBudgets(),
7941
- });
7942
- if (this.controlNode && this.controlDatabase) {
7943
- await tempService.initialize(this.controlNode, this.controlDatabase, { registerHandler: false });
7944
- }
7945
- await tempService.dialInvite(invite);
7946
- return;
8585
+ throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
7947
8586
  }
7948
- await this.seedBootstrapService.dialInvite(invite);
8587
+ return await this.seedBootstrapService.withdrawCadreInvite(key);
8588
+ }
8589
+
8590
+ /**
8591
+ * Join the cadre an invitation names by redeeming it at one of the member machines it
8592
+ * lists, the owner that minted it offline or not. Needs a started node with a stable
8593
+ * identity (`keyStore` or `privateKey`): the redemption is signed with the key behind
8594
+ * this node's peer id, and the member checks that the two match.
8595
+ *
8596
+ * Steps: refuse an invitation for another party; pin its `ownerKeys` into the node-local
8597
+ * anchor (`'invite'` provenance — the pin sticks even if the redemption then fails, as it
8598
+ * does for a seed invite, so a later attempt or seed from the same owner is anchored);
8599
+ * sign the request; dial the listed members in order ({@link redeemAtMembers}); on
8600
+ * acceptance verify the reply against the pinned keys, merge its dial hints into the peer
8601
+ * store, retain the answering member as a cold-start dial target, refresh the membership
8602
+ * gate and start a cohort reconcile pass. This node then syncs the control database over
8603
+ * the connection it already holds, the same unified behaviour as after a seed.
8604
+ *
8605
+ * A device that is already a member (a restart with the invitation still configured) is
8606
+ * answered as accepted by the member's idempotent redemption, with no second usage row.
8607
+ *
8608
+ * When the invitation grants ownership, the member seats this node's key as an `OwnerKey`
8609
+ * row by consent. The caller still has to wire that key for signing afterwards —
8610
+ * `initializeSeedBootstrap(ownKey)`, which anchors it; never `ensureOwnerKey`, the row is
8611
+ * already there. Other machines derive this node's key into their anchors from the row's
8612
+ * stored proof on their next membership refresh ({@link syncOwnerAnchor}).
8613
+ *
8614
+ * Limit: the reply check ({@link verifyRedeemReply}) does not authenticate the member. A
8615
+ * forged bundle, or a legitimate one whose addresses were swapped, dials a machine that
8616
+ * can echo the owner-signed row it was just sent; that is what pasting an attacker's
8617
+ * invitation means. What protects this node afterwards is its anchor: every row it then
8618
+ * syncs is judged against the pinned owner keys, so a machine that is not a member of the
8619
+ * cadre the invitation names can admit nobody this node will trust.
8620
+ *
8621
+ * @throws `CadreInviteRejectedError` on a final refusal, `CadreInviteReplyInvalidError` on
8622
+ * an acceptance that fails the reply check, `CadreInviteUnreachableError` when every
8623
+ * listed address was tried without an acceptance, and a plain `Error` for the
8624
+ * preconditions above.
8625
+ */
8626
+ async redeemCadreInvitation(invitation: CadreInvitation): Promise<RedeemCadreInvitationResult> {
8627
+ const controlNode = this.controlNode;
8628
+ if (!controlNode || !this.controlDatabase || !this._running) {
8629
+ throw new Error('CadreNode must be started before redeeming a cadre invitation');
8630
+ }
8631
+ if (invitation.partyId !== this.config.controlNetwork.partyId) {
8632
+ throw new Error(`Cadre invitation is for party ${invitation.partyId}; this node serves ${this.config.controlNetwork.partyId}`);
8633
+ }
8634
+ const signer = this.getSelfSigningKey();
8635
+ if (!signer) {
8636
+ throw new Error('redeemCadreInvitation: this node runs on an ephemeral identity; configure `keyStore` or `privateKey` so its key can sign the redemption');
8637
+ }
8638
+ await this.trustOwnerKeys(invitation.ownerKeys, 'invite');
8639
+ const pinned = new Set(invitation.ownerKeys);
8640
+ const request = signRedeemRequest(
8641
+ invitation,
8642
+ { peerKey: signer.publicKeyB64, peerPrivateKey: signer.privateKeyB64 },
8643
+ generateStampId(controlNode.peerId.toString()),
8644
+ await this.resolveInviteAddresses()
8645
+ );
8646
+ const { reply, memberPeerId, memberAddr } = await redeemAtMembers(controlNode, {
8647
+ invitation,
8648
+ request,
8649
+ isTrustedIssuer: (ownerKey) => pinned.has(ownerKey),
8650
+ linkRoundTripMs: this.config.network?.linkRoundTripMs,
8651
+ });
8652
+ log('Cadre invitation %s redeemed at %s (owner=%s)', invitation.invite.key, memberAddr, reply.invite.grantsOwner);
8653
+
8654
+ const selfPeerId = controlNode.peerId.toString();
8655
+ await mergeSeedPeers(controlNode, reply.peers.filter((peer) => peer.peerId !== selfPeerId));
8656
+ if (memberPeerId !== null) {
8657
+ // Every address the bundle gave the answering member, so a restart has the same choices.
8658
+ const memberAddrs = this.parseMultiaddrs(invitation.members)
8659
+ .filter((addr) => trailingPeerId(addr) === memberPeerId)
8660
+ .map((addr) => addr.toString());
8661
+ this.retainDialTarget(memberPeerId, memberAddrs, 'redeemCadreInvitation');
8662
+ }
8663
+ // The pins above flip nothing yet (this node holds no rows), but the rows about to
8664
+ // replicate in are judged against them; refresh so the snapshot is not stale when they land.
8665
+ await this.refreshMembershipGate('invitation-redeemed');
8666
+ void this.reconcileControlCohort().catch((error) =>
8667
+ log('redeemCadreInvitation: the post-redemption reconcile pass failed: %o', error));
8668
+ return { peerId: memberPeerId, grantsOwner: reply.invite.grantsOwner, redeemedAt: new Date().toISOString() };
7949
8669
  }
7950
8670
 
7951
8671
  // ============================================================================
@@ -8091,10 +8811,12 @@ export class CadreNode implements SAppIdLookup {
8091
8811
  }
8092
8812
 
8093
8813
  /**
8094
- * Addresses of the party's other machines for an invitation's bootstrap list
8095
- * ({@link selectInvitationSiblingAddrs} picks which). The source is each authorized
8096
- * member's signed `CadrePeer` record ({@link resolvePeerRecord}), so a machine whose record
8097
- * is missing, stale or untrusted is left out; live connections only order them.
8814
+ * Addresses of the party's other machines for an invitation's bootstrap list — an open
8815
+ * strand invitation's ({@link createOpenInvitation}) or a cadre invitation's
8816
+ * ({@link createCadreInvitation}); {@link selectInvitationSiblingAddrs} picks which. The
8817
+ * source is each authorized member's signed `CadrePeer` record ({@link resolvePeerRecord}),
8818
+ * so a machine whose record is missing, stale or untrusted is left out; live connections
8819
+ * only order them.
8098
8820
  *
8099
8821
  * Best-effort: a failed read leaves the invitation naming this machine alone, which is what
8100
8822
  * it named before siblings were added.
@@ -8115,7 +8837,7 @@ export class CadreNode implements SAppIdLookup {
8115
8837
  const connected = new Set(controlNode.getConnections().map((c) => c.remotePeer.toString()));
8116
8838
  return selectInvitationSiblingAddrs(siblings, connected);
8117
8839
  } catch (error) {
8118
- log('createOpenInvitation: resolving the other machines failed; the invitation names only this one: %o', error);
8840
+ log('siblingInvitationAddrs: resolving the other machines failed; the invitation names only this one: %o', error);
8119
8841
  return [];
8120
8842
  }
8121
8843
  }
@@ -8163,7 +8885,7 @@ export class CadreNode implements SAppIdLookup {
8163
8885
  * Ask to join through `invitation` and have the party keep trying until the join works, the
8164
8886
  * invitation is used up or expires, or the request is dismissed: across restarts, and from
8165
8887
  * any owner machine of the party (`docs/strands.md` → "Joining while the inviter is
8166
- * offline"). Records a party-wide `PendingJoin` row, runs one attempt on this machine at
8888
+ * offline"). Records a party-wide `JoinRequest` row, runs one attempt on this machine at
8167
8889
  * once, and returns the status after it: `'joined'` when the inviter answered yes, `'waiting'`
8168
8890
  * when it was unreachable or not ready, `'failed'` when it refused for good.
8169
8891
  *
@@ -8238,17 +8960,17 @@ export class CadreNode implements SAppIdLookup {
8238
8960
  }
8239
8961
 
8240
8962
  /**
8241
- * Write a join request's row, or adopt the party's existing row for the same invitation: a
8242
- * pending one as it is, a finished one replaced by `requested` (the user asked again).
8963
+ * Write a join request, or adopt the party's existing request for the same invitation: a
8964
+ * pending one as it is, a finished one rewritten as `requested` (the user asked again).
8243
8965
  */
8244
8966
  private async recordJoinRequest(
8245
8967
  database: ControlDatabase,
8246
- requested: PendingJoinFields,
8968
+ requested: JoinRequestFields,
8247
8969
  { ownerKey, signMessage }: { ownerKey: string; signMessage: (message: Uint8Array) => string }
8248
- ): Promise<PendingJoinRow> {
8249
- let written: PendingJoinRow;
8970
+ ): Promise<PendingJoin> {
8971
+ let written: PendingJoin;
8250
8972
  try {
8251
- written = await database.insertPendingJoin(requested, ownerKey, signMessage);
8973
+ written = await database.insertJoinRequest(requested, ownerKey, signMessage);
8252
8974
  } catch (error) {
8253
8975
  if (!isPendingJoinConflict(error)) {
8254
8976
  throw error;
@@ -8261,12 +8983,12 @@ export class CadreNode implements SAppIdLookup {
8261
8983
  { cause: error }
8262
8984
  );
8263
8985
  }
8264
- if (existing.Outcome === null) {
8986
+ if (existing.outcome === null) {
8265
8987
  return existing;
8266
8988
  }
8267
- written = await database.replacePendingJoin(existing.StampId, requested, ownerKey, signMessage);
8989
+ written = await database.rewritePendingJoin(existing.StampId, { ...requested, outcome: null }, ownerKey, signMessage);
8268
8990
  }
8269
- this.pendingJoinRunner?.noteWritten(written);
8991
+ this.pendingJoinRunner?.noteWritten(written, 'request');
8270
8992
  return written;
8271
8993
  }
8272
8994
 
@@ -8284,15 +9006,19 @@ export class CadreNode implements SAppIdLookup {
8284
9006
  selfId: this.controlNode!.peerId.toString(),
8285
9007
  linkRoundTripMs: this.config.network?.linkRoundTripMs,
8286
9008
  isOwner: async () => (await this.enrolledOwnerSigningKey()) !== null,
8287
- // NOTE: one owner-key read and one `PendingJoin` read per pass (30 s); for a party that never
8288
- // asked for a join the second reads a never-written block, which consults the cohort. If it
8289
- // shows up in a device profile, poll more slowly while the table reads empty.
9009
+ // NOTE: one owner-key read and one pending-join read (three tables) per pass (30 s); for a
9010
+ // party that never asked for a join the second reads never-written blocks, which consults
9011
+ // the cohort. If it shows up in a device profile, poll more slowly while the tables read empty.
8290
9012
  readRows: () => database().queryPendingJoins(),
8291
9013
  readRow: (id) => database().queryPendingJoin(id),
8292
- attempt: (row) => this.formStrand(this.pendingJoinInvitation(row), parseStoredDisclosure(row)),
8293
- replace: async (expectedStampId, next) => {
9014
+ attempt: (join) => this.formStrand(this.pendingJoinInvitation(join), parseStoredDisclosure(join)),
9015
+ record: async (expected, outcome) => {
9016
+ const { ownerKey, signMessage } = await outcomeSigner();
9017
+ return database().recordJoinOutcome(expected, outcome, ownerKey, signMessage);
9018
+ },
9019
+ rewrite: async (expectedStampId, next) => {
8294
9020
  const { ownerKey, signMessage } = await outcomeSigner();
8295
- return database().replacePendingJoin(expectedStampId, next, ownerKey, signMessage);
9021
+ return database().rewritePendingJoin(expectedStampId, next, ownerKey, signMessage);
8296
9022
  },
8297
9023
  remove: async (id) => {
8298
9024
  const { ownerKey, signMessage } = await outcomeSigner();
@@ -8306,33 +9032,33 @@ export class CadreNode implements SAppIdLookup {
8306
9032
  this.pendingJoinRunner.start();
8307
9033
  }
8308
9034
 
8309
- /** The invitation a `PendingJoin` row stores. A failure names the row, never the invitation, which is a bearer credential. */
8310
- private pendingJoinInvitation(row: PendingJoinRow): OpenInvitation {
9035
+ /** The invitation a `JoinRequest` row stores. A failure names the row, never the invitation, which is a bearer credential. */
9036
+ private pendingJoinInvitation(join: PendingJoin): OpenInvitation {
8311
9037
  try {
8312
- return this.decodeInvitation(row.Invitation);
9038
+ return this.decodeInvitation(join.Invitation);
8313
9039
  } catch {
8314
- throw new Error(`PendingJoin ${row.Id}: the stored invitation does not decode`);
9040
+ throw new Error(`JoinRequest ${join.Id}: the stored invitation does not decode`);
8315
9041
  }
8316
9042
  }
8317
9043
 
8318
- private pendingJoinSAppId(row: PendingJoinRow): string {
9044
+ private pendingJoinSAppId(join: PendingJoin): string {
8319
9045
  try {
8320
- return this.decodeInvitation(row.Invitation).sAppId;
9046
+ return this.decodeInvitation(join.Invitation).sAppId;
8321
9047
  } catch {
8322
- log('PendingJoin %s: the stored invitation does not decode; reporting no sApp', row.Id);
9048
+ log('JoinRequest %s: the stored invitation does not decode; reporting no sApp', join.Id);
8323
9049
  return '';
8324
9050
  }
8325
9051
  }
8326
9052
 
8327
9053
  /**
8328
- * Stage the membership invitation of each `joined` row this process has not staged yet, so a
9054
+ * Stage the membership invitation of each joined request this process has not staged yet, so a
8329
9055
  * closed strand joined on another owner machine is seated by whichever machine launches it
8330
9056
  * first; the party key it admits is the replicated `StrandPartyKey` row the finishing machine
8331
9057
  * seated. A strand that already has an invitation staged keeps it, for the reconciler to
8332
9058
  * settle first.
8333
9059
  */
8334
- private stageMembershipInvitesFromPendingJoins(rows: readonly PendingJoinRow[]): void {
8335
- for (const { rowId, strandId, invite } of membershipInvitesToStage(rows, this.stagedMembershipInviteKeys, Date.now())) {
9060
+ private stageMembershipInvitesFromPendingJoins(joins: readonly PendingJoin[]): void {
9061
+ for (const { rowId, strandId, invite } of membershipInvitesToStage(joins, this.stagedMembershipInviteKeys, Date.now())) {
8336
9062
  this.stagedMembershipInviteKeys.add(invite.inviteKey);
8337
9063
  if (this.pendingMembershipInvites.has(strandId)) {
8338
9064
  continue;