feat: roles/permissions system and Nostr profile display on admin users
Introduce granular role-based permissions with SuperAdmin env override, admin roles UI, and permission-gated API routes. Fix admin user Nostr metadata by batching relay profile fetches, normalizing npub pubkeys to hex, and adding reusable NostrAvatar/useNostrProfile components. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,10 +2,48 @@ 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<string>;
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -62,29 +100,34 @@ export const authService = {
|
||||
return jwt.sign({ pubkey, role }, JWT_SECRET, { expiresIn: '7d' });
|
||||
},
|
||||
|
||||
async getRole(pubkey: string): Promise<string> {
|
||||
const adminPubkeys = (process.env.ADMIN_PUBKEYS || '')
|
||||
.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;
|
||||
});
|
||||
isSuperadmin,
|
||||
|
||||
if (adminPubkeys.includes(pubkey)) return 'ADMIN';
|
||||
// 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<ResolvedAccess> {
|
||||
if (isSuperadmin(pubkey)) {
|
||||
return {
|
||||
pubkey,
|
||||
role: 'superadmin',
|
||||
isSuperAdmin: true,
|
||||
permissions: new Set(ALL_PERMISSION_KEYS),
|
||||
};
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { pubkey } });
|
||||
if (user?.role === 'MODERATOR') return 'MODERATOR';
|
||||
if (user?.role === 'ADMIN') return 'ADMIN';
|
||||
const role: EffectiveRole = isAssignableRole(user?.role) ? user!.role : 'guest';
|
||||
|
||||
return 'USER';
|
||||
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)),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user