@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,381 @@
1
+ import { hkdf } from '@noble/hashes/hkdf';
2
+ import { sha256 } from '@noble/hashes/sha2';
3
+ import { base64urlnopad } from '@scure/base';
4
+ import { nip44 } from 'nostr-tools';
5
+ import { verifiedSymbol } from 'nostr-tools/pure';
6
+ import { deriveInvitationId, roomInvitation, decodeInvitationRetirementNotice } from './invitation.js';
7
+ import { PERSISTENT_INVITATION_LABELS } from './persistent-invitation.js';
8
+ import { isRoomEnds } from './expiration.js';
9
+ import { isInvitationRelays } from './invitation-relays.js';
10
+ import { deriveRoom } from './room.js';
11
+ import { KINDS } from './kinds.js';
12
+ import { verifyEventUncached } from './verify.js';
13
+ export const MAX_PERSISTENT_INVITATION_EVIDENCE_EVENTS = 64;
14
+ export const MAX_PERSISTENT_INVITATION_EVENT_BYTES = 16_384;
15
+ export const MAX_PERSISTENT_INVITATION_EVIDENCE_BYTES = 262_144;
16
+ /** Incomplete observations are bounded diagnostic custody only. They are
17
+ * never accepted by revalidatePersistentInvitationEvidence. */
18
+ export class PersistentInvitationEvidenceQueryError extends Error {
19
+ observation;
20
+ constructor(message, observation) {
21
+ super(message);
22
+ this.name = 'PersistentInvitationEvidenceQueryError';
23
+ this.observation = observation;
24
+ }
25
+ }
26
+ const fields = ['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig'];
27
+ const utf8 = new TextEncoder();
28
+ const hex32 = /^[0-9a-f]{64}$/;
29
+ const hex64 = /^[0-9a-f]{128}$/;
30
+ /** JSON string byte cost including escaping, without serialising content. */
31
+ function jsonStringBytes(value) {
32
+ let bytes = 2;
33
+ for (let i = 0; i < value.length; i++) {
34
+ const c = value.charCodeAt(i);
35
+ if (c === 34 || c === 92)
36
+ bytes += 2;
37
+ else if (c < 32)
38
+ bytes += [8, 9, 10, 12, 13].includes(c) ? 2 : 6;
39
+ else if (c < 128)
40
+ bytes++;
41
+ else if (c < 2048)
42
+ bytes += 2;
43
+ else if (c >= 0xd800 && c <= 0xdbff && value.charCodeAt(i + 1) >= 0xdc00 && value.charCodeAt(i + 1) <= 0xdfff) {
44
+ bytes += 4;
45
+ i++;
46
+ }
47
+ else if (c >= 0xd800 && c <= 0xdfff)
48
+ bytes += 6;
49
+ else
50
+ bytes += 3;
51
+ if (bytes > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
52
+ throw new Error('oversized evidence event');
53
+ }
54
+ return bytes;
55
+ }
56
+ function dataRecord(raw, allowed, ignoredSymbol) {
57
+ if (!raw || typeof raw !== 'object' || ![Object.prototype, null].includes(Object.getPrototypeOf(raw)))
58
+ throw new Error('invalid evidence record');
59
+ const descriptors = Object.getOwnPropertyDescriptors(raw);
60
+ for (const key of Reflect.ownKeys(descriptors)) {
61
+ if ((typeof key === 'symbol' ? key !== ignoredSymbol : !allowed.includes(key)) || !('value' in descriptors[key]))
62
+ throw new Error('invalid evidence field');
63
+ }
64
+ const result = Object.create(null);
65
+ for (const key of allowed)
66
+ if (descriptors[key])
67
+ result[key] = descriptors[key].value;
68
+ return result;
69
+ }
70
+ function dataArray(raw, limit) {
71
+ if (!Array.isArray(raw) || Object.getPrototypeOf(raw) !== Array.prototype || raw.length > limit)
72
+ throw new Error('invalid evidence array');
73
+ const descriptors = Object.getOwnPropertyDescriptors(raw);
74
+ if (Reflect.ownKeys(descriptors).length !== raw.length + 1)
75
+ throw new Error('invalid evidence array fields');
76
+ const values = [];
77
+ for (let i = 0; i < raw.length; i++) {
78
+ if (!descriptors[i] || !('value' in descriptors[i]))
79
+ throw new Error('invalid evidence array item');
80
+ values.push(descriptors[i].value);
81
+ }
82
+ return values;
83
+ }
84
+ /** Fresh carrier copy, with no trusted verification-cache symbol. Bounds
85
+ * precede copying or serialising potentially large strings/tag arrays. */
86
+ function carrier(raw) {
87
+ const d = dataRecord(raw, fields, verifiedSymbol);
88
+ if (Object.keys(d).length !== fields.length || typeof d.id !== 'string' || !hex32.test(d.id) ||
89
+ typeof d.pubkey !== 'string' || !hex32.test(d.pubkey) || typeof d.sig !== 'string' || !hex64.test(d.sig) ||
90
+ !Number.isSafeInteger(d.created_at) || d.created_at < 0 || !Number.isSafeInteger(d.kind) || d.kind < 0 ||
91
+ typeof d.content !== 'string' || d.content.length > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
92
+ throw new Error('invalid or oversized evidence event');
93
+ let bytes = '{"id":,"pubkey":,"created_at":,"kind":,"tags":,"content":,"sig":}'.length +
94
+ String(d.created_at).length + String(d.kind).length + jsonStringBytes(d.id) + jsonStringBytes(d.pubkey) + jsonStringBytes(d.sig) + jsonStringBytes(d.content);
95
+ if (bytes > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
96
+ throw new Error('oversized evidence event');
97
+ const outer = dataArray(d.tags, 64);
98
+ bytes += 2 + Math.max(0, outer.length - 1);
99
+ const tags = outer.map(tag => {
100
+ const inner = dataArray(tag, 8);
101
+ bytes += 2 + Math.max(0, inner.length - 1);
102
+ return inner.map(value => {
103
+ if (typeof value !== 'string' || value.length > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
104
+ throw new Error('invalid or oversized evidence tag');
105
+ bytes += jsonStringBytes(value);
106
+ if (bytes > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
107
+ throw new Error('oversized evidence event');
108
+ return value;
109
+ });
110
+ });
111
+ if (bytes > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
112
+ throw new Error('oversized evidence event');
113
+ const event = { id: d.id, pubkey: d.pubkey, created_at: d.created_at, kind: d.kind, tags, content: d.content, sig: d.sig };
114
+ const canonical = JSON.stringify(event);
115
+ return { event, canonical, bytes };
116
+ }
117
+ function copyInvitation(invitation) {
118
+ if (!invitation.persistent || !(invitation.bearer instanceof Uint8Array) || invitation.bearer.length !== 32 || !hex32.test(invitation.inviter))
119
+ throw new Error('a canonical persistent invitation is required');
120
+ return roomInvitation(invitation.bearer.slice(), invitation.inviter, true);
121
+ }
122
+ function roomId(secret) {
123
+ const room = deriveRoom(secret);
124
+ room.roomKey.fill(0);
125
+ return room.roomId;
126
+ }
127
+ function signedScope(event, invitation) {
128
+ return event.pubkey === invitation.inviter && (event.kind === KINDS.GROUP_INVITATION || event.kind === KINDS.INVITATION_RETIREMENT) &&
129
+ event.tags.filter(tag => tag[0] === 'd').length === 1 && event.tags.find(tag => tag[0] === 'd')?.length === 2 &&
130
+ event.tags.find(tag => tag[0] === 'd')?.[1] === deriveInvitationId(invitation) && verifyEventUncached(event);
131
+ }
132
+ /** Strict evidence path accepts exactly the bodies/tags of supported writers;
133
+ * permissive legacy decoding and its public request remain unchanged. */
134
+ function decodeEvidenceEvent(event, invitation) {
135
+ if (!signedScope(event, invitation))
136
+ return null;
137
+ if (event.kind === KINDS.INVITATION_RETIREMENT) {
138
+ const notice = decodeInvitationRetirementNotice(event, invitation);
139
+ return notice ? 'retired' : null;
140
+ }
141
+ let accepted = false, secret;
142
+ const key = hkdf(sha256, invitation.bearer, undefined, PERSISTENT_INVITATION_LABELS[0], 32);
143
+ try {
144
+ const plaintext = nip44.v2.decrypt(event.content, key);
145
+ const body = dataRecord(JSON.parse(plaintext), ['v', 'room', 'secret', 'ends', 'destruct', 'relays']);
146
+ if (body.v !== 3 || typeof body.secret !== 'string' || (body.ends !== undefined && !isRoomEnds(body.ends)) ||
147
+ (body.destruct !== undefined && body.destruct !== true) || (body.relays !== undefined && !isInvitationRelays(body.relays)))
148
+ return null;
149
+ secret = base64urlnopad.decode(body.secret);
150
+ if (secret.length !== 32 || body.room !== roomId(secret))
151
+ return null;
152
+ const decoded = { secret, persistent: true, epoch: 0,
153
+ ...(body.ends === undefined ? {} : { endsAt: body.ends }), ...(body.destruct ? { destruct: true } : {}),
154
+ ...(body.relays === undefined ? {} : { relays: [...body.relays] }) };
155
+ const expected = JSON.stringify({ v: 3, room: body.room, secret: base64urlnopad.encode(secret),
156
+ ...(decoded.endsAt === undefined ? {} : { ends: decoded.endsAt }), ...(decoded.destruct ? { destruct: true } : {}),
157
+ ...(decoded.relays === undefined ? {} : { relays: decoded.relays }) });
158
+ const tags = decoded.endsAt === undefined ? [['d', deriveInvitationId(invitation)]] : [['d', deriveInvitationId(invitation)], ['expiration', String(decoded.endsAt)]];
159
+ accepted = plaintext === expected && JSON.stringify(event.tags) === JSON.stringify(tags);
160
+ return accepted ? decoded : null;
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ finally {
166
+ key.fill(0);
167
+ if (!accepted)
168
+ secret?.fill(0);
169
+ }
170
+ }
171
+ /** Validate an actual encoder Event or its exact retained canonical carrier.
172
+ * A room-root pin is mandatory. No signing, query, storage or publication is
173
+ * performed. In particular, this cannot manufacture one-event EOSE evidence. */
174
+ export function revalidatePersistentInvitationSource(opts) {
175
+ const invitation = copyInvitation(opts.invitation);
176
+ let root, admission;
177
+ let transferred = false;
178
+ try {
179
+ const expected = opts.expectedRoomSecret;
180
+ if (!(expected instanceof Uint8Array) || expected.length !== 32)
181
+ throw new Error('invalid expected room root');
182
+ root = expected.slice();
183
+ const raw = opts.event;
184
+ let c;
185
+ if (typeof raw === 'string') {
186
+ if (raw.length > MAX_PERSISTENT_INVITATION_EVENT_BYTES || utf8.encode(raw).length > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
187
+ throw new Error('oversized invitation source carrier');
188
+ c = carrier(JSON.parse(raw));
189
+ if (c.canonical !== raw)
190
+ throw new Error('noncanonical invitation source carrier');
191
+ }
192
+ else
193
+ c = carrier(raw);
194
+ if (c.event.kind !== KINDS.GROUP_INVITATION)
195
+ throw new Error('a group invitation source is required');
196
+ const decoded = decodeEvidenceEvent(c.event, invitation);
197
+ if (!decoded || decoded === 'retired')
198
+ throw new Error('invalid signed invitation source');
199
+ admission = decoded;
200
+ if (!admission.secret.every((byte, i) => byte === root[i]))
201
+ throw new Error('wrong invitation source room root');
202
+ const result = Object.freeze({ version: 1,
203
+ invitationId: deriveInvitationId(invitation), inviter: invitation.inviter,
204
+ eventId: c.event.id, createdAt: c.event.created_at, event: c.canonical, admission });
205
+ transferred = true;
206
+ return result;
207
+ }
208
+ finally {
209
+ if (!transferred)
210
+ admission?.secret.fill(0);
211
+ root?.fill(0);
212
+ invitation.bearer.fill(0);
213
+ }
214
+ }
215
+ function observation(invitation, events, completion) {
216
+ const ids = [...events.keys()].sort();
217
+ return Object.freeze({ version: 1, invitationId: deriveInvitationId(invitation), inviter: invitation.inviter, completion,
218
+ eventIds: Object.freeze(ids), events: Object.freeze(ids.map(id => events.get(id))) });
219
+ }
220
+ /** Reverify all carriers and reproduce selection from protected evidence.
221
+ * Optional expectedRoomSecret binds an existing physical room to its exact
222
+ * root, not merely the invitation's current or historical room label. */
223
+ export function revalidatePersistentInvitationEvidence(opts) {
224
+ const invitation = copyInvitation(opts.invitation);
225
+ const decoded = [];
226
+ try {
227
+ if (opts.expectedRoomSecret !== undefined && (!(opts.expectedRoomSecret instanceof Uint8Array) || opts.expectedRoomSecret.length !== 32))
228
+ throw new Error('invalid expected room root');
229
+ const d = dataRecord(opts.evidence, ['version', 'invitationId', 'inviter', 'completion', 'eventIds', 'events']);
230
+ if (d.version !== 1 || d.invitationId !== deriveInvitationId(invitation) || d.inviter !== invitation.inviter || d.completion !== 'eose')
231
+ throw new Error('invalid invitation evidence binding or completion');
232
+ const ids = dataArray(d.eventIds, MAX_PERSISTENT_INVITATION_EVIDENCE_EVENTS);
233
+ const raw = dataArray(d.events, MAX_PERSISTENT_INVITATION_EVIDENCE_EVENTS);
234
+ if (ids.length !== raw.length)
235
+ throw new Error('invalid invitation evidence identities');
236
+ const retained = new Map(), retired = [];
237
+ let bytes = 0;
238
+ for (let i = 0; i < raw.length; i++) {
239
+ const text = raw[i];
240
+ if (typeof text !== 'string' || text.length > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
241
+ throw new Error('invalid evidence carrier');
242
+ const carrierBytes = utf8.encode(text).length;
243
+ if (carrierBytes > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
244
+ throw new Error('oversized evidence carrier');
245
+ bytes += carrierBytes;
246
+ if (bytes > MAX_PERSISTENT_INVITATION_EVIDENCE_BYTES)
247
+ throw new Error('invitation evidence capacity exceeded');
248
+ const c = carrier(JSON.parse(text));
249
+ if (c.canonical !== text || c.event.id !== ids[i] || (i > 0 && ids[i - 1] >= c.event.id))
250
+ throw new Error('noncanonical invitation evidence');
251
+ const admission = decodeEvidenceEvent(c.event, invitation);
252
+ if (!admission)
253
+ throw new Error('invalid signed invitation evidence');
254
+ retained.set(c.event.id, text);
255
+ if (admission === 'retired')
256
+ retired.push(c.event.id);
257
+ else
258
+ decoded.push({ event: c.event, admission });
259
+ }
260
+ const evidence = observation(invitation, retained, 'eose');
261
+ const held = (reason) => Object.freeze({ evidence, outcome: Object.freeze({ status: 'held', reason, retirementEventIds: Object.freeze(retired) }) });
262
+ if (retired.length)
263
+ return held('retired');
264
+ if (!decoded.length)
265
+ return held('unavailable');
266
+ const first = decoded[0].admission.secret;
267
+ if (decoded.some(d => !d.admission.secret.every((b, i) => b === first[i])))
268
+ return held('conflicting-rooms');
269
+ if (opts.expectedRoomSecret !== undefined && !first.every((b, i) => b === opts.expectedRoomSecret[i]))
270
+ return held('wrong-room');
271
+ const ends = decoded.filter(d => d.admission.endsAt !== undefined);
272
+ const endsAt = ends.length ? Math.min(...ends.map(d => d.admission.endsAt)) : undefined;
273
+ const destruct = decoded.filter(d => d.admission.destruct);
274
+ const relayEvents = decoded.filter(d => d.admission.relays !== undefined);
275
+ const newest = relayEvents.length ? Math.max(...relayEvents.map(d => d.event.created_at)) : undefined;
276
+ const winners = relayEvents.filter(d => d.event.created_at === newest);
277
+ if (new Set(winners.map(d => JSON.stringify(d.admission.relays))).size > 1)
278
+ return held('conflicting-relays');
279
+ const relays = winners[0]?.admission.relays;
280
+ const admission = { secret: first.slice(), persistent: true, epoch: 0,
281
+ ...(endsAt === undefined ? {} : { endsAt }), ...(destruct.length ? { destruct: true } : {}),
282
+ ...(relays === undefined ? {} : { relays: [...relays] }) };
283
+ const contributors = Object.freeze({ room: Object.freeze(decoded.map(d => d.event.id)),
284
+ ends: Object.freeze(ends.filter(d => d.admission.endsAt === endsAt).map(d => d.event.id)),
285
+ destruct: Object.freeze(destruct.map(d => d.event.id)), relays: Object.freeze(winners.map(d => d.event.id)) });
286
+ return Object.freeze({ evidence, outcome: Object.freeze({ status: 'admitted', admission, contributors }) });
287
+ }
288
+ finally {
289
+ for (const d of decoded)
290
+ d.admission.secret.fill(0);
291
+ invitation.bearer.fill(0);
292
+ }
293
+ }
294
+ /** Additive evidence query. No publication, storage, relay defaults or
295
+ * membership authority. Only a complete bounded query can yield a result. */
296
+ export async function requestPersistentRoomAdmissionEvidence(opts) {
297
+ const invitation = copyInvitation(opts.invitation);
298
+ const timeout = opts.timeoutMs ?? 15_000;
299
+ if (!Number.isFinite(timeout) || timeout <= 0 || timeout > 2_147_483_647) {
300
+ invitation.bearer.fill(0);
301
+ throw new Error('invalid invitation evidence timeout');
302
+ }
303
+ try {
304
+ return await new Promise((resolve, reject) => {
305
+ const events = new Map();
306
+ let bytes = 0, received = 0, settled = false, unsub = () => { };
307
+ const finish = (error) => {
308
+ if (settled)
309
+ return;
310
+ settled = true;
311
+ clearTimeout(timer);
312
+ opts.signal?.removeEventListener('abort', abort);
313
+ try {
314
+ unsub();
315
+ }
316
+ catch { /* Observer cleanup cannot revive a query. */ }
317
+ if (error)
318
+ reject(new PersistentInvitationEvidenceQueryError(error instanceof Error ? error.message : String(error), observation(invitation, events, 'incomplete')));
319
+ else {
320
+ try {
321
+ resolve(revalidatePersistentInvitationEvidence({ evidence: observation(invitation, events, 'eose'), invitation }));
322
+ }
323
+ catch (error) {
324
+ reject(error);
325
+ }
326
+ }
327
+ };
328
+ const abort = () => finish(new Error('invitation evidence query cancelled'));
329
+ const timer = setTimeout(() => finish(new Error('invitation evidence query did not complete')), timeout);
330
+ opts.signal?.addEventListener('abort', abort, { once: true });
331
+ if (opts.signal?.aborted) {
332
+ abort();
333
+ return;
334
+ }
335
+ try {
336
+ unsub = opts.transport.subscribe([{ kinds: [KINDS.GROUP_INVITATION, KINDS.INVITATION_RETIREMENT], authors: [invitation.inviter], '#d': [deriveInvitationId(invitation)] }], event => {
337
+ if (settled)
338
+ return;
339
+ try {
340
+ // Charge invalid candidates and duplicates before carrier/crypto.
341
+ if (received >= MAX_PERSISTENT_INVITATION_EVIDENCE_EVENTS)
342
+ throw new Error('invitation evidence query capacity exceeded');
343
+ received++;
344
+ const c = carrier(event);
345
+ if (bytes + c.bytes > MAX_PERSISTENT_INVITATION_EVIDENCE_BYTES)
346
+ throw new Error('invitation evidence query capacity exceeded');
347
+ bytes += c.bytes;
348
+ const prior = events.get(c.event.id);
349
+ if (prior !== undefined) {
350
+ if (prior !== c.canonical)
351
+ throw new Error('conflicting duplicate invitation event identity');
352
+ return;
353
+ }
354
+ const decoded = decodeEvidenceEvent(c.event, invitation);
355
+ if (!decoded)
356
+ return;
357
+ if (decoded !== 'retired')
358
+ decoded.secret.fill(0);
359
+ events.set(c.event.id, c.canonical);
360
+ }
361
+ catch (error) {
362
+ finish(error);
363
+ }
364
+ }, () => finish());
365
+ // A synchronous transport can finish before returning its handle.
366
+ if (settled) {
367
+ try {
368
+ unsub();
369
+ }
370
+ catch { /* Already terminal. */ }
371
+ }
372
+ }
373
+ catch (error) {
374
+ finish(error);
375
+ }
376
+ });
377
+ }
378
+ finally {
379
+ invitation.bearer.fill(0);
380
+ }
381
+ }
@@ -0,0 +1,45 @@
1
+ /** A wait range in milliseconds: `[min, max]`, drawn uniformly. */
2
+ export type ReplyDelayRange = readonly [min: number, max: number];
3
+ /** Run `fn` after `ms` milliseconds; the returned function cancels it. For
4
+ * tests, so a desk's wait is driven without real time passing. */
5
+ export type ReplySchedule = (fn: () => void, ms: number) => () => void;
6
+ /** The wait before an automatic reply, unless the caller says otherwise. */
7
+ export declare const DEFAULT_REPLY_DELAY_MS: ReplyDelayRange;
8
+ /** How long the authority desk waits before refusing the same participant
9
+ * again: a removed member asking with fresh requests learns nothing new
10
+ * from a second refusal inside it. */
11
+ export declare const REFUSE_AGAIN_AFTER_SECONDS = 3600;
12
+ /** At most this many refusals a minute from one authority desk, whoever
13
+ * asks: a participant key is anyone's to mint, so a limit per participant
14
+ * alone does not stop a room-key holder asking as somebody new each time. */
15
+ export declare const MAX_REFUSALS_PER_MINUTE = 20;
16
+ /** How many participants a refusal limiter remembers. The oldest is
17
+ * forgotten first; the per-minute budget still holds after that. */
18
+ export declare const REFUSAL_LIMITER_CAPACITY = 256;
19
+ /** `setTimeout`, unreferenced so a pending reply never holds a process open. */
20
+ export declare const defaultReplySchedule: ReplySchedule;
21
+ /** A wait drawn from `range`: whole milliseconds, never negative. A range
22
+ * that is not two finite numbers is read as no wait; one given backwards
23
+ * is put the right way round. */
24
+ export declare function replyDelay(range: ReplyDelayRange, random?: () => number): number;
25
+ export interface RefusalLimiterOptions {
26
+ /** Refusals allowed in any 60 seconds, across every key. Default
27
+ * `MAX_REFUSALS_PER_MINUTE`. */
28
+ perMinute?: number;
29
+ /** Keys remembered. Default `REFUSAL_LIMITER_CAPACITY`. */
30
+ capacity?: number;
31
+ }
32
+ /** Decides whether a refusal goes on the wire: once per key per `every`
33
+ * seconds, and no more than `perMinute` in any minute overall. Memory is
34
+ * bounded by `capacity` keys plus `perMinute` timestamps. */
35
+ export declare class RefusalLimiter {
36
+ #private;
37
+ constructor(opts?: RefusalLimiterOptions);
38
+ /** Keys remembered now. */
39
+ get size(): number;
40
+ /** True, and recorded, when a refusal to `key` may be sent at `now`
41
+ * (unix seconds); false leaves nothing recorded. */
42
+ allow(key: string, every: number, now: number): boolean;
43
+ /** Forget `key`, as when the participant is granted. */
44
+ forget(key: string): void;
45
+ }
@@ -0,0 +1,82 @@
1
+ // Quiet automatic replies: the wait before a desk answers, and the limit on
2
+ // how often it refuses. New in this kit, not moved from KithMoot (see
3
+ // EXTRACTION.md "Quiet automatic replies").
4
+ //
5
+ // A desk that answers the instant a request lands, under a key that stays
6
+ // the same, tells anyone who can make it answer when the device holding
7
+ // that key is online and how quickly it is running ("Careless Whisper"
8
+ // style timing). Nothing here changes what a reply says: only whether one
9
+ // is sent, and when.
10
+ /** The wait before an automatic reply, unless the caller says otherwise. */
11
+ export const DEFAULT_REPLY_DELAY_MS = [500, 2000];
12
+ /** How long the authority desk waits before refusing the same participant
13
+ * again: a removed member asking with fresh requests learns nothing new
14
+ * from a second refusal inside it. */
15
+ export const REFUSE_AGAIN_AFTER_SECONDS = 3600;
16
+ /** At most this many refusals a minute from one authority desk, whoever
17
+ * asks: a participant key is anyone's to mint, so a limit per participant
18
+ * alone does not stop a room-key holder asking as somebody new each time. */
19
+ export const MAX_REFUSALS_PER_MINUTE = 20;
20
+ /** How many participants a refusal limiter remembers. The oldest is
21
+ * forgotten first; the per-minute budget still holds after that. */
22
+ export const REFUSAL_LIMITER_CAPACITY = 256;
23
+ /** `setTimeout`, unreferenced so a pending reply never holds a process open. */
24
+ export const defaultReplySchedule = (fn, ms) => {
25
+ const timer = setTimeout(fn, ms);
26
+ timer.unref?.();
27
+ return () => clearTimeout(timer);
28
+ };
29
+ /** A wait drawn from `range`: whole milliseconds, never negative. A range
30
+ * that is not two finite numbers is read as no wait; one given backwards
31
+ * is put the right way round. */
32
+ export function replyDelay(range, random = Math.random) {
33
+ const [a, b] = range;
34
+ if (!Number.isFinite(a) || !Number.isFinite(b))
35
+ return 0;
36
+ const min = Math.max(0, Math.min(a, b));
37
+ const max = Math.max(0, a, b);
38
+ if (max <= min)
39
+ return Math.floor(min);
40
+ const r = random();
41
+ const unit = Number.isFinite(r) ? Math.min(Math.max(r, 0), 1) : 0;
42
+ return Math.floor(min + unit * (max - min));
43
+ }
44
+ /** Decides whether a refusal goes on the wire: once per key per `every`
45
+ * seconds, and no more than `perMinute` in any minute overall. Memory is
46
+ * bounded by `capacity` keys plus `perMinute` timestamps. */
47
+ export class RefusalLimiter {
48
+ #last = new Map();
49
+ #sent = [];
50
+ #perMinute;
51
+ #capacity;
52
+ constructor(opts = {}) {
53
+ this.#perMinute = Math.max(0, Math.floor(opts.perMinute ?? MAX_REFUSALS_PER_MINUTE));
54
+ this.#capacity = Math.max(1, Math.floor(opts.capacity ?? REFUSAL_LIMITER_CAPACITY));
55
+ }
56
+ /** Keys remembered now. */
57
+ get size() {
58
+ return this.#last.size;
59
+ }
60
+ /** True, and recorded, when a refusal to `key` may be sent at `now`
61
+ * (unix seconds); false leaves nothing recorded. */
62
+ allow(key, every, now) {
63
+ const last = this.#last.get(key);
64
+ if (last !== undefined && now - last < every)
65
+ return false;
66
+ while (this.#sent.length > 0 && now - this.#sent[0] >= 60)
67
+ this.#sent.shift();
68
+ if (this.#sent.length >= this.#perMinute)
69
+ return false;
70
+ this.#sent.push(now);
71
+ // Re-inserted so the map's order is oldest-refused first.
72
+ this.#last.delete(key);
73
+ this.#last.set(key, now);
74
+ if (this.#last.size > this.#capacity)
75
+ this.#last.delete(this.#last.keys().next().value);
76
+ return true;
77
+ }
78
+ /** Forget `key`, as when the participant is granted. */
79
+ forget(key) {
80
+ this.#last.delete(key);
81
+ }
82
+ }
@@ -0,0 +1,56 @@
1
+ # Extraction plan excerpt
2
+
3
+ This kit was extracted from KithMoot under a private planning document that
4
+ also covers work outside this repository's scope (a downstream app that
5
+ consumes this kit, not itself part of the public KithMoot/fold-kit
6
+ ecosystem). This file carries the parts of that plan needed to understand
7
+ decisions recorded in this repository's own docs (EXTRACTION.md, AGENTS.md,
8
+ source comments), without naming or linking the private document itself.
9
+
10
+ ## Module list and splits (plan §1.1)
11
+
12
+ Moves to the kit, byte-identical bodies, only import paths changed: `hex`,
13
+ `verify`, `identity`, `kinds` (subset), `types` (subset), `credential`,
14
+ `room`, `network-hints`, `display-name`, `access` (split), `invitation`,
15
+ `persistent-invitation`, `link`, `epoch`, `channel` (one function extracted
16
+ from `chat.ts`), `lane`. A `relay` subpath (relay pool, relay auth,
17
+ anonymous routing) is a separate, later move once its churn settles.
18
+
19
+ `access.ts` split: `issueKindredProof`, `evaluateAccess` and the kindred
20
+ message move; `evaluateAgentAccess` stays with the app, since it needs
21
+ `ownership.ts` and a roster type that did not move.
22
+
23
+ `chat.ts` split: `deriveChannel` only moves (into the kit's own
24
+ `channel.ts`); `ChatLog` and the chat event codecs stay with the app.
25
+
26
+ ## Decisions relevant to this repository
27
+
28
+ - Board events (a downstream app's own event kind for its own collaborative
29
+ documents) share KithMoot's chat kind (1460) under a scoped `d` tag, so a
30
+ relay cannot tell a board from a chat. This is why the kit's `KINDS`
31
+ registry keeps `CHAT` even though nothing in the kit itself sends chat
32
+ messages - it is there for a downstream app to reuse the same kind
33
+ number, deliberately, rather than mint its own.
34
+ - The kit repository is public from the start.
35
+ - The relay pool moves to the kit in a later step, after its churn settles.
36
+
37
+ ## Corrections after the source vector review
38
+
39
+ - The kit's kinds subset must include chat kind 1460 (see the board-events
40
+ decision above).
41
+ - `types.ts` imports a `Reachability` type used by roster and media types
42
+ that did not move; the split leaves that type, and everything that
43
+ depends on it, in the app that keeps it.
44
+ - The relay transport interface (`RelayTransport`) lives in the module that
45
+ moves later with the relay pool; the kit declares the transport interface
46
+ it needs itself, rather than importing the app's.
47
+ - Every module that owns a `kithmoot/` wire string exports a frozen
48
+ `*_LABELS` list; after any future cutover, the app's own label test would
49
+ import the kit's lists rather than re-declare them.
50
+
51
+ ## Peer dependency rationale (plan §3.4)
52
+
53
+ Until an npm release exists, a consumer pins an exact Git commit. Once
54
+ published, `nostr-tools` and the noble libraries are peer dependencies so
55
+ a consumer keeps a single copy of each rather than a second one nested
56
+ under this kit.