feat(core-consent): add handlers and consentRouter tRPC router

Protocol-agnostic handlers (grant, withdraw, isGranted, getCategories)
in core-consent/handlers/ call IConsent methods and return typed results.

consentRouter uses a consent-specific tRPC context (userId + consentFactory)
so each procedure can resolve the per-user IConsent instance at call time.
Auth middleware guards all four procedures and maps UnauthenticatedError →
UNAUTHORIZED via defineErrorMiddleware from core-shared (no local duplicate).

76 tests passing; new handler and router code at 100% branch coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 13:22:33 +00:00
parent ae4e0f2680
commit e53f35a0c5
17 changed files with 518 additions and 24 deletions

View File

@@ -1,18 +1,18 @@
{
"generatedAt": "2026-05-19T12:59:06.953Z",
"commit": "c346f85",
"generatedAt": "2026-05-19T13:18:49.389Z",
"commit": "ae4e0f2",
"repo": {
"statements": 96.65,
"branches": 91.95,
"functions": 96.77,
"lines": 96.65,
"statements": 96.72,
"branches": 92.06,
"functions": 96.83,
"lines": 96.72,
"counts": {
"lf": 4511,
"lh": 4360,
"brf": 894,
"brh": 822,
"fnf": 279,
"fnh": 270
"lf": 4607,
"lh": 4456,
"brf": 907,
"brh": 835,
"fnf": 284,
"fnh": 275
}
},
"byPackage": {
@@ -59,17 +59,17 @@
}
},
"@repo/core-consent": {
"statements": 99.57,
"branches": 93.67,
"functions": 96.15,
"lines": 99.57,
"statements": 99.69,
"branches": 94.57,
"functions": 96.77,
"lines": 99.69,
"counts": {
"lf": 230,
"lh": 229,
"brf": 79,
"brh": 74,
"fnf": 26,
"fnh": 25
"lf": 326,
"lh": 325,
"brf": 92,
"brh": 87,
"fnf": 31,
"fnh": 30
}
},
"@repo/core-shared": {

View File

@@ -19,6 +19,7 @@ verification-commands:
- npm view @trpc/server license
- npm view @trpc/server version
- pnpm audit --audit-level=moderate
lastRevalidated: 2026-05-19
accepted-cves: []
---

View File

@@ -6,7 +6,7 @@ decision: approved
date: 2026-05-14
deciders: [Danijel Martinek]
adr: null
lastRevalidated: null
lastRevalidated: 2026-05-19
is-sub-processor: false
processes-pii: false
filter-results:

View File

@@ -14,7 +14,9 @@
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@repo/core-shared": "workspace:*"
"@repo/core-shared": "workspace:*",
"@trpc/server": "^11.0.0",
"zod": "^3.24.0"
},
"peerDependencies": {
"payload": "^3.0.0"

View File

@@ -0,0 +1,178 @@
import { describe, it, expect, beforeEach } from "vitest";
import { TRPCError } from "@trpc/server";
import { RecordingConsent } from "@repo/core-testing/instrumentation";
import { consentRouter } from "@/consent.router";
import type { ConsentRouterContext, ConsentRouter } from "@/consent.router";
import type { IConsent } from "@/consent.interface";
function makeContext(
consent: RecordingConsent,
userId?: string,
): ConsentRouterContext {
return {
userId,
consentFactory: async () => consent,
};
}
describe("consentRouter — procedure surface", () => {
it("exposes grant, withdraw, isGranted, getCategories procedures", () => {
const names = Object.keys(consentRouter._def.procedures);
expect(names).toContain("grant");
expect(names).toContain("withdraw");
expect(names).toContain("isGranted");
expect(names).toContain("getCategories");
});
});
describe("consentRouter — response shapes", () => {
let consent: RecordingConsent;
let caller: ReturnType<typeof consentRouter.createCaller>;
beforeEach(() => {
consent = new RecordingConsent();
caller = consentRouter.createCaller(makeContext(consent, "user-1"));
});
it("grant returns { success: true } and records the grant", async () => {
const result = await caller.grant({ category: "analytics" });
expect(result).toEqual({ success: true });
expect(consent.grants).toHaveLength(1);
expect(consent.grants[0]!.category).toBe("analytics");
});
it("grant forwards meta to the consent impl", async () => {
await caller.grant({
category: "marketing",
meta: {
bannerVersion: "v2",
policyVersion: "2026-01",
method: "banner-accept",
},
});
expect(consent.grants[0]!.meta).toEqual({
bannerVersion: "v2",
policyVersion: "2026-01",
method: "banner-accept",
});
});
it("withdraw returns { success: true } and records the withdrawal", async () => {
await caller.grant({ category: "marketing" });
const result = await caller.withdraw({ category: "marketing" });
expect(result).toEqual({ success: true });
expect(consent.withdrawals).toHaveLength(1);
expect(consent.withdrawals[0]).toBe("marketing");
});
it("isGranted returns { granted: false } before any grant", async () => {
const result = await caller.isGranted({ category: "analytics" });
expect(result).toEqual({ granted: false });
});
it("isGranted returns { granted: true } after grant", async () => {
await caller.grant({ category: "analytics" });
const result = await caller.isGranted({ category: "analytics" });
expect(result).toEqual({ granted: true });
});
it("getCategories returns { categories: [] } initially", async () => {
const result = await caller.getCategories({});
expect(result).toEqual({ categories: [] });
});
it("getCategories returns all granted categories", async () => {
await caller.grant({ category: "necessary" });
await caller.grant({ category: "analytics" });
const { categories } = await caller.getCategories({});
expect(categories).toHaveLength(2);
const names = categories.map((c) => c.category).sort();
expect(names).toEqual(["analytics", "necessary"]);
});
it("consent round-trip: grant → isGranted → withdraw → isGranted", async () => {
await caller.grant({ category: "functional" });
expect((await caller.isGranted({ category: "functional" })).granted).toBe(
true,
);
await caller.withdraw({ category: "functional" });
expect((await caller.isGranted({ category: "functional" })).granted).toBe(
false,
);
});
});
describe("consentRouter — auth checks", () => {
it("grant → UNAUTHORIZED when userId is absent", async () => {
const caller = consentRouter.createCaller(
makeContext(new RecordingConsent()),
);
await expect(caller.grant({ category: "analytics" })).rejects.toMatchObject(
{
code: "UNAUTHORIZED",
},
);
});
it("withdraw → UNAUTHORIZED when userId is absent", async () => {
const caller = consentRouter.createCaller(
makeContext(new RecordingConsent()),
);
await expect(
caller.withdraw({ category: "analytics" }),
).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
});
it("isGranted → UNAUTHORIZED when userId is absent", async () => {
const caller = consentRouter.createCaller(
makeContext(new RecordingConsent()),
);
await expect(
caller.isGranted({ category: "analytics" }),
).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
});
it("getCategories → UNAUTHORIZED when userId is absent", async () => {
const caller = consentRouter.createCaller(
makeContext(new RecordingConsent()),
);
await expect(caller.getCategories({})).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
});
it("rejected error is a TRPCError instance", async () => {
const caller = consentRouter.createCaller(
makeContext(new RecordingConsent()),
);
await expect(
caller.grant({ category: "analytics" }),
).rejects.toBeInstanceOf(TRPCError);
});
});
describe("consentRouter — error passthrough", () => {
it("propagates unmapped errors as INTERNAL_SERVER_ERROR", async () => {
const brokenConsent: IConsent = {
isGranted: () => false,
grant: async () => {
throw new Error("storage failure");
},
withdraw: async () => {},
getCategories: () => [],
};
const caller = consentRouter.createCaller({
userId: "user-1",
consentFactory: async () => brokenConsent,
});
await expect(caller.grant({ category: "analytics" })).rejects.toMatchObject(
{
code: "INTERNAL_SERVER_ERROR",
},
);
});
});

View File

@@ -0,0 +1,68 @@
import { initTRPC } from "@trpc/server";
import { z } from "zod";
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
import type { ConsentFactory } from "./di/bind-production";
import { UnauthenticatedError } from "./entities/errors/consent";
import {
grantHandler,
grantHandlerInputSchema,
} from "./handlers/grant.handler";
import {
withdrawHandler,
withdrawHandlerInputSchema,
} from "./handlers/withdraw.handler";
import {
isGrantedHandler,
isGrantedHandlerInputSchema,
} from "./handlers/is-granted.handler";
import { getCategoriesHandler } from "./handlers/get-categories.handler";
/** tRPC context expected by the consent router. */
export type ConsentRouterContext = {
/** Authenticated user id. Absent → procedures throw UNAUTHORIZED. */
userId?: string;
/** Per-user consent factory (provided by the app binder). */
consentFactory: ConsentFactory;
};
const tc = initTRPC.context<ConsentRouterContext>().create();
const consentProcedure = tc.procedure
.use(defineErrorMiddleware([[UnauthenticatedError, "UNAUTHORIZED"]]))
.use(async ({ ctx, next }) => {
if (!ctx.userId) throw new UnauthenticatedError();
return next();
});
export const consentRouter = tc.router({
grant: consentProcedure
.input(grantHandlerInputSchema)
.mutation(async ({ ctx, input }) => {
const consent = await ctx.consentFactory(ctx.userId!);
return grantHandler(consent, input);
}),
withdraw: consentProcedure
.input(withdrawHandlerInputSchema)
.mutation(async ({ ctx, input }) => {
const consent = await ctx.consentFactory(ctx.userId!);
return withdrawHandler(consent, input);
}),
isGranted: consentProcedure
.input(isGrantedHandlerInputSchema)
.query(async ({ ctx, input }) => {
const consent = await ctx.consentFactory(ctx.userId!);
return isGrantedHandler(consent, input);
}),
getCategories: consentProcedure
.input(z.object({}).strict())
.query(async ({ ctx }) => {
const consent = await ctx.consentFactory(ctx.userId!);
return getCategoriesHandler(consent);
}),
});
export type ConsentRouter = typeof consentRouter;

View File

@@ -0,0 +1,7 @@
export class UnauthenticatedError extends Error {
constructor(message = "Not authenticated") {
super(message);
this.name = "UnauthenticatedError";
Object.setPrototypeOf(this, UnauthenticatedError.prototype);
}
}

View File

@@ -0,0 +1,41 @@
import { describe, it, expect, beforeEach } from "vitest";
import { RecordingConsent } from "@repo/core-testing/instrumentation";
import { getCategoriesHandler } from "@/handlers/get-categories.handler";
describe("getCategoriesHandler", () => {
let consent: RecordingConsent;
beforeEach(() => {
consent = new RecordingConsent();
});
it("returns { categories: [] } when no consent has been recorded", () => {
expect(getCategoriesHandler(consent)).toEqual({ categories: [] });
});
it("returns granted categories after grant", async () => {
await consent.grant("analytics");
const { categories } = getCategoriesHandler(consent);
expect(categories).toHaveLength(1);
expect(categories[0]!.category).toBe("analytics");
expect(categories[0]!.state).toBe("granted");
});
it("reflects withdrawn state after withdraw", async () => {
await consent.grant("marketing");
await consent.withdraw("marketing");
const { categories } = getCategoriesHandler(consent);
expect(categories).toHaveLength(1);
expect(categories[0]!.state).toBe("denied");
});
it("returns all categories when multiple are recorded", async () => {
await consent.grant("necessary");
await consent.grant("analytics");
await consent.grant("marketing");
const { categories } = getCategoriesHandler(consent);
expect(categories).toHaveLength(3);
const catNames = categories.map((c) => c.category).sort();
expect(catNames).toEqual(["analytics", "marketing", "necessary"]);
});
});

View File

@@ -0,0 +1,8 @@
import type { IConsent } from "../consent.interface";
import type { UserConsentState } from "../consent-types";
export function getCategoriesHandler(consent: IConsent): {
categories: UserConsentState[];
} {
return { categories: consent.getCategories() };
}

View File

@@ -0,0 +1,43 @@
import { describe, it, expect, beforeEach } from "vitest";
import { RecordingConsent } from "@repo/core-testing/instrumentation";
import { grantHandler } from "@/handlers/grant.handler";
describe("grantHandler", () => {
let consent: RecordingConsent;
beforeEach(() => {
consent = new RecordingConsent();
});
it("returns { success: true }", async () => {
const result = await grantHandler(consent, { category: "analytics" });
expect(result).toEqual({ success: true });
});
it("calls consent.grant with the given category", async () => {
await grantHandler(consent, { category: "marketing" });
expect(consent.grants).toHaveLength(1);
expect(consent.grants[0]!.category).toBe("marketing");
});
it("forwards meta to consent.grant", async () => {
await grantHandler(consent, {
category: "functional",
meta: {
bannerVersion: "v2",
policyVersion: "2026-01",
method: "banner-accept",
},
});
expect(consent.grants[0]!.meta).toEqual({
bannerVersion: "v2",
policyVersion: "2026-01",
method: "banner-accept",
});
});
it("grants without meta when omitted", async () => {
await grantHandler(consent, { category: "necessary" });
expect(consent.grants[0]!.meta).toBeUndefined();
});
});

View File

@@ -0,0 +1,26 @@
import { z } from "zod";
import type { IConsent } from "../consent.interface";
export const grantHandlerInputSchema = z
.object({
category: z.string().min(1),
meta: z
.object({
bannerVersion: z.string().optional(),
policyVersion: z.string().optional(),
method: z.string().optional(),
})
.strict()
.optional(),
})
.strict();
export type GrantHandlerInput = z.infer<typeof grantHandlerInputSchema>;
export async function grantHandler(
consent: IConsent,
input: GrantHandlerInput,
): Promise<{ success: true }> {
await consent.grant(input.category, input.meta);
return { success: true };
}

View File

@@ -0,0 +1,32 @@
import { describe, it, expect, beforeEach } from "vitest";
import { RecordingConsent } from "@repo/core-testing/instrumentation";
import { isGrantedHandler } from "@/handlers/is-granted.handler";
describe("isGrantedHandler", () => {
let consent: RecordingConsent;
beforeEach(() => {
consent = new RecordingConsent();
});
it("returns { granted: false } before any grant", () => {
expect(isGrantedHandler(consent, { category: "analytics" })).toEqual({
granted: false,
});
});
it("returns { granted: true } after grant", async () => {
await consent.grant("analytics");
expect(isGrantedHandler(consent, { category: "analytics" })).toEqual({
granted: true,
});
});
it("returns { granted: false } after withdraw", async () => {
await consent.grant("analytics");
await consent.withdraw("analytics");
expect(isGrantedHandler(consent, { category: "analytics" })).toEqual({
granted: false,
});
});
});

View File

@@ -0,0 +1,17 @@
import { z } from "zod";
import type { IConsent } from "../consent.interface";
export const isGrantedHandlerInputSchema = z
.object({
category: z.string().min(1),
})
.strict();
export type IsGrantedHandlerInput = z.infer<typeof isGrantedHandlerInputSchema>;
export function isGrantedHandler(
consent: IConsent,
input: IsGrantedHandlerInput,
): { granted: boolean } {
return { granted: consent.isGranted(input.category) };
}

View File

@@ -0,0 +1,28 @@
import { describe, it, expect, beforeEach } from "vitest";
import { RecordingConsent } from "@repo/core-testing/instrumentation";
import { withdrawHandler } from "@/handlers/withdraw.handler";
describe("withdrawHandler", () => {
let consent: RecordingConsent;
beforeEach(async () => {
consent = new RecordingConsent();
await consent.grant("analytics");
});
it("returns { success: true }", async () => {
const result = await withdrawHandler(consent, { category: "analytics" });
expect(result).toEqual({ success: true });
});
it("calls consent.withdraw with the given category", async () => {
await withdrawHandler(consent, { category: "analytics" });
expect(consent.withdrawals).toHaveLength(1);
expect(consent.withdrawals[0]).toBe("analytics");
});
it("category is no longer granted after withdraw", async () => {
await withdrawHandler(consent, { category: "analytics" });
expect(consent.isGranted("analytics")).toBe(false);
});
});

View File

@@ -0,0 +1,18 @@
import { z } from "zod";
import type { IConsent } from "../consent.interface";
export const withdrawHandlerInputSchema = z
.object({
category: z.string().min(1),
})
.strict();
export type WithdrawHandlerInput = z.infer<typeof withdrawHandlerInputSchema>;
export async function withdrawHandler(
consent: IConsent,
input: WithdrawHandlerInput,
): Promise<{ success: true }> {
await consent.withdraw(input.category);
return { success: true };
}

View File

@@ -21,3 +21,22 @@ export {
extractAnonymousConsent,
migrateAnonymousConsent,
} from "./migration";
export { UnauthenticatedError } from "./entities/errors/consent";
export {
grantHandler,
grantHandlerInputSchema,
} from "./handlers/grant.handler";
export type { GrantHandlerInput } from "./handlers/grant.handler";
export {
withdrawHandler,
withdrawHandlerInputSchema,
} from "./handlers/withdraw.handler";
export type { WithdrawHandlerInput } from "./handlers/withdraw.handler";
export {
isGrantedHandler,
isGrantedHandlerInputSchema,
} from "./handlers/is-granted.handler";
export type { IsGrantedHandlerInput } from "./handlers/is-granted.handler";
export { getCategoriesHandler } from "./handlers/get-categories.handler";
export { consentRouter } from "./consent.router";
export type { ConsentRouter, ConsentRouterContext } from "./consent.router";

6
pnpm-lock.yaml generated
View File

@@ -592,6 +592,12 @@ importers:
"@repo/core-shared":
specifier: workspace:*
version: link:../core-shared
"@trpc/server":
specifier: ^11.0.0
version: 11.16.0(typescript@5.9.3)
zod:
specifier: ^3.24.0
version: 3.25.76
devDependencies:
"@repo/core-eslint":
specifier: workspace:*