@forgesworn/fold-kit 0.12.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 CHANGED
@@ -5,6 +5,120 @@ This kit is pre-1.0 (see AGENTS.md "Release Notes"); a behaviour change on
5
5
  byte-identical copy of a KithMoot function (see EXTRACTION.md) and this is
6
6
  the one place its behaviour has deliberately diverged.
7
7
 
8
+ ## 0.14.0
9
+
10
+ ### Added
11
+
12
+ - Room storage nodes: the group storage nodes a room keeps its files on,
13
+ carried in its signed group invitation (kind 1463) so a client uploads to
14
+ the group's own nodes instead of a public server.
15
+ `encodePersistentInvitation` takes an optional `storage`: zero to eight
16
+ canonical node base URLs on distinct hosts, else it throws. The encrypted v3
17
+ body carries them last, after `relays` (key order `v, room, secret, ends,
18
+ destruct, relays, storage`; the version stays 3, so every lenient reader,
19
+ including shipped Android, ignores the key). `[]` says the room's authority
20
+ withdrew every node; a body without the key says nothing about nodes.
21
+ With no `storage`, the event is byte-identical to 0.13.0's.
22
+ - An entry is a base URL and nothing else. Canonical is a grammar, pinned by
23
+ `vectors/storage-vectors.json`: `https`, or `http` only for `localhost`, a
24
+ `127.x.y.z` address or an onion v3 host; lower-case host; no default port,
25
+ trailing slash, user info, query or fragment; at most 256 characters; a host
26
+ ending in a number must be four plain decimal octets. The grammar alone
27
+ decides on the wire: no URL parser is consulted, so every client gets the
28
+ same answer, `xn--` labels included (valid Punycode or not). New exports:
29
+ `storageNodeFromBase` (an operator's URL to `{ base, server }`),
30
+ `storageNodeServer` (the `server` of a canonical base, by grammar alone, for
31
+ a client that must not need a URL parser), `isStorageNodeBase`, `isInvitationStorage`, `requireInvitationStorage`,
32
+ `MAX_INVITATION_STORAGE_NODES` (8), `MAX_STORAGE_NODE_URL_LENGTH` (256) and
33
+ the `StorageNode` type.
34
+ - `decodePersistentInvitation` returns `storage` and `storageAt` (the
35
+ `created_at` of the copy whose list stood; a client uses it as the record's
36
+ version), and returns null for the whole envelope on a malformed list.
37
+ `requestPersistentRoomAdmission` keeps the list of the newest signed copy
38
+ that names any, `[]` included; a copy without the key says nothing, and on
39
+ equal timestamps the first heard stays. Relays and storage merge
40
+ independently.
41
+ - The strict evidence path (`persistent-invitation-evidence`) accepts the new
42
+ key and merges it the same way. Two newest copies of one second that
43
+ disagree hold with the new reason `conflicting-storage`, and
44
+ `PersistentInvitationContributors` gains an optional `storage`, present
45
+ exactly when a copy names storage, so a room with none has contributors
46
+ byte-identical to 0.13.0's and a stored proof still reads back unchanged.
47
+ Compatibility with 0.13.0 strict readers is only partial. A live query on
48
+ 0.13.0 skips a copy it does not know, so with a plain copy beside it the room
49
+ still admits (the vector `storage-strict-old-reader`), and with only a
50
+ storage copy it reads as unavailable. But evidence that 0.14.0 retained
51
+ holds every valid copy, the storage copy included, and 0.13.0's
52
+ `revalidatePersistentInvitationEvidence` throws on a carrier it cannot read,
53
+ plain copy or not. So evidence stored by 0.14.0 cannot be read by 0.13.0: a
54
+ stale tab, a cached older bundle or a rollback meets it. Keep older readers
55
+ off evidence a newer version stored, or isolate each read failure.
56
+ - `vectors/storage-vectors.json` (`npm run generate-storage`).
57
+ - `hostRoomEpoch` takes `oneTimeSender`, passed to every grant the desk
58
+ writes. Off by default; off, every grant is byte-identical to 0.13.0's.
59
+
60
+ ### Fixed
61
+
62
+ - `requestPersistentRoomAdmissionEvidence` no longer fails a room on repeats,
63
+ strangers or junk. It charged every delivered event, before de-duplicating
64
+ and before checking the author, kind and `d` tag, against the 64 retained
65
+ events and 256 KiB, and one malformed or oversized event failed the whole
66
+ query. A room on four relays whose keeper had republished 17 times failed to
67
+ load, and any relay could fail a query with one oversized event or 64 events
68
+ of another author. Now a delivery is only counted against a new bound,
69
+ `MAX_PERSISTENT_INVITATION_EVIDENCE_DELIVERIES` (512, which bounds parse
70
+ work). An event that is malformed, or out of scope (not the inviter, not
71
+ kind 1463 or 1461, not exactly one `d` tag naming the invitation), is dropped
72
+ without crypto, as is an exact repeat or another carrier under an id already
73
+ held (the held event was verified, so its signed fields are the same).
74
+ Signature checks have their own bound,
75
+ `MAX_PERSISTENT_INVITATION_EVIDENCE_VERIFICATIONS` (128: 64 retained events
76
+ plus 64 in-scope events that fail). Those are checks charged, not Schnorr
77
+ verifications: a retained retirement is verified twice and the finished query
78
+ revalidates what it kept, so a query that succeeds can do up to 128 + 64,
79
+ while a hostile relay can cause at most 128. Nothing is truncated: a 65th
80
+ unique valid authority event, 256 KiB of retained carriers, more than 512
81
+ deliveries or more than 128 signature checks still fail the query.
82
+ - An in-scope event over the evidence size limits (16,384 bytes, 64 tags, eight
83
+ strings in a tag) can never be retained, so it is checked rather than
84
+ dropped unseen: one hash and one signature check, charged to the same bound
85
+ of 128. If the authority signed it, the query fails with `invitation evidence
86
+ query refused an event signed by the authority that exceeds the evidence size
87
+ limits`, because it may be a retirement or a newer list the query cannot
88
+ hold. If it only claims the authority's key it is dropped, and a repeat costs
89
+ nothing. Before this fix such an event also failed the query, as any
90
+ oversized event did; failing only for the authority's own is the change.
91
+ - Error messages. Gone from this query: `conflicting duplicate invitation event
92
+ identity` (a second carrier under a held id is now dropped, since the held
93
+ one was verified), and the carrier errors `oversized evidence event`,
94
+ `invalid or oversized evidence event`, `invalid or oversized evidence tag`,
95
+ `invalid evidence field`, `invalid evidence record` and the like (a malformed
96
+ delivery is now dropped). New: `invitation evidence query capacity exceeded:
97
+ too many deliveries`, `...: too many signature checks` and the size-limit
98
+ refusal above. Unchanged: `invitation evidence query capacity exceeded` for a
99
+ retained-event or retained-byte overflow. The revalidators still throw the
100
+ carrier errors, and the evidence format and its limits are unchanged, so
101
+ stored evidence revalidates exactly as before.
102
+ - `encodePersistentInvitation` throws when the event it would sign exceeds
103
+ those size limits (only possible with very long relay URLs: 27,813 bytes at
104
+ eight 2,048-character relays), so the authority cannot sign what the strict
105
+ reader must refuse. `encodeInvitationRetirement` is unchanged: its body and
106
+ tags are fixed and small, so it cannot exceed them.
107
+
108
+ ## 0.13.0
109
+
110
+ ### Added
111
+
112
+ - One-time senders for rekeys (1462) and authority grants (20469):
113
+ `oneTimeSender` on `encodeRekeyEvent` and `encodeEpochGrant` seals every
114
+ copy from a fresh key named in a `from` tag, so a later copy of the
115
+ authority's key no longer opens past copies. Off by default; off, events
116
+ are byte-identical to 0.12.0. Readers (`decodeRekeyEvent`,
117
+ `decodeEpochGrant`) open either form now, through the new `sealSender`
118
+ (also exported, with `SENDER_TAG`). Turn writers on only once these readers
119
+ have shipped everywhere. See docs/seal-key.md, "One-time senders"; vectors
120
+ in `seal-vectors.json`.
121
+
8
122
  ## 0.12.0
9
123
 
10
124
  ### Fixed
package/EXTRACTION.md CHANGED
@@ -330,6 +330,28 @@ declares each added line in `ROOM_RELAY_CHANGES` as 0.4.0 text against
330
330
  0.3.0 text, applied before `CONFERENCE_CHANGES`, so the whole-file
331
331
  comparison against the pinned commit still holds.
332
332
 
333
+ ## Room storage nodes: declared additions to `persistent-invitation.ts`
334
+
335
+ 0.14.0 adds the room's group storage nodes to the group invitation body,
336
+ after `relays`: the base URLs of the nodes a room keeps its files on, so a
337
+ client can upload to the group's own nodes instead of a public server. The
338
+ rule (0 to 8 canonical node URLs on distinct hosts, defined by a grammar
339
+ rather than by what a URL parser prints, and decided by the grammar alone on
340
+ the wire) lives in a new module, `src/invitation-storage.ts`,
341
+ which moved from nowhere; `storageNodeServer` there gives the `server` of a
342
+ canonical base by grammar alone, for clients that must not need a URL parser.
343
+ With no `storage` the body, and the whole event, is
344
+ byte-identical to 0.13.0's (`vectors/storage-vectors.json`, vector
345
+ `storage-absent`, pins two events recorded from 0.13.0's own encoder). The
346
+ admission gains `storage` and `storageAt`; the strict evidence path
347
+ (`src/persistent-invitation-evidence.ts`, not a moved module) allows the key
348
+ and merges it as it merges `relays`, holding a same-second disagreement as
349
+ `conflicting-storage`; its `contributors.storage` is present only when a copy
350
+ names storage, so contributors without one match 0.13.0's byte for byte. `scripts/diff-source.mjs` declares each added line in
351
+ `STORAGE_CHANGES` as 0.14.0 text against 0.13.0 text, applied before every
352
+ other change set, so the whole-file comparison against the pinned commit
353
+ still holds.
354
+
333
355
  ## Member epoch catch-up: declared additions to `epoch.ts`
334
356
 
335
357
  Unreleased. Any current member can now hand an admitted, non-removed device
package/README.md CHANGED
@@ -13,7 +13,7 @@ vectors (see EXTRACTION.md). Framework-free: owns no storage, UI, relay
13
13
  defaults or identity keys, and no relay I/O of its own - callers inject a
14
14
  transport.
15
15
 
16
- **Status:** Version 0.12.0 on `main`. Published on npm as
16
+ **Status:** Version 0.14.0 on `main`. Published on npm as
17
17
  `@forgesworn/fold-kit`, but `main` can run ahead of the npm `latest` release, so
18
18
  compare `npm view @forgesworn/fold-kit version` with this repository's
19
19
  `package.json` before relying on an API listed here. KithMoot has cut over to
@@ -250,6 +250,26 @@ decision completes, so approval cannot extend an expired request's authority.
250
250
  `requestPersistentRoomAdmission` keeps the relays of the newest signed
251
251
  copy that names any. `isInvitationRelays`, `requireInvitationRelays` and
252
252
  `MAX_INVITATION_RELAYS` are the rule
253
+ - Room storage nodes: pass `storage` (zero to eight canonical node base URLs
254
+ on distinct hosts, e.g. `https://grove.example.net` or
255
+ `http://127.0.0.1:8080`) and the encrypted body carries them last, after
256
+ `relays`, so a room that keeps its files on its own computers says where.
257
+ An entry is only a base URL; the grammar (no trailing slash, no default
258
+ port, `http` only on this machine or an onion address) is in
259
+ `src/invitation-storage.ts` and pinned by vectors; no URL parser is
260
+ consulted, so every client gets the same answer. `[]` says the authority withdrew every node; a body with no
261
+ `storage` says nothing about nodes, and is byte-identical to 0.13.0's. The
262
+ decoder returns `storage` and `storageAt` (the `created_at` of the copy
263
+ whose list stood) and refuses the whole envelope on a malformed list.
264
+ `requestPersistentRoomAdmission` and the strict evidence path keep the list
265
+ of the newest signed copy that names any, independently of the relays; the
266
+ strict path holds a same-second disagreement as `conflicting-storage`.
267
+ `storageNodeFromBase(text)` canonicalises an operator's URL and gives its
268
+ `server`; `storageNodeServer(base)` gives the `server` of a canonical base by
269
+ grammar alone (`undefined` when not canonical); `isStorageNodeBase`, `isInvitationStorage`,
270
+ `requireInvitationStorage`, `MAX_INVITATION_STORAGE_NODES` and
271
+ `MAX_STORAGE_NODE_URL_LENGTH` are the rule. Vectors:
272
+ `vectors/storage-vectors.json`
253
273
  - Self-destructing rooms (`docs/room-destruct.md`): pass `destruct: true`
254
274
  and the encrypted body carries it after `ends` (no end needed: the room
255
275
  then self-destructs when its authority closes it). The decoder returns
@@ -310,6 +330,11 @@ decision completes, so approval cannot extend an expired request's authority.
310
330
  `sealSks` on the readers; `credentialFor` on both desks;
311
331
  `credentialSeal`, `sealTarget`, `sealTo`, `openSealed`, `newerCredential`,
312
332
  `sealCredential`. Vectors: `vectors/seal-vectors.json`
333
+ - One-time senders (`docs/seal-key.md`, "One-time senders"):
334
+ `oneTimeSender` on `encodeRekeyEvent` and `encodeEpochGrant` seals every
335
+ copy from a fresh key named in a `from` tag, so a later copy of the
336
+ authority's key opens no past copy; readers use `sealSender`. Off by
337
+ default until every client's readers have shipped.
313
338
  - Scheduled rekeys and the history window (`docs/scheduled-rekey.md`):
314
339
  `encodeRekeyEvent({ scheduled: true })` marks a turn of the key that
315
340
  removes nobody, read back as `RekeyNotice.scheduled` and
@@ -386,6 +411,7 @@ npm run typecheck # type-check src/ and test/ (matching KithMoot, vectors/ is
386
411
  npm run vectors # run only the vector-verification suites
387
412
  npm run diff-source # compare moved modules against the pinned source commit (needs FOLD_KIT_SOURCE_DIR)
388
413
  npm run generate-fold # regenerate vectors/fold-vectors.json (needs a prior build)
414
+ npm run generate-storage # regenerate vectors/storage-vectors.json (needs a prior build)
389
415
  npm run bundle-check # esbuild browser bundle check (needs a prior build)
390
416
  npm run check # typecheck + test + diff-source
391
417
  ```
package/dist/epoch.d.ts CHANGED
@@ -134,6 +134,13 @@ export interface EncodeRekeyOptions {
134
134
  /** A conference room's end, in unix seconds: the event carries it as a
135
135
  * NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
136
136
  expiresAt?: number;
137
+ /** Seal the copies from a one-time key, named in a `from` tag and
138
+ * discarded once they are sealed, instead of from the authority's key.
139
+ * Readers from fold-kit 0.13.0 open either; an older reader opens no copy
140
+ * from a one-time key, so turn this on only once readers have shipped
141
+ * (docs/seal-key.md, "One-time senders"). Omitted, the event is
142
+ * byte-identical to before. */
143
+ oneTimeSender?: boolean;
137
144
  }
138
145
  /** A rekey recipient: a device, and the newest credential the sender holds
139
146
  * for it. */
@@ -330,6 +337,13 @@ export interface EncodeEpochGrantOptions {
330
337
  /** A conference room's end, in unix seconds: the event carries it as a
331
338
  * NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
332
339
  expiresAt?: number;
340
+ /** Seal the copies from a one-time key, named in a `from` tag and
341
+ * discarded once they are sealed, instead of from the authority's key.
342
+ * Readers from fold-kit 0.13.0 open either; an older reader opens no copy
343
+ * from a one-time key, so turn this on only once readers have shipped
344
+ * (docs/seal-key.md, "One-time senders"). Omitted, the event is
345
+ * byte-identical to before. */
346
+ oneTimeSender?: boolean;
333
347
  }
334
348
  /** Answer one request: the current epoch sealed to the asking device, or a
335
349
  * refusal it alone can read. */
@@ -427,6 +441,10 @@ export interface HostRoomEpochOptions {
427
441
  onRefused?: (request: EpochRequest, why: EpochRefusal) => void;
428
442
  /** A conference room's end: every grant carries it as an expiration. */
429
443
  expiresAt?: number;
444
+ /** Seal every grant from a one-time key (`EncodeEpochGrantOptions.oneTimeSender`).
445
+ * Off by default; turn it on only once every client reads one-time
446
+ * senders (fold-kit 0.13.0 and the clients built on it). */
447
+ oneTimeSender?: boolean;
430
448
  /** Seconds before the same participant is sent a `removed` or `closed`
431
449
  * refusal again. Default 3600 (`REFUSE_AGAIN_AFTER_SECONDS`). A request
432
450
  * inside it is not answered, so the asker times out instead. */
package/dist/epoch.js CHANGED
@@ -5,14 +5,14 @@ import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils';
5
5
  import { schnorr } from '@noble/curves/secp256k1.js';
6
6
  import { base64urlnopad } from '@scure/base';
7
7
  import { nip44 } from 'nostr-tools';
8
- import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
8
+ import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
9
9
  import { hexEquals, normaliseHex } from './hex.js';
10
10
  import { KINDS } from './kinds.js';
11
11
  import { deriveRoom } from './room.js';
12
12
  import { verifyDeviceCredential } from './credential.js';
13
13
  import { evaluateAccess } from './access.js';
14
14
  import { verifyEventUncached } from './verify.js';
15
- import { newerCredential, openSealed, sealTo } from './seal.js';
15
+ import { SENDER_TAG, newerCredential, openSealed, sealSender, sealTo } from './seal.js';
16
16
  import { withExpiration } from './expiration.js';
17
17
  import { epochCommitment } from './epoch-commit.js';
18
18
  import { DEFAULT_REPLY_DELAY_MS, REFUSE_AGAIN_AFTER_SECONDS, RefusalLimiter, defaultReplySchedule, replyDelay } from './quiet-reply.js';
@@ -135,6 +135,14 @@ function memberListOf(members, removed) {
135
135
  out.delete(normaliseHex(p));
136
136
  return [...out].sort();
137
137
  }
138
+ /** The key a rekey or grant seals from: the authority's own, or, with
139
+ * `oneTimeSender`, a fresh one named in a `from` tag and zeroed once used. */
140
+ function oneTimeSenderOf(opts) {
141
+ if (!opts.oneTimeSender)
142
+ return { sk: opts.authoritySk, tags: [], discard: () => { } };
143
+ const sk = generateSecretKey();
144
+ return { sk, tags: [[SENDER_TAG, getPublicKey(sk)]], discard: () => sk.fill(0) };
145
+ }
138
146
  /** Announce the next epoch. See the module comment for what this is. */
139
147
  export function encodeRekeyEvent(opts) {
140
148
  require32(opts.authoritySk, 'authority secret key');
@@ -151,10 +159,16 @@ export function encodeRekeyEvent(opts) {
151
159
  const keys = {};
152
160
  const sealed = { v: 1, secret: base64urlnopad.encode(opts.next.secret) };
153
161
  const plaintext = JSON.stringify(sealed);
154
- for (const raw of opts.closed ? [] : opts.recipients) {
155
- const recipient = typeof raw === 'string' ? { device: raw } : raw;
156
- const device = requireHex32(recipient.device, 'recipient device');
157
- keys[device] = sealTo(plaintext, opts.authoritySk, device, recipient.credential);
162
+ const sender = oneTimeSenderOf(opts);
163
+ try {
164
+ for (const raw of opts.closed ? [] : opts.recipients) {
165
+ const recipient = typeof raw === 'string' ? { device: raw } : raw;
166
+ const device = requireHex32(recipient.device, 'recipient device');
167
+ keys[device] = sealTo(plaintext, sender.sk, device, recipient.credential);
168
+ }
169
+ }
170
+ finally {
171
+ sender.discard();
158
172
  }
159
173
  const body = {
160
174
  v: 1,
@@ -174,6 +188,7 @@ export function encodeRekeyEvent(opts) {
174
188
  tags: withExpiration([
175
189
  ['d', roomId],
176
190
  ['epoch', String(epoch)],
191
+ ...sender.tags,
177
192
  ], opts.expiresAt),
178
193
  content: nip44.v2.encrypt(JSON.stringify(body), opts.current.key),
179
194
  }, opts.authoritySk);
@@ -239,13 +254,14 @@ export function decodeRekeyEvent(event, opts) {
239
254
  notice.members = members;
240
255
  const device = getPublicKey(opts.deviceSk);
241
256
  const mine = Object.entries(body.keys).find(([to]) => hexEquals(to, device))?.[1];
242
- if (typeof mine === 'string') {
257
+ const sender = sealSender(event);
258
+ if (typeof mine === 'string' && sender !== null) {
243
259
  // A copy none of this device's keys opens is no copy: sealed to a
244
260
  // seal key it has since erased, or one it never had. The rest of the
245
261
  // rekey still says who was removed, so it is read without the secret,
246
262
  // and the device asks for the epoch.
247
263
  try {
248
- const sealed = JSON.parse(openSealed(mine, event.pubkey, opts.deviceSk, opts.sealSks));
264
+ const sealed = JSON.parse(openSealed(mine, sender, opts.deviceSk, opts.sealSks));
249
265
  if (sealed.v === 1 && typeof sealed.secret === 'string') {
250
266
  const secret = base64urlnopad.decode(sealed.secret);
251
267
  if (secret.length === 32)
@@ -506,14 +522,23 @@ export function encodeEpochGrant(opts) {
506
522
  if (opts.passed !== undefined && opts.passed.length > 0)
507
523
  body.passed = passedListOf(opts.passed, body.epoch);
508
524
  }
525
+ const sender = oneTimeSenderOf(opts);
526
+ let content;
527
+ try {
528
+ content = sealTo(JSON.stringify(body), sender.sk, device, opts.credential);
529
+ }
530
+ finally {
531
+ sender.discard();
532
+ }
509
533
  return finalizeEvent({
510
534
  kind: KINDS.EPOCH_GRANT,
511
535
  created_at: opts.now,
512
536
  tags: withExpiration([
513
537
  ['d', roomId],
514
538
  ['p', device],
539
+ ...sender.tags,
515
540
  ], opts.expiresAt),
516
- content: sealTo(JSON.stringify(body), opts.authoritySk, device, opts.credential),
541
+ content,
517
542
  }, opts.authoritySk);
518
543
  }
519
544
  /** Read the authority's answer to this device's own request. Null for
@@ -535,7 +560,10 @@ export function decodeEpochGrant(event, opts) {
535
560
  const addressed = event.tags.find((t) => t[0] === 'p')?.[1];
536
561
  if (addressed === undefined || !hexEquals(addressed, device))
537
562
  return null;
538
- const body = JSON.parse(openSealed(event.content, event.pubkey, opts.deviceSk, opts.sealSks));
563
+ const sender = sealSender(event);
564
+ if (sender === null)
565
+ return null;
566
+ const body = JSON.parse(openSealed(event.content, sender, opts.deviceSk, opts.sealSks));
539
567
  if (body.v !== 1 || typeof body.request !== 'string' || !hexEquals(body.request, opts.request))
540
568
  return null;
541
569
  if (body.refused === 'removed' || body.refused === 'closed' || body.refused === 'unknown')
@@ -706,6 +734,7 @@ export function hostRoomEpoch(opts) {
706
734
  refused,
707
735
  credential: sealCredential(request, read.credential, opts.credentialFor, roomId, now()),
708
736
  expiresAt: opts.expiresAt,
737
+ ...(opts.oneTimeSender ? { oneTimeSender: true } : {}),
709
738
  });
710
739
  }
711
740
  else {
@@ -722,6 +751,7 @@ export function hostRoomEpoch(opts) {
722
751
  ...(opts.past ? { passed: passedFor(opts.past, epoch.epoch, now()) } : {}),
723
752
  credential: sealCredential(request, read.credential, opts.credentialFor, roomId, now()),
724
753
  expiresAt: opts.expiresAt,
754
+ ...(opts.oneTimeSender ? { oneTimeSender: true } : {}),
725
755
  });
726
756
  }
727
757
  }
@@ -0,0 +1,10 @@
1
+ import type { Event } from 'nostr-tools/pure';
2
+ /** Per-event ceiling of the strict evidence carrier, in canonical UTF-8 bytes. */
3
+ export declare const MAX_PERSISTENT_INVITATION_EVENT_BYTES = 16384;
4
+ /** Tag arrays a strict evidence carrier may hold, and strings in each. */
5
+ export declare const MAX_EVIDENCE_CARRIER_TAGS = 64;
6
+ export declare const MAX_EVIDENCE_CARRIER_TAG_STRINGS = 8;
7
+ /** Returns `event` if the strict evidence reader could retain it, else throws.
8
+ * A writer calls this before it signs and publishes, so the authority never
9
+ * signs what a strict reader must refuse. */
10
+ export declare function requireEvidenceCarrierLimits(event: Event, what: string): Event;
@@ -0,0 +1,19 @@
1
+ /** Per-event ceiling of the strict evidence carrier, in canonical UTF-8 bytes. */
2
+ export const MAX_PERSISTENT_INVITATION_EVENT_BYTES = 16_384;
3
+ /** Tag arrays a strict evidence carrier may hold, and strings in each. */
4
+ export const MAX_EVIDENCE_CARRIER_TAGS = 64;
5
+ export const MAX_EVIDENCE_CARRIER_TAG_STRINGS = 8;
6
+ /** Returns `event` if the strict evidence reader could retain it, else throws.
7
+ * A writer calls this before it signs and publishes, so the authority never
8
+ * signs what a strict reader must refuse. */
9
+ export function requireEvidenceCarrierLimits(event, what) {
10
+ const bytes = new TextEncoder().encode(JSON.stringify({ id: event.id, pubkey: event.pubkey, created_at: event.created_at,
11
+ kind: event.kind, tags: event.tags, content: event.content, sig: event.sig })).length;
12
+ if (bytes > MAX_PERSISTENT_INVITATION_EVENT_BYTES) {
13
+ throw new Error(`the ${what} is ${bytes} bytes, over the ${MAX_PERSISTENT_INVITATION_EVENT_BYTES} a strict reader accepts; use fewer or shorter relays or storage nodes`);
14
+ }
15
+ if (event.tags.length > MAX_EVIDENCE_CARRIER_TAGS || event.tags.some(tag => tag.length > MAX_EVIDENCE_CARRIER_TAG_STRINGS)) {
16
+ throw new Error(`the ${what} has more tags, or more strings in a tag, than a strict reader accepts`);
17
+ }
18
+ return event;
19
+ }
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@ export type { DeviceCredential, AccessTier, AgentRule, RoomPolicy, KindredProof
7
7
  export type { RelayTransport } from './transport.js';
8
8
  export { createDeviceCredential, verifyDeviceCredential, PERSON_CREDENTIAL_MAX_SECONDS, RestampedCredentialExpiryError, } from './credential.js';
9
9
  export type { CreateCredentialOptions, VerifyResult } from './credential.js';
10
- export { SEAL_TAG, isSealPubkey, generateSealKey, credentialSeal, sealTarget, sealTo, openSealed, newerCredential, } from './seal.js';
10
+ export { SEAL_TAG, SENDER_TAG, sealSender, isSealPubkey, generateSealKey, credentialSeal, sealTarget, sealTo, openSealed, newerCredential, } from './seal.js';
11
11
  export type { SealKey } from './seal.js';
12
12
  export { deriveScoped, SCOPED_LABEL_PATTERN } from './scoped.js';
13
13
  export type { ScopedKeys } from './scoped.js';
@@ -26,6 +26,8 @@ export { encodePersistentInvitation, decodePersistentInvitation, requestPersiste
26
26
  export type { PersistentRoomAdmission } from './persistent-invitation.js';
27
27
  export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } from './expiration.js';
28
28
  export { isInvitationRelays, requireInvitationRelays, MAX_INVITATION_RELAYS } from './invitation-relays.js';
29
+ export { isStorageNodeBase, storageNodeFromBase, storageNodeServer, isInvitationStorage, requireInvitationStorage, MAX_INVITATION_STORAGE_NODES, MAX_STORAGE_NODE_URL_LENGTH, } from './invitation-storage.js';
30
+ export type { StorageNode } from './invitation-storage.js';
29
31
  export { MAX_ROOM_LINK_FRAGMENT_LENGTH, parseRoomLink, encodeRoomLink, } from './link.js';
30
32
  export type { RoomLink } from './link.js';
31
33
  export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, readMemberList, REPORT_UNKNOWN_EVERY_SECONDS, decodeRekeyEvent, HISTORY_WINDOW_SECONDS, MAX_HISTORY_EPOCHS, epochsInWindow, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, sealCredential, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ export { verifyEventUncached, boundedEventVerifier } from './verify.js';
5
5
  export { localIdentity } from './identity.js';
6
6
  export { KINDS } from './kinds.js';
7
7
  export { createDeviceCredential, verifyDeviceCredential, PERSON_CREDENTIAL_MAX_SECONDS, RestampedCredentialExpiryError, } from './credential.js';
8
- export { SEAL_TAG, isSealPubkey, generateSealKey, credentialSeal, sealTarget, sealTo, openSealed, newerCredential, } from './seal.js';
8
+ export { SEAL_TAG, SENDER_TAG, sealSender, isSealPubkey, generateSealKey, credentialSeal, sealTarget, sealTo, openSealed, newerCredential, } from './seal.js';
9
9
  export { deriveScoped, SCOPED_LABEL_PATTERN } from './scoped.js';
10
10
  export { createSubKeyCertificate, verifySubKeyCertificate, SUB_KEY_CERTIFICATE_SCOPE, } from './sub-cert.js';
11
11
  export { generateRoomSecret, deriveRoom, encodeJoinUrl, decodeJoinUrl, parseRoomPolicy, ROOM_LABELS, } from './room.js';
@@ -17,6 +17,7 @@ export { encodeInvitationDecline, decodeInvitationDecline, InvitationDeclinedErr
17
17
  export { encodePersistentInvitation, decodePersistentInvitation, requestPersistentRoomAdmission, PERSISTENT_INVITATION_LABELS, } from './persistent-invitation.js';
18
18
  export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } from './expiration.js';
19
19
  export { isInvitationRelays, requireInvitationRelays, MAX_INVITATION_RELAYS } from './invitation-relays.js';
20
+ export { isStorageNodeBase, storageNodeFromBase, storageNodeServer, isInvitationStorage, requireInvitationStorage, MAX_INVITATION_STORAGE_NODES, MAX_STORAGE_NODE_URL_LENGTH, } from './invitation-storage.js';
20
21
  export { MAX_ROOM_LINK_FRAGMENT_LENGTH, parseRoomLink, encodeRoomLink, } from './link.js';
21
22
  export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, readMemberList, REPORT_UNKNOWN_EVERY_SECONDS, decodeRekeyEvent, HISTORY_WINDOW_SECONDS, MAX_HISTORY_EPOCHS, epochsInWindow, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, sealCredential, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
22
23
  export { DEFAULT_REPLY_DELAY_MS, REFUSE_AGAIN_AFTER_SECONDS, MAX_REFUSALS_PER_MINUTE, REFUSAL_LIMITER_CAPACITY, RefusalLimiter, replyDelay, } from './quiet-reply.js';
@@ -0,0 +1,65 @@
1
+ /**
2
+ * A room's own storage nodes, carried in its signed group invitation (kind 1463).
3
+ *
4
+ * A room that keeps its files on its own computers lists their base URLs
5
+ * here, so a client can upload to the group's own nodes instead of a public
6
+ * server. The list is signed by the room's authority and rides in the
7
+ * encrypted body, after `relays`; a body without it says nothing about
8
+ * storage.
9
+ *
10
+ * An entry is one string, a node's canonical base URL, and nothing else: the
11
+ * `server` a token names is its host, and scopes, ceilings and expiry belong
12
+ * to the grant. Canonical is defined by this grammar, not by what a URL parser
13
+ * prints, and the wire answer is the grammar's alone: no URL parser is
14
+ * consulted when a list is read or written, so a port written in another
15
+ * language gets the same answer from the same bytes. (`new URL` is used only
16
+ * to read an operator's text in `storageNodeFromBase`, whose output must
17
+ * itself pass the grammar.) A client that cannot parse a listed host when it
18
+ * makes a request skips that node.
19
+ *
20
+ * entry = scheme "://" host [ ":" port ] *( "/" segment )
21
+ * scheme = "https" / "http"
22
+ * host = label *( "." label ) ; 1 to 253 characters
23
+ * label = 1*63( a-z / 0-9 / "-" ) ; not starting or ending with "-"
24
+ * port = "1"-"9" *4DIGIT ; 1 to 65535, no leading zero
25
+ * segment = 1*( A-Z / a-z / 0-9 / "." / "_" / "~" / "-" ) ; not "." or ".."
26
+ *
27
+ * and: at most 256 characters; `http` only for `localhost`, a loopback
28
+ * address `127.a.b.c` or an onion v3 host; no default port; a host whose last
29
+ * label is all digits or `0x` hex must be exactly four decimal octets with no
30
+ * leading zero; no trailing slash; no user info, query or fragment.
31
+ *
32
+ * The list is strict: 0 to `MAX_INVITATION_STORAGE_NODES` entries, no two on
33
+ * one host (grants are per host), order preserved and significant. `[]` means
34
+ * the authority withdrew every node. Anything else refuses the whole envelope.
35
+ */
36
+ /** At most this many group storage nodes in a room's invitation. */
37
+ export declare const MAX_INVITATION_STORAGE_NODES = 8;
38
+ /** The longest canonical node base URL. */
39
+ export declare const MAX_STORAGE_NODE_URL_LENGTH = 256;
40
+ /** A canonical node base URL and the `server` tag it implies (its host). */
41
+ export interface StorageNode {
42
+ base: string;
43
+ server: string;
44
+ }
45
+ /** True when `value` is a canonical node base URL. Never throws. */
46
+ export declare function isStorageNodeBase(value: unknown): value is string;
47
+ /**
48
+ * The `server` a token names for a node: the host of a canonical base,
49
+ * exactly as written, or `undefined` when `base` is not canonical. Decided by
50
+ * the grammar alone, with no URL parser, so a client derives the same server
51
+ * from a listed base on every runtime. Never throws.
52
+ */
53
+ export declare function storageNodeServer(base: string): string | undefined;
54
+ /** True for a list the invitation may carry (0 to 8 canonical entries, one per host), including []. Never throws. */
55
+ export declare function isInvitationStorage(value: unknown): value is string[];
56
+ /** Throws unless `nodes` is a list the invitation may carry; returns a copy. */
57
+ export declare function requireInvitationStorage(nodes: readonly string[]): string[];
58
+ /**
59
+ * The canonical form of an operator's URL, and its server. Lower-cases the
60
+ * scheme and host, drops a default port and trailing slashes, and throws a
61
+ * message fit to show an operator on anything the grammar refuses. It does not
62
+ * refuse onion hosts: a keeper that cannot reach one says so itself. Text that
63
+ * is already canonical is returned as it stands, without a URL parser.
64
+ */
65
+ export declare function storageNodeFromBase(text: string): StorageNode;