@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.
package/README.md CHANGED
@@ -13,10 +13,12 @@ vectors (see EXTRACTION.md). Framework-free: owns no storage, UI, relay
13
13
  defaults or identity keys, and no relay I/O of its own - callers inject a
14
14
  transport.
15
15
 
16
- **Status:** T1 extraction complete (see EXTRACTION.md). Not yet published to
17
- npm under this API; pin an immutable Git commit until it is (see Install).
18
- KithMoot itself has not cut over to this kit yet - it still carries its own
19
- copy of these modules.
16
+ **Status:** Version 0.12.0 on `main`. Published on npm as
17
+ `@forgesworn/fold-kit`, but `main` can run ahead of the npm `latest` release, so
18
+ compare `npm view @forgesworn/fold-kit version` with this repository's
19
+ `package.json` before relying on an API listed here. KithMoot has cut over to
20
+ this kit and pins an exact npm version; its moved files are re-export shims
21
+ (see EXTRACTION.md).
20
22
 
21
23
  ## Install
22
24
 
@@ -24,14 +26,12 @@ copy of these modules.
24
26
  npm install @forgesworn/fold-kit
25
27
  ```
26
28
 
27
- Not yet published to npm. Until it is, pin an immutable Git commit:
29
+ To use a change that has not been released, pin an immutable Git commit (a full
30
+ SHA, for example from `git ls-remote https://github.com/forgesworn/fold-kit main`).
31
+ The package's `prepare` script builds `dist/` on install:
28
32
 
29
- ```json
30
- {
31
- "dependencies": {
32
- "@forgesworn/fold-kit": "github:forgesworn/fold-kit#<commit>"
33
- }
34
- }
33
+ ```bash
34
+ npm install "github:forgesworn/fold-kit#<commit>"
35
35
  ```
36
36
 
37
37
  ### Peer dependencies
@@ -46,6 +46,12 @@ Not yet published to npm. Until it is, pin an immutable Git commit:
46
46
  }
47
47
  ```
48
48
 
49
+ Install the peers yourself, keeping `@noble/hashes` on 1.x:
50
+
51
+ ```bash
52
+ npm install "nostr-tools@>=2.24.2 <3" "@noble/hashes@^1.8.0" "@noble/curves@^2.0.1"
53
+ ```
54
+
49
55
  Peers, not dependencies, so a consumer such as KithMoot keeps a single copy
50
56
  of `nostr-tools` (guarded by its own `overrides` and version-guard test) and
51
57
  of the noble libraries, at the same versions KithMoot already pins.
@@ -57,12 +63,69 @@ satisfy this kit's imports as written.
57
63
 
58
64
  ESM only (`"type": "module"`). Node.js `>=22.13`.
59
65
 
66
+ ## Quick start
67
+
68
+ Derive a room, then publish and load a stored group invitation through a
69
+ `RelayTransport` you supply (this one is an in-memory stub; a real transport
70
+ talks to relays and applies the filters):
71
+
72
+ ```ts
73
+ import {
74
+ generateRoomSecret,
75
+ deriveRoom,
76
+ deriveEpoch,
77
+ localIdentity,
78
+ createRoomInvitation,
79
+ encodePersistentInvitation,
80
+ requestPersistentRoomAdmission,
81
+ type RelayTransport,
82
+ } from '@forgesworn/fold-kit'
83
+ import { generateSecretKey, type Event } from 'nostr-tools/pure'
84
+
85
+ // The person: a local signer. An external one (NIP-07, NIP-46) fits the same seam.
86
+ const me = localIdentity(generateSecretKey())
87
+
88
+ // The room: one secret opens epoch 0; later epochs come from rekeys.
89
+ const roomSecret = generateRoomSecret()
90
+ const { roomId, roomKey } = deriveRoom(roomSecret)
91
+ const epoch0 = deriveEpoch({ epoch: 0, secret: roomSecret }) // { epoch: 0, id: roomId, key: roomKey }
92
+
93
+ // A RelayTransport stub: stores events, replays them on subscribe, then signals EOSE.
94
+ const stored: Event[] = []
95
+ const transport: RelayTransport = {
96
+ async publish(event) { stored.push(event) },
97
+ subscribe(_filters, onEvent, onEose) {
98
+ queueMicrotask(() => { for (const event of stored) onEvent(event); onEose?.() })
99
+ return () => {}
100
+ },
101
+ close() {},
102
+ }
103
+
104
+ // Invitation flow (v3 stored group invitation): the inviter publishes once, and a
105
+ // guest holding the link loads it from the relays with no responder online.
106
+ const { invitation, inviterSk } = createRoomInvitation(true)
107
+ await transport.publish(encodePersistentInvitation({
108
+ invitation, inviterSk, roomSecret, now: Math.floor(Date.now() / 1000),
109
+ }))
110
+ const admission = await requestPersistentRoomAdmission({ transport, invitation })
111
+ // admission.secret is the room secret: deriveRoom(admission.secret).roomId === roomId
112
+ ```
113
+
114
+ Invitation versions: v1 (`encodeJoinUrl`, the room secret in the link) is
115
+ legacy, and new links go through `createRoomInvitation`. Use
116
+ `createRoomInvitation()` for the v2 live rendezvous, which needs a responder
117
+ online (`hostRoomInvitation` answers, `requestRoomAdmissionCapability` joins),
118
+ or `createRoomInvitation(true)` for the v3 stored group invitation above, which
119
+ needs none.
120
+
60
121
  ## Exports
61
122
 
62
- Two entry points: `@forgesworn/fold-kit` (everything) and
123
+ Four entry points: `@forgesworn/fold-kit` (circle primitives),
63
124
  `@forgesworn/fold-kit/lane` (just the lane helpers, for a consumer that only
64
125
  wants to classify a relay as `public`/`sheltered`/`direct` without pulling in
65
- the rest).
126
+ the rest), `@forgesworn/fold-kit/mls-admission` (opt-in v4 evidence codec), and
127
+ `@forgesworn/fold-kit/persistent-invitation-evidence` (bounded stored invitation
128
+ observation and independent revalidation).
66
129
 
67
130
  ### Identity, verification, hex
68
131
 
@@ -104,13 +167,57 @@ the rest).
104
167
 
105
168
  ### Links and invitations
106
169
 
170
+ `hostRoomInvitation` distinguishes approval from grant publication. Its
171
+ `onGrantPublished(request)` and legacy `onAdmitted(device)` callbacks run only
172
+ after the injected transport acknowledges the grant's publication.
173
+ `onGrantFailed(request, error)` reports a rejected or throwing publication,
174
+ or approval of a request which has expired;
175
+ neither success callback runs for that failure. Closing or retiring the host
176
+ suppresses callbacks from outstanding sends. Publication acknowledgement does
177
+ not prove that a guest received the grant or joined; the guest validates its
178
+ grant independently. Observer exceptions do not change the publish outcome. Fresh retries of the same
179
+ signed request resend the exact cached grant after the configured quiet delay;
180
+ overlapping approval, wait and publication work is coalesced. Approval and
181
+ success callbacks each occur at most once per request. Failed sends may recover
182
+ on a later retry. The host retains up to 256 live decisions, including declines;
183
+ at capacity it refuses new work until settled requests expire. Close, retirement,
184
+ request freshness and responder authority still gate every send.
185
+
186
+ For a device that answers automatically, `grantDelayMs: [min, max]` waits a
187
+ random time before each grant, and `coordinate: true` lets several delegated
188
+ devices share one link: each waits (default 500 to 2000 ms) and stands down,
189
+ at most once per request, if another responder's grant to the same
190
+ requester appears. Both are off by default, and the wire format is
191
+ unchanged. See `docs/quiet-replies.md`.
192
+
193
+ An invitation request's `participant` is an unverified account claim.
194
+ Automatic admission of a previously invited account must use
195
+ `verifiedParticipant`, which is present only after a fresh signature check of
196
+ an account proof bound to this invitation, ephemeral request device and request
197
+ timestamp. `encodeInvitationAccountProof` creates that proof through the
198
+ supplied matching `ParticipantIdentity`. It grants no room access itself and
199
+ stays inside the bearer-encrypted request, rather than being published as a
200
+ public account event. Invalid or missing proofs leave a request unverified;
201
+ hosts can still make an explicit decision for anonymous and older clients.
202
+
203
+ Pass `identity` to `requestRoomAdmissionCapability` (or `requestRoomAdmission`,
204
+ its secret-only wrapper) to create the proof automatically. If `participant` is also supplied, it must
205
+ match the signer; a signer failure rejects the request without falling back to
206
+ another identity. `signal` cancels signing or waiting. Signing and relay waiting
207
+ share one deadline, capped at the request's 90-second freshness window, and
208
+ late signatures or grants cannot revive a cancelled, expired or retired request.
209
+ Retrying publication uses the same signed request. A fresh human retry starts a
210
+ new exchange. The helper copies a supplied `requesterSk` before signing and
211
+ erases only its owned copy on settlement. Hosts recheck freshness when a human
212
+ decision completes, so approval cannot extend an expired request's authority.
213
+
107
214
  - `parseRoomLink`, `encodeRoomLink`, `RoomLink` - the v1/v2/v3 link envelope
108
215
  - `createRoomInvitation`, `roomInvitation`, `deriveInvitationId`,
109
- `encodeInvitationRequest`, `decodeInvitationRequest`,
216
+ `encodeInvitationAccountProof`, `encodeInvitationRequest`, `decodeInvitationRequest`,
110
217
  `verifyInvitationDelegation`, `encodeInvitationGrant`,
111
218
  `decodeRoomAdmissionGrant`, `encodeInvitationRetirement`,
112
219
  `decodeInvitationRetirementNotice`, `hostRoomInvitation`,
113
- `requestRoomAdmission` - the v2 live rendezvous invitation, its delegation
220
+ `requestRoomAdmissionCapability`, `requestRoomAdmission` - the v2 live rendezvous invitation, its delegation
114
221
  chain and retirement
115
222
  - `encodePersistentInvitation`, `decodePersistentInvitation`,
116
223
  `requestPersistentRoomAdmission` - the v3 stored group invitation (1463).
@@ -118,6 +225,23 @@ the rest).
118
225
  conference room: the encrypted body carries `ends` and the event a NIP-40
119
226
  `expiration` tag, so relays drop it when the room ends; the decoder returns
120
227
  `endsAt` and refuses a tag that disagrees with the body
228
+ - From `@forgesworn/fold-kit/persistent-invitation-evidence`,
229
+ `requestPersistentRoomAdmissionEvidence` and
230
+ `revalidatePersistentInvitationEvidence` retain and independently recheck a
231
+ bounded set of canonical signed invitation/retirement carriers, with exact
232
+ invitation and contributing event IDs. The additive evidence path holds on
233
+ conflicting newest equal-time relay lists; the legacy request is unchanged.
234
+ EOSE is local observation metadata, never proof of current membership or
235
+ global non-retirement. Persist the evidence in protected custody before
236
+ using its decoded fields. See [the evidence contract](docs/persistent-invitation-evidence.md).
237
+ - The same optional entry exports `revalidatePersistentInvitationSource` for
238
+ an actual encoder event or its exact canonical retained string. It requires
239
+ the invitation capability and exact room-root pin, independently verifies
240
+ the signature and supported body/tags, and returns one canonical carrier
241
+ plus owned decoded fields. This single source has no EOSE, publication,
242
+ latest-selection or current-membership claim. Creators retain that event
243
+ and their publication intent before opening a pool, then retry the same
244
+ bytes; protected persistence and fresh effect guards belong to the client.
121
245
  - Room relays: pass `relays` (one to eight distinct safe relay URLs in
122
246
  canonical `normalizeURL` form, e.g. `wss://relay.example.com/`) and the
123
247
  encrypted body carries them after `ends`. Every member's pool includes
@@ -152,6 +276,14 @@ the rest).
152
276
  catch-up for a device that missed a rekey
153
277
  - `hostRoomEpoch`, `requestRoomEpoch`, `EpochRefusedError` - the live
154
278
  request/grant desk
279
+ - Quiet replies (`docs/quiet-replies.md`): `hostRoomEpoch` waits a random
280
+ `replyDelayMs` (default `[500, 2000]`) before any answer, sends a
281
+ participant a `removed` or `closed` refusal once per `refuseAgainAfter`
282
+ (default 3600 s) and `unknown` once per `reportUnknownEvery` (60 s), and
283
+ caps refusals at `maxRefusalsPerMinute` (20). Grants are never limited.
284
+ `RefusalLimiter`, `replyDelay` and the `DEFAULT_REPLY_DELAY_MS`,
285
+ `REFUSE_AGAIN_AFTER_SECONDS`, `MAX_REFUSALS_PER_MINUTE` and
286
+ `REFUSAL_LIMITER_CAPACITY` constants are exported
155
287
  - Member epoch catch-up (`docs/member-epoch-catch-up.md`): any current
156
288
  member can bring an admitted, non-removed device up to date when the
157
289
  authority is offline, and the device checks the answer against the
@@ -263,3 +395,54 @@ There is no separate lint script.
263
395
  ## Licence
264
396
 
265
397
  MIT
398
+
399
+ ### Live persistent admission (opt-in codecs)
400
+
401
+ `parseLivePersistentEvent` bounds raw event JSON and refuses duplicate fields.
402
+ `encodeLivePersistentDescriptor` / `decodeLivePersistentDescriptor` carry
403
+ routing metadata alongside an unchanged persistent invitation link.
404
+ `encodeLivePersistentRequest` / `decodeLivePersistentRequest` and
405
+ `encodeLivePersistentAnswer` / `decodeLivePersistentAnswer` implement a fresh,
406
+ root-signed challenge/answer with no delegation. The answer retains epoch-zero
407
+ capability separately from `epochHint`. Constants bound request/answer lifetimes
408
+ to 90/30 seconds. `LIVE_PERSISTENT_ADMISSION_LABELS` freezes its distinct KDF.
409
+
410
+ These functions are codecs, not an offline join API. The host must have exclusive
411
+ durable lifecycle authority; the client must complete authenticated epoch
412
+ admission before publishing, including at epoch zero. No application enables
413
+ this profile automatically. See [the wire profile and integration gates](docs/live-persistent-admission.md).
414
+ Frozen synthetic fixtures are in `vectors/live-persistent-vectors.json`; run
415
+ `npm run build && npm run generate-live-persistent` to regenerate them.
416
+
417
+ ## Authenticated invitation refusal (0.12.0)
418
+
419
+ `encodeInvitationDecline` and `decodeInvitationDecline` exchange a private,
420
+ request-bound refusal from the pinned inviter or a current delegated responder.
421
+ The version-3 refusal body uses reply kind 20467 and contains no room secret or
422
+ new delegation. Legacy version-2 grant readers ignore it.
423
+ `requestRoomAdmissionCapability` stops retries and rejects with
424
+ `InvitationDeclinedError` after verifying a refusal. Existing host `admit` hooks
425
+ returning false still leave requests unanswered; hosts must explicitly publish
426
+ a refusal using their guarded, acknowledged decision path. No UI integration or
427
+ physical acceptance is claimed by this protocol release.
428
+
429
+ ### MLS admission v4 evidence codec
430
+
431
+ The separate opt-in v4 codec binds an encrypted account/device request to signed
432
+ keeper consent and an expected MLS group. It uses existing 20466/20467 carriers,
433
+ strict canonical arrays and a fixed payload bucket. Existing room links/codecs
434
+ are unchanged. Import from `@forgesworn/fold-kit/mls-admission`; see [API and boundaries](docs/mls-admission-v4.md).
435
+
436
+ Exports: `encodeMlsInvitationV4`, `readMlsInvitationV4`,
437
+ `mlsAdmissionProofTemplatesV4`, `encodeMlsAdmissionRequestV4`,
438
+ `decodeMlsAdmissionRequestV4`, `readMlsAdmissionRequestV4`,
439
+ `encodeMlsAdmissionAnswerV4`, `decodeMlsAdmissionAnswerV4`,
440
+ `readMlsAdmissionAnswerV4`, `MLS_ADMISSION_LABELS`,
441
+ `MLS_ADMISSION_PLAINTEXT_BYTES`, `MLS_ADMISSION_CIPHERTEXT_CHARACTERS`;
442
+ types `VerifiedMlsAdmissionRequest` and `VerifiedAdmissionAnswer`.
443
+
444
+ Verified answers are evidence only. Actual admission requires new purpose-bound
445
+ vault proof and authoritative live box/Link role-witness APIs, a conditional
446
+ keeper revision at the effect boundary, current custody/consent, durable counter
447
+ and one-shot operation storage, and VMLS/Welcome verification. No browser
448
+ admission, authority factory, transport, storage or automatic retry is provided.
package/dist/epoch.d.ts CHANGED
@@ -2,6 +2,7 @@ import { type Event } from 'nostr-tools/pure';
2
2
  import type { RelayTransport } from './transport.js';
3
3
  import type { DeviceCredential, KindredProof, RoomPolicy } from './types.js';
4
4
  import type { MemberEpochSource } from './member-epoch.js';
5
+ import { type ReplyDelayRange, type ReplySchedule } from './quiet-reply.js';
5
6
  /**
6
7
  * Room epochs: how a member is removed.
7
8
  *
@@ -413,15 +414,34 @@ export interface HostRoomEpochOptions {
413
414
  * is making `known` say yes; the requester's next ask is then granted. */
414
415
  onUnknown?: (request: EpochRequest) => void;
415
416
  /** Seconds before a participant still unknown and still asking is
416
- * reported again. Default 60. */
417
+ * reported again. Default 60. An `unknown` answer goes on the wire at
418
+ * the same cadence: a fresh request inside it is reported to nobody and
419
+ * answered with silence. */
417
420
  reportUnknownEvery?: number;
418
421
  policy?: RoomPolicy;
419
422
  legacyParticipants?: ReadonlySet<string>;
420
423
  now?: () => number;
421
424
  onGranted?: (request: EpochRequest) => void;
425
+ /** Called for every refusal, including one the limits below kept off the
426
+ * wire. */
422
427
  onRefused?: (request: EpochRequest, why: EpochRefusal) => void;
423
428
  /** A conference room's end: every grant carries it as an expiration. */
424
429
  expiresAt?: number;
430
+ /** Seconds before the same participant is sent a `removed` or `closed`
431
+ * refusal again. Default 3600 (`REFUSE_AGAIN_AFTER_SECONDS`). A request
432
+ * inside it is not answered, so the asker times out instead. */
433
+ refuseAgainAfter?: number;
434
+ /** Refusals of any kind published in any 60 seconds, whoever asks.
435
+ * Default 20 (`MAX_REFUSALS_PER_MINUTE`). Grants are never limited. */
436
+ maxRefusalsPerMinute?: number;
437
+ /** The random wait, in milliseconds, before any answer is published,
438
+ * grant or refusal. Default `[500, 2000]` (`DEFAULT_REPLY_DELAY_MS`);
439
+ * `[0, 0]` answers at once, as before. */
440
+ replyDelayMs?: ReplyDelayRange;
441
+ /** For tests: a source of numbers in [0, 1). Default `Math.random`. */
442
+ random?: () => number;
443
+ /** For tests: how the wait is timed. Default `setTimeout`. */
444
+ schedule?: ReplySchedule;
425
445
  }
426
446
  /**
427
447
  * The credential to seal an answer to: the one the request carries, unless
@@ -448,6 +468,13 @@ export declare function sealCredential(request: {
448
468
  * to participants the room knows (`known`), and answers anybody else
449
469
  * `unknown` until a member lets them in. A room that has never removed
450
470
  * anybody is answered exactly as before.
471
+ *
472
+ * Every answer waits a random `replyDelayMs` first, and refusals are
473
+ * rationed: one a participant per `refuseAgainAfter` (per
474
+ * `reportUnknownEvery` for `unknown`), and `maxRefusalsPerMinute` in all.
475
+ * A removed member who still holds the room key can otherwise ask with
476
+ * fresh requests for ever and time each signed reply, which tells them
477
+ * when the authority is online. What an answer says is unchanged.
451
478
  */
452
479
  export declare function hostRoomEpoch(opts: HostRoomEpochOptions): {
453
480
  close(): void;
package/dist/epoch.js CHANGED
@@ -15,6 +15,7 @@ import { verifyEventUncached } from './verify.js';
15
15
  import { newerCredential, openSealed, sealTo } from './seal.js';
16
16
  import { withExpiration } from './expiration.js';
17
17
  import { epochCommitment } from './epoch-commit.js';
18
+ import { DEFAULT_REPLY_DELAY_MS, REFUSE_AGAIN_AFTER_SECONDS, RefusalLimiter, defaultReplySchedule, replyDelay } from './quiet-reply.js';
18
19
  /**
19
20
  * Room epochs: how a member is removed.
20
21
  *
@@ -622,6 +623,13 @@ export function sealCredential(request, presented, credentialFor, roomId, now) {
622
623
  * to participants the room knows (`known`), and answers anybody else
623
624
  * `unknown` until a member lets them in. A room that has never removed
624
625
  * anybody is answered exactly as before.
626
+ *
627
+ * Every answer waits a random `replyDelayMs` first, and refusals are
628
+ * rationed: one a participant per `refuseAgainAfter` (per
629
+ * `reportUnknownEvery` for `unknown`), and `maxRefusalsPerMinute` in all.
630
+ * A removed member who still holds the room key can otherwise ask with
631
+ * fresh requests for ever and time each signed reply, which tells them
632
+ * when the authority is online. What an answer says is unchanged.
625
633
  */
626
634
  export function hostRoomEpoch(opts) {
627
635
  require32(opts.authoritySk, 'authority secret key');
@@ -640,6 +648,13 @@ export function hostRoomEpoch(opts) {
640
648
  if (set.size > 256)
641
649
  set.delete(set.values().next().value);
642
650
  };
651
+ const refusals = new RefusalLimiter({ perMinute: opts.maxRefusalsPerMinute });
652
+ const delayRange = opts.replyDelayMs ?? DEFAULT_REPLY_DELAY_MS;
653
+ const random = opts.random ?? Math.random;
654
+ const schedule = opts.schedule ?? defaultReplySchedule;
655
+ /** Requests waiting out their delay, each with how to cancel it. Bounded:
656
+ * a request turned away while it is full is answered on its retry. */
657
+ const pending = new Map();
643
658
  let closed = false;
644
659
  const unsub = opts.transport.subscribe([{ kinds: [KINDS.EPOCH_REQUEST], '#d': [roomId], '#p': [authority] }], (event) => {
645
660
  if (closed)
@@ -652,80 +667,104 @@ export function hostRoomEpoch(opts) {
652
667
  policy: opts.policy,
653
668
  legacyParticipants: opts.legacyParticipants,
654
669
  });
655
- if (!read || answered.has(read.request.request))
670
+ if (!read || answered.has(read.request.request) || pending.has(read.request.request))
656
671
  return;
657
672
  const request = read.request;
658
- let refused;
659
- if (opts.closed?.())
660
- refused = 'closed';
661
- else if (opts.removed().has(request.participant))
662
- refused = 'removed';
663
- else if (opts.removed().size > 0 && !opts.known?.(request.participant))
664
- refused = 'unknown';
665
- if (refused === 'unknown') {
666
- if (waiting.has(request.request))
673
+ const reply = () => {
674
+ pending.delete(request.request);
675
+ if (closed)
667
676
  return;
668
- waiting.add(request.request);
669
- bound(waiting);
670
- }
671
- else {
672
- waiting.delete(request.request);
673
- answered.add(request.request);
674
- bound(answered);
675
- }
676
- let grant;
677
- try {
678
- if (refused) {
679
- grant = encodeEpochGrant({
680
- roomId,
681
- authoritySk: opts.authoritySk,
682
- device: request.device,
683
- request: request.request,
684
- now: now(),
685
- refused,
686
- credential: sealCredential(request, read.credential, opts.credentialFor, roomId, now()),
687
- expiresAt: opts.expiresAt,
688
- });
677
+ let refused;
678
+ if (opts.closed?.())
679
+ refused = 'closed';
680
+ else if (opts.removed().has(request.participant))
681
+ refused = 'removed';
682
+ else if (opts.removed().size > 0 && !opts.known?.(request.participant))
683
+ refused = 'unknown';
684
+ if (refused === 'unknown') {
685
+ if (waiting.has(request.request))
686
+ return;
687
+ waiting.add(request.request);
688
+ bound(waiting);
689
689
  }
690
690
  else {
691
- const epoch = opts.current();
692
- grant = encodeEpochGrant({
693
- roomId,
694
- authoritySk: opts.authoritySk,
695
- device: request.device,
696
- request: request.request,
697
- now: now(),
698
- epoch,
699
- removed: [...opts.removed()],
700
- ...(opts.members ? { members: [...opts.members()] } : {}),
701
- ...(opts.past ? { passed: passedFor(opts.past, epoch.epoch, now()) } : {}),
702
- credential: sealCredential(request, read.credential, opts.credentialFor, roomId, now()),
703
- expiresAt: opts.expiresAt,
704
- });
691
+ waiting.delete(request.request);
692
+ answered.add(request.request);
693
+ bound(answered);
705
694
  }
706
- }
707
- catch {
695
+ const quiet = refused !== undefined && !refusals.allow(request.participant, refused === 'unknown' ? (opts.reportUnknownEvery ?? REPORT_UNKNOWN_EVERY_SECONDS) : (opts.refuseAgainAfter ?? REFUSE_AGAIN_AFTER_SECONDS), now());
696
+ if (!quiet) {
697
+ let grant;
698
+ try {
699
+ if (refused) {
700
+ grant = encodeEpochGrant({
701
+ roomId,
702
+ authoritySk: opts.authoritySk,
703
+ device: request.device,
704
+ request: request.request,
705
+ now: now(),
706
+ refused,
707
+ credential: sealCredential(request, read.credential, opts.credentialFor, roomId, now()),
708
+ expiresAt: opts.expiresAt,
709
+ });
710
+ }
711
+ else {
712
+ const epoch = opts.current();
713
+ grant = encodeEpochGrant({
714
+ roomId,
715
+ authoritySk: opts.authoritySk,
716
+ device: request.device,
717
+ request: request.request,
718
+ now: now(),
719
+ epoch,
720
+ removed: [...opts.removed()],
721
+ ...(opts.members ? { members: [...opts.members()] } : {}),
722
+ ...(opts.past ? { passed: passedFor(opts.past, epoch.epoch, now()) } : {}),
723
+ credential: sealCredential(request, read.credential, opts.credentialFor, roomId, now()),
724
+ expiresAt: opts.expiresAt,
725
+ });
726
+ }
727
+ }
728
+ catch {
729
+ return;
730
+ }
731
+ opts.transport.publish(grant).catch(() => { });
732
+ }
733
+ if (refused === 'unknown' && now() - (reported.get(request.participant) ?? -Infinity) >= (opts.reportUnknownEvery ?? REPORT_UNKNOWN_EVERY_SECONDS)) {
734
+ reported.set(request.participant, now());
735
+ if (reported.size > 256)
736
+ reported.delete(reported.keys().next().value);
737
+ opts.onUnknown?.(request);
738
+ }
739
+ if (!refused)
740
+ reported.delete(request.participant);
741
+ if (!refused)
742
+ refusals.forget(request.participant);
743
+ if (refused)
744
+ opts.onRefused?.(request, refused);
745
+ else
746
+ opts.onGranted?.(request);
747
+ };
748
+ const delay = replyDelay(delayRange, random);
749
+ if (delay === 0) {
750
+ reply();
708
751
  return;
709
752
  }
710
- opts.transport.publish(grant).catch(() => { });
711
- if (refused === 'unknown' && now() - (reported.get(request.participant) ?? -Infinity) >= (opts.reportUnknownEvery ?? REPORT_UNKNOWN_EVERY_SECONDS)) {
712
- reported.set(request.participant, now());
713
- if (reported.size > 256)
714
- reported.delete(reported.keys().next().value);
715
- opts.onUnknown?.(request);
716
- }
717
- if (!refused)
718
- reported.delete(request.participant);
719
- if (refused)
720
- opts.onRefused?.(request, refused);
721
- else
722
- opts.onGranted?.(request);
753
+ if (pending.size >= 256)
754
+ return;
755
+ pending.set(request.request, () => { });
756
+ const cancel = schedule(reply, delay);
757
+ if (pending.has(request.request))
758
+ pending.set(request.request, cancel);
723
759
  });
724
760
  return {
725
761
  close() {
726
762
  if (closed)
727
763
  return;
728
764
  closed = true;
765
+ for (const cancel of pending.values())
766
+ cancel();
767
+ pending.clear();
729
768
  unsub();
730
769
  },
731
770
  };
package/dist/index.d.ts CHANGED
@@ -18,8 +18,10 @@ export { MAX_RELAY_HINTS, MAX_ICE_HINTS, MAX_NETWORK_HINT_LENGTH, isSafeRelayUrl
18
18
  export { sanitiseDisplayName, MAX_DISPLAY_NAME_LENGTH } from './display-name.js';
19
19
  export { issueKindredProof, evaluateAccess, ACCESS_LABELS, } from './access.js';
20
20
  export type { IssueKindredProofOptions } from './access.js';
21
- export { INVITATION_DELEGATION_TTL_SECONDS, MAX_INVITATION_DELEGATION_DEPTH, createRoomInvitation, roomInvitation, deriveInvitationId, encodeInvitationRequest, decodeInvitationRequest, verifyInvitationDelegation, encodeInvitationGrant, decodeRoomAdmissionGrant, decodeInvitationGrant, ROOM_ENDED_MESSAGE, encodeInvitationRetirement, decodeInvitationRetirement, decodeInvitationRetirementNotice, retirementError, hostRoomInvitation, requestRoomAdmissionCapability, requestRoomAdmission, INVITATION_LABELS, } from './invitation.js';
22
- export type { RoomInvitation, RoomInvitationHost, InvitationDelegation, RoomInvitationDelegate, RoomAdmission, EncodeInvitationRequestOptions, InvitationRequest, DecodeInvitationRequestOptions, EncodeInvitationGrantOptions, DecodeInvitationGrantOptions, HostRoomInvitationOptions, EncodeInvitationRetirementOptions, RequestRoomAdmissionOptions, } from './invitation.js';
21
+ export { INVITATION_DELEGATION_TTL_SECONDS, MAX_INVITATION_DELEGATION_DEPTH, createRoomInvitation, roomInvitation, deriveInvitationId, encodeInvitationAccountProof, encodeInvitationRequest, decodeInvitationRequest, verifyInvitationDelegation, encodeInvitationGrant, decodeRoomAdmissionGrant, decodeInvitationGrant, ROOM_ENDED_MESSAGE, encodeInvitationRetirement, decodeInvitationRetirement, decodeInvitationRetirementNotice, retirementError, hostRoomInvitation, requestRoomAdmissionCapability, requestRoomAdmission, INVITATION_LABELS, } from './invitation.js';
22
+ export type { RoomInvitation, RoomInvitationHost, InvitationDelegation, RoomInvitationDelegate, RoomAdmission, EncodeInvitationAccountProofOptions, EncodeInvitationRequestOptions, InvitationRequest, DecodeInvitationRequestOptions, EncodeInvitationGrantOptions, DecodeInvitationGrantOptions, HostRoomInvitationOptions, EncodeInvitationRetirementOptions, RequestRoomAdmissionOptions, } from './invitation.js';
23
+ export { encodeInvitationDecline, decodeInvitationDecline, InvitationDeclinedError } from './invitation-decline.js';
24
+ export type { EncodeInvitationDeclineOptions, InvitationDecline } from './invitation-decline.js';
23
25
  export { encodePersistentInvitation, decodePersistentInvitation, requestPersistentRoomAdmission, PERSISTENT_INVITATION_LABELS, } from './persistent-invitation.js';
24
26
  export type { PersistentRoomAdmission } from './persistent-invitation.js';
25
27
  export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } from './expiration.js';
@@ -28,9 +30,12 @@ export { MAX_ROOM_LINK_FRAGMENT_LENGTH, parseRoomLink, encodeRoomLink, } from '.
28
30
  export type { RoomLink } from './link.js';
29
31
  export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, readMemberList, REPORT_UNKNOWN_EVERY_SECONDS, decodeRekeyEvent, HISTORY_WINDOW_SECONDS, MAX_HISTORY_EPOCHS, epochsInWindow, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, sealCredential, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
30
32
  export type { RoomEpoch, EpochKeys, EncodeRekeyOptions, RekeyRecipient, RekeyNotice, PeekRekeyOptions, DecodeRekeyOptions, LeftEpoch, EpochRequestAdmissionOptions, EncodeEpochRequestOptions, DecodeEpochRequestOptions, EpochRequest, EpochRefusal, EncodeEpochGrantOptions, DecodeEpochGrantOptions, EpochGrant, HostRoomEpochOptions, RequestRoomEpochOptions, SignAdminsOptions, SignChannelsOptions, VerifyChannelsOptions, VerifyAdminsOptions, } from './epoch.js';
33
+ export { DEFAULT_REPLY_DELAY_MS, REFUSE_AGAIN_AFTER_SECONDS, MAX_REFUSALS_PER_MINUTE, REFUSAL_LIMITER_CAPACITY, RefusalLimiter, replyDelay, } from './quiet-reply.js';
34
+ export type { ReplyDelayRange, ReplySchedule, RefusalLimiterOptions } from './quiet-reply.js';
31
35
  export { EPOCH_COMMIT_PREFIX, epochCommitment, EPOCH_COMMIT_LABELS } from './epoch-commit.js';
32
36
  export { MEMBER_EPOCH_KINDS, MEMBER_EPOCH_REQUEST_KEY_INFO, MAX_MEMBER_EPOCH_CHAIN, deriveMemberEpochRequestKey, readRekeyEvidence, encodeMemberEpochRequest, decodeMemberEpochRequest, encodeMemberEpochGrant, decodeMemberEpochGrant, hostMemberEpochDesk, memberEpochSource, requestMemberEpoch, MEMBER_EPOCH_LABELS, } from './member-epoch.js';
33
37
  export type { RekeyEvidence, EncodeMemberEpochRequestOptions, DecodeMemberEpochRequestOptions, MemberEpochRequest, EncodeMemberEpochGrantOptions, DecodeMemberEpochGrantOptions, MemberEpochGrant, HostMemberEpochDeskOptions, MemberEpochRequestOptions, MemberEpochSource, } from './member-epoch.js';
34
38
  export { deriveChannel, CHANNEL_ID_INFO, CHANNEL_KEY_INFO, MAX_CHANNEL_NAME_LENGTH, CHANNEL_LABELS } from './channel.js';
35
39
  export { LANES, LANE_MEANING, LANE_LABEL, LANE_GLYPH, isLane, laneOfRelayUrl, laneOfRelays, weakestLane, isDowngrade, } from './lane.js';
36
40
  export type { Lane } from './lane.js';
41
+ export * from "./live-persistent-admission.js";
package/dist/index.js CHANGED
@@ -12,13 +12,17 @@ export { generateRoomSecret, deriveRoom, encodeJoinUrl, decodeJoinUrl, parseRoom
12
12
  export { MAX_RELAY_HINTS, MAX_ICE_HINTS, MAX_NETWORK_HINT_LENGTH, isSafeRelayUrl, safeRelayUrls, isSafeIceUrl, safeIceUrls, assertNetworkHintBounds, } from './network-hints.js';
13
13
  export { sanitiseDisplayName, MAX_DISPLAY_NAME_LENGTH } from './display-name.js';
14
14
  export { issueKindredProof, evaluateAccess, ACCESS_LABELS, } from './access.js';
15
- export { INVITATION_DELEGATION_TTL_SECONDS, MAX_INVITATION_DELEGATION_DEPTH, createRoomInvitation, roomInvitation, deriveInvitationId, encodeInvitationRequest, decodeInvitationRequest, verifyInvitationDelegation, encodeInvitationGrant, decodeRoomAdmissionGrant, decodeInvitationGrant, ROOM_ENDED_MESSAGE, encodeInvitationRetirement, decodeInvitationRetirement, decodeInvitationRetirementNotice, retirementError, hostRoomInvitation, requestRoomAdmissionCapability, requestRoomAdmission, INVITATION_LABELS, } from './invitation.js';
15
+ export { INVITATION_DELEGATION_TTL_SECONDS, MAX_INVITATION_DELEGATION_DEPTH, createRoomInvitation, roomInvitation, deriveInvitationId, encodeInvitationAccountProof, encodeInvitationRequest, decodeInvitationRequest, verifyInvitationDelegation, encodeInvitationGrant, decodeRoomAdmissionGrant, decodeInvitationGrant, ROOM_ENDED_MESSAGE, encodeInvitationRetirement, decodeInvitationRetirement, decodeInvitationRetirementNotice, retirementError, hostRoomInvitation, requestRoomAdmissionCapability, requestRoomAdmission, INVITATION_LABELS, } from './invitation.js';
16
+ export { encodeInvitationDecline, decodeInvitationDecline, InvitationDeclinedError } from './invitation-decline.js';
16
17
  export { encodePersistentInvitation, decodePersistentInvitation, requestPersistentRoomAdmission, PERSISTENT_INVITATION_LABELS, } from './persistent-invitation.js';
17
18
  export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } from './expiration.js';
18
19
  export { isInvitationRelays, requireInvitationRelays, MAX_INVITATION_RELAYS } from './invitation-relays.js';
19
20
  export { MAX_ROOM_LINK_FRAGMENT_LENGTH, parseRoomLink, encodeRoomLink, } from './link.js';
20
21
  export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, readMemberList, REPORT_UNKNOWN_EVERY_SECONDS, decodeRekeyEvent, HISTORY_WINDOW_SECONDS, MAX_HISTORY_EPOCHS, epochsInWindow, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, sealCredential, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
22
+ export { DEFAULT_REPLY_DELAY_MS, REFUSE_AGAIN_AFTER_SECONDS, MAX_REFUSALS_PER_MINUTE, REFUSAL_LIMITER_CAPACITY, RefusalLimiter, replyDelay, } from './quiet-reply.js';
21
23
  export { EPOCH_COMMIT_PREFIX, epochCommitment, EPOCH_COMMIT_LABELS } from './epoch-commit.js';
22
24
  export { MEMBER_EPOCH_KINDS, MEMBER_EPOCH_REQUEST_KEY_INFO, MAX_MEMBER_EPOCH_CHAIN, deriveMemberEpochRequestKey, readRekeyEvidence, encodeMemberEpochRequest, decodeMemberEpochRequest, encodeMemberEpochGrant, decodeMemberEpochGrant, hostMemberEpochDesk, memberEpochSource, requestMemberEpoch, MEMBER_EPOCH_LABELS, } from './member-epoch.js';
23
25
  export { deriveChannel, CHANNEL_ID_INFO, CHANNEL_KEY_INFO, MAX_CHANNEL_NAME_LENGTH, CHANNEL_LABELS } from './channel.js';
24
26
  export { LANES, LANE_MEANING, LANE_LABEL, LANE_GLYPH, isLane, laneOfRelayUrl, laneOfRelays, weakestLane, isDowngrade, } from './lane.js';
27
+ // Opt-in live persistent capability exchange; host lifecycle and epoch gates remain required.
28
+ export * from "./live-persistent-admission.js";
@@ -0,0 +1,26 @@
1
+ import { type Event } from 'nostr-tools/pure';
2
+ import type { DecodeInvitationGrantOptions, InvitationDelegation, RoomInvitation } from './invitation.js';
3
+ export interface EncodeInvitationDeclineOptions {
4
+ invitation: RoomInvitation;
5
+ inviterSk: Uint8Array;
6
+ requester: string;
7
+ request: string;
8
+ now: number;
9
+ /** Root-to-responder chain; empty only for the pinned root inviter. */
10
+ delegation?: InvitationDelegation[];
11
+ }
12
+ export interface InvitationDecline {
13
+ request: string;
14
+ responder: string;
15
+ }
16
+ export declare class InvitationDeclinedError extends Error {
17
+ constructor();
18
+ }
19
+ /** A private refusal, carrying no room secret or new responder delegation.
20
+ * Version 3 distinguishes it from version-2 grants on the same reply kind.
21
+ * Older readers ignore it and continue their existing bounded wait. */
22
+ export declare function encodeInvitationDecline(opts: EncodeInvitationDeclineOptions): Event;
23
+ /** Only a fresh, request-bound refusal from the pinned root or a currently
24
+ * delegated responder can stop this guest's wait. A bearer holder has no
25
+ * authority to refuse someone else. No room capability is returned. */
26
+ export declare function decodeInvitationDecline(event: Event, opts: DecodeInvitationGrantOptions): InvitationDecline | null;