@derec-alliance/react-native 0.0.7 → 0.0.9

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.
package/README.md CHANGED
@@ -91,12 +91,16 @@ import {
91
91
  SenderKind,
92
92
  } from '@derec-alliance/react-native';
93
93
 
94
+ // withStores sets all five stores in one call. Each with*Store setter is
95
+ // still available; the last call for a store wins.
94
96
  const protocol = new DeRecProtocolBuilder(secretId) // bigint | number
95
- .withChannelStore(myChannelStore)
96
- .withShareStore(myShareStore)
97
- .withSecretStore(mySecretStore)
98
- .withUserSecretStore(myUserSecretStore)
99
- .withStateStore(myStateStore)
97
+ .withStores({
98
+ channelStore: myChannelStore,
99
+ shareStore: myShareStore,
100
+ secretStore: mySecretStore,
101
+ userSecretStore: myUserSecretStore,
102
+ stateStore: myStateStore,
103
+ })
100
104
  .withTransport(myTransport)
101
105
  .withOwnTransports([{ uri: 'https://owner.example.com', protocol: 'https' }])
102
106
  .withThreshold(2)
@@ -125,6 +129,76 @@ longer needed (safe to call more than once).
125
129
 
126
130
  ---
127
131
 
132
+ ## Verification outcomes
133
+
134
+ `protocol.start(FlowKind.VerifyShares, …)` challenges each targeted helper,
135
+ and every challenged channel ends with exactly one event:
136
+
137
+ - `ShareVerified` — the helper proved it holds the exact share. `subshares`
138
+ is how many subshares that helper holds for `version`, read from this
139
+ device's stored share, so it is the weight of the round that published
140
+ `version`, not the helper's current weight; it is absent when that share was
141
+ stored before the count was recorded. `threshold` is the threshold that version was split with, from the
142
+ same stored share, not the current one; it is absent when that share was
143
+ stored before the threshold was recorded. To judge whether a version is
144
+ recoverable, sum `subshares` over its `ShareVerified` events and compare the
145
+ sum with their `threshold`. If either is absent on any of those events,
146
+ the result is unknown: a absent count is not `0` or `1`. The count lives on `Share.subshares`, which the
147
+ library sets on owner-side shares only (absent on a helper's share and never
148
+ backfilled): your `ShareStore` must persist it as given and return it
149
+ unchanged. `Share.threshold` follows the same rules, and must be
150
+ persisted the same way.
151
+ - `ShareVerifyInvalid { channel_id, version }` — the helper's proof does not
152
+ match the share this device stored. It is an event; `process()` does not
153
+ reject.
154
+ - `ShareVerifyRejected { status, memo }` — the helper refused the challenge
155
+ with a non-OK status.
156
+ - `VerifySharesFailed { reason, error }` — no verdict could be reached on
157
+ this device. `reason` is a `VerifyFailureReason`: `'NoSharedKey'`,
158
+ `'NoProof'` or `'DispatchFailed'`.
159
+
160
+ A stored proof is either confirmed by its helper or part of a sharing round
161
+ still open: when a round settles a channel as failed (a rejection or a
162
+ timeout), that channel's proof is deleted. A confirmation also deletes the
163
+ proofs of versions the helper dropped: a helper that stores a version
164
+ deletes every other version that round's `keepList` leaves out, so when it
165
+ confirms, its proofs for those versions are deleted here too (the confirmed
166
+ version stays). A helper that rejects the round keeps its older versions, and
167
+ so do their proofs; with no keep list nothing is deleted. A replica Destination never sees the helpers' answers, and it keeps every
168
+ proof it receives: it never deletes one on its own, and freeing that storage
169
+ is the app's concern. Verifying on a Destination a version that a helper
170
+ refused, or later dropped under the `keepList`, gets `ShareVerifyRejected`
171
+ (`UNKNOWN_SHARE_VERSION`) from that helper, where the Source reports
172
+ `NoProof`. The app may delete such rows from the Destination's share store;
173
+ verifying a version whose rows are gone then gives `VerifySharesFailed` with
174
+ `reason: 'NoProof'`. Verify only versions whose round
175
+ completed (`SharingComplete`). Starting verification on a channel with no
176
+ proof sends nothing and returns `VerifySharesFailed` with
177
+ `reason: 'NoProof'` at once. A late response to an earlier challenge is
178
+ ignored as `MessageIgnored` with `reason: 'StaleResponse'`, and the current
179
+ challenge stays open; a response missing its `result` is still an error.
180
+
181
+ **After recovery: restore or join.** `SecretRecovered` writes nothing, so
182
+ decide before calling `restore`. When `secret.replicas?.members` names live
183
+ members other than the lost device, do not restore: tell the user and offer
184
+ to join that group as a new replica — pair with a live member, confirm the
185
+ fingerprint, then run `start(FlowKind.ReplicaDiscovery)`. With no live member, restore. Neither path gives this device the proofs of
186
+ earlier versions, so it cannot verify them: restore writes none, and a
187
+ joining device gets only the share map of the single version each push or
188
+ catch-up carries. The live members keep the proofs they hold. A restored
189
+ device also cannot publish, or answer a member's catch-up, until the app
190
+ configures a `replica_id` the roster names. Joining adds the device as a new
191
+ member, while restoring next to live replicas makes a second device claim
192
+ the lost device's role. See
193
+ [Restore or join](https://derec-alliance.gitbook.io/docs/concepts/replicas/restore-or-join).
194
+
195
+ Every channel message carries one timestamp, its send time, in both the
196
+ envelope and the inner message. A message whose two timestamps differ, or
197
+ where either or both are absent, is ignored as `MessageIgnored` with
198
+ `reason: 'TimestampMismatch'`; this includes replies from 0.0.8 peers.
199
+
200
+ ---
201
+
128
202
  ## When `process()` fails
129
203
 
130
204
  `process()` settles expired deadlines (sharing-round and unpair timeouts)
@@ -144,6 +218,80 @@ try {
144
218
  }
145
219
  ```
146
220
 
221
+ A request this device fails to handle does not reject the call; see
222
+ [Failed requests](#failed-requests).
223
+
224
+ ---
225
+
226
+ ## Failed requests
227
+
228
+ Every request that arrives on an authenticated channel gets exactly one
229
+ response, even when handling it fails. Such a failure does not make
230
+ `process()` or `accept()` reject: it is reported as a `RequestFailed` event,
231
+ and the call's other events are kept.
232
+
233
+ - `action_kind` is the type of the request that failed, a
234
+ `PendingActionKind` label (`"Failed"` when its body could not be decoded,
235
+ or was a request type the channel cannot carry).
236
+ - `responded` is `true` when the library already sent the response.
237
+ - `status` is the `StatusEnum` that was sent, or would have been. It is
238
+ always present. `memo` is the wire-safe memo sent with it, or that would
239
+ have been, so `reject(action, status, memo)` sends exactly the response
240
+ the library would have sent.
241
+ - `error_code` is the error's `DeRecErrorCode` and `message` a local
242
+ description; neither goes on the wire.
243
+ - `action`, when `responded` is `false`, is the token that answers the
244
+ request. Pass it to `reject`, which sends the one response; `accept`
245
+ refuses it. The library never expires it. It is absent only when `reject`
246
+ could never answer it: a request on a replica group channel that names no
247
+ member and carries no `replyTo`, which has no reply target, or, with
248
+ `withAutoRespondOnFailure` off, a replica copy that hands this device the
249
+ group key and fails before that key is stored.
250
+
251
+ Resolve each token once. The library is stateless, so rejecting the same
252
+ token twice, or rejecting an action already accepted, sends a second
253
+ response.
254
+
255
+ `withAutoRespondOnFailure` decides who answers. With it on, the library sends
256
+ the response itself (a known failure keeps its specific status; anything else
257
+ is `FAIL` with a generic memo). With it off (the default), nothing is sent and
258
+ the event carries the token:
259
+
260
+ ```ts
261
+ for (const event of events) {
262
+ if (event.type === "RequestFailed" && !event.responded && event.action) {
263
+ await protocol.reject(event.action, event.status, event.memo);
264
+ }
265
+ }
266
+ ```
267
+
268
+ A message that decrypts under the channel key but whose body cannot be
269
+ decoded is answered with an `ErrorResponseMessage` (`FORMAT_ERROR` when the
270
+ library answers it; the status passed to `reject` otherwise). A peer that
271
+ answers this device that way is reported as an `ErrorResponseReceived` event
272
+ with `status` and `memo`; it is never answered.
273
+
274
+ An inbound `PairRequest` refused under a protocol rule (a parameter range that
275
+ does not overlap this device's, `INCOMPATIBLE_PARAMETER_RANGE`, or a replica id
276
+ already in the group, `REPLICA_ID_CONFLICT`) is answered with that status in
277
+ every mode and reported as a `PairingRefused` event with `status` and `memo`,
278
+ the reason sent to the requester and written from its side. `error_code`
279
+ (`incompatible_parameter_range` or `replica_id_conflict`) and `message` carry
280
+ the local reason for logging or display, as on `RequestFailed`; they are never
281
+ sent. `process()` does not reject for it, and no channel is created. The
282
+ requester's `process()` rejects with code `non_ok_status`, whose `status` and
283
+ `memo` hold the refusal.
284
+
285
+ An inbound share the library refuses before any `ActionRequired` (undecodable,
286
+ failing its commitment, outside this device's share-size range, or conflicting
287
+ with the share stored at that version) is reported as a `ShareRefused` event
288
+ with the `status` and `memo` sent; the owner observes it as `ShareRejected`.
289
+
290
+ A replica group member's copy of the secret that does not decode or verify is
291
+ refused, nothing is written, and it is reported as a `ReplicaSecretRefused`
292
+ event with `status` and `memo`; on a push the publisher observes it as
293
+ `ReplicaSyncRejected`.
294
+
147
295
  ---
148
296
 
149
297
  ## Store interfaces
@@ -172,12 +320,72 @@ declared in `src/types.ts`:
172
320
  deliver once, to the first one that accepts it, and reject only when none
173
321
  did. `sequentialFailover` and `singleEndpointTransport` implement that
174
322
  choice for you. It is a mailbox, not a request/response call, since not
175
- every peer (e.g. a phone behind NAT) can be dialed back.
323
+ every peer (e.g. a phone behind NAT) can be dialed back. Recommended HTTP
324
+ conventions (`POST`, `application/octet-stream` body, `202 Accepted`) and
325
+ the same-connection pattern for unreachable peers are in
326
+ [Transports](https://derec-alliance.gitbook.io/docs/protocol-specification/transports);
327
+ they are recommendations, not protocol requirements.
176
328
 
177
329
  Every method is `async` and keyed by decimal-string ids — see the next
178
330
  section for why that differs from `DeRecProtocol`'s own methods. Full field-
179
331
  level contracts are documented as TSDoc on each interface in `src/types.ts`.
180
332
 
333
+ ### Helpers and shares
334
+
335
+ - Each share goes to a **different** helper. Threshold security depends on it:
336
+ compromising one helper must yield at most one share.
337
+ - The library hands out one share per channel per version, and every pairing
338
+ produces its own channel. It cannot tell whether two channels lead to the
339
+ same party; keeping helpers distinct is the application's job.
340
+ - **Discouraged:** a single guardian acting as several helpers for the same
341
+ owner. It is against the protocol's recommendation, since one compromise
342
+ yields several shares. The library does not prevent it, but channel linking
343
+ then needs the application's help (below).
344
+ - **Channel linking is expected to be one-to-one.** After an owner re-pairs in
345
+ recovery mode, the helper application links the new channel to the old one
346
+ with `ChannelStore.linkChannel`. Linking assumes a helper holds **at most one share per owner
347
+ for a given `secretId` and version**, so each recovery channel is linked to the
348
+ single old channel it replaces. On `GetShare` the helper answers with
349
+ **one** share from the linked set: the first whose `secretId` matches the
350
+ request, in the order `ShareStore.loadMany` returns. The library imposes no order. If
351
+ several channels holding shares for the same owner are linked to one
352
+ recovery channel and every recovery channel is answered from the same
353
+ share, the owner receives it more than once (it counts once) and can fall
354
+ below the threshold. An application that runs such a helper keeps each
355
+ recovery channel resolving to a distinct share: either links stay
356
+ one-to-one, or its share store returns, for each requesting channel, the
357
+ share intended for it first, in a deterministic order.
358
+
359
+ ### Refusing a share or a verification
360
+
361
+ A helper enforces share size itself: with a parameter range configured, a
362
+ share outside its own `minShareSize`/`maxShareSize` (inclusive; a max of `0` means
363
+ unbounded) is answered with `SIZE_LIMIT_EXCEEDED` before any
364
+ `ActionRequired` or auto-accept. Frequency limits on share updates and
365
+ verifications are the application's to enforce, from its share store.
366
+
367
+ On a helper channel, throw (or reject with) `shareStoreRefusal(reason)` from
368
+ `ShareStore.save` (a genuinely new share version) or
369
+ `ShareStore.recordVerification` (an accepted verification challenge) to
370
+ refuse it:
371
+
372
+ ```ts
373
+ import { shareStoreRefusal } from "@derec-alliance/react-native";
374
+
375
+ async recordVerification(secretId: string, channelId: string, version: number) {
376
+ if (limiter.tooSoon(channelId)) throw shareStoreRefusal("too_frequent");
377
+ }
378
+ ```
379
+
380
+ The library answers the owner with the mapped status (`"too_frequent"` →
381
+ `TOO_FREQUENT`, `"size_limit_exceeded"` → `SIZE_LIMIT_EXCEEDED`,
382
+ `"rejected"` → `REJECTED`), and the owner sees `ShareRejected` or
383
+ `ShareVerifyRejected` with that `status`. Any other rejection is an ordinary
384
+ store failure, reported as a `RequestFailed` event (see
385
+ [Failed requests](#failed-requests)). Every `ShareStore` implements
386
+ `recordVerification`; an app with no limit resolves it. See
387
+ [Helper-side admission control](https://github.com/derecalliance/lib-derec/tree/main/library#helper-side-admission-control).
388
+
181
389
  ### Filtered listings
182
390
 
183
391
  `listHelpers` and `listReplicas` receive a filter object — `ids`, `status`,
@@ -226,6 +434,102 @@ for its duration. Applications that need this work off the JS thread should
226
434
  drive it through `DeRecProtocol` (e.g. `start(FlowKind.ProtectSecret, ...)`)
227
435
  instead of calling the primitive directly.
228
436
 
437
+ `split`'s `channels` argument is a `WeightedChannel[]` — `[channelId,
438
+ weight]` tuples, one per channel, letting a chosen channel hold more than
439
+ one subshare (e.g. a device the owner trusts more than the others). A
440
+ weight of `1` is an ordinary helper holding a single subshare, so with every
441
+ weight at `1`, `threshold` counts helpers as before. `ProtectSecretParams`
442
+ accepts an equivalent `weights: Map<bigint, number>` when driving the flow
443
+ through `DeRecProtocol`; `ProtectSecretStarted` and `ProtectSecretFailed`
444
+ report the resulting `weight` for the channel they name.
445
+
446
+ That map is the **full assignment, not a patch**: a paired helper it does not
447
+ name holds one subshare. Weights are recorded: each round writes the weight it gave every paired helper
448
+ to that helper's channel record before anything is sent, and the publishes the
449
+ library starts on its own (after pairing, after a fingerprint is confirmed)
450
+ reuse those weights; a newly paired helper starts at 1. The weights also travel
451
+ in the recovered secret (`helpers[].weight`), so `restore` and replica
452
+ hydration write them back into the channel records they create.
453
+
454
+ The threshold is recorded too. A round splits at `ProtectSecretParams.threshold` when
455
+ given, otherwise at the stored threshold (`UserSecrets.threshold`), and only otherwise
456
+ at `withThreshold`, which is therefore the starting threshold for a secret with
457
+ nothing stored. Every publish stores the threshold it used, the recovered
458
+ secret carries it (`secret.threshold`), and `restore` and replica
459
+ hydration put it back, so the publishes the library starts on its own and
460
+ the follow-up after `restore` keep it: leave `threshold` unset there. To
461
+ change it, pass `threshold` on `start(FlowKind.ProtectSecret, …)`. It must be at least 2,
462
+ above every weight, and no greater than a subshare cap; a round that fails
463
+ a check writes and sends nothing. Your `UserSecretStore` must persist the
464
+ snapshot's threshold as given.
465
+
466
+ The intended flow. Weights are the user's choice: before `start`, show the
467
+ user each paired helper's current weight, pre-filled from your `ChannelStore`,
468
+ and pass the user's final choice as the whole `weights` map. Reading the
469
+ stored weights first is the normal flow. Every other `start(FlowKind.ProtectSecret, ...)`
470
+ the app makes, such as a republish after editing the secrets or the follow-up
471
+ after `restore`, must pass the stored weights unchanged: a helper left out
472
+ returns to 1, which silently undoes the user's choice. Only the publishes the
473
+ library starts on its own (after pairing, after a fingerprint is confirmed)
474
+ reuse the stored weights by themselves.
475
+
476
+ A helper record is opaque JSON bytes, `{"Helper": {...}}`. Parsing one to read
477
+ `weight` is safe: it is a small integer, and the channel id comes from the
478
+ `channelId` argument your store received, never from the parsed object's
479
+ `channel_id` (a `u64` that `JSON.parse` can round). What the `ChannelStore`
480
+ docs forbid is re-serialising a parsed record; keep persisting the original
481
+ bytes.
482
+
483
+ ```ts
484
+ const weight = JSON.parse(new TextDecoder().decode(helperRecordBytes)).Helper.weight || 1;
485
+ ```
486
+
487
+ When the builder's threshold applies (no threshold is stored), lowering it to
488
+ or below a stored weight makes the next publish fail
489
+ with code `invalid_weight` before anything is sent, until `ProtectSecret`
490
+ assigns valid weights.
491
+
492
+ That failure surfaces where the library started the publish, and in each
493
+ case nothing is sent:
494
+
495
+ - `process()` throws an error whose `events` keep the message's events,
496
+ `PairingCompleted` included.
497
+ - `accept()` throws. The pairing has completed and its response was sent,
498
+ but `PairingCompleted` is not returned.
499
+ - `verifyFingerprint()` throws. The promotion stands: calling it again
500
+ resolves `true` and publishes nothing.
501
+
502
+ Call `start` with `ProtectSecret` and valid weights to publish to every
503
+ helper.
504
+
505
+ **Below the threshold.** When the paired helpers' total weight is below the
506
+ threshold, `start` skips the split: no helper receives anything, so no
507
+ `ProtectSecretStarted` / `ProtectSecretFailed` comes back for one. A paired
508
+ replica still gets its usual secret-only copy and events; with none paired,
509
+ `start` resolves with no events at all — this is not an error. The version
510
+ still advances and the secrets/description are saved as the latest
511
+ snapshot, so the next publish that reaches threshold (including one the
512
+ library starts on its own) shares it. Sum the weights about to be assigned
513
+ against the threshold beforehand, or count the returned helper events
514
+ afterward, to tell the two cases apart. This is more reachable with
515
+ weighted helpers than with the default of 1: two helpers at weight 2 against
516
+ a threshold of 3 (total weight 4) drop below it when a publish leaves both
517
+ out of the weights map, resetting each to weight 1 for a total of 2 — a
518
+ risk for a restore follow-up publish that does not pass the stored weights
519
+ unchanged.
520
+
521
+ `withMaxSubshares(max)` on the builder caps the total subshares one round
522
+ splits the secret into — the sum of every helper's weight. Unset means no
523
+ cap. Recovery time grows quadratically with that total (about 0.2 s at 200,
524
+ 5.5 s at 1,000 and 87 s at 4,000 subshares per attempt) while splitting stays
525
+ cheap, so this is the place to bound it. A round over the cap fails
526
+ `start(FlowKind.ProtectSecret, ...)` with code `too_many_subshares` before
527
+ anything is sent; publishes the library starts itself count the stored weights
528
+ and respect it too. A cap
529
+ below the threshold makes `build()` throw `invalid_input`. The cap applies at
530
+ sharing only, never at recovery: a recovering device cannot know the cap the
531
+ shares were made under.
532
+
229
533
  **Do not call protocol methods from inside a store callback.** A store
230
534
  method (`load`, `save`, `remove`, ...) is invoked as a callback into
231
535
  JavaScript, with the protocol's worker blocked waiting for the promise that
@@ -249,6 +553,11 @@ omits `"wasm"`, which a C-ABI binding can never emit, and adds `"ok"`,
249
553
  `"ffi"` and `"state_store"`, which it can. A test pins the union to the crate's shared enum fixture so it stays in step
250
554
  with the Rust enum.
251
555
 
556
+ **`DeRecError.code` is typed `DeRecErrorCode`.** It lists exactly the codes
557
+ `derec_error_code_name` can return — no WASM-only marshalling codes, since
558
+ this SDK never goes through WASM. A test pins it to the same shared enum
559
+ fixture as `DeRecErrorCategory`.
560
+
252
561
  **Ids are decimal strings in stores, `bigint | number` on `DeRecProtocol`.**
253
562
  Store interfaces (`ChannelStore`, `ShareStore`, event fields such as
254
563
  `channel_id` / `replica_id`, etc.) use decimal strings for every `u64` id,
@@ -263,6 +572,85 @@ everywhere.
263
572
 
264
573
  ---
265
574
 
575
+ ## Configuration from a file or record
576
+
577
+ Applications usually load settings as a whole: general settings from the
578
+ environment or a config file, per-user settings from a user record.
579
+ `DeRecProtocolConfig` holds every data setting of the builder, each one
580
+ optional, and `withConfig` applies it in one call:
581
+
582
+ ```ts
583
+ import { DeRecProtocolBuilder } from "@derec-alliance/react-native";
584
+ import type { DeRecProtocolConfig } from "@derec-alliance/react-native";
585
+
586
+ const general: DeRecProtocolConfig = JSON.parse(generalJson);
587
+ const perUser: DeRecProtocolConfig = JSON.parse(userRecordJson);
588
+
589
+ const protocol = new DeRecProtocolBuilder(secretId)
590
+ .withChannelStore(channelStore)
591
+ .withShareStore(shareStore)
592
+ .withSecretStore(secretStore)
593
+ .withUserSecretStore(userSecretStore)
594
+ .withStateStore(stateStore)
595
+ .withTransport(myTransport)
596
+ .withConfig(general) // 1. general settings
597
+ .withConfig(perUser) // 2. per-user settings
598
+ .withThreshold(4) // 3. individual setters
599
+ .build();
600
+ ```
601
+
602
+ - **Only set (present and non-`null`) fields are applied.** An absent or
603
+ `null` field leaves whatever the builder already holds.
604
+ - **Last write wins, per field.** Nested objects replace the builder's value
605
+ as a whole, except `timeouts`: each of its fields is merged separately, so
606
+ a per-user config that sets only `inbound_message_secs` keeps the
607
+ `sharing_round_secs` the general config set.
608
+ - **This binding assigns; it validates nothing.** Every key is forwarded
609
+ verbatim, under its own spelling, into the same `snake_case` config JSON
610
+ `build()` sends over the FFI. Rust applies the same rules a matching
611
+ `with*` setter's value would and rejects an unknown key at any level — a
612
+ misspelled key, or a value that fails validation, throws from `build()`
613
+ rather than being silently ignored.
614
+ - **Stores, the transport and the constructor's `secretId` are not settings**
615
+ and are not carried in the config.
616
+ - **A `secret_id` key in a config is ignored, not rejected.** `build()`
617
+ assigns the constructor's `secretId` last, so a config carrying a
618
+ `secret_id` key (for example a per-user record that includes it by
619
+ mistake) never changes which secret this instance manages. Every other
620
+ SDK rejects that key outright, since `DeRecProtocolConfig` has no
621
+ `secret_id` field there and unknown keys fail.
622
+
623
+ The canonical JSON, shared by every SDK, is:
624
+
625
+ ```json
626
+ {
627
+ "threshold": 3,
628
+ "max_subshares": "12",
629
+ "timeouts": {
630
+ "inbound_message_secs": 300,
631
+ "sharing_round_secs": 60,
632
+ "unpair_ack_secs": 60,
633
+ "expired_channels": { "enabled": true, "timeout_in_secs": 300 }
634
+ },
635
+ "parameter_range": { "min_share_size": 1, "max_share_size": 4096 },
636
+ "auto_accept": { "pairing": true, "store_share": true },
637
+ "auto_respond_on_failure": false,
638
+ "auto_reply_to": false,
639
+ "unpair_ack": "required",
640
+ "unsafe_connection": false,
641
+ "communication_info": { "name": "alice" },
642
+ "own_transports": [{ "uri": "https://me.example.com/derec", "protocol": "https" }],
643
+ "replica_id": "9007199254740993"
644
+ }
645
+ ```
646
+
647
+ Keys are `snake_case` and every key is optional. `replica_id` and
648
+ `max_subshares` are `u64` values written as decimal strings. Timeouts are
649
+ whole seconds. See the `DeRecProtocolConfig` doc comment in `src/types.ts` for
650
+ every field's accepted forms.
651
+
652
+ ---
653
+
266
654
  ## Package contents
267
655
 
268
656
  ```text
package/cpp/Convert.cpp CHANGED
@@ -169,6 +169,15 @@ std::optional<DecodedShare> decodeShareRecord(const uint8_t* ptr, size_t len) {
169
169
  out.secretId = std::move(*secretId);
170
170
  out.version = static_cast<uint32_t>(parseUnsignedAt(json, versionPos));
171
171
  out.bytes = parseByteArrayAt(json, bytesPos);
172
+ auto optionalUnsigned = [&json](const char* field) -> std::optional<uint32_t> {
173
+ size_t pos = fieldValueOffset(json, field);
174
+ if (pos != std::string::npos && pos < json.size() && json[pos] >= '0' && json[pos] <= '9') {
175
+ return static_cast<uint32_t>(parseUnsignedAt(json, pos));
176
+ }
177
+ return std::nullopt;
178
+ };
179
+ out.subshares = optionalUnsigned("subshares");
180
+ out.threshold = optionalUnsigned("threshold");
172
181
  return out;
173
182
  }
174
183
 
package/cpp/Convert.h CHANGED
@@ -60,14 +60,20 @@ std::vector<uint8_t> decodeSecretValueBytes(const uint8_t* ptr, size_t len);
60
60
 
61
61
  /// A `ShareRecord` as `ShareStore.save` receives it. `secretId` is the
62
62
  /// record's own `secret_id`, kept as the decimal string Rust wrote.
63
+ /// `subshares` is the owner-side subshare count and `threshold` the
64
+ /// owner-side threshold the version was split with, each empty when Rust
65
+ /// wrote none.
63
66
  struct DecodedShare {
64
67
  std::string secretId;
65
68
  uint32_t version;
66
69
  std::vector<uint8_t> bytes;
70
+ std::optional<uint32_t> subshares;
71
+ std::optional<uint32_t> threshold;
67
72
  };
68
73
 
69
- /// Decode `{"secret_id":"...","version":<n>,"bytes":[<n>,...]}`. Empty when
70
- /// any of the three fields is absent.
74
+ /// Decode `{"secret_id":"...","version":<n>,"bytes":[<n>,...],"subshares":<n>,"threshold":<n>}`.
75
+ /// Empty when any of the first three fields is absent; `subshares` and
76
+ /// `threshold` are optional, and absent or `null` decodes as none.
71
77
  std::optional<DecodedShare> decodeShareRecord(const uint8_t* ptr, size_t len);
72
78
 
73
79
  } // namespace derec