@forgesworn/fold-kit 0.13.0 → 0.14.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 +100 -0
- package/EXTRACTION.md +22 -0
- package/README.md +22 -1
- package/dist/epoch.d.ts +4 -0
- package/dist/epoch.js +2 -0
- package/dist/evidence-carrier.d.ts +10 -0
- package/dist/evidence-carrier.js +19 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/invitation-storage.d.ts +65 -0
- package/dist/invitation-storage.js +217 -0
- package/dist/persistent-invitation-evidence.d.ts +25 -2
- package/dist/persistent-invitation-evidence.js +126 -40
- package/dist/persistent-invitation.d.ts +12 -0
- package/dist/persistent-invitation.js +25 -3
- package/docs/persistent-invitation-evidence.md +46 -13
- package/docs/seal-key.md +2 -2
- package/llms.txt +3 -2
- package/package.json +3 -1
- package/vectors/storage-vectors.json +3843 -0
|
@@ -2,17 +2,38 @@ import { hkdf } from '@noble/hashes/hkdf';
|
|
|
2
2
|
import { sha256 } from '@noble/hashes/sha2';
|
|
3
3
|
import { base64urlnopad } from '@scure/base';
|
|
4
4
|
import { nip44 } from 'nostr-tools';
|
|
5
|
-
import { verifiedSymbol } from 'nostr-tools/pure';
|
|
5
|
+
import { getEventHash, verifiedSymbol } from 'nostr-tools/pure';
|
|
6
6
|
import { deriveInvitationId, roomInvitation, decodeInvitationRetirementNotice } from './invitation.js';
|
|
7
7
|
import { PERSISTENT_INVITATION_LABELS } from './persistent-invitation.js';
|
|
8
8
|
import { isRoomEnds } from './expiration.js';
|
|
9
9
|
import { isInvitationRelays } from './invitation-relays.js';
|
|
10
|
+
import { isInvitationStorage } from './invitation-storage.js';
|
|
10
11
|
import { deriveRoom } from './room.js';
|
|
11
12
|
import { KINDS } from './kinds.js';
|
|
12
13
|
import { verifyEventUncached } from './verify.js';
|
|
14
|
+
import { MAX_PERSISTENT_INVITATION_EVENT_BYTES, MAX_EVIDENCE_CARRIER_TAGS, MAX_EVIDENCE_CARRIER_TAG_STRINGS } from './evidence-carrier.js';
|
|
13
15
|
export const MAX_PERSISTENT_INVITATION_EVIDENCE_EVENTS = 64;
|
|
14
|
-
export
|
|
16
|
+
export { MAX_PERSISTENT_INVITATION_EVENT_BYTES };
|
|
15
17
|
export const MAX_PERSISTENT_INVITATION_EVIDENCE_BYTES = 262_144;
|
|
18
|
+
/** Raw deliveries one query reads, counted before anything is parsed: every
|
|
19
|
+
* relay's copy of every event, repeats, other authors and junk alike. It bounds
|
|
20
|
+
* parse work only. It is eight times the retained-event limit, so 64 events
|
|
21
|
+
* from each of eight relays still fit. Past it the query fails closed, because
|
|
22
|
+
* an event that could matter may be among those it did not read. */
|
|
23
|
+
export const MAX_PERSISTENT_INVITATION_EVIDENCE_DELIVERIES = 512;
|
|
24
|
+
/** Signature checks one query is charged. Only an event that is in scope and
|
|
25
|
+
* not already held or refused costs one, so a repeat costs none. It is twice
|
|
26
|
+
* the retained-event limit: room for 64 retained events and 64 more in-scope
|
|
27
|
+
* events that fail (forged, over the evidence size limits, or signed but not a
|
|
28
|
+
* supported body). Beyond it the query fails closed rather than skip an
|
|
29
|
+
* unchecked event.
|
|
30
|
+
*
|
|
31
|
+
* It bounds checks, not Schnorr verifications. A retained retirement is
|
|
32
|
+
* verified twice (its scope check, then the retirement decoder), and the
|
|
33
|
+
* finished query revalidates every retained event, so the worst case is
|
|
34
|
+
* 128 + 64 verifications on a query that succeeds. A forgery fails at its
|
|
35
|
+
* first verification, so a hostile relay can cause at most 128. */
|
|
36
|
+
export const MAX_PERSISTENT_INVITATION_EVIDENCE_VERIFICATIONS = 128;
|
|
16
37
|
/** Incomplete observations are bounded diagnostic custody only. They are
|
|
17
38
|
* never accepted by revalidatePersistentInvitationEvidence. */
|
|
18
39
|
export class PersistentInvitationEvidenceQueryError extends Error {
|
|
@@ -27,8 +48,9 @@ const fields = ['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig'];
|
|
|
27
48
|
const utf8 = new TextEncoder();
|
|
28
49
|
const hex32 = /^[0-9a-f]{64}$/;
|
|
29
50
|
const hex64 = /^[0-9a-f]{128}$/;
|
|
30
|
-
/** JSON string byte cost including escaping, without serialising content.
|
|
31
|
-
|
|
51
|
+
/** JSON string byte cost including escaping, without serialising content.
|
|
52
|
+
* `limited` throws past the per-event ceiling; unlimited only measures. */
|
|
53
|
+
function jsonStringBytes(value, limited = true) {
|
|
32
54
|
let bytes = 2;
|
|
33
55
|
for (let i = 0; i < value.length; i++) {
|
|
34
56
|
const c = value.charCodeAt(i);
|
|
@@ -48,7 +70,7 @@ function jsonStringBytes(value) {
|
|
|
48
70
|
bytes += 6;
|
|
49
71
|
else
|
|
50
72
|
bytes += 3;
|
|
51
|
-
if (bytes > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
|
|
73
|
+
if (limited && bytes > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
|
|
52
74
|
throw new Error('oversized evidence event');
|
|
53
75
|
}
|
|
54
76
|
return bytes;
|
|
@@ -82,37 +104,53 @@ function dataArray(raw, limit) {
|
|
|
82
104
|
return values;
|
|
83
105
|
}
|
|
84
106
|
/** Fresh carrier copy, with no trusted verification-cache symbol. Bounds
|
|
85
|
-
* precede copying or serialising potentially large strings/tag arrays.
|
|
86
|
-
|
|
107
|
+
* precede copying or serialising potentially large strings/tag arrays.
|
|
108
|
+
*
|
|
109
|
+
* `tolerant` lifts only the size ceilings (bytes, tag count, tag width): every
|
|
110
|
+
* plain-data, prototype, type and hex refusal stays. The copy of an event that
|
|
111
|
+
* exceeds a ceiling comes back with `over` set and no canonical form, because
|
|
112
|
+
* it can never be retained as evidence. The query uses that to check whether
|
|
113
|
+
* the authority signed it. */
|
|
114
|
+
function carrier(raw, tolerant = false) {
|
|
115
|
+
let over = false;
|
|
116
|
+
const exceed = (message) => { if (!tolerant)
|
|
117
|
+
throw new Error(message); over = true; };
|
|
87
118
|
const d = dataRecord(raw, fields, verifiedSymbol);
|
|
88
119
|
if (Object.keys(d).length !== fields.length || typeof d.id !== 'string' || !hex32.test(d.id) ||
|
|
89
120
|
typeof d.pubkey !== 'string' || !hex32.test(d.pubkey) || typeof d.sig !== 'string' || !hex64.test(d.sig) ||
|
|
90
121
|
!Number.isSafeInteger(d.created_at) || d.created_at < 0 || !Number.isSafeInteger(d.kind) || d.kind < 0 ||
|
|
91
|
-
typeof d.content !== 'string'
|
|
122
|
+
typeof d.content !== 'string')
|
|
92
123
|
throw new Error('invalid or oversized evidence event');
|
|
124
|
+
if (d.content.length > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
|
|
125
|
+
exceed('invalid or oversized evidence event');
|
|
93
126
|
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);
|
|
127
|
+
String(d.created_at).length + String(d.kind).length + jsonStringBytes(d.id) + jsonStringBytes(d.pubkey) + jsonStringBytes(d.sig) + jsonStringBytes(d.content, !tolerant);
|
|
95
128
|
if (bytes > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
|
|
96
|
-
|
|
97
|
-
const outer = dataArray(d.tags,
|
|
129
|
+
exceed('oversized evidence event');
|
|
130
|
+
const outer = dataArray(d.tags, tolerant ? Infinity : MAX_EVIDENCE_CARRIER_TAGS);
|
|
131
|
+
if (outer.length > MAX_EVIDENCE_CARRIER_TAGS)
|
|
132
|
+
over = true;
|
|
98
133
|
bytes += 2 + Math.max(0, outer.length - 1);
|
|
99
134
|
const tags = outer.map(tag => {
|
|
100
|
-
const inner = dataArray(tag,
|
|
135
|
+
const inner = dataArray(tag, tolerant ? Infinity : MAX_EVIDENCE_CARRIER_TAG_STRINGS);
|
|
136
|
+
if (inner.length > MAX_EVIDENCE_CARRIER_TAG_STRINGS)
|
|
137
|
+
over = true;
|
|
101
138
|
bytes += 2 + Math.max(0, inner.length - 1);
|
|
102
139
|
return inner.map(value => {
|
|
103
|
-
if (typeof value !== 'string'
|
|
140
|
+
if (typeof value !== 'string')
|
|
104
141
|
throw new Error('invalid or oversized evidence tag');
|
|
105
|
-
|
|
142
|
+
if (value.length > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
|
|
143
|
+
exceed('invalid or oversized evidence tag');
|
|
144
|
+
bytes += jsonStringBytes(value, !tolerant);
|
|
106
145
|
if (bytes > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
|
|
107
|
-
|
|
146
|
+
exceed('oversized evidence event');
|
|
108
147
|
return value;
|
|
109
148
|
});
|
|
110
149
|
});
|
|
111
150
|
if (bytes > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
|
|
112
|
-
|
|
151
|
+
exceed('oversized evidence event');
|
|
113
152
|
const event = { id: d.id, pubkey: d.pubkey, created_at: d.created_at, kind: d.kind, tags, content: d.content, sig: d.sig };
|
|
114
|
-
|
|
115
|
-
return { event, canonical, bytes };
|
|
153
|
+
return { event, canonical: over ? '' : JSON.stringify(event), bytes, over };
|
|
116
154
|
}
|
|
117
155
|
function copyInvitation(invitation) {
|
|
118
156
|
if (!invitation.persistent || !(invitation.bearer instanceof Uint8Array) || invitation.bearer.length !== 32 || !hex32.test(invitation.inviter))
|
|
@@ -124,10 +162,16 @@ function roomId(secret) {
|
|
|
124
162
|
room.roomKey.fill(0);
|
|
125
163
|
return room.roomId;
|
|
126
164
|
}
|
|
165
|
+
/** The cheap, unsigned part of scope: the authority, a source kind and exactly
|
|
166
|
+
* one two-element `d` tag naming this invitation. No crypto. */
|
|
167
|
+
function inAuthorityScope(event, inviter, invitationId) {
|
|
168
|
+
if (event.pubkey !== inviter || (event.kind !== KINDS.GROUP_INVITATION && event.kind !== KINDS.INVITATION_RETIREMENT))
|
|
169
|
+
return false;
|
|
170
|
+
const d = event.tags.filter(tag => tag[0] === 'd');
|
|
171
|
+
return d.length === 1 && d[0].length === 2 && d[0][1] === invitationId;
|
|
172
|
+
}
|
|
127
173
|
function signedScope(event, invitation) {
|
|
128
|
-
return event
|
|
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);
|
|
174
|
+
return inAuthorityScope(event, invitation.inviter, deriveInvitationId(invitation)) && verifyEventUncached(event);
|
|
131
175
|
}
|
|
132
176
|
/** Strict evidence path accepts exactly the bodies/tags of supported writers;
|
|
133
177
|
* permissive legacy decoding and its public request remain unchanged. */
|
|
@@ -142,19 +186,21 @@ function decodeEvidenceEvent(event, invitation) {
|
|
|
142
186
|
const key = hkdf(sha256, invitation.bearer, undefined, PERSISTENT_INVITATION_LABELS[0], 32);
|
|
143
187
|
try {
|
|
144
188
|
const plaintext = nip44.v2.decrypt(event.content, key);
|
|
145
|
-
const body = dataRecord(JSON.parse(plaintext), ['v', 'room', 'secret', 'ends', 'destruct', 'relays']);
|
|
189
|
+
const body = dataRecord(JSON.parse(plaintext), ['v', 'room', 'secret', 'ends', 'destruct', 'relays', 'storage']);
|
|
146
190
|
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))
|
|
191
|
+
(body.destruct !== undefined && body.destruct !== true) || (body.relays !== undefined && !isInvitationRelays(body.relays)) ||
|
|
192
|
+
(body.storage !== undefined && !isInvitationStorage(body.storage)))
|
|
148
193
|
return null;
|
|
149
194
|
secret = base64urlnopad.decode(body.secret);
|
|
150
195
|
if (secret.length !== 32 || body.room !== roomId(secret))
|
|
151
196
|
return null;
|
|
152
197
|
const decoded = { secret, persistent: true, epoch: 0,
|
|
153
198
|
...(body.ends === undefined ? {} : { endsAt: body.ends }), ...(body.destruct ? { destruct: true } : {}),
|
|
154
|
-
...(body.relays === undefined ? {} : { relays: [...body.relays] })
|
|
199
|
+
...(body.relays === undefined ? {} : { relays: [...body.relays] }),
|
|
200
|
+
...(body.storage === undefined ? {} : { storage: [...body.storage], storageAt: event.created_at }) };
|
|
155
201
|
const expected = JSON.stringify({ v: 3, room: body.room, secret: base64urlnopad.encode(secret),
|
|
156
202
|
...(decoded.endsAt === undefined ? {} : { ends: decoded.endsAt }), ...(decoded.destruct ? { destruct: true } : {}),
|
|
157
|
-
...(decoded.relays === undefined ? {} : { relays: decoded.relays }) });
|
|
203
|
+
...(decoded.relays === undefined ? {} : { relays: decoded.relays }), ...(decoded.storage === undefined ? {} : { storage: decoded.storage }) });
|
|
158
204
|
const tags = decoded.endsAt === undefined ? [['d', deriveInvitationId(invitation)]] : [['d', deriveInvitationId(invitation)], ['expiration', String(decoded.endsAt)]];
|
|
159
205
|
accepted = plaintext === expected && JSON.stringify(event.tags) === JSON.stringify(tags);
|
|
160
206
|
return accepted ? decoded : null;
|
|
@@ -277,12 +323,20 @@ export function revalidatePersistentInvitationEvidence(opts) {
|
|
|
277
323
|
if (new Set(winners.map(d => JSON.stringify(d.admission.relays))).size > 1)
|
|
278
324
|
return held('conflicting-relays');
|
|
279
325
|
const relays = winners[0]?.admission.relays;
|
|
326
|
+
const storageEvents = decoded.filter(d => d.admission.storage !== undefined);
|
|
327
|
+
const newestStorage = storageEvents.length ? Math.max(...storageEvents.map(d => d.event.created_at)) : undefined;
|
|
328
|
+
const storageWinners = storageEvents.filter(d => d.event.created_at === newestStorage);
|
|
329
|
+
if (new Set(storageWinners.map(d => JSON.stringify(d.admission.storage))).size > 1)
|
|
330
|
+
return held('conflicting-storage');
|
|
331
|
+
const storage = storageWinners[0]?.admission.storage;
|
|
280
332
|
const admission = { secret: first.slice(), persistent: true, epoch: 0,
|
|
281
333
|
...(endsAt === undefined ? {} : { endsAt }), ...(destruct.length ? { destruct: true } : {}),
|
|
282
|
-
...(relays === undefined ? {} : { relays: [...relays] })
|
|
334
|
+
...(relays === undefined ? {} : { relays: [...relays] }),
|
|
335
|
+
...(storage === undefined ? {} : { storage: [...storage], storageAt: newestStorage }) };
|
|
283
336
|
const contributors = Object.freeze({ room: Object.freeze(decoded.map(d => d.event.id)),
|
|
284
337
|
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))
|
|
338
|
+
destruct: Object.freeze(destruct.map(d => d.event.id)), relays: Object.freeze(winners.map(d => d.event.id)),
|
|
339
|
+
...(storageWinners.length ? { storage: Object.freeze(storageWinners.map(d => d.event.id)) } : {}) });
|
|
286
340
|
return Object.freeze({ evidence, outcome: Object.freeze({ status: 'admitted', admission, contributors }) });
|
|
287
341
|
}
|
|
288
342
|
finally {
|
|
@@ -333,29 +387,61 @@ export async function requestPersistentRoomAdmissionEvidence(opts) {
|
|
|
333
387
|
return;
|
|
334
388
|
}
|
|
335
389
|
try {
|
|
336
|
-
|
|
390
|
+
const invitationId = deriveInvitationId(invitation);
|
|
391
|
+
// Canonical forms that were checked and refused, so a repeat of one costs no crypto.
|
|
392
|
+
const refused = new Set();
|
|
393
|
+
let verifications = 0;
|
|
394
|
+
unsub = opts.transport.subscribe([{ kinds: [KINDS.GROUP_INVITATION, KINDS.INVITATION_RETIREMENT], authors: [invitation.inviter], '#d': [invitationId] }], event => {
|
|
337
395
|
if (settled)
|
|
338
396
|
return;
|
|
339
397
|
try {
|
|
340
|
-
//
|
|
341
|
-
|
|
342
|
-
|
|
398
|
+
// Every delivery is charged first and bounds parse work. Retained
|
|
399
|
+
// events, retained bytes and signature checks have their own limits.
|
|
400
|
+
if (received >= MAX_PERSISTENT_INVITATION_EVIDENCE_DELIVERIES)
|
|
401
|
+
throw new Error('invitation evidence query capacity exceeded: too many deliveries');
|
|
343
402
|
received++;
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
403
|
+
let c;
|
|
404
|
+
try {
|
|
405
|
+
c = carrier(event, true);
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
return;
|
|
409
|
+
} // malformed beyond size: not evidence, not a failure
|
|
410
|
+
if (!inAuthorityScope(c.event, invitation.inviter, invitationId))
|
|
411
|
+
return;
|
|
412
|
+
// An id already held was verified, so any other carrier with that id
|
|
413
|
+
// has the same signed fields and nothing new to say. A carrier that
|
|
414
|
+
// only claims it cannot verify. Dropping it loses no signed event.
|
|
415
|
+
// An over-limit carrier is remembered by its computed hash and signature,
|
|
416
|
+
// never by the id it claims: a tampered copy that claims a real event's
|
|
417
|
+
// id and signature has a different hash, so it cannot mask the real one.
|
|
418
|
+
const overKey = c.over ? `${getEventHash(c.event)}:${c.event.sig}` : '';
|
|
419
|
+
if (events.has(c.event.id) || refused.has(c.over ? overKey : c.canonical))
|
|
420
|
+
return;
|
|
421
|
+
if (verifications >= MAX_PERSISTENT_INVITATION_EVIDENCE_VERIFICATIONS)
|
|
422
|
+
throw new Error('invitation evidence query capacity exceeded: too many signature checks');
|
|
423
|
+
verifications++;
|
|
424
|
+
if (c.over) {
|
|
425
|
+
// Over the size limits, so it can never be retained. If the authority
|
|
426
|
+
// signed it, it may be a retirement or a newer list that this query
|
|
427
|
+
// cannot hold: fail closed rather than select without it. An event
|
|
428
|
+
// that only claims the authority's key is dropped.
|
|
429
|
+
if (verifyEventUncached(c.event))
|
|
430
|
+
throw new Error('invitation evidence query refused an event signed by the authority that exceeds the evidence size limits');
|
|
431
|
+
refused.add(overKey);
|
|
352
432
|
return;
|
|
353
433
|
}
|
|
354
434
|
const decoded = decodeEvidenceEvent(c.event, invitation);
|
|
355
|
-
if (!decoded)
|
|
435
|
+
if (!decoded) {
|
|
436
|
+
refused.add(c.canonical);
|
|
356
437
|
return;
|
|
438
|
+
}
|
|
357
439
|
if (decoded !== 'retired')
|
|
358
440
|
decoded.secret.fill(0);
|
|
441
|
+
// A valid authority event that no longer fits fails the query: never evict or truncate.
|
|
442
|
+
if (events.size >= MAX_PERSISTENT_INVITATION_EVIDENCE_EVENTS || bytes + c.bytes > MAX_PERSISTENT_INVITATION_EVIDENCE_BYTES)
|
|
443
|
+
throw new Error('invitation evidence query capacity exceeded');
|
|
444
|
+
bytes += c.bytes;
|
|
359
445
|
events.set(c.event.id, c.canonical);
|
|
360
446
|
}
|
|
361
447
|
catch (error) {
|
|
@@ -16,6 +16,13 @@ export interface PersistentRoomAdmission {
|
|
|
16
16
|
/** The room self-destructs: when it ends, every device deletes what it
|
|
17
17
|
* wrote and forgets the room. Absent for a room that simply ends. */
|
|
18
18
|
destruct?: true;
|
|
19
|
+
/** The room's group storage nodes, as its authority last signed them; `[]`
|
|
20
|
+
* when it withdrew them all. Absent from an invitation written before
|
|
21
|
+
* 0.14.0, or when no copy names them. */
|
|
22
|
+
storage?: string[];
|
|
23
|
+
/** The `created_at` of the copy whose `storage` stood: present exactly when
|
|
24
|
+
* `storage` is. A client uses it as the record's version. */
|
|
25
|
+
storageAt?: number;
|
|
19
26
|
}
|
|
20
27
|
/** Publish once before sharing. The link plus this envelope is a durable
|
|
21
28
|
* bearer capability: anybody holding both can learn epoch 0. Retirement
|
|
@@ -33,6 +40,11 @@ export declare function encodePersistentInvitation(opts: {
|
|
|
33
40
|
* form (see `isInvitationRelays`), else it throws. Omitted, the body is
|
|
34
41
|
* byte-identical to 0.3.0's. */
|
|
35
42
|
relays?: readonly string[];
|
|
43
|
+
/** The room's group storage nodes: zero to eight canonical node base URLs,
|
|
44
|
+
* one per host (see `isInvitationStorage`), else it throws. `[]` says the
|
|
45
|
+
* authority withdrew every node. Written after `relays`. Omitted, the body
|
|
46
|
+
* is byte-identical to 0.13.0's. */
|
|
47
|
+
storage?: readonly string[];
|
|
36
48
|
/** The room self-destructs when it ends, by its time or by its authority
|
|
37
49
|
* closing it. Written inside the encrypted body only, never as a tag.
|
|
38
50
|
* Omitted or false, the event is byte-identical to 0.8.0's. */
|
|
@@ -9,6 +9,8 @@ import { deriveRoom } from './room.js';
|
|
|
9
9
|
import { verifyEventUncached } from './verify.js';
|
|
10
10
|
import { isRoomEnds, requireRoomEnds } from './expiration.js';
|
|
11
11
|
import { isInvitationRelays, requireInvitationRelays } from './invitation-relays.js';
|
|
12
|
+
import { isInvitationStorage, requireInvitationStorage } from './invitation-storage.js';
|
|
13
|
+
import { requireEvidenceCarrierLimits } from './evidence-carrier.js';
|
|
12
14
|
function welcomeKey(invitation) {
|
|
13
15
|
if (!invitation.persistent)
|
|
14
16
|
throw new Error('a persistent group invitation is required');
|
|
@@ -25,14 +27,15 @@ export function encodePersistentInvitation(opts) {
|
|
|
25
27
|
const room = deriveRoom(opts.roomSecret).roomId;
|
|
26
28
|
const ends = opts.endsAt === undefined ? undefined : requireRoomEnds(opts.endsAt, opts.now);
|
|
27
29
|
const relays = opts.relays === undefined ? undefined : requireInvitationRelays(opts.relays);
|
|
28
|
-
|
|
30
|
+
const storage = opts.storage === undefined ? undefined : requireInvitationStorage(opts.storage);
|
|
31
|
+
return requireEvidenceCarrierLimits(finalizeEvent({
|
|
29
32
|
kind: KINDS.GROUP_INVITATION,
|
|
30
33
|
created_at: opts.now,
|
|
31
34
|
tags: ends === undefined ? [['d', deriveInvitationId(opts.invitation)]] : [['d', deriveInvitationId(opts.invitation)], ['expiration', String(ends)]],
|
|
32
35
|
content: nip44.v2.encrypt(JSON.stringify({
|
|
33
|
-
v: 3, room, secret: base64urlnopad.encode(opts.roomSecret), ...(ends === undefined ? {} : { ends }), ...(opts.destruct ? { destruct: true } : {}), ...(relays === undefined ? {} : { relays }),
|
|
36
|
+
v: 3, room, secret: base64urlnopad.encode(opts.roomSecret), ...(ends === undefined ? {} : { ends }), ...(opts.destruct ? { destruct: true } : {}), ...(relays === undefined ? {} : { relays }), ...(storage === undefined ? {} : { storage }),
|
|
34
37
|
}), welcomeKey(opts.invitation)),
|
|
35
|
-
}, opts.inviterSk);
|
|
38
|
+
}, opts.inviterSk), 'group invitation');
|
|
36
39
|
}
|
|
37
40
|
export function decodePersistentInvitation(event, invitation) {
|
|
38
41
|
try {
|
|
@@ -62,6 +65,10 @@ export function decodePersistentInvitation(event, invitation) {
|
|
|
62
65
|
// would write; a malformed one refuses the envelope rather than half of it.
|
|
63
66
|
if (body.relays !== undefined && !isInvitationRelays(body.relays))
|
|
64
67
|
return null;
|
|
68
|
+
// The same for the room's storage nodes: `[]` is valid (withdrawn), a
|
|
69
|
+
// malformed list refuses the envelope.
|
|
70
|
+
if (body.storage !== undefined && !isInvitationStorage(body.storage))
|
|
71
|
+
return null;
|
|
65
72
|
// Self-destruct is `true` or absent; anything else is a malformed
|
|
66
73
|
// envelope, refused as a malformed end is.
|
|
67
74
|
if (body.destruct !== undefined && body.destruct !== true)
|
|
@@ -69,6 +76,10 @@ export function decodePersistentInvitation(event, invitation) {
|
|
|
69
76
|
const admission = ends === undefined ? { secret, persistent: true, epoch: 0 } : { secret, persistent: true, epoch: 0, endsAt: ends };
|
|
70
77
|
if (body.relays !== undefined)
|
|
71
78
|
admission.relays = [...body.relays];
|
|
79
|
+
if (body.storage !== undefined) {
|
|
80
|
+
admission.storage = [...body.storage];
|
|
81
|
+
admission.storageAt = event.created_at;
|
|
82
|
+
}
|
|
72
83
|
if (body.destruct === true)
|
|
73
84
|
admission.destruct = true;
|
|
74
85
|
return admission;
|
|
@@ -89,6 +100,7 @@ export function requestPersistentRoomAdmission(opts) {
|
|
|
89
100
|
let settled = false;
|
|
90
101
|
let admission;
|
|
91
102
|
let relaysAt = -1;
|
|
103
|
+
let storageAt = -1;
|
|
92
104
|
let unsub = () => { };
|
|
93
105
|
const timer = setTimeout(() => finish(new Error('the group invitation could not be loaded from its relays')), opts.timeoutMs ?? 15_000);
|
|
94
106
|
function finish(error) {
|
|
@@ -136,6 +148,16 @@ export function requestPersistentRoomAdmission(opts) {
|
|
|
136
148
|
relaysAt = event.created_at;
|
|
137
149
|
else if (admission?.relays !== undefined)
|
|
138
150
|
decoded.relays = admission.relays;
|
|
151
|
+
// The storage nodes merge the same way, independently of the relays:
|
|
152
|
+
// the newest copy that names them stands, `[]` included; a copy that
|
|
153
|
+
// names none says nothing, so the keeper's plain copy never erases
|
|
154
|
+
// the list; on equal timestamps the first heard stays.
|
|
155
|
+
if (decoded.storage !== undefined && event.created_at > storageAt)
|
|
156
|
+
storageAt = event.created_at;
|
|
157
|
+
else if (admission?.storage !== undefined) {
|
|
158
|
+
decoded.storage = admission.storage;
|
|
159
|
+
decoded.storageAt = admission.storageAt;
|
|
160
|
+
}
|
|
139
161
|
admission = decoded;
|
|
140
162
|
}, () => {
|
|
141
163
|
if (admission)
|
|
@@ -49,17 +49,50 @@ Unknown fields, non-data descriptors, custom prototypes, sparse arrays and
|
|
|
49
49
|
noncanonical reload strings are refused. JavaScript proxies are outside this
|
|
50
50
|
plain-data transport boundary; callers must not pass executable objects.
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
52
|
+
Retained evidence holds at most 64 events, with 16,384 UTF-8 bytes per
|
|
53
|
+
canonical carrier and 262,144 UTF-8 bytes across all retained carriers. Event
|
|
54
|
+
tags are bounded to 64 arrays of at most eight strings. These are
|
|
55
|
+
evidence-profile limits, not new wire limits for the legacy API.
|
|
56
|
+
|
|
57
|
+
A query reads at most 512 deliveries
|
|
58
|
+
(`MAX_PERSISTENT_INVITATION_EVIDENCE_DELIVERIES`): every relay's copy of every
|
|
59
|
+
event, repeats, other authors and junk alike, each charged before it is parsed.
|
|
60
|
+
Past that the query fails, because an event that could matter may be among
|
|
61
|
+
those it did not read; delivery 513 is never inspected. The bound limits parse
|
|
62
|
+
work only. It is eight times the retained limit so that 64 events heard from
|
|
63
|
+
each of eight relays still fit.
|
|
64
|
+
|
|
65
|
+
What is delivered is then sorted cheaply, before any signature check:
|
|
66
|
+
|
|
67
|
+
- A malformed carrier (not plain data, a wrong type, bad hex), an event not by the inviter, not kind 1463
|
|
68
|
+
or 1461, or without exactly one `d` tag naming the invitation, is dropped.
|
|
69
|
+
It is not evidence and it does not fail the query.
|
|
70
|
+
- An exact repeat, another carrier under an id already held, and a carrier
|
|
71
|
+
already checked and refused, are dropped. A held id was verified, so a second
|
|
72
|
+
carrier with it has the same signed fields.
|
|
73
|
+
- An event in scope but over the carrier size limits (16,384 bytes, 64 tags,
|
|
74
|
+
eight strings in a tag) can never be retained. It is hashed and verified once,
|
|
75
|
+
charged to the signature bound below. If the authority signed it, the query
|
|
76
|
+
fails, because it may be a retirement or a newer list that no evidence can
|
|
77
|
+
hold. If not, it is dropped, and a repeat of it costs nothing.
|
|
78
|
+
- Anything else in scope costs one signature check. A query is charged at most
|
|
79
|
+
128 (`MAX_PERSISTENT_INVITATION_EVIDENCE_VERIFICATIONS`): room for 64 retained
|
|
80
|
+
events and 64 more in-scope events that fail, forged or not a supported body.
|
|
81
|
+
The 129th is never checked and the query fails. The bound counts checks, not
|
|
82
|
+
Schnorr verifications. A retained retirement is verified twice, and the
|
|
83
|
+
finished query revalidates every event it kept, so a query that succeeds can
|
|
84
|
+
do up to 128 + 64 verifications. A forgery fails at its first, so a hostile
|
|
85
|
+
relay can cause at most 128.
|
|
86
|
+
- A checked, valid authority event is retained. If that would be a 65th event
|
|
87
|
+
or pass 262,144 bytes, the query fails. Nothing is evicted or truncated.
|
|
88
|
+
|
|
89
|
+
An in-scope, well-formed, validly signed authority event is never dropped, and
|
|
90
|
+
neither is an over-limit one: each is retained or fails the query. What the
|
|
91
|
+
query drops is what cannot be an authority event (malformed, out of scope,
|
|
92
|
+
unsigned by the authority) or repeats what it already holds.
|
|
93
|
+
`encodePersistentInvitation` refuses to sign an event over the size limits, so
|
|
94
|
+
only another writer can produce one. The failures above reject with
|
|
95
|
+
`PersistentInvitationEvidenceQueryError`.
|
|
63
96
|
|
|
64
97
|
Evidence records, carrier strings and ID arrays are frozen owned copies.
|
|
65
98
|
String memory is not claimed securely erased. Each admitted result has a fresh
|
|
@@ -102,8 +135,8 @@ custody and later independent revalidation.
|
|
|
102
135
|
## Completion and failure
|
|
103
136
|
|
|
104
137
|
The query waits for the transport's EOSE even after an invitation or retirement
|
|
105
|
-
arrives. Timeout, abort, subscription failure
|
|
106
|
-
|
|
138
|
+
arrives. Timeout, abort, subscription failure or a capacity failure (deliveries,
|
|
139
|
+
signature checks, retained events or retained bytes) rejects with `PersistentInvitationEvidenceQueryError`.
|
|
107
140
|
Its bounded `observation` is explicitly `completion: 'incomplete'`, diagnostic
|
|
108
141
|
custody only. The revalidator always refuses incomplete observations. They
|
|
109
142
|
cannot be relabelled to establish completeness; completion metadata itself is
|
package/docs/seal-key.md
CHANGED
|
@@ -121,8 +121,8 @@ it costs the epoch. So `oneTimeSender` stays off in every writer until readers
|
|
|
121
121
|
have shipped on every client and had a release in the field. Off, every
|
|
122
122
|
event is byte-identical to before.
|
|
123
123
|
|
|
124
|
-
`hostRoomEpoch`
|
|
125
|
-
|
|
124
|
+
`hostRoomEpoch` takes `oneTimeSender` too and passes it to every grant it
|
|
125
|
+
writes; it is off by default, like the encoders.
|
|
126
126
|
|
|
127
127
|
## Vectors
|
|
128
128
|
|
package/llms.txt
CHANGED
|
@@ -10,7 +10,7 @@ ESM-only, Node.js 22.13+. Four entry points: `@forgesworn/fold-kit` (circle prim
|
|
|
10
10
|
npm install @forgesworn/fold-kit
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
This file describes `main` (version 0.
|
|
13
|
+
This file describes `main` (version 0.14.0), which can run ahead of the npm `latest` release; compare `npm view @forgesworn/fold-kit version` with the version of the API you need. To use an unreleased change, pin an immutable Git commit (a full SHA, for example from `git ls-remote https://github.com/forgesworn/fold-kit main`); the `prepare` script builds `dist/` on install:
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
16
|
npm install "github:forgesworn/fold-kit#<commit>"
|
|
@@ -124,6 +124,7 @@ Invitation versions: v1 (`encodeJoinUrl`, the room secret in the link) is legacy
|
|
|
124
124
|
- Same optional entry: `revalidatePersistentInvitationSource({ event, invitation, expectedRoomSecret })` independently authenticates one actual encoder Event or exact canonical stored carrier against a mandatory room-root pin. Returns a frozen `PersistentInvitationSource` with `version`, `invitationId`, `inviter`, `eventId`, `createdAt`, `event` string and owned `admission`. No completion/EOSE, publication, newest-selection, currentness or non-retirement claim. Rejects retirement, unsupported bodies/tags, forged signature caches, noncanonical strings and oversize before parsing/crypto. Creator clients retain exact event bytes and publication intent before pools; retry those bytes under fresh effect guards. Erase returned admission.secret after use.
|
|
125
125
|
- Conference rooms: `encodePersistentInvitation({ ..., endsAt })` puts `ends` in the body and `['expiration', String(endsAt)]` on the 1463; `decodePersistentInvitation` returns `endsAt` and is null when an `expiration` tag disagrees with the body; `encodeInvitationRetirement({ ..., endsAt })` tags the 1461 the same way
|
|
126
126
|
- Room relays: `encodePersistentInvitation({ ..., relays })` puts the room's own relays (1-8 distinct canonical `wss://host/` URLs, else it throws) in the body after `ends`; the decoder returns `relays` and is null on a malformed list; `requestPersistentRoomAdmission` keeps the newest signed copy's list (a copy without one says nothing); `isInvitationRelays`, `requireInvitationRelays`, `MAX_INVITATION_RELAYS` (8)
|
|
127
|
+
- Room storage nodes: `encodePersistentInvitation({ ..., storage })` puts the room's group storage nodes (0-8 canonical base URLs on distinct hosts, e.g. `https://grove.example.net`; `http` only for `localhost`, `127.x.y.z` or an onion host; no default port, no trailing slash, no user info, query or fragment; else it throws) last in the body after `relays`, never a tag; `[]` means the authority withdrew every node, an absent key says nothing and leaves the body byte-identical to 0.13.0's. The decoder returns `storage` and `storageAt` (the `created_at` of the copy whose list stood) and is null on a malformed list; `requestPersistentRoomAdmission` and the strict evidence path keep the newest signed copy that names storage (a copy without the key says nothing), independently of `relays`; the strict path holds a same-second disagreement as `conflicting-storage` and `contributors.storage` names the copy that stood (present only when a copy names storage). `storageNodeFromBase(text)` → `{ base, server }` canonicalises an operator's URL (throws a message fit to show an operator); `storageNodeServer(base)` → the `server` (host) of a canonical base by grammar alone, `undefined` when not canonical, no URL parser; `isStorageNodeBase`, `isInvitationStorage`, `requireInvitationStorage`, `MAX_INVITATION_STORAGE_NODES` (8), `MAX_STORAGE_NODE_URL_LENGTH` (256). Vectors: `vectors/storage-vectors.json`
|
|
127
128
|
- Self-destructing rooms (see `docs/room-destruct.md`): `encodePersistentInvitation({ ..., destruct: true })` puts `"destruct": true` in the encrypted body after `ends` (valid without `ends`), never a tag; `decodePersistentInvitation` returns `destruct: true` and is null when the field is present and not `true`; `requestPersistentRoomAdmission` keeps `destruct` if any valid copy carries it (sticky, like the earliest `ends`). `encodeInvitationRetirement({ ..., ended: true, destruct: true })` writes `{"v":1,"ended":true,"destruct":true}` (plain JSON, like `ended`) and throws on `destruct` without `ended`; `decodeInvitationRetirementNotice` returns `{ ended, destruct?: true }`, believing `destruct` only beside `ended`. `encodeRekeyEvent({ ..., closed: true, destruct: true })` writes it in the encrypted body after `closed` and throws without `closed`; `RekeyNotice.destruct` / `RekeyEvidence.destruct` are set only on a closing body, and a flag on an open rekey is dropped, not refused. Without the flag every event is byte-identical to 0.8.0, and a 0.8.0 reader ignores it
|
|
128
129
|
- `withExpiration(tags, expiresAt?)` - NIP-40 rule for a room that ends: add the tag, keep an earlier one, lower a later one; `isRoomEnds`, `requireRoomEnds(endsAt, now)`, `MAX_ROOM_ENDS_SECONDS` (30 days)
|
|
129
130
|
- Epoch encoders (`encodeRekeyEvent`, `encodeEpochRequest`, `encodeEpochGrant`) and `hostRoomEpoch` / `requestRoomEpoch` take `expiresAt?` for a conference room
|
|
@@ -139,7 +140,7 @@ Invitation versions: v1 (`encodeJoinUrl`, the room secret in the link) is legacy
|
|
|
139
140
|
- Quiet replies (see `docs/quiet-replies.md`): `hostRoomEpoch` waits a random `replyDelayMs` (`[min, max]` ms, default `DEFAULT_REPLY_DELAY_MS` = `[500, 2000]`, `[0, 0]` answers at once) before every grant or refusal, deciding when the wait ends; closing cancels waits. A participant is sent `removed`/`closed` at most once per `refuseAgainAfter` seconds (default `REFUSE_AGAIN_AFTER_SECONDS` = 3600) and `unknown` once per `reportUnknownEvery` (60); at most `maxRefusalsPerMinute` refusals (default `MAX_REFUSALS_PER_MINUTE` = 20) go out in any minute in all. Grants are never limited. A suppressed refusal still calls `onRefused`/`onUnknown`; its asker times out with the plain error. `random` and `schedule` (`ReplySchedule`) are injectable. `RefusalLimiter` (bounded to `REFUSAL_LIMITER_CAPACITY` = 256 keys) and `replyDelay(range, random)` are exported. Events and vectors are unchanged
|
|
140
141
|
- Member epoch catch-up (see `docs/member-epoch-catch-up.md`): `encodeRekeyEvent({ ..., commit: true })` writes `epochCommitment(roomId, epoch, secret)` into the rekey body; `hostMemberEpochDesk(opts)` lets any current member answer; `requestRoomEpoch({ ..., members: memberEpochSource(opts) })` asks the authority and members, first verifiable answer wins; `requestMemberEpoch(opts)` asks members alone. Kinds `MEMBER_EPOCH_KINDS` = `{ REQUEST: 20471, GRANT: 20472 }`. A member grant carries the authority-signed rekeys from the asker's epoch to the current one; the asker verifies the chain itself (`decodeMemberEpochGrant`) and refuses a last epoch with no commitment (legacy: authority only), a chain that removes it or closes the room, and one that stops short of `expected`. A verified grant is `{ epoch, removed, passed }`: `passed` holds the epochs between the asker's and `epoch`, oldest first, and `requestRoomEpoch` carries it as an optional `passed` when a member answered. Codecs: `encodeMemberEpochRequest`, `decodeMemberEpochRequest`, `encodeMemberEpochGrant`, `readRekeyEvidence`, `deriveMemberEpochRequestKey`, `MAX_MEMBER_EPOCH_CHAIN` (32)
|
|
141
142
|
- The known-members gate (#207, see `docs/member-epoch-catch-up.md`): once a room has removed anybody (`removed()` not empty), `hostRoomEpoch` and `hostMemberEpochDesk` grant only to a participant for which `known(participant)` is true; anybody else is reported through `onUnknown` (once per participant, and again every `reportUnknownEvery` seconds, default `REPORT_UNKNOWN_EVERY_SECONDS` = 60, while they keep asking) and refused: the authority answers `refused: 'unknown'` (an `EpochRefusal`), a member stays silent. `unknown` is not final: `requestRoomEpoch` keeps asking, calls its `onUnknown` once, and rejects with `EpochRefusedError('unknown')` only at its timeout; a desk looks at the requester's next ask afresh, so letting somebody in is making `known` say yes. No `known` after a removal means nobody is known. Member lists: `encodeRekeyEvent({ members })` writes the authority's list (removed dropped, sorted) into the body, read back as `RekeyNotice.members` and `RekeyEvidence.members`, and as `MemberEpochGrant.members` (newest in the chain); `hostRoomEpoch({ members })` puts it in its grants (`EpochGrant.members`). `readMemberList(raw)` reads one, undefined when malformed. Without `members` and with no removal every event is byte-identical to 0.5.1
|
|
142
|
-
- Seal keys (see `docs/seal-key.md`): `createDeviceCredential({ ..., seal })` appends `["seal", <x-only pubkey>]` last, under the participant's signature; mint a fresh one per credential with `generateSealKey()` and keep its secret as you keep the device key (old copies are sealed to old seal keys; healing needs only that each new one is fresh). `encodeRekeyEvent` recipients may be `{ device, credential }`; `encodeEpochGrant` and `encodeMemberEpochGrant` take `credential`; each copy is sealed to `sealTarget(device, credential)`: the credential's seal key, else the device key (never back to the device key because the credential lapsed). Readers take `sealSks` (`decodeRekeyEvent`, `decodeEpochGrant`, `decodeMemberEpochGrant`) or a `sealSks()` getter (`requestRoomEpoch`, `memberEpochSource`) and try them before the device key (`openSealed`); a rekey copy nothing opens reads as no copy. Desks take `credentialFor(device)`, the newest credential the room has seen (keep it monotone), and seal to the newer of that and the presented one (`sealCredential`, `newerCredential`: later expiration, then later created_at). `credentialSeal(c)` is the pubkey, undefined for none, null when doubled or off the curve (sealed to the device key). Without seal keys every event is byte-identical to 0.6.0 - One-time senders (see `docs/seal-key.md`, "One-time senders"): `encodeRekeyEvent` and `encodeEpochGrant` take `oneTimeSender: true`, sealing every copy from a fresh key named in one `["from", <x-only pubkey>]` tag (still authority-signed) and zeroing it, so a later copy of the authority key opens no past copy; readers open with `sealSender(event)` (the `from` key, else the signer; null when doubled or off the curve: a rekey reads without a secret, a grant is refused). Keep writers off until every client's readers have shipped; off, events are byte-identical to 0.12.0.
|
|
143
|
+
- Seal keys (see `docs/seal-key.md`): `createDeviceCredential({ ..., seal })` appends `["seal", <x-only pubkey>]` last, under the participant's signature; mint a fresh one per credential with `generateSealKey()` and keep its secret as you keep the device key (old copies are sealed to old seal keys; healing needs only that each new one is fresh). `encodeRekeyEvent` recipients may be `{ device, credential }`; `encodeEpochGrant` and `encodeMemberEpochGrant` take `credential`; each copy is sealed to `sealTarget(device, credential)`: the credential's seal key, else the device key (never back to the device key because the credential lapsed). Readers take `sealSks` (`decodeRekeyEvent`, `decodeEpochGrant`, `decodeMemberEpochGrant`) or a `sealSks()` getter (`requestRoomEpoch`, `memberEpochSource`) and try them before the device key (`openSealed`); a rekey copy nothing opens reads as no copy. Desks take `credentialFor(device)`, the newest credential the room has seen (keep it monotone), and seal to the newer of that and the presented one (`sealCredential`, `newerCredential`: later expiration, then later created_at). `credentialSeal(c)` is the pubkey, undefined for none, null when doubled or off the curve (sealed to the device key). Without seal keys every event is byte-identical to 0.6.0 - One-time senders (see `docs/seal-key.md`, "One-time senders"): `encodeRekeyEvent` and `encodeEpochGrant` take `oneTimeSender: true`, sealing every copy from a fresh key named in one `["from", <x-only pubkey>]` tag (still authority-signed) and zeroing it, so a later copy of the authority key opens no past copy; readers open with `sealSender(event)` (the `from` key, else the signer; null when doubled or off the curve: a rekey reads without a secret, a grant is refused). `hostRoomEpoch({ oneTimeSender: true })` does the same for every grant the desk writes. Keep writers off until every client's readers have shipped; off, events are byte-identical to 0.12.0.
|
|
143
144
|
- Scheduled rekeys and the history window (see `docs/scheduled-rekey.md`): `encodeRekeyEvent({ ..., scheduled: true })` writes `"scheduled": true` inside the encrypted body (never a tag), and throws beside a non-empty `removed` or `closed`; `decodeRekeyEvent` (`RekeyNotice.scheduled`) and `readRekeyEvidence` (`RekeyEvidence.scheduled`) report `true` only when `removed` is empty and the room is not closed, so a contradictory body is still announced. `HISTORY_WINDOW_SECONDS` (30 days) and `MAX_HISTORY_EPOCHS` (16); `epochsInWindow(left, now)` keeps entries with `leftAt >= now - HISTORY_WINDOW_SECONDS`, newest first by epoch, first of a doubled epoch, at most 16, never throws. `encodeEpochGrant({ ..., passed })` takes `LeftEpoch[]` (`{ epoch, secret, leftAt }`) and writes `passed: [{ epoch, secret (base64url), left }]` oldest first, only when non-empty; it throws on more than 16, epoch 0 (the room secret is never carried), an epoch not below the granted one, a doubled epoch, a non-32-byte secret or a bad `leftAt`. `hostRoomEpoch({ past })` is asked on every grant, filtered to what the encoder accepts and to `epochsInWindow`, so a bad `past()` costs the history, not the grant. `decodeEpochGrant` returns `EpochGrant.passed` (`leftAt` set) only when the list is exactly in that form, else drops the field and keeps the grant; `decodeMemberEpochGrant` sets `leftAt` on each passed epoch from the next rekey's `created_at`. `MAX_MEMBER_EPOCH_CHAIN` (32) is a different limit. Without the marker and `passed` every event is byte-identical to 0.7.0, and a 0.7.0 reader ignores both
|
|
144
145
|
- `canonicalAdmins(admins)` / `signAdmins(opts)` / `verifyAdmins(opts)` - the authority's signed admin list
|
|
145
146
|
- `canonicalChannels(channels)` / `signChannels(opts)` / `verifyChannels(opts)` - the authority's signed channel list; `CHANNEL_NAME`, `RESERVED_CHANNELS`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgesworn/fold-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Circle primitives for private Nostr groups nobody operates: epoch keys, link invites, removal by rekey, device credentials.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"vectors/seal-vectors.json",
|
|
33
33
|
"vectors/schedule-vectors.json",
|
|
34
34
|
"vectors/destruct-vectors.json",
|
|
35
|
+
"vectors/storage-vectors.json",
|
|
35
36
|
"LICENSE",
|
|
36
37
|
"README.md",
|
|
37
38
|
"llms.txt",
|
|
@@ -55,6 +56,7 @@
|
|
|
55
56
|
"generate-seal": "node scripts/generate-seal.mjs",
|
|
56
57
|
"generate-schedule": "node scripts/generate-schedule.mjs",
|
|
57
58
|
"generate-destruct": "node scripts/generate-destruct.mjs",
|
|
59
|
+
"generate-storage": "node scripts/generate-storage.mjs",
|
|
58
60
|
"bundle-check": "node scripts/bundle-check.mjs",
|
|
59
61
|
"tarball-smoke": "node scripts/tarball-smoke.mjs",
|
|
60
62
|
"vectors": "vitest run vectors/",
|