first commit

Made-with: Cursor
This commit is contained in:
Michaël
2026-02-26 18:33:00 -03:00
commit 3734365463
76 changed files with 14133 additions and 0 deletions

View File

@@ -0,0 +1,153 @@
import { Request, Response, NextFunction } from "express";
import { verifyEvent, getEventHash } from "nostr-tools";
import { config } from "../config.js";
import { getDb } from "../db/index.js";
const AUTH_SCHEME = "Nostr ";
export interface NostrAuthPayload {
pubkey: string;
eventId: string;
}
declare global {
namespace Express {
interface Request {
nostr?: NostrAuthPayload;
}
}
}
function getTag(event: { tags: string[][] }, name: string): string | null {
const row = event.tags.find((t) => t[0] === name);
return row && row[1] ? row[1] : null;
}
export async function nip98Auth(req: Request, res: Response, next: NextFunction): Promise<void> {
const auth = req.headers.authorization;
if (!auth || !auth.startsWith(AUTH_SCHEME)) {
res.status(401).json({
code: "invalid_nip98",
message: "Missing or invalid Authorization header. Use NIP-98 Nostr scheme.",
});
return;
}
const base64 = auth.slice(AUTH_SCHEME.length).trim();
let event: { id: string; pubkey: string; kind: number; created_at: number; tags: string[][]; content: string; sig: string };
try {
const decoded = Buffer.from(base64, "base64").toString("utf-8");
const parsed = JSON.parse(decoded) as Record<string, unknown>;
event = {
id: String(parsed.id ?? ""),
pubkey: String(parsed.pubkey ?? ""),
kind: Number(parsed.kind),
created_at: Number(parsed.created_at),
tags: Array.isArray(parsed.tags) ? (parsed.tags as string[][]) : [],
content: typeof parsed.content === "string" ? parsed.content : "",
sig: String(parsed.sig ?? ""),
};
} catch {
res.status(401).json({
code: "invalid_nip98",
message: "Invalid NIP-98 payload: not valid base64 or JSON.",
});
return;
}
if (event.kind !== 27235) {
res.status(401).json({
code: "invalid_nip98",
message: "NIP-98 event kind must be 27235.",
});
return;
}
const now = Math.floor(Date.now() / 1000);
if (Math.abs(event.created_at - now) > config.nip98MaxSkewSeconds) {
res.status(401).json({
code: "invalid_nip98",
message: "NIP-98 event timestamp is outside allowed window.",
});
return;
}
const u = getTag(event, "u");
const method = getTag(event, "method");
if (!u || !method) {
res.status(401).json({
code: "invalid_nip98",
message: "NIP-98 event must include 'u' and 'method' tags.",
});
return;
}
// Reconstruct absolute URL (protocol + host + path + query)
const proto = req.headers["x-forwarded-proto"] ?? (req.socket as { encrypted?: boolean }).encrypted ? "https" : "http";
const host = req.headers["x-forwarded-host"] ?? req.headers.host ?? "";
const path = req.originalUrl ?? req.url;
const absoluteUrl = `${proto}://${host}${path}`;
if (u !== absoluteUrl) {
res.status(401).json({
code: "invalid_nip98",
message: "NIP-98 'u' tag does not match request URL.",
});
return;
}
if (method.toUpperCase() !== req.method) {
res.status(401).json({
code: "invalid_nip98",
message: "NIP-98 'method' tag does not match request method.",
});
return;
}
const computedId = getEventHash({
kind: event.kind,
pubkey: event.pubkey,
created_at: event.created_at,
tags: event.tags,
content: event.content,
sig: event.sig,
} as Parameters<typeof getEventHash>[0]);
if (computedId !== event.id) {
res.status(401).json({
code: "invalid_nip98",
message: "NIP-98 event id does not match computed hash.",
});
return;
}
const valid = verifyEvent({
id: event.id,
kind: event.kind,
pubkey: event.pubkey,
created_at: event.created_at,
tags: event.tags,
content: event.content,
sig: event.sig,
} as Parameters<typeof verifyEvent>[0]);
if (!valid) {
res.status(401).json({
code: "invalid_nip98",
message: "NIP-98 signature verification failed.",
});
return;
}
const db = getDb();
const hasNonce = await db.hasNonce(event.id);
if (hasNonce) {
res.status(401).json({
code: "invalid_nip98",
message: "NIP-98 nonce already used (replay).",
});
return;
}
const expiresAt = now + config.nonceTtlSeconds;
await db.setNonce(event.id, expiresAt);
req.nostr = { pubkey: event.pubkey, eventId: event.id };
next();
}