@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 CHANGED
@@ -5,6 +5,106 @@ 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
+
8
108
  ## 0.13.0
9
109
 
10
110
  ### Added
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.13.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
@@ -391,6 +411,7 @@ npm run typecheck # type-check src/ and test/ (matching KithMoot, vectors/ is
391
411
  npm run vectors # run only the vector-verification suites
392
412
  npm run diff-source # compare moved modules against the pinned source commit (needs FOLD_KIT_SOURCE_DIR)
393
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)
394
415
  npm run bundle-check # esbuild browser bundle check (needs a prior build)
395
416
  npm run check # typecheck + test + diff-source
396
417
  ```
package/dist/epoch.d.ts CHANGED
@@ -441,6 +441,10 @@ export interface HostRoomEpochOptions {
441
441
  onRefused?: (request: EpochRequest, why: EpochRefusal) => void;
442
442
  /** A conference room's end: every grant carries it as an expiration. */
443
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;
444
448
  /** Seconds before the same participant is sent a `removed` or `closed`
445
449
  * refusal again. Default 3600 (`REFUSE_AGAIN_AFTER_SECONDS`). A request
446
450
  * inside it is not answered, so the asker times out instead. */
package/dist/epoch.js CHANGED
@@ -734,6 +734,7 @@ export function hostRoomEpoch(opts) {
734
734
  refused,
735
735
  credential: sealCredential(request, read.credential, opts.credentialFor, roomId, now()),
736
736
  expiresAt: opts.expiresAt,
737
+ ...(opts.oneTimeSender ? { oneTimeSender: true } : {}),
737
738
  });
738
739
  }
739
740
  else {
@@ -750,6 +751,7 @@ export function hostRoomEpoch(opts) {
750
751
  ...(opts.past ? { passed: passedFor(opts.past, epoch.epoch, now()) } : {}),
751
752
  credential: sealCredential(request, read.credential, opts.credentialFor, roomId, now()),
752
753
  expiresAt: opts.expiresAt,
754
+ ...(opts.oneTimeSender ? { oneTimeSender: true } : {}),
753
755
  });
754
756
  }
755
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
@@ -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
@@ -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;
@@ -0,0 +1,217 @@
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 const MAX_INVITATION_STORAGE_NODES = 8;
38
+ /** The longest canonical node base URL. */
39
+ export const MAX_STORAGE_NODE_URL_LENGTH = 256;
40
+ const MAX_HOST_LENGTH = 253;
41
+ const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
42
+ const PORT = /^[1-9][0-9]{0,4}$/;
43
+ const SEGMENT = /^[A-Za-z0-9._~-]+$/;
44
+ const OCTET = /^(?:0|[1-9][0-9]{0,2})$/;
45
+ const HEX_LABEL = /^0x[0-9a-f]*$/;
46
+ const DIGITS = /^[0-9]+$/;
47
+ const LOOPBACK = /^127\.[0-9]+\.[0-9]+\.[0-9]+$/;
48
+ const ONION_V3 = /^[a-z2-7]{56}\.onion$/;
49
+ /** Entry as written: scheme, host characters, optional port, path segments. */
50
+ const ENTRY = /^(https?):\/\/([a-z0-9.-]+)(?::([0-9]*))?((?:\/[^/]*)*)$/;
51
+ /** A host the grammar allows, with the "ends in a number" rule applied. */
52
+ function isCanonicalHost(host) {
53
+ if (host.length < 1 || host.length > MAX_HOST_LENGTH)
54
+ return false;
55
+ const labels = host.split('.');
56
+ if (!labels.every(label => LABEL.test(label)))
57
+ return false;
58
+ const last = labels[labels.length - 1];
59
+ if (DIGITS.test(last) || HEX_LABEL.test(last)) {
60
+ if (labels.length !== 4)
61
+ return false;
62
+ return labels.every(label => OCTET.test(label) && Number(label) <= 255);
63
+ }
64
+ return true;
65
+ }
66
+ /** True for a host that may be reached over plain http. */
67
+ function isPlainHttpHost(host) {
68
+ return host === 'localhost' || LOOPBACK.test(host) || ONION_V3.test(host);
69
+ }
70
+ function isCanonicalPort(port) {
71
+ return PORT.test(port) && Number(port) <= 65_535;
72
+ }
73
+ function isCanonicalPath(path) {
74
+ if (path === '')
75
+ return true;
76
+ // Every segment is non-empty (so no trailing slash, no "//"), and neither "." nor "..".
77
+ return path.slice(1).split('/').every(segment => SEGMENT.test(segment) && segment !== '.' && segment !== '..');
78
+ }
79
+ /** The host of a canonical entry. Only for a value `isStorageNodeBase` accepted. */
80
+ function hostOf(entry) {
81
+ return ENTRY.exec(entry)[2];
82
+ }
83
+ /** True when `value` is a canonical node base URL. Never throws. */
84
+ export function isStorageNodeBase(value) {
85
+ if (typeof value !== 'string' || value.length > MAX_STORAGE_NODE_URL_LENGTH)
86
+ return false;
87
+ const m = ENTRY.exec(value);
88
+ if (!m)
89
+ return false;
90
+ const [, scheme, host, port, path] = m;
91
+ if (!isCanonicalHost(host) || !isCanonicalPath(path))
92
+ return false;
93
+ if (scheme === 'http' && !isPlainHttpHost(host))
94
+ return false;
95
+ if (port !== undefined) {
96
+ if (!isCanonicalPort(port))
97
+ return false;
98
+ if ((scheme === 'https' && port === '443') || (scheme === 'http' && port === '80'))
99
+ return false;
100
+ }
101
+ // The grammar alone decides. No URL parser is consulted, so every client
102
+ // gets the same answer from the same bytes (a parser's answer differs between
103
+ // runtimes, for `xn--` labels among others). A client that cannot parse a
104
+ // listed host when it makes a request skips that node.
105
+ return true;
106
+ }
107
+ /**
108
+ * The `server` a token names for a node: the host of a canonical base,
109
+ * exactly as written, or `undefined` when `base` is not canonical. Decided by
110
+ * the grammar alone, with no URL parser, so a client derives the same server
111
+ * from a listed base on every runtime. Never throws.
112
+ */
113
+ export function storageNodeServer(base) {
114
+ return isStorageNodeBase(base) ? hostOf(base) : undefined;
115
+ }
116
+ /** True for a list the invitation may carry (0 to 8 canonical entries, one per host), including []. Never throws. */
117
+ export function isInvitationStorage(value) {
118
+ if (!Array.isArray(value) || value.length > MAX_INVITATION_STORAGE_NODES)
119
+ return false;
120
+ // Hostile input may be sparse or hold holes: read by index, never by iterator.
121
+ const hosts = new Set();
122
+ for (let i = 0; i < value.length; i++) {
123
+ const entry = value[i];
124
+ if (!isStorageNodeBase(entry))
125
+ return false;
126
+ hosts.add(hostOf(entry));
127
+ }
128
+ return hosts.size === value.length;
129
+ }
130
+ /** Throws unless `nodes` is a list the invitation may carry; returns a copy. */
131
+ export function requireInvitationStorage(nodes) {
132
+ if (!Array.isArray(nodes))
133
+ throw new Error('storage nodes must be a list');
134
+ if (nodes.length > MAX_INVITATION_STORAGE_NODES)
135
+ throw new Error(`a room can list at most ${MAX_INVITATION_STORAGE_NODES} storage nodes`);
136
+ const hosts = new Set();
137
+ for (let i = 0; i < nodes.length; i++) {
138
+ const entry = nodes[i];
139
+ if (!isStorageNodeBase(entry))
140
+ throw new Error(`storage nodes must be canonical node URLs (https, or http only on this machine): ${String(entry)}`);
141
+ const host = hostOf(entry);
142
+ if (hosts.has(host))
143
+ throw new Error(`storage nodes must not share a host name: ${host}`);
144
+ hosts.add(host);
145
+ }
146
+ return [...nodes];
147
+ }
148
+ function shorten(text) {
149
+ return text.length > 60 ? `${text.slice(0, 57)}...` : text;
150
+ }
151
+ /**
152
+ * The canonical form of an operator's URL, and its server. Lower-cases the
153
+ * scheme and host, drops a default port and trailing slashes, and throws a
154
+ * message fit to show an operator on anything the grammar refuses. It does not
155
+ * refuse onion hosts: a keeper that cannot reach one says so itself. Text that
156
+ * is already canonical is returned as it stands, without a URL parser.
157
+ */
158
+ export function storageNodeFromBase(text) {
159
+ const shown = shorten(String(text));
160
+ const typed = String(text).trim();
161
+ const already = storageNodeServer(typed);
162
+ if (already !== undefined)
163
+ return { base: typed, server: already };
164
+ let url;
165
+ try {
166
+ url = new URL(typed);
167
+ }
168
+ catch {
169
+ throw new Error(`group node ${shown}: not a URL`);
170
+ }
171
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
172
+ throw new Error(`group node ${shown}: only http and https nodes are supported in this version`);
173
+ }
174
+ // Read the pieces from the text as written, not from what the parser made of
175
+ // them: it would quietly mend `127.1`, `0x7f.0.0.1`, a dot segment or a
176
+ // percent-escape, and every one of those is refused here.
177
+ const m = /^(https?):\/\/([^/?#]*)([^?#]*)([?#].*)?$/i.exec(typed);
178
+ if (!m)
179
+ throw new Error(`group node ${shown}: not a URL`);
180
+ const scheme = m[1].toLowerCase();
181
+ const authority = m[2];
182
+ if (authority.includes('@'))
183
+ throw new Error('group node: a URL with a user name or password is refused');
184
+ if (m[4] !== undefined)
185
+ throw new Error('group node: a URL with a query or fragment is refused');
186
+ const colon = authority.lastIndexOf(':');
187
+ const bracketed = authority.startsWith('[');
188
+ const rawHost = colon === -1 || bracketed ? authority : authority.slice(0, colon);
189
+ const portText = colon === -1 || bracketed ? undefined : authority.slice(colon + 1);
190
+ // Lower-case ASCII only: a non-ASCII letter must not fold into a legal host.
191
+ const hostText = rawHost.replace(/[A-Z]/g, c => c.toLowerCase());
192
+ if (!isCanonicalHost(hostText)) {
193
+ throw new Error(`group node ${shown}: the host name must be lower-case letters, digits, hyphens and dots (no IPv6 literal, no trailing dot)`);
194
+ }
195
+ if (scheme === 'http' && !isPlainHttpHost(hostText)) {
196
+ throw new Error(`group node ${hostText}: plain http is only for a node on this machine (localhost or 127.x); use https so grants and revocations cannot be read or answered by somebody on the way`);
197
+ }
198
+ // A URL parser reads an empty port as the default and `08080` as 8080, so
199
+ // an operator's URL is mended the same way; only a port of 0 is newly refused.
200
+ const port = portText === undefined || portText === '' ? undefined : portText.replace(/^0+(?=[0-9])/, '');
201
+ if (port !== undefined && !isCanonicalPort(port)) {
202
+ throw new Error(`group node ${shown}: the port must be a number from 1 to 65535`);
203
+ }
204
+ const path = m[3].replace(/\/+$/, '');
205
+ if (!isCanonicalPath(path)) {
206
+ throw new Error(`group node ${shown}: the path may hold only letters, digits and . _ ~ - between slashes`);
207
+ }
208
+ const isDefaultPort = port !== undefined && ((scheme === 'https' && port === '443') || (scheme === 'http' && port === '80'));
209
+ const base = `${scheme}://${hostText}${port === undefined || isDefaultPort ? '' : `:${port}`}${path}`;
210
+ if (base.length > MAX_STORAGE_NODE_URL_LENGTH) {
211
+ throw new Error(`group node ${shown}: the URL is longer than ${MAX_STORAGE_NODE_URL_LENGTH} characters`);
212
+ }
213
+ const server = storageNodeServer(base);
214
+ if (server === undefined)
215
+ throw new Error(`group node ${shown}: not a canonical node URL`);
216
+ return { base, server };
217
+ }
@@ -1,9 +1,29 @@
1
1
  import { type RoomInvitation } from './invitation.js';
2
2
  import { type PersistentRoomAdmission } from './persistent-invitation.js';
3
+ import { MAX_PERSISTENT_INVITATION_EVENT_BYTES } from './evidence-carrier.js';
3
4
  import type { RelayTransport } from './transport.js';
4
5
  export declare const MAX_PERSISTENT_INVITATION_EVIDENCE_EVENTS = 64;
5
- export declare const MAX_PERSISTENT_INVITATION_EVENT_BYTES = 16384;
6
+ export { MAX_PERSISTENT_INVITATION_EVENT_BYTES };
6
7
  export declare const MAX_PERSISTENT_INVITATION_EVIDENCE_BYTES = 262144;
8
+ /** Raw deliveries one query reads, counted before anything is parsed: every
9
+ * relay's copy of every event, repeats, other authors and junk alike. It bounds
10
+ * parse work only. It is eight times the retained-event limit, so 64 events
11
+ * from each of eight relays still fit. Past it the query fails closed, because
12
+ * an event that could matter may be among those it did not read. */
13
+ export declare const MAX_PERSISTENT_INVITATION_EVIDENCE_DELIVERIES = 512;
14
+ /** Signature checks one query is charged. Only an event that is in scope and
15
+ * not already held or refused costs one, so a repeat costs none. It is twice
16
+ * the retained-event limit: room for 64 retained events and 64 more in-scope
17
+ * events that fail (forged, over the evidence size limits, or signed but not a
18
+ * supported body). Beyond it the query fails closed rather than skip an
19
+ * unchecked event.
20
+ *
21
+ * It bounds checks, not Schnorr verifications. A retained retirement is
22
+ * verified twice (its scope check, then the retirement decoder), and the
23
+ * finished query revalidates every retained event, so the worst case is
24
+ * 128 + 64 verifications on a query that succeeds. A forgery fails at its
25
+ * first verification, so a hostile relay can cause at most 128. */
26
+ export declare const MAX_PERSISTENT_INVITATION_EVIDENCE_VERIFICATIONS = 128;
7
27
  /** Protected, local observation metadata. EOSE is NOT evidence of relay
8
28
  * completeness, current membership or global absence of retirement.
9
29
  * `events` are canonical allowlisted Event carriers, not original wire JSON. */
@@ -20,6 +40,9 @@ export interface PersistentInvitationContributors {
20
40
  readonly ends: readonly string[];
21
41
  readonly destruct: readonly string[];
22
42
  readonly relays: readonly string[];
43
+ /** The copies whose list stood. Present exactly when a copy names storage,
44
+ * so a room with none has the same contributors as 0.13.0 wrote. */
45
+ readonly storage?: readonly string[];
23
46
  }
24
47
  export type PersistentInvitationEvidenceOutcome = {
25
48
  readonly status: 'admitted';
@@ -28,7 +51,7 @@ export type PersistentInvitationEvidenceOutcome = {
28
51
  readonly contributors: PersistentInvitationContributors;
29
52
  } | {
30
53
  readonly status: 'held';
31
- readonly reason: 'unavailable' | 'retired' | 'conflicting-rooms' | 'conflicting-relays' | 'wrong-room';
54
+ readonly reason: 'unavailable' | 'retired' | 'conflicting-rooms' | 'conflicting-relays' | 'conflicting-storage' | 'wrong-room';
32
55
  readonly retirementEventIds: readonly string[];
33
56
  };
34
57
  export interface PersistentInvitationEvidenceResult {