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>
This commit is contained in:
@@ -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");
|
||||
@@ -17,6 +17,21 @@ model User {
|
||||
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.
|
||||
|
||||
@@ -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;
|
||||
@@ -2,6 +2,7 @@ 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();
|
||||
|
||||
@@ -37,10 +38,12 @@ router.post('/verify', async (req: Request, res: Response) => {
|
||||
|
||||
// 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 },
|
||||
where: { pubkey: hexPubkey },
|
||||
update: {},
|
||||
create: { pubkey },
|
||||
create: { pubkey: hexPubkey },
|
||||
});
|
||||
|
||||
const access = await authService.resolveAccess(pubkey);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Router, Request, Response } from 'express';
|
||||
import { prisma } from '../db/prisma';
|
||||
import { requireAuth, requires } from '../middleware/auth';
|
||||
import { isSuperadmin } from '../services/auth';
|
||||
import { toHexPubkey, normalizePubkey } from '../services/pubkey';
|
||||
import {
|
||||
ROLE_RANK,
|
||||
isAssignableRole,
|
||||
@@ -42,6 +43,12 @@ 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(
|
||||
@@ -53,9 +60,36 @@ router.get(
|
||||
const users = await prisma.user.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// 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 = users.map((u) => {
|
||||
const result = [...byHex.values()].map((u) => {
|
||||
const superAdmin = isSuperadmin(u.pubkey);
|
||||
return {
|
||||
...u,
|
||||
@@ -71,6 +105,41 @@ 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(
|
||||
'/',
|
||||
requireAuth,
|
||||
requires('users.assign_role'),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { pubkey: rawPubkey } = req.body as { pubkey?: string };
|
||||
if (!rawPubkey || typeof rawPubkey !== 'string') {
|
||||
res.status(400).json({ error: 'pubkey is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const hex = toHexPubkey(rawPubkey);
|
||||
if (!hex) {
|
||||
res.status(400).json({ error: 'Invalid pubkey. Provide an npub or 64-char hex key.' });
|
||||
return;
|
||||
}
|
||||
|
||||
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('Create user error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 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.
|
||||
@@ -81,12 +150,13 @@ router.put(
|
||||
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 pubkey = normalizePubkey(pubkeyParam);
|
||||
|
||||
const { role } = req.body as { role?: string | null };
|
||||
|
||||
@@ -222,12 +292,13 @@ router.patch(
|
||||
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();
|
||||
@@ -238,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;
|
||||
@@ -247,7 +321,7 @@ router.patch(
|
||||
const existing = await prisma.user.findFirst({
|
||||
where: {
|
||||
username: { equals: normalized },
|
||||
NOT: { pubkey },
|
||||
NOT: { pubkey: target.pubkey },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -257,7 +331,7 @@ router.patch(
|
||||
}
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { pubkey },
|
||||
where: { pubkey: target.pubkey },
|
||||
data: { username: normalized },
|
||||
});
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ export const PERMISSIONS: PermissionDef[] = [
|
||||
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
@@ -78,10 +80,12 @@ export function isAssignableRole(role: unknown): role is AssignableRole {
|
||||
}
|
||||
|
||||
// Default permission sets seeded per role. Admin gets everything except the
|
||||
// roles editor, which stays SuperAdmin-only by default. Guest gets nothing and
|
||||
// is therefore not represented here.
|
||||
// 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'),
|
||||
admin: PERMISSIONS.map((p) => p.key).filter(
|
||||
(k) => k !== 'roles.edit_permissions' && k !== 'api_keys.manage'
|
||||
),
|
||||
moderator: [
|
||||
'events.create',
|
||||
'events.edit',
|
||||
|
||||
@@ -26,6 +26,7 @@ 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);
|
||||
@@ -57,6 +58,7 @@ 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) => {
|
||||
|
||||
@@ -1,6 +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';
|
||||
|
||||
@@ -14,11 +15,12 @@ declare global {
|
||||
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' });
|
||||
@@ -26,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;
|
||||
@@ -45,8 +63,13 @@ export function requires(permission: string) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const access = await authService.resolveAccess(req.user.pubkey);
|
||||
req.access = access;
|
||||
// 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;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user