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

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