mirror of
https://github.com/Yonle/bostr.git
synced 2025-06-28 17:50:57 +02:00
introduce nip42 and public/personal usage
Signed-off-by: Yonle <yonle@lecturify.net>
This commit is contained in:
parent
5f13300734
commit
dd1da10b8a
@ -25,7 +25,7 @@ cd bostr
|
|||||||
npm i
|
npm i
|
||||||
```
|
```
|
||||||
|
|
||||||
Rename `config.js.example` as `config.js`, Start editing the file and fill some required fields accordingly to your needs.
|
Rename `config.js.example` as `config.js`, Start editing the file and fill some required fields accordingly to your needs. You could either run it for everyone or only for yourself.
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
```
|
```
|
||||||
|
42
auth.js
Normal file
42
auth.js
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
const { validateEvent, verifySignature } = require("nostr-tools");
|
||||||
|
|
||||||
|
module.exports = (authKey, authorized, authorized_keys, data, ws, req) => {
|
||||||
|
if (!validateEvent(data)) {
|
||||||
|
ws.send(JSON.stringify(["NOTICE", "error: invalid challenge response."]));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verifySignature(data)) {
|
||||||
|
ws.send(JSON.stringify(["OK", data.id, false, "signature verification failed."]));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authorized_keys.includes(data.pubkey)) {
|
||||||
|
ws.send(JSON.stringify(["OK", data.id, false, "unauthorized."]));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.kind != 22242) {
|
||||||
|
ws.send(JSON.stringify(["OK", data.id, false, "not kind 22242."]));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authorized) {
|
||||||
|
ws.send(JSON.stringify(["OK", data.id, false, "already authorized."]));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = new Map(data.tags);
|
||||||
|
if (!tags.get("relay").includes(req.headers.host)) {
|
||||||
|
ws.send(JSON.stringify(["OK", data.id, false, "unmatched relay url."]));
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tags.get("challenge") !== authKey) {
|
||||||
|
ws.send(JSON.stringify(["OK", data.id, false, "unmatched challenge string."]));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.send(JSON.stringify(["OK", data.id, true, `Welcome ${data.pubkey}`]));
|
||||||
|
return true;
|
||||||
|
}
|
@ -1,6 +1,11 @@
|
|||||||
const SQLite = require("better-sqlite3");
|
const SQLite = require("better-sqlite3");
|
||||||
const WebSocket = require("ws");
|
const WebSocket = require("ws");
|
||||||
const { relays, tmp_store, log_about_relays } = require("../config");
|
const { validateEvent, nip19 } = require("nostr-tools");
|
||||||
|
const auth = require("../auth.js");
|
||||||
|
const nip42 = require("../nip42.js");
|
||||||
|
|
||||||
|
let { relays, tmp_store, log_about_relays, authorized_keys, private_keys } = require("../config");
|
||||||
|
|
||||||
const socks = new Set();
|
const socks = new Set();
|
||||||
const sess = new SQLite((process.env.IN_MEMORY || tmp_store != "disk") ? null : (__dirname + "/../.temporary.db"));
|
const sess = new SQLite((process.env.IN_MEMORY || tmp_store != "disk") ? null : (__dirname + "/../.temporary.db"));
|
||||||
const csess = new Map();
|
const csess = new Map();
|
||||||
@ -17,10 +22,21 @@ sess.exec("CREATE TABLE IF NOT EXISTS sess (cID TEXT, subID TEXT, filter TEXT);"
|
|||||||
sess.exec("CREATE TABLE IF NOT EXISTS events (cID TEXT, subID TEXT, eID TEXT);"); // To prevent transmitting duplicates
|
sess.exec("CREATE TABLE IF NOT EXISTS events (cID TEXT, subID TEXT, eID TEXT);"); // To prevent transmitting duplicates
|
||||||
sess.exec("CREATE TABLE IF NOT EXISTS recentEvents (cID TEXT, data TEXT);");
|
sess.exec("CREATE TABLE IF NOT EXISTS recentEvents (cID TEXT, data TEXT);");
|
||||||
|
|
||||||
|
authorized_keys = authorized_keys?.map(i => i.startsWith("npub") ? nip19.decode(i).data : i);
|
||||||
|
|
||||||
// CL - User socket
|
// CL - User socket
|
||||||
module.exports = (ws, req) => {
|
module.exports = (ws, req) => {
|
||||||
|
let authKey = null;
|
||||||
|
let authorized = true;
|
||||||
|
|
||||||
ws.id = process.pid + Math.floor(Math.random() * 1000) + "_" + csess.size;
|
ws.id = process.pid + Math.floor(Math.random() * 1000) + "_" + csess.size;
|
||||||
|
|
||||||
|
if (authorized_keys?.length) {
|
||||||
|
authKey = Date.now() + Math.random().toString(36);
|
||||||
|
authorized = false;
|
||||||
|
ws.send(JSON.stringify(["AUTH", authKey]));
|
||||||
|
}
|
||||||
|
|
||||||
console.log(process.pid, `->- ${req.headers["x-forwarded-for"]?.split(",")[0] || req.socket.address()?.address} connected as ${ws.id}`);
|
console.log(process.pid, `->- ${req.headers["x-forwarded-for"]?.split(",")[0] || req.socket.address()?.address} connected as ${ws.id}`);
|
||||||
ws.on("message", data => {
|
ws.on("message", data => {
|
||||||
try {
|
try {
|
||||||
@ -33,7 +49,9 @@ module.exports = (ws, req) => {
|
|||||||
|
|
||||||
switch (data[0]) {
|
switch (data[0]) {
|
||||||
case "EVENT":
|
case "EVENT":
|
||||||
if (!data[1]?.id) return ws.send(JSON.stringify(["NOTICE", "error: no event id."]));
|
if (!validateEvent(data[1])) return ws.send(JSON.stringify(["NOTICE", "error: invalid event"]));
|
||||||
|
if (data[1].kind == 22242) return ws.send(JSON.stringify(["OK", data[1]?.id, false, "rejected: kind 22242"]));
|
||||||
|
if (!authorized) return ws.send(JSON.stringify(["OK", data[1]?.id, false, "unauthorized."]));
|
||||||
sess.prepare("INSERT INTO recentEvents VALUES (?, ?);").run(ws.id, JSON.stringify(data));
|
sess.prepare("INSERT INTO recentEvents VALUES (?, ?);").run(ws.id, JSON.stringify(data));
|
||||||
bc(data);
|
bc(data);
|
||||||
ws.send(JSON.stringify(["OK", data[1]?.id, true, ""]));
|
ws.send(JSON.stringify(["OK", data[1]?.id, true, ""]));
|
||||||
@ -41,6 +59,7 @@ module.exports = (ws, req) => {
|
|||||||
case "REQ":
|
case "REQ":
|
||||||
if (data.length < 3) return ws.send(JSON.stringify(["NOTICE", "error: bad request."]));
|
if (data.length < 3) return ws.send(JSON.stringify(["NOTICE", "error: bad request."]));
|
||||||
if (typeof(data[2]) !== "object") return ws.send(JSON.stringify(["NOTICE", "expected filter to be obj, instead gives the otherwise."]));
|
if (typeof(data[2]) !== "object") return ws.send(JSON.stringify(["NOTICE", "expected filter to be obj, instead gives the otherwise."]));
|
||||||
|
if (!authorized) return ws.send(JSON.stringify(["NOTICE", "unauthorized."]));
|
||||||
data[1] = ws.id + ":" + data[1];
|
data[1] = ws.id + ":" + data[1];
|
||||||
// eventname -> 1_eventname
|
// eventname -> 1_eventname
|
||||||
bc(data);
|
bc(data);
|
||||||
@ -52,6 +71,7 @@ module.exports = (ws, req) => {
|
|||||||
break;
|
break;
|
||||||
case "CLOSE":
|
case "CLOSE":
|
||||||
if (typeof(data[1]) !== "string") return ws.send(JSON.stringify(["NOTICE", "error: bad request."]));
|
if (typeof(data[1]) !== "string") return ws.send(JSON.stringify(["NOTICE", "error: bad request."]));
|
||||||
|
if (!authorized) return ws.send(JSON.stringify(["NOTICE", "unauthorized."]));
|
||||||
data[1] = ws.id + ":" + data[1];
|
data[1] = ws.id + ":" + data[1];
|
||||||
bc(data);
|
bc(data);
|
||||||
pendingEOSE.delete(data[1]);
|
pendingEOSE.delete(data[1]);
|
||||||
@ -60,6 +80,12 @@ module.exports = (ws, req) => {
|
|||||||
sess.prepare("DELETE FROM sess WHERE cID = ? AND subID = ?;").run(ws.id, data[1]);
|
sess.prepare("DELETE FROM sess WHERE cID = ? AND subID = ?;").run(ws.id, data[1]);
|
||||||
sess.prepare("DELETE FROM events WHERE cID = ? AND subID = ?;").run(ws.id, data[1]);
|
sess.prepare("DELETE FROM events WHERE cID = ? AND subID = ?;").run(ws.id, data[1]);
|
||||||
break;
|
break;
|
||||||
|
case "AUTH":
|
||||||
|
if (auth(authKey, authorized, authorized_keys, data[1], ws, req)) {
|
||||||
|
ws.pubkey = data[1].pubkey;
|
||||||
|
authorized = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
console.warn(process.pid, "---", "Unknown command:", data.join(" "));
|
console.warn(process.pid, "---", "Unknown command:", data.join(" "));
|
||||||
ws.send(JSON.stringify(["NOTICE", "error: unrecognized command."]));
|
ws.send(JSON.stringify(["NOTICE", "error: unrecognized command."]));
|
||||||
@ -184,6 +210,12 @@ function newConn(addr) {
|
|||||||
reqLimit.delete(subID);
|
reqLimit.delete(subID);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "AUTH": {
|
||||||
|
if (!private_keys || typeof(data[1]) !== "string") return;
|
||||||
|
const pubkey = authorized_keys[0];
|
||||||
|
nip42(relay, pubkey, private_keys[pubkey], data[1]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
const SQLite = require("better-sqlite3");
|
const SQLite = require("better-sqlite3");
|
||||||
const WebSocket = require("ws");
|
const WebSocket = require("ws");
|
||||||
const { relays, tmp_store, log_about_relays } = require("../config");
|
const { validateEvent, nip19 } = require("nostr-tools");
|
||||||
|
const auth = require("../auth.js");
|
||||||
|
const nip42 = require("../nip42.js");
|
||||||
|
|
||||||
|
let { relays, tmp_store, log_about_relays, authorized_keys, private_keys } = require("../config");
|
||||||
|
|
||||||
const socks = new Set();
|
const socks = new Set();
|
||||||
const sess = new SQLite((process.env.IN_MEMORY || tmp_store != "disk") ? null : (__dirname + "/../.temporary.db"));
|
const sess = new SQLite((process.env.IN_MEMORY || tmp_store != "disk") ? null : (__dirname + "/../.temporary.db"));
|
||||||
const csess = new Map();
|
const csess = new Map();
|
||||||
@ -17,10 +22,21 @@ sess.exec("CREATE TABLE IF NOT EXISTS sess (cID TEXT, subID TEXT, filter TEXT);"
|
|||||||
sess.exec("CREATE TABLE IF NOT EXISTS events (cID TEXT, subID TEXT, eID TEXT);"); // To prevent transmitting duplicates
|
sess.exec("CREATE TABLE IF NOT EXISTS events (cID TEXT, subID TEXT, eID TEXT);"); // To prevent transmitting duplicates
|
||||||
sess.exec("CREATE TABLE IF NOT EXISTS recentEvents (cID TEXT, data TEXT);");
|
sess.exec("CREATE TABLE IF NOT EXISTS recentEvents (cID TEXT, data TEXT);");
|
||||||
|
|
||||||
|
authorized_keys = authorized_keys?.map(i => i.startsWith("npub") ? nip19.decode(i).data : i);
|
||||||
|
|
||||||
// CL - User socket
|
// CL - User socket
|
||||||
module.exports = (ws, req) => {
|
module.exports = (ws, req) => {
|
||||||
|
let authKey = null;
|
||||||
|
let authorized = true;
|
||||||
|
|
||||||
ws.id = process.pid + Math.floor(Math.random() * 1000) + "_" + csess.size;
|
ws.id = process.pid + Math.floor(Math.random() * 1000) + "_" + csess.size;
|
||||||
|
|
||||||
|
if (authorized_keys?.length) {
|
||||||
|
authKey = Date.now() + Math.random().toString(36);
|
||||||
|
authorized = false;
|
||||||
|
ws.send(JSON.stringify(["AUTH", authKey]));
|
||||||
|
}
|
||||||
|
|
||||||
console.log(process.pid, `->- ${req.headers["x-forwarded-for"]?.split(",")[0] || req.socket.address()?.address} connected as ${ws.id}`);
|
console.log(process.pid, `->- ${req.headers["x-forwarded-for"]?.split(",")[0] || req.socket.address()?.address} connected as ${ws.id}`);
|
||||||
ws.on("message", data => {
|
ws.on("message", data => {
|
||||||
try {
|
try {
|
||||||
@ -33,7 +49,9 @@ module.exports = (ws, req) => {
|
|||||||
|
|
||||||
switch (data[0]) {
|
switch (data[0]) {
|
||||||
case "EVENT":
|
case "EVENT":
|
||||||
if (!data[1]?.id) return ws.send(JSON.stringify(["NOTICE", "error: no event id."]));
|
if (!validateEvent(data[1])) return ws.send(JSON.stringify(["NOTICE", "error: invalid event"]));
|
||||||
|
if (data[1].kind == 22242) return ws.send(JSON.stringify(["OK", data[1]?.id, false, "rejected: kind 22242"]));
|
||||||
|
if (!authorized) return ws.send(JSON.stringify(["OK", data[1]?.id, false, "unauthorized."]));
|
||||||
sess.prepare("INSERT INTO recentEvents VALUES (?, ?);").run(ws.id, JSON.stringify(data));
|
sess.prepare("INSERT INTO recentEvents VALUES (?, ?);").run(ws.id, JSON.stringify(data));
|
||||||
bc(data, ws.id);
|
bc(data, ws.id);
|
||||||
ws.send(JSON.stringify(["OK", data[1]?.id, true, ""]));
|
ws.send(JSON.stringify(["OK", data[1]?.id, true, ""]));
|
||||||
@ -41,6 +59,7 @@ module.exports = (ws, req) => {
|
|||||||
case "REQ":
|
case "REQ":
|
||||||
if (data.length < 3) return ws.send(JSON.stringify(["NOTICE", "error: bad request."]));
|
if (data.length < 3) return ws.send(JSON.stringify(["NOTICE", "error: bad request."]));
|
||||||
if (typeof(data[2]) !== "object") return ws.send(JSON.stringify(["NOTICE", "expected filter to be obj, instead gives the otherwise."]));
|
if (typeof(data[2]) !== "object") return ws.send(JSON.stringify(["NOTICE", "expected filter to be obj, instead gives the otherwise."]));
|
||||||
|
if (!authorized) return ws.send(JSON.stringify(["NOTICE", "unauthorized."]));
|
||||||
// eventname -> 1_eventname
|
// eventname -> 1_eventname
|
||||||
bc(data, ws.id);
|
bc(data, ws.id);
|
||||||
sess.prepare("INSERT INTO sess VALUES (?, ?, ?);").run(ws.id, data[1], JSON.stringify(data[2]));
|
sess.prepare("INSERT INTO sess VALUES (?, ?, ?);").run(ws.id, data[1], JSON.stringify(data[2]));
|
||||||
@ -51,6 +70,7 @@ module.exports = (ws, req) => {
|
|||||||
break;
|
break;
|
||||||
case "CLOSE":
|
case "CLOSE":
|
||||||
if (typeof(data[1]) !== "string") return ws.send(JSON.stringify(["NOTICE", "error: bad request."]));
|
if (typeof(data[1]) !== "string") return ws.send(JSON.stringify(["NOTICE", "error: bad request."]));
|
||||||
|
if (!authorized) return ws.send(JSON.stringify(["NOTICE", "unauthorized."]));
|
||||||
bc(data, ws.id);
|
bc(data, ws.id);
|
||||||
pendingEOSE.delete(ws.id + ":" + data[1]);
|
pendingEOSE.delete(ws.id + ":" + data[1]);
|
||||||
reqLimit.delete(ws.id + ":" + data[1]);
|
reqLimit.delete(ws.id + ":" + data[1]);
|
||||||
@ -58,6 +78,12 @@ module.exports = (ws, req) => {
|
|||||||
sess.prepare("DELETE FROM sess WHERE cID = ? AND subID = ?;").run(ws.id, data[1]);
|
sess.prepare("DELETE FROM sess WHERE cID = ? AND subID = ?;").run(ws.id, data[1]);
|
||||||
sess.prepare("DELETE FROM events WHERE cID = ? AND subID = ?;").run(ws.id, data[1]);
|
sess.prepare("DELETE FROM events WHERE cID = ? AND subID = ?;").run(ws.id, data[1]);
|
||||||
break;
|
break;
|
||||||
|
case "AUTH":
|
||||||
|
if (auth(authKey, authorized, authorized_keys, data[1], ws, req)) {
|
||||||
|
ws.pubkey = data[1].pubkey;
|
||||||
|
authorized = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
console.warn(process.pid, "---", "Unknown command:", data.join(" "));
|
console.warn(process.pid, "---", "Unknown command:", data.join(" "));
|
||||||
ws.send(JSON.stringify(["NOTICE", "error: unrecognized command."]));
|
ws.send(JSON.stringify(["NOTICE", "error: unrecognized command."]));
|
||||||
@ -187,6 +213,12 @@ function newConn(addr, id) {
|
|||||||
pendingEOSE.delete(id + ":" + data[1]);
|
pendingEOSE.delete(id + ":" + data[1]);
|
||||||
reqLimit.delete(id + ":" + data[1]);
|
reqLimit.delete(id + ":" + data[1]);
|
||||||
break;
|
break;
|
||||||
|
case "AUTH": {
|
||||||
|
if (!private_keys || typeof(data[1]) !== "string") return;
|
||||||
|
const pubkey = csess.get(id)?.pubkey;
|
||||||
|
nip42(relay, pubkey, private_keys[pubkey], data[1]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -7,8 +7,8 @@ module.exports = {
|
|||||||
port: "8080",
|
port: "8080",
|
||||||
|
|
||||||
// Bouncing mode
|
// Bouncing mode
|
||||||
// 1 -> Fast. Bouncer connects to <relays> at startup.
|
// 1 -> Fast. Bouncer connects to <relays> at startup. Useful for bots
|
||||||
// 2 -> Accurate. Every clients has their own sessions (recommended).
|
// 2 -> Accurate. Every clients has their own sessions. Useful for normal users (recommended)
|
||||||
mode: 2,
|
mode: 2,
|
||||||
|
|
||||||
// Clusters.
|
// Clusters.
|
||||||
@ -21,6 +21,27 @@ module.exports = {
|
|||||||
// Log about bouncer connection with relays?
|
// Log about bouncer connection with relays?
|
||||||
log_about_relays: false,
|
log_about_relays: false,
|
||||||
|
|
||||||
|
// For personal usage. Leaving this empty allows everyone to use this bostr instance.
|
||||||
|
// NOTE: Requires NIP-42 on client.
|
||||||
|
authorized_keys: [
|
||||||
|
// "pubkey-in-hex",
|
||||||
|
// "npub ....",
|
||||||
|
// ....
|
||||||
|
],
|
||||||
|
|
||||||
|
// For personal usage. Leaving this empty completely disables NIP-42 function.
|
||||||
|
// Private key of authorized user. It could be your key.
|
||||||
|
// NOTE:
|
||||||
|
// - NIP-42 (auth) is ONLY supported with provided <private_keys>
|
||||||
|
// - While both mode could interact with NIP-42 relays,
|
||||||
|
// - It's best to use Mode 2 if you have more than <private_keys>
|
||||||
|
// - Mode 1 could only work with ONE <private_keys>
|
||||||
|
|
||||||
|
// Warning: Ensure that <authorized_keys> is NOT empty so only <authorized_keys> could access this bostr bouncer
|
||||||
|
private_keys: {
|
||||||
|
// "pubkey": "privatekey"
|
||||||
|
}
|
||||||
|
|
||||||
// Server information.
|
// Server information.
|
||||||
// Only for when nostr client requesting server information.
|
// Only for when nostr client requesting server information.
|
||||||
server_meta: {
|
server_meta: {
|
||||||
@ -31,7 +52,7 @@ module.exports = {
|
|||||||
|
|
||||||
// Some nostr client may read the following for compatibility check.
|
// Some nostr client may read the following for compatibility check.
|
||||||
// You may change the supported_nips to match with what your relays supported.
|
// You may change the supported_nips to match with what your relays supported.
|
||||||
"supported_nips": [1,2,9,11,12,15,16,20,22,33,40,50]
|
"supported_nips": [1,2,9,11,12,15,16,20,22,33,40,42,50]
|
||||||
"version": "1.0.0"
|
"version": "1.0.0"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
1
http.js
1
http.js
@ -31,6 +31,7 @@ server.on('request', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
res.write(`\nI have ${wss.clients.size} clients currently connected to this bouncer${(process.env.CLUSTERS || config.clusters) > 1 ? " on this cluster" : ""}.\n`);
|
res.write(`\nI have ${wss.clients.size} clients currently connected to this bouncer${(process.env.CLUSTERS || config.clusters) > 1 ? " on this cluster" : ""}.\n`);
|
||||||
|
if (config?.authorized_keys?.length) res.write("\nNOTE: This relay has configured for personal use only. Only authorized users could use this bostr relay.\n");
|
||||||
res.write(`\nConnect to this bouncer with nostr client: ws://${req.headers.host} or wss://${req.headers.host}\n\n---\n`);
|
res.write(`\nConnect to this bouncer with nostr client: ws://${req.headers.host} or wss://${req.headers.host}\n\n---\n`);
|
||||||
res.end("Powered by Bostr - Open source nostr Bouncer\nhttps://github.com/Yonle/bostr");
|
res.end("Powered by Bostr - Open source nostr Bouncer\nhttps://github.com/Yonle/bostr");
|
||||||
} else {
|
} else {
|
||||||
|
19
nip42.js
Normal file
19
nip42.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
const { getEventHash, getSignature } = require("nostr-tools");
|
||||||
|
|
||||||
|
module.exports = (relay, pubkey, privkey, challenge) => {
|
||||||
|
if (!privkey) return;
|
||||||
|
let signed_challenge = {
|
||||||
|
pubkey,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: 22242,
|
||||||
|
tags: [
|
||||||
|
["relay", relay.url],
|
||||||
|
["challenge", challenge]
|
||||||
|
],
|
||||||
|
content: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
signed_challenge.id = getEventHash(signed_challenge);
|
||||||
|
signed_challenge.sig = getSignature(signed_challenge, privkey);
|
||||||
|
relay.send(JSON.stringify(["AUTH", signed_challenge]));
|
||||||
|
}
|
@ -24,6 +24,7 @@
|
|||||||
"homepage": "https://github.com/Yonle/bostr#readme",
|
"homepage": "https://github.com/Yonle/bostr#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^9.1.1",
|
"better-sqlite3": "^9.1.1",
|
||||||
|
"nostr-tools": "^1.17.0",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user