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:
@@ -69,7 +69,7 @@ Actively maintained by the tRPC team. The 11.x line is the current major. Regula
|
|||||||
|
|
||||||
<!-- Result: pass -->
|
<!-- Result: pass -->
|
||||||
|
|
||||||
All five feature packages export a tRPC router that uses `@trpc/server`. `@repo/core-api` aggregates these routers. `@repo/core-shared` provides the tRPC base instance and error middleware utilities. Named, non-hypothetical consumers exist today.
|
All five feature packages export a tRPC router that uses `@trpc/server`. `@repo/core-api` aggregates these routers. `@repo/core-shared` provides the tRPC base instance and error middleware utilities. `@repo/core-dsr` exposes the `dsrRouter` via `createDsrRouter`. Named, non-hypothetical consumers exist today.
|
||||||
|
|
||||||
## Prompt: replaces
|
## Prompt: replaces
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ Zod is a pure runtime validation library with no network communication, telemetr
|
|||||||
|
|
||||||
<!-- Result: pass -->
|
<!-- Result: pass -->
|
||||||
|
|
||||||
All five feature packages use Zod for use-case input/output schemas. `core-shared` uses Zod for tRPC input validation and error schemas. `core-audit` uses Zod for audit event schemas. Named, non-hypothetical consumers exist today.
|
All five feature packages use Zod for use-case input/output schemas. `core-shared` uses Zod for tRPC input validation and error schemas. `core-audit` uses Zod for audit event schemas. `core-dsr` uses Zod for `dsrRouter` procedure input schemas. Named, non-hypothetical consumers exist today.
|
||||||
|
|
||||||
## Prompt: replaces
|
## Prompt: replaces
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts",
|
||||||
|
"./api": "./src/dsr.router.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --noEmit",
|
"build": "tsc --noEmit",
|
||||||
@@ -14,7 +15,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@repo/core-shared": "workspace:*",
|
"@repo/core-shared": "workspace:*",
|
||||||
"payload": "^3.0.0"
|
"@trpc/server": "^11.0.0",
|
||||||
|
"payload": "^3.0.0",
|
||||||
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/core-eslint": "workspace:*",
|
"@repo/core-eslint": "workspace:*",
|
||||||
|
|||||||
209
packages/core-dsr/src/__tests__/dsr.router.test.ts
Normal file
209
packages/core-dsr/src/__tests__/dsr.router.test.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { createDsrRouter } from "@/dsr.router";
|
||||||
|
import type { DsrTrpcUser } from "@/dsr.router";
|
||||||
|
import type { DsrBinding } from "@/di/bind-production";
|
||||||
|
import {
|
||||||
|
RecordingDataExport,
|
||||||
|
RecordingDataDelete,
|
||||||
|
RecordingDataRectify,
|
||||||
|
RecordingProcessingRestriction,
|
||||||
|
} from "@repo/core-testing/instrumentation";
|
||||||
|
|
||||||
|
// Recording doubles use local type aliases to avoid circular deps with core-dsr.
|
||||||
|
// The alias types are structurally compatible at runtime; cast via unknown to
|
||||||
|
// satisfy the DsrBinding constraint without modifying core-testing.
|
||||||
|
function makeBinding() {
|
||||||
|
return {
|
||||||
|
dataExport: new RecordingDataExport(),
|
||||||
|
dataDelete: new RecordingDataDelete(),
|
||||||
|
dataRectify: new RecordingDataRectify(),
|
||||||
|
processingRestriction: new RecordingProcessingRestriction(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type TestBinding = ReturnType<typeof makeBinding>;
|
||||||
|
|
||||||
|
function makeCaller(binding: TestBinding, user?: DsrTrpcUser) {
|
||||||
|
const router = createDsrRouter(binding as unknown as DsrBinding);
|
||||||
|
return router.createCaller({ user } as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authenticatedUser: DsrTrpcUser = { id: "alice", roles: ["user"] };
|
||||||
|
const adminUser: DsrTrpcUser = { id: "admin-user", roles: ["admin"] };
|
||||||
|
|
||||||
|
describe("dsrRouter.export", () => {
|
||||||
|
it("returns UserDataBundle body for authenticated user", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
const caller = makeCaller(binding, authenticatedUser);
|
||||||
|
const result = await caller.export({ subjectId: "alice", format: "json" });
|
||||||
|
|
||||||
|
expect(result.subjectId).toBe("alice");
|
||||||
|
expect(result.format).toBe("json");
|
||||||
|
expect(binding.dataExport.calls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works with json-ld format", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
const caller = makeCaller(binding, authenticatedUser);
|
||||||
|
const result = await caller.export({
|
||||||
|
subjectId: "alice",
|
||||||
|
format: "json-ld",
|
||||||
|
});
|
||||||
|
expect(result.format).toBe("json-ld");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws UNAUTHORIZED when ctx.user is absent", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
const caller = makeCaller(binding, undefined);
|
||||||
|
await expect(
|
||||||
|
caller.export({ subjectId: "alice", format: "json" }),
|
||||||
|
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates errors from dataExport", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
vi.spyOn(binding.dataExport, "exportSubjectData").mockRejectedValue(
|
||||||
|
new Error("export failed"),
|
||||||
|
);
|
||||||
|
const caller = makeCaller(binding, authenticatedUser);
|
||||||
|
await expect(
|
||||||
|
caller.export({ subjectId: "alice", format: "json" }),
|
||||||
|
).rejects.toThrow("export failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dsrRouter.delete", () => {
|
||||||
|
it("returns DeletionCertificate for soft mode (any authenticated user)", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
const caller = makeCaller(binding, authenticatedUser);
|
||||||
|
const result = await caller.delete({ subjectId: "alice", mode: "soft" });
|
||||||
|
|
||||||
|
expect(result.subjectId).toBe("alice");
|
||||||
|
expect(result.mode).toBe("soft");
|
||||||
|
expect(binding.dataDelete.calls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns DeletionCertificate for cascade-hard mode with admin user", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
const caller = makeCaller(binding, adminUser);
|
||||||
|
const result = await caller.delete({
|
||||||
|
subjectId: "alice",
|
||||||
|
mode: "cascade-hard",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.mode).toBe("cascade-hard");
|
||||||
|
expect(binding.dataDelete.calls[0]?.mode).toBe("cascade-hard");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws FORBIDDEN for cascade-hard mode with non-admin user", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
const caller = makeCaller(binding, authenticatedUser);
|
||||||
|
await expect(
|
||||||
|
caller.delete({ subjectId: "alice", mode: "cascade-hard" }),
|
||||||
|
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||||
|
expect(binding.dataDelete.calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws FORBIDDEN for cascade-hard when user has no roles", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
const noRolesUser: DsrTrpcUser = { id: "alice" };
|
||||||
|
const caller = makeCaller(binding, noRolesUser);
|
||||||
|
await expect(
|
||||||
|
caller.delete({ subjectId: "alice", mode: "cascade-hard" }),
|
||||||
|
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws UNAUTHORIZED when ctx.user is absent", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
const caller = makeCaller(binding, undefined);
|
||||||
|
await expect(
|
||||||
|
caller.delete({ subjectId: "alice", mode: "soft" }),
|
||||||
|
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dsrRouter.rectify", () => {
|
||||||
|
it("returns { ok: true } for authenticated user", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
const caller = makeCaller(binding, authenticatedUser);
|
||||||
|
const result = await caller.rectify({
|
||||||
|
subjectId: "alice",
|
||||||
|
collection: "users",
|
||||||
|
field: "name",
|
||||||
|
value: "Alice New",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
expect(binding.dataRectify.calls[0]).toEqual({
|
||||||
|
subjectId: "alice",
|
||||||
|
collection: "users",
|
||||||
|
field: "name",
|
||||||
|
value: "Alice New",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws UNAUTHORIZED when ctx.user is absent", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
const caller = makeCaller(binding, undefined);
|
||||||
|
await expect(
|
||||||
|
caller.rectify({
|
||||||
|
subjectId: "alice",
|
||||||
|
collection: "users",
|
||||||
|
field: "name",
|
||||||
|
value: "x",
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates errors from dataRectify", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
vi.spyOn(binding.dataRectify, "updateSubjectField").mockRejectedValue(
|
||||||
|
new Error('Field "secret" is not tagged as PII'),
|
||||||
|
);
|
||||||
|
const caller = makeCaller(binding, authenticatedUser);
|
||||||
|
await expect(
|
||||||
|
caller.rectify({
|
||||||
|
subjectId: "alice",
|
||||||
|
collection: "users",
|
||||||
|
field: "secret",
|
||||||
|
value: "x",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dsrRouter.restrict", () => {
|
||||||
|
it("returns { ok: true } when granting restriction", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
const caller = makeCaller(binding, authenticatedUser);
|
||||||
|
const result = await caller.restrict({
|
||||||
|
subjectId: "alice",
|
||||||
|
granted: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
expect(binding.processingRestriction.sets[0]).toEqual({
|
||||||
|
subjectId: "alice",
|
||||||
|
granted: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns { ok: true } when lifting restriction", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
const caller = makeCaller(binding, authenticatedUser);
|
||||||
|
const result = await caller.restrict({
|
||||||
|
subjectId: "alice",
|
||||||
|
granted: false,
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
expect(binding.processingRestriction.sets[0]?.granted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws UNAUTHORIZED when ctx.user is absent", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
const caller = makeCaller(binding, undefined);
|
||||||
|
await expect(
|
||||||
|
caller.restrict({ subjectId: "alice", granted: true }),
|
||||||
|
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||||
|
});
|
||||||
|
});
|
||||||
124
packages/core-dsr/src/__tests__/handlers.test.ts
Normal file
124
packages/core-dsr/src/__tests__/handlers.test.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { createExportHandler } from "@/handlers/export-handler";
|
||||||
|
import { createDeleteHandler } from "@/handlers/delete-handler";
|
||||||
|
import { createRectifyHandler } from "@/handlers/rectify-handler";
|
||||||
|
import { createRestrictHandler } from "@/handlers/restrict-handler";
|
||||||
|
import type { IDataExport } from "@/data-export.interface";
|
||||||
|
import type { IDataDelete } from "@/data-delete.interface";
|
||||||
|
import type { IDataRectify } from "@/data-rectify.interface";
|
||||||
|
import type { IProcessingRestriction } from "@/processing-restriction.interface";
|
||||||
|
import {
|
||||||
|
RecordingDataExport,
|
||||||
|
RecordingDataDelete,
|
||||||
|
RecordingDataRectify,
|
||||||
|
RecordingProcessingRestriction,
|
||||||
|
} from "@repo/core-testing/instrumentation";
|
||||||
|
|
||||||
|
// Recording doubles use local type aliases to avoid circular deps with core-dsr.
|
||||||
|
// Cast via unknown so they satisfy the interface at the TypeScript level while
|
||||||
|
// remaining structurally compatible at runtime.
|
||||||
|
|
||||||
|
describe("createExportHandler", () => {
|
||||||
|
it("calls exportSubjectData and returns status 200 with bundle body", async () => {
|
||||||
|
const dataExport = new RecordingDataExport();
|
||||||
|
const handler = createExportHandler(dataExport as unknown as IDataExport);
|
||||||
|
const res = await handler({ subjectId: "alice", format: "json" });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.subjectId).toBe("alice");
|
||||||
|
expect(res.body.format).toBe("json");
|
||||||
|
expect(dataExport.calls).toHaveLength(1);
|
||||||
|
expect(dataExport.calls[0]).toEqual({ subjectId: "alice", format: "json" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets Content-Type: application/json for json format", async () => {
|
||||||
|
const dataExport = new RecordingDataExport();
|
||||||
|
const handler = createExportHandler(dataExport as unknown as IDataExport);
|
||||||
|
const res = await handler({ subjectId: "alice", format: "json" });
|
||||||
|
expect(res.headers?.["Content-Type"]).toBe("application/json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets Content-Type: application/ld+json for json-ld format", async () => {
|
||||||
|
const dataExport = new RecordingDataExport();
|
||||||
|
const handler = createExportHandler(dataExport as unknown as IDataExport);
|
||||||
|
const res = await handler({ subjectId: "alice", format: "json-ld" });
|
||||||
|
expect(res.headers?.["Content-Type"]).toBe("application/ld+json");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createDeleteHandler", () => {
|
||||||
|
it("calls deleteSubjectData and returns status 200 with certificate body", async () => {
|
||||||
|
const dataDelete = new RecordingDataDelete();
|
||||||
|
const handler = createDeleteHandler(dataDelete as unknown as IDataDelete);
|
||||||
|
const res = await handler({ subjectId: "alice", mode: "soft" });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.subjectId).toBe("alice");
|
||||||
|
expect(res.body.mode).toBe("soft");
|
||||||
|
expect(dataDelete.calls).toHaveLength(1);
|
||||||
|
expect(dataDelete.calls[0]).toEqual({ subjectId: "alice", mode: "soft" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes cascade-hard mode to deleteSubjectData", async () => {
|
||||||
|
const dataDelete = new RecordingDataDelete();
|
||||||
|
const handler = createDeleteHandler(dataDelete as unknown as IDataDelete);
|
||||||
|
const res = await handler({ subjectId: "alice", mode: "cascade-hard" });
|
||||||
|
expect(res.body.mode).toBe("cascade-hard");
|
||||||
|
expect(dataDelete.calls[0]?.mode).toBe("cascade-hard");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createRectifyHandler", () => {
|
||||||
|
it("calls updateSubjectField and returns status 200 with ok body", async () => {
|
||||||
|
const dataRectify = new RecordingDataRectify();
|
||||||
|
const handler = createRectifyHandler(
|
||||||
|
dataRectify as unknown as IDataRectify,
|
||||||
|
);
|
||||||
|
const res = await handler({
|
||||||
|
subjectId: "alice",
|
||||||
|
collection: "users",
|
||||||
|
field: "name",
|
||||||
|
value: "Alice New",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ ok: true });
|
||||||
|
expect(dataRectify.calls).toHaveLength(1);
|
||||||
|
expect(dataRectify.calls[0]).toEqual({
|
||||||
|
subjectId: "alice",
|
||||||
|
collection: "users",
|
||||||
|
field: "name",
|
||||||
|
value: "Alice New",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createRestrictHandler", () => {
|
||||||
|
it("calls setRestriction with granted=true and returns status 200", async () => {
|
||||||
|
const processingRestriction = new RecordingProcessingRestriction();
|
||||||
|
const handler = createRestrictHandler(
|
||||||
|
processingRestriction as unknown as IProcessingRestriction,
|
||||||
|
);
|
||||||
|
const res = await handler({ subjectId: "alice", granted: true });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ ok: true });
|
||||||
|
expect(processingRestriction.sets).toHaveLength(1);
|
||||||
|
expect(processingRestriction.sets[0]).toEqual({
|
||||||
|
subjectId: "alice",
|
||||||
|
granted: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls setRestriction with granted=false", async () => {
|
||||||
|
const processingRestriction = new RecordingProcessingRestriction();
|
||||||
|
const handler = createRestrictHandler(
|
||||||
|
processingRestriction as unknown as IProcessingRestriction,
|
||||||
|
);
|
||||||
|
await handler({ subjectId: "alice", granted: false });
|
||||||
|
expect(processingRestriction.sets[0]).toEqual({
|
||||||
|
subjectId: "alice",
|
||||||
|
granted: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
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>;
|
||||||
20
packages/core-dsr/src/handlers/delete-handler.ts
Normal file
20
packages/core-dsr/src/handlers/delete-handler.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { IDataDelete } from "../data-delete.interface";
|
||||||
|
import type { DeletionMode, DeletionCertificate } from "../dsr-types";
|
||||||
|
import type { HandlerResponse } from "./handler-types";
|
||||||
|
|
||||||
|
export type DeleteHandlerInput = {
|
||||||
|
subjectId: string;
|
||||||
|
mode: DeletionMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createDeleteHandler(dataDelete: IDataDelete) {
|
||||||
|
return async (
|
||||||
|
input: DeleteHandlerInput,
|
||||||
|
): Promise<HandlerResponse<DeletionCertificate>> => {
|
||||||
|
const cert = await dataDelete.deleteSubjectData(
|
||||||
|
input.subjectId,
|
||||||
|
input.mode,
|
||||||
|
);
|
||||||
|
return { status: 200, body: cert };
|
||||||
|
};
|
||||||
|
}
|
||||||
24
packages/core-dsr/src/handlers/export-handler.ts
Normal file
24
packages/core-dsr/src/handlers/export-handler.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { IDataExport } from "../data-export.interface";
|
||||||
|
import type { DsrFormat, UserDataBundle } from "../dsr-types";
|
||||||
|
import type { HandlerResponse } from "./handler-types";
|
||||||
|
|
||||||
|
export type ExportHandlerInput = {
|
||||||
|
subjectId: string;
|
||||||
|
format: DsrFormat;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createExportHandler(dataExport: IDataExport) {
|
||||||
|
return async (
|
||||||
|
input: ExportHandlerInput,
|
||||||
|
): Promise<HandlerResponse<UserDataBundle>> => {
|
||||||
|
const bundle = await dataExport.exportSubjectData(
|
||||||
|
input.subjectId,
|
||||||
|
input.format,
|
||||||
|
);
|
||||||
|
const headers: Record<string, string> =
|
||||||
|
input.format === "json-ld"
|
||||||
|
? { "Content-Type": "application/ld+json" }
|
||||||
|
: { "Content-Type": "application/json" };
|
||||||
|
return { status: 200, body: bundle, headers };
|
||||||
|
};
|
||||||
|
}
|
||||||
5
packages/core-dsr/src/handlers/handler-types.ts
Normal file
5
packages/core-dsr/src/handlers/handler-types.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export type HandlerResponse<T = unknown> = {
|
||||||
|
status: number;
|
||||||
|
body: T;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
};
|
||||||
23
packages/core-dsr/src/handlers/rectify-handler.ts
Normal file
23
packages/core-dsr/src/handlers/rectify-handler.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { IDataRectify } from "../data-rectify.interface";
|
||||||
|
import type { HandlerResponse } from "./handler-types";
|
||||||
|
|
||||||
|
export type RectifyHandlerInput = {
|
||||||
|
subjectId: string;
|
||||||
|
collection: string;
|
||||||
|
field: string;
|
||||||
|
value: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createRectifyHandler(dataRectify: IDataRectify) {
|
||||||
|
return async (
|
||||||
|
input: RectifyHandlerInput,
|
||||||
|
): Promise<HandlerResponse<{ ok: true }>> => {
|
||||||
|
await dataRectify.updateSubjectField(
|
||||||
|
input.subjectId,
|
||||||
|
input.collection,
|
||||||
|
input.field,
|
||||||
|
input.value,
|
||||||
|
);
|
||||||
|
return { status: 200, body: { ok: true as const } };
|
||||||
|
};
|
||||||
|
}
|
||||||
18
packages/core-dsr/src/handlers/restrict-handler.ts
Normal file
18
packages/core-dsr/src/handlers/restrict-handler.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { IProcessingRestriction } from "../processing-restriction.interface";
|
||||||
|
import type { HandlerResponse } from "./handler-types";
|
||||||
|
|
||||||
|
export type RestrictHandlerInput = {
|
||||||
|
subjectId: string;
|
||||||
|
granted: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createRestrictHandler(
|
||||||
|
processingRestriction: IProcessingRestriction,
|
||||||
|
) {
|
||||||
|
return async (
|
||||||
|
input: RestrictHandlerInput,
|
||||||
|
): Promise<HandlerResponse<{ ok: true }>> => {
|
||||||
|
await processingRestriction.setRestriction(input.subjectId, input.granted);
|
||||||
|
return { status: 200, body: { ok: true as const } };
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -35,3 +35,16 @@ export { InMemoryProcessingRestriction } from "./in-memory-processing-restrictio
|
|||||||
export { bindProductionDsr } from "./di/bind-production";
|
export { bindProductionDsr } from "./di/bind-production";
|
||||||
export type { DsrBinding, BindProductionDsrOpts } from "./di/bind-production";
|
export type { DsrBinding, BindProductionDsrOpts } from "./di/bind-production";
|
||||||
export { bindDevSeedDsr } from "./di/bind-dev-seed";
|
export { bindDevSeedDsr } from "./di/bind-dev-seed";
|
||||||
|
|
||||||
|
export { createDsrRouter, dsrRouter } from "./dsr.router";
|
||||||
|
export type { DsrRouter, DsrTrpcUser } from "./dsr.router";
|
||||||
|
|
||||||
|
export type { HandlerResponse } from "./handlers/handler-types";
|
||||||
|
export { createExportHandler } from "./handlers/export-handler";
|
||||||
|
export type { ExportHandlerInput } from "./handlers/export-handler";
|
||||||
|
export { createDeleteHandler } from "./handlers/delete-handler";
|
||||||
|
export type { DeleteHandlerInput } from "./handlers/delete-handler";
|
||||||
|
export { createRectifyHandler } from "./handlers/rectify-handler";
|
||||||
|
export type { RectifyHandlerInput } from "./handlers/rectify-handler";
|
||||||
|
export { createRestrictHandler } from "./handlers/restrict-handler";
|
||||||
|
export type { RestrictHandlerInput } from "./handlers/restrict-handler";
|
||||||
|
|||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -638,9 +638,15 @@ importers:
|
|||||||
"@repo/core-shared":
|
"@repo/core-shared":
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../core-shared
|
version: link:../core-shared
|
||||||
|
"@trpc/server":
|
||||||
|
specifier: ^11.0.0
|
||||||
|
version: 11.16.0(typescript@5.9.3)
|
||||||
payload:
|
payload:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.81.0(graphql@16.13.2)(typescript@5.9.3)
|
version: 3.81.0(graphql@16.13.2)(typescript@5.9.3)
|
||||||
|
zod:
|
||||||
|
specifier: ^3.24.0
|
||||||
|
version: 3.25.76
|
||||||
devDependencies:
|
devDependencies:
|
||||||
"@repo/core-eslint":
|
"@repo/core-eslint":
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
|
|||||||
Reference in New Issue
Block a user