import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; import { verifyEvent, type VerifiedEvent, nip19 } from 'nostr-tools'; import { prisma } from '../db/prisma'; import { ALL_PERMISSION_KEYS, isAssignableRole, type EffectiveRole, } from '../constants/permissions'; const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production'; const CHALLENGE_TTL_MS = 5 * 60 * 1000; export interface ResolvedAccess { pubkey: string; role: EffectiveRole; isSuperAdmin: boolean; permissions: Set; } // Reads the SuperAdmin pubkey list from the environment, decoding npub to hex. // Falls back to the legacy ADMIN_PUBKEYS variable so existing deployments keep // working. The env-admin concept is now SuperAdmin. function getSuperadminPubkeys(): string[] { const raw = process.env.SUPERADMIN_PUBKEYS ?? process.env.ADMIN_PUBKEYS ?? ''; return raw .split(',') .map((p) => p.trim()) .filter(Boolean) .map((p) => { if (p.startsWith('npub1')) { try { const { data } = nip19.decode(p); return data as string; } catch { return p; } } return p; }); } export function isSuperadmin(pubkey: string): boolean { return getSuperadminPubkeys().includes(pubkey); } interface StoredChallenge { challenge: string; expiresAt: number; } const challenges = new Map(); // Periodically clean up expired challenges setInterval(() => { const now = Date.now(); for (const [key, value] of challenges) { if (value.expiresAt < now) { challenges.delete(key); } } }, 60_000); export const authService = { createChallenge(pubkey: string): string { const challenge = uuidv4(); challenges.set(pubkey, { challenge, expiresAt: Date.now() + CHALLENGE_TTL_MS, }); return challenge; }, verifySignature(pubkey: string, signedEvent: VerifiedEvent): boolean { const stored = challenges.get(pubkey); if (!stored) return false; if (stored.expiresAt < Date.now()) { challenges.delete(pubkey); return false; } // Verify the event signature if (!verifyEvent(signedEvent)) return false; // Kind 22242 is the NIP-42 auth kind if (signedEvent.kind !== 22242) return false; if (signedEvent.pubkey !== pubkey) return false; // Check that the challenge tag matches const challengeTag = signedEvent.tags.find( (t) => t[0] === 'challenge' ); if (!challengeTag || challengeTag[1] !== stored.challenge) return false; challenges.delete(pubkey); return true; }, generateToken(pubkey: string, role: string): string { return jwt.sign({ pubkey, role }, JWT_SECRET, { expiresIn: '7d' }); }, isSuperadmin, // Resolves the effective role and permission set for a pubkey, live, from the // env SuperAdmin list plus the database. This is the authoritative source for // authorization. The role baked into a JWT is only used for display. async resolveAccess(pubkey: string): Promise { if (isSuperadmin(pubkey)) { return { pubkey, role: 'superadmin', isSuperAdmin: true, permissions: new Set(ALL_PERMISSION_KEYS), }; } const user = await prisma.user.findUnique({ where: { pubkey } }); const role: EffectiveRole = isAssignableRole(user?.role) ? user!.role : 'guest'; if (role === 'guest') { return { pubkey, role, isSuperAdmin: false, permissions: new Set() }; } const rows = await prisma.rolePermission.findMany({ where: { role } }); return { pubkey, role, isSuperAdmin: false, permissions: new Set(rows.map((r) => r.permission)), }; }, };