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

@@ -36,6 +36,29 @@ describe("mediaRouter", () => {
});
});
describe("mediaRouter authorization (B7)", () => {
beforeEach(() => {
mediaContainer.unbindAll();
mediaContainer.load(MediaModule);
});
afterEach(() => {
mediaContainer.unbindAll();
});
it("deleteMedia rejects anonymous callers with UNAUTHORIZED", async () => {
const caller = mediaRouter.createCaller({});
await expect(caller.deleteMedia({ id: "some-id" })).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
});
it("read-only queries stay public", async () => {
const caller = mediaRouter.createCaller({});
await expect(caller.listMedia({})).resolves.toEqual([]);
});
});
describe("mediaRouter error mapping", () => {
beforeEach(() => {
mediaContainer.unbindAll();
@@ -69,7 +92,8 @@ describe("mediaRouter error mapping", () => {
});
it("translates MediaNotFoundError → NOT_FOUND on deleteMedia with missing id", async () => {
const caller = mediaRouter.createCaller({});
// Mutations are auth-gated (B7) — provide a server-resolved ctx.user.
const caller = mediaRouter.createCaller({ user: { id: "u1", roles: [] } });
try {
await caller.deleteMedia({ id: "nonexistent-id" });
throw new Error("expected throw");