@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
package/dist/chain.js ADDED
@@ -0,0 +1,339 @@
1
+ import { controlRecordId, dataEpoch, LfcpError, toHex, } from "@openlfcp/core";
2
+ import { applyCapabilities, authorizeControlRecord, transferIssuerDescriptor, } from "./capability.js";
3
+ import { controlRecordSigner, decodeControlRecord, verifyGenesis, } from "./control.js";
4
+ import { objectId, verifySignedObject } from "./cose.js";
5
+ const WIRE = {
6
+ MALFORMED: "MALFORMED_MESSAGE",
7
+ // §14: "Unknown core Control Record types MUST cause validation failure, with INVALID_CONTROL_CHAIN."
8
+ UNSUPPORTED_TYPE: "INVALID_CONTROL_CHAIN",
9
+ // §13.1: a broken chain structure is INVALID_CONTROL_CHAIN.
10
+ NO_GENESIS: "INVALID_CONTROL_CHAIN",
11
+ // §15: a Genesis whose issuer or kid is not its owner.
12
+ GENESIS_SIGNER: "INVALID_SIGNATURE",
13
+ RESOURCE: "INVALID_CONTROL_CHAIN",
14
+ SEQUENCE: "INVALID_CONTROL_CHAIN",
15
+ PREVIOUS: "INVALID_CONTROL_CHAIN",
16
+ // §13: kid MUST equal the issuer, and the signature must verify; §23.3 transfer signatures.
17
+ SIGNATURE: "INVALID_SIGNATURE",
18
+ // §13.1: an issuer the receiver cannot resolve to a Principal Descriptor.
19
+ UNRESOLVED_ISSUER: "MISSING_DEPENDENCY",
20
+ // §19 (G-EP3): a Key Epoch that is not current + 1 breaks the chain.
21
+ EPOCH: "INVALID_CONTROL_CHAIN",
22
+ UNAUTHORIZED: "AUTHORIZATION_FAILED",
23
+ // MVP-0.1-PROTOCOL-SCOPE §4 (DV1): Coordinator Recovery and Resource Tombstone records.
24
+ DEFERRED_TYPE: "PROTOCOL_UNSUPPORTED",
25
+ };
26
+ const SDK_CODE = {
27
+ MALFORMED: "INVALID_STRUCTURE",
28
+ UNSUPPORTED_TYPE: "UNSUPPORTED_VALUE",
29
+ NO_GENESIS: "INVALID_CONTROL_CHAIN",
30
+ GENESIS_SIGNER: "INVALID_SIGNATURE",
31
+ RESOURCE: "INVALID_CONTROL_CHAIN",
32
+ SEQUENCE: "INVALID_CONTROL_CHAIN",
33
+ PREVIOUS: "INVALID_CONTROL_CHAIN",
34
+ SIGNATURE: "INVALID_SIGNATURE",
35
+ UNRESOLVED_ISSUER: "MISSING_DEPENDENCY",
36
+ EPOCH: "INVALID_CONTROL_CHAIN",
37
+ UNAUTHORIZED: "AUTHORIZATION_FAILED",
38
+ DEFERRED_TYPE: "PROTOCOL_UNSUPPORTED",
39
+ };
40
+ function problem(kind, at, message, cause) {
41
+ return { problem: kind, ...at, error: cause ?? new LfcpError(SDK_CODE[kind], message) };
42
+ }
43
+ /** The record ID: SHA-256 of the exact signed bytes (§13), as a ControlRecordId. */
44
+ const rid = (record) => controlRecordId(record.signed.id);
45
+ const at = (e) => ({
46
+ index: e.index,
47
+ recordId: rid(e.record),
48
+ seq: e.record.payload.controlSeq,
49
+ });
50
+ /** The first problem by (sequence, record ID): reproducible across input orders. */
51
+ function invalid(problems) {
52
+ const first = [...problems].sort((a, b) => {
53
+ if (a.seq !== b.seq)
54
+ return a.seq < b.seq ? -1 : 1;
55
+ const x = a.recordId === null ? "" : toHex(a.recordId);
56
+ const y = b.recordId === null ? "" : toHex(b.recordId);
57
+ return x < y ? -1 : x > y ? 1 : 0;
58
+ })[0];
59
+ return Object.freeze({
60
+ kind: "invalid",
61
+ problem: first.problem,
62
+ wireCode: WIRE[first.problem],
63
+ index: first.index,
64
+ recordId: first.recordId,
65
+ error: first.error,
66
+ });
67
+ }
68
+ const sortedIds = (ids) => Object.freeze([...ids].sort((a, b) => (toHex(a) < toHex(b) ? -1 : 1)));
69
+ function conflict(commonHead, seq, competing, prefixState) {
70
+ return Object.freeze({
71
+ kind: "conflict",
72
+ wireCode: "CONTROL_CONFLICT",
73
+ commonHead,
74
+ seq,
75
+ competing: sortedIds(competing),
76
+ prefixState,
77
+ });
78
+ }
79
+ function genesisState(record) {
80
+ const body = record.body;
81
+ if (body.type !== "GENESIS")
82
+ throw new Error("not a Genesis record");
83
+ const id = controlRecordId(record.signed.id);
84
+ return Object.freeze({
85
+ resourceId: record.payload.resourceId,
86
+ genesisId: id,
87
+ head: id,
88
+ seq: 0n,
89
+ dataProfile: body.dataProfile,
90
+ owner: body.owner,
91
+ route: Object.freeze({ endpoints: body.endpoints, coordinatorUrl: body.coordinatorUrl }),
92
+ routeVersion: 0n,
93
+ epoch: Object.freeze({ epoch: dataEpoch(0n), dekCommitment: body.dekCommitment }),
94
+ epochs: new Map([
95
+ [
96
+ "0",
97
+ Object.freeze({
98
+ epoch: dataEpoch(0n),
99
+ dekCommitment: body.dekCommitment,
100
+ openedBy: id,
101
+ closedBy: null,
102
+ finalFrontier: null,
103
+ }),
104
+ ],
105
+ ]),
106
+ grants: new Map(),
107
+ principals: new Map([[toHex(body.owner.principalId), body.owner]]),
108
+ });
109
+ }
110
+ /**
111
+ * The state after a validated record. Applied records (mvpSupported)
112
+ * change derived state: descriptors (LFCP-020), grants, revocations, claims
113
+ * and routes (LFCP-021); LFCP-023 adds epochs here. Unapplied (extension)
114
+ * records move the head and nothing else.
115
+ */
116
+ function applyRecord(state, record) {
117
+ const next = { head: controlRecordId(record.signed.id), seq: record.payload.controlSeq };
118
+ if (!record.mvpSupported)
119
+ return Object.freeze({ ...state, ...next });
120
+ const principals = new Map(state.principals);
121
+ const body = record.body;
122
+ if (body.type === "CAPABILITY_GRANT")
123
+ principals.set(toHex(body.subject.principalId), body.subject);
124
+ if (body.type === "CAPABILITY_CLAIM")
125
+ principals.set(toHex(body.claimant.principalId), body.claimant);
126
+ // §23.3 "After commit, the accepting Principal becomes the Resource owner"
127
+ // (verified by authorizeControlRecord before this record was accepted).
128
+ const newOwner = transferIssuerDescriptor(record);
129
+ if (newOwner !== undefined)
130
+ principals.set(toHex(newOwner.principalId), newOwner);
131
+ const route = body.type === "ROUTE_UPDATE"
132
+ ? {
133
+ route: Object.freeze({ endpoints: body.endpoints, coordinatorUrl: body.coordinatorUrl }),
134
+ routeVersion: body.routeVersion,
135
+ }
136
+ : {};
137
+ return Object.freeze({
138
+ ...state,
139
+ ...next,
140
+ ...route,
141
+ ...(body.type === "KEY_EPOCH" ? rotate(state, record, body) : {}),
142
+ ...(newOwner !== undefined ? { owner: newOwner } : {}),
143
+ principals,
144
+ grants: applyCapabilities(state.grants, record),
145
+ });
146
+ }
147
+ /**
148
+ * §19: a committed Key Epoch record closes the current epoch with its final
149
+ * frontier and opens the next one with its DEK commitment. Its succession
150
+ * (new = current + 1) is checked before it is applied (§19, G-EP3).
151
+ */
152
+ function rotate(state, record, body) {
153
+ const id = controlRecordId(record.signed.id);
154
+ const epochs = new Map(state.epochs);
155
+ const current = epochs.get(String(state.epoch.epoch));
156
+ if (current !== undefined)
157
+ epochs.set(String(current.epoch), Object.freeze({ ...current, closedBy: id, finalFrontier: body.finalFrontier }));
158
+ epochs.set(String(body.epoch), Object.freeze({
159
+ epoch: body.epoch,
160
+ dekCommitment: body.dekCommitment,
161
+ openedBy: id,
162
+ closedBy: null,
163
+ finalFrontier: null,
164
+ }));
165
+ return { epoch: Object.freeze({ epoch: body.epoch, dekCommitment: body.dekCommitment }), epochs };
166
+ }
167
+ /**
168
+ * Validates a set of exact signed Control Records of one Resource, from
169
+ * Genesis or after `options.start`. Input objects that are already decoded
170
+ * are re-decoded from their exact bytes, never trusted as decoded.
171
+ */
172
+ export function validateControlChain(inputs, options = {}) {
173
+ const entries = [];
174
+ const early = [];
175
+ inputs.forEach((input, index) => {
176
+ const bytes = input instanceof Uint8Array ? input : input.signed.bytes;
177
+ try {
178
+ const record = decodeControlRecord(bytes);
179
+ entries.push({ index, record, key: toHex(record.signed.id) });
180
+ }
181
+ catch (e) {
182
+ const error = e instanceof LfcpError ? e : new LfcpError("INVALID_STRUCTURE", String(e));
183
+ let recordId = null;
184
+ try {
185
+ recordId = controlRecordId(objectId(bytes));
186
+ }
187
+ catch {
188
+ // not bytes at all
189
+ }
190
+ early.push(problem(error.code === "UNSUPPORTED_VALUE"
191
+ ? "UNSUPPORTED_TYPE"
192
+ : error.code === "INVALID_CONTROL_CHAIN"
193
+ ? "SEQUENCE"
194
+ : "MALFORMED", { index, recordId, seq: -1n }, error.message, error));
195
+ }
196
+ });
197
+ // DV1 (MVP-0.1-PROTOCOL-SCOPE §4): "An MVP 0.1 implementation MUST refuse
198
+ // a Control Chain that contains" a COORDINATOR_RECOVERY (7) or a
199
+ // RESOURCE_TOMBSTONE (8) record, "with PROTOCOL_UNSUPPORTED".
200
+ for (const e of entries) {
201
+ const type = e.record.body.type;
202
+ if (type === "COORDINATOR_RECOVERY" || type === "RESOURCE_TOMBSTONE")
203
+ early.push(problem("DEFERRED_TYPE", at(e), `a ${type} record: MVP 0.1 refuses a chain containing one (scope §4, DV1)`));
204
+ }
205
+ if (early.length > 0)
206
+ return invalid(early);
207
+ // The same record delivered twice is one record.
208
+ const unique = new Map();
209
+ for (const e of entries)
210
+ if (!unique.has(e.key))
211
+ unique.set(e.key, e);
212
+ const all = [...unique.values()];
213
+ const geneses = all.filter((e) => e.record.body.type === "GENESIS");
214
+ let rest = all.filter((e) => e.record.body.type !== "GENESIS");
215
+ let state;
216
+ const records = [];
217
+ const start = options.start;
218
+ if (start !== undefined) {
219
+ const others = geneses.filter((g) => g.key !== toHex(start.genesisId));
220
+ if (others.length > 0) {
221
+ if (others.some((g) => toHex(g.record.payload.resourceId) !== toHex(start.resourceId)))
222
+ return invalid(others.map((g) => problem("RESOURCE", at(g), "a Genesis of another Resource")));
223
+ // §13.2: "Two different validly signed Genesis Records for one Resource
224
+ // ID are a fork at the root": neither is accepted (CONTROL_CONFLICT).
225
+ return conflict(null, 0n, [start.genesisId, ...others.map((g) => rid(g.record))], null);
226
+ }
227
+ rest = rest.filter((e) => e.key !== toHex(start.head));
228
+ state = start;
229
+ }
230
+ else {
231
+ if (geneses.length === 0)
232
+ return invalid([
233
+ problem("NO_GENESIS", { index: null, recordId: null, seq: -1n }, "the chain has no Genesis record"),
234
+ ]);
235
+ const bad = [];
236
+ for (const g of geneses) {
237
+ const v = verifyGenesis(g.record);
238
+ if (!v.valid)
239
+ bad.push(problem(v.reason === "ISSUER_NOT_OWNER" ? "GENESIS_SIGNER" : "SIGNATURE", at(g), `Genesis is not signed by its owner (${v.reason}, §15)`));
240
+ }
241
+ if (bad.length > 0)
242
+ return invalid(bad);
243
+ if (geneses.length > 1) {
244
+ const resource = toHex(geneses[0].record.payload.resourceId);
245
+ if (geneses.some((g) => toHex(g.record.payload.resourceId) !== resource))
246
+ return invalid(geneses.map((g) => problem("RESOURCE", at(g), "Genesis records of different Resources")));
247
+ // §13.2: "Two different validly signed Genesis Records for one Resource
248
+ // ID are a fork at the root": neither is accepted (CONTROL_CONFLICT).
249
+ return conflict(null, 0n, geneses.map((g) => rid(g.record)), null);
250
+ }
251
+ const genesis = geneses[0];
252
+ state = genesisState(genesis.record);
253
+ records.push(genesis.record);
254
+ }
255
+ const structural = [];
256
+ for (const e of rest) {
257
+ if (toHex(e.record.payload.resourceId) !== toHex(state.resourceId))
258
+ structural.push(problem("RESOURCE", at(e), "the record belongs to another Resource"));
259
+ else if (e.record.payload.prevControlId === null)
260
+ structural.push(problem("PREVIOUS", at(e), "a non-Genesis record without a previous record ID (§13.1)"));
261
+ }
262
+ if (structural.length > 0)
263
+ return invalid(structural);
264
+ const children = new Map();
265
+ for (const e of rest) {
266
+ const prev = toHex(e.record.payload.prevControlId);
267
+ children.set(prev, [...(children.get(prev) ?? []), e]);
268
+ }
269
+ const unapplied = [];
270
+ const states = new Map([[toHex(state.head), state]]);
271
+ const visited = new Set();
272
+ const authorize = options.authorize ?? authorizeControlRecord;
273
+ for (;;) {
274
+ const kids = children.get(toHex(state.head)) ?? [];
275
+ if (kids.length === 0)
276
+ break;
277
+ const problems = [];
278
+ const valid = [];
279
+ for (const k of kids) {
280
+ const p = k.record.payload;
281
+ if (p.controlSeq !== state.seq + 1n) {
282
+ problems.push(problem("SEQUENCE", at(k), `control_seq ${p.controlSeq} after ${state.seq} (§13.1)`));
283
+ continue;
284
+ }
285
+ // §13: the issuer signs, and kid MUST equal it (see controlRecordSigner).
286
+ const issuer = controlRecordSigner(k.record);
287
+ const descriptor = state.principals.get(toHex(issuer)) ??
288
+ transferIssuerDescriptor(k.record) ??
289
+ options.resolvePrincipal?.(issuer);
290
+ if (descriptor === undefined) {
291
+ problems.push(problem("UNRESOLVED_ISSUER", at(k), "no descriptor is known for the issuer"));
292
+ continue;
293
+ }
294
+ const v = verifySignedObject(k.record.signed, descriptor);
295
+ if (!v.valid) {
296
+ problems.push(problem("SIGNATURE", at(k), `the record is not signed by its issuer (${v.reason})`));
297
+ continue;
298
+ }
299
+ // §19: "The new epoch number MUST be exactly the previous Data Epoch plus one."
300
+ if (k.record.body.type === "KEY_EPOCH" && k.record.body.epoch !== state.epoch.epoch + 1n) {
301
+ problems.push(problem("EPOCH", at(k), `Key Epoch ${k.record.body.epoch} after epoch ${state.epoch.epoch}: must be the previous epoch plus one (§19)`));
302
+ continue;
303
+ }
304
+ const decision = authorize(k.record, state);
305
+ if (decision === false || (typeof decision === "object" && !decision.allowed)) {
306
+ const refusal = typeof decision === "object" && !decision.allowed ? decision : undefined;
307
+ // §23.3: a transfer offer or acceptance whose signature does not verify.
308
+ if (refusal?.code === "INVALID_SIGNATURE")
309
+ problems.push(problem("SIGNATURE", at(k), refusal.reason));
310
+ else
311
+ problems.push(problem("UNAUTHORIZED", at(k), `the record is not authorized: ${refusal?.reason ?? "refused"}`));
312
+ continue;
313
+ }
314
+ valid.push(k);
315
+ }
316
+ if (problems.length > 0)
317
+ return invalid(problems);
318
+ if (valid.length > 1)
319
+ return conflict(state.head, state.seq + 1n, valid.map((k) => rid(k.record)), state);
320
+ const k = valid[0];
321
+ visited.add(k.key);
322
+ records.push(k.record);
323
+ if (!k.record.mvpSupported)
324
+ unapplied.push(rid(k.record));
325
+ state = applyRecord(state, k.record);
326
+ states.set(toHex(state.head), state);
327
+ }
328
+ const unlinked = rest.filter((e) => !visited.has(e.key));
329
+ if (unlinked.length > 0)
330
+ return invalid(unlinked.map((e) => problem("PREVIOUS", at(e), "the previous record ID does not name the chain's record at the previous sequence (§13.1)")));
331
+ return Object.freeze({
332
+ kind: "linear",
333
+ state,
334
+ records: Object.freeze(records),
335
+ unappliedRecords: Object.freeze(unapplied),
336
+ stateAt: (head) => states.get(toHex(head)),
337
+ });
338
+ }
339
+ //# sourceMappingURL=chain.js.map
@@ -0,0 +1,69 @@
1
+ import type { ControlHeadRef } from "./message.js";
2
+ /**
3
+ * Control Plane anti-entropy planning (LFCP-WIRE-01 §44, §45, §67): from
4
+ * our Control Head and the heads a peer announces in CONTROL_HAVE (or
5
+ * RESOURCE_OPENED), decide what to request. Pure; no network loop.
6
+ *
7
+ * A fork is surfaced, never resolved: two or more peer heads (§42, §44),
8
+ * or a peer head at a sequence we hold whose record ID is not ours (§13.2).
9
+ * The plan then asks for the contested records, so the receiver can
10
+ * validate them (validateControlChain reports CONTROL_CONFLICT) instead of
11
+ * choosing a branch.
12
+ */
13
+ /** What we know of our chain: its head, and the record ID at any sequence we hold. */
14
+ export interface LocalControl {
15
+ readonly head: ControlHeadRef;
16
+ readonly recordIdAt: (seq: bigint) => Uint8Array | undefined;
17
+ }
18
+ export type ControlSyncPlan =
19
+ /** Both sides have the same head. */
20
+ {
21
+ readonly kind: "in-sync";
22
+ }
23
+ /** G-HV2: an empty CONTROL_HAVE means the peer has no Control Records. */
24
+ | {
25
+ readonly kind: "peer-empty";
26
+ }
27
+ /** The peer is ahead on our chain: CONTROL_GET(start..end) (§45, §67). */
28
+ | {
29
+ readonly kind: "fetch";
30
+ readonly start: bigint;
31
+ readonly end: bigint;
32
+ }
33
+ /** The peer is behind on our chain; it may fetch from us. Nothing to request. */
34
+ | {
35
+ readonly kind: "peer-behind";
36
+ readonly peerSeq: bigint;
37
+ }
38
+ /**
39
+ * A fork: never resolved here. `fetch` covers the contested sequences
40
+ * (and anything the peer has beyond them), so every competing record can
41
+ * be validated.
42
+ */
43
+ | {
44
+ readonly kind: "fork";
45
+ readonly reason: "PEER_HEADS" | "DIVERGED";
46
+ readonly heads: readonly ControlHeadRef[];
47
+ readonly fetch: {
48
+ readonly start: bigint;
49
+ readonly end: bigint;
50
+ };
51
+ };
52
+ /** Plans Control synchronization against a peer's announced heads. `local` is null when we hold no records. */
53
+ export declare function planControlSync(local: LocalControl | null, peerHeads: readonly ControlHeadRef[]): ControlSyncPlan;
54
+ /** The LocalControl of a validated linear chain (records[i] is at sequence i from Genesis). */
55
+ export declare function localControlOf(chain: {
56
+ readonly state: {
57
+ readonly head: Uint8Array;
58
+ readonly seq: bigint;
59
+ };
60
+ readonly records: readonly {
61
+ readonly signed: {
62
+ readonly id: Uint8Array;
63
+ };
64
+ readonly payload: {
65
+ readonly controlSeq: bigint;
66
+ };
67
+ }[];
68
+ }): LocalControl;
69
+ //# sourceMappingURL=control-sync.d.ts.map
@@ -0,0 +1,44 @@
1
+ import { bytesEqual } from "@openlfcp/core";
2
+ /** Plans Control synchronization against a peer's announced heads. `local` is null when we hold no records. */
3
+ export function planControlSync(local, peerHeads) {
4
+ if (peerHeads.length === 0)
5
+ return Object.freeze({ kind: "peer-empty" });
6
+ const maxSeq = peerHeads.reduce((m, h) => (h.seq > m ? h.seq : m), 0n);
7
+ if (peerHeads.length > 1) {
8
+ const minSeq = peerHeads.reduce((m, h) => (h.seq < m ? h.seq : m), maxSeq);
9
+ const ours = local?.head.seq;
10
+ const start = ours === undefined ? 0n : ours < minSeq ? ours + 1n : minSeq;
11
+ return Object.freeze({
12
+ kind: "fork",
13
+ reason: "PEER_HEADS",
14
+ heads: Object.freeze([...peerHeads]),
15
+ fetch: Object.freeze({ start, end: maxSeq }),
16
+ });
17
+ }
18
+ const peer = peerHeads[0];
19
+ if (local === null)
20
+ return Object.freeze({ kind: "fetch", start: 0n, end: peer.seq });
21
+ if (peer.seq <= local.head.seq) {
22
+ const ours = local.recordIdAt(peer.seq);
23
+ if (ours === undefined || !bytesEqual(ours, peer.recordId))
24
+ return Object.freeze({
25
+ kind: "fork",
26
+ reason: "DIVERGED",
27
+ heads: Object.freeze([peer]),
28
+ fetch: Object.freeze({ start: peer.seq, end: peer.seq }),
29
+ });
30
+ return peer.seq === local.head.seq
31
+ ? Object.freeze({ kind: "in-sync" })
32
+ : Object.freeze({ kind: "peer-behind", peerSeq: peer.seq });
33
+ }
34
+ return Object.freeze({ kind: "fetch", start: local.head.seq + 1n, end: peer.seq });
35
+ }
36
+ /** The LocalControl of a validated linear chain (records[i] is at sequence i from Genesis). */
37
+ export function localControlOf(chain) {
38
+ const byseq = new Map(chain.records.map((r) => [r.payload.controlSeq, r.signed.id]));
39
+ return Object.freeze({
40
+ head: Object.freeze({ seq: chain.state.seq, recordId: chain.state.head }),
41
+ recordIdAt: (seq) => byseq.get(seq),
42
+ });
43
+ }
44
+ //# sourceMappingURL=control-sync.js.map
@@ -0,0 +1,173 @@
1
+ import { type ControlRecordId, type DataEpoch, type Hash32, type PrincipalId, type ResourceId } from "@openlfcp/core";
2
+ import { type CborValue } from "./cbor/index.js";
3
+ import { type SignedBytes, type Signer, type VerifyResult } from "./cose.js";
4
+ import { type Endpoint } from "./endpoint.js";
5
+ import { type ActorHave } from "./have.js";
6
+ import { type ControlRecordPayload, type Parsed } from "./objects.js";
7
+ import { type PrincipalDescriptor } from "./principal.js";
8
+ /**
9
+ * Typed Control Record bodies (LFCP-WIRE-01 §13-§24) and the Control
10
+ * Record codec on top of the generic envelope (objects.ts, LFCP-016) and
11
+ * canonical COSE_Sign1 (cose.ts, LFCP-015).
12
+ *
13
+ * Structure only: every body is a closed map with its CDDL field types.
14
+ * Authority, chain linkage, claim limits, epoch succession and route
15
+ * version monotonicity belong to LFCP-020 to LFCP-023. Decoding never
16
+ * applies a record; `mvpSupported` tells those layers whether MVP 0.1
17
+ * implements the type (.github docs/MVP-0.1-PROTOCOL-SCOPE.md §4 defers
18
+ * coordinator recovery and Resource tombstones; ownership transfer is
19
+ * verified and applied, its UI/flow deferred).
20
+ *
21
+ * Writers apply rules the prose states for creators but not as receiver
22
+ * checks: wss:// URLs (loopback ws:// allowed), no reserved endpoint flag
23
+ * bits, at most 256 UTF-8 bytes of reason or note text (writer-side, §22,
24
+ * §24), a non-empty ability list, and the Genesis invariants. Receivers
25
+ * check structure, and that every endpoint and coordinator URL is ws or
26
+ * wss (§16).
27
+ */
28
+ export type ControlBody = {
29
+ readonly type: "GENESIS";
30
+ readonly dataProfile: string;
31
+ readonly owner: PrincipalDescriptor;
32
+ readonly dekCommitment: Hash32;
33
+ readonly endpoints: readonly Endpoint[];
34
+ readonly coordinatorUrl: string;
35
+ } | {
36
+ readonly type: "CAPABILITY_GRANT";
37
+ readonly subject: PrincipalDescriptor;
38
+ readonly abilities: readonly bigint[];
39
+ readonly delegable: readonly bigint[];
40
+ readonly parentGrantId?: ControlRecordId;
41
+ readonly claimLimit?: bigint;
42
+ } | {
43
+ readonly type: "CAPABILITY_REVOKE";
44
+ readonly grantId: ControlRecordId;
45
+ } | {
46
+ readonly type: "CAPABILITY_CLAIM";
47
+ readonly invitationGrantId: ControlRecordId;
48
+ readonly claimant: PrincipalDescriptor;
49
+ readonly abilities: readonly bigint[];
50
+ } | {
51
+ readonly type: "KEY_EPOCH";
52
+ readonly epoch: DataEpoch;
53
+ readonly dekCommitment: Hash32;
54
+ /** Accepted final frontier of the previous epoch: canonical entries, one per Principal. */
55
+ readonly finalFrontier: readonly ActorHave[];
56
+ readonly reason: bigint;
57
+ } | {
58
+ readonly type: "ROUTE_UPDATE";
59
+ readonly routeVersion: bigint;
60
+ readonly endpoints: readonly Endpoint[];
61
+ readonly coordinatorUrl: string;
62
+ } | {
63
+ readonly type: "OWNER_TRANSFER_COMMIT";
64
+ /** Exact COSE bytes of the transfer offer and accept (§23.3); see parseOwnerTransferOffer/Accept. */
65
+ readonly offer: Uint8Array;
66
+ readonly accept: Uint8Array;
67
+ } | {
68
+ readonly type: "COORDINATOR_RECOVERY";
69
+ readonly routeVersion: bigint;
70
+ readonly endpoints: readonly Endpoint[];
71
+ readonly coordinatorUrl: string;
72
+ readonly reason: string;
73
+ } | {
74
+ readonly type: "RESOURCE_TOMBSTONE";
75
+ readonly reason: bigint;
76
+ readonly note?: string;
77
+ }
78
+ /** A §14 extension type (32 and up): kept, never interpreted. */
79
+ | {
80
+ readonly type: "EXTENSION";
81
+ readonly code: bigint;
82
+ readonly body: CborValue;
83
+ };
84
+ export type ControlBodyType = ControlBody["type"];
85
+ /** Whether MVP 0.1 implements a body type (false for deferred core types and extensions). */
86
+ export declare const isMvpSupported: (type: ControlBodyType) => boolean;
87
+ /** The §14 code of a body. */
88
+ export declare const controlTypeOf: (body: ControlBody) => bigint;
89
+ /**
90
+ * Decodes the body of a Control Record of §14 type `type`. Core types 0-8
91
+ * get their closed-map structure; 9-31 are UNSUPPORTED_VALUE (§14:
92
+ * INVALID_CONTROL_CHAIN on the wire); 32 and up are kept as opaque
93
+ * EXTENSION bodies.
94
+ */
95
+ export declare function controlBodyFromCbor(type: bigint, value: CborValue): ControlBody;
96
+ /** A received Control Record: the exact object, its envelope and its typed body. Nothing is applied. */
97
+ export interface ControlRecord extends Parsed<ControlRecordPayload> {
98
+ readonly body: ControlBody;
99
+ /** False for MVP-deferred core types and extensions: LFCP-020/021 must not apply them. */
100
+ readonly mvpSupported: boolean;
101
+ }
102
+ /**
103
+ * Parses a received Control Record: canonical COSE and the generic
104
+ * envelope (parseControlRecord), then the typed body. The record ID is
105
+ * `signed.id`, SHA-256 of the exact received bytes. The signature is not
106
+ * checked here; see controlRecordSigner and verifyGenesis.
107
+ */
108
+ export declare function decodeControlRecord(bytes: Uint8Array): ControlRecord;
109
+ /**
110
+ * The Principal whose key must have signed a Control Record: its issuer
111
+ * (payload field 4). §13: "the protected-header kid MUST equal field 4,
112
+ * and a record whose kid is any other Principal is rejected with
113
+ * INVALID_SIGNATURE." Resolving the issuer to a descriptor (and checking
114
+ * its authority) is LFCP-020/021.
115
+ */
116
+ export declare const controlRecordSigner: (record: Parsed<ControlRecordPayload>) => PrincipalId;
117
+ /**
118
+ * Verifies a Genesis record against the owner in its own body (§15:
119
+ * "MUST be signed by the owner Principal contained in the body"): the
120
+ * issuer and the kid must be the owner and the signature must verify with
121
+ * the owner's key. A failure surfaces as INVALID_SIGNATURE.
122
+ */
123
+ export declare function verifyGenesis(record: ControlRecord): VerifyResult | {
124
+ readonly valid: false;
125
+ readonly reason: "NOT_GENESIS" | "ISSUER_NOT_OWNER";
126
+ };
127
+ /** The CBOR of a typed body, applying the writer rules. */
128
+ export declare function controlBodyToCbor(body: ControlBody): CborValue;
129
+ /** The envelope fields a writer chooses; the type comes from the body and the issuer from the signer. */
130
+ export interface ControlRecordHeader {
131
+ readonly resourceId: ResourceId;
132
+ readonly controlSeq: bigint;
133
+ readonly prevControlId: ControlRecordId | null;
134
+ }
135
+ /**
136
+ * Deterministic CBOR of control-record-payload (§13) for `issuer`. Writer
137
+ * rules: Genesis is at control_seq 0 with a null link and is issued by the
138
+ * owner in its body; every other record has control_seq >= 1 and a link
139
+ * (§13.1). The result is checked with the receiver's decoder too.
140
+ */
141
+ export declare function encodeControlRecordPayload(header: ControlRecordHeader, issuer: PrincipalId, body: ControlBody): Uint8Array;
142
+ /** A newly signed Control Record: its exact bytes and its ID, SHA-256 of those bytes (§13, §10.6). */
143
+ export interface SignedControlRecord extends SignedBytes {
144
+ readonly recordId: ControlRecordId;
145
+ }
146
+ /**
147
+ * Signs a Control Record: typed payload -> deterministic CBOR -> canonical
148
+ * untagged COSE_Sign1 (signObject) -> exact bytes -> SHA-256 = record ID.
149
+ * The issuer is the signer's Principal (§13: kid = issuer, see
150
+ * controlRecordSigner).
151
+ */
152
+ export declare function signControlRecord(header: ControlRecordHeader, body: ControlBody, signer: Signer): SignedControlRecord;
153
+ export interface OwnerTransferOfferPayload {
154
+ readonly resourceId: ResourceId;
155
+ readonly controlHead: ControlRecordId;
156
+ readonly expectedControlSeq: bigint;
157
+ readonly proposedOwner: PrincipalDescriptor;
158
+ readonly nonce: Uint8Array;
159
+ }
160
+ export interface OwnerTransferAcceptPayload {
161
+ readonly resourceId: ResourceId;
162
+ readonly offerId: Hash32;
163
+ readonly newOwner: PrincipalId;
164
+ }
165
+ /** owner-transfer-offer-payload (§23.1). */
166
+ export declare function ownerTransferOfferPayloadFromCbor(value: CborValue): OwnerTransferOfferPayload;
167
+ /** owner-transfer-accept-payload (§23.2). */
168
+ export declare function ownerTransferAcceptPayloadFromCbor(value: CborValue): OwnerTransferAcceptPayload;
169
+ /** Parses a received transfer offer (canonical COSE, typed payload). Deferred from MVP 0.1: never applied. */
170
+ export declare function parseOwnerTransferOffer(bytes: Uint8Array): Parsed<OwnerTransferOfferPayload>;
171
+ /** Parses a received transfer accept (canonical COSE, typed payload). Deferred from MVP 0.1: never applied. */
172
+ export declare function parseOwnerTransferAccept(bytes: Uint8Array): Parsed<OwnerTransferAcceptPayload>;
173
+ //# sourceMappingURL=control.d.ts.map