From aeae39df4db047a5f70fc529238d3c9974e7e260 Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Fri, 7 Feb 2025 13:22:49 +0100 Subject: [PATCH] Initial Version (Working relay implementing basic functionality) --- .editorconfig | 5 + .gitignore | 3 + .vscode/settings.json | 15 + deno.json | 24 ++ deno.lock | 278 ++++++++++++ index.ts | 581 ++++++++++++++++++++++++++ migrations/0-init.sql | 12 + migrations/1-createEventsStore.sql | 36 ++ migrations/2-createEventsTagsView.sql | 10 + tsconfig.json | 5 + utils.ts | 65 +++ utils/encryption.ts | 33 ++ utils/files.ts | 31 ++ utils/logs.ts | 75 ++++ utils/queries.ts | 99 +++++ 15 files changed, 1272 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 deno.json create mode 100644 deno.lock create mode 100644 index.ts create mode 100644 migrations/0-init.sql create mode 100644 migrations/1-createEventsStore.sql create mode 100644 migrations/2-createEventsTagsView.sql create mode 100644 tsconfig.json create mode 100644 utils.ts create mode 100644 utils/encryption.ts create mode 100644 utils/files.ts create mode 100644 utils/logs.ts create mode 100644 utils/queries.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b6dbe6b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,5 @@ +[*] +indent = 2 + +[*.sql] +indent = 4 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d14eab8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +dist +node_modules +.env \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..96982c8 --- /dev/null +++ b/.vscode/settings.json @@ -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" + } +} diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..d505ec8 --- /dev/null +++ b/deno.json @@ -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 + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..21a17b3 --- /dev/null +++ b/deno.lock @@ -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" + ] + } +} diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..3a81e31 --- /dev/null +++ b/index.ts @@ -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 { + 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 { + 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; + public db: Database; + + constructor( + socket: WebSocket, + subscriptions: Map, + 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"); + }, +}); diff --git a/migrations/0-init.sql b/migrations/0-init.sql new file mode 100644 index 0000000..989a98f --- /dev/null +++ b/migrations/0-init.sql @@ -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); diff --git a/migrations/1-createEventsStore.sql b/migrations/1-createEventsStore.sql new file mode 100644 index 0000000..daa78f8 --- /dev/null +++ b/migrations/1-createEventsStore.sql @@ -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); \ No newline at end of file diff --git a/migrations/2-createEventsTagsView.sql b/migrations/2-createEventsTagsView.sql new file mode 100644 index 0000000..36750a0 --- /dev/null +++ b/migrations/2-createEventsTagsView.sql @@ -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; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..504cd64 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "experimentalDecorators": true + } +} diff --git a/utils.ts b/utils.ts new file mode 100644 index 0000000..a42341e --- /dev/null +++ b/utils.ts @@ -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(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 { + 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 { + const encryptedPrivateKey = Deno.readTextFileSync( + await getEveFilePath("ccn.priv"), + ); + return decryptUint8Array(decodeBase64(encryptedPrivateKey), encryptionKey); +} diff --git a/utils/encryption.ts b/utils/encryption.ts new file mode 100644 index 0000000..9973c37 --- /dev/null +++ b/utils/encryption.ts @@ -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); +} diff --git a/utils/files.ts b/utils/files.ts new file mode 100644 index 0000000..858a0d5 --- /dev/null +++ b/utils/files.ts @@ -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 { + 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 { + const storagePath = await getEveConfigHome(); + return `${storagePath}/${file}`; +} diff --git a/utils/logs.ts b/utils/logs.ts new file mode 100644 index 0000000..0e32f93 --- /dev/null +++ b/utils/logs.ts @@ -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"], + }, + }, + }); +} diff --git a/utils/queries.ts b/utils/queries.ts new file mode 100644 index 0000000..85fa9ad --- /dev/null +++ b/utils/queries.ts @@ -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; +}