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

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';