Project Structure
Directory Layout
/Users/jerome/Projects/cerberus-iam/api/
├── .github/
│ └── workflows/
│ ├── ci.yml # Continuous integration pipeline
│ └── deploy.yml # AWS ECS deployment
├── .husky/ # Git hooks
│ └── pre-commit # Lint and format staged files
├── docs/
│ └── architecture/ # Architecture documentation
├── prisma/
│ ├── schema.prisma # Database schema definition
│ ├── migrations/ # Database migration files
│ └── seed.ts # Database seeding script
├── scripts/
│ └── generate-secret-encryption-key.js # Key generation utility
├── src/
│ ├── app.ts # Express application factory
│ ├── server.ts # Server entry point
│ ├── config/
│ │ └── index.ts # Configuration with Zod validation
│ ├── logger.ts # Pino logger setup
│ ├── middleware/ # Express middleware
│ │ ├── requestId.ts # Request ID generation
│ │ ├── authn.ts # Authentication (session/bearer/optional)
│ │ ├── tenant.ts # Multi-tenancy context
│ │ ├── rbac.ts # Role-based access control
│ │ ├── csrf.ts # CSRF protection
│ │ ├── rateLimit.ts # Rate limiting
│ │ └── apikey.ts # API key authentication
│ ├── routes/ # Route definitions
│ │ ├── index.ts # Health check route
│ │ ├── well-known/ # OIDC discovery endpoints
│ │ │ ├── index.ts
│ │ │ └── openid-configuration.ts
│ │ ├── oauth2/ # OAuth 2.0/OIDC endpoints
│ │ │ ├── index.ts
│ │ │ ├── authorize.ts
│ │ │ ├── token.ts
│ │ │ ├── userinfo.ts
│ │ │ ├── introspect.ts
│ │ │ ├── revoke.ts
│ │ │ ├── jwks.ts
│ │ │ └── consent.ts
│ │ └── api/v1/ # REST API v1
│ │ ├── index.ts
│ │ ├── auth/ # Public authentication endpoints
│ │ │ ├── index.ts
│ │ │ ├── register.ts
│ │ │ ├── login.ts
│ │ │ ├── logout.ts
│ │ │ ├── forgot-password.ts
│ │ │ ├── reset-password.ts
│ │ │ ├── verify-email.ts
│ │ │ └── invitations.ts
│ │ ├── me/ # User self-service endpoints
│ │ │ ├── index.ts
│ │ │ ├── profile.ts
│ │ │ ├── sessions.ts
│ │ │ ├── mfa.ts
│ │ │ └── export.ts
│ │ └── admin/ # Admin endpoints (RBAC protected)
│ │ ├── index.ts
│ │ ├── users.ts
│ │ ├── roles.ts
│ │ ├── permissions.ts
│ │ ├── teams.ts
│ │ ├── clients.ts
│ │ ├── organisation.ts
│ │ ├── invitations.ts
│ │ ├── api-keys.ts
│ │ ├── webhooks.ts
│ │ └── audit-logs.ts
│ ├── controllers/ # Request handlers
│ │ ├── user.controller.ts
│ │ ├── client.controller.ts
│ │ ├── role.controller.ts
│ │ ├── team.controller.ts
│ │ ├── organisation.controller.ts
│ │ ├── invitation.controller.ts
│ │ ├── mfa.controller.ts
│ │ ├── apikey.controller.ts
│ │ ├── webhook.controller.ts
│ │ └── auditlog.controller.ts
│ ├── services/ # Business logic layer
│ │ ├── user.service.ts
│ │ ├── token.service.ts
│ │ ├── organisation.service.ts
│ │ ├── role.service.ts
│ │ ├── team.service.ts
│ │ ├── client.service.ts
│ │ ├── invitation.service.ts
│ │ ├── mfa.service.ts
│ │ ├── apikey.service.ts
│ │ ├── webhook.service.ts
│ │ ├── auditlog.service.ts
│ │ ├── jwk.service.ts
│ │ ├── account-token.service.ts
│ │ ├── data-export.service.ts
│ │ └── data-erasure.service.ts
│ ├── auth/ # Authentication utilities
│ │ ├── jwt.ts # JWT signing and verification
│ │ ├── password.ts # Password hashing/verification
│ │ ├── sessions.ts # Session management
│ │ ├── mfa.ts # MFA/TOTP utilities
│ │ └── jwks.ts # JWKS key management
│ ├── oidc/ # OIDC-specific logic
│ │ ├── discovery.ts
│ │ ├── authorize.ts
│ │ ├── consent.ts
│ │ ├── introspect.ts
│ │ ├── revoke.ts
│ │ ├── userinfo.ts
│ │ └── jwks.ts
│ ├── db/
│ │ └── prisma.ts # Prisma client singleton
│ ├── utils/ # Utility functions
│ │ ├── crypto.ts # Cryptographic functions
│ │ ├── problem.ts # RFC 7807 Problem Details
│ │ ├── audit.ts # Audit logging helpers
│ │ └── ids.ts # ID generation utilities
│ ├── logging/
│ │ └── remote-exporter.ts # Remote log shipping
│ ├── types/ # TypeScript type definitions
│ │ ├── index.ts
│ │ ├── auth.types.ts
│ │ ├── token.types.ts
│ │ ├── audit.types.ts
│ │ ├── webhook.types.ts
│ │ ├── middleware.types.ts
│ │ ├── logging.types.ts
│ │ ├── problem.types.ts
│ │ ├── data-export.types.ts
│ │ ├── data-erasure.types.ts
│ │ └── test.types.ts
│ └── **/*.test.ts # Test files (co-located)
├── e2e/ # End-to-end tests
├── dist/ # Compiled JavaScript (gitignored)
├── coverage/ # Test coverage reports (gitignored)
├── node_modules/ # Dependencies (gitignored)
├── .env # Environment variables (gitignored)
├── .env.example # Environment variable template
├── .eslintrc.json # ESLint configuration
├── .prettierrc # Prettier configuration
├── .gitignore # Git ignore patterns
├── jest.config.ts # Jest configuration
├── jest.setup.ts # Jest global setup
├── jest.setup-env.js # Jest environment setup
├── tsconfig.json # TypeScript configuration
├── tsconfig.test.json # TypeScript test configuration
├── package.json # NPM dependencies and scripts
├── package-lock.json # Dependency lock file
├── README.md # Project overview
├── TESTING.md # Testing documentation
├── AGENTS.md # AI agent guidelines
├── CONTRIBUTING.md # Contribution guidelines
├── SECURITY.md # Security policy
└── CLAUDE.md # Claude Code instructionsModule Organization
Core Application Files
src/app.ts
Pure Express application factory. Wires middleware in correct order:
- Request ID generation
- Security headers (Helmet)
- HTTP logging (Pino)
- CORS with allow-list
- Body parsing (JSON, URL-encoded)
- Cookie parsing
- Route registration
- 404 handler
- Central error handler
src/server.ts
Server entry point. Binds app to port, handles graceful shutdown:
import { createApp } from './app';
import { config } from './config';
const app = createApp();
const server = app.listen(config.PORT, () => {
logger.info(`Server listening on port ${config.PORT}`);
});
// Graceful shutdown
process.on('SIGTERM', () => {
server.close(() => process.exit(0));
});Configuration (src/config/)
Single source of truth for all environment variables. Uses Zod for validation.
Pattern:
const configSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
PORT: z.coerce.number().int().positive().default(4000),
// ... more fields
});
export const config: Config = parsedEnv.data;Middleware (src/middleware/)
All middleware follows Express signature:
export function middlewareName(
req: Request,
res: Response,
next: NextFunction,
): void | Promise<void> {
// ... logic
next();
}Middleware Factories
Parameterized middleware for reusability:
export function requirePerm(permission: string) {
return async (req: Request, res: Response, next: NextFunction) => {
// Check permission
};
}
// Usage
router.get('/users', requirePerm('users:read'), controller);Routes (src/routes/)
Routes are organized by API surface area:
/health: Health check for load balancers/.well-known: OIDC discovery endpoints/oauth2: OAuth 2.0/OIDC protocol endpoints/v1/auth: Public authentication (register, login, etc.)/v1/me: User self-service endpoints/v1/admin: Admin endpoints (RBAC protected)
Naming Convention:
- One file per route or route group
- Export Express
Routerinstance - Import and mount in parent
index.ts
Example:
// src/routes/v1/admin/users.ts
import { Router } from 'express';
import { requirePerm } from '@/middleware/rbac';
const router = Router();
router.get('/', requirePerm('users:read'), listUsers);
router.post('/', requirePerm('users:create'), createUser);
export default router;Controllers (src/controllers/)
Controllers handle HTTP concerns:
- Request validation
- Calling service layer
- Formatting responses
- Error handling
Pattern:
export async function getUser(req: Request, res: Response): Promise<void> {
const { userId } = req.params;
const organisationId = req.tenant!.id;
const user = await userService.findById(userId, organisationId);
if (!user) {
sendProblem(res, notFound('User not found'));
return;
}
res.json({ data: user });
}Services (src/services/)
Services contain business logic. Each service is a class with methods for domain operations.
Pattern:
export class UserService {
async findById(userId: string, organisationId: string): Promise<User | null> {
return prisma.user.findFirst({
where: { id: userId, organisationId, deletedAt: null },
});
}
async create(data: CreateUserData, actorId?: string): Promise<User> {
const user = await prisma.user.create({ data });
await auditUserCreated(organisationId, actorId, user.id);
return user;
}
}
export const userService = new UserService();Key Services:
UserService: User CRUD and managementTokenService: JWT and refresh token lifecycleOrganisationService: Organisation managementRoleService: Role and permission managementTeamService: Team membershipClientService: OAuth client managementMfaService: Multi-factor authenticationApiKeyService: API key validationWebhookService: Webhook deliveryAuditLogService: Audit loggingJwkService: JWK key rotation
Auth Utilities (src/auth/)
Authentication-specific logic:
jwt.ts: Sign and verify JWTs usingjoselibrarypassword.ts: Argon2 password hashingsessions.ts: Session CRUD operationsmfa.ts: TOTP generation and verificationjwks.ts: JWKS endpoint helpers
Database (src/db/)
Single Prisma client instance:
// src/db/prisma.ts
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient({
log: ['query', 'error', 'warn'],
});Utilities (src/utils/)
Pure functions for common operations:
crypto.ts: Cryptographic operations (hash, encrypt, token generation)problem.ts: RFC 7807 Problem Details constructorsaudit.ts: Audit log creation helpersids.ts: UUID generation
Types (src/types/)
Centralized TypeScript type definitions:
// src/types/auth.types.ts
export interface JWTPayload {
sub: string;
org: string;
roles: string[];
scope: string;
}
// src/types/index.ts
export * from './auth.types';
export * from './token.types';
// ... re-export all typesFile Naming Conventions
Source Files
- Modules:
kebab-case.ts(e.g.,rate-limit.ts) - Services:
*.service.ts(e.g.,user.service.ts) - Controllers:
*.controller.ts(e.g.,user.controller.ts) - Middleware:
camelCase.ts(e.g.,authn.ts,tenant.ts) - Types:
*.types.ts(e.g.,auth.types.ts)
Test Files
- Unit tests:
*.unit.test.ts(co-located with source) - Integration tests:
*.integration.test.ts(co-located with source) - E2E tests:
*.e2e.test.ts(ine2e/directory)
Configuration Files
- TypeScript:
tsconfig.json,tsconfig.test.json - Jest:
jest.config.ts,jest.setup.ts - ESLint:
eslint.config.js(flat config) - Prettier:
.prettierrc
Import Path Aliases
TypeScript path alias @/* maps to src/*:
// tsconfig.json
{
"compilerOptions": {
"baseUrl": "src",
"paths": {
"@/*": ["./*"]
}
}
}Usage:
import { userService } from '@/services/user.service';
import { requirePerm } from '@/middleware/rbac';
import { config } from '@/config';Code Organization Best Practices
1. Single Responsibility
Each file has one clear purpose. Controllers handle HTTP, services handle business logic, utilities are pure functions.
2. Co-located Tests
Tests live next to the code they test:
src/services/
├── user.service.ts
├── user.service.unit.test.ts
└── user.service.integration.test.ts3. Explicit Exports
Services and utilities export named instances:
export class UserService {
/* ... */
}
export const userService = new UserService();4. Barrel Exports
Type definitions use barrel exports:
// src/types/index.ts
export * from './auth.types';
export * from './token.types';
// Usage
import { JWTPayload, TokenPair } from '@/types';5. Layered Dependencies
Dependency flow is always downward:
Routes → Controllers → Services → Prisma → Database
↓ ↓ ↓
Middleware Utils Auth UtilsNever:
- Services importing controllers
- Utils importing services
- Circular dependencies
Build Output (dist/)
TypeScript compiles to CommonJS in dist/:
dist/
├── app.js
├── server.js
├── config/
│ └── index.js
├── middleware/
│ ├── authn.js
│ └── ...
└── ... (mirrors src/ structure)Production Start:
npm run build # tsc -p tsconfig.json
npm start # node dist/server.jsEnvironment Files
.env (gitignored)
Actual secrets and configuration:
NODE_ENV=development
DATABASE_URL=postgresql://user:pass@localhost:5432/cerberus
SECRET_ENCRYPTION_KEY=base64-encoded-key
JWT_ALG=EdDSA.env.example (committed)
Template for required variables:
NODE_ENV=development
PORT=4000
DATABASE_URL=postgresql://user:pass@localhost:5432/cerberus_dev
SECRET_ENCRYPTION_KEY=<generate-with-npm-run-key:generate>