@serfab/cadre-core 1.14.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 (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 +233 -45
  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 +789 -722
  140. package/src/invitation-bootstrap.ts +91 -13
  141. package/src/joined-strand-store.ts +508 -498
  142. package/src/key-store-file.ts +142 -141
  143. package/src/link-budget.ts +9 -4
  144. package/src/machine-removal.ts +216 -0
  145. package/src/membership-connection-gater.ts +638 -637
  146. package/src/node-claim-payload.ts +138 -4
  147. package/src/node-default-transports.ts +38 -0
  148. package/src/node-transports.ts +49 -0
  149. package/src/peer-dial.ts +604 -336
  150. package/src/peer-join-backfill.ts +6 -0
  151. package/src/peer-record.ts +159 -202
  152. package/src/primary-lan-address.ts +47 -0
  153. package/src/provider/client.ts +326 -0
  154. package/src/provider/descriptor.ts +129 -0
  155. package/src/provider/index.ts +59 -0
  156. package/src/provider/oauth.ts +285 -0
  157. package/src/provider/rent-node.ts +275 -0
  158. package/src/relay-addrs.ts +9 -4
  159. package/src/relay-reservation.ts +1211 -1121
  160. package/src/seed-bootstrap.ts +1502 -1471
  161. package/src/strand-commit-durability.ts +116 -0
  162. package/src/strand-database.ts +46 -14
  163. package/src/strand-first-sync-gate.ts +7 -1
  164. package/src/strand-formation-protocol.ts +1039 -1036
  165. package/src/strand-fret-addrs.ts +179 -145
  166. package/src/strand-instance-manager.ts +2026 -1883
  167. package/src/strand-move.ts +585 -0
  168. package/src/strand-network-config.ts +7 -6
  169. package/src/strand-peer-redial.ts +307 -0
  170. package/src/strand-wake-protocol.ts +10 -7
  171. package/src/types.ts +220 -4
@@ -1,498 +1,508 @@
1
- /**
2
- * The strands this party joined from ANOTHER party, held in two records.
3
- *
4
- * A party's own strands come back after a restart through its control database: the
5
- * `Strand` table holds their rows, the strand watcher polls it, and each row no sApp
6
- * config claims is offered as `strand:discovered`. A strand joined through formation with
7
- * another party (`CadreNode.formStrand`) has its `Strand` row in the OTHER party's control
8
- * database, so it needs a record of its own (gotchoices/sereus#18):
9
- *
10
- * - the PARTY-WIDE record, a `CadreControl.JoinedStrand` row ({@link PartyJoinedStrandLedger}).
11
- * Once it exists it is the authority: every machine of the party offers it, a storage
12
- * replica host launches it, and removing it is how the party leaves the strand;
13
- * - the MACHINE-LOCAL record ({@link JoinedStrandStore}, below), a queue of joins this
14
- * machine made that the party does not know about yet. A join is recorded here first,
15
- * published by an owner machine's connected reconcile pass, and forgotten as soon as the
16
- * party-wide row is visible, so a local record always means "joined here, not yet
17
- * published". A machine that is not an owner never publishes; its joins stay here.
18
- *
19
- * {@link JoinedStrandSession} moves joins between the two and is what the strand watcher
20
- * polls beside the control rows.
21
- *
22
- * Two store implementations:
23
- * - {@link MemoryJoinedStrandStore} — joins die with the process. The default only for a
24
- * node configured with no `keyStore`.
25
- * - {@link KeyStoreJoinedStrandStore} — one {@link KeyStore} slot per join. A record holds
26
- * the closed strand's shared read secret, and the KeyStore is the one seam every
27
- * platform already routes secrets through (the platform enclave on React Native, the
28
- * state directory for `FileKeyStore`), so the record goes there rather than into a
29
- * `DurableSlot` like the bootstrap-peer store.
30
- *
31
- * Party-scoped like every other node-local store, because one KeyStore can serve several
32
- * parties: the React Native app keeps one per device and lets the user switch party. A
33
- * join made for one party must never be offered to a node started for another.
34
- *
35
- * Dependency-free beyond the KeyStore seam, so safe in every entry graph.
36
- */
37
- import debug from 'debug';
38
- import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
39
- import type { KeyId, KeyStore } from './key-store.js';
40
- import { assertStrandScopeKey, isValidStrandScopeKey } from './storage-scope.js';
41
- import type { StrandRow } from './types.js';
42
-
43
- const log = debug('sereus:cadre:joined-strand-store');
44
-
45
- /** One strand this node joined from ANOTHER party and has not yet published party-wide. */
46
- export interface JoinedStrandRecord {
47
- /** The strand id (a valid scope key, see storage-scope.ts). */
48
- Id: string;
49
- Type: 'o' | 'c';
50
- /** The closed strand's shared read secret the formation delivered; null for an open strand. */
51
- MemberPrivateKey: string | null;
52
- /** Wall-clock ms this node first recorded the join. Diagnostics only. */
53
- joinedAt: number;
54
- }
55
-
56
- export interface JoinedStrandStore {
57
- /** Party this store is scoped to. */
58
- readonly partyId: string;
59
-
60
- /** Every remembered join. A call reflects every {@link record}/{@link forget} that resolved before it was made. */
61
- list(): Promise<JoinedStrandRecord[]>;
62
-
63
- /** Remember a join, REPLACING any record with the same `Id`. */
64
- record(record: JoinedStrandRecord): Promise<void>;
65
-
66
- /** Forget a join. A no-op when there is no record for `strandId`. */
67
- forget(strandId: string): Promise<void>;
68
- }
69
-
70
- /**
71
- * The {@link StrandRow} a record stands for — the shape `addStrand` and `strand:discovered`
72
- * carry. `FounderOwnerKey` is null because a joiner never founds; `CadreNode.launchStrand`
73
- * reads null as "not mine" and joins.
74
- */
75
- export function joinedStrandRow(record: JoinedStrandRecord): StrandRow {
76
- return { Id: record.Id, Type: record.Type, MemberPrivateKey: record.MemberPrivateKey, FounderOwnerKey: null };
77
- }
78
-
79
- /**
80
- * Ephemeral store for a node without a `keyStore`. Warns once, at the first join it is
81
- * asked to remember, because a join held only here is not re-offered after a restart —
82
- * exactly the gotchoices/sereus#18 shape, and it should show in a log rather than pass
83
- * silently.
84
- */
85
- export class MemoryJoinedStrandStore implements JoinedStrandStore {
86
- private readonly records = new Map<string, JoinedStrandRecord>();
87
- private warned = false;
88
-
89
- constructor(readonly partyId: string) {}
90
-
91
- async list(): Promise<JoinedStrandRecord[]> {
92
- return [...this.records.values()];
93
- }
94
-
95
- async record(record: JoinedStrandRecord): Promise<void> {
96
- this.warnOnce();
97
- this.records.set(record.Id, { ...record });
98
- }
99
-
100
- async forget(strandId: string): Promise<void> {
101
- this.records.delete(strandId);
102
- }
103
-
104
- private warnOnce(): void {
105
- if (this.warned) {
106
- return;
107
- }
108
- this.warned = true;
109
- console.warn(
110
- 'CadreNode has no keyStore and no joinedStrands.store, so strands joined from another party ' +
111
- 'are remembered in memory only and will NOT be re-offered after a restart. Configure a keyStore, ' +
112
- 'or inject joinedStrands: { store: new KeyStoreJoinedStrandStore(<a durable KeyStore>, partyId) }.'
113
- );
114
- }
115
- }
116
-
117
- /** Slot id prefix for every joined-strand record; the party segment and the strand id follow. */
118
- const SLOT_PREFIX = 'cadre/joined-strand/';
119
-
120
- /**
121
- * Durable store over a {@link KeyStore}: one slot per join, id
122
- * `cadre/joined-strand/<base64url party id>/<strand id>`, holding the record's UTF-8 JSON.
123
- * The party segment is base64url for the reason `controlStorageScope` gives: a party id is
124
- * arbitrary text, and base64url never contains the `/` that ends the segment.
125
- *
126
- * Loads every slot once, at the first {@link list}, then answers from memory and writes
127
- * through, so the strand watcher's five-second poll does not read the enclave on every
128
- * pass. A load that fails — `KeyStoreAccessError` from a refused unlock prompt, say —
129
- * is rethrown rather than read as "no joins" and retried at the next call, exactly as
130
- * `loadOrCreateIdentityKey` treats the identity slot. A slot that does not parse as a
131
- * record for its own id is dropped with a log and its siblings kept: refusing to start
132
- * repairs nothing, and the other joins are still good.
133
- */
134
- export class KeyStoreJoinedStrandStore implements JoinedStrandStore {
135
- private readonly slotPrefix: string;
136
- /** The loaded records; null until a load succeeds. */
137
- private records: Map<string, JoinedStrandRecord> | null = null;
138
- /** Runs every KeyStore access in call order, so a load never interleaves with a write. */
139
- private queue: Promise<unknown> = Promise.resolve();
140
-
141
- constructor(private readonly keyStore: KeyStore, readonly partyId: string) {
142
- this.slotPrefix = SLOT_PREFIX + uint8ArrayToString(uint8ArrayFromString(partyId, 'utf8'), 'base64url') + '/';
143
- }
144
-
145
- list(): Promise<JoinedStrandRecord[]> {
146
- return this.inOrder(async () => [...(await this.loaded()).values()]);
147
- }
148
-
149
- /** Throws `InvalidStrandIdError` for an id that could never load back, before anything is written. */
150
- record(record: JoinedStrandRecord): Promise<void> {
151
- assertStrandScopeKey(record.Id);
152
- const stored: JoinedStrandRecord = { ...record };
153
- return this.inOrder(async () => {
154
- await this.keyStore.set(this.slotId(record.Id), uint8ArrayFromString(JSON.stringify(stored), 'utf8'));
155
- this.records?.set(record.Id, stored);
156
- log('joined strand remembered (party=%s, strand=%s, type=%s)', this.partyId, record.Id, record.Type);
157
- });
158
- }
159
-
160
- forget(strandId: string): Promise<void> {
161
- return this.inOrder(async () => {
162
- await this.keyStore.delete(this.slotId(strandId));
163
- this.records?.delete(strandId);
164
- log('joined strand forgotten (party=%s, strand=%s)', this.partyId, strandId);
165
- });
166
- }
167
-
168
- private slotId(strandId: string): KeyId {
169
- return this.slotPrefix + strandId;
170
- }
171
-
172
- private inOrder<T>(op: () => Promise<T>): Promise<T> {
173
- const result = this.queue.then(op);
174
- // The caller receives `result`, rejection included; the queue only needs to know it settled.
175
- this.queue = result.catch(() => undefined);
176
- return result;
177
- }
178
-
179
- // NOTE: a failed load is retried at the next list(), which is every strand-watcher poll;
180
- // over a backend that gates reads behind an unlock prompt the user keeps declining, that
181
- // is a prompt every five seconds. Every KeyStore shipped today is ungated for this reason
182
- // (see `SecureStoreKeyStoreOptions.requireAuthentication`); if one is ever gated, back
183
- // off the retry here.
184
- private async loaded(): Promise<Map<string, JoinedStrandRecord>> {
185
- if (this.records) {
186
- return this.records;
187
- }
188
- const records = new Map<string, JoinedStrandRecord>();
189
- for (const slot of await this.keyStore.list()) {
190
- if (!slot.startsWith(this.slotPrefix)) {
191
- continue;
192
- }
193
- const strandId = slot.slice(this.slotPrefix.length);
194
- const bytes = await this.keyStore.get(slot);
195
- if (!bytes) {
196
- continue;
197
- }
198
- const record = parseRecord(strandId, bytes);
199
- if (record) {
200
- records.set(strandId, record);
201
- }
202
- }
203
- this.records = records;
204
- log('joined strands loaded (party=%s, count=%d)', this.partyId, records.size);
205
- return records;
206
- }
207
- }
208
-
209
- /** The record a slot holds, or undefined (logged) when it is not one for `strandId`. */
210
- function parseRecord(strandId: string, bytes: Uint8Array): JoinedStrandRecord | undefined {
211
- let parsed: unknown;
212
- try {
213
- parsed = JSON.parse(uint8ArrayToString(bytes, 'utf8'));
214
- } catch (error) {
215
- log('dropping joined-strand slot for %s: not JSON (%o)', strandId, error);
216
- return undefined;
217
- }
218
- const { Id, Type, MemberPrivateKey, joinedAt } = (typeof parsed === 'object' && parsed !== null ? parsed : {}) as Record<string, unknown>;
219
- const valid = Id === strandId
220
- && isValidStrandScopeKey(strandId)
221
- && (Type === 'o' || Type === 'c')
222
- && (MemberPrivateKey === null || typeof MemberPrivateKey === 'string')
223
- && typeof joinedAt === 'number';
224
- if (!valid) {
225
- log('dropping joined-strand slot for %s: not a joined-strand record for that id', strandId);
226
- return undefined;
227
- }
228
- return { Id: strandId, Type: Type as 'o' | 'c', MemberPrivateKey: MemberPrivateKey as string | null, joinedAt: joinedAt as number };
229
- }
230
-
231
- /**
232
- * The party-wide joined-strand table (`CadreControl.JoinedStrand`), as the node's control
233
- * database exposes it. `CadreNode` builds it over `ControlDatabase` and the owner signing
234
- * key; this module sees only the seam, so it stays free of the runtime.
235
- */
236
- export interface PartyJoinedStrandLedger {
237
- /** Every party-wide join, as offerable rows (`FounderOwnerKey: null`). */
238
- list(): Promise<StrandRow[]>;
239
- /** Whether this party's control database names `strandId`, as its own strand or as a party-wide join. */
240
- names(strandId: string): Promise<boolean>;
241
- /** Whether this machine can sign {@link publish} and {@link remove}: it holds an owner key the party enrolls. */
242
- canSign(): Promise<boolean>;
243
- /** Owner-signed insert. Resolves when the row landed or a sibling's join already holds the id. */
244
- publish(record: JoinedStrandRecord): Promise<void>;
245
- /** Owner-signed delete + tombstone; false when there is no row. Throws when a row exists and this machine cannot sign. */
246
- remove(strandId: string): Promise<boolean>;
247
- }
248
-
249
- /**
250
- * One node session's joined strands: what the strand watcher sees of the two records, and
251
- * the moves between them. Rebuilt at every start, which is what ends the per-session state
252
- * below.
253
- *
254
- * The watcher reads a row missing from a poll as a removal and detaches the strand, so this
255
- * holds the state a plain union would get wrong:
256
- * - the last list each record answered, reused when a later read fails, so a failed read
257
- * is not "every join was forgotten";
258
- * - joins this party was removed from ({@link forgetAfterThisSession}): offered until this
259
- * session ends, because `strand:revoked` promises that nothing is torn down for the app,
260
- * and queued for party-wide removal so the next start does not re-attach them.
261
- */
262
- export class JoinedStrandSession {
263
- private lastUnpublished: JoinedStrandRecord[] = [];
264
- private lastPartyWide: StrandRow[] = [];
265
- private readonly keptForSession = new Map<string, StrandRow>();
266
- /** Strands whose party-wide row {@link syncWithParty} removes, queued by a self-revocation. */
267
- private readonly pendingRemovals = new Set<string>();
268
- /**
269
- * Tail of the moves that must not interleave ({@link serialized}): a publish that found no
270
- * party-wide row, then a {@link leave} that removed nothing, then the publish's insert
271
- * would leave the party holding a strand the app just left.
272
- */
273
- private exclusive: Promise<unknown> = Promise.resolve();
274
-
275
- constructor(private readonly store: JoinedStrandStore, private readonly ledger: PartyJoinedStrandLedger) {}
276
-
277
- /**
278
- * `control` plus every join, one row per id, in this precedence: the control rows (the
279
- * party's own strands), then the party-wide joins, then this machine's unpublished joins
280
- * and the joins kept for this session. A local record named by either of the first two in
281
- * a read that succeeded this poll is stale — published, or the strand is the party's own
282
- * now — so it is forgotten here; this also cleans up after a crash between a publish and
283
- * its local forget. One named only by the last-good party-wide list is kept: that list may
284
- * predate a leave the record re-joined after.
285
- */
286
- async withControlRows(control: readonly StrandRow[]): Promise<StrandRow[]> {
287
- const controlIds = new Set(control.map((row) => row.Id));
288
- const named = new Set(controlIds);
289
- const offered = new Map<string, StrandRow>(control.map((row) => [row.Id, row]));
290
- const partyWide = await this.partyWide();
291
- // NOTE: a party-wide join whose id the party's own `Strand` table also holds (the party
292
- // founded AND joined it — contrived) is shadowed here, not deleted: a watcher poll must
293
- // not make owner-signed writes. If the party later unpublishes its own row the join row
294
- // resurfaces; if that is ever seen, remove the join row inside `unpublishStrand`.
295
- for (const row of partyWide.rows) {
296
- if (!offered.has(row.Id)) {
297
- offered.set(row.Id, row);
298
- }
299
- if (partyWide.fresh) {
300
- named.add(row.Id);
301
- }
302
- }
303
- for (const record of await this.unpublished()) {
304
- if (named.has(record.Id)) {
305
- await this.forgetSuperseded(record.Id);
306
- } else if (!offered.has(record.Id)) {
307
- offered.set(record.Id, joinedStrandRow(record));
308
- }
309
- }
310
- for (const [strandId, row] of this.keptForSession) {
311
- if (controlIds.has(strandId)) {
312
- this.keptForSession.delete(strandId);
313
- } else if (!offered.has(strandId)) {
314
- offered.set(strandId, row);
315
- }
316
- }
317
- return [...offered.values()];
318
- }
319
-
320
- /** Record a join `formStrand` just made. A re-join also cancels a removal a revocation queued. */
321
- async remember(record: JoinedStrandRecord): Promise<void> {
322
- this.pendingRemovals.delete(record.Id);
323
- await this.store.record(record);
324
- }
325
-
326
- /**
327
- * Record `row`, joined by `addStrand`, unless this party's control database already names
328
- * it (its own strand, or a join already published) or an identical record is already here
329
- * (an app re-claiming a remembered join on every start). Keeps the first `joinedAt`. Like
330
- * {@link remember}, cancels a queued removal.
331
- */
332
- async rememberForeign(row: StrandRow): Promise<void> {
333
- this.pendingRemovals.delete(row.Id);
334
- if (await this.namedByParty(row.Id)) {
335
- return;
336
- }
337
- const existing = (await this.store.list()).find((record) => record.Id === row.Id);
338
- if (existing?.Type === row.Type && existing.MemberPrivateKey === row.MemberPrivateKey) {
339
- return;
340
- }
341
- await this.store.record({
342
- Id: row.Id,
343
- Type: row.Type,
344
- MemberPrivateKey: row.MemberPrivateKey,
345
- joinedAt: existing?.joinedAt ?? Date.now()
346
- });
347
- }
348
-
349
- /**
350
- * Leave a join for the whole party: remove the party-wide row, then this machine's record.
351
- * Throws, keeping both, when a party-wide row exists and this machine cannot sign.
352
- */
353
- leave(strandId: string): Promise<void> {
354
- return this.serialized(async () => {
355
- await this.ledger.remove(strandId);
356
- this.pendingRemovals.delete(strandId);
357
- this.keptForSession.delete(strandId);
358
- this.lastPartyWide = this.lastPartyWide.filter((row) => row.Id !== strandId);
359
- await this.store.forget(strandId);
360
- });
361
- }
362
-
363
- /**
364
- * For a strand this party was removed from: keep offering it until this session ends, and
365
- * queue its party-wide row for removal by {@link syncWithParty}, so it does not re-attach
366
- * on every start of every machine. A no-op for a strand with neither record (one of the
367
- * party's own). The queue is in memory: if the process dies first, the next start
368
- * relaunches the strand, the revoked-peer gate raises `strand:revoked` again, and this
369
- * runs again.
370
- */
371
- forgetAfterThisSession(strandId: string): Promise<void> {
372
- return this.serialized(async () => {
373
- const local = (await this.unpublished()).find((record) => record.Id === strandId);
374
- const row = local ? joinedStrandRow(local) : this.lastPartyWide.find((listed) => listed.Id === strandId);
375
- if (!row) {
376
- return;
377
- }
378
- // Kept BEFORE the store forgets, so no poll can union without it. Queued even for a
379
- // local-only join, which a sibling may have published already.
380
- this.keptForSession.set(strandId, row);
381
- this.pendingRemovals.add(strandId);
382
- if (local) {
383
- await this.store.forget(strandId);
384
- }
385
- });
386
- }
387
-
388
- /**
389
- * The owner machine's half, run by the node's connected reconcile pass: publish every
390
- * unpublished join party-wide, then remove the party-wide rows queued by
391
- * {@link forgetAfterThisSession}. Does nothing on a machine that cannot sign, whose joins
392
- * stay machine-local. Per strand, a failure is logged and kept for the next pass; a
393
- * failure to list the store or to check the signer throws.
394
- */
395
- syncWithParty(): Promise<void> {
396
- return this.serialized(async () => {
397
- // Straight from the store, never the last-good list: a stale list could republish a
398
- // join that was left since.
399
- const unpublished = await this.store.list();
400
- if (unpublished.length === 0 && this.pendingRemovals.size === 0) {
401
- return;
402
- }
403
- if (!(await this.ledger.canSign())) {
404
- return;
405
- }
406
- for (const record of unpublished) {
407
- await this.publish(record);
408
- }
409
- for (const strandId of [...this.pendingRemovals]) {
410
- await this.removeQueued(strandId);
411
- }
412
- });
413
- }
414
-
415
- // NOTE: a sibling that joined the same strand and has not yet published publishes it after
416
- // a leave, bringing the strand back party-wide. Needs two devices joining one strand within
417
- // a reconcile interval of the leave; if it is ever seen, check the strand's `Revocation`
418
- // tombstone before publishing.
419
- private async publish(record: JoinedStrandRecord): Promise<void> {
420
- try {
421
- if (!(await this.ledger.names(record.Id))) {
422
- await this.ledger.publish(record);
423
- // Into the last-good list too: its local record goes next, so a party-wide read that
424
- // fails before one succeeds would otherwise drop the strand from the poll and hide it
425
- // from a self-revocation.
426
- this.lastPartyWide = [...this.lastPartyWide.filter((row) => row.Id !== record.Id), joinedStrandRow(record)];
427
- log('joined strand %s published party-wide (party=%s)', record.Id, this.store.partyId);
428
- }
429
- await this.store.forget(record.Id);
430
- } catch (error) {
431
- log('joined strand %s: publishing party-wide failed; kept for the next pass: %o', record.Id, error);
432
- }
433
- }
434
-
435
- private async removeQueued(strandId: string): Promise<void> {
436
- try {
437
- const removed = await this.ledger.remove(strandId);
438
- this.pendingRemovals.delete(strandId);
439
- log('joined strand %s: party-wide row %s after this party was removed from it',
440
- strandId, removed ? 'removed' : 'already absent');
441
- } catch (error) {
442
- log('joined strand %s: removing the party-wide row failed; queued for the next pass: %o', strandId, error);
443
- }
444
- }
445
-
446
- /** The party-wide joins; `fresh` is false when this read failed and the last list it answered stands in. */
447
- private async partyWide(): Promise<{ rows: StrandRow[]; fresh: boolean }> {
448
- try {
449
- this.lastPartyWide = await this.ledger.list();
450
- return { rows: this.lastPartyWide, fresh: true };
451
- } catch (error) {
452
- log('party-wide joined-strand read failed; offering the last list it answered (%d join(s)): %o',
453
- this.lastPartyWide.length, error);
454
- return { rows: this.lastPartyWide, fresh: false };
455
- }
456
- }
457
-
458
- /**
459
- * {@link PartyJoinedStrandLedger.names}, answering false when the control database cannot
460
- * be read (a machine cut off from its party before it received the `JoinedStrand` block).
461
- * Recording is the safe side: a local record the party turns out to name is forgotten by
462
- * the next poll that reads it, while a join with no record is not offered after a restart.
463
- */
464
- private async namedByParty(strandId: string): Promise<boolean> {
465
- try {
466
- return await this.ledger.names(strandId);
467
- } catch (error) {
468
- log('joined strand %s: could not read whether the control database names it; recording it locally: %o', strandId, error);
469
- return false;
470
- }
471
- }
472
-
473
- /** Run `work` after every earlier serialized move settles; see {@link exclusive}. */
474
- private serialized<T>(work: () => Promise<T>): Promise<T> {
475
- const result = this.exclusive.then(work);
476
- this.exclusive = result.catch(() => undefined);
477
- return result;
478
- }
479
-
480
- private async unpublished(): Promise<JoinedStrandRecord[]> {
481
- try {
482
- this.lastUnpublished = await this.store.list();
483
- } catch (error) {
484
- log('joined-strand store list failed; offering the last list it answered (%d join(s)): %o',
485
- this.lastUnpublished.length, error);
486
- }
487
- return this.lastUnpublished;
488
- }
489
-
490
- private async forgetSuperseded(strandId: string): Promise<void> {
491
- try {
492
- await this.store.forget(strandId);
493
- log('joined strand %s is named by this party\'s control database — local record forgotten', strandId);
494
- } catch (error) {
495
- log('forgetting superseded joined strand %s failed; the next poll retries: %o', strandId, error);
496
- }
497
- }
498
- }
1
+ /**
2
+ * The strands this party joined from ANOTHER party, held in two records.
3
+ *
4
+ * A party's own strands come back after a restart through its control database: the
5
+ * `Strand` table holds their rows, the strand watcher polls it, and each row no sApp
6
+ * config claims is offered as `strand:discovered`. A strand joined through formation with
7
+ * another party (`CadreNode.formStrand`) has its `Strand` row in the OTHER party's control
8
+ * database, so it needs a record of its own (gotchoices/sereus#18):
9
+ *
10
+ * - the PARTY-WIDE record, a `CadreControl.JoinedStrand` row ({@link PartyJoinedStrandLedger}).
11
+ * Once it exists it is the authority: every machine of the party offers it, a storage
12
+ * replica host launches it, and removing it is how the party leaves the strand;
13
+ * - the MACHINE-LOCAL record ({@link JoinedStrandStore}, below), a queue of joins this
14
+ * machine made that the party does not know about yet. A join is recorded here first,
15
+ * published by an owner machine's connected reconcile pass, and forgotten as soon as the
16
+ * party-wide row is visible, so a local record always means "joined here, not yet
17
+ * published". A machine that is not an owner never publishes; its joins stay here.
18
+ *
19
+ * {@link JoinedStrandSession} moves joins between the two and is what the strand watcher
20
+ * polls beside the control rows.
21
+ *
22
+ * Two store implementations:
23
+ * - {@link MemoryJoinedStrandStore} — joins die with the process. The default only for a
24
+ * node configured with no `keyStore`.
25
+ * - {@link KeyStoreJoinedStrandStore} — one {@link KeyStore} slot per join. A record holds
26
+ * the closed strand's shared read secret, and the KeyStore is the one seam every
27
+ * platform already routes secrets through (the platform enclave on React Native, the
28
+ * state directory for `FileKeyStore`), so the record goes there rather than into a
29
+ * `DurableSlot` like the bootstrap-peer store.
30
+ *
31
+ * Party-scoped like every other node-local store, because one KeyStore can serve several
32
+ * parties: the React Native app keeps one per device and lets the user switch party. A
33
+ * join made for one party must never be offered to a node started for another.
34
+ *
35
+ * Dependency-free beyond the KeyStore seam, so safe in every entry graph.
36
+ */
37
+ import debug from 'debug';
38
+ import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
39
+ import type { KeyId, KeyStore } from './key-store.js';
40
+ import { assertStrandScopeKey, isValidStrandScopeKey } from './storage-scope.js';
41
+ import type { StrandRow } from './types.js';
42
+
43
+ const log = debug('sereus:cadre:joined-strand-store');
44
+
45
+ /** One strand this node joined from ANOTHER party and has not yet published party-wide. */
46
+ export interface JoinedStrandRecord {
47
+ /** The strand id (a valid scope key, see storage-scope.ts). */
48
+ Id: string;
49
+ Type: 'o' | 'c';
50
+ /** The closed strand's shared read secret the formation delivered; null for an open strand. */
51
+ MemberPrivateKey: string | null;
52
+ /** Wall-clock ms this node first recorded the join. Diagnostics only. */
53
+ joinedAt: number;
54
+ }
55
+
56
+ export interface JoinedStrandStore {
57
+ /** Party this store is scoped to. */
58
+ readonly partyId: string;
59
+
60
+ /** Every remembered join. A call reflects every {@link record}/{@link forget} that resolved before it was made. */
61
+ list(): Promise<JoinedStrandRecord[]>;
62
+
63
+ /** Remember a join, REPLACING any record with the same `Id`. */
64
+ record(record: JoinedStrandRecord): Promise<void>;
65
+
66
+ /** Forget a join. A no-op when there is no record for `strandId`. */
67
+ forget(strandId: string): Promise<void>;
68
+ }
69
+
70
+ /**
71
+ * The {@link StrandRow} a record stands for — the shape `addStrand` and `strand:discovered`
72
+ * carry. `FounderOwnerKey` is null because a joiner never founds; `CadreNode.launchStrand`
73
+ * reads null as "not mine" and joins.
74
+ */
75
+ export function joinedStrandRow(record: JoinedStrandRecord): StrandRow {
76
+ return { Id: record.Id, Type: record.Type, MemberPrivateKey: record.MemberPrivateKey, FounderOwnerKey: null };
77
+ }
78
+
79
+ /**
80
+ * Ephemeral store for a node without a `keyStore`. Warns once, at the first join it is
81
+ * asked to remember, because a join held only here is not re-offered after a restart —
82
+ * exactly the gotchoices/sereus#18 shape, and it should show in a log rather than pass
83
+ * silently.
84
+ */
85
+ export class MemoryJoinedStrandStore implements JoinedStrandStore {
86
+ private readonly records = new Map<string, JoinedStrandRecord>();
87
+ private warned = false;
88
+
89
+ constructor(readonly partyId: string) {}
90
+
91
+ async list(): Promise<JoinedStrandRecord[]> {
92
+ return [...this.records.values()];
93
+ }
94
+
95
+ async record(record: JoinedStrandRecord): Promise<void> {
96
+ this.warnOnce();
97
+ this.records.set(record.Id, { ...record });
98
+ }
99
+
100
+ async forget(strandId: string): Promise<void> {
101
+ this.records.delete(strandId);
102
+ }
103
+
104
+ private warnOnce(): void {
105
+ if (this.warned) {
106
+ return;
107
+ }
108
+ this.warned = true;
109
+ console.warn(
110
+ 'CadreNode has no keyStore and no joinedStrands.store, so strands joined from another party ' +
111
+ 'are remembered in memory only and will NOT be re-offered after a restart. Configure a keyStore, ' +
112
+ 'or inject joinedStrands: { store: new KeyStoreJoinedStrandStore(<a durable KeyStore>, partyId) }.'
113
+ );
114
+ }
115
+ }
116
+
117
+ /** Slot id prefix for every joined-strand record; the party segment and the strand id follow. */
118
+ const SLOT_PREFIX = 'cadre/joined-strand/';
119
+
120
+ /**
121
+ * Durable store over a {@link KeyStore}: one slot per join, id
122
+ * `cadre/joined-strand/<base64url party id>/<strand id>`, holding the record's UTF-8 JSON.
123
+ * The party segment is base64url for the reason `controlStorageScope` gives: a party id is
124
+ * arbitrary text, and base64url never contains the `/` that ends the segment.
125
+ *
126
+ * Loads every slot once, at the first {@link list}, then answers from memory and writes
127
+ * through, so the strand watcher's five-second poll does not read the enclave on every
128
+ * pass. A load that fails — `KeyStoreAccessError` from a refused unlock prompt, say —
129
+ * is rethrown rather than read as "no joins" and retried at the next call, exactly as
130
+ * `loadOrCreateIdentityKey` treats the identity slot. A slot that does not parse as a
131
+ * record for its own id is dropped with a log and its siblings kept: refusing to start
132
+ * repairs nothing, and the other joins are still good.
133
+ */
134
+ export class KeyStoreJoinedStrandStore implements JoinedStrandStore {
135
+ private readonly slotPrefix: string;
136
+ /** The loaded records; null until a load succeeds. */
137
+ private records: Map<string, JoinedStrandRecord> | null = null;
138
+ /** Runs every KeyStore access in call order, so a load never interleaves with a write. */
139
+ private queue: Promise<unknown> = Promise.resolve();
140
+
141
+ constructor(private readonly keyStore: KeyStore, readonly partyId: string) {
142
+ this.slotPrefix = SLOT_PREFIX + uint8ArrayToString(uint8ArrayFromString(partyId, 'utf8'), 'base64url') + '/';
143
+ }
144
+
145
+ list(): Promise<JoinedStrandRecord[]> {
146
+ return this.inOrder(async () => [...(await this.loaded()).values()]);
147
+ }
148
+
149
+ /** Throws `InvalidStrandIdError` for an id that could never load back, before anything is written. */
150
+ record(record: JoinedStrandRecord): Promise<void> {
151
+ assertStrandScopeKey(record.Id);
152
+ const stored: JoinedStrandRecord = { ...record };
153
+ return this.inOrder(async () => {
154
+ await this.keyStore.set(this.slotId(record.Id), uint8ArrayFromString(JSON.stringify(stored), 'utf8'));
155
+ this.records?.set(record.Id, stored);
156
+ log('joined strand remembered (party=%s, strand=%s, type=%s)', this.partyId, record.Id, record.Type);
157
+ });
158
+ }
159
+
160
+ forget(strandId: string): Promise<void> {
161
+ return this.inOrder(async () => {
162
+ await this.keyStore.delete(this.slotId(strandId));
163
+ this.records?.delete(strandId);
164
+ log('joined strand forgotten (party=%s, strand=%s)', this.partyId, strandId);
165
+ });
166
+ }
167
+
168
+ private slotId(strandId: string): KeyId {
169
+ return this.slotPrefix + strandId;
170
+ }
171
+
172
+ private inOrder<T>(op: () => Promise<T>): Promise<T> {
173
+ const result = this.queue.then(op);
174
+ // The caller receives `result`, rejection included; the queue only needs to know it settled.
175
+ this.queue = result.catch(() => undefined);
176
+ return result;
177
+ }
178
+
179
+ // NOTE: a failed load is retried at the next list(), which is every strand-watcher poll;
180
+ // over a backend that gates reads behind an unlock prompt the user keeps declining, that
181
+ // is a prompt every five seconds. Every KeyStore shipped today is ungated for this reason
182
+ // (see `SecureStoreKeyStoreOptions.requireAuthentication`); if one is ever gated, back
183
+ // off the retry here.
184
+ private async loaded(): Promise<Map<string, JoinedStrandRecord>> {
185
+ if (this.records) {
186
+ return this.records;
187
+ }
188
+ const records = new Map<string, JoinedStrandRecord>();
189
+ for (const slot of await this.keyStore.list()) {
190
+ if (!slot.startsWith(this.slotPrefix)) {
191
+ continue;
192
+ }
193
+ const strandId = slot.slice(this.slotPrefix.length);
194
+ const bytes = await this.keyStore.get(slot);
195
+ if (!bytes) {
196
+ continue;
197
+ }
198
+ const record = parseRecord(strandId, bytes);
199
+ if (record) {
200
+ records.set(strandId, record);
201
+ }
202
+ }
203
+ this.records = records;
204
+ log('joined strands loaded (party=%s, count=%d)', this.partyId, records.size);
205
+ return records;
206
+ }
207
+ }
208
+
209
+ /** The record a slot holds, or undefined (logged) when it is not one for `strandId`. */
210
+ function parseRecord(strandId: string, bytes: Uint8Array): JoinedStrandRecord | undefined {
211
+ let parsed: unknown;
212
+ try {
213
+ parsed = JSON.parse(uint8ArrayToString(bytes, 'utf8'));
214
+ } catch (error) {
215
+ log('dropping joined-strand slot for %s: not JSON (%o)', strandId, error);
216
+ return undefined;
217
+ }
218
+ const { Id, Type, MemberPrivateKey, joinedAt } = (typeof parsed === 'object' && parsed !== null ? parsed : {}) as Record<string, unknown>;
219
+ const valid = Id === strandId
220
+ && isValidStrandScopeKey(strandId)
221
+ && (Type === 'o' || Type === 'c')
222
+ && (MemberPrivateKey === null || typeof MemberPrivateKey === 'string')
223
+ && typeof joinedAt === 'number';
224
+ if (!valid) {
225
+ log('dropping joined-strand slot for %s: not a joined-strand record for that id', strandId);
226
+ return undefined;
227
+ }
228
+ return { Id: strandId, Type: Type as 'o' | 'c', MemberPrivateKey: MemberPrivateKey as string | null, joinedAt: joinedAt as number };
229
+ }
230
+
231
+ /**
232
+ * The party-wide joined-strand table (`CadreControl.JoinedStrand`), as the node's control
233
+ * database exposes it. `CadreNode` builds it over `ControlDatabase` and the owner signing
234
+ * key; this module sees only the seam, so it stays free of the runtime.
235
+ */
236
+ export interface PartyJoinedStrandLedger {
237
+ /** Every party-wide join, as offerable rows (`FounderOwnerKey: null`). */
238
+ list(): Promise<StrandRow[]>;
239
+ /** Whether this party's control database names `strandId`, as its own strand or as a party-wide join. */
240
+ names(strandId: string): Promise<boolean>;
241
+ /** Whether this machine can sign {@link publish} and {@link remove}: it holds an owner key the party enrolls. */
242
+ canSign(): Promise<boolean>;
243
+ /** Owner-signed insert. Resolves when the row landed or a sibling's join already holds the id. */
244
+ publish(record: JoinedStrandRecord): Promise<void>;
245
+ /** Owner-signed delete + tombstone; false when there is no row. Throws when a row exists and this machine cannot sign. */
246
+ remove(strandId: string): Promise<boolean>;
247
+ }
248
+
249
+ /**
250
+ * One node session's joined strands: what the strand watcher sees of the two records, and
251
+ * the moves between them. Rebuilt at every start, which is what ends the per-session state
252
+ * below.
253
+ *
254
+ * The watcher reads a row missing from a poll as a removal and detaches the strand, so this
255
+ * holds the state a plain union would get wrong:
256
+ * - the last list each record answered, reused when a later read fails, so a failed read
257
+ * is not "every join was forgotten";
258
+ * - joins this party was removed from ({@link forgetAfterThisSession}): offered until this
259
+ * session ends, because `strand:revoked` promises that nothing is torn down for the app,
260
+ * and queued for party-wide removal so the next start does not re-attach them.
261
+ */
262
+ export class JoinedStrandSession {
263
+ private lastUnpublished: JoinedStrandRecord[] = [];
264
+ private lastPartyWide: StrandRow[] = [];
265
+ private readonly keptForSession = new Map<string, StrandRow>();
266
+ /** Strands whose party-wide row {@link syncWithParty} removes, queued by a self-revocation. */
267
+ private readonly pendingRemovals = new Set<string>();
268
+ /**
269
+ * Tail of the moves that must not interleave ({@link serialized}): a publish that found no
270
+ * party-wide row, then a {@link leave} that removed nothing, then the publish's insert
271
+ * would leave the party holding a strand the app just left.
272
+ */
273
+ private exclusive: Promise<unknown> = Promise.resolve();
274
+
275
+ constructor(private readonly store: JoinedStrandStore, private readonly ledger: PartyJoinedStrandLedger) {}
276
+
277
+ /**
278
+ * `control` plus every join, one row per id, in this precedence: the control rows (the
279
+ * party's own strands), then the party-wide joins, then this machine's unpublished joins
280
+ * and the joins kept for this session. A local record named by either of the first two in
281
+ * a read that succeeded this poll is stale — published, or the strand is the party's own
282
+ * now — so it is forgotten here; this also cleans up after a crash between a publish and
283
+ * its local forget. One named only by the last-good party-wide list is kept: that list may
284
+ * predate a leave the record re-joined after.
285
+ */
286
+ async withControlRows(control: readonly StrandRow[]): Promise<StrandRow[]> {
287
+ const controlIds = new Set(control.map((row) => row.Id));
288
+ const named = new Set(controlIds);
289
+ const offered = new Map<string, StrandRow>(control.map((row) => [row.Id, row]));
290
+ const partyWide = await this.partyWide();
291
+ // NOTE: a party-wide join whose id the party's own `Strand` table also holds (the party
292
+ // founded AND joined it — contrived) is shadowed here, not deleted: a watcher poll must
293
+ // not make owner-signed writes. If the party later unpublishes its own row the join row
294
+ // resurfaces; if that is ever seen, remove the join row inside `unpublishStrand`.
295
+ for (const row of partyWide.rows) {
296
+ if (!offered.has(row.Id)) {
297
+ offered.set(row.Id, row);
298
+ }
299
+ if (partyWide.fresh) {
300
+ named.add(row.Id);
301
+ }
302
+ }
303
+ for (const record of await this.unpublished()) {
304
+ if (named.has(record.Id)) {
305
+ await this.forgetSuperseded(record.Id);
306
+ } else if (!offered.has(record.Id)) {
307
+ offered.set(record.Id, joinedStrandRow(record));
308
+ }
309
+ }
310
+ for (const [strandId, row] of this.keptForSession) {
311
+ if (controlIds.has(strandId)) {
312
+ this.keptForSession.delete(strandId);
313
+ } else if (!offered.has(strandId)) {
314
+ offered.set(strandId, row);
315
+ }
316
+ }
317
+ return [...offered.values()];
318
+ }
319
+
320
+ /**
321
+ * This machine's unpublished joins, straight from the store (never the last-good list a
322
+ * failed read stands in with). What a strand move exports beside the `JoinedStrand`
323
+ * table: a machine publishes only while it holds a control connection, so until then its
324
+ * joins live only here.
325
+ */
326
+ unpublishedRecords(): Promise<JoinedStrandRecord[]> {
327
+ return this.store.list();
328
+ }
329
+
330
+ /** Record a join `formStrand` just made. A re-join also cancels a removal a revocation queued. */
331
+ async remember(record: JoinedStrandRecord): Promise<void> {
332
+ this.pendingRemovals.delete(record.Id);
333
+ await this.store.record(record);
334
+ }
335
+
336
+ /**
337
+ * Record `row`, joined by `addStrand`, unless this party's control database already names
338
+ * it (its own strand, or a join already published) or an identical record is already here
339
+ * (an app re-claiming a remembered join on every start). Keeps the first `joinedAt`. Like
340
+ * {@link remember}, cancels a queued removal.
341
+ */
342
+ async rememberForeign(row: StrandRow): Promise<void> {
343
+ this.pendingRemovals.delete(row.Id);
344
+ if (await this.namedByParty(row.Id)) {
345
+ return;
346
+ }
347
+ const existing = (await this.store.list()).find((record) => record.Id === row.Id);
348
+ if (existing?.Type === row.Type && existing.MemberPrivateKey === row.MemberPrivateKey) {
349
+ return;
350
+ }
351
+ await this.store.record({
352
+ Id: row.Id,
353
+ Type: row.Type,
354
+ MemberPrivateKey: row.MemberPrivateKey,
355
+ joinedAt: existing?.joinedAt ?? Date.now()
356
+ });
357
+ }
358
+
359
+ /**
360
+ * Leave a join for the whole party: remove the party-wide row, then this machine's record.
361
+ * Throws, keeping both, when a party-wide row exists and this machine cannot sign.
362
+ */
363
+ leave(strandId: string): Promise<void> {
364
+ return this.serialized(async () => {
365
+ await this.ledger.remove(strandId);
366
+ this.pendingRemovals.delete(strandId);
367
+ this.keptForSession.delete(strandId);
368
+ this.lastPartyWide = this.lastPartyWide.filter((row) => row.Id !== strandId);
369
+ await this.store.forget(strandId);
370
+ });
371
+ }
372
+
373
+ /**
374
+ * For a strand this party was removed from: keep offering it until this session ends, and
375
+ * queue its party-wide row for removal by {@link syncWithParty}, so it does not re-attach
376
+ * on every start of every machine. A no-op for a strand with neither record (one of the
377
+ * party's own). The queue is in memory: if the process dies first, the next start
378
+ * relaunches the strand, the revoked-peer gate raises `strand:revoked` again, and this
379
+ * runs again.
380
+ */
381
+ forgetAfterThisSession(strandId: string): Promise<void> {
382
+ return this.serialized(async () => {
383
+ const local = (await this.unpublished()).find((record) => record.Id === strandId);
384
+ const row = local ? joinedStrandRow(local) : this.lastPartyWide.find((listed) => listed.Id === strandId);
385
+ if (!row) {
386
+ return;
387
+ }
388
+ // Kept BEFORE the store forgets, so no poll can union without it. Queued even for a
389
+ // local-only join, which a sibling may have published already.
390
+ this.keptForSession.set(strandId, row);
391
+ this.pendingRemovals.add(strandId);
392
+ if (local) {
393
+ await this.store.forget(strandId);
394
+ }
395
+ });
396
+ }
397
+
398
+ /**
399
+ * The owner machine's half, run by the node's connected reconcile pass: publish every
400
+ * unpublished join party-wide, then remove the party-wide rows queued by
401
+ * {@link forgetAfterThisSession}. Does nothing on a machine that cannot sign, whose joins
402
+ * stay machine-local. Per strand, a failure is logged and kept for the next pass; a
403
+ * failure to list the store or to check the signer throws.
404
+ */
405
+ syncWithParty(): Promise<void> {
406
+ return this.serialized(async () => {
407
+ // Straight from the store, never the last-good list: a stale list could republish a
408
+ // join that was left since.
409
+ const unpublished = await this.store.list();
410
+ if (unpublished.length === 0 && this.pendingRemovals.size === 0) {
411
+ return;
412
+ }
413
+ if (!(await this.ledger.canSign())) {
414
+ return;
415
+ }
416
+ for (const record of unpublished) {
417
+ await this.publish(record);
418
+ }
419
+ for (const strandId of [...this.pendingRemovals]) {
420
+ await this.removeQueued(strandId);
421
+ }
422
+ });
423
+ }
424
+
425
+ // NOTE: a sibling that joined the same strand and has not yet published publishes it after
426
+ // a leave, bringing the strand back party-wide. Needs two devices joining one strand within
427
+ // a reconcile interval of the leave; if it is ever seen, check the strand's `Revocation`
428
+ // tombstone before publishing.
429
+ private async publish(record: JoinedStrandRecord): Promise<void> {
430
+ try {
431
+ if (!(await this.ledger.names(record.Id))) {
432
+ await this.ledger.publish(record);
433
+ // Into the last-good list too: its local record goes next, so a party-wide read that
434
+ // fails before one succeeds would otherwise drop the strand from the poll and hide it
435
+ // from a self-revocation.
436
+ this.lastPartyWide = [...this.lastPartyWide.filter((row) => row.Id !== record.Id), joinedStrandRow(record)];
437
+ log('joined strand %s published party-wide (party=%s)', record.Id, this.store.partyId);
438
+ }
439
+ await this.store.forget(record.Id);
440
+ } catch (error) {
441
+ log('joined strand %s: publishing party-wide failed; kept for the next pass: %o', record.Id, error);
442
+ }
443
+ }
444
+
445
+ private async removeQueued(strandId: string): Promise<void> {
446
+ try {
447
+ const removed = await this.ledger.remove(strandId);
448
+ this.pendingRemovals.delete(strandId);
449
+ log('joined strand %s: party-wide row %s after this party was removed from it',
450
+ strandId, removed ? 'removed' : 'already absent');
451
+ } catch (error) {
452
+ log('joined strand %s: removing the party-wide row failed; queued for the next pass: %o', strandId, error);
453
+ }
454
+ }
455
+
456
+ /** The party-wide joins; `fresh` is false when this read failed and the last list it answered stands in. */
457
+ private async partyWide(): Promise<{ rows: StrandRow[]; fresh: boolean }> {
458
+ try {
459
+ this.lastPartyWide = await this.ledger.list();
460
+ return { rows: this.lastPartyWide, fresh: true };
461
+ } catch (error) {
462
+ log('party-wide joined-strand read failed; offering the last list it answered (%d join(s)): %o',
463
+ this.lastPartyWide.length, error);
464
+ return { rows: this.lastPartyWide, fresh: false };
465
+ }
466
+ }
467
+
468
+ /**
469
+ * {@link PartyJoinedStrandLedger.names}, answering false when the control database cannot
470
+ * be read (a machine cut off from its party before it received the `JoinedStrand` block).
471
+ * Recording is the safe side: a local record the party turns out to name is forgotten by
472
+ * the next poll that reads it, while a join with no record is not offered after a restart.
473
+ */
474
+ private async namedByParty(strandId: string): Promise<boolean> {
475
+ try {
476
+ return await this.ledger.names(strandId);
477
+ } catch (error) {
478
+ log('joined strand %s: could not read whether the control database names it; recording it locally: %o', strandId, error);
479
+ return false;
480
+ }
481
+ }
482
+
483
+ /** Run `work` after every earlier serialized move settles; see {@link exclusive}. */
484
+ private serialized<T>(work: () => Promise<T>): Promise<T> {
485
+ const result = this.exclusive.then(work);
486
+ this.exclusive = result.catch(() => undefined);
487
+ return result;
488
+ }
489
+
490
+ private async unpublished(): Promise<JoinedStrandRecord[]> {
491
+ try {
492
+ this.lastUnpublished = await this.store.list();
493
+ } catch (error) {
494
+ log('joined-strand store list failed; offering the last list it answered (%d join(s)): %o',
495
+ this.lastUnpublished.length, error);
496
+ }
497
+ return this.lastUnpublished;
498
+ }
499
+
500
+ private async forgetSuperseded(strandId: string): Promise<void> {
501
+ try {
502
+ await this.store.forget(strandId);
503
+ log('joined strand %s is named by this party\'s control database — local record forgotten', strandId);
504
+ } catch (error) {
505
+ log('forgetting superseded joined strand %s failed; the next poll retries: %o', strandId, error);
506
+ }
507
+ }
508
+ }