@forgesworn/fold-kit 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +100 -0
- package/EXTRACTION.md +22 -0
- package/README.md +22 -1
- package/dist/epoch.d.ts +4 -0
- package/dist/epoch.js +2 -0
- package/dist/evidence-carrier.d.ts +10 -0
- package/dist/evidence-carrier.js +19 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/invitation-storage.d.ts +65 -0
- package/dist/invitation-storage.js +217 -0
- package/dist/persistent-invitation-evidence.d.ts +25 -2
- package/dist/persistent-invitation-evidence.js +126 -40
- package/dist/persistent-invitation.d.ts +12 -0
- package/dist/persistent-invitation.js +25 -3
- package/docs/persistent-invitation-evidence.md +46 -13
- package/docs/seal-key.md +2 -2
- package/llms.txt +3 -2
- package/package.json +3 -1
- package/vectors/storage-vectors.json +3843 -0
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.
|
|
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
|
|
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 {
|