@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/CHANGELOG.md +357 -0
- package/EXTRACTION.md +549 -0
- package/README.md +198 -15
- package/dist/epoch.d.ts +28 -1
- package/dist/epoch.js +99 -60
- package/dist/index.d.ts +7 -2
- package/dist/index.js +5 -1
- package/dist/invitation-decline.d.ts +26 -0
- package/dist/invitation-decline.js +74 -0
- package/dist/invitation.d.ts +61 -5
- package/dist/invitation.js +300 -57
- package/dist/live-persistent-admission.d.ts +52 -0
- package/dist/live-persistent-admission.js +183 -0
- package/dist/mls-admission.d.ts +79 -0
- package/dist/mls-admission.js +400 -0
- package/dist/persistent-invitation-evidence.d.ts +83 -0
- package/dist/persistent-invitation-evidence.js +381 -0
- package/dist/quiet-reply.d.ts +45 -0
- package/dist/quiet-reply.js +82 -0
- package/docs/extraction-plan-excerpt.md +56 -0
- package/docs/live-persistent-admission.md +192 -0
- package/docs/member-epoch-catch-up.md +346 -0
- package/docs/mls-admission-v4.md +84 -0
- package/docs/persistent-invitation-evidence.md +135 -0
- package/docs/quiet-replies.md +81 -0
- package/docs/room-destruct.md +142 -0
- package/docs/scheduled-rekey.md +142 -0
- package/docs/seal-key.md +104 -0
- package/llms.txt +97 -9
- package/package.json +24 -4
- package/vectors/fold-vectors.json +894 -0
- package/vectors/invitation-account-vectors.json +134 -0
- package/vectors/invitation-decline-vectors.json +110 -0
- package/vectors/live-persistent-vectors.json +1240 -0
- package/vectors/mls-admission-v4.json +25 -0
|
@@ -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.
|