@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
@@ -368,9 +368,15 @@ declare schema CadreControl {
368
368
  -- can pollute.
369
369
  VouchUsage text null, -- the CadreInviteUsage.UsageStampId that admitted this row; null on an
370
370
  -- owner-vouched row. A row carries exactly one proof: a signature or a usage.
371
+ Profile text null, -- the machine's own node profile (cadre-core NodeProfile): 'transaction' is an
372
+ -- app (phone, browser), 'storage' an always-on node; null until it self-publishes.
373
+ -- Self-declared and covered by Sig. No insert rule constrains it: an owner could
374
+ -- only mislabel a machine it already has full authority over, and that machine's
375
+ -- next self-publish corrects it.
371
376
  constraint NotRevoked check on insert (
372
377
  not exists (select 1 from Revocation R where R.TableName = 'CadrePeer' and R.StampId = new.StampId)
373
378
  ),
379
+ constraint KnownProfile check (new.Profile is null or new.Profile in ('transaction', 'storage')),
374
380
  constraint RevocationRecorded check on delete (
375
381
  exists (select 1 from Revocation R where R.TableName = 'CadrePeer' and R.StampId = old.StampId and R.RowKey = old.PeerId)
376
382
  ),
@@ -414,10 +420,16 @@ declare schema CadreControl {
414
420
  exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and not exists (select 1 from Revocation R where R.TableName = 'OwnerKey' and R.StampId = A.StampId) and verify(digest('CadreControl.CadrePeer', 'remove', party_id(), old.PeerId, old.StampId), context.Signature, A.Key, 'ed25519'))
415
421
  or exists (select 1 from committed.Revocation R where R.TableName = 'CadrePeer' and R.RowKey = old.PeerId and R.StampId = old.StampId)
416
422
  ),
417
- -- A peer re-publishes its own addrs and freshness under its own key (cadre-core
423
+ -- A peer re-publishes its own addrs, profile and freshness under its own key (cadre-core
418
424
  -- peer-record.ts); everything else is immutable and UpdatedAt must increase. Or an owner
419
425
  -- re-vouches the row over its current stamp, which also clears VouchUsage: the row then
420
- -- stands on the signature and its invitation is no longer consulted for it. StampId never
426
+ -- stands on the signature and its invitation is no longer consulted for it. The owner
427
+ -- branch keeps what the machine itself declares (PublicKey, Multiaddr, Sig, Profile): the
428
+ -- row's stored VouchSig is a valid re-vouch signature, so any machine holding the row can
429
+ -- take this branch. NOTE: it may still bump UpdatedAt (reauthorizeCadrePeer's re-broadcast
430
+ -- needs it), which on a self-published row leaves Sig unverifiable until the machine's
431
+ -- next heartbeat re-signs past it; if that window ever matters, pin UpdatedAt here when
432
+ -- old.Sig is not null. StampId never
421
433
  -- changes on update: an update that rotated it would leave the old stamp free and
422
434
  -- untombstoned, so retiring a stamp is delete plus tombstone. The proof columns are
423
435
  -- compared null-safe: VouchSig and VouchUsage are null on one of the two row kinds, and a
@@ -432,7 +444,7 @@ declare schema CadreControl {
432
444
  and coalesce(new.VouchUsage, '') = coalesce(old.VouchUsage, '')
433
445
  and new.UpdatedAt > coalesce(old.UpdatedAt, 0)
434
446
  and verify(
435
- digest('CadreControl.CadrePeer', 'publish', party_id(), new.PeerId, new.Multiaddr, cast(new.UpdatedAt as text)),
447
+ digest('CadreControl.CadrePeer', 'publish', party_id(), new.PeerId, new.Multiaddr, cast(new.UpdatedAt as text), coalesce(new.Profile, '')),
436
448
  new.Sig, new.PublicKey, 'ed25519')
437
449
  )
438
450
  or (
@@ -441,6 +453,10 @@ declare schema CadreControl {
441
453
  and coalesce(new.VouchOwner, '') = context.OwnerKey
442
454
  and coalesce(new.VouchSig, '') = context.Signature
443
455
  and new.VouchUsage is null
456
+ and coalesce(new.PublicKey, '') = coalesce(old.PublicKey, '')
457
+ and coalesce(new.Multiaddr, '') = coalesce(old.Multiaddr, '')
458
+ and coalesce(new.Sig, '') = coalesce(old.Sig, '')
459
+ and coalesce(new.Profile, '') = coalesce(old.Profile, '')
444
460
  )
445
461
  )
446
462
  ) with context (OwnerKey text null, Signature text);
@@ -1,6 +1,7 @@
1
1
  import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
2
2
  import { getPublicKey } from '@optimystic/quereus-plugin-crypto';
3
- import { peerIdFromString } from '@libp2p/peer-id';
3
+ import { peerIdFromPublicKey, peerIdFromString } from '@libp2p/peer-id';
4
+ import { publicKeyFromRaw } from '@libp2p/crypto/keys';
4
5
  import type { PrivateKey } from '@libp2p/interface';
5
6
 
6
7
  /**
@@ -95,6 +96,24 @@ export function ed25519PublicKeyB64FromPeerId(peerId: string): string | null {
95
96
  }
96
97
  }
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
+
98
117
  /** Longest rejected key value echoed back in an error — a real key is 43 base64url chars. */
99
118
  const REJECTED_VALUE_ECHO_LIMIT = 64;
100
119
 
@@ -122,15 +141,6 @@ function describeRejected(value: string): string {
122
141
  * are a valid point on the Ed25519 curve — a well-formed-but-off-curve key still fails
123
142
  * signature verification later exactly like any other wrong key.
124
143
  *
125
- * **This rule is restated once outside this package**, in `validateOwnerKey`
126
- * (`packages/cadre-provider/src/server/owner-key-validation.ts`): cadre-provider declares no
127
- * `workspace:` dependencies and validates `POST /containers` pins over `uint8arrays`
128
- * directly. Change the rule here and change it there — the copies are kept in step by
129
- * hand, since neither package can import the other's. Each side pins its own copy to the
130
- * same accept/reject table (`test/ed25519-key.spec.ts` here,
131
- * `create-container-owner-keys.test.ts` there), so a change fails the suite of the
132
- * package it was made in and lands the editor on this pointer.
133
- *
134
144
  * @param value - Candidate base64url-encoded Ed25519 public key.
135
145
  * @param label - Human-readable name of the field, used in error messages.
136
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
+ }