fix(core-consent): build consent router from the shared superjson t

The router previously created its own initTRPC without superjson while
the app router uses the shared transformer-enabled instance — a wire
transformer mismatch that corrupts inputs (audit finding A10). Procedures
now build on @repo/core-shared/trpc/init's t; a real client+fetch-adapter
round-trip test pins Date revival through appRouter-style mounting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 17:46:46 +02:00
parent e2a4657471
commit 8b78563881
4 changed files with 112 additions and 11 deletions

View File

@@ -36,8 +36,10 @@
"@repo/core-testing": "workspace:*", "@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*", "@repo/core-typescript": "workspace:*",
"@testing-library/react": "^16.0.0", "@testing-library/react": "^16.0.0",
"@trpc/client": "^11.18.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@vitest/coverage-v8": "^3.2.7", "@vitest/coverage-v8": "^3.2.7",
"superjson": "^2.2.1",
"jsdom": "^25.0.0", "jsdom": "^25.0.0",
"payload": "^3.14.0", "payload": "^3.14.0",
"react": "^19.0.0", "react": "^19.0.0",

View File

@@ -1,9 +1,14 @@
import { describe, it, expect, beforeEach } from "vitest"; import { describe, it, expect, beforeEach } from "vitest";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { createTRPCClient, httpLink } from "@trpc/client";
import superjson from "superjson";
import { RecordingConsent } from "@repo/core-testing/instrumentation"; import { RecordingConsent } from "@repo/core-testing/instrumentation";
import { router } from "@repo/core-shared/trpc/init";
import { consentRouter } from "@/consent.router"; import { consentRouter } from "@/consent.router";
import type { ConsentRouterContext } from "@/consent.router"; import type { ConsentRouterContext } from "@/consent.router";
import type { IConsent } from "@/consent.interface"; import type { IConsent } from "@/consent.interface";
import { InMemoryConsent } from "@/in-memory-consent";
function makeContext( function makeContext(
consent: RecordingConsent, consent: RecordingConsent,
@@ -155,6 +160,77 @@ describe("consentRouter — auth checks", () => {
}); });
}); });
describe("consentRouter — context guard", () => {
it("throws INTERNAL_SERVER_ERROR when consentFactory is missing from ctx", async () => {
const caller = consentRouter.createCaller({
userId: "user-1",
} as unknown as ConsentRouterContext);
await expect(caller.grant({ category: "analytics" })).rejects.toMatchObject(
{
code: "INTERNAL_SERVER_ERROR",
message: expect.stringContaining("consentFactory missing"),
},
);
});
});
describe("consentRouter — superjson wire round-trip (A10)", () => {
// The consent router MUST be built from the shared `t` (which is created
// with the superjson transformer). This test drives a real tRPC HTTP
// round-trip — client link + fetch adapter — so a transformer mismatch
// between the mounted router and the app client fails loudly here.
function makeClient(ctx: ConsentRouterContext) {
const appLikeRouter = router({ consent: consentRouter });
return createTRPCClient<typeof appLikeRouter>({
links: [
httpLink({
url: "http://localhost/api/trpc",
transformer: superjson,
fetch: (input, init) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req: new Request(input, init as RequestInit),
router: appLikeRouter,
createContext: () => ctx,
}),
}),
],
});
}
it("round-trips grant + getCategories, reviving Date fields", async () => {
const consent = new InMemoryConsent();
const client = makeClient({
userId: "user-1",
consentFactory: async () => consent,
});
const grantRes = await client.consent.grant.mutate({
category: "analytics",
});
expect(grantRes).toEqual({ success: true });
const { categories } = await client.consent.getCategories.query({});
expect(categories).toHaveLength(1);
expect(categories[0]!.category).toBe("analytics");
// superjson revives Dates across the wire; plain JSON would yield a string.
expect(categories[0]!.grantedAt).toBeInstanceOf(Date);
});
it("round-trips isGranted through the wire", async () => {
const consent = new InMemoryConsent();
const client = makeClient({
userId: "user-1",
consentFactory: async () => consent,
});
await client.consent.grant.mutate({ category: "marketing" });
const res = await client.consent.isGranted.query({
category: "marketing",
});
expect(res).toEqual({ granted: true });
});
});
describe("consentRouter — error passthrough", () => { describe("consentRouter — error passthrough", () => {
it("propagates unmapped errors as INTERNAL_SERVER_ERROR", async () => { it("propagates unmapped errors as INTERNAL_SERVER_ERROR", async () => {
const brokenConsent: IConsent = { const brokenConsent: IConsent = {

View File

@@ -1,5 +1,6 @@
import { initTRPC } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { t } from "@repo/core-shared/trpc/init";
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware"; import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
import type { ConsentFactory } from "./di/bind-production"; import type { ConsentFactory } from "./di/bind-production";
@@ -26,41 +27,57 @@ export type ConsentRouterContext = {
consentFactory: ConsentFactory; consentFactory: ConsentFactory;
}; };
const tc = initTRPC.context<ConsentRouterContext>().create(); /**
* Consent procedures build on the SHARED `t` instance from
const consentProcedure = tc.procedure * `@repo/core-shared/trpc/init` (audit finding A10): the app router is created
* with the superjson transformer, and a router built from a private `initTRPC`
* without superjson would corrupt every input/output that crosses the wire.
*
* The shared `t` is context-untyped, so the middleware narrows `ctx` to
* `ConsentRouterContext` at runtime — same cast pattern as the dsr router.
*/
const consentProcedure = t.procedure
.use(defineErrorMiddleware([[UnauthenticatedError, "UNAUTHORIZED"]])) .use(defineErrorMiddleware([[UnauthenticatedError, "UNAUTHORIZED"]]))
.use(async ({ ctx, next }) => { .use(async ({ ctx, next }) => {
if (!ctx.userId) throw new UnauthenticatedError(); const { userId, consentFactory } = ctx as Partial<ConsentRouterContext>;
return next(); if (!userId) throw new UnauthenticatedError();
if (!consentFactory) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
"consentFactory missing from tRPC context — wire the binding from " +
"bindProductionConsent/bindDevSeedConsent into createContext",
});
}
return next({ ctx: { ...ctx, userId, consentFactory } });
}); });
export const consentRouter = tc.router({ export const consentRouter = t.router({
grant: consentProcedure grant: consentProcedure
.input(grantHandlerInputSchema) .input(grantHandlerInputSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const consent = await ctx.consentFactory(ctx.userId!); const consent = await ctx.consentFactory(ctx.userId);
return grantHandler(consent, input); return grantHandler(consent, input);
}), }),
withdraw: consentProcedure withdraw: consentProcedure
.input(withdrawHandlerInputSchema) .input(withdrawHandlerInputSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const consent = await ctx.consentFactory(ctx.userId!); const consent = await ctx.consentFactory(ctx.userId);
return withdrawHandler(consent, input); return withdrawHandler(consent, input);
}), }),
isGranted: consentProcedure isGranted: consentProcedure
.input(isGrantedHandlerInputSchema) .input(isGrantedHandlerInputSchema)
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const consent = await ctx.consentFactory(ctx.userId!); const consent = await ctx.consentFactory(ctx.userId);
return isGrantedHandler(consent, input); return isGrantedHandler(consent, input);
}), }),
getCategories: consentProcedure getCategories: consentProcedure
.input(z.object({}).strict()) .input(z.object({}).strict())
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const consent = await ctx.consentFactory(ctx.userId!); const consent = await ctx.consentFactory(ctx.userId);
return getCategoriesHandler(consent); return getCategoriesHandler(consent);
}), }),
}); });

6
pnpm-lock.yaml generated
View File

@@ -674,6 +674,9 @@ importers:
"@testing-library/react": "@testing-library/react":
specifier: ^16.0.0 specifier: ^16.0.0
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
"@trpc/client":
specifier: ^11.18.0
version: 11.18.0(@trpc/server@11.18.0(typescript@5.9.3))(typescript@5.9.3)
"@types/react": "@types/react":
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.2.14 version: 19.2.14
@@ -689,6 +692,9 @@ importers:
react: react:
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.2.4 version: 19.2.4
superjson:
specifier: ^2.2.1
version: 2.2.6
typescript: typescript:
specifier: ^5.8.0 specifier: ^5.8.0
version: 5.9.3 version: 5.9.3