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:
@@ -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');
|
||||
@@ -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
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }[] };
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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'],
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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