@forgesworn/fold-kit 0.8.0 → 0.10.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 +37 -0
- package/dist/epoch.d.ts +8 -0
- package/dist/epoch.js +5 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/invitation.d.ts +16 -2
- package/dist/invitation.js +32 -6
- package/dist/live-persistent-admission.d.ts +52 -0
- package/dist/live-persistent-admission.js +183 -0
- package/dist/member-epoch.d.ts +2 -0
- package/dist/member-epoch.js +2 -0
- package/dist/persistent-invitation.d.ts +7 -0
- package/dist/persistent-invitation.js +11 -1
- package/llms.txt +20 -0
- package/package.json +7 -3
- package/vectors/destruct-vectors.json +675 -0
- package/vectors/live-persistent-vectors.json +1240 -0
package/README.md
CHANGED
|
@@ -104,6 +104,15 @@ the rest).
|
|
|
104
104
|
|
|
105
105
|
### Links and invitations
|
|
106
106
|
|
|
107
|
+
`hostRoomInvitation` distinguishes approval from grant publication. Its
|
|
108
|
+
`onGrantPublished(request)` and legacy `onAdmitted(device)` callbacks run only
|
|
109
|
+
after the injected transport acknowledges the grant's publication.
|
|
110
|
+
`onGrantFailed(request, error)` reports a rejected or throwing publication;
|
|
111
|
+
neither success callback runs for that failure. Closing or retiring the host
|
|
112
|
+
suppresses callbacks from outstanding sends. Publication acknowledgement does
|
|
113
|
+
not prove that a guest received the grant or joined; the guest validates its
|
|
114
|
+
grant independently. Observer exceptions do not change the publish outcome.
|
|
115
|
+
|
|
107
116
|
- `parseRoomLink`, `encodeRoomLink`, `RoomLink` - the v1/v2/v3 link envelope
|
|
108
117
|
- `createRoomInvitation`, `roomInvitation`, `deriveInvitationId`,
|
|
109
118
|
`encodeInvitationRequest`, `decodeInvitationRequest`,
|
|
@@ -126,6 +135,16 @@ the rest).
|
|
|
126
135
|
`requestPersistentRoomAdmission` keeps the relays of the newest signed
|
|
127
136
|
copy that names any. `isInvitationRelays`, `requireInvitationRelays` and
|
|
128
137
|
`MAX_INVITATION_RELAYS` are the rule
|
|
138
|
+
- Self-destructing rooms (`docs/room-destruct.md`): pass `destruct: true`
|
|
139
|
+
and the encrypted body carries it after `ends` (no end needed: the room
|
|
140
|
+
then self-destructs when its authority closes it). The decoder returns
|
|
141
|
+
`destruct: true` and refuses the envelope on any other value;
|
|
142
|
+
`requestPersistentRoomAdmission` keeps it if any signed copy says it.
|
|
143
|
+
`encodeInvitationRetirement({ ended: true, destruct: true })` and
|
|
144
|
+
`encodeRekeyEvent({ closed: true, destruct: true })` carry it when the
|
|
145
|
+
room ends early, read back by `decodeInvitationRetirementNotice`,
|
|
146
|
+
`RekeyNotice.destruct` and `RekeyEvidence.destruct`. Vectors:
|
|
147
|
+
`vectors/destruct-vectors.json`
|
|
129
148
|
- `withExpiration`, `isRoomEnds`, `requireRoomEnds`, `MAX_ROOM_ENDS_SECONDS` -
|
|
130
149
|
the conference-room expiration rule: add the end as an `expiration` tag,
|
|
131
150
|
keep an earlier one, lower a later one. `encodeInvitationRetirement` takes
|
|
@@ -253,3 +272,21 @@ There is no separate lint script.
|
|
|
253
272
|
## Licence
|
|
254
273
|
|
|
255
274
|
MIT
|
|
275
|
+
|
|
276
|
+
### Live persistent admission (opt-in codecs)
|
|
277
|
+
|
|
278
|
+
`parseLivePersistentEvent` bounds raw event JSON and refuses duplicate fields.
|
|
279
|
+
`encodeLivePersistentDescriptor` / `decodeLivePersistentDescriptor` carry
|
|
280
|
+
routing metadata alongside an unchanged persistent invitation link.
|
|
281
|
+
`encodeLivePersistentRequest` / `decodeLivePersistentRequest` and
|
|
282
|
+
`encodeLivePersistentAnswer` / `decodeLivePersistentAnswer` implement a fresh,
|
|
283
|
+
root-signed challenge/answer with no delegation. The answer retains epoch-zero
|
|
284
|
+
capability separately from `epochHint`. Constants bound request/answer lifetimes
|
|
285
|
+
to 90/30 seconds. `LIVE_PERSISTENT_ADMISSION_LABELS` freezes its distinct KDF.
|
|
286
|
+
|
|
287
|
+
These functions are codecs, not an offline join API. The host must have exclusive
|
|
288
|
+
durable lifecycle authority; the client must complete authenticated epoch
|
|
289
|
+
admission before publishing, including at epoch zero. No application enables
|
|
290
|
+
this profile automatically. See [the wire profile and integration gates](docs/live-persistent-admission.md).
|
|
291
|
+
Frozen synthetic fixtures are in `vectors/live-persistent-vectors.json`; run
|
|
292
|
+
`npm run build && npm run generate-live-persistent` to regenerate them.
|
package/dist/epoch.d.ts
CHANGED
|
@@ -111,6 +111,10 @@ export interface EncodeRekeyOptions {
|
|
|
111
111
|
/** True when the room is being closed: nobody is kept, and the event
|
|
112
112
|
* says so rather than leaving everybody to wonder. */
|
|
113
113
|
closed?: boolean;
|
|
114
|
+
/** The room being closed self-destructs: every device deletes what it
|
|
115
|
+
* wrote and forgets the room. Inside the encrypted body. Refused
|
|
116
|
+
* without `closed`. Omitted, the event is byte-identical to before. */
|
|
117
|
+
destruct?: boolean;
|
|
114
118
|
/** Mark this as a scheduled turn of the key rather than a removal, so
|
|
115
119
|
* clients can let it pass quietly. Refused with a removal or a close: a
|
|
116
120
|
* rekey that removes somebody is never quiet. Omitted, the event is
|
|
@@ -144,6 +148,10 @@ export interface RekeyNotice {
|
|
|
144
148
|
removed: string[];
|
|
145
149
|
by?: string;
|
|
146
150
|
closed: boolean;
|
|
151
|
+
/** True when the closed room self-destructs. Absent unless `closed`:
|
|
152
|
+
* beside an open room the flag is not believed, and the rekey is read
|
|
153
|
+
* as if it were not there. */
|
|
154
|
+
destruct?: true;
|
|
147
155
|
/** True for a scheduled turn of the key: nobody removed, the room still
|
|
148
156
|
* open. What lets a client move on without saying so. Absent when the
|
|
149
157
|
* body contradicts itself, so a removal is always announced. */
|
package/dist/epoch.js
CHANGED
|
@@ -145,6 +145,8 @@ export function encodeRekeyEvent(opts) {
|
|
|
145
145
|
const removed = [...new Set(opts.removed.map((p) => requireHex32(p, 'removed participant')))].sort();
|
|
146
146
|
if (opts.scheduled && (removed.length > 0 || opts.closed))
|
|
147
147
|
throw new Error('a scheduled rekey removes nobody and does not close the room');
|
|
148
|
+
if (opts.destruct && !opts.closed)
|
|
149
|
+
throw new Error('only a closing rekey can self-destruct the room');
|
|
148
150
|
const keys = {};
|
|
149
151
|
const sealed = { v: 1, secret: base64urlnopad.encode(opts.next.secret) };
|
|
150
152
|
const plaintext = JSON.stringify(sealed);
|
|
@@ -159,6 +161,7 @@ export function encodeRekeyEvent(opts) {
|
|
|
159
161
|
removed,
|
|
160
162
|
...(opts.by !== undefined ? { by: requireHex32(opts.by, 'admin') } : {}),
|
|
161
163
|
...(opts.closed ? { closed: true } : {}),
|
|
164
|
+
...(opts.destruct ? { destruct: true } : {}),
|
|
162
165
|
...(opts.scheduled ? { scheduled: true } : {}),
|
|
163
166
|
...(opts.commit ? { commit: epochCommitment(roomId, epoch, opts.next.secret) } : {}),
|
|
164
167
|
...(opts.members !== undefined ? { members: memberListOf(opts.members, removed) } : {}),
|
|
@@ -226,6 +229,8 @@ export function decodeRekeyEvent(event, opts) {
|
|
|
226
229
|
};
|
|
227
230
|
if (typeof body.by === 'string' && HEX64.test(body.by))
|
|
228
231
|
notice.by = normaliseHex(body.by);
|
|
232
|
+
if (body.destruct === true && notice.closed)
|
|
233
|
+
notice.destruct = true;
|
|
229
234
|
if (body.scheduled === true && notice.removed.length === 0 && !notice.closed)
|
|
230
235
|
notice.scheduled = true;
|
|
231
236
|
const members = readMemberList(body.members);
|
package/dist/index.d.ts
CHANGED
|
@@ -34,3 +34,4 @@ export type { RekeyEvidence, EncodeMemberEpochRequestOptions, DecodeMemberEpochR
|
|
|
34
34
|
export { deriveChannel, CHANNEL_ID_INFO, CHANNEL_KEY_INFO, MAX_CHANNEL_NAME_LENGTH, CHANNEL_LABELS } from './channel.js';
|
|
35
35
|
export { LANES, LANE_MEANING, LANE_LABEL, LANE_GLYPH, isLane, laneOfRelayUrl, laneOfRelays, weakestLane, isDowngrade, } from './lane.js';
|
|
36
36
|
export type { Lane } from './lane.js';
|
|
37
|
+
export * from "./live-persistent-admission.js";
|
package/dist/index.js
CHANGED
|
@@ -22,3 +22,5 @@ export { EPOCH_COMMIT_PREFIX, epochCommitment, EPOCH_COMMIT_LABELS } from './epo
|
|
|
22
22
|
export { MEMBER_EPOCH_KINDS, MEMBER_EPOCH_REQUEST_KEY_INFO, MAX_MEMBER_EPOCH_CHAIN, deriveMemberEpochRequestKey, readRekeyEvidence, encodeMemberEpochRequest, decodeMemberEpochRequest, encodeMemberEpochGrant, decodeMemberEpochGrant, hostMemberEpochDesk, memberEpochSource, requestMemberEpoch, MEMBER_EPOCH_LABELS, } from './member-epoch.js';
|
|
23
23
|
export { deriveChannel, CHANNEL_ID_INFO, CHANNEL_KEY_INFO, MAX_CHANNEL_NAME_LENGTH, CHANNEL_LABELS } from './channel.js';
|
|
24
24
|
export { LANES, LANE_MEANING, LANE_LABEL, LANE_GLYPH, isLane, laneOfRelayUrl, laneOfRelays, weakestLane, isDowngrade, } from './lane.js';
|
|
25
|
+
// Opt-in live persistent capability exchange; host lifecycle and epoch gates remain required.
|
|
26
|
+
export * from "./live-persistent-admission.js";
|
package/dist/invitation.d.ts
CHANGED
|
@@ -139,7 +139,13 @@ export interface HostRoomInvitationOptions {
|
|
|
139
139
|
/** Root-to-this-responder chain. Empty/absent only on the creator. */
|
|
140
140
|
delegation?: InvitationDelegation[];
|
|
141
141
|
now?: () => number;
|
|
142
|
+
/** Called after the injected transport acknowledges publication. This is
|
|
143
|
+
* not proof that the guest received the grant or joined the room. */
|
|
142
144
|
onAdmitted?: (device: string) => void;
|
|
145
|
+
/** The same publication acknowledgement, correlated to the request. */
|
|
146
|
+
onGrantPublished?: (request: InvitationRequest) => void;
|
|
147
|
+
/** Publication rejected or threw. No admission callback is made. */
|
|
148
|
+
onGrantFailed?: (request: InvitationRequest, error: unknown) => void;
|
|
143
149
|
/** Called when the creator's durable retirement tombstone is heard. */
|
|
144
150
|
onRetired?: () => void;
|
|
145
151
|
/** The epoch this responder is at, asked on every grant because it
|
|
@@ -165,6 +171,12 @@ export interface EncodeInvitationRetirementOptions {
|
|
|
165
171
|
/** The room itself was ended, not just this link replaced. Additive: a
|
|
166
172
|
* reader that predates it still sees an ordinary retirement. */
|
|
167
173
|
ended?: boolean;
|
|
174
|
+
/** The ended room self-destructs: every device deletes what it wrote and
|
|
175
|
+
* forgets the room. Only beside `ended`, else it throws. Unlike the
|
|
176
|
+
* invitation's own flag this content is not encrypted: it says no more
|
|
177
|
+
* than `ended` does about a link nobody outside the room can name.
|
|
178
|
+
* Omitted or false, the event is byte-identical to 0.8.0's. */
|
|
179
|
+
destruct?: boolean;
|
|
168
180
|
/** A conference room's end, in unix seconds: the tombstone carries the
|
|
169
181
|
* same NIP-40 expiration as the invitation it retires, and lapses with it. */
|
|
170
182
|
endsAt?: number;
|
|
@@ -176,10 +188,12 @@ export declare function encodeInvitationRetirement(opts: EncodeInvitationRetirem
|
|
|
176
188
|
/** A valid tombstone never expires: invitation ids are random and unique,
|
|
177
189
|
* and a retired bearer must not become usable again after a timeout. */
|
|
178
190
|
export declare function decodeInvitationRetirement(event: Event, invitation: RoomInvitation): boolean;
|
|
179
|
-
/** A valid retirement,
|
|
180
|
-
*
|
|
191
|
+
/** A valid retirement, whether it says the room was ended, and whether the
|
|
192
|
+
* ended room self-destructs (`destruct`, believed only beside `ended`).
|
|
193
|
+
* Undefined for anything that is not a valid retirement of this invitation. */
|
|
181
194
|
export declare function decodeInvitationRetirementNotice(event: Event, invitation: RoomInvitation): {
|
|
182
195
|
ended: boolean;
|
|
196
|
+
destruct?: true;
|
|
183
197
|
} | undefined;
|
|
184
198
|
/** The error a joiner rejects with on a retirement. */
|
|
185
199
|
export declare function retirementError(notice: {
|
package/dist/invitation.js
CHANGED
|
@@ -279,11 +279,13 @@ export function encodeInvitationRetirement(opts) {
|
|
|
279
279
|
if (!hexEquals(getPublicKey(opts.inviterSk), opts.invitation.inviter)) {
|
|
280
280
|
throw new Error('only the root inviter can retire an invitation');
|
|
281
281
|
}
|
|
282
|
+
if (opts.destruct && !opts.ended)
|
|
283
|
+
throw new Error('only an ended room can self-destruct');
|
|
282
284
|
return finalizeEvent({
|
|
283
285
|
kind: KINDS.INVITATION_RETIREMENT,
|
|
284
286
|
created_at: opts.now,
|
|
285
287
|
tags: withExpiration([['d', deriveInvitationId(opts.invitation)]], opts.endsAt),
|
|
286
|
-
content: JSON.stringify(opts.ended ? { v: 1, ended: true } : { v: 1 }),
|
|
288
|
+
content: JSON.stringify(opts.ended ? (opts.destruct ? { v: 1, ended: true, destruct: true } : { v: 1, ended: true }) : { v: 1 }),
|
|
287
289
|
}, opts.inviterSk);
|
|
288
290
|
}
|
|
289
291
|
/** A valid tombstone never expires: invitation ids are random and unique,
|
|
@@ -291,8 +293,9 @@ export function encodeInvitationRetirement(opts) {
|
|
|
291
293
|
export function decodeInvitationRetirement(event, invitation) {
|
|
292
294
|
return decodeInvitationRetirementNotice(event, invitation) !== undefined;
|
|
293
295
|
}
|
|
294
|
-
/** A valid retirement,
|
|
295
|
-
*
|
|
296
|
+
/** A valid retirement, whether it says the room was ended, and whether the
|
|
297
|
+
* ended room self-destructs (`destruct`, believed only beside `ended`).
|
|
298
|
+
* Undefined for anything that is not a valid retirement of this invitation. */
|
|
296
299
|
export function decodeInvitationRetirementNotice(event, invitation) {
|
|
297
300
|
try {
|
|
298
301
|
if (event.kind !== KINDS.INVITATION_RETIREMENT)
|
|
@@ -304,7 +307,9 @@ export function decodeInvitationRetirementNotice(event, invitation) {
|
|
|
304
307
|
if (event.tags.find((tag) => tag[0] === 'd')?.[1] !== deriveInvitationId(invitation))
|
|
305
308
|
return undefined;
|
|
306
309
|
const body = JSON.parse(event.content);
|
|
307
|
-
|
|
310
|
+
if (body.v !== 1)
|
|
311
|
+
return undefined;
|
|
312
|
+
return body.ended === true && body.destruct === true ? { ended: true, destruct: true } : { ended: body.ended === true };
|
|
308
313
|
}
|
|
309
314
|
catch {
|
|
310
315
|
return undefined;
|
|
@@ -394,8 +399,29 @@ export function hostRoomInvitation(opts) {
|
|
|
394
399
|
close();
|
|
395
400
|
return;
|
|
396
401
|
}
|
|
397
|
-
|
|
398
|
-
|
|
402
|
+
// The decision to approve and the publication of its grant are
|
|
403
|
+
// separate states. A rejected send must never look like admission.
|
|
404
|
+
Promise.resolve()
|
|
405
|
+
.then(() => { if (!closed)
|
|
406
|
+
return opts.transport.publish(grant); })
|
|
407
|
+
.then(() => {
|
|
408
|
+
if (closed)
|
|
409
|
+
return;
|
|
410
|
+
// One observer throwing must not change the publish outcome or
|
|
411
|
+
// prevent an independent observer from hearing it.
|
|
412
|
+
try {
|
|
413
|
+
opts.onGrantPublished?.(request);
|
|
414
|
+
}
|
|
415
|
+
catch { /* Observer only. */ }
|
|
416
|
+
try {
|
|
417
|
+
opts.onAdmitted?.(request.device);
|
|
418
|
+
}
|
|
419
|
+
catch { /* Observer only. */ }
|
|
420
|
+
}, error => {
|
|
421
|
+
if (!closed)
|
|
422
|
+
opts.onGrantFailed?.(request, error);
|
|
423
|
+
})
|
|
424
|
+
.catch(() => { });
|
|
399
425
|
};
|
|
400
426
|
if (!opts.admit) {
|
|
401
427
|
grantNow();
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type Event } from 'nostr-tools/pure';
|
|
2
|
+
import { type RoomInvitation } from './invitation.js';
|
|
3
|
+
import { type PersistentRoomAdmission } from './persistent-invitation.js';
|
|
4
|
+
export declare const LIVE_PERSISTENT_REQUEST_SECONDS = 90;
|
|
5
|
+
export declare const LIVE_PERSISTENT_RESPONSE_SECONDS = 30;
|
|
6
|
+
export interface LivePersistentContext {
|
|
7
|
+
invitation: RoomInvitation;
|
|
8
|
+
/** From the separate routing descriptor; authenticated again in the answer. */
|
|
9
|
+
roomId: string;
|
|
10
|
+
}
|
|
11
|
+
export interface LivePersistentRequest {
|
|
12
|
+
requestId: string;
|
|
13
|
+
requester: string;
|
|
14
|
+
createdAt: number;
|
|
15
|
+
expiresAt: number;
|
|
16
|
+
}
|
|
17
|
+
/** Capability evidence only. The caller must still perform epoch admission. */
|
|
18
|
+
export interface LivePersistentAnswer {
|
|
19
|
+
admission: PersistentRoomAdmission;
|
|
20
|
+
epochHint: number;
|
|
21
|
+
requestId: string;
|
|
22
|
+
expiresAt: number;
|
|
23
|
+
}
|
|
24
|
+
/** For raw input boundaries: bound bytes before parsing, retain exact fields,
|
|
25
|
+
* and reject duplicate JSON keys before a general event reader can discard them. */
|
|
26
|
+
export declare function parseLivePersistentEvent(json: string, request: boolean): Event | null;
|
|
27
|
+
/** Routing metadata only; never advertise the invitation bearer. */
|
|
28
|
+
export declare function encodeLivePersistentDescriptor(opts: LivePersistentContext): string;
|
|
29
|
+
export declare function decodeLivePersistentDescriptor(encoded: string, invitation: RoomInvitation): LivePersistentContext | null;
|
|
30
|
+
export declare function encodeLivePersistentRequest(opts: LivePersistentContext & {
|
|
31
|
+
requesterSk: Uint8Array;
|
|
32
|
+
now: number;
|
|
33
|
+
}): Event;
|
|
34
|
+
export declare function decodeLivePersistentRequest(event: Event, opts: LivePersistentContext & {
|
|
35
|
+
now: number;
|
|
36
|
+
}): LivePersistentRequest | null;
|
|
37
|
+
/** Codec only: callers must hold the durable lifecycle lock through handoff.
|
|
38
|
+
* This function cannot prove that the root's invitation is active. */
|
|
39
|
+
export declare function encodeLivePersistentAnswer(opts: LivePersistentContext & {
|
|
40
|
+
request: Event;
|
|
41
|
+
invitationEvent: Event;
|
|
42
|
+
inviterSk: Uint8Array;
|
|
43
|
+
epoch: number;
|
|
44
|
+
now: number;
|
|
45
|
+
}): Event;
|
|
46
|
+
/** Stateless proof reader. The request owner consumes a result at most once. */
|
|
47
|
+
export declare function decodeLivePersistentAnswer(event: Event, opts: LivePersistentContext & {
|
|
48
|
+
request: Event;
|
|
49
|
+
requesterSk: Uint8Array;
|
|
50
|
+
now: number;
|
|
51
|
+
}): LivePersistentAnswer | null;
|
|
52
|
+
export declare const LIVE_PERSISTENT_ADMISSION_LABELS: readonly ["kithmoot/v1/persistent-live/request-key"];
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { hkdf } from '@noble/hashes/hkdf';
|
|
2
|
+
import { sha256 } from '@noble/hashes/sha2';
|
|
3
|
+
import { base64urlnopad } from '@scure/base';
|
|
4
|
+
import { nip44 } from 'nostr-tools';
|
|
5
|
+
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
|
|
6
|
+
import { deriveInvitationId } from './invitation.js';
|
|
7
|
+
import { decodePersistentInvitation } from './persistent-invitation.js';
|
|
8
|
+
import { deriveRoom } from './room.js';
|
|
9
|
+
import { verifyEventUncached } from './verify.js';
|
|
10
|
+
import { KINDS } from './kinds.js';
|
|
11
|
+
const REQUEST_KEY = 'kithmoot/v1/persistent-live/request-key';
|
|
12
|
+
const PROFILE = 'persistent-live';
|
|
13
|
+
const HEX = /^[0-9a-f]{64}$/;
|
|
14
|
+
const SIG = /^[0-9a-f]{128}$/;
|
|
15
|
+
const utf8 = new TextEncoder();
|
|
16
|
+
const text = new TextDecoder('utf-8', { fatal: true });
|
|
17
|
+
export const LIVE_PERSISTENT_REQUEST_SECONDS = 90;
|
|
18
|
+
export const LIVE_PERSISTENT_RESPONSE_SECONDS = 30;
|
|
19
|
+
const SKEW = 5;
|
|
20
|
+
function check(ok) {
|
|
21
|
+
if (!ok)
|
|
22
|
+
throw new Error('invalid live persistent admission');
|
|
23
|
+
}
|
|
24
|
+
function integer(n) {
|
|
25
|
+
return typeof n === 'number' && Number.isSafeInteger(n) && n >= 0;
|
|
26
|
+
}
|
|
27
|
+
function clock(now) {
|
|
28
|
+
check(integer(now) && now <= Number.MAX_SAFE_INTEGER - LIVE_PERSISTENT_REQUEST_SECONDS);
|
|
29
|
+
}
|
|
30
|
+
function context(opts) {
|
|
31
|
+
check(opts.invitation.persistent === true && opts.invitation.bearer.length === 32);
|
|
32
|
+
check(HEX.test(opts.invitation.inviter) && HEX.test(opts.roomId));
|
|
33
|
+
}
|
|
34
|
+
function fields(raw, names) {
|
|
35
|
+
check(typeof raw === 'object' && raw !== null && !Array.isArray(raw));
|
|
36
|
+
const keys = Object.keys(raw);
|
|
37
|
+
check(keys.length === names.length && keys.every(k => names.includes(k)));
|
|
38
|
+
}
|
|
39
|
+
/** Minified JSON also rejects duplicate fields and ambiguous number spellings. */
|
|
40
|
+
function body(raw, names) {
|
|
41
|
+
const parsed = JSON.parse(raw);
|
|
42
|
+
fields(parsed, names);
|
|
43
|
+
check(JSON.stringify(parsed) === raw);
|
|
44
|
+
return parsed;
|
|
45
|
+
}
|
|
46
|
+
function key(invitation) {
|
|
47
|
+
return hkdf(sha256, invitation.bearer, undefined, REQUEST_KEY, 32);
|
|
48
|
+
}
|
|
49
|
+
function wire(event) {
|
|
50
|
+
return { id: event.id, pubkey: event.pubkey, created_at: event.created_at,
|
|
51
|
+
kind: event.kind, tags: event.tags, content: event.content, sig: event.sig };
|
|
52
|
+
}
|
|
53
|
+
/** Bound every field before serialising or verifying an attacker-supplied object. */
|
|
54
|
+
function eventBounds(raw, contentLimit, byteLimit, tagsLimit) {
|
|
55
|
+
fields(raw, ['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig']);
|
|
56
|
+
check(typeof raw.id === 'string' && HEX.test(raw.id) && typeof raw.pubkey === 'string' && HEX.test(raw.pubkey));
|
|
57
|
+
check(typeof raw.sig === 'string' && SIG.test(raw.sig) && integer(raw.kind) && integer(raw.created_at));
|
|
58
|
+
check(typeof raw.content === 'string' && raw.content.length <= contentLimit);
|
|
59
|
+
check(Array.isArray(raw.tags) && raw.tags.length <= tagsLimit);
|
|
60
|
+
for (const tag of raw.tags) {
|
|
61
|
+
check(Array.isArray(tag) && tag.length <= 4 && tag.every(v => typeof v === 'string' && v.length <= 256));
|
|
62
|
+
}
|
|
63
|
+
check(utf8.encode(JSON.stringify(raw)).length <= byteLimit);
|
|
64
|
+
}
|
|
65
|
+
/** For raw input boundaries: bound bytes before parsing, retain exact fields,
|
|
66
|
+
* and reject duplicate JSON keys before a general event reader can discard them. */
|
|
67
|
+
export function parseLivePersistentEvent(json, request) {
|
|
68
|
+
try {
|
|
69
|
+
const max = request ? 4096 : 20480;
|
|
70
|
+
check(typeof json === 'string' && json.length <= max && utf8.encode(json).length <= max);
|
|
71
|
+
const raw = JSON.parse(json);
|
|
72
|
+
check(JSON.stringify(raw) === json);
|
|
73
|
+
eventBounds(raw, request ? 2048 : 16384, max, 3);
|
|
74
|
+
return raw;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function envelope(event, opts, kind, recipient, now) {
|
|
81
|
+
context(opts);
|
|
82
|
+
clock(now);
|
|
83
|
+
const request = kind === KINDS.INVITATION_REQUEST;
|
|
84
|
+
eventBounds(event, request ? 2048 : 16384, request ? 4096 : 20480, 3);
|
|
85
|
+
check(event.kind === kind && event.created_at <= now + SKEW);
|
|
86
|
+
check(event.tags.length === 3 && event.tags.every(t => t.length === 2));
|
|
87
|
+
const tag = (name) => {
|
|
88
|
+
const matches = event.tags.filter(t => t[0] === name);
|
|
89
|
+
check(matches.length === 1);
|
|
90
|
+
return matches[0][1];
|
|
91
|
+
};
|
|
92
|
+
check(tag('d') === deriveInvitationId(opts.invitation) && tag('p') === recipient);
|
|
93
|
+
const expiresAt = Number(tag('expiration'));
|
|
94
|
+
check(integer(expiresAt) && String(expiresAt) === tag('expiration') && expiresAt > now);
|
|
95
|
+
check(verifyEventUncached(event));
|
|
96
|
+
return expiresAt;
|
|
97
|
+
}
|
|
98
|
+
/** Routing metadata only; never advertise the invitation bearer. */
|
|
99
|
+
export function encodeLivePersistentDescriptor(opts) {
|
|
100
|
+
context(opts);
|
|
101
|
+
return base64urlnopad.encode(utf8.encode(JSON.stringify({ v: 1, room: opts.roomId,
|
|
102
|
+
invitation: deriveInvitationId(opts.invitation), inviter: opts.invitation.inviter })));
|
|
103
|
+
}
|
|
104
|
+
export function decodeLivePersistentDescriptor(encoded, invitation) {
|
|
105
|
+
try {
|
|
106
|
+
check(typeof encoded === 'string' && encoded.length <= 512);
|
|
107
|
+
const bytes = base64urlnopad.decode(encoded);
|
|
108
|
+
check(bytes.length <= 384 && base64urlnopad.encode(bytes) === encoded);
|
|
109
|
+
const raw = body(text.decode(bytes), ['v', 'room', 'invitation', 'inviter']);
|
|
110
|
+
check(raw.v === 1 && typeof raw.room === 'string');
|
|
111
|
+
const opts = { invitation, roomId: raw.room };
|
|
112
|
+
context(opts);
|
|
113
|
+
check(raw.invitation === deriveInvitationId(invitation) && raw.inviter === invitation.inviter);
|
|
114
|
+
return opts;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export function encodeLivePersistentRequest(opts) {
|
|
121
|
+
context(opts);
|
|
122
|
+
clock(opts.now);
|
|
123
|
+
return finalizeEvent({ kind: KINDS.INVITATION_REQUEST, created_at: opts.now,
|
|
124
|
+
tags: [['d', deriveInvitationId(opts.invitation)], ['p', opts.invitation.inviter],
|
|
125
|
+
['expiration', String(opts.now + LIVE_PERSISTENT_REQUEST_SECONDS)]],
|
|
126
|
+
content: nip44.v2.encrypt(JSON.stringify({ v: 1, profile: PROFILE, room: opts.roomId,
|
|
127
|
+
requester: getPublicKey(opts.requesterSk) }), key(opts.invitation)),
|
|
128
|
+
}, opts.requesterSk);
|
|
129
|
+
}
|
|
130
|
+
export function decodeLivePersistentRequest(event, opts) {
|
|
131
|
+
try {
|
|
132
|
+
const expiresAt = envelope(event, opts, KINDS.INVITATION_REQUEST, opts.invitation.inviter, opts.now);
|
|
133
|
+
check(expiresAt === event.created_at + LIVE_PERSISTENT_REQUEST_SECONDS);
|
|
134
|
+
const raw = body(nip44.v2.decrypt(event.content, key(opts.invitation)), ['v', 'profile', 'room', 'requester']);
|
|
135
|
+
check(raw.v === 1 && raw.profile === PROFILE && raw.room === opts.roomId && raw.requester === event.pubkey);
|
|
136
|
+
return { requestId: event.id, requester: event.pubkey, createdAt: event.created_at, expiresAt };
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function invitationOf(event, opts, now, responseAt) {
|
|
143
|
+
eventBounds(event, 6144, 8192, 8);
|
|
144
|
+
check(event.created_at <= Math.min(now, responseAt) + SKEW);
|
|
145
|
+
const admission = decodePersistentInvitation(event, opts.invitation);
|
|
146
|
+
check(admission !== null && deriveRoom(admission.secret).roomId === opts.roomId);
|
|
147
|
+
check(admission.endsAt === undefined || admission.endsAt > now);
|
|
148
|
+
return admission;
|
|
149
|
+
}
|
|
150
|
+
/** Codec only: callers must hold the durable lifecycle lock through handoff.
|
|
151
|
+
* This function cannot prove that the root's invitation is active. */
|
|
152
|
+
export function encodeLivePersistentAnswer(opts) {
|
|
153
|
+
const request = decodeLivePersistentRequest(opts.request, opts);
|
|
154
|
+
check(request !== null && integer(opts.epoch) && getPublicKey(opts.inviterSk) === opts.invitation.inviter);
|
|
155
|
+
check(opts.now >= request.createdAt - SKEW);
|
|
156
|
+
invitationOf(opts.invitationEvent, opts, opts.now, opts.now);
|
|
157
|
+
const event = finalizeEvent({ kind: KINDS.INVITATION_GRANT, created_at: opts.now,
|
|
158
|
+
tags: [['d', deriveInvitationId(opts.invitation)], ['p', request.requester],
|
|
159
|
+
['expiration', String(Math.min(request.expiresAt, opts.now + LIVE_PERSISTENT_RESPONSE_SECONDS))]],
|
|
160
|
+
content: nip44.v2.encrypt(JSON.stringify({ v: 1, profile: PROFILE, request: request.requestId,
|
|
161
|
+
room: opts.roomId, epoch: opts.epoch, invitation: wire(opts.invitationEvent) }), nip44.v2.utils.getConversationKey(opts.inviterSk, request.requester)),
|
|
162
|
+
}, opts.inviterSk);
|
|
163
|
+
eventBounds(event, 16384, 20480, 3);
|
|
164
|
+
return event;
|
|
165
|
+
}
|
|
166
|
+
/** Stateless proof reader. The request owner consumes a result at most once. */
|
|
167
|
+
export function decodeLivePersistentAnswer(event, opts) {
|
|
168
|
+
try {
|
|
169
|
+
const request = decodeLivePersistentRequest(opts.request, opts);
|
|
170
|
+
check(request !== null && getPublicKey(opts.requesterSk) === request.requester);
|
|
171
|
+
const expiresAt = envelope(event, opts, KINDS.INVITATION_GRANT, request.requester, opts.now);
|
|
172
|
+
check(event.pubkey === opts.invitation.inviter && event.created_at >= request.createdAt - SKEW);
|
|
173
|
+
check(expiresAt === Math.min(request.expiresAt, event.created_at + LIVE_PERSISTENT_RESPONSE_SECONDS));
|
|
174
|
+
const raw = body(nip44.v2.decrypt(event.content, nip44.v2.utils.getConversationKey(opts.requesterSk, opts.invitation.inviter)), ['v', 'profile', 'request', 'room', 'epoch', 'invitation']);
|
|
175
|
+
check(raw.v === 1 && raw.profile === PROFILE && raw.request === request.requestId && raw.room === opts.roomId && integer(raw.epoch));
|
|
176
|
+
const admission = invitationOf(raw.invitation, opts, opts.now, event.created_at);
|
|
177
|
+
return { admission, epochHint: raw.epoch, requestId: request.requestId, expiresAt };
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
export const LIVE_PERSISTENT_ADMISSION_LABELS = [REQUEST_KEY];
|
package/dist/member-epoch.d.ts
CHANGED
|
@@ -59,6 +59,8 @@ export interface RekeyEvidence {
|
|
|
59
59
|
epoch: number;
|
|
60
60
|
removed: string[];
|
|
61
61
|
closed: boolean;
|
|
62
|
+
/** The closed room self-destructs: see `RekeyNotice.destruct`. */
|
|
63
|
+
destruct?: true;
|
|
62
64
|
/** A scheduled turn of the key: see `RekeyNotice.scheduled`. */
|
|
63
65
|
scheduled?: true;
|
|
64
66
|
/** The epoch commitment, when the authority wrote one. */
|
package/dist/member-epoch.js
CHANGED
|
@@ -117,6 +117,8 @@ export function readRekeyEvidence(event, opts) {
|
|
|
117
117
|
removed: [...new Set(body.removed.map(normaliseHex))].sort(),
|
|
118
118
|
closed: body.closed === true,
|
|
119
119
|
};
|
|
120
|
+
if (body.destruct === true && evidence.closed)
|
|
121
|
+
evidence.destruct = true;
|
|
120
122
|
if (body.scheduled === true && evidence.removed.length === 0 && !evidence.closed)
|
|
121
123
|
evidence.scheduled = true;
|
|
122
124
|
if (typeof body.commit === 'string' && HEX64.test(body.commit))
|
|
@@ -13,6 +13,9 @@ export interface PersistentRoomAdmission {
|
|
|
13
13
|
/** The room's own relays, as its inviter signed them: every member's pool
|
|
14
14
|
* includes them. Absent from an invitation written before 0.4.0. */
|
|
15
15
|
relays?: string[];
|
|
16
|
+
/** The room self-destructs: when it ends, every device deletes what it
|
|
17
|
+
* wrote and forgets the room. Absent for a room that simply ends. */
|
|
18
|
+
destruct?: true;
|
|
16
19
|
}
|
|
17
20
|
/** Publish once before sharing. The link plus this envelope is a durable
|
|
18
21
|
* bearer capability: anybody holding both can learn epoch 0. Retirement
|
|
@@ -30,6 +33,10 @@ export declare function encodePersistentInvitation(opts: {
|
|
|
30
33
|
* form (see `isInvitationRelays`), else it throws. Omitted, the body is
|
|
31
34
|
* byte-identical to 0.3.0's. */
|
|
32
35
|
relays?: readonly string[];
|
|
36
|
+
/** The room self-destructs when it ends, by its time or by its authority
|
|
37
|
+
* closing it. Written inside the encrypted body only, never as a tag.
|
|
38
|
+
* Omitted or false, the event is byte-identical to 0.8.0's. */
|
|
39
|
+
destruct?: boolean;
|
|
33
40
|
}): Event;
|
|
34
41
|
export declare function decodePersistentInvitation(event: Event, invitation: RoomInvitation): PersistentRoomAdmission | null;
|
|
35
42
|
/** Fetch the signed envelope and retirement in ONE stored-event query.
|
|
@@ -30,7 +30,7 @@ export function encodePersistentInvitation(opts) {
|
|
|
30
30
|
created_at: opts.now,
|
|
31
31
|
tags: ends === undefined ? [['d', deriveInvitationId(opts.invitation)]] : [['d', deriveInvitationId(opts.invitation)], ['expiration', String(ends)]],
|
|
32
32
|
content: nip44.v2.encrypt(JSON.stringify({
|
|
33
|
-
v: 3, room, secret: base64urlnopad.encode(opts.roomSecret), ...(ends === undefined ? {} : { ends }), ...(relays === undefined ? {} : { relays }),
|
|
33
|
+
v: 3, room, secret: base64urlnopad.encode(opts.roomSecret), ...(ends === undefined ? {} : { ends }), ...(opts.destruct ? { destruct: true } : {}), ...(relays === undefined ? {} : { relays }),
|
|
34
34
|
}), welcomeKey(opts.invitation)),
|
|
35
35
|
}, opts.inviterSk);
|
|
36
36
|
}
|
|
@@ -62,9 +62,15 @@ export function decodePersistentInvitation(event, invitation) {
|
|
|
62
62
|
// would write; a malformed one refuses the envelope rather than half of it.
|
|
63
63
|
if (body.relays !== undefined && !isInvitationRelays(body.relays))
|
|
64
64
|
return null;
|
|
65
|
+
// Self-destruct is `true` or absent; anything else is a malformed
|
|
66
|
+
// envelope, refused as a malformed end is.
|
|
67
|
+
if (body.destruct !== undefined && body.destruct !== true)
|
|
68
|
+
return null;
|
|
65
69
|
const admission = ends === undefined ? { secret, persistent: true, epoch: 0 } : { secret, persistent: true, epoch: 0, endsAt: ends };
|
|
66
70
|
if (body.relays !== undefined)
|
|
67
71
|
admission.relays = [...body.relays];
|
|
72
|
+
if (body.destruct === true)
|
|
73
|
+
admission.destruct = true;
|
|
68
74
|
return admission;
|
|
69
75
|
}
|
|
70
76
|
catch {
|
|
@@ -119,6 +125,10 @@ export function requestPersistentRoomAdmission(opts) {
|
|
|
119
125
|
// end stands, so a stale copy can never keep a room open longer.
|
|
120
126
|
if (admission?.endsAt !== undefined && (decoded.endsAt === undefined || decoded.endsAt > admission.endsAt))
|
|
121
127
|
decoded.endsAt = admission.endsAt;
|
|
128
|
+
// Two signed copies that disagree on self-destruct: it sticks, for
|
|
129
|
+
// the same reason, so a stale copy can never keep a room's content.
|
|
130
|
+
if (admission?.destruct)
|
|
131
|
+
decoded.destruct = true;
|
|
122
132
|
// Two signed copies that disagree on the room's relays: the newest
|
|
123
133
|
// copy that names any stands. A copy naming none (an older writer)
|
|
124
134
|
// says nothing about them, and on equal timestamps the first heard stays.
|
package/llms.txt
CHANGED
|
@@ -64,10 +64,12 @@ Not yet published to npm under this API. Until it is, pin an immutable Git commi
|
|
|
64
64
|
- `verifyInvitationDelegation(invitation, chain, now)`
|
|
65
65
|
- `encodeInvitationRetirement(opts)` / `decodeInvitationRetirementNotice(event, invitation)` - durable tombstone (kind 1461)
|
|
66
66
|
- `hostRoomInvitation(opts)` / `requestRoomAdmission(opts)` - the live desk, both sides
|
|
67
|
+
- Host `onGrantPublished(request)` / legacy `onAdmitted(device)` run after transport publication acknowledgement, not on approval. `onGrantFailed(request, error)` reports send failure; closed hosts suppress outstanding callbacks. Publication does not prove guest receipt or room entry.
|
|
67
68
|
- `encodePersistentInvitation(opts)` / `decodePersistentInvitation(event, invitation)` - v3 stored group invitation (kind 1463)
|
|
68
69
|
- `requestPersistentRoomAdmission(opts)` - joins from stored admission alone, no live desk needed
|
|
69
70
|
- 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
71
|
- 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)
|
|
72
|
+
- Self-destructing rooms (see `docs/room-destruct.md`): `encodePersistentInvitation({ ..., destruct: true })` puts `"destruct": true` in the encrypted body after `ends` (valid without `ends`), never a tag; `decodePersistentInvitation` returns `destruct: true` and is null when the field is present and not `true`; `requestPersistentRoomAdmission` keeps `destruct` if any valid copy carries it (sticky, like the earliest `ends`). `encodeInvitationRetirement({ ..., ended: true, destruct: true })` writes `{"v":1,"ended":true,"destruct":true}` (plain JSON, like `ended`) and throws on `destruct` without `ended`; `decodeInvitationRetirementNotice` returns `{ ended, destruct?: true }`, believing `destruct` only beside `ended`. `encodeRekeyEvent({ ..., closed: true, destruct: true })` writes it in the encrypted body after `closed` and throws without `closed`; `RekeyNotice.destruct` / `RekeyEvidence.destruct` are set only on a closing body, and a flag on an open rekey is dropped, not refused. Without the flag every event is byte-identical to 0.8.0, and a 0.8.0 reader ignores it
|
|
71
73
|
- `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)
|
|
72
74
|
- Epoch encoders (`encodeRekeyEvent`, `encodeEpochRequest`, `encodeEpochGrant`) and `hostRoomEpoch` / `requestRoomEpoch` take `expiresAt?` for a conference room
|
|
73
75
|
|
|
@@ -112,3 +114,21 @@ Not yet published to npm under this API. Until it is, pin an immutable Git commi
|
|
|
112
114
|
- README.md - full API overview, peer dependency rationale, how this kit relates to `covey-kit` and `roost-kit`
|
|
113
115
|
- EXTRACTION.md - the source commit, exactly which modules and functions moved, and the two splits (`access.ts`, `chat.ts`) performed on the way
|
|
114
116
|
- AGENTS.md - build/test commands and repository conventions for an agent working in this repo
|
|
117
|
+
|
|
118
|
+
### Live persistent admission (opt-in codecs)
|
|
119
|
+
|
|
120
|
+
`parseLivePersistentEvent` bounds raw event JSON and refuses duplicate fields.
|
|
121
|
+
`encodeLivePersistentDescriptor` / `decodeLivePersistentDescriptor` carry
|
|
122
|
+
routing metadata alongside an unchanged persistent invitation link.
|
|
123
|
+
`encodeLivePersistentRequest` / `decodeLivePersistentRequest` and
|
|
124
|
+
`encodeLivePersistentAnswer` / `decodeLivePersistentAnswer` implement a fresh,
|
|
125
|
+
root-signed challenge/answer with no delegation. The answer retains epoch-zero
|
|
126
|
+
capability separately from `epochHint`. Constants bound request/answer lifetimes
|
|
127
|
+
to 90/30 seconds. `LIVE_PERSISTENT_ADMISSION_LABELS` freezes its distinct KDF.
|
|
128
|
+
|
|
129
|
+
These functions are codecs, not an offline join API. The host must have exclusive
|
|
130
|
+
durable lifecycle authority; the client must complete authenticated epoch
|
|
131
|
+
admission before publishing, including at epoch zero. No application enables
|
|
132
|
+
this profile automatically. See [the wire profile and integration gates](docs/live-persistent-admission.md).
|
|
133
|
+
Frozen synthetic fixtures are in `vectors/live-persistent-vectors.json`; run
|
|
134
|
+
`npm run build && npm run generate-live-persistent` to regenerate them.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgesworn/fold-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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",
|
|
@@ -23,9 +23,11 @@
|
|
|
23
23
|
"vectors/member-epoch-vectors.json",
|
|
24
24
|
"vectors/seal-vectors.json",
|
|
25
25
|
"vectors/schedule-vectors.json",
|
|
26
|
+
"vectors/destruct-vectors.json",
|
|
26
27
|
"LICENSE",
|
|
27
28
|
"README.md",
|
|
28
|
-
"llms.txt"
|
|
29
|
+
"llms.txt",
|
|
30
|
+
"vectors/live-persistent-vectors.json"
|
|
29
31
|
],
|
|
30
32
|
"scripts": {
|
|
31
33
|
"build": "tsc -p tsconfig.json",
|
|
@@ -37,12 +39,14 @@
|
|
|
37
39
|
"generate-member-epoch": "node scripts/generate-member-epoch.mjs",
|
|
38
40
|
"generate-seal": "node scripts/generate-seal.mjs",
|
|
39
41
|
"generate-schedule": "node scripts/generate-schedule.mjs",
|
|
42
|
+
"generate-destruct": "node scripts/generate-destruct.mjs",
|
|
40
43
|
"bundle-check": "node scripts/bundle-check.mjs",
|
|
41
44
|
"tarball-smoke": "node scripts/tarball-smoke.mjs",
|
|
42
45
|
"vectors": "vitest run vectors/",
|
|
43
46
|
"check": "npm run typecheck && npm test && npm run diff-source",
|
|
44
47
|
"prepare": "npm run build",
|
|
45
|
-
"prepublishOnly": "npm run check && npm run bundle-check && npm run tarball-smoke"
|
|
48
|
+
"prepublishOnly": "npm run check && npm run bundle-check && npm run tarball-smoke",
|
|
49
|
+
"generate-live-persistent": "node scripts/generate-live-persistent.mjs"
|
|
46
50
|
},
|
|
47
51
|
"keywords": [
|
|
48
52
|
"nostr",
|