@serfab/cadre-core 0.9.0 → 0.10.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 (162) hide show
  1. package/README.md +4 -2
  2. package/dist/bootstrap-peer-store-file.d.ts +13 -0
  3. package/dist/bootstrap-peer-store-file.js +42 -0
  4. package/dist/bootstrap-peer-store-file.js.map +1 -0
  5. package/dist/bootstrap-peer-store.d.ts +73 -0
  6. package/dist/bootstrap-peer-store.js +128 -0
  7. package/dist/bootstrap-peer-store.js.map +1 -0
  8. package/dist/cadre-node.d.ts +918 -53
  9. package/dist/cadre-node.js +1843 -190
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/control-authorization.d.ts +93 -0
  12. package/dist/control-authorization.js +57 -0
  13. package/dist/control-authorization.js.map +1 -0
  14. package/dist/control-database.d.ts +990 -50
  15. package/dist/control-database.js +1459 -108
  16. package/dist/control-database.js.map +1 -1
  17. package/dist/control-formation-recorder.d.ts +89 -10
  18. package/dist/control-formation-recorder.js +129 -14
  19. package/dist/control-formation-recorder.js.map +1 -1
  20. package/dist/control-schema.d.ts +1 -1
  21. package/dist/control-schema.js +774 -210
  22. package/dist/control-schema.js.map +1 -1
  23. package/dist/control-stream.d.ts +38 -4
  24. package/dist/control-stream.js +70 -4
  25. package/dist/control-stream.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +151 -0
  27. package/dist/control-write-retry.js +411 -0
  28. package/dist/control-write-retry.js.map +1 -0
  29. package/dist/delegate-admission.d.ts +134 -0
  30. package/dist/delegate-admission.js +244 -0
  31. package/dist/delegate-admission.js.map +1 -0
  32. package/dist/device-token.d.ts +11 -11
  33. package/dist/device-token.js +14 -13
  34. package/dist/device-token.js.map +1 -1
  35. package/dist/ed25519-key.d.ts +26 -0
  36. package/dist/ed25519-key.js +56 -1
  37. package/dist/ed25519-key.js.map +1 -1
  38. package/dist/file-durable-slot.d.ts +20 -0
  39. package/dist/file-durable-slot.js +53 -0
  40. package/dist/file-durable-slot.js.map +1 -0
  41. package/dist/formation-approval.d.ts +157 -0
  42. package/dist/formation-approval.js +375 -0
  43. package/dist/formation-approval.js.map +1 -0
  44. package/dist/fs-atomic.d.ts +38 -0
  45. package/dist/fs-atomic.js +97 -0
  46. package/dist/fs-atomic.js.map +1 -0
  47. package/dist/identity-key.d.ts +49 -0
  48. package/dist/identity-key.js +81 -0
  49. package/dist/identity-key.js.map +1 -0
  50. package/dist/index.d.ts +16 -6
  51. package/dist/index.js +39 -5
  52. package/dist/index.js.map +1 -1
  53. package/dist/key-store-file.d.ts +2 -24
  54. package/dist/key-store-file.js +10 -77
  55. package/dist/key-store-file.js.map +1 -1
  56. package/dist/membership-connection-gater.d.ts +146 -0
  57. package/dist/membership-connection-gater.js +180 -0
  58. package/dist/membership-connection-gater.js.map +1 -0
  59. package/dist/node-local-snapshot.d.ts +109 -0
  60. package/dist/node-local-snapshot.js +182 -0
  61. package/dist/node-local-snapshot.js.map +1 -0
  62. package/dist/peer-authorization.d.ts +135 -18
  63. package/dist/peer-authorization.js +152 -21
  64. package/dist/peer-authorization.js.map +1 -1
  65. package/dist/peer-record.d.ts +21 -8
  66. package/dist/peer-record.js +26 -10
  67. package/dist/peer-record.js.map +1 -1
  68. package/dist/relay-addrs.d.ts +45 -0
  69. package/dist/relay-addrs.js +103 -0
  70. package/dist/relay-addrs.js.map +1 -0
  71. package/dist/relay-reservation.d.ts +266 -0
  72. package/dist/relay-reservation.js +613 -0
  73. package/dist/relay-reservation.js.map +1 -0
  74. package/dist/seed-bootstrap.d.ts +221 -56
  75. package/dist/seed-bootstrap.js +328 -188
  76. package/dist/seed-bootstrap.js.map +1 -1
  77. package/dist/seed-trust-policy.d.ts +47 -21
  78. package/dist/seed-trust-policy.js +24 -15
  79. package/dist/seed-trust-policy.js.map +1 -1
  80. package/dist/strand-addr-protocol.d.ts +42 -13
  81. package/dist/strand-addr-protocol.js +58 -52
  82. package/dist/strand-addr-protocol.js.map +1 -1
  83. package/dist/strand-backfill.d.ts +109 -0
  84. package/dist/strand-backfill.js +279 -0
  85. package/dist/strand-backfill.js.map +1 -0
  86. package/dist/strand-formation-manager.d.ts +38 -9
  87. package/dist/strand-formation-manager.js +120 -17
  88. package/dist/strand-formation-manager.js.map +1 -1
  89. package/dist/strand-formation-protocol.d.ts +103 -4
  90. package/dist/strand-formation-protocol.js +232 -4
  91. package/dist/strand-formation-protocol.js.map +1 -1
  92. package/dist/strand-instance-manager.d.ts +39 -1
  93. package/dist/strand-instance-manager.js +92 -14
  94. package/dist/strand-instance-manager.js.map +1 -1
  95. package/dist/strand-member-registry.d.ts +5 -5
  96. package/dist/strand-member-registry.js +18 -10
  97. package/dist/strand-member-registry.js.map +1 -1
  98. package/dist/strand-membership-writer.d.ts +482 -88
  99. package/dist/strand-membership-writer.js +757 -136
  100. package/dist/strand-membership-writer.js.map +1 -1
  101. package/dist/strand-solicitation.d.ts +99 -33
  102. package/dist/strand-solicitation.js +100 -52
  103. package/dist/strand-solicitation.js.map +1 -1
  104. package/dist/strand-transport-key.d.ts +40 -0
  105. package/dist/strand-transport-key.js +56 -0
  106. package/dist/strand-transport-key.js.map +1 -0
  107. package/dist/strand-wake-protocol.d.ts +9 -7
  108. package/dist/strand-wake-protocol.js +22 -46
  109. package/dist/strand-wake-protocol.js.map +1 -1
  110. package/dist/strand-watcher.d.ts +17 -2
  111. package/dist/strand-watcher.js +47 -3
  112. package/dist/strand-watcher.js.map +1 -1
  113. package/dist/trusted-owner-store-file.d.ts +12 -0
  114. package/dist/trusted-owner-store-file.js +39 -0
  115. package/dist/trusted-owner-store-file.js.map +1 -0
  116. package/dist/trusted-owner-store.d.ts +64 -0
  117. package/dist/trusted-owner-store.js +126 -0
  118. package/dist/trusted-owner-store.js.map +1 -0
  119. package/dist/types.d.ts +275 -20
  120. package/dist/types.js +13 -0
  121. package/dist/types.js.map +1 -1
  122. package/package.json +19 -8
  123. package/src/bootstrap-peer-store-file.ts +44 -0
  124. package/src/bootstrap-peer-store.ts +172 -0
  125. package/src/cadre-node.ts +1992 -202
  126. package/src/control-authorization.ts +114 -0
  127. package/src/control-database.ts +1704 -146
  128. package/src/control-formation-recorder.ts +172 -19
  129. package/src/control-schema.ts +786 -222
  130. package/src/control-stream.ts +81 -4
  131. package/src/control-write-retry.ts +473 -0
  132. package/src/delegate-admission.ts +302 -0
  133. package/src/device-token.ts +18 -13
  134. package/src/ed25519-key.ts +134 -69
  135. package/src/file-durable-slot.ts +61 -0
  136. package/src/formation-approval.ts +604 -0
  137. package/src/fs-atomic.ts +107 -0
  138. package/src/identity-key.ts +109 -0
  139. package/src/index.ts +136 -5
  140. package/src/key-store-file.ts +10 -81
  141. package/src/membership-connection-gater.ts +204 -0
  142. package/src/node-local-snapshot.ts +260 -0
  143. package/src/peer-authorization.ts +199 -21
  144. package/src/peer-record.ts +28 -10
  145. package/src/relay-addrs.ts +111 -0
  146. package/src/relay-reservation.ts +823 -0
  147. package/src/seed-bootstrap.ts +397 -197
  148. package/src/seed-trust-policy.ts +58 -25
  149. package/src/strand-addr-protocol.ts +84 -53
  150. package/src/strand-backfill.ts +366 -0
  151. package/src/strand-formation-manager.ts +145 -19
  152. package/src/strand-formation-protocol.ts +309 -8
  153. package/src/strand-instance-manager.ts +111 -14
  154. package/src/strand-member-registry.ts +18 -13
  155. package/src/strand-membership-writer.ts +1430 -640
  156. package/src/strand-solicitation.ts +155 -90
  157. package/src/strand-transport-key.ts +58 -0
  158. package/src/strand-wake-protocol.ts +29 -46
  159. package/src/strand-watcher.ts +66 -3
  160. package/src/trusted-owner-store-file.ts +41 -0
  161. package/src/trusted-owner-store.ts +171 -0
  162. package/src/types.ts +292 -21
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Everything ONE approval is bound to — the five fields inside the approver's signed digest
3
+ * (see `formationVouchMessage`), plus the hook to ask.
4
+ *
5
+ * Every field is fixed BEFORE the hook is contacted: the JOINER mints the nonce (and signs its
6
+ * own consent over it) before its contact message is sent, and the redeeming node knows the
7
+ * strand, the joiner's key, and the disclosure text it is about to write. That ordering is
8
+ * what makes the resulting signature usable — an approval signed over a nonce the node did not
9
+ * insert fails `FormationUsage.Authorized` at commit.
10
+ */
11
+ export interface FormationApprovalRequest {
12
+ /** Invitation token being redeemed. */
13
+ token: string;
14
+ /** Single-use nonce for THIS redemption, already minted by the JOINING peer. */
15
+ usageStampId: string;
16
+ /** The strand (network) being joined. */
17
+ strandId: string;
18
+ /** The joining peer's own ed25519 public key (written to `FormationUsage.PeerKey`). */
19
+ peerKey: string;
20
+ /**
21
+ * The EXACT text that will be written to `FormationUsage.Disclosure`. The approver signs
22
+ * these bytes verbatim and MUST NOT re-serialize them — the redeeming node computes this
23
+ * string once and uses the identical string for both the signature and the insert.
24
+ */
25
+ disclosure: string;
26
+ /**
27
+ * The invite's `ValidationUrl` (the hook to contact). NOT part of the signed digest: it says
28
+ * where to ask, not what is being approved.
29
+ */
30
+ validationUrl: string;
31
+ }
32
+ /**
33
+ * The five signed fields on their own — what `formationVouchMessage` consumes, and the exact
34
+ * JSON body posted to the hook. Derived from {@link FormationApprovalRequest} by subtraction so
35
+ * a field added to the request must be consciously routed here (or the build breaks) rather
36
+ * than silently dropping out of the digest.
37
+ *
38
+ * This is what an APPROVER receives: a hook is posted these five fields and nothing else, so it
39
+ * has no `validationUrl` to supply and should type its request body as this, not as the full
40
+ * {@link FormationApprovalRequest}.
41
+ */
42
+ export type FormationVouchFields = Omit<FormationApprovalRequest, 'validationUrl'>;
43
+ /** An approver's answer: which key vouched, and its signature over the request's digest. */
44
+ export interface FormationApproval {
45
+ /** Public key the approval claims; must match an enrolled `ValidationKey` row. */
46
+ validationKey: string;
47
+ /** base64url ed25519 signature over `formationVouchMessage(request)`. */
48
+ validationSignature: string;
49
+ }
50
+ /**
51
+ * Something that can obtain an approval for a redemption. The HTTP hook
52
+ * ({@link createHttpFormationApprover}) is the production implementation; tests and embedded
53
+ * deployments can supply their own.
54
+ *
55
+ * Rejects with a {@link FormationApprovalError} — never resolves to a "no". A caller that
56
+ * receives a resolved {@link FormationApproval} may still find it does not verify; use
57
+ * {@link verifyFormationApproval} before writing.
58
+ */
59
+ export interface FormationApprover {
60
+ /**
61
+ * `signal` is the caller's cancellation (the formation responder's work budget expiring):
62
+ * a pre-aborted or mid-flight abort rejects with an `unavailable`
63
+ * {@link FormationApprovalError} without (further) contacting the hook. Optional so
64
+ * signal-unaware implementations stay assignable.
65
+ */
66
+ requestApproval(request: FormationApprovalRequest, signal?: AbortSignal): Promise<FormationApproval>;
67
+ }
68
+ /**
69
+ * Why an approval could not be obtained. The cases route differently: `refused` is a
70
+ * final answer, `unavailable` may succeed on a later attempt, and `malformed` /
71
+ * `unenrolled` / `misconfigured` are operator errors that retrying never fixes.
72
+ *
73
+ * `unenrolled` is thrown by the redeeming node's LOCAL enrollment pre-check (the recorder
74
+ * asking its control database whether the approval's key is an enrolled `ValidationKey`
75
+ * row), never by the HTTP client — a hook cannot know or report it.
76
+ */
77
+ export type FormationApprovalFailure =
78
+ /** The hook answered, and the answer is no. */
79
+ 'refused'
80
+ /** Could not get an answer (network error, timeout, non-2xx status, redirect). */
81
+ | 'unavailable'
82
+ /** Got an answer that is not a usable approval (not JSON, missing/blank fields, oversized). */
83
+ | 'malformed'
84
+ /** The approval's `validationKey` is not an enrolled `ValidationKey` row (local pre-check). */
85
+ | 'unenrolled'
86
+ /** The `ValidationUrl` or the runtime is unusable (bad scheme, no `fetch`). */
87
+ | 'misconfigured';
88
+ /**
89
+ * Failure to obtain an approval, carrying the {@link FormationApprovalFailure} category the
90
+ * caller switches on to pick the rejection reason a would-be joiner is told.
91
+ */
92
+ export declare class FormationApprovalError extends Error {
93
+ readonly failure: FormationApprovalFailure;
94
+ constructor(failure: FormationApprovalFailure, message: string, options?: {
95
+ cause?: unknown;
96
+ });
97
+ }
98
+ /**
99
+ * Approver side: produce the approval for a request. Used by tests, and by anyone writing a
100
+ * hook in TypeScript.
101
+ *
102
+ * `privateKeyB64`'s public half must be the key enrolled as the `ValidationKey` row the
103
+ * redeeming node's control database will verify against — pass that same public key as
104
+ * `validationKey` (derive it with `ed25519PublicKeyFromPrivate` if you only hold the seed).
105
+ * Signing with a key that is not enrolled produces a perfectly valid signature that the
106
+ * database still rejects.
107
+ *
108
+ * Takes the five signed fields, not a full {@link FormationApprovalRequest}: an approver is
109
+ * posted only those five and has no `validationUrl` to hand back. A redeeming node holding a
110
+ * whole request can still pass it straight in.
111
+ *
112
+ * @param fields - The redemption being approved (the five signed fields).
113
+ * @param validationKey - base64url ed25519 public key that this approval claims.
114
+ * @param privateKeyB64 - base64url 32-byte ed25519 seed to sign with.
115
+ */
116
+ export declare function signFormationApproval(fields: FormationVouchFields, validationKey: string, privateKeyB64: string): FormationApproval;
117
+ /**
118
+ * Redeeming side: does this approval actually verify against the bytes we are about to write?
119
+ *
120
+ * A LOCAL PRE-CHECK, not the authority. It turns a bad approval into a legible rejection
121
+ * instead of an opaque `CHECK constraint failed: Authorized` at commit, and it catches the
122
+ * ordinary drift case (the hook signed a different nonce/strand/peer than the one we hold).
123
+ * It cannot substitute for the database check: this verifies against the key the approval
124
+ * itself names, whereas `FormationUsage.Authorized` verifies against the STORED
125
+ * `ValidationKey` row — which is what stops a redeemer approving itself with a key of its own
126
+ * choosing.
127
+ *
128
+ * Returns a boolean and never throws (same contract as `verifyPeerAuthorization`): malformed
129
+ * base64url, a garbage key, or any crypto failure resolves to `false`, logged at debug.
130
+ */
131
+ export declare function verifyFormationApproval(fields: FormationVouchFields, approval: FormationApproval): boolean;
132
+ /**
133
+ * Ask an outside approval service, over HTTP, whether a would-be joiner may redeem an
134
+ * invitation. Contacted by the INVITING party's node (the formation responder) during
135
+ * redemption — never by the joiner, which neither mints the nonce nor performs the write.
136
+ *
137
+ * Wire contract (documented for hook operators in `docs/api.md`):
138
+ *
139
+ * - `POST <ValidationUrl>`, `content-type: application/json`, `accept: application/json`.
140
+ * - Body is exactly the five signed fields — `{ token, usageStampId, strandId, peerKey,
141
+ * disclosure }` — and nothing else. No owner keys, no bootstrap addresses, no membership
142
+ * keys ever reach the hook.
143
+ * - `200` with `{ "validationKey": "...", "validationSignature": "..." }` is an approval.
144
+ * - `401` / `403` is a refusal; any other non-2xx, a network error, a timeout, or a redirect
145
+ * is `unavailable`; a 2xx that is not a usable approval is `malformed`.
146
+ *
147
+ * Cross-platform by construction: global `fetch` + `AbortController` only, no `node:` imports.
148
+ */
149
+ export declare function createHttpFormationApprover(options?: {
150
+ /**
151
+ * Abort the request after this long, INCLUDING the body read. Default 10s. Must stay under
152
+ * the responder's provisioning budget — a hook that stalls must not stall formation.
153
+ */
154
+ timeoutMs?: number;
155
+ /** Injectable for tests / non-standard runtimes. Defaults to `globalThis.fetch`. */
156
+ fetchImpl?: typeof fetch;
157
+ }): FormationApprover;
@@ -0,0 +1,375 @@
1
+ import debug from 'debug';
2
+ import { concat as uint8ArrayConcat, fromString as uint8ArrayFromString, toString as uint8ArrayToString } from 'uint8arrays';
3
+ import { sign, verify } from '@optimystic/quereus-plugin-crypto';
4
+ import { formationVouchMessage } from './control-database.js';
5
+ const log = debug('sereus:cadre:formation-approval');
6
+ /** Abort an unanswered approval request after this long when no `timeoutMs` is supplied. */
7
+ const DEFAULT_TIMEOUT_MS = 10000;
8
+ /**
9
+ * Largest approval response body this client will read (64 KiB). An approval is two short
10
+ * base64url strings; anything approaching this is a broken or hostile hook, and reading it
11
+ * unbounded would let that hook stream a redeeming node out of memory mid-formation.
12
+ */
13
+ const MAX_RESPONSE_BYTES = 64 * 1024;
14
+ /**
15
+ * Failure to obtain an approval, carrying the {@link FormationApprovalFailure} category the
16
+ * caller switches on to pick the rejection reason a would-be joiner is told.
17
+ */
18
+ export class FormationApprovalError extends Error {
19
+ constructor(failure, message, options) {
20
+ super(message, options);
21
+ this.failure = failure;
22
+ this.name = 'FormationApprovalError';
23
+ }
24
+ }
25
+ /**
26
+ * The five fields the approver signs, lifted out of the request. Written as an explicit
27
+ * destructure (not a spread-and-delete) so the object posted to the hook can never pick up a
28
+ * field the digest does not cover — in particular the `validationUrl`.
29
+ */
30
+ function vouchFields(request) {
31
+ const { token, usageStampId, strandId, peerKey, disclosure } = request;
32
+ return { token, usageStampId, strandId, peerKey, disclosure };
33
+ }
34
+ /**
35
+ * Approver side: produce the approval for a request. Used by tests, and by anyone writing a
36
+ * hook in TypeScript.
37
+ *
38
+ * `privateKeyB64`'s public half must be the key enrolled as the `ValidationKey` row the
39
+ * redeeming node's control database will verify against — pass that same public key as
40
+ * `validationKey` (derive it with `ed25519PublicKeyFromPrivate` if you only hold the seed).
41
+ * Signing with a key that is not enrolled produces a perfectly valid signature that the
42
+ * database still rejects.
43
+ *
44
+ * Takes the five signed fields, not a full {@link FormationApprovalRequest}: an approver is
45
+ * posted only those five and has no `validationUrl` to hand back. A redeeming node holding a
46
+ * whole request can still pass it straight in.
47
+ *
48
+ * @param fields - The redemption being approved (the five signed fields).
49
+ * @param validationKey - base64url ed25519 public key that this approval claims.
50
+ * @param privateKeyB64 - base64url 32-byte ed25519 seed to sign with.
51
+ */
52
+ export function signFormationApproval(fields, validationKey, privateKeyB64) {
53
+ const validationSignature = sign(formationVouchMessage(fields), privateKeyB64, 'ed25519', 'bytes', 'base64url', 'base64url');
54
+ return { validationKey, validationSignature };
55
+ }
56
+ /**
57
+ * Redeeming side: does this approval actually verify against the bytes we are about to write?
58
+ *
59
+ * A LOCAL PRE-CHECK, not the authority. It turns a bad approval into a legible rejection
60
+ * instead of an opaque `CHECK constraint failed: Authorized` at commit, and it catches the
61
+ * ordinary drift case (the hook signed a different nonce/strand/peer than the one we hold).
62
+ * It cannot substitute for the database check: this verifies against the key the approval
63
+ * itself names, whereas `FormationUsage.Authorized` verifies against the STORED
64
+ * `ValidationKey` row — which is what stops a redeemer approving itself with a key of its own
65
+ * choosing.
66
+ *
67
+ * Returns a boolean and never throws (same contract as `verifyPeerAuthorization`): malformed
68
+ * base64url, a garbage key, or any crypto failure resolves to `false`, logged at debug.
69
+ */
70
+ export function verifyFormationApproval(fields, approval) {
71
+ try {
72
+ return verify(formationVouchMessage(fields), approval.validationSignature, approval.validationKey, 'ed25519', 'bytes', 'base64url', 'base64url');
73
+ }
74
+ catch (error) {
75
+ log('verifyFormationApproval failed: %o', error);
76
+ return false;
77
+ }
78
+ }
79
+ /**
80
+ * Resolve and validate the hook URL before anything is sent.
81
+ *
82
+ * `http:` stays permitted — a self-hosted hook on a LAN is a real deployment — but note that
83
+ * it puts the disclosure text on the wire in clear, and lets anyone on the path see who is
84
+ * joining which strand. Prefer `https:` for a hook reachable off-link.
85
+ */
86
+ function parseHookUrl(validationUrl) {
87
+ let url;
88
+ try {
89
+ url = new URL(validationUrl);
90
+ }
91
+ catch (error) {
92
+ throw new FormationApprovalError('misconfigured', `ValidationUrl is not a valid URL: ${validationUrl}`, { cause: error });
93
+ }
94
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
95
+ throw new FormationApprovalError('misconfigured', `ValidationUrl scheme "${url.protocol}" is not supported; use http: or https:`);
96
+ }
97
+ return url;
98
+ }
99
+ /**
100
+ * The `fetch` this approver will call: the injected one, else the global.
101
+ *
102
+ * Bound to `globalThis` because an unbound global `fetch` is an illegal invocation in browsers
103
+ * (and in any runtime whose `fetch` is a branded method rather than a plain function).
104
+ */
105
+ function resolveFetch(fetchImpl) {
106
+ if (fetchImpl) {
107
+ return fetchImpl;
108
+ }
109
+ if (typeof globalThis.fetch !== 'function') {
110
+ throw new FormationApprovalError('misconfigured', 'No fetch implementation: globalThis.fetch is missing and no fetchImpl was supplied');
111
+ }
112
+ return globalThis.fetch.bind(globalThis);
113
+ }
114
+ /**
115
+ * Read the response body as text, refusing anything over {@link MAX_RESPONSE_BYTES}.
116
+ *
117
+ * Two paths, because `Response.body` is not universally a stream (React Native's fetch has no
118
+ * `body` reader): stream and stop at the cap where possible, otherwise read to completion and
119
+ * measure. The declared `content-length` is checked first so an honest oversized response is
120
+ * rejected without reading it at all — but it is only a hint, so the actual bytes are counted
121
+ * either way.
122
+ *
123
+ * NOTE: on the no-reader path the cap is enforced only AFTER the runtime has buffered the whole
124
+ * body, so a hook that under-declares its `content-length` can still make that runtime hold an
125
+ * arbitrary amount. Harmless where it applies today (React Native clients are not formation
126
+ * responders); if a responder ever runs on a platform without a body reader, that path needs a
127
+ * real streaming read rather than a post-hoc measurement.
128
+ */
129
+ async function readCappedText(response, budget) {
130
+ const declared = Number(response.headers.get('content-length'));
131
+ if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
132
+ throw new FormationApprovalError('malformed', `Approval response declares ${declared} bytes, over the ${MAX_RESPONSE_BYTES}-byte cap`);
133
+ }
134
+ const body = response.body;
135
+ if (!body || typeof body.getReader !== 'function') {
136
+ // NOTE: nothing releases this body when the budget wins — `text()` has already locked it and
137
+ // the runtime owns the buffering. It only applies to readerless-fetch runtimes (React
138
+ // Native), which are not formation responders; a responder on such a platform would need a
139
+ // real streaming read here, as the cap note above already says.
140
+ const text = await budget.race(response.text());
141
+ assertUnderCap(uint8ArrayFromString(text, 'utf8').byteLength);
142
+ return text;
143
+ }
144
+ return readCappedStream(body.getReader(), budget);
145
+ }
146
+ /** Drain a body stream, aborting the read the moment the accumulated bytes pass the cap. */
147
+ async function readCappedStream(reader, budget) {
148
+ const chunks = [];
149
+ let total = 0;
150
+ try {
151
+ for (;;) {
152
+ // Raced, not just aborted: a `fetch` that drops the abort leaves this read pending
153
+ // forever, and the budget is the only thing that ends it. The `finally` below cancels
154
+ // the reader, which is what actually gives the connection back.
155
+ const { done, value } = await budget.race(reader.read());
156
+ if (done) {
157
+ break;
158
+ }
159
+ total += value.byteLength;
160
+ assertUnderCap(total);
161
+ chunks.push(value);
162
+ }
163
+ }
164
+ finally {
165
+ // Releases the connection when we bailed early; a no-op on a fully-drained stream.
166
+ void reader.cancel().catch((error) => log('response cancel failed: %o', error));
167
+ }
168
+ return uint8ArrayToString(uint8ArrayConcat(chunks, total), 'utf8');
169
+ }
170
+ /**
171
+ * Release a body we are never going to read, so the runtime can put the connection back in its
172
+ * pool. Every failure decided from the status line (a refusal, a 5xx, a redirect) and the
173
+ * declared-oversize rejection throw before touching the body, and an undrained body keeps a
174
+ * socket checked out until GC — a node that asks a flapping hook once per redemption would
175
+ * accumulate them. Skips a body already locked by {@link readCappedStream}, which cancels its
176
+ * own reader.
177
+ *
178
+ * NOTE: awaited by `readApproval`, so it sits OUTSIDE the budget — a runtime whose `cancel()`
179
+ * never settles would hold the caller past `timeoutMs` even though the deadline already fired.
180
+ * Every runtime this ships on settles it promptly. If a request is ever seen outliving its budget
181
+ * despite the budget firing, stop awaiting this; the two tests that assert the body is released
182
+ * by the time the rejection surfaces would then have to poll instead.
183
+ */
184
+ async function discardBody(response) {
185
+ const body = response.body;
186
+ if (!body || body.locked) {
187
+ return;
188
+ }
189
+ try {
190
+ await body.cancel();
191
+ }
192
+ catch (error) {
193
+ log('discarding unread response body failed: %o', error);
194
+ }
195
+ }
196
+ function assertUnderCap(bytes) {
197
+ if (bytes > MAX_RESPONSE_BYTES) {
198
+ throw new FormationApprovalError('malformed', `Approval response exceeds the ${MAX_RESPONSE_BYTES}-byte cap`);
199
+ }
200
+ }
201
+ /** A blank (or whitespace-only) field is as unusable as a missing one — both are `malformed`. */
202
+ function requireField(body, field) {
203
+ const value = body[field];
204
+ if (typeof value !== 'string' || value.trim() === '') {
205
+ throw new FormationApprovalError('malformed', `Approval response is missing a usable "${field}"`);
206
+ }
207
+ return value;
208
+ }
209
+ /** Parse a 2xx body into an approval, rejecting anything that is not the documented shape. */
210
+ function parseApproval(text) {
211
+ let parsed;
212
+ try {
213
+ parsed = JSON.parse(text);
214
+ }
215
+ catch (error) {
216
+ throw new FormationApprovalError('malformed', 'Approval response is not JSON', { cause: error });
217
+ }
218
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
219
+ throw new FormationApprovalError('malformed', 'Approval response is not a JSON object');
220
+ }
221
+ const body = parsed;
222
+ return {
223
+ validationKey: requireField(body, 'validationKey'),
224
+ validationSignature: requireField(body, 'validationSignature')
225
+ };
226
+ }
227
+ /**
228
+ * Map a response's STATUS — never its body shape — onto a failure. A `403` carrying a
229
+ * well-formed approval is still a refusal: the hook said no, and a redeeming node that read
230
+ * the body anyway would let a hook accidentally approve by echoing.
231
+ */
232
+ function assertApprovingStatus(response, origin) {
233
+ if (response.status === 401 || response.status === 403) {
234
+ throw new FormationApprovalError('refused', `Approval hook ${origin} refused this redemption (HTTP ${response.status})`);
235
+ }
236
+ if (!response.ok) {
237
+ throw new FormationApprovalError('unavailable', `Approval hook ${origin} answered HTTP ${response.status}`);
238
+ }
239
+ // Belt and braces for runtimes that ignore `redirect: 'error'` rather than rejecting.
240
+ if (response.redirected) {
241
+ throw new FormationApprovalError('unavailable', `Approval hook ${origin} redirected; re-publish the ValidationUrl instead of chasing it`);
242
+ }
243
+ }
244
+ /**
245
+ * Turn a response into an approval, releasing the body on every path that rejects it.
246
+ */
247
+ async function readApproval(response, origin, budget) {
248
+ try {
249
+ assertApprovingStatus(response, origin);
250
+ return parseApproval(await readCappedText(response, budget));
251
+ }
252
+ catch (error) {
253
+ await discardBody(response);
254
+ throw error;
255
+ }
256
+ }
257
+ /**
258
+ * Arm the budget for one request. The caller's `signal` is relayed by hand rather than with
259
+ * `AbortSignal.any` (not reliably present on React Native/Hermes; this client commits to
260
+ * `fetch` + `AbortController` only).
261
+ */
262
+ function startBudget(timeoutMs, origin, controller, signal) {
263
+ let expired = false;
264
+ let expire;
265
+ const deadline = new Promise((_resolve, reject) => { expire = reject; });
266
+ // The deadline can fire between two raced awaits, with nothing attached to it; a bare handler
267
+ // keeps that from surfacing as an unhandled rejection without hiding it from `race`.
268
+ void deadline.catch(() => { });
269
+ const fire = (reason) => {
270
+ if (expired) {
271
+ return;
272
+ }
273
+ expired = true;
274
+ // Rejected BEFORE the abort, so this reason — not whatever a fetch that DOES honour the
275
+ // abort rejects with — is what the race settles on. Both are queued in the same turn, and
276
+ // the first one queued wins.
277
+ expire(new FormationApprovalError('unavailable', reason));
278
+ // Best-effort, and no longer load-bearing: where the runtime honours it, this hands the
279
+ // socket back instead of leaving it checked out until GC.
280
+ controller.abort();
281
+ };
282
+ const timer = setTimeout(() => fire(`Approval hook ${origin} did not answer within ${timeoutMs}ms`), timeoutMs);
283
+ const onCallerAbort = () => fire(`Formation was cancelled while approval hook ${origin} was being asked`);
284
+ signal?.addEventListener('abort', onCallerAbort, { once: true });
285
+ return {
286
+ race: (work) => Promise.race([work, deadline]),
287
+ expired: () => expired,
288
+ dispose: () => {
289
+ // A leaked abort timer keeps a node's event loop alive.
290
+ clearTimeout(timer);
291
+ signal?.removeEventListener('abort', onCallerAbort);
292
+ }
293
+ };
294
+ }
295
+ /**
296
+ * Await the response, giving up when the budget expires — and cleaning up after ourselves if it
297
+ * does: a `fetch` that ignored the abort still delivers its response eventually, and an unread
298
+ * body keeps a connection checked out. A late rejection is logged rather than left unhandled.
299
+ */
300
+ async function fetchWithinBudget(pending, budget) {
301
+ void pending.then((response) => {
302
+ if (budget.expired()) {
303
+ void discardBody(response);
304
+ }
305
+ }, (error) => {
306
+ if (budget.expired()) {
307
+ log('abandoned approval request rejected after its budget expired: %o', error);
308
+ }
309
+ });
310
+ return budget.race(pending);
311
+ }
312
+ /**
313
+ * Ask an outside approval service, over HTTP, whether a would-be joiner may redeem an
314
+ * invitation. Contacted by the INVITING party's node (the formation responder) during
315
+ * redemption — never by the joiner, which neither mints the nonce nor performs the write.
316
+ *
317
+ * Wire contract (documented for hook operators in `docs/api.md`):
318
+ *
319
+ * - `POST <ValidationUrl>`, `content-type: application/json`, `accept: application/json`.
320
+ * - Body is exactly the five signed fields — `{ token, usageStampId, strandId, peerKey,
321
+ * disclosure }` — and nothing else. No owner keys, no bootstrap addresses, no membership
322
+ * keys ever reach the hook.
323
+ * - `200` with `{ "validationKey": "...", "validationSignature": "..." }` is an approval.
324
+ * - `401` / `403` is a refusal; any other non-2xx, a network error, a timeout, or a redirect
325
+ * is `unavailable`; a 2xx that is not a usable approval is `malformed`.
326
+ *
327
+ * Cross-platform by construction: global `fetch` + `AbortController` only, no `node:` imports.
328
+ */
329
+ export function createHttpFormationApprover(options) {
330
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
331
+ // A non-positive or non-finite budget would abort every request before it left, turning a
332
+ // config typo into a hook that appears permanently down. Fail where the mistake was made.
333
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
334
+ throw new FormationApprovalError('misconfigured', `timeoutMs must be a positive number of milliseconds, got ${timeoutMs}`);
335
+ }
336
+ const fetchImpl = options?.fetchImpl;
337
+ return {
338
+ async requestApproval(request, signal) {
339
+ const doFetch = resolveFetch(fetchImpl);
340
+ const url = parseHookUrl(request.validationUrl);
341
+ // Origin only in messages: a ValidationUrl's path/query may carry a hook secret, and
342
+ // these strings reach logs and the joiner's rejection reason.
343
+ const origin = url.origin;
344
+ if (signal?.aborted) {
345
+ throw new FormationApprovalError('unavailable', `Formation was cancelled before approval hook ${origin} was asked`);
346
+ }
347
+ const controller = new AbortController();
348
+ const budget = startBudget(timeoutMs, origin, controller, signal);
349
+ try {
350
+ const response = await fetchWithinBudget(doFetch(url.toString(), {
351
+ method: 'POST',
352
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
353
+ body: JSON.stringify(vouchFields(request)),
354
+ redirect: 'error',
355
+ signal: controller.signal
356
+ }), budget);
357
+ const approval = await readApproval(response, origin, budget);
358
+ log('approval obtained from %s for token %s', origin, request.token);
359
+ return approval;
360
+ }
361
+ catch (error) {
362
+ // Includes the budget's own rejection, which already carries the timeout/cancellation
363
+ // wording; anything else got here without the budget firing, so the hook is unreachable.
364
+ if (error instanceof FormationApprovalError) {
365
+ throw error;
366
+ }
367
+ throw new FormationApprovalError('unavailable', `Approval hook ${origin} could not be reached`, { cause: error });
368
+ }
369
+ finally {
370
+ budget.dispose();
371
+ }
372
+ }
373
+ };
374
+ }
375
+ //# sourceMappingURL=formation-approval.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"formation-approval.js","sourceRoot":"","sources":["../src/formation-approval.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,38 @@
1
+ /** Best-effort POSIX permissions (ignored on Windows / unsupported FSes). */
2
+ export declare const DIR_MODE = 448;
3
+ export declare const FILE_MODE = 384;
4
+ /**
5
+ * Percent-encode a string into a filesystem-safe filename component. Beyond
6
+ * `encodeURIComponent` (which already escapes `/`, spaces, and unicode), this
7
+ * also escapes the unreserved characters `encodeURIComponent` leaves intact but
8
+ * that are unsafe in filenames on some platforms (notably `*` on Windows) — and
9
+ * `.`, so a file suffix appended by the caller is the only literal dot, making
10
+ * suffix-stripping unambiguous. Reversed by `decodeURIComponent`. The result
11
+ * contains only `A-Za-z0-9-_` and `%XX`.
12
+ */
13
+ export declare function encodeFileSafeComponent(component: string): string;
14
+ /** Whether an unknown error is a Node "file/dir not found" (ENOENT). */
15
+ export declare function isNotFound(error: unknown): boolean;
16
+ /**
17
+ * Crash-atomic file write: a concurrent reader sees either the complete
18
+ * previous bytes or the complete new bytes, never a torn file. `dir` is created
19
+ * (0700) if absent; the data is written to `tmpPath` (created exclusively,
20
+ * 0600), fsync'd, then atomically renamed onto `finalPath`. A failure at any
21
+ * point removes the temp file and leaves the previous file untouched.
22
+ *
23
+ * `tmpPath` must be a sibling of `finalPath` (same directory) so the rename
24
+ * stays within one filesystem and is therefore atomic, and should carry a
25
+ * random component so concurrent writers (and crash-orphaned leftovers) are
26
+ * collide-free.
27
+ *
28
+ * Durability bar: the temp file is fsync'd before the rename so a power loss
29
+ * cannot surface a file whose bytes never reached the platter, and `dir` is
30
+ * fsync'd afterwards (best-effort — see {@link syncDirBestEffort}) so the
31
+ * rename itself survives a crash. On POSIX `rename(2)` replaces atomically;
32
+ * Node maps to `MoveFileEx(MOVEFILE_REPLACE_EXISTING)` on Windows, which is
33
+ * also atomic — though it can fail with `EPERM`/`EBUSY` if another process
34
+ * holds the destination open. That cross-process case is out of scope for the
35
+ * single-flight in-process callers here; such a failure surfaces to the caller
36
+ * with the previous file left intact.
37
+ */
38
+ export declare function writeFileAtomically(dir: string, tmpPath: string, finalPath: string, data: Uint8Array): Promise<void>;