@forgesworn/fold-kit 0.9.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 +27 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/invitation.d.ts +6 -0
- package/dist/invitation.js +23 -2
- package/dist/live-persistent-admission.d.ts +52 -0
- package/dist/live-persistent-admission.js +183 -0
- package/llms.txt +19 -0
- package/package.json +5 -3
- 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`,
|
|
@@ -263,3 +272,21 @@ There is no separate lint script.
|
|
|
263
272
|
## Licence
|
|
264
273
|
|
|
265
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/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
|
package/dist/invitation.js
CHANGED
|
@@ -399,8 +399,29 @@ export function hostRoomInvitation(opts) {
|
|
|
399
399
|
close();
|
|
400
400
|
return;
|
|
401
401
|
}
|
|
402
|
-
|
|
403
|
-
|
|
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(() => { });
|
|
404
425
|
};
|
|
405
426
|
if (!opts.admit) {
|
|
406
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/llms.txt
CHANGED
|
@@ -64,6 +64,7 @@ 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
|
|
@@ -113,3 +114,21 @@ Not yet published to npm under this API. Until it is, pin an immutable Git commi
|
|
|
113
114
|
- README.md - full API overview, peer dependency rationale, how this kit relates to `covey-kit` and `roost-kit`
|
|
114
115
|
- EXTRACTION.md - the source commit, exactly which modules and functions moved, and the two splits (`access.ts`, `chat.ts`) performed on the way
|
|
115
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",
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
"vectors/destruct-vectors.json",
|
|
27
27
|
"LICENSE",
|
|
28
28
|
"README.md",
|
|
29
|
-
"llms.txt"
|
|
29
|
+
"llms.txt",
|
|
30
|
+
"vectors/live-persistent-vectors.json"
|
|
30
31
|
],
|
|
31
32
|
"scripts": {
|
|
32
33
|
"build": "tsc -p tsconfig.json",
|
|
@@ -44,7 +45,8 @@
|
|
|
44
45
|
"vectors": "vitest run vectors/",
|
|
45
46
|
"check": "npm run typecheck && npm test && npm run diff-source",
|
|
46
47
|
"prepare": "npm run build",
|
|
47
|
-
"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"
|
|
48
50
|
},
|
|
49
51
|
"keywords": [
|
|
50
52
|
"nostr",
|