@owli/agent-sdk 0.1.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.
@@ -0,0 +1,152 @@
1
+ // Default backend for real (non-test) use: a single AES-256-GCM-encrypted
2
+ // JSON file on disk, not a database - zero setup, no native deps, works
3
+ // anywhere Node runs. See backend.js for the interface this honors.
4
+ //
5
+ // Encryption key: either derived (PBKDF2) from a passphrase the caller
6
+ // supplies, or - for genuinely zero-config bot use - a random key
7
+ // generated once and stored in a sibling file with 0600 permissions next
8
+ // to the data file. That sibling-key mode is NOT secret against anyone who
9
+ // can already read files as the same OS user (or root) - same framing the
10
+ // browser app's own storage-at-rest design already uses for its
11
+ // device-local encryption: it protects against a stolen/copied data file
12
+ // being useful on its own, not against a fully compromised machine.
13
+ //
14
+ // Writes go through a temp-file-then-rename atomic write - the one real
15
+ // correctness risk a naive port of the browser's IndexedDB-backed design
16
+ // would miss, since IndexedDB gives you that safety for free and a plain
17
+ // fs.writeFile does not (a crash mid-write there would corrupt the file).
18
+ import { webcrypto } from 'node:crypto';
19
+ import { readFile, writeFile, rename, mkdir, chmod } from 'node:fs/promises';
20
+ import { existsSync, readFileSync } from 'node:fs';
21
+ import { dirname, join } from 'node:path';
22
+
23
+ const { subtle } = webcrypto;
24
+ // Not destructured off webcrypto like `subtle` above - getRandomValues is a
25
+ // method that requires `this` to be the real Crypto object, so it has to
26
+ // stay bound to webcrypto or every call throws ERR_INVALID_THIS.
27
+ const getRandomValues = (arr) => webcrypto.getRandomValues(arr);
28
+ const PBKDF2_ITERATIONS = 600000; // matches the browser app's own vault.js choice (OWASP 2023 guidance)
29
+
30
+ function toB64(bytes) {
31
+ return Buffer.from(bytes).toString('base64');
32
+ }
33
+ function fromB64(b64) {
34
+ return new Uint8Array(Buffer.from(b64, 'base64'));
35
+ }
36
+
37
+ async function deriveKey(passphrase, salt) {
38
+ const material = await subtle.importKey('raw', new TextEncoder().encode(passphrase), 'PBKDF2', false, ['deriveKey']);
39
+ return subtle.deriveKey(
40
+ { name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
41
+ material,
42
+ { name: 'AES-GCM', length: 256 },
43
+ false,
44
+ ['encrypt', 'decrypt']
45
+ );
46
+ }
47
+
48
+ // Zero-config mode: a random key generated once, stored in its own 0600
49
+ // file next to the data file. Not derived from a passphrase at all, since
50
+ // there isn't one - this is the "just work with no setup" path.
51
+ async function loadOrCreateLocalKey(keyPath) {
52
+ if (existsSync(keyPath)) {
53
+ return fromB64(readFileSync(keyPath, 'utf8').trim());
54
+ }
55
+ const raw = getRandomValues(new Uint8Array(32));
56
+ await mkdir(dirname(keyPath), { recursive: true });
57
+ await writeFile(keyPath, toB64(raw), { mode: 0o600 });
58
+ await chmod(keyPath, 0o600); // belt-and-suspenders in case the OS/umask ignored the mode above
59
+ return raw;
60
+ }
61
+
62
+ // path: where the encrypted data file lives. passphrase: optional - if
63
+ // omitted, a local key file (see above) is used instead, next to `path`
64
+ // with a `.key` suffix. pubkey: used to build a unique default filename
65
+ // when `path` isn't given - without this, every agent run on the same
66
+ // machine without an explicit path would fall back to the exact same
67
+ // file (a real bug this parameter exists to close, not a hypothetical
68
+ // one - see index.js's own comment on the collision it caused).
69
+ export async function createFileBackend({ path, passphrase, pubkey } = {}) {
70
+ const filePath = path || join(homeDefault(), `${pubkey || 'default'}.json`);
71
+ await mkdir(dirname(filePath), { recursive: true });
72
+
73
+ let cachedKey = null;
74
+ async function getKey(saltForPassphrase) {
75
+ if (cachedKey) return cachedKey;
76
+ if (passphrase) {
77
+ cachedKey = await deriveKey(passphrase, saltForPassphrase);
78
+ } else {
79
+ const raw = await loadOrCreateLocalKey(`${filePath}.key`);
80
+ cachedKey = await subtle.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt']);
81
+ }
82
+ return cachedKey;
83
+ }
84
+
85
+ async function readAll() {
86
+ if (!existsSync(filePath)) return {};
87
+ let raw;
88
+ try {
89
+ raw = await readFile(filePath, 'utf8');
90
+ } catch {
91
+ return {};
92
+ }
93
+ if (!raw.trim()) return {};
94
+ const envelope = JSON.parse(raw);
95
+ const salt = fromB64(envelope.salt);
96
+ const key = await getKey(salt);
97
+ const plaintext = await subtle.decrypt({ name: 'AES-GCM', iv: fromB64(envelope.iv) }, key, fromB64(envelope.ct));
98
+ return JSON.parse(new TextDecoder().decode(plaintext));
99
+ }
100
+
101
+ async function writeAll(map) {
102
+ const salt = getRandomValues(new Uint8Array(16));
103
+ const iv = getRandomValues(new Uint8Array(12));
104
+ const key = await getKey(salt);
105
+ const ct = await subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(JSON.stringify(map)));
106
+ const envelope = JSON.stringify({ v: 1, salt: toB64(salt), iv: toB64(iv), ct: toB64(ct) });
107
+ // Atomic write: write to a temp file in the same directory (so the
108
+ // rename is on the same filesystem), then rename over the real path -
109
+ // a crash mid-write leaves the old file intact, never a half-written one.
110
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
111
+ await writeFile(tmpPath, envelope);
112
+ await rename(tmpPath, filePath);
113
+ }
114
+
115
+ // Serialize concurrent writes within this process - two overlapping
116
+ // set() calls doing read-modify-write on the same file could otherwise
117
+ // clobber each other's changes.
118
+ let writeQueue = Promise.resolve();
119
+ function queueWrite(fn) {
120
+ writeQueue = writeQueue.then(fn, fn);
121
+ return writeQueue;
122
+ }
123
+
124
+ return {
125
+ async get(key) {
126
+ const all = await readAll();
127
+ return Object.prototype.hasOwnProperty.call(all, key) ? all[key] : null;
128
+ },
129
+ async set(key, value) {
130
+ return queueWrite(async () => {
131
+ const all = await readAll();
132
+ all[key] = value;
133
+ await writeAll(all);
134
+ });
135
+ },
136
+ async del(key) {
137
+ return queueWrite(async () => {
138
+ const all = await readAll();
139
+ delete all[key];
140
+ await writeAll(all);
141
+ });
142
+ },
143
+ async keys(prefix) {
144
+ const all = await readAll();
145
+ return Object.keys(all).filter((k) => k.startsWith(prefix));
146
+ },
147
+ };
148
+ }
149
+
150
+ function homeDefault() {
151
+ return join(process.env.HOME || process.env.USERPROFILE || '.', '.owli-agent');
152
+ }
@@ -0,0 +1,25 @@
1
+ // In-memory-only backend - a plain Map, no disk I/O at all. Default for
2
+ // tests, and for any caller who explicitly wants a fully ephemeral agent
3
+ // (a restart loses everything, including forward-secrecy state - the same
4
+ // "start every contact's ratchet from scratch" tradeoff ratchet.js's own
5
+ // comments already document as by-design for a fresh device, not a new
6
+ // concept introduced here). See backend.js for the interface this honors.
7
+
8
+ export function createMemoryBackend() {
9
+ const map = new Map();
10
+
11
+ return {
12
+ async get(key) {
13
+ return map.has(key) ? map.get(key) : null;
14
+ },
15
+ async set(key, value) {
16
+ map.set(key, value);
17
+ },
18
+ async del(key) {
19
+ map.delete(key);
20
+ },
21
+ async keys(prefix) {
22
+ return [...map.keys()].filter((k) => k.startsWith(prefix));
23
+ },
24
+ };
25
+ }
package/src/storage.js ADDED
@@ -0,0 +1,71 @@
1
+ // The storage.js that ratchet.js's `import * as storage from './storage.js'`
2
+ // resolves to in this SDK. NOT a port of webapp/src/lib/storage.js (that
3
+ // file is built directly on IndexedDB/localStorage, browser-only) - this is
4
+ // a from-scratch implementation of exactly the interface ratchet.js
5
+ // actually calls, confirmed directly against its source before writing
6
+ // this: getContacts, getRatchetSession, setRatchetSession,
7
+ // deleteRatchetSession, getOwnInvite, setOwnInvite - plus addContact/
8
+ // removeContact for the SDK's own public API to manage the contact list
9
+ // these functions need. Built on a pluggable backend (see storage/) so
10
+ // swapping the default file-based store for something else later never
11
+ // touches ratchet.js or this file's public shape.
12
+
13
+ const CONTACTS_KEY = 'owl:contacts';
14
+ const OWN_INVITE_KEY = 'owl:ratchet-own-invite';
15
+ const ratchetSessionKey = (pubkey) => `owl:ratchet-session:${pubkey}`;
16
+
17
+ let backend = null;
18
+
19
+ // Called once by index.js when an OwliAgent is created, before anything
20
+ // else touches this module - same "wire the real implementation in before
21
+ // first use" pattern ratchet.js's own configure() already establishes for
22
+ // getPool/getActiveRelays.
23
+ export function configure(chosenBackend) {
24
+ backend = chosenBackend;
25
+ }
26
+
27
+ function requireBackend() {
28
+ if (!backend) throw new Error('storage.configure(backend) must be called before use - see OwliAgent.create()');
29
+ return backend;
30
+ }
31
+
32
+ export async function getContacts() {
33
+ const raw = await requireBackend().get(CONTACTS_KEY);
34
+ return raw ? JSON.parse(raw) : [];
35
+ }
36
+
37
+ export async function addContact(pubkey) {
38
+ const contacts = await getContacts();
39
+ if (contacts.some((c) => c.pubkey === pubkey)) return contacts;
40
+ const next = [...contacts, { pubkey, addedAt: Date.now() }];
41
+ await requireBackend().set(CONTACTS_KEY, JSON.stringify(next));
42
+ return next;
43
+ }
44
+
45
+ export async function removeContact(pubkey) {
46
+ const contacts = await getContacts();
47
+ const next = contacts.filter((c) => c.pubkey !== pubkey);
48
+ await requireBackend().set(CONTACTS_KEY, JSON.stringify(next));
49
+ await requireBackend().del(ratchetSessionKey(pubkey));
50
+ return next;
51
+ }
52
+
53
+ export async function getRatchetSession(pubkey) {
54
+ return (await requireBackend().get(ratchetSessionKey(pubkey))) || null;
55
+ }
56
+
57
+ export async function setRatchetSession(pubkey, serializedState) {
58
+ await requireBackend().set(ratchetSessionKey(pubkey), serializedState);
59
+ }
60
+
61
+ export async function deleteRatchetSession(pubkey) {
62
+ await requireBackend().del(ratchetSessionKey(pubkey));
63
+ }
64
+
65
+ export async function getOwnInvite() {
66
+ return (await requireBackend().get(OWN_INVITE_KEY)) || null;
67
+ }
68
+
69
+ export async function setOwnInvite(serializedInvite) {
70
+ await requireBackend().set(OWN_INVITE_KEY, serializedInvite);
71
+ }
@@ -0,0 +1,267 @@
1
+ // Minimal rewrite of webapp/src/lib/nostr.js's relevant subset - the
2
+ // send/receive orchestration that actually matters for messaging (relay
3
+ // pool, ratchet-first-then-plain-fallback send, inbox subscription, catch-up
4
+ // fetch), with settings passed in as a plain config object instead of
5
+ // nostr.js's ~45 localStorage-backed getters, and every raw-key-only path
6
+ // kept (no NIP-46 signer branches here yet - v1.1, see this SDK's README).
7
+ // Text messages only for v1: no reactions/receipts/presence/group/trade/
8
+ // profile/session-claim routing, since none of that is core to "an agent
9
+ // sends and receives encrypted messages."
10
+ import { SimplePool } from 'nostr-tools/pool';
11
+ import { generateSecretKey, finalizeEvent } from 'nostr-tools/pure';
12
+ import * as nip59 from 'nostr-tools/nip59';
13
+ import * as nip44 from 'nostr-tools/nip44';
14
+ import * as ratchet from './ratchet.js';
15
+
16
+ export const DEFAULT_RELAYS = [
17
+ 'wss://relay.damus.io',
18
+ 'wss://nos.lol',
19
+ 'wss://relay.nostr.band',
20
+ 'wss://relay.primal.net',
21
+ 'wss://nostr.wine',
22
+ ];
23
+
24
+ const CHAT_KIND = 14;
25
+ const DISAPPEAR_TAG = 'disappear';
26
+ const TWO_DAYS_SECONDS = 2 * 24 * 60 * 60;
27
+ // Matches the dashboard's own PRESENCE_KIND/PRESENCE_TTL_SECONDS exactly
28
+ // (webapp/src/lib/nostr.js) - the dashboard already has everything needed
29
+ // to receive and render this (Avatar.js's online dot, state.js's
30
+ // isOnline 75s window), it's only ever been unused because no agent has
31
+ // ever sent one. A one-way "I'm here" ping, gift-wrapped like a real
32
+ // message but never through the ratchet transport - no session needed,
33
+ // no reply expected, and a real timestamp doesn't matter given the short
34
+ // TTL, so this deliberately skips trySend's session-bootstrap entirely.
35
+ const PRESENCE_KIND = 10450;
36
+ const PRESENCE_TTL_SECONDS = 90;
37
+ // Matches the dashboard's own PROFILE_KIND exactly (webapp/src/lib/
38
+ // nostr.js's sendProfile) - a plain gift-wrapped {name, emoji} rumor, no
39
+ // ratchet session needed. Real gap this closes: a brand-new agent had no
40
+ // way to tell a human (or another agent) who it is beyond a bare npub -
41
+ // whoever added it saw a generic "Contact" placeholder forever, since
42
+ // nothing on the agent's side ever sent a name. No photo support here
43
+ // (unlike the dashboard's own sendProfile) - an agent has no avatar
44
+ // image to send, only ever the name/emoji baked into its script.
45
+ const PROFILE_KIND = 30079;
46
+
47
+ function disappearAtFromTags(tags) {
48
+ const raw = tags.find((t) => t[0] === DISAPPEAR_TAG)?.[1];
49
+ const seconds = raw ? Number(raw) : NaN;
50
+ return Number.isFinite(seconds) ? seconds * 1000 : null;
51
+ }
52
+
53
+ function jitteredNow() {
54
+ // Matches nostr-tools' own randomNow() for the wrap timestamp -
55
+ // randomizes apparent send time within the past 2 days so relays can't
56
+ // use created_at to line up who was messaging whom, when.
57
+ return Math.round(Date.now() / 1000 - Math.random() * TWO_DAYS_SECONDS);
58
+ }
59
+
60
+ // Same rumor -> seal -> wrap shape as nip17.wrapEvent/nip59.wrapEvent, but
61
+ // nostr-tools exposes no way to attach a tag to the outer gift-wrap event -
62
+ // createWrap there hardcodes `tags: [["p", recipientPublicKey]]`. Relays
63
+ // only ever see this outer event, so the expiration tag has to live here,
64
+ // not on the inner rumor. Reuses nip59's own exported createRumor/createSeal
65
+ // (identical crypto, no reimplementation) and only recreates the final wrap
66
+ // step to add the tag.
67
+ function wrapEventWithExpiry(event, senderSecretKey, recipientPublicKey, expirySeconds) {
68
+ const seal = nip59.createSeal(nip59.createRumor(event, senderSecretKey), senderSecretKey, recipientPublicKey);
69
+ const randomKey = generateSecretKey();
70
+ const conversationKey = nip44.getConversationKey(randomKey, recipientPublicKey);
71
+ const tags = [['p', recipientPublicKey]];
72
+ if (expirySeconds != null) {
73
+ tags.push(['expiration', String(Math.floor(Date.now() / 1000) + expirySeconds)]);
74
+ }
75
+ return finalizeEvent(
76
+ {
77
+ kind: 1059,
78
+ content: nip44.encrypt(JSON.stringify(seal), conversationKey),
79
+ created_at: jitteredNow(),
80
+ tags,
81
+ },
82
+ randomKey
83
+ );
84
+ }
85
+
86
+ // config: { relays?: string[], messageExpirySeconds?: number|null }
87
+ export function createTransport({ relays = DEFAULT_RELAYS, messageExpirySeconds = null } = {}) {
88
+ let pool = null;
89
+ const getPool = () => (pool ??= new SimplePool());
90
+ const getActiveRelays = () => relays;
91
+
92
+ // Same wiring nostr.js does at module load - ratchet.js calls back into
93
+ // these two functions for its own publish/subscribe, never owns a pool
94
+ // itself.
95
+ ratchet.configure({ getPool, getActiveRelays });
96
+
97
+ // Every send to the same recipient runs through this - real bug found
98
+ // via a live agent whose CC of its own conversation would work for one
99
+ // turn, then silently vanish on the next: OwliAgent.ccToOwner fires
100
+ // twice per turn (once for the message it received, once for its own
101
+ // reply), both to the SAME ownerPubkey, both fire-and-forget, so they
102
+ // could genuinely be in flight at once. ratchet.js's trySend (kept
103
+ // byte-identical to the human webapp's copy, never edited here) reads
104
+ // its session object, mutates it, publishes, then persists - with real
105
+ // `await` gaps in between. Two concurrent calls for the same
106
+ // contactPubkey share that ONE in-memory session object; whichever
107
+ // call's persist() lands second in wall-clock time silently overwrites
108
+ // the other's, and a slower network round-trip can easily make that the
109
+ // FIRST call's write landing last - rolling the saved session back a
110
+ // step and corrupting what the ratchet protocol thinks comes next. This
111
+ // never showed up in the human dashboard, where a person sending one
112
+ // message at a time to the same contact essentially never overlaps like
113
+ // this - it took an automated script's own concurrent CC calls to
114
+ // surface it. Fixed at this layer (not inside ratchet.js itself, which
115
+ // stays untouched) by simply never letting two sends to the same
116
+ // recipient run concurrently at all.
117
+ const sendQueues = new Map();
118
+ function enqueue(recipientPubkey, task) {
119
+ const previous = sendQueues.get(recipientPubkey) || Promise.resolve();
120
+ const settled = previous.then(task, task);
121
+ // Always chain off a resolved promise, even when `task` rejected - a
122
+ // failed send must not permanently wedge every later send to this
123
+ // same recipient behind a dead queue entry.
124
+ sendQueues.set(recipientPubkey, settled.catch(() => {}));
125
+ return settled;
126
+ }
127
+
128
+ // The actual plain-NIP-17 send, never called directly by anything
129
+ // outside this module - always through the queued sendPlainMessage
130
+ // below, or as sendMessageInner's own fallback (already inside the
131
+ // queue's serialized slot at that point, so calling this directly
132
+ // there is correct, not a race).
133
+ async function sendPlainMessageInner(secretKey, myPubkey, recipientPubkey, text, disappearAfterSec) {
134
+ const tags = [['p', recipientPubkey]];
135
+ if (disappearAfterSec) tags.push([DISAPPEAR_TAG, String(Math.floor(Date.now() / 1000) + disappearAfterSec)]);
136
+ const event = { kind: CHAT_KIND, created_at: Math.floor(Date.now() / 1000), tags, content: text };
137
+ const wrapped = wrapEventWithExpiry(event, secretKey, recipientPubkey, messageExpirySeconds);
138
+ await Promise.any(getPool().publish(getActiveRelays(), wrapped));
139
+ return wrapped;
140
+ }
141
+
142
+ // Plain NIP-17 only, no ratchet attempt - used as sendMessage's fallback
143
+ // and directly by callers who explicitly want no forward-secrecy attempt.
144
+ async function sendPlainMessage(secretKey, myPubkey, recipientPubkey, text, disappearAfterSec) {
145
+ return enqueue(recipientPubkey, () => sendPlainMessageInner(secretKey, myPubkey, recipientPubkey, text, disappearAfterSec));
146
+ }
147
+
148
+ async function sendPresencePing(secretKey, myPubkey, recipientPubkey) {
149
+ const event = { kind: PRESENCE_KIND, created_at: Math.floor(Date.now() / 1000), tags: [['p', recipientPubkey]], content: '' };
150
+ const wrapped = wrapEventWithExpiry(event, secretKey, recipientPubkey, PRESENCE_TTL_SECONDS);
151
+ await Promise.any(getPool().publish(getActiveRelays(), wrapped));
152
+ }
153
+
154
+ async function sendProfile(secretKey, recipientPubkey, profile) {
155
+ const event = {
156
+ kind: PROFILE_KIND,
157
+ created_at: Math.floor(Date.now() / 1000),
158
+ tags: [],
159
+ content: JSON.stringify(profile),
160
+ };
161
+ const wrapped = wrapEventWithExpiry(event, secretKey, recipientPubkey, messageExpirySeconds);
162
+ await Promise.any(getPool().publish(getActiveRelays(), wrapped));
163
+ return wrapped;
164
+ }
165
+
166
+ // Tries the Double Ratchet transport first - forward secrecy, not just
167
+ // gift-wrap metadata protection. Falls back to plain NIP-17 automatically
168
+ // if the recipient hasn't published an invite yet (hasn't upgraded, or
169
+ // isn't currently reachable to discover one from) - no caller-visible
170
+ // difference either way. Calls sendPlainMessageInner directly, not the
171
+ // queued sendPlainMessage - this whole function only ever runs from
172
+ // inside enqueue()'s serialized slot (see sendMessage below), so
173
+ // routing back through the queue here would just wait on itself.
174
+ async function sendMessageInner(secretKey, myPubkey, recipientPubkey, text, disappearAfterSec) {
175
+ const viaRatchet = await ratchet.trySend(secretKey, myPubkey, recipientPubkey, text, messageExpirySeconds, disappearAfterSec);
176
+ if (viaRatchet) return viaRatchet;
177
+ return sendPlainMessageInner(secretKey, myPubkey, recipientPubkey, text, disappearAfterSec);
178
+ }
179
+
180
+ async function sendMessage(secretKey, myPubkey, recipientPubkey, text, disappearAfterSec) {
181
+ return enqueue(recipientPubkey, () => sendMessageInner(secretKey, myPubkey, recipientPubkey, text, disappearAfterSec));
182
+ }
183
+
184
+ // Plain-NIP-17 inbox - the ratchet transport has its own separate
185
+ // subscription inside ratchet.js itself (started via startRatchetInbox
186
+ // below), so a message can arrive via either path and both need to be
187
+ // running for onMessage to see everything.
188
+ function subscribeInbox(secretKey, myPubkey, onMessage) {
189
+ const seen = new Set();
190
+ return getPool().subscribeMany(
191
+ getActiveRelays(),
192
+ { kinds: [1059], '#p': [myPubkey] },
193
+ {
194
+ onevent(event) {
195
+ if (seen.has(event.id)) return;
196
+ seen.add(event.id);
197
+ try {
198
+ const rumor = nip59.unwrapEvent(event, secretKey);
199
+ if (rumor.kind !== CHAT_KIND) return; // v1: text messages only
200
+ onMessage({
201
+ from: rumor.pubkey,
202
+ text: rumor.content,
203
+ createdAt: rumor.created_at,
204
+ eventId: event.id,
205
+ disappearAt: disappearAtFromTags(rumor.tags),
206
+ });
207
+ } catch {
208
+ // not decryptable to us (unrelated/relay noise) - ignore
209
+ }
210
+ },
211
+ }
212
+ );
213
+ }
214
+
215
+ // One-shot re-query for anything since a given time - covers what a dead
216
+ // socket would otherwise lose, same purpose as nostr.js's own fetchMissed.
217
+ async function fetchMissedPlain(secretKey, myPubkey, sinceSeconds) {
218
+ const filter = { kinds: [1059], '#p': [myPubkey] };
219
+ if (sinceSeconds) filter.since = sinceSeconds;
220
+ const events = await getPool().querySync(getActiveRelays(), filter);
221
+ const messages = [];
222
+ for (const event of events) {
223
+ try {
224
+ const rumor = nip59.unwrapEvent(event, secretKey);
225
+ if (rumor.kind !== CHAT_KIND) continue;
226
+ messages.push({
227
+ from: rumor.pubkey,
228
+ text: rumor.content,
229
+ createdAt: rumor.created_at,
230
+ eventId: event.id,
231
+ disappearAt: disappearAtFromTags(rumor.tags),
232
+ });
233
+ } catch {
234
+ // not ours / not decryptable - ignore
235
+ }
236
+ }
237
+ return messages;
238
+ }
239
+
240
+ async function fetchMissed(secretKey, myPubkey, sinceSeconds) {
241
+ const [plain, ratchetMissed] = await Promise.all([
242
+ fetchMissedPlain(secretKey, myPubkey, sinceSeconds),
243
+ ratchet.fetchMissed(sinceSeconds),
244
+ ]);
245
+ return [...plain, ...ratchetMissed].sort((a, b) => a.createdAt - b.createdAt);
246
+ }
247
+
248
+ function startRatchetInbox(secretKey, myPubkey, onMessage) {
249
+ return ratchet.start(secretKey, myPubkey, onMessage, null, null, null, null, null);
250
+ }
251
+
252
+ return {
253
+ getPool,
254
+ getActiveRelays,
255
+ sendMessage,
256
+ sendPlainMessage,
257
+ sendPresencePing,
258
+ sendProfile,
259
+ subscribeInbox,
260
+ fetchMissed,
261
+ startRatchetInbox,
262
+ stopRatchetInbox: ratchet.stop,
263
+ hasSession: ratchet.hasSession,
264
+ refreshInviteListen: ratchet.refreshInviteListen,
265
+ close: () => getPool().close(getActiveRelays()),
266
+ };
267
+ }
@@ -0,0 +1,17 @@
1
+ // nostr-tools' SimplePool reads a module-level WebSocket reference, set via
2
+ // its own exported useWebSocketImplementation(impl) - confirmed directly
3
+ // in its source (lib/esm/pool.js), called once before any SimplePool is
4
+ // constructed. Node 22+ already has a global WebSocket (this becomes a
5
+ // no-op there); older Node needs the `ws` package wired in through this
6
+ // same call.
7
+ import { useWebSocketImplementation } from 'nostr-tools/pool';
8
+
9
+ let ensured = false;
10
+
11
+ export async function ensureWebSocket() {
12
+ if (ensured) return;
13
+ ensured = true;
14
+ if (typeof globalThis.WebSocket !== 'undefined') return; // Node 22+, nothing to do
15
+ const { default: WebSocketImpl } = await import('ws');
16
+ useWebSocketImplementation(WebSocketImpl);
17
+ }