Add four protocol-agnostic handlers (export, delete, rectify, restrict)
returning normalized { status, body, headers } responses, and a tRPC
dsrRouter via createDsrRouter(binding) following the factory pattern.
Auth checks: requireAuthenticated middleware gates all four procedures;
cascade-hard delete additionally requires admin role. Integration tests
assert happy-path response shapes, UNAUTHORIZED/FORBIDDEN error codes,
and error passthrough from the DSR service layer.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
140 lines
4.1 KiB
TypeScript
140 lines
4.1 KiB
TypeScript
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<typeof createDsrRouter>;
|