@forgesworn/fold-kit 0.9.0 → 0.12.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.
@@ -0,0 +1,74 @@
1
+ import { nip44 } from 'nostr-tools';
2
+ import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
3
+ import { hexEquals, normaliseHex } from './hex.js';
4
+ import { KINDS } from './kinds.js';
5
+ import { deriveInvitationId, verifyInvitationDelegation } from './invitation.js';
6
+ import { verifyEventUncached } from './verify.js';
7
+ export class InvitationDeclinedError extends Error {
8
+ constructor() {
9
+ super('Someone in the room declined your request to join.');
10
+ this.name = 'InvitationDeclinedError';
11
+ }
12
+ }
13
+ /** A private refusal, carrying no room secret or new responder delegation.
14
+ * Version 3 distinguishes it from version-2 grants on the same reply kind.
15
+ * Older readers ignore it and continue their existing bounded wait. */
16
+ export function encodeInvitationDecline(opts) {
17
+ if (opts.inviterSk.length !== 32)
18
+ throw new Error('inviter secret key must be 32 bytes');
19
+ if (!Number.isSafeInteger(opts.now) || opts.now < 0)
20
+ throw new Error('invalid invitation decline time');
21
+ if (!/^[0-9a-f]{64}$/i.test(opts.requester) || !/^[0-9a-f]{64}$/i.test(opts.request)) {
22
+ throw new Error('request and requester must be 32-byte hex');
23
+ }
24
+ const responder = getPublicKey(opts.inviterSk);
25
+ const delegation = opts.delegation ?? [];
26
+ const authority = verifyInvitationDelegation(opts.invitation, delegation, opts.now);
27
+ if (authority === null || !hexEquals(authority, responder)) {
28
+ throw new Error('responder is not delegated for invitation');
29
+ }
30
+ const requester = normaliseHex(opts.requester);
31
+ return finalizeEvent({
32
+ kind: KINDS.INVITATION_GRANT,
33
+ created_at: opts.now,
34
+ tags: [['d', deriveInvitationId(opts.invitation)], ['p', requester]],
35
+ content: nip44.v2.encrypt(JSON.stringify({
36
+ v: 3, decision: 'declined', request: normaliseHex(opts.request), delegation,
37
+ }), nip44.v2.utils.getConversationKey(opts.inviterSk, requester)),
38
+ }, opts.inviterSk);
39
+ }
40
+ /** Only a fresh, request-bound refusal from the pinned root or a currently
41
+ * delegated responder can stop this guest's wait. A bearer holder has no
42
+ * authority to refuse someone else. No room capability is returned. */
43
+ export function decodeInvitationDecline(event, opts) {
44
+ try {
45
+ const age = opts.maxAgeSeconds ?? 90;
46
+ if (!Number.isSafeInteger(opts.now) || opts.now < 0 || !Number.isSafeInteger(age) || age < 0)
47
+ return null;
48
+ if (event.kind !== KINDS.INVITATION_GRANT || !verifyEventUncached(event))
49
+ return null;
50
+ if (!Number.isSafeInteger(event.created_at) || event.created_at < 0 || Math.abs(opts.now - event.created_at) > age)
51
+ return null;
52
+ const rendezvous = event.tags.filter(t => t[0] === 'd');
53
+ const addressed = event.tags.filter(t => t[0] === 'p');
54
+ if (rendezvous.length !== 1 || rendezvous[0]?.[1] !== deriveInvitationId(opts.invitation))
55
+ return null;
56
+ const requester = getPublicKey(opts.requesterSk);
57
+ if (addressed.length !== 1 || !hexEquals(addressed[0]?.[1] ?? '', requester))
58
+ return null;
59
+ const body = JSON.parse(nip44.v2.decrypt(event.content, nip44.v2.utils.getConversationKey(opts.requesterSk, event.pubkey)));
60
+ if (!body || body.v !== 3 || body.decision !== 'declined')
61
+ return null;
62
+ if (typeof body.request !== 'string' || !/^[0-9a-f]{64}$/i.test(body.request) || !hexEquals(body.request, opts.request))
63
+ return null;
64
+ if ('secret' in body || !Array.isArray(body.delegation))
65
+ return null;
66
+ const authority = verifyInvitationDelegation(opts.invitation, body.delegation, opts.now);
67
+ if (authority === null || !hexEquals(authority, event.pubkey))
68
+ return null;
69
+ return { request: normaliseHex(body.request), responder: normaliseHex(event.pubkey) };
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
@@ -1,5 +1,7 @@
1
1
  import { type Event } from 'nostr-tools/pure';
2
2
  import type { RelayTransport } from './transport.js';
3
+ import type { ParticipantIdentity } from './identity.js';
4
+ import { type ReplyDelayRange, type ReplySchedule } from './quiet-reply.js';
3
5
  /** A delegated responder cannot silently make its authority permanent. */
4
6
  export declare const INVITATION_DELEGATION_TTL_SECONDS: number;
5
7
  /** Bounds both verification work and the size of a grant from a hostile peer. */
@@ -73,13 +75,31 @@ export interface EncodeInvitationRequestOptions {
73
75
  now: number;
74
76
  name?: string;
75
77
  participant?: string;
78
+ /** Account-signed proof bound to this invitation, request device and time.
79
+ * The proof stays inside the bearer-encrypted request, never a public relay
80
+ * event. Without it, `participant` remains an unverified claim. */
81
+ accountProof?: Event;
76
82
  }
83
+ export interface EncodeInvitationAccountProofOptions {
84
+ invitation: RoomInvitation;
85
+ device: string;
86
+ identity: ParticipantIdentity;
87
+ now: number;
88
+ }
89
+ /** Prove control of an account for one ephemeral admission device. This is
90
+ * deliberately separate from a room device credential: a guest does not yet
91
+ * know the room or its traffic key, and this proof grants neither. */
92
+ export declare function encodeInvitationAccountProof(opts: EncodeInvitationAccountProofOptions): Promise<Event>;
77
93
  /** Who is asking, as decoded from a request. */
78
94
  export interface InvitationRequest {
79
95
  device: string;
80
96
  request: string;
81
97
  name?: string;
82
98
  participant?: string;
99
+ /** Present only after fresh signature verification of an account proof
100
+ * bound to this exact invitation, request-signing device and timestamp.
101
+ * Never authorise automatic admission from `participant` alone. */
102
+ verifiedParticipant?: string;
83
103
  }
84
104
  /** Prove possession of the bearer without putting it, or a traffic key, on a relay. */
85
105
  export declare function encodeInvitationRequest(opts: EncodeInvitationRequestOptions): Event;
@@ -139,14 +159,21 @@ export interface HostRoomInvitationOptions {
139
159
  /** Root-to-this-responder chain. Empty/absent only on the creator. */
140
160
  delegation?: InvitationDelegation[];
141
161
  now?: () => number;
162
+ /** Called after the injected transport acknowledges publication. This is
163
+ * not proof that the guest received the grant or joined the room. */
142
164
  onAdmitted?: (device: string) => void;
165
+ /** The same publication acknowledgement, correlated to the request. */
166
+ onGrantPublished?: (request: InvitationRequest) => void;
167
+ /** A publication attempt failed, or approval came after request expiry.
168
+ * A valid retry may recover; no admission callback follows a failed send. */
169
+ onGrantFailed?: (request: InvitationRequest, error: unknown) => void;
143
170
  /** Called when the creator's durable retirement tombstone is heard. */
144
171
  onRetired?: () => void;
145
172
  /** The epoch this responder is at, asked on every grant because it
146
173
  * moves. Omit to say nothing, which a joiner treats as unknown. */
147
174
  epoch?: () => number;
148
175
  /**
149
- * Asked before every grant, when present. Return true to let the
176
+ * Asked once per fresh signed request, when present. Return true to let the
150
177
  * request in, false to leave it unanswered: there is no refusal on the
151
178
  * wire, so a declined person sees the room not answer, which is the same
152
179
  * as nobody being home. A request is asked about once, whatever its
@@ -155,6 +182,30 @@ export interface HostRoomInvitationOptions {
155
182
  * room's link has always meant.
156
183
  */
157
184
  admit?: (request: InvitationRequest) => boolean | Promise<boolean>;
185
+ /**
186
+ * Wait a random time in this range, in milliseconds, before publishing
187
+ * each grant, after `admit` when there is one. Absent, a grant goes out
188
+ * as soon as it is decided, as it always has (or after
189
+ * `DEFAULT_REPLY_DELAY_MS` when `coordinate` is set). For a device that
190
+ * answers automatically: an instant answer under the same key tells
191
+ * whoever holds the link when that device is online.
192
+ */
193
+ grantDelayMs?: ReplyDelayRange;
194
+ /**
195
+ * Several of the room's devices answer the same link: wait first, and
196
+ * stand down if another responder's grant to the same requester appears
197
+ * during the wait, so normally one device answers. Waits
198
+ * `grantDelayMs`, default `[500, 2000]`. A grant is seen by its public
199
+ * tags only, so anyone holding the link could post a false one; this
200
+ * responder stands down at most once per request, and answers the
201
+ * requester's next retry. Absent or false, nothing changes and no extra
202
+ * subscription is opened.
203
+ */
204
+ coordinate?: boolean;
205
+ /** For tests: a source of numbers in [0, 1). Default `Math.random`. */
206
+ random?: () => number;
207
+ /** For tests: how the wait is timed. Default `setTimeout`. */
208
+ schedule?: ReplySchedule;
158
209
  }
159
210
  export interface EncodeInvitationRetirementOptions {
160
211
  invitation: RoomInvitation;
@@ -213,10 +264,15 @@ export interface RequestRoomAdmissionOptions {
213
264
  /** Carried in the request for a host who asks before letting people in. */
214
265
  name?: string;
215
266
  participant?: string;
267
+ /** Optional proof of the claimed account, made by this matching identity.
268
+ * A signer error is a failed request, never a switch to another account. */
269
+ identity?: ParticipantIdentity;
270
+ signal?: AbortSignal;
216
271
  }
217
- /** Resolve the room and a bounded responder delegation, with no account or
218
- * prompt. Retaining the delegation is what removes the creator as an
219
- * availability dependency for the next arrival. */
272
+ /** Resolve the room and a bounded responder delegation. Anonymous requests
273
+ * need no account. Supplying an identity signs a proof for this request; its
274
+ * signer may ask the person to approve. Retaining the delegation removes the
275
+ * creator as an availability dependency for the next arrival. */
220
276
  export declare function requestRoomAdmissionCapability(opts: RequestRoomAdmissionOptions): Promise<RoomAdmission>;
221
277
  /** Compatibility wrapper for non-interactive consumers. It joins correctly,
222
278
  * but discards the ability to keep the link available for somebody else. */
@@ -225,4 +281,4 @@ export declare function requestRoomAdmission(opts: RequestRoomAdmissionOptions):
225
281
  * `src/labels.test.ts`, which checks each module against its own exported
226
282
  * list rather than scanning file text for matching comments. Pure data -
227
283
  * adding this export changes no runtime behaviour. */
228
- export declare const INVITATION_LABELS: readonly ["kithmoot/v2/invitation-delegation:", "kithmoot/v2/invitation-id", "kithmoot/v2/invitation-request-key"];
284
+ export declare const INVITATION_LABELS: readonly ["kithmoot/v2/invitation-account-proof", "kithmoot/v2/invitation-delegation:", "kithmoot/v2/invitation-id", "kithmoot/v2/invitation-request-key"];
@@ -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,61 +513,97 @@ export function hostRoomInvitation(opts) {
399
513
  close();
400
514
  return;
401
515
  }
402
- opts.transport.publish(grant).catch(() => { });
403
- opts.onAdmitted?.(request.device);
516
+ // The first ephemeral grant may precede the guest's subscription.
517
+ // A retry sends this exact envelope, never new delegation or approval.
518
+ Promise.resolve()
519
+ .then(async () => {
520
+ if (!fresh())
521
+ return;
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.
527
+ try {
528
+ opts.onGrantPublished?.(request);
529
+ }
530
+ catch { /* Observer only. */ }
531
+ try {
532
+ opts.onAdmitted?.(request.device);
533
+ }
534
+ catch { /* Observer only. */ }
535
+ })
536
+ .catch(error => {
537
+ if (current()) {
538
+ try {
539
+ opts.onGrantFailed?.(request, error);
540
+ }
541
+ catch { /* Observer only. */ }
542
+ }
543
+ })
544
+ .finally(() => { busy = false; });
404
545
  };
405
- 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
+ }
406
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();
407
580
  return;
408
581
  }
409
- // A host that asks first answers later, if at all. Never awaited in
410
- // 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.
411
584
  Promise.resolve()
412
- .then(() => opts.admit(request))
413
- .then((yes) => { if (yes)
414
- grantNow(); })
415
- .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; });
416
591
  });
417
592
  return { close };
418
593
  }
419
- /** Resolve the room and a bounded responder delegation, with no account or
420
- * prompt. Retaining the delegation is what removes the creator as an
421
- * 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. */
422
598
  export function requestRoomAdmissionCapability(opts) {
423
599
  const now = opts.now ?? (() => Math.floor(Date.now() / 1000));
424
- const requesterSk = opts.requesterSk ?? generateSecretKey();
425
- const requester = getPublicKey(requesterSk);
426
- const request = encodeInvitationRequest({
427
- invitation: opts.invitation, requesterSk, now: now(),
428
- ...(opts.name !== undefined ? { name: opts.name } : {}),
429
- ...(opts.participant !== undefined ? { participant: opts.participant } : {}),
430
- });
431
600
  return new Promise((resolve, reject) => {
432
601
  let settled = false;
602
+ let ownedKey;
603
+ let request;
433
604
  let retry;
434
605
  let expiry;
435
- const invitationId = deriveInvitationId(opts.invitation);
436
606
  let unsub = () => { };
437
- unsub = opts.transport.subscribe([
438
- { kinds: [KINDS.INVITATION_GRANT], '#d': [invitationId], '#p': [requester] },
439
- { kinds: [KINDS.INVITATION_RETIREMENT], '#d': [invitationId], authors: [opts.invitation.inviter] },
440
- ], (event) => {
441
- const retired = decodeInvitationRetirementNotice(event, opts.invitation);
442
- if (retired) {
443
- finish(() => reject(retirementError(retired)));
444
- return;
445
- }
446
- const admission = decodeRoomAdmissionGrant(event, {
447
- invitation: opts.invitation,
448
- requesterSk,
449
- request: request.id,
450
- now: now(),
451
- });
452
- if (admission)
453
- finish(() => resolve(admission));
454
- });
455
- if (settled)
456
- unsub();
457
607
  function finish(settle) {
458
608
  if (settled)
459
609
  return;
@@ -462,16 +612,108 @@ export function requestRoomAdmissionCapability(opts) {
462
612
  clearInterval(retry);
463
613
  if (expiry !== undefined)
464
614
  clearTimeout(expiry);
465
- 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);
466
621
  settle();
467
622
  }
623
+ function abort() {
624
+ const error = new Error('invitation request cancelled');
625
+ error.name = 'AbortError';
626
+ finish(() => reject(error));
627
+ }
468
628
  function ask() {
469
- if (!settled)
470
- 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));
471
716
  }
472
- expiry = setTimeout(() => finish(() => reject(new Error('the room is not answering this invitation'))), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
473
- retry = setInterval(ask, opts.retryMs ?? DEFAULT_RETRY_MS);
474
- ask();
475
717
  });
476
718
  }
477
719
  /** Compatibility wrapper for non-interactive consumers. It joins correctly,
@@ -484,6 +726,7 @@ export async function requestRoomAdmission(opts) {
484
726
  * list rather than scanning file text for matching comments. Pure data -
485
727
  * adding this export changes no runtime behaviour. */
486
728
  export const INVITATION_LABELS = [
729
+ "kithmoot/v2/invitation-account-proof",
487
730
  "kithmoot/v2/invitation-delegation:",
488
731
  "kithmoot/v2/invitation-id",
489
732
  "kithmoot/v2/invitation-request-key",