Skip to content

Post-Middleware Checks

Some authorization checks need both the validated input AND data that middleware loaded onto the context. Resource ownership is the canonical case: a user is allowed to delete their own comment, so the check needs the comment’s authorId (loaded by middleware) and the comment’s id (from input).

.guard() runs before input validation and only sees ctx. .policy() runs before middleware. Neither fits the “middleware loads the resource, then we check ownership against input + ctx” pattern.

.check() is the missing primitive: it runs after input validation, after .through() pipeline transforms, and after every .use() middleware has populated the context — immediately before the handler.

import { authenticated } from '@veloxts/auth';
procedure()
.input(z.object({ id: z.string() }))
.guard(authenticated) // pre-input, ctx-only — fast-fails 401
.use(loadComment) // adds ctx.comment from input.id
.check(({ ctx }) => // sees ctx.comment AND input
ctx.comment.authorId === ctx.user.id
)
.mutation(async ({ input, ctx }) => {
return ctx.db.comment.delete({ where: { id: input.id } });
});
PrimitiveRunsSeesUse for
.guard(authenticated)Before inputctxAuthentication, role checks (fast-fail)
.policy(PostPolicy.update)After input, before middlewarectx (pre-middleware) + inputDeclarative resource policies (Laravel-style)
.check(({ input, ctx }) => ...)After middlewarectx (post-middleware) + inputAd-hoc ownership / participation checks

Returning false throws ForbiddenError (403):

.check(({ input, ctx }) => ctx.user.id === input.userId)
// → 403 Forbidden if the user IDs don't match

Throwing inside a check propagates as-is, so you can throw custom errors with finer-grained status codes:

import { UnauthorizedError } from '@veloxts/core';
.check(({ ctx }) => {
if (ctx.user.suspended) throw new UnauthorizedError('Account suspended');
return true;
})

.check() calls AND-compose in declaration order with short-circuit on first failure:

procedure()
.check(isOwnerOrEditor) // 1st — runs first
.check(notLockedByModeration) // 2nd — only runs if 1st passes
.check(withinDailyQuota) // 3rd — only runs if 2nd passes

If any check returns false or throws, later checks aren’t called and the handler doesn’t run.

Place .check() after .use() (so middleware has populated ctx) and after .input() (so the schema has parsed the body), but before .query() / .mutation():

procedure()
.input(...) // 1. parse + validate input
.guard(...) // 2. ctx-only authentication
.resource(...) // 3. declare resource projection
.use(...) // 4. middleware extends ctx
.rest(...) // 5. (optional) override REST route
.check(({ input, ctx }) => ...) // 6. post-middleware authorization
.query(handler); // 7. handler

Practical example: retro session participants

Section titled “Practical example: retro session participants”

A retrospective tool where participants can only act on sessions they joined. The session is loaded from input.sessionId by middleware; .check() then verifies the authenticated user is a participant.

import { authenticated } from '@veloxts/auth';
import { procedure, procedures } from '@veloxts/router';
const loadSession = async ({ input, ctx, next }) => {
const session = await ctx.db.retroSession.findUniqueOrThrow({
where: { id: input.sessionId },
include: { participants: true },
});
return next({ ctx: { session } });
};
const isParticipant = ({ ctx }) =>
ctx.session.participants.some((p) => p.userId === ctx.user.id);
export const cardProcedures = procedures('cards', {
addCard: procedure()
.input(z.object({ sessionId: z.string(), content: z.string() }))
.guard(authenticated)
.use(loadSession)
.check(isParticipant)
.mutation(async ({ input, ctx }) => {
return ctx.db.card.create({
data: { sessionId: input.sessionId, content: input.content, authorId: ctx.user.id },
});
}),
});

Without .check(), the participant validation would have to live as .use() middleware that throws — readable, but it conflates two concerns (extending context vs. authorizing) and makes the intent harder to scan.

Checks are predicate functions, so they’re easy to unit-test in isolation:

import { isParticipant } from './checks';
test('rejects non-participants', () => {
const ctx = {
session: { participants: [{ userId: 'u1' }] },
user: { id: 'u2' },
};
expect(isParticipant({ input: {}, ctx })).toBe(false);
});

For end-to-end testing, drive the procedure with executeProcedure(proc, input, ctx) from @veloxts/router and assert the resulting ForbiddenError or successful handler invocation.