From 3c9310f81cdc2983d1220496b1b8c39b79b22148 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 2 Jun 2026 11:59:52 +0200 Subject: [PATCH] =?UTF-8?q?fix(audit):=20critical=20C3=20=E2=80=94=20enfor?= =?UTF-8?q?ce=20residential=20module=20gate=20on=20all=20v1=20API=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds assertResidentialModuleEnabled(ctx.portId) as the first statement in every residential v1 handler (24 handlers across 13 files), mirroring the Tenancies pattern. Previously the disabled-module state was enforced only in the page layout, so a disabled module still accepted API writes (including partner-forward emails on residential interest creation). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/api/v1/residential/assignable-users/route.ts | 2 ++ src/app/api/v1/residential/clients/[id]/activity/route.ts | 2 ++ .../api/v1/residential/clients/[id]/notes/[noteId]/route.ts | 3 +++ src/app/api/v1/residential/clients/[id]/notes/route.ts | 3 +++ src/app/api/v1/residential/clients/[id]/route.ts | 4 ++++ src/app/api/v1/residential/clients/route.ts | 3 +++ src/app/api/v1/residential/interests/[id]/activity/route.ts | 2 ++ .../api/v1/residential/interests/[id]/notes/[noteId]/route.ts | 3 +++ src/app/api/v1/residential/interests/[id]/notes/route.ts | 3 +++ src/app/api/v1/residential/interests/[id]/route.ts | 4 ++++ src/app/api/v1/residential/interests/bulk/route.ts | 2 ++ src/app/api/v1/residential/interests/route.ts | 3 +++ src/app/api/v1/residential/stages/route.ts | 3 +++ 13 files changed, 37 insertions(+) diff --git a/src/app/api/v1/residential/assignable-users/route.ts b/src/app/api/v1/residential/assignable-users/route.ts index ca3a9cb9..64bff40d 100644 --- a/src/app/api/v1/residential/assignable-users/route.ts +++ b/src/app/api/v1/residential/assignable-users/route.ts @@ -5,6 +5,7 @@ import { withAuth, withPermission } from '@/lib/api/helpers'; import { db } from '@/lib/db'; import { roles, user, userPortRoles } from '@/lib/db/schema/users'; import { errorResponse } from '@/lib/errors'; +import { assertResidentialModuleEnabled } from '@/lib/services/residential-module.service'; /** * Returns the set of users in the current port who can be assigned a @@ -21,6 +22,7 @@ import { errorResponse } from '@/lib/errors'; export const GET = withAuth( withPermission('residential_interests', 'view', async (_req, ctx) => { try { + await assertResidentialModuleEnabled(ctx.portId); const rows = await db .selectDistinct({ id: user.id, diff --git a/src/app/api/v1/residential/clients/[id]/activity/route.ts b/src/app/api/v1/residential/clients/[id]/activity/route.ts index 40ce12dc..1b1a2078 100644 --- a/src/app/api/v1/residential/clients/[id]/activity/route.ts +++ b/src/app/api/v1/residential/clients/[id]/activity/route.ts @@ -5,11 +5,13 @@ import { withAuth, withPermission } from '@/lib/api/helpers'; import { db } from '@/lib/db'; import { residentialClients } from '@/lib/db/schema/residential'; import { loadEntityActivity } from '@/lib/services/entity-activity.service'; +import { assertResidentialModuleEnabled } from '@/lib/services/residential-module.service'; import { errorResponse, NotFoundError } from '@/lib/errors'; export const GET = withAuth( withPermission('residential_clients', 'view', async (_req, ctx, params) => { try { + await assertResidentialModuleEnabled(ctx.portId); const id = params.id; if (!id) throw new NotFoundError('residential client'); const exists = await db diff --git a/src/app/api/v1/residential/clients/[id]/notes/[noteId]/route.ts b/src/app/api/v1/residential/clients/[id]/notes/[noteId]/route.ts index 647d01a3..5cf5f266 100644 --- a/src/app/api/v1/residential/clients/[id]/notes/[noteId]/route.ts +++ b/src/app/api/v1/residential/clients/[id]/notes/[noteId]/route.ts @@ -4,11 +4,13 @@ import { withAuth, withPermission } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { updateNoteSchema } from '@/lib/validators/notes'; import * as notesService from '@/lib/services/notes.service'; +import { assertResidentialModuleEnabled } from '@/lib/services/residential-module.service'; import { errorResponse, NotFoundError } from '@/lib/errors'; export const PATCH = withAuth( withPermission('residential_clients', 'edit', async (req, ctx, params) => { try { + await assertResidentialModuleEnabled(ctx.portId); const id = params.id; const noteId = params.noteId; if (!id || !noteId) throw new NotFoundError('Residential client note'); @@ -24,6 +26,7 @@ export const PATCH = withAuth( export const DELETE = withAuth( withPermission('residential_clients', 'edit', async (_req, ctx, params) => { try { + await assertResidentialModuleEnabled(ctx.portId); const id = params.id; const noteId = params.noteId; if (!id || !noteId) throw new NotFoundError('Residential client note'); diff --git a/src/app/api/v1/residential/clients/[id]/notes/route.ts b/src/app/api/v1/residential/clients/[id]/notes/route.ts index c6a420e5..aabd67c7 100644 --- a/src/app/api/v1/residential/clients/[id]/notes/route.ts +++ b/src/app/api/v1/residential/clients/[id]/notes/route.ts @@ -4,11 +4,13 @@ import { withAuth, withPermission } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { createNoteSchema } from '@/lib/validators/notes'; import * as notesService from '@/lib/services/notes.service'; +import { assertResidentialModuleEnabled } from '@/lib/services/residential-module.service'; import { errorResponse, NotFoundError } from '@/lib/errors'; export const GET = withAuth( withPermission('residential_clients', 'view', async (req, ctx, params) => { try { + await assertResidentialModuleEnabled(ctx.portId); const id = params.id; if (!id) throw new NotFoundError('Residential client'); const aggregate = new URL(req.url).searchParams.get('aggregate') === 'true'; @@ -25,6 +27,7 @@ export const GET = withAuth( export const POST = withAuth( withPermission('residential_clients', 'edit', async (req, ctx, params) => { try { + await assertResidentialModuleEnabled(ctx.portId); const id = params.id; if (!id) throw new NotFoundError('Residential client'); const body = await parseBody(req, createNoteSchema); diff --git a/src/app/api/v1/residential/clients/[id]/route.ts b/src/app/api/v1/residential/clients/[id]/route.ts index 8c3e029b..c76cd14b 100644 --- a/src/app/api/v1/residential/clients/[id]/route.ts +++ b/src/app/api/v1/residential/clients/[id]/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { errorResponse } from '@/lib/errors'; +import { assertResidentialModuleEnabled } from '@/lib/services/residential-module.service'; import { archiveResidentialClient, getResidentialClientById, @@ -13,6 +14,7 @@ import { updateResidentialClientSchema } from '@/lib/validators/residential'; export const GET = withAuth( withPermission('residential_clients', 'view', async (req, ctx, params) => { try { + await assertResidentialModuleEnabled(ctx.portId); const client = await getResidentialClientById(params.id!, ctx.portId); return NextResponse.json({ data: client }); } catch (error) { @@ -24,6 +26,7 @@ export const GET = withAuth( export const PATCH = withAuth( withPermission('residential_clients', 'edit', async (req, ctx, params) => { try { + await assertResidentialModuleEnabled(ctx.portId); const body = await parseBody(req, updateResidentialClientSchema); const updated = await updateResidentialClient(params.id!, ctx.portId, body, { userId: ctx.userId, @@ -41,6 +44,7 @@ export const PATCH = withAuth( export const DELETE = withAuth( withPermission('residential_clients', 'delete', async (req, ctx, params) => { try { + await assertResidentialModuleEnabled(ctx.portId); await archiveResidentialClient(params.id!, ctx.portId, { userId: ctx.userId, portId: ctx.portId, diff --git a/src/app/api/v1/residential/clients/route.ts b/src/app/api/v1/residential/clients/route.ts index 7a84294a..a232ac6f 100644 --- a/src/app/api/v1/residential/clients/route.ts +++ b/src/app/api/v1/residential/clients/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers'; import { parseQuery, parseBody } from '@/lib/api/route-helpers'; import { errorResponse } from '@/lib/errors'; +import { assertResidentialModuleEnabled } from '@/lib/services/residential-module.service'; import { createResidentialClient, listResidentialClients, @@ -15,6 +16,7 @@ import { export const GET = withAuth( withPermission('residential_clients', 'view', async (req, ctx) => { try { + await assertResidentialModuleEnabled(ctx.portId); const query = parseQuery(req, listResidentialClientsSchema); const result = await listResidentialClients(ctx.portId, query); const { page, limit } = query; @@ -39,6 +41,7 @@ export const GET = withAuth( export const POST = withAuth( withPermission('residential_clients', 'create', async (req, ctx) => { try { + await assertResidentialModuleEnabled(ctx.portId); const body = await parseBody(req, createResidentialClientSchema); const client = await createResidentialClient(ctx.portId, body, { userId: ctx.userId, diff --git a/src/app/api/v1/residential/interests/[id]/activity/route.ts b/src/app/api/v1/residential/interests/[id]/activity/route.ts index ad70ae81..4ddbc51b 100644 --- a/src/app/api/v1/residential/interests/[id]/activity/route.ts +++ b/src/app/api/v1/residential/interests/[id]/activity/route.ts @@ -5,11 +5,13 @@ import { withAuth, withPermission } from '@/lib/api/helpers'; import { db } from '@/lib/db'; import { residentialInterests } from '@/lib/db/schema/residential'; import { loadEntityActivity } from '@/lib/services/entity-activity.service'; +import { assertResidentialModuleEnabled } from '@/lib/services/residential-module.service'; import { errorResponse, NotFoundError } from '@/lib/errors'; export const GET = withAuth( withPermission('residential_interests', 'view', async (_req, ctx, params) => { try { + await assertResidentialModuleEnabled(ctx.portId); const id = params.id; if (!id) throw new NotFoundError('residential interest'); const exists = await db diff --git a/src/app/api/v1/residential/interests/[id]/notes/[noteId]/route.ts b/src/app/api/v1/residential/interests/[id]/notes/[noteId]/route.ts index ca99ef56..089119e8 100644 --- a/src/app/api/v1/residential/interests/[id]/notes/[noteId]/route.ts +++ b/src/app/api/v1/residential/interests/[id]/notes/[noteId]/route.ts @@ -4,11 +4,13 @@ import { withAuth, withPermission } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { updateNoteSchema } from '@/lib/validators/notes'; import * as notesService from '@/lib/services/notes.service'; +import { assertResidentialModuleEnabled } from '@/lib/services/residential-module.service'; import { errorResponse, NotFoundError } from '@/lib/errors'; export const PATCH = withAuth( withPermission('residential_interests', 'edit', async (req, ctx, params) => { try { + await assertResidentialModuleEnabled(ctx.portId); const id = params.id; const noteId = params.noteId; if (!id || !noteId) throw new NotFoundError('Residential interest note'); @@ -24,6 +26,7 @@ export const PATCH = withAuth( export const DELETE = withAuth( withPermission('residential_interests', 'edit', async (_req, ctx, params) => { try { + await assertResidentialModuleEnabled(ctx.portId); const id = params.id; const noteId = params.noteId; if (!id || !noteId) throw new NotFoundError('Residential interest note'); diff --git a/src/app/api/v1/residential/interests/[id]/notes/route.ts b/src/app/api/v1/residential/interests/[id]/notes/route.ts index d38bbcfa..4cb80eaa 100644 --- a/src/app/api/v1/residential/interests/[id]/notes/route.ts +++ b/src/app/api/v1/residential/interests/[id]/notes/route.ts @@ -4,11 +4,13 @@ import { withAuth, withPermission } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { createNoteSchema } from '@/lib/validators/notes'; import * as notesService from '@/lib/services/notes.service'; +import { assertResidentialModuleEnabled } from '@/lib/services/residential-module.service'; import { errorResponse, NotFoundError } from '@/lib/errors'; export const GET = withAuth( withPermission('residential_interests', 'view', async (_req, ctx, params) => { try { + await assertResidentialModuleEnabled(ctx.portId); const id = params.id; if (!id) throw new NotFoundError('Residential interest'); const notes = await notesService.listForEntity(ctx.portId, 'residential_interests', id); @@ -22,6 +24,7 @@ export const GET = withAuth( export const POST = withAuth( withPermission('residential_interests', 'edit', async (req, ctx, params) => { try { + await assertResidentialModuleEnabled(ctx.portId); const id = params.id; if (!id) throw new NotFoundError('Residential interest'); const body = await parseBody(req, createNoteSchema); diff --git a/src/app/api/v1/residential/interests/[id]/route.ts b/src/app/api/v1/residential/interests/[id]/route.ts index 1a13d2da..a503f297 100644 --- a/src/app/api/v1/residential/interests/[id]/route.ts +++ b/src/app/api/v1/residential/interests/[id]/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { errorResponse } from '@/lib/errors'; +import { assertResidentialModuleEnabled } from '@/lib/services/residential-module.service'; import { archiveResidentialInterest, getResidentialInterestById, @@ -13,6 +14,7 @@ import { updateResidentialInterestSchema } from '@/lib/validators/residential'; export const GET = withAuth( withPermission('residential_interests', 'view', async (req, ctx, params) => { try { + await assertResidentialModuleEnabled(ctx.portId); const interest = await getResidentialInterestById(params.id!, ctx.portId); return NextResponse.json({ data: interest }); } catch (error) { @@ -24,6 +26,7 @@ export const GET = withAuth( export const PATCH = withAuth( withPermission('residential_interests', 'edit', async (req, ctx, params) => { try { + await assertResidentialModuleEnabled(ctx.portId); const body = await parseBody(req, updateResidentialInterestSchema); const updated = await updateResidentialInterest(params.id!, ctx.portId, body, { userId: ctx.userId, @@ -41,6 +44,7 @@ export const PATCH = withAuth( export const DELETE = withAuth( withPermission('residential_interests', 'delete', async (req, ctx, params) => { try { + await assertResidentialModuleEnabled(ctx.portId); await archiveResidentialInterest(params.id!, ctx.portId, { userId: ctx.userId, portId: ctx.portId, diff --git a/src/app/api/v1/residential/interests/bulk/route.ts b/src/app/api/v1/residential/interests/bulk/route.ts index 82e84289..bc0bfa44 100644 --- a/src/app/api/v1/residential/interests/bulk/route.ts +++ b/src/app/api/v1/residential/interests/bulk/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { withAuth } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { errorResponse } from '@/lib/errors'; +import { assertResidentialModuleEnabled } from '@/lib/services/residential-module.service'; import { archiveResidentialInterest, updateResidentialInterest, @@ -48,6 +49,7 @@ const PERMISSION_BY_ACTION: Record< export const POST = withAuth(async (req, ctx) => { let body: z.infer; try { + await assertResidentialModuleEnabled(ctx.portId); body = await parseBody(req, bulkSchema); } catch (error) { return errorResponse(error); diff --git a/src/app/api/v1/residential/interests/route.ts b/src/app/api/v1/residential/interests/route.ts index 89cdf3e1..df4b1b83 100644 --- a/src/app/api/v1/residential/interests/route.ts +++ b/src/app/api/v1/residential/interests/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers'; import { parseQuery, parseBody } from '@/lib/api/route-helpers'; import { errorResponse } from '@/lib/errors'; +import { assertResidentialModuleEnabled } from '@/lib/services/residential-module.service'; import { createResidentialInterest, listResidentialInterests, @@ -15,6 +16,7 @@ import { export const GET = withAuth( withPermission('residential_interests', 'view', async (req, ctx) => { try { + await assertResidentialModuleEnabled(ctx.portId); const query = parseQuery(req, listResidentialInterestsSchema); const result = await listResidentialInterests(ctx.portId, query); const { page, limit } = query; @@ -39,6 +41,7 @@ export const GET = withAuth( export const POST = withAuth( withPermission('residential_interests', 'create', async (req, ctx) => { try { + await assertResidentialModuleEnabled(ctx.portId); const body = await parseBody(req, createResidentialInterestSchema); const interest = await createResidentialInterest(ctx.portId, body, { userId: ctx.userId, diff --git a/src/app/api/v1/residential/stages/route.ts b/src/app/api/v1/residential/stages/route.ts index f69fb3a5..e19e4454 100644 --- a/src/app/api/v1/residential/stages/route.ts +++ b/src/app/api/v1/residential/stages/route.ts @@ -9,11 +9,13 @@ import { saveStages, type ResidentialStage, } from '@/lib/services/residential-stages.service'; +import { assertResidentialModuleEnabled } from '@/lib/services/residential-module.service'; import { errorResponse } from '@/lib/errors'; export const GET = withAuth( withPermission('residential_interests', 'view', async (_req, ctx) => { try { + await assertResidentialModuleEnabled(ctx.portId); const stages = await listStages(ctx.portId); const orphans = await findOrphanInterests( ctx.portId, @@ -45,6 +47,7 @@ const putSchema = z.object({ export const PUT = withAuth( withPermission('admin', 'manage_settings', async (req, ctx) => { try { + await assertResidentialModuleEnabled(ctx.portId); const body = await parseBody(req, putSchema); await saveStages( {