Initial commit

This commit is contained in:
2026-03-10 21:30:52 -03:00
commit 72a4f0be26
145 changed files with 14881 additions and 0 deletions

25
apps/api/.env.example Normal file
View File

@@ -0,0 +1,25 @@
# Application
NODE_ENV=development
PORT=3001
HOST=0.0.0.0
# Database (see packages/db/.env.example)
DATABASE_URL="postgresql://dwellops:dwellops@localhost:5432/dwellops_dev?schema=public"
# Auth
BETTER_AUTH_SECRET="change-me-in-production-use-openssl-rand-base64-32"
BETTER_AUTH_URL="http://localhost:3001"
# CORS
CORS_ORIGIN="http://localhost:3000"
# Email / Mailpit (local dev)
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_FROM="noreply@dwellops.local"
# OIDC (optional — set OIDC_ENABLED=true to activate)
OIDC_ENABLED=false
# OIDC_ISSUER=https://your-idp.example.com
# OIDC_CLIENT_ID=your-client-id
# OIDC_CLIENT_SECRET=your-client-secret

View File

@@ -0,0 +1,3 @@
import { base } from '@dwellops/config/eslint';
export default base;

45
apps/api/package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "@dwellops/api",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc --noEmit",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"test": "vitest run --coverage",
"test:watch": "vitest"
},
"dependencies": {
"fastify": "catalog:",
"@fastify/cors": "catalog:",
"@fastify/helmet": "catalog:",
"@fastify/swagger": "catalog:",
"@fastify/swagger-ui": "catalog:",
"@fastify/env": "catalog:",
"@fastify/rate-limit": "catalog:",
"zod": "catalog:",
"pino": "catalog:",
"better-auth": "catalog:",
"fastify-plugin": "catalog:",
"@dwellops/db": "workspace:*",
"@dwellops/types": "workspace:*",
"@dwellops/schemas": "workspace:*"
},
"devDependencies": {
"typescript": "catalog:",
"tsx": "catalog:",
"@types/node": "catalog:",
"vitest": "catalog:",
"@vitest/coverage-v8": "catalog:",
"pino-pretty": "catalog:",
"eslint": "catalog:",
"typescript-eslint": "catalog:",
"@dwellops/config": "workspace:*",
"@dwellops/test-utils": "workspace:*"
}
}

89
apps/api/src/app.ts Normal file
View File

@@ -0,0 +1,89 @@
import Fastify, { type FastifyError } from 'fastify';
import helmet from '@fastify/helmet';
import rateLimit from '@fastify/rate-limit';
import { corsPlugin } from './plugins/cors';
import { swaggerPlugin } from './plugins/swagger';
import { authPlugin } from './plugins/auth';
import { healthRoutes } from './modules/health/health.routes';
import { authRoutes } from './modules/auth/auth.routes';
import { hoaRoutes } from './modules/hoa/hoa.routes';
import { AppError } from './lib/errors';
import { env } from './lib/env';
/**
* Creates and configures the Fastify application.
* Separated from index.ts to support testing without binding to a port.
*/
export async function buildApp() {
const app = Fastify({
logger: {
level: env.NODE_ENV === 'production' ? 'info' : 'debug',
...(env.NODE_ENV !== 'production' && {
transport: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname',
},
},
}),
},
trustProxy: true,
});
// Security headers
await app.register(helmet, {
contentSecurityPolicy: false, // Managed at edge/CDN in production
});
// Rate limiting
await app.register(rateLimit, {
max: 100,
timeWindow: '1 minute',
});
// CORS
await app.register(corsPlugin);
// API docs (must be before route registration)
await app.register(swaggerPlugin);
// Session resolution (populates request.session)
await app.register(authPlugin);
// Routes
await app.register(healthRoutes);
await app.register(authRoutes);
await app.register(hoaRoutes);
// Global error handler — converts AppError to structured JSON response.
app.setErrorHandler((error: FastifyError, _request, reply) => {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
statusCode: error.statusCode,
code: error.code,
message: error.message,
});
}
// Fastify validation error
if ('validation' in error && error.validation) {
return reply.status(400).send({
statusCode: 400,
code: 'VALIDATION_ERROR',
message: 'Request validation failed',
details: error.validation,
});
}
app.log.error({ err: error }, 'Unhandled error');
return reply.status(500).send({
statusCode: 500,
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
});
});
return app;
}

26
apps/api/src/index.ts Normal file
View File

@@ -0,0 +1,26 @@
import { buildApp } from './app';
import { env } from './lib/env';
import { logger } from './lib/logger';
async function start(): Promise<void> {
const app = await buildApp();
try {
await app.listen({ port: env.PORT, host: env.HOST });
logger.info({ port: env.PORT }, 'DwellOps API is running');
} catch (err) {
logger.error({ err }, 'Failed to start server');
process.exit(1);
}
}
// Handle graceful shutdown
process.on('SIGTERM', () => {
logger.info('SIGTERM received — shutting down gracefully');
process.exit(0);
});
start().catch((err) => {
logger.error({ err }, 'Startup error');
process.exit(1);
});

57
apps/api/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,57 @@
import { betterAuth } from 'better-auth';
import { prismaAdapter } from 'better-auth/adapters/prisma';
import { magicLink, oidcProvider } from 'better-auth/plugins';
import { prisma } from '@dwellops/db';
import { env } from './env';
import { logger } from './logger';
/**
* Plugins array, conditionally extended with OIDC if enabled.
*
* NOTE: Passkey/WebAuthn support is not yet available in better-auth 1.5.x.
* Track https://github.com/better-auth/better-auth for availability.
* When released, import from 'better-auth/plugins' and add to this array.
*/
const plugins: Parameters<typeof betterAuth>[0]['plugins'] = [
magicLink({
sendMagicLink: async ({ email, url }) => {
// In production, wire this to your transactional email provider (e.g. nodemailer via SMTP).
// In development, Mailpit captures the email at http://localhost:8025.
logger.info({ email, url }, 'Magic link requested');
},
}),
];
if (env.OIDC_ENABLED) {
if (!env.OIDC_ISSUER || !env.OIDC_CLIENT_ID || !env.OIDC_CLIENT_SECRET) {
throw new Error(
'OIDC_ENABLED=true but OIDC_ISSUER, OIDC_CLIENT_ID, or OIDC_CLIENT_SECRET is missing.',
);
}
plugins.push(
oidcProvider({
loginPage: '/auth/login',
}),
);
}
/**
* Configured Better Auth instance.
*
* Supported flows:
* - Magic link (always enabled)
* - OIDC (optional, controlled by OIDC_ENABLED env var)
*
* Passkey/WebAuthn: planned, pending better-auth plugin availability.
* Email/password login is intentionally disabled.
*/
export const auth = betterAuth({
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL,
basePath: '/api/auth',
database: prismaAdapter(prisma, { provider: 'postgresql' }),
emailAndPassword: { enabled: false },
plugins,
trustedOrigins: [env.CORS_ORIGIN],
});

46
apps/api/src/lib/env.ts Normal file
View File

@@ -0,0 +1,46 @@
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3001),
HOST: z.string().default('0.0.0.0'),
DATABASE_URL: z.string().url(),
BETTER_AUTH_SECRET: z.string().min(32),
BETTER_AUTH_URL: z.string().url(),
CORS_ORIGIN: z.string().default('http://localhost:3000'),
SMTP_HOST: z.string().default('localhost'),
SMTP_PORT: z.coerce.number().int().default(1025),
SMTP_FROM: z.string().email().default('noreply@dwellops.local'),
OIDC_ENABLED: z
.string()
.default('false')
.transform((v) => v === 'true'),
OIDC_ISSUER: z.string().url().optional(),
OIDC_CLIENT_ID: z.string().optional(),
OIDC_CLIENT_SECRET: z.string().optional(),
});
export type Env = z.infer<typeof envSchema>;
/**
* Validates and returns the current environment variables.
* Throws a descriptive error on startup if required variables are missing.
*/
export function validateEnv(): Env {
const result = envSchema.safeParse(process.env);
if (!result.success) {
throw new Error(
`Invalid environment variables:\n${result.error.issues
.map((i) => ` ${i.path.join('.')}: ${i.message}`)
.join('\n')}`,
);
}
return result.data;
}
export const env = validateEnv();

View File

@@ -0,0 +1,54 @@
/**
* Typed application error hierarchy.
* Use these instead of raw Error objects so callers can identify error kinds.
*/
export class AppError extends Error {
/** HTTP status code to return. */
readonly statusCode: number;
/** Machine-readable error code. */
readonly code: string;
constructor(message: string, statusCode = 500, code = 'INTERNAL_ERROR') {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.code = code;
Error.captureStackTrace(this, this.constructor);
}
}
/** 400 — caller sent invalid data. */
export class ValidationError extends AppError {
constructor(message: string, code = 'VALIDATION_ERROR') {
super(message, 400, code);
}
}
/** 401 — request is not authenticated. */
export class UnauthenticatedError extends AppError {
constructor(message = 'Authentication required') {
super(message, 401, 'UNAUTHENTICATED');
}
}
/** 403 — authenticated but not permitted. */
export class ForbiddenError extends AppError {
constructor(message = 'You do not have permission to perform this action') {
super(message, 403, 'FORBIDDEN');
}
}
/** 404 — requested resource does not exist. */
export class NotFoundError extends AppError {
constructor(resource: string, id?: string) {
super(id ? `${resource} '${id}' not found` : `${resource} not found`, 404, 'NOT_FOUND');
}
}
/** 409 — conflict with existing data. */
export class ConflictError extends AppError {
constructor(message: string) {
super(message, 409, 'CONFLICT');
}
}

View File

@@ -0,0 +1,20 @@
import pino from 'pino';
import { env } from './env';
/**
* Application-level Pino logger.
* Uses pretty-printing in development, structured JSON in production.
*/
export const logger = pino({
level: env.NODE_ENV === 'production' ? 'info' : 'debug',
...(env.NODE_ENV !== 'production' && {
transport: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname',
},
},
}),
});

View File

@@ -0,0 +1,17 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { UnauthenticatedError } from './errors';
/**
* Fastify preHandler that enforces authentication.
* Throws UnauthenticatedError (401) if no valid session is present.
*
* @example
* ```ts
* fastify.get('/me', { preHandler: [requireAuth] }, handler);
* ```
*/
export async function requireAuth(request: FastifyRequest, _reply: FastifyReply): Promise<void> {
if (!request.session) {
throw new UnauthenticatedError();
}
}

View File

@@ -0,0 +1,10 @@
/**
* Vitest global setup for apps/api.
* Sets minimum required env vars before any test module is loaded.
*/
process.env['NODE_ENV'] = 'test';
process.env['DATABASE_URL'] = 'postgresql://test:test@localhost:5432/dwellops_test';
process.env['BETTER_AUTH_SECRET'] = 'test-secret-at-least-32-characters-long!';
process.env['BETTER_AUTH_URL'] = 'http://localhost:3001';
process.env['PORT'] = '3001';

View File

@@ -0,0 +1,41 @@
import type { FastifyInstance } from 'fastify';
import { auth } from '../../lib/auth';
/**
* Auth module routes.
*
* All /api/auth/* requests are forwarded to the Better Auth handler.
* Better Auth handles magic link, passkey, and optional OIDC flows.
*
* Better Auth uses Web standard Request/Response. We bridge from Fastify
* via the Node.js http.IncomingMessage/ServerResponse adapters.
*/
export async function authRoutes(app: FastifyInstance): Promise<void> {
/**
* Wildcard handler — forwards all /api/auth/* requests to Better Auth.
* The `*` wildcard covers all sub-paths and methods.
*/
app.all('/api/auth/*', async (request, reply) => {
const nodeHandler = auth.handler;
// Build a Web standard Request from the Fastify raw request.
const url = `${request.protocol}://${request.hostname}${request.url}`;
const webRequest = new Request(url, {
method: request.method,
headers: request.headers as Record<string, string>,
body: ['GET', 'HEAD'].includes(request.method)
? undefined
: JSON.stringify(request.body),
});
const response = await nodeHandler(webRequest);
reply.status(response.status);
response.headers.forEach((value, key) => {
reply.header(key, value);
});
const body = await response.text();
return reply.send(body);
});
}

View File

@@ -0,0 +1,75 @@
import type { FastifyInstance } from 'fastify';
import { prisma } from '@dwellops/db';
/**
* Health check module.
* GET /health — shallow liveness
* GET /health/ready — readiness including DB connectivity
*/
export async function healthRoutes(app: FastifyInstance): Promise<void> {
app.get(
'/health',
{
schema: {
tags: ['Health'],
summary: 'Liveness probe',
response: {
200: {
type: 'object',
properties: {
status: { type: 'string' },
timestamp: { type: 'string' },
},
},
},
},
},
async (_req, reply) => {
return reply.send({ status: 'ok', timestamp: new Date().toISOString() });
},
);
app.get(
'/health/ready',
{
schema: {
tags: ['Health'],
summary: 'Readiness probe — includes database check',
response: {
200: {
type: 'object',
properties: {
status: { type: 'string' },
db: { type: 'string' },
timestamp: { type: 'string' },
},
},
503: {
type: 'object',
properties: {
status: { type: 'string' },
db: { type: 'string' },
error: { type: 'string' },
},
},
},
},
},
async (_req, reply) => {
try {
await prisma.$queryRaw`SELECT 1`;
return reply.send({
status: 'ok',
db: 'connected',
timestamp: new Date().toISOString(),
});
} catch (err) {
return reply.status(503).send({
status: 'error',
db: 'unavailable',
error: String(err instanceof Error ? err.message : err),
});
}
},
);
}

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import Fastify from 'fastify';
import { healthRoutes } from './health.routes';
// vi.mock is hoisted — factory must not reference outer variables.
vi.mock('@dwellops/db', () => ({
prisma: {
$queryRaw: vi.fn().mockResolvedValue([{ 1: 1 }]),
},
}));
// Access the mock AFTER the vi.mock call.
const { prisma } = await import('@dwellops/db');
/**
* Returns a fresh Fastify instance with health routes registered.
* A new instance is required per test because Fastify cannot register
* plugins onto an already-booted server.
*/
async function buildTestApp() {
const app = Fastify({ logger: false });
await app.register(healthRoutes);
await app.ready();
return app;
}
describe('Health routes', () => {
beforeEach(() => {
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ 1: 1 }]);
});
it('GET /health returns 200 with status ok', async () => {
const app = await buildTestApp();
const response = await app.inject({ method: 'GET', url: '/health' });
expect(response.statusCode).toBe(200);
const body = response.json<{ status: string }>();
expect(body.status).toBe('ok');
await app.close();
});
it('GET /health/ready returns 200 when DB is up', async () => {
const app = await buildTestApp();
const response = await app.inject({ method: 'GET', url: '/health/ready' });
expect(response.statusCode).toBe(200);
const body = response.json<{ db: string }>();
expect(body.db).toBe('connected');
await app.close();
});
it('GET /health/ready returns 503 when DB is down with an Error', async () => {
vi.mocked(prisma.$queryRaw).mockRejectedValueOnce(new Error('Connection refused'));
const app = await buildTestApp();
const response = await app.inject({ method: 'GET', url: '/health/ready' });
expect(response.statusCode).toBe(503);
const body = response.json<{ status: string; db: string; error: string }>();
expect(body.status).toBe('error');
expect(body.db).toBe('unavailable');
expect(body.error).toBe('Connection refused');
await app.close();
});
it('GET /health/ready returns 503 when DB is down with a non-Error', async () => {
vi.mocked(prisma.$queryRaw).mockRejectedValueOnce('string error');
const app = await buildTestApp();
const response = await app.inject({ method: 'GET', url: '/health/ready' });
expect(response.statusCode).toBe(503);
const body = response.json<{ status: string }>();
expect(body.status).toBe('error');
await app.close();
});
});

View File

@@ -0,0 +1,105 @@
import type { FastifyInstance } from 'fastify';
import { requireAuth } from '../../lib/require-auth';
import { NotFoundError, ForbiddenError } from '../../lib/errors';
import { prisma } from '@dwellops/db';
/**
* HOA module routes — demonstrates a permission-aware, audited route structure.
*
* Pattern:
* - Thin route handlers: validate → check auth/permissions → call service → shape response.
* - Business logic lives in services, not here.
*/
export async function hoaRoutes(app: FastifyInstance): Promise<void> {
/**
* GET /api/hoas — list HOAs the authenticated user is a member of.
*/
app.get(
'/api/hoas',
{
preHandler: [requireAuth],
schema: {
tags: ['HOA'],
summary: 'List HOAs for the current user',
security: [{ sessionCookie: [] }],
response: {
200: {
type: 'object',
properties: {
data: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
slug: { type: 'string' },
role: { type: 'string' },
},
},
},
},
},
},
},
},
async (request, reply) => {
const userId = request.session!.user.id;
const memberships = await prisma.membership.findMany({
where: { userId },
include: { hoa: true },
orderBy: { createdAt: 'asc' },
});
type MembershipWithHoa = (typeof memberships)[number];
return reply.send({
data: memberships.map((m: MembershipWithHoa) => ({
id: m.hoa.id,
name: m.hoa.name,
slug: m.hoa.slug,
role: m.role,
})),
});
},
);
/**
* GET /api/hoas/:hoaId — get HOA details (member or admin only).
*/
app.get(
'/api/hoas/:hoaId',
{
preHandler: [requireAuth],
schema: {
tags: ['HOA'],
summary: 'Get HOA by ID',
security: [{ sessionCookie: [] }],
params: {
type: 'object',
properties: { hoaId: { type: 'string' } },
required: ['hoaId'],
},
},
},
async (request, reply) => {
const { hoaId } = request.params as { hoaId: string };
const userId = request.session!.user.id;
const membership = await prisma.membership.findFirst({
where: { userId, hoaId },
});
if (!membership) {
throw new ForbiddenError();
}
const hoa = await prisma.hoa.findUnique({ where: { id: hoaId } });
if (!hoa) {
throw new NotFoundError('HOA', hoaId);
}
return reply.send({ data: hoa });
},
);
}

View File

@@ -0,0 +1,45 @@
import type { FastifyInstance } from 'fastify';
import fp from 'fastify-plugin';
import { auth } from '../lib/auth';
import type { RequestSession } from '@dwellops/types';
declare module 'fastify' {
interface FastifyRequest {
/** Resolved session from Better Auth, or null if unauthenticated. */
session: RequestSession | null;
}
}
/**
* Fastify plugin that resolves the Better Auth session on every request
* and attaches it to `request.session`.
*
* Does NOT enforce authentication — individual routes must check
* `request.session` or use the `requireAuth` preHandler.
*/
export const authPlugin = fp(async (app: FastifyInstance) => {
app.decorateRequest('session', null);
app.addHook('preHandler', async (request) => {
try {
const session = await auth.api.getSession({
headers: request.headers as unknown as Headers,
});
if (session) {
request.session = {
user: {
id: session.user.id as RequestSession['user']['id'],
email: session.user.email,
name: session.user.name ?? null,
emailVerified: session.user.emailVerified,
image: session.user.image ?? null,
},
sessionId: session.session.id,
expiresAt: session.session.expiresAt,
};
}
} catch {
// No valid session — leave request.session as null.
}
});
});

View File

@@ -0,0 +1,15 @@
import type { FastifyInstance } from 'fastify';
import cors from '@fastify/cors';
import { env } from '../lib/env';
/**
* Registers CORS plugin.
* Origin is controlled by the CORS_ORIGIN environment variable.
*/
export async function corsPlugin(app: FastifyInstance): Promise<void> {
await app.register(cors, {
origin: env.CORS_ORIGIN.split(',').map((o) => o.trim()),
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
});
}

View File

@@ -0,0 +1,41 @@
import type { FastifyInstance } from 'fastify';
import swagger from '@fastify/swagger';
import swaggerUi from '@fastify/swagger-ui';
/**
* Registers Swagger and Swagger UI plugins.
* API docs are served at /documentation.
*/
export async function swaggerPlugin(app: FastifyInstance): Promise<void> {
await app.register(swagger, {
openapi: {
info: {
title: 'DwellOps API',
description: 'HOA management platform API',
version: '1.0.0',
},
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
},
sessionCookie: {
type: 'apiKey',
in: 'cookie',
name: 'better-auth.session',
},
},
},
},
});
await app.register(swaggerUi, {
routePrefix: '/documentation',
uiConfig: {
docExpansion: 'list',
deepLinking: true,
},
});
}

View File

@@ -0,0 +1,47 @@
import type { db as DbType } from '@dwellops/db';
import type { AuditAction } from '@dwellops/types';
import { logger } from '../lib/logger';
export interface AuditLogInput {
userId?: string;
action: AuditAction | string;
entityType: string;
entityId?: string;
payload?: Record<string, unknown>;
ipAddress?: string;
userAgent?: string;
}
/**
* Service responsible for recording audit log entries.
*
* All destructive or sensitive actions must be recorded here.
* The service never throws — failures are logged but do not
* propagate to prevent audit failures from blocking primary operations.
*/
export class AuditService {
constructor(private readonly db: typeof DbType) {}
/**
* Records an audit log entry.
*
* @param input - The audit log entry data.
*/
async record(input: AuditLogInput): Promise<void> {
try {
await this.db.auditLog.create({
data: {
userId: input.userId,
action: input.action,
entityType: input.entityType,
entityId: input.entityId,
payload: (input.payload ?? undefined) as object | undefined,
ipAddress: input.ipAddress,
userAgent: input.userAgent,
},
});
} catch (err) {
logger.error({ err, input }, 'Failed to write audit log entry');
}
}
}

12
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "@dwellops/config/tsconfig/node.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"noEmit": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

File diff suppressed because one or more lines are too long

26
apps/api/vitest.config.ts Normal file
View File

@@ -0,0 +1,26 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
setupFiles: ['./src/lib/test-setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
thresholds: {
lines: 85,
functions: 85,
branches: 85,
statements: 85,
},
exclude: [
'**/node_modules/**',
'**/dist/**',
'**/*.d.ts',
'**/*.config.*',
'**/index.ts',
],
},
},
});