import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { t } from "@repo/core-shared/trpc/init"; import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware"; import type { DsrBinding } from "./di/bind-production"; import { createExportHandler } from "./handlers/export-handler"; import { createDeleteHandler } from "./handlers/delete-handler"; import { createRectifyHandler } from "./handlers/rectify-handler"; import type { RectifyHandlerInput } from "./handlers/rectify-handler"; import { createRestrictHandler } from "./handlers/restrict-handler"; export type DsrTrpcUser = { id?: string; roles?: string[]; }; const requireAuthenticated = t.middleware(({ ctx, next }) => { const user = (ctx as { user?: DsrTrpcUser }).user; if (!user) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required", }); } return next({ ctx: { ...ctx, user } }); }); const dsrProcedure = t.procedure .use(requireAuthenticated) .use(defineErrorMiddleware([])); /** * Creates the DSR tRPC router. * * Capture `binding` at router-creation time. Apps that mount this router * must pass the `DsrBinding` returned by `bindProductionDsr` or `bindDevSeedDsr`. * * @example * ```ts * const binding = bindProductionDsr({ config, auditLog }); * const appRouter = t.router({ ..., dsr: createDsrRouter(binding) }); * ``` */ export function createDsrRouter(binding: DsrBinding) { // Handlers are created lazily (inside procedure closures) so that the // dsrRouter singleton proxy doesn't trigger at module init time. return t.router({ export: dsrProcedure .input( z .object({ subjectId: z.string().min(1), format: z.enum(["json", "json-ld"]), }) .strict(), ) .query(async ({ input }) => { const res = await createExportHandler(binding.dataExport)(input); return res.body; }), delete: dsrProcedure .input( z .object({ subjectId: z.string().min(1), mode: z.enum(["soft", "cascade-hard"]), }) .strict(), ) .mutation(async ({ ctx, input }) => { if (input.mode === "cascade-hard") { const user = (ctx as { user: DsrTrpcUser }).user; if (!user.roles?.includes("admin")) { throw new TRPCError({ code: "FORBIDDEN", message: "Admin role required for cascade-hard deletion", }); } } const res = await createDeleteHandler(binding.dataDelete)(input); return res.body; }), rectify: dsrProcedure .input( z .object({ subjectId: z.string().min(1), collection: z.string().min(1), field: z.string().min(1), value: z.unknown(), }) .strict(), ) .mutation(async ({ input }) => { // tRPC infers z.unknown() as value?: unknown; cast to assert presence const res = await createRectifyHandler(binding.dataRectify)( input as RectifyHandlerInput, ); return res.body; }), restrict: dsrProcedure .input( z .object({ subjectId: z.string().min(1), granted: z.boolean(), }) .strict(), ) .mutation(async ({ input }) => { const res = await createRestrictHandler(binding.processingRestriction)( input, ); return res.body; }), }); } /** * Convenience singleton for projects with a single DSR binding instance. * Most callers should use `createDsrRouter(binding)` and pass the binding * explicitly. This export exists for type inference (`DsrRouter`) only. */ export const dsrRouter = createDsrRouter( new Proxy({} as DsrBinding, { get(_target, prop) { if (prop === "then") return undefined; // not a Promise throw new Error( `dsrRouter singleton used without providing a DsrBinding. ` + `Use createDsrRouter(binding) instead.`, ); }, }), ); export type DsrRouter = ReturnType;