@openlfcp/wire 0.1.0-rc.1

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 (53) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +27 -0
  3. package/dist/capability.d.ts +143 -0
  4. package/dist/capability.js +409 -0
  5. package/dist/cbor/decode.d.ts +23 -0
  6. package/dist/cbor/decode.js +163 -0
  7. package/dist/cbor/encode.d.ts +10 -0
  8. package/dist/cbor/encode.js +149 -0
  9. package/dist/cbor/index.d.ts +10 -0
  10. package/dist/cbor/index.js +10 -0
  11. package/dist/cbor/text.d.ts +11 -0
  12. package/dist/cbor/text.js +5 -0
  13. package/dist/cbor/value.d.ts +30 -0
  14. package/dist/cbor/value.js +23 -0
  15. package/dist/chain.d.ts +140 -0
  16. package/dist/chain.js +339 -0
  17. package/dist/control-sync.d.ts +69 -0
  18. package/dist/control-sync.js +44 -0
  19. package/dist/control.d.ts +173 -0
  20. package/dist/control.js +369 -0
  21. package/dist/cose.d.ts +81 -0
  22. package/dist/cose.js +133 -0
  23. package/dist/data-unit.d.ts +204 -0
  24. package/dist/data-unit.js +314 -0
  25. package/dist/endpoint.d.ts +51 -0
  26. package/dist/endpoint.js +116 -0
  27. package/dist/epoch.d.ts +109 -0
  28. package/dist/epoch.js +128 -0
  29. package/dist/fields.d.ts +18 -0
  30. package/dist/fields.js +78 -0
  31. package/dist/handshake.d.ts +175 -0
  32. package/dist/handshake.js +297 -0
  33. package/dist/have.d.ts +101 -0
  34. package/dist/have.js +268 -0
  35. package/dist/index.d.ts +21 -0
  36. package/dist/index.js +21 -0
  37. package/dist/invite.d.ts +80 -0
  38. package/dist/invite.js +247 -0
  39. package/dist/key-package.d.ts +92 -0
  40. package/dist/key-package.js +132 -0
  41. package/dist/message.d.ts +367 -0
  42. package/dist/message.js +690 -0
  43. package/dist/objects.d.ts +154 -0
  44. package/dist/objects.js +156 -0
  45. package/dist/principal.d.ts +43 -0
  46. package/dist/principal.js +81 -0
  47. package/dist/session-state.d.ts +47 -0
  48. package/dist/session-state.js +49 -0
  49. package/dist/snapshot.d.ts +86 -0
  50. package/dist/snapshot.js +189 -0
  51. package/dist/transition.d.ts +97 -0
  52. package/dist/transition.js +130 -0
  53. package/package.json +53 -0
@@ -0,0 +1,297 @@
1
+ import { bytesEqual, LfcpError, principalId, secureRandom } from "@openlfcp/core";
2
+ import { decodeDeterministic, encode } from "./cbor/index.js";
3
+ import { parseSignedObject, signObject, verifySignedObject } from "./cose.js";
4
+ import { createMessage, ERROR_CODE, replyTo, } from "./message.js";
5
+ import { principalDescriptorFromCbor, principalDescriptorToCbor, } from "./principal.js";
6
+ /**
7
+ * The LFCP session handshake (LFCP-WIRE-01 §34-§37): HELLO → CHALLENGE →
8
+ * AUTH → READY, as pure functions over decoded messages. No sockets.
9
+ *
10
+ * The handshake authenticates the session Principal: it proves possession
11
+ * of the Ed25519 key of the descriptor sent in HELLO, bound to this
12
+ * session's nonces, session ID and server ID. It grants no Resource
13
+ * authority: AuthenticatedSession has no abilities, and the optional
14
+ * hosting credential is an opaque server-policy value, never a capability
15
+ * (§36). Resource authority comes only from Control Chains (capability.ts).
16
+ */
17
+ /** The wire profile this SDK implements (§34). */
18
+ export const WIRE_PROFILE = "LFCP-WIRE-01";
19
+ const AUTH_LABEL = "LFCP-AUTH-v1";
20
+ const NONCE = 16;
21
+ const SERVER_ID = 32;
22
+ function sized(what, v, length) {
23
+ if (!(v instanceof Uint8Array) || v.length !== length)
24
+ throw new LfcpError("INVALID_STRUCTURE", `${what} must be ${length} bytes`);
25
+ return v;
26
+ }
27
+ /**
28
+ * §36: deterministic CBOR of ["LFCP-AUTH-v1", session_id, client_nonce,
29
+ * server_nonce, server_id, principal_id]; nothing else.
30
+ */
31
+ export function authTranscript(f) {
32
+ return encode([
33
+ AUTH_LABEL,
34
+ sized("the session id", f.sessionId, NONCE),
35
+ sized("the client nonce", f.clientNonce, NONCE),
36
+ sized("the server nonce", f.serverNonce, NONCE),
37
+ sized("the server id", f.serverId, SERVER_ID),
38
+ sized("the Principal ID", f.principalId, 32),
39
+ ]);
40
+ }
41
+ /** Decodes an auth transcript strictly: deterministic CBOR, the label, six elements, exact sizes. */
42
+ export function decodeAuthTranscript(bytes) {
43
+ const v = decodeDeterministic(bytes);
44
+ if (!Array.isArray(v) || v.length !== 6 || v[0] !== AUTH_LABEL)
45
+ throw new LfcpError("INVALID_STRUCTURE", 'an auth transcript is ["LFCP-AUTH-v1", ...5 fields]');
46
+ const [, sessionId, clientNonce, serverNonce, serverId, principal] = v;
47
+ return Object.freeze({
48
+ sessionId: Uint8Array.from(sized("the session id", sessionId, NONCE)),
49
+ clientNonce: Uint8Array.from(sized("the client nonce", clientNonce, NONCE)),
50
+ serverNonce: Uint8Array.from(sized("the server nonce", serverNonce, NONCE)),
51
+ serverId: Uint8Array.from(sized("the server id", serverId, SERVER_ID)),
52
+ principalId: principalId(sized("the Principal ID", principal, 32)),
53
+ });
54
+ }
55
+ /** The AUTH proof: a §10 COSE_Sign1 by the session Principal over the exact transcript. */
56
+ export function signAuthProof(fields, signer) {
57
+ if (!bytesEqual(fields.principalId, signer.descriptor.principalId))
58
+ throw new LfcpError("COSE_SIGNER_MISMATCH", "the transcript names another Principal");
59
+ return signObject(authTranscript(fields), signer).bytes;
60
+ }
61
+ /**
62
+ * §36: the proof must be a §10 signed object whose kid is the HELLO
63
+ * Principal, whose payload is exactly this session's transcript, and whose
64
+ * signature verifies (§10.5.1). Every failure is AUTH_FAILED (G-MSG4).
65
+ */
66
+ export function verifyAuthProof(proof, expected, principal) {
67
+ let signed;
68
+ try {
69
+ signed = parseSignedObject(proof);
70
+ }
71
+ catch {
72
+ return { valid: false, reason: "MALFORMED" };
73
+ }
74
+ if (!bytesEqual(signed.payloadBytes, authTranscript(expected)))
75
+ return { valid: false, reason: "TRANSCRIPT_MISMATCH" };
76
+ const v = verifySignedObject(signed, principal);
77
+ return v.valid ? { valid: true } : { valid: false, reason: v.reason };
78
+ }
79
+ /**
80
+ * §34 profile negotiation: the first profile the client offers that the
81
+ * server supports, or undefined.
82
+ */
83
+ export const selectWireProfile = (offered, supported) => offered.find((p) => supported.includes(p));
84
+ /** Message types a server rejects before READY with AUTHORIZATION_FAILED (§64, G-MSG7). */
85
+ const RESOURCE_FAMILY = new Set([
86
+ "RESOURCE_HOST",
87
+ "RESOURCE_HOSTED",
88
+ "RESOURCE_OPEN",
89
+ "RESOURCE_OPENED",
90
+ "RESOURCE_CLOSE",
91
+ "CONTROL_HAVE",
92
+ "CONTROL_GET",
93
+ "CONTROL_BATCH",
94
+ "CONTROL_PUT",
95
+ "DATA_HAVE",
96
+ "DATA_GET",
97
+ "DATA_BATCH",
98
+ "DATA_PUT",
99
+ "KEY_PACKAGE_GET",
100
+ "KEY_PACKAGE_BATCH",
101
+ "KEY_PACKAGE_PUT",
102
+ "SNAPSHOT_GET",
103
+ "SNAPSHOT",
104
+ "SNAPSHOT_PUT",
105
+ "PRESENCE",
106
+ "PRESENCE_LEAVE",
107
+ ]);
108
+ /** Whether a message type is a Resource, Control, Data, Key, Snapshot or Presence message. */
109
+ export const isResourceMessage = (type) => type !== "EXTENSION" && RESOURCE_FAMILY.has(type);
110
+ const errorTo = (request, code, diagnostic) => replyTo(request, "ERROR", { code: ERROR_CODE[code], diagnostic });
111
+ const nackTo = (request, code, diagnostic) => replyTo(request, "NACK", { code: ERROR_CODE[code], diagnostic });
112
+ /** §64: ACCEPTED moves straight to WAIT_HELLO. */
113
+ export const startServerSession = () => Object.freeze({ phase: "WAIT_HELLO" });
114
+ /**
115
+ * One received message on a server session (§34-§37, §64). Before READY:
116
+ * PING is answered with PONG, PONG and ERROR are accepted (G-SM4); Resource,
117
+ * Control, Data, Key, Snapshot and Presence messages get
118
+ * NACK(AUTHORIZATION_FAILED) (G-MSG7); any other out-of-order message is a
119
+ * protocol violation: ERROR(MALFORMED_MESSAGE) and close. An invalid HELLO
120
+ * descriptor or any AUTH proof failure is ERROR(AUTH_FAILED) and close
121
+ * (P3, G-MSG4); no common profile is ERROR(PROTOCOL_UNSUPPORTED) and close.
122
+ */
123
+ export function serverReceive(session, message, config) {
124
+ const step = (s, send = [], close = false) => Object.freeze({ session: s, send: Object.freeze(send), close });
125
+ const fatal = (code, why) => step(Object.freeze({ phase: "CLOSED", reason: `${code}: ${why}` }), [errorTo(message, code, why)], true);
126
+ if (session.phase === "CLOSED")
127
+ return step(session, [], true);
128
+ if (message.type === "PING")
129
+ return step(session, [replyTo(message, "PONG", message.body)]);
130
+ if (message.type === "PONG" || message.type === "ERROR")
131
+ return session.phase === "READY"
132
+ ? Object.freeze({ ...step(session), deliver: message })
133
+ : step(session);
134
+ if (session.phase !== "READY" && isResourceMessage(message.type))
135
+ return step(session, [
136
+ nackTo(message, "AUTHORIZATION_FAILED", "the session is not READY (§64)"),
137
+ ]);
138
+ switch (session.phase) {
139
+ case "WAIT_HELLO": {
140
+ if (message.type !== "HELLO")
141
+ return fatal("MALFORMED_MESSAGE", `${message.type} before HELLO`);
142
+ const body = message.body;
143
+ let principal;
144
+ try {
145
+ // §7: recompute the ID and validate the Ed25519 key (G-RS2) at receipt.
146
+ principal = principalDescriptorFromCbor(principalDescriptorToCbor(body.principal));
147
+ }
148
+ catch {
149
+ return fatal("AUTH_FAILED", "the HELLO Principal Descriptor is invalid (§7)");
150
+ }
151
+ const wireProfile = selectWireProfile(body.wireProfiles, config.wireProfiles);
152
+ if (wireProfile === undefined)
153
+ return fatal("PROTOCOL_UNSUPPORTED", "no offered wire profile is supported (§34)");
154
+ const random = config.random ?? secureRandom;
155
+ const serverNonce = sized("the server nonce", random(NONCE), NONCE);
156
+ const sessionId = sized("the session id", random(NONCE), NONCE);
157
+ const serverId = sized("the server id", config.serverId, SERVER_ID);
158
+ return step(Object.freeze({
159
+ phase: "WAIT_AUTH",
160
+ principal,
161
+ clientNonce: body.clientNonce,
162
+ ...(body.dataProfiles !== undefined ? { dataProfiles: body.dataProfiles } : {}),
163
+ wireProfile,
164
+ serverNonce,
165
+ sessionId,
166
+ }), [replyTo(message, "CHALLENGE", { wireProfile, serverNonce, sessionId, serverId })]);
167
+ }
168
+ case "WAIT_AUTH": {
169
+ if (message.type !== "AUTH")
170
+ return fatal("MALFORMED_MESSAGE", `${message.type} before AUTH`);
171
+ const proof = verifyAuthProof(message.body.proof, {
172
+ sessionId: session.sessionId,
173
+ clientNonce: session.clientNonce,
174
+ serverNonce: session.serverNonce,
175
+ serverId: config.serverId,
176
+ principalId: session.principal.principalId,
177
+ }, session.principal);
178
+ if (!proof.valid)
179
+ return fatal("AUTH_FAILED", `the AUTH proof fails (${proof.reason}, §36)`);
180
+ const authenticated = Object.freeze({
181
+ principal: session.principal,
182
+ wireProfile: session.wireProfile,
183
+ sessionId: session.sessionId,
184
+ serverId: Uint8Array.from(config.serverId),
185
+ ...(session.dataProfiles !== undefined ? { dataProfiles: session.dataProfiles } : {}),
186
+ ...(message.body.credential !== undefined ? { credential: message.body.credential } : {}),
187
+ });
188
+ return step(Object.freeze({ phase: "READY", session: authenticated }), [
189
+ replyTo(message, "READY", {
190
+ wireProfile: session.wireProfile,
191
+ serverId: Uint8Array.from(config.serverId),
192
+ maxMessageBytes: config.maxMessageBytes,
193
+ durability: config.durability,
194
+ heartbeatMs: config.heartbeatMs,
195
+ ...(config.extensions !== undefined ? { extensions: config.extensions } : {}),
196
+ }),
197
+ ]);
198
+ }
199
+ case "READY":
200
+ if (message.type === "HELLO" ||
201
+ message.type === "AUTH" ||
202
+ message.type === "CHALLENGE" ||
203
+ message.type === "READY")
204
+ return fatal("MALFORMED_MESSAGE", `${message.type} on an authenticated session`);
205
+ return Object.freeze({ ...step(session), deliver: message });
206
+ }
207
+ }
208
+ /** Starts the handshake once the WebSocket with lfcp-1 is open: the HELLO to send (§34). */
209
+ export function startClientHandshake(config) {
210
+ const random = config.random ?? secureRandom;
211
+ const hello = createMessage("HELLO", {
212
+ wireProfiles: config.wireProfiles ?? [WIRE_PROFILE],
213
+ principal: config.signer.descriptor,
214
+ clientNonce: sized("the client nonce", random(NONCE), NONCE),
215
+ ...(config.dataProfiles !== undefined ? { dataProfiles: config.dataProfiles } : {}),
216
+ });
217
+ return Object.freeze({
218
+ session: Object.freeze({ phase: "NEGOTIATING", hello }),
219
+ send: Object.freeze([hello]),
220
+ close: false,
221
+ });
222
+ }
223
+ /**
224
+ * One received message on a client session. CHALLENGE must select a
225
+ * profile the client offered (else PROTOCOL_UNSUPPORTED) and, when the
226
+ * client expects one, name its server ID; the client then signs the
227
+ * transcript. READY must repeat the selected profile and the CHALLENGE's
228
+ * server ID (else MALFORMED_MESSAGE). An ERROR before READY ends the
229
+ * handshake. Failures close the connection.
230
+ */
231
+ export function clientReceive(session, message, config) {
232
+ const step = (s, send = [], close = false) => Object.freeze({ session: s, send: Object.freeze(send), close });
233
+ const fail = (code, why, notify = true) => step(Object.freeze({ phase: "DISCONNECTED", reason: `${code}: ${why}` }), notify ? [errorTo(message, code, why)] : [], true);
234
+ if (session.phase === "DISCONNECTED")
235
+ return step(session, [], true);
236
+ if (message.type === "PING")
237
+ return step(session, [replyTo(message, "PONG", message.body)]);
238
+ if (message.type === "PONG")
239
+ return step(session);
240
+ if (message.type === "ERROR" && session.phase !== "READY")
241
+ return fail("MALFORMED_MESSAGE", `the server sent ERROR ${message.body.code}`, false);
242
+ switch (session.phase) {
243
+ case "NEGOTIATING": {
244
+ if (message.type !== "CHALLENGE")
245
+ return fail("MALFORMED_MESSAGE", `${message.type} before CHALLENGE`);
246
+ const c = message.body;
247
+ if (!session.hello.body.wireProfiles.includes(c.wireProfile))
248
+ return fail("PROTOCOL_UNSUPPORTED", `the server selected ${c.wireProfile}, which was not offered`);
249
+ if (config.expectedServerId !== undefined && !bytesEqual(c.serverId, config.expectedServerId))
250
+ return fail("AUTH_FAILED", "the CHALLENGE names another server");
251
+ const proof = signAuthProof({
252
+ sessionId: c.sessionId,
253
+ clientNonce: session.hello.body.clientNonce,
254
+ serverNonce: c.serverNonce,
255
+ serverId: c.serverId,
256
+ principalId: config.signer.descriptor.principalId,
257
+ }, config.signer);
258
+ const auth = replyTo(message, "AUTH", {
259
+ proof,
260
+ ...(config.credential !== undefined ? { credential: config.credential } : {}),
261
+ });
262
+ return step(Object.freeze({
263
+ phase: "AUTHENTICATING",
264
+ hello: session.hello,
265
+ wireProfile: c.wireProfile,
266
+ serverId: c.serverId,
267
+ sessionId: c.sessionId,
268
+ }), [auth]);
269
+ }
270
+ case "AUTHENTICATING": {
271
+ if (message.type !== "READY")
272
+ return fail("MALFORMED_MESSAGE", `${message.type} before READY`);
273
+ const r = message.body;
274
+ if (r.wireProfile !== session.wireProfile)
275
+ return fail("MALFORMED_MESSAGE", "READY names another wire profile than CHALLENGE");
276
+ if (!bytesEqual(r.serverId, session.serverId))
277
+ return fail("MALFORMED_MESSAGE", "READY names another server than CHALLENGE");
278
+ return step(Object.freeze({
279
+ phase: "READY",
280
+ ready: Object.freeze({
281
+ wireProfile: r.wireProfile,
282
+ serverId: r.serverId,
283
+ sessionId: session.sessionId,
284
+ maxMessageBytes: r.maxMessageBytes,
285
+ durability: r.durability,
286
+ heartbeatMs: r.heartbeatMs,
287
+ extensions: r.extensions ?? [],
288
+ }),
289
+ }));
290
+ }
291
+ case "READY":
292
+ if (message.type === "CHALLENGE" || message.type === "READY")
293
+ return fail("MALFORMED_MESSAGE", `${message.type} on an authenticated session`);
294
+ return Object.freeze({ ...step(session), deliver: message });
295
+ }
296
+ }
297
+ //# sourceMappingURL=handshake.js.map
package/dist/have.d.ts ADDED
@@ -0,0 +1,101 @@
1
+ import { type PrincipalId } from "@openlfcp/core";
2
+ import { type CborValue } from "./cbor/index.js";
3
+ /** An inclusive range of actor sequence numbers (§28 `sequence-range`). */
4
+ export type SequenceRange = readonly [start: bigint, end: bigint];
5
+ /**
6
+ * An Actor Have entry (LFCP-WIRE-01 §28): the actor's highest contiguous
7
+ * sequence and the extra ranges received above it.
8
+ */
9
+ export interface ActorHave {
10
+ readonly principalId: PrincipalId;
11
+ readonly contiguous: bigint;
12
+ /** Empty when the CBOR omits key 2. */
13
+ readonly extras: readonly SequenceRange[];
14
+ }
15
+ /**
16
+ * Decodes a canonical `actor-have` (§28.1 rules 1–8), the form required
17
+ * inside persistent objects and cryptographic inputs:
18
+ *
19
+ * - keys 0 and 1 present, key 2 only when there is at least one range;
20
+ * - every range is a two-element array with start <= end;
21
+ * - the first range starts strictly above `contiguous`;
22
+ * - each later range starts more than one past the previous end, so the
23
+ * ranges are sorted, non-overlapping and non-adjacent.
24
+ *
25
+ * A violation is INVALID_STRUCTURE (MALFORMED_MESSAGE on the wire, §28.1, N6).
26
+ * Live Haves in messages are decoded as received by message.ts and
27
+ * normalized by LFCP-028; they are not covered here.
28
+ */
29
+ export declare function actorHaveFromCbor(value: CborValue): ActorHave;
30
+ /**
31
+ * Decodes a canonical frontier (§28.2): an array of canonical actor-have
32
+ * entries in strictly ascending raw Principal ID order, which also rules
33
+ * out two entries for one Principal (§28.1 rule 9). A violation is
34
+ * INVALID_STRUCTURE (MALFORMED_MESSAGE on the wire, §28.2, N6).
35
+ */
36
+ export declare function canonicalFrontierFromCbor(value: CborValue): readonly ActorHave[];
37
+ /** The canonical CBOR of an actor-have (§28.1): key 2 only when there are ranges. Checked like a received one. */
38
+ export declare function actorHaveToCbor(have: ActorHave): CborValue;
39
+ /** A canonical frontier (§28.2): entries sorted by raw Principal ID; duplicates are refused. */
40
+ export declare function canonicalFrontierToCbor(frontier: readonly ActorHave[]): CborValue;
41
+ /** A Have Vector: normalized ActorHave entries, one per actor, by ascending raw Principal ID. */
42
+ export type HaveVector = readonly ActorHave[];
43
+ /** A live actor-have as received in a message (§48): any range order, possibly repeated actors. */
44
+ export interface LiveHaveEntry {
45
+ readonly principalId: PrincipalId;
46
+ readonly contiguous: bigint;
47
+ readonly ranges?: readonly SequenceRange[];
48
+ }
49
+ /** An inclusive range of one actor's sequences, as in a §49 `data-range`. */
50
+ export interface ActorRange {
51
+ readonly actor: PrincipalId;
52
+ readonly start: bigint;
53
+ readonly end: bigint;
54
+ }
55
+ /** §49: "A request SHOULD contain no more than 256 ranges." */
56
+ export declare const MAX_DATA_GET_RANGES = 256;
57
+ /**
58
+ * §48 (G-HV1): a live range with start > end, or one that includes
59
+ * sequence 0, is MALFORMED_MESSAGE (INVALID_STRUCTURE here); sequences
60
+ * must fit in uint64. Use as DecodeOptions.liveHave; decodeMessage applies
61
+ * it by default.
62
+ */
63
+ export declare function checkLiveHave(entry: LiveHaveEntry): void;
64
+ /**
65
+ * Normalizes live actor-haves losslessly (§48, G-MSG6): unsorted,
66
+ * overlapping or adjacent ranges, ranges touching `contiguous`, and several
67
+ * entries for one actor are merged into one normalized entry per actor,
68
+ * sorted by raw Principal ID. Reversed ranges and sequence 0 are refused
69
+ * (checkLiveHave). An actor entry holding nothing stays as contiguous 0
70
+ * with no ranges.
71
+ */
72
+ export declare function normalizeLiveHaves(entries: readonly LiveHaveEntry[]): HaveVector;
73
+ /** A normalized vector as live entries for DATA_HAVE and friends: our encoder always sends normalized form. */
74
+ export declare const liveHavesOf: (vector: HaveVector) => LiveHaveEntry[];
75
+ /** Whether the vector holds `seq` of `actor`. */
76
+ export declare function hasSequence(vector: HaveVector, actor: PrincipalId, seq: bigint): boolean;
77
+ /**
78
+ * Adds `start..end` of `actor` (sequences of accepted units only; see the
79
+ * module note on held units). Extends the contiguous prefix, fills holes
80
+ * and merges extras; adding what is already held changes nothing.
81
+ */
82
+ export declare function addRange(vector: HaveVector, actor: PrincipalId, start: bigint, end: bigint): HaveVector;
83
+ /** Adds one sequence of `actor` (see addRange). */
84
+ export declare const addSequence: (vector: HaveVector, actor: PrincipalId, seq: bigint) => HaveVector;
85
+ /** The union of two vectors (normalized). */
86
+ export declare function unionHaves(a: HaveVector, b: HaveVector): HaveVector;
87
+ /**
88
+ * The units `remote` holds that `local` does not (§68), as the fewest
89
+ * possible ranges: per actor, maximal intervals, by ascending raw Principal
90
+ * ID and then sequence. Covers actors absent locally, a missing contiguous
91
+ * tail, holes, and remote extras; never requests what local already holds.
92
+ */
93
+ export declare function missingFrom(local: HaveVector, remote: HaveVector): ActorRange[];
94
+ /**
95
+ * Catch-up after loading a Snapshot (§29, §66 step 4): what `theirs` holds
96
+ * that is neither in `ours` nor covered by the Snapshot's frontier.
97
+ */
98
+ export declare const missingAfter: (snapshotFrontier: HaveVector, ours: HaveVector, theirs: HaveVector) => ActorRange[];
99
+ /** Splits ranges into DATA_GET-sized batches (§49: at most 256 ranges per request). */
100
+ export declare function batchDataRanges(ranges: readonly ActorRange[], max?: number): ActorRange[][];
101
+ //# sourceMappingURL=have.d.ts.map
package/dist/have.js ADDED
@@ -0,0 +1,268 @@
1
+ import { compareCanonicalFrontierOrder, principalId, toHex, } from "@openlfcp/core";
2
+ import { cborMap } from "./cbor/index.js";
3
+ import { Fields, invalid } from "./fields.js";
4
+ /**
5
+ * Decodes a canonical `actor-have` (§28.1 rules 1–8), the form required
6
+ * inside persistent objects and cryptographic inputs:
7
+ *
8
+ * - keys 0 and 1 present, key 2 only when there is at least one range;
9
+ * - every range is a two-element array with start <= end;
10
+ * - the first range starts strictly above `contiguous`;
11
+ * - each later range starts more than one past the previous end, so the
12
+ * ranges are sorted, non-overlapping and non-adjacent.
13
+ *
14
+ * A violation is INVALID_STRUCTURE (MALFORMED_MESSAGE on the wire, §28.1, N6).
15
+ * Live Haves in messages are decoded as received by message.ts and
16
+ * normalized by LFCP-028; they are not covered here.
17
+ */
18
+ export function actorHaveFromCbor(value) {
19
+ const what = "actor-have";
20
+ const f = new Fields(value, what, [0, 1], [2]);
21
+ const id = principalId(f.bytes(0, 32));
22
+ const contiguous = f.uint(1);
23
+ const extras = [];
24
+ if (f.has(2)) {
25
+ const list = f.array(2);
26
+ if (list.length === 0)
27
+ invalid(what, "key 2 must be omitted when there are no ranges (§28.1)");
28
+ // §28.1 rule 5: "ranges MUST be strictly above contiguous; the first
29
+ // range MUST start at or above contiguous + 2, because a range starting
30
+ // at contiguous + 1 extends the contiguous prefix".
31
+ let floor = contiguous + 1n; // the next range must start above this
32
+ for (const item of list) {
33
+ if (!Array.isArray(item) || item.length !== 2)
34
+ invalid(what, "a range must be [start, end]");
35
+ const [start, end] = item.map((n) => {
36
+ if ((typeof n === "number" || typeof n === "bigint") && n >= 0)
37
+ return BigInt(n);
38
+ return invalid(what, "range bounds must be unsigned integers");
39
+ });
40
+ if (start > end)
41
+ invalid(what, "a range must have start <= end (§28.1)");
42
+ if (start <= floor) {
43
+ invalid(what, extras.length === 0
44
+ ? "ranges must start above contiguous + 1 (§28.1 rule 5)"
45
+ : "ranges must be sorted, non-overlapping and non-adjacent (§28.1)");
46
+ }
47
+ extras.push(Object.freeze([start, end]));
48
+ floor = end + 1n;
49
+ }
50
+ }
51
+ return Object.freeze({ principalId: id, contiguous, extras: Object.freeze(extras) });
52
+ }
53
+ /**
54
+ * Decodes a canonical frontier (§28.2): an array of canonical actor-have
55
+ * entries in strictly ascending raw Principal ID order, which also rules
56
+ * out two entries for one Principal (§28.1 rule 9). A violation is
57
+ * INVALID_STRUCTURE (MALFORMED_MESSAGE on the wire, §28.2, N6).
58
+ */
59
+ export function canonicalFrontierFromCbor(value) {
60
+ if (!Array.isArray(value))
61
+ invalid("canonical frontier", "not an array");
62
+ const entries = value.map(actorHaveFromCbor);
63
+ for (let i = 1; i < entries.length; i++) {
64
+ const order = compareCanonicalFrontierOrder(entries[i - 1].principalId, entries[i].principalId);
65
+ if (order === 0)
66
+ invalid("canonical frontier", "two entries for the same Principal (§28.1 rule 9)");
67
+ if (order > 0)
68
+ invalid("canonical frontier", "entries must be sorted by raw Principal ID (§28.2)");
69
+ }
70
+ return Object.freeze(entries);
71
+ }
72
+ /** The canonical CBOR of an actor-have (§28.1): key 2 only when there are ranges. Checked like a received one. */
73
+ export function actorHaveToCbor(have) {
74
+ const value = cborMap(have.extras.length === 0
75
+ ? [
76
+ [0, have.principalId],
77
+ [1, have.contiguous],
78
+ ]
79
+ : [
80
+ [0, have.principalId],
81
+ [1, have.contiguous],
82
+ [2, have.extras.map(([start, end]) => [start, end])],
83
+ ]);
84
+ actorHaveFromCbor(value);
85
+ return value;
86
+ }
87
+ /** A canonical frontier (§28.2): entries sorted by raw Principal ID; duplicates are refused. */
88
+ export function canonicalFrontierToCbor(frontier) {
89
+ const sorted = [...frontier].sort((a, b) => compareCanonicalFrontierOrder(a.principalId, b.principalId));
90
+ const value = sorted.map(actorHaveToCbor);
91
+ canonicalFrontierFromCbor(value);
92
+ return value;
93
+ }
94
+ /** §49: "A request SHOULD contain no more than 256 ranges." */
95
+ export const MAX_DATA_GET_RANGES = 256;
96
+ const UINT64_MAX = 2n ** 64n - 1n;
97
+ /** Merges intervals into sorted, non-overlapping, non-adjacent ones (lossless). */
98
+ function mergeIntervals(intervals) {
99
+ const sorted = [...intervals].sort(([a, b], [c, d]) => a !== c ? (a < c ? -1 : 1) : b < d ? -1 : b > d ? 1 : 0);
100
+ const out = [];
101
+ for (const [s, e] of sorted) {
102
+ const last = out[out.length - 1];
103
+ if (last !== undefined && s <= last[1] + 1n) {
104
+ if (e > last[1])
105
+ last[1] = e;
106
+ }
107
+ else
108
+ out.push([s, e]);
109
+ }
110
+ return out;
111
+ }
112
+ /** The normalized ActorHave holding exactly 1..contiguous plus `ranges` (§28). */
113
+ function haveFromIntervals(principal, intervals) {
114
+ const merged = mergeIntervals(intervals);
115
+ let contiguous = 0n;
116
+ let i = 0;
117
+ if (merged[0] !== undefined && merged[0][0] === 1n) {
118
+ contiguous = merged[0][1];
119
+ i = 1;
120
+ }
121
+ return Object.freeze({
122
+ principalId: principal,
123
+ contiguous,
124
+ extras: Object.freeze(merged.slice(i).map(([s, e]) => Object.freeze([s, e]))),
125
+ });
126
+ }
127
+ /** The holdings of one ActorHave as intervals. */
128
+ const intervalsOf = (have) => [
129
+ ...(have.contiguous > 0n ? [[1n, have.contiguous]] : []),
130
+ ...have.extras,
131
+ ];
132
+ /**
133
+ * §48 (G-HV1): a live range with start > end, or one that includes
134
+ * sequence 0, is MALFORMED_MESSAGE (INVALID_STRUCTURE here); sequences
135
+ * must fit in uint64. Use as DecodeOptions.liveHave; decodeMessage applies
136
+ * it by default.
137
+ */
138
+ export function checkLiveHave(entry) {
139
+ if (entry.contiguous < 0n || entry.contiguous > UINT64_MAX)
140
+ invalid("actor-have", "contiguous must be a uint64");
141
+ for (const [start, end] of entry.ranges ?? []) {
142
+ if (start > end)
143
+ invalid("actor-have", "a range has start > end (§48)");
144
+ if (start === 0n)
145
+ invalid("actor-have", "a range includes sequence 0 (§48)");
146
+ if (end > UINT64_MAX)
147
+ invalid("actor-have", "a range end must be a uint64");
148
+ }
149
+ }
150
+ /**
151
+ * Normalizes live actor-haves losslessly (§48, G-MSG6): unsorted,
152
+ * overlapping or adjacent ranges, ranges touching `contiguous`, and several
153
+ * entries for one actor are merged into one normalized entry per actor,
154
+ * sorted by raw Principal ID. Reversed ranges and sequence 0 are refused
155
+ * (checkLiveHave). An actor entry holding nothing stays as contiguous 0
156
+ * with no ranges.
157
+ */
158
+ export function normalizeLiveHaves(entries) {
159
+ const byActor = new Map();
160
+ for (const e of entries) {
161
+ checkLiveHave(e);
162
+ const key = toKey(e.principalId);
163
+ const slot = byActor.get(key) ?? { id: principalId(e.principalId), intervals: [] };
164
+ if (e.contiguous > 0n)
165
+ slot.intervals.push([1n, e.contiguous]);
166
+ slot.intervals.push(...(e.ranges ?? []));
167
+ byActor.set(key, slot);
168
+ }
169
+ return sortVector([...byActor.values()].map((a) => haveFromIntervals(a.id, a.intervals)));
170
+ }
171
+ /** A normalized vector as live entries for DATA_HAVE and friends: our encoder always sends normalized form. */
172
+ export const liveHavesOf = (vector) => vector.map((h) => Object.freeze({
173
+ principalId: h.principalId,
174
+ contiguous: h.contiguous,
175
+ ...(h.extras.length > 0 ? { ranges: h.extras } : {}),
176
+ }));
177
+ const toKey = (id) => toHex(id);
178
+ function sortVector(entries) {
179
+ return Object.freeze([...entries].sort((a, b) => compareCanonicalFrontierOrder(a.principalId, b.principalId)));
180
+ }
181
+ const find = (vector, actor) => {
182
+ const key = toKey(actor);
183
+ return vector.find((h) => toKey(h.principalId) === key);
184
+ };
185
+ /** Whether the vector holds `seq` of `actor`. */
186
+ export function hasSequence(vector, actor, seq) {
187
+ const h = find(vector, actor);
188
+ if (h === undefined || seq < 1n)
189
+ return false;
190
+ return seq <= h.contiguous || h.extras.some(([s, e]) => s <= seq && seq <= e);
191
+ }
192
+ /**
193
+ * Adds `start..end` of `actor` (sequences of accepted units only; see the
194
+ * module note on held units). Extends the contiguous prefix, fills holes
195
+ * and merges extras; adding what is already held changes nothing.
196
+ */
197
+ export function addRange(vector, actor, start, end) {
198
+ checkLiveHave({ principalId: actor, contiguous: 0n, ranges: [[start, end]] });
199
+ const current = find(vector, actor);
200
+ const next = haveFromIntervals(principalId(actor), [
201
+ ...(current ? intervalsOf(current) : []),
202
+ [start, end],
203
+ ]);
204
+ return sortVector([...vector.filter((h) => h !== current), next]);
205
+ }
206
+ /** Adds one sequence of `actor` (see addRange). */
207
+ export const addSequence = (vector, actor, seq) => addRange(vector, actor, seq, seq);
208
+ /** The union of two vectors (normalized). */
209
+ export function unionHaves(a, b) {
210
+ return normalizeLiveHaves([...liveHavesOf(a), ...liveHavesOf(b)]);
211
+ }
212
+ /**
213
+ * Subtracts sorted, merged `have` from sorted, merged `want` in one pass:
214
+ * linear in the number of intervals, whatever their span.
215
+ */
216
+ function subtract(want, have) {
217
+ const out = [];
218
+ let j = 0;
219
+ for (const [ws, we] of want) {
220
+ let s = ws;
221
+ while (j < have.length && have[j][1] < s)
222
+ j++;
223
+ for (let k = j; k < have.length && have[k][0] <= we; k++) {
224
+ const [hs, he] = have[k];
225
+ if (hs > s)
226
+ out.push([s, hs - 1n]);
227
+ s = he + 1n;
228
+ j = k;
229
+ if (s > we)
230
+ break;
231
+ }
232
+ if (s <= we)
233
+ out.push([s, we]);
234
+ }
235
+ return out;
236
+ }
237
+ /**
238
+ * The units `remote` holds that `local` does not (§68), as the fewest
239
+ * possible ranges: per actor, maximal intervals, by ascending raw Principal
240
+ * ID and then sequence. Covers actors absent locally, a missing contiguous
241
+ * tail, holes, and remote extras; never requests what local already holds.
242
+ */
243
+ export function missingFrom(local, remote) {
244
+ const out = [];
245
+ const mine = new Map(local.map((h) => [toKey(h.principalId), h]));
246
+ for (const r of sortVector([...remote])) {
247
+ const l = mine.get(toKey(r.principalId));
248
+ const gaps = subtract(mergeIntervals(intervalsOf(r)), l ? mergeIntervals(intervalsOf(l)) : []);
249
+ for (const [start, end] of gaps)
250
+ out.push(Object.freeze({ actor: r.principalId, start, end }));
251
+ }
252
+ return out;
253
+ }
254
+ /**
255
+ * Catch-up after loading a Snapshot (§29, §66 step 4): what `theirs` holds
256
+ * that is neither in `ours` nor covered by the Snapshot's frontier.
257
+ */
258
+ export const missingAfter = (snapshotFrontier, ours, theirs) => missingFrom(unionHaves(ours, snapshotFrontier), theirs);
259
+ /** Splits ranges into DATA_GET-sized batches (§49: at most 256 ranges per request). */
260
+ export function batchDataRanges(ranges, max = MAX_DATA_GET_RANGES) {
261
+ if (!Number.isInteger(max) || max < 1)
262
+ throw new RangeError("the batch size must be a positive integer");
263
+ const out = [];
264
+ for (let i = 0; i < ranges.length; i += max)
265
+ out.push(ranges.slice(i, i + max));
266
+ return out;
267
+ }
268
+ //# sourceMappingURL=have.js.map