initial version

This commit is contained in:
Danny Morabito 2025-08-05 23:48:43 +02:00
commit 9a58dcc097
Signed by: dannym
GPG key ID: 7CC8056A5A04557E
10 changed files with 823 additions and 0 deletions

36
.gitignore vendored Normal file
View file

@ -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

20
Dockerfile Normal file
View file

@ -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"]

128
README.md Normal file
View file

@ -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.

31
biome.json Normal file
View file

@ -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"
}
}
}

58
bun.lock Normal file
View file

@ -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=="],
}
}

22
index.ts Normal file
View file

@ -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);

15
package.json Normal file
View file

@ -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"
}
}

371
src/main.ts Normal file
View file

@ -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<Nip42ProxySocketData>,
message: any[],
) => ws.send(JSON.stringify(message), true);
const sendAuth = (ws: ServerWebSocket<Nip42ProxySocketData>) => {
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<Nip42ProxySocketData>({
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);
}

113
src/utils.ts Normal file
View file

@ -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<boolean> {
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<boolean> {
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<number> {
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<number> {
const pubkeys = await getAllAllowedPubkeys();
if (!pubkeys.includes(pubkey)) pubkeys.push(pubkey);
return await saveAllowedPubkeys(pubkeys);
}
export async function banPubkey(pubkey: string): Promise<number> {
const pubkeys = await getAllAllowedPubkeys();
const filtered = pubkeys.filter((p: string) => p !== pubkey);
return await saveAllowedPubkeys(filtered);
}
export async function isKindAllowed(event: Event): Promise<boolean> {
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<number[]> {
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<number> {
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<number> {
const kinds = await getAllAllowedKinds();
if (!kinds.includes(kind)) kinds.push(kind);
return await saveAllowedKinds(kinds);
}
export async function disallowKind(kind: number): Promise<number> {
const kinds = await getAllAllowedKinds();
const filtered = kinds.filter((p: number) => p !== kind);
return await saveAllowedKinds(filtered);
}

29
tsconfig.json Normal file
View file

@ -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
}
}