@forgesworn/fold-kit 0.2.0 → 0.3.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
@@ -113,7 +113,16 @@ the rest).
113
113
  `requestRoomAdmission` - the v2 live rendezvous invitation, its delegation
114
114
  chain and retirement
115
115
  - `encodePersistentInvitation`, `decodePersistentInvitation`,
116
- `requestPersistentRoomAdmission` - the v3 stored group invitation (1463)
116
+ `requestPersistentRoomAdmission` - the v3 stored group invitation (1463).
117
+ Pass `endsAt` (unix seconds, after `now` and at most 30 days on) for a
118
+ conference room: the encrypted body carries `ends` and the event a NIP-40
119
+ `expiration` tag, so relays drop it when the room ends; the decoder returns
120
+ `endsAt` and refuses a tag that disagrees with the body
121
+ - `withExpiration`, `isRoomEnds`, `requireRoomEnds`, `MAX_ROOM_ENDS_SECONDS` -
122
+ the conference-room expiration rule: add the end as an `expiration` tag,
123
+ keep an earlier one, lower a later one. `encodeInvitationRetirement` takes
124
+ `endsAt`, and the epoch encoders, `hostRoomEpoch` and `requestRoomEpoch`
125
+ take `expiresAt`, to tag what they sign the same way
117
126
 
118
127
  ### Epochs (removal by rekey)
119
128
 
package/dist/epoch.d.ts CHANGED
@@ -104,6 +104,9 @@ export interface EncodeRekeyOptions {
104
104
  * says so rather than leaving everybody to wonder. */
105
105
  closed?: boolean;
106
106
  now: number;
107
+ /** A conference room's end, in unix seconds: the event carries it as a
108
+ * NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
109
+ expiresAt?: number;
107
110
  }
108
111
  /** Announce the next epoch. See the module comment for what this is. */
109
112
  export declare function encodeRekeyEvent(opts: EncodeRekeyOptions): Event;
@@ -189,6 +192,9 @@ export interface EncodeEpochRequestOptions {
189
192
  credential: DeviceCredential;
190
193
  proof?: KindredProof;
191
194
  now: number;
195
+ /** A conference room's end, in unix seconds: the event carries it as a
196
+ * NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
197
+ expiresAt?: number;
192
198
  }
193
199
  /** Ask the authority which epoch the room is at, and for its secret. */
194
200
  export declare function encodeEpochRequest(opts: EncodeEpochRequestOptions): Event;
@@ -226,6 +232,9 @@ export interface EncodeEpochGrantOptions {
226
232
  epoch?: RoomEpoch;
227
233
  removed?: string[];
228
234
  refused?: EpochRefusal;
235
+ /** A conference room's end, in unix seconds: the event carries it as a
236
+ * NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
237
+ expiresAt?: number;
229
238
  }
230
239
  /** Answer one request: the current epoch sealed to the asking device, or a
231
240
  * refusal it alone can read. */
@@ -268,6 +277,8 @@ export interface HostRoomEpochOptions {
268
277
  now?: () => number;
269
278
  onGranted?: (request: EpochRequest) => void;
270
279
  onRefused?: (request: EpochRequest, why: EpochRefusal) => void;
280
+ /** A conference room's end: every grant carries it as an expiration. */
281
+ expiresAt?: number;
271
282
  }
272
283
  /**
273
284
  * Answer epoch requests for as long as the handle is open. What a keeper
@@ -294,6 +305,8 @@ export interface RequestRoomEpochOptions {
294
305
  now?: () => number;
295
306
  timeoutMs?: number;
296
307
  retryMs?: number;
308
+ /** A conference room's end: the request carries it as an expiration. */
309
+ expiresAt?: number;
297
310
  }
298
311
  /** Thrown when the authority answered, and the answer was no. */
299
312
  export declare class EpochRefusedError extends Error {
package/dist/epoch.js CHANGED
@@ -12,6 +12,7 @@ import { deriveRoom } from './room.js';
12
12
  import { verifyDeviceCredential } from './credential.js';
13
13
  import { evaluateAccess } from './access.js';
14
14
  import { verifyEventUncached } from './verify.js';
15
+ import { withExpiration } from './expiration.js';
15
16
  /**
16
17
  * Room epochs: how a member is removed.
17
18
  *
@@ -144,10 +145,10 @@ export function encodeRekeyEvent(opts) {
144
145
  return finalizeEvent({
145
146
  kind: KINDS.ROOM_REKEY,
146
147
  created_at: opts.now,
147
- tags: [
148
+ tags: withExpiration([
148
149
  ['d', roomId],
149
150
  ['epoch', String(epoch)],
150
- ],
151
+ ], opts.expiresAt),
151
152
  content: nip44.v2.encrypt(JSON.stringify(body), opts.current.key),
152
153
  }, opts.authoritySk);
153
154
  }
@@ -282,10 +283,10 @@ export function encodeEpochRequest(opts) {
282
283
  return finalizeEvent({
283
284
  kind: KINDS.EPOCH_REQUEST,
284
285
  created_at: opts.now,
285
- tags: [
286
+ tags: withExpiration([
286
287
  ['d', roomId],
287
288
  ['p', authority],
288
- ],
289
+ ], opts.expiresAt),
289
290
  content: nip44.v2.encrypt(JSON.stringify(body), nip44.v2.utils.getConversationKey(opts.deviceSk, authority)),
290
291
  }, opts.deviceSk);
291
292
  }
@@ -367,10 +368,10 @@ export function encodeEpochGrant(opts) {
367
368
  return finalizeEvent({
368
369
  kind: KINDS.EPOCH_GRANT,
369
370
  created_at: opts.now,
370
- tags: [
371
+ tags: withExpiration([
371
372
  ['d', roomId],
372
373
  ['p', device],
373
- ],
374
+ ], opts.expiresAt),
374
375
  content: nip44.v2.encrypt(JSON.stringify(body), nip44.v2.utils.getConversationKey(opts.authoritySk, device)),
375
376
  }, opts.authoritySk);
376
377
  }
@@ -458,7 +459,7 @@ export function hostRoomEpoch(opts) {
458
459
  let grant;
459
460
  try {
460
461
  grant = refused
461
- ? encodeEpochGrant({ roomId, authoritySk: opts.authoritySk, device: request.device, request: request.request, now: now(), refused })
462
+ ? encodeEpochGrant({ roomId, authoritySk: opts.authoritySk, device: request.device, request: request.request, now: now(), refused, expiresAt: opts.expiresAt })
462
463
  : encodeEpochGrant({
463
464
  roomId,
464
465
  authoritySk: opts.authoritySk,
@@ -467,6 +468,7 @@ export function hostRoomEpoch(opts) {
467
468
  now: now(),
468
469
  epoch: opts.current(),
469
470
  removed: [...opts.removed()],
471
+ expiresAt: opts.expiresAt,
470
472
  });
471
473
  }
472
474
  catch {
@@ -511,6 +513,7 @@ export function requestRoomEpoch(opts) {
511
513
  credential: opts.credential,
512
514
  proof: opts.proof,
513
515
  now: now(),
516
+ expiresAt: opts.expiresAt,
514
517
  });
515
518
  return new Promise((resolve, reject) => {
516
519
  let settled = false;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * NIP-40 expiration for a room that ends: a conference room.
3
+ *
4
+ * A conference room is a persistent group with a fixed end time. Every event
5
+ * signed for it carries `['expiration', String(ends)]`, so relays that honour
6
+ * NIP-40 drop the room's traffic once it has ended, rather than holding it
7
+ * for as long as they keep anything. Not new in this kit: the shape is the
8
+ * plain NIP-40 tag; what is decided here is how it meets an event that
9
+ * already carries one of its own (a signal wrap, a call bell).
10
+ */
11
+ /** The furthest ahead a conference room may end, from the moment it is
12
+ * created: thirty days. */
13
+ export declare const MAX_ROOM_ENDS_SECONDS: number;
14
+ /** True for a whole number of unix seconds a room could end at: a positive
15
+ * safe integer. Says nothing about whether it is in the future. */
16
+ export declare function isRoomEnds(value: unknown): value is number;
17
+ /** Throws unless `endsAt` is a valid end for a room created at `now`: after
18
+ * `now`, and no more than `MAX_ROOM_ENDS_SECONDS` beyond it. */
19
+ export declare function requireRoomEnds(endsAt: number, now: number): number;
20
+ /**
21
+ * `tags` with the room's end applied as a NIP-40 expiration.
22
+ *
23
+ * An event with no expiration gains `['expiration', String(expiresAt)]`. One
24
+ * that already expires earlier keeps its own: the room ending does not make
25
+ * a short-lived event live longer. One that expires later is lowered to
26
+ * `expiresAt`. Never more than one expiration tag comes back; any extra is
27
+ * folded into the earliest. `expiresAt` undefined returns `tags` untouched,
28
+ * the same array, so a room with no end signs exactly the bytes it always
29
+ * did.
30
+ */
31
+ export declare function withExpiration(tags: string[][], expiresAt: number | undefined): string[][];
@@ -0,0 +1,62 @@
1
+ /**
2
+ * NIP-40 expiration for a room that ends: a conference room.
3
+ *
4
+ * A conference room is a persistent group with a fixed end time. Every event
5
+ * signed for it carries `['expiration', String(ends)]`, so relays that honour
6
+ * NIP-40 drop the room's traffic once it has ended, rather than holding it
7
+ * for as long as they keep anything. Not new in this kit: the shape is the
8
+ * plain NIP-40 tag; what is decided here is how it meets an event that
9
+ * already carries one of its own (a signal wrap, a call bell).
10
+ */
11
+ /** The furthest ahead a conference room may end, from the moment it is
12
+ * created: thirty days. */
13
+ export const MAX_ROOM_ENDS_SECONDS = 30 * 24 * 60 * 60;
14
+ /** True for a whole number of unix seconds a room could end at: a positive
15
+ * safe integer. Says nothing about whether it is in the future. */
16
+ export function isRoomEnds(value) {
17
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
18
+ }
19
+ /** Throws unless `endsAt` is a valid end for a room created at `now`: after
20
+ * `now`, and no more than `MAX_ROOM_ENDS_SECONDS` beyond it. */
21
+ export function requireRoomEnds(endsAt, now) {
22
+ if (!isRoomEnds(endsAt))
23
+ throw new Error('a room end must be a positive whole number of seconds');
24
+ if (endsAt <= now)
25
+ throw new Error('a room cannot end in the past');
26
+ if (endsAt > now + MAX_ROOM_ENDS_SECONDS)
27
+ throw new Error('a room cannot end more than 30 days from now');
28
+ return endsAt;
29
+ }
30
+ /**
31
+ * `tags` with the room's end applied as a NIP-40 expiration.
32
+ *
33
+ * An event with no expiration gains `['expiration', String(expiresAt)]`. One
34
+ * that already expires earlier keeps its own: the room ending does not make
35
+ * a short-lived event live longer. One that expires later is lowered to
36
+ * `expiresAt`. Never more than one expiration tag comes back; any extra is
37
+ * folded into the earliest. `expiresAt` undefined returns `tags` untouched,
38
+ * the same array, so a room with no end signs exactly the bytes it always
39
+ * did.
40
+ */
41
+ export function withExpiration(tags, expiresAt) {
42
+ if (expiresAt === undefined)
43
+ return tags;
44
+ if (!isRoomEnds(expiresAt))
45
+ throw new Error('an expiration must be a positive whole number of seconds');
46
+ let earliest = expiresAt;
47
+ let first = -1;
48
+ tags.forEach((tag, i) => {
49
+ if (tag[0] !== 'expiration')
50
+ return;
51
+ if (first === -1)
52
+ first = i;
53
+ const own = Number(tag[1]);
54
+ if (isRoomEnds(own) && own < earliest)
55
+ earliest = own;
56
+ });
57
+ const out = tags.filter((tag, i) => tag[0] !== 'expiration' || i === first);
58
+ const tag = ['expiration', String(earliest)];
59
+ if (first === -1)
60
+ return [...out, tag];
61
+ return out.map((t) => (t[0] === 'expiration' ? tag : t));
62
+ }
package/dist/index.d.ts CHANGED
@@ -20,6 +20,7 @@ export { INVITATION_DELEGATION_TTL_SECONDS, MAX_INVITATION_DELEGATION_DEPTH, cre
20
20
  export type { RoomInvitation, RoomInvitationHost, InvitationDelegation, RoomInvitationDelegate, RoomAdmission, EncodeInvitationRequestOptions, InvitationRequest, DecodeInvitationRequestOptions, EncodeInvitationGrantOptions, DecodeInvitationGrantOptions, HostRoomInvitationOptions, EncodeInvitationRetirementOptions, RequestRoomAdmissionOptions, } from './invitation.js';
21
21
  export { encodePersistentInvitation, decodePersistentInvitation, requestPersistentRoomAdmission, PERSISTENT_INVITATION_LABELS, } from './persistent-invitation.js';
22
22
  export type { PersistentRoomAdmission } from './persistent-invitation.js';
23
+ export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } from './expiration.js';
23
24
  export { MAX_ROOM_LINK_FRAGMENT_LENGTH, parseRoomLink, encodeRoomLink, } from './link.js';
24
25
  export type { RoomLink } from './link.js';
25
26
  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
@@ -13,6 +13,7 @@ export { sanitiseDisplayName, MAX_DISPLAY_NAME_LENGTH } from './display-name.js'
13
13
  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
+ export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } from './expiration.js';
16
17
  export { MAX_ROOM_LINK_FRAGMENT_LENGTH, parseRoomLink, encodeRoomLink, } from './link.js';
17
18
  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';
18
19
  export { deriveChannel, CHANNEL_ID_INFO, CHANNEL_KEY_INFO, MAX_CHANNEL_NAME_LENGTH, CHANNEL_LABELS } from './channel.js';
@@ -165,6 +165,9 @@ export interface EncodeInvitationRetirementOptions {
165
165
  /** The room itself was ended, not just this link replaced. Additive: a
166
166
  * reader that predates it still sees an ordinary retirement. */
167
167
  ended?: boolean;
168
+ /** A conference room's end, in unix seconds: the tombstone carries the
169
+ * same NIP-40 expiration as the invitation it retires, and lapses with it. */
170
+ endsAt?: number;
168
171
  }
169
172
  /** What a joiner is told when a link was retired because its room ended. */
170
173
  export declare const ROOM_ENDED_MESSAGE = "this room was ended by the person who started it";
@@ -8,6 +8,7 @@ import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure
8
8
  import { hexEquals, normaliseHex } from './hex.js';
9
9
  import { KINDS } from './kinds.js';
10
10
  import { verifyEventUncached } from './verify.js';
11
+ import { withExpiration } from './expiration.js';
11
12
  import { deriveRoom } from './room.js';
12
13
  const INVITATION_ID_INFO = 'kithmoot/v2/invitation-id';
13
14
  const INVITATION_REQUEST_KEY_INFO = 'kithmoot/v2/invitation-request-key';
@@ -281,7 +282,7 @@ export function encodeInvitationRetirement(opts) {
281
282
  return finalizeEvent({
282
283
  kind: KINDS.INVITATION_RETIREMENT,
283
284
  created_at: opts.now,
284
- tags: [['d', deriveInvitationId(opts.invitation)]],
285
+ tags: withExpiration([['d', deriveInvitationId(opts.invitation)]], opts.endsAt),
285
286
  content: JSON.stringify(opts.ended ? { v: 1, ended: true } : { v: 1 }),
286
287
  }, opts.inviterSk);
287
288
  }
@@ -7,6 +7,9 @@ export interface PersistentRoomAdmission {
7
7
  secret: Uint8Array;
8
8
  persistent: true;
9
9
  epoch: 0;
10
+ /** When the room ends, in unix seconds: a conference room. Absent for a
11
+ * group that runs until somebody ends it. */
12
+ endsAt?: number;
10
13
  }
11
14
  /** Publish once before sharing. The link plus this envelope is a durable
12
15
  * bearer capability: anybody holding both can learn epoch 0. Retirement
@@ -16,6 +19,10 @@ export declare function encodePersistentInvitation(opts: {
16
19
  inviterSk: Uint8Array;
17
20
  roomSecret: Uint8Array;
18
21
  now: number;
22
+ /** A conference room's end, in unix seconds: after `now` and no more than
23
+ * 30 days beyond it. Carried in the body and as a NIP-40 expiration, so
24
+ * relays drop the invitation when the room ends. */
25
+ endsAt?: number;
19
26
  }): Event;
20
27
  export declare function decodePersistentInvitation(event: Event, invitation: RoomInvitation): PersistentRoomAdmission | null;
21
28
  /** Fetch the signed envelope and retirement in ONE stored-event query.
@@ -7,6 +7,7 @@ import { decodeInvitationRetirementNotice, deriveInvitationId, retirementError }
7
7
  import { KINDS } from './kinds.js';
8
8
  import { deriveRoom } from './room.js';
9
9
  import { verifyEventUncached } from './verify.js';
10
+ import { isRoomEnds, requireRoomEnds } from './expiration.js';
10
11
  function welcomeKey(invitation) {
11
12
  if (!invitation.persistent)
12
13
  throw new Error('a persistent group invitation is required');
@@ -21,12 +22,13 @@ export function encodePersistentInvitation(opts) {
21
22
  if (getPublicKey(opts.inviterSk) !== opts.invitation.inviter)
22
23
  throw new Error('only the inviter can publish a group invitation');
23
24
  const room = deriveRoom(opts.roomSecret).roomId;
25
+ const ends = opts.endsAt === undefined ? undefined : requireRoomEnds(opts.endsAt, opts.now);
24
26
  return finalizeEvent({
25
27
  kind: KINDS.GROUP_INVITATION,
26
28
  created_at: opts.now,
27
- tags: [['d', deriveInvitationId(opts.invitation)]],
29
+ tags: ends === undefined ? [['d', deriveInvitationId(opts.invitation)]] : [['d', deriveInvitationId(opts.invitation)], ['expiration', String(ends)]],
28
30
  content: nip44.v2.encrypt(JSON.stringify({
29
- v: 3, room, secret: base64urlnopad.encode(opts.roomSecret),
31
+ v: 3, room, secret: base64urlnopad.encode(opts.roomSecret), ...(ends === undefined ? {} : { ends }),
30
32
  }), welcomeKey(opts.invitation)),
31
33
  }, opts.inviterSk);
32
34
  }
@@ -45,7 +47,16 @@ export function decodePersistentInvitation(event, invitation) {
45
47
  const secret = base64urlnopad.decode(body.secret);
46
48
  if (deriveRoom(secret).roomId !== body.room)
47
49
  return null;
48
- return { secret, persistent: true, epoch: 0 };
50
+ // A conference room's end rides in the body and, for relays, as a NIP-40
51
+ // expiration. The two must agree: a tag with no body end, a second tag,
52
+ // or an end that is not a whole number of seconds is refused outright.
53
+ const ends = body.ends === undefined ? undefined : isRoomEnds(body.ends) ? body.ends : null;
54
+ const expirations = event.tags.filter(t => t[0] === 'expiration');
55
+ if (ends === null || expirations.length > 1)
56
+ return null;
57
+ if (expirations.length === 1 && (ends === undefined || expirations[0][1] !== String(ends)))
58
+ return null;
59
+ return ends === undefined ? { secret, persistent: true, epoch: 0 } : { secret, persistent: true, epoch: 0, endsAt: ends };
49
60
  }
50
61
  catch {
51
62
  return null;
@@ -94,6 +105,10 @@ export function requestPersistentRoomAdmission(opts) {
94
105
  finish(new Error('the group invitation names conflicting rooms'));
95
106
  return;
96
107
  }
108
+ // Two signed copies that disagree on when the room ends: the earlier
109
+ // end stands, so a stale copy can never keep a room open longer.
110
+ if (admission?.endsAt !== undefined && (decoded.endsAt === undefined || decoded.endsAt > admission.endsAt))
111
+ decoded.endsAt = admission.endsAt;
97
112
  admission = decoded;
98
113
  }, () => {
99
114
  if (admission)
package/llms.txt CHANGED
@@ -66,6 +66,9 @@ Not yet published to npm under this API. Until it is, pin an immutable Git commi
66
66
  - `hostRoomInvitation(opts)` / `requestRoomAdmission(opts)` - the live desk, both sides
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
+ - 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
+ - `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
+ - Epoch encoders (`encodeRekeyEvent`, `encodeEpochRequest`, `encodeEpochGrant`) and `hostRoomEpoch` / `requestRoomEpoch` take `expiresAt?` for a conference room
69
72
 
70
73
  ### Epochs (removal by rekey)
71
74
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgesworn/fold-kit",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",