fix(audit): critical C3 — enforce residential module gate on all v1 API routes

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 11:59:52 +02:00
parent 7aa639f195
commit 3c9310f81c
13 changed files with 37 additions and 0 deletions

View File

@@ -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,

View File

@@ -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

View File

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

View File

@@ -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);

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

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

View File

@@ -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);

View File

@@ -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,

View File

@@ -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<typeof bulkSchema>;
try {
await assertResidentialModuleEnabled(ctx.portId);
body = await parseBody(req, bulkSchema);
} catch (error) {
return errorResponse(error);

View File

@@ -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,

View File

@@ -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(
{