@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 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
- return ends === undefined ? { secret, persistent: true, epoch: 0 } : { secret, persistent: true, epoch: 0, endsAt: ends };
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.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",