basic nip86 implmentation

This commit is contained in:
Danny Morabito 2025-08-09 00:42:41 +02:00
parent 22a4fe069f
commit df94aed0d0
Signed by: dannym
GPG key ID: 7CC8056A5A04557E
6 changed files with 370 additions and 56 deletions

View file

@ -1,99 +1,270 @@
import type { ServerWebSocket } from "bun";
interface Event {
kind: number;
tags: string[][];
content: string;
created_at: number;
pubkey: string;
id: string;
sig: string;
}
import {
allowPubkey,
banPubkey,
getAllAllowedPubkeys,
isPubkeyAllowed,
validateAuthEvent,
} from "./utils.ts";
import { getGitCommitHash } from "./utils.ts" with { type: "macro" };
import { nip98 } from "nostr-tools";
type Nip42ProxySocketData = {
authenticated: boolean;
authToken: string;
remoteWs: WebSocket;
remoteWs?: WebSocket;
};
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;
return await isPubkeyAllowed(event);
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;
relay: string;
name?: string;
description?: string;
icon?: string;
banner?: string;
contact?: string;
policy?: string;
allowUnauthedPublish: boolean;
}
async function isPubkeyAllowed(event: Event): Promise<boolean> {
const file = Bun.file("./allowed-pubkeys.json");
if (!await file.exists()) return true;
const allowedPubkeys = JSON.parse(await file.text());
return allowedPubkeys.includes(event.pubkey);
}
export function main({
relay,
adminPubkey,
name,
description,
icon,
banner,
contact,
policy,
allowUnauthedPublish,
}: RelayConfig) {
function relayInfo() {
return new Response(
JSON.stringify({
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,
},
}),
{ headers: { "content-type": "application/json; charset=utf-8" } },
);
}
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."]);
async function relayRPC(
config: RelayConfig,
token: string,
url: string,
method: string,
params: any[],
) {
if (method === "supportedmethods")
return new Response(
JSON.stringify({
result: [
"supportedmethods",
"banpubkey",
"allowpubkey",
"listallowedpubkeys",
],
}),
);
const valid = await nip98.validateToken(token, url, "POST");
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",
}),
);
else 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,
}),
);
}
return new Response(
JSON.stringify({
error: `Unsupported method ${method}`,
}),
);
}
export function main(mainRelayUrl: string, allowUnauthedPublish: boolean) {
const server = Bun.serve<Nip42ProxySocketData, {}>({
fetch(req, server) {
const upgrade = server.upgrade(req, {
async fetch(req, server) {
const upgraded = server.upgrade(req, {
data: {
authenticated: false,
authToken: Bun.randomUUIDv7()
}
authToken: Bun.randomUUIDv7(),
},
});
if (!upgrade) console.log(`http request`)
return new Response("this is a proxy. Use http to access it");
if (upgraded) return undefined as any;
const url = new URL(req.url);
if (url.pathname === "/") {
switch (req.headers.get("content-type")) {
case "application/nostr+json":
return relayInfo();
case "application/nostr+json+rpc":
const token = req.headers.get("Authorization") || "";
let data = await req.json();
if (!data)
return new Response(
JSON.stringify({
error: "Please send a valid nostr rpc request",
}),
);
let { method, params } = await req.json();
params ||= [];
return relayRPC(adminPubkey, token, req.url, method, params);
break;
default:
return new Response("Nip42 Proxy");
}
}
if (url.pathname === "/api/health")
return new Response(
JSON.stringify({
activeRequests: server.pendingRequests,
activeWebsockets: server.pendingWebSockets,
}),
);
},
websocket: {
async message(ws, msg) {
const [command, ...data] = JSON.parse(msg);
const [command, ...data] = JSON.parse(msg as string);
if (!ws.data.authenticated) {
if (command === "REQ") {
const [name, ...filters] = data;
sendMessage(ws, ["CLOSED", name, 'auth-required: you must authenticate first']);
sendMessage(ws, [
"CLOSED",
name,
"auth-required: you must authenticate first",
]);
}
if (command === "EVENT") {
const [event] = data;
if (allowUnauthedPublish && await isPubkeyAllowed(event)) {
if (allowUnauthedPublish && (await isPubkeyAllowed(event))) {
ws.data.remoteWs.send(msg);
return;
}
sendMessage(ws, ["OK", event.id, false, 'auth-required: you must authenticate first']);
sendMessage(ws, [
"OK",
event.id,
false,
"auth-required: you must authenticate first",
]);
}
if (command === "AUTH") {
const [event] = data;
const valid = await validateAuthEvent(event, ws.data.authToken);
if (!valid) return sendMessage(ws, ['NOTICE', "Invalid auth event"]);
sendMessage(ws, ['OK', event.id, true, "successully authenticated. welcome"]);
if (!valid)
return sendMessage(ws, ["NOTICE", "Invalid auth event"]);
sendMessage(ws, [
"OK",
event.id,
true,
"successully authenticated. welcome",
]);
ws.data.authenticated = true;
return;
}
return sendAuth(ws);
} else if (ws.data.authenticated && command === "AUTH") {
const [event] = data;
return sendMessage(ws, ['OK', event.id, true, 'you were already authenticated'])
return sendMessage(ws, [
"OK",
event.id,
true,
"you were already authenticated",
]);
}
ws.data.remoteWs.send(msg);
},
open(ws) {
const remoteWs = new WebSocket(mainRelayUrl);
remoteWs.addEventListener("message", data => ws.send(data.data, true));
const remoteWs = new WebSocket(relay);
remoteWs.addEventListener("message", (data) =>
ws.send(
(data as MessageEvent).data as string | ArrayBufferLike,
true,
),
);
remoteWs.addEventListener("open", () => {
ws.data.remoteWs = remoteWs;
sendAuth(ws);
})
remoteWs.addEventListener("error", console.error)
});
remoteWs.addEventListener("error", console.error);
},
perMessageDeflate: true,
perMessageDeflateCompressionLevel: 9
perMessageDeflateCompressionLevel: 9,
close(ws) {
ws.data.remoteWs?.close();
}
}
})
console.log('Server listening @', server.url.host)
},
},
});
console.log("Server listening @", server.url.host);
}

67
src/utils.ts Normal file
View file

@ -0,0 +1,67 @@
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;
return await isPubkeyAllowed(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);
}