@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
|
@@ -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
|
+
}
|
package/src/transport.js
ADDED
|
@@ -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
|
+
}
|
package/src/websocket.js
ADDED
|
@@ -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
|
+
}
|