@serfab/cadre-core 1.14.0 → 1.16.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 (171) hide show
  1. package/README.md +293 -274
  2. package/dist/cadre-invite-protocol.d.ts +64 -29
  3. package/dist/cadre-invite-protocol.js +207 -38
  4. package/dist/cadre-invite-protocol.js.map +1 -1
  5. package/dist/cadre-join-request.d.ts +42 -0
  6. package/dist/cadre-join-request.js +144 -0
  7. package/dist/cadre-join-request.js.map +1 -0
  8. package/dist/cadre-node.d.ts +375 -79
  9. package/dist/cadre-node.js +758 -186
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/control-database.d.ts +92 -12
  12. package/dist/control-database.js +323 -142
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-schema.d.ts +1 -1
  15. package/dist/control-schema.js +19 -3
  16. package/dist/control-schema.js.map +1 -1
  17. package/dist/ed25519-key.d.ts +7 -9
  18. package/dist/ed25519-key.js +20 -10
  19. package/dist/ed25519-key.js.map +1 -1
  20. package/dist/fetch-impl.d.ts +12 -0
  21. package/dist/fetch-impl.js +23 -0
  22. package/dist/fetch-impl.js.map +1 -0
  23. package/dist/file-durable-slot.js +2 -1
  24. package/dist/file-durable-slot.js.map +1 -1
  25. package/dist/file-safe-name.d.ts +25 -0
  26. package/dist/file-safe-name.js +29 -0
  27. package/dist/file-safe-name.js.map +1 -0
  28. package/dist/formation-approval.js +5 -11
  29. package/dist/formation-approval.js.map +1 -1
  30. package/dist/fs-atomic.d.ts +0 -19
  31. package/dist/fs-atomic.js +3 -24
  32. package/dist/fs-atomic.js.map +1 -1
  33. package/dist/index.d.ts +12 -5
  34. package/dist/index.js +31 -6
  35. package/dist/index.js.map +1 -1
  36. package/dist/invitation-bootstrap.d.ts +50 -12
  37. package/dist/invitation-bootstrap.js +76 -13
  38. package/dist/invitation-bootstrap.js.map +1 -1
  39. package/dist/joined-strand-store.d.ts +7 -0
  40. package/dist/joined-strand-store.js +9 -0
  41. package/dist/joined-strand-store.js.map +1 -1
  42. package/dist/key-store-file.js +2 -1
  43. package/dist/key-store-file.js.map +1 -1
  44. package/dist/link-budget.d.ts +9 -4
  45. package/dist/link-budget.js +9 -4
  46. package/dist/link-budget.js.map +1 -1
  47. package/dist/machine-removal.d.ts +78 -0
  48. package/dist/machine-removal.js +166 -0
  49. package/dist/machine-removal.js.map +1 -0
  50. package/dist/membership-connection-gater.d.ts +6 -5
  51. package/dist/membership-connection-gater.js +6 -5
  52. package/dist/membership-connection-gater.js.map +1 -1
  53. package/dist/node-claim-payload.d.ts +55 -0
  54. package/dist/node-claim-payload.js +108 -4
  55. package/dist/node-claim-payload.js.map +1 -1
  56. package/dist/node-default-transports.d.ts +27 -0
  57. package/dist/node-default-transports.js +35 -0
  58. package/dist/node-default-transports.js.map +1 -0
  59. package/dist/node-transports.d.ts +30 -0
  60. package/dist/node-transports.js +40 -0
  61. package/dist/node-transports.js.map +1 -0
  62. package/dist/peer-dial.d.ts +124 -27
  63. package/dist/peer-dial.js +254 -30
  64. package/dist/peer-dial.js.map +1 -1
  65. package/dist/peer-join-backfill.js +6 -0
  66. package/dist/peer-join-backfill.js.map +1 -1
  67. package/dist/peer-record.d.ts +6 -45
  68. package/dist/peer-record.js +8 -50
  69. package/dist/peer-record.js.map +1 -1
  70. package/dist/primary-lan-address.d.ts +8 -0
  71. package/dist/primary-lan-address.js +46 -0
  72. package/dist/primary-lan-address.js.map +1 -0
  73. package/dist/provider/client.d.ts +131 -0
  74. package/dist/provider/client.js +226 -0
  75. package/dist/provider/client.js.map +1 -0
  76. package/dist/provider/descriptor.d.ts +41 -0
  77. package/dist/provider/descriptor.js +103 -0
  78. package/dist/provider/descriptor.js.map +1 -0
  79. package/dist/provider/index.d.ts +9 -0
  80. package/dist/provider/index.js +10 -0
  81. package/dist/provider/index.js.map +1 -0
  82. package/dist/provider/oauth.d.ts +125 -0
  83. package/dist/provider/oauth.js +199 -0
  84. package/dist/provider/oauth.js.map +1 -0
  85. package/dist/provider/rent-node.d.ts +101 -0
  86. package/dist/provider/rent-node.js +220 -0
  87. package/dist/provider/rent-node.js.map +1 -0
  88. package/dist/relay-addrs.d.ts +9 -4
  89. package/dist/relay-addrs.js +9 -4
  90. package/dist/relay-addrs.js.map +1 -1
  91. package/dist/relay-reservation.d.ts +51 -16
  92. package/dist/relay-reservation.js +107 -24
  93. package/dist/relay-reservation.js.map +1 -1
  94. package/dist/seed-bootstrap.d.ts +22 -5
  95. package/dist/seed-bootstrap.js +48 -25
  96. package/dist/seed-bootstrap.js.map +1 -1
  97. package/dist/strand-commit-durability.d.ts +54 -0
  98. package/dist/strand-commit-durability.js +74 -0
  99. package/dist/strand-commit-durability.js.map +1 -0
  100. package/dist/strand-database.d.ts +23 -8
  101. package/dist/strand-database.js +41 -13
  102. package/dist/strand-database.js.map +1 -1
  103. package/dist/strand-first-sync-gate.d.ts +7 -1
  104. package/dist/strand-first-sync-gate.js +7 -1
  105. package/dist/strand-first-sync-gate.js.map +1 -1
  106. package/dist/strand-formation-protocol.js +4 -1
  107. package/dist/strand-formation-protocol.js.map +1 -1
  108. package/dist/strand-fret-addrs.d.ts +19 -1
  109. package/dist/strand-fret-addrs.js +27 -9
  110. package/dist/strand-fret-addrs.js.map +1 -1
  111. package/dist/strand-instance-manager.d.ts +81 -19
  112. package/dist/strand-instance-manager.js +216 -105
  113. package/dist/strand-instance-manager.js.map +1 -1
  114. package/dist/strand-move.d.ts +124 -0
  115. package/dist/strand-move.js +438 -0
  116. package/dist/strand-move.js.map +1 -0
  117. package/dist/strand-network-config.js +7 -6
  118. package/dist/strand-network-config.js.map +1 -1
  119. package/dist/strand-peer-redial.d.ts +113 -0
  120. package/dist/strand-peer-redial.js +236 -0
  121. package/dist/strand-peer-redial.js.map +1 -0
  122. package/dist/strand-wake-protocol.js +10 -7
  123. package/dist/strand-wake-protocol.js.map +1 -1
  124. package/dist/types.d.ts +212 -4
  125. package/dist/types.js +10 -0
  126. package/dist/types.js.map +1 -1
  127. package/package.json +18 -10
  128. package/src/cadre-invite-protocol.ts +1052 -864
  129. package/src/cadre-join-request.ts +167 -0
  130. package/src/cadre-node.ts +10003 -9381
  131. package/src/control-database.ts +4663 -4451
  132. package/src/control-schema.ts +19 -3
  133. package/src/ed25519-key.ts +20 -10
  134. package/src/fetch-impl.ts +23 -0
  135. package/src/file-durable-slot.ts +64 -63
  136. package/src/file-safe-name.ts +32 -0
  137. package/src/formation-approval.ts +604 -610
  138. package/src/fs-atomic.ts +92 -117
  139. package/src/index.ts +71 -4
  140. package/src/invitation-bootstrap.ts +91 -13
  141. package/src/joined-strand-store.ts +508 -498
  142. package/src/key-store-file.ts +142 -141
  143. package/src/link-budget.ts +9 -4
  144. package/src/machine-removal.ts +216 -0
  145. package/src/membership-connection-gater.ts +638 -637
  146. package/src/node-claim-payload.ts +138 -4
  147. package/src/node-default-transports.ts +38 -0
  148. package/src/node-transports.ts +49 -0
  149. package/src/peer-dial.ts +604 -336
  150. package/src/peer-join-backfill.ts +6 -0
  151. package/src/peer-record.ts +159 -202
  152. package/src/primary-lan-address.ts +47 -0
  153. package/src/provider/client.ts +326 -0
  154. package/src/provider/descriptor.ts +129 -0
  155. package/src/provider/index.ts +59 -0
  156. package/src/provider/oauth.ts +285 -0
  157. package/src/provider/rent-node.ts +275 -0
  158. package/src/relay-addrs.ts +9 -4
  159. package/src/relay-reservation.ts +1211 -1121
  160. package/src/seed-bootstrap.ts +1502 -1471
  161. package/src/strand-commit-durability.ts +116 -0
  162. package/src/strand-database.ts +46 -14
  163. package/src/strand-first-sync-gate.ts +7 -1
  164. package/src/strand-formation-protocol.ts +1039 -1036
  165. package/src/strand-fret-addrs.ts +179 -145
  166. package/src/strand-instance-manager.ts +2026 -1883
  167. package/src/strand-move.ts +585 -0
  168. package/src/strand-network-config.ts +7 -6
  169. package/src/strand-peer-redial.ts +307 -0
  170. package/src/strand-wake-protocol.ts +10 -7
  171. package/src/types.ts +220 -4
@@ -1,610 +1,604 @@
1
- import debug from 'debug';
2
- import {
3
- concat as uint8ArrayConcat,
4
- fromString as uint8ArrayFromString,
5
- toString as uint8ArrayToString
6
- } from 'uint8arrays';
7
- import { sign, verify } from '@optimystic/quereus-plugin-crypto';
8
- import { formationVouchMessage } from './control-database.js';
9
-
10
- const log = debug('sereus:cadre:formation-approval');
11
-
12
- /**
13
- * Abort an unanswered approval request after this long when no `timeoutMs` is supplied.
14
- * Exported because the formation responder's provisioning budget has to contain it:
15
- * `formationDeadlines` (`strand-formation-deadlines.ts`) adds it as a flat term, since an HTTP
16
- * call to the hook does not cross the link between the two parties' machines.
17
- */
18
- // eslint-disable-next-line no-restricted-syntax -- link-independent: an outbound HTTP call to the approval hook, not the libp2p link between machines
19
- export const DEFAULT_APPROVAL_TIMEOUT_MS = 10_000;
20
-
21
- /**
22
- * Largest approval response body this client will read (64 KiB). An approval is two short
23
- * base64url strings; anything approaching this is a broken or hostile hook, and reading it
24
- * unbounded would let that hook stream a redeeming node out of memory mid-formation.
25
- */
26
- const MAX_RESPONSE_BYTES = 64 * 1024;
27
-
28
- /**
29
- * Everything ONE approval is bound to — the five fields inside the approver's signed digest
30
- * (see `formationVouchMessage`), plus the hook to ask.
31
- *
32
- * Every field is fixed BEFORE the hook is contacted: the JOINER mints the nonce (and signs its
33
- * own consent over it) before its contact message is sent, and the redeeming node knows the
34
- * strand, the joiner's key, and the disclosure text it is about to write. That ordering is
35
- * what makes the resulting signature usable — an approval signed over a nonce the node did not
36
- * insert fails `FormationUsage.Authorized` at commit.
37
- */
38
- export interface FormationApprovalRequest {
39
- /** Invitation token being redeemed. */
40
- token: string;
41
- /** Single-use nonce for THIS redemption, already minted by the JOINING peer. */
42
- usageStampId: string;
43
- /** The strand (network) being joined. */
44
- strandId: string;
45
- /** The joining peer's own ed25519 public key (written to `FormationUsage.PeerKey`). */
46
- peerKey: string;
47
- /**
48
- * The EXACT text that will be written to `FormationUsage.Disclosure`. The approver signs
49
- * these bytes verbatim and MUST NOT re-serialize them — the redeeming node computes this
50
- * string once and uses the identical string for both the signature and the insert.
51
- */
52
- disclosure: string;
53
- /**
54
- * The invite's `ValidationUrl` (the hook to contact). NOT part of the signed digest: it says
55
- * where to ask, not what is being approved.
56
- */
57
- validationUrl: string;
58
- }
59
-
60
- /**
61
- * The five signed fields on their own — what `formationVouchMessage` consumes, and the exact
62
- * JSON body posted to the hook. Derived from {@link FormationApprovalRequest} by subtraction so
63
- * a field added to the request must be consciously routed here (or the build breaks) rather
64
- * than silently dropping out of the digest.
65
- *
66
- * This is what an APPROVER receives: a hook is posted these five fields and nothing else, so it
67
- * has no `validationUrl` to supply and should type its request body as this, not as the full
68
- * {@link FormationApprovalRequest}.
69
- */
70
- export type FormationVouchFields = Omit<FormationApprovalRequest, 'validationUrl'>;
71
-
72
- /** An approver's answer: which key vouched, and its signature over the request's digest. */
73
- export interface FormationApproval {
74
- /** Public key the approval claims; must match an enrolled `ValidationKey` row. */
75
- validationKey: string;
76
- /** base64url ed25519 signature over `formationVouchMessage(request)`. */
77
- validationSignature: string;
78
- }
79
-
80
- /**
81
- * Something that can obtain an approval for a redemption. The HTTP hook
82
- * ({@link createHttpFormationApprover}) is the production implementation; tests and embedded
83
- * deployments can supply their own.
84
- *
85
- * Rejects with a {@link FormationApprovalError} — never resolves to a "no". A caller that
86
- * receives a resolved {@link FormationApproval} may still find it does not verify; use
87
- * {@link verifyFormationApproval} before writing.
88
- */
89
- export interface FormationApprover {
90
- /**
91
- * `signal` is the caller's cancellation (the formation responder's work budget expiring):
92
- * a pre-aborted or mid-flight abort rejects with an `unavailable`
93
- * {@link FormationApprovalError} without (further) contacting the hook. Optional so
94
- * signal-unaware implementations stay assignable.
95
- */
96
- requestApproval(request: FormationApprovalRequest, signal?: AbortSignal): Promise<FormationApproval>;
97
- }
98
-
99
- /**
100
- * Why an approval could not be obtained. The cases route differently: `refused` is a
101
- * final answer, `unavailable` may succeed on a later attempt, and `malformed` /
102
- * `unenrolled` / `misconfigured` are operator errors that retrying never fixes.
103
- *
104
- * `unenrolled` is thrown by the redeeming node's LOCAL enrollment pre-check (the recorder
105
- * asking its control database whether the approval's key is an enrolled `ValidationKey`
106
- * row), never by the HTTP client — a hook cannot know or report it.
107
- */
108
- export type FormationApprovalFailure =
109
- /** The hook answered, and the answer is no. */
110
- | 'refused'
111
- /** Could not get an answer (network error, timeout, non-2xx status, redirect). */
112
- | 'unavailable'
113
- /** Got an answer that is not a usable approval (not JSON, missing/blank fields, oversized). */
114
- | 'malformed'
115
- /** The approval's `validationKey` is not an enrolled `ValidationKey` row (local pre-check). */
116
- | 'unenrolled'
117
- /** The `ValidationUrl` or the runtime is unusable (bad scheme, no `fetch`). */
118
- | 'misconfigured';
119
-
120
- /**
121
- * Failure to obtain an approval, carrying the {@link FormationApprovalFailure} category the
122
- * caller switches on to pick the rejection reason a would-be joiner is told.
123
- */
124
- export class FormationApprovalError extends Error {
125
- constructor(
126
- readonly failure: FormationApprovalFailure,
127
- message: string,
128
- options?: { cause?: unknown }
129
- ) {
130
- super(message, options);
131
- this.name = 'FormationApprovalError';
132
- }
133
- }
134
-
135
- /**
136
- * The five fields the approver signs, lifted out of the request. Written as an explicit
137
- * destructure (not a spread-and-delete) so the object posted to the hook can never pick up a
138
- * field the digest does not cover — in particular the `validationUrl`.
139
- */
140
- function vouchFields(request: FormationApprovalRequest): FormationVouchFields {
141
- const { token, usageStampId, strandId, peerKey, disclosure } = request;
142
- return { token, usageStampId, strandId, peerKey, disclosure };
143
- }
144
-
145
- /**
146
- * Approver side: produce the approval for a request. Used by tests, and by anyone writing a
147
- * hook in TypeScript.
148
- *
149
- * `privateKeyB64`'s public half must be the key enrolled as the `ValidationKey` row the
150
- * redeeming node's control database will verify against — pass that same public key as
151
- * `validationKey` (derive it with `ed25519PublicKeyFromPrivate` if you only hold the seed).
152
- * Signing with a key that is not enrolled produces a perfectly valid signature that the
153
- * database still rejects.
154
- *
155
- * Takes the five signed fields, not a full {@link FormationApprovalRequest}: an approver is
156
- * posted only those five and has no `validationUrl` to hand back. A redeeming node holding a
157
- * whole request can still pass it straight in.
158
- *
159
- * @param fields - The redemption being approved (the five signed fields).
160
- * @param validationKey - base64url ed25519 public key that this approval claims.
161
- * @param privateKeyB64 - base64url 32-byte ed25519 seed to sign with.
162
- */
163
- export function signFormationApproval(
164
- fields: FormationVouchFields,
165
- validationKey: string,
166
- privateKeyB64: string
167
- ): FormationApproval {
168
- const validationSignature = sign(
169
- formationVouchMessage(fields),
170
- privateKeyB64,
171
- 'ed25519',
172
- 'bytes',
173
- 'base64url',
174
- 'base64url'
175
- ) as string;
176
- return { validationKey, validationSignature };
177
- }
178
-
179
- /**
180
- * Redeeming side: does this approval actually verify against the bytes we are about to write?
181
- *
182
- * A LOCAL PRE-CHECK, not the authority. It turns a bad approval into a legible rejection
183
- * instead of an opaque `CHECK constraint failed: Authorized` at commit, and it catches the
184
- * ordinary drift case (the hook signed a different nonce/strand/peer than the one we hold).
185
- * It cannot substitute for the database check: this verifies against the key the approval
186
- * itself names, whereas `FormationUsage.Authorized` verifies against the STORED
187
- * `ValidationKey` row — which is what stops a redeemer approving itself with a key of its own
188
- * choosing.
189
- *
190
- * Returns a boolean and never throws (same contract as `verifyPeerAuthorization`): malformed
191
- * base64url, a garbage key, or any crypto failure resolves to `false`, logged at debug.
192
- */
193
- export function verifyFormationApproval(
194
- fields: FormationVouchFields,
195
- approval: FormationApproval
196
- ): boolean {
197
- try {
198
- return verify(
199
- formationVouchMessage(fields),
200
- approval.validationSignature,
201
- approval.validationKey,
202
- 'ed25519',
203
- 'bytes',
204
- 'base64url',
205
- 'base64url'
206
- );
207
- } catch (error) {
208
- log('verifyFormationApproval failed: %o', error);
209
- return false;
210
- }
211
- }
212
-
213
- /**
214
- * Resolve and validate the hook URL before anything is sent.
215
- *
216
- * `http:` stays permitted — a self-hosted hook on a LAN is a real deployment — but note that
217
- * it puts the disclosure text on the wire in clear, and lets anyone on the path see who is
218
- * joining which strand. Prefer `https:` for a hook reachable off-link.
219
- */
220
- function parseHookUrl(validationUrl: string): URL {
221
- let url: URL;
222
- try {
223
- url = new URL(validationUrl);
224
- } catch (error) {
225
- throw new FormationApprovalError(
226
- 'misconfigured',
227
- `ValidationUrl is not a valid URL: ${validationUrl}`,
228
- { cause: error }
229
- );
230
- }
231
- if (url.protocol !== 'http:' && url.protocol !== 'https:') {
232
- throw new FormationApprovalError(
233
- 'misconfigured',
234
- `ValidationUrl scheme "${url.protocol}" is not supported; use http: or https:`
235
- );
236
- }
237
- return url;
238
- }
239
-
240
- /**
241
- * The `fetch` this approver will call: the injected one, else the global.
242
- *
243
- * Bound to `globalThis` because an unbound global `fetch` is an illegal invocation in browsers
244
- * (and in any runtime whose `fetch` is a branded method rather than a plain function).
245
- */
246
- function resolveFetch(fetchImpl?: typeof fetch): typeof fetch {
247
- if (fetchImpl) {
248
- return fetchImpl;
249
- }
250
- if (typeof globalThis.fetch !== 'function') {
251
- throw new FormationApprovalError(
252
- 'misconfigured',
253
- 'No fetch implementation: globalThis.fetch is missing and no fetchImpl was supplied'
254
- );
255
- }
256
- return globalThis.fetch.bind(globalThis);
257
- }
258
-
259
- /**
260
- * Read the response body as text, refusing anything over {@link MAX_RESPONSE_BYTES}.
261
- *
262
- * Two paths, because `Response.body` is not universally a stream (React Native's fetch has no
263
- * `body` reader): stream and stop at the cap where possible, otherwise read to completion and
264
- * measure. The declared `content-length` is checked first so an honest oversized response is
265
- * rejected without reading it at all — but it is only a hint, so the actual bytes are counted
266
- * either way.
267
- *
268
- * NOTE: on the no-reader path the cap is enforced only AFTER the runtime has buffered the whole
269
- * body, so a hook that under-declares its `content-length` can still make that runtime hold an
270
- * arbitrary amount. Harmless where it applies today (React Native clients are not formation
271
- * responders); if a responder ever runs on a platform without a body reader, that path needs a
272
- * real streaming read rather than a post-hoc measurement.
273
- */
274
- async function readCappedText(response: Response, budget: ApprovalBudget): Promise<string> {
275
- const declared = Number(response.headers.get('content-length'));
276
- if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
277
- throw new FormationApprovalError(
278
- 'malformed',
279
- `Approval response declares ${declared} bytes, over the ${MAX_RESPONSE_BYTES}-byte cap`
280
- );
281
- }
282
-
283
- const body = response.body;
284
- if (!body || typeof body.getReader !== 'function') {
285
- // NOTE: nothing releases this body when the budget wins — `text()` has already locked it and
286
- // the runtime owns the buffering. It only applies to readerless-fetch runtimes (React
287
- // Native), which are not formation responders; a responder on such a platform would need a
288
- // real streaming read here, as the cap note above already says.
289
- const text = await budget.race(response.text());
290
- assertUnderCap(uint8ArrayFromString(text, 'utf8').byteLength);
291
- return text;
292
- }
293
- return readCappedStream(body.getReader(), budget);
294
- }
295
-
296
- /** Drain a body stream, aborting the read the moment the accumulated bytes pass the cap. */
297
- async function readCappedStream(
298
- reader: ReadableStreamDefaultReader<Uint8Array>,
299
- budget: ApprovalBudget
300
- ): Promise<string> {
301
- const chunks: Uint8Array[] = [];
302
- let total = 0;
303
- try {
304
- for (;;) {
305
- // Raced, not just aborted: a `fetch` that drops the abort leaves this read pending
306
- // forever, and the budget is the only thing that ends it. The `finally` below cancels
307
- // the reader, which is what actually gives the connection back.
308
- const { done, value } = await budget.race(reader.read());
309
- if (done) {
310
- break;
311
- }
312
- total += value.byteLength;
313
- assertUnderCap(total);
314
- chunks.push(value);
315
- }
316
- } finally {
317
- // Releases the connection when we bailed early; a no-op on a fully-drained stream.
318
- void reader.cancel().catch((error: unknown) => log('response cancel failed: %o', error));
319
- }
320
- return uint8ArrayToString(uint8ArrayConcat(chunks, total), 'utf8');
321
- }
322
-
323
- /**
324
- * Release a body we are never going to read, so the runtime can put the connection back in its
325
- * pool. Every failure decided from the status line (a refusal, a 5xx, a redirect) and the
326
- * declared-oversize rejection throw before touching the body, and an undrained body keeps a
327
- * socket checked out until GC — a node that asks a flapping hook once per redemption would
328
- * accumulate them. Skips a body already locked by {@link readCappedStream}, which cancels its
329
- * own reader.
330
- *
331
- * NOTE: awaited by `readApproval`, so it sits OUTSIDE the budget — a runtime whose `cancel()`
332
- * never settles would hold the caller past `timeoutMs` even though the deadline already fired.
333
- * Every runtime this ships on settles it promptly. If a request is ever seen outliving its budget
334
- * despite the budget firing, stop awaiting this; the two tests that assert the body is released
335
- * by the time the rejection surfaces would then have to poll instead.
336
- */
337
- async function discardBody(response: Response): Promise<void> {
338
- const body = response.body;
339
- if (!body || body.locked) {
340
- return;
341
- }
342
- try {
343
- await body.cancel();
344
- } catch (error) {
345
- log('discarding unread response body failed: %o', error);
346
- }
347
- }
348
-
349
- function assertUnderCap(bytes: number): void {
350
- if (bytes > MAX_RESPONSE_BYTES) {
351
- throw new FormationApprovalError(
352
- 'malformed',
353
- `Approval response exceeds the ${MAX_RESPONSE_BYTES}-byte cap`
354
- );
355
- }
356
- }
357
-
358
- /** A blank (or whitespace-only) field is as unusable as a missing one — both are `malformed`. */
359
- function requireField(body: Record<string, unknown>, field: string): string {
360
- const value = body[field];
361
- if (typeof value !== 'string' || value.trim() === '') {
362
- throw new FormationApprovalError(
363
- 'malformed',
364
- `Approval response is missing a usable "${field}"`
365
- );
366
- }
367
- return value;
368
- }
369
-
370
- /** Parse a 2xx body into an approval, rejecting anything that is not the documented shape. */
371
- function parseApproval(text: string): FormationApproval {
372
- let parsed: unknown;
373
- try {
374
- parsed = JSON.parse(text);
375
- } catch (error) {
376
- throw new FormationApprovalError('malformed', 'Approval response is not JSON', { cause: error });
377
- }
378
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
379
- throw new FormationApprovalError('malformed', 'Approval response is not a JSON object');
380
- }
381
- const body = parsed as Record<string, unknown>;
382
- return {
383
- validationKey: requireField(body, 'validationKey'),
384
- validationSignature: requireField(body, 'validationSignature')
385
- };
386
- }
387
-
388
- /**
389
- * Map a response's STATUS — never its body shape — onto a failure. A `403` carrying a
390
- * well-formed approval is still a refusal: the hook said no, and a redeeming node that read
391
- * the body anyway would let a hook accidentally approve by echoing.
392
- */
393
- function assertApprovingStatus(response: Response, origin: string): void {
394
- if (response.status === 401 || response.status === 403) {
395
- throw new FormationApprovalError(
396
- 'refused',
397
- `Approval hook ${origin} refused this redemption (HTTP ${response.status})`
398
- );
399
- }
400
- if (!response.ok) {
401
- throw new FormationApprovalError(
402
- 'unavailable',
403
- `Approval hook ${origin} answered HTTP ${response.status}`
404
- );
405
- }
406
- // Belt and braces for runtimes that ignore `redirect: 'error'` rather than rejecting.
407
- if (response.redirected) {
408
- throw new FormationApprovalError(
409
- 'unavailable',
410
- `Approval hook ${origin} redirected; re-publish the ValidationUrl instead of chasing it`
411
- );
412
- }
413
- }
414
-
415
- /**
416
- * Turn a response into an approval, releasing the body on every path that rejects it.
417
- */
418
- async function readApproval(
419
- response: Response,
420
- origin: string,
421
- budget: ApprovalBudget
422
- ): Promise<FormationApproval> {
423
- try {
424
- assertApprovingStatus(response, origin);
425
- return parseApproval(await readCappedText(response, budget));
426
- } catch (error) {
427
- await discardBody(response);
428
- throw error;
429
- }
430
- }
431
-
432
- /**
433
- * ONE deadline covering a whole approval exchange — connect, headers, AND body read — that fires
434
- * whether or not the runtime's `fetch` honours an abort.
435
- *
436
- * Aborting is still done (it is what releases the socket where it works), but it cannot be the
437
- * only bound: Node's `fetch` occasionally drops an abort that lands mid-body-read, leaving the
438
- * read pending until undici's own 300s body timeout. Every await that can outlive the budget is
439
- * raced against {@link ApprovalBudget.race} instead.
440
- */
441
- interface ApprovalBudget {
442
- /** `work`, or the budget's `unavailable` rejection — whichever settles first. */
443
- race<T>(work: Promise<T>): Promise<T>;
444
- /** True once the deadline has fired, i.e. any result arriving now is unwanted. */
445
- expired(): boolean;
446
- /** Clears the timer and the caller's abort listener. Must run on every exit path. */
447
- dispose(): void;
448
- }
449
-
450
- /**
451
- * Arm the budget for one request. The caller's `signal` is relayed by hand rather than with
452
- * `AbortSignal.any` (not reliably present on React Native/Hermes; this client commits to
453
- * `fetch` + `AbortController` only).
454
- */
455
- function startBudget(
456
- timeoutMs: number,
457
- origin: string,
458
- controller: AbortController,
459
- signal?: AbortSignal
460
- ): ApprovalBudget {
461
- let expired = false;
462
- let expire!: (error: FormationApprovalError) => void;
463
- const deadline = new Promise<never>((_resolve, reject) => { expire = reject; });
464
- // The deadline can fire between two raced awaits, with nothing attached to it; a bare handler
465
- // keeps that from surfacing as an unhandled rejection without hiding it from `race`.
466
- void deadline.catch(() => { /* observed by race() */ });
467
-
468
- const fire = (reason: string): void => {
469
- if (expired) {
470
- return;
471
- }
472
- expired = true;
473
- // Rejected BEFORE the abort, so this reason — not whatever a fetch that DOES honour the
474
- // abort rejects with — is what the race settles on. Both are queued in the same turn, and
475
- // the first one queued wins.
476
- expire(new FormationApprovalError('unavailable', reason));
477
- // Best-effort, and no longer load-bearing: where the runtime honours it, this hands the
478
- // socket back instead of leaving it checked out until GC.
479
- controller.abort();
480
- };
481
-
482
- const timer = setTimeout(
483
- () => fire(`Approval hook ${origin} did not answer within ${timeoutMs}ms`),
484
- timeoutMs
485
- );
486
- const onCallerAbort = (): void =>
487
- fire(`Formation was cancelled while approval hook ${origin} was being asked`);
488
- signal?.addEventListener('abort', onCallerAbort, { once: true });
489
-
490
- return {
491
- race: <T>(work: Promise<T>): Promise<T> => Promise.race([work, deadline]),
492
- expired: () => expired,
493
- dispose: (): void => {
494
- // A leaked abort timer keeps a node's event loop alive.
495
- clearTimeout(timer);
496
- signal?.removeEventListener('abort', onCallerAbort);
497
- }
498
- };
499
- }
500
-
501
- /**
502
- * Await the response, giving up when the budget expires — and cleaning up after ourselves if it
503
- * does: a `fetch` that ignored the abort still delivers its response eventually, and an unread
504
- * body keeps a connection checked out. A late rejection is logged rather than left unhandled.
505
- */
506
- async function fetchWithinBudget(
507
- pending: Promise<Response>,
508
- budget: ApprovalBudget
509
- ): Promise<Response> {
510
- void pending.then(
511
- (response) => {
512
- if (budget.expired()) {
513
- void discardBody(response);
514
- }
515
- },
516
- (error: unknown) => {
517
- if (budget.expired()) {
518
- log('abandoned approval request rejected after its budget expired: %o', error);
519
- }
520
- }
521
- );
522
- return budget.race(pending);
523
- }
524
-
525
- /**
526
- * Ask an outside approval service, over HTTP, whether a would-be joiner may redeem an
527
- * invitation. Contacted by the INVITING party's node (the formation responder) during
528
- * redemption — never by the joiner, which neither mints the nonce nor performs the write.
529
- *
530
- * Wire contract (documented for hook operators in `docs/api.md`):
531
- *
532
- * - `POST <ValidationUrl>`, `content-type: application/json`, `accept: application/json`.
533
- * - Body is exactly the five signed fields — `{ token, usageStampId, strandId, peerKey,
534
- * disclosure }` — and nothing else. No owner keys, no bootstrap addresses, no membership
535
- * keys ever reach the hook.
536
- * - `200` with `{ "validationKey": "...", "validationSignature": "..." }` is an approval.
537
- * - `401` / `403` is a refusal; any other non-2xx, a network error, a timeout, or a redirect
538
- * is `unavailable`; a 2xx that is not a usable approval is `malformed`.
539
- *
540
- * Cross-platform by construction: global `fetch` + `AbortController` only, no `node:` imports.
541
- */
542
- export function createHttpFormationApprover(options?: {
543
- /**
544
- * Abort the request after this long, INCLUDING the body read. Default 10s. Must stay under
545
- * the responder's provisioning budget — a hook that stalls must not stall formation.
546
- */
547
- timeoutMs?: number;
548
- /** Injectable for tests / non-standard runtimes. Defaults to `globalThis.fetch`. */
549
- fetchImpl?: typeof fetch;
550
- }): FormationApprover {
551
- const timeoutMs = options?.timeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS;
552
- // A non-positive or non-finite budget would abort every request before it left, turning a
553
- // config typo into a hook that appears permanently down. Fail where the mistake was made.
554
- if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
555
- throw new FormationApprovalError(
556
- 'misconfigured',
557
- `timeoutMs must be a positive number of milliseconds, got ${timeoutMs}`
558
- );
559
- }
560
- const fetchImpl = options?.fetchImpl;
561
-
562
- return {
563
- async requestApproval(request: FormationApprovalRequest, signal?: AbortSignal): Promise<FormationApproval> {
564
- const doFetch = resolveFetch(fetchImpl);
565
- const url = parseHookUrl(request.validationUrl);
566
- // Origin only in messages: a ValidationUrl's path/query may carry a hook secret, and
567
- // these strings reach logs and the joiner's rejection reason.
568
- const origin = url.origin;
569
-
570
- if (signal?.aborted) {
571
- throw new FormationApprovalError(
572
- 'unavailable',
573
- `Formation was cancelled before approval hook ${origin} was asked`
574
- );
575
- }
576
-
577
- const controller = new AbortController();
578
- const budget = startBudget(timeoutMs, origin, controller, signal);
579
-
580
- try {
581
- const response = await fetchWithinBudget(
582
- doFetch(url.toString(), {
583
- method: 'POST',
584
- headers: { 'content-type': 'application/json', accept: 'application/json' },
585
- body: JSON.stringify(vouchFields(request)),
586
- redirect: 'error',
587
- signal: controller.signal
588
- }),
589
- budget
590
- );
591
- const approval = await readApproval(response, origin, budget);
592
- log('approval obtained from %s for token %s', origin, request.token);
593
- return approval;
594
- } catch (error) {
595
- // Includes the budget's own rejection, which already carries the timeout/cancellation
596
- // wording; anything else got here without the budget firing, so the hook is unreachable.
597
- if (error instanceof FormationApprovalError) {
598
- throw error;
599
- }
600
- throw new FormationApprovalError(
601
- 'unavailable',
602
- `Approval hook ${origin} could not be reached`,
603
- { cause: error }
604
- );
605
- } finally {
606
- budget.dispose();
607
- }
608
- }
609
- };
610
- }
1
+ import debug from 'debug';
2
+ import {
3
+ concat as uint8ArrayConcat,
4
+ fromString as uint8ArrayFromString,
5
+ toString as uint8ArrayToString
6
+ } from 'uint8arrays';
7
+ import { sign, verify } from '@optimystic/quereus-plugin-crypto';
8
+ import { formationVouchMessage } from './control-database.js';
9
+ import { resolveFetch as resolveFetchImpl } from './fetch-impl.js';
10
+
11
+ const log = debug('sereus:cadre:formation-approval');
12
+
13
+ /**
14
+ * Abort an unanswered approval request after this long when no `timeoutMs` is supplied.
15
+ * Exported because the formation responder's provisioning budget has to contain it:
16
+ * `formationDeadlines` (`strand-formation-deadlines.ts`) adds it as a flat term, since an HTTP
17
+ * call to the hook does not cross the link between the two parties' machines.
18
+ */
19
+ // eslint-disable-next-line no-restricted-syntax -- link-independent: an outbound HTTP call to the approval hook, not the libp2p link between machines
20
+ export const DEFAULT_APPROVAL_TIMEOUT_MS = 10_000;
21
+
22
+ /**
23
+ * Largest approval response body this client will read (64 KiB). An approval is two short
24
+ * base64url strings; anything approaching this is a broken or hostile hook, and reading it
25
+ * unbounded would let that hook stream a redeeming node out of memory mid-formation.
26
+ */
27
+ const MAX_RESPONSE_BYTES = 64 * 1024;
28
+
29
+ /**
30
+ * Everything ONE approval is bound to — the five fields inside the approver's signed digest
31
+ * (see `formationVouchMessage`), plus the hook to ask.
32
+ *
33
+ * Every field is fixed BEFORE the hook is contacted: the JOINER mints the nonce (and signs its
34
+ * own consent over it) before its contact message is sent, and the redeeming node knows the
35
+ * strand, the joiner's key, and the disclosure text it is about to write. That ordering is
36
+ * what makes the resulting signature usable — an approval signed over a nonce the node did not
37
+ * insert fails `FormationUsage.Authorized` at commit.
38
+ */
39
+ export interface FormationApprovalRequest {
40
+ /** Invitation token being redeemed. */
41
+ token: string;
42
+ /** Single-use nonce for THIS redemption, already minted by the JOINING peer. */
43
+ usageStampId: string;
44
+ /** The strand (network) being joined. */
45
+ strandId: string;
46
+ /** The joining peer's own ed25519 public key (written to `FormationUsage.PeerKey`). */
47
+ peerKey: string;
48
+ /**
49
+ * The EXACT text that will be written to `FormationUsage.Disclosure`. The approver signs
50
+ * these bytes verbatim and MUST NOT re-serialize them — the redeeming node computes this
51
+ * string once and uses the identical string for both the signature and the insert.
52
+ */
53
+ disclosure: string;
54
+ /**
55
+ * The invite's `ValidationUrl` (the hook to contact). NOT part of the signed digest: it says
56
+ * where to ask, not what is being approved.
57
+ */
58
+ validationUrl: string;
59
+ }
60
+
61
+ /**
62
+ * The five signed fields on their own — what `formationVouchMessage` consumes, and the exact
63
+ * JSON body posted to the hook. Derived from {@link FormationApprovalRequest} by subtraction so
64
+ * a field added to the request must be consciously routed here (or the build breaks) rather
65
+ * than silently dropping out of the digest.
66
+ *
67
+ * This is what an APPROVER receives: a hook is posted these five fields and nothing else, so it
68
+ * has no `validationUrl` to supply and should type its request body as this, not as the full
69
+ * {@link FormationApprovalRequest}.
70
+ */
71
+ export type FormationVouchFields = Omit<FormationApprovalRequest, 'validationUrl'>;
72
+
73
+ /** An approver's answer: which key vouched, and its signature over the request's digest. */
74
+ export interface FormationApproval {
75
+ /** Public key the approval claims; must match an enrolled `ValidationKey` row. */
76
+ validationKey: string;
77
+ /** base64url ed25519 signature over `formationVouchMessage(request)`. */
78
+ validationSignature: string;
79
+ }
80
+
81
+ /**
82
+ * Something that can obtain an approval for a redemption. The HTTP hook
83
+ * ({@link createHttpFormationApprover}) is the production implementation; tests and embedded
84
+ * deployments can supply their own.
85
+ *
86
+ * Rejects with a {@link FormationApprovalError} — never resolves to a "no". A caller that
87
+ * receives a resolved {@link FormationApproval} may still find it does not verify; use
88
+ * {@link verifyFormationApproval} before writing.
89
+ */
90
+ export interface FormationApprover {
91
+ /**
92
+ * `signal` is the caller's cancellation (the formation responder's work budget expiring):
93
+ * a pre-aborted or mid-flight abort rejects with an `unavailable`
94
+ * {@link FormationApprovalError} without (further) contacting the hook. Optional so
95
+ * signal-unaware implementations stay assignable.
96
+ */
97
+ requestApproval(request: FormationApprovalRequest, signal?: AbortSignal): Promise<FormationApproval>;
98
+ }
99
+
100
+ /**
101
+ * Why an approval could not be obtained. The cases route differently: `refused` is a
102
+ * final answer, `unavailable` may succeed on a later attempt, and `malformed` /
103
+ * `unenrolled` / `misconfigured` are operator errors that retrying never fixes.
104
+ *
105
+ * `unenrolled` is thrown by the redeeming node's LOCAL enrollment pre-check (the recorder
106
+ * asking its control database whether the approval's key is an enrolled `ValidationKey`
107
+ * row), never by the HTTP client — a hook cannot know or report it.
108
+ */
109
+ export type FormationApprovalFailure =
110
+ /** The hook answered, and the answer is no. */
111
+ | 'refused'
112
+ /** Could not get an answer (network error, timeout, non-2xx status, redirect). */
113
+ | 'unavailable'
114
+ /** Got an answer that is not a usable approval (not JSON, missing/blank fields, oversized). */
115
+ | 'malformed'
116
+ /** The approval's `validationKey` is not an enrolled `ValidationKey` row (local pre-check). */
117
+ | 'unenrolled'
118
+ /** The `ValidationUrl` or the runtime is unusable (bad scheme, no `fetch`). */
119
+ | 'misconfigured';
120
+
121
+ /**
122
+ * Failure to obtain an approval, carrying the {@link FormationApprovalFailure} category the
123
+ * caller switches on to pick the rejection reason a would-be joiner is told.
124
+ */
125
+ export class FormationApprovalError extends Error {
126
+ constructor(
127
+ readonly failure: FormationApprovalFailure,
128
+ message: string,
129
+ options?: { cause?: unknown }
130
+ ) {
131
+ super(message, options);
132
+ this.name = 'FormationApprovalError';
133
+ }
134
+ }
135
+
136
+ /**
137
+ * The five fields the approver signs, lifted out of the request. Written as an explicit
138
+ * destructure (not a spread-and-delete) so the object posted to the hook can never pick up a
139
+ * field the digest does not cover — in particular the `validationUrl`.
140
+ */
141
+ function vouchFields(request: FormationApprovalRequest): FormationVouchFields {
142
+ const { token, usageStampId, strandId, peerKey, disclosure } = request;
143
+ return { token, usageStampId, strandId, peerKey, disclosure };
144
+ }
145
+
146
+ /**
147
+ * Approver side: produce the approval for a request. Used by tests, and by anyone writing a
148
+ * hook in TypeScript.
149
+ *
150
+ * `privateKeyB64`'s public half must be the key enrolled as the `ValidationKey` row the
151
+ * redeeming node's control database will verify against — pass that same public key as
152
+ * `validationKey` (derive it with `ed25519PublicKeyFromPrivate` if you only hold the seed).
153
+ * Signing with a key that is not enrolled produces a perfectly valid signature that the
154
+ * database still rejects.
155
+ *
156
+ * Takes the five signed fields, not a full {@link FormationApprovalRequest}: an approver is
157
+ * posted only those five and has no `validationUrl` to hand back. A redeeming node holding a
158
+ * whole request can still pass it straight in.
159
+ *
160
+ * @param fields - The redemption being approved (the five signed fields).
161
+ * @param validationKey - base64url ed25519 public key that this approval claims.
162
+ * @param privateKeyB64 - base64url 32-byte ed25519 seed to sign with.
163
+ */
164
+ export function signFormationApproval(
165
+ fields: FormationVouchFields,
166
+ validationKey: string,
167
+ privateKeyB64: string
168
+ ): FormationApproval {
169
+ const validationSignature = sign(
170
+ formationVouchMessage(fields),
171
+ privateKeyB64,
172
+ 'ed25519',
173
+ 'bytes',
174
+ 'base64url',
175
+ 'base64url'
176
+ ) as string;
177
+ return { validationKey, validationSignature };
178
+ }
179
+
180
+ /**
181
+ * Redeeming side: does this approval actually verify against the bytes we are about to write?
182
+ *
183
+ * A LOCAL PRE-CHECK, not the authority. It turns a bad approval into a legible rejection
184
+ * instead of an opaque `CHECK constraint failed: Authorized` at commit, and it catches the
185
+ * ordinary drift case (the hook signed a different nonce/strand/peer than the one we hold).
186
+ * It cannot substitute for the database check: this verifies against the key the approval
187
+ * itself names, whereas `FormationUsage.Authorized` verifies against the STORED
188
+ * `ValidationKey` row — which is what stops a redeemer approving itself with a key of its own
189
+ * choosing.
190
+ *
191
+ * Returns a boolean and never throws (same contract as `verifyPeerAuthorization`): malformed
192
+ * base64url, a garbage key, or any crypto failure resolves to `false`, logged at debug.
193
+ */
194
+ export function verifyFormationApproval(
195
+ fields: FormationVouchFields,
196
+ approval: FormationApproval
197
+ ): boolean {
198
+ try {
199
+ return verify(
200
+ formationVouchMessage(fields),
201
+ approval.validationSignature,
202
+ approval.validationKey,
203
+ 'ed25519',
204
+ 'bytes',
205
+ 'base64url',
206
+ 'base64url'
207
+ );
208
+ } catch (error) {
209
+ log('verifyFormationApproval failed: %o', error);
210
+ return false;
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Resolve and validate the hook URL before anything is sent.
216
+ *
217
+ * `http:` stays permitted — a self-hosted hook on a LAN is a real deployment — but note that
218
+ * it puts the disclosure text on the wire in clear, and lets anyone on the path see who is
219
+ * joining which strand. Prefer `https:` for a hook reachable off-link.
220
+ */
221
+ function parseHookUrl(validationUrl: string): URL {
222
+ let url: URL;
223
+ try {
224
+ url = new URL(validationUrl);
225
+ } catch (error) {
226
+ throw new FormationApprovalError(
227
+ 'misconfigured',
228
+ `ValidationUrl is not a valid URL: ${validationUrl}`,
229
+ { cause: error }
230
+ );
231
+ }
232
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
233
+ throw new FormationApprovalError(
234
+ 'misconfigured',
235
+ `ValidationUrl scheme "${url.protocol}" is not supported; use http: or https:`
236
+ );
237
+ }
238
+ return url;
239
+ }
240
+
241
+ /** The `fetch` this approver will call (`fetch-impl.ts`); none at all is a misconfiguration. */
242
+ function resolveFetch(fetchImpl?: typeof fetch): typeof fetch {
243
+ const resolved = resolveFetchImpl(fetchImpl);
244
+ if (!resolved) {
245
+ throw new FormationApprovalError(
246
+ 'misconfigured',
247
+ 'No fetch implementation: globalThis.fetch is missing and no fetchImpl was supplied'
248
+ );
249
+ }
250
+ return resolved;
251
+ }
252
+
253
+ /**
254
+ * Read the response body as text, refusing anything over {@link MAX_RESPONSE_BYTES}.
255
+ *
256
+ * Two paths, because `Response.body` is not universally a stream (React Native's fetch has no
257
+ * `body` reader): stream and stop at the cap where possible, otherwise read to completion and
258
+ * measure. The declared `content-length` is checked first so an honest oversized response is
259
+ * rejected without reading it at all — but it is only a hint, so the actual bytes are counted
260
+ * either way.
261
+ *
262
+ * NOTE: on the no-reader path the cap is enforced only AFTER the runtime has buffered the whole
263
+ * body, so a hook that under-declares its `content-length` can still make that runtime hold an
264
+ * arbitrary amount. Harmless where it applies today (React Native clients are not formation
265
+ * responders); if a responder ever runs on a platform without a body reader, that path needs a
266
+ * real streaming read rather than a post-hoc measurement.
267
+ */
268
+ async function readCappedText(response: Response, budget: ApprovalBudget): Promise<string> {
269
+ const declared = Number(response.headers.get('content-length'));
270
+ if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
271
+ throw new FormationApprovalError(
272
+ 'malformed',
273
+ `Approval response declares ${declared} bytes, over the ${MAX_RESPONSE_BYTES}-byte cap`
274
+ );
275
+ }
276
+
277
+ const body = response.body;
278
+ if (!body || typeof body.getReader !== 'function') {
279
+ // NOTE: nothing releases this body when the budget wins — `text()` has already locked it and
280
+ // the runtime owns the buffering. It only applies to readerless-fetch runtimes (React
281
+ // Native), which are not formation responders; a responder on such a platform would need a
282
+ // real streaming read here, as the cap note above already says.
283
+ const text = await budget.race(response.text());
284
+ assertUnderCap(uint8ArrayFromString(text, 'utf8').byteLength);
285
+ return text;
286
+ }
287
+ return readCappedStream(body.getReader(), budget);
288
+ }
289
+
290
+ /** Drain a body stream, aborting the read the moment the accumulated bytes pass the cap. */
291
+ async function readCappedStream(
292
+ reader: ReadableStreamDefaultReader<Uint8Array>,
293
+ budget: ApprovalBudget
294
+ ): Promise<string> {
295
+ const chunks: Uint8Array[] = [];
296
+ let total = 0;
297
+ try {
298
+ for (;;) {
299
+ // Raced, not just aborted: a `fetch` that drops the abort leaves this read pending
300
+ // forever, and the budget is the only thing that ends it. The `finally` below cancels
301
+ // the reader, which is what actually gives the connection back.
302
+ const { done, value } = await budget.race(reader.read());
303
+ if (done) {
304
+ break;
305
+ }
306
+ total += value.byteLength;
307
+ assertUnderCap(total);
308
+ chunks.push(value);
309
+ }
310
+ } finally {
311
+ // Releases the connection when we bailed early; a no-op on a fully-drained stream.
312
+ void reader.cancel().catch((error: unknown) => log('response cancel failed: %o', error));
313
+ }
314
+ return uint8ArrayToString(uint8ArrayConcat(chunks, total), 'utf8');
315
+ }
316
+
317
+ /**
318
+ * Release a body we are never going to read, so the runtime can put the connection back in its
319
+ * pool. Every failure decided from the status line (a refusal, a 5xx, a redirect) and the
320
+ * declared-oversize rejection throw before touching the body, and an undrained body keeps a
321
+ * socket checked out until GC — a node that asks a flapping hook once per redemption would
322
+ * accumulate them. Skips a body already locked by {@link readCappedStream}, which cancels its
323
+ * own reader.
324
+ *
325
+ * NOTE: awaited by `readApproval`, so it sits OUTSIDE the budget — a runtime whose `cancel()`
326
+ * never settles would hold the caller past `timeoutMs` even though the deadline already fired.
327
+ * Every runtime this ships on settles it promptly. If a request is ever seen outliving its budget
328
+ * despite the budget firing, stop awaiting this; the two tests that assert the body is released
329
+ * by the time the rejection surfaces would then have to poll instead.
330
+ */
331
+ async function discardBody(response: Response): Promise<void> {
332
+ const body = response.body;
333
+ if (!body || body.locked) {
334
+ return;
335
+ }
336
+ try {
337
+ await body.cancel();
338
+ } catch (error) {
339
+ log('discarding unread response body failed: %o', error);
340
+ }
341
+ }
342
+
343
+ function assertUnderCap(bytes: number): void {
344
+ if (bytes > MAX_RESPONSE_BYTES) {
345
+ throw new FormationApprovalError(
346
+ 'malformed',
347
+ `Approval response exceeds the ${MAX_RESPONSE_BYTES}-byte cap`
348
+ );
349
+ }
350
+ }
351
+
352
+ /** A blank (or whitespace-only) field is as unusable as a missing one — both are `malformed`. */
353
+ function requireField(body: Record<string, unknown>, field: string): string {
354
+ const value = body[field];
355
+ if (typeof value !== 'string' || value.trim() === '') {
356
+ throw new FormationApprovalError(
357
+ 'malformed',
358
+ `Approval response is missing a usable "${field}"`
359
+ );
360
+ }
361
+ return value;
362
+ }
363
+
364
+ /** Parse a 2xx body into an approval, rejecting anything that is not the documented shape. */
365
+ function parseApproval(text: string): FormationApproval {
366
+ let parsed: unknown;
367
+ try {
368
+ parsed = JSON.parse(text);
369
+ } catch (error) {
370
+ throw new FormationApprovalError('malformed', 'Approval response is not JSON', { cause: error });
371
+ }
372
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
373
+ throw new FormationApprovalError('malformed', 'Approval response is not a JSON object');
374
+ }
375
+ const body = parsed as Record<string, unknown>;
376
+ return {
377
+ validationKey: requireField(body, 'validationKey'),
378
+ validationSignature: requireField(body, 'validationSignature')
379
+ };
380
+ }
381
+
382
+ /**
383
+ * Map a response's STATUS — never its body shape — onto a failure. A `403` carrying a
384
+ * well-formed approval is still a refusal: the hook said no, and a redeeming node that read
385
+ * the body anyway would let a hook accidentally approve by echoing.
386
+ */
387
+ function assertApprovingStatus(response: Response, origin: string): void {
388
+ if (response.status === 401 || response.status === 403) {
389
+ throw new FormationApprovalError(
390
+ 'refused',
391
+ `Approval hook ${origin} refused this redemption (HTTP ${response.status})`
392
+ );
393
+ }
394
+ if (!response.ok) {
395
+ throw new FormationApprovalError(
396
+ 'unavailable',
397
+ `Approval hook ${origin} answered HTTP ${response.status}`
398
+ );
399
+ }
400
+ // Belt and braces for runtimes that ignore `redirect: 'error'` rather than rejecting.
401
+ if (response.redirected) {
402
+ throw new FormationApprovalError(
403
+ 'unavailable',
404
+ `Approval hook ${origin} redirected; re-publish the ValidationUrl instead of chasing it`
405
+ );
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Turn a response into an approval, releasing the body on every path that rejects it.
411
+ */
412
+ async function readApproval(
413
+ response: Response,
414
+ origin: string,
415
+ budget: ApprovalBudget
416
+ ): Promise<FormationApproval> {
417
+ try {
418
+ assertApprovingStatus(response, origin);
419
+ return parseApproval(await readCappedText(response, budget));
420
+ } catch (error) {
421
+ await discardBody(response);
422
+ throw error;
423
+ }
424
+ }
425
+
426
+ /**
427
+ * ONE deadline covering a whole approval exchange — connect, headers, AND body read — that fires
428
+ * whether or not the runtime's `fetch` honours an abort.
429
+ *
430
+ * Aborting is still done (it is what releases the socket where it works), but it cannot be the
431
+ * only bound: Node's `fetch` occasionally drops an abort that lands mid-body-read, leaving the
432
+ * read pending until undici's own 300s body timeout. Every await that can outlive the budget is
433
+ * raced against {@link ApprovalBudget.race} instead.
434
+ */
435
+ interface ApprovalBudget {
436
+ /** `work`, or the budget's `unavailable` rejection — whichever settles first. */
437
+ race<T>(work: Promise<T>): Promise<T>;
438
+ /** True once the deadline has fired, i.e. any result arriving now is unwanted. */
439
+ expired(): boolean;
440
+ /** Clears the timer and the caller's abort listener. Must run on every exit path. */
441
+ dispose(): void;
442
+ }
443
+
444
+ /**
445
+ * Arm the budget for one request. The caller's `signal` is relayed by hand rather than with
446
+ * `AbortSignal.any` (not reliably present on React Native/Hermes; this client commits to
447
+ * `fetch` + `AbortController` only).
448
+ */
449
+ function startBudget(
450
+ timeoutMs: number,
451
+ origin: string,
452
+ controller: AbortController,
453
+ signal?: AbortSignal
454
+ ): ApprovalBudget {
455
+ let expired = false;
456
+ let expire!: (error: FormationApprovalError) => void;
457
+ const deadline = new Promise<never>((_resolve, reject) => { expire = reject; });
458
+ // The deadline can fire between two raced awaits, with nothing attached to it; a bare handler
459
+ // keeps that from surfacing as an unhandled rejection without hiding it from `race`.
460
+ void deadline.catch(() => { /* observed by race() */ });
461
+
462
+ const fire = (reason: string): void => {
463
+ if (expired) {
464
+ return;
465
+ }
466
+ expired = true;
467
+ // Rejected BEFORE the abort, so this reason — not whatever a fetch that DOES honour the
468
+ // abort rejects with — is what the race settles on. Both are queued in the same turn, and
469
+ // the first one queued wins.
470
+ expire(new FormationApprovalError('unavailable', reason));
471
+ // Best-effort, and no longer load-bearing: where the runtime honours it, this hands the
472
+ // socket back instead of leaving it checked out until GC.
473
+ controller.abort();
474
+ };
475
+
476
+ const timer = setTimeout(
477
+ () => fire(`Approval hook ${origin} did not answer within ${timeoutMs}ms`),
478
+ timeoutMs
479
+ );
480
+ const onCallerAbort = (): void =>
481
+ fire(`Formation was cancelled while approval hook ${origin} was being asked`);
482
+ signal?.addEventListener('abort', onCallerAbort, { once: true });
483
+
484
+ return {
485
+ race: <T>(work: Promise<T>): Promise<T> => Promise.race([work, deadline]),
486
+ expired: () => expired,
487
+ dispose: (): void => {
488
+ // A leaked abort timer keeps a node's event loop alive.
489
+ clearTimeout(timer);
490
+ signal?.removeEventListener('abort', onCallerAbort);
491
+ }
492
+ };
493
+ }
494
+
495
+ /**
496
+ * Await the response, giving up when the budget expires — and cleaning up after ourselves if it
497
+ * does: a `fetch` that ignored the abort still delivers its response eventually, and an unread
498
+ * body keeps a connection checked out. A late rejection is logged rather than left unhandled.
499
+ */
500
+ async function fetchWithinBudget(
501
+ pending: Promise<Response>,
502
+ budget: ApprovalBudget
503
+ ): Promise<Response> {
504
+ void pending.then(
505
+ (response) => {
506
+ if (budget.expired()) {
507
+ void discardBody(response);
508
+ }
509
+ },
510
+ (error: unknown) => {
511
+ if (budget.expired()) {
512
+ log('abandoned approval request rejected after its budget expired: %o', error);
513
+ }
514
+ }
515
+ );
516
+ return budget.race(pending);
517
+ }
518
+
519
+ /**
520
+ * Ask an outside approval service, over HTTP, whether a would-be joiner may redeem an
521
+ * invitation. Contacted by the INVITING party's node (the formation responder) during
522
+ * redemption — never by the joiner, which neither mints the nonce nor performs the write.
523
+ *
524
+ * Wire contract (documented for hook operators in `docs/api.md`):
525
+ *
526
+ * - `POST <ValidationUrl>`, `content-type: application/json`, `accept: application/json`.
527
+ * - Body is exactly the five signed fields — `{ token, usageStampId, strandId, peerKey,
528
+ * disclosure }` — and nothing else. No owner keys, no bootstrap addresses, no membership
529
+ * keys ever reach the hook.
530
+ * - `200` with `{ "validationKey": "...", "validationSignature": "..." }` is an approval.
531
+ * - `401` / `403` is a refusal; any other non-2xx, a network error, a timeout, or a redirect
532
+ * is `unavailable`; a 2xx that is not a usable approval is `malformed`.
533
+ *
534
+ * Cross-platform by construction: global `fetch` + `AbortController` only, no `node:` imports.
535
+ */
536
+ export function createHttpFormationApprover(options?: {
537
+ /**
538
+ * Abort the request after this long, INCLUDING the body read. Default 10s. Must stay under
539
+ * the responder's provisioning budget — a hook that stalls must not stall formation.
540
+ */
541
+ timeoutMs?: number;
542
+ /** Injectable for tests / non-standard runtimes. Defaults to `globalThis.fetch`. */
543
+ fetchImpl?: typeof fetch;
544
+ }): FormationApprover {
545
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS;
546
+ // A non-positive or non-finite budget would abort every request before it left, turning a
547
+ // config typo into a hook that appears permanently down. Fail where the mistake was made.
548
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
549
+ throw new FormationApprovalError(
550
+ 'misconfigured',
551
+ `timeoutMs must be a positive number of milliseconds, got ${timeoutMs}`
552
+ );
553
+ }
554
+ const fetchImpl = options?.fetchImpl;
555
+
556
+ return {
557
+ async requestApproval(request: FormationApprovalRequest, signal?: AbortSignal): Promise<FormationApproval> {
558
+ const doFetch = resolveFetch(fetchImpl);
559
+ const url = parseHookUrl(request.validationUrl);
560
+ // Origin only in messages: a ValidationUrl's path/query may carry a hook secret, and
561
+ // these strings reach logs and the joiner's rejection reason.
562
+ const origin = url.origin;
563
+
564
+ if (signal?.aborted) {
565
+ throw new FormationApprovalError(
566
+ 'unavailable',
567
+ `Formation was cancelled before approval hook ${origin} was asked`
568
+ );
569
+ }
570
+
571
+ const controller = new AbortController();
572
+ const budget = startBudget(timeoutMs, origin, controller, signal);
573
+
574
+ try {
575
+ const response = await fetchWithinBudget(
576
+ doFetch(url.toString(), {
577
+ method: 'POST',
578
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
579
+ body: JSON.stringify(vouchFields(request)),
580
+ redirect: 'error',
581
+ signal: controller.signal
582
+ }),
583
+ budget
584
+ );
585
+ const approval = await readApproval(response, origin, budget);
586
+ log('approval obtained from %s for token %s', origin, request.token);
587
+ return approval;
588
+ } catch (error) {
589
+ // Includes the budget's own rejection, which already carries the timeout/cancellation
590
+ // wording; anything else got here without the budget firing, so the hook is unreachable.
591
+ if (error instanceof FormationApprovalError) {
592
+ throw error;
593
+ }
594
+ throw new FormationApprovalError(
595
+ 'unavailable',
596
+ `Approval hook ${origin} could not be reached`,
597
+ { cause: error }
598
+ );
599
+ } finally {
600
+ budget.dispose();
601
+ }
602
+ }
603
+ };
604
+ }