/** * TASK-0132 — SCIM 2.0 error type + response filter. * * Every non-2xx on the `/scim/v2/*` surface is the RFC 7644 error envelope * (content-type `application/scim+json`): * * { "schemas": ["urn:ietf:params:scim:api:errors:2.0"], * "status": "409", // HTTP status AS A STRING * "detail": "…", * "scimType": "duplicate" } // optional, when the spec names one * * `ScimError` is thrown by the SCIM services/guard; `ScimExceptionsFilter` * (applied per-controller via `@UseFilters`) renders it — and renders ANY * other exception on this surface as a 500 envelope, so the standard error * model never leaks out of the protocol boundary. */ import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from "@nestjs/common"; import type { Request, Response } from "express"; import { ApiError } from "../../common/errors.js"; import { logger } from "../../common/logger.js"; export const SCIM_ERROR_SCHEMAS = ["urn:ietf:params:scim:api:errors:2.0"]; export class ScimError extends Error { constructor( public readonly status: number, public readonly scimType: string | undefined, message: string, public readonly extra: Record = {} ) { super(message); this.name = "ScimError"; } static unauthorized(detail = "Invalid authentication credentials"): ScimError { return new ScimError(401, "invalidCredentials", detail); } static forbidden(detail = "Forbidden."): ScimError { return new ScimError(403, "forbidden", detail); } static notFound(detail = "Resource not found."): ScimError { return new ScimError(404, undefined, detail); } static invalidFilter(detail: string): ScimError { return new ScimError(400, "invalidFilter", detail); } static invalidValue(detail: string): ScimError { return new ScimError(400, "invalidValue", detail); } static duplicate(detail: string): ScimError { return new ScimError(409, "duplicate", detail); } static preconditionFailed(detail = "Precondition Failed"): ScimError { return new ScimError(412, undefined, detail); } } function envelope(status: number, detail: string, scimType?: string, extra: Record = {}) { const body: Record = { schemas: SCIM_ERROR_SCHEMAS, status: String(status), detail }; if (scimType) body.scimType = scimType; Object.assign(body, extra); return body; } @Catch() export class ScimExceptionsFilter implements ExceptionFilter { catch(exception: unknown, host: ArgumentsHost): void { const res = host.switchToHttp().getResponse(); const req = host.switchToHttp().getRequest(); res.setHeader("content-type", "application/scim+json; charset=utf-8"); if (exception instanceof ScimError) { res.status(exception.status).json( envelope(exception.status, exception.message, exception.scimType, exception.extra) ); return; } // An `ApiError` (our own pipe throws these — `ApiError` extends plain // `Error`, NOT `HttpException`, so it must be matched BEFORE the // HttpException branch): a VALIDATION_ERROR (ZodValidate) → 400 // invalidValue; everything else maps by its status. if (exception instanceof ApiError) { if (exception.code === "VALIDATION_ERROR") { const details = (exception.details ?? []).map((d) => d.issue ?? d.field ?? d.reason).filter(Boolean); res.status(400).json( envelope(400, details.length ? details.join("; ") : exception.message, "invalidValue") ); return; } if (exception.httpStatus === 404) { res.status(404).json(envelope(404, "Resource not found.")); return; } res.status(exception.httpStatus).json(envelope(exception.httpStatus, exception.message)); return; } if (exception instanceof HttpException) { const status = exception.getStatus(); if (status === 400) { const body = exception.getResponse() as { message?: string | string[] } | string; const detail = typeof body === "string" ? body : Array.isArray(body.message) ? body.message.join("; ") : body.message ?? "Invalid request."; res.status(400).json(envelope(400, detail, "invalidValue")); return; } if (status === 404) { res.status(404).json(envelope(404, "Resource not found.")); return; } if (status === 409) { const body = exception.getResponse() as { message?: string }; res.status(409).json(envelope(409, body.message ?? "Conflict.", "duplicate")); return; } if (status === 412) { res.status(412).json(envelope(412, "Precondition Failed")); return; } const body = exception.getResponse() as { message?: string }; const detail = typeof body === "string" ? body : body.message ?? "Request failed."; res.status(status).json(envelope(status, detail)); return; } // Unknown → 500 (details stay server-side; the log line carries them). const message = exception instanceof Error ? exception.message : "Internal Server Error"; logger.warn(`scim 500 on ${req.method} ${req.path}: ${message}`); res.status(500).json(envelope(500, "Internal Server Error")); } }