@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.
package/src/ratchet.js ADDED
@@ -0,0 +1,505 @@
1
+ // Copied verbatim from webapp/src/lib/ratchet.js (2026-08-28) - zero edits.
2
+ // Its only environment coupling is the injected storage module (this SDK's
3
+ // own src/storage.js, built to satisfy the exact 6-function interface this
4
+ // file calls) and the configure({getPool, getActiveRelays}) call (wired
5
+ // from src/transport.js) - both already parameterized in the original, not
6
+ // hardcoded, which is exactly why this file needed no changes to run in
7
+ // Node. Diff against webapp/src/lib/ratchet.js before any protocol change
8
+ // ships on either side - see this SDK's README.
9
+
10
+ // Double Ratchet (NIP-104-shaped) transport, layered underneath the
11
+ // existing NIP-17 messaging in nostr.js - see architecture-v1_1.md §5a.
12
+ // Attempted automatically for every contact; falls back to plain NIP-17
13
+ // if the other side hasn't published an invite (hasn't upgraded yet, or
14
+ // hasn't been online since upgrading). Verified against live relays in
15
+ // spike/ratchet-roundtrip.mjs and spike/ratchet-owl-pattern.mjs before
16
+ // any of this was written - this module carries that verified shape
17
+ // forward, not a fresh, unverified design.
18
+ //
19
+ // No handshake-ping here (see spike/ratchet-owl-pattern.mjs's Fix 1):
20
+ // that was needed only because the spike tested "accept" and "send the
21
+ // first real message" as separate steps. Here, whichever contact sends
22
+ // first is the one who discovers and accepts the other's invite, and
23
+ // their real message *is* the unlocking event - sent immediately after
24
+ // accepting, with no gap for a separate ping to fill.
25
+
26
+ import { finalizeEvent } from 'nostr-tools/pure';
27
+ import {
28
+ Invite,
29
+ Session,
30
+ buildTextRumor,
31
+ serializeSessionState,
32
+ deserializeSessionState,
33
+ MESSAGE_EVENT_KIND,
34
+ GROUP_METADATA_KIND,
35
+ GROUP_SENDER_KEY_DISTRIBUTION_KIND,
36
+ GROUP_INVITE_RUMOR_KIND,
37
+ RECEIPT_KIND,
38
+ } from 'nostr-double-ratchet';
39
+ import * as storage from './storage.js';
40
+ import * as signerLib from './signer.js';
41
+
42
+ // A signer-backed identity is threaded through every function below the
43
+ // exact same way a raw secretKey Uint8Array always has been - as
44
+ // { signer: BunkerSignerInstance } instead - so every function that just
45
+ // passes secretKey along on its way to one of the three real touch points
46
+ // below needs zero changes at all. Only those three actually branch on
47
+ // which shape they were given. Exported so nostr.js/zap.js/blossom.js
48
+ // reuse this exact check for their own raw-key touch points instead of
49
+ // each re-deriving the same { signer } shape test independently.
50
+ export function isSignerBacked(secretKey) {
51
+ return !!(secretKey && typeof secretKey === 'object' && secretKey.signer);
52
+ }
53
+
54
+ const INVITE_DISCOVERY_TIMEOUT_MS = 4000;
55
+
56
+ // Group chat (architecture-v1_1.md §5b) rides on top of these same
57
+ // pairwise sessions - GroupManager distributes sender keys and roster/
58
+ // metadata updates via ordinary rumors sent through them, confirmed
59
+ // directly against real traffic in spike/group-roundtrip.mjs. Anything
60
+ // arriving with one of these kinds gets routed to the group layer
61
+ // instead of treated as chat text.
62
+ const GROUP_RUMOR_KINDS = new Set([GROUP_METADATA_KIND, GROUP_SENDER_KEY_DISTRIBUTION_KIND, GROUP_INVITE_RUMOR_KIND]);
63
+
64
+ // Trade requests (architecture-v1_1.md §5d) - not a library kind, Owl's
65
+ // own, verified unused against the installed nostr-double-ratchet package
66
+ // and Owl's other custom kinds before picking it (full taken-kind list in
67
+ // §5d). Same shape as the group kinds above: one rumor kind, routed away
68
+ // from chat text to its own handler, with every action (propose/accept/
69
+ // decline/etc.) distinguished by a `content.action` field rather than a
70
+ // kind per action - matching how group metadata already reuses one kind
71
+ // for name/emoji/founder updates.
72
+ const TRADE_REQUEST_KIND = 10449;
73
+
74
+ // A reaction to a message - matches nostr.js's own REACTION_KIND constant
75
+ // (each transport file defines its own copy of every custom kind it needs,
76
+ // the same convention TRADE_REQUEST_KIND above already follows).
77
+ const REACTION_KIND = 16;
78
+
79
+ // Disappearing messages (architecture-v1_1.md §5e) - a tag on the inner
80
+ // rumor, not a kind, matching this. Owl's own, distinct from the
81
+ // library's EXPIRATION_TAG ("expiration"), which the Message Expiry line
82
+ // above already puts on this same inner rumor for a different purpose
83
+ // (relay-side NIP-40 pruning). Value is a Unix-seconds timestamp, same
84
+ // format as EXPIRATION_TAG's, computed at send time.
85
+ const DISAPPEAR_TAG = 'disappear';
86
+ function disappearAtFromTags(tags) {
87
+ const raw = tags.find((t) => t[0] === DISAPPEAR_TAG)?.[1];
88
+ const seconds = raw ? Number(raw) : NaN;
89
+ return Number.isFinite(seconds) ? seconds * 1000 : null;
90
+ }
91
+
92
+ let pool = null;
93
+ let relaysFn = null;
94
+
95
+ // Wired from nostr.js at module load (see the bottom of this file's
96
+ // import in nostr.js) rather than importing nostr.js directly here, to
97
+ // avoid a circular import (nostr.js's sendMessage calls into this module).
98
+ export function configure({ getPool, getActiveRelays }) {
99
+ pool = getPool;
100
+ relaysFn = getActiveRelays;
101
+ }
102
+
103
+ function publish(event) {
104
+ return Promise.any(pool().publish(relaysFn(), event));
105
+ }
106
+
107
+ function ratchetSubscribe(filter, onEvent) {
108
+ const sub = pool().subscribeMany(relaysFn(), filter, { onevent: onEvent });
109
+ return () => sub.close();
110
+ }
111
+
112
+ // In-memory only: rehydrated from storage.js on demand. Never derived from
113
+ // anything the recovery phrase alone could reconstruct - restoring a fresh
114
+ // device via phrase starts every contact's ratchet from scratch, by
115
+ // design (§5a).
116
+ const sessions = new Map(); // contactPubkey -> Session
117
+ let onMessageCallback = null;
118
+ let onNewSessionCallback = null;
119
+ let onGroupRumorCallback = null;
120
+ let onTradeRumorCallback = null;
121
+ let onReactionCallback = null;
122
+ let onReceiptCallback = null;
123
+ let ownInviteListenUnsub = null;
124
+ let msgSubUnsub = null;
125
+ let msgSubAuthorsKey = null;
126
+
127
+ // Stored as an envelope, not just the raw serialized state: `sourceEventId`
128
+ // carries the invite-response event that produced this session (only set
129
+ // on the inbound/listen path - see the library's Invite.listen, which
130
+ // passes `name: event.id` into Session.init and Session.init assigns it
131
+ // to session.name). Lets a later inbound session be compared against this
132
+ // one by *which event created it*, not just "does a session exist" - see
133
+ // start()'s listen callback for why that distinction matters.
134
+ async function persist(contactPubkey, session) {
135
+ await storage.setRatchetSession(contactPubkey, JSON.stringify({
136
+ state: serializeSessionState(session.state),
137
+ sourceEventId: session.name,
138
+ }));
139
+ }
140
+
141
+ async function loadSession(contactPubkey) {
142
+ if (sessions.has(contactPubkey)) return sessions.get(contactPubkey);
143
+ const saved = await storage.getRatchetSession(contactPubkey);
144
+ if (!saved) return null;
145
+ try {
146
+ const envelope = JSON.parse(saved);
147
+ const session = new Session(deserializeSessionState(envelope.state));
148
+ session.name = envelope.sourceEventId;
149
+ sessions.set(contactPubkey, session);
150
+ return session;
151
+ } catch {
152
+ // corrupted/incompatible stored state - drop it and start fresh next
153
+ // time this contact is messaged, rather than blocking on it forever
154
+ await storage.deleteRatchetSession(contactPubkey);
155
+ return null;
156
+ }
157
+ }
158
+
159
+ // UI-facing: does this contact have an established ratchet session, or
160
+ // would sendMessage currently fall back to plain NIP-17 (no forward
161
+ // secrecy)? Reuses loadSession's own cache/storage lookup rather than a
162
+ // separate check, so this never disagrees with what trySend would actually
163
+ // do for the same contact.
164
+ export async function hasSession(contactPubkey) {
165
+ return !!(await loadSession(contactPubkey));
166
+ }
167
+
168
+ async function getOrCreateOwnInvite(myPubkey) {
169
+ const saved = await storage.getOwnInvite();
170
+ if (saved) {
171
+ try {
172
+ return Invite.deserialize(saved);
173
+ } catch {
174
+ // corrupted - fall through and create a fresh one
175
+ }
176
+ }
177
+ const invite = Invite.createNew(myPubkey, 'owl-device');
178
+ await storage.setOwnInvite(invite.serialize());
179
+ return invite;
180
+ }
181
+
182
+ // Publishes (or republishes - same `d` tag, so relays just replace the
183
+ // existing copy) this device's own invite, so contacts can discover and
184
+ // message this identity first. Same "once per unlock" pattern already
185
+ // used for profile broadcast elsewhere in this codebase.
186
+ async function publishOwnInvite(secretKey, myPubkey) {
187
+ const invite = await getOrCreateOwnInvite(myPubkey);
188
+ // finalizeEvent needs a raw key and has no callback form - a signer's
189
+ // signEvent already returns a complete, signed event on its own, so it
190
+ // replaces finalizeEvent entirely rather than feeding into it.
191
+ const signedEvent = isSignerBacked(secretKey)
192
+ ? await signerLib.signEventWithSigner(secretKey.signer, invite.getEvent())
193
+ : finalizeEvent(invite.getEvent(), secretKey);
194
+ await publish(signedEvent);
195
+ return invite;
196
+ }
197
+
198
+ function knownAuthors() {
199
+ const authors = new Set();
200
+ for (const session of sessions.values()) {
201
+ const s = session.state;
202
+ if (s.theirCurrentNostrPublicKey) authors.add(s.theirCurrentNostrPublicKey);
203
+ if (s.theirNextNostrPublicKey) authors.add(s.theirNextNostrPublicKey);
204
+ for (const pubkey of Object.keys(s.skippedKeys || {})) authors.add(pubkey);
205
+ }
206
+ return [...authors];
207
+ }
208
+
209
+ // One shared subscription across every active session, narrowed to just
210
+ // the pubkeys any session could currently receive from - re-subscribed
211
+ // whenever a DH ratchet step changes that set. See spike/
212
+ // ratchet-owl-pattern.mjs's NarrowedInbox for why this matters: a broad
213
+ // `{kinds:[MESSAGE_EVENT_KIND]}` filter has to try every event on a busy
214
+ // public relay, which measured up to 45s of unrelated background traffic
215
+ // in testing.
216
+ function refreshMessageSubscription() {
217
+ const authors = knownAuthors();
218
+ const authorsKey = authors.slice().sort().join(',');
219
+ if (authorsKey === msgSubAuthorsKey) return;
220
+ msgSubAuthorsKey = authorsKey;
221
+ if (msgSubUnsub) msgSubUnsub();
222
+ if (!authors.length) return;
223
+ msgSubUnsub = ratchetSubscribe({ kinds: [MESSAGE_EVENT_KIND], authors }, (event) => {
224
+ for (const [contactPubkey, session] of sessions) {
225
+ try {
226
+ const rumor = session.receiveEvent(event);
227
+ if (!rumor) continue;
228
+ persist(contactPubkey, session).catch(() => {});
229
+ refreshMessageSubscription();
230
+ if (GROUP_RUMOR_KINDS.has(rumor.kind)) {
231
+ if (onGroupRumorCallback) onGroupRumorCallback(rumor, contactPubkey);
232
+ return;
233
+ }
234
+ if (rumor.kind === TRADE_REQUEST_KIND) {
235
+ if (onTradeRumorCallback) onTradeRumorCallback(rumor, contactPubkey);
236
+ return;
237
+ }
238
+ if (rumor.kind === REACTION_KIND) {
239
+ const targetEventId = rumor.tags.find((t) => t[0] === 'e')?.[1];
240
+ if (targetEventId && onReactionCallback) onReactionCallback({ from: contactPubkey, targetEventId, emoji: rumor.content });
241
+ return;
242
+ }
243
+ if (rumor.kind === RECEIPT_KIND) {
244
+ if (onReceiptCallback) {
245
+ try {
246
+ const payload = JSON.parse(rumor.content);
247
+ onReceiptCallback({ from: contactPubkey, type: payload.type, messageIds: payload.messageIds || [] });
248
+ } catch {
249
+ // malformed receipt payload - drop it, not a chat message either
250
+ }
251
+ }
252
+ return;
253
+ }
254
+ if (onMessageCallback) {
255
+ onMessageCallback({
256
+ from: contactPubkey,
257
+ text: rumor.content,
258
+ createdAt: rumor.created_at,
259
+ eventId: event.id,
260
+ disappearAt: disappearAtFromTags(rumor.tags),
261
+ });
262
+ }
263
+ return;
264
+ } catch {
265
+ // not this session's event - try the next registered session
266
+ }
267
+ }
268
+ });
269
+ }
270
+
271
+ // Manual catch-up, mirroring nostr.js's fetchMissed for the NIP-17 path:
272
+ // the live subscription in refreshMessageSubscription can silently die
273
+ // (sleep, network drop, relay hiccup) exactly like the NIP-17 one can, so
274
+ // this exists as the same belt-and-suspenders "Resync" button covers both
275
+ // transports uniformly, rather than only ever fixing NIP-17.
276
+ //
277
+ // Deliberately narrow: only queries for currently-known session authors
278
+ // (same set refreshMessageSubscription filters live delivery to), so it
279
+ // can't discover a session that was never established while this device
280
+ // was closed - if that happens, the sender's next message after this
281
+ // device reopens still delivers live and bootstraps normally; there's no
282
+ // gap in eventual delivery, only in exactly when this specific query can
283
+ // see it. Doesn't re-run invite discovery/bootstrap - only catches up
284
+ // messages on sessions that already exist.
285
+ export async function fetchMissed(sinceSeconds) {
286
+ const authors = knownAuthors();
287
+ if (!authors.length) return [];
288
+ const filter = { kinds: [MESSAGE_EVENT_KIND], authors };
289
+ if (sinceSeconds) filter.since = sinceSeconds;
290
+ const events = await pool().querySync(relaysFn(), filter);
291
+ events.sort((a, b) => a.created_at - b.created_at);
292
+
293
+ const missed = [];
294
+ for (const event of events) {
295
+ for (const [contactPubkey, session] of sessions) {
296
+ try {
297
+ const rumor = session.receiveEvent(event);
298
+ if (!rumor) continue;
299
+ await persist(contactPubkey, session);
300
+ if (GROUP_RUMOR_KINDS.has(rumor.kind)) {
301
+ if (onGroupRumorCallback) await onGroupRumorCallback(rumor, contactPubkey);
302
+ } else if (rumor.kind === TRADE_REQUEST_KIND) {
303
+ if (onTradeRumorCallback) await onTradeRumorCallback(rumor, contactPubkey);
304
+ } else if (rumor.kind === REACTION_KIND) {
305
+ const targetEventId = rumor.tags.find((t) => t[0] === 'e')?.[1];
306
+ if (targetEventId && onReactionCallback) await onReactionCallback({ from: contactPubkey, targetEventId, emoji: rumor.content });
307
+ } else if (rumor.kind === RECEIPT_KIND) {
308
+ if (onReceiptCallback) {
309
+ try {
310
+ const payload = JSON.parse(rumor.content);
311
+ await onReceiptCallback({ from: contactPubkey, type: payload.type, messageIds: payload.messageIds || [] });
312
+ } catch {
313
+ // malformed receipt payload - drop it
314
+ }
315
+ }
316
+ } else {
317
+ missed.push({
318
+ from: contactPubkey,
319
+ text: rumor.content,
320
+ createdAt: rumor.created_at,
321
+ eventId: event.id,
322
+ disappearAt: disappearAtFromTags(rumor.tags),
323
+ });
324
+ }
325
+ break;
326
+ } catch {
327
+ // not this session's event - try the next registered session
328
+ }
329
+ }
330
+ }
331
+ refreshMessageSubscription();
332
+ return missed;
333
+ }
334
+
335
+ // Discovers a contact's published invite and accepts it, becoming the
336
+ // ratchet's sender so a real message can go out immediately after. Times
337
+ // out and returns null (caller falls back to plain NIP-17) if the contact
338
+ // hasn't published an invite - not upgraded yet, or hasn't been online
339
+ // since upgrading.
340
+ async function bootstrapOutbound(secretKey, myPubkey, contactPubkey) {
341
+ const theirInvite = await Invite.waitFor(contactPubkey, ratchetSubscribe, INVITE_DISCOVERY_TIMEOUT_MS);
342
+ if (!theirInvite) return null;
343
+ // accept() natively takes a raw key OR an encrypt callback (its own
344
+ // type signature already supports this) - a signer's nip44Encrypt does
345
+ // the same DH(inviter, invitee) + NIP-44 encrypt the doc comment on
346
+ // accept() describes, just performed remotely instead of locally.
347
+ const encryptor = isSignerBacked(secretKey) ? signerLib.encryptorFor(secretKey.signer) : secretKey;
348
+ const { session, event } = await theirInvite.accept(myPubkey, encryptor);
349
+ await publish(event);
350
+ sessions.set(contactPubkey, session);
351
+ await persist(contactPubkey, session);
352
+ refreshMessageSubscription();
353
+ if (onNewSessionCallback) onNewSessionCallback(contactPubkey);
354
+ return session;
355
+ }
356
+
357
+ async function getOrBootstrapSession(secretKey, myPubkey, contactPubkey) {
358
+ const existing = await loadSession(contactPubkey);
359
+ if (existing) {
360
+ refreshMessageSubscription();
361
+ return existing;
362
+ }
363
+ return bootstrapOutbound(secretKey, myPubkey, contactPubkey);
364
+ }
365
+
366
+ // Returns the published event on success via the ratchet, or null if no
367
+ // session could be established (contact hasn't upgraded / isn't
368
+ // reachable right now) - callers fall back to nip17.wrapEvent in that case.
369
+ // expirySeconds (from nostr.js's getMessageExpirySeconds) is passed straight
370
+ // through to the library's own NIP-40 expiration handling - null/undefined
371
+ // means no expiration tag at all, matching "Never" in Settings.
372
+ export async function trySend(secretKey, myPubkey, contactPubkey, text, expirySeconds, disappearAfterSec) {
373
+ const session = await getOrBootstrapSession(secretKey, myPubkey, contactPubkey);
374
+ if (!session) return null;
375
+ const rumorOptions = expirySeconds != null ? { expiration: { ttlSeconds: expirySeconds } } : {};
376
+ // Deliberately its own tag, not the library's EXPIRATION_TAG above - that
377
+ // one's already spoken for by the ratchet-path Message Expiry line just
378
+ // above (a relay-side NIP-40 hint), and the two would silently collide
379
+ // on the same inner rumor if this reused it.
380
+ if (disappearAfterSec) rumorOptions.tags = [[DISAPPEAR_TAG, String(Math.floor(Date.now() / 1000) + disappearAfterSec)]];
381
+ const { event } = session.sendEvent(buildTextRumor(text, rumorOptions));
382
+ await publish(event);
383
+ await persist(contactPubkey, session);
384
+ return event;
385
+ }
386
+
387
+ // Group chat's sendPairwise (architecture-v1_1.md §5b): sends an
388
+ // arbitrary pre-built rumor (roster/metadata updates, sender-key
389
+ // distributions) through the same pairwise session as regular messages,
390
+ // bootstrapping it first if needed. No NIP-17 fallback here, unlike
391
+ // trySend - these are Owl's own ratchet-specific protocol messages, not
392
+ // user-facing chat content with an equivalent plain-NIP-17 shape to fall
393
+ // back to. Throws if no session could be established, so group.js's
394
+ // sendPairwise implementation surfaces that as a real failure rather than
395
+ // silently dropping the distribution.
396
+ export async function sendRumorPairwise(secretKey, myPubkey, contactPubkey, rumor) {
397
+ const session = await getOrBootstrapSession(secretKey, myPubkey, contactPubkey);
398
+ if (!session) throw new Error(`no ratchet session with ${contactPubkey} and none could be bootstrapped`);
399
+ const { event } = session.sendEvent(rumor);
400
+ await publish(event);
401
+ await persist(contactPubkey, session);
402
+ return event;
403
+ }
404
+
405
+ // Non-throwing variant of the above, for callers with their own plain-
406
+ // NIP-17 fallback (reactions, same as sendMessage/trySend) rather than
407
+ // ratchet-only protocol traffic - returns null instead of throwing so the
408
+ // caller can fall through to that fallback, matching trySend's shape.
409
+ export async function trySendRumorPairwise(secretKey, myPubkey, contactPubkey, rumor) {
410
+ const session = await getOrBootstrapSession(secretKey, myPubkey, contactPubkey);
411
+ if (!session) return null;
412
+ const { event } = session.sendEvent(rumor);
413
+ await publish(event);
414
+ await persist(contactPubkey, session);
415
+ return event;
416
+ }
417
+
418
+ // The invite-listen half of start() - pulled out so it can also be
419
+ // re-run on demand to recover from a live subscription that's gone
420
+ // stale without anyone noticing (a real risk for an agent meant to run
421
+ // for days/weeks unattended). Invite.listen()'s own subscription is just
422
+ // a relay REQ under the hood (via ratchetSubscribe), and a fresh REQ
423
+ // gets matching events a relay still has stored replayed back
424
+ // immediately, not just future ones - so simply re-subscribing recovers
425
+ // any accept-response that arrived while the old listener wasn't
426
+ // actually delivering anymore. This is the ONLY way a brand-new incoming
427
+ // session can ever be discovered after the fact at all: unlike regular
428
+ // ratchet messages (kind 1060, no plaintext tag), the accept-response
429
+ // event IS tagged with the invite's own ephemeral pubkey, so it's the
430
+ // one part of this protocol a stale listener can actually recover from
431
+ // without live delivery. Exported so a long-running caller (a script
432
+ // using OwliAgent for days at a time) can call this periodically as
433
+ // cheap insurance, not just once at startup.
434
+ export async function refreshInviteListen(secretKey, myPubkey) {
435
+ const invite = await publishOwnInvite(secretKey, myPubkey);
436
+ if (ownInviteListenUnsub) ownInviteListenUnsub();
437
+ // Mirror of accept()'s encryptor above - listen() takes a raw key or a
438
+ // decrypt callback natively; a signer's nip44Decrypt does the same
439
+ // DH-based decrypt remotely.
440
+ const decryptor = isSignerBacked(secretKey) ? signerLib.decryptorFor(secretKey.signer) : secretKey;
441
+ ownInviteListenUnsub = invite.listen(decryptor, ratchetSubscribe, async (session, contactPubkey) => {
442
+ // Our own invite is long-lived and reused across unlocks (by design,
443
+ // so it stays discoverable at a stable identity), so the SAME
444
+ // accept-response event a contact published when they first messaged
445
+ // us is still sitting on relays and gets redelivered every time this
446
+ // listener starts fresh - Invite.listen's own dedup is per-instance/
447
+ // in-memory and doesn't survive a reload. Compare by *which event*
448
+ // produced each session (session.name === the causing event.id, set
449
+ // internally by Invite.listen), not just "do we have any session for
450
+ // this contact": identical event.id means a genuine replay of one
451
+ // we've already processed (skip it); a different event.id means the
452
+ // contact published a fresh accept-response - a real, legitimate
453
+ // re-bootstrap (e.g. they lost their own local session state) - and
454
+ // should replace our stale session, not be ignored by it.
455
+ const existing = sessions.get(contactPubkey) || (await loadSession(contactPubkey));
456
+ if (existing && existing.name === session.name) return;
457
+ sessions.set(contactPubkey, session);
458
+ await persist(contactPubkey, session);
459
+ refreshMessageSubscription();
460
+ if (onNewSessionCallback) onNewSessionCallback(contactPubkey);
461
+ });
462
+ }
463
+
464
+ // Starts the ratchet subsystem for the current unlocked session: loads
465
+ // every contact's persisted session, publishes this device's own invite,
466
+ // and listens for both new inbound sessions (someone else discovered our
467
+ // invite and messaged us first) and messages on existing sessions. Call
468
+ // once per unlock, alongside subscribeInbox - a separate transport
469
+ // layered under the same contact list, not a replacement for it.
470
+ export async function start(secretKey, myPubkey, onMessage, onNewSession, onGroupRumor, onTradeRumor, onReaction, onReceipt) {
471
+ onMessageCallback = onMessage;
472
+ onNewSessionCallback = onNewSession;
473
+ onGroupRumorCallback = onGroupRumor;
474
+ onTradeRumorCallback = onTradeRumor;
475
+ onReactionCallback = onReaction;
476
+ onReceiptCallback = onReceipt;
477
+
478
+ const contacts = await storage.getContacts();
479
+ for (const c of contacts) await loadSession(c.pubkey);
480
+ refreshMessageSubscription();
481
+
482
+ await refreshInviteListen(secretKey, myPubkey);
483
+ }
484
+
485
+ export function stop() {
486
+ if (ownInviteListenUnsub) { ownInviteListenUnsub(); ownInviteListenUnsub = null; }
487
+ if (msgSubUnsub) { msgSubUnsub(); msgSubUnsub = null; }
488
+ msgSubAuthorsKey = null;
489
+ sessions.clear();
490
+ onMessageCallback = null;
491
+ onNewSessionCallback = null;
492
+ onGroupRumorCallback = null;
493
+ onTradeRumorCallback = null;
494
+ onReactionCallback = null;
495
+ onReceiptCallback = null;
496
+ }
497
+
498
+ // A contact's session is dropped locally when they're removed/declined
499
+ // (storage.js's removeContact/declineContact already clear the persisted
500
+ // copy) - drop the in-memory copy too so a stale session object can't
501
+ // linger and get reused if the same pubkey is re-added later.
502
+ export function forget(contactPubkey) {
503
+ sessions.delete(contactPubkey);
504
+ refreshMessageSubscription();
505
+ }
package/src/signer.js ADDED
@@ -0,0 +1,110 @@
1
+ // Adapted from webapp/src/lib/signer.js (2026-08-28) - included here only
2
+ // because ratchet.js (copied verbatim into this SDK) statically imports it;
3
+ // NIP-46 remote-signer support isn't wired into OwliAgent's public API yet
4
+ // (v1.1 per the SDK plan). The only real change from the browser version:
5
+ // the 4 localStorage call sites persisting a signer connection now go to a
6
+ // small local JSON file instead - everything else, including the doc
7
+ // comments explaining the actual NIP-46 integration, is unchanged.
8
+ import { BunkerSigner, parseBunkerInput, createNostrConnectURI } from 'nostr-tools/nip46';
9
+ import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
10
+ import { SimplePool } from 'nostr-tools/pool';
11
+ import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { homedir } from 'node:os';
14
+
15
+ const CONNECTION_FILE = join(homedir(), '.owli-agent', 'signer-connection.json');
16
+
17
+ let pool = null;
18
+ function getSignerPool() {
19
+ if (!pool) pool = new SimplePool();
20
+ return pool;
21
+ }
22
+
23
+ // The *client* keypair used to talk to the signer over relays - not the
24
+ // identity key, generated fresh per connection, safe to persist locally
25
+ // (it only ever protects the Owli<->signer conversation, never the
26
+ // identity itself).
27
+ function loadOrCreateClientKey() {
28
+ if (!existsSync(CONNECTION_FILE)) return null;
29
+ const saved = JSON.parse(readFileSync(CONNECTION_FILE, 'utf8'));
30
+ return { clientSecretKey: Uint8Array.from(Object.values(saved.clientSecretKey)), bunkerPointer: saved.bunkerPointer };
31
+ }
32
+
33
+ function persistConnection(clientSecretKey, bunkerPointer) {
34
+ writeFileSync(CONNECTION_FILE, JSON.stringify({ clientSecretKey: Array.from(clientSecretKey), bunkerPointer }));
35
+ }
36
+
37
+ export function hasSavedConnection() {
38
+ return existsSync(CONNECTION_FILE);
39
+ }
40
+
41
+ export function forgetConnection() {
42
+ if (existsSync(CONNECTION_FILE)) unlinkSync(CONNECTION_FILE);
43
+ }
44
+
45
+ // Reconnects using a previously-approved pairing - no new prompt on the
46
+ // signer app needed, same as reopening any app you've already granted
47
+ // access to once.
48
+ export async function reconnect() {
49
+ const saved = loadOrCreateClientKey();
50
+ if (!saved) return null;
51
+ return BunkerSigner.fromBunker(saved.clientSecretKey, saved.bunkerPointer, { pool: getSignerPool() });
52
+ }
53
+
54
+ // One-time pairing via a bunker:// URL or a NIP-05-style bunker address,
55
+ // pasted from the signer app. Persists the connection so future runs
56
+ // reconnect silently via reconnect() above.
57
+ export async function connectWithBunkerURL(input) {
58
+ const bunkerPointer = await parseBunkerInput(input);
59
+ if (!bunkerPointer) throw new Error("That doesn't look like a valid bunker link.");
60
+ const clientSecretKey = generateSecretKey();
61
+ const signer = BunkerSigner.fromBunker(clientSecretKey, bunkerPointer, { pool: getSignerPool() });
62
+ await signer.getPublicKey(); // forces the connection handshake now, surfaces a bad link immediately
63
+ persistConnection(clientSecretKey, bunkerPointer);
64
+ return signer;
65
+ }
66
+
67
+ // One-time pairing the other direction - Owli generates a nostrconnect://
68
+ // link/QR, the signer app scans it and approves. onConnected fires once
69
+ // the signer app actually approves (this can take a while - it's a human
70
+ // tapping "approve" on their phone, not an instant round-trip).
71
+ export function startConnectFlow({ appName = 'Owli Agent', onConnected, onError }) {
72
+ const clientSecretKey = generateSecretKey();
73
+ const clientPubkey = getPublicKey(clientSecretKey);
74
+ const relay = 'wss://relay.nsec.app';
75
+ const uri = createNostrConnectURI({ clientPubkey, relays: [relay], appName });
76
+ BunkerSigner.fromURI(clientSecretKey, uri, { pool: getSignerPool() })
77
+ .then(async (signer) => {
78
+ await signer.getPublicKey();
79
+ persistConnection(clientSecretKey, signer.bp);
80
+ onConnected(signer);
81
+ })
82
+ .catch((err) => onError(err));
83
+ return uri;
84
+ }
85
+
86
+ // --- Adapters bridging BunkerSigner's real NIP-46 methods to the exact
87
+ // shapes ratchet.js's three real identity-key touch points expect. Kept
88
+ // here, not duplicated at each call site. ---
89
+
90
+ // finalizeEvent(unsignedEvent, secretKey) needs a raw key and can't take a
91
+ // callback - BunkerSigner.signEvent already returns a complete, signed
92
+ // VerifiedEvent on its own, so this replaces the finalizeEvent call
93
+ // entirely rather than feeding into it.
94
+ export async function signEventWithSigner(signer, unsignedEvent) {
95
+ return signer.signEvent(unsignedEvent);
96
+ }
97
+
98
+ // Invite.accept()'s EncryptFunction type is (plaintext, pubkey) - note the
99
+ // reversed argument order from BunkerSigner's own nip44Encrypt(pubkey,
100
+ // plaintext), easy to get backwards, confirmed against both type
101
+ // definitions directly before writing this.
102
+ export function encryptorFor(signer) {
103
+ return (plaintext, pubkey) => signer.nip44Encrypt(pubkey, plaintext);
104
+ }
105
+
106
+ // Invite.listen()'s DecryptFunction type is (ciphertext, pubkey) - same
107
+ // reversed-order note as encryptorFor above.
108
+ export function decryptorFor(signer) {
109
+ return (ciphertext, pubkey) => signer.nip44Decrypt(pubkey, ciphertext);
110
+ }
@@ -0,0 +1,14 @@
1
+ // The low-level KV interface every storage backend implements. Deliberately
2
+ // the same shape as nostr-double-ratchet's own internal StorageAdapter type
3
+ // (get/put/del/list) so a future backend (SQLite, Redis, or wiring in the
4
+ // library's own GroupManager if group chat is ever added here) drops in
5
+ // without touching storage.js's 6-function public surface.
6
+ //
7
+ // This file is documentation, not code that runs - a plain JS project has
8
+ // no interfaces to implement against, so this is the contract every
9
+ // *Backend.js file in this folder honors:
10
+ //
11
+ // async get(key: string): Promise<string | null>
12
+ // async set(key: string, value: string): Promise<void>
13
+ // async del(key: string): Promise<void>
14
+ // async keys(prefix: string): Promise<string[]>