@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 +10 -1
- package/dist/epoch.d.ts +13 -0
- package/dist/epoch.js +10 -7
- package/dist/expiration.d.ts +31 -0
- package/dist/expiration.js +62 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/invitation.d.ts +3 -0
- package/dist/invitation.js +2 -1
- package/dist/persistent-invitation.d.ts +7 -0
- package/dist/persistent-invitation.js +18 -3
- package/llms.txt +3 -0
- package/package.json +1 -1
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';
|
package/dist/invitation.d.ts
CHANGED
|
@@ -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";
|
package/dist/invitation.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|