@interop/wallet-core 0.101.1 → 0.104.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 (135) hide show
  1. package/README.md +36 -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 -18
  11. package/dist/clientAnnex/grantRevocation.d.ts.map +1 -1
  12. package/dist/clientAnnex/grantRevocation.js +2 -40
  13. package/dist/clientAnnex/grantRevocation.js.map +1 -1
  14. package/dist/clientAnnex/index.d.ts +2 -1
  15. package/dist/clientAnnex/index.d.ts.map +1 -1
  16. package/dist/clientAnnex/index.js +2 -1
  17. package/dist/clientAnnex/index.js.map +1 -1
  18. package/dist/clientAnnex/log.d.ts +4 -42
  19. package/dist/clientAnnex/log.d.ts.map +1 -1
  20. package/dist/clientAnnex/log.js +10 -65
  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/grantProof.d.ts +64 -0
  34. package/dist/connections/grantProof.d.ts.map +1 -0
  35. package/dist/connections/grantProof.js +195 -0
  36. package/dist/connections/grantProof.js.map +1 -0
  37. package/dist/connections/grants.d.ts +225 -3
  38. package/dist/connections/grants.d.ts.map +1 -1
  39. package/dist/connections/grants.js +293 -8
  40. package/dist/connections/grants.js.map +1 -1
  41. package/dist/connections/index.d.ts +22 -8
  42. package/dist/connections/index.d.ts.map +1 -1
  43. package/dist/connections/index.js +19 -6
  44. package/dist/connections/index.js.map +1 -1
  45. package/dist/connections/read.d.ts.map +1 -1
  46. package/dist/connections/read.js +2 -1
  47. package/dist/connections/read.js.map +1 -1
  48. package/dist/connections/resourceId.d.ts +33 -0
  49. package/dist/connections/resourceId.d.ts.map +1 -1
  50. package/dist/connections/resourceId.js +88 -8
  51. package/dist/connections/resourceId.js.map +1 -1
  52. package/dist/connections/upsert.d.ts +252 -7
  53. package/dist/connections/upsert.d.ts.map +1 -1
  54. package/dist/connections/upsert.js +674 -41
  55. package/dist/connections/upsert.js.map +1 -1
  56. package/dist/connections/writers.js +2 -1
  57. package/dist/connections/writers.js.map +1 -1
  58. package/dist/descriptors/index.d.ts +5 -0
  59. package/dist/descriptors/index.d.ts.map +1 -1
  60. package/dist/descriptors/index.js +5 -0
  61. package/dist/descriptors/index.js.map +1 -1
  62. package/dist/descriptors/logSource.d.ts +9 -4
  63. package/dist/descriptors/logSource.d.ts.map +1 -1
  64. package/dist/descriptors/logSource.js +13 -6
  65. package/dist/descriptors/logSource.js.map +1 -1
  66. package/dist/jsonObject.d.ts +16 -0
  67. package/dist/jsonObject.d.ts.map +1 -0
  68. package/dist/jsonObject.js +18 -0
  69. package/dist/jsonObject.js.map +1 -0
  70. package/dist/keys/index.d.ts +13 -9
  71. package/dist/keys/index.d.ts.map +1 -1
  72. package/dist/keys/index.js +11 -7
  73. package/dist/keys/index.js.map +1 -1
  74. package/dist/keys/userKeyCascade.d.ts +46 -0
  75. package/dist/keys/userKeyCascade.d.ts.map +1 -1
  76. package/dist/keys/userKeyCascade.js +153 -65
  77. package/dist/keys/userKeyCascade.js.map +1 -1
  78. package/dist/keys/userKeyRoster.d.ts +60 -7
  79. package/dist/keys/userKeyRoster.d.ts.map +1 -1
  80. package/dist/keys/userKeyRoster.js +165 -47
  81. package/dist/keys/userKeyRoster.js.map +1 -1
  82. package/dist/keys/userKeyRosterCascade.d.ts.map +1 -1
  83. package/dist/keys/userKeyRosterCascade.js +1 -0
  84. package/dist/keys/userKeyRosterCascade.js.map +1 -1
  85. package/dist/menders/ids.d.ts +1 -1
  86. package/dist/menders/ids.d.ts.map +1 -1
  87. package/dist/menders/ids.js +20 -4
  88. package/dist/menders/ids.js.map +1 -1
  89. package/dist/resourceLog/controller.d.ts +11 -7
  90. package/dist/resourceLog/controller.d.ts.map +1 -1
  91. package/dist/resourceLog/controller.js +15 -31
  92. package/dist/resourceLog/controller.js.map +1 -1
  93. package/dist/resourceLog/document.d.ts +9 -11
  94. package/dist/resourceLog/document.d.ts.map +1 -1
  95. package/dist/resourceLog/document.js +9 -11
  96. package/dist/resourceLog/document.js.map +1 -1
  97. package/dist/space/collections.d.ts +23 -3
  98. package/dist/space/collections.d.ts.map +1 -1
  99. package/dist/space/collections.js +26 -2
  100. package/dist/space/collections.js.map +1 -1
  101. package/dist/space/inboxMessage.d.ts +47 -0
  102. package/dist/space/inboxMessage.d.ts.map +1 -0
  103. package/dist/space/inboxMessage.js +86 -0
  104. package/dist/space/inboxMessage.js.map +1 -0
  105. package/dist/space/index.d.ts +6 -1
  106. package/dist/space/index.d.ts.map +1 -1
  107. package/dist/space/index.js +5 -1
  108. package/dist/space/index.js.map +1 -1
  109. package/dist/sync/contactsConflict.d.ts +23 -10
  110. package/dist/sync/contactsConflict.d.ts.map +1 -1
  111. package/dist/sync/contactsConflict.js +40 -21
  112. package/dist/sync/contactsConflict.js.map +1 -1
  113. package/dist/unlock/index.d.ts +1 -1
  114. package/dist/unlock/index.d.ts.map +1 -1
  115. package/dist/unlock/index.js +1 -1
  116. package/dist/unlock/index.js.map +1 -1
  117. package/dist/webvh/clientAnnexDid.d.ts +76 -0
  118. package/dist/webvh/clientAnnexDid.d.ts.map +1 -0
  119. package/dist/webvh/clientAnnexDid.js +117 -0
  120. package/dist/webvh/clientAnnexDid.js.map +1 -0
  121. package/dist/webvh/didWebProjection.d.ts +38 -5
  122. package/dist/webvh/didWebProjection.d.ts.map +1 -1
  123. package/dist/webvh/didWebProjection.js +35 -22
  124. package/dist/webvh/didWebProjection.js.map +1 -1
  125. package/dist/webvh/documentDids.d.ts +3 -3
  126. package/dist/webvh/documentDids.js +3 -3
  127. package/dist/webvh/index.d.ts +4 -3
  128. package/dist/webvh/index.d.ts.map +1 -1
  129. package/dist/webvh/index.js +4 -3
  130. package/dist/webvh/index.js.map +1 -1
  131. package/dist/webvh/standingZcap.d.ts +27 -2
  132. package/dist/webvh/standingZcap.d.ts.map +1 -1
  133. package/dist/webvh/standingZcap.js +42 -4
  134. package/dist/webvh/standingZcap.js.map +1 -1
  135. package/package.json +14 -7
@@ -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`, `dropReceivedGrant`,
30
+ * `clearReceivedGrants`, and `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,189 @@ 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
+ * With `unlessRetired`, a retired entry is left as stored and the result's
398
+ * outcome is `unchanged`. A party's own fresh consent un-retires its entry,
399
+ * and a restore the owner runs from a listing does not: the entry was
400
+ * retired by a revoke that landed after that listing was read, and the
401
+ * restore must not undo it. The check runs inside the compare-and-swap, so
402
+ * a revoke racing the write is met on the re-read.
403
+ *
404
+ * A `message` (an envelope carrying exactly the grants of this call) is
405
+ * queued on `outbox` in the same write, stamped `now`, so a restore that
406
+ * records fresh grants and owes the party their envelope has no tear between
407
+ * the two. Each zcap of this call is first removed from every pending
408
+ * envelope that carries it (an envelope left with none is dropped), so no
409
+ * two pending envelopes name one zcap id. A consent torn after its write
410
+ * lands and re-run therefore leaves one pending envelope, the re-run's, and
411
+ * a grant recorded earlier with no envelope is told on the first call that
412
+ * brings one.
413
+ *
414
+ * A `seed` and its `seedTag` (both or neither) are written when the entry
415
+ * carries no seed, and left as stored when it carries the same one. An entry
416
+ * carrying a different seed is refused with an `Error`, since the seed is
417
+ * written once. A tag that does not verify under `hmacKey` is refused with a
418
+ * `TypeError` before anything is read.
230
419
  *
231
420
  * Refuses an entry of another kind with {@link ConnectionKindMismatchError},
232
421
  * and a grant delegated to another party or targeting another Space with a
@@ -246,10 +435,18 @@ function checkedGrant({ did, spaceUrl, grant }) {
246
435
  * @param [options.origin] {string} the attested Web origin (App Connect)
247
436
  * @param [options.url] {string} the canonical app URL (App Connect)
248
437
  * @param [options.appKey] {string} the app-key Resource id (App Connect)
438
+ * @param [options.seed] {string} the wallet's pairwise seed toward the
439
+ * party, base64url with no padding
440
+ * @param [options.seedTag] {string} the seed's tag (`connectionSeedTag`)
441
+ * @param [options.message] {object} an envelope to queue on `outbox` with
442
+ * the consent, carrying exactly the grants of this call
443
+ * @param [options.unlessRetired] {boolean} leave a retired entry as
444
+ * stored, with the outcome `unchanged`
249
445
  * @param [options.now] {Date}
250
446
  * @returns {Promise<ConnectionWriteResult>}
251
447
  */
252
- export async function recordGrants({ store, hmacKey, did, kind, spaceUrl, grants, name, origin, url, appKey, now = new Date() }) {
448
+ export async function recordGrants({ store, hmacKey, did, kind, spaceUrl, grants, name, origin, url, appKey, seed, seedTag, message, unlessRetired, now = new Date() }) {
449
+ await assertPairwiseSeed({ hmacKey, seed, seedTag });
253
450
  const stamp = now.toISOString();
254
451
  const recorded = [];
255
452
  const seen = new Set();
@@ -264,6 +461,13 @@ export async function recordGrants({ store, hmacKey, did, kind, spaceUrl, grants
264
461
  recorded.push({ zcap, grantKind: grant.grantKind, grantedAt: stamp });
265
462
  }
266
463
  }
464
+ if (message !== undefined) {
465
+ assertEnvelopeCarries({
466
+ message,
467
+ zcapIds: recorded.map(grant => grant.zcap.id),
468
+ what: 'consent'
469
+ });
470
+ }
267
471
  const normalizedName = name === undefined ? undefined : normalizeDisplayName({ value: name });
268
472
  const declared = {
269
473
  ...(normalizedName !== undefined && { name: normalizedName }),
@@ -276,27 +480,51 @@ export async function recordGrants({ store, hmacKey, did, kind, spaceUrl, grants
276
480
  hmacKey,
277
481
  did,
278
482
  change(current) {
483
+ const stored = current?.entry;
484
+ const held = new Set(stored?.grants.map(grant => grant.zcap.id));
485
+ let body;
279
486
  if (current === undefined) {
280
- return {
281
- body: {
282
- ...newConnectionEntry({ kind, did, now }),
283
- ...declared,
284
- grants: recorded
285
- }
487
+ body = {
488
+ ...newConnectionEntry({ kind, did, now }),
489
+ ...declared,
490
+ grants: recorded
491
+ };
492
+ }
493
+ else {
494
+ assertKind({ entry: current.entry, kind });
495
+ if (unlessRetired && current.entry.retired !== undefined) {
496
+ return { stop: 'unchanged' };
497
+ }
498
+ body = {
499
+ ...current.body,
500
+ ...declared,
501
+ lastSeen: stamp,
502
+ grants: [
503
+ ...current.entry.grants,
504
+ ...recorded.filter(grant => !held.has(grant.zcap.id))
505
+ ]
506
+ };
507
+ delete body.retired;
508
+ delete body.declined;
509
+ }
510
+ if (seed !== undefined && stored?.seed === undefined) {
511
+ body = { ...body, seed, seedTag };
512
+ }
513
+ else if (seed !== undefined && stored?.seed !== seed) {
514
+ throw new Error('The connections entry already carries a different pairwise seed; ' +
515
+ 'a seed is written once.');
516
+ }
517
+ if (message !== undefined) {
518
+ body = {
519
+ ...body,
520
+ outbox: supersedeAndQueueEnvelope({
521
+ outbox: stored?.outbox ?? [],
522
+ message,
523
+ stamp,
524
+ supersedes: zcap => seen.has(zcap.id)
525
+ })
286
526
  };
287
527
  }
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
528
  return { body };
301
529
  }
302
530
  });
@@ -334,8 +562,9 @@ export async function removeGrants({ store, hmacKey, did, zcapIds }) {
334
562
  }
335
563
  /**
336
564
  * 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
565
+ * grants: empties `grants`, `grantsReceived`, and `outbox`, drops `appKey`,
566
+ * and stamps `retired`. `name`, `label`, `origin`, `url`, `firstSeen`,
567
+ * `lastSeen`, `writers`, `seed`, `seedTag`, and `declined` stay, so a
339
568
  * departed party keeps resolving. An entry already retired with nothing left
340
569
  * to empty writes nothing, and an absent entry writes nothing.
341
570
  *
@@ -381,12 +610,16 @@ export async function retireConnection({ store, hmacKey, did, handledZcapIds, sp
381
610
  const { entry } = current;
382
611
  if (entry.retired !== undefined &&
383
612
  entry.grants.length === 0 &&
613
+ entry.grantsReceived.length === 0 &&
614
+ entry.outbox.length === 0 &&
384
615
  entry.appKey === undefined) {
385
616
  return { stop: 'unchanged' };
386
617
  }
387
618
  const body = {
388
619
  ...current.body,
389
620
  grants: [],
621
+ grantsReceived: [],
622
+ outbox: [],
390
623
  retired: entry.retired ?? now.toISOString()
391
624
  };
392
625
  delete body.appKey;
@@ -515,4 +748,404 @@ export async function setConnectionLabel({ store, hmacKey, did, kind, label, nam
515
748
  }
516
749
  });
517
750
  }
751
+ /**
752
+ * Records the capabilities a party handed the wallet on its entry's
753
+ * `grantsReceived`. Each new record is merged in turn: one whose capability
754
+ * id is already held is left as stored, so the drain's re-run is a no-op;
755
+ * one with the `grantKind` and `invocationTarget` of a held record replaces
756
+ * it when its `expires` is strictly later or when the held record has
757
+ * lapsed as of `now` (at the earlier of its `expires` and `receivedAt` plus
758
+ * `channelMaxLifetimeMs`), and leaves the held record otherwise; any other
759
+ * is appended. Every record whose `expires`
760
+ * is past by more than the revocation clock skew is then dropped. A new
761
+ * record carries `receivedAt: now`.
762
+ *
763
+ * Each grant is checked strictly before anything is read, refused with a
764
+ * `TypeError`: its known members well-formed, its `controller` the wallet's
765
+ * pairwise DID toward the party, its `invocationTarget` outside this Space,
766
+ * its kind one of `RECEIVED_GRANT_KINDS`, an `'inbox'` grant allowing POST,
767
+ * and an `expires` in strict ISO 8601 form, with any number of fractional
768
+ * second digits (a format one engine's `Date.parse` accepts and another's
769
+ * refuses would make the entry unparseable there). A retired entry is refused
770
+ * with an `Error`, and an absent entry writes nothing.
771
+ *
772
+ * @param options {object}
773
+ * @param options.store {ConnectionsStore}
774
+ * @param options.hmacKey {ConnectionIdKey}
775
+ * @param options.did {string} the party's DID
776
+ * @param options.pairwiseDid {string} the wallet's pairwise did:key toward
777
+ * the party, every received grant's controller
778
+ * @param options.spaceUrl {string} this Space's container URL, which no
779
+ * received grant may target
780
+ * @param options.grants {Array<object>} `{ zcap, grantKind }` per grant
781
+ * @param options.channelMaxLifetimeMs {number} how long after `receivedAt`
782
+ * a received record lapses at the latest
783
+ * @param [options.now] {Date}
784
+ * @returns {Promise<ConnectionWriteResult>}
785
+ */
786
+ export async function recordReceivedGrants({ store, hmacKey, did, pairwiseDid, spaceUrl, grants, channelMaxLifetimeMs, now = new Date() }) {
787
+ const stamp = now.toISOString();
788
+ const incoming = [];
789
+ for (const grant of grants) {
790
+ const zcap = requiredConnectionZcap({
791
+ value: grant.zcap,
792
+ what: 'received grant'
793
+ });
794
+ if (zcap.controller !== pairwiseDid) {
795
+ throw new TypeError(`A received grant must be delegated to the pairwise DID ` +
796
+ `"${pairwiseDid}"; its controller is "${zcap.controller}".`);
797
+ }
798
+ if (isTargetInSpace({ target: zcap.invocationTarget, spaceUrl })) {
799
+ throw new TypeError('A received grant must target the party, not this Space; its ' +
800
+ `invocation target is "${zcap.invocationTarget}".`);
801
+ }
802
+ if (!RECEIVED_GRANT_KINDS.includes(grant.grantKind)) {
803
+ throw new TypeError(`Unknown received grant kind "${grant.grantKind}".`);
804
+ }
805
+ if (grant.grantKind === 'inbox' &&
806
+ !allowsAction({ allowedAction: zcap.allowedAction, action: 'POST' })) {
807
+ throw new TypeError('An inbox grant must allow POST.');
808
+ }
809
+ if (!STRICT_ISO_8601.test(zcap.expires)) {
810
+ throw new TypeError("A received grant's `expires` must be an ISO 8601 date-time; got " +
811
+ `"${zcap.expires}".`);
812
+ }
813
+ incoming.push({ zcap, grantKind: grant.grantKind, receivedAt: stamp });
814
+ }
815
+ return writePartyConnection({
816
+ store,
817
+ hmacKey,
818
+ did,
819
+ change(current) {
820
+ if (current === undefined) {
821
+ return { stop: 'absent' };
822
+ }
823
+ if (current.entry.retired !== undefined) {
824
+ throw new Error('The connections entry is retired; it records no received grants.');
825
+ }
826
+ const merged = [...current.entry.grantsReceived];
827
+ for (const record of incoming) {
828
+ if (merged.some(held => held.zcap.id === record.zcap.id)) {
829
+ continue;
830
+ }
831
+ const sameChannel = merged.findIndex(held => held.grantKind === record.grantKind &&
832
+ held.zcap.invocationTarget === record.zcap.invocationTarget);
833
+ if (sameChannel === -1) {
834
+ merged.push(record);
835
+ }
836
+ else if (expiresAtOf(record.zcap) > expiresAtOf(merged[sameChannel].zcap) ||
837
+ receivedGrantLapsed({
838
+ grant: {
839
+ expires: merged[sameChannel].zcap.expires,
840
+ receivedAt: merged[sameChannel].receivedAt
841
+ },
842
+ now,
843
+ maxLifetimeMs: channelMaxLifetimeMs
844
+ })) {
845
+ merged[sameChannel] = record;
846
+ }
847
+ }
848
+ const kept = merged.filter(record => !expiredBeyondSkew({ zcap: record.zcap, now }));
849
+ if (kept.length === current.entry.grantsReceived.length &&
850
+ kept.every((record, index) => record === current.entry.grantsReceived[index])) {
851
+ return { stop: 'unchanged' };
852
+ }
853
+ return { body: { ...current.body, grantsReceived: kept } };
854
+ }
855
+ });
856
+ }
857
+ /**
858
+ * The renewal's pivot: appends the renewed grant records and queues the
859
+ * envelope that carries them to the party, in one compare-and-swap. Each
860
+ * renewed record keeps its source's `grantKind` and `grantedAt` and carries
861
+ * `renewedAt: now`; the source record stays as stored. On `outbox`, every
862
+ * zcap sharing a renewed zcap's scope is removed from the envelopes already
863
+ * pending, an envelope left with none is dropped, and `message` is appended
864
+ * with `createdAt: now`.
865
+ *
866
+ * A renewed zcap must copy its source's `invocationTarget` as stored, since
867
+ * the scope compares it verbatim (a trailing `*` marker included).
868
+ *
869
+ * Refuses with an `Error`, writing nothing: a retired entry (whose `retired`
870
+ * stays), an entry with no live inbox channel (`receivedGrants`, then
871
+ * `liveInboxChannel`), a renewal whose source record is gone, a renewal
872
+ * whose source is no longer the latest record of its scope (a concurrent
873
+ * renewal ran), and a renewal whose `expires` is not strictly later than its
874
+ * source's. The last is what keeps a renewal minted under a short-lived parent from
875
+ * being appended at every login. A renewed zcap not delegated to the party,
876
+ * not targeting this Space, or of another scope than its source, and a
877
+ * `message` whose zcap ids are not exactly the renewed zcap ids, are refused
878
+ * with a `TypeError`. An absent entry writes nothing.
879
+ *
880
+ * @param options {object}
881
+ * @param options.store {ConnectionsStore}
882
+ * @param options.hmacKey {ConnectionIdKey}
883
+ * @param options.did {string} the party's DID, every renewed grant's
884
+ * controller
885
+ * @param options.pairwiseDid {string} the wallet's pairwise did:key toward
886
+ * the party, the inbox channel's controller
887
+ * @param options.spaceUrl {string} this Space's container URL
888
+ * @param options.renewals {Array<object>} `{ zcap, sourceZcapId }` per
889
+ * renewed grant: the new capability and the id of the record it renews
890
+ * @param options.message {object} the envelope to queue, verbatim
891
+ * @param options.channelMaxLifetimeMs {number} how long after `receivedAt`
892
+ * an inbox record lapses at the latest
893
+ * @param [options.now] {Date}
894
+ * @returns {Promise<ConnectionWriteResult>}
895
+ */
896
+ export async function recordRenewedGrants({ store, hmacKey, did, pairwiseDid, spaceUrl, renewals, message, channelMaxLifetimeMs, now = new Date() }) {
897
+ if (renewals.length === 0) {
898
+ throw new TypeError('A renewal write needs at least one renewed grant.');
899
+ }
900
+ const renewed = renewals.map(renewal => ({
901
+ zcap: checkedDelegatedZcap({ did, spaceUrl, zcap: renewal.zcap }),
902
+ sourceZcapId: renewal.sourceZcapId
903
+ }));
904
+ if (new Set(renewed.map(renewal => renewal.sourceZcapId)).size !==
905
+ renewed.length) {
906
+ throw new TypeError('Each renewed grant must renew a different record.');
907
+ }
908
+ assertEnvelopeCarries({
909
+ message,
910
+ zcapIds: renewed.map(renewal => renewal.zcap.id),
911
+ what: 'renewal'
912
+ });
913
+ const renewedScopes = new Set(renewed.map(renewal => grantScopeKey({ zcap: renewal.zcap })));
914
+ const stamp = now.toISOString();
915
+ return writePartyConnection({
916
+ store,
917
+ hmacKey,
918
+ did,
919
+ change(current) {
920
+ if (current === undefined) {
921
+ return { stop: 'absent' };
922
+ }
923
+ const { entry } = current;
924
+ if (entry.retired !== undefined) {
925
+ throw new Error('The connections entry is retired; its grants are not renewed.');
926
+ }
927
+ const channel = liveInboxChannel({
928
+ grants: receivedGrants({ entry, pairwiseDid, spaceUrl }),
929
+ now,
930
+ maxLifetimeMs: channelMaxLifetimeMs
931
+ });
932
+ if (channel === undefined) {
933
+ throw new Error('The connections entry holds no live inbox channel to deliver a ' +
934
+ 'renewal through.');
935
+ }
936
+ const latest = latestPerScope({ grants: entry.grants });
937
+ const appended = [];
938
+ for (const { zcap, sourceZcapId } of renewed) {
939
+ const sourceIndex = entry.grants.findIndex(grant => grant.zcap.id === sourceZcapId);
940
+ const source = entry.grants[sourceIndex];
941
+ if (source === undefined) {
942
+ throw new Error(`The grant "${sourceZcapId}" a renewal renews is no longer on ` +
943
+ 'the connections entry.');
944
+ }
945
+ const scope = grantScopeKey({ zcap: source.zcap });
946
+ if (grantScopeKey({ zcap }) !== scope) {
947
+ throw new TypeError(`A renewal of "${sourceZcapId}" must keep its controller, ` +
948
+ 'invocation target, and allowed actions.');
949
+ }
950
+ if (latest.get(scope) !== sourceIndex) {
951
+ throw new Error(`The grant "${sourceZcapId}" is no longer the latest of its ` +
952
+ 'scope; a concurrent renewal superseded it.');
953
+ }
954
+ if (expiresAtOf(zcap) <= expiresAtOf(source.zcap)) {
955
+ throw new Error(`A renewal of "${sourceZcapId}" must expire strictly later than ` +
956
+ 'the record it renews.');
957
+ }
958
+ appended.push({
959
+ zcap,
960
+ grantKind: source.grantKind,
961
+ grantedAt: source.grantedAt,
962
+ renewedAt: stamp
963
+ });
964
+ }
965
+ return {
966
+ body: {
967
+ ...current.body,
968
+ grants: [...entry.grants, ...appended],
969
+ outbox: supersedeAndQueueEnvelope({
970
+ outbox: entry.outbox,
971
+ message,
972
+ stamp,
973
+ supersedes: zcap => renewedScopes.has(grantScopeKey({ zcap }))
974
+ })
975
+ }
976
+ };
977
+ }
978
+ });
979
+ }
980
+ /**
981
+ * Removes the outbox item(s) queued at `createdAt`, once a push delivered
982
+ * them. None queued then writes nothing, and an absent entry writes nothing.
983
+ *
984
+ * @param options {object}
985
+ * @param options.store {ConnectionsStore}
986
+ * @param options.hmacKey {ConnectionIdKey}
987
+ * @param options.did {string} the party's DID
988
+ * @param options.createdAt {string} the delivered item's `createdAt`
989
+ * @returns {Promise<ConnectionWriteResult>}
990
+ */
991
+ export async function settleOutboxItem({ store, hmacKey, did, createdAt }) {
992
+ return writePartyConnection({
993
+ store,
994
+ hmacKey,
995
+ did,
996
+ change(current) {
997
+ if (current === undefined) {
998
+ return { stop: 'absent' };
999
+ }
1000
+ const kept = current.entry.outbox.filter(item => item.createdAt !== createdAt);
1001
+ if (kept.length === current.entry.outbox.length) {
1002
+ return { stop: 'unchanged' };
1003
+ }
1004
+ return { body: { ...current.body, outbox: kept } };
1005
+ }
1006
+ });
1007
+ }
1008
+ /**
1009
+ * Drops the grant records renewal superseded, and the outbox items no push
1010
+ * can still deliver. In each scope holding more than one record, every
1011
+ * record but the latest is dropped when its `expires` is past by more than
1012
+ * the revocation clock skew, or when its capability id is among
1013
+ * `deadChainZcapIds`. The latest record of a scope always stays. An outbox
1014
+ * item is discarded when every zcap its envelope carries is past `expires` by
1015
+ * more than the skew; an item carrying a zcap this build cannot read stays.
1016
+ * Nothing to drop writes nothing, and an absent
1017
+ * entry writes nothing.
1018
+ *
1019
+ * @param options {object}
1020
+ * @param options.store {ConnectionsStore}
1021
+ * @param options.hmacKey {ConnectionIdKey}
1022
+ * @param options.did {string} the party's DID
1023
+ * @param [options.deadChainZcapIds] {Iterable<string>} ids of superseded
1024
+ * records whose chain revocation reads as dead (the signer gone from the
1025
+ * account document, or the parent naming another annex generation) and
1026
+ * whose successor's outbox item was settled. A grant `agentGrantDue` reads
1027
+ * as due by replacement does not qualify on that alone: a grant under an
1028
+ * in-place-renewed delegation stays live until that delegation expires.
1029
+ * @param [options.now] {Date}
1030
+ * @returns {Promise<ConnectionWriteResult>}
1031
+ */
1032
+ export async function pruneSupersededGrants({ store, hmacKey, did, deadChainZcapIds = [], now = new Date() }) {
1033
+ const deadChains = new Set(deadChainZcapIds);
1034
+ return writePartyConnection({
1035
+ store,
1036
+ hmacKey,
1037
+ did,
1038
+ change(current) {
1039
+ if (current === undefined) {
1040
+ return { stop: 'absent' };
1041
+ }
1042
+ const { entry } = current;
1043
+ const kept = new Set(latestPerScope({ grants: entry.grants }).values());
1044
+ const grants = entry.grants.filter((grant, index) => kept.has(index) ||
1045
+ !(expiredBeyondSkew({ zcap: grant.zcap, now }) ||
1046
+ deadChains.has(grant.zcap.id)));
1047
+ const outbox = entry.outbox.filter(item => {
1048
+ const zcaps = envelopeZcaps(item.message)?.map(parseConnectionZcap);
1049
+ return (zcaps === undefined ||
1050
+ zcaps.length === 0 ||
1051
+ !zcaps.every(zcap => zcap !== undefined && expiredBeyondSkew({ zcap, now })));
1052
+ });
1053
+ if (grants.length === entry.grants.length &&
1054
+ outbox.length === entry.outbox.length) {
1055
+ return { stop: 'unchanged' };
1056
+ }
1057
+ return { body: { ...current.body, grants, outbox } };
1058
+ }
1059
+ });
1060
+ }
1061
+ /**
1062
+ * Empties the party's `grantsReceived` and `outbox`: the agent revoke's
1063
+ * first write, so no client pushes to the party once it starts. Both already
1064
+ * empty writes nothing, and an absent entry writes nothing.
1065
+ *
1066
+ * @param options {object}
1067
+ * @param options.store {ConnectionsStore}
1068
+ * @param options.hmacKey {ConnectionIdKey}
1069
+ * @param options.did {string} the party's DID
1070
+ * @returns {Promise<ConnectionWriteResult>}
1071
+ */
1072
+ export async function clearReceivedGrants({ store, hmacKey, did }) {
1073
+ return writePartyConnection({
1074
+ store,
1075
+ hmacKey,
1076
+ did,
1077
+ change(current) {
1078
+ if (current === undefined) {
1079
+ return { stop: 'absent' };
1080
+ }
1081
+ if (current.entry.grantsReceived.length === 0 &&
1082
+ current.entry.outbox.length === 0) {
1083
+ return { stop: 'unchanged' };
1084
+ }
1085
+ return { body: { ...current.body, grantsReceived: [], outbox: [] } };
1086
+ }
1087
+ });
1088
+ }
1089
+ /**
1090
+ * Drops one record from the party's `grantsReceived` by its capability id,
1091
+ * and leaves `outbox` as it is: what a push refused by the party's server
1092
+ * (a 401, a 403, or a WAS host's masked 404) does with the channel it went
1093
+ * through, since the refusal says the capability is expired or revoked. The
1094
+ * queued envelopes stay for the party's next invitation, and a replacement
1095
+ * channel the party then hands over is stored by `recordReceivedGrants`
1096
+ * whatever its `expires`, since the dropped record is gone. A record not
1097
+ * held writes nothing, and an absent entry writes nothing.
1098
+ *
1099
+ * @param options {object}
1100
+ * @param options.store {ConnectionsStore}
1101
+ * @param options.hmacKey {ConnectionIdKey}
1102
+ * @param options.did {string} the party's DID
1103
+ * @param options.zcapId {string} the `id` of the received capability
1104
+ * @returns {Promise<ConnectionWriteResult>}
1105
+ */
1106
+ export async function dropReceivedGrant({ store, hmacKey, did, zcapId }) {
1107
+ return writePartyConnection({
1108
+ store,
1109
+ hmacKey,
1110
+ did,
1111
+ change(current) {
1112
+ if (current === undefined) {
1113
+ return { stop: 'absent' };
1114
+ }
1115
+ const kept = current.entry.grantsReceived.filter(record => record.zcap.id !== zcapId);
1116
+ if (kept.length === current.entry.grantsReceived.length) {
1117
+ return { stop: 'unchanged' };
1118
+ }
1119
+ return { body: { ...current.body, grantsReceived: kept } };
1120
+ }
1121
+ });
1122
+ }
1123
+ /**
1124
+ * Records the owner's decline of the party's offer: sets `declined` to
1125
+ * `now`. An entry already declined keeps its time and writes nothing, and an
1126
+ * absent entry writes nothing. A later consent (`recordGrants`) clears it.
1127
+ *
1128
+ * @param options {object}
1129
+ * @param options.store {ConnectionsStore}
1130
+ * @param options.hmacKey {ConnectionIdKey}
1131
+ * @param options.did {string} the party's DID
1132
+ * @param [options.now] {Date}
1133
+ * @returns {Promise<ConnectionWriteResult>}
1134
+ */
1135
+ export async function markDeclined({ store, hmacKey, did, now = new Date() }) {
1136
+ return writePartyConnection({
1137
+ store,
1138
+ hmacKey,
1139
+ did,
1140
+ change(current) {
1141
+ if (current === undefined) {
1142
+ return { stop: 'absent' };
1143
+ }
1144
+ if (current.entry.declined !== undefined) {
1145
+ return { stop: 'unchanged' };
1146
+ }
1147
+ return { body: { ...current.body, declined: now.toISOString() } };
1148
+ }
1149
+ });
1150
+ }
518
1151
  //# sourceMappingURL=upsert.js.map