@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 +114 -0
- package/EXTRACTION.md +22 -0
- package/README.md +27 -1
- package/dist/epoch.d.ts +18 -0
- package/dist/epoch.js +40 -10
- package/dist/evidence-carrier.d.ts +10 -0
- package/dist/evidence-carrier.js +19 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -1
- package/dist/invitation-storage.d.ts +65 -0
- package/dist/invitation-storage.js +217 -0
- package/dist/persistent-invitation-evidence.d.ts +25 -2
- package/dist/persistent-invitation-evidence.js +126 -40
- package/dist/persistent-invitation.d.ts +12 -0
- package/dist/persistent-invitation.js +25 -3
- package/dist/seal.d.ts +20 -0
- package/dist/seal.js +25 -0
- package/docs/persistent-invitation-evidence.md +46 -13
- package/docs/seal-key.md +39 -0
- package/llms.txt +3 -2
- package/package.json +3 -1
- package/vectors/seal-vectors.json +304 -0
- package/vectors/storage-vectors.json +3843 -0
|
@@ -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
|
|
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
|
|
16
|
+
export { MAX_PERSISTENT_INVITATION_EVENT_BYTES };
|
|
15
17
|
export const MAX_PERSISTENT_INVITATION_EVIDENCE_BYTES = 262_144;
|
|
18
|
+
/** Raw deliveries one query reads, counted before anything is parsed: every
|
|
19
|
+
* relay's copy of every event, repeats, other authors and junk alike. It bounds
|
|
20
|
+
* parse work only. It is eight times the retained-event limit, so 64 events
|
|
21
|
+
* from each of eight relays still fit. Past it the query fails closed, because
|
|
22
|
+
* an event that could matter may be among those it did not read. */
|
|
23
|
+
export const MAX_PERSISTENT_INVITATION_EVIDENCE_DELIVERIES = 512;
|
|
24
|
+
/** Signature checks one query is charged. Only an event that is in scope and
|
|
25
|
+
* not already held or refused costs one, so a repeat costs none. It is twice
|
|
26
|
+
* the retained-event limit: room for 64 retained events and 64 more in-scope
|
|
27
|
+
* events that fail (forged, over the evidence size limits, or signed but not a
|
|
28
|
+
* supported body). Beyond it the query fails closed rather than skip an
|
|
29
|
+
* unchecked event.
|
|
30
|
+
*
|
|
31
|
+
* It bounds checks, not Schnorr verifications. A retained retirement is
|
|
32
|
+
* verified twice (its scope check, then the retirement decoder), and the
|
|
33
|
+
* finished query revalidates every retained event, so the worst case is
|
|
34
|
+
* 128 + 64 verifications on a query that succeeds. A forgery fails at its
|
|
35
|
+
* first verification, so a hostile relay can cause at most 128. */
|
|
36
|
+
export const MAX_PERSISTENT_INVITATION_EVIDENCE_VERIFICATIONS = 128;
|
|
16
37
|
/** Incomplete observations are bounded diagnostic custody only. They are
|
|
17
38
|
* never accepted by revalidatePersistentInvitationEvidence. */
|
|
18
39
|
export class PersistentInvitationEvidenceQueryError extends Error {
|
|
@@ -27,8 +48,9 @@ const fields = ['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig'];
|
|
|
27
48
|
const utf8 = new TextEncoder();
|
|
28
49
|
const hex32 = /^[0-9a-f]{64}$/;
|
|
29
50
|
const hex64 = /^[0-9a-f]{128}$/;
|
|
30
|
-
/** JSON string byte cost including escaping, without serialising content.
|
|
31
|
-
|
|
51
|
+
/** JSON string byte cost including escaping, without serialising content.
|
|
52
|
+
* `limited` throws past the per-event ceiling; unlimited only measures. */
|
|
53
|
+
function jsonStringBytes(value, limited = true) {
|
|
32
54
|
let bytes = 2;
|
|
33
55
|
for (let i = 0; i < value.length; i++) {
|
|
34
56
|
const c = value.charCodeAt(i);
|
|
@@ -48,7 +70,7 @@ function jsonStringBytes(value) {
|
|
|
48
70
|
bytes += 6;
|
|
49
71
|
else
|
|
50
72
|
bytes += 3;
|
|
51
|
-
if (bytes > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
|
|
73
|
+
if (limited && bytes > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
|
|
52
74
|
throw new Error('oversized evidence event');
|
|
53
75
|
}
|
|
54
76
|
return bytes;
|
|
@@ -82,37 +104,53 @@ function dataArray(raw, limit) {
|
|
|
82
104
|
return values;
|
|
83
105
|
}
|
|
84
106
|
/** Fresh carrier copy, with no trusted verification-cache symbol. Bounds
|
|
85
|
-
* precede copying or serialising potentially large strings/tag arrays.
|
|
86
|
-
|
|
107
|
+
* precede copying or serialising potentially large strings/tag arrays.
|
|
108
|
+
*
|
|
109
|
+
* `tolerant` lifts only the size ceilings (bytes, tag count, tag width): every
|
|
110
|
+
* plain-data, prototype, type and hex refusal stays. The copy of an event that
|
|
111
|
+
* exceeds a ceiling comes back with `over` set and no canonical form, because
|
|
112
|
+
* it can never be retained as evidence. The query uses that to check whether
|
|
113
|
+
* the authority signed it. */
|
|
114
|
+
function carrier(raw, tolerant = false) {
|
|
115
|
+
let over = false;
|
|
116
|
+
const exceed = (message) => { if (!tolerant)
|
|
117
|
+
throw new Error(message); over = true; };
|
|
87
118
|
const d = dataRecord(raw, fields, verifiedSymbol);
|
|
88
119
|
if (Object.keys(d).length !== fields.length || typeof d.id !== 'string' || !hex32.test(d.id) ||
|
|
89
120
|
typeof d.pubkey !== 'string' || !hex32.test(d.pubkey) || typeof d.sig !== 'string' || !hex64.test(d.sig) ||
|
|
90
121
|
!Number.isSafeInteger(d.created_at) || d.created_at < 0 || !Number.isSafeInteger(d.kind) || d.kind < 0 ||
|
|
91
|
-
typeof d.content !== 'string'
|
|
122
|
+
typeof d.content !== 'string')
|
|
92
123
|
throw new Error('invalid or oversized evidence event');
|
|
124
|
+
if (d.content.length > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
|
|
125
|
+
exceed('invalid or oversized evidence event');
|
|
93
126
|
let bytes = '{"id":,"pubkey":,"created_at":,"kind":,"tags":,"content":,"sig":}'.length +
|
|
94
|
-
String(d.created_at).length + String(d.kind).length + jsonStringBytes(d.id) + jsonStringBytes(d.pubkey) + jsonStringBytes(d.sig) + jsonStringBytes(d.content);
|
|
127
|
+
String(d.created_at).length + String(d.kind).length + jsonStringBytes(d.id) + jsonStringBytes(d.pubkey) + jsonStringBytes(d.sig) + jsonStringBytes(d.content, !tolerant);
|
|
95
128
|
if (bytes > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
|
|
96
|
-
|
|
97
|
-
const outer = dataArray(d.tags,
|
|
129
|
+
exceed('oversized evidence event');
|
|
130
|
+
const outer = dataArray(d.tags, tolerant ? Infinity : MAX_EVIDENCE_CARRIER_TAGS);
|
|
131
|
+
if (outer.length > MAX_EVIDENCE_CARRIER_TAGS)
|
|
132
|
+
over = true;
|
|
98
133
|
bytes += 2 + Math.max(0, outer.length - 1);
|
|
99
134
|
const tags = outer.map(tag => {
|
|
100
|
-
const inner = dataArray(tag,
|
|
135
|
+
const inner = dataArray(tag, tolerant ? Infinity : MAX_EVIDENCE_CARRIER_TAG_STRINGS);
|
|
136
|
+
if (inner.length > MAX_EVIDENCE_CARRIER_TAG_STRINGS)
|
|
137
|
+
over = true;
|
|
101
138
|
bytes += 2 + Math.max(0, inner.length - 1);
|
|
102
139
|
return inner.map(value => {
|
|
103
|
-
if (typeof value !== 'string'
|
|
140
|
+
if (typeof value !== 'string')
|
|
104
141
|
throw new Error('invalid or oversized evidence tag');
|
|
105
|
-
|
|
142
|
+
if (value.length > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
|
|
143
|
+
exceed('invalid or oversized evidence tag');
|
|
144
|
+
bytes += jsonStringBytes(value, !tolerant);
|
|
106
145
|
if (bytes > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
|
|
107
|
-
|
|
146
|
+
exceed('oversized evidence event');
|
|
108
147
|
return value;
|
|
109
148
|
});
|
|
110
149
|
});
|
|
111
150
|
if (bytes > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
|
|
112
|
-
|
|
151
|
+
exceed('oversized evidence event');
|
|
113
152
|
const event = { id: d.id, pubkey: d.pubkey, created_at: d.created_at, kind: d.kind, tags, content: d.content, sig: d.sig };
|
|
114
|
-
|
|
115
|
-
return { event, canonical, bytes };
|
|
153
|
+
return { event, canonical: over ? '' : JSON.stringify(event), bytes, over };
|
|
116
154
|
}
|
|
117
155
|
function copyInvitation(invitation) {
|
|
118
156
|
if (!invitation.persistent || !(invitation.bearer instanceof Uint8Array) || invitation.bearer.length !== 32 || !hex32.test(invitation.inviter))
|
|
@@ -124,10 +162,16 @@ function roomId(secret) {
|
|
|
124
162
|
room.roomKey.fill(0);
|
|
125
163
|
return room.roomId;
|
|
126
164
|
}
|
|
165
|
+
/** The cheap, unsigned part of scope: the authority, a source kind and exactly
|
|
166
|
+
* one two-element `d` tag naming this invitation. No crypto. */
|
|
167
|
+
function inAuthorityScope(event, inviter, invitationId) {
|
|
168
|
+
if (event.pubkey !== inviter || (event.kind !== KINDS.GROUP_INVITATION && event.kind !== KINDS.INVITATION_RETIREMENT))
|
|
169
|
+
return false;
|
|
170
|
+
const d = event.tags.filter(tag => tag[0] === 'd');
|
|
171
|
+
return d.length === 1 && d[0].length === 2 && d[0][1] === invitationId;
|
|
172
|
+
}
|
|
127
173
|
function signedScope(event, invitation) {
|
|
128
|
-
return event
|
|
129
|
-
event.tags.filter(tag => tag[0] === 'd').length === 1 && event.tags.find(tag => tag[0] === 'd')?.length === 2 &&
|
|
130
|
-
event.tags.find(tag => tag[0] === 'd')?.[1] === deriveInvitationId(invitation) && verifyEventUncached(event);
|
|
174
|
+
return inAuthorityScope(event, invitation.inviter, deriveInvitationId(invitation)) && verifyEventUncached(event);
|
|
131
175
|
}
|
|
132
176
|
/** Strict evidence path accepts exactly the bodies/tags of supported writers;
|
|
133
177
|
* permissive legacy decoding and its public request remain unchanged. */
|
|
@@ -142,19 +186,21 @@ function decodeEvidenceEvent(event, invitation) {
|
|
|
142
186
|
const key = hkdf(sha256, invitation.bearer, undefined, PERSISTENT_INVITATION_LABELS[0], 32);
|
|
143
187
|
try {
|
|
144
188
|
const plaintext = nip44.v2.decrypt(event.content, key);
|
|
145
|
-
const body = dataRecord(JSON.parse(plaintext), ['v', 'room', 'secret', 'ends', 'destruct', 'relays']);
|
|
189
|
+
const body = dataRecord(JSON.parse(plaintext), ['v', 'room', 'secret', 'ends', 'destruct', 'relays', 'storage']);
|
|
146
190
|
if (body.v !== 3 || typeof body.secret !== 'string' || (body.ends !== undefined && !isRoomEnds(body.ends)) ||
|
|
147
|
-
(body.destruct !== undefined && body.destruct !== true) || (body.relays !== undefined && !isInvitationRelays(body.relays))
|
|
191
|
+
(body.destruct !== undefined && body.destruct !== true) || (body.relays !== undefined && !isInvitationRelays(body.relays)) ||
|
|
192
|
+
(body.storage !== undefined && !isInvitationStorage(body.storage)))
|
|
148
193
|
return null;
|
|
149
194
|
secret = base64urlnopad.decode(body.secret);
|
|
150
195
|
if (secret.length !== 32 || body.room !== roomId(secret))
|
|
151
196
|
return null;
|
|
152
197
|
const decoded = { secret, persistent: true, epoch: 0,
|
|
153
198
|
...(body.ends === undefined ? {} : { endsAt: body.ends }), ...(body.destruct ? { destruct: true } : {}),
|
|
154
|
-
...(body.relays === undefined ? {} : { relays: [...body.relays] })
|
|
199
|
+
...(body.relays === undefined ? {} : { relays: [...body.relays] }),
|
|
200
|
+
...(body.storage === undefined ? {} : { storage: [...body.storage], storageAt: event.created_at }) };
|
|
155
201
|
const expected = JSON.stringify({ v: 3, room: body.room, secret: base64urlnopad.encode(secret),
|
|
156
202
|
...(decoded.endsAt === undefined ? {} : { ends: decoded.endsAt }), ...(decoded.destruct ? { destruct: true } : {}),
|
|
157
|
-
...(decoded.relays === undefined ? {} : { relays: decoded.relays }) });
|
|
203
|
+
...(decoded.relays === undefined ? {} : { relays: decoded.relays }), ...(decoded.storage === undefined ? {} : { storage: decoded.storage }) });
|
|
158
204
|
const tags = decoded.endsAt === undefined ? [['d', deriveInvitationId(invitation)]] : [['d', deriveInvitationId(invitation)], ['expiration', String(decoded.endsAt)]];
|
|
159
205
|
accepted = plaintext === expected && JSON.stringify(event.tags) === JSON.stringify(tags);
|
|
160
206
|
return accepted ? decoded : null;
|
|
@@ -277,12 +323,20 @@ export function revalidatePersistentInvitationEvidence(opts) {
|
|
|
277
323
|
if (new Set(winners.map(d => JSON.stringify(d.admission.relays))).size > 1)
|
|
278
324
|
return held('conflicting-relays');
|
|
279
325
|
const relays = winners[0]?.admission.relays;
|
|
326
|
+
const storageEvents = decoded.filter(d => d.admission.storage !== undefined);
|
|
327
|
+
const newestStorage = storageEvents.length ? Math.max(...storageEvents.map(d => d.event.created_at)) : undefined;
|
|
328
|
+
const storageWinners = storageEvents.filter(d => d.event.created_at === newestStorage);
|
|
329
|
+
if (new Set(storageWinners.map(d => JSON.stringify(d.admission.storage))).size > 1)
|
|
330
|
+
return held('conflicting-storage');
|
|
331
|
+
const storage = storageWinners[0]?.admission.storage;
|
|
280
332
|
const admission = { secret: first.slice(), persistent: true, epoch: 0,
|
|
281
333
|
...(endsAt === undefined ? {} : { endsAt }), ...(destruct.length ? { destruct: true } : {}),
|
|
282
|
-
...(relays === undefined ? {} : { relays: [...relays] })
|
|
334
|
+
...(relays === undefined ? {} : { relays: [...relays] }),
|
|
335
|
+
...(storage === undefined ? {} : { storage: [...storage], storageAt: newestStorage }) };
|
|
283
336
|
const contributors = Object.freeze({ room: Object.freeze(decoded.map(d => d.event.id)),
|
|
284
337
|
ends: Object.freeze(ends.filter(d => d.admission.endsAt === endsAt).map(d => d.event.id)),
|
|
285
|
-
destruct: Object.freeze(destruct.map(d => d.event.id)), relays: Object.freeze(winners.map(d => d.event.id))
|
|
338
|
+
destruct: Object.freeze(destruct.map(d => d.event.id)), relays: Object.freeze(winners.map(d => d.event.id)),
|
|
339
|
+
...(storageWinners.length ? { storage: Object.freeze(storageWinners.map(d => d.event.id)) } : {}) });
|
|
286
340
|
return Object.freeze({ evidence, outcome: Object.freeze({ status: 'admitted', admission, contributors }) });
|
|
287
341
|
}
|
|
288
342
|
finally {
|
|
@@ -333,29 +387,61 @@ export async function requestPersistentRoomAdmissionEvidence(opts) {
|
|
|
333
387
|
return;
|
|
334
388
|
}
|
|
335
389
|
try {
|
|
336
|
-
|
|
390
|
+
const invitationId = deriveInvitationId(invitation);
|
|
391
|
+
// Canonical forms that were checked and refused, so a repeat of one costs no crypto.
|
|
392
|
+
const refused = new Set();
|
|
393
|
+
let verifications = 0;
|
|
394
|
+
unsub = opts.transport.subscribe([{ kinds: [KINDS.GROUP_INVITATION, KINDS.INVITATION_RETIREMENT], authors: [invitation.inviter], '#d': [invitationId] }], event => {
|
|
337
395
|
if (settled)
|
|
338
396
|
return;
|
|
339
397
|
try {
|
|
340
|
-
//
|
|
341
|
-
|
|
342
|
-
|
|
398
|
+
// Every delivery is charged first and bounds parse work. Retained
|
|
399
|
+
// events, retained bytes and signature checks have their own limits.
|
|
400
|
+
if (received >= MAX_PERSISTENT_INVITATION_EVIDENCE_DELIVERIES)
|
|
401
|
+
throw new Error('invitation evidence query capacity exceeded: too many deliveries');
|
|
343
402
|
received++;
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
403
|
+
let c;
|
|
404
|
+
try {
|
|
405
|
+
c = carrier(event, true);
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
return;
|
|
409
|
+
} // malformed beyond size: not evidence, not a failure
|
|
410
|
+
if (!inAuthorityScope(c.event, invitation.inviter, invitationId))
|
|
411
|
+
return;
|
|
412
|
+
// An id already held was verified, so any other carrier with that id
|
|
413
|
+
// has the same signed fields and nothing new to say. A carrier that
|
|
414
|
+
// only claims it cannot verify. Dropping it loses no signed event.
|
|
415
|
+
// An over-limit carrier is remembered by its computed hash and signature,
|
|
416
|
+
// never by the id it claims: a tampered copy that claims a real event's
|
|
417
|
+
// id and signature has a different hash, so it cannot mask the real one.
|
|
418
|
+
const overKey = c.over ? `${getEventHash(c.event)}:${c.event.sig}` : '';
|
|
419
|
+
if (events.has(c.event.id) || refused.has(c.over ? overKey : c.canonical))
|
|
420
|
+
return;
|
|
421
|
+
if (verifications >= MAX_PERSISTENT_INVITATION_EVIDENCE_VERIFICATIONS)
|
|
422
|
+
throw new Error('invitation evidence query capacity exceeded: too many signature checks');
|
|
423
|
+
verifications++;
|
|
424
|
+
if (c.over) {
|
|
425
|
+
// Over the size limits, so it can never be retained. If the authority
|
|
426
|
+
// signed it, it may be a retirement or a newer list that this query
|
|
427
|
+
// cannot hold: fail closed rather than select without it. An event
|
|
428
|
+
// that only claims the authority's key is dropped.
|
|
429
|
+
if (verifyEventUncached(c.event))
|
|
430
|
+
throw new Error('invitation evidence query refused an event signed by the authority that exceeds the evidence size limits');
|
|
431
|
+
refused.add(overKey);
|
|
352
432
|
return;
|
|
353
433
|
}
|
|
354
434
|
const decoded = decodeEvidenceEvent(c.event, invitation);
|
|
355
|
-
if (!decoded)
|
|
435
|
+
if (!decoded) {
|
|
436
|
+
refused.add(c.canonical);
|
|
356
437
|
return;
|
|
438
|
+
}
|
|
357
439
|
if (decoded !== 'retired')
|
|
358
440
|
decoded.secret.fill(0);
|
|
441
|
+
// A valid authority event that no longer fits fails the query: never evict or truncate.
|
|
442
|
+
if (events.size >= MAX_PERSISTENT_INVITATION_EVIDENCE_EVENTS || bytes + c.bytes > MAX_PERSISTENT_INVITATION_EVIDENCE_BYTES)
|
|
443
|
+
throw new Error('invitation evidence query capacity exceeded');
|
|
444
|
+
bytes += c.bytes;
|
|
359
445
|
events.set(c.event.id, c.canonical);
|
|
360
446
|
}
|
|
361
447
|
catch (error) {
|
|
@@ -16,6 +16,13 @@ export interface PersistentRoomAdmission {
|
|
|
16
16
|
/** The room self-destructs: when it ends, every device deletes what it
|
|
17
17
|
* wrote and forgets the room. Absent for a room that simply ends. */
|
|
18
18
|
destruct?: true;
|
|
19
|
+
/** The room's group storage nodes, as its authority last signed them; `[]`
|
|
20
|
+
* when it withdrew them all. Absent from an invitation written before
|
|
21
|
+
* 0.14.0, or when no copy names them. */
|
|
22
|
+
storage?: string[];
|
|
23
|
+
/** The `created_at` of the copy whose `storage` stood: present exactly when
|
|
24
|
+
* `storage` is. A client uses it as the record's version. */
|
|
25
|
+
storageAt?: number;
|
|
19
26
|
}
|
|
20
27
|
/** Publish once before sharing. The link plus this envelope is a durable
|
|
21
28
|
* bearer capability: anybody holding both can learn epoch 0. Retirement
|
|
@@ -33,6 +40,11 @@ export declare function encodePersistentInvitation(opts: {
|
|
|
33
40
|
* form (see `isInvitationRelays`), else it throws. Omitted, the body is
|
|
34
41
|
* byte-identical to 0.3.0's. */
|
|
35
42
|
relays?: readonly string[];
|
|
43
|
+
/** The room's group storage nodes: zero to eight canonical node base URLs,
|
|
44
|
+
* one per host (see `isInvitationStorage`), else it throws. `[]` says the
|
|
45
|
+
* authority withdrew every node. Written after `relays`. Omitted, the body
|
|
46
|
+
* is byte-identical to 0.13.0's. */
|
|
47
|
+
storage?: readonly string[];
|
|
36
48
|
/** The room self-destructs when it ends, by its time or by its authority
|
|
37
49
|
* closing it. Written inside the encrypted body only, never as a tag.
|
|
38
50
|
* Omitted or false, the event is byte-identical to 0.8.0's. */
|
|
@@ -9,6 +9,8 @@ import { deriveRoom } from './room.js';
|
|
|
9
9
|
import { verifyEventUncached } from './verify.js';
|
|
10
10
|
import { isRoomEnds, requireRoomEnds } from './expiration.js';
|
|
11
11
|
import { isInvitationRelays, requireInvitationRelays } from './invitation-relays.js';
|
|
12
|
+
import { isInvitationStorage, requireInvitationStorage } from './invitation-storage.js';
|
|
13
|
+
import { requireEvidenceCarrierLimits } from './evidence-carrier.js';
|
|
12
14
|
function welcomeKey(invitation) {
|
|
13
15
|
if (!invitation.persistent)
|
|
14
16
|
throw new Error('a persistent group invitation is required');
|
|
@@ -25,14 +27,15 @@ export function encodePersistentInvitation(opts) {
|
|
|
25
27
|
const room = deriveRoom(opts.roomSecret).roomId;
|
|
26
28
|
const ends = opts.endsAt === undefined ? undefined : requireRoomEnds(opts.endsAt, opts.now);
|
|
27
29
|
const relays = opts.relays === undefined ? undefined : requireInvitationRelays(opts.relays);
|
|
28
|
-
|
|
30
|
+
const storage = opts.storage === undefined ? undefined : requireInvitationStorage(opts.storage);
|
|
31
|
+
return requireEvidenceCarrierLimits(finalizeEvent({
|
|
29
32
|
kind: KINDS.GROUP_INVITATION,
|
|
30
33
|
created_at: opts.now,
|
|
31
34
|
tags: ends === undefined ? [['d', deriveInvitationId(opts.invitation)]] : [['d', deriveInvitationId(opts.invitation)], ['expiration', String(ends)]],
|
|
32
35
|
content: nip44.v2.encrypt(JSON.stringify({
|
|
33
|
-
v: 3, room, secret: base64urlnopad.encode(opts.roomSecret), ...(ends === undefined ? {} : { ends }), ...(opts.destruct ? { destruct: true } : {}), ...(relays === undefined ? {} : { relays }),
|
|
36
|
+
v: 3, room, secret: base64urlnopad.encode(opts.roomSecret), ...(ends === undefined ? {} : { ends }), ...(opts.destruct ? { destruct: true } : {}), ...(relays === undefined ? {} : { relays }), ...(storage === undefined ? {} : { storage }),
|
|
34
37
|
}), welcomeKey(opts.invitation)),
|
|
35
|
-
}, opts.inviterSk);
|
|
38
|
+
}, opts.inviterSk), 'group invitation');
|
|
36
39
|
}
|
|
37
40
|
export function decodePersistentInvitation(event, invitation) {
|
|
38
41
|
try {
|
|
@@ -62,6 +65,10 @@ export function decodePersistentInvitation(event, invitation) {
|
|
|
62
65
|
// would write; a malformed one refuses the envelope rather than half of it.
|
|
63
66
|
if (body.relays !== undefined && !isInvitationRelays(body.relays))
|
|
64
67
|
return null;
|
|
68
|
+
// The same for the room's storage nodes: `[]` is valid (withdrawn), a
|
|
69
|
+
// malformed list refuses the envelope.
|
|
70
|
+
if (body.storage !== undefined && !isInvitationStorage(body.storage))
|
|
71
|
+
return null;
|
|
65
72
|
// Self-destruct is `true` or absent; anything else is a malformed
|
|
66
73
|
// envelope, refused as a malformed end is.
|
|
67
74
|
if (body.destruct !== undefined && body.destruct !== true)
|
|
@@ -69,6 +76,10 @@ export function decodePersistentInvitation(event, invitation) {
|
|
|
69
76
|
const admission = ends === undefined ? { secret, persistent: true, epoch: 0 } : { secret, persistent: true, epoch: 0, endsAt: ends };
|
|
70
77
|
if (body.relays !== undefined)
|
|
71
78
|
admission.relays = [...body.relays];
|
|
79
|
+
if (body.storage !== undefined) {
|
|
80
|
+
admission.storage = [...body.storage];
|
|
81
|
+
admission.storageAt = event.created_at;
|
|
82
|
+
}
|
|
72
83
|
if (body.destruct === true)
|
|
73
84
|
admission.destruct = true;
|
|
74
85
|
return admission;
|
|
@@ -89,6 +100,7 @@ export function requestPersistentRoomAdmission(opts) {
|
|
|
89
100
|
let settled = false;
|
|
90
101
|
let admission;
|
|
91
102
|
let relaysAt = -1;
|
|
103
|
+
let storageAt = -1;
|
|
92
104
|
let unsub = () => { };
|
|
93
105
|
const timer = setTimeout(() => finish(new Error('the group invitation could not be loaded from its relays')), opts.timeoutMs ?? 15_000);
|
|
94
106
|
function finish(error) {
|
|
@@ -136,6 +148,16 @@ export function requestPersistentRoomAdmission(opts) {
|
|
|
136
148
|
relaysAt = event.created_at;
|
|
137
149
|
else if (admission?.relays !== undefined)
|
|
138
150
|
decoded.relays = admission.relays;
|
|
151
|
+
// The storage nodes merge the same way, independently of the relays:
|
|
152
|
+
// the newest copy that names them stands, `[]` included; a copy that
|
|
153
|
+
// names none says nothing, so the keeper's plain copy never erases
|
|
154
|
+
// the list; on equal timestamps the first heard stays.
|
|
155
|
+
if (decoded.storage !== undefined && event.created_at > storageAt)
|
|
156
|
+
storageAt = event.created_at;
|
|
157
|
+
else if (admission?.storage !== undefined) {
|
|
158
|
+
decoded.storage = admission.storage;
|
|
159
|
+
decoded.storageAt = admission.storageAt;
|
|
160
|
+
}
|
|
139
161
|
admission = decoded;
|
|
140
162
|
}, () => {
|
|
141
163
|
if (admission)
|