@interop/wallet-core 0.101.1 → 0.103.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +33 -17
  2. package/dist/audiences/audienceGrant.d.ts +49 -0
  3. package/dist/audiences/audienceGrant.d.ts.map +1 -0
  4. package/dist/audiences/audienceGrant.js +46 -0
  5. package/dist/audiences/audienceGrant.js.map +1 -0
  6. package/dist/audiences/index.d.ts +10 -0
  7. package/dist/audiences/index.d.ts.map +1 -0
  8. package/dist/audiences/index.js +10 -0
  9. package/dist/audiences/index.js.map +1 -0
  10. package/dist/clientAnnex/grantRevocation.d.ts +0 -10
  11. package/dist/clientAnnex/grantRevocation.d.ts.map +1 -1
  12. package/dist/clientAnnex/grantRevocation.js +1 -23
  13. package/dist/clientAnnex/grantRevocation.js.map +1 -1
  14. package/dist/clientAnnex/index.d.ts +1 -1
  15. package/dist/clientAnnex/index.d.ts.map +1 -1
  16. package/dist/clientAnnex/index.js +1 -1
  17. package/dist/clientAnnex/index.js.map +1 -1
  18. package/dist/clientAnnex/log.d.ts +2 -1
  19. package/dist/clientAnnex/log.d.ts.map +1 -1
  20. package/dist/clientAnnex/log.js +5 -2
  21. package/dist/clientAnnex/log.js.map +1 -1
  22. package/dist/clients/rosterPolicy.d.ts.map +1 -1
  23. package/dist/clients/rosterPolicy.js +3 -1
  24. package/dist/clients/rosterPolicy.js.map +1 -1
  25. package/dist/connections/didKey.d.ts +27 -0
  26. package/dist/connections/didKey.d.ts.map +1 -1
  27. package/dist/connections/didKey.js +42 -6
  28. package/dist/connections/didKey.js.map +1 -1
  29. package/dist/connections/entry.d.ts +95 -13
  30. package/dist/connections/entry.d.ts.map +1 -1
  31. package/dist/connections/entry.js +102 -16
  32. package/dist/connections/entry.js.map +1 -1
  33. package/dist/connections/grants.d.ts +225 -3
  34. package/dist/connections/grants.d.ts.map +1 -1
  35. package/dist/connections/grants.js +293 -8
  36. package/dist/connections/grants.js.map +1 -1
  37. package/dist/connections/index.d.ts +15 -8
  38. package/dist/connections/index.d.ts.map +1 -1
  39. package/dist/connections/index.js +13 -6
  40. package/dist/connections/index.js.map +1 -1
  41. package/dist/connections/read.d.ts.map +1 -1
  42. package/dist/connections/read.js +2 -1
  43. package/dist/connections/read.js.map +1 -1
  44. package/dist/connections/resourceId.d.ts +33 -0
  45. package/dist/connections/resourceId.d.ts.map +1 -1
  46. package/dist/connections/resourceId.js +88 -8
  47. package/dist/connections/resourceId.js.map +1 -1
  48. package/dist/connections/upsert.d.ts +219 -7
  49. package/dist/connections/upsert.d.ts.map +1 -1
  50. package/dist/connections/upsert.js +628 -41
  51. package/dist/connections/upsert.js.map +1 -1
  52. package/dist/connections/writers.js +2 -1
  53. package/dist/connections/writers.js.map +1 -1
  54. package/dist/descriptors/index.d.ts +5 -0
  55. package/dist/descriptors/index.d.ts.map +1 -1
  56. package/dist/descriptors/index.js +5 -0
  57. package/dist/descriptors/index.js.map +1 -1
  58. package/dist/descriptors/logSource.d.ts +9 -4
  59. package/dist/descriptors/logSource.d.ts.map +1 -1
  60. package/dist/descriptors/logSource.js +13 -6
  61. package/dist/descriptors/logSource.js.map +1 -1
  62. package/dist/jsonObject.d.ts +16 -0
  63. package/dist/jsonObject.d.ts.map +1 -0
  64. package/dist/jsonObject.js +18 -0
  65. package/dist/jsonObject.js.map +1 -0
  66. package/dist/keys/index.d.ts +13 -9
  67. package/dist/keys/index.d.ts.map +1 -1
  68. package/dist/keys/index.js +11 -7
  69. package/dist/keys/index.js.map +1 -1
  70. package/dist/keys/userKeyCascade.d.ts +46 -0
  71. package/dist/keys/userKeyCascade.d.ts.map +1 -1
  72. package/dist/keys/userKeyCascade.js +153 -65
  73. package/dist/keys/userKeyCascade.js.map +1 -1
  74. package/dist/keys/userKeyRoster.d.ts +60 -7
  75. package/dist/keys/userKeyRoster.d.ts.map +1 -1
  76. package/dist/keys/userKeyRoster.js +165 -47
  77. package/dist/keys/userKeyRoster.js.map +1 -1
  78. package/dist/keys/userKeyRosterCascade.d.ts.map +1 -1
  79. package/dist/keys/userKeyRosterCascade.js +1 -0
  80. package/dist/keys/userKeyRosterCascade.js.map +1 -1
  81. package/dist/menders/ids.d.ts +1 -1
  82. package/dist/menders/ids.d.ts.map +1 -1
  83. package/dist/menders/ids.js +20 -4
  84. package/dist/menders/ids.js.map +1 -1
  85. package/dist/space/collections.d.ts +23 -3
  86. package/dist/space/collections.d.ts.map +1 -1
  87. package/dist/space/collections.js +26 -2
  88. package/dist/space/collections.js.map +1 -1
  89. package/dist/space/inboxMessage.d.ts +47 -0
  90. package/dist/space/inboxMessage.d.ts.map +1 -0
  91. package/dist/space/inboxMessage.js +86 -0
  92. package/dist/space/inboxMessage.js.map +1 -0
  93. package/dist/space/index.d.ts +6 -1
  94. package/dist/space/index.d.ts.map +1 -1
  95. package/dist/space/index.js +5 -1
  96. package/dist/space/index.js.map +1 -1
  97. package/dist/sync/contactsConflict.d.ts +23 -10
  98. package/dist/sync/contactsConflict.d.ts.map +1 -1
  99. package/dist/sync/contactsConflict.js +40 -21
  100. package/dist/sync/contactsConflict.js.map +1 -1
  101. package/dist/webvh/didWebProjection.d.ts +38 -5
  102. package/dist/webvh/didWebProjection.d.ts.map +1 -1
  103. package/dist/webvh/didWebProjection.js +35 -22
  104. package/dist/webvh/didWebProjection.js.map +1 -1
  105. package/dist/webvh/index.d.ts +4 -3
  106. package/dist/webvh/index.d.ts.map +1 -1
  107. package/dist/webvh/index.js +4 -3
  108. package/dist/webvh/index.js.map +1 -1
  109. package/dist/webvh/standingZcap.d.ts +19 -2
  110. package/dist/webvh/standingZcap.d.ts.map +1 -1
  111. package/dist/webvh/standingZcap.js +31 -3
  112. package/dist/webvh/standingZcap.js.map +1 -1
  113. package/package.json +11 -5
@@ -1,12 +1,33 @@
1
1
  /*!
2
2
  * Copyright (c) 2026 Interop Alliance. All rights reserved.
3
3
  */
4
+ /**
5
+ * A capability to record, read by the codec, or a `TypeError` naming the
6
+ * write when it lacks an id, a controller, or an invocation target.
7
+ *
8
+ * @param options {object}
9
+ * @param options.value {object}
10
+ * @param options.what {string} the write's name, for the message
11
+ * @returns {ConnectionZcap}
12
+ */
13
+ function requiredConnectionZcap({ value, what }) {
14
+ const zcap = parseConnectionZcap(value);
15
+ if (zcap === undefined) {
16
+ throw new TypeError(`A ${what} must carry a capability with an id, a controller, and an ` +
17
+ 'invocation target.');
18
+ }
19
+ return zcap;
20
+ }
4
21
  /**
5
22
  * The upsert helpers every flow writes a party's entry through:
6
23
  * `recordGrants` at consent, `removeGrants` at an unshare or a torn
7
24
  * consent's rollback, `retireConnection` when a relationship ends,
8
- * `unretireConnection` when one resumes without a consent, and
9
- * `setConnectionLabel` for the user's rename and the enrollment-time name.
25
+ * `unretireConnection` when one resumes without a consent,
26
+ * `setConnectionLabel` for the user's rename and the enrollment-time name,
27
+ * and the agent-connection helpers over `grantsReceived` and `outbox`:
28
+ * `recordReceivedGrants`, `recordRenewedGrants` (the renewal's one write),
29
+ * `settleOutboxItem`, `pruneSupersededGrants`, `clearReceivedGrants`, and
30
+ * `markDeclined`.
10
31
  *
11
32
  * Each runs `writeConnection`, the one bounded compare-and-swap loop at a
12
33
  * party's resource id, which the writer arm (`writers.ts`) runs too. It reads
@@ -22,15 +43,18 @@
22
43
  */
23
44
  import { NotSupportedError } from '@interop/was-client';
24
45
  import { errorNameOf } from '../errorName.js';
46
+ import { isJsonObject } from '../jsonObject.js';
25
47
  import { normalizeDisplayName } from '../labelText.js';
48
+ import { REVOCATION_CLOCK_SKEW_MS } from '../webvh/index.js';
26
49
  import { signingKeyMultibaseOfDid } from './didKey.js';
27
- import { CONNECTION_ENTRY_VERSION, GRANT_KINDS, isWritableConnectionEntry, newConnectionEntry, parseConnectionEntry, parseConnectionZcap } from './entry.js';
50
+ import { CONNECTION_ENTRY_VERSION, GRANT_KINDS, RECEIVED_GRANT_KINDS, isWritableConnectionEntry, newConnectionEntry, parseConnectionEntry, parseConnectionZcap } from './entry.js';
28
51
  import { ConnectionKindMismatchError } from './errors.js';
29
- import { connectionGrants, isTargetInSpace } from './grants.js';
52
+ import { allowsAction, connectionGrants, grantScopeKey, latestIndexPerScope, isTargetInSpace, liveInboxChannel, receivedGrantLapsed, receivedGrants } from './grants.js';
30
53
  import { claimedResourceId } from './read.js';
31
- import { connectionResourceId } from './resourceId.js';
54
+ import { connectionResourceId, verifyConnectionSeedTag } from './resourceId.js';
32
55
  const PRECONDITION_FAILED_ERROR_NAME = 'PreconditionFailedError';
33
56
  const WRITE_ATTEMPTS = 3;
57
+ const STRICT_ISO_8601 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
34
58
  /**
35
59
  * Whether a store write lost a race. Matched by name, since the store is an
36
60
  * injected seam that may raise from a second copy of was-client.
@@ -188,24 +212,19 @@ function assertKind({ entry, kind }) {
188
212
  }
189
213
  }
190
214
  /**
191
- * The capability of one grant to record, checked strictly: its known members
192
- * well-formed, its `controller` the party's DID, its `invocationTarget` under
193
- * this Space, its kind one this build writes. The target check is the same
194
- * one every reader applies, so a grant that is stored is a grant the
195
- * revocation index will see.
215
+ * A capability the wallet delegated, checked strictly: its known members
216
+ * well-formed, its `controller` the party's DID, its `invocationTarget`
217
+ * under this Space. The target check is the same one every reader applies,
218
+ * so a grant that is stored is a grant the revocation index will see.
196
219
  *
197
220
  * @param options {object}
198
221
  * @param options.did {string}
199
222
  * @param options.spaceUrl {string}
200
- * @param options.grant {object}
223
+ * @param options.zcap {object}
201
224
  * @returns {ConnectionZcap}
202
225
  */
203
- function checkedGrant({ did, spaceUrl, grant }) {
204
- const zcap = parseConnectionZcap(grant.zcap);
205
- if (zcap === undefined) {
206
- throw new TypeError('A grant to record must carry a capability with an id, a controller, ' +
207
- 'and an invocation target.');
208
- }
226
+ function checkedDelegatedZcap({ did, spaceUrl, zcap: value }) {
227
+ const zcap = requiredConnectionZcap({ value, what: 'grant to record' });
209
228
  if (zcap.controller !== did) {
210
229
  throw new TypeError(`A grant to record must be delegated to the party "${did}"; its ` +
211
230
  `controller is "${zcap.controller}".`);
@@ -214,19 +233,182 @@ function checkedGrant({ did, spaceUrl, grant }) {
214
233
  throw new TypeError('A grant to record must target this Space; its invocation target is ' +
215
234
  `"${zcap.invocationTarget}".`);
216
235
  }
236
+ return zcap;
237
+ }
238
+ /**
239
+ * The capability of one grant to record: {@link checkedDelegatedZcap}, and
240
+ * its kind one this build writes.
241
+ *
242
+ * @param options {object}
243
+ * @param options.did {string}
244
+ * @param options.spaceUrl {string}
245
+ * @param options.grant {object}
246
+ * @returns {ConnectionZcap}
247
+ */
248
+ function checkedGrant({ did, spaceUrl, grant }) {
249
+ const zcap = checkedDelegatedZcap({ did, spaceUrl, zcap: grant.zcap });
217
250
  if (!GRANT_KINDS.includes(grant.grantKind)) {
218
251
  throw new TypeError(`Unknown grant kind "${grant.grantKind}".`);
219
252
  }
220
253
  return zcap;
221
254
  }
255
+ /**
256
+ * A capability's `expires` as epoch milliseconds. The codec has already
257
+ * checked the member is present and parses.
258
+ *
259
+ * @param zcap {ConnectionZcap}
260
+ * @returns {number}
261
+ */
262
+ function expiresAtOf(zcap) {
263
+ return Date.parse(zcap.expires);
264
+ }
265
+ /**
266
+ * Whether a capability's `expires` is past by more than the revocation clock
267
+ * skew.
268
+ *
269
+ * @param options {object}
270
+ * @param options.zcap {ConnectionZcap}
271
+ * @param options.now {Date}
272
+ * @returns {boolean}
273
+ */
274
+ function expiredBeyondSkew({ zcap, now }) {
275
+ return expiresAtOf(zcap) + REVOCATION_CLOCK_SKEW_MS <= now.getTime();
276
+ }
277
+ /**
278
+ * The index of the latest record in each scope of a list of grant records
279
+ * ({@link latestIndexPerScope} over their capabilities).
280
+ *
281
+ * @param options {object}
282
+ * @param options.grants {ConnectionGrantRecord[]}
283
+ * @returns {Map<string, number>} scope key to the latest record's index
284
+ */
285
+ function latestPerScope({ grants }) {
286
+ return latestIndexPerScope({ zcaps: grants.map(grant => grant.zcap) });
287
+ }
288
+ /**
289
+ * The capabilities an envelope carries (`message.object.zcaps`), or
290
+ * `undefined` when it carries no such list.
291
+ *
292
+ * @param message {object} the envelope, verbatim
293
+ * @returns {unknown[] | undefined}
294
+ */
295
+ function envelopeZcaps(message) {
296
+ const { object } = message;
297
+ if (!isJsonObject(object) || !Array.isArray(object.zcaps)) {
298
+ return undefined;
299
+ }
300
+ return object.zcaps;
301
+ }
302
+ /**
303
+ * Checks an envelope a write queues beside the records it carries: its
304
+ * `object.zcaps` must be a non-empty list whose zcap ids are exactly
305
+ * `zcapIds`, so a reused message cannot queue a capability the write does
306
+ * not record. Refuses with a `TypeError`.
307
+ *
308
+ * @param options {object}
309
+ * @param options.message {object} the envelope, verbatim
310
+ * @param options.zcapIds {Iterable<string>} the ids the write records
311
+ * @param options.what {string} the write's name, for the message
312
+ */
313
+ function assertEnvelopeCarries({ message, zcapIds, what }) {
314
+ const zcaps = envelopeZcaps(message);
315
+ if (zcaps === undefined || zcaps.length === 0) {
316
+ throw new TypeError(`A ${what} message must carry a non-empty \`object.zcaps\` list.`);
317
+ }
318
+ const messageIds = new Set(zcaps.map(value => parseConnectionZcap(value)?.id));
319
+ const recordedIds = new Set(zcapIds);
320
+ if (messageIds.size !== recordedIds.size ||
321
+ [...messageIds].some(id => id === undefined || !recordedIds.has(id))) {
322
+ throw new TypeError(`A ${what} message's zcaps must be exactly the recorded zcaps.`);
323
+ }
324
+ }
325
+ /**
326
+ * Refuses a `seed` without its `seedTag` (or the reverse) and a tag that does
327
+ * not verify under `hmacKey` with a `TypeError`, before anything is read.
328
+ * A write carrying neither passes.
329
+ *
330
+ * @param options {object}
331
+ * @param options.hmacKey {ConnectionIdKey}
332
+ * @param [options.seed] {string}
333
+ * @param [options.seedTag] {string}
334
+ * @returns {Promise<void>}
335
+ */
336
+ async function assertPairwiseSeed({ hmacKey, seed, seedTag }) {
337
+ if (seed === undefined && seedTag === undefined) {
338
+ return;
339
+ }
340
+ if (seed === undefined || seedTag === undefined) {
341
+ throw new TypeError('A connection seed is written with its tag, or not.');
342
+ }
343
+ if (!(await verifyConnectionSeedTag({ hmacKey, seed, seedTag }))) {
344
+ throw new TypeError("The connection seed's tag does not verify under this directory's key.");
345
+ }
346
+ }
347
+ /**
348
+ * The outbox after queueing `message`: the pending items with every zcap
349
+ * `supersedes` admits removed from their envelopes (an envelope left with
350
+ * none is dropped), then the new envelope stamped `createdAt: stamp`. A
351
+ * zcap the codec cannot parse is kept.
352
+ *
353
+ * @param options {object}
354
+ * @param options.outbox {Array<ConnectionOutboxItem>} the pending items
355
+ * @param options.message {object} the envelope to queue, verbatim
356
+ * @param options.stamp {string} the queueing time, ISO 8601 UTC
357
+ * @param options.supersedes {Function} whether the new envelope supersedes
358
+ * a pending zcap
359
+ * @returns {Array<ConnectionOutboxItem>}
360
+ */
361
+ function supersedeAndQueueEnvelope({ outbox, message, stamp, supersedes }) {
362
+ const queued = [];
363
+ for (const item of outbox) {
364
+ const zcaps = envelopeZcaps(item.message);
365
+ if (zcaps === undefined) {
366
+ queued.push(item);
367
+ continue;
368
+ }
369
+ const kept = zcaps.filter(value => {
370
+ const zcap = parseConnectionZcap(value);
371
+ return zcap === undefined || !supersedes(zcap);
372
+ });
373
+ if (kept.length === zcaps.length) {
374
+ queued.push(item);
375
+ }
376
+ else if (kept.length > 0) {
377
+ const itemObject = item.message.object;
378
+ queued.push({
379
+ ...item,
380
+ message: { ...item.message, object: { ...itemObject, zcaps: kept } }
381
+ });
382
+ }
383
+ }
384
+ queued.push({ message, createdAt: stamp });
385
+ return queued;
386
+ }
222
387
  /**
223
388
  * Records a consent on the party's entry: creates the entry, or merges into
224
389
  * it. Each new grant is added by its capability id (one already recorded is
225
390
  * left as stored), `name` is set when it passes the display-name rule (left
226
391
  * as it was otherwise), `origin`, `url`, and `appKey` are set when given,
227
- * `retired` is cleared, and `lastSeen` moves to `now`. `firstSeen` is set
228
- * only on create, and `label` is never touched. A zero-grant consent writes
229
- * too, so an App Connect app with no grants is indexed by its key.
392
+ * `retired` and `declined` are cleared, and `lastSeen` moves to `now`.
393
+ * `firstSeen` is set only on create, and `label` is never touched. Every
394
+ * grant of one call carries the same `grantedAt`. A zero-grant consent
395
+ * writes too, so an App Connect app with no grants is indexed by its key.
396
+ *
397
+ * A `message` (an envelope carrying exactly the grants of this call) is
398
+ * queued on `outbox` in the same write, stamped `now`, so a restore that
399
+ * records fresh grants and owes the party their envelope has no tear between
400
+ * the two. Each zcap of this call is first removed from every pending
401
+ * envelope that carries it (an envelope left with none is dropped), so no
402
+ * two pending envelopes name one zcap id. A consent torn after its write
403
+ * lands and re-run therefore leaves one pending envelope, the re-run's, and
404
+ * a grant recorded earlier with no envelope is told on the first call that
405
+ * brings one.
406
+ *
407
+ * A `seed` and its `seedTag` (both or neither) are written when the entry
408
+ * carries no seed, and left as stored when it carries the same one. An entry
409
+ * carrying a different seed is refused with an `Error`, since the seed is
410
+ * written once. A tag that does not verify under `hmacKey` is refused with a
411
+ * `TypeError` before anything is read.
230
412
  *
231
413
  * Refuses an entry of another kind with {@link ConnectionKindMismatchError},
232
414
  * and a grant delegated to another party or targeting another Space with a
@@ -246,10 +428,16 @@ function checkedGrant({ did, spaceUrl, grant }) {
246
428
  * @param [options.origin] {string} the attested Web origin (App Connect)
247
429
  * @param [options.url] {string} the canonical app URL (App Connect)
248
430
  * @param [options.appKey] {string} the app-key Resource id (App Connect)
431
+ * @param [options.seed] {string} the wallet's pairwise seed toward the
432
+ * party, base64url with no padding
433
+ * @param [options.seedTag] {string} the seed's tag (`connectionSeedTag`)
434
+ * @param [options.message] {object} an envelope to queue on `outbox` with
435
+ * the consent, carrying exactly the grants of this call
249
436
  * @param [options.now] {Date}
250
437
  * @returns {Promise<ConnectionWriteResult>}
251
438
  */
252
- export async function recordGrants({ store, hmacKey, did, kind, spaceUrl, grants, name, origin, url, appKey, now = new Date() }) {
439
+ export async function recordGrants({ store, hmacKey, did, kind, spaceUrl, grants, name, origin, url, appKey, seed, seedTag, message, now = new Date() }) {
440
+ await assertPairwiseSeed({ hmacKey, seed, seedTag });
253
441
  const stamp = now.toISOString();
254
442
  const recorded = [];
255
443
  const seen = new Set();
@@ -264,6 +452,13 @@ export async function recordGrants({ store, hmacKey, did, kind, spaceUrl, grants
264
452
  recorded.push({ zcap, grantKind: grant.grantKind, grantedAt: stamp });
265
453
  }
266
454
  }
455
+ if (message !== undefined) {
456
+ assertEnvelopeCarries({
457
+ message,
458
+ zcapIds: recorded.map(grant => grant.zcap.id),
459
+ what: 'consent'
460
+ });
461
+ }
267
462
  const normalizedName = name === undefined ? undefined : normalizeDisplayName({ value: name });
268
463
  const declared = {
269
464
  ...(normalizedName !== undefined && { name: normalizedName }),
@@ -276,27 +471,48 @@ export async function recordGrants({ store, hmacKey, did, kind, spaceUrl, grants
276
471
  hmacKey,
277
472
  did,
278
473
  change(current) {
474
+ const stored = current?.entry;
475
+ const held = new Set(stored?.grants.map(grant => grant.zcap.id));
476
+ let body;
279
477
  if (current === undefined) {
280
- return {
281
- body: {
282
- ...newConnectionEntry({ kind, did, now }),
283
- ...declared,
284
- grants: recorded
285
- }
478
+ body = {
479
+ ...newConnectionEntry({ kind, did, now }),
480
+ ...declared,
481
+ grants: recorded
482
+ };
483
+ }
484
+ else {
485
+ assertKind({ entry: current.entry, kind });
486
+ body = {
487
+ ...current.body,
488
+ ...declared,
489
+ lastSeen: stamp,
490
+ grants: [
491
+ ...current.entry.grants,
492
+ ...recorded.filter(grant => !held.has(grant.zcap.id))
493
+ ]
494
+ };
495
+ delete body.retired;
496
+ delete body.declined;
497
+ }
498
+ if (seed !== undefined && stored?.seed === undefined) {
499
+ body = { ...body, seed, seedTag };
500
+ }
501
+ else if (seed !== undefined && stored?.seed !== seed) {
502
+ throw new Error('The connections entry already carries a different pairwise seed; ' +
503
+ 'a seed is written once.');
504
+ }
505
+ if (message !== undefined) {
506
+ body = {
507
+ ...body,
508
+ outbox: supersedeAndQueueEnvelope({
509
+ outbox: stored?.outbox ?? [],
510
+ message,
511
+ stamp,
512
+ supersedes: zcap => seen.has(zcap.id)
513
+ })
286
514
  };
287
515
  }
288
- assertKind({ entry: current.entry, kind });
289
- const held = new Set(current.entry.grants.map(grant => grant.zcap.id));
290
- const body = {
291
- ...current.body,
292
- ...declared,
293
- lastSeen: stamp,
294
- grants: [
295
- ...current.entry.grants,
296
- ...recorded.filter(grant => !held.has(grant.zcap.id))
297
- ]
298
- };
299
- delete body.retired;
300
516
  return { body };
301
517
  }
302
518
  });
@@ -334,8 +550,9 @@ export async function removeGrants({ store, hmacKey, did, zcapIds }) {
334
550
  }
335
551
  /**
336
552
  * Retires the party's entry, of any kind, once a revocation has handled its
337
- * grants: empties `grants`, drops `appKey`, and stamps `retired`. `name`,
338
- * `label`, `origin`, `url`, `firstSeen`, `lastSeen`, and `writers` stay, so a
553
+ * grants: empties `grants`, `grantsReceived`, and `outbox`, drops `appKey`,
554
+ * and stamps `retired`. `name`, `label`, `origin`, `url`, `firstSeen`,
555
+ * `lastSeen`, `writers`, `seed`, `seedTag`, and `declined` stay, so a
339
556
  * departed party keeps resolving. An entry already retired with nothing left
340
557
  * to empty writes nothing, and an absent entry writes nothing.
341
558
  *
@@ -381,12 +598,16 @@ export async function retireConnection({ store, hmacKey, did, handledZcapIds, sp
381
598
  const { entry } = current;
382
599
  if (entry.retired !== undefined &&
383
600
  entry.grants.length === 0 &&
601
+ entry.grantsReceived.length === 0 &&
602
+ entry.outbox.length === 0 &&
384
603
  entry.appKey === undefined) {
385
604
  return { stop: 'unchanged' };
386
605
  }
387
606
  const body = {
388
607
  ...current.body,
389
608
  grants: [],
609
+ grantsReceived: [],
610
+ outbox: [],
390
611
  retired: entry.retired ?? now.toISOString()
391
612
  };
392
613
  delete body.appKey;
@@ -515,4 +736,370 @@ export async function setConnectionLabel({ store, hmacKey, did, kind, label, nam
515
736
  }
516
737
  });
517
738
  }
739
+ /**
740
+ * Records the capabilities a party handed the wallet on its entry's
741
+ * `grantsReceived`. Each new record is merged in turn: one whose capability
742
+ * id is already held is left as stored, so the drain's re-run is a no-op;
743
+ * one with the `grantKind` and `invocationTarget` of a held record replaces
744
+ * it when its `expires` is strictly later or when the held record has
745
+ * lapsed as of `now` (at the earlier of its `expires` and `receivedAt` plus
746
+ * `channelMaxLifetimeMs`), and leaves the held record otherwise; any other
747
+ * is appended. Every record whose `expires`
748
+ * is past by more than the revocation clock skew is then dropped. A new
749
+ * record carries `receivedAt: now`.
750
+ *
751
+ * Each grant is checked strictly before anything is read, refused with a
752
+ * `TypeError`: its known members well-formed, its `controller` the wallet's
753
+ * pairwise DID toward the party, its `invocationTarget` outside this Space,
754
+ * its kind one of `RECEIVED_GRANT_KINDS`, an `'inbox'` grant allowing POST,
755
+ * and an `expires` in strict ISO 8601 form, with any number of fractional
756
+ * second digits (a format one engine's `Date.parse` accepts and another's
757
+ * refuses would make the entry unparseable there). A retired entry is refused
758
+ * with an `Error`, and an absent entry writes nothing.
759
+ *
760
+ * @param options {object}
761
+ * @param options.store {ConnectionsStore}
762
+ * @param options.hmacKey {ConnectionIdKey}
763
+ * @param options.did {string} the party's DID
764
+ * @param options.pairwiseDid {string} the wallet's pairwise did:key toward
765
+ * the party, every received grant's controller
766
+ * @param options.spaceUrl {string} this Space's container URL, which no
767
+ * received grant may target
768
+ * @param options.grants {Array<object>} `{ zcap, grantKind }` per grant
769
+ * @param options.channelMaxLifetimeMs {number} how long after `receivedAt`
770
+ * a received record lapses at the latest
771
+ * @param [options.now] {Date}
772
+ * @returns {Promise<ConnectionWriteResult>}
773
+ */
774
+ export async function recordReceivedGrants({ store, hmacKey, did, pairwiseDid, spaceUrl, grants, channelMaxLifetimeMs, now = new Date() }) {
775
+ const stamp = now.toISOString();
776
+ const incoming = [];
777
+ for (const grant of grants) {
778
+ const zcap = requiredConnectionZcap({
779
+ value: grant.zcap,
780
+ what: 'received grant'
781
+ });
782
+ if (zcap.controller !== pairwiseDid) {
783
+ throw new TypeError(`A received grant must be delegated to the pairwise DID ` +
784
+ `"${pairwiseDid}"; its controller is "${zcap.controller}".`);
785
+ }
786
+ if (isTargetInSpace({ target: zcap.invocationTarget, spaceUrl })) {
787
+ throw new TypeError('A received grant must target the party, not this Space; its ' +
788
+ `invocation target is "${zcap.invocationTarget}".`);
789
+ }
790
+ if (!RECEIVED_GRANT_KINDS.includes(grant.grantKind)) {
791
+ throw new TypeError(`Unknown received grant kind "${grant.grantKind}".`);
792
+ }
793
+ if (grant.grantKind === 'inbox' &&
794
+ !allowsAction({ allowedAction: zcap.allowedAction, action: 'POST' })) {
795
+ throw new TypeError('An inbox grant must allow POST.');
796
+ }
797
+ if (!STRICT_ISO_8601.test(zcap.expires)) {
798
+ throw new TypeError("A received grant's `expires` must be an ISO 8601 date-time; got " +
799
+ `"${zcap.expires}".`);
800
+ }
801
+ incoming.push({ zcap, grantKind: grant.grantKind, receivedAt: stamp });
802
+ }
803
+ return writePartyConnection({
804
+ store,
805
+ hmacKey,
806
+ did,
807
+ change(current) {
808
+ if (current === undefined) {
809
+ return { stop: 'absent' };
810
+ }
811
+ if (current.entry.retired !== undefined) {
812
+ throw new Error('The connections entry is retired; it records no received grants.');
813
+ }
814
+ const merged = [...current.entry.grantsReceived];
815
+ for (const record of incoming) {
816
+ if (merged.some(held => held.zcap.id === record.zcap.id)) {
817
+ continue;
818
+ }
819
+ const sameChannel = merged.findIndex(held => held.grantKind === record.grantKind &&
820
+ held.zcap.invocationTarget === record.zcap.invocationTarget);
821
+ if (sameChannel === -1) {
822
+ merged.push(record);
823
+ }
824
+ else if (expiresAtOf(record.zcap) > expiresAtOf(merged[sameChannel].zcap) ||
825
+ receivedGrantLapsed({
826
+ grant: {
827
+ expires: merged[sameChannel].zcap.expires,
828
+ receivedAt: merged[sameChannel].receivedAt
829
+ },
830
+ now,
831
+ maxLifetimeMs: channelMaxLifetimeMs
832
+ })) {
833
+ merged[sameChannel] = record;
834
+ }
835
+ }
836
+ const kept = merged.filter(record => !expiredBeyondSkew({ zcap: record.zcap, now }));
837
+ if (kept.length === current.entry.grantsReceived.length &&
838
+ kept.every((record, index) => record === current.entry.grantsReceived[index])) {
839
+ return { stop: 'unchanged' };
840
+ }
841
+ return { body: { ...current.body, grantsReceived: kept } };
842
+ }
843
+ });
844
+ }
845
+ /**
846
+ * The renewal's pivot: appends the renewed grant records and queues the
847
+ * envelope that carries them to the party, in one compare-and-swap. Each
848
+ * renewed record keeps its source's `grantKind` and `grantedAt` and carries
849
+ * `renewedAt: now`; the source record stays as stored. On `outbox`, every
850
+ * zcap sharing a renewed zcap's scope is removed from the envelopes already
851
+ * pending, an envelope left with none is dropped, and `message` is appended
852
+ * with `createdAt: now`.
853
+ *
854
+ * A renewed zcap must copy its source's `invocationTarget` as stored, since
855
+ * the scope compares it verbatim (a trailing `*` marker included).
856
+ *
857
+ * Refuses with an `Error`, writing nothing: a retired entry (whose `retired`
858
+ * stays), an entry with no live inbox channel (`receivedGrants`, then
859
+ * `liveInboxChannel`), a renewal whose source record is gone, a renewal
860
+ * whose source is no longer the latest record of its scope (a concurrent
861
+ * renewal ran), and a renewal whose `expires` is not strictly later than its
862
+ * source's. The last is what keeps a renewal minted under a short-lived parent from
863
+ * being appended at every login. A renewed zcap not delegated to the party,
864
+ * not targeting this Space, or of another scope than its source, and a
865
+ * `message` whose zcap ids are not exactly the renewed zcap ids, are refused
866
+ * with a `TypeError`. An absent entry writes nothing.
867
+ *
868
+ * @param options {object}
869
+ * @param options.store {ConnectionsStore}
870
+ * @param options.hmacKey {ConnectionIdKey}
871
+ * @param options.did {string} the party's DID, every renewed grant's
872
+ * controller
873
+ * @param options.pairwiseDid {string} the wallet's pairwise did:key toward
874
+ * the party, the inbox channel's controller
875
+ * @param options.spaceUrl {string} this Space's container URL
876
+ * @param options.renewals {Array<object>} `{ zcap, sourceZcapId }` per
877
+ * renewed grant: the new capability and the id of the record it renews
878
+ * @param options.message {object} the envelope to queue, verbatim
879
+ * @param options.channelMaxLifetimeMs {number} how long after `receivedAt`
880
+ * an inbox record lapses at the latest
881
+ * @param [options.now] {Date}
882
+ * @returns {Promise<ConnectionWriteResult>}
883
+ */
884
+ export async function recordRenewedGrants({ store, hmacKey, did, pairwiseDid, spaceUrl, renewals, message, channelMaxLifetimeMs, now = new Date() }) {
885
+ if (renewals.length === 0) {
886
+ throw new TypeError('A renewal write needs at least one renewed grant.');
887
+ }
888
+ const renewed = renewals.map(renewal => ({
889
+ zcap: checkedDelegatedZcap({ did, spaceUrl, zcap: renewal.zcap }),
890
+ sourceZcapId: renewal.sourceZcapId
891
+ }));
892
+ if (new Set(renewed.map(renewal => renewal.sourceZcapId)).size !==
893
+ renewed.length) {
894
+ throw new TypeError('Each renewed grant must renew a different record.');
895
+ }
896
+ assertEnvelopeCarries({
897
+ message,
898
+ zcapIds: renewed.map(renewal => renewal.zcap.id),
899
+ what: 'renewal'
900
+ });
901
+ const renewedScopes = new Set(renewed.map(renewal => grantScopeKey({ zcap: renewal.zcap })));
902
+ const stamp = now.toISOString();
903
+ return writePartyConnection({
904
+ store,
905
+ hmacKey,
906
+ did,
907
+ change(current) {
908
+ if (current === undefined) {
909
+ return { stop: 'absent' };
910
+ }
911
+ const { entry } = current;
912
+ if (entry.retired !== undefined) {
913
+ throw new Error('The connections entry is retired; its grants are not renewed.');
914
+ }
915
+ const channel = liveInboxChannel({
916
+ grants: receivedGrants({ entry, pairwiseDid, spaceUrl }),
917
+ now,
918
+ maxLifetimeMs: channelMaxLifetimeMs
919
+ });
920
+ if (channel === undefined) {
921
+ throw new Error('The connections entry holds no live inbox channel to deliver a ' +
922
+ 'renewal through.');
923
+ }
924
+ const latest = latestPerScope({ grants: entry.grants });
925
+ const appended = [];
926
+ for (const { zcap, sourceZcapId } of renewed) {
927
+ const sourceIndex = entry.grants.findIndex(grant => grant.zcap.id === sourceZcapId);
928
+ const source = entry.grants[sourceIndex];
929
+ if (source === undefined) {
930
+ throw new Error(`The grant "${sourceZcapId}" a renewal renews is no longer on ` +
931
+ 'the connections entry.');
932
+ }
933
+ const scope = grantScopeKey({ zcap: source.zcap });
934
+ if (grantScopeKey({ zcap }) !== scope) {
935
+ throw new TypeError(`A renewal of "${sourceZcapId}" must keep its controller, ` +
936
+ 'invocation target, and allowed actions.');
937
+ }
938
+ if (latest.get(scope) !== sourceIndex) {
939
+ throw new Error(`The grant "${sourceZcapId}" is no longer the latest of its ` +
940
+ 'scope; a concurrent renewal superseded it.');
941
+ }
942
+ if (expiresAtOf(zcap) <= expiresAtOf(source.zcap)) {
943
+ throw new Error(`A renewal of "${sourceZcapId}" must expire strictly later than ` +
944
+ 'the record it renews.');
945
+ }
946
+ appended.push({
947
+ zcap,
948
+ grantKind: source.grantKind,
949
+ grantedAt: source.grantedAt,
950
+ renewedAt: stamp
951
+ });
952
+ }
953
+ return {
954
+ body: {
955
+ ...current.body,
956
+ grants: [...entry.grants, ...appended],
957
+ outbox: supersedeAndQueueEnvelope({
958
+ outbox: entry.outbox,
959
+ message,
960
+ stamp,
961
+ supersedes: zcap => renewedScopes.has(grantScopeKey({ zcap }))
962
+ })
963
+ }
964
+ };
965
+ }
966
+ });
967
+ }
968
+ /**
969
+ * Removes the outbox item(s) queued at `createdAt`, once a push delivered
970
+ * them. None queued then writes nothing, and an absent entry writes nothing.
971
+ *
972
+ * @param options {object}
973
+ * @param options.store {ConnectionsStore}
974
+ * @param options.hmacKey {ConnectionIdKey}
975
+ * @param options.did {string} the party's DID
976
+ * @param options.createdAt {string} the delivered item's `createdAt`
977
+ * @returns {Promise<ConnectionWriteResult>}
978
+ */
979
+ export async function settleOutboxItem({ store, hmacKey, did, createdAt }) {
980
+ return writePartyConnection({
981
+ store,
982
+ hmacKey,
983
+ did,
984
+ change(current) {
985
+ if (current === undefined) {
986
+ return { stop: 'absent' };
987
+ }
988
+ const kept = current.entry.outbox.filter(item => item.createdAt !== createdAt);
989
+ if (kept.length === current.entry.outbox.length) {
990
+ return { stop: 'unchanged' };
991
+ }
992
+ return { body: { ...current.body, outbox: kept } };
993
+ }
994
+ });
995
+ }
996
+ /**
997
+ * Drops the grant records renewal superseded, and the outbox items no push
998
+ * can still deliver. In each scope holding more than one record, every
999
+ * record but the latest is dropped when its `expires` is past by more than
1000
+ * the revocation clock skew, or when its capability id is among
1001
+ * `deadChainZcapIds`. The latest record of a scope always stays. An outbox
1002
+ * item is discarded when every zcap its envelope carries is past `expires` by
1003
+ * more than the skew; an item carrying a zcap this build cannot read stays.
1004
+ * Nothing to drop writes nothing, and an absent
1005
+ * entry writes nothing.
1006
+ *
1007
+ * @param options {object}
1008
+ * @param options.store {ConnectionsStore}
1009
+ * @param options.hmacKey {ConnectionIdKey}
1010
+ * @param options.did {string} the party's DID
1011
+ * @param [options.deadChainZcapIds] {Iterable<string>} ids of superseded
1012
+ * records whose chain revocation reads as dead (the signer gone from the
1013
+ * account document, or the parent naming another annex generation) and
1014
+ * whose successor's outbox item was settled. A grant `agentGrantDue` reads
1015
+ * as due by replacement does not qualify on that alone: a grant under an
1016
+ * in-place-renewed delegation stays live until that delegation expires.
1017
+ * @param [options.now] {Date}
1018
+ * @returns {Promise<ConnectionWriteResult>}
1019
+ */
1020
+ export async function pruneSupersededGrants({ store, hmacKey, did, deadChainZcapIds = [], now = new Date() }) {
1021
+ const deadChains = new Set(deadChainZcapIds);
1022
+ return writePartyConnection({
1023
+ store,
1024
+ hmacKey,
1025
+ did,
1026
+ change(current) {
1027
+ if (current === undefined) {
1028
+ return { stop: 'absent' };
1029
+ }
1030
+ const { entry } = current;
1031
+ const kept = new Set(latestPerScope({ grants: entry.grants }).values());
1032
+ const grants = entry.grants.filter((grant, index) => kept.has(index) ||
1033
+ !(expiredBeyondSkew({ zcap: grant.zcap, now }) ||
1034
+ deadChains.has(grant.zcap.id)));
1035
+ const outbox = entry.outbox.filter(item => {
1036
+ const zcaps = envelopeZcaps(item.message)?.map(parseConnectionZcap);
1037
+ return (zcaps === undefined ||
1038
+ zcaps.length === 0 ||
1039
+ !zcaps.every(zcap => zcap !== undefined && expiredBeyondSkew({ zcap, now })));
1040
+ });
1041
+ if (grants.length === entry.grants.length &&
1042
+ outbox.length === entry.outbox.length) {
1043
+ return { stop: 'unchanged' };
1044
+ }
1045
+ return { body: { ...current.body, grants, outbox } };
1046
+ }
1047
+ });
1048
+ }
1049
+ /**
1050
+ * Empties the party's `grantsReceived` and `outbox`: the agent revoke's
1051
+ * first write, so no client pushes to the party once it starts. Both already
1052
+ * empty writes nothing, and an absent entry writes nothing.
1053
+ *
1054
+ * @param options {object}
1055
+ * @param options.store {ConnectionsStore}
1056
+ * @param options.hmacKey {ConnectionIdKey}
1057
+ * @param options.did {string} the party's DID
1058
+ * @returns {Promise<ConnectionWriteResult>}
1059
+ */
1060
+ export async function clearReceivedGrants({ store, hmacKey, did }) {
1061
+ return writePartyConnection({
1062
+ store,
1063
+ hmacKey,
1064
+ did,
1065
+ change(current) {
1066
+ if (current === undefined) {
1067
+ return { stop: 'absent' };
1068
+ }
1069
+ if (current.entry.grantsReceived.length === 0 &&
1070
+ current.entry.outbox.length === 0) {
1071
+ return { stop: 'unchanged' };
1072
+ }
1073
+ return { body: { ...current.body, grantsReceived: [], outbox: [] } };
1074
+ }
1075
+ });
1076
+ }
1077
+ /**
1078
+ * Records the owner's decline of the party's offer: sets `declined` to
1079
+ * `now`. An entry already declined keeps its time and writes nothing, and an
1080
+ * absent entry writes nothing. A later consent (`recordGrants`) clears it.
1081
+ *
1082
+ * @param options {object}
1083
+ * @param options.store {ConnectionsStore}
1084
+ * @param options.hmacKey {ConnectionIdKey}
1085
+ * @param options.did {string} the party's DID
1086
+ * @param [options.now] {Date}
1087
+ * @returns {Promise<ConnectionWriteResult>}
1088
+ */
1089
+ export async function markDeclined({ store, hmacKey, did, now = new Date() }) {
1090
+ return writePartyConnection({
1091
+ store,
1092
+ hmacKey,
1093
+ did,
1094
+ change(current) {
1095
+ if (current === undefined) {
1096
+ return { stop: 'absent' };
1097
+ }
1098
+ if (current.entry.declined !== undefined) {
1099
+ return { stop: 'unchanged' };
1100
+ }
1101
+ return { body: { ...current.body, declined: now.toISOString() } };
1102
+ }
1103
+ });
1104
+ }
518
1105
  //# sourceMappingURL=upsert.js.map