@serfab/cadre-core 1.15.0 → 1.17.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 (199) hide show
  1. package/README.md +1 -1
  2. package/dist/bootstrap-peer-store-file.js +1 -1
  3. package/dist/bootstrap-peer-store.d.ts +1 -1
  4. package/dist/bootstrap-peer-store.js +4 -4
  5. package/dist/cadre-invite-protocol.d.ts +55 -138
  6. package/dist/cadre-invite-protocol.js +94 -449
  7. package/dist/cadre-invite-protocol.js.map +1 -1
  8. package/dist/cadre-join-request.d.ts +15 -2
  9. package/dist/cadre-join-request.js +22 -3
  10. package/dist/cadre-join-request.js.map +1 -1
  11. package/dist/cadre-node.d.ts +530 -247
  12. package/dist/cadre-node.js +1046 -525
  13. package/dist/cadre-node.js.map +1 -1
  14. package/dist/control-authorization.d.ts +36 -14
  15. package/dist/control-authorization.js +20 -3
  16. package/dist/control-authorization.js.map +1 -1
  17. package/dist/control-cohort.d.ts +22 -23
  18. package/dist/control-cohort.js +24 -24
  19. package/dist/control-cohort.js.map +1 -1
  20. package/dist/control-database.d.ts +416 -136
  21. package/dist/control-database.js +757 -378
  22. package/dist/control-database.js.map +1 -1
  23. package/dist/control-protocol-guard.d.ts +5 -4
  24. package/dist/control-protocol-guard.js +6 -3
  25. package/dist/control-protocol-guard.js.map +1 -1
  26. package/dist/control-schema-version.d.ts +80 -0
  27. package/dist/control-schema-version.js +203 -0
  28. package/dist/control-schema-version.js.map +1 -0
  29. package/dist/control-schema.d.ts +45 -5
  30. package/dist/control-schema.js +254 -70
  31. package/dist/control-schema.js.map +1 -1
  32. package/dist/control-write-retry.d.ts +5 -5
  33. package/dist/control-write-retry.js +10 -8
  34. package/dist/control-write-retry.js.map +1 -1
  35. package/dist/delegate-admission.d.ts +13 -0
  36. package/dist/delegate-admission.js +27 -0
  37. package/dist/delegate-admission.js.map +1 -1
  38. package/dist/ed25519-key.d.ts +10 -7
  39. package/dist/ed25519-key.js +23 -20
  40. package/dist/ed25519-key.js.map +1 -1
  41. package/dist/edge-node/config.d.ts +122 -0
  42. package/dist/edge-node/config.js +199 -0
  43. package/dist/edge-node/config.js.map +1 -0
  44. package/dist/edge-node/dissolve.d.ts +95 -0
  45. package/dist/edge-node/dissolve.js +184 -0
  46. package/dist/edge-node/dissolve.js.map +1 -0
  47. package/dist/edge-node/edge-join.d.ts +44 -0
  48. package/dist/edge-node/edge-join.js +227 -0
  49. package/dist/edge-node/edge-join.js.map +1 -0
  50. package/dist/edge-node/edge-node.d.ts +295 -0
  51. package/dist/edge-node/edge-node.js +704 -0
  52. package/dist/edge-node/edge-node.js.map +1 -0
  53. package/dist/edge-node/index.d.ts +18 -0
  54. package/dist/edge-node/index.js +18 -0
  55. package/dist/edge-node/index.js.map +1 -0
  56. package/dist/edge-node/join-credential.d.ts +11 -0
  57. package/dist/edge-node/join-credential.js +11 -0
  58. package/dist/edge-node/join-credential.js.map +1 -0
  59. package/dist/edge-node/lazy-raw-storage.d.ts +40 -0
  60. package/dist/edge-node/lazy-raw-storage.js +134 -0
  61. package/dist/edge-node/lazy-raw-storage.js.map +1 -0
  62. package/dist/edge-node/node-local.d.ts +60 -0
  63. package/dist/edge-node/node-local.js +58 -0
  64. package/dist/edge-node/node-local.js.map +1 -0
  65. package/dist/edge-node/options.d.ts +83 -0
  66. package/dist/edge-node/options.js +110 -0
  67. package/dist/edge-node/options.js.map +1 -0
  68. package/dist/edge-node/owner-grant.d.ts +41 -0
  69. package/dist/edge-node/owner-grant.js +38 -0
  70. package/dist/edge-node/owner-grant.js.map +1 -0
  71. package/dist/edge-node/platform.d.ts +140 -0
  72. package/dist/edge-node/platform.js +12 -0
  73. package/dist/edge-node/platform.js.map +1 -0
  74. package/dist/edge-node/strand-move.d.ts +115 -0
  75. package/dist/edge-node/strand-move.js +223 -0
  76. package/dist/edge-node/strand-move.js.map +1 -0
  77. package/dist/edge-node/strands.d.ts +28 -0
  78. package/dist/edge-node/strands.js +60 -0
  79. package/dist/edge-node/strands.js.map +1 -0
  80. package/dist/index.d.ts +16 -7
  81. package/dist/index.js +26 -7
  82. package/dist/index.js.map +1 -1
  83. package/dist/joined-strand-store.d.ts +3 -3
  84. package/dist/joined-strand-store.js +3 -3
  85. package/dist/joined-strand-store.js.map +1 -1
  86. package/dist/key-store.d.ts +9 -4
  87. package/dist/key-store.js +9 -4
  88. package/dist/key-store.js.map +1 -1
  89. package/dist/machine-removal.d.ts +19 -39
  90. package/dist/machine-removal.js +24 -98
  91. package/dist/machine-removal.js.map +1 -1
  92. package/dist/member-redemption.d.ts +250 -0
  93. package/dist/member-redemption.js +485 -0
  94. package/dist/member-redemption.js.map +1 -0
  95. package/dist/owner-anchor-sync.d.ts +4 -4
  96. package/dist/owner-anchor-sync.js +16 -15
  97. package/dist/owner-anchor-sync.js.map +1 -1
  98. package/dist/owner-grant-protocol.d.ts +247 -0
  99. package/dist/owner-grant-protocol.js +330 -0
  100. package/dist/owner-grant-protocol.js.map +1 -0
  101. package/dist/owner-key-export.d.ts +70 -0
  102. package/dist/owner-key-export.js +279 -0
  103. package/dist/owner-key-export.js.map +1 -0
  104. package/dist/owner-key-flows.d.ts +55 -0
  105. package/dist/owner-key-flows.js +60 -0
  106. package/dist/owner-key-flows.js.map +1 -0
  107. package/dist/owner-key-store.d.ts +31 -0
  108. package/dist/owner-key-store.js +81 -0
  109. package/dist/owner-key-store.js.map +1 -0
  110. package/dist/owner-keys.d.ts +78 -0
  111. package/dist/owner-keys.js +133 -0
  112. package/dist/owner-keys.js.map +1 -0
  113. package/dist/owner-signer.d.ts +54 -0
  114. package/dist/owner-signer.js +51 -0
  115. package/dist/owner-signer.js.map +1 -0
  116. package/dist/peer-authorization.d.ts +83 -18
  117. package/dist/peer-authorization.js +123 -53
  118. package/dist/peer-authorization.js.map +1 -1
  119. package/dist/provider/rent-node.js +1 -1
  120. package/dist/provider/rent-node.js.map +1 -1
  121. package/dist/seed-bootstrap.d.ts +131 -109
  122. package/dist/seed-bootstrap.js +260 -248
  123. package/dist/seed-bootstrap.js.map +1 -1
  124. package/dist/seed-trust-policy.js +4 -4
  125. package/dist/strand-addr-protocol.d.ts +4 -1
  126. package/dist/strand-addr-protocol.js +8 -2
  127. package/dist/strand-addr-protocol.js.map +1 -1
  128. package/dist/strand-cohort.d.ts +3 -0
  129. package/dist/strand-cohort.js.map +1 -1
  130. package/dist/strand-database.d.ts +1 -0
  131. package/dist/strand-database.js +3 -0
  132. package/dist/strand-database.js.map +1 -1
  133. package/dist/strand-first-sync-gate.d.ts +4 -3
  134. package/dist/strand-first-sync-gate.js +4 -3
  135. package/dist/strand-first-sync-gate.js.map +1 -1
  136. package/dist/strand-instance-manager.d.ts +1 -1
  137. package/dist/strand-membership-writer.d.ts +13 -2
  138. package/dist/strand-membership-writer.js +52 -16
  139. package/dist/strand-membership-writer.js.map +1 -1
  140. package/dist/strand-move.d.ts +6 -5
  141. package/dist/strand-move.js +31 -30
  142. package/dist/strand-move.js.map +1 -1
  143. package/dist/strand-peer-redial.js +2 -3
  144. package/dist/strand-peer-redial.js.map +1 -1
  145. package/dist/types.d.ts +286 -103
  146. package/dist/types.js.map +1 -1
  147. package/package.json +16 -7
  148. package/src/bootstrap-peer-store-file.ts +44 -44
  149. package/src/bootstrap-peer-store.ts +214 -214
  150. package/src/cadre-invite-protocol.ts +131 -531
  151. package/src/cadre-join-request.ts +188 -167
  152. package/src/cadre-node.ts +1166 -541
  153. package/src/control-authorization.ts +47 -14
  154. package/src/control-cohort.ts +127 -128
  155. package/src/control-database.ts +940 -456
  156. package/src/control-protocol-guard.ts +7 -4
  157. package/src/control-schema-version.ts +251 -0
  158. package/src/control-schema.ts +265 -70
  159. package/src/control-write-retry.ts +569 -567
  160. package/src/delegate-admission.ts +37 -0
  161. package/src/ed25519-key.ts +174 -171
  162. package/src/edge-node/config.ts +277 -0
  163. package/src/edge-node/dissolve.ts +251 -0
  164. package/src/edge-node/edge-join.ts +319 -0
  165. package/src/edge-node/edge-node.ts +1045 -0
  166. package/src/edge-node/index.ts +59 -0
  167. package/src/edge-node/join-credential.ts +16 -0
  168. package/src/edge-node/lazy-raw-storage.ts +178 -0
  169. package/src/edge-node/node-local.ts +84 -0
  170. package/src/edge-node/options.ts +179 -0
  171. package/src/edge-node/owner-grant.ts +63 -0
  172. package/src/edge-node/platform.ts +151 -0
  173. package/src/edge-node/strand-move.ts +270 -0
  174. package/src/edge-node/strands.ts +72 -0
  175. package/src/index.ts +102 -5
  176. package/src/joined-strand-store.ts +4 -4
  177. package/src/key-store.ts +10 -4
  178. package/src/machine-removal.ts +26 -111
  179. package/src/member-redemption.ts +642 -0
  180. package/src/owner-anchor-sync.ts +19 -18
  181. package/src/owner-grant-protocol.ts +532 -0
  182. package/src/owner-key-export.ts +348 -0
  183. package/src/owner-key-flows.ts +109 -0
  184. package/src/owner-key-store.ts +85 -0
  185. package/src/owner-keys.ts +178 -0
  186. package/src/owner-signer.ts +94 -0
  187. package/src/peer-authorization.ts +558 -488
  188. package/src/provider/rent-node.ts +1 -1
  189. package/src/seed-bootstrap.ts +301 -312
  190. package/src/seed-trust-policy.ts +4 -4
  191. package/src/strand-addr-protocol.ts +547 -538
  192. package/src/strand-cohort.ts +4 -0
  193. package/src/strand-database.ts +3 -0
  194. package/src/strand-first-sync-gate.ts +4 -3
  195. package/src/strand-instance-manager.ts +1 -1
  196. package/src/strand-membership-writer.ts +61 -16
  197. package/src/strand-move.ts +45 -36
  198. package/src/strand-peer-redial.ts +2 -3
  199. package/src/types.ts +304 -103
@@ -0,0 +1,151 @@
1
+ /**
2
+ * What an app supplies to run the edge node (`createEdgeNode`): its key store, its storage, and
3
+ * the platform choices (transports, listen addresses, Noise crypto) that cadre-core cannot make
4
+ * for it. Fixed for the app's lifetime.
5
+ */
6
+
7
+ import type { IRawStorage, Libp2pTransports, NoiseCryptoInterface } from '@optimystic/db-p2p';
8
+ import type { KeyStore } from '../key-store.js';
9
+ import type { DurableSlot } from '../node-local-snapshot.js';
10
+ import type { CadreNodeConfig } from '../types.js';
11
+ import type { NodeLocalKv } from './node-local.js';
12
+ import type { NoiseCryptoMode } from './options.js';
13
+
14
+ export interface EdgeNodePlatform {
15
+ /**
16
+ * Everything secret: the identity (`DEFAULT_IDENTITY_KEY_ID`), the owner key in its own slot
17
+ * (`DEFAULT_OWNER_KEY_ID`), the joined-strand records (`KeyStoreJoinedStrandStore`) and the
18
+ * strand-move records. The phone's is the platform secure store; a browser's or a desktop
19
+ * app's may be a plaintext database, which is then where its identity already lives.
20
+ */
21
+ keyStore: KeyStore;
22
+ storage: EdgeNodeStorage;
23
+ /**
24
+ * libp2p transports, given the relays the node reserves on. Default: WebSockets and circuit
25
+ * relay. An app with WebRTC adds `webRTC({ rtcConfiguration: { iceServers } })`, `iceServers`
26
+ * from `resolveStunServers(relayAddrs)`.
27
+ */
28
+ transports?: (relayAddrs: readonly string[]) => Libp2pTransports;
29
+ /**
30
+ * Listen addresses besides the bare `/p2p-circuit` entry cadre-core adds for a named relay
31
+ * (`relay-addrs.ts` → `resolveListenAddrs`). Default `[]`: an edge device cannot accept a
32
+ * connection, so the only address it has is the one a relay reservation earns it. A browser
33
+ * tab returns `['/webrtc']` when it has relays, so a relayed connection can upgrade to a direct
34
+ * one.
35
+ */
36
+ listenAddrs?: (relayAddrs: readonly string[]) => string[];
37
+ /**
38
+ * Noise crypto built outside libp2p-noise: `build` returns the implementation for a mode,
39
+ * `defaultMode` the mode used when the start options name none. Absent: libp2p-noise's own
40
+ * crypto, pure JavaScript on a phone (the React Native kit's `/noise-crypto` is the native one).
41
+ */
42
+ noiseCrypto?: {
43
+ build: (mode: NoiseCryptoMode) => NoiseCryptoInterface | undefined;
44
+ defaultMode: NoiseCryptoMode;
45
+ };
46
+ /**
47
+ * The keys inside the node-local store. Each is a persistence contract: renaming one orphans
48
+ * every installed app's records. The defaults are the reference apps'; an app that names its
49
+ * own should pin them in a test.
50
+ */
51
+ names?: Partial<EdgeNodeNames>;
52
+ /**
53
+ * Dial loopback, private and plain `ws://` addresses. Default `true`: see the
54
+ * `connectionGater` comment in `buildEdgeNodeConfig`.
55
+ */
56
+ allowPrivateDial?: boolean;
57
+ /** Bound on owner genesis; default 60 s. See `runOwnerGenesis`. */
58
+ ownerGenesisTimeoutMs?: number;
59
+ /**
60
+ * A version naming the format of the data this build writes, for example the stack version it
61
+ * was built against. Saved with each successful start and reported by `loadSavedStart` as
62
+ * `writtenBy`, so a later build can tell data it cannot use from data it can.
63
+ */
64
+ dataVersion?: string;
65
+ /**
66
+ * The last word on the config the edge node built: strand filter, schema-signing policy,
67
+ * hibernation, `linkRoundTripMs`, or anything the parameters above do not cover.
68
+ */
69
+ configure?: (config: CadreNodeConfig) => CadreNodeConfig;
70
+ }
71
+
72
+ /** The keys inside the node-local store (`EdgeNodeStorage.nodeLocal`) that are the edge node's. */
73
+ export interface EdgeNodeNames {
74
+ /**
75
+ * Key of the saved-start record. One per app, not per party: it is what names the party every
76
+ * other record is filed under. Dot-free, so it cannot collide with the `<record>.<partyId>`
77
+ * keys of `node-local.ts`.
78
+ */
79
+ savedStartKey: string;
80
+ /**
81
+ * Key of the record naming a party whose dissolve has begun. Dot-free and one per app, as
82
+ * {@link EdgeNodeNames.savedStartKey} is.
83
+ */
84
+ dissolveMarkerKey: string;
85
+ /**
86
+ * Key of the join request this app opened as a link to another app (`LinkRequestStore` in
87
+ * `@serfab/cadre-rn/app-link`). Dot-free and one per app, as {@link EdgeNodeNames.savedStartKey} is.
88
+ */
89
+ linkRequestKey: string;
90
+ /**
91
+ * Key of the apps the user allowed to join before they asked (`AppRecommendationStore` in
92
+ * `@serfab/cadre-rn/app-link`). Dot-free and one per app, as {@link EdgeNodeNames.savedStartKey}
93
+ * is; each entry names its party.
94
+ */
95
+ recommendationsKey: string;
96
+ }
97
+
98
+ export const DEFAULT_EDGE_NODE_NAMES: EdgeNodeNames = {
99
+ savedStartKey: 'start-options',
100
+ dissolveMarkerKey: 'dissolving-party',
101
+ linkRequestKey: 'link-join-request',
102
+ recommendationsKey: 'recommended-apps',
103
+ };
104
+
105
+ /**
106
+ * The app's storage, as the edge node opens, closes and deletes it: one raw block store per
107
+ * cadre-core storage scope, and one node-local key-value store beside them. The platform owns
108
+ * every handle; the edge node only says when it is done with them (`close`).
109
+ */
110
+ export interface EdgeNodeStorage {
111
+ /**
112
+ * Names the storage. `createEdgeNode` refuses a second starting, joining or running edge node
113
+ * over the same id in one process, and replaces a stopped one: two over the same storage
114
+ * would share databases and an identity.
115
+ */
116
+ readonly id: string;
117
+ /**
118
+ * One storage scope's raw block storage: `controlStorageScope(partyId)` for the control
119
+ * network, the strand id for each strand. The key is already safe as a database-name segment;
120
+ * use it verbatim, do not parse it. Synchronous, as `CadreNodeConfig.storage.provider` is; a
121
+ * platform whose opener is async returns `lazyRawStorage(...)`. The same handle for the same
122
+ * scope until {@link close}: cadre-core asks once per scope per runtime, and again after a
123
+ * strand stops.
124
+ */
125
+ rawStorage(scope: string): IRawStorage;
126
+ /** Delete a scope's storage. No handle on it may be open. Resolves when it is gone or was absent. */
127
+ destroyScope(scope: string): Promise<void>;
128
+ /**
129
+ * The node-local key-value store, holding the saved start, the dissolve marker, the app-link
130
+ * records and the per-party records of `node-local.ts`. Usable with no node running: reopens
131
+ * after {@link close}.
132
+ */
133
+ nodeLocal(): NodeLocalKv;
134
+ /**
135
+ * Flush and close the node-local store, then close every handle {@link rawStorage} opened. A
136
+ * failure closing one is logged and the rest still close; the call does not reject.
137
+ */
138
+ close(): Promise<void>;
139
+ /**
140
+ * Per-party slot for the trusted-owner anchor (`PersistentTrustedOwnerStore`). Absent: the
141
+ * node-local key `trustedOwnersKvKey(partyId)`. The anchor is not secret but it is
142
+ * trust-bearing, so the phone puts it in its secure store, beside the identity it qualifies.
143
+ */
144
+ trustedOwnerSlot?(partyId: string): RemovableSlot;
145
+ }
146
+
147
+ /** A {@link DurableSlot} a removal can delete: the trusted-owner anchor of a party being removed. */
148
+ export interface RemovableSlot extends DurableSlot {
149
+ /** Delete the slot's record, durably. Resolves when it is gone or was absent. */
150
+ remove(): Promise<void>;
151
+ }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Carrying the strands of the party this app founded into the party it joins
3
+ * (docs/architecture.md → "An app joins an existing cadre" → "Moving the app's strands").
4
+ *
5
+ * The app runs one node, so cadre-core's two ends of a move run on two nodes, one after the
6
+ * other: `exportStrandBundle` on the left party's node before the switch, `adoptStrandBundle` on
7
+ * the joined party's node once the app's key is wired as an owner there. Between them the bundle
8
+ * is kept here, one record per left party, so a join killed partway, or an owner key wired only
9
+ * on a later launch, finishes the move on a later start. The bundle carries read secrets, party
10
+ * keys and bearer invitations, so the record lives in the key store beside the identity.
11
+ */
12
+
13
+ import { adoptStrandBundle, StrandMoveNotOwnerError } from '../strand-move.js';
14
+ import type { StrandBundle, StrandMoveReport } from '../strand-move.js';
15
+ import type { CadreNode } from '../cadre-node.js';
16
+ import type { KeyId, KeyStore } from '../key-store.js';
17
+ import { isOwnerGrant, type JoinCredential } from './join-credential.js';
18
+ import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
19
+
20
+ /** How a join's strand move settled: `JoinCadreResult.strandMove`. */
21
+ export type StrandMoveOutcome =
22
+ /** Adopted. `partial`: `report.failed` lists what the next start, or `finishStrandMove`, retries. */
23
+ | { state: 'done' | 'partial'; report: StrandMoveReport }
24
+ /** Not adopted yet: the app's key is not wired as an owner of the joined party. A start after it is adopts. */
25
+ | { state: 'deferred'; reason: 'owner-key-not-wired' }
26
+ /** Not adopted yet: the adoption threw (logged). The next start, or `finishStrandMove`, retries. */
27
+ | { state: 'deferred'; reason: 'adoption-failed'; error: Error };
28
+
29
+ /** A move still waiting: `EdgeNode.pendingStrandMove`. */
30
+ export interface PendingStrandMove {
31
+ fromPartyId: string;
32
+ /** The party the strands move into; the record is adopted there and nowhere else. */
33
+ toPartyId: string;
34
+ /** Strands of `fromPartyId` not seated in `toPartyId` yet. */
35
+ strandIds: string[];
36
+ /** Strands `toPartyId` refused permanently so far: it participates in them under its own identity. */
37
+ refused: StrandMoveReport['refused'];
38
+ }
39
+
40
+ /**
41
+ * `EdgeNode.joinCadre` refused before anything stopped: the party this app founded holds strands,
42
+ * pending joins or invitations, and a machine invitation does not make the app an owner of the
43
+ * party it joins. Every row a move seats there is owner-signed, so a member's join would leave
44
+ * them in a party the app no longer runs. Joining with an owner grant moves them.
45
+ */
46
+ export class StrandsNeedOwnerJoinError extends Error {
47
+ readonly fromPartyId: string;
48
+ readonly toPartyId: string;
49
+ readonly strandCount: number;
50
+
51
+ constructor(bundle: StrandBundle, toPartyId: string) {
52
+ super(
53
+ `Cannot join party ${toPartyId} as a member: ${describeContent(bundle)} of party ${bundle.partyId} ` +
54
+ 'would be left behind, and only an owner can move them. Join with an owner grant instead, made under the owner app\'s Owner keys.',
55
+ );
56
+ this.name = 'StrandsNeedOwnerJoinError';
57
+ this.fromPartyId = bundle.partyId;
58
+ this.toPartyId = toPartyId;
59
+ this.strandCount = bundle.strands.length;
60
+ }
61
+ }
62
+
63
+ /** What the key store holds for one left party between the export and a complete adoption. */
64
+ interface StrandMoveRecord {
65
+ v: typeof RECORD_VERSION;
66
+ targetPartyId: string;
67
+ bundle: StrandBundle;
68
+ /** Strand ids an earlier adoption seated. Never left out of a retry: an invitation bound to one needs it under `moved`. */
69
+ moved: string[];
70
+ /** Permanent refusals from earlier adoptions, left out of a retry and reported each time. */
71
+ refused: StrandMoveReport['refused'];
72
+ }
73
+
74
+ const RECORD_VERSION = 1;
75
+
76
+ /** Key-store id prefix of every move record; the left party's id follows, base64url. */
77
+ const SLOT_PREFIX = 'cadre/strand-move/';
78
+
79
+ /**
80
+ * A persistence contract: `cadre/strand-move/<base64url of the party id's UTF-8 bytes>`, the
81
+ * id the React Native kit wrote before the records moved here (its `secureStoreKeySegment`
82
+ * encodes the same way), so an installed phone's record is read back.
83
+ */
84
+ function slotId(fromPartyId: string): KeyId {
85
+ return SLOT_PREFIX + uint8ArrayToString(uint8ArrayFromString(fromPartyId, 'utf8'), 'base64url');
86
+ }
87
+
88
+ /** The move records, one key-store slot per left party. A read the key store refuses throws; it is never read as "no record". */
89
+ export class StrandMoveRecords {
90
+ constructor(private readonly keyStore: KeyStore) {}
91
+
92
+ /**
93
+ * Replaces the left party's record: a newer export is the fuller one.
94
+ *
95
+ * NOTE: on a phone, a bundle with a routing table per strand runs past expo-secure-store's
96
+ * 2048-byte value guideline; version 14 warns and stores it, and the iOS Keychain and Android's
97
+ * encrypted preferences hold more. If a later SDK throws instead, keep the routing tables
98
+ * (not secret) in the node-local store and only the secrets here.
99
+ */
100
+ save(record: StrandMoveRecord): Promise<void> {
101
+ return this.keyStore.set(slotId(record.bundle.partyId), uint8ArrayFromString(JSON.stringify(record), 'utf8'));
102
+ }
103
+
104
+ load(fromPartyId: string): Promise<StrandMoveRecord | undefined> {
105
+ return this.read(slotId(fromPartyId));
106
+ }
107
+
108
+ remove(fromPartyId: string): Promise<void> {
109
+ return this.keyStore.delete(slotId(fromPartyId));
110
+ }
111
+
112
+ /** Every record naming `toPartyId`, in slot order. */
113
+ async naming(toPartyId: string): Promise<StrandMoveRecord[]> {
114
+ const slots = (await this.keyStore.list()).filter((slot) => slot.startsWith(SLOT_PREFIX)).sort();
115
+ const records: StrandMoveRecord[] = [];
116
+ for (const slot of slots) {
117
+ const record = await this.read(slot);
118
+ if (record?.targetPartyId === toPartyId) records.push(record);
119
+ }
120
+ return records;
121
+ }
122
+
123
+ /**
124
+ * The record in `slot`. One that does not parse is deleted with a warning: nothing can adopt
125
+ * it, and the strands are still in the left party's storage. Its absence then reads as
126
+ * "nothing waiting", so code that deletes that storage must not take a missing record alone
127
+ * as proof that every strand reached the joined party.
128
+ */
129
+ private async read(slot: KeyId): Promise<StrandMoveRecord | undefined> {
130
+ const bytes = await this.keyStore.get(slot);
131
+ if (bytes === undefined) return undefined;
132
+ const record = parseRecord(uint8ArrayToString(bytes, 'utf8'));
133
+ if (record !== undefined && slotId(record.bundle.partyId) === slot) return record;
134
+ console.warn(`[cadre-core/edge-node] deleting the strand move record in ${slot}: it is not one this kit wrote`);
135
+ try {
136
+ await this.keyStore.delete(slot);
137
+ } catch (err) {
138
+ // Read as absent either way; failing every start over a record nothing can use would be worse.
139
+ console.warn(`[cadre-core/edge-node] could not delete the strand move record in ${slot}:`, err);
140
+ }
141
+ return undefined;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * The record a join writes before the switch, or `undefined` when the bundle holds nothing to
147
+ * move. Throws {@link StrandsNeedOwnerJoinError} when it does and `credential` is a machine
148
+ * invitation, which does not make the app an owner of the party it joins.
149
+ */
150
+ export function joinMoveRecord(bundle: StrandBundle, credential: JoinCredential): StrandMoveRecord | undefined {
151
+ if (bundle.strands.length + bundle.pendingJoins.length + bundle.formationInvites.length === 0) return undefined;
152
+ if (!isOwnerGrant(credential)) throw new StrandsNeedOwnerJoinError(bundle, credential.partyId);
153
+ return { v: RECORD_VERSION, targetPartyId: credential.partyId, bundle, moved: [], refused: [] };
154
+ }
155
+
156
+ /**
157
+ * Adopt `record` on `node`, running in the record's target party, without the strands an earlier
158
+ * attempt had refused; then delete the record when nothing failed, or save this attempt's
159
+ * progress. The report lists every refusal so far. Throws what `adoptStrandBundle` throws. A
160
+ * failure updating the record is only logged: the record left behind is adopted again, without
161
+ * harm, by the next start.
162
+ */
163
+ export async function adoptMoveRecord(node: CadreNode, records: StrandMoveRecords, record: StrandMoveRecord): Promise<StrandMoveReport> {
164
+ const refused = new Set(record.refused.map((entry) => entry.strandId));
165
+ const strands = record.bundle.strands.filter((entry) => !refused.has(entry.row.Id));
166
+ const adopted = await adoptStrandBundle(node, { ...record.bundle, strands });
167
+ const report: StrandMoveReport = { ...adopted, refused: [...record.refused, ...adopted.refused] };
168
+ await keepWhatIsLeft(records, record, report);
169
+ return report;
170
+ }
171
+
172
+ async function keepWhatIsLeft(records: StrandMoveRecords, record: StrandMoveRecord, report: StrandMoveReport): Promise<void> {
173
+ const fromPartyId = record.bundle.partyId;
174
+ try {
175
+ if (report.failed.length === 0) {
176
+ await records.remove(fromPartyId);
177
+ } else {
178
+ const moved = [...new Set([...record.moved, ...report.moved])];
179
+ await records.save({ ...record, moved, refused: report.refused });
180
+ }
181
+ } catch (err) {
182
+ console.warn(`[cadre-core/edge-node] could not update the strand move record of party ${fromPartyId}; the next start adopts it again:`, err);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Adopt every record naming the node's party, by left party. A record the key store refuses to
188
+ * read throws; an adoption that throws is logged and reported as deferred, and its record kept.
189
+ */
190
+ export async function adoptMovesInto(node: CadreNode, records: StrandMoveRecords): Promise<Map<string, StrandMoveOutcome>> {
191
+ const outcomes = new Map<string, StrandMoveOutcome>();
192
+ for (const record of await records.naming(node.partyId)) {
193
+ outcomes.set(record.bundle.partyId, await adoptOrDefer(node, records, record));
194
+ }
195
+ return outcomes;
196
+ }
197
+
198
+ async function adoptOrDefer(node: CadreNode, records: StrandMoveRecords, record: StrandMoveRecord): Promise<StrandMoveOutcome> {
199
+ try {
200
+ const report = await adoptMoveRecord(node, records, record);
201
+ return { state: report.failed.length === 0 ? 'done' : 'partial', report };
202
+ } catch (err) {
203
+ console.warn(`[cadre-core/edge-node] moving the strands of party ${record.bundle.partyId} failed; the next start retries:`, err);
204
+ return deferredOutcome(err);
205
+ }
206
+ }
207
+
208
+ export function deferredOutcome(err: unknown): StrandMoveOutcome {
209
+ if (err instanceof StrandMoveNotOwnerError) return { state: 'deferred', reason: 'owner-key-not-wired' };
210
+ return { state: 'deferred', reason: 'adoption-failed', error: toError(err) };
211
+ }
212
+
213
+ /** A thrown value as an `Error`, for a status or outcome that carries one. */
214
+ export function toError(err: unknown): Error {
215
+ return err instanceof Error ? err : new Error(String(err));
216
+ }
217
+
218
+ export function pendingMove(record: StrandMoveRecord): PendingStrandMove {
219
+ const settled = new Set([...record.moved, ...record.refused.map((entry) => entry.strandId)]);
220
+ return {
221
+ fromPartyId: record.bundle.partyId,
222
+ toPartyId: record.targetPartyId,
223
+ strandIds: record.bundle.strands.map((entry) => entry.row.Id).filter((strandId) => !settled.has(strandId)),
224
+ refused: record.refused,
225
+ };
226
+ }
227
+
228
+ /** The record in `text`, or `undefined` (logged) when it is not one. */
229
+ function parseRecord(text: string): StrandMoveRecord | undefined {
230
+ let raw: unknown;
231
+ try {
232
+ raw = JSON.parse(text);
233
+ } catch (err) {
234
+ console.warn('[cadre-core/edge-node] a strand move record is not JSON:', err);
235
+ return undefined;
236
+ }
237
+ return isRecord(raw) ? raw : undefined;
238
+ }
239
+
240
+ /** The record's shape down to the bundle's lists; their entries are cadre-core's export, written by this module. */
241
+ function isRecord(value: unknown): value is StrandMoveRecord {
242
+ if (!isObject(value) || value.v !== RECORD_VERSION || typeof value.targetPartyId !== 'string') return false;
243
+ if (!Array.isArray(value.moved) || !Array.isArray(value.refused)) return false;
244
+ const bundle = value.bundle;
245
+ return isObject(bundle)
246
+ && bundle.v === 1
247
+ && typeof bundle.partyId === 'string'
248
+ && Array.isArray(bundle.strands)
249
+ && Array.isArray(bundle.pendingJoins)
250
+ && Array.isArray(bundle.formationInvites);
251
+ }
252
+
253
+ function isObject(value: unknown): value is Record<string, unknown> {
254
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
255
+ }
256
+
257
+ /** "2 strands and 1 invitation": the bundle's non-empty lists. */
258
+ function describeContent(bundle: StrandBundle): string {
259
+ const parts = [
260
+ count(bundle.strands.length, 'strand'),
261
+ count(bundle.pendingJoins.length, 'pending join'),
262
+ count(bundle.formationInvites.length, 'invitation'),
263
+ ].filter((part): part is string => part !== null);
264
+ return parts.length > 1 ? `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}` : parts.join('');
265
+ }
266
+
267
+ function count(n: number, noun: string): string | null {
268
+ if (n === 0) return null;
269
+ return `${n} ${noun}${n === 1 ? '' : 's'}`;
270
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Strand helpers every edge app needs around a running node, from sereus-chat's bring-up.
3
+ */
4
+
5
+ import { StrandAwaitingFirstSyncError } from '../strand-first-sync-gate.js';
6
+ import type { CadreNode } from '../cadre-node.js';
7
+ import type { StrandConfig, StrandInstance } from '../types.js';
8
+
9
+ /** Default patience for {@link attachStrandWhenWritable}: generous enough that running out means something is wrong. */
10
+ export const DEFAULT_STRAND_PATIENCE_MS = 240_000;
11
+
12
+ /**
13
+ * `addStrand`, then keep waiting when the first sync outlasts its budget.
14
+ *
15
+ * `addStrand` rejects with `StrandAwaitingFirstSyncError` when the first sync has not finished,
16
+ * and leaves the strand launched and syncing: the timeout is a progress report, not an outcome.
17
+ * Measured on a link with a 1.8 s round trip, a first sync completed at about 150 s against a
18
+ * 120 s budget. So this waits for the strand to become writable, and rejects only when it has not
19
+ * within `patienceMs`.
20
+ */
21
+ export async function attachStrandWhenWritable(
22
+ node: CadreNode,
23
+ config: StrandConfig,
24
+ options: { patienceMs?: number } = {},
25
+ ): Promise<StrandInstance> {
26
+ try {
27
+ return await node.addStrand(config);
28
+ } catch (err) {
29
+ if (!(err instanceof StrandAwaitingFirstSyncError)) throw err;
30
+ const patienceMs = options.patienceMs ?? DEFAULT_STRAND_PATIENCE_MS;
31
+ console.info(
32
+ `[cadre-core/edge-node] first sync of ${config.strandRow.Id} still arriving; ` +
33
+ `waiting up to ${patienceMs / 1000} s for it to become writable`,
34
+ );
35
+ return await node.whenStrandWritable(config.strandRow.Id, { timeoutMs: patienceMs });
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Optimystic's cluster coordinator reports a write that could not gather enough approvals this
41
+ * way. It is transient right after a node restarts alone: its cohort has not reconnected yet.
42
+ *
43
+ * NOTE: matched on the message because Optimystic throws a plain `Error` here. If it gains a typed
44
+ * error, match on that instead — a reworded message would silently stop the retries.
45
+ */
46
+ const SUPER_MAJORITY_FAILURE = /Failed to get super-majority/i;
47
+
48
+ /**
49
+ * Run a write, retrying only on the transient super-majority failure the first writes after a lone
50
+ * restart hit (v1.9 release notes): `attempts` tries, `delayMs` apart. Any other error, and the
51
+ * last attempt's, propagates.
52
+ */
53
+ export async function retryAfterRestart<T>(
54
+ write: () => Promise<T>,
55
+ options: { attempts?: number; delayMs?: number } = {},
56
+ ): Promise<T> {
57
+ const attempts = options.attempts ?? 5;
58
+ const delayMs = options.delayMs ?? 3_000;
59
+ for (let attempt = 1; ; attempt++) {
60
+ try {
61
+ return await write();
62
+ } catch (err) {
63
+ if (!isTransientWriteFailure(err) || attempt >= attempts) throw err;
64
+ console.info(`[cadre-core/edge-node] write attempt ${attempt} lacked a super-majority; retrying in ${delayMs} ms`);
65
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
66
+ }
67
+ }
68
+ }
69
+
70
+ function isTransientWriteFailure(err: unknown): boolean {
71
+ return SUPER_MAJORITY_FAILURE.test(err instanceof Error ? err.message : String(err));
72
+ }
package/src/index.ts CHANGED
@@ -5,14 +5,23 @@ export * from './types.js';
5
5
  export { canonicalJson } from './canonical-json.js';
6
6
 
7
7
  // Main CadreNode class
8
- export { CadreNode, OWNER_INVITATION_DEFAULT_TTL_MS, CADRE_INVITATION_DEFAULT_TTL_MS } from './cadre-node.js';
8
+ export { CadreNode, CADRE_INVITATION_DEFAULT_TTL_MS, OWNER_GRANT_DEFAULT_TTL_MS } from './cadre-node.js';
9
9
 
10
10
  // Removing a machine from a cadre: the by-name refusal `CadreNode.removeMachine` throws
11
11
  // (the listing, plan and result types are in types.ts)
12
12
  export { MachineRemovalRefusedError } from './machine-removal.js';
13
13
 
14
+ // Adding and removing an owner key: the by-name refusals `CadreNode.addOwner` and
15
+ // `CadreNode.removeOwner` throw (the listing, plan and result types are in types.ts)
16
+ export { OwnerKeyAddRefusedError, OwnerKeyRemovalRefusedError } from './owner-keys.js';
17
+
14
18
  // Control database
15
- export { ControlDatabase, parseNullableStoredDatetimeMs, MissingHostStrandError, InvitationExhaustedError, CadreInviteIssuerUnknownError, buildAuthorizationMessage, registerPartyIdFunction, formationVouchMessage, formationConsentMessage, cadreInviteAddMessage, cadreInviteRedeemMessage, cadreInviteConsentMessage, projectSeedPeers, REAPABLE_TABLES, type ReapableTable, type ControlDatabaseConfig, type ControlTable, type RevocableTable, type FormationUsageResult, type FormationInviteRow, type MembershipChangeListener, type GuardedDeleteListener, type ControlWriteAbandonedListener, type RevokedRowRef, type MachineRemovalWrites } from './control-database.js';
19
+ export { ControlDatabase, parseNullableStoredDatetimeMs, MissingHostStrandError, InvitationExhaustedError, CadreInviteIssuerUnknownError, OwnerGrantIssuerUnknownError, IssuedRowNotHereYetError, buildAuthorizationMessage, registerPartyIdFunction, formationVouchMessage, formationConsentMessage, cadreInviteAddMessage, cadreInviteRedeemMessage, cadreInviteConsentMessage, ownerGrantAddMessage, ownerGrantRedeemMessage, ownerGrantConsentMessage, projectSeedPeers, REAPABLE_TABLES, type ReapableTable, type ControlDatabaseConfig, type ControlTable, type RevocableTable, type FormationUsageResult, type FormationInviteRow, type MembershipChangeListener, type GuardedDeleteListener, type ControlWriteAbandonedListener, type RevokedRowRef, type ControlSign, type OwnerGrantRedemptionFields } from './control-database.js';
20
+
21
+ // What `start()` throws when the control store was written with a control schema this build
22
+ // cannot apply to it (recreate procedure: docs/architecture.md)
23
+ export { CONTROL_SCHEMA_VERSION, CONTROL_SCHEMA_BASELINE_VERSION } from './control-schema.js';
24
+ export { ControlStoreIncompatibleError } from './control-schema-version.js';
16
25
 
17
26
  // Bounded retry for transient control-write failures (classifier + loop behind
18
27
  // ControlDatabase.lockedWithRetry; exported so the integration package can drive the
@@ -49,10 +58,10 @@ export type {
49
58
  } from './control-retry.js';
50
59
 
51
60
  // Control-plane authorization field vector (the domain/action tagging every signer shares)
52
- export { controlAuthorizationFields, unboundAuthorizationFields, cadreInviteRowFields, type ControlDomain, type BoundControlDomain, type UnboundControlDomain, type ControlAction, type CadreInviteSignedFields } from './control-authorization.js';
61
+ export { controlAuthorizationFields, unboundAuthorizationFields, cadreInviteRowFields, ownerGrantRowFields, type ControlDomain, type BoundControlDomain, type UnboundControlDomain, type ControlAction, type CadreInviteSignedFields, type OwnerGrantSignedFields } from './control-authorization.js';
53
62
 
54
63
  // Ed25519 key bridge (libp2p Ed25519 -> base64url keypair)
55
- export { ed25519KeyPairFromLibp2p, ed25519PublicKeyFromPrivate, ed25519PublicKeyB64FromPeerId, requireEd25519PublicKeyB64, type Ed25519KeyPair } from './ed25519-key.js';
64
+ export { ed25519KeyPairFromLibp2p, ed25519PublicKeyFromPrivate, ed25519PublicKeyB64FromPeerId, requireEd25519PublicKeyB64, requireEd25519PrivateKeyB64, type Ed25519KeyPair } from './ed25519-key.js';
56
65
 
57
66
  // Pluggable key store (backend-agnostic identity/owner key material seam).
58
67
  // Dependency-free: the interface, error, default slot id, and in-memory backend
@@ -63,6 +72,7 @@ export {
63
72
  InMemoryKeyStore,
64
73
  KeyStoreAccessError,
65
74
  DEFAULT_IDENTITY_KEY_ID,
75
+ DEFAULT_OWNER_KEY_ID,
66
76
  type KeyStore,
67
77
  type KeyId
68
78
  } from './key-store.js';
@@ -75,6 +85,44 @@ export { encodeFileSafeComponent } from './file-safe-name.js';
75
85
  // node key BEFORE `CadreNode` exists.
76
86
  export { loadOrCreateIdentityKey } from './identity-key.js';
77
87
 
88
+ // Owner keys (`docs/architecture.md` → Owner Keys): the one interface owner signatures go
89
+ // through, the key-store slot an owner key lives in apart from the identity slot, and the
90
+ // text form a key is exported and imported in. Cross-platform: pure JS crypto only.
91
+ export {
92
+ softwareOwnerSigner,
93
+ OwnerSigningDeclinedError,
94
+ type OwnerSigner,
95
+ type OwnerSign,
96
+ type OwnerSigningRequest
97
+ } from './owner-signer.js';
98
+ export {
99
+ createOwnerKey,
100
+ loadOwnerSigner,
101
+ importOwnerKey,
102
+ readOwnerPrivateKey,
103
+ OwnerKeySlotOccupiedError
104
+ } from './owner-key-store.js';
105
+ export {
106
+ encodeOwnerKeyExport,
107
+ decodeOwnerKeyExport,
108
+ unlockOwnerKeyExport,
109
+ OwnerKeyExportError,
110
+ OWNER_KEY_EXPORT_PREFIX,
111
+ LOCKED_OWNER_KEY_EXPORT_PREFIX,
112
+ OWNER_KEY_MAX_LABEL,
113
+ type OwnerKeyExportErrorCode,
114
+ type OwnerKeyExportContents,
115
+ type OwnerKeyExportInput,
116
+ type DecodedOwnerKeyExport
117
+ } from './owner-key-export.js';
118
+ export {
119
+ exportOwnerKey,
120
+ importOwnerKeyInto,
121
+ type ExportedOwnerKey,
122
+ type ImportedOwnerKey,
123
+ type OwnerKeyExportOptions
124
+ } from './owner-key-flows.js';
125
+
78
126
  // Node-local trusted-owner anchor: the NON-replicated, per-party record of
79
127
  // out-of-band-established owner keys (the trust anchor the replicated OwnerKey
80
128
  // table cannot be). Cross-platform: interface + in-memory store only — the
@@ -395,9 +443,14 @@ export {
395
443
  verifyRevocationSigner,
396
444
  verifyFormationConsent,
397
445
  verifyInvitationAdmission,
398
- verifyInvitationOwnerAdmission,
399
446
  verifyCadreInviteRow,
400
447
  verifyCadreInviteRedemption,
448
+ ownerGrantAddDigest,
449
+ ownerGrantRedeemDigest,
450
+ ownerGrantConsentDigest,
451
+ verifyOwnerGrantRow,
452
+ verifyOwnerGrantRedemption,
453
+ verifyOwnerGrantAdmission,
401
454
  type DeviceTokenAuthorizedRow
402
455
  } from './peer-authorization.js';
403
456
 
@@ -599,6 +652,8 @@ export {
599
652
  requireCadreJoinReply,
600
653
  CADRE_JOIN_REQUEST_PREFIX,
601
654
  CADRE_INVITATION_PREFIX,
655
+ OWNER_KEY_EXPORT_STEM,
656
+ OWNER_GRANT_PREFIX,
602
657
  CADRE_JOIN_REQUEST_MAX_APP_NAME,
603
658
  type CadreJoinReply,
604
659
  type CadreJoinRequest
@@ -683,6 +738,48 @@ export {
683
738
  type RedeemSigner,
684
739
  type CadreInviteRedemption
685
740
  } from './cadre-invite-protocol.js';
741
+ // Owner grant redemption transport: the bundle an owner hands out, the wire messages, the
742
+ // member-side handler and the holder-side dialer, and how a redemption fails
743
+ export {
744
+ OwnerGrantHandler,
745
+ redeemOwnerGrantAtMembers,
746
+ redeemOwnerGrantInPasses,
747
+ signOwnerGrantRequest,
748
+ verifyOwnerGrantReply,
749
+ encodeOwnerGrant,
750
+ decodeOwnerGrant,
751
+ isWellFormedOwnerGrantRow,
752
+ OwnerGrantRejectedError,
753
+ OwnerGrantUnreachableError,
754
+ OwnerGrantReplyInvalidError,
755
+ OWNER_GRANT_PROTOCOL,
756
+ OWNER_GRANT_REJECTION_RETRYABLE,
757
+ isOwnerGrantRejectionCode,
758
+ type OwnerGrantBundle,
759
+ type OwnerGrantRedeemRequest,
760
+ type OwnerGrantRedeemReply,
761
+ type OwnerGrantAccepted,
762
+ type OwnerGrantRejectionCode,
763
+ type OwnerGrantHandlerOptions,
764
+ type OwnerGrantStore,
765
+ type RedeemOwnerGrantAtMembersOptions,
766
+ type RedeemOwnerGrantInPassesOptions,
767
+ type OwnerGrantRedemption
768
+ } from './owner-grant-protocol.js';
769
+ // The exchange shape both redemption protocols are built on
770
+ export {
771
+ MemberRedemptionHandler,
772
+ dialRedemption,
773
+ dialRedemptionInPasses,
774
+ MAX_REDEMPTION_FRAME_BYTES,
775
+ type MemberRedemptionHandlerOptions,
776
+ type MemberRedemptionOptions,
777
+ type MemberRedemptionPassOptions,
778
+ type MemberRedemption,
779
+ type RedemptionAddressOutcome,
780
+ type RedemptionRejection,
781
+ type RedemptionRefusalFrame
782
+ } from './member-redemption.js';
686
783
  // Which machines, and how many addresses of each, an invitation names; how far a cadre invitation reaches
687
784
  export {
688
785
  selectCadreInvitationMembers,