feat: roles/permissions system and Nostr profile display on admin users
Introduce granular role-based permissions with SuperAdmin env override, admin roles UI, and permission-gated API routes. Fix admin user Nostr metadata by batching relay profile fetches, normalizing npub pubkeys to hex, and adding reusable NostrAvatar/useNostrProfile components. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+36
-5
@@ -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,30 @@ 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 }) }),
|
||||
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 }) }
|
||||
),
|
||||
|
||||
// Categories
|
||||
getCategories: () => request<any[]>("/categories"),
|
||||
createCategory: (data: { name: string; slug: string }) =>
|
||||
|
||||
+188
-11
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user