feat(core-shared): auth-gate mutating feature procedures

Adds a shared requireAuthenticated tRPC middleware (reads the server-
resolved ctx.user from createTrpcContext) and applies it to every
mutating feature procedure — blog.createArticle and media.deleteMedia
were anonymous-callable (audit finding B7). Read-only queries stay
public; features compose <x>ProtectedProcedure from their error-mapped
base procedure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 17:55:27 +02:00
parent 49241845b5
commit d09b3e2cdd
9 changed files with 175 additions and 11 deletions

View File

@@ -16,6 +16,7 @@
"./payload": "./src/payload/index.ts",
"./trpc/init": "./src/trpc/init.ts",
"./trpc/context": "./src/trpc/context.ts",
"./trpc/require-authenticated": "./src/trpc/require-authenticated.ts",
"./trpc/define-error-middleware": "./src/trpc/define-error-middleware.ts",
"./instrumentation": "./src/instrumentation/index.ts",
"./instrumentation/otel": "./src/instrumentation/otel/index.ts",

View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { t } from "@/trpc/init";
import {
requireAuthenticated,
protectedProcedure,
} from "@/trpc/require-authenticated";
const echoRouter = t.router({
publicEcho: t.procedure
.input(z.object({ value: z.string() }).strict())
.query(({ input }) => input.value),
protectedEcho: protectedProcedure
.input(z.object({ value: z.string() }).strict())
.mutation(({ input, ctx }) => ({
value: input.value,
userId: (ctx as { user: { id: string } }).user.id,
})),
composedEcho: t.procedure
.use(requireAuthenticated)
.input(z.object({}).strict())
.mutation(() => "ok"),
});
describe("requireAuthenticated middleware (B7)", () => {
it("rejects anonymous callers with UNAUTHORIZED", async () => {
const caller = echoRouter.createCaller({});
await expect(caller.protectedEcho({ value: "x" })).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
await expect(caller.composedEcho({})).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
});
it("passes through authenticated callers and exposes ctx.user", async () => {
const caller = echoRouter.createCaller({
user: { id: "user-1", roles: [] },
});
await expect(caller.protectedEcho({ value: "x" })).resolves.toEqual({
value: "x",
userId: "user-1",
});
});
it("leaves public procedures untouched", async () => {
const caller = echoRouter.createCaller({});
await expect(caller.publicEcho({ value: "hi" })).resolves.toBe("hi");
});
});

View File

@@ -0,0 +1,32 @@
import { TRPCError } from "@trpc/server";
import { t } from "./init";
import type { TrpcSessionUser } from "./context";
/**
* Shared authentication guard for MUTATING tRPC procedures (audit finding
* B7). Reads the server-resolved `ctx.user` (attached by `createTrpcContext`
* via the app's `resolveUser`) and rejects anonymous callers with
* UNAUTHORIZED. Read-only queries stay public; each feature opts its
* mutations in by composing this middleware into its procedure chain:
*
* ```ts
* export const blogProtectedProcedure = blogProcedure.use(requireAuthenticated);
* ```
*
* The shared `t` is context-untyped, so the middleware narrows at runtime
* (same cast pattern as the dsr/audit routers) and re-publishes `user` into
* the downstream ctx with a non-optional type.
*/
export const requireAuthenticated = t.middleware(({ ctx, next }) => {
const user = (ctx as { user?: TrpcSessionUser }).user;
if (!user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Authentication required",
});
}
return next({ ctx: { ...ctx, user } });
});
/** Convenience base procedure for apps composing ad-hoc protected routes. */
export const protectedProcedure = t.procedure.use(requireAuthenticated);