Skip to content

Middleware

CruzJS provides a layered middleware system that handles session validation, organization context, and permission checks. Middleware runs in tRPC procedures automatically (via procedure types) and can also be used in React Router loaders and actions.

Request
|
v
Session middleware ---- validates JWT, attaches ctx.session
|
v
Org context middleware - reads X-Organization-ID header, loads membership
|
v
Permission middleware -- checks role-based access for the operation
|
v
Your procedure/loader

In tRPC, protectedProcedure runs the session middleware, and orgProcedure runs both session and org context middleware. Permission checks are called explicitly inside procedures.

The session middleware extracts a token from the Authorization: Bearer <token> header or a session cookie, validates it against the SessionService, and returns the authenticated user context.

protectedProcedure and orgProcedure enforce authentication automatically. If no valid session exists, they throw an UNAUTHORIZED error before your code runs:

import { router, protectedProcedure } from '@cruzjs/core/trpc/context';
export const profileRouter = router({
get: protectedProcedure.query(async ({ ctx }) => {
// ctx.session is guaranteed to exist
const userId = ctx.session.user.id;
// ...
}),
});

For React Router loaders and actions, call requireSession directly:

import type { LoaderFunctionArgs } from 'react-router';
import { handleCruzLoader } from '@cruzjs/core/routing';
import { requireSession } from '@cruzjs/core/shared/middleware/session.middleware';
export const loader = (...args: [LoaderFunctionArgs]) =>
handleCruzLoader(args, async ({ request, container }) => {
const session = await requireSession(request, container);
// session.user.id is available
return { userId: session.user.id };
});

Use getSession when authentication is optional (e.g., a page that shows different content for logged-in users):

import { getSession } from '@cruzjs/core/shared/middleware/session.middleware';
export const loader = (...args: [LoaderFunctionArgs]) =>
handleCruzLoader(args, async ({ request, container }) => {
const session = await getSession(request, container);
// session is null if not authenticated
return { isLoggedIn: !!session };
});
type AuthenticatedRequest = {
user: {
id: string;
};
session: SessionData; // Full session data including token metadata
};

The org context middleware extracts the organization ID from either route params (params.orgId) or the X-Organization-ID header, then loads the user’s membership and role in that organization.

orgProcedure handles this automatically:

import { router, orgProcedure } from '@cruzjs/core/trpc/context';
export const projectRouter = router({
list: orgProcedure.query(async ({ ctx }) => {
// ctx.org is guaranteed to exist
const { orgId, userId, role } = ctx.org;
// ...
}),
});

Call requireOrgContext after establishing a session:

import { requireSession } from '@cruzjs/core/shared/middleware/session.middleware';
import { requireOrgContext } from '@cruzjs/core/shared/middleware/org-context.middleware';
export const loader = (...args: [LoaderFunctionArgs]) =>
handleCruzLoader(args, async ({ request, params, container }) => {
const session = await requireSession(request, container);
const orgContext = await requireOrgContext(request, params, session, container);
// orgContext.org.orgId, orgContext.org.role available
return { role: orgContext.org.role };
});

The middleware checks two sources in order:

  1. Route paramsparams.orgId (for routes like /api/orgs/:orgId/...)
  2. HeaderX-Organization-ID (set automatically by the client-side OrgContextBridge)
type AuthenticatedOrgRequest = AuthenticatedRequest & {
org: {
orgId: string; // Organization ID
userId: string; // Current user ID
role: 'OWNER' | 'ADMIN' | 'MEMBER' | 'VIEWER'; // User's role
};
};
  1. An org ID is present (from params or header)
  2. The organization exists and is not soft-deleted
  3. The user is a member of the organization
  4. The user’s role is loaded

If any check fails, the middleware throws an appropriate HTTP error (400, 403, or 404).

Permission middleware checks whether the user’s role grants access to a specific operation. Call it explicitly inside your procedures after the org context is established.

Check a single permission:

import { requirePermission } from '@cruzjs/core/shared/middleware/permission.middleware';
create: orgProcedure
.input(createProjectSchema)
.mutation(async ({ ctx, input }) => {
await requirePermission(ctx, 'project:write');
// User has write access, proceed
}),

Check that the user has at least one of the listed permissions (OR logic):

import { requireAnyPermission } from '@cruzjs/core/shared/middleware/permission.middleware';
export: orgProcedure.query(async ({ ctx }) => {
await requireAnyPermission(ctx, ['report:read', 'admin:read']);
// User has either report:read or admin:read
}),

Check that the user has every listed permission (AND logic):

import { requireAllPermissions } from '@cruzjs/core/shared/middleware/permission.middleware';
dangerousAction: orgProcedure.mutation(async ({ ctx }) => {
await requireAllPermissions(ctx, ['admin:write', 'billing:write']);
// User has both permissions
}),

Permissions follow the pattern <resource>:<action>:

PermissionDescription
project:readView projects
project:writeCreate and update projects
project:deleteDelete projects
org:readView organization details
org:writeUpdate organization settings
org:deleteDelete the organization
member:readView members
member:writeAdd/update members
billing:readView billing info
billing:writeUpdate billing
Permission patternOWNERADMINMEMBERVIEWER
*:readYesYesYesYes
*:writeYesYesYesNo
*:deleteYesYesNoNo
org:writeYesYesNoNo
org:deleteYesNoNoNo
billing:readYesYesNoNo
billing:writeYesNoNoNo

handleCruzLoader and handleCruzAction wrap your loader/action functions to handle bootstrapping and error processing:

import { handleCruzLoader, handleCruzAction } from '@cruzjs/core/routing';
export const loader = (...args: [LoaderFunctionArgs]) =>
handleCruzLoader(args, async ({ request, params, container }) => {
// container is the DI container, ready to use
// Any thrown errors are caught and logged by middleware processors
return { data: 'hello' };
});
export const action = (...args: [ActionFunctionArgs]) =>
handleCruzAction(args, async ({ request, container }) => {
const formData = await request.formData();
// Process form...
return { success: true };
});

Both wrappers accept an optional third argument:

handleCruzLoader(args, handler, {
// Status codes that should not trigger error logging
allowedStatusCodes: [404],
// Custom middleware processors for error/status handling
processors: [new MyCustomProcessor()],
});

The previous names withLoaderMiddleware and withActionMiddleware are still exported as deprecated aliases. They work identically to handleCruzLoader and handleCruzAction but will be removed in a future release. Update existing code to use the new names.

Extend MiddlewareProcessor to add custom error handling or logging:

import { MiddlewareProcessor } from '@cruzjs/core/routing';
export class SentryMiddleware extends MiddlewareProcessor {
async handleError(error: unknown, request: Request, context: 'loader' | 'action'): Promise<void> {
// Report to Sentry, Datadog, etc.
Sentry.captureException(error, {
tags: { context, url: request.url },
});
}
async handleStatusCode(status: number, request: Request, context: 'loader' | 'action'): Promise<void> {
if (status >= 500) {
Sentry.captureMessage(`HTTP ${status} in ${context}: ${request.url}`);
}
}
}

Use it in your routes:

export const loader = (...args: [LoaderFunctionArgs]) =>
handleCruzLoader(args, handler, {
processors: [new SentryMiddleware()],
});

The middleware execution order within a tRPC request is:

  1. Context creationcreateContext() runs getSession() and getOrgContext() for every request
  2. Procedure middlewareprotectedProcedure asserts ctx.session exists; orgProcedure asserts ctx.org exists
  3. Permission check — Your explicit requirePermission() call inside the procedure
  4. Business logic — Your service method runs