@serfab/cadre-core 1.17.0 → 1.18.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.
- package/README.md +3 -2
- package/dist/cadre-invite-protocol.d.ts +35 -13
- package/dist/cadre-invite-protocol.js +90 -25
- package/dist/cadre-invite-protocol.js.map +1 -1
- package/dist/cadre-node.d.ts +586 -255
- package/dist/cadre-node.js +1176 -564
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-cohort.d.ts +66 -9
- package/dist/control-cohort.js +67 -9
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +451 -193
- package/dist/control-database.js +701 -359
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +46 -43
- package/dist/control-formation-recorder.js +77 -78
- package/dist/control-formation-recorder.js.map +1 -1
- package/dist/control-protocol-guard.d.ts +6 -0
- package/dist/control-protocol-guard.js +14 -2
- package/dist/control-protocol-guard.js.map +1 -1
- package/dist/control-schema-version.d.ts +48 -31
- package/dist/control-schema-version.js +67 -45
- package/dist/control-schema-version.js.map +1 -1
- package/dist/control-schema.d.ts +8 -8
- package/dist/control-schema.js +16 -16
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +42 -7
- package/dist/control-write-retry.js +87 -19
- package/dist/control-write-retry.js.map +1 -1
- package/dist/enrolled-machine-store.d.ts +3 -3
- package/dist/enrolled-machine-store.js +3 -3
- package/dist/formation-token.d.ts +48 -0
- package/dist/formation-token.js +26 -0
- package/dist/formation-token.js.map +1 -0
- package/dist/index.d.ts +14 -13
- package/dist/index.js +16 -13
- package/dist/index.js.map +1 -1
- package/dist/joined-strand-store.d.ts +26 -9
- package/dist/joined-strand-store.js +63 -24
- package/dist/joined-strand-store.js.map +1 -1
- package/dist/link-budget.d.ts +9 -7
- package/dist/link-budget.js +9 -7
- package/dist/link-budget.js.map +1 -1
- package/dist/member-redemption.d.ts +15 -9
- package/dist/member-redemption.js +13 -8
- package/dist/member-redemption.js.map +1 -1
- package/dist/membership-view.d.ts +91 -0
- package/dist/membership-view.js +110 -0
- package/dist/membership-view.js.map +1 -0
- package/dist/owner-grant-protocol.d.ts +11 -7
- package/dist/owner-grant-protocol.js +31 -17
- package/dist/owner-grant-protocol.js.map +1 -1
- package/dist/peer-addr-book.d.ts +10 -1
- package/dist/peer-addr-book.js +4 -3
- package/dist/peer-addr-book.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +89 -20
- package/dist/peer-join-backfill.js +265 -108
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/pending-join-runner.d.ts +48 -18
- package/dist/pending-join-runner.js +74 -56
- package/dist/pending-join-runner.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +47 -40
- package/dist/seed-bootstrap.js +66 -61
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/strand-database.d.ts +23 -2
- package/dist/strand-database.js +26 -6
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +176 -89
- package/dist/strand-first-sync-gate.js +288 -122
- package/dist/strand-first-sync-gate.js.map +1 -1
- package/dist/strand-formation-deadlines.d.ts +9 -9
- package/dist/strand-formation-deadlines.js +9 -9
- package/dist/strand-formation-manager.d.ts +23 -17
- package/dist/strand-formation-manager.js +46 -44
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +5 -8
- package/dist/strand-formation-protocol.js +101 -16
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +26 -17
- package/dist/strand-instance-manager.js +119 -50
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-reconciler.d.ts +16 -13
- package/dist/strand-membership-reconciler.js +30 -22
- package/dist/strand-membership-reconciler.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +40 -28
- package/dist/strand-membership-writer.js +212 -153
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-move.js +5 -2
- package/dist/strand-move.js.map +1 -1
- package/dist/strand-revocation-enforcer.d.ts +134 -60
- package/dist/strand-revocation-enforcer.js +144 -71
- package/dist/strand-revocation-enforcer.js.map +1 -1
- package/dist/strand-solicitation.d.ts +50 -39
- package/dist/strand-solicitation.js +10 -3
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-watcher.d.ts +80 -13
- package/dist/strand-watcher.js +152 -48
- package/dist/strand-watcher.js.map +1 -1
- package/dist/table-change-watch.d.ts +63 -0
- package/dist/table-change-watch.js +209 -0
- package/dist/table-change-watch.js.map +1 -0
- package/dist/timeout-scheduler.d.ts +4 -3
- package/dist/timeout-scheduler.js +4 -3
- package/dist/timeout-scheduler.js.map +1 -1
- package/dist/types.d.ts +90 -49
- package/dist/types.js +4 -4
- package/dist/types.js.map +1 -1
- package/package.json +8 -8
- package/src/cadre-invite-protocol.ts +119 -31
- package/src/cadre-node.ts +1282 -584
- package/src/control-cohort.ts +86 -9
- package/src/control-database.ts +848 -403
- package/src/control-formation-recorder.ts +359 -353
- package/src/control-protocol-guard.ts +16 -2
- package/src/control-schema-version.ts +101 -51
- package/src/control-schema.ts +16 -16
- package/src/control-write-retry.ts +91 -19
- package/src/delegate-admission.ts +351 -351
- package/src/enrolled-machine-store.ts +3 -3
- package/src/formation-token.ts +63 -0
- package/src/index.ts +30 -17
- package/src/joined-strand-store.ts +69 -24
- package/src/link-budget.ts +9 -7
- package/src/member-redemption.ts +20 -13
- package/src/membership-view.ts +144 -0
- package/src/owner-grant-protocol.ts +33 -18
- package/src/peer-addr-book.ts +4 -3
- package/src/peer-join-backfill.ts +971 -761
- package/src/pending-join-runner.ts +76 -53
- package/src/seed-bootstrap.ts +72 -67
- package/src/strand-database.ts +352 -322
- package/src/strand-first-sync-gate.ts +334 -134
- package/src/strand-formation-deadlines.ts +227 -227
- package/src/strand-formation-manager.ts +731 -725
- package/src/strand-formation-protocol.ts +122 -24
- package/src/strand-instance-manager.ts +2092 -2026
- package/src/strand-membership-reconciler.ts +33 -25
- package/src/strand-membership-writer.ts +2093 -2019
- package/src/strand-move.ts +5 -2
- package/src/strand-revocation-enforcer.ts +740 -654
- package/src/strand-solicitation.ts +61 -34
- package/src/strand-watcher.ts +175 -56
- package/src/table-change-watch.ts +285 -0
- package/src/timeout-scheduler.ts +29 -28
- package/src/types.ts +89 -51
|
@@ -1,353 +1,359 @@
|
|
|
1
|
-
import debug from 'debug';
|
|
2
|
-
import { FormationAbortedError, InvitationExhaustedError, type ControlDatabase } from './control-database.js';
|
|
3
|
-
import { mintStrandId } from './strand-id.js';
|
|
4
|
-
import {
|
|
5
|
-
createHttpFormationApprover,
|
|
6
|
-
verifyFormationApproval,
|
|
7
|
-
FormationApprovalError,
|
|
8
|
-
type FormationApprover,
|
|
9
|
-
type FormationApproval,
|
|
10
|
-
type FormationApprovalRequest
|
|
11
|
-
} from './formation-approval.js';
|
|
12
|
-
import type {
|
|
13
|
-
AuthorizedFormationUsage,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
* {@link
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
*
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*/
|
|
114
|
-
async
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
return {
|
|
238
|
-
record: async () => {
|
|
239
|
-
await this.controlDatabase.recordFormationUsage({
|
|
240
|
-
token, strandId, peerKey, peerSignature, usageStampId, disclosure, signal,
|
|
241
|
-
totalUses, ...approval,
|
|
242
|
-
});
|
|
243
|
-
log('Recorded formation usage: token=%s strand=%s', token, strandId);
|
|
244
|
-
}
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* Refuse a redemption whose invite
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
* - `StrandId` set
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
kind: '
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
* `
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
* a
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
1
|
+
import debug from 'debug';
|
|
2
|
+
import { FormationAbortedError, InvitationExhaustedError, type ControlDatabase } from './control-database.js';
|
|
3
|
+
import { mintStrandId } from './strand-id.js';
|
|
4
|
+
import {
|
|
5
|
+
createHttpFormationApprover,
|
|
6
|
+
verifyFormationApproval,
|
|
7
|
+
FormationApprovalError,
|
|
8
|
+
type FormationApprover,
|
|
9
|
+
type FormationApproval,
|
|
10
|
+
type FormationApprovalRequest
|
|
11
|
+
} from './formation-approval.js';
|
|
12
|
+
import type {
|
|
13
|
+
AuthorizedFormationUsage,
|
|
14
|
+
BoundHostStrand,
|
|
15
|
+
FormationUsageParams,
|
|
16
|
+
FormationUsageRecorder,
|
|
17
|
+
ResolvedHostStrand
|
|
18
|
+
} from './strand-solicitation.js';
|
|
19
|
+
import { isInviteExpired, type FormationInviteFacts, type FormationTokenRead } from './formation-token.js';
|
|
20
|
+
|
|
21
|
+
const log = debug('sereus:cadre:formation-recorder');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* {@link FormationUsageRecorder} backed by the real `CadreControl` tables.
|
|
25
|
+
*
|
|
26
|
+
* It reads a session's token once ({@link readToken}: the `FormationInvite` row and its
|
|
27
|
+
* `FormationUsage` count), and writes the consent row that records a redemption from that
|
|
28
|
+
* read. Two provisioning shapes are supported, keyed on whether the invite binds a host strand:
|
|
29
|
+
*
|
|
30
|
+
* - **Bound (provision-then-record):** the host strand already exists (owner-signed
|
|
31
|
+
* up front and named by the invite's `StrandId`), so {@link resolveStrand} reports it
|
|
32
|
+
* and {@link authorizeUsage} then its `record()` (or {@link recordUsage}, both at once)
|
|
33
|
+
* writes the consent row against that pre-existing strand
|
|
34
|
+
* (record-only) rather than inserting a new `Strand`.
|
|
35
|
+
* - **Unbound (responder-provisions):** the invite carries no `StrandId`, so
|
|
36
|
+
* {@link provisionAndRecord} mints a fresh strand and records consent against it
|
|
37
|
+
* ATOMICALLY (one `FormationUsage` row), closing the single-use hole the older
|
|
38
|
+
* never-record fallback left open.
|
|
39
|
+
*
|
|
40
|
+
* Every `CadreNode`'s formation responder uses it unless the embedder passes another
|
|
41
|
+
* recorder to `initializeStrandSolicitation`.
|
|
42
|
+
*
|
|
43
|
+
* Usage accounting follows the schema's `FormationUsage.Authorized` semantics:
|
|
44
|
+
* a null `TotalUses` means unlimited uses; otherwise the invite is "used up"
|
|
45
|
+
* once the recorded usage count reaches `TotalUses`.
|
|
46
|
+
*
|
|
47
|
+
* When the invite carries a `ValidationUrl`, both write paths first obtain an outside
|
|
48
|
+
* approval ({@link obtainApproval}) — the recorder is the one place where the nonce that is
|
|
49
|
+
* SIGNED and the nonce that is INSERTED are trivially the same value, since it also performs
|
|
50
|
+
* the write. That makes this class do network I/O; failures surface as
|
|
51
|
+
* {@link FormationApprovalError}s the manager maps to protocol rejection reasons. The bound
|
|
52
|
+
* path is split in two ({@link authorizeUsage}, then the returned `record()`) so the manager
|
|
53
|
+
* can issue the joiner's membership pass between the approval and the write; the handle
|
|
54
|
+
* carries the approved fields across, which keeps the signed and inserted nonce the same.
|
|
55
|
+
*/
|
|
56
|
+
export class ControlFormationUsageRecorder implements FormationUsageRecorder {
|
|
57
|
+
private readonly approver: FormationApprover;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `approver` defaults to the real HTTP hook client — deliberately ON: `CadreNode` builds this
|
|
61
|
+
* recorder for every node's responder with no options, so an opt-in approver would leave
|
|
62
|
+
* every real deployment unable to redeem a `ValidationUrl`-bearing invite. Tests inject a fake.
|
|
63
|
+
*/
|
|
64
|
+
constructor(
|
|
65
|
+
private readonly controlDatabase: ControlDatabase,
|
|
66
|
+
options?: { approver?: FormationApprover }
|
|
67
|
+
) {
|
|
68
|
+
this.approver = options?.approver ?? createHttpFormationApprover();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One `FormationInvite` read, then one `FormationUsage` count only when the invite is
|
|
73
|
+
* metered (`TotalUses` set) and unexpired: an unknown, expired or unlimited token has no
|
|
74
|
+
* seat to count. This early count refuses a spent token before anything costly (the outside
|
|
75
|
+
* approval call, the strand-database write that issues the membership invite); the count
|
|
76
|
+
* inside the write lock decides.
|
|
77
|
+
*/
|
|
78
|
+
async readToken(token: string): Promise<FormationTokenRead> {
|
|
79
|
+
const invite = await this.controlDatabase.queryFormationInvite(token);
|
|
80
|
+
const metered = invite !== null && invite.totalUses !== null && !isInviteExpired(invite, Date.now());
|
|
81
|
+
return {
|
|
82
|
+
token,
|
|
83
|
+
invite,
|
|
84
|
+
usesRecorded: metered ? await this.controlDatabase.countFormationUsage(token) : null,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Any `FormationInvite` row still unexpired and not fully consumed — the
|
|
90
|
+
* durable half of the control-network connection gate's "does this node
|
|
91
|
+
* expect a stranger?" question. Survives a restart and sees invites
|
|
92
|
+
* replicated in from sibling nodes of the same cadre.
|
|
93
|
+
*/
|
|
94
|
+
async hasOutstandingInvitation(): Promise<boolean> {
|
|
95
|
+
return await this.controlDatabase.hasOutstandingFormationInvite();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Obtain the approval material for ONE redemption, when the invite demands it.
|
|
100
|
+
*
|
|
101
|
+
* `validationUrl === null` → `{}`: no approval required. The `usageStampId` nonce is
|
|
102
|
+
* ALWAYS supplied by the caller — the JOINER mints it, signs its own consent over it, and
|
|
103
|
+
* sends it in the contact message — so the approver signs over the identical nonce in both
|
|
104
|
+
* paths. Two local pre-checks run before anything is written:
|
|
105
|
+
*
|
|
106
|
+
* - {@link verifyFormationApproval} — did the hook sign the exact fields we hold?
|
|
107
|
+
* - {@link ControlDatabase.queryValidationKeyStampId} — is the approval's key enrolled?
|
|
108
|
+
*
|
|
109
|
+
* Both pre-checks exist purely for legibility — they turn a bad approval into a named
|
|
110
|
+
* {@link FormationApprovalError} instead of an opaque `CHECK constraint failed: Authorized`
|
|
111
|
+
* at commit. The database re-verifies against the STORED `ValidationKey` row and remains
|
|
112
|
+
* the security authority; do not "simplify" the database check away in favour of these.
|
|
113
|
+
*/
|
|
114
|
+
private async obtainApproval(
|
|
115
|
+
request: Omit<FormationApprovalRequest, 'validationUrl'> & { validationUrl: string | null },
|
|
116
|
+
signal?: AbortSignal
|
|
117
|
+
): Promise<{ validationKey?: string; validationSignature?: string }> {
|
|
118
|
+
const { validationUrl, ...fields } = request;
|
|
119
|
+
if (validationUrl === null) {
|
|
120
|
+
return {};
|
|
121
|
+
}
|
|
122
|
+
if (signal?.aborted) {
|
|
123
|
+
throw new FormationAbortedError(fields.token, 'approval');
|
|
124
|
+
}
|
|
125
|
+
const fullRequest: FormationApprovalRequest = { ...fields, validationUrl };
|
|
126
|
+
const approval = await this.askApprover(fullRequest, signal);
|
|
127
|
+
if (!verifyFormationApproval(fullRequest, approval)) {
|
|
128
|
+
throw new FormationApprovalError(
|
|
129
|
+
'malformed',
|
|
130
|
+
`Approval signature does not verify over this redemption (token ${fields.token})`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (await this.controlDatabase.queryValidationKeyStampId(approval.validationKey) === null) {
|
|
134
|
+
throw new FormationApprovalError(
|
|
135
|
+
'unenrolled',
|
|
136
|
+
'Approval is signed by a key that is not an enrolled ValidationKey'
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
return approval;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Ask the approver, re-labelling a caller-abort as a {@link FormationAbortedError}.
|
|
144
|
+
*
|
|
145
|
+
* The HTTP client relays a caller-abort onto its own request and reports it as an
|
|
146
|
+
* `unavailable` {@link FormationApprovalError} — which the manager cannot tell from a
|
|
147
|
+
* genuinely dead hook, and so maps to a rejection reason and RETURNS. Every other abort on
|
|
148
|
+
* this path THROWS, which is what makes the manager rethrow and leave the reply to the
|
|
149
|
+
* listener's timeout path; a mid-hook abort belongs in the same class. The approval error
|
|
150
|
+
* is kept as `cause` so the transport-level detail is not lost.
|
|
151
|
+
*/
|
|
152
|
+
private async askApprover(
|
|
153
|
+
request: FormationApprovalRequest,
|
|
154
|
+
signal?: AbortSignal
|
|
155
|
+
): Promise<FormationApproval> {
|
|
156
|
+
try {
|
|
157
|
+
return await this.approver.requestApproval(request, signal);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
if (signal?.aborted) {
|
|
160
|
+
throw new FormationAbortedError(request.token, 'approval', { cause: err });
|
|
161
|
+
}
|
|
162
|
+
throw err;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Record consent against an **already-existing** host strand (record-only): the
|
|
168
|
+
* single `FormationUsage` insert auto-commits and the deferred `StrandExists`
|
|
169
|
+
* CHECK is satisfied by the pre-existing strand. This is the provision-then-record
|
|
170
|
+
* commitment — the strand was minted owner-signed up front, so we do NOT
|
|
171
|
+
* re-insert it (which would double-insert the same PK). `peerKey` is written as the
|
|
172
|
+
* usage `PeerKey`, which BOTH signed digests cover: the joiner's own consent signature
|
|
173
|
+
* (`peerSignature`, verified by the schema's `PeerConsented` CHECK) and — when the invite
|
|
174
|
+
* carries a `ValidationUrl` — the approver's sign-off, so it is the joiner an approval is
|
|
175
|
+
* spent on AND provably the joiner that agreed. Use
|
|
176
|
+
* {@link ControlDatabase.redeemInvitation} for the consent-creates-strand path instead.
|
|
177
|
+
*
|
|
178
|
+
* Reads the invite for itself (it is handed no session read) and the strand's stamp inside
|
|
179
|
+
* `recordFormationUsage`; otherwise authorizes as {@link authorizeUsage} does and writes in
|
|
180
|
+
* one step, for callers with nothing to do in between.
|
|
181
|
+
*/
|
|
182
|
+
async recordUsage(params: FormationUsageParams): Promise<void> {
|
|
183
|
+
if (params.signal?.aborted) {
|
|
184
|
+
throw new FormationAbortedError(params.token, 'usage recording');
|
|
185
|
+
}
|
|
186
|
+
const invite = await this.controlDatabase.queryFormationInvite(params.token);
|
|
187
|
+
await (await this.authorize(params, invite, undefined)).record();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Everything {@link recordUsage} does before its write, so the manager can issue the
|
|
192
|
+
* joiner's membership pass into the host strand only once the redemption is cleared, and
|
|
193
|
+
* spend the token only after that. Takes the invite's `ValidationUrl` and seat budget from
|
|
194
|
+
* the session's `read`, and the host strand's stamp from `host`, so it reads neither again.
|
|
195
|
+
*/
|
|
196
|
+
async authorizeUsage(
|
|
197
|
+
params: FormationUsageParams,
|
|
198
|
+
read: FormationTokenRead,
|
|
199
|
+
host: BoundHostStrand
|
|
200
|
+
): Promise<AuthorizedFormationUsage> {
|
|
201
|
+
return await this.authorize(params, read.invite, host.strandStampId);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Obtain the approval through {@link obtainApproval} when `invite` demands one, re-check the
|
|
206
|
+
* abort (the approval call may have been long), and after an obtained approval re-check the
|
|
207
|
+
* seat budget ({@link assertSeatFree}). The returned `record()` closes over the captured
|
|
208
|
+
* fields and approval, so the nonce the approver signed is the nonce inserted without the
|
|
209
|
+
* caller ever passing it back. `strandStampId` undefined makes `recordFormationUsage` read it.
|
|
210
|
+
*/
|
|
211
|
+
private async authorize(
|
|
212
|
+
params: FormationUsageParams,
|
|
213
|
+
invite: FormationInviteFacts | null,
|
|
214
|
+
strandStampId: string | undefined
|
|
215
|
+
): Promise<AuthorizedFormationUsage> {
|
|
216
|
+
const { token, peerKey, peerSignature, usageStampId, strandId, disclosure, signal } = params;
|
|
217
|
+
if (signal?.aborted) {
|
|
218
|
+
throw new FormationAbortedError(token, 'usage recording');
|
|
219
|
+
}
|
|
220
|
+
// NOTE: a missing invite row reads as "no approval required" here and in
|
|
221
|
+
// `provisionAndRecord`, so an invite that was absent from the session's read (or vanished
|
|
222
|
+
// before `recordUsage`'s) would be written unapproved. Safe only because the database's
|
|
223
|
+
// `FormationUsage.Authorized` CHECK demands a matching `FormationInvite` and rolls the write
|
|
224
|
+
// back regardless; if that CHECK ever loosens, reject on `invite === null` instead of
|
|
225
|
+
// defaulting to null.
|
|
226
|
+
const validationUrl = invite?.validationUrl ?? null;
|
|
227
|
+
const approval = await this.obtainApproval({
|
|
228
|
+
token, usageStampId, strandId, peerKey, disclosure, validationUrl,
|
|
229
|
+
}, signal);
|
|
230
|
+
if (signal?.aborted) {
|
|
231
|
+
throw new FormationAbortedError(token, 'usage recording');
|
|
232
|
+
}
|
|
233
|
+
const totalUses = invite?.totalUses ?? null;
|
|
234
|
+
if (validationUrl !== null) {
|
|
235
|
+
await this.assertSeatFree(token, totalUses);
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
record: async () => {
|
|
239
|
+
await this.controlDatabase.recordFormationUsage({
|
|
240
|
+
token, strandId, peerKey, peerSignature, usageStampId, disclosure, signal,
|
|
241
|
+
totalUses, strandStampId, ...approval,
|
|
242
|
+
});
|
|
243
|
+
log('Recorded formation usage: token=%s strand=%s', token, strandId);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Refuse a redemption whose invite lost its last seat while this redemption waited on its
|
|
250
|
+
* outside approval, BEFORE anything is written — the authoritative check is the one
|
|
251
|
+
* `recordFormationUsage` repeats inside its write lock. Runs only after an obtained approval:
|
|
252
|
+
* that call is the one long wait between the session's early count (`readToken`) and the
|
|
253
|
+
* membership pass, in which a same-node rival could take the seat unseen.
|
|
254
|
+
*
|
|
255
|
+
* NOTE: two redemptions that both pass the last count before issuance (this one after an
|
|
256
|
+
* approval, `readToken`'s without one) within the time one of them takes to issue its
|
|
257
|
+
* membership pass still both issue one, and the loser's pass is orphaned (expiry-bounded,
|
|
258
|
+
* never handed out). Holding the control write lock across issuance would close it, but
|
|
259
|
+
* `lockedWithRetry` re-runs locked bodies on transient failures (a re-run would double-issue)
|
|
260
|
+
* and every control write would stall behind a strand-database write. If orphaned passes from
|
|
261
|
+
* same-node races ever matter, reserve the seat before issuance.
|
|
262
|
+
*/
|
|
263
|
+
private async assertSeatFree(token: string, totalUses: number | null): Promise<void> {
|
|
264
|
+
if (totalUses === null) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const used = await this.controlDatabase.countFormationUsage(token);
|
|
268
|
+
if (used >= totalUses) {
|
|
269
|
+
throw new InvitationExhaustedError(token, used, totalUses);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Classify the host strand the read invite binds to (see {@link ResolvedHostStrand}), with
|
|
275
|
+
* one `Strand` read and no invite read:
|
|
276
|
+
*
|
|
277
|
+
* - no invite / no `StrandId` → `unbound` (responder-provisions path).
|
|
278
|
+
* - `StrandId` set AND the strand row is present → `bound`, carrying its type, its
|
|
279
|
+
* `StampId` (which the consent row names) and its `MemberPrivateKey` (the closed-strand
|
|
280
|
+
* read-gating secret) for delivery to a validated invitee.
|
|
281
|
+
* - `StrandId` set but the strand row is absent → `missing` (the host strand has not
|
|
282
|
+
* converged on this responder yet); the manager rejects cleanly instead of recording
|
|
283
|
+
* usage against a non-existent strand, which would fail the deferred `StrandExists`
|
|
284
|
+
* CHECK at commit and drop the result frame.
|
|
285
|
+
*/
|
|
286
|
+
async resolveStrand(read: FormationTokenRead): Promise<ResolvedHostStrand> {
|
|
287
|
+
const strandId = read.invite?.strandId;
|
|
288
|
+
if (!strandId) {
|
|
289
|
+
return { kind: 'unbound' };
|
|
290
|
+
}
|
|
291
|
+
const strand = await this.controlDatabase.queryStrandWithStamp(strandId);
|
|
292
|
+
if (!strand) {
|
|
293
|
+
return { kind: 'missing', strandId };
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
kind: 'bound',
|
|
297
|
+
strandId,
|
|
298
|
+
strandType: strand.Type,
|
|
299
|
+
strandStampId: strand.StampId,
|
|
300
|
+
memberPrivateKey: strand.MemberPrivateKey ?? null,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Provision a NEW strand for an UNBOUND invite and record consent against it in ONE
|
|
306
|
+
* transaction (the responder-provisions fallback, now single-use-enforced).
|
|
307
|
+
*
|
|
308
|
+
* Mints a fresh, globally-unique strand id with {@link mintStrandId} — an
|
|
309
|
+
* unguessable CSPRNG id, and one `strand-id.ts` has already checked is usable as a
|
|
310
|
+
* storage scope key and network name — then delegates to
|
|
311
|
+
* {@link ControlDatabase.redeemInvitation}, whose
|
|
312
|
+
* single `begin … commit` inserts the consent-authorized `Strand` row AND the matching
|
|
313
|
+
* `FormationUsage` row together (both deferred CHECKs see both rows at commit). That one
|
|
314
|
+
* `FormationUsage` row makes the unbound redemption single-use exactly like the bound
|
|
315
|
+
* path: the next redemption of a `TotalUses:1` invite sees `count 1 >= 1` and is rejected.
|
|
316
|
+
*
|
|
317
|
+
* The strand is open (`'o'`) — an unbound responder-provisioned strand has no membership
|
|
318
|
+
* key — so the returned `memberPrivateKey` is null. `sAppId` is accepted for parity/future
|
|
319
|
+
* use; `redeemInvitation` does not currently thread it into the `Strand` row. The invite's
|
|
320
|
+
* `ValidationUrl` and seat budget come from the session's `read`.
|
|
321
|
+
*
|
|
322
|
+
* A concurrent redemption of the same single-use invite still THROWS for the loser — the
|
|
323
|
+
* manager maps that to a clean protocol rejection (it never lets the dropped insert close
|
|
324
|
+
* the stream silently): the local write queue serializes the two writes, so the loser's
|
|
325
|
+
* seat check reads the winner's committed row against the cap and is refused as a named
|
|
326
|
+
* `InvitationExhaustedError`. A CROSS-node race is not a collision to retry at all — each
|
|
327
|
+
* redemption writes under its own `UsageStampId`, both rows land and survive convergence,
|
|
328
|
+
* and the cap can over-admit by the number of concurrent redeemers. That over-admission is
|
|
329
|
+
* the accepted trade (visible in the append-only record, reversible by owner-gated
|
|
330
|
+
* removal), chosen over a fought-over shared key whose failure mode was the silent loss of
|
|
331
|
+
* a consented join.
|
|
332
|
+
*/
|
|
333
|
+
async provisionAndRecord(params: {
|
|
334
|
+
token: string;
|
|
335
|
+
peerKey: string;
|
|
336
|
+
peerSignature: string;
|
|
337
|
+
usageStampId: string;
|
|
338
|
+
sAppId: string;
|
|
339
|
+
disclosure: string;
|
|
340
|
+
signal?: AbortSignal;
|
|
341
|
+
}, read: FormationTokenRead): Promise<{ strandId: string; memberPrivateKey: string | null }> {
|
|
342
|
+
const { token, peerKey, peerSignature, usageStampId, disclosure, signal } = params;
|
|
343
|
+
if (signal?.aborted) {
|
|
344
|
+
throw new FormationAbortedError(token, 'redemption');
|
|
345
|
+
}
|
|
346
|
+
const strandId = mintStrandId();
|
|
347
|
+
const invite = read.invite;
|
|
348
|
+
const approval = await this.obtainApproval({
|
|
349
|
+
token, usageStampId, strandId, peerKey, disclosure,
|
|
350
|
+
validationUrl: invite?.validationUrl ?? null,
|
|
351
|
+
}, signal);
|
|
352
|
+
await this.controlDatabase.redeemInvitation({
|
|
353
|
+
token, strandId, peerKey, peerSignature, usageStampId, disclosure, signal,
|
|
354
|
+
totalUses: invite?.totalUses ?? null, ...approval,
|
|
355
|
+
});
|
|
356
|
+
log('Provisioned + recorded unbound strand: token=%s strand=%s', token, strandId);
|
|
357
|
+
return { strandId, memberPrivateKey: null };
|
|
358
|
+
}
|
|
359
|
+
}
|