Skip to content

omar-dulaimi/trpc-shield

Β 
Β 

Repository files navigation

tRPC Shield

NPM Version NPM Downloads GitHub Stars License Test Coverage

tRPC Shield Logo

πŸ›‘οΈ tRPC Shield

Powerful permission layer for tRPC applications
Create secure, type-safe APIs with intuitive rule-based authorization

Quick Start β€’ Documentation β€’ Examples β€’ Contributing

πŸ’– Support This Project

If this tool helps you build better applications, please consider supporting its development:

GitHub Sponsors

Your sponsorship helps maintain and improve this project. Thank you! πŸ™

πŸ†• Latest Version

Get the latest stable version with full tRPC v11 support!

npm install trpc-shield

This version includes tRPC v11.x compatibility and context extension support - bringing full compatibility with the latest tRPC features. For specific version requirements, see the compatibility table below.

✨ Features

  • πŸ”’ Rule-based permissions - Define authorization logic with intuitive, composable rules
  • πŸš€ tRPC v11 support - Full compatibility with the latest tRPC features
  • πŸ”„ Context extension - Rules can extend context with authentication data
  • 🧩 Logic operators - Combine rules with and, or, not, chain, and race
  • πŸ›‘οΈ Secure by default - Prevents data leaks with fallback rules
  • πŸ“ TypeScript first - Full type safety and IntelliSense support
  • 🎯 Zero dependencies - Lightweight and fast
  • πŸ§ͺ Well tested - Comprehensive test coverage

πŸš€ Quick Start

Installation

# npm
npm install trpc-shield

# yarn
yarn add trpc-shield

# pnpm
pnpm add trpc-shield

Basic Example

import { initTRPC } from '@trpc/server';
import { rule, shield, and, or, not } from 'trpc-shield';

type Context = {
  user?: { id: string; role: string; name: string };
  token?: string;
};

// Create rules
const isAuthenticated = rule<Context>()(async (ctx) => {
  return ctx.user !== null;
});

const isAdmin = rule<Context>()(async (ctx) => {
  return ctx.user?.role === 'admin';
});

// Create permissions
const permissions = shield<Context>({
  query: {
    publicData: true, // Always allow
    profile: isAuthenticated,
    adminData: and(isAuthenticated, isAdmin),
  },
  mutation: {
    updateProfile: isAuthenticated,
    deleteUser: and(isAuthenticated, isAdmin),
  },
});

// Apply to tRPC
const t = initTRPC.context<Context>().create();
const middleware = t.middleware(permissions);
const protectedProcedure = t.procedure.use(middleware);

πŸ“‹ Version Compatibility

tRPC Version Shield Version Status
v11.x v1.0.0+ βœ… Recommended
v10.x v0.2.0 - v0.4.x ⚠️ Legacy
v9.x v0.1.2 and below ❌ Deprecated

πŸ†• What's New in Latest Version

  • tRPC v11 Support - Full compatibility with latest tRPC features
  • Context Extension - Rules can now extend context (see Context Extension)
  • Improved TypeScript - Better type inference and safety
  • Performance Optimizations - Faster rule evaluation
  • Enhanced Testing - Comprehensive test coverage

πŸ”§ Core Concepts

Rules

Rules are the building blocks of your permission system. Each rule is an async function that returns:

  • true - Allow access
  • false - Deny access
  • Error - Deny with custom error
  • { ctx: {...} } - Allow and extend context
const isOwner = rule<Context>()(async (ctx, type, path, input) => {
  const resourceId = input.id;
  const resource = await getResource(resourceId);
  
  if (resource.ownerId !== ctx.user?.id) {
    return new Error('You can only access your own resources');
  }
  
  return true;
});

Logic Operators

Combine rules with powerful logic operators:

const permissions = shield<Context>({
  query: {
    // All rules must pass
    sensitiveData: and(isAuthenticated, isAdmin, isEmailVerified),
    
    // At least one rule must pass
    moderatedContent: or(isAdmin, isModerator),
    
    // Rule must fail
    publicEndpoint: not(isInternalRequest),
    
    // Execute rules in sequence until one passes
    content: race(isOwner, isCollaborator, isPublicAccess),
    
    // Execute rules in sequence, all must pass
    secureAction: chain(isAuthenticated, isEmailVerified, hasPermission),
  },
});

πŸ”„ Context Extension

New in v1.0.0 - Rules can extend the tRPC context

Rules can return an object with a ctx property to extend the context for subsequent middleware and procedures:

const withAuth = rule<Context>()(async (ctx) => {
  // If user is already in context, just validate
  if (ctx.user) {
    return true;
  }
  
  // If we have a token, validate and extend context
  if (ctx.token) {
    try {
      const user = await validateToken(ctx.token);
      // Extend context with user data
      return { ctx: { user } };
    } catch {
      return new Error('Invalid token');
    }
  }
  
  return false;
});

// Usage
const authenticatedProcedure = t.procedure
  .use(shield({ query: { profile: withAuth } }))
  .query(({ ctx }) => {
    // ctx.user is now available and properly typed!
    return { message: `Hello ${ctx.user.name}!` };
  });

πŸ“š Advanced Usage

Namespaced Routers

Organize permissions for complex router structures:

const permissions = shield<Context>({
  // Nested router permissions
  user: {
    query: {
      profile: isAuthenticated,
      list: and(isAuthenticated, isAdmin),
    },
    mutation: {
      update: isOwner,
      delete: and(isAuthenticated, or(isOwner, isAdmin)),
    },
  },
  
  // Another namespace
  posts: {
    query: {
      public: true,
      drafts: isOwner,
    },
    mutation: {
      create: isAuthenticated,
      publish: and(isOwner, hasPublishPermission),
    },
  },
});

Configuration Options

Customize shield behavior:

const permissions = shield<Context>(
  {
    query: {
      data: isAuthenticated,
    },
  },
  {
    // Allow external errors to be thrown (default: false)
    allowExternalErrors: true,
    
    // Enable debug mode for development
    debug: process.env.NODE_ENV === 'development',
    
    // Default rule for undefined paths (default: allow)
    fallbackRule: deny,
    
    // Custom error message or Error instance
    fallbackError: 'Access denied',
    // or
    fallbackError: new CustomError('Insufficient permissions'),
  }
);

Error Handling

const permissions = shield<Context>({
  mutation: {
    deletePost: rule<Context>()(async (ctx, type, path, input) => {
      const post = await getPost(input.id);
      
      if (!post) {
        return new Error('Post not found');
      }
      
      if (post.authorId !== ctx.user?.id && ctx.user?.role !== 'admin') {
        return new Error('You can only delete your own posts');
      }
      
      return true;
    }),
  },
});

🎯 Examples

Complete Authentication Flow

import { initTRPC, TRPCError } from '@trpc/server';
import { shield, rule, and, or, not } from 'trpc-shield';
import jwt from 'jsonwebtoken';

type User = {
  id: string;
  email: string;
  role: 'user' | 'admin' | 'moderator';
  emailVerified: boolean;
};

type Context = {
  user?: User;
  token?: string;
};

// Authentication rule with context extension
const authenticate = rule<Context>()(async (ctx) => {
  if (ctx.user) return true;
  
  if (!ctx.token) {
    return new Error('Authentication required');
  }
  
  try {
    const payload = jwt.verify(ctx.token, process.env.JWT_SECRET!) as any;
    const user = await getUserById(payload.userId);
    
    if (!user) {
      return new Error('User not found');
    }
    
    // Extend context with user
    return { ctx: { user } };
  } catch {
    return new Error('Invalid token');
  }
});

// Authorization rules
const isAdmin = rule<Context>()(async (ctx) => ctx.user?.role === 'admin');
const isModerator = rule<Context>()(async (ctx) => ctx.user?.role === 'moderator');
const isEmailVerified = rule<Context>()(async (ctx) => ctx.user?.emailVerified === true);

// Permission definitions
const permissions = shield<Context>({
  query: {
    // Public endpoints
    publicPosts: true,
    healthCheck: true,
    
    // Authenticated endpoints
    profile: authenticate,
    notifications: and(authenticate, isEmailVerified),
    
    // Admin endpoints
    userList: and(authenticate, isAdmin),
    analytics: and(authenticate, or(isAdmin, isModerator)),
  },
  
  mutation: {
    // Public mutations
    register: not(authenticate), // Only unauthenticated users
    login: not(authenticate),
    
    // Authenticated mutations
    updateProfile: and(authenticate, isEmailVerified),
    createPost: authenticate,
    
    // Admin mutations
    deleteUser: and(authenticate, isAdmin),
    banUser: and(authenticate, or(isAdmin, isModerator)),
  },
});

// tRPC setup
const t = initTRPC.context<Context>().create();

export const middleware = t.middleware(permissions);
export const protectedProcedure = t.procedure.use(middleware);

// Usage in router
export const appRouter = t.router({
  profile: protectedProcedure
    .query(({ ctx }) => {
      // ctx.user is guaranteed to exist and be typed correctly
      return {
        id: ctx.user.id,
        email: ctx.user.email,
        role: ctx.user.role,
      };
    }),
    
  updateProfile: protectedProcedure
    .input(z.object({ name: z.string() }))
    .mutation(async ({ ctx, input }) => {
      // User is authenticated and email verified
      return await updateUser(ctx.user.id, { name: input.name });
    }),
});

Resource-Based Permissions

const isResourceOwner = (resourceType: string) => 
  rule<Context>(`isOwnerOf${resourceType}`)(async (ctx, type, path, input) => {
    const resource = await getResource(resourceType, input.id);
    return resource.ownerId === ctx.user?.id;
  });

const permissions = shield<Context>({
  mutation: {
    updatePost: and(authenticate, isResourceOwner('post')),
    deleteComment: and(authenticate, or(
      isResourceOwner('comment'),
      isResourceOwner('post'), // Post owner can delete comments
      isAdmin
    )),
  },
});

πŸ§ͺ Testing

tRPC Shield is extensively tested with comprehensive coverage. Test your rules in isolation:

import { describe, it, expect } from 'vitest';

describe('Authentication Rules', () => {
  it('should allow authenticated users', async () => {
    const ctx = { user: { id: '1', role: 'user' } };
    const result = await isAuthenticated.resolve(ctx, 'query', 'profile', {}, {}, {});
    expect(result).toBe(true);
  });
  
  it('should extend context with user data', async () => {
    const ctx = { token: 'valid-jwt-token' };
    const result = await authenticate.resolve(ctx, 'query', 'profile', {}, {}, {});
    expect(result).toEqual({ ctx: { user: expect.any(Object) } });
  });
});

πŸ”’ Security Best Practices

  1. Use deny as fallback for sensitive applications:

    shield(permissions, { fallbackRule: deny })
  2. Validate input in rules:

    const isOwner = rule<Context>()(async (ctx, type, path, input) => {
      if (!input?.id) return new Error('Resource ID required');
      // ... rest of logic
    });
  3. Don't expose sensitive errors in production:

    shield(permissions, { 
      allowExternalErrors: process.env.NODE_ENV === 'development' 
    })
  4. Use specific error messages for better UX:

    const hasPermission = rule<Context>()(async (ctx) => {
      if (!ctx.user) return new Error('Please log in to continue');
      if (!ctx.user.emailVerified) return new Error('Please verify your email');
      return true;
    });

πŸ“– API Reference

shield(permissions, options?)

Creates a tRPC middleware from your permission rules.

Parameters:

  • permissions - Object defining rules for queries and mutations
  • options - Configuration object

Options:

Option Type Default Description
allowExternalErrors boolean false Allow custom errors to bubble up
debug boolean false Enable debug logging
fallbackRule Rule allow Default rule for undefined paths
fallbackError string | Error "Not Authorised!" Default error message

rule(name?)(fn)

Creates a permission rule.

Parameters:

  • name - Optional rule name for debugging
  • fn - Rule function (ctx, type, path, input, rawInput, options) => boolean | Error | {ctx: object}

Logic Operators

  • and(...rules) - All rules must pass
  • or(...rules) - At least one rule must pass
  • not(rule, error?) - Rule must fail
  • chain(...rules) - Execute rules sequentially, all must pass
  • race(...rules) - Execute rules sequentially until one passes

Built-in Rules

  • allow - Always allows access
  • deny - Always denies access

🀝 Contributing

We welcome contributions! Please see our Contributing Guide for details.

Development Setup

git clone https://github.com/omar-dulaimi/trpc-shield.git
cd trpc-shield
npm install
npm run build
npm test

πŸ“„ License

This project is licensed under the MIT License - see the LICENSE file for details.

πŸ™ Acknowledgments


Made with ❀️ by the tRPC Shield team

⭐ Star us on GitHub β€’ πŸ› Report Issues β€’ πŸ’¬ Discussions

About

πŸ›‘ A tRPC tool to ease the creation of permission layer.

Topics

Resources

License

Stars

Watchers

Forks

Sponsor this project

 

Packages

No packages published

Languages

  • TypeScript 96.8%
  • JavaScript 2.4%
  • Shell 0.8%