@forgesworn/fold-kit 0.10.0 → 0.13.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.
@@ -10,8 +10,11 @@ import { KINDS } from './kinds.js';
10
10
  import { verifyEventUncached } from './verify.js';
11
11
  import { withExpiration } from './expiration.js';
12
12
  import { deriveRoom } from './room.js';
13
+ import { decodeInvitationDecline, InvitationDeclinedError } from './invitation-decline.js';
14
+ import { DEFAULT_REPLY_DELAY_MS, defaultReplySchedule, replyDelay } from './quiet-reply.js';
13
15
  const INVITATION_ID_INFO = 'kithmoot/v2/invitation-id';
14
16
  const INVITATION_REQUEST_KEY_INFO = 'kithmoot/v2/invitation-request-key';
17
+ const INVITATION_ACCOUNT_PROOF = 'kithmoot/v2/invitation-account-proof';
15
18
  const INVITATION_MAX_AGE_SECONDS = 90;
16
19
  const DEFAULT_TIMEOUT_MS = 60_000;
17
20
  const DEFAULT_RETRY_MS = 2_000;
@@ -56,6 +59,40 @@ function requestKey(invitation) {
56
59
  /** How much of a name an admission request carries. The same bound as a
57
60
  * display name on the roster. */
58
61
  const MAX_REQUEST_NAME_LENGTH = 64;
62
+ /** Prove control of an account for one ephemeral admission device. This is
63
+ * deliberately separate from a room device credential: a guest does not yet
64
+ * know the room or its traffic key, and this proof grants neither. */
65
+ export async function encodeInvitationAccountProof(opts) {
66
+ if (!Number.isSafeInteger(opts.now) || opts.now < 0)
67
+ throw new Error('invalid invitation account proof time');
68
+ const device = requirePubkey(opts.device), participant = requirePubkey(opts.identity.pubkey);
69
+ const proof = await opts.identity.signEvent({
70
+ kind: KINDS.INVITATION_REQUEST,
71
+ created_at: opts.now,
72
+ tags: [['t', INVITATION_ACCOUNT_PROOF], ['d', deriveInvitationId(opts.invitation)], ['p', requirePubkey(opts.invitation.inviter)]],
73
+ content: JSON.stringify({ v: 1, device }),
74
+ });
75
+ if (!accountProofMatches(proof, { invitation: opts.invitation, device, participant, now: opts.now }))
76
+ throw new Error('invalid invitation account proof from signer');
77
+ return proof;
78
+ }
79
+ function accountProofMatches(raw, opts) {
80
+ try {
81
+ if (!raw || typeof raw !== 'object' || !opts.participant)
82
+ return false;
83
+ const proof = raw;
84
+ if (proof.kind !== KINDS.INVITATION_REQUEST || proof.created_at !== opts.now || !hexEquals(proof.pubkey, opts.participant))
85
+ return false;
86
+ if (JSON.stringify(proof.tags) !== JSON.stringify([['t', INVITATION_ACCOUNT_PROOF], ['d', deriveInvitationId(opts.invitation)], ['p', requirePubkey(opts.invitation.inviter)]]))
87
+ return false;
88
+ if (proof.content !== JSON.stringify({ v: 1, device: requirePubkey(opts.device) }))
89
+ return false;
90
+ return verifyEventUncached(proof);
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ }
59
96
  /** Prove possession of the bearer without putting it, or a traffic key, on a relay. */
60
97
  export function encodeInvitationRequest(opts) {
61
98
  require32(opts.requesterSk, 'requester secret key');
@@ -66,6 +103,11 @@ export function encodeInvitationRequest(opts) {
66
103
  body.name = name;
67
104
  if (opts.participant !== undefined)
68
105
  body.participant = requirePubkey(opts.participant);
106
+ if (opts.accountProof !== undefined) {
107
+ if (!accountProofMatches(opts.accountProof, { invitation: opts.invitation, device, participant: body.participant, now: opts.now }))
108
+ throw new Error('invalid invitation account proof');
109
+ body.accountProof = opts.accountProof;
110
+ }
69
111
  return finalizeEvent({
70
112
  kind: KINDS.INVITATION_REQUEST,
71
113
  created_at: opts.now,
@@ -105,6 +147,8 @@ export function decodeInvitationRequest(event, opts) {
105
147
  }
106
148
  if (typeof body.participant === 'string' && /^[0-9a-f]{64}$/i.test(body.participant))
107
149
  decoded.participant = body.participant.toLowerCase();
150
+ if (accountProofMatches(body.accountProof, { invitation: opts.invitation, device: decoded.device, participant: decoded.participant, now: event.created_at }))
151
+ decoded.verifiedParticipant = decoded.participant;
108
152
  return decoded;
109
153
  }
110
154
  catch {
@@ -339,16 +383,38 @@ export function hostRoomInvitation(opts) {
339
383
  }
340
384
  const responder = getPublicKey(opts.inviterSk);
341
385
  const invitationId = deriveInvitationId(opts.invitation);
342
- const answered = new Set();
386
+ // Keep each live decision, including refusals and pending approval, until
387
+ // its authenticated request expires. Evicting a live entry could re-prompt
388
+ // or mint fresh authority for the same signed request.
389
+ const requests = new Map();
390
+ const delayRange = opts.grantDelayMs ?? (opts.coordinate ? DEFAULT_REPLY_DELAY_MS : undefined);
391
+ const random = opts.random ?? Math.random;
392
+ const schedule = opts.schedule ?? defaultReplySchedule;
393
+ /** Grants waiting out their delay, each as how to cancel it. */
394
+ const waits = new Set();
395
+ /** Requesting devices another responder's grant was seen for since their
396
+ * request reached this one. Only with `coordinate`. */
397
+ const grantSeen = new Set();
398
+ const bound = (set) => {
399
+ if (set.size > 256)
400
+ set.delete(set.values().next().value);
401
+ };
343
402
  let closed = false;
344
403
  let unsubRequests = () => { };
345
404
  let unsubRetirement = () => { };
405
+ let unsubGrants = () => { };
346
406
  const close = () => {
347
407
  if (closed)
348
408
  return;
349
409
  closed = true;
410
+ for (const cancel of waits)
411
+ cancel();
412
+ waits.clear();
413
+ requests.clear();
414
+ grantSeen.clear();
350
415
  unsubRequests();
351
416
  unsubRetirement();
417
+ unsubGrants();
352
418
  };
353
419
  // Subscribe to the durable tombstone before accepting requests. Real
354
420
  // relays replay stored regular events, so a responder returning from an
@@ -363,26 +429,74 @@ export function hostRoomInvitation(opts) {
363
429
  unsubRetirement();
364
430
  return { close };
365
431
  }
432
+ if (opts.coordinate) {
433
+ unsubGrants = opts.transport.subscribe([{ kinds: [KINDS.INVITATION_GRANT], '#d': [invitationId] }], (event) => {
434
+ if (closed || hexEquals(event.pubkey, responder))
435
+ return;
436
+ const to = event.tags.find((t) => t[0] === 'p')?.[1];
437
+ if (to === undefined || !/^[0-9a-f]{64}$/i.test(to))
438
+ return;
439
+ grantSeen.add(normaliseHex(to));
440
+ bound(grantSeen);
441
+ });
442
+ }
366
443
  unsubRequests = opts.transport.subscribe([{ kinds: [KINDS.INVITATION_REQUEST], '#d': [invitationId], '#p': [opts.invitation.inviter] }], (event) => {
367
444
  if (closed)
368
445
  return;
369
446
  const request = decodeInvitationRequest(event, { invitation: opts.invitation, now: now() });
370
447
  // Some relays incorrectly retain and replay ephemeral requests. A newly
371
448
  // admitted delegate must not answer the request that admitted itself.
372
- if (!request || hexEquals(request.device, responder) || answered.has(request.request))
449
+ if (!request || hexEquals(request.device, responder))
450
+ return;
451
+ const existing = requests.get(request.request);
452
+ if (existing) {
453
+ existing.retry();
373
454
  return;
374
- answered.add(request.request);
375
- // A long-running public room must not grow this replay guard without
376
- // bound. Duplicate requests are harmless after eviction: they only
377
- // cause the same encrypted grant to be sent again.
378
- if (answered.size > 256)
379
- answered.delete(answered.values().next().value);
455
+ }
456
+ const at = now();
457
+ for (const [id, state] of requests) {
458
+ if (state.idle() && at - state.createdAt > INVITATION_MAX_AGE_SECONDS)
459
+ requests.delete(id);
460
+ }
461
+ // Saturation refuses new work rather than forgetting a live decision.
462
+ if (requests.size >= 256)
463
+ return;
464
+ let approved = !opts.admit;
465
+ let deciding = !approved;
466
+ let busy = false;
467
+ let stoodDown = false;
468
+ let acknowledged = false;
469
+ let grant;
470
+ const state = { createdAt: event.created_at, idle: () => !deciding && !busy, retry: () => { if (approved && !busy)
471
+ grantLater(); } };
472
+ requests.set(request.request, state);
473
+ grantSeen.delete(normaliseHex(request.device));
474
+ const current = () => !closed && requests.get(request.request) === state;
475
+ const fresh = () => {
476
+ if (!current())
477
+ return false;
478
+ if (!decodeInvitationRequest(event, { invitation: opts.invitation, now: now() })) {
479
+ try {
480
+ opts.onGrantFailed?.(request, new Error('the invitation request expired before admission'));
481
+ }
482
+ catch { /* Observer only. */ }
483
+ return false;
484
+ }
485
+ // Reusing a signed grant must not extend the responder's authority.
486
+ const authority = verifyInvitationDelegation(opts.invitation, delegation, now());
487
+ if (authority === null || !hexEquals(authority, responder)) {
488
+ close();
489
+ return false;
490
+ }
491
+ return true;
492
+ };
380
493
  const grantNow = () => {
381
- if (closed)
494
+ if (!fresh()) {
495
+ busy = false;
382
496
  return;
383
- let grant;
497
+ }
384
498
  try {
385
- grant = encodeInvitationGrant({
499
+ grant ??= encodeInvitationGrant({
386
500
  invitation: opts.invitation,
387
501
  inviterSk: opts.inviterSk,
388
502
  requester: request.device,
@@ -399,16 +513,17 @@ export function hostRoomInvitation(opts) {
399
513
  close();
400
514
  return;
401
515
  }
402
- // The decision to approve and the publication of its grant are
403
- // separate states. A rejected send must never look like admission.
516
+ // The first ephemeral grant may precede the guest's subscription.
517
+ // A retry sends this exact envelope, never new delegation or approval.
404
518
  Promise.resolve()
405
- .then(() => { if (!closed)
406
- return opts.transport.publish(grant); })
407
- .then(() => {
408
- if (closed)
519
+ .then(async () => {
520
+ if (!fresh())
409
521
  return;
410
- // One observer throwing must not change the publish outcome or
411
- // prevent an independent observer from hearing it.
522
+ await opts.transport.publish(grant);
523
+ if (!current() || acknowledged)
524
+ return;
525
+ acknowledged = true;
526
+ // Observer acknowledgement is once per request, not per retry.
412
527
  try {
413
528
  opts.onGrantPublished?.(request);
414
529
  }
@@ -417,64 +532,78 @@ export function hostRoomInvitation(opts) {
417
532
  opts.onAdmitted?.(request.device);
418
533
  }
419
534
  catch { /* Observer only. */ }
420
- }, error => {
421
- if (!closed)
422
- opts.onGrantFailed?.(request, error);
423
535
  })
424
- .catch(() => { });
536
+ .catch(error => {
537
+ if (current()) {
538
+ try {
539
+ opts.onGrantFailed?.(request, error);
540
+ }
541
+ catch { /* Observer only. */ }
542
+ }
543
+ })
544
+ .finally(() => { busy = false; });
425
545
  };
426
- if (!opts.admit) {
546
+ const grantAfterWait = () => {
547
+ if (!fresh()) {
548
+ busy = false;
549
+ return;
550
+ }
551
+ if (opts.coordinate && grantSeen.has(normaliseHex(request.device)) && !stoodDown) {
552
+ // Stand down at most once. The retry retains the original approval.
553
+ stoodDown = true;
554
+ busy = false;
555
+ return;
556
+ }
427
557
  grantNow();
558
+ };
559
+ const grantLater = () => {
560
+ if (!current() || busy)
561
+ return;
562
+ busy = true;
563
+ const delay = delayRange ? replyDelay(delayRange, random) : 0;
564
+ if (delay === 0) {
565
+ grantAfterWait();
566
+ return;
567
+ }
568
+ if (waits.size >= 256) {
569
+ busy = false;
570
+ return;
571
+ }
572
+ let cancel = () => { };
573
+ let done = false;
574
+ cancel = schedule(() => { done = true; waits.delete(cancel); grantAfterWait(); }, delay);
575
+ if (!done)
576
+ waits.add(cancel);
577
+ };
578
+ if (approved) {
579
+ grantLater();
428
580
  return;
429
581
  }
430
- // A host that asks first answers later, if at all. Never awaited in
431
- // the relay callback, and a hook that throws declines.
582
+ // Pending and declined decisions stay cached. Duplicates cannot ask
583
+ // again, race approval, or publish before the original decision.
432
584
  Promise.resolve()
433
- .then(() => opts.admit(request))
434
- .then((yes) => { if (yes)
435
- grantNow(); })
436
- .catch(() => { });
585
+ .then(() => current() ? opts.admit(request) : false)
586
+ .then((yes) => { deciding = false; if (yes && current()) {
587
+ approved = true;
588
+ grantLater();
589
+ } })
590
+ .catch(() => { deciding = false; });
437
591
  });
438
592
  return { close };
439
593
  }
440
- /** Resolve the room and a bounded responder delegation, with no account or
441
- * prompt. Retaining the delegation is what removes the creator as an
442
- * availability dependency for the next arrival. */
594
+ /** Resolve the room and a bounded responder delegation. Anonymous requests
595
+ * need no account. Supplying an identity signs a proof for this request; its
596
+ * signer may ask the person to approve. Retaining the delegation removes the
597
+ * creator as an availability dependency for the next arrival. */
443
598
  export function requestRoomAdmissionCapability(opts) {
444
599
  const now = opts.now ?? (() => Math.floor(Date.now() / 1000));
445
- const requesterSk = opts.requesterSk ?? generateSecretKey();
446
- const requester = getPublicKey(requesterSk);
447
- const request = encodeInvitationRequest({
448
- invitation: opts.invitation, requesterSk, now: now(),
449
- ...(opts.name !== undefined ? { name: opts.name } : {}),
450
- ...(opts.participant !== undefined ? { participant: opts.participant } : {}),
451
- });
452
600
  return new Promise((resolve, reject) => {
453
601
  let settled = false;
602
+ let ownedKey;
603
+ let request;
454
604
  let retry;
455
605
  let expiry;
456
- const invitationId = deriveInvitationId(opts.invitation);
457
606
  let unsub = () => { };
458
- unsub = opts.transport.subscribe([
459
- { kinds: [KINDS.INVITATION_GRANT], '#d': [invitationId], '#p': [requester] },
460
- { kinds: [KINDS.INVITATION_RETIREMENT], '#d': [invitationId], authors: [opts.invitation.inviter] },
461
- ], (event) => {
462
- const retired = decodeInvitationRetirementNotice(event, opts.invitation);
463
- if (retired) {
464
- finish(() => reject(retirementError(retired)));
465
- return;
466
- }
467
- const admission = decodeRoomAdmissionGrant(event, {
468
- invitation: opts.invitation,
469
- requesterSk,
470
- request: request.id,
471
- now: now(),
472
- });
473
- if (admission)
474
- finish(() => resolve(admission));
475
- });
476
- if (settled)
477
- unsub();
478
607
  function finish(settle) {
479
608
  if (settled)
480
609
  return;
@@ -483,16 +612,108 @@ export function requestRoomAdmissionCapability(opts) {
483
612
  clearInterval(retry);
484
613
  if (expiry !== undefined)
485
614
  clearTimeout(expiry);
486
- unsub();
615
+ opts.signal?.removeEventListener('abort', abort);
616
+ try {
617
+ unsub();
618
+ }
619
+ catch { /* A broken observer cannot keep the request live. */ }
620
+ ownedKey?.fill(0);
487
621
  settle();
488
622
  }
623
+ function abort() {
624
+ const error = new Error('invitation request cancelled');
625
+ error.name = 'AbortError';
626
+ finish(() => reject(error));
627
+ }
489
628
  function ask() {
490
- if (!settled)
491
- opts.transport.publish(request).catch(() => { });
629
+ if (!settled && request) {
630
+ // Keep the same signed request on retries; a late publish result
631
+ // never revives a cancelled or expired exchange.
632
+ try {
633
+ void opts.transport.publish(request).catch(() => { });
634
+ }
635
+ catch { /* Retried within the same deadline. */ }
636
+ }
637
+ }
638
+ try {
639
+ if (opts.signal?.aborted) {
640
+ abort();
641
+ return;
642
+ }
643
+ const requestedTimeout = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, retryMs = opts.retryMs ?? DEFAULT_RETRY_MS;
644
+ if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0 || !Number.isFinite(retryMs) || retryMs <= 0)
645
+ throw new Error('invalid invitation request timing');
646
+ const participant = opts.participant === undefined
647
+ ? (opts.identity ? requirePubkey(opts.identity.pubkey) : undefined)
648
+ : requirePubkey(opts.participant);
649
+ if (opts.identity && (!participant || !hexEquals(requirePubkey(opts.identity.pubkey), participant)))
650
+ throw new Error('invitation account does not match the requested participant');
651
+ // Clone caller-owned keys before asynchronous signing, and erase only
652
+ // our copy on every outcome. The caller retains ownership of its key.
653
+ if (opts.requesterSk)
654
+ require32(opts.requesterSk, 'requester secret key');
655
+ ownedKey = opts.requesterSk?.slice() ?? generateSecretKey();
656
+ const requester = getPublicKey(ownedKey), invitationId = deriveInvitationId(opts.invitation), requestedAt = now();
657
+ if (!Number.isSafeInteger(requestedAt) || requestedAt < 0)
658
+ throw new Error('invalid invitation request time');
659
+ // Signing and relay waiting share one deadline, no longer than the
660
+ // request's authenticated freshness window. Approval does not renew it.
661
+ expiry = setTimeout(() => finish(() => reject(new Error(opts.identity && !request
662
+ ? 'the account did not finish signing the invitation request'
663
+ : 'the room is not answering this invitation'))), Math.min(requestedTimeout, INVITATION_MAX_AGE_SECONDS * 1000));
664
+ opts.signal?.addEventListener('abort', abort, { once: true });
665
+ if (opts.signal?.aborted) {
666
+ abort();
667
+ return;
668
+ }
669
+ unsub = opts.transport.subscribe([
670
+ { kinds: [KINDS.INVITATION_GRANT], '#d': [invitationId], '#p': [requester] },
671
+ { kinds: [KINDS.INVITATION_RETIREMENT], '#d': [invitationId], authors: [opts.invitation.inviter] },
672
+ ], event => {
673
+ if (settled)
674
+ return;
675
+ const retired = decodeInvitationRetirementNotice(event, opts.invitation);
676
+ if (retired) {
677
+ finish(() => reject(retirementError(retired)));
678
+ return;
679
+ }
680
+ if (!request)
681
+ return;
682
+ const declined = decodeInvitationDecline(event, { invitation: opts.invitation, requesterSk: ownedKey, request: request.id, now: now() });
683
+ if (declined) {
684
+ finish(() => reject(new InvitationDeclinedError()));
685
+ return;
686
+ }
687
+ const admission = decodeRoomAdmissionGrant(event, { invitation: opts.invitation, requesterSk: ownedKey, request: request.id, now: now() });
688
+ if (admission) {
689
+ // The delegation owns its responder key after admission. Erasing
690
+ // our exchange key must not erase that independently retained key.
691
+ const retained = { ...admission, delegate: { ...admission.delegate, delegateSk: admission.delegate.delegateSk.slice() } };
692
+ finish(() => resolve(retained));
693
+ }
694
+ });
695
+ if (settled) {
696
+ unsub();
697
+ return;
698
+ }
699
+ const prepare = async () => {
700
+ const accountProof = opts.identity ? await encodeInvitationAccountProof({ invitation: opts.invitation, device: requester, identity: opts.identity, now: requestedAt }) : undefined;
701
+ if (settled)
702
+ return;
703
+ const at = now();
704
+ if (!Number.isSafeInteger(at) || Math.abs(at - requestedAt) > INVITATION_MAX_AGE_SECONDS)
705
+ throw new Error('the invitation request expired while signing');
706
+ request = encodeInvitationRequest({ invitation: opts.invitation, requesterSk: ownedKey, now: requestedAt,
707
+ ...(opts.name !== undefined ? { name: opts.name } : {}), ...(participant !== undefined ? { participant } : {}),
708
+ ...(accountProof ? { accountProof } : {}) });
709
+ retry = setInterval(ask, retryMs);
710
+ ask();
711
+ };
712
+ void prepare().catch(error => finish(() => reject(error)));
713
+ }
714
+ catch (error) {
715
+ finish(() => reject(error));
492
716
  }
493
- expiry = setTimeout(() => finish(() => reject(new Error('the room is not answering this invitation'))), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
494
- retry = setInterval(ask, opts.retryMs ?? DEFAULT_RETRY_MS);
495
- ask();
496
717
  });
497
718
  }
498
719
  /** Compatibility wrapper for non-interactive consumers. It joins correctly,
@@ -505,6 +726,7 @@ export async function requestRoomAdmission(opts) {
505
726
  * list rather than scanning file text for matching comments. Pure data -
506
727
  * adding this export changes no runtime behaviour. */
507
728
  export const INVITATION_LABELS = [
729
+ "kithmoot/v2/invitation-account-proof",
508
730
  "kithmoot/v2/invitation-delegation:",
509
731
  "kithmoot/v2/invitation-id",
510
732
  "kithmoot/v2/invitation-request-key",
@@ -0,0 +1,79 @@
1
+ import type { UnsignedEvent } from './identity.js';
2
+ export declare const MLS_ADMISSION_LABELS: readonly string[];
3
+ export declare const MLS_ADMISSION_PLAINTEXT_BYTES = 16384;
4
+ export declare const MLS_ADMISSION_CIPHERTEXT_CHARACTERS = 21936;
5
+ /** Evidence only. No admission/capability constructor is exported. */
6
+ export interface VerifiedMlsAdmissionRequest {
7
+ readonly __verifiedMlsAdmissionRequest: unique symbol;
8
+ }
9
+ export interface VerifiedAdmissionAnswer {
10
+ readonly __verifiedAdmissionAnswer: unique symbol;
11
+ }
12
+ /** Link fragment only; caller owns CSPRNG generation and secret lifetime. */
13
+ export declare function encodeMlsInvitationV4(root: string, bearer: Uint8Array): string;
14
+ export declare function readMlsInvitationV4(fragment: string): {
15
+ root: string;
16
+ bearer: Uint8Array;
17
+ invitationId: string;
18
+ };
19
+ /** Templates only. Account/device signers remain caller-owned and settle under
20
+ * the application's actual custody. Returned templates never confer authority.
21
+ * core is the exact canonical request array or, with request, Allow array. */
22
+ export declare function mlsAdmissionProofTemplatesV4(opts: {
23
+ fragment: string;
24
+ core: string;
25
+ now: number;
26
+ request?: VerifiedMlsAdmissionRequest;
27
+ }): {
28
+ pubkey: string;
29
+ event: UnsignedEvent;
30
+ }[];
31
+ /** proofs is canonical JSON of the three seven-field signed proof arrays. */
32
+ export declare function encodeMlsAdmissionRequestV4(opts: {
33
+ fragment: string;
34
+ core: string;
35
+ proofs: string;
36
+ requestSecretKey: Uint8Array;
37
+ now: number;
38
+ }): string;
39
+ /** Either endpoint may decode its exact request; only this decoder issues the
40
+ * nominal result, after fresh outer/inner checks. Caller secrets are not kept. */
41
+ export declare function decodeMlsAdmissionRequestV4(opts: {
42
+ fragment: string;
43
+ event: string;
44
+ localSecretKey: Uint8Array;
45
+ side: 'root' | 'joiner';
46
+ now: number;
47
+ }): VerifiedMlsAdmissionRequest;
48
+ /** Returned strings are copies of evidence, not a rehydration format. */
49
+ export declare function readMlsAdmissionRequestV4(handle: VerifiedMlsAdmissionRequest): Readonly<{
50
+ core: string;
51
+ digest: string;
52
+ eventId: string;
53
+ }>;
54
+ /** Root correlation and signed keeper consent only. This encoder cannot attest
55
+ * current keeper role; actual vault/box custody and fresh checks stay external. */
56
+ export declare function encodeMlsAdmissionAnswerV4(opts: {
57
+ fragment: string;
58
+ request: VerifiedMlsAdmissionRequest;
59
+ core: string;
60
+ proofs: string;
61
+ rootSecretKey: Uint8Array;
62
+ now: number;
63
+ }): string;
64
+ export declare function decodeMlsAdmissionAnswerV4(opts: {
65
+ fragment: string;
66
+ request: VerifiedMlsAdmissionRequest;
67
+ event: string;
68
+ requestSecretKey: Uint8Array;
69
+ now: number;
70
+ }): VerifiedAdmissionAnswer;
71
+ /** Cryptographic evidence only: no usable capability, child or admission. The
72
+ * application must obtain current role witness and consume the operation once. */
73
+ export declare function readMlsAdmissionAnswerV4(handle: VerifiedAdmissionAnswer): Readonly<{
74
+ decision: 'allow' | 'deny';
75
+ core: string;
76
+ digest: string;
77
+ eventId: string;
78
+ requestEventId: string;
79
+ }>;