feat: opt web ux
This commit is contained in:
29
packages/server/src/lib/crypto.ts
Normal file
29
packages/server/src/lib/crypto.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
|
||||
function getEncryptionKey(): Buffer {
|
||||
const secret = process.env.API_KEY_ENCRYPTION_SECRET;
|
||||
if (!secret) throw new Error('API_KEY_ENCRYPTION_SECRET environment variable is required');
|
||||
return Buffer.from(secret, 'hex');
|
||||
}
|
||||
|
||||
export function encryptApiKey(plaintext: string): string {
|
||||
const key = getEncryptionKey();
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted.toString('base64')}`;
|
||||
}
|
||||
|
||||
export function decryptApiKey(ciphertext: string): string {
|
||||
const key = getEncryptionKey();
|
||||
const [ivB64, tagB64, dataB64] = ciphertext.split(':');
|
||||
const iv = Buffer.from(ivB64, 'base64');
|
||||
const authTag = Buffer.from(tagB64, 'base64');
|
||||
const encrypted = Buffer.from(dataB64, 'base64');
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
return decipher.update(encrypted) + decipher.final('utf8');
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { prisma } from '@agent-fox/shared';
|
||||
import { hashPassword, verifyPassword } from '../lib/password.js';
|
||||
import { generateTokenPair, verifyRefreshToken } from '../lib/jwt.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { generateApiKey } from '../lib/api-key.js';
|
||||
import { encryptApiKey, decryptApiKey } from '../lib/crypto.js';
|
||||
|
||||
const router: RouterType = Router();
|
||||
|
||||
@@ -150,4 +152,86 @@ router.get('/me', requireAuth, async (req, res) => {
|
||||
res.json({ success: true, data: user });
|
||||
});
|
||||
|
||||
// --- API Key Management ---
|
||||
|
||||
router.get('/api-key/status', requireAuth, async (req, res) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.userId },
|
||||
select: { apiKeyPrefix: true, apiKeyHash: true },
|
||||
});
|
||||
res.json({
|
||||
success: true,
|
||||
data: { hasKey: !!user?.apiKeyHash, prefix: user?.apiKeyPrefix || null },
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/api-key/generate', requireAuth, async (req, res) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.userId },
|
||||
select: { apiKeyHash: true },
|
||||
});
|
||||
if (user?.apiKeyHash) {
|
||||
res.status(400).json({ success: false, error: { code: 'ALREADY_EXISTS', message: 'API key already exists. Use rotate to replace it.' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const { raw, hash } = generateApiKey();
|
||||
const encrypted = encryptApiKey(raw);
|
||||
const prefix = raw.slice(0, 12);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: req.user!.userId },
|
||||
data: { apiKeyHash: hash, apiKeyEncrypted: encrypted, apiKeyPrefix: prefix },
|
||||
});
|
||||
|
||||
res.json({ success: true, data: { apiKey: raw } });
|
||||
});
|
||||
|
||||
router.post('/api-key/rotate', requireAuth, async (req, res) => {
|
||||
const { raw, hash } = generateApiKey();
|
||||
const encrypted = encryptApiKey(raw);
|
||||
const prefix = raw.slice(0, 12);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: req.user!.userId },
|
||||
data: { apiKeyHash: hash, apiKeyEncrypted: encrypted, apiKeyPrefix: prefix },
|
||||
});
|
||||
|
||||
res.json({ success: true, data: { apiKey: raw } });
|
||||
});
|
||||
|
||||
const revealSchema = z.object({ password: z.string() });
|
||||
|
||||
router.post('/api-key/reveal', requireAuth, async (req, res) => {
|
||||
const parsed = revealSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Password is required' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.userId },
|
||||
select: { passwordHash: true, apiKeyEncrypted: true },
|
||||
});
|
||||
|
||||
if (!user?.passwordHash) {
|
||||
res.status(400).json({ success: false, error: { code: 'NO_PASSWORD', message: 'No password set' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(parsed.data.password, user.passwordHash);
|
||||
if (!valid) {
|
||||
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Incorrect password' } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.apiKeyEncrypted) {
|
||||
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'No API key generated' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = decryptApiKey(user.apiKeyEncrypted);
|
||||
res.json({ success: true, data: { apiKey } });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Router, type Router as RouterType } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { prisma } from '@agent-fox/shared';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { generateApiKey } from '../lib/api-key.js';
|
||||
import { parseOpenApiDocument } from '../services/openapi-parser.js';
|
||||
|
||||
const router: RouterType = Router();
|
||||
@@ -18,7 +17,6 @@ router.post('/', async (req, res) => {
|
||||
try {
|
||||
const input = specUrl || spec;
|
||||
const parsed = await parseOpenApiDocument(input);
|
||||
const { raw: apiKey, hash: apiKeyHash } = generateApiKey();
|
||||
|
||||
const project = await prisma.$transaction(async (tx) => {
|
||||
const proj = await tx.project.create({
|
||||
@@ -29,7 +27,6 @@ router.post('/', async (req, res) => {
|
||||
baseUrl: parsed.baseUrl,
|
||||
openApiSpec: parsed.spec as any,
|
||||
openApiVersion: parsed.openApiVersion,
|
||||
apiKeyHash,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -64,7 +61,6 @@ router.post('/', async (req, res) => {
|
||||
success: true,
|
||||
data: {
|
||||
project: { id: project.id, name: project.name },
|
||||
apiKey,
|
||||
stats: { modules: parsed.modules.length, endpoints: parsed.endpoints.length },
|
||||
},
|
||||
});
|
||||
@@ -136,17 +132,4 @@ router.delete('/:id', async (req, res) => {
|
||||
res.json({ success: true, data: { deleted: true } });
|
||||
});
|
||||
|
||||
router.post('/:id/api-key/rotate', async (req, res) => {
|
||||
const { raw, hash } = generateApiKey();
|
||||
const result = await prisma.project.updateMany({
|
||||
where: { id: req.params.id, userId: req.user!.userId },
|
||||
data: { apiKeyHash: hash },
|
||||
});
|
||||
if (result.count === 0) {
|
||||
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true, data: { apiKey: raw } });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
Reference in New Issue
Block a user