From e53f35a0c55ce7f43063af726f79d4dd9a81a6b9 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Tue, 19 May 2026 13:22:33 +0000 Subject: [PATCH] feat(core-consent): add handlers and consentRouter tRPC router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- coverage/summary.json | 44 ++--- .../2026-05-14-@trpc/server.md | 1 + docs/library-decisions/2026-05-14-zod.md | 2 +- packages/core-consent/package.json | 4 +- .../core-consent/src/consent.router.test.ts | 178 ++++++++++++++++++ packages/core-consent/src/consent.router.ts | 68 +++++++ .../src/entities/errors/consent.ts | 7 + .../handlers/get-categories.handler.test.ts | 41 ++++ .../src/handlers/get-categories.handler.ts | 8 + .../src/handlers/grant.handler.test.ts | 43 +++++ .../src/handlers/grant.handler.ts | 26 +++ .../src/handlers/is-granted.handler.test.ts | 32 ++++ .../src/handlers/is-granted.handler.ts | 17 ++ .../src/handlers/withdraw.handler.test.ts | 28 +++ .../src/handlers/withdraw.handler.ts | 18 ++ packages/core-consent/src/index.ts | 19 ++ pnpm-lock.yaml | 6 + 17 files changed, 518 insertions(+), 24 deletions(-) create mode 100644 packages/core-consent/src/consent.router.test.ts create mode 100644 packages/core-consent/src/consent.router.ts create mode 100644 packages/core-consent/src/entities/errors/consent.ts create mode 100644 packages/core-consent/src/handlers/get-categories.handler.test.ts create mode 100644 packages/core-consent/src/handlers/get-categories.handler.ts create mode 100644 packages/core-consent/src/handlers/grant.handler.test.ts create mode 100644 packages/core-consent/src/handlers/grant.handler.ts create mode 100644 packages/core-consent/src/handlers/is-granted.handler.test.ts create mode 100644 packages/core-consent/src/handlers/is-granted.handler.ts create mode 100644 packages/core-consent/src/handlers/withdraw.handler.test.ts create mode 100644 packages/core-consent/src/handlers/withdraw.handler.ts diff --git a/coverage/summary.json b/coverage/summary.json index f49ce04..b266de8 100644 --- a/coverage/summary.json +++ b/coverage/summary.json @@ -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": { diff --git a/docs/library-decisions/2026-05-14-@trpc/server.md b/docs/library-decisions/2026-05-14-@trpc/server.md index e2668ee..fb6d200 100644 --- a/docs/library-decisions/2026-05-14-@trpc/server.md +++ b/docs/library-decisions/2026-05-14-@trpc/server.md @@ -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: [] --- diff --git a/docs/library-decisions/2026-05-14-zod.md b/docs/library-decisions/2026-05-14-zod.md index c528a42..e2bf665 100644 --- a/docs/library-decisions/2026-05-14-zod.md +++ b/docs/library-decisions/2026-05-14-zod.md @@ -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: diff --git a/packages/core-consent/package.json b/packages/core-consent/package.json index 14bd40f..f499c80 100644 --- a/packages/core-consent/package.json +++ b/packages/core-consent/package.json @@ -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" diff --git a/packages/core-consent/src/consent.router.test.ts b/packages/core-consent/src/consent.router.test.ts new file mode 100644 index 0000000..297d37c --- /dev/null +++ b/packages/core-consent/src/consent.router.test.ts @@ -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; + + 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", + }, + ); + }); +}); diff --git a/packages/core-consent/src/consent.router.ts b/packages/core-consent/src/consent.router.ts new file mode 100644 index 0000000..9ecc8c9 --- /dev/null +++ b/packages/core-consent/src/consent.router.ts @@ -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().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; diff --git a/packages/core-consent/src/entities/errors/consent.ts b/packages/core-consent/src/entities/errors/consent.ts new file mode 100644 index 0000000..e6d4141 --- /dev/null +++ b/packages/core-consent/src/entities/errors/consent.ts @@ -0,0 +1,7 @@ +export class UnauthenticatedError extends Error { + constructor(message = "Not authenticated") { + super(message); + this.name = "UnauthenticatedError"; + Object.setPrototypeOf(this, UnauthenticatedError.prototype); + } +} diff --git a/packages/core-consent/src/handlers/get-categories.handler.test.ts b/packages/core-consent/src/handlers/get-categories.handler.test.ts new file mode 100644 index 0000000..ba72dad --- /dev/null +++ b/packages/core-consent/src/handlers/get-categories.handler.test.ts @@ -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"]); + }); +}); diff --git a/packages/core-consent/src/handlers/get-categories.handler.ts b/packages/core-consent/src/handlers/get-categories.handler.ts new file mode 100644 index 0000000..7ed0693 --- /dev/null +++ b/packages/core-consent/src/handlers/get-categories.handler.ts @@ -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() }; +} diff --git a/packages/core-consent/src/handlers/grant.handler.test.ts b/packages/core-consent/src/handlers/grant.handler.test.ts new file mode 100644 index 0000000..3241090 --- /dev/null +++ b/packages/core-consent/src/handlers/grant.handler.test.ts @@ -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(); + }); +}); diff --git a/packages/core-consent/src/handlers/grant.handler.ts b/packages/core-consent/src/handlers/grant.handler.ts new file mode 100644 index 0000000..7ac2f5e --- /dev/null +++ b/packages/core-consent/src/handlers/grant.handler.ts @@ -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; + +export async function grantHandler( + consent: IConsent, + input: GrantHandlerInput, +): Promise<{ success: true }> { + await consent.grant(input.category, input.meta); + return { success: true }; +} diff --git a/packages/core-consent/src/handlers/is-granted.handler.test.ts b/packages/core-consent/src/handlers/is-granted.handler.test.ts new file mode 100644 index 0000000..e0b8909 --- /dev/null +++ b/packages/core-consent/src/handlers/is-granted.handler.test.ts @@ -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, + }); + }); +}); diff --git a/packages/core-consent/src/handlers/is-granted.handler.ts b/packages/core-consent/src/handlers/is-granted.handler.ts new file mode 100644 index 0000000..113a191 --- /dev/null +++ b/packages/core-consent/src/handlers/is-granted.handler.ts @@ -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; + +export function isGrantedHandler( + consent: IConsent, + input: IsGrantedHandlerInput, +): { granted: boolean } { + return { granted: consent.isGranted(input.category) }; +} diff --git a/packages/core-consent/src/handlers/withdraw.handler.test.ts b/packages/core-consent/src/handlers/withdraw.handler.test.ts new file mode 100644 index 0000000..1809996 --- /dev/null +++ b/packages/core-consent/src/handlers/withdraw.handler.test.ts @@ -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); + }); +}); diff --git a/packages/core-consent/src/handlers/withdraw.handler.ts b/packages/core-consent/src/handlers/withdraw.handler.ts new file mode 100644 index 0000000..46bc06d --- /dev/null +++ b/packages/core-consent/src/handlers/withdraw.handler.ts @@ -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; + +export async function withdrawHandler( + consent: IConsent, + input: WithdrawHandlerInput, +): Promise<{ success: true }> { + await consent.withdraw(input.category); + return { success: true }; +} diff --git a/packages/core-consent/src/index.ts b/packages/core-consent/src/index.ts index 94debaa..de08935 100644 --- a/packages/core-consent/src/index.ts +++ b/packages/core-consent/src/index.ts @@ -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"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12981fb..9c74a26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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:*