Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
"@fastify/multipart": "^9.0.0",
"@fastify/static": "^8.0.0",
"@prisma/client": "^6.0.0",
"@fastify/swagger": "^9.7.0",
"@fastify/swagger-ui": "^5.2.6",
"bcryptjs": "^2.4.3",
"dotenv": "^16.4.0",
"fastify": "^5.0.0",
"fastify-plugin": "^5.0.0",
Expand All @@ -33,6 +36,7 @@
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/bcryptjs": "^2.4.2",
"@types/qrcode": "^1.5.0",
"pino-pretty": "^13.1.3",
"prisma": "^6.0.0",
Expand Down
14 changes: 14 additions & 0 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,20 @@ model OAuthToken {
@@map("oauth_tokens")
}

model ApiKey {
id String @id @default(uuid())
userId String @map("user_id")
keyHash String @map("key_hash")
label String?
lastUsed DateTime? @map("last_used")
createdAt DateTime @default(now()) @map("created_at")
revokedAt DateTime? @map("revoked_at")

user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@map("api_keys")
}

model CardView {
id String @id @default(uuid())
cardId String? @map("card_id") // null = default profile view
Expand Down
194 changes: 194 additions & 0 deletions apps/backend/src/__tests__/apiKey.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Fastify from 'fastify';
import swagger from '@fastify/swagger';
import { apiKeyPlugin } from '../plugins/apiKey.js';
import { v1Routes } from '../routes/v1.js';
import * as bcrypt from 'bcryptjs';

const mockPrisma = {
apiKey: {
findUnique: vi.fn(),
update: vi.fn(),
create: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
platformLink: {
aggregate: vi.fn(),
create: vi.fn(),
findFirst: vi.fn(),
delete: vi.fn(),
},
};

const mockRedis = {
incr: vi.fn(),
expire: vi.fn(),
};

async function buildApp() {
const app = Fastify();
app.decorate('prisma', mockPrisma);
app.decorate('redis', mockRedis);
await app.register(swagger, {
openapi: {
openapi: '3.1.0',
info: { title: 'DevCard Public API', version: '1.0.0' },
servers: [{ url: 'http://localhost:3000/api/v1' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'API Key',
},
},
},
},
exposeRoute: false,
});
app.register(apiKeyPlugin);
app.decorate('authenticate', async (request: any) => {
request.user = { id: 'user-abc' };
});
app.register(v1Routes, { prefix: '/api/v1' });
await app.ready();
return app;
}

describe('Public v1 API', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('creates an API key and returns the raw secret once', async () => {
mockPrisma.apiKey.create.mockResolvedValue({
id: 'key-abc',
userId: 'user-abc',
label: 'test key',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
lastUsed: null,
revokedAt: null,
});

const app = await buildApp();
const res = await app.inject({ method: 'POST', url: '/api/v1/keys', payload: { label: 'test key' } });
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.id).toBe('key-abc');
expect(body.label).toBe('test key');
expect(typeof body.key).toBe('string');
expect(body.key.startsWith('key-abc.')).toBe(true);
expect(mockPrisma.apiKey.create).toHaveBeenCalled();
});

it('allows a valid API key to fetch authenticated profile data', async () => {
const secret = 'my-secret-token';
const hashValue = await bcrypt.hash(secret, 10);
const apiKeyRecord = {
id: 'auth-key',
userId: 'user-abc',
keyHash: hashValue,
revokedAt: null,
};

mockPrisma.apiKey.findUnique.mockResolvedValue(apiKeyRecord);
mockPrisma.apiKey.update.mockResolvedValue({ ...apiKeyRecord, lastUsed: new Date() });
mockRedis.incr.mockResolvedValue(1);
mockRedis.expire.mockResolvedValue(1);
mockPrisma.user.findUnique.mockResolvedValue({
id: 'user-abc',
email: 'api@example.com',
username: 'apiprofile',
displayName: 'API User',
bio: 'API bio',
pronouns: 'they/them',
role: 'developer',
company: 'DevCard',
avatarUrl: null,
accentColor: '#ff0000',
platformLinks: [],
cards: [],
});

const app = await buildApp();
const res = await app.inject({
method: 'GET',
url: '/api/v1/profiles/me',
headers: { authorization: `Bearer auth-key.${secret}` },
});

expect(res.statusCode).toBe(200);
expect(res.json()).toEqual(expect.objectContaining({ username: 'apiprofile', email: 'api@example.com' }));
expect(mockPrisma.apiKey.update).toHaveBeenCalled();
});

it('revokes an API key and rejects it on next request', async () => {
const secret = 'revoked-secret';
const hashValue = await bcrypt.hash(secret, 10);
const currentKey = {
id: 'revoked-key',
userId: 'user-abc',
keyHash: hashValue,
revokedAt: null,
};

mockPrisma.apiKey.findUnique.mockImplementation(async ({ where }: any) => {
if (where?.id === 'revoked-key') {
return currentKey;
}
return null;
});
mockPrisma.apiKey.update.mockImplementation(async ({ where }: any) => ({ ...currentKey, ...where }));

const app = await buildApp();
const deleteRes = await app.inject({ method: 'DELETE', url: '/api/v1/keys/revoked-key' });

expect(deleteRes.statusCode).toBe(204);
expect(mockPrisma.apiKey.update).toHaveBeenCalledWith({ where: { id: 'revoked-key' }, data: { revokedAt: expect.any(Date) } });

mockPrisma.apiKey.findUnique.mockResolvedValue({ ...currentKey, revokedAt: new Date() });
const fetchRes = await app.inject({
method: 'GET',
url: '/api/v1/profiles/me',
headers: { authorization: `Bearer revoked-key.${secret}` },
});

expect(fetchRes.statusCode).toBe(401);
expect(fetchRes.json().error).toBe('Unauthorized');
});

it('returns public profile data without an API key', async () => {
mockPrisma.user.findUnique.mockResolvedValue({
id: 'user-xyz',
username: 'publicuser',
displayName: 'Public User',
bio: 'Public bio',
pronouns: null,
role: null,
company: null,
avatarUrl: null,
accentColor: '#000000',
platformLinks: [],
cards: [],
});

const app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/api/v1/profiles/publicuser' });

expect(res.statusCode).toBe(200);
expect(res.json().username).toBe('publicuser');
});

it('serves OpenAPI JSON at /api/v1/openapi.json', async () => {
const app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/api/v1/openapi.json' });

expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toContain('application/json');
const body = res.json();
expect(body.openapi).toBe('3.1.0');
expect(body.paths['/profiles/me']).toBeDefined();
});
});
35 changes: 35 additions & 0 deletions apps/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,20 @@ import multipart from '@fastify/multipart';
import fastifyStatic from '@fastify/static';
import path from 'path';
import { fileURLToPath } from 'url';
import swagger from '@fastify/swagger';
import swaggerUi from '@fastify/swagger-ui';

import { prismaPlugin } from './plugins/prisma.js';
import { redisPlugin } from './plugins/redis.js';
import { apiKeyPlugin } from './plugins/apiKey.js';
import { authRoutes } from './routes/auth.js';
import { profileRoutes } from './routes/profiles.js';
import { cardRoutes } from './routes/cards.js';
import { publicRoutes } from './routes/public.js';
import { followRoutes } from './routes/follow.js';
import { connectRoutes } from './routes/connect.js';
import { analyticsRoutes } from './routes/analytics.js';
import { v1Routes } from './routes/v1.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

Expand Down Expand Up @@ -71,6 +75,36 @@ export async function buildApp() {
// ─── Database & Cache Plugins ───
await app.register(prismaPlugin);
await app.register(redisPlugin);
await app.register(apiKeyPlugin);

await app.register(swagger, {
openapi: {
openapi: '3.1.0',
info: {
title: 'DevCard Public API',
description: 'Versioned public REST API for DevCard.',
version: '1.0.0',
},
servers: [{ url: process.env.PUBLIC_API_URL || 'http://localhost:3000/api/v1' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'API Key',
},
},
},
},
});

await app.register(swaggerUi, {
routePrefix: '/api/v1/docs',
staticCSP: true,
swagger: {
url: '/api/v1/openapi.json',
},
});

// ─── Auth Decorator ───
app.decorate('authenticate', async function (request: any, reply: any) {
Expand All @@ -89,6 +123,7 @@ export async function buildApp() {
await app.register(followRoutes, { prefix: '/api/follow' });
await app.register(connectRoutes, { prefix: '/api/connect' });
await app.register(analyticsRoutes, { prefix: '/api/analytics' });
await app.register(v1Routes, { prefix: '/api/v1' });

// ─── Health Check ───
app.get('/health', async () => ({
Expand Down
59 changes: 59 additions & 0 deletions apps/backend/src/plugins/apiKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import fp from 'fastify-plugin';
import * as bcrypt from 'bcryptjs';
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';

declare module 'fastify' {
interface FastifyInstance {
verifyApiKey: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
}

interface FastifyRequest {
apiKey?: {
id: string;
userId: string;
};
}
}

export const apiKeyPlugin = fp(async (app: FastifyInstance) => {
app.decorateRequest('apiKey', undefined as any);

app.decorate('verifyApiKey', async function (request: FastifyRequest, reply: FastifyReply) {
const header = request.headers.authorization;
if (typeof header !== 'string' || !header.startsWith('Bearer ')) {
return reply.status(401).send({ error: 'Unauthorized' });
}

const token = header.slice(7).trim();
const [id, secret] = token.split('.', 2);
if (!id || !secret) {
return reply.status(401).send({ error: 'Unauthorized' });
}

const apiKey = await app.prisma.apiKey.findUnique({ where: { id } });
if (!apiKey || apiKey.revokedAt) {
return reply.status(401).send({ error: 'Unauthorized' });
}

const isMatch = await bcrypt.compare(secret, apiKey.keyHash);
if (!isMatch) {
return reply.status(401).send({ error: 'Unauthorized' });
}

await app.prisma.apiKey.update({ where: { id }, data: { lastUsed: new Date() } });
request.apiKey = { id: apiKey.id, userId: apiKey.userId };

// Separate API key rate limiting for versioned public API traffic.
if (app.redis) {
const windowSeconds = 60;
const rateKey = `rate:api:${apiKey.id}:${Math.floor(Date.now() / 1000 / windowSeconds)}`;
const count = await app.redis.incr(rateKey);
if (count === 1) {
await app.redis.expire(rateKey, windowSeconds);
}
if (count > 300) {
return reply.status(429).send({ error: 'Too Many Requests' });
}
}
});
});
Loading