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:
@@ -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"
|
||||
|
||||
178
packages/core-consent/src/consent.router.test.ts
Normal file
178
packages/core-consent/src/consent.router.test.ts
Normal 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",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
68
packages/core-consent/src/consent.router.ts
Normal file
68
packages/core-consent/src/consent.router.ts
Normal 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;
|
||||
7
packages/core-consent/src/entities/errors/consent.ts
Normal file
7
packages/core-consent/src/entities/errors/consent.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export class UnauthenticatedError extends Error {
|
||||
constructor(message = "Not authenticated") {
|
||||
super(message);
|
||||
this.name = "UnauthenticatedError";
|
||||
Object.setPrototypeOf(this, UnauthenticatedError.prototype);
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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() };
|
||||
}
|
||||
43
packages/core-consent/src/handlers/grant.handler.test.ts
Normal file
43
packages/core-consent/src/handlers/grant.handler.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
26
packages/core-consent/src/handlers/grant.handler.ts
Normal file
26
packages/core-consent/src/handlers/grant.handler.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
17
packages/core-consent/src/handlers/is-granted.handler.ts
Normal file
17
packages/core-consent/src/handlers/is-granted.handler.ts
Normal 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) };
|
||||
}
|
||||
28
packages/core-consent/src/handlers/withdraw.handler.test.ts
Normal file
28
packages/core-consent/src/handlers/withdraw.handler.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
18
packages/core-consent/src/handlers/withdraw.handler.ts
Normal file
18
packages/core-consent/src/handlers/withdraw.handler.ts
Normal 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 };
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user