@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 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";
@@ -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
@@ -399,8 +399,29 @@ export function hostRoomInvitation(opts) {
399
399
  close();
400
400
  return;
401
401
  }
402
- opts.transport.publish(grant).catch(() => { });
403
- opts.onAdmitted?.(request.device);
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.9.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",