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:
bbe
2026-06-23 08:46:56 +02:00
co-authored by Cursor
parent 3bdd01dedf
commit 70e3e0633d
38 changed files with 1556 additions and 419 deletions
@@ -0,0 +1,83 @@
-- Roles and permissions system.
-- Makes User.role nullable (null means Guest), backfills legacy uppercase roles
-- to the new lowercase enum, adds the RolePermission table, and seeds default
-- permission sets. Safe and idempotent to run on a live database.
-- RedefineTables: make User.role nullable and drop its default, converting values.
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_User" (
"id" TEXT NOT NULL PRIMARY KEY,
"pubkey" TEXT NOT NULL,
"role" TEXT,
"displayName" TEXT,
"username" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_User" ("id", "pubkey", "role", "displayName", "username", "createdAt", "updatedAt")
SELECT
"id",
"pubkey",
CASE
WHEN "role" = 'ADMIN' THEN 'admin'
WHEN "role" = 'MODERATOR' THEN 'moderator'
WHEN "role" = 'WRITER' THEN 'writer'
WHEN "role" IN ('admin', 'moderator', 'writer') THEN "role"
ELSE NULL
END,
"displayName",
"username",
"createdAt",
"updatedAt"
FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_pubkey_key" ON "User"("pubkey");
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateTable
CREATE TABLE IF NOT EXISTS "RolePermission" (
"role" TEXT NOT NULL,
"permission" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("role", "permission")
);
-- Seed default permission sets. INSERT OR IGNORE keeps this idempotent and never
-- clobbers permissions an operator has already customized at runtime.
INSERT OR IGNORE INTO "RolePermission" ("role", "permission") VALUES
('admin', 'events.create'),
('admin', 'events.edit'),
('admin', 'events.delete'),
('admin', 'organizers.manage'),
('admin', 'gallery.upload'),
('admin', 'gallery.delete'),
('admin', 'blog.draft'),
('admin', 'blog.publish'),
('admin', 'blog.delete'),
('admin', 'faq.manage'),
('admin', 'submissions.review'),
('admin', 'board.manage'),
('admin', 'moderation.act'),
('admin', 'categories.manage'),
('admin', 'users.assign_role'),
('admin', 'nip05.assign'),
('admin', 'relays.manage'),
('admin', 'settings.edit'),
('admin', 'nostr_tools.use'),
('moderator', 'events.create'),
('moderator', 'events.edit'),
('moderator', 'events.delete'),
('moderator', 'submissions.review'),
('moderator', 'moderation.act'),
('moderator', 'gallery.upload'),
('moderator', 'board.manage'),
('moderator', 'categories.manage'),
('moderator', 'faq.manage'),
('writer', 'blog.draft'),
('writer', 'gallery.upload'),
('writer', 'events.create');
+12 -1
View File
@@ -10,13 +10,24 @@ datasource db {
model User {
id String @id @default(uuid())
pubkey String @unique
role String @default("USER") // USER, MODERATOR, ADMIN
role String? // admin, moderator, writer. Null means Guest (no elevated role). SuperAdmin is env-sourced and never stored.
displayName String?
username String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Maps an assignable role to a granted permission key. Editable at runtime.
// One row per (role, permission). SuperAdmin is never stored here; it bypasses
// all checks via the env list.
model RolePermission {
role String
permission String
createdAt DateTime @default(now())
@@id([role, permission])
}
model Organizer {
id String @id @default(uuid())
name String
+13
View File
@@ -1,6 +1,7 @@
import dotenv from 'dotenv';
import path from 'path';
import { PrismaClient } from '@prisma/client';
import { DEFAULT_ROLE_PERMISSIONS } from '../src/constants/permissions';
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
@@ -87,6 +88,18 @@ async function main() {
});
}
// Seed default role permissions. This only fills in missing rows so it never
// clobbers permissions an operator has customized at runtime.
for (const [role, permissions] of Object.entries(DEFAULT_ROLE_PERMISSIONS)) {
for (const permission of permissions) {
await prisma.rolePermission.upsert({
where: { role_permission: { role, permission } },
update: {},
create: { role, permission },
});
}
}
console.log('Seed completed successfully.');
}
+2 -2
View File
@@ -1,10 +1,10 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
const router = Router();
router.use(requireAuth, requireRole(['ADMIN', 'MODERATOR']));
router.use(requireAuth, requires('board.manage'));
router.get('/', async (_req: Request, res: Response) => {
try {
+40 -6
View File
@@ -1,6 +1,7 @@
import { Router, Request, Response } from 'express';
import { authService } from '../services/auth';
import { prisma } from '../db/prisma';
import { requireAuth } from '../middleware/auth';
const router = Router();
@@ -34,20 +35,53 @@ router.post('/verify', async (req: Request, res: Response) => {
return;
}
const role = await authService.getRole(pubkey);
// Ensure a user row exists, but never overwrite an existing role on login.
// The role is managed only through role assignment, and SuperAdmin is env-sourced.
const dbUser = await prisma.user.upsert({
where: { pubkey },
update: { role },
create: { pubkey, role },
update: {},
create: { pubkey },
});
const token = authService.generateToken(pubkey, role);
res.json({ token, user: { pubkey, role, username: dbUser.username ?? undefined } });
const access = await authService.resolveAccess(pubkey);
// The token carries a display role for convenience only. Authorization is
// always resolved live from the env list and the database.
const token = authService.generateToken(pubkey, access.role);
res.json({
token,
user: {
pubkey,
role: access.role,
isSuperAdmin: access.isSuperAdmin,
permissions: [...access.permissions],
username: dbUser.username ?? undefined,
},
});
} catch (err) {
console.error('Verify error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// Returns the current user's live effective role and permission set, for
// frontend gating. Identity comes from the JWT, authorization is resolved fresh.
router.get('/me', requireAuth, async (req: Request, res: Response) => {
try {
const pubkey = req.user!.pubkey;
const access = await authService.resolveAccess(pubkey);
const dbUser = await prisma.user.findUnique({ where: { pubkey } });
res.json({
pubkey,
role: access.role,
isSuperAdmin: access.isSuperAdmin,
permissions: [...access.permissions],
username: dbUser?.username ?? undefined,
});
} catch (err) {
console.error('Me error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;
+4 -4
View File
@@ -1,6 +1,6 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
const router = Router();
@@ -19,7 +19,7 @@ router.get('/', async (_req: Request, res: Response) => {
router.post(
'/',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('categories.manage'),
async (req: Request, res: Response) => {
try {
const { name, slug, sortOrder } = req.body;
@@ -47,7 +47,7 @@ router.post(
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('categories.manage'),
async (req: Request, res: Response) => {
try {
const category = await prisma.category.findUnique({
@@ -80,7 +80,7 @@ router.patch(
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN']),
requires('categories.manage'),
async (req: Request, res: Response) => {
try {
const category = await prisma.category.findUnique({
+6 -6
View File
@@ -1,6 +1,6 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
const router = Router();
@@ -23,7 +23,7 @@ router.get('/', async (req: Request, res: Response) => {
router.get(
'/all',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('faq.manage'),
async (_req: Request, res: Response) => {
try {
const faqs = await prisma.faq.findMany({
@@ -41,7 +41,7 @@ router.get(
router.post(
'/',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('faq.manage'),
async (req: Request, res: Response) => {
try {
const { question, answer, showOnHomepage } = req.body;
@@ -75,7 +75,7 @@ router.post(
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('faq.manage'),
async (req: Request, res: Response) => {
try {
const faq = await prisma.faq.findUnique({ where: { id: req.params.id as string } });
@@ -107,7 +107,7 @@ router.patch(
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('faq.manage'),
async (req: Request, res: Response) => {
try {
const faq = await prisma.faq.findUnique({ where: { id: req.params.id as string } });
@@ -129,7 +129,7 @@ router.delete(
router.post(
'/reorder',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('faq.manage'),
async (req: Request, res: Response) => {
try {
const { items } = req.body as { items: { id: string; order: number }[] };
+4 -4
View File
@@ -5,7 +5,7 @@ import fs from 'fs';
import { ulid } from 'ulid';
import slugify from 'slugify';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
const REPO_ROOT = path.resolve(__dirname, '../../..');
const STORAGE_PATH = process.env.MEDIA_STORAGE_PATH
@@ -43,7 +43,7 @@ const router = Router();
router.post(
'/upload',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('gallery.upload'),
upload.single('file'),
async (req: Request, res: Response) => {
try {
@@ -142,7 +142,7 @@ router.get('/:id', async (req: Request, res: Response) => {
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('gallery.upload'),
async (req: Request, res: Response) => {
try {
const media = await prisma.media.findUnique({
@@ -180,7 +180,7 @@ router.patch(
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('gallery.delete'),
async (req: Request, res: Response) => {
try {
const media = await prisma.media.findUnique({
+6 -6
View File
@@ -1,6 +1,6 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
import { DEFAULT_ORGANIZER_SLUG } from '../constants/organizer';
import { respondIfOrganizerMigrationNeeded } from '../lib/prismaMigrationHint';
@@ -80,7 +80,7 @@ router.get('/:id', async (req: Request, res: Response) => {
router.post(
'/',
requireAuth,
requireRole(['ADMIN']),
requires('events.create'),
async (req: Request, res: Response) => {
try {
const {
@@ -138,7 +138,7 @@ router.post(
router.post(
'/bulk',
requireAuth,
requireRole(['ADMIN']),
requires('events.edit'),
async (req: Request, res: Response) => {
try {
const { action, ids } = req.body as { action: string; ids: string[] };
@@ -200,7 +200,7 @@ router.post(
router.post(
'/:id/duplicate',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('events.create'),
async (req: Request, res: Response) => {
try {
const original = await prisma.meetup.findUnique({ where: { id: req.params.id as string } });
@@ -237,7 +237,7 @@ router.post(
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('events.edit'),
async (req: Request, res: Response) => {
try {
const meetup = await prisma.meetup.findUnique({
@@ -292,7 +292,7 @@ router.patch(
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN']),
requires('events.delete'),
async (req: Request, res: Response) => {
try {
const meetup = await prisma.meetup.findUnique({
+7 -7
View File
@@ -1,13 +1,13 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
const router = Router();
router.get(
'/hidden',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('moderation.act'),
async (_req: Request, res: Response) => {
try {
const hidden = await prisma.hiddenContent.findMany({
@@ -24,7 +24,7 @@ router.get(
router.post(
'/hide',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('moderation.act'),
async (req: Request, res: Response) => {
try {
const { nostrEventId, reason } = req.body;
@@ -52,7 +52,7 @@ router.post(
router.delete(
'/unhide/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('moderation.act'),
async (req: Request, res: Response) => {
try {
const item = await prisma.hiddenContent.findUnique({
@@ -75,7 +75,7 @@ router.delete(
router.get(
'/blocked',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('moderation.act'),
async (_req: Request, res: Response) => {
try {
const blocked = await prisma.blockedPubkey.findMany({
@@ -92,7 +92,7 @@ router.get(
router.post(
'/block',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('moderation.act'),
async (req: Request, res: Response) => {
try {
const { pubkey, reason } = req.body;
@@ -120,7 +120,7 @@ router.post(
router.delete(
'/unblock/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('moderation.act'),
async (req: Request, res: Response) => {
try {
const item = await prisma.blockedPubkey.findUnique({
+4 -4
View File
@@ -1,6 +1,6 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
import { nostrService } from '../services/nostr';
const router = Router();
@@ -8,7 +8,7 @@ const router = Router();
router.post(
'/fetch',
requireAuth,
requireRole(['ADMIN']),
requires('nostr_tools.use'),
async (req: Request, res: Response) => {
try {
const { eventId, naddr } = req.body;
@@ -40,7 +40,7 @@ router.post(
router.post(
'/cache/refresh',
requireAuth,
requireRole(['ADMIN']),
requires('nostr_tools.use'),
async (_req: Request, res: Response) => {
try {
const cachedEvents = await prisma.nostrEventCache.findMany();
@@ -62,7 +62,7 @@ router.post(
router.get(
'/debug/:eventId',
requireAuth,
requireRole(['ADMIN']),
requires('nostr_tools.use'),
async (req: Request, res: Response) => {
try {
const cached = await prisma.nostrEventCache.findUnique({
+4 -4
View File
@@ -1,6 +1,6 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
import { respondIfOrganizerMigrationNeeded } from '../lib/prismaMigrationHint';
const router = Router();
@@ -38,7 +38,7 @@ router.get('/by-slug/:slug', async (req: Request, res: Response) => {
router.post(
'/',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('organizers.manage'),
async (req: Request, res: Response) => {
try {
const { name, slug } = req.body;
@@ -66,7 +66,7 @@ router.post(
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('organizers.manage'),
async (req: Request, res: Response) => {
try {
const organizer = await prisma.organizer.findUnique({
@@ -102,7 +102,7 @@ router.patch(
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN']),
requires('organizers.manage'),
async (req: Request, res: Response) => {
try {
const organizer = await prisma.organizer.findUnique({
+4 -4
View File
@@ -1,6 +1,6 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
import { nostrService } from '../services/nostr';
const router = Router();
@@ -66,7 +66,7 @@ router.get('/:slug', async (req: Request, res: Response) => {
router.post(
'/import',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('blog.draft'),
async (req: Request, res: Response) => {
try {
const { nostrEventId, naddr, title, excerpt, authorPubkey, publishedAt, tags } = req.body;
@@ -145,7 +145,7 @@ router.post(
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('blog.draft'),
async (req: Request, res: Response) => {
try {
const { title, slug, excerpt, featured, visible, categories } = req.body;
@@ -240,7 +240,7 @@ router.get('/:slug/replies', async (req: Request, res: Response) => {
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN']),
requires('blog.delete'),
async (req: Request, res: Response) => {
try {
const post = await prisma.post.findUnique({ where: { id: req.params.id as string } });
+6 -6
View File
@@ -1,7 +1,7 @@
import { Router, Request, Response } from 'express';
import { SimplePool } from 'nostr-tools';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
const router = Router();
@@ -24,7 +24,7 @@ router.get(
router.get(
'/',
requireAuth,
requireRole(['ADMIN']),
requires('relays.manage'),
async (_req: Request, res: Response) => {
try {
const relays = await prisma.relay.findMany({
@@ -41,7 +41,7 @@ router.get(
router.post(
'/',
requireAuth,
requireRole(['ADMIN']),
requires('relays.manage'),
async (req: Request, res: Response) => {
try {
const { url, priority } = req.body;
@@ -68,7 +68,7 @@ router.post(
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN']),
requires('relays.manage'),
async (req: Request, res: Response) => {
try {
const relay = await prisma.relay.findUnique({
@@ -101,7 +101,7 @@ router.patch(
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN']),
requires('relays.manage'),
async (req: Request, res: Response) => {
try {
const relay = await prisma.relay.findUnique({
@@ -124,7 +124,7 @@ router.delete(
router.post(
'/:id/test',
requireAuth,
requireRole(['ADMIN']),
requires('relays.manage'),
async (req: Request, res: Response) => {
try {
const relay = await prisma.relay.findUnique({
+128
View File
@@ -0,0 +1,128 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requires } from '../middleware/auth';
import {
PERMISSIONS,
ASSIGNABLE_ROLES,
isAssignableRole,
isValidPermissionKey,
} from '../constants/permissions';
const router = Router();
// Returns the full permission registry plus the assignable roles. Adding a key
// to the registry surfaces it here automatically.
router.get(
'/permissions',
requireAuth,
requires('roles.edit_permissions'),
async (_req: Request, res: Response) => {
res.json({
permissions: PERMISSIONS,
roles: ASSIGNABLE_ROLES,
});
}
);
// Returns each assignable role with its current permission set.
router.get(
'/roles',
requireAuth,
requires('roles.edit_permissions'),
async (_req: Request, res: Response) => {
try {
const rows = await prisma.rolePermission.findMany();
const byRole: Record<string, string[]> = {};
for (const role of ASSIGNABLE_ROLES) byRole[role] = [];
for (const row of rows) {
if (byRole[row.role]) byRole[row.role].push(row.permission);
}
res.json({
roles: ASSIGNABLE_ROLES.map((role) => ({ role, permissions: byRole[role] })),
});
} catch (err) {
console.error('List roles error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
// Replaces a role's permission set. Validates the role and every key, prevents
// privilege escalation, and prevents the caller from locking themselves out.
router.put(
'/roles/:role/permissions',
requireAuth,
requires('roles.edit_permissions'),
async (req: Request, res: Response) => {
try {
const roleRaw = req.params.role;
const role = typeof roleRaw === 'string' ? roleRaw : Array.isArray(roleRaw) ? roleRaw[0] : '';
if (!isAssignableRole(role)) {
res.status(400).json({ error: 'Invalid role' });
return;
}
const { permissions } = req.body as { permissions?: unknown };
if (!Array.isArray(permissions) || permissions.some((p) => typeof p !== 'string')) {
res.status(400).json({ error: 'permissions must be an array of strings' });
return;
}
const requested = [...new Set(permissions as string[])];
const unknown = requested.filter((p) => !isValidPermissionKey(p));
if (unknown.length > 0) {
res.status(400).json({ error: `Unknown permission keys: ${unknown.join(', ')}` });
return;
}
const caller = req.access!;
const newSet = new Set(requested);
const existingRows = await prisma.rolePermission.findMany({ where: { role } });
const currentSet = new Set(existingRows.map((r) => r.permission));
// No privilege escalation: any newly granted permission must be held by the
// caller. SuperAdmin holds everything and is exempt.
if (!caller.isSuperAdmin) {
const added = requested.filter((p) => !currentSet.has(p));
const escalated = added.filter((p) => !caller.permissions.has(p));
if (escalated.length > 0) {
res.status(403).json({
error: `You cannot grant permissions you do not hold: ${escalated.join(', ')}`,
});
return;
}
// Prevent self-lockout: when editing your own role, you cannot drop the
// permissions that keep you able to manage roles and users.
if (caller.role === role) {
const protectedKeys = ['users.assign_role', 'roles.edit_permissions'];
const lockedOut = protectedKeys.filter(
(k) => caller.permissions.has(k) && !newSet.has(k)
);
if (lockedOut.length > 0) {
res.status(403).json({
error: `You cannot remove your own ${lockedOut.join(', ')}`,
});
return;
}
}
}
// Replace the role's permission rows transactionally.
await prisma.$transaction([
prisma.rolePermission.deleteMany({ where: { role } }),
prisma.rolePermission.createMany({
data: requested.map((permission) => ({ role, permission })),
}),
]);
res.json({ role, permissions: requested });
} catch (err) {
console.error('Update role permissions error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;
+3 -3
View File
@@ -1,6 +1,6 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
const router = Router();
@@ -18,7 +18,7 @@ const PUBLIC_SETTINGS = [
router.get(
'/',
requireAuth,
requireRole(['ADMIN']),
requires('settings.edit'),
async (req: Request, res: Response) => {
try {
const settings = await prisma.setting.findMany();
@@ -53,7 +53,7 @@ router.get('/public', async (_req: Request, res: Response) => {
router.patch(
'/',
requireAuth,
requireRole(['ADMIN']),
requires('settings.edit'),
async (req: Request, res: Response) => {
try {
const { key, value } = req.body;
+3 -3
View File
@@ -1,6 +1,6 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
const router = Router();
@@ -53,7 +53,7 @@ router.get(
router.get(
'/',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('submissions.review'),
async (req: Request, res: Response) => {
try {
const status = req.query.status as string | undefined;
@@ -76,7 +76,7 @@ router.get(
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('submissions.review'),
async (req: Request, res: Response) => {
try {
const { status, reviewNote } = req.body;
+71 -35
View File
@@ -1,6 +1,12 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
import { isSuperadmin } from '../services/auth';
import {
ROLE_RANK,
isAssignableRole,
type EffectiveRole,
} from '../constants/permissions';
import fs from 'fs';
import path from 'path';
@@ -41,13 +47,23 @@ const router = Router();
router.get(
'/',
requireAuth,
requireRole(['ADMIN']),
requires('users.assign_role'),
async (_req: Request, res: Response) => {
try {
const users = await prisma.user.findMany({
orderBy: { createdAt: 'desc' },
});
res.json(users);
// Flag env-sourced SuperAdmins so the UI can render them locked. Their
// effective role is reported as superadmin regardless of any stored value.
const result = users.map((u) => {
const superAdmin = isSuperadmin(u.pubkey);
return {
...u,
role: superAdmin ? 'superadmin' : (u.role ?? null),
isSuperAdmin: superAdmin,
};
});
res.json(result);
} catch (err) {
console.error('List users error:', err);
res.status(500).json({ error: 'Internal server error' });
@@ -55,53 +71,73 @@ router.get(
}
);
router.post(
'/promote',
// Assigns a role to a user, replacing the old promote/demote toggle. Enforces the
// hierarchy: SuperAdmins are never modifiable here, and a non-SuperAdmin caller
// cannot assign a role at or above their own or modify anyone at or above them.
router.put(
'/:pubkey/role',
requireAuth,
requireRole(['ADMIN']),
requires('users.assign_role'),
async (req: Request, res: Response) => {
try {
const { pubkey } = req.body;
const pubkeyRaw = req.params.pubkey;
const pubkey =
typeof pubkeyRaw === 'string' ? pubkeyRaw : Array.isArray(pubkeyRaw) ? pubkeyRaw[0] : '';
if (!pubkey) {
res.status(400).json({ error: 'pubkey is required' });
return;
}
const user = await prisma.user.upsert({
where: { pubkey },
update: { role: 'MODERATOR' },
create: { pubkey, role: 'MODERATOR' },
});
const { role } = req.body as { role?: string | null };
res.json(user);
} catch (err) {
console.error('Promote user error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/demote',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const { pubkey } = req.body;
if (!pubkey) {
res.status(400).json({ error: 'pubkey is required' });
// Normalize the requested role. Null, empty, or "guest" all mean remove
// any elevated role. SuperAdmin can never be assigned here.
let newRole: EffectiveRole;
if (role === null || role === undefined || role === '' || role === 'guest') {
newRole = 'guest';
} else if (isAssignableRole(role)) {
newRole = role;
} else {
res.status(400).json({ error: 'Invalid role' });
return;
}
const user = await prisma.user.upsert({
if (isSuperadmin(pubkey)) {
res.status(403).json({ error: 'SuperAdmins cannot be modified' });
return;
}
const caller = req.access!;
const callerRank = ROLE_RANK[caller.role];
const newRank = ROLE_RANK[newRole];
const target = await prisma.user.findUnique({ where: { pubkey } });
const targetCurrent: EffectiveRole = isAssignableRole(target?.role)
? (target!.role as EffectiveRole)
: 'guest';
const targetRank = ROLE_RANK[targetCurrent];
if (!caller.isSuperAdmin) {
if (newRank >= callerRank) {
res.status(403).json({ error: 'You cannot assign a role at or above your own' });
return;
}
if (targetRank >= callerRank) {
res.status(403).json({ error: 'You cannot modify a user at or above your own role' });
return;
}
}
const storedRole = newRole === 'guest' ? null : newRole;
const updated = await prisma.user.upsert({
where: { pubkey },
update: { role: 'USER' },
create: { pubkey, role: 'USER' },
update: { role: storedRole },
create: { pubkey, role: storedRole },
});
res.json(user);
res.json({ ...updated, role: updated.role ?? null, isSuperAdmin: false });
} catch (err) {
console.error('Demote user error:', err);
console.error('Assign role error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
@@ -182,7 +218,7 @@ router.patch(
router.patch(
'/:pubkey',
requireAuth,
requireRole(['ADMIN']),
requires('nip05.assign'),
async (req: Request, res: Response) => {
try {
const pubkeyRaw = req.params.pubkey;
+97
View File
@@ -0,0 +1,97 @@
// Central permission registry. This is the single source of truth for every
// granular permission key in the dashboard. Adding a key here automatically
// surfaces it in the GET /admin/permissions endpoint and the Roles matrix UI.
// To add a new permission: add an entry to PERMISSIONS below, then (optionally)
// grant it to roles by default in DEFAULT_ROLE_PERMISSIONS and the migration.
export type PermissionKey = string;
export interface PermissionDef {
key: PermissionKey;
label: string;
group: string;
}
// Ordered list of every permission, grouped by feature area for the matrix UI.
export const PERMISSIONS: PermissionDef[] = [
{ key: 'events.create', label: 'Create events', group: 'Events' },
{ key: 'events.edit', label: 'Edit events', group: 'Events' },
{ key: 'events.delete', label: 'Delete events', group: 'Events' },
{ key: 'organizers.manage', label: 'Manage organizers', group: 'Organizers' },
{ key: 'gallery.upload', label: 'Upload media', group: 'Gallery' },
{ key: 'gallery.delete', label: 'Delete media', group: 'Gallery' },
{ key: 'blog.draft', label: 'Draft and edit posts', group: 'Blog' },
{ key: 'blog.publish', label: 'Publish posts', group: 'Blog' },
{ key: 'blog.delete', label: 'Delete posts', group: 'Blog' },
{ key: 'faq.manage', label: 'Manage FAQ', group: 'FAQ' },
{ key: 'submissions.review', label: 'Review submissions', group: 'Submissions' },
{ key: 'board.manage', label: 'Moderate the message board', group: 'Board' },
{ key: 'moderation.act', label: 'Hide content and block pubkeys', group: 'Moderation' },
{ key: 'categories.manage', label: 'Manage categories', group: 'Categories' },
{ key: 'users.assign_role', label: 'Assign user roles', group: 'Users' },
{ key: 'nip05.assign', label: 'Assign NIP-05 usernames', group: 'Users' },
{ key: 'relays.manage', label: 'Manage relays', group: 'Relays' },
{ key: 'settings.edit', label: 'Edit site settings', group: 'Settings' },
{ key: 'roles.edit_permissions', label: 'Edit roles and permissions', group: 'Roles' },
{ key: 'nostr_tools.use', label: 'Use Nostr tools', group: 'Nostr Tools' },
];
// Assignable roles, highest to lowest. SuperAdmin is env-sourced and never stored
// or assignable, so it is not part of this list. Guest is the absence of a role.
export const ASSIGNABLE_ROLES = ['admin', 'moderator', 'writer'] as const;
export type AssignableRole = (typeof ASSIGNABLE_ROLES)[number];
export type EffectiveRole = 'superadmin' | AssignableRole | 'guest';
// Hierarchy ranks used for safeguard comparisons.
export const ROLE_RANK: Record<EffectiveRole, number> = {
superadmin: 4,
admin: 3,
moderator: 2,
writer: 1,
guest: 0,
};
export const ALL_PERMISSION_KEYS: ReadonlySet<PermissionKey> = new Set(
PERMISSIONS.map((p) => p.key)
);
export function isValidPermissionKey(key: string): boolean {
return ALL_PERMISSION_KEYS.has(key);
}
export function isAssignableRole(role: unknown): role is AssignableRole {
return typeof role === 'string' && (ASSIGNABLE_ROLES as readonly string[]).includes(role);
}
// Default permission sets seeded per role. Admin gets everything except the
// roles editor, which stays SuperAdmin-only by default. Guest gets nothing and
// is therefore not represented here.
export const DEFAULT_ROLE_PERMISSIONS: Record<AssignableRole, PermissionKey[]> = {
admin: PERMISSIONS.map((p) => p.key).filter((k) => k !== 'roles.edit_permissions'),
moderator: [
'events.create',
'events.edit',
'events.delete',
'submissions.review',
'moderation.act',
'gallery.upload',
'board.manage',
'categories.manage',
'faq.manage',
],
writer: ['blog.draft', 'gallery.upload', 'events.create'],
};
+2
View File
@@ -25,6 +25,7 @@ import nip05Router from './api/nip05';
import messagesRouter from './api/messages';
import adminMessagesRouter from './api/adminMessages';
import userRelaysRouter from './api/userRelays';
import rolesRouter from './api/roles';
const app = express();
const PORT = parseInt(process.env.BACKEND_PORT || '4000', 10);
@@ -55,6 +56,7 @@ app.use('/api/calendar', calendarRouter);
app.use('/api/nip05', nip05Router);
app.use('/api/messages', messagesRouter);
app.use('/api/admin/messages', adminMessagesRouter);
app.use('/api/admin', rolesRouter);
app.use('/api/user-relays', userRelaysRouter);
app.get('/api/health', (_req, res) => {
+17 -5
View File
@@ -1,5 +1,6 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { authService, type ResolvedAccess } from '../services/auth';
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
@@ -12,6 +13,7 @@ declare global {
namespace Express {
interface Request {
user?: AuthPayload;
access?: ResolvedAccess;
}
}
}
@@ -33,16 +35,26 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
}
}
export function requireRole(roles: string[]) {
return (req: Request, res: Response, next: NextFunction): void => {
// Resolves the requester's effective access (env SuperAdmin list plus database)
// and gates the request on a single permission key. SuperAdmin bypasses every
// check. The resolved access is attached to req.access for downstream handlers.
export function requires(permission: string) {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
if (!req.user) {
res.status(401).json({ error: 'Not authenticated' });
return;
}
if (!roles.includes(req.user.role)) {
try {
const access = await authService.resolveAccess(req.user.pubkey);
req.access = access;
if (access.isSuperAdmin || access.permissions.has(permission)) {
next();
return;
}
res.status(403).json({ error: 'Insufficient permissions' });
return;
} catch (err) {
console.error('Permission check error:', err);
res.status(500).json({ error: 'Internal server error' });
}
next();
};
}
+63 -20
View File
@@ -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)),
};
},
};