@reticulum/dacar 1.0.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.
package/src/config.js ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Node configuration: trust anchors, salts, and thresholds (§4, §10).
3
+ *
4
+ * Every Dacar node is bootstrapped out-of-band with one or more Root Trust
5
+ * Anchors (single identities or Threshold Groups), a Privacy Salt (plus up to
6
+ * two Legacy Salts for rotation, §10), and optionally an Authoritative Identity
7
+ * for Strict Consistency (§8).
8
+ */
9
+
10
+ import { toHex } from "@reticulum/core";
11
+ import {
12
+ DEFAULT_SALT,
13
+ HASH_SIZE,
14
+ MAX_LEGACY_SALTS,
15
+ SALT_SIZE,
16
+ NamespaceHasher,
17
+ bytesEqual,
18
+ } from "./namespace.js";
19
+
20
+ /** Default deletion horizon H (days), see §9. */
21
+ export const DEFAULT_DELETION_HORIZON_DAYS = 180;
22
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
23
+
24
+ /**
25
+ * Guard ensuring the fail-open null-salt notice is logged at most once per
26
+ * process, so test suites and tooling that build many Configs are not flooded
27
+ * while still flagging the footgun at first startup.
28
+ */
29
+ let __nullSaltWarned = false;
30
+
31
+ /**
32
+ * @typedef {Object} ConfigInit
33
+ * @property {Iterable<Uint8Array>} rootTrustAnchors One or more 16-byte hashes.
34
+ * @property {Uint8Array} [primarySalt] 32-byte Primary Privacy Salt.
35
+ * @property {Uint8Array[]} [legacySalts] Ordered Legacy Salts (≤ MAX_LEGACY_SALTS).
36
+ * @property {import("./threshold.js").ThresholdGroup[]} [thresholdGroups]
37
+ * @property {Uint8Array} [authoritativeIdentity] One identity for §8, or omit.
38
+ * @property {number} [deletionHorizonDays] Deletion horizon H (§9).
39
+ */
40
+
41
+ export class Config {
42
+ /** @param {ConfigInit} init */
43
+ constructor({
44
+ rootTrustAnchors,
45
+ primarySalt = DEFAULT_SALT,
46
+ legacySalts = [],
47
+ thresholdGroups = [],
48
+ authoritativeIdentity,
49
+ deletionHorizonDays = DEFAULT_DELETION_HORIZON_DAYS,
50
+ }) {
51
+ const anchors = new Set();
52
+ for (const anchor of rootTrustAnchors) {
53
+ if (!(anchor instanceof Uint8Array) || anchor.length !== HASH_SIZE) {
54
+ throw new TypeError(`trust anchor must be ${HASH_SIZE} bytes`);
55
+ }
56
+ anchors.add(toHex(anchor));
57
+ }
58
+ if (anchors.size === 0) {
59
+ throw new Error("at least one Root Trust Anchor is required (§4.1)");
60
+ }
61
+ /** @type {Set<string>} hex of each Root Trust Anchor. */
62
+ this.rootTrustAnchors = anchors;
63
+
64
+ if (!(primarySalt instanceof Uint8Array) || primarySalt.length !== SALT_SIZE) {
65
+ throw new TypeError(`primarySalt must be ${SALT_SIZE} bytes`);
66
+ }
67
+ /** @type {Uint8Array} */
68
+ this.primarySalt = primarySalt;
69
+
70
+ if (legacySalts.length > MAX_LEGACY_SALTS) {
71
+ throw new Error(
72
+ `at most ${MAX_LEGACY_SALTS} Legacy Salts are allowed (§10.2), got ${legacySalts.length}`,
73
+ );
74
+ }
75
+ /** @type {Uint8Array[]} */
76
+ this.legacySalts = legacySalts.map((s) => {
77
+ if (!(s instanceof Uint8Array) || s.length !== SALT_SIZE) {
78
+ throw new TypeError(`each legacy salt must be ${SALT_SIZE} bytes`);
79
+ }
80
+ return s;
81
+ });
82
+
83
+ /** @type {import("./threshold.js").ThresholdGroup[]} */
84
+ this.thresholdGroups = [...thresholdGroups];
85
+
86
+ if (authoritativeIdentity !== undefined) {
87
+ if (!(authoritativeIdentity instanceof Uint8Array) || authoritativeIdentity.length !== HASH_SIZE) {
88
+ throw new TypeError(`authoritativeIdentity must be ${HASH_SIZE} bytes`);
89
+ }
90
+ this.authoritativeIdentity = authoritativeIdentity;
91
+ } else {
92
+ this.authoritativeIdentity = undefined;
93
+ }
94
+
95
+ if (!(Number.isInteger(deletionHorizonDays) && deletionHorizonDays >= 1)) {
96
+ throw new Error("deletionHorizonDays must be >= 1");
97
+ }
98
+ /** @type {number} */
99
+ this.deletionHorizonDays = deletionHorizonDays;
100
+
101
+ // §3.3 fail-open guard: only warn once the Config is fully valid, so a
102
+ // misconfiguration that already throws is not also noisily warned about.
103
+ if (_isDefaultSalt(primarySalt) && !__nullSaltWarned) {
104
+ __nullSaltWarned = true;
105
+ console.warn(
106
+ "Config started with the default null Privacy Salt: label hashes are " +
107
+ "fail-open (trivially dictionary-attackable, §3.3). Set a strong " +
108
+ "random primarySalt for any real deployment.",
109
+ );
110
+ }
111
+ }
112
+
113
+ /** Primary hasher, then Legacy hashers in order (§10.2). @returns {NamespaceHasher[]} */
114
+ get hashers() {
115
+ return [new NamespaceHasher(this.primarySalt), ...this.legacySalts.map((s) => new NamespaceHasher(s))];
116
+ }
117
+
118
+ /** @returns {NamespaceHasher} */
119
+ get primaryHasher() {
120
+ return new NamespaceHasher(this.primarySalt);
121
+ }
122
+
123
+ /** @param {Uint8Array} identityHash @returns {boolean} */
124
+ isRootAnchor(identityHash) {
125
+ return this.rootTrustAnchors.has(toHex(identityHash));
126
+ }
127
+
128
+ /**
129
+ * The Threshold Group with the given Group ID, or undefined (§4.1). Async
130
+ * because Group IDs are SHA-256 hashes (Web Crypto).
131
+ * @param {Uint8Array} groupIdBytes
132
+ * @returns {Promise<import("./threshold.js").ThresholdGroup | undefined>}
133
+ */
134
+ async groupFor(groupIdBytes) {
135
+ const target = toHex(groupIdBytes);
136
+ for (const group of this.thresholdGroups) {
137
+ if (toHex(await group.groupId()) === target) return group;
138
+ }
139
+ return undefined;
140
+ }
141
+
142
+ /** Deletion horizon in milliseconds. @returns {number} */
143
+ get deletionHorizonMs() {
144
+ return this.deletionHorizonDays * MS_PER_DAY;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * @param {Uint8Array} salt
150
+ * @returns {boolean} `true` iff `salt` is the all-zero fail-open default.
151
+ */
152
+ function _isDefaultSalt(salt) {
153
+ return bytesEqual(salt, DEFAULT_SALT);
154
+ }
package/src/crdt.js ADDED
@@ -0,0 +1,318 @@
1
+ /**
2
+ * The authorization state: an LWW-Element-Set CRDT (§6).
3
+ *
4
+ * The global state maps a Tuple identity to an HLC timestamp, split into an Add
5
+ * set and a Remove set. A Tuple is active iff its Add timestamp is strictly
6
+ * greater than its Remove timestamp; ties resolve to removed (Remove wins).
7
+ *
8
+ * Storage is bounded by **Time-Horizon Tombstone Pruning** (§9): once a tuple
9
+ * resolves inactive *and* both its Add and Remove timestamps are older than the
10
+ * deletion horizon, both entries are silently deleted. Incoming Operations
11
+ * older than the horizon are rejected outright (intake rejection, §9).
12
+ */
13
+
14
+ import { MsgPack } from "@reticulum/core";
15
+ import { Action } from "./operation.js";
16
+ import { MAX_HLC, physicalNowMs, unpackHlc } from "./hlc.js";
17
+ import { Tuple } from "./tuple.js";
18
+ import { verifyOperation } from "./verifier.js";
19
+
20
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
21
+ /** Operations more than this far in the future are rejected (§12). */
22
+ const DEFAULT_MAX_FUTURE_MS = MS_PER_DAY;
23
+ /** Default deletion horizon H for Time-Horizon Tombstone Pruning (§9). */
24
+ export const DEFAULT_DELETION_HORIZON_DAYS = 180;
25
+
26
+ /**
27
+ * @typedef {Object} Entry
28
+ * @property {Tuple} tuple
29
+ * @property {bigint | null} addTs
30
+ * @property {bigint | null} removeTs
31
+ */
32
+
33
+ /** @param {bigint | null} existing @param {bigint} incoming @returns {bigint} */
34
+ function maxTs(existing, incoming) {
35
+ return existing === null ? incoming : existing > incoming ? existing : incoming;
36
+ }
37
+
38
+ /** @param {bigint | null} a @param {bigint | null} b @returns {bigint | null} */
39
+ function maxBoth(a, b) {
40
+ if (a === null) return b;
41
+ if (b === null) return a;
42
+ return a > b ? a : b;
43
+ }
44
+
45
+ /**
46
+ * Guard ensuring the trusted-local-only notice for `StateVector.fromPayload()`
47
+ * is logged at most once per process, so legitimate snapshot/restore does not
48
+ * flood logs while still flagging the footgun the first time.
49
+ */
50
+ let __trustedLocalWarned = false;
51
+
52
+ /** @param {number | bigint | null} value @returns {bigint | null} */
53
+ function normalizeTs(value) {
54
+ return value === null ? null : BigInt(value);
55
+ }
56
+
57
+ export class StateVector {
58
+ /**
59
+ * @param {Object} [opts]
60
+ * @param {number} [opts.deletionHorizonDays] Deletion horizon H (§9).
61
+ */
62
+ constructor({ deletionHorizonDays = DEFAULT_DELETION_HORIZON_DAYS } = {}) {
63
+ if (!(Number.isInteger(deletionHorizonDays) && deletionHorizonDays >= 1)) {
64
+ throw new Error("deletionHorizonDays must be >= 1");
65
+ }
66
+ /** @type {Map<string, Entry>} */
67
+ this._entries = new Map();
68
+ this.deletionHorizonDays = deletionHorizonDays;
69
+ }
70
+
71
+ /** Deletion horizon in milliseconds. @returns {number} */
72
+ get deletionHorizonMs() {
73
+ return this.deletionHorizonDays * MS_PER_DAY;
74
+ }
75
+
76
+ /** Number of distinct tuples known (active or revoked). @returns {number} */
77
+ get size() {
78
+ return this._entries.size;
79
+ }
80
+
81
+ /** @param {string} key @returns {boolean} */
82
+ has(key) {
83
+ return this._entries.has(key);
84
+ }
85
+
86
+ /** @param {string} key @returns {Entry | undefined} */
87
+ get(key) {
88
+ return this._entries.get(key);
89
+ }
90
+
91
+ /**
92
+ * Authenticate then apply a network-received Delta (§11.2.4, §5.2).
93
+ *
94
+ * This is the secure entry point for Operations received over any transport
95
+ * (RFed, LXMF, optical sneakernet). The Operation's Ed25519 signature(s)
96
+ * MUST verify against the public key(s) resolved for its claimed Issuer
97
+ * before the pure CRDT update (`apply()`) is allowed to mutate state. Any
98
+ * authentication failure — unknown Issuer, bad signature, wrong threshold —
99
+ * drops the Operation (returns `false`).
100
+ *
101
+ * Returns `true` iff authenticated *and* applied. Distinct from `apply()`,
102
+ * which trusts its caller and performs no cryptography.
103
+ * @param {import("./operation.js").Operation} operation
104
+ * @param {import("./verifier.js").KeyResolver | import("./verifier.js").Keyring} keyResolver
105
+ * @param {Object} [options]
106
+ * @param {number} [options.nowMs]
107
+ * @param {number | null} [options.maxFutureMs]
108
+ * @returns {Promise<boolean>}
109
+ */
110
+ async ingest(operation, keyResolver, options = {}) {
111
+ if (!(await verifyOperation(operation, keyResolver))) return false;
112
+ return this.apply(operation, options);
113
+ }
114
+
115
+ /**
116
+ * Apply one Operation (Delta) to the appropriate set (§6.1, §9, §12).
117
+ * @param {import("./operation.js").Operation} operation
118
+ * @param {Object} [options]
119
+ * @param {number} [options.nowMs] Override the wall clock for testing.
120
+ * @param {number | null} [options.maxFutureMs] Max clock skew; null disables.
121
+ * @returns {boolean} true if applied, false if rejected.
122
+ */
123
+ apply(operation, { nowMs, maxFutureMs = DEFAULT_MAX_FUTURE_MS } = {}) {
124
+ const { physicalMs } = unpackHlc(operation.hlc);
125
+ const now = nowMs ?? physicalNowMs();
126
+ if (maxFutureMs !== null && physicalMs > now + maxFutureMs) return false; // §12
127
+ if (physicalMs < now - this.deletionHorizonMs) return false; // §9 intake rejection
128
+ const key = operation.tuple.key;
129
+ let entry = this._entries.get(key);
130
+ if (!entry) {
131
+ entry = { tuple: operation.tuple, addTs: null, removeTs: null };
132
+ this._entries.set(key, entry);
133
+ }
134
+ if (operation.action === Action.GRANT) {
135
+ entry.addTs = maxTs(entry.addTs, operation.hlc);
136
+ } else {
137
+ entry.removeTs = maxTs(entry.removeTs, operation.hlc);
138
+ }
139
+ return true;
140
+ }
141
+
142
+ /**
143
+ * Merge another StateVector by taking the max HLC per set per tuple (§6.1).
144
+ *
145
+ * > **Warning: Trusted-local-only — never feed network bytes.**
146
+ * > `merge()` trusts its argument completely and performs **no** signature
147
+ * > verification, so it can inject or alter authorization state (including
148
+ * > Root Trust Anchor grants) for any tuple. It also skips the §9
149
+ * > stale-horizon and §12 future-skew intake checks that `apply()`/`ingest()`
150
+ * > enforce per-delta, so even a trusted source can silently reintroduce
151
+ * > operations per-delta ingestion would have rejected.
152
+ * >
153
+ * > Legitimate uses are confined to a node's own trusted state: CRDT unit
154
+ * > testing and restoring a snapshot previously produced by `toPayload()` on
155
+ * > the *same* node. For network convergence use `DeltaReceiver.applyPayloads()`
156
+ * > (a batch of signed Deltas) instead.
157
+ * @param {StateVector} other
158
+ */
159
+ merge(other) {
160
+ for (const [key, otherEntry] of other._entries) {
161
+ let entry = this._entries.get(key);
162
+ if (!entry) {
163
+ entry = { tuple: otherEntry.tuple, addTs: null, removeTs: null };
164
+ this._entries.set(key, entry);
165
+ }
166
+ entry.addTs = maxBoth(entry.addTs, otherEntry.addTs);
167
+ entry.removeTs = maxBoth(entry.removeTs, otherEntry.removeTs);
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Run Time-Horizon Tombstone Pruning (§9). Deletes both the Add and Remove
173
+ * entries for any tuple that resolves inactive *and* whose Add and Remove
174
+ * timestamps are both older than the horizon. Returns the count pruned.
175
+ * @param {Object} [opts]
176
+ * @param {number} [opts.nowMs]
177
+ * @returns {number}
178
+ */
179
+ prune({ nowMs } = {}) {
180
+ const now = nowMs ?? physicalNowMs();
181
+ const cutoff = now - this.deletionHorizonMs;
182
+ let pruned = 0;
183
+ for (const [key, entry] of this._entries) {
184
+ if (this._isActiveEntry(entry)) continue;
185
+ const { addTs, removeTs } = entry;
186
+ if (addTs === null || removeTs === null) continue;
187
+ if (unpackHlc(addTs).physicalMs < cutoff && unpackHlc(removeTs).physicalMs < cutoff) {
188
+ this._entries.delete(key);
189
+ pruned += 1;
190
+ }
191
+ }
192
+ return pruned;
193
+ }
194
+
195
+ /** @param {string} key @returns {boolean} */
196
+ isActive(key) {
197
+ const entry = this._entries.get(key);
198
+ return entry !== undefined && this._isActiveEntry(entry);
199
+ }
200
+
201
+ /** @param {Entry} entry @returns {boolean} */
202
+ _isActiveEntry(entry) {
203
+ return (
204
+ entry.addTs !== null &&
205
+ (entry.removeTs === null || entry.addTs > entry.removeTs)
206
+ );
207
+ }
208
+
209
+ /** Generator yielding every currently active Tuple. @returns {Generator<Tuple>} */
210
+ *activeTuples() {
211
+ for (const entry of this._entries.values()) {
212
+ if (this._isActiveEntry(entry)) yield entry.tuple;
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Serialize the full state vector as a MessagePack array of entries. Each
218
+ * entry is `[relationHash(16), [objectHashes], wildcard_bool, grantee(16),
219
+ * issuer(16), addTs | null, removeTs | null]`.
220
+ *
221
+ * > **Warning: Trusted-local-only.** The payload is an unauthenticated dump
222
+ * > of this node's CRDT and carries **no** Ed25519 signature material; it
223
+ * > exists for a node to snapshot *its own* state (e.g. a local backup or
224
+ * > CRDT unit test). It MUST NOT be accepted from the network — deserialize
225
+ * > such bytes only via your own trusted store, and if it ever crosses a
226
+ * > trust boundary use `DeltaReceiver.applyPayloads()` (a batch of signed
227
+ * > §5.3 Operations) instead.
228
+ * @returns {Uint8Array}
229
+ */
230
+ toPayload() {
231
+ const rows = [];
232
+ for (const entry of this._entries.values()) {
233
+ rows.push([
234
+ entry.tuple.relationHash,
235
+ [...entry.tuple.objectHashes],
236
+ entry.tuple.wildcard,
237
+ entry.tuple.grantee,
238
+ entry.tuple.issuer,
239
+ entry.addTs,
240
+ entry.removeTs,
241
+ ]);
242
+ }
243
+ return MsgPack.encode(rows);
244
+ }
245
+
246
+ /**
247
+ * Deserialize a state vector produced by `toPayload()`.
248
+ *
249
+ * > **Warning: Trusted-local-only — never feed network bytes.** The payload
250
+ * > carries **no** signature material, so deserializing attacker bytes and
251
+ * > then `merge()`-ing it lets a peer forge arbitrary authorization state
252
+ * > (including Root Trust Anchor grants) and silently bypass the §9
253
+ * > stale-horizon and §12 future-skew intake checks. Only deserialize bytes
254
+ * > from your own trusted store (a snapshot you previously produced with
255
+ * > `toPayload()` on this node). For network convergence use
256
+ * > `DeltaReceiver.applyPayloads()` (a batch of signed §5.3 Operations)
257
+ * > instead.
258
+ * >
259
+ * > A one-time `console.warn` is emitted to make this contract audible.
260
+ * @param {Uint8Array} data
261
+ * @param {Object} [opts]
262
+ * @param {number} [opts.deletionHorizonDays]
263
+ * @returns {StateVector}
264
+ */
265
+ static fromPayload(data, { deletionHorizonDays = DEFAULT_DELETION_HORIZON_DAYS } = {}) {
266
+ if (!__trustedLocalWarned) {
267
+ __trustedLocalWarned = true;
268
+ console.warn(
269
+ "StateVector.fromPayload() is trusted-local-only: it performs no " +
270
+ "signature verification and must not be fed network bytes. For " +
271
+ "network convergence use DeltaReceiver.applyPayloads() instead.",
272
+ );
273
+ }
274
+ const rows = MsgPack.decode(data);
275
+ if (!Array.isArray(rows)) {
276
+ throw new Error("state vector payload must be a MessagePack array");
277
+ }
278
+ const state = new StateVector({ deletionHorizonDays });
279
+ for (const row of rows) {
280
+ if (!Array.isArray(row) || row.length !== 7) {
281
+ throw new Error("each state entry must be a 7-element array");
282
+ }
283
+ const [relationHash, objectHashes, wildcard, grantee, issuer, addTs, removeTs] = row;
284
+ const tuple = new Tuple({
285
+ relationHash: expectBytes(relationHash, 16, "relation_hash"),
286
+ objectHashes: objectHashes.map((h) => expectBytes(h, 16, "object_hash")),
287
+ wildcard: expectBool(wildcard, "wildcard"),
288
+ grantee: expectBytes(grantee, 16, "grantee"),
289
+ issuer: expectBytes(issuer, 16, "issuer"),
290
+ });
291
+ state._entries.set(tuple.key, {
292
+ tuple,
293
+ addTs: normalizeTs(addTs),
294
+ removeTs: normalizeTs(removeTs),
295
+ });
296
+ }
297
+ return state;
298
+ }
299
+ }
300
+
301
+ /**
302
+ * @param {unknown} value
303
+ * @param {number} len
304
+ * @param {string} name
305
+ * @returns {Uint8Array}
306
+ */
307
+ function expectBytes(value, len, name) {
308
+ if (!(value instanceof Uint8Array) || value.length !== len) {
309
+ throw new Error(`${name} must be a ${len}-byte Uint8Array`);
310
+ }
311
+ return value;
312
+ }
313
+
314
+ /** @param {unknown} value @param {string} name @returns {boolean} */
315
+ function expectBool(value, name) {
316
+ if (typeof value !== "boolean") throw new Error(`${name} must be a boolean`);
317
+ return value;
318
+ }
package/src/delta.js ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Transport-agnostic Delta receive boundary (spec §11.2.4).
3
+ *
4
+ * Every transport — RFed (§11.1), LXMF store-and-forward (§11.2), and optical
5
+ * Paper Messages (§11.3) — funnels incoming bytes through one identical path:
6
+ * decode the §5.3 Operation payload, authenticate it via verify-on-ingest
7
+ * (§5.2 / §11.2.4), and merge it into the CRDT. `DeltaReceiver` is that shared
8
+ * boundary. Malformed or unauthenticated Deltas are dropped silently rather
9
+ * than propagated into state or crashing a transport callback.
10
+ *
11
+ * This keeps the (optional) transport adapters thin: an adapter only has to
12
+ * hand received bytes to `DeltaReceiver.applyPayload()`, regardless of whether
13
+ * they arrived over RFed, LXMF, or a scanned QR code.
14
+ */
15
+
16
+ import { MsgPack } from "@reticulum/core";
17
+ import { Operation } from "./operation.js";
18
+
19
+ export class DeltaReceiver {
20
+ /**
21
+ * Decode -> verify -> apply incoming Delta payloads (§11.2.4).
22
+ * @param {import("./crdt.js").StateVector} state
23
+ * @param {import("./verifier.js").KeyResolver | import("./verifier.js").Keyring} keyResolver
24
+ */
25
+ constructor(state, keyResolver) {
26
+ this._state = state;
27
+ this._resolver = keyResolver;
28
+ }
29
+
30
+ /**
31
+ * Apply one wire-format Delta.
32
+ *
33
+ * Returns `true` iff the payload decoded, authenticated, and was applied to
34
+ * the CRDT. Malformed payloads are swallowed (return `false`) — a transport
35
+ * callback must never crash on arbitrary bytes. Signature and CRDT-level
36
+ * rejection (unknown Issuer, bad sig, stale/future) is delegated to
37
+ * `StateVector.ingest()`.
38
+ * @param {Uint8Array} payload
39
+ * @param {Object} [options]
40
+ * @param {number} [options.nowMs]
41
+ * @param {number | null} [options.maxFutureMs]
42
+ * @returns {Promise<boolean>}
43
+ */
44
+ async applyPayload(payload, options = {}) {
45
+ let operation;
46
+ try {
47
+ operation = Operation.fromPayload(payload);
48
+ } catch {
49
+ return false; // malformed -> drop silently
50
+ }
51
+ return this._state.ingest(operation, this._resolver, options);
52
+ }
53
+
54
+ /**
55
+ * Authenticate and apply a *batch* of Deltas (§11.1, §11.2.4).
56
+ *
57
+ * The secure alternative to `StateVector.merge()` for network sync.
58
+ * `payload` is a MessagePack array of §5.3 Operation payloads
59
+ * (`MsgPack.encode([opA.toPayload(), opB.toPayload(), ...])`); each element
60
+ * is decoded and run through `applyPayload()`, i.e. it is independently
61
+ * Ed25519/threshold-authenticated before it may touch state. A single
62
+ * forged, stale (§9), or future-skewed (§12) element is dropped without
63
+ * affecting the rest of the batch.
64
+ *
65
+ * Returns the number of Deltas authenticated *and* applied. A malformed
66
+ * outer payload (not a MessagePack array, undecodable) yields `0` and is
67
+ * swallowed, so a transport callback can never crash on arbitrary bytes —
68
+ * exactly like `applyPayload()`.
69
+ *
70
+ * > **Warning:** This is the *only* safe entry point for full-state / bulk
71
+ * > convergence received over the network. `StateVector.merge()` /
72
+ * > `StateVector.fromPayload()` are trusted-local snapshot primitives that
73
+ * > perform **no** signature verification and **must not** be fed network
74
+ * > bytes.
75
+ *
76
+ * @param {Uint8Array} payload A MessagePack array of Operation payloads.
77
+ * @param {Object} [options]
78
+ * @param {number} [options.nowMs]
79
+ * @param {number | null} [options.maxFutureMs]
80
+ * @returns {Promise<number>}
81
+ */
82
+ async applyPayloads(payload, options = {}) {
83
+ let items;
84
+ try {
85
+ items = MsgPack.decode(payload);
86
+ } catch {
87
+ return 0; // malformed outer payload -> drop silently
88
+ }
89
+ if (!Array.isArray(items)) return 0;
90
+ let applied = 0;
91
+ for (const item of items) {
92
+ if (!(item instanceof Uint8Array)) continue; // skip non-bin elements
93
+ if (await this.applyPayload(item, options)) applied += 1;
94
+ }
95
+ return applied;
96
+ }
97
+
98
+ /**
99
+ * Encode a list of §5.3 Operation payloads as a batch (§11.1). Inverse of
100
+ * `applyPayloads()`: `MsgPack.encode([...])` of already-signed Operation
101
+ * payload byte-strings, suitable for publishing as one bulk sync message.
102
+ * @param {Uint8Array[]} operationPayloads
103
+ * @returns {Uint8Array}
104
+ */
105
+ static packPayloads(operationPayloads) {
106
+ return MsgPack.encode(operationPayloads.map((p) => new Uint8Array(p)));
107
+ }
108
+ }