@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/LICENSE +21 -0
- package/README.md +170 -0
- package/examples/claude-bridge.mjs +49 -0
- package/examples/echo-bot.mjs +21 -0
- package/examples/gemini-bridge.mjs +47 -0
- package/examples/groq-bridge.mjs +44 -0
- package/examples/lmstudio-bridge.mjs +44 -0
- package/examples/mistral-bridge.mjs +44 -0
- package/examples/ollama-bridge.mjs +47 -0
- package/examples/openai-bridge.mjs +45 -0
- package/examples/openai-compatible-bridge.mjs +56 -0
- package/package.json +39 -0
- package/src/identity.js +70 -0
- package/src/index.js +257 -0
- package/src/ratchet.js +505 -0
- package/src/signer.js +110 -0
- package/src/storage/backend.js +14 -0
- package/src/storage/fileBackend.js +152 -0
- package/src/storage/memoryBackend.js +25 -0
- package/src/storage.js +71 -0
- package/src/transport.js +267 -0
- package/src/websocket.js +17 -0
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[]>
|