@forgesworn/fold-kit 0.3.0 → 0.4.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/README.md +8 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/invitation-relays.d.ts +21 -0
- package/dist/invitation-relays.js +52 -0
- package/dist/persistent-invitation.d.ts +7 -0
- package/dist/persistent-invitation.js +19 -2
- package/llms.txt +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -118,6 +118,14 @@ the rest).
|
|
|
118
118
|
conference room: the encrypted body carries `ends` and the event a NIP-40
|
|
119
119
|
`expiration` tag, so relays drop it when the room ends; the decoder returns
|
|
120
120
|
`endsAt` and refuses a tag that disagrees with the body
|
|
121
|
+
- Room relays: pass `relays` (one to eight distinct safe relay URLs in
|
|
122
|
+
canonical `normalizeURL` form, e.g. `wss://relay.example.com/`) and the
|
|
123
|
+
encrypted body carries them after `ends`. Every member's pool includes
|
|
124
|
+
them, so members never land on disjoint relays. The decoder returns
|
|
125
|
+
`relays` and refuses the whole envelope on a malformed list;
|
|
126
|
+
`requestPersistentRoomAdmission` keeps the relays of the newest signed
|
|
127
|
+
copy that names any. `isInvitationRelays`, `requireInvitationRelays` and
|
|
128
|
+
`MAX_INVITATION_RELAYS` are the rule
|
|
121
129
|
- `withExpiration`, `isRoomEnds`, `requireRoomEnds`, `MAX_ROOM_ENDS_SECONDS` -
|
|
122
130
|
the conference-room expiration rule: add the end as an `expiration` tag,
|
|
123
131
|
keep an earlier one, lower a later one. `encodeInvitationRetirement` takes
|
package/dist/index.d.ts
CHANGED
|
@@ -21,6 +21,7 @@ export type { RoomInvitation, RoomInvitationHost, InvitationDelegation, RoomInvi
|
|
|
21
21
|
export { encodePersistentInvitation, decodePersistentInvitation, requestPersistentRoomAdmission, PERSISTENT_INVITATION_LABELS, } from './persistent-invitation.js';
|
|
22
22
|
export type { PersistentRoomAdmission } from './persistent-invitation.js';
|
|
23
23
|
export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } from './expiration.js';
|
|
24
|
+
export { isInvitationRelays, requireInvitationRelays, MAX_INVITATION_RELAYS } from './invitation-relays.js';
|
|
24
25
|
export { MAX_ROOM_LINK_FRAGMENT_LENGTH, parseRoomLink, encodeRoomLink, } from './link.js';
|
|
25
26
|
export type { RoomLink } from './link.js';
|
|
26
27
|
export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, decodeRekeyEvent, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ export { issueKindredProof, evaluateAccess, ACCESS_LABELS, } from './access.js';
|
|
|
14
14
|
export { INVITATION_DELEGATION_TTL_SECONDS, MAX_INVITATION_DELEGATION_DEPTH, createRoomInvitation, roomInvitation, deriveInvitationId, encodeInvitationRequest, decodeInvitationRequest, verifyInvitationDelegation, encodeInvitationGrant, decodeRoomAdmissionGrant, decodeInvitationGrant, ROOM_ENDED_MESSAGE, encodeInvitationRetirement, decodeInvitationRetirement, decodeInvitationRetirementNotice, retirementError, hostRoomInvitation, requestRoomAdmissionCapability, requestRoomAdmission, INVITATION_LABELS, } from './invitation.js';
|
|
15
15
|
export { encodePersistentInvitation, decodePersistentInvitation, requestPersistentRoomAdmission, PERSISTENT_INVITATION_LABELS, } from './persistent-invitation.js';
|
|
16
16
|
export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } from './expiration.js';
|
|
17
|
+
export { isInvitationRelays, requireInvitationRelays, MAX_INVITATION_RELAYS } from './invitation-relays.js';
|
|
17
18
|
export { MAX_ROOM_LINK_FRAGMENT_LENGTH, parseRoomLink, encodeRoomLink, } from './link.js';
|
|
18
19
|
export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, decodeRekeyEvent, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
|
|
19
20
|
export { deriveChannel, CHANNEL_ID_INFO, CHANNEL_KEY_INFO, MAX_CHANNEL_NAME_LENGTH, CHANNEL_LABELS } from './channel.js';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A room's own relays, carried in its signed group invitation (kind 1463).
|
|
3
|
+
*
|
|
4
|
+
* The relays a room is created on are its meeting place: every member's pool
|
|
5
|
+
* includes them, whatever else that member uses, so two members can never
|
|
6
|
+
* end up on disjoint relays. A link's relay hints are unsigned and drift as
|
|
7
|
+
* links are re-shared; the invitation's list is signed by the inviter, so a
|
|
8
|
+
* stale bookmark converges on it.
|
|
9
|
+
*
|
|
10
|
+
* The list is strict: one to `MAX_INVITATION_RELAYS` distinct URLs, each a
|
|
11
|
+
* safe relay URL already in canonical form (`normalizeURL` from
|
|
12
|
+
* `nostr-tools/utils`: lower-case host, no default port, no trailing slash
|
|
13
|
+
* except the root's, sorted query), with no credentials. Anything else
|
|
14
|
+
* refuses the whole envelope; a list is never silently trimmed.
|
|
15
|
+
*/
|
|
16
|
+
/** At most this many room relays: the same bound as a link's relay hints. */
|
|
17
|
+
export declare const MAX_INVITATION_RELAYS = 8;
|
|
18
|
+
/** True for a list of room relays the invitation may carry. Never throws. */
|
|
19
|
+
export declare function isInvitationRelays(value: unknown): value is string[];
|
|
20
|
+
/** Throws unless `relays` is a list the invitation may carry; returns a copy. */
|
|
21
|
+
export declare function requireInvitationRelays(relays: readonly string[]): string[];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { normalizeURL } from 'nostr-tools/utils';
|
|
2
|
+
import { isSafeRelayUrl, MAX_RELAY_HINTS } from './network-hints.js';
|
|
3
|
+
/**
|
|
4
|
+
* A room's own relays, carried in its signed group invitation (kind 1463).
|
|
5
|
+
*
|
|
6
|
+
* The relays a room is created on are its meeting place: every member's pool
|
|
7
|
+
* includes them, whatever else that member uses, so two members can never
|
|
8
|
+
* end up on disjoint relays. A link's relay hints are unsigned and drift as
|
|
9
|
+
* links are re-shared; the invitation's list is signed by the inviter, so a
|
|
10
|
+
* stale bookmark converges on it.
|
|
11
|
+
*
|
|
12
|
+
* The list is strict: one to `MAX_INVITATION_RELAYS` distinct URLs, each a
|
|
13
|
+
* safe relay URL already in canonical form (`normalizeURL` from
|
|
14
|
+
* `nostr-tools/utils`: lower-case host, no default port, no trailing slash
|
|
15
|
+
* except the root's, sorted query), with no credentials. Anything else
|
|
16
|
+
* refuses the whole envelope; a list is never silently trimmed.
|
|
17
|
+
*/
|
|
18
|
+
/** At most this many room relays: the same bound as a link's relay hints. */
|
|
19
|
+
export const MAX_INVITATION_RELAYS = MAX_RELAY_HINTS;
|
|
20
|
+
function isCanonicalRelayUrl(value) {
|
|
21
|
+
if (typeof value !== 'string' || !isSafeRelayUrl(value))
|
|
22
|
+
return false;
|
|
23
|
+
try {
|
|
24
|
+
const parsed = new URL(value);
|
|
25
|
+
if (parsed.username || parsed.password)
|
|
26
|
+
return false;
|
|
27
|
+
return normalizeURL(value) === value;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** True for a list of room relays the invitation may carry. Never throws. */
|
|
34
|
+
export function isInvitationRelays(value) {
|
|
35
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_INVITATION_RELAYS)
|
|
36
|
+
return false;
|
|
37
|
+
return value.every(isCanonicalRelayUrl) && new Set(value).size === value.length;
|
|
38
|
+
}
|
|
39
|
+
/** Throws unless `relays` is a list the invitation may carry; returns a copy. */
|
|
40
|
+
export function requireInvitationRelays(relays) {
|
|
41
|
+
if (!Array.isArray(relays) || relays.length === 0)
|
|
42
|
+
throw new Error('a room relay list needs at least one relay');
|
|
43
|
+
if (relays.length > MAX_INVITATION_RELAYS)
|
|
44
|
+
throw new Error(`a room can list at most ${MAX_INVITATION_RELAYS} relays`);
|
|
45
|
+
for (const url of relays) {
|
|
46
|
+
if (!isCanonicalRelayUrl(url))
|
|
47
|
+
throw new Error(`room relays must be wss:// URLs (ws:// only on localhost) in canonical form, without credentials: ${String(url)}`);
|
|
48
|
+
}
|
|
49
|
+
if (new Set(relays).size !== relays.length)
|
|
50
|
+
throw new Error('room relays must not repeat');
|
|
51
|
+
return [...relays];
|
|
52
|
+
}
|
|
@@ -10,6 +10,9 @@ export interface PersistentRoomAdmission {
|
|
|
10
10
|
/** When the room ends, in unix seconds: a conference room. Absent for a
|
|
11
11
|
* group that runs until somebody ends it. */
|
|
12
12
|
endsAt?: number;
|
|
13
|
+
/** The room's own relays, as its inviter signed them: every member's pool
|
|
14
|
+
* includes them. Absent from an invitation written before 0.4.0. */
|
|
15
|
+
relays?: string[];
|
|
13
16
|
}
|
|
14
17
|
/** Publish once before sharing. The link plus this envelope is a durable
|
|
15
18
|
* bearer capability: anybody holding both can learn epoch 0. Retirement
|
|
@@ -23,6 +26,10 @@ export declare function encodePersistentInvitation(opts: {
|
|
|
23
26
|
* 30 days beyond it. Carried in the body and as a NIP-40 expiration, so
|
|
24
27
|
* relays drop the invitation when the room ends. */
|
|
25
28
|
endsAt?: number;
|
|
29
|
+
/** The room's own relays: one to eight distinct safe URLs in canonical
|
|
30
|
+
* form (see `isInvitationRelays`), else it throws. Omitted, the body is
|
|
31
|
+
* byte-identical to 0.3.0's. */
|
|
32
|
+
relays?: readonly string[];
|
|
26
33
|
}): Event;
|
|
27
34
|
export declare function decodePersistentInvitation(event: Event, invitation: RoomInvitation): PersistentRoomAdmission | null;
|
|
28
35
|
/** Fetch the signed envelope and retirement in ONE stored-event query.
|
|
@@ -8,6 +8,7 @@ import { KINDS } from './kinds.js';
|
|
|
8
8
|
import { deriveRoom } from './room.js';
|
|
9
9
|
import { verifyEventUncached } from './verify.js';
|
|
10
10
|
import { isRoomEnds, requireRoomEnds } from './expiration.js';
|
|
11
|
+
import { isInvitationRelays, requireInvitationRelays } from './invitation-relays.js';
|
|
11
12
|
function welcomeKey(invitation) {
|
|
12
13
|
if (!invitation.persistent)
|
|
13
14
|
throw new Error('a persistent group invitation is required');
|
|
@@ -23,12 +24,13 @@ export function encodePersistentInvitation(opts) {
|
|
|
23
24
|
throw new Error('only the inviter can publish a group invitation');
|
|
24
25
|
const room = deriveRoom(opts.roomSecret).roomId;
|
|
25
26
|
const ends = opts.endsAt === undefined ? undefined : requireRoomEnds(opts.endsAt, opts.now);
|
|
27
|
+
const relays = opts.relays === undefined ? undefined : requireInvitationRelays(opts.relays);
|
|
26
28
|
return finalizeEvent({
|
|
27
29
|
kind: KINDS.GROUP_INVITATION,
|
|
28
30
|
created_at: opts.now,
|
|
29
31
|
tags: ends === undefined ? [['d', deriveInvitationId(opts.invitation)]] : [['d', deriveInvitationId(opts.invitation)], ['expiration', String(ends)]],
|
|
30
32
|
content: nip44.v2.encrypt(JSON.stringify({
|
|
31
|
-
v: 3, room, secret: base64urlnopad.encode(opts.roomSecret), ...(ends === undefined ? {} : { ends }),
|
|
33
|
+
v: 3, room, secret: base64urlnopad.encode(opts.roomSecret), ...(ends === undefined ? {} : { ends }), ...(relays === undefined ? {} : { relays }),
|
|
32
34
|
}), welcomeKey(opts.invitation)),
|
|
33
35
|
}, opts.inviterSk);
|
|
34
36
|
}
|
|
@@ -56,7 +58,14 @@ export function decodePersistentInvitation(event, invitation) {
|
|
|
56
58
|
return null;
|
|
57
59
|
if (expirations.length === 1 && (ends === undefined || expirations[0][1] !== String(ends)))
|
|
58
60
|
return null;
|
|
59
|
-
|
|
61
|
+
// The room's relays, when the body names them, must be a list the encoder
|
|
62
|
+
// would write; a malformed one refuses the envelope rather than half of it.
|
|
63
|
+
if (body.relays !== undefined && !isInvitationRelays(body.relays))
|
|
64
|
+
return null;
|
|
65
|
+
const admission = ends === undefined ? { secret, persistent: true, epoch: 0 } : { secret, persistent: true, epoch: 0, endsAt: ends };
|
|
66
|
+
if (body.relays !== undefined)
|
|
67
|
+
admission.relays = [...body.relays];
|
|
68
|
+
return admission;
|
|
60
69
|
}
|
|
61
70
|
catch {
|
|
62
71
|
return null;
|
|
@@ -73,6 +82,7 @@ export function requestPersistentRoomAdmission(opts) {
|
|
|
73
82
|
return new Promise((resolve, reject) => {
|
|
74
83
|
let settled = false;
|
|
75
84
|
let admission;
|
|
85
|
+
let relaysAt = -1;
|
|
76
86
|
let unsub = () => { };
|
|
77
87
|
const timer = setTimeout(() => finish(new Error('the group invitation could not be loaded from its relays')), opts.timeoutMs ?? 15_000);
|
|
78
88
|
function finish(error) {
|
|
@@ -109,6 +119,13 @@ export function requestPersistentRoomAdmission(opts) {
|
|
|
109
119
|
// end stands, so a stale copy can never keep a room open longer.
|
|
110
120
|
if (admission?.endsAt !== undefined && (decoded.endsAt === undefined || decoded.endsAt > admission.endsAt))
|
|
111
121
|
decoded.endsAt = admission.endsAt;
|
|
122
|
+
// Two signed copies that disagree on the room's relays: the newest
|
|
123
|
+
// copy that names any stands. A copy naming none (an older writer)
|
|
124
|
+
// says nothing about them, and on equal timestamps the first heard stays.
|
|
125
|
+
if (decoded.relays !== undefined && event.created_at > relaysAt)
|
|
126
|
+
relaysAt = event.created_at;
|
|
127
|
+
else if (admission?.relays !== undefined)
|
|
128
|
+
decoded.relays = admission.relays;
|
|
112
129
|
admission = decoded;
|
|
113
130
|
}, () => {
|
|
114
131
|
if (admission)
|
package/llms.txt
CHANGED
|
@@ -67,6 +67,7 @@ Not yet published to npm under this API. Until it is, pin an immutable Git commi
|
|
|
67
67
|
- `encodePersistentInvitation(opts)` / `decodePersistentInvitation(event, invitation)` - v3 stored group invitation (kind 1463)
|
|
68
68
|
- `requestPersistentRoomAdmission(opts)` - joins from stored admission alone, no live desk needed
|
|
69
69
|
- Conference rooms: `encodePersistentInvitation({ ..., endsAt })` puts `ends` in the body and `['expiration', String(endsAt)]` on the 1463; `decodePersistentInvitation` returns `endsAt` and is null when an `expiration` tag disagrees with the body; `encodeInvitationRetirement({ ..., endsAt })` tags the 1461 the same way
|
|
70
|
+
- Room relays: `encodePersistentInvitation({ ..., relays })` puts the room's own relays (1-8 distinct canonical `wss://host/` URLs, else it throws) in the body after `ends`; the decoder returns `relays` and is null on a malformed list; `requestPersistentRoomAdmission` keeps the newest signed copy's list (a copy without one says nothing); `isInvitationRelays`, `requireInvitationRelays`, `MAX_INVITATION_RELAYS` (8)
|
|
70
71
|
- `withExpiration(tags, expiresAt?)` - NIP-40 rule for a room that ends: add the tag, keep an earlier one, lower a later one; `isRoomEnds`, `requireRoomEnds(endsAt, now)`, `MAX_ROOM_ENDS_SECONDS` (30 days)
|
|
71
72
|
- Epoch encoders (`encodeRekeyEvent`, `encodeEpochRequest`, `encodeEpochGrant`) and `hostRoomEpoch` / `requestRoomEpoch` take `expiresAt?` for a conference room
|
|
72
73
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgesworn/fold-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Circle primitives for private Nostr groups nobody operates: epoch keys, link invites, removal by rekey, device credentials.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|