@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.
- package/README.md +9 -8
- package/dist/authority-key.d.ts +41 -0
- package/dist/authority-key.js +52 -0
- package/dist/authority-key.js.map +1 -0
- package/dist/cadre-node.d.ts +770 -11
- package/dist/cadre-node.js +1900 -88
- package/dist/cadre-node.js.map +1 -1
- package/dist/canonical-datetime.d.ts +33 -0
- package/dist/canonical-datetime.js +38 -0
- package/dist/canonical-datetime.js.map +1 -0
- package/dist/canonical-json.d.ts +12 -0
- package/dist/canonical-json.js +33 -0
- package/dist/canonical-json.js.map +1 -0
- package/dist/control-cohort.d.ts +63 -0
- package/dist/control-cohort.js +80 -0
- package/dist/control-cohort.js.map +1 -0
- package/dist/control-database.d.ts +259 -5
- package/dist/control-database.js +541 -128
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +89 -0
- package/dist/control-formation-recorder.js +139 -0
- package/dist/control-formation-recorder.js.map +1 -0
- package/dist/control-schema.d.ts +12 -0
- package/dist/control-schema.js +199 -0
- package/dist/control-schema.js.map +1 -0
- package/dist/control-stream.d.ts +54 -0
- package/dist/control-stream.js +104 -0
- package/dist/control-stream.js.map +1 -0
- package/dist/device-token.d.ts +50 -0
- package/dist/device-token.js +66 -0
- package/dist/device-token.js.map +1 -0
- package/dist/diagnostics/connection-path.d.ts +118 -0
- package/dist/diagnostics/connection-path.js +204 -0
- package/dist/diagnostics/connection-path.js.map +1 -0
- package/dist/diagnostics/webrtc-turn-tracker.d.ts +50 -0
- package/dist/diagnostics/webrtc-turn-tracker.js +196 -0
- package/dist/diagnostics/webrtc-turn-tracker.js.map +1 -0
- package/dist/hibernation-manager.d.ts +79 -0
- package/dist/hibernation-manager.js +173 -23
- package/dist/hibernation-manager.js.map +1 -1
- package/dist/index.d.ts +25 -4
- package/dist/index.js +59 -4
- package/dist/index.js.map +1 -1
- package/dist/key-store-file.d.ts +52 -0
- package/dist/key-store-file.js +194 -0
- package/dist/key-store-file.js.map +1 -0
- package/dist/key-store.d.ts +75 -0
- package/dist/key-store.js +66 -0
- package/dist/key-store.js.map +1 -0
- package/dist/peer-authorization.d.ts +26 -0
- package/dist/peer-authorization.js +40 -0
- package/dist/peer-authorization.js.map +1 -0
- package/dist/peer-record.d.ts +89 -0
- package/dist/peer-record.js +117 -0
- package/dist/peer-record.js.map +1 -0
- package/dist/push-credentials.d.ts +34 -0
- package/dist/push-credentials.js +81 -0
- package/dist/push-credentials.js.map +1 -0
- package/dist/push-fanout.d.ts +137 -0
- package/dist/push-fanout.js +197 -0
- package/dist/push-fanout.js.map +1 -0
- package/dist/push-notifier-apns.d.ts +53 -0
- package/dist/push-notifier-apns.js +195 -0
- package/dist/push-notifier-apns.js.map +1 -0
- package/dist/push-notifier-fcm.d.ts +38 -0
- package/dist/push-notifier-fcm.js +183 -0
- package/dist/push-notifier-fcm.js.map +1 -0
- package/dist/push-notifier-shared.d.ts +18 -0
- package/dist/push-notifier-shared.js +25 -0
- package/dist/push-notifier-shared.js.map +1 -0
- package/dist/push-notifier.d.ts +71 -0
- package/dist/push-notifier.js +50 -0
- package/dist/push-notifier.js.map +1 -0
- package/dist/schema-verification.d.ts +11 -2
- package/dist/schema-verification.js +15 -4
- package/dist/schema-verification.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +197 -9
- package/dist/seed-bootstrap.js +396 -137
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.d.ts +63 -0
- package/dist/seed-trust-policy.js +56 -0
- package/dist/seed-trust-policy.js.map +1 -0
- package/dist/strand-addr-protocol.d.ts +132 -0
- package/dist/strand-addr-protocol.js +313 -0
- package/dist/strand-addr-protocol.js.map +1 -0
- package/dist/strand-cohort.d.ts +54 -0
- package/dist/strand-cohort.js +38 -0
- package/dist/strand-cohort.js.map +1 -0
- package/dist/strand-database.d.ts +54 -6
- package/dist/strand-database.js +88 -100
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-formation-manager.d.ts +79 -19
- package/dist/strand-formation-manager.js +196 -104
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +162 -0
- package/dist/strand-formation-protocol.js +279 -0
- package/dist/strand-formation-protocol.js.map +1 -0
- package/dist/strand-instance-manager.d.ts +73 -4
- package/dist/strand-instance-manager.js +167 -41
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-member-key.d.ts +29 -0
- package/dist/strand-member-key.js +38 -0
- package/dist/strand-member-key.js.map +1 -0
- package/dist/strand-member-registry.d.ts +98 -0
- package/dist/strand-member-registry.js +129 -0
- package/dist/strand-member-registry.js.map +1 -0
- package/dist/strand-membership-writer.d.ts +361 -0
- package/dist/strand-membership-writer.js +449 -0
- package/dist/strand-membership-writer.js.map +1 -0
- package/dist/strand-solicitation.d.ts +77 -2
- package/dist/strand-solicitation.js +25 -4
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-wake-payload.d.ts +30 -0
- package/dist/strand-wake-payload.js +20 -0
- package/dist/strand-wake-payload.js.map +1 -0
- package/dist/strand-wake-protocol.d.ts +114 -0
- package/dist/strand-wake-protocol.js +257 -0
- package/dist/strand-wake-protocol.js.map +1 -0
- package/dist/strand-watcher.d.ts +6 -2
- package/dist/strand-watcher.js +57 -17
- package/dist/strand-watcher.js.map +1 -1
- package/dist/types.d.ts +406 -23
- package/dist/types.js +22 -5
- package/dist/types.js.map +1 -1
- package/package.json +14 -8
- package/src/authority-key.ts +68 -0
- package/src/cadre-node.ts +2937 -948
- package/src/canonical-datetime.ts +39 -0
- package/src/canonical-json.ts +30 -0
- package/src/control-cohort.ts +103 -0
- package/src/control-database.ts +931 -383
- package/src/control-formation-recorder.ts +151 -0
- package/src/control-schema.ts +198 -0
- package/src/control-stream.ts +128 -0
- package/src/device-token.ts +89 -0
- package/src/diagnostics/connection-path.ts +277 -0
- package/src/diagnostics/webrtc-turn-tracker.ts +222 -0
- package/src/hibernation-manager.ts +197 -28
- package/src/index.ts +273 -79
- package/src/key-store-file.ts +203 -0
- package/src/key-store.ts +99 -0
- package/src/peer-authorization.ts +53 -0
- package/src/peer-record.ts +146 -0
- package/src/push-credentials.ts +84 -0
- package/src/push-fanout.ts +272 -0
- package/src/push-notifier-apns.ts +243 -0
- package/src/push-notifier-fcm.ts +221 -0
- package/src/push-notifier-shared.ts +28 -0
- package/src/push-notifier.ts +93 -0
- package/src/schema-verification.ts +18 -4
- package/src/seed-bootstrap.ts +464 -188
- package/src/seed-trust-policy.ts +107 -0
- package/src/strand-addr-protocol.ts +410 -0
- package/src/strand-cohort.ts +77 -0
- package/src/strand-database.ts +113 -141
- package/src/strand-formation-manager.ts +233 -142
- package/src/strand-formation-protocol.ts +391 -0
- package/src/strand-instance-manager.ts +207 -56
- package/src/strand-member-key.ts +39 -0
- package/src/strand-member-registry.ts +177 -0
- package/src/strand-membership-writer.ts +640 -0
- package/src/strand-solicitation.ts +93 -4
- package/src/strand-wake-payload.ts +32 -0
- package/src/strand-wake-protocol.ts +331 -0
- package/src/strand-watcher.ts +61 -17
- 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
|
-
|
|
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
|
|
64
|
-
*
|
|
64
|
+
* StrandFormationManager drives the native cadre-core formation transport
|
|
65
|
+
* (`strand-formation-protocol.ts`) from cadre-core's strand-solicitation interfaces.
|
|
65
66
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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
|
-
|
|
145
|
-
const link: BootstrapLink = {
|
|
146
|
-
responderPeerAddrs: invitation.bootstrap,
|
|
151
|
+
const contact: FormationContactMessage = {
|
|
147
152
|
token: invitation.token,
|
|
148
|
-
|
|
149
|
-
|
|
153
|
+
partyId: disclosure.partyId ?? this.partyId,
|
|
154
|
+
disclosure,
|
|
155
|
+
cadrePeerAddrs: this.cadrePeerAddrs
|
|
150
156
|
};
|
|
151
157
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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.
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
206
|
+
if (await this.formationUsageRecorder.isTokenUsed(token)) {
|
|
207
|
+
log('Token already used: %s', token);
|
|
208
|
+
return { valid: false };
|
|
209
|
+
}
|
|
199
210
|
|
|
200
|
-
|
|
201
|
-
|
|
211
|
+
return { valid: true };
|
|
212
|
+
}
|
|
202
213
|
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
255
|
-
log('validateResponse (session: %s)', sessionId);
|
|
256
|
-
// Accept all responses for now
|
|
257
|
-
return true;
|
|
258
|
-
},
|
|
347
|
+
// ── Initiator-side result validation ─────────────────────────────────────────
|
|
259
348
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|