From 9a58dcc0971b08ae3a80d4767fa51e17928c7f6f Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Tue, 5 Aug 2025 23:48:43 +0200 Subject: [PATCH] initial version --- .gitignore | 36 +++++ Dockerfile | 20 +++ README.md | 128 +++++++++++++++++ biome.json | 31 +++++ bun.lock | 58 ++++++++ index.ts | 22 +++ package.json | 15 ++ src/main.ts | 371 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/utils.ts | 113 +++++++++++++++ tsconfig.json | 29 ++++ 10 files changed, 823 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 biome.json create mode 100644 bun.lock create mode 100644 index.ts create mode 100644 package.json create mode 100644 src/main.ts create mode 100644 src/utils.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53ffd00 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +allowed-pubkeys.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..894158f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM oven/bun:canary-debian AS builder + +WORKDIR /app + +ADD package.json . +RUN bun install + +ADD . . + +RUN bun build index.ts --compile + +FROM debian:bookworm-slim + +WORKDIR /app + +COPY --from=builder /app/index /app/index + +EXPOSE 3000 + +CMD ["/app/index"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d92fe0 --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +# NIP-42 Proxy + +This project is a NIP-42 proxy for Nostr relays. It provides an authentication layer in front of a public relay, allowing only authenticated users to connect and interact with it. + +## Features + +- **NIP-42 Authentication**: Enforces NIP-42 authentication, ensuring that only authorized users can access the relay. +- **Proxy Layer**: Acts as a proxy, forwarding messages between authenticated clients and the main relay. +- **Dynamic Whitelist**: Manage allowed public keys and event kinds dynamically through an admin RPC interface. +- **Admin RPC Interface**: A NIP-98-protected RPC interface for administering the proxy. +- **Docker Support**: Comes with a `Dockerfile` for easy deployment. +- **Bun Support**: Can be run directly with `bun`. + +## Prerequisites + +- [Bun](https://bun.sh/) installed on your system. +- [Docker](https://www.docker.com/) (optional) installed on your system. + +## Installation + +1. **Clone the repository**: + + ```bash + git clone https://git.arx-ccn.com/Arx/nip42-proxy.git + cd nip42-proxy + ``` + +2. **Install dependencies**: + ```bash + bun install + ``` + +## Configuration + +The proxy is configured through environment variables. + +- `ALLOW_UNAUTHED_PUBLISH`: (Optional) Set to `true` to allow unauthenticated clients to publish events. Defaults to `false`. +- `RELAY_URL`: The URL of the relay that the proxy will connect to. +- `RELAY_OUTSIDE_URL`: (Optional) The URL that clients use to connect to the proxy. Defaults to the `RELAY_URL`. +- `RELAY_NAME`: (Optional) The name of the relay. +- `RELAY_DESCRIPTION`: (Optional) A description of the relay. +- `RELAY_BANNER`: (Optional) A URL to a banner image for the relay. +- `RELAY_ICON`: (Optional) A URL to an icon for the relay. +- `RELAY_CONTACT`: (Optional) A contact email or address for the relay. +- `RELAY_POLICY`: (Optional) A URL to the relay's policy document. +- `ADMIN_PUBKEY`: (Optional) The public key of the administrator of the relay. + +## Usage + +### With Bun + +To run the proxy directly with Bun: + +```bash +bun run index.ts +``` + +You can also set environment variables in the same command: + +```bash +RELAY_URL="wss://my-relay.com" ADMIN_PUBKEY="my-admin-pubkey" bun run index.ts +``` + +### With Docker + +To run the proxy using Docker, follow these steps: + +1. **Build the Docker image**: + + ```bash + docker build -t nip42-proxy . + ``` + +2. **Run the Docker container**: + + ```bash + docker run -p 3000:3000 --name nip42-proxy nip42-proxy + ``` + + - This command maps port `3000` on your local machine to port `3000` in the container. + + To run with a custom relay URL and other environment variables, use the `-e` flag: + + ```bash + docker run -p 3000:3000 -e RELAY_URL="wss://your-relay-url.com" -e ADMIN_PUBKEY="my-admin-pubkey" --name nip42-proxy nip42-proxy + ``` + +The server will start, and you can connect to it using a Nostr client that supports NIP-42 authentication. + +## Admin RPC Interface + +The proxy exposes a NIP-98-protected RPC interface for administration. To use it, you need to send a `POST` request to the root URL (`/`) with the `Content-Type` header set to `application/nostr+json+rpc`. The `Authorization` header must contain a NIP-98 token. + +**Available Methods:** + +- `supportedmethods`: Get a list of supported RPC methods. +- `getinfo`: Get the relay's information document. +- `banpubkey`: Ban a public key. +- `allowpubkey`: Allow a public key. +- `listallowedpubkeys`: List all allowed public keys. +- `allowkind`: Allow a specific event kind. +- `disallowkind`: Disallow a specific event kind. +- `listallowedkinds`: List all allowed event kinds. + +**Example using `curl`:** + +```bash +# First, generate a NIP-98 token. This is usually done with a Nostr library. +# For this example, let's assume you have a valid token in the $TOKEN variable. + +# Get the list of allowed pubkeys +curl -X POST -H "Content-Type: application/nostr+json+rpc" -H "Authorization: $TOKEN" -d '{"method": "listallowedpubkeys", "params": []}' http://localhost:3000/ + +# Allow a new pubkey +curl -X POST -H "Content-Type: application/nostr+json+rpc" -H "Authorization: $TOKEN" -d '{"method": "allowpubkey", "params": ["new-pubkey-to-allow"]}' http://localhost:3000/ +``` + +## How It Works + +1. **Client Connection**: When a client connects to the proxy, it is initially in an unauthenticated state. +2. **Authentication Request**: The proxy sends an `AUTH` challenge to the client. +3. **Client Authentication**: The client must respond with a valid `AUTH` event, signed with an allowed public key. +4. **Authenticated State**: Once authenticated, the client can send and receive messages from the main relay through the proxy. +5. **Message Forwarding**: All messages from the authenticated client are forwarded to the main relay, and all messages from the main relay are forwarded to the client. + +## Contributing + +Contributions are welcome! Please open an issue or submit a pull request with your improvements. \ No newline at end of file diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..5f36d03 --- /dev/null +++ b/biome.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false, + "ignore": [] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..9e52ef6 --- /dev/null +++ b/bun.lock @@ -0,0 +1,58 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "nip42-proxy", + "dependencies": { + "nostr-tools": "^2.16.2", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@noble/ciphers": ["@noble/ciphers@0.5.3", "", {}, "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="], + + "@noble/curves": ["@noble/curves@1.2.0", "", { "dependencies": { "@noble/hashes": "1.3.2" } }, "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw=="], + + "@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="], + + "@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="], + + "@scure/bip32": ["@scure/bip32@1.3.1", "", { "dependencies": { "@noble/curves": "~1.1.0", "@noble/hashes": "~1.3.1", "@scure/base": "~1.1.0" } }, "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A=="], + + "@scure/bip39": ["@scure/bip39@1.2.1", "", { "dependencies": { "@noble/hashes": "~1.3.0", "@scure/base": "~1.1.0" } }, "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg=="], + + "@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="], + + "@types/node": ["@types/node@24.2.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw=="], + + "@types/react": ["@types/react@19.1.9", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA=="], + + "bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "nostr-tools": ["nostr-tools@2.16.2", "", { "dependencies": { "@noble/ciphers": "^0.5.1", "@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": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-ZxH9EbSt5ypURZj2TGNJxZd0Omb5ag5KZSu8IyJMCdLyg2KKz+2GA0sP/cSawCQEkyviIN4eRT4G2gB/t9lMRw=="], + + "nostr-wasm": ["nostr-wasm@0.1.0", "", {}, "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA=="], + + "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], + + "undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + + "@noble/curves/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], + + "@scure/bip32/@noble/curves": ["@noble/curves@1.1.0", "", { "dependencies": { "@noble/hashes": "1.3.1" } }, "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA=="], + + "@scure/bip32/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], + + "@scure/bip39/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], + + "@scure/bip32/@noble/curves/@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="], + } +} diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..d3082e5 --- /dev/null +++ b/index.ts @@ -0,0 +1,22 @@ +import { type RelayConfig, main } from "./src/main.ts"; + +const config: RelayConfig = { + allowUnauthedPublish: Boolean(process.env.ALLOW_UNAUTHED_PUBLISH) || false, + outsideURL: process.env.RELAY_OUTSIDE_URL || process.env.RELAY_URL!, + relay: process.env.RELAY_URL!, + name: process.env.RELAY_NAME, + description: process.env.RELAY_DESCRIPTION, + banner: process.env.RELAY_BANNER, + icon: process.env.RELAY_ICON, + contact: process.env.RELAY_CONTACT, + policy: process.env.RELAY_POLICY, + adminPubkey: process.env.ADMIN_PUBKEY, +}; + +if ( + !config.relay || + (!config.relay?.startsWith("wss://") && !config.relay?.startsWith("ws://")) +) + config.relay = "wss://relay.arx-ccn.com"; + +main(config); diff --git a/package.json b/package.json new file mode 100644 index 0000000..eabe435 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "nip42-proxy", + "module": "index.ts", + "type": "module", + "private": true, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "nostr-tools": "^2.16.2" + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..46da03d --- /dev/null +++ b/src/main.ts @@ -0,0 +1,371 @@ +import type { ServerWebSocket } from "bun"; +import { nip98, type Event } from "nostr-tools"; +import { + allowKind, + allowPubkey, + banPubkey, + disallowKind, + getAllAllowedKinds, + getAllAllowedPubkeys, + isKindAllowed, + isPubkeyAllowed, + validateAuthEvent, +} from "./utils.ts"; +import { getGitCommitHash } from "./utils.ts" with { type: "macro" }; + +type Nip42ProxySocketData = { + authenticated: boolean; + authToken: string; + remoteWs?: WebSocket; +}; + +const sendMessage = ( + ws: ServerWebSocket, + message: any[], +) => ws.send(JSON.stringify(message), true); + +const sendAuth = (ws: ServerWebSocket) => { + sendMessage(ws, [ + "AUTH", + ws.data.authToken, + "This is an authenticated relay.", + ]); +}; + +export interface RelayConfig { + adminPubkey?: string; + outsideURL: string; + relay: string; + name?: string; + description?: string; + icon?: string; + banner?: string; + contact?: string; + policy?: string; + allowUnauthedPublish: boolean; +} + +export function main({ + relay, + outsideURL, + adminPubkey, + name, + description, + icon, + banner, + contact, + policy, + allowUnauthedPublish, +}: RelayConfig) { + function relayInfo(returnJson: boolean = false) { + const json = { + name, + description, + banner, + pubkey: adminPubkey, + contact, + software: "https://git.arx-ccn.com/Arx/nip42-proxy.git", + supported_nips: [1, 2, 4, 9, 11, 22, 28, 40, 42, 70, 77, 86], + version: `nip42-proxy - ${getGitCommitHash()}`, + posting_policy: policy, + icon, + limitation: { + auth_required: true, + restricted_writes: true, + }, + }; + if (returnJson) return json; + return new Response(JSON.stringify(json), { + headers: { "content-type": "application/json; charset=utf-8" }, + }); + } + + async function relayRPC( + adminPubkey: string | undefined, + token: string, + url: string, + method: string, + params: any[], + ) { + if (method === "supportedmethods") + return new Response( + JSON.stringify({ + result: [ + "supportedmethods", + "getinfo", + "banpubkey", + "allowpubkey", + "listallowedpubkeys", + "allowkind", + "disallowkind", + "listallowedkinds", + ], + }), + ); + if (method === "getinfo") + return new Response( + JSON.stringify({ + result: relayInfo(true), + }), + ); + const valid = await nip98.validateToken(token, url, method); + if (!token || !valid) + return new Response( + JSON.stringify({ + error: "You are not authorized", + }), + ); + const event = await nip98.unpackEventFromToken(token); + if (adminPubkey && event.pubkey !== adminPubkey) + return new Response( + JSON.stringify({ + error: "You are not authorized", + }), + ); + if (!(await isPubkeyAllowed(event))) + return new Response( + JSON.stringify({ + error: "You are not authorized", + }), + ); + + if (method === "allowpubkey") { + const [pubkey] = params; + if (!pubkey) + return new Response( + JSON.stringify({ + error: "Missing pubkey parameter", + }), + ); + await allowPubkey(pubkey); + return new Response( + JSON.stringify({ + result: true, + }), + ); + } + if (method === "listallowedpubkeys") { + const resp = await getAllAllowedPubkeys(); + return new Response( + JSON.stringify({ + result: resp.map((k: string) => ({ pubkey: k })), + }), + ); + } + if (method === "banpubkey") { + const [pubkey] = params; + if (!pubkey) + return new Response( + JSON.stringify({ + error: "Missing pubkey parameter", + }), + ); + await banPubkey(pubkey); + return new Response( + JSON.stringify({ + result: true, + }), + ); + } + if (method === "allowkind") { + const [kind] = params; + if (typeof kind !== "number") + return new Response( + JSON.stringify({ + error: "Missing or invalid kind parameter", + }), + ); + await allowKind(kind); + return new Response( + JSON.stringify({ + result: true, + }), + ); + } + if (method === "disallowkind") { + const [kind] = params; + if (typeof kind !== "number") + return new Response( + JSON.stringify({ + error: "Missing or invalid kind parameter", + }), + ); + await disallowKind(kind); + return new Response( + JSON.stringify({ + result: true, + }), + ); + } + if (method === "listallowedkinds") { + const resp = (await getAllAllowedKinds()).sort((a, b) => a - b); + return new Response( + JSON.stringify({ + result: resp, + }), + ); + } + return new Response( + JSON.stringify({ + error: `Unsupported method ${method}`, + }), + ); + } + + const server = Bun.serve({ + async fetch(req, server) { + const upgraded = server.upgrade(req, { + data: { + authenticated: false, + authToken: Bun.randomUUIDv7(), + }, + }); + if (upgraded) return new Response("Upgraded"); + const url = new URL(req.url); + if (url.pathname === "") url.pathname = "/"; + if (url.pathname === "/") { + if (req.headers.get("accept") === "application/nostr+json") + return relayInfo() as Response; + switch (req.headers.get("content-type")) { + case "application/nostr+json": + return relayInfo() as Response; + case "application/nostr+json+rpc": { + const token = req.headers.get("Authorization") || ""; + const data: { method?: string; params?: any[] } = await req.json(); + if (!data || !data.method) + return new Response( + JSON.stringify({ + error: "Please send a valid nostr rpc request", + }), + ); + let { method, params } = data; + params ||= []; + const urlForRequest = new URL(req.url); + urlForRequest.hostname = new URL(outsideURL).hostname; + urlForRequest.protocol = "https"; + return relayRPC( + adminPubkey, + token, + urlForRequest.toString(), + method, + params, + ); + } + default: + return new Response("Nip42 Proxy"); + } + } + if (url.pathname === "/api/health") + return new Response( + JSON.stringify({ + activeRequests: server.pendingRequests, + activeWebsockets: server.pendingWebSockets, + }), + ); + return new Response("Not Found", { status: 404 }); + }, + websocket: { + async message(ws, msg) { + const [command, ...data] = JSON.parse(msg as string); + if (!ws.data.authenticated) { + if (command === "REQ") { + const [name] = data; + sendMessage(ws, [ + "CLOSED", + name, + "auth-required: you must authenticate first", + ]); + + } else if (command === "EVENT") { + const [event] = data as [Event]; + if ( + allowUnauthedPublish && + (await isPubkeyAllowed(event)) && + (await isKindAllowed(event)) + ) { + if (ws.data.remoteWs) { + ws.data.remoteWs.send(msg); + } + return; + } + sendMessage(ws, [ + "OK", + event.id, + false, + "auth-required: you must authenticate first", + ]); + } else if (command === "AUTH") { + const [event] = data as [Event]; + const valid = await validateAuthEvent(event, ws.data.authToken); + if (!valid) { + sendMessage(ws, ["NOTICE", "Invalid auth event"]); + return; + } + sendMessage(ws, [ + "OK", + event.id, + true, + "successully authenticated. welcome", + ]); + ws.data.authenticated = true; + } else { + sendAuth(ws); + } + return; + } + + if (ws.data.authenticated && command === "AUTH") { + const [event] = data as [Event]; + sendMessage(ws, [ + "OK", + event.id, + true, + "you were already authenticated", + ]); + return; + } + + if (ws.data.remoteWs) { + const [event] = data as [Event]; + if(command === "EVENT") { + if(!await isPubkeyAllowed(event)) + return sendMessage(ws, [ + "OK", + event.id, + false, + "pubkey not allowed", + ]); + if(!await isKindAllowed(event)) + return sendMessage(ws, [ + "OK", + event.id, + false, + "kind not allowed", + ]); + } + ws.data.remoteWs.send(msg); + } + }, + open(ws) { + const remoteWs = new WebSocket(relay); + ws.data.remoteWs = remoteWs; + + remoteWs.addEventListener("message", (data) => + ws.send( + (data as MessageEvent).data as string | ArrayBufferLike, + true, + ), + ); + remoteWs.addEventListener("open", () => { + sendAuth(ws); + }); + remoteWs.addEventListener("error", console.error); + }, + perMessageDeflate: true, + close(ws) { + ws.data.remoteWs?.close(); + }, + }, + }); + console.log("Server listening @", server.url.host); +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..f637578 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,113 @@ +import type { Event } from "nostr-tools"; + +export function getGitCommitHash() { + try { + const { stdout } = Bun.spawnSync({ + cmd: ["git", "rev-parse", "HEAD"], + stdout: "pipe", + stderr: "pipe", + }); + const out = stdout?.toString()?.trim(); + return out?.split("\n")[0] || "unknown"; + } catch { + return "unknown"; + } +} + +export async function validateAuthEvent( + event: Event, + challenge: string, +): Promise { + if (event.kind !== 22242) return false; + const last30Seconds = Math.floor(Date.now() / 1000) - 30; + if (event.created_at < last30Seconds) return false; + const challengeTag = event.tags.find((tag) => tag[0] === "challenge")?.[1]; + if (challengeTag !== challenge) return false; + if(!await isPubkeyAllowed(event)) + return false; + return await isKindAllowed(event); +} + +export async function isPubkeyAllowed(event: Event): Promise { + const file = Bun.file("./allowed-pubkeys.json"); + if (!(await file.exists())) return true; + try { + const allowedPubkeys = JSON.parse(await file.text()); + return ( + Array.isArray(allowedPubkeys) && allowedPubkeys.includes(event.pubkey) + ); + } catch { + // If the file is malformed, default to deny + return false; + } +} + +export async function getAllAllowedPubkeys() { + const file = Bun.file("./allowed-pubkeys.json"); + try { + if (await file.exists()) return JSON.parse(await file.text()); + } catch {} + return [] as string[]; +} + +export async function saveAllowedPubkeys(pubkeys: string[]): Promise { + const file = Bun.file("./allowed-pubkeys.json"); + const unique = Array.from(new Set(pubkeys)); + return await file.write(JSON.stringify(unique)); +} + +export async function allowPubkey(pubkey: string): Promise { + const pubkeys = await getAllAllowedPubkeys(); + if (!pubkeys.includes(pubkey)) pubkeys.push(pubkey); + return await saveAllowedPubkeys(pubkeys); +} + + +export async function banPubkey(pubkey: string): Promise { + const pubkeys = await getAllAllowedPubkeys(); + const filtered = pubkeys.filter((p: string) => p !== pubkey); + return await saveAllowedPubkeys(filtered); +} + + +export async function isKindAllowed(event: Event): Promise { + const file = Bun.file("./allowed-kinds.json"); + if (!(await file.exists())) return true; + try { + const allowedKinds = JSON.parse(await file.text()); + if(!Array.isArray(allowedKinds)) return true; + if(allowedKinds.length === 0) return true; + return ( + Array.isArray(allowedKinds) && allowedKinds.includes(event.kind) + ); + } catch { + // If the file is malformed, default to allow + return true; + } +} + +export async function getAllAllowedKinds(): Promise { + const file = Bun.file("./allowed-kinds.json"); + try { + if (await file.exists()) return JSON.parse(await file.text()); + } catch {} + return [] as number[]; +} + +export async function saveAllowedKinds(kinds: number[]): Promise { + const file = Bun.file("./allowed-kinds.json"); + const unique = Array.from(new Set(kinds)); + return await file.write(JSON.stringify(unique)); +} + +export async function allowKind(kind: number): Promise { + const kinds = await getAllAllowedKinds(); + if (!kinds.includes(kind)) kinds.push(kind); + return await saveAllowedKinds(kinds); +} + +export async function disallowKind(kind: number): Promise { + const kinds = await getAllAllowedKinds(); + const filtered = kinds.filter((p: number) => p !== kind); + return await saveAllowedKinds(filtered); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}