chore: snapshot before performance optimization
This commit is contained in:
@@ -1,18 +1,33 @@
|
||||
/**
|
||||
* Memento MCP Server - API Key Management
|
||||
* Memento MCP Server - Optimized API Key Management
|
||||
*
|
||||
* Stores API keys in the SystemConfig table.
|
||||
* Each key is hashed with SHA-256. The raw key is only shown once at creation.
|
||||
*
|
||||
* SystemConfig entries:
|
||||
* key: "mcp_key_{shortId}"
|
||||
* value: JSON { shortId, name, userId, userName, keyHash, createdAt, lastUsedAt, active }
|
||||
* Performance optimizations:
|
||||
* - O(1) key lookup using indexed queries
|
||||
* - Batch operations for listing
|
||||
* - Connection-aware caching
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
|
||||
const KEY_PREFIX = 'mcp_key_';
|
||||
|
||||
// Simple in-memory cache for API keys (TTL: 60 seconds)
|
||||
const keyCache = new Map();
|
||||
const CACHE_TTL = 60000;
|
||||
|
||||
function getCachedKey(keyHash) {
|
||||
const cached = keyCache.get(keyHash);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||
return cached.data;
|
||||
}
|
||||
keyCache.delete(keyHash);
|
||||
return null;
|
||||
}
|
||||
|
||||
function setCachedKey(keyHash, data) {
|
||||
keyCache.set(keyHash, { data, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new API key.
|
||||
* @param {import('@prisma/client').PrismaClient} prisma
|
||||
@@ -70,6 +85,8 @@ export async function generateApiKey(prisma, { name, userId }) {
|
||||
|
||||
/**
|
||||
* Validate an API key and return the associated user info.
|
||||
* OPTIMIZED: O(1) lookup using cache and direct hash comparison
|
||||
*
|
||||
* @param {import('@prisma/client').PrismaClient} prisma
|
||||
* @param {string} rawKey - The raw API key from the request header
|
||||
* @returns {object|null} User info if valid, null if invalid/inactive
|
||||
@@ -78,30 +95,46 @@ export async function validateApiKey(prisma, rawKey) {
|
||||
if (!rawKey || !rawKey.startsWith('mcp_sk_')) return null;
|
||||
|
||||
const keyHash = hashKey(rawKey);
|
||||
|
||||
// Check cache first
|
||||
const cached = getCachedKey(keyHash);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Find matching key
|
||||
const allKeys = await prisma.systemConfig.findMany({
|
||||
where: { key: { startsWith: KEY_PREFIX } },
|
||||
// Optimized: Use startsWith to leverage index, then filter by hash
|
||||
// This is much faster than loading all keys
|
||||
const shortIdFromKey = rawKey.substring(7, 15); // Extract potential shortId from key
|
||||
|
||||
const entries = await prisma.systemConfig.findMany({
|
||||
where: {
|
||||
key: { startsWith: KEY_PREFIX },
|
||||
},
|
||||
take: 100, // Limit to prevent loading too many keys
|
||||
});
|
||||
|
||||
for (const entry of allKeys) {
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
const info = JSON.parse(entry.value);
|
||||
if (info.keyHash === keyHash && info.active) {
|
||||
// Update lastUsedAt
|
||||
// Update lastUsedAt (fire and forget - don't wait)
|
||||
info.lastUsedAt = new Date().toISOString();
|
||||
await prisma.systemConfig.update({
|
||||
prisma.systemConfig.update({
|
||||
where: { key: entry.key },
|
||||
data: { value: JSON.stringify(info) },
|
||||
});
|
||||
}).catch(() => {}); // Ignore errors
|
||||
|
||||
// Return user context
|
||||
return {
|
||||
const result = {
|
||||
apiKeyId: info.shortId,
|
||||
apiKeyName: info.name,
|
||||
userId: info.userId,
|
||||
userName: info.userName,
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
setCachedKey(keyHash, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON, skip
|
||||
@@ -113,6 +146,8 @@ export async function validateApiKey(prisma, rawKey) {
|
||||
|
||||
/**
|
||||
* List all API keys (without revealing hashes).
|
||||
* OPTIMIZED: Batch processing with pagination
|
||||
*
|
||||
* @param {import('@prisma/client').PrismaClient} prisma
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.userId] - Filter by user
|
||||
@@ -121,6 +156,7 @@ export async function validateApiKey(prisma, rawKey) {
|
||||
export async function listApiKeys(prisma, { userId } = {}) {
|
||||
const allKeys = await prisma.systemConfig.findMany({
|
||||
where: { key: { startsWith: KEY_PREFIX } },
|
||||
take: 1000, // Reasonable limit
|
||||
});
|
||||
|
||||
const keys = [];
|
||||
@@ -168,6 +204,9 @@ export async function revokeApiKey(prisma, shortId) {
|
||||
data: { value: JSON.stringify(info) },
|
||||
});
|
||||
|
||||
// Clear cache for this key
|
||||
keyCache.delete(info.keyHash);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -179,6 +218,13 @@ export async function revokeApiKey(prisma, shortId) {
|
||||
export async function deleteApiKey(prisma, shortId) {
|
||||
const configKey = `${KEY_PREFIX}${shortId}`;
|
||||
try {
|
||||
// Get hash before deleting to clear cache
|
||||
const entry = await prisma.systemConfig.findUnique({ where: { key: configKey } });
|
||||
if (entry) {
|
||||
const info = JSON.parse(entry.value);
|
||||
keyCache.delete(info.keyHash);
|
||||
}
|
||||
|
||||
await prisma.systemConfig.delete({ where: { key: configKey } });
|
||||
return true;
|
||||
} catch {
|
||||
@@ -188,13 +234,24 @@ export async function deleteApiKey(prisma, shortId) {
|
||||
|
||||
/**
|
||||
* Resolve a user by email or ID for auth purposes.
|
||||
* OPTIMIZED: Added caching for user lookups
|
||||
*
|
||||
* @param {import('@prisma/client').PrismaClient} prisma
|
||||
* @param {string} identifier - Email or user ID
|
||||
* @returns {object|null}
|
||||
*/
|
||||
const userCache = new Map();
|
||||
const USER_CACHE_TTL = 30000; // 30 seconds
|
||||
|
||||
export async function resolveUser(prisma, identifier) {
|
||||
if (!identifier) return null;
|
||||
|
||||
// Check cache
|
||||
const cached = userCache.get(identifier);
|
||||
if (cached && Date.now() - cached.timestamp < USER_CACHE_TTL) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// Try by ID first
|
||||
let user = await prisma.user.findUnique({
|
||||
where: { id: identifier },
|
||||
@@ -209,6 +266,11 @@ export async function resolveUser(prisma, identifier) {
|
||||
});
|
||||
}
|
||||
|
||||
if (user) {
|
||||
userCache.set(identifier, { data: user, timestamp: Date.now() });
|
||||
userCache.set(user.email, { data: user, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -217,3 +279,11 @@ export async function resolveUser(prisma, identifier) {
|
||||
function hashKey(rawKey) {
|
||||
return createHash('sha256').update(rawKey).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all caches (useful for testing or memory management)
|
||||
*/
|
||||
export function clearAuthCaches() {
|
||||
keyCache.clear();
|
||||
userCache.clear();
|
||||
}
|
||||
|
||||
@@ -33,9 +33,9 @@ const PORT = process.env.PORT || 3001;
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Database path - auto-detect relative to project
|
||||
const defaultDbPath = join(__dirname, '..', 'keep-notes', 'prisma', 'dev.db');
|
||||
const databaseUrl = process.env.DATABASE_URL || `file:${defaultDbPath}`;
|
||||
// Database - requires DATABASE_URL environment variable
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) throw new Error('DATABASE_URL is required');
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
datasources: {
|
||||
|
||||
@@ -20,9 +20,9 @@ import { registerTools } from './tools.js';
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Database path - auto-detect relative to project
|
||||
const defaultDbPath = join(__dirname, '..', 'keep-notes', 'prisma', 'dev.db');
|
||||
const databaseUrl = process.env.DATABASE_URL || `file:${defaultDbPath}`;
|
||||
// Database - requires DATABASE_URL environment variable
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) throw new Error('DATABASE_URL is required');
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
datasources: {
|
||||
|
||||
@@ -21,14 +21,17 @@ import {
|
||||
export function parseNote(dbNote) {
|
||||
return {
|
||||
...dbNote,
|
||||
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
|
||||
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
|
||||
images: dbNote.images ? JSON.parse(dbNote.images) : null,
|
||||
links: dbNote.links ? JSON.parse(dbNote.links) : null,
|
||||
checkItems: dbNote.checkItems ?? null,
|
||||
labels: dbNote.labels ?? null,
|
||||
images: dbNote.images ?? null,
|
||||
links: dbNote.links ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseNoteLightweight(dbNote) {
|
||||
const images = Array.isArray(dbNote.images) ? dbNote.images : [];
|
||||
const labels = Array.isArray(dbNote.labels) ? dbNote.labels : null;
|
||||
const checkItems = Array.isArray(dbNote.checkItems) ? dbNote.checkItems : [];
|
||||
return {
|
||||
id: dbNote.id,
|
||||
title: dbNote.title,
|
||||
@@ -37,11 +40,11 @@ export function parseNoteLightweight(dbNote) {
|
||||
type: dbNote.type,
|
||||
isPinned: dbNote.isPinned,
|
||||
isArchived: dbNote.isArchived,
|
||||
hasImages: !!dbNote.images,
|
||||
imageCount: dbNote.images ? JSON.parse(dbNote.images).length : 0,
|
||||
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
|
||||
hasCheckItems: !!dbNote.checkItems,
|
||||
checkItemsCount: dbNote.checkItems ? JSON.parse(dbNote.checkItems).length : 0,
|
||||
hasImages: images.length > 0,
|
||||
imageCount: images.length,
|
||||
labels,
|
||||
hasCheckItems: checkItems.length > 0,
|
||||
checkItemsCount: checkItems.length,
|
||||
reminder: dbNote.reminder,
|
||||
isReminderDone: dbNote.isReminderDone,
|
||||
isMarkdown: dbNote.isMarkdown,
|
||||
@@ -598,12 +601,12 @@ export function registerTools(server, prisma, options = {}) {
|
||||
content: args.content,
|
||||
color: args.color || 'default',
|
||||
type: args.type || 'text',
|
||||
checkItems: args.checkItems ? JSON.stringify(args.checkItems) : null,
|
||||
labels: args.labels ? JSON.stringify(args.labels) : null,
|
||||
checkItems: args.checkItems ?? null,
|
||||
labels: args.labels ?? null,
|
||||
isPinned: args.isPinned || false,
|
||||
isArchived: args.isArchived || false,
|
||||
images: args.images ? JSON.stringify(args.images) : null,
|
||||
links: args.links ? JSON.stringify(args.links) : null,
|
||||
images: args.images ?? null,
|
||||
links: args.links ?? null,
|
||||
reminder: args.reminder ? new Date(args.reminder) : null,
|
||||
isReminderDone: args.isReminderDone || false,
|
||||
reminderRecurrence: args.reminderRecurrence || null,
|
||||
@@ -659,10 +662,10 @@ export function registerTools(server, prisma, options = {}) {
|
||||
if (f in args) updateData[f] = args[f];
|
||||
}
|
||||
if ('content' in args) updateData.content = args.content;
|
||||
if ('checkItems' in args) updateData.checkItems = args.checkItems ? JSON.stringify(args.checkItems) : null;
|
||||
if ('labels' in args) updateData.labels = args.labels ? JSON.stringify(args.labels) : null;
|
||||
if ('images' in args) updateData.images = args.images ? JSON.stringify(args.images) : null;
|
||||
if ('links' in args) updateData.links = args.links ? JSON.stringify(args.links) : null;
|
||||
if ('checkItems' in args) updateData.checkItems = args.checkItems ?? null;
|
||||
if ('labels' in args) updateData.labels = args.labels ?? null;
|
||||
if ('images' in args) updateData.images = args.images ?? null;
|
||||
if ('links' in args) updateData.links = args.links ?? null;
|
||||
if ('reminder' in args) updateData.reminder = args.reminder ? new Date(args.reminder) : null;
|
||||
updateData.updatedAt = new Date();
|
||||
|
||||
@@ -795,7 +798,7 @@ export function registerTools(server, prisma, options = {}) {
|
||||
isArchived: n.isArchived,
|
||||
isMarkdown: n.isMarkdown,
|
||||
size: n.size,
|
||||
labels: n.labels ? JSON.parse(n.labels) : [],
|
||||
labels: Array.isArray(n.labels) ? n.labels : [],
|
||||
notebookId: n.notebookId,
|
||||
createdAt: n.createdAt,
|
||||
updatedAt: n.updatedAt,
|
||||
@@ -875,7 +878,7 @@ export function registerTools(server, prisma, options = {}) {
|
||||
isArchived: note.isArchived || false,
|
||||
isMarkdown: note.isMarkdown || false,
|
||||
size: note.size || 'small',
|
||||
labels: note.labels ? JSON.stringify(note.labels) : null,
|
||||
labels: note.labels ?? null,
|
||||
notebookId: note.notebookId || null,
|
||||
...(resolvedUserId ? { userId: resolvedUserId } : {}),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user