@serfab/cadre-core 0.7.1 → 0.8.1

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 (166) hide show
  1. package/README.md +9 -8
  2. package/dist/authority-key.d.ts +41 -0
  3. package/dist/authority-key.js +52 -0
  4. package/dist/authority-key.js.map +1 -0
  5. package/dist/cadre-node.d.ts +770 -11
  6. package/dist/cadre-node.js +1900 -88
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/canonical-datetime.d.ts +33 -0
  9. package/dist/canonical-datetime.js +38 -0
  10. package/dist/canonical-datetime.js.map +1 -0
  11. package/dist/canonical-json.d.ts +12 -0
  12. package/dist/canonical-json.js +33 -0
  13. package/dist/canonical-json.js.map +1 -0
  14. package/dist/control-cohort.d.ts +63 -0
  15. package/dist/control-cohort.js +80 -0
  16. package/dist/control-cohort.js.map +1 -0
  17. package/dist/control-database.d.ts +259 -5
  18. package/dist/control-database.js +541 -128
  19. package/dist/control-database.js.map +1 -1
  20. package/dist/control-formation-recorder.d.ts +89 -0
  21. package/dist/control-formation-recorder.js +139 -0
  22. package/dist/control-formation-recorder.js.map +1 -0
  23. package/dist/control-schema.d.ts +12 -0
  24. package/dist/control-schema.js +199 -0
  25. package/dist/control-schema.js.map +1 -0
  26. package/dist/control-stream.d.ts +54 -0
  27. package/dist/control-stream.js +104 -0
  28. package/dist/control-stream.js.map +1 -0
  29. package/dist/device-token.d.ts +50 -0
  30. package/dist/device-token.js +66 -0
  31. package/dist/device-token.js.map +1 -0
  32. package/dist/diagnostics/connection-path.d.ts +118 -0
  33. package/dist/diagnostics/connection-path.js +204 -0
  34. package/dist/diagnostics/connection-path.js.map +1 -0
  35. package/dist/diagnostics/webrtc-turn-tracker.d.ts +50 -0
  36. package/dist/diagnostics/webrtc-turn-tracker.js +196 -0
  37. package/dist/diagnostics/webrtc-turn-tracker.js.map +1 -0
  38. package/dist/hibernation-manager.d.ts +79 -0
  39. package/dist/hibernation-manager.js +173 -23
  40. package/dist/hibernation-manager.js.map +1 -1
  41. package/dist/index.d.ts +25 -4
  42. package/dist/index.js +59 -4
  43. package/dist/index.js.map +1 -1
  44. package/dist/key-store-file.d.ts +52 -0
  45. package/dist/key-store-file.js +194 -0
  46. package/dist/key-store-file.js.map +1 -0
  47. package/dist/key-store.d.ts +75 -0
  48. package/dist/key-store.js +66 -0
  49. package/dist/key-store.js.map +1 -0
  50. package/dist/peer-authorization.d.ts +26 -0
  51. package/dist/peer-authorization.js +40 -0
  52. package/dist/peer-authorization.js.map +1 -0
  53. package/dist/peer-record.d.ts +89 -0
  54. package/dist/peer-record.js +117 -0
  55. package/dist/peer-record.js.map +1 -0
  56. package/dist/push-credentials.d.ts +34 -0
  57. package/dist/push-credentials.js +81 -0
  58. package/dist/push-credentials.js.map +1 -0
  59. package/dist/push-fanout.d.ts +137 -0
  60. package/dist/push-fanout.js +197 -0
  61. package/dist/push-fanout.js.map +1 -0
  62. package/dist/push-notifier-apns.d.ts +53 -0
  63. package/dist/push-notifier-apns.js +195 -0
  64. package/dist/push-notifier-apns.js.map +1 -0
  65. package/dist/push-notifier-fcm.d.ts +38 -0
  66. package/dist/push-notifier-fcm.js +183 -0
  67. package/dist/push-notifier-fcm.js.map +1 -0
  68. package/dist/push-notifier-shared.d.ts +18 -0
  69. package/dist/push-notifier-shared.js +25 -0
  70. package/dist/push-notifier-shared.js.map +1 -0
  71. package/dist/push-notifier.d.ts +71 -0
  72. package/dist/push-notifier.js +50 -0
  73. package/dist/push-notifier.js.map +1 -0
  74. package/dist/schema-verification.d.ts +11 -2
  75. package/dist/schema-verification.js +15 -4
  76. package/dist/schema-verification.js.map +1 -1
  77. package/dist/seed-bootstrap.d.ts +197 -9
  78. package/dist/seed-bootstrap.js +396 -137
  79. package/dist/seed-bootstrap.js.map +1 -1
  80. package/dist/seed-trust-policy.d.ts +63 -0
  81. package/dist/seed-trust-policy.js +56 -0
  82. package/dist/seed-trust-policy.js.map +1 -0
  83. package/dist/strand-addr-protocol.d.ts +132 -0
  84. package/dist/strand-addr-protocol.js +313 -0
  85. package/dist/strand-addr-protocol.js.map +1 -0
  86. package/dist/strand-cohort.d.ts +54 -0
  87. package/dist/strand-cohort.js +38 -0
  88. package/dist/strand-cohort.js.map +1 -0
  89. package/dist/strand-database.d.ts +54 -6
  90. package/dist/strand-database.js +88 -100
  91. package/dist/strand-database.js.map +1 -1
  92. package/dist/strand-formation-manager.d.ts +79 -19
  93. package/dist/strand-formation-manager.js +196 -104
  94. package/dist/strand-formation-manager.js.map +1 -1
  95. package/dist/strand-formation-protocol.d.ts +162 -0
  96. package/dist/strand-formation-protocol.js +279 -0
  97. package/dist/strand-formation-protocol.js.map +1 -0
  98. package/dist/strand-instance-manager.d.ts +73 -4
  99. package/dist/strand-instance-manager.js +167 -41
  100. package/dist/strand-instance-manager.js.map +1 -1
  101. package/dist/strand-member-key.d.ts +29 -0
  102. package/dist/strand-member-key.js +38 -0
  103. package/dist/strand-member-key.js.map +1 -0
  104. package/dist/strand-member-registry.d.ts +98 -0
  105. package/dist/strand-member-registry.js +129 -0
  106. package/dist/strand-member-registry.js.map +1 -0
  107. package/dist/strand-membership-writer.d.ts +361 -0
  108. package/dist/strand-membership-writer.js +449 -0
  109. package/dist/strand-membership-writer.js.map +1 -0
  110. package/dist/strand-solicitation.d.ts +77 -2
  111. package/dist/strand-solicitation.js +25 -4
  112. package/dist/strand-solicitation.js.map +1 -1
  113. package/dist/strand-wake-payload.d.ts +30 -0
  114. package/dist/strand-wake-payload.js +20 -0
  115. package/dist/strand-wake-payload.js.map +1 -0
  116. package/dist/strand-wake-protocol.d.ts +114 -0
  117. package/dist/strand-wake-protocol.js +257 -0
  118. package/dist/strand-wake-protocol.js.map +1 -0
  119. package/dist/strand-watcher.d.ts +6 -2
  120. package/dist/strand-watcher.js +57 -17
  121. package/dist/strand-watcher.js.map +1 -1
  122. package/dist/types.d.ts +406 -23
  123. package/dist/types.js +22 -5
  124. package/dist/types.js.map +1 -1
  125. package/package.json +14 -8
  126. package/src/authority-key.ts +68 -0
  127. package/src/cadre-node.ts +2937 -948
  128. package/src/canonical-datetime.ts +39 -0
  129. package/src/canonical-json.ts +30 -0
  130. package/src/control-cohort.ts +103 -0
  131. package/src/control-database.ts +931 -383
  132. package/src/control-formation-recorder.ts +151 -0
  133. package/src/control-schema.ts +198 -0
  134. package/src/control-stream.ts +128 -0
  135. package/src/device-token.ts +89 -0
  136. package/src/diagnostics/connection-path.ts +277 -0
  137. package/src/diagnostics/webrtc-turn-tracker.ts +222 -0
  138. package/src/hibernation-manager.ts +197 -28
  139. package/src/index.ts +273 -79
  140. package/src/key-store-file.ts +203 -0
  141. package/src/key-store.ts +99 -0
  142. package/src/peer-authorization.ts +53 -0
  143. package/src/peer-record.ts +146 -0
  144. package/src/push-credentials.ts +84 -0
  145. package/src/push-fanout.ts +272 -0
  146. package/src/push-notifier-apns.ts +243 -0
  147. package/src/push-notifier-fcm.ts +221 -0
  148. package/src/push-notifier-shared.ts +28 -0
  149. package/src/push-notifier.ts +93 -0
  150. package/src/schema-verification.ts +18 -4
  151. package/src/seed-bootstrap.ts +464 -188
  152. package/src/seed-trust-policy.ts +107 -0
  153. package/src/strand-addr-protocol.ts +410 -0
  154. package/src/strand-cohort.ts +77 -0
  155. package/src/strand-database.ts +113 -141
  156. package/src/strand-formation-manager.ts +233 -142
  157. package/src/strand-formation-protocol.ts +391 -0
  158. package/src/strand-instance-manager.ts +207 -56
  159. package/src/strand-member-key.ts +39 -0
  160. package/src/strand-member-registry.ts +177 -0
  161. package/src/strand-membership-writer.ts +640 -0
  162. package/src/strand-solicitation.ts +93 -4
  163. package/src/strand-wake-payload.ts +32 -0
  164. package/src/strand-wake-protocol.ts +331 -0
  165. package/src/strand-watcher.ts +61 -17
  166. package/src/types.ts +1012 -582
@@ -1,17 +1,5 @@
1
1
  import debug from 'debug';
2
2
  import type { Libp2p } from '@libp2p/interface';
3
- import {
4
- SessionManager,
5
- createBootstrapManager,
6
- DEFAULT_PROTOCOL_ID,
7
- type SessionHooks,
8
- type SessionConfig,
9
- type BootstrapLink,
10
- type BootstrapResult,
11
- type BootstrapMode,
12
- type DialogParty,
13
- type ProvisionResult
14
- } from '@serfab/strand-proto';
15
3
  import type {
16
4
  OpenInvitation,
17
5
  FormStrandResult,
@@ -20,8 +8,19 @@ import type {
20
8
  import type {
21
9
  DisclosureValidator,
22
10
  FormationUsageRecorder,
23
- StrandProvisioner
11
+ ResolvedHostStrand,
12
+ StrandProvisioner,
13
+ FormationResponseValidator
24
14
  } from './strand-solicitation.js';
15
+ import {
16
+ FormationListener,
17
+ dialFormation,
18
+ isValidResponderCreatesResult,
19
+ type FormationContactMessage,
20
+ type FormationProvisionResult,
21
+ type FormationResultMessage,
22
+ type ResponderProvisionOutcome
23
+ } from './strand-formation-protocol.js';
25
24
 
26
25
  const log = debug('sereus:cadre:formation-manager');
27
26
 
@@ -45,12 +44,14 @@ export interface StrandFormationManagerConfig {
45
44
  * Options for creating a StrandFormationManager
46
45
  */
47
46
  export interface StrandFormationManagerOptions {
48
- /** Validates disclosures from initiators */
47
+ /** Validates disclosures from initiators (responder side) */
49
48
  disclosureValidator?: DisclosureValidator;
50
- /** Records and validates token usage */
49
+ /** Records and validates token usage (responder side) */
51
50
  formationUsageRecorder?: FormationUsageRecorder;
52
- /** Provisions strands after validation */
51
+ /** Provisions strands after validation (responder side) */
53
52
  strandProvisioner?: StrandProvisioner;
53
+ /** Validates the responder's result (initiator side); defaults to a structural check */
54
+ formationResponseValidator?: FormationResponseValidator;
54
55
  /** This party's ID for identification */
55
56
  partyId: string;
56
57
  /** This party's cadre peer addresses */
@@ -60,43 +61,50 @@ export interface StrandFormationManagerOptions {
60
61
  }
61
62
 
62
63
  /**
63
- * StrandFormationManager bridges cadre-core's strand solicitation interfaces
64
- * with strand-proto's SessionManager for actual protocol handling over libp2p.
64
+ * StrandFormationManager drives the native cadre-core formation transport
65
+ * (`strand-formation-protocol.ts`) from cadre-core's strand-solicitation interfaces.
65
66
  *
66
- * It implements SessionHooks by delegating to the existing interfaces:
67
- * - DisclosureValidator -> validateIdentity
68
- * - FormationUsageRecorder -> validateToken
69
- * - StrandProvisioner -> provisionStrand
67
+ * Responder side: a {@link FormationListener} wires the inbound protocol to
68
+ * {@link FormationUsageRecorder} (token), {@link DisclosureValidator} (identity),
69
+ * and {@link StrandProvisioner} (provisioning), disclosing this party's real
70
+ * identity + cadre only after validation.
71
+ *
72
+ * Initiator side: {@link formStrand} dials the responder carrying the real
73
+ * disclosure/token/cadre, then validates the responder's result via the
74
+ * {@link FormationResponseValidator} (or a built-in structural check).
70
75
  */
71
76
  export class StrandFormationManager {
72
- private readonly sessionManager: SessionManager;
73
77
  private readonly disclosureValidator?: DisclosureValidator;
74
78
  private readonly formationUsageRecorder?: FormationUsageRecorder;
75
79
  private readonly strandProvisioner?: StrandProvisioner;
80
+ private readonly formationResponseValidator?: FormationResponseValidator;
76
81
  private readonly partyId: string;
77
82
  private readonly cadrePeerAddrs: string[];
78
- private registeredNodes: Set<Libp2p> = new Set();
83
+ private readonly config: StrandFormationManagerConfig;
84
+ private readonly listener: FormationListener;
85
+ private readonly registeredNodes = new Set<Libp2p>();
86
+ private dialerSessions = 0;
79
87
 
80
88
  constructor(options: StrandFormationManagerOptions) {
81
89
  this.disclosureValidator = options.disclosureValidator;
82
90
  this.formationUsageRecorder = options.formationUsageRecorder;
83
91
  this.strandProvisioner = options.strandProvisioner;
92
+ this.formationResponseValidator = options.formationResponseValidator;
84
93
  this.partyId = options.partyId;
85
94
  this.cadrePeerAddrs = options.cadrePeerAddrs ?? [];
95
+ this.config = options.config ?? {};
96
+
97
+ this.listener = new FormationListener({
98
+ validateToken: (token) => this.validateToken(token),
99
+ validateDisclosure: (token, disclosure) => this.validateDisclosure(token, disclosure),
100
+ provisionStrand: (token, initiatorPartyId, disclosure) =>
101
+ this.provisionAsResponder(token, initiatorPartyId, disclosure),
102
+ getResponderIdentity: () => ({ partyId: this.partyId, cadrePeerAddrs: this.cadrePeerAddrs }),
103
+ sessionTimeoutMs: this.config.sessionTimeoutMs,
104
+ stepTimeoutMs: this.config.stepTimeoutMs,
105
+ maxConcurrentSessions: this.config.maxConcurrentSessions
106
+ });
86
107
 
87
- // Create SessionHooks that delegate to our interfaces
88
- const hooks = this.createSessionHooks();
89
-
90
- // Create the underlying SessionManager
91
- const config: Partial<SessionConfig> = {
92
- sessionTimeoutMs: options.config?.sessionTimeoutMs ?? 30000,
93
- stepTimeoutMs: options.config?.stepTimeoutMs ?? 5000,
94
- maxConcurrentSessions: options.config?.maxConcurrentSessions ?? 100,
95
- enableDebugLogging: options.config?.enableDebugLogging ?? false,
96
- protocolId: options.config?.protocolId ?? DEFAULT_PROTOCOL_ID
97
- };
98
-
99
- this.sessionManager = createBootstrapManager(hooks, config);
100
108
  log('StrandFormationManager created for party: %s', this.partyId);
101
109
  }
102
110
 
@@ -109,7 +117,7 @@ export class StrandFormationManager {
109
117
  log('Node already registered');
110
118
  return;
111
119
  }
112
- this.sessionManager.register(node, protocolId);
120
+ this.listener.register(node, protocolId ?? this.config.protocolId);
113
121
  this.registeredNodes.add(node);
114
122
  log('Registered as responder on node');
115
123
  }
@@ -121,18 +129,17 @@ export class StrandFormationManager {
121
129
  if (!this.registeredNodes.has(node)) {
122
130
  return;
123
131
  }
124
- this.sessionManager.unregister(node, protocolId);
132
+ this.listener.unregister(node, protocolId ?? this.config.protocolId);
125
133
  this.registeredNodes.delete(node);
126
134
  log('Unregistered from node');
127
135
  }
128
136
 
129
137
  /**
130
- * Form a strand with a responder via an open invitation.
138
+ * Form a strand with a responder via an open invitation (initiator side).
131
139
  *
132
- * This is the initiator-side operation. It:
133
- * 1. Converts the OpenInvitation to a BootstrapLink
134
- * 2. Calls SessionManager.initiateBootstrap()
135
- * 3. Returns the result as a FormStrandResult
140
+ * Builds a contact message carrying the real token + disclosure + this party's
141
+ * real cadre addresses, dials the responder over the native protocol, and
142
+ * validates the responder's result before returning.
136
143
  */
137
144
  async formStrand(
138
145
  invitation: OpenInvitation,
@@ -141,128 +148,213 @@ export class StrandFormationManager {
141
148
  ): Promise<FormStrandResult> {
142
149
  log('Forming strand with invitation token: %s', invitation.token);
143
150
 
144
- // Convert OpenInvitation to BootstrapLink
145
- const link: BootstrapLink = {
146
- responderPeerAddrs: invitation.bootstrap,
151
+ const contact: FormationContactMessage = {
147
152
  token: invitation.token,
148
- tokenExpiryUtc: invitation.expiration.toISOString(),
149
- mode: 'responderCreates' // Responder provisions the strand
153
+ partyId: disclosure.partyId ?? this.partyId,
154
+ disclosure,
155
+ cadrePeerAddrs: this.cadrePeerAddrs
150
156
  };
151
157
 
152
- // The disclosure is passed via the identityBundle in the contact message
153
- // We store it temporarily so the hooks can access it
154
- // Note: strand-proto's DialerSession constructs the contact message internally
155
- // We need to extend this to pass the disclosure - for now we use partyId
156
-
157
- const result = await this.sessionManager.initiateBootstrap(link, node as any);
158
-
159
- log('Strand formed: %s', result.strand.strandId);
160
-
161
- return {
162
- memberKey: this.partyId, // The initiator's member key
163
- invitePrivateKey: '', // Would come from key generation
164
- strandId: result.strand.strandId
165
- };
158
+ this.dialerSessions++;
159
+ try {
160
+ const provision = await dialFormation(node, {
161
+ contact,
162
+ responderAddrs: invitation.bootstrap,
163
+ validateResponse: (response) => this.validateResponse(invitation, disclosure, response),
164
+ sessionTimeoutMs: this.config.sessionTimeoutMs,
165
+ stepTimeoutMs: this.config.stepTimeoutMs,
166
+ protocolId: this.config.protocolId
167
+ });
168
+
169
+ log('Strand formed: %s', provision.strand.strandId);
170
+
171
+ return {
172
+ memberKey: contact.partyId,
173
+ invitePrivateKey: '',
174
+ strandId: provision.strand.strandId,
175
+ // The host strand's membership key, delivered through the protocol (provision-then-record).
176
+ // Undefined for an open strand. Kept separate from invitePrivateKey (the initiator's
177
+ // generated signing key), which is set by the StrandSolicitationService layer.
178
+ memberPrivateKey: provision.memberPrivateKey
179
+ };
180
+ } finally {
181
+ this.dialerSessions--;
182
+ }
166
183
  }
167
184
 
168
185
  /**
169
186
  * Get the number of active sessions
170
187
  */
171
188
  getActiveSessionCounts(): { listeners: number; dialers: number } {
172
- return this.sessionManager.getActiveSessionCounts();
189
+ return { listeners: this.listener.activeCount, dialers: this.dialerSessions };
173
190
  }
174
191
 
175
- /**
176
- * Create SessionHooks that delegate to our interfaces
177
- */
178
- private createSessionHooks(): SessionHooks {
179
- return {
180
- validateToken: async (token: string, sessionId: string) => {
181
- log('validateToken: %s (session: %s)', token, sessionId);
192
+ // ── Responder-side hooks ─────────────────────────────────────────────────────
182
193
 
183
- if (!this.formationUsageRecorder) {
184
- // No recorder configured - accept all tokens
185
- return { mode: 'responderCreates' as BootstrapMode, valid: true };
186
- }
194
+ private async validateToken(token: string): Promise<{ valid: boolean }> {
195
+ if (!this.formationUsageRecorder) {
196
+ // No recorder configured — accept all tokens.
197
+ return { valid: true };
198
+ }
187
199
 
188
- const tokenCheck = await this.formationUsageRecorder.isTokenValid(token);
189
- if (!tokenCheck.valid) {
190
- log('Token invalid: %s', token);
191
- return { mode: 'responderCreates' as BootstrapMode, valid: false };
192
- }
200
+ const tokenCheck = await this.formationUsageRecorder.isTokenValid(token);
201
+ if (!tokenCheck.valid) {
202
+ log('Token invalid: %s', token);
203
+ return { valid: false };
204
+ }
193
205
 
194
- const isUsed = await this.formationUsageRecorder.isTokenUsed(token);
195
- if (isUsed) {
196
- log('Token already used: %s', token);
197
- return { mode: 'responderCreates' as BootstrapMode, valid: false };
198
- }
206
+ if (await this.formationUsageRecorder.isTokenUsed(token)) {
207
+ log('Token already used: %s', token);
208
+ return { valid: false };
209
+ }
199
210
 
200
- return { mode: 'responderCreates' as BootstrapMode, valid: true };
201
- },
211
+ return { valid: true };
212
+ }
202
213
 
203
- validateIdentity: async (identity: unknown, sessionId: string) => {
204
- log('validateIdentity (session: %s)', sessionId);
214
+ private async validateDisclosure(token: string, disclosure: StrandFormationDisclosure): Promise<boolean> {
215
+ if (!this.disclosureValidator) {
216
+ // No validator configured — accept all disclosures.
217
+ return true;
218
+ }
219
+ return this.disclosureValidator.validateDisclosure(token, disclosure);
220
+ }
205
221
 
206
- if (!this.disclosureValidator) {
207
- // No validator configured - accept all identities
208
- return true;
222
+ /**
223
+ * Responder-side provisioning. Routes on how the invite resolves
224
+ * ({@link ResolvedHostStrand}), and can REJECT post-validation by returning a
225
+ * {@link ResponderProvisionOutcome} with `approved: false` — `runSession` turns that
226
+ * into a clean, non-disclosing reply instead of a dropped result frame.
227
+ *
228
+ * - **bound** (host strand present): provision-then-record — write the single
229
+ * `FormationUsage` consent row against the pre-existing strand (record-only) and
230
+ * return it + its membership key (a read-gating secret disclosed only here, behind the
231
+ * token + disclosure validation `runSession` already enforced).
232
+ * - **missing** (invite names a host strand absent on this responder, e.g. unconverged):
233
+ * reject cleanly + retryably, writing NO usage row — recording usage here would fail the
234
+ * deferred `StrandExists` CHECK at commit and drop the frame.
235
+ * - **unbound** (no binding): the responder-provisions fallback — see {@link provisionUnbound}.
236
+ *
237
+ * Defense-in-depth: known provisioning/redeem failures (notably the concurrent
238
+ * `(Token, UseNumber)` PK collision when two redemptions of one unbound single-use invite
239
+ * race) are caught and mapped to a logged, retry-suggesting rejection rather than a thrown
240
+ * insert + a silently-closed stream. The LOG-before-reject keeps this a deliberate
241
+ * internal-error→protocol-rejection conversion (AGENTS.md: don't eat exceptions silently),
242
+ * not control-flow-by-exception.
243
+ */
244
+ private async provisionAsResponder(
245
+ token: string,
246
+ initiatorPartyId: string,
247
+ _disclosure: StrandFormationDisclosure
248
+ ): Promise<ResponderProvisionOutcome> {
249
+ const recorder = this.formationUsageRecorder;
250
+ const resolved: ResolvedHostStrand = recorder?.resolveStrand
251
+ ? await recorder.resolveStrand(token)
252
+ : { kind: 'unbound' };
253
+
254
+ try {
255
+ switch (resolved.kind) {
256
+ case 'bound': {
257
+ // recorder is guaranteed non-null here: only resolveStrand can yield 'bound'.
258
+ await recorder!.recordUsage(token, initiatorPartyId, resolved.strandId);
259
+ return this.approve({
260
+ strand: { strandId: resolved.strandId, createdBy: 'responder' },
261
+ memberPrivateKey: resolved.memberPrivateKey ?? undefined,
262
+ dbConnectionInfo: { endpoint: 'local', credentialsRef: '' }
263
+ });
209
264
  }
265
+ case 'missing': {
266
+ log('Host strand %s not yet available on this responder; rejecting token %s', resolved.strandId, token);
267
+ return { approved: false, reason: 'Host strand not yet available on this responder' };
268
+ }
269
+ case 'unbound':
270
+ return await this.provisionUnbound(token, initiatorPartyId);
271
+ }
272
+ } catch (err) {
273
+ log('provisionAsResponder failed for token %s: %o', token, err);
274
+ return { approved: false, reason: 'Formation conflict, retry' };
275
+ }
276
+ }
210
277
 
211
- // Extract disclosure from identity bundle
212
- const disclosure = identity as StrandFormationDisclosure;
213
- // We need the token here - it's passed via the contact message
214
- // For now, use a placeholder - this will be refined
215
- const token = (identity as any)?.token ?? '';
216
-
217
- return this.disclosureValidator.validateDisclosure(token, disclosure);
218
- },
219
-
220
- provisionStrand: async (
221
- creator: DialogParty,
222
- creatorPartyId: string,
223
- otherPartyId: string,
224
- sessionId: string
225
- ): Promise<ProvisionResult> => {
226
- log('provisionStrand: creator=%s, other=%s (session: %s)',
227
- creatorPartyId, otherPartyId, sessionId);
278
+ /**
279
+ * Responder-provisions fallback (invite binds no host strand). Precedence:
280
+ *
281
+ * 1. `recorder.provisionAndRecord` present (a real DB recorder) → atomic create-strand +
282
+ * record-consent, so the unbound redemption is single-use just like the bound path.
283
+ * Returns the new strand (+ key, null for an open responder-provisioned strand).
284
+ * 2. else `strandProvisioner` present → the legacy/mock contract: provision a bare strand,
285
+ * NO inline usage write (those callers record usage explicitly via
286
+ * `recordFormationComplete`, or assert transport invariants only). Threads the invite's
287
+ * REAL `sAppId`.
288
+ * 3. else → a structural placeholder (no recorder + no provisioner ⇒ no single-use semantics
289
+ * exist to enforce).
290
+ *
291
+ * Plan tradeoff — option (a) "record usage on the fallback" over (b) "remove the fallback":
292
+ * (a) closes the single-use hole with a targeted atomic create+record and leaves the
293
+ * `StrandProvisioner` mock-transport tests untouched; (b) would delete the provisioner
294
+ * surface and churn ~6 unit + ~6 integration sites for ZERO production benefit (production
295
+ * — `cadre-web.ts` / `cadre-phone.ts` — always publishes strand-BOUND invites and treats
296
+ * the responder-provisions placeholder as failure). The broader "should this fallback exist
297
+ * at all?" question lives in backlog `formation-initiatorcreates-cover-or-remove`.
298
+ */
299
+ private async provisionUnbound(
300
+ token: string,
301
+ initiatorPartyId: string
302
+ ): Promise<ResponderProvisionOutcome> {
303
+ const recorder = this.formationUsageRecorder;
304
+ if (recorder?.provisionAndRecord) {
305
+ const sAppId = await this.resolveInviteSAppId(token);
306
+ const provisioned = await recorder.provisionAndRecord(token, initiatorPartyId, sAppId);
307
+ return this.approve({
308
+ strand: { strandId: provisioned.strandId, createdBy: 'responder' },
309
+ memberPrivateKey: provisioned.memberPrivateKey ?? undefined,
310
+ dbConnectionInfo: { endpoint: 'local', credentialsRef: '' }
311
+ });
312
+ }
228
313
 
229
- if (!this.strandProvisioner) {
230
- // No provisioner - return a placeholder
231
- const strandId = `strand-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
232
- return {
233
- strand: { strandId, createdBy: creator },
234
- dbConnectionInfo: { endpoint: 'local', credentialsRef: '' }
235
- };
236
- }
314
+ if (this.strandProvisioner) {
315
+ const sAppId = await this.resolveInviteSAppId(token);
316
+ const result = await this.strandProvisioner.provisionStrand(sAppId, initiatorPartyId, this.partyId);
317
+ return this.approve({
318
+ strand: { strandId: result.strandId, createdBy: 'responder' },
319
+ dbConnectionInfo: { endpoint: 'local', credentialsRef: '' }
320
+ });
321
+ }
237
322
 
238
- // Determine initiator/responder keys based on creator
239
- const initiatorKey = creator === 'initiator' ? creatorPartyId : otherPartyId;
240
- const responderKey = creator === 'responder' ? creatorPartyId : otherPartyId;
323
+ // No recorder + no provisioner — a structural placeholder the initiator can still validate.
324
+ const strandId = `strand-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
325
+ return this.approve({
326
+ strand: { strandId, createdBy: 'responder' },
327
+ dbConnectionInfo: { endpoint: 'local', credentialsRef: '' }
328
+ });
329
+ }
241
330
 
242
- const result = await this.strandProvisioner.provisionStrand(
243
- '', // sAppId - would come from invitation
244
- initiatorKey,
245
- responderKey
246
- );
331
+ /** Wrap a provision result as an approving {@link ResponderProvisionOutcome}. */
332
+ private approve(result: FormationProvisionResult): ResponderProvisionOutcome {
333
+ return { approved: true, result };
334
+ }
247
335
 
248
- return {
249
- strand: { strandId: result.strandId, createdBy: creator },
250
- dbConnectionInfo: { endpoint: 'local', credentialsRef: '' }
251
- };
252
- },
336
+ /**
337
+ * The invite's authoritative `sAppId` for the responder-provisions fallback, read back
338
+ * from the recorder's `isTokenValid` invitation (the real `FormationInvite.sAppId`).
339
+ * Empty string when no recorder is wired or the invitation omits it.
340
+ */
341
+ private async resolveInviteSAppId(token: string): Promise<string> {
342
+ if (!this.formationUsageRecorder) return '';
343
+ const check = await this.formationUsageRecorder.isTokenValid(token);
344
+ return check.invitation?.sAppId ?? '';
345
+ }
253
346
 
254
- validateResponse: async (response: unknown, sessionId: string) => {
255
- log('validateResponse (session: %s)', sessionId);
256
- // Accept all responses for now
257
- return true;
258
- },
347
+ // ── Initiator-side result validation ─────────────────────────────────────────
259
348
 
260
- validateDatabaseResult: async (result: unknown, sessionId: string) => {
261
- log('validateDatabaseResult (session: %s)', sessionId);
262
- // Accept all database results for now
263
- return true;
264
- }
265
- };
349
+ private async validateResponse(
350
+ invitation: OpenInvitation,
351
+ disclosure: StrandFormationDisclosure,
352
+ response: FormationResultMessage
353
+ ): Promise<boolean> {
354
+ if (this.formationResponseValidator) {
355
+ return this.formationResponseValidator.validateResponse({ invitation, disclosure, response });
356
+ }
357
+ return isValidResponderCreatesResult(response);
266
358
  }
267
359
  }
268
360
 
@@ -274,4 +366,3 @@ export function createStrandFormationManager(
274
366
  ): StrandFormationManager {
275
367
  return new StrandFormationManager(options);
276
368
  }
277
-