Initial Version (Working relay implementing basic functionality)

This commit is contained in:
Danny Morabito 2025-02-07 13:22:49 +01:00
commit aeae39df4d
Signed by: dannym
GPG key ID: 7CC8056A5A04557E
15 changed files with 1272 additions and 0 deletions

5
.editorconfig Normal file
View file

@ -0,0 +1,5 @@
[*]
indent = 2
[*.sql]
indent = 4

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
dist
node_modules
.env

15
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,15 @@
{
"deno.enable": true,
"sqltools.highlightQuery": true,
"sqltools.format": {
"language": "sql",
"keywordCase": "upper"
},
"[typescript]": {
"editor.formatOnPaste": true,
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file",
"editor.formatOnType": true,
"editor.defaultFormatter": "denoland.vscode-deno"
}
}

24
deno.json Normal file
View file

@ -0,0 +1,24 @@
{
"tasks": {
"dev": "deno run --allow-read --allow-write --allow-net --allow-ffi --allow-env --env-file --watch index.ts"
},
"imports": {
"@db/sqlite": "jsr:@db/sqlite@^0.12.0",
"@noble/ciphers": "jsr:@noble/ciphers@^1.2.1",
"@nostr/tools": "jsr:@nostr/tools@^2.10.4",
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.37.0",
"@nostrify/types": "jsr:@nostrify/types@^0.36.0",
"@std/encoding": "jsr:@std/encoding@^1.0.6",
"@std/fmt": "jsr:@std/fmt@^1.0.4",
"@std/log": "jsr:@std/log@^0.224.13",
"@types/deno": "npm:@types/deno@^2.0.0"
},
"fmt": {
"indentWidth": 2,
"useTabs": false,
"lineWidth": 80,
"proseWrap": "always",
"semiColons": true,
"singleQuote": false
}
}

278
deno.lock generated Normal file
View file

@ -0,0 +1,278 @@
{
"version": "4",
"specifiers": {
"jsr:@db/sqlite@*": "0.12.0",
"jsr:@db/sqlite@0.12": "0.12.0",
"jsr:@denosaurs/plug@1": "1.0.6",
"jsr:@noble/ciphers@^1.2.1": "1.2.1",
"jsr:@nostr/tools@^2.10.4": "2.10.4",
"jsr:@nostrify/nostrify@*": "0.37.0",
"jsr:@nostrify/nostrify@0.37": "0.37.0",
"jsr:@nostrify/types@0.36": "0.36.0",
"jsr:@std/assert@0.217": "0.217.0",
"jsr:@std/assert@0.221": "0.221.0",
"jsr:@std/assert@0.224": "0.224.0",
"jsr:@std/crypto@0.224": "0.224.0",
"jsr:@std/encoding@*": "1.0.7",
"jsr:@std/encoding@0.221": "0.221.0",
"jsr:@std/encoding@0.224": "0.224.3",
"jsr:@std/encoding@^1.0.6": "1.0.7",
"jsr:@std/encoding@~0.224.1": "0.224.3",
"jsr:@std/fmt@0.221": "0.221.0",
"jsr:@std/fmt@^1.0.4": "1.0.5",
"jsr:@std/fmt@^1.0.5": "1.0.5",
"jsr:@std/fs@*": "1.0.11",
"jsr:@std/fs@0.221": "0.221.0",
"jsr:@std/fs@^1.0.11": "1.0.11",
"jsr:@std/io@~0.225.2": "0.225.2",
"jsr:@std/log@*": "0.224.14",
"jsr:@std/log@~0.224.13": "0.224.14",
"jsr:@std/path@0.217": "0.217.0",
"jsr:@std/path@0.221": "0.221.0",
"jsr:@std/path@^1.0.8": "1.0.8",
"npm:@noble/ciphers@~0.5.1": "0.5.3",
"npm:@noble/curves@1.2.0": "1.2.0",
"npm:@noble/hashes@1.3.1": "1.3.1",
"npm:@scure/base@1.1.1": "1.1.1",
"npm:@scure/base@^1.1.6": "1.2.4",
"npm:@scure/bip32@1.3.1": "1.3.1",
"npm:@scure/bip32@^1.4.0": "1.6.2",
"npm:@scure/bip39@1.2.1": "1.2.1",
"npm:@scure/bip39@^1.3.0": "1.5.4",
"npm:@types/deno@2": "2.0.0",
"npm:lru-cache@^10.2.0": "10.4.3",
"npm:nostr-tools@^2.7.0": "2.10.4",
"npm:nostr-wasm@0.1.0": "0.1.0",
"npm:websocket-ts@^2.1.5": "2.1.5",
"npm:zod@^3.23.8": "3.24.1"
},
"jsr": {
"@db/sqlite@0.12.0": {
"integrity": "dd1ef7f621ad50fc1e073a1c3609c4470bd51edc0994139c5bf9851de7a6d85f",
"dependencies": [
"jsr:@denosaurs/plug",
"jsr:@std/path@0.217"
]
},
"@denosaurs/plug@1.0.6": {
"integrity": "6cf5b9daba7799837b9ffbe89f3450510f588fafef8115ddab1ff0be9cb7c1a7",
"dependencies": [
"jsr:@std/encoding@0.221",
"jsr:@std/fmt@0.221",
"jsr:@std/fs@0.221",
"jsr:@std/path@0.221"
]
},
"@noble/ciphers@1.2.1": {
"integrity": "e8eba45a1a6fefa6e522872d2f6b2bcc40d6ff928bdacfb3add5e245c1656819"
},
"@nostr/tools@2.10.4": {
"integrity": "7fda015c96b4f674727843aecb990e2af1989e4724588415ccf6f69066abfd4f",
"dependencies": [
"npm:@noble/ciphers@~0.5.1",
"npm:@noble/curves",
"npm:@noble/hashes",
"npm:@scure/base@1.1.1",
"npm:@scure/bip32@1.3.1",
"npm:@scure/bip39@1.2.1",
"npm:nostr-wasm"
]
},
"@nostrify/nostrify@0.37.0": {
"integrity": "fa1439cc5e9a74986c4fb799a38a9ed7bd8663c62ae2a9363ca9b987548e27a0",
"dependencies": [
"jsr:@nostrify/types",
"jsr:@std/crypto",
"jsr:@std/encoding@~0.224.1",
"npm:@scure/base@^1.1.6",
"npm:@scure/bip32@^1.4.0",
"npm:@scure/bip39@^1.3.0",
"npm:lru-cache",
"npm:nostr-tools",
"npm:websocket-ts",
"npm:zod"
]
},
"@nostrify/types@0.36.0": {
"integrity": "b3413467debcbd298d217483df4e2aae6c335a34765c90ac7811cf7c637600e7"
},
"@std/assert@0.217.0": {
"integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642"
},
"@std/assert@0.221.0": {
"integrity": "a5f1aa6e7909dbea271754fd4ab3f4e687aeff4873b4cef9a320af813adb489a"
},
"@std/assert@0.224.0": {
"integrity": "8643233ec7aec38a940a8264a6e3eed9bfa44e7a71cc6b3c8874213ff401967f"
},
"@std/crypto@0.224.0": {
"integrity": "154ef3ff08ef535562ef1a718718c5b2c5fc3808f0f9100daad69e829bfcdf2d",
"dependencies": [
"jsr:@std/assert@0.224",
"jsr:@std/encoding@0.224"
]
},
"@std/encoding@0.221.0": {
"integrity": "d1dd76ef0dc5d14088411e6dc1dede53bf8308c95d1537df1214c97137208e45"
},
"@std/encoding@0.224.3": {
"integrity": "5e861b6d81be5359fad4155e591acf17c0207b595112d1840998bb9f476dbdaf"
},
"@std/encoding@1.0.7": {
"integrity": "f631247c1698fef289f2de9e2a33d571e46133b38d042905e3eac3715030a82d"
},
"@std/fmt@0.221.0": {
"integrity": "379fed69bdd9731110f26b9085aeb740606b20428ce6af31ef6bd45ef8efa62a"
},
"@std/fmt@1.0.5": {
"integrity": "0cfab43364bc36650d83c425cd6d99910fc20c4576631149f0f987eddede1a4d"
},
"@std/fs@0.221.0": {
"integrity": "028044450299de8ed5a716ade4e6d524399f035513b85913794f4e81f07da286",
"dependencies": [
"jsr:@std/assert@0.221",
"jsr:@std/path@0.221"
]
},
"@std/fs@1.0.11": {
"integrity": "ba674672693340c5ebdd018b4fe1af46cb08741f42b4c538154e97d217b55bdd",
"dependencies": [
"jsr:@std/path@^1.0.8"
]
},
"@std/io@0.225.2": {
"integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7"
},
"@std/log@0.224.14": {
"integrity": "257f7adceee3b53bb2bc86c7242e7d1bc59729e57d4981c4a7e5b876c808f05e",
"dependencies": [
"jsr:@std/fmt@^1.0.5",
"jsr:@std/fs@^1.0.11",
"jsr:@std/io"
]
},
"@std/path@0.217.0": {
"integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11",
"dependencies": [
"jsr:@std/assert@0.217"
]
},
"@std/path@0.221.0": {
"integrity": "0a36f6b17314ef653a3a1649740cc8db51b25a133ecfe838f20b79a56ebe0095",
"dependencies": [
"jsr:@std/assert@0.221"
]
},
"@std/path@1.0.8": {
"integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be"
}
},
"npm": {
"@noble/ciphers@0.5.3": {
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="
},
"@noble/curves@1.1.0": {
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
"dependencies": [
"@noble/hashes@1.3.1"
]
},
"@noble/curves@1.2.0": {
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
"dependencies": [
"@noble/hashes@1.3.2"
]
},
"@noble/curves@1.8.1": {
"integrity": "sha512-warwspo+UYUPep0Q+vtdVB4Ugn8GGQj8iyB3gnRWsztmUHTI3S1nhdiWNsPUGL0vud7JlRRk1XEu7Lq1KGTnMQ==",
"dependencies": [
"@noble/hashes@1.7.1"
]
},
"@noble/hashes@1.3.1": {
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="
},
"@noble/hashes@1.3.2": {
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="
},
"@noble/hashes@1.7.1": {
"integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ=="
},
"@scure/base@1.1.1": {
"integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="
},
"@scure/base@1.2.4": {
"integrity": "sha512-5Yy9czTO47mqz+/J8GM6GIId4umdCk1wc1q8rKERQulIoc8VP9pzDcghv10Tl2E7R96ZUx/PhND3ESYUQX8NuQ=="
},
"@scure/bip32@1.3.1": {
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
"dependencies": [
"@noble/curves@1.1.0",
"@noble/hashes@1.3.2",
"@scure/base@1.1.1"
]
},
"@scure/bip32@1.6.2": {
"integrity": "sha512-t96EPDMbtGgtb7onKKqxRLfE5g05k7uHnHRM2xdE6BP/ZmxaLtPek4J4KfVn/90IQNrU1IOAqMgiDtUdtbe3nw==",
"dependencies": [
"@noble/curves@1.8.1",
"@noble/hashes@1.7.1",
"@scure/base@1.2.4"
]
},
"@scure/bip39@1.2.1": {
"integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
"dependencies": [
"@noble/hashes@1.3.2",
"@scure/base@1.1.1"
]
},
"@scure/bip39@1.5.4": {
"integrity": "sha512-TFM4ni0vKvCfBpohoh+/lY05i9gRbSwXWngAsF4CABQxoaOHijxuaZ2R6cStDQ5CHtHO9aGJTr4ksVJASRRyMA==",
"dependencies": [
"@noble/hashes@1.7.1",
"@scure/base@1.2.4"
]
},
"@types/deno@2.0.0": {
"integrity": "sha512-O9/jRVlq93kqfkl4sYR5N7+Pz4ukzXVIbMnE/VgvpauNHsvjQ9iBVnJ3X0gAvMa2khcoFD8DSO7mQVCuiuDMPg=="
},
"lru-cache@10.4.3": {
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
},
"nostr-tools@2.10.4": {
"integrity": "sha512-biU7sk+jxHgVASfobg2T5ttxOGGSt69wEVBC51sHHOEaKAAdzHBLV/I2l9Rf61UzClhliZwNouYhqIso4a3HYg==",
"dependencies": [
"@noble/ciphers",
"@noble/curves@1.2.0",
"@noble/hashes@1.3.1",
"@scure/base@1.1.1",
"@scure/bip32@1.3.1",
"@scure/bip39@1.2.1",
"nostr-wasm"
]
},
"nostr-wasm@0.1.0": {
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA=="
},
"websocket-ts@2.1.5": {
"integrity": "sha512-rCNl9w6Hsir1azFm/pbjBEFzLD/gi7Th5ZgOxMifB6STUfTSovYAzryWw0TRvSZ1+Qu1Z5Plw4z42UfTNA9idA=="
},
"zod@3.24.1": {
"integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="
}
},
"workspace": {
"dependencies": [
"jsr:@db/sqlite@0.12",
"jsr:@noble/ciphers@^1.2.1",
"jsr:@nostr/tools@^2.10.4",
"jsr:@nostrify/nostrify@0.37",
"jsr:@nostrify/types@0.36",
"jsr:@std/encoding@^1.0.6",
"jsr:@std/fmt@^1.0.4",
"jsr:@std/log@~0.224.13",
"npm:@types/deno@2"
]
}
}

581
index.ts Normal file
View file

@ -0,0 +1,581 @@
import { NSchema as n } from "jsr:@nostrify/nostrify";
import type {
NostrClientREQ,
NostrEvent,
NostrFilter,
} from "jsr:@nostrify/types";
import {
getCCNPrivateKey,
getCCNPubkey,
isArray,
isLocalhost,
isValidJSON,
randomTimeUpTo2DaysInThePast,
} from "./utils.ts";
import * as nostrTools from "@nostr/tools";
import { nip44 } from "@nostr/tools";
import { randomBytes } from "@noble/ciphers/webcrypto";
import { encodeBase64 } from "jsr:@std/encoding@0.224/base64";
import { Database } from "jsr:@db/sqlite";
import { mixQuery, sql, sqlPartial } from "./utils/queries.ts";
import { log, setupLogger } from "./utils/logs.ts";
import { getEveFilePath } from "./utils/files.ts";
await setupLogger();
if (!Deno.env.has("ENCRYPTION_KEY")) {
log.error(
`Missing ENCRYPTION_KEY. Please set it in your env.\nA new one has been generated for you: ENCRYPTION_KEY="${
encodeBase64(
randomBytes(32),
)
}"`,
);
Deno.exit(1);
}
const db = new Database(await getEveFilePath("db"));
const pool = new nostrTools.SimplePool();
const relays = [
"wss://relay.arx-ccn.com/",
"wss://relay.dannymorabito.com/",
"wss://nos.lol/",
"wss://nostr.einundzwanzig.space/",
"wss://nostr.massmux.com/",
"wss://nostr.mom/",
"wss://nostr.wine/",
"wss://purplerelay.com/",
"wss://relay.damus.io/",
"wss://relay.goodmorningbitcoin.com/",
"wss://relay.lexingtonbitcoin.org/",
"wss://relay.nostr.band/",
"wss://relay.primal.net/",
"wss://relay.snort.social/",
"wss://strfry.iris.to/",
"wss://cache2.primal.net/v1",
];
export function runMigrations(db: Database, latestVersion: number) {
const migrations = Deno.readDirSync(`${import.meta.dirname}/migrations`);
for (const migrationFile of migrations) {
const migrationVersion = Number.parseInt(
migrationFile.name.split("-")[0],
10,
);
if (migrationVersion > latestVersion) {
log.info(
`Running migration ${migrationFile.name} (version ${migrationVersion})`,
);
const start = Date.now();
const migrationSql = Deno.readTextFileSync(
`${import.meta.dirname}/migrations/${migrationFile.name}`,
);
db.run("BEGIN TRANSACTION");
try {
db.run(migrationSql);
const end = Date.now();
const durationMs = end - start;
sql`
INSERT INTO migration_history (migration_version, migration_name, executed_at, duration_ms, status) VALUES (${migrationVersion}, ${migrationFile.name}, ${
new Date().toISOString()
}, ${durationMs}, 'success');
db.run("COMMIT TRANSACTION");
`(db);
} catch (e) {
db.run("ROLLBACK TRANSACTION");
const error = e instanceof Error
? e
: typeof e === "string"
? new Error(e)
: new Error(JSON.stringify(e));
const end = Date.now();
const durationMs = end - start;
sql`
INSERT INTO migration_history (migration_version, migration_name, executed_at, duration_ms, status, error_message) VALUES (${migrationVersion}, ${migrationFile.name}, ${
new Date().toISOString()
}, ${durationMs}, 'failed', ${error.message});
`(db);
throw e;
}
db.run("END TRANSACTION");
}
}
}
async function createEncryptedEvent(
event: nostrTools.VerifiedEvent,
): Promise<nostrTools.VerifiedEvent> {
if (!event.id) throw new Error("Event must have an ID");
if (!event.sig) throw new Error("Event must be signed");
const ccnPubKey = await getCCNPubkey();
const ccnPrivateKey = await getCCNPrivateKey();
const randomPrivateKey = nostrTools.generateSecretKey();
const conversationKey = nip44.getConversationKey(randomPrivateKey, ccnPubKey);
const sealTemplate = {
kind: 13,
created_at: randomTimeUpTo2DaysInThePast(),
content: nip44.encrypt(JSON.stringify(event), conversationKey),
tags: [],
};
const seal = nostrTools.finalizeEvent(sealTemplate, ccnPrivateKey);
const giftWrapTemplate = {
kind: 1059,
created_at: randomTimeUpTo2DaysInThePast(),
content: nip44.encrypt(JSON.stringify(seal), conversationKey),
tags: [["p", ccnPubKey]],
};
const giftWrap = nostrTools.finalizeEvent(giftWrapTemplate, randomPrivateKey);
return giftWrap;
}
async function decryptEvent(
event: nostrTools.Event,
): Promise<nostrTools.VerifiedEvent> {
const ccnPrivkey = await getCCNPrivateKey();
if (event.kind !== 1059) {
throw new Error("Cannot decrypt event -- not a gift wrap");
}
const conversationKey = nip44.getConversationKey(ccnPrivkey, event.pubkey);
const seal = JSON.parse(nip44.decrypt(event.content, conversationKey));
if (!seal) throw new Error("Cannot decrypt event -- no seal");
if (seal.kind !== 13) {
throw new Error("Cannot decrypt event subevent -- not a seal");
}
const content = JSON.parse(nip44.decrypt(seal.content, conversationKey));
return content as nostrTools.VerifiedEvent;
}
class EventAlreadyExistsException extends Error {}
function addEventToDb(
decryptedEvent: nostrTools.VerifiedEvent,
encryptedEvent: nostrTools.VerifiedEvent,
) {
const existingEvent = sql`
SELECT * FROM events WHERE id = ${decryptedEvent.id}
`(db)[0];
if (existingEvent) throw new EventAlreadyExistsException();
try {
db.run("BEGIN TRANSACTION");
sql`
INSERT INTO events (id, original_id, pubkey, created_at, kind, content, sig, first_seen) VALUES (
${decryptedEvent.id},
${encryptedEvent.id},
${decryptedEvent.pubkey},
${decryptedEvent.created_at},
${decryptedEvent.kind},
${decryptedEvent.content},
${decryptedEvent.sig},
unixepoch()
)
`(db);
if (decryptedEvent.tags) {
for (let i = 0; i < decryptedEvent.tags.length; i++) {
const tag = sql`
INSERT INTO event_tags(event_id, tag_name, tag_index) VALUES (
${decryptedEvent.id},
${decryptedEvent.tags[i][0]},
${i}
) RETURNING tag_id
`(db)[0];
for (let j = 1; j < decryptedEvent.tags[i].length; j++) {
sql`
INSERT INTO event_tags_values(tag_id, value_position, value) VALUES (
${tag.tag_id},
${j},
${decryptedEvent.tags[i][j]}
)
`(db);
}
}
}
db.run("COMMIT TRANSACTION");
} catch (e) {
db.run("ROLLBACK TRANSACTION");
throw e;
}
}
function encryptedEventIsInDb(event: nostrTools.VerifiedEvent) {
return sql`
SELECT * FROM events WHERE original_id = ${event.id}
`(db)[0];
}
async function setupAndSubscribeToExternalEvents() {
const ccnPubkey = await getCCNPubkey();
const isInitialized = sql`
SELECT name FROM sqlite_master WHERE type='table' AND name='migration_history'
`(db)[0];
if (!isInitialized) runMigrations(db, -1);
const latestVersion = sql`
SELECT migration_version FROM migration_history WHERE status = 'success' ORDER BY migration_version DESC LIMIT 1
`(db)[0]?.migration_version ?? -1;
runMigrations(db, latestVersion);
pool.subscribeMany(
relays,
[
{
"#p": [ccnPubkey],
kinds: [1059],
},
],
{
async onevent(event: nostrTools.Event) {
if (timer) {
timerCleaned = true;
clearTimeout(timer);
}
if (knownOriginalEvents.indexOf(event.id) >= 0) return;
if (!nostrTools.verifyEvent(event)) {
log.warn("Invalid event received");
return;
}
if (encryptedEventIsInDb(event)) return;
const decryptedEvent = await decryptEvent(event);
try {
addEventToDb(decryptedEvent, event);
} catch (e) {
if (e instanceof EventAlreadyExistsException) return;
}
},
},
);
let timerCleaned = false;
const knownOriginalEvents = sql`SELECT original_id FROM events`(db).flatMap(
(row) => row.original_id,
);
const timer = setTimeout(async () => {
// if nothing is found in 10 seconds, create a new CCN, TODO: change logic
const ccnCreationEventTemplate = {
kind: 0,
content: JSON.stringify({
display_name: "New CCN",
name: "New CCN",
bot: true,
}),
created_at: Math.floor(Date.now() / 1000),
tags: [["p", ccnPubkey]],
};
const ccnCreationEvent = nostrTools.finalizeEvent(
ccnCreationEventTemplate,
await getCCNPrivateKey(),
);
const encryptedCCNCreationEvent = await createEncryptedEvent(
ccnCreationEvent,
);
if (timerCleaned) return; // in case we get an event before the timer is cleaned
await Promise.any(pool.publish(relays, encryptedCCNCreationEvent));
}, 10000);
}
await setupAndSubscribeToExternalEvents();
class UserConnection {
public socket: WebSocket;
public subscriptions: Map<string, NostrFilter[]>;
public db: Database;
constructor(
socket: WebSocket,
subscriptions: Map<string, NostrFilter[]>,
db: Database,
) {
this.socket = socket;
this.subscriptions = subscriptions;
this.db = db;
}
}
function filtersMatchingEvent(
event: NostrEvent,
connection: UserConnection,
): string[] {
const matching = [];
for (const subscription of connection.subscriptions.keys()) {
const filters = connection.subscriptions.get(subscription);
if (!filters) continue;
const isMatching = filters.every((filter) =>
Object.entries(filter).every(([type, value]) => {
if (type === "ids") return value.includes(event.id);
if (type === "kinds") return value.includes(event.kind);
if (type === "authors") return value.includes(event.pubkey);
if (type === "since") return event.created_at >= value;
if (type === "until") return event.created_at <= value;
if (type === "limit") return event.created_at <= value;
if (type.startsWith("#")) {
const tagName = type.slice(1);
return event.tags.some(
(tag: string[]) => tag[0] === tagName && value.includes(tag[1]),
);
}
return false;
})
);
if (isMatching) matching.push(subscription);
}
return matching;
}
function handleRequest(connection: UserConnection, request: NostrClientREQ) {
const [, subscriptionId, ...filters] = request;
if (connection.subscriptions.has(subscriptionId)) {
return log.warn("Duplicate subscription ID");
}
log.info(
`New subscription: ${subscriptionId} with filters: ${
JSON.stringify(
filters,
)
}`,
);
let query = sqlPartial`SELECT * FROM events`;
const filtersAreNotEmpty = filters.some((filter) => {
return Object.values(filter).some((value) => {
return value.length > 0;
});
});
if (filtersAreNotEmpty) {
query = mixQuery(query, sqlPartial`WHERE`);
for (let i = 0; i < filters.length; i++) {
// filters act as OR, filter groups act as AND
query = mixQuery(query, sqlPartial`(`);
const filter = Object.entries(filters[i]).filter(([type, value]) => {
if (type === "ids") return value.length > 0;
if (type === "authors") return value.length > 0;
if (type === "kinds") return value.length > 0;
if (type.startsWith("#")) return value.length > 0;
if (type === "since") return value > 0;
if (type === "until") return value > 0;
return false;
});
for (let j = 0; j < filter.length; j++) {
const [type, value] = filter[j];
if (type === "ids") {
const uniqueIds = [...new Set(value)];
query = mixQuery(query, sqlPartial`id IN (`);
for (let k = 0; k < uniqueIds.length; k++) {
const id = uniqueIds[k] as string;
query = mixQuery(query, sqlPartial`${id}`);
if (k < uniqueIds.length - 1) {
query = mixQuery(query, sqlPartial`,`);
}
}
query = mixQuery(query, sqlPartial`)`);
}
if (type === "authors") {
const uniqueAuthors = [...new Set(value)];
query = mixQuery(query, sqlPartial`pubkey IN (`);
for (let k = 0; k < uniqueAuthors.length; k++) {
const author = uniqueAuthors[k] as string;
query = mixQuery(query, sqlPartial`${author}`);
if (k < uniqueAuthors.length - 1) {
query = mixQuery(query, sqlPartial`,`);
}
}
query = mixQuery(query, sqlPartial`)`);
}
if (type === "kinds") {
const uniqueKinds = [...new Set(value)];
query = mixQuery(query, sqlPartial`kind IN (`);
for (let k = 0; k < uniqueKinds.length; k++) {
const kind = uniqueKinds[k] as number;
query = mixQuery(query, sqlPartial`${kind}`);
if (k < uniqueKinds.length - 1) {
query = mixQuery(query, sqlPartial`,`);
}
}
query = mixQuery(query, sqlPartial`)`);
}
if (type.startsWith("#")) {
const tag = type.slice(1);
const uniqueValues = [...new Set(value)];
query = mixQuery(query, sqlPartial`(`);
for (let k = 0; k < uniqueValues.length; k++) {
const value = uniqueValues[k] as string;
query = mixQuery(
query,
sqlPartial`id IN (
SELECT t.event_id
FROM event_tags t
WHERE t.tag_name = ${tag}
AND t.tag_id IN (
SELECT v.tag_id
FROM event_tags_values v
WHERE v.value_position = 1
AND v.value = ${value}
)
)`,
);
if (k < uniqueValues.length - 1) {
query = mixQuery(query, sqlPartial`OR`);
}
}
query = mixQuery(query, sqlPartial`)`);
}
if (type === "since") {
query = mixQuery(query, sqlPartial`created_at >= ${value}`);
}
if (type === "until") {
query = mixQuery(query, sqlPartial`created_at <= ${value}`);
}
if (j < filter.length - 1) query = mixQuery(query, sqlPartial`AND`);
}
query = mixQuery(query, sqlPartial`)`);
if (i < filters.length - 1) query = mixQuery(query, sqlPartial`OR`);
}
}
query = mixQuery(query, sqlPartial`ORDER BY created_at ASC`);
log.debug(query.query, ...query.values);
const events = connection.db.prepare(query.query).all(...query.values);
for (let i = 0; i < events.length; i++) {
const rawTags = sql`SELECT * FROM event_tags_view WHERE event_id = ${
events[i].id
}`(connection.db);
const tags: { [key: string]: string[] } = {};
for (const item of rawTags) {
if (!tags[item.tag_name]) tags[item.tag_name] = [item.tag_name];
tags[item.tag_name].push(item.tag_value);
}
const tagsArray = Object.values(tags);
const event = {
id: events[i].id,
pubkey: events[i].pubkey,
created_at: events[i].created_at,
kind: events[i].kind,
tags: tagsArray,
content: events[i].content,
sig: events[i].sig,
};
connection.socket.send(JSON.stringify(["EVENT", subscriptionId, event]));
}
connection.socket.send(JSON.stringify(["EOSE", subscriptionId]));
connection.subscriptions.set(subscriptionId, filters);
}
async function handleEvent(
connection: UserConnection,
event: nostrTools.Event,
) {
const valid = nostrTools.verifyEvent(event);
if (!valid) {
connection.socket.send(JSON.stringify(["NOTICE", "Invalid event"]));
return log.warn("Invalid event");
}
const encryptedEvent = await createEncryptedEvent(event);
try {
addEventToDb(event, encryptedEvent);
} catch (e) {
if (e instanceof EventAlreadyExistsException) {
log.warn("Event already exists");
return;
}
}
await Promise.any(pool.publish(relays, encryptedEvent));
connection.socket.send(JSON.stringify(["OK", event.id, true, "Event added"]));
const filtersThatMatchEvent = filtersMatchingEvent(event, connection);
for (let i = 0; i < filtersThatMatchEvent.length; i++) {
const filter = filtersThatMatchEvent[i];
connection.socket.send(JSON.stringify(["EVENT", filter, event]));
}
}
function handleClose(connection: UserConnection, subscriptionId: string) {
if (!connection.subscriptions.has(subscriptionId)) {
return log.warn(
`Closing unknown subscription? That's weird. Subscription ID: ${subscriptionId}`,
);
}
connection.subscriptions.delete(subscriptionId);
}
Deno.serve({
port: 6942,
handler: (request) => {
if (request.headers.get("upgrade") === "websocket") {
if (!isLocalhost(request)) {
return new Response(
"Forbidden. Please read the Arx-CCN documentation for more information on how to interact with the relay.",
{ status: 403 },
);
}
const { socket, response } = Deno.upgradeWebSocket(request);
const connection = new UserConnection(socket, new Map(), db);
socket.onopen = () => log.info("User connected");
socket.onmessage = (event) => {
log.debug(`Received: ${event.data}`);
if (typeof event.data !== "string" || !isValidJSON(event.data)) {
return log.warn("Invalid request");
}
const data = JSON.parse(event.data);
if (!isArray(data)) return log.warn("Invalid request");
const msg = n.clientMsg().parse(data);
switch (msg[0]) {
case "REQ":
return handleRequest(connection, n.clientREQ().parse(data));
case "EVENT":
return handleEvent(connection, n.clientEVENT().parse(data)[1]);
case "CLOSE":
return handleClose(connection, n.clientCLOSE().parse(data)[1]);
default:
return log.warn("Invalid request");
}
};
socket.onclose = () => log.info("User disconnected");
return response;
}
return new Response("Eve Relay");
},
});

12
migrations/0-init.sql Normal file
View file

@ -0,0 +1,12 @@
CREATE TABLE migration_history (
migration_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
migration_version INTEGER NOT NULL,
migration_name TEXT NOT NULL,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
duration_ms INTEGER NOT NULL,
status TEXT NOT NULL CHECK (status IN ('success', 'failed', 'reverted')),
error_message TEXT
);
CREATE INDEX idx_migration_history_name ON migration_history(migration_name);
CREATE INDEX idx_migration_history_executed_at ON migration_history(executed_at);

View file

@ -0,0 +1,36 @@
CREATE TABLE events (
id TEXT NOT NULL, -- Event ID (32-byte hex)
original_id TEXT, -- Original (encrypted) event ID (32-byte hex)
pubkey TEXT NOT NULL, -- Author's public key (32-byte hex)
created_at INTEGER NOT NULL,-- Unix timestamp in seconds
kind INTEGER NOT NULL, -- Event kind number
content TEXT NOT NULL,
sig TEXT NOT NULL, -- Event signature (64-byte hex)
first_seen INTEGER,
PRIMARY KEY (id)
);
CREATE TABLE event_tags (
tag_id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT NOT NULL,
tag_name TEXT NOT NULL,
tag_index INTEGER NOT NULL,
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE
);
CREATE TABLE event_tags_values (
tag_id INTEGER NOT NULL,
value_position INTEGER NOT NULL,
value TEXT NOT NULL,
FOREIGN KEY (tag_id) REFERENCES event_tags(tag_id)
);
CREATE INDEX idx_events_id ON events(id);
CREATE INDEX idx_events_pubkey ON events(pubkey);
CREATE INDEX idx_events_created_at ON events(created_at);
CREATE INDEX idx_events_kind ON events(kind);
CREATE INDEX idx_events_original_id ON events(original_id);
CREATE INDEX idx_event_tags_event_id ON event_tags(event_id);
CREATE INDEX idx_event_tags_name ON event_tags(tag_name, tag_index);
CREATE INDEX idx_event_tags_values ON event_tags_values(tag_id, value_position, value);

View file

@ -0,0 +1,10 @@
CREATE VIEW event_tags_view AS
SELECT
t.event_id,
t.tag_index,
t.tag_name,
v.value as tag_value,
v.value_position as tag_value_position
FROM event_tags t
LEFT JOIN event_tags_values v ON t.tag_id = v.tag_id
ORDER BY t.tag_index, v.value_position;

5
tsconfig.json Normal file
View file

@ -0,0 +1,5 @@
{
"compilerOptions": {
"experimentalDecorators": true
}
}

65
utils.ts Normal file
View file

@ -0,0 +1,65 @@
import { exists } from "jsr:@std/fs";
import * as nostrTools from "@nostr/tools";
import * as nip06 from "@nostr/tools/nip06";
import { decodeBase64, encodeBase64 } from "jsr:@std/encoding@0.224/base64";
import { getEveFilePath } from "./utils/files.ts";
import {
decryptUint8Array,
encryptionKey,
encryptUint8Array,
} from "./utils/encryption.ts";
export function isLocalhost(req: Request): boolean {
const url = new URL(req.url);
const hostname = url.hostname;
return (
hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost"
);
}
export function isValidJSON(str: string) {
try {
JSON.parse(str);
} catch {
return false;
}
return true;
}
export function isArray<T>(obj: unknown): obj is T[] {
return Array.isArray(obj);
}
export function randomTimeUpTo2DaysInThePast() {
const now = Date.now();
const twoDaysAgo = now - 2 * 24 * 60 * 60 * 1000 - 3600 * 1000; // 1 hour buffer in case of clock skew
return Math.floor(
(Math.floor(Math.random() * (now - twoDaysAgo)) + twoDaysAgo) / 1000,
);
}
export async function getCCNPubkey(): Promise<string> {
const ccnPubPath = await getEveFilePath("ccn.pub");
const doWeHaveKey = await exists(ccnPubPath);
if (doWeHaveKey) return Deno.readTextFileSync(ccnPubPath);
const ccnSeed = Deno.env.get("CCN_SEED") || nip06.generateSeedWords();
const ccnPrivateKey = nip06.privateKeyFromSeedWords(ccnSeed);
const ccnPublicKey = nostrTools.getPublicKey(ccnPrivateKey);
const encryptedPrivateKey = encryptUint8Array(ccnPrivateKey, encryptionKey);
Deno.writeTextFileSync(ccnPubPath, ccnPublicKey);
Deno.writeTextFileSync(
await getEveFilePath("ccn.priv"),
encodeBase64(encryptedPrivateKey),
);
Deno.writeTextFileSync(await getEveFilePath("ccn.seed"), ccnSeed);
return ccnPublicKey;
}
export async function getCCNPrivateKey(): Promise<Uint8Array> {
const encryptedPrivateKey = Deno.readTextFileSync(
await getEveFilePath("ccn.priv"),
);
return decryptUint8Array(decodeBase64(encryptedPrivateKey), encryptionKey);
}

33
utils/encryption.ts Normal file
View file

@ -0,0 +1,33 @@
import { xchacha20poly1305 } from "@noble/ciphers/chacha";
import { managedNonce } from "@noble/ciphers/webcrypto";
import { decodeBase64 } from "jsr:@std/encoding/base64";
export const encryptionKey = decodeBase64(Deno.env.get("ENCRYPTION_KEY") || "");
/**
* Encrypts a given Uint8Array using the XChaCha20-Poly1305 algorithm.
*
* @param data - The data to be encrypted as a Uint8Array.
* @param key - The encryption key as a Uint8Array.
* @returns The encrypted data as a Uint8Array.
*/
export function encryptUint8Array(
data: Uint8Array,
key: Uint8Array,
): Uint8Array {
return managedNonce(xchacha20poly1305)(key).encrypt(data);
}
/**
* Decrypts a given Uint8Array using the XChaCha20-Poly1305 algorithm.
*
* @param data - The data to be decrypted as a Uint8Array.
* @param key - The decryption key as a Uint8Array.
* @returns The decrypted data as a Uint8Array.
*/
export function decryptUint8Array(
data: Uint8Array,
key: Uint8Array,
): Uint8Array {
return managedNonce(xchacha20poly1305)(key).decrypt(data);
}

31
utils/files.ts Normal file
View file

@ -0,0 +1,31 @@
import { exists } from "jsr:@std/fs";
/**
* Return the path to Eve's configuration directory.
*
* The configuration directory is resolved in the following order:
* 1. The value of the `XDG_CONFIG_HOME` environment variable.
* 2. The value of the `HOME` environment variable, with `.config` appended.
*
* If the resolved path does not exist, create it.
*/
export async function getEveConfigHome(): Promise<string> {
const xdgConfigHome = Deno.env.get("XDG_CONFIG_HOME") ??
`${Deno.env.get("HOME")}/.config`;
const storagePath = `${xdgConfigHome}/arx/Eve`;
if (!(await exists(storagePath))) {
await Deno.mkdir(storagePath, { recursive: true });
}
return storagePath;
}
/**
* Return the path to the file in Eve's configuration directory.
*
* @param file The name of the file to return the path for.
* @returns The path to the file in Eve's configuration directory.
*/
export async function getEveFilePath(file: string): Promise<string> {
const storagePath = await getEveConfigHome();
return `${storagePath}/${file}`;
}

75
utils/logs.ts Normal file
View file

@ -0,0 +1,75 @@
import * as colors from "jsr:@std/fmt@^1.0.4/colors";
import * as log from "jsr:@std/log";
import { getEveFilePath } from "./files.ts";
export * as log from "jsr:@std/log";
export async function setupLogger() {
const formatLevel = (level: number): string => {
return (
{
10: colors.gray("[DEBUG]"),
20: colors.green("[INFO] "),
30: colors.yellow("[WARN] "),
40: colors.red("[ERROR]"),
50: colors.bgRed("[FATAL]"),
}[level] || `[LVL${level}]`
);
};
const levelName = (level: number): string => {
return {
10: "DEBUG",
20: "INFO",
30: "WARN",
40: "ERROR",
50: "FATAL",
}[level] || `LVL${level}`;
};
const formatArg = (arg: unknown): string => {
if (typeof arg === "object") return JSON.stringify(arg);
return String(arg);
};
await log.setup({
handlers: {
console: new log.ConsoleHandler("DEBUG", {
useColors: true,
formatter: (record) => {
const timestamp = new Date().toISOString();
let msg = `${colors.dim(`[${timestamp}]`)} ${
formatLevel(record.level)
} ${record.msg}`;
if (record.args.length > 0) {
const args = record.args
.map((arg, i) => `${colors.dim(`arg${i}:`)} ${formatArg(arg)}`)
.join(" ");
msg += ` ${colors.dim("|")} ${args}`;
}
return msg;
},
}),
file: new log.FileHandler("DEBUG", {
filename: Deno.env.get("LOG_FILE") ||
await getEveFilePath("eve-logs.jsonl"),
formatter: (record) => {
const timestamp = new Date().toISOString();
return JSON.stringify({
timestamp,
level: levelName(record.level),
msg: record.msg,
args: record.args,
});
},
}),
},
loggers: {
default: {
level: "DEBUG",
handlers: ["console", "file"],
},
},
});
}

99
utils/queries.ts Normal file
View file

@ -0,0 +1,99 @@
import type { BindValue, Database } from "@db/sqlite";
/**
* Construct a SQL query with placeholders for values.
*
* This function takes a template string and interpolates it with the given
* values, replacing placeholders with `?`.
*
* @example
* const query = sqlPartial`SELECT * FROM events WHERE id = ? OR id = ?`,
* ['1', '2'];
* // query = {
* // query: 'SELECT * FROM events WHERE id = ? OR id = ?',
* // values: ['1', '2']
* // }
*
* @param {TemplateStringsArray} segments A template string
* @param {...BindValue[]} values Values to interpolate
* @returns {{ query: string, values: BindValue[] }} A SQL query with placeholders
*/
export function sqlPartial(
segments: TemplateStringsArray,
...values: BindValue[]
) {
return {
query: segments.reduce(
(acc, str, i) => acc + str + (i < values.length ? "?" : ""),
"",
),
values: values,
};
}
/**
* Construct a SQL query with placeholders for values and return a function
* that executes that query on a database.
*
* This is a convenience wrapper around `sqlPartial` and `sqlPartialRunner`.
*
* @example
* const run = sql`SELECT * FROM events WHERE id = ? OR id = ?`,
* ['1', '2'];
* const results = run(db);
*
* @param {TemplateStringsArray} segments A template string
* @param {...BindValue[]} values Values to interpolate
* @returns {Function} A function that takes a Database and returns the query results
*/
export function sql(segments: TemplateStringsArray, ...values: BindValue[]) {
return sqlPartialRunner(sqlPartial(segments, ...values));
}
/**
* Combine multiple partial queries into a single query.
*
* This function takes any number of partial queries with values and combines
* them into a single query.
*
* @example
* const query1 = { query: 'SELECT * FROM foo', values: [] };
* const query2 = { query: 'WHERE bar = ?', values: ['5'] };
* const query = mixQuery(query1, query2);
* // query = {
* // query: 'SELECT * FROM foo WHERE bar = ?',
* // values: ['5']
* // }
*
* @param {...{ query: string, values: BindValue[] }} queries Partial queries
* @returns {{ query: string, values: BindValue[] }} A combined query
*/
export function mixQuery(...queries: { query: string; values: BindValue[] }[]) {
const { query, values } = queries.reduce(
(acc, { query, values }) => ({
query: `${acc.query} ${query}`,
values: [...acc.values, ...values],
}),
{ query: "", values: [] },
);
return { query, values };
}
/**
* Executes a SQL query against a database.
*
* This function takes a query object containing a SQL query string with placeholders
* and an array of values. It returns a function that, when given a Database instance,
* prepares the query and executes it, returning all results.
*
* @param {Object} query An object containing the SQL query string and corresponding values
* @returns {Function} A function that takes a Database instance and returns the query results
*/
export function sqlPartialRunner(query: {
query: string;
values: BindValue[];
}) {
const run = (db: Database) => db.prepare(query.query).all(...query.values);
return run;
}