feat(core-dsr): handlers, dsrRouter, integration tests
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>
This commit is contained in:
139
packages/core-dsr/src/dsr.router.ts
Normal file
139
packages/core-dsr/src/dsr.router.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
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>;
|
||||
Reference in New Issue
Block a user