@forgesworn/fold-kit 0.3.0 → 0.5.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.
@@ -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
 
@@ -78,6 +79,7 @@ Not yet published to npm under this API. Until it is, pin an immutable Git commi
78
79
  - `encodeEpochGrant(opts)` / `decodeEpochGrant(event, opts)` - the authority's answer (secret, epoch-0, or a refusal `'removed' | 'closed'`)
79
80
  - `epochRequestAdmission(opts)`, `deriveEpochRequestKey(roomKey)`
80
81
  - `hostRoomEpoch(opts)` / `requestRoomEpoch(opts)` / `EpochRefusedError` - the live request/grant desk, both sides
82
+ - Member epoch catch-up (see `docs/member-epoch-catch-up.md`): `encodeRekeyEvent({ ..., commit: true })` writes `epochCommitment(roomId, epoch, secret)` into the rekey body; `hostMemberEpochDesk(opts)` lets any current member answer; `requestRoomEpoch({ ..., members: memberEpochSource(opts) })` asks the authority and members, first verifiable answer wins; `requestMemberEpoch(opts)` asks members alone. Kinds `MEMBER_EPOCH_KINDS` = `{ REQUEST: 20471, GRANT: 20472 }`. A member grant carries the authority-signed rekeys from the asker's epoch to the current one; the asker verifies the chain itself (`decodeMemberEpochGrant`) and refuses a last epoch with no commitment (legacy: authority only), a chain that removes it or closes the room, and one that stops short of `expected`. Codecs: `encodeMemberEpochRequest`, `decodeMemberEpochRequest`, `encodeMemberEpochGrant`, `readRekeyEvidence`, `deriveMemberEpochRequestKey`, `MAX_MEMBER_EPOCH_CHAIN` (32)
81
83
  - `canonicalAdmins(admins)` / `signAdmins(opts)` / `verifyAdmins(opts)` - the authority's signed admin list
82
84
  - `canonicalChannels(channels)` / `signChannels(opts)` / `verifyChannels(opts)` - the authority's signed channel list; `CHANNEL_NAME`, `RESERVED_CHANNELS`
83
85
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgesworn/fold-kit",
3
- "version": "0.3.0",
3
+ "version": "0.5.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",
@@ -20,6 +20,7 @@
20
20
  "dist",
21
21
  "vectors/kithmoot-vectors.json",
22
22
  "vectors/circle-vectors.json",
23
+ "vectors/member-epoch-vectors.json",
23
24
  "LICENSE",
24
25
  "README.md",
25
26
  "llms.txt"
@@ -31,6 +32,7 @@
31
32
  "typecheck": "tsc -p tsconfig.typecheck.json",
32
33
  "diff-source": "node scripts/diff-source.mjs",
33
34
  "generate-fold": "node scripts/generate-fold.mjs",
35
+ "generate-member-epoch": "node scripts/generate-member-epoch.mjs",
34
36
  "bundle-check": "node scripts/bundle-check.mjs",
35
37
  "tarball-smoke": "node scripts/tarball-smoke.mjs",
36
38
  "vectors": "vitest run vectors/",