@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.
@@ -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 {
@@ -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 const MAX_PERSISTENT_INVITATION_EVENT_BYTES = 16_384;
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
- function jsonStringBytes(value) {
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
- function carrier(raw) {
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' || d.content.length > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
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
- throw new Error('oversized evidence event');
97
- const outer = dataArray(d.tags, 64);
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, 8);
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' || value.length > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
140
+ if (typeof value !== 'string')
104
141
  throw new Error('invalid or oversized evidence tag');
105
- bytes += jsonStringBytes(value);
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
- throw new Error('oversized evidence event');
146
+ exceed('oversized evidence event');
108
147
  return value;
109
148
  });
110
149
  });
111
150
  if (bytes > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
112
- throw new Error('oversized evidence event');
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
- const canonical = JSON.stringify(event);
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.pubkey === invitation.inviter && (event.kind === KINDS.GROUP_INVITATION || event.kind === KINDS.INVITATION_RETIREMENT) &&
129
- event.tags.filter(tag => tag[0] === 'd').length === 1 && event.tags.find(tag => tag[0] === 'd')?.length === 2 &&
130
- event.tags.find(tag => tag[0] === 'd')?.[1] === deriveInvitationId(invitation) && verifyEventUncached(event);
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
- unsub = opts.transport.subscribe([{ kinds: [KINDS.GROUP_INVITATION, KINDS.INVITATION_RETIREMENT], authors: [invitation.inviter], '#d': [deriveInvitationId(invitation)] }], event => {
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
- // Charge invalid candidates and duplicates before carrier/crypto.
341
- if (received >= MAX_PERSISTENT_INVITATION_EVIDENCE_EVENTS)
342
- throw new Error('invitation evidence query capacity exceeded');
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
- const c = carrier(event);
345
- if (bytes + c.bytes > MAX_PERSISTENT_INVITATION_EVIDENCE_BYTES)
346
- throw new Error('invitation evidence query capacity exceeded');
347
- bytes += c.bytes;
348
- const prior = events.get(c.event.id);
349
- if (prior !== undefined) {
350
- if (prior !== c.canonical)
351
- throw new Error('conflicting duplicate invitation event identity');
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
- return finalizeEvent({
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)