@openlfcp/client 0.2.2 → 0.3.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 (43) hide show
  1. package/dist/data-unit.d.ts +11 -0
  2. package/dist/data-unit.js +9 -0
  3. package/dist/identity/accept.d.ts +115 -0
  4. package/dist/identity/accept.js +335 -0
  5. package/dist/identity/context.d.ts +214 -0
  6. package/dist/identity/context.js +495 -0
  7. package/dist/identity/devices.d.ts +127 -0
  8. package/dist/identity/devices.js +221 -0
  9. package/dist/identity/document.d.ts +112 -0
  10. package/dist/identity/document.js +54 -0
  11. package/dist/identity/index.d.ts +17 -0
  12. package/dist/identity/index.js +17 -0
  13. package/dist/identity/journal.d.ts +139 -0
  14. package/dist/identity/journal.js +202 -0
  15. package/dist/identity/network.d.ts +88 -0
  16. package/dist/identity/network.js +243 -0
  17. package/dist/identity/pairing-request.d.ts +147 -0
  18. package/dist/identity/pairing-request.js +422 -0
  19. package/dist/identity/pairing.d.ts +225 -0
  20. package/dist/identity/pairing.js +624 -0
  21. package/dist/identity/phases.d.ts +75 -0
  22. package/dist/identity/phases.js +104 -0
  23. package/dist/identity/proof.d.ts +45 -0
  24. package/dist/identity/proof.js +71 -0
  25. package/dist/identity/reconcile.d.ts +73 -0
  26. package/dist/identity/reconcile.js +273 -0
  27. package/dist/identity/resource.d.ts +74 -0
  28. package/dist/identity/resource.js +201 -0
  29. package/dist/identity/revocation.d.ts +106 -0
  30. package/dist/identity/revocation.js +403 -0
  31. package/dist/identity/roster.d.ts +89 -0
  32. package/dist/identity/roster.js +130 -0
  33. package/dist/identity/rotation.d.ts +131 -0
  34. package/dist/identity/rotation.js +363 -0
  35. package/dist/index.d.ts +2 -1
  36. package/dist/index.js +2 -1
  37. package/dist/revoke.d.ts +8 -0
  38. package/dist/revoke.js +12 -8
  39. package/dist/storage.d.ts +18 -0
  40. package/dist/storage.js +26 -0
  41. package/dist/sync-client.d.ts +2 -0
  42. package/dist/sync-client.js +1 -0
  43. package/package.json +5 -5
@@ -0,0 +1,403 @@
1
+ import { bytesEqual, fromBase64url, fromHex, principalId, resourceId, secureRandom, toHex, } from "@openlfcp/core";
2
+ import { activeDevice, signControlRecord, validateControlChain, } from "@openlfcp/wire";
3
+ import { fetchDeks, genesisOwner, IdentityConflict, loadIdentityDocument, runControlPhase, runUnitPhase, sealIdentityUnit, syncChain, } from "./context.js";
4
+ import { mayManageDevices } from "./devices.js";
5
+ import { principalRefOf } from "./document.js";
6
+ import { newOperationId, OperationJournal, phaseOf } from "./journal.js";
7
+ import { ChannelPool, IDENTITY_EXTENSION, IdentityRefused, IdentityUnavailable, } from "./network.js";
8
+ import { classifyRevoke } from "./phases.js";
9
+ import { identityTarget } from "./resource.js";
10
+ import { exposingRevocationsSince, resumeRotations, rotateAfter, rotatorTest, } from "./rotation.js";
11
+ const toKeep = (state, root) => [...state.devices.entries()]
12
+ .filter(([, d]) => bytesEqual(d.root, root))
13
+ .map(([hex]) => principalId(fromHex(hex)))
14
+ .filter((p) => activeDevice(state, p) !== undefined);
15
+ /** The DEVICE_REVOKE on the chain's head, if this installation may issue it there. */
16
+ function revokeOn(chain, o, root, device, reason) {
17
+ const signed = signControlRecord({
18
+ resourceId: chain.state.resourceId,
19
+ controlSeq: chain.state.seq + 1n,
20
+ prevControlId: chain.state.head,
21
+ }, { type: "DEVICE_REVOKE", root, device, reason }, o.installation.signer);
22
+ const after = validateControlChain([...chain.records.map((r) => r.signed.bytes), signed.bytes]);
23
+ if (after.kind !== "linear")
24
+ return undefined;
25
+ // §8.1: the revoker must stay active in this Resource's post-state; a chain
26
+ // through the target is re-certified first, here, not only in I.
27
+ const me = o.installation.signer.descriptor.principalId;
28
+ if (!bytesEqual(me, device) &&
29
+ activeDevice(chain.state, me) !== undefined &&
30
+ activeDevice(after.state, me) === undefined)
31
+ return "self-dependent";
32
+ return { bytes: signed.bytes, after };
33
+ }
34
+ function previewOn(chain, o, root, device, reason) {
35
+ const R = chain.state.resourceId;
36
+ const empty = { suspended: [], exposing: false, rotator: false };
37
+ if (chain.state.revoked.has(`${toHex(root)}:${toHex(device)}`))
38
+ return { resource: R, status: "already-revoked", ...empty };
39
+ const sim = revokeOn(chain, o, root, device, reason);
40
+ if (sim === "self-dependent")
41
+ return { resource: R, status: "self-dependent", ...empty };
42
+ if (sim === undefined)
43
+ return { resource: R, status: "not-authorized", ...empty };
44
+ const before = new Set(toKeep(chain.state, root).map(toHex));
45
+ const after = new Set(toKeep(sim.after.state, root).map(toHex));
46
+ const suspended = [...before]
47
+ .filter((p) => !after.has(p) && p !== toHex(device))
48
+ .map((p) => principalId(fromHex(p)));
49
+ const exposing = exposingRevocationsSince(sim.after).some((e) => e.seq === sim.after.state.seq);
50
+ return {
51
+ resource: R,
52
+ status: "ok",
53
+ suspended,
54
+ exposing,
55
+ rotator: rotatorTest(sim.after.state, o.installation.signer.descriptor.principalId).rotator,
56
+ };
57
+ }
58
+ /** Roster targets of the Identity Resource (not archived), I excluded. */
59
+ async function rosterTargets(o, channel, chain) {
60
+ const doc = o.document(chain.state.resourceId, genesisOwner(chain));
61
+ await loadIdentityDocument({
62
+ channel,
63
+ chain,
64
+ deks: await fetchDeks(o.storage, o.secrets, channel, chain, o.installation),
65
+ doc,
66
+ });
67
+ return [...doc.roster()]
68
+ .filter(([, e]) => e.archived !== true)
69
+ .map(([ref, e]) => ({ resource: resourceId(fromBase64url(ref)), url: e.coordinator }))
70
+ .filter((t) => !bytesEqual(t.resource, chain.state.resourceId));
71
+ }
72
+ /**
73
+ * §8.1: the post-state of revoking `device` in `I` and every reachable
74
+ * roster Resource: suspended devices, exposure, whether this installation
75
+ * stays active and who rotates.
76
+ */
77
+ export async function previewRevocation(o) {
78
+ const target = await identityTarget(o);
79
+ const pool = new ChannelPool(o.network);
80
+ const me = o.installation.signer.descriptor.principalId;
81
+ const reason = o.reason ?? 1n;
82
+ try {
83
+ const channel = await pool.get(o.installation.signer, target.url);
84
+ const I = await syncChain(o.storage, channel, target.resource);
85
+ const R = genesisOwner(I);
86
+ const mine = activeDevice(I.state, me);
87
+ const selfDependent = !bytesEqual(me, o.device) &&
88
+ mine !== undefined &&
89
+ mine.issuers.some((e) => bytesEqual(e, o.device));
90
+ const resources = [previewOn(I, o, R, o.device, reason)];
91
+ for (const t of await rosterTargets(o, channel, I)) {
92
+ const empty = { suspended: [], exposing: false, rotator: false };
93
+ try {
94
+ const c = await pool.get(o.installation.signer, t.url);
95
+ if (!c.extensions.includes(IDENTITY_EXTENSION)) {
96
+ resources.push({ resource: t.resource, status: "unsupported", ...empty });
97
+ continue;
98
+ }
99
+ resources.push(previewOn(await syncChain(o.storage, c, t.resource), o, R, o.device, reason));
100
+ }
101
+ catch (e) {
102
+ if (e instanceof IdentityUnavailable || e instanceof IdentityRefused)
103
+ resources.push({ resource: t.resource, status: "unreachable", ...empty });
104
+ else if (e instanceof IdentityConflict)
105
+ resources.push({ resource: t.resource, status: "conflict", ...empty });
106
+ else
107
+ throw e;
108
+ }
109
+ }
110
+ return {
111
+ device: o.device,
112
+ selfDependent: selfDependent || resources.some((r) => r.status === "self-dependent"),
113
+ resources,
114
+ };
115
+ }
116
+ finally {
117
+ pool.closeAll();
118
+ }
119
+ }
120
+ class NotAuthorized extends Error {
121
+ }
122
+ class SelfDependent extends Error {
123
+ }
124
+ /**
125
+ * §8.2–§8.5: `DEVICE_REVOKE {R, D, r}` in `I`, then in every roster
126
+ * Resource where this installation may act, each followed by the rotation
127
+ * when the revocation is exposing there and this installation is the
128
+ * rotator (else `awaiting-rotation`). A self-retiring device (`D` is this
129
+ * installation, reason 0) commits the revocations only. Every phase is
130
+ * journaled and classified by §9.2: a revocation by another hand with
131
+ * another reason is `complete` and the rotation test still runs.
132
+ */
133
+ export async function revokeDevice(o) {
134
+ const target = await identityTarget(o);
135
+ const me = o.installation.signer.descriptor.principalId;
136
+ const selfRetire = bytesEqual(me, o.device);
137
+ const journal = new OperationJournal(o.storage);
138
+ const pool = new ChannelPool(o.network);
139
+ const operation = o.operation ?? newOperationId("revoke-device", secureRandom(8));
140
+ try {
141
+ // Rotations an earlier operation committed but did not adopt are finished first (§9.1).
142
+ if (!selfRetire)
143
+ await resumeRotations({ ...o, pool });
144
+ const channel = await pool.get(o.installation.signer, target.url);
145
+ const known = await journal.get(operation);
146
+ let I;
147
+ try {
148
+ I = await syncChain(o.storage, channel, target.resource);
149
+ }
150
+ catch (e) {
151
+ // A resumed self-retirement no longer reads I: its targets come from the journal.
152
+ if (!(e instanceof IdentityRefused) || !selfRetire || known === undefined)
153
+ throw e;
154
+ return retireElsewhere(o, pool, journal, known);
155
+ }
156
+ const R = genesisOwner(I);
157
+ if (!selfRetire && !mayManageDevices(I.state, me, R))
158
+ return { kind: "not-authorized" };
159
+ const mine = activeDevice(I.state, me);
160
+ if (!selfRetire && mine !== undefined && mine.issuers.some((e) => bytesEqual(e, o.device)))
161
+ return { kind: "self-dependent" };
162
+ // The targets are journaled before the first record: a self-retiring device
163
+ // cannot read I's roster once I has revoked it (§9.1).
164
+ if (known === undefined)
165
+ await journal.begin({
166
+ id: operation,
167
+ kind: "revoke-device",
168
+ createdAt: o.now(),
169
+ intent: {
170
+ identity: toHex(target.resource),
171
+ device: toHex(o.device),
172
+ root: toHex(R),
173
+ reason: String(o.reason),
174
+ targets: (await rosterTargets(o, channel, I)).map((t) => ({
175
+ resource: toHex(t.resource),
176
+ url: t.url,
177
+ })),
178
+ },
179
+ });
180
+ const roster = targetsOf(await journal.load(operation));
181
+ const results = [];
182
+ const one = async (c, X) => {
183
+ let outcome;
184
+ try {
185
+ outcome = await runControlPhase(o.storage, c, journal, operation, {
186
+ id: `revoke:${toHex(X)}`,
187
+ kind: "revoke",
188
+ resource: X,
189
+ classify: (chain) => classifyRevoke(chain.state, R, o.device),
190
+ build: (chain) => {
191
+ const sim = revokeOn(chain, o, R, o.device, o.reason);
192
+ if (sim === "self-dependent")
193
+ throw new SelfDependent();
194
+ if (sim === undefined)
195
+ throw new NotAuthorized();
196
+ return sim.bytes;
197
+ },
198
+ });
199
+ }
200
+ catch (e) {
201
+ if (e instanceof NotAuthorized)
202
+ return { resource: X, revoke: "not-authorized" };
203
+ if (e instanceof SelfDependent)
204
+ return { resource: X, revoke: "self-dependent" };
205
+ throw e;
206
+ }
207
+ if (outcome.state === "superseded")
208
+ return { resource: X, revoke: "superseded" };
209
+ if (outcome.state === "refused")
210
+ return { resource: X, revoke: "refused" };
211
+ const revoke = outcome.state === "committed-own" ? "revoked" : "complete";
212
+ if (selfRetire && o.reason === 0n)
213
+ return { resource: X, revoke };
214
+ let rotation;
215
+ try {
216
+ rotation = await rotateIfExposed({ ...o, channel: c, operation, resource: X });
217
+ }
218
+ catch (e) {
219
+ // The revocation stands; the rotation resumes from the journal (§9.1).
220
+ if (!(e instanceof IdentityUnavailable))
221
+ throw e;
222
+ rotation = { resource: X, status: "unreachable" };
223
+ }
224
+ return { resource: X, revoke, rotation };
225
+ };
226
+ const first = await one(channel, target.resource);
227
+ results.push(first);
228
+ if (first.revoke === "superseded") {
229
+ await journal.setStatus(operation, "superseded");
230
+ return { kind: "superseded", operation };
231
+ }
232
+ // §8.2: display only.
233
+ const document = !selfRetire && (first.revoke === "revoked" || first.revoke === "complete")
234
+ ? await markRevoked(o, channel, journal, operation, target.resource, o.device)
235
+ : { state: "none" };
236
+ for (const t of roster) {
237
+ try {
238
+ const c = await pool.get(o.installation.signer, t.url);
239
+ if (!c.extensions.includes(IDENTITY_EXTENSION)) {
240
+ results.push({ resource: t.resource, revoke: "unsupported" });
241
+ continue;
242
+ }
243
+ results.push(await one(c, t.resource));
244
+ }
245
+ catch (e) {
246
+ if (e instanceof IdentityUnavailable || e instanceof IdentityRefused)
247
+ results.push({ resource: t.resource, revoke: "unreachable" });
248
+ else if (e instanceof IdentityConflict)
249
+ results.push({ resource: t.resource, revoke: "conflict" });
250
+ else
251
+ throw e;
252
+ }
253
+ }
254
+ const open = document.state === "refused" ||
255
+ document.state === "unreachable" ||
256
+ results.some((r) => r.revoke === "unreachable" ||
257
+ r.revoke === "conflict" ||
258
+ r.rotation?.status === "awaiting-key-holder" ||
259
+ r.rotation?.status === "unreachable");
260
+ await journal.setStatus(operation, open ? "open" : "done");
261
+ return { kind: "revoked", operation, results: Object.freeze(results), document };
262
+ }
263
+ catch (e) {
264
+ if (e instanceof IdentityUnavailable)
265
+ return { kind: "unavailable", operation, reason: e.message };
266
+ if (e instanceof IdentityConflict)
267
+ return { kind: "conflict" };
268
+ throw e;
269
+ }
270
+ finally {
271
+ pool.closeAll();
272
+ }
273
+ }
274
+ const targetsOf = (op) => (op.intent.targets ?? []).map((t) => ({
275
+ resource: resourceId(fromHex(t.resource)),
276
+ url: t.url,
277
+ }));
278
+ /** A resumed self-retirement whose I revocation already holds: the journaled roster targets. */
279
+ async function retireElsewhere(o, pool, journal, op) {
280
+ const R = principalId(fromHex(op.intent.root));
281
+ const results = [
282
+ { resource: resourceId(fromHex(op.intent.identity)), revoke: "complete" },
283
+ ];
284
+ for (const t of targetsOf(op)) {
285
+ try {
286
+ const c = await pool.get(o.installation.signer, t.url);
287
+ const outcome = await runControlPhase(o.storage, c, journal, op.id, {
288
+ id: `revoke:${toHex(t.resource)}`,
289
+ kind: "revoke",
290
+ resource: t.resource,
291
+ classify: (chain) => classifyRevoke(chain.state, R, o.device),
292
+ build: (chain) => {
293
+ const sim = revokeOn(chain, o, R, o.device, o.reason);
294
+ if (sim === undefined || sim === "self-dependent")
295
+ throw new NotAuthorized();
296
+ return sim.bytes;
297
+ },
298
+ });
299
+ results.push({
300
+ resource: t.resource,
301
+ revoke: outcome.state === "committed-own"
302
+ ? "revoked"
303
+ : outcome.state === "refused"
304
+ ? "refused"
305
+ : outcome.state === "superseded"
306
+ ? "superseded"
307
+ : "complete",
308
+ });
309
+ }
310
+ catch (e) {
311
+ if (e instanceof NotAuthorized)
312
+ results.push({ resource: t.resource, revoke: "not-authorized" });
313
+ else if (e instanceof IdentityUnavailable || e instanceof IdentityRefused)
314
+ results.push({ resource: t.resource, revoke: "unreachable" });
315
+ else
316
+ throw e;
317
+ }
318
+ }
319
+ const open = results.some((r) => r.revoke === "unreachable");
320
+ await journal.setStatus(op.id, open ? "open" : "done");
321
+ // A self-retirement writes no display flag (§8.2).
322
+ return {
323
+ kind: "revoked",
324
+ operation: op.id,
325
+ results: Object.freeze(results),
326
+ document: { state: "none" },
327
+ };
328
+ }
329
+ async function markRevoked(o, channel, journal, operation, I, device) {
330
+ const key = principalRefOf(device);
331
+ /** I's current state, and the device's entry as a flag to write, if any. */
332
+ const current = async () => {
333
+ const chain = await syncChain(o.storage, channel, I);
334
+ const doc = o.document(I, genesisOwner(chain));
335
+ const deks = await fetchDeks(o.storage, o.secrets, channel, chain, o.installation);
336
+ const load = await loadIdentityDocument({ channel, chain, deks, doc });
337
+ const entry = doc.devices().get(key);
338
+ return { chain, doc, deks, load, entry };
339
+ };
340
+ try {
341
+ const { entry } = await current();
342
+ if (entry === undefined)
343
+ return { state: "none" };
344
+ // Built on the current state of I (a rotation after the revocation makes an
345
+ // earlier unit stale, W §19.1); a resumed operation writes a refused flag again.
346
+ // Another writer may delete the entry or set the flag meanwhile: nothing is sent.
347
+ let gone = false;
348
+ const unit = await runUnitPhase(channel, journal, operation, {
349
+ id: "display",
350
+ resource: I,
351
+ rebuildOn: ["STALE_DATA_EPOCH"],
352
+ rebuildRefused: true,
353
+ build: async () => {
354
+ const now = await current();
355
+ gone = now.entry === undefined;
356
+ if (now.entry === undefined || now.entry.revoked === true)
357
+ return null;
358
+ // The device's missing_keys hint is written only by the device (§3 AI5).
359
+ const { missing_keys: _hint, ...display } = now.entry;
360
+ return sealIdentityUnit({
361
+ storage: o.storage,
362
+ chain: now.chain,
363
+ deks: now.deks,
364
+ signer: o.installation.signer,
365
+ doc: now.doc,
366
+ latest: now.load.latest,
367
+ intents: [{ intent: "device.put", key, entry: { ...display, revoked: true } }],
368
+ });
369
+ },
370
+ });
371
+ if (unit.state === "refused")
372
+ return { state: "refused", code: unit.code ?? "refused" };
373
+ return { state: gone ? "none" : unit.state };
374
+ }
375
+ catch (e) {
376
+ // The revocation stands without its display flag; anything else is an error.
377
+ if (e instanceof IdentityUnavailable)
378
+ return { state: "unreachable" };
379
+ throw e;
380
+ }
381
+ }
382
+ /**
383
+ * §8.4 rotation policy: when the Resource holds an exposing revocation
384
+ * committed after its current epoch opened and this installation is the
385
+ * rotator, rotate once (covering every such revocation) and seal the new
386
+ * DEK to every reader; otherwise `awaiting-rotation`, or `not-needed` when
387
+ * nothing exposing happened since the last rotation.
388
+ */
389
+ export async function rotateIfExposed(o) {
390
+ // A rotation this operation already started resumes from its journaled phase: its
391
+ // KEY_EPOCH may be committed while its DEK still waits in the pending slot (§9.1).
392
+ const started = phaseOf(await new OperationJournal(o.storage).load(o.operation), `rotate:${toHex(o.resource)}`);
393
+ const at = started?.facts?.revokedAt;
394
+ if (at !== undefined && started?.state !== "awaiting-rotation")
395
+ return rotateAfter({ ...o, revokedAt: BigInt(at) });
396
+ const chain = await syncChain(o.storage, o.channel, o.resource);
397
+ const exposing = exposingRevocationsSince(chain);
398
+ const last = exposing.at(-1);
399
+ if (last === undefined)
400
+ return { resource: o.resource, status: "not-needed" };
401
+ return rotateAfter({ ...o, revokedAt: last.seq });
402
+ }
403
+ //# sourceMappingURL=revocation.js.map
@@ -0,0 +1,89 @@
1
+ import { type ResourceId } from "@openlfcp/core";
2
+ import { type RosterEntryValue } from "./document.js";
3
+ import { OperationJournal } from "./journal.js";
4
+ import { type IdentityChannel } from "./network.js";
5
+ import { type IdentityFlowOptions } from "./resource.js";
6
+ /**
7
+ * The roster of the Identity Resource (IDENTITY-PROFILE-01 §2.1): how the
8
+ * human's other installations learn a Resource (§6). An entry is written as
9
+ * its own durable phase of an operation (§9.1): a unit refused as stale
10
+ * after a rotation of `I` is written again in the current epoch (W §19.1),
11
+ * and an entry already present, by this installation or another writer,
12
+ * is not written again.
13
+ */
14
+ export type RosterWrite =
15
+ /** This installation's unit carrying the entry was accepted. */
16
+ {
17
+ readonly state: "written";
18
+ }
19
+ /** The roster already held an entry for the Resource. */
20
+ | {
21
+ readonly state: "present";
22
+ }
23
+ /** The coordinator refused the unit for another reason; the phase stays open. */
24
+ | {
25
+ readonly state: "refused";
26
+ readonly code: string;
27
+ };
28
+ /** Writes `roster[ref(resource)] = entry` in `I` as the phase `roster` of `operation`. */
29
+ export declare function writeRosterEntry(o: IdentityFlowOptions, channel: IdentityChannel, journal: OperationJournal, operation: string, identity: ResourceId, resource: ResourceId, entry: RosterEntryValue): Promise<RosterWrite>;
30
+ export interface AddToRosterOptions extends IdentityFlowOptions {
31
+ /** The Resource to list (a Resource of the human: created or joined here). */
32
+ readonly resource: ResourceId;
33
+ /** A display name for the roster entry. */
34
+ readonly name?: string;
35
+ /**
36
+ * The entry's fields; by default taken from the Resource's Control Chain:
37
+ * its Data Profile, route endpoints and coordinator (stored here, or read
38
+ * from `url`).
39
+ */
40
+ readonly entry?: RosterEntryValue;
41
+ /** The Resource's coordinator, when its chain is not stored here. */
42
+ readonly url?: string;
43
+ /** Resume this operation. */
44
+ readonly operation?: string;
45
+ }
46
+ export type AddToRosterResult =
47
+ /** This installation wrote the entry. */
48
+ {
49
+ readonly kind: "added";
50
+ readonly operation: string;
51
+ readonly resource: ResourceId;
52
+ }
53
+ /** The roster already lists the Resource (written earlier, or by another device). */
54
+ | {
55
+ readonly kind: "present";
56
+ readonly operation: string;
57
+ readonly resource: ResourceId;
58
+ }
59
+ /** This installation cannot write `I` (`holds(D, 2)` is false, e.g. a Read device, §7.2). */
60
+ | {
61
+ readonly kind: "needs-writer";
62
+ }
63
+ /** The coordinator refused the unit: the operation stays open; resume it with `operation`. */
64
+ | {
65
+ readonly kind: "document-incomplete";
66
+ readonly operation: string;
67
+ readonly resource: ResourceId;
68
+ readonly code: string;
69
+ } | {
70
+ readonly kind: "conflict";
71
+ } | {
72
+ readonly kind: "unavailable";
73
+ readonly operation?: string;
74
+ readonly reason: string;
75
+ };
76
+ /**
77
+ * Adds a Resource to the roster of the Identity Resource as one durable
78
+ * operation (IDENTITY-PROFILE-01 §2.1, §6, §9): the entry the human's other
79
+ * installations read to reach the Resource (pairing step 10, reconciliation
80
+ * §7.1). For a Resource created or joined after `I` was filled (§5.1), for
81
+ * example a section created later on any personal device. Idempotent: an
82
+ * entry already present is not written again; a unit refused as stale
83
+ * after a rotation of `I` is written again in the current epoch; any other
84
+ * refusal leaves the operation open. Only a writer of `I` (the root or a
85
+ * personal device holding data/write there) may add entries (§3 AI4: a
86
+ * pairing or service device never writes the roster).
87
+ */
88
+ export declare function addToRoster(o: AddToRosterOptions): Promise<AddToRosterResult>;
89
+ //# sourceMappingURL=roster.d.ts.map
@@ -0,0 +1,130 @@
1
+ import { fromHex, LfcpError, resourceId, secureRandom, toHex, } from "@openlfcp/core";
2
+ import { ABILITY, hasAbility } from "@openlfcp/wire";
3
+ import { loadControlChain } from "../storage.js";
4
+ import { fetchDeks, genesisOwner, IdentityConflict, loadIdentityDocument, runUnitPhase, sealIdentityUnit, syncChain, } from "./context.js";
5
+ import { resourceRef } from "./document.js";
6
+ import { newOperationId, OperationJournal } from "./journal.js";
7
+ import { ChannelPool, IdentityRefused, IdentityUnavailable, } from "./network.js";
8
+ import { identityTarget } from "./resource.js";
9
+ /** Writes `roster[ref(resource)] = entry` in `I` as the phase `roster` of `operation`. */
10
+ export async function writeRosterEntry(o, channel, journal, operation, identity, resource, entry) {
11
+ const me = o.installation.signer;
12
+ const openI = async () => {
13
+ const chain = await syncChain(o.storage, channel, identity);
14
+ const doc = o.document(identity, genesisOwner(chain));
15
+ const deks = await fetchDeks(o.storage, o.secrets, channel, chain, o.installation);
16
+ const load = await loadIdentityDocument({ channel, chain, deks, doc });
17
+ return { chain, doc, deks, load };
18
+ };
19
+ if ((await openI()).doc.roster().has(resourceRef(resource)))
20
+ return { state: "present" };
21
+ const r = await runUnitPhase(channel, journal, operation, {
22
+ id: "roster",
23
+ resource: identity,
24
+ // A unit refused after a rotation of I is written again in the new epoch (W §19.1).
25
+ rebuildOn: ["STALE_DATA_EPOCH"],
26
+ // A resumed operation whose unit was refused writes the entry again on the
27
+ // current document, previous unit and actor sequence: the refused bytes may
28
+ // name a previous unit this actor has since moved past (W §26.2). Exact
29
+ // bytes are re-sent only after an unknown outcome (§9.1).
30
+ rebuildRefused: true,
31
+ build: async () => {
32
+ const { chain, doc, deks, load } = await openI();
33
+ // Another writer may have listed the Resource since the last check (a stale
34
+ // retry, a resume): its entry stands and the phase is complete (§9.2).
35
+ if (doc.roster().has(resourceRef(resource)))
36
+ return null;
37
+ return sealIdentityUnit({
38
+ storage: o.storage,
39
+ chain,
40
+ deks,
41
+ signer: me,
42
+ doc,
43
+ latest: load.latest,
44
+ intents: [{ intent: "roster.put", key: resourceRef(resource), entry }],
45
+ });
46
+ },
47
+ });
48
+ if (r.state === "committed-own")
49
+ return { state: "written" };
50
+ if (r.state === "complete")
51
+ return { state: "present" };
52
+ return { state: "refused", code: r.code ?? "refused" };
53
+ }
54
+ /**
55
+ * Adds a Resource to the roster of the Identity Resource as one durable
56
+ * operation (IDENTITY-PROFILE-01 §2.1, §6, §9): the entry the human's other
57
+ * installations read to reach the Resource (pairing step 10, reconciliation
58
+ * §7.1). For a Resource created or joined after `I` was filled (§5.1), for
59
+ * example a section created later on any personal device. Idempotent: an
60
+ * entry already present is not written again; a unit refused as stale
61
+ * after a rotation of `I` is written again in the current epoch; any other
62
+ * refusal leaves the operation open. Only a writer of `I` (the root or a
63
+ * personal device holding data/write there) may add entries (§3 AI4: a
64
+ * pairing or service device never writes the roster).
65
+ */
66
+ export async function addToRoster(o) {
67
+ const target = await identityTarget(o);
68
+ const me = o.installation.signer;
69
+ const journal = new OperationJournal(o.storage);
70
+ const pool = new ChannelPool(o.network);
71
+ let operation = o.operation;
72
+ try {
73
+ const channel = await pool.get(me, target.url);
74
+ const I = await syncChain(o.storage, channel, target.resource);
75
+ if (!hasAbility(I.state, me.descriptor.principalId, ABILITY.DATA_WRITE))
76
+ return { kind: "needs-writer" };
77
+ if (operation === undefined || (await journal.get(operation)) === undefined) {
78
+ const entry = o.entry ?? (await entryOf(o, pool, o.resource));
79
+ operation = operation ?? newOperationId("add-to-roster", secureRandom(8));
80
+ await journal.begin({
81
+ id: operation,
82
+ kind: "add-to-roster",
83
+ createdAt: o.now(),
84
+ intent: {
85
+ identity: toHex(target.resource),
86
+ resource: toHex(o.resource),
87
+ entry: { ...entry, ...(o.name === undefined ? {} : { name: o.name }) },
88
+ },
89
+ });
90
+ }
91
+ const op = await journal.load(operation);
92
+ const resource = resourceId(fromHex(op.intent.resource));
93
+ const written = await writeRosterEntry(o, channel, journal, op.id, target.resource, resource, op.intent.entry);
94
+ if (written.state === "refused")
95
+ return { kind: "document-incomplete", operation: op.id, resource, code: written.code };
96
+ await journal.setStatus(op.id, "done");
97
+ return { kind: written.state === "written" ? "added" : "present", operation: op.id, resource };
98
+ }
99
+ catch (e) {
100
+ if (e instanceof IdentityUnavailable || e instanceof IdentityRefused)
101
+ return {
102
+ kind: "unavailable",
103
+ ...(operation === undefined ? {} : { operation }),
104
+ reason: e.message,
105
+ };
106
+ if (e instanceof IdentityConflict)
107
+ return { kind: "conflict" };
108
+ throw e;
109
+ }
110
+ finally {
111
+ pool.closeAll();
112
+ }
113
+ }
114
+ /** The roster entry of a Resource from its Control Chain (W §15, §20): profile, routes, coordinator. */
115
+ async function entryOf(o, pool, resource) {
116
+ const stored = await loadControlChain(o.storage, resource);
117
+ let chain;
118
+ if (stored?.kind === "linear")
119
+ chain = stored;
120
+ else if (o.url !== undefined)
121
+ chain = await syncChain(o.storage, await pool.get(o.installation.signer, o.url), resource);
122
+ else
123
+ throw new LfcpError("MISSING_DEPENDENCY", "the Resource's chain is not stored here: give its entry or its coordinator URL");
124
+ return {
125
+ profile: chain.state.dataProfile,
126
+ endpoints: chain.state.route.endpoints.map((e) => e.url),
127
+ coordinator: chain.state.route.coordinatorUrl,
128
+ };
129
+ }
130
+ //# sourceMappingURL=roster.js.map