Compare commits

..
2 Commits
Author SHA1 Message Date
bbeandCursor a6a2b113ee feat: add scoped API keys for programmatic site access
Introduce ApiKey model, CRUD endpoints, and admin UI so agents can
authenticate with permission-scoped keys. Normalize pubkeys to hex on login,
dedupe legacy npub/hex user rows, and ignore .cursor in git.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 09:29:30 +02:00
bbeandCursor 70e3e0633d 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>
2026-06-23 08:46:56 +02:00
45 changed files with 2338 additions and 465 deletions
+5 -2
View File
@@ -1,5 +1,8 @@
# Admin pubkeys (comma-separated hex pubkeys)
ADMIN_PUBKEYS=npub1examplepubkey1,npub1examplepubkey2
# SuperAdmin pubkeys (comma-separated hex or npub). SuperAdmins always have every
# permission, are env-only, and can never be edited or removed through the UI.
# SUPERADMIN_PUBKEYS replaces the older ADMIN_PUBKEYS. If SUPERADMIN_PUBKEYS is
# unset, the app falls back to ADMIN_PUBKEYS so existing deployments keep working.
SUPERADMIN_PUBKEYS=npub1examplepubkey1,npub1examplepubkey2
# Nostr relays (comma-separated)
RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band
+1
View File
@@ -21,6 +21,7 @@ Thumbs.db
# IDE
.idea/
.vscode/
.cursor/
*.swp
*.swo
+41
View File
@@ -0,0 +1,41 @@
# Changelog
## Unreleased
### Roles and permissions system
Replaced the single moderator promote/demote toggle with a full role hierarchy
and a runtime-editable, granular permission system.
- **Role hierarchy**: SuperAdmin (env-only) > Admin > Moderator > Writer > Guest
(absence of a role). SuperAdmin always holds every permission and can never be
edited, demoted, or removed through the UI.
- **New env var `SUPERADMIN_PUBKEYS`** (comma-separated hex or npub) replaces the
old env-admin concept. Falls back to `ADMIN_PUBKEYS` when unset so existing
deployments keep working.
- **Permission registry**: a central list of granular keys in
`backend/src/constants/permissions.ts` is the single source of truth. Adding a
key there surfaces it automatically in the API and the Roles matrix page.
- **Authorization**: a single `requires(permission)` guard replaces the old
role-name middleware and is applied to every protected endpoint. Roles and
permissions are resolved live per request, so a stale JWT no longer drives
access. SuperAdmin bypasses all checks.
- **Data model**: `User.role` is now nullable (null means Guest) and uses the
lowercase enum `admin`, `moderator`, `writer`. A new `RolePermission` table
maps roles to permission keys. A migration backfills legacy uppercase roles and
seeds default permission sets; it is idempotent and safe on a live database.
- **New endpoints**: `GET /api/auth/me`, `GET /api/admin/permissions`,
`GET /api/admin/roles`, `PUT /api/admin/roles/:role/permissions`, and
`PUT /api/users/:pubkey/role` (replacing `POST /api/users/promote` and
`POST /api/users/demote`).
- **Dashboard**: User Management now uses a per-user role selector with hierarchy
enforcement and a locked SuperAdmin badge. A new SuperAdmin-only Roles page
renders a permission-by-role matrix. Nav items are gated on resolved
permissions. The NIP-05 reserved-username assignment is unchanged.
#### Migration notes
After pulling, from `backend` run `npm run migrate:deploy` (or, for databases
created with `db push`, `npm run db:baseline-and-migrate`). Set
`SUPERADMIN_PUBKEYS` in your `.env`. Existing moderators become `moderator`,
existing database admins become `admin`, and prior env admins become SuperAdmin.
+44 -5
View File
@@ -31,7 +31,7 @@ cp .env.example backend/.env
cp .env.example frontend/.env.local
```
Edit `backend/.env` with your admin pubkeys and a secure JWT secret.
Edit `backend/.env` with your SuperAdmin pubkeys (`SUPERADMIN_PUBKEYS`) and a secure JWT secret.
### 3. Set up database
@@ -91,15 +91,54 @@ npm run dev
| PATCH | /api/meetups/:id | Update meetup |
| POST | /api/moderation/hide | Hide content |
| POST | /api/moderation/block | Block pubkey |
| GET | /api/auth/me | Current user's effective role and permissions |
| GET | /api/users | List users |
| POST | /api/users/promote | Promote user |
| PUT | /api/users/:pubkey/role | Assign a role to a user |
| GET | /api/admin/permissions | Permission registry (keys, labels, groups) |
| GET | /api/admin/roles | Each role and its permission set |
| PUT | /api/admin/roles/:role/permissions | Update a role's permissions |
| GET | /api/categories | List categories |
| POST | /api/categories | Create category |
## Roles
## Roles and permissions
- **Admin**: Full access. Defined by pubkeys in `.env`
- **Moderator**: Content moderation. Assigned by admins via dashboard.
Access is controlled by an ordered role hierarchy backed by a runtime-editable,
granular permission system. From highest to lowest:
1. **SuperAdmin** sourced only from the `SUPERADMIN_PUBKEYS` env var. Always has
every permission. Cannot be created, edited, demoted, or removed through the
UI. This replaces the old "admin set in env" concept. If `SUPERADMIN_PUBKEYS`
is unset, the app falls back to the legacy `ADMIN_PUBKEYS` variable.
2. **Admin** highest role assignable through the dashboard.
3. **Moderator**
4. **Writer**
5. **Guest** the fallback for any logged-in pubkey with no assigned role. Guest is
not a stored record, it is simply the absence of a role.
Each assignable role (Admin, Moderator, Writer) maps to a set of permission keys
stored in the `RolePermission` table. SuperAdmins manage these from the dashboard
**Roles** page, which renders a permission-by-role matrix. The dashboard nav and
actions are gated on the current user's resolved permissions, not on role names.
### Safeguards
- SuperAdmin is never assignable through the UI or API. Only the env var grants it.
- A user cannot assign a role at or above their own, or modify a SuperAdmin.
- A user cannot grant a role a permission they do not themselves hold.
- A user cannot remove their own `users.assign_role` or `roles.edit_permissions`.
SuperAdmin (env-sourced) is always the recovery path.
- Unknown role values and permission keys are rejected by the server.
### Adding a new permission key
1. Add an entry to `PERMISSIONS` in
[backend/src/constants/permissions.ts](backend/src/constants/permissions.ts)
with a `key`, `label`, and `group`.
2. The key automatically appears in `GET /admin/permissions` and the Roles matrix
page, so no UI changes are needed.
3. Apply it to the relevant endpoints with the `requires('<key>')` guard.
4. Optionally grant it to roles by default in `DEFAULT_ROLE_PERMISSIONS` (used by
the seed) and in the migration so fresh and existing databases get it.
## License
@@ -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');
@@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "ApiKey" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"prefix" TEXT NOT NULL,
"keyHash" TEXT NOT NULL,
"permissions" TEXT NOT NULL,
"createdByPubkey" TEXT NOT NULL,
"lastUsedAt" DATETIME,
"revokedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE UNIQUE INDEX "ApiKey_keyHash_key" ON "ApiKey"("keyHash");
+27 -1
View File
@@ -10,13 +10,39 @@ 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
}
// Scoped API key for programmatic access. The raw key is shown to the creator
// only once; only its SHA-256 hash is stored. `permissions` is a JSON array of
// permission keys that gate what the key may do.
model ApiKey {
id String @id @default(uuid())
name String
prefix String // leading characters of the raw key, for display
keyHash String @unique // SHA-256 hex of the full key
permissions String // JSON array of permission keys
createdByPubkey String
lastUsedAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
}
// 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 {
+150
View File
@@ -0,0 +1,150 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requires } from '../middleware/auth';
import { generateApiKey } from '../services/apiKeys';
import { isValidPermissionKey } from '../constants/permissions';
const router = Router();
function serialize(key: {
id: string;
name: string;
prefix: string;
permissions: string;
createdByPubkey: string;
lastUsedAt: Date | null;
revokedAt: Date | null;
createdAt: Date;
}) {
let permissions: string[] = [];
try {
const parsed = JSON.parse(key.permissions);
if (Array.isArray(parsed)) permissions = parsed.filter((p): p is string => typeof p === 'string');
} catch {
permissions = [];
}
return {
id: key.id,
name: key.name,
prefix: key.prefix,
permissions,
createdByPubkey: key.createdByPubkey,
lastUsedAt: key.lastUsedAt,
revokedAt: key.revokedAt,
createdAt: key.createdAt,
};
}
router.get(
'/',
requireAuth,
requires('api_keys.manage'),
async (_req: Request, res: Response) => {
try {
const keys = await prisma.apiKey.findMany({ orderBy: { createdAt: 'desc' } });
res.json(keys.map(serialize));
} catch (err) {
console.error('List API keys error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/',
requireAuth,
requires('api_keys.manage'),
async (req: Request, res: Response) => {
try {
const { name, permissions } = req.body as { name?: string; permissions?: unknown };
const trimmedName = typeof name === 'string' ? name.trim() : '';
if (!trimmedName) {
res.status(400).json({ error: 'name is required' });
return;
}
if (trimmedName.length > 100) {
res.status(400).json({ error: 'name must be 100 characters or fewer' });
return;
}
if (!Array.isArray(permissions) || permissions.length === 0) {
res.status(400).json({ error: 'At least one permission is required' });
return;
}
const requested = [...new Set(permissions.filter((p): p is string => typeof p === 'string'))];
const invalid = requested.filter((p) => !isValidPermissionKey(p));
if (invalid.length > 0) {
res.status(400).json({ error: `Unknown permissions: ${invalid.join(', ')}` });
return;
}
// A key can never grant more than its creator holds. SuperAdmins hold all.
const caller = req.access!;
if (!caller.isSuperAdmin) {
const exceeded = requested.filter((p) => !caller.permissions.has(p));
if (exceeded.length > 0) {
res
.status(403)
.json({ error: `You cannot grant permissions you do not hold: ${exceeded.join(', ')}` });
return;
}
}
const { rawKey, prefix, keyHash } = generateApiKey();
const created = await prisma.apiKey.create({
data: {
name: trimmedName,
prefix,
keyHash,
permissions: JSON.stringify(requested),
createdByPubkey: req.user!.pubkey,
},
});
// The raw key is returned exactly once and never stored in plaintext.
res.status(201).json({ ...serialize(created), key: rawKey });
} catch (err) {
console.error('Create API key error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.delete(
'/:id',
requireAuth,
requires('api_keys.manage'),
async (req: Request, res: Response) => {
try {
const idRaw = req.params.id;
const id = typeof idRaw === 'string' ? idRaw : Array.isArray(idRaw) ? idRaw[0] : '';
if (!id) {
res.status(400).json({ error: 'id is required' });
return;
}
const existing = await prisma.apiKey.findUnique({ where: { id } });
if (!existing) {
res.status(404).json({ error: 'API key not found' });
return;
}
if (existing.revokedAt) {
res.json(serialize(existing));
return;
}
const revoked = await prisma.apiKey.update({
where: { id },
data: { revokedAt: new Date() },
});
res.json(serialize(revoked));
} catch (err) {
console.error('Revoke API key error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;
+44 -7
View File
@@ -1,6 +1,8 @@
import { Router, Request, Response } from 'express';
import { authService } from '../services/auth';
import { prisma } from '../db/prisma';
import { requireAuth } from '../middleware/auth';
import { normalizePubkey } from '../services/pubkey';
const router = Router();
@@ -34,20 +36,55 @@ 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.
// Store the pubkey as hex so the same identity is never duplicated as npub.
const hexPubkey = normalizePubkey(pubkey);
const dbUser = await prisma.user.upsert({
where: { pubkey },
update: { role },
create: { pubkey, role },
where: { pubkey: hexPubkey },
update: {},
create: { pubkey: hexPubkey },
});
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;
+140 -30
View File
@@ -1,6 +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';
import { isSuperadmin } from '../services/auth';
import { toHexPubkey, normalizePubkey } from '../services/pubkey';
import {
ROLE_RANK,
isAssignableRole,
type EffectiveRole,
} from '../constants/permissions';
import fs from 'fs';
import path from 'path';
@@ -36,18 +43,61 @@ function validateUsername(
return null;
}
function pickHigherRole(a: string | null, b: string | null): string | null {
const rankOf = (r: string | null): number =>
r && isAssignableRole(r) ? ROLE_RANK[r as EffectiveRole] : 0;
return rankOf(a) >= rankOf(b) ? a : b;
}
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);
// The same person can have been stored both as hex and as npub (no
// normalization existed historically). Collapse those into a single
// identity keyed by hex so the list never shows a duplicate row.
const byHex = new Map<string, (typeof users)[number]>();
for (const u of users) {
const hex = toHexPubkey(u.pubkey) ?? u.pubkey;
const existing = byHex.get(hex);
if (!existing) {
byHex.set(hex, { ...u, pubkey: hex });
continue;
}
// Merge: prefer a stored role over none, the higher-ranked role on
// conflict, and the first available username. Keep the earliest join.
const mergedRole = pickHigherRole(existing.role, u.role);
const mergedUsername = existing.username ?? u.username;
const earliest = existing.createdAt <= u.createdAt ? existing : u;
byHex.set(hex, {
...existing,
role: mergedRole,
username: mergedUsername,
displayName: existing.displayName ?? u.displayName,
createdAt: earliest.createdAt,
pubkey: hex,
});
}
// 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 = [...byHex.values()].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 +105,109 @@ router.get(
}
);
// Creates a user row from an npub or hex pubkey so admins can pre-register
// people before they have logged in. The pubkey is normalized to hex.
router.post(
'/promote',
'/',
requireAuth,
requireRole(['ADMIN']),
requires('users.assign_role'),
async (req: Request, res: Response) => {
try {
const { pubkey } = req.body;
if (!pubkey) {
const { pubkey: rawPubkey } = req.body as { pubkey?: string };
if (!rawPubkey || typeof rawPubkey !== 'string') {
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 hex = toHexPubkey(rawPubkey);
if (!hex) {
res.status(400).json({ error: 'Invalid pubkey. Provide an npub or 64-char hex key.' });
return;
}
res.json(user);
const existing = await prisma.user.findUnique({ where: { pubkey: hex } });
if (existing) {
res.status(409).json({ error: 'User already exists' });
return;
}
const user = await prisma.user.create({ data: { pubkey: hex } });
res.status(201).json({ ...user, role: user.role ?? null, isSuperAdmin: isSuperadmin(hex) });
} catch (err) {
console.error('Promote user error:', err);
console.error('Create user error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/demote',
// 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;
if (!pubkey) {
const pubkeyRaw = req.params.pubkey;
const pubkeyParam =
typeof pubkeyRaw === 'string' ? pubkeyRaw : Array.isArray(pubkeyRaw) ? pubkeyRaw[0] : '';
if (!pubkeyParam) {
res.status(400).json({ error: 'pubkey is required' });
return;
}
const pubkey = normalizePubkey(pubkeyParam);
const user = await prisma.user.upsert({
const { role } = req.body as { role?: string | null };
// 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;
}
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,16 +288,17 @@ router.patch(
router.patch(
'/:pubkey',
requireAuth,
requireRole(['ADMIN']),
requires('nip05.assign'),
async (req: Request, res: Response) => {
try {
const pubkeyRaw = req.params.pubkey;
const pubkey =
const pubkeyParam =
typeof pubkeyRaw === 'string' ? pubkeyRaw : Array.isArray(pubkeyRaw) ? pubkeyRaw[0] : '';
if (!pubkey) {
if (!pubkeyParam) {
res.status(400).json({ error: 'pubkey is required' });
return;
}
const hex = normalizePubkey(pubkeyParam);
const { username } = req.body;
const normalized = (username as string || '').trim().toLowerCase();
@@ -202,7 +309,10 @@ router.patch(
return;
}
const target = await prisma.user.findUnique({ where: { pubkey } });
// Tolerate legacy rows still stored as npub by matching either form.
const target = await prisma.user.findFirst({
where: { OR: [{ pubkey: hex }, { pubkey: pubkeyParam }] },
});
if (!target) {
res.status(404).json({ error: 'User not found' });
return;
@@ -211,7 +321,7 @@ router.patch(
const existing = await prisma.user.findFirst({
where: {
username: { equals: normalized },
NOT: { pubkey },
NOT: { pubkey: target.pubkey },
},
});
@@ -221,7 +331,7 @@ router.patch(
}
const user = await prisma.user.update({
where: { pubkey },
where: { pubkey: target.pubkey },
data: { username: normalized },
});
+101
View File
@@ -0,0 +1,101 @@
// 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: 'api_keys.manage', label: 'Create and manage API keys', group: 'API Keys' },
{ 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 and API key management, which stay 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' && k !== 'api_keys.manage'
),
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'],
};
+4
View File
@@ -25,6 +25,8 @@ 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';
import apiKeysRouter from './api/apiKeys';
const app = express();
const PORT = parseInt(process.env.BACKEND_PORT || '4000', 10);
@@ -55,6 +57,8 @@ 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/api-keys', apiKeysRouter);
app.use('/api/user-relays', userRelaysRouter);
app.get('/api/health', (_req, res) => {
+41 -6
View File
@@ -1,5 +1,7 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { authService, type ResolvedAccess } from '../services/auth';
import { looksLikeApiKey, resolveApiKey } from '../services/apiKeys';
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
@@ -12,11 +14,13 @@ declare global {
namespace Express {
interface Request {
user?: AuthPayload;
access?: ResolvedAccess;
isApiKey?: boolean;
}
}
}
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
export async function requireAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing or invalid authorization header' });
@@ -24,6 +28,22 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
}
const token = header.slice(7);
// API keys authenticate programmatic clients. They carry their own scoped
// permission set rather than a role, so we pre-resolve access here.
if (looksLikeApiKey(token)) {
const access = await resolveApiKey(token);
if (!access) {
res.status(401).json({ error: 'Invalid or revoked API key' });
return;
}
req.user = { pubkey: access.pubkey, role: 'apikey' };
req.access = access;
req.isApiKey = true;
next();
return;
}
try {
const payload = jwt.verify(token, JWT_SECRET) as AuthPayload;
req.user = payload;
@@ -33,16 +53,31 @@ 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 {
// API key access is already resolved (and scoped) in requireAuth; for JWT
// callers, resolve their live role-based access here.
let access = req.access;
if (!req.isApiKey || !access) {
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();
};
}
+57
View File
@@ -0,0 +1,57 @@
import crypto from 'crypto';
import { prisma } from '../db/prisma';
import type { ResolvedAccess } from './auth';
// Raw keys are prefixed so the auth middleware can distinguish them from JWTs
// (which always start with "eyJ").
export const API_KEY_PREFIX = 'bbe_';
const DISPLAY_PREFIX_LENGTH = API_KEY_PREFIX.length + 8;
export function hashApiKey(rawKey: string): string {
return crypto.createHash('sha256').update(rawKey).digest('hex');
}
// Generates a new random key. Returns the raw key (shown once), its display
// prefix, and the hash to persist.
export function generateApiKey(): { rawKey: string; prefix: string; keyHash: string } {
const rawKey = API_KEY_PREFIX + crypto.randomBytes(32).toString('hex');
return {
rawKey,
prefix: rawKey.slice(0, DISPLAY_PREFIX_LENGTH),
keyHash: hashApiKey(rawKey),
};
}
export function looksLikeApiKey(token: string): boolean {
return token.startsWith(API_KEY_PREFIX);
}
// Resolves a raw API key into an access object scoped to the key's permissions.
// Returns null when the key is unknown or revoked. Identity is attributed to the
// pubkey that created the key, but the permissions come from the key, not the
// creator's role.
export async function resolveApiKey(rawKey: string): Promise<ResolvedAccess | null> {
const keyHash = hashApiKey(rawKey);
const key = await prisma.apiKey.findUnique({ where: { keyHash } });
if (!key || key.revokedAt) return null;
let permissions: string[] = [];
try {
const parsed = JSON.parse(key.permissions);
if (Array.isArray(parsed)) permissions = parsed.filter((p): p is string => typeof p === 'string');
} catch {
permissions = [];
}
// Best-effort usage tracking; never block the request on it.
prisma.apiKey
.update({ where: { id: key.id }, data: { lastUsedAt: new Date() } })
.catch(() => undefined);
return {
pubkey: key.createdByPubkey,
role: 'guest',
isSuperAdmin: false,
permissions: new Set(permissions),
};
}
+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)),
};
},
};
+24
View File
@@ -0,0 +1,24 @@
import { nip19 } from 'nostr-tools';
// Relays and the rest of the app key identities by lowercase hex pubkeys.
// Pubkeys may arrive as npub/nprofile, so normalize them to hex. Returns null
// when the input cannot be interpreted as a pubkey.
export function toHexPubkey(pubkey: string | null | undefined): string | null {
if (!pubkey) return null;
const trimmed = pubkey.trim();
if (/^[0-9a-f]{64}$/i.test(trimmed)) return trimmed.toLowerCase();
try {
const decoded = nip19.decode(trimmed);
if (decoded.type === 'npub') return decoded.data as string;
if (decoded.type === 'nprofile') return (decoded.data as { pubkey: string }).pubkey;
} catch {
// fall through
}
return null;
}
// Normalizes to hex when possible, otherwise returns the trimmed original so we
// never silently drop an identity we cannot decode.
export function normalizePubkey(pubkey: string): string {
return toHexPubkey(pubkey) ?? pubkey.trim();
}
+305
View File
@@ -0,0 +1,305 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { formatDate } from "@/lib/utils";
import { Copy, Check, KeyRound, Plus, Trash2, ShieldAlert } from "lucide-react";
interface PermissionDef {
key: string;
label: string;
group: string;
}
interface ApiKey {
id: string;
name: string;
prefix: string;
permissions: string[];
createdByPubkey: string;
lastUsedAt: string | null;
revokedAt: string | null;
createdAt: string;
}
export default function ApiKeysPage() {
const { can } = useAuth();
const allowed = can("api_keys.manage");
const [permissions, setPermissions] = useState<PermissionDef[]>([]);
const [keys, setKeys] = useState<ApiKey[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [name, setName] = useState("");
const [selected, setSelected] = useState<Record<string, boolean>>({});
const [creating, setCreating] = useState(false);
const [revealedKey, setRevealedKey] = useState<{ name: string; key: string } | null>(null);
const [copied, setCopied] = useState(false);
const load = async () => {
setLoading(true);
try {
const [registry, list] = await Promise.all([
api.getPermissionRegistry(),
api.getApiKeys(),
]);
setPermissions(registry.permissions);
setKeys(list);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (allowed) load();
else setLoading(false);
}, [allowed]);
const groups = useMemo(() => {
const order: string[] = [];
const byGroup: Record<string, PermissionDef[]> = {};
for (const perm of permissions) {
if (!byGroup[perm.group]) {
byGroup[perm.group] = [];
order.push(perm.group);
}
byGroup[perm.group].push(perm);
}
return order.map((group) => ({ group, perms: byGroup[group] }));
}, [permissions]);
const selectedKeys = useMemo(
() => Object.entries(selected).filter(([, v]) => v).map(([k]) => k),
[selected]
);
const toggle = (key: string) => {
setSelected((prev) => ({ ...prev, [key]: !prev[key] }));
};
const handleCreate = async () => {
if (!name.trim() || selectedKeys.length === 0) return;
setCreating(true);
setError("");
try {
const created = await api.createApiKey({ name: name.trim(), permissions: selectedKeys });
setRevealedKey({ name: created.name, key: created.key });
setCopied(false);
setName("");
setSelected({});
await load();
} catch (err: any) {
setError(err.message);
} finally {
setCreating(false);
}
};
const handleRevoke = async (id: string) => {
setError("");
try {
await api.revokeApiKey(id);
await load();
} catch (err: any) {
setError(err.message);
}
};
const handleCopy = async () => {
if (!revealedKey) return;
try {
await navigator.clipboard.writeText(revealedKey.key);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
// ignore
}
};
if (!allowed) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">
You do not have permission to manage API keys.
</div>
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading API keys...</div>
</div>
);
}
const labelFor = (key: string) => permissions.find((p) => p.key === key)?.label ?? key;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-on-surface">API Keys</h1>
<p className="text-on-surface/60 text-sm mt-1">
Create scoped API keys for programmatic access. A key is shown in full
only once, right after you create it, so copy it immediately.
</p>
</div>
{error && <p className="text-error text-sm">{error}</p>}
{revealedKey && (
<div className="bg-primary-container/10 border border-primary/30 rounded-xl p-6 space-y-3">
<div className="flex items-center gap-2 text-primary font-semibold">
<ShieldAlert size={18} />
Copy your new key now it won&apos;t be shown again
</div>
<p className="text-on-surface/70 text-sm">
Key for <span className="font-semibold">{revealedKey.name}</span>:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 font-mono text-sm break-all">
{revealedKey.key}
</code>
<button
onClick={handleCopy}
className="flex items-center gap-2 px-4 py-3 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity whitespace-nowrap"
>
{copied ? <Check size={16} /> : <Copy size={16} />}
{copied ? "Copied" : "Copy"}
</button>
</div>
<button
onClick={() => setRevealedKey(null)}
className="text-on-surface/50 text-sm hover:text-on-surface transition-colors"
>
I&apos;ve saved it, dismiss
</button>
</div>
)}
{/* Create form */}
<div className="bg-surface-container-low rounded-xl p-6 space-y-5">
<h2 className="text-sm font-semibold text-on-surface/70">Create API Key</h2>
<div>
<label className="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2">
Name
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Blog publishing bot"
maxLength={100}
className="w-full bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
</div>
<div>
<p className="text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-3">
Permissions
</p>
<div className="space-y-4">
{groups.map(({ group, perms }) => (
<div key={group}>
<p className="text-xs font-bold uppercase tracking-wide text-on-surface/40 mb-2">
{group}
</p>
<div className="grid sm:grid-cols-2 gap-2">
{perms.map((perm) => (
<label
key={perm.key}
className="flex items-start gap-2 cursor-pointer text-sm text-on-surface"
>
<input
type="checkbox"
checked={!!selected[perm.key]}
onChange={() => toggle(perm.key)}
className="mt-0.5 h-4 w-4 accent-primary cursor-pointer"
/>
<span>
{perm.label}
<span className="block text-on-surface/40 text-xs font-mono">
{perm.key}
</span>
</span>
</label>
))}
</div>
</div>
))}
</div>
</div>
<button
onClick={handleCreate}
disabled={!name.trim() || selectedKeys.length === 0 || creating}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
>
<Plus size={16} />
{creating ? "Creating..." : "Create API Key"}
</button>
</div>
{/* Existing keys */}
<div className="space-y-3">
<h2 className="text-sm font-semibold text-on-surface/70">Existing Keys</h2>
{keys.length === 0 ? (
<div className="bg-surface-container-low rounded-xl p-8 text-center">
<KeyRound size={32} className="text-on-surface-variant/30 mx-auto mb-3" />
<p className="text-on-surface-variant/60 text-sm">No API keys yet.</p>
</div>
) : (
keys.map((key) => (
<div
key={key.id}
className={`bg-surface-container-low rounded-xl p-6 ${key.revokedAt ? "opacity-60" : ""}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-on-surface">{key.name}</h3>
{key.revokedAt && (
<span className="text-xs font-bold text-error bg-error/10 px-2 py-0.5 rounded-full">
Revoked
</span>
)}
</div>
<p className="font-mono text-sm text-on-surface/60 mt-1">{key.prefix}</p>
<p className="text-on-surface/40 text-xs mt-1">
Created {formatDate(key.createdAt)}
{key.lastUsedAt ? ` · Last used ${formatDate(key.lastUsedAt)}` : " · Never used"}
</p>
<div className="flex flex-wrap gap-1.5 mt-3">
{key.permissions.map((p) => (
<span
key={p}
className="text-xs font-semibold text-primary bg-primary/10 px-2 py-0.5 rounded-full"
title={p}
>
{labelFor(p)}
</span>
))}
</div>
</div>
{!key.revokedAt && (
<button
onClick={() => handleRevoke(key.id)}
className="text-on-surface-variant/50 hover:text-error transition-colors p-2 rounded-lg hover:bg-error/10 shrink-0"
title="Revoke key"
>
<Trash2 size={16} />
</button>
)}
</div>
</div>
))
)}
</div>
</div>
);
}
+8 -4
View File
@@ -6,21 +6,25 @@ import { useAuth } from "@/hooks/useAuth";
import { AdminSidebar } from "@/components/admin/AdminSidebar";
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const { user, loading, isSuperAdmin, permissions } = useAuth();
const router = useRouter();
// Access to the dashboard requires at least one elevated permission, or being
// a SuperAdmin. Guests (no permissions) are sent to their own dashboard.
const hasDashboardAccess = isSuperAdmin || permissions.length > 0;
useEffect(() => {
if (loading) return;
if (!user) {
router.push("/login");
return;
}
if (user.role !== "ADMIN" && user.role !== "MODERATOR") {
if (!hasDashboardAccess) {
router.push("/dashboard");
}
}, [user, loading, router]);
}, [user, loading, hasDashboardAccess, router]);
if (loading || !user || (user.role !== "ADMIN" && user.role !== "MODERATOR")) {
if (loading || !user || !hasDashboardAccess) {
return null;
}
+5 -1
View File
@@ -86,7 +86,11 @@ export default function OverviewPage() {
<StatCard icon={Calendar} label="Total Meetups" value={meetups.length} />
<StatCard icon={FileText} label="Blog Posts" value={posts.length} />
<StatCard icon={Tag} label="Categories" value={categories.length} />
<StatCard icon={User} label="Your Role" value={user.role} />
<StatCard
icon={User}
label="Your Role"
value={user.isSuperAdmin ? "SuperAdmin" : user.role}
/>
</div>
{upcomingMeetup && (
+3 -3
View File
@@ -6,7 +6,7 @@ import { useAuth } from "@/hooks/useAuth";
import { LogIn } from "lucide-react";
export default function AdminPage() {
const { user, loading, login } = useAuth();
const { user, loading, login, isSuperAdmin, permissions } = useAuth();
const router = useRouter();
const [error, setError] = useState("");
const [loggingIn, setLoggingIn] = useState(false);
@@ -14,12 +14,12 @@ export default function AdminPage() {
useEffect(() => {
if (loading) return;
if (!user) return;
if (user.role === "ADMIN" || user.role === "MODERATOR") {
if (isSuperAdmin || permissions.length > 0) {
router.push("/admin/overview");
} else {
router.push("/dashboard");
}
}, [user, loading, router]);
}, [user, loading, isSuperAdmin, permissions, router]);
const handleLogin = async () => {
setError("");
+234
View File
@@ -0,0 +1,234 @@
"use client";
import { Fragment, useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { Check, Lock, Save } from "lucide-react";
interface PermissionDef {
key: string;
label: string;
group: string;
}
type Matrix = Record<string, Record<string, boolean>>;
const ROLE_LABELS: Record<string, string> = {
admin: "Admin",
moderator: "Moderator",
writer: "Writer",
};
function buildMatrix(
roles: string[],
perms: PermissionDef[],
granted: Record<string, string[]>
): Matrix {
const matrix: Matrix = {};
for (const role of roles) {
matrix[role] = {};
const set = new Set(granted[role] ?? []);
for (const perm of perms) {
matrix[role][perm.key] = set.has(perm.key);
}
}
return matrix;
}
export default function RolesPage() {
const { can } = useAuth();
const allowed = can("roles.edit_permissions");
const [permissions, setPermissions] = useState<PermissionDef[]>([]);
const [roles, setRoles] = useState<string[]>([]);
const [matrix, setMatrix] = useState<Matrix>({});
const [original, setOriginal] = useState<Matrix>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
const [notice, setNotice] = useState("");
const load = async () => {
setLoading(true);
try {
const [registry, current] = await Promise.all([
api.getPermissionRegistry(),
api.getRolePermissions(),
]);
const granted: Record<string, string[]> = {};
for (const r of current.roles) granted[r.role] = r.permissions;
const built = buildMatrix(registry.roles, registry.permissions, granted);
setPermissions(registry.permissions);
setRoles(registry.roles);
setMatrix(built);
setOriginal(built);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (allowed) load();
else setLoading(false);
}, [allowed]);
const groups = useMemo(() => {
const order: string[] = [];
const byGroup: Record<string, PermissionDef[]> = {};
for (const perm of permissions) {
if (!byGroup[perm.group]) {
byGroup[perm.group] = [];
order.push(perm.group);
}
byGroup[perm.group].push(perm);
}
return order.map((group) => ({ group, perms: byGroup[group] }));
}, [permissions]);
const dirtyRoles = useMemo(() => {
return roles.filter((role) =>
permissions.some((perm) => matrix[role]?.[perm.key] !== original[role]?.[perm.key])
);
}, [roles, permissions, matrix, original]);
const toggle = (role: string, key: string) => {
setNotice("");
setMatrix((prev) => ({
...prev,
[role]: { ...prev[role], [key]: !prev[role]?.[key] },
}));
};
const saveAll = async () => {
if (dirtyRoles.length === 0) return;
setSaving(true);
setError("");
setNotice("");
try {
for (const role of dirtyRoles) {
const keys = permissions.filter((p) => matrix[role]?.[p.key]).map((p) => p.key);
await api.updateRolePermissions(role, keys);
}
setOriginal(() => {
const next: Matrix = {};
for (const role of roles) next[role] = { ...matrix[role] };
return next;
});
setNotice("Permissions saved.");
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
};
if (!allowed) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">
You do not have permission to manage roles.
</div>
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading roles...</div>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-on-surface">Roles and Permissions</h1>
<p className="text-on-surface/60 text-sm mt-1">
Toggle what each role can do. SuperAdmin always has every permission and
cannot be changed.
</p>
</div>
{error && <p className="text-error text-sm">{error}</p>}
{notice && <p className="text-primary text-sm">{notice}</p>}
<div className="bg-surface-container-low rounded-xl overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="text-left">
<th className="sticky left-0 bg-surface-container-low p-4 font-semibold text-on-surface/70">
Permission
</th>
{roles.map((role) => (
<th key={role} className="p-4 text-center font-semibold text-on-surface/70">
{ROLE_LABELS[role] ?? role}
</th>
))}
<th className="p-4 text-center font-semibold text-on-surface/70">
<span className="inline-flex items-center gap-1">
<Lock size={12} />
SuperAdmin
</span>
</th>
</tr>
</thead>
<tbody>
{groups.map(({ group, perms }) => (
<Fragment key={group}>
<tr>
<td
colSpan={roles.length + 2}
className="bg-surface-container px-4 py-2 text-xs font-bold uppercase tracking-wide text-on-surface/50"
>
{group}
</td>
</tr>
{perms.map((perm) => (
<tr key={perm.key} className="border-b border-surface-container-high/40">
<td className="sticky left-0 bg-surface-container-low p-4">
<div className="text-on-surface">{perm.label}</div>
<div className="text-on-surface/40 text-xs font-mono">{perm.key}</div>
</td>
{roles.map((role) => (
<td key={role} className="p-4 text-center">
<input
type="checkbox"
checked={!!matrix[role]?.[perm.key]}
onChange={() => toggle(role, perm.key)}
className="h-4 w-4 accent-primary cursor-pointer"
aria-label={`${ROLE_LABELS[role] ?? role}: ${perm.label}`}
/>
</td>
))}
<td className="p-4 text-center text-primary/70">
<Check size={16} className="inline" aria-label="Always granted" />
</td>
</tr>
))}
</Fragment>
))}
</tbody>
</table>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
onClick={saveAll}
disabled={dirtyRoles.length === 0 || saving}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
>
<Save size={16} />
{saving ? "Saving..." : "Save changes"}
</button>
{dirtyRoles.length > 0 && !saving && (
<span className="text-on-surface/50 text-sm">
Unsaved changes to {dirtyRoles.map((r) => ROLE_LABELS[r] ?? r).join(", ")}
</span>
)}
</div>
</div>
);
}
+254 -226
View File
@@ -1,16 +1,39 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import { nip19 } from "nostr-tools";
import { api } from "@/lib/api";
import { cn, formatDate } from "@/lib/utils";
import { fetchNostrProfile, type NostrProfile } from "@/lib/nostr";
import { ShieldCheck, ShieldOff, UserPlus } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { useNostrProfile } from "@/hooks/useNostrProfile";
import { NostrAvatar } from "@/components/nostr/NostrAvatar";
import { formatDate } from "@/lib/utils";
import { Lock, Plus } from "lucide-react";
function hexToNpub(hex: string): string {
const ROLE_RANK: Record<string, number> = {
superadmin: 4,
admin: 3,
moderator: 2,
writer: 1,
guest: 0,
};
const ROLE_OPTIONS: { value: string; label: string }[] = [
{ value: "admin", label: "Admin" },
{ value: "moderator", label: "Moderator" },
{ value: "writer", label: "Writer" },
{ value: "guest", label: "Guest (no role)" },
];
function normalizeRole(role: string | null | undefined): string {
return role && ROLE_RANK[role] !== undefined ? role : "guest";
}
function hexToNpub(pubkey: string): string {
if (!pubkey) return "";
// Already an npub (some users are stored in npub form).
if (pubkey.startsWith("npub1")) return pubkey;
try {
return nip19.npubEncode(hex);
return nip19.npubEncode(pubkey);
} catch {
return "";
}
@@ -21,25 +44,173 @@ function shortenNpub(npub: string): string {
return `${npub.slice(0, 14)}...${npub.slice(-10)}`;
}
function profileInitials(profile: NostrProfile | undefined, npub: string): string {
const n = profile?.name || profile?.displayName;
if (n?.trim()) return n.trim().slice(0, 2).toUpperCase();
if (npub.length >= 8) return npub.slice(5, 7).toUpperCase();
return "?";
interface UserRowProps {
user: any;
draft: string;
onDraftChange: (pubkey: string, value: string) => void;
savingPubkey: string | null;
onSaveUsername: (pubkey: string, currentUsername: string | null | undefined) => void;
onCancelUsername: (pubkey: string, stored: string | null | undefined) => void;
hostname: string;
copiedPubkey: string | null;
onCopyNpub: (pubkey: string) => void;
callerRank: number;
isSuperAdmin: boolean;
savingRole: string | null;
onRoleChange: (pubkey: string, role: string) => void;
}
function UserRow({
user,
draft,
onDraftChange,
savingPubkey,
onSaveUsername,
onCancelUsername,
hostname,
copiedPubkey,
onCopyNpub,
callerRank,
isSuperAdmin,
savingRole,
onRoleChange,
}: UserRowProps) {
const { profile, loading: profileLoading } = useNostrProfile(user.pubkey);
const stored = user.username ?? "";
const usernameDirty = draft.trim().toLowerCase() !== stored.toLowerCase();
const fullNpub = hexToNpub(user.pubkey);
const nostrDisplay = profile?.name || profile?.displayName;
return (
<div className="bg-surface-container-low rounded-xl p-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex-1 min-w-0 flex gap-4 items-start">
<NostrAvatar pubkey={user.pubkey} size={56} fallbackText={fullNpub} />
<div className="flex-1 min-w-0 space-y-3">
<div>
<p className="text-on-surface font-semibold text-base truncate">
{profileLoading ? (
<span className="text-on-surface/40 font-normal"></span>
) : nostrDisplay ? (
nostrDisplay
) : (
<span className="text-on-surface/50 font-normal">No Nostr name</span>
)}
</p>
<button
type="button"
onClick={() => onCopyNpub(user.pubkey)}
className="mt-1 text-left font-mono text-sm text-on-surface/80 hover:text-primary transition-colors cursor-pointer break-all w-full"
title={fullNpub || "Copy npub"}
>
{copiedPubkey === user.pubkey
? "Copied!"
: fullNpub
? shortenNpub(fullNpub)
: `${user.pubkey?.slice(0, 12)}...${user.pubkey?.slice(-8)}`}
</button>
</div>
<div>
<p className="text-xs font-semibold text-on-surface/50 mb-1 uppercase tracking-wide">
NIP-05 username
</p>
<p className="text-on-surface-variant text-xs mb-2">
Reserved names from the site blocklist can be assigned here (users cannot claim them on the dashboard).
</p>
<div className="flex flex-wrap items-center gap-2">
<input
value={draft}
onChange={(e) => onDraftChange(user.pubkey, e.target.value)}
disabled={savingPubkey === user.pubkey}
placeholder="local-part"
className="bg-surface-container-highest text-on-surface rounded-lg px-3 py-2 text-sm font-mono min-w-[8rem] max-w-full flex-1 focus:outline-none focus:ring-1 focus:ring-primary/40 disabled:opacity-50"
/>
<span className="text-on-surface/50 text-sm font-mono shrink-0">
@{hostname || "…"}
</span>
<button
type="button"
onClick={() => onSaveUsername(user.pubkey, user.username)}
disabled={savingPubkey === user.pubkey || !draft.trim() || !usernameDirty}
className="px-3 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50 whitespace-nowrap"
>
{savingPubkey === user.pubkey ? "Saving…" : "Save"}
</button>
<button
type="button"
onClick={() => onCancelUsername(user.pubkey, user.username)}
disabled={savingPubkey === user.pubkey || !usernameDirty}
className="px-3 py-2 rounded-lg bg-surface-container-highest text-on-surface/70 text-sm hover:text-on-surface transition-colors disabled:opacity-50 whitespace-nowrap"
>
Cancel
</button>
</div>
{draft.trim() && (
<p className="text-on-surface-variant text-xs font-mono mt-2">
{draft.trim().toLowerCase()}@{hostname || "…"}
</p>
)}
</div>
<div className="flex items-center gap-3 flex-wrap">
{user.isSuperAdmin ? (
<span className="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-bold bg-primary-container/20 text-primary">
<Lock size={12} />
SuperAdmin
</span>
) : (
(() => {
const targetRole = normalizeRole(user.role);
const targetRank = ROLE_RANK[targetRole];
const canModify = callerRank > targetRank;
return (
<label className="flex items-center gap-2">
<span className="text-xs font-semibold text-on-surface/50 uppercase tracking-wide">
Role
</span>
<select
value={targetRole}
disabled={!canModify || savingRole === user.pubkey}
onChange={(e) => onRoleChange(user.pubkey, e.target.value)}
className="bg-surface-container-highest text-on-surface rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/40 disabled:opacity-50"
>
{ROLE_OPTIONS.map((opt) => (
<option
key={opt.value}
value={opt.value}
disabled={!isSuperAdmin && ROLE_RANK[opt.value] >= callerRank}
>
{opt.label}
</option>
))}
</select>
</label>
);
})()
)}
{user.createdAt && (
<span className="text-on-surface/40 text-xs">
Joined {formatDate(user.createdAt)}
</span>
)}
</div>
</div>
</div>
</div>
);
}
export default function UsersPage() {
const { user: currentUser, isSuperAdmin } = useAuth();
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [promotePubkey, setPromotePubkey] = useState("");
const [promoting, setPromoting] = useState(false);
const [savingRole, setSavingRole] = useState<string | null>(null);
const [hostname, setHostname] = useState("");
const [usernameDrafts, setUsernameDrafts] = useState<Record<string, string>>({});
const [savingPubkey, setSavingPubkey] = useState<string | null>(null);
const [nostrByPubkey, setNostrByPubkey] = useState<Record<string, NostrProfile>>({});
const [nostrLoading, setNostrLoading] = useState(false);
const [copiedPubkey, setCopiedPubkey] = useState<string | null>(null);
const [newUserPubkey, setNewUserPubkey] = useState("");
const [addingUser, setAddingUser] = useState(false);
const [addUserSuccess, setAddUserSuccess] = useState("");
const loadUsers = async () => {
try {
@@ -65,32 +236,6 @@ export default function UsersPage() {
setHostname(typeof window !== "undefined" ? window.location.hostname : "");
}, []);
useEffect(() => {
if (users.length === 0) {
setNostrByPubkey({});
setNostrLoading(false);
return;
}
let cancelled = false;
setNostrLoading(true);
setNostrByPubkey({});
(async () => {
const entries = await Promise.all(
users.map(async (u: { pubkey: string }) => {
const profile = await fetchNostrProfile(u.pubkey);
return [u.pubkey, profile] as const;
})
);
if (!cancelled) {
setNostrByPubkey(Object.fromEntries(entries));
setNostrLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [users]);
const handleCopyNpub = async (pubkey: string) => {
const full = hexToNpub(pubkey);
if (!full) return;
@@ -103,39 +248,20 @@ export default function UsersPage() {
}
};
const handlePromote = async () => {
if (!promotePubkey.trim()) return;
setPromoting(true);
const callerRank = isSuperAdmin
? ROLE_RANK.superadmin
: ROLE_RANK[normalizeRole(currentUser?.role)];
const handleRoleChange = async (pubkey: string, role: string) => {
setSavingRole(pubkey);
setError("");
try {
await api.promoteUser(promotePubkey);
setPromotePubkey("");
await api.setUserRole(pubkey, role === "guest" ? null : role);
await loadUsers();
} catch (err: any) {
setError(err.message);
} finally {
setPromoting(false);
}
};
const handleDemote = async (pubkey: string) => {
if (!confirm("Demote this user to regular user?")) return;
setError("");
try {
await api.demoteUser(pubkey);
await loadUsers();
} catch (err: any) {
setError(err.message);
}
};
const handlePromoteUser = async (pubkey: string) => {
setError("");
try {
await api.promoteUser(pubkey);
await loadUsers();
} catch (err: any) {
setError(err.message);
setSavingRole(null);
}
};
@@ -159,6 +285,28 @@ export default function UsersPage() {
setUsernameDrafts((prev) => ({ ...prev, [pubkey]: stored ?? "" }));
};
const handleDraftChange = (pubkey: string, value: string) => {
setUsernameDrafts((prev) => ({ ...prev, [pubkey]: value }));
};
const handleAddUser = async () => {
const value = newUserPubkey.trim();
if (!value) return;
setAddingUser(true);
setError("");
setAddUserSuccess("");
try {
await api.createUser(value);
setNewUserPubkey("");
setAddUserSuccess("User added.");
await loadUsers();
} catch (err: any) {
setError(err.message);
} finally {
setAddingUser(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
@@ -174,177 +322,57 @@ export default function UsersPage() {
{error && <p className="text-error text-sm">{error}</p>}
<div className="bg-surface-container-low rounded-xl p-6">
<h2 className="text-sm font-semibold text-on-surface/70 mb-3">Promote User</h2>
<div className="flex gap-3">
<h2 className="text-sm font-semibold text-on-surface/70 mb-3">Add User</h2>
<p className="text-on-surface-variant text-xs mb-3">
Pre-register a user by their npub or hex pubkey so you can assign a role before they log in.
</p>
<div className="flex flex-col sm:flex-row gap-3">
<input
placeholder="Pubkey (hex)"
value={promotePubkey}
onChange={(e) => setPromotePubkey(e.target.value)}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40 flex-1"
placeholder="npub1... or hex pubkey"
value={newUserPubkey}
onChange={(e) => setNewUserPubkey(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleAddUser();
}}
disabled={addingUser}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full font-mono text-sm focus:outline-none focus:ring-1 focus:ring-primary/40 flex-1 disabled:opacity-50"
/>
<button
onClick={handlePromote}
disabled={promoting || !promotePubkey.trim()}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50 whitespace-nowrap"
onClick={handleAddUser}
disabled={!newUserPubkey.trim() || addingUser}
className="flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50 whitespace-nowrap"
>
<UserPlus size={16} />
{promoting ? "Promoting..." : "Promote"}
<Plus size={16} />
{addingUser ? "Adding…" : "Add User"}
</button>
</div>
{addUserSuccess && (
<p className="text-green-400 text-sm mt-3">{addUserSuccess}</p>
)}
</div>
<div className="space-y-3">
{users.length === 0 ? (
<p className="text-on-surface/50 text-sm">No users found.</p>
) : (
users.map((user) => {
const draft = usernameDrafts[user.pubkey] ?? "";
const stored = user.username ?? "";
const usernameDirty = draft.trim().toLowerCase() !== stored.toLowerCase();
const profile = nostrByPubkey[user.pubkey];
const fullNpub = hexToNpub(user.pubkey);
const nostrDisplay = profile?.name || profile?.displayName;
return (
<div
users.map((user) => (
<UserRow
key={user.pubkey || user.id}
className="bg-surface-container-low rounded-xl p-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div className="flex-1 min-w-0 flex gap-4 items-start">
<div className="shrink-0 w-14 h-14 rounded-full bg-surface-container-high flex items-center justify-center overflow-hidden text-on-surface">
{nostrLoading ? (
<span className="text-on-surface/40 text-xs"></span>
) : profile?.picture ? (
<Image
src={profile.picture}
alt={nostrDisplay ? `Avatar: ${nostrDisplay}` : "Nostr profile picture"}
width={56}
height={56}
className="object-cover w-full h-full"
unoptimized
/>
) : (
<span className="font-semibold text-sm" aria-hidden>
{profileInitials(profile, fullNpub)}
</span>
)}
</div>
<div className="flex-1 min-w-0 space-y-3">
<div>
<p className="text-on-surface font-semibold text-base truncate">
{nostrLoading ? (
<span className="text-on-surface/40 font-normal"></span>
) : nostrDisplay ? (
nostrDisplay
) : (
<span className="text-on-surface/50 font-normal">No Nostr name</span>
)}
</p>
<button
type="button"
onClick={() => handleCopyNpub(user.pubkey)}
className="mt-1 text-left font-mono text-sm text-on-surface/80 hover:text-primary transition-colors cursor-pointer break-all w-full"
title={fullNpub || "Copy npub"}
>
{copiedPubkey === user.pubkey
? "Copied!"
: fullNpub
? shortenNpub(fullNpub)
: `${user.pubkey?.slice(0, 12)}...${user.pubkey?.slice(-8)}`}
</button>
</div>
<div>
<p className="text-xs font-semibold text-on-surface/50 mb-1 uppercase tracking-wide">
NIP-05 username
</p>
<p className="text-on-surface-variant text-xs mb-2">
Reserved names from the site blocklist can be assigned here (users cannot claim them on the dashboard).
</p>
<div className="flex flex-wrap items-center gap-2">
<input
value={draft}
onChange={(e) =>
setUsernameDrafts((prev) => ({ ...prev, [user.pubkey]: e.target.value }))
}
disabled={savingPubkey === user.pubkey}
placeholder="local-part"
className="bg-surface-container-highest text-on-surface rounded-lg px-3 py-2 text-sm font-mono min-w-[8rem] max-w-full flex-1 focus:outline-none focus:ring-1 focus:ring-primary/40 disabled:opacity-50"
/>
<span className="text-on-surface/50 text-sm font-mono shrink-0">
@{hostname || "…"}
</span>
<button
type="button"
onClick={() => handleSaveUsername(user.pubkey, user.username)}
disabled={
savingPubkey === user.pubkey ||
!draft.trim() ||
!usernameDirty
}
className="px-3 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50 whitespace-nowrap"
>
{savingPubkey === user.pubkey ? "Saving…" : "Save"}
</button>
<button
type="button"
onClick={() => handleCancelUsername(user.pubkey, user.username)}
disabled={savingPubkey === user.pubkey || !usernameDirty}
className="px-3 py-2 rounded-lg bg-surface-container-highest text-on-surface/70 text-sm hover:text-on-surface transition-colors disabled:opacity-50 whitespace-nowrap"
>
Cancel
</button>
</div>
{draft.trim() && (
<p className="text-on-surface-variant text-xs font-mono mt-2">
{draft.trim().toLowerCase()}@{hostname || "…"}
</p>
)}
</div>
<div className="flex items-center gap-3 flex-wrap">
<span
className={cn(
"rounded-full px-3 py-1 text-xs font-bold",
user.role === "ADMIN"
? "bg-primary-container/20 text-primary"
: user.role === "MODERATOR"
? "bg-secondary-container text-on-secondary-container"
: "bg-surface-container-highest text-on-surface/50"
)}
>
{user.role}
</span>
{user.createdAt && (
<span className="text-on-surface/40 text-xs">
Joined {formatDate(user.createdAt)}
</span>
)}
</div>
</div>
</div>
{user.role !== "ADMIN" && (
<div className="flex items-center gap-2">
{user.role !== "MODERATOR" && (
<button
onClick={() => handlePromoteUser(user.pubkey)}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface-container-highest text-on-surface/70 hover:text-primary text-sm transition-colors"
>
<ShieldCheck size={14} />
Promote
</button>
)}
{user.role === "MODERATOR" && (
<button
onClick={() => handleDemote(user.pubkey)}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface-container-highest text-on-surface/70 hover:text-error text-sm transition-colors"
>
<ShieldOff size={14} />
Demote
</button>
)}
</div>
)}
</div>
);
})
user={user}
draft={usernameDrafts[user.pubkey] ?? ""}
onDraftChange={handleDraftChange}
savingPubkey={savingPubkey}
onSaveUsername={handleSaveUsername}
onCancelUsername={handleCancelUsername}
hostname={hostname}
copiedPubkey={copiedPubkey}
onCopyNpub={handleCopyNpub}
callerRank={callerRank}
isSuperAdmin={isSuperAdmin}
savingRole={savingRole}
onRoleChange={handleRoleChange}
/>
))
)}
</div>
</div>
+50 -53
View File
@@ -5,7 +5,7 @@ import Image from "next/image";
import { Send, FileText, Clock, CheckCircle, XCircle, Plus, User, Loader2, AtSign, Radio, Trash2, Download, Eye, Pencil } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { api } from "@/lib/api";
import { shortenPubkey } from "@/lib/nostr";
import { shortenPubkey, fetchEventFromRelays, fetchLongformFromRelays } from "@/lib/nostr";
import { formatDate } from "@/lib/utils";
import { Button } from "@/components/ui/Button";
@@ -61,9 +61,7 @@ export default function DashboardPage() {
const [submissions, setSubmissions] = useState<Submission[]>([]);
const [loadingSubs, setLoadingSubs] = useState(true);
const [showForm, setShowForm] = useState(false);
const [title, setTitle] = useState("");
const [eventId, setEventId] = useState("");
const [naddr, setNaddr] = useState("");
const [noteInput, setNoteInput] = useState("");
const [submitting, setSubmitting] = useState(false);
const [formError, setFormError] = useState("");
const [formSuccess, setFormSuccess] = useState("");
@@ -125,31 +123,56 @@ export default function DashboardPage() {
loadRelays();
}, [loadSubmissions, loadRelays]);
const extractTitle = (event: any): string => {
const titleTag = event?.tags?.find((t: string[]) => t[0] === "title");
return titleTag?.[1]?.trim() || "Untitled";
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError("");
setFormSuccess("");
if (!title.trim()) {
setFormError("Title is required");
return;
}
if (!eventId.trim() && !naddr.trim()) {
setFormError("Either an Event ID or naddr is required");
const input = noteInput.trim();
if (!input) {
setFormError("A Note ID or naddr is required");
return;
}
setSubmitting(true);
try {
await api.createSubmission({
title: title.trim(),
eventId: eventId.trim() || undefined,
naddr: naddr.trim() || undefined,
});
setFormSuccess("Submission sent for review!");
setTitle("");
setEventId("");
setNaddr("");
let payload: { title: string; eventId?: string; naddr?: string };
if (input.startsWith("naddr1")) {
const event = await fetchLongformFromRelays(input);
if (!event) {
throw new Error("Could not find that article on the relays");
}
payload = { title: extractTitle(event), naddr: input };
} else {
let hexId = input;
if (input.startsWith("note1")) {
try {
const { nip19 } = await import("nostr-tools");
const decoded = nip19.decode(input);
if (decoded.type !== "note") throw new Error();
hexId = decoded.data as string;
} catch {
throw new Error("Invalid note id");
}
} else if (!/^[0-9a-f]{64}$/i.test(input)) {
throw new Error("Enter a valid note id, naddr, or hex event id");
}
const event = await fetchEventFromRelays(hexId);
if (!event) {
throw new Error("Could not find that note on the relays");
}
payload = { title: extractTitle(event), eventId: hexId };
}
await api.createSubmission(payload);
setFormSuccess(`Submission "${payload.title}" sent for review!`);
setNoteInput("");
setShowForm(false);
await loadSubmissions();
} catch (err: any) {
@@ -381,46 +404,20 @@ export default function DashboardPage() {
className="bg-surface-container-low rounded-xl p-6 mb-8 space-y-4"
>
<p className="text-on-surface-variant text-sm mb-2">
Submit a Nostr longform post for moderator review. Provide the
event ID or naddr of the article you&apos;d like published on the
blog.
Submit a Nostr longform post for moderator review. Paste the
note ID or naddr of the article you&apos;d like published on the
blog. The title is pulled automatically from the note.
</p>
<div>
<label className="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2">
Title
Note ID or naddr
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="My Bitcoin Article"
className="w-full bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2">
Nostr Event ID
</label>
<input
type="text"
value={eventId}
onChange={(e) => setEventId(e.target.value)}
placeholder="note1... or hex event id"
className="w-full bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 font-mono text-sm placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2">
Or naddr
</label>
<input
type="text"
value={naddr}
onChange={(e) => setNaddr(e.target.value)}
placeholder="naddr1..."
value={noteInput}
onChange={(e) => setNoteInput(e.target.value)}
placeholder="naddr1... or note1... or hex event id"
className="w-full bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 font-mono text-sm placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
</div>
@@ -438,7 +435,7 @@ export default function DashboardPage() {
>
<span className="flex items-center gap-2">
<Send size={16} />
{submitting ? "Submitting..." : "Submit for Review"}
{submitting ? "Fetching & submitting..." : "Submit for Review"}
</span>
</Button>
<Button
+6 -6
View File
@@ -38,11 +38,11 @@ export default function LoginPage() {
const [bunkerInput, setBunkerInput] = useState("");
useEffect(() => {
if (!loading && user) redirectByRole(user.role);
if (!loading && user) redirectFor(user);
}, [user, loading]);
function redirectByRole(role: string) {
if (role === "ADMIN" || role === "MODERATOR") {
function redirectFor(u: { isSuperAdmin?: boolean; permissions?: string[] }) {
if (u.isSuperAdmin || (u.permissions?.length ?? 0) > 0) {
router.push("/admin/overview");
} else {
router.push("/dashboard");
@@ -78,7 +78,7 @@ export default function LoginPage() {
setLoggingIn(true);
const loggedInUser = await loginWithConnectedSigner(signer);
await signer.close().catch(() => {});
redirectByRole(loggedInUser.role);
redirectFor(loggedInUser);
} catch (err: any) {
if (controller.signal.aborted) return;
setError(err.message || "Connection failed");
@@ -104,7 +104,7 @@ export default function LoginPage() {
setLoggingIn(true);
try {
const loggedInUser = await login();
redirectByRole(loggedInUser.role);
redirectFor(loggedInUser);
} catch (err: any) {
setError(err.message || "Login failed");
} finally {
@@ -118,7 +118,7 @@ export default function LoginPage() {
setLoggingIn(true);
try {
const loggedInUser = await loginWithBunker(bunkerInput.trim());
redirectByRole(loggedInUser.role);
redirectFor(loggedInUser);
} catch (err: any) {
setError(err.message || "Connection failed");
} finally {
+39 -25
View File
@@ -21,36 +21,47 @@ import {
HelpCircle,
MessageSquare,
Building2,
KeyRound,
Key,
} from "lucide-react";
const navItems = [
{ href: "/admin/overview", label: "Overview", icon: LayoutDashboard, adminOnly: false },
{ href: "/admin/events", label: "Events", icon: Calendar, adminOnly: false },
{ href: "/admin/organizers", label: "Organizers", icon: Building2, adminOnly: false },
{ href: "/admin/gallery", label: "Gallery", icon: ImageIcon, adminOnly: false },
{ href: "/admin/blog", label: "Blog", icon: FileText, adminOnly: false },
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle, adminOnly: false },
{ href: "/admin/submissions", label: "Submissions", icon: Inbox, adminOnly: false },
{ href: "/admin/messages", label: "Board", icon: MessageSquare, adminOnly: false },
{ href: "/admin/moderation", label: "Moderation", icon: Shield, adminOnly: false },
{ href: "/admin/categories", label: "Categories", icon: Tag, adminOnly: false },
{ href: "/admin/users", label: "Users", icon: Users, adminOnly: true },
{ href: "/admin/relays", label: "Relays", icon: Radio, adminOnly: true },
{ href: "/admin/settings", label: "Settings", icon: Settings, adminOnly: true },
{ href: "/admin/nostr", label: "Nostr Tools", icon: Wrench, adminOnly: true },
// Each item is shown when the user holds any of its permissions. Items with no
// permissions are always visible. SuperAdmin sees everything via can().
const navItems: {
href: string;
label: string;
icon: typeof LayoutDashboard;
permissions?: string[];
}[] = [
{ href: "/admin/overview", label: "Overview", icon: LayoutDashboard },
{ href: "/admin/events", label: "Events", icon: Calendar, permissions: ["events.create", "events.edit", "events.delete"] },
{ href: "/admin/organizers", label: "Organizers", icon: Building2, permissions: ["organizers.manage"] },
{ href: "/admin/gallery", label: "Gallery", icon: ImageIcon, permissions: ["gallery.upload", "gallery.delete"] },
{ href: "/admin/blog", label: "Blog", icon: FileText, permissions: ["blog.draft", "blog.publish", "blog.delete"] },
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle, permissions: ["faq.manage"] },
{ href: "/admin/submissions", label: "Submissions", icon: Inbox, permissions: ["submissions.review"] },
{ href: "/admin/messages", label: "Board", icon: MessageSquare, permissions: ["board.manage"] },
{ href: "/admin/moderation", label: "Moderation", icon: Shield, permissions: ["moderation.act"] },
{ href: "/admin/categories", label: "Categories", icon: Tag, permissions: ["categories.manage"] },
{ href: "/admin/users", label: "Users", icon: Users, permissions: ["users.assign_role", "nip05.assign"] },
{ href: "/admin/roles", label: "Roles", icon: KeyRound, permissions: ["roles.edit_permissions"] },
{ href: "/admin/api-keys", label: "API Keys", icon: Key, permissions: ["api_keys.manage"] },
{ href: "/admin/relays", label: "Relays", icon: Radio, permissions: ["relays.manage"] },
{ href: "/admin/settings", label: "Settings", icon: Settings, permissions: ["settings.edit"] },
{ href: "/admin/nostr", label: "Nostr Tools", icon: Wrench, permissions: ["nostr_tools.use"] },
];
export function AdminSidebar() {
const pathname = usePathname();
const { user, logout, isAdmin } = useAuth();
const { user, logout, can } = useAuth();
const shortPubkey = user?.pubkey
? `${user.pubkey.slice(0, 8)}...${user.pubkey.slice(-8)}`
: "";
return (
<aside className="w-64 bg-surface-container-lowest min-h-screen p-6 flex flex-col shrink-0">
<div className="mb-8">
<aside className="w-64 bg-surface-container-lowest h-screen sticky top-0 p-6 flex flex-col shrink-0">
<div className="mb-8 shrink-0">
<Link href="/" className="text-primary-container font-bold text-xl">
BBE Admin
</Link>
@@ -64,23 +75,26 @@ export function AdminSidebar() {
</div>
) : (
<>
<div className="mb-6">
<div className="mb-6 shrink-0">
<p className="text-on-surface/70 text-sm font-mono truncate">{shortPubkey}</p>
<span
className={cn(
"inline-block mt-1 rounded-full px-3 py-1 text-xs font-bold",
user.role === "ADMIN"
"inline-block mt-1 rounded-full px-3 py-1 text-xs font-bold capitalize",
user.isSuperAdmin || user.role === "admin"
? "bg-primary-container/20 text-primary"
: "bg-secondary-container text-on-secondary-container"
)}
>
{user.role}
{user.isSuperAdmin ? "SuperAdmin" : user.role}
</span>
</div>
<nav className="flex-1 space-y-1">
<nav className="flex-1 overflow-y-auto min-h-0 space-y-1">
{navItems
.filter((item) => !item.adminOnly || isAdmin)
.filter(
(item) =>
!item.permissions || item.permissions.some((p) => can(p))
)
.map((item) => {
const Icon = item.icon;
const active = pathname === item.href;
@@ -102,7 +116,7 @@ export function AdminSidebar() {
})}
</nav>
<div className="mt-auto space-y-2 pt-6">
<div className="shrink-0 space-y-2 pt-6">
<button
onClick={logout}
className="flex items-center gap-3 px-4 py-3 rounded-lg transition-colors text-on-surface/70 hover:text-on-surface hover:bg-surface-container w-full"
+62
View File
@@ -0,0 +1,62 @@
"use client";
import Image from "next/image";
import { useNostrProfile } from "@/hooks/useNostrProfile";
import type { NostrProfile } from "@/lib/nostr";
function computeInitials(profile: NostrProfile | null, fallback?: string): string {
const name = profile?.name || profile?.displayName;
if (name?.trim()) return name.trim().slice(0, 2).toUpperCase();
const fb = fallback?.trim();
if (fb) {
// For npub-style fallbacks skip the "npub1" prefix for nicer initials.
if (fb.startsWith("npub1") && fb.length >= 8) return fb.slice(5, 7).toUpperCase();
return fb.slice(0, 2).toUpperCase();
}
return "?";
}
export interface NostrAvatarProps {
pubkey: string | null | undefined;
/** Rendered size in pixels (square). */
size?: number;
/** Text used to derive initials when no Nostr name/picture is available. */
fallbackText?: string;
className?: string;
}
// Self-contained avatar: fetches the user's Nostr metadata from relays and
// renders their profile picture, falling back to initials.
export function NostrAvatar({
pubkey,
size = 56,
fallbackText,
className = "",
}: NostrAvatarProps) {
const { profile, loading } = useNostrProfile(pubkey);
const displayName = profile?.name || profile?.displayName;
return (
<div
className={`shrink-0 rounded-full bg-surface-container-high flex items-center justify-center overflow-hidden text-on-surface ${className}`}
style={{ width: size, height: size }}
>
{loading ? (
<span className="text-on-surface/40 text-xs"></span>
) : profile?.picture ? (
<Image
src={profile.picture}
alt={displayName ? `Avatar: ${displayName}` : "Nostr profile picture"}
width={size}
height={size}
className="object-cover w-full h-full"
unoptimized
/>
) : (
<span className="font-semibold text-sm" aria-hidden>
{computeInitials(profile, fallbackText)}
</span>
)}
</div>
);
}
+2 -1
View File
@@ -80,7 +80,8 @@ export function Navbar() {
}
const displayName = user?.name || user?.displayName || shortenPubkey(user?.pubkey || "");
const isStaff = user?.role === "ADMIN" || user?.role === "MODERATOR";
const isStaff =
!!user?.isSuperAdmin || (user?.permissions?.length ?? 0) > 0;
function handleLogout() {
setDropdownOpen(false);
+55 -6
View File
@@ -15,6 +15,8 @@ import {
export interface User {
pubkey: string;
role: string;
isSuperAdmin?: boolean;
permissions?: string[];
username?: string;
name?: string;
picture?: string;
@@ -32,17 +34,25 @@ interface AuthContextType {
logout: () => void;
isAdmin: boolean;
isModerator: boolean;
isSuperAdmin: boolean;
permissions: string[];
can: (permission: string) => boolean;
refreshAccess: () => Promise<void>;
}
export const AuthContext = createContext<AuthContextType>({
user: null,
loading: true,
login: async () => ({ pubkey: "", role: "USER" }),
loginWithBunker: async () => ({ pubkey: "", role: "USER" }),
loginWithConnectedSigner: async () => ({ pubkey: "", role: "USER" }),
login: async () => ({ pubkey: "", role: "guest" }),
loginWithBunker: async () => ({ pubkey: "", role: "guest" }),
loginWithConnectedSigner: async () => ({ pubkey: "", role: "guest" }),
logout: () => {},
isAdmin: false,
isModerator: false,
isSuperAdmin: false,
permissions: [],
can: () => false,
refreshAccess: async () => {},
});
export function useAuth() {
@@ -53,6 +63,28 @@ export function useAuthProvider(): AuthContextType {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const refreshAccess = useCallback(async () => {
const token = localStorage.getItem("bbe_token");
if (!token) return;
try {
const me = await api.getMe();
setUser((prev) => {
if (!prev) return prev;
const next: User = {
...prev,
role: me.role,
isSuperAdmin: me.isSuperAdmin,
permissions: me.permissions,
username: me.username ?? prev.username,
};
localStorage.setItem("bbe_user", JSON.stringify(next));
return next;
});
} catch {
// Best-effort refresh. The stored snapshot remains usable.
}
}, []);
useEffect(() => {
const stored = localStorage.getItem("bbe_user");
const token = localStorage.getItem("bbe_token");
@@ -63,9 +95,12 @@ export function useAuthProvider(): AuthContextType {
localStorage.removeItem("bbe_user");
localStorage.removeItem("bbe_token");
}
// Refresh effective role and permissions live so a stale JWT snapshot does
// not drive what the user can see or do.
void refreshAccess();
}
setLoading(false);
}, []);
}, [refreshAccess]);
const completeAuth = useCallback(
async (
@@ -87,6 +122,8 @@ export function useAuthProvider(): AuthContextType {
const fullUser: User = {
...userData,
isSuperAdmin: userData.isSuperAdmin ?? false,
permissions: userData.permissions ?? [],
name: profile.name,
displayName: profile.displayName,
picture: profile.picture,
@@ -138,6 +175,13 @@ export function useAuthProvider(): AuthContextType {
setUser(null);
}, []);
const permissions = user?.permissions ?? [];
const isSuperAdmin = user?.isSuperAdmin ?? false;
const can = useCallback(
(permission: string) => isSuperAdmin || permissions.includes(permission),
[isSuperAdmin, permissions]
);
return {
user,
loading,
@@ -145,7 +189,12 @@ export function useAuthProvider(): AuthContextType {
loginWithBunker,
loginWithConnectedSigner,
logout,
isAdmin: user?.role === "ADMIN",
isModerator: user?.role === "MODERATOR" || user?.role === "ADMIN",
isSuperAdmin,
permissions,
can,
refreshAccess,
// Compatibility shims for any remaining role-name checks.
isAdmin: isSuperAdmin || user?.role === "admin",
isModerator: isSuperAdmin || user?.role === "admin" || user?.role === "moderator",
};
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import { useEffect, useState } from "react";
import { loadNostrProfile, type NostrProfile } from "@/lib/nostr";
export interface UseNostrProfileResult {
profile: NostrProfile | null;
loading: boolean;
}
// Fetches a single user's Nostr kind:0 metadata from relays. Concurrent calls
// across components are batched into one relay query by the shared loader.
export function useNostrProfile(
pubkey: string | null | undefined
): UseNostrProfileResult {
const [profile, setProfile] = useState<NostrProfile | null>(null);
const [loading, setLoading] = useState<boolean>(!!pubkey);
useEffect(() => {
if (!pubkey) {
setProfile(null);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
loadNostrProfile(pubkey)
.then((p) => {
if (!cancelled) {
setProfile(p);
setLoading(false);
}
})
.catch(() => {
if (!cancelled) {
setProfile({});
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [pubkey]);
return { profile, loading };
}
+45 -5
View File
@@ -24,10 +24,27 @@ export const api = {
body: JSON.stringify({ pubkey }),
}),
verify: (pubkey: string, signedEvent: any) =>
request<{ token: string; user: { pubkey: string; role: string; username?: string } }>("/auth/verify", {
request<{
token: string;
user: {
pubkey: string;
role: string;
isSuperAdmin?: boolean;
permissions?: string[];
username?: string;
};
}>("/auth/verify", {
method: "POST",
body: JSON.stringify({ pubkey, signedEvent }),
}),
getMe: () =>
request<{
pubkey: string;
role: string;
isSuperAdmin: boolean;
permissions: string[];
username?: string;
}>("/auth/me"),
// Posts
getPosts: (params?: { category?: string; page?: number; limit?: number; all?: boolean }) => {
@@ -93,16 +110,39 @@ export const api = {
// Users
getUsers: () => request<any[]>("/users"),
promoteUser: (pubkey: string) =>
request<any>("/users/promote", { method: "POST", body: JSON.stringify({ pubkey }) }),
demoteUser: (pubkey: string) =>
request<any>("/users/demote", { method: "POST", body: JSON.stringify({ pubkey }) }),
createUser: (pubkey: string) =>
request<any>("/users", { method: "POST", body: JSON.stringify({ pubkey }) }),
setUserRole: (pubkey: string, role: string | null) =>
request<any>(`/users/${encodeURIComponent(pubkey)}/role`, {
method: "PUT",
body: JSON.stringify({ role }),
}),
updateUserUsername: (pubkey: string, username: string) =>
request<any>(`/users/${encodeURIComponent(pubkey)}`, {
method: "PATCH",
body: JSON.stringify({ username }),
}),
// Roles and permissions
getPermissionRegistry: () =>
request<{ permissions: { key: string; label: string; group: string }[]; roles: string[] }>(
"/admin/permissions"
),
getRolePermissions: () =>
request<{ roles: { role: string; permissions: string[] }[] }>("/admin/roles"),
updateRolePermissions: (role: string, permissions: string[]) =>
request<{ role: string; permissions: string[] }>(
`/admin/roles/${encodeURIComponent(role)}/permissions`,
{ method: "PUT", body: JSON.stringify({ permissions }) }
),
// API keys
getApiKeys: () => request<any[]>("/api-keys"),
createApiKey: (data: { name: string; permissions: string[] }) =>
request<any>("/api-keys", { method: "POST", body: JSON.stringify(data) }),
revokeApiKey: (id: string) =>
request<any>(`/api-keys/${encodeURIComponent(id)}`, { method: "DELETE" }),
// Categories
getCategories: () => request<any[]>("/categories"),
createCategory: (data: { name: string; slug: string }) =>
+188 -11
View File
@@ -1,4 +1,29 @@
import { generateSecretKey, getPublicKey as getPubKeyFromSecret } from "nostr-tools/pure";
import { nip19 } from "nostr-tools";
// Relays return events keyed by hex pubkeys and only accept hex in `authors`
// filters. Pubkeys may be stored/passed as npub (or nprofile), so normalize.
export function toHexPubkey(pubkey: string | null | undefined): string | null {
if (!pubkey) return null;
const trimmed = pubkey.trim();
if (/^[0-9a-f]{64}$/i.test(trimmed)) return trimmed.toLowerCase();
try {
const decoded = nip19.decode(trimmed);
if (decoded.type === "npub") return decoded.data as string;
if (decoded.type === "nprofile") return (decoded.data as { pubkey: string }).pubkey;
} catch {
// fall through
}
return null;
}
// Relays that specialize in (or broadly aggregate) kind:0 profile metadata.
// Included alongside the site relays so profiles are found even when a user's
// metadata was never published to the site's configured relay set.
const PROFILE_METADATA_RELAYS = [
"wss://purplepag.es",
"wss://relay.nostr.band",
];
declare global {
interface Window {
@@ -205,11 +230,15 @@ export async function fetchNostrProfile(
pubkey: string,
relayUrls?: string[]
): Promise<NostrProfile> {
const hex = toHexPubkey(pubkey);
if (!hex) return {};
const { SimplePool } = await import("nostr-tools/pool");
const allRelays = new Set<string>(relayUrls || await getSiteRelays());
PROFILE_METADATA_RELAYS.forEach((url) => allRelays.add(url));
try {
const nip65 = await fetchNip65RelayList(pubkey);
const nip65 = await fetchNip65RelayList(hex);
nip65.write.forEach((url) => allRelays.add(url));
} catch {}
@@ -219,19 +248,11 @@ export async function fetchNostrProfile(
try {
const event = await pool.get(urls, {
kinds: [0],
authors: [pubkey],
authors: [hex],
});
if (!event?.content) return {};
const meta = JSON.parse(event.content);
return {
name: meta.name || meta.display_name,
displayName: meta.display_name,
picture: meta.picture,
about: meta.about,
nip05: meta.nip05,
};
return parseProfileContent(event.content);
} catch {
return {};
} finally {
@@ -239,6 +260,162 @@ export async function fetchNostrProfile(
}
}
function parseProfileContent(content: string): NostrProfile {
const meta = JSON.parse(content);
return {
name: meta.name || meta.display_name,
displayName: meta.display_name,
picture: meta.picture,
about: meta.about,
nip05: meta.nip05,
};
}
const _profileCache = new Map<string, { profile: NostrProfile; fetchedAt: number; empty: boolean }>();
const PROFILE_TTL = 5 * 60 * 1000; // 5 minutes for resolved profiles
const PROFILE_EMPTY_TTL = 30 * 1000; // retry misses sooner
function isProfileEmpty(p: NostrProfile): boolean {
return !p.name && !p.displayName && !p.picture && !p.about && !p.nip05;
}
function readProfileCache(pubkey: string, now: number): NostrProfile | null {
const cached = _profileCache.get(pubkey);
if (!cached) return null;
const ttl = cached.empty ? PROFILE_EMPTY_TTL : PROFILE_TTL;
return now - cached.fetchedAt < ttl ? cached.profile : null;
}
function writeProfileCache(pubkey: string, profile: NostrProfile, now: number): void {
_profileCache.set(pubkey, { profile, fetchedAt: now, empty: isProfileEmpty(profile) });
}
// Batched profile fetch. Resolves kind:0 metadata for many pubkeys using a
// single SimplePool and one query, instead of opening a pool (plus a NIP-65
// lookup pool) per pubkey. Querying many profiles individually opens dozens of
// simultaneous websocket connections to the same relays, which get
// throttled/dropped and return empty results. Pubkeys are normalized to hex
// (relays reject npub/nprofile in `authors`).
export async function fetchNostrProfiles(
pubkeys: string[],
relayUrls?: string[]
): Promise<Record<string, NostrProfile>> {
const result: Record<string, NostrProfile> = {};
const now = Date.now();
// Map each requested key to its hex form. Skip cached and un-decodable keys.
const hexByKey = new Map<string, string>();
for (const pk of pubkeys) {
const cached = readProfileCache(pk, now);
if (cached) {
result[pk] = cached;
continue;
}
const hex = toHexPubkey(pk);
if (!hex) {
result[pk] = {};
continue;
}
hexByKey.set(pk, hex);
}
if (hexByKey.size === 0) return result;
const { SimplePool } = await import("nostr-tools/pool");
const base = relayUrls && relayUrls.length > 0 ? relayUrls : await getSiteRelays();
const urls = [...new Set([...base, ...PROFILE_METADATA_RELAYS])];
const pool = new SimplePool();
try {
const authors = [...new Set(hexByKey.values())];
const events = await pool.querySync(
urls,
{ kinds: [0], authors },
{ maxWait: 6000 }
);
// Keep only the most recent kind:0 event per hex author.
const latest = new Map<string, { created_at: number; content: string }>();
for (const event of events) {
const prev = latest.get(event.pubkey);
if (!prev || event.created_at > prev.created_at) {
latest.set(event.pubkey, { created_at: event.created_at, content: event.content });
}
}
for (const [key, hex] of hexByKey) {
const ev = latest.get(hex);
let profile: NostrProfile = {};
if (ev?.content) {
try {
profile = parseProfileContent(ev.content);
} catch {
profile = {};
}
}
result[key] = profile;
writeProfileCache(key, profile, now);
}
return result;
} catch {
for (const key of hexByKey.keys()) {
if (!(key in result)) result[key] = {};
}
return result;
} finally {
pool.close(urls);
}
}
// DataLoader-style batching for single-pubkey requests. Component instances
// (e.g. <NostrAvatar />) each ask for one pubkey; this coalesces all requests
// made within a short window into a single batched relay query.
let _batchQueue = new Set<string>();
let _batchResolvers = new Map<string, Array<(p: NostrProfile) => void>>();
let _batchTimer: ReturnType<typeof setTimeout> | null = null;
const BATCH_WINDOW_MS = 60;
async function flushProfileBatch(): Promise<void> {
const pubkeys = [..._batchQueue];
const resolvers = _batchResolvers;
_batchQueue = new Set();
_batchResolvers = new Map();
_batchTimer = null;
let profiles: Record<string, NostrProfile> = {};
try {
profiles = await fetchNostrProfiles(pubkeys);
} catch {
profiles = {};
}
for (const pk of pubkeys) {
const profile = profiles[pk] ?? {};
resolvers.get(pk)?.forEach((resolve) => resolve(profile));
}
}
// Resolve a single pubkey's profile, batching concurrent calls and reusing the
// shared profile cache.
export function loadNostrProfile(pubkey: string): Promise<NostrProfile> {
const cached = readProfileCache(pubkey, Date.now());
if (cached) {
return Promise.resolve(cached);
}
return new Promise((resolve) => {
const existing = _batchResolvers.get(pubkey);
if (existing) {
existing.push(resolve);
} else {
_batchResolvers.set(pubkey, [resolve]);
}
_batchQueue.add(pubkey);
if (_batchTimer === null) {
_batchTimer = setTimeout(() => void flushProfileBatch(), BATCH_WINDOW_MS);
}
});
}
export async function fetchEventFromRelays(eventId: string): Promise<any | null> {
const { SimplePool } = await import("nostr-tools/pool");
const siteRelays = await getSiteRelays();