gupt-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/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # gupt-sdk
2
+
3
+ Node.js SDK for running end-to-end encrypted GUPT bots over user-selected relays.
4
+
5
+ The SDK currently supports one-to-one text messages. Use a dedicated bot identity; never reuse a
6
+ personal GUPT key.
7
+
8
+ ```js
9
+ import { GuptBot } from "gupt-sdk";
10
+
11
+ const bot = new GuptBot({
12
+ secretHex: process.env.GUPT_KEY,
13
+ relays: ["wss://relay-a.example", "wss://relay-b.example"],
14
+ });
15
+
16
+ bot.onMessage(async (ctx) => {
17
+ if (ctx.text.startsWith("/echo ")) await ctx.reply(ctx.text.slice(6));
18
+ });
19
+
20
+ bot.onError((error) => console.error(error.message));
21
+ await bot.start();
22
+ ```
23
+
24
+ At least two distinct `wss://` bootstrap relays are required. The default Originless server is
25
+ `https://originless.gupt.app`; pass `originless` as a URL or URL array to override it.
26
+
27
+ Inbound messages teach the bot both the ingress relay and the sender relay hint carried in the
28
+ signed `p` tag. Learned hints are bounded and obvious local/private addresses are rejected. Replies
29
+ try the ingress relay first, then the peer's learned relays and the configured bootstrap relays.
30
+
31
+ By default, handlers accept all human senders with a one-second per-sender cooldown. Supply an
32
+ `allowlist`, call `bot.allow(pubkey)`, or tune `senderCooldownMs` as needed. SDK-generated messages
33
+ carry `bot: true` and are ignored by other SDK bots unless `acceptBotMessages` is enabled.
34
+
35
+ Call `bot.stop()` during shutdown to close relay connections and cancel queued sends.
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "gupt-sdk",
3
+ "version": "0.1.0",
4
+ "description": "Node.js SDK for building end-to-end encrypted GUPT bots",
5
+ "homepage": "https://github.com/besoeasy/gupt/tree/main/sdk#readme",
6
+ "bugs": "https://github.com/besoeasy/gupt/issues",
7
+ "license": "CC-BY-NC-4.0",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/besoeasy/gupt.git",
11
+ "directory": "sdk"
12
+ },
13
+ "files": [
14
+ "src/"
15
+ ],
16
+ "type": "module",
17
+ "exports": {
18
+ ".": "./src/index.js",
19
+ "./ingestion": "./src/ingestion.js",
20
+ "./queue": "./src/queue.js",
21
+ "./relay-book": "./src/relayBook.js",
22
+ "./wire": "./src/wire.js"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "registry": "https://registry.npmjs.org"
27
+ },
28
+ "scripts": {
29
+ "test": "node --test \"test/*.test.mjs\""
30
+ },
31
+ "dependencies": {
32
+ "@noble/ciphers": "^2.3.0",
33
+ "@noble/hashes": "^2.3.0",
34
+ "@noble/secp256k1": "^3.1.0"
35
+ },
36
+ "engines": {
37
+ "node": ">=22.12.0"
38
+ }
39
+ }
package/src/index.js ADDED
@@ -0,0 +1,301 @@
1
+ import { IngestionPipeline, RECENCY_WINDOW_MS } from "./ingestion.js";
2
+ import { RelayPool } from "./pool.js";
3
+ import { SendQueue } from "./queue.js";
4
+ import { normalizeRelayUrl, RelayBook } from "./relayBook.js";
5
+ import {
6
+ buildDirectMessageEvent,
7
+ getPublicKey,
8
+ normalizePubkey,
9
+ normalizeSecretHex,
10
+ } from "./wire.js";
11
+
12
+ export const DEFAULT_ORIGINLESS_SERVERS = Object.freeze(["https://originless.gupt.app"]);
13
+ export const MAX_TEXT_LENGTH = 8_000;
14
+ export const DEFAULT_SENDER_COOLDOWN_MS = 1_000;
15
+ export const DEFAULT_REPLY_COOLDOWN_MS = 1_000;
16
+ export const DEFAULT_MAX_REPLIES_PER_MINUTE = 20;
17
+ export const DEFAULT_MAX_HANDLER_BACKLOG = 100;
18
+
19
+ function normalizeOriginlessUrl(value, allowPrivate) {
20
+ try {
21
+ const url = new URL(String(value || "").trim());
22
+ if (url.username || url.password || url.search || url.hash) return null;
23
+ if (url.protocol !== "https:" && !(allowPrivate && url.protocol === "http:")) return null;
24
+ url.pathname = url.pathname.replace(/\/upload\/?$/i, "").replace(/\/+$/, "");
25
+ return url.toString().replace(/\/$/, "");
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ function normalizeAllowlist(values) {
32
+ if (values == null) return null;
33
+ if (!Array.isArray(values) && !(values instanceof Set)) {
34
+ throw new TypeError("allowlist must be an array or Set of public keys");
35
+ }
36
+ return new Set([...values].map(normalizePubkey));
37
+ }
38
+
39
+ export class GuptBot {
40
+ constructor({
41
+ secretHex,
42
+ relays,
43
+ originless = DEFAULT_ORIGINLESS_SERVERS,
44
+ allowlist = null,
45
+ senderCooldownMs = DEFAULT_SENDER_COOLDOWN_MS,
46
+ replyCooldownMs = DEFAULT_REPLY_COOLDOWN_MS,
47
+ maxRepliesPerMinute = DEFAULT_MAX_REPLIES_PER_MINUTE,
48
+ maxHandlerBacklog = DEFAULT_MAX_HANDLER_BACKLOG,
49
+ acceptBotMessages = false,
50
+ allowPrivateRelays = false,
51
+ WebSocketImpl = globalThis.WebSocket,
52
+ onDrop = null,
53
+ logger = console,
54
+ poolOptions = {},
55
+ queueOptions = {},
56
+ } = {}) {
57
+ this.secretHex = normalizeSecretHex(secretHex);
58
+ this.pubkey = getPublicKey(this.secretHex);
59
+ this.allowPrivateRelays = Boolean(allowPrivateRelays);
60
+
61
+ if (!Array.isArray(relays)) throw new TypeError("relays must be an array");
62
+ const normalizedRelays = relays.map((relay) => {
63
+ const normalized = normalizeRelayUrl(relay, {
64
+ allowInsecure: this.allowPrivateRelays,
65
+ allowPrivate: this.allowPrivateRelays,
66
+ });
67
+ if (!normalized) throw new TypeError(`Invalid relay URL: ${String(relay)}`);
68
+ return normalized;
69
+ });
70
+ this.relays = [...new Set(normalizedRelays)];
71
+ if (this.relays.length < 2) throw new TypeError("At least 2 distinct relays are required");
72
+
73
+ const originlessValues = Array.isArray(originless) ? originless : [originless];
74
+ this.originlessServers = [
75
+ ...new Set(
76
+ originlessValues.map((server) => {
77
+ const normalized = normalizeOriginlessUrl(server, this.allowPrivateRelays);
78
+ if (!normalized) throw new TypeError(`Invalid Originless server URL: ${String(server)}`);
79
+ return normalized;
80
+ }),
81
+ ),
82
+ ];
83
+ if (!this.originlessServers.length) {
84
+ throw new TypeError("At least 1 Originless server is required");
85
+ }
86
+
87
+ this.allowlist = normalizeAllowlist(allowlist);
88
+ this.senderCooldownMs = Math.max(0, Number(senderCooldownMs) || 0);
89
+ this.replyCooldownMs = Math.max(0, Number(replyCooldownMs) || 0);
90
+ this.maxRepliesPerMinute = Math.max(1, Number(maxRepliesPerMinute) || 1);
91
+ this.maxHandlerBacklog = Math.max(1, Number(maxHandlerBacklog) || 1);
92
+ this.acceptBotMessages = Boolean(acceptBotMessages);
93
+ this.logger = logger;
94
+ this.status = "idle";
95
+ this.handlers = new Set();
96
+ this.errorHandlers = new Set();
97
+ this.handlerChains = new Map();
98
+ this.handlerBacklogs = new Map();
99
+ this.lastHandledAt = new Map();
100
+ this.replyWindows = new Map();
101
+
102
+ this.relayBook = new RelayBook(this.relays, { allowPrivateRelays: this.allowPrivateRelays });
103
+ this.ingestion = new IngestionPipeline({
104
+ secretHex: this.secretHex,
105
+ pubkey: this.pubkey,
106
+ onDrop,
107
+ });
108
+ this.queue = new SendQueue(queueOptions);
109
+ this.pool = new RelayPool({
110
+ ...poolOptions,
111
+ WebSocketImpl,
112
+ allowPrivateRelays: this.allowPrivateRelays,
113
+ onEvent: (event, relayUrl) => this.receiveEvent(event, relayUrl),
114
+ onRelayError: (error, relayUrl) => this.emitError(error, { relayUrl }),
115
+ });
116
+ }
117
+
118
+ onMessage(handler) {
119
+ if (typeof handler !== "function") throw new TypeError("Message handler must be a function");
120
+ this.handlers.add(handler);
121
+ return () => this.handlers.delete(handler);
122
+ }
123
+
124
+ onError(handler) {
125
+ if (typeof handler !== "function") throw new TypeError("Error handler must be a function");
126
+ this.errorHandlers.add(handler);
127
+ return () => this.errorHandlers.delete(handler);
128
+ }
129
+
130
+ allow(pubkey) {
131
+ if (!this.allowlist) this.allowlist = new Set();
132
+ this.allowlist.add(normalizePubkey(pubkey));
133
+ return this;
134
+ }
135
+
136
+ disallow(pubkey) {
137
+ this.allowlist?.delete(normalizePubkey(pubkey));
138
+ return this;
139
+ }
140
+
141
+ async start() {
142
+ if (this.status === "running") return this;
143
+ if (this.status !== "idle") throw new Error("A stopped GuptBot instance cannot be restarted");
144
+ this.status = "starting";
145
+ try {
146
+ await this.pool.start(this.relays, () => ({
147
+ kinds: [4],
148
+ "#p": [this.pubkey],
149
+ since: Math.floor((Date.now() - RECENCY_WINDOW_MS) / 1000),
150
+ limit: 200,
151
+ }));
152
+ this.status = "running";
153
+ return this;
154
+ } catch (error) {
155
+ this.status = "idle";
156
+ throw error;
157
+ }
158
+ }
159
+
160
+ stop() {
161
+ if (this.status === "stopped") return;
162
+ this.status = "stopped";
163
+ this.pool.stop();
164
+ this.queue.stop();
165
+ this.ingestion.close();
166
+ this.handlerChains.clear();
167
+ this.handlerBacklogs.clear();
168
+ }
169
+
170
+ receiveEvent(event, relayUrl) {
171
+ const message = this.ingestion.ingest(event, { relayUrl });
172
+ if (!message) return;
173
+
174
+ const discovered = this.relayBook.learn(
175
+ message.senderPubkey,
176
+ { sourceRelay: relayUrl, hintedRelay: message.relayHint },
177
+ message.receivedAt,
178
+ );
179
+ for (const relay of discovered) {
180
+ this.pool.addRelay(relay).catch((error) => this.emitError(error, { relayUrl: relay }));
181
+ }
182
+
183
+ const { payload, senderPubkey } = message;
184
+ if (payload.type !== "text" || typeof payload.text !== "string") return;
185
+ if (!payload.text.trim() || payload.text.length > MAX_TEXT_LENGTH) return;
186
+ if (payload.bot === true && !this.acceptBotMessages) return;
187
+ if (this.allowlist && !this.allowlist.has(senderPubkey)) return;
188
+
189
+ const lastHandledAt = this.lastHandledAt.get(senderPubkey) || 0;
190
+ if (message.receivedAt - lastHandledAt < this.senderCooldownMs) return;
191
+ this.lastHandledAt.set(senderPubkey, message.receivedAt);
192
+
193
+ const backlog = this.handlerBacklogs.get(senderPubkey) || 0;
194
+ if (backlog >= this.maxHandlerBacklog) {
195
+ this.emitError(new Error("Handler backlog limit reached"), {
196
+ eventId: event.id,
197
+ senderPubkey,
198
+ });
199
+ return;
200
+ }
201
+
202
+ this.handlerBacklogs.set(senderPubkey, backlog + 1);
203
+ const previous = this.handlerChains.get(senderPubkey) || Promise.resolve();
204
+ const next = previous
205
+ .catch(() => {})
206
+ .then(() => this.dispatchMessage(message))
207
+ .catch((error) =>
208
+ this.emitError(error, { eventId: message.event.id, senderPubkey: message.senderPubkey }),
209
+ )
210
+ .finally(() => {
211
+ const remaining = Math.max(0, (this.handlerBacklogs.get(senderPubkey) || 1) - 1);
212
+ if (remaining) this.handlerBacklogs.set(senderPubkey, remaining);
213
+ else {
214
+ this.handlerBacklogs.delete(senderPubkey);
215
+ if (this.handlerChains.get(senderPubkey) === next)
216
+ this.handlerChains.delete(senderPubkey);
217
+ }
218
+ });
219
+ this.handlerChains.set(senderPubkey, next);
220
+ }
221
+
222
+ async dispatchMessage(message) {
223
+ const context = Object.freeze({
224
+ id: message.event.id,
225
+ senderPubkey: message.senderPubkey,
226
+ text: message.payload.text,
227
+ payload: Object.freeze({ ...message.payload }),
228
+ relayUrl: message.relayUrl,
229
+ receivedAt: message.receivedAt,
230
+ reply: (text) => this.reply(message.senderPubkey, text, message.relayUrl),
231
+ });
232
+ for (const handler of this.handlers) await handler(context);
233
+ }
234
+
235
+ async reply(peerPubkey, text, ingressRelay = null) {
236
+ if (this.status !== "running") throw new Error("Bot is not running");
237
+ const peer = normalizePubkey(peerPubkey);
238
+ const replyText = String(text || "").trim();
239
+ if (!replyText) throw new TypeError("Reply text is required");
240
+ if (replyText.length > MAX_TEXT_LENGTH) {
241
+ throw new TypeError(`Reply text cannot exceed ${MAX_TEXT_LENGTH} characters`);
242
+ }
243
+ this.reserveReply(peer);
244
+
245
+ const event = buildDirectMessageEvent({
246
+ secretHex: this.secretHex,
247
+ recipientPubkey: peer,
248
+ payload: {
249
+ type: "text",
250
+ text: replyText,
251
+ ts: Date.now(),
252
+ bot: true,
253
+ },
254
+ });
255
+ const targets = this.relayBook.replyRelays(peer, ingressRelay);
256
+ return this.queue.enqueue({
257
+ id: event.id,
258
+ lane: peer,
259
+ fn: () => this.pool.publish(targets, event),
260
+ });
261
+ }
262
+
263
+ reserveReply(peer, now = Date.now()) {
264
+ const cutoff = now - 60_000;
265
+ const window = (this.replyWindows.get(peer) || []).filter((timestamp) => timestamp > cutoff);
266
+ const previous = window.at(-1) || 0;
267
+ if (now - previous < this.replyCooldownMs) {
268
+ throw new Error("Reply cooldown is active for this sender");
269
+ }
270
+ if (window.length >= this.maxRepliesPerMinute) {
271
+ throw new Error("Per-sender reply rate limit reached");
272
+ }
273
+ window.push(now);
274
+ this.replyWindows.set(peer, window);
275
+ }
276
+
277
+ emitError(error, context = {}) {
278
+ if (this.errorHandlers.size) {
279
+ for (const handler of this.errorHandlers) handler(error, context);
280
+ return;
281
+ }
282
+ this.logger?.error?.("[gupt-bot]", error?.message || String(error), context);
283
+ }
284
+
285
+ snapshot() {
286
+ return {
287
+ status: this.status,
288
+ pubkey: this.pubkey,
289
+ relays: [...this.relays],
290
+ originlessServers: [...this.originlessServers],
291
+ relayBook: this.relayBook.snapshot(),
292
+ relayPool: this.pool.snapshot(),
293
+ sendQueue: this.queue.snapshot(),
294
+ };
295
+ }
296
+ }
297
+
298
+ export * from "./ingestion.js";
299
+ export * from "./queue.js";
300
+ export * from "./relayBook.js";
301
+ export * from "./wire.js";
@@ -0,0 +1,127 @@
1
+ import {
2
+ assertDirectMessageEvent,
3
+ decryptDirectMessage,
4
+ getSenderRelayHint,
5
+ isExpiredEvent,
6
+ verifyEventSignature,
7
+ } from "./wire.js";
8
+
9
+ export const RECENCY_WINDOW_MS = 100_000;
10
+ export const FUTURE_SKEW_MS = 30_000;
11
+ export const SEEN_TTL_MS = 5 * 60_000;
12
+ export const MAX_SEEN_EVENTS = 10_000;
13
+
14
+ export class SeenEventTracker {
15
+ constructor({
16
+ ttlMs = SEEN_TTL_MS,
17
+ maxEntries = MAX_SEEN_EVENTS,
18
+ sweepIntervalMs = 60_000,
19
+ clock = Date.now,
20
+ } = {}) {
21
+ this.ttlMs = ttlMs;
22
+ this.maxEntries = maxEntries;
23
+ this.clock = clock;
24
+ this.entries = new Map();
25
+ this.sweepTimer = setInterval(() => this.sweep(), sweepIntervalMs);
26
+ this.sweepTimer.unref?.();
27
+ }
28
+
29
+ has(id, now = this.clock()) {
30
+ const expiresAt = this.entries.get(id);
31
+ if (expiresAt == null) return false;
32
+ if (expiresAt <= now) {
33
+ this.entries.delete(id);
34
+ return false;
35
+ }
36
+ return true;
37
+ }
38
+
39
+ add(id, now = this.clock()) {
40
+ this.sweep(now);
41
+ this.entries.delete(id);
42
+ this.entries.set(id, now + this.ttlMs);
43
+ while (this.entries.size > this.maxEntries) {
44
+ this.entries.delete(this.entries.keys().next().value);
45
+ }
46
+ }
47
+
48
+ sweep(now = this.clock()) {
49
+ for (const [id, expiresAt] of this.entries) {
50
+ if (expiresAt <= now) this.entries.delete(id);
51
+ }
52
+ }
53
+
54
+ close() {
55
+ clearInterval(this.sweepTimer);
56
+ this.entries.clear();
57
+ }
58
+ }
59
+
60
+ export class IngestionPipeline {
61
+ constructor({
62
+ secretHex,
63
+ pubkey,
64
+ recencyWindowMs = RECENCY_WINDOW_MS,
65
+ futureSkewMs = FUTURE_SKEW_MS,
66
+ seenTracker = new SeenEventTracker(),
67
+ clock = Date.now,
68
+ onDrop = null,
69
+ }) {
70
+ this.secretHex = secretHex;
71
+ this.pubkey = pubkey;
72
+ this.recencyWindowMs = recencyWindowMs;
73
+ this.futureSkewMs = futureSkewMs;
74
+ this.seenTracker = seenTracker;
75
+ this.clock = clock;
76
+ this.onDrop = onDrop;
77
+ }
78
+
79
+ drop(reason, event, relayUrl) {
80
+ this.onDrop?.({ reason, eventId: event?.id || null, relayUrl });
81
+ return null;
82
+ }
83
+
84
+ ingest(event, { relayUrl = null, now = this.clock() } = {}) {
85
+ try {
86
+ assertDirectMessageEvent(event, this.pubkey);
87
+ } catch {
88
+ return this.drop("invalid-event", event, relayUrl);
89
+ }
90
+
91
+ const createdAt = event.created_at * 1000;
92
+ if (createdAt < now - this.recencyWindowMs) {
93
+ return this.drop("too-old", event, relayUrl);
94
+ }
95
+ if (createdAt > now + this.futureSkewMs) {
96
+ return this.drop("from-future", event, relayUrl);
97
+ }
98
+ if (isExpiredEvent(event, now)) {
99
+ return this.drop("expired", event, relayUrl);
100
+ }
101
+ if (this.seenTracker.has(event.id, now)) {
102
+ return this.drop("duplicate", event, relayUrl);
103
+ }
104
+ if (!verifyEventSignature(event)) {
105
+ return this.drop("invalid-signature", event, relayUrl);
106
+ }
107
+
108
+ this.seenTracker.add(event.id, now);
109
+
110
+ try {
111
+ return {
112
+ event,
113
+ payload: decryptDirectMessage(event, this.secretHex, this.pubkey),
114
+ senderPubkey: event.pubkey,
115
+ relayHint: getSenderRelayHint(event, this.pubkey),
116
+ relayUrl,
117
+ receivedAt: now,
118
+ };
119
+ } catch {
120
+ return this.drop("invalid-ciphertext", event, relayUrl);
121
+ }
122
+ }
123
+
124
+ close() {
125
+ this.seenTracker.close();
126
+ }
127
+ }
package/src/pool.js ADDED
@@ -0,0 +1,278 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { isIP } from "node:net";
3
+
4
+ import { isPrivateRelayHostname } from "./relayBook.js";
5
+
6
+ export const CONNECT_TIMEOUT_MS = 5_000;
7
+ export const PUBLISH_TIMEOUT_MS = 5_000;
8
+ export const MAX_RECONNECT_DELAY_MS = 30_000;
9
+ export const MAX_RELAY_FRAME_BYTES = 256 * 1024;
10
+
11
+ async function assertPublicRelayAddress(relay, allowPrivateRelays) {
12
+ if (allowPrivateRelays) return;
13
+ const { hostname } = new URL(relay);
14
+ if (isPrivateRelayHostname(hostname)) throw new Error(`Private relay address rejected: ${relay}`);
15
+ if (isIP(hostname.replace(/^\[|\]$/g, ""))) return;
16
+ const addresses = await lookup(hostname, { all: true, verbatim: true });
17
+ if (!addresses.length || addresses.some(({ address }) => isPrivateRelayHostname(address))) {
18
+ throw new Error(`Relay resolved to a private address: ${relay}`);
19
+ }
20
+ }
21
+
22
+ function messageText(data) {
23
+ if (typeof data === "string") return data;
24
+ if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
25
+ if (ArrayBuffer.isView(data)) {
26
+ return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8");
27
+ }
28
+ return "";
29
+ }
30
+
31
+ export class RelayPool {
32
+ constructor({
33
+ WebSocketImpl = globalThis.WebSocket,
34
+ allowPrivateRelays = false,
35
+ connectTimeoutMs = CONNECT_TIMEOUT_MS,
36
+ publishTimeoutMs = PUBLISH_TIMEOUT_MS,
37
+ maxFrameBytes = MAX_RELAY_FRAME_BYTES,
38
+ onEvent = null,
39
+ onRelayError = null,
40
+ } = {}) {
41
+ if (!WebSocketImpl) throw new Error("Node.js 22.12 or newer is required for WebSocket support");
42
+ this.WebSocketImpl = WebSocketImpl;
43
+ this.allowPrivateRelays = allowPrivateRelays;
44
+ this.connectTimeoutMs = connectTimeoutMs;
45
+ this.publishTimeoutMs = publishTimeoutMs;
46
+ this.maxFrameBytes = maxFrameBytes;
47
+ this.onEvent = onEvent;
48
+ this.onRelayError = onRelayError;
49
+ this.states = new Map();
50
+ this.desired = new Set();
51
+ this.pendingPublishes = new Map();
52
+ this.filterFactory = null;
53
+ this.running = false;
54
+ }
55
+
56
+ async start(relays, filterFactory) {
57
+ if (this.running) return;
58
+ this.running = true;
59
+ this.filterFactory =
60
+ typeof filterFactory === "function" ? filterFactory : () => ({ ...(filterFactory || {}) });
61
+ const results = await Promise.allSettled(relays.map((relay) => this.addRelay(relay)));
62
+ if (!results.some((result) => result.status === "fulfilled")) {
63
+ this.stop();
64
+ throw new Error("Could not connect to any configured relay");
65
+ }
66
+ }
67
+
68
+ async addRelay(relay) {
69
+ this.desired.add(relay);
70
+ try {
71
+ return await this.connect(relay);
72
+ } catch (error) {
73
+ if (this.running) this.scheduleReconnect(relay);
74
+ throw error;
75
+ }
76
+ }
77
+
78
+ stateFor(relay) {
79
+ if (!this.states.has(relay)) {
80
+ this.states.set(relay, {
81
+ socket: null,
82
+ connectPromise: null,
83
+ reconnectAttempts: 0,
84
+ reconnectTimer: null,
85
+ subId: null,
86
+ });
87
+ }
88
+ return this.states.get(relay);
89
+ }
90
+
91
+ async connect(relay) {
92
+ const state = this.stateFor(relay);
93
+ if (state.socket?.readyState === this.WebSocketImpl.OPEN) return state.socket;
94
+ if (state.connectPromise) return state.connectPromise;
95
+
96
+ state.connectPromise = (async () => {
97
+ await assertPublicRelayAddress(relay, this.allowPrivateRelays);
98
+ return new Promise((resolve, reject) => {
99
+ const socket = new this.WebSocketImpl(relay);
100
+ state.socket = socket;
101
+ let settled = false;
102
+ const timeout = setTimeout(() => {
103
+ if (settled) return;
104
+ settled = true;
105
+ socket.close();
106
+ reject(new Error(`Timed out connecting to ${relay}`));
107
+ }, this.connectTimeoutMs);
108
+
109
+ socket.addEventListener("open", () => {
110
+ if (settled) return;
111
+ settled = true;
112
+ clearTimeout(timeout);
113
+ state.reconnectAttempts = 0;
114
+ state.subId = `gupt_bot_${crypto.randomUUID().replaceAll("-", "")}`;
115
+ if (this.filterFactory) {
116
+ socket.send(JSON.stringify(["REQ", state.subId, this.filterFactory(relay)]));
117
+ }
118
+ resolve(socket);
119
+ });
120
+
121
+ socket.addEventListener("message", (message) => this.handleMessage(relay, state, message));
122
+
123
+ socket.addEventListener("error", () => {
124
+ if (!settled) {
125
+ settled = true;
126
+ clearTimeout(timeout);
127
+ socket.close();
128
+ reject(new Error(`Failed to connect to ${relay}`));
129
+ }
130
+ });
131
+
132
+ socket.addEventListener("close", () => {
133
+ clearTimeout(timeout);
134
+ if (!settled) {
135
+ settled = true;
136
+ reject(new Error(`Connection closed before opening: ${relay}`));
137
+ }
138
+ if (state.socket === socket) {
139
+ state.socket = null;
140
+ state.subId = null;
141
+ }
142
+ this.rejectPendingForRelay(relay, new Error(`Relay connection closed: ${relay}`));
143
+ if (this.running && this.desired.has(relay)) this.scheduleReconnect(relay);
144
+ });
145
+ });
146
+ })();
147
+
148
+ try {
149
+ return await state.connectPromise;
150
+ } finally {
151
+ state.connectPromise = null;
152
+ }
153
+ }
154
+
155
+ handleMessage(relay, state, message) {
156
+ const text = messageText(message.data);
157
+ if (!text || Buffer.byteLength(text, "utf8") > this.maxFrameBytes) return;
158
+
159
+ let frame;
160
+ try {
161
+ frame = JSON.parse(text);
162
+ } catch {
163
+ return;
164
+ }
165
+ if (!Array.isArray(frame)) return;
166
+
167
+ if (frame[0] === "EVENT" && frame[1] === state.subId && frame[2]) {
168
+ this.onEvent?.(frame[2], relay);
169
+ return;
170
+ }
171
+ if (frame[0] === "OK" && typeof frame[1] === "string") {
172
+ const key = `${relay}:${frame[1]}`;
173
+ const pending = this.pendingPublishes.get(key);
174
+ if (!pending) return;
175
+ this.pendingPublishes.delete(key);
176
+ clearTimeout(pending.timer);
177
+ if (frame[2]) pending.resolve({ relay, id: frame[1] });
178
+ else pending.reject(new Error(`Relay rejected event: ${String(frame[3] || "rejected")}`));
179
+ return;
180
+ }
181
+ if (frame[0] === "CLOSED" && frame[1] === state.subId) {
182
+ this.onRelayError?.(
183
+ new Error(`Relay closed subscription: ${String(frame[2] || relay)}`),
184
+ relay,
185
+ );
186
+ }
187
+ }
188
+
189
+ scheduleReconnect(relay) {
190
+ const state = this.stateFor(relay);
191
+ if (state.reconnectTimer || state.connectPromise) return;
192
+ state.reconnectAttempts++;
193
+ const ceiling = Math.min(1_000 * 2 ** (state.reconnectAttempts - 1), MAX_RECONNECT_DELAY_MS);
194
+ const delay = Math.floor(ceiling / 2 + Math.random() * (ceiling / 2));
195
+ state.reconnectTimer = setTimeout(() => {
196
+ state.reconnectTimer = null;
197
+ this.connect(relay).catch((error) => {
198
+ this.onRelayError?.(error, relay);
199
+ this.scheduleReconnect(relay);
200
+ });
201
+ }, delay);
202
+ }
203
+
204
+ publish(relays, event, { timeoutMs = this.publishTimeoutMs } = {}) {
205
+ const targets = [...new Set(relays)].filter(Boolean);
206
+ if (!targets.length) return Promise.reject(new Error("No relay available for reply"));
207
+ return Promise.any(targets.map((relay) => this.publishOne(relay, event, timeoutMs))).catch(
208
+ (error) => {
209
+ const reasons = error?.errors?.map((entry) => entry.message).filter(Boolean) || [];
210
+ throw new Error(reasons.length ? reasons.join(" | ") : "All relays rejected the event");
211
+ },
212
+ );
213
+ }
214
+
215
+ async publishOne(relay, event, timeoutMs) {
216
+ const socket = await this.connect(relay);
217
+ return new Promise((resolve, reject) => {
218
+ const key = `${relay}:${event.id}`;
219
+ if (this.pendingPublishes.has(key)) {
220
+ reject(new Error(`Event ${event.id} is already pending on ${relay}`));
221
+ return;
222
+ }
223
+ const timer = setTimeout(() => {
224
+ this.pendingPublishes.delete(key);
225
+ reject(new Error(`Timed out publishing to ${relay}`));
226
+ }, timeoutMs);
227
+ this.pendingPublishes.set(key, { resolve, reject, timer });
228
+ try {
229
+ socket.send(JSON.stringify(["EVENT", event]));
230
+ } catch (error) {
231
+ clearTimeout(timer);
232
+ this.pendingPublishes.delete(key);
233
+ reject(error);
234
+ }
235
+ });
236
+ }
237
+
238
+ rejectPendingForRelay(relay, error) {
239
+ for (const [key, pending] of this.pendingPublishes) {
240
+ if (!key.startsWith(`${relay}:`)) continue;
241
+ clearTimeout(pending.timer);
242
+ pending.reject(error);
243
+ this.pendingPublishes.delete(key);
244
+ }
245
+ }
246
+
247
+ stop() {
248
+ this.running = false;
249
+ for (const state of this.states.values()) {
250
+ clearTimeout(state.reconnectTimer);
251
+ state.reconnectTimer = null;
252
+ state.socket?.close();
253
+ state.socket = null;
254
+ }
255
+ for (const pending of this.pendingPublishes.values()) {
256
+ clearTimeout(pending.timer);
257
+ pending.reject(new Error("Relay pool stopped"));
258
+ }
259
+ this.pendingPublishes.clear();
260
+ this.desired.clear();
261
+ }
262
+
263
+ snapshot() {
264
+ return {
265
+ running: this.running,
266
+ relays: Object.fromEntries(
267
+ [...this.states].map(([relay, state]) => [
268
+ relay,
269
+ {
270
+ desired: this.desired.has(relay),
271
+ connected: state.socket?.readyState === this.WebSocketImpl.OPEN,
272
+ reconnectAttempts: state.reconnectAttempts,
273
+ },
274
+ ]),
275
+ ),
276
+ };
277
+ }
278
+ }
package/src/queue.js ADDED
@@ -0,0 +1,174 @@
1
+ export const BASE_DELAY_MS = 1_000;
2
+ export const MAX_DELAY_MS = 3 * 60_000;
3
+ export const MAX_ATTEMPTS = 8;
4
+ export const GLOBAL_THROTTLE_MS = 1_200;
5
+ export const MAX_PENDING_SENDS = 1_000;
6
+
7
+ export class PermanentSendError extends Error {
8
+ constructor(message, options) {
9
+ super(message, options);
10
+ this.name = "PermanentSendError";
11
+ }
12
+ }
13
+
14
+ export class SendQueue {
15
+ constructor({
16
+ baseDelayMs = BASE_DELAY_MS,
17
+ maxDelayMs = MAX_DELAY_MS,
18
+ maxAttempts = MAX_ATTEMPTS,
19
+ throttleMs = GLOBAL_THROTTLE_MS,
20
+ maxPending = MAX_PENDING_SENDS,
21
+ clock = Date.now,
22
+ } = {}) {
23
+ this.baseDelayMs = Math.max(0, Number(baseDelayMs) || 0);
24
+ this.maxDelayMs = Math.max(this.baseDelayMs, Number(maxDelayMs) || 0);
25
+ this.maxAttempts = Math.max(1, Math.floor(Number(maxAttempts) || 1));
26
+ this.throttleMs = Math.max(0, Number(throttleMs) || 0);
27
+ this.maxPending = Math.max(1, Math.floor(Number(maxPending) || 1));
28
+ this.clock = clock;
29
+ this.lastStartedAt = 0;
30
+ this.pendingCount = 0;
31
+ this.lanes = new Map();
32
+ this.ids = new Set();
33
+ this.waiters = new Set();
34
+ this.dispatchChain = Promise.resolve();
35
+ this.stopped = false;
36
+ }
37
+
38
+ enqueue({ id, lane = "__default__", fn }) {
39
+ const taskId = String(id || "").trim();
40
+ const laneId = String(lane || "__default__");
41
+ if (this.stopped) return Promise.reject(new Error("Send queue is stopped"));
42
+ if (!taskId) return Promise.reject(new TypeError("Send task id is required"));
43
+ if (typeof fn !== "function") return Promise.reject(new TypeError("Send task fn is required"));
44
+ if (this.ids.has(taskId)) return Promise.reject(new Error(`Duplicate send task: ${taskId}`));
45
+ if (this.pendingCount >= this.maxPending)
46
+ return Promise.reject(new Error("Send queue is full"));
47
+
48
+ let resolveTask;
49
+ let rejectTask;
50
+ const result = new Promise((resolve, reject) => {
51
+ resolveTask = resolve;
52
+ rejectTask = reject;
53
+ });
54
+ const task = {
55
+ id: taskId,
56
+ fn,
57
+ attempts: 0,
58
+ resolve: resolveTask,
59
+ reject: rejectTask,
60
+ };
61
+
62
+ if (!this.lanes.has(laneId)) this.lanes.set(laneId, { running: false, tasks: [] });
63
+ this.lanes.get(laneId).tasks.push(task);
64
+ this.ids.add(taskId);
65
+ this.pendingCount++;
66
+ void this.drainLane(laneId);
67
+ return result;
68
+ }
69
+
70
+ retryDelay(attempts) {
71
+ return Math.min(this.baseDelayMs * 2 ** Math.max(0, attempts - 1), this.maxDelayMs);
72
+ }
73
+
74
+ acquireThrottle() {
75
+ const turn = this.dispatchChain.then(async () => {
76
+ const waitMs = Math.max(0, this.lastStartedAt + this.throttleMs - this.clock());
77
+ if (waitMs) await this.wait(waitMs);
78
+ if (this.stopped) throw new Error("Send queue is stopped");
79
+ this.lastStartedAt = this.clock();
80
+ });
81
+ this.dispatchChain = turn.catch(() => {});
82
+ return turn;
83
+ }
84
+
85
+ wait(delayMs) {
86
+ return new Promise((resolve) => {
87
+ const waiter = {
88
+ timer: setTimeout(() => {
89
+ this.waiters.delete(waiter);
90
+ resolve();
91
+ }, delayMs),
92
+ resolve,
93
+ };
94
+ this.waiters.add(waiter);
95
+ });
96
+ }
97
+
98
+ async drainLane(laneId) {
99
+ const lane = this.lanes.get(laneId);
100
+ if (!lane || lane.running) return;
101
+ lane.running = true;
102
+
103
+ try {
104
+ while (lane.tasks.length) {
105
+ const task = lane.tasks[0];
106
+ let completed = false;
107
+
108
+ while (!completed && task.attempts < this.maxAttempts) {
109
+ try {
110
+ await this.acquireThrottle();
111
+ task.attempts++;
112
+ const value = await task.fn({ attempt: task.attempts });
113
+ this.finishTask(lane, task);
114
+ task.resolve(value);
115
+ completed = true;
116
+ } catch (error) {
117
+ const final =
118
+ this.stopped ||
119
+ error instanceof PermanentSendError ||
120
+ task.attempts >= this.maxAttempts;
121
+ if (final) {
122
+ this.finishTask(lane, task);
123
+ task.reject(error);
124
+ completed = true;
125
+ } else {
126
+ await this.wait(this.retryDelay(task.attempts));
127
+ }
128
+ }
129
+ }
130
+ }
131
+ } finally {
132
+ lane.running = false;
133
+ if (!lane.tasks.length) this.lanes.delete(laneId);
134
+ }
135
+ }
136
+
137
+ finishTask(lane, task) {
138
+ if (lane.tasks[0] === task) lane.tasks.shift();
139
+ else lane.tasks.splice(lane.tasks.indexOf(task), 1);
140
+ this.ids.delete(task.id);
141
+ this.pendingCount = Math.max(0, this.pendingCount - 1);
142
+ }
143
+
144
+ stop() {
145
+ if (this.stopped) return;
146
+ this.stopped = true;
147
+ for (const waiter of this.waiters) {
148
+ clearTimeout(waiter.timer);
149
+ waiter.resolve();
150
+ }
151
+ this.waiters.clear();
152
+
153
+ for (const lane of this.lanes.values()) {
154
+ for (const task of lane.tasks) task.reject(new Error("Send queue is stopped"));
155
+ lane.tasks.length = 0;
156
+ }
157
+ this.lanes.clear();
158
+ this.ids.clear();
159
+ this.pendingCount = 0;
160
+ }
161
+
162
+ snapshot() {
163
+ return {
164
+ stopped: this.stopped,
165
+ pendingCount: this.pendingCount,
166
+ lanes: Object.fromEntries(
167
+ [...this.lanes].map(([laneId, lane]) => [
168
+ laneId,
169
+ lane.tasks.map((task) => ({ id: task.id, attempts: task.attempts })),
170
+ ]),
171
+ ),
172
+ };
173
+ }
174
+ }
@@ -0,0 +1,174 @@
1
+ import { isIP } from "node:net";
2
+
3
+ import { normalizePubkey } from "./wire.js";
4
+
5
+ export const MAX_LEARNED_RELAYS = 50;
6
+ export const MAX_PEER_RELAYS = 10;
7
+ export const MAX_REPLY_RELAYS = 5;
8
+
9
+ function isPrivateIpv4(hostname) {
10
+ const parts = hostname.split(".").map(Number);
11
+ if (
12
+ parts.length !== 4 ||
13
+ parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
14
+ ) {
15
+ return false;
16
+ }
17
+ const [a, b] = parts;
18
+ return (
19
+ a === 0 ||
20
+ a === 10 ||
21
+ a === 127 ||
22
+ (a === 100 && b >= 64 && b <= 127) ||
23
+ (a === 169 && b === 254) ||
24
+ (a === 172 && b >= 16 && b <= 31) ||
25
+ (a === 192 && b === 168) ||
26
+ (a === 198 && (b === 18 || b === 19)) ||
27
+ a >= 224
28
+ );
29
+ }
30
+
31
+ function isPrivateIpv6(hostname) {
32
+ const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();
33
+ const mappedIpv4 = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1];
34
+ return (
35
+ normalized === "::" ||
36
+ normalized === "::1" ||
37
+ (mappedIpv4 ? isPrivateIpv4(mappedIpv4) : false) ||
38
+ normalized.startsWith("fc") ||
39
+ normalized.startsWith("fd") ||
40
+ /^fe[89ab]/.test(normalized)
41
+ );
42
+ }
43
+
44
+ export function isPrivateRelayHostname(hostname) {
45
+ const normalized = String(hostname || "")
46
+ .replace(/^\[|\]$/g, "")
47
+ .toLowerCase();
48
+ if (
49
+ normalized === "localhost" ||
50
+ normalized.endsWith(".localhost") ||
51
+ normalized.endsWith(".local")
52
+ ) {
53
+ return true;
54
+ }
55
+ const family = isIP(normalized);
56
+ if (family === 4) return isPrivateIpv4(normalized);
57
+ if (family === 6) return isPrivateIpv6(normalized);
58
+ return false;
59
+ }
60
+
61
+ export function normalizeRelayUrl(value, { allowInsecure = false, allowPrivate = false } = {}) {
62
+ try {
63
+ const url = new URL(String(value || "").trim());
64
+ if (url.username || url.password || url.search || url.hash) return null;
65
+ if (url.protocol !== "wss:" && !(allowInsecure && url.protocol === "ws:")) return null;
66
+ if (!allowPrivate && isPrivateRelayHostname(url.hostname)) return null;
67
+ url.pathname = url.pathname.replace(/\/+$/, "") || "/";
68
+ return url.toString().replace(/\/$/, "");
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ export class RelayBook {
75
+ constructor(
76
+ bootstrapRelays,
77
+ {
78
+ maxLearnedRelays = MAX_LEARNED_RELAYS,
79
+ maxPeerRelays = MAX_PEER_RELAYS,
80
+ maxReplyRelays = MAX_REPLY_RELAYS,
81
+ allowPrivateRelays = false,
82
+ clock = Date.now,
83
+ } = {},
84
+ ) {
85
+ this.allowPrivateRelays = allowPrivateRelays;
86
+ this.clock = clock;
87
+ this.maxLearnedRelays = maxLearnedRelays;
88
+ this.maxPeerRelays = maxPeerRelays;
89
+ this.maxReplyRelays = maxReplyRelays;
90
+ this.bootstrapRelays = [...new Set(bootstrapRelays)];
91
+ this.bootstrapSet = new Set(this.bootstrapRelays);
92
+ this.peers = new Map();
93
+ this.learned = new Map();
94
+ }
95
+
96
+ learn(peerPubkey, { sourceRelay = null, hintedRelay = null } = {}, now = this.clock()) {
97
+ const peer = normalizePubkey(peerPubkey);
98
+ const source = normalizeRelayUrl(sourceRelay, {
99
+ allowInsecure: this.allowPrivateRelays,
100
+ allowPrivate: this.allowPrivateRelays,
101
+ });
102
+ const hint = normalizeRelayUrl(hintedRelay, {
103
+ allowInsecure: false,
104
+ allowPrivate: this.allowPrivateRelays,
105
+ });
106
+ const discovered = [];
107
+
108
+ if (source) this.rememberPeerRelay(peer, source, now);
109
+ if (hint) {
110
+ this.rememberPeerRelay(peer, hint, now);
111
+ if (!this.bootstrapSet.has(hint)) {
112
+ const isNew = !this.learned.has(hint);
113
+ this.learned.delete(hint);
114
+ this.learned.set(hint, now);
115
+ if (isNew) discovered.push(hint);
116
+ }
117
+ }
118
+
119
+ this.enforceGlobalLimit();
120
+ return discovered.filter((relay) => this.learned.has(relay));
121
+ }
122
+
123
+ rememberPeerRelay(peer, relay, now) {
124
+ if (!this.peers.has(peer)) this.peers.set(peer, new Map());
125
+ const peerRelays = this.peers.get(peer);
126
+ peerRelays.delete(relay);
127
+ peerRelays.set(relay, now);
128
+ while (peerRelays.size > this.maxPeerRelays) {
129
+ peerRelays.delete(peerRelays.keys().next().value);
130
+ }
131
+ }
132
+
133
+ enforceGlobalLimit() {
134
+ while (this.learned.size > this.maxLearnedRelays) {
135
+ const evicted = this.learned.keys().next().value;
136
+ this.learned.delete(evicted);
137
+ for (const peerRelays of this.peers.values()) peerRelays.delete(evicted);
138
+ }
139
+ }
140
+
141
+ replyRelays(peerPubkey, ingressRelay = null) {
142
+ const peer = normalizePubkey(peerPubkey);
143
+ const ingress = normalizeRelayUrl(ingressRelay, {
144
+ allowInsecure: this.allowPrivateRelays,
145
+ allowPrivate: this.allowPrivateRelays,
146
+ });
147
+ const peerRelays = [...(this.peers.get(peer)?.entries() || [])]
148
+ .sort((left, right) => right[1] - left[1])
149
+ .map(([relay]) => relay);
150
+ return [...new Set([ingress, ...peerRelays, ...this.bootstrapRelays].filter(Boolean))].slice(
151
+ 0,
152
+ this.maxReplyRelays,
153
+ );
154
+ }
155
+
156
+ learnedRelays() {
157
+ return [...this.learned.keys()];
158
+ }
159
+
160
+ snapshot() {
161
+ return {
162
+ bootstrapRelays: [...this.bootstrapRelays],
163
+ learnedRelays: this.learnedRelays(),
164
+ peers: Object.fromEntries(
165
+ [...this.peers].map(([peer, relays]) => [
166
+ peer,
167
+ [...relays.entries()]
168
+ .sort((left, right) => right[1] - left[1])
169
+ .map(([relay, lastSeenAt]) => ({ relay, lastSeenAt })),
170
+ ]),
171
+ ),
172
+ };
173
+ }
174
+ }
package/src/wire.js ADDED
@@ -0,0 +1,202 @@
1
+ import { gcm } from "@noble/ciphers/aes.js";
2
+ import { hmac } from "@noble/hashes/hmac.js";
3
+ import { sha256 } from "@noble/hashes/sha2.js";
4
+ import { hexToBytes } from "@noble/hashes/utils.js";
5
+ import * as secp from "@noble/secp256k1";
6
+
7
+ secp.hashes.sha256 = sha256;
8
+ secp.hashes.hmacSha256 = (key, ...messages) => hmac(sha256, key, secp.etc.concatBytes(...messages));
9
+
10
+ export const DM_KIND = 4;
11
+ export const DM_TAG = "gupt-dm";
12
+ export const RETENTION_DAYS = 100;
13
+ export const MAX_EVENT_BYTES = 128 * 1024;
14
+ export const MAX_CONTENT_BYTES = 96 * 1024;
15
+
16
+ const HEX_64 = /^[0-9a-f]{64}$/;
17
+ const HEX_128 = /^[0-9a-f]{128}$/;
18
+ const textEncoder = new TextEncoder();
19
+ const textDecoder = new TextDecoder();
20
+
21
+ export function normalizeSecretHex(value) {
22
+ const normalized = String(value || "")
23
+ .trim()
24
+ .toLowerCase();
25
+ if (!HEX_64.test(normalized)) throw new TypeError("secretHex must be 64 hexadecimal characters");
26
+ const bytes = hexToBytes(normalized);
27
+ if (!secp.utils.isValidSecretKey(bytes))
28
+ throw new TypeError("secretHex is not a valid secp256k1 key");
29
+ return normalized;
30
+ }
31
+
32
+ export function normalizePubkey(value) {
33
+ const normalized = String(value || "")
34
+ .trim()
35
+ .toLowerCase();
36
+ const xOnly = /^(02|03)[0-9a-f]{64}$/.test(normalized) ? normalized.slice(2) : normalized;
37
+ if (!HEX_64.test(xOnly)) throw new TypeError("pubkey must be 64 hexadecimal characters");
38
+ return xOnly;
39
+ }
40
+
41
+ export function getPublicKey(secretHex) {
42
+ return secp.etc.bytesToHex(secp.schnorr.getPublicKey(hexToBytes(normalizeSecretHex(secretHex))));
43
+ }
44
+
45
+ export function serializeEvent(event) {
46
+ return JSON.stringify([0, event.pubkey, event.created_at, event.kind, event.tags, event.content]);
47
+ }
48
+
49
+ export function computeEventId(event) {
50
+ return secp.etc.bytesToHex(sha256(textEncoder.encode(serializeEvent(event))));
51
+ }
52
+
53
+ export function finalizeEvent(template, secretHex) {
54
+ const secret = hexToBytes(normalizeSecretHex(secretHex));
55
+ const event = {
56
+ ...template,
57
+ pubkey: secp.etc.bytesToHex(secp.schnorr.getPublicKey(secret)),
58
+ created_at: template.created_at ?? Math.floor(Date.now() / 1000),
59
+ tags: template.tags || [],
60
+ content: template.content || "",
61
+ };
62
+ event.id = computeEventId(event);
63
+ event.sig = secp.etc.bytesToHex(secp.schnorr.sign(hexToBytes(event.id), secret));
64
+ return event;
65
+ }
66
+
67
+ export function verifyEventSignature(event) {
68
+ try {
69
+ if (!HEX_64.test(event?.id) || !HEX_64.test(event?.pubkey) || !HEX_128.test(event?.sig)) {
70
+ return false;
71
+ }
72
+ if (computeEventId(event) !== event.id) return false;
73
+ return secp.schnorr.verify(
74
+ hexToBytes(event.sig),
75
+ hexToBytes(event.id),
76
+ hexToBytes(event.pubkey),
77
+ );
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ export function assertDirectMessageEvent(event, recipientPubkey) {
84
+ if (!event || typeof event !== "object" || Array.isArray(event)) {
85
+ throw new TypeError("Event must be an object");
86
+ }
87
+ if (event.kind !== DM_KIND) throw new TypeError("Unsupported event kind");
88
+ if (!Number.isSafeInteger(event.created_at) || event.created_at <= 0) {
89
+ throw new TypeError("Invalid event timestamp");
90
+ }
91
+ if (!HEX_64.test(event.id || "") || !HEX_64.test(event.pubkey || "")) {
92
+ throw new TypeError("Invalid event identity");
93
+ }
94
+ if (!HEX_128.test(event.sig || "")) throw new TypeError("Invalid event signature");
95
+ if (!Array.isArray(event.tags) || event.tags.length > 32) {
96
+ throw new TypeError("Invalid event tags");
97
+ }
98
+ for (const tag of event.tags) {
99
+ if (
100
+ !Array.isArray(tag) ||
101
+ tag.length === 0 ||
102
+ tag.length > 4 ||
103
+ tag.some((value) => typeof value !== "string" || value.length > 2048)
104
+ ) {
105
+ throw new TypeError("Invalid event tag");
106
+ }
107
+ }
108
+ if (typeof event.content !== "string") throw new TypeError("Invalid event content");
109
+ if (Buffer.byteLength(event.content, "utf8") > MAX_CONTENT_BYTES) {
110
+ throw new TypeError("Event content is too large");
111
+ }
112
+ if (Buffer.byteLength(JSON.stringify(event), "utf8") > MAX_EVENT_BYTES) {
113
+ throw new TypeError("Event is too large");
114
+ }
115
+
116
+ const self = normalizePubkey(recipientPubkey);
117
+ const recipientTag = event.tags.find((tag) => tag[0] === "p" && tag[1] === self);
118
+ if (!recipientTag) throw new TypeError("Event is not addressed to this bot");
119
+ if (!event.tags.some((tag) => tag[0] === "t" && tag[1] === DM_TAG)) {
120
+ throw new TypeError("Event is not a GUPT direct message");
121
+ }
122
+
123
+ return event;
124
+ }
125
+
126
+ export function getSenderRelayHint(event, recipientPubkey) {
127
+ const self = normalizePubkey(recipientPubkey);
128
+ const recipientTag = event?.tags?.find((tag) => tag[0] === "p" && tag[1] === self);
129
+ return typeof recipientTag?.[2] === "string" ? recipientTag[2] : null;
130
+ }
131
+
132
+ export function getExpiryTimestampSec(now = Date.now()) {
133
+ return Math.floor(now / 1000) + RETENTION_DAYS * 24 * 60 * 60;
134
+ }
135
+
136
+ export function isExpiredEvent(event, now = Date.now()) {
137
+ const expiration = event?.tags?.find((tag) => tag[0] === "expiration")?.[1];
138
+ if (expiration == null) return false;
139
+ const expiresAt = Number(expiration);
140
+ return !Number.isFinite(expiresAt) || expiresAt <= Math.floor(now / 1000);
141
+ }
142
+
143
+ export function getDmSharedSecret(secretHex, pubkey) {
144
+ const secret = hexToBytes(normalizeSecretHex(secretHex));
145
+ const compressedPubkey = hexToBytes(`02${normalizePubkey(pubkey)}`);
146
+ return sha256(secp.getSharedSecret(secret, compressedPubkey).subarray(1, 33));
147
+ }
148
+
149
+ export function encryptDm(secretHex, pubkey, plaintext, options = {}) {
150
+ const nonce = options.nonce
151
+ ? Uint8Array.from(options.nonce)
152
+ : globalThis.crypto.getRandomValues(new Uint8Array(12));
153
+ if (nonce.length !== 12) throw new TypeError("AES-GCM nonce must contain 12 bytes");
154
+ const ciphertext = gcm(getDmSharedSecret(secretHex, pubkey), nonce).encrypt(
155
+ textEncoder.encode(String(plaintext)),
156
+ );
157
+ return `v1:${Buffer.from(nonce).toString("base64")}:${Buffer.from(ciphertext).toString("base64")}`;
158
+ }
159
+
160
+ export function decryptDm(secretHex, pubkey, blob) {
161
+ const parts = String(blob || "").split(":");
162
+ if (parts.length !== 3 || parts[0] !== "v1") throw new TypeError("Unsupported ciphertext format");
163
+ const nonce = Buffer.from(parts[1], "base64");
164
+ const ciphertext = Buffer.from(parts[2], "base64");
165
+ if (nonce.length !== 12 || ciphertext.length < 16) throw new TypeError("Invalid ciphertext");
166
+ const plaintext = gcm(getDmSharedSecret(secretHex, pubkey), nonce).decrypt(ciphertext);
167
+ return textDecoder.decode(plaintext);
168
+ }
169
+
170
+ export function buildDirectMessageEvent({
171
+ secretHex,
172
+ recipientPubkey,
173
+ payload,
174
+ relayHint = null,
175
+ now = Date.now(),
176
+ }) {
177
+ const recipient = normalizePubkey(recipientPubkey);
178
+ const content = encryptDm(secretHex, recipient, JSON.stringify(payload));
179
+ return finalizeEvent(
180
+ {
181
+ kind: DM_KIND,
182
+ created_at: Math.floor(now / 1000),
183
+ tags: [
184
+ relayHint ? ["p", recipient, String(relayHint)] : ["p", recipient],
185
+ ["t", DM_TAG],
186
+ ["expiration", String(getExpiryTimestampSec(now))],
187
+ ],
188
+ content,
189
+ },
190
+ secretHex,
191
+ );
192
+ }
193
+
194
+ export function decryptDirectMessage(event, secretHex, recipientPubkey) {
195
+ assertDirectMessageEvent(event, recipientPubkey);
196
+ const plaintext = decryptDm(secretHex, event.pubkey, event.content);
197
+ const payload = JSON.parse(plaintext);
198
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
199
+ throw new TypeError("Invalid direct-message payload");
200
+ }
201
+ return payload;
202
+ }