refactor: migrate backend to pnpm monorepo with Hono module architecture
- Replace Bun with pnpm 9 + Turborepo + tsx; apps/api renamed to apps/backend - Split backend into http/, modules/, lib/ layers mirroring tai-specguard - Add use-case + Result pattern, Problem Details RFC 7807, basePath /api/v1 - Mount Better Auth at /api/v1/auth, keep session-auth whitelist - Split Prisma schema into prisma/models/*, generate into generated/ - Rework packages/shared into lib/ + schemas/ with pagination DTOs - Implement health, groups, students, payments, waitlist modules - Add vitest suite with prisma mocks (21 tests), biome lint - Point web client to /api/v1/auth and /api/v1/groups/from-organization
This commit is contained in:
19
apps/backend/src/http/env.ts
Normal file
19
apps/backend/src/http/env.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { auth } from '@/modules/auth/auth';
|
||||
|
||||
type AuthSession = typeof auth.$Infer.Session;
|
||||
|
||||
export type BackendEnv = {
|
||||
Variables: {
|
||||
user: AuthSession['user'] | null;
|
||||
session: AuthSession['session'] | null;
|
||||
requestId: string;
|
||||
};
|
||||
};
|
||||
|
||||
declare module 'hono' {
|
||||
interface ContextVariableMap {
|
||||
user: AuthSession['user'] | null;
|
||||
session: AuthSession['session'] | null;
|
||||
requestId: string;
|
||||
}
|
||||
}
|
||||
89
apps/backend/src/http/problem-builders.ts
Normal file
89
apps/backend/src/http/problem-builders.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { ProblemDetails } from '@gruperly/shared';
|
||||
import { notFoundProblem } from './problem-details';
|
||||
import { PROBLEM_DOMAIN } from './problem-domain';
|
||||
|
||||
export function validationProblem(params: {
|
||||
detail?: string;
|
||||
instance?: string;
|
||||
errors?: Record<string, string[]>;
|
||||
}): ProblemDetails {
|
||||
return {
|
||||
type: `${PROBLEM_DOMAIN}/problems/validation`,
|
||||
title: 'Bad Request',
|
||||
status: 400,
|
||||
detail: params.detail ?? 'Invalid request data',
|
||||
instance: params.instance,
|
||||
errors: params.errors,
|
||||
};
|
||||
}
|
||||
|
||||
export function conflictProblem(params: {
|
||||
detail: string;
|
||||
code?: string;
|
||||
instance?: string;
|
||||
}): ProblemDetails {
|
||||
return {
|
||||
type: `${PROBLEM_DOMAIN}/problems/conflict`,
|
||||
title: 'Conflict',
|
||||
status: 409,
|
||||
detail: params.detail,
|
||||
instance: params.instance,
|
||||
code: params.code,
|
||||
};
|
||||
}
|
||||
|
||||
export function forbiddenProblem(params: {
|
||||
detail: string;
|
||||
code?: string;
|
||||
instance?: string;
|
||||
}): ProblemDetails {
|
||||
return {
|
||||
type: `${PROBLEM_DOMAIN}/problems/forbidden`,
|
||||
title: 'Forbidden',
|
||||
status: 403,
|
||||
detail: params.detail,
|
||||
instance: params.instance,
|
||||
code: params.code,
|
||||
};
|
||||
}
|
||||
|
||||
export function organizationNotFoundProblem(id: string): ProblemDetails {
|
||||
return {
|
||||
type: `${PROBLEM_DOMAIN}/problems/organization-not-found`,
|
||||
title: 'Not Found',
|
||||
status: 404,
|
||||
detail: `Organization ${id} was not found.`,
|
||||
code: 'organization_not_found',
|
||||
};
|
||||
}
|
||||
|
||||
export function groupOwnerRequiredProblem(): ProblemDetails {
|
||||
return forbiddenProblem({
|
||||
detail: 'Only the organization owner can create the group.',
|
||||
code: 'group_owner_required',
|
||||
});
|
||||
}
|
||||
|
||||
export function databaseUnavailableProblem(params?: {
|
||||
instance?: string;
|
||||
}): ProblemDetails {
|
||||
return {
|
||||
type: `${PROBLEM_DOMAIN}/problems/database-unavailable`,
|
||||
title: 'Service Unavailable',
|
||||
status: 503,
|
||||
detail: 'Database health check failed',
|
||||
instance: params?.instance,
|
||||
code: 'database_unavailable',
|
||||
};
|
||||
}
|
||||
|
||||
export function noGroupAccessProblem(): ProblemDetails {
|
||||
return forbiddenProblem({
|
||||
detail: 'You do not have access to this group.',
|
||||
code: 'group_access_denied',
|
||||
});
|
||||
}
|
||||
|
||||
export function notFoundResourceProblem(resourceName: string, id: string): ProblemDetails {
|
||||
return notFoundProblem(`${resourceName} ${id}`);
|
||||
}
|
||||
82
apps/backend/src/http/problem-details.ts
Normal file
82
apps/backend/src/http/problem-details.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { ProblemDetails, Result } from '@gruperly/shared';
|
||||
import type { Context } from 'hono';
|
||||
import type {
|
||||
ClientErrorStatusCode,
|
||||
ServerErrorStatusCode,
|
||||
SuccessStatusCode,
|
||||
} from 'hono/utils/http-status';
|
||||
|
||||
type ProblemStatusCode = ClientErrorStatusCode | ServerErrorStatusCode;
|
||||
type JsonSuccessStatusCode = Exclude<SuccessStatusCode, 204 | 205>;
|
||||
|
||||
type ProblemDetailsInput = Omit<ProblemDetails, 'status' | 'title'> & {
|
||||
status: ProblemStatusCode;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type ResultJsonOptions = {
|
||||
status?: JsonSuccessStatusCode;
|
||||
};
|
||||
|
||||
const DEFAULT_TYPE = 'about:blank';
|
||||
|
||||
export const problemDetails = ({
|
||||
type = DEFAULT_TYPE,
|
||||
title,
|
||||
status,
|
||||
detail,
|
||||
instance,
|
||||
code,
|
||||
}: ProblemDetailsInput): ProblemDetails => ({
|
||||
type,
|
||||
title,
|
||||
status,
|
||||
...(detail ? { detail } : {}),
|
||||
...(instance ? { instance } : {}),
|
||||
...(code ? { code } : {}),
|
||||
});
|
||||
|
||||
export const problemJson = (c: Context, problem: ProblemDetails) => {
|
||||
return c.json(problem, problem.status as ProblemStatusCode, {
|
||||
'Content-Type': 'application/problem+json',
|
||||
});
|
||||
};
|
||||
|
||||
export const resultJson = <TValue>(
|
||||
c: Context,
|
||||
result: Result<TValue, ProblemDetails>,
|
||||
{ status = 200 }: ResultJsonOptions = {},
|
||||
) => {
|
||||
if (!result.ok) {
|
||||
return problemJson(c, result.error);
|
||||
}
|
||||
|
||||
return c.json(result.value, status);
|
||||
};
|
||||
|
||||
export const notFoundProblem = (instance: string): ProblemDetails =>
|
||||
problemDetails({
|
||||
title: 'Not Found',
|
||||
status: 404,
|
||||
detail: 'The requested resource was not found.',
|
||||
instance,
|
||||
code: 'not_found',
|
||||
});
|
||||
|
||||
export const internalServerErrorProblem = (instance: string): ProblemDetails =>
|
||||
problemDetails({
|
||||
title: 'Internal Server Error',
|
||||
status: 500,
|
||||
detail: 'An unexpected error occurred.',
|
||||
instance,
|
||||
code: 'internal_server_error',
|
||||
});
|
||||
|
||||
export const unauthorizedProblem = (instance: string): ProblemDetails =>
|
||||
problemDetails({
|
||||
title: 'Unauthorized',
|
||||
status: 401,
|
||||
detail: 'Authentication is required to access this resource.',
|
||||
instance,
|
||||
code: 'unauthorized',
|
||||
});
|
||||
1
apps/backend/src/http/problem-domain.ts
Normal file
1
apps/backend/src/http/problem-domain.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const PROBLEM_DOMAIN = process.env.PROBLEM_DOMAIN_URL ?? 'https://gruperly.com';
|
||||
12
apps/backend/src/http/request-id.ts
Normal file
12
apps/backend/src/http/request-id.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { runWithRequestLogContext } from '@/logger';
|
||||
import type { BackendEnv } from './env';
|
||||
|
||||
export const requestIdMiddleware: MiddlewareHandler<BackendEnv> = async (c, next) => {
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
c.set('requestId', requestId);
|
||||
c.header('X-Request-Id', requestId);
|
||||
|
||||
await runWithRequestLogContext(requestId, next);
|
||||
};
|
||||
22
apps/backend/src/http/request-logger.ts
Normal file
22
apps/backend/src/http/request-logger.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { logger } from '@/logger';
|
||||
import type { BackendEnv } from './env';
|
||||
|
||||
export const requestLoggerMiddleware: MiddlewareHandler<BackendEnv> = async (c, next) => {
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
await next();
|
||||
} finally {
|
||||
logger.info(
|
||||
{
|
||||
method: c.req.method,
|
||||
path: c.req.path,
|
||||
requestId: c.get('requestId'),
|
||||
status: c.res.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
'Request completed',
|
||||
);
|
||||
}
|
||||
};
|
||||
34
apps/backend/src/http/security-headers.ts
Normal file
34
apps/backend/src/http/security-headers.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { cors as honoCors } from 'hono/cors';
|
||||
|
||||
function getWebOrigin(): string {
|
||||
return process.env.WEB_URL ?? 'http://localhost:6173';
|
||||
}
|
||||
|
||||
export const corsMiddleware: MiddlewareHandler = honoCors({
|
||||
origin: (requestOrigin) => {
|
||||
const allowed = getWebOrigin();
|
||||
if (requestOrigin === allowed) {
|
||||
return requestOrigin;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type', 'Authorization'],
|
||||
maxAge: 600,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
export const securityHeadersMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
await next();
|
||||
|
||||
c.header('X-Content-Type-Options', 'nosniff');
|
||||
c.header('X-Frame-Options', 'DENY');
|
||||
c.header('X-XSS-Protection', '0');
|
||||
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
c.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
};
|
||||
43
apps/backend/src/http/session-auth.ts
Normal file
43
apps/backend/src/http/session-auth.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { auth } from '@/modules/auth/auth';
|
||||
import type { BackendEnv } from './env';
|
||||
import { problemJson, unauthorizedProblem } from './problem-details';
|
||||
|
||||
export const sessionAuthMiddleware: MiddlewareHandler<BackendEnv> = async (c, next) => {
|
||||
if (isPublicApiRequest(c.req.method, c.req.path)) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
||||
if (!session) {
|
||||
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||
}
|
||||
|
||||
c.set('user', session.user);
|
||||
c.set('session', session.session);
|
||||
|
||||
await next();
|
||||
};
|
||||
|
||||
export function isPublicApiRequest(method: string, path: string): boolean {
|
||||
if (method === 'OPTIONS') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedPath = normalizePath(path);
|
||||
|
||||
return (
|
||||
normalizedPath === '/api/v1/health'
|
||||
|| matchesPublicPrefix(normalizedPath, '/api/v1/auth')
|
||||
|| matchesPublicPrefix(normalizedPath, '/api/auth')
|
||||
);
|
||||
}
|
||||
|
||||
function matchesPublicPrefix(path: string, prefix: string): boolean {
|
||||
return path === prefix || path.startsWith(`${prefix}/`);
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path;
|
||||
}
|
||||
57
apps/backend/src/http/validate.ts
Normal file
57
apps/backend/src/http/validate.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import type { Context } from 'hono';
|
||||
import type { ZodIssue, ZodSchema } from 'zod';
|
||||
import { validationProblem } from './problem-builders';
|
||||
import { zodIssuesToRecord } from './zod-issues';
|
||||
|
||||
type ValidationTarget = 'json' | 'query' | 'param' | 'header' | 'form';
|
||||
|
||||
function makeValidator(target: ValidationTarget, schema: ZodSchema) {
|
||||
return zValidator(
|
||||
target,
|
||||
schema,
|
||||
(result: { success: boolean; error?: { issues: ZodIssue[] } }, c: Context) => {
|
||||
if (result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = c.get('requestId');
|
||||
const instance = requestId ? `/requests/${requestId}` : undefined;
|
||||
const issues = result.error?.issues ?? [];
|
||||
|
||||
return c.json(
|
||||
validationProblem({
|
||||
detail: `Invalid ${target} data`,
|
||||
instance,
|
||||
errors: zodIssuesToRecord(issues),
|
||||
}),
|
||||
400,
|
||||
{
|
||||
'Content-Type': 'application/problem+json',
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const validate = {
|
||||
json(schema: ZodSchema) {
|
||||
return makeValidator('json', schema);
|
||||
},
|
||||
|
||||
query(schema: ZodSchema) {
|
||||
return makeValidator('query', schema);
|
||||
},
|
||||
|
||||
param(schema: ZodSchema) {
|
||||
return makeValidator('param', schema);
|
||||
},
|
||||
|
||||
header(schema: ZodSchema) {
|
||||
return makeValidator('header', schema);
|
||||
},
|
||||
|
||||
form(schema: ZodSchema) {
|
||||
return makeValidator('form', schema);
|
||||
},
|
||||
};
|
||||
16
apps/backend/src/http/zod-issues.ts
Normal file
16
apps/backend/src/http/zod-issues.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
type IssueLike = {
|
||||
path: PropertyKey[];
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function zodIssuesToRecord(issues: IssueLike[]): Record<string, string[]> {
|
||||
const out: Record<string, string[]> = {};
|
||||
|
||||
for (const issue of issues) {
|
||||
const key = issue.path.length ? issue.path.join('.') : 'root';
|
||||
out[key] ??= [];
|
||||
out[key].push(issue.message);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user