Skip to content

Middleware

Prerequisites

Install Vurb.ts before following this guide: npm install @vurb/core @modelcontextprotocol/sdk zod — or scaffold a project with vurb create.

TELL YOUR AI AGENT
"Add auth middleware that validates JWT, injects tenant context, rate-limits by API key, and logs every tool call to an audit trail."

CROSS-CUTTING CONCERNS
Auth, audit, rate limiting.
Write once, apply everywhere.
Without middleware, you'd duplicate auth checks in every handler. Middleware extracts concerns into reusable, composable functions — fully typed context derivation at every step.
WITHOUT MIDDLEWARE
typescript
// ❌ Repeated in EVERY handler
.handle(async (input, ctx) => {
  const session = await checkAuth(ctx.token);
  if (!session) throw new Error('Unauthorized');
  if (!session.isAdmin) throw new Error('Forbidden');
  await ctx.db.auditLogs.create({ ... });
  // ...finally, the actual logic
})
WITH MIDDLEWARE
typescript
// ✅ Defined once, applied everywhere
const requireAuth = f.middleware(async (ctx) => {
  const user = await db.getUser(ctx.token);
  if (!user) throw new Error('Unauthorized');
  return { user, permissions: user.permissions };
});

f.middleware() — Context Derivation

The primary pattern. Create a middleware that derives data and injects it into context — like tRPC's .use():

src/middleware/auth.ts
typescript
import { initVurb } from '@vurb/core';

const f = initVurb<AppContext>();

const requireAuth = f.middleware(async (ctx) => {
  const user = await db.getUser(ctx.token);
  if (!user) throw new Error('Unauthorized');
  return { user, permissions: user.permissions };
});

The returned object merges into ctx via Object.assign. Downstream handlers see ctx.user and ctx.permissions — fully typed, no annotations.

TIP

f.middleware() returns a MiddlewareDefinition. Call .toMiddlewareFn() when passing it to a tool or group that expects a raw MiddlewareFn.

defineMiddleware() — Standalone Packages

Same as f.middleware() but without needing an initVurb() instance — for shared utility packages:

typescript
import { defineMiddleware } from '@vurb/core';

const addTenant = defineMiddleware(async (ctx: { orgId: string }) => {
  const tenant = await db.getTenant(ctx.orgId);
  return { tenant };
});

Use defineMiddleware() when building reusable middleware libraries that don't know the application's context type.

Per-Tool .use() — Inline Chain

Apply middleware to a single tool with .use(). Stack multiple calls for layered derivations:

tools/admin/sensitive.ts
typescript
export const sensitiveTool = f.query('admin.sensitive_data')
  .describe('Access restricted data')
  .use(async ({ ctx, next }) => {
    const session = await checkAuth(ctx.token);
    if (!session) throw new Error('Unauthorized');
    return next({ ...ctx, session });
  })
  .use(async ({ ctx, next }) => {
    if (!ctx.session.permissions.includes('read:sensitive')) {
      throw new Error('Insufficient permissions');
    }
    return next({ ...ctx, canReadSensitive: true });
  })
  .handle(async (input, ctx) => {
    // ctx.session AND ctx.canReadSensitive are both typed
    return ctx.db.sensitiveData.findMany();
  });

Architect's Check

Verify that auth middleware runs BEFORE business logic. If your AI agent stacked .use() calls in the wrong order — permissions check before authentication — the chain will fail silently. Order matters.

Raw MiddlewareFn

For before/after hooks that need to wrap next() directly:

typescript
import { type MiddlewareFn } from '@vurb/core';

const loggingMiddleware: MiddlewareFn<AppContext> = async (ctx, args, next) => {
  console.log(`[${new Date().toISOString()}] Action called`);
  const result = await next();
  console.log(`[${new Date().toISOString()}] Action completed`);
  return result;
};

Call next() to continue to the next middleware or handler. Don't call it to block the request.


Execution Order

Middleware executes in declaration order, outermost first:

01
Global
Registry-level middleware
02
Per-Tool .use()
Tool-specific derivations
03
.handle()
Your business logic

Each step can:

  • Enrich: Return next({ ...ctx, newProp }) to add properties
  • Guard: Throw or return an error to halt execution
  • Observe: Call const result = await next() to run after the handler

Pre-Compilation

Middleware chains are compiled at registration time into a single nested function. No array iteration, no allocation per request:

typescript
// What the compiler produces (conceptual):
const chain = (ctx, args) =>
  loggingMiddleware(ctx, args, () =>
    authMiddleware(ctx, args, () =>
      handler(ctx, args)
    )
  );

At runtime, handler execution is a Map.get() lookup + one function call. O(1) dispatch.


Common Patterns

Authentication Guard
Block unauthenticated requests with error()
Role Factory
requireRole('admin', 'editor') — parametric guard
Audit Logging
Capture result AFTER handler with await next()
Stacking Derivations
Compose withDatabase + withCurrentUser

Authentication Guard

typescript
const authMiddleware: MiddlewareFn<AppContext> = async (ctx, args, next) => {
  if (!ctx.session?.userId) {
    return error('Authentication required. Missing token.');
  }
  return next();
};

Role Factory

typescript
function requireRole(...roles: string[]): MiddlewareFn<AppContext> {
  return async (ctx, args, next) => {
    if (!roles.includes(ctx.role)) {
      return error(`Forbidden: requires one of [${roles.join(', ')}]`);
    }
    return next();
  };
}

Audit Logging

Capture the result after the handler completes:

typescript
const auditLog: MiddlewareFn<AppContext> = async (ctx, args, next) => {
  const result = await next();
  await ctx.db.auditLogs.create({
    data: {
      userId: ctx.session.userId,
      action: args.action as string,
      timestamp: new Date(),
    },
  });
  return result;
};

Stacking Derivations

typescript
const withDatabase = f.middleware(async (ctx) => {
  const db = await getDbConnection(ctx.tenantId);
  return { db };
});

const withCurrentUser = f.middleware(async (ctx) => {
  const user = await ctx.db.users.findUnique({ where: { id: ctx.userId } });
  return { user, isAdmin: user?.role === 'admin' };
});

NOTE

resolveMiddleware(mw) accepts either MiddlewareFn or MiddlewareDefinition and returns a MiddlewareFn. Useful for accepting middleware from external packages.


Next Steps