@serfab/cadre-core 0.10.0 → 0.12.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 (145) hide show
  1. package/README.md +255 -255
  2. package/dist/announce-addrs.d.ts +49 -0
  3. package/dist/announce-addrs.js +78 -0
  4. package/dist/announce-addrs.js.map +1 -0
  5. package/dist/cadre-node.d.ts +484 -58
  6. package/dist/cadre-node.js +953 -182
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/control-cohort.d.ts +26 -0
  9. package/dist/control-cohort.js +26 -0
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +222 -125
  12. package/dist/control-database.js +356 -266
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +8 -7
  15. package/dist/control-formation-recorder.js +8 -7
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-read-retry.d.ts +103 -0
  18. package/dist/control-read-retry.js +145 -0
  19. package/dist/control-read-retry.js.map +1 -0
  20. package/dist/control-retry.d.ts +101 -0
  21. package/dist/control-retry.js +108 -0
  22. package/dist/control-retry.js.map +1 -0
  23. package/dist/control-schema.d.ts +1 -1
  24. package/dist/control-schema.js +805 -774
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +65 -31
  27. package/dist/control-write-retry.js +33 -103
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/delegate-admission.d.ts +17 -15
  30. package/dist/delegate-admission.js +17 -15
  31. package/dist/delegate-admission.js.map +1 -1
  32. package/dist/index.d.ts +7 -5
  33. package/dist/index.js +18 -9
  34. package/dist/index.js.map +1 -1
  35. package/dist/membership-connection-gater.d.ts +211 -25
  36. package/dist/membership-connection-gater.js +309 -29
  37. package/dist/membership-connection-gater.js.map +1 -1
  38. package/dist/peer-addr-book.d.ts +92 -0
  39. package/dist/peer-addr-book.js +220 -0
  40. package/dist/peer-addr-book.js.map +1 -0
  41. package/dist/peer-record.d.ts +26 -0
  42. package/dist/peer-record.js +32 -0
  43. package/dist/peer-record.js.map +1 -1
  44. package/dist/relay-addrs.d.ts +76 -26
  45. package/dist/relay-addrs.js +115 -27
  46. package/dist/relay-addrs.js.map +1 -1
  47. package/dist/relay-reservation.d.ts +20 -15
  48. package/dist/relay-reservation.js +18 -14
  49. package/dist/relay-reservation.js.map +1 -1
  50. package/dist/seed-bootstrap.d.ts +7 -0
  51. package/dist/seed-bootstrap.js +15 -13
  52. package/dist/seed-bootstrap.js.map +1 -1
  53. package/dist/strand-addr-protocol.js +16 -0
  54. package/dist/strand-addr-protocol.js.map +1 -1
  55. package/dist/strand-backfill.js +13 -4
  56. package/dist/strand-backfill.js.map +1 -1
  57. package/dist/strand-cohort.d.ts +4 -37
  58. package/dist/strand-cohort.js +4 -15
  59. package/dist/strand-cohort.js.map +1 -1
  60. package/dist/strand-database.d.ts +11 -16
  61. package/dist/strand-database.js +19 -7
  62. package/dist/strand-database.js.map +1 -1
  63. package/dist/strand-formation-manager.d.ts +18 -17
  64. package/dist/strand-formation-manager.js +25 -27
  65. package/dist/strand-formation-manager.js.map +1 -1
  66. package/dist/strand-formation-protocol.d.ts +7 -7
  67. package/dist/strand-formation-protocol.js +8 -8
  68. package/dist/strand-formation-protocol.js.map +1 -1
  69. package/dist/strand-instance-manager.d.ts +53 -21
  70. package/dist/strand-instance-manager.js +128 -37
  71. package/dist/strand-instance-manager.js.map +1 -1
  72. package/dist/strand-membership-writer.d.ts +113 -19
  73. package/dist/strand-membership-writer.js +219 -51
  74. package/dist/strand-membership-writer.js.map +1 -1
  75. package/dist/strand-solicitation.d.ts +3 -3
  76. package/dist/strand-solicitation.js +4 -5
  77. package/dist/strand-solicitation.js.map +1 -1
  78. package/dist/strand-wake-protocol.d.ts +47 -2
  79. package/dist/strand-wake-protocol.js +76 -6
  80. package/dist/strand-wake-protocol.js.map +1 -1
  81. package/dist/types.d.ts +107 -54
  82. package/dist/types.js.map +1 -1
  83. package/package.json +8 -7
  84. package/src/announce-addrs.ts +93 -0
  85. package/src/arachnode-stub.ts +159 -159
  86. package/src/bootstrap-peer-store-file.ts +44 -44
  87. package/src/bootstrap-peer-store.ts +172 -172
  88. package/src/cadre-node.ts +1026 -194
  89. package/src/canonical-json.ts +30 -30
  90. package/src/control-authorization.ts +114 -114
  91. package/src/control-cohort.ts +130 -103
  92. package/src/control-database.ts +376 -298
  93. package/src/control-formation-recorder.ts +305 -304
  94. package/src/control-read-retry.ts +167 -0
  95. package/src/control-retry.ts +190 -0
  96. package/src/control-schema.ts +817 -786
  97. package/src/control-stream.ts +205 -205
  98. package/src/control-write-retry.ts +383 -473
  99. package/src/delegate-admission.ts +304 -302
  100. package/src/device-token.ts +94 -94
  101. package/src/diagnostics/connection-path.ts +277 -277
  102. package/src/diagnostics/webrtc-turn-tracker.ts +222 -222
  103. package/src/ed25519-key.ts +134 -134
  104. package/src/enrollment.ts +199 -199
  105. package/src/file-durable-slot.ts +61 -61
  106. package/src/formation-approval.ts +604 -604
  107. package/src/fs-atomic.ts +107 -107
  108. package/src/hibernation-manager.ts +437 -437
  109. package/src/identity-key.ts +109 -109
  110. package/src/index.ts +435 -406
  111. package/src/key-store-file.ts +132 -132
  112. package/src/key-store.ts +99 -99
  113. package/src/membership-connection-gater.ts +368 -39
  114. package/src/node-local-snapshot.ts +260 -260
  115. package/src/peer-addr-book.ts +250 -0
  116. package/src/peer-authorization.ts +259 -259
  117. package/src/peer-record.ts +197 -164
  118. package/src/push-credentials.ts +84 -84
  119. package/src/push-node.ts +64 -64
  120. package/src/push-notifier-apns.ts +247 -247
  121. package/src/push-notifier-fcm.ts +220 -220
  122. package/src/push-notifier-shared.ts +28 -28
  123. package/src/push-notifier.ts +52 -52
  124. package/src/relay-addrs.ts +134 -27
  125. package/src/relay-reservation.ts +22 -17
  126. package/src/seed-bootstrap.ts +15 -15
  127. package/src/seed-trust-policy.ts +140 -140
  128. package/src/strand-addr-protocol.ts +458 -441
  129. package/src/strand-backfill.ts +28 -5
  130. package/src/strand-cohort.ts +38 -77
  131. package/src/strand-database.ts +202 -204
  132. package/src/strand-formation-manager.ts +26 -28
  133. package/src/strand-formation-protocol.ts +692 -692
  134. package/src/strand-instance-manager.ts +636 -553
  135. package/src/strand-member-key.ts +39 -39
  136. package/src/strand-member-registry.ts +182 -182
  137. package/src/strand-membership-writer.ts +1627 -1430
  138. package/src/strand-solicitation.ts +471 -472
  139. package/src/strand-transport-key.ts +58 -58
  140. package/src/strand-wake-payload.ts +32 -32
  141. package/src/strand-wake-protocol.ts +400 -314
  142. package/src/strand-watcher.ts +314 -314
  143. package/src/trusted-owner-store-file.ts +41 -41
  144. package/src/trusted-owner-store.ts +171 -171
  145. package/src/types.ts +1354 -1303
@@ -1,304 +1,305 @@
1
- import debug from 'debug';
2
- import { randomBytes } from '@optimystic/quereus-plugin-crypto';
3
- import { FormationAbortedError, type ControlDatabase } from './control-database.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 { FormationUsageRecorder, ResolvedHostStrand } from './strand-solicitation.js';
13
- import type { OpenInvitation } from './types.js';
14
-
15
- const log = debug('sereus:cadre:formation-recorder');
16
-
17
- /** Far-future sentinel for an invitation that never expires (max valid JS Date). */
18
- const NEVER_EXPIRES = new Date(8640000000000000);
19
-
20
- /**
21
- * {@link FormationUsageRecorder} backed by the real `CadreControl` tables.
22
- *
23
- * It reads `FormationInvite` / `FormationUsage` to answer token-validity and
24
- * usage questions, and writes the consent row that records a redemption. Two
25
- * provisioning shapes are supported, keyed on whether the invite binds a host strand:
26
- *
27
- * - **Bound (provision-then-record):** the host strand already exists (owner-signed
28
- * up front and named by the invite's `StrandId`), so {@link resolveStrand} reports it
29
- * and {@link recordUsage} writes the consent row against that pre-existing strand
30
- * (record-only) rather than inserting a new `Strand`.
31
- * - **Unbound (responder-provisions):** the invite carries no `StrandId`, so
32
- * {@link provisionAndRecord} mints a fresh strand and records consent against it
33
- * ATOMICALLY (one `FormationUsage` row), closing the single-use hole the older
34
- * never-record fallback left open.
35
- *
36
- * This replaces the in-memory stubs used by the formation tests so the consent path
37
- * is exercised against the persisted control network.
38
- *
39
- * Usage accounting follows the schema's `FormationUsage.Authorized` semantics:
40
- * a null `TotalUses` means unlimited uses; otherwise the invite is "used up"
41
- * once the recorded usage count reaches `TotalUses`.
42
- *
43
- * When the invite carries a `ValidationUrl`, both write paths first obtain an outside
44
- * approval ({@link obtainApproval}) — the recorder is the one place where the nonce that is
45
- * SIGNED and the nonce that is INSERTED are trivially the same value, since it also performs
46
- * the write. That makes this class do network I/O; failures surface as
47
- * {@link FormationApprovalError}s the manager maps to protocol rejection reasons.
48
- */
49
- export class ControlFormationUsageRecorder implements FormationUsageRecorder {
50
- private readonly approver: FormationApprover;
51
-
52
- /**
53
- * `approver` defaults to the real HTTP hook client — deliberately ON: this recorder is
54
- * constructed by the reference apps and the integration harness, not by `CadreNode`, so an
55
- * opt-in approver would leave every real deployment unable to redeem a
56
- * `ValidationUrl`-bearing invite. Tests inject a fake.
57
- */
58
- constructor(
59
- private readonly controlDatabase: ControlDatabase,
60
- options?: { approver?: FormationApprover }
61
- ) {
62
- this.approver = options?.approver ?? createHttpFormationApprover();
63
- }
64
-
65
- /** A token is valid when a matching, unexpired `FormationInvite` exists. */
66
- async isTokenValid(token: string): Promise<{ valid: boolean; invitation?: OpenInvitation }> {
67
- const invite = await this.controlDatabase.queryFormationInvite(token);
68
- if (!invite) {
69
- return { valid: false };
70
- }
71
- if (invite.expiresAtMs !== null && invite.expiresAtMs <= Date.now()) {
72
- log('Token expired: %s', token);
73
- return { valid: false };
74
- }
75
- return {
76
- valid: true,
77
- invitation: {
78
- token: invite.token,
79
- sAppId: invite.sAppId,
80
- expiration: invite.expiresAtMs !== null ? new Date(invite.expiresAtMs) : NEVER_EXPIRES,
81
- bootstrap: [],
82
- },
83
- };
84
- }
85
-
86
- /**
87
- * A token is "used" when its recorded usage count has reached the invite's
88
- * `TotalUses`. A null `TotalUses` (unlimited) is never used up; an unknown
89
- * token is reported not-used (validity is handled by {@link isTokenValid}).
90
- */
91
- async isTokenUsed(token: string): Promise<boolean> {
92
- const invite = await this.controlDatabase.queryFormationInvite(token);
93
- if (!invite || invite.totalUses === null) {
94
- return false;
95
- }
96
- const uses = await this.controlDatabase.countFormationUsage(token);
97
- return uses >= invite.totalUses;
98
- }
99
-
100
- /**
101
- * Any `FormationInvite` row still unexpired and not fully consumed — the
102
- * durable half of the control-network connection gate's "does this node
103
- * expect a stranger?" question. Survives a restart and sees invites
104
- * replicated in from sibling nodes of the same cadre.
105
- */
106
- async hasOutstandingInvitation(): Promise<boolean> {
107
- return await this.controlDatabase.hasOutstandingFormationInvite();
108
- }
109
-
110
- /**
111
- * Obtain the approval material for ONE redemption, when the invite demands it.
112
- *
113
- * `validationUrl === null` → `{}`: no approval required. The `usageStampId` nonce is
114
- * ALWAYS supplied by the caller — the JOINER mints it, signs its own consent over it, and
115
- * sends it in the contact message — so the approver signs over the identical nonce in both
116
- * paths. Two local pre-checks run before anything is written:
117
- *
118
- * - {@link verifyFormationApproval} — did the hook sign the exact fields we hold?
119
- * - {@link ControlDatabase.queryValidationKeyStampId} — is the approval's key enrolled?
120
- *
121
- * Both pre-checks exist purely for legibility — they turn a bad approval into a named
122
- * {@link FormationApprovalError} instead of an opaque `CHECK constraint failed: Authorized`
123
- * at commit. The database re-verifies against the STORED `ValidationKey` row and remains
124
- * the security authority; do not "simplify" the database check away in favour of these.
125
- */
126
- private async obtainApproval(
127
- request: Omit<FormationApprovalRequest, 'validationUrl'> & { validationUrl: string | null },
128
- signal?: AbortSignal
129
- ): Promise<{ validationKey?: string; validationSignature?: string }> {
130
- const { validationUrl, ...fields } = request;
131
- if (validationUrl === null) {
132
- return {};
133
- }
134
- if (signal?.aborted) {
135
- throw new FormationAbortedError(fields.token, 'approval');
136
- }
137
- const fullRequest: FormationApprovalRequest = { ...fields, validationUrl };
138
- const approval = await this.askApprover(fullRequest, signal);
139
- if (!verifyFormationApproval(fullRequest, approval)) {
140
- throw new FormationApprovalError(
141
- 'malformed',
142
- `Approval signature does not verify over this redemption (token ${fields.token})`
143
- );
144
- }
145
- if (await this.controlDatabase.queryValidationKeyStampId(approval.validationKey) === null) {
146
- throw new FormationApprovalError(
147
- 'unenrolled',
148
- 'Approval is signed by a key that is not an enrolled ValidationKey'
149
- );
150
- }
151
- return approval;
152
- }
153
-
154
- /**
155
- * Ask the approver, re-labelling a caller-abort as a {@link FormationAbortedError}.
156
- *
157
- * The HTTP client relays a caller-abort onto its own request and reports it as an
158
- * `unavailable` {@link FormationApprovalError} — which the manager cannot tell from a
159
- * genuinely dead hook, and so maps to a rejection reason and RETURNS. Every other abort on
160
- * this path THROWS, which is what makes the manager rethrow and leave the reply to the
161
- * listener's timeout path; a mid-hook abort belongs in the same class. The approval error
162
- * is kept as `cause` so the transport-level detail is not lost.
163
- */
164
- private async askApprover(
165
- request: FormationApprovalRequest,
166
- signal?: AbortSignal
167
- ): Promise<FormationApproval> {
168
- try {
169
- return await this.approver.requestApproval(request, signal);
170
- } catch (err) {
171
- if (signal?.aborted) {
172
- throw new FormationAbortedError(request.token, 'approval', { cause: err });
173
- }
174
- throw err;
175
- }
176
- }
177
-
178
- /**
179
- * Record consent against an **already-existing** host strand (record-only): the
180
- * single `FormationUsage` insert auto-commits and the deferred `StrandExists`
181
- * CHECK is satisfied by the pre-existing strand. This is the provision-then-record
182
- * commitment — the strand was minted owner-signed up front, so we do NOT
183
- * re-insert it (which would double-insert the same PK). `peerKey` is written as the
184
- * usage `PeerKey`, which BOTH signed digests cover: the joiner's own consent signature
185
- * (`peerSignature`, verified by the schema's `PeerConsented` CHECK) and — when the invite
186
- * carries a `ValidationUrl` — the approver's sign-off, so it is the joiner an approval is
187
- * spent on AND provably the joiner that agreed. Use
188
- * {@link ControlDatabase.redeemInvitation} for the consent-creates-strand path instead.
189
- *
190
- * Reads the invite first (one extra read per redemption) to learn its `ValidationUrl`, and
191
- * obtains the approval through {@link obtainApproval} when one is demanded.
192
- */
193
- async recordUsage(params: {
194
- token: string;
195
- peerKey: string;
196
- peerSignature: string;
197
- usageStampId: string;
198
- strandId: string;
199
- disclosure: string;
200
- signal?: AbortSignal;
201
- }): Promise<void> {
202
- const { token, peerKey, peerSignature, usageStampId, strandId, disclosure, signal } = params;
203
- if (signal?.aborted) {
204
- throw new FormationAbortedError(token, 'usage recording');
205
- }
206
- const invite = await this.controlDatabase.queryFormationInvite(token);
207
- // NOTE: a missing invite row reads as "no approval required" here and in
208
- // `provisionAndRecord`, so an invite that vanished between `resolveStrand` and this read
209
- // would be written unapproved. Safe only because the database's `FormationUsage.Authorized`
210
- // CHECK demands a matching `FormationInvite` and rolls the write back regardless; if that
211
- // CHECK ever loosens, reject on `invite === null` instead of defaulting to null.
212
- const approval = await this.obtainApproval({
213
- token, usageStampId, strandId, peerKey, disclosure,
214
- validationUrl: invite?.validationUrl ?? null,
215
- }, signal);
216
- await this.controlDatabase.recordFormationUsage({
217
- token, strandId, peerKey, peerSignature, usageStampId, disclosure, signal,
218
- totalUses: invite?.totalUses ?? null, ...approval,
219
- });
220
- log('Recorded formation usage: token=%s strand=%s', token, strandId);
221
- }
222
-
223
- /**
224
- * Classify the host strand this invite binds to (see {@link ResolvedHostStrand}):
225
- *
226
- * - no invite / no `StrandId` → `unbound` (responder-provisions path).
227
- * - `StrandId` set AND the strand row is present → `bound`, carrying its
228
- * `MemberPrivateKey` (the closed-strand read-gating secret) for delivery to a
229
- * validated invitee.
230
- * - `StrandId` set but the strand row is absent → `missing` (the host strand has not
231
- * converged on this responder yet); the manager rejects cleanly instead of recording
232
- * usage against a non-existent strand, which would fail the deferred `StrandExists`
233
- * CHECK at commit and drop the result frame.
234
- */
235
- async resolveStrand(token: string): Promise<ResolvedHostStrand> {
236
- const invite = await this.controlDatabase.queryFormationInvite(token);
237
- if (!invite || !invite.strandId) {
238
- return { kind: 'unbound' };
239
- }
240
- const strand = await this.controlDatabase.queryStrand(invite.strandId);
241
- if (!strand) {
242
- return { kind: 'missing', strandId: invite.strandId };
243
- }
244
- return {
245
- kind: 'bound',
246
- strandId: invite.strandId,
247
- memberPrivateKey: strand.MemberPrivateKey ?? null,
248
- };
249
- }
250
-
251
- /**
252
- * Provision a NEW strand for an UNBOUND invite and record consent against it in ONE
253
- * transaction (the responder-provisions fallback, now single-use-enforced).
254
- *
255
- * Mints a fresh, globally-unique strand id from {@link randomBytes} — the same
256
- * cross-platform CSPRNG `control-database`'s `generateStampId` uses, NOT
257
- * `crypto.randomUUID` / `Date.now` / `Math.random` (not uniformly available across
258
- * node/browser/RN) — then delegates to {@link ControlDatabase.redeemInvitation}, whose
259
- * single `begin … commit` inserts the consent-authorized `Strand` row AND the matching
260
- * `FormationUsage` row together (both deferred CHECKs see both rows at commit). That one
261
- * `FormationUsage` row makes the unbound redemption single-use exactly like the bound
262
- * path: the next redemption of a `TotalUses:1` invite sees `count 1 >= 1` and is rejected.
263
- *
264
- * The strand is open (`'o'`) — an unbound responder-provisioned strand has no membership
265
- * key — so the returned `memberPrivateKey` is null. `sAppId` is accepted for parity/future
266
- * use; `redeemInvitation` does not currently thread it into the `Strand` row.
267
- *
268
- * A concurrent redemption of the same single-use invite still THROWS for the loser — the
269
- * manager maps that to a clean protocol rejection (it never lets the dropped insert close
270
- * the stream silently) — but no longer via a `(Token, UseNumber)` primary-key collision on
271
- * this node: `redeemInvitation` reads the use number INSIDE the write lock, so two local
272
- * redemptions serialize and the second one reads the number the first already committed.
273
- * It is then refused because that number is past the invite's seat budget, reported as a
274
- * named `InvitationExhaustedError` on every attempt (including the first, which is what a
275
- * same-node race hits). A PK collision is still what a cross-node race surfaces as, and that
276
- * one IS retried under a fresh use number rather than thrown.
277
- */
278
- async provisionAndRecord(params: {
279
- token: string;
280
- peerKey: string;
281
- peerSignature: string;
282
- usageStampId: string;
283
- sAppId: string;
284
- disclosure: string;
285
- signal?: AbortSignal;
286
- }): Promise<{ strandId: string; memberPrivateKey: string | null }> {
287
- const { token, peerKey, peerSignature, usageStampId, disclosure, signal } = params;
288
- if (signal?.aborted) {
289
- throw new FormationAbortedError(token, 'redemption');
290
- }
291
- const strandId = `strand-${randomBytes(128, 'hex') as string}`;
292
- const invite = await this.controlDatabase.queryFormationInvite(token);
293
- const approval = await this.obtainApproval({
294
- token, usageStampId, strandId, peerKey, disclosure,
295
- validationUrl: invite?.validationUrl ?? null,
296
- }, signal);
297
- await this.controlDatabase.redeemInvitation({
298
- token, strandId, peerKey, peerSignature, usageStampId, disclosure, signal,
299
- totalUses: invite?.totalUses ?? null, ...approval,
300
- });
301
- log('Provisioned + recorded unbound strand: token=%s strand=%s', token, strandId);
302
- return { strandId, memberPrivateKey: null };
303
- }
304
- }
1
+ import debug from 'debug';
2
+ import { randomBytes } from '@optimystic/quereus-plugin-crypto';
3
+ import { FormationAbortedError, type ControlDatabase } from './control-database.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 { FormationUsageRecorder, ResolvedHostStrand } from './strand-solicitation.js';
13
+ import type { OpenInvitation } from './types.js';
14
+
15
+ const log = debug('sereus:cadre:formation-recorder');
16
+
17
+ /** Far-future sentinel for an invitation that never expires (max valid JS Date). */
18
+ const NEVER_EXPIRES = new Date(8640000000000000);
19
+
20
+ /**
21
+ * {@link FormationUsageRecorder} backed by the real `CadreControl` tables.
22
+ *
23
+ * It reads `FormationInvite` / `FormationUsage` to answer token-validity and
24
+ * usage questions, and writes the consent row that records a redemption. Two
25
+ * provisioning shapes are supported, keyed on whether the invite binds a host strand:
26
+ *
27
+ * - **Bound (provision-then-record):** the host strand already exists (owner-signed
28
+ * up front and named by the invite's `StrandId`), so {@link resolveStrand} reports it
29
+ * and {@link recordUsage} writes the consent row against that pre-existing strand
30
+ * (record-only) rather than inserting a new `Strand`.
31
+ * - **Unbound (responder-provisions):** the invite carries no `StrandId`, so
32
+ * {@link provisionAndRecord} mints a fresh strand and records consent against it
33
+ * ATOMICALLY (one `FormationUsage` row), closing the single-use hole the older
34
+ * never-record fallback left open.
35
+ *
36
+ * This replaces the in-memory stubs used by the formation tests so the consent path
37
+ * is exercised against the persisted control network.
38
+ *
39
+ * Usage accounting follows the schema's `FormationUsage.Authorized` semantics:
40
+ * a null `TotalUses` means unlimited uses; otherwise the invite is "used up"
41
+ * once the recorded usage count reaches `TotalUses`.
42
+ *
43
+ * When the invite carries a `ValidationUrl`, both write paths first obtain an outside
44
+ * approval ({@link obtainApproval}) — the recorder is the one place where the nonce that is
45
+ * SIGNED and the nonce that is INSERTED are trivially the same value, since it also performs
46
+ * the write. That makes this class do network I/O; failures surface as
47
+ * {@link FormationApprovalError}s the manager maps to protocol rejection reasons.
48
+ */
49
+ export class ControlFormationUsageRecorder implements FormationUsageRecorder {
50
+ private readonly approver: FormationApprover;
51
+
52
+ /**
53
+ * `approver` defaults to the real HTTP hook client — deliberately ON: this recorder is
54
+ * constructed by the reference apps and the integration harness, not by `CadreNode`, so an
55
+ * opt-in approver would leave every real deployment unable to redeem a
56
+ * `ValidationUrl`-bearing invite. Tests inject a fake.
57
+ */
58
+ constructor(
59
+ private readonly controlDatabase: ControlDatabase,
60
+ options?: { approver?: FormationApprover }
61
+ ) {
62
+ this.approver = options?.approver ?? createHttpFormationApprover();
63
+ }
64
+
65
+ /** A token is valid when a matching, unexpired `FormationInvite` exists. */
66
+ async isTokenValid(token: string): Promise<{ valid: boolean; invitation?: OpenInvitation }> {
67
+ const invite = await this.controlDatabase.queryFormationInvite(token);
68
+ if (!invite) {
69
+ return { valid: false };
70
+ }
71
+ if (invite.expiresAtMs !== null && invite.expiresAtMs <= Date.now()) {
72
+ log('Token expired: %s', token);
73
+ return { valid: false };
74
+ }
75
+ return {
76
+ valid: true,
77
+ invitation: {
78
+ token: invite.token,
79
+ sAppId: invite.sAppId,
80
+ expiration: invite.expiresAtMs !== null ? new Date(invite.expiresAtMs) : NEVER_EXPIRES,
81
+ bootstrap: [],
82
+ },
83
+ };
84
+ }
85
+
86
+ /**
87
+ * A token is "used" when its recorded usage count has reached the invite's
88
+ * `TotalUses`. A null `TotalUses` (unlimited) is never used up; an unknown
89
+ * token is reported not-used (validity is handled by {@link isTokenValid}).
90
+ */
91
+ async isTokenUsed(token: string): Promise<boolean> {
92
+ const invite = await this.controlDatabase.queryFormationInvite(token);
93
+ if (!invite || invite.totalUses === null) {
94
+ return false;
95
+ }
96
+ const uses = await this.controlDatabase.countFormationUsage(token);
97
+ return uses >= invite.totalUses;
98
+ }
99
+
100
+ /**
101
+ * Any `FormationInvite` row still unexpired and not fully consumed — the
102
+ * durable half of the control-network connection gate's "does this node
103
+ * expect a stranger?" question. Survives a restart and sees invites
104
+ * replicated in from sibling nodes of the same cadre.
105
+ */
106
+ async hasOutstandingInvitation(): Promise<boolean> {
107
+ return await this.controlDatabase.hasOutstandingFormationInvite();
108
+ }
109
+
110
+ /**
111
+ * Obtain the approval material for ONE redemption, when the invite demands it.
112
+ *
113
+ * `validationUrl === null` → `{}`: no approval required. The `usageStampId` nonce is
114
+ * ALWAYS supplied by the caller — the JOINER mints it, signs its own consent over it, and
115
+ * sends it in the contact message — so the approver signs over the identical nonce in both
116
+ * paths. Two local pre-checks run before anything is written:
117
+ *
118
+ * - {@link verifyFormationApproval} — did the hook sign the exact fields we hold?
119
+ * - {@link ControlDatabase.queryValidationKeyStampId} — is the approval's key enrolled?
120
+ *
121
+ * Both pre-checks exist purely for legibility — they turn a bad approval into a named
122
+ * {@link FormationApprovalError} instead of an opaque `CHECK constraint failed: Authorized`
123
+ * at commit. The database re-verifies against the STORED `ValidationKey` row and remains
124
+ * the security authority; do not "simplify" the database check away in favour of these.
125
+ */
126
+ private async obtainApproval(
127
+ request: Omit<FormationApprovalRequest, 'validationUrl'> & { validationUrl: string | null },
128
+ signal?: AbortSignal
129
+ ): Promise<{ validationKey?: string; validationSignature?: string }> {
130
+ const { validationUrl, ...fields } = request;
131
+ if (validationUrl === null) {
132
+ return {};
133
+ }
134
+ if (signal?.aborted) {
135
+ throw new FormationAbortedError(fields.token, 'approval');
136
+ }
137
+ const fullRequest: FormationApprovalRequest = { ...fields, validationUrl };
138
+ const approval = await this.askApprover(fullRequest, signal);
139
+ if (!verifyFormationApproval(fullRequest, approval)) {
140
+ throw new FormationApprovalError(
141
+ 'malformed',
142
+ `Approval signature does not verify over this redemption (token ${fields.token})`
143
+ );
144
+ }
145
+ if (await this.controlDatabase.queryValidationKeyStampId(approval.validationKey) === null) {
146
+ throw new FormationApprovalError(
147
+ 'unenrolled',
148
+ 'Approval is signed by a key that is not an enrolled ValidationKey'
149
+ );
150
+ }
151
+ return approval;
152
+ }
153
+
154
+ /**
155
+ * Ask the approver, re-labelling a caller-abort as a {@link FormationAbortedError}.
156
+ *
157
+ * The HTTP client relays a caller-abort onto its own request and reports it as an
158
+ * `unavailable` {@link FormationApprovalError} — which the manager cannot tell from a
159
+ * genuinely dead hook, and so maps to a rejection reason and RETURNS. Every other abort on
160
+ * this path THROWS, which is what makes the manager rethrow and leave the reply to the
161
+ * listener's timeout path; a mid-hook abort belongs in the same class. The approval error
162
+ * is kept as `cause` so the transport-level detail is not lost.
163
+ */
164
+ private async askApprover(
165
+ request: FormationApprovalRequest,
166
+ signal?: AbortSignal
167
+ ): Promise<FormationApproval> {
168
+ try {
169
+ return await this.approver.requestApproval(request, signal);
170
+ } catch (err) {
171
+ if (signal?.aborted) {
172
+ throw new FormationAbortedError(request.token, 'approval', { cause: err });
173
+ }
174
+ throw err;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Record consent against an **already-existing** host strand (record-only): the
180
+ * single `FormationUsage` insert auto-commits and the deferred `StrandExists`
181
+ * CHECK is satisfied by the pre-existing strand. This is the provision-then-record
182
+ * commitment — the strand was minted owner-signed up front, so we do NOT
183
+ * re-insert it (which would double-insert the same PK). `peerKey` is written as the
184
+ * usage `PeerKey`, which BOTH signed digests cover: the joiner's own consent signature
185
+ * (`peerSignature`, verified by the schema's `PeerConsented` CHECK) and — when the invite
186
+ * carries a `ValidationUrl` — the approver's sign-off, so it is the joiner an approval is
187
+ * spent on AND provably the joiner that agreed. Use
188
+ * {@link ControlDatabase.redeemInvitation} for the consent-creates-strand path instead.
189
+ *
190
+ * Reads the invite first (one extra read per redemption) to learn its `ValidationUrl`, and
191
+ * obtains the approval through {@link obtainApproval} when one is demanded.
192
+ */
193
+ async recordUsage(params: {
194
+ token: string;
195
+ peerKey: string;
196
+ peerSignature: string;
197
+ usageStampId: string;
198
+ strandId: string;
199
+ disclosure: string;
200
+ signal?: AbortSignal;
201
+ }): Promise<void> {
202
+ const { token, peerKey, peerSignature, usageStampId, strandId, disclosure, signal } = params;
203
+ if (signal?.aborted) {
204
+ throw new FormationAbortedError(token, 'usage recording');
205
+ }
206
+ const invite = await this.controlDatabase.queryFormationInvite(token);
207
+ // NOTE: a missing invite row reads as "no approval required" here and in
208
+ // `provisionAndRecord`, so an invite that vanished between `resolveStrand` and this read
209
+ // would be written unapproved. Safe only because the database's `FormationUsage.Authorized`
210
+ // CHECK demands a matching `FormationInvite` and rolls the write back regardless; if that
211
+ // CHECK ever loosens, reject on `invite === null` instead of defaulting to null.
212
+ const approval = await this.obtainApproval({
213
+ token, usageStampId, strandId, peerKey, disclosure,
214
+ validationUrl: invite?.validationUrl ?? null,
215
+ }, signal);
216
+ await this.controlDatabase.recordFormationUsage({
217
+ token, strandId, peerKey, peerSignature, usageStampId, disclosure, signal,
218
+ totalUses: invite?.totalUses ?? null, ...approval,
219
+ });
220
+ log('Recorded formation usage: token=%s strand=%s', token, strandId);
221
+ }
222
+
223
+ /**
224
+ * Classify the host strand this invite binds to (see {@link ResolvedHostStrand}):
225
+ *
226
+ * - no invite / no `StrandId` → `unbound` (responder-provisions path).
227
+ * - `StrandId` set AND the strand row is present → `bound`, carrying its
228
+ * `MemberPrivateKey` (the closed-strand read-gating secret) for delivery to a
229
+ * validated invitee.
230
+ * - `StrandId` set but the strand row is absent → `missing` (the host strand has not
231
+ * converged on this responder yet); the manager rejects cleanly instead of recording
232
+ * usage against a non-existent strand, which would fail the deferred `StrandExists`
233
+ * CHECK at commit and drop the result frame.
234
+ */
235
+ async resolveStrand(token: string): Promise<ResolvedHostStrand> {
236
+ const invite = await this.controlDatabase.queryFormationInvite(token);
237
+ if (!invite || !invite.strandId) {
238
+ return { kind: 'unbound' };
239
+ }
240
+ const strand = await this.controlDatabase.queryStrand(invite.strandId);
241
+ if (!strand) {
242
+ return { kind: 'missing', strandId: invite.strandId };
243
+ }
244
+ return {
245
+ kind: 'bound',
246
+ strandId: invite.strandId,
247
+ memberPrivateKey: strand.MemberPrivateKey ?? null,
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Provision a NEW strand for an UNBOUND invite and record consent against it in ONE
253
+ * transaction (the responder-provisions fallback, now single-use-enforced).
254
+ *
255
+ * Mints a fresh, globally-unique strand id from {@link randomBytes} — the same
256
+ * cross-platform CSPRNG `control-database`'s `generateStampId` uses, NOT
257
+ * `crypto.randomUUID` / `Date.now` / `Math.random` (not uniformly available across
258
+ * node/browser/RN) — then delegates to {@link ControlDatabase.redeemInvitation}, whose
259
+ * single `begin … commit` inserts the consent-authorized `Strand` row AND the matching
260
+ * `FormationUsage` row together (both deferred CHECKs see both rows at commit). That one
261
+ * `FormationUsage` row makes the unbound redemption single-use exactly like the bound
262
+ * path: the next redemption of a `TotalUses:1` invite sees `count 1 >= 1` and is rejected.
263
+ *
264
+ * The strand is open (`'o'`) — an unbound responder-provisioned strand has no membership
265
+ * key — so the returned `memberPrivateKey` is null. `sAppId` is accepted for parity/future
266
+ * use; `redeemInvitation` does not currently thread it into the `Strand` row.
267
+ *
268
+ * A concurrent redemption of the same single-use invite still THROWS for the loser — the
269
+ * manager maps that to a clean protocol rejection (it never lets the dropped insert close
270
+ * the stream silently): the local write queue serializes the two writes, so the loser's
271
+ * seat check reads the winner's committed row against the cap and is refused as a named
272
+ * `InvitationExhaustedError`. A CROSS-node race is not a collision to retry at all — each
273
+ * redemption writes under its own `UsageStampId`, both rows land and survive convergence,
274
+ * and the cap can over-admit by the number of concurrent redeemers. That over-admission is
275
+ * the accepted trade (visible in the append-only record, reversible by owner-gated
276
+ * removal), chosen over a fought-over shared key whose failure mode was the silent loss of
277
+ * a consented join.
278
+ */
279
+ async provisionAndRecord(params: {
280
+ token: string;
281
+ peerKey: string;
282
+ peerSignature: string;
283
+ usageStampId: string;
284
+ sAppId: string;
285
+ disclosure: string;
286
+ signal?: AbortSignal;
287
+ }): Promise<{ strandId: string; memberPrivateKey: string | null }> {
288
+ const { token, peerKey, peerSignature, usageStampId, disclosure, signal } = params;
289
+ if (signal?.aborted) {
290
+ throw new FormationAbortedError(token, 'redemption');
291
+ }
292
+ const strandId = `strand-${randomBytes(128, 'hex') as string}`;
293
+ const invite = await this.controlDatabase.queryFormationInvite(token);
294
+ const approval = await this.obtainApproval({
295
+ token, usageStampId, strandId, peerKey, disclosure,
296
+ validationUrl: invite?.validationUrl ?? null,
297
+ }, signal);
298
+ await this.controlDatabase.redeemInvitation({
299
+ token, strandId, peerKey, peerSignature, usageStampId, disclosure, signal,
300
+ totalUses: invite?.totalUses ?? null, ...approval,
301
+ });
302
+ log('Provisioned + recorded unbound strand: token=%s strand=%s', token, strandId);
303
+ return { strandId, memberPrivateKey: null };
304
+ }
305
+ }