| name | api-development-standards |
| description | Node.js/Express/Prisma/PostgreSQL development patterns for Design Token Manager API. Use when working with controllers, routes, middleware, DAL, or database operations. Enforces multi-tenant isolation, Prisma best practices, Express patterns, authentication, and API response standards. |
🔧 API Development Standards
Enforces architectural patterns and best practices for the Design Token Manager API (Node.js/Express/Prisma/PostgreSQL).
When to Use This Skill
ALWAYS activate when:
- Creating or modifying API routes
- Building controllers or services
- Database operations with Prisma
- Adding middleware (auth, validation, tenant context)
- Multi-tenant data operations
- API authentication (JWT, API keys)
- Error handling and validation
- Writing tests for endpoints
Architecture Overview
Three-Layer Architecture
ROUTES (Express)
↓
CONTROLLERS (Business Logic)
↓
DAL (Database Abstraction Layer)
↓
PRISMA (ORM)
↓
POSTGRESQL (Supabase)
Key principles:
- Routes handle HTTP concerns only (validation, auth)
- Controllers contain business logic
- DAL provides database abstraction
- Prisma performs type-safe queries
- All data operations enforce tenant/project isolation
Core Standards
1. Multi-Tenant Data Isolation
CRITICAL: Every database query MUST include tenant/project filtering.
✅ CORRECT:
async function getTokens(req, res, next) {
const { tenantId, projectId } = req.tenant;
const tokens = await databaseService.findTokensByProject(tenantId, projectId);
res.json({ tokens });
}
❌ WRONG:
async function getTokens(req, res, next) {
const tokens = await databaseService.findAllTokens();
res.json({ tokens });
}
Rules:
- NEVER query without tenant/project filtering
- Use
tenantContext middleware to extract tenant/project IDs
- DAL methods already enforce isolation - use them
- Test multi-tenant isolation in integration tests
2. Database Access via DAL
ALWAYS use the Database Abstraction Layer, never direct Prisma calls in controllers.
✅ CORRECT:
import databaseService from '../dal/DatabaseService.js';
async function createToken(req, res, next) {
const { tenantId, projectId } = req.tenant;
const tokenData = req.body;
const token = await databaseService.createToken({
...tokenData,
tenantId,
projectId
});
res.status(201).json({ token });
}
❌ WRONG:
import prisma from '../config/prisma.js';
async function createToken(req, res, next) {
const token = await prisma.token.create({
data: req.body
});
res.json({ token });
}
Why:
- DAL provides database flexibility (could switch from Supabase)
- DAL methods enforce tenant isolation
- Consistent patterns across controllers
- Easier testing with DAL mocks
3. Controller Structure
Controllers should follow this pattern:
import databaseService from '../dal/DatabaseService.js';
export async function listTokens(req, res, next) {
try {
const { tenantId, projectId } = req.tenant;
const { page = 1, limit = 20, category } = req.query;
const tokens = await databaseService.findTokensByProject(
tenantId,
projectId,
{ page, limit, category }
);
res.json({
tokens,
pagination: { page, limit, total: tokens.length }
});
} catch (error) {
next(error);
}
}
export async function createToken(req, res, next) {
try {
const { tenantId, projectId } = req.tenant;
const tokenData = req.body;
const token = await databaseService.createToken({
...tokenData,
tenantId,
projectId
});
res.status(201).json({ token });
} catch (error) {
next(error);
}
}
Key points:
- Try/catch with
next(error) for centralized error handling
- Extract tenant/project from
req.tenant (middleware)
- JSDoc comments for route documentation
- Return consistent JSON responses
- Proper HTTP status codes (201 for created, 200 for success)
4. Route Structure
Routes handle HTTP concerns, delegate to controllers:
import express from 'express';
import { body, query } from 'express-validator';
import { verifyJWT } from '../middleware/verifyJWT.js';
import { tenantContext } from '../middleware/tenantContext.js';
import { requireRole } from '../middleware/requireRole.js';
import * as tokenController from '../controllers/tokenController.js';
const router = express.Router();
router.get(
'/',
verifyJWT,
tenantContext,
requireRole(['admin', 'editor', 'viewer']),
query('category').optional().isString(),
tokenController.listTokens
);
router.post(
'/',
verifyJWT,
tenantContext,
requireRole(['admin', 'editor']),
body('name').notEmpty().isString(),
body('value').notEmpty().isString(),
body('category').optional().isString(),
tokenController.createToken
);
export default router;
Middleware order matters:
- Authentication (
verifyJWT or verifyApiKey)
- Tenant context (
tenantContext)
- Authorization (
requireRole)
- Validation (
express-validator)
- Controller
5. Prisma/DAL Best Practices
When working in the DAL layer:
✅ Use transactions for multiple operations:
async createTokenWithCategory(tokenData) {
return await this.prisma.$transaction(async (tx) => {
const category = await tx.category.findUnique({
where: { name: tokenData.category }
});
if (!category) {
throw new Error('Category not found');
}
return await tx.token.create({
data: tokenData
});
});
}
✅ Use proper error handling:
async findTokenById(id) {
try {
const token = await this.prisma.token.findUnique({
where: { id },
include: { tenant: true, project: true }
});
if (!token) {
return null;
}
return token;
} catch (error) {
throw new Error(`Failed to find token: ${error.message}`);
}
}
✅ Use select/include for performance:
const tokens = await this.prisma.token.findMany({
where: { tenantId, projectId },
select: {
id: true,
name: true,
value: true,
category: true
}
});
6. Authentication Patterns
JWT Authentication (Admin Users):
import jwt from 'jsonwebtoken';
export function verifyJWT(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ message: 'Authentication required' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({ message: 'Invalid token' });
}
}
API Key Authentication (Programmatic Access):
export async function verifyApiKey(req, res, next) {
const apiKey = req.headers.authorization?.replace('Bearer ', '');
if (!apiKey) {
return res.status(401).json({ message: 'API key required' });
}
const hashedKey = hashApiKey(apiKey);
const keyRecord = await databaseService.findApiKeyByHash(hashedKey);
if (!keyRecord || keyRecord.revoked) {
return res.status(401).json({ message: 'Invalid API key' });
}
req.tenant = {
tenantId: keyRecord.tenantId,
projectId: keyRecord.projectId
};
req.apiKey = keyRecord;
next();
}
7. Error Handling
Centralized error handler:
export function errorHandler(err, req, res, next) {
console.error(err.stack);
if (err.code === 'P2002') {
return res.status(409).json({
message: 'Resource already exists',
field: err.meta?.target
});
}
if (err.name === 'ValidationError') {
return res.status(400).json({
message: 'Validation failed',
errors: err.errors
});
}
res.status(err.status || 500).json({
message: err.message || 'Internal server error'
});
}
Custom error classes:
export class NotFoundError extends Error {
constructor(resource) {
super(`${resource} not found`);
this.name = 'NotFoundError';
this.status = 404;
}
}
export class ForbiddenError extends Error {
constructor(message = 'Access forbidden') {
super(message);
this.name = 'ForbiddenError';
this.status = 403;
}
}
8. Response Standards
Consistent response format:
✅ Success (200/201):
res.json({
token: { id, name, value, category }
});
res.json({
tokens: [...],
pagination: {
page: 1,
limit: 20,
total: 45,
pages: 3
}
});
✅ Created (201):
res.status(201).json({
token: newToken,
message: 'Token created successfully'
});
✅ Error (4xx/5xx):
res.status(404).json({
message: 'Token not found',
code: 'RESOURCE_NOT_FOUND'
});
9. Testing Standards
Integration tests for endpoints:
import request from 'supertest';
import app from '../src/index.js';
import databaseService from '../src/dal/DatabaseService.js';
describe('POST /api/v1/tokens', () => {
let authToken;
let tenantId;
let projectId;
beforeAll(async () => {
const tenant = await databaseService.createTenant({ name: 'Test' });
tenantId = tenant.id;
const project = await databaseService.createProject({
name: 'Test Project',
tenantId
});
projectId = project.id;
authToken = 'test-jwt-token';
});
it('should create a token with valid data', async () => {
const response = await request(app)
.post('/api/v1/tokens')
.set('Authorization', `Bearer ${authToken}`)
.set('X-Tenant-ID', tenantId)
.set('X-Project-ID', projectId)
.send({
name: 'primary-color',
value: '#007bff',
category: 'color'
});
expect(response.status).toBe(201);
expect(response.body.token).toHaveProperty('id');
expect(response.body.token.name).toBe('primary-color');
});
it('should enforce tenant isolation', async () => {
const response = await request(app)
.get('/api/v1/tokens')
.set('Authorization', `Bearer ${authToken}`)
.set('X-Tenant-ID', 'wrong-tenant-id')
.set('X-Project-ID', projectId);
expect(response.status).toBe(403);
});
});
Quick Reference
Controller Checklist
Route Checklist
DAL Checklist
Testing Checklist
Common Violations
❌ Direct Prisma Access in Controller
import prisma from '../config/prisma.js';
const tokens = await prisma.token.findMany();
❌ Missing Tenant Isolation
const tokens = await databaseService.findAllTokens();
❌ Wrong Middleware Order
router.get('/', requireRole(['admin']), verifyJWT, controller);
❌ Inconsistent Error Handling
catch (error) {
res.status(500).json({ error: error.message });
}
Remember: Multi-tenant isolation is CRITICAL. Every database query must filter by tenant/project. Use the DAL layer. Follow middleware order. Test tenant isolation.