@serfab/cadre-core 1.13.0 → 1.15.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 (230) hide show
  1. package/README.md +293 -274
  2. package/dist/cadre-invite-protocol.d.ts +383 -0
  3. package/dist/cadre-invite-protocol.js +774 -0
  4. package/dist/cadre-invite-protocol.js.map +1 -0
  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 +849 -210
  9. package/dist/cadre-node.js +1574 -374
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/claim-proof.d.ts +45 -0
  12. package/dist/claim-proof.js +132 -0
  13. package/dist/claim-proof.js.map +1 -0
  14. package/dist/control-authorization.d.ts +80 -19
  15. package/dist/control-authorization.js +55 -13
  16. package/dist/control-authorization.js.map +1 -1
  17. package/dist/control-cohort.js +1 -1
  18. package/dist/control-cohort.js.map +1 -1
  19. package/dist/control-database.d.ts +605 -108
  20. package/dist/control-database.js +1299 -280
  21. package/dist/control-database.js.map +1 -1
  22. package/dist/control-protocol-guard.d.ts +142 -0
  23. package/dist/control-protocol-guard.js +293 -0
  24. package/dist/control-protocol-guard.js.map +1 -0
  25. package/dist/control-schema.d.ts +1 -1
  26. package/dist/control-schema.js +817 -1067
  27. package/dist/control-schema.js.map +1 -1
  28. package/dist/control-stream.d.ts +4 -0
  29. package/dist/control-stream.js +22 -7
  30. package/dist/control-stream.js.map +1 -1
  31. package/dist/delegate-admission.d.ts +1 -1
  32. package/dist/delegate-admission.js +1 -1
  33. package/dist/device-token.d.ts +11 -9
  34. package/dist/device-token.js +14 -12
  35. package/dist/device-token.js.map +1 -1
  36. package/dist/ed25519-key.d.ts +22 -9
  37. package/dist/ed25519-key.js +47 -10
  38. package/dist/ed25519-key.js.map +1 -1
  39. package/dist/fetch-impl.d.ts +12 -0
  40. package/dist/fetch-impl.js +23 -0
  41. package/dist/fetch-impl.js.map +1 -0
  42. package/dist/file-durable-slot.js +2 -1
  43. package/dist/file-durable-slot.js.map +1 -1
  44. package/dist/file-safe-name.d.ts +25 -0
  45. package/dist/file-safe-name.js +29 -0
  46. package/dist/file-safe-name.js.map +1 -0
  47. package/dist/formation-approval.js +5 -11
  48. package/dist/formation-approval.js.map +1 -1
  49. package/dist/fs-atomic.d.ts +0 -19
  50. package/dist/fs-atomic.js +3 -24
  51. package/dist/fs-atomic.js.map +1 -1
  52. package/dist/index.d.ts +22 -10
  53. package/dist/index.js +54 -11
  54. package/dist/index.js.map +1 -1
  55. package/dist/invitation-bootstrap.d.ts +50 -12
  56. package/dist/invitation-bootstrap.js +76 -13
  57. package/dist/invitation-bootstrap.js.map +1 -1
  58. package/dist/joined-strand-store.d.ts +7 -0
  59. package/dist/joined-strand-store.js +9 -0
  60. package/dist/joined-strand-store.js.map +1 -1
  61. package/dist/key-store-file.js +2 -1
  62. package/dist/key-store-file.js.map +1 -1
  63. package/dist/link-budget.d.ts +25 -33
  64. package/dist/link-budget.js +25 -35
  65. package/dist/link-budget.js.map +1 -1
  66. package/dist/machine-removal.d.ts +78 -0
  67. package/dist/machine-removal.js +166 -0
  68. package/dist/machine-removal.js.map +1 -0
  69. package/dist/membership-connection-gater.d.ts +191 -151
  70. package/dist/membership-connection-gater.js +236 -173
  71. package/dist/membership-connection-gater.js.map +1 -1
  72. package/dist/node-claim-payload.d.ts +74 -0
  73. package/dist/node-claim-payload.js +224 -0
  74. package/dist/node-claim-payload.js.map +1 -0
  75. package/dist/node-default-transports.d.ts +27 -0
  76. package/dist/node-default-transports.js +35 -0
  77. package/dist/node-default-transports.js.map +1 -0
  78. package/dist/node-transports.d.ts +30 -0
  79. package/dist/node-transports.js +40 -0
  80. package/dist/node-transports.js.map +1 -0
  81. package/dist/owner-anchor-sync.d.ts +22 -0
  82. package/dist/owner-anchor-sync.js +143 -0
  83. package/dist/owner-anchor-sync.js.map +1 -0
  84. package/dist/peer-authorization.d.ts +113 -18
  85. package/dist/peer-authorization.js +225 -29
  86. package/dist/peer-authorization.js.map +1 -1
  87. package/dist/peer-dial.d.ts +143 -27
  88. package/dist/peer-dial.js +276 -31
  89. package/dist/peer-dial.js.map +1 -1
  90. package/dist/peer-join-backfill.d.ts +26 -2
  91. package/dist/peer-join-backfill.js +60 -12
  92. package/dist/peer-join-backfill.js.map +1 -1
  93. package/dist/peer-record.d.ts +17 -52
  94. package/dist/peer-record.js +20 -58
  95. package/dist/peer-record.js.map +1 -1
  96. package/dist/pending-join-runner.d.ts +66 -58
  97. package/dist/pending-join-runner.js +136 -145
  98. package/dist/pending-join-runner.js.map +1 -1
  99. package/dist/primary-lan-address.d.ts +8 -0
  100. package/dist/primary-lan-address.js +46 -0
  101. package/dist/primary-lan-address.js.map +1 -0
  102. package/dist/provider/client.d.ts +131 -0
  103. package/dist/provider/client.js +226 -0
  104. package/dist/provider/client.js.map +1 -0
  105. package/dist/provider/descriptor.d.ts +41 -0
  106. package/dist/provider/descriptor.js +103 -0
  107. package/dist/provider/descriptor.js.map +1 -0
  108. package/dist/provider/index.d.ts +9 -0
  109. package/dist/provider/index.js +10 -0
  110. package/dist/provider/index.js.map +1 -0
  111. package/dist/provider/oauth.d.ts +125 -0
  112. package/dist/provider/oauth.js +199 -0
  113. package/dist/provider/oauth.js.map +1 -0
  114. package/dist/provider/rent-node.d.ts +101 -0
  115. package/dist/provider/rent-node.js +220 -0
  116. package/dist/provider/rent-node.js.map +1 -0
  117. package/dist/push-fanout.d.ts +8 -2
  118. package/dist/push-fanout.js +8 -2
  119. package/dist/push-fanout.js.map +1 -1
  120. package/dist/relay-addrs.d.ts +9 -4
  121. package/dist/relay-addrs.js +9 -4
  122. package/dist/relay-addrs.js.map +1 -1
  123. package/dist/relay-reservation.d.ts +51 -16
  124. package/dist/relay-reservation.js +107 -24
  125. package/dist/relay-reservation.js.map +1 -1
  126. package/dist/seed-bootstrap.d.ts +165 -123
  127. package/dist/seed-bootstrap.js +313 -275
  128. package/dist/seed-bootstrap.js.map +1 -1
  129. package/dist/seed-trust-policy.d.ts +98 -37
  130. package/dist/seed-trust-policy.js +188 -2
  131. package/dist/seed-trust-policy.js.map +1 -1
  132. package/dist/strand-addr-protocol.d.ts +24 -26
  133. package/dist/strand-addr-protocol.js +19 -26
  134. package/dist/strand-addr-protocol.js.map +1 -1
  135. package/dist/strand-commit-durability.d.ts +54 -0
  136. package/dist/strand-commit-durability.js +74 -0
  137. package/dist/strand-commit-durability.js.map +1 -0
  138. package/dist/strand-database.d.ts +23 -8
  139. package/dist/strand-database.js +41 -13
  140. package/dist/strand-database.js.map +1 -1
  141. package/dist/strand-first-sync-gate.d.ts +7 -1
  142. package/dist/strand-first-sync-gate.js +7 -1
  143. package/dist/strand-first-sync-gate.js.map +1 -1
  144. package/dist/strand-formation-protocol.js +5 -2
  145. package/dist/strand-formation-protocol.js.map +1 -1
  146. package/dist/strand-fret-addrs.d.ts +19 -1
  147. package/dist/strand-fret-addrs.js +27 -9
  148. package/dist/strand-fret-addrs.js.map +1 -1
  149. package/dist/strand-instance-manager.d.ts +81 -19
  150. package/dist/strand-instance-manager.js +216 -105
  151. package/dist/strand-instance-manager.js.map +1 -1
  152. package/dist/strand-move.d.ts +124 -0
  153. package/dist/strand-move.js +438 -0
  154. package/dist/strand-move.js.map +1 -0
  155. package/dist/strand-network-config.js +7 -6
  156. package/dist/strand-network-config.js.map +1 -1
  157. package/dist/strand-peer-redial.d.ts +113 -0
  158. package/dist/strand-peer-redial.js +236 -0
  159. package/dist/strand-peer-redial.js.map +1 -0
  160. package/dist/strand-revocation-enforcer.d.ts +11 -11
  161. package/dist/strand-revocation-enforcer.js +11 -11
  162. package/dist/strand-wake-protocol.d.ts +13 -16
  163. package/dist/strand-wake-protocol.js +23 -27
  164. package/dist/strand-wake-protocol.js.map +1 -1
  165. package/dist/trusted-owner-store.d.ts +35 -5
  166. package/dist/trusted-owner-store.js +50 -8
  167. package/dist/trusted-owner-store.js.map +1 -1
  168. package/dist/types.d.ts +512 -89
  169. package/dist/types.js +10 -0
  170. package/dist/types.js.map +1 -1
  171. package/package.json +19 -10
  172. package/src/cadre-invite-protocol.ts +1052 -0
  173. package/src/cadre-join-request.ts +167 -0
  174. package/src/cadre-node.ts +1703 -386
  175. package/src/claim-proof.ts +147 -0
  176. package/src/control-authorization.ts +203 -117
  177. package/src/control-cohort.ts +1 -1
  178. package/src/control-database.ts +4663 -3453
  179. package/src/control-protocol-guard.ts +348 -0
  180. package/src/control-schema.ts +828 -1078
  181. package/src/control-stream.ts +26 -7
  182. package/src/delegate-admission.ts +1 -1
  183. package/src/device-token.ts +14 -10
  184. package/src/ed25519-key.ts +47 -10
  185. package/src/fetch-impl.ts +23 -0
  186. package/src/file-durable-slot.ts +64 -63
  187. package/src/file-safe-name.ts +32 -0
  188. package/src/formation-approval.ts +604 -610
  189. package/src/fs-atomic.ts +92 -117
  190. package/src/index.ts +789 -648
  191. package/src/invitation-bootstrap.ts +91 -13
  192. package/src/joined-strand-store.ts +508 -498
  193. package/src/key-store-file.ts +142 -141
  194. package/src/link-budget.ts +25 -36
  195. package/src/machine-removal.ts +216 -0
  196. package/src/membership-connection-gater.ts +259 -191
  197. package/src/node-claim-payload.ts +259 -0
  198. package/src/node-default-transports.ts +38 -0
  199. package/src/node-transports.ts +49 -0
  200. package/src/owner-anchor-sync.ts +171 -0
  201. package/src/peer-authorization.ts +258 -29
  202. package/src/peer-dial.ts +604 -315
  203. package/src/peer-join-backfill.ts +62 -11
  204. package/src/peer-record.ts +159 -197
  205. package/src/pending-join-runner.ts +729 -735
  206. package/src/primary-lan-address.ts +47 -0
  207. package/src/provider/client.ts +326 -0
  208. package/src/provider/descriptor.ts +129 -0
  209. package/src/provider/index.ts +59 -0
  210. package/src/provider/oauth.ts +285 -0
  211. package/src/provider/rent-node.ts +275 -0
  212. package/src/push-fanout.ts +8 -2
  213. package/src/relay-addrs.ts +9 -4
  214. package/src/relay-reservation.ts +1211 -1121
  215. package/src/seed-bootstrap.ts +1502 -1448
  216. package/src/seed-trust-policy.ts +353 -140
  217. package/src/strand-addr-protocol.ts +26 -35
  218. package/src/strand-commit-durability.ts +116 -0
  219. package/src/strand-database.ts +46 -14
  220. package/src/strand-first-sync-gate.ts +7 -1
  221. package/src/strand-formation-protocol.ts +1039 -1036
  222. package/src/strand-fret-addrs.ts +179 -145
  223. package/src/strand-instance-manager.ts +2026 -1883
  224. package/src/strand-move.ts +585 -0
  225. package/src/strand-network-config.ts +7 -6
  226. package/src/strand-peer-redial.ts +307 -0
  227. package/src/strand-revocation-enforcer.ts +11 -11
  228. package/src/strand-wake-protocol.ts +23 -30
  229. package/src/trusted-owner-store.ts +242 -171
  230. package/src/types.ts +2687 -2242
@@ -35,6 +35,10 @@ export interface ControlStream extends AsyncIterable<Uint8Array> {
35
35
  send(data: Uint8Array): boolean;
36
36
  close(): Promise<void>;
37
37
  abort(err: Error): void;
38
+ /** Whether the remote has half-closed its write end (`'closed'`); absent on test doubles. */
39
+ readonly remoteWriteStatus?: string;
40
+ /** Bytes received and not yet read; absent on test doubles. */
41
+ readonly readBufferLength?: number;
38
42
  }
39
43
 
40
44
  /** Write a JSON object as a single 4-byte big-endian length-prefixed frame. */
@@ -206,16 +210,31 @@ export function readStreamToEnd(
206
210
  return Promise.race([readLoop, timeout]).finally(() => clearTimeout(timer));
207
211
  }
208
212
 
209
- /** Accumulate every chunk to EOF, rejecting once the running total exceeds `maxBytes`. */
213
+ /**
214
+ * Accumulate every chunk to EOF, rejecting once the running total exceeds `maxBytes`.
215
+ *
216
+ * A remote that half-closed before this read began has already sent everything it will,
217
+ * so the read ends once those buffered bytes are in. Its own end-of-stream cannot be
218
+ * waited for: libp2p 3.3.11's stream iterator ends on the one-shot `remoteCloseWrite`
219
+ * event, and an iterator attached after it fired never ends. That happens to any handler
220
+ * that awaits before reading, as the control-protocol guard's live membership check makes
221
+ * the wake and strand-addr handlers do.
222
+ */
210
223
  async function collect(stream: ControlStream, maxBytes: number, label: string): Promise<Uint8Array> {
211
224
  const chunks: Uint8Array[] = [];
212
225
  let total = 0;
213
- for await (const chunk of stream) {
214
- const bytes = toBytes(chunk);
215
- chunks.push(bytes);
216
- total += bytes.length;
217
- if (total > maxBytes) {
218
- throw new Error(`${label} message too large: ${total} bytes exceeds max ${maxBytes}`);
226
+ const alreadySent = stream.remoteWriteStatus === 'closed' ? (stream.readBufferLength ?? 0) : undefined;
227
+ if (alreadySent !== 0) {
228
+ for await (const chunk of stream) {
229
+ const bytes = toBytes(chunk);
230
+ chunks.push(bytes);
231
+ total += bytes.length;
232
+ if (total > maxBytes) {
233
+ throw new Error(`${label} message too large: ${total} bytes exceeds max ${maxBytes}`);
234
+ }
235
+ if (alreadySent !== undefined && total >= alreadySent) {
236
+ break;
237
+ }
219
238
  }
220
239
  }
221
240
 
@@ -21,7 +21,7 @@
21
21
  * granted delegate is admitted outright, never drawing on the small
22
22
  * unauthorized-reservation budget that unplaced peers share
23
23
  * (`membership-connection-gater.ts` → "The relay-reservation seam") and never
24
- * racing its not-reserving connection deadline. The fail-closed per-stream gate
24
+ * put on the provisional connection deadline. The fail-closed per-stream gate
25
25
  * (`authorizeInboundControlStream`) never honors a grant, so a delegate gets
26
26
  * the connection and its reservation (all a circuit-relay `hop` needs) and
27
27
  * nothing more.
@@ -9,15 +9,16 @@
9
9
  * encoding, so no field split is ambiguous even with an opaque platform token. It
10
10
  * is intentionally NOT JSON-canonicalized (deterministic across node/browser/RN,
11
11
  * no key ordering) and is reconstructable inside the `DeviceToken.AuthorizedUpdate`
12
- * SQL constraint from the row's own columns:
12
+ * SQL constraint from the row's own columns and the node-local `party_id()`:
13
13
  *
14
- * digest('CadreControl.DeviceToken', 'publish',
14
+ * digest('CadreControl.DeviceToken', 'publish', party_id(),
15
15
  * new.PeerId, new.Platform, new.Token, cast(new.UpdatedAt as text))
16
16
  *
17
17
  * Keep {@link deviceTokenSignedPayload} and that constraint byte-for-byte in sync.
18
18
  * The `'publish'` action tag marks this as a peer's SELF-signed record (signed with
19
19
  * the peer's own key, not an owner key) and keeps it disjoint from every
20
- * owner-signed digest.
20
+ * owner-signed digest; the party id keeps a record published into one party's table
21
+ * from verifying in another's (peer-record.ts says why a node uses its own party).
21
22
  *
22
23
  * Unlike a peer-address record, a device-token record carries NO public key: the
23
24
  * signature is verified against the `CadrePeer.PublicKey` bound to the same PeerId
@@ -34,10 +35,11 @@ import type { DeviceTokenRecord, PushPlatform } from './types.js';
34
35
  * `DeviceToken.AuthorizedUpdate` constraint exactly; both sides take the default
35
36
  * base64url output of a single `digest(...)`, which round-trips cleanly.
36
37
  */
37
- export function deviceTokenSignedPayload(record: Omit<DeviceTokenRecord, 'sig'>): string {
38
+ export function deviceTokenSignedPayload(partyId: string, record: Omit<DeviceTokenRecord, 'sig'>): string {
38
39
  const fields = controlAuthorizationFields(
39
40
  'CadreControl.DeviceToken',
40
41
  'publish',
42
+ partyId,
41
43
  [record.peerId, record.platform, record.token, String(record.updatedAt)]
42
44
  );
43
45
  return digest(fields, 'sha256', 'base64url') as string;
@@ -47,14 +49,16 @@ export function deviceTokenSignedPayload(record: Omit<DeviceTokenRecord, 'sig'>)
47
49
  * Sign a device-token record with the ed25519 private key behind its `peerId`.
48
50
  * Returns a fully-populated {@link DeviceTokenRecord}.
49
51
  *
52
+ * @param partyId - the party whose `DeviceToken` table the record is published into
50
53
  * @param fields - the record fields to sign
51
54
  * @param privateKeyB64 - base64url ed25519 seed (see `ed25519KeyPairFromLibp2p`)
52
55
  */
53
56
  export function signDeviceTokenRecord(
57
+ partyId: string,
54
58
  fields: Omit<DeviceTokenRecord, 'sig'>,
55
59
  privateKeyB64: string
56
60
  ): DeviceTokenRecord {
57
- const payloadDigest = deviceTokenSignedPayload(fields);
61
+ const payloadDigest = deviceTokenSignedPayload(partyId, fields);
58
62
  const sig = sign(
59
63
  payloadDigest,
60
64
  privateKeyB64,
@@ -68,15 +72,15 @@ export function signDeviceTokenRecord(
68
72
 
69
73
  /**
70
74
  * Verify a record's self-signature against the `CadrePeer.PublicKey` (base64url)
71
- * bound to its `peerId`. Reconstructs the signed bytes from the record exactly as
72
- * {@link signDeviceTokenRecord} produced them. Returns false on a missing key/sig or
73
- * any verification failure.
75
+ * bound to its `peerId`, for `partyId`. Reconstructs the signed bytes from the record
76
+ * exactly as {@link signDeviceTokenRecord} produced them. Returns false on a missing
77
+ * key/sig or any verification failure.
74
78
  */
75
- export function verifyDeviceTokenSignature(record: DeviceTokenRecord, publicKeyB64: string): boolean {
79
+ export function verifyDeviceTokenSignature(partyId: string, record: DeviceTokenRecord, publicKeyB64: string): boolean {
76
80
  if (!publicKeyB64 || !record.sig) {
77
81
  return false;
78
82
  }
79
- const payloadDigest = deviceTokenSignedPayload(record);
83
+ const payloadDigest = deviceTokenSignedPayload(partyId, record);
80
84
  return verify(
81
85
  payloadDigest,
82
86
  record.sig,
@@ -1,5 +1,7 @@
1
1
  import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
2
2
  import { getPublicKey } from '@optimystic/quereus-plugin-crypto';
3
+ import { peerIdFromPublicKey, peerIdFromString } from '@libp2p/peer-id';
4
+ import { publicKeyFromRaw } from '@libp2p/crypto/keys';
3
5
  import type { PrivateKey } from '@libp2p/interface';
4
6
 
5
7
  /**
@@ -68,12 +70,56 @@ export function ed25519PublicKeyFromPrivate(privateKeyB64: string): string {
68
70
  return getPublicKey(privateKeyB64, 'ed25519', 'base64url', 'base64url') as string;
69
71
  }
70
72
 
73
+ /**
74
+ * Derive the base64url ed25519 public key embedded in an Ed25519 libp2p PeerId.
75
+ *
76
+ * An Ed25519 PeerId is an identity multihash of the public key, so
77
+ * `peerIdFromString(id).publicKey.raw` is the 32-byte ed25519 key whose
78
+ * base64url form matches the `OwnerKey.Key` representation (and
79
+ * `ed25519KeyPairFromLibp2p().publicKeyB64`). Returns null for a non-Ed25519
80
+ * id, a missing embedded key, or any parse failure — callers treat null as
81
+ * "not an owner" rather than throwing.
82
+ *
83
+ * Lives here, beside the other key bridges, so the lightweight verifiers in
84
+ * `peer-authorization.ts` can check a stored `PeerKey` against a row's `PeerId`
85
+ * without importing the seed-bootstrap runtime.
86
+ */
87
+ export function ed25519PublicKeyB64FromPeerId(peerId: string): string | null {
88
+ try {
89
+ const parsed = peerIdFromString(peerId);
90
+ if (parsed.type !== 'Ed25519' || !parsed.publicKey) {
91
+ return null;
92
+ }
93
+ return uint8ArrayToString(parsed.publicKey.raw, 'base64url');
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * The inverse of {@link ed25519PublicKeyB64FromPeerId}: the libp2p peer id whose identity
101
+ * is this ed25519 public key (base64url), or null when the value does not decode to a
102
+ * 32-byte key. How an `OwnerKey` row is matched to the machine it names
103
+ * (`machine-removal.ts`), since a machine's owner key is the key behind its peer id.
104
+ */
105
+ export function peerIdFromEd25519PublicKeyB64(publicKeyB64: string): string | null {
106
+ try {
107
+ const raw = uint8ArrayFromString(publicKeyB64, 'base64url');
108
+ if (raw.length !== 32) {
109
+ return null;
110
+ }
111
+ return peerIdFromPublicKey(publicKeyFromRaw(raw)).toString();
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
71
117
  /** Longest rejected key value echoed back in an error — a real key is 43 base64url chars. */
72
118
  const REJECTED_VALUE_ECHO_LIMIT = 64;
73
119
 
74
120
  /**
75
121
  * Render a rejected value for an error message, capped: owner keys now reach this check
76
- * from remote-supplied fields (a `CadreInvite`'s `ownerKeys`, a donation request's
122
+ * from remote-supplied fields (a cadre invitation's `ownerKeys`, a donation request's
77
123
  * `ownerKeys`), so an unbounded echo would let a peer turn one junk string into a
78
124
  * megabyte of log line.
79
125
  */
@@ -95,15 +141,6 @@ function describeRejected(value: string): string {
95
141
  * are a valid point on the Ed25519 curve — a well-formed-but-off-curve key still fails
96
142
  * signature verification later exactly like any other wrong key.
97
143
  *
98
- * **This rule is restated once outside this package**, in `validateOwnerKey`
99
- * (`packages/cadre-provider/src/server/owner-key-validation.ts`): cadre-provider declares no
100
- * `workspace:` dependencies and validates `POST /containers` pins over `uint8arrays`
101
- * directly. Change the rule here and change it there — the copies are kept in step by
102
- * hand, since neither package can import the other's. Each side pins its own copy to the
103
- * same accept/reject table (`test/ed25519-key.spec.ts` here,
104
- * `create-container-owner-keys.test.ts` there), so a change fails the suite of the
105
- * package it was made in and lands the editor on this pointer.
106
- *
107
144
  * @param value - Candidate base64url-encoded Ed25519 public key.
108
145
  * @param label - Human-readable name of the field, used in error messages.
109
146
  * @returns The trimmed value, so callers write the same bytes they validated.
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The `fetch` a cross-platform HTTP caller in this package uses: the one injected for tests
3
+ * and embedders, else the runtime's global. Shared by the formation-approval client and the
4
+ * provider client, so both resolve it the same way.
5
+ *
6
+ * Bound to `globalThis` because an unbound global `fetch` is an illegal invocation in browsers
7
+ * (and in any runtime whose `fetch` is a branded method rather than a plain function).
8
+ */
9
+
10
+ /** The injected `fetch`, else the bound global; undefined when the runtime has none. */
11
+ export function resolveFetch(fetchImpl?: typeof fetch): typeof fetch | undefined {
12
+ if (fetchImpl) return fetchImpl;
13
+ return typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : undefined;
14
+ }
15
+
16
+ /** {@link resolveFetch}, throwing when the runtime has no `fetch` and none was injected. */
17
+ export function requireFetch(fetchImpl?: typeof fetch): typeof fetch {
18
+ const resolved = resolveFetch(fetchImpl);
19
+ if (!resolved) {
20
+ throw new Error('No fetch implementation: globalThis.fetch is missing and none was supplied');
21
+ }
22
+ return resolved;
23
+ }
@@ -1,63 +1,64 @@
1
- /**
2
- * Node-only {@link DurableSlot} backend: one JSON file per party under `dir`,
3
- * at `<dir>/<name>.<encoded partyId>.json` (0600, atomically replaced on every
4
- * save), so one directory can hold the same node-local record for several
5
- * parties without cross-party leakage.
6
- *
7
- * This is the whole platform-specific part of the file-backed node-local
8
- * stores (`trusted-owner-store-file.ts`, `bootstrap-peer-store-file.ts`,
9
- * `enrolled-machine-store-file.ts`); the load policy, envelope and write chain
10
- * are cross-platform — in `node-local-snapshot.ts` for the first two, and in
11
- * `enrolled-machine-store.ts` for the third, which deliberately does not share
12
- * that machinery. Like its three importers, this module imports
13
- * `node:fs/promises`, so it is kept OUT of the package's cross-platform
14
- * default entry and is not itself an exported subpath.
15
- */
16
- import { readFile } from 'node:fs/promises';
17
- import { join } from 'node:path';
18
- import { randomBytes } from 'node:crypto';
19
- import { encodeFileSafeComponent, isNotFound, writeFileAtomically } from './fs-atomic.js';
20
- import type { DurableSlot } from './node-local-snapshot.js';
21
-
22
- export class FileDurableSlot implements DurableSlot {
23
- /** Filename-safe form of the party id, shared by the record and temp paths. */
24
- private readonly encodedParty: string;
25
-
26
- constructor(
27
- private readonly dir: string,
28
- private readonly name: string,
29
- partyId: string
30
- ) {
31
- this.encodedParty = encodeFileSafeComponent(partyId);
32
- }
33
-
34
- /**
35
- * The record's bytes as text. An absent file (ENOENT — including an absent
36
- * directory) is `undefined`, a cold start. Every other read failure
37
- * (EACCES, EISDIR, EIO, …) THROWS, per {@link DurableSlot.load}: the caller
38
- * must not mistake an unreadable file for an empty one and snapshot-write
39
- * over it.
40
- */
41
- async load(): Promise<string | undefined> {
42
- try {
43
- return (await readFile(this.filePath())).toString('utf8');
44
- } catch (error) {
45
- if (isNotFound(error)) return undefined;
46
- throw new Error(`FileDurableSlot: ${this.filePath()} is present but unreadable`, { cause: error });
47
- }
48
- }
49
-
50
- async save(text: string): Promise<void> {
51
- await writeFileAtomically(this.dir, this.tempPath(), this.filePath(), new TextEncoder().encode(text));
52
- }
53
-
54
- private filePath(): string {
55
- return join(this.dir, `${this.name}.${this.encodedParty}.json`);
56
- }
57
-
58
- /** Sibling temp path for the atomic replace (random component: collide-free). */
59
- private tempPath(): string {
60
- const unique = randomBytes(6).toString('hex');
61
- return join(this.dir, `${this.name}.${this.encodedParty}.${unique}.tmp`);
62
- }
63
- }
1
+ /**
2
+ * Node-only {@link DurableSlot} backend: one JSON file per party under `dir`,
3
+ * at `<dir>/<name>.<encoded partyId>.json` (0600, atomically replaced on every
4
+ * save), so one directory can hold the same node-local record for several
5
+ * parties without cross-party leakage.
6
+ *
7
+ * This is the whole platform-specific part of the file-backed node-local
8
+ * stores (`trusted-owner-store-file.ts`, `bootstrap-peer-store-file.ts`,
9
+ * `enrolled-machine-store-file.ts`); the load policy, envelope and write chain
10
+ * are cross-platform — in `node-local-snapshot.ts` for the first two, and in
11
+ * `enrolled-machine-store.ts` for the third, which deliberately does not share
12
+ * that machinery. Like its three importers, this module imports
13
+ * `node:fs/promises`, so it is kept OUT of the package's cross-platform
14
+ * default entry and is not itself an exported subpath.
15
+ */
16
+ import { readFile } from 'node:fs/promises';
17
+ import { join } from 'node:path';
18
+ import { randomBytes } from 'node:crypto';
19
+ import { isNotFound, writeFileAtomically } from './fs-atomic.js';
20
+ import { encodeFileSafeComponent } from './file-safe-name.js';
21
+ import type { DurableSlot } from './node-local-snapshot.js';
22
+
23
+ export class FileDurableSlot implements DurableSlot {
24
+ /** Filename-safe form of the party id, shared by the record and temp paths. */
25
+ private readonly encodedParty: string;
26
+
27
+ constructor(
28
+ private readonly dir: string,
29
+ private readonly name: string,
30
+ partyId: string
31
+ ) {
32
+ this.encodedParty = encodeFileSafeComponent(partyId);
33
+ }
34
+
35
+ /**
36
+ * The record's bytes as text. An absent file (ENOENT — including an absent
37
+ * directory) is `undefined`, a cold start. Every other read failure
38
+ * (EACCES, EISDIR, EIO, …) THROWS, per {@link DurableSlot.load}: the caller
39
+ * must not mistake an unreadable file for an empty one and snapshot-write
40
+ * over it.
41
+ */
42
+ async load(): Promise<string | undefined> {
43
+ try {
44
+ return (await readFile(this.filePath())).toString('utf8');
45
+ } catch (error) {
46
+ if (isNotFound(error)) return undefined;
47
+ throw new Error(`FileDurableSlot: ${this.filePath()} is present but unreadable`, { cause: error });
48
+ }
49
+ }
50
+
51
+ async save(text: string): Promise<void> {
52
+ await writeFileAtomically(this.dir, this.tempPath(), this.filePath(), new TextEncoder().encode(text));
53
+ }
54
+
55
+ private filePath(): string {
56
+ return join(this.dir, `${this.name}.${this.encodedParty}.json`);
57
+ }
58
+
59
+ /** Sibling temp path for the atomic replace (random component: collide-free). */
60
+ private tempPath(): string {
61
+ const unique = randomBytes(6).toString('hex');
62
+ return join(this.dir, `${this.name}.${this.encodedParty}.${unique}.tmp`);
63
+ }
64
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The filename-safe encoding of an id, shared by every Node-only file store that keys a file on
3
+ * a party or key id (`<name>.<encoded id>.json`). A pure string function, so it lives outside
4
+ * `fs-atomic.ts` and is exported from the cross-platform entry: an embedder that keeps its own
5
+ * per-party file beside cadre-core's (cadre-cli's removal marker) names it the same way.
6
+ */
7
+
8
+ /**
9
+ * Percent-encode a string into a filesystem-safe filename component. Beyond
10
+ * `encodeURIComponent` (which already escapes `/`, spaces, and unicode), this
11
+ * also escapes the unreserved characters `encodeURIComponent` leaves intact but
12
+ * that are unsafe in filenames on some platforms (notably `*` on Windows) — and
13
+ * `.`, so a file suffix appended by the caller is the only literal dot, making
14
+ * suffix-stripping unambiguous — and every uppercase letter, because Windows
15
+ * (NTFS) and macOS (APFS/HFS+ by default) ignore case in file names. Reversed by
16
+ * `decodeURIComponent`.
17
+ *
18
+ * The result contains only `a-z0-9-_` and `%XX` escapes (uppercase hex). Since a
19
+ * literal `%` is itself escaped, every `%` starts an escape, so ignoring case
20
+ * can change only escape hex digits — which decode case-insensitively — and two
21
+ * different inputs never yield names equal ignoring case. The escapes keep
22
+ * `encodeURIComponent`'s uppercase hex so a name built from an id with no
23
+ * capital letters (the identity slot `cadre%2Fidentity.key`, a lowercase party
24
+ * id) is the same as before uppercase letters were escaped.
25
+ */
26
+ export function encodeFileSafeComponent(component: string): string {
27
+ // NOTE: a file left from before uppercase letters were escaped (`trusted-owners.Alice.json`) has, ignoring case, the new name of the all-lowercase id (`alice`), so on Windows/macOS a lookup for that id opens it; the per-party records' envelope party-id check rejects it (cold start), and `FileKeyStore` needs two parties in one key directory whose ids fold to the same lowercase string. The release note says to delete old files; if a store ever reads another id's old file in practice, rename or remove old-format files when the store opens.
28
+ return encodeURIComponent(component).replace(
29
+ /(%[0-9A-F]{2})|[!'()*~.A-Z]/g,
30
+ (c, escape: string | undefined) => escape ?? `%${c.charCodeAt(0).toString(16).toUpperCase()}`
31
+ );
32
+ }